//! HTML. //! //! Import is a tolerant tag-soup reader (no DOM): a tokenizer plus a stack of open elements with //! HTML's implicit closes for `p`, `li`, `tr`, `td`/`th` and headings. It understands `p`, `div`, //! `h1`–`h6`, `b`/`strong`, `i`/`em`, `u`, `s`/`strike`/`del`, `sup`/`sub`, `code`/`kbd`, `a` //! (links and `id`/`name` anchors), `br`, `hr`, `ul`/`ol`/`li` (nested), `table`/`tr`/`td`/`th` //! (colspan/rowspan), `img` (`data:` URIs, other sources through a caller's [`ImageLoader`]), `blockquote`, `pre`, `font`, `ruby` //! (`rb`/`rt`/`rp`: furigana and other phonetic guides, #1681), and the CSS //! properties `color`, `background-color`, `font-weight`, `font-style`, `font-size`, //! `font-family`, `text-decoration`, `vertical-align`, `text-align`, `white-space` and page //! breaks. Scripts and styles are skipped; nesting is capped. //! //! Export writes clean semantic HTML with inline CSS, pictures as `data:` URIs and ruby as //! ``. use std::sync::Arc; use wordcraft_doc::props::VAlign; use wordcraft_doc::{Align, Document}; use crate::model::{ self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, base64_encode, data_uri, make_img, mime_of, parse_color, }; /// Open elements kept at most (deeper start tags are ignored). const MAX_STACK: usize = 256; // --------------------------------------------------------------------------------------------- // Entities and decoding /// Decode an entity body (`amp`, `#38`, `#x26`) to its text. pub fn decode_entity(name: &str) -> Option { if let Some(num) = name.strip_prefix('#') { let (digits, radix) = match num.strip_prefix(['x', 'X']) { Some(h) => (h, 16), None => (num, 10), }; if !digits.chars().all(|c| c.is_digit(radix)) { return None; } let v = u32::from_str_radix(digits, radix).ok()?; let c = match v { 0 => '\u{FFFD}', 0x80..=0x9F => crate::txt::cp1252(v as u8), _ => char::from_u32(v).unwrap_or('\u{FFFD}'), }; return Some(c.to_string()); } let c = match name { "amp" | "AMP" => '&', "lt" | "LT" => '<', "gt" | "GT" => '>', "quot" | "QUOT" => '"', "apos" | "APOS" => '\'', "nbsp" | "NBSP" => '\u{00A0}', "ensp" => '\u{2002}', "emsp" => '\u{2003}', "thinsp" => '\u{2009}', "shy" => '\u{00AD}', "copy" | "COPY" => '©', "reg" | "REG" => '®', "trade" | "TRADE" => '™', "hellip" => '…', "mdash" => '—', "ndash" => '–', "minus" => '−', "lsquo" => '‘', "rsquo" => '’', "sbquo" => '‚', "ldquo" => '“', "rdquo" => '”', "bdquo" => '„', "laquo" => '«', "raquo" => '»', "bull" => '•', "middot" => '·', "deg" => '°', "plusmn" => '±', "times" => '×', "divide" => '÷', "frac12" => '½', "frac14" => '¼', "frac34" => '¾', "frac13" => '⅓', "frac23" => '⅔', "frac18" => '⅛', "frac38" => '⅜', "frac58" => '⅝', "frac78" => '⅞', "sup1" => '¹', "sup2" => '²', "sup3" => '³', "micro" => 'µ', "permil" => '‰', "prime" => '′', "Prime" => '″', "oline" => '‾', "frasl" => '⁄', "sect" => '§', "para" => '¶', "euro" | "EURO" => '€', "pound" | "POUND" => '£', "yen" | "YEN" => '¥', "cent" | "CENT" => '¢', "curren" => '¤', "dagger" => '†', "Dagger" => '‡', "larr" => '←', "rarr" => '→', "uarr" => '↑', "darr" => '↓', "harr" => '↔', "lArr" => '⇐', "rArr" => '⇒', "uArr" => '⇑', "dArr" => '⇓', "hArr" => '⇔', "crarr" => '↵', "forall" => '∀', "part" => '∂', "exist" => '∃', "empty" => '∅', "nabla" => '∇', "isin" => '∈', "notin" => '∉', "ni" => '∋', "prod" => '∏', "sum" => '∑', "radic" => '√', "prop" => '∝', "ang" => '∠', "and" => '∧', "or" => '∨', "cap" => '∩', "cup" => '∪', "int" => '∫', "there4" => '∴', "sim" => '∼', "cong" => '≅', "asymp" | "approx" => '≈', "ne" => '≠', "equiv" => '≡', "le" => '≤', "ge" => '≥', "sub" => '⊂', "sup" => '⊃', "nsub" => '⊄', "sube" => '⊆', "supe" => '⊇', "oplus" => '⊕', "otimes" => '⊗', "perp" => '⊥', "sdot" => '⋅', "infin" => '∞', "loz" => '◊', "spades" => '♠', "clubs" => '♣', "hearts" => '♥', "diams" => '♦', "check" => '✓', "iexcl" => '¡', "iquest" => '¿', "szlig" => 'ß', "auml" => 'ä', "ouml" => 'ö', "uuml" => 'ü', "Auml" => 'Ä', "Ouml" => 'Ö', "Uuml" => 'Ü', "eacute" => 'é', "egrave" => 'è', "ecirc" => 'ê', "euml" => 'ë', "Eacute" => 'É', "Egrave" => 'È', "Ecirc" => 'Ê', "Euml" => 'Ë', "aacute" => 'á', "agrave" => 'à', "acirc" => 'â', "atilde" => 'ã', "Agrave" => 'À', "Aacute" => 'Á', "Acirc" => 'Â', "Atilde" => 'Ã', "iacute" => 'í', "igrave" => 'ì', "icirc" => 'î', "iuml" => 'ï', "Iacute" => 'Í', "Igrave" => 'Ì', "Icirc" => 'Î', "Iuml" => 'Ï', "oacute" => 'ó', "ograve" => 'ò', "ocirc" => 'ô', "otilde" => 'õ', "Oacute" => 'Ó', "Ograve" => 'Ò', "Ocirc" => 'Ô', "Otilde" => 'Õ', "uacute" => 'ú', "ugrave" => 'ù', "ucirc" => 'û', "Uacute" => 'Ú', "Ugrave" => 'Ù', "Ucirc" => 'Û', "yacute" => 'ý', "yuml" => 'ÿ', "Yacute" => 'Ý', "ntilde" => 'ñ', "Ntilde" => 'Ñ', "ccedil" => 'ç', "Ccedil" => 'Ç', "aring" => 'å', "Aring" => 'Å', "oslash" => 'ø', "Oslash" => 'Ø', "aelig" => 'æ', "AElig" => 'Æ', "alpha" => 'α', "beta" => 'β', "gamma" => 'γ', "delta" => 'δ', "epsilon" => 'ε', "zeta" => 'ζ', "eta" => 'η', "theta" => 'θ', "iota" => 'ι', "kappa" => 'κ', "lambda" => 'λ', "mu" => 'μ', "nu" => 'ν', "xi" => 'ξ', "pi" => 'π', "rho" => 'ρ', "sigma" => 'σ', "tau" => 'τ', "upsilon" => 'υ', "phi" => 'φ', "chi" => 'χ', "psi" => 'ψ', "omega" => 'ω', "Gamma" => 'Γ', "Delta" => 'Δ', "Theta" => 'Θ', "Lambda" => 'Λ', "Xi" => 'Ξ', "Pi" => 'Π', "Sigma" => 'Σ', "Upsilon" => 'Υ', "Phi" => 'Φ', "Psi" => 'Ψ', "Omega" => 'Ω', "zwj" => '\u{200D}', "zwnj" => '\u{200C}', _ => return None, }; Some(c.to_string()) } /// Replace entity references in `s`. pub fn unescape(s: &str) -> String { if !s.contains('&') { return s.to_string(); } let mut out = String::with_capacity(s.len()); let mut rest = s; while let Some(k) = rest.find('&') { out.push_str(&rest[..k]); let after = &rest[k + 1..]; // An entity name is short; looking further would rescan the whole text for every stray `&`. let mut cut = after.len().min(33); while !after.is_char_boundary(cut) { cut -= 1; } let end = after.get(..cut).and_then(|w| w.find(';')); match end.and_then(|e| decode_entity(&after[..e]).map(|t| (t, e))) { Some((t, e)) => { out.push_str(&t); rest = &after[e + 1..]; } None => { out.push('&'); rest = after; } } } out.push_str(rest); out } fn sniff_charset(head: &str) -> Option<&str> { let (i, n) = head.find("charset").map(|i| (i, 7)).or_else(|| head.find("encoding").map(|i| (i, 8)))?; let after = head.get(i + n..)?.trim_start().strip_prefix('=')?; let trimmed = after.trim_start().trim_start_matches(['"', '\'']); let end = trimmed.find(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ';' | '>' | '/')).unwrap_or(trimmed.len()); let label = trimmed[..end].trim(); if label.is_empty() { None } else { Some(label) } } /// Decode HTML bytes: BOM, then a `charset` in the first 2 KB, else UTF-8 with a legacy fallback. pub fn decode(b: &[u8]) -> String { if b.starts_with(&[0xFF, 0xFE]) || b.starts_with(&[0xFE, 0xFF]) || b.starts_with(&[0xEF, 0xBB, 0xBF]) { return crate::txt::decode(b); } let head = String::from_utf8_lossy(b.get(..b.len().min(2048)).unwrap_or(b)).to_ascii_lowercase(); if let Some(label) = sniff_charset(&head) && let Some(s) = crate::txt::decode_as(b, label) { return s; } if let Ok(s) = std::str::from_utf8(b) { return s.to_string(); } crate::txt::decode(b) } // --------------------------------------------------------------------------------------------- // Tokenizer #[derive(Debug)] enum Tok { Text(String), Start { name: String, attrs: Vec<(String, String)>, self_close: bool }, End(String), } struct Lexer<'a> { s: &'a str, pos: usize, } impl Lexer<'_> { fn rest(&self) -> &str { self.s.get(self.pos..).unwrap_or("") } /// Skip past the next occurrence of `pat` (ASCII case-insensitive), or to the end. fn skip_past(&mut self, pat: &str) -> usize { let start = self.pos; let hay = self.rest().as_bytes(); let p = pat.as_bytes(); let found = hay.windows(p.len().max(1)).position(|w| w.eq_ignore_ascii_case(p)); match found { Some(k) => self.pos += k + p.len(), None => self.pos = self.s.len(), } found.map(|k| start + k).unwrap_or(self.s.len()) } /// Skip past the next matching end tag `` (ASCII case-insensitive), or to the end. fn skip_past_end_tag(&mut self, tag: &str) { let prefix = format!("' || *b == b'/') { self.pos += after_tag; self.skip_past(">"); return; } self.pos += k + 2; } } fn next(&mut self) -> Option { let rest = self.rest(); if rest.is_empty() { return None; } if !rest.starts_with('<') { let end = rest.find('<').unwrap_or(rest.len()); let t = unescape(&rest[..end]); self.pos += end; return Some(Tok::Text(t)); } let b = rest.as_bytes(); if rest.starts_with(""); return Some(Tok::Text(String::new())); } if rest.starts_with(""); return Some(Tok::Text(String::new())); } if rest.starts_with(""); return Some(Tok::End(name)); } if !b.get(1).is_some_and(u8::is_ascii_alphabetic) { self.pos += 1; return Some(Tok::Text("<".into())); } // Start tag. let name: String = rest[1..].chars().take_while(|c| c.is_ascii_alphanumeric() || *c == ':' || *c == '-').map(|c| c.to_ascii_lowercase()).collect(); let mut i = 1 + name.len(); let mut attrs = Vec::new(); let mut self_close = false; loop { while b.get(i).is_some_and(u8::is_ascii_whitespace) { i += 1; } match b.get(i) { None => break, Some(b'>') => { i += 1; break; } Some(b'/') => { if b.get(i + 1) == Some(&b'>') { self_close = true; i += 2; break; } i += 1; continue; } _ => {} } let a0 = i; while b.get(i).is_some_and(|c| !c.is_ascii_whitespace() && !matches!(c, b'=' | b'>' | b'/')) { i += 1; } let an = rest.get(a0..i).unwrap_or("").to_ascii_lowercase(); if an.is_empty() { i += 1; continue; } while b.get(i).is_some_and(u8::is_ascii_whitespace) { i += 1; } let mut val = String::new(); if b.get(i) == Some(&b'=') { i += 1; while b.get(i).is_some_and(u8::is_ascii_whitespace) { i += 1; } match b.get(i) { Some(&q) if q == b'"' || q == b'\'' => { let v0 = i + 1; let e = rest.get(v0..).and_then(|r| r.find(q as char)).map(|k| v0 + k).unwrap_or(rest.len()); val = unescape(rest.get(v0..e).unwrap_or("")); i = e + 1; } _ => { let v0 = i; while b.get(i).is_some_and(|c| !c.is_ascii_whitespace() && *c != b'>') { i += 1; } val = unescape(rest.get(v0..i).unwrap_or("")); } } } if attrs.len() < 64 { attrs.push((an, val)); } } self.pos += i.min(rest.len()); Some(Tok::Start { name, attrs, self_close }) } } // --------------------------------------------------------------------------------------------- // Builder #[derive(Clone, Copy, PartialEq, Debug)] enum ElKind { Inline, Block, Heading(u8), Quote, Pre, List(bool), Li, Table, Tr, Cell, Head, } #[derive(Clone, Debug)] struct El { name: String, kind: ElKind, fmt: Fmt, align: Option, preserve: bool, page_break: bool, page_break_after: bool, /// Li: a paragraph of this item was already emitted. used: bool, } struct TableB { rows: Vec>, row: Option>, /// The table or one of its cells asks for borders. bordered: bool, } /// Whether an element's `border` attribute or `style` declares a visible border. fn declares_border(attrs: &[(String, String)], is_table: bool) -> bool { if is_table && attr(attrs, "border").is_some_and(|b| b.trim().parse::().map(|n| n > 0).unwrap_or(b.trim().is_empty())) { return true; } let Some(style) = attr(attrs, "style") else { return false }; style.split(';').any(|decl| { let Some((name, value)) = decl.split_once(':') else { return false }; let name = name.trim().to_ascii_lowercase(); if !matches!(name.as_str(), "border" | "border-top" | "border-right" | "border-bottom" | "border-left") { return false; } let value = value.trim().to_ascii_lowercase(); !value.is_empty() && !value.split_whitespace().any(|tok| matches!(tok, "none" | "hidden" | "0" | "0px" | "0pt")) }) } /// Loads the bytes of an image an `img` names by a source other than a `data:` URI (a path /// relative to the HTML file). `None` when it can't be found. A picture used more than once can /// share one buffer. pub type ImageLoader<'a> = &'a dyn Fn(&str) -> Option>>; struct Builder<'r> { images: ImageLoader<'r>, containers: Vec>, cells: Vec, tables: Vec, stack: Vec, para: Option, /// The current paragraph continues a list item (joins the previous paragraph). para_cont: bool, /// Pending page break from preceding elements with page-break-after or unconsumed break. pending_page_break: bool, /// Whether the current paragraph's first / last text was read with preserved white space /// (CSS `white-space: pre…`): those edge spaces are kept when the paragraph ends. pre_lead: Option, pre_tail: bool, meta: Meta, title: Option, } fn is_void(n: &str) -> bool { matches!( n, "area" | "base" | "br" | "col" | "embed" | "hr" | "img" | "input" | "link" | "meta" | "param" | "source" | "track" | "wbr" | "basefont" | "frame" ) } fn block_kind(n: &str) -> Option { Some(match n { "p" | "div" | "section" | "article" | "header" | "footer" | "main" | "nav" | "aside" | "figure" | "figcaption" | "address" | "dl" | "dt" | "dd" | "center" | "form" | "fieldset" | "body" | "html" | "caption" | "thead" | "tbody" | "tfoot" | "details" | "summary" | "legend" | "hgroup" => ElKind::Block, "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => ElKind::Heading(n.get(1..2).and_then(|d| d.parse().ok()).unwrap_or(1)), "blockquote" => ElKind::Quote, "pre" | "listing" | "xmp" | "plaintext" => ElKind::Pre, "ul" | "menu" | "dir" => ElKind::List(false), "ol" => ElKind::List(true), "li" => ElKind::Li, "table" => ElKind::Table, "tr" => ElKind::Tr, "td" | "th" => ElKind::Cell, "head" => ElKind::Head, _ => return None, }) } /// Closes an open `p` (HTML's "close a p element" on block starts). fn closes_p(n: &str) -> bool { matches!( n, "address" | "article" | "aside" | "blockquote" | "div" | "dl" | "fieldset" | "footer" | "form" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "header" | "hr" | "main" | "nav" | "ol" | "p" | "pre" | "section" | "table" | "ul" | "figure" | "center" | "dd" | "dt" | "li" ) } /// A CSS length (or font-size keyword) in points; `None` when it is not one. pub fn css_len_pt(v: &str) -> Option { let v = v.trim().to_ascii_lowercase(); let num = |s: &str| s.trim().parse::().ok().filter(|x| x.is_finite()); if let Some(x) = v.strip_suffix("pt") { num(x) } else if let Some(x) = v.strip_suffix("px") { num(x).map(|p| p * 0.75) } else if let Some(x) = v.strip_suffix("rem").or_else(|| v.strip_suffix("em")) { num(x).map(|p| p * 12.0) } else if let Some(x) = v.strip_suffix("in") { num(x).map(|p| p * 72.0) } else if let Some(x) = v.strip_suffix("cm") { num(x).map(|p| p * 72.0 / 2.54) } else if let Some(x) = v.strip_suffix("mm") { num(x).map(|p| p * 72.0 / 25.4) } else if let Some(x) = v.strip_suffix('%') { num(x).map(|p| p * 0.12) } else { match v.as_str() { "xx-small" => Some(7.0), "x-small" => Some(7.5), "small" | "smaller" => Some(10.0), "medium" => Some(12.0), "large" | "larger" => Some(13.5), "x-large" => Some(18.0), "xx-large" => Some(24.0), "xxx-large" => Some(36.0), _ => num(&v).map(|p| p * 0.75), } } } fn strip_important(v: &str) -> &str { let s = v.trim(); if let Some(pos) = s.rfind('!') { let after = s[pos + 1..].trim(); if after.eq_ignore_ascii_case("important") { return s[..pos].trim(); } } s } /// Apply a `style` attribute to formatting / block properties. fn apply_style(css: &str, f: &mut Fmt, align: &mut Option, preserve: &mut bool, page_break: &mut bool, page_break_after: &mut bool) { for decl in css.split(';').take(64) { let Some((k, v)) = decl.split_once(':') else { continue }; let k = k.trim().to_ascii_lowercase(); let v = strip_important(v); let lv = v.to_ascii_lowercase(); match k.as_str() { "font-weight" => f.bold = lv.starts_with("bold") || lv.parse::().is_ok_and(|w| w >= 600), "font-style" => f.italic = lv == "italic" || lv == "oblique", "text-decoration" | "text-decoration-line" => { if lv.contains("none") { f.underline = false; f.strike = false; } if lv.contains("underline") { f.underline = true; } if lv.contains("line-through") { f.strike = true; } } "color" => { if let Some(c) = parse_color(v) { f.color = Some(c); } } "background-color" | "background" => { if let Some(c) = v.split_whitespace().find_map(parse_color) { f.background = Some(c); } } "font-size" => { if let Some(p) = css_len_pt(v).filter(|p| *p >= 1.0 && *p <= 1638.0) { f.size = Some(p); } } "font-family" => { let fam = v.split(',').next().unwrap_or("").trim().trim_matches(['"', '\'']).to_string(); if model::is_mono(&fam) || fam.eq_ignore_ascii_case("monospace") { f.code = true; } else if !fam.is_empty() && !matches!(fam.to_ascii_lowercase().as_str(), "serif" | "sans-serif" | "inherit" | "initial") { f.font = Some(fam); } } "vertical-align" => { f.sup = lv == "super"; f.sub = lv == "sub"; } "text-align" => { *align = match lv.as_str() { "center" => Some(Align::Center), "right" | "end" => Some(Align::Right), "justify" => Some(Align::Justify), "left" | "start" => Some(Align::Left), _ => *align, } } "white-space" => *preserve = lv.starts_with("pre"), "page-break-before" | "break-before" => *page_break = lv == "always" || lv == "page", "page-break-after" | "break-after" => *page_break_after = lv == "always" || lv == "page", _ => {} } } } fn attr<'a>(attrs: &'a [(String, String)], name: &str) -> Option<&'a str> { attrs.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str()) } impl<'r> Builder<'r> { fn new(images: ImageLoader<'r>) -> Builder<'r> { Builder { images, containers: vec![Vec::new()], cells: Vec::new(), tables: Vec::new(), stack: Vec::new(), para: None, para_cont: false, pending_page_break: false, pre_lead: None, pre_tail: false, meta: Meta::default(), title: None, } } fn fmt(&self) -> Fmt { self.stack.last().map(|e| e.fmt.clone()).unwrap_or_default() } fn preserve(&self) -> bool { self.stack.last().is_some_and(|e| e.preserve) } fn in_head(&self) -> bool { self.stack.iter().any(|e| e.kind == ElKind::Head) } /// Paragraph context from the open elements (innermost first, up to the enclosing cell). fn start_para(&mut self) -> &mut Para { if self.para.is_none() { let mut p = Para::default(); let mut list_level: Option<(bool, usize)> = None; let mut li_at: Option = None; let mut lists = 0usize; for (i, e) in self.stack.iter().enumerate().rev() { match e.kind { ElKind::Cell => { // CSS inherits `text-align` from the cell, its row, row group and table // (#1381); the paragraph's own (found above) wins. if p.align.is_none() { p.align = self.stack.get(..=i).and_then(|outer| { outer .iter() .rev() .take_while(|o| o.kind != ElKind::Table) .chain(outer.iter().rev().find(|o| o.kind == ElKind::Table)) .find_map(|o| o.align) }); } break; } ElKind::Table => break, ElKind::Heading(n) if p.kind == Kind::Normal => p.kind = Kind::Heading(n.clamp(1, 6)), ElKind::Pre if p.kind == Kind::Normal => p.kind = Kind::Code, ElKind::Quote if p.kind == Kind::Normal => p.kind = Kind::Quote, ElKind::Li if li_at.is_none() => li_at = Some(i), ElKind::List(o) => { if list_level.is_none() { list_level = Some((o, 0)); } lists += 1; } _ => {} } if p.align.is_none() && e.align.is_some() && e.kind != ElKind::Inline { p.align = e.align; } } if let (Some((o, _)), Some(li)) = (list_level, li_at) { p.list = Some(ListInfo { ordered: o, level: lists.saturating_sub(1).min(8) as u8, start: 1 }); self.para_cont = self.stack.get(li).is_some_and(|e| e.used); if let Some(e) = self.stack.get_mut(li) { e.used = true; } } else { self.para_cont = false; } let pb_before = self.stack.iter_mut().rev().take_while(|e| e.kind != ElKind::Cell).any(|e| std::mem::take(&mut e.page_break)); p.page_break = pb_before || std::mem::take(&mut self.pending_page_break); self.para = Some(p); } self.para.get_or_insert_with(Para::default) } fn container(&mut self) -> &mut Vec { if self.containers.is_empty() { self.containers.push(Vec::new()); } let n = self.containers.len() - 1; &mut self.containers[n] } fn flush(&mut self) { let Some(mut p) = self.para.take() else { return }; let (pre_lead, pre_tail) = (self.pre_lead.take().unwrap_or(false), std::mem::take(&mut self.pre_tail)); if p.kind != Kind::Code { // Preserved white space keeps its spaces and tabs at the edges; line breaks there // (source formatting) still go. const ALL: &[char] = &[' ', '\t', '\r', '\n']; const BREAKS: &[char] = &['\r', '\n']; p.trim_with(if pre_lead { BREAKS } else { ALL }, if pre_tail { BREAKS } else { ALL }); if p.text().trim().is_empty() { // Only white space (indentation between blocks): nothing to keep. p.trim(); } } if p.inlines.len() == 1 && p.text() == "\u{00A0}" { // `

 

