docs: README (Italian) and MIT licence with font carve-out

This commit is contained in:
mozempk
2026-08-27 09:06:58 +02:00
parent 2f7db0e4fd
commit d2f3d9fda2
6 changed files with 2549 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
MIT License
Copyright (c) 2026 mozempk
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Note: bundled fonts under vendor/fonts/ are NOT covered by this licence. Each
retains its own (SIL Open Font License 1.1 or Apache 2.0); see THIRD-PARTY-FONTS.md
and the LICENSE file shipped alongside each family.
+87
View File
@@ -0,0 +1,87 @@
# pi-imgen
Genera **locandine, loghi e immagini per i social** per eventi, direttamente da
[pi](https://pi.dev). Tutto in locale, senza abbonamenti a servizi di immagini.
Pensato per chi organizza eventi e non è un grafico: rispondi a qualche domanda in
italiano e ottieni i file finiti, incluso il **PDF pronto per la tipografia**.
```
/poster una locandina completa, dal titolo al PDF per la stampa
/logo un marchio semplice, con sfondo trasparente e SVG
/social le stesse grafiche in tutti i formati social
/retouch ritocca un'immagine che hai già
/presets salva i tuoi colori, caratteri e logo
```
## Come funziona
Il trucco è **non far scrivere il testo al modello di immagini**. I modelli di
diffusione sbagliano le lettere, e sbagliano quasi sempre le accentate italiane.
```
brief ──► direttore artistico (LLM) ──► modello locale ──► impaginazione (Typst) ──► export
palette, caratteri, SOLO l'immagine, caratteri veri, PDF A3
impaginazione, prompt nessuna scritta accenti giusti, IG post
data e luogo esatti IG story
```
Il modello dipinge soltanto l'illustrazione. Il testo viene composto dopo, con caratteri
veri. Due conseguenze pratiche:
- **à è é ì ò ù, date e nomi dei luoghi sono sempre corretti.** Non sono pixel, sono dati.
- **Cambiare un colore o un carattere è istantaneo.** Non serve rigenerare l'immagine:
si ricompone soltanto il testo, in meno di un secondo.
## Requisiti
- **macOS su Apple Silicon.** Sviluppato per un Mac mini M4 base con 16 GB.
- [`draw-things-cli`](https://github.com/drawthingsai/draw-things-community) e
[`typst`](https://typst.app) — installati da `install.sh`.
- Spazio per i modelli (~10 GB). Possono stare su un **disco esterno**: il percorso è
configurabile e non deve stare nella home.
- Un modello di testo per il direttore artistico, tramite un provider già configurato in
pi. Nessun servizio di generazione immagini è richiesto o usato.
## Installazione
```bash
pi install git:git.sal.giize.com/mozempk/pi-imgen
cd ~/.pi/agent/git/git.sal.giize.com/mozempk/pi-imgen && ./install.sh
```
`install.sh` è ri-eseguibile: chiede dove tenere i modelli e dove salvare i file,
scarica i caratteri, e scrive `~/.pi/agent/pi-imgen.json`. Se lo rilanci, ripara invece
di duplicare, e **non sovrascrive i tuoi preset**.
Per aggiornare: `pi update --extensions`.
## Configurazione
Tutto ciò che è specifico della tua macchina sta in `~/.pi/agent/pi-imgen.json` — mai nel
repository. Percorso dei modelli, cartella di destinazione, e i tuoi preset di marca
(logo, colori, caratteri) restano sul tuo computer.
## Stampa
L'output per la tipografia è un PDF singolo A3 o A4 con **3 mm di abbondanza** e un
**TrimBox** corretto, caratteri incorporati, in RGB. Va bene per la grande maggioranza
delle copisterie. I crocini di taglio sono disattivati di default: i servizi di stampa
online li rifiutano, le tipografie tradizionali li chiedono — si attivano dalla
configurazione.
## Sotto il cofano
| Pezzo | Scelta |
|---|---|
| Generazione | `draw-things-cli` + `gRPCServerCLI` residente (modello caldo per le bozze) |
| Modello | Z-Image Turbo (Apache-2.0) — piccolo, veloce, e il migliore fra gli open sul testo |
| Impaginazione | Typst 0.15 — un solo binario, PDF di stampa nativo, sillabazione italiana |
| Caratteri | 22 famiglie OFL/Apache incluse nel pacchetto ([THIRD-PARTY-FONTS.md](THIRD-PARTY-FONTS.md)) |
Dettagli verificati sperimentalmente in [`docs/typst-verified.md`](docs/typst-verified.md).
## Licenza
MIT per il codice. I caratteri inclusi restano sotto le loro licenze originali (OFL 1.1 o
Apache 2.0), elencate in `THIRD-PARTY-FONTS.md` con il file di licenza di ciascuno.
File diff suppressed because it is too large Load Diff
+811
View File
@@ -0,0 +1,811 @@
/**
* Lifecycle for the resident `gRPCServerCLI-macOS` daemon.
*
* WHY A DAEMON AT ALL: `draw-things-cli` has no daemon mode — every invocation reloads
* the model from disk. The resident gRPC server keeps weights warm, which is the whole
* difference between a usable draft loop and a 40s-per-thumbnail one. The CLI talks to
* it with `--remote --remote-url 127.0.0.1 --remote-port N --no-remote-tls`
* (see `remoteFlags()` — the single place that knowledge lives).
*
* HARD RULE: exactly ONE daemon per machine. Two diffusion processes will OOM a 16GB
* Mac mini before either finishes a VAE decode. Every path here therefore prefers
* ADOPTING whatever already listens on the port over spawning a rival.
*
* pi RULE: nothing in this module runs at import time. `start()` is called from
* session_start, `stop()` from session_shutdown, and both are safe to call twice.
*/
import { spawn } from "node:child_process";
import { createWriteStream, constants as FS } from "node:fs";
import {
access,
chmod,
mkdir,
open,
readFile,
rename,
rm,
stat,
unlink,
writeFile,
} from "node:fs/promises";
import { createConnection } from "node:net";
import { arch, platform } from "node:process";
import { dirname, join } from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { setTimeout as delay } from "node:timers/promises";
import type { ImgenConfig } from "../config.ts";
import { BackendError, type ProgressFn } from "./types.ts";
/* -------------------------------------------------------------------------- */
/* Constants */
/* -------------------------------------------------------------------------- */
/**
* Pinned release. gRPCServerCLI ships as a GitHub RELEASE ASSET only — it is NOT in the
* `draw-things-cli` Homebrew formula, so `brew install draw-things-cli` gives you the
* client and nothing else.
*/
export const GRPC_SERVER_VERSION = "v1.20260716.0";
export const GRPC_SERVER_BINARY = "gRPCServerCLI-macOS";
export const GRPC_SERVER_URL =
`https://github.com/drawthingsai/draw-things-community/releases/download/${GRPC_SERVER_VERSION}/${GRPC_SERVER_BINARY}`;
/** Subdirectory of the pi config dir holding log, pid and lock files. */
const STATE_DIR = "imgen";
const LOG_NAME = "server.log";
const PID_NAME = "server.pid";
const LOCK_NAME = "server.lock";
/** Rotate at 2 MiB, keep 3 old generations. The daemon is chatty about model loads. */
const MAX_LOG_BYTES = 2 * 1024 * 1024;
const LOG_GENERATIONS = 3;
/** A start lock older than this is assumed to be from a crashed session. */
const LOCK_STALE_MS = 60_000;
const DEFAULT_READY_TIMEOUT_MS = 60_000;
const DEFAULT_GRACE_MS = 5_000;
const POLL_INTERVAL_MS = 250;
const CONNECT_TIMEOUT_MS = 750;
/** Mach-O / universal-binary magic numbers, read big-endian. Guards against a
* download that is really an HTML error page or a Git LFS pointer. */
const MACHO_MAGIC = new Set([0xfeedfacf, 0xcffaedfe, 0xfeedface, 0xcefaedfe, 0xcafebabe, 0xbebafeca]);
/* -------------------------------------------------------------------------- */
/* Public types */
/* -------------------------------------------------------------------------- */
/**
* How the daemon on our port came to be.
* - `spawned` — this process started it; ours to kill.
* - `adopted-pidfile` — a previous pi session started it and left a pid file; ours.
* - `adopted-foreign` — something else listens there (the Draw Things GUI, a manual
* run). Usable, but `stop()` leaves it alone unless forced.
*/
export type Ownership = "spawned" | "adopted-pidfile" | "adopted-foreign";
export interface ServerStatus {
/** Something accepts TCP on the configured port. */
running: boolean;
port: number;
ownership: Ownership | null;
pid: number | null;
binary: string;
logFile: string;
/** True when config.server.enabled is false — the daemon is deliberately off. */
disabled: boolean;
}
export interface StartResult {
status: ServerStatus;
/** What `start()` actually did. `disabled` and `already-running` do no work. */
action: "spawned" | "adopted" | "already-running" | "disabled";
/** Human-visible, Italian. Safe to show in a session banner. */
message: string;
}
export interface StopResult {
stopped: boolean;
/** Italian. Explains a `stopped: false` (nothing running / not ours). */
message: string;
}
export interface HealthReport {
ok: boolean;
/** All Italian; ready to print under `imgen doctor`. */
problems: string[];
status: ServerStatus;
}
export interface EnsureBinaryOptions {
/**
* Downloads are NEVER silent: the caller must pass `true` after asking the user.
* With `false` a missing binary raises a BackendError explaining how to get it.
*/
allowDownload?: boolean;
onProgress?: ProgressFn;
signal?: AbortSignal;
}
export interface EnsureBinaryResult {
path: string;
action: "present" | "downloaded";
bytes?: number;
}
export interface StartOptions {
/** Total budget for spawn + first TCP accept. */
readyTimeoutMs?: number;
/** Passed through to `ensureBinary()`. Default false: never download unasked. */
allowDownload?: boolean;
onProgress?: ProgressFn;
}
/* -------------------------------------------------------------------------- */
/* Small helpers */
/* -------------------------------------------------------------------------- */
/**
* CLI flags that point `draw-things-cli` at the warm daemon. Exported so the backend
* never has to re-derive them (and never forgets `--no-remote-tls`, which is required
* because we start the server with `--no-tls`).
*/
export function remoteFlags(cfg: ImgenConfig, host = "127.0.0.1"): string[] {
return [
"--remote",
"--remote-url", host,
"--remote-port", String(cfg.server.port),
"--no-remote-tls",
];
}
/** Resolved on-disk locations for a given config. Pure; touches nothing. */
export function serverPaths(cfg: ImgenConfig, piConfigDir: string): {
stateDir: string;
binary: string;
logFile: string;
pidFile: string;
lockFile: string;
} {
const stateDir = join(piConfigDir, STATE_DIR);
return {
stateDir,
// A configured binary wins; otherwise we keep our own copy beside the state files
// rather than writing into a Homebrew prefix we do not own.
binary: cfg.server.binary ?? join(stateDir, GRPC_SERVER_BINARY),
logFile: join(stateDir, LOG_NAME),
pidFile: join(stateDir, PID_NAME),
lockFile: join(stateDir, LOCK_NAME),
};
}
/** True if a TCP connection to the port is accepted. Never throws. */
export function isPortOpen(port: number, host = "127.0.0.1", timeoutMs = CONNECT_TIMEOUT_MS): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const socket = createConnection({ port, host });
let settled = false;
const finish = (ok: boolean): void => {
if (settled) return;
settled = true;
socket.destroy();
resolve(ok);
};
socket.setTimeout(timeoutMs);
socket.once("connect", () => finish(true));
socket.once("timeout", () => finish(false));
socket.once("error", () => finish(false));
});
}
/** `kill(pid, 0)`: EPERM means alive but not ours, which still counts as alive. */
function pidAlive(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 1) return false;
try {
process.kill(pid, 0);
return true;
} catch (e) {
return (e as NodeJS.ErrnoException).code === "EPERM";
}
}
async function exists(p: string): Promise<boolean> {
try {
await access(p, FS.F_OK);
return true;
} catch {
return false;
}
}
async function isExecutable(p: string): Promise<boolean> {
try {
await access(p, FS.X_OK);
return (await stat(p)).isFile();
} catch {
return false;
}
}
interface PidRecord {
pid: number;
port: number;
binary: string;
startedAt: string;
}
async function readPidFile(pidFile: string): Promise<PidRecord | null> {
try {
const parsed: unknown = JSON.parse(await readFile(pidFile, "utf8"));
if (typeof parsed !== "object" || parsed === null) return null;
const rec = parsed as Partial<PidRecord>;
if (typeof rec.pid !== "number" || typeof rec.port !== "number") return null;
return {
pid: rec.pid,
port: rec.port,
binary: typeof rec.binary === "string" ? rec.binary : "",
startedAt: typeof rec.startedAt === "string" ? rec.startedAt : "",
};
} catch {
return null;
}
}
/** Rotate server.log -> .1 -> .2 -> .3 once it grows past MAX_LOG_BYTES. */
async function rotateLog(logFile: string): Promise<void> {
try {
const st = await stat(logFile);
if (st.size < MAX_LOG_BYTES) return;
} catch {
return; // no log yet
}
await rm(`${logFile}.${LOG_GENERATIONS}`, { force: true });
for (let i = LOG_GENERATIONS - 1; i >= 1; i--) {
try {
await rename(`${logFile}.${i}`, `${logFile}.${i + 1}`);
} catch { /* generation absent */ }
}
try {
await rename(logFile, `${logFile}.1`);
} catch { /* raced with another session; harmless */ }
}
/* -------------------------------------------------------------------------- */
/* DrawThingsServer */
/* -------------------------------------------------------------------------- */
/**
* One instance per pi session. Construct it in session_start, keep it on the extension
* state, call `stop()` in session_shutdown. Construction itself does no I/O.
*/
export class DrawThingsServer {
readonly port: number;
private readonly paths: ReturnType<typeof serverPaths>;
private readonly cfg: ImgenConfig;
/** Set only when THIS process spawned the daemon. */
private childPid: number | null = null;
private ownership: Ownership | null = null;
/** In-process de-duplication: concurrent `start()` calls share one attempt. */
private starting: Promise<StartResult> | null = null;
/** Flipped by the child's own `exit` event so `waitUntilReady` can fail fast. */
private childExit: { code: number | null; signal: NodeJS.Signals | null } | null = null;
constructor(cfg: ImgenConfig, piConfigDir: string) {
this.cfg = cfg;
this.port = cfg.server.port;
this.paths = serverPaths(cfg, piConfigDir);
}
get binaryPath(): string { return this.paths.binary; }
get logFile(): string { return this.paths.logFile; }
/* ---------------------------------------------------------------- status */
/** Is a daemon accepting connections on our port? Says nothing about ownership. */
async isRunning(): Promise<boolean> {
return isPortOpen(this.port);
}
async status(): Promise<ServerStatus> {
const running = await this.isRunning();
let ownership = this.ownership;
let pid = this.childPid;
if (running && ownership === null) {
const rec = await readPidFile(this.paths.pidFile);
if (rec && rec.port === this.port && pidAlive(rec.pid)) {
ownership = "adopted-pidfile";
pid = rec.pid;
} else {
ownership = "adopted-foreign";
}
}
if (!running) { ownership = null; pid = null; }
return {
running,
port: this.port,
ownership,
pid,
binary: this.paths.binary,
logFile: this.paths.logFile,
disabled: !this.cfg.server.enabled,
};
}
/**
* Health check for `imgen doctor` and for session_start. Never throws.
*
* NOTE: readiness is a TCP accept, nothing more. Without the FlatBuffer schema we
* cannot speak gRPC to ask the daemon anything. UNVERIFIED: whether the daemon binds
* the port before or after it finishes scanning the models directory — in practice it
* binds early and loads weights lazily per request, so a first generation after a
* cold start is still slow. Treat "porta aperta" as "reachable", not "warm".
*/
async health(): Promise<HealthReport> {
const problems: string[] = [];
// Only environment/binary faults are fatal. A daemon that is merely off is a
// slowdown, not a failure: draw-things-cli still runs cold.
let fatal = false;
const fail = (msg: string): void => { fatal = true; problems.push(msg); };
const status = await this.status();
if (platform !== "darwin") {
fail("Il server Draw Things gira solo su macOS (Apple Silicon).");
} else if (arch !== "arm64") {
fail("Serve un Mac Apple Silicon (arm64): non esiste una build Intel del server.");
}
if (!(await exists(this.paths.binary))) {
fail(`Binario del server assente: ${this.paths.binary}. Scaricalo con \`imgen setup --download\` (${GRPC_SERVER_URL}).`);
} else if (!(await isExecutable(this.paths.binary))) {
fail(`Binario del server non eseguibile: ${this.paths.binary} (manca il permesso di esecuzione).`);
}
if (!(await exists(this.cfg.modelsPath))) {
fail(`Cartella dei modelli inesistente: ${this.cfg.modelsPath}.`);
}
if (status.disabled) {
problems.push("Server residente disattivato in configurazione: ogni generazione ricaricherà il modello da zero.");
} else if (!status.running) {
problems.push(`Nessun server in ascolto sulla porta ${this.port}: le generazioni partiranno a freddo.`);
} else if (status.ownership === "adopted-foreign") {
problems.push(
`Sulla porta ${this.port} risponde un processo che non abbiamo avviato noi: lo useremo così com'è e non lo fermeremo.`,
);
}
return { ok: !fatal, problems, status };
}
/* ----------------------------------------------------------------- start */
/**
* Idempotent. Adopts an existing listener, otherwise spawns one detached.
* Returns rather than throws for the "already fine" cases; throws BackendError only
* when it genuinely could not get a daemon up.
*/
async start(opts: StartOptions = {}): Promise<StartResult> {
if (this.starting) return this.starting;
this.starting = this.startOnce(opts).finally(() => { this.starting = null; });
return this.starting;
}
private async startOnce(opts: StartOptions): Promise<StartResult> {
if (!this.cfg.server.enabled) {
return {
status: await this.status(),
action: "disabled",
message: "Server residente disattivato in configurazione: si procede senza modello caldo.",
};
}
// Fast path: someone is already there. Adopt, never duplicate.
if (await this.isRunning()) return this.adoptResult("already-running");
if (platform !== "darwin") {
throw new BackendError(
"gRPCServerCLI requires macOS",
"Il server Draw Things gira solo su macOS: qui non può essere avviato.",
`platform=${platform} arch=${arch}`,
);
}
await mkdir(this.paths.stateDir, { recursive: true });
const binary = (await this.ensureBinary({
allowDownload: opts.allowDownload ?? false,
onProgress: opts.onProgress,
})).path;
// Cross-session lock: two pi sessions opening at once must not both spawn.
const lockHeld = await this.acquireLock();
try {
// Re-check under the lock: the other session may have just won the race.
if (await this.isRunning()) return this.adoptResult("already-running");
if (!lockHeld) {
// Someone else holds a fresh lock and is mid-spawn. Wait for their daemon.
opts.onProgress?.("Un'altra sessione sta avviando il server, attendo…");
const ready = await this.waitUntilReady(opts.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS);
if (ready) return this.adoptResult("adopted");
throw new BackendError(
"another session holds the start lock but no server appeared",
`Un'altra sessione risulta stia avviando il server, ma la porta ${this.port} non ha mai risposto. ` +
`Se il problema persiste elimina ${this.paths.lockFile}.`,
);
}
await rotateLog(this.paths.logFile);
const args = this.spawnArgs();
const logFd = await open(this.paths.logFile, "a");
this.childExit = null;
const child = spawn(binary, args, {
detached: true, // own process group: survives the terminal, and
stdio: ["ignore", logFd.fd, logFd.fd], // lets us signal the whole group on stop
});
child.once("error", (err) => {
this.childExit = { code: -1, signal: null };
// Surfaced through waitUntilReady's log tail; nothing to do here.
void err;
});
child.once("exit", (code, signal) => { this.childExit = { code, signal }; });
const pid = child.pid ?? null;
child.unref();
await logFd.close();
if (pid === null) {
throw new BackendError(
"spawn returned no pid",
"Avvio del server fallito: il processo non è partito.",
`${binary} ${args.join(" ")}`,
);
}
this.childPid = pid;
this.ownership = "spawned";
await writeFile(
this.paths.pidFile,
JSON.stringify({ pid, port: this.port, binary, startedAt: new Date().toISOString() } satisfies PidRecord, null, 2),
"utf8",
);
const ready = await this.waitUntilReady(opts.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS);
if (!ready) {
const tail = await this.tailLog(20);
await this.stop({ force: true }).catch(() => undefined);
throw new BackendError(
"gRPCServerCLI did not start listening in time",
`Il server non ha risposto sulla porta ${this.port} entro il tempo previsto. ` +
`Log: ${this.paths.logFile}`,
tail,
);
}
return {
status: await this.status(),
action: "spawned",
message: `Server Draw Things avviato sulla porta ${this.port} (pid ${pid}).`,
};
} finally {
if (lockHeld) await this.releaseLock();
}
}
/** Exact argv for the daemon. Kept separate so the doctor can print it verbatim. */
spawnArgs(): string[] {
const args = [this.cfg.modelsPath, "--no-tls", "--port", String(this.port)];
// "offload some weights to CPU during inference" — undocumented in the README and
// effectively mandatory at 16GB.
if (this.cfg.server.cpuOffload) args.push("--cpu-offload");
// Deliberately NOT passing -w/--weights-cache: the default of 0 GiB is correct on a
// 16GB machine, and raising it trades away exactly the RAM the VAE decode needs.
return args;
}
/** Poll until the port accepts a connection, the child dies, or the budget expires. */
async waitUntilReady(timeoutMs = DEFAULT_READY_TIMEOUT_MS, signal?: AbortSignal): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
for (;;) {
if (signal?.aborted) return false;
if (await isPortOpen(this.port)) return true;
// A child that already exited will never open the port; stop waiting 60s for it.
if (this.childExit !== null) return false;
if (Date.now() >= deadline) return false;
await delay(POLL_INTERVAL_MS);
}
}
private async adoptResult(action: "already-running" | "adopted"): Promise<StartResult> {
const status = await this.status();
if (status.ownership === "adopted-pidfile" || status.ownership === "adopted-foreign") {
this.ownership = status.ownership;
this.childPid = status.pid;
}
const who = status.ownership === "adopted-foreign" ? " (avviato da un altro processo)" : "";
return {
status,
action,
message: `Server Draw Things già attivo sulla porta ${this.port}${who}: riutilizzato.`,
};
}
/* ------------------------------------------------------------------ stop */
/**
* Graceful SIGTERM, then SIGKILL. Safe when nothing is running.
* A foreign daemon (GUI, manual run) is left alone unless `force` is set — killing
* the user's own Draw Things session out from under them would be rude.
*/
async stop(opts: { graceMs?: number; force?: boolean } = {}): Promise<StopResult> {
const graceMs = opts.graceMs ?? DEFAULT_GRACE_MS;
const status = await this.status();
if (!status.running) {
await rm(this.paths.pidFile, { force: true });
this.childPid = null;
this.ownership = null;
this.childExit = null;
return { stopped: false, message: "Nessun server da fermare." };
}
if (status.ownership === "adopted-foreign" && !opts.force) {
return {
stopped: false,
message: `Il server sulla porta ${this.port} non è stato avviato da noi: lasciato in esecuzione.`,
};
}
const pid = status.pid;
if (pid === null) {
return {
stopped: false,
message: `Server attivo sulla porta ${this.port} ma senza pid noto: fermalo a mano.`,
};
}
this.signal(pid, "SIGTERM");
const deadline = Date.now() + graceMs;
while (Date.now() < deadline) {
if (!pidAlive(pid) && !(await isPortOpen(this.port))) break;
await delay(POLL_INTERVAL_MS);
}
let forced = false;
if (pidAlive(pid)) {
this.signal(pid, "SIGKILL");
forced = true;
// Give the kernel a moment to reap before we report success.
for (let i = 0; i < 8 && pidAlive(pid); i++) await delay(POLL_INTERVAL_MS);
}
await rm(this.paths.pidFile, { force: true });
this.childPid = null;
this.ownership = null;
this.childExit = null;
return {
stopped: true,
message: forced
? `Server Draw Things terminato forzatamente (pid ${pid}).`
: `Server Draw Things fermato (pid ${pid}).`,
};
}
/**
* Signal the process GROUP first — we spawn with `detached: true`, so the daemon is a
* group leader and any helper it forked dies with it. Falls back to the bare pid for
* an adopted daemon that may not lead its own group.
*/
private signal(pid: number, sig: NodeJS.Signals): void {
try {
process.kill(-pid, sig);
return;
} catch { /* not a group leader, or already gone */ }
try {
process.kill(pid, sig);
} catch { /* already gone */ }
}
/* -------------------------------------------------------------- binaries */
/**
* Make sure the server binary exists and is executable. NEVER downloads unless the
* caller passes `allowDownload: true` — that flag is the user's answer to a prompt,
* not a default.
*/
async ensureBinary(opts: EnsureBinaryOptions = {}): Promise<EnsureBinaryResult> {
const target = this.paths.binary;
if (await exists(target)) {
if (!(await isExecutable(target))) {
// Most common cause: a manual `curl -O` that never chmod'ed. Fix it in place.
await chmod(target, 0o755).catch(() => undefined);
}
if (!(await isExecutable(target))) {
throw new BackendError(
`server binary is not executable: ${target}`,
`Il binario del server non è eseguibile: ${target}. Prova con \`chmod +x "${target}"\`.`,
);
}
return { path: target, action: "present" };
}
if (!opts.allowDownload) {
throw new BackendError(
`server binary missing: ${target}`,
`Manca il binario del server (${GRPC_SERVER_BINARY}). Non lo scarico senza il tuo consenso: ` +
`esegui \`imgen setup --download\` oppure scaricalo a mano da ${GRPC_SERVER_URL} e mettilo in ${target}. ` +
`Nota: \`brew install draw-things-cli\` installa solo il client, non il server.`,
);
}
const bytes = await this.downloadBinary(target, opts.onProgress, opts.signal);
return { path: target, action: "downloaded", bytes };
}
private async downloadBinary(target: string, onProgress?: ProgressFn, signal?: AbortSignal): Promise<number> {
await mkdir(dirname(target), { recursive: true });
const tmp = `${target}.part-${process.pid}`;
onProgress?.(`Scarico ${GRPC_SERVER_BINARY} ${GRPC_SERVER_VERSION}…`, 0);
let written = 0;
try {
const res = await fetch(GRPC_SERVER_URL, { redirect: "follow", signal });
if (!res.ok || res.body === null) {
throw new BackendError(
`download failed: HTTP ${res.status}`,
`Scaricamento del server fallito (HTTP ${res.status}). Controlla la connessione o scarica a mano da ${GRPC_SERVER_URL}.`,
);
}
const totalHeader = res.headers.get("content-length");
const total = totalHeader === null ? 0 : Number.parseInt(totalHeader, 10);
const source = Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]);
source.on("data", (chunk: Buffer) => {
written += chunk.length;
if (total > 0) {
onProgress?.(`Scarico ${GRPC_SERVER_BINARY}…`, Math.min(1, written / total));
}
});
await pipeline(source, createWriteStream(tmp, { mode: 0o755 }));
await this.verifyMachO(tmp);
await chmod(tmp, 0o755);
await rename(tmp, target);
} catch (e) {
await unlink(tmp).catch(() => undefined);
if (e instanceof BackendError) throw e;
throw new BackendError(
`download failed: ${(e as Error).message}`,
`Scaricamento del server fallito. Scaricalo a mano da ${GRPC_SERVER_URL} e mettilo in ${target}.`,
(e as Error).message,
);
}
// ASSUMPTION (unverified): a file fetched by Node is not flagged with
// com.apple.quarantine the way a browser download is, so Gatekeeper should not
// block it. We clear the attribute best-effort anyway; failure is not fatal.
await this.clearQuarantine(target);
if (!(await isExecutable(target))) {
throw new BackendError(
"downloaded binary is not executable",
`Il binario scaricato non risulta eseguibile: ${target}.`,
);
}
onProgress?.(`${GRPC_SERVER_BINARY} installato in ${target}.`, 1);
return written;
}
/** Reject an HTML error page or LFS pointer masquerading as a 200 OK. */
private async verifyMachO(file: string): Promise<void> {
const st = await stat(file);
if (st.size < 1024 * 1024) {
throw new BackendError(
`downloaded file is implausibly small (${st.size} bytes)`,
`Il file scaricato è troppo piccolo (${st.size} byte) per essere il server: probabilmente è una pagina di errore.`,
);
}
const fh = await open(file, "r");
try {
const head = Buffer.alloc(4);
await fh.read(head, 0, 4, 0);
if (!MACHO_MAGIC.has(head.readUInt32BE(0))) {
throw new BackendError(
"downloaded file is not a Mach-O binary",
"Il file scaricato non è un eseguibile macOS valido: scaricamento annullato.",
`magic=0x${head.readUInt32BE(0).toString(16)}`,
);
}
} finally {
await fh.close();
}
}
private async clearQuarantine(target: string): Promise<void> {
if (platform !== "darwin") return;
await new Promise<void>((resolve) => {
const p = spawn("/usr/bin/xattr", ["-d", "com.apple.quarantine", target], { stdio: "ignore" });
p.once("error", () => resolve());
p.once("exit", () => resolve());
});
}
/* ------------------------------------------------------------------ misc */
/** Last `lines` lines of the daemon log — the only diagnostics gRPC denies us. */
async tailLog(lines = 40): Promise<string> {
try {
const text = await readFile(this.paths.logFile, "utf8");
return text.split("\n").slice(-lines).join("\n").trim();
} catch {
return "";
}
}
/* ------------------------------------------------------------------ lock */
/** O_EXCL lock file. Returns false when a FRESH lock is held elsewhere. */
private async acquireLock(): Promise<boolean> {
for (let attempt = 0; attempt < 2; attempt++) {
try {
const fh = await open(this.paths.lockFile, "wx");
await fh.writeFile(JSON.stringify({ pid: process.pid, at: new Date().toISOString() }));
await fh.close();
return true;
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== "EEXIST") return false;
let stale = true;
try {
stale = Date.now() - (await stat(this.paths.lockFile)).mtimeMs > LOCK_STALE_MS;
} catch {
stale = true; // vanished between calls: try again
}
if (!stale) return false;
await rm(this.paths.lockFile, { force: true });
}
}
return false;
}
private async releaseLock(): Promise<void> {
await rm(this.paths.lockFile, { force: true });
}
}
/* -------------------------------------------------------------------------- */
/* Convenience wrappers */
/* -------------------------------------------------------------------------- */
/**
* One-shot helpers for callers that do not want to hold an instance (e.g. a `doctor`
* command). Constructing a DrawThingsServer does no I/O, so this is cheap.
*/
export function createServer(cfg: ImgenConfig, piConfigDir: string): DrawThingsServer {
return new DrawThingsServer(cfg, piConfigDir);
}
/** session_start helper: never throws, returns an Italian line to show the user. */
export async function startForSession(
cfg: ImgenConfig,
piConfigDir: string,
opts: StartOptions = {},
): Promise<{ server: DrawThingsServer; message: string; ok: boolean }> {
const server = createServer(cfg, piConfigDir);
try {
const res = await server.start(opts);
return { server, message: res.message, ok: res.action !== "disabled" };
} catch (e) {
const message = e instanceof BackendError
? e.italian
: `Avvio del server non riuscito: ${(e as Error).message}`;
// A dead daemon is not fatal: draw-things-cli still runs cold, just slowly.
return { server, message: `${message} Si continuerà senza modello caldo (più lento).`, ok: false };
}
}
+132
View File
@@ -0,0 +1,132 @@
//
// cutout.swift — subject cutout (background removal) via Apple Vision.
//
// Why this and not rembg/U^2-Net: VNGenerateForegroundInstanceMaskRequest is built into
// macOS, runs on the ANE/GPU, downloads nothing, adds no Python, and carries no licence
// question at all. rembg is only the fallback (see backends/cpu.ts) because its DEFAULT
// model, bria-rmbg, is CC BY-NC 4.0 and therefore unusable for a paying client's poster.
//
// BUILD (universal-arm64, ~1s):
//
// swiftc -O -whole-module-optimization \
// -target arm64-apple-macos14.0 \
// -framework Vision -framework CoreImage -framework ImageIO -framework CoreGraphics \
// -o vendor/bin/cutout native/cutout.swift
//
// backends/cpu.ts runs exactly that command for you (buildCutout()) whenever the binary
// is missing and `swiftc` is on PATH — Xcode Command Line Tools are enough, no full Xcode.
//
// USAGE
// cutout <input-image> <output.png> [--crop] [--largest]
//
// --crop crop the result to the subjects' bounding box instead of keeping the
// original canvas size (handy for logos, wrong for compositing).
// --largest keep only the biggest instance instead of every detected subject.
//
// EXIT CODES (backends/cpu.ts maps these to Italian messages — keep them in sync)
// 0 ok · 2 bad usage · 3 macOS too old (needs 14) · 4 no subject found
// 5 cannot read input · 6 Vision request failed · 7 cannot write output
//
// NOTE: the input is expected to be already upright. cpu.ts normalises EXIF orientation
// with sharp before calling, because it is NOT verified here whether the pixel buffer
// returned by generateMaskedImage is in original or in oriented coordinates.
//
import Foundation
import Vision
import CoreImage
import CoreGraphics
func fail(_ message: String, _ code: Int32) -> Never {
FileHandle.standardError.write(Data(("cutout: " + message + "\n").utf8))
exit(code)
}
let argv = CommandLine.arguments
let positional = argv.dropFirst().filter { !$0.hasPrefix("--") }
let flags = Set(argv.dropFirst().filter { $0.hasPrefix("--") })
guard positional.count == 2 else {
fail("uso: cutout <immagine-input> <output.png> [--crop] [--largest]", 2)
}
let inputURL = URL(fileURLWithPath: positional[positional.startIndex])
let outputURL = URL(fileURLWithPath: positional[positional.index(after: positional.startIndex)])
let cropToExtent = flags.contains("--crop")
let onlyLargest = flags.contains("--largest")
guard #available(macOS 14.0, *) else {
fail("serve macOS 14 o superiore per il ritaglio automatico del soggetto.", 3)
}
guard FileManager.default.isReadableFile(atPath: inputURL.path) else {
fail("immagine non leggibile: \(inputURL.path)", 5)
}
let handler = VNImageRequestHandler(url: inputURL, options: [:])
let request = VNGenerateForegroundInstanceMaskRequest()
do {
try handler.perform([request])
} catch {
fail("analisi Vision fallita: \(error.localizedDescription)", 6)
}
guard let observation = request.results?.first, !observation.allInstances.isEmpty else {
fail("nessun soggetto riconosciuto nell'immagine.", 4)
}
var instances = observation.allInstances
// --largest: Vision numbers instances from 1; instance 0 is the background and is never
// part of allInstances. Pick the one whose mask covers the most pixels.
if onlyLargest && instances.count > 1 {
var best = instances.first!
var bestArea = -1.0
for index in instances {
guard let buffer = try? observation.generateScaledMaskForImage(forInstances: IndexSet(integer: index),
from: handler) else { continue }
CVPixelBufferLockBaseAddress(buffer, .readOnly)
let width = CVPixelBufferGetWidth(buffer)
let height = CVPixelBufferGetHeight(buffer)
let stride = CVPixelBufferGetBytesPerRow(buffer)
var area = 0.0
if let base = CVPixelBufferGetBaseAddress(buffer) {
let floats = base.assumingMemoryBound(to: Float.self)
let perRow = stride / MemoryLayout<Float>.size
for y in 0..<height {
for x in 0..<width where floats[y * perRow + x] > 0.5 { area += 1 }
}
}
CVPixelBufferUnlockBaseAddress(buffer, .readOnly)
if area > bestArea { bestArea = area; best = index }
}
instances = IndexSet(integer: best)
}
var masked: CVPixelBuffer
do {
masked = try observation.generateMaskedImage(ofInstances: instances,
from: handler,
croppedToInstancesExtent: cropToExtent)
} catch {
fail("creazione della maschera fallita: \(error.localizedDescription)", 6)
}
let image = CIImage(cvPixelBuffer: masked)
let context = CIContext(options: [.useSoftwareRenderer: false])
guard let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) else {
fail("spazio colore sRGB non disponibile.", 7)
}
do {
try context.writePNGRepresentation(of: image,
to: outputURL,
format: .RGBA8,
colorSpace: colorSpace)
} catch {
fail("impossibile scrivere \(outputURL.path): \(error.localizedDescription)", 7)
}
// Machine-readable one-liner for the caller.
print("{\"instances\":\(instances.count),\"cropped\":\(cropToExtent)}")
+482
View File
@@ -0,0 +1,482 @@
// lib.typ — shared kernel imported by every template in templates/.
//
// Templates are fixed and reviewed; the LLM never writes Typst. Everything here reads
// the ResolvedSpec (see extensions/imgen/design/spec.ts) as DATA, loaded once by the
// template with:
//
// #let spec = json(sys.inputs.specfile)
//
// Two invariants this file must never break:
// 1. Determinism. No randomness, no dates, no system fonts, no wall-clock. Combined
// with --ignore-system-fonts + SOURCE_DATE_EPOCH + `#set document(date: none)`
// the output is byte-identical across runs, which is what makes the golden-file
// tests meaningful.
// 2. Resolution independence. Sizes are expressed as fractions of the trim, so the
// same template is correct at 1080 px and at 300 dpi A3.
//
// Colour note: this is an RGB pipeline on purpose. Never introduce cmyk() — its
// gradients render wrong (typst#4422) and CMYK ICC images produce PDFs Acrobat
// refuses to open (typst#3781).
// ---------------------------------------------------------------------------
// Internal helpers — tolerant readers for a spec whose optional fields may be
// missing entirely, or present but JSON `null` (which reaches Typst as `none`).
// ---------------------------------------------------------------------------
/// Read `key` from dictionary `d`, falling back to `default` when the dictionary is
/// not a dictionary, the key is absent, or the value is `none`.
#let _get(d, key, default) = {
if type(d) != dictionary { return default }
let v = d.at(key, default: default)
if v == none { default } else { v }
}
/// Read a nested path, e.g. `_dig(spec, ("page", "bleedMm"), 0)`. Same tolerance as
/// `_get` at every level, so a missing `page` object degrades instead of panicking.
#let _dig(d, path, default) = {
let cur = d
for key in path {
if type(cur) != dictionary { return default }
cur = cur.at(key, default: none)
if cur == none { return default }
}
cur
}
/// Parse a spec hex string into a colour. Anything unparseable falls back rather than
/// killing the render — a wrong colour is recoverable, a failed poster is not.
#let hex(v, fallback: black) = {
if type(v) == color { return v }
if type(v) != str { return fallback }
let s = v.trim()
if not s.starts-with("#") { return fallback }
if s.len() in (4, 7, 9) { rgb(s) } else { fallback }
}
/// The four spec colours, always complete. `ink_resolved` is the contrast-checked ink
/// the renderer computed; it wins over the art director's `palette.ink`.
#let palette-of(spec) = {
let p = _get(spec, "palette", (:))
let ink = hex(_get(spec, "ink_resolved", _get(p, "ink", none)), fallback: rgb("#111111"))
(
bg: hex(_get(p, "bg", none), fallback: rgb("#ffffff")),
ink: ink,
accent: hex(_get(p, "accent", none), fallback: ink),
scrim: hex(_get(p, "scrim", none), fallback: rgb("#000000")),
)
}
/// Font family for a role group: "display" for headlines, "body" for everything else.
/// Returns `none` when the spec omits it, which callers pass straight through (Typst
/// then keeps the inherited family instead of erroring on `font: none`).
#let font-of(spec, kind) = {
let v = _dig(spec, ("fonts", kind), none)
if type(v) == str and v.trim() != "" { v } else { none }
}
/// Variable-font axes usable for `family`, as `(wdth: (62, 125), wght: (100, 900))`.
/// Optional: the renderer may inject `spec.font_axes` (family -> axis ranges) from
/// design/fonts.ts. Absent, this returns `(:)` and `fit` simply bisects on size.
#let font-axes(spec, family) = {
if type(family) != str { return (:) }
let table = _get(spec, "font_axes", (:))
if type(table) != dictionary { return (:) }
let a = table.at(family, default: (:))
if type(a) == dictionary { a } else { (:) }
}
/// Trim size in mm, as plain numbers. Bleed deliberately excluded: every ratio in this
/// file is a fraction of the TRIM, so adding bleed never changes how big the title is.
#let trim-mm(spec) = (
width: _dig(spec, ("page", "widthMm"), 210),
height: _dig(spec, ("page", "heightMm"), 297),
)
/// Short edge of the trim, in mm. The scale reference for every type ratio and for the
/// logo, so a portrait and a landscape poster get proportionally the same headline.
#let short-edge-mm(spec) = {
let t = trim-mm(spec)
calc.min(t.width, t.height)
}
/// Normalise an axis range from the spec: `(62, 125)`, `(min: 62, max: 125)` or a bare
/// number all become a `(lo, hi)` pair; anything else becomes `none`.
#let _axis-range(a) = {
if a == none { return none }
if type(a) == array and a.len() >= 2 { return (a.at(0), a.at(1)) }
if type(a) == dictionary {
let lo = a.at("min", default: a.at("lo", default: none))
let hi = a.at("max", default: a.at("hi", default: none))
if lo != none and hi != none { return (lo, hi) }
}
if type(a) in (int, float) { return (a, a) }
none
}
/// Build the `text()` arguments dictionary, skipping every key the caller left `none`
/// so we never hand Typst `font: none` or an empty `variations`.
#let _text-args(family, size, weight, tracking, fill, wdth) = {
let args = (size: size)
if family != none { args.insert("font", family) }
if weight != none { args.insert("weight", weight) }
if tracking != none { args.insert("tracking", tracking) }
if fill != none { args.insert("fill", fill) }
if wdth != none { args.insert("variations", (wdth: wdth)) }
args
}
// ---------------------------------------------------------------------------
// fit — auto-fit text into a box
// ---------------------------------------------------------------------------
/// Fit `body` into a `w` x `h` box, returning the styled content.
///
/// `w` and `h` MUST be absolute lengths (mm/pt). Inside `page(background:)` or a
/// `layout()` block use `safe-area(spec)`'s mm keys; percentages cannot be measured.
///
/// Strategy, and the whole reason this helper exists: when `family` exposes a `wdth`
/// axis we bisect on WIDTH FIRST, keeping the requested size and narrowing the letters
/// until the line fits. Narrowing preserves a poster's optical weight — the headline
/// still fills its band and still reads from across the piazza. Shrinking destroys it:
/// a 40 % smaller title is a 40 % quieter poster. Only once the axis bottoms out at its
/// minimum do we fall back to bisecting the size.
///
/// - body: content to typeset
/// - w, h: absolute box size
/// - family: font family string, or `none` to inherit
/// - axes: axis ranges for that family, e.g. `(wdth: (62, 125))` — `none`/`(:)` is fine
/// - size: the ideal (maximum) size; the fit never grows past it
/// - min-size: hard floor; defaults to 28 % of `size`, never below 6pt
/// - steps: bisection iterations (~12 is well past visual convergence)
#let fit(
body,
w,
h,
family,
axes,
size: 72pt,
min-size: none,
weight: none,
tracking: none,
fill: none,
leading: 0.34em,
justify: false,
align-to: left,
steps: 12,
) = context {
let axes = if type(axes) == dictionary { axes } else { (:) }
let wdth = _axis-range(axes.at("wdth", default: none))
let floor = if min-size != none { min-size } else {
calc.max(6pt, size * 0.28)
}
let floor = calc.min(floor, size)
// One trial layout, measured inside the real box. `measure` with a region makes the
// paragraph wrap exactly as it will on the page, so multi-line titles are handled.
let trial(sz, wd) = {
set par(leading: leading, linebreaks: "optimized", justify: justify)
set align(align-to)
text(.._text-args(family, sz, weight, tracking, fill, wd), body)
}
let fits(sz, wd) = {
let m = measure(width: w, height: h, trial(sz, wd))
// 0.01pt slack absorbs the last bit of floating-point noise in the bisection.
m.width <= w + 0.01pt and m.height <= h + 0.01pt
}
let final-size = size
let final-wdth = none
if wdth != none and wdth.at(0) < wdth.at(1) {
let lo = calc.min(wdth.at(0), wdth.at(1)) // narrowest, most likely to fit
let hi = calc.max(wdth.at(0), wdth.at(1)) // widest, what we would rather keep
if fits(size, hi) {
final-wdth = hi
} else if not fits(size, lo) {
// The axis bottomed out: stay narrow and buy the rest back by shrinking.
final-wdth = lo
let slo = floor
let shi = size
let i = 0
while i < steps {
let mid = (slo + shi) / 2
if fits(mid, lo) { slo = mid } else { shi = mid }
i += 1
}
final-size = slo
} else {
// Widest that still fits at full size.
let i = 0
while i < steps {
let mid = (lo + hi) / 2
if fits(size, mid) { lo = mid } else { hi = mid }
i += 1
}
final-wdth = lo
}
} else {
if wdth != none { final-wdth = wdth.at(0) }
if not fits(size, final-wdth) {
let slo = floor
let shi = size
let i = 0
while i < steps {
let mid = (slo + shi) / 2
if fits(mid, final-wdth) { slo = mid } else { shi = mid }
i += 1
}
final-size = slo
}
}
trial(final-size, final-wdth)
}
// ---------------------------------------------------------------------------
// scrim — the gradient wash that buys legibility over busy artwork
// ---------------------------------------------------------------------------
/// A gradient rectangle drawn BEHIND a text band: opaque at the band's outer edge,
/// fading to nothing over the artwork. Draw it, then the text, then nothing else.
///
/// - height: band height (absolute length or a ratio of the container)
/// - colour: the wash colour — normally `palette.scrim`; `none` falls back to black
/// - angle: gradient direction. `none` -> 90deg, i.e. transparent at top, solid at the
/// bottom, which is what a bottom-anchored text band wants. Use 270deg for a top band.
///
/// The mid stop is deliberately not linear: a straight ramp reads as a grey haze across
/// the whole image, whereas holding the fade back until ~45 % keeps the artwork clean.
#let scrim(height, colour, angle, width: 100%, strength: 100%) = {
let c = if colour == none { black } else { hex(colour, fallback: black) }
let a = if angle == none { 90deg } else { angle }
let solid = if strength >= 100% { c } else { c.transparentize(100% - strength) }
rect(
width: width,
height: height,
stroke: none,
fill: gradient.linear(
(c.transparentize(100%), 0%),
(c.transparentize(78%), 45%),
(c.transparentize(30%), 75%),
(solid, 100%),
angle: a,
),
)
}
// ---------------------------------------------------------------------------
// safe-area — the usable rect
// ---------------------------------------------------------------------------
/// The rect that content may occupy: the trim, inset by the safe margin.
///
/// Returns absolute mm lengths AND fractions, because the two live in different
/// coordinate systems and mixing them is the classic bleed bug:
/// * `x`/`y`/`width`/`height` are offsets from the FULL page origin (top-left of the
/// bleed), which is exactly what `page(background:)` and `page(foreground:)`
/// resolve against.
/// * `fx`/`fy`/`fw`/`fh` are the same rect as fractions of the FULL page, for
/// percentage-based placement.
/// * `tw`/`th` are the safe rect as fractions of the TRIM — the resolution-independent
/// numbers to build layout ratios from.
#let safe-area(spec) = {
let t = trim-mm(spec)
let bleed = _dig(spec, ("page", "bleedMm"), 0)
let safe = _dig(spec, ("page", "safeMm"), 0)
// A safe margin can never eat more than 40 % of an edge, whatever the config says.
let safe = calc.max(0, calc.min(safe, calc.min(t.width, t.height) * 0.4))
let bleed = calc.max(0, bleed)
let full-w = t.width + 2 * bleed
let full-h = t.height + 2 * bleed
let inset = bleed + safe
let usable-w = t.width - 2 * safe
let usable-h = t.height - 2 * safe
(
// absolute, from the full-page origin
x: inset * 1mm,
y: inset * 1mm,
width: usable-w * 1mm,
height: usable-h * 1mm,
// the boxes this rect sits inside
trim-width: t.width * 1mm,
trim-height: t.height * 1mm,
full-width: full-w * 1mm,
full-height: full-h * 1mm,
bleed: bleed * 1mm,
safe: safe * 1mm,
short-edge: calc.min(t.width, t.height) * 1mm,
// fractions of the full page (bleed included)
fx: inset / full-w * 100%,
fy: inset / full-h * 100%,
fw: usable-w / full-w * 100%,
fh: usable-h / full-h * 100%,
// fractions of the trim — the stable numbers for layout ratios
tw: usable-w / t.width * 100%,
th: usable-h / t.height * 100%,
)
}
// ---------------------------------------------------------------------------
// crop-marks
// ---------------------------------------------------------------------------
/// Eight short rules marking the trim corners, for `page(foreground: ...)`.
///
/// Off by default (`spec.page.cropMarks`), because web-to-print services reject them;
/// offset houses want them. Each mark runs from a trim corner outward to the page edge,
/// so it lives entirely in the bleed and never touches the trimmed piece. With no bleed
/// there is nowhere to put them and nothing is drawn.
///
/// Drawn in plain black: this is an RGB pipeline, and cmyk() registration black is
/// exactly the kind of colour that breaks the PDF (see the header note).
#let crop-marks(spec) = {
if not _dig(spec, ("page", "cropMarks"), false) { return none }
let bleed = _dig(spec, ("page", "bleedMm"), 0)
if bleed <= 0 { return none }
let t = trim-mm(spec)
let b = bleed * 1mm
let tw = t.width * 1mm
let th = t.height * 1mm
let s = 0.25pt + black
let hmark(x, y) = place(dx: x, dy: y, line(length: b, angle: 0deg, stroke: s))
let vmark(x, y) = place(dx: x, dy: y, line(length: b, angle: 90deg, stroke: s))
hmark(0pt, b) // top-left, horizontal
vmark(b, 0pt) // top-left, vertical
hmark(b + tw, b) // top-right, horizontal
vmark(b + tw, 0pt) // top-right, vertical
hmark(0pt, b + th) // bottom-left, horizontal
vmark(b, b + th) // bottom-left, vertical
hmark(b + tw, b + th) // bottom-right, horizontal
vmark(b + tw, b + th) // bottom-right, vertical
}
// ---------------------------------------------------------------------------
// block-style — role to typography
// ---------------------------------------------------------------------------
// Ratios are fractions of the TRIM'S SHORT EDGE, so a headline is the same physical
// fraction of the poster at any format or dpi. The title dominates by roughly 8x the
// footer, which is what makes a poster readable at distance and legal up close.
#let _ROLE-STYLES = (
title: (ratio: 0.130, kind: "display", weight: 800, tracking: -0.015em, leading: 0.30em, upper: true, ink: "ink"),
subtitle: (ratio: 0.052, kind: "display", weight: 500, tracking: 0.000em, leading: 0.42em, upper: false, ink: "ink"),
date: (ratio: 0.046, kind: "body", weight: 700, tracking: 0.020em, leading: 0.40em, upper: true, ink: "accent"),
venue: (ratio: 0.036, kind: "body", weight: 500, tracking: 0.030em, leading: 0.45em, upper: true, ink: "ink"),
details: (ratio: 0.024, kind: "body", weight: 400, tracking: 0.010em, leading: 0.60em, upper: false, ink: "ink"),
price: (ratio: 0.032, kind: "body", weight: 700, tracking: 0.020em, leading: 0.45em, upper: false, ink: "accent"),
footer: (ratio: 0.016, kind: "body", weight: 400, tracking: 0.060em, leading: 0.70em, upper: true, ink: "muted"),
)
/// Typography for a block role. Unknown roles degrade to `details` rather than failing.
///
/// Returns:
/// ratio fraction of the short edge used for `size`
/// size absolute ideal size (the maximum `fit` will use)
/// min-size floor for the auto-fit
/// family resolved family name, or `none` to inherit
/// axes that family's variable axes, ready for `fit`
/// upper whether the template should uppercase the text
/// args spreadable straight into `fit`: `fit(body, w, h, st.family, st.axes, ..st.args)`
#let block-style(spec, role) = {
let key = if type(role) == str and role in _ROLE-STYLES { role } else { "details" }
let s = _ROLE-STYLES.at(key)
let pal = palette-of(spec)
let fill = if s.ink == "accent" { pal.accent } else if s.ink == "muted" {
pal.ink.transparentize(30%)
} else { pal.ink }
let family = font-of(spec, s.kind)
let size = short-edge-mm(spec) * s.ratio * 1mm
// Titles may shrink hard; small roles must stay legible, so their floor is tight.
let min-size = if key == "title" { size * 0.32 } else if key == "subtitle" {
size * 0.55
} else { size * 0.8 }
(
role: key,
ratio: s.ratio,
size: size,
min-size: calc.max(5pt, min-size),
family: family,
axes: font-axes(spec, family),
weight: s.weight,
tracking: s.tracking,
leading: s.leading,
fill: fill,
upper: s.upper,
args: (
size: size,
min-size: calc.max(5pt, min-size),
weight: s.weight,
tracking: s.tracking,
leading: s.leading,
fill: fill,
),
)
}
/// Every block with the given role, in spec order. Returns `()` when there are none —
/// templates should treat an absent role as "draw nothing", not as an error.
#let blocks-of(spec, role) = {
let bs = _get(spec, "blocks", ())
if type(bs) != array { return () }
bs.filter(b => type(b) == dictionary and _get(b, "role", none) == role)
}
/// The text of the first block with `role`, uppercased when the role calls for it.
/// `none` when the role is absent.
#let block-text(spec, role, upper: auto) = {
let bs = blocks-of(spec, role)
if bs.len() == 0 { return none }
let t = _get(bs.at(0), "text", "")
if type(t) != str or t.trim() == "" { return none }
let up = if upper == auto { block-style(spec, role).upper } else { upper }
if up { upper(t) } else { t }
}
// ---------------------------------------------------------------------------
// logo-place
// ---------------------------------------------------------------------------
/// Place `spec.logo` in its corner, sized to `spec.logo.scale` of the trim's short
/// edge, inset to the safe area. For `page(foreground: ...)`, whose origin is the full
/// page including bleed — which is why the inset is bleed + safe, not just safe.
///
/// Optional throughout: no `logo`, no `path`, or an empty path all draw nothing.
/// NOTE: `path` must be root-relative (leading "/") — Typst resolves image paths
/// against `--root`, not against the filesystem root, so the renderer rewrites it.
#let logo-place(spec, margin: none) = {
let logo = _get(spec, "logo", none)
if type(logo) != dictionary { return none }
let path = _get(logo, "path", none)
if type(path) != str or path.trim() == "" { return none }
let sa = safe-area(spec)
let corner = _get(logo, "corner", "br")
let scale = _get(logo, "scale", 0.12)
if type(scale) not in (int, float) { scale = 0.12 }
let scale = calc.max(0.02, calc.min(0.4, scale))
let w = sa.short-edge * scale
let inset = if margin != none { margin } else { sa.x }
let insety = if margin != none { margin } else { sa.y }
let spot = (
tl: (top + left, inset, insety),
tr: (top + right, -inset, insety),
bl: (bottom + left, inset, -insety),
br: (bottom + right, -inset, -insety),
).at(corner, default: (bottom + right, -inset, -insety))
place(
spot.at(0),
dx: spot.at(1),
dy: spot.at(2),
image(path, width: w, fit: "contain"),
)
}