From 9e521ddaef33bf46754f62c831d839c39582a6e5 Mon Sep 17 00:00:00 2001 From: Tyler Hallada Date: Thu, 1 Oct 2026 19:53:06 +0000 Subject: [PATCH] Lay out X4 code blocks as one paragraph per line The X4 firmwares (CrossPoint and forks such as CrossInk) ignore white-space and treat
 as inline. Its margins never applied, so the
paragraph after a listing started on the next line. CrossInk also glued
the first code line onto the paragraph before it. The simplifier now makes
each listing line a 

, the one path both firmwares lay out identically. The first and last lines carry the block's margins. Inline tags open across a line break are closed and reopened on the next line. A blank source line used to be a paragraph of its own. With the firmware's default paragraph spacing that read as a double newline, so blank lines now become a half-line gap (p.code-gap) on the line before. The X4 theme matches the standard one but uses only the device's exact levels (#000, #555, #aaa), so glyphs are not dithered. Inline code is display: inline for the XTC converter's CREngine. Co-Authored-By: Claude Opus 5.5 --- src/epub/templates/style-x4.css | 86 ++++++++++++-- src/epub/x4.rs | 195 ++++++++++++++++++++++++++++++-- 2 files changed, 264 insertions(+), 17 deletions(-) diff --git a/src/epub/templates/style-x4.css b/src/epub/templates/style-x4.css index 435681b..d89ad22 100644 --- a/src/epub/templates/style-x4.css +++ b/src/epub/templates/style-x4.css @@ -160,7 +160,16 @@ figcaption, text-align: center; } -pre, code { +/* The firmwares (CrossPoint, CrossInk) ignore `white-space` and treat `pre` + as inline, so the X4 simplifier makes each listing line a `p.code-line`; + the first and last lines carry the block's spacing, and a blank source + line becomes a `p.code-gap` margin. `figure` centres for + image captions, so lines reset alignment. The XTC converter's CREngine + defaults `code` to a block. */ +pre { + display: block; + margin: 0; + text-align: left; font-family: monospace; font-size: 0.85em; white-space: pre-wrap; @@ -169,24 +178,81 @@ pre, code { -webkit-hyphens: none; } -/* Syntax tokens (src/highlight.rs), monochrome for e-ink: weight and slant - carry the structure, one grey for comments. Flat selectors on purpose. */ -.hl-keyword, -.hl-tag, +p.code-line { + margin: 0; + text-indent: 0; + text-align: left; + hyphens: none; + -webkit-hyphens: none; +} + +p.code-first { + margin-top: 0.6em; +} + +p.code-last { + margin-bottom: 0.6em; +} + +/* Where the source had a blank line: half a line, not a whole one. */ +p.code-gap { + margin-bottom: 0.5em; +} + +code { + display: inline; + font-family: monospace; + font-size: 0.85em; + hyphens: none; + -webkit-hyphens: none; +} + +/* Syntax tokens (src/highlight.rs), as in style.css but on the X4's four + levels only (#000, #555, #aaa, white), so glyphs are not dithered: ink for + names, dark grey for keywords, literals and punctuation, light grey for + comments. */ +.hl-function, +.hl-macro, .hl-title, .hl-strong { font-weight: bold; } -.hl-comment { - font-style: italic; - color: #444444; -} - +.hl-type, +.hl-namespace, +.hl-constructor, +.hl-attribute, .hl-emphasis { font-style: italic; } +.hl-keyword, +.hl-tag { + font-weight: bold; + color: #555555; +} + +.hl-string { + color: #555555; +} + +.hl-number, +.hl-constant, +.hl-label { + font-style: italic; + color: #555555; +} + +.hl-punctuation, +.hl-operator { + color: #555555; +} + +.hl-comment { + font-style: italic; + color: #aaaaaa; +} + table { border-collapse: collapse; font-size: 0.85em; diff --git a/src/epub/x4.rs b/src/epub/x4.rs index 7ccfca8..e58a3c7 100644 --- a/src/epub/x4.rs +++ b/src/epub/x4.rs @@ -124,10 +124,161 @@ fn is_dropped_declaration(decl: &str) -> bool { 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 { - break_long_words(&strip_attributes(xhtml, DROPPED_ATTRIBUTES)) + paragraph_code_lines(&break_long_words(&strip_attributes( + xhtml, + DROPPED_ATTRIBUTES, + ))) +} + +/// 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
@@ -609,7 +760,7 @@ mod tests {
     #[test]
     fn pre_whitespace_is_made_explicit_inside_nested_tags() {
         let input = "
fn main() {\n    if x < 2 {\n        return;\n    }\n // done\n}
"; - let out = simplify_xhtml(input); + let out = break_long_words(input); assert_eq!( out, concat!( @@ -622,10 +773,40 @@ mod tests { ); } + #[test] + fn each_code_line_becomes_a_paragraph() { + let input = "

Before.

let a = 1;\n\n  let b = 2;

After.

"; + assert_eq!( + simplify_xhtml(input), + concat!( + "

Before.

",
+                "

let a = 1;

", + "

\u{00a0}\u{00a0}let b = 2;

", + "

After.

" + ) + ); + // `x"), "x"); + } + + #[test] + fn spans_open_across_a_line_break_are_closed_and_reopened() { + let input = "
/* one\ntwo */ x\n
"; + assert_eq!( + simplify_xhtml(input), + concat!( + "
",
+                "

/* one

", + "

two */ x

", + "
" + ) + ); + } + #[test] fn tabs_in_pre_become_four_no_break_spaces() { assert_eq!( - simplify_xhtml("
one\ttwo
"), + break_long_words("
one\ttwo
"), "
one\u{00a0}\u{00a0}\u{00a0}\u{00a0}two
" ); } @@ -634,7 +815,7 @@ mod tests { fn whitespace_outside_pre_is_untouched() { let input = "

outside text\n next\tword

x  y

tail text

"; assert_eq!( - simplify_xhtml(input), + break_long_words(input), "

outside text\n next\tword

x\u{00a0}\u{00a0}y

tail text

" ); } @@ -642,11 +823,11 @@ mod tests { #[test] fn newline_right_after_pre_open_is_not_a_blank_line() { assert_eq!( - simplify_xhtml("
\nlet a = 1;\n\nlet b = 2;
"), + break_long_words("
\nlet a = 1;\n\nlet b = 2;
"), "
let a = 1;

let b = 2;
" ); assert_eq!( - simplify_xhtml("
\n  x
"), + break_long_words("
\n  x
"), "
\u{00a0}\u{00a0}x
" ); } @@ -654,7 +835,7 @@ mod tests { #[test] fn pre_without_a_trailing_newline_gets_no_extra_break() { let input = "
let answer = 42;
"; - assert_eq!(simplify_xhtml(input), input); + assert_eq!(break_long_words(input), input); } /// The shipped X4 stylesheet must already satisfy the X4 rules, so the