diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 124a7c7..7486c1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,8 +28,10 @@ jobs: with: bun-version: 1.3.9 - run: bun install --frozen-lockfile - - run: docker compose up -d --wait + # Before compose: its bind mounts create .local/ as root, which would then + # keep the runner from writing the ICC profile into it. - run: bun run setup:icc + - run: docker compose up -d --wait - run: bun run db:migrate - run: bun run db:seed - run: bunx playwright install --with-deps chromium diff --git a/e2e/critical-workflows.spec.ts b/e2e/critical-workflows.spec.ts index 7608f65..4c0107e 100644 --- a/e2e/critical-workflows.spec.ts +++ b/e2e/critical-workflows.spec.ts @@ -395,6 +395,53 @@ test.describe.serial("critical local prototype workflows", () => { await expectAccessible(page) }) + test("layout editor flags photo slots that do not match the question", async ({ + page, + request, + }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto(`/projects/${closedProjectId}?tab=layouts`) + await expect(page.getByRole("heading", { name: "Page layouts" })).toBeVisible() + const originalProject = (await ( + await request.get(`/api/projects/${closedProjectId}`) + ).json()) as Project + const originalLayout = originalProject.layouts.find( + (layout) => layout.name === "Warm quote" + ) as LayoutRecord + + const photoPrompt = "Add one or two favourite photos" + const mismatch = page.getByText(/photo slots? for up to/) + await expect(mismatch).toHaveCount(0) + + try { + await page.getByRole("button", { name: `Add image for ${photoPrompt}` }).click() + await expect(mismatch).toHaveText("1 photo slot for up to 2 uploads") + + await page.getByRole("button", { name: `Add image for ${photoPrompt}` }).click() + await expect(mismatch).toHaveCount(0) + + await page.getByRole("button", { name: `Add gallery for ${photoPrompt}` }).click() + await expect(mismatch).toHaveText("6 photo slots for up to 2 uploads") + } finally { + const changedProject = (await ( + await request.get(`/api/projects/${closedProjectId}`) + ).json()) as Project + const changedLayout = changedProject.layouts.find( + (layout) => layout.id === originalLayout.id + ) as LayoutRecord + expect( + ( + await request.patch(`/api/projects/${closedProjectId}/layouts/${changedLayout.id}`, { + data: { + expectedRevision: changedLayout.revision, + schema: originalLayout.schema, + }, + }) + ).ok() + ).toBe(true) + } + }) + test("answer labels edit directly on the layout canvas", async ({ page, request }) => { test.setTimeout(60_000) await page.setViewportSize({ width: 1440, height: 900 }) 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 2e8ec33..b89b356 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 c10cef0..77714b3 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-editor.tsx b/src/components/layout-editor.tsx index b96e06e..c336018 100644 --- a/src/components/layout-editor.tsx +++ b/src/components/layout-editor.tsx @@ -23,6 +23,7 @@ import { SaveIcon, SendToBackIcon, Trash2Icon, + TriangleAlertIcon, TypeIcon, Undo2Icon, UnlockIcon, @@ -98,6 +99,7 @@ import { questionPrompt, } from "#/domain/layout-question-palette.ts" import { addElement, PAGE_SPEC } from "#/domain/layout.ts" +import { photoSlotMismatches } from "#/domain/photo-assignment.ts" import { enforceMinimumTextBoxHeight } from "#/domain/text-layout.ts" import { type FormQuestion, @@ -833,6 +835,12 @@ function Editor({ const selected = schema.elements.find((element) => element.id === selectedId) const questionPalette = layoutQuestionPalette(project.formSchema.questions) + const slotMismatches = new Map( + photoSlotMismatches(schema.elements, project.formSchema.questions).map((mismatch) => [ + mismatch.questionId, + mismatch, + ]) + ) const add = ( type: LayoutElement["type"], @@ -1040,34 +1048,46 @@ function Editor({
- {questionPalette.map((item) => ( -
- - {item.prompt} - -
- {item.actions.map((action) => ( - add(action.elementType, item.questionId)} - /> - ))} + {questionPalette.map((item) => { + const mismatch = slotMismatches.get(item.questionId) + return ( +
+ + + {item.prompt} + + {mismatch && ( + + + )} + +
+ {item.actions.map((action) => ( + add(action.elementType, item.questionId)} + /> + ))} +
-
- ))} + ) + })}
diff --git a/src/components/layout-page.test.tsx b/src/components/layout-page.test.tsx index a3b198c..b7233e0 100644 --- a/src/components/layout-page.test.tsx +++ b/src/components/layout-page.test.tsx @@ -2,6 +2,7 @@ import { renderToStaticMarkup } from "react-dom/server" import { describe, expect, it } from "vitest" import { addElement, emptyLayoutSchema } from "#/domain/layout.ts" +import { type ImageAnswer, type SubmissionSummary } from "#/domain/types.ts" import { LayoutPageElements } from "./layout-page.tsx" @@ -69,3 +70,55 @@ describe("selected element rendering", () => { expect(preview).toContain("outline:2px solid var(--destructive)") }) }) + +describe("photo distribution in the preview", () => { + function photo(assetId: string): ImageAnswer { + return { + assetId, + name: `${assetId}.jpg`, + mimeType: "image/jpeg", + width: 3000, + height: 2000, + sizeBytes: 1_000, + previewUrl: `/preview/${assetId}.jpg`, + } + } + + const submission: SubmissionSummary = { + id: "submission", + sequence: 1, + submittedAt: "2026-07-18T00:00:00.000Z", + answers: { photos: [photo("first"), photo("second")] }, + } + + it("renders a different photo in each frame bound to one question", () => { + let schema = addElement(emptyLayoutSchema(), "image-frame", "photos") + schema = addElement(schema, "image-frame", "photos") + schema.elements[0]!.geometry = { x: 10, y: 10, width: 40, height: 30, rotation: 0 } + schema.elements[1]!.geometry = { x: 10, y: 60, width: 40, height: 30, rotation: 0 } + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain(`src="/preview/first.jpg"`) + expect(markup).toContain(`src="/preview/second.jpg"`) + expect(markup.indexOf("/preview/first.jpg")).toBeLessThan(markup.indexOf("/preview/second.jpg")) + }) + + it("leaves a frame empty 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") + schema.elements.forEach((element, index) => { + element.geometry = { x: 10, y: 10 + index * 40, width: 40, height: 30, rotation: 0 } + }) + + const markup = renderToStaticMarkup( + + ) + + expect(markup.match(/ typeof item === "object" && item !== null && "assetId" in item - ) -} - function elementStyle(element: LayoutElement): React.CSSProperties { const geometry = canonicalToPercentageGeometry(element.geometry) return { @@ -76,12 +75,14 @@ function imagePosition( function ElementContent({ element, content, + photoAssignment, showEditorPlaceholders, editingElementId, selectedElementId, }: { element: LayoutElement content: LayoutPageContent + photoAssignment: PhotoAssignment showEditorPlaceholders: boolean editingElementId?: string selectedElementId?: string @@ -234,7 +235,7 @@ function ElementContent({ } if (element.type !== "image-frame" && element.type !== "gallery-frame") return null - const images = answerImages(content.submission?.answers[element.questionId]) + const images = framePhotos(photoAssignment, element.id) if (element.type === "image-frame") { const image = images[0] return ( @@ -323,6 +324,10 @@ export function LayoutPageElements({ editingElementId?: string selectedElementId?: string }) { + const photoAssignment = useMemo( + () => assignPhotosToFrames(schema.elements, content.submission?.answers ?? {}), + [schema.elements, content.submission] + ) return (
{ ) }) }) + +describe("photo distribution problems", () => { + function photo(assetId: string, width = 3000, height = 2000): ImageAnswer { + return { + assetId, + name: `${assetId}.jpg`, + mimeType: "image/jpeg", + width, + height, + sizeBytes: 1_000, + } + } + + function photoLayout(frames: Array<{ id: string; y: number }>): LayoutRecord { + const layout = layoutFixture() + layout.schema.elements = frames.map((frame) => ({ + id: frame.id, + type: "image-frame" as const, + questionId: "photos", + cornerRadius: 0, + opacity: 1, + geometry: { x: 10, y: frame.y, width: 40, height: 27, rotation: 0 }, + })) + return layout + } + + it("warns once per question when frames stay empty", () => { + const layout = photoLayout( + [10, 40, 70, 100, 130].map((y, index) => ({ id: `frame-${index}`, y })) + ) + const submission = submissionFixture(submissionIds[0]!, 3) + submission.answers.photos = ["a", "b", "c", "d"].map((assetId) => photo(assetId)) + + const problems = inspectSubmissionPage("page", layout, submission, completeForm, []).filter( + (problem) => problem.code === "photo-slot-mismatch" + ) + + expect(problems).toEqual([ + expect.objectContaining({ + blocking: false, + message: + '1 photo slot for "Photos" stays empty on Response 3. The layout has 5 photo slots for 4 uploaded photos.', + }), + ]) + }) + + it("warns about photos it cannot show without blocking the export", () => { + const layout = photoLayout([10, 40, 70, 100].map((y, index) => ({ id: `frame-${index}`, y }))) + const submission = submissionFixture(submissionIds[0]!, 2) + submission.answers.photos = ["a", "b", "c", "d", "e", "f"].map((assetId) => photo(assetId)) + + const book = generateBook({ + projectId: layout.projectId, + form: completeForm, + submissions: [submission], + layouts: [layout], + settings: cycleSettings, + now: "2026-07-18T00:00:00.000Z", + }) + + expect( + book.pages[0]!.problems.filter((problem) => problem.code === "photo-slot-mismatch") + ).toEqual([ + expect.objectContaining({ + blocking: false, + message: + '2 photos for "Photos" are not shown on Response 2. The layout has 4 photo slots for 6 uploaded photos.', + }), + ]) + expect(blockingProblems(book)).toEqual([]) + }) + + it("reports one problem per question with mismatched frames", () => { + const layout = photoLayout([{ id: "photo-frame", y: 10 }]) + layout.schema.elements.push({ + id: "portrait-frame", + type: "image-frame", + questionId: "portraits", + cornerRadius: 0, + opacity: 1, + geometry: { x: 60, y: 10, width: 40, height: 27, rotation: 0 }, + }) + const form = { + ...completeForm, + questions: [ + ...completeForm.questions, + { + id: "portraits", + type: "images" as const, + prompt: "Portraits", + required: false, + maxImages: 1, + }, + ], + } + const submission = submissionFixture(submissionIds[0]!, 1) + submission.answers.photos = [photo("a"), photo("b")] + submission.answers.portraits = [] + + const problems = inspectSubmissionPage("page", layout, submission, form, []).filter( + (problem) => problem.code === "photo-slot-mismatch" + ) + + expect(problems.map((problem) => problem.message)).toEqual([ + expect.stringContaining('1 photo for "Photos" is not shown'), + expect.stringContaining('1 photo slot for "Portraits" stays empty'), + ]) + expect(new Set(problems.map((problem) => problem.id))).toHaveLength(2) + }) + + it("checks effective resolution against the frame each photo is assigned to", () => { + const layout = photoLayout([ + { id: "small-frame", y: 10 }, + { id: "large-frame", y: 60 }, + ]) + layout.schema.elements[0]!.geometry = { x: 10, y: 10, width: 20, height: 14, rotation: 0 } + layout.schema.elements[1]!.geometry = { x: 10, y: 60, width: 180, height: 80, rotation: 0 } + const submission = submissionFixture(submissionIds[0]!, 1) + submission.answers.photos = [photo("sharp", 3000, 2000), photo("soft", 400, 300)] + + const problems = inspectSubmissionPage("page", layout, submission, completeForm, []).filter( + (problem) => problem.code === "image-blocking-resolution" + ) + + expect(problems).toEqual([ + expect.objectContaining({ assetId: "soft", elementId: "large-frame", blocking: true }), + ]) + }) +}) diff --git a/src/domain/generation.ts b/src/domain/generation.ts index 0baee14..53818ea 100644 --- a/src/domain/generation.ts +++ b/src/domain/generation.ts @@ -3,15 +3,21 @@ import { type FormSchema, type GeneratedBook, type GenerationSettings, - type ImageAnswer, type LayoutElement, type LayoutRecord, type PageProblem, - type SubmissionAnswers, type SubmissionBookPage, type SubmissionSummary, } from "./types" import { elementExtendsBeyondBleed, gallerySlots, isCriticalElementOutsideSafeArea } from "./layout" +import { questionPrompt } from "./layout-question-palette.ts" +import { + assignPhotosToFrames, + framePhotos, + isPhotoFrame, + type PhotoFrameElement, + type QuestionPhotoAssignment, +} from "./photo-assignment.ts" import { layoutText, textRunsForElement, type TextLayoutResult } from "./text-layout.ts" function hashString(value: string): number { @@ -125,15 +131,16 @@ function textProblemNames(elements: LayoutElement[], form: FormSchema): Map, - answers: SubmissionAnswers -): ImageAnswer[] { - const answer = answers[element.questionId] - if (!Array.isArray(answer)) return [] - return answer.filter( - (item): item is ImageAnswer => - typeof item === "object" && item !== null && "assetId" in item && "width" in item +/** Slot rectangles a frame prints into, in canonical millimetres relative to the frame. */ +function frameSlots(element: PhotoFrameElement): Array<{ width: number; height: number }> { + if (element.type === "image-frame") { + return [{ width: element.geometry.width, height: element.geometry.height }] + } + return gallerySlots( + element.arrangement, + element.geometry.width, + element.geometry.height, + element.gap ) } @@ -142,20 +149,35 @@ function problem( code: PageProblem["code"], message: string, blocking: boolean, - elementId?: string, - assetId?: string + scope: { elementId?: string; assetId?: string; key?: string } = {} ): PageProblem { return { - id: `${pageId}:${elementId ?? "page"}:${assetId ?? code}:${code}`, + id: `${pageId}:${scope.elementId ?? "page"}:${scope.key ?? scope.assetId ?? code}:${code}`, code, pageId, - elementId, - assetId, + elementId: scope.elementId, + assetId: scope.assetId, message, blocking, } } +function plural(count: number, noun: string): string { + return `${count} ${count === 1 ? noun : `${noun}s`}` +} + +function photoSlotMessage( + prompt: string, + response: number, + question: QuestionPhotoAssignment +): string { + const capacity = `The layout has ${plural(question.slotCount, "photo slot")} for ${plural(question.photoCount, "uploaded photo")}.` + if (question.unplacedPhotoCount > 0) { + return `${plural(question.unplacedPhotoCount, "photo")} for "${prompt}" ${question.unplacedPhotoCount === 1 ? "is" : "are"} not shown on Response ${response}. ${capacity}` + } + return `${plural(question.emptySlotCount, "photo slot")} for "${prompt}" ${question.emptySlotCount === 1 ? "stays" : "stay"} empty on Response ${response}. ${capacity}` +} + export function inspectSubmissionPage( pageId: string, layout: LayoutRecord, @@ -165,6 +187,7 @@ export function inspectSubmissionPage( ): PageProblem[] { const problems: PageProblem[] = [] const overrides = new Set(resolutionOverrides) + const assignment = assignPhotosToFrames(layout.schema.elements, submission.answers) const problemNames = textProblemNames(layout.schema.elements, form) const requiredQuestions = new Map( form.questions @@ -180,7 +203,7 @@ export function inspectSubmissionPage( "outside-print-area", "An element extends beyond the 3 mm bleed boundary.", true, - element.id + { elementId: element.id } ) ) } else if (isCriticalElementOutsideSafeArea(element)) { @@ -190,7 +213,7 @@ export function inspectSubmissionPage( "outside-print-area", "Text or critical content is outside the 6 mm safe area.", true, - element.id + { elementId: element.id } ) ) } @@ -202,7 +225,7 @@ export function inspectSubmissionPage( "empty-decorative-image", "A decorative image has no image selected and will be omitted from preview and export.", false, - element.id + { elementId: element.id } ) ) } @@ -229,7 +252,7 @@ export function inspectSubmissionPage( "missing-required-answer", "A required answer used by this layout is missing.", true, - element.id + { elementId: element.id } ) ) } @@ -248,42 +271,17 @@ export function inspectSubmissionPage( element.geometry.height ), !fit.fits, - element.id + { elementId: element.id } ) ) } continue } - if (element.type === "image-frame" || element.type === "gallery-frame") { - const images = imagesForElement(element, submission.answers) - if (images.length === 0) continue - const slots = - element.type === "image-frame" - ? [ - { - width: element.geometry.width, - height: element.geometry.height, - }, - ] - : gallerySlots( - element.arrangement, - element.geometry.width, - element.geometry.height, - element.gap - ) - if (images.length > slots.length) { - problems.push( - problem( - pageId, - "gallery-overflow", - `${images.length - slots.length} image(s) do not fit in the configured gallery.`, - true, - element.id - ) - ) - } - images.slice(0, slots.length).forEach((image, index) => { + if (isPhotoFrame(element)) { + const slots = frameSlots(element) + framePhotos(assignment, element.id).forEach((image, index) => { + if (!image) return if (image.mimeType !== "image/jpeg" && image.mimeType !== "image/png") { problems.push( problem( @@ -291,8 +289,7 @@ export function inspectSubmissionPage( "unsupported-asset", `${image.name} is not a supported print-master format.`, true, - element.id, - image.assetId + { elementId: element.id, assetId: image.assetId } ) ) return @@ -306,8 +303,7 @@ export function inspectSubmissionPage( "image-blocking-resolution", `${image.name} has ${ppi} effective PPI; at least 150 PPI or an explicit override is required.`, true, - element.id, - image.assetId + { elementId: element.id, assetId: image.assetId } ) ) } else if (ppi < 300) { @@ -317,14 +313,29 @@ export function inspectSubmissionPage( "image-low-resolution", `${image.name} has ${ppi} effective PPI; 300 PPI is recommended.`, false, - element.id, - image.assetId + { elementId: element.id, assetId: image.assetId } ) ) } }) } } + + for (const question of assignment.questions) { + if (question.unplacedPhotoCount === 0 && question.emptySlotCount === 0) continue + const prompt = questionPrompt( + form.questions.find((candidate) => candidate.id === question.questionId) + ) + problems.push( + problem( + pageId, + "photo-slot-mismatch", + photoSlotMessage(prompt, submission.sequence, question), + false, + { key: question.questionId } + ) + ) + } return problems } diff --git a/src/domain/photo-assignment.test.ts b/src/domain/photo-assignment.test.ts new file mode 100644 index 0000000..439a032 --- /dev/null +++ b/src/domain/photo-assignment.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from "vitest" + +import { assignPhotosToFrames, photoSlotMismatches } from "./photo-assignment.ts" +import { + type FormQuestion, + type GalleryArrangement, + type ImageAnswer, + type LayoutElement, +} from "./types.ts" + +function photo(assetId: string): ImageAnswer { + return { + assetId, + name: `${assetId}.jpg`, + mimeType: "image/jpeg", + width: 3000, + height: 2000, + sizeBytes: 1000, + } +} + +function imageFrame(id: string, questionId: string, x: number, y: number): LayoutElement { + return { + id, + type: "image-frame", + questionId, + cornerRadius: 0, + opacity: 1, + geometry: { x, y, width: 40, height: 30, rotation: 0 }, + } +} + +function galleryFrame( + id: string, + questionId: string, + x: number, + y: number, + arrangement: GalleryArrangement +): LayoutElement { + return { + id, + type: "gallery-frame", + questionId, + arrangement, + gap: 2, + opacity: 1, + geometry: { x, y, width: 60, height: 40, rotation: 0 }, + } +} + +/** Which photo landed in which slot, keyed by frame, for readable expectations. */ +function placement(elements: LayoutElement[], answers: Record) { + const assignment = assignPhotosToFrames(elements, answers) + return Object.fromEntries( + [...assignment.byElement].map(([elementId, photos]) => [ + elementId, + photos.map((image) => image?.assetId ?? null), + ]) + ) +} + +describe("photo distribution across frames", () => { + it("gives every frame bound to one question a different photo", () => { + const elements = [ + imageFrame("top", "photos", 10, 10), + imageFrame("middle", "photos", 10, 50), + imageFrame("bottom", "photos", 10, 90), + ] + const photos = { photos: [photo("a"), photo("b"), photo("c")] } + + expect(placement(elements, photos)).toEqual({ + top: ["a"], + middle: ["b"], + bottom: ["c"], + }) + expect(assignPhotosToFrames(elements, photos).questions).toEqual([ + { + questionId: "photos", + photoCount: 3, + slotCount: 3, + unplacedPhotoCount: 0, + emptySlotCount: 0, + }, + ]) + }) + + it("fills frames in reading order and leaves the trailing frames empty", () => { + const elements = [ + imageFrame("bottom-right", "photos", 90, 60), + imageFrame("top-right", "photos", 90, 10), + imageFrame("bottom-left", "photos", 10, 60), + imageFrame("top-left", "photos", 10, 10), + imageFrame("middle", "photos", 50, 35), + ] + const photos = { photos: [photo("a"), photo("b"), photo("c"), photo("d")] } + + expect(placement(elements, photos)).toEqual({ + "top-left": ["a"], + "top-right": ["b"], + middle: ["c"], + "bottom-left": ["d"], + "bottom-right": [null], + }) + expect(assignPhotosToFrames(elements, photos).questions[0]).toMatchObject({ + slotCount: 5, + photoCount: 4, + emptySlotCount: 1, + unplacedPhotoCount: 0, + }) + }) + + it("places as many photos as there are slots and reports the rest as unplaced", () => { + const elements = [ + imageFrame("hero", "photos", 10, 10), + galleryFrame("strip", "photos", 10, 50, "three-column"), + ] + const photos = { + photos: ["a", "b", "c", "d", "e", "f"].map(photo), + } + + expect(placement(elements, photos)).toEqual({ + hero: ["a"], + strip: ["b", "c", "d"], + }) + expect(assignPhotosToFrames(elements, photos).questions[0]).toMatchObject({ + slotCount: 4, + photoCount: 6, + unplacedPhotoCount: 2, + emptySlotCount: 0, + }) + }) + + it("gives a gallery a contiguous block at its place in the reading order", () => { + const elements = [ + galleryFrame("gallery", "photos", 10, 50, "two-portrait"), + imageFrame("above", "photos", 10, 10), + imageFrame("below", "photos", 10, 100), + ] + + expect(placement(elements, { photos: ["a", "b", "c", "d"].map(photo) })).toEqual({ + above: ["a"], + gallery: ["b", "c"], + below: ["d"], + }) + }) + + it("scopes distribution to each question", () => { + const elements = [ + imageFrame("portrait", "people", 10, 10), + imageFrame("place-one", "places", 60, 10), + imageFrame("place-two", "places", 60, 50), + ] + + expect( + placement(elements, { + people: [photo("face")], + places: [photo("beach"), photo("forest")], + }) + ).toEqual({ + portrait: ["face"], + "place-one": ["beach"], + "place-two": ["forest"], + }) + }) + + it("keeps the mapping stable when frames are relayered or duplicated", () => { + const elements = [ + imageFrame("alpha", "photos", 10, 10), + imageFrame("beta", "photos", 10, 10), + imageFrame("gamma", "photos", 60, 10), + ] + const photos = { photos: [photo("a"), photo("b"), photo("c")] } + const expected = { alpha: ["a"], beta: ["b"], gamma: ["c"] } + + expect(placement(elements, photos)).toEqual(expected) + expect(placement([...elements].reverse(), photos)).toEqual(expected) + }) + + it("ignores answers that are not uploaded photos", () => { + const elements = [imageFrame("frame", "photos", 10, 10)] + + expect(placement(elements, { photos: ["not-a-photo"] as unknown as ImageAnswer[] })).toEqual({ + frame: [null], + }) + }) +}) + +describe("design-time photo slot mismatches", () => { + const question = (maxImages: number): FormQuestion => ({ + id: "photos", + type: "images", + prompt: "Photos", + required: false, + maxImages, + }) + + it("reports a layout that cannot show every allowed upload", () => { + const elements = [imageFrame("frame", "photos", 10, 10)] + + expect(photoSlotMismatches(elements, [question(3)])).toEqual([ + { questionId: "photos", slotCount: 1, maxImages: 3 }, + ]) + }) + + it("reports a layout with more slots than a contributor may upload", () => { + const elements = [ + imageFrame("frame", "photos", 10, 10), + galleryFrame("gallery", "photos", 10, 50, "four-square"), + ] + + expect(photoSlotMismatches(elements, [question(2)])).toEqual([ + { questionId: "photos", slotCount: 5, maxImages: 2 }, + ]) + }) + + it("stays quiet when the slots match, and for questions this layout does not use", () => { + const matching = [imageFrame("frame", "photos", 10, 10), imageFrame("second", "photos", 60, 10)] + + expect(photoSlotMismatches(matching, [question(2)])).toEqual([]) + expect(photoSlotMismatches([], [question(2)])).toEqual([]) + }) +}) diff --git a/src/domain/photo-assignment.ts b/src/domain/photo-assignment.ts new file mode 100644 index 0000000..769d2be --- /dev/null +++ b/src/domain/photo-assignment.ts @@ -0,0 +1,146 @@ +import { gallerySlots } from "./layout.ts" +import { + type FormQuestion, + type ImageAnswer, + type LayoutElement, + type SubmissionAnswer, + type SubmissionAnswers, +} from "./types.ts" + +export type PhotoFrameElement = Extract + +export function isPhotoFrame(element: LayoutElement): element is PhotoFrameElement { + return element.type === "image-frame" || element.type === "gallery-frame" +} + +/** Photos a contributor uploaded for one question, ignoring malformed answer payloads. */ +export function answerImages(answer: SubmissionAnswer | undefined): ImageAnswer[] { + if (!Array.isArray(answer)) return [] + return answer.filter( + (item): item is ImageAnswer => + typeof item === "object" && item !== null && "assetId" in item && "width" in item + ) +} + +/** How many photos a frame shows. An image frame holds one, a gallery one per slot. */ +export function frameSlotCount(element: PhotoFrameElement): number { + if (element.type === "image-frame") return 1 + return gallerySlots( + element.arrangement, + element.geometry.width, + element.geometry.height, + element.gap + ).length +} + +/** + * Visual reading order: top to bottom, then left to right. The element id breaks remaining + * ties so duplicating or relayering an element never reshuffles the photos. + */ +function readingOrder(left: PhotoFrameElement, right: PhotoFrameElement): number { + if (left.geometry.y !== right.geometry.y) return left.geometry.y - right.geometry.y + if (left.geometry.x !== right.geometry.x) return left.geometry.x - right.geometry.x + return left.id < right.id ? -1 : left.id > right.id ? 1 : 0 +} + +export interface QuestionPhotoAssignment { + questionId: string + photoCount: number + slotCount: number + /** Photos with no slot left, which are therefore not printed. */ + unplacedPhotoCount: number + /** Slots with no photo left, which therefore print empty. */ + emptySlotCount: number +} + +export interface PhotoAssignment { + /** + * Photos per frame id, index-aligned with that frame's slots. `undefined` marks a slot that + * stays empty because the contributor uploaded fewer photos than the layout can show. + */ + byElement: Map> + questions: QuestionPhotoAssignment[] +} + +/** + * Spreads a response's photos across the frames bound to each question, so a layout with + * several frames on one question shows several different photos. Distribution is scoped per + * question and deterministic: photo N always lands in the Nth slot of the reading order, which + * keeps per-element focal points meaningful across regenerations. + * + * Preview, PDF export, and preflight all read from this so a reported problem always describes + * the photo that actually prints. + */ +export function assignPhotosToFrames( + elements: LayoutElement[], + answers: SubmissionAnswers +): PhotoAssignment { + const framesByQuestion = new Map() + for (const element of elements) { + if (!isPhotoFrame(element)) continue + const frames = framesByQuestion.get(element.questionId) + if (frames) frames.push(element) + else framesByQuestion.set(element.questionId, [element]) + } + + const byElement = new Map>() + const questions: QuestionPhotoAssignment[] = [] + for (const [questionId, frames] of framesByQuestion) { + const photos = answerImages(answers[questionId]) + let taken = 0 + for (const frame of [...frames].sort(readingOrder)) { + const slotCount = frameSlotCount(frame) + byElement.set( + frame.id, + Array.from({ length: slotCount }, (_, index) => photos[taken + index]) + ) + taken += slotCount + } + questions.push({ + questionId, + photoCount: photos.length, + slotCount: taken, + unplacedPhotoCount: Math.max(0, photos.length - taken), + emptySlotCount: Math.max(0, taken - photos.length), + }) + } + return { byElement, questions } +} + +/** Photos assigned to one frame, index-aligned with its slots. */ +export function framePhotos( + assignment: PhotoAssignment, + elementId: string +): Array { + return assignment.byElement.get(elementId) ?? [] +} + +export interface PhotoSlotMismatch { + questionId: string + slotCount: number + maxImages: number +} + +/** + * Design-time check: the frames a layout binds to a question hold a different number of photos + * than a contributor is allowed to upload. Questions the layout does not use are not reported. + */ +export function photoSlotMismatches( + elements: LayoutElement[], + questions: FormQuestion[] +): PhotoSlotMismatch[] { + const slotCounts = new Map() + for (const element of elements) { + if (!isPhotoFrame(element)) continue + slotCounts.set( + element.questionId, + (slotCounts.get(element.questionId) ?? 0) + frameSlotCount(element) + ) + } + return questions.flatMap((question) => { + if (question.type !== "images") return [] + const slotCount = slotCounts.get(question.id) + if (slotCount === undefined || slotCount === question.maxImages) return [] + return [{ questionId: question.id, slotCount, maxImages: question.maxImages }] + }) +} diff --git a/src/domain/types.ts b/src/domain/types.ts index 7223fef..3cac84d 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -190,6 +190,9 @@ export type ProblemCode = | "image-low-resolution" | "image-blocking-resolution" | "unsupported-asset" + | "photo-slot-mismatch" + // Superseded by "photo-slot-mismatch"; still accepted so books persisted before the rename + // stay loadable and re-saveable until they are regenerated. | "gallery-overflow" | "outside-print-area" | "missing-required-answer" diff --git a/src/server/pdf-renderer-assets.test.ts b/src/server/pdf-renderer-assets.test.ts index 4aae078..94d382f 100644 --- a/src/server/pdf-renderer-assets.test.ts +++ b/src/server/pdf-renderer-assets.test.ts @@ -76,3 +76,66 @@ describe("PDF raster metadata", () => { }) }) }) + +describe("PDF photo distribution", () => { + it("places a different photo in every frame bound to one question", async () => { + const { inspectPdf, renderBookPdf } = await import("./pdf-renderer.ts") + const layout = layoutFixture() + layout.schema.elements = [ + { + id: "lower-frame", + type: "image-frame", + opacity: 1, + geometry: { x: 12, y: 80, width: 80, height: 60, rotation: 0 }, + questionId: "photos", + cornerRadius: 0, + }, + { + id: "upper-frame", + type: "image-frame", + opacity: 1, + geometry: { x: 12, y: 12, width: 80, height: 60, rotation: 0 }, + questionId: "photos", + cornerRadius: 0, + }, + ] + const submission = submissionFixture("10000000-0000-4000-8000-000000000001", 1) + submission.answers.photos = ["first", "second"].map((assetId) => ({ + assetId, + name: `${assetId}.png`, + mimeType: "image/png", + width: 1, + height: 1, + sizeBytes: 100, + })) + + 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: "distribution-test", + generatedAt: "2026-07-18T00:00:00.000Z", + updatedAt: "2026-07-18T00:00:00.000Z", + }, + layouts: [layout], + submissions: [submission], + form: completeForm, + marks: false, + }) + + // Reading order, not element order: the upper frame prints the first uploaded photo. + expect((await inspectPdf(bytes)).assetPlacements).toEqual([ + { assetId: "second", elementId: "lower-frame" }, + { assetId: "first", elementId: "upper-frame" }, + ]) + }) +}) diff --git a/src/server/pdf-renderer.test.ts b/src/server/pdf-renderer.test.ts index 5617ffa..e5b1187 100644 --- a/src/server/pdf-renderer.test.ts +++ b/src/server/pdf-renderer.test.ts @@ -48,6 +48,7 @@ describe("PDF renderer", () => { pdfxMetadata: true, assetResolutionMetadata: true, assetResolutionCount: 0, + assetPlacements: [], }) }) }) diff --git a/src/server/pdf-renderer.ts b/src/server/pdf-renderer.ts index d1c020e..decd853 100644 --- a/src/server/pdf-renderer.ts +++ b/src/server/pdf-renderer.ts @@ -23,15 +23,18 @@ import { import { effectivePpi } from "../domain/generation" import { gallerySlots, PAGE_SPEC } from "../domain/layout" +import { + assignPhotosToFrames, + framePhotos, + type PhotoAssignment, +} from "../domain/photo-assignment.ts" import { layoutText, textRunsForElement, type TextLayoutRun } from "../domain/text-layout.ts" import { type BookPage, type FormSchema, type GeneratedBook, - type ImageAnswer, type LayoutElement, type LayoutRecord, - type SubmissionAnswer, type SubmissionSummary, type TextSettings, } from "../domain/types" @@ -90,13 +93,6 @@ async function embedImage(pdf: PDFDocument, assetId: string): Promise return asset.mimeType === "image/png" ? pdf.embedPng(source.body) : pdf.embedJpg(source.body) } -function answerImages(answer: SubmissionAnswer | undefined): ImageAnswer[] { - if (!Array.isArray(answer)) return [] - return answer.filter( - (item): item is ImageAnswer => typeof item === "object" && item !== null && "assetId" in item - ) -} - function drawCroppedImage( page: PDFPage, image: PDFImage, @@ -183,6 +179,7 @@ async function drawElement(input: { pageId: string element: LayoutElement submission: SubmissionSummary + photoAssignment: PhotoAssignment form: FormSchema fonts: EmbeddedFonts assetResolutions: AssetResolutionMetadata[] @@ -269,10 +266,10 @@ async function drawElement(input: { return } - const images = answerImages(input.submission.answers[element.questionId]) - if (images.length === 0) return + const images = framePhotos(input.photoAssignment, element.id) if (element.type === "image-frame") { - const image = images[0]! + const image = images[0] + if (!image) return const embeddedImage = await embedImage(input.pdf, image.assetId) input.assetResolutions.push({ assetId: image.assetId, @@ -294,8 +291,9 @@ async function drawElement(input: { } const slots = gallerySlots(element.arrangement, geometry.width, geometry.height, element.gap) await Promise.all( - images.slice(0, slots.length).map(async (image, index) => { - const slot = slots[index]! + slots.map(async (slot, index) => { + const image = images[index] + if (!image) return const embeddedImage = await embedImage(input.pdf, image.assetId) input.assetResolutions.push({ assetId: image.assetId, @@ -546,6 +544,7 @@ export async function renderBookPdf(input: { height: pt(PAGE_SPEC.mediaHeightMm), color: color(layout.schema.background), }) + const photoAssignment = assignPhotosToFrames(layout.schema.elements, submission.answers) for (const element of layout.schema.elements) { await drawElement({ pdf, @@ -553,6 +552,7 @@ export async function renderBookPdf(input: { pageId: bookPage.id, element, submission, + photoAssignment, form: input.form, fonts, assetResolutions, @@ -573,6 +573,7 @@ export async function inspectPdf(bytes: Uint8Array): Promise<{ pdfxMetadata: boolean assetResolutionMetadata: boolean assetResolutionCount: number + assetPlacements: Array<{ assetId: string; elementId: string }> }> { const document = await PDFDocument.load(bytes) const tolerance = 0.2 @@ -591,11 +592,15 @@ export async function inspectPdf(bytes: Uint8Array): Promise<{ }) const raw = Buffer.from(bytes).toString("latin1") const assetResolutions = document.catalog.lookup(PDFName.of("SakekeepAssetResolutions")) + const assetResolutionEntries = + assetResolutions instanceof PDFArray + ? Array.from({ length: assetResolutions.size() }, (_, index) => + assetResolutions.lookup(index) + ) + : [] const assetResolutionMetadata = assetResolutions instanceof PDFArray && - Array.from({ length: assetResolutions.size() }, (_, index) => - assetResolutions.lookup(index) - ).every( + assetResolutionEntries.every( (entry) => entry instanceof PDFDict && entry.has(PDFName.of("AssetID")) && @@ -615,6 +620,15 @@ export async function inspectPdf(bytes: Uint8Array): Promise<{ /\/OutputIntents\b/.test(raw) && /\/GTS_PDFX\b/.test(raw) && /FOGRA51/.test(raw), pdfxMetadata: /GTS_PDFXVersion/.test(raw) && /PDF\/X-4/.test(raw), assetResolutionMetadata, - assetResolutionCount: assetResolutions instanceof PDFArray ? assetResolutions.size() : 0, + assetResolutionCount: assetResolutionEntries.length, + // Which photo the export actually placed in which frame, so distribution is verifiable + // from the produced PDF rather than only from the renderer's inputs. + assetPlacements: assetResolutionEntries.flatMap((entry) => { + if (!(entry instanceof PDFDict)) return [] + const assetId = entry.lookup(PDFName.of("AssetID")) + const elementId = entry.lookup(PDFName.of("ElementID")) + if (!(assetId instanceof PDFString) || !(elementId instanceof PDFString)) return [] + return [{ assetId: assetId.asString(), elementId: elementId.asString() }] + }), } } diff --git a/visual-artifacts/issues/60/after-layout-editor-palette.png b/visual-artifacts/issues/60/after-layout-editor-palette.png new file mode 100644 index 0000000..e6aae37 Binary files /dev/null and b/visual-artifacts/issues/60/after-layout-editor-palette.png differ diff --git a/visual-artifacts/issues/60/after-page-preview.png b/visual-artifacts/issues/60/after-page-preview.png new file mode 100644 index 0000000..77714b3 Binary files /dev/null and b/visual-artifacts/issues/60/after-page-preview.png differ diff --git a/visual-artifacts/issues/60/before-layout-editor-palette.png b/visual-artifacts/issues/60/before-layout-editor-palette.png new file mode 100644 index 0000000..bf93c85 Binary files /dev/null and b/visual-artifacts/issues/60/before-layout-editor-palette.png differ diff --git a/visual-artifacts/issues/60/before-page-preview.png b/visual-artifacts/issues/60/before-page-preview.png new file mode 100644 index 0000000..26907d9 Binary files /dev/null and b/visual-artifacts/issues/60/before-page-preview.png differ