From db616480e22f29ef3e904c1ea80e6f88d07a690b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 21:33:47 -0500 Subject: [PATCH] craft-html: HTML documents on craft-flow (feature flow), from WordCraft (#1050) * Add craft-wordmodel: WordCraft's document model without its editing code WordCraft's crates/doc (wordcraft-doc, at 07f97737) moved as it is, so the DOCX, RTF, ODT and other text-format crates can read into and write from one model. Module paths, type names and serde attributes are unchanged: an app that re-exports the crate keeps its native file format. Left in the app: range editing (edit.rs), IME text and equation editing. Not model, and already in craft-libs: code pages (craft-rtf), the ZIP entry-count check (craft-opc), the compound-file guard (craft-cfb). The paragraph walks moved to walk.rs and the unit helpers of wordcraft-geom to geom.rs. * craft-wordmodel: math_tree, the arguments of an equation node and a walk over its runs The DOCX reader and writer walk an equation's runs. The walk and the child accessors it uses were in WordCraft's equation editor (math_edit.rs), which stays in the app; they are structure, not editing. * craft-mime: detect, for bytes that name no character set is_text, decode (BOM, unmarked UTF-16, UTF-8, else the legacy encoding the bytes look like), legacy and legacy_label, behind a detect feature that brings in chardetng. Moved from WordCraft's crates/formats/src/txt.rs at 06c2ae6f with its text-sniffing tests. Also charset::is_known, so a caller can tell a label decode would read as UTF-8 from one it knows. * craft-wordmodel: keep the document operations (edit.rs) with the model The DOCX and RTF readers use them: alternative-format chunks are merged into a document with adopt_blocks, and a reader formats ranges with format_range and inserts objects. They operate on the document, not on a view, so they belong beside it. The tests that drove them come back with them; the paragraph walks stay in walk.rs and edit re-exports them. base64 0.23, as WordCraft and craft-offcrypto use. * craft-codepage: one table per legacy code page, for craft-rtf, craft-dxf and craft-mime craft-rtf and craft-dxf each carried tables for DOS 437, 737, 850, 852 and 857 (and craft-rtf one for 866). They are now one set in craft-codepage, which takes craft-rtf's codepage module (Windows and Mac tables, OEM glyphs, font charsets) and craft-dxf's DOS tables. craft_rtf::codepage re-exports it unchanged; craft-dxf keeps its $DWGCODEPAGE names and Decoder and looks the DOS tables up there. Every byte of every code page either crate decodes is unchanged: the public decoders of both crates were dumped over all 256 bytes of each page (and the East Asian pairs) before and after, and the dumps are identical. craft-mime now reads the DOS pages by their IANA names (IBM437, cp850, ibm-852, 857...), which it read as UTF-8 before. * Add craft-math: the equation tree with OMML, MathML, LaTeX and UnicodeMath The math tree, LaTeX, linear-format, symbol and tree-walk modules come from craft-wordmodel (storytold/craft-libs#963, WordCraft's crates/doc at 07f97737) unchanged, with the run colour (Rgb) and the symbol-font tables they use. MathML is WordCraft's crates/formats/src/mathml.rs and OMML is WordCraft's crates/docx/src/read/math.rs and write/math.rs (06c2ae6f / 07f97737), moved onto craft-xml. The HTML-specific MathML tests stay in WordCraft; the rest moved with the code. mathml::parse and omml::parse/read_str/write_str are new: string entry points for callers that have no tree of their own. * craft-math: read MathLive's and other spellings of LaTeX math commands \abs{...} (an absolute value), \lvert \rvert \lVert \rVert, \nparallel, \backprime, \infinity, \cross, \differentialD, \exponentialE and \imaginaryI (MathLive's d, upright e and i), \emph, \textsf, \hbox, \mathnormal and \underleftarrow / \underrightarrow / \underleftrightarrow were unknown and read as literal text. They are read as equaSHion's typed-input parser reads them (storytold/craft-libs#29). * craft-math: equaSHion's equation corpus as test data corpus/equations.tsv, corpus/latex.tsv and the OMML samples of storytold/craft-libs#29 (7a6ef6b9), unmodified. * craft-math: corpus round trips, GridCraft's OMML cases, entry points and limits tested; README The corpus equations go through OMML, MathML and LaTeX and back; the OMML samples are written back as kept and from the tree. GridCraft's OMML to LaTeX test cases (crates/xlsx/src/omml.rs) give LaTeX that reads back the same through the tree. omml::parse and read_str are tested against other prefixes, the strict namespace, kept OMML, junk, the element cap and deep nesting. * Add craft-flow: the flow model the text formats map to, on craft-wordmodel WordCraft's crates/formats/src/model (07f97737) moved as it is: Flow, FBlock, Para, Inline, Fmt, tables and cells, pictures, to_doc and from_doc, picture sniffing, colours, base64 and the Dropped report. Only the paths changed (wordcraft_doc is craft_wordmodel); the report helpers are public so the format crates can call them; the image re-encoding helper stays with WordCraft's RTF writer. * Add craft-markdown: Markdown read and written, on craft-flow WordCraft's crates/formats/src/markdown (07f97737) with its 35 tests, moved onto craft-flow and craft-wordmodel. Character references come from craft-html. The table cell budget lives in craft-flow, which names it in its warning. Two test lines that wrote plain text through WordCraft's txt module stay in WordCraft. * WIP: craft-latex sources from WordCraft (unmodified) * Add craft-latex: LaTeX documents read and written, on craft-flow WordCraft's crates/formats/src/latex (07f97737) with its 21 tests, moved onto craft-flow and craft-wordmodel; bytes are decoded by craft-mime's detect. The LaTeX parts of WordCraft's cross-format tests (nesting, column specs, labels, no TeX commands from the document, the fuzz and round-trip properties) come along as tests/hostile.rs. The ligature test builds its three-font paragraph as a flow instead of reading HTML. * craft-math: latex_text, LaTeX math and the linear format converted as text WordCraft's crates/formats/src/latex/math.rs (07f97737): the converter its LaTeX document reader and writer use, which keeps the source's spacing. It moves here so craft-math is the one place that converts LaTeX math; craft-latex calls it, and its output does not change. Its tests (from WordCraft's latex tests and cross-format tests) come along. * craft-math: latex_text's group finders are public, for craft-latex's length parser * craft-latex: math through craft_math::latex_text; label timing test by ratio The document math converter moved to craft-math (#1040) unchanged; this crate calls it there (and its length parser uses its group finders), so export output is the same. The label test compared one read with a ten-second wall clock and failed on a loaded machine; it now compares the fastest of three reads of 25,000 and 100,000 labels, which a linear read keeps near 4x and a quadratic one near 16x. * Cargo.lock after the merge * WIP: WordCraft's HTML document reader and writer, unmodified (crates/formats/src/html at 07f97737) * craft-html: HTML documents on craft-flow, behind a flow feature WordCraft's crates/formats/src/html (07f97737) as craft_html::doc: the tag-soup builder, CSS subset, ruby and MathML reading, page decoding and the writer, with its 44 tests. It reads tokens from this crate's tokenizer through a small adapter (decoded text and attributes, names in lower case) instead of its own lexer, and its character references are this crate's. Lexer::set_pos is new: the ruby reader hands a token back. The HTML parts of WordCraft's MathML and cross-format tests come along as tests/doc.rs, the linear-time check as a ratio. * craft-math: operator names take their LaTeX scripts directly again (restores #1019) #1019 (22161d2d) wrote \sin^2\theta as \sin ^{2}{\theta } so it reads back as the same tree. The squash merge of #1038, whose branch predated it, put math_latex.rs and the corpus test back as they were before. This applies #1019's change again, unchanged, with its test. * Cargo.lock: craft-html's flow dependencies --------- Co-authored-by: shuff57 <62350898+shuff57@users.noreply.github.com> --- Cargo.lock | 6 + README.md | 2 +- crates/html/Cargo.toml | 16 + crates/html/README.md | 18 +- crates/html/src/doc.rs | 50 ++ crates/html/src/doc/builder.rs | 1193 ++++++++++++++++++++++++++++++ crates/html/src/doc/css.rs | 167 +++++ crates/html/src/doc/entity.rs | 46 ++ crates/html/src/doc/export.rs | 393 ++++++++++ crates/html/src/doc/import.rs | 125 ++++ crates/html/src/doc/lexer.rs | 50 ++ crates/html/src/doc/math_ruby.rs | 156 ++++ crates/html/src/doc/tests.rs | 769 +++++++++++++++++++ crates/html/src/lex.rs | 12 + crates/html/src/lex/tests.rs | 15 + crates/html/src/lib.rs | 2 + crates/html/tests/doc.rs | 93 +++ 17 files changed, 3111 insertions(+), 2 deletions(-) create mode 100644 crates/html/src/doc.rs create mode 100644 crates/html/src/doc/builder.rs create mode 100644 crates/html/src/doc/css.rs create mode 100644 crates/html/src/doc/entity.rs create mode 100644 crates/html/src/doc/export.rs create mode 100644 crates/html/src/doc/import.rs create mode 100644 crates/html/src/doc/lexer.rs create mode 100644 crates/html/src/doc/math_ruby.rs create mode 100644 crates/html/src/doc/tests.rs create mode 100644 crates/html/tests/doc.rs diff --git a/Cargo.lock b/Cargo.lock index 43093fd2..5b22a65a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1290,6 +1290,12 @@ dependencies = [ name = "craft-html" version = "0.1.0" dependencies = [ + "craft-codepage", + "craft-flow", + "craft-math", + "craft-mime", + "craft-wordmodel", + "encoding_rs", "proptest", ] diff --git a/README.md b/README.md index 5df97746..598c082f 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " " '` as references; `escape_dq` leaves `'`, for content and attribute values in double quotes. +## HTML documents (feature `flow`) + +`craft_html::doc` reads a page or a pasted fragment into the [craft-flow](../flow/README.md) +model and a craft-wordmodel `Document`, and writes one as clean HTML. The reader is a tolerant +tag-soup builder on this crate's tokenizer (a stack of open elements with HTML's implicit closes), +with a CSS subset from `style` attributes and `\n\n\n", + ); + blocks_html(&flow.blocks, &mut out, 0); + out.push_str("\n\n"); + out +} diff --git a/crates/html/src/doc/import.rs b/crates/html/src/doc/import.rs new file mode 100644 index 00000000..c653f4b4 --- /dev/null +++ b/crates/html/src/doc/import.rs @@ -0,0 +1,125 @@ +use super::builder::{Builder, ImageLoader, parse_sheet}; +use super::lexer::{Lexer, Tok}; +use super::math_ruby::{read_math, read_ruby}; +use super::{Document, Flow, Inline, model}; + +/// 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 { + parse_in(s, images, false) +} + +pub(super) fn parse_in(s: &str, images: ImageLoader, page: bool) -> Flow { + // HTML normalizes input newlines before tokenization, not after decoding character references. + let normalized = if s.contains('\r') { + let mut out = String::with_capacity(s.len()); + let mut chunks = s.split('\r'); + out.push_str(chunks.next().unwrap_or_default()); + for chunk in chunks { + out.push('\n'); + out.push_str(chunk.strip_prefix('\n').unwrap_or(chunk)); + } + std::borrow::Cow::Owned(out) + } else { + std::borrow::Cow::Borrowed(s) + }; + // The lexer's offsets (a style element's text is cut out by them) are into the normalized text. + let s: &str = &normalized; + let mut lx = Lexer::new(s); + let mut b = Builder::new(images, page); + 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 craft_math::mathml::local(&name) == "math" && !self_close { + let el = read_math(&mut lx, name, attrs); + if !b.in_head() + && let Some((linear, display)) = craft_math::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 { + let from = lx.pos(); + let to = lx.skip_past_end_tag(&name); + if name == "style" { + parse_sheet(s.get(from..to).unwrap_or(""), &mut b.sheet); + } + continue; + } + b.start(&name, &attrs, self_close); + } + Tok::End(name) => b.end(&name), + } + } + b.finish() +} + +/// 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. +pub(super) 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::begin_import(); + 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::begin_import(); + model::to_doc(&parse_with(s, images)) +} + +/// A web page opened as a document, as Word opens one (a file, or a document's HTML +/// `w:altChunk`): its text is Times New Roman 12 pt on single lines, headings bold and black at +/// the sizes browsers give them, and a `p` or heading whose CSS leaves a margin out (or writes it +/// invalidly) gets HTML's automatic spacing there; a margin a span states replaces the one its +/// paragraph states (both as Word's import does). [`import`] reads a pasted fragment, which takes +/// the document's own formatting instead (but its CSS still applies). +pub fn import_page(s: &str, images: ImageLoader) -> Document { + model::begin_import(); + model::to_doc(&parse_in(s, images, true)) +} + +/// [`import_page`] 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: &craft_wordmodel::SectionProps) -> Document { + model::begin_import(); + model::to_doc_in(&parse_in(s, images, true), page.clone()) +} + +/// The font of a web page's text when its CSS names none. +pub const PAGE_FONT: &str = "Times New Roman"; +/// Heading sizes on a web page, `h1`–`h6`, points. +pub(super) const PAGE_HEADING_SIZES: [f32; 6] = [24.0, 18.0, 13.5, 12.0, 10.0, 7.5]; diff --git a/crates/html/src/doc/lexer.rs b/crates/html/src/doc/lexer.rs new file mode 100644 index 00000000..a29d6d38 --- /dev/null +++ b/crates/html/src/doc/lexer.rs @@ -0,0 +1,50 @@ +//! The crate's tokenizer ([`crate::Lexer`]) as the document reader takes its tokens: text and +//! attribute values decoded, element and attribute names in lower case. + +use crate::{Token, unescape, unescape_attr}; + +#[derive(Debug)] +pub(super) enum Tok { + Text(String), + Start { name: String, attrs: Vec<(String, String)>, self_close: bool }, + End(String), +} + +pub(super) struct Lexer<'a> { + inner: crate::Lexer<'a, 'static>, +} + +impl<'a> Lexer<'a> { + pub(super) fn new(s: &'a str) -> Lexer<'a> { + Lexer { inner: crate::Lexer::new(s) } + } + + /// Where the next token starts. + pub(super) fn pos(&self) -> usize { + self.inner.pos() + } + + /// Read again from `pos` (an earlier [`Lexer::pos`]). + pub(super) fn set_pos(&mut self, pos: usize) { + self.inner.set_pos(pos); + } + + /// Skip past the end tag of raw text element `tag`; returns where that end tag starts (the + /// end of the input when there is none). + pub(super) fn skip_past_end_tag(&mut self, tag: &str) -> usize { + let from = self.inner.pos(); + from + self.inner.raw_content(tag).len() + } + + pub(super) fn next(&mut self) -> Option { + Some(match self.inner.next()? { + Token::Text(t) => Tok::Text(unescape(t)), + Token::Start(tag) => Tok::Start { + attrs: tag.attrs.iter().map(|(n, v)| (n.to_ascii_lowercase(), unescape_attr(v))).collect(), + name: tag.name, + self_close: tag.self_close, + }, + Token::End(name) => Tok::End(name), + }) + } +} diff --git a/crates/html/src/doc/math_ruby.rs b/crates/html/src/doc/math_ruby.rs new file mode 100644 index 00000000..3a81cde5 --- /dev/null +++ b/crates/html/src/doc/math_ruby.rs @@ -0,0 +1,156 @@ +use super::builder::block_kind; +use super::lexer::{Lexer, Tok}; + +/// 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. +pub(super) fn read_math(lx: &mut Lexer, name: String, attrs: Vec<(String, String)>) -> craft_math::mathml::El { + use craft_math::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() > craft_wordmodel::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 craft_math::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)] +pub(super) struct RubyText { + pub(super) base: String, + pub(super) 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. +pub(super) 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.set_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 +} diff --git a/crates/html/src/doc/tests.rs b/crates/html/src/doc/tests.rs new file mode 100644 index 00000000..640ab07d --- /dev/null +++ b/crates/html/src/doc/tests.rs @@ -0,0 +1,769 @@ +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"); +} + +#[test] +fn important_declarations_win_the_cascade() { + let color = |html: &str| fmt_of(paras(&parse(html))[0]).color; + let red = Some(craft_wordmodel::Rgb(255, 0, 0)); + let blue = Some(craft_wordmodel::Rgb(0, 0, 255)); + assert_eq!(color("

x

"), blue); + assert_eq!(color("

x

"), red); + assert_eq!(color("

x

"), blue); + assert_eq!(color("

x

"), red); +} + +#[test] +fn a_declared_charset_decodes_the_page() { + // ISO 8859-2 (Central European): 0xA9 Š, 0xB9 š; named by the caller or the page. + assert_eq!(decode_as(&[0xA9, b' ', 0xB9], Some("iso-8859-2")), "Š š"); + assert!(decode_as(b"

\xA9

", None).contains("

Š

")); + assert_eq!(decode_as(&[0x8A], Some("windows-1252")), "Š"); + // A page that says UTF-16 without a BOM is read as UTF-8, as browsers do. + assert_eq!(decode_as("é".as_bytes(), Some("utf-16")), "é"); +} + +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 = craft_flow::to_doc(&parse(html)); + doc.body.iter().find_map(|b| match &**b { + craft_wordmodel::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")); + // The last declaration decides: a `style` attribute's over the style sheet's. + assert_eq!(table_style("
a
").as_deref(), Some("TableGrid")); + assert_eq!(table_style("
a
").as_deref(), Some("")); +} + +#[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(&craft_flow::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 input_newlines_preserve_preformatted_lines() { + for newline in ["\n", "\r\n", "\r"] { + let f = parse(&format!("
{newline}  Alpha{newline}  Beta{newline}{newline}  Gamma
")); + assert_eq!(texts(&f), ["CodeNone: Alpha", "CodeNone: Beta", "CodeNone:", "CodeNone: Gamma"], "{newline:?}"); + let FBlock::Para(p) = &f.blocks[1] else { panic!() }; + assert!(matches!(p.inlines.last(), Some(Inline::Text(t, f)) if t == "Beta" && f.bold)); + } +} + +#[test] +fn input_newlines_are_normalized_before_character_references() { + let f = parse("

