diff --git a/README.md b/README.md index 1dd8dfb..03b5971 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,8 @@ Steady-state cost is roughly **$0.05–0.30/day** in DeepSeek tokens, hard-cappe ``` Miniflux ingest ─▶ dedupe ─▶ extraction ─▶ persist ─▶ social enrichment - ─▶ pre-filter ─▶ LLM scoring ─▶ selection ─▶ comments ─▶ world briefing - ─▶ editorial ─▶ EPUB (standard + X4) ─▶ XTC ─▶ publish ─▶ report + ─▶ pre-filter ─▶ LLM scoring ─▶ selection ─▶ comments ─▶ editorial + ─▶ world briefing ─▶ EPUB (standard + X4) ─▶ XTC ─▶ publish ─▶ report ``` Every stage writes to SQLite, so a run is idempotent per date: re-running @@ -380,7 +380,8 @@ from what you see in step 8. | `Font path is required` for a settings file that *does* set `font.path` | The process cannot read the file, and the converter cannot tell that apart from the file not existing. Almost always running the converter as yourself instead of `daily-epub` (see above), or a font path that has moved. `sudo -u daily-epub cat /etc/daily-epub/xtc-settings.json` and `sudo -u daily-epub test -r && echo ok` settle it. | | The X4's OPDS browser says "No entries found" | It fetched and parsed the feed but accepted no entry. Every acquisition link must be typed exactly `application/epub+zip`; anything else is dropped silently. `curl -s -u user:pass https://daily.hallada.net/opds/daily.xml \| grep -c ""` — zero means nothing has been published yet. | | The X4's OPDS browser says "Failed to fetch feed" | The request never completed: wrong URL, TLS, or credentials. "Failed to parse feed" means malformed XML. The three messages are distinct — read which one you got. | -| No World Briefing | The portal page for the issue's own date is an empty stub until midday UTC, so the run falls back up to `world::MAX_LOOKBACK_DAYS` days. A warning means even those were empty or Wikipedia was unreachable. | +| No World Briefing | Retrieval begins with the previous calendar day (the latest completed page) and falls back up to `world::MAX_LOOKBACK_DAYS` days. A warning means those pages were empty or Wikipedia was unreachable. | +| An X4 cover still shows an old black band after regeneration | CrossInk caches its generated home-screen thumbnail under the EPUB path. Delete that book's cache before retesting the same filename, reopen the EPUB, then return to the Minimal home screen. | --- diff --git a/src/epub/build.rs b/src/epub/build.rs index 88b5702..9793808 100644 --- a/src/epub/build.rs +++ b/src/epub/build.rs @@ -27,8 +27,19 @@ pub const CREATOR: &str = "The Daily EPUB"; pub const LANGUAGE: &str = "en"; /// Characters of the hex HMAC kept in rating links (§3.9). pub const TOKEN_LEN: usize = crate::auth::TOKEN_LEN; -/// Cover image path inside the EPUB. -pub const COVER_HREF: &str = "cover.png"; +#[derive(Debug, Clone, PartialEq)] +pub struct CoverAsset { + pub bytes: Vec, + pub filename: &'static str, + pub mime: &'static str, +} + +pub fn cover_href(edition: Edition) -> &'static str { + match edition { + Edition::Standard => "cover.png", + Edition::X4 => "cover.jpg", + } +} /// One rendered chapter ready to be added to the EPUB (§3.10). #[derive(Debug, Clone, PartialEq)] @@ -103,6 +114,7 @@ struct CoverSvg { struct CoverPage { title: String, alt: String, + cover_href: &'static str, } #[derive(Template)] @@ -298,21 +310,27 @@ fn cover_svg( Ok(tpl.render()?) } -/// Render the cover SVG and rasterize it with `resvg` + `tiny-skia`: -/// 1200×1600 for `Standard`, 480×800 grayscale for `X4` (§3.10). -pub fn render_cover(issue: &Issue, edition: Edition) -> Result, EpubError> { +/// Render the standard cover as RGB PNG and the X4 cover as baseline RGB JPEG. +pub fn render_cover(issue: &Issue, edition: Edition) -> Result { let (width, height) = cover_size(edition); - let grayscale = edition == Edition::X4; let svg = cover_svg(issue, edition, width, height)?; - match rasterize(&svg, width, height) { - Some(pixmap) => encode_cover(pixmap, grayscale), + let pixmap = match rasterize(&svg, width, height) { + Some(pixmap) => pixmap, None => { tracing::warn!("no usable system fonts: falling back to a geometric cover"); - let pixmap = draw_fallback_cover(width, height, edition) - .ok_or_else(|| EpubError::Build("could not allocate the cover".into()))?; - encode_cover(pixmap, grayscale) + draw_fallback_cover(width, height, edition) + .ok_or_else(|| EpubError::Build("could not allocate the cover".into()))? } - } + }; + Ok(CoverAsset { + bytes: encode_cover(pixmap, edition)?, + filename: cover_href(edition), + mime: if edition == Edition::X4 { + "image/jpeg" + } else { + "image/png" + }, + }) } fn rasterize(svg: &str, width: u32, height: u32) -> Option { @@ -410,21 +428,22 @@ fn draw_fallback_cover(width: u32, height: u32, edition: Edition) -> Option Result, EpubError> { +fn encode_cover(pixmap: tiny_skia::Pixmap, edition: Edition) -> Result, EpubError> { let (w, h) = (pixmap.width(), pixmap.height()); let rgba = image::RgbaImage::from_raw(w, h, pixmap.take_demultiplied()) .ok_or_else(|| EpubError::Build("cover pixel buffer had the wrong size".into()))?; - let dynamic = image::DynamicImage::ImageRgba8(rgba); - let dynamic = if grayscale { - image::DynamicImage::ImageLuma8(dynamic.to_luma8()) - } else { - image::DynamicImage::ImageRgb8(dynamic.to_rgb8()) - }; - let mut out = std::io::Cursor::new(Vec::new()); - dynamic - .write_to(&mut out, image::ImageFormat::Png) - .map_err(|e| EpubError::Build(format!("cover encoding failed: {e}")))?; - Ok(out.into_inner()) + let rgb = image::DynamicImage::ImageRgba8(rgba).to_rgb8(); + let mut bytes = Vec::new(); + match edition { + Edition::Standard => image::DynamicImage::ImageRgb8(rgb).write_to( + &mut std::io::Cursor::new(&mut bytes), + image::ImageFormat::Png, + ), + Edition::X4 => image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, 92) + .encode_image(&image::DynamicImage::ImageRgb8(rgb)), + } + .map_err(|e| EpubError::Build(format!("cover encoding failed: {e}")))?; + Ok(bytes) } // --------------------------------------------------------------------------- @@ -759,6 +778,7 @@ fn render_cover_page(issue: &Issue, edition: Edition) -> Result MetadataOpfV3 { "dcterms:date", &format!( "{date}\n {date}\n \ + {LANGUAGE}\n \ {date}" ), None, @@ -889,7 +910,7 @@ pub fn assemble( edition: Edition, chapters: &[Chapter], images_: &[ImageAsset], - cover_png: &[u8], + cover: &CoverAsset, ) -> Result, EpubError> { let zip = ZipLibrary::new().map_err(|e| epub_err("zip library", e))?; let mut builder = EpubBuilder::new(zip).map_err(|e| epub_err("epub builder", e))?; @@ -900,9 +921,6 @@ pub fn assemble( builder .metadata("author", CREATOR) .map_err(|e| epub_err("author metadata", e))?; - builder - .metadata("lang", LANGUAGE) - .map_err(|e| epub_err("lang metadata", e))?; builder .metadata( "generator", @@ -948,7 +966,7 @@ pub fn assemble( .stylesheet(stylesheet(edition).as_bytes()) .map_err(|e| epub_err("stylesheet", e))?; builder - .add_cover_image(COVER_HREF, cover_png, "image/png") + .add_cover_image(cover.filename, cover.bytes.as_slice(), cover.mime) .map_err(|e| epub_err("cover image", e))?; for asset in images_ { builder @@ -1104,7 +1122,17 @@ pub mod fixtures { date: "2026-08-15".parse().expect("fixed date"), source_url: "https://en.wikipedia.org/wiki/Portal:Current_events/2026_August_15" .into(), - body_html: "
  • Something happened somewhere.
