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!("{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!("
{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 = "
"
);
}
@@ -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