tests: fix root-relative spec path; document ink-surface blocker
This commit is contained in:
@@ -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`.
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Executable
+634
@@ -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."
|
||||
@@ -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()
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user