Build seeded TeX newspaper press

This commit is contained in:
Meta Vens 2026-08-30 18:30:56 +02:00
commit f7a02bc6c1
12 changed files with 428 additions and 471 deletions

View file

@ -0,0 +1,70 @@
# The Canopy Ledger
*Current Edition*
---
## The Cordon Holds. So Do the Severed Hands.
*Roots are bending toward live mana conduits, the Exchange remains isolated, and severed hands lie undisturbed at the scene.*
**Mera Quill**
**Boundary Rail Exchange —** The Boundary Rail Exchange remains isolated as roots bend toward active mana conduits, with its warning procedure maintained and severed hands left undisturbed at the scene. That last fact is the one procedure cannot make tidy: at the Exchange, severed hands remain while roots bend toward live power.
Boundary Exchange Workers erected a reinforced conduit exclusion cordon on Sept. 20. Eight hours later, Vesa Thorn marked a safe withdrawal route and widened the warning cordon. By early Sept. 21, Orin Pell had recorded that the roots were bending toward active mana conduits and that the Exchange must remain isolated. The sequence is emergency restraint: workers established a cordon, retreat was made clearer, and isolation continued after the roots’ movement toward active conduits was recorded.
But a cordon and warning procedure do two things at once: they mark a boundary around the conduits, while the hands remain undisturbed at the Exchange. No operational warning answers who lost them, who controls their recovery and examination, or whose authority could safely reopen the Exchange while the roots still lean toward active mana. Until those questions are answered, the severed hands are not incidental debris beside a safety procedure. They are the bodily account the procedure has not yet settled.
---
### Guilds & Trade
## Sinkroot Wins Scrutiny, Not Yet Safety, as Deep-Current Dispute Settles
*Residents have secured immediate scrutiny of measured deep-current turbulence and proposed interim feeding, but the public rail shows neither a shutdown nor restitution—and a petition for a stay is not a stay.*
**Ossan Reed**
**Sinkroot Gauge Gallery —** Immediate public scrutiny of Sinkroot’s measured deep-current turbulence and proposed interim feeding has been settled after residents established a dated public report and petition. That is a material gain for the Sinkroot Residential Roll: readings and the proposed response can no longer be treated as private workshop weather. It is not, on the present public account, proof that the turbulence has stopped, that a temporary stay has taken effect, or that any household has received restitution.
Hours earlier, the residential roll established a dated turbulence report and petitioned for disclosure, restitution and a temporary stay. The order matters. First the residential roll made a record; then scrutiny became immediate. Discussion first and measurements later is a familiar arrangement, but a dated gauge reading is harder to sweep into the sump than a reassurance.
The unresolved mechanical question is interim feeding: what is being fed, through which arrangement, under what measured conditions, and who remains nearest the consequences if the turbulence persists. The settled scrutiny makes that proposal public business, but the available account does not specify what residents may halt, which thresholds would compel a shutdown, or who must answer if the proposed feed worsens the current. A petition can restrain an official’s sleep; only an operational restraint can restrain a system.
Sinkroot is not alone in reaching for instruments before promises. Gaugewatch Households have retained a dated calibrated counterflow report and a hearing petition at Gaugewatch Gallery. Two residential populations now possess measurements and written demands. That may be the beginning of leverage, but nobody should mistake paperwork for pressure relief: until the readings produce safer feeding, an enforceable stop or paid restitution, the households are still carrying the load while the rail records the dispute.
---
### Courts & Councils
## The Witnesses Copied the Ledger Before the Curator Found His Voice
*Witnesses secured separately recorded accounts before Curator Oren Vale stated an intention to conduct interviews and issue a rejection of interference—without saying whose interference was at issue.*
**Aven Tarl**
**Contested Seed-Vault —** The Seed-Vault Witnesses created and retained an independent copy of the witness ledger, with accounts recorded separately, at 22:00 on Sept. 20. That is the accomplished fact in this dispute: the people whose testimony is at stake created their own record before the curator’s stated course could take effect.
Eight hours later, Curator Oren Vale publicly stated an intention to conduct separate formal witness interviews and issue an in-person statement rejecting seizure, substituted testimony and unsupported custody claims. It is an admirable catalogue of abuses to oppose, and a revealing one. Vale did not name the person or authority behind any seizure, substitution or custody claim, leaving the public with all the ceremonial language of vigilance and none of the discourtesy of identifying whom vigilance is required against.
Vale’s interviews and proposed rejection remain a promised course. The witnesses’ copy already exists in their keeping. Whatever title eventually arrives to assert custody, the contest now begins from the embarrassing position that the witnesses acted first—and that their dignity is safer in a duplicated ledger than in anyone’s reassurance.
---
### Dispatches
## Grain-Passage Proposal Announced; the Bridge Remains the Weapon
*The commune has proposed the same witnessed accounting terms to the claimant, the courts and the refusing legion. None has yet proved that grain or its carriers can cross safely.*
**Lysa Fen**
**Occupied River Toll Bridge —** The River Commune Bridge Assembly has publicly proposed opening witnessed grain-passage negotiations with the Veiled Claimant and the Rival Bloodline Courts, while proposing to send the same accounting offer to the Refusing Border Legion. That symmetry is the commune’s sharpest available defense: no throne, bloodline or uniform is offered private terms in the proposal. It is not yet safe passage. At an occupied crossing, every contender can praise procedure; the test is whether the proposal produces passage.
In a public statement, the legion proposed issuing and publishing an order barring seizure of any toll, seed-vault, child or ledger under disputed command. The statement also proposed requesting authenticated legal grounds and directing patrols to secure the camp and approaches without advancing. Read from the bridge rather than the camp, the useful words are “without advancing”; the dangerous ones are “secure” and “approaches.” A proposed non-seizure order restrains soldiers on paper only if issued and followed. It does not establish who controls the road, who may halt a carrier, or whether refusal survives the first disputed wagon.
Marshal Eryn Tal has committed a witnessed summons and conference-procedure order, copied by canopy-wire. That gives the coming contest witnesses and a public trail. But the next meaningful fact will not be another signature. It will be grain crossing with its carriers and commune negotiators intact—and an accounting no claimant, court or legion can alter after the gates close.

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

