Skip to main content

vertext_html/
lib.rs

1//! The shared `Layout` → HTML renderer.
2//!
3//! Every HTML-producing host — the CLI today, `vertext-wasm` tomorrow — goes
4//! through this crate, so the slot-to-class mapping and the mode protocol are
5//! defined exactly once. The crate is `wasm32`-clean: no I/O, strings in,
6//! strings out.
7
8use vertext_core::{
9    is_mongolian, layout_text, prefers_horizontal, HorizontalKind, Layout, LayoutConfig,
10    Progression, Slot,
11};
12
13/// Reserved private-use markers that the Quarto filter inserts around a
14/// segment so a single invocation can switch mid-stream between rule sets.
15/// Source authors never type these, which is why they live in the Unicode
16/// Private Use Area.
17///
18/// A marker means "everything after me is this kind of segment, until the
19/// next marker". Structure that markdown expresses and a flat string cannot —
20/// a heading is not a paragraph that happens to be short — has to cross the
21/// boundary somehow, and this is the seam it crosses.
22///
23/// Wire protocol note: `extensions/vertext/vertext.lua` carries the same
24/// codepoints as string literals. The `mode_markers_are_the_wire_protocol`
25/// test pins the values so a drift on the Rust side cannot pass silently.
26pub const MODE_CODE: char = '\u{E000}';
27pub const MODE_PROSE: char = '\u{E001}';
28/// Heading levels 1–6 occupy U+E002–U+E007.
29pub const MODE_HEADING_BASE: u32 = 0xE002;
30pub const MAX_HEADING_LEVEL: u8 = 6;
31/// A table segment. Within it, cells are separated by [`CELL_SEP`] and rows by
32/// [`ROW_SEP`]; a table is 2-D and the wire is a flat string, so the structure
33/// needs separators rather than a mode alone.
34pub const MODE_TABLE: char = '\u{E008}';
35pub const CELL_SEP: char = '\u{E009}';
36pub const ROW_SEP: char = '\u{E00A}';
37/// One list item. Each item is its own segment: flattening a whole list into
38/// a single blob welds the items together *and* mixes their scripts, so a
39/// list of mostly-CJK items with Latin terms in them gets classified by the
40/// aggregate rather than item by item.
41pub const MODE_LIST: char = '\u{E00B}';
42/// One item of a *numbered* list. Ordered items already carry their number in
43/// the text, so they must not also be given a bullet; a marker of their own
44/// is what lets the stylesheet tell them apart.
45pub const MODE_LIST_ORDERED: char = '\u{E00C}';
46/// One past the last reserved codepoint — exclusive, like every other Rust
47/// upper bound. The filter strips `MODE_CODE ..RESERVED_END` from author
48/// text; keep the two in step. `vertext.lua` expresses the same bound as
49/// `MODE_CODE_POINT + RESERVED_COUNT`, and
50/// `the_reserved_range_ends_where_the_filter_stops_stripping` pins them to
51/// each other.
52pub const RESERVED_END: u32 = 0xE00D;
53
54/// The marker introducing a heading of `level` (clamped to 1–6).
55pub fn heading_marker(level: u8) -> char {
56    let level = level.clamp(1, MAX_HEADING_LEVEL);
57    char::from_u32(MODE_HEADING_BASE + u32::from(level) - 1).expect("heading marker in PUA")
58}
59
60/// Single source of truth for the Latin slot caps. The renderer publishes
61/// them to CSS as custom properties on the root element, so the stylesheet
62/// never hardcodes a width that could drift from the layout.
63pub const PROSE_LATIN_CAP: usize = 12;
64pub const CODE_LATIN_CAP: usize = 24;
65
66pub fn prose_config(progression: Progression) -> LayoutConfig {
67    LayoutConfig {
68        max_latin_word_width: PROSE_LATIN_CAP,
69        preserve_spaces: false,
70        progression,
71    }
72}
73
74pub fn code_config(progression: Progression) -> LayoutConfig {
75    LayoutConfig {
76        max_latin_word_width: CODE_LATIN_CAP,
77        preserve_spaces: true,
78        progression,
79    }
80}
81
82pub fn escape(text: &str) -> String {
83    text.replace('&', "&")
84        .replace('<', "&lt;")
85        .replace('>', "&gt;")
86        .replace('"', "&quot;")
87}
88
89fn advance_keyword(progression: Progression) -> &'static str {
90    match progression {
91        Progression::RightToLeft => "left",
92        Progression::LeftToRight => "right",
93    }
94}
95
96#[derive(Clone, Copy, PartialEq, Eq, Debug)]
97enum Mode {
98    Prose,
99    Code,
100    Heading(u8),
101    Table,
102    ListItem { ordered: bool },
103}
104
105impl Mode {
106    fn from_marker(ch: char) -> Option<Mode> {
107        if ch == MODE_CODE {
108            return Some(Mode::Code);
109        }
110        if ch == MODE_PROSE {
111            return Some(Mode::Prose);
112        }
113        if ch == MODE_TABLE {
114            return Some(Mode::Table);
115        }
116        if ch == MODE_LIST {
117            return Some(Mode::ListItem { ordered: false });
118        }
119        if ch == MODE_LIST_ORDERED {
120            return Some(Mode::ListItem { ordered: true });
121        }
122        let offset = (ch as u32).checked_sub(MODE_HEADING_BASE)?;
123        (offset < u32::from(MAX_HEADING_LEVEL)).then(|| Mode::Heading(offset as u8 + 1))
124    }
125
126    fn config(self, progression: Progression) -> LayoutConfig {
127        match self {
128            // A heading is prose that happens to be short and loud. It gets
129            // the prose rule set; only its presentation differs.
130            Mode::Prose | Mode::Heading(_) | Mode::Table | Mode::ListItem { .. } => prose_config(progression),
131            Mode::Code => code_config(progression),
132        }
133    }
134
135    fn column_class(self) -> String {
136        match self {
137            Mode::Prose | Mode::Table => "vertext-column vertext-column-prose".to_string(),
138            Mode::ListItem { ordered } => {
139                let kind = if ordered { "vertext-column-list-ordered" } else { "vertext-column-list-bullet" };
140                format!("vertext-column vertext-column-prose vertext-column-list {kind}")
141            }
142            Mode::Code => "vertext-column vertext-column-code".to_string(),
143            Mode::Heading(level) => format!(
144                "vertext-column vertext-column-prose vertext-column-heading vertext-column-h{level}"
145            ),
146        }
147    }
148
149    /// Which way this segment is set.
150    ///
151    /// Code is always horizontal — program source has a left-to-right reading
152    /// order built into its own syntax, and Japanese and Chinese technical
153    /// publishing has set code horizontally inside vertical books for decades.
154    /// Prose is decided by which script carries the line. Headings follow the
155    /// body so a section title never sits at odds with the section.
156    fn block(self, text: &str) -> BlockKind {
157        match self {
158            Mode::Table => BlockKind::Table,
159            Mode::Code => BlockKind::Horizontal(HorizontalKind::Code),
160            Mode::Prose | Mode::Heading(_) | Mode::ListItem { .. } => {
161                if prefers_horizontal(text) {
162                    BlockKind::Horizontal(HorizontalKind::Prose)
163                } else {
164                    BlockKind::Vertical
165                }
166            }
167        }
168    }
169}
170
171#[derive(Clone, Copy, PartialEq, Eq, Debug)]
172enum BlockKind {
173    Vertical,
174    Horizontal(HorizontalKind),
175    Table,
176}
177
178#[derive(Clone, Copy, Debug)]
179pub struct RenderOptions {
180    /// Lay the entire input out under the code rule set, ignore the mode
181    /// markers, and put `vertext-code` on the root.
182    pub whole_strip_code: bool,
183    /// Put `vertext-page` on the root. The stylesheet keys full-page vertical
184    /// flow (native `writing-mode` on the surrounding page) off this class.
185    pub page: bool,
186    /// Which way columns advance. A property of the document's script, and
187    /// the one thing an engine must never hardcode: every renderer that fixes
188    /// this to right-to-left has decided permanently which literatures it can
189    /// carry. Declared by the author, because it cannot be inferred — a
190    /// Chinese document teaching Mongolian and a Mongolian document teaching
191    /// Chinese contain the same scripts and want opposite answers.
192    pub progression: Progression,
193}
194
195impl Default for RenderOptions {
196    fn default() -> Self {
197        Self {
198            whole_strip_code: false,
199            page: false,
200            // CJK is the bulk of vertical text on the web; Mongolian
201            // documents declare. Neither is privileged in the model.
202            progression: Progression::RightToLeft,
203        }
204    }
205}
206
207/// Renders one `.vertext` strip to HTML.
208///
209/// Without [`RenderOptions::whole_strip_code`] the input is split at the
210/// reserved markers into prose/code segments, each laid out under its own
211/// rule set.
212pub fn render_document(input: &str, options: RenderOptions) -> String {
213    let mut segments: Vec<(Mode, String)> = Vec::new();
214    if options.whole_strip_code {
215        segments.push((Mode::Code, input.to_owned()));
216    } else {
217        let mut current = (Mode::Prose, String::new());
218        for ch in input.chars() {
219            match Mode::from_marker(ch) {
220                // A marker at the very start has nothing to close, so it
221                // retargets the open segment instead of emitting an empty one.
222                Some(mode) if current.1.is_empty() && segments.is_empty() => {
223                    current = (mode, String::new());
224                }
225                Some(mode) => {
226                    segments.push(std::mem::replace(&mut current, (mode, String::new())));
227                }
228                None => current.1.push(ch),
229            }
230        }
231        if !current.1.is_empty() || segments.is_empty() {
232            segments.push(current);
233        }
234    }
235
236    let mut root_class = String::from("vertext");
237    if options.whole_strip_code {
238        root_class.push_str(" vertext-code");
239    }
240    if options.page {
241        root_class.push_str(" vertext-page");
242    }
243    let advance = advance_keyword(options.progression);
244    let mut html = format!(
245        "<div class=\"{root_class}\" data-column-advance=\"{advance}\" \
246         style=\"--vertext-latin-cap-prose:{PROSE_LATIN_CAP}ch;\
247         --vertext-latin-cap-code:{CODE_LATIN_CAP}ch\">"
248    );
249
250    let mut emitted_any = false;
251    let mut in_stack = false;
252    let last_index = segments.len().saturating_sub(1);
253    for (index, (mode, segment_text)) in segments.into_iter().enumerate() {
254        let trimmed = segment_text.trim_end_matches(['\n', '\r']);
255        // Counted, not merely detected: the LAST segment's first trailing
256        // newline is the file's line terminator, and every later one is the
257        // author's. Counting '\n' inside the trimmed run keeps CRLF input
258        // answering the same as LF.
259        let trailing_newlines = segment_text[trimmed.len()..].matches('\n').count();
260        // A wholly blank segment carries nothing and must stay transparent.
261        // The separator newline between a heading and the fenced block under
262        // it produces one, and treating it as content made it a vertical block
263        // that split the two apart — the heading ended one horizontal stack
264        // and its own code block started another.
265        if trimmed.is_empty() {
266            continue;
267        }
268        emitted_any = true;
269        // `.vertext-code` is the author declaring "set this vertically as
270        // code" — the showcase case. A fenced block inside ordinary prose is
271        // the opposite instruction and goes horizontal. Same content, and the
272        // difference is what the author asked for, never what we guessed.
273        let block = if options.whole_strip_code { BlockKind::Vertical } else { mode.block(trimmed) };
274
275        // Consecutive horizontal blocks stack vertically instead of each
276        // claiming its own slot beside the columns. Without this a one-word
277        // English heading takes a full column's width and leaves the height of
278        // the page empty beneath it, with its own paragraph stranded in the
279        // next slot over. Stacked, the heading sits on top and its text runs
280        // underneath — which is how a heading and its paragraph relate.
281        //
282        // Tables join the stack for the same reason: a table's caption line
283        // belongs above it and its commentary below, not beside it. A table
284        // that happens to sit among vertical columns simply ends up alone in
285        // its stack, which lays out exactly as it did before.
286        let horizontal = matches!(block, BlockKind::Horizontal(_) | BlockKind::Table);
287        if horizontal && !in_stack {
288            html.push_str("<div class=\"vertext-hstack\">");
289            in_stack = true;
290        } else if !horizontal && in_stack {
291            html.push_str("</div>");
292            in_stack = false;
293        }
294
295        match block {
296            BlockKind::Table => render_table(&mut html, trimmed, options.progression),
297            BlockKind::Horizontal(kind) => render_horizontal(&mut html, trimmed, kind, mode),
298            BlockKind::Vertical => {
299                let layout = layout_text(trimmed, &mode.config(options.progression));
300                let column_class = mode.column_class();
301                for column in &layout.columns {
302                    html.push_str(&format!("<div class=\"{column_class}\">"));
303                    render_slots(&mut html, &column.slots);
304                    html.push_str("</div>");
305                }
306                // Preserve a blank column for a paragraph break that ends the
307                // segment (source newline immediately before a mode toggle).
308                //
309                // Except at the end of the input, where the last newline is the
310                // file's terminator and not a break the author typed. Every
311                // document ends with one, so counting it put a blank column at
312                // the foot of every strip -- six of them on one real page,
313                // each holding open a column's width of nothing. A blank line
314                // deliberately left at the end still reads as a break: it is
315                // the SECOND trailing newline that carries the intent.
316                let ends_the_input = index == last_index;
317                let author_broke = if ends_the_input {
318                    trailing_newlines > 1
319                } else {
320                    trailing_newlines > 0
321                };
322                if author_broke && !layout.columns.is_empty() {
323                    html.push_str(&format!("<div class=\"{column_class}\"></div>"));
324                }
325            }
326        }
327    }
328    if in_stack {
329        html.push_str("</div>");
330    }
331    if !emitted_any {
332        // Empty input must still produce a well-formed empty strip.
333        html.push_str("<div class=\"vertext-column vertext-column-prose\"></div>");
334    }
335    html.push_str("</div>\n");
336    html
337}
338
339/// A horizontal block: an orthogonal island in the vertical flow.
340///
341/// The wrap measure is published as a custom property rather than baked into
342/// the stylesheet, for the same reason the Latin caps are — one source, no
343/// drift. Line breaking is left to the browser, which has the font metrics.
344/// Wrap each Mongolian run in a span the stylesheet can reach, escaping as it
345/// goes.
346///
347/// A horizontal block does not go through slot layout — its text is passed
348/// through whole — so the runs inside it carry no class, and the stylesheet's
349/// Mongolian `font-family` lives on `.vertext-mongolian`, which only the
350/// vertical path emits. The result was measured in a real browser: bichig in a
351/// Latin-majority line renders in whatever face the browser falls back to, and
352/// with `init`/`medi`/`fina` switched off it does not change at all — nothing
353/// was joining it.
354///
355/// The class is a *different* one on purpose. `.vertext-mongolian` also
356/// declares `display: inline-block` and `writing-mode: vertical-lr`; reusing it
357/// here would stand the run upright inside a horizontal line, trading a font
358/// defect for a layout one. This one carries the face and nothing else.
359///
360/// U+202F is taken into the run when Mongolian holds it on both sides, for the
361/// same reason the layout keeps it inside `Slot::MongolianRun`: it is the joint
362/// of a suffix, and a font that receives it split receives two words.
363fn mark_mongolian_runs(text: &str) -> String {
364    let chars: Vec<char> = text.chars().collect();
365    let in_run = |index: usize| -> bool {
366        let ch = chars[index];
367        if is_mongolian(ch) {
368            return true;
369        }
370        if ch != '\u{202F}' {
371            return false;
372        }
373        let before = index.checked_sub(1).map(|i| is_mongolian(chars[i])).unwrap_or(false);
374        let after = chars.get(index + 1).copied().map(is_mongolian).unwrap_or(false);
375        before && after
376    };
377
378    let mut html = String::with_capacity(text.len());
379    let mut index = 0;
380    while index < chars.len() {
381        if in_run(index) {
382            let start = index;
383            while index < chars.len() && in_run(index) {
384                index += 1;
385            }
386            let run: String = chars[start..index].iter().collect();
387            html.push_str("<span class=\"vertext-mongolian-inline\">");
388            html.push_str(&escape(&run));
389            html.push_str("</span>");
390        } else {
391            let start = index;
392            while index < chars.len() && !in_run(index) {
393                index += 1;
394            }
395            let plain: String = chars[start..index].iter().collect();
396            html.push_str(&escape(&plain));
397        }
398    }
399    html
400}
401
402fn render_horizontal(html: &mut String, text: &str, kind: HorizontalKind, mode: Mode) {
403    let (kind_class, wrap) = match kind {
404        HorizontalKind::Prose => ("vertext-horizontal-prose", kind.default_wrap()),
405        HorizontalKind::Code => ("vertext-horizontal-code", kind.default_wrap()),
406    };
407    let heading_class = match mode {
408        Mode::Heading(level) => format!(" vertext-horizontal-heading vertext-horizontal-h{level}"),
409        Mode::ListItem { ordered } => {
410            let kind = if ordered { " vertext-horizontal-list-ordered" } else { " vertext-horizontal-list-bullet" };
411            format!(" vertext-horizontal-list{kind}")
412        }
413        _ => String::new(),
414    };
415    let tag = if matches!(kind, HorizontalKind::Code) { "pre" } else { "div" };
416    // Prose only. Code keeps its monospace face deliberately, and a span that
417    // changed the family mid-line would break the column alignment that is the
418    // whole point of setting code in monospace. Bichig inside a code block is
419    // therefore still unstyled; it is rare, and trading one visible defect for
420    // another silently is how this one got here.
421    let body = match kind {
422        HorizontalKind::Prose => mark_mongolian_runs(text),
423        HorizontalKind::Code => escape(text),
424    };
425    html.push_str(&format!(
426        "<div class=\"vertext-horizontal {kind_class}{heading_class}\" \
427         style=\"--vertext-wrap:{wrap}ch\"><{tag}>{body}</{tag}></div>"
428    ));
429}
430
431/// A table. Rows are separated by [`ROW_SEP`] and cells by [`CELL_SEP`].
432///
433/// Vertical text is the one place a table's structure falls out for free: a
434/// row set as a column reads top-to-bottom as one entry, and successive rows
435/// advance the way the surrounding text does. A markup `<table>` under
436/// `writing-mode: vertical-rl` does exactly that transposition natively, so
437/// the row stays a `<tr>` and the browser places it — no transposing here,
438/// which keeps the markup honest for screen readers and for `display: block`
439/// fallbacks.
440///
441/// Cell contents go through the ordinary slot layout, so a Mongolian cell
442/// keeps its joined run and a Latin cell keeps its word slots. Cells do not
443/// hyphenate: the column width is the constraint, and a romanization broken
444/// across a hard hyphen is unreadable as a citation form.
445fn render_table(html: &mut String, text: &str, progression: Progression) {
446    let config = LayoutConfig { max_latin_word_width: usize::MAX, ..prose_config(progression) };
447    html.push_str("<table class=\"vertext-table\">");
448    for (index, row) in text.split(ROW_SEP).enumerate() {
449        if row.is_empty() {
450            continue;
451        }
452        let header = index == 0;
453        let cell_tag = if header { "th" } else { "td" };
454        html.push_str(if header {
455            "<thead><tr class=\"vertext-row vertext-row-header\">"
456        } else {
457            "<tr class=\"vertext-row\">"
458        });
459        for cell in row.split(CELL_SEP) {
460            html.push_str(&format!("<{cell_tag} class=\"vertext-cell\">"));
461            let layout = layout_text(cell, &config);
462            for column in &layout.columns {
463                html.push_str("<div class=\"vertext-column vertext-column-cell\">");
464                render_slots(html, &column.slots);
465                html.push_str("</div>");
466            }
467            html.push_str(&format!("</{cell_tag}>"));
468        }
469        html.push_str(if header { "</tr></thead><tbody>" } else { "</tr>" });
470    }
471    html.push_str("</tbody></table>");
472}
473
474fn render_slots(html: &mut String, slots: &[Slot]) {
475    for slot in slots {
476        // Whitespace is emitted as the character the author typed, never a
477        // stand-in glyph. Code indentation is made visible by the stylesheet
478        // instead — a background, not a substitution, so the text a reader
479        // copies is the text a writer wrote.
480        let (class, body) = match slot {
481            Slot::Upright(s) => ("vertext-upright", escape(s)),
482            Slot::LatinWord(s) => ("vertext-latin", escape(s)),
483            Slot::MongolianRun(s) => ("vertext-mongolian", escape(s)),
484            Slot::Space(s) => ("vertext-space", escape(s)),
485            // The character is emitted exactly as the author wrote it. The
486            // vertical appearance is the stylesheet's job — see the note on
487            // `Slot::VerticalPunctuation`.
488            Slot::VerticalPunctuation(s) => ("vertext-vform", escape(s)),
489            Slot::CornerPunctuation(s) => ("vertext-corner", escape(s)),
490            Slot::Neutral(s) => ("vertext-neutral", escape(s)),
491        };
492        html.push_str(&format!("<span class=\"{class}\">{body}</span>"));
493    }
494}
495
496/// Exposes the advance keyword for hosts that render their own shell.
497pub fn column_advance(layout: &Layout) -> &'static str {
498    advance_keyword(layout.progression)
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    /// The Rust upper bound and the Lua strip loop must name the same edge.
506    ///
507    /// `RESERVED_END` exists for one reason — "keep the two in step" — and
508    /// until now nothing checked that it did. It said "one past the last"
509    /// while holding the last, so anyone implementing a stripper from its own
510    /// documentation would have left `MODE_LIST_ORDERED` in the author's text:
511    /// an ordered-list marker surviving into a document, no error, no warning.
512    ///
513    /// `vertext.lua` writes the same edge as `MODE_CODE_POINT +
514    /// RESERVED_COUNT`, with `RESERVED_COUNT = 13`. Changing either side alone
515    /// now fails here.
516    #[test]
517    fn the_reserved_range_ends_where_the_filter_stops_stripping() {
518        assert_eq!(RESERVED_END, MODE_LIST_ORDERED as u32 + 1);
519        assert_eq!(RESERVED_END, MODE_CODE as u32 + 13);
520        assert!(Mode::from_marker(MODE_LIST_ORDERED).is_some());
521        assert!(char::from_u32(RESERVED_END).and_then(Mode::from_marker).is_none());
522    }
523
524    #[test]
525    fn mode_markers_are_the_wire_protocol() {
526        // These codepoints are duplicated as literals in
527        // extensions/vertext/vertext.lua. Do not change one side alone.
528        assert_eq!(MODE_CODE, '\u{E000}');
529        assert_eq!(MODE_PROSE, '\u{E001}');
530        assert_eq!(heading_marker(1), '\u{E002}');
531        assert_eq!(heading_marker(2), '\u{E003}');
532        assert_eq!(heading_marker(3), '\u{E004}');
533        assert_eq!(heading_marker(4), '\u{E005}');
534        assert_eq!(heading_marker(5), '\u{E006}');
535        assert_eq!(heading_marker(6), '\u{E007}');
536        assert_eq!(MODE_TABLE, '\u{E008}');
537        assert_eq!(CELL_SEP, '\u{E009}');
538        assert_eq!(ROW_SEP, '\u{E00A}');
539        assert_eq!(MODE_LIST, '\u{E00B}');
540        assert_eq!(MODE_LIST_ORDERED, '\u{E00C}');
541        // Every reserved codepoint is pinned above. A round-trip test cannot
542        // stand in for this one: it compares a constant against itself, so
543        // renumbering MODE_TABLE would leave it green while the literal in
544        // extensions/vertext/vertext.lua silently means something else.
545        // Out-of-range levels clamp rather than producing a stray codepoint.
546        assert_eq!(heading_marker(0), heading_marker(1));
547        assert_eq!(heading_marker(9), heading_marker(6));
548    }
549
550    #[test]
551    fn every_marker_round_trips_to_its_mode() {
552        assert_eq!(Mode::from_marker(MODE_CODE), Some(Mode::Code));
553        assert_eq!(Mode::from_marker(MODE_PROSE), Some(Mode::Prose));
554        assert_eq!(Mode::from_marker(MODE_TABLE), Some(Mode::Table));
555        assert_eq!(Mode::from_marker(MODE_LIST), Some(Mode::ListItem { ordered: false }));
556        assert_eq!(Mode::from_marker(MODE_LIST_ORDERED), Some(Mode::ListItem { ordered: true }));
557        for level in 1..=MAX_HEADING_LEVEL {
558            assert_eq!(Mode::from_marker(heading_marker(level)), Some(Mode::Heading(level)));
559        }
560        // Ordinary text must never be mistaken for a marker.
561        // '\u{E00D}' is one past the last reserved codepoint and '\u{D7FF}'
562        // sits below the whole block. Both must read as ordinary text.
563        for ch in ['字', 'a', '\u{E00D}', '\u{D7FF}', '\u{E7FF}'] {
564            assert_eq!(Mode::from_marker(ch), None, "{ch:?} should not be a marker");
565        }
566    }
567
568    #[test]
569    fn a_heading_segment_gets_its_level_on_the_column() {
570        // A CJK heading stays vertical and carries its level on the column.
571        let input = format!("{}中文{MODE_PROSE}山川", heading_marker(2));
572        let html = render_document(&input, RenderOptions::default());
573        assert!(html.contains("vertext-column-heading vertext-column-h2"));
574        assert!(html.contains("vertext-column-prose vertext-column-heading"));
575        assert!(!html.contains(heading_marker(2)));
576        // A Latin heading goes horizontal and carries its level there instead.
577        let latin = format!("{}Chinese{MODE_PROSE}山川", heading_marker(2));
578        let html = render_document(&latin, RenderOptions::default());
579        assert!(html.contains("vertext-horizontal-heading vertext-horizontal-h2"));
580    }
581
582    #[test]
583    fn headings_do_not_leak_into_the_following_prose() {
584        let input = format!("{}中文{MODE_PROSE}山川", heading_marker(2));
585        let html = render_document(&input, RenderOptions::default());
586        let heading_at = html.find("vertext-column-heading").unwrap();
587        let prose_at = html.rfind("vertext-column-prose\"").unwrap();
588        assert!(prose_at > heading_at, "the prose column must follow the heading column");
589    }
590
591    #[test]
592    fn empty_input_produces_a_well_formed_empty_strip() {
593        let html = render_document("", RenderOptions::default());
594        assert!(html.contains("<div class=\"vertext-column vertext-column-prose\"></div>"));
595        assert!(html.starts_with("<div class=\"vertext\""));
596    }
597
598    #[test]
599    fn prose_and_code_segments_get_their_own_column_classes() {
600        let input = format!("散文\n{MODE_CODE}let x = 1{MODE_PROSE}又散文");
601        let html = render_document(&input, RenderOptions::default());
602        // CJK prose stays vertical; the fenced code becomes a horizontal block.
603        assert!(html.contains("vertext-column-prose"));
604        assert!(html.contains("vertext-horizontal-code"));
605        // The markers themselves must never reach the output.
606        assert!(!html.contains(MODE_CODE));
607        assert!(!html.contains(MODE_PROSE));
608    }
609
610    #[test]
611    fn leading_code_marker_does_not_create_an_empty_prose_segment() {
612        let input = format!("{MODE_CODE}code{MODE_PROSE}");
613        let html = render_document(&input, RenderOptions::default());
614        assert!(!html.contains("vertext-column-prose\"><"));
615        assert!(html.contains("vertext-horizontal-code"));
616    }
617
618    #[test]
619    fn whole_strip_code_sets_root_class_and_ignores_markers() {
620        // `.vertext-code` is an explicit request for vertical code, so it
621        // must NOT be turned horizontal by the orientation rule.
622        let html = render_document("  let", RenderOptions { whole_strip_code: true, ..Default::default() });
623        assert!(html.starts_with("<div class=\"vertext vertext-code\""));
624        assert!(html.contains("vertext-space"), "indentation must survive");
625        assert!(html.contains("vertext-column-code"), "must stay vertical");
626        assert!(!html.contains("vertext-horizontal"));
627    }
628
629    #[test]
630    fn latin_caps_are_published_as_css_custom_properties() {
631        let html = render_document("字", RenderOptions::default());
632        assert!(html.contains("--vertext-latin-cap-prose:12ch"));
633        assert!(html.contains("--vertext-latin-cap-code:24ch"));
634    }
635
636    #[test]
637    fn progression_reaches_the_dom_as_data() {
638        let html = render_document("字", RenderOptions::default());
639        assert!(html.contains("data-column-advance=\"left\""));
640    }
641
642    #[test]
643    fn a_table_keeps_its_cells_apart() {
644        let input = format!(
645            "{MODE_TABLE}蒙古文{CELL_SEP}转写{ROW_SEP}ᠰᠠᠶᠢᠨ{CELL_SEP}sayin"
646        );
647        let html = render_document(&input, RenderOptions::default());
648        assert!(html.contains("<table class=\"vertext-table\">"));
649        assert!(html.contains("<th class=\"vertext-cell\">"));
650        assert!(html.contains("<td class=\"vertext-cell\">"));
651        // The failure this exists to prevent: cells welding into one run.
652        assert!(!html.contains("蒙古文转写"));
653        assert!(!html.contains("ᠰᠠᠶᠢᠨsayin"));
654        // The Mongolian cell keeps its joined run rather than per-glyph slots.
655        assert!(html.contains("<span class=\"vertext-mongolian\">ᠰᠠᠶᠢᠨ</span>"));
656        assert!(!html.contains(CELL_SEP));
657        assert!(!html.contains(ROW_SEP));
658    }
659
660    #[test]
661    fn a_table_stacks_with_the_text_around_it() {
662        // Caption above, table, commentary below — one stack, not three slots
663        // side by side.
664        let input = format!(
665            "A vocabulary table follows.{MODE_TABLE}x{CELL_SEP}y{MODE_PROSE}\
666             Read each column top to bottom."
667        );
668        let html = render_document(&input, RenderOptions::default());
669        assert_eq!(html.matches("vertext-hstack").count(), 1);
670        let stack = html.find("vertext-hstack").unwrap();
671        let table = html.find("vertext-table").unwrap();
672        let close = html.rfind("</div></div>").unwrap();
673        assert!(stack < table && table < close, "the table must sit inside the stack");
674    }
675
676    #[test]
677    fn table_cells_never_hyphenate() {
678        // A romanization broken across a hard hyphen is unusable as a
679        // citation form; the column width is the constraint instead.
680        let input = format!("{MODE_TABLE}x{CELL_SEP}bayarlal_a_bayartai_teyimu");
681        let html = render_document(&input, RenderOptions::default());
682        assert!(html.contains("bayarlal_a_bayartai_teyimu"));
683        assert!(!html.contains('‐'));
684    }
685
686    #[test]
687    fn bichig_in_a_horizontal_line_carries_a_face_it_can_join_with() {
688        // The defect this pins was measured in a browser before it was fixed:
689        // on the horizontal path the runs carried no class, the stylesheet's
690        // Mongolian family lives on one, and with init/medi/fina switched off
691        // the render did not change by a single pixel -- nothing was joining
692        // it. Real glyphs, wrong font, grammar severed.
693        let html = render_document(
694            "ene minU eji (ᠡᠨᠡ ᠮᠢᠨᠦ ᠡᠵᠢ) is my mother.",
695            RenderOptions::default(),
696        );
697        assert!(html.contains("vertext-horizontal-prose"), "this line is horizontal");
698        assert!(
699            html.contains("<span class=\"vertext-mongolian-inline\">ᠡᠨᠡ</span>"),
700            "each run is marked so the stylesheet can reach it: {html}"
701        );
702        // Not the vertical class: that one also declares writing-mode, which
703        // would stand the run upright inside a line of English.
704        assert!(!html.contains("\"vertext-mongolian\""));
705        // The Latin around it is untouched, and still escaped.
706        assert!(html.contains("ene minU eji ("));
707    }
708
709    #[test]
710    fn a_suffix_joint_stays_inside_one_inline_run() {
711        // Same reason the layout keeps U+202F inside Slot::MongolianRun: split
712        // across two spans, the font sees two words and the genitive breaks.
713        let html = render_document(
714            "the genitive ᠮᠣᠩᠭᠣᠯ\u{202F}ᠤᠨ is one word in this sentence",
715            RenderOptions::default(),
716        );
717        assert!(html.contains("vertext-horizontal-prose"));
718        assert!(
719            html.contains("<span class=\"vertext-mongolian-inline\">ᠮᠣᠩᠭᠣᠯ\u{202F}ᠤᠨ</span>"),
720            "the joint is inside the run: {html}"
721        );
722    }
723
724    #[test]
725    fn marking_runs_does_not_stop_escaping_the_rest() {
726        let html = render_document(
727            "a & b <tag> and ᠨᠣᠮ in a mostly Latin line of prose here",
728            RenderOptions::default(),
729        );
730        assert!(html.contains("vertext-horizontal-prose"));
731        assert!(html.contains("a &amp; b &lt;tag&gt;"), "{html}");
732        assert!(html.contains("<span class=\"vertext-mongolian-inline\">ᠨᠣᠮ</span>"));
733    }
734
735    #[test]
736    fn code_keeps_its_monospace_face() {
737        // Deliberate: a family change mid-line breaks the column alignment that
738        // is the whole point of setting code in monospace.
739        let input = format!("{MODE_CODE}let x = \"ᠨᠣᠮ\";{MODE_PROSE}");
740        let html = render_document(&input, RenderOptions::default());
741        assert!(html.contains("vertext-horizontal-code"));
742        assert!(!html.contains("vertext-mongolian-inline"), "{html}");
743    }
744
745    #[test]
746    fn latin_prose_is_set_horizontally_and_cjk_is_not() {
747        let english = render_document(
748            "It is a truth universally acknowledged, that a single man",
749            RenderOptions::default(),
750        );
751        assert!(english.contains("vertext-horizontal-prose"));
752        assert!(english.contains("--vertext-wrap:66ch"));
753        assert!(!english.contains("vertext-column-prose\">"));
754
755        let chinese = render_document("山川异域,风月同天。", RenderOptions::default());
756        assert!(chinese.contains("vertext-column-prose"));
757        assert!(!chinese.contains("vertext-horizontal"));
758    }
759
760    #[test]
761    fn fenced_code_is_horizontal_at_the_code_measure() {
762        let input = format!("{MODE_CODE}fn main() {{}}{MODE_PROSE}");
763        let html = render_document(&input, RenderOptions::default());
764        assert!(html.contains("vertext-horizontal-code"));
765        assert!(html.contains("--vertext-wrap:80ch"));
766        assert!(html.contains("<pre>"));
767        // Source must still be escaped inside the pre.
768        let injected = format!("{MODE_CODE}<script>{MODE_PROSE}");
769        assert!(!render_document(&injected, RenderOptions::default()).contains("<script>"));
770    }
771
772    #[test]
773    fn mongolian_progression_reaches_the_dom_and_the_layout() {
774        let options = RenderOptions {
775            progression: Progression::LeftToRight,
776            ..Default::default()
777        };
778        let html = render_document("ᠮᠣᠩᠭᠤᠯ\nᠤᠯᠤᠰ", options);
779        // `right` means columns advance rightward: vertical-lr, the Mongolian
780        // direction. Getting this backwards does not look wrong, it reads the
781        // document in reverse order.
782        assert!(html.contains("data-column-advance=\"right\""));
783        assert!(!html.contains("data-column-advance=\"left\""));
784        // And the default stays CJK for every document that does not declare.
785        let cjk = render_document("山川", RenderOptions::default());
786        assert!(cjk.contains("data-column-advance=\"left\""));
787    }
788
789    /// A case ending reaches the DOM inside its stem's span.
790    ///
791    /// This is the artifact the browser actually shapes. Split across two
792    /// spans with a `vertext-space` between them, the stylesheet gives that
793    /// space a fixed half-em box (`.vertext-space { height: .5em }`) and the
794    /// suffix drops a row: a genitive set as a separate word. One span is also
795    /// what lets the font join across the joint and keeps the browser from
796    /// breaking a line there, which U+202F forbids.
797    #[test]
798    fn a_suffix_separator_reaches_the_dom_inside_the_run() {
799        let options = RenderOptions {
800            progression: Progression::LeftToRight,
801            ..Default::default()
802        };
803        let html = render_document("ᠮᠣᠩᠭᠣᠯ\u{202F}ᠤᠨ", options);
804        assert!(html.contains("<span class=\"vertext-mongolian\">ᠮᠣᠩᠭᠣᠯ\u{202F}ᠤᠨ</span>"),
805            "the stem and its case ending must share one span: {html}");
806        assert!(!html.contains("vertext-space"),
807            "no word space belongs inside a suffixed word: {html}");
808    }
809
810    /// The renderer must never substitute a character. Presentation forms
811    /// like U+FE35 look right and destroy the document: copy-paste, find,
812    /// and screen readers all yield codepoints the author never typed. The
813    /// view rotates; the text is untouched.
814    #[test]
815    fn punctuation_is_never_substituted() {
816        let source = "好(天):川、月。—…「引」";
817        let html = render_document(source, RenderOptions::default());
818        for original in ['(', ')', ':', '、', '。', '—', '…', '「', '」'] {
819            assert!(html.contains(original), "{original} must survive verbatim");
820        }
821        // Nothing from the vertical presentation blocks may appear.
822        for ch in html.chars() {
823            let c = ch as u32;
824            assert!(!(0xFE10..=0xFE19).contains(&c), "presentation form {ch:?} leaked in");
825            assert!(!(0xFE30..=0xFE4F).contains(&c), "presentation form {ch:?} leaked in");
826        }
827    }
828
829    #[test]
830    fn page_mode_marks_the_root() {
831        let html = render_document("字", RenderOptions { page: true, ..Default::default() });
832        assert!(html.starts_with("<div class=\"vertext vertext-page\""));
833    }
834
835    const BLANK_COLUMN: &str = "<div class=\"vertext-column vertext-column-prose\"></div>";
836
837    #[test]
838    fn user_text_is_escaped() {
839        let html = render_document("<script>", RenderOptions::default());
840        assert!(!html.contains("<script>"));
841        assert!(html.contains("&lt;"));
842    }
843
844    #[test]
845    fn trailing_newline_before_mode_toggle_keeps_a_blank_column() {
846        let input = format!("散文\n{MODE_CODE}code{MODE_PROSE}");
847        let html = render_document(&input, RenderOptions::default());
848        assert!(html.contains("<div class=\"vertext-column vertext-column-prose\"></div>"));
849    }
850
851    // The three below divide one condition that used to be a single "were any
852    // trailing newlines trimmed?". Every file ends with a newline, so that
853    // question was answered yes for every document ever rendered, and each one
854    // carried a blank column at its foot holding open a column's width of
855    // nothing -- six on one real page. What the blank column is FOR is a
856    // break the author typed, which is why the toggle case above still keeps
857    // one and why a deliberate blank line at the end still counts.
858
859    #[test]
860    fn the_terminating_newline_is_not_a_paragraph_break() {
861        let html = render_document("散文\n", RenderOptions::default());
862        assert!(!html.contains(BLANK_COLUMN), "{html}");
863    }
864
865    #[test]
866    fn a_blank_line_left_at_the_end_still_is_one() {
867        let html = render_document("散文\n\n", RenderOptions::default());
868        assert!(html.contains(BLANK_COLUMN), "{html}");
869    }
870
871    #[test]
872    fn crlf_answers_the_same_as_lf_at_the_end() {
873        let terminator = render_document("散文\r\n", RenderOptions::default());
874        let break_too = render_document("散文\r\n\r\n", RenderOptions::default());
875        assert!(!terminator.contains(BLANK_COLUMN), "{terminator}");
876        assert!(break_too.contains(BLANK_COLUMN), "{break_too}");
877    }
878}