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 | |
| 21 | use oxedyne_fe2o3_core::prelude::*; |
| 22 | |
| 23 | use crate::doc::{ |
| 24 | Align, |
| 25 | Block, |
| 26 | Cell, |
| 27 | Doc, |
| 28 | Inline, |
| 29 | Row, |
| 30 | }; |
| 31 | |
| 32 | /// Renders a document as Markdown. |
| 33 | pub 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. |
| 50 | fn 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. |
| 64 | fn 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. |
| 148 | fn 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. |
| 176 | fn 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. |
| 199 | fn 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("; |
| 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. |
| 267 | fn 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. |
| 295 | fn 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. |
| 304 | fn 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 | } |