View file

@ -0,0 +1,15 @@
# Boundary Rail Exchange woodcut
Generation mode: built-in ImageGen, fresh generation from text.
```text
Use case: illustration-story
Asset type: printable wordless woodcut for an early hand-press fantasy broadsheet
Primary request: a stark Boundary Rail Exchange breach: two heavy parallel iron rails and timber sleepers cross a cramped masonry exchange; thick living roots bend toward exposed mana conduits; in the immediate foreground lie several unmistakable severed gloved elven hands, each palm marked by a different simple burned court seal; a rough warning cordon and marked withdrawal path remain secondary
Style/medium: authentic late-15th-century relief woodcut printed in BLACK INK ON WHITE PAPER, bold carved black contours, irregular crosshatching, white negative space between marks, uneven hand-pressed ink, archaic perspective
Composition/framing: horizontal rectangular block, centered subject, roots and severed hands clearly readable at 3-inch newspaper width, balanced white margins
Color palette: black marks on a bright white background only
Constraints: the page must be predominantly white, never a black or dark background; wordless; no caption, letters, numerals, frame, logo, watermark, gradients, gray wash, glowing white lines, photorealism, modern comic style, or vector cleanliness; macabre but not wet or photorealistically gory
```
Press preparation: converted to grayscale, thresholded at 72%, and trimmed without semantic edits.

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View file

@ -0,0 +1,15 @@
# Sinkroot Gauge Gallery woodcut
Generation mode: built-in ImageGen, fresh generation from text.
```text
Use case: illustration-story
Asset type: printable wordless woodcut for an early hand-press fantasy broadsheet
Primary request: Sinkroot Gauge Gallery during a deep-current dispute: one enormous round calibrated pressure gauge with a violently deflected needle dominates the center; branching iron pipes, rootlike conduits, worn shutoff wheels, workers' measuring rods, and a bound petition sit below it; carved wave and vibration marks inside the pipes imply turbulent current
Style/medium: authentic late-15th-century relief woodcut printed in BLACK INK ON WHITE PAPER, bold carved black outlines, rhythmic crosshatching, ample white negative space, uneven hand-pressed ink, archaic technical illustration without labels
Composition/framing: horizontal rectangular block, central dial unmistakable and fully visible, petition and tools secondary, readable at 3-inch newspaper width, balanced white margins
Color palette: black marks on a bright white background only
Constraints: the page must be predominantly white, never a black or dark background; wordless; no caption, letters, numerals, frame, logo, watermark, gradients, gray wash, glowing white lines, photorealism, modern vector style, or steampunk clutter
```
Press preparation: converted to grayscale, thresholded at 72%, and trimmed without semantic edits.

View file

@ -1490,8 +1490,17 @@ Typesetting is delivered at
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.
144-DPI visual QA finds no clipping or overlap. Preserve it as the verified
Run 60 press baseline.
The operator has now authorized a new press-layout task over the designated
accepted Canopy Ledger edition. Do not rewrite its copy. Build a reusable
random-seed-driven newspaper layout script with bounded typography and press
variation; the same source and seed must reproduce the same layout choices.
Use ImageGen for story-specific woodcuts. Reserve printer's ornaments and
fleurons for reusable decoration rather than narrative illustration. Preserve
the source identity plus generation and visual provenance with the finished
artifact. This task temporarily precedes the Delvehold adapter.
Run 54 exposed one separate upstream publicity defect: a public strategic
activity propagates its channel to private `MemberMemory` outcomes, which is
@ -1502,8 +1511,11 @@ materializing a `NewsIssue`.
1. Preserve immutable accepted Run 60 and Run 59; do not rewrite, recompose, or
resimulate either edition.
2. Preserve the delivered typeset PDF and its frozen-copy verification above.
3. Advance the Delvehold-owned adapter against
2. Preserve the delivered Run 60 PDF and its frozen-copy verification as the
baseline. Typeset the operator-designated accepted Canopy Ledger with the
reusable seeded press-layout machinery described above; verify the artifact
without changing accepted copy.
3. After the press-layout task, advance the Delvehold-owned adapter against
Ghostlight's admitted generic consumer contract. Defer multiple publications
until separately authorized, or follow the operator's next directed work.
Preserve immutable Runs 60 through 63 and do not replay newspaper work for

View file

