Take the Canopy Ledger to press

This commit is contained in:
Meta Vens 2026-08-30 12:00:36 +02:00
commit b1fe2fa4f1
6 changed files with 503 additions and 12 deletions

View file

@ -1444,7 +1444,8 @@ independently dispatched blind reviewer saw only the reader-facing fantasy
newspaper and read it as a real left-populist civic broadsheet; it did not
immediately identify procedural generation, so the narrative reverse-Turing
criterion passes. The review's criticism that all three stories feel like
three rewrites of one briefing is later editorial differentiation debt.
three rewrites of one briefing belongs to reporter-personality differentiation
in the next newsroom run; it does not authorize rewriting accepted Run 60.
Run 60 used 96,902 prompt tokens and reported zero cache hits. The bounded
snapshot initialization had split the stable charter from the first-step
@ -1455,6 +1456,14 @@ preserving later bounded workbench replacement; all 479 library tests pass.
This cache-boundary fix does not require resimulation or recomposition of the
accepted edition.
Typesetting is delivered at
`output/pdf/canopy-ledger-run-60-front-page.pdf`. It is one 10-by-12.2-inch
three-column page with embedded Georgia/Times faces, warm press stock, and
wordless rail/gauge cuts. The frozen Markdown source hash remains unchanged.
Region-based extraction finds all 23 expected copy fragments, and full-page
144-DPI visual QA finds no clipping or overlap. Typesetting is complete and no
longer active work.
Run 54 exposed one separate upstream publicity defect: a public strategic
activity propagates its channel to private `MemberMemory` outcomes, which is
how Orin Pell retaining a memory became a public news row. The newsroom should
@ -1464,14 +1473,15 @@ materializing a `NewsIssue`.
1. Preserve immutable accepted Run 60 and Run 59; do not rewrite, recompose, or
resimulate either edition.
2. Advance the Delvehold-owned adapter against Ghostlight's admitted generic
consumer contract. On the next naturally required multi-step provider agent
run, capture whether exact pushed `8f3c33b` produces nonzero cache hits; do
not replay Run 60 to manufacture a better metric.
3. Keep the blind review's three-rewrites criticism as later reporter-voice
differentiation debt. Defer multiple publications, TeX layout, and engraved
cuts until separately authorized.
4. Per-wave issues may use daily-like reporting windows, while a final
2. Preserve the delivered typeset PDF and its frozen-copy verification above.
3. On the next newsroom run, strengthen reporter-personality differentiation
so assigned journalists produce distinct framing, rhythm, and priorities;
also capture whether exact pushed `8f3c33b` produces nonzero cache hits. Do
not replay Run 60 to manufacture either improvement.
4. After that newsroom run, advance the Delvehold-owned adapter against
Ghostlight's admitted generic consumer contract. Defer multiple publications
until separately authorized.
5. Per-wave issues may use daily-like reporting windows, while a final
composition may be an equally current weekly-style issue with a broader
window. Both are selective journalism; broad fact availability increases
editorial freedom and does not license recap voice.

Binary file not shown.

View file

