Flatten X4 tables that hold images or nested tables

CrossPoint and CrossInk print an image's alt text instead of drawing it
inside a table cell, and CrossInk drops all text of a nested table.
Layout tables (The Space Review's figures) lost every image on the X4:
59 images across 8 articles in the published issues.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 20:13:21 +00:00
co-authored by Claude Opus 5.5
parent 7851d8e231
commit bf6a8d0129
+228 -1
View File
@@ -136,11 +136,145 @@ pub const CODE_GAP_CLASS: &str = "code-gap";
/// then soft-hyphenate anything too long for it to wrap (§3.10). /// then soft-hyphenate anything too long for it to wrap (§3.10).
pub fn simplify_xhtml(xhtml: &str) -> String { pub fn simplify_xhtml(xhtml: &str) -> String {
paragraph_code_lines(&break_long_words(&strip_attributes( paragraph_code_lines(&break_long_words(&strip_attributes(
xhtml, &flatten_lossy_tables(xhtml),
DROPPED_ATTRIBUTES, 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 `<div>` 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
/// `<div>` 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 `<table` tag, so it doesn't count as its own nesting.
let inner = &table[end - start..];
if contains_element(inner, IMAGE_ELEMENTS) || contains_element(inner, &["table"]) {
out.push_str(&flatten_table(table));
} else {
out.push_str(table);
}
cursor = table_end;
}
out.push_str(&html[cursor..]);
out
}
/// Byte offset just past the `</table>` 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 `<div>` 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("/>") {
"<div></div>"
} else {
"<div>"
});
} else if is(TABLE_CELL_ELEMENTS, true) {
out.push_str("</div>");
} else {
out.push_str(tag);
}
}
out.push_str(&table[cursor..]);
out
}
/// Turn each line of every `<pre>` into its own `<p class="code-line">`. /// Turn each line of every `<pre>` into its own `<p class="code-line">`.
/// ///
/// The X4 firmwares (CrossPoint and forks such as CrossInk) ignore /// The X4 firmwares (CrossPoint and forks such as CrossInk) ignore
@@ -975,4 +1109,97 @@ mod tests {
"<?xml version=\"1.0\"?>\n<!DOCTYPE html>\n<html><body><p>2 &lt; 3</p></body></html>"; "<?xml version=\"1.0\"?>\n<!DOCTYPE html>\n<html><body><p>2 &lt; 3</p></body></html>";
assert_eq!(simplify_xhtml(input), input); 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 = "<p>Before.</p><table><tbody><tr><td><img src=\"images/a.jpg\" alt=\"ELINT images\"/><p>During the 1970s. (credit: NRO)</p></td></tr></tbody></table><h3>Next</h3>";
let out = simplify_xhtml(input);
assert!(!image_inside_table(&out), "{out}");
assert!(!out.contains("<t"), "{out}");
assert!(
out.contains("<div><img src=\"images/a.jpg\" alt=\"ELINT images\"/><p>During the 1970s. (credit: NRO)</p></div>"),
"{out}"
);
assert!(
out.starts_with("<p>Before.</p>") && out.ends_with("<h3>Next</h3>"),
"{out}"
);
}
#[test]
fn every_cell_of_an_image_table_keeps_its_content_in_order() {
let input = "<table class=\"x\" width=\"600\"><caption>Cap</caption><thead><tr><th>A</th><th>B</th></tr></thead><tr><td colspan=\"2\"><img src=\"1.jpg\"/></td></tr><tr><td>left</td><td>right</td></tr></table>";
let out = flatten_lossy_tables(input);
assert_eq!(
out.split_whitespace().collect::<String>(),
"<div>Cap</div><div>A</div><div>B</div><div><imgsrc=\"1.jpg\"/></div><div>left</div><div>right</div>"
);
}
#[test]
fn nested_image_tables_flatten_from_the_outside_in() {
for input in [
// Image in the inner table only.
"<table><tr><td><table><tr><td><img src=\"1.jpg\"/></td></tr></table></td></tr></table><p>after</p>",
// Image in the outer table after a text-only inner table.
"<table><tr><td><table><tr><td>x</td></tr></table><img src=\"1.jpg\"/></td></tr></table><p>after</p>",
// An SVG `<image>` counts too.
"<table><tr><td><svg><image href=\"1.jpg\"/></svg></td></tr></table><p>after</p>",
] {
let out = simplify_xhtml(input);
assert!(!image_inside_table(&out), "{out}");
assert!(!out.contains("<table"), "{out}");
assert!(out.ends_with("<p>after</p>"), "{out}");
}
}
#[test]
fn a_nested_text_table_flattens_its_outer_table() {
let input = "<table><tr><td>outer<table><tr><td>inner</td></tr></table></td></tr></table>";
let out = flatten_lossy_tables(input);
assert_eq!(
out.split_whitespace().collect::<String>(),
"<div>outer<div>inner</div></div>"
);
}
#[test]
fn text_tables_stay_tables() {
let input = "<table><tr><th>Year</th><td>1971</td></tr></table><p><img src=\"1.jpg\"/></p><table><tr><td>2</td></tr></table>";
assert_eq!(flatten_lossy_tables(input), input);
}
#[test]
fn table_markup_inside_raw_text_is_left_alone() {
let input = "<script>let t = '<table><td><img src=x></td></table>';</script>";
assert_eq!(flatten_lossy_tables(input), input);
}
#[test]
fn an_unclosed_image_table_still_flattens() {
let out = flatten_lossy_tables("<table><tr><td><img src=\"1.jpg\"/>");
assert!(!image_inside_table(&out), "{out}");
}
} }