diff --git a/e2e/layout-parity.spec.ts-snapshots/editor-layout-parity.png b/e2e/layout-parity.spec.ts-snapshots/editor-layout-parity.png
index b89b356..51190b3 100644
Binary files a/e2e/layout-parity.spec.ts-snapshots/editor-layout-parity.png and b/e2e/layout-parity.spec.ts-snapshots/editor-layout-parity.png differ
diff --git a/e2e/layout-parity.spec.ts-snapshots/preview-layout-parity.png b/e2e/layout-parity.spec.ts-snapshots/preview-layout-parity.png
index 77714b3..369cba6 100644
Binary files a/e2e/layout-parity.spec.ts-snapshots/preview-layout-parity.png and b/e2e/layout-parity.spec.ts-snapshots/preview-layout-parity.png differ
diff --git a/src/components/layout-page.test.tsx b/src/components/layout-page.test.tsx
index 30e8290..b6529b0 100644
--- a/src/components/layout-page.test.tsx
+++ b/src/components/layout-page.test.tsx
@@ -342,7 +342,7 @@ describe("photo distribution in the preview", () => {
expect(markup).toContain("object-position:25% 10%")
})
- it("leaves a frame empty once the uploaded photos run out", () => {
+ it("fills a frame with placeholder art once the uploaded photos run out", () => {
let schema = addElement(emptyLayoutSchema(), "image-frame", "photos")
schema = addElement(schema, "image-frame", "photos")
schema = addElement(schema, "image-frame", "photos")
@@ -355,6 +355,32 @@ describe("photo distribution in the preview", () => {
)
expect(markup.match(/
{
+ const schema = addElement(emptyLayoutSchema(), "gallery-frame", "photos")
+ const gallery = schema.elements[0]!
+ if (gallery.type !== "gallery-frame") throw new Error("Expected a gallery frame")
+ gallery.arrangement = "four-square"
+ gallery.geometry = { x: 10, y: 10, width: 80, height: 80, rotation: 0 }
+
+ const markup = renderToStaticMarkup(
+
+ )
+
+ const motifs = [...markup.matchAll(/data-filler-motif="([a-z-]+)"/g)].map((match) => match[1])
+ expect(motifs).toHaveLength(2)
+ expect(new Set(motifs).size).toBe(2)
+ })
+
+ it("keeps empty frames plainly empty while a layout is still being authored", () => {
+ const schema = addElement(emptyLayoutSchema(), "image-frame", "photos")
+
+ const markup = renderToStaticMarkup()
+
expect(markup).toContain("border-dashed")
+ expect(markup).not.toContain("data-filler-motif")
})
})
diff --git a/src/components/layout-page.tsx b/src/components/layout-page.tsx
index 2346378..edd94d7 100644
--- a/src/components/layout-page.tsx
+++ b/src/components/layout-page.tsx
@@ -1,5 +1,12 @@
import { useMemo } from "react"
+import {
+ fillerMotif,
+ fillerPalette,
+ fillerSeed,
+ MOTIF_VIEWBOX,
+ type FillerPalette,
+} from "#/domain/filler-art.ts"
import { gallerySlots } from "#/domain/layout.ts"
import {
pageSpecification,
@@ -176,10 +183,51 @@ export function textElementVerticalOffsetMm(
return layoutText(runs, target.geometry.width, target.geometry.height, target.text).offsetYMm
}
+/**
+ * Stands in for a photo the contributor did not upload. Only rendered once a response is being
+ * previewed: while a layout is being authored every frame is empty, so art there would say
+ * nothing about the finished page.
+ *
+ * `xMidYMid meet` centres the square motif on the slot's shorter side without stretching it,
+ * which is the fit the PDF exporter reproduces with `motifPlacement`.
+ */
+function FillerArt({
+ seed,
+ slotIndex,
+ palette,
+}: {
+ seed: string
+ slotIndex: number
+ palette: FillerPalette
+}) {
+ const motif = fillerMotif(seed, slotIndex)
+ return (
+
+ )
+}
+
function ElementContent({
element,
content,
photoAssignment,
+ palette,
showEditorPlaceholders,
editingElementId,
editingText,
@@ -189,6 +237,7 @@ function ElementContent({
element: LayoutElement
content: LayoutPageContent
photoAssignment: PhotoAssignment
+ palette: FillerPalette
showEditorPlaceholders: boolean
editingElementId?: string
editingText?: string
@@ -383,6 +432,12 @@ function ElementContent({
content={content}
borderRadius={millimetresToContainerWidth(element.cornerRadius, specification)}
/>
+ ) : content.submission ? (
+
) : (
assignPhotosToFrames(schema.elements, content.submission?.answers ?? {}),
[schema.elements, content.submission]
)
+ const palette = useMemo(() => fillerPalette(schema), [schema])
return (
({
+ id: `shape-${index}`,
+ type: "rectangle",
+ geometry: { x: 0, y: index * 10, width: 40, height: 10, rotation: 0 },
+ opacity: 1,
+ fill,
+ stroke: "transparent",
+ strokeWidth: 0,
+ }))
+ return { ...emptyLayoutSchema(), background, elements }
+}
+
+describe("filler art palette", () => {
+ it("draws its accents from the colours the layout itself uses", () => {
+ const palette = fillerPalette(schemaWithShapes("#fbf3e7", ["#5b927b", "#b45f52", "#27485b"]))
+
+ expect([palette.primary, palette.secondary, palette.ink].sort()).toEqual(
+ ["#27485b", "#5b927b", "#b45f52"].sort()
+ )
+ })
+
+ it("falls back to the house palette when a layout has no shapes to borrow from", () => {
+ const palette = fillerPalette(emptyLayoutSchema())
+
+ expect(palette.ink).toBe("#27485b")
+ expect(palette.primary).not.toBe(palette.secondary)
+ })
+
+ it("reads a line's colour from its stroke, which is all a line paints", () => {
+ const schema = emptyLayoutSchema()
+ schema.elements = [
+ {
+ id: "rule",
+ type: "line",
+ geometry: { x: 0, y: 0, width: 60, height: 1, rotation: 0 },
+ opacity: 1,
+ fill: "transparent",
+ stroke: "#7a3b8f",
+ strokeWidth: 1.2,
+ },
+ ]
+
+ expect([fillerPalette(schema).primary, fillerPalette(schema).secondary]).toContain("#7a3b8f")
+ })
+
+ it("ignores colours on an element the layout does not actually paint", () => {
+ const schema = emptyLayoutSchema()
+ schema.elements = [
+ {
+ id: "hidden",
+ type: "rectangle",
+ geometry: { x: 0, y: 0, width: 90, height: 90, rotation: 0 },
+ opacity: 0,
+ fill: "#7a3b8f",
+ stroke: "#7a3b8f",
+ strokeWidth: 2,
+ },
+ ]
+ const palette = fillerPalette(schema)
+
+ expect([palette.primary, palette.secondary, palette.ink]).not.toContain("#7a3b8f")
+ })
+
+ it("skips shape colours that would be invisible against the page background", () => {
+ const palette = fillerPalette(schemaWithShapes("#fbf3e7", ["#faf2e6", "#b45f52"]))
+
+ expect([palette.primary, palette.secondary, palette.ink]).not.toContain("#faf2e6")
+ expect([palette.primary, palette.secondary, palette.ink]).toContain("#b45f52")
+ })
+
+ it("inks with a light tone on a dark page and a dark tone on a pale one", () => {
+ const pale = fillerPalette(schemaWithShapes("#fffdf7", ["#27485b", "#f0c66f", "#b45f52"]))
+ const dark = fillerPalette(schemaWithShapes("#101014", ["#27485b", "#f0c66f", "#b45f52"]))
+
+ expect(pale.ink).toBe("#27485b")
+ expect(dark.ink).toBe("#f0c66f")
+ })
+
+ it("keeps every tone clear of the page it will be drawn on", () => {
+ const preset = backgroundPresets("a5", "landscape").find(
+ (candidate) => candidate.id === "geometric-collage"
+ )!
+ const palette = fillerPalette(preset.schema)
+
+ expect([palette.primary, palette.secondary, palette.ink]).not.toContain(
+ preset.schema.background
+ )
+ })
+})
+
+describe("filler motif selection", () => {
+ const seed = fillerSeed("11111111-1111-4111-8111-111111111111", "gallery")
+
+ it("picks the same motif for a slot every time, so regenerating a book keeps its art", () => {
+ expect(fillerMotif(seed, 2).id).toBe(fillerMotif(seed, 2).id)
+ })
+
+ it("never repeats a motif between the slots of one frame", () => {
+ for (const element of ["frame-a", "frame-b", "frame-c", "gallery"]) {
+ const frameSeed = fillerSeed("11111111-1111-4111-8111-111111111111", element)
+ const chosen = [0, 1, 2, 3].map((slot) => fillerMotif(frameSeed, slot).id)
+ expect(new Set(chosen).size).toBe(4)
+ }
+ })
+
+ it("spreads motifs across frames and responses rather than reusing one", () => {
+ const ids = new Set(
+ ["a", "b", "c", "d", "e", "f"].flatMap((submission) =>
+ ["one", "two"].map((element) => fillerMotif(fillerSeed(submission, element), 0).id)
+ )
+ )
+
+ expect(ids.size).toBeGreaterThan(3)
+ })
+})
+
+describe("motif drawing data", () => {
+ it("keeps every motif inside the square both renderers scale", () => {
+ for (const motif of FILLER_MOTIFS) {
+ const coordinates = motif.shapes.flatMap((shape) =>
+ (shape.d.match(/-?\d+(?:\.\d+)?/g) ?? []).map(Number)
+ )
+ expect(Math.min(...coordinates), motif.id).toBeGreaterThanOrEqual(0)
+ expect(Math.max(...coordinates), motif.id).toBeLessThanOrEqual(MOTIF_VIEWBOX)
+ }
+ })
+
+ it("centres the motif on a slot's shorter side instead of stretching it", () => {
+ const wide = motifPlacement(200, 100)
+ expect(wide.scale).toBe(1)
+ expect(wide.offsetX).toBe(50)
+ expect(wide.offsetY).toBe(0)
+
+ const tall = motifPlacement(50, 150)
+ expect(tall.scale).toBe(0.5)
+ expect(tall.offsetX).toBe(0)
+ expect(tall.offsetY).toBe(50)
+ })
+})
diff --git a/src/domain/filler-art.ts b/src/domain/filler-art.ts
new file mode 100644
index 0000000..65fcb82
--- /dev/null
+++ b/src/domain/filler-art.ts
@@ -0,0 +1,410 @@
+import { type LayoutElement, type LayoutSchema } from "./types.ts"
+
+/**
+ * Placeholder art for photo slots a contributor left unfilled. A layout with five frames and
+ * three uploaded photos would otherwise print two blank rectangles, so the remaining slots get a
+ * flat vector motif instead. The motif is drawn straight onto the page with no panel behind it,
+ * so an unfilled slot reads as part of the page rather than as an empty card on top of it.
+ *
+ * Everything here is authored as SVG path data in one square coordinate system, which both the
+ * HTML preview and the `pdf-lib` exporter draw directly. Staying vector keeps filler art out of
+ * the effective-PPI preflight rules that apply to embedded photos, and keeps preview and print
+ * identical without a second drawing implementation.
+ */
+
+/** Motifs are authored inside this square and scaled to a slot's shorter side. */
+export const MOTIF_VIEWBOX = 100
+
+export type FillerTone = "primary" | "secondary" | "ink"
+
+export interface FillerShape {
+ d: string
+ tone: FillerTone
+ /** Stroke the path at this width in motif units instead of filling it. */
+ strokeWidth?: number
+}
+
+export interface FillerMotif {
+ id: string
+ family: "botanical" | "geometric" | "textural"
+ shapes: FillerShape[]
+}
+
+export interface FillerPalette {
+ primary: string
+ secondary: string
+ ink: string
+}
+
+const KAPPA = 0.5522847498307936
+
+function round(value: number): number {
+ return Number(value.toFixed(2))
+}
+
+function radians(degrees: number): number {
+ return (degrees * Math.PI) / 180
+}
+
+/** Rotates a point around a centre, so every motif helper can share one rotation convention. */
+function rotator(cx: number, cy: number, angleDegrees: number) {
+ const cos = Math.cos(radians(angleDegrees))
+ const sin = Math.sin(radians(angleDegrees))
+ return (x: number, y: number) =>
+ `${round(cx + x * cos - y * sin)} ${round(cy + x * sin + y * cos)}`
+}
+
+/**
+ * Ellipse as four cubic curves. Arcs would be shorter, but curves avoid depending on the `A`
+ * command's parameterisation matching between the browser and pdf-lib's path parser.
+ */
+function ellipse(cx: number, cy: number, rx: number, ry: number, angleDegrees = 0): string {
+ const at = rotator(cx, cy, angleDegrees)
+ const ox = rx * KAPPA
+ const oy = ry * KAPPA
+ return [
+ `M ${at(-rx, 0)}`,
+ `C ${at(-rx, -oy)} ${at(-ox, -ry)} ${at(0, -ry)}`,
+ `C ${at(ox, -ry)} ${at(rx, -oy)} ${at(rx, 0)}`,
+ `C ${at(rx, oy)} ${at(ox, ry)} ${at(0, ry)}`,
+ `C ${at(-ox, ry)} ${at(-rx, oy)} ${at(-rx, 0)}`,
+ "Z",
+ ].join(" ")
+}
+
+function circle(cx: number, cy: number, r: number): string {
+ return ellipse(cx, cy, r, r)
+}
+
+function rectangle(x: number, y: number, width: number, height: number): string {
+ return `M ${x} ${y} L ${round(x + width)} ${y} L ${round(x + width)} ${round(y + height)} L ${x} ${round(y + height)} Z`
+}
+
+/** Rectangle rotated around its own centre, used for the scattered confetti bars. */
+function bar(cx: number, cy: number, length: number, thickness: number, angleDegrees: number) {
+ const at = rotator(cx, cy, angleDegrees)
+ const halfLength = length / 2
+ const halfThickness = thickness / 2
+ return `M ${at(-halfLength, -halfThickness)} L ${at(halfLength, -halfThickness)} L ${at(halfLength, halfThickness)} L ${at(-halfLength, halfThickness)} Z`
+}
+
+/** Rectangle with a semicircular top. */
+function arch(x: number, y: number, width: number, height: number): string {
+ const r = width / 2
+ const offset = r * KAPPA
+ const springing = y + r
+ return [
+ `M ${x} ${round(y + height)}`,
+ `L ${x} ${round(springing)}`,
+ `C ${x} ${round(springing - offset)} ${round(x + r - offset)} ${y} ${round(x + r)} ${y}`,
+ `C ${round(x + r + offset)} ${y} ${round(x + width)} ${round(springing - offset)} ${round(x + width)} ${round(springing)}`,
+ `L ${round(x + width)} ${round(y + height)}`,
+ "Z",
+ ].join(" ")
+}
+
+/** Pie slice spanning ninety degrees clockwise from `startAngleDegrees`. */
+function quarterDisc(cx: number, cy: number, r: number, startAngleDegrees: number): string {
+ const start = radians(startAngleDegrees)
+ const end = radians(startAngleDegrees + 90)
+ const startX = cx + r * Math.cos(start)
+ const startY = cy + r * Math.sin(start)
+ const endX = cx + r * Math.cos(end)
+ const endY = cy + r * Math.sin(end)
+ const controlOne = `${round(startX - r * KAPPA * Math.sin(start))} ${round(startY + r * KAPPA * Math.cos(start))}`
+ const controlTwo = `${round(endX + r * KAPPA * Math.sin(end))} ${round(endY - r * KAPPA * Math.cos(end))}`
+ return `M ${cx} ${cy} L ${round(startX)} ${round(startY)} C ${controlOne} ${controlTwo} ${round(endX)} ${round(endY)} Z`
+}
+
+/** Pointed leaf drawn as two mirrored quadratic curves between its base and its tip. */
+function leaf(baseX: number, baseY: number, tipX: number, tipY: number, bulge: number): string {
+ const midX = (baseX + tipX) / 2
+ const midY = (baseY + tipY) / 2
+ const length = Math.hypot(tipX - baseX, tipY - baseY) || 1
+ const normalX = ((baseY - tipY) / length) * bulge
+ const normalY = ((tipX - baseX) / length) * bulge
+ return [
+ `M ${baseX} ${baseY}`,
+ `Q ${round(midX + normalX)} ${round(midY + normalY)} ${tipX} ${tipY}`,
+ `Q ${round(midX - normalX)} ${round(midY - normalY)} ${baseX} ${baseY}`,
+ "Z",
+ ].join(" ")
+}
+
+function bloomPetals(cx: number, cy: number, count: number, distance: number): FillerShape[] {
+ return Array.from({ length: count }, (_, index) => {
+ const angle = (360 / count) * index - 90
+ return {
+ d: ellipse(
+ cx + Math.cos(radians(angle)) * distance,
+ cy + Math.sin(radians(angle)) * distance,
+ 6.5,
+ 12,
+ angle + 90
+ ),
+ tone: "primary" as const,
+ }
+ })
+}
+
+function dotGrid(): FillerShape[] {
+ const positions = [22, 41, 60, 79]
+ return positions.flatMap((y, row) =>
+ positions.map((x, column) => ({
+ d: circle(x, y, 5),
+ tone: ((row + column) % 2 === 0 ? "primary" : "secondary") as FillerTone,
+ }))
+ )
+}
+
+function waveBand(y: number, tone: FillerTone): FillerShape {
+ return {
+ d: `M 12 ${y} Q 26 ${y - 9} 40 ${y} Q 54 ${y + 9} 68 ${y} Q 78 ${y - 6} 88 ${y}`,
+ tone,
+ strokeWidth: 4,
+ }
+}
+
+/**
+ * Nine motifs across three families. The count is deliberately coprime with the strides in
+ * `fillerMotif`, which is what lets every slot in one frame land on a different motif.
+ */
+export const FILLER_MOTIFS: FillerMotif[] = [
+ {
+ id: "single-bloom",
+ family: "botanical",
+ shapes: [
+ { d: rectangle(48.5, 46, 3, 40), tone: "ink" },
+ ...bloomPetals(50, 38, 6, 13),
+ { d: circle(50, 38, 7.5), tone: "secondary" },
+ ],
+ },
+ {
+ id: "leaf-pair",
+ family: "botanical",
+ shapes: [
+ { d: rectangle(48.5, 18, 3, 68), tone: "ink" },
+ { d: leaf(50, 64, 22, 42, 16), tone: "primary" },
+ { d: leaf(50, 46, 78, 26, 16), tone: "secondary" },
+ ],
+ },
+ {
+ id: "berry-branch",
+ family: "botanical",
+ shapes: [
+ // The berries sit on sampled points of the branch curve, so they read as growing from it.
+ { d: "M 22 82 Q 42 66 50 40 Q 56 22 70 18", tone: "ink", strokeWidth: 3 },
+ { d: circle(70, 18, 7), tone: "primary" },
+ { d: circle(40.4, 61.4, 5.5), tone: "secondary" },
+ { d: circle(31.25, 73.35, 4.5), tone: "ink" },
+ ],
+ },
+ {
+ id: "arch-stack",
+ family: "geometric",
+ shapes: [
+ { d: arch(18, 20, 26, 58), tone: "primary" },
+ { d: arch(50, 32, 22, 46), tone: "secondary" },
+ { d: rectangle(14, 82, 72, 4), tone: "ink" },
+ ],
+ },
+ {
+ id: "circle-cluster",
+ family: "geometric",
+ shapes: [
+ { d: circle(38, 42, 22), tone: "primary" },
+ { d: circle(65, 55, 16), tone: "secondary" },
+ { d: circle(45, 71, 9), tone: "ink" },
+ ],
+ },
+ {
+ id: "nested-arcs",
+ family: "geometric",
+ shapes: [
+ { d: quarterDisc(16, 84, 68, 270), tone: "primary" },
+ { d: quarterDisc(16, 84, 42, 270), tone: "secondary" },
+ { d: quarterDisc(16, 84, 18, 270), tone: "ink" },
+ ],
+ },
+ {
+ id: "dot-grid",
+ family: "textural",
+ shapes: dotGrid(),
+ },
+ {
+ id: "wave-bands",
+ family: "textural",
+ shapes: [waveBand(32, "primary"), waveBand(50, "ink"), waveBand(68, "secondary")],
+ },
+ {
+ id: "confetti-scatter",
+ family: "textural",
+ shapes: [
+ { d: circle(26, 30, 6), tone: "primary" },
+ { d: bar(62, 26, 18, 5, -25), tone: "ink" },
+ { d: circle(72, 52, 4.5), tone: "secondary" },
+ { d: bar(34, 58, 16, 5, 35), tone: "secondary" },
+ { d: circle(52, 74, 7), tone: "primary" },
+ { d: bar(74, 78, 14, 5, -10), tone: "ink" },
+ ],
+ },
+]
+
+/**
+ * Fallback accents for layouts that carry no shapes of their own, matching the colours the
+ * background presets already use. The dark tone leads so a blank layout still gets an ink.
+ */
+const HOUSE_ACCENTS = ["#27485b", "#5b927b", "#b45f52", "#f0c66f"]
+
+interface Channels {
+ r: number
+ g: number
+ b: number
+}
+
+function parseHex(value: string): Channels | null {
+ const hex = /^#([0-9a-f]{6})$/i.exec(value.trim())?.[1]
+ if (!hex) return null
+ return {
+ r: Number.parseInt(hex.slice(0, 2), 16),
+ g: Number.parseInt(hex.slice(2, 4), 16),
+ b: Number.parseInt(hex.slice(4, 6), 16),
+ }
+}
+
+function toHex({ r, g, b }: Channels): string {
+ return `#${[r, g, b].map((channel) => Math.round(channel).toString(16).padStart(2, "0")).join("")}`
+}
+
+function distance(left: Channels, right: Channels): number {
+ return Math.hypot(left.r - right.r, left.g - right.g, left.b - right.b)
+}
+
+function luminance({ r, g, b }: Channels): number {
+ return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255
+}
+
+/** Below this an accent reads as the page background rather than as art on top of it. */
+const MINIMUM_ACCENT_CONTRAST = 45
+
+function isShape(element: LayoutElement): element is Extract {
+ return element.type === "rectangle" || element.type === "circle" || element.type === "line"
+}
+
+/**
+ * The colour a shape contributes to the palette. A line has no interior, so it reads through its
+ * stroke; everything else reads through its fill. Outlines are deliberately not collected: accents
+ * rank by contrast against the page, so a dark hairline border would outrank the panel it edges.
+ * A fully transparent element paints nothing and contributes nothing.
+ */
+function paintedColor(element: Extract): string | undefined {
+ if (element.opacity === 0) return undefined
+ if (element.type !== "line") return element.fill
+ return element.strokeWidth > 0 ? element.stroke : undefined
+}
+
+/**
+ * Colours the filler art from the layout itself rather than from a fixed theme, so art on a sage
+ * and terracotta page does not arrive in someone else's palette. Background presets are copied
+ * into a layout's elements when applied and their preset id is not stored, so the accents are
+ * read back off the shapes the layout actually contains. This also covers hand-built layouts and
+ * presets the organizer has since recoloured.
+ *
+ * Accents are ordered by how far they sit from the page background, so the most legible colour
+ * leads rather than whichever panel happens to be largest.
+ */
+export function fillerPalette(schema: LayoutSchema): FillerPalette {
+ const background = parseHex(schema.background) ?? { r: 255, g: 255, b: 255 }
+ const candidates = schema.elements
+ .filter(isShape)
+ .flatMap((element) => {
+ const painted = paintedColor(element)
+ const channels = painted ? parseHex(painted) : null
+ return channels
+ ? [{ channels, area: element.geometry.width * element.geometry.height, id: element.id }]
+ : []
+ })
+ .sort(
+ (left, right) =>
+ distance(right.channels, background) - distance(left.channels, background) ||
+ right.area - left.area ||
+ (left.id < right.id ? -1 : 1)
+ )
+
+ const accents: Channels[] = []
+ const push = (channels: Channels) => {
+ if (distance(channels, background) < MINIMUM_ACCENT_CONTRAST) return
+ if (accents.some((accent) => distance(accent, channels) < 24)) return
+ accents.push(channels)
+ }
+ for (const candidate of candidates) push(candidate.channels)
+ for (const accent of HOUSE_ACCENTS) {
+ if (accents.length >= 3) break
+ const channels = parseHex(accent)
+ if (channels) push(channels)
+ }
+ // A page coloured close to every house accent rejects them all; fall back to a paper tone.
+ while (accents.length < 3) accents.push({ r: 245, g: 240, b: 232 })
+ accents.sort((left, right) => distance(right, background) - distance(left, background))
+
+ // One tone carries the line work: the accent furthest from the page in lightness, which is the
+ // darkest on paper tones and the lightest on a dark page. The two most legible of the rest fill.
+ const backgroundLuminance = luminance(background)
+ const ink = accents.reduce((left, right) =>
+ Math.abs(luminance(left) - backgroundLuminance) >=
+ Math.abs(luminance(right) - backgroundLuminance)
+ ? left
+ : right
+ )
+ const [primary, secondary] = accents.filter((accent) => accent !== ink)
+
+ return {
+ primary: toHex(primary!),
+ secondary: toHex(secondary!),
+ ink: toHex(ink),
+ }
+}
+
+function hash(value: string): number {
+ let result = 2166136261
+ for (let index = 0; index < value.length; index += 1) {
+ result ^= value.charCodeAt(index)
+ result = Math.imul(result, 16777619)
+ }
+ return result >>> 0
+}
+
+/** Coprime with the motif count, so stepping by one of these visits every motif before repeating. */
+const STRIDES = [1, 2, 4, 5, 7, 8]
+
+/**
+ * Picks a motif for one empty slot. The seed is derived from the submission and the frame rather
+ * than from anything generated per export, so regenerating a book never reshuffles the art, and
+ * the stride guarantees that neighbouring slots inside one frame never show the same motif.
+ */
+export function fillerMotif(seed: string, slotIndex: number): FillerMotif {
+ const seeded = hash(seed)
+ const stride = STRIDES[(seeded >>> 8) % STRIDES.length]!
+ const index = (seeded + slotIndex * stride) % FILLER_MOTIFS.length
+ return FILLER_MOTIFS[index]!
+}
+
+/** Seed for every slot of one photo frame on one response's page. */
+export function fillerSeed(submissionId: string, elementId: string): string {
+ return `${submissionId}:${elementId}`
+}
+
+/**
+ * Where a motif's square sits inside a slot of arbitrary shape: centred on the shorter side, so
+ * the art never stretches. This is the same fit the preview gets from the SVG viewBox attribute
+ * `preserveAspectRatio="xMidYMid meet"`; the exporter has to compute it.
+ */
+export function motifPlacement(width: number, height: number) {
+ const size = Math.min(width, height)
+ return {
+ size,
+ scale: size / MOTIF_VIEWBOX,
+ offsetX: (width - size) / 2,
+ offsetY: (height - size) / 2,
+ }
+}
diff --git a/src/server/pdf-renderer.test.ts b/src/server/pdf-renderer.test.ts
index 3342a2d..d16b8e7 100644
--- a/src/server/pdf-renderer.test.ts
+++ b/src/server/pdf-renderer.test.ts
@@ -1,9 +1,33 @@
+import { PDFArray, PDFDocument, PDFName, PDFRawStream, decodePDFRawStream } from "pdf-lib"
import { describe, expect, it } from "vitest"
import { fitSingleLineTextSize, inspectPdf, renderBookPdf } from "./pdf-renderer.ts"
+import { fillerPalette } from "../domain/filler-art.ts"
import { pageSpecification } from "../domain/page-format.ts"
import { completeForm, cycleSettings, layoutFixture, submissionFixture } from "../test/fixtures.ts"
+/** The drawing operators of one page, so a test can assert what the exporter actually painted. */
+async function pageOperators(bytes: Uint8Array, index: number): Promise {
+ const document = await PDFDocument.load(bytes)
+ const contents = document.context.lookup(document.getPage(index).node.get(PDFName.of("Contents")))
+ const streams =
+ contents instanceof PDFArray
+ ? contents.asArray().map((reference) => document.context.lookup(reference))
+ : [contents]
+ return streams
+ .filter((stream): stream is PDFRawStream => stream instanceof PDFRawStream)
+ .map((stream) => Buffer.from(decodePDFRawStream(stream).decode()).toString("latin1"))
+ .join("\n")
+}
+
+/** Matches a hex colour used as either a fill or a stroke, at the precision pdf-lib writes. */
+function colorOperator(hex: string): RegExp {
+ const channels = [1, 3, 5].map(
+ (offset) => Number.parseInt(hex.slice(offset, offset + 2), 16) / 255
+ )
+ return new RegExp(`${channels.join(" ")} (rg|RG)\n`)
+}
+
describe("PDF renderer", () => {
it("fits standalone titles to the available page width", () => {
expect(fitSingleLineTextSize(30, 200, 100)).toBe(15)
@@ -98,6 +122,43 @@ describe("PDF renderer", () => {
expect([...embedded].every((name) => name.includes("Caveat"))).toBe(true)
})
+ it("draws vector placeholder art in photo slots the contributor left empty", async () => {
+ const layout = layoutFixture()
+ const submission = submissionFixture("10000000-0000-4000-8000-000000000002", 1)
+ const bytes = await renderBookPdf({
+ book: {
+ projectId: layout.projectId,
+ settings: cycleSettings,
+ pages: [
+ {
+ id: `submission:${submission.id}`,
+ kind: "submission" as const,
+ submissionId: submission.id,
+ layoutId: layout.id,
+ problems: [],
+ },
+ ],
+ sourceFingerprint: "filler-test",
+ generatedAt: "2026-07-18T00:00:00.000Z",
+ updatedAt: "2026-07-18T00:00:00.000Z",
+ },
+ layouts: [layout],
+ submissions: [submission],
+ form: completeForm,
+ marks: false,
+ })
+
+ const operators = await pageOperators(bytes, 0)
+ const palette = fillerPalette(layout.schema)
+ const tones = [palette.primary, palette.secondary, palette.ink]
+ expect(tones.some((tone) => colorOperator(tone).test(operators))).toBe(true)
+ // Path construction rather than an embedded raster placeholder, which is what keeps filler
+ // art out of the effective-PPI checks that apply to real photos.
+ expect(operators).toMatch(/ m\n/)
+ expect(operators).toMatch(/ (c|v|y)\n/)
+ expect(await inspectPdf(bytes)).toMatchObject({ assetResolutionCount: 0 })
+ })
+
it("emits portrait pages with format-specific media and trim boxes", async () => {
const pages = [
{
diff --git a/src/server/pdf-renderer.ts b/src/server/pdf-renderer.ts
index cdd64bb..362c262 100644
--- a/src/server/pdf-renderer.ts
+++ b/src/server/pdf-renderer.ts
@@ -6,6 +6,7 @@ import {
clip,
degrees,
endPath,
+ LineCapStyle,
PDFArray,
PDFDocument,
PDFDict,
@@ -21,6 +22,13 @@ import {
type PDFPage,
} from "pdf-lib"
+import {
+ fillerMotif,
+ fillerPalette,
+ fillerSeed,
+ motifPlacement,
+ type FillerPalette,
+} from "../domain/filler-art.ts"
import { effectivePpi } from "../domain/generation"
import { FONT_CUT_FILES, fontCut, type FontCut } from "../domain/fonts.ts"
import { gallerySlots, PAGE_SPEC } from "../domain/layout"
@@ -137,6 +145,48 @@ function drawCroppedImage(
page.pushOperators(popGraphicsState())
}
+/**
+ * Draws the placeholder motif for a photo slot the contributor left unfilled. Nothing is painted
+ * behind it, so the slot keeps whatever the page already puts there. The art is vector, so unlike
+ * an embedded photo it carries no resolution and never reaches the preflight PPI rules.
+ *
+ * `drawSvgPath` translates to the given point and then flips the Y axis, so the anchor is the top
+ * edge of the motif square in page space.
+ */
+function drawFillerArt(input: {
+ page: PDFPage
+ geometry: { x: number; y: number; width: number; height: number }
+ specification: PageSpecification
+ palette: FillerPalette
+ seed: string
+ slotIndex: number
+ opacity: number
+}) {
+ const x = pt(input.specification.bleedMm + input.geometry.x)
+ const y = pdfY(input.geometry.y, input.geometry.height, input.specification)
+ const width = pt(input.geometry.width)
+ const height = pt(input.geometry.height)
+ const placement = motifPlacement(width, height)
+ const left = x + placement.offsetX
+ const top = y + height - placement.offsetY
+ for (const shape of fillerMotif(input.seed, input.slotIndex).shapes) {
+ const tone = color(input.palette[shape.tone])
+ input.page.drawSvgPath(shape.d, {
+ x: left,
+ y: top,
+ scale: placement.scale,
+ ...(shape.strokeWidth
+ ? {
+ borderColor: tone,
+ borderWidth: shape.strokeWidth,
+ borderLineCap: LineCapStyle.Round,
+ borderOpacity: input.opacity,
+ }
+ : { color: tone, opacity: input.opacity }),
+ })
+ }
+}
+
function wrapText(text: string, font: PDFFont, size: number, width: number) {
const lines: string[] = []
for (const explicitLine of text.replace(/\r\n/g, "\n").split("\n")) {
@@ -207,6 +257,7 @@ async function drawElement(input: {
photoAssignment: PhotoAssignment
form: FormSchema
fonts: EmbeddedFonts
+ fillerPalette: FillerPalette
assetResolutions: AssetResolutionMetadata[]
specification: PageSpecification
}) {
@@ -294,9 +345,21 @@ async function drawElement(input: {
}
const images = framePhotos(input.photoAssignment, element.id)
+ const seed = fillerSeed(input.submission.id, element.id)
if (element.type === "image-frame") {
const image = images[0]
- if (!image) return
+ if (!image) {
+ drawFillerArt({
+ page,
+ geometry,
+ specification: input.specification,
+ palette: input.fillerPalette,
+ seed,
+ slotIndex: 0,
+ opacity: element.opacity,
+ })
+ return
+ }
const embeddedImage = await embedImage(input.pdf, image.assetId)
input.assetResolutions.push({
assetId: image.assetId,
@@ -326,7 +389,24 @@ async function drawElement(input: {
await Promise.all(
slots.map(async (slot, index) => {
const image = images[index]
- if (!image) return
+ const slotGeometry = {
+ x: geometry.x + slot.x,
+ y: geometry.y + slot.y,
+ width: slot.width,
+ height: slot.height,
+ }
+ if (!image) {
+ drawFillerArt({
+ page,
+ geometry: slotGeometry,
+ specification: input.specification,
+ palette: input.fillerPalette,
+ seed,
+ slotIndex: index,
+ opacity: element.opacity,
+ })
+ return
+ }
const embeddedImage = await embedImage(input.pdf, image.assetId)
input.assetResolutions.push({
assetId: image.assetId,
@@ -346,12 +426,7 @@ async function drawElement(input: {
drawCroppedImage(
page,
embeddedImage,
- {
- x: geometry.x + slot.x,
- y: geometry.y + slot.y,
- width: slot.width,
- height: slot.height,
- },
+ slotGeometry,
input.specification,
effectiveFocalPoint(element, image)
)
@@ -605,6 +680,7 @@ export async function renderBookPdf(input: {
color: color(layout.schema.background),
})
const photoAssignment = assignPhotosToFrames(layout.schema.elements, submission.answers)
+ const palette = fillerPalette(layout.schema)
for (const element of layout.schema.elements) {
await drawElement({
pdf,
@@ -615,6 +691,7 @@ export async function renderBookPdf(input: {
photoAssignment,
form: input.form,
fonts,
+ fillerPalette: palette,
assetResolutions,
specification,
})
diff --git a/visual-artifacts/issues/83/after-exported-pdf.png b/visual-artifacts/issues/83/after-exported-pdf.png
new file mode 100644
index 0000000..ee84767
Binary files /dev/null and b/visual-artifacts/issues/83/after-exported-pdf.png differ
diff --git a/visual-artifacts/issues/83/after-filler-art.png b/visual-artifacts/issues/83/after-filler-art.png
new file mode 100644
index 0000000..a3117af
Binary files /dev/null and b/visual-artifacts/issues/83/after-filler-art.png differ
diff --git a/visual-artifacts/issues/83/before-empty-photo-slots.png b/visual-artifacts/issues/83/before-empty-photo-slots.png
new file mode 100644
index 0000000..72d1291
Binary files /dev/null and b/visual-artifacts/issues/83/before-empty-photo-slots.png differ