use vertext_core::{
is_mongolian, layout_text, prefers_horizontal, HorizontalKind, Layout, LayoutConfig,
Progression, Slot,
};
pub const MODE_CODE: char = '\u{E000}';
pub const MODE_PROSE: char = '\u{E001}';
pub const MODE_HEADING_BASE: u32 = 0xE002;
pub const MAX_HEADING_LEVEL: u8 = 6;
pub const MODE_TABLE: char = '\u{E008}';
pub const CELL_SEP: char = '\u{E009}';
pub const ROW_SEP: char = '\u{E00A}';
pub const MODE_LIST: char = '\u{E00B}';
pub const MODE_LIST_ORDERED: char = '\u{E00C}';
pub const RESERVED_END: u32 = 0xE00D;
pub fn heading_marker(level: u8) -> char {
let level = level.clamp(1, MAX_HEADING_LEVEL);
char::from_u32(MODE_HEADING_BASE + u32::from(level) - 1).expect("heading marker in PUA")
}
pub const PROSE_LATIN_CAP: usize = 12;
pub const CODE_LATIN_CAP: usize = 24;
pub fn prose_config(progression: Progression) -> LayoutConfig {
LayoutConfig {
max_latin_word_width: PROSE_LATIN_CAP,
preserve_spaces: false,
progression,
}
}
pub fn code_config(progression: Progression) -> LayoutConfig {
LayoutConfig {
max_latin_word_width: CODE_LATIN_CAP,
preserve_spaces: true,
progression,
}
}
pub fn escape(text: &str) -> String {
text.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
fn advance_keyword(progression: Progression) -> &'static str {
match progression {
Progression::RightToLeft => "left",
Progression::LeftToRight => "right",
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Mode {
Prose,
Code,
Heading(u8),
Table,
ListItem { ordered: bool },
}
impl Mode {
fn from_marker(ch: char) -> Option<Mode> {
if ch == MODE_CODE {
return Some(Mode::Code);
}
if ch == MODE_PROSE {
return Some(Mode::Prose);
}
if ch == MODE_TABLE {
return Some(Mode::Table);
}
if ch == MODE_LIST {
return Some(Mode::ListItem { ordered: false });
}
if ch == MODE_LIST_ORDERED {
return Some(Mode::ListItem { ordered: true });
}
let offset = (ch as u32).checked_sub(MODE_HEADING_BASE)?;
(offset < u32::from(MAX_HEADING_LEVEL)).then(|| Mode::Heading(offset as u8 + 1))
}
fn config(self, progression: Progression) -> LayoutConfig {
match self {
Mode::Prose | Mode::Heading(_) | Mode::Table | Mode::ListItem { .. } => prose_config(progression),
Mode::Code => code_config(progression),
}
}
fn column_class(self) -> String {
match self {
Mode::Prose | Mode::Table => "vertext-column vertext-column-prose".to_string(),
Mode::ListItem { ordered } => {
let kind = if ordered { "vertext-column-list-ordered" } else { "vertext-column-list-bullet" };
format!("vertext-column vertext-column-prose vertext-column-list {kind}")
}
Mode::Code => "vertext-column vertext-column-code".to_string(),
Mode::Heading(level) => format!(
"vertext-column vertext-column-prose vertext-column-heading vertext-column-h{level}"
),
}
}
fn block(self, text: &str) -> BlockKind {
match self {
Mode::Table => BlockKind::Table,
Mode::Code => BlockKind::Horizontal(HorizontalKind::Code),
Mode::Prose | Mode::Heading(_) | Mode::ListItem { .. } => {
if prefers_horizontal(text) {
BlockKind::Horizontal(HorizontalKind::Prose)
} else {
BlockKind::Vertical
}
}
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum BlockKind {
Vertical,
Horizontal(HorizontalKind),
Table,
}
#[derive(Clone, Copy, Debug)]
pub struct RenderOptions {
pub whole_strip_code: bool,
pub page: bool,
pub progression: Progression,
}
impl Default for RenderOptions {
fn default() -> Self {
Self {
whole_strip_code: false,
page: false,
progression: Progression::RightToLeft,
}
}
}
pub fn render_document(input: &str, options: RenderOptions) -> String {
let mut segments: Vec<(Mode, String)> = Vec::new();
if options.whole_strip_code {
segments.push((Mode::Code, input.to_owned()));
} else {
let mut current = (Mode::Prose, String::new());
for ch in input.chars() {
match Mode::from_marker(ch) {
Some(mode) if current.1.is_empty() && segments.is_empty() => {
current = (mode, String::new());
}
Some(mode) => {
segments.push(std::mem::replace(&mut current, (mode, String::new())));
}
None => current.1.push(ch),
}
}
if !current.1.is_empty() || segments.is_empty() {
segments.push(current);
}
}
let mut root_class = String::from("vertext");
if options.whole_strip_code {
root_class.push_str(" vertext-code");
}
if options.page {
root_class.push_str(" vertext-page");
}
let advance = advance_keyword(options.progression);
let mut html = format!(
"<div class=\"{root_class}\" data-column-advance=\"{advance}\" \
style=\"--vertext-latin-cap-prose:{PROSE_LATIN_CAP}ch;\
--vertext-latin-cap-code:{CODE_LATIN_CAP}ch\">"
);
let mut emitted_any = false;
let mut in_stack = false;
let last_index = segments.len().saturating_sub(1);
for (index, (mode, segment_text)) in segments.into_iter().enumerate() {
let trimmed = segment_text.trim_end_matches(['\n', '\r']);
let trailing_newlines = segment_text[trimmed.len()..].matches('\n').count();
if trimmed.is_empty() {
continue;
}
emitted_any = true;
let block = if options.whole_strip_code { BlockKind::Vertical } else { mode.block(trimmed) };
let horizontal = matches!(block, BlockKind::Horizontal(_) | BlockKind::Table);
if horizontal && !in_stack {
html.push_str("<div class=\"vertext-hstack\">");
in_stack = true;
} else if !horizontal && in_stack {
html.push_str("</div>");
in_stack = false;
}
match block {
BlockKind::Table => render_table(&mut html, trimmed, options.progression),
BlockKind::Horizontal(kind) => render_horizontal(&mut html, trimmed, kind, mode),
BlockKind::Vertical => {
let layout = layout_text(trimmed, &mode.config(options.progression));
let column_class = mode.column_class();
for column in &layout.columns {
html.push_str(&format!("<div class=\"{column_class}\">"));
render_slots(&mut html, &column.slots);
html.push_str("</div>");
}
let ends_the_input = index == last_index;
let author_broke = if ends_the_input {
trailing_newlines > 1
} else {
trailing_newlines > 0
};
if author_broke && !layout.columns.is_empty() {
html.push_str(&format!("<div class=\"{column_class}\"></div>"));
}
}
}
}
if in_stack {
html.push_str("</div>");
}
if !emitted_any {
html.push_str("<div class=\"vertext-column vertext-column-prose\"></div>");
}
html.push_str("</div>\n");
html
}
fn mark_mongolian_runs(text: &str) -> String {
let chars: Vec<char> = text.chars().collect();
let in_run = |index: usize| -> bool {
let ch = chars[index];
if is_mongolian(ch) {
return true;
}
if ch != '\u{202F}' {
return false;
}
let before = index.checked_sub(1).map(|i| is_mongolian(chars[i])).unwrap_or(false);
let after = chars.get(index + 1).copied().map(is_mongolian).unwrap_or(false);
before && after
};
let mut html = String::with_capacity(text.len());
let mut index = 0;
while index < chars.len() {
if in_run(index) {
let start = index;
while index < chars.len() && in_run(index) {
index += 1;
}
let run: String = chars[start..index].iter().collect();
html.push_str("<span class=\"vertext-mongolian-inline\">");
html.push_str(&escape(&run));
html.push_str("</span>");
} else {
let start = index;
while index < chars.len() && !in_run(index) {
index += 1;
}
let plain: String = chars[start..index].iter().collect();
html.push_str(&escape(&plain));
}
}
html
}
fn render_horizontal(html: &mut String, text: &str, kind: HorizontalKind, mode: Mode) {
let (kind_class, wrap) = match kind {
HorizontalKind::Prose => ("vertext-horizontal-prose", kind.default_wrap()),
HorizontalKind::Code => ("vertext-horizontal-code", kind.default_wrap()),
};
let heading_class = match mode {
Mode::Heading(level) => format!(" vertext-horizontal-heading vertext-horizontal-h{level}"),
Mode::ListItem { ordered } => {
let kind = if ordered { " vertext-horizontal-list-ordered" } else { " vertext-horizontal-list-bullet" };
format!(" vertext-horizontal-list{kind}")
}
_ => String::new(),
};
let tag = if matches!(kind, HorizontalKind::Code) { "pre" } else { "div" };
let body = match kind {
HorizontalKind::Prose => mark_mongolian_runs(text),
HorizontalKind::Code => escape(text),
};
html.push_str(&format!(
"<div class=\"vertext-horizontal {kind_class}{heading_class}\" \
style=\"--vertext-wrap:{wrap}ch\"><{tag}>{body}</{tag}></div>"
));
}
fn render_table(html: &mut String, text: &str, progression: Progression) {
let config = LayoutConfig { max_latin_word_width: usize::MAX, ..prose_config(progression) };
html.push_str("<table class=\"vertext-table\">");
for (index, row) in text.split(ROW_SEP).enumerate() {
if row.is_empty() {
continue;
}
let header = index == 0;
let cell_tag = if header { "th" } else { "td" };
html.push_str(if header {
"<thead><tr class=\"vertext-row vertext-row-header\">"
} else {
"<tr class=\"vertext-row\">"
});
for cell in row.split(CELL_SEP) {
html.push_str(&format!("<{cell_tag} class=\"vertext-cell\">"));
let layout = layout_text(cell, &config);
for column in &layout.columns {
html.push_str("<div class=\"vertext-column vertext-column-cell\">");
render_slots(html, &column.slots);
html.push_str("</div>");
}
html.push_str(&format!("</{cell_tag}>"));
}
html.push_str(if header { "</tr></thead><tbody>" } else { "</tr>" });
}
html.push_str("</tbody></table>");
}
fn render_slots(html: &mut String, slots: &[Slot]) {
for slot in slots {
let (class, body) = match slot {
Slot::Upright(s) => ("vertext-upright", escape(s)),
Slot::LatinWord(s) => ("vertext-latin", escape(s)),
Slot::MongolianRun(s) => ("vertext-mongolian", escape(s)),
Slot::Space(s) => ("vertext-space", escape(s)),
Slot::VerticalPunctuation(s) => ("vertext-vform", escape(s)),
Slot::CornerPunctuation(s) => ("vertext-corner", escape(s)),
Slot::Neutral(s) => ("vertext-neutral", escape(s)),
};
html.push_str(&format!("<span class=\"{class}\">{body}</span>"));
}
}
pub fn column_advance(layout: &Layout) -> &'static str {
advance_keyword(layout.progression)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_reserved_range_ends_where_the_filter_stops_stripping() {
assert_eq!(RESERVED_END, MODE_LIST_ORDERED as u32 + 1);
assert_eq!(RESERVED_END, MODE_CODE as u32 + 13);
assert!(Mode::from_marker(MODE_LIST_ORDERED).is_some());
assert!(char::from_u32(RESERVED_END).and_then(Mode::from_marker).is_none());
}
#[test]
fn mode_markers_are_the_wire_protocol() {
assert_eq!(MODE_CODE, '\u{E000}');
assert_eq!(MODE_PROSE, '\u{E001}');
assert_eq!(heading_marker(1), '\u{E002}');
assert_eq!(heading_marker(2), '\u{E003}');
assert_eq!(heading_marker(3), '\u{E004}');
assert_eq!(heading_marker(4), '\u{E005}');
assert_eq!(heading_marker(5), '\u{E006}');
assert_eq!(heading_marker(6), '\u{E007}');
assert_eq!(MODE_TABLE, '\u{E008}');
assert_eq!(CELL_SEP, '\u{E009}');
assert_eq!(ROW_SEP, '\u{E00A}');
assert_eq!(MODE_LIST, '\u{E00B}');
assert_eq!(MODE_LIST_ORDERED, '\u{E00C}');
assert_eq!(heading_marker(0), heading_marker(1));
assert_eq!(heading_marker(9), heading_marker(6));
}
#[test]
fn every_marker_round_trips_to_its_mode() {
assert_eq!(Mode::from_marker(MODE_CODE), Some(Mode::Code));
assert_eq!(Mode::from_marker(MODE_PROSE), Some(Mode::Prose));
assert_eq!(Mode::from_marker(MODE_TABLE), Some(Mode::Table));
assert_eq!(Mode::from_marker(MODE_LIST), Some(Mode::ListItem { ordered: false }));
assert_eq!(Mode::from_marker(MODE_LIST_ORDERED), Some(Mode::ListItem { ordered: true }));
for level in 1..=MAX_HEADING_LEVEL {
assert_eq!(Mode::from_marker(heading_marker(level)), Some(Mode::Heading(level)));
}
for ch in ['字', 'a', '\u{E00D}', '\u{D7FF}', '\u{E7FF}'] {
assert_eq!(Mode::from_marker(ch), None, "{ch:?} should not be a marker");
}
}
#[test]
fn a_heading_segment_gets_its_level_on_the_column() {
let input = format!("{}中文{MODE_PROSE}山川", heading_marker(2));
let html = render_document(&input, RenderOptions::default());
assert!(html.contains("vertext-column-heading vertext-column-h2"));
assert!(html.contains("vertext-column-prose vertext-column-heading"));
assert!(!html.contains(heading_marker(2)));
let latin = format!("{}Chinese{MODE_PROSE}山川", heading_marker(2));
let html = render_document(&latin, RenderOptions::default());
assert!(html.contains("vertext-horizontal-heading vertext-horizontal-h2"));
}
#[test]
fn headings_do_not_leak_into_the_following_prose() {
let input = format!("{}中文{MODE_PROSE}山川", heading_marker(2));
let html = render_document(&input, RenderOptions::default());
let heading_at = html.find("vertext-column-heading").unwrap();
let prose_at = html.rfind("vertext-column-prose\"").unwrap();
assert!(prose_at > heading_at, "the prose column must follow the heading column");
}
#[test]
fn empty_input_produces_a_well_formed_empty_strip() {
let html = render_document("", RenderOptions::default());
assert!(html.contains("<div class=\"vertext-column vertext-column-prose\"></div>"));
assert!(html.starts_with("<div class=\"vertext\""));
}
#[test]
fn prose_and_code_segments_get_their_own_column_classes() {
let input = format!("散文\n{MODE_CODE}let x = 1{MODE_PROSE}又散文");
let html = render_document(&input, RenderOptions::default());
assert!(html.contains("vertext-column-prose"));
assert!(html.contains("vertext-horizontal-code"));
assert!(!html.contains(MODE_CODE));
assert!(!html.contains(MODE_PROSE));
}
#[test]
fn leading_code_marker_does_not_create_an_empty_prose_segment() {
let input = format!("{MODE_CODE}code{MODE_PROSE}");
let html = render_document(&input, RenderOptions::default());
assert!(!html.contains("vertext-column-prose\"><"));
assert!(html.contains("vertext-horizontal-code"));
}
#[test]
fn whole_strip_code_sets_root_class_and_ignores_markers() {
let html = render_document(" let", RenderOptions { whole_strip_code: true, ..Default::default() });
assert!(html.starts_with("<div class=\"vertext vertext-code\""));
assert!(html.contains("vertext-space"), "indentation must survive");
assert!(html.contains("vertext-column-code"), "must stay vertical");
assert!(!html.contains("vertext-horizontal"));
}
#[test]
fn latin_caps_are_published_as_css_custom_properties() {
let html = render_document("字", RenderOptions::default());
assert!(html.contains("--vertext-latin-cap-prose:12ch"));
assert!(html.contains("--vertext-latin-cap-code:24ch"));
}
#[test]
fn progression_reaches_the_dom_as_data() {
let html = render_document("字", RenderOptions::default());
assert!(html.contains("data-column-advance=\"left\""));
}
#[test]
fn a_table_keeps_its_cells_apart() {
let input = format!(
"{MODE_TABLE}蒙古文{CELL_SEP}转写{ROW_SEP}ᠰᠠᠶᠢᠨ{CELL_SEP}sayin"
);
let html = render_document(&input, RenderOptions::default());
assert!(html.contains("<table class=\"vertext-table\">"));
assert!(html.contains("<th class=\"vertext-cell\">"));
assert!(html.contains("<td class=\"vertext-cell\">"));
assert!(!html.contains("蒙古文转写"));
assert!(!html.contains("ᠰᠠᠶᠢᠨsayin"));
assert!(html.contains("<span class=\"vertext-mongolian\">ᠰᠠᠶᠢᠨ</span>"));
assert!(!html.contains(CELL_SEP));
assert!(!html.contains(ROW_SEP));
}
#[test]
fn a_table_stacks_with_the_text_around_it() {
let input = format!(
"A vocabulary table follows.{MODE_TABLE}x{CELL_SEP}y{MODE_PROSE}\
Read each column top to bottom."
);
let html = render_document(&input, RenderOptions::default());
assert_eq!(html.matches("vertext-hstack").count(), 1);
let stack = html.find("vertext-hstack").unwrap();
let table = html.find("vertext-table").unwrap();
let close = html.rfind("</div></div>").unwrap();
assert!(stack < table && table < close, "the table must sit inside the stack");
}
#[test]
fn table_cells_never_hyphenate() {
let input = format!("{MODE_TABLE}x{CELL_SEP}bayarlal_a_bayartai_teyimu");
let html = render_document(&input, RenderOptions::default());
assert!(html.contains("bayarlal_a_bayartai_teyimu"));
assert!(!html.contains('‐'));
}
#[test]
fn bichig_in_a_horizontal_line_carries_a_face_it_can_join_with() {
let html = render_document(
"ene minU eji (ᠡᠨᠡ ᠮᠢᠨᠦ ᠡᠵᠢ) is my mother.",
RenderOptions::default(),
);
assert!(html.contains("vertext-horizontal-prose"), "this line is horizontal");
assert!(
html.contains("<span class=\"vertext-mongolian-inline\">ᠡᠨᠡ</span>"),
"each run is marked so the stylesheet can reach it: {html}"
);
assert!(!html.contains("\"vertext-mongolian\""));
assert!(html.contains("ene minU eji ("));
}
#[test]
fn a_suffix_joint_stays_inside_one_inline_run() {
let html = render_document(
"the genitive ᠮᠣᠩᠭᠣᠯ\u{202F}ᠤᠨ is one word in this sentence",
RenderOptions::default(),
);
assert!(html.contains("vertext-horizontal-prose"));
assert!(
html.contains("<span class=\"vertext-mongolian-inline\">ᠮᠣᠩᠭᠣᠯ\u{202F}ᠤᠨ</span>"),
"the joint is inside the run: {html}"
);
}
#[test]
fn marking_runs_does_not_stop_escaping_the_rest() {
let html = render_document(
"a & b <tag> and ᠨᠣᠮ in a mostly Latin line of prose here",
RenderOptions::default(),
);
assert!(html.contains("vertext-horizontal-prose"));
assert!(html.contains("a & b <tag>"), "{html}");
assert!(html.contains("<span class=\"vertext-mongolian-inline\">ᠨᠣᠮ</span>"));
}
#[test]
fn code_keeps_its_monospace_face() {
let input = format!("{MODE_CODE}let x = \"ᠨᠣᠮ\";{MODE_PROSE}");
let html = render_document(&input, RenderOptions::default());
assert!(html.contains("vertext-horizontal-code"));
assert!(!html.contains("vertext-mongolian-inline"), "{html}");
}
#[test]
fn latin_prose_is_set_horizontally_and_cjk_is_not() {
let english = render_document(
"It is a truth universally acknowledged, that a single man",
RenderOptions::default(),
);
assert!(english.contains("vertext-horizontal-prose"));
assert!(english.contains("--vertext-wrap:66ch"));
assert!(!english.contains("vertext-column-prose\">"));
let chinese = render_document("山川异域,风月同天。", RenderOptions::default());
assert!(chinese.contains("vertext-column-prose"));
assert!(!chinese.contains("vertext-horizontal"));
}
#[test]
fn fenced_code_is_horizontal_at_the_code_measure() {
let input = format!("{MODE_CODE}fn main() {{}}{MODE_PROSE}");
let html = render_document(&input, RenderOptions::default());
assert!(html.contains("vertext-horizontal-code"));
assert!(html.contains("--vertext-wrap:80ch"));
assert!(html.contains("<pre>"));
let injected = format!("{MODE_CODE}<script>{MODE_PROSE}");
assert!(!render_document(&injected, RenderOptions::default()).contains("<script>"));
}
#[test]
fn mongolian_progression_reaches_the_dom_and_the_layout() {
let options = RenderOptions {
progression: Progression::LeftToRight,
..Default::default()
};
let html = render_document("ᠮᠣᠩᠭᠤᠯ\nᠤᠯᠤᠰ", options);
assert!(html.contains("data-column-advance=\"right\""));
assert!(!html.contains("data-column-advance=\"left\""));
let cjk = render_document("山川", RenderOptions::default());
assert!(cjk.contains("data-column-advance=\"left\""));
}
#[test]
fn a_suffix_separator_reaches_the_dom_inside_the_run() {
let options = RenderOptions {
progression: Progression::LeftToRight,
..Default::default()
};
let html = render_document("ᠮᠣᠩᠭᠣᠯ\u{202F}ᠤᠨ", options);
assert!(html.contains("<span class=\"vertext-mongolian\">ᠮᠣᠩᠭᠣᠯ\u{202F}ᠤᠨ</span>"),
"the stem and its case ending must share one span: {html}");
assert!(!html.contains("vertext-space"),
"no word space belongs inside a suffixed word: {html}");
}
#[test]
fn punctuation_is_never_substituted() {
let source = "好(天):川、月。—…「引」";
let html = render_document(source, RenderOptions::default());
for original in ['(', ')', ':', '、', '。', '—', '…', '「', '」'] {
assert!(html.contains(original), "{original} must survive verbatim");
}
for ch in html.chars() {
let c = ch as u32;
assert!(!(0xFE10..=0xFE19).contains(&c), "presentation form {ch:?} leaked in");
assert!(!(0xFE30..=0xFE4F).contains(&c), "presentation form {ch:?} leaked in");
}
}
#[test]
fn page_mode_marks_the_root() {
let html = render_document("字", RenderOptions { page: true, ..Default::default() });
assert!(html.starts_with("<div class=\"vertext vertext-page\""));
}
const BLANK_COLUMN: &str = "<div class=\"vertext-column vertext-column-prose\"></div>";
#[test]
fn user_text_is_escaped() {
let html = render_document("<script>", RenderOptions::default());
assert!(!html.contains("<script>"));
assert!(html.contains("<"));
}
#[test]
fn trailing_newline_before_mode_toggle_keeps_a_blank_column() {
let input = format!("散文\n{MODE_CODE}code{MODE_PROSE}");
let html = render_document(&input, RenderOptions::default());
assert!(html.contains("<div class=\"vertext-column vertext-column-prose\"></div>"));
}
#[test]
fn the_terminating_newline_is_not_a_paragraph_break() {
let html = render_document("散文\n", RenderOptions::default());
assert!(!html.contains(BLANK_COLUMN), "{html}");
}
#[test]
fn a_blank_line_left_at_the_end_still_is_one() {
let html = render_document("散文\n\n", RenderOptions::default());
assert!(html.contains(BLANK_COLUMN), "{html}");
}
#[test]
fn crlf_answers_the_same_as_lf_at_the_end() {
let terminator = render_document("散文\r\n", RenderOptions::default());
let break_too = render_document("散文\r\n\r\n", RenderOptions::default());
assert!(!terminator.contains(BLANK_COLUMN), "{terminator}");
assert!(break_too.contains(BLANK_COLUMN), "{break_too}");
}
}