diff --git a/.gitignore b/.gitignore index ebc6e0b..96e9a4f 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,11 @@ coverage.xml output/ *.log +# Demo photo dumps — examples/*/photos/ hold personal images extracted +# locally from source HTML and must not land in a public repo. +examples/*/photos/* +!examples/*/photos/.gitkeep + # Editors .idea/ .vscode/ diff --git a/CHANGELOG.md b/CHANGELOG.md index af87f0b..37ac10c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,49 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Changed +- **Editorial style — magazine-grade redesign.** Same `editorial` Style + enum value, same renderer entrypoint, same `NarrativeOutput` contract; + the template is rewritten head-to-toe. New visual identity: oklch + paper/ink tokens (no accent colors, no gradients, no rounded corners), + Source Serif 4 italic-top / roman-bottom display title, drop cap on + the first paragraph, two-column desktop grid with marginalia sidebar + (THE FACTS / THE PATH / ELEVATION), mono "eyebrow" labels, hairline + rules, photo float-right + mid-body full-bleed + after-quote variants, + reading-progress bar, 3-way EN/RU/DE toggle with keyboard shortcuts + (`←/→` cycles language, `J/K` jumps paragraph, `?` opens a + cheatsheet), `localStorage` language persistence, soft fade swap with + paragraph-anchored scroll preservation, photo blur-up on load, print + rules, `::selection` style. The `log` and `encyclopedia` styles are + untouched. +- **Marginalia is statically positioned** in the editorial style (no + `position: sticky`). Sticky behaviour interacts badly with headless + PDF capture (Puppeteer, wkhtmltopdf) and with the user's primary + share path, which is "save the page, send the file." Static keeps + print-to-PDF and any headless renderer producing the same layout the + user sees on screen. + ### Added +- **Editorial fonts embedded as base64 WOFF2** under + `templates/fonts/editorial/`. Six subsets — Source Serif 4 italic + + roman variable axes × latin + cyrillic, plus JetBrains Mono variable + × latin + cyrillic, ~476 KB on disk. Loaded into the template context + by a new `_editorial_fonts()` helper in + `trailstory.renderers.html` (memoised with `lru_cache`, only invoked + when `memory.style == Style.editorial`) so the rendered memory page + carries its own typography and works fully offline — honors ADR-001's + "single self-contained HTML, no CDN" guarantee. Newsreader (the + family from the original design brief) ships no Cyrillic subset on + Google Fonts, so Source Serif 4 stands in: same `opsz` variable + axis, same italic + roman pair, full Latin + Cyrillic coverage. + License notes in `templates/fonts/editorial/LICENSE.md`. +- **`examples/wax_saints/` demo render.** Standalone script that + extracts photos from a local source HTML, deduplicates by SHA-256, + fabricates a small GPX track, and renders the editorial template + against the example's tri-lingual text. Useful for dogfooding + template changes without paying for a live LLM call. The `photos/` + directory is gitignored — personal images do not land in a public + repo. - **Per-IP rate limit on `POST /generate`** (10 requests / hour / client IP, sliding window). Caps abuse cost at the most-expensive route — each `/generate` triggers an Anthropic diff --git a/examples/wax_saints/photos/.gitkeep b/examples/wax_saints/photos/.gitkeep new file mode 100644 index 0000000..36b9f9b --- /dev/null +++ b/examples/wax_saints/photos/.gitkeep @@ -0,0 +1,3 @@ +Placeholder. The render script (../render.py) writes JPEGs here at runtime +from the source HTML on the operator's local machine. Photos are intentionally +gitignored — personal images must not land in a public repo. diff --git a/examples/wax_saints/render.py b/examples/wax_saints/render.py new file mode 100644 index 0000000..9338619 --- /dev/null +++ b/examples/wax_saints/render.py @@ -0,0 +1,230 @@ +"""One-off demo: render the editorial template with the Bad Tölz example. + +Pulls the 8 base64 photos out of the source HTML provided by the user, +saves them as JPEGs under ``examples/wax_saints/photos/``, builds a +``Memory`` from the example's tri-lingual text + a synthesized GPX, and +runs the existing pipeline to produce +``output/dev/wax-saints/wax-saints-bad-toelz-april-18.html``. + +Throwaway. Not wired into tests or the web app. Run with:: + + python examples/wax_saints/render.py +""" + +from __future__ import annotations + +import base64 +import re +import sys +from datetime import UTC, date, datetime, timedelta +from pathlib import Path + +from PIL import Image + +# Make `trailstory` importable when running this script from the repo root. +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from trailstory.models import ( # noqa: E402 + GpxStats, + HikeInput, + LocalizedParagraphs, + LocalizedString, + Memory, + NarrativeOutput, + PhotoMeta, + Style, + Waypoint, +) +from trailstory.renderers.html import render_html # noqa: E402 + +SOURCE_HTML = Path( + "/Users/igorkochman/Downloads/journal-nice-ausflug-for-a-warm-saturday-2026-04-18.html" +) +PHOTOS_DIR = Path(__file__).parent / "photos" +OUTPUT_DIR = REPO_ROOT / "output" / "dev" / "wax-saints" +SLUG = "wax-saints-bad-toelz-april-18" + + +def extract_photos(source: Path, photos_dir: Path) -> list[Path]: + """Walk the source HTML, decode every base64-embedded image, save as JPEG. + + The example uses CSS ``background-image: url('data:image/...;base64,...')`` + for every photo (no ````), so we extract by matching the + data URI in CSS attribute values. The source HTML reuses the same image + in multiple places (hero + first photo card both point at the same + bytes), so dedupe by SHA-256 of the decoded payload before writing. + """ + import hashlib + from io import BytesIO + + html = source.read_text(encoding="utf-8") + data_uris = re.findall( + r"data:image/([a-z]+);base64,([A-Za-z0-9+/=]+)", + html, + ) + photos_dir.mkdir(parents=True, exist_ok=True) + # Wipe any prior extracts so old duplicates don't linger. + for old in photos_dir.glob("*.jpg"): + old.unlink() + + seen: set[str] = set() + saved: list[Path] = [] + counter = 0 + for fmt, b64 in data_uris: + raw = base64.b64decode(b64) + digest = hashlib.sha256(raw).hexdigest() + if digest in seen: + print(f" skipped duplicate of {digest[:8]}") + continue + seen.add(digest) + counter += 1 + try: + img = Image.open(BytesIO(raw)).convert("RGB") + out = photos_dir / f"{counter:02d}.jpg" + img.save(out, "JPEG", quality=88) + saved.append(out) + print(f" saved {out.name} {out.stat().st_size // 1024} KB") + except Exception as exc: + print(f" failed image #{counter} ({fmt}): {exc}") + return saved + + +def synthesize_gpx_stats() -> GpxStats: + """The example is map-based, not GPX-recorded. Fabricate enough waypoints + along the Isar river south of Bad Tölz to draw a credible route SVG + + elevation sparkline. Numbers are taken from the example's stat strip + (8.3 km, 4h 16m).""" + base_lat, base_lon = 47.7613, 11.5594 # Bad Tölz market square + base_time = datetime(2026, 4, 18, 12, 5, tzinfo=UTC) + # Roughly south-westward along the river, then loop back. + track = [ + (0.0000, 0.0000, 660), + (-0.0030, 0.0010, 657), + (-0.0065, 0.0028, 654), + (-0.0110, 0.0055, 652), + (-0.0160, 0.0085, 658), + (-0.0205, 0.0110, 665), + (-0.0240, 0.0140, 672), + (-0.0265, 0.0175, 685), + (-0.0280, 0.0210, 698), + (-0.0265, 0.0240, 706), + (-0.0220, 0.0235, 695), + (-0.0170, 0.0225, 682), + (-0.0120, 0.0210, 672), + (-0.0070, 0.0190, 664), + (-0.0030, 0.0150, 660), + (-0.0010, 0.0095, 658), + (0.0000, 0.0040, 660), + ] + waypoints = [ + Waypoint( + lat=base_lat + dlat, + lon=base_lon + dlon, + ele_m=float(ele), + time=base_time + timedelta(minutes=i * 15), + ) + for i, (dlat, dlon, ele) in enumerate(track) + ] + return GpxStats( + distance_km=8.3, + elevation_gain_m=68, # mild — riverside, not alpine + duration_min=256, # 4h 16m + start_elev_m=660, + summit_elev_m=706, + waypoints=waypoints, + ) + + +def build_narrative() -> NarrativeOutput: + return NarrativeOutput( + title=LocalizedString( + en="Wax Saints & River Air", + ru="Восковые святые и речной воздух", + de="Wachsheilige und Flussluft", + ), + subtitle=LocalizedString( + en="A warm April Saturday along the Isar — brunch, a creepy church, and dinner by the water.", + ru="Тёплая апрельская суббота вдоль Изара — бранч, жуткая церковь и ужин у воды.", + de="Ein warmer Aprilsamstag an der Isar — Brunch, eine schaurige Kirche und Abendessen am Wasser.", + ), + paragraphs=LocalizedParagraphs( + en=[ + "We pulled into the parking spot just around noon, the kind of warm April Saturday that makes you glad you didn't sleep in. Bad Tölz greeted us with blue skies and a city centre that felt unhurried and inviting. We found a spot for brunch, and little Danny promptly became the star of the room — strangers couldn't help but stop and say hello.", + "Fed and happy, we followed the Isar out of town. The river has a way of pulling you along, and for the next few hours the four of us — plus Danny in the carrier — let it do exactly that.", + "Eight kilometres and just over four hours later, we were tired in the best possible way. The highlight nobody had planned for was a small old church tucked along the route, its interior populated with wax figures of Jesus and his disciples lurking in unexpected corners. Genuinely eerie, genuinely unforgettable.", + "We capped the evening with Asian food to go, eaten on the riverbank as the light faded.", + ], + ru=[ + "Мы припарковались около полудня — в один из тех тёплых апрельских субботних дней, когда радуешься, что не залежался в постели. Бад-Тёльц встретил нас голубым небом и неспешным, располагающим к прогулке центром города. Нашли место для бранча, и маленький Дэнни моментально стал звездой заведения — прохожие то и дело останавливались, чтобы с ним поздороваться.", + "Сытые и довольные, мы двинулись вдоль Изара за город. Река умеет увлекать за собой, и несколько ближайших часов мы просто шли туда, куда она вела, — все четверо, и Дэнни в слинг-рюкзаке.", + "8,3 километра и чуть больше четырёх часов спустя усталость была приятной. Незапланированным открытием стала маленькая старая церковь на маршруте: внутри нас поджидали восковые фигуры Иисуса и его учеников, расставленные в самых неожиданных местах. По-настоящему жутко — и по-настоящему незабываемо.", + "Вечер завершили едой навынос из азиатского кафе, которую съели прямо на берегу реки, пока гасло небо.", + ], + de=[ + "Wir kamen gegen Mittag am Parkplatz an — an einem jener warmen Aprilsamstage, an denen man froh ist, nicht länger im Bett geblieben zu sein. Bad Tölz empfing uns mit blauem Himmel und einem Stadtzentrum, das einladend und entspannt wirkte. Wir fanden einen Platz für einen ausgedehnten Brunch, und der kleine Danny wurde prompt zum Mittelpunkt des Raumes — Fremde konnten einfach nicht widerstehen, ihn anzulächeln und Hallo zu sagen.", + "Gestärkt und gut gelaunt folgten wir der Isar aus der Stadt hinaus. Der Fluss hat eine Art, einen mitzuziehen, und genau das ließen wir die nächsten Stunden zu — alle vier, Danny im Tragerucksack.", + "8,3 Kilometer und gut vier Stunden später waren wir auf die schönste Art erschöpft. Das ungeplante Highlight war eine kleine alte Kirche am Wegesrand, in der Wachsfiguren von Jesus und seinen Jüngern an unverhofften Stellen auf uns warteten. Wirklich gruselig — und wirklich unvergesslich.", + "Den Abend ließen wir mit asiatischem Essen zum Mitnehmen ausklingen, gegessen am Flussufer, während das Licht langsam verblasste.", + ], + ), + pull_quote=LocalizedString( + en="Genuinely eerie, genuinely unforgettable.", + ru="По-настоящему жутко — и по-настоящему незабываемо.", + de="Wirklich gruselig — und wirklich unvergesslich.", + ), + milestone=LocalizedString( + en="First Bad Tölz Saturday", + ru="Первая суббота в Бад-Тёльце", + de="Erster Samstag in Bad Tölz", + ), + # Filled in by main() once we know how many uniques we extracted. + selected_photo_indices=[], + ) + + +def main() -> None: + if not SOURCE_HTML.is_file(): + sys.exit(f"source HTML not found: {SOURCE_HTML}") + + print(f"→ extracting photos from {SOURCE_HTML.name}") + photo_paths = extract_photos(SOURCE_HTML, PHOTOS_DIR) + if not photo_paths: + sys.exit("no photos extracted — source format may have changed") + print(f" {len(photo_paths)} photos saved to {PHOTOS_DIR}\n") + + base_time = datetime(2026, 4, 18, 12, 5, tzinfo=UTC) + photo_metas = [ + PhotoMeta(path=p, timestamp=base_time + timedelta(minutes=i * 25), index=i) + for i, p in enumerate(photo_paths) + ] + + narrative = build_narrative() + narrative.selected_photo_indices = list(range(len(photo_paths))) + stats = synthesize_gpx_stats() + memory = Memory( + hike_input=HikeInput( + gpx_path=PHOTOS_DIR / "fake.gpx", + photos_dir=PHOTOS_DIR, + seed_text="Bad Tölz on a warm April Saturday: river walk + brunch + that strange wax-figure church.", + location_name="Bad Tölz, Bavaria", + ), + gpx_stats=stats, + narrative=narrative, + selected_photos=photo_metas, + style=Style.editorial, + ) + + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + out = render_html( + memory=memory, + output_dir=OUTPUT_DIR, + slug=SLUG, + hike_date=date(2026, 4, 18), + location="Bad Tölz, Bavaria", + ) + print(f"→ rendered {out} ({out.stat().st_size // 1024} KB)") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index dc66d67..680e779 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,6 +70,11 @@ ignore = [ [tool.ruff.lint.isort] known-first-party = ["trailstory", "web"] +[tool.ruff.lint.per-file-ignores] +# Demo scripts under examples/ carry dense paragraphs of Russian narrative +# (intentional, not lookalike confusables). RUF001 would flag every line. +"examples/**/*.py" = ["RUF001"] + # ── Mypy ────────────────────────────────────────────────────────────────────── [tool.mypy] diff --git a/templates/fonts/editorial/JetBrainsMono-VF.cyrillic.woff2 b/templates/fonts/editorial/JetBrainsMono-VF.cyrillic.woff2 new file mode 100644 index 0000000..1b61cf9 Binary files /dev/null and b/templates/fonts/editorial/JetBrainsMono-VF.cyrillic.woff2 differ diff --git a/templates/fonts/editorial/JetBrainsMono-VF.latin.woff2 b/templates/fonts/editorial/JetBrainsMono-VF.latin.woff2 new file mode 100644 index 0000000..2ca6ac6 Binary files /dev/null and b/templates/fonts/editorial/JetBrainsMono-VF.latin.woff2 differ diff --git a/templates/fonts/editorial/LICENSE.md b/templates/fonts/editorial/LICENSE.md new file mode 100644 index 0000000..ff69888 --- /dev/null +++ b/templates/fonts/editorial/LICENSE.md @@ -0,0 +1,22 @@ +# Editorial style font licenses + +Both font families bundled in this directory ship under the +**SIL Open Font License 1.1**. Copying the WOFF2 files into the +repo and embedding them as base64 in the rendered HTML is +explicitly permitted by the license. + +| File family | Foundry | Source | License | +|---|---|---|---| +| `SourceSerif4-Italic-VF.*.woff2`, `SourceSerif4-Roman-VF.*.woff2` | Frank Grießhammer / Adobe | https://github.com/adobe-fonts/source-serif / Google Fonts | OFL 1.1 | +| `JetBrainsMono-VF.*.woff2` | JetBrains | https://github.com/JetBrains/JetBrainsMono | OFL 1.1 | + +The WOFF2 files in this directory are the subset builds served by +Google Fonts (latin + cyrillic). They were chosen, instead of the +Newsreader family named in the original design brief, because +Newsreader on Google Fonts does not include a Cyrillic subset — +Russian readers (a primary audience for this project) would have +fallen back to system serif. Source Serif 4 shares the same +optical-size variable axis and italic + roman pair as Newsreader, +and ships full Cyrillic + Latin coverage. + +Full OFL text: https://openfontlicense.org/open-font-license-official-text/ diff --git a/templates/fonts/editorial/SourceSerif4-Italic-VF.cyrillic.woff2 b/templates/fonts/editorial/SourceSerif4-Italic-VF.cyrillic.woff2 new file mode 100644 index 0000000..b475eea Binary files /dev/null and b/templates/fonts/editorial/SourceSerif4-Italic-VF.cyrillic.woff2 differ diff --git a/templates/fonts/editorial/SourceSerif4-Italic-VF.latin.woff2 b/templates/fonts/editorial/SourceSerif4-Italic-VF.latin.woff2 new file mode 100644 index 0000000..e99ceb8 Binary files /dev/null and b/templates/fonts/editorial/SourceSerif4-Italic-VF.latin.woff2 differ diff --git a/templates/fonts/editorial/SourceSerif4-Roman-VF.cyrillic.woff2 b/templates/fonts/editorial/SourceSerif4-Roman-VF.cyrillic.woff2 new file mode 100644 index 0000000..daf2e86 Binary files /dev/null and b/templates/fonts/editorial/SourceSerif4-Roman-VF.cyrillic.woff2 differ diff --git a/templates/fonts/editorial/SourceSerif4-Roman-VF.latin.woff2 b/templates/fonts/editorial/SourceSerif4-Roman-VF.latin.woff2 new file mode 100644 index 0000000..56a26e1 Binary files /dev/null and b/templates/fonts/editorial/SourceSerif4-Roman-VF.latin.woff2 differ diff --git a/templates/styles/editorial.html.j2 b/templates/styles/editorial.html.j2 index e91c3e7..844716f 100644 --- a/templates/styles/editorial.html.j2 +++ b/templates/styles/editorial.html.j2 @@ -2,358 +2,961 @@ - + + {{ narrative.title.en }} -
- -
-
-
- + + + + +
+ +
+ + + +
+ +
+ {{ narrative.milestone.en }} {{ narrative.milestone.ru }} {{ narrative.milestone.de }} -

