392 lines
16 KiB
Typst
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)
|