Skip to main content

max / docengine

Carry the block a run came from, not only the marks over it render_runs said what was painted on the words and not what the words were, so a heading arrived at the weight of the prose around it. That was written off as a limit of the medium; it is not one. Weight is a thing a terminal cell has, and a bullet is a thing a renderer can draw. TextRun gains a Block: Prose, Heading(level), Item or Quote. An item carries no marker of its own, because what a bullet looks like is the caller's answer and a number would be wrong for half of them. The innermost block wins and nesting is not carried, but it is tracked: the stack is what puts the quote back after an item inside it closes. Breaking, TextRun grew a public field: 0.6.0 -> 0.7.0.
Author: Max Johnson <me@maxj.phd> · 2026-08-12 16:54 UTC
Signed with PGP, not checked
Commit: 6858555ec2c2e08e8834c867169c108a6aa3e9d2
Parent: 2e11553
3 files changed, +193 insertions, -33 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "docengine"
3 - version = "0.6.0"
3 + version = "0.7.0"
4 4 edition = "2024"
5 5 license = "MIT"
6 6
M src/lib.rs +1 -1
@@ -43,7 +43,7 @@
43 43 mod quotes;
44 44
45 45 // Re-export core types
46 - pub use plain::{Emphasis, TextRun, render_plain, render_runs};
46 + pub use plain::{Block, Emphasis, TextRun, render_plain, render_runs};
47 47 pub use render::{RenderResult, Renderer};
48 48 pub use sanitize::SanitizePreset;
49 49 pub use text::{extract_title, reading_time_minutes, strip_first_heading, word_count};
M src/plain.rs +191 -31
@@ -26,11 +26,7 @@
26 26 /// are independent and any combination can hold at once, which is why this is a
27 27 /// set of flags rather than one kind.
28 28 ///
29 - /// Block structure is deliberately not here. A heading's level, a list's
30 - /// nesting and a quote's depth are gone by this point for the same reason they
31 - /// are gone from [`render_plain`]: the destination is a place with no room to
32 - /// spend on them. What survives is exactly what a caller can paint on a run of
33 - /// text without moving it.
29 + /// What kind of block the run sits in is [`Block`], on the run beside this.
34 30 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
35 31 pub struct Emphasis {
36 32 /// `**strong**`.
@@ -52,13 +48,41 @@
52 48 }
53 49 }
54 50
55 - /// A stretch of text under one set of marks.
51 + /// The kind of block a run sits in.
52 + ///
53 + /// Emphasis is what a caller paints on the words. This is what the words *are*,
54 + /// and a destination with no markup can still answer it: a heading takes weight,
55 + /// an item takes a bullet, a quote takes a marker in the margin.
56 + ///
57 + /// The innermost block wins and nesting is not carried. An item inside a quote
58 + /// reads as an item, because a caller that has one line to draw has to pick one
59 + /// of the two anyway and the inner one is the one holding the words. A caller
60 + /// that needs the tree wants a markup preset rather than this.
61 + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
62 + pub enum Block {
63 + /// A paragraph, a table cell, or anything else with nothing to say about
64 + /// itself.
65 + #[default]
66 + Prose,
67 + /// A heading, at its markdown level: 1 for `#` through 6 for `######`.
68 + Heading(u8),
69 + /// A list item, ordered or not. The marker is not in the text, because what
70 + /// a bullet looks like is the caller's answer and a number would be wrong
71 + /// for half of them.
72 + Item,
73 + /// A line inside a block quote.
74 + Quote,
75 + }
76 +
77 + /// A stretch of text under one set of marks, in one kind of block.
56 78 #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
57 79 pub struct TextRun {
58 80 /// The words, with none of the syntax that shaped them.
59 81 pub text: String,
60 82 /// What was over them.
61 83 pub emphasis: Emphasis,
84 + /// What they are part of.
85 + pub block: Block,
62 86 }
63 87
64 88 /// How deep we are inside each inline mark.
@@ -77,6 +101,41 @@
77 101 code_block: u16,
78 102 }
79 103
104 + /// Which block a run is in, innermost last.
105 + ///
106 + /// A stack and not a field, because the blocks that matter here nest: an item
107 + /// inside a quote inside an item is ordinary markdown, and closing the inner one
108 + /// has to put the outer one back rather than fall to prose.
109 + #[derive(Default)]
110 + struct Blocks(Vec<Block>);
111 +
112 + impl Blocks {
113 + fn current(&self) -> Block {
114 + self.0.last().copied().unwrap_or_default()
115 + }
116 +
117 + fn open(&mut self, block: Block) {
118 + self.0.push(block);
119 + }
120 +
121 + fn close(&mut self) {
122 + self.0.pop();
123 + }
124 + }
125 +
126 + /// A markdown heading level as the number the author typed.
127 + fn level_of(level: pulldown_cmark::HeadingLevel) -> u8 {
128 + use pulldown_cmark::HeadingLevel::{H1, H2, H3, H4, H5, H6};
129 + match level {
130 + H1 => 1,
131 + H2 => 2,
132 + H3 => 3,
133 + H4 => 4,
134 + H5 => 5,
135 + H6 => 6,
136 + }
137 + }
138 +
80 139 impl Marks {
81 140 fn emphasis(self) -> Emphasis {
82 141 Emphasis {
@@ -151,6 +210,7 @@
151 210
152 211 let mut runs: Vec<TextRun> = Vec::new();
153 212 let mut marks = Marks::default();
213 + let mut blocks = Blocks::default();
154 214
155 215 for event in Parser::new_ext(markdown, options) {
156 216 match event {
@@ -164,8 +224,18 @@
164 224 code: true,
165 225 ..marks.emphasis()
166 226 },
227 + blocks.current(),
167 228 ),
168 - Event::Text(text) => push(&mut runs, &text, marks.emphasis()),
229 + Event::Text(text) => push(&mut runs, &text, marks.emphasis(), blocks.current()),
230 +
231 + // What the words are, as opposed to what is painted on them. A
232 + // heading has an answer on any destination that can set one line
233 + // heavier than the next, and a terminal is one of them.
234 + Event::Start(Tag::Heading { level, .. }) => {
235 + blocks.open(Block::Heading(level_of(level)));
236 + }
237 + Event::Start(Tag::Item) => blocks.open(Block::Item),
238 + Event::Start(Tag::BlockQuote(_)) => blocks.open(Block::Quote),
169 239
170 240 // Inline marks, tracked rather than passed over. This is the whole
171 241 // difference between the two functions above; the rest of the walk
@@ -183,36 +253,46 @@
183 253 // one line back rather than a break they never typed. Both carry
184 254 // the marks in force, so a break inside `**...**` does not cut the
185 255 // run in three over a character nobody can see the style of.
186 - Event::HardBreak => push(&mut runs, "\n", marks.emphasis()),
187 - Event::SoftBreak => push(&mut runs, " ", marks.emphasis()),
256 + Event::HardBreak => push(&mut runs, "\n", marks.emphasis(), blocks.current()),
257 + Event::SoftBreak => push(&mut runs, " ", marks.emphasis(), blocks.current()),
188 258
189 259 // A prose block ends with a blank line after it, because two
190 260 // paragraphs run together read as one sentence that does not parse.
191 - // Inline marks are closed by now, so these are unmarked by
192 - // construction rather than by choice.
261 + //
262 + // Every separator below is prose under no marks, and by
263 + // construction rather than by choice: it stands between two blocks
264 + // and belongs to neither, and the block it closes is closed before
265 + // it is written. That is also what keeps two adjacent items from
266 + // merging into one run.
193 267 Event::End(TagEnd::CodeBlock) => {
194 268 marks.code_block = marks.code_block.saturating_sub(1);
195 - push(&mut runs, "\n\n", Emphasis::default());
269 + push(&mut runs, "\n\n", Emphasis::default(), Block::Prose);
270 + }
271 + Event::End(TagEnd::Heading(_) | TagEnd::BlockQuote(_)) => {
272 + blocks.close();
273 + push(&mut runs, "\n\n", Emphasis::default(), Block::Prose);
274 + }
275 + Event::End(TagEnd::Paragraph | TagEnd::List(_) | TagEnd::FootnoteDefinition) => {
276 + push(&mut runs, "\n\n", Emphasis::default(), Block::Prose);
196 277 }
197 - Event::End(
198 - TagEnd::Paragraph
199 - | TagEnd::Heading(_)
200 - | TagEnd::BlockQuote(_)
201 - | TagEnd::List(_)
202 - | TagEnd::FootnoteDefinition,
203 - ) => push(&mut runs, "\n\n", Emphasis::default()),
204 278
205 279 // A member of a block ends a line and no more: a list is one item
206 280 // per line, and a table is one row per line.
207 - Event::End(TagEnd::Item | TagEnd::TableRow | TagEnd::TableHead) => {
208 - push(&mut runs, "\n", Emphasis::default());
281 + Event::End(TagEnd::Item) => {
282 + blocks.close();
283 + push(&mut runs, "\n", Emphasis::default(), Block::Prose);
284 + }
285 + Event::End(TagEnd::TableRow | TagEnd::TableHead) => {
286 + push(&mut runs, "\n", Emphasis::default(), Block::Prose);
209 287 }
210 288
211 289 // Cells sit in a row, so they want a separator rather than a break.
212 - Event::End(TagEnd::TableCell) => push(&mut runs, "\t", Emphasis::default()),
290 + Event::End(TagEnd::TableCell) => {
291 + push(&mut runs, "\t", Emphasis::default(), Block::Prose);
292 + }
213 293
214 294 // A rule is a boundary with nothing to say.
215 - Event::Rule => push(&mut runs, "\n", Emphasis::default()),
295 + Event::Rule => push(&mut runs, "\n", Emphasis::default(), Block::Prose),
216 296
217 297 // An image's alt text is the only thing here a reader can use, and
218 298 // pulldown emits it as the tag's inner text, so the tag itself is
@@ -229,17 +309,20 @@
229 309 tidy(runs)
230 310 }
231 311
232 - /// Append `text` under `emphasis`, joining the run before it when the marks
233 - /// match, so a paragraph with no emphasis in it stays one run.
234 - fn push(runs: &mut Vec<TextRun>, text: &str, emphasis: Emphasis) {
312 + /// Append `text` under `emphasis` and `block`, joining the run before it when
313 + /// both match, so a paragraph with no emphasis in it stays one run.
314 + fn push(runs: &mut Vec<TextRun>, text: &str, emphasis: Emphasis, block: Block) {
235 315 if text.is_empty() {
236 316 return;
237 317 }
238 318 match runs.last_mut() {
239 - Some(last) if last.emphasis == emphasis => last.text.push_str(text),
319 + Some(last) if last.emphasis == emphasis && last.block == block => {
320 + last.text.push_str(text);
321 + }
240 322 _ => runs.push(TextRun {
241 323 text: text.to_string(),
242 324 emphasis,
325 + block,
243 326 }),
244 327 }
245 328 }
@@ -284,10 +367,10 @@
284 367 let mut out: Vec<TextRun> = Vec::new();
285 368 for (index, line) in kept.into_iter().enumerate() {
286 369 if index > 0 {
287 - push(&mut out, "\n", Emphasis::default());
370 + push(&mut out, "\n", Emphasis::default(), Block::Prose);
288 371 }
289 372 for run in line {
290 - push(&mut out, &run.text, run.emphasis);
373 + push(&mut out, &run.text, run.emphasis, run.block);
291 374 }
292 375 }
293 376 out
@@ -308,6 +391,7 @@
308 391 line.push(TextRun {
309 392 text: piece.to_string(),
310 393 emphasis: run.emphasis,
394 + block: run.block,
311 395 });
312 396 }
313 397 }
@@ -330,7 +414,7 @@
330 414
331 415 #[cfg(test)]
332 416 mod tests {
333 - use super::{Emphasis, render_plain, render_runs};
417 + use super::{Block, Emphasis, render_plain, render_runs};
334 418
335 419 /// The marks over each run, for asserting shape without spelling out four
336 420 /// booleans a run.
@@ -341,6 +425,14 @@
341 425 .collect()
342 426 }
343 427
428 + /// The block each run sits in.
429 + fn blocks(markdown: &str) -> Vec<(String, Block)> {
430 + render_runs(markdown)
431 + .into_iter()
432 + .map(|run| (run.text, run.block))
433 + .collect()
434 + }
435 +
344 436 #[test]
345 437 fn inline_syntax_becomes_the_words_it_wrapped() {
346 438 assert_eq!(
@@ -616,11 +708,79 @@
616 708 ..Emphasis::default()
617 709 }
618 710 ),
619 - ("\ntwo\n\nAfter.".to_string(), Emphasis::default()),
711 + ("\n".to_string(), Emphasis::default()),
712 + ("two".to_string(), Emphasis::default()),
713 + ("\n\nAfter.".to_string(), Emphasis::default()),
620 714 ]
621 715 );
622 716 }
623 717
718 + #[test]
719 + fn a_heading_says_which_level_it_was() {
720 + // The one this was written for: a destination with no markup can still
721 + // set one line heavier than the next, and until now it was not told
722 + // which line.
723 + assert_eq!(
724 + blocks("# Title\n\nBody.\n\n### Deeper"),
725 + vec![
726 + ("Title".to_string(), Block::Heading(1)),
727 + ("\n\nBody.\n\n".to_string(), Block::Prose),
728 + ("Deeper".to_string(), Block::Heading(3)),
729 + ]
730 + );
731 + }
732 +
733 + #[test]
734 + fn an_item_carries_no_marker_of_its_own() {
735 + // What a bullet looks like is the caller's answer, and a number would be
736 + // wrong for half of them. The separator between two items is prose,
737 + // which is also what keeps them from merging into one run.
738 + assert_eq!(
739 + blocks("- one\n- two"),
740 + vec![
741 + ("one".to_string(), Block::Item),
742 + ("\n".to_string(), Block::Prose),
743 + ("two".to_string(), Block::Item),
744 + ]
745 + );
746 + assert_eq!(render_plain("1. one\n2. two"), "one\ntwo");
747 + }
748 +
749 + #[test]
750 + fn the_innermost_block_wins_and_the_outer_one_comes_back() {
751 + // An item inside a quote reads as an item: a caller with one line to
752 + // draw has to pick one of the two, and the inner one holds the words.
753 + // What the stack is for is the line after it, where the quote is still
754 + // open and prose would be wrong.
755 + assert_eq!(
756 + blocks("> - one\n>\n> after"),
757 + vec![
758 + ("one".to_string(), Block::Item),
759 + ("\n\n".to_string(), Block::Prose),
760 + ("after".to_string(), Block::Quote),
761 + ]
762 + );
763 + }
764 +
765 + #[test]
766 + fn a_block_role_does_not_disturb_the_words() {
767 + // The invariant again, over the sources that carry blocks. Adding a
768 + // reason to split a run must not add or drop a character.
769 + for source in [
770 + "# Title\n\nBody.",
771 + "- one\n- two\n\nAfter.",
772 + "> quoted\n\nafter",
773 + "> - **one**\n> - two\n\nAfter.",
774 + "1. one\n2. two",
775 + ] {
776 + let joined: String = render_runs(source)
777 + .into_iter()
778 + .map(|run| run.text)
779 + .collect();
780 + assert_eq!(joined, render_plain(source), "source: {source:?}");
781 + }
782 + }
783 +
624 784 #[test]
625 785 fn a_line_ending_in_its_own_run_of_whitespace_loses_it() {
626 786 // A row ends every cell with a tab, including the last one, so each