Files
wordcraft/crates/formats/src/html.rs
T
47edcd2ff2 chore: resolve SonarCloud maintainability issues (#4083)
* chore: resolve SonarCloud maintainability issues

* fix: restore compiling refactors, revert behaviour changes, run cargo fmt

- fontdb::norm: keep the closure for filter (char::is_alphanumeric takes
  self, not &self, so it does not fit Iterator::filter).
- story.rs: write_mark_rpr needs &mut self (rev_attrs, rpr_change).
- Revert the viewport meta change and the return 0 added to
  workspace_version/sha256, which changed behaviour or masked failures.
- cargo fmt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Import Document in odt page_parts after dropping glob re-export

* fix: apply_kind_properties binds ContentControl; cell_ref keeps char closures

* fix(engine): pass case helpers by value (clippy needless borrows)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Dylan Taylor <dylan@craftmaker.dev>
2026-10-24 03:54:19 -04:00

2428 lines
92 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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
//! `<ruby>`.
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<String> {
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 `</tag...>` (ASCII case-insensitive), or to the end.
fn skip_past_end_tag(&mut self, tag: &str) {
let prefix = format!("</{tag}");
let p = prefix.as_bytes();
while self.pos < self.s.len() {
let hay = self.rest().as_bytes();
let found = hay.windows(p.len().max(1)).position(|w| w.eq_ignore_ascii_case(p));
let Some(k) = found else {
self.pos = self.s.len();
return;
};
let after_tag = k + p.len();
let next_byte = hay.get(after_tag);
if next_byte.is_none_or(|b| b.is_ascii_whitespace() || *b == b'>' || *b == b'/') {
self.pos += after_tag;
self.skip_past(">");
return;
}
self.pos += k + 2;
}
}
fn next(&mut self) -> Option<Tok> {
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("<!--") {
self.pos += 4;
self.skip_past("-->");
return Some(Tok::Text(String::new()));
}
if rest.starts_with("<!") || rest.starts_with("<?") {
self.skip_past(">");
return Some(Tok::Text(String::new()));
}
if rest.starts_with("</") {
let name: String = rest
.strip_prefix("</")
.unwrap_or("")
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == ':' || *c == '-')
.map(|c| c.to_ascii_lowercase())
.collect();
if name.is_empty() {
self.pos += 2;
return Some(Tok::Text("</".into()));
}
self.skip_past(">");
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<Align>,
preserve: bool,
page_break: bool,
page_break_after: bool,
/// Li: a paragraph of this item was already emitted.
used: bool,
}
struct TableB {
rows: Vec<Vec<Cell>>,
row: Option<Vec<Cell>>,
/// 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::<u32>().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<Arc<Vec<u8>>>;
struct Builder<'r> {
images: ImageLoader<'r>,
containers: Vec<Vec<FBlock>>,
cells: Vec<Cell>,
tables: Vec<TableB>,
stack: Vec<El>,
para: Option<Para>,
/// 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<bool>,
pre_tail: bool,
meta: Meta,
title: Option<String>,
}
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<ElKind> {
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<f32> {
let v = v.trim().to_ascii_lowercase();
let num = |s: &str| s.trim().parse::<f32>().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<Align>, 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::<u32>().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<usize> = 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<FBlock> {
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}" {
// `<p>&nbsp;</p>`: 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 <pre> 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 `<ruby>` 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 `<tr>`.
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<usize> {
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 </head>.
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::<f32>().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::<i32>().ok().map(|d| 3 + d)
} else if let Some(rest) = s.strip_prefix('-') {
rest.parse::<i32>().ok().map(|d| 3 - d)
} else {
s.parse::<i32>().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::<u32>().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::<u32>().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::<Vec<_>>().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 `<math>` 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<El> = vec![El { name, attrs, children: Vec::new() }];
let mut count = 0usize;
let close = |stack: &mut Vec<El>| {
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 `</math>` that closes the element
/// being read; nested `<math>` 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 `<ruby>` element's base text and reading (#1681).
#[derive(Default)]
struct RubyText {
base: String,
ruby: String,
}
/// Which part of a `<ruby>` element the text being read belongs to.
#[derive(Clone, Copy, PartialEq, Eq)]
enum RubyPart {
/// The text being annotated: an `<rb>` child, or the text outside `<rt>`/`<rp>`.
Base,
/// The reading: an `<rt>` child.
Ruby,
/// `<rp>` fallback parentheses, which are not text: browsers show them only where ruby isn't
/// supported.
Fallback,
}
/// A block-level tag ends an open `<ruby>` 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 `<ruby>` element's content: its base text from `<rb>` children (or the text outside
/// them), its reading from `<rt>` children, and `<rp>` 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 `<ruby>` 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 (`<rt/>` 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("&amp;"),
'<' => o.push_str("&lt;"),
'>' => o.push_str("&gt;"),
'"' => o.push_str("&quot;"),
'\u{000C}' | '\u{000E}' => {}
_ => o.push(c),
}
}
o
}
fn align_css(a: Option<Align>) -> 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("<br>");
prev_space = true;
continue;
}
'\t' => o.push_str("<span style=\"white-space:pre\">\t</span>"),
' ' if prev_space => o.push_str("&nbsp;"),
_ => 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!("<a href=\"{}\">", esc(l)));
close.push("</a>");
}
let mut tag = |o: &str, c: &'static str| {
open.push_str(o);
close.push(c);
};
if f.bold {
tag("<strong>", "</strong>");
}
if f.italic {
tag("<em>", "</em>");
}
if f.underline {
tag("<u>", "</u>");
}
if f.strike {
tag("<s>", "</s>");
}
if f.sup {
tag("<sup>", "</sup>");
} else if f.sub {
tag("<sub>", "</sub>");
}
if f.code {
tag("<code>", "</code>");
}
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!("<span style=\"{}\">", css.join(";")));
close.push("</span>");
}
(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 `<rt>` (#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!("<ruby>{}<rt>{}</rt></ruby>", 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!(
"<img src=\"data:{};base64,{}\" alt=\"{}\" width=\"{}\" height=\"{}\">",
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!("<a id=\"{}\"></a>", esc(a))),
// No picture to show: the browser shows the alt text in its place.
Inline::Figure(alt) => o.push_str(&format!("<img alt=\"{}\">", 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!("<h{n}{attrs}>{body}</h{n}>", n = n.clamp(1, 6)),
Kind::Title => format!("<h1{attrs}>{body}</h1>"),
Kind::Rule => "<hr>".to_string(),
_ if body.is_empty() => format!("<p{attrs}>&nbsp;</p>"),
_ => format!("<p{attrs}>{body}</p>"),
}
}
fn table_html(t: &FTable, out: &mut String, depth: usize) {
out.push_str("<table style=\"border-collapse:collapse\">\n");
for row in &t.rows {
out.push_str("<tr>");
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!("</{tag}>"));
}
out.push_str("</tr>\n");
}
out.push_str("</table>\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("<li>");
top.1 = true;
}
out.push_str(if li.ordered { "<ol>" } else { "<ul>" });
stack.push((li.ordered, false));
}
if let Some(top) = stack.last_mut() {
if top.1 {
out.push_str("</li>\n");
}
top.1 = true;
}
let body = inlines_html(&q.inlines);
let attrs = para_attrs(q);
out.push_str(&format!("<li{attrs}>{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("<pre><code>");
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("</code></pre>\n");
}
FBlock::Para(p) if p.kind == Kind::Quote => {
out.push_str("<blockquote>\n");
while let Some(FBlock::Para(q)) = blocks.get(i) {
if q.kind != Kind::Quote || q.list.is_some() {
break;
}
out.push_str(&para_html(&Para { kind: Kind::Normal, ..q.clone() }));
out.push('\n');
i += 1;
}
out.push_str("</blockquote>\n");
}
FBlock::Para(p) => {
out.push_str(&para_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("</li>");
}
out.push_str(if ordered { "</ol>" } else { "</ul>" });
}
}
/// 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("<meta charset=\"utf-8\">");
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("<!DOCTYPE html>\n");
out.push_str(&format!("<html lang=\"{}\">\n<head>\n<meta charset=\"utf-8\">\n", esc(lang)));
out.push_str("<meta name=\"generator\" content=\"WordCraft\">\n");
out.push_str(&format!("<title>{}</title>\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!("<meta name=\"{k}\" content=\"{}\">\n", esc(v)));
}
}
out.push_str(
"<style>\nbody{font-family:Aptos,Calibri,'Segoe UI',Arial,sans-serif;font-size:12pt;line-height:1.15;max-width:48em;margin:2em auto;padding:0 1em}\n\
table{border-collapse:collapse;margin:0.5em 0}\nblockquote{margin:0.5em 2em;font-style:italic;color:#404040}\n\
pre{background:#f5f5f5;padding:0.5em;font-family:'Courier New',monospace;font-size:10pt}\n</style>\n</head>\n<body>\n",
);
blocks_html(&flow.blocks, &mut out, 0);
out.push_str("</body>\n</html>\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("a&#65;&#x41;b"), "aAAb");
}
fn texts(f: &Flow) -> Vec<String> {
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<String> {
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("<table><tr><td>a<td>b</table>").as_deref(), Some(""));
assert_eq!(table_style("<table style='border:none'><tr><td style='border:0'>a</table>").as_deref(), Some(""));
assert_eq!(table_style("<table border=0><tr><td style='border: 0px none'>a</table>").as_deref(), Some(""));
assert_eq!(table_style("<table border=1><tr><td>a</table>").as_deref(), Some("TableGrid"));
assert_eq!(table_style("<table style='border:1px solid #000'><tr><td>a</table>").as_deref(), Some("TableGrid"));
assert_eq!(table_style("<table><tr><td style='border:1px solid #999'>a</table>").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 [
("<table style='border:none'><tr><td>A<td>B</table>", false),
("<table><tr><td>A<td>B</table>", false),
("<table border=1><tr><td>A<td>B</table>", 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</td>") && out.contains(">B</td>"), "{out}");
}
}
#[test]
fn css_preformatted_paragraphs_keep_edge_spaces() {
// #439: `white-space:pre` keeps leading and trailing spaces like `<pre>`; normal
// paragraphs still collapse.
let f = parse(
"<p style=\"white-space:pre\"> Alpha Beta </p><p style=\"white-space:pre-wrap\">\n Gamma \n</p><pre> Alpha </pre><p> Alpha Beta </p>",
);
assert_eq!(texts(&f), vec!["NormalNone: Alpha Beta ", "NormalNone: Gamma ", "CodeNone: Alpha ", "NormalNone:Alpha Beta"]);
}
#[test]
fn tag_soup() {
let f = parse(
"<html><head><title>T &amp; U</title><style>p{}</style><script>if(a<b)x</script></head><body><h1>Head</h1><p>One <b>bold<p>Two <i>it</i>\n text<ul><li>a<li>b<ol><li>c</ol></ul><table><tr><td>x<td colspan=2>y<tr><th>z</table><blockquote>q</blockquote><pre>l1\nl2</pre>",
);
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("<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>T</title>\n<body><p>Hello world</p><p>Second</p></body></html>");
assert_eq!(f.meta.title, "T");
assert_eq!(texts(&f), vec!["NormalNone:Hello world", "NormalNone:Second"]);
for body in [
"<p>Hello world</p>",
"<div>Hello world</div>",
"<span>Hello world</span>",
"<a href='https://example.test'>Hello world</a>",
"<custom>Hello world</custom>",
"<textarea>ignored</textarea>Hello world",
] {
let f = parse(&format!(
"<html><head><title>T &amp; U</title><meta name='author' content='Ann'><style>p{{}}</style><script>ignored</script>{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("<p style='text-align:center'><span style=\"color:#ff0000;font-weight:700\">red</span> <a href=\"http://x\">l</a><br>n</p>");
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<Vec<Vec<Para>>> {
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 <th> is bold, as in browsers and Word; `font-weight:normal` on it or inside it wins.
let f = parse(
"<table><thead><tr><th>Head</th><th style='font-weight:normal'>Plain</th><th><span style='font-weight:400'>Inner</span></th></tr></thead><tbody><tr><td>a<td>b<td>c</tbody></table>",
);
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(
"<table style='text-align:center'><tbody style='text-align:justify'><tr style='text-align:right'>\
<td style='text-align:right;'>r</td><td>inherited</td><td style='text-align:right'><p style='text-align:center'>own</p></td>\
<td align=right>attr</td></tr><tr><td>body</td></tr></tbody><tr><td>table</td></tr></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("<table align=center><tr><td>x</td></tr></table>");
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(
"<table border=\"1\" style=\"width:100%;border-collapse:collapse;\">\n<thead><tr style=\"background-color:#e8f0fa;\"><th>Indicatore</th><th>Valore atteso</th></tr></thead>\n<tbody>\n<tr><td>Audit</td><td style=\"text-align:right;\">4</td></tr>\n</tbody></table>",
);
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 &lt;b&gt; &#65;&#x42; &bogus; &"), "a <b> AB &bogus; &");
assert_eq!(unescape("&minus; &COPY; &EURO; &TRADE; &frac12; &frac34;"), "− © € ™ ½ ¾");
assert_eq!(unescape("&theta; &lambda; &Delta; &nabla; &sum; &radic; &ne; &le; &ge; &hearts;"), "θ λ Δ ∇ ∑ √ ≠ ≤ ≥ ♥");
}
#[test]
fn wbr_word_break_opportunity() {
let f = parse("<p>Super<wbr>cali<wbr>fragilistic</p>");
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("<p style='page-break-after: always'>First</p><p>Second</p>");
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("<div style='break-after: page'><p>Part 1</p></div><p>Part 2</p>");
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("<font size=\"+1\">larger</font><font size=\"-1\">smaller</font><font size=\"5\">huge</font>");
let inls = match &f.blocks[0] {
FBlock::Para(p) => &p.inlines,
_ => panic!("expected para"),
};
let sizes: Vec<Option<f32>> = 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"<html><head><meta charset=\"iso-8859-1\"></head><body>\xE9l\xE8ve</body></html>";
assert!(decode(iso).contains("élève"));
let win = b"<html><head><meta charset='windows-1252'></head><body>\x80100</body></html>";
assert!(decode(win).contains("€100"));
let gbk = b"<html><head><meta http-equiv=\"Content-Type\" content=\"text/html; charset=gbk\"></head><body>\xC4\xE3\xBA\xC3</body></html>";
assert!(decode(gbk).contains("你好"));
let xml = b"<?xml version=\"1.0\" encoding=\"utf-8\"?><html><body>hello</body></html>";
assert!(decode(xml).contains("hello"));
}
#[test]
fn raw_text_elements_skip_only_matching_end_tag() {
let f = parse("<script>var a = \"</scripted>\"; var b = \"<b>malicious</b>\";</script><p>Hello</p>");
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("<style>/* </styles> */ p { color: red; }</style><p>World</p>");
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("<p> 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 `<rt>`, not plain base text.
let out = export(&ruby_doc("漢字", "かんじ"));
assert!(out.contains("<p>I read <ruby>漢字<rt>かんじ</rt></ruby> daily.</p>"), "{out}");
// The base and the reading are escaped like any other text.
let out = export(&ruby_doc("A&B", "x<y"));
assert!(out.contains("<ruby>A&amp;B<rt>x&lt;y</rt></ruby>"), "{out}");
}
#[test]
fn ruby_imports_with_its_surrounding_text() {
let f = parse("<p>I read <ruby>漢字<rt>かんじ</rt></ruby> daily.</p>");
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() {
// `<rb>` holds the base text and `<rp>` the fallback parentheses for browsers without
// ruby: the parentheses are not text, and the base pieces join.
let f = parse("<p><ruby><rb>漢</rb><rb>字</rb><rp>(</rp><rt>かんじ</rt><rp>)</rp></ruby></p>");
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("<p style='font-size:20pt'><ruby>漢字<rt>かんじ</rt></ruby></p>");
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("<p><ruby>漢字<rt>かんじ</rt></ruby></p>");
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("<strong><ruby>漢字<rt>かんじ</rt></ruby></strong>"), "{}", export(&d));
// Importing it back keeps the formatting for the base text and the reading.
let f = parse("<p><strong><ruby>漢字<rt>かんじ</rt></ruby></strong></p>");
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] = [
("<p>a <ruby>漢字</ruby> b</p>", &["NormalNone:a 漢字 b"]),
("<p>a <ruby>漢字<rt></rt></ruby> b</p>", &["NormalNone:a 漢字 b"]),
("<p>a <ruby><rt>かんじ</rt></ruby> b</p>", &["NormalNone:a かんじ b"]),
("<p>a <ruby><rt></rt></ruby> b</p>", &["NormalNone:a b"]),
("<ruby>", &[]),
("<p>a <ruby>漢字<rt>かんじ</rt> b</p><p>next</p>", &["NormalNone:a 漢字 b", "NormalNone:next"]),
("<p>a <ruby>漢字<rt>かんじ</rt></p><p>next</p>", &["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("<p><ruby>漢字<ruby>字<rt>じ</rt></ruby><rt>かんじ</rt></ruby></p>")), Some(("漢字字".into(), "じかんじ".into())));
}
#[test]
fn small_big_and_css_font_sizes() {
let f = parse(
"<p><small>small</small> <big>big</big> <span style=\"font-size: xxx-large\">huge</span> <span style=\"font-size: smaller\">tiny</span></p>",
);
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(
"<p style='color: #ff0000 !IMPORTANT; font-size: 16pt ! important; font-weight: 700 !Important; text-align: center !IMPORTANT'>Styled</p>",
);
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("<table><tr><td valign=top>top</td><td valign=\"center\">mid</td><td style=\"vertical-align: bottom\">bot</td></tr></table>");
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"));
}
}