`: an empty paragraph on purpose. p.inlines.clear(); self.para_cont = false; self.container().push(FBlock::Para(p)); return; } if p.is_empty() && p.kind != Kind::Code && p.list.is_none() { // Whitespace-only text between blocks. return; } let cont = std::mem::take(&mut self.para_cont); let level = p.list.map(|l| l.level); let c = self.container(); if cont && let Some(FBlock::Para(prev)) = c.last_mut() && prev.list.is_some() && prev.list.map(|l| l.level) == level { prev.push_text("\n", &Fmt::default()); prev.inlines.extend(p.inlines); return; } c.push(FBlock::Para(p)); } fn text(&mut self, t: &str) { if self.in_head() { if let Some(title) = &mut self.title { title.push_str(t); } return; } if t.is_empty() { return; } let inside_table_only = matches!( self.stack.iter().rev().find(|e| matches!(e.kind, ElKind::Table | ElKind::Tr | ElKind::Cell)).map(|e| e.kind), Some(ElKind::Table | ElKind::Tr) ); if inside_table_only && t.trim().is_empty() { return; } let code = self.stack.iter().any(|e| e.kind == ElKind::Pre); let f = self.fmt(); if code { let mut first = true; for line in t.split('\n') { if !first { // A newline right after
 is ignored.
                    let empty_start = self.para.as_ref().is_none_or(|p| p.inlines.is_empty()) && self.pre_fresh();
                    if !empty_start {
                        self.start_para();
                        self.flush();
                    }
                }
                first = false;
                let line = line.trim_end_matches('\r');
                if !line.is_empty() {
                    self.start_para().push_text(line, &Fmt { code: false, ..f.clone() });
                    self.mark_pre_used();
                }
            }
            return;
        }
        if self.preserve() {
            self.note_edge(true);
            self.start_para().push_text(t, &f);
            return;
        }
        // Collapse whitespace.
        let mut s = String::with_capacity(t.len());
        let mut prev_space = self.para.as_ref().is_none_or(|p| match p.inlines.last() {
            Some(Inline::Text(x, _)) => x.ends_with([' ', '\n']),
            Some(_) => false,
            None => true,
        });
        for c in t.chars() {
            if matches!(c, ' ' | '\t' | '\n' | '\r' | '\u{000C}') {
                if !prev_space {
                    s.push(' ');
                    prev_space = true;
                }
            } else {
                s.push(c);
                prev_space = false;
            }
        }
        if s.is_empty() {
            return;
        }
        self.note_edge(false);
        self.start_para().push_text(&s, &f);
    }

    /// Record whether text about to be added keeps its white space (see `pre_lead`).
    fn note_edge(&mut self, preserved: bool) {
        if self.para.as_ref().is_none_or(|p| p.inlines.is_empty()) {
            self.pre_lead = Some(preserved);
        }
        self.pre_tail = preserved;
    }

    /// Add a `` element's base text and reading as one inline (#1681). Half-marked ruby (no
    /// base, or no reading) is plain text instead: a guide that annotates nothing is no guide, and
    /// the text it held is never lost.
    fn push_ruby(&mut self, base: &str, ruby: &str) {
        let preserve = self.preserve();
        let text = |s: &str| if preserve { s.to_string() } else { collapse_ws(s) };
        let (base, ruby) = (text(base), text(ruby));
        let f = self.fmt();
        if base.is_empty() || ruby.is_empty() {
            self.start_para().push_text(if base.is_empty() { &ruby } else { &base }, &f);
            return;
        }
        self.start_para().inlines.push(Inline::Ruby { base, ruby, fmt: f });
    }

    fn pre_fresh(&self) -> bool {
        self.stack.iter().rev().find(|e| e.kind == ElKind::Pre).is_some_and(|e| !e.used)
    }

    fn mark_pre_used(&mut self) {
        if let Some(e) = self.stack.iter_mut().rev().find(|e| e.kind == ElKind::Pre) {
            e.used = true;
        }
    }

    /// Pop elements down to and including index `k`.
    fn pop_to(&mut self, k: usize) {
        while self.stack.len() > k {
            let Some(e) = self.stack.pop() else { break };
            self.close(&e);
        }
    }

    fn close(&mut self, e: &El) {
        if e.page_break_after || e.page_break {
            self.pending_page_break = true;
        }
        if e.kind != ElKind::Inline {
            self.flush();
        }
        match e.kind {
            ElKind::Cell => {
                self.flush();
                let blocks = if self.containers.len() > 1 { self.containers.pop().unwrap_or_default() } else { Vec::new() };
                let mut cell = self.cells.pop().unwrap_or_default();
                cell.blocks = blocks;
                if let Some(t) = self.tables.last_mut() {
                    t.row.get_or_insert_with(Vec::new).push(cell);
                }
            }
            ElKind::Tr => {
                if let Some(t) = self.tables.last_mut()
                    && let Some(r) = t.row.take()
                {
                    t.rows.push(r);
                }
            }
            ElKind::Table => {
                if let Some(mut t) = self.tables.pop() {
                    if let Some(r) = t.row.take() {
                        t.rows.push(r);
                    }
                    let borderless = !t.bordered;
                    // Keep at most MAX_ROWS rows (the model drops the rest anyway): bounds the
                    // O(rows × rowspan) work of `insert_covered` on a table of millions of ``.
                    let rows: Vec<_> = t.rows.into_iter().filter(|r| !r.is_empty()).take(wordcraft_doc::table::MAX_ROWS).collect();
                    let mut ft = FTable { rows, widths: Vec::new(), borderless };
                    if !ft.rows.is_empty() {
                        ft.insert_covered();
                        self.container().push(FBlock::Table(ft));
                    }
                }
            }
            ElKind::Head => {}
            _ => {}
        }
    }

    /// Index of the innermost open element named `name`, not looking past a table boundary
    /// unless `cross_tables`.
    fn find_open(&self, pred: impl Fn(&El) -> bool, stop: impl Fn(&El) -> bool) -> Option {
        for (i, e) in self.stack.iter().enumerate().rev() {
            if pred(e) {
                return Some(i);
            }
            if stop(e) {
                return None;
            }
        }
        None
    }

    fn close_head_for_start(&mut self, name: &str) {
        // A body-content start tag implicitly ends an omitted .
        if !matches!(
            name,
            "html" | "head" | "base" | "basefont" | "bgsound" | "link" | "meta" | "noframes" | "noscript" | "script" | "style" | "template" | "title"
        ) && let Some(k) = self.find_open(|e| e.kind == ElKind::Head, |_| false)
        {
            self.pop_to(k);
        }
    }

    fn start(&mut self, name: &str, attrs: &[(String, String)], self_close: bool) {
        // Head metadata.
        match name {
            "title" => {
                self.title = Some(String::new());
            }
            "meta" => {
                let n = attr(attrs, "name").unwrap_or("").to_ascii_lowercase();
                let c = attr(attrs, "content").unwrap_or("").to_string();
                match n.as_str() {
                    "author" => self.meta.author = c,
                    "description" => self.meta.description = c,
                    "keywords" => self.meta.keywords = c,
                    "subject" => self.meta.subject = c,
                    _ => {}
                }
                return;
            }
            _ => {}
        }
        let is_table_part = matches!(name, "tr" | "td" | "th");
        let in_table = self.tables.len() < model::MAX_DEPTH;
        // Implicit closes.
        if closes_p(name)
            && let Some(k) = self.find_open(|e| e.name == "p", |e| matches!(e.kind, ElKind::Cell | ElKind::Table | ElKind::Li | ElKind::Quote))
        {
            self.pop_to(k);
        }
        if matches!(name, "h1" | "h2" | "h3" | "h4" | "h5" | "h6") && self.stack.last().is_some_and(|e| matches!(e.kind, ElKind::Heading(_))) {
            let k = self.stack.len() - 1;
            self.pop_to(k);
        }
        if name == "li"
            && let Some(k) = self.find_open(|e| e.kind == ElKind::Li, |e| matches!(e.kind, ElKind::List(_) | ElKind::Table | ElKind::Cell))
        {
            self.pop_to(k);
        }
        if matches!(name, "dt" | "dd")
            && let Some(k) =
                self.find_open(|e| e.name == "dt" || e.name == "dd", |e| matches!(e.kind, ElKind::Table | ElKind::Cell) || e.name == "dl")
        {
            self.pop_to(k);
        }
        if is_table_part && !self.tables.is_empty() {
            if matches!(name, "td" | "th")
                && let Some(k) = self.find_open(|e| e.kind == ElKind::Cell, |e| matches!(e.kind, ElKind::Table | ElKind::Tr))
            {
                self.pop_to(k);
            }
            if name == "tr"
                && let Some(k) = self.find_open(|e| matches!(e.kind, ElKind::Tr | ElKind::Cell), |e| e.kind == ElKind::Table)
            {
                // Pop to the outermost of cell/row inside the current table.
                let mut kk = k;
                while kk > 0 && self.stack.get(kk - 1).is_some_and(|e| matches!(e.kind, ElKind::Tr | ElKind::Cell)) {
                    kk -= 1;
                }
                self.pop_to(kk);
            }
        }
        if name == "a"
            && !self.in_head()
            && let Some(id) = attr(attrs, "id").or_else(|| attr(attrs, "name")).filter(|s| !s.is_empty())
        {
            self.start_para().inlines.push(Inline::Anchor(id.to_string()));
        }
        // Element-specific work.
        match name {
            "br" => {
                if self.stack.iter().any(|e| e.kind == ElKind::Pre) {
                    self.start_para();
                    self.flush();
                } else {
                    let f = self.fmt();
                    self.start_para().push_text("\n", &f);
                }
                return;
            }
            "hr" => {
                self.flush();
                self.container().push(FBlock::Para(Para::new(Kind::Rule)));
                return;
            }
            "wbr" => {
                let f = self.fmt();
                self.start_para().push_text("\u{200B}", &f);
                return;
            }
            "img" => {
                let alt = attr(attrs, "alt").unwrap_or("").to_string();
                let dim = |k: &str| attr(attrs, k).and_then(|v| v.trim().trim_end_matches("px").trim().parse::().ok()).map(|p| p * 0.75);
                let mut w = dim("width");
                let mut h = dim("height");
                if let Some(st) = attr(attrs, "style") {
                    for decl in st.split(';') {
                        if let Some((k, v)) = decl.split_once(':') {
                            match k.trim().to_ascii_lowercase().as_str() {
                                "width" => w = css_len_pt(v).or(w),
                                "height" => h = css_len_pt(v).or(h),
                                _ => {}
                            }
                        }
                    }
                }
                let img = attr(attrs, "src")
                    .and_then(|src| if src.trim_start().starts_with("data:") { data_uri(src).map(Arc::new) } else { (self.images)(src.trim()) })
                    .and_then(|d| make_img(d, w, h, &alt));
                let f = self.fmt();
                match img {
                    Some(im) => self.start_para().inlines.push(Inline::Image(im)),
                    None if !alt.is_empty() => self.start_para().push_text(&alt, &f),
                    None => {}
                }
                return;
            }
            _ => {}
        }
        if is_void(name) || self_close && block_kind(name).is_none() && !matches!(name, "a" | "span") {
            return;
        }
        if self.stack.len() >= MAX_STACK {
            return;
        }
        let mut kind = block_kind(name).unwrap_or(ElKind::Inline);
        if matches!(kind, ElKind::Table | ElKind::Tr | ElKind::Cell) && !in_table {
            kind = ElKind::Block;
        }
        if matches!(kind, ElKind::Tr | ElKind::Cell) && self.tables.is_empty() {
            kind = ElKind::Block;
        }
        if kind != ElKind::Inline {
            self.flush();
        }
        let mut fmt = self.fmt();
        let mut align = None;
        let mut preserve = self.preserve();
        let mut page_break = false;
        let mut page_break_after = false;
        match name {
            // A header cell is bold, as in browsers and Word, unless its own CSS says otherwise.
            "b" | "strong" | "th" => fmt.bold = true,
            "i" | "em" | "cite" | "var" | "dfn" | "address" => fmt.italic = true,
            "u" | "ins" => fmt.underline = true,
            "s" | "strike" | "del" => fmt.strike = true,
            "code" | "kbd" | "tt" | "samp" => fmt.code = true,
            "sup" => fmt.sup = true,
            "sub" => fmt.sub = true,
            "small" => fmt.size = Some(fmt.size.map_or(10.0, |s| (s * 0.833).max(1.0))),
            "big" => fmt.size = Some(fmt.size.map_or(14.0, |s| (s * 1.2).min(1638.0))),
            "mark" => fmt.background = Some(wordcraft_doc::Rgb(255, 255, 0)),
            "font" => {
                if let Some(c) = attr(attrs, "color").and_then(parse_color) {
                    fmt.color = Some(c);
                }
                if let Some(face) = attr(attrs, "face") {
                    let fam = face.split(',').next().unwrap_or("").trim().to_string();
                    if model::is_mono(&fam) {
                        fmt.code = true;
                    } else if !fam.is_empty() {
                        fmt.font = Some(fam);
                    }
                }
                if let Some(sz) = attr(attrs, "size") {
                    let s = sz.trim();
                    let val = if let Some(rest) = s.strip_prefix('+') {
                        rest.parse::().ok().map(|d| 3 + d)
                    } else if let Some(rest) = s.strip_prefix('-') {
                        rest.parse::().ok().map(|d| 3 - d)
                    } else {
                        s.parse::().ok()
                    };
                    if let Some(sz) = val {
                        fmt.size = Some([7.5, 10.0, 12.0, 13.5, 18.0, 24.0, 36.0].get((sz.clamp(1, 7) - 1) as usize).copied().unwrap_or(12.0));
                    }
                }
            }
            "a" => {
                if let Some(h) = attr(attrs, "href").filter(|h| wordcraft_doc::link::is_safe_href(h)) {
                    fmt.link = Some(h.trim().to_string());
                }
            }
            _ => {}
        }
        // `align` on a table is about where the table sits (not imported); it doesn't align the
        // text in its cells.
        if let Some(a) = attr(attrs, "align").filter(|_| kind != ElKind::Table) {
            align = match a.to_ascii_lowercase().as_str() {
                "center" | "middle" => Some(Align::Center),
                "right" => Some(Align::Right),
                "justify" => Some(Align::Justify),
                "left" => Some(Align::Left),
                _ => None,
            };
        }
        if name == "center" {
            align = Some(Align::Center);
        }
        if let Some(st) = attr(attrs, "style") {
            apply_style(st, &mut fmt, &mut align, &mut preserve, &mut page_break, &mut page_break_after);
        }
        if kind == ElKind::Pre {
            preserve = true;
        }
        match kind {
            ElKind::Table => self.tables.push(TableB { rows: Vec::new(), row: None, bordered: declares_border(attrs, true) }),
            ElKind::Tr => {
                if let Some(t) = self.tables.last_mut()
                    && let Some(r) = t.row.take()
                {
                    t.rows.push(r);
                }
            }
            ElKind::Cell => {
                let tb = self.find_open(|e| e.kind == ElKind::Tr, |e| e.kind == ElKind::Table).is_none();
                if let Some(t) = self.tables.last_mut()
                    && declares_border(attrs, false)
                {
                    t.bordered = true;
                }
                if tb && let Some(t) = self.tables.last_mut() {
                    // A cell without a row: start one.
                    if let Some(r) = t.row.take() {
                        t.rows.push(r);
                    }
                    t.row = Some(Vec::new());
                }
                let num = |k: &str| attr(attrs, k).and_then(|v| v.trim().parse::().ok()).map(|v| v.clamp(1, 63));
                let shading = attr(attrs, "bgcolor").and_then(parse_color).or(fmt.background);
                let mut valign = attr(attrs, "valign").and_then(|v| match v.trim().to_ascii_lowercase().as_str() {
                    "top" => Some(VAlign::Top),
                    "middle" | "center" => Some(VAlign::Center),
                    "bottom" => Some(VAlign::Bottom),
                    _ => None,
                });
                if let Some(st) = attr(attrs, "style") {
                    for decl in st.split(';') {
                        if let Some((k, v)) = decl.split_once(':')
                            && k.trim().eq_ignore_ascii_case("vertical-align")
                        {
                            match v.trim().to_ascii_lowercase().as_str() {
                                "top" => valign = Some(VAlign::Top),
                                "middle" | "center" => valign = Some(VAlign::Center),
                                "bottom" => valign = Some(VAlign::Bottom),
                                _ => {}
                            }
                        }
                    }
                }
                self.cells.push(Cell {
                    blocks: Vec::new(),
                    colspan: num("colspan").unwrap_or(1),
                    rowspan: attr(attrs, "rowspan").and_then(|v| v.trim().parse::().ok()).map(|v| v.clamp(1, 1000)).unwrap_or(1),
                    covered: false,
                    header: name == "th",
                    shading,
                    borders: None,
                    valign,
                });
                fmt.background = None;
                self.containers.push(Vec::new());
            }
            _ => {}
        }
        self.stack.push(El { name: name.to_string(), kind, fmt, align, preserve, page_break, page_break_after, used: false });
    }

    fn end(&mut self, name: &str) {
        if name == "title" {
            if let Some(t) = self.title.take() {
                self.meta.title = t.split_whitespace().collect::>().join(" ");
            }
            return;
        }
        let crosses = matches!(name, "table");
        let target = if matches!(name, "td" | "th") {
            self.find_open(|e| e.kind == ElKind::Cell, |e| e.kind == ElKind::Table)
        } else if name == "tr" {
            self.find_open(|e| e.kind == ElKind::Tr, |e| e.kind == ElKind::Table)
        } else {
            self.find_open(|e| e.name == name, |e| !crosses && matches!(e.kind, ElKind::Table | ElKind::Cell))
        };
        if let Some(k) = target {
            self.pop_to(k);
        }
    }

    fn finish(mut self) -> Flow {
        self.pop_to(0);
        self.flush();
        // Unclosed cells: fold remaining containers into the body.
        while self.containers.len() > 1 {
            let c = self.containers.pop().unwrap_or_default();
            self.container().extend(c);
        }
        let blocks = self.containers.pop().unwrap_or_default();
        if let Some(t) = self.title.take() {
            self.meta.title = t;
        }
        Flow { blocks, meta: self.meta }
    }
}

/// Parse HTML text into the flow model.
pub fn parse(s: &str) -> Flow {
    parse_with(s, &|_| None)
}

/// [`parse`], loading pictures that aren't `data:` URIs through `images`.
pub fn parse_with(s: &str, images: ImageLoader) -> Flow {
    let mut lx = Lexer { s, pos: 0 };
    let mut b = Builder::new(images);
    let mut guard = 0usize;
    while let Some(t) = lx.next() {
        guard += 1;
        if guard > 50_000_000 {
            break;
        }
        match t {
            Tok::Text(t) => b.text(&t),
            Tok::Start { name, attrs, self_close } => {
                b.close_head_for_start(&name);
                if crate::mathml::local(&name) == "math" && !self_close {
                    let el = read_math(&mut lx, name, attrs);
                    if !b.in_head()
                        && let Some((linear, display)) = crate::mathml::read(&el)
                    {
                        b.start_para().inlines.push(Inline::Equation { linear, display });
                    }
                    continue;
                }
                if name == "ruby" && !self_close {
                    let r = read_ruby(&mut lx);
                    if !b.in_head() {
                        b.push_ruby(&r.base, &r.ruby);
                    }
                    continue;
                }
                if matches!(name.as_str(), "script" | "style" | "noscript" | "template" | "textarea") && !self_close {
                    lx.skip_past_end_tag(&name);
                    continue;
                }
                b.start(&name, &attrs, self_close);
            }
            Tok::End(name) => b.end(&name),
        }
    }
    b.finish()
}

/// A `` element (#816): its tokens up to the matching end tag, as a tree. Nesting and size
/// are bounded; an unclosed element ends with the input.
fn read_math(lx: &mut Lexer, name: String, attrs: Vec<(String, String)>) -> crate::mathml::El {
    use crate::mathml::{El, Item, MAX_NODES, local};
    let mut stack: Vec = vec![El { name, attrs, children: Vec::new() }];
    let mut count = 0usize;
    let close = |stack: &mut Vec| {
        if stack.len() > 1
            && let Some(done) = stack.pop()
            && let Some(parent) = stack.last_mut()
        {
            parent.children.push(Item::El(done));
        }
    };
    while let Some(t) = lx.next() {
        count += 1;
        if count > MAX_NODES * 4 {
            // Over the cap: drop the rest of this element so its tokens aren't read as HTML text.
            skip_math(lx, t);
            break;
        }
        match t {
            Tok::Text(t) => {
                if let Some(top) = stack.last_mut()
                    && !t.is_empty()
                {
                    top.children.push(Item::Text(t));
                }
            }
            Tok::Start { name, attrs, self_close } => {
                let el = El { name, attrs, children: Vec::new() };
                if self_close || stack.len() > wordcraft_doc::math::MAX_DEPTH {
                    if let Some(top) = stack.last_mut() {
                        top.children.push(Item::El(el));
                    }
                } else {
                    stack.push(el);
                }
            }
            Tok::End(name) => {
                let name = local(&name).to_string();
                // Close up to the matching open element; a stray end tag is ignored.
                if let Some(k) = stack.iter().rposition(|e| local(&e.name) == name) {
                    if k == 0 {
                        break;
                    }
                    while stack.len() > k {
                        close(&mut stack);
                    }
                }
            }
        }
    }
    while stack.len() > 1 {
        close(&mut stack);
    }
    stack.pop().unwrap_or_default()
}

/// Skip tokens (starting with `first`) up to and including the `` that closes the element
/// being read; nested `` elements are counted. An unclosed element ends with the input.
fn skip_math(lx: &mut Lexer, first: Tok) {
    use crate::mathml::local;
    let mut depth = 0usize;
    let mut t = Some(first);
    while let Some(tok) = t {
        match tok {
            Tok::Start { name, self_close: false, .. } if local(&name) == "math" => depth = depth.saturating_add(1),
            Tok::End(name) if local(&name) == "math" => {
                if depth == 0 {
                    return;
                }
                depth = depth.saturating_sub(1);
            }
            _ => {}
        }
        t = lx.next();
    }
}

/// A `` element's base text and reading (#1681).
#[derive(Default)]
struct RubyText {
    base: String,
    ruby: String,
}

/// Which part of a `` element the text being read belongs to.
#[derive(Clone, Copy, PartialEq, Eq)]
enum RubyPart {
    /// The text being annotated: an `` child, or the text outside ``/``.
    Base,
    /// The reading: an `` child.
    Ruby,
    /// `` fallback parentheses, which are not text: browsers show them only where ruby isn't
    /// supported.
    Fallback,
}

/// A block-level tag ends an open `` implicitly, as it does in a browser.
fn ends_ruby(t: &Tok) -> bool {
    match t {
        Tok::Start { name, .. } | Tok::End(name) => block_kind(name).is_some(),
        Tok::Text(_) => false,
    }
}

/// Read a `` element's content: its base text from `` children (or the text outside
/// them), its reading from `` children, and `` fallback parentheses dropped. Tolerant,
/// like the rest of the reader: tags inside are ignored, an unclosed element ends at the next
/// block-level tag (handed back to the caller) or with the input, and the tokens read are capped.
fn read_ruby(lx: &mut Lexer) -> RubyText {
    /// Most tokens one `` element is read from: a missing end tag must not scan the rest of
    /// a hostile file here.
    const MAX_TOKENS: usize = 4096;
    let mut out = RubyText::default();
    let mut part = RubyPart::Base;
    let mut nested = 0usize;
    let mut count = 0usize;
    while count < MAX_TOKENS {
        count += 1;
        let start = lx.pos;
        let Some(t) = lx.next() else { break };
        if nested == 0 && ends_ruby(&t) {
            // The boundary ends the element: give the token back to the main reader.
            lx.pos = start;
            break;
        }
        match t {
            Tok::Text(t) => match part {
                RubyPart::Base => out.base.push_str(&t),
                RubyPart::Ruby => out.ruby.push_str(&t),
                RubyPart::Fallback => {}
            },
            // The self-closing slash is ignored on HTML elements (`` opens an `rt`), as in a
            // browser's parser.
            Tok::Start { name, self_close, .. } => match name.as_str() {
                "rb" => part = RubyPart::Base,
                "rt" => part = RubyPart::Ruby,
                "rp" => part = RubyPart::Fallback,
                "ruby" if !self_close => nested += 1,
                _ => {}
            },
            Tok::End(name) => match name.as_str() {
                "rb" | "rt" | "rp" => part = RubyPart::Base,
                "ruby" if nested > 0 => nested -= 1,
                "ruby" => break,
                _ => {}
            },
        }
    }
    out
}

/// Collapse white space inside ruby markup as body text does (HTML's white space only: an
/// ideographic space is text), without the space the element's indentation leaves at the edges.
fn collapse_ws(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        if matches!(c, ' ' | '\t' | '\n' | '\r' | '\u{000C}') {
            if !out.ends_with(' ') {
                out.push(' ');
            }
        } else {
            out.push(c);
        }
    }
    out.trim_matches(' ').to_string()
}

/// Parse HTML into a document.
pub fn import(s: &str) -> Document {
    model::to_doc(&parse(s))
}

/// [`import`], loading pictures that aren't `data:` URIs through `images`.
pub fn import_with(s: &str, images: ImageLoader) -> Document {
    model::to_doc(&parse_with(s, images))
}

/// [`import_with`] onto `page` (its size and margins), which tables size to: HTML gives no page
/// size, so the caller picks it (the region's paper, #1381).
pub fn import_on(s: &str, images: ImageLoader, page: &wordcraft_doc::SectionProps) -> Document {
    model::to_doc_in(&parse_with(s, images), page.clone())
}

// ---------------------------------------------------------------------------------------------
// Export

/// Escape text for HTML content and attribute values.
pub fn esc(s: &str) -> String {
    let mut o = String::with_capacity(s.len() + 8);
    for c in s.chars() {
        match c {
            '&' => o.push_str("&"),
            '<' => o.push_str("<"),
            '>' => o.push_str(">"),
            '"' => o.push_str("""),
            '\u{000C}' | '\u{000E}' => {}
            _ => o.push(c),
        }
    }
    o
}

fn align_css(a: Option) -> Option<&'static str> {
    match a? {
        Align::Center => Some("text-align:center"),
        Align::Right => Some("text-align:right"),
        Align::Justify | Align::Distribute => Some("text-align:justify"),
        Align::Left => None,
    }
}

fn text_html(t: &str, at_start: bool) -> String {
    let mut o = String::new();
    let mut prev_space = at_start;
    for c in t.chars() {
        match c {
            '\n' => {
                o.push_str("
"); prev_space = true; continue; } '\t' => o.push_str("\t"), ' ' if prev_space => o.push_str(" "), _ => o.push_str(&esc(&c.to_string())), } prev_space = c == ' '; } o } /// The tags that carry `f`'s character formatting: what opens them, and what closes them /// (innermost last, so the closes are written in reverse). fn fmt_tags(f: &Fmt) -> (String, Vec<&'static str>) { let mut open = String::new(); let mut close: Vec<&str> = Vec::new(); // A script or other non-web target is written as plain text (`is_safe_href`). if let Some(l) = f.link.as_deref().filter(|l| wordcraft_doc::link::is_safe_href(l)) { open.push_str(&format!("", esc(l))); close.push(""); } let mut tag = |o: &str, c: &'static str| { open.push_str(o); close.push(c); }; if f.bold { tag("", ""); } if f.italic { tag("", ""); } if f.underline { tag("", ""); } if f.strike { tag("", ""); } if f.sup { tag("", ""); } else if f.sub { tag("", ""); } if f.code { tag("", ""); } let mut css = Vec::new(); if let Some(c) = f.color { css.push(format!("color:#{}", c.hex())); } if let Some(c) = f.background { css.push(format!("background-color:#{}", c.hex())); } if let Some(s) = f.size { css.push(format!("font-size:{}pt", trim_num(s))); } if let Some(fam) = &f.font { css.push(format!("font-family:'{}'", esc(&fam.replace('\'', "")))); } if !css.is_empty() { open.push_str(&format!("", css.join(";"))); close.push(""); } (open, close) } /// Inline spans → HTML. pub fn inlines_html(inlines: &[Inline]) -> String { let mut o = String::new(); for i in inlines.iter() { match i { Inline::Text(t, f) => { let (open, close) = fmt_tags(f); let at_start = o.is_empty(); o.push_str(&open); o.push_str(&text_html(t, at_start)); for c in close.iter().rev() { o.push_str(c); } } // A phonetic guide: the base text with its reading in `` (#1681). HTML ruby has // no alignment or size, so the guide's own attributes stay in the document model. Inline::Ruby { base, ruby, fmt } => { let (open, close) = fmt_tags(fmt); let at_start = o.is_empty(); o.push_str(&open); o.push_str(&format!("{}{}", text_html(base, at_start), text_html(ruby, false))); for c in close.iter().rev() { o.push_str(c); } } Inline::Image(img) => { o.push_str(&format!( "\"{}\"", mime_of(&img.ext), base64_encode(&img.data), esc(&img.alt), (img.w / 0.75).round(), (img.h / 0.75).round() )); } Inline::Anchor(a) => o.push_str(&format!("", esc(a))), // No picture to show: the browser shows the alt text in its place. Inline::Figure(alt) => o.push_str(&format!("\"{}\"", esc(alt))), // MathML, which browsers, Word and LibreOffice show as an equation (#816). Inline::Equation { linear, display } => o.push_str(&crate::mathml::linear_to_mathml(linear, *display)), } } o } fn trim_num(v: f32) -> String { let s = format!("{:.2}", v); s.trim_end_matches('0').trim_end_matches('.').to_string() } fn para_attrs(p: &Para) -> String { let mut css: Vec<&str> = Vec::new(); if let Some(a) = align_css(p.align) { css.push(a); } if p.page_break { css.push("break-before:page;page-break-before:always"); } if css.is_empty() { String::new() } else { format!(" style=\"{}\"", css.join(";")) } } fn para_html(p: &Para) -> String { let body = inlines_html(&p.inlines); let attrs = para_attrs(p); match p.kind { Kind::Heading(n) => format!("{body}", n = n.clamp(1, 6)), Kind::Title => format!("{body}"), Kind::Rule => "
".to_string(), _ if body.is_empty() => format!(" 

"), _ => format!("{body}

"), } } fn table_html(t: &FTable, out: &mut String, depth: usize) { out.push_str("\n"); for row in &t.rows { out.push_str(""); for c in row { if c.covered { continue; } let tag = if c.header { "th" } else { "td" }; let mut attrs = String::new(); if c.colspan > 1 { attrs.push_str(&format!(" colspan=\"{}\"", c.colspan)); } if c.rowspan > 1 { attrs.push_str(&format!(" rowspan=\"{}\"", c.rowspan)); } let v_css = match c.valign { Some(VAlign::Center) => "vertical-align:middle", Some(VAlign::Bottom) => "vertical-align:bottom", _ => "vertical-align:top", }; let mut css = vec!["padding:4px 6px".to_string(), v_css.to_string()]; if !t.borderless { css.insert(0, "border:1px solid #999".to_string()); } if let Some(s) = c.shading { css.push(format!("background-color:#{}", s.hex())); } let single = match c.blocks.as_slice() { [FBlock::Para(p)] if p.kind == Kind::Normal && p.list.is_none() && !p.page_break => Some(p), _ => None, }; if let Some(a) = single.and_then(|p| align_css(p.align)) { css.push(a.to_string()); } out.push_str(&format!("<{tag}{attrs} style=\"{}\">", css.join(";"))); match single { Some(p) => out.push_str(&inlines_html(&p.inlines)), None => { out.push('\n'); blocks_html(&c.blocks, out, depth + 1); } } out.push_str(&format!("")); } out.push_str("\n"); } out.push_str("
\n"); } fn blocks_html(blocks: &[FBlock], out: &mut String, depth: usize) { let mut i = 0; while let Some(b) = blocks.get(i) { match b { FBlock::Table(t) => { if depth < model::MAX_DEPTH { table_html(t, out, depth); } i += 1; } FBlock::Para(p) if p.list.is_some() => { // Nested lists from consecutive list paragraphs. let mut stack: Vec<(bool, bool)> = Vec::new(); // (ordered, li open) while let Some(FBlock::Para(q)) = blocks.get(i) { let Some(li) = q.list else { break }; let lv = li.level.min(8) as usize; while stack.len() > lv + 1 { close_list(&mut stack, out); } if stack.len() == lv + 1 && stack.last().is_some_and(|s| s.0 != li.ordered) { close_list(&mut stack, out); } while stack.len() < lv + 1 { if let Some(top) = stack.last_mut() && !top.1 { out.push_str("
  • "); top.1 = true; } out.push_str(if li.ordered { "
      " } else { "
        " }); stack.push((li.ordered, false)); } if let Some(top) = stack.last_mut() { if top.1 { out.push_str("\n"); } top.1 = true; } let body = inlines_html(&q.inlines); let attrs = para_attrs(q); out.push_str(&format!("{body}")); i += 1; } while !stack.is_empty() { close_list(&mut stack, out); } out.push('\n'); } FBlock::Para(p) if p.kind == Kind::Code => { out.push_str("
        ");
                        let mut first = true;
                        while let Some(FBlock::Para(q)) = blocks.get(i) {
                            if q.kind != Kind::Code || q.list.is_some() {
                                break;
                            }
                            if !first {
                                out.push('\n');
                            }
                            first = false;
                            out.push_str(&esc(&q.text()));
                            i += 1;
                        }
                        out.push_str("
        \n"); } FBlock::Para(p) if p.kind == Kind::Quote => { out.push_str("
        \n"); while let Some(FBlock::Para(q)) = blocks.get(i) { if q.kind != Kind::Quote || q.list.is_some() { break; } out.push_str(¶_html(&Para { kind: Kind::Normal, ..q.clone() })); out.push('\n'); i += 1; } out.push_str("
        \n"); } FBlock::Para(p) => { out.push_str(¶_html(p)); out.push('\n'); i += 1; } } } } fn close_list(stack: &mut Vec<(bool, bool)>, out: &mut String) { if let Some((ordered, li_open)) = stack.pop() { if li_open { out.push_str(""); } out.push_str(if ordered { "
    " } else { "" }); } } /// A complete HTML document. pub fn export(doc: &Document) -> String { let flow = model::from_doc(doc); export_flow(&flow, doc.styles.default_chr.lang.as_deref().unwrap_or("en")) } /// `doc`'s content as an HTML fragment (no head or style sheet): what a copy puts on the system /// clipboard beside its text, equations as MathML (#816). pub fn export_fragment(doc: &Document) -> String { let flow = model::from_doc(doc); let mut out = String::from(""); blocks_html(&flow.blocks, &mut out, 0); out } pub fn export_flow(flow: &Flow, lang: &str) -> String { let m = &flow.meta; let mut out = String::new(); out.push_str("\n"); out.push_str(&format!("\n\n\n", esc(lang))); out.push_str("\n"); out.push_str(&format!("{}\n", esc(&m.title))); for (k, v) in [("author", &m.author), ("description", &m.description), ("keywords", &m.keywords), ("subject", &m.subject)] { if !v.is_empty() { out.push_str(&format!("\n", esc(v))); } } out.push_str( "\n\n\n", ); blocks_html(&flow.blocks, &mut out, 0); out.push_str("\n\n"); out } #[cfg(test)] mod tests { use super::*; #[test] fn numeric_entity_rejects_sign() { assert_eq!(unescape("a&#+65;b"), "a&#+65;b"); assert_eq!(unescape("a&#x+41;b"), "a&#x+41;b"); assert_eq!(unescape("aAAb"), "aAAb"); } fn texts(f: &Flow) -> Vec { f.blocks .iter() .map(|b| match b { FBlock::Para(p) => format!("{:?}{:?}:{}", p.kind, p.list.map(|l| (l.ordered, l.level)), p.text()), FBlock::Table(t) => format!("table{}x{}", t.rows.len(), t.cols()), }) .collect() } fn table_style(html: &str) -> Option { let doc = crate::model::to_doc(&parse(html)); doc.body.iter().find_map(|b| match &**b { wordcraft_doc::Block::Table(t) => Some(t.props.style.clone().unwrap_or_default()), _ => None, }) } #[test] fn borderless_tables_get_no_grid_style() { assert_eq!(table_style("
    ab
    ").as_deref(), Some("")); assert_eq!(table_style("
    a
    ").as_deref(), Some("")); assert_eq!(table_style("
    a
    ").as_deref(), Some("")); assert_eq!(table_style("
    a
    ").as_deref(), Some("TableGrid")); assert_eq!(table_style("
    a
    ").as_deref(), Some("TableGrid")); assert_eq!(table_style("
    a
    ").as_deref(), Some("TableGrid")); } #[test] fn borderless_tables_export_without_cell_borders() { // #438: a table without grid style and borders writes no cell borders and reopens borderless. for (src, grid) in [ ("
    AB
    ", false), ("
    AB
    ", false), ("
    AB
    ", true), ] { let out = export(&crate::model::to_doc(&parse(src))); assert_eq!(out.contains("border:1px"), grid, "{src}: {out}"); assert_eq!(table_style(&out).as_deref(), Some(if grid { "TableGrid" } else { "" }), "{src}"); assert!(out.contains(">A") && out.contains(">B"), "{out}"); } } #[test] fn css_preformatted_paragraphs_keep_edge_spaces() { // #439: `white-space:pre` keeps leading and trailing spaces like `
    `; normal
            // paragraphs still collapse.
            let f = parse(
                "

    Alpha Beta

    \n Gamma \n

      Alpha  

    Alpha Beta

    ", ); assert_eq!(texts(&f), vec!["NormalNone: Alpha Beta ", "NormalNone: Gamma ", "CodeNone: Alpha ", "NormalNone:Alpha Beta"]); } #[test] fn tag_soup() { let f = parse( "T & U

    Head

    One bold

    Two it\n text

    • a
    • b
      1. c
    xy
    z
    q
    l1\nl2
    ", ); assert_eq!(f.meta.title, "T & U"); assert_eq!( texts(&f), vec![ "Heading(1)None:Head", "NormalNone:One bold", "NormalNone:Two it text", "NormalSome((false, 0)):a", "NormalSome((false, 0)):b", "NormalSome((true, 1)):c", "table2x3", "QuoteNone:q", "CodeNone:l1", "CodeNone:l2", ] ); let FBlock::Para(p) = &f.blocks[1] else { panic!() }; assert!(matches!(&p.inlines[1], Inline::Text(t, f) if t == "bold" && f.bold)); } #[test] fn omitted_head_keeps_body_text() { let f = parse("T\n

    Hello world

    Second

    "); assert_eq!(f.meta.title, "T"); assert_eq!(texts(&f), vec!["NormalNone:Hello world", "NormalNone:Second"]); for body in [ "

    Hello world

    ", "
    Hello world
    ", "Hello world", "Hello world", "Hello world", "Hello world", ] { let f = parse(&format!( "T & U{body}", )); assert_eq!(f.meta.title, "T & U"); assert_eq!(f.meta.author, "Ann"); assert_eq!(texts(&f), vec!["NormalNone:Hello world"], "{body}"); } } #[test] fn styles_and_links() { let f = parse("

    red l
    n

    "); let FBlock::Para(p) = &f.blocks[0] else { panic!() }; assert_eq!(p.align, Some(Align::Center)); assert!(matches!(&p.inlines[0], Inline::Text(t, f) if t == "red" && f.bold && f.color == Some(wordcraft_doc::Rgb(255,0,0)))); assert!(p.inlines.iter().any(|i| matches!(i, Inline::Text(t, f) if t == "l" && f.link.as_deref() == Some("http://x")))); assert!(p.text().ends_with("l\nn")); } /// The paragraphs of every cell of the first table, row by row. fn cell_paras(f: &Flow) -> Vec>> { let Some(FBlock::Table(t)) = f.blocks.iter().find(|b| matches!(b, FBlock::Table(_))) else { panic!("no table") }; t.rows .iter() .map(|r| { r.iter().map(|c| c.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p.clone()) } else { None }).collect()).collect() }) .collect() } fn all_bold(p: &Para) -> bool { !p.inlines.is_empty() && p.inlines.iter().all(|i| matches!(i, Inline::Text(_, f) if f.bold)) } fn any_bold(p: &Para) -> bool { p.inlines.iter().any(|i| matches!(i, Inline::Text(_, f) if f.bold)) } #[test] fn header_cells_are_bold_unless_their_css_says_otherwise() { // #1381: a is bold, as in browsers and Word; `font-weight:normal` on it or inside it wins. let f = parse( "
    HeadPlainInner
    abc
    ", ); let rows = cell_paras(&f); assert!(all_bold(&rows[0][0][0]), "{:?}", rows[0][0]); assert!(!any_bold(&rows[0][1][0]), "{:?}", rows[0][1]); assert!(!any_bold(&rows[0][2][0]), "{:?}", rows[0][2]); assert!(!any_bold(&rows[1][0][0]), "td stays regular"); } #[test] fn cell_text_align_reaches_the_cell_paragraphs() { // #1381: `text-align` on a td/th, or inherited from its tr/thead/tbody/table, aligns the // cell's paragraphs; a paragraph's own text-align inside the cell wins. let f = parse( "\ \
    rinherited

    own

    attr
    body
    table
    ", ); let rows = cell_paras(&f); assert_eq!(rows[0][0][0].align, Some(Align::Right), "td style"); assert_eq!(rows[0][1][0].align, Some(Align::Right), "from tr"); assert_eq!(rows[0][2][0].align, Some(Align::Center), "own paragraph wins"); assert_eq!(rows[0][3][0].align, Some(Align::Right), "align attribute"); assert_eq!(rows[1][0][0].align, Some(Align::Justify), "from tbody"); assert_eq!(rows[2][0][0].align, Some(Align::Center), "from table"); // `align` on a table places the table; it doesn't align the text in its cells. let f = parse("
    x
    "); assert_eq!(cell_paras(&f)[0][0][0].align, None); } #[test] fn reporter_sample_header_bold_and_right_aligned_cells() { // #1381: the reporter's table. let f = parse( "\n\n\n\n
    IndicatoreValore atteso
    Audit4
    ", ); let rows = cell_paras(&f); assert!(all_bold(&rows[0][0][0]) && all_bold(&rows[0][1][0]), "{:?}", rows[0]); assert_eq!(rows[1][1][0].align, Some(Align::Right)); assert_eq!(rows[1][0][0].align, None); } #[test] fn entities() { assert_eq!(unescape("a <b> AB &bogus; &"), "a AB &bogus; &"); assert_eq!(unescape("− © &EURO; ™ ½ ¾"), "− © € ™ ½ ¾"); assert_eq!(unescape("θ λ Δ ∇ ∑ √ ≠ ≤ ≥ ♥"), "θ λ Δ ∇ ∑ √ ≠ ≤ ≥ ♥"); } #[test] fn wbr_word_break_opportunity() { let f = parse("

    Supercalifragilistic

    "); let Some(FBlock::Para(p)) = f.blocks.first() else { panic!("expected para") }; assert_eq!(p.text(), "Super\u{200B}cali\u{200B}fragilistic"); } #[test] fn page_break_after_and_before() { let f = parse("

    First

    Second

    "); let paras: Vec<_> = f.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None }).collect(); assert_eq!(paras.len(), 2); assert!(!paras[0].page_break); assert!(paras[1].page_break); let f2 = parse("

    Part 1

    Part 2

    "); let paras2: Vec<_> = f2.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None }).collect(); assert_eq!(paras2.len(), 2); assert!(!paras2[0].page_break); assert!(paras2[1].page_break); } #[test] fn font_size_relative_and_absolute() { let f = parse("largersmallerhuge"); let inls = match &f.blocks[0] { FBlock::Para(p) => &p.inlines, _ => panic!("expected para"), }; let sizes: Vec> = inls .iter() .map(|i| match i { Inline::Text(_, fmt) => fmt.size, _ => None, }) .collect(); assert_eq!(sizes, vec![Some(13.5), Some(10.0), Some(18.0)]); } #[test] fn decode_with_charset_declarations() { let iso = b"\xE9l\xE8ve"; assert!(decode(iso).contains("élève")); let win = b"\x80100"; assert!(decode(win).contains("€100")); let gbk = b"\xC4\xE3\xBA\xC3"; assert!(decode(gbk).contains("你好")); let xml = b"hello"; assert!(decode(xml).contains("hello")); } #[test] fn raw_text_elements_skip_only_matching_end_tag() { let f = parse("

    Hello

    "); assert_eq!(f.blocks.len(), 1); let p = match &f.blocks[0] { FBlock::Para(p) => p, b => panic!("expected para, got {b:?}"), }; assert_eq!(p.text(), "Hello"); let f = parse("

    World

    "); assert_eq!(f.blocks.len(), 1); let p = match &f.blocks[0] { FBlock::Para(p) => p, b => panic!("expected para, got {b:?}"), }; assert_eq!(p.text(), "World"); } #[test] fn leading_space_survives_round_trip() { let doc = Document::from_text(" lead"); let html = export(&doc); assert!(!html.contains("

    lead"), "{html}"); let back = import(&html); assert_eq!(back.plain_text(Default::default()).replace('\u{a0}', " "), " lead"); } /// A document that reads "I read " then a phonetic guide over `base`, then " daily.". fn ruby_doc(base: &str, ruby: &str) -> Document { let props = wordcraft_doc::CharProps::default(); let mut p = wordcraft_doc::Paragraph::with_text("I read ", props.clone()); let n = p.len(); p.insert_object(n, crate::model::ruby_object(base.into(), ruby.into(), 20.0), &props).unwrap(); let n = p.len(); p.insert_text(n, " daily.", &props).unwrap(); let mut d = Document::new(); d.body = vec![wordcraft_doc::para_block(p)]; d } /// The first flow ruby: its base text and reading. fn ruby_of(f: &Flow) -> Option<(String, String)> { f.blocks.iter().find_map(|b| match b { FBlock::Para(p) => p.inlines.iter().find_map(|i| match i { Inline::Ruby { base, ruby, .. } => Some((base.clone(), ruby.clone())), _ => None, }), _ => None, }) } /// The first document ruby: its base text and reading. fn doc_ruby(d: &Document) -> Option<(String, String)> { d.body.iter().find_map(|b| match &**b { wordcraft_doc::Block::Para(p) => p.objects.iter().find_map(|o| match o { wordcraft_doc::InlineObject::Ruby { base, ruby, .. } => Some((base.clone(), ruby.clone())), _ => None, }), _ => None, }) } /// The first document ruby: base size, ruby size, raise and alignment. fn doc_ruby_attrs(d: &Document) -> Option<(f32, f32, f32, wordcraft_doc::para::RubyAlign)> { d.body.iter().find_map(|b| match &**b { wordcraft_doc::Block::Para(p) => p.objects.iter().find_map(|o| match o { wordcraft_doc::InlineObject::Ruby { base_size, size, raise, align, .. } => Some((*base_size, *size, *raise, *align)), _ => None, }), _ => None, }) } #[test] fn ruby_exports_as_ruby_markup() { // #1681: the base text with its reading in `

    `, not plain base text. let out = export(&ruby_doc("漢字", "かんじ")); assert!(out.contains("

    I read 漢字かんじ daily.

    "), "{out}"); // The base and the reading are escaped like any other text. let out = export(&ruby_doc("A&B", "xA&Bx<y"), "{out}"); } #[test] fn ruby_imports_with_its_surrounding_text() { let f = parse("

    I read 漢字かんじ daily.

    "); assert_eq!(ruby_of(&f), Some(("漢字".into(), "かんじ".into()))); assert_eq!(texts(&f), vec!["NormalNone:I read 漢字 daily."]); let FBlock::Para(p) = &f.blocks[0] else { panic!() }; assert!(matches!(&p.inlines[0], Inline::Text(t, _) if t == "I read "), "{:?}", p.inlines); assert!(matches!(p.inlines.last(), Some(Inline::Text(t, _)) if t == " daily."), "{:?}", p.inlines); // One document object, holding the base text and the reading. assert_eq!(doc_ruby(&crate::model::to_doc(&f)), Some(("漢字".into(), "かんじ".into()))); } #[test] fn ruby_rb_and_rp_fallbacks_import_as_one_guide() { // `` holds the base text and `` the fallback parentheses for browsers without // ruby: the parentheses are not text, and the base pieces join. let f = parse("

    漢字(かんじ)

    "); assert_eq!(ruby_of(&f), Some(("漢字".into(), "かんじ".into()))); assert_eq!(texts(&f), vec!["NormalNone:漢字"]); assert_eq!(doc_ruby(&crate::model::to_doc(&f)), Some(("漢字".into(), "かんじ".into()))); } #[test] fn ruby_import_uses_the_phonetic_guide_defaults() { // HTML states no alignment, size or raise: the Phonetic Guide's own defaults over a // 20 pt run (half the base size, raised clear of the base text, centred)… let d = crate::html::import("

    漢字かんじ

    "); let Some((base_size, size, raise, align)) = doc_ruby_attrs(&d) else { panic!("no ruby") }; assert_eq!((base_size, size, align.ooxml()), (20.0, 10.0, "center")); assert!((raise - 18.0).abs() < 0.001, "{raise}"); // …and the guide's 10.5 pt fallback base size when the source states none. let d = crate::html::import("

    漢字かんじ

    "); let Some((base_size, size, raise, _)) = doc_ruby_attrs(&d) else { panic!("no ruby") }; assert_eq!((base_size, size), (10.5, 5.0)); assert!((raise - 9.4).abs() < 0.001, "{raise}"); } #[test] fn ruby_survives_an_html_round_trip() { let html = export(&ruby_doc("漢字", "かんじ")); assert_eq!(ruby_of(&parse(&html)), Some(("漢字".into(), "かんじ".into()))); let back = crate::html::import(&html); assert_eq!(doc_ruby(&back), Some(("漢字".into(), "かんじ".into()))); assert_eq!(back.plain_text(Default::default()), "I read 漢字 daily."); } #[test] fn ruby_keeps_the_run_formatting() { let props = wordcraft_doc::CharProps { bold: Some(true), ..Default::default() }; let mut p = wordcraft_doc::Paragraph::with_text("I read ", props.clone()); let n = p.len(); p.insert_object(n, crate::model::ruby_object("漢字".into(), "かんじ".into(), 20.0), &props).unwrap(); let mut d = Document::new(); d.body = vec![wordcraft_doc::para_block(p)]; assert!(export(&d).contains("漢字かんじ"), "{}", export(&d)); // Importing it back keeps the formatting for the base text and the reading. let f = parse("

    漢字かんじ

    "); let FBlock::Para(p) = &f.blocks[0] else { panic!() }; assert!(matches!(&p.inlines[0], Inline::Ruby { fmt, .. } if fmt.bold), "{:?}", p.inlines); } #[test] fn malformed_ruby_keeps_its_text() { // No reading, an empty reading, a missing base, an unclosed element and a block boundary // closing it: none of them panics, and the text inside is kept. let cases: [(&str, &[&str]); 7] = [ ("

    a 漢字 b

    ", &["NormalNone:a 漢字 b"]), ("

    a 漢字 b

    ", &["NormalNone:a 漢字 b"]), ("

    a かんじ b

    ", &["NormalNone:a かんじ b"]), ("

    a b

    ", &["NormalNone:a b"]), ("", &[]), ("

    a 漢字かんじ b

    next

    ", &["NormalNone:a 漢字 b", "NormalNone:next"]), ("

    a 漢字かんじ

    next

    ", &["NormalNone:a 漢字", "NormalNone:next"]), ]; for (src, want) in cases { assert_eq!(texts(&parse(src)), want, "{src}"); } // Ruby in ruby: the inner element's text joins the outer guide's. assert_eq!(ruby_of(&parse("

    漢字字じかんじ

    ")), Some(("漢字字".into(), "じかんじ".into()))); } #[test] fn small_big_and_css_font_sizes() { let f = parse( "

    small big huge tiny

    ", ); let FBlock::Para(p) = &f.blocks[0] else { panic!() }; let sz = |i: usize| match &p.inlines[i] { Inline::Text(_, f) => f.size, _ => None, }; assert_eq!(sz(0), Some(10.0)); assert_eq!(sz(2), Some(14.0)); assert_eq!(sz(4), Some(36.0)); assert_eq!(sz(6), Some(10.0)); } #[test] fn css_important_casing_and_spacing() { let f = parse( "

    Styled

    ", ); assert_eq!(f.blocks.len(), 1); let p = match &f.blocks[0] { FBlock::Para(p) => p, b => panic!("expected para, got {b:?}"), }; assert_eq!(p.align, Some(Align::Center)); match &p.inlines[0] { Inline::Text(_, fmt) => { assert_eq!(fmt.color, Some(wordcraft_doc::props::Rgb(0xFF, 0x00, 0x00))); assert_eq!(fmt.size, Some(16.0)); assert!(fmt.bold); } i => panic!("expected text inline, got {i:?}"), } } #[test] fn table_cell_vertical_alignment() { let f = parse("
    topmidbot
    "); let FBlock::Table(t) = &f.blocks[0] else { panic!() }; assert_eq!(t.rows[0][0].valign, Some(VAlign::Top)); assert_eq!(t.rows[0][1].valign, Some(VAlign::Center)); assert_eq!(t.rows[0][2].valign, Some(VAlign::Bottom)); let doc = crate::model::to_doc(&f); let wordcraft_doc::Block::Table(dt) = &*doc.body[0] else { panic!() }; assert_eq!(dt.rows[0].cells[0].props.valign, Some(VAlign::Top)); assert_eq!(dt.rows[0].cells[1].props.valign, Some(VAlign::Center)); assert_eq!(dt.rows[0].cells[2].props.valign, Some(VAlign::Bottom)); let html = export(&doc); assert!(html.contains("vertical-align:top")); assert!(html.contains("vertical-align:middle")); assert!(html.contains("vertical-align:bottom")); } }