diff --git a/src/epub/x4.rs b/src/epub/x4.rs index e58a3c7..715f22f 100644 --- a/src/epub/x4.rs +++ b/src/epub/x4.rs @@ -136,11 +136,145 @@ pub const CODE_GAP_CLASS: &str = "code-gap"; /// then soft-hyphenate anything too long for it to wrap (ยง3.10). pub fn simplify_xhtml(xhtml: &str) -> String { paragraph_code_lines(&break_long_words(&strip_attributes( - xhtml, + &flatten_lossy_tables(xhtml), DROPPED_ATTRIBUTES, ))) } +/// Table elements dropped outright when an image table is flattened. +const TABLE_FRAME_ELEMENTS: &[&str] = + &["table", "thead", "tbody", "tfoot", "tr", "colgroup", "col"]; + +/// Table elements whose content becomes a `
` when a table is flattened. +const TABLE_CELL_ELEMENTS: &[&str] = &["td", "th", "caption"]; + +/// Elements the X4 firmwares treat as images. +const IMAGE_ELEMENTS: &[&str] = &["img", "image"]; + +/// Turn every table the X4 firmwares would lose content from into plain +/// `
` blocks: one that holds an image or another table. +/// +/// The firmwares (CrossPoint and CrossInk) never draw an image inside a table +/// cell: they print its alt text instead. Publishers that lay out a figure as a +/// one-cell table with the caption beneath the image (The Space Review, many +/// older sites) would lose every figure. CrossInk also throws away all the text +/// of a table nested in another. Flattening keeps each cell's content in order, +/// which is how the firmwares already fall back for any table they cannot lay +/// out as a grid. Tables of plain text stay tables. +fn flatten_lossy_tables(html: &str) -> String { + let mut out = String::with_capacity(html.len()); + let mut cursor = 0usize; + while let Some(rel) = html[cursor..].find('<') { + let start = cursor + rel; + out.push_str(&html[cursor..start]); + let Some(end) = tag_end(html, start) else { + out.push_str(&html[start..]); + return out; + }; + let tag = &html[start..end]; + if let Some(name) = raw_text_name(tag) { + let close = find_close_tag(html, end, name).unwrap_or(html.len()); + out.push_str(&html[start..close]); + cursor = close; + continue; + } + if !is_element_tag(tag, "table", false) { + out.push_str(tag); + cursor = end; + continue; + } + let table_end = matching_table_end(html, start); + let table = &html[start..table_end]; + // Past the outer `` that closes the table opened at +/// `start`, counting nested tables; the end of `html` if it is never closed. +fn matching_table_end(html: &str, start: usize) -> usize { + let mut depth = 0usize; + let mut cursor = start; + while let Some(rel) = html[cursor..].find('<') { + let tag_start = cursor + rel; + let Some(end) = tag_end(html, tag_start) else { + break; + }; + let tag = &html[tag_start..end]; + if is_element_tag(tag, "table", false) && !tag.ends_with("/>") { + depth += 1; + } else if is_element_tag(tag, "table", true) { + depth = depth.saturating_sub(1); + if depth == 0 { + return end; + } + } + cursor = end; + } + html.len() +} + +/// Whether any tag in `html` opens one of the named elements. +fn contains_element(html: &str, names: &[&str]) -> bool { + let mut cursor = 0usize; + while let Some(rel) = html[cursor..].find('<') { + let start = cursor + rel; + let Some(end) = tag_end(html, start) else { + return false; + }; + let tag = &html[start..end]; + if names.iter().any(|name| is_element_tag(tag, name, false)) { + return true; + } + cursor = end; + } + false +} + +/// Rewrite one table's markup (nested tables included) as `
` blocks. +fn flatten_table(table: &str) -> String { + let mut out = String::with_capacity(table.len()); + let mut cursor = 0usize; + while let Some(rel) = table[cursor..].find('<') { + let start = cursor + rel; + out.push_str(&table[cursor..start]); + let Some(end) = tag_end(table, start) else { + out.push_str(&table[start..]); + return out; + }; + let tag = &table[start..end]; + cursor = end; + let is = |names: &[&str], closing: bool| { + names.iter().any(|name| is_element_tag(tag, name, closing)) + }; + if is(TABLE_FRAME_ELEMENTS, false) || is(TABLE_FRAME_ELEMENTS, true) { + // A newline, not nothing: the firmwares buffer the last word + // before an element boundary until whitespace arrives. + out.push('\n'); + } else if is(TABLE_CELL_ELEMENTS, false) { + out.push_str(if tag.ends_with("/>") { + "
" + } else { + "
" + }); + } else if is(TABLE_CELL_ELEMENTS, true) { + out.push_str("
"); + } else { + out.push_str(tag); + } + } + out.push_str(&table[cursor..]); + out +} + /// Turn each line of every `
` into its own `

