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:
@@ -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;
|
||||
|
||||
+188
-7
@@ -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 `<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
|
||||
@@ -609,7 +760,7 @@ mod tests {
|
||||
#[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 = 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 = "<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!(
|
||||
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>"
|
||||
);
|
||||
}
|
||||
@@ -634,7 +815,7 @@ mod tests {
|
||||
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!(
|
||||
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>"
|
||||
);
|
||||
}
|
||||
@@ -642,11 +823,11 @@ mod tests {
|
||||
#[test]
|
||||
fn newline_right_after_pre_open_is_not_a_blank_line() {
|
||||
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>"
|
||||
);
|
||||
assert_eq!(
|
||||
simplify_xhtml("<pre>\n x</pre>"),
|
||||
break_long_words("<pre>\n x</pre>"),
|
||||
"<pre>\u{00a0}\u{00a0}x</pre>"
|
||||
);
|
||||
}
|
||||
@@ -654,7 +835,7 @@ mod tests {
|
||||
#[test]
|
||||
fn pre_without_a_trailing_newline_gets_no_extra_break() {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user