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 ? ( + ) : (
)} @@ -416,6 +471,14 @@ function ElementContent({
+ ) : 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