".into(), + overview: Some("A concise view of the day.".into()), + sections: vec![WorldBriefingSection { + title: "Top Stories".into(), + events: vec![WorldEvent { + id: "s1-e1".into(), + source_text: "Something happened somewhere.".into(), + links: vec![], + children: vec![], + summary: Some("The event in context.".into()), + }], + }], }), colophon: Colophon { model: "deepseek-v4-flash".into(), @@ -1363,22 +1391,39 @@ mod tests { fn covers_rasterize_for_both_editions() { let issue = issue(); for edition in [Edition::Standard, Edition::X4] { - let png = render_cover(&issue, edition).expect("cover"); - let decoded = image::load_from_memory(&png).expect("cover is a valid png"); + let cover = render_cover(&issue, edition).expect("cover"); + let decoded = image::load_from_memory(&cover.bytes).expect("cover is a valid image"); assert_eq!( (decoded.width(), decoded.height()), cover_size(edition), "cover size for {edition:?}" ); + assert_eq!(decoded.color(), image::ColorType::Rgb8); if edition == Edition::X4 { - assert_eq!(decoded.color(), image::ColorType::L8); + assert_eq!(cover.filename, "cover.jpg"); + assert_eq!(cover.mime, "image/jpeg"); + assert!( + cover.bytes.windows(2).any(|marker| marker == [0xff, 0xc0]), + "baseline SOF0 missing" + ); + assert!( + !cover.bytes.windows(2).any(|marker| marker == [0xff, 0xc2]), + "progressive SOF2 present" + ); + } else { + assert_eq!(cover.filename, "cover.png"); + assert_eq!(cover.mime, "image/png"); } } } #[test] fn fallback_cover_is_drawn_without_fonts() { - let png = encode_cover(draw_fallback_cover(480, 800, Edition::X4).unwrap(), true).unwrap(); + let png = encode_cover( + draw_fallback_cover(480, 800, Edition::X4).unwrap(), + Edition::X4, + ) + .unwrap(); let decoded = image::load_from_memory(&png).unwrap(); assert_eq!((decoded.width(), decoded.height()), (480, 800)); // Some ink actually landed on the page. @@ -1426,8 +1471,8 @@ mod tests { assert_eq!(standard.matches("fill=\"#ffffff\"").count(), 1); // The badge sits between the stats line and the footer, inside the frame. - let png = render_cover(&issue, Edition::X4).unwrap(); - let gray = image::load_from_memory(&png).unwrap().to_luma8(); + let cover = render_cover(&issue, Edition::X4).unwrap(); + let gray = image::load_from_memory(&cover.bytes).unwrap().to_luma8(); let dark_in_badge = (584..634) .flat_map(|y| (144..336).map(move |x| (x, y))) .filter(|&(x, y)| gray.get_pixel(x, y)[0] < 32) diff --git a/src/epub/mod.rs b/src/epub/mod.rs index 75fa8e4..24a88e5 100644 --- a/src/epub/mod.rs +++ b/src/epub/mod.rs @@ -182,7 +182,6 @@ mod tests { "OEBPS/toc.ncx", "OEBPS/nav.xhtml", "OEBPS/stylesheet.css", - "OEBPS/cover.png", "OEBPS/cover.xhtml", "OEBPS/front.xhtml", "OEBPS/in-this-issue.xhtml", @@ -197,6 +196,11 @@ mod tests { "missing {entry} in {edition:?}" ); } + let cover_entry = match edition { + Edition::Standard => "OEBPS/cover.png", + Edition::X4 => "OEBPS/cover.jpg", + }; + assert!(contains_entry(&zip, cover_entry), "missing {cover_entry}"); // No leftover temp file. assert!( !dir.path() diff --git a/src/epub/templates/colophon.xhtml b/src/epub/templates/colophon.xhtml index a8a488c..441f8d3 100644 --- a/src/epub/templates/colophon.xhtml +++ b/src/epub/templates/colophon.xhtml @@ -7,17 +7,15 @@ feed reader: entries are deduplicated, read in full, weighed against social proof, then scored, sectioned and introduced by a language model.

