//! 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}'; /// 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 } /// 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 { break_long_words(&strip_attributes(xhtml, DROPPED_ATTRIBUTES)) } /// Insert soft hyphens into words longer than [`MAX_WORD_CHARS`], 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; while let Some(rel) = html[cursor..].find('<') { let start = cursor + rel; 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; // `")); 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); } }