A B\r\nC\rD

A\r\nB\rC

"); + assert_eq!(texts(&f), ["NormalNone:A\rB\nC\nD", "NormalNone:A B C"]); + let f = parse("

α\r\r\nβ γ

link"); + assert_eq!(texts(&f), ["NormalNone:α\n\nβ\rγ", "NormalNone:link"]); + let FBlock::Para(p) = &f.blocks[1] else { panic!() }; + assert!(matches!(p.inlines.first(), Some(Inline::Text(_, f)) if f.link.as_deref() == Some("https://example.test/a\nb\nc\rd"))); +} + +#[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(craft_wordmodel::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("θ λ Δ ∇ ∑ √ ≠ ≤ ≥ ♥"), "θ λ Δ ∇ ∑ √ ≠ ≤ ≥ ♥"); +} + +/// Browsers decode numeric and legacy named references without their `;` in text, +/// but keep `&name=` and `&nameX` intact inside attribute values (query strings). +#[test] +fn references_without_semicolon() { + assert_eq!(unescape("© 2024 Acme"), "© 2024 Acme"); + assert_eq!(unescape("© 2024 Acme"), "© 2024 Acme"); + assert_eq!(unescape("© 2024 & <"), "© 2024 & <"); + assert_eq!(unescape("&bogus &# &#x &"), "&bogus &# &#x &"); + assert_eq!(unescape(" x"), "\u{00A0}x"); + let f = parse("

