Initial commit: The Daily EPUB full implementation

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

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

Full documentation of the plan is in docs/plans and setup and install
instructions are in the README.md file.
This commit is contained in:
2026-08-15 17:46:19 +00:00
commit 9e30c1dcdf
80 changed files with 27578 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
# EPUB templates
Askama templates and stylesheets for the two editions (spec §3.10, implementation
notes §11). `src/epub/build.rs` owns the structs they bind to; `askama.toml` at
the crate root points askama here (`dirs = ["src/epub/templates"]`).
| File | Template struct | Purpose |
|---|---|---|
| `base.xhtml` | — | Shared XHTML skeleton (`{% block body_class %}`, `{% block content %}`) |
| `cover_page.xhtml` | `CoverPage` | Page that displays the rasterized cover image |
| `front_page.xhtml` | `FrontPage` | "From the Editor" + issue stats line |
| `in_this_issue.xhtml` | `InThisIssue` | Introduction chapter: per-section linked index |
| `section.xhtml` | `SectionPage` | Section title page + LLM intro |
| `chapter.xhtml` | `ArticleChapter` | Article: header, body, rating/read-online footer |
| `discussion.xhtml` | `DiscussionChapter` | Comment chapter (§3.7); body from `comments::render_xhtml` |
| `world_briefing.xhtml` | `WorldBriefingChapter` | Wikipedia Current Events (§3.8), body from `world::render_xhtml` |
| `colophon.xhtml` | `ColophonChapter` | Back matter: models, cost, counts |
| `cover.svg` | `CoverSvg` | Typographic cover, rasterized with resvg + tiny-skia |
| `style.css` | — | Standard-edition stylesheet, embedded as `stylesheet.css` |
| `style-x4.css` | — | X4 stylesheet: no floats/flex/grid, no fonts, hyphenation on |
Conventions:
- Every content template `{% extends "base.xhtml" %}` and provides `title`.
- Templates declare `escape = "html"``.xhtml`/`.svg` are not in askama's
default escaper extension list. Escaping emits numeric character references
(`&`), 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 (`·`, `👍`, …).
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta charset="utf-8"/>
<title>{{ title }}</title>
<link rel="stylesheet" type="text/css" href="stylesheet.css"/>
</head>
<body>
<div class="chapter {% block body_class %}text{% endblock %}">
{% block content %}{% endblock %}
</div>
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
{% extends "base.xhtml" %}
{% block body_class %}article{% endblock %}
{% block content %}
<div class="article-header">
<h1 class="article-title">{{ article_title }}</h1>
{% if let Some(line) = byline %}
<p class="byline">{{ line }}</p>
{% endif %}
<p class="meta">{{ meta_line }}</p>
{% if let Some(line) = social_line %}
<p class="social">{{ line }}</p>
{% endif %}
{% if let Some(text) = summary %}
<p class="summary">{{ text }}</p>
{% endif %}
{% if excerpt_only %}
<p class="notice">(excerpt only &#8212; read online)</p>
{% endif %}
</div>
<hr class="rule"/>
<div class="article-body">
{{ body_html|safe }}
</div>
<hr class="rule"/>
<div class="article-footer">
{% if let Some(links) = rating %}
<p class="rating">Was this a good pick? <a href="{{ links.up_url }}">[ &#128077; Yes ]</a> &#183; <a href="{{ links.down_url }}">[ &#128078; No ]</a></p>
{% endif %}
<p class="read-online"><a href="{{ read_online_url }}">Read online &#8599;</a></p>
{% if let Some(href) = discussion_href %}
<p class="see-discussion"><a href="{{ href }}">&#128172; Read the discussion</a></p>
{% endif %}
</div>
{% endblock %}
+25
View File
@@ -0,0 +1,25 @@
{% extends "base.xhtml" %}
{% block body_class %}colophon{% endblock %}
{% block content %}
<h1>Colophon</h1>
<p>
<em>The Daily EPUB</em> is assembled every morning from a personal Miniflux
feed reader: entries are deduplicated, read in full, weighed against social
proof, then scored, sectioned and introduced by a language model.
</p>
<dl class="colophon-facts">
<dt class="fact-key">Issue</dt><dd class="fact-value">No. {{ issue_number }} &#183; {{ display_date }}</dd>
<dt class="fact-key">Generated</dt><dd class="fact-value">{{ generated_at }}</dd>
<dt class="fact-key">Curation model</dt><dd class="fact-value">{{ model }}</dd>
<dt class="fact-key">Entries considered</dt><dd class="fact-value">{{ entries_fetched }} from {{ feeds_seen }} feeds</dd>
<dt class="fact-key">Candidates scored</dt><dd class="fact-value">{{ candidates }}</dd>
<dt class="fact-key">Articles selected</dt><dd class="fact-value">{{ article_count }} across {{ section_count }} sections</dd>
<dt class="fact-key">Words</dt><dd class="fact-value">{{ total_words }} &#183; {{ reading_line }}</dd>
<dt class="fact-key">Token cost</dt><dd class="fact-value">{{ cost_usd }}</dd>
<dt class="fact-key">Generator</dt><dd class="fact-value">{{ generator_version }}</dd>
</dl>
<p class="attribution">
Article text belongs to its authors and publications; excerpts and links are
provided for personal reading. Comment excerpts belong to their posters.
</p>
{% endblock %}
+25
View File
@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="{{ width }}" height="{{ height }}" viewBox="0 0 {{ width }} {{ height }}">
<rect x="0" y="0" width="{{ width }}" height="{{ height }}" fill="#ffffff"/>
<rect x="{{ margin }}" y="{{ margin }}" width="{{ inner_width }}" height="{{ inner_height }}"
fill="none" stroke="#111111" stroke-width="{{ border }}"/>
<text x="{{ center_x }}" y="{{ masthead_y }}" text-anchor="middle" fill="#111111"
font-family="Georgia, 'Times New Roman', Times, serif" font-size="{{ masthead_size }}">The Daily EPUB</text>
<line x1="{{ rule_x1 }}" y1="{{ rule_y }}" x2="{{ rule_x2 }}" y2="{{ rule_y }}" stroke="#111111" stroke-width="{{ border }}"/>
<text x="{{ center_x }}" y="{{ weekday_y }}" text-anchor="middle" fill="#111111"
font-family="Georgia, 'Times New Roman', Times, serif" font-size="{{ weekday_size }}">{{ weekday }}</text>
<text x="{{ center_x }}" y="{{ date_y }}" text-anchor="middle" fill="#111111"
font-family="Georgia, 'Times New Roman', Times, serif" font-size="{{ date_size }}">{{ long_date }}</text>
<line x1="{{ rule_x1 }}" y1="{{ rule2_y }}" x2="{{ rule_x2 }}" y2="{{ rule2_y }}" stroke="#111111" stroke-width="{{ hairline }}"/>
<text x="{{ center_x }}" y="{{ issue_y }}" text-anchor="middle" fill="#111111"
font-family="Georgia, 'Times New Roman', Times, serif" font-size="{{ issue_size }}">No. {{ issue_number }}</text>
<text x="{{ center_x }}" y="{{ stats_y }}" text-anchor="middle" fill="#111111"
font-family="Georgia, 'Times New Roman', Times, serif" font-size="{{ stats_size }}">{{ stats_line }}</text>
{% if !edition_tag.is_empty() %}
<rect x="{{ badge_x }}" y="{{ badge_y }}" width="{{ badge_width }}" height="{{ badge_height }}"
rx="{{ badge_radius }}" fill="#111111"/>
<text x="{{ center_x }}" y="{{ badge_text_y }}" text-anchor="middle" fill="#ffffff"
font-family="Georgia, 'Times New Roman', Times, serif" font-size="{{ badge_size }}">{{ edition_tag }}</text>
{% endif %}
<text x="{{ center_x }}" y="{{ footer_y }}" text-anchor="middle" fill="#111111"
font-family="Georgia, 'Times New Roman', Times, serif" font-size="{{ footer_size }}">{{ footer }}</text>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+5
View File
@@ -0,0 +1,5 @@
{% extends "base.xhtml" %}
{% block body_class %}cover-page{% endblock %}
{% block content %}
<div class="cover-image"><img src="cover.png" alt="{{ alt }}"/></div>
{% endblock %}
+8
View File
@@ -0,0 +1,8 @@
{% extends "base.xhtml" %}
{% block body_class %}discussion{% endblock %}
{% block content %}
<h1 class="discussion-title">{{ heading }}</h1>
<p class="discussion-note">Selected threads, truncated for reading on e-ink.</p>
{{ body_html|safe }}
<p class="back-link"><a href="{{ article_href }}">&#8617; Back to the article</a></p>
{% endblock %}
+12
View File
@@ -0,0 +1,12 @@
{% extends "base.xhtml" %}
{% block body_class %}front-page{% endblock %}
{% block content %}
<h1 class="masthead">The Daily EPUB</h1>
<p class="dateline">{{ display_date }} &#183; No. {{ issue_number }}</p>
<hr class="rule"/>
<h2 class="kicker">From the Editor</h2>
<div class="editorial">
{{ body_html|safe }}
</div>
<p class="stats">{{ stats_line }}</p>
{% endblock %}
+20
View File
@@ -0,0 +1,20 @@
{% extends "base.xhtml" %}
{% block body_class %}in-this-issue{% endblock %}
{% block content %}
<h1>In This Issue</h1>
<p class="stats">{{ stats_line }}</p>
{% for section in sections %}
<h2 class="index-section">{{ section.name }}</h2>
<ul class="index-list">
{% for entry in section.entries %}
<li class="index-entry">
<p class="index-title"><a href="{{ entry.href }}">{{ entry.title }}</a></p>
<p class="index-meta">{{ entry.source }} &#183; {{ entry.reading_minutes }} min read</p>
{% if !entry.summary.is_empty() %}
<p class="index-summary">{{ entry.summary }}</p>
{% endif %}
</li>
{% endfor %}
</ul>
{% endfor %}
{% endblock %}
+9
View File
@@ -0,0 +1,9 @@
{% extends "base.xhtml" %}
{% block body_class %}section-page{% endblock %}
{% block content %}
<h1 class="section-title">{{ name }}</h1>
<hr class="rule"/>
{% if let Some(text) = intro %}
<p class="section-intro">{{ text }}</p>
{% endif %}
{% endblock %}
+188
View File
@@ -0,0 +1,188 @@
/* Xteink X4 stylesheet (spec §3.10 "X4 edition").
No floats, no flex, no grid, no embedded fonts, larger base font,
generous line-height, hyphenation on. 480x800, 2-bit grayscale.
Selectors are `tag`, `.class` and `tag.class` only — the X4 firmware's CSS
engine does not support descendant combinators, so a rule like
`.comment-body p` is silently dropped on the device. Where a tag rule needs
an exception, the `tag.class` override follows it immediately so the result
is right whether the engine resolves by specificity or by source order. */
@page {
margin: 0.4em;
}
body {
font-family: serif;
font-size: 1.2em;
line-height: 1.7;
margin: 0 0.5em;
text-align: left;
hyphens: auto;
-webkit-hyphens: auto;
adobe-hyphenate: auto;
word-wrap: break-word;
}
.chapter {
page-break-before: always;
break-before: page;
}
h1, h2, h3, h4 {
font-weight: bold;
line-height: 1.3;
page-break-after: avoid;
break-after: avoid;
margin: 0.6em 0 0.35em 0;
hyphens: none;
-webkit-hyphens: none;
}
h1 { font-size: 1.35em; }
h2 { font-size: 1.15em; }
h3, h4 { font-size: 1em; }
p {
margin: 0 0 0.6em 0;
}
a {
color: #000000;
text-decoration: underline;
}
hr.rule {
border: 0;
border-top: 1px solid #000000;
margin: 0.7em 0;
height: 0;
}
img {
max-width: 100%;
height: auto;
}
.cover-page {
text-align: center;
margin: 0;
}
.masthead {
font-size: 1.6em;
text-align: center;
}
.dateline,
.stats,
.meta,
.social,
.index-meta,
.discussion-note,
.attribution,
.comment-meta {
font-size: 0.85em;
}
.dateline,
.stats {
text-align: center;
}
.section-page {
text-align: center;
}
.section-title {
font-size: 1.5em;
margin-top: 1.5em;
}
.section-intro,
.summary,
.byline {
font-style: italic;
}
ul, ol {
margin: 0 0 0.5em 1em;
padding: 0;
}
ul.index-list {
list-style: none;
margin: 0;
padding: 0;
}
.index-entry {
margin: 0 0 0.8em 0;
}
.index-title,
.index-meta,
.index-summary {
margin: 0;
}
figure {
margin: 0.6em 0;
text-align: center;
}
figcaption,
.image-caption,
.image-placeholder {
font-size: 0.85em;
font-style: italic;
text-align: center;
}
pre, code {
font-family: monospace;
font-size: 0.85em;
white-space: pre-wrap;
word-wrap: break-word;
hyphens: none;
-webkit-hyphens: none;
}
table {
border-collapse: collapse;
font-size: 0.85em;
}
td, th {
border: 1px solid #666666;
padding: 0.15em 0.3em;
}
blockquote {
margin: 0.5em 0 0.5em 0.3em;
padding-left: 0.5em;
border-left: 2px solid #666666;
}
blockquote.comment {
margin: 0.4em 0;
padding-left: 0.5em;
border-left: 2px solid #666666;
}
blockquote.reply {
border-left: 1px solid #999999;
}
p.comment-line {
margin: 0 0 0.35em 0;
}
dt.fact-key {
font-weight: bold;
margin-top: 0.35em;
}
dd.fact-value {
margin: 0 0 0 0.8em;
}
+290
View File
@@ -0,0 +1,290 @@
/* Standard-edition stylesheet (spec §3.10 "CSS").
Serif body, grayscale only, page-break-before on chapters,
blockquote-indent comment styling. Tuned for e-ink readers. */
@page {
margin: 1em;
}
body {
font-family: Georgia, "Times New Roman", Times, serif;
font-size: 1em;
line-height: 1.5;
margin: 0 1em;
text-align: left;
widows: 2;
orphans: 2;
}
.chapter {
page-break-before: always;
break-before: page;
}
h1, h2, h3, h4 {
font-weight: normal;
line-height: 1.25;
page-break-after: avoid;
break-after: avoid;
margin: 0.8em 0 0.4em 0;
}
h1 { font-size: 1.5em; }
h2 { font-size: 1.25em; }
h3 { font-size: 1.1em; }
h4 { font-size: 1em; font-style: italic; }
p {
margin: 0 0 0.7em 0;
}
a {
color: #000000;
text-decoration: underline;
}
hr.rule {
border: 0;
border-top: 1px solid #000000;
margin: 0.9em 0;
height: 0;
}
/* --- cover ------------------------------------------------------------- */
.cover-page {
text-align: center;
margin: 0;
padding: 0;
}
.cover-image img {
max-width: 100%;
max-height: 100%;
}
/* --- front page -------------------------------------------------------- */
.masthead {
font-size: 2.1em;
text-align: center;
letter-spacing: 0.02em;
margin-bottom: 0.1em;
}
.dateline {
text-align: center;
font-size: 0.9em;
font-variant: small-caps;
margin-bottom: 0.6em;
}
.kicker {
font-variant: small-caps;
text-align: center;
}
.stats {
font-size: 0.85em;
font-style: italic;
text-align: center;
margin-top: 1em;
}
/* --- in this issue ----------------------------------------------------- */
.index-list {
list-style: none;
margin: 0;
padding: 0;
}
.index-entry {
margin: 0 0 0.9em 0;
page-break-inside: avoid;
break-inside: avoid;
}
.index-title {
margin: 0;
font-size: 1.05em;
}
.index-meta {
margin: 0;
font-size: 0.8em;
font-variant: small-caps;
}
.index-summary {
margin: 0.2em 0 0 0;
font-size: 0.9em;
}
/* --- sections and articles --------------------------------------------- */
.section-page {
text-align: center;
}
.section-title {
font-size: 2em;
font-variant: small-caps;
margin-top: 2.5em;
}
.section-intro {
font-style: italic;
margin: 0 1.5em;
}
.article-title {
font-size: 1.6em;
margin-bottom: 0.2em;
}
.byline {
margin: 0;
font-style: italic;
}
.meta, .social {
margin: 0;
font-size: 0.8em;
font-variant: small-caps;
}
.summary {
margin: 0.5em 0 0 0;
font-style: italic;
}
.notice {
font-size: 0.85em;
font-style: italic;
}
.article-body img {
max-width: 100%;
height: auto;
}
.article-body figure {
margin: 0.8em 0;
text-align: center;
page-break-inside: avoid;
break-inside: avoid;
}
.article-body figcaption,
.image-caption {
font-size: 0.8em;
font-style: italic;
text-align: center;
}
.image-placeholder {
font-size: 0.85em;
font-style: italic;
color: #444444;
}
.article-body blockquote {
margin: 0.6em 0 0.6em 1em;
padding-left: 0.6em;
border-left: 2px solid #999999;
font-style: italic;
}
.article-body pre,
.article-body code {
font-family: "DejaVu Sans Mono", "Courier New", monospace;
font-size: 0.85em;
}
.article-body pre {
white-space: pre-wrap;
word-wrap: break-word;
border-left: 2px solid #cccccc;
padding-left: 0.5em;
}
.article-body table {
border-collapse: collapse;
font-size: 0.85em;
}
.article-body td,
.article-body th {
border: 1px solid #999999;
padding: 0.2em 0.4em;
}
.article-footer {
font-size: 0.9em;
}
.rating a {
text-decoration: none;
}
/* --- discussion chapters (§3.7) ---------------------------------------- */
.discussion-note {
font-size: 0.8em;
font-style: italic;
}
.discussion-source {
font-variant: small-caps;
font-size: 1.1em;
margin-top: 1em;
}
blockquote.comment {
border-left: 2px solid #888888;
margin: 0.5em 0 0.5em 0;
padding-left: 0.7em;
page-break-inside: avoid;
break-inside: avoid;
}
blockquote.comment blockquote.comment {
border-left: 1px solid #aaaaaa;
margin-left: 0.2em;
}
.comment-meta {
font-size: 0.78em;
font-variant: small-caps;
margin: 0 0 0.15em 0;
}
.comment-body p {
margin: 0 0 0.4em 0;
}
/* --- world briefing and colophon --------------------------------------- */
.world-body ul {
margin: 0 0 0.6em 1.1em;
padding: 0;
}
.world-body li {
margin-bottom: 0.25em;
}
.attribution {
font-size: 0.8em;
font-style: italic;
}
.colophon-facts dt {
font-variant: small-caps;
margin-top: 0.4em;
}
.colophon-facts dd {
margin: 0 0 0 1em;
}
+10
View File
@@ -0,0 +1,10 @@
{% extends "base.xhtml" %}
{% block body_class %}world-briefing{% endblock %}
{% block content %}
<h1>World Briefing</h1>
<p class="dateline">{{ display_date }}</p>
<hr class="rule"/>
<div class="world-body">
{{ body_html|safe }}
</div>
{% endblock %}