Initial commit: The Daily EPUB full implementation

Full implementation of a personalized daily newspaper delivered as an
EPUB.

Articles are pulled from a local self-hosted Miniflux instance, enriched
with comments, summarized and filtered by DeepSeek AI, and then
assembled into two EPUB editions: standard and optimized for the Xteink
X4 e-ink reader. Both are served by the local self-hosted BookOrbit OPDS
server in a separate library. Then the X4 edition is futher converted to
XTC format and served over a separate OPDS server hosted by the Rust
binary. Runs are tracked in a local SQLite database so runs are
idempotent per date.

Full documentation of the plan is in docs/plans and setup and install
instructions are in the README.md file.
This commit is contained in:
2026-08-15 17:46:19 +00:00
commit 9e30c1dcdf
80 changed files with 27578 additions and 0 deletions
+1440
View File
File diff suppressed because it is too large Load Diff
+715
View File
@@ -0,0 +1,715 @@
//! Image download and re-encoding (spec §3.10 "Images").
//!
//! Failed downloads degrade to a `[image: alt text]` placeholder paragraph — the
//! run never fails because of an image (notes §3).
use std::collections::HashMap;
use std::io::Cursor;
use std::time::Duration;
use futures::StreamExt;
use image::{DynamicImage, GenericImageView, ImageFormat};
use crate::types::{Edition, ImageAsset, Pick};
/// Per-image download timeout (§3.10).
pub const DOWNLOAD_TIMEOUT_SECS: u64 = 10;
/// Per-image size cap (§3.10).
pub const MAX_IMAGE_BYTES: usize = 5 * 1024 * 1024;
/// Concurrent downloads (§3.10).
pub const CONCURRENCY: usize = 8;
/// Whole-issue asset budget (§3.10).
pub const ISSUE_ASSET_BUDGET_BYTES: usize = 25 * 1024 * 1024;
/// Images smaller than this in either dimension are decorative — skipped (§3.10).
pub const MIN_DIMENSION_PX: u32 = 24;
/// Images referenced per article are already capped at 12 by extraction (§3.3).
pub const MAX_IMAGES_PER_ARTICLE: usize = 12;
/// HTML void elements: XHTML requires them self-closed (§3.10 "valid XHTML").
pub const VOID_ELEMENTS: &[&str] = &[
"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source",
"track", "wbr",
];
/// Per-edition re-encoding parameters (§3.10).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ImageProfile {
pub max_width: u32,
pub max_height: u32,
pub jpeg_quality: u8,
pub grayscale: bool,
}
impl ImageProfile {
/// Standard edition: max width 1200px, JPEG q80, color (§3.10).
pub const STANDARD: ImageProfile = ImageProfile {
max_width: 1200,
max_height: 4000,
jpeg_quality: 80,
grayscale: false,
};
/// X4 edition: grayscale Luma8, fit within 480×800, JPEG q70 (§3.10).
pub const X4: ImageProfile = ImageProfile {
max_width: 480,
max_height: 800,
jpeg_quality: 70,
grayscale: true,
};
pub fn for_edition(edition: Edition) -> Self {
match edition {
Edition::Standard => Self::STANDARD,
Edition::X4 => Self::X4,
}
}
}
/// One `<img>` found in article markup, with the caption of its `<figure>` if any.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImgRef {
pub src: String,
pub alt: String,
pub caption: Option<String>,
}
/// Collect `<img>` references (src, alt, enclosing figcaption) from article markup.
pub fn extract_img_refs(html: &str) -> Vec<ImgRef> {
let doc = scraper::Html::parse_fragment(html);
let Ok(img_sel) = scraper::Selector::parse("img") else {
return Vec::new();
};
let cap_sel = scraper::Selector::parse("figcaption").ok();
let mut out = Vec::new();
let mut seen: Vec<String> = Vec::new();
for el in doc.select(&img_sel) {
let Some(src) = el.value().attr("src") else {
continue;
};
let src = src.trim();
if src.is_empty() || src.starts_with("data:") {
continue;
}
if seen.iter().any(|s| s == src) {
continue;
}
seen.push(src.to_string());
let alt = el
.value()
.attr("alt")
.unwrap_or_default()
.trim()
.to_string();
// Walk up to an enclosing <figure> and take its caption, if any.
let mut caption = None;
if let Some(cap_sel) = &cap_sel {
let mut cursor = el.parent();
while let Some(node) = cursor {
if let Some(elem) = scraper::ElementRef::wrap(node) {
if elem.value().name() == "figure" {
caption = elem.select(cap_sel).next().map(|c| {
c.text()
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
});
break;
}
cursor = elem.parent();
} else {
break;
}
}
}
out.push(ImgRef {
src: src.to_string(),
alt,
caption: caption.filter(|c| !c.is_empty()),
});
}
out
}
/// Download one image, honoring the timeout and size cap (§3.10).
pub async fn download(http: &reqwest::Client, url: &str) -> Option<Vec<u8>> {
let resp = http
.get(url)
.timeout(Duration::from_secs(DOWNLOAD_TIMEOUT_SECS))
.send()
.await
.map_err(|e| tracing::debug!(url, "image download failed: {e}"))
.ok()?;
if !resp.status().is_success() {
tracing::debug!(url, status = %resp.status(), "image download rejected");
return None;
}
if let Some(len) = resp.content_length()
&& len as usize > MAX_IMAGE_BYTES
{
tracing::debug!(url, len, "image exceeds the size cap");
return None;
}
let mut resp = resp;
let mut buf: Vec<u8> = Vec::new();
loop {
match resp.chunk().await {
Ok(Some(chunk)) => {
if buf.len() + chunk.len() > MAX_IMAGE_BYTES {
tracing::debug!(url, "image exceeds the size cap mid-stream");
return None;
}
buf.extend_from_slice(&chunk);
}
Ok(None) => break,
Err(e) => {
tracing::debug!(url, "image download interrupted: {e}");
return None;
}
}
}
if buf.is_empty() { None } else { Some(buf) }
}
/// Decode, resize/grayscale, flatten transparency to white and re-encode (§3.10).
///
/// Line art with transparency is kept as PNG after flattening; everything else
/// becomes JPEG. Returns `None` for undecodable sources (SVG/WebP without support).
pub fn reencode(bytes: &[u8], profile: ImageProfile) -> Option<(Vec<u8>, &'static str)> {
let format = image::guess_format(bytes).ok();
let decoded = image::load_from_memory(bytes)
.map_err(|e| tracing::debug!("undecodable image: {e}"))
.ok()?;
let (w, h) = decoded.dimensions();
if w < MIN_DIMENSION_PX || h < MIN_DIMENSION_PX {
tracing::debug!(w, h, "skipping decorative image");
return None;
}
let has_alpha = decoded.color().has_alpha();
let flattened = if has_alpha {
flatten_to_white(&decoded)
} else {
decoded
};
let resized = if w > profile.max_width || h > profile.max_height {
flattened.resize(
profile.max_width,
profile.max_height,
image::imageops::FilterType::Lanczos3,
)
} else {
flattened
};
// Keep line art (PNG source, few distinct tones) lossless; everything else
// becomes JPEG, which is far smaller for photographs (§3.10).
let keep_png = format == Some(ImageFormat::Png) && is_line_art(&resized);
let mut out = Cursor::new(Vec::new());
// NB: encode the concrete buffer, not the `DynamicImage` — the latter always
// reports RGBA pixels, which would silently re-colorize a grayscale image.
if profile.grayscale {
let gray = resized.to_luma8();
if keep_png {
DynamicImage::ImageLuma8(gray)
.write_to(&mut out, ImageFormat::Png)
.ok()?;
return Some((out.into_inner(), "image/png"));
}
let mut enc =
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, profile.jpeg_quality);
enc.encode_image(&gray).ok()?;
return Some((out.into_inner(), "image/jpeg"));
}
let rgb = resized.to_rgb8();
if keep_png {
DynamicImage::ImageRgb8(rgb)
.write_to(&mut out, ImageFormat::Png)
.ok()?;
return Some((out.into_inner(), "image/png"));
}
let mut enc =
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, profile.jpeg_quality);
enc.encode_image(&rgb).ok()?;
Some((out.into_inner(), "image/jpeg"))
}
/// Composite over an opaque white page — e-ink has no transparency (§3.10).
fn flatten_to_white(img: &DynamicImage) -> DynamicImage {
let rgba = img.to_rgba8();
let mut rgb = image::RgbImage::new(rgba.width(), rgba.height());
for (x, y, px) in rgba.enumerate_pixels() {
let a = f32::from(px[3]) / 255.0;
let blend = |c: u8| {
((f32::from(c) * a) + 255.0 * (1.0 - a))
.round()
.clamp(0.0, 255.0) as u8
};
rgb.put_pixel(x, y, image::Rgb([blend(px[0]), blend(px[1]), blend(px[2])]));
}
DynamicImage::ImageRgb8(rgb)
}
/// Cheap line-art test: few distinct colors (diagrams, logos, screenshots of text).
fn is_line_art(img: &DynamicImage) -> bool {
const SAMPLE_LIMIT: usize = 20_000;
const DISTINCT_LIMIT: usize = 64;
let rgb = img.to_rgb8();
let mut distinct: Vec<[u8; 3]> = Vec::with_capacity(DISTINCT_LIMIT + 1);
for (i, px) in rgb.pixels().enumerate() {
if i >= SAMPLE_LIMIT {
break;
}
let c = [px[0], px[1], px[2]];
if !distinct.contains(&c) {
distinct.push(c);
if distinct.len() > DISTINCT_LIMIT {
return false;
}
}
}
true
}
/// Everything needed to fetch one image, in deterministic issue order.
#[derive(Debug, Clone)]
struct PendingImage {
id: String,
url: String,
alt: String,
caption: Option<String>,
}
fn pending_for_pick(pick: &Pick) -> Vec<PendingImage> {
let entry_id = pick.article.best_entry_id;
let mut refs = extract_img_refs(&pick.article.content_html);
if refs.is_empty() {
refs = pick
.article
.image_urls
.iter()
.map(|u| ImgRef {
src: u.clone(),
alt: String::new(),
caption: None,
})
.collect();
}
refs.into_iter()
.filter(|r| r.src.starts_with("http://") || r.src.starts_with("https://"))
.take(MAX_IMAGES_PER_ARTICLE)
.enumerate()
.map(|(i, r)| PendingImage {
id: format!("img-{entry_id}-{i}"),
url: r.src,
alt: r.alt,
caption: r.caption,
})
.collect()
}
/// Download and re-encode every image referenced by the lineup for one edition,
/// respecting [`ISSUE_ASSET_BUDGET_BYTES`] (§3.10).
pub async fn collect_for_issue(
http: &reqwest::Client,
picks: &[Pick],
edition: Edition,
) -> Vec<ImageAsset> {
let profile = ImageProfile::for_edition(edition);
let pending: Vec<PendingImage> = picks.iter().flat_map(pending_for_pick).collect();
if pending.is_empty() {
return Vec::new();
}
tracing::info!(count = pending.len(), ?edition, "downloading issue images");
let results: Vec<Option<(PendingImage, Vec<u8>, &'static str)>> =
futures::stream::iter(pending.into_iter().map(|p| {
let http = http.clone();
async move {
let raw = download(&http, &p.url).await?;
let (bytes, mime) = tokio::task::spawn_blocking(move || reencode(&raw, profile))
.await
.ok()
.flatten()?;
Some((p, bytes, mime))
}
}))
.buffered(CONCURRENCY)
.collect()
.await;
let mut assets = Vec::new();
let mut budget_used = 0usize;
let mut skipped = 0usize;
for result in results.into_iter().flatten() {
let (pending, bytes, mime) = result;
if budget_used + bytes.len() > ISSUE_ASSET_BUDGET_BYTES {
skipped += 1;
continue;
}
budget_used += bytes.len();
let ext = if mime == "image/png" { "png" } else { "jpg" };
assets.push(ImageAsset {
href: format!("images/{}.{ext}", pending.id),
id: pending.id,
mime: mime.to_string(),
data: bytes,
alt: pending.alt,
caption: pending.caption,
source_url: pending.url,
});
}
if skipped > 0 {
tracing::warn!(skipped, budget_used, "issue image budget exhausted");
}
tracing::info!(
embedded = assets.len(),
bytes = budget_used,
"issue images ready"
);
assets
}
// ---------------------------------------------------------------------------
// Markup rewriting
// ---------------------------------------------------------------------------
/// End index (exclusive) of the tag starting at `start` (`html[start] == '<'`),
/// respecting quoted attribute values and comments.
pub(crate) fn tag_end(html: &str, start: usize) -> Option<usize> {
let rest = &html[start..];
if rest.starts_with("<!--") {
return rest.find("-->").map(|i| start + i + 3);
}
let mut quote: Option<char> = None;
for (i, c) in rest.char_indices().skip(1) {
match (quote, c) {
(Some(q), c) if c == q => quote = None,
(Some(_), _) => {}
(None, '"') | (None, '\'') => quote = Some(c),
(None, '>') => return Some(start + i + c.len_utf8()),
(None, _) => {}
}
}
None
}
/// Lowercased element name of a tag body such as `img src="…"`.
pub(crate) fn tag_name(inner: &str) -> String {
inner
.trim_start_matches('/')
.split(|c: char| c.is_whitespace() || c == '/' || c == '>')
.next()
.unwrap_or("")
.to_ascii_lowercase()
}
/// Parse `name="value"` pairs out of a tag body.
fn parse_attrs(inner: &str) -> Vec<(String, String)> {
let mut attrs = Vec::new();
let bytes: Vec<char> = inner.chars().collect();
let mut i = 0;
// Skip the element name.
while i < bytes.len() && !bytes[i].is_whitespace() {
i += 1;
}
while i < bytes.len() {
while i < bytes.len() && (bytes[i].is_whitespace() || bytes[i] == '/') {
i += 1;
}
let name_start = i;
while i < bytes.len() && !bytes[i].is_whitespace() && bytes[i] != '=' && bytes[i] != '/' {
i += 1;
}
if i == name_start {
break;
}
let name: String = bytes[name_start..i]
.iter()
.collect::<String>()
.to_ascii_lowercase();
while i < bytes.len() && bytes[i].is_whitespace() {
i += 1;
}
let mut value = String::new();
if i < bytes.len() && bytes[i] == '=' {
i += 1;
while i < bytes.len() && bytes[i].is_whitespace() {
i += 1;
}
if i < bytes.len() && (bytes[i] == '"' || bytes[i] == '\'') {
let quote = bytes[i];
i += 1;
while i < bytes.len() && bytes[i] != quote {
value.push(bytes[i]);
i += 1;
}
i += 1;
} else {
while i < bytes.len() && !bytes[i].is_whitespace() && bytes[i] != '>' {
value.push(bytes[i]);
i += 1;
}
}
}
attrs.push((name, value));
}
attrs
}
/// Escape a string for use inside a double-quoted XML attribute.
fn attr_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
_ => out.push(c),
}
}
out
}
/// Escape a string for XML text content.
pub fn text_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
_ => out.push(c),
}
}
out
}
/// Rewrite `<img src>` to the embedded hrefs, replacing misses with the
/// `[image: alt]` placeholder paragraph (§3.10).
pub fn rewrite_img_srcs(html: &str, assets: &[ImageAsset]) -> String {
let by_url: HashMap<&str, &ImageAsset> =
assets.iter().map(|a| (a.source_url.as_str(), a)).collect();
let mut out = String::with_capacity(html.len());
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 raw = &html[start..end];
let inner = raw
.trim_start_matches('<')
.trim_end_matches('>')
.trim_end_matches('/');
if tag_name(inner) == "img" {
let attrs = parse_attrs(inner);
let src = attrs
.iter()
.find(|(k, _)| k == "src")
.map(|(_, v)| v.trim().to_string())
.unwrap_or_default();
let alt = attrs
.iter()
.find(|(k, _)| k == "alt")
.map(|(_, v)| v.trim().to_string())
.unwrap_or_default();
match by_url.get(src.as_str()) {
Some(asset) => {
let alt = if alt.is_empty() { &asset.alt } else { &alt };
out.push_str(&format!(
"<img src=\"{}\" alt=\"{}\"/>",
attr_escape(&asset.href),
attr_escape(alt)
));
}
None => {
let label = if alt.is_empty() {
"image unavailable"
} else {
&alt
};
out.push_str(&format!(
"<p class=\"image-placeholder\">[image: {}]</p>",
text_escape(label)
));
}
}
} else {
out.push_str(raw);
}
cursor = end;
}
out.push_str(&html[cursor..]);
out
}
/// Self-close HTML void elements and normalize `&nbsp;` so the markup parses as
/// XML — EPUB3 content documents are XHTML (§3.10).
pub fn to_xhtml(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;
out.push_str(&html[cursor..start]);
let Some(end) = tag_end(html, start) else {
out.push_str(&html[start..]);
cursor = html.len();
break;
};
let raw = &html[start..end];
let inner = raw.trim_start_matches('<').trim_end_matches('>');
let name = tag_name(inner);
if VOID_ELEMENTS.contains(&name.as_str()) && !inner.trim_end().ends_with('/') {
out.push('<');
out.push_str(inner.trim_end());
out.push_str("/>");
} else {
out.push_str(raw);
}
cursor = end;
}
out.push_str(&html[cursor..]);
// html5ever (via ammonia) emits `&nbsp;`, which is undefined in XML.
out.replace("&nbsp;", "&#160;")
}
#[cfg(test)]
mod tests {
use super::*;
fn asset(url: &str, href: &str) -> ImageAsset {
ImageAsset {
id: "img-1-0".into(),
href: href.into(),
mime: "image/jpeg".into(),
data: vec![1, 2, 3],
alt: "fallback alt".into(),
caption: None,
source_url: url.into(),
}
}
#[test]
fn extracts_img_refs_with_captions() {
let html = r#"<p>hi</p>
<figure><img src="https://e.g/a.png" alt="A diagram"/>
<figcaption>Figure 1: the thing</figcaption></figure>
<img src="https://e.g/b.jpg"/>
<img src="data:image/png;base64,zz"/>
<img src="https://e.g/a.png" alt="dupe"/>"#;
let refs = extract_img_refs(html);
assert_eq!(refs.len(), 2);
assert_eq!(refs[0].src, "https://e.g/a.png");
assert_eq!(refs[0].alt, "A diagram");
assert_eq!(refs[0].caption.as_deref(), Some("Figure 1: the thing"));
assert_eq!(refs[1].src, "https://e.g/b.jpg");
assert!(refs[1].caption.is_none());
}
#[test]
fn rewrites_hits_and_placeholders_misses() {
let assets = vec![asset("https://e.g/a.png", "images/img-1-0.jpg")];
let html = r#"<p>x</p><img src="https://e.g/a.png" alt="Alt &amp; more"><img src="https://e.g/gone.png" alt="Missing">"#;
let out = rewrite_img_srcs(html, &assets);
assert!(out.contains(r#"<img src="images/img-1-0.jpg" alt="Alt &amp;amp; more"/>"#));
assert!(out.contains(r#"<p class="image-placeholder">[image: Missing]</p>"#));
assert!(!out.contains("gone.png"));
}
#[test]
fn placeholder_falls_back_when_alt_is_missing() {
let out = rewrite_img_srcs(r#"<img src="https://e.g/x.png">"#, &[]);
assert_eq!(
out,
r#"<p class="image-placeholder">[image: image unavailable]</p>"#
);
}
#[test]
fn to_xhtml_self_closes_voids_and_entities() {
let html = "<p>a<br>b<hr>c&nbsp;d<img src=\"x.png\" alt=\"y\"></p><p>e<br/></p>";
let out = to_xhtml(html);
assert!(out.contains("<br/>"));
assert!(out.contains("<hr/>"));
assert!(out.contains("<img src=\"x.png\" alt=\"y\"/>"));
assert!(out.contains("&#160;"));
assert!(!out.contains("&nbsp;"));
assert!(!out.contains("<br/ >"));
// Already-closed voids are left alone (no double slash).
assert_eq!(out.matches("<br/>").count(), 2);
}
#[test]
fn tag_scanner_ignores_angle_brackets_in_attributes() {
let html = r#"<a title="a > b">x</a>"#;
assert_eq!(to_xhtml(html), html);
}
#[test]
fn reencode_resizes_grayscales_and_encodes() {
let mut img = image::RgbaImage::new(200, 100);
for (x, y, px) in img.enumerate_pixels_mut() {
*px = image::Rgba([(x % 256) as u8, (y % 256) as u8, 128, 255]);
}
let mut png = Cursor::new(Vec::new());
DynamicImage::ImageRgba8(img)
.write_to(&mut png, ImageFormat::Png)
.unwrap();
let raw = png.into_inner();
let (std_bytes, std_mime) = reencode(&raw, ImageProfile::STANDARD).unwrap();
assert_eq!(std_mime, "image/jpeg");
let decoded = image::load_from_memory(&std_bytes).unwrap();
assert_eq!(decoded.dimensions(), (200, 100), "no upscaling");
let (x4_bytes, _) = reencode(&raw, ImageProfile::X4).unwrap();
let x4 = image::load_from_memory(&x4_bytes).unwrap();
assert!(x4.width() <= 480 && x4.height() <= 800);
assert_eq!(x4.color(), image::ColorType::L8, "X4 is grayscale Luma8");
}
#[test]
fn reencode_skips_decorative_images_and_junk() {
let tiny = image::RgbaImage::new(8, 8);
let mut png = Cursor::new(Vec::new());
DynamicImage::ImageRgba8(tiny)
.write_to(&mut png, ImageFormat::Png)
.unwrap();
assert!(reencode(&png.into_inner(), ImageProfile::STANDARD).is_none());
assert!(reencode(b"<svg>not an image</svg>", ImageProfile::STANDARD).is_none());
}
#[test]
fn line_art_png_stays_png_and_is_flattened() {
let mut img = image::RgbaImage::new(120, 60);
for (x, _y, px) in img.enumerate_pixels_mut() {
*px = if x % 12 == 0 {
image::Rgba([0, 0, 0, 255])
} else {
image::Rgba([255, 255, 255, 0])
};
}
let mut png = Cursor::new(Vec::new());
DynamicImage::ImageRgba8(img)
.write_to(&mut png, ImageFormat::Png)
.unwrap();
let (bytes, mime) = reencode(&png.into_inner(), ImageProfile::STANDARD).unwrap();
assert_eq!(mime, "image/png");
let decoded = image::load_from_memory(&bytes).unwrap();
assert!(!decoded.color().has_alpha(), "transparency is flattened");
// Transparent pixels became white.
assert_eq!(
decoded.to_rgb8().get_pixel(1, 1),
&image::Rgb([255, 255, 255])
);
}
}
+208
View File
@@ -0,0 +1,208 @@
//! EPUB assembly (spec §3.10).
//!
//! Two editions per issue: `Standard` and `X4`. Both are fully offline (every
//! asset embedded), EPUB3 with a nav TOC + NCX fallback, chapter ids
//! `art-{entry_id}` so rating links stay stable across regenerations.
pub mod build;
pub mod images;
pub mod x4;
use std::path::{Path, PathBuf};
use crate::config::Config;
use crate::types::{Artifact, Edition, ImageAsset, Issue};
/// Chapter order inside an issue (§3.10).
pub const CHAPTER_ORDER: &[&str] = &[
"cover",
"from-the-editor",
"in-this-issue",
"sections",
"world-briefing",
"colophon",
];
#[derive(Debug, thiserror::Error)]
pub enum EpubError {
#[error("epub build failed: {0}")]
Build(String),
#[error("template rendering failed: {0}")]
Template(#[from] askama::Error),
#[error("io error writing {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
/// Output filename: `The Daily EPUB - 2026-08-15.epub` / `… (X4).epub` (§3.11).
pub fn output_filename(issue: &Issue, edition: Edition) -> String {
format!(
"The Daily EPUB - {}{}.epub",
issue.meta.date,
edition.file_suffix()
)
}
/// Build one edition into `out_dir`, returning the written artifact (§3.10).
///
/// Downloads and re-encodes the issue's images first; everything else is offline.
pub async fn build_edition(
issue: &Issue,
edition: Edition,
cfg: &Config,
out_dir: &Path,
) -> Result<Artifact, EpubError> {
let http = crate::http::build_client(crate::http::DEFAULT_TIMEOUT)
.map_err(|e| EpubError::Build(format!("http client: {e}")))?;
let assets = images::collect_for_issue(&http, &issue.lineup.picks, edition).await;
build_edition_with_images(issue, edition, cfg, out_dir, &assets)
}
/// The offline half of [`build_edition`]: render, zip and write (§3.10).
pub fn build_edition_with_images(
issue: &Issue,
edition: Edition,
cfg: &Config,
out_dir: &Path,
assets: &[ImageAsset],
) -> Result<Artifact, EpubError> {
let span = tracing::info_span!("epub", %issue.meta.date, ?edition);
let _guard = span.enter();
let chapters = build::render_all(
issue,
edition,
assets,
&cfg.server.public_url,
cfg.server.hmac_secret.as_deref(),
)?;
let cover = build::render_cover(issue, edition)?;
let bytes = build::assemble(issue, edition, &chapters, assets, &cover)?;
std::fs::create_dir_all(out_dir).map_err(|source| EpubError::Io {
path: out_dir.to_path_buf(),
source,
})?;
let path = out_dir.join(output_filename(issue, edition));
// Write + rename so a reader (or BookOrbit's watcher) never sees a partial file.
let tmp = path.with_extension("epub.part");
std::fs::write(&tmp, &bytes).map_err(|source| EpubError::Io {
path: tmp.clone(),
source,
})?;
std::fs::rename(&tmp, &path).map_err(|source| EpubError::Io {
path: path.clone(),
source,
})?;
tracing::info!(
path = %path.display(),
bytes = bytes.len(),
chapters = chapters.len(),
images = assets.len(),
"wrote edition"
);
Ok(Artifact {
edition,
path,
bytes: bytes.len() as u64,
})
}
/// Build both editions, returning the artifacts and how many images were
/// embedded across them (the run report records the count, §3.10, §3.13).
pub async fn build_all(
issue: &Issue,
cfg: &Config,
out_dir: &Path,
) -> Result<(Vec<Artifact>, usize), EpubError> {
let http = crate::http::build_client(crate::http::DEFAULT_TIMEOUT)
.map_err(|e| EpubError::Build(format!("http client: {e}")))?;
let mut artifacts = Vec::with_capacity(2);
let mut embedded = 0;
for edition in [Edition::Standard, Edition::X4] {
// Downloaded per edition: the two editions need different resolutions
// and colour profiles (§3.10 images).
let assets = images::collect_for_issue(&http, &issue.lineup.picks, edition).await;
embedded += assets.len();
artifacts.push(build_edition_with_images(
issue, edition, cfg, out_dir, &assets,
)?);
}
Ok((artifacts, embedded))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::epub::build::fixtures;
/// Local file headers store entry names verbatim, so a byte search over the
/// archive is enough to assert its contents without a zip reader.
fn contains_entry(zip: &[u8], name: &str) -> bool {
zip.windows(name.len()).any(|w| w == name.as_bytes())
}
#[test]
fn output_filenames_follow_the_spec() {
let issue = fixtures::issue();
assert_eq!(
output_filename(&issue, Edition::Standard),
"The Daily EPUB - 2026-08-15.epub"
);
assert_eq!(
output_filename(&issue, Edition::X4),
"The Daily EPUB - 2026-08-15 (X4).epub"
);
}
#[test]
fn builds_a_complete_epub_for_both_editions() {
let issue = fixtures::issue();
let cfg = Config::default();
let dir = tempfile::tempdir().expect("tempdir");
for edition in [Edition::Standard, Edition::X4] {
let artifact =
build_edition_with_images(&issue, edition, &cfg, dir.path(), &[]).expect("build");
assert_eq!(artifact.edition, edition);
assert!(artifact.path.exists());
assert!(artifact.bytes > 1000);
let zip = std::fs::read(&artifact.path).expect("read epub");
assert_eq!(&zip[0..4], b"PK\x03\x04", "is a zip");
assert_eq!(&zip[30..38], b"mimetype", "mimetype is the first entry");
assert_eq!(&zip[38..58], b"application/epub+zip");
for entry in [
"META-INF/container.xml",
"OEBPS/content.opf",
"OEBPS/toc.ncx",
"OEBPS/nav.xhtml",
"OEBPS/stylesheet.css",
"OEBPS/cover.png",
"OEBPS/cover.xhtml",
"OEBPS/front.xhtml",
"OEBPS/in-this-issue.xhtml",
"OEBPS/art-1001.xhtml",
"OEBPS/disc-1001.xhtml",
"OEBPS/art-1002.xhtml",
"OEBPS/world.xhtml",
"OEBPS/colophon.xhtml",
] {
assert!(
contains_entry(&zip, entry),
"missing {entry} in {edition:?}"
);
}
// No leftover temp file.
assert!(
!dir.path()
.join("The Daily EPUB - 2026-08-15.epub.part")
.exists()
);
}
}
}
+32
View File
@@ -0,0 +1,32 @@
# EPUB templates
Askama templates and stylesheets for the two editions (spec §3.10, implementation
notes §11). `src/epub/build.rs` owns the structs they bind to; `askama.toml` at
the crate root points askama here (`dirs = ["src/epub/templates"]`).
| File | Template struct | Purpose |
|---|---|---|
| `base.xhtml` | — | Shared XHTML skeleton (`{% block body_class %}`, `{% block content %}`) |
| `cover_page.xhtml` | `CoverPage` | Page that displays the rasterized cover image |
| `front_page.xhtml` | `FrontPage` | "From the Editor" + issue stats line |
| `in_this_issue.xhtml` | `InThisIssue` | Introduction chapter: per-section linked index |
| `section.xhtml` | `SectionPage` | Section title page + LLM intro |
| `chapter.xhtml` | `ArticleChapter` | Article: header, body, rating/read-online footer |
| `discussion.xhtml` | `DiscussionChapter` | Comment chapter (§3.7); body from `comments::render_xhtml` |
| `world_briefing.xhtml` | `WorldBriefingChapter` | Wikipedia Current Events (§3.8), body from `world::render_xhtml` |
| `colophon.xhtml` | `ColophonChapter` | Back matter: models, cost, counts |
| `cover.svg` | `CoverSvg` | Typographic cover, rasterized with resvg + tiny-skia |
| `style.css` | — | Standard-edition stylesheet, embedded as `stylesheet.css` |
| `style-x4.css` | — | X4 stylesheet: no floats/flex/grid, no fonts, hyphenation on |
Conventions:
- Every content template `{% extends "base.xhtml" %}` and provides `title`.
- Templates declare `escape = "html"``.xhtml`/`.svg` are not in askama's
default escaper extension list. Escaping emits numeric character references
(`&#38;`), which are valid XML; only pre-sanitized markup uses `|safe`.
- Markup that reaches `|safe` has gone through `ammonia` **and**
`epub::images::to_xhtml` (void elements self-closed, `&nbsp;``&#160;`) so
the output parses as XML, as EPUB3 content documents must.
- Entities other than the five XML built-ins are written as numeric references
in the templates themselves (`&#183;`, `&#128077;`, …).
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta charset="utf-8"/>
<title>{{ title }}</title>
<link rel="stylesheet" type="text/css" href="stylesheet.css"/>
</head>
<body>
<div class="chapter {% block body_class %}text{% endblock %}">
{% block content %}{% endblock %}
</div>
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
{% extends "base.xhtml" %}
{% block body_class %}article{% endblock %}
{% block content %}
<div class="article-header">
<h1 class="article-title">{{ article_title }}</h1>
{% if let Some(line) = byline %}
<p class="byline">{{ line }}</p>
{% endif %}
<p class="meta">{{ meta_line }}</p>
{% if let Some(line) = social_line %}
<p class="social">{{ line }}</p>
{% endif %}
{% if let Some(text) = summary %}
<p class="summary">{{ text }}</p>
{% endif %}
{% if excerpt_only %}
<p class="notice">(excerpt only &#8212; read online)</p>
{% endif %}
</div>
<hr class="rule"/>
<div class="article-body">
{{ body_html|safe }}
</div>
<hr class="rule"/>
<div class="article-footer">
{% if let Some(links) = rating %}
<p class="rating">Was this a good pick? <a href="{{ links.up_url }}">[ &#128077; Yes ]</a> &#183; <a href="{{ links.down_url }}">[ &#128078; No ]</a></p>
{% endif %}
<p class="read-online"><a href="{{ read_online_url }}">Read online &#8599;</a></p>
{% if let Some(href) = discussion_href %}
<p class="see-discussion"><a href="{{ href }}">&#128172; Read the discussion</a></p>
{% endif %}
</div>
{% endblock %}
+25
View File
@@ -0,0 +1,25 @@
{% extends "base.xhtml" %}
{% block body_class %}colophon{% endblock %}
{% block content %}
<h1>Colophon</h1>
<p>
<em>The Daily EPUB</em> is assembled every morning from a personal Miniflux
feed reader: entries are deduplicated, read in full, weighed against social
proof, then scored, sectioned and introduced by a language model.
</p>
<dl class="colophon-facts">
<dt class="fact-key">Issue</dt><dd class="fact-value">No. {{ issue_number }} &#183; {{ display_date }}</dd>
<dt class="fact-key">Generated</dt><dd class="fact-value">{{ generated_at }}</dd>
<dt class="fact-key">Curation model</dt><dd class="fact-value">{{ model }}</dd>
<dt class="fact-key">Entries considered</dt><dd class="fact-value">{{ entries_fetched }} from {{ feeds_seen }} feeds</dd>
<dt class="fact-key">Candidates scored</dt><dd class="fact-value">{{ candidates }}</dd>
<dt class="fact-key">Articles selected</dt><dd class="fact-value">{{ article_count }} across {{ section_count }} sections</dd>
<dt class="fact-key">Words</dt><dd class="fact-value">{{ total_words }} &#183; {{ reading_line }}</dd>
<dt class="fact-key">Token cost</dt><dd class="fact-value">{{ cost_usd }}</dd>
<dt class="fact-key">Generator</dt><dd class="fact-value">{{ generator_version }}</dd>
</dl>
<p class="attribution">
Article text belongs to its authors and publications; excerpts and links are
provided for personal reading. Comment excerpts belong to their posters.
</p>
{% endblock %}
+25
View File
@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="{{ width }}" height="{{ height }}" viewBox="0 0 {{ width }} {{ height }}">
<rect x="0" y="0" width="{{ width }}" height="{{ height }}" fill="#ffffff"/>
<rect x="{{ margin }}" y="{{ margin }}" width="{{ inner_width }}" height="{{ inner_height }}"
fill="none" stroke="#111111" stroke-width="{{ border }}"/>
<text x="{{ center_x }}" y="{{ masthead_y }}" text-anchor="middle" fill="#111111"
font-family="Georgia, 'Times New Roman', Times, serif" font-size="{{ masthead_size }}">The Daily EPUB</text>
<line x1="{{ rule_x1 }}" y1="{{ rule_y }}" x2="{{ rule_x2 }}" y2="{{ rule_y }}" stroke="#111111" stroke-width="{{ border }}"/>
<text x="{{ center_x }}" y="{{ weekday_y }}" text-anchor="middle" fill="#111111"
font-family="Georgia, 'Times New Roman', Times, serif" font-size="{{ weekday_size }}">{{ weekday }}</text>
<text x="{{ center_x }}" y="{{ date_y }}" text-anchor="middle" fill="#111111"
font-family="Georgia, 'Times New Roman', Times, serif" font-size="{{ date_size }}">{{ long_date }}</text>
<line x1="{{ rule_x1 }}" y1="{{ rule2_y }}" x2="{{ rule_x2 }}" y2="{{ rule2_y }}" stroke="#111111" stroke-width="{{ hairline }}"/>
<text x="{{ center_x }}" y="{{ issue_y }}" text-anchor="middle" fill="#111111"
font-family="Georgia, 'Times New Roman', Times, serif" font-size="{{ issue_size }}">No. {{ issue_number }}</text>
<text x="{{ center_x }}" y="{{ stats_y }}" text-anchor="middle" fill="#111111"
font-family="Georgia, 'Times New Roman', Times, serif" font-size="{{ stats_size }}">{{ stats_line }}</text>
{% if !edition_tag.is_empty() %}
<rect x="{{ badge_x }}" y="{{ badge_y }}" width="{{ badge_width }}" height="{{ badge_height }}"
rx="{{ badge_radius }}" fill="#111111"/>
<text x="{{ center_x }}" y="{{ badge_text_y }}" text-anchor="middle" fill="#ffffff"
font-family="Georgia, 'Times New Roman', Times, serif" font-size="{{ badge_size }}">{{ edition_tag }}</text>
{% endif %}
<text x="{{ center_x }}" y="{{ footer_y }}" text-anchor="middle" fill="#111111"
font-family="Georgia, 'Times New Roman', Times, serif" font-size="{{ footer_size }}">{{ footer }}</text>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+5
View File
@@ -0,0 +1,5 @@
{% extends "base.xhtml" %}
{% block body_class %}cover-page{% endblock %}
{% block content %}
<div class="cover-image"><img src="cover.png" alt="{{ alt }}"/></div>
{% endblock %}
+8
View File
@@ -0,0 +1,8 @@
{% extends "base.xhtml" %}
{% block body_class %}discussion{% endblock %}
{% block content %}
<h1 class="discussion-title">{{ heading }}</h1>
<p class="discussion-note">Selected threads, truncated for reading on e-ink.</p>
{{ body_html|safe }}
<p class="back-link"><a href="{{ article_href }}">&#8617; Back to the article</a></p>
{% endblock %}
+12
View File
@@ -0,0 +1,12 @@
{% extends "base.xhtml" %}
{% block body_class %}front-page{% endblock %}
{% block content %}
<h1 class="masthead">The Daily EPUB</h1>
<p class="dateline">{{ display_date }} &#183; No. {{ issue_number }}</p>
<hr class="rule"/>
<h2 class="kicker">From the Editor</h2>
<div class="editorial">
{{ body_html|safe }}
</div>
<p class="stats">{{ stats_line }}</p>
{% endblock %}
+20
View File
@@ -0,0 +1,20 @@
{% extends "base.xhtml" %}
{% block body_class %}in-this-issue{% endblock %}
{% block content %}
<h1>In This Issue</h1>
<p class="stats">{{ stats_line }}</p>
{% for section in sections %}
<h2 class="index-section">{{ section.name }}</h2>
<ul class="index-list">
{% for entry in section.entries %}
<li class="index-entry">
<p class="index-title"><a href="{{ entry.href }}">{{ entry.title }}</a></p>
<p class="index-meta">{{ entry.source }} &#183; {{ entry.reading_minutes }} min read</p>
{% if !entry.summary.is_empty() %}
<p class="index-summary">{{ entry.summary }}</p>
{% endif %}
</li>
{% endfor %}
</ul>
{% endfor %}
{% endblock %}
+9
View File
@@ -0,0 +1,9 @@
{% extends "base.xhtml" %}
{% block body_class %}section-page{% endblock %}
{% block content %}
<h1 class="section-title">{{ name }}</h1>
<hr class="rule"/>
{% if let Some(text) = intro %}
<p class="section-intro">{{ text }}</p>
{% endif %}
{% endblock %}
+188
View File
@@ -0,0 +1,188 @@
/* Xteink X4 stylesheet (spec §3.10 "X4 edition").
No floats, no flex, no grid, no embedded fonts, larger base font,
generous line-height, hyphenation on. 480x800, 2-bit grayscale.
Selectors are `tag`, `.class` and `tag.class` only — the X4 firmware's CSS
engine does not support descendant combinators, so a rule like
`.comment-body p` is silently dropped on the device. Where a tag rule needs
an exception, the `tag.class` override follows it immediately so the result
is right whether the engine resolves by specificity or by source order. */
@page {
margin: 0.4em;
}
body {
font-family: serif;
font-size: 1.2em;
line-height: 1.7;
margin: 0 0.5em;
text-align: left;
hyphens: auto;
-webkit-hyphens: auto;
adobe-hyphenate: auto;
word-wrap: break-word;
}
.chapter {
page-break-before: always;
break-before: page;
}
h1, h2, h3, h4 {
font-weight: bold;
line-height: 1.3;
page-break-after: avoid;
break-after: avoid;
margin: 0.6em 0 0.35em 0;
hyphens: none;
-webkit-hyphens: none;
}
h1 { font-size: 1.35em; }
h2 { font-size: 1.15em; }
h3, h4 { font-size: 1em; }
p {
margin: 0 0 0.6em 0;
}
a {
color: #000000;
text-decoration: underline;
}
hr.rule {
border: 0;
border-top: 1px solid #000000;
margin: 0.7em 0;
height: 0;
}
img {
max-width: 100%;
height: auto;
}
.cover-page {
text-align: center;
margin: 0;
}
.masthead {
font-size: 1.6em;
text-align: center;
}
.dateline,
.stats,
.meta,
.social,
.index-meta,
.discussion-note,
.attribution,
.comment-meta {
font-size: 0.85em;
}
.dateline,
.stats {
text-align: center;
}
.section-page {
text-align: center;
}
.section-title {
font-size: 1.5em;
margin-top: 1.5em;
}
.section-intro,
.summary,
.byline {
font-style: italic;
}
ul, ol {
margin: 0 0 0.5em 1em;
padding: 0;
}
ul.index-list {
list-style: none;
margin: 0;
padding: 0;
}
.index-entry {
margin: 0 0 0.8em 0;
}
.index-title,
.index-meta,
.index-summary {
margin: 0;
}
figure {
margin: 0.6em 0;
text-align: center;
}
figcaption,
.image-caption,
.image-placeholder {
font-size: 0.85em;
font-style: italic;
text-align: center;
}
pre, code {
font-family: monospace;
font-size: 0.85em;
white-space: pre-wrap;
word-wrap: break-word;
hyphens: none;
-webkit-hyphens: none;
}
table {
border-collapse: collapse;
font-size: 0.85em;
}
td, th {
border: 1px solid #666666;
padding: 0.15em 0.3em;
}
blockquote {
margin: 0.5em 0 0.5em 0.3em;
padding-left: 0.5em;
border-left: 2px solid #666666;
}
blockquote.comment {
margin: 0.4em 0;
padding-left: 0.5em;
border-left: 2px solid #666666;
}
blockquote.reply {
border-left: 1px solid #999999;
}
p.comment-line {
margin: 0 0 0.35em 0;
}
dt.fact-key {
font-weight: bold;
margin-top: 0.35em;
}
dd.fact-value {
margin: 0 0 0 0.8em;
}
+290
View File
@@ -0,0 +1,290 @@
/* Standard-edition stylesheet (spec §3.10 "CSS").
Serif body, grayscale only, page-break-before on chapters,
blockquote-indent comment styling. Tuned for e-ink readers. */
@page {
margin: 1em;
}
body {
font-family: Georgia, "Times New Roman", Times, serif;
font-size: 1em;
line-height: 1.5;
margin: 0 1em;
text-align: left;
widows: 2;
orphans: 2;
}
.chapter {
page-break-before: always;
break-before: page;
}
h1, h2, h3, h4 {
font-weight: normal;
line-height: 1.25;
page-break-after: avoid;
break-after: avoid;
margin: 0.8em 0 0.4em 0;
}
h1 { font-size: 1.5em; }
h2 { font-size: 1.25em; }
h3 { font-size: 1.1em; }
h4 { font-size: 1em; font-style: italic; }
p {
margin: 0 0 0.7em 0;
}
a {
color: #000000;
text-decoration: underline;
}
hr.rule {
border: 0;
border-top: 1px solid #000000;
margin: 0.9em 0;
height: 0;
}
/* --- cover ------------------------------------------------------------- */
.cover-page {
text-align: center;
margin: 0;
padding: 0;
}
.cover-image img {
max-width: 100%;
max-height: 100%;
}
/* --- front page -------------------------------------------------------- */
.masthead {
font-size: 2.1em;
text-align: center;
letter-spacing: 0.02em;
margin-bottom: 0.1em;
}
.dateline {
text-align: center;
font-size: 0.9em;
font-variant: small-caps;
margin-bottom: 0.6em;
}
.kicker {
font-variant: small-caps;
text-align: center;
}
.stats {
font-size: 0.85em;
font-style: italic;
text-align: center;
margin-top: 1em;
}
/* --- in this issue ----------------------------------------------------- */
.index-list {
list-style: none;
margin: 0;
padding: 0;
}
.index-entry {
margin: 0 0 0.9em 0;
page-break-inside: avoid;
break-inside: avoid;
}
.index-title {
margin: 0;
font-size: 1.05em;
}
.index-meta {
margin: 0;
font-size: 0.8em;
font-variant: small-caps;
}
.index-summary {
margin: 0.2em 0 0 0;
font-size: 0.9em;
}
/* --- sections and articles --------------------------------------------- */
.section-page {
text-align: center;
}
.section-title {
font-size: 2em;
font-variant: small-caps;
margin-top: 2.5em;
}
.section-intro {
font-style: italic;
margin: 0 1.5em;
}
.article-title {
font-size: 1.6em;
margin-bottom: 0.2em;
}
.byline {
margin: 0;
font-style: italic;
}
.meta, .social {
margin: 0;
font-size: 0.8em;
font-variant: small-caps;
}
.summary {
margin: 0.5em 0 0 0;
font-style: italic;
}
.notice {
font-size: 0.85em;
font-style: italic;
}
.article-body img {
max-width: 100%;
height: auto;
}
.article-body figure {
margin: 0.8em 0;
text-align: center;
page-break-inside: avoid;
break-inside: avoid;
}
.article-body figcaption,
.image-caption {
font-size: 0.8em;
font-style: italic;
text-align: center;
}
.image-placeholder {
font-size: 0.85em;
font-style: italic;
color: #444444;
}
.article-body blockquote {
margin: 0.6em 0 0.6em 1em;
padding-left: 0.6em;
border-left: 2px solid #999999;
font-style: italic;
}
.article-body pre,
.article-body code {
font-family: "DejaVu Sans Mono", "Courier New", monospace;
font-size: 0.85em;
}
.article-body pre {
white-space: pre-wrap;
word-wrap: break-word;
border-left: 2px solid #cccccc;
padding-left: 0.5em;
}
.article-body table {
border-collapse: collapse;
font-size: 0.85em;
}
.article-body td,
.article-body th {
border: 1px solid #999999;
padding: 0.2em 0.4em;
}
.article-footer {
font-size: 0.9em;
}
.rating a {
text-decoration: none;
}
/* --- discussion chapters (§3.7) ---------------------------------------- */
.discussion-note {
font-size: 0.8em;
font-style: italic;
}
.discussion-source {
font-variant: small-caps;
font-size: 1.1em;
margin-top: 1em;
}
blockquote.comment {
border-left: 2px solid #888888;
margin: 0.5em 0 0.5em 0;
padding-left: 0.7em;
page-break-inside: avoid;
break-inside: avoid;
}
blockquote.comment blockquote.comment {
border-left: 1px solid #aaaaaa;
margin-left: 0.2em;
}
.comment-meta {
font-size: 0.78em;
font-variant: small-caps;
margin: 0 0 0.15em 0;
}
.comment-body p {
margin: 0 0 0.4em 0;
}
/* --- world briefing and colophon --------------------------------------- */
.world-body ul {
margin: 0 0 0.6em 1.1em;
padding: 0;
}
.world-body li {
margin-bottom: 0.25em;
}
.attribution {
font-size: 0.8em;
font-style: italic;
}
.colophon-facts dt {
font-variant: small-caps;
margin-top: 0.4em;
}
.colophon-facts dd {
margin: 0 0 0 1em;
}
+10
View File
@@ -0,0 +1,10 @@
{% extends "base.xhtml" %}
{% block body_class %}world-briefing{% endblock %}
{% block content %}
<h1>World Briefing</h1>
<p class="dateline">{{ display_date }}</p>
<hr class="rule"/>
<div class="world-body">
{{ body_html|safe }}
</div>
{% endblock %}
+644
View File
@@ -0,0 +1,644 @@
//! 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 <repo>/cli/index.js convert <in.epub> -o <out.xtch> -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 super::images::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;
// `<style>`/`<script>` bodies are not prose: copy to the closing tag verbatim.
if let Some(name) = raw_text_name(tag)
&& let Some(close) = find_close_tag(html, cursor, name)
{
out.push_str(&html[cursor..close]);
cursor = close;
}
}
soften_text(&html[cursor..], &mut out);
out
}
/// The element name when `tag` opens a raw-text element, else `None`.
fn raw_text_name(tag: &str) -> Option<&'static str> {
let rest = tag.strip_prefix('<')?;
if rest.starts_with('/') {
return None;
}
RAW_TEXT_ELEMENTS.iter().copied().find(|name| {
rest.len() >= name.len()
&& rest[..name.len()].eq_ignore_ascii_case(name)
// Only `<style>` and `<style type=…>`, never `<styled-thing>`.
&& rest[name.len()..]
.starts_with([' ', '\t', '\n', '\r', '>', '/'])
})
}
/// Byte offset of `</name` at or after `from`, else `None`.
fn find_close_tag(html: &str, from: usize, name: &str) -> Option<usize> {
let needle = format!("</{name}");
let hay = html.get(from..)?.to_ascii_lowercase();
hay.find(&needle).map(|i| from + i)
}
/// Copy `text` into `out`, soft-hyphenating any over-long word.
fn soften_text(text: &str, out: &mut String) {
// Byte length bounds character count, so a short run holds no long word.
if text.len() <= MAX_WORD_CHARS {
out.push_str(text);
return;
}
let mut word_start = 0usize;
for (i, c) in text.char_indices() {
if c.is_whitespace() {
push_soft_hyphenated(&text[word_start..i], out);
out.push(c);
word_start = i + c.len_utf8();
}
}
push_soft_hyphenated(&text[word_start..], out);
}
fn push_soft_hyphenated(word: &str, out: &mut String) {
if word.len() <= MAX_WORD_CHARS {
out.push_str(word);
return;
}
let mut units = 0usize;
let mut rest = word;
while !rest.is_empty() {
if units == MAX_WORD_CHARS {
out.push(SOFT_HYPHEN);
units = 0;
}
let take =
entity_len(rest).unwrap_or_else(|| rest.chars().next().map_or(1, char::len_utf8));
out.push_str(&rest[..take]);
rest = &rest[take..];
units += 1;
}
}
/// Byte length of the `&…;` reference starting `s`, if there is one.
///
/// A character reference is one unit: splitting `&amp;` down the middle would
/// turn it into literal text and break the XHTML.
fn entity_len(s: &str) -> Option<usize> {
/// `&thickapprox;` is 13 bytes; nothing we emit is longer.
const MAX_ENTITY_BYTES: usize = 16;
let bytes = s.as_bytes();
if bytes.first() != Some(&b'&') {
return None;
}
bytes
.iter()
.take(MAX_ENTITY_BYTES)
.position(|&b| b == b';')
.map(|p| p + 1)
}
/// Remove the named attributes from every tag, leaving the rest verbatim.
fn strip_attributes(html: &str, drop: &[&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;
out.push_str(&html[cursor..start]);
let Some(end) = tag_end(html, start) else {
out.push_str(&html[start..]);
return out;
};
out.push_str(&filter_tag(&html[start..end], drop));
cursor = end;
}
out.push_str(&html[cursor..]);
out
}
/// `<p style="x" class="y">` → `<p class="y">`.
fn filter_tag(tag: &str, drop: &[&str]) -> String {
if tag.starts_with("<!") || tag.starts_with("<?") || tag.starts_with("</") {
return tag.to_string();
}
let bytes = tag.as_bytes();
let mut out = String::with_capacity(tag.len());
let mut i = 1; // past '<'
while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b'>' {
i += 1;
}
out.push_str(&tag[..i]);
while i < bytes.len() {
let ws_start = i;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() || bytes[i] == b'>' || bytes[i] == b'/' {
out.push_str(&tag[ws_start..]);
return out;
}
let name_start = i;
while i < bytes.len()
&& !bytes[i].is_ascii_whitespace()
&& bytes[i] != b'='
&& bytes[i] != b'>'
&& bytes[i] != b'/'
{
i += 1;
}
let name = tag[name_start..i].to_ascii_lowercase();
let mut after_name = i;
while after_name < bytes.len() && bytes[after_name].is_ascii_whitespace() {
after_name += 1;
}
if after_name < bytes.len() && bytes[after_name] == b'=' {
i = after_name + 1;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i < bytes.len() && (bytes[i] == b'"' || bytes[i] == b'\'') {
let quote = bytes[i];
i += 1;
while i < bytes.len() && bytes[i] != quote {
i += 1;
}
i = (i + 1).min(bytes.len());
} else {
while i < bytes.len() && !bytes[i].is_ascii_whitespace() && bytes[i] != b'>' {
i += 1;
}
}
}
if !drop.contains(&name.as_str()) {
out.push_str(&tag[ws_start..i]);
}
}
out
}
/// Full argv for the converter: `command` + `args` + `<input> -o <output> -f <format>`
/// (+ `-c <settings>` when configured) (§3.11).
pub fn build_command(cfg: &XtcConfig, input: &Path, output: &Path) -> (String, Vec<String>) {
let mut args = cfg.args.clone();
args.push(input.display().to_string());
args.push("-o".to_string());
args.push(output.display().to_string());
args.push("-f".to_string());
args.push(cfg.format.as_str().to_string());
if let Some(settings) = &cfg.settings {
args.push("-c".to_string());
args.push(settings.display().to_string());
}
(cfg.command.clone(), args)
}
/// Output path for an input EPUB: `{out_dir}/{stem}.{xtc|xtch}` (§3.11).
pub fn output_path(cfg: &XtcConfig, input: &Path, out_dir: &Path) -> PathBuf {
let stem = input
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "issue".to_string());
out_dir.join(format!("{stem}.{}", cfg.format.extension()))
}
/// Convert the X4 EPUB to `.xtc`/`.xtch` via `tokio::process::Command` (§3.11).
///
/// Callers treat every error as a warning and continue — XTC is a bonus
/// artifact, the X4 can always fall back to the X4 EPUB from BookOrbit.
pub async fn convert(cfg: &XtcConfig, input: &Path, out_dir: &Path) -> Result<PathBuf, XtcError> {
if cfg.settings.is_none() {
// The converter refuses to start without `font.path`, which can only be
// supplied through the settings JSON: `-c` is mandatory in practice even
// though the flag is optional.
tracing::warn!(
"xtc.settings is unset; epub-to-xtc-converter requires a settings \
file with a font.path and will refuse to run without one"
);
}
let output = output_path(cfg, input, out_dir);
if let Some(parent) = output.parent() {
std::fs::create_dir_all(parent).map_err(|e| XtcError::Spawn {
command: parent.display().to_string(),
source: e,
})?;
}
let (command, args) = build_command(cfg, input, &output);
tracing::info!(command, ?args, "running the xtc converter");
let result = tokio::process::Command::new(&command)
.args(&args)
.output()
.await
.map_err(|e| XtcError::Spawn {
command: command.clone(),
source: e,
})?;
if !result.status.success() {
return Err(XtcError::Failed {
status: result.status.code().unwrap_or(-1),
stderr: String::from_utf8_lossy(&result.stderr)
.lines()
.take(5)
.collect::<Vec<_>>()
.join(" | "),
});
}
if !output.exists() {
return Err(XtcError::NoOutput(output));
}
tracing::info!(path = %output.display(), "xtc conversion complete");
Ok(output)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::XtcFormat;
fn cfg() -> XtcConfig {
XtcConfig {
enabled: true,
command: "node".into(),
args: vec![
"/opt/epub-to-xtc-converter/cli/index.js".into(),
"convert".into(),
],
format: XtcFormat::Xtch,
settings: None,
}
}
#[test]
fn builds_the_documented_converter_argv() {
let (command, args) = build_command(
&cfg(),
Path::new("/out/The Daily EPUB - 2026-08-15 (X4).epub"),
Path::new("/xtc/The Daily EPUB - 2026-08-15 (X4).xtch"),
);
assert_eq!(command, "node");
assert_eq!(
args,
vec![
"/opt/epub-to-xtc-converter/cli/index.js",
"convert",
"/out/The Daily EPUB - 2026-08-15 (X4).epub",
"-o",
"/xtc/The Daily EPUB - 2026-08-15 (X4).xtch",
"-f",
"xtch",
]
);
}
#[test]
fn settings_file_is_passed_with_dash_c() {
let mut cfg = cfg();
cfg.settings = Some(PathBuf::from("/etc/xtc.json"));
cfg.format = XtcFormat::Xtc;
let (_, args) = build_command(&cfg, Path::new("in.epub"), Path::new("out.xtc"));
assert_eq!(args[args.len() - 4..], ["-f", "xtc", "-c", "/etc/xtc.json"]);
}
#[test]
fn output_path_follows_the_format_extension() {
let out = output_path(&cfg(), Path::new("/out/Issue (X4).epub"), Path::new("/xtc"));
assert_eq!(out, PathBuf::from("/xtc/Issue (X4).xtch"));
}
/// The pipeline turns every one of these into a report warning, so the error
/// has to say which of them happened (§3.11).
#[tokio::test]
async fn converter_failures_are_distinguishable() {
let dir = tempfile::tempdir().unwrap();
let mut missing = cfg();
missing.command = "definitely-not-a-real-binary-9f3b".into();
missing.args.clear();
match convert(&missing, Path::new("in.epub"), dir.path()).await {
Err(XtcError::Spawn { command, .. }) => {
assert_eq!(command, "definitely-not-a-real-binary-9f3b")
}
other => panic!("expected a spawn failure, got {other:?}"),
}
let mut failing = cfg();
failing.command = "false".into();
failing.args.clear();
match convert(&failing, Path::new("in.epub"), dir.path()).await {
Err(XtcError::Failed { status, .. }) => assert_ne!(status, 0),
other => panic!("expected a nonzero exit, got {other:?}"),
}
// Exit 0 but nothing written is its own error, not a silent success.
let mut silent = cfg();
silent.command = "true".into();
silent.args.clear();
match convert(&silent, Path::new("in.epub"), dir.path()).await {
Err(XtcError::NoOutput(path)) => assert_eq!(path, dir.path().join("in.xtch")),
other => panic!("expected NoOutput, got {other:?}"),
}
}
#[test]
fn css_simplification_drops_layout_and_fonts() {
let css = r#"
@font-face { font-family: "Serif"; src: url(serif.woff2); }
.a { float: left; color: #000; }
.b { display: flex; flex-direction: row; }
.c { position: absolute; margin: 1em; }
.d { float: right; }
"#;
let out = simplify_css(css);
assert!(!out.contains("@font-face"));
assert!(!out.contains("float"));
assert!(!out.contains("flex"));
assert!(!out.contains("position"));
assert!(out.contains("color: #000"));
assert!(out.contains("margin: 1em"));
// A rule left with no declarations disappears entirely.
assert!(!out.contains(".d"));
}
#[test]
fn xhtml_simplification_drops_layout_attributes_only() {
let input = r#"<p class="meta" style="float:left" align="center">a &amp; b</p><img src="x.jpg" alt="An x" width="900"/>"#;
let out = simplify_xhtml(input);
assert_eq!(
out,
r#"<p class="meta">a &amp; b</p><img src="x.jpg" alt="An x"/>"#
);
}
/// The shipped X4 stylesheet must already satisfy the X4 rules, so the
/// simplifier is a no-op over it (§3.10).
#[test]
fn the_shipped_x4_stylesheet_is_already_simplified() {
let css = super::super::build::stylesheet(crate::types::Edition::X4);
let simplified = simplify_css(css);
assert_eq!(
css.matches(';').count(),
simplified.matches(';').count(),
"the simplifier dropped a declaration from style-x4.css"
);
// Declarations only — the file's header comment mentions what it avoids.
for banned in [
"float:",
"clear:",
"display: flex",
"display: grid",
"position:",
"@font-face",
] {
assert!(!css.contains(banned), "style-x4.css must not use {banned}");
}
}
/// The firmware stops wrapping past 200 characters and the line runs off
/// the screen, so long tokens get soft hyphens (§3.10).
#[test]
fn over_long_words_are_soft_hyphenated() {
let long = "a".repeat(450);
let out = simplify_xhtml(&format!("<p>short {long} tail</p>"));
assert_eq!(out.matches(SOFT_HYPHEN).count(), 2);
// Only the long token is touched; the rest of the line is byte-identical.
assert!(out.starts_with("<p>short "));
assert!(out.ends_with(" tail</p>"));
assert!(!out.contains(&format!("short{SOFT_HYPHEN}")));
// Removing the hyphens gets the original word back — nothing was lost.
assert!(out.replace(SOFT_HYPHEN, "").contains(&long));
// Every run between hyphens is within the limit.
for run in out.replace(['<', '>'], " ").split_whitespace() {
for piece in run.split(SOFT_HYPHEN) {
assert!(piece.chars().count() <= MAX_WORD_CHARS, "{}", piece.len());
}
}
// Words at the limit are left alone.
let exact = "b".repeat(MAX_WORD_CHARS);
assert_eq!(
simplify_xhtml(&format!("<p>{exact} {exact}</p>")),
format!("<p>{exact} {exact}</p>")
);
}
/// A soft hyphen dropped into `&amp;` would turn it into literal text and
/// break the XHTML, so character references are indivisible (§3.10).
#[test]
fn entities_and_markup_survive_word_breaking() {
// 120 entities: the raw string is far past the limit, but it is only
// 120 units, so no break is due — and the entities stay intact.
let entities = "&amp;".repeat(120);
let out = simplify_xhtml(&format!("<p>{entities}</p>"));
assert!(!out.contains(SOFT_HYPHEN));
assert_eq!(out.matches("&amp;").count(), 120);
// Past the limit the breaks land between entities, never inside one.
let out = simplify_xhtml(&format!("<p>{}</p>", "&amp;".repeat(260)));
assert_eq!(out.matches("&amp;").count(), 260);
assert_eq!(out.matches(SOFT_HYPHEN).count(), 1);
assert!(!out.contains(&format!("&amp{SOFT_HYPHEN}")));
assert!(!out.contains(&format!("&{SOFT_HYPHEN}")));
// Attribute values are not text content and must not be rewritten.
let href = "https://example.com/".to_string() + &"z".repeat(300);
let out = simplify_xhtml(&format!("<p><a href=\"{href}\">link</a></p>"));
assert!(out.contains(&format!("href=\"{href}\"")), "{out}");
assert!(!out.contains(SOFT_HYPHEN));
}
/// Stylesheets and scripts are code: a soft hyphen inside one corrupts it.
#[test]
fn raw_text_elements_are_copied_through_verbatim() {
let css = format!("p{{content:\"{}\"}}", "x".repeat(400));
let out = simplify_xhtml(&format!("<style type=\"text/css\">{css}</style>"));
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!("<styled-note>{long}</styled-note>"));
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 =
"<?xml version=\"1.0\"?>\n<!DOCTYPE html>\n<html><body><p>2 &lt; 3</p></body></html>";
assert_eq!(simplify_xhtml(input), input);
}
}