1use vertext_core::{
9 layout_text, prefers_horizontal, HorizontalKind, Layout, LayoutConfig, Progression, Slot,
10};
11
12pub const MODE_CODE: char = '\u{E000}';
26pub const MODE_PROSE: char = '\u{E001}';
27pub const MODE_HEADING_BASE: u32 = 0xE002;
29pub const MAX_HEADING_LEVEL: u8 = 6;
30pub const MODE_TABLE: char = '\u{E008}';
34pub const CELL_SEP: char = '\u{E009}';
35pub const ROW_SEP: char = '\u{E00A}';
36pub const MODE_LIST: char = '\u{E00B}';
41pub const MODE_LIST_ORDERED: char = '\u{E00C}';
45pub const RESERVED_END: u32 = 0xE00C;
48
49pub 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
55pub 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('<', "<")
80 .replace('>', ">")
81 .replace('"', """)
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 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 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 pub whole_strip_code: bool,
178 pub page: bool,
181 pub progression: Progression,
188}
189
190impl Default for RenderOptions {
191 fn default() -> Self {
192 Self {
193 whole_strip_code: false,
194 page: false,
195 progression: Progression::RightToLeft,
198 }
199 }
200}
201
202pub 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 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 if trimmed.is_empty() {
255 continue;
256 }
257 emitted_any = true;
258 let block = if options.whole_strip_code { BlockKind::Vertical } else { mode.block(trimmed) };
263
264 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 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 html.push_str("<div class=\"vertext-column vertext-column-prose\"></div>");
309 }
310 html.push_str("</div>\n");
311 html
312}
313
314fn 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
340fn 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 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 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
405pub 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 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 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 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 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 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 assert!(html.contains("vertext-column-prose"));
481 assert!(html.contains("vertext-horizontal-code"));
482 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 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 assert!(!html.contains("蒙古文转写"));
530 assert!(!html.contains("ᠰᠠᠶᠢᠨsayin"));
531 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 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 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 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 assert!(html.contains("data-column-advance=\"right\""));
601 assert!(!html.contains("data-column-advance=\"left\""));
602 let cjk = render_document("山川", RenderOptions::default());
604 assert!(cjk.contains("data-column-advance=\"left\""));
605 }
606
607 #[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 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("<"));
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}