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>
This commit is contained in:
Dylan M. Taylor
2026-11-11 21:33:47 -05:00
committed by GitHub
co-authored by shuff57
parent f3fe9de956
commit db616480e2
17 changed files with 3111 additions and 2 deletions
Generated
+6
View File
@@ -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",
]
+1 -1
View File
@@ -144,7 +144,7 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
heic-rs reads the container and HEVC pictures, and this crate converts to 16-bit RGB to match
libheif (VUI range and matrix, chroma upsampling, `imir`, ICC on grid tiles). Also orientation,
alpha, EXIF/XMP and thumbnails. It never panics, and apps put it behind a non-default feature.
- [`craft-html`](crates/html/README.md): the bottom layer of reading and writing HTML: a tolerant tokenizer (text, start tags, end tags; elements to drop or to read as raw text are options), character references with the legacy names browsers accept without `;`, escaping, and the character set a page declares. No tree, no CSS, no rendering.
- [`craft-html`](crates/html/README.md): the bottom layer of reading and writing HTML: a tolerant tokenizer (text, start tags, end tags; elements to drop or to read as raw text are options), character references with the legacy names browsers accept without `;`, escaping, and the character set a page declares. With the `flow` feature, HTML documents read into and written from the craft-flow model.
- [`craft-i18n`](crates/i18n/README.md): translations over the embedded craft-i18n catalogs:
lookup with fallback (`pt-br` → `pt` → English), plurals, language names, the system locale and
a pseudo locale. The catalogs stay a fully optional build input; apps keep `tl!` and their
+16
View File
@@ -10,5 +10,21 @@ repository.workspace = true
[lints]
workspace = true
[features]
# HTML documents: read into and written from the craft-flow model and a craft-wordmodel Document.
flow = ["dep:craft-codepage", "dep:craft-flow", "dep:craft-math", "dep:craft-mime", "dep:craft-wordmodel", "dep:encoding_rs"]
[dependencies]
craft-codepage = { workspace = true, optional = true }
craft-flow = { workspace = true, optional = true }
craft-math = { workspace = true, optional = true }
craft-mime = { workspace = true, features = ["detect"], optional = true }
craft-wordmodel = { workspace = true, optional = true }
encoding_rs = { workspace = true, optional = true }
[dev-dependencies]
proptest = { workspace = true }
[[test]]
name = "doc"
required-features = ["flow"]
+17 -1
View File
@@ -52,12 +52,28 @@ linear.
`escape` writes `& < > " '` 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 `<style>` rules with simple selectors in cascade
order, tables with spans, lists, pictures (`data:` URIs, others through the caller's
`ImageLoader`), ruby and MathML equations ([craft-math](../math/README.md)). `import_page` opens a
web page as Word does (Times New Roman 12 pt, HTML's default spacing); `import` reads a fragment in
the document's own formatting. `decode` / `decode_as` turn a page's bytes into text (BOM, declared
charset, else detection). The writer gives semantic HTML with inline CSS, pictures as `data:`
URIs, equations as MathML and ruby as `<ruby>`.
The feature brings in craft-flow, craft-wordmodel, craft-math, craft-mime and craft-codepage; a
reader that only needs tokens (mail, clipboard tables) leaves it off.
## Not here
- The full HTML5 named reference table (about 2,200 names): only the names above.
- Decoding bytes to text. `declared_charset` gives the label a page names; a decoder for it is
the caller's (`craft-mime::charset` for the WHATWG labels).
- Tree construction, implied end tags, CSS.
- A DOM, and CSS beyond the subset the document reader applies.
## Provenance
+50
View File
@@ -0,0 +1,50 @@
//! HTML documents (feature `flow`): read into and written from the craft-flow model and a
//! craft-wordmodel `Document`.
//!
//! 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, and on paragraphs `margin`, `text-indent` and `line-height`. CSS comes from `style`
//! attributes and from `style` elements' rules with simple selectors (`p`, `.note`, `p.note`),
//! applied in cascade order (specificity, then source order, then the `style` attribute).
//! Scripts 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 craft_wordmodel::props::LineSpacing;
use craft_wordmodel::props::VAlign;
use craft_wordmodel::{Align, Document};
use craft_flow::{
self as model, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, Spacing, base64_encode, data_uri, make_img, mime_of,
parse_color,
};
mod builder;
mod css;
mod entity;
mod export;
mod import;
mod lexer;
mod math_ruby;
pub use builder::ImageLoader;
pub use css::css_len_pt;
pub use entity::{decode, decode_as, decode_entity, unescape};
pub use export::{esc, export, export_flow, export_fragment, inlines_html};
#[cfg(test)]
use import::parse_in;
pub use import::{PAGE_FONT, import, import_on, import_page, import_with, parse, parse_with};
#[cfg(test)]
use css::css_unescape;
#[cfg(test)]
mod tests;
File diff suppressed because it is too large Load Diff
+167
View File
@@ -0,0 +1,167 @@
use super::builder::decls;
use super::import::PAGE_FONT;
use super::{Align, Fmt, model, parse_color};
/// 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),
}
}
}
pub(super) 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
}
/// Decode CSS escapes (CSS Syntax §4.3.7): `\` and one to six hex digits, with one whitespace
/// after them, is that code point; `\` and any other character is that character. CR LF, CR and
/// form feed count as one newline, as CSS reads them (§3.3).
pub(super) fn css_unescape(s: &str) -> String {
let mut o = String::with_capacity(s.len());
let mut it = s.chars().peekable();
// Takes a newline (CR LF as one) or other whitespace off the front, if there is one.
let skip_space = |it: &mut std::iter::Peekable<std::str::Chars>| match it.peek() {
Some('\r') => {
it.next();
it.next_if_eq(&'\n');
}
Some(' ' | '\t' | '\n' | '\u{C}') => {
it.next();
}
_ => {}
};
while let Some(c) = it.next() {
if c != '\\' {
o.push(c);
continue;
}
let mut hex = String::new();
while hex.len() < 6 && it.peek().is_some_and(char::is_ascii_hexdigit) {
hex.extend(it.next());
}
if hex.is_empty() {
// Before a newline it continues the line; at the end it is dropped.
match it.peek() {
Some('\r' | '\n' | '\u{C}') => skip_space(&mut it),
Some(_) => o.extend(it.next()),
None => {}
}
continue;
}
skip_space(&mut it);
let cp = u32::from_str_radix(&hex, 16).unwrap_or(0);
o.push(char::from_u32(cp).filter(|c| *c != '\0').unwrap_or('\u{FFFD}'));
}
o
}
/// Apply a `style` attribute to formatting / block properties.
pub(super) 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 (k, v) in decls(css) {
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 = css_unescape(v.split(',').next().unwrap_or("").trim().trim_matches(['"', '\'']));
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 = model::kept_font(&fam).or(f.font.take());
} else if f.font.is_some() {
// A generic family replaces a face the text has (a page's Times New Roman);
// text without one keeps the document's font.
match fam.to_ascii_lowercase().as_str() {
"serif" => f.font = Some(PAGE_FONT.into()),
"sans-serif" => f.font = Some("Arial".into()),
_ => {}
}
}
}
"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",
_ => {}
}
}
}
+46
View File
@@ -0,0 +1,46 @@
//! Character references (from the crate's core) and decoding a page's bytes to text.
/// The text of an entity body (`amp`, `#38`, `#x26`).
pub fn decode_entity(name: &str) -> Option<String> {
crate::decode_entity(name).map(String::from)
}
pub use crate::unescape;
/// 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 {
decode_as(b, None)
}
/// [`decode`] in the `charset` its container states (a MIME part's, a content type's), when it
/// states one. Single-byte Windows code pages (`windows-1250`…, `iso-8859-1`, `us-ascii` as 1252)
/// are decoded as such, other labels by their WHATWG decoders.
pub fn decode_as(b: &[u8], charset: Option<&str>) -> String {
if b.starts_with(&[0xFF, 0xFE]) || b.starts_with(&[0xFE, 0xFF]) || b.starts_with(&[0xEF, 0xBB, 0xBF]) {
return craft_mime::detect::decode(b);
}
let named = charset.map(str::to_ascii_lowercase).or_else(|| crate::declared_charset(b));
let code_page = match named.as_deref().map(str::trim) {
Some("iso-8859-1" | "latin1" | "us-ascii") => Some(1252),
Some(cs) => cs
.strip_prefix("windows-")
.or_else(|| cs.strip_prefix("cp"))
.and_then(|n| n.parse::<u16>().ok())
.filter(|cp| craft_codepage::SINGLE_BYTE_CODE_PAGES.contains(cp)),
None => None,
};
// Other labels (ISO 8859, KOI8, Shift_JIS, GBK, Big5, EUC-KR…) by their WHATWG decoders; a
// UTF-16 one without a BOM means UTF-8, as in browsers.
let other = named
.as_deref()
.and_then(|l| encoding_rs::Encoding::for_label(l.trim().as_bytes()))
.filter(|e| ![encoding_rs::UTF_8, encoding_rs::UTF_16LE, encoding_rs::UTF_16BE].contains(e));
match (code_page, other) {
(Some(cp), _) => b.iter().map(|&c| craft_codepage::windows_byte(cp, c)).collect(),
(None, Some(enc)) => enc.decode_without_bom_handling(b).0.into_owned(),
(None, None) => match std::str::from_utf8(b) {
Ok(s) => s.to_string(),
Err(_) => craft_mime::detect::decode(b),
},
}
}
+393
View File
@@ -0,0 +1,393 @@
use super::{Align, Document, FBlock, FTable, Flow, Fmt, Inline, Kind, Para, VAlign, base64_encode, mime_of, model};
// ---------------------------------------------------------------------------------------------
// 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
}
/// A font name as a quoted CSS string. Quotes, `\` and control characters would end the string,
/// and `;` and `,` the declaration or the family list for a reader that splits on them, so all of
/// these are written as hex escapes ([`css_unescape`] reads them back).
fn css_font_name(name: &str) -> String {
let mut o = String::with_capacity(name.len() + 2);
o.push('\'');
for c in name.chars() {
match c {
'\0' => o.push('\u{FFFD}'),
'\'' | '"' | '\\' | ';' | ',' => o.push_str(&format!("\\{:x} ", u32::from(c))),
c if c.is_control() => o.push_str(&format!("\\{:x} ", u32::from(c))),
c => o.push(c),
}
}
o.push('\'');
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| craft_wordmodel::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(&css_font_name(fam))));
}
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))),
Inline::Note { .. }
| Inline::Comment { .. }
| Inline::CommentEnd(_)
| Inline::Shape { .. }
| Inline::Group { .. }
| Inline::FieldStart(_)
| Inline::FieldEnd
| Inline::AnchorEnd(_) => {}
// ASK and SET show nothing (see `model::shows_nothing`).
Inline::Field { instr, .. } if model::shows_nothing(instr) => {}
Inline::Field { result, fmt, .. } | Inline::Control { text: result, fmt, .. } => {
let (open, close) = fmt_tags(fmt);
let at_start = o.is_empty();
o.push_str(&open);
o.push_str(&text_html(result, at_start));
for c in close.iter().rev() {
o.push_str(c);
}
}
// No picture to show: the browser shows the alt text in its place.
Inline::Figure(alt) | Inline::Chart(craft_flow::Chart { 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(&craft_math::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
}
+125
View File
@@ -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];
+50
View File
@@ -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<Tok> {
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),
})
}
}
+156
View File
@@ -0,0 +1,156 @@
use super::builder::block_kind;
use super::lexer::{Lexer, Tok};
/// 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.
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<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() > 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 `</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 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 `<ruby>` 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 `<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.
pub(super) 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.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 (`<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
}
+769
View File
@@ -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("a&#65;&#x41;b"), "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("<style>p { color: red }</style><p style='color: blue'>x</p>"), blue);
assert_eq!(color("<style>p { color: red !important }</style><p style='color: blue'>x</p>"), red);
assert_eq!(color("<style>p { color: red !important }</style><p style='color: blue !important'>x</p>"), blue);
assert_eq!(color("<p style='color: red !important; color: blue'>x</p>"), 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"<meta charset=iso-8859-2><p>\xA9</p>", None).contains("<p>Š</p>"));
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<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 = 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("<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"));
// The last declaration decides: a `style` attribute's over the style sheet's.
assert_eq!(table_style("<style>table { border: 1px solid }</style><table><tr><td>a</table>").as_deref(), Some("TableGrid"));
assert_eq!(table_style("<style>table { border: 1px solid }</style><table style='border:none'><tr><td>a</table>").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 [
("<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(&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</td>") && out.contains(">B</td>"), "{out}");
}
}
#[test]
fn input_newlines_preserve_preformatted_lines() {
for newline in ["\n", "\r\n", "\r"] {
let f = parse(&format!("<pre>{newline} Alpha{newline} <b>Beta</b>{newline}{newline} Gamma</pre>"));
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("<p style='white-space:pre'>A&#13;B\r\nC\rD</p><p>A\r\nB\rC</p>");
assert_eq!(texts(&f), ["NormalNone:A\rB\nC\nD", "NormalNone:A B C"]);
let f = parse("<p style='white-space:pre-wrap'>α\r\r\nβ&#13;γ</p><a href='https://example.test/a\r\nb\rc&#13;d'>link</a>");
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 `<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(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<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;"), "θ λ Δ ∇ ∑ √ ≠ ≤ ≥ ♥");
}
/// 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("&copy 2024 Acme"), "© 2024 Acme");
assert_eq!(unescape("&#169 2024 Acme"), "© 2024 Acme");
assert_eq!(unescape("&#xA9 2024 &amp &lt"), "© 2024 & <");
assert_eq!(unescape("&bogus &# &#x &"), "&bogus &# &#x &");
assert_eq!(unescape("&nbsp;x"), "\u{00A0}x");
let f = parse("<p><a href=\"/q?a=1&copy=2&reg2=3&amp;b=&lt\">l</a> &copy 2024</p>");
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&copy=2&reg2=3&b=<"))));
assert!(p.text().ends_with("© 2024"));
}
#[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");
}
#[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<String>)> = 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 `<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(&craft_flow::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(&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("<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::doc::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::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("<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(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("<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 = 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 `<br>` was trimmed one
// span at a time with `Vec::remove(0)`.
let html = format!("<p>{}x</p>", "<b><br></b><i><br></i>".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<String> = 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() {
// `<font size>` 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!("<font size=\"{size}\">x</font>"));
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("<font size=\"+2147483647\">x</font>"), Some(36.0));
assert_eq!(size("<font size=\"--2147483648\">x</font>"), Some(36.0));
assert_eq!(size("<font size=\"-2147483647\">x</font>"), Some(7.5));
}
#[test]
fn empty_comments_do_not_swallow_the_rest() {
for html in ["<p>a<!-->b</p><p>c</p>", "<p>a<!--->b</p><p>c</p>"] {
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(
"<style><!-- p { color: #00ff00; font-size: 10pt } .big { font-size: 20pt } p.big { font-weight: bold } \
/* a comment { } */ @media print { p { color: #ff0000 } } div p, li:hover { font-style: italic } .000003 { text-align: center } --></style>\
<p>plain</p><p class='big x'>big</p><p class=big style='font-size: 8pt'>inline wins</p><p class=000003>digits</p>",
);
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(
"<style>p.a { margin: 6pt 1in 3pt; text-indent: -18pt; line-height: 150% }</style>\
<p class=a>a</p><p style='margin-top: 14,4px; margin-bottom: auto; line-height: 20pt'>b</p><p>c</p>",
);
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 = "<style>p.k { margin-bottom: 13px } span.z { margin: 0in }</style>\
<p>first</p><h1>Head</h1><p class=k><span class=z>kept</span></p><p class=k>own</p><div>bare</div><p>&nbsp;</p>";
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("<table><tr><td>cell</td></tr></table>", &|_| 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("<p style='font-family: sans-serif'>a</p><p style='font-family: serif'>b</p>", &|_| None, true);
let fonts: Vec<Option<String>> = 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("<p style='font-family: sans-serif'>a</p>"))[0]).font, None);
let inherited = parse_in("<body style='color: #000080'><h1>Head</h1></body>", &|_| 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));
// `<p>&nbsp;</p>` 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(
"<style>td.n { width: 42px }</style><table border=1><tr><td colspan=3>t</td></tr><tr><td class=n>1</td><td width=200>a</td><td style='width: 2in'>b</td></tr></table>",
);
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("<table><tr><td width=100>a</td><td>b</td></tr></table>");
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("<table><tr><td rowspan=2>a</td><td width=200>b</td></tr><tr><td width=300>c</td></tr><tr><td width=100>d</td><td>e</td></tr></table>");
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(
"<style>td.b { border-top: solid windowtext 1px; border-bottom: 2pt double #ff0000; border-left: none; border-right: 0px solid; vertical-align: bottom }</style><table><tr><td class=b>a</td><td>b</td></tr></table>",
);
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(
"<style>@charset \"utf-8\"; @import url(a.css); p.a { color: #0000ff } body { margin: 20px } p.b { margin: 10 5% medium }</style>\
<body><p class=a>a</p>bare<p class=b>b</p></body>",
&|_| 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::<Vec<_>>().join(";");
let f = parse(&format!("<style>p {{ {many}; color: #ff0000 }}</style><p style='color: #00ff00'>c</p>"));
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!("<style>{selectors} {{ color: red; {} }}</style><p>x</p>", "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!("<style>{css}</style><p class='a é'>x</p><td style='width: 1e40px'>y</td>"), &|_| None, true);
}
}
+12
View File
@@ -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.
+15
View File
@@ -175,3 +175,18 @@ proptest::proptest! {
}
}
}
#[test]
fn a_token_can_be_read_again() {
let mut l = Lexer::new("é<b>x</b>");
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);
}
+2
View File
@@ -34,6 +34,8 @@
#[doc = include_str!("../README.md")]
struct ReadmeExamples;
#[cfg(feature = "flow")]
pub mod doc;
mod entity;
mod lex;
+93
View File
@@ -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("<math"), "{html}");
assert_eq!(equations(&format!("<p>Before {html} after</p>")), vec![(linear.to_string(), display)], "{html}");
}
// Text after the equation still arrives; an unclosed element ends with the input.
assert!(import("<p><math><mi>x</mi></math> tail</p>").plain_text(Default::default()).contains("tail"));
assert!(!equations("<p><math><mfrac><mi>x").is_empty());
let deep = format!("<math>{}<mi>x</mi>{}</math>", "<mrow>".repeat(5000), "</mrow>".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!("<p><math>{}<mrow><mi>z</mi></mrow></math> tail</p>", "<mi>y</mi>".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 = "<div><table><tr><td>".repeat(5000) + "x";
assert!(import(&html).plain_text(Default::default()).contains('x'));
}
#[test]
fn entities_in_text() {
let d = import("<p>&lt;&amp;&#65;&eacute; &nosuchentity; &#x41;</p>");
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!("<p>&{};</p>", "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);
}
}