@ -233,4 +233,5 @@
{"note": "Run 59 prompt audit falsifies v8 as the target newsroom/cache architecture: narrative selection made ten provider calls over 1,373,208 input characters and 582,184 prompt tokens with zero cached tokens. The generic agent harness places changing action schema before stable work and replays the accumulated tool transcript; selector, whole-page writer, and Night Editor duplicate editorial doctrine. Operator-authorized owners are story-and-journalist assignment from the frozen ledger, bounded personality-bearing journalist drafting, copy desk as sole semantic factual query owner, one Night Editor checklist repair preserving narrative, and deterministic structural/provenance/query-disposition press admission. Exact schema and cache cuts remain pending bounded source inspection.", "status": "ok", "ts": "2026-08-29T20:29:24+00:00", "type": "research"}
{"note": "character-newsroom.v9 replaces the monolithic page writer with an embodied assignment editor and one bounded journalist agent per assigned story, preserves exact assignment-owned structure, bounds Night Editor context to copy-queried articles and facts, and makes generic agent requests append-only with stable schemas and cache routing. Verified 475/475 library tests, 16/16 strategic-smoke tests, and ghostlight-dungeon all-target checks; no provider or simulation run was launched.", "status": "ok", "ts": "2026-08-29T21:42:15+00:00", "type": "implementation"}
{"note": "character-newsroom.v10 gives the assignment editor a stable-prefix bounded workbench projection with compact inspected-record index and exact-ID refetch; retrieval retires any pending agenda. Exact task/editorial-bound agenda and per-article production checkpoints preserve accepted work before copy, and cited datelines compile into the reporter action schema. Verified 479/479 library tests, 16/16 strategic-smoke tests, and all-target check with only the pre-existing native-client unused-path warning.", "status": "ok", "ts": "2026-08-30T05:37:54+00:00", "type": "implementation"}
{"ts": "2026-08-30T06:11:33+00:00", "type": "verification", "status": "ok", "note": "Run 60 completed at source 139b8a69bc297e406ed5aea45574ea5e5724c685, world revision 27, without world or per-wave replay. Newspaper SHA-256 f98a4684e6eef43f5eb4bf104248a21dcea1ecdf1c4e4e14fa405f32c51b8c1c; archive SHA-256 6cacc7b543f75c4e9cfb8d7f695c90a507b7f11125e8204101af1a7dd74c61b1. A fresh blind editor read it as a real left-populist civic broadsheet rather than procedural generation, while flagging same-voice repetition as later debt. Run 60 reported 96902 prompt tokens and zero cache hits. The bounded-agent initializer had split the stable charter from a changing step message; the current cut makes the entire first request one stable user message and later replaces only a second dynamic workbench message. Existing assignment-editor prefix verification and all 479 library tests pass. Provider cache eligibility is proven; nonzero live cache telemetry remains for the next multi-step agent run."}
{"note": "Run 60 completed at source 139b8a69bc297e406ed5aea45574ea5e5724c685, world revision 27, without world or per-wave replay. Newspaper SHA-256 f98a4684e6eef43f5eb4bf104248a21dcea1ecdf1c4e4e14fa405f32c51b8c1c; archive SHA-256 6cacc7b543f75c4e9cfb8d7f695c90a507b7f11125e8204101af1a7dd74c61b1. A fresh blind editor read it as a real left-populist civic broadsheet rather than procedural generation, while flagging same-voice repetition as later debt. Run 60 reported 96902 prompt tokens and zero cache hits. The bounded-agent initializer had split the stable charter from a changing step message; the current cut makes the entire first request one stable user message and later replaces only a second dynamic workbench message. Existing assignment-editor prefix verification and all 479 library tests pass. Provider cache eligibility is proven; nonzero live cache telemetry remains for the next multi-step agent run.", "status": "ok", "ts": "2026-08-30T06:11:33+00:00", "type": "verification"}
{"ts": "2026-08-30T09:58:52+00:00", "type": "verification", "status": "ok", "note": "Frozen accepted Run 60 copy was typeset as output/pdf/canopy-ledger-run-60-front-page.pdf without newsroom replay or word changes. The one-page 10 x 12.2 inch folio uses embedded Georgia and Times New Roman faces, three columns, warm press stock, and wordless rail/gauge cuts. Region-based PDF extraction resolved all 23 expected source fragments; 144-DPI full-page raster review found no clipping or overlap. PDF SHA-256 6A9D16FD5FA20221FCEEA0B850D2D074D276C18CB283996A49CB4088C0ABCE21; frozen Markdown source remains F98A4684E6EEF43F5EB4BF104248A21DCEA1ECDF1C4E4E14FA405F32C51B8C1C."}

View file