-
-
Issue
No. {{ issue_number }} · {{ display_date }}
-
Generated
{{ generated_at }}
-
Curation model
{{ model }}
-
Entries considered
{{ entries_fetched }} from {{ feeds_seen }} feeds
-
Candidates scored
{{ candidates }}
-
Articles selected
{{ article_count }} across {{ section_count }} sections
-
Words
{{ total_words }} · {{ reading_line }}
-
Token cost
{{ cost_usd }}
-
Generator
{{ generator_version }}
-
+

Issue: No. {{ issue_number }} · {{ display_date }}

+

Generated: {{ generated_at }}

+

Curation model: {{ model }}

+

Entries considered: {{ entries_fetched }} from {{ feeds_seen }} feeds

+

Candidates scored: {{ candidates }}

+

Articles selected: {{ article_count }} across {{ section_count }} sections

+

Words: {{ total_words }} · {{ reading_line }}

+

Token cost: {{ cost_usd }}

+

Generator: {{ generator_version }}

Article text belongs to its authors and publications; excerpts and links are provided for personal reading. Comment excerpts belong to their posters. diff --git a/src/epub/templates/cover_page.xhtml b/src/epub/templates/cover_page.xhtml index 8489323..9717a0c 100644 --- a/src/epub/templates/cover_page.xhtml +++ b/src/epub/templates/cover_page.xhtml @@ -1,5 +1,5 @@ {% extends "base.xhtml" %} {% block body_class %}cover-page{% endblock %} {% block content %} -

{{ alt }}
+
{{ alt }}
{% endblock %} diff --git a/src/epub/templates/style-x4.css b/src/epub/templates/style-x4.css index 38b7556..5c858f3 100644 --- a/src/epub/templates/style-x4.css +++ b/src/epub/templates/style-x4.css @@ -178,11 +178,15 @@ p.comment-line { margin: 0 0 0.35em 0; } -dt.fact-key { - font-weight: bold; - margin-top: 0.35em; +.world-summary { + margin: 0.2em 0 0.45em 0; + font-style: italic; } -dd.fact-value { - margin: 0 0 0 0.8em; +.world-overview { + margin-bottom: 0.7em; +} + +.fact-line { + margin: 0 0 0.35em 0; } diff --git a/src/epub/templates/style.css b/src/epub/templates/style.css index 48b0a95..3ff1c77 100644 --- a/src/epub/templates/style.css +++ b/src/epub/templates/style.css @@ -275,16 +275,20 @@ blockquote.comment blockquote.comment { margin-bottom: 0.25em; } +.world-summary { + margin: 0.2em 0 0.45em 0; + font-style: italic; +} + +.world-overview { + margin-bottom: 0.7em; +} + .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; +.fact-line { + margin: 0 0 0.35em 0; } diff --git a/src/lib.rs b/src/lib.rs index 63f15f7..c74f837 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,8 +8,8 @@ //! //! ```text //! Miniflux ingest → dedupe → extraction → persist → social enrichment -//! → pre-filter → LLM scoring → selection → comments → world briefing -//! → editorial → EPUB build (standard + X4) → XTC → publish → report +//! → pre-filter → LLM scoring → selection → comments → editorial +//! → world briefing → EPUB build (standard + X4) → XTC → publish → report //! ``` pub mod auth; diff --git a/src/pipeline.rs b/src/pipeline.rs index 664a008..06b4d36 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -2,8 +2,8 @@ //! //! ```text //! Miniflux ingest ─▶ dedupe ─▶ extraction ─▶ persist ─▶ social enrichment -//! ─▶ pre-filter ─▶ LLM scoring ─▶ selection ─▶ comments ─▶ world briefing -//! ─▶ editorial ─▶ EPUB (standard + X4) ─▶ XTC ─▶ publish ─▶ report +//! ─▶ pre-filter ─▶ LLM scoring ─▶ selection ─▶ comments ─▶ editorial +//! ─▶ world briefing ─▶ EPUB (standard + X4) ─▶ XTC ─▶ publish ─▶ report //! ``` //! //! Failure policy (notes §3): @@ -422,15 +422,7 @@ async fn run_stages( report.counts.discussions = comments::fetch_all(&http, &mut lineup.picks).await as i64; report.timings.record("comments", elapsed_ms(stage)); - // --- Stage 9: world briefing (§3.8) — non-fatal by construction --- - let stage = Timestamp::now(); - let world_briefing = world::fetch_optional(&http, date, config.world_briefing).await; - if config.world_briefing && world_briefing.is_none() { - report.warn("the world briefing was unavailable; the section is omitted"); - } - report.timings.record("world", elapsed_ms(stage)); - - // --- Stage 10: editorial (§3.6 C) --- + // --- Stage 9: editorial (§3.6 C) --- let stage = Timestamp::now(); let editorial = match curator.editorial(&lineup).await { Ok(editorial) => editorial, @@ -444,6 +436,23 @@ async fn run_stages( apply_summaries(&mut lineup, &editorial); report.timings.record("editorial", elapsed_ms(stage)); + // --- Stage 10: completed-day World Briefing (§3.8), best effort --- + // Editorial retains budget priority; only the remaining metered budget is + // available for per-event summaries and the overview. + let stage = Timestamp::now(); + let mut world_briefing = world::fetch_optional(&http, date, config.world_briefing).await; + if config.world_briefing { + match world_briefing.as_mut() { + Some(briefing) => { + for warning in world::enrich(&http, briefing, curator.llm.as_ref()).await { + report.warn(warning); + } + } + None => report.warn("the world briefing was unavailable; the section is omitted"), + } + } + report.timings.record("world", elapsed_ms(stage)); + // --- Stage 11: assemble the issue (§3.10) --- let issue_number = db .next_issue_number(date) diff --git a/src/types.rs b/src/types.rs index dfdcaac..14f1e77 100644 --- a/src/types.rs +++ b/src/types.rs @@ -399,8 +399,29 @@ pub struct WorldBriefing { pub date: Date, /// Portal URL the content came from (also used for CC BY-SA attribution). pub source_url: String, - /// Sanitized `