l © 2024

"); + let FBlock::Para(p) = &f.blocks[0] else { panic!() }; + assert!(p.inlines.iter().any(|i| matches!(i, Inline::Text(t, f) if t == "l" && f.link.as_deref() == Some("/q?a=1©=2®2=3&b=<")))); + assert!(p.text().ends_with("© 2024")); +} + +#[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"); +} + +#[test] +fn css_escapes_read_every_kind_of_newline() { + // A backslash before CR LF, CR, LF or form feed continues the line; after hex digits, one + // of them (CR LF as one) ends the escape. + assert_eq!(css_unescape("A\\\r\nB\\\rC\\\nD\\\u{C}E"), "ABCDE"); + assert_eq!(css_unescape("\\41\r\nB \\42\u{C}C \\43 D"), "AB BC CD"); + assert_eq!(css_unescape("x\\'y\\"), "x'y"); +} + +#[test] +fn font_names_are_written_as_css_strings() { + // A font name could end the CSS string or the declaration and add CSS of its own (#3739). + let fonts = ["Arial\n;position:fixed", "Back\\slash", "O'Brien \"Sans\"", "A,B", "Times New Roman", "M+ 1p"]; + let mut p = Para::default(); + for (i, font) in fonts.iter().enumerate() { + p.push_text(&format!("t{i}"), &Fmt { font: Some((*font).into()), ..Default::default() }); + } + let html = export(&model::to_doc(&Flow { blocks: vec![FBlock::Para(p)], meta: Meta::default() })); + assert!(html.contains("font-family:'Times New Roman'") && html.contains("font-family:'M+ 1p'"), "{html}"); + let styles: Vec<&str> = html.split(" style=\"").skip(1).filter_map(|s| s.split('"').next()).filter(|s| s.contains("font-family")).collect(); + assert_eq!(styles.len(), fonts.len(), "{html}"); + for s in styles { + assert!(!s.contains([';', '\n']) && s.matches('\'').count() == 2, "{s}"); + } + // Read back, each run has its font as it was. + let back = parse(&html); + let runs: Vec<(String, Option)> = back + .blocks + .iter() + .filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None }) + .flat_map(|p| p.inlines.iter()) + .filter_map(|i| if let Inline::Text(t, f) = i { Some((t.clone(), f.font.as_deref().map(String::from))) } else { None }) + .collect(); + for (i, font) in fonts.iter().enumerate() { + assert!(runs.contains(&(format!("t{i}"), Some(font.to_string()))), "{font:?} in {runs:?}"); + } +} + +/// A document that reads "I read " then a phonetic guide over `base`, then " daily.". +fn ruby_doc(base: &str, ruby: &str) -> Document { + let props = craft_wordmodel::CharProps::default(); + let mut p = craft_wordmodel::Paragraph::with_text("I read ", props.clone()); + let n = p.len(); + p.insert_object(n, craft_flow::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![craft_wordmodel::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 { + craft_wordmodel::Block::Para(p) => p.objects.iter().find_map(|o| match o { + craft_wordmodel::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, craft_wordmodel::para::RubyAlign)> { + d.body.iter().find_map(|b| match &**b { + craft_wordmodel::Block::Para(p) => p.objects.iter().find_map(|o| match o { + craft_wordmodel::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(&craft_flow::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(&craft_flow::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::doc::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::doc::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::doc::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 = craft_wordmodel::CharProps { bold: Some(true), ..Default::default() }; + let mut p = craft_wordmodel::Paragraph::with_text("I read ", props.clone()); + let n = p.len(); + p.insert_object(n, craft_flow::ruby_object("漢字".into(), "かんじ".into(), 20.0), &props).unwrap(); + let mut d = Document::new(); + d.body = vec![craft_wordmodel::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(craft_wordmodel::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 = craft_flow::to_doc(&f); + let craft_wordmodel::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")); +} + +#[test] +fn many_formatted_leading_breaks_trim_quickly() { + // #3705: a paragraph opening with alternating bold and italic `
` was trimmed one + // span at a time with `Vec::remove(0)`. + let html = format!("

{}x

", "

".repeat(100_000)); + let t = std::time::Instant::now(); + let f = parse(&html); + // About a second on a loaded machine; trimming one span at a time ran past 6 minutes. + assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed()); + let texts: Vec = f.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p.text()) } else { None }).collect(); + assert_eq!(texts, ["x"]); +} + +#[test] +fn font_size_at_the_integer_limits_does_not_overflow() { + // `` offsets were added to 3 unchecked, so the extreme values panicked in debug builds. + for size in ["+2147483647", "--2147483648", "-2147483647", "+99999999999"] { + let f = parse(&format!("x")); + assert!(texts(&f).iter().any(|t| t.ends_with(":x")), "size={size}"); + } + // The offsets saturate, then clamp to the 1..=7 scale: 7 is 36 pt, 1 is 7.5 pt. + let size = |html: &str| match &parse(html).blocks[0] { + FBlock::Para(p) => p.inlines.iter().find_map(|i| match i { + Inline::Text(_, fmt) => fmt.size, + _ => None, + }), + _ => None, + }; + assert_eq!(size("x"), Some(36.0)); + assert_eq!(size("x"), Some(36.0)); + assert_eq!(size("x"), Some(7.5)); +} + +#[test] +fn empty_comments_do_not_swallow_the_rest() { + for html in ["

ab

c

", "

ab

c

"] { + let doc = crate::doc::import(html); + assert_eq!(doc.plain_text(Default::default()), "ab\nc", "{html}"); + } +} + +fn paras(f: &Flow) -> Vec<&Para> { + f.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None }).collect() +} +fn fmt_of(p: &Para) -> Fmt { + p.inlines.iter().find_map(|i| if let Inline::Text(_, f) = i { Some(f.clone()) } else { None }).unwrap_or_default() +} + +#[test] +fn style_sheet_rules_apply_in_cascade_order() { + let f = parse( + "\ +

plain

big

inline wins

digits

", + ); + let ps = paras(&f); + let (plain, big, inline, digits) = (fmt_of(ps[0]), fmt_of(ps[1]), fmt_of(ps[2]), fmt_of(ps[3])); + assert_eq!((plain.color, plain.size, plain.bold, plain.italic), (Some(craft_wordmodel::Rgb(0, 255, 0)), Some(10.0), false, false)); + assert_eq!((big.size, big.bold), (Some(20.0), true)); + assert_eq!(inline.size, Some(8.0)); + assert_eq!(ps[3].align, Some(Align::Center)); + assert_eq!(digits.size, Some(10.0)); +} + +#[test] +fn paragraph_css_states_spacing_indents_and_line_height() { + let f = parse( + "\ +

a

b

c

", + ); + let ps = paras(&f); + assert_eq!( + ps[0].spacing, + Spacing { + before: Some(6.0), + after: Some(3.0), + left: Some(72.0), + right: Some(72.0), + first: Some(-18.0), + line: Some(LineSpacing::Multiple(1.5)), + ..Spacing::default() + } + ); + // Values that don't parse (a comma decimal, `auto`) state nothing. + assert_eq!(ps[1].spacing, Spacing { line: Some(LineSpacing::Exactly(20.0)), ..Spacing::default() }); + assert_eq!(ps[2].spacing, Spacing::default()); +} + +#[test] +fn a_page_opens_as_word_opens_one() { + let html = "\ +

first

Head

kept

own

bare

 

"; + let f = parse_in(html, &|_| None, true); + let ps = paras(&f); + // Times New Roman 12 pt on single lines; headings bold and black at a browser's sizes. + let first = fmt_of(ps[0]); + assert_eq!((first.font.as_deref(), first.size), (Some(PAGE_FONT), Some(12.0))); + assert_eq!(ps[0].spacing.line, Some(LineSpacing::Multiple(1.0))); + let cell = parse_in("
cell
", &|_| None, true); + let FBlock::Table(t) = &cell.blocks[0] else { panic!() }; + let FBlock::Para(cp) = &t.rows[0][0].blocks[0] else { panic!() }; + assert_eq!(cp.spacing.line, Some(LineSpacing::Multiple(1.0))); + let head = fmt_of(ps[1]); + assert_eq!((head.bold, head.size, head.color), (true, Some(24.0), Some(craft_wordmodel::Rgb(0, 0, 0)))); + // A colour the heading inherits stays. + // A generic family names a face on a page; pasted text keeps the document's font. + let generic = parse_in("

a

b

", &|_| None, true); + let fonts: Vec> = paras(&generic).iter().map(|p| fmt_of(p).font.map(|f| f.to_string())).collect(); + assert_eq!(fonts, [Some("Arial".to_string()), Some(PAGE_FONT.to_string())]); + assert_eq!(fmt_of(paras(&parse("

a

"))[0]).font, None); + let inherited = parse_in("

Head

", &|_| None, true); + assert_eq!(fmt_of(paras(&inherited)[0]).color, Some(craft_wordmodel::Rgb(0, 0, 0x80))); + // Automatic spacing where a p or heading leaves its margins out (layout drops it at the + // top of the page and of a cell). + let auto = |p: &Para| (p.spacing.before_auto, p.spacing.after_auto, p.spacing.before, p.spacing.after); + assert_eq!(auto(ps[0]), (true, true, None, None)); + assert_eq!(auto(ps[1]), (true, true, None, None)); + // A span's margin replaces the one its paragraph states; the one it leaves out stays automatic. + assert_eq!(auto(ps[2]), (true, false, None, Some(0.0))); + assert_eq!(auto(ps[3]), (true, false, None, Some(9.75))); + // Other blocks get no automatic spacing. + assert_eq!(auto(ps[4]), (false, false, None, None)); + // The document says so, and the empty paragraph's mark keeps its size. + let d = model::to_doc(&f); + let props = |i: usize| match &*d.body[i] { + craft_wordmodel::Block::Para(p) => p.clone(), + b => panic!("{b:?}"), + }; + assert_eq!((props(0).props.space_before_auto, props(0).props.space_after_auto), (Some(true), Some(true))); + assert_eq!((props(2).props.space_after_auto, props(2).props.space_after), (None, Some(0.0))); + assert_eq!(props(5).mark.size, Some(12.0)); + // `

 