@ -235,3 +235,4 @@
{"key": "2026-08-29T214215Z0000-0233-cdea7f28b17e", "payload": "eyJub3RlIjogImNoYXJhY3Rlci1uZXdzcm9vbS52OSByZXBsYWNlcyB0aGUgbW9ub2xpdGhpYyBwYWdlIHdyaXRlciB3aXRoIGFuIGVtYm9kaWVkIGFzc2lnbm1lbnQgZWRpdG9yIGFuZCBvbmUgYm91bmRlZCBqb3VybmFsaXN0IGFnZW50IHBlciBhc3NpZ25lZCBzdG9yeSwgcHJlc2VydmVzIGV4YWN0IGFzc2lnbm1lbnQtb3duZWQgc3RydWN0dXJlLCBib3VuZHMgTmlnaHQgRWRpdG9yIGNvbnRleHQgdG8gY29weS1xdWVyaWVkIGFydGljbGVzIGFuZCBmYWN0cywgYW5kIG1ha2VzIGdlbmVyaWMgYWdlbnQgcmVxdWVzdHMgYXBwZW5kLW9ubHkgd2l0aCBzdGFibGUgc2NoZW1hcyBhbmQgY2FjaGUgcm91dGluZy4gVmVyaWZpZWQgNDc1LzQ3NSBsaWJyYXJ5IHRlc3RzLCAxNi8xNiBzdHJhdGVnaWMtc21va2UgdGVzdHMsIGFuZCBnaG9zdGxpZ2h0LWR1bmdlb24gYWxsLXRhcmdldCBjaGVja3M7IG5vIHByb3ZpZGVyIG9yIHNpbXVsYXRpb24gcnVuIHdhcyBsYXVuY2hlZC4iLCAic3RhdHVzIjogIm9rIiwgInRzIjogIjIwMjYtMDgtMjlUMjE6NDI6MTUrMDA6MDAiLCAidHlwZSI6ICJpbXBsZW1lbnRhdGlvbiJ9", "stored_at": "2026-08-29T21:42:15.490465+00:00", "type": "ghostlight.evidence_record.v0"}
{"key": "2026-08-30T053754Z0000-0234-3f241b6ff6fc", "payload": "eyJub3RlIjogImNoYXJhY3Rlci1uZXdzcm9vbS52MTAgZ2l2ZXMgdGhlIGFzc2lnbm1lbnQgZWRpdG9yIGEgc3RhYmxlLXByZWZpeCBib3VuZGVkIHdvcmtiZW5jaCBwcm9qZWN0aW9uIHdpdGggY29tcGFjdCBpbnNwZWN0ZWQtcmVjb3JkIGluZGV4IGFuZCBleGFjdC1JRCByZWZldGNoOyByZXRyaWV2YWwgcmV0aXJlcyBhbnkgcGVuZGluZyBhZ2VuZGEuIEV4YWN0IHRhc2svZWRpdG9yaWFsLWJvdW5kIGFnZW5kYSBhbmQgcGVyLWFydGljbGUgcHJvZHVjdGlvbiBjaGVja3BvaW50cyBwcmVzZXJ2ZSBhY2NlcHRlZCB3b3JrIGJlZm9yZSBjb3B5LCBhbmQgY2l0ZWQgZGF0ZWxpbmVzIGNvbXBpbGUgaW50byB0aGUgcmVwb3J0ZXIgYWN0aW9uIHNjaGVtYS4gVmVyaWZpZWQgNDc5LzQ3OSBsaWJyYXJ5IHRlc3RzLCAxNi8xNiBzdHJhdGVnaWMtc21va2UgdGVzdHMsIGFuZCBhbGwtdGFyZ2V0IGNoZWNrIHdpdGggb25seSB0aGUgcHJlLWV4aXN0aW5nIG5hdGl2ZS1jbGllbnQgdW51c2VkLXBhdGggd2FybmluZy4iLCAic3RhdHVzIjogIm9rIiwgInRzIjogIjIwMjYtMDgtMzBUMDU6Mzc6NTQrMDA6MDAiLCAidHlwZSI6ICJpbXBsZW1lbnRhdGlvbiJ9", "stored_at": "2026-08-30T05:37:54.505622+00:00", "type": "ghostlight.evidence_record.v0"}
{"key": "2026-08-30T061133Z0000-0235-41f8be920abd", "payload": "eyJub3RlIjogIlJ1biA2MCBjb21wbGV0ZWQgYXQgc291cmNlIDEzOWI4YTY5YmMyOTdlNDA2ZWQ1YWVhNDU1NzRlYTVlNTcyNGM2ODUsIHdvcmxkIHJldmlzaW9uIDI3LCB3aXRob3V0IHdvcmxkIG9yIHBlci13YXZlIHJlcGxheS4gTmV3c3BhcGVyIFNIQS0yNTYgZjk4YTQ2ODRlNmVlZjQzZjVlYjRiZjEwNDI0OGEyMWRjZWExZWNkZjFjNGU0ZTE0ZmE0MDVmMzJjNTFiOGMxYzsgYXJjaGl2ZSBTSEEtMjU2IDZjYWNjN2I1NDNmNzVjNGU5Y2ZiOGQ3ZjY5NWM5MGE1MDdiN2YxMTEyNWU4MjA0MTAxYWYxYTdkZDc0YzYxYjEuIEEgZnJlc2ggYmxpbmQgZWRpdG9yIHJlYWQgaXQgYXMgYSByZWFsIGxlZnQtcG9wdWxpc3QgY2l2aWMgYnJvYWRzaGVldCByYXRoZXIgdGhhbiBwcm9jZWR1cmFsIGdlbmVyYXRpb24sIHdoaWxlIGZsYWdnaW5nIHNhbWUtdm9pY2UgcmVwZXRpdGlvbiBhcyBsYXRlciBkZWJ0LiBSdW4gNjAgcmVwb3J0ZWQgOTY5MDIgcHJvbXB0IHRva2VucyBhbmQgemVybyBjYWNoZSBoaXRzLiBUaGUgYm91bmRlZC1hZ2VudCBpbml0aWFsaXplciBoYWQgc3BsaXQgdGhlIHN0YWJsZSBjaGFydGVyIGZyb20gYSBjaGFuZ2luZyBzdGVwIG1lc3NhZ2U7IHRoZSBjdXJyZW50IGN1dCBtYWtlcyB0aGUgZW50aXJlIGZpcnN0IHJlcXVlc3Qgb25lIHN0YWJsZSB1c2VyIG1lc3NhZ2UgYW5kIGxhdGVyIHJlcGxhY2VzIG9ubHkgYSBzZWNvbmQgZHluYW1pYyB3b3JrYmVuY2ggbWVzc2FnZS4gRXhpc3RpbmcgYXNzaWdubWVudC1lZGl0b3IgcHJlZml4IHZlcmlmaWNhdGlvbiBhbmQgYWxsIDQ3OSBsaWJyYXJ5IHRlc3RzIHBhc3MuIFByb3ZpZGVyIGNhY2hlIGVsaWdpYmlsaXR5IGlzIHByb3Zlbjsgbm9uemVybyBsaXZlIGNhY2hlIHRlbGVtZXRyeSByZW1haW5zIGZvciB0aGUgbmV4dCBtdWx0aS1zdGVwIGFnZW50IHJ1bi4iLCAic3RhdHVzIjogIm9rIiwgInRzIjogIjIwMjYtMDgtMzBUMDY6MTE6MzMrMDA6MDAiLCAidHlwZSI6ICJ2ZXJpZmljYXRpb24ifQ==", "stored_at": "2026-08-30T06:11:33.326826+00:00", "type": "ghostlight.evidence_record.v0"}
{"key": "2026-08-30T095852Z0000-0236-af580d1057d2", "payload": "eyJub3RlIjogIkZyb3plbiBhY2NlcHRlZCBSdW4gNjAgY29weSB3YXMgdHlwZXNldCBhcyBvdXRwdXQvcGRmL2Nhbm9weS1sZWRnZXItcnVuLTYwLWZyb250LXBhZ2UucGRmIHdpdGhvdXQgbmV3c3Jvb20gcmVwbGF5IG9yIHdvcmQgY2hhbmdlcy4gVGhlIG9uZS1wYWdlIDEwIHggMTIuMiBpbmNoIGZvbGlvIHVzZXMgZW1iZWRkZWQgR2VvcmdpYSBhbmQgVGltZXMgTmV3IFJvbWFuIGZhY2VzLCB0aHJlZSBjb2x1bW5zLCB3YXJtIHByZXNzIHN0b2NrLCBhbmQgd29yZGxlc3MgcmFpbC9nYXVnZSBjdXRzLiBSZWdpb24tYmFzZWQgUERGIGV4dHJhY3Rpb24gcmVzb2x2ZWQgYWxsIDIzIGV4cGVjdGVkIHNvdXJjZSBmcmFnbWVudHM7IDE0NC1EUEkgZnVsbC1wYWdlIHJhc3RlciByZXZpZXcgZm91bmQgbm8gY2xpcHBpbmcgb3Igb3ZlcmxhcC4gUERGIFNIQS0yNTYgNkE5RDE2RkQ1RkEyMDIyMUZDRUVBMEI4NTBEMkQwNzREMjc2QzE4Q0IyODM5OTZBNDlDQjQwODhDMEFCQ0UyMTsgZnJvemVuIE1hcmtkb3duIHNvdXJjZSByZW1haW5zIEY5OEE0Njg0RTZFRUY0M0Y1RUI0QkYxMDQyNDhBMjFEQ0VBMUVDREYxQzRFNEUxNEZBNDA1RjMyQzUxQjhDMUMuIiwgInN0YXR1cyI6ICJvayIsICJ0cyI6ICIyMDI2LTA4LTMwVDA5OjU4OjUyKzAwOjAwIiwgInR5cGUiOiAidmVyaWZpY2F0aW9uIn0=", "stored_at": "2026-08-30T09:58:52.190327+00:00", "type": "ghostlight.evidence_record.v0"}

