diff --git a/THIRD-PARTY-FONTS.md b/THIRD-PARTY-FONTS.md new file mode 100644 index 0000000..a8fede2 --- /dev/null +++ b/THIRD-PARTY-FONTS.md @@ -0,0 +1,52 @@ +# Caratteri di terze parti inclusi in pi-imgen + +Generato da `scripts/fetch-fonts.sh` il 2026-08-27 07:25 UTC. Non modificare a mano. + +I file dei caratteri stanno in `vendor/fonts//` e **non** sono versionati. +Ogni cartella contiene il file `LICENSE` originale. + +La licenza è letta dalla **directory upstream** in `google/fonts` (il bucket +`ofl/`, `apache/`, `ufl/`, `cc-by-sa/`), mai dai metadati dentro il binario: +per esempio il binario di Roboto Condensed dichiara ancora Apache-2.0 nel name +ID 13 mentre la concessione reale è OFL. Il bucket viene risolto a ogni +esecuzione, mai scritto a mano, perché le famiglie migrano fra bucket. + +| Famiglia | Licenza | Origine upstream | File | Variabile | Sorgente | +|---|---|---|---|---|---| +| Archivo | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/archivo/OFL.txt) | [ofl/archivo](https://github.com/google/fonts/tree/main/ofl/archivo) | 2 | sì | google/fonts (TTF completi) | +| Anton | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/anton/OFL.txt) | [ofl/anton](https://github.com/google/fonts/tree/main/ofl/anton) | 1 | no | google/fonts (TTF completi) | +| Oswald | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/oswald/OFL.txt) | [ofl/oswald](https://github.com/google/fonts/tree/main/ofl/oswald) | 1 | sì | google/fonts (TTF completi) | +| Bebas Neue | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/bebasneue/OFL.txt) | [ofl/bebasneue](https://github.com/google/fonts/tree/main/ofl/bebasneue) | 1 | no | google/fonts (TTF completi) | +| Big Shoulders | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/bigshoulders/OFL.txt) | [ofl/bigshoulders](https://github.com/google/fonts/tree/main/ofl/bigshoulders) | 1 | sì | google/fonts (TTF completi) | +| Playfair Display | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/playfairdisplay/OFL.txt) | [ofl/playfairdisplay](https://github.com/google/fonts/tree/main/ofl/playfairdisplay) | 2 | sì | google/fonts (TTF completi) | +| Bodoni Moda | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/bodonimoda/OFL.txt) | [ofl/bodonimoda](https://github.com/google/fonts/tree/main/ofl/bodonimoda) | 2 | sì | google/fonts (TTF completi) | +| DM Serif Display | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/dmserifdisplay/OFL.txt) | [ofl/dmserifdisplay](https://github.com/google/fonts/tree/main/ofl/dmserifdisplay) | 2 | no | google/fonts (TTF completi) | +| Instrument Serif | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/instrumentserif/OFL.txt) | [ofl/instrumentserif](https://github.com/google/fonts/tree/main/ofl/instrumentserif) | 2 | no | google/fonts (TTF completi) | +| Fraunces | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/fraunces/OFL.txt) | [ofl/fraunces](https://github.com/google/fonts/tree/main/ofl/fraunces) | 2 | sì | google/fonts (TTF completi) | +| Outfit | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/outfit/OFL.txt) | [ofl/outfit](https://github.com/google/fonts/tree/main/ofl/outfit) | 1 | sì | google/fonts (TTF completi) | +| Inter | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/inter/OFL.txt) | [ofl/inter](https://github.com/google/fonts/tree/main/ofl/inter) | 2 | sì | google/fonts (TTF completi) | +| Caveat | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/caveat/OFL.txt) | [ofl/caveat](https://github.com/google/fonts/tree/main/ofl/caveat) | 1 | sì | google/fonts (TTF completi) | +| Permanent Marker | [Apache-2.0](https://raw.githubusercontent.com/google/fonts/main/apache/permanentmarker/LICENSE.txt) | [apache/permanentmarker](https://github.com/google/fonts/tree/main/apache/permanentmarker) | 1 | no | google/fonts (TTF completi) | +| Bungee | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/bungee/OFL.txt) | [ofl/bungee](https://github.com/google/fonts/tree/main/ofl/bungee) | 1 | no | google/fonts (TTF completi) | +| Alfa Slab One | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/alfaslabone/OFL.txt) | [ofl/alfaslabone](https://github.com/google/fonts/tree/main/ofl/alfaslabone) | 1 | no | google/fonts (TTF completi) | +| Special Elite | [Apache-2.0](https://raw.githubusercontent.com/google/fonts/main/apache/specialelite/LICENSE.txt) | [apache/specialelite](https://github.com/google/fonts/tree/main/apache/specialelite) | 1 | no | google/fonts (TTF completi) | +| Big Shoulders Stencil Display | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/bigshouldersstencildisplay/OFL.txt) | [ofl/bigshouldersstencildisplay](https://github.com/google/fonts/tree/main/ofl/bigshouldersstencildisplay) | 1 | sì | google/fonts (TTF completi) | +| Saira Stencil One | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/sairastencilone/OFL.txt) | [ofl/sairastencilone](https://github.com/google/fonts/tree/main/ofl/sairastencilone) | 1 | no | google/fonts (TTF completi) | +| Space Mono | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/spacemono/OFL.txt) | [ofl/spacemono](https://github.com/google/fonts/tree/main/ofl/spacemono) | 4 | no | google/fonts (TTF completi) | +| JetBrains Mono | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/jetbrainsmono/OFL.txt) | [ofl/jetbrainsmono](https://github.com/google/fonts/tree/main/ofl/jetbrainsmono) | 2 | sì | google/fonts (TTF completi) | +| Unbounded | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/unbounded/OFL.txt) | [ofl/unbounded](https://github.com/google/fonts/tree/main/ofl/unbounded) | 1 | sì | google/fonts (TTF completi) | + +## Note + +- **OFL-1.1**: ridistribuzione libera, anche commerciale, purché i caratteri + restino accompagnati dalla licenza e non vengano venduti da soli. Le famiglie + con Reserved Font Name (Playfair Display, DM Serif Display, Alfa Slab One) + vanno incluse **non modificate**: rinominare il file non basta, va rinominato + il font se lo si altera. +- **Apache-2.0**: nessun obbligo di attribuzione nel prodotto finale, ma il file + `LICENSE` va conservato accanto ai binari. +- I TTF variabili arrivano solo da `raw.githubusercontent.com/google/fonts`. + L'endpoint css2 istanzia sempre e non può restituire un font variabile; + lo zip di Fontsource contiene TTF statici divisi per unicode-range e i + variabili solo in WOFF2, inutili per Typst. +- Rigenera questo file con `scripts/fetch-fonts.sh --check`. diff --git a/docs/OPEN-DEFECTS.md b/docs/OPEN-DEFECTS.md new file mode 100644 index 0000000..0ab0de4 --- /dev/null +++ b/docs/OPEN-DEFECTS.md @@ -0,0 +1,51 @@ +# Open defects found by the fixture harness + +## 1. BLOCKER — ink colour ignores what is actually behind the text + +**Symptom.** In `framed` and on the flat panel of `split`, body text renders white on the +cream `palette.bg` (`#f4efe6`) and is essentially illegible. Reproduce with: + +```bash +tests/render-fixtures.sh /path/to/typst +# then look at tests/out/framed__accents.png and split__landscape.png +``` + +**Cause.** `templates/lib.typ:61`, in `palette-of()`: + +```typst +let ink = hex(_get(spec, "ink_resolved", _get(p, "ink", none)), fallback: rgb("#111111")) +``` + +`ink_resolved` **always** wins over `palette.ink`. But `ink_resolved` is measured by +`render/contrast.ts` against the **artwork**. That is correct only for text sitting over +the artwork. Templates that place text on flat colour — `framed` entirely, `split` on its +colour half — inherit an ink chosen for a completely different surface. `split.typ:7` +already carries a comment noticing the tension. + +**Fix.** Contrast must be resolved per *surface*, not once per poster: + +- Extend `ResolvedSpec` with `ink_on_art` (measured, today's `ink_resolved`) and + `ink_on_bg` (checked against `palette.bg`; `design/palettes.ts` already guarantees every + palette meets 4.5:1, so this is normally just `palette.ink`). +- Add `ink-for(spec, surface)` to `lib.typ`, where surface is `"art"` or `"bg"`, and have + each template ask for the surface its text actually sits on. +- Keep `ink_resolved` as a deprecated alias so nothing breaks mid-migration. +- `resolveSpec()` in `render/typst.ts` must populate both. + +Add a fixture whose artwork is dark and whose `palette.bg` is light — the case where a +single ink cannot possibly satisfy both surfaces — so this cannot regress silently. + +## 2. Gotcha (fixed in the harness, must hold in `render/typst.ts`) + +Typst resolves a leading `/` against `--root`, **not** the filesystem. Passing an absolute +path to `--input specfile=...` yields `/home/you/...` → "file not found". The spec +path must be **root-relative**. Same rule applies to `art_file` and every `font_files` +entry. + +## Confirmed working + +- A3 trim geometry exact: TrimBox 297.0 × 420.0 mm inside a 303 × 426 mm MediaBox. +- Italian typography: `PERCHÉ`, `È COSÌ`, `CITTÀ`, `SANT'ANNA`, `FORLÌ`, `«PIAZZA GRANDE»`. +- Auto-fit: a 92-character title shrinks and wraps rather than overflowing. +- `split` genuinely adapts to landscape (art left / text right) rather than assuming portrait. +- 18/18 template × fixture combinations compile. diff --git a/extensions/imgen/design/director.ts b/extensions/imgen/design/director.ts new file mode 100644 index 0000000..6b8743e --- /dev/null +++ b/extensions/imgen/design/director.ts @@ -0,0 +1,1549 @@ +/** + * director.ts — the ART DIRECTOR. + * + * One structured-output LLM call turns a plain-language Italian brief into a validated + * DesignSpec. Everything downstream is a pure function of that object, so this file is + * where output quality is actually decided: the prompt below is the product. + * + * --------------------------------------------------------------------------- + * WHY THE MODEL IS NOT TRUSTED WITH EVERYTHING + * --------------------------------------------------------------------------- + * The LLM is asked for a *narrower* object than DesignSpec (see DirectorOutputSchema): + * + * - it names a palette MOOD, never hex values — palettes.ts already proved every + * shipped palette clears WCAG AA, and a model-invented `#7a7a7a` would not; + * - it picks fonts from StringEnums built from FONTS, so a caps-only family can never + * be sampled into the body slot in the first place; + * - it never emits `slug` (slugify() owns that pattern), `seed` (we pin it so draft + * and final are the same image), `source`/`photoPath` (the brief owns those) or + * `logo` (the preset owns that). + * + * The narrowed output is then assembled into a real DesignSpec and validated against + * DesignSpecSchema — the fixed contract. A mismatch is fed back verbatim for exactly one + * retry, after which we give up with an Italian error rather than shipping a broken job. + * + * On top of schema validation there is a deterministic repair pass (reconcileBlocks, + * scrubArtPrompt, ensureNegative). Schemas constrain SHAPE; only code can enforce + * "did not invent a venue" and "did not ask the diffusion model to draw a word". + * + * --------------------------------------------------------------------------- + * WHICH MODEL RUNS THIS + * --------------------------------------------------------------------------- + * `cfg.directorProvider` / `cfg.directorModel` choose the provider and model; when they + * are unset we fall back to whatever model pi currently has loaded (`ctx.model`). + * + * The user's subscription is **opencode Go** ($10/month), which pi-ai already ships as + * the built-in provider id `opencode-go` (OpenAI-compatible, base + * `https://opencode.ai/zen/go/v1`, credential `OPENCODE_API_KEY`, ~23 text models). + * It contains ZERO image models, so it can only ever be the art director — never the + * renderer. To register it with pi, either: + * + * 1. export OPENCODE_API_KEY=... # ambient, picked up automatically, or + * 2. add to ~/.pi/agent/auth.json: + * { "opencode-go": { "type": "api_key", "key": "..." } } + * + * then in ~/.pi/agent/pi-imgen.json: + * + * { "directorProvider": "opencode-go", "directorModel": "glm-5.3" } + * + * On a pi build old enough not to know `opencode-go`, declare it as a custom provider in + * ~/.pi/agent/models.json instead (api "openai-completions", baseUrl + * "https://opencode.ai/zen/go/v1", apiKey env OPENCODE_API_KEY) and point + * `directorProvider` at whatever id you gave it. + */ + +import { Type, type Static } from "typebox"; +import { + StringEnum, + contentText, + parseJsonWithRepair, + validateToolArguments, + type Api, + type AssistantMessage, + type AuthResult, + type Context, + type Message, + type Model, + type Tool, +} from "@earendil-works/pi-ai"; + +import type { ImgenConfig, Preset } from "../config.ts"; +import { S, errorText } from "../ui/strings.ts"; +import { + foldAccents, + formatItalianDate, + parseDate, + slugify, + type ParsedDate, +} from "../job.ts"; +import { + BLOCK_ROLES, + BlockSchema, + DesignSpecSchema, + FORMATS, + TEMPLATES, + type Block, + type DesignSpec, + type Format, + type Palette, + type TemplateName, +} from "./spec.ts"; +import { FONTS, PAIRINGS, bodyFamilies, byFamily, displayFamilies } from "./fonts.ts"; +import { + DEFAULT_PALETTE_NAME, + PALETTES, + PALETTE_NAMES, + mergePresetDetailed, + paletteByName, + paletteFor, +} from "./palettes.ts"; + +export type BlockRole = (typeof BLOCK_ROLES)[number]; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** + * What the guided form collects. Every field is optional because the form lets him skip + * anything; `freeText` is the "Altro / contesto" box every form must have, and is also + * where text typed after the command lands. + */ +export interface Brief { + /** Which command produced this brief. Changes the art direction, not the schema. */ + kind?: "poster" | "logo" | "social"; + title?: string; + subtitle?: string; + date?: string; + venue?: string; + details?: string; + price?: string; + footer?: string; + /** Free-form context: tone, audience, what must NOT appear. */ + freeText?: string; + /** Chosen in the form; when absent the director picks one. */ + format?: Format; + /** Locked by the user during a refinement; the director must not override it. */ + template?: TemplateName; + /** Name of the brand preset to apply, as a key of `cfg.presets`. */ + preset?: string; + /** An existing photo to use instead of generated artwork. */ + photoPath?: string; + /** Pin the artwork seed (re-render of an approved draft). */ + seed?: number; +} + +/** + * A repair the deterministic pass had to make. `code` is stable and machine-readable; + * `italian` is ready to show. + * + * TODO: ui/strings.ts is being written in parallel and has no `S.director` section yet. + * When it grows one, delete WARNING_TEXT below and render from `code` + `params` there. + */ +export interface DirectorWarning { + code: DirectorWarningCode; + italian: string; + params?: Record; +} + +export type DirectorWarningCode = + | "font-display-replaced" + | "font-body-replaced" + | "palette-unknown" + | "template-unknown" + | "format-unknown" + | "art-prompt-scrubbed" + | "art-prompt-empty" + | "negative-completed" + | "block-restored" + | "block-invented" + | "block-empty" + | "title-restored" + | "preset-applied" + | "retried"; + +export interface DirectResult { + spec: DesignSpec; + /** One Italian line from the director: why these choices. For "Ho capito così". */ + note?: string; + warnings: DirectorWarning[]; + /** "provider/model" actually used — worth logging, and worth showing in /doctor. */ + model: string; + /** 1 when the first attempt validated, 2 when the retry saved it. */ + attempts: number; + elapsedMs: number; +} + +/** Confidence is reported honestly: a missing venue stays missing. */ +export type Confidence = "alta" | "media" | "bassa"; + +export interface ParsedBrief { + /** Pre-filled form fields. A field is present ONLY when it was really in the text. */ + fields: Partial>; + /** Whatever was left over — tone, audience, wishes. Goes in "Altro / contesto". */ + freeText: string; + format?: Format; + /** Structured form of `fields.date`, for slugs. The DISPLAYED date stays his wording. */ + dateParsed?: ParsedDate; + confidence: Confidence; + /** Roles the text simply did not mention. The form should ask for these. */ + missing: BlockRole[]; + /** True when the LLM was unavailable and the local regex fallback produced this. */ + offline: boolean; +} + +export interface CaptionResult { + /** The post copy, Italian, no hashtags inside it. */ + text: string; + /** Lowercase, accent-free, no leading '#'. */ + hashtags: string[]; + /** text + blank line + "#a #b #c" — exactly what goes into caption.txt. */ + full: string; +} + +/** + * The slice of pi's ExtensionContext this module needs. `modelRegistry` is deliberately + * `unknown`: pi's ModelRegistry is a compatibility facade whose exact surface varies by + * version, so we probe for methods at runtime instead of compiling against them. An + * ExtensionContext / ExtensionCommandContext is structurally assignable to this. + */ +export interface DirectorContext { + modelRegistry?: unknown; + model?: Model | undefined; + signal?: AbortSignal | undefined; +} + +/** Anything that stopped the director. `italian` is what the user is shown. */ +export class DirectorError extends Error { + constructor(message: string, readonly italian: string, readonly detail?: string) { + super(message); + this.name = "DirectorError"; + } +} + +// --------------------------------------------------------------------------- +// The narrowed schema the model actually fills in +// --------------------------------------------------------------------------- + +/** + * Built from FONTS at module load, so adding a family to fonts.ts changes what the + * director may choose with no edit here. `bodyFamilies()` already excludes caps-only + * faces, which is the guarantee that a caps-only family can never reach a body block. + */ +const DISPLAY_FAMILIES = displayFamilies(); +const BODY_FAMILIES = bodyFamilies(); + +export const DIRECTOR_TOOL_NAME = "emit_design"; + +export const DirectorOutputSchema = Type.Object( + { + note: Type.String({ + description: + "ONE short sentence in ITALIAN explaining the choice, shown to the user as 'Ho capito così'.", + }), + format: StringEnum([...FORMATS], { + description: "Primary output format. Use 'a3-portrait' for a printed event poster.", + }), + template: StringEnum([...TEMPLATES], { + description: "Layout template. See the guidance in the system prompt.", + }), + palette_mood: StringEnum(PALETTE_NAMES, { + description: + "Name of a curated palette. Never invent hex colours: the palette settles the colours AND suggests the artwork's colour direction.", + }), + fonts: Type.Object({ + display: StringEnum(DISPLAY_FAMILIES, { description: "Headline family." }), + body: StringEnum(BODY_FAMILIES, { + description: "Family for every non-headline line. Caps-only families are not in this list.", + }), + }), + art: Type.Object({ + prompt: Type.String({ + description: + "ENGLISH. The ARTWORK ONLY: subject, setting, light, colour, medium, composition. Must never request text, words, letters, numbers, signage or typography.", + }), + negative: Type.String({ + description: + "ENGLISH, comma separated. Must start with: text, letters, words, typography, watermark, signature.", + }), + }), + blocks: Type.Array(BlockSchema, { + minItems: 1, + description: + "The real text, in ITALIAN. Dates, venues and prices are copied from the brief CHARACTER FOR CHARACTER.", + }), + }, + { $id: "DirectorOutput" }, +); + +export type DirectorOutput = Static; + +const FreeTextSchema = Type.Object( + { + title: Type.Optional(Type.String({ description: "The event name, if stated." })), + subtitle: Type.Optional(Type.String()), + date: Type.Optional( + Type.String({ description: "VERBATIM substring of the input, e.g. 'sabato 12 settembre'." }), + ), + venue: Type.Optional(Type.String({ description: "VERBATIM. Never guess a place." })), + details: Type.Optional(Type.String()), + price: Type.Optional(Type.String()), + footer: Type.Optional(Type.String()), + rest: Type.String({ description: "Everything left over: tone, audience, wishes. May be empty." }), + format: Type.Optional(StringEnum([...FORMATS])), + confidence: StringEnum(["alta", "media", "bassa"], { + description: "How sure you are. Use 'bassa' when you had to guess anything at all.", + }), + }, + { $id: "FreeTextBrief" }, +); + +const CaptionSchema = Type.Object( + { + caption: Type.String({ description: "ITALIAN post copy. No hashtags inside it." }), + hashtags: Type.Array(Type.String(), { minItems: 3, maxItems: 12 }), + }, + { $id: "Caption" }, +); + +// --------------------------------------------------------------------------- +// Prompt construction — this is the part that decides quality +// --------------------------------------------------------------------------- + +/** The six words the negative prompt must always contain, per the DesignSpec contract. */ +export const REQUIRED_NEGATIVE = [ + "text", + "letters", + "words", + "typography", + "watermark", + "signature", +] as const; + +/** + * Words that mean "draw something readable". A clause containing one of these is cut out + * of art.prompt before it ever reaches the diffusion model. Word-boundary matched so + * "design" is not caught by "sign". + */ +const LETTERING_RE = new RegExp( + "\\b(" + + [ + "text", "texts", "lettering", "letter", "letters", "word", "words", + "typography", "typographic", "typeface", "font", "fonts", + "caption", "captions", "headline", "headlines", "title", "titles", "subtitle", + "signage", "street sign", "road sign", "neon sign", "shop sign", "billboard", + "writing", "written", "inscription", "inscribed", "calligraphy", "handwriting", + "label", "labels", "logo", "logos", "watermark", "signature", + "poster", "flyer", "leaflet", "book cover", "magazine cover", "newspaper", + "number", "numbers", "numeral", "digit", "digits", + ].join("|") + + ")\\b", + "i", +); + +function fontCatalogue(): string { + return FONTS.map((f) => { + const slots: string[] = []; + if (f.display) slots.push("display"); + if (f.body && !f.capsOnly) slots.push("body"); + const caps = f.capsOnly ? " [CAPS-ONLY — never as body]" : ""; + return `- ${f.family} (${f.mood}) — ${slots.join(" + ") || "display"}${caps}. ${f.note}`; + }).join("\n"); +} + +function pairingCatalogue(): string { + return PAIRINGS.map((p) => `- ${p.mood}: ${p.display} + ${p.body}`).join("\n"); +} + +function paletteCatalogue(): string { + return PALETTES.map( + (p) => `- "${p.name}" — ${p.mood}\n artwork should read as: ${p.artHint}`, + ).join("\n"); +} + +/** + * When each template suits an event. Written as advice rather than rules, because the + * StringEnum already makes an invalid value impossible and what the model actually needs + * is a reason to prefer one. + */ +const TEMPLATE_GUIDE = `- hero-bottom — artwork full-bleed, all text in a band along the bottom. + The default. Safest with a long title, safest with photographic or painterly artwork, + and the one that survives being reshared on a phone. Choose it when in doubt. +- banded — a solid colour band straight across the middle. + Loud and cheap-and-cheerful: sagre, feste di piazza, mercatini. Also the rescue when + the artwork is busy edge to edge and nothing would sit legibly on top of it. +- framed — artwork inset inside a coloured frame. + Formal and quiet: teatro, mostre, concerti di musica classica. The frame gives small + print (patrocini, sponsor, orari) somewhere to live without crowding the image. +- centred-stack — the type stack centred over the artwork. + Only when the artwork has a calm, empty middle: a sky, a wall, fog, a flat colour + field. Elegant and modern, and merciless if the artwork is busy. +- split — a hard split, artwork on one half, flat colour on the other. + Editorial and graphic: club nights, rassegne, anything with a lot of text to place. + Good when there are five or six lines to fit and none of them can be dropped.`; + +/** + * The hand-written few-shot. This is the single highest-leverage thing in the file: the + * model copies its density, its restraint and its verbatim handling of the date far more + * reliably than it follows any rule written in prose. + */ +const FEW_SHOT_BRIEF = `Titolo: Sagra della Castagna +Data e ora: sabato 12 e domenica 13 ottobre +Dove: Piazza della Repubblica, Rocca di Papa +Prezzo: Ingresso libero +Altro / contesto: la fa la pro loco, stand gastronomici dalla mezzogiorno, la sera c'e la musica dal vivo, e una cosa di paese, deve essere allegra e calda, niente robe moderne`; + +const FEW_SHOT_OUTPUT: DirectorOutput = { + note: "Sagra di paese: colori caldi da luna park, titolo grasso che si legge dall'altro lato della piazza.", + format: "a3-portrait", + template: "hero-bottom", + palette_mood: "Sagra paesana", + fonts: { display: "Alfa Slab One", body: "Outfit" }, + art: { + prompt: + "warm autumn still life in an Italian village square at golden hour, a woven wicker basket spilling glossy chestnuts across a rustic wooden table, a perforated roasting pan over glowing embers, thin curl of wood smoke, strings of small warm bulb lights blurred in the background, red and white checked cloth, painterly illustration in warm browns cream and deep red, soft low evening light, uncluttered composition with generous empty space across the lower third", + negative: + "text, letters, words, typography, watermark, signature, signage, numbers, faces, modern packaging, plastic, harsh flash, cluttered background, low contrast mush", + }, + blocks: [ + { role: "title", text: "Sagra della Castagna" }, + { role: "subtitle", text: "Rocca di Papa" }, + { role: "date", text: "sabato 12 e domenica 13 ottobre" }, + { role: "venue", text: "Piazza della Repubblica" }, + { role: "details", text: "Stand gastronomici dalle 12 · Musica dal vivo la sera", optional: true }, + { role: "price", text: "Ingresso libero" }, + { role: "footer", text: "A cura della Pro Loco", optional: true }, + ], +}; + +const FEW_SHOT_NOTES = `Why that example is right: +- the date is CHARACTER FOR CHARACTER what he typed — not normalised to "12-13 ottobre", + not expanded with a year he never mentioned; +- "Rocca di Papa" was promoted to subtitle because the venue line already carries the + square, and a poster read from across a piazza wants the town name big; +- "stand gastronomici dalla mezzogiorno" was tidied into "dalle 12" — that is tightening + wording he gave, which is allowed. Inventing "ore 12:00 - 23:00" would not be; +- the Pro Loco appears only because he mentioned it, and is marked optional:true because + the poster still works without it; +- the artwork is a still life. Not a poster, not a sign, not a festival banner: things + that would tempt the model into drawing letters; +- the last clause of the art prompt reserves empty space exactly where hero-bottom puts + the text band. Composition and template are chosen together.`; + +/** + * The system prompt. English, per the project rule that model-facing text stays English; + * everything the model WRITES for the user stays Italian, which the prompt says loudly. + */ +export function buildDirectorSystemPrompt(options: { kind?: Brief["kind"]; tone?: string } = {}): string { + const kind = options.kind ?? "poster"; + const kindLine = + kind === "logo" + ? "You are designing a LOGO: one strong mark, at most two lines of text, no dates, no venue." + : kind === "social" + ? "You are designing SOCIAL images: read on a phone, at a glance, thumb-sized. Fewer words than a poster." + : "You are designing a PRINTED EVENT POSTER, read from three metres away on a wall."; + + return `You are the art director of a small Italian design studio. Your client organises +local events — sagre, concerti, mostre, serate in piazza. He is not a designer and not a +prompt engineer. He describes an event in plain Italian and expects a finished poster. + +${kindLine} + +You produce ONE JSON object matching the given schema. Nothing else: no prose, no +markdown fence, no explanation outside the object. + +=== HOW THE PIPELINE WORKS (this changes what you must write) === +A local diffusion model paints the ARTWORK ONLY. It never draws a single letter. The real +text is typeset afterwards with real fonts, from the \`blocks\` you return. That is why +Italian accents, dates and venue names are always correct — and why any request for +lettering in \`art.prompt\` produces garbled fake writing across the poster, which is the +single worst failure this system can have. + +=== 1. TEXT: PRESERVE, DO NOT INVENT === +- \`blocks[].text\` is ITALIAN and is what gets printed. +- The date, the venue, the price and the footer are copied from the brief CHARACTER FOR + CHARACTER: same words, same accents, same punctuation, same capitalisation. "sabato 12 + settembre, ore 21" stays exactly that. Do not normalise it, do not add a year, do not + convert it to a numeric date, do not translate it. +- You MAY sharpen the TITLE: drop filler, fix capitalisation, cut it to something a person + can read from across a square. You may not rename the event. +- You may NEVER invent a fact that is not in the brief: no venue, no time, no price, no + sponsor, no phone number, no website, no "prenotazione obbligatoria". If he did not say + it, the block does not exist. A poster with four lines is better than a poster with six + lines two of which are lies. +- One block per role, at most. Order does not matter; priority does. +- Mark a block \`optional: true\` when the poster still works without it — details, footer, + price, sometimes subtitle. Never mark the title or the date optional. The renderer drops + optional blocks when space runs short, so this is how you tell it what is expendable. +- No emoji. No ALL CAPS in the text: the template decides case, not you. + +=== 2. FONTS: ONLY THE BUNDLED FAMILIES === +Choose \`fonts.display\` and \`fonts.body\` from these, and nothing else: + +${fontCatalogue()} + +Families marked CAPS-ONLY draw capitals at lowercase codepoints. They are headline faces +and must NEVER be used as \`fonts.body\` — a caps-only body line is unreadable. + +Prefer one of these curated pairings unless the brief clearly calls for something else. +They are proven, and each one has a palette of the same name: + +${pairingCatalogue()} + +=== 3. PALETTE: NAME A MOOD, NEVER A COLOUR === +\`palette_mood\` must be one of these names exactly. Each was proved to clear WCAG AA +contrast, so naming one is what guarantees the text stays readable: + +${paletteCatalogue()} + +Pick the palette and the fonts together — the pairing named after the palette is almost +always the right answer. The palette's "artwork should read as" line tells you the colour +direction \`art.prompt\` must agree with; a warm sagra palette under cold blue artwork looks +broken. + +=== 4. TEMPLATE === +${TEMPLATE_GUIDE} + +=== 5. ART PROMPT: ARTWORK ONLY, IN ENGLISH === +\`art.prompt\` is English, 40 to 80 words, one flowing comma-separated description of: +subject, setting, time of day and light, colour direction (agreeing with the palette), +medium or style (painterly illustration, risograph, photograph, linocut, collage…), and +composition — including where you are leaving EMPTY SPACE for the text band the template +will put there. + +FORBIDDEN in \`art.prompt\`, without exception: text, letters, words, numbers, dates, +typography, fonts, captions, titles, signage, street signs, billboards, banners with +writing, labels, logos, watermarks, book or magazine covers, and "a poster of…". Describe +a scene or an object, never a printed thing. If the event is a concert, describe the +instrument, the light, the crowd's silhouette — not the gig poster. + +Also avoid: recognisable real people, celebrities, brand marks, and anything that would +put a face in the middle of the composition where the title goes. + +\`art.negative\` is English, comma separated, and MUST begin with exactly: + text, letters, words, typography, watermark, signature +then add whatever else this particular image needs to avoid. + +=== 6. NOTE === +\`note\` is ONE short sentence in Italian, addressed to him, saying what you understood and +why you chose this look. It is shown under the heading "Ho capito così". Warm and plain: +no jargon, no font names he does not know, no English. + +=== WORKED EXAMPLE === +Brief: +${FEW_SHOT_BRIEF} + +Correct output: +${JSON.stringify(FEW_SHOT_OUTPUT, null, 2)} + +${FEW_SHOT_NOTES} +${options.tone ? `\n=== HOUSE TONE (from his saved style — respect it) ===\n${options.tone}\n` : ""}`; +} + +/** The brief, rendered the way the form labels it, so the model sees his own words. */ +export function formatBrief(brief: Brief): string { + const lines: string[] = []; + const push = (label: string, value?: string) => { + const v = value?.trim(); + if (v) lines.push(`${label}: ${v}`); + }; + push("Titolo", brief.title); + push("Sottotitolo", brief.subtitle); + push("Data e ora", brief.date); + push("Dove", brief.venue); + push("Dettagli", brief.details); + push("Prezzo", brief.price); + push("In fondo", brief.footer); + push("Altro / contesto", brief.freeText); + if (brief.format) lines.push(`Formato richiesto: ${brief.format}`); + if (brief.template) lines.push(`Impaginazione già scelta (non cambiarla): ${brief.template}`); + if (brief.photoPath) { + lines.push( + "NOTA: userà una sua fotografia al posto di un'immagine generata. Scrivi comunque " + + "art.prompt descrivendo la fotografia ideale, servirà come ripiego.", + ); + } + return lines.join("\n"); +} + +/** Everything he actually typed, folded — the corpus every anti-invention check runs on. */ +function briefCorpus(brief: Brief): string { + return foldAccents( + [ + brief.title, brief.subtitle, brief.date, brief.venue, + brief.details, brief.price, brief.footer, brief.freeText, + ] + .filter(Boolean) + .join(" \n "), + ); +} + +/** + * True when `text` is grounded in what he wrote: at least 60% of its meaningful tokens + * appear in the corpus. Cheap, accent-blind, and enough to catch a hallucinated venue + * without rejecting a legitimate rewording. + */ +function supportedByBrief(text: string, corpus: string): boolean { + const tokens = foldAccents(text) + .replace(/[^a-z0-9]+/g, " ") + .split(" ") + .filter((t) => t.length >= 3 && !STOPWORDS.has(t)); + if (tokens.length === 0) return true; // "10 €", "ore 21" — nothing to check against + const hits = tokens.filter((t) => corpus.includes(t)).length; + return hits / tokens.length >= 0.6; +} + +const STOPWORDS = new Set([ + "con", "per", "del", "della", "dei", "delle", "dal", "dalla", "alle", "allo", "alla", + "una", "uno", "gli", "che", "non", "più", "piu", "dalle", "sul", "sulla", "nel", "nella", +]); + +// --------------------------------------------------------------------------- +// Model plumbing +// --------------------------------------------------------------------------- + +/** Runtime probe of pi's ModelRegistry; see DirectorContext for why this is untyped. */ +type Registry = Record; + +function registryOf(ctx: DirectorContext): Registry | undefined { + const r = ctx.modelRegistry; + return r && typeof r === "object" ? (r as Registry) : undefined; +} + +function callable(reg: Registry | undefined, name: string): ((...args: unknown[]) => unknown) | undefined { + const fn = reg?.[name]; + return typeof fn === "function" ? (fn as (...args: unknown[]) => unknown).bind(reg) : undefined; +} + +/** + * Model ids we reach for when the user configured a provider but not a model. Substring + * match, best-first — a hint only, and the catalogue moves fast enough that the real fix + * is to set `directorModel` in the config. + */ +const MODEL_PREFERENCE = ["glm-5", "qwen3.7", "kimi-k", "deepseek-v4-pro", "minimax-m", "gpt-5"]; + +function resolveDirectorModel(cfg: ImgenConfig, ctx: DirectorContext): Model { + const reg = registryOf(ctx); + const wantProvider = cfg.directorProvider?.trim(); + const wantModel = cfg.directorModel?.trim(); + + if (wantProvider) { + // TODO: assumes ModelRegistry exposes one of getModel(provider,id) / find(provider,id) + // and getModels(provider), as documented for pi's compatibility facade. + const lookup = callable(reg, "getModel") ?? callable(reg, "find"); + if (wantModel && lookup) { + const found = lookup(wantProvider, wantModel) as Model | undefined; + if (found) return found; + } + const list = callable(reg, "getModels"); + const models = (list?.(wantProvider) ?? []) as readonly Model[]; + if (models.length) { + if (wantModel) { + const exact = models.find((m) => m.id === wantModel); + if (exact) return exact; + } + for (const hint of MODEL_PREFERENCE) { + const hit = models.find((m) => m.id.includes(hint)); + if (hit) return hit; + } + return models[0]!; + } + if (wantModel) { + throw new DirectorError( + `director model ${wantProvider}/${wantModel} not found in the registry`, + errorText(S.errors.directorUnavailable), + `${wantProvider}/${wantModel}`, + ); + } + } + + // Fall back to whatever pi currently has loaded. + if (ctx.model) return ctx.model; + + throw new DirectorError( + "no director model: neither cfg.directorProvider/directorModel nor ctx.model resolved", + errorText(S.errors.directorUnavailable), + ); +} + +const DEFAULT_TIMEOUT_MS = 90_000; + +function withTimeout(signal: AbortSignal | undefined, ms: number): AbortSignal | undefined { + const timeout = typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(ms) : undefined; + if (!timeout) return signal; + if (!signal) return timeout; + // AbortSignal.any is Node >= 20.3; degrade to the caller's signal if it is missing. + return typeof AbortSignal.any === "function" ? AbortSignal.any([signal, timeout]) : signal; +} + +/** + * One request. Prefers the registry's own `complete`/`completeSimple` (which resolve auth + * internally); falls back to resolving auth with `getProviderAuth(id)` and driving the + * provider's stream directly, which is the documented "use pi's credentials without a + * loaded model" path. + */ +async function complete( + model: Model, + context: Context, + ctx: DirectorContext, + opts: { maxTokens: number; temperature: number }, +): Promise { + const reg = registryOf(ctx); + const signal = withTimeout(ctx.signal, DEFAULT_TIMEOUT_MS); + const streamOptions = { + signal, + temperature: opts.temperature, + maxTokens: Math.min(opts.maxTokens, model.maxTokens || opts.maxTokens), + toolChoice: "auto" as const, + }; + + const direct = callable(reg, "complete") ?? callable(reg, "completeSimple"); + if (direct) { + return (await direct(model, context, streamOptions)) as AssistantMessage; + } + + const getProvider = callable(reg, "getProvider"); + const provider = getProvider?.(model.provider) as + | { streamSimple?: (m: Model, c: Context, o?: unknown) => { result(): Promise } } + | undefined; + if (!provider?.streamSimple) { + throw new DirectorError( + `provider ${model.provider} exposes no usable stream entry point`, + errorText(S.errors.directorUnavailable), + model.provider, + ); + } + + const getAuth = callable(reg, "getProviderAuth"); + const resolved = (await getAuth?.(model.provider)) as AuthResult | undefined; + const auth = resolved?.auth; + // A resolved baseUrl overrides the catalogue's; everything else rides on the options. + const effective = auth?.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model; + + return await provider + .streamSimple(effective, context, { + ...streamOptions, + apiKey: auth?.apiKey, + headers: auth?.headers, + env: resolved?.env, + }) + .result(); +} + +/** The first balanced `{...}` in a string, or undefined. Survives prose and fences. */ +function firstJsonObject(text: string): string | undefined { + const start = text.indexOf("{"); + if (start < 0) return undefined; + let depth = 0; + let inString = false; + let escaped = false; + for (let i = start; i < text.length; i++) { + const c = text[i]!; + if (inString) { + if (escaped) escaped = false; + else if (c === "\\") escaped = true; + else if (c === '"') inString = false; + continue; + } + if (c === '"') inString = true; + else if (c === "{") depth++; + else if (c === "}" && --depth === 0) return text.slice(start, i + 1); + } + return undefined; +} + +/** + * Pulls the structured object out of a reply: the tool call when the model made one, + * otherwise JSON out of the text. Both routes end in the same validator, so a model that + * cannot do tool calls still works. + */ +function extractStructured(message: AssistantMessage, tool: Tool): unknown { + const call = message.content.find( + (c): c is Extract => c.type === "toolCall" && c.name === tool.name, + ); + if (call) return validateToolArguments(tool, call); + + const text = contentText(message.content); + const json = firstJsonObject(text); + if (!json) { + throw new Error( + `model returned no tool call and no JSON object (stopReason=${message.stopReason})` + + (message.errorMessage ? `: ${message.errorMessage}` : ""), + ); + } + const parsed = parseJsonWithRepair(json); + return validateToolArguments(tool, { + type: "toolCall", + id: "inline", + name: tool.name, + arguments: parsed as Record, + }); +} + +/** + * One structured call with exactly one retry, feeding the validation error back verbatim. + * `finish` runs after schema validation and may throw its own Error — a DesignSpec that + * fails DesignSpecSchema is a mismatch like any other and earns the same retry. + */ +async function askStructured( + ctx: DirectorContext, + model: Model, + systemPrompt: string, + userPrompt: string, + schema: Parameters[0] extends never ? never : Tool["parameters"], + toolName: string, + toolDescription: string, + finish: (raw: unknown) => T, + opts: { maxTokens: number; temperature: number }, +): Promise<{ value: T; attempts: number }> { + const tool: Tool = { + name: toolName, + description: toolDescription, + parameters: schema, + // "prefer" rather than "require": opencode Go fronts a couple of dozen different + // models and not all of them honour strict json-schema sampling. The validator plus + // the retry is what actually guarantees the shape. + constrainedSampling: { type: "json_schema", strict: "prefer" }, + }; + + const messages: Message[] = [{ role: "user", content: userPrompt, timestamp: Date.now() }]; + let lastError: Error | undefined; + + for (let attempt = 1; attempt <= 2; attempt++) { + const context: Context = { systemPrompt, messages: [...messages], tools: [tool] }; + let reply: AssistantMessage; + try { + reply = await complete(model, context, ctx, opts); + } catch (e) { + if (ctx.signal?.aborted) throw new DirectorError("aborted", errorText(S.errors.cancelled)); + throw new DirectorError( + `director request failed: ${(e as Error).message}`, + errorText(S.errors.directorUnavailable), + (e as Error).message, + ); + } + + if (reply.stopReason === "aborted") { + throw new DirectorError("aborted", errorText(S.errors.cancelled)); + } + + try { + return { value: finish(extractStructured(reply, tool)), attempts: attempt }; + } catch (e) { + lastError = e as Error; + if (attempt === 2) break; + // Feed the failure back. The assistant turn is replayed so the model sees what it + // actually produced, then a user turn naming the exact violated constraint. + messages.push(reply); + messages.push({ + role: "user", + content: + "That output was rejected. Fix it and return the corrected JSON object only, " + + "with no commentary:\n\n" + + lastError.message, + timestamp: Date.now(), + }); + } + } + + throw new DirectorError( + `director output failed validation twice: ${lastError?.message}`, + errorText(S.errors.directorFailed(lastError?.message)), + lastError?.message, + ); +} + +// --------------------------------------------------------------------------- +// direct() — brief -> DesignSpec +// --------------------------------------------------------------------------- + +/** + * The one call the whole extension is built around. + * + * Throws DirectorError (with an Italian `italian` field) on an empty brief, an + * unreachable model, or two consecutive schema failures. Never returns a spec that would + * not validate. + */ +export async function direct(brief: Brief, cfg: ImgenConfig, ctx: DirectorContext): Promise { + const started = Date.now(); + + const hasSomething = Boolean( + brief.title?.trim() || brief.freeText?.trim() || brief.subtitle?.trim() || brief.venue?.trim(), + ); + if (!hasSomething) { + throw new DirectorError("empty brief", errorText(S.errors.briefEmpty)); + } + + const preset = brief.preset ? cfg.presets[brief.preset] : undefined; + if (brief.preset && !preset) { + throw new DirectorError( + `unknown preset ${brief.preset}`, + errorText(S.errors.presetUnknown(brief.preset)), + brief.preset, + ); + } + + const model = resolveDirectorModel(cfg, ctx); + const systemPrompt = buildDirectorSystemPrompt({ kind: brief.kind, tone: preset?.tone }); + const userPrompt = `Brief:\n${formatBrief(brief)}\n\nReturn the JSON object.`; + + const warnings: DirectorWarning[] = []; + let note: string | undefined; + + const { value: spec, attempts } = await askStructured( + ctx, + model, + systemPrompt, + userPrompt, + DirectorOutputSchema, + DIRECTOR_TOOL_NAME, + "Emit the finished design specification for this event.", + (raw) => { + // A fresh warning list per attempt: repairs from a rejected attempt are not real. + warnings.length = 0; + const out = raw as DirectorOutput; + note = out.note?.trim() || undefined; + const assembled = assembleSpec(out, brief, preset, warnings); + // The fixed contract has the last word. + return validateToolArguments( + { name: "DesignSpec", description: "", parameters: DesignSpecSchema }, + { type: "toolCall", id: "spec", name: "DesignSpec", arguments: assembled as unknown as Record }, + ) as DesignSpec; + }, + { maxTokens: 3000, temperature: 0.6 }, + ); + + if (attempts > 1) warnings.push(warn("retried")); + if (preset) warnings.push(warn("preset-applied", { nome: preset.label })); + + return { + spec, + note, + warnings, + model: `${model.provider}/${model.id}`, + attempts, + elapsedMs: Date.now() - started, + }; +} + +// --------------------------------------------------------------------------- +// The deterministic repair pass +// --------------------------------------------------------------------------- + +/** Turns the narrowed director output into a real DesignSpec, repairing as it goes. */ +function assembleSpec( + out: DirectorOutput, + brief: Brief, + preset: Preset | undefined, + warnings: DirectorWarning[], +): DesignSpec { + const corpus = briefCorpus(brief); + + // --- palette ------------------------------------------------------------ + let moodName = out.palette_mood; + if (!paletteByName(moodName)) { + warnings.push(warn("palette-unknown", { nome: String(moodName) })); + moodName = DEFAULT_PALETTE_NAME; + } + let palette: Palette = paletteFor(moodName); + if (preset) { + const merged = mergePresetDetailed(palette, preset); + palette = merged.palette; + // mergePresetDetailed already phrases its own Italian warnings. + for (const w of merged.warnings) warnings.push({ code: "preset-applied", italian: w }); + } + + // --- fonts -------------------------------------------------------------- + const fonts = chooseFonts(out.fonts, moodName, preset, warnings); + + // --- template / format -------------------------------------------------- + let template: TemplateName = brief.template ?? (out.template as TemplateName); + if (!(TEMPLATES as readonly string[]).includes(template)) { + warnings.push(warn("template-unknown", { nome: String(out.template) })); + template = "hero-bottom"; + } + let format: Format = brief.format ?? (out.format as Format); + if (!(FORMATS as readonly string[]).includes(format)) { + warnings.push(warn("format-unknown", { nome: String(out.format) })); + format = "a3-portrait"; + } + + // --- blocks ------------------------------------------------------------- + const blocks = reconcileBlocks(out.blocks ?? [], brief, corpus, warnings); + + // --- art ---------------------------------------------------------------- + const artHint = paletteByName(moodName)?.artHint; + const prompt = scrubArtPrompt(out.art?.prompt ?? "", artHint, warnings); + const negative = ensureNegative(out.art?.negative ?? "", warnings); + + const title = blocks.find((b) => b.role === "title")?.text ?? brief.title ?? "senza titolo"; + const dateText = blocks.find((b) => b.role === "date")?.text ?? brief.date; + // "sabato 12 e domenica 13 ottobre" would slug as "-sabato-12-e"; normalise it for the + // folder name only. The printed date is untouched. + const parsedDate = dateText ? parseSpokenDate(dateText) : undefined; + + const spec: DesignSpec = { + slug: slugify(title, parsedDate ? formatItalianDate(parsedDate) : dateText), + format, + template, + palette, + fonts, + art: { + prompt, + negative, + seed: brief.seed ?? randomSeed(), + source: brief.photoPath ? "photo" : "generate", + ...(brief.photoPath ? { photoPath: brief.photoPath } : {}), + }, + blocks, + }; + + if (preset?.logo) { + spec.logo = { path: preset.logo, corner: "br", scale: 0.12 }; + } + return spec; +} + +/** + * Fonts, in order of trust: the preset (his brand), then the director's pick, then the + * palette's own pairing, then the safe default. A caps-only family reaching `body` is the + * one failure this must make impossible, so it is checked last and unconditionally. + */ +function chooseFonts( + picked: DirectorOutput["fonts"] | undefined, + moodName: string, + preset: Preset | undefined, + warnings: DirectorWarning[], +): DesignSpec["fonts"] { + const pairing = PAIRINGS.find((p) => p.mood.toLowerCase().startsWith(moodName.trim().toLowerCase())); + + const wantedDisplay = preset?.fonts?.display ?? picked?.display; + const wantedBody = preset?.fonts?.body ?? picked?.body; + + let display = wantedDisplay; + if (!display || !byFamily(display)?.display) { + if (display) warnings.push(warn("font-display-replaced", { carattere: display })); + display = pairing?.display ?? "Anton"; + } + + let body = wantedBody; + const bodyEntry = body ? byFamily(body) : undefined; + if (!body || !bodyEntry?.body || bodyEntry.capsOnly) { + if (body) warnings.push(warn("font-body-replaced", { carattere: body })); + body = pairing?.body ?? "Inter"; + } + + // Belt and braces: whatever happened above, the body face is never caps-only. + if (byFamily(body)?.capsOnly) body = "Inter"; + + return { display: byFamily(display)!.family, body: byFamily(body)!.family }; +} + +/** + * Enforces the "preserve his wording, invent nothing" contract. + * + * - a role he filled in is restored to his exact string, whatever the model wrote; + * - a role he did not fill in survives only if it is grounded in the brief text; + * - the title may be rewritten, but not replaced by something unrelated; + * - blocks are deduped by role and ordered by BLOCK_ROLES priority. + */ +function reconcileBlocks( + raw: readonly Block[], + brief: Brief, + corpus: string, + warnings: DirectorWarning[], +): Block[] { + const byRole = new Map(); + for (const b of raw) { + if (!b || typeof b.text !== "string") continue; + const role = b.role as BlockRole; + if (!(BLOCK_ROLES as readonly string[]).includes(role)) continue; + const text = b.text.trim().replace(/\s+/g, " "); + if (!text) { + warnings.push(warn("block-empty", { campo: role })); + continue; + } + if (!byRole.has(role)) byRole.set(role, { ...b, role, text }); + } + + const given: Partial> = { + title: brief.title, + subtitle: brief.subtitle, + date: brief.date, + venue: brief.venue, + details: brief.details, + price: brief.price, + footer: brief.footer, + }; + + for (const role of BLOCK_ROLES) { + const his = given[role]?.trim(); + const block = byRole.get(role); + + if (his) { + // Everything except the title is his, verbatim. The title he may have sharpened. + if (role === "title") { + if (!block) byRole.set(role, { role, text: his }); + else if (!supportedByBrief(block.text, corpus)) { + warnings.push(warn("title-restored", { titolo: his })); + byRole.set(role, { ...block, text: his }); + } + } else if (!block) { + byRole.set(role, { role, text: his, ...(isSecondary(role) ? { optional: true } : {}) }); + } else if (block.text !== his) { + warnings.push(warn("block-restored", { campo: role, testo: his })); + byRole.set(role, { ...block, text: his }); + } + continue; + } + + // He gave nothing for this role: the model may only have derived it from free text. + if (block && FACTUAL_ROLES.has(role) && !supportedByBrief(block.text, corpus)) { + warnings.push(warn("block-invented", { campo: role, testo: block.text })); + byRole.delete(role); + } + } + + const ordered = BLOCK_ROLES.map((r) => byRole.get(r)).filter((b): b is Block => Boolean(b)); + if (ordered.length === 0) { + // DesignSpecSchema demands minItems 1, and a poster with no words is not a poster. + ordered.push({ role: "title", text: brief.title?.trim() || "Evento" }); + } + return ordered; +} + +/** Roles that state a fact about the world, and therefore may never be invented. */ +const FACTUAL_ROLES = new Set(["date", "venue", "price", "details", "footer"]); + +/** Roles the renderer is allowed to drop when space runs short. */ +function isSecondary(role: BlockRole): boolean { + return role === "details" || role === "footer" || role === "price"; +} + +/** + * Removes any clause that asks the diffusion model for something readable. + * + * Splitting on commas and dropping whole clauses is deliberately blunt: a prompt that + * loses a clause still paints, while a prompt that keeps "vintage poster lettering" + * produces fake garbled Italian across the artwork, which is unrecoverable. + */ +export function scrubArtPrompt( + prompt: string, + artHint: string | undefined, + warnings: DirectorWarning[], +): string { + const clauses = prompt + .split(/[,;]+/) + .map((c) => c.trim()) + .filter(Boolean); + const kept = clauses.filter((c) => !LETTERING_RE.test(c)); + + if (kept.length !== clauses.length) { + warnings.push(warn("art-prompt-scrubbed")); + } + + let out = kept.join(", ").trim(); + if (!out) { + warnings.push(warn("art-prompt-empty")); + out = artHint ?? "abstract painterly texture, soft light, generous empty space, no subject"; + } + // Long prompts stop helping and start confusing small local models. + if (out.length > 700) out = out.slice(0, 700).replace(/,[^,]*$/, ""); + return out; +} + +/** Guarantees the six mandatory terms lead the negative prompt, keeping the model's extras. */ +export function ensureNegative(negative: string, warnings: DirectorWarning[]): string { + const have = new Set( + negative + .split(/[,;]+/) + .map((t) => t.trim().toLowerCase()) + .filter(Boolean), + ); + const missing = REQUIRED_NEGATIVE.filter((t) => !have.has(t)); + if (missing.length) warnings.push(warn("negative-completed")); + + const extras = [...have].filter((t) => !(REQUIRED_NEGATIVE as readonly string[]).includes(t)); + return [...REQUIRED_NEGATIVE, ...extras].join(", "); +} + +/** 31-bit, so it survives every backend's integer handling. */ +function randomSeed(): number { + return Math.floor(Math.random() * 0x7fffffff); +} + +// --------------------------------------------------------------------------- +// parseFreeText() — "/poster concerto jazz al chiostro 12 settembre" +// --------------------------------------------------------------------------- + +const PARSE_SYSTEM_PROMPT = `You extract structured fields from one line of informal Italian +typed after a command, so a form can be pre-filled. You are not designing anything. + +RULES +- Every field you return must be a VERBATIM substring of the input, unchanged: same + words, same accents, same capitalisation. You are cutting the sentence up, not + rewriting it. The only exception is \`title\`, where you may drop a leading verb + ("faccio un concerto..." -> "concerto..."). +- Return a field ONLY if it is actually there. An event with no venue has no \`venue\`. + Guessing a plausible piazza is the worst thing you can do here: he will not notice the + invention until the poster is printed. +- Italian dates come in many shapes and all of them stay verbatim: "12 set", + "sabato 12 settembre", "12/09", "il 12 e 13 ottobre", "venerdì sera". Copy the whole + date-and-time phrase into \`date\`, including "ore 21" if present. +- \`venue\` is a place: "al chiostro", "in piazza Grande", "al bar Centrale", "Teatro + Comunale". Strip the leading preposition, keep the name. +- \`rest\` gets everything that is not a field: tone, audience, wishes, what to avoid. + Never leave meaningful words out of both the fields and \`rest\`. +- \`confidence\`: "alta" only when title, date and venue were all clearly stated. "bassa" + whenever you were tempted to guess. + +EXAMPLES +Input: concerto jazz al chiostro 12 settembre +{"title":"concerto jazz","date":"12 settembre","venue":"chiostro","rest":"","confidence":"media"} + +Input: sagra della porchetta sabato 3 e domenica 4 agosto in piazza a Ariccia, ingresso libero, deve essere allegra +{"title":"sagra della porchetta","date":"sabato 3 e domenica 4 agosto","venue":"piazza a Ariccia","price":"ingresso libero","rest":"deve essere allegra","confidence":"alta"} + +Input: una cosa per la mia band, tipo anni 70 +{"title":"la mia band","rest":"tipo anni 70","confidence":"bassa"}`; + +/** + * Pre-fills the form from free text. Honest by construction: anything the model returns + * that is not actually in the text is discarded, and a failed model call degrades to a + * local regex parse rather than to an error — the form still opens. + */ +export async function parseFreeText( + text: string, + ctx: DirectorContext, + cfg?: ImgenConfig, +): Promise { + const input = text.trim(); + if (!input) return emptyParse(); + + let model: Model; + try { + model = resolveDirectorModel(cfg ?? ({ presets: {} } as unknown as ImgenConfig), ctx); + } catch { + return parseFreeTextLocally(input); + } + + let raw: Static; + try { + const { value } = await askStructured( + ctx, + model, + PARSE_SYSTEM_PROMPT, + `Input: ${input}`, + FreeTextSchema, + "prefill_form", + "Extract the fields present in this line of Italian.", + (v) => v as Static, + { maxTokens: 800, temperature: 0.1 }, + ); + raw = value; + } catch { + // A form that opens half-filled beats an error message. Fall back and say so. + return parseFreeTextLocally(input); + } + + const corpus = foldAccents(input); + const fields: Partial> = {}; + let dropped = false; + + for (const role of BLOCK_ROLES) { + const value = (raw as Record)[role]; + if (typeof value !== "string") continue; + const trimmed = value.trim(); + if (!trimmed) continue; + // The anti-invention gate: it has to be in what he typed. + if (!supportedByBrief(trimmed, corpus)) { + dropped = true; + continue; + } + fields[role] = trimmed; + } + + const local = parseFreeTextLocally(input); + // The regex date parser is stricter than the model at spotting "12/09"; if the model + // missed a date the local pass found, take the local one. + if (!fields.date && local.fields.date) fields.date = local.fields.date; + if (!fields.title && local.fields.title) fields.title = local.fields.title; + + const missing = BLOCK_ROLES.filter((r) => !fields[r]); + let confidence: Confidence = + raw.confidence === "alta" || raw.confidence === "media" || raw.confidence === "bassa" + ? raw.confidence + : "media"; + if (dropped && confidence === "alta") confidence = "media"; + if (!fields.title) confidence = "bassa"; + + return { + fields, + freeText: typeof raw.rest === "string" ? raw.rest.trim() : "", + format: (FORMATS as readonly string[]).includes(raw.format ?? "") ? (raw.format as Format) : undefined, + dateParsed: fields.date ? parseSpokenDate(fields.date) : undefined, + confidence, + missing, + offline: false, + }; +} + +/** + * No-model fallback. Finds an Italian date with job.ts's parser, a venue after a locative + * preposition, and treats the head of the line as the title. Deliberately timid: it + * reports "bassa" and leaves everything it is unsure about in `freeText`. + */ +export function parseFreeTextLocally(text: string): ParsedBrief { + // A command prefix may still be attached when this is called straight off the input line. + const input = text.trim().replace(/^\/[a-z][\w-]*\s*/i, "").trim(); + if (!input) return emptyParse(); + + const fields: Partial> = {}; + const spans: [number, number][] = []; + const take = (m: RegExpExecArray | null, group = 0): string | undefined => { + if (!m || m[group] === undefined) return undefined; + const offset = group === 0 ? m.index : m.index + m[0].indexOf(m[group]!); + spans.push([offset, offset + m[group]!.length]); + return m[group]!.trim(); + }; + + // Date, kept verbatim as he typed it. Three shapes, longest first: + // worded ("sabato 12 e domenica 13 ottobre"), numeric ("12/09"), bare weekday + // ("venerdì sera"). The bare weekday has no parseable date, and that is fine — + // the wording is what gets printed; `dateParsed` is only for the slug. + const dateHit = DATE_RE.exec(input); + const date = take(dateHit); + if (date) fields.date = date; + + // Venue: "al chiostro", "in piazza a Ariccia", "presso il Teatro Comunale". + const venueHit = VENUE_RE.exec(input); + const venue = take(venueHit, 1)?.replace(/[,.;].*$/, "").trim(); + if (venue && venue.length >= 3) fields.venue = venue; + else if (venue) spans.pop(); + + const priceHit = PRICE_RE.exec(input); + const price = take(priceHit); + if (price) fields.price = price; + + // Title: everything before the first thing we recognised. + const firstHit = spans.length ? Math.min(...spans.map(([s]) => s)) : input.length; + const head = input + .slice(0, firstHit) + .replace(/\b(?:il|lo|la|i|gli|le|del|della|al|in|a|di|e)\s*$/i, "") + .replace(/[,;:–—-]\s*$/, "") + .trim(); + if (head.length >= 3) { + fields.title = head; + spans.push([0, firstHit]); + } + + let rest = leftovers(input, spans); + + // He may have led with the date ("sabato 12 settembre concerto d'estate al Teatro"), + // leaving nothing before the first span. Promote the meatiest leftover instead of + // opening the form with an empty title and a confusing "Altro" box. + if (!fields.title) { + const frags = rest.split(/,\s*/).map((f) => f.trim()).filter((f) => f.length >= 3); + const best = frags.slice().sort((a, b) => b.length - a.length)[0]; + if (best) { + const cleaned = best.replace(/\b(?:al|allo|alla|in|a|di|del|della|e|per|con)\s*$/i, "").trim(); + if (cleaned.length >= 3) { + fields.title = cleaned; + rest = frags.filter((f) => f !== best).join(", "); + } + } + } + + return { + fields, + freeText: rest, + dateParsed: fields.date ? parseSpokenDate(fields.date) : undefined, + confidence: "bassa", + missing: BLOCK_ROLES.filter((r) => !fields[r]), + offline: true, + }; +} + +/** + * job.ts's parseDate stops at the first number it sees, so "sabato 3 e domenica 4 agosto" + * defeats it. Retry from the last conjunction, where the day that carries the month is. + * Only ever used for the slug — the printed date stays his wording either way. + */ +function parseSpokenDate(text: string): ParsedDate | undefined { + const direct = parseDate(text); + if (direct) return direct; + const tail = text.split(/\s+e\s+/i).pop(); + return tail && tail !== text ? parseDate(tail) : undefined; +} + +const WEEKDAY = "(?:luned[iì]|marted[iì]|mercoled[iì]|gioved[iì]|venerd[iì]|sabato|domenica)"; +const MONTH = "(?:gen|feb|mar|apr|mag|giu|lug|ago|sett?|ott|nov|dic)[a-zà-ù]*"; +const TIME = "(?:\\s*,?\\s*(?:ore|alle|dalle)\\s*\\d{1,2}(?:[:.]\\d{2})?)?"; +const DAYPART = "(?:\\s+(?:sera|serata|pomeriggio|mattina|mattino|notte))?"; + +/** "sabato 12 e domenica 13 ottobre 2026, ore 21" | "12/09/26" | "venerdì sera". */ +const DATE_RE = new RegExp( + "(?:" + + // worded, with an optional second day for two-day sagre + `(?:${WEEKDAY}\\s+)?\\d{1,2}(?:\\s*(?:e|-|\\/)\\s*(?:${WEEKDAY}\\s+)?\\d{1,2})?\\s+(?:di\\s+)?${MONTH}(?:\\s+\\d{4})?` + + "|" + + // numeric, day-first as in Italy + `(?:${WEEKDAY}\\s+)?\\d{1,2}\\s*[\\/.\\-]\\s*\\d{1,2}(?:\\s*[\\/.\\-]\\s*\\d{2,4})?` + + "|" + + // bare weekday, optionally with a part of the day + `${WEEKDAY}${DAYPART}` + + ")" + + TIME, + "i", +); + +const VENUE_RE = + /\b(?:al|allo|alla|all'|ai|agli|alle|in|presso|dal|dalla|da)\s+((?:il\s+|lo\s+|la\s+|i\s+|gli\s+|le\s+)?[A-Za-zÀ-ÿ'][\wÀ-ÿ'’]*(?:\s+(?:di|del|della|dei|delle|d'|a)?\s*[A-ZÀ-Ý][\wÀ-ÿ'’]*){0,3})/; + +const PRICE_RE = + /(ingresso\s+(?:libero|gratuito|a\s+offerta(?:\s+libera)?)|\bgratis\b|\b\d{1,3}(?:[.,]\d{2})?\s*(?:€|euro\b)|€\s*\d{1,3}(?:[.,]\d{2})?)/i; + +/** Function words that carry nothing on their own — a fragment made only of these is noise. */ +const FILLER = new Set([ + "al", "allo", "alla", "all", "ai", "agli", "alle", "in", "presso", "dal", "dalla", "da", + "il", "lo", "la", "i", "gli", "le", "un", "una", "uno", "e", "di", "del", "della", "dei", + "delle", "per", "con", "a", "the", "of", +]); + +/** + * What is left of the line once the recognised spans are cut out, tidied so the "Altro / + * contesto" box does not open showing "al , in a". Fragments made only of function words + * are dropped; everything else is kept exactly as he wrote it. + */ +function leftovers(input: string, spans: readonly [number, number][]): string { + const merged = [...spans].sort((a, b) => a[0] - b[0]); + const pieces: string[] = []; + let cursor = 0; + for (const [s, e] of merged) { + if (s > cursor) pieces.push(input.slice(cursor, s)); + cursor = Math.max(cursor, e); + } + if (cursor < input.length) pieces.push(input.slice(cursor)); + + return pieces + .join(" ") + .split(/[,;\n]+/) + .map((frag) => frag.replace(/\s+/g, " ").trim()) + .filter((frag) => { + const words = frag.toLowerCase().replace(/[^a-zà-ÿ0-9\s]/g, " ").split(/\s+/).filter(Boolean); + return words.length > 0 && words.some((w) => !FILLER.has(w)); + }) + .join(", ") + .replace(/^[\s,;.\-–—]+|[\s,;.\-–—]+$/g, ""); +} + +function emptyParse(): ParsedBrief { + return { + fields: {}, + freeText: "", + confidence: "bassa", + missing: [...BLOCK_ROLES], + offline: true, + }; +} + +// --------------------------------------------------------------------------- +// generateCaption() — only when he asks +// --------------------------------------------------------------------------- + +const CAPTION_SYSTEM_PROMPT = `You write the social post that goes with a poster, in ITALIAN, +for someone who organises small local events. + +- 2 to 4 short sentences. Spoken Italian, warm, no marketing voice, no "non perdetevi + questo evento imperdibile", no exclamation marks stacked up. +- Repeat the date and the venue EXACTLY as they appear in the design, character for + character. They are already correct; changing them is the only way to get them wrong. +- Invent nothing: no times, no prices, no line-ups, no "prenotazione obbligatoria", no + links, no "link in bio". If it is not in the design, it does not go in the caption. +- At most one emoji, and only if it genuinely fits the event. +- hashtags: 5 to 8, lowercase, no accents, no '#' prefix, no spaces. Mix the event, the + kind of event, and the town. Do not hashtag the date. + +Return only the JSON object.`; + +/** + * Italian social copy for a finished job. Called ONLY after he has answered yes to + * "Vuoi che scriva anche il testo del post?" — never on its own initiative, and never as + * a rewrite of copy he wrote himself. + */ +export async function generateCaption( + spec: DesignSpec, + brief: Brief, + ctx: DirectorContext, + cfg?: ImgenConfig, +): Promise { + const model = resolveDirectorModel(cfg ?? ({ presets: {} } as unknown as ImgenConfig), ctx); + + const lines = spec.blocks.map((b) => `${b.role}: ${b.text}`).join("\n"); + const extra = brief.freeText?.trim(); + const dateHint = spec.blocks.find((b) => b.role === "date")?.text; + const humanDate = dateHint ? formatItalianDate(dateHint) : ""; + + const userPrompt = + `Il testo della locandina:\n${lines}\n` + + (humanDate && humanDate !== dateHint ? `\n(la data, per capirci: ${humanDate})\n` : "") + + (extra ? `\nContesto che mi ha dato lui: ${extra}\n` : "") + + `\nScrivi il post.`; + + const { value } = await askStructured( + ctx, + model, + CAPTION_SYSTEM_PROMPT, + userPrompt, + CaptionSchema, + "emit_caption", + "Emit the Italian social caption and its hashtags.", + (v) => v as Static, + { maxTokens: 700, temperature: 0.8 }, + ); + + const text = value.caption.trim(); + const hashtags = [ + ...new Set( + (value.hashtags ?? []) + .map((h) => foldAccents(String(h)).replace(/[^a-z0-9]/g, "")) + .filter((h) => h.length >= 3), + ), + ].slice(0, 8); + + return { + text, + hashtags, + full: hashtags.length ? `${text}\n\n${hashtags.map((h) => `#${h}`).join(" ")}` : text, + }; +} + +// --------------------------------------------------------------------------- +// Warning text +// --------------------------------------------------------------------------- + +/** + * TODO: these belong in `S.director` in ui/strings.ts, which is being written in + * parallel and has no such section yet. They are kept here, together, so moving them is + * a single cut-and-paste — nothing else in this file contains Italian. + */ +const WARNING_TEXT: Record) => string> = { + "font-display-replaced": (p) => `Il carattere «${p.carattere}» non andava bene per i titoli: ne ho scelto un altro.`, + "font-body-replaced": (p) => `«${p.carattere}» esiste solo in maiuscolo, quindi per le righe piccole ho usato un altro carattere.`, + "palette-unknown": () => "Non ho riconosciuto i colori proposti: ho usato quelli standard.", + "template-unknown": () => "Non ho riconosciuto l'impaginazione proposta: ho usato quella classica.", + "format-unknown": () => "Non ho riconosciuto il formato proposto: faccio la locandina A3.", + "art-prompt-scrubbed": () => "Ho tolto dalla descrizione dell'immagine i pezzi che chiedevano delle scritte: le scritte le metto io dopo, giuste.", + "art-prompt-empty": () => "La descrizione dell'immagine è rimasta vuota: ne ho usata una di riserva.", + "negative-completed": () => "Ho ricordato al programma di disegno di non scrivere nulla nell'immagine.", + "block-restored": (p) => `«${p.campo}» l'ho rimesso esattamente come l'hai scritto tu: ${p.testo}`, + "block-invented": (p) => `Ho tolto «${p.campo}»: c'era scritto qualcosa che tu non mi avevi detto.`, + "block-empty": (p) => `«${p.campo}» era vuoto, l'ho tolto.`, + "title-restored": (p) => `Ho rimesso il titolo che avevi scritto tu: ${p.titolo}`, + "preset-applied": (p) => (p.nome ? `Sto usando lo stile «${p.nome}».` : "Ho applicato il tuo stile salvato."), + retried: () => "Al primo tentativo non tornava: ho rifatto e adesso è a posto.", +}; + +function warn(code: DirectorWarningCode, params: Record = {}): DirectorWarning { + return { code, params, italian: WARNING_TEXT[code](params) }; +} diff --git a/extensions/imgen/doctor.ts b/extensions/imgen/doctor.ts new file mode 100644 index 0000000..34f67f9 --- /dev/null +++ b/extensions/imgen/doctor.ts @@ -0,0 +1,1090 @@ +/** + * doctor.ts — il controllo generale. + * + * Two callers, one implementation: + * • session_start → `runDoctor()` then `sessionBanner()`: one short line, only if + * something is actually wrong. Silence when all is well. + * • `/doctor` → `runDoctor()` then `formatReport()`: the full table. + * + * THREE RULES THIS FILE EXISTS TO KEEP: + * + * 1. IT NEVER THROWS. Every single check runs inside `check()`, which converts any + * exception into an ordinary "error" row. A health check that crashes is worse than + * no health check: it takes the whole session down at start-up. + * + * 2. A DISCONNECTED MODEL DISK IS A NORMAL CONDITION, NOT A FAULT. The models live on + * an external volume; it *will* be unplugged. We name the volume, say it is not + * connected, and tell him to plug it in — no stack trace, no ENOENT, no drama. + * + * 3. EVERY LINE HE READS IS ITALIAN, and every problem carries a concrete remedy. + * A check that says only "manca X" has failed at its job. + * + * Nothing here mutates anything except one temp file in the output directory (written + * and deleted immediately) — that is the only honest way to answer "can I write there?". + */ + +import { access, mkdir, readdir, rm, stat, statfs, writeFile } from "node:fs/promises"; +import { constants as FS } from "node:fs"; +import { spawn } from "node:child_process"; +import { homedir } from "node:os"; +import { arch, platform } from "node:process"; +import { dirname, isAbsolute, join, parse as parsePath, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { configPath, loadConfig, type ImgenConfig, type ModelsConfig } from "./config.ts"; +import { FONTS } from "./design/fonts.ts"; +import { GRPC_SERVER_BINARY, GRPC_SERVER_URL, isPortOpen, serverPaths } from "./backends/server.ts"; +import S, { bullets, errorText, fill, list, type ErrorMessage } from "./ui/strings.ts"; + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + +/** extensions/imgen/doctor.ts -> repo root. */ +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); + +/** Where install.sh unpacks the 22 bundled families (one directory per family). */ +export const FONTS_DIR = join(REPO_ROOT, "vendor", "fonts"); + +/** Default pi config dir, used only when the caller does not pass one. */ +export const DEFAULT_PI_CONFIG_DIR = join(homedir(), ".pi", "agent"); + +/** + * pi may be launched from a GUI context, where PATH is the bare + * `/usr/bin:/bin:/usr/sbin:/sbin` and Homebrew is invisible. Same list as cpu.ts — + * duplicated on purpose, because cpu.ts keeps its resolver private and doctor must not + * drag the whole CPU-tools module (and sharp) into session start-up. + */ +const EXTRA_BIN_DIRS = [ + join(REPO_ROOT, "vendor", "bin"), + "/opt/homebrew/bin", + "/usr/local/bin", + join(homedir(), ".cargo", "bin"), + join(homedir(), ".local", "bin"), +]; + +// --------------------------------------------------------------------------- +// Thresholds +// --------------------------------------------------------------------------- + +/** A "model" smaller than this is a Git LFS pointer or an interrupted download. */ +const MIN_MODEL_BYTES = 64 * 1024 * 1024; + +/** Below this much free space a print job (300 dpi A3 PNG + PDF) starts to be at risk. */ +const LOW_DISK_BYTES = 2 * 1024 * 1024 * 1024; + +/** Version probes must never hang a session start. */ +const VERSION_TIMEOUT_MS = 4_000; + +/** The templates are written against Typst 0.15 (see docs/typst-verified.md). */ +const TYPST_MIN = [0, 15] as const; + +// --------------------------------------------------------------------------- +// Local Italian strings +// --------------------------------------------------------------------------- + +/** + * Only the lines `ui/strings.ts` does not (yet) carry. `S.doctor.checks` already owns + * every check LABEL, and `S.errors.*` owns the big remedies, so this table is + * deliberately small: statuses and detail lines. + * + * TODO: fold into `S.doctor` once that table settles — kept local for now so two files + * are not being edited for one feature. + */ +const T = { + /** Row status when a check itself blew up. Rule 1 made visible. */ + checkFailed: "Non sono riuscito a fare questo controllo.", + checkFailedFix: "Non è colpa tua: riprova, e se si ripete mandami quello che scrive qui sotto.", + + config: { + ok: "Impostazioni lette.", + okDefaults: "Nessun file di impostazioni: uso quelle standard.", + fixDefaults: "Va benissimo così. Se vuoi cambiare cartelle o modelli, lancia ./install.sh.", + }, + + platform: { + notMac: "Questo pacchetto disegna solo su un Mac con chip Apple.", + notArm: "Serve un Mac con chip Apple (M1 o successivi): su Intel il motore non esiste.", + fix: "Il resto (impaginazione, PDF, ritagli) funziona lo stesso.", + }, + + disk: { + /** The headline case: the external volume is simply unplugged. */ + volumeAbsent: "Il disco «{disco}» non è collegato.", + volumeAbsentFix: "Collega il disco e riprova. I modelli li cerco in {dove}.", + volumeThereFolderMissing: "Il disco «{disco}» c'è, ma dentro manca la cartella dei modelli.", + folderMissing: "Non trovo la cartella dei modelli.", + folderMissingFix: "Dovrebbe essere {dove}. Creala e rimettici dentro i modelli, oppure lancia ./install.sh.", + notADir: "Il percorso dei modelli non è una cartella: {dove}", + notReadable: "La cartella dei modelli c'è ma non riesco a leggerla: {dove}", + notReadableFix: "Controlla i permessi della cartella (in Finder: Informazioni ▸ Autorizzazioni).", + notAbsolute: "Il percorso dei modelli deve partire dalla radice del disco: {dove}", + notAbsoluteFix: "Correggilo in {file} oppure rilancia ./install.sh.", + okExternal: "Il disco «{disco}» è collegato.", + okInternal: "I modelli stanno sul disco del computer.", + space: "Spazio libero: {spazio}.", + }, + + models: { + allOk: "Ci sono tutti i modelli che servono.", + someMissing: "Manca qualche modello.", + noneConfigured: "Non è configurato nessun modello per disegnare.", + skippedDiskOff: "Non posso controllare i modelli finché non è sistemato il disco qui sopra.", + remote: "si scarica da internet al primo uso", + present: "c'è", + missing: "manca", + truncated: "il file è troppo piccolo: scaricato a metà", + unusedRole: "non configurato", + fixMissing: "Rimetti i file mancanti in {dove}, oppure rilancia ./install.sh per riscaricarli.", + /** Roles, as he would name them. */ + roles: { + draft: "prova veloce", + final: "immagine definitiva", + alt: "stile alternativo", + edit: "modifica di un'immagine", + upscale: "ingrandimento per la stampa", + } as Record, + }, + + drawThings: { + ok: "Trovato: {dove}", + missing: "Non trovo il comando che disegna le immagini.", + }, + + typst: { + ok: "Trovato: {dove}", + okVersion: "Versione {versione}, trovato in {dove}", + missing: "Non trovo il programma che compone le scritte.", + tooOld: "La versione di Typst è vecchia ({versione}): le impaginazioni sono fatte per la {minima} o successive.", + tooOldFix: "Aggiornalo con «brew upgrade typst».", + }, + + server: { + disabled: "Il motore residente è spento nelle impostazioni: ogni immagine partirà da fredda (più lenta).", + disabledFix: "Se vuoi riaccenderlo, metti «server.enabled: true» in {file}.", + running: "Acceso e in ascolto sulla porta {porta}.", + binaryMissing: "Manca il programma del motore ({nome}).", + binaryMissingFix: "Scaricalo da {url} e mettilo in {dove}, oppure lancia ./install.sh.", + notExecutable: "Il programma del motore c'è ma non ha il permesso di partire: {dove}", + notExecutableFix: "Dagli il permesso con «chmod +x \"{dove}\"».", + stopped: "Il motore c'è ma non è acceso: le immagini partiranno da fredde (più lente).", + stoppedFix: "Lo accendo da solo quando serve. Se vuoi accenderlo a mano: {comando}", + foreign: "Sulla porta {porta} risponde già qualcosa che non ho avviato io: lo uso com'è e non lo tocco.", + }, + + fonts: { + ok: "Ci sono tutti i {n} caratteri, con le loro licenze.", + empty: "La cartella dei caratteri è vuota.", + emptyFix: "Lancia ./install.sh per scaricarli: senza caratteri non posso comporre le scritte.", + missing: "Mancano {n} caratteri su {tot}.", + missingFix: "Lancia ./install.sh per riscaricare quelli che mancano.", + noLicense: "{n} caratteri sono senza file di licenza.", + noLicenseItem: "manca la licenza", + noLicenseFix: "Le licenze vanno distribuite insieme ai caratteri: rilancia ./install.sh.", + dir: "Cartella: {dove}", + }, + + print: { + label: "Gli strumenti per la stampa", + allOk: "Ci sono tutti: posso controllare il PDF prima di darlo alla tipografia.", + someMissing: "Ne mancano alcuni: il PDF lo preparo lo stesso, ma non posso controllarlo bene.", + noneNeeded: "Sono facoltativi: senza di loro il PDF si fa comunque.", + fix: "Se li vuoi: brew install poppler qpdf ghostscript", + tools: { + pdfinfo: "controlla le misure e il riquadro di taglio del PDF", + pdffonts: "controlla che i caratteri siano incorporati", + qpdf: "controlla che il PDF non sia rovinato", + gs: "converte e comprime il PDF quando la tipografia lo chiede", + } as Record, + }, + + output: { + ok: "Posso scrivere in {dove}", + created: "Ho creato la cartella dei lavori: {dove}", + notAbsolute: "Il percorso della cartella dei lavori deve partire dalla radice del disco: {dove}", + volumeAbsent: "La cartella dei lavori sta sul disco «{disco}», che non è collegato: {dove}", + volumeAbsentFix: "Collega il disco «{disco}» e riprova. Non creo niente finché non c'è, altrimenti il Mac poi rimonta il disco con un altro nome.", + lowSpace: "Resta poco spazio sul disco ({spazio}): una locandina da stampare può occuparne parecchio.", + lowSpaceFix: "Libera un po' di posto, oppure sposta la cartella dei lavori.", + }, + + /** Report chrome. */ + report: { + heading: "Controllo generale", + detailPrefix: "dettaglio: ", + }, +} as const; + +// --------------------------------------------------------------------------- +// Public shape +// --------------------------------------------------------------------------- + +export type CheckId = + | "config" + | "platform" + | "modelsDisk" + | "models" + | "drawThings" + | "server" + | "typst" + | "fonts" + | "printTools" + | "outputDir"; + +/** + * - `ok` tutto a posto + * - `info` a posto, ma c'è qualcosa da sapere (roba facoltativa che manca) + * - `warn` si lavora lo stesso, ma peggio / più lentamente + * - `error` non si lavora finché non lo sistemi + */ +export type CheckLevel = "ok" | "info" | "warn" | "error"; + +/** One line inside a check: a model, a font family, an optional tool. */ +export interface CheckItem { + name: string; + ok: boolean; + /** Italian, short: "c'è", "manca", "si scarica al primo uso"… */ + status: string; + detail?: string; +} + +export interface CheckResult { + id: CheckId; + /** Italian label — from `S.doctor.checks` wherever that table has one. */ + label: string; + level: CheckLevel; + /** One Italian sentence: what the situation is. */ + message: string; + /** One Italian sentence: what to do about it. Absent when there is nothing to do. */ + fix?: string; + /** Technical detail (path, port, version). Never the whole message. */ + detail?: string; + items?: CheckItem[]; +} + +export interface DoctorReport { + /** True when nothing is blocking: no `error` rows. Warnings do not clear this flag. */ + ok: boolean; + /** True when at least one `warn` row is present. */ + warnings: boolean; + checks: CheckResult[]; + /** Every warn/error message, ready for `bullets()` or a `Backend.probe()` shape. */ + problems: string[]; + /** The config the checks were run against (defaults when the file was unreadable). */ + config: ImgenConfig; + configFile: string; + elapsedMs: number; +} + +export interface DoctorOptions { + /** Where `pi-imgen.json` lives. Defaults to `~/.pi/agent`. */ + piConfigDir?: string; + /** Pre-loaded config, when the caller already has one (session_start does). */ + config?: ImgenConfig; + /** Problems already found while loading that config. */ + configProblems?: string[]; + /** Skip the external `--version` calls. session_start passes true to stay instant. */ + fast?: boolean; +} + +// --------------------------------------------------------------------------- +// Never-throwing primitives +// --------------------------------------------------------------------------- + +async function statOf(path: string): Promise { + try { + return await stat(path); + } catch { + return null; + } +} + +async function canAccess(path: string, mode: number): Promise { + try { + await access(path, mode); + return true; + } catch { + return false; + } +} + +async function listDir(path: string): Promise { + try { + return await readdir(path); + } catch { + return []; + } +} + +/** Resolves an executable by name, searching the GUI-invisible dirs first. Never throws. */ +async function findExecutable(name: string): Promise { + if (name.includes("/")) return (await canAccess(name, FS.X_OK)) ? name : null; + const pathDirs = (process.env["PATH"] ?? "").split(":").filter(Boolean); + for (const dir of [...EXTRA_BIN_DIRS, ...pathDirs]) { + const candidate = join(dir, name); + const st = await statOf(candidate); + if (st?.isFile() && (await canAccess(candidate, FS.X_OK))) return candidate; + } + return null; +} + +/** Runs a short command for its stdout. Returns null on any failure or timeout. */ +function runBriefly(bin: string, args: string[], timeoutMs = VERSION_TIMEOUT_MS): Promise { + return new Promise((resolveOut) => { + let done = false; + const finish = (value: string | null): void => { + if (done) return; + done = true; + clearTimeout(timer); + resolveOut(value); + }; + let child: ReturnType | null = null; + const timer = setTimeout(() => { + try { child?.kill("SIGKILL"); } catch { /* already gone */ } + finish(null); + }, timeoutMs); + try { + child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] }); + } catch { + finish(null); + return; + } + let out = ""; + child.stdout?.on("data", (b: Buffer) => { out += b.toString("utf8"); }); + child.stderr?.on("data", (b: Buffer) => { out += b.toString("utf8"); }); + child.on("error", () => finish(null)); + child.on("close", (code) => finish(code === 0 ? out : out.trim() === "" ? null : out)); + }); +} + +/** "1,2 GB" — sizes as he would read them, with the Italian decimal comma. */ +export function humanBytes(bytes: number): string { + const units = ["B", "kB", "MB", "GB", "TB"]; + let value = bytes; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + const text = unit === 0 ? String(Math.round(value)) : value.toFixed(1).replace(".", ","); + return `${text} ${units[unit]}`; +} + +/** Free bytes on the filesystem holding `path`, or null when it cannot be measured. */ +async function freeBytes(path: string): Promise { + try { + const fsStat = await statfs(path); + return Number(fsStat.bavail) * Number(fsStat.bsize); + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Volumes — the check that makes an unplugged disk a sentence, not a crash +// --------------------------------------------------------------------------- + +export interface VolumeInfo { + /** The path we were asked about. */ + path: string; + /** Mount point of the volume that (should) hold it, e.g. `/Volumes/Foto` or `/`. */ + root: string; + /** Volume name as it appears in Finder, when the path is on an external disk. */ + name: string | null; + /** True when the path is NOT on the boot disk. */ + external: boolean; + /** True when the volume is actually mounted right now. */ + mounted: boolean; + /** Deepest ancestor of `path` that exists. Useful to explain what is missing. */ + deepestExisting: string | null; +} + +/** + * Works out whether the model volume is mounted, without ever asking the OS a question + * it can answer with an exception. + * + * macOS mounts external disks under `/Volumes/`, so the volume NAME is simply the + * second path segment — that is what lets us say «Il disco "Foto" non è collegato» + * instead of printing an ENOENT. Two traps handled: + * • an unmounted disk usually leaves NO `/Volumes/` entry at all; + * • sometimes it leaves an empty stub directory on the boot volume. We catch that by + * comparing st_dev with `/`: same device means the mount point is a plain folder, + * i.e. the real disk is not there. + * Off macOS the same st_dev walk still finds the mount point, so this is portable. + */ +export async function volumeOf(path: string): Promise { + const info: VolumeInfo = { + path, + root: "/", + name: null, + external: false, + mounted: true, + deepestExisting: null, + }; + + // Deepest existing ancestor: also tells us where a missing tree stops existing. + let cursor = path; + for (;;) { + const st = await statOf(cursor); + if (st) { info.deepestExisting = cursor; break; } + const parent = dirname(cursor); + if (parent === cursor) break; + cursor = parent; + } + + const segments = path.split("/").filter(Boolean); + const underVolumes = path.startsWith("/Volumes/") && segments.length >= 2; + if (underVolumes) { + info.name = segments[1] ?? null; + info.root = `/Volumes/${info.name}`; + info.external = true; + } + + const rootStat = await statOf(info.root); + const bootStat = await statOf("/"); + + if (info.external) { + if (!rootStat) { + info.mounted = false; + } else if (bootStat && rootStat.dev === bootStat.dev) { + // Empty stub left behind by an ejected disk: the folder exists, the disk does not. + const entries = await listDir(info.root); + info.mounted = entries.length > 0; + } + return info; + } + + // Not under /Volumes: walk up from the deepest existing ancestor to its mount point, + // so a path on any other mounted filesystem is still described correctly. + if (info.deepestExisting) { + let current = info.deepestExisting; + let currentStat = await statOf(current); + for (;;) { + const parent = dirname(current); + if (parent === current) break; + const parentStat = await statOf(parent); + if (!parentStat || !currentStat || parentStat.dev !== currentStat.dev) break; + current = parent; + currentStat = parentStat; + } + info.root = current; + if (bootStat && currentStat && currentStat.dev !== bootStat.dev) { + info.external = true; + info.name = parsePath(current).base || current; + } + } + return info; +} + +// --------------------------------------------------------------------------- +// The checks +// --------------------------------------------------------------------------- + +type CheckBody = Omit; + +/** Rule 1, in one function: no check can ever escape with an exception. */ +async function check(id: CheckId, label: string, body: () => Promise): Promise { + try { + return { id, label, ...(await body()) }; + } catch (e) { + return { + id, + label, + level: "error", + message: T.checkFailed, + fix: T.checkFailedFix, + detail: (e as Error)?.message ?? String(e), + }; + } +} + +/** Turns an `S.errors.*` entry into a check body, so remedies stay in one place. */ +function fromError(level: CheckLevel, e: ErrorMessage): CheckBody { + return { + level, + message: e.message, + ...(e.fix ? { fix: e.fix } : {}), + ...(e.detail ? { detail: e.detail } : {}), + }; +} + +function isRemoteModelRef(ref: string): boolean { + return /^hf:\/\//i.test(ref) || /^https?:\/\//i.test(ref); +} + +async function checkConfig(configFile: string, problems: string[]): Promise { + const present = (await statOf(configFile)) !== null; + if (problems.length > 0) { + return { ...fromError("warn", S.errors.configBroken(configFile, problems.join("; "))), detail: configFile }; + } + if (!present) { + return { level: "info", message: T.config.okDefaults, fix: T.config.fixDefaults, detail: configFile }; + } + return { level: "ok", message: T.config.ok, detail: configFile }; +} + +async function checkPlatform(): Promise { + if (platform !== "darwin") { + return { level: "error", message: T.platform.notMac, fix: T.platform.fix, detail: `${platform}/${arch}` }; + } + if (arch !== "arm64") { + return { level: "error", message: T.platform.notArm, fix: T.platform.fix, detail: `${platform}/${arch}` }; + } + return { level: "ok", message: "macOS · Apple Silicon", detail: `${platform}/${arch}` }; +} + +async function checkModelsDisk(cfg: ImgenConfig, configFile: string): Promise { + const path = cfg.modelsPath; + + if (!isAbsolute(path)) { + return { + usable: false, + level: "error", + message: fill(T.disk.notAbsolute, { dove: path }), + fix: fill(T.disk.notAbsoluteFix, { file: configFile }), + detail: path, + }; + } + + const vol = await volumeOf(path); + + // THE expected condition: the external disk is simply not plugged in. + if (vol.external && !vol.mounted) { + const err = S.errors.modelsDiskMissing(path); + return { + usable: false, + level: "error", + message: fill(T.disk.volumeAbsent, { disco: vol.name ?? vol.root }), + fix: fill(T.disk.volumeAbsentFix, { dove: path }), + detail: `${vol.root} — ${err.detail ?? path}`, + }; + } + + const st = await statOf(path); + if (!st) { + return { + usable: false, + level: "error", + message: vol.external + ? fill(T.disk.volumeThereFolderMissing, { disco: vol.name ?? vol.root }) + : T.disk.folderMissing, + fix: fill(T.disk.folderMissingFix, { dove: path }), + detail: vol.deepestExisting ? `esiste fino a ${vol.deepestExisting}` : path, + }; + } + if (!st.isDirectory()) { + return { usable: false, level: "error", message: fill(T.disk.notADir, { dove: path }), detail: path }; + } + if (!(await canAccess(path, FS.R_OK | FS.X_OK))) { + return { + usable: false, + level: "error", + message: fill(T.disk.notReadable, { dove: path }), + fix: T.disk.notReadableFix, + detail: path, + }; + } + + const free = await freeBytes(path); + const space = free === null ? "" : ` ${fill(T.disk.space, { spazio: humanBytes(free) })}`; + return { + usable: true, + level: "ok", + message: (vol.external + ? fill(T.disk.okExternal, { disco: vol.name ?? vol.root }) + : T.disk.okInternal) + space, + detail: path, + }; +} + +async function checkModels(cfg: ImgenConfig, diskUsable: boolean): Promise { + const roles: (keyof ModelsConfig)[] = ["draft", "final", "alt", "edit", "upscale"]; + const blocking = new Set(["draft", "final"]); + + if (!diskUsable) { + return { level: "warn", message: T.models.skippedDiskOff, detail: cfg.modelsPath }; + } + + const items: CheckItem[] = []; + const missing: string[] = []; + let blockingMissing = false; + + for (const role of roles) { + const file = cfg.models[role]; + const roleLabel = T.models.roles[role]; + if (!file) { + if (blocking.has(role)) { + blockingMissing = true; + items.push({ name: roleLabel, ok: false, status: T.models.unusedRole }); + } + continue; + } + if (isRemoteModelRef(file)) { + items.push({ name: `${roleLabel} — ${file}`, ok: true, status: T.models.remote }); + continue; + } + const full = join(cfg.modelsPath, file); + const st = await statOf(full); + if (!st?.isFile()) { + items.push({ name: `${roleLabel} — ${file}`, ok: false, status: T.models.missing, detail: full }); + missing.push(file); + if (blocking.has(role)) blockingMissing = true; + continue; + } + if (st.size < MIN_MODEL_BYTES) { + // A 4-6GB checkpoint that weighs a few kB is an LFS pointer or a killed download. + items.push({ + name: `${roleLabel} — ${file}`, + ok: false, + status: T.models.truncated, + detail: `${full} (${humanBytes(st.size)})`, + }); + missing.push(file); + if (blocking.has(role)) blockingMissing = true; + continue; + } + items.push({ name: `${roleLabel} — ${file}`, ok: true, status: `${T.models.present} (${humanBytes(st.size)})` }); + } + + if (items.length === 0) { + return { level: "error", message: T.models.noneConfigured, fix: fill(T.models.fixMissing, { dove: cfg.modelsPath }) }; + } + if (missing.length === 0 && !blockingMissing) { + return { level: "ok", message: T.models.allOk, items }; + } + // One missing model gets the fully specific remedy from strings.ts; several get the + // generic one, because five identical paragraphs are not a better message. + const single = missing.length === 1 ? S.errors.modelMissing(missing[0]!, cfg.modelsPath) : null; + return { + level: blockingMissing ? "error" : "warn", + message: single ? single.message : T.models.someMissing, + fix: single?.fix ?? fill(T.models.fixMissing, { dove: cfg.modelsPath }), + items, + }; +} + +async function checkDrawThings(): Promise { + const bin = await findExecutable("draw-things-cli"); + if (!bin) return fromError("error", S.errors.drawThingsMissing); + return { level: "ok", message: fill(T.drawThings.ok, { dove: bin }), detail: bin }; +} + +async function checkTypst(fast: boolean): Promise { + const bin = await findExecutable("typst"); + if (!bin) return fromError("error", S.errors.typstMissing); + if (fast) return { level: "ok", message: fill(T.typst.ok, { dove: bin }), detail: bin }; + + const out = (await runBriefly(bin, ["--version"])) ?? ""; + const m = /(\d+)\.(\d+)\.(\d+)/.exec(out); + if (!m) return { level: "ok", message: fill(T.typst.ok, { dove: bin }), detail: bin }; + + const version = `${m[1]}.${m[2]}.${m[3]}`; + const major = Number(m[1]); + const minor = Number(m[2]); + const old = major < TYPST_MIN[0] || (major === TYPST_MIN[0] && minor < TYPST_MIN[1]); + if (old) { + return { + level: "warn", + message: fill(T.typst.tooOld, { versione: version, minima: `${TYPST_MIN[0]}.${TYPST_MIN[1]}` }), + fix: T.typst.tooOldFix, + detail: bin, + }; + } + return { level: "ok", message: fill(T.typst.okVersion, { versione: version, dove: bin }), detail: bin }; +} + +/** + * The daemon. NOTE: nothing here is an `error`. `draw-things-cli` works perfectly well + * cold — it just reloads 6GB of weights on every single call, which turns a draft loop + * into a coffee break. So: warn, never block. + */ +async function checkServer(cfg: ImgenConfig, piConfigDir: string): Promise { + const paths = serverPaths(cfg, piConfigDir); + const configFile = configPath(piConfigDir); + const listening = await isPortOpen(cfg.server.port); + + if (!cfg.server.enabled) { + return { + level: listening ? "info" : "warn", + message: T.server.disabled, + fix: fill(T.server.disabledFix, { file: configFile }), + detail: paths.binary, + }; + } + + // A binary on PATH is just as good as our own copy under the pi config dir. + const own = await statOf(paths.binary); + const bin = own?.isFile() ? paths.binary : await findExecutable(GRPC_SERVER_BINARY); + + if (!bin) { + return { + level: listening ? "info" : "warn", + message: listening + ? fill(T.server.foreign, { porta: cfg.server.port }) + : fill(T.server.binaryMissing, { nome: GRPC_SERVER_BINARY }), + ...(listening + ? {} + : { fix: fill(T.server.binaryMissingFix, { url: GRPC_SERVER_URL, dove: paths.stateDir }) }), + detail: paths.binary, + }; + } + + if (!(await canAccess(bin, FS.X_OK))) { + return { + level: "warn", + message: fill(T.server.notExecutable, { dove: bin }), + fix: fill(T.server.notExecutableFix, { dove: bin }), + detail: bin, + }; + } + + if (listening) { + return { level: "ok", message: fill(T.server.running, { porta: cfg.server.port }), detail: `${bin} :${cfg.server.port}` }; + } + + const command = + `"${bin}" "${cfg.modelsPath}" --no-tls --port ${cfg.server.port}` + + (cfg.server.cpuOffload ? " --cpu-offload" : ""); + return { + level: "warn", + message: T.server.stopped, + fix: fill(T.server.stoppedFix, { comando: command }), + detail: bin, + }; +} + +// -- fonts ------------------------------------------------------------------- + +const FONT_EXT = /\.(ttf|otf|ttc|woff2?)$/i; +const LICENSE_FILE = /^(license|licence|ofl|ufl|copying|apache)/i; + +const normalise = (s: string): string => s.toLowerCase().replace(/[^a-z0-9]/g, ""); + +/** + * vendor/fonts is written by install.sh from Fontsource + * (`api.fontsource.org/v1/download/{id}` ships the TTFs *and* the LICENSE), so the + * expected layout is one directory per family named after its Fontsource id. We accept + * the google/fonts slug and the family name too, plus a flat dump of files, because a + * doctor that only recognises one spelling reports false alarms. + */ +async function checkFonts(): Promise { + const entries = await listDir(FONTS_DIR); + const real = entries.filter((e) => !e.startsWith(".")); + if (real.length === 0) { + return { level: "error", message: T.fonts.empty, fix: T.fonts.emptyFix, detail: FONTS_DIR }; + } + + // Index the tree once: directories with their files, plus any files at the top level. + const dirs = new Map(); + const flat: string[] = []; + for (const entry of real) { + const full = join(FONTS_DIR, entry); + const st = await statOf(full); + if (st?.isDirectory()) dirs.set(normalise(entry), await listDir(full)); + else if (st?.isFile()) flat.push(entry); + } + + const items: CheckItem[] = []; + const missing: string[] = []; + const unlicensed: string[] = []; + + for (const font of FONTS) { + const keys = [font.id, font.slug, font.family].map(normalise); + const dirKey = keys.find((k) => dirs.has(k)); + let files: string[] = []; + if (dirKey) { + files = dirs.get(dirKey) ?? []; + } else { + // Flat layout: match "Anton-Regular.ttf", "anton-latin-400-normal.woff2", … + files = flat.filter((f) => keys.some((k) => normalise(f).startsWith(k))); + } + + const faces = files.filter((f) => FONT_EXT.test(f)); + const hasLicense = files.some((f) => LICENSE_FILE.test(f)); + + if (faces.length === 0) { + missing.push(font.family); + items.push({ name: font.family, ok: false, status: T.models.missing }); + continue; + } + if (!hasLicense) { + unlicensed.push(font.family); + items.push({ name: font.family, ok: false, status: `${T.fonts.noLicenseItem} (${font.license})` }); + continue; + } + items.push({ name: font.family, ok: true, status: `${faces.length} · ${font.license}` }); + } + + if (missing.length === 0 && unlicensed.length === 0) { + return { + level: "ok", + message: fill(T.fonts.ok, { n: FONTS.length }), + detail: fill(T.fonts.dir, { dove: FONTS_DIR }), + items, + }; + } + if (missing.length > 0) { + // Every family missing = install.sh never ran; a couple missing = a partial download. + return { + level: missing.length === FONTS.length ? "error" : "warn", + message: fill(T.fonts.missing, { n: missing.length, tot: FONTS.length }), + fix: T.fonts.missingFix, + detail: list(missing), + items, + }; + } + return { + level: "warn", + message: fill(T.fonts.noLicense, { n: unlicensed.length }), + fix: T.fonts.noLicenseFix, + detail: list(unlicensed), + items, + }; +} + +/** + * Print helpers. All optional by design: the PDF is produced by Typst alone. These only + * let us VERIFY it (TrimBox, embedded fonts, integrity) before he sends it to a printer, + * so their absence is never worse than `info`/`warn`. + */ +async function checkPrintTools(): Promise { + const names = ["pdfinfo", "pdffonts", "qpdf", "gs"]; + const items: CheckItem[] = []; + const found: string[] = []; + + for (const name of names) { + const bin = await findExecutable(name); + const what = T.print.tools[name] ?? ""; + if (bin) found.push(name); + items.push({ + name: `${name} — ${what}`, + ok: Boolean(bin), + status: bin ? T.models.present : T.models.missing, + ...(bin ? { detail: bin } : {}), + }); + } + + if (found.length === names.length) return { level: "ok", message: T.print.allOk, items }; + return { + level: found.length === 0 ? "info" : "warn", + message: found.length === 0 ? T.print.noneNeeded : T.print.someMissing, + fix: T.print.fix, + items, + }; +} + +/** + * The only check that writes: creating the directory and putting one byte in it is the + * only way to distinguish "writable" from "looks writable" (network shares, read-only + * volumes, and full disks all pass a permission test and then fail on write). + * + * The volume test in front of the `mkdir` is not decoration. If the output directory + * lives on the same external disk as the models and that disk is unplugged, a + * `mkdir -p` would happily create `/Volumes//…` ON THE BOOT DISK — the stub that + * makes macOS remount the real disk as " 1" next time. We refuse to write into a + * volume that is not there. + */ +async function checkOutputDir(cfg: ImgenConfig): Promise { + const dir = cfg.outputDir; + if (!isAbsolute(dir)) { + return { level: "error", message: fill(T.output.notAbsolute, { dove: dir }), detail: dir }; + } + + const vol = await volumeOf(dir); + if (vol.external && !vol.mounted) { + return { + level: "error", + message: fill(T.output.volumeAbsent, { disco: vol.name ?? vol.root, dove: dir }), + fix: fill(T.output.volumeAbsentFix, { disco: vol.name ?? vol.root }), + detail: vol.root, + }; + } + + const existed = (await statOf(dir))?.isDirectory() ?? false; + const probe = join(dir, `.imgen-doctor-${process.pid}`); + try { + await mkdir(dir, { recursive: true }); + await writeFile(probe, "ok", "utf8"); + } catch (e) { + return fromError("error", S.errors.writeFailed(dir, (e as Error).message)); + } finally { + await rm(probe, { force: true }).catch(() => { /* nothing to clean up */ }); + } + + const free = await freeBytes(dir); + if (free !== null && free < LOW_DISK_BYTES) { + return { + level: "warn", + message: fill(T.output.lowSpace, { spazio: humanBytes(free) }), + fix: T.output.lowSpaceFix, + detail: dir, + }; + } + const space = free === null ? "" : ` ${fill(T.disk.space, { spazio: humanBytes(free) })}`; + return { + level: "ok", + message: (existed ? fill(T.output.ok, { dove: dir }) : fill(T.output.created, { dove: dir })) + space, + detail: dir, + }; +} + +// --------------------------------------------------------------------------- +// Runner +// --------------------------------------------------------------------------- + +const LABELS = S.doctor.checks; + +/** + * Runs every check. NEVER throws, NEVER rejects: the worst it can return is a report + * made entirely of `error` rows. + * + * Checks are ordered the way he would fix them: settings, machine, disk, models, tools. + * The models check is deliberately sequenced AFTER the disk check and is fed its result, + * so an unplugged disk produces one clear sentence instead of six ENOENTs. + */ +export async function runDoctor(opts: DoctorOptions = {}): Promise { + const started = Date.now(); + const piConfigDir = opts.piConfigDir ?? DEFAULT_PI_CONFIG_DIR; + const configFile = configPath(piConfigDir); + const fast = opts.fast ?? false; + + let config: ImgenConfig; + let configProblems: string[]; + if (opts.config) { + config = opts.config; + configProblems = opts.configProblems ?? []; + } else { + // loadConfig() is itself never-throwing and degrades to DEFAULTS. + const loaded = loadConfig(piConfigDir); + config = loaded.config; + configProblems = loaded.problems; + } + + const checks: CheckResult[] = []; + checks.push(await check("config", LABELS.config, () => checkConfig(configFile, configProblems))); + checks.push(await check("platform", "Il computer", () => checkPlatform())); + + // Disk first, then models, which need to know whether the disk answered at all. + let diskUsable = false; + const disk = await check("modelsDisk", LABELS.modelsDisk, async () => { + const { usable, ...body } = await checkModelsDisk(config, configFile); + diskUsable = usable; + return body; + }); + checks.push(disk); + checks.push(await check("models", LABELS.models, () => checkModels(config, diskUsable))); + + checks.push(await check("drawThings", LABELS.drawThings, () => checkDrawThings())); + checks.push(await check("server", LABELS.server, () => checkServer(config, piConfigDir))); + checks.push(await check("typst", LABELS.typst, () => checkTypst(fast))); + checks.push(await check("fonts", LABELS.fonts, () => checkFonts())); + checks.push(await check("printTools", T.print.label, () => checkPrintTools())); + checks.push(await check("outputDir", LABELS.outputDir, () => checkOutputDir(config))); + + const problems = checks + .filter((c) => c.level === "warn" || c.level === "error") + .map((c) => c.message); + + return { + ok: !checks.some((c) => c.level === "error"), + warnings: checks.some((c) => c.level === "warn"), + checks, + problems, + config, + configFile, + elapsedMs: Date.now() - started, + }; +} + +/** `Backend.probe()`-shaped view, for callers that only want the two fields. */ +export async function probe(opts: DoctorOptions = {}): Promise<{ ok: boolean; problems: string[] }> { + const report = await runDoctor(opts); + return { ok: report.ok, problems: report.problems }; +} + +// --------------------------------------------------------------------------- +// Presentation +// --------------------------------------------------------------------------- + +const MARK: Record = { ok: "✓", info: "·", warn: "!", error: "✗" }; + +export function isBlocking(c: CheckResult): boolean { + return c.level === "error"; +} + +/** The full Italian report, for `/doctor`. Pure string building; cannot throw. */ +export function formatReport(report: DoctorReport, opts: { verbose?: boolean } = {}): string { + const verbose = opts.verbose ?? false; + const lines: string[] = [S.doctor.title, ""]; + + for (const c of report.checks) { + lines.push(`${MARK[c.level]} ${c.label} — ${c.message}`); + if (c.fix && c.level !== "ok") lines.push(` → ${c.fix}`); + if (c.items && (verbose || c.level !== "ok")) { + // On a healthy row the item list is noise; on a broken one it is the diagnosis. + const items = verbose ? c.items : c.items.filter((i) => !i.ok); + for (const i of items) lines.push(` ${i.ok ? "·" : "✗"} ${i.name}: ${i.status}`); + } + if (verbose && c.detail) lines.push(` ${T.report.detailPrefix}${c.detail}`); + } + + lines.push(""); + if (report.ok && !report.warnings) { + lines.push(S.doctor.allGood); + } else { + lines.push(S.doctor.someProblems); + lines.push(bullets(report.problems)); + lines.push(S.doctor.hint); + } + return lines.join("\n"); +} + +/** + * One line for session_start. Returns null when everything is fine and there is nothing + * worth saying — a health check that greets him every morning is a health check he stops + * reading. + */ +export function sessionBanner(report: DoctorReport): string | null { + if (report.ok && !report.warnings) return null; + + // One blocking fault gets the full two-line treatment: what happened, what to do. + // Several get a list, because four remedies in a row at start-up is a wall of text. + const blocking = report.checks.filter(isBlocking); + if (blocking.length === 1) { + const only = blocking[0]!; + return errorText({ message: only.message, ...(only.fix ? { fix: only.fix } : {}) }); + } + const shown = blocking.length > 0 ? blocking.map((c) => c.message) : report.problems; + return `${S.doctor.someProblems}\n${bullets(shown)}\n${S.doctor.hint}`; +} + +// --------------------------------------------------------------------------- +// Standalone entry point: `node --import jiti/register doctor.ts [--verbose]` +// --------------------------------------------------------------------------- + +async function main(): Promise { + const argv = process.argv.slice(2); + const verbose = argv.includes("--verbose") || argv.includes("-v"); + const dirFlag = argv.findIndex((a) => a === "--config-dir"); + const piConfigDir = dirFlag >= 0 ? argv[dirFlag + 1] : undefined; + + const report = await runDoctor({ + ...(piConfigDir ? { piConfigDir } : {}), + }); + process.stdout.write(`${formatReport(report, { verbose })}\n`); + process.exitCode = report.ok ? 0 : 1; +} + +const invokedDirectly = + process.argv[1] !== undefined && + import.meta.url === pathToFileURL(process.argv[1]).href; + +if (invokedDirectly) { + // Even the CLI path obeys rule 1: a failure here prints, it does not stack-trace. + main().catch((e: unknown) => { + process.stdout.write(`${errorText(S.errors.unknown((e as Error)?.message))}\n`); + process.exitCode = 1; + }); +} + +export default runDoctor; diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..aa80a63 --- /dev/null +++ b/install.sh @@ -0,0 +1,1036 @@ +#!/usr/bin/env bash +# +# pi-imgen — installazione per macOS su Apple Silicon. +# +# Ri-eseguibile per costruzione: ogni passo controlla prima di agire, ripara invece di +# duplicare, e NON sovrascrive mai i preset dell'utente in ~/.pi/agent/pi-imgen.json. +# +# NOTE FOR MAINTAINERS (English, per CODE STYLE; all user-visible strings are Italian): +# - macOS ships bash 3.2, so: no associative arrays, no ${var,,}, no `read -i`, +# and empty arrays must be guarded with ${#arr[@]} before expanding under `set -u`. +# - The gRPCServerCLI install location and pinned version MUST stay in sync with +# extensions/imgen/backends/server.ts (GRPC_SERVER_VERSION / serverPaths()), which +# defaults the binary to /imgen/gRPCServerCLI-macOS. We deliberately do +# NOT write into a Homebrew prefix we do not own, and we deliberately leave +# `server.binary` unset in the config so the code default keeps the two in step. +# - We never invent CLI flags. `draw-things-cli models ensure` is attempted ONLY if the +# locally installed CLI's own --help confirms both the subcommand and the flags; see +# prefetch_models(). Otherwise we print exact manual instructions and say so. +# - No sudo is ever run by this script. The only privileged step is the official +# Homebrew installer, which asks for its own password and only after we ask first. + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Costanti +# --------------------------------------------------------------------------- + +REPO_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" + +# Sincronizzati con extensions/imgen/backends/server.ts +GRPC_SERVER_VERSION="v1.20260716.0" +GRPC_SERVER_BINARY="gRPCServerCLI-macOS" +GRPC_SERVER_URL="https://github.com/drawthingsai/draw-things-community/releases/download/${GRPC_SERVER_VERSION}/${GRPC_SERVER_BINARY}" + +MODELS_CATALOGUE_URL="https://models.drawthings.ai/models.json" + +PI_CONFIG_DIR="${PI_CONFIG_DIR:-$HOME/.pi/agent}" +STATE_DIR="$PI_CONFIG_DIR/imgen" +CFG_PATH="$PI_CONFIG_DIR/pi-imgen.json" + +BREW_REQUIRED="draw-things-cli typst" +BREW_OPTIONAL="ghostscript poppler qpdf" + +# Spazio consigliato per i modelli (~10 GB di pesi + margine di lavoro). +MIN_MODELS_GB=15 + +DEFAULT_OUT_DIR="$HOME/Immagini/generated" + +# --------------------------------------------------------------------------- +# Interfaccia +# --------------------------------------------------------------------------- + +if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then + C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'; C_DIM=$'\033[2m' + C_RED=$'\033[31m'; C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'; C_BLUE=$'\033[34m' +else + C_RESET=""; C_BOLD=""; C_DIM=""; C_RED=""; C_GREEN=""; C_YELLOW=""; C_BLUE="" +fi + +STEP_NO=0 +step() { STEP_NO=$((STEP_NO + 1)); printf '\n%s[%d/8] %s%s\n' "$C_BOLD$C_BLUE" "$STEP_NO" "$1" "$C_RESET"; } +ok() { printf ' %s✓%s %s\n' "$C_GREEN" "$C_RESET" "$1"; } +info() { printf ' %s·%s %s\n' "$C_DIM" "$C_RESET" "$1"; } +warn() { printf ' %s!%s %s\n' "$C_YELLOW" "$C_RESET" "$1"; } +err() { printf ' %s✗%s %s\n' "$C_RED" "$C_RESET" "$1" >&2; } +hint() { printf ' %s%s%s\n' "$C_DIM" "$1" "$C_RESET"; } + +die() { + printf '\n%s✗ Installazione interrotta.%s %s\n\n' "$C_RED$C_BOLD" "$C_RESET" "$1" >&2 + exit 1 +} + +# Esiti raccolti per il riepilogo finale. +SUMMARY_LINES=() +ESSENTIAL_MISSING=0 + +record() { # record + SUMMARY_LINES+=("$1"$'\t'"$2"$'\t'"$3") + [ "$1" = "fail" ] && ESSENTIAL_MISSING=$((ESSENTIAL_MISSING + 1)) + return 0 +} + +# --------------------------------------------------------------------------- +# Opzioni +# --------------------------------------------------------------------------- + +ASSUME_YES=0 +DOCTOR_ONLY=0 +SKIP_MODELS=0 +ARG_MODELS_DIR="" +ARG_OUT_DIR="" + +usage() { + cat <<'USAGE' +pi-imgen — installazione (macOS, Apple Silicon) + +Uso: ./install.sh [opzioni] + + -y, --yes non fare domande: usa i valori predefiniti (o quelli già + presenti nella configurazione). Utile per riesecuzioni. + --models-dir DIR cartella dei modelli (deve stare su un disco secondario) + --out-dir DIR cartella dei file generati + --skip-models non prescaricare i modelli + --doctor esegui solo il controllo finale, senza installare nulla + -h, --help mostra questo messaggio + +Lo script è ri-eseguibile: ripara ciò che manca e non tocca i tuoi preset. +USAGE +} + +while [ $# -gt 0 ]; do + case "$1" in + -y|--yes) ASSUME_YES=1 ;; + --doctor) DOCTOR_ONLY=1 ;; + --skip-models) SKIP_MODELS=1 ;; + --models-dir) [ $# -ge 2 ] || die "Manca il valore per --models-dir."; ARG_MODELS_DIR="$2"; shift ;; + --models-dir=*) ARG_MODELS_DIR="${1#*=}" ;; + --out-dir) [ $# -ge 2 ] || die "Manca il valore per --out-dir."; ARG_OUT_DIR="$2"; shift ;; + --out-dir=*) ARG_OUT_DIR="${1#*=}" ;; + -h|--help) usage; exit 0 ;; + *) usage >&2; die "Opzione sconosciuta: $1" ;; + esac + shift +done + +INTERACTIVE=1 +if [ ! -t 0 ] || [ "$ASSUME_YES" = "1" ]; then INTERACTIVE=0; fi + +# --------------------------------------------------------------------------- +# Utilità +# --------------------------------------------------------------------------- + +have() { command -v "$1" >/dev/null 2>&1; } + +expand_tilde() { # stampa il percorso con ~ espansa + case "$1" in + "~") printf '%s' "$HOME" ;; + "~/"*) printf '%s/%s' "$HOME" "${1#\~/}" ;; + *) printf '%s' "$1" ;; + esac +} + +ask() { # ask -> stampa la risposta + local question="$1" default="$2" reply="" + if [ "$INTERACTIVE" = "0" ]; then printf '%s' "$default"; return 0; fi + printf ' %s%s%s\n [%s]: ' "$C_BOLD" "$question" "$C_RESET" "$default" >&2 + IFS= read -r reply || reply="" + [ -n "$reply" ] || reply="$default" + printf '%s' "$reply" +} + +confirm() { # confirm -> 0 = sì + local question="$1" default="$2" reply="" + if [ "$INTERACTIVE" = "0" ]; then [ "$default" = "s" ]; return; fi + local prompt="s/N"; [ "$default" = "s" ] && prompt="S/n" + printf ' %s%s%s [%s]: ' "$C_BOLD" "$question" "$C_RESET" "$prompt" >&2 + IFS= read -r reply || reply="" + [ -n "$reply" ] || reply="$default" + case "$reply" in [sSyY]*) return 0 ;; *) return 1 ;; esac +} + +free_gb() { # spazio libero in GB; stampa sempre un numero, 0 se non si sa + local gb + gb="$(df -Pk "$1" 2>/dev/null | awk 'NR==2 { printf "%.0f", $4 / 1048576 }')" + case "$gb" in + ''|*[!0-9]*) printf '0' ;; + *) printf '%s' "$gb" ;; + esac +} + +# Un file scaricato è davvero un eseguibile macOS? Stessi magic number di server.ts: +# difende da una pagina di errore HTML o da un puntatore Git LFS serviti con 200 OK. +is_macho() { + local magic + magic="$(head -c 4 "$1" 2>/dev/null | od -An -tx1 | tr -d ' \n')" + case "$magic" in + feedfacf|cffaedfe|feedface|cefaedfe|cafebabe|bebafeca) return 0 ;; + *) return 1 ;; + esac +} + +# Percorso scrivibile? Prova davvero a scrivere: -w mente su volumi di rete e su ACL. +dir_is_writable() { + local probe="$1/.pi-imgen-write-test.$$" + if ( umask 077; : > "$probe" ) 2>/dev/null; then rm -f "$probe"; return 0; fi + return 1 +} + +# --------------------------------------------------------------------------- +# 0. Configurazione esistente (letta prima di tutto: guida i valori predefiniti) +# --------------------------------------------------------------------------- + +EXIST_CFG_VALID=0 +EXIST_MODELS_PATH="" +EXIST_OUT_DIR="" +EXIST_SERVER_BINARY="" + +read_existing_config() { + [ -f "$CFG_PATH" ] || return 0 + have python3 || return 0 + local out="" + out="$(CFG_PATH="$CFG_PATH" python3 <<'PY' 2>/dev/null || true +import json, os, shlex + +def emit(name, value): + print("%s=%s" % (name, shlex.quote(value if isinstance(value, str) else ""))) + +try: + with open(os.environ["CFG_PATH"], encoding="utf-8") as fh: + cfg = json.load(fh) + if not isinstance(cfg, dict): + raise ValueError("root is not an object") +except Exception: + raise SystemExit(0) + +print("EXIST_CFG_VALID=1") +emit("EXIST_MODELS_PATH", cfg.get("modelsPath", "")) +emit("EXIST_OUT_DIR", cfg.get("outputDir", "")) +server = cfg.get("server") if isinstance(cfg.get("server"), dict) else {} +emit("EXIST_SERVER_BINARY", server.get("binary", "")) +PY +)" + # `out` contiene solo assegnazioni con valori già quotati da shlex.quote(). + [ -n "$out" ] && eval "$out" + return 0 +} + +# --------------------------------------------------------------------------- +# 1. Sistema +# --------------------------------------------------------------------------- + +check_system() { + step "Controllo del sistema" + + local os arch + os="$(uname -s)" + arch="$(uname -m)" + + if [ "$os" != "Darwin" ]; then + die "pi-imgen funziona solo su macOS: qui il sistema è «$os». + Il motore di generazione (Draw Things) esiste soltanto per macOS su Apple Silicon." + fi + + # Sotto Rosetta `uname -m` risponde x86_64 anche su un Mac Apple Silicon: distinguere + # i due casi evita di dire a un utente M4 che il suo Mac non è compatibile. + local translated=0 + translated="$(sysctl -n sysctl.proc_translated 2>/dev/null || printf '0')" + if [ "$translated" = "1" ]; then + die "Questo terminale sta girando sotto Rosetta (emulazione Intel). + Draw Things richiede un processo nativo arm64. + Apri un terminale nativo, oppure rilancia così: + arch -arm64 /bin/bash \"$REPO_DIR/install.sh\"" + fi + + if [ "$arch" != "arm64" ]; then + die "pi-imgen richiede un Mac Apple Silicon (M1 o successivi): qui l'architettura è «$arch». + Sui Mac Intel il motore di generazione non è disponibile e non esiste un ripiego." + fi + + local osver; osver="$(sw_vers -productVersion 2>/dev/null || printf 'sconosciuta')" + ok "macOS $osver su Apple Silicon ($arch)." + + # draw-things-cli richiede macOS 13+. + case "$osver" in + 1[0-2].*|[0-9].*) warn "draw-things-cli richiede macOS 13 o successivo: questa è la $osver." ;; + esac + + local membytes memgb + membytes="$(sysctl -n hw.memsize 2>/dev/null || printf '0')" + case "$membytes" in ''|*[!0-9]*) membytes=0 ;; esac + memgb="$(( membytes / 1073741824 ))" + if [ "$memgb" -gt 0 ]; then + info "Memoria unificata: ${memgb} GB." + if [ "$memgb" -le 16 ]; then + info "Con 16 GB il decoding a riquadri (tiled) resta attivo: dimezza la memoria di picco." + fi + fi + + have python3 || die "Serve python3 (incluso in macOS) per aggiornare la configurazione senza perdere i tuoi preset." + have curl || die "Serve curl (incluso in macOS) per scaricare il server." + record ok "Sistema" "macOS $osver, Apple Silicon" +} + +# --------------------------------------------------------------------------- +# 2. Homebrew e pacchetti +# --------------------------------------------------------------------------- + +BREW="" + +find_brew() { + if have brew; then BREW="$(command -v brew)"; return 0; fi + if [ -x /opt/homebrew/bin/brew ]; then BREW=/opt/homebrew/bin/brew; return 0; fi + if [ -x /usr/local/bin/brew ]; then BREW=/usr/local/bin/brew; return 0; fi + return 1 +} + +ensure_homebrew() { + step "Homebrew" + + if find_brew; then + ok "Homebrew trovato: $BREW" + else + warn "Homebrew non è installato: serve per draw-things-cli e typst." + hint "L'installazione ufficiale chiederà LA TUA PASSWORD (usa sudo per conto suo)." + if ! confirm "Installo Homebrew adesso?" "s"; then + record fail "Homebrew" "assente — installalo da https://brew.sh e rilancia" + err "Salto Homebrew. Senza, draw-things-cli e typst non si possono installare." + return 0 + fi + /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" \ + || die "Installazione di Homebrew fallita. Installalo a mano da https://brew.sh e rilancia questo script." + find_brew || die "Homebrew risulta installato ma non lo trovo nel PATH. Apri un nuovo terminale e rilancia." + ok "Homebrew installato: $BREW" + fi + + # Su Apple Silicon il prefisso nativo è /opt/homebrew. Un brew in /usr/local è la + # versione Intel: funziona sotto Rosetta ma non fornirà bottiglie arm64. + case "$BREW" in + /usr/local/*) warn "Questo Homebrew sta in /usr/local: è l'installazione Intel. Per pacchetti arm64 serve quella in /opt/homebrew." ;; + esac + + eval "$("$BREW" shellenv)" 2>/dev/null || true + + local f + for f in $BREW_REQUIRED; do + if "$BREW" list --formula --versions "$f" >/dev/null 2>&1; then + ok "$f già installato ($("$BREW" list --formula --versions "$f" | head -1))." + record ok "$f" "già presente" + else + info "Installo $f…" + # NB: formula di Homebrew CORE. Niente tap personalizzati, niente --HEAD. + if "$BREW" install "$f"; then + ok "$f installato." + record ok "$f" "installato ora" + else + err "Installazione di $f fallita." + record fail "$f" "installazione fallita — riprova con: brew install $f" + fi + fi + done + + # Facoltativi: servono solo al controllo pre-stampa del PDF. + local missing_opt="" + for f in $BREW_OPTIONAL; do + "$BREW" list --formula --versions "$f" >/dev/null 2>&1 || missing_opt="$missing_opt $f" + done + missing_opt="${missing_opt# }" + + if [ -n "$missing_opt" ]; then + info "Facoltativi per il controllo pre-stampa (verifica di TrimBox, abbondanza e caratteri incorporati): $missing_opt" + hint "Senza, la locandina si genera lo stesso: salta solo la verifica del PDF." + if confirm "Li installo?" "n"; then + # shellcheck disable=SC2086 + if "$BREW" install $missing_opt; then + ok "Strumenti di pre-stampa installati." + record ok "Pre-stampa" "$missing_opt installati" + else + warn "Installazione dei facoltativi fallita: si prosegue senza." + record warn "Pre-stampa" "installazione fallita (facoltativo)" + fi + else + info "Salto i facoltativi. Più avanti: brew install $missing_opt" + record warn "Pre-stampa" "assenti (facoltativo): $missing_opt" + fi + else + ok "Strumenti di pre-stampa già presenti ($BREW_OPTIONAL)." + record ok "Pre-stampa" "completi" + fi +} + +# --------------------------------------------------------------------------- +# 3. gRPCServerCLI (asset di release GitHub, NON in Homebrew) +# --------------------------------------------------------------------------- + +SERVER_TARGET="" + +ensure_grpc_server() { + step "Server residente ($GRPC_SERVER_BINARY)" + + # Se la configurazione indica già un percorso per il binario, rispettiamolo: è quello + # che il codice userà (config.server.binary vince su serverPaths()). + if [ -n "$EXIST_SERVER_BINARY" ]; then + SERVER_TARGET="$(expand_tilde "$EXIST_SERVER_BINARY")" + info "Percorso preso dalla configurazione esistente: $SERVER_TARGET" + else + SERVER_TARGET="$STATE_DIR/$GRPC_SERVER_BINARY" + fi + + mkdir -p "$(dirname -- "$SERVER_TARGET")" + local stamp="$SERVER_TARGET.version" + + info "Non è distribuito da Homebrew: brew install draw-things-cli dà solo il client." + + if [ -f "$SERVER_TARGET" ]; then + if [ ! -x "$SERVER_TARGET" ]; then + chmod +x "$SERVER_TARGET" 2>/dev/null || true + info "Permesso di esecuzione ripristinato." + fi + if [ -x "$SERVER_TARGET" ] && is_macho "$SERVER_TARGET"; then + local installed="" + [ -f "$stamp" ] && installed="$(cat "$stamp" 2>/dev/null || printf '')" + + if [ "$installed" = "$GRPC_SERVER_VERSION" ]; then + ok "Già presente e aggiornato ($GRPC_SERVER_VERSION): $SERVER_TARGET" + record ok "Server gRPC" "$GRPC_SERVER_VERSION" + return 0 + fi + + if [ -z "$installed" ]; then + # Installato a mano: è valido, quindi lo teniamo. Riscaricarlo a ogni + # esecuzione sarebbe uno spreco, e questo script deve essere ri-eseguibile. + ok "Già presente (installato a mano): $SERVER_TARGET" + info "Versione non determinabile; quella attesa è $GRPC_SERVER_VERSION." + hint "Per allinearlo: rm \"$SERVER_TARGET\" e rilancia ./install.sh" + record warn "Server gRPC" "presente, versione ignota (attesa $GRPC_SERVER_VERSION)" + return 0 + fi + + warn "Presente una versione diversa (installata: $installed, attesa: $GRPC_SERVER_VERSION)." + if ! confirm "Lo aggiorno?" "s"; then + record warn "Server gRPC" "versione $installed (attesa $GRPC_SERVER_VERSION)" + return 0 + fi + else + warn "Il file esistente non è un eseguibile macOS valido: lo riscarico." + fi + fi + + if ! confirm "Scarico $GRPC_SERVER_BINARY $GRPC_SERVER_VERSION (~decine di MB) da GitHub?" "s"; then + record fail "Server gRPC" "assente — scaricalo da $GRPC_SERVER_URL in $SERVER_TARGET" + warn "Salto lo scaricamento. Senza il server ogni immagine ricarica il modello da zero." + return 0 + fi + + local tmp="$SERVER_TARGET.part.$$" + info "Scarico da $GRPC_SERVER_URL" + if ! curl -fL --progress-bar -o "$tmp" "$GRPC_SERVER_URL"; then + rm -f "$tmp" + err "Scaricamento fallito (rete o URL non raggiungibile)." + hint "A mano: curl -fL -o \"$SERVER_TARGET\" \"$GRPC_SERVER_URL\" && chmod +x \"$SERVER_TARGET\"" + record fail "Server gRPC" "scaricamento fallito" + return 0 + fi + + # Un 200 OK non garantisce un binario: verifichiamo dimensione e magic number. + local size; size="$(wc -c < "$tmp" | tr -d ' ')" + if [ "$size" -lt 1048576 ] || ! is_macho "$tmp"; then + rm -f "$tmp" + err "Il file scaricato non è un eseguibile macOS valido ($size byte): probabilmente una pagina di errore." + record fail "Server gRPC" "file scaricato non valido" + return 0 + fi + + chmod 755 "$tmp" + # Gatekeeper mette in quarantena i file scaricati: senza questo il primo avvio viene bloccato. + /usr/bin/xattr -d com.apple.quarantine "$tmp" 2>/dev/null || true + mv -f "$tmp" "$SERVER_TARGET" + printf '%s\n' "$GRPC_SERVER_VERSION" > "$stamp" + + ok "Installato in $SERVER_TARGET ($GRPC_SERVER_VERSION)." + record ok "Server gRPC" "$GRPC_SERVER_VERSION" +} + +# --------------------------------------------------------------------------- +# 4. Percorsi: modelli (disco secondario) e destinazione +# --------------------------------------------------------------------------- + +MODELS_PATH="" +OUT_DIR="" + +suggest_models_dir() { + # Candidati: volumi montati sotto /Volumes che NON sono il disco di avvio. + local root_dev v dev gb best_gb=0 best="" + root_dev="$(df -P / 2>/dev/null | awk 'NR==2 { print $1 }')" + + for v in /Volumes/*; do + [ -d "$v" ] || continue + case "$v" in + *com.apple.TimeMachine*|*.timemachine*) continue ;; + esac + dev="$(df -P "$v" 2>/dev/null | awk 'NR==2 { print $1 }')" + if [ -z "$dev" ] || [ "$dev" = "$root_dev" ]; then continue; fi + dir_is_writable "$v" || continue + gb="$(free_gb "$v")" + [ -n "$gb" ] || gb=0 + if [ "$gb" -gt "$best_gb" ]; then best_gb="$gb"; best="$v"; fi + done + + if [ -n "$best" ]; then printf '%s/pi-imgen/models' "$best"; return 0; fi + # Nessun disco secondario montato: proponiamo comunque un percorso fuori dalla home + # se esiste, altrimenti la home (chiedendo conferma esplicita più avanti). + printf '%s' "$HOME/Documents/Models" +} + +prompt_paths() { + step "Dove tenere i modelli e i file generati" + + # -- modelli --------------------------------------------------------------- + local default_models="" + if [ -n "$ARG_MODELS_DIR" ]; then + default_models="$ARG_MODELS_DIR" + elif [ -n "$EXIST_MODELS_PATH" ]; then + default_models="$EXIST_MODELS_PATH" + info "Configurazione attuale: $EXIST_MODELS_PATH" + else + default_models="$(suggest_models_dir)" + fi + + printf ' %sI modelli occupano circa 10 GB. Tienili su un disco secondario o esterno,%s\n' "$C_DIM" "$C_RESET" + printf ' %snon nella cartella utente: così i backup di casa restano leggeri.%s\n' "$C_DIM" "$C_RESET" + + local candidates="" v + for v in /Volumes/*; do + [ -d "$v" ] || continue + case "$v" in *com.apple.TimeMachine*) continue ;; esac + candidates="$candidates $v ($(free_gb "$v") GB liberi)" + done + [ -n "$candidates" ] && info "Volumi montati:$candidates" + + while :; do + local answer + answer="$(ask "Cartella dei modelli" "$default_models")" + answer="$(expand_tilde "$answer")" + + case "$answer" in + /*) ;; + *) err "Il percorso deve essere assoluto (deve iniziare con «/»). Ricevuto: «$answer»" + if [ "$INTERACTIVE" = "0" ]; then die "Percorso dei modelli non assoluto: $answer"; fi + continue ;; + esac + + case "$answer" in + "$HOME"|"$HOME"/*) + warn "«$answer» sta nella tua cartella utente: finirà nei backup e in iCloud Drive." + if [ "$INTERACTIVE" = "1" ] && ! confirm "Va bene lo stesso?" "n"; then + default_models="$(suggest_models_dir)" + continue + fi + ;; + esac + + if ! mkdir -p "$answer" 2>/dev/null; then + err "Non riesco a creare «$answer» (permessi, o disco non montato)." + if [ "$INTERACTIVE" = "0" ]; then die "Cartella dei modelli non creabile: $answer"; fi + continue + fi + if ! dir_is_writable "$answer"; then + err "«$answer» non è scrivibile (volume in sola lettura?)." + if [ "$INTERACTIVE" = "0" ]; then die "Cartella dei modelli non scrivibile: $answer"; fi + continue + fi + + local gb; gb="$(free_gb "$answer")"; [ -n "$gb" ] || gb=0 + if [ "$gb" -lt "$MIN_MODELS_GB" ]; then + warn "Spazio libero: ${gb} GB. Ne servono almeno ${MIN_MODELS_GB} per i modelli." + if [ "$INTERACTIVE" = "1" ] && ! confirm "Proseguo comunque?" "n"; then continue; fi + else + info "Spazio libero: ${gb} GB." + fi + + MODELS_PATH="$answer" + ok "Modelli: $MODELS_PATH" + break + done + + # -- destinazione ---------------------------------------------------------- + local default_out="" + if [ -n "$ARG_OUT_DIR" ]; then default_out="$ARG_OUT_DIR" + elif [ -n "$EXIST_OUT_DIR" ]; then default_out="$EXIST_OUT_DIR" + else default_out="$DEFAULT_OUT_DIR"; fi + + while :; do + local answer + answer="$(ask "Cartella dei file generati" "$default_out")" + answer="$(expand_tilde "$answer")" + + case "$answer" in + /*) ;; + *) err "Il percorso deve essere assoluto. Ricevuto: «$answer»" + if [ "$INTERACTIVE" = "0" ]; then die "Cartella di destinazione non assoluta: $answer"; fi + continue ;; + esac + + if ! mkdir -p "$answer" 2>/dev/null || ! dir_is_writable "$answer"; then + err "«$answer» non è utilizzabile: non riesco a scriverci." + if [ "$INTERACTIVE" = "0" ]; then die "Cartella di destinazione non scrivibile: $answer"; fi + continue + fi + + OUT_DIR="$answer" + ok "Destinazione: $OUT_DIR" + break + done + + record ok "Cartelle" "modelli in $MODELS_PATH, uscite in $OUT_DIR" +} + +# --------------------------------------------------------------------------- +# 5. Caratteri +# --------------------------------------------------------------------------- + +FONT_SCRIPT="$REPO_DIR/scripts/fetch-fonts.sh" + +fetch_fonts() { + step "Caratteri tipografici (22 famiglie OFL/Apache)" + + if [ ! -f "$FONT_SCRIPT" ]; then + warn "Script assente: $FONT_SCRIPT" + hint "Senza i caratteri, l'impaginazione con Typst non parte." + record fail "Caratteri" "scripts/fetch-fonts.sh non trovato" + return 0 + fi + + [ -x "$FONT_SCRIPT" ] || chmod +x "$FONT_SCRIPT" 2>/dev/null || true + + info "Eseguo scripts/fetch-fonts.sh…" + # Fallire qui non deve interrompere l'installazione: i caratteri si riscaricano dopo. + if ( cd "$REPO_DIR" && /usr/bin/env bash "$FONT_SCRIPT" ); then + local n; n="$(find "$REPO_DIR/vendor/fonts" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l | tr -d ' ')" + ok "Caratteri pronti ($n famiglie in vendor/fonts)." + record ok "Caratteri" "$n famiglie" + else + err "Scaricamento dei caratteri fallito." + hint "Riprova più tardi con: \"$FONT_SCRIPT\"" + record fail "Caratteri" "scaricamento fallito" + fi +} + +# --------------------------------------------------------------------------- +# 6. Modelli +# --------------------------------------------------------------------------- + +# Nomi dei file modello attesi: prima dalla configurazione scritta, poi — come ripiego — +# dalle costanti in config.ts. Così non duplichiamo l'elenco dentro l'installer. +model_files() { + local from_config="" + if [ -f "$CFG_PATH" ] && have python3; then + from_config="$(CFG_PATH="$CFG_PATH" python3 <<'PY' 2>/dev/null || true +import json, os +try: + with open(os.environ["CFG_PATH"], encoding="utf-8") as fh: + models = json.load(fh).get("models", {}) + if isinstance(models, dict): + for name in dict.fromkeys(v for v in models.values() if isinstance(v, str) and v): + print(name) +except Exception: + pass +PY +)" + fi + + if [ -n "$from_config" ]; then + printf '%s\n' "$from_config" + return 0 + fi + + # Ripiego: i nomi in DEFAULTS.models dentro config.ts, per non duplicare qui l'elenco. + grep -oE '"[A-Za-z0-9_.-]+\.ckpt"' "$REPO_DIR/extensions/imgen/config.ts" 2>/dev/null \ + | tr -d '"' | sort -u +} + +prefetch_models() { + step "Modelli di generazione" + + if [ "$SKIP_MODELS" = "1" ]; then + info "Salto lo scaricamento dei modelli (--skip-models)." + record warn "Modelli" "scaricamento saltato" + return 0 + fi + + local wanted present=0 + wanted="$(model_files | sort -u)" + if [ -z "$wanted" ]; then + warn "Non riesco a determinare quali modelli servono." + record warn "Modelli" "elenco non determinato" + return 0 + fi + + local m missing="" + for m in $wanted; do + if [ -f "$MODELS_PATH/$m" ]; then + ok "Già presente: $m" + present=$((present + 1)) + else + missing="$missing $m" + fi + done + missing="${missing# }" + + if [ -z "$missing" ]; then + ok "Tutti i modelli sono già in $MODELS_PATH." + record ok "Modelli" "$present presenti" + return 0 + fi + + info "Mancano: $missing" + + if ! have draw-things-cli; then + warn "draw-things-cli non è installato: non posso prescaricare nulla." + document_model_download + record fail "Modelli" "mancanti: $missing" + return 0 + fi + + # NON inventiamo flag. Interroghiamo l'aiuto del CLI installato e usiamo il + # sottocomando SOLO se l'aiuto stesso conferma sia «ensure» sia «--models-dir». + local root_help models_help="" + root_help="$(draw-things-cli --help 2>&1 || true)" + if printf '%s' "$root_help" | grep -qE '(^|[[:space:]])models([[:space:]]|$)'; then + models_help="$(draw-things-cli models --help 2>&1 || true)" + fi + + if [ -n "$models_help" ] \ + && printf '%s' "$models_help" | grep -qE '(^|[[:space:]])ensure([[:space:]]|$)' \ + && printf '%s' "$models_help" | grep -q -- '--models-dir'; then + + info "Il CLI dichiara «models ensure»: uso la forma verificata dal suo --help." + local still="" + for m in $missing; do + info "Scarico $m…" + # Un'uscita a 0 non basta: verifichiamo che il file esista davvero, come + # facciamo con --output in fase di generazione. + if draw-things-cli models ensure --models-dir "$MODELS_PATH" "$m" && [ -s "$MODELS_PATH/$m" ]; then + ok "$m pronto." + elif [ -s "$MODELS_PATH/$m" ]; then + ok "$m pronto." + else + warn "$m non è arrivato in $MODELS_PATH." + still="$still $m" + fi + done + still="${still# }" + if [ -z "$still" ]; then + record ok "Modelli" "tutti presenti" + else + document_model_download + record fail "Modelli" "mancanti: $still" + fi + return 0 + fi + + # Il sottocomando non esiste (o ha un'altra forma) in questa versione del CLI. + warn "Questa versione di draw-things-cli non espone «models ensure» nel suo aiuto." + info "Non tiro a indovinare i parametri: qui sotto trovi le vie sicure." + document_model_download + record fail "Modelli" "mancanti: $missing" +} + +document_model_download() { + cat </dev/null || printf '0')" + if [ "$kept" != "0" ]; then ok "Preset conservati: $kept."; else info "Nessun preset salvato (li creerai con /presets)."; fi + record ok "Configurazione" "$CFG_PATH" + else + err "Scrittura della configurazione fallita." + record fail "Configurazione" "scrittura fallita in $CFG_PATH" + fi +} + +# --------------------------------------------------------------------------- +# 8. Controllo finale +# --------------------------------------------------------------------------- + +doctor() { + step "Controllo finale" + + local v + + # -- strumenti ------------------------------------------------------------- + if have draw-things-cli; then + v="$(draw-things-cli --version 2>&1 | head -1 || true)" + [ -n "$v" ] || v="presente" + ok "draw-things-cli: $v" + else + err "draw-things-cli non trovato nel PATH." + hint "brew install draw-things-cli" + fi + + if have typst; then + ok "typst: $(typst --version 2>&1 | head -1)" + else + err "typst non trovato nel PATH." + hint "brew install typst" + fi + + if have node; then + local nodemajor; nodemajor="$(node -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)" + if [ "$nodemajor" -ge 20 ] 2>/dev/null; then + ok "Node $(node -v)." + else + warn "Node $(node -v): pi-imgen richiede Node 20 o successivo." + fi + else + warn "Node non trovato: lo fornisce pi, di solito non è un problema." + fi + + # -- server ---------------------------------------------------------------- + if [ -n "$SERVER_TARGET" ] && [ -x "$SERVER_TARGET" ] && is_macho "$SERVER_TARGET"; then + ok "Server residente pronto: $SERVER_TARGET" + else + err "Server residente assente o non eseguibile." + hint "curl -fL -o \"${SERVER_TARGET:-$STATE_DIR/$GRPC_SERVER_BINARY}\" \"$GRPC_SERVER_URL\" && chmod +x \"${SERVER_TARGET:-$STATE_DIR/$GRPC_SERVER_BINARY}\"" + fi + + # Porta 7859: già occupata significa quasi sempre un server già avviato — bene così, + # pi-imgen adotta quello esistente invece di avviarne un secondo (due processi di + # diffusione saturano 16 GB prima di finire una decodifica). + local port=7859 + if have python3; then + port="$(CFG_PATH="$CFG_PATH" python3 -c 'import json,os;print((json.load(open(os.environ["CFG_PATH"])).get("server") or {}).get("port",7859))' 2>/dev/null || printf '7859')" + fi + if have nc && nc -z 127.0.0.1 "$port" >/dev/null 2>&1; then + info "Porta $port: qualcosa è già in ascolto (server già avviato: verrà adottato)." + else + info "Porta $port libera: il server partirà all'avvio della sessione pi." + fi + + # -- cartelle e modelli ---------------------------------------------------- + if [ -d "$MODELS_PATH" ] && dir_is_writable "$MODELS_PATH"; then + ok "Cartella modelli: $MODELS_PATH ($(free_gb "$MODELS_PATH") GB liberi)." + else + err "Cartella modelli non utilizzabile: $MODELS_PATH" + fi + + if [ -d "$OUT_DIR" ] && dir_is_writable "$OUT_DIR"; then + ok "Cartella di destinazione: $OUT_DIR" + else + err "Cartella di destinazione non utilizzabile: $OUT_DIR" + fi + + local m found=0 lost=0 + for m in $(model_files | sort -u); do + if [ -f "$MODELS_PATH/$m" ]; then found=$((found + 1)); else lost=$((lost + 1)); fi + done + if [ "$lost" = "0" ] && [ "$found" -gt 0 ]; then + ok "Modelli: $found presenti." + else + warn "Modelli: $found presenti, $lost mancanti." + fi + + # -- caratteri ------------------------------------------------------------- + local nf; nf="$(find "$REPO_DIR/vendor/fonts" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l | tr -d ' ')" + if [ "$nf" -ge 22 ]; then ok "Caratteri: $nf famiglie." + elif [ "$nf" -gt 0 ]; then warn "Caratteri: solo $nf famiglie su 22." + else err "Caratteri assenti: esegui \"$FONT_SCRIPT\"." + fi + + # -- configurazione -------------------------------------------------------- + if [ -f "$CFG_PATH" ] && C="$CFG_PATH" python3 -c 'import json,os;json.load(open(os.environ["C"]))' >/dev/null 2>&1; then + ok "Configurazione leggibile: $CFG_PATH" + else + err "Configurazione assente o illeggibile: $CFG_PATH" + fi +} + +print_summary() { + printf '\n%s────────────────────────────────────────────────────────────%s\n' "$C_DIM" "$C_RESET" + printf '%sRiepilogo%s\n\n' "$C_BOLD" "$C_RESET" + + local line status label detail mark color + if [ "${#SUMMARY_LINES[@]}" -gt 0 ]; then + for line in "${SUMMARY_LINES[@]}"; do + status="${line%%$'\t'*}" + label="$(printf '%s' "$line" | cut -f2)" + detail="$(printf '%s' "$line" | cut -f3)" + case "$status" in + ok) mark="pronto" ; color="$C_GREEN" ;; + warn) mark="parziale" ; color="$C_YELLOW" ;; + *) mark="da sistemare" ; color="$C_RED" ;; + esac + printf ' %s%-12s%s %-16s %s\n' "$color" "$mark" "$C_RESET" "$label" "$detail" + done + fi + + printf '\n' + if [ "$ESSENTIAL_MISSING" = "0" ]; then + printf '%s✓ Tutto pronto.%s\n\n' "$C_GREEN$C_BOLD" "$C_RESET" + printf ' Apri pi e prova:\n' + printf ' %s/poster%s una locandina completa, dal titolo al PDF per la stampa\n' "$C_BOLD" "$C_RESET" + printf ' %s/presets%s salva i tuoi colori, caratteri e logo\n\n' "$C_BOLD" "$C_RESET" + else + if [ "$ESSENTIAL_MISSING" = "1" ]; then + printf '%s! Manca ancora una cosa.%s\n\n' "$C_YELLOW$C_BOLD" "$C_RESET" + else + printf '%s! Mancano ancora %d cose.%s\n\n' "$C_YELLOW$C_BOLD" "$ESSENTIAL_MISSING" "$C_RESET" + fi + printf ' Sistema le voci «da sistemare» qui sopra e rilancia:\n' + printf ' %s./install.sh%s (ripara ciò che manca, non tocca i tuoi preset)\n\n' "$C_BOLD" "$C_RESET" + fi + + printf ' %sConfigurazione:%s %s\n' "$C_DIM" "$C_RESET" "$CFG_PATH" + printf ' %sModelli:%s %s\n' "$C_DIM" "$C_RESET" "${MODELS_PATH:-non impostata}" + printf ' %sDestinazione:%s %s\n\n' "$C_DIM" "$C_RESET" "${OUT_DIR:-non impostata}" +} + +# --------------------------------------------------------------------------- +# main +# --------------------------------------------------------------------------- + +main() { + printf '\n%spi-imgen — installazione%s\n' "$C_BOLD" "$C_RESET" + printf '%sLocandine, loghi e immagini per eventi. Tutto in locale.%s\n' "$C_DIM" "$C_RESET" + + read_existing_config + + if [ "$DOCTOR_ONLY" = "1" ]; then + MODELS_PATH="$(expand_tilde "${EXIST_MODELS_PATH:-$HOME/Documents/Models}")" + OUT_DIR="$(expand_tilde "${EXIST_OUT_DIR:-$DEFAULT_OUT_DIR}")" + SERVER_TARGET="${EXIST_SERVER_BINARY:-$STATE_DIR/$GRPC_SERVER_BINARY}" + SERVER_TARGET="$(expand_tilde "$SERVER_TARGET")" + STEP_NO=7 + doctor + printf '\n' + exit 0 + fi + + check_system + ensure_homebrew + ensure_grpc_server + prompt_paths + fetch_fonts + prefetch_models + write_config + doctor + print_summary + + [ "$ESSENTIAL_MISSING" = "0" ] || exit 1 +} + +main "$@" diff --git a/scripts/fetch-fonts.sh b/scripts/fetch-fonts.sh new file mode 100755 index 0000000..a0296a9 --- /dev/null +++ b/scripts/fetch-fonts.sh @@ -0,0 +1,634 @@ +#!/usr/bin/env bash +# +# fetch-fonts.sh — scarica le 22 famiglie di caratteri incluse in pi-imgen. +# +# --------------------------------------------------------------------------- +# Target shell: bash 3.2 (the one macOS actually ships). No associative arrays, +# no `mapfile`, no `${var,,}`. BSD sed/grep/awk only: no `grep -P`, no GNU-only +# flags. Tools required: curl, unzip, awk, sed, od. +# +# WHERE THE FILES COME FROM, AND WHY (measured 2026-08-27, not assumed): +# +# 1. PRIMARY for the binaries — raw.githubusercontent.com/google/fonts. +# This is the ONLY source that yields VARIABLE TTFs, which is what we want +# because Typst 0.15 sets axes (wght/wdth/opsz/SOFT/WONK) at render time. +# Exact file names are read from METADATA.pb, never guessed: the bracketed +# variable name is spelled differently per family (Archivo[wdth,wght].ttf +# but Fraunces[SOFT,WONK,opsz,wght].ttf) and the axis order is not +# alphabetical by luck. Brackets are percent-encoded for the URL. +# +# NOTE — this inverts the source order given in the brief, deliberately. +# The brief assumed the Fontsource zip ships plain TTFs. It does not: +# `api.fontsource.org/v1/download/archivo` returns 54 TTFs that are all +# SUBSET BY UNICODE-RANGE (…-latin-400-normal.ttf, …-latin-ext-…, +# …-vietnamese-…) plus a `variable/` folder that is WOFF2 ONLY. Bundling +# those would give Typst a dozen faces sharing one family name and no +# variable axes at all. So Fontsource stays in the chain as the keyless +# safety net (tier 2), not as the primary. Set FONT_SOURCE=fontsource (or +# --source=fontsource) to force the brief's original order. +# +# 2. FALLBACK — the Fontsource zip. Keyless, ships a LICENSE, always up. +# We take its `static/*-latin-*.ttf` slice only: the Google "latin" subset +# covers the whole audited Italian set (accented vowels, « », ° € – — ‘ ’ +# “ ”), and taking latin-ext as well would install duplicate faces under +# the same family name. Degraded mode — recorded as such in the manifest +# and in THIRD-PARTY-FONTS.md. +# +# 3. LAST RESORT — the Google css2 endpoint with a NON-BROWSER User-Agent, +# which hands back full unsubsetted static TTFs keyless. css2 ALWAYS +# instantiates: it can never return a variable font, so a family that +# lands here loses its axes. +# +# THE THREE RULES THAT PREVENT REAL MISTAKES (each implemented below): +# +# RULE 1 — the licence is read from the DIRECTORY, never from the binary. +# Roboto Condensed's shipped binary still declares Apache-2.0 in name ID 13 +# while the actual grant is OFL. The bucket a family lives in under +# google/fonts is the grant; see license_id_for_bucket(). +# +# RULE 2 — the bucket is NEVER hardcoded. Roboto and Open Sans have already +# migrated out of apache/. resolve_bucket() probes ofl/ apache/ ufl/ +# cc-by-sa/ and uses whichever answers. +# +# RULE 3 — a 200 proves a path exists, NOT that it is the family you asked +# for. There are five distinct "Big Shoulders" families. resolve_bucket() +# accepts a bucket only when METADATA.pb's `name:` matches the family in +# fonts.ts EXACTLY. Downloaded bytes are checked for a real sfnt signature +# too, so an HTML error page can never be saved as a .ttf. +# +# The family list is PARSED from extensions/imgen/design/fonts.ts. It is never +# duplicated here — adding a family there is the only edit needed. +# +# Idempotent: a family whose manifest is complete is skipped unless --force. +# +# Uso: +# scripts/fetch-fonts.sh [--force] [--only=] +# [--source=gf|fontsource] [--check] [--help] +# --------------------------------------------------------------------------- + +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +ROOT=$(cd "$SCRIPT_DIR/.." && pwd) +FONTS_TS="$ROOT/extensions/imgen/design/fonts.ts" +DEST="$ROOT/vendor/fonts" +REPORT="$ROOT/THIRD-PARTY-FONTS.md" + +RAW="https://raw.githubusercontent.com/google/fonts/main" +TREE="https://github.com/google/fonts/tree/main" +FONTSOURCE="https://api.fontsource.org/v1/download" +CSS2="https://fonts.googleapis.com/css2" + +# Non-browser UA: this is what makes css2 return static TTFs instead of woff2. +UA="pi-imgen/1.0" + +# RULE 2: the bucket is probed, in this order, never assumed. +BUCKETS="ofl apache ufl cc-by-sa" + +FORCE=0 +ONLY="" +CHECK_ONLY=0 +SOURCE_PREF="${FONT_SOURCE:-gf}" + +TMP=$(mktemp -d "${TMPDIR:-/tmp}/pi-imgen-fonts.XXXXXX") +trap 'rm -rf "$TMP"' EXIT INT TERM + +# --- output ----------------------------------------------------------------- +if [ -t 1 ]; then C_OK=$'\033[32m'; C_WARN=$'\033[33m'; C_ERR=$'\033[31m'; C_DIM=$'\033[2m'; C_OFF=$'\033[0m' +else C_OK=""; C_WARN=""; C_ERR=""; C_DIM=""; C_OFF=""; fi + +say() { printf '%s\n' "$*"; } +ok() { printf '%s✓%s %s\n' "$C_OK" "$C_OFF" "$*"; } +info() { printf '%s %s%s\n' "$C_DIM" "$*" "$C_OFF"; } +warn() { printf '%s!%s %s\n' "$C_WARN" "$C_OFF" "$*" >&2; } +fail() { printf '%s✗%s %s\n' "$C_ERR" "$C_OFF" "$*" >&2; } +die() { fail "$*"; exit 1; } + +usage() { + cat <<'USAGE' +Scarica le famiglie di caratteri incluse in pi-imgen dentro vendor/fonts/. + + --force riscarica anche le famiglie già complete + --only= lavora su una sola famiglia (es. --only=anton) + --source=gf|fontsource + sorgente preferita per i binari (predefinita: gf, + cioè i TTF variabili di google/fonts) + --check nessuna rete: verifica quanto è già su disco e + rigenera THIRD-PARTY-FONTS.md + --help questo messaggio + +Variabili d'ambiente: FONT_SOURCE=gf|fontsource +USAGE +} + +for arg in "$@"; do + case "$arg" in + --force) FORCE=1 ;; + --check) CHECK_ONLY=1 ;; + --only=*) ONLY=$(printf '%s' "${arg#--only=}" | tr '[:upper:]' '[:lower:]') ;; + --source=*) SOURCE_PREF="${arg#--source=}" ;; + --help|-h) usage; exit 0 ;; + *) die "Opzione sconosciuta: $arg (usa --help)" ;; + esac +done + +case "$SOURCE_PREF" in + gf|fontsource) ;; + *) die "--source deve essere 'gf' oppure 'fontsource', ricevuto '$SOURCE_PREF'." ;; +esac + +for bin in curl unzip awk sed od; do + command -v "$bin" >/dev/null 2>&1 || die "Manca il comando richiesto: $bin" +done +[ -f "$FONTS_TS" ] || die "Non trovo l'elenco delle famiglie: $FONTS_TS" + +# --- helpers ---------------------------------------------------------------- + +# One place for every network read: retries, timeouts, the non-browser UA, and +# a hard requirement of HTTP 200 + non-empty body. curl leaves a truncated file +# behind on failure, so we remove it ourselves rather than trust a partial. +http_get() { # url dest + local url="$1" dest="$2" code + code=$(curl -sS -L -A "$UA" \ + --connect-timeout 15 --max-time 300 \ + --retry 3 --retry-delay 2 \ + -o "$dest" -w '%{http_code}' "$url" /dev/null) || { rm -f "$dest"; return 1; } + [ "$code" = "200" ] || { rm -f "$dest"; return 1; } + [ -s "$dest" ] || { rm -f "$dest"; return 1; } + return 0 +} + +# RULE 3, byte level: a 200 can still be an HTML error page. Require a real +# sfnt signature (0x00010000 | 'true' | 'OTTO' | 'ttcf'). +is_sfnt() { # path + local sig + sig=$(od -An -tx1 -N4 "$1" 2>/dev/null | tr -d ' \n') + case "$sig" in + 00010000|74727565|4f54544f|74746366) return 0 ;; + *) return 1 ;; + esac +} + +# Variable TTFs live at Family[axes].ttf; the brackets must be percent-encoded +# or raw.githubusercontent answers 404. Commas are legal in a path, leave them. +urlenc() { printf '%s' "$1" | sed -e 's/\[/%5B/g' -e 's/\]/%5D/g'; } + +lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; } + +# RULE 1: the grant comes from the directory the family lives in, never from +# name ID 13 inside the binary (Roboto Condensed still says Apache-2.0 there +# while the real grant is OFL). +license_id_for_bucket() { + case "$1" in + ofl) printf 'OFL-1.1' ;; + apache) printf 'Apache-2.0' ;; + ufl) printf 'UFL-1.0' ;; + cc-by-sa) printf 'CC-BY-SA-4.0' ;; + *) printf 'SCONOSCIUTA' ;; + esac +} + +# Candidate licence file names inside the bucket directory, most likely first. +license_files_for_bucket() { + case "$1" in + ofl) printf 'OFL.txt LICENSE.txt LICENSE' ;; + apache) printf 'LICENSE.txt LICENSE' ;; + ufl) printf 'UFL.txt LICENSE.txt LICENSE' ;; + cc-by-sa) printf 'LICENSE.txt LICENSE' ;; + *) printf 'LICENSE.txt OFL.txt LICENSE' ;; + esac +} + +manifest_get() { # dir key + [ -f "$1/.fetch-manifest" ] || return 1 + sed -n "s/^$2=//p" "$1/.fetch-manifest" | head -1 +} + +# --- the family list, parsed from fonts.ts --------------------------------- +# Emits TSV: family \t id \t slug \t declared-licence \t axes ("-" if static). +# Records are gathered by brace balancing, so reformatting fonts.ts does not +# break this. Never hand-duplicate the list. +parse_fonts_ts() { + awk ' + function fld(rec, key, re, s) { + re = "(^|[^A-Za-z_])" key ":[ \t]*\"[^\"]*\"" + if (!match(rec, re)) return "" + s = substr(rec, RSTART, RLENGTH) + sub(/^[^"]*"/, "", s) + sub(/"$/, "", s) + return s + } + function axesof(rec, s, out, t) { + if (!match(rec, /axes:[ \t]*\{[^}]*\}/)) return "-" + s = substr(rec, RSTART, RLENGTH) + sub(/^axes:[ \t]*\{/, "", s) + sub(/\}$/, "", s) + out = "" + while (match(s, /[A-Za-z]+:[ \t]*\[[ \t]*[0-9.]+[ \t]*,[ \t]*[0-9.]+[ \t]*\]/)) { + t = substr(s, RSTART, RLENGTH) + s = substr(s, RSTART + RLENGTH) + gsub(/[ \t\[\]]/, "", t) + sub(/,/, "-", t) + out = (out == "" ? t : out "," t) + } + return (out == "" ? "-" : out) + } + /export const FONTS/ { inarr = 1; next } + !inarr { next } + /^\];/ { inarr = 0; next } + { + line = $0 + if (rec == "" && line !~ /\{[ \t]*family:/) next + rec = rec " " line + depth += gsub(/\{/, "{", line) - gsub(/\}/, "}", line) + if (depth <= 0) { + printf "%s\t%s\t%s\t%s\t%s\n", fld(rec, "family"), fld(rec, "id"), \ + fld(rec, "slug"), fld(rec, "license"), axesof(rec) + rec = ""; depth = 0 + } + } + ' "$FONTS_TS" +} + +LIST="$TMP/families.tsv" +parse_fonts_ts > "$LIST" +TOTAL=$(wc -l < "$LIST" | tr -d ' ') +# Sanity check the parse against a dumb count of the entries in the array. +DECLARED=$(awk '/export const FONTS/{i=1;next} /^\];/{i=0} i && /\{[ \t]*family:/{n++} END{print n+0}' "$FONTS_TS") +[ "$TOTAL" -gt 0 ] || die "Nessuna famiglia estratta da $FONTS_TS — il parser va aggiornato." +[ "$TOTAL" = "$DECLARED" ] || die "Parser incoerente: $TOTAL famiglie lette, $DECLARED dichiarate in fonts.ts." +awk -F'\t' '$1=="" || $2=="" || $3=="" {exit 1}' "$LIST" || die "Una voce di fonts.ts non ha family/id/slug." + +say "pi-imgen — caratteri: $TOTAL famiglie da $FONTS_TS" +[ "$CHECK_ONLY" = 1 ] && say "Modalità --check: nessun download." +say "" + +mkdir -p "$DEST" + +# --- per-source fetchers ---------------------------------------------------- +# Each prints the names of the files it installed into $stage, one per line, on +# fd 3 (stdout is reserved for progress), and returns non-zero if it got nothing. + +# Tier 1 — google/fonts raw. Variable TTFs, full character set, exact names +# taken from METADATA.pb. +fetch_gf() { # stage meta bucket slug + local stage="$1" meta="$2" bucket="$3" slug="$4" + local got=0 f url + + # Names straight from METADATA.pb: never reconstructed from the family name, + # never guessed at the axis order. + for f in $(sed -n 's/^[ ]*filename:[ ]*"\(.*\)"/\1/p' "$meta" | sort -u); do + url="$RAW/$bucket/$slug/$(urlenc "$f")" + if http_get "$url" "$stage/$f"; then + if is_sfnt "$stage/$f"; then + got=$((got + 1)) + printf '%s\n' "$f" >&3 + else + # 200 but not a font: an error page, or the path moved. + rm -f "$stage/$f" + warn " $slug: $f non è un font valido, scartato." + fi + fi + done + [ "$got" -gt 0 ] +} + +# Tier 2 — the Fontsource zip. Keyless and always ships a LICENSE, but its TTFs +# are unicode-range subsets and its variable folder is woff2 only. We keep the +# `latin` subset, which covers the whole audited Italian character set. +fetch_fontsource() { # stage id + local stage="$1" id="$2" zip="$stage/.fontsource.zip" n + http_get "$FONTSOURCE/$id" "$zip" || return 1 + unzip -o -q -j "$zip" 'static/*-latin-[0-9]*.ttf' -d "$stage" 2>/dev/null || true + # LICENSE from the zip is only a fallback for the bucket copy (RULE 1). + unzip -o -q -j "$zip" 'LICENSE' -d "$stage" 2>/dev/null || true + [ -f "$stage/LICENSE" ] && mv -f "$stage/LICENSE" "$stage/.fontsource-LICENSE" + rm -f "$zip" + n=0 + for f in "$stage"/*.ttf; do + [ -e "$f" ] || continue + if is_sfnt "$f"; then n=$((n + 1)); printf '%s\n' "$(basename "$f")" >&3 + else rm -f "$f"; fi + done + [ "$n" -gt 0 ] +} + +# css2 is strict about the weight list: it MUST be ascending and duplicate-free, +# and every value must sit inside the family's own wght range — otherwise the +# endpoint answers 400 with an HTML page instead of CSS. Verified 2026-08-27: +# `Oswald:wght@200;400;700;700` -> 400, `…@200;400;700` -> 200. +css2_weights() { # min max + local lo="$1" hi="$2" v + { + printf '%s\n' "$lo" "$hi" + for v in 400 700; do + if [ "$v" -lt "$lo" ]; then printf '%s\n' "$lo" + elif [ "$v" -gt "$hi" ]; then printf '%s\n' "$hi" + else printf '%s\n' "$v"; fi + done + } | sort -n -u | tr '\n' ';' | sed 's/;$//' +} + +# Tier 3 — css2 with a non-browser UA. Full unsubsetted statics, but css2 always +# instantiates: a family that lands here has NO variable axes. +fetch_css2() { # stage family axes + local stage="$1" family="$2" axes="$3" q wmin wmax n=0 line w url + q=$(printf '%s' "$family" | sed 's/ /+/g') + case "$axes" in + *wght:*) + wmin=$(printf '%s' "$axes" | sed -n 's/.*wght:\([0-9.]*\)-.*/\1/p') + wmax=$(printf '%s' "$axes" | sed -n 's/.*wght:[0-9.]*-\([0-9.]*\).*/\1/p') + q="$q:wght@$(css2_weights "${wmin%.*}" "${wmax%.*}")" ;; + esac + http_get "$CSS2?family=$q" "$stage/.css2.css" || return 1 + # Pair each @font-face's weight/style with its .ttf url. + awk ' + /font-style:/ { st=$2; sub(/;/,"",st) } + /font-weight:/ { w=$2; sub(/;/,"",w) } + /url\(/ { if (match($0, /https:[^)]*\.ttf/)) print w "-" st " " substr($0, RSTART, RLENGTH) } + ' "$stage/.css2.css" | sort -u > "$stage/.css2.list" + rm -f "$stage/.css2.css" + while read -r line; do + [ -n "$line" ] || continue + w=${line%% *}; url=${line##* } + local out + out=$(printf '%s' "$family" | tr -d ' ')"-$w.ttf" + if http_get "$url" "$stage/$out" && is_sfnt "$stage/$out"; then + n=$((n + 1)); printf '%s\n' "$out" >&3 + else + rm -f "$stage/$out" + fi + done < "$stage/.css2.list" + rm -f "$stage/.css2.list" + [ "$n" -gt 0 ] +} + +# --- bucket resolution ------------------------------------------------------ +# RULE 2 (probe, never hardcode) + RULE 3 (a 200 is not proof of identity). +resolve_bucket() { # family slug meta-out -> echoes bucket + local family="$1" slug="$2" meta="$3" b name + for b in $BUCKETS; do + http_get "$RAW/$b/$slug/METADATA.pb" "$meta" || continue + name=$(awk -F'"' '/^name:/ { print $2; exit }' "$meta") + if [ "$name" = "$family" ]; then + printf '%s' "$b" + return 0 + fi + # e.g. bigshoulders vs the four other "Big Shoulders …" families. + warn " $slug: $b/ esiste ma contiene «$name», non «$family» — ignorato." + rm -f "$meta" + done + return 1 +} + +# Cross-check the axes declared in fonts.ts against the ones the upstream +# variable font actually has. Typst asks for these at render time, so a stale +# range in fonts.ts would silently render at a clamped weight. +check_axes() { # meta declared slug + local meta="$1" declared="$2" slug="$3" upstream a tag lo hi + [ "$declared" = "-" ] && { printf '%s' "-"; return 0; } + upstream=$(awk ' + /^axes \{/ { inax=1; tag=""; lo=""; hi=""; next } + inax && /tag:/ { t=$2; gsub(/"/,"",t); tag=t } + inax && /min_value:/ { lo=$2; sub(/\.0$/,"",lo) } + inax && /max_value:/ { hi=$2; sub(/\.0$/,"",hi) } + inax && /^\}/ { inax=0; printf "%s%s:%s-%s", (n++ ? "," : ""), tag, lo, hi } + END { print "" } + ' "$meta") + [ -z "$upstream" ] && upstream="-" + for a in $(printf '%s' "$declared" | tr ',' ' '); do + tag=${a%%:*} + case ",$upstream," in + *",$a,"*) ;; + *) warn " $slug: asse $a dichiarato in fonts.ts, upstream ha «$upstream» — controlla." ;; + esac + done + printf '%s' "$upstream" +} + +# --- main loop -------------------------------------------------------------- +FAILED="" +COUNT_OK=0; COUNT_SKIP=0; COUNT_FAIL=0 + +while IFS=$(printf '\t') read -r family id slug declic axes; do + [ -n "$family" ] || continue + if [ -n "$ONLY" ] && [ "$(lower "$id")" != "$ONLY" ] && [ "$(lower "$family")" != "$ONLY" ]; then + continue + fi + + dir="$DEST/$id" + + # Idempotency: complete family + no --force -> no network at all. + if [ "$FORCE" = 0 ] || [ "$CHECK_ONLY" = 1 ]; then + if [ -s "$dir/LICENSE" ] && [ -n "$(manifest_get "$dir" license || true)" ] && \ + ls "$dir"/*.ttf >/dev/null 2>&1; then + info "$family — già presente, salto." + COUNT_SKIP=$((COUNT_SKIP + 1)) + continue + fi + fi + if [ "$CHECK_ONLY" = 1 ]; then + fail "$family — incompleta o assente (modalità --check, non scarico)." + FAILED="$FAILED $family" + COUNT_FAIL=$((COUNT_FAIL + 1)) + continue + fi + + say "$family ($id)" + stage="$TMP/stage-$id" + rm -rf "$stage"; mkdir -p "$stage" + meta="$stage/METADATA.pb" + filelist="$stage/.files" + : > "$filelist" + + bucket="" + if bucket=$(resolve_bucket "$family" "$slug" "$meta"); then + info "bucket risolto: $bucket/ (licenza dalla directory: $(license_id_for_bucket "$bucket"))" + else + bucket="" + warn " $family: nessun bucket di google/fonts corrisponde a «$family» (slug $slug)." + fi + + # --- binaries, in the configured order ----------------------------------- + source_used="" + if [ "$SOURCE_PREF" = "gf" ] && [ -n "$bucket" ]; then + if fetch_gf "$stage" "$meta" "$bucket" "$slug" 3>>"$filelist"; then source_used="google-fonts-raw"; fi + fi + if [ -z "$source_used" ]; then + if fetch_fontsource "$stage" "$id" 3>>"$filelist"; then source_used="fontsource-latin-subset"; fi + fi + if [ -z "$source_used" ] && [ "$SOURCE_PREF" = "fontsource" ] && [ -n "$bucket" ]; then + if fetch_gf "$stage" "$meta" "$bucket" "$slug" 3>>"$filelist"; then source_used="google-fonts-raw"; fi + fi + if [ -z "$source_used" ]; then + if fetch_css2 "$stage" "$family" "$axes" 3>>"$filelist"; then source_used="google-css2-static"; fi + fi + + if [ -z "$source_used" ]; then + fail "$family: nessuna sorgente ha fornito un TTF valido." + FAILED="$FAILED $family" + COUNT_FAIL=$((COUNT_FAIL + 1)) + continue + fi + + # --- licence: from the DIRECTORY first (RULE 1) -------------------------- + license_url="" + if [ -n "$bucket" ]; then + for lf in $(license_files_for_bucket "$bucket"); do + if http_get "$RAW/$bucket/$slug/$lf" "$stage/LICENSE"; then + license_url="$RAW/$bucket/$slug/$lf" + break + fi + done + fi + if [ ! -s "$stage/LICENSE" ] && [ -s "$stage/.fontsource-LICENSE" ]; then + mv -f "$stage/.fontsource-LICENSE" "$stage/LICENSE" + license_url="$FONTSOURCE/$id (LICENSE nello zip Fontsource)" + warn " $family: licenza presa dallo zip Fontsource, non dalla directory upstream." + fi + rm -f "$stage/.fontsource-LICENSE" + + if [ ! -s "$stage/LICENSE" ]; then + fail "$family: nessun file LICENSE trovato — la famiglia NON viene installata." + FAILED="$FAILED $family" + COUNT_FAIL=$((COUNT_FAIL + 1)) + continue + fi + + license_id=$(license_id_for_bucket "${bucket:-ofl}") + [ -z "$bucket" ] && license_id="$declic(dichiarata)" + if [ -n "$bucket" ] && [ "$license_id" != "$declic" ]; then + warn " $family: fonts.ts dichiara $declic, la directory upstream dice $license_id." + fi + + axes_upstream="-" + [ -f "$meta" ] && axes_upstream=$(check_axes "$meta" "$axes" "$slug") + + variable="no" + grep -q '\[' "$filelist" 2>/dev/null && variable="sì" + + # --- install atomically --------------------------------------------------- + files_csv=$(tr '\n' ';' < "$filelist" | sed 's/;$//') + nfiles=$(grep -c . "$filelist" || true) + { + printf 'family=%s\n' "$family" + printf 'id=%s\n' "$id" + printf 'slug=%s\n' "$slug" + printf 'bucket=%s\n' "${bucket:-sconosciuto}" + printf 'license=%s\n' "$license_id" + printf 'license_declared=%s\n' "$declic" + printf 'license_url=%s\n' "$license_url" + printf 'upstream=%s\n' "${bucket:+$TREE/$bucket/$slug}" + printf 'source=%s\n' "$source_used" + printf 'variable=%s\n' "$variable" + printf 'axes_declared=%s\n' "$axes" + printf 'axes_upstream=%s\n' "$axes_upstream" + printf 'files=%s\n' "$files_csv" + printf 'fetched=%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" + } > "$stage/.fetch-manifest" + rm -f "$filelist" + + rm -rf "$dir" + mkdir -p "$(dirname "$dir")" + mv "$stage" "$dir" + + ok "$family — $nfiles file, $license_id, sorgente: $source_used, variabile: $variable" + COUNT_OK=$((COUNT_OK + 1)) +done < "$LIST" + +# --- report ----------------------------------------------------------------- +# Rebuilt from the manifests on disk, so it always describes what is actually +# installed — including families skipped as already-present in this run. +build_report() { + { + printf '# Caratteri di terze parti inclusi in pi-imgen\n\n' + printf 'Generato da `scripts/fetch-fonts.sh` il %s. Non modificare a mano.\n\n' "$(date -u '+%Y-%m-%d %H:%M UTC')" + printf 'I file dei caratteri stanno in `vendor/fonts//` e **non** sono versionati.\n' + printf 'Ogni cartella contiene il file `LICENSE` originale.\n\n' + printf 'La licenza è letta dalla **directory upstream** in `google/fonts` (il bucket\n' + printf '`ofl/`, `apache/`, `ufl/`, `cc-by-sa/`), mai dai metadati dentro il binario:\n' + printf 'per esempio il binario di Roboto Condensed dichiara ancora Apache-2.0 nel name\n' + printf 'ID 13 mentre la concessione reale è OFL. Il bucket viene risolto a ogni\n' + printf 'esecuzione, mai scritto a mano, perché le famiglie migrano fra bucket.\n\n' + printf '| Famiglia | Licenza | Origine upstream | File | Variabile | Sorgente |\n' + printf '|---|---|---|---|---|---|\n' + + while IFS=$(printf '\t') read -r family id slug declic axes; do + [ -n "$family" ] || continue + d="$DEST/$id" + if [ ! -f "$d/.fetch-manifest" ]; then + printf '| %s | %s (dichiarata) | — | **non scaricata** | — | — |\n' "$family" "$declic" + continue + fi + lic=$(manifest_get "$d" license) + up=$(manifest_get "$d" upstream) + lurl=$(manifest_get "$d" license_url) + src=$(manifest_get "$d" source) + var=$(manifest_get "$d" variable) + nf=$(ls "$d"/*.ttf 2>/dev/null | wc -l | tr -d ' ') + [ -n "$up" ] || up="—" + case "$src" in + google-fonts-raw) srclabel='google/fonts (TTF completi)' ;; + fontsource-latin-subset) srclabel='Fontsource (**subset `latin`**, niente assi)' ;; + google-css2-static) srclabel='css2 (**statici**, niente assi)' ;; + *) srclabel="$src" ;; + esac + printf '| %s | [%s](%s) | [%s](%s) | %s | %s | %s |\n' \ + "$family" "$lic" "${lurl:-$up}" "${up##*/main/}" "$up" "$nf" "$var" "$srclabel" + done < "$LIST" + + printf '\n## Note\n\n' + printf -- '- **OFL-1.1**: ridistribuzione libera, anche commerciale, purché i caratteri\n' + printf -- ' restino accompagnati dalla licenza e non vengano venduti da soli. Le famiglie\n' + printf -- ' con Reserved Font Name (Playfair Display, DM Serif Display, Alfa Slab One)\n' + printf -- ' vanno incluse **non modificate**: rinominare il file non basta, va rinominato\n' + printf -- ' il font se lo si altera.\n' + printf -- '- **Apache-2.0**: nessun obbligo di attribuzione nel prodotto finale, ma il file\n' + printf -- ' `LICENSE` va conservato accanto ai binari.\n' + printf -- '- I TTF variabili arrivano solo da `raw.githubusercontent.com/google/fonts`.\n' + printf -- ' L'"'"'endpoint css2 istanzia sempre e non può restituire un font variabile;\n' + printf -- ' lo zip di Fontsource contiene TTF statici divisi per unicode-range e i\n' + printf -- ' variabili solo in WOFF2, inutili per Typst.\n' + printf -- '- Rigenera questo file con `scripts/fetch-fonts.sh --check`.\n' + } > "$REPORT" +} + +build_report +ok "Rigenerato $REPORT" + +# --- final verification: no family may end up without a LICENSE ------------- +MISSING="" +while IFS=$(printf '\t') read -r family id slug declic axes; do + [ -n "$family" ] || continue + if [ -n "$ONLY" ] && [ "$(lower "$id")" != "$ONLY" ] && [ "$(lower "$family")" != "$ONLY" ]; then + continue + fi + d="$DEST/$id" + if [ ! -s "$d/LICENSE" ]; then + MISSING="$MISSING $family(LICENSE)" + elif ! ls "$d"/*.ttf >/dev/null 2>&1; then + MISSING="$MISSING $family(TTF)" + fi +done < "$LIST" + +if [ -n "$ONLY" ] && [ $((COUNT_OK + COUNT_SKIP + COUNT_FAIL)) -eq 0 ]; then + die "--only=$ONLY non corrisponde a nessuna famiglia di fonts.ts." +fi + +say "" +say "Scaricate: $COUNT_OK · già presenti: $COUNT_SKIP · fallite: $COUNT_FAIL" + +if [ -n "$MISSING" ] || [ -n "$FAILED" ]; then + say "" + if [ -n "$FAILED" ]; then + fail "Non scaricate:$FAILED" + fi + if [ -n "$MISSING" ]; then + fail "Incomplete su disco:$MISSING" + fail "Una famiglia senza LICENSE non è ridistribuibile: non pubblicare questa build." + fi + fail "Riprova con: scripts/fetch-fonts.sh --force --only=" + exit 1 +fi + +ok "Tutte le famiglie richieste hanno TTF e LICENSE." diff --git a/templates/banded.typ b/templates/banded.typ new file mode 100644 index 0000000..fa3fb08 --- /dev/null +++ b/templates/banded.typ @@ -0,0 +1,429 @@ +// banded.typ — "a fascia": a solid colour band across the middle carries the title, +// with the artwork left visible above and below it. +// +// Loud and graphic; built for sagre, feste patronali and summer festivals. The title +// sits on FLAT colour, never on the picture, so it needs no scrim and stays legible +// whatever the diffusion model painted. Only the secondary lines at the foot of the +// page sit over the artwork, and those are the ones `spec.needs_scrim` protects. +// +// Composition, portrait / square (a3, a4, ig-post, ig-story): +// +// ┌───────────────────┐ +// │ artwork │ +// ├═══════════════════┤ band : accent, full width, bleeds off both edges +// │ TITOLO sottot. │ title (+ subtitle), centred +// ├───────────────────┤ strip : ink, the same width, inverted colours +// │ data · luogo │ date, venue +// ├───────────────────┤ +// │ artwork │ +// │ dettagli/prezzo │ foot : over the artwork, scrimmed when needed +// └───────────────────┘ +// +// Landscape (fb-cover, yt-thumb) is NOT the same layout scaled down. A wide canvas has +// no vertical room for a band plus a strip, and a title set across 1640 px is an +// unreadable measure. So on landscape the band swallows the strip and becomes two +// columns — title left, date/venue right — and the whole thing is proportionally +// taller. See `landscape` below. +// +// Everything is a fraction of the TRIM's short edge or of the trim height, so the same +// file is correct at 1080 px and at 300 dpi A3. Nothing here reads the clock, the +// filesystem or a random source: the output is byte-identical across runs. + +#import "lib.typ": * +#let spec = json(sys.inputs.specfile) + +// Byte-reproducible output — the load-bearing directive, see docs/typst-verified.md. +#set document(date: none) +// Italian hyphenation and Knuth-Plass line breaking for free. +#set text(lang: "it") +#set par(linebreaks: "optimized") + +// --------------------------------------------------------------------------- +// Spec-derived constants +// --------------------------------------------------------------------------- + +#let pal = palette-of(spec) +#let sa = safe-area(spec) +#let t = trim-mm(spec) +#let short = short-edge-mm(spec) + +/// True for fb-cover and yt-thumb, false for everything portrait or square-ish. +/// 1.25 sits well clear of ig-post's 0.8 and of a square, and well below fb-cover's 2.47. +#let landscape = t.width >= t.height * 1.25 + +// --------------------------------------------------------------------------- +// Band colour +// +// `ink_resolved` was contrast-checked by the renderer against the ARTWORK, and this +// template must not second-guess it — but inside the band the type is not over the +// artwork, it is over flat accent, a pair nobody has checked. So the ink stays exactly +// as resolved and the BAND moves instead: keep the accent when it separates, otherwise +// push its lightness away from the ink while holding its hue. A slightly shifted red is +// a small art-direction concession; a title nobody can read is not recoverable. +// --------------------------------------------------------------------------- + +/// sRGB -> linear, per WCAG 2.x relative luminance. +#let _channel(u) = if u <= 0.04045 { u / 12.92 } else { calc.pow((u + 0.055) / 1.055, 2.4) } + +#let _luma(c) = { + let k = rgb(c).components().slice(0, 3).map(v => _channel(v / 100%)) + 0.2126 * k.at(0) + 0.7152 * k.at(1) + 0.0722 * k.at(2) +} + +/// WCAG contrast ratio, 1 (identical) to 21 (black on white). +#let _contrast(a, b) = { + let la = _luma(a) + let lb = _luma(b) + (calc.max(la, lb) + 0.05) / (calc.min(la, lb) + 0.05) +} + +// 3.0 is the WCAG threshold for LARGE text, which is all this band ever carries: the +// title is 13% of the short edge (38 mm on A3) and even the date is over 13 mm. +#let _KEEP = 3.0 // good enough to leave the art director's accent alone +#let _AIM = 3.5 // what a shifted accent must reach, with a little headroom + +#let _band-fill = { + let ink = pal.ink + let base = pal.accent + if _contrast(base, ink) >= _KEEP { + base + } else { + // Which way to run: away from the ink, so a light ink darkens the band. + let lighter = _luma(ink) < 0.5 + let found = none + for i in range(1, 13) { + if found == none { + let c = if lighter { base.lighten(i * 8%) } else { base.darken(i * 8%) } + if _contrast(c, ink) >= _AIM { found = c } + } + } + if found != none { + found + } else if _contrast(pal.bg, ink) >= _AIM { + pal.bg // the palette's own background is the next most considered choice + } else if lighter { + white // last resort: a colour that cannot fail + } else { + black + } + } +} + +// The strip under the band inverts the pair: ink ground, band-coloured type. That is +// exactly the contrast we just guaranteed, read the other way round. +#let _strip-fill = pal.ink +#let _strip-ink = _band-fill + +// --------------------------------------------------------------------------- +// Blocks -> zones +// +// `spec.blocks` is walked ONCE, in order, and each block is filed into the zone its +// role belongs to. Order inside a zone is spec order, and the zones themselves run down +// the page in role order, so the spec's sequence is what the reader's eye follows. +// Roles the kernel does not know fall back to `details`, which lands in the foot. +// --------------------------------------------------------------------------- + +#let _ZONES = (title: "band", subtitle: "band", date: "strip", venue: "strip") + +#let _prepared = { + let out = () + let bs = _get(spec, "blocks", ()) + if type(bs) == array { + for b in bs { + if type(b) == dictionary { + let raw = _get(b, "text", "") + if type(raw) == str and raw.trim() != "" { + // `block-style` normalises the role, so `st.role` is never an unknown one. + let st = block-style(spec, _get(b, "role", none)) + out.push(( + role: st.role, + zone: _ZONES.at(st.role, default: "lower"), + // CAPS-ONLY families (Bebas Neue, Bungee) draw capitals at lowercase + // codepoints; upper() is harmless for them and correct for everyone else. + text: if st.upper { upper(raw) } else { raw }, + optional: _get(b, "optional", false) == true, + style: st, + )) + } + } + } + } + out +} + +#let band-blocks = _prepared.filter(b => b.zone == "band") +#let strip-blocks = _prepared.filter(b => b.zone == "strip") +#let lower-blocks = _prepared.filter(b => b.zone == "lower") + +// On landscape the strip has nowhere to go, so it rides inside the band as a second +// column instead of a second bar. +#let split-band = landscape and strip-blocks.len() > 0 and band-blocks.len() > 0 + +// --------------------------------------------------------------------------- +// Type boxes +// +// These are the boxes handed to `fit`, i.e. CEILINGS, not targets: `fit` never grows +// text past `block-style`'s ideal size, so a two-word title yields a slim band and the +// artwork gets the rest. Only a long title spends the whole budget — and it spends it +// on the wdth axis first, staying big and simply narrowing. +// --------------------------------------------------------------------------- + +/// Height allowance for `n` lines at this role's ideal size (1.45 covers em box + +/// leading for every role in the kernel's table). +#let _lines(st, n) = st.size * n * 1.45 + +#let band-pad-y = short * 0.05 * 1mm +#let band-pad-x = sa.x // bleed + safe: content lands in the safe area + +// The band may take at most this much of the page. Landscape gets more because it has +// no strip below it and the artwork strips above/below stay legible at a smaller share. +#let band-max = t.height * (if landscape { 0.50 } else { 0.42 }) * 1mm +#let band-inner = calc.max(short * 0.12 * 1mm, band-max - 2 * band-pad-y) + +#let n-title = band-blocks.filter(b => b.role != "subtitle").len() +#let has-sub = band-blocks.any(b => b.role == "subtitle") + +// Two and a half lines of title at every format: short * 0.42 against a title set at +// short * 0.13 with 1.3 line advance. The band budget wins when it is the tighter one. +#let title-h = calc.min( + if has-sub { band-inner * 0.66 } else { band-inner }, + short * 0.42 * 1mm, +) / calc.max(1, n-title) +#let sub-h = if has-sub { band-inner * 0.26 } else { 0pt } + +// Column split inside a landscape band. The three add up to exactly the inner width. +#let title-w = if split-band { sa.width * 0.66 } else { sa.width } +#let info-w = if split-band { sa.width * 0.28 } else { sa.width } +#let gutter-w = sa.width * 0.06 + +// Centred is the sagra-banner reading of this layout; landscape needs a left edge to +// hang the title on, because its right-hand column is the date. +#let band-align = if landscape { left } else { center } +#let foot-align = if landscape { left } else { center } + +// --------------------------------------------------------------------------- +// Logo reservation +// +// `logo-place` puts the logo in its corner regardless of what is there. When that +// corner is at the bottom it shares the foot with the smaller blocks, so the foot gives +// up the width instead of colliding with it. +// --------------------------------------------------------------------------- + +#let _logo-w = { + let lg = _get(spec, "logo", none) + let path = if type(lg) == dictionary { _get(lg, "path", none) } else { none } + if type(path) != str or path.trim() == "" { + 0pt + } else { + let raw = _get(lg, "scale", 0.12) + let s = if type(raw) in (int, float) { calc.max(0.02, calc.min(0.4, raw)) } else { 0.12 } + sa.short-edge * s + } +} +#let _logo-corner = _dig(spec, ("logo", "corner"), "br") +#let _logo-at-foot = _logo-w > 0pt and _logo-corner in ("bl", "br") + +#let foot-w = if _logo-at-foot { sa.width - _logo-w - short * 0.03 * 1mm } else { sa.width } +// Slide the foot away from the logo rather than under it. +#let foot-dx = if _logo-at-foot and _logo-corner == "bl" { sa.x + _logo-w + short * 0.03 * 1mm } else { sa.x } + +// --------------------------------------------------------------------------- +// Rendering helpers +// --------------------------------------------------------------------------- + +/// Auto-fit one prepared block into `w` x `h`, overriding only the colour: on the band +/// the kernel's art-checked fill would be the wrong one, everywhere else it is right. +#let render-block(b, w, h, colour, al) = { + let args = b.style.args + if colour != none { args.insert("fill", colour) } + fit(b.text, w, h, b.style.family, b.style.axes, ..args, align-to: al) +} + +/// A vertical run of blocks with an even optical gap and no trailing space. +#let stack-blocks(blocks, w, h-of, colour, al, gap) = { + let first = true + for b in blocks { + if not first { v(gap, weak: false) } + render-block(b, w, h-of(b), colour, al) + first = false + } +} + +#let band-column = stack-blocks( + band-blocks, + title-w, + b => if b.role == "subtitle" { sub-h } else { title-h }, + pal.ink, + band-align, + short * 0.022 * 1mm, +) + +/// date + venue: inside the band on landscape, on their own inverted strip otherwise. +#let strip-column(colour, al) = stack-blocks( + strip-blocks, + if split-band { info-w } else { sa.width }, + b => _lines(b.style, 2), + colour, + al, + short * 0.014 * 1mm, +) + +/// The foot keeps each role's own resolved fill: those colours were contrast-checked +/// against the artwork, which is exactly what they sit on here. +#let foot-column(blocks) = { + // Hyphenation earns its keep in a narrow column of small text, and can never reach + // the title from here. + set text(hyphenate: true) + stack-blocks( + blocks, + foot-w, + b => _lines(b.style, if b.role == "details" { 3 } else { 2 }), + none, + foot-align, + short * 0.018 * 1mm, + ) +} + +// --------------------------------------------------------------------------- +// The three painted pieces +// --------------------------------------------------------------------------- + +/// Full-bleed artwork. Explicit mm rather than 100% so it cannot be misread as a +/// fraction of the trim: this must cover the whole page, bleed included. +/// NOTE: `art_file` must be root-relative — Typst resolves image paths against --root, +/// so the renderer rewrites it, exactly as it does for the logo. +#let art-layer = { + let p = _get(spec, "art_file", none) + if type(p) == str and p.trim() != "" { + image(p, width: sa.full-width, height: sa.full-height, fit: "cover") + } +} + +#let band-block = if band-blocks.len() == 0 and not split-band { + none +} else { + block( + width: sa.full-width, + fill: _band-fill, + inset: (left: band-pad-x, right: band-pad-x, top: band-pad-y, bottom: band-pad-y), + if split-band { + // Bottom-aligned info hangs off the same optical line as the last title line. + grid( + columns: (title-w, info-w), + column-gutter: gutter-w, + align: (left + horizon, right + bottom), + band-column, + strip-column(pal.ink, right), + ) + } else { + band-column + }, + ) +} + +#let strip-block = if landscape or strip-blocks.len() == 0 { + none +} else { + block( + width: sa.full-width, + fill: _strip-fill, + inset: ( + left: band-pad-x, + right: band-pad-x, + top: band-pad-y * 0.5, + bottom: band-pad-y * 0.5, + ), + strip-column(_strip-ink, center), + ) +} + +// --------------------------------------------------------------------------- +// Composition +// +// One `context` block solves the whole vertical layout, because every decision depends +// on a measurement: how tall the band came out, whether the foot still fits under it, +// and how much scrim that foot needs. Drawn into `page(foreground:)`, whose origin is +// the FULL page including bleed — the same coordinate system `safe-area` reports. +// --------------------------------------------------------------------------- + +#let composition = context { + let band-h = if band-block == none { 0pt } else { + measure(width: sa.full-width, band-block).height + } + let strip-h = if strip-block == none { 0pt } else { + measure(width: sa.full-width, strip-block).height + } + let group-h = band-h + strip-h + + // Optically centred: a hair above the middle, because the foot below reads as weight. + let nudge = -t.height * (if landscape { 0.02 } else { 0.045 }) * 1mm + let min-top = sa.y + t.height * 0.08 * 1mm // always leave a real strip of artwork + let group-top = calc.max(min-top, (sa.full-height - group-h) / 2 + nudge) + + // How much room is left between the band group and the bottom safe edge. + let foot-gap = short * 0.06 * 1mm + let avail = sa.full-height - sa.y - (group-top + group-h) - foot-gap + + let measure-foot(blocks) = { + if blocks.len() == 0 { 0pt } else { + measure(width: foot-w, block(width: foot-w, foot-column(blocks))).height + } + } + + let shown = lower-blocks + let foot-h = measure-foot(shown) + + // `optional: true` means "drop me before you break the page". Only then, and only + // when dropping actually buys the space. + if foot-h > avail and shown.any(b => b.optional) { + shown = shown.filter(b => not b.optional) + foot-h = measure-foot(shown) + } + // Still tight: slide the band up into the top artwork, as far as `min-top` allows. + if foot-h > avail { + group-top = calc.max(min-top, group-top - (foot-h - avail)) + } + + // Scrim only over the artwork, only under the foot, only when the renderer measured + // the region behind it as busy. The band never needs one — it is flat colour. + if foot-h > 0pt and _get(spec, "needs_scrim", false) == true { + let h = calc.min(foot-h + short * 0.20 * 1mm, sa.full-height * 0.45) + place(bottom + left, scrim(h, pal.scrim, none)) + } + + if band-block != none { + place(top + left, dy: group-top, band-block) + } + if strip-block != none { + place(top + left, dy: group-top + band-h, strip-block) + } + if foot-h > 0pt { + place(bottom + left, dx: foot-dx, dy: -sa.y, block(width: foot-w, foot-column(shown))) + } +} + +// --------------------------------------------------------------------------- +// Page +// +// `width`/`height` are the TRIM and `bleed` extends outward, which is what makes Typst +// emit a real TrimBox for the copy shop. PNG export renders the trim page, so the +// bleed and the crop marks are a PDF concern only — by design. +// --------------------------------------------------------------------------- + +#set page( + width: sa.trim-width, + height: sa.trim-height, + margin: 0pt, + bleed: sa.bleed, + fill: pal.bg, // shows only if the artwork is missing or transparent + background: art-layer, + foreground: { + composition + logo-place(spec) + crop-marks(spec) + }, +) + +// The body stays empty on purpose: every element is placed in the foreground, whose +// origin is the full page, so nothing depends on where a margin box would have started. +#box() diff --git a/templates/centred-stack.typ b/templates/centred-stack.typ new file mode 100644 index 0000000..eb2ac4c --- /dev/null +++ b/templates/centred-stack.typ @@ -0,0 +1,333 @@ +// centred-stack.typ — a centred, symmetrical type stack over full-bleed art. +// +// Voice: formal and classical. Teatro, opera, concerto, conferenza — the kind of piece +// where symmetry IS the design and any deliberate asymmetry would read as a mistake. +// +// Composition, top to bottom, all on one centred axis: +// +// ┌──────────────────────────┐ +// │ full-bleed art │ art: fit "cover", never distorted +// │ ····· centre veil ···· │ scrim only when spec.needs_scrim +// │ T I T O L O │ display face, auto-fit +// │ ────────── │ short accent rule +// │ sottotitolo │ body face from here down +// │ DATA E ORARIO │ +// │ LUOGO │ +// │ dettagli, prezzo │ +// │ │ +// │ footer · logo │ footer pinned to the foot of the sheet +// └──────────────────────────┘ +// +// Three rules this file obeys, in order of importance: +// 1. Nothing ever leaves the safe area. The stack is budgeted BEFORE it is typeset +// (see `cap` below), and `fit` only ever shrinks, so overflow is impossible rather +// than unlikely. That matters because a poster is checked once, at the printer. +// 2. Every measurement is a fraction of the trim's short edge, so the composition is +// re-solved per format instead of being a fixed layout that gets scaled. +// 3. Determinism: no dates, no randomness, no system fonts. See lib.typ's header. +// +// Coordinates: with `page(bleed:)` the BODY's origin is the trim's top-left, while +// `background:`/`foreground:` resolve against the full bleed page. Verified, not assumed. +// So body placement insets by `sa.safe`, and background art uses `sa.full-*`. + +#import "lib.typ": * + +#let spec = json(sys.inputs.specfile) + +// --------------------------------------------------------------------------- +// Geometry, palette, and the two knobs the aspect ratio turns +// --------------------------------------------------------------------------- + +#let sa = safe-area(spec) +#let pal = palette-of(spec) +#let trim = trim-mm(spec) +#let se = short-edge-mm(spec) * 1mm // the scale reference for everything here +#let aspect = trim.width / trim.height + +// fb-cover (2.47:1) and yt-thumb (1.78:1) have almost no vertical room, so the same +// centred stack has to be re-solved rather than scaled: a shorter measure, fewer lines +// per block, tighter gaps. Portrait and 4:5 keep the classical airy setting. +#let wide = aspect > 1.05 +#let ultra-wide = aspect > 1.8 + +// Measure. A full-width line on a 289 mm fb-cover would be unreadable; a centred stack +// wants a column, not the whole sheet. +#let col = sa.width * (if ultra-wide { 0.70 } else if wide { 0.82 } else { 0.90 }) + +#let gap = se * (if wide { 0.020 } else { 0.030 }) +#let rule-gap = gap * 1.30 +#let rule-len = calc.min(col * 0.32, se * 0.20) +#let rule-w = calc.max(0.5pt, se * 0.0018) + +// --------------------------------------------------------------------------- +// The centre veil — the "full-page scrim" this layout needs +// --------------------------------------------------------------------------- + +/// A wash over the WHOLE page that is densest along the horizontal centre line, which is +/// exactly where a centred stack puts its type. Built from lib's `scrim` twice (each +/// half-page gradient runs solid-at-the-middle, transparent-at-its-edge) over a light +/// flat base, so the artwork stays visible at the top and bottom edges and the type +/// still sits on enough density to hold contrast. +/// +/// Edge-anchored bands (the hero-bottom kind) are wrong here: they would darken exactly +/// the two strips this layout leaves empty and leave the headline unsupported. +#let centre-veil(sa, colour) = { + let half = sa.full-height / 2 + place(top + left, rect( + width: sa.full-width, height: sa.full-height, + stroke: none, fill: colour.transparentize(66%), + )) + place(top + left, scrim(half, colour, 90deg, width: sa.full-width, strength: 46%)) + place(bottom + left, scrim(half, colour, 270deg, width: sa.full-width, strength: 46%)) +} + +// --------------------------------------------------------------------------- +// Blocks +// --------------------------------------------------------------------------- + +/// Every block with usable text, in spec order. Blocks are rendered in the order the art +/// director wrote them; only the role decides the typography. +#let all-blocks = { + let bs = _get(spec, "blocks", ()) + if type(bs) != array { () } else { + bs.filter(b => { + if type(b) != dictionary { return false } + let t = _get(b, "text", none) + type(t) == str and t.trim() != "" + }) + } +} + +#let role-of(b) = { + let r = _get(b, "role", "details") + if type(r) == str { r } else { "details" } +} + +/// The footer is lifted out of the stack and pinned to the foot of the sheet — the +/// classical position for a patrocinio/credit line, and it keeps the tiny type from +/// hanging off the bottom of an otherwise centred group. +#let footers = all-blocks.filter(b => role-of(b) == "footer") +#let stack-blocks = all-blocks.filter(b => role-of(b) != "footer") + +// Relative appetite for vertical space. The title gets the lion's share; everything else +// is proportioned against it. Only used when space is short — see `cap`. +#let _WEIGHT = ( + title: 5.0, subtitle: 2.0, date: 1.7, venue: 1.4, details: 2.2, price: 1.2, footer: 1.0, +) +// Hard ceiling on how many lines a role may occupy. Without it a spacious A3 would hand +// the title a 200 mm box and cheerfully set it on six lines. +#let _LINES = if wide { + (title: 2, subtitle: 2, date: 1, venue: 1, details: 2, price: 1, footer: 1) +} else { + (title: 3, subtitle: 2, date: 2, venue: 2, details: 4, price: 1, footer: 2) +} + +#let weight-of(role) = _WEIGHT.at(role, default: 1.2) + +/// Height of `lines` lines at this role's ideal size. `leading` is an em-relative length, +/// so it is resolved against the role's own size instead of the ambient text size. +#let lines-height(st, lines) = { + let lead = st.leading.em * st.size + st.leading.abs + st.size * 1.2 * lines + lead * calc.max(0, lines - 1) +} + +/// The floor a block needs to still be worth setting: one line at its minimum size — two +/// for a title, because a headline broken to a single shrunken line is not a headline. +#let need-of(st) = { + st.min-size * (if st.role == "title" { 2.6 } else { 1.25 }) +} + +// --------------------------------------------------------------------------- +// Vertical budget +// --------------------------------------------------------------------------- + +// Optical centring: the eye reads the centre of a sheet as slightly above the geometric +// centre, so the whole group is lifted. Implemented by shortening the centring box from +// the bottom, which also makes the lift self-clamping — the stack is budgeted against the +// SHORTENED box, so it can never be pushed past the safe edge at either end. +#let lift = sa.height * (if wide { 0.015 } else { 0.030 }) + +// The footer band, reserved out of the stack's height before anything is measured. +#let footer-style = block-style(spec, "footer") +#let footer-band = if footers.len() == 0 { 0pt } else { + lines-height(footer-style, _LINES.footer) + gap * 1.2 +} + +#let avail = sa.height - 2 * lift - footer-band + +// A rule is drawn only where it means something: between the title and whatever follows. +#let has-rule = { + let roles = stack-blocks.map(role-of) + roles.contains("title") and roles.filter(r => r != "title").len() > 0 +} +#let rule-extra = if has-rule { 2 * rule-gap + rule-w - gap } else { 0pt } + +/// Drop `optional: true` blocks, last (least important) first, until what is left can be +/// set above its minimum sizes. In practice this almost never fires — the auto-fit +/// absorbs a crowded spec by narrowing and shrinking — which is the point: dropping a +/// block the user asked for is the last resort, not the first. +#let keep-blocks = { + let kept = stack-blocks + while kept.len() > 1 { + let n = kept.len() + let gaps = (n - 1) * gap + rule-extra + let pool = avail - gaps + let need = kept.map(b => need-of(block-style(spec, role-of(b)))).sum(default: 0pt) + if pool >= need { break } + // Least important droppable block = the last one flagged optional. + let idx = none + for (i, b) in kept.enumerate() { + if _get(b, "optional", false) == true { idx = i } + } + if idx == none { break } + kept = kept.slice(0, idx) + kept.slice(idx + 1) + } + kept +} + +#let n-kept = keep-blocks.len() +#let gaps-total = if n-kept <= 1 { 0pt } else { (n-kept - 1) * gap + rule-extra } +#let pool = calc.max(0pt, avail - gaps-total) +#let weight-total = keep-blocks.map(b => weight-of(role-of(b))).sum(default: 1.0) + +/// The box a block may fill: its share of the leftover height, never more than its line +/// ceiling. Because every block is capped and `fit` never grows past its box, the summed +/// stack cannot exceed `avail` — no post-hoc overflow check is needed anywhere below. +#let cap(st) = { + let share = pool * (weight-of(st.role) / weight-total) + calc.min(lines-height(st, _LINES.at(st.role, default: 2)), share) +} + +// --------------------------------------------------------------------------- +// The stack +// --------------------------------------------------------------------------- + +#let type-stack = { + let prev = none + for b in keep-blocks { + let role = role-of(b) + let st = block-style(spec, role) + + // Spacing. The rule replaces the gap after the title band ends. + if prev != none { + if prev == "title" and role != "title" and has-rule { + v(rule-gap) + block(width: 100%, align(center, line( + length: rule-len, + stroke: rule-w + pal.accent, + ))) + v(rule-gap) + } else { + v(gap) + } + } + + // Uppercasing is the role's decision (lib's _ROLE-STYLES). Caps-only families such as + // Bebas Neue or Bungee draw capitals at lowercase codepoints, so a non-uppercased + // role still renders correctly with them — we just never rely on lowercase shapes. + let t = _get(b, "text", "") + let body = if st.upper { upper(t) } else { t } + + // fit bisects the wdth axis first and the size only as a fallback, which keeps a long + // Italian title at full optical weight instead of quietly shrinking the poster. + block(width: 100%, fit( + body, col, cap(st), st.family, st.axes, + ..st.args, + align-to: center, + justify: false, + )) + + prev = role + } +} + +// A bottom-corner logo shares the footer band, so the footer's measure is inset by the +// logo's width on BOTH sides — symmetric, because a centred footer nudged off-axis to +// dodge a logo is exactly the kind of near-miss this layout cannot afford. +#let footer-width = { + let logo = _get(spec, "logo", none) + let inset = if type(logo) != dictionary { 0pt } else { + let path = _get(logo, "path", none) + if type(path) != str or path.trim() == "" { 0pt } else { + let corner = _get(logo, "corner", "br") + if corner in ("bl", "br") { + let s = _get(logo, "scale", 0.12) + let s = if type(s) in (int, float) { calc.max(0.02, calc.min(0.4, s)) } else { 0.12 } + sa.short-edge * s + gap + } else { 0pt } + } + } + calc.max(sa.width * 0.4, sa.width - 2 * inset) +} + +#let footer-stack = { + for (i, b) in footers.enumerate() { + if i > 0 { v(gap * 0.4) } + let st = block-style(spec, "footer") + let t = _get(b, "text", "") + block(width: 100%, fit( + if st.upper { upper(t) } else { t }, + footer-width, lines-height(st, _LINES.footer), st.family, st.axes, + ..st.args, + align-to: center, + justify: false, + )) + } +} + +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- + +#set document(date: none) +#set text(lang: "it", fill: pal.ink) +#set par(linebreaks: "optimized", justify: false) + +#set page( + width: sa.trim-width, + height: sa.trim-height, + margin: 0pt, + bleed: sa.bleed, + fill: pal.bg, + background: { + // Full-bleed artwork. "cover" crops rather than distorts; the crop is centred, so a + // symmetrical layout keeps a symmetrical background. + let art = _get(spec, "art_file", none) + if type(art) == str and art.trim() != "" { + place(top + left, image( + art, + width: sa.full-width, + height: sa.full-height, + fit: "cover", + )) + } + // ink_resolved was already contrast-checked upstream; needs_scrim is that check's + // verdict. Never second-guess either one here. + if _get(spec, "needs_scrim", false) == true { + centre-veil(sa, pal.scrim) + } + }, + foreground: { + crop-marks(spec) + logo-place(spec) + }, +) + +// Body origin = trim top-left, so the safe inset is `sa.safe` alone. +#place(top + left, dx: sa.safe + (sa.width - col) / 2, dy: sa.safe, box( + width: col, + height: avail, + align(center + horizon, type-stack), +)) + +#if footers.len() > 0 { + place(top + left, + dx: sa.safe + (sa.width - footer-width) / 2, + dy: sa.safe + sa.height - footer-band, + box( + width: footer-width, + height: footer-band, + align(center + bottom, footer-stack), + ), + ) +} diff --git a/templates/framed.typ b/templates/framed.typ new file mode 100644 index 0000000..d3c5663 --- /dev/null +++ b/templates/framed.typ @@ -0,0 +1,391 @@ +// framed.typ — artwork inset inside a generous coloured frame. +// +// The gallery layout, and the safest of the five: the type NEVER sits on the artwork, +// so a busy, high-variance image can never eat a word of it. The coloured frame is the +// page itself (`palette.bg`), the artwork is a panel floated inside it, and every text +// block lives in the frame's margins. +// +// Two compositions, chosen from the trim's aspect ratio — never a fixed layout scaled: +// +// STACKED (portrait and square-ish: a3, a4, ig-post, ig-story) +// +----------------------+ art panel across the top, flush with the side frames, +// | +------------+ | the type band in the deeper bottom margin. The classic +// | | art | | museum poster; the band is only as tall as the type +// | +------------+ | actually needs, so every spare millimetre goes to art. +// | TITOLO | +// | data · luogo | +// +----------------------+ +// +// SIDE (landscape: fb-cover, yt-thumb) +// +---------------------------+ a 2.5:1 cover has no room for a bottom band — the +// | TITOLO | art | type would be a 12 mm strip. So the frame margin +// | data | | that carries the type moves to the left edge and +// +---------------------------+ becomes a column, art fills the rest. +// +// Everything is drawn in `page(background:)`, whose origin is the FULL page INCLUDING +// bleed — hence the `+ b` on every coordinate. That is deliberate: one coordinate system +// for the whole composition is the only reliable defence against the classic bleed bug. +// Only the frame colour bleeds; the art panel is inset by definition and never reaches +// the trim, which is what makes this template unusually forgiving to print. + +#import "lib.typ": * +#let spec = json(sys.inputs.specfile) + +// --------------------------------------------------------------------------- +// Tunables — all fractions of the trim's SHORT edge, so the piece looks the same +// at 1080 px and at 300 dpi A3. +// --------------------------------------------------------------------------- + +/// Frame width: the margin between the trim and the art panel. Generous on purpose — +/// a mean frame reads as a printing mistake, a wide one reads as a decision. +#let FRAME-RATIO = 0.070 + +/// Breathing space between the art panel and the type, and between type blocks. +#let GAP-RATIO = 0.030 + +/// The type band may never take more than this fraction of the height (stacked), and the +/// art panel may never be squeezed below its own floor. Between them they guarantee the +/// composition stays a framed picture with a caption, not a caption with a stamp. +#let BAND-MAX = 0.46 +#let ART-MIN = 0.26 + +/// Width of the type column in the landscape composition, as a fraction of the space +/// inside the frame. 0.36 keeps a readable measure without starving the artwork. +#let COL-RATIO = 0.36 + +/// Hairline keyline around the art panel: a gallery frame's inner edge. Scaled with the +/// piece so it stays a hairline rather than a rule. +#let KEYLINE-RATIO = 0.0016 + +/// Ceiling on how many lines each role may claim when the band height is budgeted. The +/// auto-fit is what actually guarantees the text fits; this only stops one very long +/// block from claiming the whole band before the others are placed. +#let MAX-LINES = (title: 3, subtitle: 3, date: 2, venue: 2, details: 4, price: 2, footer: 2) + +/// Rough line box: cap height plus leading. Only used to cap the budget above. +#let LINE-FACTOR = 1.34 + +// --------------------------------------------------------------------------- +// Blocks +// --------------------------------------------------------------------------- + +/// Every renderable block in SPEC ORDER, with its resolved style and its text already +/// uppercased where the role calls for it. Blocks that are not dictionaries, or whose +/// text is missing/blank, simply do not exist — a half-filled spec must still print. +#let entries(spec) = { + let bs = _get(spec, "blocks", ()) + if type(bs) != array { return () } + let out = () + for b in bs { + if type(b) != dictionary { continue } + let raw = _get(b, "text", "") + if type(raw) != str or raw.trim() == "" { continue } + let st = block-style(spec, _get(b, "role", none)) + out.push(( + st: st, + body: if st.upper { upper(raw) } else { raw }, + // Only an explicit `true` makes a block droppable; anything else is mandatory. + optional: _get(b, "optional", false) == true, + )) + } + out +} + +/// Height this block wants in a `w`-wide column: the height it takes at its ideal size +/// and at the NARROWEST width the auto-fit is allowed to use — i.e. the best case `fit` +/// could reach without shrinking. Budgeting against the best case is what lets a long +/// Italian title keep its size by narrowing, instead of being handed three lines' worth +/// of room and dutifully filling them. +/// +/// Must be called from a `context` block: it measures. +#let wanted-height(st, body, w) = { + let rng = _axis-range(st.axes.at("wdth", default: none)) + // `fit` never exceeds the natural instance (100), so the floor is the axis minimum. + let wd = if rng == none { none } else { calc.min(rng.at(0), rng.at(1)) } + let h = measure(width: w, { + set par(leading: st.leading, linebreaks: "optimized", justify: false) + text(.._text-args(st.family, st.size, st.weight, st.tracking, none, wd), body) + }).height + calc.min(h, MAX-LINES.at(st.role, default: 2) * st.size * LINE-FACTOR) +} + +/// Space above block `i`. A title is followed by a wider gap: the reader needs to see +/// where the headline stops and the practical information starts. +#let gap-before(items, i, gap) = { + if i == 0 { return 0pt } + if items.at(i - 1).st.role == "title" { gap * 1.7 } else { gap } +} + +/// Budget the type stack into `max-h`. +/// +/// 1. Measure what every block wants. +/// 2. While the stack is over budget, drop the LAST `optional: true` block — the spec +/// orders blocks by priority, so the last optional one is the least missed. +/// 3. If it is still over budget, scale every allocation by the same factor and let +/// `fit` shrink the text into it. Uniform scaling keeps the typographic hierarchy: +/// everything gets quieter together, nothing collapses on its own. +/// +/// Returns `(items, heights, gaps, height)` with `height` the stack's real extent. +#let budget(items, w, gap, max-h) = { + let kept = items + let hs = () + let gs = () + let total = 0pt + + // Recompute from scratch after each drop: removing a block also removes its gap, and + // a title that is no longer followed by anything no longer needs its wider one. + let plan(list) = { + let h = () + let g = () + let t = 0pt + for i in range(list.len()) { + let gb = gap-before(list, i, gap) + let hh = wanted-height(list.at(i).st, list.at(i).body, w) + h.push(hh) + g.push(gb) + t += gb + hh + } + (h, g, t) + } + + let (h, g, t) = plan(kept) + hs = h + gs = g + total = t + + // Drop optional blocks, last first, until the stack fits or none are left. + while total > max-h { + let drop = none + for i in range(kept.len()) { + if kept.at(i).optional { drop = i } + } + if drop == none { break } + kept = kept.slice(0, drop) + kept.slice(drop + 1) + let (h2, g2, t2) = plan(kept) + hs = h2 + gs = g2 + total = t2 + } + + // Still over: squeeze uniformly. `fit` does the rest. + if total > max-h and total > 0pt { + let k = max-h / total + hs = hs.map(x => x * k) + gs = gs.map(x => x * k) + total = max-h + } + + (items: kept, heights: hs, gaps: gs, height: total) +} + +/// Draw a budgeted stack from `(x, y)`, top-anchored, in a `w`-wide column. +#let draw-stack(plan, x, y, w, align-to) = { + let cy = y + for i in range(plan.items.len()) { + let it = plan.items.at(i) + cy += plan.gaps.at(i) + place( + top + left, + dx: x, + dy: cy, + block( + width: w, + fit(it.body, w, plan.heights.at(i), it.st.family, it.st.axes, align-to: align-to, ..it.st.args), + ), + ) + cy += plan.heights.at(i) + } +} + +// --------------------------------------------------------------------------- +// Art panel +// --------------------------------------------------------------------------- + +/// The inset artwork, plus its keyline and (only when the renderer measured the art as +/// busy) a soft scrim along the edge that faces the type. +/// +/// `fit: "cover"` inside a fixed-size, clipped block: the artwork fills the panel at its +/// own aspect ratio and the overflow is cropped, so a 4:5 generation never distorts to +/// fill a 2.5:1 cover panel. +/// +/// The scrim here is NOT a legibility fix — no text ever crosses this panel. It is a +/// vignette that settles a noisy image into the frame colour instead of butting against +/// it, which is exactly the case (`needs_scrim`) the renderer flags. +#let art-panel(spec, pal, w, h, short, scrim-edge) = { + let path = _get(spec, "art_file", none) + let keyline = calc.max(0.2pt, short * KEYLINE-RATIO) + + block( + width: w, + height: h, + clip: true, + // The keyline sits on the panel edge, in ink at low strength: it defines the picture + // without competing with it. Over a dark frame it reads as light, over a light frame + // as dark, because `ink` is already the contrast-checked colour for this background. + stroke: keyline + pal.ink.transparentize(72%), + fill: if type(path) == str and path.trim() != "" { none } else { + // No artwork yet (text-only draft): a flat tint keeps the composition honest + // instead of leaving a hole where the picture goes. + pal.ink.transparentize(88%) + }, + { + if type(path) == str and path.trim() != "" { + image(path, width: 100%, height: 100%, fit: "cover") + } + if _get(spec, "needs_scrim", false) == true and scrim-edge != none { + if scrim-edge == "bottom" { + // Solid at the panel's bottom edge, fading up into the picture. + place(bottom + left, scrim(h * 0.34, pal.scrim, 90deg, width: 100%, strength: 62%)) + } else { + // Landscape: solid at the left edge, fading right — towards the type column. + place(top + left, scrim(h, pal.scrim, 180deg, width: w * 0.30, strength: 62%)) + } + } + }, + ) +} + +// --------------------------------------------------------------------------- +// Logo reserve +// --------------------------------------------------------------------------- + +/// `logo-place` anchors the logo at the safe corner, which in this template is inside the +/// frame margin — the right place for it. But the frame is only ~7% of the short edge and +/// a logo is up to 40%, so the layout must be told to keep out of its corner. +/// +/// Returns the reserve as a length (0pt when there is no logo) plus its corner. The +/// reserve is a SQUARE of the logo's width: `logo-place` constrains width only, so a +/// taller-than-wide logo can still exceed it — rare, and it costs a little art, never a +/// word of type. +#let logo-reserve(spec, short) = { + let logo = _get(spec, "logo", none) + if type(logo) != dictionary { return (size: 0pt, corner: none) } + let path = _get(logo, "path", none) + if type(path) != str or path.trim() == "" { return (size: 0pt, corner: none) } + let raw = _get(logo, "scale", 0.12) + let s = if type(raw) in (int, float) { calc.max(0.02, calc.min(0.4, raw)) } else { 0.12 } + let corner = _get(logo, "corner", "br") + ( + size: short * s, + corner: if corner in ("tl", "tr", "bl", "br") { corner } else { "br" }, + ) +} + +// --------------------------------------------------------------------------- +// Composition +// --------------------------------------------------------------------------- + +#let compose(spec) = context { + let pal = palette-of(spec) + let sa = safe-area(spec) + let short = sa.short-edge + let b = sa.bleed + let tw = sa.trim-width + let th = sa.trim-height + + // The frame can never be narrower than the safe margin, and never so wide that it eats + // the picture — 18% of the short edge on each side is already a very deep mount. + let frame = calc.min(calc.max(sa.safe, short * FRAME-RATIO), short * 0.18) + let gap = short * GAP-RATIO + let lg = logo-reserve(spec, short) + let items = entries(spec) + + // Landscape gets the side composition. The threshold is above 1 on purpose: a 1080x1350 + // ig-post is "wide" only arithmetically, and reads as a portrait. + let landscape = (tw / th) >= 1.2 + + if landscape { + // ---- SIDE: type column left, art right ------------------------------------- + let inner-w = tw - 2 * frame + let col-w = inner-w * COL-RATIO + let art-x = frame + col-w + gap + let art-w = tw - frame - art-x + let art-y = frame + let art-h = th - 2 * frame + + // A logo on the right sits over the artwork; give it a full-height gutter instead. + // Landscape art is wide and shallow, so width is the cheap axis here. + if lg.corner in ("tr", "br") and lg.size > 0pt { + art-w = calc.max(inner-w * 0.30, art-w - lg.size - gap) + } + + // A logo on the left sits in the type column: shorten the column at that end. + let col-y = art-y + let col-h = art-h + if lg.corner == "tl" and lg.size > 0pt { + col-y += lg.size + gap * 0.6 + col-h -= lg.size + gap * 0.6 + } else if lg.corner == "bl" and lg.size > 0pt { + col-h -= lg.size + gap * 0.6 + } + + let plan = budget(items, col-w, gap, calc.max(0pt, col-h)) + // Optically centre the stack in the column: a top-anchored column under a wide + // picture reads as if the type has slipped. + let sy = col-y + calc.max(0pt, (col-h - plan.height) / 2) + + place(top + left, dx: b + art-x, dy: b + art-y, + art-panel(spec, pal, art-w, art-h, short, "left")) + draw-stack(plan, b + frame, b + sy, col-w, left) + } else { + // ---- STACKED: art on top, type band in the bottom margin -------------------- + // A logo in a top corner would land on the picture, so the top frame grows to clear + // it; in a bottom corner it gets its own strip under the type band. + let top-extra = if lg.corner in ("tl", "tr") and lg.size > 0pt { + calc.max(0pt, sa.safe + lg.size + gap * 0.6 - frame) + } else { 0pt } + let bottom-extra = if lg.corner in ("bl", "br") and lg.size > 0pt { + calc.max(0pt, sa.safe + lg.size + gap * 0.6 - frame) + } else { 0pt } + + let art-x = frame + let art-y = frame + top-extra + let art-w = tw - 2 * frame + let band-bottom = th - frame - bottom-extra + + // The band gets what it asks for, capped so the picture keeps its floor. Whatever it + // does not use goes to the artwork, not to slack: a short title means a bigger picture. + let room = band-bottom - art-y - gap - th * ART-MIN + let max-band = calc.min(th * BAND-MAX, calc.max(0pt, room)) + let plan = budget(items, art-w, gap, max-band) + + let band-y = band-bottom - plan.height + let art-h = calc.max(th * 0.12, band-y - gap - art-y) + + place(top + left, dx: b + art-x, dy: b + art-y, + art-panel(spec, pal, art-w, art-h, short, "bottom")) + draw-stack(plan, b + art-x, b + band-y, art-w, left) + } +} + +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- + +#let pal = palette-of(spec) +#let sa = safe-area(spec) + +// `date: none` is the load-bearing line for byte-identical output — without it the PDF +// carries a creation timestamp and every golden-file test is a coin toss. +#set document(date: none) +#set text(lang: "it", fill: pal.ink) +#set par(linebreaks: "optimized") + +#set page( + width: sa.trim-width, + height: sa.trim-height, + margin: 0pt, + bleed: sa.bleed, + // The frame colour IS the page, so it covers the bleed too and the guillotine can land + // anywhere in that 3 mm without exposing white. + fill: pal.bg, + background: compose(spec), + foreground: { + logo-place(spec) + crop-marks(spec) + }, +) + +// The composition lives entirely in `background:`/`foreground:`, which resolve against +// the full bleed page. The body only has to exist so that Typst emits the page. +#v(0pt) diff --git a/templates/hero-bottom.typ b/templates/hero-bottom.typ new file mode 100644 index 0000000..418063c --- /dev/null +++ b/templates/hero-bottom.typ @@ -0,0 +1,352 @@ +// hero-bottom.typ — the default poster layout. +// +// Artwork full-bleed; every block of type in one band across the bottom, over a +// gradient scrim. Title dominant, then date/venue, details small at the foot. It is the +// safest and most reusable of the five layouts, which is why it is the default: it never +// fights the artwork for the middle of the page and it survives any aspect ratio. +// +// COORDINATES (verified against typst 0.15.1, see docs/typst-verified.md): +// * `page(background:)` and `page(foreground:)` resolve their offsets against the FULL +// page INCLUDING bleed — the same origin every mm key of `safe-area()` is measured +// from, so the whole composition lives there; +// * the page BODY with `margin: 0pt` starts at the TRIM instead, which is off by the +// bleed on a print job. The body is therefore left empty. Typst still emits the page. +// * PNG export renders the trim, so the bleed and the crop marks are PDF-only. Nothing +// load-bearing may live outside the safe area. +// +// Layering, bottom to top: page fill -> artwork -> scrim -> type -> crop marks. +// +// The LLM never edits this file. It reads the ResolvedSpec as data and the fixed rules +// below decide the composition. + +#import "lib.typ": * +#let spec = json(sys.inputs.specfile) + +#let pal = palette-of(spec) +#let sa = safe-area(spec) + +// --------------------------------------------------------------------------- +// Tuning +// +// Every constant is a ratio, never a size. The same file has to be right on A3 at +// 300 dpi and on a 1280x720 thumbnail, so nothing here may be expressed in points. +// --------------------------------------------------------------------------- + +/// Trim width/height above which the band splits into two columns. fb-cover (2.47) and +/// yt-thumb (1.78) land above it; a3/a4 (0.71), ig-post (0.80) and ig-story (0.56) below. +#let LANDSCAPE-AT = 1.25 + +/// The band grows to fit its content, but only between these fractions of the trim +/// height. The floor keeps it reading as a band when there are only two blocks; the +/// ceiling stops a seven-block spec from swallowing the artwork. Wide formats get a +/// higher ceiling because a third of 664 px is not a band, it is a caption. +#let BAND-MIN = 0.30 +#let BAND-MAX-TALL = 0.62 +#let BAND-MAX-WIDE = 0.80 + +/// Vertical gap between blocks, as a fraction of the trim's short edge, and the wider +/// step that separates the title group from the informative blocks. That single larger +/// gap is what makes the band read as "headline, then facts" instead of one grey stack. +#let GAP-RATIO = 0.020 +#let GROUP-GAP = 1.9 + +/// Lines the title may claim before the band stops growing for it. Past this the title +/// is narrowed on the `wdth` axis (and only then shrunk) by `fit`, which is the whole +/// point of the auto-fit: a four-line headline is not a headline. +#let TITLE-LINES = 3 + +/// Rough line advance in multiples of the font size. Used only to cap how much room the +/// title may claim — never to lay anything out, which is always done from real metrics. +#let LINE-ADVANCE = 1.32 + +/// Scrim height as a multiple of the band. The kernel's gradient holds its fade back +/// until ~45% so the artwork stays clean, which means the scrim has to start well above +/// the type for the small blocks at the foot to sit on full density. +#let SCRIM-TALL = 1.50 +#let SCRIM-WIDE = 1.30 + +/// Two-column split for landscape formats: title column, gutter, and the rest. +#let COL-SPLIT = 0.60 +#let COL-GUTTER = 0.045 + +// --------------------------------------------------------------------------- +// Blocks -> laid-out items +// --------------------------------------------------------------------------- + +/// Every block that has real text, IN SPEC ORDER, paired with its resolved typography. +/// +/// `hero` marks the first `title` block — the one the band is sized around. A second +/// title block (rare, but the schema allows it) is treated as an ordinary line rather +/// than competing for the same room. Empty and malformed blocks are dropped silently: +/// a missing subtitle means "draw nothing", never a failed render. +#let prepare(spec) = { + let bs = _get(spec, "blocks", ()) + if type(bs) != array { return () } + let items = () + let hero-taken = false + for b in bs { + if type(b) != dictionary { continue } + let raw = _get(b, "text", "") + if type(raw) != str or raw.trim() == "" { continue } + let st = block-style(spec, _get(b, "role", none)) + let hero = st.role == "title" and not hero-taken + if hero { hero-taken = true } + items.push(( + style: st, + // Uppercasing happens here, not in `fit`, so the measured text and the drawn text + // are the same string. CAPS-ONLY families (Bebas Neue, Bungee) are unaffected: + // they draw capitals either way. + body: if st.upper { upper(raw) } else { raw }, + hero: hero, + optional: _get(b, "optional", false) == true, + )) + } + items +} + +/// Roles that belong to the headline group — the left column on a landscape format. +#let HEAD-ROLES = ("title", "subtitle") + +/// Leading gap for each item; the first has none. +#let gaps-for(items, gap) = { + let gs = () + for (i, it) in items.enumerate() { + if i == 0 { + gs.push(0pt) + } else { + let step = items.at(i - 1).style.role in HEAD-ROLES and not (it.style.role in HEAD-ROLES) + gs.push(if step { gap * GROUP-GAP } else { gap }) + } + } + gs +} + +// --------------------------------------------------------------------------- +// Measuring and fitting a column +// +// The band is sized from REAL metrics, not from a guess at how many lines each block +// takes. A one-line date must not reserve two lines' worth of room, or a seven-block +// poster squeezes a title that had space all along. +// --------------------------------------------------------------------------- + +/// Height this block wants at its natural size in a `w`-wide column. Measured with the +/// same paragraph settings and the same (absent) width variation `fit` starts from, so +/// handing `fit` exactly this much room reproduces the measurement instead of shrinking. +/// Must be called inside `context`. +#let natural-h(it, w) = { + let st = it.style + measure(width: w, { + set par(leading: st.leading, linebreaks: "optimized") + text(.._text-args(st.family, st.size, st.weight, st.tracking, none, none), it.body) + }).height +} + +/// Natural heights of a column plus the room it would like: the sum of those heights and +/// the gaps, with the title capped at `TITLE-LINES`. Must be called inside `context`. +#let col-plan(items, w, gap) = { + let hs = items.map(it => natural-h(it, w)) + let need = gaps-for(items, gap).fold(0pt, (a, b) => a + b) + for (i, it) in items.enumerate() { + let h = hs.at(i) + if it.hero { h = calc.min(h, it.style.size * LINE-ADVANCE * TITLE-LINES) } + need += h + } + (hs: hs, need: need) +} + +/// Budget for one candidate stack: the gaps, where the title sits, how much room the +/// other blocks need, and the floor the title is never pushed below. +#let col-budget(items, hs, gap) = { + let gs = gaps-for(items, gap) + let hero-i = items.position(it => it.hero) + let rest = 0pt + for (i, h) in hs.enumerate() { + if hero-i == none or i != hero-i { rest += h } + } + ( + gs: gs, + gaps: gs.fold(0pt, (a, b) => a + b), + hero-i: hero-i, + rest: rest, + // A shade above `fit`'s own floor, so the title reaches its hard minimum only after + // the layout has already given up everything else it could. + floor: if hero-i == none { 0pt } else { items.at(hero-i).style.min-size * 1.15 }, + ) +} + +/// Lay one column into `avail` of vertical room and return it bottom-anchored. +/// +/// The title is what the layout protects: its floor is subtracted first, the other +/// blocks live on what is left. When that does not balance, blocks marked +/// `optional: true` are dropped from the end — never a block the art director did not +/// mark, which is instead squeezed and left to `fit`'s own min-size. +/// Must be called inside `context`. +#let col-render(items, hs, w, avail, gap, align-to) = { + if items.len() == 0 { return none } + + let live = items + let heights = hs + let b = col-budget(live, heights, gap) + + // Drop optional blocks, last first, while the stack cannot balance. + while avail - b.gaps - b.rest < b.floor { + let drop = none + for (i, it) in live.enumerate() { + if it.optional and not it.hero { drop = i } + } + if drop == none { break } + live = live.slice(0, drop) + live.slice(drop + 1) + heights = heights.slice(0, drop) + heights.slice(drop + 1) + b = col-budget(live, heights, gap) + } + + // Whatever is left over goes to the title. `fit` never grows past the ideal size, so a + // generous allocation simply means the title stays full size on fewer lines. + let hero-alloc = avail - b.gaps - b.rest + let squeeze = 1.0 + if b.hero-i != none and hero-alloc < b.floor { + hero-alloc = b.floor + let room = avail - b.gaps - b.floor + squeeze = if b.rest > 0pt { calc.max(0.35, calc.min(1.0, room / b.rest)) } else { 1.0 } + } else if b.hero-i == none and b.rest + b.gaps > avail { + squeeze = calc.max(0.35, (avail - b.gaps) / b.rest) + } + + let out = () + for (i, it) in live.enumerate() { + if i > 0 { out.push(b.gs.at(i)) } + let h = if it.hero { hero-alloc } else { heights.at(i) * squeeze } + // `fit` bisects the width axis before the size, so a long Italian title narrows and + // keeps its optical weight instead of quietly becoming a small title. + out.push(fit( + it.body, w, calc.max(h, 1pt), + it.style.family, it.style.axes, + align-to: align-to, + ..it.style.args, + )) + } + + // Each fitted block occupies its own natural height, so the stack collapses any slack + // and hugs the bottom of the band — the foot of the poster stays a straight edge. + block(width: w, stack(dir: ttb, ..out)) +} + +// --------------------------------------------------------------------------- +// The composition +// --------------------------------------------------------------------------- + +/// Full-bleed artwork, drawn from the full-page origin so it covers the bleed too. +/// `fit: "cover"` crops rather than distorts — a stretched face is worse than a lost +/// corner. NOTE: `art_file` must be root-relative; Typst resolves image paths against +/// `--root`, so the renderer rewrites it (same rule as `logo.path`). +#let artwork(spec) = { + let p = _get(spec, "art_file", none) + if type(p) != str or p.trim() == "" { return none } + place(top + left, image(p, width: sa.full-width, height: sa.full-height, fit: "cover")) +} + +/// Scrim + type. One `context` for the whole thing, because the scrim's height is +/// derived from the band, the band's height from the measured content, and the logo's +/// ceiling from the band: they must all come out of a single measuring pass. +#let composition(spec) = context { + let items = prepare(spec) + if items.len() > 0 { + let gap = sa.short-edge * GAP-RATIO + let landscape = sa.trim-width / sa.trim-height >= LANDSCAPE-AT + + // On a wide format a bottom third is a caption, not a band: the title would have to + // shrink to nothing to leave room for six more lines under it. So the band splits — + // headline left, the facts right-aligned against the safe edge — and both columns + // sit on the same baseline. Portrait and square formats keep the single stack. + let head = items.filter(it => it.style.role in HEAD-ROLES) + let facts = items.filter(it => not (it.style.role in HEAD-ROLES)) + let split = landscape and head.len() > 0 and facts.len() > 0 + + let lw = if split { sa.width * COL-SPLIT } else { sa.width } + let rw = sa.width * (1.0 - COL-SPLIT - COL-GUTTER) + + let left-plan = col-plan(if split { head } else { items }, lw, gap) + let right-plan = if split { col-plan(facts, rw, gap) } else { (hs: (), need: 0pt) } + + // The band is content-driven and then clamped: it never looks thinner than a band, + // never eats more of the artwork than the format can spare, and never spills out of + // the safe area. + let want = calc.max(left-plan.need, right-plan.need) + let band-h = calc.min( + calc.max(want, sa.trim-height * BAND-MIN), + sa.trim-height * (if landscape { BAND-MAX-WIDE } else { BAND-MAX-TALL }), + sa.height, + ) + + // Scrim first, type second: within `foreground` the drawing order is the content + // order. It runs to the physical bottom edge so the wash does not stop at the trim + // on a bled job. Skipped entirely when the renderer measured the artwork behind the + // band as calm and light enough — `needs_scrim` is not ours to second-guess. + if _get(spec, "needs_scrim", false) == true { + let scrim-h = calc.min( + sa.full-height, + band-h * (if landscape { SCRIM-WIDE } else { SCRIM-TALL }) + sa.bleed + sa.safe, + ) + place(bottom + left, scrim(scrim-h, pal.scrim, none)) + } + + // Both columns are bottom-anchored at the safe inset, so the foot of the type is a + // straight line whatever each column ended up containing. + place( + bottom + left, + dx: sa.x, + dy: -(sa.bleed + sa.safe), + col-render(if split { head } else { items }, left-plan.hs, lw, band-h, gap, left), + ) + if split { + place( + bottom + right, + dx: -(sa.bleed + sa.safe), + dy: -(sa.bleed + sa.safe), + col-render(facts, right-plan.hs, rw, band-h, gap, right), + ) + } + + // The logo keeps the corner the art director chose, but the band owns the bottom of + // the page, so a `bl`/`br` logo is lifted to rest just above it. `logo-place` still + // does the sizing and the inset: it is handed a shortened container that ends at the + // band, and its own bottom alignment does the rest. Top corners are unaffected. + let ceiling = calc.max(sa.full-height - band-h - gap, sa.full-height * 0.30) + place(top + left, block(width: sa.full-width, height: ceiling, logo-place(spec))) + } +} + +// --------------------------------------------------------------------------- +// Page +// --------------------------------------------------------------------------- + +// Byte-reproducible output: this is the load-bearing directive, not SOURCE_DATE_EPOCH. +// Without it the golden-file tests compare a timestamp. +#set document(date: none) + +// `lang: "it"` buys Italian hyphenation and quotation conventions for free. The body +// family is only set when the spec names one — `text(font: none)` is an error, and an +// inherited family is a better failure than a dead render. +#let body-font = font-of(spec, "body") +#set text( + lang: "it", + fill: pal.ink, + ..if body-font == none { (:) } else { (font: body-font) }, +) +#set par(linebreaks: "optimized") + +// `bleed:` is what makes Typst write a real PDF TrimBox; `margin: 0pt` because nothing +// flows — every element is placed. `fill` shows through only where the artwork is +// missing or does not cover, which is the one case where the palette background matters. +#set page( + width: sa.trim-width, + height: sa.trim-height, + bleed: sa.bleed, + margin: 0pt, + fill: pal.bg, + background: artwork(spec), + foreground: { + composition(spec) + crop-marks(spec) + }, +) diff --git a/templates/split.typ b/templates/split.typ new file mode 100644 index 0000000..9422637 --- /dev/null +++ b/templates/split.typ @@ -0,0 +1,347 @@ +// split.typ — hard geometric split: artwork owns one half, flat colour the other, +// and every word is typeset on the flat side. +// +// The idea is editorial rather than pictorial. There is no soft blend, no text over a +// photograph, no drop shadow doing the work a scrim should: a single straight seam cuts +// the piece in two, the artwork bleeds off three edges of its half, and the type sits on +// clean `palette.bg` where `ink_resolved` was contrast-checked against a flat colour and +// is therefore actually true. +// +// The split axis follows the aspect, which is the only thing that changes between an A3 +// and a YouTube thumbnail: +// +// portrait / square landscape +// +-------------------+ +---------+---------+ +// | ARTWORK | | | TITLE | +// | | | ARTWORK | date | +// +===================+ seam | | venue | seam is the vertical rule +// | TITLE | | | | +// | date, venue, ... | | | footer | +// +-------------------+ +---------+---------+ +// +// Everything below is a fraction of the TRIM, so the same file is correct at 1080 px and +// at 300 dpi A3, and turning bleed on never changes how big the headline is. + +#import "lib.typ": * +#let spec = json(sys.inputs.specfile) + +#set document(date: none) // load-bearing: this is what makes output byte-identical +#set text(lang: "it") // Italian hyphenation, for free +#set par(linebreaks: "optimized") // Knuth-Plass; `fit` sets it again on its own trials + +// --------------------------------------------------------------------------- +// Geometry +// --------------------------------------------------------------------------- + +#let sa = safe-area(spec) +#let pal = palette-of(spec) +#let trim = trim-mm(spec) + +/// Landscape is the ONLY switch in this file. Square counts as portrait: a 4:5 ig-post +/// reads as an upright piece, so it gets the horizontal seam like A3 does. +#let landscape = trim.width > trim.height + +/// A hard half. This constant is deliberately not spec-driven — "one half" IS the +/// layout, and an art director who wants 60/40 wants `banded`, not `split`. +#let SPLIT = 0.5 + +/// Breathing room between the seam and the first word. Measured off the short edge so +/// the optical distance is the same on every format. +#let gutter = sa.short-edge * 0.045 + +/// Vertical rhythm between stacked blocks, and the spacing unit for the footer line. +#let gap = sa.short-edge * 0.016 + +/// The seam, in FULL-PAGE coordinates (bleed included) — the origin `page(background:)` +/// and `page(foreground:)` resolve against. Along the split axis the artwork runs from +/// the page edge to here, so it covers its own bleed; the panel starts here. +#let seam = sa.bleed + (if landscape { trim.width } else { trim.height }) * SPLIT * 1mm + +/// The panel: the flat half, inset to the safe area, pushed off the seam by the gutter. +/// `calc.max` guards the pathological case of a safe margin wide enough to swallow half +/// the piece — `safe-area` already caps it at 40% of the short edge, but a zero-height +/// box would be a hard error rather than an ugly poster. +#let panel = if landscape { + ( + x: seam + gutter, + y: sa.y, + width: calc.max(1mm, sa.x + sa.width - seam - gutter), + height: sa.height, + ) +} else { + ( + x: sa.x, + y: seam + gutter, + width: sa.width, + height: calc.max(1mm, sa.y + sa.height - seam - gutter), + ) +} + +// --------------------------------------------------------------------------- +// Logo — placed by lib's `logo-place`, but its footprint has to be reserved here +// --------------------------------------------------------------------------- +// +// `logo-place` honours `spec.logo.corner` and works off the same safe rect, so a corner +// that lands on the panel lands exactly on the panel's own edge. We do not move it — +// the art director's corner is respected — we only make room for it, and let the scrim +// cover the case where it was asked for on top of the artwork. + +#let logo-raw = if type(spec) == dictionary { spec.at("logo", default: none) } else { none } +#let logo-path = if type(logo-raw) == dictionary { logo-raw.at("path", default: none) } else { none } +#let has-logo = type(logo-path) == str and logo-path.trim() != "" + +/// Reserved logo width. This mirrors `logo-place`'s own sizing (short edge x clamped +/// scale) because the helper places the image but cannot tell us how much room it took. +#let logo-w = if not has-logo { 0pt } else { + let s = logo-raw.at("scale", default: 0.12) + let s = if type(s) in (int, float) { calc.max(0.02, calc.min(0.4, s)) } else { 0.12 } + sa.short-edge * s +} +#let logo-corner = if has-logo { logo-raw.at("corner", default: "br") } else { none } +#let logo-left = logo-corner in ("tl", "bl") + +/// Which side of the seam the logo fell on. In portrait the panel is the bottom half, so +/// only bl/br touch it; in landscape the panel is the right half, so tr/br do. +#let logo-on-panel-bottom = has-logo and ( + if landscape { logo-corner == "br" } else { logo-corner in ("bl", "br") } +) +#let logo-on-panel-top = has-logo and landscape and logo-corner == "tr" + +/// Vertical reservation. The image's height is unknown at layout time — `logo-place` +/// sizes by width and lets the aspect decide — so we reserve a square. Wider-than-tall +/// wordmarks, which is nearly all of them, simply leave a little air. +#let logo-h = logo-w + +// --------------------------------------------------------------------------- +// Blocks +// --------------------------------------------------------------------------- + +/// How many lines a role is expected to run to. Multiplied by the role's type size this +/// becomes its share of the panel: a title is budgeted three lines of very large type, a +/// price one line of small type, and the ratio between them is what makes the hierarchy +/// survive at every format instead of only at the one it was drawn on. +#let _LINES = ( + title: 3.0, subtitle: 2.0, date: 1.5, venue: 1.5, details: 3.0, price: 1.0, footer: 1.5, +) + +/// Every block, IN SPEC ORDER, with its text resolved. This generalises lib's +/// `block-text` — which returns the first block of a role — to a spec that legitimately +/// repeats a role (two `details` lines, say). Blank and malformed entries are dropped +/// silently: a missing venue means "draw no venue", never an error. +/// +/// Casing comes from the role via `block-style`, never from the glyphs. Bebas Neue and +/// Bungee draw capitals at lowercase codepoints, so a mixed-case subtitle in one of them +/// simply arrives in caps; nothing here reads lowercase shapes as a signal. +#let entries = { + let out = () + let raw = if type(spec) == dictionary { spec.at("blocks", default: ()) } else { () } + if type(raw) != array { raw = () } + for b in raw { + if type(b) != dictionary { continue } + let t = b.at("text", default: "") + if type(t) != str or t.trim() == "" { continue } + let st = block-style(spec, b.at("role", default: none)) + out.push(( + role: st.role, + body: if st.upper { upper(t) } else { t }, + style: st, + optional: b.at("optional", default: false) == true, + weight: st.ratio * _LINES.at(st.role, default: 1.5), + )) + } + out +} + +/// The colophon line. Several footer blocks collapse into one rule-of-thumb line rather +/// than stacking, because the foot of the panel is a single band shared with the logo. +#let foot-body = { + let fs = entries.filter(e => e.role == "footer") + if fs.len() == 0 { none } else { fs.map(e => e.body).join(" · ") } +} +#let foot-style = block-style(spec, "footer") + +/// Height of the bottom band: whichever is taller, the colophon or the logo sharing it. +#let foot-text-h = if foot-body == none { 0pt } else { foot-style.size * 2.2 } +#let foot-h = calc.max(foot-text-h, if logo-on-panel-bottom { logo-h } else { 0pt }) + +/// Reservation above the stack, for the one corner (landscape + `tr`) where the logo +/// would otherwise sit on the headline. +#let head-h = if logo-on-panel-top { logo-h + gap } else { 0pt } + +/// What is left for the type stack. +#let flow-h = calc.max( + 1mm, + panel.height - head-h - foot-h - (if foot-h > 0pt { gap } else { 0pt }), +) + +// --------------------------------------------------------------------------- +// Dropping optional blocks +// --------------------------------------------------------------------------- + +/// The least space a block can occupy and still be worth printing: one line at the floor +/// size `block-style` computed for its role, plus its leading. +#let _min-need(st) = st.min-size * 1.55 + +/// Blocks that go in the stack, after dropping `optional: true` entries that genuinely +/// do not fit. Spec order is priority order (title first, footer last), so the LAST +/// optional block is always the cheapest thing to lose. Note this only fires when the +/// panel cannot hold the blocks at their floor sizes; the ordinary crowding case is +/// handled by `fit`, which narrows the type on the wdth axis long before anything is +/// thrown away. +#let flow = { + let list = entries.filter(e => e.role != "footer") + let needed(l) = { + let s = 0pt + for e in l { s += _min-need(e.style) } + s + gap * calc.max(0, l.len() - 1) + } + while needed(list) > flow-h and list.any(e => e.optional) { + let idx = 0 + for (i, e) in list.enumerate() { if e.optional { idx = i } } + list = list.slice(0, idx) + list.slice(idx + 1) + } + list +} + +/// Split the flow height between the survivors by weight, so the panel is always exactly +/// filled: one lone title gets the whole panel (and `fit` still refuses to grow past its +/// ideal size), while a seven-block spec divides it proportionally and the title keeps +/// roughly half. Because every budget is honoured by `fit`, the stack can never be taller +/// than the panel — the layout has no overflow case. +#let _wsum = flow.fold(0.0, (a, e) => a + e.weight) +#let _inner = calc.max(1mm, flow-h - gap * calc.max(0, flow.len() - 1)) +#let budget(e) = if _wsum <= 0 { _inner } else { _inner * (e.weight / _wsum) } + +// --------------------------------------------------------------------------- +// The three pieces of the composition +// --------------------------------------------------------------------------- + +/// The artwork half, bleeding off the three page edges it touches and stopping dead on +/// the seam. `fit: "cover"` fills the box and crops the overflow, so the art is never +/// distorted whatever aspect the backend produced; `clip` makes the seam a hard edge +/// rather than an overhang. +/// +/// NOTE: `spec.art_file` must reach us ROOT-RELATIVE (leading "/"). Typst resolves image +/// paths against `--root`, not the filesystem root — the renderer rewrites it, exactly as +/// it does for `spec.logo.path`. With no artwork at all we fall back to a flat scrim- +/// coloured half: a poster missing its picture is recoverable, a failed compile is not. +#let art-file = if type(spec) == dictionary { spec.at("art_file", default: none) } else { none } +#let art-w = if landscape { seam } else { sa.full-width } +#let art-h = if landscape { sa.full-height } else { seam } + +#let artwork = place(top + left, dx: 0pt, dy: 0pt, box( + width: art-w, + height: art-h, + clip: true, + if type(art-file) == str and art-file.trim() != "" { + image(art-file, width: 100%, height: 100%, fit: "cover") + } else { + rect(width: 100%, height: 100%, fill: pal.scrim, stroke: none) + }, +)) + +/// The scrim, and the one place it is honest in this template. +/// +/// No text ever sits on the artwork here, so this is not carrying the headline. It is +/// anchored on the OUTER edge of the art half — the top in portrait, the left in +/// landscape — and fades toward the seam, which is precisely where the two art-side +/// corners are. That covers the case the art director asked for: a logo placed `tl`/`tr` +/// (portrait) or `tl`/`bl` (landscape) sits on the picture, and when the renderer has +/// measured that picture as bright or busy (`needs_scrim`) it needs something under it. +/// It also keeps loud artwork from shouting across the seam at the flat panel. +#let needs-scrim = if type(spec) == dictionary { spec.at("needs_scrim", default: false) == true } else { false } + +#let art-scrim = if not needs-scrim { none } else if landscape { + place(top + left, dx: 0pt, dy: 0pt, + scrim(sa.full-height, pal.scrim, 180deg, width: art-w * 0.45, strength: 80%)) +} else { + place(top + left, dx: 0pt, dy: 0pt, + scrim(art-h * 0.45, pal.scrim, 270deg, width: sa.full-width, strength: 80%)) +} + +/// The seam itself: an accent hairline drawn on the PANEL side of the cut, so it always +/// reads against flat `bg` instead of disappearing into whatever the artwork does at its +/// edge. It runs the full page, bleed included — a rule that stopped at the trim would +/// leave a notch after cutting. +#let seam-rule = { + let t = calc.max(0.6pt, sa.short-edge * 0.0025) + if landscape { + place(top + left, dx: seam, dy: 0pt, + rect(width: t, height: sa.full-height, fill: pal.accent, stroke: none)) + } else { + place(top + left, dx: 0pt, dy: seam, + rect(width: sa.full-width, height: t, fill: pal.accent, stroke: none)) + } +} + +// --------------------------------------------------------------------------- +// Type +// --------------------------------------------------------------------------- + +/// The stack, vertically centred in the flow area. Centring rather than top-aligning is +/// what makes a two-block spec look composed instead of abandoned: the panel is often +/// far taller than the words that have to go in it (an ig-story panel is ~156 mm for as +/// little as a title and a date), and a heading pinned to the top of that void reads as +/// a mistake. +#let type-stack = block(width: panel.width, height: flow-h, align(horizon + left, { + let first = true + for e in flow { + if not first { v(gap) } + first = false + let st = e.style + // One line per block, and the whole reason `fit` exists: a long Italian title + // narrows on the wdth axis before it is allowed to shrink, so it keeps filling its + // band. "Sagra della Castagna e dell'Autunno in Piazza" lands on three lines at + // ig-story and stays a headline instead of becoming a caption. + block(width: panel.width, fit(e.body, panel.width, budget(e), st.family, st.axes, ..st.args)) + } +})) + +/// The colophon, pinned to the foot of the panel and pushed clear of the logo when they +/// share that edge. It is aligned AWAY from the logo, so the two read as one line with a +/// hole in the middle rather than as a collision. +#let foot-line = if foot-body == none { none } else { + let reserve = if logo-on-panel-bottom { logo-w + gap } else { 0pt } + let w = calc.max(1mm, panel.width - reserve) + let x = panel.x + (if logo-on-panel-bottom and logo-left { reserve } else { 0pt }) + let a = if logo-on-panel-bottom and logo-left { right } else { left } + place(bottom + left, dx: x, dy: -sa.y, box( + width: w, + fit(foot-body, w, calc.max(foot-text-h, 1mm), foot-style.family, foot-style.axes, + align-to: a, ..foot-style.args), + )) +} + +// --------------------------------------------------------------------------- +// The page +// --------------------------------------------------------------------------- +// +// `width`/`height` are the TRIM and `bleed` is separate, which is what makes Typst write +// a real PDF TrimBox for the copy shop. Everything is drawn in `background`/`foreground` +// because those two resolve their offsets against the FULL page including bleed — the +// same origin `safe-area` reports — while the body would not. The body stays empty. +// +// PNG export renders the trim page, so the bleed art and the crop marks are a PDF-only +// concern; the composition is identical either way because the seam is measured off the +// trim, not off the page. + +#set page( + width: sa.trim-width, + height: sa.trim-height, + margin: 0pt, + bleed: sa.bleed, + fill: pal.bg, + background: { + set text(lang: "it") + set par(linebreaks: "optimized") + artwork + art-scrim + seam-rule + place(top + left, dx: panel.x, dy: panel.y + head-h, type-stack) + foot-line + }, + foreground: { + crop-marks(spec) + logo-place(spec) + }, +) diff --git a/tests/render-fixtures.sh b/tests/render-fixtures.sh index 3bf8035..4855ce9 100755 --- a/tests/render-fixtures.sh +++ b/tests/render-fixtures.sh @@ -5,6 +5,11 @@ # --ignore-system-fonts and every template sets `#set document(date: none)`, # output is byte-identical across runs and machines, so a diff is a real regression. # +# GOTCHA: Typst resolves a leading "/" against --root, NOT the filesystem. So the spec +# path passed via --input must be ROOT-RELATIVE ("/tests/fixtures/x.json"); handing it an +# absolute filesystem path silently produces "/home/you/...", i.e. file not found. +# extensions/imgen/render/typst.ts must obey the same rule. +# # Usage: tests/render-fixtures.sh [path-to-typst] set -uo pipefail cd "$(dirname "$0")/.." @@ -29,7 +34,7 @@ for tpl in "$ROOT"/templates/*.typ; do fxname="$(basename "$fx" .json)" out="$OUT/${name}__${fxname}.pdf" if SOURCE_DATE_EPOCH=0 "$TYPST" compile "$tpl" "$out" \ - --input specfile="$fx" \ + --input specfile="/tests/fixtures/${fxname}.json" \ --font-path "$FONTS" --ignore-system-fonts \ --root "$ROOT" >"$OUT/${name}__${fxname}.log" 2>&1; then pass=$((pass+1)); printf ' ok %s / %s\n' "$name" "$fxname"