Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_text/src/doc/markdown/write.rs

9.3 KiB, 1 run

created by r1870400018:22712, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Writing the document tree back out as Markdown.
2//!
3//! The counterpart to [`parse`](crate::doc::markdown::parse), and the output a *model* wants. HTML is
4//! for a browser and this is for a reader that thinks in prose: a language model handed the text of a
5//! Word document reads `## The second heading` and knows what it is, where it would have to be told
6//! what `<h2>` means, and pays for the telling in every request.
7//!
8//! # What it is faithful to
9//!
10//! The tree, not the source. Markdown read and written back is not the bytes it was -- `_emphasis_`
11//! comes back as `*emphasis*`, a setext heading comes back as an ATX one, and the amount of
12//! whitespace is this writer's own. What survives is what the tree carries, which is what a document
13//! *says*. Anything that needs the original bytes back wants [`crate::xml`] and its spans, not this.
14//!
15//! # Escaping is deliberately light
16//!
17//! Enough that a round trip holds: the characters that would start a construct where they stand, and
18//! no others. Escaping every asterisk in a document would make prose unreadable to the reader this
19//! exists for, which defeats the point of choosing Markdown over HTML.
20
21use oxedyne_fe2o3_core::prelude::*;
22
23use crate::doc::{
24 Align,
25 Block,
26 Cell,
27 Doc,
28 Inline,
29 Row,
30};
31
32/// Renders a document as Markdown.
33pub fn render(doc: &Doc) -> String {
34 let mut out = String::new();
35 blocks(&mut out, &doc.blocks);
36 // One trailing newline, however the last block ended.
37 while out.ends_with("\n\n") {
38 out.pop();
39 }
40 if !out.is_empty() && !out.ends_with('\n') {
41 out.push('\n');
42 }
43 out
44}
45
46/// Writes a run of blocks, a blank line between each.
47///
48/// With one exception: a list straight after a paragraph gets no blank line, which is what makes a
49/// list item holding a nested list read as one item rather than as two lists with a gap.
50fn blocks(out: &mut String, blocks: &[Block]) {
51 for (i, block) in blocks.iter().enumerate() {
52 let tight = matches!(
53 (blocks.get(i.wrapping_sub(1)), block),
54 (Some(Block::Para(_)), Block::List { .. }),
55 );
56 if i > 0 && !tight {
57 out.push('\n');
58 }
59 one(out, block);
60 }
61}
62
63/// Writes one block.
64fn one(out: &mut String, block: &Block) {
65 match block {
66 Block::Heading { level, content } => {
67 for _ in 0..(*level).clamp(1, 6) {
68 out.push('#');
69 }
70 out.push(' ');
71 inlines(out, content, false);
72 out.push('\n');
73 }
74 Block::Para(content) => {
75 inlines(out, content, true);
76 out.push('\n');
77 }
78 Block::Code { lang, text } => {
79 // A fence longer than any run of backticks inside, or a listing about Markdown closes
80 // its own fence three characters in.
81 let n = longest_run(text, '`').max(2) + 1;
82 let fence: String = "`".repeat(n);
83 out.push_str(&fence);
84 if let Some(lang) = lang {
85 out.push_str(lang);
86 }
87 out.push('\n');
88 out.push_str(text);
89 if !text.ends_with('\n') {
90 out.push('\n');
91 }
92 out.push_str(&fence);
93 out.push('\n');
94 }
95 Block::Quote(inner) => {
96 let mut body = String::new();
97 blocks(&mut body, inner);
98 for line in body.lines() {
99 match line.is_empty() {
100 true => out.push_str(">\n"),
101 false => {
102 out.push_str("> ");
103 out.push_str(line);
104 out.push('\n');
105 }
106 }
107 }
108 }
109 Block::List { ordered, items } => {
110 for (n, item) in items.iter().enumerate() {
111 let marker = match ordered {
112 true => fmt!("{}. ", n + 1),
113 false => "- ".to_string(),
114 };
115 // An item's content is written as though it stood alone, and the indent it needs is
116 // added here. Nesting is therefore the sum of the markers above it, which is what
117 // lines a nested list up under its parent's text rather than under its bullet -- and
118 // what an item does NOT need is a second helping of the depth it is already inside.
119 let mut body = String::new();
120 blocks(&mut body, item);
121 let pad = " ".repeat(marker.chars().count());
122 for (k, line) in body.lines().enumerate() {
123 match (k, line.is_empty()) {
124 (_, true) => out.push('\n'),
125 (0, false) => {
126 out.push_str(&marker);
127 out.push_str(line);
128 out.push('\n');
129 }
130 (_, false) => {
131 out.push_str(&pad);
132 out.push_str(line);
133 out.push('\n');
134 }
135 }
136 }
137 }
138 }
139 Block::Rule => out.push_str("---\n"),
140 Block::Table { head, rows, cols } => table(out, head, rows, cols),
141 // A division names a region and Markdown has no syntax for one. Its content stands where it
142 // stood, which is what the HTML writer does with an attribute-less division too.
143 Block::Div { content, .. } => blocks(out, content),
144 }
145}
146
147/// Writes a table as a pipe table.
148fn table(out: &mut String, head: &Option<Row>, rows: &[Row], cols: &[Align]) {
149 let n = head.iter().chain(rows).map(|r| r.0.len()).max().unwrap_or(0).max(cols.len());
150 if n == 0 {
151 return;
152 }
153 // A pipe table has to have a header. A table that carried none gets an empty one, because the
154 // alternative is a body that reads as prose.
155 let empty = Row::default();
156 let head = head.as_ref().unwrap_or(&empty);
157 row(out, head, n);
158 out.push('|');
159 for i in 0..n {
160 let bar = match cols.get(i).copied().unwrap_or(Align::None) {
161 Align::None => " --- ",
162 Align::Start => " :-- ",
163 Align::Centre => " :-: ",
164 Align::End => " --: ",
165 };
166 out.push_str(bar);
167 out.push('|');
168 }
169 out.push('\n');
170 for r in rows {
171 row(out, r, n);
172 }
173}
174
175/// Writes one row of a table, padded to the width of the widest.
176fn row(out: &mut String, r: &Row, n: usize) {
177 let empty = Cell::default();
178 out.push('|');
179 for i in 0..n {
180 out.push(' ');
181 let mut cell = String::new();
182 // A cell is never the start of a line, whatever it looks like: `3.40` in a cell opens no
183 // ordered list, and escaping it there would put a backslash in front of every price in the
184 // document.
185 inlines(&mut cell, &r.0.get(i).unwrap_or(&empty).0, false);
186 // A pipe inside a cell would end it.
187 out.push_str(&cell.replace('|', "\\|"));
188 out.push(' ');
189 out.push('|');
190 }
191 out.push('\n');
192}
193
194/// Writes a run of inline content.
195///
196/// `start` says whether what follows begins a line, which is the whole of what decides an escape: a
197/// `-` or a `1.` opens a block where a line begins and is punctuation everywhere else. A writer that
198/// did not track it would escape every hyphen in the document, or none of the ones that matter.
199fn inlines(out: &mut String, content: &[Inline], start: bool) {
200 let mut start = start;
201 for item in content {
202 match item {
203 Inline::Text(text) => {
204 out.push_str(&escape(text, start));
205 start = text.ends_with('\n');
206 }
207 Inline::Emph { strong, content } => {
208 let mark = match strong {
209 true => "**",
210 false => "*",
211 };
212 out.push_str(mark);
213 inlines(out, content, false);
214 out.push_str(mark);
215 start = false;
216 }
217 Inline::Link { to, content } => {
218 out.push('[');
219 inlines(out, content, false);
220 out.push_str("](");
221 out.push_str(to);
222 out.push(')');
223 start = false;
224 }
225 Inline::Image { src, alt } => {
226 out.push_str("![");
227 out.push_str(&escape(alt, false));
228 out.push_str("](");
229 out.push_str(src);
230 out.push(')');
231 start = false;
232 }
233 Inline::Code(code) => {
234 let n = longest_run(code, '`') + 1;
235 let fence: String = "`".repeat(n);
236 out.push_str(&fence);
237 // A span that begins or ends with a backtick needs a space, which the reader eats.
238 if code.starts_with('`') || code.ends_with('`') {
239 out.push(' ');
240 out.push_str(code);
241 out.push(' ');
242 } else {
243 out.push_str(code);
244 }
245 out.push_str(&fence);
246 start = false;
247 }
248 // A span names a region and Markdown has no syntax for one.
249 Inline::Span { content, .. } => {
250 inlines(out, content, start);
251 start = false;
252 }
253 // Two spaces then a newline: the only hard break Markdown has that survives a reader that
254 // treats a lone newline as a space, which is what this crate's own reader does.
255 Inline::Break => {
256 out.push_str(" \n");
257 start = true;
258 }
259 }
260 }
261}
262
263/// The text with the characters that would start a construct where they stand escaped.
264///
265/// Light on purpose -- see the module's own note. A `*` between two letters starts nothing and is left
266/// alone; one that could open emphasis is escaped.
267fn escape(text: &str, start: bool) -> String {
268 let mut out = String::with_capacity(text.len());
269 let b = text.as_bytes();
270 for (i, c) in text.char_indices() {
271 let at_start = match i {
272 0 => start,
273 _ => out.ends_with('\n'),
274 };
275 match c {
276 '\\' | '`' | '*' | '_' | '[' | ']' => {
277 out.push('\\');
278 out.push(c);
279 }
280 // These open a block only at the start of a line.
281 '#' | '>' | '-' | '+' if at_start => {
282 out.push('\\');
283 out.push(c);
284 }
285 // A digit followed by a full stop opens an ordered list, at the start of a line.
286 '.' if start && at_start_number(b, i) => out.push_str("\\."),
287 _ => out.push(c),
288 }
289 }
290 out
291}
292
293/// Whether a full stop at this offset closes a run of digits that begins its line, which is what would
294/// open an ordered list.
295fn at_start_number(b: &[u8], i: usize) -> bool {
296 let mut k = i;
297 while k > 0 && b[k - 1].is_ascii_digit() {
298 k -= 1;
299 }
300 k < i && (k == 0 || b[k - 1] == b'\n')
301}
302
303/// The longest unbroken run of a character in a string.
304fn longest_run(s: &str, c: char) -> usize {
305 let mut best = 0;
306 let mut run = 0;
307 for k in s.chars() {
308 match k == c {
309 true => {
310 run += 1;
311 best = best.max(run);
312 }
313 false => run = 0,
314 }
315 }
316 best
317}