1use vertext_core::{
9 is_mongolian, layout_text, prefers_horizontal, HorizontalKind, Layout, LayoutConfig,
10 Progression, Slot,
11};
12
13pub const MODE_CODE: char = '\u{E000}';
27pub const MODE_PROSE: char = '\u{E001}';
28pub const MODE_HEADING_BASE: u32 = 0xE002;
30pub const MAX_HEADING_LEVEL: u8 = 6;
31pub const MODE_TABLE: char = '\u{E008}';
35pub const CELL_SEP: char = '\u{E009}';
36pub const ROW_SEP: char = '\u{E00A}';
37pub const MODE_LIST: char = '\u{E00B}';
42pub const MODE_LIST_ORDERED: char = '\u{E00C}';
46pub const RESERVED_END: u32 = 0xE00D;
53
54pub 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
60pub 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('<', "<")
85 .replace('>', ">")
86 .replace('"', """)
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 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 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 pub whole_strip_code: bool,
183 pub page: bool,
186 pub progression: Progression,
193}
194
195impl Default for RenderOptions {
196 fn default() -> Self {
197 Self {
198 whole_strip_code: false,
199 page: false,
200 progression: Progression::RightToLeft,
203 }
204 }
205}
206
207pub 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 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 let trailing_newlines = segment_text[trimmed.len()..].matches('\n').count();
260 if trimmed.is_empty() {
266 continue;
267 }
268 emitted_any = true;
269 let block = if options.whole_strip_code { BlockKind::Vertical } else { mode.block(trimmed) };
274
275 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 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 html.push_str("<div class=\"vertext-column vertext-column-prose\"></div>");
334 }
335 html.push_str("</div>\n");
336 html
337}
338
339fn 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 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
431fn 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 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 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
496pub fn column_advance(layout: &Layout) -> &'static str {
498 advance_keyword(layout.progression)
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504
505 #[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 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 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 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 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 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 assert!(html.contains("vertext-column-prose"));
604 assert!(html.contains("vertext-horizontal-code"));
605 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 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 assert!(!html.contains("蒙古文转写"));
653 assert!(!html.contains("ᠰᠠᠶᠢᠨsayin"));
654 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 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 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 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 assert!(!html.contains("\"vertext-mongolian\""));
705 assert!(html.contains("ene minU eji ("));
707 }
708
709 #[test]
710 fn a_suffix_joint_stays_inside_one_inline_run() {
711 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 & b <tag>"), "{html}");
732 assert!(html.contains("<span class=\"vertext-mongolian-inline\">ᠨᠣᠮ</span>"));
733 }
734
735 #[test]
736 fn code_keeps_its_monospace_face() {
737 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 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 assert!(html.contains("data-column-advance=\"right\""));
783 assert!(!html.contains("data-column-advance=\"left\""));
784 let cjk = render_document("山川", RenderOptions::default());
786 assert!(cjk.contains("data-column-advance=\"left\""));
787 }
788
789 #[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 #[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 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("<"));
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 #[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}