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>
1206 lines
44 KiB
Rust
1206 lines
44 KiB
Rust
//! Xteink X4 edition transforms and the XTC converter invocation
|
|
//! (spec §3.10 X4 edition, §3.11).
|
|
//!
|
|
//! The converter has no global npm bin: it is run as
|
|
//! `node <repo>/cli/index.js convert <in.epub> -o <out.xtch> -f xtch [-c settings.json]`
|
|
//! (implementation notes, verified facts). A missing or failing converter is
|
|
//! non-fatal — XTC is a bonus artifact.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use crate::config::XtcConfig;
|
|
|
|
use crate::html::tag_end;
|
|
|
|
/// Native X4 screen size, used for the cover and image fitting (§3.10).
|
|
pub const X4_SCREEN: (u32, u32) = (480, 800);
|
|
|
|
/// Attributes that let a document lay itself out — dropped for the X4 (§3.10).
|
|
pub const DROPPED_ATTRIBUTES: &[&str] = &[
|
|
"style", "align", "width", "height", "srcset", "sizes", "loading", "hspace", "vspace", "border",
|
|
];
|
|
|
|
/// Longest unbroken run of non-whitespace the X4 firmware will lay out; past
|
|
/// this it stops wrapping and the line runs off the 480px screen (§3.10).
|
|
///
|
|
/// Real text never gets near 200 characters — this is for minified source in a
|
|
/// code block and for bare URLs pasted into comment threads.
|
|
pub const MAX_WORD_CHARS: usize = 200;
|
|
|
|
/// U+00AD, invisible unless the renderer actually needs to break there.
|
|
const SOFT_HYPHEN: char = '\u{00ad}';
|
|
|
|
/// U+00A0, rendered as visible indentation even when the X4 collapses whitespace.
|
|
const NO_BREAK_SPACE: char = '\u{00a0}';
|
|
|
|
/// Elements whose content is code, not prose, and must be copied through
|
|
/// untouched — a soft hyphen inside a stylesheet would corrupt it.
|
|
const RAW_TEXT_ELEMENTS: &[&str] = &["script", "style"];
|
|
|
|
/// Declarations the X4 renderer cannot honor (§3.10).
|
|
const DROPPED_PROPERTIES: &[&str] = &[
|
|
"float",
|
|
"clear",
|
|
"position",
|
|
"z-index",
|
|
"box-shadow",
|
|
"text-shadow",
|
|
"transform",
|
|
"columns",
|
|
"column-count",
|
|
"column-gap",
|
|
"letter-spacing",
|
|
];
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum XtcError {
|
|
#[error("could not run `{command}`: {source}")]
|
|
Spawn {
|
|
command: String,
|
|
#[source]
|
|
source: std::io::Error,
|
|
},
|
|
#[error("converter exited with status {status}: {stderr}")]
|
|
Failed { status: i32, stderr: String },
|
|
#[error("converter produced no output at {0}")]
|
|
NoOutput(PathBuf),
|
|
}
|
|
|
|
/// Simplify CSS for the X4: no floats/flex/grid, no embedded fonts, larger base
|
|
/// font, generous line-height, hyphenation on (§3.10).
|
|
pub fn simplify_css(css: &str) -> String {
|
|
let mut out = String::with_capacity(css.len());
|
|
let mut rest = css;
|
|
while let Some(open) = rest.find('{') {
|
|
let selector = &rest[..open];
|
|
let Some(close) = rest[open..].find('}') else {
|
|
break;
|
|
};
|
|
let body = &rest[open + 1..open + close];
|
|
rest = &rest[open + close + 1..];
|
|
|
|
// `@font-face` (and any other embedded-font rule) is dropped wholesale.
|
|
if selector.to_ascii_lowercase().contains("@font-face") {
|
|
continue;
|
|
}
|
|
let kept: Vec<&str> = body
|
|
.split(';')
|
|
.filter(|decl| !decl.trim().is_empty())
|
|
.filter(|decl| !is_dropped_declaration(decl))
|
|
.collect();
|
|
if kept.is_empty() {
|
|
continue;
|
|
}
|
|
out.push_str(selector.trim_start_matches('\n'));
|
|
out.push('{');
|
|
for decl in kept {
|
|
out.push_str(decl);
|
|
out.push(';');
|
|
}
|
|
out.push('}');
|
|
out.push('\n');
|
|
}
|
|
out
|
|
}
|
|
|
|
fn is_dropped_declaration(decl: &str) -> bool {
|
|
let Some((property, value)) = decl.split_once(':') else {
|
|
return true;
|
|
};
|
|
let property = property.trim().to_ascii_lowercase();
|
|
let value = value.trim().to_ascii_lowercase();
|
|
if DROPPED_PROPERTIES.contains(&property.as_str()) {
|
|
return true;
|
|
}
|
|
if property == "display" && (value.contains("flex") || value.contains("grid")) {
|
|
return true;
|
|
}
|
|
if property.starts_with("flex") || property.starts_with("grid") {
|
|
return true;
|
|
}
|
|
if property == "font-family" && value.contains("url(") {
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Class of every paragraph [`paragraph_code_lines`] makes from a listing
|
|
/// line; the first and last also get [`CODE_FIRST_CLASS`] / [`CODE_LAST_CLASS`].
|
|
pub const CODE_LINE_CLASS: &str = "code-line";
|
|
pub const CODE_FIRST_CLASS: &str = "code-first";
|
|
pub const CODE_LAST_CLASS: &str = "code-last";
|
|
/// Marks a line followed by blank lines in the source; it gets a short gap.
|
|
pub const CODE_GAP_CLASS: &str = "code-gap";
|
|
|
|
/// Strip layout constructs the X4 renderer handles poorly from chapter markup,
|
|
/// 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(
|
|
&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 `<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">`.
|
|
///
|
|
/// The X4 firmwares (CrossPoint and forks such as CrossInk) ignore
|
|
/// `white-space` and lay out only `p`, `div`, `li`, `blockquote`, lists and
|
|
/// `br` as blocks: a `<pre>` is inline, so its margins never apply and its
|
|
/// `<br/>` lines are handled by each firmware's own rules. CrossInk glues the
|
|
/// first line onto the paragraph before, and empty `<br/>` lines count as scene
|
|
/// breaks. A paragraph per line takes the one path every firmware lays out the
|
|
/// same way. The first and last lines carry the block's margins; blank lines
|
|
/// become a half-line gap (see [`code_paragraphs`]).
|
|
///
|
|
/// Inline elements open across a line break (a `<code>`, a multi-line
|
|
/// comment's span) are closed at the end of the line and reopened at the start
|
|
/// of the next, so every paragraph is well formed.
|
|
fn paragraph_code_lines(html: &str) -> String {
|
|
let mut out = String::with_capacity(html.len() + 256);
|
|
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];
|
|
out.push_str(tag);
|
|
cursor = end;
|
|
if !is_element_tag(tag, "pre", false) {
|
|
continue;
|
|
}
|
|
let Some(close) = find_close_tag(html, cursor, "pre") else {
|
|
continue;
|
|
};
|
|
out.push_str(&code_paragraphs(&html[cursor..close]));
|
|
cursor = close;
|
|
}
|
|
out.push_str(&html[cursor..]);
|
|
out
|
|
}
|
|
|
|
/// The paragraphs for one `<pre>` body whose line breaks are `<br/>`.
|
|
fn code_paragraphs(body: &str) -> String {
|
|
// Each line's markup, plus whether it shows any text.
|
|
let mut lines: Vec<(String, bool)> = Vec::new();
|
|
// Inline elements open at this point: (name, opening tag).
|
|
let mut open: Vec<(String, &str)> = Vec::new();
|
|
let mut line = String::new();
|
|
let mut visible = false;
|
|
let mut cursor = 0usize;
|
|
let finish = |line: &mut String, visible: &mut bool, open: &[(String, &str)]| {
|
|
let mut done = std::mem::take(line);
|
|
if !*visible {
|
|
done.push(NO_BREAK_SPACE);
|
|
}
|
|
for (name, _) in open.iter().rev() {
|
|
done.push_str(&format!("</{name}>"));
|
|
}
|
|
let shown = std::mem::replace(visible, false);
|
|
for (_, tag) in open {
|
|
line.push_str(tag);
|
|
}
|
|
(done, shown)
|
|
};
|
|
while cursor < body.len() {
|
|
let Some(rel) = body[cursor..].find('<') else {
|
|
let text = &body[cursor..];
|
|
visible |= shows_text(text);
|
|
line.push_str(text);
|
|
break;
|
|
};
|
|
let start = cursor + rel;
|
|
let text = &body[cursor..start];
|
|
visible |= shows_text(text);
|
|
line.push_str(text);
|
|
let Some(end) = tag_end(body, start) else {
|
|
line.push_str(&body[start..]);
|
|
break;
|
|
};
|
|
let tag = &body[start..end];
|
|
cursor = end;
|
|
let inner = tag.trim_start_matches('<').trim_end_matches('>');
|
|
let name = crate::html::tag_name(inner);
|
|
if name == "br" {
|
|
lines.push(finish(&mut line, &mut visible, &open));
|
|
} else if inner.starts_with('/') {
|
|
if let Some(at) = open.iter().rposition(|(open_name, _)| *open_name == name) {
|
|
open.remove(at);
|
|
}
|
|
line.push_str(tag);
|
|
} else {
|
|
if !inner.trim_end().ends_with('/') {
|
|
open.push((name, tag));
|
|
}
|
|
line.push_str(tag);
|
|
}
|
|
}
|
|
lines.push(finish(&mut line, &mut visible, &open));
|
|
// Blank lines become a short gap after the line before them rather than
|
|
// paragraphs of their own: with the firmware's paragraph spacing on, a
|
|
// blank paragraph adds a full line plus two half-line gaps and reads as a
|
|
// double newline. Leading, trailing and repeated blanks collapse.
|
|
let mut kept: Vec<(String, bool)> = Vec::with_capacity(lines.len());
|
|
for (line, shown) in lines {
|
|
if shown {
|
|
kept.push((line, false));
|
|
} else if let Some(previous) = kept.last_mut() {
|
|
previous.1 = true;
|
|
}
|
|
}
|
|
if kept.is_empty() {
|
|
// An all-blank listing still occupies one line.
|
|
kept.push((NO_BREAK_SPACE.to_string(), false));
|
|
}
|
|
|
|
let last = kept.len() - 1;
|
|
let mut out = String::with_capacity(body.len() + kept.len() * 48);
|
|
for (index, (line, gap_after)) in kept.into_iter().enumerate() {
|
|
let mut class = String::from(CODE_LINE_CLASS);
|
|
if index == 0 {
|
|
class.push(' ');
|
|
class.push_str(CODE_FIRST_CLASS);
|
|
}
|
|
if index == last {
|
|
class.push(' ');
|
|
class.push_str(CODE_LAST_CLASS);
|
|
} else if gap_after {
|
|
class.push(' ');
|
|
class.push_str(CODE_GAP_CLASS);
|
|
}
|
|
out.push_str(&format!("<p class=\"{class}\">{line}</p>"));
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Whether a run of character data shows anything but spaces.
|
|
fn shows_text(text: &str) -> bool {
|
|
text.chars()
|
|
.any(|c| !c.is_whitespace() && c != NO_BREAK_SPACE)
|
|
}
|
|
|
|
/// Insert soft hyphens into over-long words and make `<pre>` whitespace
|
|
/// explicit, in text content only (§3.10).
|
|
fn break_long_words(html: &str) -> String {
|
|
let mut out = String::with_capacity(html.len());
|
|
let mut cursor = 0usize;
|
|
let mut inside_pre = false;
|
|
let mut at_line_start = false;
|
|
// A newline right after `<pre>` (or `<pre><code>`) is not a blank line:
|
|
// browsers drop it, so we do too.
|
|
let mut pre_just_opened = false;
|
|
while let Some(rel) = html[cursor..].find('<') {
|
|
let start = cursor + rel;
|
|
if inside_pre {
|
|
preserve_pre_whitespace(
|
|
&html[cursor..start],
|
|
&mut out,
|
|
&mut at_line_start,
|
|
&mut pre_just_opened,
|
|
);
|
|
} else {
|
|
soften_text(&html[cursor..start], &mut out);
|
|
}
|
|
let Some(end) = tag_end(html, start) else {
|
|
out.push_str(&html[start..]);
|
|
return out;
|
|
};
|
|
let tag = &html[start..end];
|
|
out.push_str(tag);
|
|
cursor = end;
|
|
if is_element_tag(tag, "pre", false) {
|
|
inside_pre = true;
|
|
at_line_start = true;
|
|
pre_just_opened = true;
|
|
} else if is_element_tag(tag, "pre", true) {
|
|
inside_pre = false;
|
|
} else if inside_pre && is_element_tag(tag, "br", false) {
|
|
at_line_start = true;
|
|
} else if inside_pre && !is_element_tag(tag, "code", false) {
|
|
pre_just_opened = false;
|
|
}
|
|
// `<style>`/`<script>` bodies are not prose: copy to the closing tag verbatim.
|
|
if let Some(name) = raw_text_name(tag)
|
|
&& let Some(close) = find_close_tag(html, cursor, name)
|
|
{
|
|
out.push_str(&html[cursor..close]);
|
|
cursor = close;
|
|
}
|
|
}
|
|
if inside_pre {
|
|
preserve_pre_whitespace(
|
|
&html[cursor..],
|
|
&mut out,
|
|
&mut at_line_start,
|
|
&mut pre_just_opened,
|
|
);
|
|
} else {
|
|
soften_text(&html[cursor..], &mut out);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Whether `tag` opens or closes the named element.
|
|
fn is_element_tag(tag: &str, name: &str, closing: bool) -> bool {
|
|
let Some(mut rest) = tag.strip_prefix('<') else {
|
|
return false;
|
|
};
|
|
if closing {
|
|
let Some(after_slash) = rest.strip_prefix('/') else {
|
|
return false;
|
|
};
|
|
rest = after_slash;
|
|
} else if rest.starts_with(['/', '!', '?']) {
|
|
return false;
|
|
}
|
|
rest.len() >= name.len()
|
|
&& rest[..name.len()].eq_ignore_ascii_case(name)
|
|
&& rest[name.len()..].starts_with([' ', '\t', '\n', '\r', '>', '/'])
|
|
}
|
|
|
|
/// Make whitespace in a `<pre>` text node survive renderers that ignore CSS.
|
|
fn preserve_pre_whitespace(
|
|
text: &str,
|
|
out: &mut String,
|
|
at_line_start: &mut bool,
|
|
pre_just_opened: &mut bool,
|
|
) {
|
|
let mut rest = text;
|
|
if *pre_just_opened && !rest.is_empty() {
|
|
*pre_just_opened = false;
|
|
rest = rest
|
|
.strip_prefix("\r\n")
|
|
.or_else(|| rest.strip_prefix(['\n', '\r']))
|
|
.unwrap_or(rest);
|
|
}
|
|
while !rest.is_empty() {
|
|
let plain_len = rest.find([' ', '\t', '\n', '\r']).unwrap_or(rest.len());
|
|
if plain_len > 0 {
|
|
soften_text(&rest[..plain_len], out);
|
|
*at_line_start = false;
|
|
rest = &rest[plain_len..];
|
|
continue;
|
|
}
|
|
|
|
if rest.starts_with("\r\n") {
|
|
out.push_str("<br/>");
|
|
*at_line_start = true;
|
|
rest = &rest[2..];
|
|
} else if rest.starts_with(['\n', '\r']) {
|
|
out.push_str("<br/>");
|
|
*at_line_start = true;
|
|
rest = &rest[1..];
|
|
} else if rest.starts_with('\t') {
|
|
out.extend(std::iter::repeat_n(NO_BREAK_SPACE, 4));
|
|
rest = &rest[1..];
|
|
} else {
|
|
let spaces = rest.bytes().take_while(|&b| b == b' ').count();
|
|
if *at_line_start || spaces >= 2 {
|
|
out.extend(std::iter::repeat_n(NO_BREAK_SPACE, spaces));
|
|
} else {
|
|
out.push(' ');
|
|
}
|
|
rest = &rest[spaces..];
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The element name when `tag` opens a raw-text element, else `None`.
|
|
fn raw_text_name(tag: &str) -> Option<&'static str> {
|
|
let rest = tag.strip_prefix('<')?;
|
|
if rest.starts_with('/') {
|
|
return None;
|
|
}
|
|
RAW_TEXT_ELEMENTS.iter().copied().find(|name| {
|
|
rest.len() >= name.len()
|
|
&& rest[..name.len()].eq_ignore_ascii_case(name)
|
|
// Only `<style>` and `<style type=…>`, never `<styled-thing>`.
|
|
&& rest[name.len()..]
|
|
.starts_with([' ', '\t', '\n', '\r', '>', '/'])
|
|
})
|
|
}
|
|
|
|
/// Byte offset of `</name` at or after `from`, else `None`.
|
|
fn find_close_tag(html: &str, from: usize, name: &str) -> Option<usize> {
|
|
let needle = format!("</{name}");
|
|
let hay = html.get(from..)?.to_ascii_lowercase();
|
|
hay.find(&needle).map(|i| from + i)
|
|
}
|
|
|
|
/// Copy `text` into `out`, soft-hyphenating any over-long word.
|
|
fn soften_text(text: &str, out: &mut String) {
|
|
// Byte length bounds character count, so a short run holds no long word.
|
|
if text.len() <= MAX_WORD_CHARS {
|
|
out.push_str(text);
|
|
return;
|
|
}
|
|
let mut word_start = 0usize;
|
|
for (i, c) in text.char_indices() {
|
|
if c.is_whitespace() {
|
|
push_soft_hyphenated(&text[word_start..i], out);
|
|
out.push(c);
|
|
word_start = i + c.len_utf8();
|
|
}
|
|
}
|
|
push_soft_hyphenated(&text[word_start..], out);
|
|
}
|
|
|
|
fn push_soft_hyphenated(word: &str, out: &mut String) {
|
|
if word.len() <= MAX_WORD_CHARS {
|
|
out.push_str(word);
|
|
return;
|
|
}
|
|
let mut units = 0usize;
|
|
let mut rest = word;
|
|
while !rest.is_empty() {
|
|
if units == MAX_WORD_CHARS {
|
|
out.push(SOFT_HYPHEN);
|
|
units = 0;
|
|
}
|
|
let take =
|
|
entity_len(rest).unwrap_or_else(|| rest.chars().next().map_or(1, char::len_utf8));
|
|
out.push_str(&rest[..take]);
|
|
rest = &rest[take..];
|
|
units += 1;
|
|
}
|
|
}
|
|
|
|
/// Byte length of the `&…;` reference starting `s`, if there is one.
|
|
///
|
|
/// A character reference is one unit: splitting `&` down the middle would
|
|
/// turn it into literal text and break the XHTML.
|
|
fn entity_len(s: &str) -> Option<usize> {
|
|
/// `≈` is 13 bytes; nothing we emit is longer.
|
|
const MAX_ENTITY_BYTES: usize = 16;
|
|
let bytes = s.as_bytes();
|
|
if bytes.first() != Some(&b'&') {
|
|
return None;
|
|
}
|
|
bytes
|
|
.iter()
|
|
.take(MAX_ENTITY_BYTES)
|
|
.position(|&b| b == b';')
|
|
.map(|p| p + 1)
|
|
}
|
|
|
|
/// Remove the named attributes from every tag, leaving the rest verbatim.
|
|
fn strip_attributes(html: &str, drop: &[&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;
|
|
};
|
|
out.push_str(&filter_tag(&html[start..end], drop));
|
|
cursor = end;
|
|
}
|
|
out.push_str(&html[cursor..]);
|
|
out
|
|
}
|
|
|
|
/// `<p style="x" class="y">` → `<p class="y">`.
|
|
fn filter_tag(tag: &str, drop: &[&str]) -> String {
|
|
if tag.starts_with("<!") || tag.starts_with("<?") || tag.starts_with("</") {
|
|
return tag.to_string();
|
|
}
|
|
let bytes = tag.as_bytes();
|
|
let mut out = String::with_capacity(tag.len());
|
|
let mut i = 1; // past '<'
|
|
while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b'>' {
|
|
i += 1;
|
|
}
|
|
out.push_str(&tag[..i]);
|
|
|
|
while i < bytes.len() {
|
|
let ws_start = i;
|
|
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
|
|
i += 1;
|
|
}
|
|
if i >= bytes.len() || bytes[i] == b'>' || bytes[i] == b'/' {
|
|
out.push_str(&tag[ws_start..]);
|
|
return out;
|
|
}
|
|
let name_start = i;
|
|
while i < bytes.len()
|
|
&& !bytes[i].is_ascii_whitespace()
|
|
&& bytes[i] != b'='
|
|
&& bytes[i] != b'>'
|
|
&& bytes[i] != b'/'
|
|
{
|
|
i += 1;
|
|
}
|
|
let name = tag[name_start..i].to_ascii_lowercase();
|
|
let mut after_name = i;
|
|
while after_name < bytes.len() && bytes[after_name].is_ascii_whitespace() {
|
|
after_name += 1;
|
|
}
|
|
if after_name < bytes.len() && bytes[after_name] == b'=' {
|
|
i = after_name + 1;
|
|
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
|
|
i += 1;
|
|
}
|
|
if i < bytes.len() && (bytes[i] == b'"' || bytes[i] == b'\'') {
|
|
let quote = bytes[i];
|
|
i += 1;
|
|
while i < bytes.len() && bytes[i] != quote {
|
|
i += 1;
|
|
}
|
|
i = (i + 1).min(bytes.len());
|
|
} else {
|
|
while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b'>' {
|
|
i += 1;
|
|
}
|
|
}
|
|
}
|
|
if !drop.contains(&name.as_str()) {
|
|
out.push_str(&tag[ws_start..i]);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Full argv for the converter: `command` + `args` + `<input> -o <output> -f <format>`
|
|
/// (+ `-c <settings>` when configured) (§3.11).
|
|
pub fn build_command(cfg: &XtcConfig, input: &Path, output: &Path) -> (String, Vec<String>) {
|
|
let mut args = cfg.args.clone();
|
|
args.push(input.display().to_string());
|
|
args.push("-o".to_string());
|
|
args.push(output.display().to_string());
|
|
args.push("-f".to_string());
|
|
args.push(cfg.format.as_str().to_string());
|
|
if let Some(settings) = &cfg.settings {
|
|
args.push("-c".to_string());
|
|
args.push(settings.display().to_string());
|
|
}
|
|
(cfg.command.clone(), args)
|
|
}
|
|
|
|
/// Output path for an input EPUB: `{out_dir}/{stem}.{xtc|xtch}` (§3.11).
|
|
pub fn output_path(cfg: &XtcConfig, input: &Path, out_dir: &Path) -> PathBuf {
|
|
let stem = input
|
|
.file_stem()
|
|
.map(|s| s.to_string_lossy().to_string())
|
|
.unwrap_or_else(|| "issue".to_string());
|
|
out_dir.join(format!("{stem}.{}", cfg.format.extension()))
|
|
}
|
|
|
|
/// Convert the X4 EPUB to `.xtc`/`.xtch` via `tokio::process::Command` (§3.11).
|
|
///
|
|
/// Callers treat every error as a warning and continue — XTC is a bonus
|
|
/// artifact, the X4 can always fall back to the X4 EPUB from BookOrbit.
|
|
pub async fn convert(cfg: &XtcConfig, input: &Path, out_dir: &Path) -> Result<PathBuf, XtcError> {
|
|
if cfg.settings.is_none() {
|
|
// The converter refuses to start without `font.path`, which can only be
|
|
// supplied through the settings JSON: `-c` is mandatory in practice even
|
|
// though the flag is optional.
|
|
tracing::warn!(
|
|
"xtc.settings is unset; epub-to-xtc-converter requires a settings \
|
|
file with a font.path and will refuse to run without one"
|
|
);
|
|
}
|
|
let output = output_path(cfg, input, out_dir);
|
|
if let Some(parent) = output.parent() {
|
|
std::fs::create_dir_all(parent).map_err(|e| XtcError::Spawn {
|
|
command: parent.display().to_string(),
|
|
source: e,
|
|
})?;
|
|
}
|
|
let (command, args) = build_command(cfg, input, &output);
|
|
tracing::info!(command, ?args, "running the xtc converter");
|
|
|
|
let result = tokio::process::Command::new(&command)
|
|
.args(&args)
|
|
.output()
|
|
.await
|
|
.map_err(|e| XtcError::Spawn {
|
|
command: command.clone(),
|
|
source: e,
|
|
})?;
|
|
if !result.status.success() {
|
|
return Err(XtcError::Failed {
|
|
status: result.status.code().unwrap_or(-1),
|
|
stderr: String::from_utf8_lossy(&result.stderr)
|
|
.lines()
|
|
.take(5)
|
|
.collect::<Vec<_>>()
|
|
.join(" | "),
|
|
});
|
|
}
|
|
if !output.exists() {
|
|
return Err(XtcError::NoOutput(output));
|
|
}
|
|
tracing::info!(path = %output.display(), "xtc conversion complete");
|
|
Ok(output)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::config::XtcFormat;
|
|
|
|
fn cfg() -> XtcConfig {
|
|
XtcConfig {
|
|
enabled: true,
|
|
command: "node".into(),
|
|
args: vec![
|
|
"/opt/epub-to-xtc-converter/cli/index.js".into(),
|
|
"convert".into(),
|
|
],
|
|
format: XtcFormat::Xtch,
|
|
settings: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn builds_the_documented_converter_argv() {
|
|
let (command, args) = build_command(
|
|
&cfg(),
|
|
Path::new("/out/The Daily EPUB - 2026-08-15 (X4).epub"),
|
|
Path::new("/xtc/The Daily EPUB - 2026-08-15 (X4).xtch"),
|
|
);
|
|
assert_eq!(command, "node");
|
|
assert_eq!(
|
|
args,
|
|
vec![
|
|
"/opt/epub-to-xtc-converter/cli/index.js",
|
|
"convert",
|
|
"/out/The Daily EPUB - 2026-08-15 (X4).epub",
|
|
"-o",
|
|
"/xtc/The Daily EPUB - 2026-08-15 (X4).xtch",
|
|
"-f",
|
|
"xtch",
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn settings_file_is_passed_with_dash_c() {
|
|
let mut cfg = cfg();
|
|
cfg.settings = Some(PathBuf::from("/etc/xtc.json"));
|
|
cfg.format = XtcFormat::Xtc;
|
|
let (_, args) = build_command(&cfg, Path::new("in.epub"), Path::new("out.xtc"));
|
|
assert_eq!(args[args.len() - 4..], ["-f", "xtc", "-c", "/etc/xtc.json"]);
|
|
}
|
|
|
|
#[test]
|
|
fn output_path_follows_the_format_extension() {
|
|
let out = output_path(&cfg(), Path::new("/out/Issue (X4).epub"), Path::new("/xtc"));
|
|
assert_eq!(out, PathBuf::from("/xtc/Issue (X4).xtch"));
|
|
}
|
|
|
|
/// The pipeline turns every one of these into a report warning, so the error
|
|
/// has to say which of them happened (§3.11).
|
|
#[tokio::test]
|
|
async fn converter_failures_are_distinguishable() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
let mut missing = cfg();
|
|
missing.command = "definitely-not-a-real-binary-9f3b".into();
|
|
missing.args.clear();
|
|
match convert(&missing, Path::new("in.epub"), dir.path()).await {
|
|
Err(XtcError::Spawn { command, .. }) => {
|
|
assert_eq!(command, "definitely-not-a-real-binary-9f3b")
|
|
}
|
|
other => panic!("expected a spawn failure, got {other:?}"),
|
|
}
|
|
|
|
let mut failing = cfg();
|
|
failing.command = "false".into();
|
|
failing.args.clear();
|
|
match convert(&failing, Path::new("in.epub"), dir.path()).await {
|
|
Err(XtcError::Failed { status, .. }) => assert_ne!(status, 0),
|
|
other => panic!("expected a nonzero exit, got {other:?}"),
|
|
}
|
|
|
|
// Exit 0 but nothing written is its own error, not a silent success.
|
|
let mut silent = cfg();
|
|
silent.command = "true".into();
|
|
silent.args.clear();
|
|
match convert(&silent, Path::new("in.epub"), dir.path()).await {
|
|
Err(XtcError::NoOutput(path)) => assert_eq!(path, dir.path().join("in.xtch")),
|
|
other => panic!("expected NoOutput, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn css_simplification_drops_layout_and_fonts() {
|
|
let css = r#"
|
|
@font-face { font-family: "Serif"; src: url(serif.woff2); }
|
|
.a { float: left; color: #000; }
|
|
.b { display: flex; flex-direction: row; }
|
|
.c { position: absolute; margin: 1em; }
|
|
.d { float: right; }
|
|
"#;
|
|
let out = simplify_css(css);
|
|
assert!(!out.contains("@font-face"));
|
|
assert!(!out.contains("float"));
|
|
assert!(!out.contains("flex"));
|
|
assert!(!out.contains("position"));
|
|
assert!(out.contains("color: #000"));
|
|
assert!(out.contains("margin: 1em"));
|
|
// A rule left with no declarations disappears entirely.
|
|
assert!(!out.contains(".d"));
|
|
}
|
|
|
|
#[test]
|
|
fn xhtml_simplification_drops_layout_attributes_only() {
|
|
let input = r#"<p class="meta" style="float:left" align="center">a & b</p><img src="x.jpg" alt="An x" width="900"/>"#;
|
|
let out = simplify_xhtml(input);
|
|
assert_eq!(
|
|
out,
|
|
r#"<p class="meta">a & b</p><img src="x.jpg" alt="An x"/>"#
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pre_whitespace_is_made_explicit_inside_nested_tags() {
|
|
let input = "<pre><code>fn main() {\n if x < 2 {\n <em>return</em>;\n }\n <strong><a href=\"#\">// done</a></strong>\n}</code></pre>";
|
|
let out = break_long_words(input);
|
|
assert_eq!(
|
|
out,
|
|
concat!(
|
|
"<pre><code>fn main() {<br/>",
|
|
"\u{00a0}\u{00a0}\u{00a0}\u{00a0}if x < 2 {<br/>",
|
|
"\u{00a0}\u{00a0}\u{00a0}\u{00a0}\u{00a0}\u{00a0}\u{00a0}\u{00a0}<em>return</em>;<br/>",
|
|
"\u{00a0}\u{00a0}\u{00a0}\u{00a0}}<br/>",
|
|
"\u{00a0}<strong><a href=\"#\">// done</a></strong><br/>}</code></pre>"
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn each_code_line_becomes_a_paragraph() {
|
|
let input = "<p>Before.</p><figure><pre><code>let a = 1;\n\n let b = 2;</code></pre></figure><p>After.</p>";
|
|
assert_eq!(
|
|
simplify_xhtml(input),
|
|
concat!(
|
|
"<p>Before.</p><figure><pre>",
|
|
"<p class=\"code-line code-first code-gap\"><code>let a = 1;</code></p>",
|
|
"<p class=\"code-line code-last\"><code>\u{00a0}\u{00a0}let b = 2;</code></p>",
|
|
"</pre></figure><p>After.</p>"
|
|
)
|
|
);
|
|
// `<pre` as a prefix of another name is not a code block.
|
|
assert_eq!(simplify_xhtml("<prefix>x</prefix>"), "<prefix>x</prefix>");
|
|
}
|
|
|
|
#[test]
|
|
fn spans_open_across_a_line_break_are_closed_and_reopened() {
|
|
let input = "<pre><code><span class=\"hl-comment\">/* one\ntwo */</span> x\n</code></pre>";
|
|
assert_eq!(
|
|
simplify_xhtml(input),
|
|
concat!(
|
|
"<pre>",
|
|
"<p class=\"code-line code-first\"><code><span class=\"hl-comment\">/* one</span></code></p>",
|
|
"<p class=\"code-line code-last\"><code><span class=\"hl-comment\">two */</span> x</code></p>",
|
|
"</pre>"
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tabs_in_pre_become_four_no_break_spaces() {
|
|
assert_eq!(
|
|
break_long_words("<pre><code>one\ttwo</code></pre>"),
|
|
"<pre><code>one\u{00a0}\u{00a0}\u{00a0}\u{00a0}two</code></pre>"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn whitespace_outside_pre_is_untouched() {
|
|
let input = "<p>outside text\n next\tword</p><pre>x y</pre><p>tail text</p>";
|
|
assert_eq!(
|
|
break_long_words(input),
|
|
"<p>outside text\n next\tword</p><pre>x\u{00a0}\u{00a0}y</pre><p>tail text</p>"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn newline_right_after_pre_open_is_not_a_blank_line() {
|
|
assert_eq!(
|
|
break_long_words("<pre><code>\nlet a = 1;\n\nlet b = 2;</code></pre>"),
|
|
"<pre><code>let a = 1;<br/><br/>let b = 2;</code></pre>"
|
|
);
|
|
assert_eq!(
|
|
break_long_words("<pre>\n x</pre>"),
|
|
"<pre>\u{00a0}\u{00a0}x</pre>"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pre_without_a_trailing_newline_gets_no_extra_break() {
|
|
let input = "<pre><code>let answer = 42;</code></pre>";
|
|
assert_eq!(break_long_words(input), input);
|
|
}
|
|
|
|
/// The shipped X4 stylesheet must already satisfy the X4 rules, so the
|
|
/// simplifier is a no-op over it (§3.10).
|
|
#[test]
|
|
fn the_shipped_x4_stylesheet_is_already_simplified() {
|
|
let css = super::super::build::stylesheet(crate::types::Edition::X4);
|
|
let simplified = simplify_css(css);
|
|
assert_eq!(
|
|
css.matches(';').count(),
|
|
simplified.matches(';').count(),
|
|
"the simplifier dropped a declaration from style-x4.css"
|
|
);
|
|
// Declarations only — the file's header comment mentions what it avoids.
|
|
for banned in [
|
|
"float:",
|
|
"clear:",
|
|
"display: flex",
|
|
"display: grid",
|
|
"position:",
|
|
"@font-face",
|
|
] {
|
|
assert!(!css.contains(banned), "style-x4.css must not use {banned}");
|
|
}
|
|
}
|
|
|
|
/// The firmware stops wrapping past 200 characters and the line runs off
|
|
/// the screen, so long tokens get soft hyphens (§3.10).
|
|
#[test]
|
|
fn over_long_words_are_soft_hyphenated() {
|
|
let long = "a".repeat(450);
|
|
let out = simplify_xhtml(&format!("<p>short {long} tail</p>"));
|
|
assert_eq!(out.matches(SOFT_HYPHEN).count(), 2);
|
|
// Only the long token is touched; the rest of the line is byte-identical.
|
|
assert!(out.starts_with("<p>short "));
|
|
assert!(out.ends_with(" tail</p>"));
|
|
assert!(!out.contains(&format!("short{SOFT_HYPHEN}")));
|
|
// Removing the hyphens gets the original word back — nothing was lost.
|
|
assert!(out.replace(SOFT_HYPHEN, "").contains(&long));
|
|
// Every run between hyphens is within the limit.
|
|
for run in out.replace(['<', '>'], " ").split_whitespace() {
|
|
for piece in run.split(SOFT_HYPHEN) {
|
|
assert!(piece.chars().count() <= MAX_WORD_CHARS, "{}", piece.len());
|
|
}
|
|
}
|
|
// Words at the limit are left alone.
|
|
let exact = "b".repeat(MAX_WORD_CHARS);
|
|
assert_eq!(
|
|
simplify_xhtml(&format!("<p>{exact} {exact}</p>")),
|
|
format!("<p>{exact} {exact}</p>")
|
|
);
|
|
}
|
|
|
|
/// A soft hyphen dropped into `&` would turn it into literal text and
|
|
/// break the XHTML, so character references are indivisible (§3.10).
|
|
#[test]
|
|
fn entities_and_markup_survive_word_breaking() {
|
|
// 120 entities: the raw string is far past the limit, but it is only
|
|
// 120 units, so no break is due — and the entities stay intact.
|
|
let entities = "&".repeat(120);
|
|
let out = simplify_xhtml(&format!("<p>{entities}</p>"));
|
|
assert!(!out.contains(SOFT_HYPHEN));
|
|
assert_eq!(out.matches("&").count(), 120);
|
|
|
|
// Past the limit the breaks land between entities, never inside one.
|
|
let out = simplify_xhtml(&format!("<p>{}</p>", "&".repeat(260)));
|
|
assert_eq!(out.matches("&").count(), 260);
|
|
assert_eq!(out.matches(SOFT_HYPHEN).count(), 1);
|
|
assert!(!out.contains(&format!("&{SOFT_HYPHEN}")));
|
|
assert!(!out.contains(&format!("&{SOFT_HYPHEN}")));
|
|
|
|
// Attribute values are not text content and must not be rewritten.
|
|
let href = "https://example.com/".to_string() + &"z".repeat(300);
|
|
let out = simplify_xhtml(&format!("<p><a href=\"{href}\">link</a></p>"));
|
|
assert!(out.contains(&format!("href=\"{href}\"")), "{out}");
|
|
assert!(!out.contains(SOFT_HYPHEN));
|
|
}
|
|
|
|
/// Stylesheets and scripts are code: a soft hyphen inside one corrupts it.
|
|
#[test]
|
|
fn raw_text_elements_are_copied_through_verbatim() {
|
|
let css = format!("p{{content:\"{}\"}}", "x".repeat(400));
|
|
let out = simplify_xhtml(&format!("<style type=\"text/css\">{css}</style>"));
|
|
assert!(out.contains(&css), "{out}");
|
|
assert!(!out.contains(SOFT_HYPHEN));
|
|
|
|
// A tag that merely starts with the same letters is ordinary prose.
|
|
let long = "y".repeat(400);
|
|
let out = simplify_xhtml(&format!("<styled-note>{long}</styled-note>"));
|
|
assert_eq!(out.matches(SOFT_HYPHEN).count(), 1);
|
|
}
|
|
|
|
/// `/* … */` runs, which are prose and may contain anything.
|
|
fn strip_css_comments(css: &str) -> String {
|
|
let mut out = String::with_capacity(css.len());
|
|
let mut rest = css;
|
|
while let Some(open) = rest.find("/*") {
|
|
out.push_str(&rest[..open]);
|
|
match rest[open + 2..].find("*/") {
|
|
Some(close) => rest = &rest[open + 4 + close..],
|
|
None => return out,
|
|
}
|
|
}
|
|
out.push_str(rest);
|
|
out
|
|
}
|
|
|
|
/// The X4's CSS engine understands `tag`, `.class` and `tag.class` only —
|
|
/// a descendant combinator silently drops the whole rule (§3.10).
|
|
#[test]
|
|
fn the_x4_stylesheet_uses_no_descendant_selectors() {
|
|
let css = strip_css_comments(super::super::build::stylesheet(crate::types::Edition::X4));
|
|
for (i, _) in css.match_indices('{') {
|
|
let selector_list = css[..i].rsplit('}').next().unwrap_or_default().trim();
|
|
for selector in selector_list.split(',') {
|
|
let selector = selector.trim();
|
|
if selector.is_empty() || selector.starts_with('@') {
|
|
continue;
|
|
}
|
|
assert!(
|
|
!selector.contains(char::is_whitespace),
|
|
"descendant selector {selector:?} will not match on the X4"
|
|
);
|
|
for combinator in ['>', '+', '~'] {
|
|
assert!(
|
|
!selector.contains(combinator),
|
|
"combinator {combinator:?} in {selector:?} is unsupported on the X4"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn simplification_leaves_prologue_and_text_untouched() {
|
|
let input =
|
|
"<?xml version=\"1.0\"?>\n<!DOCTYPE html>\n<html><body><p>2 < 3</p></body></html>";
|
|
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}");
|
|
}
|
|
}
|