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    layout_text, prefers_horizontal, HorizontalKind, Layout, LayoutConfig, Progression, Slot,
10};
11
12/// Reserved private-use markers that the Quarto filter inserts around a
13/// segment so a single invocation can switch mid-stream between rule sets.
14/// Source authors never type these, which is why they live in the Unicode
15/// Private Use Area.
16///
17/// A marker means "everything after me is this kind of segment, until the
18/// next marker". Structure that markdown expresses and a flat string cannot —
19/// a heading is not a paragraph that happens to be short — has to cross the
20/// boundary somehow, and this is the seam it crosses.
21///
22/// Wire protocol note: `extensions/vertext/vertext.lua` carries the same
23/// codepoints as string literals. The `mode_markers_are_the_wire_protocol`
24/// test pins the values so a drift on the Rust side cannot pass silently.
25pub const MODE_CODE: char = '\u{E000}';
26pub const MODE_PROSE: char = '\u{E001}';
27/// Heading levels 1–6 occupy U+E002–U+E007.
28pub const MODE_HEADING_BASE: u32 = 0xE002;
29pub const MAX_HEADING_LEVEL: u8 = 6;
30/// A table segment. Within it, cells are separated by [`CELL_SEP`] and rows by
31/// [`ROW_SEP`]; a table is 2-D and the wire is a flat string, so the structure
32/// needs separators rather than a mode alone.
33pub const MODE_TABLE: char = '\u{E008}';
34pub const CELL_SEP: char = '\u{E009}';
35pub const ROW_SEP: char = '\u{E00A}';
36/// One list item. Each item is its own segment: flattening a whole list into
37/// a single blob welds the items together *and* mixes their scripts, so a
38/// list of mostly-CJK items with Latin terms in them gets classified by the
39/// aggregate rather than item by item.
40pub const MODE_LIST: char = '\u{E00B}';
41/// One item of a *numbered* list. Ordered items already carry their number in
42/// the text, so they must not also be given a bullet; a marker of their own
43/// is what lets the stylesheet tell them apart.
44pub const MODE_LIST_ORDERED: char = '\u{E00C}';
45/// One past the last reserved codepoint. The filter strips this whole range
46/// from author text; keep the two in step.
47pub const RESERVED_END: u32 = 0xE00C;
48
49/// The marker introducing a heading of `level` (clamped to 1–6).
50pub fn heading_marker(level: u8) -> char {
51    let level = level.clamp(1, MAX_HEADING_LEVEL);
52    char::from_u32(MODE_HEADING_BASE + u32::from(level) - 1).expect("heading marker in PUA")
53}
54
55/// Single source of truth for the Latin slot caps. The renderer publishes
56/// them to CSS as custom properties on the root element, so the stylesheet
57/// never hardcodes a width that could drift from the layout.
58pub const PROSE_LATIN_CAP: usize = 12;
59pub const CODE_LATIN_CAP: usize = 24;
60
61pub fn prose_config(progression: Progression) -> LayoutConfig {
62    LayoutConfig {
63        max_latin_word_width: PROSE_LATIN_CAP,
64        preserve_spaces: false,
65        progression,
66    }
67}
68
69pub fn code_config(progression: Progression) -> LayoutConfig {
70    LayoutConfig {
71        max_latin_word_width: CODE_LATIN_CAP,
72        preserve_spaces: true,
73        progression,
74    }
75}
76
77pub fn escape(text: &str) -> String {
78    text.replace('&', "&")
79        .replace('<', "&lt;")
80        .replace('>', "&gt;")
81        .replace('"', "&quot;")
82}
83
84fn advance_keyword(progression: Progression) -> &'static str {
85    match progression {
86        Progression::RightToLeft => "left",
87        Progression::LeftToRight => "right",
88    }
89}
90
91#[derive(Clone, Copy, PartialEq, Eq, Debug)]
92enum Mode {
93    Prose,
94    Code,
95    Heading(u8),
96    Table,
97    ListItem { ordered: bool },
98}
99
100impl Mode {
101    fn from_marker(ch: char) -> Option<Mode> {
102        if ch == MODE_CODE {
103            return Some(Mode::Code);
104        }
105        if ch == MODE_PROSE {
106            return Some(Mode::Prose);
107        }
108        if ch == MODE_TABLE {
109            return Some(Mode::Table);
110        }
111        if ch == MODE_LIST {
112            return Some(Mode::ListItem { ordered: false });
113        }
114        if ch == MODE_LIST_ORDERED {
115            return Some(Mode::ListItem { ordered: true });
116        }
117        let offset = (ch as u32).checked_sub(MODE_HEADING_BASE)?;
118        (offset < u32::from(MAX_HEADING_LEVEL)).then(|| Mode::Heading(offset as u8 + 1))
119    }
120
121    fn config(self, progression: Progression) -> LayoutConfig {
122        match self {
123            // A heading is prose that happens to be short and loud. It gets
124            // the prose rule set; only its presentation differs.
125            Mode::Prose | Mode::Heading(_) | Mode::Table | Mode::ListItem { .. } => prose_config(progression),
126            Mode::Code => code_config(progression),
127        }
128    }
129
130    fn column_class(self) -> String {
131        match self {
132            Mode::Prose | Mode::Table => "vertext-column vertext-column-prose".to_string(),
133            Mode::ListItem { ordered } => {
134                let kind = if ordered { "vertext-column-list-ordered" } else { "vertext-column-list-bullet" };
135                format!("vertext-column vertext-column-prose vertext-column-list {kind}")
136            }
137            Mode::Code => "vertext-column vertext-column-code".to_string(),
138            Mode::Heading(level) => format!(
139                "vertext-column vertext-column-prose vertext-column-heading vertext-column-h{level}"
140            ),
141        }
142    }
143
144    /// Which way this segment is set.
145    ///
146    /// Code is always horizontal — program source has a left-to-right reading
147    /// order built into its own syntax, and Japanese and Chinese technical
148    /// publishing has set code horizontally inside vertical books for decades.
149    /// Prose is decided by which script carries the line. Headings follow the
150    /// body so a section title never sits at odds with the section.
151    fn block(self, text: &str) -> BlockKind {
152        match self {
153            Mode::Table => BlockKind::Table,
154            Mode::Code => BlockKind::Horizontal(HorizontalKind::Code),
155            Mode::Prose | Mode::Heading(_) | Mode::ListItem { .. } => {
156                if prefers_horizontal(text) {
157                    BlockKind::Horizontal(HorizontalKind::Prose)
158                } else {
159                    BlockKind::Vertical
160                }
161            }
162        }
163    }
164}
165
166#[derive(Clone, Copy, PartialEq, Eq, Debug)]
167enum BlockKind {
168    Vertical,
169    Horizontal(HorizontalKind),
170    Table,
171}
172
173#[derive(Clone, Copy, Debug)]
174pub struct RenderOptions {
175    /// Lay the entire input out under the code rule set, ignore the mode
176    /// markers, and put `vertext-code` on the root.
177    pub whole_strip_code: bool,
178    /// Put `vertext-page` on the root. The stylesheet keys full-page vertical
179    /// flow (native `writing-mode` on the surrounding page) off this class.
180    pub page: bool,
181    /// Which way columns advance. A property of the document's script, and
182    /// the one thing an engine must never hardcode: every renderer that fixes
183    /// this to right-to-left has decided permanently which literatures it can
184    /// carry. Declared by the author, because it cannot be inferred — a
185    /// Chinese document teaching Mongolian and a Mongolian document teaching
186    /// Chinese contain the same scripts and want opposite answers.
187    pub progression: Progression,
188}
189
190impl Default for RenderOptions {
191    fn default() -> Self {
192        Self {
193            whole_strip_code: false,
194            page: false,
195            // CJK is the bulk of vertical text on the web; Mongolian
196            // documents declare. Neither is privileged in the model.
197            progression: Progression::RightToLeft,
198        }
199    }
200}
201
202/// Renders one `.vertext` strip to HTML.
203///
204/// Without [`RenderOptions::whole_strip_code`] the input is split at the
205/// reserved markers into prose/code segments, each laid out under its own
206/// rule set.
207pub fn render_document(input: &str, options: RenderOptions) -> String {
208    let mut segments: Vec<(Mode, String)> = Vec::new();
209    if options.whole_strip_code {
210        segments.push((Mode::Code, input.to_owned()));
211    } else {
212        let mut current = (Mode::Prose, String::new());
213        for ch in input.chars() {
214            match Mode::from_marker(ch) {
215                // A marker at the very start has nothing to close, so it
216                // retargets the open segment instead of emitting an empty one.
217                Some(mode) if current.1.is_empty() && segments.is_empty() => {
218                    current = (mode, String::new());
219                }
220                Some(mode) => {
221                    segments.push(std::mem::replace(&mut current, (mode, String::new())));
222                }
223                None => current.1.push(ch),
224            }
225        }
226        if !current.1.is_empty() || segments.is_empty() {
227            segments.push(current);
228        }
229    }
230
231    let mut root_class = String::from("vertext");
232    if options.whole_strip_code {
233        root_class.push_str(" vertext-code");
234    }
235    if options.page {
236        root_class.push_str(" vertext-page");
237    }
238    let advance = advance_keyword(options.progression);
239    let mut html = format!(
240        "<div class=\"{root_class}\" data-column-advance=\"{advance}\" \
241         style=\"--vertext-latin-cap-prose:{PROSE_LATIN_CAP}ch;\
242         --vertext-latin-cap-code:{CODE_LATIN_CAP}ch\">"
243    );
244
245    let mut emitted_any = false;
246    let mut in_stack = false;
247    for (mode, segment_text) in segments {
248        let trimmed = segment_text.trim_end_matches('\n');
249        // A wholly blank segment carries nothing and must stay transparent.
250        // The separator newline between a heading and the fenced block under
251        // it produces one, and treating it as content made it a vertical block
252        // that split the two apart — the heading ended one horizontal stack
253        // and its own code block started another.
254        if trimmed.is_empty() {
255            continue;
256        }
257        emitted_any = true;
258        // `.vertext-code` is the author declaring "set this vertically as
259        // code" — the showcase case. A fenced block inside ordinary prose is
260        // the opposite instruction and goes horizontal. Same content, and the
261        // difference is what the author asked for, never what we guessed.
262        let block = if options.whole_strip_code { BlockKind::Vertical } else { mode.block(trimmed) };
263
264        // Consecutive horizontal blocks stack vertically instead of each
265        // claiming its own slot beside the columns. Without this a one-word
266        // English heading takes a full column's width and leaves the height of
267        // the page empty beneath it, with its own paragraph stranded in the
268        // next slot over. Stacked, the heading sits on top and its text runs
269        // underneath — which is how a heading and its paragraph relate.
270        //
271        // Tables join the stack for the same reason: a table's caption line
272        // belongs above it and its commentary below, not beside it. A table
273        // that happens to sit among vertical columns simply ends up alone in
274        // its stack, which lays out exactly as it did before.
275        let horizontal = matches!(block, BlockKind::Horizontal(_) | BlockKind::Table);
276        if horizontal && !in_stack {
277            html.push_str("<div class=\"vertext-hstack\">");
278            in_stack = true;
279        } else if !horizontal && in_stack {
280            html.push_str("</div>");
281            in_stack = false;
282        }
283
284        match block {
285            BlockKind::Table => render_table(&mut html, trimmed, options.progression),
286            BlockKind::Horizontal(kind) => render_horizontal(&mut html, trimmed, kind, mode),
287            BlockKind::Vertical => {
288                let layout = layout_text(trimmed, &mode.config(options.progression));
289                let column_class = mode.column_class();
290                for column in &layout.columns {
291                    html.push_str(&format!("<div class=\"{column_class}\">"));
292                    render_slots(&mut html, &column.slots);
293                    html.push_str("</div>");
294                }
295                // Preserve a blank column for a paragraph break that ends the
296                // segment (source newline immediately before a mode toggle).
297                if trimmed.len() < segment_text.len() && !layout.columns.is_empty() {
298                    html.push_str(&format!("<div class=\"{column_class}\"></div>"));
299                }
300            }
301        }
302    }
303    if in_stack {
304        html.push_str("</div>");
305    }
306    if !emitted_any {
307        // Empty input must still produce a well-formed empty strip.
308        html.push_str("<div class=\"vertext-column vertext-column-prose\"></div>");
309    }
310    html.push_str("</div>\n");
311    html
312}
313
314/// A horizontal block: an orthogonal island in the vertical flow.
315///
316/// The wrap measure is published as a custom property rather than baked into
317/// the stylesheet, for the same reason the Latin caps are — one source, no
318/// drift. Line breaking is left to the browser, which has the font metrics.
319fn render_horizontal(html: &mut String, text: &str, kind: HorizontalKind, mode: Mode) {
320    let (kind_class, wrap) = match kind {
321        HorizontalKind::Prose => ("vertext-horizontal-prose", kind.default_wrap()),
322        HorizontalKind::Code => ("vertext-horizontal-code", kind.default_wrap()),
323    };
324    let heading_class = match mode {
325        Mode::Heading(level) => format!(" vertext-horizontal-heading vertext-horizontal-h{level}"),
326        Mode::ListItem { ordered } => {
327            let kind = if ordered { " vertext-horizontal-list-ordered" } else { " vertext-horizontal-list-bullet" };
328            format!(" vertext-horizontal-list{kind}")
329        }
330        _ => String::new(),
331    };
332    let tag = if matches!(kind, HorizontalKind::Code) { "pre" } else { "div" };
333    html.push_str(&format!(
334        "<div class=\"vertext-horizontal {kind_class}{heading_class}\" \
335         style=\"--vertext-wrap:{wrap}ch\"><{tag}>{}</{tag}></div>",
336        escape(text)
337    ));
338}
339
340/// A table. Rows are separated by [`ROW_SEP`] and cells by [`CELL_SEP`].
341///
342/// Vertical text is the one place a table's structure falls out for free: a
343/// row set as a column reads top-to-bottom as one entry, and successive rows
344/// advance the way the surrounding text does. A markup `<table>` under
345/// `writing-mode: vertical-rl` does exactly that transposition natively, so
346/// the row stays a `<tr>` and the browser places it — no transposing here,
347/// which keeps the markup honest for screen readers and for `display: block`
348/// fallbacks.
349///
350/// Cell contents go through the ordinary slot layout, so a Mongolian cell
351/// keeps its joined run and a Latin cell keeps its word slots. Cells do not
352/// hyphenate: the column width is the constraint, and a romanization broken
353/// across a hard hyphen is unreadable as a citation form.
354fn render_table(html: &mut String, text: &str, progression: Progression) {
355    let config = LayoutConfig { max_latin_word_width: usize::MAX, ..prose_config(progression) };
356    html.push_str("<table class=\"vertext-table\">");
357    for (index, row) in text.split(ROW_SEP).enumerate() {
358        if row.is_empty() {
359            continue;
360        }
361        let header = index == 0;
362        let cell_tag = if header { "th" } else { "td" };
363        html.push_str(if header {
364            "<thead><tr class=\"vertext-row vertext-row-header\">"
365        } else {
366            "<tr class=\"vertext-row\">"
367        });
368        for cell in row.split(CELL_SEP) {
369            html.push_str(&format!("<{cell_tag} class=\"vertext-cell\">"));
370            let layout = layout_text(cell, &config);
371            for column in &layout.columns {
372                html.push_str("<div class=\"vertext-column vertext-column-cell\">");
373                render_slots(html, &column.slots);
374                html.push_str("</div>");
375            }
376            html.push_str(&format!("</{cell_tag}>"));
377        }
378        html.push_str(if header { "</tr></thead><tbody>" } else { "</tr>" });
379    }
380    html.push_str("</tbody></table>");
381}
382
383fn render_slots(html: &mut String, slots: &[Slot]) {
384    for slot in slots {
385        // Whitespace is emitted as the character the author typed, never a
386        // stand-in glyph. Code indentation is made visible by the stylesheet
387        // instead — a background, not a substitution, so the text a reader
388        // copies is the text a writer wrote.
389        let (class, body) = match slot {
390            Slot::Upright(s) => ("vertext-upright", escape(s)),
391            Slot::LatinWord(s) => ("vertext-latin", escape(s)),
392            Slot::MongolianRun(s) => ("vertext-mongolian", escape(s)),
393            Slot::Space(s) => ("vertext-space", escape(s)),
394            // The character is emitted exactly as the author wrote it. The
395            // vertical appearance is the stylesheet's job — see the note on
396            // `Slot::VerticalPunctuation`.
397            Slot::VerticalPunctuation(s) => ("vertext-vform", escape(s)),
398            Slot::CornerPunctuation(s) => ("vertext-corner", escape(s)),
399            Slot::Neutral(s) => ("vertext-neutral", escape(s)),
400        };
401        html.push_str(&format!("<span class=\"{class}\">{body}</span>"));
402    }
403}
404
405/// Exposes the advance keyword for hosts that render their own shell.
406pub fn column_advance(layout: &Layout) -> &'static str {
407    advance_keyword(layout.progression)
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    #[test]
415    fn mode_markers_are_the_wire_protocol() {
416        // These codepoints are duplicated as literals in
417        // extensions/vertext/vertext.lua. Do not change one side alone.
418        assert_eq!(MODE_CODE, '\u{E000}');
419        assert_eq!(MODE_PROSE, '\u{E001}');
420        assert_eq!(heading_marker(1), '\u{E002}');
421        assert_eq!(heading_marker(6), '\u{E007}');
422        // Out-of-range levels clamp rather than producing a stray codepoint.
423        assert_eq!(heading_marker(0), heading_marker(1));
424        assert_eq!(heading_marker(9), heading_marker(6));
425    }
426
427    #[test]
428    fn every_marker_round_trips_to_its_mode() {
429        assert_eq!(Mode::from_marker(MODE_CODE), Some(Mode::Code));
430        assert_eq!(Mode::from_marker(MODE_PROSE), Some(Mode::Prose));
431        assert_eq!(Mode::from_marker(MODE_TABLE), Some(Mode::Table));
432        assert_eq!(Mode::from_marker(MODE_LIST), Some(Mode::ListItem { ordered: false }));
433        assert_eq!(Mode::from_marker(MODE_LIST_ORDERED), Some(Mode::ListItem { ordered: true }));
434        for level in 1..=MAX_HEADING_LEVEL {
435            assert_eq!(Mode::from_marker(heading_marker(level)), Some(Mode::Heading(level)));
436        }
437        // Ordinary text must never be mistaken for a marker.
438        // '\u{E00D}' is one past the last reserved codepoint and '\u{D7FF}'
439        // sits below the whole block. Both must read as ordinary text.
440        for ch in ['字', 'a', '\u{E00D}', '\u{D7FF}', '\u{E7FF}'] {
441            assert_eq!(Mode::from_marker(ch), None, "{ch:?} should not be a marker");
442        }
443    }
444
445    #[test]
446    fn a_heading_segment_gets_its_level_on_the_column() {
447        // A CJK heading stays vertical and carries its level on the column.
448        let input = format!("{}中文{MODE_PROSE}山川", heading_marker(2));
449        let html = render_document(&input, RenderOptions::default());
450        assert!(html.contains("vertext-column-heading vertext-column-h2"));
451        assert!(html.contains("vertext-column-prose vertext-column-heading"));
452        assert!(!html.contains(heading_marker(2)));
453        // A Latin heading goes horizontal and carries its level there instead.
454        let latin = format!("{}Chinese{MODE_PROSE}山川", heading_marker(2));
455        let html = render_document(&latin, RenderOptions::default());
456        assert!(html.contains("vertext-horizontal-heading vertext-horizontal-h2"));
457    }
458
459    #[test]
460    fn headings_do_not_leak_into_the_following_prose() {
461        let input = format!("{}中文{MODE_PROSE}山川", heading_marker(2));
462        let html = render_document(&input, RenderOptions::default());
463        let heading_at = html.find("vertext-column-heading").unwrap();
464        let prose_at = html.rfind("vertext-column-prose\"").unwrap();
465        assert!(prose_at > heading_at, "the prose column must follow the heading column");
466    }
467
468    #[test]
469    fn empty_input_produces_a_well_formed_empty_strip() {
470        let html = render_document("", RenderOptions::default());
471        assert!(html.contains("<div class=\"vertext-column vertext-column-prose\"></div>"));
472        assert!(html.starts_with("<div class=\"vertext\""));
473    }
474
475    #[test]
476    fn prose_and_code_segments_get_their_own_column_classes() {
477        let input = format!("散文\n{MODE_CODE}let x = 1{MODE_PROSE}又散文");
478        let html = render_document(&input, RenderOptions::default());
479        // CJK prose stays vertical; the fenced code becomes a horizontal block.
480        assert!(html.contains("vertext-column-prose"));
481        assert!(html.contains("vertext-horizontal-code"));
482        // The markers themselves must never reach the output.
483        assert!(!html.contains(MODE_CODE));
484        assert!(!html.contains(MODE_PROSE));
485    }
486
487    #[test]
488    fn leading_code_marker_does_not_create_an_empty_prose_segment() {
489        let input = format!("{MODE_CODE}code{MODE_PROSE}");
490        let html = render_document(&input, RenderOptions::default());
491        assert!(!html.contains("vertext-column-prose\"><"));
492        assert!(html.contains("vertext-horizontal-code"));
493    }
494
495    #[test]
496    fn whole_strip_code_sets_root_class_and_ignores_markers() {
497        // `.vertext-code` is an explicit request for vertical code, so it
498        // must NOT be turned horizontal by the orientation rule.
499        let html = render_document("  let", RenderOptions { whole_strip_code: true, ..Default::default() });
500        assert!(html.starts_with("<div class=\"vertext vertext-code\""));
501        assert!(html.contains("vertext-space"), "indentation must survive");
502        assert!(html.contains("vertext-column-code"), "must stay vertical");
503        assert!(!html.contains("vertext-horizontal"));
504    }
505
506    #[test]
507    fn latin_caps_are_published_as_css_custom_properties() {
508        let html = render_document("字", RenderOptions::default());
509        assert!(html.contains("--vertext-latin-cap-prose:12ch"));
510        assert!(html.contains("--vertext-latin-cap-code:24ch"));
511    }
512
513    #[test]
514    fn progression_reaches_the_dom_as_data() {
515        let html = render_document("字", RenderOptions::default());
516        assert!(html.contains("data-column-advance=\"left\""));
517    }
518
519    #[test]
520    fn a_table_keeps_its_cells_apart() {
521        let input = format!(
522            "{MODE_TABLE}蒙古文{CELL_SEP}转写{ROW_SEP}ᠰᠠᠶᠢᠨ{CELL_SEP}sayin"
523        );
524        let html = render_document(&input, RenderOptions::default());
525        assert!(html.contains("<table class=\"vertext-table\">"));
526        assert!(html.contains("<th class=\"vertext-cell\">"));
527        assert!(html.contains("<td class=\"vertext-cell\">"));
528        // The failure this exists to prevent: cells welding into one run.
529        assert!(!html.contains("蒙古文转写"));
530        assert!(!html.contains("ᠰᠠᠶᠢᠨsayin"));
531        // The Mongolian cell keeps its joined run rather than per-glyph slots.
532        assert!(html.contains("<span class=\"vertext-mongolian\">ᠰᠠᠶᠢᠨ</span>"));
533        assert!(!html.contains(CELL_SEP));
534        assert!(!html.contains(ROW_SEP));
535    }
536
537    #[test]
538    fn a_table_stacks_with_the_text_around_it() {
539        // Caption above, table, commentary below — one stack, not three slots
540        // side by side.
541        let input = format!(
542            "A vocabulary table follows.{MODE_TABLE}x{CELL_SEP}y{MODE_PROSE}\
543             Read each column top to bottom."
544        );
545        let html = render_document(&input, RenderOptions::default());
546        assert_eq!(html.matches("vertext-hstack").count(), 1);
547        let stack = html.find("vertext-hstack").unwrap();
548        let table = html.find("vertext-table").unwrap();
549        let close = html.rfind("</div></div>").unwrap();
550        assert!(stack < table && table < close, "the table must sit inside the stack");
551    }
552
553    #[test]
554    fn table_cells_never_hyphenate() {
555        // A romanization broken across a hard hyphen is unusable as a
556        // citation form; the column width is the constraint instead.
557        let input = format!("{MODE_TABLE}x{CELL_SEP}bayarlal_a_bayartai_teyimu");
558        let html = render_document(&input, RenderOptions::default());
559        assert!(html.contains("bayarlal_a_bayartai_teyimu"));
560        assert!(!html.contains('‐'));
561    }
562
563    #[test]
564    fn latin_prose_is_set_horizontally_and_cjk_is_not() {
565        let english = render_document(
566            "It is a truth universally acknowledged, that a single man",
567            RenderOptions::default(),
568        );
569        assert!(english.contains("vertext-horizontal-prose"));
570        assert!(english.contains("--vertext-wrap:66ch"));
571        assert!(!english.contains("vertext-column-prose\">"));
572
573        let chinese = render_document("山川异域,风月同天。", RenderOptions::default());
574        assert!(chinese.contains("vertext-column-prose"));
575        assert!(!chinese.contains("vertext-horizontal"));
576    }
577
578    #[test]
579    fn fenced_code_is_horizontal_at_the_code_measure() {
580        let input = format!("{MODE_CODE}fn main() {{}}{MODE_PROSE}");
581        let html = render_document(&input, RenderOptions::default());
582        assert!(html.contains("vertext-horizontal-code"));
583        assert!(html.contains("--vertext-wrap:80ch"));
584        assert!(html.contains("<pre>"));
585        // Source must still be escaped inside the pre.
586        let injected = format!("{MODE_CODE}<script>{MODE_PROSE}");
587        assert!(!render_document(&injected, RenderOptions::default()).contains("<script>"));
588    }
589
590    #[test]
591    fn mongolian_progression_reaches_the_dom_and_the_layout() {
592        let options = RenderOptions {
593            progression: Progression::LeftToRight,
594            ..Default::default()
595        };
596        let html = render_document("ᠮᠣᠩᠭᠤᠯ\nᠤᠯᠤᠰ", options);
597        // `right` means columns advance rightward: vertical-lr, the Mongolian
598        // direction. Getting this backwards does not look wrong, it reads the
599        // document in reverse order.
600        assert!(html.contains("data-column-advance=\"right\""));
601        assert!(!html.contains("data-column-advance=\"left\""));
602        // And the default stays CJK for every document that does not declare.
603        let cjk = render_document("山川", RenderOptions::default());
604        assert!(cjk.contains("data-column-advance=\"left\""));
605    }
606
607    /// The renderer must never substitute a character. Presentation forms
608    /// like U+FE35 look right and destroy the document: copy-paste, find,
609    /// and screen readers all yield codepoints the author never typed. The
610    /// view rotates; the text is untouched.
611    #[test]
612    fn punctuation_is_never_substituted() {
613        let source = "好(天):川、月。—…「引」";
614        let html = render_document(source, RenderOptions::default());
615        for original in ['(', ')', ':', '、', '。', '—', '…', '「', '」'] {
616            assert!(html.contains(original), "{original} must survive verbatim");
617        }
618        // Nothing from the vertical presentation blocks may appear.
619        for ch in html.chars() {
620            let c = ch as u32;
621            assert!(!(0xFE10..=0xFE19).contains(&c), "presentation form {ch:?} leaked in");
622            assert!(!(0xFE30..=0xFE4F).contains(&c), "presentation form {ch:?} leaked in");
623        }
624    }
625
626    #[test]
627    fn page_mode_marks_the_root() {
628        let html = render_document("字", RenderOptions { page: true, ..Default::default() });
629        assert!(html.starts_with("<div class=\"vertext vertext-page\""));
630    }
631
632    #[test]
633    fn user_text_is_escaped() {
634        let html = render_document("<script>", RenderOptions::default());
635        assert!(!html.contains("<script>"));
636        assert!(html.contains("&lt;"));
637    }
638
639    #[test]
640    fn trailing_newline_before_mode_toggle_keeps_a_blank_column() {
641        let input = format!("散文\n{MODE_CODE}code{MODE_PROSE}");
642        let html = render_document(&input, RenderOptions::default());
643        assert!(html.contains("<div class=\"vertext-column vertext-column-prose\"></div>"));
644    }
645}