//! 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 /cli/index.js convert -o -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 `
` 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 /// `white-space` and lay out only `p`, `div`, `li`, `blockquote`, lists and /// `br` as blocks: a `

` is inline, so its margins never apply and its
/// `
` lines are handled by each firmware's own rules. CrossInk glues the /// first line onto the paragraph before, and empty `
` 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 ``, 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 `
` body whose line breaks are `
`. 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!("")); } 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!("

{line}

")); } 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 `
` 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 `
` (or `
`) 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;
        }
        // `"));
        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!("{long}"));
        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 =
            "\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}"); } }