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:
+228
-1
@@ -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 < 3</p></body></html>";
|
"<?xml version=\"1.0\"?>\n<!DOCTYPE html>\n<html><body><p>2 < 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}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user