mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-28 04:17:06 -06:00
* chore: resolve SonarCloud maintainability issues * fix: restore compiling refactors, revert behaviour changes, run cargo fmt - fontdb::norm: keep the closure for filter (char::is_alphanumeric takes self, not &self, so it does not fit Iterator::filter). - story.rs: write_mark_rpr needs &mut self (rev_attrs, rpr_change). - Revert the viewport meta change and the return 0 added to workspace_version/sha256, which changed behaviour or masked failures. - cargo fmt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Import Document in odt page_parts after dropping glob re-export * fix: apply_kind_properties binds ContentControl; cell_ref keeps char closures * fix(engine): pass case helpers by value (clippy needless borrows) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Dylan Taylor <dylan@craftmaker.dev>
2428 lines
92 KiB
Rust
2428 lines
92 KiB
Rust
//! HTML.
|
||
//!
|
||
//! Import is a tolerant tag-soup reader (no DOM): a tokenizer plus a stack of open elements with
|
||
//! HTML's implicit closes for `p`, `li`, `tr`, `td`/`th` and headings. It understands `p`, `div`,
|
||
//! `h1`–`h6`, `b`/`strong`, `i`/`em`, `u`, `s`/`strike`/`del`, `sup`/`sub`, `code`/`kbd`, `a`
|
||
//! (links and `id`/`name` anchors), `br`, `hr`, `ul`/`ol`/`li` (nested), `table`/`tr`/`td`/`th`
|
||
//! (colspan/rowspan), `img` (`data:` URIs, other sources through a caller's [`ImageLoader`]), `blockquote`, `pre`, `font`, `ruby`
|
||
//! (`rb`/`rt`/`rp`: furigana and other phonetic guides, #1681), and the CSS
|
||
//! properties `color`, `background-color`, `font-weight`, `font-style`, `font-size`,
|
||
//! `font-family`, `text-decoration`, `vertical-align`, `text-align`, `white-space` and page
|
||
//! breaks. Scripts and styles are skipped; nesting is capped.
|
||
//!
|
||
//! Export writes clean semantic HTML with inline CSS, pictures as `data:` URIs and ruby as
|
||
//! `<ruby>`.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use wordcraft_doc::props::VAlign;
|
||
use wordcraft_doc::{Align, Document};
|
||
|
||
use crate::model::{
|
||
self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, base64_encode, data_uri, make_img, mime_of, parse_color,
|
||
};
|
||
|
||
/// Open elements kept at most (deeper start tags are ignored).
|
||
const MAX_STACK: usize = 256;
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Entities and decoding
|
||
|
||
/// Decode an entity body (`amp`, `#38`, `#x26`) to its text.
|
||
pub fn decode_entity(name: &str) -> Option<String> {
|
||
if let Some(num) = name.strip_prefix('#') {
|
||
let (digits, radix) = match num.strip_prefix(['x', 'X']) {
|
||
Some(h) => (h, 16),
|
||
None => (num, 10),
|
||
};
|
||
if !digits.chars().all(|c| c.is_digit(radix)) {
|
||
return None;
|
||
}
|
||
let v = u32::from_str_radix(digits, radix).ok()?;
|
||
let c = match v {
|
||
0 => '\u{FFFD}',
|
||
0x80..=0x9F => crate::txt::cp1252(v as u8),
|
||
_ => char::from_u32(v).unwrap_or('\u{FFFD}'),
|
||
};
|
||
return Some(c.to_string());
|
||
}
|
||
let c = match name {
|
||
"amp" | "AMP" => '&',
|
||
"lt" | "LT" => '<',
|
||
"gt" | "GT" => '>',
|
||
"quot" | "QUOT" => '"',
|
||
"apos" | "APOS" => '\'',
|
||
"nbsp" | "NBSP" => '\u{00A0}',
|
||
"ensp" => '\u{2002}',
|
||
"emsp" => '\u{2003}',
|
||
"thinsp" => '\u{2009}',
|
||
"shy" => '\u{00AD}',
|
||
"copy" | "COPY" => '©',
|
||
"reg" | "REG" => '®',
|
||
"trade" | "TRADE" => '™',
|
||
"hellip" => '…',
|
||
"mdash" => '—',
|
||
"ndash" => '–',
|
||
"minus" => '−',
|
||
"lsquo" => '‘',
|
||
"rsquo" => '’',
|
||
"sbquo" => '‚',
|
||
"ldquo" => '“',
|
||
"rdquo" => '”',
|
||
"bdquo" => '„',
|
||
"laquo" => '«',
|
||
"raquo" => '»',
|
||
"bull" => '•',
|
||
"middot" => '·',
|
||
"deg" => '°',
|
||
"plusmn" => '±',
|
||
"times" => '×',
|
||
"divide" => '÷',
|
||
"frac12" => '½',
|
||
"frac14" => '¼',
|
||
"frac34" => '¾',
|
||
"frac13" => '⅓',
|
||
"frac23" => '⅔',
|
||
"frac18" => '⅛',
|
||
"frac38" => '⅜',
|
||
"frac58" => '⅝',
|
||
"frac78" => '⅞',
|
||
"sup1" => '¹',
|
||
"sup2" => '²',
|
||
"sup3" => '³',
|
||
"micro" => 'µ',
|
||
"permil" => '‰',
|
||
"prime" => '′',
|
||
"Prime" => '″',
|
||
"oline" => '‾',
|
||
"frasl" => '⁄',
|
||
"sect" => '§',
|
||
"para" => '¶',
|
||
"euro" | "EURO" => '€',
|
||
"pound" | "POUND" => '£',
|
||
"yen" | "YEN" => '¥',
|
||
"cent" | "CENT" => '¢',
|
||
"curren" => '¤',
|
||
"dagger" => '†',
|
||
"Dagger" => '‡',
|
||
"larr" => '←',
|
||
"rarr" => '→',
|
||
"uarr" => '↑',
|
||
"darr" => '↓',
|
||
"harr" => '↔',
|
||
"lArr" => '⇐',
|
||
"rArr" => '⇒',
|
||
"uArr" => '⇑',
|
||
"dArr" => '⇓',
|
||
"hArr" => '⇔',
|
||
"crarr" => '↵',
|
||
"forall" => '∀',
|
||
"part" => '∂',
|
||
"exist" => '∃',
|
||
"empty" => '∅',
|
||
"nabla" => '∇',
|
||
"isin" => '∈',
|
||
"notin" => '∉',
|
||
"ni" => '∋',
|
||
"prod" => '∏',
|
||
"sum" => '∑',
|
||
"radic" => '√',
|
||
"prop" => '∝',
|
||
"ang" => '∠',
|
||
"and" => '∧',
|
||
"or" => '∨',
|
||
"cap" => '∩',
|
||
"cup" => '∪',
|
||
"int" => '∫',
|
||
"there4" => '∴',
|
||
"sim" => '∼',
|
||
"cong" => '≅',
|
||
"asymp" | "approx" => '≈',
|
||
"ne" => '≠',
|
||
"equiv" => '≡',
|
||
"le" => '≤',
|
||
"ge" => '≥',
|
||
"sub" => '⊂',
|
||
"sup" => '⊃',
|
||
"nsub" => '⊄',
|
||
"sube" => '⊆',
|
||
"supe" => '⊇',
|
||
"oplus" => '⊕',
|
||
"otimes" => '⊗',
|
||
"perp" => '⊥',
|
||
"sdot" => '⋅',
|
||
"infin" => '∞',
|
||
"loz" => '◊',
|
||
"spades" => '♠',
|
||
"clubs" => '♣',
|
||
"hearts" => '♥',
|
||
"diams" => '♦',
|
||
"check" => '✓',
|
||
"iexcl" => '¡',
|
||
"iquest" => '¿',
|
||
"szlig" => 'ß',
|
||
"auml" => 'ä',
|
||
"ouml" => 'ö',
|
||
"uuml" => 'ü',
|
||
"Auml" => 'Ä',
|
||
"Ouml" => 'Ö',
|
||
"Uuml" => 'Ü',
|
||
"eacute" => 'é',
|
||
"egrave" => 'è',
|
||
"ecirc" => 'ê',
|
||
"euml" => 'ë',
|
||
"Eacute" => 'É',
|
||
"Egrave" => 'È',
|
||
"Ecirc" => 'Ê',
|
||
"Euml" => 'Ë',
|
||
"aacute" => 'á',
|
||
"agrave" => 'à',
|
||
"acirc" => 'â',
|
||
"atilde" => 'ã',
|
||
"Agrave" => 'À',
|
||
"Aacute" => 'Á',
|
||
"Acirc" => 'Â',
|
||
"Atilde" => 'Ã',
|
||
"iacute" => 'í',
|
||
"igrave" => 'ì',
|
||
"icirc" => 'î',
|
||
"iuml" => 'ï',
|
||
"Iacute" => 'Í',
|
||
"Igrave" => 'Ì',
|
||
"Icirc" => 'Î',
|
||
"Iuml" => 'Ï',
|
||
"oacute" => 'ó',
|
||
"ograve" => 'ò',
|
||
"ocirc" => 'ô',
|
||
"otilde" => 'õ',
|
||
"Oacute" => 'Ó',
|
||
"Ograve" => 'Ò',
|
||
"Ocirc" => 'Ô',
|
||
"Otilde" => 'Õ',
|
||
"uacute" => 'ú',
|
||
"ugrave" => 'ù',
|
||
"ucirc" => 'û',
|
||
"Uacute" => 'Ú',
|
||
"Ugrave" => 'Ù',
|
||
"Ucirc" => 'Û',
|
||
"yacute" => 'ý',
|
||
"yuml" => 'ÿ',
|
||
"Yacute" => 'Ý',
|
||
"ntilde" => 'ñ',
|
||
"Ntilde" => 'Ñ',
|
||
"ccedil" => 'ç',
|
||
"Ccedil" => 'Ç',
|
||
"aring" => 'å',
|
||
"Aring" => 'Å',
|
||
"oslash" => 'ø',
|
||
"Oslash" => 'Ø',
|
||
"aelig" => 'æ',
|
||
"AElig" => 'Æ',
|
||
"alpha" => 'α',
|
||
"beta" => 'β',
|
||
"gamma" => 'γ',
|
||
"delta" => 'δ',
|
||
"epsilon" => 'ε',
|
||
"zeta" => 'ζ',
|
||
"eta" => 'η',
|
||
"theta" => 'θ',
|
||
"iota" => 'ι',
|
||
"kappa" => 'κ',
|
||
"lambda" => 'λ',
|
||
"mu" => 'μ',
|
||
"nu" => 'ν',
|
||
"xi" => 'ξ',
|
||
"pi" => 'π',
|
||
"rho" => 'ρ',
|
||
"sigma" => 'σ',
|
||
"tau" => 'τ',
|
||
"upsilon" => 'υ',
|
||
"phi" => 'φ',
|
||
"chi" => 'χ',
|
||
"psi" => 'ψ',
|
||
"omega" => 'ω',
|
||
"Gamma" => 'Γ',
|
||
"Delta" => 'Δ',
|
||
"Theta" => 'Θ',
|
||
"Lambda" => 'Λ',
|
||
"Xi" => 'Ξ',
|
||
"Pi" => 'Π',
|
||
"Sigma" => 'Σ',
|
||
"Upsilon" => 'Υ',
|
||
"Phi" => 'Φ',
|
||
"Psi" => 'Ψ',
|
||
"Omega" => 'Ω',
|
||
"zwj" => '\u{200D}',
|
||
"zwnj" => '\u{200C}',
|
||
_ => return None,
|
||
};
|
||
Some(c.to_string())
|
||
}
|
||
|
||
/// Replace entity references in `s`.
|
||
pub fn unescape(s: &str) -> String {
|
||
if !s.contains('&') {
|
||
return s.to_string();
|
||
}
|
||
let mut out = String::with_capacity(s.len());
|
||
let mut rest = s;
|
||
while let Some(k) = rest.find('&') {
|
||
out.push_str(&rest[..k]);
|
||
let after = &rest[k + 1..];
|
||
// An entity name is short; looking further would rescan the whole text for every stray `&`.
|
||
let mut cut = after.len().min(33);
|
||
while !after.is_char_boundary(cut) {
|
||
cut -= 1;
|
||
}
|
||
let end = after.get(..cut).and_then(|w| w.find(';'));
|
||
match end.and_then(|e| decode_entity(&after[..e]).map(|t| (t, e))) {
|
||
Some((t, e)) => {
|
||
out.push_str(&t);
|
||
rest = &after[e + 1..];
|
||
}
|
||
None => {
|
||
out.push('&');
|
||
rest = after;
|
||
}
|
||
}
|
||
}
|
||
out.push_str(rest);
|
||
out
|
||
}
|
||
|
||
fn sniff_charset(head: &str) -> Option<&str> {
|
||
let (i, n) = head.find("charset").map(|i| (i, 7)).or_else(|| head.find("encoding").map(|i| (i, 8)))?;
|
||
let after = head.get(i + n..)?.trim_start().strip_prefix('=')?;
|
||
let trimmed = after.trim_start().trim_start_matches(['"', '\'']);
|
||
let end = trimmed.find(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ';' | '>' | '/')).unwrap_or(trimmed.len());
|
||
let label = trimmed[..end].trim();
|
||
if label.is_empty() { None } else { Some(label) }
|
||
}
|
||
|
||
/// Decode HTML bytes: BOM, then a `charset` in the first 2 KB, else UTF-8 with a legacy fallback.
|
||
pub fn decode(b: &[u8]) -> String {
|
||
if b.starts_with(&[0xFF, 0xFE]) || b.starts_with(&[0xFE, 0xFF]) || b.starts_with(&[0xEF, 0xBB, 0xBF]) {
|
||
return crate::txt::decode(b);
|
||
}
|
||
let head = String::from_utf8_lossy(b.get(..b.len().min(2048)).unwrap_or(b)).to_ascii_lowercase();
|
||
if let Some(label) = sniff_charset(&head)
|
||
&& let Some(s) = crate::txt::decode_as(b, label)
|
||
{
|
||
return s;
|
||
}
|
||
if let Ok(s) = std::str::from_utf8(b) {
|
||
return s.to_string();
|
||
}
|
||
crate::txt::decode(b)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Tokenizer
|
||
|
||
#[derive(Debug)]
|
||
enum Tok {
|
||
Text(String),
|
||
Start { name: String, attrs: Vec<(String, String)>, self_close: bool },
|
||
End(String),
|
||
}
|
||
|
||
struct Lexer<'a> {
|
||
s: &'a str,
|
||
pos: usize,
|
||
}
|
||
|
||
impl Lexer<'_> {
|
||
fn rest(&self) -> &str {
|
||
self.s.get(self.pos..).unwrap_or("")
|
||
}
|
||
|
||
/// Skip past the next occurrence of `pat` (ASCII case-insensitive), or to the end.
|
||
fn skip_past(&mut self, pat: &str) -> usize {
|
||
let start = self.pos;
|
||
let hay = self.rest().as_bytes();
|
||
let p = pat.as_bytes();
|
||
let found = hay.windows(p.len().max(1)).position(|w| w.eq_ignore_ascii_case(p));
|
||
match found {
|
||
Some(k) => self.pos += k + p.len(),
|
||
None => self.pos = self.s.len(),
|
||
}
|
||
found.map(|k| start + k).unwrap_or(self.s.len())
|
||
}
|
||
|
||
/// Skip past the next matching end tag `</tag...>` (ASCII case-insensitive), or to the end.
|
||
fn skip_past_end_tag(&mut self, tag: &str) {
|
||
let prefix = format!("</{tag}");
|
||
let p = prefix.as_bytes();
|
||
while self.pos < self.s.len() {
|
||
let hay = self.rest().as_bytes();
|
||
let found = hay.windows(p.len().max(1)).position(|w| w.eq_ignore_ascii_case(p));
|
||
let Some(k) = found else {
|
||
self.pos = self.s.len();
|
||
return;
|
||
};
|
||
let after_tag = k + p.len();
|
||
let next_byte = hay.get(after_tag);
|
||
if next_byte.is_none_or(|b| b.is_ascii_whitespace() || *b == b'>' || *b == b'/') {
|
||
self.pos += after_tag;
|
||
self.skip_past(">");
|
||
return;
|
||
}
|
||
self.pos += k + 2;
|
||
}
|
||
}
|
||
|
||
fn next(&mut self) -> Option<Tok> {
|
||
let rest = self.rest();
|
||
if rest.is_empty() {
|
||
return None;
|
||
}
|
||
if !rest.starts_with('<') {
|
||
let end = rest.find('<').unwrap_or(rest.len());
|
||
let t = unescape(&rest[..end]);
|
||
self.pos += end;
|
||
return Some(Tok::Text(t));
|
||
}
|
||
let b = rest.as_bytes();
|
||
if rest.starts_with("<!--") {
|
||
self.pos += 4;
|
||
self.skip_past("-->");
|
||
return Some(Tok::Text(String::new()));
|
||
}
|
||
if rest.starts_with("<!") || rest.starts_with("<?") {
|
||
self.skip_past(">");
|
||
return Some(Tok::Text(String::new()));
|
||
}
|
||
if rest.starts_with("</") {
|
||
let name: String = rest
|
||
.strip_prefix("</")
|
||
.unwrap_or("")
|
||
.chars()
|
||
.take_while(|c| c.is_ascii_alphanumeric() || *c == ':' || *c == '-')
|
||
.map(|c| c.to_ascii_lowercase())
|
||
.collect();
|
||
if name.is_empty() {
|
||
self.pos += 2;
|
||
return Some(Tok::Text("</".into()));
|
||
}
|
||
self.skip_past(">");
|
||
return Some(Tok::End(name));
|
||
}
|
||
if !b.get(1).is_some_and(u8::is_ascii_alphabetic) {
|
||
self.pos += 1;
|
||
return Some(Tok::Text("<".into()));
|
||
}
|
||
// Start tag.
|
||
let name: String =
|
||
rest[1..].chars().take_while(|c| c.is_ascii_alphanumeric() || *c == ':' || *c == '-').map(|c| c.to_ascii_lowercase()).collect();
|
||
let mut i = 1 + name.len();
|
||
let mut attrs = Vec::new();
|
||
let mut self_close = false;
|
||
loop {
|
||
while b.get(i).is_some_and(u8::is_ascii_whitespace) {
|
||
i += 1;
|
||
}
|
||
match b.get(i) {
|
||
None => break,
|
||
Some(b'>') => {
|
||
i += 1;
|
||
break;
|
||
}
|
||
Some(b'/') => {
|
||
if b.get(i + 1) == Some(&b'>') {
|
||
self_close = true;
|
||
i += 2;
|
||
break;
|
||
}
|
||
i += 1;
|
||
continue;
|
||
}
|
||
_ => {}
|
||
}
|
||
let a0 = i;
|
||
while b.get(i).is_some_and(|c| !c.is_ascii_whitespace() && !matches!(c, b'=' | b'>' | b'/')) {
|
||
i += 1;
|
||
}
|
||
let an = rest.get(a0..i).unwrap_or("").to_ascii_lowercase();
|
||
if an.is_empty() {
|
||
i += 1;
|
||
continue;
|
||
}
|
||
while b.get(i).is_some_and(u8::is_ascii_whitespace) {
|
||
i += 1;
|
||
}
|
||
let mut val = String::new();
|
||
if b.get(i) == Some(&b'=') {
|
||
i += 1;
|
||
while b.get(i).is_some_and(u8::is_ascii_whitespace) {
|
||
i += 1;
|
||
}
|
||
match b.get(i) {
|
||
Some(&q) if q == b'"' || q == b'\'' => {
|
||
let v0 = i + 1;
|
||
let e = rest.get(v0..).and_then(|r| r.find(q as char)).map(|k| v0 + k).unwrap_or(rest.len());
|
||
val = unescape(rest.get(v0..e).unwrap_or(""));
|
||
i = e + 1;
|
||
}
|
||
_ => {
|
||
let v0 = i;
|
||
while b.get(i).is_some_and(|c| !c.is_ascii_whitespace() && *c != b'>') {
|
||
i += 1;
|
||
}
|
||
val = unescape(rest.get(v0..i).unwrap_or(""));
|
||
}
|
||
}
|
||
}
|
||
if attrs.len() < 64 {
|
||
attrs.push((an, val));
|
||
}
|
||
}
|
||
self.pos += i.min(rest.len());
|
||
Some(Tok::Start { name, attrs, self_close })
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Builder
|
||
|
||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||
enum ElKind {
|
||
Inline,
|
||
Block,
|
||
Heading(u8),
|
||
Quote,
|
||
Pre,
|
||
List(bool),
|
||
Li,
|
||
Table,
|
||
Tr,
|
||
Cell,
|
||
Head,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
struct El {
|
||
name: String,
|
||
kind: ElKind,
|
||
fmt: Fmt,
|
||
align: Option<Align>,
|
||
preserve: bool,
|
||
page_break: bool,
|
||
page_break_after: bool,
|
||
/// Li: a paragraph of this item was already emitted.
|
||
used: bool,
|
||
}
|
||
|
||
struct TableB {
|
||
rows: Vec<Vec<Cell>>,
|
||
row: Option<Vec<Cell>>,
|
||
/// The table or one of its cells asks for borders.
|
||
bordered: bool,
|
||
}
|
||
|
||
/// Whether an element's `border` attribute or `style` declares a visible border.
|
||
fn declares_border(attrs: &[(String, String)], is_table: bool) -> bool {
|
||
if is_table && attr(attrs, "border").is_some_and(|b| b.trim().parse::<u32>().map(|n| n > 0).unwrap_or(b.trim().is_empty())) {
|
||
return true;
|
||
}
|
||
let Some(style) = attr(attrs, "style") else { return false };
|
||
style.split(';').any(|decl| {
|
||
let Some((name, value)) = decl.split_once(':') else { return false };
|
||
let name = name.trim().to_ascii_lowercase();
|
||
if !matches!(name.as_str(), "border" | "border-top" | "border-right" | "border-bottom" | "border-left") {
|
||
return false;
|
||
}
|
||
let value = value.trim().to_ascii_lowercase();
|
||
!value.is_empty() && !value.split_whitespace().any(|tok| matches!(tok, "none" | "hidden" | "0" | "0px" | "0pt"))
|
||
})
|
||
}
|
||
|
||
/// Loads the bytes of an image an `img` names by a source other than a `data:` URI (a path
|
||
/// relative to the HTML file). `None` when it can't be found. A picture used more than once can
|
||
/// share one buffer.
|
||
pub type ImageLoader<'a> = &'a dyn Fn(&str) -> Option<Arc<Vec<u8>>>;
|
||
|
||
struct Builder<'r> {
|
||
images: ImageLoader<'r>,
|
||
containers: Vec<Vec<FBlock>>,
|
||
cells: Vec<Cell>,
|
||
tables: Vec<TableB>,
|
||
stack: Vec<El>,
|
||
para: Option<Para>,
|
||
/// The current paragraph continues a list item (joins the previous paragraph).
|
||
para_cont: bool,
|
||
/// Pending page break from preceding elements with page-break-after or unconsumed break.
|
||
pending_page_break: bool,
|
||
/// Whether the current paragraph's first / last text was read with preserved white space
|
||
/// (CSS `white-space: pre…`): those edge spaces are kept when the paragraph ends.
|
||
pre_lead: Option<bool>,
|
||
pre_tail: bool,
|
||
meta: Meta,
|
||
title: Option<String>,
|
||
}
|
||
|
||
fn is_void(n: &str) -> bool {
|
||
matches!(
|
||
n,
|
||
"area"
|
||
| "base"
|
||
| "br"
|
||
| "col"
|
||
| "embed"
|
||
| "hr"
|
||
| "img"
|
||
| "input"
|
||
| "link"
|
||
| "meta"
|
||
| "param"
|
||
| "source"
|
||
| "track"
|
||
| "wbr"
|
||
| "basefont"
|
||
| "frame"
|
||
)
|
||
}
|
||
|
||
fn block_kind(n: &str) -> Option<ElKind> {
|
||
Some(match n {
|
||
"p" | "div" | "section" | "article" | "header" | "footer" | "main" | "nav" | "aside" | "figure" | "figcaption" | "address" | "dl" | "dt"
|
||
| "dd" | "center" | "form" | "fieldset" | "body" | "html" | "caption" | "thead" | "tbody" | "tfoot" | "details" | "summary" | "legend"
|
||
| "hgroup" => ElKind::Block,
|
||
"h1" | "h2" | "h3" | "h4" | "h5" | "h6" => ElKind::Heading(n.get(1..2).and_then(|d| d.parse().ok()).unwrap_or(1)),
|
||
"blockquote" => ElKind::Quote,
|
||
"pre" | "listing" | "xmp" | "plaintext" => ElKind::Pre,
|
||
"ul" | "menu" | "dir" => ElKind::List(false),
|
||
"ol" => ElKind::List(true),
|
||
"li" => ElKind::Li,
|
||
"table" => ElKind::Table,
|
||
"tr" => ElKind::Tr,
|
||
"td" | "th" => ElKind::Cell,
|
||
"head" => ElKind::Head,
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
/// Closes an open `p` (HTML's "close a p element" on block starts).
|
||
fn closes_p(n: &str) -> bool {
|
||
matches!(
|
||
n,
|
||
"address"
|
||
| "article"
|
||
| "aside"
|
||
| "blockquote"
|
||
| "div"
|
||
| "dl"
|
||
| "fieldset"
|
||
| "footer"
|
||
| "form"
|
||
| "h1"
|
||
| "h2"
|
||
| "h3"
|
||
| "h4"
|
||
| "h5"
|
||
| "h6"
|
||
| "header"
|
||
| "hr"
|
||
| "main"
|
||
| "nav"
|
||
| "ol"
|
||
| "p"
|
||
| "pre"
|
||
| "section"
|
||
| "table"
|
||
| "ul"
|
||
| "figure"
|
||
| "center"
|
||
| "dd"
|
||
| "dt"
|
||
| "li"
|
||
)
|
||
}
|
||
|
||
/// A CSS length (or font-size keyword) in points; `None` when it is not one.
|
||
pub fn css_len_pt(v: &str) -> Option<f32> {
|
||
let v = v.trim().to_ascii_lowercase();
|
||
let num = |s: &str| s.trim().parse::<f32>().ok().filter(|x| x.is_finite());
|
||
if let Some(x) = v.strip_suffix("pt") {
|
||
num(x)
|
||
} else if let Some(x) = v.strip_suffix("px") {
|
||
num(x).map(|p| p * 0.75)
|
||
} else if let Some(x) = v.strip_suffix("rem").or_else(|| v.strip_suffix("em")) {
|
||
num(x).map(|p| p * 12.0)
|
||
} else if let Some(x) = v.strip_suffix("in") {
|
||
num(x).map(|p| p * 72.0)
|
||
} else if let Some(x) = v.strip_suffix("cm") {
|
||
num(x).map(|p| p * 72.0 / 2.54)
|
||
} else if let Some(x) = v.strip_suffix("mm") {
|
||
num(x).map(|p| p * 72.0 / 25.4)
|
||
} else if let Some(x) = v.strip_suffix('%') {
|
||
num(x).map(|p| p * 0.12)
|
||
} else {
|
||
match v.as_str() {
|
||
"xx-small" => Some(7.0),
|
||
"x-small" => Some(7.5),
|
||
"small" | "smaller" => Some(10.0),
|
||
"medium" => Some(12.0),
|
||
"large" | "larger" => Some(13.5),
|
||
"x-large" => Some(18.0),
|
||
"xx-large" => Some(24.0),
|
||
"xxx-large" => Some(36.0),
|
||
_ => num(&v).map(|p| p * 0.75),
|
||
}
|
||
}
|
||
}
|
||
|
||
fn strip_important(v: &str) -> &str {
|
||
let s = v.trim();
|
||
if let Some(pos) = s.rfind('!') {
|
||
let after = s[pos + 1..].trim();
|
||
if after.eq_ignore_ascii_case("important") {
|
||
return s[..pos].trim();
|
||
}
|
||
}
|
||
s
|
||
}
|
||
|
||
/// Apply a `style` attribute to formatting / block properties.
|
||
fn apply_style(css: &str, f: &mut Fmt, align: &mut Option<Align>, preserve: &mut bool, page_break: &mut bool, page_break_after: &mut bool) {
|
||
for decl in css.split(';').take(64) {
|
||
let Some((k, v)) = decl.split_once(':') else { continue };
|
||
let k = k.trim().to_ascii_lowercase();
|
||
let v = strip_important(v);
|
||
let lv = v.to_ascii_lowercase();
|
||
match k.as_str() {
|
||
"font-weight" => f.bold = lv.starts_with("bold") || lv.parse::<u32>().is_ok_and(|w| w >= 600),
|
||
"font-style" => f.italic = lv == "italic" || lv == "oblique",
|
||
"text-decoration" | "text-decoration-line" => {
|
||
if lv.contains("none") {
|
||
f.underline = false;
|
||
f.strike = false;
|
||
}
|
||
if lv.contains("underline") {
|
||
f.underline = true;
|
||
}
|
||
if lv.contains("line-through") {
|
||
f.strike = true;
|
||
}
|
||
}
|
||
"color" => {
|
||
if let Some(c) = parse_color(v) {
|
||
f.color = Some(c);
|
||
}
|
||
}
|
||
"background-color" | "background" => {
|
||
if let Some(c) = v.split_whitespace().find_map(parse_color) {
|
||
f.background = Some(c);
|
||
}
|
||
}
|
||
"font-size" => {
|
||
if let Some(p) = css_len_pt(v).filter(|p| *p >= 1.0 && *p <= 1638.0) {
|
||
f.size = Some(p);
|
||
}
|
||
}
|
||
"font-family" => {
|
||
let fam = v.split(',').next().unwrap_or("").trim().trim_matches(['"', '\'']).to_string();
|
||
if model::is_mono(&fam) || fam.eq_ignore_ascii_case("monospace") {
|
||
f.code = true;
|
||
} else if !fam.is_empty() && !matches!(fam.to_ascii_lowercase().as_str(), "serif" | "sans-serif" | "inherit" | "initial") {
|
||
f.font = Some(fam);
|
||
}
|
||
}
|
||
"vertical-align" => {
|
||
f.sup = lv == "super";
|
||
f.sub = lv == "sub";
|
||
}
|
||
"text-align" => {
|
||
*align = match lv.as_str() {
|
||
"center" => Some(Align::Center),
|
||
"right" | "end" => Some(Align::Right),
|
||
"justify" => Some(Align::Justify),
|
||
"left" | "start" => Some(Align::Left),
|
||
_ => *align,
|
||
}
|
||
}
|
||
"white-space" => *preserve = lv.starts_with("pre"),
|
||
"page-break-before" | "break-before" => *page_break = lv == "always" || lv == "page",
|
||
"page-break-after" | "break-after" => *page_break_after = lv == "always" || lv == "page",
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn attr<'a>(attrs: &'a [(String, String)], name: &str) -> Option<&'a str> {
|
||
attrs.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str())
|
||
}
|
||
|
||
impl<'r> Builder<'r> {
|
||
fn new(images: ImageLoader<'r>) -> Builder<'r> {
|
||
Builder {
|
||
images,
|
||
containers: vec![Vec::new()],
|
||
cells: Vec::new(),
|
||
tables: Vec::new(),
|
||
stack: Vec::new(),
|
||
para: None,
|
||
para_cont: false,
|
||
pending_page_break: false,
|
||
pre_lead: None,
|
||
pre_tail: false,
|
||
meta: Meta::default(),
|
||
title: None,
|
||
}
|
||
}
|
||
|
||
fn fmt(&self) -> Fmt {
|
||
self.stack.last().map(|e| e.fmt.clone()).unwrap_or_default()
|
||
}
|
||
|
||
fn preserve(&self) -> bool {
|
||
self.stack.last().is_some_and(|e| e.preserve)
|
||
}
|
||
|
||
fn in_head(&self) -> bool {
|
||
self.stack.iter().any(|e| e.kind == ElKind::Head)
|
||
}
|
||
|
||
/// Paragraph context from the open elements (innermost first, up to the enclosing cell).
|
||
fn start_para(&mut self) -> &mut Para {
|
||
if self.para.is_none() {
|
||
let mut p = Para::default();
|
||
let mut list_level: Option<(bool, usize)> = None;
|
||
let mut li_at: Option<usize> = None;
|
||
let mut lists = 0usize;
|
||
for (i, e) in self.stack.iter().enumerate().rev() {
|
||
match e.kind {
|
||
ElKind::Cell => {
|
||
// CSS inherits `text-align` from the cell, its row, row group and table
|
||
// (#1381); the paragraph's own (found above) wins.
|
||
if p.align.is_none() {
|
||
p.align = self.stack.get(..=i).and_then(|outer| {
|
||
outer
|
||
.iter()
|
||
.rev()
|
||
.take_while(|o| o.kind != ElKind::Table)
|
||
.chain(outer.iter().rev().find(|o| o.kind == ElKind::Table))
|
||
.find_map(|o| o.align)
|
||
});
|
||
}
|
||
break;
|
||
}
|
||
ElKind::Table => break,
|
||
ElKind::Heading(n) if p.kind == Kind::Normal => p.kind = Kind::Heading(n.clamp(1, 6)),
|
||
ElKind::Pre if p.kind == Kind::Normal => p.kind = Kind::Code,
|
||
ElKind::Quote if p.kind == Kind::Normal => p.kind = Kind::Quote,
|
||
ElKind::Li if li_at.is_none() => li_at = Some(i),
|
||
ElKind::List(o) => {
|
||
if list_level.is_none() {
|
||
list_level = Some((o, 0));
|
||
}
|
||
lists += 1;
|
||
}
|
||
_ => {}
|
||
}
|
||
if p.align.is_none() && e.align.is_some() && e.kind != ElKind::Inline {
|
||
p.align = e.align;
|
||
}
|
||
}
|
||
if let (Some((o, _)), Some(li)) = (list_level, li_at) {
|
||
p.list = Some(ListInfo { ordered: o, level: lists.saturating_sub(1).min(8) as u8, start: 1 });
|
||
self.para_cont = self.stack.get(li).is_some_and(|e| e.used);
|
||
if let Some(e) = self.stack.get_mut(li) {
|
||
e.used = true;
|
||
}
|
||
} else {
|
||
self.para_cont = false;
|
||
}
|
||
let pb_before = self.stack.iter_mut().rev().take_while(|e| e.kind != ElKind::Cell).any(|e| std::mem::take(&mut e.page_break));
|
||
p.page_break = pb_before || std::mem::take(&mut self.pending_page_break);
|
||
self.para = Some(p);
|
||
}
|
||
self.para.get_or_insert_with(Para::default)
|
||
}
|
||
|
||
fn container(&mut self) -> &mut Vec<FBlock> {
|
||
if self.containers.is_empty() {
|
||
self.containers.push(Vec::new());
|
||
}
|
||
let n = self.containers.len() - 1;
|
||
&mut self.containers[n]
|
||
}
|
||
|
||
fn flush(&mut self) {
|
||
let Some(mut p) = self.para.take() else { return };
|
||
let (pre_lead, pre_tail) = (self.pre_lead.take().unwrap_or(false), std::mem::take(&mut self.pre_tail));
|
||
if p.kind != Kind::Code {
|
||
// Preserved white space keeps its spaces and tabs at the edges; line breaks there
|
||
// (source formatting) still go.
|
||
const ALL: &[char] = &[' ', '\t', '\r', '\n'];
|
||
const BREAKS: &[char] = &['\r', '\n'];
|
||
p.trim_with(if pre_lead { BREAKS } else { ALL }, if pre_tail { BREAKS } else { ALL });
|
||
if p.text().trim().is_empty() {
|
||
// Only white space (indentation between blocks): nothing to keep.
|
||
p.trim();
|
||
}
|
||
}
|
||
if p.inlines.len() == 1 && p.text() == "\u{00A0}" {
|
||
// `<p> </p>`: an empty paragraph on purpose.
|
||
p.inlines.clear();
|
||
self.para_cont = false;
|
||
self.container().push(FBlock::Para(p));
|
||
return;
|
||
}
|
||
if p.is_empty() && p.kind != Kind::Code && p.list.is_none() {
|
||
// Whitespace-only text between blocks.
|
||
return;
|
||
}
|
||
let cont = std::mem::take(&mut self.para_cont);
|
||
let level = p.list.map(|l| l.level);
|
||
let c = self.container();
|
||
if cont
|
||
&& let Some(FBlock::Para(prev)) = c.last_mut()
|
||
&& prev.list.is_some()
|
||
&& prev.list.map(|l| l.level) == level
|
||
{
|
||
prev.push_text("\n", &Fmt::default());
|
||
prev.inlines.extend(p.inlines);
|
||
return;
|
||
}
|
||
c.push(FBlock::Para(p));
|
||
}
|
||
|
||
fn text(&mut self, t: &str) {
|
||
if self.in_head() {
|
||
if let Some(title) = &mut self.title {
|
||
title.push_str(t);
|
||
}
|
||
return;
|
||
}
|
||
if t.is_empty() {
|
||
return;
|
||
}
|
||
let inside_table_only = matches!(
|
||
self.stack.iter().rev().find(|e| matches!(e.kind, ElKind::Table | ElKind::Tr | ElKind::Cell)).map(|e| e.kind),
|
||
Some(ElKind::Table | ElKind::Tr)
|
||
);
|
||
if inside_table_only && t.trim().is_empty() {
|
||
return;
|
||
}
|
||
let code = self.stack.iter().any(|e| e.kind == ElKind::Pre);
|
||
let f = self.fmt();
|
||
if code {
|
||
let mut first = true;
|
||
for line in t.split('\n') {
|
||
if !first {
|
||
// A newline right after <pre> is ignored.
|
||
let empty_start = self.para.as_ref().is_none_or(|p| p.inlines.is_empty()) && self.pre_fresh();
|
||
if !empty_start {
|
||
self.start_para();
|
||
self.flush();
|
||
}
|
||
}
|
||
first = false;
|
||
let line = line.trim_end_matches('\r');
|
||
if !line.is_empty() {
|
||
self.start_para().push_text(line, &Fmt { code: false, ..f.clone() });
|
||
self.mark_pre_used();
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
if self.preserve() {
|
||
self.note_edge(true);
|
||
self.start_para().push_text(t, &f);
|
||
return;
|
||
}
|
||
// Collapse whitespace.
|
||
let mut s = String::with_capacity(t.len());
|
||
let mut prev_space = self.para.as_ref().is_none_or(|p| match p.inlines.last() {
|
||
Some(Inline::Text(x, _)) => x.ends_with([' ', '\n']),
|
||
Some(_) => false,
|
||
None => true,
|
||
});
|
||
for c in t.chars() {
|
||
if matches!(c, ' ' | '\t' | '\n' | '\r' | '\u{000C}') {
|
||
if !prev_space {
|
||
s.push(' ');
|
||
prev_space = true;
|
||
}
|
||
} else {
|
||
s.push(c);
|
||
prev_space = false;
|
||
}
|
||
}
|
||
if s.is_empty() {
|
||
return;
|
||
}
|
||
self.note_edge(false);
|
||
self.start_para().push_text(&s, &f);
|
||
}
|
||
|
||
/// Record whether text about to be added keeps its white space (see `pre_lead`).
|
||
fn note_edge(&mut self, preserved: bool) {
|
||
if self.para.as_ref().is_none_or(|p| p.inlines.is_empty()) {
|
||
self.pre_lead = Some(preserved);
|
||
}
|
||
self.pre_tail = preserved;
|
||
}
|
||
|
||
/// Add a `<ruby>` element's base text and reading as one inline (#1681). Half-marked ruby (no
|
||
/// base, or no reading) is plain text instead: a guide that annotates nothing is no guide, and
|
||
/// the text it held is never lost.
|
||
fn push_ruby(&mut self, base: &str, ruby: &str) {
|
||
let preserve = self.preserve();
|
||
let text = |s: &str| if preserve { s.to_string() } else { collapse_ws(s) };
|
||
let (base, ruby) = (text(base), text(ruby));
|
||
let f = self.fmt();
|
||
if base.is_empty() || ruby.is_empty() {
|
||
self.start_para().push_text(if base.is_empty() { &ruby } else { &base }, &f);
|
||
return;
|
||
}
|
||
self.start_para().inlines.push(Inline::Ruby { base, ruby, fmt: f });
|
||
}
|
||
|
||
fn pre_fresh(&self) -> bool {
|
||
self.stack.iter().rev().find(|e| e.kind == ElKind::Pre).is_some_and(|e| !e.used)
|
||
}
|
||
|
||
fn mark_pre_used(&mut self) {
|
||
if let Some(e) = self.stack.iter_mut().rev().find(|e| e.kind == ElKind::Pre) {
|
||
e.used = true;
|
||
}
|
||
}
|
||
|
||
/// Pop elements down to and including index `k`.
|
||
fn pop_to(&mut self, k: usize) {
|
||
while self.stack.len() > k {
|
||
let Some(e) = self.stack.pop() else { break };
|
||
self.close(&e);
|
||
}
|
||
}
|
||
|
||
fn close(&mut self, e: &El) {
|
||
if e.page_break_after || e.page_break {
|
||
self.pending_page_break = true;
|
||
}
|
||
if e.kind != ElKind::Inline {
|
||
self.flush();
|
||
}
|
||
match e.kind {
|
||
ElKind::Cell => {
|
||
self.flush();
|
||
let blocks = if self.containers.len() > 1 { self.containers.pop().unwrap_or_default() } else { Vec::new() };
|
||
let mut cell = self.cells.pop().unwrap_or_default();
|
||
cell.blocks = blocks;
|
||
if let Some(t) = self.tables.last_mut() {
|
||
t.row.get_or_insert_with(Vec::new).push(cell);
|
||
}
|
||
}
|
||
ElKind::Tr => {
|
||
if let Some(t) = self.tables.last_mut()
|
||
&& let Some(r) = t.row.take()
|
||
{
|
||
t.rows.push(r);
|
||
}
|
||
}
|
||
ElKind::Table => {
|
||
if let Some(mut t) = self.tables.pop() {
|
||
if let Some(r) = t.row.take() {
|
||
t.rows.push(r);
|
||
}
|
||
let borderless = !t.bordered;
|
||
// Keep at most MAX_ROWS rows (the model drops the rest anyway): bounds the
|
||
// O(rows × rowspan) work of `insert_covered` on a table of millions of `<tr>`.
|
||
let rows: Vec<_> = t.rows.into_iter().filter(|r| !r.is_empty()).take(wordcraft_doc::table::MAX_ROWS).collect();
|
||
let mut ft = FTable { rows, widths: Vec::new(), borderless };
|
||
if !ft.rows.is_empty() {
|
||
ft.insert_covered();
|
||
self.container().push(FBlock::Table(ft));
|
||
}
|
||
}
|
||
}
|
||
ElKind::Head => {}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
/// Index of the innermost open element named `name`, not looking past a table boundary
|
||
/// unless `cross_tables`.
|
||
fn find_open(&self, pred: impl Fn(&El) -> bool, stop: impl Fn(&El) -> bool) -> Option<usize> {
|
||
for (i, e) in self.stack.iter().enumerate().rev() {
|
||
if pred(e) {
|
||
return Some(i);
|
||
}
|
||
if stop(e) {
|
||
return None;
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
fn close_head_for_start(&mut self, name: &str) {
|
||
// A body-content start tag implicitly ends an omitted </head>.
|
||
if !matches!(
|
||
name,
|
||
"html" | "head" | "base" | "basefont" | "bgsound" | "link" | "meta" | "noframes" | "noscript" | "script" | "style" | "template" | "title"
|
||
) && let Some(k) = self.find_open(|e| e.kind == ElKind::Head, |_| false)
|
||
{
|
||
self.pop_to(k);
|
||
}
|
||
}
|
||
|
||
fn start(&mut self, name: &str, attrs: &[(String, String)], self_close: bool) {
|
||
// Head metadata.
|
||
match name {
|
||
"title" => {
|
||
self.title = Some(String::new());
|
||
}
|
||
"meta" => {
|
||
let n = attr(attrs, "name").unwrap_or("").to_ascii_lowercase();
|
||
let c = attr(attrs, "content").unwrap_or("").to_string();
|
||
match n.as_str() {
|
||
"author" => self.meta.author = c,
|
||
"description" => self.meta.description = c,
|
||
"keywords" => self.meta.keywords = c,
|
||
"subject" => self.meta.subject = c,
|
||
_ => {}
|
||
}
|
||
return;
|
||
}
|
||
_ => {}
|
||
}
|
||
let is_table_part = matches!(name, "tr" | "td" | "th");
|
||
let in_table = self.tables.len() < model::MAX_DEPTH;
|
||
// Implicit closes.
|
||
if closes_p(name)
|
||
&& let Some(k) = self.find_open(|e| e.name == "p", |e| matches!(e.kind, ElKind::Cell | ElKind::Table | ElKind::Li | ElKind::Quote))
|
||
{
|
||
self.pop_to(k);
|
||
}
|
||
if matches!(name, "h1" | "h2" | "h3" | "h4" | "h5" | "h6") && self.stack.last().is_some_and(|e| matches!(e.kind, ElKind::Heading(_))) {
|
||
let k = self.stack.len() - 1;
|
||
self.pop_to(k);
|
||
}
|
||
if name == "li"
|
||
&& let Some(k) = self.find_open(|e| e.kind == ElKind::Li, |e| matches!(e.kind, ElKind::List(_) | ElKind::Table | ElKind::Cell))
|
||
{
|
||
self.pop_to(k);
|
||
}
|
||
if matches!(name, "dt" | "dd")
|
||
&& let Some(k) =
|
||
self.find_open(|e| e.name == "dt" || e.name == "dd", |e| matches!(e.kind, ElKind::Table | ElKind::Cell) || e.name == "dl")
|
||
{
|
||
self.pop_to(k);
|
||
}
|
||
if is_table_part && !self.tables.is_empty() {
|
||
if matches!(name, "td" | "th")
|
||
&& let Some(k) = self.find_open(|e| e.kind == ElKind::Cell, |e| matches!(e.kind, ElKind::Table | ElKind::Tr))
|
||
{
|
||
self.pop_to(k);
|
||
}
|
||
if name == "tr"
|
||
&& let Some(k) = self.find_open(|e| matches!(e.kind, ElKind::Tr | ElKind::Cell), |e| e.kind == ElKind::Table)
|
||
{
|
||
// Pop to the outermost of cell/row inside the current table.
|
||
let mut kk = k;
|
||
while kk > 0 && self.stack.get(kk - 1).is_some_and(|e| matches!(e.kind, ElKind::Tr | ElKind::Cell)) {
|
||
kk -= 1;
|
||
}
|
||
self.pop_to(kk);
|
||
}
|
||
}
|
||
if name == "a"
|
||
&& !self.in_head()
|
||
&& let Some(id) = attr(attrs, "id").or_else(|| attr(attrs, "name")).filter(|s| !s.is_empty())
|
||
{
|
||
self.start_para().inlines.push(Inline::Anchor(id.to_string()));
|
||
}
|
||
// Element-specific work.
|
||
match name {
|
||
"br" => {
|
||
if self.stack.iter().any(|e| e.kind == ElKind::Pre) {
|
||
self.start_para();
|
||
self.flush();
|
||
} else {
|
||
let f = self.fmt();
|
||
self.start_para().push_text("\n", &f);
|
||
}
|
||
return;
|
||
}
|
||
"hr" => {
|
||
self.flush();
|
||
self.container().push(FBlock::Para(Para::new(Kind::Rule)));
|
||
return;
|
||
}
|
||
"wbr" => {
|
||
let f = self.fmt();
|
||
self.start_para().push_text("\u{200B}", &f);
|
||
return;
|
||
}
|
||
"img" => {
|
||
let alt = attr(attrs, "alt").unwrap_or("").to_string();
|
||
let dim = |k: &str| attr(attrs, k).and_then(|v| v.trim().trim_end_matches("px").trim().parse::<f32>().ok()).map(|p| p * 0.75);
|
||
let mut w = dim("width");
|
||
let mut h = dim("height");
|
||
if let Some(st) = attr(attrs, "style") {
|
||
for decl in st.split(';') {
|
||
if let Some((k, v)) = decl.split_once(':') {
|
||
match k.trim().to_ascii_lowercase().as_str() {
|
||
"width" => w = css_len_pt(v).or(w),
|
||
"height" => h = css_len_pt(v).or(h),
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let img = attr(attrs, "src")
|
||
.and_then(|src| if src.trim_start().starts_with("data:") { data_uri(src).map(Arc::new) } else { (self.images)(src.trim()) })
|
||
.and_then(|d| make_img(d, w, h, &alt));
|
||
let f = self.fmt();
|
||
match img {
|
||
Some(im) => self.start_para().inlines.push(Inline::Image(im)),
|
||
None if !alt.is_empty() => self.start_para().push_text(&alt, &f),
|
||
None => {}
|
||
}
|
||
return;
|
||
}
|
||
_ => {}
|
||
}
|
||
if is_void(name) || self_close && block_kind(name).is_none() && !matches!(name, "a" | "span") {
|
||
return;
|
||
}
|
||
if self.stack.len() >= MAX_STACK {
|
||
return;
|
||
}
|
||
let mut kind = block_kind(name).unwrap_or(ElKind::Inline);
|
||
if matches!(kind, ElKind::Table | ElKind::Tr | ElKind::Cell) && !in_table {
|
||
kind = ElKind::Block;
|
||
}
|
||
if matches!(kind, ElKind::Tr | ElKind::Cell) && self.tables.is_empty() {
|
||
kind = ElKind::Block;
|
||
}
|
||
if kind != ElKind::Inline {
|
||
self.flush();
|
||
}
|
||
let mut fmt = self.fmt();
|
||
let mut align = None;
|
||
let mut preserve = self.preserve();
|
||
let mut page_break = false;
|
||
let mut page_break_after = false;
|
||
match name {
|
||
// A header cell is bold, as in browsers and Word, unless its own CSS says otherwise.
|
||
"b" | "strong" | "th" => fmt.bold = true,
|
||
"i" | "em" | "cite" | "var" | "dfn" | "address" => fmt.italic = true,
|
||
"u" | "ins" => fmt.underline = true,
|
||
"s" | "strike" | "del" => fmt.strike = true,
|
||
"code" | "kbd" | "tt" | "samp" => fmt.code = true,
|
||
"sup" => fmt.sup = true,
|
||
"sub" => fmt.sub = true,
|
||
"small" => fmt.size = Some(fmt.size.map_or(10.0, |s| (s * 0.833).max(1.0))),
|
||
"big" => fmt.size = Some(fmt.size.map_or(14.0, |s| (s * 1.2).min(1638.0))),
|
||
"mark" => fmt.background = Some(wordcraft_doc::Rgb(255, 255, 0)),
|
||
"font" => {
|
||
if let Some(c) = attr(attrs, "color").and_then(parse_color) {
|
||
fmt.color = Some(c);
|
||
}
|
||
if let Some(face) = attr(attrs, "face") {
|
||
let fam = face.split(',').next().unwrap_or("").trim().to_string();
|
||
if model::is_mono(&fam) {
|
||
fmt.code = true;
|
||
} else if !fam.is_empty() {
|
||
fmt.font = Some(fam);
|
||
}
|
||
}
|
||
if let Some(sz) = attr(attrs, "size") {
|
||
let s = sz.trim();
|
||
let val = if let Some(rest) = s.strip_prefix('+') {
|
||
rest.parse::<i32>().ok().map(|d| 3 + d)
|
||
} else if let Some(rest) = s.strip_prefix('-') {
|
||
rest.parse::<i32>().ok().map(|d| 3 - d)
|
||
} else {
|
||
s.parse::<i32>().ok()
|
||
};
|
||
if let Some(sz) = val {
|
||
fmt.size = Some([7.5, 10.0, 12.0, 13.5, 18.0, 24.0, 36.0].get((sz.clamp(1, 7) - 1) as usize).copied().unwrap_or(12.0));
|
||
}
|
||
}
|
||
}
|
||
"a" => {
|
||
if let Some(h) = attr(attrs, "href").filter(|h| wordcraft_doc::link::is_safe_href(h)) {
|
||
fmt.link = Some(h.trim().to_string());
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
// `align` on a table is about where the table sits (not imported); it doesn't align the
|
||
// text in its cells.
|
||
if let Some(a) = attr(attrs, "align").filter(|_| kind != ElKind::Table) {
|
||
align = match a.to_ascii_lowercase().as_str() {
|
||
"center" | "middle" => Some(Align::Center),
|
||
"right" => Some(Align::Right),
|
||
"justify" => Some(Align::Justify),
|
||
"left" => Some(Align::Left),
|
||
_ => None,
|
||
};
|
||
}
|
||
if name == "center" {
|
||
align = Some(Align::Center);
|
||
}
|
||
if let Some(st) = attr(attrs, "style") {
|
||
apply_style(st, &mut fmt, &mut align, &mut preserve, &mut page_break, &mut page_break_after);
|
||
}
|
||
if kind == ElKind::Pre {
|
||
preserve = true;
|
||
}
|
||
match kind {
|
||
ElKind::Table => self.tables.push(TableB { rows: Vec::new(), row: None, bordered: declares_border(attrs, true) }),
|
||
ElKind::Tr => {
|
||
if let Some(t) = self.tables.last_mut()
|
||
&& let Some(r) = t.row.take()
|
||
{
|
||
t.rows.push(r);
|
||
}
|
||
}
|
||
ElKind::Cell => {
|
||
let tb = self.find_open(|e| e.kind == ElKind::Tr, |e| e.kind == ElKind::Table).is_none();
|
||
if let Some(t) = self.tables.last_mut()
|
||
&& declares_border(attrs, false)
|
||
{
|
||
t.bordered = true;
|
||
}
|
||
if tb && let Some(t) = self.tables.last_mut() {
|
||
// A cell without a row: start one.
|
||
if let Some(r) = t.row.take() {
|
||
t.rows.push(r);
|
||
}
|
||
t.row = Some(Vec::new());
|
||
}
|
||
let num = |k: &str| attr(attrs, k).and_then(|v| v.trim().parse::<u32>().ok()).map(|v| v.clamp(1, 63));
|
||
let shading = attr(attrs, "bgcolor").and_then(parse_color).or(fmt.background);
|
||
let mut valign = attr(attrs, "valign").and_then(|v| match v.trim().to_ascii_lowercase().as_str() {
|
||
"top" => Some(VAlign::Top),
|
||
"middle" | "center" => Some(VAlign::Center),
|
||
"bottom" => Some(VAlign::Bottom),
|
||
_ => None,
|
||
});
|
||
if let Some(st) = attr(attrs, "style") {
|
||
for decl in st.split(';') {
|
||
if let Some((k, v)) = decl.split_once(':')
|
||
&& k.trim().eq_ignore_ascii_case("vertical-align")
|
||
{
|
||
match v.trim().to_ascii_lowercase().as_str() {
|
||
"top" => valign = Some(VAlign::Top),
|
||
"middle" | "center" => valign = Some(VAlign::Center),
|
||
"bottom" => valign = Some(VAlign::Bottom),
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
self.cells.push(Cell {
|
||
blocks: Vec::new(),
|
||
colspan: num("colspan").unwrap_or(1),
|
||
rowspan: attr(attrs, "rowspan").and_then(|v| v.trim().parse::<u32>().ok()).map(|v| v.clamp(1, 1000)).unwrap_or(1),
|
||
covered: false,
|
||
header: name == "th",
|
||
shading,
|
||
borders: None,
|
||
valign,
|
||
});
|
||
fmt.background = None;
|
||
self.containers.push(Vec::new());
|
||
}
|
||
_ => {}
|
||
}
|
||
self.stack.push(El { name: name.to_string(), kind, fmt, align, preserve, page_break, page_break_after, used: false });
|
||
}
|
||
|
||
fn end(&mut self, name: &str) {
|
||
if name == "title" {
|
||
if let Some(t) = self.title.take() {
|
||
self.meta.title = t.split_whitespace().collect::<Vec<_>>().join(" ");
|
||
}
|
||
return;
|
||
}
|
||
let crosses = matches!(name, "table");
|
||
let target = if matches!(name, "td" | "th") {
|
||
self.find_open(|e| e.kind == ElKind::Cell, |e| e.kind == ElKind::Table)
|
||
} else if name == "tr" {
|
||
self.find_open(|e| e.kind == ElKind::Tr, |e| e.kind == ElKind::Table)
|
||
} else {
|
||
self.find_open(|e| e.name == name, |e| !crosses && matches!(e.kind, ElKind::Table | ElKind::Cell))
|
||
};
|
||
if let Some(k) = target {
|
||
self.pop_to(k);
|
||
}
|
||
}
|
||
|
||
fn finish(mut self) -> Flow {
|
||
self.pop_to(0);
|
||
self.flush();
|
||
// Unclosed cells: fold remaining containers into the body.
|
||
while self.containers.len() > 1 {
|
||
let c = self.containers.pop().unwrap_or_default();
|
||
self.container().extend(c);
|
||
}
|
||
let blocks = self.containers.pop().unwrap_or_default();
|
||
if let Some(t) = self.title.take() {
|
||
self.meta.title = t;
|
||
}
|
||
Flow { blocks, meta: self.meta }
|
||
}
|
||
}
|
||
|
||
/// Parse HTML text into the flow model.
|
||
pub fn parse(s: &str) -> Flow {
|
||
parse_with(s, &|_| None)
|
||
}
|
||
|
||
/// [`parse`], loading pictures that aren't `data:` URIs through `images`.
|
||
pub fn parse_with(s: &str, images: ImageLoader) -> Flow {
|
||
let mut lx = Lexer { s, pos: 0 };
|
||
let mut b = Builder::new(images);
|
||
let mut guard = 0usize;
|
||
while let Some(t) = lx.next() {
|
||
guard += 1;
|
||
if guard > 50_000_000 {
|
||
break;
|
||
}
|
||
match t {
|
||
Tok::Text(t) => b.text(&t),
|
||
Tok::Start { name, attrs, self_close } => {
|
||
b.close_head_for_start(&name);
|
||
if crate::mathml::local(&name) == "math" && !self_close {
|
||
let el = read_math(&mut lx, name, attrs);
|
||
if !b.in_head()
|
||
&& let Some((linear, display)) = crate::mathml::read(&el)
|
||
{
|
||
b.start_para().inlines.push(Inline::Equation { linear, display });
|
||
}
|
||
continue;
|
||
}
|
||
if name == "ruby" && !self_close {
|
||
let r = read_ruby(&mut lx);
|
||
if !b.in_head() {
|
||
b.push_ruby(&r.base, &r.ruby);
|
||
}
|
||
continue;
|
||
}
|
||
if matches!(name.as_str(), "script" | "style" | "noscript" | "template" | "textarea") && !self_close {
|
||
lx.skip_past_end_tag(&name);
|
||
continue;
|
||
}
|
||
b.start(&name, &attrs, self_close);
|
||
}
|
||
Tok::End(name) => b.end(&name),
|
||
}
|
||
}
|
||
b.finish()
|
||
}
|
||
|
||
/// A `<math>` element (#816): its tokens up to the matching end tag, as a tree. Nesting and size
|
||
/// are bounded; an unclosed element ends with the input.
|
||
fn read_math(lx: &mut Lexer, name: String, attrs: Vec<(String, String)>) -> crate::mathml::El {
|
||
use crate::mathml::{El, Item, MAX_NODES, local};
|
||
let mut stack: Vec<El> = vec![El { name, attrs, children: Vec::new() }];
|
||
let mut count = 0usize;
|
||
let close = |stack: &mut Vec<El>| {
|
||
if stack.len() > 1
|
||
&& let Some(done) = stack.pop()
|
||
&& let Some(parent) = stack.last_mut()
|
||
{
|
||
parent.children.push(Item::El(done));
|
||
}
|
||
};
|
||
while let Some(t) = lx.next() {
|
||
count += 1;
|
||
if count > MAX_NODES * 4 {
|
||
// Over the cap: drop the rest of this element so its tokens aren't read as HTML text.
|
||
skip_math(lx, t);
|
||
break;
|
||
}
|
||
match t {
|
||
Tok::Text(t) => {
|
||
if let Some(top) = stack.last_mut()
|
||
&& !t.is_empty()
|
||
{
|
||
top.children.push(Item::Text(t));
|
||
}
|
||
}
|
||
Tok::Start { name, attrs, self_close } => {
|
||
let el = El { name, attrs, children: Vec::new() };
|
||
if self_close || stack.len() > wordcraft_doc::math::MAX_DEPTH {
|
||
if let Some(top) = stack.last_mut() {
|
||
top.children.push(Item::El(el));
|
||
}
|
||
} else {
|
||
stack.push(el);
|
||
}
|
||
}
|
||
Tok::End(name) => {
|
||
let name = local(&name).to_string();
|
||
// Close up to the matching open element; a stray end tag is ignored.
|
||
if let Some(k) = stack.iter().rposition(|e| local(&e.name) == name) {
|
||
if k == 0 {
|
||
break;
|
||
}
|
||
while stack.len() > k {
|
||
close(&mut stack);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
while stack.len() > 1 {
|
||
close(&mut stack);
|
||
}
|
||
stack.pop().unwrap_or_default()
|
||
}
|
||
|
||
/// Skip tokens (starting with `first`) up to and including the `</math>` that closes the element
|
||
/// being read; nested `<math>` elements are counted. An unclosed element ends with the input.
|
||
fn skip_math(lx: &mut Lexer, first: Tok) {
|
||
use crate::mathml::local;
|
||
let mut depth = 0usize;
|
||
let mut t = Some(first);
|
||
while let Some(tok) = t {
|
||
match tok {
|
||
Tok::Start { name, self_close: false, .. } if local(&name) == "math" => depth = depth.saturating_add(1),
|
||
Tok::End(name) if local(&name) == "math" => {
|
||
if depth == 0 {
|
||
return;
|
||
}
|
||
depth = depth.saturating_sub(1);
|
||
}
|
||
_ => {}
|
||
}
|
||
t = lx.next();
|
||
}
|
||
}
|
||
|
||
/// A `<ruby>` element's base text and reading (#1681).
|
||
#[derive(Default)]
|
||
struct RubyText {
|
||
base: String,
|
||
ruby: String,
|
||
}
|
||
|
||
/// Which part of a `<ruby>` element the text being read belongs to.
|
||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||
enum RubyPart {
|
||
/// The text being annotated: an `<rb>` child, or the text outside `<rt>`/`<rp>`.
|
||
Base,
|
||
/// The reading: an `<rt>` child.
|
||
Ruby,
|
||
/// `<rp>` fallback parentheses, which are not text: browsers show them only where ruby isn't
|
||
/// supported.
|
||
Fallback,
|
||
}
|
||
|
||
/// A block-level tag ends an open `<ruby>` implicitly, as it does in a browser.
|
||
fn ends_ruby(t: &Tok) -> bool {
|
||
match t {
|
||
Tok::Start { name, .. } | Tok::End(name) => block_kind(name).is_some(),
|
||
Tok::Text(_) => false,
|
||
}
|
||
}
|
||
|
||
/// Read a `<ruby>` element's content: its base text from `<rb>` children (or the text outside
|
||
/// them), its reading from `<rt>` children, and `<rp>` fallback parentheses dropped. Tolerant,
|
||
/// like the rest of the reader: tags inside are ignored, an unclosed element ends at the next
|
||
/// block-level tag (handed back to the caller) or with the input, and the tokens read are capped.
|
||
fn read_ruby(lx: &mut Lexer) -> RubyText {
|
||
/// Most tokens one `<ruby>` element is read from: a missing end tag must not scan the rest of
|
||
/// a hostile file here.
|
||
const MAX_TOKENS: usize = 4096;
|
||
let mut out = RubyText::default();
|
||
let mut part = RubyPart::Base;
|
||
let mut nested = 0usize;
|
||
let mut count = 0usize;
|
||
while count < MAX_TOKENS {
|
||
count += 1;
|
||
let start = lx.pos;
|
||
let Some(t) = lx.next() else { break };
|
||
if nested == 0 && ends_ruby(&t) {
|
||
// The boundary ends the element: give the token back to the main reader.
|
||
lx.pos = start;
|
||
break;
|
||
}
|
||
match t {
|
||
Tok::Text(t) => match part {
|
||
RubyPart::Base => out.base.push_str(&t),
|
||
RubyPart::Ruby => out.ruby.push_str(&t),
|
||
RubyPart::Fallback => {}
|
||
},
|
||
// The self-closing slash is ignored on HTML elements (`<rt/>` opens an `rt`), as in a
|
||
// browser's parser.
|
||
Tok::Start { name, self_close, .. } => match name.as_str() {
|
||
"rb" => part = RubyPart::Base,
|
||
"rt" => part = RubyPart::Ruby,
|
||
"rp" => part = RubyPart::Fallback,
|
||
"ruby" if !self_close => nested += 1,
|
||
_ => {}
|
||
},
|
||
Tok::End(name) => match name.as_str() {
|
||
"rb" | "rt" | "rp" => part = RubyPart::Base,
|
||
"ruby" if nested > 0 => nested -= 1,
|
||
"ruby" => break,
|
||
_ => {}
|
||
},
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Collapse white space inside ruby markup as body text does (HTML's white space only: an
|
||
/// ideographic space is text), without the space the element's indentation leaves at the edges.
|
||
fn collapse_ws(s: &str) -> String {
|
||
let mut out = String::with_capacity(s.len());
|
||
for c in s.chars() {
|
||
if matches!(c, ' ' | '\t' | '\n' | '\r' | '\u{000C}') {
|
||
if !out.ends_with(' ') {
|
||
out.push(' ');
|
||
}
|
||
} else {
|
||
out.push(c);
|
||
}
|
||
}
|
||
out.trim_matches(' ').to_string()
|
||
}
|
||
|
||
/// Parse HTML into a document.
|
||
pub fn import(s: &str) -> Document {
|
||
model::to_doc(&parse(s))
|
||
}
|
||
|
||
/// [`import`], loading pictures that aren't `data:` URIs through `images`.
|
||
pub fn import_with(s: &str, images: ImageLoader) -> Document {
|
||
model::to_doc(&parse_with(s, images))
|
||
}
|
||
|
||
/// [`import_with`] onto `page` (its size and margins), which tables size to: HTML gives no page
|
||
/// size, so the caller picks it (the region's paper, #1381).
|
||
pub fn import_on(s: &str, images: ImageLoader, page: &wordcraft_doc::SectionProps) -> Document {
|
||
model::to_doc_in(&parse_with(s, images), page.clone())
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Export
|
||
|
||
/// Escape text for HTML content and attribute values.
|
||
pub fn esc(s: &str) -> String {
|
||
let mut o = String::with_capacity(s.len() + 8);
|
||
for c in s.chars() {
|
||
match c {
|
||
'&' => o.push_str("&"),
|
||
'<' => o.push_str("<"),
|
||
'>' => o.push_str(">"),
|
||
'"' => o.push_str("""),
|
||
'\u{000C}' | '\u{000E}' => {}
|
||
_ => o.push(c),
|
||
}
|
||
}
|
||
o
|
||
}
|
||
|
||
fn align_css(a: Option<Align>) -> Option<&'static str> {
|
||
match a? {
|
||
Align::Center => Some("text-align:center"),
|
||
Align::Right => Some("text-align:right"),
|
||
Align::Justify | Align::Distribute => Some("text-align:justify"),
|
||
Align::Left => None,
|
||
}
|
||
}
|
||
|
||
fn text_html(t: &str, at_start: bool) -> String {
|
||
let mut o = String::new();
|
||
let mut prev_space = at_start;
|
||
for c in t.chars() {
|
||
match c {
|
||
'\n' => {
|
||
o.push_str("<br>");
|
||
prev_space = true;
|
||
continue;
|
||
}
|
||
'\t' => o.push_str("<span style=\"white-space:pre\">\t</span>"),
|
||
' ' if prev_space => o.push_str(" "),
|
||
_ => o.push_str(&esc(&c.to_string())),
|
||
}
|
||
prev_space = c == ' ';
|
||
}
|
||
o
|
||
}
|
||
|
||
/// The tags that carry `f`'s character formatting: what opens them, and what closes them
|
||
/// (innermost last, so the closes are written in reverse).
|
||
fn fmt_tags(f: &Fmt) -> (String, Vec<&'static str>) {
|
||
let mut open = String::new();
|
||
let mut close: Vec<&str> = Vec::new();
|
||
// A script or other non-web target is written as plain text (`is_safe_href`).
|
||
if let Some(l) = f.link.as_deref().filter(|l| wordcraft_doc::link::is_safe_href(l)) {
|
||
open.push_str(&format!("<a href=\"{}\">", esc(l)));
|
||
close.push("</a>");
|
||
}
|
||
let mut tag = |o: &str, c: &'static str| {
|
||
open.push_str(o);
|
||
close.push(c);
|
||
};
|
||
if f.bold {
|
||
tag("<strong>", "</strong>");
|
||
}
|
||
if f.italic {
|
||
tag("<em>", "</em>");
|
||
}
|
||
if f.underline {
|
||
tag("<u>", "</u>");
|
||
}
|
||
if f.strike {
|
||
tag("<s>", "</s>");
|
||
}
|
||
if f.sup {
|
||
tag("<sup>", "</sup>");
|
||
} else if f.sub {
|
||
tag("<sub>", "</sub>");
|
||
}
|
||
if f.code {
|
||
tag("<code>", "</code>");
|
||
}
|
||
let mut css = Vec::new();
|
||
if let Some(c) = f.color {
|
||
css.push(format!("color:#{}", c.hex()));
|
||
}
|
||
if let Some(c) = f.background {
|
||
css.push(format!("background-color:#{}", c.hex()));
|
||
}
|
||
if let Some(s) = f.size {
|
||
css.push(format!("font-size:{}pt", trim_num(s)));
|
||
}
|
||
if let Some(fam) = &f.font {
|
||
css.push(format!("font-family:'{}'", esc(&fam.replace('\'', ""))));
|
||
}
|
||
if !css.is_empty() {
|
||
open.push_str(&format!("<span style=\"{}\">", css.join(";")));
|
||
close.push("</span>");
|
||
}
|
||
(open, close)
|
||
}
|
||
|
||
/// Inline spans → HTML.
|
||
pub fn inlines_html(inlines: &[Inline]) -> String {
|
||
let mut o = String::new();
|
||
for i in inlines.iter() {
|
||
match i {
|
||
Inline::Text(t, f) => {
|
||
let (open, close) = fmt_tags(f);
|
||
let at_start = o.is_empty();
|
||
o.push_str(&open);
|
||
o.push_str(&text_html(t, at_start));
|
||
for c in close.iter().rev() {
|
||
o.push_str(c);
|
||
}
|
||
}
|
||
// A phonetic guide: the base text with its reading in `<rt>` (#1681). HTML ruby has
|
||
// no alignment or size, so the guide's own attributes stay in the document model.
|
||
Inline::Ruby { base, ruby, fmt } => {
|
||
let (open, close) = fmt_tags(fmt);
|
||
let at_start = o.is_empty();
|
||
o.push_str(&open);
|
||
o.push_str(&format!("<ruby>{}<rt>{}</rt></ruby>", text_html(base, at_start), text_html(ruby, false)));
|
||
for c in close.iter().rev() {
|
||
o.push_str(c);
|
||
}
|
||
}
|
||
Inline::Image(img) => {
|
||
o.push_str(&format!(
|
||
"<img src=\"data:{};base64,{}\" alt=\"{}\" width=\"{}\" height=\"{}\">",
|
||
mime_of(&img.ext),
|
||
base64_encode(&img.data),
|
||
esc(&img.alt),
|
||
(img.w / 0.75).round(),
|
||
(img.h / 0.75).round()
|
||
));
|
||
}
|
||
Inline::Anchor(a) => o.push_str(&format!("<a id=\"{}\"></a>", esc(a))),
|
||
// No picture to show: the browser shows the alt text in its place.
|
||
Inline::Figure(alt) => o.push_str(&format!("<img alt=\"{}\">", esc(alt))),
|
||
// MathML, which browsers, Word and LibreOffice show as an equation (#816).
|
||
Inline::Equation { linear, display } => o.push_str(&crate::mathml::linear_to_mathml(linear, *display)),
|
||
}
|
||
}
|
||
o
|
||
}
|
||
|
||
fn trim_num(v: f32) -> String {
|
||
let s = format!("{:.2}", v);
|
||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||
}
|
||
|
||
fn para_attrs(p: &Para) -> String {
|
||
let mut css: Vec<&str> = Vec::new();
|
||
if let Some(a) = align_css(p.align) {
|
||
css.push(a);
|
||
}
|
||
if p.page_break {
|
||
css.push("break-before:page;page-break-before:always");
|
||
}
|
||
if css.is_empty() { String::new() } else { format!(" style=\"{}\"", css.join(";")) }
|
||
}
|
||
|
||
fn para_html(p: &Para) -> String {
|
||
let body = inlines_html(&p.inlines);
|
||
let attrs = para_attrs(p);
|
||
match p.kind {
|
||
Kind::Heading(n) => format!("<h{n}{attrs}>{body}</h{n}>", n = n.clamp(1, 6)),
|
||
Kind::Title => format!("<h1{attrs}>{body}</h1>"),
|
||
Kind::Rule => "<hr>".to_string(),
|
||
_ if body.is_empty() => format!("<p{attrs}> </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(¶_html(&Para { kind: Kind::Normal, ..q.clone() }));
|
||
out.push('\n');
|
||
i += 1;
|
||
}
|
||
out.push_str("</blockquote>\n");
|
||
}
|
||
FBlock::Para(p) => {
|
||
out.push_str(¶_html(p));
|
||
out.push('\n');
|
||
i += 1;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn close_list(stack: &mut Vec<(bool, bool)>, out: &mut String) {
|
||
if let Some((ordered, li_open)) = stack.pop() {
|
||
if li_open {
|
||
out.push_str("</li>");
|
||
}
|
||
out.push_str(if ordered { "</ol>" } else { "</ul>" });
|
||
}
|
||
}
|
||
|
||
/// A complete HTML document.
|
||
pub fn export(doc: &Document) -> String {
|
||
let flow = model::from_doc(doc);
|
||
export_flow(&flow, doc.styles.default_chr.lang.as_deref().unwrap_or("en"))
|
||
}
|
||
|
||
/// `doc`'s content as an HTML fragment (no head or style sheet): what a copy puts on the system
|
||
/// clipboard beside its text, equations as MathML (#816).
|
||
pub fn export_fragment(doc: &Document) -> String {
|
||
let flow = model::from_doc(doc);
|
||
let mut out = String::from("<meta charset=\"utf-8\">");
|
||
blocks_html(&flow.blocks, &mut out, 0);
|
||
out
|
||
}
|
||
|
||
pub fn export_flow(flow: &Flow, lang: &str) -> String {
|
||
let m = &flow.meta;
|
||
let mut out = String::new();
|
||
out.push_str("<!DOCTYPE html>\n");
|
||
out.push_str(&format!("<html lang=\"{}\">\n<head>\n<meta charset=\"utf-8\">\n", esc(lang)));
|
||
out.push_str("<meta name=\"generator\" content=\"WordCraft\">\n");
|
||
out.push_str(&format!("<title>{}</title>\n", esc(&m.title)));
|
||
for (k, v) in [("author", &m.author), ("description", &m.description), ("keywords", &m.keywords), ("subject", &m.subject)] {
|
||
if !v.is_empty() {
|
||
out.push_str(&format!("<meta name=\"{k}\" content=\"{}\">\n", esc(v)));
|
||
}
|
||
}
|
||
out.push_str(
|
||
"<style>\nbody{font-family:Aptos,Calibri,'Segoe UI',Arial,sans-serif;font-size:12pt;line-height:1.15;max-width:48em;margin:2em auto;padding:0 1em}\n\
|
||
table{border-collapse:collapse;margin:0.5em 0}\nblockquote{margin:0.5em 2em;font-style:italic;color:#404040}\n\
|
||
pre{background:#f5f5f5;padding:0.5em;font-family:'Courier New',monospace;font-size:10pt}\n</style>\n</head>\n<body>\n",
|
||
);
|
||
blocks_html(&flow.blocks, &mut out, 0);
|
||
out.push_str("</body>\n</html>\n");
|
||
out
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn numeric_entity_rejects_sign() {
|
||
assert_eq!(unescape("a&#+65;b"), "a&#+65;b");
|
||
assert_eq!(unescape("a&#x+41;b"), "a&#x+41;b");
|
||
assert_eq!(unescape("aAAb"), "aAAb");
|
||
}
|
||
|
||
fn texts(f: &Flow) -> Vec<String> {
|
||
f.blocks
|
||
.iter()
|
||
.map(|b| match b {
|
||
FBlock::Para(p) => format!("{:?}{:?}:{}", p.kind, p.list.map(|l| (l.ordered, l.level)), p.text()),
|
||
FBlock::Table(t) => format!("table{}x{}", t.rows.len(), t.cols()),
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn table_style(html: &str) -> Option<String> {
|
||
let doc = crate::model::to_doc(&parse(html));
|
||
doc.body.iter().find_map(|b| match &**b {
|
||
wordcraft_doc::Block::Table(t) => Some(t.props.style.clone().unwrap_or_default()),
|
||
_ => None,
|
||
})
|
||
}
|
||
|
||
#[test]
|
||
fn borderless_tables_get_no_grid_style() {
|
||
assert_eq!(table_style("<table><tr><td>a<td>b</table>").as_deref(), Some(""));
|
||
assert_eq!(table_style("<table style='border:none'><tr><td style='border:0'>a</table>").as_deref(), Some(""));
|
||
assert_eq!(table_style("<table border=0><tr><td style='border: 0px none'>a</table>").as_deref(), Some(""));
|
||
assert_eq!(table_style("<table border=1><tr><td>a</table>").as_deref(), Some("TableGrid"));
|
||
assert_eq!(table_style("<table style='border:1px solid #000'><tr><td>a</table>").as_deref(), Some("TableGrid"));
|
||
assert_eq!(table_style("<table><tr><td style='border:1px solid #999'>a</table>").as_deref(), Some("TableGrid"));
|
||
}
|
||
|
||
#[test]
|
||
fn borderless_tables_export_without_cell_borders() {
|
||
// #438: a table without grid style and borders writes no cell borders and reopens borderless.
|
||
for (src, grid) in [
|
||
("<table style='border:none'><tr><td>A<td>B</table>", false),
|
||
("<table><tr><td>A<td>B</table>", false),
|
||
("<table border=1><tr><td>A<td>B</table>", true),
|
||
] {
|
||
let out = export(&crate::model::to_doc(&parse(src)));
|
||
assert_eq!(out.contains("border:1px"), grid, "{src}: {out}");
|
||
assert_eq!(table_style(&out).as_deref(), Some(if grid { "TableGrid" } else { "" }), "{src}");
|
||
assert!(out.contains(">A</td>") && out.contains(">B</td>"), "{out}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn css_preformatted_paragraphs_keep_edge_spaces() {
|
||
// #439: `white-space:pre` keeps leading and trailing spaces like `<pre>`; normal
|
||
// paragraphs still collapse.
|
||
let f = parse(
|
||
"<p style=\"white-space:pre\"> Alpha Beta </p><p style=\"white-space:pre-wrap\">\n Gamma \n</p><pre> Alpha </pre><p> Alpha Beta </p>",
|
||
);
|
||
assert_eq!(texts(&f), vec!["NormalNone: Alpha Beta ", "NormalNone: Gamma ", "CodeNone: Alpha ", "NormalNone:Alpha Beta"]);
|
||
}
|
||
|
||
#[test]
|
||
fn tag_soup() {
|
||
let f = parse(
|
||
"<html><head><title>T & 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 & U</title><meta name='author' content='Ann'><style>p{{}}</style><script>ignored</script>{body}",
|
||
));
|
||
assert_eq!(f.meta.title, "T & U");
|
||
assert_eq!(f.meta.author, "Ann");
|
||
assert_eq!(texts(&f), vec!["NormalNone:Hello world"], "{body}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn styles_and_links() {
|
||
let f = parse("<p style='text-align:center'><span style=\"color:#ff0000;font-weight:700\">red</span> <a href=\"http://x\">l</a><br>n</p>");
|
||
let FBlock::Para(p) = &f.blocks[0] else { panic!() };
|
||
assert_eq!(p.align, Some(Align::Center));
|
||
assert!(matches!(&p.inlines[0], Inline::Text(t, f) if t == "red" && f.bold && f.color == Some(wordcraft_doc::Rgb(255,0,0))));
|
||
assert!(p.inlines.iter().any(|i| matches!(i, Inline::Text(t, f) if t == "l" && f.link.as_deref() == Some("http://x"))));
|
||
assert!(p.text().ends_with("l\nn"));
|
||
}
|
||
|
||
/// The paragraphs of every cell of the first table, row by row.
|
||
fn cell_paras(f: &Flow) -> Vec<Vec<Vec<Para>>> {
|
||
let Some(FBlock::Table(t)) = f.blocks.iter().find(|b| matches!(b, FBlock::Table(_))) else { panic!("no table") };
|
||
t.rows
|
||
.iter()
|
||
.map(|r| {
|
||
r.iter().map(|c| c.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p.clone()) } else { None }).collect()).collect()
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn all_bold(p: &Para) -> bool {
|
||
!p.inlines.is_empty() && p.inlines.iter().all(|i| matches!(i, Inline::Text(_, f) if f.bold))
|
||
}
|
||
|
||
fn any_bold(p: &Para) -> bool {
|
||
p.inlines.iter().any(|i| matches!(i, Inline::Text(_, f) if f.bold))
|
||
}
|
||
|
||
#[test]
|
||
fn header_cells_are_bold_unless_their_css_says_otherwise() {
|
||
// #1381: a <th> is bold, as in browsers and Word; `font-weight:normal` on it or inside it wins.
|
||
let f = parse(
|
||
"<table><thead><tr><th>Head</th><th style='font-weight:normal'>Plain</th><th><span style='font-weight:400'>Inner</span></th></tr></thead><tbody><tr><td>a<td>b<td>c</tbody></table>",
|
||
);
|
||
let rows = cell_paras(&f);
|
||
assert!(all_bold(&rows[0][0][0]), "{:?}", rows[0][0]);
|
||
assert!(!any_bold(&rows[0][1][0]), "{:?}", rows[0][1]);
|
||
assert!(!any_bold(&rows[0][2][0]), "{:?}", rows[0][2]);
|
||
assert!(!any_bold(&rows[1][0][0]), "td stays regular");
|
||
}
|
||
|
||
#[test]
|
||
fn cell_text_align_reaches_the_cell_paragraphs() {
|
||
// #1381: `text-align` on a td/th, or inherited from its tr/thead/tbody/table, aligns the
|
||
// cell's paragraphs; a paragraph's own text-align inside the cell wins.
|
||
let f = parse(
|
||
"<table style='text-align:center'><tbody style='text-align:justify'><tr style='text-align:right'>\
|
||
<td style='text-align:right;'>r</td><td>inherited</td><td style='text-align:right'><p style='text-align:center'>own</p></td>\
|
||
<td align=right>attr</td></tr><tr><td>body</td></tr></tbody><tr><td>table</td></tr></table>",
|
||
);
|
||
let rows = cell_paras(&f);
|
||
assert_eq!(rows[0][0][0].align, Some(Align::Right), "td style");
|
||
assert_eq!(rows[0][1][0].align, Some(Align::Right), "from tr");
|
||
assert_eq!(rows[0][2][0].align, Some(Align::Center), "own paragraph wins");
|
||
assert_eq!(rows[0][3][0].align, Some(Align::Right), "align attribute");
|
||
assert_eq!(rows[1][0][0].align, Some(Align::Justify), "from tbody");
|
||
assert_eq!(rows[2][0][0].align, Some(Align::Center), "from table");
|
||
// `align` on a table places the table; it doesn't align the text in its cells.
|
||
let f = parse("<table align=center><tr><td>x</td></tr></table>");
|
||
assert_eq!(cell_paras(&f)[0][0][0].align, None);
|
||
}
|
||
|
||
#[test]
|
||
fn reporter_sample_header_bold_and_right_aligned_cells() {
|
||
// #1381: the reporter's table.
|
||
let f = parse(
|
||
"<table border=\"1\" style=\"width:100%;border-collapse:collapse;\">\n<thead><tr style=\"background-color:#e8f0fa;\"><th>Indicatore</th><th>Valore atteso</th></tr></thead>\n<tbody>\n<tr><td>Audit</td><td style=\"text-align:right;\">4</td></tr>\n</tbody></table>",
|
||
);
|
||
let rows = cell_paras(&f);
|
||
assert!(all_bold(&rows[0][0][0]) && all_bold(&rows[0][1][0]), "{:?}", rows[0]);
|
||
assert_eq!(rows[1][1][0].align, Some(Align::Right));
|
||
assert_eq!(rows[1][0][0].align, None);
|
||
}
|
||
|
||
#[test]
|
||
fn entities() {
|
||
assert_eq!(unescape("a <b> AB &bogus; &"), "a <b> AB &bogus; &");
|
||
assert_eq!(unescape("− © &EURO; ™ ½ ¾"), "− © € ™ ½ ¾");
|
||
assert_eq!(unescape("θ λ Δ ∇ ∑ √ ≠ ≤ ≥ ♥"), "θ λ Δ ∇ ∑ √ ≠ ≤ ≥ ♥");
|
||
}
|
||
|
||
#[test]
|
||
fn wbr_word_break_opportunity() {
|
||
let f = parse("<p>Super<wbr>cali<wbr>fragilistic</p>");
|
||
let Some(FBlock::Para(p)) = f.blocks.first() else { panic!("expected para") };
|
||
assert_eq!(p.text(), "Super\u{200B}cali\u{200B}fragilistic");
|
||
}
|
||
|
||
#[test]
|
||
fn page_break_after_and_before() {
|
||
let f = parse("<p style='page-break-after: always'>First</p><p>Second</p>");
|
||
let paras: Vec<_> = f.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None }).collect();
|
||
assert_eq!(paras.len(), 2);
|
||
assert!(!paras[0].page_break);
|
||
assert!(paras[1].page_break);
|
||
|
||
let f2 = parse("<div style='break-after: page'><p>Part 1</p></div><p>Part 2</p>");
|
||
let paras2: Vec<_> = f2.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None }).collect();
|
||
assert_eq!(paras2.len(), 2);
|
||
assert!(!paras2[0].page_break);
|
||
assert!(paras2[1].page_break);
|
||
}
|
||
|
||
#[test]
|
||
fn font_size_relative_and_absolute() {
|
||
let f = parse("<font size=\"+1\">larger</font><font size=\"-1\">smaller</font><font size=\"5\">huge</font>");
|
||
let inls = match &f.blocks[0] {
|
||
FBlock::Para(p) => &p.inlines,
|
||
_ => panic!("expected para"),
|
||
};
|
||
let sizes: Vec<Option<f32>> = inls
|
||
.iter()
|
||
.map(|i| match i {
|
||
Inline::Text(_, fmt) => fmt.size,
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
assert_eq!(sizes, vec![Some(13.5), Some(10.0), Some(18.0)]);
|
||
}
|
||
|
||
#[test]
|
||
fn decode_with_charset_declarations() {
|
||
let iso = b"<html><head><meta charset=\"iso-8859-1\"></head><body>\xE9l\xE8ve</body></html>";
|
||
assert!(decode(iso).contains("élève"));
|
||
|
||
let win = b"<html><head><meta charset='windows-1252'></head><body>\x80100</body></html>";
|
||
assert!(decode(win).contains("€100"));
|
||
|
||
let gbk = b"<html><head><meta http-equiv=\"Content-Type\" content=\"text/html; charset=gbk\"></head><body>\xC4\xE3\xBA\xC3</body></html>";
|
||
assert!(decode(gbk).contains("你好"));
|
||
|
||
let xml = b"<?xml version=\"1.0\" encoding=\"utf-8\"?><html><body>hello</body></html>";
|
||
assert!(decode(xml).contains("hello"));
|
||
}
|
||
|
||
#[test]
|
||
fn raw_text_elements_skip_only_matching_end_tag() {
|
||
let f = parse("<script>var a = \"</scripted>\"; var b = \"<b>malicious</b>\";</script><p>Hello</p>");
|
||
assert_eq!(f.blocks.len(), 1);
|
||
let p = match &f.blocks[0] {
|
||
FBlock::Para(p) => p,
|
||
b => panic!("expected para, got {b:?}"),
|
||
};
|
||
assert_eq!(p.text(), "Hello");
|
||
|
||
let f = parse("<style>/* </styles> */ p { color: red; }</style><p>World</p>");
|
||
assert_eq!(f.blocks.len(), 1);
|
||
let p = match &f.blocks[0] {
|
||
FBlock::Para(p) => p,
|
||
b => panic!("expected para, got {b:?}"),
|
||
};
|
||
assert_eq!(p.text(), "World");
|
||
}
|
||
|
||
#[test]
|
||
fn leading_space_survives_round_trip() {
|
||
let doc = Document::from_text(" lead");
|
||
let html = export(&doc);
|
||
assert!(!html.contains("<p> lead"), "{html}");
|
||
let back = import(&html);
|
||
assert_eq!(back.plain_text(Default::default()).replace('\u{a0}', " "), " lead");
|
||
}
|
||
|
||
/// A document that reads "I read " then a phonetic guide over `base`, then " daily.".
|
||
fn ruby_doc(base: &str, ruby: &str) -> Document {
|
||
let props = wordcraft_doc::CharProps::default();
|
||
let mut p = wordcraft_doc::Paragraph::with_text("I read ", props.clone());
|
||
let n = p.len();
|
||
p.insert_object(n, crate::model::ruby_object(base.into(), ruby.into(), 20.0), &props).unwrap();
|
||
let n = p.len();
|
||
p.insert_text(n, " daily.", &props).unwrap();
|
||
let mut d = Document::new();
|
||
d.body = vec![wordcraft_doc::para_block(p)];
|
||
d
|
||
}
|
||
|
||
/// The first flow ruby: its base text and reading.
|
||
fn ruby_of(f: &Flow) -> Option<(String, String)> {
|
||
f.blocks.iter().find_map(|b| match b {
|
||
FBlock::Para(p) => p.inlines.iter().find_map(|i| match i {
|
||
Inline::Ruby { base, ruby, .. } => Some((base.clone(), ruby.clone())),
|
||
_ => None,
|
||
}),
|
||
_ => None,
|
||
})
|
||
}
|
||
|
||
/// The first document ruby: its base text and reading.
|
||
fn doc_ruby(d: &Document) -> Option<(String, String)> {
|
||
d.body.iter().find_map(|b| match &**b {
|
||
wordcraft_doc::Block::Para(p) => p.objects.iter().find_map(|o| match o {
|
||
wordcraft_doc::InlineObject::Ruby { base, ruby, .. } => Some((base.clone(), ruby.clone())),
|
||
_ => None,
|
||
}),
|
||
_ => None,
|
||
})
|
||
}
|
||
|
||
/// The first document ruby: base size, ruby size, raise and alignment.
|
||
fn doc_ruby_attrs(d: &Document) -> Option<(f32, f32, f32, wordcraft_doc::para::RubyAlign)> {
|
||
d.body.iter().find_map(|b| match &**b {
|
||
wordcraft_doc::Block::Para(p) => p.objects.iter().find_map(|o| match o {
|
||
wordcraft_doc::InlineObject::Ruby { base_size, size, raise, align, .. } => Some((*base_size, *size, *raise, *align)),
|
||
_ => None,
|
||
}),
|
||
_ => None,
|
||
})
|
||
}
|
||
|
||
#[test]
|
||
fn ruby_exports_as_ruby_markup() {
|
||
// #1681: the base text with its reading in `<rt>`, not plain base text.
|
||
let out = export(&ruby_doc("漢字", "かんじ"));
|
||
assert!(out.contains("<p>I read <ruby>漢字<rt>かんじ</rt></ruby> daily.</p>"), "{out}");
|
||
// The base and the reading are escaped like any other text.
|
||
let out = export(&ruby_doc("A&B", "x<y"));
|
||
assert!(out.contains("<ruby>A&B<rt>x<y</rt></ruby>"), "{out}");
|
||
}
|
||
|
||
#[test]
|
||
fn ruby_imports_with_its_surrounding_text() {
|
||
let f = parse("<p>I read <ruby>漢字<rt>かんじ</rt></ruby> daily.</p>");
|
||
assert_eq!(ruby_of(&f), Some(("漢字".into(), "かんじ".into())));
|
||
assert_eq!(texts(&f), vec!["NormalNone:I read 漢字 daily."]);
|
||
let FBlock::Para(p) = &f.blocks[0] else { panic!() };
|
||
assert!(matches!(&p.inlines[0], Inline::Text(t, _) if t == "I read "), "{:?}", p.inlines);
|
||
assert!(matches!(p.inlines.last(), Some(Inline::Text(t, _)) if t == " daily."), "{:?}", p.inlines);
|
||
// One document object, holding the base text and the reading.
|
||
assert_eq!(doc_ruby(&crate::model::to_doc(&f)), Some(("漢字".into(), "かんじ".into())));
|
||
}
|
||
|
||
#[test]
|
||
fn ruby_rb_and_rp_fallbacks_import_as_one_guide() {
|
||
// `<rb>` holds the base text and `<rp>` the fallback parentheses for browsers without
|
||
// ruby: the parentheses are not text, and the base pieces join.
|
||
let f = parse("<p><ruby><rb>漢</rb><rb>字</rb><rp>(</rp><rt>かんじ</rt><rp>)</rp></ruby></p>");
|
||
assert_eq!(ruby_of(&f), Some(("漢字".into(), "かんじ".into())));
|
||
assert_eq!(texts(&f), vec!["NormalNone:漢字"]);
|
||
assert_eq!(doc_ruby(&crate::model::to_doc(&f)), Some(("漢字".into(), "かんじ".into())));
|
||
}
|
||
|
||
#[test]
|
||
fn ruby_import_uses_the_phonetic_guide_defaults() {
|
||
// HTML states no alignment, size or raise: the Phonetic Guide's own defaults over a
|
||
// 20 pt run (half the base size, raised clear of the base text, centred)…
|
||
let d = crate::html::import("<p style='font-size:20pt'><ruby>漢字<rt>かんじ</rt></ruby></p>");
|
||
let Some((base_size, size, raise, align)) = doc_ruby_attrs(&d) else { panic!("no ruby") };
|
||
assert_eq!((base_size, size, align.ooxml()), (20.0, 10.0, "center"));
|
||
assert!((raise - 18.0).abs() < 0.001, "{raise}");
|
||
// …and the guide's 10.5 pt fallback base size when the source states none.
|
||
let d = crate::html::import("<p><ruby>漢字<rt>かんじ</rt></ruby></p>");
|
||
let Some((base_size, size, raise, _)) = doc_ruby_attrs(&d) else { panic!("no ruby") };
|
||
assert_eq!((base_size, size), (10.5, 5.0));
|
||
assert!((raise - 9.4).abs() < 0.001, "{raise}");
|
||
}
|
||
|
||
#[test]
|
||
fn ruby_survives_an_html_round_trip() {
|
||
let html = export(&ruby_doc("漢字", "かんじ"));
|
||
assert_eq!(ruby_of(&parse(&html)), Some(("漢字".into(), "かんじ".into())));
|
||
let back = crate::html::import(&html);
|
||
assert_eq!(doc_ruby(&back), Some(("漢字".into(), "かんじ".into())));
|
||
assert_eq!(back.plain_text(Default::default()), "I read 漢字 daily.");
|
||
}
|
||
|
||
#[test]
|
||
fn ruby_keeps_the_run_formatting() {
|
||
let props = wordcraft_doc::CharProps { bold: Some(true), ..Default::default() };
|
||
let mut p = wordcraft_doc::Paragraph::with_text("I read ", props.clone());
|
||
let n = p.len();
|
||
p.insert_object(n, crate::model::ruby_object("漢字".into(), "かんじ".into(), 20.0), &props).unwrap();
|
||
let mut d = Document::new();
|
||
d.body = vec![wordcraft_doc::para_block(p)];
|
||
assert!(export(&d).contains("<strong><ruby>漢字<rt>かんじ</rt></ruby></strong>"), "{}", export(&d));
|
||
// Importing it back keeps the formatting for the base text and the reading.
|
||
let f = parse("<p><strong><ruby>漢字<rt>かんじ</rt></ruby></strong></p>");
|
||
let FBlock::Para(p) = &f.blocks[0] else { panic!() };
|
||
assert!(matches!(&p.inlines[0], Inline::Ruby { fmt, .. } if fmt.bold), "{:?}", p.inlines);
|
||
}
|
||
|
||
#[test]
|
||
fn malformed_ruby_keeps_its_text() {
|
||
// No reading, an empty reading, a missing base, an unclosed element and a block boundary
|
||
// closing it: none of them panics, and the text inside is kept.
|
||
let cases: [(&str, &[&str]); 7] = [
|
||
("<p>a <ruby>漢字</ruby> b</p>", &["NormalNone:a 漢字 b"]),
|
||
("<p>a <ruby>漢字<rt></rt></ruby> b</p>", &["NormalNone:a 漢字 b"]),
|
||
("<p>a <ruby><rt>かんじ</rt></ruby> b</p>", &["NormalNone:a かんじ b"]),
|
||
("<p>a <ruby><rt></rt></ruby> b</p>", &["NormalNone:a b"]),
|
||
("<ruby>", &[]),
|
||
("<p>a <ruby>漢字<rt>かんじ</rt> b</p><p>next</p>", &["NormalNone:a 漢字 b", "NormalNone:next"]),
|
||
("<p>a <ruby>漢字<rt>かんじ</rt></p><p>next</p>", &["NormalNone:a 漢字", "NormalNone:next"]),
|
||
];
|
||
for (src, want) in cases {
|
||
assert_eq!(texts(&parse(src)), want, "{src}");
|
||
}
|
||
// Ruby in ruby: the inner element's text joins the outer guide's.
|
||
assert_eq!(ruby_of(&parse("<p><ruby>漢字<ruby>字<rt>じ</rt></ruby><rt>かんじ</rt></ruby></p>")), Some(("漢字字".into(), "じかんじ".into())));
|
||
}
|
||
|
||
#[test]
|
||
fn small_big_and_css_font_sizes() {
|
||
let f = parse(
|
||
"<p><small>small</small> <big>big</big> <span style=\"font-size: xxx-large\">huge</span> <span style=\"font-size: smaller\">tiny</span></p>",
|
||
);
|
||
let FBlock::Para(p) = &f.blocks[0] else { panic!() };
|
||
let sz = |i: usize| match &p.inlines[i] {
|
||
Inline::Text(_, f) => f.size,
|
||
_ => None,
|
||
};
|
||
assert_eq!(sz(0), Some(10.0));
|
||
assert_eq!(sz(2), Some(14.0));
|
||
assert_eq!(sz(4), Some(36.0));
|
||
assert_eq!(sz(6), Some(10.0));
|
||
}
|
||
|
||
#[test]
|
||
fn css_important_casing_and_spacing() {
|
||
let f = parse(
|
||
"<p style='color: #ff0000 !IMPORTANT; font-size: 16pt ! important; font-weight: 700 !Important; text-align: center !IMPORTANT'>Styled</p>",
|
||
);
|
||
assert_eq!(f.blocks.len(), 1);
|
||
let p = match &f.blocks[0] {
|
||
FBlock::Para(p) => p,
|
||
b => panic!("expected para, got {b:?}"),
|
||
};
|
||
assert_eq!(p.align, Some(Align::Center));
|
||
match &p.inlines[0] {
|
||
Inline::Text(_, fmt) => {
|
||
assert_eq!(fmt.color, Some(wordcraft_doc::props::Rgb(0xFF, 0x00, 0x00)));
|
||
assert_eq!(fmt.size, Some(16.0));
|
||
assert!(fmt.bold);
|
||
}
|
||
i => panic!("expected text inline, got {i:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn table_cell_vertical_alignment() {
|
||
let f = parse("<table><tr><td valign=top>top</td><td valign=\"center\">mid</td><td style=\"vertical-align: bottom\">bot</td></tr></table>");
|
||
let FBlock::Table(t) = &f.blocks[0] else { panic!() };
|
||
assert_eq!(t.rows[0][0].valign, Some(VAlign::Top));
|
||
assert_eq!(t.rows[0][1].valign, Some(VAlign::Center));
|
||
assert_eq!(t.rows[0][2].valign, Some(VAlign::Bottom));
|
||
let doc = crate::model::to_doc(&f);
|
||
let wordcraft_doc::Block::Table(dt) = &*doc.body[0] else { panic!() };
|
||
assert_eq!(dt.rows[0].cells[0].props.valign, Some(VAlign::Top));
|
||
assert_eq!(dt.rows[0].cells[1].props.valign, Some(VAlign::Center));
|
||
assert_eq!(dt.rows[0].cells[2].props.valign, Some(VAlign::Bottom));
|
||
let html = export(&doc);
|
||
assert!(html.contains("vertical-align:top"));
|
||
assert!(html.contains("vertical-align:middle"));
|
||
assert!(html.contains("vertical-align:bottom"));
|
||
}
|
||
}
|