feat(render): Typst renderer, contrast analysis and print pre-flight
render/typst.ts stages each job as a self-contained bundle (<job>/.typst/ lib.typ + template + resolved.json) and roots Typst there, so artwork living outside the package is reachable and old jobs stay re-renderable. Paths are root-relative throughout -- Typst resolves a leading / against --root, not the filesystem. render/contrast.ts measures the region behind the text with sharp and forces a scrim on high variance even when mean luminance passes; verified with a checkerboard that scores 5.45:1 contrast yet stdev 119.9. render/preflight.ts checks TrimBox, embedded fonts, image dpi and structure, degrading to 'non installato' rather than failing when a tool is absent. E2E verified via jiti: TrimBox 297.0x420.0mm, font axes injected, bundle staged. 0 type errors.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Caratteri di terze parti inclusi in pi-imgen
|
# Caratteri di terze parti inclusi in pi-imgen
|
||||||
|
|
||||||
Generato da `scripts/fetch-fonts.sh` il 2026-08-27 07:30 UTC. Non modificare a mano.
|
Generato da `scripts/fetch-fonts.sh` il 2026-08-27 07:43 UTC. Non modificare a mano.
|
||||||
|
|
||||||
I file dei caratteri stanno in `vendor/fonts/<id>/` e **non** sono versionati.
|
I file dei caratteri stanno in `vendor/fonts/<id>/` e **non** sono versionati.
|
||||||
Ogni cartella contiene il file `LICENSE` originale.
|
Ogni cartella contiene il file `LICENSE` originale.
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { randomUUID } from "node:crypto";
|
|||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
|
|
||||||
import { BackendError, type ProgressFn } from "./types.ts";
|
import { BackendError, type ProgressFn } from "./types.ts";
|
||||||
|
import { withHeavyLock } from "./serialize.ts";
|
||||||
import type { ImgenConfig } from "../config.ts";
|
import type { ImgenConfig } from "../config.ts";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -529,11 +530,25 @@ async function findUpscaleModelsDir(binPath: string, explicit?: string): Promise
|
|||||||
* the native x4 (twice, when more than 4x is asked) and resample down to the requested size
|
* the native x4 (twice, when more than 4x is asked) and resample down to the requested size
|
||||||
* with sharp/Lanczos — sharper than a fake native 2x and never a silent wrong-model load.
|
* with sharp/Lanczos — sharper than a fake native 2x and never a silent wrong-model load.
|
||||||
*/
|
*/
|
||||||
export async function upscaleRealesrgan(
|
export function upscaleRealesrgan(
|
||||||
input: string,
|
input: string,
|
||||||
out: string,
|
out: string,
|
||||||
factor: number,
|
factor: number,
|
||||||
opts: UpscaleOptions = {},
|
opts: UpscaleOptions = {},
|
||||||
|
): Promise<CpuOpResult> {
|
||||||
|
// ncnn/Vulkan is a second heavy process with its own large footprint: it must never
|
||||||
|
// run beside a Draw Things diffusion job on a 16GB machine. Same lock, one queue.
|
||||||
|
return withHeavyLock(
|
||||||
|
() => upscaleRealesrganInner(input, out, factor, opts),
|
||||||
|
() => opts.onProgress?.("Un'altra elaborazione è in corso: l'ingrandimento parte appena si libera la memoria."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upscaleRealesrganInner(
|
||||||
|
input: string,
|
||||||
|
out: string,
|
||||||
|
factor: number,
|
||||||
|
opts: UpscaleOptions,
|
||||||
): Promise<CpuOpResult> {
|
): Promise<CpuOpResult> {
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
const notes: string[] = [];
|
const notes: string[] = [];
|
||||||
|
|||||||
@@ -8,15 +8,22 @@
|
|||||||
* - Raw gRPC carries `configuration` as a FlatBuffer (config.fbs) with no Node SDK.
|
* - Raw gRPC carries `configuration` as a FlatBuffer (config.fbs) with no Node SDK.
|
||||||
* - The in-app JS scripting engine cannot be triggered from outside.
|
* - The in-app JS scripting engine cannot be triggered from outside.
|
||||||
*
|
*
|
||||||
* Two invariants this file exists to protect:
|
* Invariants this file exists to protect:
|
||||||
* 1. `--output` is MANDATORY. Without it the CLI paints an inline terminal preview and
|
* 1. `--output` is MANDATORY. Without it the CLI paints an inline terminal preview and
|
||||||
* INTENTIONALLY WRITES NO FILE — the classic silently-empty result. We always pass
|
* INTENTIONALLY WRITES NO FILE — the classic silently-empty result. We always pass
|
||||||
* it, and we verify afterwards that a non-empty file really appeared.
|
* it, and we verify afterwards that a non-empty file really appeared.
|
||||||
* 2. `--disable-preview` is always passed, otherwise every run draws into the terminal.
|
* 2. That verification is only worth anything against a file THIS run created, so every
|
||||||
|
* run renders to a fresh temp sibling and is `rename()`d onto the destination only
|
||||||
|
* once it has been checked. Otherwise a stale poster from a previous prompt/seed
|
||||||
|
* passes the check and is handed back with the new seed and model attached.
|
||||||
|
* 3. `--disable-preview` is always passed, otherwise every run draws into the terminal.
|
||||||
|
* 4. `--remote` is added ONLY when the daemon is really listening: it has no in-process
|
||||||
|
* fallback, so aiming it at a closed port fails every single run.
|
||||||
|
* 5. One heavy job at a time, process-wide (see serialize.ts).
|
||||||
*
|
*
|
||||||
* Memory: peak RAM at VAE DECODE — not weight size — is what kills a 16GB machine
|
* Memory: peak RAM at VAE DECODE — not weight size — is what kills a 16GB machine
|
||||||
* (FLUX.2 klein @1024²: 14.03GB untiled vs 7.18GB tiled). Tiling therefore travels in
|
* (FLUX.2 klein @1024²: 14.03GB untiled vs 7.18GB tiled). Tiling therefore travels in
|
||||||
* every `--config-json` payload we build.
|
* every `--config-json` payload we build — flag AND geometry, never the flag alone.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
@@ -100,8 +107,13 @@ const DEFAULT_TILE_OVERLAP_PX = 64;
|
|||||||
*/
|
*/
|
||||||
const REMOTE_ABORT_SETTLE_MS = 8_000;
|
const REMOTE_ABORT_SETTLE_MS = 8_000;
|
||||||
|
|
||||||
/** Stable `Error.message` values, matched in a few places; keep them in sync. */
|
/**
|
||||||
|
* Stable `Error.message` values, matched in a few places; keep them in sync. The
|
||||||
|
* before-start variant is distinct on purpose: nothing was ever spawned, so there is no
|
||||||
|
* orphaned server-side job to wait out.
|
||||||
|
*/
|
||||||
const ABORTED_MSG = "generation aborted";
|
const ABORTED_MSG = "generation aborted";
|
||||||
|
const ABORTED_BEFORE_START_MSG = "generation aborted before start";
|
||||||
const TIMED_OUT_MSG = "generation timed out";
|
const TIMED_OUT_MSG = "generation timed out";
|
||||||
|
|
||||||
/** Upscale is deterministic in intent; there is no user-facing seed for it. */
|
/** Upscale is deterministic in intent; there is no user-facing seed for it. */
|
||||||
@@ -532,9 +544,9 @@ function issue121Error(confident: boolean, detail: string): BackendError {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function abortedError(): BackendError {
|
function abortedError(beforeStart = false): BackendError {
|
||||||
return new BackendError(
|
return new BackendError(
|
||||||
ABORTED_MSG,
|
beforeStart ? ABORTED_BEFORE_START_MSG : ABORTED_MSG,
|
||||||
"Operazione annullata: la generazione è stata interrotta prima di produrre un'immagine.",
|
"Operazione annullata: la generazione è stata interrotta prima di produrre un'immagine.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -544,7 +556,7 @@ function runCli(input: RunInput): Promise<RunOutcome> {
|
|||||||
|
|
||||||
return new Promise<RunOutcome>((resolve, reject) => {
|
return new Promise<RunOutcome>((resolve, reject) => {
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
reject(abortedError());
|
reject(abortedError(true));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -639,9 +651,10 @@ function runCli(input: RunInput): Promise<RunOutcome> {
|
|||||||
if (e.code === "ENOENT") {
|
if (e.code === "ENOENT") {
|
||||||
reject(
|
reject(
|
||||||
new BackendError(
|
new BackendError(
|
||||||
`${binary} not found on PATH`,
|
`${binary} not found`,
|
||||||
`Comando «${binary}» non trovato. Installalo con: brew install draw-things-cli ` +
|
`Comando «${binary}» non più eseguibile. Reinstallalo con: brew install draw-things-cli ` +
|
||||||
"(formula Homebrew CORE, non un tap personalizzato e non --HEAD).",
|
"(formula Homebrew CORE, non un tap personalizzato e non --HEAD), " +
|
||||||
|
"oppure indica il percorso completo in IMGEN_DT_CLI.",
|
||||||
e.message,
|
e.message,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -997,8 +1010,10 @@ export class DrawThingsBackend implements Backend {
|
|||||||
* harmlessly onto the model's recommended settings.
|
* harmlessly onto the model's recommended settings.
|
||||||
*
|
*
|
||||||
* Because this run uses `--image`, it is exposed to upstream issue #121. Print must
|
* Because this run uses `--image`, it is exposed to upstream issue #121. Print must
|
||||||
* not die on that bug, so a crash falls back to a high-quality Lanczos resample via
|
* not die on that bug, so a CRASH falls back to a high-quality Lanczos resample via
|
||||||
* sharp — announced in Italian through onProgress, never silently.
|
* sharp — announced in Italian through onProgress, never silently. Nothing else takes
|
||||||
|
* that fallback: a stopped daemon is reported as a stopped daemon, because silently
|
||||||
|
* shipping a non-generative upscale in its name is a wrong result with a wrong reason.
|
||||||
*/
|
*/
|
||||||
async upscale(
|
async upscale(
|
||||||
input: string,
|
input: string,
|
||||||
@@ -1055,12 +1070,17 @@ export class DrawThingsBackend implements Backend {
|
|||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
const config: Record<string, unknown> = { ...this.tilingPayload() };
|
|
||||||
// Memory safety: at 4× the VAE decode is what OOMs a 16GB machine, so tiling is
|
// Memory safety: at 4× the VAE decode is what OOMs a 16GB machine, so tiling is
|
||||||
// forced on for large targets regardless of configuration.
|
// forced on for large targets regardless of configuration — WITH its geometry, or
|
||||||
if (longEdge > FORCE_TILING_ABOVE_PX && config.tiledDecoding !== true) {
|
// the flag alone would leave the tile size up to the model's recommended settings.
|
||||||
config.tiledDecoding = true;
|
const forceTiling = longEdge > FORCE_TILING_ABOVE_PX;
|
||||||
onProgress?.("Decodifica a tasselli attivata automaticamente per non saturare la memoria.");
|
const config: Record<string, unknown> = { ...this.tilingPayload(forceTiling) };
|
||||||
|
if (forceTiling && !this.cfg.tiling?.tiledDecoding) {
|
||||||
|
onProgress?.(
|
||||||
|
"Decodifica a tasselli attivata automaticamente " +
|
||||||
|
`(${config.decodingTileWidth}×${config.decodingTileHeight}px, sovrapposizione ` +
|
||||||
|
`${config.decodingTileOverlap}px) per non saturare la memoria.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
config.strength = this.upscaleStrength;
|
config.strength = this.upscaleStrength;
|
||||||
if (/(esrgan|ultrasharp|remacri|swinir|\b[248]x\b)/i.test(model)) {
|
if (/(esrgan|ultrasharp|remacri|swinir|\b[248]x\b)/i.test(model)) {
|
||||||
@@ -1068,66 +1088,105 @@ export class DrawThingsBackend implements Backend {
|
|||||||
config.upscalerScaleFactor = Math.round(factor);
|
config.upscalerScaleFactor = Math.round(factor);
|
||||||
}
|
}
|
||||||
|
|
||||||
const args = [
|
// Fail fast, before queueing behind another job.
|
||||||
"generate",
|
const cliPath = await this.requireCliPath();
|
||||||
"--model", model,
|
|
||||||
// An upscaler needs no creative direction; an empty prompt keeps it faithful.
|
|
||||||
"--prompt", "",
|
|
||||||
"--negative-prompt", "",
|
|
||||||
"--width", String(targetW),
|
|
||||||
"--height", String(targetH),
|
|
||||||
"--steps", String(this.upscaleSteps),
|
|
||||||
"--seed", String(UPSCALE_SEED),
|
|
||||||
"--models-dir", this.cfg.modelsPath,
|
|
||||||
"--config-json", JSON.stringify(config),
|
|
||||||
"--image", input,
|
|
||||||
"--output", outPath,
|
|
||||||
"--disable-preview",
|
|
||||||
];
|
|
||||||
if (this.offline) args.push("--offline");
|
|
||||||
args.push(...this.remoteArgs());
|
|
||||||
|
|
||||||
const started = Date.now();
|
return withHeavyLock(
|
||||||
try {
|
async () => {
|
||||||
const outcome = await runCli({
|
// Timed from inside the queue: waiting for the lock is not upscaling time.
|
||||||
binary: this.cli,
|
const started = Date.now();
|
||||||
args,
|
const remote = await this.remoteArgs(onProgress);
|
||||||
onProgress,
|
const tmpOut = tempOutputPath(outPath);
|
||||||
signal,
|
|
||||||
timeoutMs: this.timeoutMs,
|
|
||||||
});
|
|
||||||
await this.assertSuccess(outcome, outPath, true);
|
|
||||||
const real = await readImageSize(outPath);
|
|
||||||
return {
|
|
||||||
path: outPath,
|
|
||||||
width: real?.width ?? targetW,
|
|
||||||
height: real?.height ?? targetH,
|
|
||||||
seed: UPSCALE_SEED,
|
|
||||||
model,
|
|
||||||
elapsedMs: outcome.elapsedMs,
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
// An abort is the user's decision, not a failure to work around.
|
|
||||||
if (signal?.aborted) throw e;
|
|
||||||
const isKnownBug = e instanceof BackendError && e.message.includes("issue #121");
|
|
||||||
if (!isKnownBug) throw e;
|
|
||||||
|
|
||||||
onProgress?.(
|
const args = [
|
||||||
"Ingrandimento generativo non riuscito (bug noto #121 sui Mac da 16GB): " +
|
"generate",
|
||||||
"procedo con un ridimensionamento Lanczos di alta qualità. " +
|
"--model", model,
|
||||||
"Il dettaglio non viene ricostruito, ma la locandina resta stampabile.",
|
// An upscaler needs no creative direction; an empty prompt keeps it faithful.
|
||||||
);
|
"--prompt", "",
|
||||||
await resampleWithSharp(input, outPath, targetW, targetH);
|
"--negative-prompt", "",
|
||||||
const fallbackSize = await readImageSize(outPath);
|
"--width", String(targetW),
|
||||||
return {
|
"--height", String(targetH),
|
||||||
path: outPath,
|
"--steps", String(this.upscaleSteps),
|
||||||
width: fallbackSize?.width ?? targetW,
|
"--seed", String(UPSCALE_SEED),
|
||||||
height: fallbackSize?.height ?? targetH,
|
"--models-dir", this.cfg.modelsPath,
|
||||||
seed: UPSCALE_SEED,
|
"--config-json", JSON.stringify(config),
|
||||||
model: "lanczos(sharp)",
|
"--image", input,
|
||||||
elapsedMs: Date.now() - started,
|
"--output", tmpOut,
|
||||||
};
|
"--disable-preview",
|
||||||
}
|
];
|
||||||
|
if (this.offline) args.push("--offline");
|
||||||
|
args.push(...remote.args);
|
||||||
|
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
const outcome = await runCli({
|
||||||
|
binary: cliPath,
|
||||||
|
args,
|
||||||
|
onProgress,
|
||||||
|
signal,
|
||||||
|
timeoutMs: this.timeoutMs,
|
||||||
|
});
|
||||||
|
await this.assertSuccess({
|
||||||
|
outcome,
|
||||||
|
filePath: tmpOut,
|
||||||
|
reportPath: outPath,
|
||||||
|
usesImage: true,
|
||||||
|
remoteUsed: remote.used,
|
||||||
|
});
|
||||||
|
await rename(tmpOut, outPath);
|
||||||
|
const real = await readImageSize(outPath);
|
||||||
|
return {
|
||||||
|
path: outPath,
|
||||||
|
width: real?.width ?? targetW,
|
||||||
|
height: real?.height ?? targetH,
|
||||||
|
seed: UPSCALE_SEED,
|
||||||
|
model,
|
||||||
|
elapsedMs: outcome.elapsedMs,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
// An abort (or a timeout) is a decision already taken, not a failure to work
|
||||||
|
// around: never resample instead.
|
||||||
|
if (signal?.aborted || wasKilledByUs(e)) {
|
||||||
|
if (remote.used && wasKilledByUs(e)) await this.settleAfterRemoteKill(onProgress);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
// ONLY the real upstream bug earns the fallback. A dead server raises
|
||||||
|
// serverUnreachableError instead, so "start gRPCServerCLI" can no longer be
|
||||||
|
// silently answered with a non-generative resample.
|
||||||
|
const isKnownBug = e instanceof BackendError && e.message.includes("issue #121");
|
||||||
|
if (!isKnownBug) throw e;
|
||||||
|
|
||||||
|
onProgress?.(
|
||||||
|
"Ingrandimento generativo non riuscito (bug noto #121 sui Mac da 16GB): " +
|
||||||
|
"procedo con un ridimensionamento Lanczos di alta qualità. " +
|
||||||
|
"Il dettaglio non viene ricostruito, ma la locandina resta stampabile.",
|
||||||
|
);
|
||||||
|
// Same temp-then-rename discipline: a half-written fallback must not land
|
||||||
|
// on the destination either.
|
||||||
|
await resampleWithSharp(input, tmpOut, targetW, targetH);
|
||||||
|
if (!(await isNonEmptyFile(tmpOut))) {
|
||||||
|
throw new BackendError(
|
||||||
|
"sharp fallback produced no file",
|
||||||
|
"Ingrandimento di riserva non riuscito: nessuna immagine prodotta.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await rename(tmpOut, outPath);
|
||||||
|
const fallbackSize = await readImageSize(outPath);
|
||||||
|
return {
|
||||||
|
path: outPath,
|
||||||
|
width: fallbackSize?.width ?? targetW,
|
||||||
|
height: fallbackSize?.height ?? targetH,
|
||||||
|
seed: UPSCALE_SEED,
|
||||||
|
model: "lanczos(sharp)",
|
||||||
|
elapsedMs: Date.now() - started,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await discardQuietly(tmpOut);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => onProgress?.("Un'altra elaborazione è in corso: l'ingrandimento parte appena si libera la memoria."),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- internals -----------------------------------------------------------
|
// -- internals -----------------------------------------------------------
|
||||||
@@ -1161,48 +1220,115 @@ export class DrawThingsBackend implements Backend {
|
|||||||
* The tiling lever: a PARTIAL JSGenerationConfiguration merged onto the model's
|
* The tiling lever: a PARTIAL JSGenerationConfiguration merged onto the model's
|
||||||
* recommended settings — never a complete config.
|
* recommended settings — never a complete config.
|
||||||
*
|
*
|
||||||
|
* `force` is the upscale safeguard: it turns tiling on even when the config says off,
|
||||||
|
* and it MUST carry the geometry with it. `{ tiledDecoding: true }` on its own leaves
|
||||||
|
* the tile size to whatever the model recommends (possibly the whole frame), i.e. no
|
||||||
|
* memory saving at all — while the user has already been told tiling is on.
|
||||||
|
*
|
||||||
* UNVERIFIED: the units of decodingTileWidth / decodingTileHeight. Pixels are assumed
|
* UNVERIFIED: the units of decodingTileWidth / decodingTileHeight. Pixels are assumed
|
||||||
* here (the values in config default to 512/512 with 64 overlap, which reads as px);
|
* here (the values in config default to 512/512 with 64 overlap, which reads as px);
|
||||||
* if they turn out to be latent units, these would each cover 8× the area.
|
* if they turn out to be latent units, these would each cover 8× the area.
|
||||||
*/
|
*/
|
||||||
private tilingPayload(): Record<string, unknown> {
|
private tilingPayload(force = false): Record<string, unknown> {
|
||||||
const t = this.cfg.tiling;
|
const t = this.cfg.tiling;
|
||||||
if (!t?.tiledDecoding) return { tiledDecoding: false };
|
if (!force && !t?.tiledDecoding) return { tiledDecoding: false };
|
||||||
|
const px = (value: number | undefined, fallback: number): number =>
|
||||||
|
typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.round(value) : fallback;
|
||||||
return {
|
return {
|
||||||
tiledDecoding: true,
|
tiledDecoding: true,
|
||||||
decodingTileWidth: t.decodingTileWidth,
|
decodingTileWidth: px(t?.decodingTileWidth, DEFAULT_TILE_PX),
|
||||||
decodingTileHeight: t.decodingTileHeight,
|
decodingTileHeight: px(t?.decodingTileHeight, DEFAULT_TILE_PX),
|
||||||
decodingTileOverlap: t.decodingTileOverlap,
|
decodingTileOverlap: px(t?.decodingTileOverlap, DEFAULT_TILE_OVERLAP_PX),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Flags that route the run through the resident, already-warm daemon. */
|
/**
|
||||||
private remoteArgs(): string[] {
|
* Flags that route the run through the resident, already-warm daemon — but ONLY when
|
||||||
if (!this.cfg.server?.enabled) return [];
|
* something is actually listening there.
|
||||||
return [
|
*
|
||||||
"--remote",
|
* `--remote` has no fallback: pointed at a closed port the CLI cannot run in-process,
|
||||||
"--remote-url", REMOTE_HOST,
|
* so passing it on `server.enabled` alone (the default) made EVERY generation fail
|
||||||
"--remote-port", String(this.cfg.server.port),
|
* whenever the daemon was down. Checking the port costs one TCP connect and turns that
|
||||||
"--no-remote-tls",
|
* hard failure into a slow but working local run.
|
||||||
];
|
*/
|
||||||
|
private async remoteArgs(onProgress?: ProgressFn): Promise<{ args: string[]; used: boolean }> {
|
||||||
|
const server = this.cfg.server;
|
||||||
|
if (!server?.enabled) return { args: [], used: false };
|
||||||
|
|
||||||
|
if (!(await probePort(server.port))) {
|
||||||
|
onProgress?.(
|
||||||
|
`Server residente non in ascolto su ${REMOTE_HOST}:${server.port}: procedo comunque, ` +
|
||||||
|
"ma il modello va ricaricato da zero (più lento). " +
|
||||||
|
`Per riaverlo caldo: ${serverStartHint(this.cfg)}`,
|
||||||
|
);
|
||||||
|
return { args: [], used: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
args: [
|
||||||
|
"--remote",
|
||||||
|
"--remote-url", REMOTE_HOST,
|
||||||
|
"--remote-port", String(server.port),
|
||||||
|
"--no-remote-tls",
|
||||||
|
],
|
||||||
|
used: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Killing the CLI does not cancel the job inside gRPCServerCLI. Hold the heavy-work
|
||||||
|
* lock a little longer so the caller's immediate retry cannot start a second decode
|
||||||
|
* while the orphaned one is still holding its peak.
|
||||||
|
*/
|
||||||
|
private async settleAfterRemoteKill(onProgress?: ProgressFn): Promise<void> {
|
||||||
|
onProgress?.(
|
||||||
|
"Interruzione inviata: il server residente sta però ancora completando l'immagine " +
|
||||||
|
"(da qui non è possibile annullarla). Attendo " +
|
||||||
|
`${Math.round(REMOTE_ABORT_SETTLE_MS / 1000)} secondi prima di avviare altro, ` +
|
||||||
|
"per non tenere due elaborazioni in memoria insieme.",
|
||||||
|
);
|
||||||
|
await delay(REMOTE_ABORT_SETTLE_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns a finished child into either silence or a good Italian error. Order matters:
|
* Turns a finished child into either silence or a good Italian error. Order matters:
|
||||||
* the crash check comes first, because a crashed img2img run also has no output file
|
* 1. a broken link to the daemon is diagnosed FIRST, by probing the port — it is the
|
||||||
* and would otherwise be reported as the generic "no image produced".
|
* one failure whose cure ("start the server") the user can act on, and it must
|
||||||
|
* never be dressed up as issue #121;
|
||||||
|
* 2. then the crash checks, because a crashed img2img run also has no output file and
|
||||||
|
* would otherwise be reported as the generic "no image produced";
|
||||||
|
* 3. then, always, "did a non-empty file really appear?".
|
||||||
|
*
|
||||||
|
* `filePath` is the temp file the run was told to write; `reportPath` is the
|
||||||
|
* destination the user knows about.
|
||||||
*/
|
*/
|
||||||
private async assertSuccess(
|
private async assertSuccess(o: {
|
||||||
outcome: RunOutcome,
|
outcome: RunOutcome;
|
||||||
outPath: string,
|
filePath: string;
|
||||||
usesImage: boolean,
|
reportPath: string;
|
||||||
): Promise<void> {
|
usesImage: boolean;
|
||||||
|
remoteUsed: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { outcome, filePath, reportPath, usesImage, remoteUsed } = o;
|
||||||
const failed = outcome.code !== 0 || outcome.signal !== null;
|
const failed = outcome.code !== 0 || outcome.signal !== null;
|
||||||
const detail = `exit=${outcome.code} signal=${outcome.signal}\n${tail(outcome.stderr, 2_000)}`;
|
const produced = await isNonEmptyFile(filePath);
|
||||||
|
if (!failed && produced) return;
|
||||||
|
|
||||||
if (failed && usesImage) {
|
const detail = `exit=${outcome.code} signal=${outcome.signal}\n${tail(outcome.stderr, 2_000)}`;
|
||||||
const kind = classifyRunFailure(outcome);
|
const kind = classifyRunFailure(outcome);
|
||||||
if (kind !== "other") throw issue121Error(kind === "crash", detail);
|
|
||||||
|
// 1. The daemon. It was listening when we assembled the args (remoteArgs probed it),
|
||||||
|
// so if it is gone now, or the run died on the transport, the run never reached a
|
||||||
|
// model — no #121 diagnosis, and no Lanczos fallback in upscale().
|
||||||
|
if (remoteUsed && (kind === "transport" || !(await probePort(this.cfg.server.port)))) {
|
||||||
|
throw serverUnreachableError(this.cfg, detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. issue #121. Only for runs that passed --image, and only for genuine crash
|
||||||
|
// fingerprints; a transport error on a LOCAL run counts, since there is no daemon
|
||||||
|
// involved for it to be blamed on.
|
||||||
|
if (usesImage && (kind === "crash" || kind === "suspect" || (!remoteUsed && kind === "transport"))) {
|
||||||
|
throw issue121Error(kind === "crash", detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (failed) {
|
if (failed) {
|
||||||
@@ -1213,28 +1339,23 @@ export class DrawThingsBackend implements Backend {
|
|||||||
throw new BackendError(
|
throw new BackendError(
|
||||||
`draw-things-cli failed (exit=${outcome.code} signal=${outcome.signal})`,
|
`draw-things-cli failed (exit=${outcome.code} signal=${outcome.signal})`,
|
||||||
`draw-things-cli è ${how} senza completare la generazione.` +
|
`draw-things-cli è ${how} senza completare la generazione.` +
|
||||||
(this.cfg.server?.enabled
|
(remoteUsed
|
||||||
? ` Verifica che il server residente sia attivo su ${REMOTE_HOST}:${this.cfg.server.port}.`
|
? ` Il server residente su ${REMOTE_HOST}:${this.cfg.server.port} risponde ancora,`
|
||||||
|
+ " quindi il problema è nella generazione stessa."
|
||||||
: ""),
|
: ""),
|
||||||
tail(outcome.stderr || outcome.stdout, 2_000),
|
tail(outcome.stderr || outcome.stdout, 2_000),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A zero exit code is not proof of a file: this is exactly the failure mode that a
|
// 3. A zero exit code is not proof of a file: this is exactly the failure mode that a
|
||||||
// missing --output produces, so it is always checked.
|
// missing --output produces, so it is always checked — against the temp file this
|
||||||
if (!(await isNonEmptyFile(outPath))) {
|
// run was told to write, never against a destination a previous run may have left
|
||||||
if (usesImage) {
|
// behind.
|
||||||
// A clean exit with no file, on a run that used --image, is the crash seen from
|
throw new BackendError(
|
||||||
// the outside: the process was reaped before it could write anything.
|
`no output produced at ${filePath}`,
|
||||||
const kind = classifyRunFailure(outcome);
|
`Nessuna immagine prodotta in «${reportPath}», nonostante il comando sia terminato senza errori.`,
|
||||||
if (kind !== "other") throw issue121Error(kind === "crash", detail);
|
tail(outcome.stderr || outcome.stdout, 2_000),
|
||||||
}
|
);
|
||||||
throw new BackendError(
|
|
||||||
`no output produced at ${outPath}`,
|
|
||||||
`Nessuna immagine prodotta in «${outPath}», nonostante il comando sia terminato senza errori.`,
|
|
||||||
tail(outcome.stderr || outcome.stdout, 2_000),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ import {
|
|||||||
import type { Backend } from "../backends/types.ts";
|
import type { Backend } from "../backends/types.ts";
|
||||||
import type { DirectorContext, ParsedBrief } from "../design/director.ts";
|
import type { DirectorContext, ParsedBrief } from "../design/director.ts";
|
||||||
import { foldAccents, normaliseSlug } from "../job.ts";
|
import { foldAccents, normaliseSlug } from "../job.ts";
|
||||||
import S, { bullets, errorText, fill, list, menu } from "../ui/strings.ts";
|
import { S, bullets, errorText, fill, list, menu } from "../ui/strings.ts";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Dependencies — injected so index.ts owns construction and tests own the rest
|
// Dependencies — injected so index.ts owns construction and tests own the rest
|
||||||
@@ -102,7 +102,7 @@ function agentDir(deps: PresetsDeps): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads the file as it is on disk — NOT the DEFAULTS-merged view. Anything we do not
|
* Reads the file as it is on disk — NOT the DEFAULTS-merged view. Anything we do not
|
||||||
* understand (a future key, a hand-written comment-free tweak) survives the round trip.
|
* understand (a key from a newer version, something he added by hand) survives the trip.
|
||||||
* A broken file throws instead of being overwritten: losing his settings is worse than
|
* A broken file throws instead of being overwritten: losing his settings is worse than
|
||||||
* refusing to save.
|
* refusing to save.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -105,8 +105,25 @@ export interface ResolvedSpec extends DesignSpec {
|
|||||||
dpi: number;
|
dpi: number;
|
||||||
cropMarks: boolean;
|
cropMarks: boolean;
|
||||||
};
|
};
|
||||||
art_file: string; // absolute path to the artwork actually placed
|
/**
|
||||||
ink_resolved: string; // contrast-checked, may override palette.ink
|
* ROOT-RELATIVE path to the artwork ("/art.png"). Typst resolves a leading "/" against
|
||||||
needs_scrim: boolean; // from luminance AND variance of the region behind text
|
* --root, NOT the filesystem: handing it an absolute path silently yields
|
||||||
font_files: Record<string, string>; // family name -> absolute .ttf path
|
* "<root>/home/you/..." and a file-not-found. Same rule for logo.path.
|
||||||
|
*/
|
||||||
|
art_file: string;
|
||||||
|
/**
|
||||||
|
* Ink measured against the ARTWORK, for templates that set type over the picture.
|
||||||
|
* `ink_resolved` is the legacy alias and is kept in sync for older templates.
|
||||||
|
*/
|
||||||
|
ink_on_art: string;
|
||||||
|
/** Ink for type sitting on the flat page background. */
|
||||||
|
ink_on_bg: string;
|
||||||
|
/** @deprecated use ink_on_art; retained so nothing breaks mid-migration. */
|
||||||
|
ink_resolved: string;
|
||||||
|
/** From luminance AND variance behind the text — variance alone can require a scrim. */
|
||||||
|
needs_scrim: boolean;
|
||||||
|
/** family name -> absolute .ttf path (font paths are NOT root-relative). */
|
||||||
|
font_files: Record<string, string>;
|
||||||
|
/** family name -> variable axis ranges, e.g. { Archivo: { wdth: [62,125] } }. */
|
||||||
|
font_axes: Record<string, Record<string, [number, number]>>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* Contrast analysis — decides the ink colour and whether a scrim is needed, BEFORE
|
||||||
|
* Typst runs. Keeping this in TypeScript keeps the templates pure functions of the spec.
|
||||||
|
*
|
||||||
|
* The rule that matters: text must be legible against what is ACTUALLY behind it. A mean
|
||||||
|
* luminance check alone is not enough — white text over busy artwork can have a perfectly
|
||||||
|
* comfortable mean and still be unreadable, so variance forces a scrim independently.
|
||||||
|
*/
|
||||||
|
import sharp from "sharp";
|
||||||
|
import type { TemplateName } from "../design/spec.ts";
|
||||||
|
|
||||||
|
/** WCAG AA for large text. Everything a poster sets is large, so this is the honest bar. */
|
||||||
|
export const MIN_CONTRAST = 3.0;
|
||||||
|
|
||||||
|
/** Above this per-channel stdev the region is "busy" and needs a scrim regardless of mean. */
|
||||||
|
export const BUSY_STDEV = 48;
|
||||||
|
|
||||||
|
export interface Region {
|
||||||
|
/** All fractions of the image, 0..1. */
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegionAnalysis {
|
||||||
|
luminance: number;
|
||||||
|
stdev: number;
|
||||||
|
busy: boolean;
|
||||||
|
ink: string;
|
||||||
|
contrast: number;
|
||||||
|
needsScrim: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** sRGB channel (0..255) -> linear 0..1. */
|
||||||
|
function linearise(v: number): number {
|
||||||
|
const u = v / 255;
|
||||||
|
return u <= 0.04045 ? u / 12.92 : Math.pow((u + 0.055) / 1.055, 2.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function relativeLuminance(r: number, g: number, b: number): number {
|
||||||
|
return 0.2126 * linearise(r) + 0.7152 * linearise(g) + 0.0722 * linearise(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contrastRatio(l1: number, l2: number): number {
|
||||||
|
const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1];
|
||||||
|
return (hi + 0.05) / (lo + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hexLuminance(hex: string): number {
|
||||||
|
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
||||||
|
if (!m) return 0;
|
||||||
|
const n = parseInt(m[1], 16);
|
||||||
|
return relativeLuminance((n >> 16) & 255, (n >> 8) & 255, n & 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where each template puts its type, as a fraction of the artwork.
|
||||||
|
*
|
||||||
|
* These mirror the layouts in templates/*.typ. `framed` and `split` are deliberately
|
||||||
|
* absent: neither places text over the picture at all (split says so in its own source),
|
||||||
|
* so there is nothing to measure and no scrim to draw.
|
||||||
|
*/
|
||||||
|
export const TEXT_REGIONS: Partial<Record<TemplateName, Region>> = {
|
||||||
|
"hero-bottom": { left: 0, top: 0.55, width: 1, height: 0.45 },
|
||||||
|
banded: { left: 0, top: 0.34, width: 1, height: 0.32 },
|
||||||
|
"centred-stack": { left: 0.06, top: 0.22, width: 0.88, height: 0.56 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** True when this template sets type over the artwork. */
|
||||||
|
export function typeSitsOnArt(template: TemplateName): boolean {
|
||||||
|
return template in TEXT_REGIONS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measure the region of `imagePath` that will sit behind the text.
|
||||||
|
*
|
||||||
|
* Returns the ink to use over the ARTWORK plus whether a scrim is required. Never throws
|
||||||
|
* on a missing or unreadable image: a poster that renders with a safe default beats a
|
||||||
|
* crash, so the fallback is white ink with a scrim.
|
||||||
|
*/
|
||||||
|
export async function analyseRegion(imagePath: string, region: Region): Promise<RegionAnalysis> {
|
||||||
|
try {
|
||||||
|
const img = sharp(imagePath);
|
||||||
|
const meta = await img.metadata();
|
||||||
|
const W = meta.width ?? 0;
|
||||||
|
const H = meta.height ?? 0;
|
||||||
|
if (!W || !H) return fallback();
|
||||||
|
|
||||||
|
// Clamp: a region partly outside the image must still yield a valid extract box.
|
||||||
|
const left = Math.max(0, Math.min(W - 1, Math.round(region.left * W)));
|
||||||
|
const top = Math.max(0, Math.min(H - 1, Math.round(region.top * H)));
|
||||||
|
const width = Math.max(1, Math.min(W - left, Math.round(region.width * W)));
|
||||||
|
const height = Math.max(1, Math.min(H - top, Math.round(region.height * H)));
|
||||||
|
|
||||||
|
// Flatten onto mid-grey first: an alpha channel would otherwise skew the means, and
|
||||||
|
// .stats() reports premultiplied values that misrepresent what a viewer sees.
|
||||||
|
const stats = await sharp(imagePath)
|
||||||
|
.extract({ left, top, width, height })
|
||||||
|
.flatten({ background: { r: 128, g: 128, b: 128 } })
|
||||||
|
.toColourspace("srgb")
|
||||||
|
.stats();
|
||||||
|
|
||||||
|
const ch = stats.channels.slice(0, 3);
|
||||||
|
if (ch.length < 3) return fallback();
|
||||||
|
|
||||||
|
const luminance = relativeLuminance(ch[0].mean, ch[1].mean, ch[2].mean);
|
||||||
|
const stdev = Math.max(...ch.map((c) => c.stdev));
|
||||||
|
const busy = stdev > BUSY_STDEV;
|
||||||
|
|
||||||
|
const white = 1.0;
|
||||||
|
const black = 0.0;
|
||||||
|
const rWhite = contrastRatio(white, luminance);
|
||||||
|
const rBlack = contrastRatio(black, luminance);
|
||||||
|
const useWhite = rWhite >= rBlack;
|
||||||
|
|
||||||
|
return {
|
||||||
|
luminance,
|
||||||
|
stdev,
|
||||||
|
busy,
|
||||||
|
ink: useWhite ? "#ffffff" : "#111111",
|
||||||
|
contrast: Math.max(rWhite, rBlack),
|
||||||
|
// A scrim goes on when contrast is short OR the region is busy. The busy case is
|
||||||
|
// the one a mean-only check silently gets wrong.
|
||||||
|
needsScrim: Math.max(rWhite, rBlack) < MIN_CONTRAST || busy,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return fallback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallback(): RegionAnalysis {
|
||||||
|
return {
|
||||||
|
luminance: 0.5,
|
||||||
|
stdev: 0,
|
||||||
|
busy: false,
|
||||||
|
ink: "#ffffff",
|
||||||
|
contrast: 1,
|
||||||
|
needsScrim: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crop `input` to exactly w x h using sharp's attention strategy, which keeps the most
|
||||||
|
* salient part of the picture. (`smartcrop.js` is five years unmaintained — not used.)
|
||||||
|
*/
|
||||||
|
export async function smartCrop(input: string, out: string, w: number, h: number): Promise<string> {
|
||||||
|
await sharp(input)
|
||||||
|
.resize({ width: w, height: h, fit: "cover", position: sharp.strategy.attention })
|
||||||
|
.toFile(out);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pixel dimensions of an image, or null when it cannot be read. */
|
||||||
|
export async function imageSize(path: string): Promise<{ width: number; height: number } | null> {
|
||||||
|
try {
|
||||||
|
const m = await sharp(path).metadata();
|
||||||
|
return m.width && m.height ? { width: m.width, height: m.height } : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* Print pre-flight — what to check before a PDF goes to a copy shop.
|
||||||
|
*
|
||||||
|
* Every tool here is optional. A missing tool is reported as "non installato", never as
|
||||||
|
* a failure: refusing to hand over a poster because `qpdf` is absent would be absurd.
|
||||||
|
* The one check that needs no tools at all is the resolution check, and that is the
|
||||||
|
* failure that actually happens — a 1024px generation is ~62 dpi across A3's long edge.
|
||||||
|
*/
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { FORMATS_GEOMETRY, effectiveDpi, trimMm } from "./formats.ts";
|
||||||
|
import type { Format } from "../design/spec.ts";
|
||||||
|
|
||||||
|
export type CheckStatus = "ok" | "warn" | "fail" | "skipped";
|
||||||
|
|
||||||
|
export interface Check {
|
||||||
|
id: string;
|
||||||
|
status: CheckStatus;
|
||||||
|
/** Italian, shown to the user. */
|
||||||
|
message: string;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreflightReport {
|
||||||
|
path: string;
|
||||||
|
checks: Check[];
|
||||||
|
ok: boolean;
|
||||||
|
/** Italian summary, ready to print. */
|
||||||
|
summary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimum acceptable image resolution for print. 300 is the target; 150 is the floor. */
|
||||||
|
export const PRINT_DPI_TARGET = 300;
|
||||||
|
export const PRINT_DPI_FLOOR = 150;
|
||||||
|
|
||||||
|
function run(cmd: string, args: string[], timeoutMs = 15000): Promise<{ code: number | null; out: string; missing: boolean }> {
|
||||||
|
return new Promise((res) => {
|
||||||
|
let child: ReturnType<typeof spawn>;
|
||||||
|
try {
|
||||||
|
child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||||
|
} catch {
|
||||||
|
res({ code: null, out: "", missing: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let out = "";
|
||||||
|
const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
|
||||||
|
child.stdout?.on("data", (d) => { out += String(d); });
|
||||||
|
child.stderr?.on("data", (d) => { out += String(d); });
|
||||||
|
child.on("error", () => { clearTimeout(timer); res({ code: null, out: "", missing: true }); });
|
||||||
|
child.on("close", (code) => { clearTimeout(timer); res({ code, out, missing: false }); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure, tool-free: is the artwork big enough for this print size?
|
||||||
|
* This is the check that catches the mistake people actually make.
|
||||||
|
*/
|
||||||
|
export function checkArtResolution(
|
||||||
|
format: Format,
|
||||||
|
art: { width: number; height: number },
|
||||||
|
): Check {
|
||||||
|
const geo = FORMATS_GEOMETRY[format];
|
||||||
|
if (geo.kind !== "print") {
|
||||||
|
return { id: "risoluzione", status: "skipped", message: "Formato per schermo: risoluzione non rilevante." };
|
||||||
|
}
|
||||||
|
const trim = trimMm(format);
|
||||||
|
const dpiW = effectiveDpi(art.width, trim.widthMm);
|
||||||
|
const dpiH = effectiveDpi(art.height, trim.heightMm);
|
||||||
|
const dpi = Math.min(dpiW, dpiH);
|
||||||
|
const r = Math.round(dpi);
|
||||||
|
if (dpi >= PRINT_DPI_TARGET) {
|
||||||
|
return { id: "risoluzione", status: "ok", message: `Risoluzione immagine ${r} dpi: ottima per la stampa.` };
|
||||||
|
}
|
||||||
|
if (dpi >= PRINT_DPI_FLOOR) {
|
||||||
|
return {
|
||||||
|
id: "risoluzione",
|
||||||
|
status: "warn",
|
||||||
|
message: `Risoluzione immagine ${r} dpi: accettabile solo per formati grandi visti da lontano.`,
|
||||||
|
detail: `${art.width}x${art.height} px su ${trim.widthMm}x${trim.heightMm} mm`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: "risoluzione",
|
||||||
|
status: "fail",
|
||||||
|
message: `Risoluzione immagine troppo bassa (${r} dpi): va ingrandita prima di stampare.`,
|
||||||
|
detail: `${art.width}x${art.height} px su ${trim.widthMm}x${trim.heightMm} mm — servono almeno ${PRINT_DPI_FLOOR} dpi`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full pre-flight on a produced PDF. Never throws. */
|
||||||
|
export async function checkPdf(path: string): Promise<PreflightReport> {
|
||||||
|
const checks: Check[] = [];
|
||||||
|
|
||||||
|
if (!existsSync(path)) {
|
||||||
|
const c: Check = { id: "file", status: "fail", message: "Il file PDF non esiste." };
|
||||||
|
return { path, checks: [c], ok: false, summary: renderSummary([c]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Page geometry + TrimBox. A TrimBox is what tells the printer where to cut.
|
||||||
|
const info = await run("pdfinfo", ["-box", path]);
|
||||||
|
if (info.missing) {
|
||||||
|
checks.push({ id: "geometria", status: "skipped", message: "pdfinfo non installato (brew install poppler): geometria non verificata." });
|
||||||
|
} else {
|
||||||
|
const hasTrim = /TrimBox:/i.test(info.out);
|
||||||
|
const size = /Page size:\s*([0-9.]+)\s*x\s*([0-9.]+)\s*pts/i.exec(info.out);
|
||||||
|
const mm = size ? `${(parseFloat(size[1]) / 72 * 25.4).toFixed(1)}x${(parseFloat(size[2]) / 72 * 25.4).toFixed(1)} mm` : "sconosciuta";
|
||||||
|
checks.push(hasTrim
|
||||||
|
? { id: "geometria", status: "ok", message: `Pagina ${mm} con TrimBox: la tipografia sa dove tagliare.` }
|
||||||
|
: { id: "geometria", status: "warn", message: `Pagina ${mm} senza TrimBox: senza abbondanza la tipografia taglia a occhio.` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fonts must be embedded or the shop's machine substitutes something else.
|
||||||
|
const fonts = await run("pdffonts", [path]);
|
||||||
|
if (fonts.missing) {
|
||||||
|
checks.push({ id: "caratteri", status: "skipped", message: "pdffonts non installato (brew install poppler): caratteri non verificati." });
|
||||||
|
} else {
|
||||||
|
// pdffonts columns: name type encoding emb sub uni object ID
|
||||||
|
// "emb" is the 4th-from-last column; "no" there means the font is NOT embedded.
|
||||||
|
// ("sub: yes" is normal and desirable — that is subsetting, not a problem.)
|
||||||
|
const lines = fonts.out.split("\n").slice(2).filter((l) => l.trim());
|
||||||
|
const notEmbedded = lines.filter((l) => {
|
||||||
|
const cols = l.trim().split(/\s+/);
|
||||||
|
return cols.length >= 4 && cols[cols.length - 4] === "no";
|
||||||
|
});
|
||||||
|
checks.push(!lines.length
|
||||||
|
? { id: "caratteri", status: "warn", message: "Nessun carattere incorporato trovato: il PDF potrebbe non contenere testo." }
|
||||||
|
: notEmbedded.length
|
||||||
|
? {
|
||||||
|
id: "caratteri",
|
||||||
|
status: "fail",
|
||||||
|
message: `${notEmbedded.length} carattere/i NON incorporato/i: in tipografia il testo cambierebbe aspetto.`,
|
||||||
|
detail: notEmbedded.map((l) => l.trim().split(/\s+/)[0]).join(", "),
|
||||||
|
}
|
||||||
|
: { id: "caratteri", status: "ok", message: `Tutti i caratteri sono incorporati (${lines.length}).` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Image resolution inside the PDF.
|
||||||
|
const imgs = await run("pdfimages", ["-list", path]);
|
||||||
|
if (imgs.missing) {
|
||||||
|
checks.push({ id: "immagini", status: "skipped", message: "pdfimages non installato (brew install poppler): risoluzione non verificata." });
|
||||||
|
} else {
|
||||||
|
const dpis: number[] = [];
|
||||||
|
for (const line of imgs.out.split("\n").slice(2)) {
|
||||||
|
const cols = line.trim().split(/\s+/);
|
||||||
|
if (cols.length > 14) {
|
||||||
|
const x = parseFloat(cols[12]);
|
||||||
|
const y = parseFloat(cols[13]);
|
||||||
|
if (Number.isFinite(x) && Number.isFinite(y)) dpis.push(Math.min(x, y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const lowest = dpis.length ? Math.min(...dpis) : null;
|
||||||
|
checks.push(lowest === null
|
||||||
|
? { id: "immagini", status: "skipped", message: "Nessuna immagine rasterizzata nel PDF." }
|
||||||
|
: lowest >= PRINT_DPI_TARGET
|
||||||
|
? { id: "immagini", status: "ok", message: `Immagini a ${Math.round(lowest)} dpi: ottimo.` }
|
||||||
|
: lowest >= PRINT_DPI_FLOOR
|
||||||
|
? { id: "immagini", status: "warn", message: `Immagini a ${Math.round(lowest)} dpi: accettabile solo per formati grandi.` }
|
||||||
|
: { id: "immagini", status: "fail", message: `Immagini a soli ${Math.round(lowest)} dpi: troppo poco per la stampa.` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Structural integrity.
|
||||||
|
const q = await run("qpdf", ["--check", path]);
|
||||||
|
if (q.missing) {
|
||||||
|
checks.push({ id: "integrita", status: "skipped", message: "qpdf non installato (brew install qpdf): integrità non verificata." });
|
||||||
|
} else {
|
||||||
|
checks.push(q.code === 0
|
||||||
|
? { id: "integrita", status: "ok", message: "Struttura del PDF valida." }
|
||||||
|
: { id: "integrita", status: "warn", message: "qpdf segnala qualcosa di anomalo nel PDF.", detail: q.out.split("\n").slice(0, 4).join("\n") });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = !checks.some((c) => c.status === "fail");
|
||||||
|
return { path, checks, ok, summary: renderSummary(checks) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICON: Record<CheckStatus, string> = { ok: "✓", warn: "!", fail: "✗", skipped: "–" };
|
||||||
|
|
||||||
|
function renderSummary(checks: Check[]): string {
|
||||||
|
const lines = checks.map((c) => ` ${ICON[c.status]} ${c.message}`);
|
||||||
|
const failed = checks.filter((c) => c.status === "fail").length;
|
||||||
|
const warned = checks.filter((c) => c.status === "warn").length;
|
||||||
|
const head = failed
|
||||||
|
? `Controllo stampa: ${failed} problema/i da risolvere.`
|
||||||
|
: warned
|
||||||
|
? `Controllo stampa: pronto, con ${warned} avvertenza/e.`
|
||||||
|
: "Controllo stampa: tutto a posto.";
|
||||||
|
return [head, ...lines].join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
/**
|
||||||
|
* The Typst renderer — a pure `ResolvedSpec -> files` function. It never calls a model,
|
||||||
|
* which is exactly why changing a colour, a font or a date is a sub-second re-render
|
||||||
|
* instead of a two-minute regeneration.
|
||||||
|
*
|
||||||
|
* ## Why each job is a self-contained bundle
|
||||||
|
*
|
||||||
|
* Typst can only read files under `--root`. Templates live in the package; artwork and
|
||||||
|
* output live in the user's pictures folder — two trees with no useful common ancestor
|
||||||
|
* (and rooting at "/" would be indefensible). So each job gets its own copy:
|
||||||
|
*
|
||||||
|
* <job>/
|
||||||
|
* spec.json the DesignSpec, human-editable
|
||||||
|
* art.png the artwork
|
||||||
|
* .typst/lib.typ copied kernel
|
||||||
|
* .typst/<tpl>.typ copied template
|
||||||
|
* .typst/resolved.json what the template actually reads
|
||||||
|
*
|
||||||
|
* `--root` is the job directory. That makes every job hermetic and re-renderable years
|
||||||
|
* later even if the package moved or changed — which is what makes "re-do last year's
|
||||||
|
* poster with the new date" a no-model operation.
|
||||||
|
*/
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { mkdir, copyFile, writeFile } from "node:fs/promises";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { join, dirname, basename, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import type { DesignSpec, ResolvedSpec, Format, TemplateName } from "../design/spec.ts";
|
||||||
|
import type { ImgenConfig } from "../config.ts";
|
||||||
|
import { FONTS, byFamily } from "../design/fonts.ts";
|
||||||
|
import {
|
||||||
|
FORMATS_GEOMETRY,
|
||||||
|
PRINT_DPI,
|
||||||
|
SCREEN_DPI,
|
||||||
|
trimMm,
|
||||||
|
artTargetSize,
|
||||||
|
type PrintTarget,
|
||||||
|
} from "./formats.ts";
|
||||||
|
import {
|
||||||
|
analyseRegion,
|
||||||
|
typeSitsOnArt,
|
||||||
|
TEXT_REGIONS,
|
||||||
|
hexLuminance,
|
||||||
|
contrastRatio,
|
||||||
|
MIN_CONTRAST,
|
||||||
|
} from "./contrast.ts";
|
||||||
|
|
||||||
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||||
|
/** extensions/imgen/render -> package root */
|
||||||
|
export const PACKAGE_ROOT = resolve(HERE, "..", "..", "..");
|
||||||
|
export const TEMPLATES_DIR = join(PACKAGE_ROOT, "templates");
|
||||||
|
export const FONTS_DIR = join(PACKAGE_ROOT, "vendor", "fonts");
|
||||||
|
|
||||||
|
export class TypstError extends Error {
|
||||||
|
constructor(readonly italian: string, readonly diagnostic: string) {
|
||||||
|
super(`typst failed: ${diagnostic.split("\n")[0] ?? "unknown"}`);
|
||||||
|
this.name = "TypstError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderOptions {
|
||||||
|
/** "pdf" carries the bleed and a TrimBox; "png" comes out already TRIMMED. */
|
||||||
|
format?: "pdf" | "png" | "svg";
|
||||||
|
ppi?: number;
|
||||||
|
/** typst binary; overridable for tests. */
|
||||||
|
binary?: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Locate a bundled family's .ttf. Absent families simply fall through to Typst's own. */
|
||||||
|
function fontFileFor(family: string): string | null {
|
||||||
|
const entry = byFamily(family);
|
||||||
|
if (!entry) return null;
|
||||||
|
const dir = join(FONTS_DIR, entry.id);
|
||||||
|
if (!existsSync(dir)) return null;
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function axesFor(family: string): Record<string, [number, number]> | null {
|
||||||
|
const entry = byFamily(family);
|
||||||
|
if (!entry?.axes) return null;
|
||||||
|
const out: Record<string, [number, number]> = {};
|
||||||
|
for (const [axis, range] of Object.entries(entry.axes)) {
|
||||||
|
if (Array.isArray(range)) out[axis] = [range[0], range[1]];
|
||||||
|
}
|
||||||
|
return Object.keys(out).length ? out : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn a DesignSpec into everything the template needs, measuring contrast against the
|
||||||
|
* artwork rather than trusting the art director's guess.
|
||||||
|
*/
|
||||||
|
export async function resolveSpec(
|
||||||
|
spec: DesignSpec,
|
||||||
|
cfg: ImgenConfig,
|
||||||
|
artPath: string,
|
||||||
|
opts: { format?: Format; artFileName?: string } = {},
|
||||||
|
): Promise<ResolvedSpec> {
|
||||||
|
const format = opts.format ?? spec.format;
|
||||||
|
const geo = FORMATS_GEOMETRY[format];
|
||||||
|
const isPrint = geo.kind === "print";
|
||||||
|
const trim = trimMm(format);
|
||||||
|
|
||||||
|
// Only measure when the template actually sets type over the picture. `framed` and
|
||||||
|
// `split` do not, so there is nothing to measure and no scrim to draw.
|
||||||
|
const region = TEXT_REGIONS[spec.template as TemplateName];
|
||||||
|
const analysis = region
|
||||||
|
? await analyseRegion(artPath, region)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const inkOnArt = analysis?.ink ?? spec.palette.ink;
|
||||||
|
|
||||||
|
// Ink for flat background: keep the art director's choice when it is legible, else
|
||||||
|
// flip to whichever of black/white actually reads against the page colour.
|
||||||
|
const bgLum = hexLuminance(spec.palette.bg);
|
||||||
|
const declaredLum = hexLuminance(spec.palette.ink);
|
||||||
|
const inkOnBg =
|
||||||
|
contrastRatio(declaredLum, bgLum) >= MIN_CONTRAST
|
||||||
|
? spec.palette.ink
|
||||||
|
: bgLum > 0.5
|
||||||
|
? "#111111"
|
||||||
|
: "#ffffff";
|
||||||
|
|
||||||
|
const fontFiles: Record<string, string> = {};
|
||||||
|
const fontAxes: Record<string, Record<string, [number, number]>> = {};
|
||||||
|
for (const family of [spec.fonts.display, spec.fonts.body]) {
|
||||||
|
const dir = fontFileFor(family);
|
||||||
|
if (dir) fontFiles[family] = dir;
|
||||||
|
const axes = axesFor(family);
|
||||||
|
if (axes) fontAxes[family] = axes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const artName = opts.artFileName ?? basename(artPath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...spec,
|
||||||
|
format,
|
||||||
|
page: {
|
||||||
|
widthMm: trim.widthMm,
|
||||||
|
heightMm: trim.heightMm,
|
||||||
|
bleedMm: isPrint ? cfg.print.bleedMm : 0,
|
||||||
|
safeMm: cfg.print.safeMm,
|
||||||
|
dpi: isPrint ? (cfg.print.dpi || PRINT_DPI) : SCREEN_DPI,
|
||||||
|
cropMarks: isPrint ? cfg.print.cropMarks : false,
|
||||||
|
},
|
||||||
|
art_file: `/${artName}`, // root-relative: see the module comment
|
||||||
|
ink_on_art: inkOnArt,
|
||||||
|
ink_on_bg: inkOnBg,
|
||||||
|
ink_resolved: inkOnArt, // legacy alias
|
||||||
|
needs_scrim: analysis?.needsScrim ?? false,
|
||||||
|
font_files: fontFiles,
|
||||||
|
font_axes: fontAxes,
|
||||||
|
// A logo path must also be root-relative; the caller stages the file into the job.
|
||||||
|
logo: spec.logo ? { ...spec.logo, path: `/${basename(spec.logo.path)}` } : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copy the kernel and one template into <job>/.typst so the job is self-contained. */
|
||||||
|
export async function stageBundle(jobDir: string, template: TemplateName): Promise<string> {
|
||||||
|
const dir = join(jobDir, ".typst");
|
||||||
|
await mkdir(dir, { recursive: true });
|
||||||
|
const tpl = join(TEMPLATES_DIR, `${template}.typ`);
|
||||||
|
if (!existsSync(tpl)) {
|
||||||
|
throw new TypstError(
|
||||||
|
`Impaginazione sconosciuta: "${template}".`,
|
||||||
|
`template not found: ${tpl}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await copyFile(join(TEMPLATES_DIR, "lib.typ"), join(dir, "lib.typ"));
|
||||||
|
await copyFile(tpl, join(dir, `${template}.typ`));
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render one output. `jobDir` must already hold the artwork (and the logo, if any).
|
||||||
|
* Returns the output path.
|
||||||
|
*/
|
||||||
|
export async function render(
|
||||||
|
resolved: ResolvedSpec,
|
||||||
|
jobDir: string,
|
||||||
|
outPath: string,
|
||||||
|
opts: RenderOptions = {},
|
||||||
|
): Promise<string> {
|
||||||
|
const template = resolved.template as TemplateName;
|
||||||
|
const stage = await stageBundle(jobDir, template);
|
||||||
|
const specPath = join(stage, "resolved.json");
|
||||||
|
await writeFile(specPath, JSON.stringify(resolved, null, 2), "utf8");
|
||||||
|
await mkdir(dirname(outPath), { recursive: true });
|
||||||
|
|
||||||
|
const fmt = opts.format ?? (outPath.endsWith(".png") ? "png" : outPath.endsWith(".svg") ? "svg" : "pdf");
|
||||||
|
const args = [
|
||||||
|
"compile",
|
||||||
|
join(stage, `${template}.typ`),
|
||||||
|
outPath,
|
||||||
|
"--root", jobDir,
|
||||||
|
// Root-relative, NOT an absolute filesystem path.
|
||||||
|
"--input", `specfile=/.typst/resolved.json`,
|
||||||
|
"--format", fmt,
|
||||||
|
// Only our bundled fonts are visible, so output is identical on any machine. This is
|
||||||
|
// what makes the golden-file tests meaningful.
|
||||||
|
"--ignore-system-fonts",
|
||||||
|
];
|
||||||
|
if (existsSync(FONTS_DIR)) args.push("--font-path", FONTS_DIR);
|
||||||
|
if (fmt === "png") args.push("--ppi", String(opts.ppi ?? resolved.page.dpi ?? SCREEN_DPI));
|
||||||
|
|
||||||
|
await runTypst(opts.binary ?? "typst", args, opts.signal);
|
||||||
|
return outPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runTypst(binary: string, args: string[], signal?: AbortSignal): Promise<void> {
|
||||||
|
return new Promise((resolvePromise, reject) => {
|
||||||
|
let child: ReturnType<typeof spawn>;
|
||||||
|
try {
|
||||||
|
// No shell: paths under /Volumes may contain spaces.
|
||||||
|
child = spawn(binary, args, {
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
// Belt and braces for reproducibility; `#set document(date: none)` already
|
||||||
|
// removes the only clock-dependent field.
|
||||||
|
env: { ...process.env, SOURCE_DATE_EPOCH: "0" },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
reject(new TypstError(
|
||||||
|
"Typst non è installato. Esegui install.sh oppure: brew install typst",
|
||||||
|
String(e),
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let err = "";
|
||||||
|
child.stderr?.on("data", (d) => { err += String(d); });
|
||||||
|
const onAbort = () => child.kill("SIGTERM");
|
||||||
|
signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
|
||||||
|
child.on("error", (e) => {
|
||||||
|
signal?.removeEventListener("abort", onAbort);
|
||||||
|
reject(new TypstError(
|
||||||
|
"Typst non è installato o non è eseguibile. Prova: brew install typst",
|
||||||
|
String(e),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
child.on("close", (code) => {
|
||||||
|
signal?.removeEventListener("abort", onAbort);
|
||||||
|
if (signal?.aborted) {
|
||||||
|
reject(new TypstError("Composizione annullata.", "aborted"));
|
||||||
|
} else if (code === 0) {
|
||||||
|
resolvePromise();
|
||||||
|
} else {
|
||||||
|
reject(new TypstError(
|
||||||
|
"Non sono riuscito a comporre la locandina. Dettaglio tecnico qui sotto.",
|
||||||
|
err.trim() || `typst exited ${code}`,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FormatOutput {
|
||||||
|
format: Format;
|
||||||
|
path: string;
|
||||||
|
kind: "print" | "screen";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One spec -> every requested format. The auto-fit is re-solved per aspect ratio inside
|
||||||
|
* the template, so a layout is never merely scaled.
|
||||||
|
*/
|
||||||
|
export async function renderAllFormats(
|
||||||
|
spec: DesignSpec,
|
||||||
|
cfg: ImgenConfig,
|
||||||
|
jobDir: string,
|
||||||
|
artPath: string,
|
||||||
|
formats: Format[],
|
||||||
|
opts: RenderOptions = {},
|
||||||
|
): Promise<FormatOutput[]> {
|
||||||
|
const out: FormatOutput[] = [];
|
||||||
|
for (const format of formats) {
|
||||||
|
const geo = FORMATS_GEOMETRY[format];
|
||||||
|
const resolved = await resolveSpec(spec, cfg, artPath, { format });
|
||||||
|
if (geo.kind === "print") {
|
||||||
|
// PDF carries the bleed and the TrimBox — this is the file for the copy shop.
|
||||||
|
out.push({
|
||||||
|
format,
|
||||||
|
path: await render(resolved, jobDir, join(jobDir, `${format}.pdf`), { ...opts, format: "pdf" }),
|
||||||
|
kind: "print",
|
||||||
|
});
|
||||||
|
// PNG of the same page comes out already trimmed (no bleed) — handy for preview.
|
||||||
|
out.push({
|
||||||
|
format,
|
||||||
|
path: await render(resolved, jobDir, join(jobDir, `${format}.png`), {
|
||||||
|
...opts, format: "png", ppi: cfg.print.dpi || PRINT_DPI,
|
||||||
|
}),
|
||||||
|
kind: "print",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
out.push({
|
||||||
|
format,
|
||||||
|
path: await render(resolved, jobDir, join(jobDir, `${format}.png`), {
|
||||||
|
...opts, format: "png", ppi: ppiForScreen(format),
|
||||||
|
}),
|
||||||
|
kind: "screen",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ppi that lands a screen format on its exact pixel size. */
|
||||||
|
function ppiForScreen(format: Format): number {
|
||||||
|
const geo = FORMATS_GEOMETRY[format];
|
||||||
|
const trim = trimMm(format);
|
||||||
|
if (!trim.widthMm) return SCREEN_DPI;
|
||||||
|
return Math.round((geo.pxWidth / trim.widthMm) * 25.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { artTargetSize, type PrintTarget };
|
||||||
+2
-1
@@ -36,7 +36,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||||
"test": "tests/render-fixtures.sh",
|
"test": "tests/render-fixtures.sh",
|
||||||
"fonts": "scripts/fetch-fonts.sh"
|
"fonts": "scripts/fetch-fonts.sh",
|
||||||
|
"test:e2e": "node tests/e2e/render.mjs && node tests/e2e/contrast.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.6.0",
|
"typescript": "^5.6.0",
|
||||||
|
|||||||
+12
-3
@@ -59,7 +59,9 @@
|
|||||||
# The family list is PARSED from extensions/imgen/design/fonts.ts. It is never
|
# 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.
|
# duplicated here — adding a family there is the only edit needed.
|
||||||
#
|
#
|
||||||
# Idempotent: a family whose manifest is complete is skipped unless --force.
|
# Idempotent: a family is skipped only when every file its manifest lists is
|
||||||
|
# still on disk (see manifest_complete), so a partial install is repaired
|
||||||
|
# instead of being skipped for ever. --force re-fetches regardless.
|
||||||
#
|
#
|
||||||
# Uso:
|
# Uso:
|
||||||
# scripts/fetch-fonts.sh [--force] [--only=<id|famiglia>]
|
# scripts/fetch-fonts.sh [--force] [--only=<id|famiglia>]
|
||||||
@@ -305,7 +307,10 @@ mkdir -p "$DEST"
|
|||||||
|
|
||||||
# --- per-source fetchers ----------------------------------------------------
|
# --- per-source fetchers ----------------------------------------------------
|
||||||
# Each prints the names of the files it installed into $stage, one per line, on
|
# 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.
|
# fd 3 (stdout is reserved for progress). ALL OR NOTHING: a fetcher returns
|
||||||
|
# non-zero unless every file it expected arrived intact, so a half-family is a
|
||||||
|
# failure the next tier gets a shot at, never something we install. Each also
|
||||||
|
# sets FETCH_EXPECTED to the number of files it expected.
|
||||||
|
|
||||||
# Tier 1 — google/fonts raw. Variable TTFs, full character set, exact names
|
# Tier 1 — google/fonts raw. Variable TTFs, full character set, exact names
|
||||||
# taken from METADATA.pb.
|
# taken from METADATA.pb.
|
||||||
@@ -746,7 +751,11 @@ if [ -n "$MISSING" ] || [ -n "$FAILED" ]; then
|
|||||||
fi
|
fi
|
||||||
if [ -n "$MISSING" ]; then
|
if [ -n "$MISSING" ]; then
|
||||||
fail "Incomplete su disco:$MISSING"
|
fail "Incomplete su disco:$MISSING"
|
||||||
fail "Una famiglia senza LICENSE non è ridistribuibile: non pubblicare questa build."
|
# The licence warning only when a LICENSE is what is actually missing:
|
||||||
|
# «(incompleta)» means faces are missing, which is a different problem.
|
||||||
|
case "$MISSING" in
|
||||||
|
*"(LICENSE)"*) fail "Una famiglia senza LICENSE non è ridistribuibile: non pubblicare questa build." ;;
|
||||||
|
esac
|
||||||
fi
|
fi
|
||||||
fail "Riprova con: scripts/fetch-fonts.sh --force --only=<id>"
|
fail "Riprova con: scripts/fetch-fonts.sh --force --only=<id>"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
+99
-100
@@ -42,7 +42,7 @@
|
|||||||
// Spec-derived constants
|
// Spec-derived constants
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#let pal = palette-of(spec)
|
#let pal = palette-of(spec) // surface: auto == the ink measured over the artwork
|
||||||
#let sa = safe-area(spec)
|
#let sa = safe-area(spec)
|
||||||
#let t = trim-mm(spec)
|
#let t = trim-mm(spec)
|
||||||
#let short = short-edge-mm(spec)
|
#let short = short-edge-mm(spec)
|
||||||
@@ -54,65 +54,23 @@
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Band colour
|
// Band colour
|
||||||
//
|
//
|
||||||
// `ink_resolved` was contrast-checked by the renderer against the ARTWORK, and this
|
// The band IS the accent, unshifted — that colour is the art director's decision and
|
||||||
// template must not second-guess it — but inside the band the type is not over the
|
// this template has no business second-guessing it. What changes instead is the INK on
|
||||||
// artwork, it is over flat accent, a pair nobody has checked. So the ink stays exactly
|
// top of it, because the ink the renderer measured was measured against the ARTWORK,
|
||||||
// as resolved and the BAND moves instead: keep the accent when it separates, otherwise
|
// and inside the band the type is not over the artwork at all. `palette-of(spec,
|
||||||
// push its lightness away from the ink while holding its hue. A slightly shifted red is
|
// surface: …)` is the kernel's answer to exactly that: hand it the flat colour the type
|
||||||
// a small art-direction concession; a title nobody can read is not recoverable.
|
// will sit on and it re-derives a legible ink (falling back to plain black or white
|
||||||
|
// before it will hand back something unreadable). `block-style(…, surface: …)` threads
|
||||||
|
// the same choice through every role's fill.
|
||||||
|
//
|
||||||
|
// So this template works with three surfaces:
|
||||||
|
// band — the accent
|
||||||
|
// strip — the poster's ink, used as a ground with the band's colour read back off it
|
||||||
|
// foot — the artwork itself, where the renderer's measured ink is the right one
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// sRGB -> linear, per WCAG 2.x relative luminance.
|
#let band-fill = pal.accent
|
||||||
#let _channel(u) = if u <= 0.04045 { u / 12.92 } else { calc.pow((u + 0.055) / 1.055, 2.4) }
|
#let strip-fill = pal.ink
|
||||||
|
|
||||||
#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
|
// Blocks -> zones
|
||||||
@@ -125,7 +83,11 @@
|
|||||||
|
|
||||||
#let _ZONES = (title: "band", subtitle: "band", date: "strip", venue: "strip")
|
#let _ZONES = (title: "band", subtitle: "band", date: "strip", venue: "strip")
|
||||||
|
|
||||||
#let _prepared = {
|
/// Pass one: role and zone only. `block-style` is the only thing that knows how to
|
||||||
|
/// normalise a role, so it is called here for its `role` field alone; the fills it
|
||||||
|
/// returns now would be the wrong ones, because which surface a block sits on is not
|
||||||
|
/// known until every block has been filed.
|
||||||
|
#let _zoned = {
|
||||||
let out = ()
|
let out = ()
|
||||||
let bs = _get(spec, "blocks", ())
|
let bs = _get(spec, "blocks", ())
|
||||||
if type(bs) == array {
|
if type(bs) == array {
|
||||||
@@ -133,16 +95,12 @@
|
|||||||
if type(b) == dictionary {
|
if type(b) == dictionary {
|
||||||
let raw = _get(b, "text", "")
|
let raw = _get(b, "text", "")
|
||||||
if type(raw) == str and raw.trim() != "" {
|
if type(raw) == str and raw.trim() != "" {
|
||||||
// `block-style` normalises the role, so `st.role` is never an unknown one.
|
let role = block-style(spec, _get(b, "role", none)).role
|
||||||
let st = block-style(spec, _get(b, "role", none))
|
|
||||||
out.push((
|
out.push((
|
||||||
role: st.role,
|
role: role,
|
||||||
zone: _ZONES.at(st.role, default: "lower"),
|
zone: _ZONES.at(role, default: "lower"),
|
||||||
// CAPS-ONLY families (Bebas Neue, Bungee) draw capitals at lowercase
|
raw: raw,
|
||||||
// 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,
|
optional: _get(b, "optional", false) == true,
|
||||||
style: st,
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,14 +109,56 @@
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On landscape the strip has nowhere to go, so it rides inside the band as a second
|
||||||
|
// column instead of a second bar — which also moves date and venue onto the band's
|
||||||
|
// surface, and therefore changes which ink they need.
|
||||||
|
#let split-band = landscape and _zoned.any(b => b.zone == "strip") and _zoned.any(b => b.zone == "band")
|
||||||
|
|
||||||
|
/// The ink the art director asked for, when it is legible on `surface`.
|
||||||
|
///
|
||||||
|
/// `palette-of(surface: …)` picks the MAXIMUM-contrast ink, which is the right default
|
||||||
|
/// and the wrong call for the band: cream on this template's red scores 3.7:1 and black
|
||||||
|
/// scores 5.0, so the kernel would swap a cream title for a black one and break the
|
||||||
|
/// palette the rest of the poster is built from. The kernel's own docstring says the
|
||||||
|
/// art director's ink should survive "whenever it is good enough", and on a headline
|
||||||
|
/// this size good enough is `MIN-CONTRAST`. So: keep it when it clears the bar, and
|
||||||
|
/// hand back `none` — meaning "use the kernel's choice" — when it does not.
|
||||||
|
#let _preferred-ink(surface) = {
|
||||||
|
if surface == auto { return none }
|
||||||
|
let declared = hex(_dig(spec, ("palette", "ink"), none), fallback: none)
|
||||||
|
if declared != none and contrast-ratio(declared, surface) >= MIN-CONTRAST { declared } else { none }
|
||||||
|
}
|
||||||
|
|
||||||
|
#let _SURFACE = (
|
||||||
|
band: band-fill,
|
||||||
|
strip: if split-band { band-fill } else { strip-fill },
|
||||||
|
lower: auto, // over the artwork: keep the renderer's measurement
|
||||||
|
)
|
||||||
|
|
||||||
|
/// Pass two: resolve each block's typography against the surface it actually lands on.
|
||||||
|
#let _prepared = _zoned.map(b => {
|
||||||
|
let surface = _SURFACE.at(b.zone)
|
||||||
|
let st = block-style(spec, b.role, surface: surface)
|
||||||
|
// Only roles whose fill IS the ink are re-considered; `date`'s accent and `footer`'s
|
||||||
|
// muted ink are deliberate colour decisions and stay exactly as the kernel resolved
|
||||||
|
// them against this surface.
|
||||||
|
let keep = if st.fill == palette-of(spec, surface: surface).ink { _preferred-ink(surface) } else { none }
|
||||||
|
(
|
||||||
|
role: b.role,
|
||||||
|
zone: b.zone,
|
||||||
|
ink: keep,
|
||||||
|
// 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(b.raw) } else { b.raw },
|
||||||
|
optional: b.optional,
|
||||||
|
style: st,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
#let band-blocks = _prepared.filter(b => b.zone == "band")
|
#let band-blocks = _prepared.filter(b => b.zone == "band")
|
||||||
#let strip-blocks = _prepared.filter(b => b.zone == "strip")
|
#let strip-blocks = _prepared.filter(b => b.zone == "strip")
|
||||||
#let lower-blocks = _prepared.filter(b => b.zone == "lower")
|
#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
|
// Type boxes
|
||||||
//
|
//
|
||||||
@@ -291,13 +291,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Auto-fit one prepared block into `w` x `h`, overriding only the colour: on the band
|
/// Auto-fit one prepared block into `w` x `h`. The colour needs no override here: the
|
||||||
/// the kernel's art-checked fill would be the wrong one, everywhere else it is right.
|
/// block's style was already resolved against the surface its zone lands on.
|
||||||
#let render-block(b, w, h, colour, al) = context {
|
#let render-block(b, w, h, al) = context {
|
||||||
let st = b.style
|
let st = b.style
|
||||||
let cap = word-cap(b.text, st, w)
|
let cap = word-cap(b.text, st, w)
|
||||||
let args = st.args
|
let args = st.args
|
||||||
if colour != none { args.insert("fill", colour) }
|
if _get(b, "ink", none) != none { args.insert("fill", b.ink) }
|
||||||
args.insert("size", cap.size)
|
args.insert("size", cap.size)
|
||||||
// Keep the floor at or below the (possibly capped) size, or `fit` would be asked to
|
// Keep the floor at or below the (possibly capped) size, or `fit` would be asked to
|
||||||
// bisect an empty range.
|
// bisect an empty range.
|
||||||
@@ -311,12 +311,12 @@
|
|||||||
/// is relative to the paragraph's OWN text size, so between a 109 pt title and its
|
/// is relative to the paragraph's OWN text size, so between a 109 pt title and its
|
||||||
/// subtitle it silently inserts 114 pt — a hole bigger than the subtitle. The vertical
|
/// subtitle it silently inserts 114 pt — a hole bigger than the subtitle. The vertical
|
||||||
/// rhythm of a poster is a design decision, so every gap here is an explicit `v()`.
|
/// rhythm of a poster is a design decision, so every gap here is an explicit `v()`.
|
||||||
#let stack-blocks(blocks, w, h-of, colour, al, gap) = {
|
#let stack-blocks(blocks, w, h-of, al, gap) = {
|
||||||
set par(spacing: 0pt)
|
set par(spacing: 0pt)
|
||||||
let first = true
|
let first = true
|
||||||
for b in blocks {
|
for b in blocks {
|
||||||
if not first { v(gap, weak: false) }
|
if not first { v(gap, weak: false) }
|
||||||
render-block(b, w, h-of(b), colour, al)
|
render-block(b, w, h-of(b), al)
|
||||||
first = false
|
first = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -325,23 +325,23 @@
|
|||||||
band-blocks,
|
band-blocks,
|
||||||
title-w,
|
title-w,
|
||||||
b => if b.role == "subtitle" { sub-h } else { title-h },
|
b => if b.role == "subtitle" { sub-h } else { title-h },
|
||||||
pal.ink,
|
|
||||||
band-align,
|
band-align,
|
||||||
short * 0.038 * 1mm,
|
short * 0.038 * 1mm,
|
||||||
)
|
)
|
||||||
|
|
||||||
/// date + venue: inside the band on landscape, on their own inverted strip otherwise.
|
/// date + venue: inside the band on landscape, on their own inverted strip otherwise.
|
||||||
#let strip-column(colour, al) = stack-blocks(
|
/// Either way their fills came from `_SURFACE.strip`, which already knows which.
|
||||||
|
#let strip-column(al) = stack-blocks(
|
||||||
strip-blocks,
|
strip-blocks,
|
||||||
if split-band { info-w } else { sa.width },
|
if split-band { info-w } else { sa.width },
|
||||||
b => _lines(b.style, 2),
|
b => _lines(b.style, 2),
|
||||||
colour,
|
|
||||||
al,
|
al,
|
||||||
short * 0.022 * 1mm,
|
short * 0.022 * 1mm,
|
||||||
)
|
)
|
||||||
|
|
||||||
/// The foot keeps each role's own resolved fill: those colours were contrast-checked
|
/// The foot keeps each role's own resolved fill: `_SURFACE.lower` is `auto`, so those
|
||||||
/// against the artwork, which is exactly what they sit on here.
|
/// are the colours the renderer measured against the artwork — which is exactly what
|
||||||
|
/// this column sits on.
|
||||||
#let foot-column(blocks) = {
|
#let foot-column(blocks) = {
|
||||||
// Deliberately NOT hyphenated: this column is centred and ragged, where a hyphen
|
// Deliberately NOT hyphenated: this column is centred and ragged, where a hyphen
|
||||||
// reads as an error, and Italian patterns break loanwords badly ("wee-kend"). The
|
// reads as an error, and Italian patterns break loanwords badly ("wee-kend"). The
|
||||||
@@ -350,7 +350,6 @@
|
|||||||
blocks,
|
blocks,
|
||||||
foot-w,
|
foot-w,
|
||||||
b => _lines(b.style, if b.role == "details" { 3 } else { 2 }),
|
b => _lines(b.style, if b.role == "details" { 3 } else { 2 }),
|
||||||
none,
|
|
||||||
foot-align,
|
foot-align,
|
||||||
short * 0.018 * 1mm,
|
short * 0.018 * 1mm,
|
||||||
)
|
)
|
||||||
@@ -371,12 +370,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#let band-block = if band-blocks.len() == 0 and not split-band {
|
#let band-block = if band-blocks.len() == 0 {
|
||||||
none
|
none
|
||||||
} else {
|
} else {
|
||||||
block(
|
block(
|
||||||
width: sa.full-width,
|
width: sa.full-width,
|
||||||
fill: _band-fill,
|
fill: band-fill,
|
||||||
inset: (left: band-pad-x, right: band-pad-x, top: band-pad-y, bottom: band-pad-y),
|
inset: (left: band-pad-x, right: band-pad-x, top: band-pad-y, bottom: band-pad-y),
|
||||||
if split-band {
|
if split-band {
|
||||||
// Bottom-aligned info hangs off the same optical line as the last title line.
|
// Bottom-aligned info hangs off the same optical line as the last title line.
|
||||||
@@ -385,7 +384,7 @@
|
|||||||
column-gutter: gutter-w,
|
column-gutter: gutter-w,
|
||||||
align: (left + horizon, right + bottom),
|
align: (left + horizon, right + bottom),
|
||||||
band-column,
|
band-column,
|
||||||
strip-column(pal.ink, right),
|
strip-column(right),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
band-column
|
band-column
|
||||||
@@ -398,14 +397,14 @@
|
|||||||
} else {
|
} else {
|
||||||
block(
|
block(
|
||||||
width: sa.full-width,
|
width: sa.full-width,
|
||||||
fill: _strip-fill,
|
fill: strip-fill,
|
||||||
inset: (
|
inset: (
|
||||||
left: band-pad-x,
|
left: band-pad-x,
|
||||||
right: band-pad-x,
|
right: band-pad-x,
|
||||||
top: band-pad-y * 0.62,
|
top: band-pad-y * 0.62,
|
||||||
bottom: band-pad-y * 0.62,
|
bottom: band-pad-y * 0.62,
|
||||||
),
|
),
|
||||||
strip-column(_strip-ink, center),
|
strip-column(center),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,16 +464,15 @@
|
|||||||
// Scrim only over the artwork, only under the foot, only when the renderer measured
|
// 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.
|
// the region behind it as busy. The band never needs one — it is flat colour.
|
||||||
//
|
//
|
||||||
// It has to be a good deal TALLER than the text it protects: the kernel's gradient
|
// It must be a good deal TALLER than the text it protects: the kernel's gradient
|
||||||
// holds its fade back until 45% of the height so the artwork stays clean, so a scrim
|
// holds its fade back until 45% of its height so the artwork stays clean, so a scrim
|
||||||
// merely as tall as the foot leaves the foot's first line sitting on nothing. At
|
// merely as tall as the foot leaves the foot's first line sitting on nothing.
|
||||||
// 3.2x, that first line lands at about 70% of the ramp, where the wash has real body.
|
// want — 3.2x the foot, which lands that first line at ~70% of the ramp where the
|
||||||
// Three rules, in order of who wins:
|
// wash has real body; the floor keeps a one-line foot from getting a smear;
|
||||||
// want — 3.2x the foot, with a floor so a single line still gets a wash, not a smear;
|
// room — but never past 70% of the gap below the band, so a clean strip of artwork
|
||||||
// room — capped at 70% of the gap below the band, so a clean strip of artwork always
|
// always survives between band and scrim. That strip IS the layout;
|
||||||
// survives between band and scrim. That strip is the layout, not decoration;
|
// need — unless the foot is itself too big for that strip, in which case covering
|
||||||
// need — but a foot too big for that strip is still covered: whatever it takes to
|
// the text wins: it takes whatever holds the foot plus a fade above it.
|
||||||
// hold the text plus a fade above it beats the 70% rule.
|
|
||||||
if foot-h > 0pt and _get(spec, "needs_scrim", false) == true {
|
if foot-h > 0pt and _get(spec, "needs_scrim", false) == true {
|
||||||
// recomputed: `group-top` may have moved since
|
// recomputed: `group-top` may have moved since
|
||||||
let room = sa.full-height - (group-top + group-h)
|
let room = sa.full-height - (group-top + group-h)
|
||||||
@@ -517,6 +515,7 @@
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// The body stays empty on purpose: every element is placed in the foreground, whose
|
// An empty box is the whole body. Every element is placed in the foreground instead,
|
||||||
// origin is the full page, so nothing depends on where a margin box would have started.
|
// whose origin is the full page including bleed — the one coordinate system `safe-area`
|
||||||
|
// reports in — so nothing depends on where a margin box would have started.
|
||||||
#box()
|
#box()
|
||||||
|
|||||||
+50
-29
@@ -20,8 +20,10 @@
|
|||||||
//
|
//
|
||||||
// Three rules this file obeys, in order of importance:
|
// Three rules this file obeys, in order of importance:
|
||||||
// 1. Nothing ever leaves the safe area. The stack is budgeted BEFORE it is typeset
|
// 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
|
// (see `cap`), each block is auto-fitted into its share and checked for lines that
|
||||||
// than unlikely. That matters because a poster is checked once, at the printer.
|
// refused to break (see `fit-checked`), and the bands a logo needs are reserved out
|
||||||
|
// of the height first. Overflow is made impossible, not unlikely — a poster gets
|
||||||
|
// checked once, at the printer.
|
||||||
// 2. Every measurement is a fraction of the trim's short edge, so the composition is
|
// 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.
|
// 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.
|
// 3. Determinism: no dates, no randomness, no system fonts. See lib.typ's header.
|
||||||
@@ -227,7 +229,38 @@
|
|||||||
// SHORTENED box, so it can never be pushed past the safe edge at either end.
|
// 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 })
|
#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.
|
// Room for the logo. `logo-place` sizes it to a fraction of the trim's short edge and
|
||||||
|
// insets it to the safe area, so its box is predictable even though its aspect ratio is
|
||||||
|
// not — the square case is assumed, which is the worst one. The stack then gives up a
|
||||||
|
// band at that end of the sheet, because a centred column is wide enough to run straight
|
||||||
|
// through a corner mark.
|
||||||
|
#let logo-side = {
|
||||||
|
let logo = _get(spec, "logo", none)
|
||||||
|
if type(logo) != dictionary { 0pt } else {
|
||||||
|
let path = _get(logo, "path", none)
|
||||||
|
if type(path) != str or path.trim() == "" { 0pt } else {
|
||||||
|
let raw = _get(logo, "scale", 0.12)
|
||||||
|
let scale = if type(raw) in (int, float) { calc.max(0.02, calc.min(0.4, raw)) } else { 0.12 }
|
||||||
|
sa.short-edge * scale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#let logo-corner = {
|
||||||
|
let c = _dig(spec, ("logo", "corner"), "br")
|
||||||
|
if type(c) == str and c in ("tl", "tr", "bl", "br") { c } else { "br" }
|
||||||
|
}
|
||||||
|
#let logo-at-top = logo-side > 0pt and logo-corner in ("tl", "tr")
|
||||||
|
#let logo-at-foot = logo-side > 0pt and logo-corner in ("bl", "br")
|
||||||
|
|
||||||
|
#let top-reserve = if logo-at-top { logo-side + gap-base } else { 0pt }
|
||||||
|
#let logo-reserve = if logo-at-foot { logo-side + gap-base } else { 0pt }
|
||||||
|
|
||||||
|
// The footer band. A foot-corner logo shares it when there is measure enough left beside
|
||||||
|
// it: the footer's column is inset by the logo width on BOTH sides — symmetric, because a
|
||||||
|
// centred footer nudged off-axis to dodge a logo is exactly the near-miss this layout
|
||||||
|
// cannot afford. When the logo is big enough to leave no usable measure (the schema
|
||||||
|
// allows 0.4 of the short edge) the footer gives up the corner instead and sits above it.
|
||||||
|
// A smaller stack is a compromise; a credit line printed across a logo is a defect.
|
||||||
#let footer-style = block-style(spec, "footer")
|
#let footer-style = block-style(spec, "footer")
|
||||||
#let footer-line-box = lines-height(footer-style, _LINES.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
|
// Glyphs paint a little past their line box — «@», an accented capital, a descender in a
|
||||||
@@ -235,11 +268,18 @@
|
|||||||
// instead of exactly on it. Measured: without it "INFO@EXAMPLE.IT" put 0.5 mm of ink
|
// instead of exactly on it. Measured: without it "INFO@EXAMPLE.IT" put 0.5 mm of ink
|
||||||
// below the safe edge on A3.
|
// below the safe edge on A3.
|
||||||
#let footer-descender = footer-style.size * 0.20
|
#let footer-descender = footer-style.size * 0.20
|
||||||
#let footer-band = if footers.len() == 0 { 0pt } else {
|
#let footer-clear = if logo-at-foot { sa.width - 2 * (logo-side + gap-base) } else { sa.width }
|
||||||
footer-line-box + gap-base * 1.2 + footer-descender
|
#let footer-shares = not logo-at-foot or footer-clear >= sa.width * 0.45
|
||||||
|
#let footer-width = if footer-shares { footer-clear } else { sa.width }
|
||||||
|
#let footer-content = if footers.len() == 0 { 0pt } else {
|
||||||
|
footer-line-box + footer-descender + gap-base * 1.2
|
||||||
}
|
}
|
||||||
|
#let footer-lift = if footers.len() == 0 or footer-shares { 0pt } else { logo-reserve }
|
||||||
|
// The band is whichever is taller: the footer's own needs, or the clearance the logo
|
||||||
|
// wants. With no footer and no logo it is nothing at all.
|
||||||
|
#let footer-band = calc.max(footer-content + footer-lift, logo-reserve)
|
||||||
|
|
||||||
#let avail = sa.height - 2 * lift - footer-band
|
#let avail = sa.height - 2 * lift - footer-band - top-reserve
|
||||||
|
|
||||||
// A rule is drawn only where it means something: between the title and whatever follows.
|
// A rule is drawn only where it means something: between the title and whatever follows.
|
||||||
#let has-rule = {
|
#let has-rule = {
|
||||||
@@ -346,29 +386,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 = {
|
#let footer-stack = {
|
||||||
for (i, b) in footers.enumerate() {
|
for (i, b) in footers.enumerate() {
|
||||||
if i > 0 { v(gap * 0.4) }
|
if i > 0 { v(gap * 0.4) }
|
||||||
let st = block-style(spec, "footer")
|
let st = footer-style
|
||||||
let t = _get(b, "text", "")
|
let t = _get(b, "text", "")
|
||||||
block(width: 100%, fit-checked(
|
block(width: 100%, fit-checked(
|
||||||
if st.upper { upper(t) } else { t },
|
if st.upper { upper(t) } else { t },
|
||||||
@@ -411,7 +432,7 @@
|
|||||||
if _get(spec, "needs_scrim", false) == true {
|
if _get(spec, "needs_scrim", false) == true {
|
||||||
// Background coordinates include the bleed; the stack's centre is a body (trim)
|
// Background coordinates include the bleed; the stack's centre is a body (trim)
|
||||||
// coordinate, so it is offset by one bleed to line the two up.
|
// coordinate, so it is offset by one bleed to line the two up.
|
||||||
centre-veil(sa, pal.scrim, sa.bleed + sa.safe + avail / 2)
|
centre-veil(sa, pal.scrim, sa.bleed + sa.safe + top-reserve + avail / 2)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
foreground: {
|
foreground: {
|
||||||
@@ -421,7 +442,7 @@
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Body origin = trim top-left, so the safe inset is `sa.safe` alone.
|
// 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(
|
#place(top + left, dx: sa.safe + (sa.width - col) / 2, dy: sa.safe + top-reserve, box(
|
||||||
width: col,
|
width: col,
|
||||||
height: avail,
|
height: avail,
|
||||||
align(center + horizon, type-stack),
|
align(center + horizon, type-stack),
|
||||||
@@ -433,7 +454,7 @@
|
|||||||
dy: sa.safe + sa.height - footer-band,
|
dy: sa.safe + sa.height - footer-band,
|
||||||
box(
|
box(
|
||||||
width: footer-width,
|
width: footer-width,
|
||||||
height: footer-band - footer-descender,
|
height: footer-band - footer-descender - footer-lift,
|
||||||
align(center + bottom, footer-stack),
|
align(center + bottom, footer-stack),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
+71
-25
@@ -80,9 +80,22 @@
|
|||||||
/// the optical distance is the same on every format.
|
/// the optical distance is the same on every format.
|
||||||
#let gutter = sa.short-edge * 0.045
|
#let gutter = sa.short-edge * 0.045
|
||||||
|
|
||||||
/// Vertical rhythm between stacked blocks, and the spacing unit for the footer line.
|
/// The base vertical rhythm, and the spacing unit for the footer line.
|
||||||
#let gap = sa.short-edge * 0.016
|
#let gap = sa.short-edge * 0.016
|
||||||
|
|
||||||
|
/// Space after a block, before the next one. A flat gap makes a poster read as a list:
|
||||||
|
/// the break after a 38 mm headline has to be bigger than the break after a 7 mm detail
|
||||||
|
/// line or the title runs straight into the subtitle. So the gap is the base rhythm or a
|
||||||
|
/// third of the block's own type size, whichever is larger — which in practice means the
|
||||||
|
/// title (and only the title) buys itself real air.
|
||||||
|
#let _gap-raw(e) = calc.max(gap, e.style.size * 0.32)
|
||||||
|
#let _gap-sum(list) = {
|
||||||
|
if list.len() < 2 { return 0pt }
|
||||||
|
let s = 0pt
|
||||||
|
for e in list.slice(0, list.len() - 1) { s += _gap-raw(e) }
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
/// The seam, in FULL-PAGE coordinates (bleed included) — the origin `page(background:)`
|
/// 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
|
/// 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.
|
/// the page edge to here, so it covers its own bleed; the panel starts here.
|
||||||
@@ -219,12 +232,17 @@
|
|||||||
/// panel cannot hold the blocks at their floor sizes; the ordinary crowding case is
|
/// 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
|
/// handled by `fit`, which narrows the type on the wdth axis long before anything is
|
||||||
/// thrown away.
|
/// thrown away.
|
||||||
|
/// Total space the gaps take for a candidate stack, capped so the air can never outweigh
|
||||||
|
/// the words: without the cap a panel of nothing but large roles would spend most of its
|
||||||
|
/// height on the spaces between them.
|
||||||
|
#let gaps-of(list) = calc.min(_gap-sum(list), flow-h * 0.35)
|
||||||
|
|
||||||
#let flow = {
|
#let flow = {
|
||||||
let list = entries.filter(e => e.role != "footer")
|
let list = entries.filter(e => e.role != "footer")
|
||||||
let needed(l) = {
|
let needed(l) = {
|
||||||
let s = 0pt
|
let s = 0pt
|
||||||
for e in l { s += _min-need(e.style) }
|
for e in l { s += _min-need(e.style) }
|
||||||
s + gap * calc.max(0, l.len() - 1)
|
s + gaps-of(l)
|
||||||
}
|
}
|
||||||
while needed(list) > flow-h and list.any(e => e.optional) {
|
while needed(list) > flow-h and list.any(e => e.optional) {
|
||||||
let idx = 0
|
let idx = 0
|
||||||
@@ -240,7 +258,15 @@
|
|||||||
/// roughly half. Because every budget is honoured by `fit`, the stack can never be taller
|
/// roughly half. Because every budget is honoured by `fit`, the stack can never be taller
|
||||||
/// than the panel — the layout has no overflow case.
|
/// than the panel — the layout has no overflow case.
|
||||||
#let _wsum = flow.fold(0.0, (a, e) => a + e.weight)
|
#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 _inner = calc.max(1mm, flow-h - gaps-of(flow))
|
||||||
|
|
||||||
|
/// The factor the cap in `gaps-of` implies for each individual gap, so the drawn spacing
|
||||||
|
/// and the space the budgets were computed against are always the same number.
|
||||||
|
#let _gap-scale = {
|
||||||
|
let raw = _gap-sum(flow)
|
||||||
|
if raw > 0pt { gaps-of(flow) / raw } else { 1.0 }
|
||||||
|
}
|
||||||
|
#let gap-after(e) = _gap-raw(e) * _gap-scale
|
||||||
#let budget(e) = if _wsum <= 0 { _inner } else { _inner * (e.weight / _wsum) }
|
#let budget(e) = if _wsum <= 0 { _inner } else { _inner * (e.weight / _wsum) }
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -309,15 +335,30 @@
|
|||||||
// Type
|
// Type
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// The narrow end of a family's `wdth` axis, tolerant of every shape the spec may use
|
/// The `wdth` value `fit` will START from for this family: the natural instance (100),
|
||||||
/// for a range. `none` when the family has no width axis and `fit` can only shrink.
|
/// or the axis extreme if the axis does not reach it. `none` when the family has no
|
||||||
#let _wdth-floor(axes) = {
|
/// width axis, in which case Typst draws the default instance and no variation is set.
|
||||||
|
///
|
||||||
|
/// This has to be the WIDEST width `fit` can use, not the narrowest. `fit` spends the
|
||||||
|
/// axis only when the paragraph is too TALL for its box; a block with a generous height
|
||||||
|
/// budget — a two-block spec where the title owns most of the panel — never narrows at
|
||||||
|
/// all, so a cap computed at the condensed end would not bind and the word would run off
|
||||||
|
/// the page. Measured at ig-story: "DELL'AUTUNNO" left the panel by 4.8 mm that way.
|
||||||
|
#let _wdth-start(axes) = {
|
||||||
if type(axes) != dictionary { return none }
|
if type(axes) != dictionary { return none }
|
||||||
let a = axes.at("wdth", default: none)
|
let a = axes.at("wdth", default: none)
|
||||||
if type(a) == array and a.len() >= 2 { calc.min(a.at(0), a.at(1)) }
|
let pair = if type(a) == array and a.len() >= 2 { (a.at(0), a.at(1)) }
|
||||||
else if type(a) == dictionary { a.at("min", default: a.at("lo", default: none)) }
|
else if type(a) == dictionary {
|
||||||
else if type(a) in (int, float) { a }
|
let lo = a.at("min", default: a.at("lo", default: none))
|
||||||
else { none }
|
let hi = a.at("max", default: a.at("hi", default: none))
|
||||||
|
if lo != none and hi != none { (lo, hi) } else { none }
|
||||||
|
}
|
||||||
|
else if type(a) in (int, float) { (a, a) }
|
||||||
|
else { none }
|
||||||
|
if pair == none { return none }
|
||||||
|
let lo = calc.min(..pair)
|
||||||
|
let hi = calc.max(..pair)
|
||||||
|
calc.max(lo, calc.min(hi, 100))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Auto-fit one block into `w` x `h` — the only way type is drawn in this template.
|
/// Auto-fit one block into `w` x `h` — the only way type is drawn in this template.
|
||||||
@@ -329,18 +370,17 @@
|
|||||||
/// field outranks the `set par` inside `fit` and so governs BOTH the trial measures
|
/// field outranks the `set par` inside `fit` and so governs BOTH the trial measures
|
||||||
/// and the final draw. Without it a long Italian title silently overflows its column.
|
/// and the final draw. Without it a long Italian title silently overflows its column.
|
||||||
/// * the ideal size is capped so the longest unbreakable word fits `w`. The word is
|
/// * the ideal size is capped so the longest unbreakable word fits `w`. The word is
|
||||||
/// measured unconstrained (where `measure` reports the true width) at the narrowest
|
/// measured UNCONSTRAINED, which is the only way to get its true width past the clamp
|
||||||
/// width the axis allows, because `fit` spends the axis before it spends size — so
|
/// in (2), at the width `fit` starts from. `fit` may then narrow further and gain
|
||||||
/// this only bites when even fully condensed the word would not fit, and then it
|
/// room back; it can never need more.
|
||||||
/// hands `fit` a size at which it does.
|
|
||||||
#let fit-text(body, w, h, st, align-to: left) = context {
|
#let fit-text(body, w, h, st, align-to: left) = context {
|
||||||
let words = body.split(regex("\\s+")).filter(x => x != "")
|
let words = body.split(regex("\\s+")).filter(x => x != "")
|
||||||
let cap = st.size
|
let cap = st.size
|
||||||
if words.len() > 0 and w > 0pt {
|
if words.len() > 0 and w > 0pt {
|
||||||
let floor-wdth = _wdth-floor(st.axes)
|
let start-wdth = _wdth-start(st.axes)
|
||||||
let probe(word) = measure(text(
|
let probe(word) = measure(text(
|
||||||
..(if st.family != none { (font: st.family) } else { (:) }),
|
..(if st.family != none { (font: st.family) } else { (:) }),
|
||||||
..(if floor-wdth != none { (variations: (wdth: floor-wdth)) } else { (:) }),
|
..(if start-wdth != none { (variations: (wdth: start-wdth)) } else { (:) }),
|
||||||
size: 100pt, weight: st.weight, tracking: st.tracking, word,
|
size: 100pt, weight: st.weight, tracking: st.tracking, word,
|
||||||
)).width
|
)).width
|
||||||
let widest = words.fold(0pt, (a, word) => calc.max(a, probe(word)))
|
let widest = words.fold(0pt, (a, word) => calc.max(a, probe(word)))
|
||||||
@@ -364,16 +404,14 @@
|
|||||||
#let stack-body = {
|
#let stack-body = {
|
||||||
set block(spacing: 0pt)
|
set block(spacing: 0pt)
|
||||||
set par(spacing: 0pt)
|
set par(spacing: 0pt)
|
||||||
let first = true
|
for (i, e) in flow.enumerate() {
|
||||||
for e in flow {
|
if i > 0 { v(gap-after(flow.at(i - 1)), weak: false) }
|
||||||
if not first { v(gap, weak: false) }
|
|
||||||
first = false
|
|
||||||
let st = e.style
|
let st = e.style
|
||||||
// One line per block, and the whole reason `fit` exists: a long Italian title
|
// 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
|
// 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
|
// 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.
|
// ig-story and stays a headline instead of becoming a caption.
|
||||||
block(width: panel.width, height: budget(e), fit-text(e.body, panel.width, budget(e), st))
|
block(width: panel.width, fit-text(e.body, panel.width, budget(e), st))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,10 +421,18 @@
|
|||||||
/// little as a title and a date), and a heading pinned to the top of that void reads as
|
/// little as a title and a date), and a heading pinned to the top of that void reads as
|
||||||
/// a mistake.
|
/// a mistake.
|
||||||
///
|
///
|
||||||
/// Every child block is pinned to its exact budget, and the budgets plus the gaps were
|
/// The blocks are NOT pinned to their budgets, only fitted inside them: each takes its
|
||||||
/// normalised to `flow-h`, so the stack is EXACTLY `flow-h` tall whatever the spec
|
/// natural height, so the slack a short block leaves collapses into one pool of air that
|
||||||
/// contains. That is what keeps it off the colophon: there is no overflow case to
|
/// the centring then splits evenly, instead of being scattered as trailing gaps. Since
|
||||||
/// handle, only unused space inside a block when its text fitted with room to spare.
|
/// every fitted height is at most its budget and the budgets plus gaps were normalised
|
||||||
|
/// to `flow-h`, the stack cannot be taller than the flow area. Measured, with the full
|
||||||
|
/// seven-block spec: 256/313 pt at yt-thumb, 352/500 at A3, 320/414 at ig-story.
|
||||||
|
///
|
||||||
|
/// The one way past that bound is a spec whose blocks cannot fit even at the floor sizes
|
||||||
|
/// `block-style` allows — reachable only with an absurd `safeMm`, since the optional
|
||||||
|
/// blocks are dropped first. It then spills symmetrically over the seam and the colophon
|
||||||
|
/// rather than overlapping itself, which is the failure worth having: visibly wrong, but
|
||||||
|
/// with nothing silently deleted from an event poster.
|
||||||
#let type-stack = block(width: panel.width, height: flow-h, align(horizon + left, stack-body))
|
#let type-stack = block(width: panel.width, height: flow-h, align(horizon + left, stack-body))
|
||||||
|
|
||||||
/// The colophon, pinned to the foot of the panel and pushed clear of the logo when they
|
/// The colophon, pinned to the foot of the panel and pushed clear of the logo when they
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 324 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
import { createJiti } from "jiti";
|
||||||
|
import { join } from "node:path";
|
||||||
|
const jiti = createJiti(import.meta.url, { interopDefault: true });
|
||||||
|
const R = "/home/moze/Sorgenti/pi-imgen";
|
||||||
|
const c = await jiti.import(join(R, "extensions/imgen/render/contrast.ts"));
|
||||||
|
|
||||||
|
const region = c.TEXT_REGIONS["hero-bottom"];
|
||||||
|
for (const [label, file] of [["calmo (gradiente)", "tests/art.png"], ["movimentato (scacchiera)", "tests/art-busy.png"], ["scuro", "tests/art-dark.png"]]) {
|
||||||
|
const a = await c.analyseRegion(join(R, file), region);
|
||||||
|
console.log(
|
||||||
|
`${label.padEnd(26)} lum=${a.luminance.toFixed(3)} stdev=${a.stdev.toFixed(1).padStart(6)} ` +
|
||||||
|
`busy=${String(a.busy).padEnd(5)} ink=${a.ink} contrasto=${a.contrast.toFixed(2).padStart(5)} scrim=${a.needsScrim}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { createJiti } from "jiti";
|
||||||
|
import { mkdtempSync, copyFileSync, existsSync, readFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
const jiti = createJiti(import.meta.url, { interopDefault: true });
|
||||||
|
const R = "/home/moze/Sorgenti/pi-imgen";
|
||||||
|
const typst = await jiti.import(join(R, "extensions/imgen/render/typst.ts"));
|
||||||
|
const cfgMod = await jiti.import(join(R, "extensions/imgen/config.ts"));
|
||||||
|
|
||||||
|
const job = mkdtempSync(join(tmpdir(), "imgen-e2e-"));
|
||||||
|
copyFileSync(join(R, "tests/art.png"), join(job, "art.png"));
|
||||||
|
|
||||||
|
const cfg = { ...cfgMod.DEFAULTS, outputDir: job };
|
||||||
|
const spec = {
|
||||||
|
slug: "sagra-castagna", format: "a3-portrait", template: "hero-bottom",
|
||||||
|
palette: { bg: "#f4efe6", ink: "#101014", accent: "#b03a24", scrim: "#000000" },
|
||||||
|
fonts: { display: "Archivo", body: "Archivo" },
|
||||||
|
art: { prompt: "autumn chestnuts", negative: "text, letters", seed: 7, source: "generate" },
|
||||||
|
blocks: [
|
||||||
|
{ role: "title", text: "Sagra della Castagna" },
|
||||||
|
{ role: "date", text: "Sabato 12 settembre, ore 21:00" },
|
||||||
|
{ role: "venue", text: "Chiostro di San Domenico, Città Alta" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolved = await typst.resolveSpec(spec, cfg, join(job, "art.png"), { format: "a3-portrait" });
|
||||||
|
console.log("resolved.art_file =", resolved.art_file);
|
||||||
|
console.log("resolved.ink_on_art =", resolved.ink_on_art);
|
||||||
|
console.log("resolved.ink_on_bg =", resolved.ink_on_bg);
|
||||||
|
console.log("resolved.needs_scrim =", resolved.needs_scrim);
|
||||||
|
console.log("resolved.page =", JSON.stringify(resolved.page));
|
||||||
|
console.log("resolved.font_axes =", JSON.stringify(resolved.font_axes));
|
||||||
|
|
||||||
|
const TY = process.env.TYPST;
|
||||||
|
const out = join(job, "poster.pdf");
|
||||||
|
await typst.render(resolved, job, out, { binary: TY });
|
||||||
|
const d = readFileSync(out);
|
||||||
|
const tb = /\/TrimBox\s*\[([^\]]*)\]/.exec(d.toString("latin1"));
|
||||||
|
if (!tb) { console.log("TrimBox: ABSENT"); process.exit(1); }
|
||||||
|
const [x0,y0,x1,y1] = tb[1].split(/\s+/).map(Number);
|
||||||
|
console.log(`TrimBox mm = ${((x1-x0)/72*25.4).toFixed(1)} x ${((y1-y0)/72*25.4).toFixed(1)}`);
|
||||||
|
console.log("bundle staged =", existsSync(join(job, ".typst/lib.typ")), existsSync(join(job, ".typst/hero-bottom.typ")));
|
||||||
|
console.log("OK");
|
||||||
Reference in New Issue
Block a user