Lay out X4 code blocks as one paragraph per line

The X4 firmwares (CrossPoint and forks such as CrossInk) ignore
white-space and treat <pre> 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 <p class="code-line">, 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 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 19:53:06 +00:00
co-authored by Claude Opus 5.5
parent 50551114cd
commit 9e521ddaef
2 changed files with 264 additions and 17 deletions
+76 -10
View File
@@ -160,7 +160,16 @@ figcaption,
text-align: center; 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-family: monospace;
font-size: 0.85em; font-size: 0.85em;
white-space: pre-wrap; white-space: pre-wrap;
@@ -169,24 +178,81 @@ pre, code {
-webkit-hyphens: none; -webkit-hyphens: none;
} }
/* Syntax tokens (src/highlight.rs), monochrome for e-ink: weight and slant p.code-line {
carry the structure, one grey for comments. Flat selectors on purpose. */ margin: 0;
.hl-keyword, text-indent: 0;
.hl-tag, 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-title,
.hl-strong { .hl-strong {
font-weight: bold; font-weight: bold;
} }
.hl-comment { .hl-type,
font-style: italic; .hl-namespace,
color: #444444; .hl-constructor,
} .hl-attribute,
.hl-emphasis { .hl-emphasis {
font-style: italic; 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 { table {
border-collapse: collapse; border-collapse: collapse;
font-size: 0.85em; font-size: 0.85em;
+188 -7
View File
@@ -124,10 +124,161 @@ fn is_dropped_declaration(decl: &str) -> bool {
false 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, /// Strip layout constructs the X4 renderer handles poorly from chapter markup,
/// 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 {
break_long_words(&strip_attributes(xhtml, DROPPED_ATTRIBUTES)) paragraph_code_lines(&break_long_words(&strip_attributes(
xhtml,
DROPPED_ATTRIBUTES,
)))
}
/// 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 /// Insert soft hyphens into over-long words and make `<pre>` whitespace
@@ -609,7 +760,7 @@ mod tests {
#[test] #[test]
fn pre_whitespace_is_made_explicit_inside_nested_tags() { fn pre_whitespace_is_made_explicit_inside_nested_tags() {
let input = "<pre><code>fn main() {\n if x &lt; 2 {\n <em>return</em>;\n }\n <strong><a href=\"#\">// done</a></strong>\n}</code></pre>"; let input = "<pre><code>fn main() {\n if x &lt; 2 {\n <em>return</em>;\n }\n <strong><a href=\"#\">// done</a></strong>\n}</code></pre>";
let out = simplify_xhtml(input); let out = break_long_words(input);
assert_eq!( assert_eq!(
out, out,
concat!( concat!(
@@ -622,10 +773,40 @@ mod tests {
); );
} }
#[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] #[test]
fn tabs_in_pre_become_four_no_break_spaces() { fn tabs_in_pre_become_four_no_break_spaces() {
assert_eq!( assert_eq!(
simplify_xhtml("<pre><code>one\ttwo</code></pre>"), break_long_words("<pre><code>one\ttwo</code></pre>"),
"<pre><code>one\u{00a0}\u{00a0}\u{00a0}\u{00a0}two</code></pre>" "<pre><code>one\u{00a0}\u{00a0}\u{00a0}\u{00a0}two</code></pre>"
); );
} }
@@ -634,7 +815,7 @@ mod tests {
fn whitespace_outside_pre_is_untouched() { fn whitespace_outside_pre_is_untouched() {
let input = "<p>outside text\n next\tword</p><pre>x y</pre><p>tail text</p>"; let input = "<p>outside text\n next\tword</p><pre>x y</pre><p>tail text</p>";
assert_eq!( assert_eq!(
simplify_xhtml(input), break_long_words(input),
"<p>outside text\n next\tword</p><pre>x\u{00a0}\u{00a0}y</pre><p>tail text</p>" "<p>outside text\n next\tword</p><pre>x\u{00a0}\u{00a0}y</pre><p>tail text</p>"
); );
} }
@@ -642,11 +823,11 @@ mod tests {
#[test] #[test]
fn newline_right_after_pre_open_is_not_a_blank_line() { fn newline_right_after_pre_open_is_not_a_blank_line() {
assert_eq!( assert_eq!(
simplify_xhtml("<pre><code>\nlet a = 1;\n\nlet b = 2;</code></pre>"), 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>" "<pre><code>let a = 1;<br/><br/>let b = 2;</code></pre>"
); );
assert_eq!( assert_eq!(
simplify_xhtml("<pre>\n x</pre>"), break_long_words("<pre>\n x</pre>"),
"<pre>\u{00a0}\u{00a0}x</pre>" "<pre>\u{00a0}\u{00a0}x</pre>"
); );
} }
@@ -654,7 +835,7 @@ mod tests {
#[test] #[test]
fn pre_without_a_trailing_newline_gets_no_extra_break() { fn pre_without_a_trailing_newline_gets_no_extra_break() {
let input = "<pre><code>let answer = 42;</code></pre>"; let input = "<pre><code>let answer = 42;</code></pre>";
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 /// The shipped X4 stylesheet must already satisfy the X4 rules, so the