View file

@ -194,6 +194,7 @@ live_architecture:
- Institution, faction, and consumer decision models
active_subgoals:
- On the next newsroom run, strengthen reporter-personality differentiation so assigned journalists produce distinct framing, rhythm, and priorities rather than three rewrites of one briefing. Preserve accepted Run 60 unchanged; also capture whether exact pushed `8f3c33b` produces nonzero cache hits on that naturally required multi-step run.
- Connect a Delvehold-owned adapter to the admitted generic consumer contract: shared compiler/consumer WorldSeed admission, externally authoritative institution-or-Gestalt snapshots, attributed proposal acknowledgement, and committed-event newspaper composition over typed CultNet, without consumer-specific IDs or schemas in Ghostlight.
- Extend strategic/offscreen named-person individuation beyond the proven emerge-then-act path so people plot across sustained political waves, acquire relationships, fold, and return without granting a cell, arena, or model commit authority.
- Pressure the proven 200-cell dispatcher with authored consumer hierarchies and measure provider cost; the active budget ceiling is 240 while provider concurrency remains separately capped at 32.
@ -324,7 +325,7 @@ data_flow:
current_status:
summary: Ghostlight's generic consumer boundary admits sparse WorldSeeds, runs the hundreds-class parallel cell machinery used by Dungeon, and projects grounded newspapers from committed public events. Local acceptance proves 1,000 subjects across 200 cells and two-wave durable named-person emergence. Exact-source provider run 38 at `596e50679244b5d42b4cfdf70d723f815751b8ff` resumed the preserved world at revision 8 without replaying compilation or four committed locality elaborations, recovered 51 of 52 strategic-wave cells on pulse three and the final cell on pulse four, then committed all six waves through revision 14. Pushed commit `3a69306f3004e4af2cd57bba612a6ad8d1b8132f` implements the mapped clock-consequence owner and is verified locally: world clocks own declared consequence text and exact observable scope; a bounded agent may propose legacy scope binding, while `WorldKernel` alone admits it and materializes stable threshold-crossing events and public news through the common mutation path. Binding, emitted-event ancestry, mutation receipts, and the next newspaper boundary persist atomically. Exact-source run 39 resumed run 38 without replaying its six committed waves, bound clock consequences, committed six new waves through revision 21, and emitted Issues 7 through 12 plus a final digest. This completes the mechanical provider acceptance path; all inherited wave 1-6 checkpoint hashes match run 38 exactly and live services were unchanged. Independent blind editorial review returns FAIL: only Issue 7 approaches spicy, while Issues 8-12 are dominated by plans, filings, inspections, warnings, and abstract relationship deltas without betrayal, scandal, public reaction, sustained reversal, or material lived cost. That run-39 diagnosis is superseded for the current phase: run 51 closes the world and reconciliation mechanics, and the active defect belongs to newsroom narrative selection and self-contained semantic provenance over immutable facts. Independent grounding review also returns FAIL: Issue 9 inflates a planned expedited agenda into completion, while Issue 8 assigns unsupported feminine pronouns to Sela Venn and Eryn Tal. Other grounding mechanics pass: 208/208 citations resolve, no future events appear, no replay occurred, the player remained unchanged, all 116/116 manifest entries verify, the archive is exact, and root independently confirmed the live service PIDs and invocation IDs remained unchanged. Editorial and grounding acceptance are terminally failed for run 39. Eight titled elaborators use the generic agent harness under deterministic weighted scheduling, bounded parallelism, and Luna/Terra/Sol routing. Workers remain proposal-only; deterministic admission binds exact frozen schedules, dispatch, receipts, conflicts, and semantic verification, and `WorldKernel` alone owns atomic CultCache persistence. Destination reconciliation is a private transactional workbench, provider schema compatibility is lowered once at the shared model-connector boundary, and durable elaboration and strategic-pulse checkpoints bind to their canonical mutation proofs. The generic ModelAgent harness remains consumer-neutral transport. Provider run 23 remains stopped; earlier failed runs and checkpoint archives remain immutable provenance. The live daemons remain outside the acceptance body. Dungeon play remains regression pressure, the Delvehold game-side adapter remains unimplemented, and unpublished Mars and Kalsa destination previews remain unapproved.
provider_acceptance_checkpoint: Exact pushed commit `139b8a6` completed Run 60 under `character-newsroom.v10` without world or per-wave replay. Its acceptance witness and terminal status bind the completed page and exact resume lineage. The independently dispatched blind reviewer, shown only the reader-facing fantasy newspaper, read it as a real left-populist civic broadsheet and did not immediately identify procedural generation, so the narrative reverse-Turing criterion passes. The review's substantive criticism is that the three stories feel like three rewrites of one briefing; preserve that as future editorial differentiation debt. The run used 96,902 prompt tokens and reported zero cache hits.
provider_acceptance_checkpoint: Exact pushed commit `139b8a6` completed Run 60 under `character-newsroom.v10` without world or per-wave replay. Its acceptance witness and terminal status bind the completed page and exact resume lineage. The independently dispatched blind reviewer, shown only the reader-facing fantasy newspaper, read it as a real left-populist civic broadsheet and did not immediately identify procedural generation, so the narrative reverse-Turing criterion passes. The review's substantive criticism is that the three stories feel like three rewrites of one briefing; reporter-personality differentiation is deferred to the next newsroom run and must not rewrite accepted Run 60. The run used 96,902 prompt tokens and reported zero cache hits. The delivered typeset artifact is `output/pdf/canopy-ledger-run-60-front-page.pdf`: one 10-by-12.2-inch three-column page with embedded Georgia/Times faces, warm press stock, and wordless rail/gauge cuts. The frozen Markdown source hash is unchanged; region extraction finds all 23 expected copy fragments, and 144-DPI full-page visual QA finds no clipping or overlap.
implementation_checkpoint: Exact pushed commit `8f3c33b` (`Preserve bounded agent cache prefixes`) collapses the stable charter and first-step directive into one exact initial cacheable message while preserving later tool-owned bounded workbench replacement; all 479 library tests pass. Exact pushed `139b8a6` remains the provider-proven v10 newsroom boundary with bounded assignment context, task/editorial-bound agenda and per-article production checkpoints, deterministic cited-place datelines, embodied context membranes, and no-replay resume. Accepted Run 60 remains immutable. Its zero-hit result falsifies the earlier split-prefix implementation but does not require recomposition to validate the corrected harness.
next_action: Advance the Delvehold-owned adapter against Ghostlight's admitted generic consumer contract. On the next naturally required multi-step provider agent run, capture telemetry proving whether exact pushed `8f3c33b` produces nonzero cache hits; do not replay, recompose, or reopen accepted Run 60 merely to improve the metric. Preserve the zero-hit Run 60 evidence and keep the blind review's three-rewrites criticism as later reporter-voice differentiation debt.
next_action: Run the next newsroom acceptance with stronger reporter-personality differentiation while preserving accepted Run 60 and its delivered PDF unchanged; use that naturally required multi-step provider work to capture whether exact pushed `8f3c33b` produces nonzero cache hits. After that newsroom run, advance the Delvehold-owned adapter against Ghostlight's admitted generic consumer contract. Do not replay Run 60 to improve copy or telemetry.
gestalt_population: Gestalt Personas own shared population baselines; persistent member deltas own named individuality; WorldKernel materialization and approval-gated fission preserve lineage and identity. Materialisation/folding changes only representation and the exact member delta, advances resolution epoch, and retires the old cover; it has no Gestalt-wide effect authority. Selected actionable member exceptions are mandatory Projector perspective owners, so aggregation cannot silently erase their decision. Presence projection is limited to nearby active leaves and exact-location dormant members, includes reciprocal player relationship evidence, and may propose an earned cast without committing it. Simulation cells are derived covers only.