`. /// /// The X4 firmwares (CrossPoint and forks such as CrossInk) ignore @@ -975,4 +1109,97 @@ mod tests { "\n\n

2 < 3

"; assert_eq!(simplify_xhtml(input), input); } + + /// Whether any image in `html` sits inside a table, where the X4 + /// firmwares print its alt text instead of drawing it. + fn image_inside_table(html: &str) -> bool { + let mut depth = 0usize; + let mut cursor = 0usize; + while let Some(rel) = html[cursor..].find('<') { + let start = cursor + rel; + let end = tag_end(html, start).unwrap(); + let tag = &html[start..end]; + if is_element_tag(tag, "table", false) { + depth += 1; + } else if is_element_tag(tag, "table", true) { + depth -= 1; + } else if depth > 0 && IMAGE_ELEMENTS.iter().any(|n| is_element_tag(tag, n, false)) { + return true; + } + cursor = end; + } + false + } + + #[test] + fn a_figure_laid_out_as_a_table_becomes_divs() { + // The Space Review's figure markup (issue 2026-10-01). + let input = "

Before.

\"ELINT

During the 1970s. (credit: NRO)

Next

"; + let out = simplify_xhtml(input); + assert!(!image_inside_table(&out), "{out}"); + assert!(!out.contains("\"ELINT

During the 1970s. (credit: NRO)

"), + "{out}" + ); + assert!( + out.starts_with("

Before.

") && out.ends_with("

Next

"), + "{out}" + ); + } + + #[test] + fn every_cell_of_an_image_table_keeps_its_content_in_order() { + let input = "
Cap
AB
leftright
"; + let out = flatten_lossy_tables(input); + assert_eq!( + out.split_whitespace().collect::(), + "
Cap
A
B
left
right
" + ); + } + + #[test] + fn nested_image_tables_flatten_from_the_outside_in() { + for input in [ + // Image in the inner table only. + "

after

", + // Image in the outer table after a text-only inner table. + "
x

after

", + // An SVG `` counts too. + "

after

", + ] { + let out = simplify_xhtml(input); + assert!(!image_inside_table(&out), "{out}"); + assert!(!out.contains("after

"), "{out}"); + } + } + + #[test] + fn a_nested_text_table_flattens_its_outer_table() { + let input = "
outer
inner
"; + let out = flatten_lossy_tables(input); + assert_eq!( + out.split_whitespace().collect::(), + "
outer
inner
" + ); + } + + #[test] + fn text_tables_stay_tables() { + let input = "
Year1971

2
"; + assert_eq!(flatten_lossy_tables(input), input); + } + + #[test] + fn table_markup_inside_raw_text_is_left_alone() { + let input = ""; + assert_eq!(flatten_lossy_tables(input), input); + } + + #[test] + fn an_unclosed_image_table_still_flattens() { + let out = flatten_lossy_tables("
"); + assert!(!image_inside_table(&out), "{out}"); + } }