` keeps the size of its space for its line. + assert!(ps[5].inlines.is_empty()); + assert_eq!(ps[5].mark.as_ref().and_then(|m| m.size), Some(12.0)); + // A fragment (a paste) keeps the document's own formatting. + let pasted = parse(html); + assert_eq!(fmt_of(paras(&pasted)[0]).font, None); + assert_eq!(paras(&pasted)[0].spacing, Spacing::default()); +} + +#[test] +fn cells_give_their_columns_widths() { + let f = parse( + "
t
1ab
", + ); + let FBlock::Table(t) = &f.blocks[0] else { panic!("{:?}", f.blocks) }; + assert_eq!(t.widths, [31.5, 150.0, 144.0]); + // A column without a width leaves them all to the layout. + let f = parse("
ab
"); + let FBlock::Table(t) = &f.blocks[0] else { panic!() }; + assert!(t.widths.is_empty()); + // A cell beside a cell spanning rows from above gives its own column its width. + let f = + parse("
ab
c
de
"); + let FBlock::Table(t) = &f.blocks[0] else { panic!() }; + assert_eq!(t.widths, [75.0, 150.0]); +} + +#[test] +fn cells_take_their_css_borders() { + use craft_wordmodel::props::{Border, BorderStyle}; + let f = parse( + "
ab
", + ); + let FBlock::Table(t) = &f.blocks[0] else { panic!() }; + // The style sheet's other cell properties apply too. + assert_eq!(t.rows[0][0].valign, Some(craft_wordmodel::props::VAlign::Bottom)); + let b = t.rows[0][0].borders.expect("borders"); + assert_eq!(b.top, Some(Border::single(0.75))); + assert_eq!(b.bottom, Some(Border { style: BorderStyle::Double, width: 2.0, color: Some(craft_wordmodel::Rgb(255, 0, 0)), space: 0.0 })); + assert_eq!(b.left.map(|l| l.style), Some(BorderStyle::None)); + // A border of no width is none (OOXML has no zero-width line). + assert_eq!(b.right.map(|r| r.style), Some(BorderStyle::None)); + assert_eq!(t.rows[0][1].borders, None); +} + +#[test] +fn style_sheet_edge_cases() { + let f = parse_in( + "\ +

a

bare

b

", + &|_| None, + true, + ); + let ps = paras(&f); + // A rule right after statement at-rules still applies. + assert_eq!(fmt_of(ps[0]).color, Some(craft_wordmodel::Rgb(0, 0, 255))); + // The body's margins aren't the paragraphs' indents. + assert_eq!((ps[1].spacing.left, ps[1].spacing.before), (None, None)); + // Bare numbers, percentages and keywords state no margin: automatic spacing stays. + assert_eq!((ps[2].spacing.before_auto, ps[2].spacing.left), (true, None)); + // The `style` attribute wins even over a sheet with more declarations than are read. + let many = (0..600).map(|i| format!("x-{i}: 1")).collect::>().join(";"); + let f = parse(&format!("

c

")); + assert_eq!(fmt_of(paras(&f)[0]).color, Some(craft_wordmodel::Rgb(0, 255, 0))); +} + +#[test] +fn hostile_style_sheets_never_panic() { + // One declaration block shared by many selectors isn't copied for each. + let selectors = vec!["p"; 20_000].join(","); + let huge = format!("

x

", "x: y;".repeat(200_000)); + let _ = parse_in(&huge, &|_| None, true); + let many = ".a { color: red } ".repeat(20_000); + for css in ["{", "}", "p {", "@media { p { x } ", "p { color: red", "/* open", ".{}", "p..a{}", "\u{0}.é{x:y}", many.as_str()] { + let _ = parse_in(&format!("

x

y"), &|_| None, true); + } +} diff --git a/crates/html/src/lex.rs b/crates/html/src/lex.rs index bf6688ff..ef6b2063 100644 --- a/crates/html/src/lex.rs +++ b/crates/html/src/lex.rs @@ -110,6 +110,18 @@ impl<'a, 'o> Lexer<'a, 'o> { self.pos } + /// Goes back (or forward) to `pos`, a byte offset an earlier [`Lexer::pos`] gave, to read + /// from there again: for a reader that looked at a token and hands it back. An offset past the + /// end, or inside a character, moves to the next character boundary. + pub fn set_pos(&mut self, pos: usize) { + let mut pos = pos.min(self.src.len()); + while !self.src.is_char_boundary(pos) { + pos += 1; + } + self.pos = pos; + self.raw = None; + } + /// The raw content of element `name`, whose start tag was the last token: everything up to /// its end tag (any letter case), or to the end of the input when it has none. The end tag /// is consumed. For `style` and `script`, whose content is not markup. diff --git a/crates/html/src/lex/tests.rs b/crates/html/src/lex/tests.rs index 4f19e5b7..084caf70 100644 --- a/crates/html/src/lex/tests.rs +++ b/crates/html/src/lex/tests.rs @@ -175,3 +175,18 @@ proptest::proptest! { } } } + +#[test] +fn a_token_can_be_read_again() { + let mut l = Lexer::new("éx"); + assert_eq!(l.next(), Some(Token::Text("é"))); + let at = l.pos(); + assert!(matches!(l.next(), Some(Token::Start(t)) if t.name == "b")); + l.set_pos(at); + assert!(matches!(l.next(), Some(Token::Start(t)) if t.name == "b")); + // Inside a character or past the end: the next boundary. + l.set_pos(1); + assert!(matches!(l.next(), Some(Token::Start(t)) if t.name == "b")); + l.set_pos(1000); + assert_eq!(l.next(), None); +} diff --git a/crates/html/src/lib.rs b/crates/html/src/lib.rs index 45f29766..70f19746 100644 --- a/crates/html/src/lib.rs +++ b/crates/html/src/lib.rs @@ -34,6 +34,8 @@ #[doc = include_str!("../README.md")] struct ReadmeExamples; +#[cfg(feature = "flow")] +pub mod doc; mod entity; mod lex; diff --git a/crates/html/tests/doc.rs b/crates/html/tests/doc.rs new file mode 100644 index 00000000..fab55cc1 --- /dev/null +++ b/crates/html/tests/doc.rs @@ -0,0 +1,93 @@ +//! HTML documents (feature `flow`): the HTML parts of WordCraft's cross-format and MathML tests +//! (`crates/formats/src/tests.rs`, `mathml.rs`): equations through HTML, hostile input, entities. + +use craft_flow::{FBlock, Inline}; +use craft_html::doc::{import, inlines_html, parse}; +use std::time::{Duration, Instant}; + +fn equations(html: &str) -> Vec<(String, bool)> { + let mut out = Vec::new(); + for b in &parse(html).blocks { + if let FBlock::Para(p) = b { + for i in &p.inlines { + if let Inline::Equation { linear, display } = i { + out.push((linear.clone(), *display)); + } + } + } + } + out +} + +#[test] +fn html_export_round_trips_equations() { + for (linear, display) in [("y=2x_s", false), ("x=(-b±√(b^2-4ac))/2a", true), ("∑_(k=1)^n▒k", false), ("■(a&b@c&d)", false)] { + let html = inlines_html(&[Inline::Equation { linear: linear.into(), display }]); + assert!(html.starts_with("Before {html} after

")), vec![(linear.to_string(), display)], "{html}"); + } + // Text after the equation still arrives; an unclosed element ends with the input. + assert!(import("

x tail

").plain_text(Default::default()).contains("tail")); + assert!(!equations("

x").is_empty()); + let deep = format!("{}x{}", "".repeat(5000), "".repeat(5000)); + assert!(equations(&deep).len() <= 1); +} + +#[test] +fn math_over_the_token_cap_is_skipped_to_its_end() { + // Past the cap, the rest of the element is dropped, not read as body text. + let html = format!("

{}z tail

", "y".repeat(craft_math::mathml::MAX_NODES * 2)); + let mut text = String::new(); + for b in &parse(&html).blocks { + if let FBlock::Para(p) = b { + for i in &p.inlines { + if let Inline::Text(t, _) = i { + text.push_str(t); + } + } + } + } + assert_eq!(text.trim(), "tail"); +} + +#[test] +fn deep_nesting_is_bounded() { + let html = "
".repeat(5000) + "x"; + assert!(import(&html).plain_text(Default::default()).contains('x')); +} + +#[test] +fn entities_in_text() { + let d = import("

<&Aé &nosuchentity; A

"); + assert_eq!(d.plain_text(Default::default()).trim(), "<&Aé &nosuchentity; A"); + // An entity name longer than the scan window is left alone. + let long = format!("

&{};

", "a".repeat(40)); + assert!(import(&long).plain_text(Default::default()).contains(&"a".repeat(40))); +} + +/// A run of `&#` is read in linear time: four times as much takes well under the sixteen times +/// as long a quadratic read would (a ratio, so a busy machine does not fail it). +#[test] +fn a_run_of_references_takes_linear_time() { + let fastest = |n: usize| { + let text = "&#".repeat(n); + (0..3) + .map(|_| { + let t = Instant::now(); + let _ = import(&text).plain_text(Default::default()); + t.elapsed() + }) + .min() + .unwrap_or_default() + }; + let (small, large) = (fastest(125_000), fastest(500_000)); + assert!(large < small * 8 + Duration::from_millis(50), "{small:?} for 125,000, {large:?} for 500,000"); +} + +proptest::proptest! { + #[test] + fn fuzz_html(s in "(<(/)?(p|b|i|ul|ol|li|table|tr|td|th|pre|blockquote|a href=x|img src=data:,|br|h2|span style='color:red')>|[a-z &;#<>\"'=]){0,120}") { + let d = import(&s); + let _ = craft_html::doc::export(&d); + } +}