478
tools/typeset_newspaper.py Normal file
View file

@ -0,0 +1,478 @@
#!/usr/bin/env python3
"""Typeset a compact Markdown newspaper front page as a press-ready PDF."""
from __future__ import annotations
import argparse
import html
import math
import random
import re
from dataclasses import dataclass
from pathlib import Path
from xml.sax.saxutils import escape
from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY, TA_LEFT
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.units import inch
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import Frame, Paragraph
INK = colors.HexColor("#17120d")
PAPER = colors.HexColor("#f2e6ca")
HAIRLINE = colors.HexColor("#4b3b2b")
FOLIO = (10 * inch, 12.2 * inch)
@dataclass(frozen=True)
class Article:
section: str | None
headline: str
deck: str
author: str
paragraphs: tuple[str, ...]
@dataclass(frozen=True)
class Edition:
masthead: str
edition: str
articles: tuple[Article, ...]
def register_fonts() -> None:
font_root = Path("C:/Windows/Fonts")
faces = {
"LedgerSerif": "times.ttf",
"LedgerSerifBold": "timesbd.ttf",
"LedgerSerifItalic": "timesi.ttf",
"LedgerDisplay": "georgia.ttf",
"LedgerDisplayBold": "georgiab.ttf",
"LedgerDisplayItalic": "georgiai.ttf",
}
for family, filename in faces.items():
path = font_root / filename
if not path.exists():
raise FileNotFoundError(f"Required font is unavailable: {path}")
pdfmetrics.registerFont(TTFont(family, str(path)))
pdfmetrics.registerFontFamily(
"LedgerSerif",
normal="LedgerSerif",
bold="LedgerSerifBold",
italic="LedgerSerifItalic",
boldItalic="LedgerSerifBold",
)
pdfmetrics.registerFontFamily(
"LedgerDisplay",
normal="LedgerDisplay",
bold="LedgerDisplayBold",
italic="LedgerDisplayItalic",
boldItalic="LedgerDisplayBold",
)
def _plain_markdown(text: str) -> str:
text = text.strip()
if len(text) >= 4 and text.startswith("**") and text.endswith("**"):
text = text[2:-2]
elif len(text) >= 2 and text[:1] == text[-1:] and text[:1] in {"*", "_"}:
text = text[1:-1]
return html.unescape(text)
def _inline_markup(text: str) -> str:
"""Render only the tiny inline-Markdown subset used by the frozen copy."""
source = html.unescape(text)
parts = re.split(r"(\*\*.*?\*\*|\*.*?\*)", source)
rendered: list[str] = []
for part in parts:
if part.startswith("**") and part.endswith("**"):
rendered.append(f"<b>{escape(part[2:-2])}</b>")
elif part.startswith("*") and part.endswith("*"):
rendered.append(f"<i>{escape(part[1:-1])}</i>")
else:
rendered.append(escape(part))
return "".join(rendered)
def parse_edition(path: Path) -> Edition:
lines = path.read_text(encoding="utf-8").splitlines()
masthead = ""
edition = ""
articles: list[Article] = []
section: str | None = None
index = 0
while index < len(lines):
line = lines[index].strip()
if line.startswith("# ") and not masthead:
masthead = _plain_markdown(line[2:])
elif not edition and line.startswith("*") and line.endswith("*"):
edition = _plain_markdown(line)
elif line.startswith("### "):
section = _plain_markdown(line[4:])
elif line.startswith("## "):
headline = _plain_markdown(line[3:])
index += 1
while index < len(lines) and not lines[index].strip():
index += 1
deck = _plain_markdown(lines[index])
index += 1
while index < len(lines) and not lines[index].strip():
index += 1
author = _plain_markdown(lines[index])
index += 1
paragraphs: list[str] = []
paragraph_lines: list[str] = []
while index < len(lines):
current = lines[index].strip()
if current == "---" or current.startswith("### ") or current.startswith("## "):
if paragraph_lines:
paragraphs.append(" ".join(paragraph_lines))
paragraph_lines.clear()
break
if current:
paragraph_lines.append(current)
elif paragraph_lines:
paragraphs.append(" ".join(paragraph_lines))
paragraph_lines.clear()
index += 1
if paragraph_lines:
paragraphs.append(" ".join(paragraph_lines))
articles.append(Article(section, headline, deck, author, tuple(paragraphs)))
section = None
continue
index += 1
if not masthead or not edition or len(articles) != 3:
raise ValueError(
f"Expected masthead, edition label, and three articles; got "
f"masthead={bool(masthead)}, edition={bool(edition)}, articles={len(articles)}"
)
return Edition(masthead, edition, tuple(articles))
def _styles(body_size: float) -> dict[str, ParagraphStyle]:
return {
"body": ParagraphStyle(
"body",
fontName="LedgerSerif",
fontSize=body_size,
leading=body_size * 1.18,
alignment=TA_JUSTIFY,
textColor=INK,
spaceAfter=body_size * 0.72,
allowWidows=0,
allowOrphans=0,
),
"section": ParagraphStyle(
"section",
fontName="LedgerDisplayBold",
fontSize=7.3,
leading=8.4,
alignment=TA_LEFT,
textColor=INK,
spaceAfter=3.5,
borderPadding=(2.2, 0, 1.5, 0),
borderWidth=0,
borderColor=HAIRLINE,
),
"side_head": ParagraphStyle(
"side_head",
fontName="LedgerDisplayBold",
fontSize=15.5,
leading=16.3,
alignment=TA_LEFT,
textColor=INK,
spaceAfter=5.2,
),
"side_deck": ParagraphStyle(
"side_deck",
fontName="LedgerDisplayItalic",
fontSize=8.2,
leading=10.0,
alignment=TA_LEFT,
textColor=INK,
spaceAfter=5.0,
),
"byline": ParagraphStyle(
"byline",
fontName="LedgerDisplayBold",
fontSize=7.4,
leading=8.5,
alignment=TA_LEFT,
textColor=INK,
spaceAfter=5.5,
),
}
def _paper_texture(canvas: Canvas, width: float, height: float) -> None:
canvas.setFillColor(PAPER)
canvas.rect(0, 0, width, height, fill=1, stroke=0)
random.seed(6043)
canvas.saveState()
try:
canvas.setStrokeAlpha(0.10)
except AttributeError:
pass
for _ in range(150):
shade = random.choice((colors.HexColor("#8d7354"), colors.HexColor("#fff7e4")))
canvas.setStrokeColor(shade)
canvas.setLineWidth(random.choice((0.15, 0.22, 0.3)))
x = random.uniform(0.2 * inch, width - 0.2 * inch)
y = random.uniform(0.2 * inch, height - 0.2 * inch)
length = random.uniform(7, 32)
canvas.line(x, y, min(width - 0.2 * inch, x + length), y + random.uniform(-0.5, 0.5))
canvas.restoreState()
def _pressmark(canvas: Canvas, x: float, y: float, scale: float = 1.0) -> None:
"""A small one-color canopy mark, drawn like a worn printer's ornament."""
canvas.saveState()
canvas.translate(x, y)
canvas.scale(scale, scale)
canvas.setStrokeColor(INK)
canvas.setLineCap(1)
canvas.setLineJoin(1)
canvas.setLineWidth(1.0)
canvas.circle(0, 0, 20, fill=0, stroke=1)
canvas.line(0, -14, 0, 10)
for dx, dy, ex, ey in (
(0, 7, -13, 15),
(0, 6, 13, 15),
(0, 2, -16, 7),
(0, 2, 16, 7),
(-4, -3, -13, -8),
(4, -3, 13, -8),
):
canvas.line(dx, dy, ex, ey)
for offset in (-3, 0, 3):
canvas.line(-9, -14 + offset * 0.25, 9, -14 + offset * 0.25)
canvas.restoreState()
def _draw_rule(canvas: Canvas, x1: float, y: float, x2: float, width: float = 0.7) -> None:
canvas.saveState()
canvas.setStrokeColor(INK)
canvas.setLineWidth(width)
canvas.line(x1, y, x2, y + 0.15)
canvas.restoreState()
def _root_rail_cut(canvas: Canvas, x: float, y: float, width: float, height: float) -> None:
"""Wordless relief-print ornament: roots crossing a breached pair of rails."""
canvas.saveState()
canvas.setStrokeColor(INK)
canvas.setFillColor(INK)
canvas.setLineCap(1)
canvas.setLineJoin(1)
canvas.setLineWidth(0.75)
canvas.rect(x, y, width, height, fill=0, stroke=1)
pad = 8
for offset in (0.34, 0.66):
rail_x = x + width * offset
canvas.setLineWidth(2.0)
canvas.line(rail_x, y + pad, rail_x, y + height - pad)
canvas.setLineWidth(0.45)
canvas.line(rail_x - 3, y + pad, rail_x - 3, y + height - pad)
for rung_y in range(int(y + 13), int(y + height - 8), 12):
canvas.setLineWidth(0.65)
canvas.line(x + width * 0.27, rung_y, x + width * 0.73, rung_y + 1.2)
roots = (
[(0.08, 0.08), (0.20, 0.25), (0.38, 0.30), (0.55, 0.52), (0.89, 0.88)],
[(0.02, 0.68), (0.24, 0.56), (0.46, 0.60), (0.70, 0.38), (0.98, 0.30)],
[(0.18, 0.98), (0.29, 0.77), (0.55, 0.71), (0.78, 0.57), (0.96, 0.10)],
)
canvas.setLineWidth(3.1)
for points in roots:
path = canvas.beginPath()
first_x, first_y = points[0]
path.moveTo(x + first_x * width, y + first_y * height)
for point_x, point_y in points[1:]:
path.lineTo(x + point_x * width, y + point_y * height)
canvas.drawPath(path, fill=0, stroke=1)
canvas.setLineWidth(0.38)
for index in range(24):
hatch_x = x + pad + (index * 17) % max(18, int(width - 2 * pad))
hatch_y = y + pad + (index * 29) % max(18, int(height - 2 * pad))
canvas.line(hatch_x, hatch_y, hatch_x + 11, hatch_y + 5)
canvas.restoreState()
def _gauge_cut(canvas: Canvas, x: float, y: float, width: float, height: float) -> None:
"""Wordless relief-print ornament: a calibrated counterflow dial."""
canvas.saveState()
canvas.setStrokeColor(INK)
canvas.setFillColor(INK)
canvas.setLineCap(1)
canvas.setLineJoin(1)
canvas.setLineWidth(0.75)
canvas.rect(x, y, width, height, fill=0, stroke=1)
radius = min(width * 0.30, height * 0.38)
cx = x + width / 2
cy = y + height * 0.50
canvas.setLineWidth(1.5)
canvas.circle(cx, cy, radius, fill=0, stroke=1)
canvas.circle(cx, cy, radius * 0.82, fill=0, stroke=1)
for step in range(13):
angle = 3.1415926535 * (0.12 + 0.063 * step)
inner = radius * (0.70 if step % 3 else 0.62)
outer = radius * 0.84
canvas.line(
cx + inner * math.cos(angle),
cy + inner * math.sin(angle),
cx + outer * math.cos(angle),
cy + outer * math.sin(angle),
)
canvas.setLineWidth(2.4)
canvas.line(cx, cy, cx + radius * 0.45, cy + radius * 0.48)
canvas.circle(cx, cy, 3.2, fill=1, stroke=0)
canvas.setLineWidth(0.45)
for offset in (-8, -4, 0, 4, 8):
canvas.line(x + 10, y + 10 + offset * 0.2, x + width * 0.25, cy + offset)
canvas.line(x + width - 10, y + 10 - offset * 0.2, x + width * 0.75, cy - offset)
canvas.restoreState()
def _build_article(article: Article, styles: dict[str, ParagraphStyle], include_header: bool) -> list:
story: list = []
if include_header:
if article.section:
story.append(Paragraph(escape(article.section.upper()), styles["section"]))
story.append(Paragraph(escape(article.headline), styles["side_head"]))
story.append(Paragraph(escape(article.deck), styles["side_deck"]))
story.append(Paragraph(f"BY {escape(article.author.upper())}", styles["byline"]))
else:
story.append(Paragraph(f"BY {escape(article.author.upper())}", styles["byline"]))
for paragraph in article.paragraphs:
story.append(Paragraph(_inline_markup(paragraph), styles["body"]))
return story
def _draw_page(canvas: Canvas, edition: Edition, body_size: float) -> bool:
width, height = FOLIO
_paper_texture(canvas, width, height)
margin_x = 0.48 * inch
usable_width = width - 2 * margin_x
# Printer's strapline and masthead.
canvas.setFillColor(INK)
canvas.setFont("LedgerDisplay", 7.2)
canvas.drawString(margin_x, height - 0.34 * inch, edition.edition.upper())
_draw_rule(canvas, margin_x, height - 0.43 * inch, width - margin_x, 0.55)
masthead_y = height - 0.91 * inch
_pressmark(canvas, margin_x + 22, masthead_y + 3, 0.83)
_pressmark(canvas, width - margin_x - 22, masthead_y + 3, 0.83)
canvas.setFont("LedgerDisplayBold", 32)
canvas.drawCentredString(width / 2, masthead_y - 7, edition.masthead.upper())
_draw_rule(canvas, margin_x, height - 1.23 * inch, width - margin_x, 1.65)
_draw_rule(canvas, margin_x, height - 1.29 * inch, width - margin_x, 0.42)
# Lead banner.
lead = edition.articles[0]
lead_head = ParagraphStyle(
"lead_head",
fontName="LedgerDisplayBold",
fontSize=25,
leading=26.2,
alignment=TA_CENTER,
textColor=INK,
)
lead_deck = ParagraphStyle(
"lead_deck",
fontName="LedgerDisplayItalic",
fontSize=9.5,
leading=11.8,
alignment=TA_CENTER,
textColor=INK,
)
headline = Paragraph(escape(lead.headline), lead_head)
_, headline_h = headline.wrap(usable_width - 0.35 * inch, 0.8 * inch)
headline_y = height - 1.45 * inch - headline_h
headline.drawOn(canvas, margin_x + 0.175 * inch, headline_y)
deck = Paragraph(escape(lead.deck), lead_deck)
_, deck_h = deck.wrap(usable_width - 0.85 * inch, 0.5 * inch)
deck_y = headline_y - 0.09 * inch - deck_h
deck.drawOn(canvas, margin_x + 0.425 * inch, deck_y)
column_top = deck_y - 0.16 * inch
_draw_rule(canvas, margin_x, column_top, width - margin_x, 0.7)
column_top -= 0.12 * inch
gutter = 0.20 * inch
column_width = (usable_width - 2 * gutter) / 3
column_bottom = 0.42 * inch
column_height = column_top - column_bottom
styles = _styles(body_size)
all_fit = True
for index, article in enumerate(edition.articles):
x = margin_x + index * (column_width + gutter)
if index:
divider_x = x - gutter / 2
canvas.setStrokeColor(HAIRLINE)
canvas.setLineWidth(0.35)
canvas.line(divider_x, column_bottom, divider_x, column_top)
frame = Frame(
x,
column_bottom,
column_width,
column_height,
leftPadding=0,
rightPadding=0,
topPadding=0,
bottomPadding=0,
showBoundary=0,
)
story = _build_article(article, styles, include_header=index != 0)
frame.addFromList(story, canvas)
if story:
all_fit = False
if all_fit:
_root_rail_cut(canvas, margin_x, column_bottom + 4, column_width, 2.45 * inch)
_gauge_cut(
canvas,
margin_x + column_width + gutter,
column_bottom + 4,
column_width,
1.25 * inch,
)
return all_fit
def render(edition: Edition, output: Path) -> float:
output.parent.mkdir(parents=True, exist_ok=True)
last_size = 0.0
for body_size in (10.0, 9.7, 9.4, 9.1, 8.8, 8.5, 8.15):
last_size = body_size
canvas = Canvas(str(output), pagesize=FOLIO, pageCompression=1)
canvas.setTitle(edition.masthead)
canvas.setAuthor("The Canopy Ledger")
fit = _draw_page(canvas, edition, body_size)
canvas.showPage()
canvas.save()
if fit:
return body_size
raise RuntimeError(f"Copy did not fit at minimum body size {last_size}")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("source", type=Path, help="Markdown front page")
parser.add_argument("output", type=Path, help="Output PDF")
args = parser.parse_args()
register_fonts()
edition = parse_edition(args.source)
body_size = render(edition, args.output)
print(f"typeset={args.output} body_size={body_size:.2f} articles={len(edition.articles)}")
if __name__ == "__main__":
main()