Files
pi-imgen/templates/framed.typ
T

392 lines
16 KiB
Typst

// 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)