From fd39886168fbc1dac23d88ffd8aad10ccdd588f6 Mon Sep 17 00:00:00 2001 From: mozempk Date: Thu, 27 Aug 2026 09:34:11 +0200 Subject: [PATCH] chore: npm scripts for typecheck/test/fonts; pin tsc as a devDependency --- extensions/imgen/commands/social.ts | 69 ++++++++++++++------------ extensions/imgen/ui/strings.ts | 75 +++++++++++++++++++++++++++++ package.json | 25 ++++++++-- templates/centred-stack.typ | 10 +++- templates/framed.typ | 22 ++++++--- templates/split.typ | 12 +++-- 6 files changed, 169 insertions(+), 44 deletions(-) diff --git a/extensions/imgen/commands/social.ts b/extensions/imgen/commands/social.ts index 2c372f0..a690604 100644 --- a/extensions/imgen/commands/social.ts +++ b/extensions/imgen/commands/social.ts @@ -18,7 +18,7 @@ */ import { copyFileSync, existsSync, readFileSync, statSync } from "node:fs"; -import { basename } from "node:path"; +import { basename, join } from "node:path"; import { withFileMutationQueue } from "@earendil-works/pi-coding-agent"; import type { @@ -39,6 +39,7 @@ import type { import { FORMATS_GEOMETRY } from "../render/formats.ts"; import { ART_FILENAME, + CAPTION_FILENAME, JobError, blockText, createJob, @@ -50,6 +51,7 @@ import { normaliseSlug, openInFinder, openJob, + reEditionSlug, saveCaption, saveSpec, updateBlocks, @@ -221,7 +223,8 @@ export async function runSocial( ): Promise { const now = deps.now ?? Date.now; const started = now(); - const formats = [...(req.formats?.length ? req.formats : SOCIAL_FORMATS)]; + const formats = [...(req.formats ?? SOCIAL_FORMATS)]; + if (formats.length === 0) throw new Error(errorText(S.errors.exportFailed(T.nothingRendered))); const warnings: string[] = []; const slug = normaliseSlug(req.slug); @@ -241,7 +244,8 @@ export async function runSocial( let forked = false; if (changed && req.fork !== false) { - const editionSlug = reEditionSlugFor(patched, req.newDate ?? blockText(spec, "date")); + // `sagra-castagna-12-set` becomes `sagra-castagna-11-set`: same title, new date. + const editionSlug = reEditionSlug(patched, req.newDate ?? blockText(spec, "date") ?? null); target = createJob(deps.config, editionSlug); forked = true; @@ -300,13 +304,10 @@ export async function runSocial( let caption: CaptionResult | undefined; let captionPath: string | undefined; if (req.caption) { - if (!deps.director) throw new Error(errorText(S.errors.directorUnavailable)); req.onProgress?.(S.progress.caption); - caption = await deps.director.generateCaption(patched, briefFromSpec(patched), ctx, deps.config); - captionPath = await withFileMutationQueue( - `${target.dir}/${"caption.txt"}`, - async () => saveCaption(target, caption!.full), - ); + const written = await writeSocialCaption(target.slug, deps, ctx); + caption = written.caption; + captionPath = written.path; } return { @@ -323,6 +324,26 @@ export async function runSocial( }; } +/** + * The caption. Called ONLY after he has answered yes — never on our own initiative, and + * never as a rewrite of copy he wrote himself. This is the one call in the file that + * reaches a model. + */ +export async function writeSocialCaption( + slug: string, + deps: SocialDeps, + ctx: DirectorContext = {}, +): Promise<{ path: string; caption: CaptionResult }> { + if (!deps.director) throw new Error(errorText(S.errors.directorUnavailable)); + const job = openJob(deps.config, normaliseSlug(slug)); + const spec = loadSpec(job); + const caption = await deps.director.generateCaption(spec, briefFromSpec(spec), ctx, deps.config); + const path = await withFileMutationQueue(join(job.dir, CAPTION_FILENAME), async () => + saveCaption(job, caption.full), + ); + return { path, caption }; +} + /** A Brief reconstructed from a spec — everything the caption writer needs, no model. */ export function briefFromSpec(spec: DesignSpec): Brief { return { @@ -431,19 +452,16 @@ async function socialCommand( if (wants) { ctx.ui.setStatus(STATUS_KEY, S.progress.caption); try { - const withCaption = await runSocial( - { slug: result.slug, formats: [], caption: true, onProgress: () => {} }, - deps, - ctx, - ); - result.captionPath = withCaption.captionPath; - result.caption = withCaption.caption; - if (withCaption.caption) { - say(pi, [S.caption.ready, "", withCaption.caption.full, "", S.caption.hashtagsNote].join("\n")); - say(pi, S.caption.savedTo); - } + // Only the caption is regenerated: the images are already on disk. + const written = await writeSocialCaption(result.slug, deps, ctx); + result.captionPath = written.path; + result.caption = written.caption; + result.usedModel = true; + say(pi, [S.caption.ready, "", written.caption.full, "", S.caption.hashtagsNote].join("\n")); + say(pi, S.caption.savedTo); } catch (e) { - ctx.ui.notify(messageOf(e), "warning"); + // A failed caption must not lose the images that already succeeded. + ctx.ui.notify(italianOf(e), "warning"); } } else { say(pi, S.caption.declined); @@ -711,15 +729,6 @@ function titleOf(deps: SocialDeps, slug: string): string | undefined { } } -/** - * Slug for the new edition. Uses job.ts's helper, which keeps the title and swaps the - * date part — `sagra-castagna-12-set` becomes `sagra-castagna-11-set`. - */ -function reEditionSlugFor(spec: DesignSpec, date: string | undefined): string { - // Imported lazily by name to keep the dependency obvious at the call site. - return reEdition(spec, date ?? null); -} - function emptySummary(slug: string): JobSummary { return { slug, diff --git a/extensions/imgen/ui/strings.ts b/extensions/imgen/ui/strings.ts index dcf49e2..36a7547 100644 --- a/extensions/imgen/ui/strings.ts +++ b/extensions/imgen/ui/strings.ts @@ -265,6 +265,76 @@ export const S = { deleted: "Ho cancellato lo stile «{nome}».", applied: "Sto usando lo stile «{nome}».", defaultSet: "D'ora in poi parto da «{nome}».", + + // --- /presets: il gestore degli stili salvati --- + intro: "Uno stile salvato è l'insieme di colori, carattere e logo che riusi ogni volta.", + actions: { + list: "Guarda quelli che hai", + create: "Crea uno stile nuovo", + edit: "Modifica uno stile", + remove: "Cancella uno stile", + setDefault: "Scegli da quale partire", + close: "Chiudi", + }, + order: ["list", "create", "edit", "remove", "setDefault", "close"] as const, + pick: "Quale stile?", + listHeader: "I tuoi stili salvati:", + listEmpty: "Non hai ancora salvato nessuno stile.", + listHint: "Scrivi /presets e scegli «Crea uno stile nuovo».", + isDefault: "— parto da questo", + detailTitle: "Lo stile «{nome}»", + fieldLabels: { + palette: "Colori", + fonts: "Carattere", + logo: "Logo", + tone: "Descrizione", + }, + moodQuestion: "Che aria deve avere?", + moodKeep: "Lascia com'è", + fontsQuestion: "Che carattere usiamo?", + fontsKeep: "Quello che viene con l'aria che hai scelto", + coloursQuestion: "Vuoi mettere i colori tuoi?", + colourAsk: "{campo} — scrivilo così: #B4121F", + colourKeep: "Lascia vuoto per tenere quello che c'è.", + colourFields: { + bg: "Sfondo", + ink: "Testo", + accent: "Colore in evidenza", + scrim: "Velo dietro le scritte", + }, + colourInvalid: "«{valore}» non è un colore che so leggere: ci vuole qualcosa come #1A2B3C.", + colourAdjusted: "Ho ritoccato qualche colore perché le scritte restassero leggibili:", + logoQuestion: "Dov'è il file del logo?", + logoPlaceholder: "~/Immagini/logo-band.png", + logoHint: "Trascina qui il file. Lascia vuoto se non hai un logo.", + logoNone: "Nessun logo", + logoNotAFile: "«{percorso}» non è un file: controlla il percorso.", + logoBadType: "Il logo dev'essere un PNG, un JPG o un SVG.", + toneQuestion: "Come lo descriveresti in due parole?", + tonePlaceholder: "caldo, artigianale, un po' anni Settanta", + toneHint: "Mi serve per dare il tono alle immagini. Puoi lasciarlo vuoto.", + summaryTitle: "Ecco com'è «{nome}»", + confirmSave: "Lo salvo?", + defaultQuestion: "Vuoi che d'ora in poi parta da questo?", + defaultNone: "Nessuno: ogni volta parto da zero.", + defaultCleared: "Va bene: d'ora in poi parto da zero ogni volta.", + editWhat: "Cosa cambio di «{nome}»?", + editFields: { + label: "Il nome", + mood: "L'aria: colori e carattere insieme", + colours: "Solo i colori", + fonts: "Solo il carattere", + logo: "Il logo", + tone: "La descrizione", + done: "Ho finito", + }, + editOrder: ["label", "mood", "colours", "fonts", "logo", "tone", "done"] as const, + updated: "Ho aggiornato lo stile «{nome}».", + nameMissing: "Mi serve un nome, altrimenti non so come richiamarlo.", + nameBad: "Quel nome non va bene: usa lettere e numeri, per esempio «La mia band».", + nothingChanged: "Non ho cambiato niente.", + savedWhere: "L'ho scritto nelle tue impostazioni: {dove}", + needsTerminal: "Per creare o modificare uno stile mi serve il terminale: apri pi e scrivi /presets.", }, progress: { @@ -537,6 +607,11 @@ export const S = { fix: "Scrivi /presets per vedere quelli che hai.", detail: name, }), + configWriteFailed: (path: string, detail?: string): ErrorMessage => ({ + message: "Non riesco a salvare le tue impostazioni.", + fix: fill("Controlla di poter scrivere in {dove}.", { dove: path }), + detail, + }), configBroken: (path: string, detail?: string): ErrorMessage => ({ message: "Le tue impostazioni non si leggono, così vado avanti con quelle standard.", fix: fill("Se vuoi sistemarle, il file è {dove}.", { dove: path }), diff --git a/package.json b/package.json index 66cf114..b52afa9 100644 --- a/package.json +++ b/package.json @@ -2,14 +2,22 @@ "name": "pi-imgen", "version": "0.1.0", "description": "Genera locandine, loghi e immagini per eventi - pi extension (local, Apple Silicon)", - "keywords": ["pi-package", "pi-extension", "image-generation", "poster", "typst"], + "keywords": [ + "pi-package", + "pi-extension", + "image-generation", + "poster", + "typst" + ], "license": "MIT", "repository": { "type": "git", "url": "https://git.sal.giize.com/mozempk/pi-imgen.git" }, "pi": { - "extensions": ["./extensions/imgen"] + "extensions": [ + "./extensions/imgen" + ] }, "type": "module", "dependencies": { @@ -22,5 +30,16 @@ "@earendil-works/pi-tui": "*", "typebox": "*" }, - "engines": { "node": ">=20" } + "engines": { + "node": ">=20" + }, + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json", + "test": "tests/render-fixtures.sh", + "fonts": "scripts/fetch-fonts.sh" + }, + "devDependencies": { + "typescript": "^5.6.0", + "@types/node": "^20.14.0" + } } diff --git a/templates/centred-stack.typ b/templates/centred-stack.typ index 3ae2a67..ccbf23b 100644 --- a/templates/centred-stack.typ +++ b/templates/centred-stack.typ @@ -183,8 +183,14 @@ // The footer band, reserved out of the stack's height before anything is measured. #let footer-style = block-style(spec, "footer") +#let footer-line-box = lines-height(footer-style, _LINES.footer) +// Glyphs paint a little past their line box — «@», an accented capital, a descender in a +// mixed-case credit line — so the footer sits one small allowance ABOVE the safe line +// instead of exactly on it. Measured: without it "INFO@EXAMPLE.IT" put 0.5 mm of ink +// below the safe edge on A3. +#let footer-descender = footer-style.size * 0.20 #let footer-band = if footers.len() == 0 { 0pt } else { - lines-height(footer-style, _LINES.footer) + gap * 1.2 + footer-line-box + gap * 1.2 + footer-descender } #let avail = sa.height - 2 * lift - footer-band @@ -360,7 +366,7 @@ dy: sa.safe + sa.height - footer-band, box( width: footer-width, - height: footer-band, + height: footer-band - footer-descender, align(center + bottom, footer-stack), ), ) diff --git a/templates/framed.typ b/templates/framed.typ index 9ec509b..4e10cf8 100644 --- a/templates/framed.typ +++ b/templates/framed.typ @@ -74,6 +74,15 @@ #let GAP-FLOOR = 0.45 #let MINOR-FLOOR = 0.70 +/// Width of the coloured frame: the margin between the trim and everything inside it. +/// Never narrower than the safe margin, never so wide that it eats the picture — 18% of +/// the short edge per side is already a very deep mount. The logo is aligned to this +/// same edge, which is why it is a function rather than a local. +#let frame-of(spec) = { + let sa = safe-area(spec) + calc.min(calc.max(sa.safe, sa.short-edge * FRAME-RATIO), sa.short-edge * 0.18) +} + // --------------------------------------------------------------------------- // Blocks // --------------------------------------------------------------------------- @@ -393,9 +402,7 @@ let tw = sa.trim-width let th = sa.trim-height - // The frame is never narrower than the safe margin and never so wide that it eats the - // picture — 18% of the short edge per side is already a very deep mount. - let frame = calc.min(calc.max(sa.safe, short * FRAME-RATIO), short * 0.18) + let frame = frame-of(spec) let gap = short * GAP-RATIO let lg = logo-reserve(spec, short) let items = entries(spec) @@ -442,7 +449,7 @@ // 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 beneath the type band, which is how // civic posters carry their patron's mark anyway. - let clearance = calc.max(0pt, sa.safe + lg.size + gap * 0.6 - frame) + let clearance = lg.size + gap * 0.6 let top-extra = if lg.corner in ("tl", "tr") { clearance } else { 0pt } let bottom-extra = if lg.corner in ("bl", "br") { clearance } else { 0pt } @@ -454,7 +461,7 @@ // The band gets what it asks for, capped so the picture keeps its floor. What it does // not use goes to the artwork rather than to slack: a short title means a big picture. let room = band-bottom - art-y - gap - th * ART-MIN - let max-band = calc.min(th * BAND-MAX, calc.max(0pt, room)) + let max-band = calc.max(th * 0.12, calc.min(th * BAND-MAX, room)) let plan = budget(items, art-w, gap, max-band) let band-y = band-bottom - plan.height @@ -489,7 +496,10 @@ fill: pal.bg, background: compose(spec), foreground: { - logo-place(spec) + // Aligned to the frame's inner edge, not to the safe corner: the logo then shares a + // margin with the picture and the type instead of hanging out towards the trim. The + // composition reserves exactly this much room for it. + logo-place(spec, margin: sa.bleed + frame-of(spec)) crop-marks(spec) }, ) diff --git a/templates/split.typ b/templates/split.typ index 1ad4650..3f55f8f 100644 --- a/templates/split.typ +++ b/templates/split.typ @@ -3,9 +3,15 @@ // // 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 piece in two, the artwork bleeds off three edges of its half, and every word sits +// on flat `palette.bg`. +// +// That last point decides the colour handling. The ink the renderer measured +// (`ink_on_art` / legacy `ink_resolved`) was chosen against the PICTURE, and no type +// here ever touches the picture — so every call passes `surface: "bg"` and lets +// `palette-of` / `block-style` re-derive the ink and the accent by contrast against the +// flat half. Inheriting an ink picked for a photograph is the one colour bug this layout +// is uniquely exposed to. // // The split axis follows the aspect, which is the only thing that changes between an A3 // and a YouTube thumbnail: