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:
+1440
File diff suppressed because it is too large
Load Diff
@@ -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("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
_ => 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("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
_ => 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 ` ` 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 ` `, which is undefined in XML.
|
||||
out.replace(" ", " ")
|
||||
}
|
||||
|
||||
#[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 & 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; 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 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(" "));
|
||||
assert!(!out.contains(" "));
|
||||
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
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
(`&`), 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, ` ` → ` `) 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 (`·`, `👍`, …).
|
||||
@@ -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>
|
||||
@@ -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 — 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 }}">[ 👍 Yes ]</a> · <a href="{{ links.down_url }}">[ 👎 No ]</a></p>
|
||||
{% endif %}
|
||||
<p class="read-online"><a href="{{ read_online_url }}">Read online ↗</a></p>
|
||||
{% if let Some(href) = discussion_href %}
|
||||
<p class="see-discussion"><a href="{{ href }}">💬 Read the discussion</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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 }} · {{ 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 }} · {{ 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 %}
|
||||
@@ -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 |
@@ -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 %}
|
||||
@@ -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 }}">↩ Back to the article</a></p>
|
||||
{% endblock %}
|
||||
@@ -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 }} · 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 %}
|
||||
@@ -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 }} · {{ entry.reading_minutes }} min read</p>
|
||||
{% if !entry.summary.is_empty() %}
|
||||
<p class="index-summary">{{ entry.summary }}</p>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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 `&` down the middle would
|
||||
/// turn it into literal text and break the XHTML.
|
||||
fn entity_len(s: &str) -> Option<usize> {
|
||||
/// `≈` 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 & b</p><img src="x.jpg" alt="An x" width="900"/>"#;
|
||||
let out = simplify_xhtml(input);
|
||||
assert_eq!(
|
||||
out,
|
||||
r#"<p class="meta">a & 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 `&` 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 = "&".repeat(120);
|
||||
let out = simplify_xhtml(&format!("<p>{entities}</p>"));
|
||||
assert!(!out.contains(SOFT_HYPHEN));
|
||||
assert_eq!(out.matches("&").count(), 120);
|
||||
|
||||
// Past the limit the breaks land between entities, never inside one.
|
||||
let out = simplify_xhtml(&format!("<p>{}</p>", "&".repeat(260)));
|
||||
assert_eq!(out.matches("&").count(), 260);
|
||||
assert_eq!(out.matches(SOFT_HYPHEN).count(), 1);
|
||||
assert!(!out.contains(&format!("&{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 < 3</p></body></html>";
|
||||
assert_eq!(simplify_xhtml(input), input);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user