- {{ narrative.title.en }} - {{ narrative.title.ru }} - {{ narrative.title.de }} + {# Split each language's title across two lines: top word italic / rest roman. + If the title is one word, both spans get the same text — the italic top + then collapses naturally because of the `display: block` on `.bot`. #} +

+ + {{ narrative.title.en }} + {% set words = narrative.title.en.split(' ', 1) %} + + + + + {{ narrative.title.ru }} + {% set words = narrative.title.ru.split(' ', 1) %} + + + + + {{ narrative.title.de }} + {% set words = narrative.title.de.split(' ', 1) %} + + +

-

+

{{ narrative.subtitle.en }} {{ narrative.subtitle.ru }} {{ narrative.subtitle.de }}

{% if meta.location or meta.date %}
- {% if meta.location %}{{ meta.location }}{% endif %} - {% if meta.location and meta.date %} · {% endif %} + {% if meta.location %}{{ meta.location | upper }}{% endif %} + {% if meta.location and meta.date %} · {% endif %} {% if meta.date %}{{ meta.date }}{% endif %}
{% endif %} +
-
-
{{ stats.distance_km }}
km
-
{{ stats.elevation_gain_m | int }}
m gain
-
{{ stats.duration_min }}
min
-
{{ stats.summit_elev_m | int }}
m summit
-
- - - -
-
- {% for p in narrative.paragraphs.en %}

{{ p }}

{% endfor %} -
-
- {% for p in narrative.paragraphs.ru %}

{{ p }}

{% endfor %} -
-
- {% for p in narrative.paragraphs.de %}

{{ p }}

{% endfor %} -
- -
- {{ narrative.pull_quote.en }} - {{ narrative.pull_quote.ru }} - {{ narrative.pull_quote.de }} -
-
- -
- {% for photo in photos %} - - {% endfor %} -
- - - - -
- - +
+ +
+ {# Hero photo: float-right at top of prose, OUTSIDE the language + divs so body text in any language wraps around the same single + emission of the base64 payload. #} + {% if photos|length > 0 %} +
+ {% endif %} + + {# Mid-body photo: between paragraph blocks. Each language has the + same paragraph count; the per-language wrappers around

's use + .en/.ru/.de classes so .lang-* on body shows the right one. #} + {% set n = narrative.paragraphs.en | length %} + {% set mid = (n // 2) if n >= 3 else n %} +

+ {% for i in range(n) %} +

{{ narrative.paragraphs.en[i] }}

+

{{ narrative.paragraphs.ru[i] }}

+

{{ narrative.paragraphs.de[i] }}

+ {%- if loop.index0 == mid - 1 and photos|length > 1 %} +
+ {% endif %} + {% endfor %} +
+ +
+ {{ narrative.pull_quote.en }} + {{ narrative.pull_quote.ru }} + {{ narrative.pull_quote.de }} +
+ + {# Remaining photos in two layout variants, rotating, after the quote. #} + {% if photos|length > 2 %} + {% for ph in photos[2:] %} + {% set v = ['v-e', 'v-b', 'v-d', 'v-b'][loop.index0 % 4] %} +
+ +
+ {% endfor %} + {% endif %} + + + +
{{ meta.slug }}
+
+ + + +
+ + + diff --git a/tests/golden/test-render-editorial.html b/tests/golden/test-render-editorial.html index d38932c..a511218 100644 --- a/tests/golden/test-render-editorial.html +++ b/tests/golden/test-render-editorial.html @@ -2,357 +2,945 @@ - + + Above the fog line -
- -
-
-
- + + + + +
+ +
+ + + +
+ +
+ First mountain hike Первый горный поход Erste Bergwanderung -

- Above the fog line - Над линией тумана - Über der Nebelgrenze +

+ + Above the fog line + + + + + Над линией тумана + + + + + Über der Nebelgrenze + + +

-

+

A morning above the cloud sea Утро над морем облаков Ein Morgen über dem Wolkenmeer

-Bavarian Alps · 2025-08-15
+BAVARIAN ALPS · 2025-08-15 +
-
-
2.258
km
-
610
m gain
-
100
min
-
1330
m summit
-
- - - -
-
-

We left the trailhead at first light, the air sharp with damp moss.

By the saddle the cloud was thinning into a soft white scarf.

Mia slept the whole climb, her cheek warm against the carrier.

-
-

Вышли на тропу с первыми лучами; воздух пах мхом и хвоей.

К седловине облака уже редели, превращаясь в белый шарф.

Мия проспала весь подъём, прижавшись щекой к переноске.

-
-

Bei erstem Licht brachen wir auf, die Luft scharf von feuchtem Moos.

Am Sattel zog die Wolke sich zu einem weichen weißen Schal zusammen.

Mia schlief den ganzen Aufstieg, die Wange warm an der Trage.

- -
- The fog cleared just as we reached the ridge. - Туман рассеялся как раз когда мы вышли на хребет. - Der Nebel lichtete sich, gerade als wir den Grat erreichten. -
-
- -
- - - - - - - - - - - - -
- - - - -
- - +
+ +
+
+ +
+

We left the trailhead at first light, the air sharp with damp moss.

+

Вышли на тропу с первыми лучами; воздух пах мхом и хвоей.

+

Bei erstem Licht brachen wir auf, die Luft scharf von feuchtem Moos.

+

By the saddle the cloud was thinning into a soft white scarf.

+

К седловине облака уже редели, превращаясь в белый шарф.

+

Am Sattel zog die Wolke sich zu einem weichen weißen Schal zusammen.

Mia slept the whole climb, her cheek warm against the carrier.

+

Мия проспала весь подъём, прижавшись щекой к переноске.

+

Mia schlief den ganzen Aufstieg, die Wange warm an der Trage.

+ +
+ The fog cleared just as we reached the ridge. + Туман рассеялся как раз когда мы вышли на хребет. + Der Nebel lichtete sich, gerade als wir den Grat erreichten. +
+ +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ + + +
test-render-editorial
+
+ + + +
+ + + diff --git a/tests/test_styles.py b/tests/test_styles.py index 352c635..d4e6a54 100644 --- a/tests/test_styles.py +++ b/tests/test_styles.py @@ -175,14 +175,33 @@ def test_encyclopedia_style_uses_two_column_body_with_drop_cap( assert "
" in html -def test_editorial_style_keeps_existing_visual_identity(tmp_path: Path) -> None: - """Editorial is the original look; markers from the previous template - must still be present so we don't silently drift the polished default.""" +def test_editorial_style_keeps_magazine_visual_identity(tmp_path: Path) -> None: + """Editorial is the magazine treatment (Source Serif 4 + JetBrains Mono, + oklch paper/ink tokens, italic-top / roman-bottom display title, drop + cap, marginalia sidebar, reading-progress bar). The markers below + are the load-bearing structural signals — if any of them disappear the + style has drifted away from the intended design.""" html = _render_all_styles(tmp_path)[Style.editorial] + # Embedded WOFF2 fonts (ADR-001 self-contained guarantee). + assert "@font-face" in html + assert "Editorial Serif" in html + assert "Editorial Mono" in html + assert "data:font/woff2;base64," in html + + # Design tokens and layout primitives. + assert "--paper:" in html and "--ink:" in html + assert 'class="display"' in html + assert 'class="eyebrow"' in html + assert 'class="margin"' in html + assert 'class="quote' in html # the pull-quote callout (may carry extra classes) assert 'class="elevation"' in html - assert "EN · RU · DE" in html - assert "
" in html + + # Three discrete language buttons (replaces the older single EN·RU·DE label). + assert 'data-lang="en"' in html + assert 'data-lang="ru"' in html + assert 'data-lang="de"' in html + # Editorial does NOT use figcaptions; that is a log/encyclopedia marker. assert "
" not in html diff --git a/trailstory/renderers/html.py b/trailstory/renderers/html.py index 988c78e..ff6ca9a 100644 --- a/trailstory/renderers/html.py +++ b/trailstory/renderers/html.py @@ -14,13 +14,14 @@ import base64 import logging from datetime import date +from functools import lru_cache from pathlib import Path from typing import Final from jinja2 import Environment, FileSystemLoader, select_autoescape from trailstory.gpx import elevation_profile -from trailstory.models import Memory, PhotoMeta +from trailstory.models import Memory, PhotoMeta, Style logger = logging.getLogger(__name__) @@ -28,6 +29,19 @@ TEMPLATE_NAME: Final[str] = "memory.html.j2" ELEVATION_POINTS: Final[int] = 40 +# Editorial-style WOFF2 subsets — committed under templates/fonts/editorial/ +# and embedded as base64 data URIs so the rendered HTML works offline (ADR-001). +# Source Serif 4 stands in for Newsreader because the latter has no Cyrillic +# subset on Google Fonts; see templates/fonts/editorial/LICENSE.md. +_EDITORIAL_FONT_FILES: Final[dict[str, str]] = { + "serif_italic_latin": "SourceSerif4-Italic-VF.latin.woff2", + "serif_italic_cyrillic": "SourceSerif4-Italic-VF.cyrillic.woff2", + "serif_roman_latin": "SourceSerif4-Roman-VF.latin.woff2", + "serif_roman_cyrillic": "SourceSerif4-Roman-VF.cyrillic.woff2", + "mono_latin": "JetBrainsMono-VF.latin.woff2", + "mono_cyrillic": "JetBrainsMono-VF.cyrillic.woff2", +} + class HtmlRenderError(Exception): """Raised when the HTML output cannot be produced. @@ -84,6 +98,7 @@ def render_html( "location": location or "", "style": memory.style.value, }, + fonts=_editorial_fonts() if memory.style == Style.editorial else {}, ) output_dir.mkdir(parents=True, exist_ok=True) @@ -107,6 +122,27 @@ def _environment() -> Environment: ) +@lru_cache(maxsize=1) +def _editorial_fonts() -> dict[str, str]: + """Return editorial WOFF2 fonts as base64 data URIs, keyed by role. + + Read once per process. The returned mapping is what + ``templates/styles/editorial.html.j2`` uses inside its ``@font-face`` + declarations — each value is the base64 payload only (no + ``data:font/woff2;base64,`` prefix), so the template can construct + full ``src: url(...)`` expressions. + """ + fonts_dir = TEMPLATE_DIR / "fonts" / "editorial" + encoded: dict[str, str] = {} + for role, filename in _EDITORIAL_FONT_FILES.items(): + path = fonts_dir / filename + try: + encoded[role] = base64.b64encode(path.read_bytes()).decode("ascii") + except OSError as exc: + raise HtmlRenderError(f"unable to read editorial font {path}: {exc}") from exc + return encoded + + def _photo_context(photo: PhotoMeta) -> dict[str, str | int]: try: raw = photo.path.read_bytes()