tests: fix root-relative spec path; document ink-surface blocker

This commit is contained in:
mozempk
2026-08-27 09:25:34 +02:00
parent cf7f0aa386
commit 5fabf5345f
12 changed files with 6270 additions and 1 deletions
+52
View File
@@ -0,0 +1,52 @@
# Caratteri di terze parti inclusi in pi-imgen
Generato da `scripts/fetch-fonts.sh` il 2026-08-27 07:25 UTC. Non modificare a mano.
I file dei caratteri stanno in `vendor/fonts/<id>/` e **non** sono versionati.
Ogni cartella contiene il file `LICENSE` originale.
La licenza è letta dalla **directory upstream** in `google/fonts` (il bucket
`ofl/`, `apache/`, `ufl/`, `cc-by-sa/`), mai dai metadati dentro il binario:
per esempio il binario di Roboto Condensed dichiara ancora Apache-2.0 nel name
ID 13 mentre la concessione reale è OFL. Il bucket viene risolto a ogni
esecuzione, mai scritto a mano, perché le famiglie migrano fra bucket.
| Famiglia | Licenza | Origine upstream | File | Variabile | Sorgente |
|---|---|---|---|---|---|
| Archivo | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/archivo/OFL.txt) | [ofl/archivo](https://github.com/google/fonts/tree/main/ofl/archivo) | 2 | sì | google/fonts (TTF completi) |
| Anton | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/anton/OFL.txt) | [ofl/anton](https://github.com/google/fonts/tree/main/ofl/anton) | 1 | no | google/fonts (TTF completi) |
| Oswald | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/oswald/OFL.txt) | [ofl/oswald](https://github.com/google/fonts/tree/main/ofl/oswald) | 1 | sì | google/fonts (TTF completi) |
| Bebas Neue | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/bebasneue/OFL.txt) | [ofl/bebasneue](https://github.com/google/fonts/tree/main/ofl/bebasneue) | 1 | no | google/fonts (TTF completi) |
| Big Shoulders | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/bigshoulders/OFL.txt) | [ofl/bigshoulders](https://github.com/google/fonts/tree/main/ofl/bigshoulders) | 1 | sì | google/fonts (TTF completi) |
| Playfair Display | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/playfairdisplay/OFL.txt) | [ofl/playfairdisplay](https://github.com/google/fonts/tree/main/ofl/playfairdisplay) | 2 | sì | google/fonts (TTF completi) |
| Bodoni Moda | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/bodonimoda/OFL.txt) | [ofl/bodonimoda](https://github.com/google/fonts/tree/main/ofl/bodonimoda) | 2 | sì | google/fonts (TTF completi) |
| DM Serif Display | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/dmserifdisplay/OFL.txt) | [ofl/dmserifdisplay](https://github.com/google/fonts/tree/main/ofl/dmserifdisplay) | 2 | no | google/fonts (TTF completi) |
| Instrument Serif | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/instrumentserif/OFL.txt) | [ofl/instrumentserif](https://github.com/google/fonts/tree/main/ofl/instrumentserif) | 2 | no | google/fonts (TTF completi) |
| Fraunces | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/fraunces/OFL.txt) | [ofl/fraunces](https://github.com/google/fonts/tree/main/ofl/fraunces) | 2 | sì | google/fonts (TTF completi) |
| Outfit | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/outfit/OFL.txt) | [ofl/outfit](https://github.com/google/fonts/tree/main/ofl/outfit) | 1 | sì | google/fonts (TTF completi) |
| Inter | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/inter/OFL.txt) | [ofl/inter](https://github.com/google/fonts/tree/main/ofl/inter) | 2 | sì | google/fonts (TTF completi) |
| Caveat | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/caveat/OFL.txt) | [ofl/caveat](https://github.com/google/fonts/tree/main/ofl/caveat) | 1 | sì | google/fonts (TTF completi) |
| Permanent Marker | [Apache-2.0](https://raw.githubusercontent.com/google/fonts/main/apache/permanentmarker/LICENSE.txt) | [apache/permanentmarker](https://github.com/google/fonts/tree/main/apache/permanentmarker) | 1 | no | google/fonts (TTF completi) |
| Bungee | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/bungee/OFL.txt) | [ofl/bungee](https://github.com/google/fonts/tree/main/ofl/bungee) | 1 | no | google/fonts (TTF completi) |
| Alfa Slab One | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/alfaslabone/OFL.txt) | [ofl/alfaslabone](https://github.com/google/fonts/tree/main/ofl/alfaslabone) | 1 | no | google/fonts (TTF completi) |
| Special Elite | [Apache-2.0](https://raw.githubusercontent.com/google/fonts/main/apache/specialelite/LICENSE.txt) | [apache/specialelite](https://github.com/google/fonts/tree/main/apache/specialelite) | 1 | no | google/fonts (TTF completi) |
| Big Shoulders Stencil Display | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/bigshouldersstencildisplay/OFL.txt) | [ofl/bigshouldersstencildisplay](https://github.com/google/fonts/tree/main/ofl/bigshouldersstencildisplay) | 1 | sì | google/fonts (TTF completi) |
| Saira Stencil One | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/sairastencilone/OFL.txt) | [ofl/sairastencilone](https://github.com/google/fonts/tree/main/ofl/sairastencilone) | 1 | no | google/fonts (TTF completi) |
| Space Mono | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/spacemono/OFL.txt) | [ofl/spacemono](https://github.com/google/fonts/tree/main/ofl/spacemono) | 4 | no | google/fonts (TTF completi) |
| JetBrains Mono | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/jetbrainsmono/OFL.txt) | [ofl/jetbrainsmono](https://github.com/google/fonts/tree/main/ofl/jetbrainsmono) | 2 | sì | google/fonts (TTF completi) |
| Unbounded | [OFL-1.1](https://raw.githubusercontent.com/google/fonts/main/ofl/unbounded/OFL.txt) | [ofl/unbounded](https://github.com/google/fonts/tree/main/ofl/unbounded) | 1 | sì | google/fonts (TTF completi) |
## Note
- **OFL-1.1**: ridistribuzione libera, anche commerciale, purché i caratteri
restino accompagnati dalla licenza e non vengano venduti da soli. Le famiglie
con Reserved Font Name (Playfair Display, DM Serif Display, Alfa Slab One)
vanno incluse **non modificate**: rinominare il file non basta, va rinominato
il font se lo si altera.
- **Apache-2.0**: nessun obbligo di attribuzione nel prodotto finale, ma il file
`LICENSE` va conservato accanto ai binari.
- I TTF variabili arrivano solo da `raw.githubusercontent.com/google/fonts`.
L'endpoint css2 istanzia sempre e non può restituire un font variabile;
lo zip di Fontsource contiene TTF statici divisi per unicode-range e i
variabili solo in WOFF2, inutili per Typst.
- Rigenera questo file con `scripts/fetch-fonts.sh --check`.
+51
View File
@@ -0,0 +1,51 @@
# Open defects found by the fixture harness
## 1. BLOCKER — ink colour ignores what is actually behind the text
**Symptom.** In `framed` and on the flat panel of `split`, body text renders white on the
cream `palette.bg` (`#f4efe6`) and is essentially illegible. Reproduce with:
```bash
tests/render-fixtures.sh /path/to/typst
# then look at tests/out/framed__accents.png and split__landscape.png
```
**Cause.** `templates/lib.typ:61`, in `palette-of()`:
```typst
let ink = hex(_get(spec, "ink_resolved", _get(p, "ink", none)), fallback: rgb("#111111"))
```
`ink_resolved` **always** wins over `palette.ink`. But `ink_resolved` is measured by
`render/contrast.ts` against the **artwork**. That is correct only for text sitting over
the artwork. Templates that place text on flat colour — `framed` entirely, `split` on its
colour half — inherit an ink chosen for a completely different surface. `split.typ:7`
already carries a comment noticing the tension.
**Fix.** Contrast must be resolved per *surface*, not once per poster:
- Extend `ResolvedSpec` with `ink_on_art` (measured, today's `ink_resolved`) and
`ink_on_bg` (checked against `palette.bg`; `design/palettes.ts` already guarantees every
palette meets 4.5:1, so this is normally just `palette.ink`).
- Add `ink-for(spec, surface)` to `lib.typ`, where surface is `"art"` or `"bg"`, and have
each template ask for the surface its text actually sits on.
- Keep `ink_resolved` as a deprecated alias so nothing breaks mid-migration.
- `resolveSpec()` in `render/typst.ts` must populate both.
Add a fixture whose artwork is dark and whose `palette.bg` is light — the case where a
single ink cannot possibly satisfy both surfaces — so this cannot regress silently.
## 2. Gotcha (fixed in the harness, must hold in `render/typst.ts`)
Typst resolves a leading `/` against `--root`, **not** the filesystem. Passing an absolute
path to `--input specfile=...` yields `<root>/home/you/...` → "file not found". The spec
path must be **root-relative**. Same rule applies to `art_file` and every `font_files`
entry.
## Confirmed working
- A3 trim geometry exact: TrimBox 297.0 × 420.0 mm inside a 303 × 426 mm MediaBox.
- Italian typography: `PERCHÉ`, `È COSÌ`, `CITTÀ`, `SANT'ANNA`, `FORLÌ`, `«PIAZZA GRANDE»`.
- Auto-fit: a 92-character title shrinks and wraps rather than overflowing.
- `split` genuinely adapts to landscape (art left / text right) rather than assuming portrait.
- 18/18 template × fixture combinations compile.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Executable
+1036
View File
File diff suppressed because it is too large Load Diff
+634
View File
@@ -0,0 +1,634 @@
#!/usr/bin/env bash
#
# fetch-fonts.sh — scarica le 22 famiglie di caratteri incluse in pi-imgen.
#
# ---------------------------------------------------------------------------
# Target shell: bash 3.2 (the one macOS actually ships). No associative arrays,
# no `mapfile`, no `${var,,}`. BSD sed/grep/awk only: no `grep -P`, no GNU-only
# flags. Tools required: curl, unzip, awk, sed, od.
#
# WHERE THE FILES COME FROM, AND WHY (measured 2026-08-27, not assumed):
#
# 1. PRIMARY for the binaries — raw.githubusercontent.com/google/fonts.
# This is the ONLY source that yields VARIABLE TTFs, which is what we want
# because Typst 0.15 sets axes (wght/wdth/opsz/SOFT/WONK) at render time.
# Exact file names are read from METADATA.pb, never guessed: the bracketed
# variable name is spelled differently per family (Archivo[wdth,wght].ttf
# but Fraunces[SOFT,WONK,opsz,wght].ttf) and the axis order is not
# alphabetical by luck. Brackets are percent-encoded for the URL.
#
# NOTE — this inverts the source order given in the brief, deliberately.
# The brief assumed the Fontsource zip ships plain TTFs. It does not:
# `api.fontsource.org/v1/download/archivo` returns 54 TTFs that are all
# SUBSET BY UNICODE-RANGE (…-latin-400-normal.ttf, …-latin-ext-…,
# …-vietnamese-…) plus a `variable/` folder that is WOFF2 ONLY. Bundling
# those would give Typst a dozen faces sharing one family name and no
# variable axes at all. So Fontsource stays in the chain as the keyless
# safety net (tier 2), not as the primary. Set FONT_SOURCE=fontsource (or
# --source=fontsource) to force the brief's original order.
#
# 2. FALLBACK — the Fontsource zip. Keyless, ships a LICENSE, always up.
# We take its `static/*-latin-*.ttf` slice only: the Google "latin" subset
# covers the whole audited Italian set (accented vowels, « », ° € – — ‘ ’
# “ ”), and taking latin-ext as well would install duplicate faces under
# the same family name. Degraded mode — recorded as such in the manifest
# and in THIRD-PARTY-FONTS.md.
#
# 3. LAST RESORT — the Google css2 endpoint with a NON-BROWSER User-Agent,
# which hands back full unsubsetted static TTFs keyless. css2 ALWAYS
# instantiates: it can never return a variable font, so a family that
# lands here loses its axes.
#
# THE THREE RULES THAT PREVENT REAL MISTAKES (each implemented below):
#
# RULE 1 — the licence is read from the DIRECTORY, never from the binary.
# Roboto Condensed's shipped binary still declares Apache-2.0 in name ID 13
# while the actual grant is OFL. The bucket a family lives in under
# google/fonts is the grant; see license_id_for_bucket().
#
# RULE 2 — the bucket is NEVER hardcoded. Roboto and Open Sans have already
# migrated out of apache/. resolve_bucket() probes ofl/ apache/ ufl/
# cc-by-sa/ and uses whichever answers.
#
# RULE 3 — a 200 proves a path exists, NOT that it is the family you asked
# for. There are five distinct "Big Shoulders" families. resolve_bucket()
# accepts a bucket only when METADATA.pb's `name:` matches the family in
# fonts.ts EXACTLY. Downloaded bytes are checked for a real sfnt signature
# too, so an HTML error page can never be saved as a .ttf.
#
# The family list is PARSED from extensions/imgen/design/fonts.ts. It is never
# duplicated here — adding a family there is the only edit needed.
#
# Idempotent: a family whose manifest is complete is skipped unless --force.
#
# Uso:
# scripts/fetch-fonts.sh [--force] [--only=<id|famiglia>]
# [--source=gf|fontsource] [--check] [--help]
# ---------------------------------------------------------------------------
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
FONTS_TS="$ROOT/extensions/imgen/design/fonts.ts"
DEST="$ROOT/vendor/fonts"
REPORT="$ROOT/THIRD-PARTY-FONTS.md"
RAW="https://raw.githubusercontent.com/google/fonts/main"
TREE="https://github.com/google/fonts/tree/main"
FONTSOURCE="https://api.fontsource.org/v1/download"
CSS2="https://fonts.googleapis.com/css2"
# Non-browser UA: this is what makes css2 return static TTFs instead of woff2.
UA="pi-imgen/1.0"
# RULE 2: the bucket is probed, in this order, never assumed.
BUCKETS="ofl apache ufl cc-by-sa"
FORCE=0
ONLY=""
CHECK_ONLY=0
SOURCE_PREF="${FONT_SOURCE:-gf}"
TMP=$(mktemp -d "${TMPDIR:-/tmp}/pi-imgen-fonts.XXXXXX")
trap 'rm -rf "$TMP"' EXIT INT TERM
# --- output -----------------------------------------------------------------
if [ -t 1 ]; then C_OK=$'\033[32m'; C_WARN=$'\033[33m'; C_ERR=$'\033[31m'; C_DIM=$'\033[2m'; C_OFF=$'\033[0m'
else C_OK=""; C_WARN=""; C_ERR=""; C_DIM=""; C_OFF=""; fi
say() { printf '%s\n' "$*"; }
ok() { printf '%s✓%s %s\n' "$C_OK" "$C_OFF" "$*"; }
info() { printf '%s %s%s\n' "$C_DIM" "$*" "$C_OFF"; }
warn() { printf '%s!%s %s\n' "$C_WARN" "$C_OFF" "$*" >&2; }
fail() { printf '%s✗%s %s\n' "$C_ERR" "$C_OFF" "$*" >&2; }
die() { fail "$*"; exit 1; }
usage() {
cat <<'USAGE'
Scarica le famiglie di caratteri incluse in pi-imgen dentro vendor/fonts/.
--force riscarica anche le famiglie già complete
--only=<id|nome> lavora su una sola famiglia (es. --only=anton)
--source=gf|fontsource
sorgente preferita per i binari (predefinita: gf,
cioè i TTF variabili di google/fonts)
--check nessuna rete: verifica quanto è già su disco e
rigenera THIRD-PARTY-FONTS.md
--help questo messaggio
Variabili d'ambiente: FONT_SOURCE=gf|fontsource
USAGE
}
for arg in "$@"; do
case "$arg" in
--force) FORCE=1 ;;
--check) CHECK_ONLY=1 ;;
--only=*) ONLY=$(printf '%s' "${arg#--only=}" | tr '[:upper:]' '[:lower:]') ;;
--source=*) SOURCE_PREF="${arg#--source=}" ;;
--help|-h) usage; exit 0 ;;
*) die "Opzione sconosciuta: $arg (usa --help)" ;;
esac
done
case "$SOURCE_PREF" in
gf|fontsource) ;;
*) die "--source deve essere 'gf' oppure 'fontsource', ricevuto '$SOURCE_PREF'." ;;
esac
for bin in curl unzip awk sed od; do
command -v "$bin" >/dev/null 2>&1 || die "Manca il comando richiesto: $bin"
done
[ -f "$FONTS_TS" ] || die "Non trovo l'elenco delle famiglie: $FONTS_TS"
# --- helpers ----------------------------------------------------------------
# One place for every network read: retries, timeouts, the non-browser UA, and
# a hard requirement of HTTP 200 + non-empty body. curl leaves a truncated file
# behind on failure, so we remove it ourselves rather than trust a partial.
http_get() { # url dest
local url="$1" dest="$2" code
code=$(curl -sS -L -A "$UA" \
--connect-timeout 15 --max-time 300 \
--retry 3 --retry-delay 2 \
-o "$dest" -w '%{http_code}' "$url" </dev/null 2>/dev/null) || { rm -f "$dest"; return 1; }
[ "$code" = "200" ] || { rm -f "$dest"; return 1; }
[ -s "$dest" ] || { rm -f "$dest"; return 1; }
return 0
}
# RULE 3, byte level: a 200 can still be an HTML error page. Require a real
# sfnt signature (0x00010000 | 'true' | 'OTTO' | 'ttcf').
is_sfnt() { # path
local sig
sig=$(od -An -tx1 -N4 "$1" 2>/dev/null | tr -d ' \n')
case "$sig" in
00010000|74727565|4f54544f|74746366) return 0 ;;
*) return 1 ;;
esac
}
# Variable TTFs live at Family[axes].ttf; the brackets must be percent-encoded
# or raw.githubusercontent answers 404. Commas are legal in a path, leave them.
urlenc() { printf '%s' "$1" | sed -e 's/\[/%5B/g' -e 's/\]/%5D/g'; }
lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; }
# RULE 1: the grant comes from the directory the family lives in, never from
# name ID 13 inside the binary (Roboto Condensed still says Apache-2.0 there
# while the real grant is OFL).
license_id_for_bucket() {
case "$1" in
ofl) printf 'OFL-1.1' ;;
apache) printf 'Apache-2.0' ;;
ufl) printf 'UFL-1.0' ;;
cc-by-sa) printf 'CC-BY-SA-4.0' ;;
*) printf 'SCONOSCIUTA' ;;
esac
}
# Candidate licence file names inside the bucket directory, most likely first.
license_files_for_bucket() {
case "$1" in
ofl) printf 'OFL.txt LICENSE.txt LICENSE' ;;
apache) printf 'LICENSE.txt LICENSE' ;;
ufl) printf 'UFL.txt LICENSE.txt LICENSE' ;;
cc-by-sa) printf 'LICENSE.txt LICENSE' ;;
*) printf 'LICENSE.txt OFL.txt LICENSE' ;;
esac
}
manifest_get() { # dir key
[ -f "$1/.fetch-manifest" ] || return 1
sed -n "s/^$2=//p" "$1/.fetch-manifest" | head -1
}
# --- the family list, parsed from fonts.ts ---------------------------------
# Emits TSV: family \t id \t slug \t declared-licence \t axes ("-" if static).
# Records are gathered by brace balancing, so reformatting fonts.ts does not
# break this. Never hand-duplicate the list.
parse_fonts_ts() {
awk '
function fld(rec, key, re, s) {
re = "(^|[^A-Za-z_])" key ":[ \t]*\"[^\"]*\""
if (!match(rec, re)) return ""
s = substr(rec, RSTART, RLENGTH)
sub(/^[^"]*"/, "", s)
sub(/"$/, "", s)
return s
}
function axesof(rec, s, out, t) {
if (!match(rec, /axes:[ \t]*\{[^}]*\}/)) return "-"
s = substr(rec, RSTART, RLENGTH)
sub(/^axes:[ \t]*\{/, "", s)
sub(/\}$/, "", s)
out = ""
while (match(s, /[A-Za-z]+:[ \t]*\[[ \t]*[0-9.]+[ \t]*,[ \t]*[0-9.]+[ \t]*\]/)) {
t = substr(s, RSTART, RLENGTH)
s = substr(s, RSTART + RLENGTH)
gsub(/[ \t\[\]]/, "", t)
sub(/,/, "-", t)
out = (out == "" ? t : out "," t)
}
return (out == "" ? "-" : out)
}
/export const FONTS/ { inarr = 1; next }
!inarr { next }
/^\];/ { inarr = 0; next }
{
line = $0
if (rec == "" && line !~ /\{[ \t]*family:/) next
rec = rec " " line
depth += gsub(/\{/, "{", line) - gsub(/\}/, "}", line)
if (depth <= 0) {
printf "%s\t%s\t%s\t%s\t%s\n", fld(rec, "family"), fld(rec, "id"), \
fld(rec, "slug"), fld(rec, "license"), axesof(rec)
rec = ""; depth = 0
}
}
' "$FONTS_TS"
}
LIST="$TMP/families.tsv"
parse_fonts_ts > "$LIST"
TOTAL=$(wc -l < "$LIST" | tr -d ' ')
# Sanity check the parse against a dumb count of the entries in the array.
DECLARED=$(awk '/export const FONTS/{i=1;next} /^\];/{i=0} i && /\{[ \t]*family:/{n++} END{print n+0}' "$FONTS_TS")
[ "$TOTAL" -gt 0 ] || die "Nessuna famiglia estratta da $FONTS_TS — il parser va aggiornato."
[ "$TOTAL" = "$DECLARED" ] || die "Parser incoerente: $TOTAL famiglie lette, $DECLARED dichiarate in fonts.ts."
awk -F'\t' '$1=="" || $2=="" || $3=="" {exit 1}' "$LIST" || die "Una voce di fonts.ts non ha family/id/slug."
say "pi-imgen — caratteri: $TOTAL famiglie da $FONTS_TS"
[ "$CHECK_ONLY" = 1 ] && say "Modalità --check: nessun download."
say ""
mkdir -p "$DEST"
# --- per-source fetchers ----------------------------------------------------
# Each prints the names of the files it installed into $stage, one per line, on
# fd 3 (stdout is reserved for progress), and returns non-zero if it got nothing.
# Tier 1 — google/fonts raw. Variable TTFs, full character set, exact names
# taken from METADATA.pb.
fetch_gf() { # stage meta bucket slug
local stage="$1" meta="$2" bucket="$3" slug="$4"
local got=0 f url
# Names straight from METADATA.pb: never reconstructed from the family name,
# never guessed at the axis order.
for f in $(sed -n 's/^[ ]*filename:[ ]*"\(.*\)"/\1/p' "$meta" | sort -u); do
url="$RAW/$bucket/$slug/$(urlenc "$f")"
if http_get "$url" "$stage/$f"; then
if is_sfnt "$stage/$f"; then
got=$((got + 1))
printf '%s\n' "$f" >&3
else
# 200 but not a font: an error page, or the path moved.
rm -f "$stage/$f"
warn " $slug: $f non è un font valido, scartato."
fi
fi
done
[ "$got" -gt 0 ]
}
# Tier 2 — the Fontsource zip. Keyless and always ships a LICENSE, but its TTFs
# are unicode-range subsets and its variable folder is woff2 only. We keep the
# `latin` subset, which covers the whole audited Italian character set.
fetch_fontsource() { # stage id
local stage="$1" id="$2" zip="$stage/.fontsource.zip" n
http_get "$FONTSOURCE/$id" "$zip" || return 1
unzip -o -q -j "$zip" 'static/*-latin-[0-9]*.ttf' -d "$stage" 2>/dev/null || true
# LICENSE from the zip is only a fallback for the bucket copy (RULE 1).
unzip -o -q -j "$zip" 'LICENSE' -d "$stage" 2>/dev/null || true
[ -f "$stage/LICENSE" ] && mv -f "$stage/LICENSE" "$stage/.fontsource-LICENSE"
rm -f "$zip"
n=0
for f in "$stage"/*.ttf; do
[ -e "$f" ] || continue
if is_sfnt "$f"; then n=$((n + 1)); printf '%s\n' "$(basename "$f")" >&3
else rm -f "$f"; fi
done
[ "$n" -gt 0 ]
}
# css2 is strict about the weight list: it MUST be ascending and duplicate-free,
# and every value must sit inside the family's own wght range — otherwise the
# endpoint answers 400 with an HTML page instead of CSS. Verified 2026-08-27:
# `Oswald:wght@200;400;700;700` -> 400, `…@200;400;700` -> 200.
css2_weights() { # min max
local lo="$1" hi="$2" v
{
printf '%s\n' "$lo" "$hi"
for v in 400 700; do
if [ "$v" -lt "$lo" ]; then printf '%s\n' "$lo"
elif [ "$v" -gt "$hi" ]; then printf '%s\n' "$hi"
else printf '%s\n' "$v"; fi
done
} | sort -n -u | tr '\n' ';' | sed 's/;$//'
}
# Tier 3 — css2 with a non-browser UA. Full unsubsetted statics, but css2 always
# instantiates: a family that lands here has NO variable axes.
fetch_css2() { # stage family axes
local stage="$1" family="$2" axes="$3" q wmin wmax n=0 line w url
q=$(printf '%s' "$family" | sed 's/ /+/g')
case "$axes" in
*wght:*)
wmin=$(printf '%s' "$axes" | sed -n 's/.*wght:\([0-9.]*\)-.*/\1/p')
wmax=$(printf '%s' "$axes" | sed -n 's/.*wght:[0-9.]*-\([0-9.]*\).*/\1/p')
q="$q:wght@$(css2_weights "${wmin%.*}" "${wmax%.*}")" ;;
esac
http_get "$CSS2?family=$q" "$stage/.css2.css" || return 1
# Pair each @font-face's weight/style with its .ttf url.
awk '
/font-style:/ { st=$2; sub(/;/,"",st) }
/font-weight:/ { w=$2; sub(/;/,"",w) }
/url\(/ { if (match($0, /https:[^)]*\.ttf/)) print w "-" st " " substr($0, RSTART, RLENGTH) }
' "$stage/.css2.css" | sort -u > "$stage/.css2.list"
rm -f "$stage/.css2.css"
while read -r line; do
[ -n "$line" ] || continue
w=${line%% *}; url=${line##* }
local out
out=$(printf '%s' "$family" | tr -d ' ')"-$w.ttf"
if http_get "$url" "$stage/$out" && is_sfnt "$stage/$out"; then
n=$((n + 1)); printf '%s\n' "$out" >&3
else
rm -f "$stage/$out"
fi
done < "$stage/.css2.list"
rm -f "$stage/.css2.list"
[ "$n" -gt 0 ]
}
# --- bucket resolution ------------------------------------------------------
# RULE 2 (probe, never hardcode) + RULE 3 (a 200 is not proof of identity).
resolve_bucket() { # family slug meta-out -> echoes bucket
local family="$1" slug="$2" meta="$3" b name
for b in $BUCKETS; do
http_get "$RAW/$b/$slug/METADATA.pb" "$meta" || continue
name=$(awk -F'"' '/^name:/ { print $2; exit }' "$meta")
if [ "$name" = "$family" ]; then
printf '%s' "$b"
return 0
fi
# e.g. bigshoulders vs the four other "Big Shoulders …" families.
warn " $slug: $b/ esiste ma contiene «$name», non «$family» — ignorato."
rm -f "$meta"
done
return 1
}
# Cross-check the axes declared in fonts.ts against the ones the upstream
# variable font actually has. Typst asks for these at render time, so a stale
# range in fonts.ts would silently render at a clamped weight.
check_axes() { # meta declared slug
local meta="$1" declared="$2" slug="$3" upstream a tag lo hi
[ "$declared" = "-" ] && { printf '%s' "-"; return 0; }
upstream=$(awk '
/^axes \{/ { inax=1; tag=""; lo=""; hi=""; next }
inax && /tag:/ { t=$2; gsub(/"/,"",t); tag=t }
inax && /min_value:/ { lo=$2; sub(/\.0$/,"",lo) }
inax && /max_value:/ { hi=$2; sub(/\.0$/,"",hi) }
inax && /^\}/ { inax=0; printf "%s%s:%s-%s", (n++ ? "," : ""), tag, lo, hi }
END { print "" }
' "$meta")
[ -z "$upstream" ] && upstream="-"
for a in $(printf '%s' "$declared" | tr ',' ' '); do
tag=${a%%:*}
case ",$upstream," in
*",$a,"*) ;;
*) warn " $slug: asse $a dichiarato in fonts.ts, upstream ha «$upstream» — controlla." ;;
esac
done
printf '%s' "$upstream"
}
# --- main loop --------------------------------------------------------------
FAILED=""
COUNT_OK=0; COUNT_SKIP=0; COUNT_FAIL=0
while IFS=$(printf '\t') read -r family id slug declic axes; do
[ -n "$family" ] || continue
if [ -n "$ONLY" ] && [ "$(lower "$id")" != "$ONLY" ] && [ "$(lower "$family")" != "$ONLY" ]; then
continue
fi
dir="$DEST/$id"
# Idempotency: complete family + no --force -> no network at all.
if [ "$FORCE" = 0 ] || [ "$CHECK_ONLY" = 1 ]; then
if [ -s "$dir/LICENSE" ] && [ -n "$(manifest_get "$dir" license || true)" ] && \
ls "$dir"/*.ttf >/dev/null 2>&1; then
info "$family — già presente, salto."
COUNT_SKIP=$((COUNT_SKIP + 1))
continue
fi
fi
if [ "$CHECK_ONLY" = 1 ]; then
fail "$family — incompleta o assente (modalità --check, non scarico)."
FAILED="$FAILED $family"
COUNT_FAIL=$((COUNT_FAIL + 1))
continue
fi
say "$family ($id)"
stage="$TMP/stage-$id"
rm -rf "$stage"; mkdir -p "$stage"
meta="$stage/METADATA.pb"
filelist="$stage/.files"
: > "$filelist"
bucket=""
if bucket=$(resolve_bucket "$family" "$slug" "$meta"); then
info "bucket risolto: $bucket/ (licenza dalla directory: $(license_id_for_bucket "$bucket"))"
else
bucket=""
warn " $family: nessun bucket di google/fonts corrisponde a «$family» (slug $slug)."
fi
# --- binaries, in the configured order -----------------------------------
source_used=""
if [ "$SOURCE_PREF" = "gf" ] && [ -n "$bucket" ]; then
if fetch_gf "$stage" "$meta" "$bucket" "$slug" 3>>"$filelist"; then source_used="google-fonts-raw"; fi
fi
if [ -z "$source_used" ]; then
if fetch_fontsource "$stage" "$id" 3>>"$filelist"; then source_used="fontsource-latin-subset"; fi
fi
if [ -z "$source_used" ] && [ "$SOURCE_PREF" = "fontsource" ] && [ -n "$bucket" ]; then
if fetch_gf "$stage" "$meta" "$bucket" "$slug" 3>>"$filelist"; then source_used="google-fonts-raw"; fi
fi
if [ -z "$source_used" ]; then
if fetch_css2 "$stage" "$family" "$axes" 3>>"$filelist"; then source_used="google-css2-static"; fi
fi
if [ -z "$source_used" ]; then
fail "$family: nessuna sorgente ha fornito un TTF valido."
FAILED="$FAILED $family"
COUNT_FAIL=$((COUNT_FAIL + 1))
continue
fi
# --- licence: from the DIRECTORY first (RULE 1) --------------------------
license_url=""
if [ -n "$bucket" ]; then
for lf in $(license_files_for_bucket "$bucket"); do
if http_get "$RAW/$bucket/$slug/$lf" "$stage/LICENSE"; then
license_url="$RAW/$bucket/$slug/$lf"
break
fi
done
fi
if [ ! -s "$stage/LICENSE" ] && [ -s "$stage/.fontsource-LICENSE" ]; then
mv -f "$stage/.fontsource-LICENSE" "$stage/LICENSE"
license_url="$FONTSOURCE/$id (LICENSE nello zip Fontsource)"
warn " $family: licenza presa dallo zip Fontsource, non dalla directory upstream."
fi
rm -f "$stage/.fontsource-LICENSE"
if [ ! -s "$stage/LICENSE" ]; then
fail "$family: nessun file LICENSE trovato — la famiglia NON viene installata."
FAILED="$FAILED $family"
COUNT_FAIL=$((COUNT_FAIL + 1))
continue
fi
license_id=$(license_id_for_bucket "${bucket:-ofl}")
[ -z "$bucket" ] && license_id="$declic(dichiarata)"
if [ -n "$bucket" ] && [ "$license_id" != "$declic" ]; then
warn " $family: fonts.ts dichiara $declic, la directory upstream dice $license_id."
fi
axes_upstream="-"
[ -f "$meta" ] && axes_upstream=$(check_axes "$meta" "$axes" "$slug")
variable="no"
grep -q '\[' "$filelist" 2>/dev/null && variable="sì"
# --- install atomically ---------------------------------------------------
files_csv=$(tr '\n' ';' < "$filelist" | sed 's/;$//')
nfiles=$(grep -c . "$filelist" || true)
{
printf 'family=%s\n' "$family"
printf 'id=%s\n' "$id"
printf 'slug=%s\n' "$slug"
printf 'bucket=%s\n' "${bucket:-sconosciuto}"
printf 'license=%s\n' "$license_id"
printf 'license_declared=%s\n' "$declic"
printf 'license_url=%s\n' "$license_url"
printf 'upstream=%s\n' "${bucket:+$TREE/$bucket/$slug}"
printf 'source=%s\n' "$source_used"
printf 'variable=%s\n' "$variable"
printf 'axes_declared=%s\n' "$axes"
printf 'axes_upstream=%s\n' "$axes_upstream"
printf 'files=%s\n' "$files_csv"
printf 'fetched=%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
} > "$stage/.fetch-manifest"
rm -f "$filelist"
rm -rf "$dir"
mkdir -p "$(dirname "$dir")"
mv "$stage" "$dir"
ok "$family — $nfiles file, $license_id, sorgente: $source_used, variabile: $variable"
COUNT_OK=$((COUNT_OK + 1))
done < "$LIST"
# --- report -----------------------------------------------------------------
# Rebuilt from the manifests on disk, so it always describes what is actually
# installed — including families skipped as already-present in this run.
build_report() {
{
printf '# Caratteri di terze parti inclusi in pi-imgen\n\n'
printf 'Generato da `scripts/fetch-fonts.sh` il %s. Non modificare a mano.\n\n' "$(date -u '+%Y-%m-%d %H:%M UTC')"
printf 'I file dei caratteri stanno in `vendor/fonts/<id>/` e **non** sono versionati.\n'
printf 'Ogni cartella contiene il file `LICENSE` originale.\n\n'
printf 'La licenza è letta dalla **directory upstream** in `google/fonts` (il bucket\n'
printf '`ofl/`, `apache/`, `ufl/`, `cc-by-sa/`), mai dai metadati dentro il binario:\n'
printf 'per esempio il binario di Roboto Condensed dichiara ancora Apache-2.0 nel name\n'
printf 'ID 13 mentre la concessione reale è OFL. Il bucket viene risolto a ogni\n'
printf 'esecuzione, mai scritto a mano, perché le famiglie migrano fra bucket.\n\n'
printf '| Famiglia | Licenza | Origine upstream | File | Variabile | Sorgente |\n'
printf '|---|---|---|---|---|---|\n'
while IFS=$(printf '\t') read -r family id slug declic axes; do
[ -n "$family" ] || continue
d="$DEST/$id"
if [ ! -f "$d/.fetch-manifest" ]; then
printf '| %s | %s (dichiarata) | — | **non scaricata** | — | — |\n' "$family" "$declic"
continue
fi
lic=$(manifest_get "$d" license)
up=$(manifest_get "$d" upstream)
lurl=$(manifest_get "$d" license_url)
src=$(manifest_get "$d" source)
var=$(manifest_get "$d" variable)
nf=$(ls "$d"/*.ttf 2>/dev/null | wc -l | tr -d ' ')
[ -n "$up" ] || up="—"
case "$src" in
google-fonts-raw) srclabel='google/fonts (TTF completi)' ;;
fontsource-latin-subset) srclabel='Fontsource (**subset `latin`**, niente assi)' ;;
google-css2-static) srclabel='css2 (**statici**, niente assi)' ;;
*) srclabel="$src" ;;
esac
printf '| %s | [%s](%s) | [%s](%s) | %s | %s | %s |\n' \
"$family" "$lic" "${lurl:-$up}" "${up##*/main/}" "$up" "$nf" "$var" "$srclabel"
done < "$LIST"
printf '\n## Note\n\n'
printf -- '- **OFL-1.1**: ridistribuzione libera, anche commerciale, purché i caratteri\n'
printf -- ' restino accompagnati dalla licenza e non vengano venduti da soli. Le famiglie\n'
printf -- ' con Reserved Font Name (Playfair Display, DM Serif Display, Alfa Slab One)\n'
printf -- ' vanno incluse **non modificate**: rinominare il file non basta, va rinominato\n'
printf -- ' il font se lo si altera.\n'
printf -- '- **Apache-2.0**: nessun obbligo di attribuzione nel prodotto finale, ma il file\n'
printf -- ' `LICENSE` va conservato accanto ai binari.\n'
printf -- '- I TTF variabili arrivano solo da `raw.githubusercontent.com/google/fonts`.\n'
printf -- ' L'"'"'endpoint css2 istanzia sempre e non può restituire un font variabile;\n'
printf -- ' lo zip di Fontsource contiene TTF statici divisi per unicode-range e i\n'
printf -- ' variabili solo in WOFF2, inutili per Typst.\n'
printf -- '- Rigenera questo file con `scripts/fetch-fonts.sh --check`.\n'
} > "$REPORT"
}
build_report
ok "Rigenerato $REPORT"
# --- final verification: no family may end up without a LICENSE -------------
MISSING=""
while IFS=$(printf '\t') read -r family id slug declic axes; do
[ -n "$family" ] || continue
if [ -n "$ONLY" ] && [ "$(lower "$id")" != "$ONLY" ] && [ "$(lower "$family")" != "$ONLY" ]; then
continue
fi
d="$DEST/$id"
if [ ! -s "$d/LICENSE" ]; then
MISSING="$MISSING $family(LICENSE)"
elif ! ls "$d"/*.ttf >/dev/null 2>&1; then
MISSING="$MISSING $family(TTF)"
fi
done < "$LIST"
if [ -n "$ONLY" ] && [ $((COUNT_OK + COUNT_SKIP + COUNT_FAIL)) -eq 0 ]; then
die "--only=$ONLY non corrisponde a nessuna famiglia di fonts.ts."
fi
say ""
say "Scaricate: $COUNT_OK · già presenti: $COUNT_SKIP · fallite: $COUNT_FAIL"
if [ -n "$MISSING" ] || [ -n "$FAILED" ]; then
say ""
if [ -n "$FAILED" ]; then
fail "Non scaricate:$FAILED"
fi
if [ -n "$MISSING" ]; then
fail "Incomplete su disco:$MISSING"
fail "Una famiglia senza LICENSE non è ridistribuibile: non pubblicare questa build."
fi
fail "Riprova con: scripts/fetch-fonts.sh --force --only=<id>"
exit 1
fi
ok "Tutte le famiglie richieste hanno TTF e LICENSE."
+429
View File
@@ -0,0 +1,429 @@
// banded.typ — "a fascia": a solid colour band across the middle carries the title,
// with the artwork left visible above and below it.
//
// Loud and graphic; built for sagre, feste patronali and summer festivals. The title
// sits on FLAT colour, never on the picture, so it needs no scrim and stays legible
// whatever the diffusion model painted. Only the secondary lines at the foot of the
// page sit over the artwork, and those are the ones `spec.needs_scrim` protects.
//
// Composition, portrait / square (a3, a4, ig-post, ig-story):
//
// ┌───────────────────┐
// │ artwork │
// ├═══════════════════┤ band : accent, full width, bleeds off both edges
// │ TITOLO sottot. │ title (+ subtitle), centred
// ├───────────────────┤ strip : ink, the same width, inverted colours
// │ data · luogo │ date, venue
// ├───────────────────┤
// │ artwork │
// │ dettagli/prezzo │ foot : over the artwork, scrimmed when needed
// └───────────────────┘
//
// Landscape (fb-cover, yt-thumb) is NOT the same layout scaled down. A wide canvas has
// no vertical room for a band plus a strip, and a title set across 1640 px is an
// unreadable measure. So on landscape the band swallows the strip and becomes two
// columns — title left, date/venue right — and the whole thing is proportionally
// taller. See `landscape` below.
//
// Everything is a fraction of the TRIM's short edge or of the trim height, so the same
// file is correct at 1080 px and at 300 dpi A3. Nothing here reads the clock, the
// filesystem or a random source: the output is byte-identical across runs.
#import "lib.typ": *
#let spec = json(sys.inputs.specfile)
// Byte-reproducible output — the load-bearing directive, see docs/typst-verified.md.
#set document(date: none)
// Italian hyphenation and Knuth-Plass line breaking for free.
#set text(lang: "it")
#set par(linebreaks: "optimized")
// ---------------------------------------------------------------------------
// Spec-derived constants
// ---------------------------------------------------------------------------
#let pal = palette-of(spec)
#let sa = safe-area(spec)
#let t = trim-mm(spec)
#let short = short-edge-mm(spec)
/// True for fb-cover and yt-thumb, false for everything portrait or square-ish.
/// 1.25 sits well clear of ig-post's 0.8 and of a square, and well below fb-cover's 2.47.
#let landscape = t.width >= t.height * 1.25
// ---------------------------------------------------------------------------
// Band colour
//
// `ink_resolved` was contrast-checked by the renderer against the ARTWORK, and this
// template must not second-guess it — but inside the band the type is not over the
// artwork, it is over flat accent, a pair nobody has checked. So the ink stays exactly
// as resolved and the BAND moves instead: keep the accent when it separates, otherwise
// push its lightness away from the ink while holding its hue. A slightly shifted red is
// a small art-direction concession; a title nobody can read is not recoverable.
// ---------------------------------------------------------------------------
/// sRGB -> linear, per WCAG 2.x relative luminance.
#let _channel(u) = if u <= 0.04045 { u / 12.92 } else { calc.pow((u + 0.055) / 1.055, 2.4) }
#let _luma(c) = {
let k = rgb(c).components().slice(0, 3).map(v => _channel(v / 100%))
0.2126 * k.at(0) + 0.7152 * k.at(1) + 0.0722 * k.at(2)
}
/// WCAG contrast ratio, 1 (identical) to 21 (black on white).
#let _contrast(a, b) = {
let la = _luma(a)
let lb = _luma(b)
(calc.max(la, lb) + 0.05) / (calc.min(la, lb) + 0.05)
}
// 3.0 is the WCAG threshold for LARGE text, which is all this band ever carries: the
// title is 13% of the short edge (38 mm on A3) and even the date is over 13 mm.
#let _KEEP = 3.0 // good enough to leave the art director's accent alone
#let _AIM = 3.5 // what a shifted accent must reach, with a little headroom
#let _band-fill = {
let ink = pal.ink
let base = pal.accent
if _contrast(base, ink) >= _KEEP {
base
} else {
// Which way to run: away from the ink, so a light ink darkens the band.
let lighter = _luma(ink) < 0.5
let found = none
for i in range(1, 13) {
if found == none {
let c = if lighter { base.lighten(i * 8%) } else { base.darken(i * 8%) }
if _contrast(c, ink) >= _AIM { found = c }
}
}
if found != none {
found
} else if _contrast(pal.bg, ink) >= _AIM {
pal.bg // the palette's own background is the next most considered choice
} else if lighter {
white // last resort: a colour that cannot fail
} else {
black
}
}
}
// The strip under the band inverts the pair: ink ground, band-coloured type. That is
// exactly the contrast we just guaranteed, read the other way round.
#let _strip-fill = pal.ink
#let _strip-ink = _band-fill
// ---------------------------------------------------------------------------
// Blocks -> zones
//
// `spec.blocks` is walked ONCE, in order, and each block is filed into the zone its
// role belongs to. Order inside a zone is spec order, and the zones themselves run down
// the page in role order, so the spec's sequence is what the reader's eye follows.
// Roles the kernel does not know fall back to `details`, which lands in the foot.
// ---------------------------------------------------------------------------
#let _ZONES = (title: "band", subtitle: "band", date: "strip", venue: "strip")
#let _prepared = {
let out = ()
let bs = _get(spec, "blocks", ())
if type(bs) == array {
for b in bs {
if type(b) == dictionary {
let raw = _get(b, "text", "")
if type(raw) == str and raw.trim() != "" {
// `block-style` normalises the role, so `st.role` is never an unknown one.
let st = block-style(spec, _get(b, "role", none))
out.push((
role: st.role,
zone: _ZONES.at(st.role, default: "lower"),
// CAPS-ONLY families (Bebas Neue, Bungee) draw capitals at lowercase
// codepoints; upper() is harmless for them and correct for everyone else.
text: if st.upper { upper(raw) } else { raw },
optional: _get(b, "optional", false) == true,
style: st,
))
}
}
}
}
out
}
#let band-blocks = _prepared.filter(b => b.zone == "band")
#let strip-blocks = _prepared.filter(b => b.zone == "strip")
#let lower-blocks = _prepared.filter(b => b.zone == "lower")
// On landscape the strip has nowhere to go, so it rides inside the band as a second
// column instead of a second bar.
#let split-band = landscape and strip-blocks.len() > 0 and band-blocks.len() > 0
// ---------------------------------------------------------------------------
// Type boxes
//
// These are the boxes handed to `fit`, i.e. CEILINGS, not targets: `fit` never grows
// text past `block-style`'s ideal size, so a two-word title yields a slim band and the
// artwork gets the rest. Only a long title spends the whole budget — and it spends it
// on the wdth axis first, staying big and simply narrowing.
// ---------------------------------------------------------------------------
/// Height allowance for `n` lines at this role's ideal size (1.45 covers em box +
/// leading for every role in the kernel's table).
#let _lines(st, n) = st.size * n * 1.45
#let band-pad-y = short * 0.05 * 1mm
#let band-pad-x = sa.x // bleed + safe: content lands in the safe area
// The band may take at most this much of the page. Landscape gets more because it has
// no strip below it and the artwork strips above/below stay legible at a smaller share.
#let band-max = t.height * (if landscape { 0.50 } else { 0.42 }) * 1mm
#let band-inner = calc.max(short * 0.12 * 1mm, band-max - 2 * band-pad-y)
#let n-title = band-blocks.filter(b => b.role != "subtitle").len()
#let has-sub = band-blocks.any(b => b.role == "subtitle")
// Two and a half lines of title at every format: short * 0.42 against a title set at
// short * 0.13 with 1.3 line advance. The band budget wins when it is the tighter one.
#let title-h = calc.min(
if has-sub { band-inner * 0.66 } else { band-inner },
short * 0.42 * 1mm,
) / calc.max(1, n-title)
#let sub-h = if has-sub { band-inner * 0.26 } else { 0pt }
// Column split inside a landscape band. The three add up to exactly the inner width.
#let title-w = if split-band { sa.width * 0.66 } else { sa.width }
#let info-w = if split-band { sa.width * 0.28 } else { sa.width }
#let gutter-w = sa.width * 0.06
// Centred is the sagra-banner reading of this layout; landscape needs a left edge to
// hang the title on, because its right-hand column is the date.
#let band-align = if landscape { left } else { center }
#let foot-align = if landscape { left } else { center }
// ---------------------------------------------------------------------------
// Logo reservation
//
// `logo-place` puts the logo in its corner regardless of what is there. When that
// corner is at the bottom it shares the foot with the smaller blocks, so the foot gives
// up the width instead of colliding with it.
// ---------------------------------------------------------------------------
#let _logo-w = {
let lg = _get(spec, "logo", none)
let path = if type(lg) == dictionary { _get(lg, "path", none) } else { none }
if type(path) != str or path.trim() == "" {
0pt
} else {
let raw = _get(lg, "scale", 0.12)
let s = if type(raw) in (int, float) { calc.max(0.02, calc.min(0.4, raw)) } else { 0.12 }
sa.short-edge * s
}
}
#let _logo-corner = _dig(spec, ("logo", "corner"), "br")
#let _logo-at-foot = _logo-w > 0pt and _logo-corner in ("bl", "br")
#let foot-w = if _logo-at-foot { sa.width - _logo-w - short * 0.03 * 1mm } else { sa.width }
// Slide the foot away from the logo rather than under it.
#let foot-dx = if _logo-at-foot and _logo-corner == "bl" { sa.x + _logo-w + short * 0.03 * 1mm } else { sa.x }
// ---------------------------------------------------------------------------
// Rendering helpers
// ---------------------------------------------------------------------------
/// Auto-fit one prepared block into `w` x `h`, overriding only the colour: on the band
/// the kernel's art-checked fill would be the wrong one, everywhere else it is right.
#let render-block(b, w, h, colour, al) = {
let args = b.style.args
if colour != none { args.insert("fill", colour) }
fit(b.text, w, h, b.style.family, b.style.axes, ..args, align-to: al)
}
/// A vertical run of blocks with an even optical gap and no trailing space.
#let stack-blocks(blocks, w, h-of, colour, al, gap) = {
let first = true
for b in blocks {
if not first { v(gap, weak: false) }
render-block(b, w, h-of(b), colour, al)
first = false
}
}
#let band-column = stack-blocks(
band-blocks,
title-w,
b => if b.role == "subtitle" { sub-h } else { title-h },
pal.ink,
band-align,
short * 0.022 * 1mm,
)
/// date + venue: inside the band on landscape, on their own inverted strip otherwise.
#let strip-column(colour, al) = stack-blocks(
strip-blocks,
if split-band { info-w } else { sa.width },
b => _lines(b.style, 2),
colour,
al,
short * 0.014 * 1mm,
)
/// The foot keeps each role's own resolved fill: those colours were contrast-checked
/// against the artwork, which is exactly what they sit on here.
#let foot-column(blocks) = {
// Hyphenation earns its keep in a narrow column of small text, and can never reach
// the title from here.
set text(hyphenate: true)
stack-blocks(
blocks,
foot-w,
b => _lines(b.style, if b.role == "details" { 3 } else { 2 }),
none,
foot-align,
short * 0.018 * 1mm,
)
}
// ---------------------------------------------------------------------------
// The three painted pieces
// ---------------------------------------------------------------------------
/// Full-bleed artwork. Explicit mm rather than 100% so it cannot be misread as a
/// fraction of the trim: this must cover the whole page, bleed included.
/// NOTE: `art_file` must be root-relative — Typst resolves image paths against --root,
/// so the renderer rewrites it, exactly as it does for the logo.
#let art-layer = {
let p = _get(spec, "art_file", none)
if type(p) == str and p.trim() != "" {
image(p, width: sa.full-width, height: sa.full-height, fit: "cover")
}
}
#let band-block = if band-blocks.len() == 0 and not split-band {
none
} else {
block(
width: sa.full-width,
fill: _band-fill,
inset: (left: band-pad-x, right: band-pad-x, top: band-pad-y, bottom: band-pad-y),
if split-band {
// Bottom-aligned info hangs off the same optical line as the last title line.
grid(
columns: (title-w, info-w),
column-gutter: gutter-w,
align: (left + horizon, right + bottom),
band-column,
strip-column(pal.ink, right),
)
} else {
band-column
},
)
}
#let strip-block = if landscape or strip-blocks.len() == 0 {
none
} else {
block(
width: sa.full-width,
fill: _strip-fill,
inset: (
left: band-pad-x,
right: band-pad-x,
top: band-pad-y * 0.5,
bottom: band-pad-y * 0.5,
),
strip-column(_strip-ink, center),
)
}
// ---------------------------------------------------------------------------
// Composition
//
// One `context` block solves the whole vertical layout, because every decision depends
// on a measurement: how tall the band came out, whether the foot still fits under it,
// and how much scrim that foot needs. Drawn into `page(foreground:)`, whose origin is
// the FULL page including bleed — the same coordinate system `safe-area` reports.
// ---------------------------------------------------------------------------
#let composition = context {
let band-h = if band-block == none { 0pt } else {
measure(width: sa.full-width, band-block).height
}
let strip-h = if strip-block == none { 0pt } else {
measure(width: sa.full-width, strip-block).height
}
let group-h = band-h + strip-h
// Optically centred: a hair above the middle, because the foot below reads as weight.
let nudge = -t.height * (if landscape { 0.02 } else { 0.045 }) * 1mm
let min-top = sa.y + t.height * 0.08 * 1mm // always leave a real strip of artwork
let group-top = calc.max(min-top, (sa.full-height - group-h) / 2 + nudge)
// How much room is left between the band group and the bottom safe edge.
let foot-gap = short * 0.06 * 1mm
let avail = sa.full-height - sa.y - (group-top + group-h) - foot-gap
let measure-foot(blocks) = {
if blocks.len() == 0 { 0pt } else {
measure(width: foot-w, block(width: foot-w, foot-column(blocks))).height
}
}
let shown = lower-blocks
let foot-h = measure-foot(shown)
// `optional: true` means "drop me before you break the page". Only then, and only
// when dropping actually buys the space.
if foot-h > avail and shown.any(b => b.optional) {
shown = shown.filter(b => not b.optional)
foot-h = measure-foot(shown)
}
// Still tight: slide the band up into the top artwork, as far as `min-top` allows.
if foot-h > avail {
group-top = calc.max(min-top, group-top - (foot-h - avail))
}
// Scrim only over the artwork, only under the foot, only when the renderer measured
// the region behind it as busy. The band never needs one — it is flat colour.
if foot-h > 0pt and _get(spec, "needs_scrim", false) == true {
let h = calc.min(foot-h + short * 0.20 * 1mm, sa.full-height * 0.45)
place(bottom + left, scrim(h, pal.scrim, none))
}
if band-block != none {
place(top + left, dy: group-top, band-block)
}
if strip-block != none {
place(top + left, dy: group-top + band-h, strip-block)
}
if foot-h > 0pt {
place(bottom + left, dx: foot-dx, dy: -sa.y, block(width: foot-w, foot-column(shown)))
}
}
// ---------------------------------------------------------------------------
// Page
//
// `width`/`height` are the TRIM and `bleed` extends outward, which is what makes Typst
// emit a real TrimBox for the copy shop. PNG export renders the trim page, so the
// bleed and the crop marks are a PDF concern only — by design.
// ---------------------------------------------------------------------------
#set page(
width: sa.trim-width,
height: sa.trim-height,
margin: 0pt,
bleed: sa.bleed,
fill: pal.bg, // shows only if the artwork is missing or transparent
background: art-layer,
foreground: {
composition
logo-place(spec)
crop-marks(spec)
},
)
// The body stays empty on purpose: every element is placed in the foreground, whose
// origin is the full page, so nothing depends on where a margin box would have started.
#box()
+333
View File
@@ -0,0 +1,333 @@
// centred-stack.typ — a centred, symmetrical type stack over full-bleed art.
//
// Voice: formal and classical. Teatro, opera, concerto, conferenza — the kind of piece
// where symmetry IS the design and any deliberate asymmetry would read as a mistake.
//
// Composition, top to bottom, all on one centred axis:
//
// ┌──────────────────────────┐
// │ full-bleed art │ art: fit "cover", never distorted
// │ ····· centre veil ···· │ scrim only when spec.needs_scrim
// │ T I T O L O │ display face, auto-fit
// │ ────────── │ short accent rule
// │ sottotitolo │ body face from here down
// │ DATA E ORARIO │
// │ LUOGO │
// │ dettagli, prezzo │
// │ │
// │ footer · logo │ footer pinned to the foot of the sheet
// └──────────────────────────┘
//
// Three rules this file obeys, in order of importance:
// 1. Nothing ever leaves the safe area. The stack is budgeted BEFORE it is typeset
// (see `cap` below), and `fit` only ever shrinks, so overflow is impossible rather
// than unlikely. That matters because a poster is checked once, at the printer.
// 2. Every measurement is a fraction of the trim's short edge, so the composition is
// re-solved per format instead of being a fixed layout that gets scaled.
// 3. Determinism: no dates, no randomness, no system fonts. See lib.typ's header.
//
// Coordinates: with `page(bleed:)` the BODY's origin is the trim's top-left, while
// `background:`/`foreground:` resolve against the full bleed page. Verified, not assumed.
// So body placement insets by `sa.safe`, and background art uses `sa.full-*`.
#import "lib.typ": *
#let spec = json(sys.inputs.specfile)
// ---------------------------------------------------------------------------
// Geometry, palette, and the two knobs the aspect ratio turns
// ---------------------------------------------------------------------------
#let sa = safe-area(spec)
#let pal = palette-of(spec)
#let trim = trim-mm(spec)
#let se = short-edge-mm(spec) * 1mm // the scale reference for everything here
#let aspect = trim.width / trim.height
// fb-cover (2.47:1) and yt-thumb (1.78:1) have almost no vertical room, so the same
// centred stack has to be re-solved rather than scaled: a shorter measure, fewer lines
// per block, tighter gaps. Portrait and 4:5 keep the classical airy setting.
#let wide = aspect > 1.05
#let ultra-wide = aspect > 1.8
// Measure. A full-width line on a 289 mm fb-cover would be unreadable; a centred stack
// wants a column, not the whole sheet.
#let col = sa.width * (if ultra-wide { 0.70 } else if wide { 0.82 } else { 0.90 })
#let gap = se * (if wide { 0.020 } else { 0.030 })
#let rule-gap = gap * 1.30
#let rule-len = calc.min(col * 0.32, se * 0.20)
#let rule-w = calc.max(0.5pt, se * 0.0018)
// ---------------------------------------------------------------------------
// The centre veil — the "full-page scrim" this layout needs
// ---------------------------------------------------------------------------
/// A wash over the WHOLE page that is densest along the horizontal centre line, which is
/// exactly where a centred stack puts its type. Built from lib's `scrim` twice (each
/// half-page gradient runs solid-at-the-middle, transparent-at-its-edge) over a light
/// flat base, so the artwork stays visible at the top and bottom edges and the type
/// still sits on enough density to hold contrast.
///
/// Edge-anchored bands (the hero-bottom kind) are wrong here: they would darken exactly
/// the two strips this layout leaves empty and leave the headline unsupported.
#let centre-veil(sa, colour) = {
let half = sa.full-height / 2
place(top + left, rect(
width: sa.full-width, height: sa.full-height,
stroke: none, fill: colour.transparentize(66%),
))
place(top + left, scrim(half, colour, 90deg, width: sa.full-width, strength: 46%))
place(bottom + left, scrim(half, colour, 270deg, width: sa.full-width, strength: 46%))
}
// ---------------------------------------------------------------------------
// Blocks
// ---------------------------------------------------------------------------
/// Every block with usable text, in spec order. Blocks are rendered in the order the art
/// director wrote them; only the role decides the typography.
#let all-blocks = {
let bs = _get(spec, "blocks", ())
if type(bs) != array { () } else {
bs.filter(b => {
if type(b) != dictionary { return false }
let t = _get(b, "text", none)
type(t) == str and t.trim() != ""
})
}
}
#let role-of(b) = {
let r = _get(b, "role", "details")
if type(r) == str { r } else { "details" }
}
/// The footer is lifted out of the stack and pinned to the foot of the sheet — the
/// classical position for a patrocinio/credit line, and it keeps the tiny type from
/// hanging off the bottom of an otherwise centred group.
#let footers = all-blocks.filter(b => role-of(b) == "footer")
#let stack-blocks = all-blocks.filter(b => role-of(b) != "footer")
// Relative appetite for vertical space. The title gets the lion's share; everything else
// is proportioned against it. Only used when space is short — see `cap`.
#let _WEIGHT = (
title: 5.0, subtitle: 2.0, date: 1.7, venue: 1.4, details: 2.2, price: 1.2, footer: 1.0,
)
// Hard ceiling on how many lines a role may occupy. Without it a spacious A3 would hand
// the title a 200 mm box and cheerfully set it on six lines.
#let _LINES = if wide {
(title: 2, subtitle: 2, date: 1, venue: 1, details: 2, price: 1, footer: 1)
} else {
(title: 3, subtitle: 2, date: 2, venue: 2, details: 4, price: 1, footer: 2)
}
#let weight-of(role) = _WEIGHT.at(role, default: 1.2)
/// Height of `lines` lines at this role's ideal size. `leading` is an em-relative length,
/// so it is resolved against the role's own size instead of the ambient text size.
#let lines-height(st, lines) = {
let lead = st.leading.em * st.size + st.leading.abs
st.size * 1.2 * lines + lead * calc.max(0, lines - 1)
}
/// The floor a block needs to still be worth setting: one line at its minimum size — two
/// for a title, because a headline broken to a single shrunken line is not a headline.
#let need-of(st) = {
st.min-size * (if st.role == "title" { 2.6 } else { 1.25 })
}
// ---------------------------------------------------------------------------
// Vertical budget
// ---------------------------------------------------------------------------
// Optical centring: the eye reads the centre of a sheet as slightly above the geometric
// centre, so the whole group is lifted. Implemented by shortening the centring box from
// the bottom, which also makes the lift self-clamping — the stack is budgeted against the
// SHORTENED box, so it can never be pushed past the safe edge at either end.
#let lift = sa.height * (if wide { 0.015 } else { 0.030 })
// The footer band, reserved out of the stack's height before anything is measured.
#let footer-style = block-style(spec, "footer")
#let footer-band = if footers.len() == 0 { 0pt } else {
lines-height(footer-style, _LINES.footer) + gap * 1.2
}
#let avail = sa.height - 2 * lift - footer-band
// A rule is drawn only where it means something: between the title and whatever follows.
#let has-rule = {
let roles = stack-blocks.map(role-of)
roles.contains("title") and roles.filter(r => r != "title").len() > 0
}
#let rule-extra = if has-rule { 2 * rule-gap + rule-w - gap } else { 0pt }
/// Drop `optional: true` blocks, last (least important) first, until what is left can be
/// set above its minimum sizes. In practice this almost never fires — the auto-fit
/// absorbs a crowded spec by narrowing and shrinking — which is the point: dropping a
/// block the user asked for is the last resort, not the first.
#let keep-blocks = {
let kept = stack-blocks
while kept.len() > 1 {
let n = kept.len()
let gaps = (n - 1) * gap + rule-extra
let pool = avail - gaps
let need = kept.map(b => need-of(block-style(spec, role-of(b)))).sum(default: 0pt)
if pool >= need { break }
// Least important droppable block = the last one flagged optional.
let idx = none
for (i, b) in kept.enumerate() {
if _get(b, "optional", false) == true { idx = i }
}
if idx == none { break }
kept = kept.slice(0, idx) + kept.slice(idx + 1)
}
kept
}
#let n-kept = keep-blocks.len()
#let gaps-total = if n-kept <= 1 { 0pt } else { (n-kept - 1) * gap + rule-extra }
#let pool = calc.max(0pt, avail - gaps-total)
#let weight-total = keep-blocks.map(b => weight-of(role-of(b))).sum(default: 1.0)
/// The box a block may fill: its share of the leftover height, never more than its line
/// ceiling. Because every block is capped and `fit` never grows past its box, the summed
/// stack cannot exceed `avail` — no post-hoc overflow check is needed anywhere below.
#let cap(st) = {
let share = pool * (weight-of(st.role) / weight-total)
calc.min(lines-height(st, _LINES.at(st.role, default: 2)), share)
}
// ---------------------------------------------------------------------------
// The stack
// ---------------------------------------------------------------------------
#let type-stack = {
let prev = none
for b in keep-blocks {
let role = role-of(b)
let st = block-style(spec, role)
// Spacing. The rule replaces the gap after the title band ends.
if prev != none {
if prev == "title" and role != "title" and has-rule {
v(rule-gap)
block(width: 100%, align(center, line(
length: rule-len,
stroke: rule-w + pal.accent,
)))
v(rule-gap)
} else {
v(gap)
}
}
// Uppercasing is the role's decision (lib's _ROLE-STYLES). Caps-only families such as
// Bebas Neue or Bungee draw capitals at lowercase codepoints, so a non-uppercased
// role still renders correctly with them — we just never rely on lowercase shapes.
let t = _get(b, "text", "")
let body = if st.upper { upper(t) } else { t }
// fit bisects the wdth axis first and the size only as a fallback, which keeps a long
// Italian title at full optical weight instead of quietly shrinking the poster.
block(width: 100%, fit(
body, col, cap(st), st.family, st.axes,
..st.args,
align-to: center,
justify: false,
))
prev = role
}
}
// A bottom-corner logo shares the footer band, so the footer's measure is inset by the
// logo's width on BOTH sides — symmetric, because a centred footer nudged off-axis to
// dodge a logo is exactly the kind of near-miss this layout cannot afford.
#let footer-width = {
let logo = _get(spec, "logo", none)
let inset = if type(logo) != dictionary { 0pt } else {
let path = _get(logo, "path", none)
if type(path) != str or path.trim() == "" { 0pt } else {
let corner = _get(logo, "corner", "br")
if corner in ("bl", "br") {
let s = _get(logo, "scale", 0.12)
let s = if type(s) in (int, float) { calc.max(0.02, calc.min(0.4, s)) } else { 0.12 }
sa.short-edge * s + gap
} else { 0pt }
}
}
calc.max(sa.width * 0.4, sa.width - 2 * inset)
}
#let footer-stack = {
for (i, b) in footers.enumerate() {
if i > 0 { v(gap * 0.4) }
let st = block-style(spec, "footer")
let t = _get(b, "text", "")
block(width: 100%, fit(
if st.upper { upper(t) } else { t },
footer-width, lines-height(st, _LINES.footer), st.family, st.axes,
..st.args,
align-to: center,
justify: false,
))
}
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
#set document(date: none)
#set text(lang: "it", fill: pal.ink)
#set par(linebreaks: "optimized", justify: false)
#set page(
width: sa.trim-width,
height: sa.trim-height,
margin: 0pt,
bleed: sa.bleed,
fill: pal.bg,
background: {
// Full-bleed artwork. "cover" crops rather than distorts; the crop is centred, so a
// symmetrical layout keeps a symmetrical background.
let art = _get(spec, "art_file", none)
if type(art) == str and art.trim() != "" {
place(top + left, image(
art,
width: sa.full-width,
height: sa.full-height,
fit: "cover",
))
}
// ink_resolved was already contrast-checked upstream; needs_scrim is that check's
// verdict. Never second-guess either one here.
if _get(spec, "needs_scrim", false) == true {
centre-veil(sa, pal.scrim)
}
},
foreground: {
crop-marks(spec)
logo-place(spec)
},
)
// Body origin = trim top-left, so the safe inset is `sa.safe` alone.
#place(top + left, dx: sa.safe + (sa.width - col) / 2, dy: sa.safe, box(
width: col,
height: avail,
align(center + horizon, type-stack),
))
#if footers.len() > 0 {
place(top + left,
dx: sa.safe + (sa.width - footer-width) / 2,
dy: sa.safe + sa.height - footer-band,
box(
width: footer-width,
height: footer-band,
align(center + bottom, footer-stack),
),
)
}
+391
View File
@@ -0,0 +1,391 @@
// framed.typ — artwork inset inside a generous coloured frame.
//
// The gallery layout, and the safest of the five: the type NEVER sits on the artwork,
// so a busy, high-variance image can never eat a word of it. The coloured frame is the
// page itself (`palette.bg`), the artwork is a panel floated inside it, and every text
// block lives in the frame's margins.
//
// Two compositions, chosen from the trim's aspect ratio — never a fixed layout scaled:
//
// STACKED (portrait and square-ish: a3, a4, ig-post, ig-story)
// +----------------------+ art panel across the top, flush with the side frames,
// | +------------+ | the type band in the deeper bottom margin. The classic
// | | art | | museum poster; the band is only as tall as the type
// | +------------+ | actually needs, so every spare millimetre goes to art.
// | TITOLO |
// | data · luogo |
// +----------------------+
//
// SIDE (landscape: fb-cover, yt-thumb)
// +---------------------------+ a 2.5:1 cover has no room for a bottom band — the
// | TITOLO | art | type would be a 12 mm strip. So the frame margin
// | data | | that carries the type moves to the left edge and
// +---------------------------+ becomes a column, art fills the rest.
//
// Everything is drawn in `page(background:)`, whose origin is the FULL page INCLUDING
// bleed — hence the `+ b` on every coordinate. That is deliberate: one coordinate system
// for the whole composition is the only reliable defence against the classic bleed bug.
// Only the frame colour bleeds; the art panel is inset by definition and never reaches
// the trim, which is what makes this template unusually forgiving to print.
#import "lib.typ": *
#let spec = json(sys.inputs.specfile)
// ---------------------------------------------------------------------------
// Tunables — all fractions of the trim's SHORT edge, so the piece looks the same
// at 1080 px and at 300 dpi A3.
// ---------------------------------------------------------------------------
/// Frame width: the margin between the trim and the art panel. Generous on purpose —
/// a mean frame reads as a printing mistake, a wide one reads as a decision.
#let FRAME-RATIO = 0.070
/// Breathing space between the art panel and the type, and between type blocks.
#let GAP-RATIO = 0.030
/// The type band may never take more than this fraction of the height (stacked), and the
/// art panel may never be squeezed below its own floor. Between them they guarantee the
/// composition stays a framed picture with a caption, not a caption with a stamp.
#let BAND-MAX = 0.46
#let ART-MIN = 0.26
/// Width of the type column in the landscape composition, as a fraction of the space
/// inside the frame. 0.36 keeps a readable measure without starving the artwork.
#let COL-RATIO = 0.36
/// Hairline keyline around the art panel: a gallery frame's inner edge. Scaled with the
/// piece so it stays a hairline rather than a rule.
#let KEYLINE-RATIO = 0.0016
/// Ceiling on how many lines each role may claim when the band height is budgeted. The
/// auto-fit is what actually guarantees the text fits; this only stops one very long
/// block from claiming the whole band before the others are placed.
#let MAX-LINES = (title: 3, subtitle: 3, date: 2, venue: 2, details: 4, price: 2, footer: 2)
/// Rough line box: cap height plus leading. Only used to cap the budget above.
#let LINE-FACTOR = 1.34
// ---------------------------------------------------------------------------
// Blocks
// ---------------------------------------------------------------------------
/// Every renderable block in SPEC ORDER, with its resolved style and its text already
/// uppercased where the role calls for it. Blocks that are not dictionaries, or whose
/// text is missing/blank, simply do not exist — a half-filled spec must still print.
#let entries(spec) = {
let bs = _get(spec, "blocks", ())
if type(bs) != array { return () }
let out = ()
for b in bs {
if type(b) != dictionary { continue }
let raw = _get(b, "text", "")
if type(raw) != str or raw.trim() == "" { continue }
let st = block-style(spec, _get(b, "role", none))
out.push((
st: st,
body: if st.upper { upper(raw) } else { raw },
// Only an explicit `true` makes a block droppable; anything else is mandatory.
optional: _get(b, "optional", false) == true,
))
}
out
}
/// Height this block wants in a `w`-wide column: the height it takes at its ideal size
/// and at the NARROWEST width the auto-fit is allowed to use — i.e. the best case `fit`
/// could reach without shrinking. Budgeting against the best case is what lets a long
/// Italian title keep its size by narrowing, instead of being handed three lines' worth
/// of room and dutifully filling them.
///
/// Must be called from a `context` block: it measures.
#let wanted-height(st, body, w) = {
let rng = _axis-range(st.axes.at("wdth", default: none))
// `fit` never exceeds the natural instance (100), so the floor is the axis minimum.
let wd = if rng == none { none } else { calc.min(rng.at(0), rng.at(1)) }
let h = measure(width: w, {
set par(leading: st.leading, linebreaks: "optimized", justify: false)
text(.._text-args(st.family, st.size, st.weight, st.tracking, none, wd), body)
}).height
calc.min(h, MAX-LINES.at(st.role, default: 2) * st.size * LINE-FACTOR)
}
/// Space above block `i`. A title is followed by a wider gap: the reader needs to see
/// where the headline stops and the practical information starts.
#let gap-before(items, i, gap) = {
if i == 0 { return 0pt }
if items.at(i - 1).st.role == "title" { gap * 1.7 } else { gap }
}
/// Budget the type stack into `max-h`.
///
/// 1. Measure what every block wants.
/// 2. While the stack is over budget, drop the LAST `optional: true` block — the spec
/// orders blocks by priority, so the last optional one is the least missed.
/// 3. If it is still over budget, scale every allocation by the same factor and let
/// `fit` shrink the text into it. Uniform scaling keeps the typographic hierarchy:
/// everything gets quieter together, nothing collapses on its own.
///
/// Returns `(items, heights, gaps, height)` with `height` the stack's real extent.
#let budget(items, w, gap, max-h) = {
let kept = items
let hs = ()
let gs = ()
let total = 0pt
// Recompute from scratch after each drop: removing a block also removes its gap, and
// a title that is no longer followed by anything no longer needs its wider one.
let plan(list) = {
let h = ()
let g = ()
let t = 0pt
for i in range(list.len()) {
let gb = gap-before(list, i, gap)
let hh = wanted-height(list.at(i).st, list.at(i).body, w)
h.push(hh)
g.push(gb)
t += gb + hh
}
(h, g, t)
}
let (h, g, t) = plan(kept)
hs = h
gs = g
total = t
// Drop optional blocks, last first, until the stack fits or none are left.
while total > max-h {
let drop = none
for i in range(kept.len()) {
if kept.at(i).optional { drop = i }
}
if drop == none { break }
kept = kept.slice(0, drop) + kept.slice(drop + 1)
let (h2, g2, t2) = plan(kept)
hs = h2
gs = g2
total = t2
}
// Still over: squeeze uniformly. `fit` does the rest.
if total > max-h and total > 0pt {
let k = max-h / total
hs = hs.map(x => x * k)
gs = gs.map(x => x * k)
total = max-h
}
(items: kept, heights: hs, gaps: gs, height: total)
}
/// Draw a budgeted stack from `(x, y)`, top-anchored, in a `w`-wide column.
#let draw-stack(plan, x, y, w, align-to) = {
let cy = y
for i in range(plan.items.len()) {
let it = plan.items.at(i)
cy += plan.gaps.at(i)
place(
top + left,
dx: x,
dy: cy,
block(
width: w,
fit(it.body, w, plan.heights.at(i), it.st.family, it.st.axes, align-to: align-to, ..it.st.args),
),
)
cy += plan.heights.at(i)
}
}
// ---------------------------------------------------------------------------
// Art panel
// ---------------------------------------------------------------------------
/// The inset artwork, plus its keyline and (only when the renderer measured the art as
/// busy) a soft scrim along the edge that faces the type.
///
/// `fit: "cover"` inside a fixed-size, clipped block: the artwork fills the panel at its
/// own aspect ratio and the overflow is cropped, so a 4:5 generation never distorts to
/// fill a 2.5:1 cover panel.
///
/// The scrim here is NOT a legibility fix — no text ever crosses this panel. It is a
/// vignette that settles a noisy image into the frame colour instead of butting against
/// it, which is exactly the case (`needs_scrim`) the renderer flags.
#let art-panel(spec, pal, w, h, short, scrim-edge) = {
let path = _get(spec, "art_file", none)
let keyline = calc.max(0.2pt, short * KEYLINE-RATIO)
block(
width: w,
height: h,
clip: true,
// The keyline sits on the panel edge, in ink at low strength: it defines the picture
// without competing with it. Over a dark frame it reads as light, over a light frame
// as dark, because `ink` is already the contrast-checked colour for this background.
stroke: keyline + pal.ink.transparentize(72%),
fill: if type(path) == str and path.trim() != "" { none } else {
// No artwork yet (text-only draft): a flat tint keeps the composition honest
// instead of leaving a hole where the picture goes.
pal.ink.transparentize(88%)
},
{
if type(path) == str and path.trim() != "" {
image(path, width: 100%, height: 100%, fit: "cover")
}
if _get(spec, "needs_scrim", false) == true and scrim-edge != none {
if scrim-edge == "bottom" {
// Solid at the panel's bottom edge, fading up into the picture.
place(bottom + left, scrim(h * 0.34, pal.scrim, 90deg, width: 100%, strength: 62%))
} else {
// Landscape: solid at the left edge, fading right — towards the type column.
place(top + left, scrim(h, pal.scrim, 180deg, width: w * 0.30, strength: 62%))
}
}
},
)
}
// ---------------------------------------------------------------------------
// Logo reserve
// ---------------------------------------------------------------------------
/// `logo-place` anchors the logo at the safe corner, which in this template is inside the
/// frame margin — the right place for it. But the frame is only ~7% of the short edge and
/// a logo is up to 40%, so the layout must be told to keep out of its corner.
///
/// Returns the reserve as a length (0pt when there is no logo) plus its corner. The
/// reserve is a SQUARE of the logo's width: `logo-place` constrains width only, so a
/// taller-than-wide logo can still exceed it — rare, and it costs a little art, never a
/// word of type.
#let logo-reserve(spec, short) = {
let logo = _get(spec, "logo", none)
if type(logo) != dictionary { return (size: 0pt, corner: none) }
let path = _get(logo, "path", none)
if type(path) != str or path.trim() == "" { return (size: 0pt, corner: none) }
let raw = _get(logo, "scale", 0.12)
let s = if type(raw) in (int, float) { calc.max(0.02, calc.min(0.4, raw)) } else { 0.12 }
let corner = _get(logo, "corner", "br")
(
size: short * s,
corner: if corner in ("tl", "tr", "bl", "br") { corner } else { "br" },
)
}
// ---------------------------------------------------------------------------
// Composition
// ---------------------------------------------------------------------------
#let compose(spec) = context {
let pal = palette-of(spec)
let sa = safe-area(spec)
let short = sa.short-edge
let b = sa.bleed
let tw = sa.trim-width
let th = sa.trim-height
// The frame can never be narrower than the safe margin, and never so wide that it eats
// the picture — 18% of the short edge on each side is already a very deep mount.
let frame = calc.min(calc.max(sa.safe, short * FRAME-RATIO), short * 0.18)
let gap = short * GAP-RATIO
let lg = logo-reserve(spec, short)
let items = entries(spec)
// Landscape gets the side composition. The threshold is above 1 on purpose: a 1080x1350
// ig-post is "wide" only arithmetically, and reads as a portrait.
let landscape = (tw / th) >= 1.2
if landscape {
// ---- SIDE: type column left, art right -------------------------------------
let inner-w = tw - 2 * frame
let col-w = inner-w * COL-RATIO
let art-x = frame + col-w + gap
let art-w = tw - frame - art-x
let art-y = frame
let art-h = th - 2 * frame
// A logo on the right sits over the artwork; give it a full-height gutter instead.
// Landscape art is wide and shallow, so width is the cheap axis here.
if lg.corner in ("tr", "br") and lg.size > 0pt {
art-w = calc.max(inner-w * 0.30, art-w - lg.size - gap)
}
// A logo on the left sits in the type column: shorten the column at that end.
let col-y = art-y
let col-h = art-h
if lg.corner == "tl" and lg.size > 0pt {
col-y += lg.size + gap * 0.6
col-h -= lg.size + gap * 0.6
} else if lg.corner == "bl" and lg.size > 0pt {
col-h -= lg.size + gap * 0.6
}
let plan = budget(items, col-w, gap, calc.max(0pt, col-h))
// Optically centre the stack in the column: a top-anchored column under a wide
// picture reads as if the type has slipped.
let sy = col-y + calc.max(0pt, (col-h - plan.height) / 2)
place(top + left, dx: b + art-x, dy: b + art-y,
art-panel(spec, pal, art-w, art-h, short, "left"))
draw-stack(plan, b + frame, b + sy, col-w, left)
} else {
// ---- STACKED: art on top, type band in the bottom margin --------------------
// A logo in a top corner would land on the picture, so the top frame grows to clear
// it; in a bottom corner it gets its own strip under the type band.
let top-extra = if lg.corner in ("tl", "tr") and lg.size > 0pt {
calc.max(0pt, sa.safe + lg.size + gap * 0.6 - frame)
} else { 0pt }
let bottom-extra = if lg.corner in ("bl", "br") and lg.size > 0pt {
calc.max(0pt, sa.safe + lg.size + gap * 0.6 - frame)
} else { 0pt }
let art-x = frame
let art-y = frame + top-extra
let art-w = tw - 2 * frame
let band-bottom = th - frame - bottom-extra
// The band gets what it asks for, capped so the picture keeps its floor. Whatever it
// does not use goes to the artwork, not to slack: a short title means a bigger picture.
let room = band-bottom - art-y - gap - th * ART-MIN
let max-band = calc.min(th * BAND-MAX, calc.max(0pt, room))
let plan = budget(items, art-w, gap, max-band)
let band-y = band-bottom - plan.height
let art-h = calc.max(th * 0.12, band-y - gap - art-y)
place(top + left, dx: b + art-x, dy: b + art-y,
art-panel(spec, pal, art-w, art-h, short, "bottom"))
draw-stack(plan, b + art-x, b + band-y, art-w, left)
}
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
#let pal = palette-of(spec)
#let sa = safe-area(spec)
// `date: none` is the load-bearing line for byte-identical output — without it the PDF
// carries a creation timestamp and every golden-file test is a coin toss.
#set document(date: none)
#set text(lang: "it", fill: pal.ink)
#set par(linebreaks: "optimized")
#set page(
width: sa.trim-width,
height: sa.trim-height,
margin: 0pt,
bleed: sa.bleed,
// The frame colour IS the page, so it covers the bleed too and the guillotine can land
// anywhere in that 3 mm without exposing white.
fill: pal.bg,
background: compose(spec),
foreground: {
logo-place(spec)
crop-marks(spec)
},
)
// The composition lives entirely in `background:`/`foreground:`, which resolve against
// the full bleed page. The body only has to exist so that Typst emits the page.
#v(0pt)
+352
View File
@@ -0,0 +1,352 @@
// hero-bottom.typ — the default poster layout.
//
// Artwork full-bleed; every block of type in one band across the bottom, over a
// gradient scrim. Title dominant, then date/venue, details small at the foot. It is the
// safest and most reusable of the five layouts, which is why it is the default: it never
// fights the artwork for the middle of the page and it survives any aspect ratio.
//
// COORDINATES (verified against typst 0.15.1, see docs/typst-verified.md):
// * `page(background:)` and `page(foreground:)` resolve their offsets against the FULL
// page INCLUDING bleed — the same origin every mm key of `safe-area()` is measured
// from, so the whole composition lives there;
// * the page BODY with `margin: 0pt` starts at the TRIM instead, which is off by the
// bleed on a print job. The body is therefore left empty. Typst still emits the page.
// * PNG export renders the trim, so the bleed and the crop marks are PDF-only. Nothing
// load-bearing may live outside the safe area.
//
// Layering, bottom to top: page fill -> artwork -> scrim -> type -> crop marks.
//
// The LLM never edits this file. It reads the ResolvedSpec as data and the fixed rules
// below decide the composition.
#import "lib.typ": *
#let spec = json(sys.inputs.specfile)
#let pal = palette-of(spec)
#let sa = safe-area(spec)
// ---------------------------------------------------------------------------
// Tuning
//
// Every constant is a ratio, never a size. The same file has to be right on A3 at
// 300 dpi and on a 1280x720 thumbnail, so nothing here may be expressed in points.
// ---------------------------------------------------------------------------
/// Trim width/height above which the band splits into two columns. fb-cover (2.47) and
/// yt-thumb (1.78) land above it; a3/a4 (0.71), ig-post (0.80) and ig-story (0.56) below.
#let LANDSCAPE-AT = 1.25
/// The band grows to fit its content, but only between these fractions of the trim
/// height. The floor keeps it reading as a band when there are only two blocks; the
/// ceiling stops a seven-block spec from swallowing the artwork. Wide formats get a
/// higher ceiling because a third of 664 px is not a band, it is a caption.
#let BAND-MIN = 0.30
#let BAND-MAX-TALL = 0.62
#let BAND-MAX-WIDE = 0.80
/// Vertical gap between blocks, as a fraction of the trim's short edge, and the wider
/// step that separates the title group from the informative blocks. That single larger
/// gap is what makes the band read as "headline, then facts" instead of one grey stack.
#let GAP-RATIO = 0.020
#let GROUP-GAP = 1.9
/// Lines the title may claim before the band stops growing for it. Past this the title
/// is narrowed on the `wdth` axis (and only then shrunk) by `fit`, which is the whole
/// point of the auto-fit: a four-line headline is not a headline.
#let TITLE-LINES = 3
/// Rough line advance in multiples of the font size. Used only to cap how much room the
/// title may claim — never to lay anything out, which is always done from real metrics.
#let LINE-ADVANCE = 1.32
/// Scrim height as a multiple of the band. The kernel's gradient holds its fade back
/// until ~45% so the artwork stays clean, which means the scrim has to start well above
/// the type for the small blocks at the foot to sit on full density.
#let SCRIM-TALL = 1.50
#let SCRIM-WIDE = 1.30
/// Two-column split for landscape formats: title column, gutter, and the rest.
#let COL-SPLIT = 0.60
#let COL-GUTTER = 0.045
// ---------------------------------------------------------------------------
// Blocks -> laid-out items
// ---------------------------------------------------------------------------
/// Every block that has real text, IN SPEC ORDER, paired with its resolved typography.
///
/// `hero` marks the first `title` block — the one the band is sized around. A second
/// title block (rare, but the schema allows it) is treated as an ordinary line rather
/// than competing for the same room. Empty and malformed blocks are dropped silently:
/// a missing subtitle means "draw nothing", never a failed render.
#let prepare(spec) = {
let bs = _get(spec, "blocks", ())
if type(bs) != array { return () }
let items = ()
let hero-taken = false
for b in bs {
if type(b) != dictionary { continue }
let raw = _get(b, "text", "")
if type(raw) != str or raw.trim() == "" { continue }
let st = block-style(spec, _get(b, "role", none))
let hero = st.role == "title" and not hero-taken
if hero { hero-taken = true }
items.push((
style: st,
// Uppercasing happens here, not in `fit`, so the measured text and the drawn text
// are the same string. CAPS-ONLY families (Bebas Neue, Bungee) are unaffected:
// they draw capitals either way.
body: if st.upper { upper(raw) } else { raw },
hero: hero,
optional: _get(b, "optional", false) == true,
))
}
items
}
/// Roles that belong to the headline group — the left column on a landscape format.
#let HEAD-ROLES = ("title", "subtitle")
/// Leading gap for each item; the first has none.
#let gaps-for(items, gap) = {
let gs = ()
for (i, it) in items.enumerate() {
if i == 0 {
gs.push(0pt)
} else {
let step = items.at(i - 1).style.role in HEAD-ROLES and not (it.style.role in HEAD-ROLES)
gs.push(if step { gap * GROUP-GAP } else { gap })
}
}
gs
}
// ---------------------------------------------------------------------------
// Measuring and fitting a column
//
// The band is sized from REAL metrics, not from a guess at how many lines each block
// takes. A one-line date must not reserve two lines' worth of room, or a seven-block
// poster squeezes a title that had space all along.
// ---------------------------------------------------------------------------
/// Height this block wants at its natural size in a `w`-wide column. Measured with the
/// same paragraph settings and the same (absent) width variation `fit` starts from, so
/// handing `fit` exactly this much room reproduces the measurement instead of shrinking.
/// Must be called inside `context`.
#let natural-h(it, w) = {
let st = it.style
measure(width: w, {
set par(leading: st.leading, linebreaks: "optimized")
text(.._text-args(st.family, st.size, st.weight, st.tracking, none, none), it.body)
}).height
}
/// Natural heights of a column plus the room it would like: the sum of those heights and
/// the gaps, with the title capped at `TITLE-LINES`. Must be called inside `context`.
#let col-plan(items, w, gap) = {
let hs = items.map(it => natural-h(it, w))
let need = gaps-for(items, gap).fold(0pt, (a, b) => a + b)
for (i, it) in items.enumerate() {
let h = hs.at(i)
if it.hero { h = calc.min(h, it.style.size * LINE-ADVANCE * TITLE-LINES) }
need += h
}
(hs: hs, need: need)
}
/// Budget for one candidate stack: the gaps, where the title sits, how much room the
/// other blocks need, and the floor the title is never pushed below.
#let col-budget(items, hs, gap) = {
let gs = gaps-for(items, gap)
let hero-i = items.position(it => it.hero)
let rest = 0pt
for (i, h) in hs.enumerate() {
if hero-i == none or i != hero-i { rest += h }
}
(
gs: gs,
gaps: gs.fold(0pt, (a, b) => a + b),
hero-i: hero-i,
rest: rest,
// A shade above `fit`'s own floor, so the title reaches its hard minimum only after
// the layout has already given up everything else it could.
floor: if hero-i == none { 0pt } else { items.at(hero-i).style.min-size * 1.15 },
)
}
/// Lay one column into `avail` of vertical room and return it bottom-anchored.
///
/// The title is what the layout protects: its floor is subtracted first, the other
/// blocks live on what is left. When that does not balance, blocks marked
/// `optional: true` are dropped from the end — never a block the art director did not
/// mark, which is instead squeezed and left to `fit`'s own min-size.
/// Must be called inside `context`.
#let col-render(items, hs, w, avail, gap, align-to) = {
if items.len() == 0 { return none }
let live = items
let heights = hs
let b = col-budget(live, heights, gap)
// Drop optional blocks, last first, while the stack cannot balance.
while avail - b.gaps - b.rest < b.floor {
let drop = none
for (i, it) in live.enumerate() {
if it.optional and not it.hero { drop = i }
}
if drop == none { break }
live = live.slice(0, drop) + live.slice(drop + 1)
heights = heights.slice(0, drop) + heights.slice(drop + 1)
b = col-budget(live, heights, gap)
}
// Whatever is left over goes to the title. `fit` never grows past the ideal size, so a
// generous allocation simply means the title stays full size on fewer lines.
let hero-alloc = avail - b.gaps - b.rest
let squeeze = 1.0
if b.hero-i != none and hero-alloc < b.floor {
hero-alloc = b.floor
let room = avail - b.gaps - b.floor
squeeze = if b.rest > 0pt { calc.max(0.35, calc.min(1.0, room / b.rest)) } else { 1.0 }
} else if b.hero-i == none and b.rest + b.gaps > avail {
squeeze = calc.max(0.35, (avail - b.gaps) / b.rest)
}
let out = ()
for (i, it) in live.enumerate() {
if i > 0 { out.push(b.gs.at(i)) }
let h = if it.hero { hero-alloc } else { heights.at(i) * squeeze }
// `fit` bisects the width axis before the size, so a long Italian title narrows and
// keeps its optical weight instead of quietly becoming a small title.
out.push(fit(
it.body, w, calc.max(h, 1pt),
it.style.family, it.style.axes,
align-to: align-to,
..it.style.args,
))
}
// Each fitted block occupies its own natural height, so the stack collapses any slack
// and hugs the bottom of the band — the foot of the poster stays a straight edge.
block(width: w, stack(dir: ttb, ..out))
}
// ---------------------------------------------------------------------------
// The composition
// ---------------------------------------------------------------------------
/// Full-bleed artwork, drawn from the full-page origin so it covers the bleed too.
/// `fit: "cover"` crops rather than distorts — a stretched face is worse than a lost
/// corner. NOTE: `art_file` must be root-relative; Typst resolves image paths against
/// `--root`, so the renderer rewrites it (same rule as `logo.path`).
#let artwork(spec) = {
let p = _get(spec, "art_file", none)
if type(p) != str or p.trim() == "" { return none }
place(top + left, image(p, width: sa.full-width, height: sa.full-height, fit: "cover"))
}
/// Scrim + type. One `context` for the whole thing, because the scrim's height is
/// derived from the band, the band's height from the measured content, and the logo's
/// ceiling from the band: they must all come out of a single measuring pass.
#let composition(spec) = context {
let items = prepare(spec)
if items.len() > 0 {
let gap = sa.short-edge * GAP-RATIO
let landscape = sa.trim-width / sa.trim-height >= LANDSCAPE-AT
// On a wide format a bottom third is a caption, not a band: the title would have to
// shrink to nothing to leave room for six more lines under it. So the band splits —
// headline left, the facts right-aligned against the safe edge — and both columns
// sit on the same baseline. Portrait and square formats keep the single stack.
let head = items.filter(it => it.style.role in HEAD-ROLES)
let facts = items.filter(it => not (it.style.role in HEAD-ROLES))
let split = landscape and head.len() > 0 and facts.len() > 0
let lw = if split { sa.width * COL-SPLIT } else { sa.width }
let rw = sa.width * (1.0 - COL-SPLIT - COL-GUTTER)
let left-plan = col-plan(if split { head } else { items }, lw, gap)
let right-plan = if split { col-plan(facts, rw, gap) } else { (hs: (), need: 0pt) }
// The band is content-driven and then clamped: it never looks thinner than a band,
// never eats more of the artwork than the format can spare, and never spills out of
// the safe area.
let want = calc.max(left-plan.need, right-plan.need)
let band-h = calc.min(
calc.max(want, sa.trim-height * BAND-MIN),
sa.trim-height * (if landscape { BAND-MAX-WIDE } else { BAND-MAX-TALL }),
sa.height,
)
// Scrim first, type second: within `foreground` the drawing order is the content
// order. It runs to the physical bottom edge so the wash does not stop at the trim
// on a bled job. Skipped entirely when the renderer measured the artwork behind the
// band as calm and light enough — `needs_scrim` is not ours to second-guess.
if _get(spec, "needs_scrim", false) == true {
let scrim-h = calc.min(
sa.full-height,
band-h * (if landscape { SCRIM-WIDE } else { SCRIM-TALL }) + sa.bleed + sa.safe,
)
place(bottom + left, scrim(scrim-h, pal.scrim, none))
}
// Both columns are bottom-anchored at the safe inset, so the foot of the type is a
// straight line whatever each column ended up containing.
place(
bottom + left,
dx: sa.x,
dy: -(sa.bleed + sa.safe),
col-render(if split { head } else { items }, left-plan.hs, lw, band-h, gap, left),
)
if split {
place(
bottom + right,
dx: -(sa.bleed + sa.safe),
dy: -(sa.bleed + sa.safe),
col-render(facts, right-plan.hs, rw, band-h, gap, right),
)
}
// The logo keeps the corner the art director chose, but the band owns the bottom of
// the page, so a `bl`/`br` logo is lifted to rest just above it. `logo-place` still
// does the sizing and the inset: it is handed a shortened container that ends at the
// band, and its own bottom alignment does the rest. Top corners are unaffected.
let ceiling = calc.max(sa.full-height - band-h - gap, sa.full-height * 0.30)
place(top + left, block(width: sa.full-width, height: ceiling, logo-place(spec)))
}
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
// Byte-reproducible output: this is the load-bearing directive, not SOURCE_DATE_EPOCH.
// Without it the golden-file tests compare a timestamp.
#set document(date: none)
// `lang: "it"` buys Italian hyphenation and quotation conventions for free. The body
// family is only set when the spec names one — `text(font: none)` is an error, and an
// inherited family is a better failure than a dead render.
#let body-font = font-of(spec, "body")
#set text(
lang: "it",
fill: pal.ink,
..if body-font == none { (:) } else { (font: body-font) },
)
#set par(linebreaks: "optimized")
// `bleed:` is what makes Typst write a real PDF TrimBox; `margin: 0pt` because nothing
// flows — every element is placed. `fill` shows through only where the artwork is
// missing or does not cover, which is the one case where the palette background matters.
#set page(
width: sa.trim-width,
height: sa.trim-height,
bleed: sa.bleed,
margin: 0pt,
fill: pal.bg,
background: artwork(spec),
foreground: {
composition(spec)
crop-marks(spec)
},
)
+347
View File
@@ -0,0 +1,347 @@
// split.typ — hard geometric split: artwork owns one half, flat colour the other,
// and every word is typeset on the flat side.
//
// The idea is editorial rather than pictorial. There is no soft blend, no text over a
// photograph, no drop shadow doing the work a scrim should: a single straight seam cuts
// the piece in two, the artwork bleeds off three edges of its half, and the type sits on
// clean `palette.bg` where `ink_resolved` was contrast-checked against a flat colour and
// is therefore actually true.
//
// The split axis follows the aspect, which is the only thing that changes between an A3
// and a YouTube thumbnail:
//
// portrait / square landscape
// +-------------------+ +---------+---------+
// | ARTWORK | | | TITLE |
// | | | ARTWORK | date |
// +===================+ seam | | venue | seam is the vertical rule
// | TITLE | | | |
// | date, venue, ... | | | footer |
// +-------------------+ +---------+---------+
//
// Everything below is a fraction of the TRIM, so the same file is correct at 1080 px and
// at 300 dpi A3, and turning bleed on never changes how big the headline is.
#import "lib.typ": *
#let spec = json(sys.inputs.specfile)
#set document(date: none) // load-bearing: this is what makes output byte-identical
#set text(lang: "it") // Italian hyphenation, for free
#set par(linebreaks: "optimized") // Knuth-Plass; `fit` sets it again on its own trials
// ---------------------------------------------------------------------------
// Geometry
// ---------------------------------------------------------------------------
#let sa = safe-area(spec)
#let pal = palette-of(spec)
#let trim = trim-mm(spec)
/// Landscape is the ONLY switch in this file. Square counts as portrait: a 4:5 ig-post
/// reads as an upright piece, so it gets the horizontal seam like A3 does.
#let landscape = trim.width > trim.height
/// A hard half. This constant is deliberately not spec-driven — "one half" IS the
/// layout, and an art director who wants 60/40 wants `banded`, not `split`.
#let SPLIT = 0.5
/// Breathing room between the seam and the first word. Measured off the short edge so
/// the optical distance is the same on every format.
#let gutter = sa.short-edge * 0.045
/// Vertical rhythm between stacked blocks, and the spacing unit for the footer line.
#let gap = sa.short-edge * 0.016
/// The seam, in FULL-PAGE coordinates (bleed included) — the origin `page(background:)`
/// and `page(foreground:)` resolve against. Along the split axis the artwork runs from
/// the page edge to here, so it covers its own bleed; the panel starts here.
#let seam = sa.bleed + (if landscape { trim.width } else { trim.height }) * SPLIT * 1mm
/// The panel: the flat half, inset to the safe area, pushed off the seam by the gutter.
/// `calc.max` guards the pathological case of a safe margin wide enough to swallow half
/// the piece — `safe-area` already caps it at 40% of the short edge, but a zero-height
/// box would be a hard error rather than an ugly poster.
#let panel = if landscape {
(
x: seam + gutter,
y: sa.y,
width: calc.max(1mm, sa.x + sa.width - seam - gutter),
height: sa.height,
)
} else {
(
x: sa.x,
y: seam + gutter,
width: sa.width,
height: calc.max(1mm, sa.y + sa.height - seam - gutter),
)
}
// ---------------------------------------------------------------------------
// Logo — placed by lib's `logo-place`, but its footprint has to be reserved here
// ---------------------------------------------------------------------------
//
// `logo-place` honours `spec.logo.corner` and works off the same safe rect, so a corner
// that lands on the panel lands exactly on the panel's own edge. We do not move it —
// the art director's corner is respected — we only make room for it, and let the scrim
// cover the case where it was asked for on top of the artwork.
#let logo-raw = if type(spec) == dictionary { spec.at("logo", default: none) } else { none }
#let logo-path = if type(logo-raw) == dictionary { logo-raw.at("path", default: none) } else { none }
#let has-logo = type(logo-path) == str and logo-path.trim() != ""
/// Reserved logo width. This mirrors `logo-place`'s own sizing (short edge x clamped
/// scale) because the helper places the image but cannot tell us how much room it took.
#let logo-w = if not has-logo { 0pt } else {
let s = logo-raw.at("scale", default: 0.12)
let s = if type(s) in (int, float) { calc.max(0.02, calc.min(0.4, s)) } else { 0.12 }
sa.short-edge * s
}
#let logo-corner = if has-logo { logo-raw.at("corner", default: "br") } else { none }
#let logo-left = logo-corner in ("tl", "bl")
/// Which side of the seam the logo fell on. In portrait the panel is the bottom half, so
/// only bl/br touch it; in landscape the panel is the right half, so tr/br do.
#let logo-on-panel-bottom = has-logo and (
if landscape { logo-corner == "br" } else { logo-corner in ("bl", "br") }
)
#let logo-on-panel-top = has-logo and landscape and logo-corner == "tr"
/// Vertical reservation. The image's height is unknown at layout time — `logo-place`
/// sizes by width and lets the aspect decide — so we reserve a square. Wider-than-tall
/// wordmarks, which is nearly all of them, simply leave a little air.
#let logo-h = logo-w
// ---------------------------------------------------------------------------
// Blocks
// ---------------------------------------------------------------------------
/// How many lines a role is expected to run to. Multiplied by the role's type size this
/// becomes its share of the panel: a title is budgeted three lines of very large type, a
/// price one line of small type, and the ratio between them is what makes the hierarchy
/// survive at every format instead of only at the one it was drawn on.
#let _LINES = (
title: 3.0, subtitle: 2.0, date: 1.5, venue: 1.5, details: 3.0, price: 1.0, footer: 1.5,
)
/// Every block, IN SPEC ORDER, with its text resolved. This generalises lib's
/// `block-text` — which returns the first block of a role — to a spec that legitimately
/// repeats a role (two `details` lines, say). Blank and malformed entries are dropped
/// silently: a missing venue means "draw no venue", never an error.
///
/// Casing comes from the role via `block-style`, never from the glyphs. Bebas Neue and
/// Bungee draw capitals at lowercase codepoints, so a mixed-case subtitle in one of them
/// simply arrives in caps; nothing here reads lowercase shapes as a signal.
#let entries = {
let out = ()
let raw = if type(spec) == dictionary { spec.at("blocks", default: ()) } else { () }
if type(raw) != array { raw = () }
for b in raw {
if type(b) != dictionary { continue }
let t = b.at("text", default: "")
if type(t) != str or t.trim() == "" { continue }
let st = block-style(spec, b.at("role", default: none))
out.push((
role: st.role,
body: if st.upper { upper(t) } else { t },
style: st,
optional: b.at("optional", default: false) == true,
weight: st.ratio * _LINES.at(st.role, default: 1.5),
))
}
out
}
/// The colophon line. Several footer blocks collapse into one rule-of-thumb line rather
/// than stacking, because the foot of the panel is a single band shared with the logo.
#let foot-body = {
let fs = entries.filter(e => e.role == "footer")
if fs.len() == 0 { none } else { fs.map(e => e.body).join(" · ") }
}
#let foot-style = block-style(spec, "footer")
/// Height of the bottom band: whichever is taller, the colophon or the logo sharing it.
#let foot-text-h = if foot-body == none { 0pt } else { foot-style.size * 2.2 }
#let foot-h = calc.max(foot-text-h, if logo-on-panel-bottom { logo-h } else { 0pt })
/// Reservation above the stack, for the one corner (landscape + `tr`) where the logo
/// would otherwise sit on the headline.
#let head-h = if logo-on-panel-top { logo-h + gap } else { 0pt }
/// What is left for the type stack.
#let flow-h = calc.max(
1mm,
panel.height - head-h - foot-h - (if foot-h > 0pt { gap } else { 0pt }),
)
// ---------------------------------------------------------------------------
// Dropping optional blocks
// ---------------------------------------------------------------------------
/// The least space a block can occupy and still be worth printing: one line at the floor
/// size `block-style` computed for its role, plus its leading.
#let _min-need(st) = st.min-size * 1.55
/// Blocks that go in the stack, after dropping `optional: true` entries that genuinely
/// do not fit. Spec order is priority order (title first, footer last), so the LAST
/// optional block is always the cheapest thing to lose. Note this only fires when the
/// panel cannot hold the blocks at their floor sizes; the ordinary crowding case is
/// handled by `fit`, which narrows the type on the wdth axis long before anything is
/// thrown away.
#let flow = {
let list = entries.filter(e => e.role != "footer")
let needed(l) = {
let s = 0pt
for e in l { s += _min-need(e.style) }
s + gap * calc.max(0, l.len() - 1)
}
while needed(list) > flow-h and list.any(e => e.optional) {
let idx = 0
for (i, e) in list.enumerate() { if e.optional { idx = i } }
list = list.slice(0, idx) + list.slice(idx + 1)
}
list
}
/// Split the flow height between the survivors by weight, so the panel is always exactly
/// filled: one lone title gets the whole panel (and `fit` still refuses to grow past its
/// ideal size), while a seven-block spec divides it proportionally and the title keeps
/// roughly half. Because every budget is honoured by `fit`, the stack can never be taller
/// than the panel — the layout has no overflow case.
#let _wsum = flow.fold(0.0, (a, e) => a + e.weight)
#let _inner = calc.max(1mm, flow-h - gap * calc.max(0, flow.len() - 1))
#let budget(e) = if _wsum <= 0 { _inner } else { _inner * (e.weight / _wsum) }
// ---------------------------------------------------------------------------
// The three pieces of the composition
// ---------------------------------------------------------------------------
/// The artwork half, bleeding off the three page edges it touches and stopping dead on
/// the seam. `fit: "cover"` fills the box and crops the overflow, so the art is never
/// distorted whatever aspect the backend produced; `clip` makes the seam a hard edge
/// rather than an overhang.
///
/// NOTE: `spec.art_file` must reach us ROOT-RELATIVE (leading "/"). Typst resolves image
/// paths against `--root`, not the filesystem root — the renderer rewrites it, exactly as
/// it does for `spec.logo.path`. With no artwork at all we fall back to a flat scrim-
/// coloured half: a poster missing its picture is recoverable, a failed compile is not.
#let art-file = if type(spec) == dictionary { spec.at("art_file", default: none) } else { none }
#let art-w = if landscape { seam } else { sa.full-width }
#let art-h = if landscape { sa.full-height } else { seam }
#let artwork = place(top + left, dx: 0pt, dy: 0pt, box(
width: art-w,
height: art-h,
clip: true,
if type(art-file) == str and art-file.trim() != "" {
image(art-file, width: 100%, height: 100%, fit: "cover")
} else {
rect(width: 100%, height: 100%, fill: pal.scrim, stroke: none)
},
))
/// The scrim, and the one place it is honest in this template.
///
/// No text ever sits on the artwork here, so this is not carrying the headline. It is
/// anchored on the OUTER edge of the art half — the top in portrait, the left in
/// landscape — and fades toward the seam, which is precisely where the two art-side
/// corners are. That covers the case the art director asked for: a logo placed `tl`/`tr`
/// (portrait) or `tl`/`bl` (landscape) sits on the picture, and when the renderer has
/// measured that picture as bright or busy (`needs_scrim`) it needs something under it.
/// It also keeps loud artwork from shouting across the seam at the flat panel.
#let needs-scrim = if type(spec) == dictionary { spec.at("needs_scrim", default: false) == true } else { false }
#let art-scrim = if not needs-scrim { none } else if landscape {
place(top + left, dx: 0pt, dy: 0pt,
scrim(sa.full-height, pal.scrim, 180deg, width: art-w * 0.45, strength: 80%))
} else {
place(top + left, dx: 0pt, dy: 0pt,
scrim(art-h * 0.45, pal.scrim, 270deg, width: sa.full-width, strength: 80%))
}
/// The seam itself: an accent hairline drawn on the PANEL side of the cut, so it always
/// reads against flat `bg` instead of disappearing into whatever the artwork does at its
/// edge. It runs the full page, bleed included — a rule that stopped at the trim would
/// leave a notch after cutting.
#let seam-rule = {
let t = calc.max(0.6pt, sa.short-edge * 0.0025)
if landscape {
place(top + left, dx: seam, dy: 0pt,
rect(width: t, height: sa.full-height, fill: pal.accent, stroke: none))
} else {
place(top + left, dx: 0pt, dy: seam,
rect(width: sa.full-width, height: t, fill: pal.accent, stroke: none))
}
}
// ---------------------------------------------------------------------------
// Type
// ---------------------------------------------------------------------------
/// The stack, vertically centred in the flow area. Centring rather than top-aligning is
/// what makes a two-block spec look composed instead of abandoned: the panel is often
/// far taller than the words that have to go in it (an ig-story panel is ~156 mm for as
/// little as a title and a date), and a heading pinned to the top of that void reads as
/// a mistake.
#let type-stack = block(width: panel.width, height: flow-h, align(horizon + left, {
let first = true
for e in flow {
if not first { v(gap) }
first = false
let st = e.style
// One line per block, and the whole reason `fit` exists: a long Italian title
// narrows on the wdth axis before it is allowed to shrink, so it keeps filling its
// band. "Sagra della Castagna e dell'Autunno in Piazza" lands on three lines at
// ig-story and stays a headline instead of becoming a caption.
block(width: panel.width, fit(e.body, panel.width, budget(e), st.family, st.axes, ..st.args))
}
}))
/// The colophon, pinned to the foot of the panel and pushed clear of the logo when they
/// share that edge. It is aligned AWAY from the logo, so the two read as one line with a
/// hole in the middle rather than as a collision.
#let foot-line = if foot-body == none { none } else {
let reserve = if logo-on-panel-bottom { logo-w + gap } else { 0pt }
let w = calc.max(1mm, panel.width - reserve)
let x = panel.x + (if logo-on-panel-bottom and logo-left { reserve } else { 0pt })
let a = if logo-on-panel-bottom and logo-left { right } else { left }
place(bottom + left, dx: x, dy: -sa.y, box(
width: w,
fit(foot-body, w, calc.max(foot-text-h, 1mm), foot-style.family, foot-style.axes,
align-to: a, ..foot-style.args),
))
}
// ---------------------------------------------------------------------------
// The page
// ---------------------------------------------------------------------------
//
// `width`/`height` are the TRIM and `bleed` is separate, which is what makes Typst write
// a real PDF TrimBox for the copy shop. Everything is drawn in `background`/`foreground`
// because those two resolve their offsets against the FULL page including bleed — the
// same origin `safe-area` reports — while the body would not. The body stays empty.
//
// PNG export renders the trim page, so the bleed art and the crop marks are a PDF-only
// concern; the composition is identical either way because the seam is measured off the
// trim, not off the page.
#set page(
width: sa.trim-width,
height: sa.trim-height,
margin: 0pt,
bleed: sa.bleed,
fill: pal.bg,
background: {
set text(lang: "it")
set par(linebreaks: "optimized")
artwork
art-scrim
seam-rule
place(top + left, dx: panel.x, dy: panel.y + head-h, type-stack)
foot-line
},
foreground: {
crop-marks(spec)
logo-place(spec)
},
)
+6 -1
View File
@@ -5,6 +5,11 @@
# --ignore-system-fonts and every template sets `#set document(date: none)`,
# output is byte-identical across runs and machines, so a diff is a real regression.
#
# GOTCHA: Typst resolves a leading "/" against --root, NOT the filesystem. So the spec
# path passed via --input must be ROOT-RELATIVE ("/tests/fixtures/x.json"); handing it an
# absolute filesystem path silently produces "<root>/home/you/...", i.e. file not found.
# extensions/imgen/render/typst.ts must obey the same rule.
#
# Usage: tests/render-fixtures.sh [path-to-typst]
set -uo pipefail
cd "$(dirname "$0")/.."
@@ -29,7 +34,7 @@ for tpl in "$ROOT"/templates/*.typ; do
fxname="$(basename "$fx" .json)"
out="$OUT/${name}__${fxname}.pdf"
if SOURCE_DATE_EPOCH=0 "$TYPST" compile "$tpl" "$out" \
--input specfile="$fx" \
--input specfile="/tests/fixtures/${fxname}.json" \
--font-path "$FONTS" --ignore-system-fonts \
--root "$ROOT" >"$OUT/${name}__${fxname}.log" 2>&1; then
pass=$((pass+1)); printf ' ok %s / %s\n' "$name" "$fxname"