@ -1688,6 +1688,52 @@ applies the same plain-text boundary to its audit data. Neither assignment
editor, writer, copy desk, Night Editor, renderer, nor receipt persistence can
write world state.
#### Seeded TeX press projection
- **Owner:** the operator-designated frozen newsroom Markdown owns masthead,
edition label, sections, story order, headlines, decks, bylines, and every
paragraph. `tools/typeset_newspaper.py` owns presentation only: parsing that
copy, choosing bounded seed-driven style, emitting TeX and its manifest, and
invoking LuaLaTeX. Woodcut bitmaps and their adjacent prompt sidecars own
illustration provenance, not facts or copy.
- **Inputs:** one frozen Markdown edition, one required integer seed, zero or
more ordered wordless woodcut PNGs, output/TeX/manifest paths, and an optional
LuaLaTeX engine or `--no-compile`. The current Run 61 projection uses seed
1847 and the `boundary-rail-exchange` and `sinkroot-gauge-gallery` cuts. Their
matching prompt files preserve the exact fresh ImageGen requests and record
grayscale, 72-percent threshold, and trim preparation.
- **Outputs:** escaped TeX, a `ghostlight.seeded_tex_press.v1` TOML manifest, and
optionally a compiled PDF. The manifest records source/output/TeX paths,
seed, selected style values, ordered woodcut paths, and SHA-256 digests for
source, TeX, tool, woodcuts, engine, and compiled output. These are press
artifacts, not another newsroom composition or correction surface.
- **Derived state:** the parsed `Edition`, seeded `Style`, page grouping,
ornament choice, image placement and widths, TeX markup, and PDF pagination
are presentation projections. The same seed deterministically selects the
same bounded style. Exact portable PDF reproduction additionally depends on
the named system fonts; source, woodcut, engine, tool, TeX, and output bytes
are bound by the manifest.
- **Forbidden writers:** the press, TeX compiler, manifest, woodcuts, and prompt
sidecars cannot add, remove, summarize, reorder, correct, or reinterpret
newsroom copy; change bylines or story priority; write newsroom checkpoints
or world state; or promote an illustration into evidence. A successful PDF
compile cannot admit missing or altered copy.
- **Shared path:** every press run parses the frozen source, derives one style
from the seed, writes TeX and manifest, then either stops at `--no-compile` or
compiles that exact TeX in a temporary directory and copies the resulting PDF
to the named output. Woodcut order alone selects lead and later placement.
- **Cut line:** the live press path contains no model rewrite, editorial
summarizer, copy-fitting loop, or second content owner. Press variation is
bounded to page geometry, typography, color, rules, fleurons, and illustration
sizing/placement; the former fixed ReportLab front-page renderer is not live.
- **Verification layer:** admission requires source-to-TeX/PDF copy completeness
and order, a same-source/seed derivation check, successful halt-on-error
compilation, rendered-page inspection for clipping and overlap, and matching
manifest/woodcut prompt provenance. LuaLaTeX produces a two-page PDF with
embedded Times New Roman faces. The parser flushes the final article paragraph
at end of file; extracted copy includes the closing `Marshal Eryn Tal`
paragraph, and rendered inspection shows no clipping or overlap.
#### Acceptance-driver per-wave newspaper recovery
- **Owner:** the strategic acceptance driver owns recovery of a missing

Binary file not shown.

View file

@ -0,0 +1,38 @@
schema = "ghostlight.seeded_tex_press.v1"
source = "F:/Projects/Ghostlight/acceptance-run61-newspaper.md"
source_sha256 = "8f195140f52a40ba705d6a66987146a1500ae89668e5d82b01d1792eb3afb882"
output = "F:/Projects/Ghostlight/output/pdf/canopy-ledger-run-61-seed-1847.pdf"
tex = "F:/Projects/Ghostlight/output/tex/canopy-ledger-run-61-seed-1847.tex"
tex_sha256 = "c25e7390b2ca3ba68d455962401cc7e7bb77ce99caf4610f4565a380ca10a0ff"
tool = "F:/Projects/Ghostlight/tools/typeset_newspaper.py"
tool_sha256 = "3090b99546c12d2cb9afa44ec1afa2270cb7474aadd3a242a7dafeab37e931d7"
seed = 1847
engine = "C:/Users/Meta/AppData/Local/Programs/MiKTeX/miktex/bin/x64/lualatex.exe"
engine_sha256 = "e2bce0b59faf2d6501d0d03d8d13a5b93621285d7839438a19911ef963d46bfa"
output_sha256 = "529de5202c5c948f3e1339f8663184cc933794a0d85c1e42f30cd84a7f95c6c2"
[style]
width = 9.8
height = 14.0
margin = 0.38
body = 8.6
leading = 1.04
gutter = 10.5
rule = 0.36
masthead = 39
lead = 29
display = "Times New Roman"
text = "Times New Roman"
paper = "EFE1C2"
ink = "21170F"
fleuron = "cross"
lead_cut = 0.82
inside_cut = 0.54
rules = "double"
[[woodcuts]]
path = "F:/Projects/Ghostlight/assets/newspaper/woodcuts/boundary-rail-exchange.png"
sha256 = "87666118f63d55c797cece58eb5b09ff0984cecfe9c5e29e69932c830508bf87"
[[woodcuts]]
path = "F:/Projects/Ghostlight/assets/newspaper/woodcuts/sinkroot-gauge-gallery.png"
sha256 = "496e5b83e296e9c4479c9b00eca01ac34ed610de2aa11f8a9353ac1dc5f3cc95"

View file

@ -0,0 +1,60 @@
\documentclass{article}
\usepackage[paperwidth=9.8in,paperheight=14.0in,margin=0.38in]{geometry}
\usepackage{fontspec,graphicx,xcolor,microtype,multicol,ragged2e,fancyhdr}
\setmainfont{Times New Roman}\newfontfamily\displayface{Times New Roman}\newfontfamily\sectionface{Times New Roman}
\definecolor{paper}{HTML}{EFE1C2}\definecolor{ink}{HTML}{21170F}\pagecolor{paper}\color{ink}
\setlength{\columnsep}{10.5pt}\setlength{\columnseprule}{0.36pt}\setlength{\parindent}{1em}\setlength{\parskip}{0pt}\setlength{\emergencystretch}{1em}
\pagestyle{fancy}\fancyhf{}\renewcommand{\headrulewidth}{0pt}\fancyfoot[C]{\displayface\fontsize{6.5}{7}\selectfont THE CANOPY LEDGER · SEED 1847 · \thepage}
\newcommand{\pressrules}{\hrule height 1.1pt\vspace{2pt}\hrule height .35pt}\newcommand{\fleuron}{\rule{18pt}{.35pt}\hspace{5pt}\raisebox{-1pt}{\rule{1pt}{7pt}}\hspace{-4pt}\rule{7pt}{1pt}\hspace{5pt}\rule{18pt}{.35pt}}
\newcommand{\masthead}[1]{{\displayface\bfseries\fontsize{39}{41}\selectfont\centering\MakeUppercase{#1}\par}}
\newcommand{\leadhead}[1]{{\displayface\bfseries\fontsize{29}{30.5}\selectfont\centering #1\par}}
\newcommand{\storyhead}[1]{{\displayface\bfseries\fontsize{14}{15}\selectfont\RaggedRight #1\par\smallskip}}
\newcommand{\deck}[1]{{\displayface\itshape\fontsize{8}{9.2}\selectfont\RaggedRight #1\par\smallskip}}
\newcommand{\byline}[1]{{\displayface\bfseries\fontsize{6.8}{7.5}\selectfont\MakeUppercase{By #1}\par\smallskip}}
\AtBeginDocument{\fontsize{8.6}{8.94}\selectfont\justifying}
\begin{document}\thispagestyle{fancy}
{\displayface\fontsize{7}{8}\selectfont CURRENT EDITION\hfill SOURCE: acceptance-run61-newspaper.md\par}\smallskip\pressrules\smallskip
\masthead{The Canopy Ledger}\smallskip\pressrules\medskip
\leadhead{The Cordon Holds. So Do the Severed Hands.}\smallskip
{\displayface\itshape\fontsize{9.2}{10.5}\selectfont\centering Roots are bending toward live mana conduits, the Exchange remains isolated, and severed hands lie undisturbed at the scene.\par}
{\displayface\bfseries\fontsize{7}{8}\selectfont\centering BY MERA QUILL\par}\smallskip
\noindent\includegraphics[width=0.82\textwidth]{F:/Projects/Ghostlight/assets/newspaper/woodcuts/boundary-rail-exchange.png}\par\smallskip
\begin{multicols}{3}\textbf{Boundary Rail Exchange —} The Boundary Rail Exchange remains isolated as roots bend toward active mana conduits, with its warning procedure maintained and severed hands left undisturbed at the scene. That last fact is the one procedure cannot make tidy: at the Exchange, severed hands remain while roots bend toward live power.
Boundary Exchange Workers erected a reinforced conduit exclusion cordon on Sept. 20. Eight hours later, Vesa Thorn marked a safe withdrawal route and widened the warning cordon. By early Sept. 21, Orin Pell had recorded that the roots were bending toward active mana conduits and that the Exchange must remain isolated. The sequence is emergency restraint: workers established a cordon, retreat was made clearer, and isolation continued after the roots’ movement toward active conduits was recorded.
But a cordon and warning procedure do two things at once: they mark a boundary around the conduits, while the hands remain undisturbed at the Exchange. No operational warning answers who lost them, who controls their recovery and examination, or whose authority could safely reopen the Exchange while the roots still lean toward active mana. Until those questions are answered, the severed hands are not incidental debris beside a safety procedure. They are the bodily account the procedure has not yet settled.\end{multicols}\vfill\begin{center}\fleuron\end{center}
\clearpage\thispagestyle{fancy}{\displayface\fontsize{7}{8}\selectfont CURRENT EDITION\hfill PAGE 2\par}\smallskip\pressrules\smallskip
{\displayface\bfseries\fontsize{18}{19}\selectfont\centering The Canopy Ledger\par}\smallskip\pressrules\medskip
\noindent
\begin{minipage}[t]{\dimexpr(\textwidth-2\columnsep)/3\relax}\begin{minipage}[t]{\linewidth}{\sectionface\fontsize{7.2}{8}\selectfont\MakeUppercase{Guilds \& Trade}}\par\smallskip \storyhead{Sinkroot Wins Scrutiny, Not Yet Safety, as Deep-Current Dispute Settles}
\deck{Residents have secured immediate scrutiny of measured deep-current turbulence and proposed interim feeding, but the public rail shows neither a shutdown nor restitution—and a petition for a stay is not a stay.}
\byline{Ossan Reed}
\textbf{Sinkroot Gauge Gallery —} Immediate public scrutiny of Sinkroot’s measured deep-current turbulence and proposed interim feeding has been settled after residents established a dated public report and petition. That is a material gain for the Sinkroot Residential Roll: readings and the proposed response can no longer be treated as private workshop weather. It is not, on the present public account, proof that the turbulence has stopped, that a temporary stay has taken effect, or that any household has received restitution.
Hours earlier, the residential roll established a dated turbulence report and petitioned for disclosure, restitution and a temporary stay. The order matters. First the residential roll made a record; then scrutiny became immediate. Discussion first and measurements later is a familiar arrangement, but a dated gauge reading is harder to sweep into the sump than a reassurance.
The unresolved mechanical question is interim feeding: what is being fed, through which arrangement, under what measured conditions, and who remains nearest the consequences if the turbulence persists. The settled scrutiny makes that proposal public business, but the available account does not specify what residents may halt, which thresholds would compel a shutdown, or who must answer if the proposed feed worsens the current. A petition can restrain an official’s sleep; only an operational restraint can restrain a system.
Sinkroot is not alone in reaching for instruments before promises. Gaugewatch Households have retained a dated calibrated counterflow report and a hearing petition at Gaugewatch Gallery. Two residential populations now possess measurements and written demands. That may be the beginning of leverage, but nobody should mistake paperwork for pressure relief: until the readings produce safer feeding, an enforceable stop or paid restitution, the households are still carrying the load while the rail records the dispute.\end{minipage}\end{minipage}\hspace{\columnsep}%
\begin{minipage}[t]{\dimexpr(\textwidth-2\columnsep)/3\relax}\begin{minipage}[t]{\linewidth}{\sectionface\fontsize{7.2}{8}\selectfont\MakeUppercase{Courts \& Councils}}\par\smallskip \storyhead{The Witnesses Copied the Ledger Before the Curator Found His Voice}
\deck{Witnesses secured separately recorded accounts before Curator Oren Vale stated an intention to conduct interviews and issue a rejection of interference—without saying whose interference was at issue.}
\byline{Aven Tarl}
\textbf{Contested Seed-Vault —} The Seed-Vault Witnesses created and retained an independent copy of the witness ledger, with accounts recorded separately, at 22:00 on Sept. 20. That is the accomplished fact in this dispute: the people whose testimony is at stake created their own record before the curator’s stated course could take effect.
Eight hours later, Curator Oren Vale publicly stated an intention to conduct separate formal witness interviews and issue an in-person statement rejecting seizure, substituted testimony and unsupported custody claims. It is an admirable catalogue of abuses to oppose, and a revealing one. Vale did not name the person or authority behind any seizure, substitution or custody claim, leaving the public with all the ceremonial language of vigilance and none of the discourtesy of identifying whom vigilance is required against.
Vale’s interviews and proposed rejection remain a promised course. The witnesses’ copy already exists in their keeping. Whatever title eventually arrives to assert custody, the contest now begins from the embarrassing position that the witnesses acted first—and that their dignity is safer in a duplicated ledger than in anyone’s reassurance.\end{minipage}\end{minipage}\hspace{\columnsep}%
\begin{minipage}[t]{\dimexpr(\textwidth-2\columnsep)/3\relax}\begin{minipage}[t]{\linewidth}{\sectionface\fontsize{7.2}{8}\selectfont\MakeUppercase{Dispatches}}\par\smallskip \storyhead{Grain-Passage Proposal Announced; the Bridge Remains the Weapon}
\deck{The commune has proposed the same witnessed accounting terms to the claimant, the courts and the refusing legion. None has yet proved that grain or its carriers can cross safely.}
\byline{Lysa Fen}
\textbf{Occupied River Toll Bridge —} The River Commune Bridge Assembly has publicly proposed opening witnessed grain-passage negotiations with the Veiled Claimant and the Rival Bloodline Courts, while proposing to send the same accounting offer to the Refusing Border Legion. That symmetry is the commune’s sharpest available defense: no throne, bloodline or uniform is offered private terms in the proposal. It is not yet safe passage. At an occupied crossing, every contender can praise procedure; the test is whether the proposal produces passage.
In a public statement, the legion proposed issuing and publishing an order barring seizure of any toll, seed-vault, child or ledger under disputed command. The statement also proposed requesting authenticated legal grounds and directing patrols to secure the camp and approaches without advancing. Read from the bridge rather than the camp, the useful words are “without advancing”; the dangerous ones are “secure” and “approaches.” A proposed non-seizure order restrains soldiers on paper only if issued and followed. It does not establish who controls the road, who may halt a carrier, or whether refusal survives the first disputed wagon.
Marshal Eryn Tal has committed a witnessed summons and conference-procedure order, copied by canopy-wire. That gives the coming contest witnesses and a public trail. But the next meaningful fact will not be another signature. It will be grain crossing with its carriers and commune negotiators intact—and an accounting no claimant, court or legion can alter after the gates close.\end{minipage}\end{minipage}\vfill\begin{center}\includegraphics[width=0.54\textwidth]{F:/Projects/Ghostlight/assets/newspaper/woodcuts/sinkroot-gauge-gallery.png}\end{center}\smallskip\begin{center}\fleuron\end{center}
\end{document}

View file

@ -194,6 +194,7 @@ live_architecture:
- Institution, faction, and consumer decision models
active_subgoals:
- Typeset the operator-designated accepted Canopy Ledger edition without changing its copy: build a reusable random-seed-driven newspaper layout script with bounded typography and press variation, use ImageGen for story-specific woodcuts, and reserve printer's ornaments and fleurons for reusable decoration. Preserve source identity and make a given source-plus-seed reproducible.
- 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.
@ -326,5 +327,5 @@ 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; accepted Run 60 remains immutable. The run used 96,902 prompt tokens and reported zero cache hits. Its delivered typeset artifact is `output/pdf/canopy-ledger-run-60-front-page.pdf`; frozen-source and visual verification remain accepted. Exact pushed `bc2ca39` then produced frozen-fact newsroom-only Run 61 Issues 13, 15, 16, 17, and 18 without world or wave model replay. Provider output proves `special_issue` convergence in Issues 13 and 15, independent `general_issue` beats in Issues 16 through 18, and assignment-selector cache hits. Blind review of Issues 13, 15, 16, and 17 passes both distinct recurring voices and issue-shape diversity, recognizing Ossan, Mera, Corin, and Lysa's biases plus modest cross-issue evolution. Run 62 exercised v12 without world or wave replay and failed only because two lead articles proposed invalid datelines; recurring reporter calls still recorded zero cache hits. Run 63 used exact pushed v13 `a52d57b`; Issues 17 and 18 completed before root stopped the final weekly recomposition, both repeated bylines again recorded zero cache hits, and live services remained unchanged. Preserve Runs 61 through 63 as stopped or completed immutable evidence. Copy defects remain later deadline-correction work rather than a reason to reopen accepted issue artifacts.
implementation_checkpoint: Exact pushed v13 `a52d57b` keeps one stable reporter action schema, places a common stable house charter before recurring identity and dynamic assignment, and projects deterministic `allowed_datelines` from cited records while retaining copy-desk factual authority and deterministic cited-place admission; 480 library tests and the all-target check pass. Run 63 falsifies reporter-cache pursuit as a useful acceptance target for sporadic independent filings. The proposed v14 cache-floor prompt growth was removed. Append-only agent contexts remain the natural cache beneficiary; accepted Run 60 remains immutable.
next_action: Advance the Delvehold-owned adapter against Ghostlight's admitted generic consumer contract, or follow the operator's next directed work. Preserve Runs 60 through 63 and do not replay newspaper work to pursue provider cache hits.
next_action: Typeset the operator-designated accepted Canopy Ledger without rewriting its copy. Build a reusable random-seed-driven newspaper layout script whose bounded typography and press variation are reproducible for the same source and seed; use ImageGen for story-specific woodcuts and printer's ornaments or fleurons only for reusable decoration. Verify the produced press artifact and preserve its source and visual provenance. Resume the Delvehold-owned adapter after this operator-directed press-layout task.
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.

View file

@ -1,478 +1,178 @@
#!/usr/bin/env python3
"""Typeset a compact Markdown newspaper front page as a press-ready PDF."""
"""Emit and compile a reproducible seed-varied TeX newspaper."""
from __future__ import annotations
import argparse
import html
import math
import random
import re
from dataclasses import dataclass
import argparse, hashlib, html, random, re, shutil, subprocess, tempfile
from dataclasses import asdict, 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, ...]
section: str | None; headline: str; deck: str; author: str; paragraphs: tuple[str, ...]
@dataclass(frozen=True)
class Edition:
masthead: str
edition: str
articles: tuple[Article, ...]
masthead: str; edition: str; articles: tuple[Article, ...]
@dataclass(frozen=True)
class Style:
seed: int; width: float; height: float; margin: float; body: float; leading: float
gutter: float; rule: float; masthead: int; lead: int; display: str; text: str
paper: str; ink: str; fleuron: str; lead_cut: float; inside_cut: float; rules: str
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(s: str) -> str:
s=s.strip()
if s.startswith("**") and s.endswith("**"): s=s[2:-2]
elif s[:1] in "*_" and s[-1:]==s[:1]: s=s[1:-1]
return html.unescape(s)
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()
def parse(path: Path) -> Edition:
lines=path.read_text(encoding="utf-8").splitlines(); mast=""; ed=""; sec=None; out=[]; i=0
while i < len(lines):
s=lines[i].strip()
if s.startswith("# ") and not mast: mast=plain(s[2:])
elif not ed and s.startswith("*") and s.endswith("*"): ed=plain(s)
elif s.startswith("### "): sec=plain(s[4:])
elif s.startswith("## "):
head=plain(s[3:]); i+=1
while not lines[i].strip(): i+=1
deck=plain(lines[i]); i+=1
while not lines[i].strip(): i+=1
author=plain(lines[i]); i+=1; paras=[]; acc=[]
while i < len(lines):
t=lines[i].strip()
if t=="---" or t.startswith(("## ","### ")):
if acc: paras.append(" ".join(acc)); acc=[]
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 t: acc.append(t)
elif acc: paras.append(" ".join(acc)); acc=[]
i+=1
if acc: paras.append(" ".join(acc))
out.append(Article(sec,head,deck,author,tuple(paras))); sec=None; continue
i+=1
if not mast or not ed or not out: raise ValueError("source lacks masthead, edition, or articles")
return Edition(mast,ed,tuple(out))
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 choose(seed: int) -> Style:
r=random.Random(seed)
return Style(seed,r.choice((9.8,10.0,10.2)),r.choice((13.7,14.0,14.3)),r.choice((.38,.42,.46)),
r.choice((8.6,8.8,9.0)),r.choice((1.04,1.07,1.10)),r.choice((9.,10.5,12.)),
r.choice((0.,.24,.36)),r.choice((36,39,42)),r.choice((25,27,29)),
r.choice(("Georgia","Times New Roman")),"Times New Roman",
r.choice(("F2E5C7","F5E9CE","EFE1C2")),r.choice(("17120D","21170F","1A1510")),
r.choice(("diamond","cross","leaf")),r.choice((.78,.82,.86)),r.choice((.44,.50,.54)),
r.choice(("double","heavy-thin","triple")))
def esc(s: str) -> str:
m={"\\":r"\textbackslash{}","&":r"\&","%":r"\%","$":r"\$","#":r"\#","_":r"\_","{":r"\{","}":r"\}","~":r"\textasciitilde{}","^":r"\textasciicircum{}"}
return "".join(m.get(c,c) for c in html.unescape(s))
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 inline(s: str) -> str:
out=[]
for p in re.split(r"(\*\*.*?\*\*|\*.*?\*)",html.unescape(s)):
if p.startswith("**") and p.endswith("**"): out.append(r"\textbf{"+esc(p[2:-2])+"}")
elif p.startswith("*") and p.endswith("*"): out.append(r"\textit{"+esc(p[1:-1])+"}")
else: out.append(esc(p))
return "".join(out)
def article(a: Article, lead=False) -> str:
if lead:
return "\n\n".join(inline(p) for p in a.paragraphs)
sec=(r"{\sectionface\fontsize{7.2}{8}\selectfont\MakeUppercase{"+esc(a.section)+r"}}\par\smallskip " if a.section else "")
head=r"\storyhead{"+esc(a.headline)+"}\n"
return sec+head+r"\deck{"+esc(a.deck)+"}\n"+r"\byline{"+esc(a.author)+"}\n\n"+"\n\n".join(inline(p) for p in a.paragraphs)
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 rules(kind: str) -> str:
return {"double":r"\hrule height 1.1pt\vspace{2pt}\hrule height .35pt","heavy-thin":r"\hrule height 1.7pt\vspace{1pt}\hrule height .25pt","triple":r"\hrule height .3pt\vspace{1pt}\hrule height 1.1pt\vspace{1pt}\hrule height .3pt"}[kind]
def ornament(kind: str) -> str:
center={"diamond":r"\rotatebox{45}{\rule{5pt}{5pt}}","cross":r"\raisebox{-1pt}{\rule{1pt}{7pt}}\hspace{-4pt}\rule{7pt}{1pt}","leaf":r"\rotatebox{35}{\rule{3pt}{7pt}}\hspace{-1pt}\rotatebox{-35}{\rule{3pt}{7pt}}"}[kind]
return r"\rule{18pt}{.35pt}\hspace{5pt}"+center+r"\hspace{5pt}\rule{18pt}{.35pt}"
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 tex_path(p: Path) -> str: return p.resolve().as_posix()
def build(e: Edition, s: Style, cuts: list[Path], source: Path) -> str:
leadcut=""
if cuts:
leadcut=r"\noindent\includegraphics[width="+f"{s.lead_cut:.2f}"+r"\textwidth]{"+tex_path(cuts[0])+"}"
leadcut+=r"\par"
pre=rf"""\documentclass{{article}}
\usepackage[paperwidth={s.width}in,paperheight={s.height}in,margin={s.margin}in]{{geometry}}
\usepackage{{fontspec,graphicx,xcolor,microtype,multicol,ragged2e,fancyhdr}}
\setmainfont{{{s.text}}}\newfontfamily\displayface{{{s.display}}}\newfontfamily\sectionface{{{s.display}}}
\definecolor{{paper}}{{HTML}}{{{s.paper}}}\definecolor{{ink}}{{HTML}}{{{s.ink}}}\pagecolor{{paper}}\color{{ink}}
\setlength{{\columnsep}}{{{s.gutter}pt}}\setlength{{\columnseprule}}{{{s.rule}pt}}\setlength{{\parindent}}{{1em}}\setlength{{\parskip}}{{0pt}}\setlength{{\emergencystretch}}{{1em}}
\pagestyle{{fancy}}\fancyhf{{}}\renewcommand{{\headrulewidth}}{{0pt}}\fancyfoot[C]{{\displayface\fontsize{{6.5}}{{7}}\selectfont {esc(e.masthead.upper())} · SEED {s.seed} · \thepage}}
\newcommand{{\pressrules}}{{{rules(s.rules)}}}\newcommand{{\fleuron}}{{{ornament(s.fleuron)}}}
\newcommand{{\masthead}}[1]{{{{\displayface\bfseries\fontsize{{{s.masthead}}}{{{s.masthead+2}}}\selectfont\centering\MakeUppercase{{#1}}\par}}}}
\newcommand{{\leadhead}}[1]{{{{\displayface\bfseries\fontsize{{{s.lead}}}{{{s.lead+1.5}}}\selectfont\centering #1\par}}}}
\newcommand{{\storyhead}}[1]{{{{\displayface\bfseries\fontsize{{14}}{{15}}\selectfont\RaggedRight #1\par\smallskip}}}}
\newcommand{{\deck}}[1]{{{{\displayface\itshape\fontsize{{8}}{{9.2}}\selectfont\RaggedRight #1\par\smallskip}}}}
\newcommand{{\byline}}[1]{{{{\displayface\bfseries\fontsize{{6.8}}{{7.5}}\selectfont\MakeUppercase{{By #1}}\par\smallskip}}}}
\AtBeginDocument{{\fontsize{{{s.body}}}{{{s.body*s.leading:.2f}}}\selectfont\justifying}}
\begin{{document}}\thispagestyle{{fancy}}
{{\displayface\fontsize{{7}}{{8}}\selectfont {esc(e.edition.upper())}\hfill SOURCE: {esc(source.name)}\par}}\smallskip\pressrules\smallskip
\masthead{{{esc(e.masthead)}}}\smallskip\pressrules\medskip
\leadhead{{{esc(e.articles[0].headline)}}}\smallskip
{{\displayface\itshape\fontsize{{9.2}}{{10.5}}\selectfont\centering {esc(e.articles[0].deck)}\par}}
{{\displayface\bfseries\fontsize{{7}}{{8}}\selectfont\centering BY {esc(e.articles[0].author.upper())}\par}}\smallskip
{leadcut}\smallskip
\begin{{multicols}}{{3}}{article(e.articles[0],True)}\end{{multicols}}\vfill\begin{{center}}\fleuron\end{{center}}
"""
pages=[pre]; rest=list(e.articles[1:]); page=2; ci=1
while rest:
group,rest=rest[:3],rest[3:]
bottomcut=""
if ci<len(cuts):
bottomcut=r"\vfill\begin{center}\includegraphics[width="+f"{s.inside_cut:.2f}"+r"\textwidth]{"+tex_path(cuts[ci])+r"}\end{center}\smallskip"; ci+=1
cols=[r"\begin{minipage}[t]{\linewidth}"+article(a)+r"\end{minipage}" for a in group]+[""]*3
pages.append(rf"""\clearpage\thispagestyle{{fancy}}{{\displayface\fontsize{{7}}{{8}}\selectfont {esc(e.edition.upper())}\hfill PAGE {page}\par}}\smallskip\pressrules\smallskip
{{\displayface\bfseries\fontsize{{18}}{{19}}\selectfont\centering {esc(e.masthead)}\par}}\smallskip\pressrules\medskip
\noindent
\begin{{minipage}}[t]{{\dimexpr(\textwidth-2\columnsep)/3\relax}}{cols[0]}\end{{minipage}}\hspace{{\columnsep}}%
\begin{{minipage}}[t]{{\dimexpr(\textwidth-2\columnsep)/3\relax}}{cols[1]}\end{{minipage}}\hspace{{\columnsep}}%
\begin{{minipage}}[t]{{\dimexpr(\textwidth-2\columnsep)/3\relax}}{cols[2]}\end{{minipage}}{bottomcut}\begin{{center}}\fleuron\end{{center}}"""); page+=1
return "\n".join(pages)+"\n\\end{document}\n"
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 digest(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def manifest(path: Path, src: Path, out: Path, tex: Path, cuts: list[Path], style: Style, engine: Path | None):
tool=Path(__file__).resolve()
rows=['schema = "ghostlight.seeded_tex_press.v1"',f'source = "{src.resolve().as_posix()}"',f'source_sha256 = "{digest(src)}"',f'output = "{out.resolve().as_posix()}"',f'tex = "{tex.resolve().as_posix()}"',f'tex_sha256 = "{digest(tex)}"',f'tool = "{tool.as_posix()}"',f'tool_sha256 = "{digest(tool)}"',f"seed = {style.seed}"]
if engine:
rows += [f'engine = "{engine.resolve().as_posix()}"',f'engine_sha256 = "{digest(engine)}"']
if out.is_file(): rows.append(f'output_sha256 = "{digest(out)}"')
rows += ["","[style]"]
for k,v in asdict(style).items():
if k!="seed": rows.append(f'{k} = "{v}"' if isinstance(v,str) else f"{k} = {v}")
rows += ["","[[woodcuts]]"] if cuts else []
for index,c in enumerate(cuts):
if index: rows.append("[[woodcuts]]")
rows += [f'path = "{c.resolve().as_posix()}"',f'sha256 = "{digest(c)}"']
path.parent.mkdir(parents=True,exist_ok=True); path.write_text("\n".join(rows)+"\n",encoding="utf-8")
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)
def compile(engine: Path, tex: Path, out: Path):
out.parent.mkdir(parents=True,exist_ok=True)
with tempfile.TemporaryDirectory(prefix="ghostlight-tex-") as td:
p=subprocess.run([str(engine),"-interaction=nonstopmode","-halt-on-error",f"-output-directory={td}",str(tex.resolve())],capture_output=True,text=True)
built=Path(td)/(tex.stem+".pdf")
if p.returncode or not built.exists(): raise RuntimeError((p.stdout+p.stderr)[-6000:])
shutil.copy2(built,out)
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 main():
ap=argparse.ArgumentParser(description=__doc__); ap.add_argument("source",type=Path); ap.add_argument("output",type=Path); ap.add_argument("--seed",type=int,required=True); ap.add_argument("--woodcut",type=Path,action="append",default=[]); ap.add_argument("--tex-output",type=Path); ap.add_argument("--manifest",type=Path); ap.add_argument("--engine",type=Path); ap.add_argument("--no-compile",action="store_true"); a=ap.parse_args()
src=a.source.resolve(); out=a.output.resolve(); cuts=[p.resolve() for p in a.woodcut]
for p in [src,*cuts]:
if not p.is_file(): raise FileNotFoundError(p)
tx=(a.tex_output or Path("output/tex")/(out.stem+".tex")).resolve(); mf=(a.manifest or tx.with_suffix(".manifest.toml")).resolve(); st=choose(a.seed); ed=parse(src)
tx.parent.mkdir(parents=True,exist_ok=True); tx.write_text(build(ed,st,cuts,src),encoding="utf-8")
eng=None
if not a.no_compile:
eng=a.engine or Path(shutil.which("lualatex") or "")
if not eng.is_file(): raise FileNotFoundError("LuaLaTeX not found; pass --engine")
compile(eng,tx,out)
manifest(mf,src,out,tx,cuts,st,eng)
print(f"typeset={out} tex={tx} manifest={mf} seed={st.seed} articles={len(ed.articles)} pages={1+(len(ed.articles)-1+2)//3}")
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()
if __name__=="__main__": main()