From d2a2728cde4c4e5de4e5c2a39333a0ee3e3cf633 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 22 Oct 2026 12:14:22 +0200 Subject: [PATCH] feat(studio): the book engine in shared: layouts, grids, page ops and findings --- client/scripts/format-baseline.json | 6 - client/scripts/layers-baseline.json | 2 +- client/scripts/size-baseline.json | 2 +- client/scripts/theme-baseline.json | 1 - client/src/components/Studio/StudioCanvas.tsx | 2 +- .../components/Studio/StudioElementsPanel.tsx | 2 +- .../components/Studio/StudioSavedLayouts.tsx | 5 +- .../src/components/Studio/StudioSidebar.tsx | 6 +- .../components/Studio/StudioTravelPanel.tsx | 3 +- client/src/components/Studio/autoLayout.ts | 23 +- client/src/components/Studio/bookIds.ts | 31 - client/src/components/Studio/grids.ts | 226 --- client/src/components/Studio/savedLayouts.ts | 131 -- .../src/components/Studio/studioClipboard.ts | 2 +- .../pages/journeyStudio/useJourneyStudio.ts | 3 +- client/src/store/studioStore.ts | 41 +- client/tests/unit/studio/grids.test.ts | 178 --- .../tests/unit/studio/pagePresetSizes.test.ts | 20 + .../studio/studioClipboardLayouts.test.ts | 5 +- client/tests/unit/studio/templates.test.ts | 150 -- scripts/ci/knip-baseline.json | 2 - shared/src/book/book-findings.ts | 387 +++++ shared/src/book/book-grids.spec.ts | 180 +++ shared/src/book/book-grids.ts | 282 ++++ shared/src/book/book-ids.ts | 41 + .../src/book/book-layouts.parity-books.json | 1314 +++++++++++++++++ shared/src/book/book-layouts.parity.json | 429 ++++++ shared/src/book/book-layouts.parity.spec.ts | 113 ++ shared/src/book/book-layouts.spec.ts | 210 +++ .../src/book/book-layouts.ts | 411 +++--- shared/src/book/book-ops-core.ts | 174 +++ shared/src/book/book-ops.schema.ts | 374 +++++ shared/src/book/book-ops.spec.ts | 625 ++++++++ shared/src/book/book-ops.ts | 668 +++++++++ shared/src/book/book-page-ops.spec.ts | 276 ++++ shared/src/book/book-page-ops.ts | 245 +++ shared/src/book/book-pages.ts | 133 ++ shared/src/book/book-saved-layouts.ts | 147 ++ shared/src/book/describe-spread.spec.ts | 296 ++++ shared/src/book/describe-spread.ts | 265 ++++ 40 files changed, 6450 insertions(+), 961 deletions(-) delete mode 100644 client/src/components/Studio/bookIds.ts delete mode 100644 client/src/components/Studio/grids.ts delete mode 100644 client/src/components/Studio/savedLayouts.ts delete mode 100644 client/tests/unit/studio/grids.test.ts create mode 100644 client/tests/unit/studio/pagePresetSizes.test.ts delete mode 100644 client/tests/unit/studio/templates.test.ts create mode 100644 shared/src/book/book-findings.ts create mode 100644 shared/src/book/book-grids.spec.ts create mode 100644 shared/src/book/book-grids.ts create mode 100644 shared/src/book/book-ids.ts create mode 100644 shared/src/book/book-layouts.parity-books.json create mode 100644 shared/src/book/book-layouts.parity.json create mode 100644 shared/src/book/book-layouts.parity.spec.ts create mode 100644 shared/src/book/book-layouts.spec.ts rename client/src/components/Studio/templates.ts => shared/src/book/book-layouts.ts (55%) create mode 100644 shared/src/book/book-ops-core.ts create mode 100644 shared/src/book/book-ops.schema.ts create mode 100644 shared/src/book/book-ops.spec.ts create mode 100644 shared/src/book/book-ops.ts create mode 100644 shared/src/book/book-page-ops.spec.ts create mode 100644 shared/src/book/book-page-ops.ts create mode 100644 shared/src/book/book-pages.ts create mode 100644 shared/src/book/book-saved-layouts.ts create mode 100644 shared/src/book/describe-spread.spec.ts create mode 100644 shared/src/book/describe-spread.ts diff --git a/client/scripts/format-baseline.json b/client/scripts/format-baseline.json index fa4e505fb..d4157106d 100644 --- a/client/scripts/format-baseline.json +++ b/client/scripts/format-baseline.json @@ -977,7 +977,6 @@ "src/components/Studio/autoLayout.ts", "src/components/Studio/bookFontFaces.ts", "src/components/Studio/bookFonts.ts", - "src/components/Studio/bookIds.ts", "src/components/Studio/bookRender.ts", "src/components/Studio/bookSheets.ts", "src/components/Studio/bookTemplates.data.ts", @@ -985,7 +984,6 @@ "src/components/Studio/entryText.ts", "src/components/Studio/flags.ts", "src/components/Studio/folioColour.ts", - "src/components/Studio/grids.ts", "src/components/Studio/iconLibrary.ts", "src/components/Studio/mapSources.ts", "src/components/Studio/mapTiles.ts", @@ -995,12 +993,10 @@ "src/components/Studio/printSheets.ts", "src/components/Studio/resolveBindings.ts", "src/components/Studio/roadRoute.ts", - "src/components/Studio/savedLayouts.ts", "src/components/Studio/shapes.ts", "src/components/Studio/spreadFile.ts", "src/components/Studio/studioClipboard.ts", "src/components/Studio/studioUpload.ts", - "src/components/Studio/templates.ts", "src/components/Studio/travelRefresh.ts", "src/components/Studio/useBookPresence.ts", "src/components/Studio/useBookStore.ts", @@ -2114,7 +2110,6 @@ "tests/unit/studio/entryText.test.ts", "tests/unit/studio/flags.test.ts", "tests/unit/studio/folioColour.test.ts", - "tests/unit/studio/grids.test.ts", "tests/unit/studio/iconElement.test.tsx", "tests/unit/studio/inspector.test.tsx", "tests/unit/studio/inspectorTravel.test.tsx", @@ -2138,7 +2133,6 @@ "tests/unit/studio/studioClipboardLayouts.test.ts", "tests/unit/studio/studioStore.spreads.test.ts", "tests/unit/studio/studioUpload.test.ts", - "tests/unit/studio/templates.test.ts", "tests/unit/studio/travelRefresh.test.ts", "tests/unit/studio/travelStops.test.tsx", "tests/unit/studio/trimField.test.tsx", diff --git a/client/scripts/layers-baseline.json b/client/scripts/layers-baseline.json index bad532aac..bb9dc7e67 100644 --- a/client/scripts/layers-baseline.json +++ b/client/scripts/layers-baseline.json @@ -56,6 +56,6 @@ "src/hooks/useWebPush.ts": 1, "src/repo/pluginPoiRepo.ts": 1, "src/store/slices/budgetSlice.ts": 1, - "src/store/studioStore.ts": 2, + "src/store/studioStore.ts": 1, "src/store/vacayStore.ts": 1 } diff --git a/client/scripts/size-baseline.json b/client/scripts/size-baseline.json index 21f0f765f..ccd1ab164 100644 --- a/client/scripts/size-baseline.json +++ b/client/scripts/size-baseline.json @@ -17,7 +17,7 @@ "src/components/Planner/PlaceFormModal.tsx": 1193, "src/components/Planner/PlaceInspector.tsx": 1304, "src/components/Studio/TravelElements.tsx": 1738, - "src/components/Studio/autoLayout.ts": 1691, + "src/components/Studio/autoLayout.ts": 1675, "src/components/Studio/bookTemplates.data.ts": 1309, "src/components/Studio/countryShapes.ts": 2595, "src/index.css": 1636, diff --git a/client/scripts/theme-baseline.json b/client/scripts/theme-baseline.json index c0319022d..c8486101d 100644 --- a/client/scripts/theme-baseline.json +++ b/client/scripts/theme-baseline.json @@ -108,7 +108,6 @@ "components/Studio/StudioTravelPanel.tsx": 1, "components/Studio/TravelElements.tsx": 5, "components/Studio/printSheets.ts": 5, - "components/Studio/templates.ts": 3, "components/SystemNotices/ReleaseNoticeVisuals.tsx": 4, "components/SystemNotices/SystemNoticeBanner.tsx": 28, "components/SystemNotices/SystemNoticeModal.tsx": 107, diff --git a/client/src/components/Studio/StudioCanvas.tsx b/client/src/components/Studio/StudioCanvas.tsx index 60bcec74e..392b9773d 100644 --- a/client/src/components/Studio/StudioCanvas.tsx +++ b/client/src/components/Studio/StudioCanvas.tsx @@ -4,7 +4,7 @@ import type { BookElement, BookPageSetup, BookSpread } from '@trek/shared' import { SpreadFold, SpreadView } from './SpreadView' import { PeerCursors } from './PeerCursors' import type { PeerCursor } from './useBookPresence' -import { elementId } from './bookIds' +import { bookElementId as elementId } from '@trek/shared' import { fontStack } from './bookFonts' import { useSpreadInteraction, type HandleId } from './useSpreadInteraction' import { useStudioStore } from '../../store/studioStore' diff --git a/client/src/components/Studio/StudioElementsPanel.tsx b/client/src/components/Studio/StudioElementsPanel.tsx index de5a868f3..222b66473 100644 --- a/client/src/components/Studio/StudioElementsPanel.tsx +++ b/client/src/components/Studio/StudioElementsPanel.tsx @@ -3,7 +3,7 @@ import { Minus, Quote, Search, Square, X } from 'lucide-react' import type { BookElement, BookPageSetup, BookShapeId } from '@trek/shared' import { useStudioStore } from '../../store/studioStore' import { FRAME_SHAPES, HOLED_SHAPES, SHAPE_GROUPS, SHAPE_PATHS } from './shapes' -import { GRIDS, defaultGridBox, gridElements } from './grids' +import { BOOK_GRIDS as GRIDS, bookGridElements as gridElements, defaultBookGridBox as defaultGridBox } from '@trek/shared' import { PanelHead } from './StudioPanelHead' import { FEATURED_ICONS, iconComponent, iconLabel, searchIcons } from './iconLibrary' diff --git a/client/src/components/Studio/StudioSavedLayouts.tsx b/client/src/components/Studio/StudioSavedLayouts.tsx index f91c9caee..d9c621ba7 100644 --- a/client/src/components/Studio/StudioSavedLayouts.tsx +++ b/client/src/components/Studio/StudioSavedLayouts.tsx @@ -1,10 +1,11 @@ import type { BookPageSetup } from '@trek/shared' -import { MAX_BOOK_LAYOUTS } from '@trek/shared' +import { + MAX_BOOK_LAYOUTS, bookLayoutPreview as layoutPreview, bookLayoutRole as layoutRole, nextBookLayoutName as nextLayoutName, +} from '@trek/shared' import { BookmarkPlus, Trash2 } from 'lucide-react' import { useStudioStore } from '../../store/studioStore' import { Tooltip } from '../shared/Tooltip' import { SpreadView } from './SpreadView' -import { layoutPreview, layoutRole, nextLayoutName } from './savedLayouts' /** * The book's own layouts, above the built-in ones (#2316). diff --git a/client/src/components/Studio/StudioSidebar.tsx b/client/src/components/Studio/StudioSidebar.tsx index 48c73be51..b0c32712f 100644 --- a/client/src/components/Studio/StudioSidebar.tsx +++ b/client/src/components/Studio/StudioSidebar.tsx @@ -11,10 +11,12 @@ import { useToast } from '../shared/Toast' import { SpreadFold, SpreadView } from './SpreadView' import { folioLabel, foliosOf } from './bookSheets' import { photoSrc } from './bookRender' -import { elementId as uid } from './bookIds' +import { + BOOK_COVER_TEMPLATES as COVER_TEMPLATES, BOOK_TEMPLATES as TEMPLATES, applyBookTemplate as applyTemplate, + bookElementId as uid, +} from '@trek/shared' import { formatBookCoords, formatBookDate, type CoordFormat } from './entryText' import { coordValue } from './resolveBindings' -import { COVER_TEMPLATES, TEMPLATES, applyTemplate } from './templates' import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants' import { PanelHead as Head } from './StudioPanelHead' import { StudioElementsPanel } from './StudioElementsPanel' diff --git a/client/src/components/Studio/StudioTravelPanel.tsx b/client/src/components/Studio/StudioTravelPanel.tsx index e52581995..f9c7ac240 100644 --- a/client/src/components/Studio/StudioTravelPanel.tsx +++ b/client/src/components/Studio/StudioTravelPanel.tsx @@ -1,13 +1,12 @@ import { useEffect, useRef, useState } from 'react' import { Check, RouteOff } from 'lucide-react' import type { BookElement, BookMetric, BookPageSetup, JourneyStats } from '@trek/shared' -import { BOOK_METRICS } from '@trek/shared' +import { BOOK_METRICS, bookElementId as uid } from '@trek/shared' import { useStudioStore } from '../../store/studioStore' import { PanelHead } from './StudioPanelHead' import { Section } from './StudioControls' import { TravelPreview } from './TravelPreview' import { formatBookCoords } from './entryText' -import { elementId as uid } from './bookIds' import { routeFor } from './travelRefresh' import { useMapSources } from './mapSources' import { fetchRoads } from './roadRoute' diff --git a/client/src/components/Studio/autoLayout.ts b/client/src/components/Studio/autoLayout.ts index 66f3d78b0..2e85c369f 100644 --- a/client/src/components/Studio/autoLayout.ts +++ b/client/src/components/Studio/autoLayout.ts @@ -4,7 +4,7 @@ import type { import { MAX_BOOK_COUNTRIES, MAX_BOOK_TITLE, MAX_COUNTRY_NAME, MAX_MAP_POINTS, MAX_MAP_POINT_LABEL, MAX_PATH_POINTS, MAX_PATH_SEGMENTS, MAX_SPREADS, - MAX_SPREAD_ELEMENTS, MAX_TEXT_LENGTH, + MAX_SPREAD_ELEMENTS, MAX_TEXT_LENGTH, blankBook, blankSpread, } from '@trek/shared' import { SPREAD_TEMPLATES } from './bookTemplates.data' import { applyTemplate, templateFit } from './applyTemplate' @@ -1581,17 +1581,6 @@ export function buildBook(input: AutoInput): BookDocument { } } -/** - * A page with nothing on it, of the given kind. - * - * The first and the last page of a book are laid down empty on purpose: the - * first is where a title page or a dedication goes, the last where a colophon - * does, and neither is something a layout should decide for a person. - */ -function blankSpread(role: BookSpread['role']): BookSpread { - return { id: uid('sp'), role, background: null, elements: [], parked: [], entryId: null } -} - /** * The page setup for a book that opens on a single page. * @@ -1619,15 +1608,11 @@ function withFirstPage(page: BookPageSetup): BookPageSetup { * The covers are here rather than left out because a book has them and there is * no other way to add one: the pages rail only inserts inner spreads, between * the two. The single first and last pages likewise (#2317). Empty, though, in - * the same way the spread between them is. + * the same way the spread between them is. The same blank book the assistant's + * `create_book` writes (shared/src/book/book-pages.ts). */ export function emptyBook(input: AutoInput): BookDocument { - return { - version: 1, - title: input.title.slice(0, MAX_BOOK_TITLE), - page: withFirstPage(input.page), - spreads: [blankSpread('cover'), blankSpread('first'), blankSpread('inner'), blankSpread('last'), blankSpread('back')], - } + return blankBook({ title: input.title, page: input.page }) } /** diff --git a/client/src/components/Studio/bookIds.ts b/client/src/components/Studio/bookIds.ts deleted file mode 100644 index dcf46d9f4..000000000 --- a/client/src/components/Studio/bookIds.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Ids for the things a book is made of. - * - * Every panel that places an element mints one, and they were four copies of - * the same line: a prefix, `Math.random().toString(36)` and a slice. Copies of - * a line drift, and this one had already drifted in its slice length. - * - * `getRandomValues` rather than `Math.random`, for the reason - * `utils/randomId.ts` gives at greater length: TREK's own quickstart is plain - * http on a LAN address, where `crypto.randomUUID` is undefined but - * `getRandomValues` still works. A book document is shared over a websocket and - * merged by whoever saves last, so two editors minting the same id for two - * different elements is a collision that shows up as one of them losing their - * work. Short of Web Crypto entirely, the old arithmetic is still there. - * - * Seven characters, as before: 36^7 is about 78 billion, against a document - * that holds a few hundred elements. - */ -const ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789' - -export function elementId(prefix: string): string { - const c = globalThis.crypto - if (c && typeof c.getRandomValues === 'function') { - const bytes = new Uint8Array(7) - c.getRandomValues(bytes) - let out = '' - for (const b of bytes) out += ALPHABET[b % ALPHABET.length] - return `${prefix}-${out}` - } - return `${prefix}-${Math.random().toString(36).slice(2, 9)}` -} diff --git a/client/src/components/Studio/grids.ts b/client/src/components/Studio/grids.ts deleted file mode 100644 index 9d12f6da5..000000000 --- a/client/src/components/Studio/grids.ts +++ /dev/null @@ -1,226 +0,0 @@ -import type { BookElement, BookPageSetup } from '@trek/shared' - -/** - * Photo grids — a set of frames placed together, in one gesture. - * - * ── How this differs from a layout ──────────────────────────────────────── - * - * A layout (`templates.ts`) rearranges the *whole spread*: it takes what is - * already there, pours it into a new arrangement and parks the rest. A grid - * adds a block of empty frames wherever you put it, and touches nothing else. - * You reach for a layout when you are deciding what a page is; you reach for a - * grid when you have decided, and want four pictures in a row on the left half. - * - * Canva keeps them apart for the same reason, and calling both "templates" - * would make the difference invisible at exactly the moment it matters. - * - * ── Why fractions ──────────────────────────────────────────────────────── - * - * Cells are stored as fractions of the block, so one definition serves a grid - * dropped across a whole spread and the same grid dropped into a quarter of a - * page. The gutter is the exception: it is a real measurement in millimetres, - * because 2mm between two pictures is 2mm whether the block is 60mm or 300mm - * wide — scaling the gap with the block would make small grids look glued - * together and big ones look scattered. - */ - -export interface GridCell { - /** Fractions of the block, 0..1. */ - x: number - y: number - w: number - h: number -} - -export interface GridDef { - id: string - cells: GridCell[] -} - -/** `rows` of equal cells, each row split into that many columns. */ -function rows(...counts: number[]): GridCell[] { - const h = 1 / counts.length - const out: GridCell[] = [] - counts.forEach((cols, r) => { - for (let c = 0; c < cols; c++) out.push({ x: c / cols, y: r * h, w: 1 / cols, h }) - }) - return out -} - -/** `cols` of equal columns, each split into that many rows. */ -function cols(...counts: number[]): GridCell[] { - const w = 1 / counts.length - const out: GridCell[] = [] - counts.forEach((n, c) => { - for (let r = 0; r < n; r++) out.push({ x: c * w, y: r / n, w, h: 1 / n }) - }) - return out -} - -/** - * The catalogue. - * - * Ordered by how often a photo book actually wants them: one big picture, then - * pairs, then the asymmetric arrangements that carry a page, then the dense - * grids that end a chapter. - */ -export const GRIDS: GridDef[] = [ - { id: 'single', cells: [{ x: 0, y: 0, w: 1, h: 1 }] }, - - { id: 'two-across', cells: rows(2) }, - { id: 'two-down', cells: cols(2) }, - { id: 'three-across', cells: rows(3) }, - { id: 'three-down', cells: cols(3) }, - - // One picture carrying the block, the rest in support. The workhorse of a - // travel page: a landscape wide enough to hold the eye, and the details - // underneath it. - { id: 'hero-two', cells: [ - { x: 0, y: 0, w: 1, h: 0.62 }, - { x: 0, y: 0.62, w: 0.5, h: 0.38 }, - { x: 0.5, y: 0.62, w: 0.5, h: 0.38 }, - ] }, - { id: 'hero-three', cells: [ - { x: 0, y: 0, w: 1, h: 0.64 }, - ...[0, 1, 2].map(i => ({ x: i / 3, y: 0.64, w: 1 / 3, h: 0.36 })), - ] }, - { id: 'two-hero', cells: [ - { x: 0, y: 0, w: 0.5, h: 0.36 }, - { x: 0.5, y: 0, w: 0.5, h: 0.36 }, - { x: 0, y: 0.36, w: 1, h: 0.64 }, - ] }, - { id: 'side-hero', cells: [ - { x: 0, y: 0, w: 0.62, h: 1 }, - { x: 0.62, y: 0, w: 0.38, h: 0.5 }, - { x: 0.62, y: 0.5, w: 0.38, h: 0.5 }, - ] }, - { id: 'side-hero-left', cells: [ - { x: 0, y: 0, w: 0.38, h: 0.5 }, - { x: 0, y: 0.5, w: 0.38, h: 0.5 }, - { x: 0.38, y: 0, w: 0.62, h: 1 }, - ] }, - { id: 'side-three', cells: [ - { x: 0, y: 0, w: 0.6, h: 1 }, - ...[0, 1, 2].map(i => ({ x: 0.6, y: i / 3, w: 0.4, h: 1 / 3 })), - ] }, - - { id: 'grid-4', cells: rows(2, 2) }, - { id: 'grid-6', cells: rows(3, 3) }, - { id: 'grid-9', cells: rows(3, 3, 3) }, - { id: 'grid-8', cells: rows(4, 4) }, - - { id: 'one-two', cells: [ - { x: 0, y: 0, w: 0.5, h: 1 }, - { x: 0.5, y: 0, w: 0.5, h: 0.5 }, - { x: 0.5, y: 0.5, w: 0.5, h: 0.5 }, - ] }, - { id: 'two-one', cells: [ - { x: 0, y: 0, w: 0.5, h: 0.5 }, - { x: 0, y: 0.5, w: 0.5, h: 0.5 }, - { x: 0.5, y: 0, w: 0.5, h: 1 }, - ] }, - { id: 'rows-1-2', cells: [ - { x: 0, y: 0, w: 1, h: 0.5 }, - { x: 0, y: 0.5, w: 0.5, h: 0.5 }, - { x: 0.5, y: 0.5, w: 0.5, h: 0.5 }, - ] }, - { id: 'rows-2-1', cells: [ - { x: 0, y: 0, w: 0.5, h: 0.5 }, - { x: 0.5, y: 0, w: 0.5, h: 0.5 }, - { x: 0, y: 0.5, w: 1, h: 0.5 }, - ] }, - { id: 'rows-2-3', cells: [...rows(2).map(c => ({ ...c, h: 0.52 })), - ...[0, 1, 2].map(i => ({ x: i / 3, y: 0.52, w: 1 / 3, h: 0.48 }))] }, - { id: 'rows-3-2', cells: [...[0, 1, 2].map(i => ({ x: i / 3, y: 0, w: 1 / 3, h: 0.48 })), - ...rows(2).map(c => ({ ...c, y: 0.48, h: 0.52 }))] }, - - { id: 'cols-1-2', cells: cols(1, 2) }, - { id: 'cols-2-1', cells: cols(2, 1) }, - { id: 'cols-2-1-2', cells: cols(2, 1, 2) }, - { id: 'cols-1-2-1', cells: cols(1, 2, 1) }, - - // A band across the middle: three pictures at the same height with room above - // and below for words. The arrangement a chapter opener usually wants. - { id: 'band-three', cells: [0, 1, 2].map(i => ({ x: i / 3, y: 0.26, w: 1 / 3, h: 0.48 })) }, - { id: 'band-four', cells: [0, 1, 2, 3].map(i => ({ x: i / 4, y: 0.3, w: 0.25, h: 0.4 })) }, - - // Staggered: alternate cells drop by a fraction of their height, which is what - // stops a row of four squares reading as a contact sheet. - { id: 'stagger-four', cells: [0, 1, 2, 3].map(i => ({ - x: i / 4, y: i % 2 ? 0.14 : 0, w: 0.25, h: 0.86, - })) }, - { id: 'stagger-three', cells: [0, 1, 2].map(i => ({ - x: i / 3, y: i === 1 ? 0.16 : 0, w: 1 / 3, h: 0.84, - })) }, - - { id: 'filmstrip', cells: [0, 1, 2, 3, 4].map(i => ({ x: i / 5, y: 0.32, w: 0.2, h: 0.36 })) }, - { id: 'grid-12', cells: rows(4, 4, 4) }, -] - -const uid = (p: string) => `${p}-${Math.random().toString(36).slice(2, 9)}` - -/** - * Turn a grid into elements, inside a box on the page. - * - * The gutter is taken out of each cell rather than added between them, so the - * block occupies exactly the box it was given — a grid asked to fill the safe - * area fills the safe area, instead of overhanging it by one gutter. - */ -export function gridElements( - grid: GridDef, - box: { x: number; y: number; w: number; h: number }, - gutter = 3, - radius = 0, -): BookElement[] { - const half = gutter / 2 - return grid.cells.map(cell => { - const x = box.x + cell.x * box.w - const y = box.y + cell.y * box.h - const w = cell.w * box.w - const h = cell.h * box.h - // Only interior edges get half a gutter; the block's outer edge is the box. - const left = cell.x > 0.001 ? half : 0 - const top = cell.y > 0.001 ? half : 0 - const right = cell.x + cell.w < 0.999 ? half : 0 - const bottom = cell.y + cell.h < 0.999 ? half : 0 - return { - id: uid('p'), - kind: 'photo', - frame: { - x: x + left, - y: y + top, - w: Math.max(2, w - left - right), - h: Math.max(2, h - top - bottom), - }, - rotation: 0, - opacity: 1, - locked: false, - photoId: null, - fit: 'cover', - focalX: 0.5, - focalY: 0.5, - radius, - filter: 'none', - mask: null, - frameStyle: 'none', - } as BookElement - }) -} - -/** - * The box a grid lands in when it is simply clicked rather than placed. - * - * The safe area of the page you are looking at, which is almost always what was - * meant — a grid that lands half in the gutter has to be dragged before it is - * any use. - */ -export function defaultGridBox(page: BookPageSetup, single: boolean): { x: number; y: number; w: number; h: number } { - const W = single ? page.pageWidth : page.pageWidth * 2 - const margin = Math.max(page.safe, Math.min(W, page.pageHeight) * 0.075) - return { - x: margin, - y: margin, - w: W - margin * 2, - h: page.pageHeight - margin * 2, - } -} diff --git a/client/src/components/Studio/savedLayouts.ts b/client/src/components/Studio/savedLayouts.ts deleted file mode 100644 index be80eb516..000000000 --- a/client/src/components/Studio/savedLayouts.ts +++ /dev/null @@ -1,131 +0,0 @@ -import type { BookElement, BookLayout, BookPageSetup, BookSpread } from '@trek/shared' -import { MAX_SPREAD_ELEMENTS } from '@trek/shared' -import { elementId } from './bookIds' - -/** - * A book's own layouts (#2316): a page somebody arranged, kept to lay other - * pages out the same way. - * - * The built-in layouts in templates.ts are frames only. A saved one is the whole - * design, panels and shapes and type included, with the pictures taken out; the - * page it is applied to brings its own. - */ - -/** Which saved layouts fit a spread: a double spread's design does not fit a single page. */ -export const layoutRole = (spread: BookSpread): BookLayout['role'] => (spread.role === 'inner' ? 'inner' : 'single') - -/** The arrangement of a spread, as a layout to keep. */ -export function layoutFromSpread(spread: BookSpread, page: BookPageSetup, name: string): BookLayout { - return { - id: elementId('ly'), - name, - role: layoutRole(spread), - pageWidth: page.pageWidth, - pageHeight: page.pageHeight, - background: spread.background, - // The design, not the pictures: every frame comes out empty. - elements: spread.elements.map(el => (el.kind === 'photo' ? { ...el, photoId: null } : el)), - } -} - -/** The next free "Layout n" name, so a save needs no dialog. */ -export function nextLayoutName(existing: readonly BookLayout[], base: string): string { - const taken = new Set(existing.map(l => l.name)) - let n = existing.length + 1 - while (taken.has(`${base} ${n}`)) n += 1 - return `${base} ${n}` -} - -const textSize = (el: BookElement) => (el.kind === 'text' ? el.size : 0) - -/** - * Lay a spread out on a saved layout, keeping what the spread holds. - * - * The same contract as applying a built-in layout: the spread's pictures go into - * the layout's frames in order, its words into the layout's text boxes (the - * largest into the largest, so the title stays the title), and whatever does not - * fit is parked rather than lost. The layout's own type keeps its look and takes - * the spread's words; a text box that was filled from a journal entry and finds - * nothing to take is left out, because its words belonged to the page it was - * saved from. The small marks and the pros and cons list are content too: each - * takes the values of the spread's own mark of the same kind, in order (the - * spread's day, its coordinates), and keeps the layout's look. Everything else - * is the design and is copied as drawn, scaled from the page it was drawn on to - * this one. - */ -export function applySavedLayout(spread: BookSpread, layout: BookLayout, page: BookPageSetup): BookSpread { - const sx = page.pageWidth / layout.pageWidth - const sy = page.pageHeight / layout.pageHeight - const pool = [...spread.elements, ...(spread.parked ?? [])] - const photos = pool.filter(e => e.kind === 'photo' && e.photoId != null) - const texts = pool.filter(e => e.kind === 'text' && e.text.trim()).sort((a, b) => textSize(b) - textSize(a)) - - // Which of the layout's text boxes takes which of the spread's words. - const slots = layout.elements.filter(e => e.kind === 'text').sort((a, b) => textSize(b) - textSize(a)) - const wordsFor = new Map() - slots.forEach((slot, i) => { if (texts[i]) wordsFor.set(slot, texts[i]) }) - - // The spread's own marks and lists, queued per kind, for the layout's to take their values from. - const marks = new Map() - for (const el of spread.elements) { - const key = el.kind === 'badge' ? `badge:${el.variant}` : el.kind === 'list' ? 'list' : null - if (key) marks.set(key, [...(marks.get(key) ?? []), el]) - } - const takeMark = (key: string) => marks.get(key)?.shift() - - let pi = 0 - const out: BookElement[] = [] - for (const el of layout.elements) { - const frame = { x: el.frame.x * sx, y: el.frame.y * sy, w: el.frame.w * sx, h: el.frame.h * sy } - const base = { ...el, id: elementId(el.kind[0]), frame } as BookElement - if (base.kind === 'photo') { - const src = photos[pi++] - out.push(src && src.kind === 'photo' - ? { ...base, photoId: src.photoId, focalX: src.focalX, focalY: src.focalY } - : base) - continue - } - if (base.kind === 'text' && el.kind === 'text') { - const src = wordsFor.get(el) - if (src && src.kind === 'text') { - out.push({ ...base, text: src.text, binding: src.binding, overridden: src.overridden }) - } else if (!el.binding) { - out.push(base) - } - continue - } - if (base.kind === 'badge') { - const own = takeMark(`badge:${base.variant}`) - out.push(own && own.kind === 'badge' ? { ...base, text: own.text, sub: own.sub, code: own.code } : base) - continue - } - if (base.kind === 'list') { - const own = takeMark('list') - out.push(own && own.kind === 'list' ? { ...base, items: own.items } : base) - continue - } - out.push(base) - } - - const used = new Set(wordsFor.values()) - const parked = [ - ...photos.slice(pi), - ...texts.filter(t => !used.has(t)), - ].slice(0, MAX_SPREAD_ELEMENTS) - - return { ...spread, background: layout.background, elements: out, parked } -} - -/** A saved layout drawn as a spread on this book's page, for its card. Nothing is filled in. */ -export function layoutPreview(layout: BookLayout, page: BookPageSetup): BookSpread { - const sx = page.pageWidth / layout.pageWidth - const sy = page.pageHeight / layout.pageHeight - return { - id: layout.id, - role: layout.role === 'inner' ? 'inner' : 'first', - background: layout.background, - elements: layout.elements.map(el => ({ ...el, frame: { x: el.frame.x * sx, y: el.frame.y * sy, w: el.frame.w * sx, h: el.frame.h * sy } }) as BookElement), - parked: [], - entryId: null, - } -} diff --git a/client/src/components/Studio/studioClipboard.ts b/client/src/components/Studio/studioClipboard.ts index ba96d06e9..96937cd57 100644 --- a/client/src/components/Studio/studioClipboard.ts +++ b/client/src/components/Studio/studioClipboard.ts @@ -1,5 +1,5 @@ import type { BookElement, BookPageSetup, BookSpread } from '@trek/shared' -import { elementId } from './bookIds' +import { bookElementId as elementId } from '@trek/shared' /** * What was copied, and from where (#2316). diff --git a/client/src/pages/journeyStudio/useJourneyStudio.ts b/client/src/pages/journeyStudio/useJourneyStudio.ts index b4ffa306a..7162c9431 100644 --- a/client/src/pages/journeyStudio/useJourneyStudio.ts +++ b/client/src/pages/journeyStudio/useJourneyStudio.ts @@ -1,12 +1,11 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useLocation, useNavigate, useParams } from 'react-router' import type { BookElement, BookPageNumbers, JourneyStats } from '@trek/shared' -import { bookPageSetupSchema } from '@trek/shared' +import { bookElementId as elementId, bookPageSetupSchema } from '@trek/shared' import { journeyApi } from '../../api/client' import { useJourneyStore, type GalleryPhoto, type JourneyEntry, type JourneyPhoto } from '../../store/journeyStore' import { useStudioStore } from '../../store/studioStore' import { useToast } from '../../components/shared/Toast' -import { elementId } from '../../components/Studio/bookIds' import { uploadStudioPhotos, type StudioUploader } from '../../components/Studio/studioUpload' import { useBookStore } from '../../components/Studio/useBookStore' import { useBookPresence } from '../../components/Studio/useBookPresence' diff --git a/client/src/store/studioStore.ts b/client/src/store/studioStore.ts index 3cb9fe08d..ac54abe05 100644 --- a/client/src/store/studioStore.ts +++ b/client/src/store/studioStore.ts @@ -1,8 +1,10 @@ import { create } from 'zustand' import type { BookDocument, BookElement, BookFrame, BookLayout, BookSpread } from '@trek/shared' -import { MAX_BOOK_LAYOUTS, MAX_SPREAD_ELEMENTS } from '@trek/shared' +import { + MAX_BOOK_LAYOUTS, MAX_SPREAD_ELEMENTS, applySavedBookLayout as applySavedLayout, + bookLayoutFromSpread as layoutFromSpread, canEditSpread as isMovableSpread, innerSpreadBounds as innerBounds, +} from '@trek/shared' import { pastedElements, type StudioClipboard } from '../components/Studio/studioClipboard' -import { applySavedLayout, layoutFromSpread } from '../components/Studio/savedLayouts' /** * The book being edited. @@ -92,33 +94,9 @@ interface StudioState { canRedo: () => boolean } -/** - * Where an inner spread may go: after the cover and the first page, before - * the last page and the back cover. - * - * Read from the document each time rather than assumed, because a book from - * before the single first page existed has none, and a book with no covers at - * all (the tests build those) has nothing to stay inside of. The lower bound - * is not decoration either: a book with no inner spreads yet reports its last - * inner one as -1, which asked for a page at 0 — in front of the cover, where - * it could not be moved back from, because a move only ever swaps with - * another inner spread. - */ -function innerBounds(spreads: readonly BookSpread[]): { first: number; limit: number } { - let first = 0 - for (let i = 0; i < spreads.length; i++) { - const role = spreads[i].role - if (role === 'cover' || role === 'first') first = i + 1 - else break - } - let limit = spreads.length - for (let i = spreads.length - 1; i >= 0; i--) { - const role = spreads[i].role - if (role === 'back' || role === 'last') limit = i - else break - } - return { first, limit: Math.max(first, limit) } -} +// Where an inner spread may go (`innerBounds`) and which spreads may move at +// all (`isMovableSpread`) are shared with the server's book tools +// (shared/src/book/book-pages.ts), so the assistant keeps the same pages fixed. function replaceSpread(doc: BookDocument, index: number, fn: (s: BookSpread) => BookSpread): BookDocument { const spreads = doc.spreads.slice() @@ -289,10 +267,7 @@ export const useStudioStore = create((set, get) => ({ * rail, the menu and the store all ask it rather than each testing `role` * for themselves. */ - canEditSpread: index => { - const sp = get().doc?.spreads[index] - return !!sp && sp.role === 'inner' - }, + canEditSpread: index => isMovableSpread(get().doc?.spreads[index]), addSpread: index => { const doc = get().doc diff --git a/client/tests/unit/studio/grids.test.ts b/client/tests/unit/studio/grids.test.ts deleted file mode 100644 index d8af2ff50..000000000 --- a/client/tests/unit/studio/grids.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { describe, it, expect } from 'vitest' -import type { BookPageSetup } from '@trek/shared' -import { GRIDS, defaultGridBox, gridElements } from '../../../src/components/Studio/grids' -import { bookPageSetupSchema } from '@trek/shared' - -/** - * Photo grids (#1973). - * - * The rules worth pinning are geometric: a grid must tile the box it is given - * without gaps or overlaps, and the block it produces must occupy exactly that - * box — a grid asked to fill the safe area that overhangs it by one gutter is - * a grid that has to be dragged before it is any use. - */ - -const page: BookPageSetup = bookPageSetupSchema.parse({ - preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5, -}) - -const box = { x: 0, y: 0, w: 100, h: 100 } - -describe('the grid catalogue', () => { - it('gives every grid a unique id', () => { - const ids = GRIDS.map(g => g.id) - expect(new Set(ids).size).toBe(ids.length) - }) - - it('gives every grid at least one cell', () => { - for (const grid of GRIDS) expect(grid.cells.length, grid.id).toBeGreaterThan(0) - }) - - it('keeps every cell inside the unit block', () => { - for (const grid of GRIDS) { - for (const cell of grid.cells) { - expect(cell.x, grid.id).toBeGreaterThanOrEqual(0) - expect(cell.y, grid.id).toBeGreaterThanOrEqual(0) - expect(cell.x + cell.w, grid.id).toBeLessThanOrEqual(1.0001) - expect(cell.y + cell.h, grid.id).toBeLessThanOrEqual(1.0001) - expect(cell.w, grid.id).toBeGreaterThan(0) - expect(cell.h, grid.id).toBeGreaterThan(0) - } - } - }) - - /* - * Tiling grids cover the block exactly. The staggered and band arrangements - * deliberately do not — they are compositions with air around them — so they - * are checked for "no overlap" only, further down. - */ - it('tiles the block completely, for the grids that are meant to', () => { - const tiling = GRIDS.filter(g => !/stagger|band|filmstrip/.test(g.id)) - for (const grid of tiling) { - const area = grid.cells.reduce((sum, c) => sum + c.w * c.h, 0) - expect(area, grid.id).toBeCloseTo(1, 5) - } - }) - - it('never overlaps two cells', () => { - for (const grid of GRIDS) { - for (let i = 0; i < grid.cells.length; i++) { - for (let j = i + 1; j < grid.cells.length; j++) { - const a = grid.cells[i] - const b = grid.cells[j] - const overlapX = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x) - const overlapY = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y) - const overlaps = overlapX > 0.0001 && overlapY > 0.0001 - expect(overlaps, `${grid.id}: cell ${i} and ${j}`).toBe(false) - } - } - } - }) -}) - -describe('gridElements', () => { - it('makes one empty photo frame per cell', () => { - const els = gridElements(GRIDS.find(g => g.id === 'grid-4')!, box, 0) - expect(els).toHaveLength(4) - for (const el of els) { - expect(el.kind).toBe('photo') - expect(el.kind === 'photo' && el.photoId).toBeNull() - } - }) - - it('gives every frame its own id', () => { - const els = gridElements(GRIDS.find(g => g.id === 'grid-9')!, box, 0) - expect(new Set(els.map(e => e.id)).size).toBe(els.length) - }) - - it('fills the box exactly when there is no gutter', () => { - const els = gridElements(GRIDS.find(g => g.id === 'grid-4')!, box, 0) - const left = Math.min(...els.map(e => e.frame.x)) - const top = Math.min(...els.map(e => e.frame.y)) - const right = Math.max(...els.map(e => e.frame.x + e.frame.w)) - const bottom = Math.max(...els.map(e => e.frame.y + e.frame.h)) - expect(left).toBeCloseTo(0, 6) - expect(top).toBeCloseTo(0, 6) - expect(right).toBeCloseTo(100, 6) - expect(bottom).toBeCloseTo(100, 6) - }) - - /* - * The gutter comes out of the cells, not out of the block. Added between them - * instead, a four-across grid would be three gutters wider than the box it - * was told to fill, and would hang off the safe area it was aligned to. - */ - it('keeps the block inside its box once a gutter is applied', () => { - const els = gridElements(GRIDS.find(g => g.id === 'grid-6')!, box, 4) - for (const el of els) { - expect(el.frame.x).toBeGreaterThanOrEqual(-0.001) - expect(el.frame.y).toBeGreaterThanOrEqual(-0.001) - expect(el.frame.x + el.frame.w).toBeLessThanOrEqual(100.001) - expect(el.frame.y + el.frame.h).toBeLessThanOrEqual(100.001) - } - }) - - it('leaves the block edges flush and only splits the interior gaps', () => { - const gutter = 6 - const els = gridElements(GRIDS.find(g => g.id === 'two-across')!, box, gutter) - const [a, b] = [...els].sort((x, y) => x.frame.x - y.frame.x) - // Outer edges untouched... - expect(a.frame.x).toBeCloseTo(0, 6) - expect(b.frame.x + b.frame.w).toBeCloseTo(100, 6) - // ...and exactly one gutter between them. - expect(b.frame.x - (a.frame.x + a.frame.w)).toBeCloseTo(gutter, 6) - }) - - it('offsets the whole block when the box does not start at the origin', () => { - const els = gridElements(GRIDS.find(g => g.id === 'single')!, { x: 20, y: 30, w: 50, h: 40 }, 0) - expect(els[0].frame).toMatchObject({ x: 20, y: 30, w: 50, h: 40 }) - }) - - it('never produces a frame too small to see, even from a tiny box', () => { - const els = gridElements(GRIDS.find(g => g.id === 'grid-12')!, { x: 0, y: 0, w: 8, h: 8 }, 6) - for (const el of els) { - expect(el.frame.w).toBeGreaterThanOrEqual(2) - expect(el.frame.h).toBeGreaterThanOrEqual(2) - } - }) - - it('carries the corner radius onto every frame', () => { - const els = gridElements(GRIDS.find(g => g.id === 'grid-4')!, box, 3, 5) - for (const el of els) expect(el.kind === 'photo' && el.radius).toBe(5) - }) - - it('produces frames for every grid in the catalogue without a bad number', () => { - for (const grid of GRIDS) { - for (const el of gridElements(grid, box, 3)) { - for (const v of [el.frame.x, el.frame.y, el.frame.w, el.frame.h]) { - expect(Number.isFinite(v), grid.id).toBe(true) - } - } - } - }) -}) - -describe('defaultGridBox', () => { - it('spans both pages of a spread', () => { - const b = defaultGridBox(page, false) - expect(b.x + b.w).toBeCloseTo(page.pageWidth * 2 - b.x, 6) - expect(b.w).toBeGreaterThan(page.pageWidth) - }) - - it('stays on one page for a cover', () => { - const b = defaultGridBox(page, true) - expect(b.x + b.w).toBeLessThanOrEqual(page.pageWidth) - }) - - it('keeps clear of the safe area rather than landing on it', () => { - const b = defaultGridBox(page, true) - expect(b.x).toBeGreaterThanOrEqual(page.safe) - expect(b.y).toBeGreaterThanOrEqual(page.safe) - }) - - it('is symmetric, so a grid dropped into it looks centred', () => { - const b = defaultGridBox(page, false) - expect(b.x).toBeCloseTo(page.pageWidth * 2 - (b.x + b.w), 6) - expect(b.y).toBeCloseTo(page.pageHeight - (b.y + b.h), 6) - }) -}) diff --git a/client/tests/unit/studio/pagePresetSizes.test.ts b/client/tests/unit/studio/pagePresetSizes.test.ts new file mode 100644 index 000000000..b8260b1f9 --- /dev/null +++ b/client/tests/unit/studio/pagePresetSizes.test.ts @@ -0,0 +1,20 @@ +/** + * PRESETS-001 + * + * The format sizes live in two places: the Studio's picker here, and shared, + * where the assistant's create_book lays a new book down. This holds them to + * the same millimetres, so a book made either way has the same trim. + */ +import { BOOK_PAGE_PRESET_SIZES } from '@trek/shared'; +import { describe, expect, it } from 'vitest'; +import { PAGE_PRESETS } from '../../../src/components/Studio/pagePresets'; + +describe('page format sizes', () => { + it('PRESETS-001: match the shared table a new book is laid down with, format for format', () => { + const fromStudio: Record = {}; + for (const p of Object.values(PAGE_PRESETS)) { + fromStudio[p.id] = { pageWidth: p.pageWidthMm, pageHeight: p.pageHeightMm, bleed: p.bleedMm, safe: p.safeMm }; + } + expect(BOOK_PAGE_PRESET_SIZES).toEqual(fromStudio); + }); +}); diff --git a/client/tests/unit/studio/studioClipboardLayouts.test.ts b/client/tests/unit/studio/studioClipboardLayouts.test.ts index 1a82dbc23..c25d25ead 100644 --- a/client/tests/unit/studio/studioClipboardLayouts.test.ts +++ b/client/tests/unit/studio/studioClipboardLayouts.test.ts @@ -3,11 +3,10 @@ import type { BookDocument, BookElement, BookPageSetup, BookSpread } from '@trek import { MAX_BOOK_LAYOUTS, bookBadgeElementSchema, bookLayoutSchema, bookPageSetupSchema, bookPhotoElementSchema, bookShapeElementSchema, bookTextElementSchema, + applySavedBookLayout as applySavedLayout, bookLayoutFromSpread as layoutFromSpread, + bookLayoutPreview as layoutPreview, bookLayoutRole as layoutRole, nextBookLayoutName as nextLayoutName, } from '@trek/shared' import { pastedElements } from '../../../src/components/Studio/studioClipboard' -import { - applySavedLayout, layoutFromSpread, layoutPreview, layoutRole, nextLayoutName, -} from '../../../src/components/Studio/savedLayouts' import { useStudioStore } from '../../../src/store/studioStore' /** diff --git a/client/tests/unit/studio/templates.test.ts b/client/tests/unit/studio/templates.test.ts deleted file mode 100644 index e91052e52..000000000 --- a/client/tests/unit/studio/templates.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { describe, it, expect } from 'vitest' -import type { BookPageSetup } from '@trek/shared' -import { bookPageSetupSchema } from '@trek/shared' -import { COVER_TEMPLATES, TEMPLATES, applyTemplate } from '../../../src/components/Studio/templates' - -/** - * Layouts (#1973). - * - * The cover set exists because a cover is a different problem from a spread: - * one page, type as the subject rather than as a caption. A spread layout - * applied to it lands half its frames past the edge of the page, which is why - * the two sets are kept apart rather than filtered out of one. - */ - -const page: BookPageSetup = bookPageSetupSchema.parse({ - preset: 'square-210', pageWidth: 210, pageHeight: 210, -}) - -describe('the layout catalogue', () => { - it('gives every layout a unique id across both sets', () => { - const ids = [...TEMPLATES, ...COVER_TEMPLATES].map(t => t.id) - expect(new Set(ids).size).toBe(ids.length) - }) - - it('gives every layout at least one slot', () => { - for (const tpl of [...TEMPLATES, ...COVER_TEMPLATES]) { - expect(tpl.build(page).length, tpl.id).toBeGreaterThan(0) - } - }) - - it('counts its photo slots honestly', () => { - for (const tpl of [...TEMPLATES, ...COVER_TEMPLATES]) { - const drawn = tpl.build(page).filter(s => s.kind === 'photo').length - expect(drawn, tpl.id).toBe(tpl.photoSlots) - } - }) - - it('gives every slot a frame with a positive size', () => { - for (const tpl of [...TEMPLATES, ...COVER_TEMPLATES]) { - for (const slot of tpl.build(page)) { - expect(slot.frame.w, tpl.id).toBeGreaterThan(0) - expect(slot.frame.h, tpl.id).toBeGreaterThan(0) - } - } - }) -}) - -describe('the cover set', () => { - it('is marked as single-page throughout', () => { - for (const tpl of COVER_TEMPLATES) { - expect(tpl.role, tpl.id).toBe('single') - } - }) - - /* - * The rule that makes them a separate set: everything stays on one page. - * A frame reaching past pageWidth would be half in the fold of a book that - * has no fold there. - */ - it('keeps every frame on the one page', () => { - for (const tpl of COVER_TEMPLATES) { - for (const slot of tpl.build(page)) { - expect(slot.frame.x, `${tpl.id} x`).toBeGreaterThanOrEqual(-page.bleed - 0.01) - expect(slot.frame.x + slot.frame.w, `${tpl.id} right`) - .toBeLessThanOrEqual(page.pageWidth + page.bleed + 0.01) - expect(slot.frame.y + slot.frame.h, `${tpl.id} bottom`) - .toBeLessThanOrEqual(page.pageHeight + page.bleed + 0.01) - } - } - }) - - it('always has somewhere for the title', () => { - for (const tpl of COVER_TEMPLATES) { - expect(tpl.build(page).some(s => s.kind === 'heading'), tpl.id).toBe(true) - } - }) - - it('includes one that needs no photograph at all', () => { - expect(COVER_TEMPLATES.some(t => t.photoSlots === 0)).toBe(true) - }) -}) - -describe('the spread set', () => { - it('is not marked single, so the panel keeps the two apart', () => { - for (const tpl of TEMPLATES) { - expect(tpl.role ?? 'inner', tpl.id).toBe('inner') - } - }) - - it('stays within the spread', () => { - for (const tpl of TEMPLATES) { - for (const slot of tpl.build(page)) { - expect(slot.frame.x + slot.frame.w, `${tpl.id} right`) - .toBeLessThanOrEqual(page.pageWidth * 2 + page.bleed + 0.01) - } - } - }) - - it('offers a range of photo counts rather than five versions of one idea', () => { - const counts = new Set(TEMPLATES.map(t => t.photoSlots)) - expect(counts.size).toBeGreaterThanOrEqual(6) - }) -}) - -describe('applying a cover layout', () => { - const spread = { - id: 'c1', role: 'cover' as const, background: null, parked: [], entryId: null, - elements: [ - { - id: 'p1', kind: 'photo' as const, frame: { x: 0, y: 0, w: 50, h: 50 }, - rotation: 0, opacity: 1, locked: false, photoId: 42, fit: 'cover' as const, - focalX: 0.5, focalY: 0.5, radius: 0, filter: 'none' as const, - mask: null, frameStyle: 'none' as const, - }, - { - id: 't1', kind: 'text' as const, frame: { x: 0, y: 60, w: 100, h: 20 }, - rotation: 0, opacity: 1, locked: false, text: 'Iceland', font: 'sans' as const, - size: 30, weight: 700 as const, italic: false, align: 'left' as const, - leading: 1.1, tracking: 0, color: '#111111', binding: null, overridden: false, - }, - ], - } - - it('pours the existing picture and title into the new arrangement', () => { - const next = applyTemplate(spread, COVER_TEMPLATES[0], page) - const photo = next.elements.find(e => e.kind === 'photo') - const text = next.elements.find(e => e.kind === 'text') - expect(photo?.kind === 'photo' && photo.photoId).toBe(42) - expect(text?.kind === 'text' && text.text).toBe('Iceland') - }) - - /* - * Trying the type-only cover must not cost the photograph — it comes back - * the moment a layout with a frame is applied. - */ - it('parks what a type-only cover has no room for', () => { - const quiet = COVER_TEMPLATES.find(t => t.photoSlots === 0)! - const next = applyTemplate(spread, quiet, page) - expect(next.elements.some(e => e.kind === 'photo')).toBe(false) - expect(next.parked.some(e => e.kind === 'photo' && e.photoId === 42)).toBe(true) - }) - - it('brings the parked picture back on a layout that has room', () => { - const quiet = COVER_TEMPLATES.find(t => t.photoSlots === 0)! - const parked = applyTemplate(spread, quiet, page) - const back = applyTemplate(parked, COVER_TEMPLATES[0], page) - const photo = back.elements.find(e => e.kind === 'photo') - expect(photo?.kind === 'photo' && photo.photoId).toBe(42) - }) -}) diff --git a/scripts/ci/knip-baseline.json b/scripts/ci/knip-baseline.json index a33c09736..a48c67e7b 100644 --- a/scripts/ci/knip-baseline.json +++ b/scripts/ci/knip-baseline.json @@ -148,9 +148,7 @@ "client/src/components/Roadtrip/useFollowTrack.ts": 1, "client/src/components/Roadtrip/useRoadtripRoutes.ts": 3, "client/src/components/Studio/StudioCanvas.tsx": 1, - "client/src/components/Studio/grids.ts": 1, "client/src/components/Studio/mapTiles.ts": 1, - "client/src/components/Studio/templates.ts": 1, "client/src/components/Trips/useTripShare.ts": 2, "client/src/components/shared/DialogShell.tsx": 1, "client/src/components/shared/SlidingTabs.tsx": 1, diff --git a/shared/src/book/book-findings.ts b/shared/src/book/book-findings.ts new file mode 100644 index 000000000..3973b809b --- /dev/null +++ b/shared/src/book/book-findings.ts @@ -0,0 +1,387 @@ +import type { BookPhotoInfo } from './book-ops-core'; +import { BOOK_OP_LIMITS, type BookFinding } from './book-ops.schema'; +import type { BookElement, BookPageSetup, BookPhotoElement, BookSpread, BookTextElement } from './book.schema'; + +/** + * What is likely wrong with a page, worked out from the document alone. + * + * The checks a person makes looking at a proof, as far as the numbers allow: + * pictures on top of each other, words in the fold or past the trim, frames + * left empty, a picture printed larger than its pixels carry, the interesting + * part of a picture cut off, words that will not fit their box. A model that + * cannot see the page checks its work against these; one that can gets them + * beside the image. + * + * Severity says what to do: `severe` is fixed before a change is proposed, + * `warn` is worth saying, `info` is context. All of it is estimation, a text + * is measured with an average letter width, not set, so a finding reads as + * "likely", never as a verdict. + */ + +/** Below this a print looks soft at arm's length. */ +export const MIN_PRINT_DPI = 200; + +interface Box { + x: number; + y: number; + w: number; + h: number; +} + +/** The box an element occupies on the page, turned with it when it is rotated. */ +function elementBox(el: BookElement): Box { + const { x, y, w, h } = el.frame; + if (!el.rotation) return { x, y, w, h }; + const rad = (el.rotation * Math.PI) / 180; + const cos = Math.abs(Math.cos(rad)); + const sin = Math.abs(Math.sin(rad)); + const bw = w * cos + h * sin; + const bh = w * sin + h * cos; + return { x: x + w / 2 - bw / 2, y: y + h / 2 - bh / 2, w: bw, h: bh }; +} + +const area = (b: Box) => Math.max(0, b.w) * Math.max(0, b.h); + +function overlapArea(a: Box, b: Box): number { + const w = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x); + const h = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y); + return w > 0 && h > 0 ? w * h : 0; +} + +const contains = (b: Box, px: number, py: number) => px >= b.x && px <= b.x + b.w && py >= b.y && py <= b.y + b.h; + +const mm = (v: number) => `${Math.round(v * 10) / 10}`; + +/** Words, shapes aside: what reads as content rather than decoration. */ +const WORDS = new Set(['text', 'badge', 'stats', 'list', 'countries', 'icon']); + +/** The effective resolution of a photo at its printed size, or null when the original's size is unknown. */ +export function photoDpi(el: BookPhotoElement, info: BookPhotoInfo | undefined): number | null { + if (!info?.width || !info.height || el.frame.w <= 0 || el.frame.h <= 0) return null; + const mmPerPx = + el.fit === 'contain' + ? Math.min(el.frame.w / info.width, el.frame.h / info.height) + : Math.max(el.frame.w / info.width, el.frame.h / info.height); + return Math.round(25.4 / mmPerPx); +} + +/** Average letter width per em, per family: enough to tell one line from three. */ +const LETTER_EM: Record = { + sans: 0.5, + inter: 0.52, + serif: 0.47, + garamond: 0.44, + playfair: 0.5, + display: 0.48, + bebas: 0.38, +}; + +/** A number the text carries, or the schema's default when it carries none (a layout's meta line sets no leading). */ +const orDefault = (v: number | undefined, fallback: number) => (Number.isFinite(v) ? (v as number) : fallback); + +/** How many millimetres a text needs to be set at its frame's width, roughly. */ +function textHeightNeeded(el: BookTextElement): number { + const sizeMm = (orDefault(el.size, 11) * 25.4) / 72; + const tracking = orDefault(el.tracking, 0); + const letter = sizeMm * ((LETTER_EM[el.font] ?? 0.5) * (el.weight >= 600 ? 1.06 : 1) + tracking); + const perLine = Math.max(1, Math.floor(el.frame.w / Math.max(letter, 0.1))); + const lines = el.text + .split('\n') + .reduce((sum, para) => sum + Math.max(1, Math.ceil(para.trimEnd().length / perLine)), 0); + return lines * sizeMm * orDefault(el.leading, 1.45); +} + +interface Ctx { + spread: BookSpread; + page: BookPageSetup; + W: number; + H: number; + photos: readonly BookPhotoInfo[] | undefined; + out: BookFinding[]; +} + +function add(ctx: Ctx, f: BookFinding): void { + ctx.out.push({ ...f, message: f.message.slice(0, 300) }); +} + +const placedPhotos = (spread: BookSpread) => spread.elements.filter((e): e is BookPhotoElement => e.kind === 'photo'); + +/** + * A photo under a quarter of the size of the one it lies on is an inset: the + * auto layout sets a small picture over the hero on purpose. Context, not a + * fault; two pictures of a size on top of each other are. + */ +const INSET_RATIO = 0.25; + +function overlapFinding(a: BookElement, b: BookElement, ba: Box, bb: Box, shared: number): BookFinding { + const [small, large] = area(ba) <= area(bb) ? [a, b] : [b, a]; + const smaller = Math.min(area(ba), area(bb)); + const pct = Math.round((shared / Math.max(smaller, 1)) * 100); + if (a.kind === 'photo' && smaller < Math.max(area(ba), area(bb)) * INSET_RATIO) { + return { + code: 'overlap', + severity: 'info', + elementIds: [small.id, large.id], + message: `photo ${small.id} is set as an inset over ${large.id} (${pct}% of it on the larger one)`, + }; + } + return { + code: 'overlap', + severity: 'severe', + elementIds: [a.id, b.id], + message: `${a.kind === 'photo' ? 'photos' : 'texts'} ${a.id} and ${b.id} overlap (${pct}% of the smaller one)`, + }; +} + +function overlaps(ctx: Ctx): void { + const content = ctx.spread.elements.filter((e) => e.kind === 'photo' || e.kind === 'text'); + let found = 0; + for (let i = 0; i < content.length && found < 6; i++) { + for (let j = i + 1; j < content.length && found < 6; j++) { + const a = content[i] as BookElement; + const b = content[j] as BookElement; + if (a.kind !== b.kind) continue; + const ba = elementBox(a); + const bb = elementBox(b); + const shared = overlapArea(ba, bb); + if (shared < 4 || shared < Math.min(area(ba), area(bb)) * 0.05) continue; + found += 1; + add(ctx, overlapFinding(a, b, ba, bb, shared)); + } + } +} + +/** + * Words across the fold are lost in the binding, unless their frame is centred + * on it: then the type is set across the spread on purpose (the band-quote + * layout does that), and the finding asks for a look rather than a fix. + */ +function wordsInFold(ctx: Ctx, el: BookElement, b: Box, x: number): void { + const centred = Math.abs(b.x + b.w / 2 - x) < 2; + add(ctx, { + code: 'fold', + severity: centred ? 'warn' : 'severe', + elementIds: [el.id], + message: centred + ? `${el.kind} ${el.id} is set across the spread, centred on the fold; check its words clear the binding` + : `${el.kind} ${el.id} runs across the fold at x ${mm(x)}; the binding swallows what sits there`, + }); +} + +/** + * A photo across the fold is a choice the built-in grids and the auto layout + * make themselves (a middle column, the picture crossing from the panel), so + * it is worth saying, never a fault; a focal point in the fold is said with it. + */ +function photoInFold(ctx: Ctx, el: BookPhotoElement, x: number): void { + const focalInFold = el.photoId != null && Math.abs(el.frame.x + el.focalX * el.frame.w - x) < 12; + if (el.frame.w >= ctx.page.pageWidth && !focalInFold) return; + let message = `the focal point of photo ${el.id} sits in the fold; move it with focalX`; + if (el.frame.w < ctx.page.pageWidth) { + message = focalInFold + ? `photo ${el.id} is split by the fold with its focal point in it; move it with focalX or keep it on one page` + : `photo ${el.id} is split by the fold; fine for a picture meant to cross, else keep it on one page`; + } + add(ctx, { code: 'fold', severity: 'warn', elementIds: [el.id], message }); +} + +function fold(ctx: Ctx): void { + if (ctx.spread.role !== 'inner') return; + const x = ctx.page.pageWidth; + for (const el of ctx.spread.elements) { + const b = elementBox(el); + if (!(b.x < x - 2 && b.x + b.w > x + 2)) continue; + if (WORDS.has(el.kind)) wordsInFold(ctx, el, b, x); + else if (el.kind === 'photo') photoInFold(ctx, el, x); + } +} + +function trimEdges(ctx: Ctx, el: BookElement, b: Box): void { + const { bleed, safe } = ctx.page; + const past = b.x < -0.5 || b.y < -0.5 || b.x + b.w > ctx.W + 0.5 || b.y + b.h > ctx.H + 0.5; + if (WORDS.has(el.kind)) { + if (past) { + add(ctx, { + code: 'trim', + severity: 'severe', + elementIds: [el.id], + message: `${el.kind} ${el.id} reaches past the trim and will be cut`, + }); + } else if (b.x < safe || b.y < safe || b.x + b.w > ctx.W - safe || b.y + b.h > ctx.H - safe) { + add(ctx, { + code: 'trim', + severity: 'warn', + elementIds: [el.id], + message: `${el.kind} ${el.id} is inside the ${mm(safe)} mm safe margin`, + }); + } + return; + } + if (el.kind !== 'photo' && el.kind !== 'shape') return; + // An edge past the trim should reach the bleed; one just short of the trim leaves a sliver of paper. + const gaps = [b.x, b.y, ctx.W - (b.x + b.w), ctx.H - (b.y + b.h)]; + const between = gaps.some((g) => g < -0.5 && g > -bleed + 0.5); + const sliver = gaps.some((g) => g > 0.05 && g < 1.5); + if (between || sliver) { + add(ctx, { + code: 'trim', + severity: 'warn', + elementIds: [el.id], + message: between + ? `${el.kind} ${el.id} goes past the trim but stops short of the ${mm(bleed)} mm bleed` + : `${el.kind} ${el.id} stops just short of the page edge; take it to the bleed or in by the margin`, + }); + } +} + +function trim(ctx: Ctx): void { + for (const el of ctx.spread.elements) { + const b = elementBox(el); + if (b.x + b.w < 0 || b.y + b.h < 0 || b.x > ctx.W || b.y > ctx.H) { + add(ctx, { + code: 'trim', + severity: 'warn', + elementIds: [el.id], + message: `${el.kind} ${el.id} lies off the page`, + }); + continue; + } + trimEdges(ctx, el, b); + } +} + +function emptyFrames(ctx: Ctx): void { + const empty = placedPhotos(ctx.spread).filter((e) => e.photoId == null); + if (!empty.length) return; + add(ctx, { + code: 'empty_frame', + severity: 'severe', + elementIds: empty.slice(0, 8).map((e) => e.id), + message: `${empty.length} empty photo frame${empty.length === 1 ? '' : 's'} (${empty + .slice(0, 8) + .map((e) => e.id) + .join(', ')}); an empty frame prints as nothing`, + }); +} + +/** The first element painted above `index` that hides the point, or undefined. */ +function coveredBy(spread: BookSpread, index: number, px: number, py: number): BookElement | undefined { + return spread.elements.slice(index + 1).find((e) => { + const opaque = + (e.kind === 'photo' && e.photoId != null) || + e.kind === 'map' || + (e.kind === 'shape' && e.fill != null && e.opacity >= 0.9 && e.shape !== 'line'); + return opaque && e.opacity >= 0.9 && contains(elementBox(e), px, py); + }); +} + +function photoChecks(ctx: Ctx): void { + ctx.spread.elements.forEach((el, index) => { + if (el.kind !== 'photo' || el.photoId == null) return; + const info = ctx.photos?.find((p) => p.photoId === el.photoId); + if (ctx.photos && !info) { + add(ctx, { + code: 'not_in_gallery', + severity: 'warn', + elementIds: [el.id], + message: `photo ${el.photoId} in ${el.id} is not in this journey's gallery`, + }); + } + if (info?.hidden) { + add(ctx, { + code: 'hidden_photo', + severity: 'info', + elementIds: [el.id], + message: `photo ${el.photoId} in ${el.id} is by another contributor and is not shown in the page image`, + }); + } + const dpi = photoDpi(el, info); + if (dpi !== null && dpi < MIN_PRINT_DPI) { + add(ctx, { + code: 'low_dpi', + severity: 'warn', + elementIds: [el.id], + message: `photo ${el.photoId} in ${el.id} prints at about ${dpi} dpi; under ${MIN_PRINT_DPI} it looks soft, make the frame smaller`, + }); + } + focal(ctx, el, index); + }); +} + +function focal(ctx: Ctx, el: BookPhotoElement, index: number): void { + const px = el.frame.x + el.focalX * el.frame.w; + const py = el.frame.y + el.focalY * el.frame.h; + const say = (why: string) => + add(ctx, { + code: 'focal_cut', + severity: 'warn', + elementIds: [el.id], + message: `the focal point of photo ${el.id} ${why}`, + }); + if (px < 0 || py < 0 || px > ctx.W || py > ctx.H) return say('lies past the trim; move it with focalX/focalY'); + if (el.mask === 'ellipse') { + const dx = (el.focalX - 0.5) * 2; + const dy = (el.focalY - 0.5) * 2; + if (dx * dx + dy * dy > 1) return say('falls outside the ellipse mask'); + } + const over = coveredBy(ctx.spread, index, px, py); + if (over) say(`is hidden under ${over.kind} ${over.id}`); +} + +function overflow(ctx: Ctx): void { + for (const el of ctx.spread.elements) { + if (el.kind !== 'text' || !el.text.trim()) continue; + const need = textHeightNeeded(el); + if (need <= el.frame.h * 1.1 + 0.5) continue; + add(ctx, { + code: 'text_overflow', + severity: 'severe', + elementIds: [el.id], + message: `text ${el.id} likely overflows: it needs about ${mm(need)} mm and its frame is ${mm(el.frame.h)} mm tall; enlarge the frame, shorten the text or set it smaller`, + }); + } +} + +function parked(ctx: Ctx): void { + const list = ctx.spread.parked; + if (!list.length) return; + const kinds = new Map(); + for (const el of list) kinds.set(el.kind, (kinds.get(el.kind) ?? 0) + 1); + const what = [...kinds].map(([k, n]) => `${n} ${k}`).join(', '); + add(ctx, { + code: 'parked', + severity: 'info', + elementIds: list.slice(0, 8).map((e) => e.id), + message: `${list.length} parked off the page (${what}); a layout with room brings them back`, + }); +} + +const SEVERITY_ORDER = { severe: 0, warn: 1, info: 2 } as const; + +/** Every finding on one page, severe first, at most `BOOK_OP_LIMITS.findings`. */ +export function spreadFindings( + spread: BookSpread, + page: BookPageSetup, + photos?: readonly BookPhotoInfo[], +): BookFinding[] { + const ctx: Ctx = { + spread, + page, + W: page.pageWidth * (spread.role === 'inner' ? 2 : 1), + H: page.pageHeight, + photos, + out: [], + }; + overlaps(ctx); + fold(ctx); + trim(ctx); + emptyFrames(ctx); + overflow(ctx); + photoChecks(ctx); + parked(ctx); + return ctx.out + .map((f, i) => ({ f, i })) + .sort((a, b) => SEVERITY_ORDER[a.f.severity] - SEVERITY_ORDER[b.f.severity] || a.i - b.i) + .map(({ f }) => f) + .slice(0, BOOK_OP_LIMITS.findings); +} diff --git a/shared/src/book/book-grids.spec.ts b/shared/src/book/book-grids.spec.ts new file mode 100644 index 000000000..893689dd6 --- /dev/null +++ b/shared/src/book/book-grids.spec.ts @@ -0,0 +1,180 @@ +import { BOOK_GRIDS, bookGridElements, defaultBookGridBox, findBookGrid, type BookGridDef } from './book-grids'; +import { bookPageSetupSchema, type BookPageSetup } from './book.schema'; + +import { describe, expect, it } from 'vitest'; + +/** + * Photo grids (#1973), moved with their code from the Studio + * (client/tests/unit/studio/grids.test.ts). + * + * The rules worth pinning are geometric: a grid must tile the box it is given + * without gaps or overlaps, and the block it produces must occupy exactly that + * box: a grid asked to fill the safe area that overhangs it by one gutter is + * a grid that has to be dragged before it is any use. + */ + +const page: BookPageSetup = bookPageSetupSchema.parse({ + preset: 'square-210', + pageWidth: 210, + pageHeight: 210, + bleed: 3, + safe: 5, +}); + +const box = { x: 0, y: 0, w: 100, h: 100 }; +const grid = (id: string) => findBookGrid(id) as BookGridDef; + +describe('the grid catalogue', () => { + it('BOOKGRID-001: gives every grid a unique id', () => { + const ids = BOOK_GRIDS.map((g) => g.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('BOOKGRID-002: gives every grid at least one cell', () => { + for (const g of BOOK_GRIDS) expect(g.cells.length, g.id).toBeGreaterThan(0); + }); + + it('BOOKGRID-003: keeps every cell inside the unit block', () => { + for (const g of BOOK_GRIDS) { + for (const cell of g.cells) { + expect(cell.x, g.id).toBeGreaterThanOrEqual(0); + expect(cell.y, g.id).toBeGreaterThanOrEqual(0); + expect(cell.x + cell.w, g.id).toBeLessThanOrEqual(1.0001); + expect(cell.y + cell.h, g.id).toBeLessThanOrEqual(1.0001); + expect(cell.w, g.id).toBeGreaterThan(0); + expect(cell.h, g.id).toBeGreaterThan(0); + } + } + }); + + /* + * Tiling grids cover the block exactly. The staggered and band arrangements + * deliberately do not, they are compositions with air around them, so they + * are checked for "no overlap" only, further down. + */ + it('BOOKGRID-004: tiles the block completely, for the grids that are meant to', () => { + const tiling = BOOK_GRIDS.filter((g) => !/stagger|band|filmstrip/.test(g.id)); + for (const g of tiling) { + const area = g.cells.reduce((sum, c) => sum + c.w * c.h, 0); + expect(area, g.id).toBeCloseTo(1, 5); + } + }); + + it('BOOKGRID-005: never overlaps two cells', () => { + for (const g of BOOK_GRIDS) { + g.cells.forEach((a, i) => { + g.cells.slice(i + 1).forEach((b, k) => { + const overlapX = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x); + const overlapY = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y); + const overlaps = overlapX > 0.0001 && overlapY > 0.0001; + expect(overlaps, `${g.id}: cell ${i} and ${i + 1 + k}`).toBe(false); + }); + }); + } + }); + + it('BOOKGRID-006: finds a grid by id, and nothing for an unknown id', () => { + expect(findBookGrid('grid-9')?.cells).toHaveLength(9); + expect(findBookGrid('nope')).toBeUndefined(); + }); +}); + +describe('bookGridElements', () => { + it('BOOKGRID-007: makes one empty photo frame per cell', () => { + const els = bookGridElements(grid('grid-4'), box, 0); + expect(els).toHaveLength(4); + for (const el of els) { + expect(el.kind).toBe('photo'); + expect(el.kind === 'photo' && el.photoId).toBeNull(); + } + }); + + it('BOOKGRID-008: gives every frame its own id', () => { + const els = bookGridElements(grid('grid-9'), box, 0); + expect(new Set(els.map((e) => e.id)).size).toBe(els.length); + }); + + it('BOOKGRID-009: fills the box exactly when there is no gutter', () => { + const els = bookGridElements(grid('grid-4'), box, 0); + expect(Math.min(...els.map((e) => e.frame.x))).toBeCloseTo(0, 6); + expect(Math.min(...els.map((e) => e.frame.y))).toBeCloseTo(0, 6); + expect(Math.max(...els.map((e) => e.frame.x + e.frame.w))).toBeCloseTo(100, 6); + expect(Math.max(...els.map((e) => e.frame.y + e.frame.h))).toBeCloseTo(100, 6); + }); + + /* + * The gutter comes out of the cells, not out of the block. Added between them + * instead, a four-across grid would be three gutters wider than the box it + * was told to fill, and would hang off the safe area it was aligned to. + */ + it('BOOKGRID-010: keeps the block inside its box once a gutter is applied', () => { + for (const el of bookGridElements(grid('grid-6'), box, 4)) { + expect(el.frame.x).toBeGreaterThanOrEqual(-0.001); + expect(el.frame.y).toBeGreaterThanOrEqual(-0.001); + expect(el.frame.x + el.frame.w).toBeLessThanOrEqual(100.001); + expect(el.frame.y + el.frame.h).toBeLessThanOrEqual(100.001); + } + }); + + it('BOOKGRID-011: leaves the block edges flush and only splits the interior gaps', () => { + const gutter = 6; + const [a, b] = [...bookGridElements(grid('two-across'), box, gutter)].sort((x, y) => x.frame.x - y.frame.x); + if (!a || !b) throw new Error('two frames expected'); + // Outer edges untouched... + expect(a.frame.x).toBeCloseTo(0, 6); + expect(b.frame.x + b.frame.w).toBeCloseTo(100, 6); + // ...and exactly one gutter between them. + expect(b.frame.x - (a.frame.x + a.frame.w)).toBeCloseTo(gutter, 6); + }); + + it('BOOKGRID-012: offsets the whole block when the box does not start at the origin', () => { + const els = bookGridElements(grid('single'), { x: 20, y: 30, w: 50, h: 40 }, 0); + expect(els[0]?.frame).toMatchObject({ x: 20, y: 30, w: 50, h: 40 }); + }); + + it('BOOKGRID-013: never produces a frame too small to see, even from a tiny box', () => { + for (const el of bookGridElements(grid('grid-12'), { x: 0, y: 0, w: 8, h: 8 }, 6)) { + expect(el.frame.w).toBeGreaterThanOrEqual(2); + expect(el.frame.h).toBeGreaterThanOrEqual(2); + } + }); + + it('BOOKGRID-014: carries the corner radius onto every frame', () => { + for (const el of bookGridElements(grid('grid-4'), box, 3, 5)) expect(el.kind === 'photo' && el.radius).toBe(5); + }); + + it('BOOKGRID-015: produces frames for every grid in the catalogue without a bad number', () => { + for (const g of BOOK_GRIDS) { + for (const el of bookGridElements(g, box, 3)) { + for (const v of [el.frame.x, el.frame.y, el.frame.w, el.frame.h]) { + expect(Number.isFinite(v), g.id).toBe(true); + } + } + } + }); +}); + +describe('defaultBookGridBox', () => { + it('BOOKGRID-016: spans both pages of a spread', () => { + const b = defaultBookGridBox(page, false); + expect(b.x + b.w).toBeCloseTo(page.pageWidth * 2 - b.x, 6); + expect(b.w).toBeGreaterThan(page.pageWidth); + }); + + it('BOOKGRID-017: stays on one page for a cover', () => { + const b = defaultBookGridBox(page, true); + expect(b.x + b.w).toBeLessThanOrEqual(page.pageWidth); + }); + + it('BOOKGRID-018: keeps clear of the safe area rather than landing on it', () => { + const b = defaultBookGridBox(page, true); + expect(b.x).toBeGreaterThanOrEqual(page.safe); + expect(b.y).toBeGreaterThanOrEqual(page.safe); + }); + + it('BOOKGRID-019: is symmetric, so a grid dropped into it looks centred', () => { + const b = defaultBookGridBox(page, false); + expect(b.x).toBeCloseTo(page.pageWidth * 2 - (b.x + b.w), 6); + expect(b.y).toBeCloseTo(page.pageHeight - (b.y + b.h), 6); + }); +}); diff --git a/shared/src/book/book-grids.ts b/shared/src/book/book-grids.ts new file mode 100644 index 000000000..464c68fed --- /dev/null +++ b/shared/src/book/book-grids.ts @@ -0,0 +1,282 @@ +import { bookElementId } from './book-ids'; +import type { BookElement, BookPageSetup } from './book.schema'; + +/** + * Photo grids: a set of frames placed together, in one gesture. + * + * Moved from the Studio (client/src/components/Studio/grids.ts) unchanged, so + * the assistant's book tools offer the same grids the elements panel does. + * + * ── How this differs from a layout ──────────────────────────────────────── + * + * A layout (`book-layouts.ts`) rearranges the *whole spread*: it takes what is + * already there, pours it into a new arrangement and parks the rest. A grid + * adds a block of empty frames wherever you put it, and touches nothing else. + * You reach for a layout when you are deciding what a page is; you reach for a + * grid when you have decided, and want four pictures in a row on the left half. + * + * Canva keeps them apart for the same reason, and calling both "templates" + * would make the difference invisible at exactly the moment it matters. + * + * ── Why fractions ──────────────────────────────────────────────────────── + * + * Cells are stored as fractions of the block, so one definition serves a grid + * dropped across a whole spread and the same grid dropped into a quarter of a + * page. The gutter is the exception: it is a real measurement in millimetres, + * because 2mm between two pictures is 2mm whether the block is 60mm or 300mm + * wide: scaling the gap with the block would make small grids look glued + * together and big ones look scattered. + */ + +export interface BookGridCell { + /** Fractions of the block, 0..1. */ + x: number; + y: number; + w: number; + h: number; +} + +export interface BookGridDef { + id: string; + cells: BookGridCell[]; +} + +/** `rows` of equal cells, each row split into that many columns. */ +function rows(...counts: number[]): BookGridCell[] { + const h = 1 / counts.length; + const out: BookGridCell[] = []; + counts.forEach((cols, r) => { + for (let c = 0; c < cols; c++) out.push({ x: c / cols, y: r * h, w: 1 / cols, h }); + }); + return out; +} + +/** `cols` of equal columns, each split into that many rows. */ +function cols(...counts: number[]): BookGridCell[] { + const w = 1 / counts.length; + const out: BookGridCell[] = []; + counts.forEach((n, c) => { + for (let r = 0; r < n; r++) out.push({ x: c * w, y: r / n, w, h: 1 / n }); + }); + return out; +} + +/** + * The catalogue. + * + * Ordered by how often a photo book actually wants them: one big picture, then + * pairs, then the asymmetric arrangements that carry a page, then the dense + * grids that end a chapter. + */ +export const BOOK_GRIDS: BookGridDef[] = [ + { id: 'single', cells: [{ x: 0, y: 0, w: 1, h: 1 }] }, + + { id: 'two-across', cells: rows(2) }, + { id: 'two-down', cells: cols(2) }, + { id: 'three-across', cells: rows(3) }, + { id: 'three-down', cells: cols(3) }, + + // One picture carrying the block, the rest in support. The workhorse of a + // travel page: a landscape wide enough to hold the eye, and the details + // underneath it. + { + id: 'hero-two', + cells: [ + { x: 0, y: 0, w: 1, h: 0.62 }, + { x: 0, y: 0.62, w: 0.5, h: 0.38 }, + { x: 0.5, y: 0.62, w: 0.5, h: 0.38 }, + ], + }, + { + id: 'hero-three', + cells: [{ x: 0, y: 0, w: 1, h: 0.64 }, ...[0, 1, 2].map((i) => ({ x: i / 3, y: 0.64, w: 1 / 3, h: 0.36 }))], + }, + { + id: 'two-hero', + cells: [ + { x: 0, y: 0, w: 0.5, h: 0.36 }, + { x: 0.5, y: 0, w: 0.5, h: 0.36 }, + { x: 0, y: 0.36, w: 1, h: 0.64 }, + ], + }, + { + id: 'side-hero', + cells: [ + { x: 0, y: 0, w: 0.62, h: 1 }, + { x: 0.62, y: 0, w: 0.38, h: 0.5 }, + { x: 0.62, y: 0.5, w: 0.38, h: 0.5 }, + ], + }, + { + id: 'side-hero-left', + cells: [ + { x: 0, y: 0, w: 0.38, h: 0.5 }, + { x: 0, y: 0.5, w: 0.38, h: 0.5 }, + { x: 0.38, y: 0, w: 0.62, h: 1 }, + ], + }, + { + id: 'side-three', + cells: [{ x: 0, y: 0, w: 0.6, h: 1 }, ...[0, 1, 2].map((i) => ({ x: 0.6, y: i / 3, w: 0.4, h: 1 / 3 }))], + }, + + { id: 'grid-4', cells: rows(2, 2) }, + { id: 'grid-6', cells: rows(3, 3) }, + { id: 'grid-9', cells: rows(3, 3, 3) }, + { id: 'grid-8', cells: rows(4, 4) }, + + { + id: 'one-two', + cells: [ + { x: 0, y: 0, w: 0.5, h: 1 }, + { x: 0.5, y: 0, w: 0.5, h: 0.5 }, + { x: 0.5, y: 0.5, w: 0.5, h: 0.5 }, + ], + }, + { + id: 'two-one', + cells: [ + { x: 0, y: 0, w: 0.5, h: 0.5 }, + { x: 0, y: 0.5, w: 0.5, h: 0.5 }, + { x: 0.5, y: 0, w: 0.5, h: 1 }, + ], + }, + { + id: 'rows-1-2', + cells: [ + { x: 0, y: 0, w: 1, h: 0.5 }, + { x: 0, y: 0.5, w: 0.5, h: 0.5 }, + { x: 0.5, y: 0.5, w: 0.5, h: 0.5 }, + ], + }, + { + id: 'rows-2-1', + cells: [ + { x: 0, y: 0, w: 0.5, h: 0.5 }, + { x: 0.5, y: 0, w: 0.5, h: 0.5 }, + { x: 0, y: 0.5, w: 1, h: 0.5 }, + ], + }, + { + id: 'rows-2-3', + cells: [ + ...rows(2).map((c) => ({ ...c, h: 0.52 })), + ...[0, 1, 2].map((i) => ({ x: i / 3, y: 0.52, w: 1 / 3, h: 0.48 })), + ], + }, + { + id: 'rows-3-2', + cells: [ + ...[0, 1, 2].map((i) => ({ x: i / 3, y: 0, w: 1 / 3, h: 0.48 })), + ...rows(2).map((c) => ({ ...c, y: 0.48, h: 0.52 })), + ], + }, + + { id: 'cols-1-2', cells: cols(1, 2) }, + { id: 'cols-2-1', cells: cols(2, 1) }, + { id: 'cols-2-1-2', cells: cols(2, 1, 2) }, + { id: 'cols-1-2-1', cells: cols(1, 2, 1) }, + + // A band across the middle: three pictures at the same height with room above + // and below for words. The arrangement a chapter opener usually wants. + { id: 'band-three', cells: [0, 1, 2].map((i) => ({ x: i / 3, y: 0.26, w: 1 / 3, h: 0.48 })) }, + { id: 'band-four', cells: [0, 1, 2, 3].map((i) => ({ x: i / 4, y: 0.3, w: 0.25, h: 0.4 })) }, + + // Staggered: alternate cells drop by a fraction of their height, which is what + // stops a row of four squares reading as a contact sheet. + { + id: 'stagger-four', + cells: [0, 1, 2, 3].map((i) => ({ + x: i / 4, + y: i % 2 ? 0.14 : 0, + w: 0.25, + h: 0.86, + })), + }, + { + id: 'stagger-three', + cells: [0, 1, 2].map((i) => ({ + x: i / 3, + y: i === 1 ? 0.16 : 0, + w: 1 / 3, + h: 0.84, + })), + }, + + { id: 'filmstrip', cells: [0, 1, 2, 3, 4].map((i) => ({ x: i / 5, y: 0.32, w: 0.2, h: 0.36 })) }, + { id: 'grid-12', cells: rows(4, 4, 4) }, +]; + +/** A grid by id, or undefined. */ +export function findBookGrid(id: string): BookGridDef | undefined { + return BOOK_GRIDS.find((g) => g.id === id); +} + +/** + * Turn a grid into elements, inside a box on the page. + * + * The gutter is taken out of each cell rather than added between them, so the + * block occupies exactly the box it was given: a grid asked to fill the safe + * area fills the safe area, instead of overhanging it by one gutter. + */ +export function bookGridElements( + grid: BookGridDef, + box: { x: number; y: number; w: number; h: number }, + gutter = 3, + radius = 0, +): BookElement[] { + const half = gutter / 2; + return grid.cells.map((cell) => { + const x = box.x + cell.x * box.w; + const y = box.y + cell.y * box.h; + const w = cell.w * box.w; + const h = cell.h * box.h; + // Only interior edges get half a gutter; the block's outer edge is the box. + const left = cell.x > 0.001 ? half : 0; + const top = cell.y > 0.001 ? half : 0; + const right = cell.x + cell.w < 0.999 ? half : 0; + const bottom = cell.y + cell.h < 0.999 ? half : 0; + return { + id: bookElementId('p'), + kind: 'photo', + frame: { + x: x + left, + y: y + top, + w: Math.max(2, w - left - right), + h: Math.max(2, h - top - bottom), + }, + rotation: 0, + opacity: 1, + locked: false, + photoId: null, + fit: 'cover', + focalX: 0.5, + focalY: 0.5, + radius, + filter: 'none', + mask: null, + frameStyle: 'none', + } as BookElement; + }); +} + +/** + * The box a grid lands in when it is simply clicked rather than placed. + * + * The safe area of the page you are looking at, which is almost always what was + * meant: a grid that lands half in the gutter has to be dragged before it is + * any use. + */ +export function defaultBookGridBox( + page: BookPageSetup, + single: boolean, +): { x: number; y: number; w: number; h: number } { + const W = single ? page.pageWidth : page.pageWidth * 2; + const margin = Math.max(page.safe, Math.min(W, page.pageHeight) * 0.075); + return { + x: margin, + y: margin, + w: W - margin * 2, + h: page.pageHeight - margin * 2, + }; +} diff --git a/shared/src/book/book-ids.ts b/shared/src/book/book-ids.ts new file mode 100644 index 000000000..14bce25e6 --- /dev/null +++ b/shared/src/book/book-ids.ts @@ -0,0 +1,41 @@ +/** + * Ids for the things a book is made of. + * + * Every panel that places an element mints one, and they were four copies of + * the same line: a prefix, `Math.random().toString(36)` and a slice. Copies of + * a line drift, and this one had already drifted in its slice length. Moved + * here from the Studio (client/src/components/Studio/bookIds.ts) so the editor, + * the layouts and the server's book tools mint them the same way. + * + * `getRandomValues` rather than `Math.random`: TREK's own quickstart is plain + * http on a LAN address, where `crypto.randomUUID` is undefined but + * `getRandomValues` still works, and every Node TREK supports has it as a + * global too. A book document is shared over a websocket and merged by whoever + * saves last, so two editors minting the same id for two different elements is + * a collision that shows up as one of them losing their work. A runtime without + * Web Crypto at all (none TREK supports) still gets an id that is unique within + * its own session, from the clock and a counter. + * + * Seven characters, as before: 36^7 is about 78 billion, against a document + * that holds a few hundred elements. + */ +const ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'; + +interface RandomSource { + getRandomValues?: (bytes: Uint8Array) => Uint8Array; +} + +let fallbackSeq = 0; + +export function bookElementId(prefix: string): string { + const c = (globalThis as { crypto?: RandomSource }).crypto; + if (c && typeof c.getRandomValues === 'function') { + const bytes = new Uint8Array(7); + c.getRandomValues(bytes); + let out = ''; + for (const b of bytes) out += ALPHABET[b % ALPHABET.length]; + return `${prefix}-${out}`; + } + fallbackSeq += 1; + return `${prefix}-${(Date.now() * 1000 + fallbackSeq).toString(36).slice(-7)}`; +} diff --git a/shared/src/book/book-layouts.parity-books.json b/shared/src/book/book-layouts.parity-books.json new file mode 100644 index 000000000..0316478f1 --- /dev/null +++ b/shared/src/book/book-layouts.parity-books.json @@ -0,0 +1,1314 @@ +{ + "books": { + "a": { + "title": "Iceland", + "page": { + "preset": "square-210", + "pageWidth": 210, + "pageHeight": 210, + "bleed": 3, + "safe": 5 + }, + "spreads": [ + { + "id": "a-cover", + "role": "cover", + "elements": [ + { + "id": "a-c-p", + "kind": "photo", + "frame": { + "x": -3, + "y": -3, + "w": 216, + "h": 216 + }, + "photoId": 101, + "focalY": 0.4 + }, + { + "id": "a-c-t", + "kind": "text", + "frame": { + "x": 16, + "y": 140, + "w": 178, + "h": 30 + }, + "text": "Iceland", + "size": 30, + "weight": 700, + "binding": { + "source": "journey.title" + }, + "color": "#ffffff" + }, + { + "id": "a-c-s", + "kind": "text", + "frame": { + "x": 16, + "y": 180, + "w": 178, + "h": 8 + }, + "text": "Summer 2026", + "size": 9, + "binding": { + "source": "journey.subtitle" + } + } + ] + }, + { + "id": "a-first", + "role": "first", + "elements": [] + }, + { + "id": "a-1", + "role": "inner", + "elements": [ + { + "id": "a-1-hero", + "kind": "photo", + "frame": { + "x": -3, + "y": -3, + "w": 213, + "h": 216 + }, + "photoId": 102, + "focalX": 0.55, + "filter": "bw" + }, + { + "id": "a-1-cap", + "kind": "text", + "frame": { + "x": 18, + "y": 186, + "w": 126, + "h": 5 + }, + "text": "Harbour at dawn", + "size": 7.5, + "weight": 500, + "color": "#ffffff", + "binding": { + "source": "photo.caption", + "entryId": 11, + "photoId": 102 + } + }, + { + "id": "a-1-h", + "kind": "text", + "frame": { + "x": 228, + "y": 52, + "w": 174, + "h": 14 + }, + "text": "Reykjavik", + "size": 20, + "weight": 700, + "binding": { + "source": "entry.title", + "entryId": 11 + } + }, + { + "id": "a-1-b", + "kind": "text", + "frame": { + "x": 228, + "y": 70, + "w": 174, + "h": 80 + }, + "text": "We walked along the old harbour while the fishing boats came in.\nThe light stayed until midnight.", + "size": 10, + "leading": 1.62, + "binding": { + "source": "entry.story", + "entryId": 11 + } + }, + { + "id": "a-1-m", + "kind": "text", + "frame": { + "x": 228, + "y": 40, + "w": 174, + "h": 5 + }, + "text": "MAY 28, 2026", + "size": 7.5, + "weight": 600, + "tracking": 0.14, + "binding": { + "source": "entry.date", + "entryId": 11, + "value": "2026-05-28" + }, + "overridden": true + }, + { + "id": "a-1-p2", + "kind": "photo", + "frame": { + "x": 228, + "y": 160, + "w": 55, + "h": 34 + }, + "photoId": 103, + "mask": "heart", + "radius": 2 + }, + { + "id": "a-1-p3", + "kind": "photo", + "frame": { + "x": 290, + "y": 160, + "w": 55, + "h": 34 + }, + "photoId": 104, + "frameStyle": "polaroid", + "fit": "contain" + }, + { + "id": "a-1-s", + "kind": "shape", + "frame": { + "x": 210, + "y": 0, + "w": 210, + "h": 6 + }, + "shape": "rect", + "fill": "#111827" + }, + { + "id": "a-1-badge", + "kind": "badge", + "frame": { + "x": 380, + "y": 20, + "w": 30, + "h": 8 + }, + "variant": "day", + "text": "DAY 1" + }, + { + "id": "a-1-map", + "kind": "map", + "frame": { + "x": 228, + "y": 18, + "w": 26, + "h": 20 + }, + "points": [ + { + "lat": 64.1, + "lng": -21.9, + "label": "Reykjavik" + } + ] + } + ], + "entryId": 11, + "background": "#ffffff" + }, + { + "id": "a-2", + "role": "inner", + "elements": [ + { + "id": "a-2-p0", + "kind": "photo", + "frame": { + "x": 16, + "y": 16, + "w": 60, + "h": 80 + }, + "photoId": 105, + "focalX": 0 + }, + { + "id": "a-2-p1", + "kind": "photo", + "frame": { + "x": 80, + "y": 16, + "w": 60, + "h": 80 + }, + "photoId": 106, + "focalX": 0.1 + }, + { + "id": "a-2-p2", + "kind": "photo", + "frame": { + "x": 144, + "y": 16, + "w": 60, + "h": 80 + }, + "photoId": 107, + "focalX": 0.2 + }, + { + "id": "a-2-p3", + "kind": "photo", + "frame": { + "x": 208, + "y": 16, + "w": 60, + "h": 80 + }, + "photoId": 108, + "focalX": 0.30000000000000004 + }, + { + "id": "a-2-p4", + "kind": "photo", + "frame": { + "x": 272, + "y": 16, + "w": 60, + "h": 80 + }, + "photoId": 109, + "focalX": 0.4 + }, + { + "id": "a-2-p5", + "kind": "photo", + "frame": { + "x": 336, + "y": 16, + "w": 60, + "h": 80 + }, + "photoId": 110, + "focalX": 0.5 + }, + { + "id": "a-2-empty", + "kind": "photo", + "frame": { + "x": 16, + "y": 110, + "w": 60, + "h": 80 + }, + "photoId": null + } + ], + "parked": [ + { + "id": "a-2-park-p", + "kind": "photo", + "frame": { + "x": 0, + "y": 0, + "w": 10, + "h": 10 + }, + "photoId": 111, + "filter": "warm" + }, + { + "id": "a-2-park-t", + "kind": "text", + "frame": { + "x": 0, + "y": 0, + "w": 50, + "h": 10 + }, + "text": "Parked words", + "size": 12 + } + ] + }, + { + "id": "a-3", + "role": "inner", + "elements": [ + { + "id": "a-3-h", + "kind": "text", + "frame": { + "x": 226, + "y": 50, + "w": 178, + "h": 14 + }, + "text": "Quiet", + "size": 22, + "weight": 700, + "font": "serif", + "align": "center" + }, + { + "id": "a-3-b", + "kind": "text", + "frame": { + "x": 226, + "y": 70, + "w": 178, + "h": 100 + }, + "text": "A long story about a quiet day at the lake, with nothing much happening and that being the point.", + "size": 10 + }, + { + "id": "a-3-m", + "kind": "text", + "frame": { + "x": 226, + "y": 40, + "w": 178, + "h": 5 + }, + "text": "DAY 4", + "size": 7.5 + }, + { + "id": "a-3-x", + "kind": "text", + "frame": { + "x": 16, + "y": 16, + "w": 60, + "h": 5 + }, + "text": "Note", + "size": 8, + "italic": true + }, + { + "id": "a-3-blank", + "kind": "text", + "frame": { + "x": 16, + "y": 30, + "w": 60, + "h": 5 + }, + "text": " ", + "size": 9 + } + ] + }, + { + "id": "a-4", + "role": "inner", + "elements": [] + }, + { + "id": "a-last", + "role": "last", + "elements": [ + { + "id": "a-l-t", + "kind": "text", + "frame": { + "x": 16, + "y": 190, + "w": 100, + "h": 5 + }, + "text": "Printed with TREK", + "size": 8 + } + ] + }, + { + "id": "a-back", + "role": "back", + "elements": [ + { + "id": "a-b-s", + "kind": "shape", + "frame": { + "x": -3, + "y": -3, + "w": 216, + "h": 216 + }, + "shape": "rect", + "fill": "#141414" + }, + { + "id": "a-b-t", + "kind": "text", + "frame": { + "x": 16, + "y": 100, + "w": 178, + "h": 10 + }, + "text": "Iceland, 2026", + "size": 12, + "color": "#ffffff" + } + ], + "background": "#141414" + } + ], + "layouts": [ + { + "id": "ly-a1", + "name": "Hero", + "role": "inner", + "pageWidth": 210, + "pageHeight": 210, + "background": "#fafafa", + "elements": [ + { + "id": "ly-a1-s", + "kind": "shape", + "frame": { + "x": 0, + "y": 0, + "w": 420, + "h": 20 + }, + "shape": "rect", + "fill": "#222222" + }, + { + "id": "ly-a1-p1", + "kind": "photo", + "frame": { + "x": -3, + "y": -3, + "w": 213, + "h": 216 + }, + "photoId": null, + "frameStyle": "white" + }, + { + "id": "ly-a1-p2", + "kind": "photo", + "frame": { + "x": 230, + "y": 120, + "w": 80, + "h": 60 + }, + "photoId": null + }, + { + "id": "ly-a1-h", + "kind": "text", + "frame": { + "x": 230, + "y": 40, + "w": 170, + "h": 14 + }, + "text": "Title", + "size": 24, + "weight": 700 + }, + { + "id": "ly-a1-b", + "kind": "text", + "frame": { + "x": 230, + "y": 60, + "w": 170, + "h": 50 + }, + "text": "Story", + "size": 10, + "binding": { + "source": "entry.story", + "entryId": 99 + } + }, + { + "id": "ly-a1-l", + "kind": "text", + "frame": { + "x": 230, + "y": 30, + "w": 170, + "h": 5 + }, + "text": "CHAPTER", + "size": 8 + }, + { + "id": "ly-a1-badge", + "kind": "badge", + "frame": { + "x": 380, + "y": 190, + "w": 30, + "h": 8 + }, + "variant": "day", + "text": "DAY 9" + }, + { + "id": "ly-a1-list", + "kind": "list", + "frame": { + "x": 320, + "y": 120, + "w": 80, + "h": 60 + }, + "items": [ + { + "text": "Good", + "tone": "pro" + } + ] + } + ] + }, + { + "id": "ly-a2", + "name": "Small cover", + "role": "single", + "pageWidth": 105, + "pageHeight": 105, + "background": null, + "elements": [ + { + "id": "ly-a2-p", + "kind": "photo", + "frame": { + "x": 10, + "y": 10, + "w": 85, + "h": 60 + }, + "photoId": null + }, + { + "id": "ly-a2-h", + "kind": "text", + "frame": { + "x": 10, + "y": 75, + "w": 85, + "h": 12 + }, + "text": "Cover title", + "size": 18, + "weight": 700 + } + ] + } + ] + }, + "b": { + "title": "Portugal", + "page": { + "preset": "a4-landscape", + "pageWidth": 297, + "pageHeight": 210, + "bleed": 5, + "safe": 8 + }, + "spreads": [ + { + "id": "b-cover", + "role": "cover", + "elements": [ + { + "id": "b-c-p1", + "kind": "photo", + "frame": { + "x": -5, + "y": -5, + "w": 307, + "h": 110 + }, + "photoId": 201 + }, + { + "id": "b-c-p2", + "kind": "photo", + "frame": { + "x": -5, + "y": 105, + "w": 307, + "h": 110 + }, + "photoId": 202, + "focalY": 0.8 + } + ] + }, + { + "id": "b-first", + "role": "first", + "elements": [ + { + "id": "b-f-p", + "kind": "photo", + "frame": { + "x": 40, + "y": 40, + "w": 217, + "h": 130 + }, + "photoId": 203, + "filter": "fade" + } + ] + }, + { + "id": "b-1", + "role": "inner", + "elements": [ + { + "id": "b-1-p0", + "kind": "photo", + "frame": { + "x": 10, + "y": 10, + "w": 90, + "h": 95 + }, + "photoId": 204, + "fit": "cover", + "focalX": 0, + "focalY": 1 + }, + { + "id": "b-1-p1", + "kind": "photo", + "frame": { + "x": 105, + "y": 10, + "w": 90, + "h": 95 + }, + "photoId": 205, + "fit": "contain", + "focalX": 0.25, + "focalY": 0.5 + }, + { + "id": "b-1-p2", + "kind": "photo", + "frame": { + "x": 200, + "y": 10, + "w": 90, + "h": 95 + }, + "photoId": 206, + "fit": "cover", + "focalX": 0.5, + "focalY": 0 + }, + { + "id": "b-1-p3", + "kind": "photo", + "frame": { + "x": 295, + "y": 10, + "w": 90, + "h": 95 + }, + "photoId": 207, + "fit": "contain", + "focalX": 0.75, + "focalY": 1 + }, + { + "id": "b-1-p4", + "kind": "photo", + "frame": { + "x": 390, + "y": 10, + "w": 90, + "h": 95 + }, + "photoId": 208, + "fit": "cover", + "focalX": 1, + "focalY": 0.5 + }, + { + "id": "b-1-p5", + "kind": "photo", + "frame": { + "x": 485, + "y": 10, + "w": 90, + "h": 95 + }, + "photoId": 209, + "fit": "contain", + "focalX": 0, + "focalY": 0 + }, + { + "id": "b-1-p6", + "kind": "photo", + "frame": { + "x": 10, + "y": 110, + "w": 90, + "h": 95 + }, + "photoId": 210, + "fit": "cover", + "focalX": 0.25, + "focalY": 1 + }, + { + "id": "b-1-p7", + "kind": "photo", + "frame": { + "x": 105, + "y": 110, + "w": 90, + "h": 95 + }, + "photoId": 211, + "fit": "contain", + "focalX": 0.5, + "focalY": 0.5 + }, + { + "id": "b-1-p8", + "kind": "photo", + "frame": { + "x": 200, + "y": 110, + "w": 90, + "h": 95 + }, + "photoId": 212, + "fit": "cover", + "focalX": 0.75, + "focalY": 0 + }, + { + "id": "b-1-p9", + "kind": "photo", + "frame": { + "x": 295, + "y": 110, + "w": 90, + "h": 95 + }, + "photoId": 213, + "fit": "contain", + "focalX": 1, + "focalY": 1 + }, + { + "id": "b-1-p10", + "kind": "photo", + "frame": { + "x": 390, + "y": 110, + "w": 90, + "h": 95 + }, + "photoId": 214, + "fit": "cover", + "focalX": 0, + "focalY": 0.5 + }, + { + "id": "b-1-p11", + "kind": "photo", + "frame": { + "x": 485, + "y": 110, + "w": 90, + "h": 95 + }, + "photoId": 215, + "fit": "contain", + "focalX": 0.25, + "focalY": 0 + } + ] + }, + { + "id": "b-2", + "role": "inner", + "elements": [ + { + "id": "b-2-lock", + "kind": "photo", + "frame": { + "x": 20, + "y": 20, + "w": 200, + "h": 150 + }, + "photoId": 216, + "locked": true, + "rotation": 4 + }, + { + "id": "b-2-t14", + "kind": "text", + "frame": { + "x": 320, + "y": 20, + "w": 200, + "h": 10 + }, + "text": "Fourteen points", + "size": 14, + "binding": { + "source": "entry.title", + "entryId": 21 + } + }, + { + "id": "b-2-t16", + "kind": "text", + "frame": { + "x": 320, + "y": 40, + "w": 200, + "h": 10 + }, + "text": "Sixteen points, the heading", + "size": 16, + "font": "playfair", + "weight": 600 + }, + { + "id": "b-2-t9", + "kind": "text", + "frame": { + "x": 320, + "y": 60, + "w": 200, + "h": 60 + }, + "text": "Nine points of body copy that runs a little longer than the rest of them.", + "size": 9, + "align": "justify", + "overridden": true + } + ], + "entryId": 21 + }, + { + "id": "b-3", + "role": "inner", + "elements": [ + { + "id": "b-3-list", + "kind": "list", + "frame": { + "x": 20, + "y": 20, + "w": 120, + "h": 80 + }, + "items": [ + { + "text": "Tram 28", + "tone": "pro" + }, + { + "text": "Hills", + "tone": "con" + } + ] + }, + { + "id": "b-3-stats", + "kind": "stats", + "frame": { + "x": 160, + "y": 20, + "w": 120, + "h": 40 + }, + "metrics": [ + "distance", + "days" + ] + }, + { + "id": "b-3-countries", + "kind": "countries", + "frame": { + "x": 320, + "y": 20, + "w": 200, + "h": 80 + }, + "codes": [ + "PT", + "ES" + ] + }, + { + "id": "b-3-icon", + "kind": "icon", + "frame": { + "x": 540, + "y": 20, + "w": 20, + "h": 20 + }, + "name": "Sun" + }, + { + "id": "b-3-t", + "kind": "text", + "frame": { + "x": 320, + "y": 120, + "w": 200, + "h": 14 + }, + "text": "Lisbon in numbers", + "size": 18, + "weight": 700 + }, + { + "id": "b-3-badge", + "kind": "badge", + "frame": { + "x": 20, + "y": 120, + "w": 40, + "h": 10 + }, + "variant": "coords", + "text": "38.7 N 9.1 W" + } + ] + }, + { + "id": "b-last", + "role": "last", + "elements": [] + }, + { + "id": "b-back", + "role": "back", + "elements": [ + { + "id": "b-b-t", + "kind": "text", + "frame": { + "x": 20, + "y": 180, + "w": 100, + "h": 8 + }, + "text": "Back", + "size": 10 + } + ] + } + ], + "layouts": [ + { + "id": "ly-b1", + "name": "Marks", + "role": "inner", + "pageWidth": 297, + "pageHeight": 210, + "background": null, + "elements": [ + { + "id": "ly-b1-p", + "kind": "photo", + "frame": { + "x": 10, + "y": 10, + "w": 200, + "h": 150 + }, + "photoId": null, + "mask": "ellipse", + "frameStyle": "shadow" + }, + { + "id": "ly-b1-coords", + "kind": "badge", + "frame": { + "x": 320, + "y": 10, + "w": 60, + "h": 10 + }, + "variant": "coords", + "text": "0 N 0 E" + }, + { + "id": "ly-b1-day", + "kind": "badge", + "frame": { + "x": 400, + "y": 10, + "w": 40, + "h": 10 + }, + "variant": "day", + "text": "DAY 0" + }, + { + "id": "ly-b1-list", + "kind": "list", + "frame": { + "x": 320, + "y": 30, + "w": 200, + "h": 80 + }, + "items": [ + { + "text": "x", + "tone": "plain" + } + ] + }, + { + "id": "ly-b1-big", + "kind": "text", + "frame": { + "x": 320, + "y": 120, + "w": 200, + "h": 20 + }, + "text": "Big", + "size": 28, + "weight": 700 + }, + { + "id": "ly-b1-small", + "kind": "text", + "frame": { + "x": 320, + "y": 150, + "w": 200, + "h": 20 + }, + "text": "Small", + "size": 9 + }, + { + "id": "ly-b1-bound", + "kind": "text", + "frame": { + "x": 320, + "y": 175, + "w": 200, + "h": 20 + }, + "text": "Bound", + "size": 9, + "binding": { + "source": "entry.date", + "entryId": 5 + } + } + ] + } + ] + }, + "c": { + "title": "Weekend", + "page": { + "preset": "custom", + "pageWidth": 148, + "pageHeight": 105, + "bleed": 2, + "safe": 4 + }, + "spreads": [ + { + "id": "c-cover", + "role": "cover", + "elements": [ + { + "id": "c-c-p", + "kind": "photo", + "frame": { + "x": -2, + "y": -2, + "w": 152, + "h": 70 + }, + "photoId": 301 + }, + { + "id": "c-c-t", + "kind": "text", + "frame": { + "x": 8, + "y": 75, + "w": 132, + "h": 10 + }, + "text": "Weekend", + "size": 12 + } + ] + }, + { + "id": "c-1", + "role": "inner", + "elements": [ + { + "id": "c-1-p1", + "kind": "photo", + "frame": { + "x": 8, + "y": 8, + "w": 60, + "h": 40 + }, + "photoId": 302 + }, + { + "id": "c-1-p2", + "kind": "photo", + "frame": { + "x": 80, + "y": 8, + "w": 60, + "h": 40 + }, + "photoId": 303, + "radius": 3 + }, + { + "id": "c-1-p3", + "kind": "photo", + "frame": { + "x": 160, + "y": 8, + "w": 60, + "h": 40 + }, + "photoId": 304 + } + ], + "parked": [ + { + "id": "c-1-k1", + "kind": "photo", + "frame": { + "x": 0, + "y": 0, + "w": 10, + "h": 10 + }, + "photoId": 305 + }, + { + "id": "c-1-k2", + "kind": "photo", + "frame": { + "x": 0, + "y": 0, + "w": 10, + "h": 10 + }, + "photoId": 306, + "mask": "star-5" + }, + { + "id": "c-1-k3", + "kind": "photo", + "frame": { + "x": 0, + "y": 0, + "w": 10, + "h": 10 + }, + "photoId": 307 + }, + { + "id": "c-1-kt1", + "kind": "text", + "frame": { + "x": 0, + "y": 0, + "w": 40, + "h": 10 + }, + "text": "Parked heading", + "size": 15 + }, + { + "id": "c-1-kt2", + "kind": "text", + "frame": { + "x": 0, + "y": 0, + "w": 40, + "h": 10 + }, + "text": "Parked line", + "size": 8 + } + ] + }, + { + "id": "c-2", + "role": "inner", + "elements": [ + { + "id": "c-2-p", + "kind": "photo", + "frame": { + "x": 150, + "y": 8, + "w": 140, + "h": 90 + }, + "photoId": 308 + }, + { + "id": "c-2-t", + "kind": "text", + "frame": { + "x": 8, + "y": 8, + "w": 130, + "h": 10 + }, + "text": "Exactly fourteen", + "size": 14 + } + ], + "entryId": 31 + }, + { + "id": "c-back", + "role": "back", + "elements": [] + } + ] + } + } +} diff --git a/shared/src/book/book-layouts.parity.json b/shared/src/book/book-layouts.parity.json new file mode 100644 index 000000000..081cb7659 --- /dev/null +++ b/shared/src/book/book-layouts.parity.json @@ -0,0 +1,429 @@ +{ + "a/a-cover/template/cover-full": "b44896e2dc27ad7c", + "a/a-cover/template/cover-band": "bfac29a8f60e8fd2", + "a/a-cover/template/cover-window": "bccf4137df7ff6c9", + "a/a-cover/template/cover-quiet": "9f5fbe40b13cfcc6", + "a/a-cover/template/cover-half": "14d560741d9858ec", + "a/a-cover/chain/cover-quiet+cover-full": "b44896e2dc27ad7c", + "a/a-cover/layoutFromSpread": "3891f6d00d973b8e", + "a/a-cover/layoutRole": "0fff5f85e4fa1de5", + "a/a-cover/saved/ly-a2": "3997b4aa30503e2d", + "a/a-first/template/cover-full": "270d029be936ff3b", + "a/a-first/template/cover-band": "db9501268eaf892d", + "a/a-first/template/cover-window": "c0d30158602b5b63", + "a/a-first/template/cover-quiet": "b1b357caa76888e8", + "a/a-first/template/cover-half": "957e7e68c7738dbe", + "a/a-first/chain/cover-quiet+cover-full": "270d029be936ff3b", + "a/a-first/layoutFromSpread": "069286432a23e80e", + "a/a-first/layoutRole": "0fff5f85e4fa1de5", + "a/a-first/saved/ly-a2": "c41883ad3db510ea", + "a/a-1/template/hero-story": "78abf9f63d5c0ea8", + "a/a-1/template/full-bleed": "3c7677cb0987591c", + "a/a-1/template/two-up": "c5323de1a6a6ee61", + "a/a-1/template/grid-4": "9d8a49d70087429e", + "a/a-1/template/grid-6": "a24af576d9a2e775", + "a/a-1/template/strip": "4b7fc3c283c81602", + "a/a-1/template/quiet-text": "4560578158fa4940", + "a/a-1/template/full-text": "6b05acdd75a0ec6f", + "a/a-1/template/grid-9": "ebcb56d935cae43a", + "a/a-1/template/mosaic": "34392e1938873347", + "a/a-1/template/band-quote": "1268bb27ee2a1e42", + "a/a-1/template/stagger-four": "9bd83de7bbdcefb8", + "a/a-1/template/portrait-pair": "68b9bc19a28556e8", + "a/a-1/chain/quiet-text+grid-9": "76c6799fbb3773be", + "a/a-1/layoutFromSpread": "4e648402d31ada50", + "a/a-1/layoutRole": "0b17fc916c3c9a05", + "a/a-1/saved/ly-a1": "2e8699b2f9958ee6", + "a/a-2/template/hero-story": "0d3b15268b6ee40e", + "a/a-2/template/full-bleed": "342d953907bf88bb", + "a/a-2/template/two-up": "203768dc2fb981e3", + "a/a-2/template/grid-4": "0dd06aeabc0144b3", + "a/a-2/template/grid-6": "9fbc7d6bc7c7fd51", + "a/a-2/template/strip": "d27eec8336b902e1", + "a/a-2/template/quiet-text": "75c9d9c9f0440041", + "a/a-2/template/full-text": "290d2b7848f955ae", + "a/a-2/template/grid-9": "49bf96d78a525a55", + "a/a-2/template/mosaic": "f55515bd8af02078", + "a/a-2/template/band-quote": "a480fa37c0313190", + "a/a-2/template/stagger-four": "b426292cdca9f661", + "a/a-2/template/portrait-pair": "909f4603d6eb0a7b", + "a/a-2/chain/quiet-text+grid-9": "f15a814fb5d73060", + "a/a-2/layoutFromSpread": "3c03d3ff7788fe67", + "a/a-2/layoutRole": "0b17fc916c3c9a05", + "a/a-2/saved/ly-a1": "6d7c6d3761cd3e8b", + "a/a-3/template/hero-story": "b6736774fc3ec973", + "a/a-3/template/full-bleed": "d67992b0c76cd061", + "a/a-3/template/two-up": "0bb67780cacb882c", + "a/a-3/template/grid-4": "2942603177c62d41", + "a/a-3/template/grid-6": "3f9452c0879bc57c", + "a/a-3/template/strip": "6639c098cfd20915", + "a/a-3/template/quiet-text": "bdd76420940fdceb", + "a/a-3/template/full-text": "cb52a975e3699389", + "a/a-3/template/grid-9": "8b7b4db6a31d339d", + "a/a-3/template/mosaic": "397165f7bdeb1169", + "a/a-3/template/band-quote": "b0901c3bd303faa6", + "a/a-3/template/stagger-four": "5ccb9026951a89b4", + "a/a-3/template/portrait-pair": "e09e94fcb6b4c060", + "a/a-3/chain/quiet-text+grid-9": "aaedc035d6fe3dfb", + "a/a-3/layoutFromSpread": "4fc4370dfc6e7435", + "a/a-3/layoutRole": "0b17fc916c3c9a05", + "a/a-3/saved/ly-a1": "c72dd863fcfa1a0c", + "a/a-4/template/hero-story": "9213d12e8d9c7652", + "a/a-4/template/full-bleed": "416e72aa1fb367e9", + "a/a-4/template/two-up": "c43a7ce5dd8642a0", + "a/a-4/template/grid-4": "170af1cb20da8937", + "a/a-4/template/grid-6": "a1ee5b3e19404131", + "a/a-4/template/strip": "509ffb9465d2afdd", + "a/a-4/template/quiet-text": "2e4af0cf19829281", + "a/a-4/template/full-text": "970de9fa12f844a5", + "a/a-4/template/grid-9": "629ada27998746d4", + "a/a-4/template/mosaic": "b3dbb9fffd65c6a2", + "a/a-4/template/band-quote": "9c3cb19e3829005c", + "a/a-4/template/stagger-four": "619968eb9873e8f8", + "a/a-4/template/portrait-pair": "69b5591bb6b1937b", + "a/a-4/chain/quiet-text+grid-9": "629ada27998746d4", + "a/a-4/layoutFromSpread": "59ac97a117a1cf27", + "a/a-4/layoutRole": "0b17fc916c3c9a05", + "a/a-4/saved/ly-a1": "29d4c7c597010f0d", + "a/a-last/template/cover-full": "0c2b7acd21a89a47", + "a/a-last/template/cover-band": "b4fc15fb81f9a90a", + "a/a-last/template/cover-window": "beab5ffa19e68f85", + "a/a-last/template/cover-quiet": "97012bfc786e27a2", + "a/a-last/template/cover-half": "2856a6938e5ec42e", + "a/a-last/chain/cover-quiet+cover-full": "0c2b7acd21a89a47", + "a/a-last/layoutFromSpread": "95131757684b09ea", + "a/a-last/layoutRole": "0fff5f85e4fa1de5", + "a/a-last/saved/ly-a2": "2977502f9d5c383b", + "a/a-back/template/cover-full": "7e83533cfb17448e", + "a/a-back/template/cover-band": "fe3738e0be0361ad", + "a/a-back/template/cover-window": "7522b948eb324b54", + "a/a-back/template/cover-quiet": "7e876c2c3b0e0915", + "a/a-back/template/cover-half": "e131dfb8d8ca04e1", + "a/a-back/chain/cover-quiet+cover-full": "7e83533cfb17448e", + "a/a-back/layoutFromSpread": "36013ebb46ce5fe3", + "a/a-back/layoutRole": "0fff5f85e4fa1de5", + "a/a-back/saved/ly-a2": "2adc79f7d61c97be", + "a/grid/single/spread": "39e7461dca6dc13a", + "a/grid/single/single": "dcf530b0dff3912f", + "a/grid/two-across/spread": "bcd0b491783f4b8b", + "a/grid/two-across/single": "efc5720cdb17e9f6", + "a/grid/two-down/spread": "1620dce6e3e24e8f", + "a/grid/two-down/single": "32c79cd77185b005", + "a/grid/three-across/spread": "d0ceed9c4a0783bf", + "a/grid/three-across/single": "df2cd8da7c2a17f2", + "a/grid/three-down/spread": "7692b76cc1b8243a", + "a/grid/three-down/single": "cd05b484cfdf96b0", + "a/grid/hero-two/spread": "14cf8fdcd724fdb0", + "a/grid/hero-two/single": "61cab96215ce769d", + "a/grid/hero-three/spread": "d973922478a01a78", + "a/grid/hero-three/single": "593da0c30362e37b", + "a/grid/two-hero/spread": "52e37d3f2b1d9c3d", + "a/grid/two-hero/single": "6ceb0f2124b7d826", + "a/grid/side-hero/spread": "dd1382332cebdf3b", + "a/grid/side-hero/single": "60903b8b888b00d8", + "a/grid/side-hero-left/spread": "7952a434411123da", + "a/grid/side-hero-left/single": "ea35e864cb478d02", + "a/grid/side-three/spread": "de0ac3caf1949108", + "a/grid/side-three/single": "c6f66731b048333d", + "a/grid/grid-4/spread": "683f40a2ae0dfa84", + "a/grid/grid-4/single": "d141882326efcf93", + "a/grid/grid-6/spread": "91420c62cfc8e104", + "a/grid/grid-6/single": "5a2a570832d74ad4", + "a/grid/grid-9/spread": "c9603e1d12cd4fdf", + "a/grid/grid-9/single": "c1471474d9d52b48", + "a/grid/grid-8/spread": "8f342528638d638a", + "a/grid/grid-8/single": "f773e0204024cc57", + "a/grid/one-two/spread": "cb233ee63e04fe78", + "a/grid/one-two/single": "e24f3d0eca8234da", + "a/grid/two-one/spread": "57acee8cfb8e372e", + "a/grid/two-one/single": "1a060bf4eaa76290", + "a/grid/rows-1-2/spread": "8071baa32a36dd33", + "a/grid/rows-1-2/single": "c699484d187d4fd4", + "a/grid/rows-2-1/spread": "5e40701c767e2ec6", + "a/grid/rows-2-1/single": "e30eb4cb57da18ee", + "a/grid/rows-2-3/spread": "9e72bb0f0a6627da", + "a/grid/rows-2-3/single": "be770b63b438e49e", + "a/grid/rows-3-2/spread": "02a7ffce8eb3b96d", + "a/grid/rows-3-2/single": "9dc92550225d9639", + "a/grid/cols-1-2/spread": "cb233ee63e04fe78", + "a/grid/cols-1-2/single": "e24f3d0eca8234da", + "a/grid/cols-2-1/spread": "57acee8cfb8e372e", + "a/grid/cols-2-1/single": "1a060bf4eaa76290", + "a/grid/cols-2-1-2/spread": "7570e2850153506c", + "a/grid/cols-2-1-2/single": "f6dc4c9f229d28b5", + "a/grid/cols-1-2-1/spread": "870933b2815f2fb4", + "a/grid/cols-1-2-1/single": "b85ada6613aeafa6", + "a/grid/band-three/spread": "d488be9c056c6479", + "a/grid/band-three/single": "3e60329148c63ec2", + "a/grid/band-four/spread": "c988047bf11c783d", + "a/grid/band-four/single": "dbdf300e82ea6db5", + "a/grid/stagger-four/spread": "517f7ed08080fe26", + "a/grid/stagger-four/single": "1954cb5b2176498f", + "a/grid/stagger-three/spread": "5c0c53edc986eb0d", + "a/grid/stagger-three/single": "1546a984df77b564", + "a/grid/filmstrip/spread": "a04b3ebc5413180b", + "a/grid/filmstrip/single": "63e97c88bd739d53", + "a/grid/grid-12/spread": "6214dd3b71ccd0d4", + "a/grid/grid-12/single": "6c6d11e5a81ccc84", + "a/preview/ly-a1": "d19a17cf9d7a4557", + "a/preview/ly-a2": "dc279c6d4103e328", + "a/nextLayoutName": "3e8f5cf842cd9ea1", + "b/b-cover/template/cover-full": "6d00fde07530a280", + "b/b-cover/template/cover-band": "4b6b0bd5b7db98f4", + "b/b-cover/template/cover-window": "848a8d1a927aa0b0", + "b/b-cover/template/cover-quiet": "cec772593bdec80e", + "b/b-cover/template/cover-half": "2310cc2ddaa56df5", + "b/b-cover/chain/cover-quiet+cover-full": "6d00fde07530a280", + "b/b-cover/layoutFromSpread": "781649128704b8ea", + "b/b-cover/layoutRole": "0fff5f85e4fa1de5", + "b/b-first/template/cover-full": "15d2ae9e4138cf19", + "b/b-first/template/cover-band": "fbe7d833f04d7678", + "b/b-first/template/cover-window": "a4dcc3d3d5845d52", + "b/b-first/template/cover-quiet": "b0be9211240bab15", + "b/b-first/template/cover-half": "a77c59c493eef396", + "b/b-first/chain/cover-quiet+cover-full": "15d2ae9e4138cf19", + "b/b-first/layoutFromSpread": "8e329e95e5da6859", + "b/b-first/layoutRole": "0fff5f85e4fa1de5", + "b/b-1/template/hero-story": "3cbfcf7a474ee4b8", + "b/b-1/template/full-bleed": "e058ccb891389888", + "b/b-1/template/two-up": "c5453891933a5942", + "b/b-1/template/grid-4": "a6046edf5ad60713", + "b/b-1/template/grid-6": "9500f303f5445b80", + "b/b-1/template/strip": "359282c20ebac1e7", + "b/b-1/template/quiet-text": "73fa1028f9028536", + "b/b-1/template/full-text": "3026d85b11bcdcd9", + "b/b-1/template/grid-9": "6a874a4bf391d858", + "b/b-1/template/mosaic": "bb00d6fb4a84359c", + "b/b-1/template/band-quote": "8edff9999fcc2f85", + "b/b-1/template/stagger-four": "35b6bccb1b29f3cb", + "b/b-1/template/portrait-pair": "4d145ff05dee1d77", + "b/b-1/chain/quiet-text+grid-9": "6a874a4bf391d858", + "b/b-1/layoutFromSpread": "8e1e33bf57b3f3b5", + "b/b-1/layoutRole": "0b17fc916c3c9a05", + "b/b-1/saved/ly-b1": "f11024bf83676ac6", + "b/b-2/template/hero-story": "194a359b43c71a2d", + "b/b-2/template/full-bleed": "70cb77e044c0d113", + "b/b-2/template/two-up": "30af5a87c8045a77", + "b/b-2/template/grid-4": "2bc8928ae6d70c79", + "b/b-2/template/grid-6": "868869ddde809722", + "b/b-2/template/strip": "5743b391672e8179", + "b/b-2/template/quiet-text": "45236a574673171e", + "b/b-2/template/full-text": "7f131a83cf321e79", + "b/b-2/template/grid-9": "8afbf4dca2b076bd", + "b/b-2/template/mosaic": "0c9b5d584f3c5549", + "b/b-2/template/band-quote": "8ceee8c8b3770904", + "b/b-2/template/stagger-four": "40e66fc0a4024f8b", + "b/b-2/template/portrait-pair": "e14a62ffab5bb6ff", + "b/b-2/chain/quiet-text+grid-9": "bd1099864bfb4cf8", + "b/b-2/layoutFromSpread": "59a3a1ad72348e67", + "b/b-2/layoutRole": "0b17fc916c3c9a05", + "b/b-2/saved/ly-b1": "9d2abafed61e8ab2", + "b/b-3/template/hero-story": "4930d30b151e57cd", + "b/b-3/template/full-bleed": "cd6021317b4f130d", + "b/b-3/template/two-up": "10b9e7dc26e275d3", + "b/b-3/template/grid-4": "425d015b493a7302", + "b/b-3/template/grid-6": "caa987116ccb232b", + "b/b-3/template/strip": "712f03cbf83efd68", + "b/b-3/template/quiet-text": "d2085fd0011c93e7", + "b/b-3/template/full-text": "3738f69014f696c7", + "b/b-3/template/grid-9": "fb22023e2fc34a97", + "b/b-3/template/mosaic": "91ed2ff18701619b", + "b/b-3/template/band-quote": "9c19bd2fdae2fae9", + "b/b-3/template/stagger-four": "0b291200f386373e", + "b/b-3/template/portrait-pair": "d6788d9c44c7c3a8", + "b/b-3/chain/quiet-text+grid-9": "bd32b0b6bf1fcdb8", + "b/b-3/layoutFromSpread": "904af5e1d47a74f4", + "b/b-3/layoutRole": "0b17fc916c3c9a05", + "b/b-3/saved/ly-b1": "0706b939b85b22da", + "b/b-last/template/cover-full": "f072cd92c8a86030", + "b/b-last/template/cover-band": "bddf6811a2d1b365", + "b/b-last/template/cover-window": "d00bc25d36a0d60d", + "b/b-last/template/cover-quiet": "902b5350d9f346bf", + "b/b-last/template/cover-half": "1f1f2b5fa9f6e8cd", + "b/b-last/chain/cover-quiet+cover-full": "f072cd92c8a86030", + "b/b-last/layoutFromSpread": "a1ba0a5e54a828c5", + "b/b-last/layoutRole": "0fff5f85e4fa1de5", + "b/b-back/template/cover-full": "81ae283a61804872", + "b/b-back/template/cover-band": "ed0ec09b49944b1d", + "b/b-back/template/cover-window": "89fcd0d684712f8d", + "b/b-back/template/cover-quiet": "20a4ed7235b1dfa6", + "b/b-back/template/cover-half": "2ed9a743d8f95cc8", + "b/b-back/chain/cover-quiet+cover-full": "81ae283a61804872", + "b/b-back/layoutFromSpread": "150168ae50bf7620", + "b/b-back/layoutRole": "0fff5f85e4fa1de5", + "b/grid/single/spread": "7aed115f22cc4080", + "b/grid/single/single": "0e703f777970db51", + "b/grid/two-across/spread": "7a8b2d3e19844a09", + "b/grid/two-across/single": "c0fe47f5ea99da8e", + "b/grid/two-down/spread": "5668b83da46620c1", + "b/grid/two-down/single": "b06a3a6e75c743a3", + "b/grid/three-across/spread": "b8479a1dd9edc604", + "b/grid/three-across/single": "6910088fcc46454a", + "b/grid/three-down/spread": "0dea3ed3e7e0c96f", + "b/grid/three-down/single": "14001eb5e0b2cf56", + "b/grid/hero-two/spread": "6a6381cec4fadb87", + "b/grid/hero-two/single": "c1f962a79fd08f0c", + "b/grid/hero-three/spread": "417fcde7e2e07d5b", + "b/grid/hero-three/single": "bd916509fb56c251", + "b/grid/two-hero/spread": "138cdcf005bad495", + "b/grid/two-hero/single": "ba85a77b9631d685", + "b/grid/side-hero/spread": "5db0e5893ea3a178", + "b/grid/side-hero/single": "c1638b8200d12b90", + "b/grid/side-hero-left/spread": "70799a2c3958ad1c", + "b/grid/side-hero-left/single": "ea14f9969015064a", + "b/grid/side-three/spread": "49413259e6513647", + "b/grid/side-three/single": "7edf5a26495c7412", + "b/grid/grid-4/spread": "c3b30fd1f0a30efd", + "b/grid/grid-4/single": "6acc846c26e60139", + "b/grid/grid-6/spread": "3ff7d7754fce59d4", + "b/grid/grid-6/single": "624e3e0ffaf2a699", + "b/grid/grid-9/spread": "1427bdfcb2cc60e2", + "b/grid/grid-9/single": "13a716942e713401", + "b/grid/grid-8/spread": "1432931f58eaf65f", + "b/grid/grid-8/single": "fc94650a089ade79", + "b/grid/one-two/spread": "e454ecf5f86d55d4", + "b/grid/one-two/single": "e5cc37166238fea5", + "b/grid/two-one/spread": "629ab52b62c9cacb", + "b/grid/two-one/single": "279f7b91ad0b1893", + "b/grid/rows-1-2/spread": "d786ae17cea24e28", + "b/grid/rows-1-2/single": "f33f9eb9538cf61a", + "b/grid/rows-2-1/spread": "51144505404b0c44", + "b/grid/rows-2-1/single": "a16ca2030d0de0e5", + "b/grid/rows-2-3/spread": "a44f2a054be5d66e", + "b/grid/rows-2-3/single": "d6f1a492039efdf7", + "b/grid/rows-3-2/spread": "bf1de726ae04eecc", + "b/grid/rows-3-2/single": "bb74e7570793c683", + "b/grid/cols-1-2/spread": "e454ecf5f86d55d4", + "b/grid/cols-1-2/single": "e5cc37166238fea5", + "b/grid/cols-2-1/spread": "629ab52b62c9cacb", + "b/grid/cols-2-1/single": "279f7b91ad0b1893", + "b/grid/cols-2-1-2/spread": "b0943c50d5f76bdd", + "b/grid/cols-2-1-2/single": "35bb2c88aad8b04d", + "b/grid/cols-1-2-1/spread": "b15cb3ff37e62633", + "b/grid/cols-1-2-1/single": "adde5bdbdc6da90a", + "b/grid/band-three/spread": "848d2929d51790c3", + "b/grid/band-three/single": "ffd8e21bab926a8d", + "b/grid/band-four/spread": "1701bd3249d40d25", + "b/grid/band-four/single": "a6b8a2768ff08369", + "b/grid/stagger-four/spread": "85b213673b40bb1c", + "b/grid/stagger-four/single": "9ea81d8739e3c155", + "b/grid/stagger-three/spread": "1327c6dcd9c74889", + "b/grid/stagger-three/single": "83fa89c6c8891c17", + "b/grid/filmstrip/spread": "1d28005e7a6fac1e", + "b/grid/filmstrip/single": "883e4f39ebc016c5", + "b/grid/grid-12/spread": "cd5a037ed10273ad", + "b/grid/grid-12/single": "df48ebfb687dff6a", + "b/preview/ly-b1": "a198d537bf079995", + "b/nextLayoutName": "7d09d65a12896a39", + "c/c-cover/template/cover-full": "d0eefe623db2f832", + "c/c-cover/template/cover-band": "698a695ef0beab76", + "c/c-cover/template/cover-window": "458a8f6385115e8f", + "c/c-cover/template/cover-quiet": "b362f2372829aab0", + "c/c-cover/template/cover-half": "20c8e22d15418865", + "c/c-cover/chain/cover-quiet+cover-full": "d0eefe623db2f832", + "c/c-cover/layoutFromSpread": "159bbddc92655565", + "c/c-cover/layoutRole": "0fff5f85e4fa1de5", + "c/c-1/template/hero-story": "3bd41da88e579765", + "c/c-1/template/full-bleed": "a205147ac10d6f2e", + "c/c-1/template/two-up": "91d4aea8d80391e9", + "c/c-1/template/grid-4": "b90331b7e881e210", + "c/c-1/template/grid-6": "7f617bd2c9d286c6", + "c/c-1/template/strip": "3ae4d273c559d106", + "c/c-1/template/quiet-text": "16a9e2b5a5cc0fe6", + "c/c-1/template/full-text": "52024694a2b4171b", + "c/c-1/template/grid-9": "532d2db4f8314ebb", + "c/c-1/template/mosaic": "6e942e57c620933e", + "c/c-1/template/band-quote": "324d8f9f3539efcc", + "c/c-1/template/stagger-four": "92e98cb85126323b", + "c/c-1/template/portrait-pair": "ca58aedd07c6ad7f", + "c/c-1/chain/quiet-text+grid-9": "bed187156a55877c", + "c/c-1/layoutFromSpread": "9b153780130ea824", + "c/c-1/layoutRole": "0b17fc916c3c9a05", + "c/c-2/template/hero-story": "28b656fb05f2555d", + "c/c-2/template/full-bleed": "f6739d71a50291ea", + "c/c-2/template/two-up": "bfbf326e9cd05bc2", + "c/c-2/template/grid-4": "69d546878e7f3ca4", + "c/c-2/template/grid-6": "24c405ec459524ae", + "c/c-2/template/strip": "fe77b7b4282e30cf", + "c/c-2/template/quiet-text": "817c9edc47f79c2b", + "c/c-2/template/full-text": "ce612b95ee6fe15e", + "c/c-2/template/grid-9": "0867f6fa8416dacb", + "c/c-2/template/mosaic": "3fc5a62fe24560f1", + "c/c-2/template/band-quote": "6258a2bb776283da", + "c/c-2/template/stagger-four": "97642fe5eeaadf7d", + "c/c-2/template/portrait-pair": "cd8972e3d754dc50", + "c/c-2/chain/quiet-text+grid-9": "9dfb06e549f8afb3", + "c/c-2/layoutFromSpread": "7adae8d9eb92374c", + "c/c-2/layoutRole": "0b17fc916c3c9a05", + "c/c-back/template/cover-full": "8e3245e331b60b51", + "c/c-back/template/cover-band": "a75d6942afce7f25", + "c/c-back/template/cover-window": "4ade77d96666f8dd", + "c/c-back/template/cover-quiet": "3f6780307f4f25d3", + "c/c-back/template/cover-half": "d01c67d7dbc5fae1", + "c/c-back/chain/cover-quiet+cover-full": "8e3245e331b60b51", + "c/c-back/layoutFromSpread": "c0685776e757f7f0", + "c/c-back/layoutRole": "0fff5f85e4fa1de5", + "c/grid/single/spread": "115e5385006fb74b", + "c/grid/single/single": "71b48c555953f12d", + "c/grid/two-across/spread": "fa9ad0bb1247fc3a", + "c/grid/two-across/single": "140e90137ebd7d83", + "c/grid/two-down/spread": "e14d1d81209d1237", + "c/grid/two-down/single": "d66e63b09dc4fd80", + "c/grid/three-across/spread": "f35aca38df530d0c", + "c/grid/three-across/single": "dd5d1a41935a4ae5", + "c/grid/three-down/spread": "3131f700371e2b74", + "c/grid/three-down/single": "cb085d2a1e0552f0", + "c/grid/hero-two/spread": "c7bd1eb1c3c11c24", + "c/grid/hero-two/single": "21f89c6a81093056", + "c/grid/hero-three/spread": "5269ce71758a01a1", + "c/grid/hero-three/single": "167899e1fe10b8a8", + "c/grid/two-hero/spread": "8eac40625d1bf60e", + "c/grid/two-hero/single": "b18e2942e073e128", + "c/grid/side-hero/spread": "d5491292ec1b0763", + "c/grid/side-hero/single": "e5e78f63dffdc9bb", + "c/grid/side-hero-left/spread": "470d27086e7f0cb5", + "c/grid/side-hero-left/single": "488b7eed602b2041", + "c/grid/side-three/spread": "6b925bc631158893", + "c/grid/side-three/single": "971f74956d45bd67", + "c/grid/grid-4/spread": "043518fd5d20fe13", + "c/grid/grid-4/single": "4e96b86ac156baf5", + "c/grid/grid-6/spread": "19318b38ff69a764", + "c/grid/grid-6/single": "c749dcb29400f670", + "c/grid/grid-9/spread": "5fd76952ab552376", + "c/grid/grid-9/single": "9b0d400ae474f690", + "c/grid/grid-8/spread": "f5fe8dd0b05a478d", + "c/grid/grid-8/single": "1fd1e9890c0e3dea", + "c/grid/one-two/spread": "f996f471465e6a1a", + "c/grid/one-two/single": "92c8b266c0410b95", + "c/grid/two-one/spread": "763dd98ff628c4c6", + "c/grid/two-one/single": "255730c314109186", + "c/grid/rows-1-2/spread": "0b6a03fe2529be14", + "c/grid/rows-1-2/single": "2407833dc59ecef2", + "c/grid/rows-2-1/spread": "66c866e4c52575b5", + "c/grid/rows-2-1/single": "d801e484d8bc23c4", + "c/grid/rows-2-3/spread": "50d5d4751163bd0c", + "c/grid/rows-2-3/single": "4ce778de3690fbe1", + "c/grid/rows-3-2/spread": "7eacc57a2347b174", + "c/grid/rows-3-2/single": "d9b33a82985d08f5", + "c/grid/cols-1-2/spread": "f996f471465e6a1a", + "c/grid/cols-1-2/single": "92c8b266c0410b95", + "c/grid/cols-2-1/spread": "763dd98ff628c4c6", + "c/grid/cols-2-1/single": "255730c314109186", + "c/grid/cols-2-1-2/spread": "dfcc3095aea1e781", + "c/grid/cols-2-1-2/single": "4ad56cf7eac73027", + "c/grid/cols-1-2-1/spread": "4608f49708705150", + "c/grid/cols-1-2-1/single": "097c6faef7e7d63a", + "c/grid/band-three/spread": "cf13a9fefdc4de19", + "c/grid/band-three/single": "d46e5068fa4bc5e3", + "c/grid/band-four/spread": "02dcea8e00f11987", + "c/grid/band-four/single": "7a97fea769f197f2", + "c/grid/stagger-four/spread": "aed883e827e1511d", + "c/grid/stagger-four/single": "e3ae04ef38b38bd6", + "c/grid/stagger-three/spread": "999458eedf790049", + "c/grid/stagger-three/single": "5a52b5d48f28981c", + "c/grid/filmstrip/spread": "8c226bb4a0bace77", + "c/grid/filmstrip/single": "52b28c6930e0ef0c", + "c/grid/grid-12/spread": "9595dd6df14e4aef", + "c/grid/grid-12/single": "c943ac14e0fea630", + "c/nextLayoutName": "54e129379ad40472" +} diff --git a/shared/src/book/book-layouts.parity.spec.ts b/shared/src/book/book-layouts.parity.spec.ts new file mode 100644 index 000000000..4d86c6298 --- /dev/null +++ b/shared/src/book/book-layouts.parity.spec.ts @@ -0,0 +1,113 @@ +import { BOOK_GRIDS, bookGridElements, defaultBookGridBox } from './book-grids'; +import { BOOK_COVER_TEMPLATES, BOOK_TEMPLATES, applyBookTemplate, type BookTemplate } from './book-layouts'; +import { + applySavedBookLayout, + bookLayoutFromSpread, + bookLayoutPreview, + bookLayoutRole, + nextBookLayoutName, +} from './book-saved-layouts'; +import { normalizeBookDocument, type BookDocument } from './book.schema'; + +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +/** + * The layout code moved here from the Studio (templates, grids, saved layouts) + * still lays pages out exactly as the Studio's own copy did. + * + * `book-layouts.parity.json` holds a digest of every result the old client + * functions gave on the three books in `book-layouts.parity-books.json` (every + * layout on every page, a layout over a layout so the parked pictures come + * back, every grid in both boxes, every saved layout, the layout a page saves + * as and its preview), taken from the client code before it was deleted. Ids + * the call mints are random and read as `#new`; everything else must match to + * the hundredth of a millimetre. A deliberate change to a layout changes its + * digests: say so in the change and write the new ones. + */ + +const here = (file: string) => fileURLToPath(new URL(file, import.meta.url)); + +const books = ( + JSON.parse(readFileSync(here('./book-layouts.parity-books.json'), 'utf8')) as { books: Record } +).books; +const expected = JSON.parse(readFileSync(here('./book-layouts.parity.json'), 'utf8')) as Record; + +function canonical(value: unknown, known: Set): string { + const walk = (v: unknown, key?: string): unknown => { + if (Array.isArray(v)) return v.map((x) => walk(x)); + if (v && typeof v === 'object') { + const o = v as Record; + return Object.fromEntries( + Object.keys(o) + .sort() + .map((k) => [k, walk(o[k], k)]), + ); + } + if (key === 'id' && typeof v === 'string' && !known.has(v)) return '#new'; + return v; + }; + return JSON.stringify(walk(value)); +} + +const digest = (s: string) => createHash('sha256').update(s).digest('hex').slice(0, 16); + +const template = (id: string): BookTemplate => + [...BOOK_TEMPLATES, ...BOOK_COVER_TEMPLATES].find((t) => t.id === id) as BookTemplate; + +function knownIds(doc: BookDocument): Set { + const known = new Set(); + for (const s of doc.spreads) { + known.add(s.id); + for (const e of [...s.elements, ...s.parked]) known.add(e.id); + } + for (const l of doc.layouts ?? []) { + known.add(l.id); + for (const e of l.elements) known.add(e.id); + } + return known; +} + +function cases(): Map { + const out = new Map(); + for (const [name, input] of Object.entries(books)) { + const doc = normalizeBookDocument(input); + const known = knownIds(doc); + const put = (key: string, v: unknown) => out.set(`${name}/${key}`, canonical(v, known)); + const apply = (s: (typeof doc.spreads)[number], id: string) => applyBookTemplate(s, template(id), doc.page); + for (const s of doc.spreads) { + const set = s.role === 'inner' ? BOOK_TEMPLATES : BOOK_COVER_TEMPLATES; + for (const t of set) put(`${s.id}/template/${t.id}`, apply(s, t.id)); + const [empty, full] = s.role === 'inner' ? ['quiet-text', 'grid-9'] : ['cover-quiet', 'cover-full']; + put(`${s.id}/chain/${empty}+${full}`, apply(apply(s, empty as string), full as string)); + put(`${s.id}/layoutFromSpread`, bookLayoutFromSpread(s, doc.page, 'L')); + put(`${s.id}/layoutRole`, bookLayoutRole(s)); + for (const l of doc.layouts ?? []) { + if (l.role === bookLayoutRole(s)) put(`${s.id}/saved/${l.id}`, applySavedBookLayout(s, l, doc.page)); + } + } + for (const g of BOOK_GRIDS) { + put(`grid/${g.id}/spread`, bookGridElements(g, defaultBookGridBox(doc.page, false), 3, 0)); + put(`grid/${g.id}/single`, bookGridElements(g, defaultBookGridBox(doc.page, true), 6, 2)); + } + for (const l of doc.layouts ?? []) put(`preview/${l.id}`, bookLayoutPreview(l, doc.page)); + put('nextLayoutName', nextBookLayoutName(doc.layouts ?? [], 'Layout')); + } + return out; +} + +describe('the layout code moved from the Studio', () => { + const actual = cases(); + + it('BOOKPAR-001: covers the same cases the client code was measured on', () => { + expect([...actual.keys()].sort()).toEqual(Object.keys(expected).sort()); + expect(actual.size).toBeGreaterThan(400); + }); + + it('BOOKPAR-002: lays out every fixture page exactly as the client code did', () => { + const differ = [...actual].filter(([key, value]) => expected[key] !== digest(value)).map(([key]) => key); + expect(differ).toEqual([]); + }); +}); diff --git a/shared/src/book/book-layouts.spec.ts b/shared/src/book/book-layouts.spec.ts new file mode 100644 index 000000000..82f935be1 --- /dev/null +++ b/shared/src/book/book-layouts.spec.ts @@ -0,0 +1,210 @@ +import { + BOOK_COVER_TEMPLATES, + BOOK_TEMPLATES, + applyBookTemplate, + classifyBookTexts, + findBookTemplate, +} from './book-layouts'; +import { bookPageSetupSchema, type BookElement, type BookPageSetup } from './book.schema'; + +import { describe, expect, it } from 'vitest'; + +/** + * Layouts (#1973), moved with their code from the Studio + * (client/tests/unit/studio/templates.test.ts). + * + * The cover set exists because a cover is a different problem from a spread: + * one page, type as the subject rather than as a caption. A spread layout + * applied to it lands half its frames past the edge of the page, which is why + * the two sets are kept apart rather than filtered out of one. + */ + +const page: BookPageSetup = bookPageSetupSchema.parse({ + preset: 'square-210', + pageWidth: 210, + pageHeight: 210, +}); + +describe('the layout catalogue', () => { + it('BOOKLAY-001: gives every layout a unique id across both sets', () => { + const ids = [...BOOK_TEMPLATES, ...BOOK_COVER_TEMPLATES].map((t) => t.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('BOOKLAY-002: gives every layout at least one slot', () => { + for (const tpl of [...BOOK_TEMPLATES, ...BOOK_COVER_TEMPLATES]) { + expect(tpl.build(page).length, tpl.id).toBeGreaterThan(0); + } + }); + + it('BOOKLAY-003: counts its photo slots honestly', () => { + for (const tpl of [...BOOK_TEMPLATES, ...BOOK_COVER_TEMPLATES]) { + const drawn = tpl.build(page).filter((s) => s.kind === 'photo').length; + expect(drawn, tpl.id).toBe(tpl.photoSlots); + } + }); + + it('BOOKLAY-004: gives every slot a frame with a positive size', () => { + for (const tpl of [...BOOK_TEMPLATES, ...BOOK_COVER_TEMPLATES]) { + for (const slot of tpl.build(page)) { + expect(slot.frame.w, tpl.id).toBeGreaterThan(0); + expect(slot.frame.h, tpl.id).toBeGreaterThan(0); + } + } + }); + + it('BOOKLAY-005: finds a layout by id in either set, and nothing for an unknown id', () => { + expect(findBookTemplate('grid-4')?.photoSlots).toBe(4); + expect(findBookTemplate('cover-band')?.role).toBe('single'); + expect(findBookTemplate('nope')).toBeUndefined(); + }); +}); + +describe('the cover set', () => { + it('BOOKLAY-006: is marked as single-page throughout', () => { + for (const tpl of BOOK_COVER_TEMPLATES) { + expect(tpl.role, tpl.id).toBe('single'); + } + }); + + /* + * The rule that makes them a separate set: everything stays on one page. + * A frame reaching past pageWidth would be half in the fold of a book that + * has no fold there. + */ + it('BOOKLAY-007: keeps every frame on the one page', () => { + for (const tpl of BOOK_COVER_TEMPLATES) { + for (const slot of tpl.build(page)) { + expect(slot.frame.x, `${tpl.id} x`).toBeGreaterThanOrEqual(-page.bleed - 0.01); + expect(slot.frame.x + slot.frame.w, `${tpl.id} right`).toBeLessThanOrEqual(page.pageWidth + page.bleed + 0.01); + expect(slot.frame.y + slot.frame.h, `${tpl.id} bottom`).toBeLessThanOrEqual( + page.pageHeight + page.bleed + 0.01, + ); + } + } + }); + + it('BOOKLAY-008: always has somewhere for the title', () => { + for (const tpl of BOOK_COVER_TEMPLATES) { + expect( + tpl.build(page).some((s) => s.kind === 'heading'), + tpl.id, + ).toBe(true); + } + }); + + it('BOOKLAY-009: includes one that needs no photograph at all', () => { + expect(BOOK_COVER_TEMPLATES.some((t) => t.photoSlots === 0)).toBe(true); + }); +}); + +describe('the spread set', () => { + it('BOOKLAY-010: is not marked single, so the panel keeps the two apart', () => { + for (const tpl of BOOK_TEMPLATES) { + expect(tpl.role ?? 'inner', tpl.id).toBe('inner'); + } + }); + + it('BOOKLAY-011: stays within the spread', () => { + for (const tpl of BOOK_TEMPLATES) { + for (const slot of tpl.build(page)) { + expect(slot.frame.x + slot.frame.w, `${tpl.id} right`).toBeLessThanOrEqual( + page.pageWidth * 2 + page.bleed + 0.01, + ); + } + } + }); + + it('BOOKLAY-012: offers a range of photo counts rather than five versions of one idea', () => { + const counts = new Set(BOOK_TEMPLATES.map((t) => t.photoSlots)); + expect(counts.size).toBeGreaterThanOrEqual(6); + }); +}); + +describe('applying a cover layout', () => { + const spread = { + id: 'c1', + role: 'cover' as const, + background: null, + parked: [], + entryId: null, + elements: [ + { + id: 'p1', + kind: 'photo' as const, + frame: { x: 0, y: 0, w: 50, h: 50 }, + rotation: 0, + opacity: 1, + locked: false, + photoId: 42, + fit: 'cover' as const, + focalX: 0.5, + focalY: 0.5, + radius: 0, + filter: 'none' as const, + mask: null, + frameStyle: 'none' as const, + }, + { + id: 't1', + kind: 'text' as const, + frame: { x: 0, y: 60, w: 100, h: 20 }, + rotation: 0, + opacity: 1, + locked: false, + text: 'Iceland', + font: 'sans' as const, + size: 30, + weight: 700 as const, + italic: false, + align: 'left' as const, + leading: 1.1, + tracking: 0, + color: '#111111', + binding: null, + overridden: false, + }, + ], + }; + const full = BOOK_COVER_TEMPLATES[0] as (typeof BOOK_COVER_TEMPLATES)[number]; + const quiet = BOOK_COVER_TEMPLATES.find((t) => t.photoSlots === 0) as (typeof BOOK_COVER_TEMPLATES)[number]; + + it('BOOKLAY-013: pours the existing picture and title into the new arrangement', () => { + const next = applyBookTemplate(spread, full, page); + const photo = next.elements.find((e) => e.kind === 'photo'); + const text = next.elements.find((e) => e.kind === 'text'); + expect(photo?.kind === 'photo' && photo.photoId).toBe(42); + expect(text?.kind === 'text' && text.text).toBe('Iceland'); + }); + + /* + * Trying the type-only cover must not cost the photograph: it comes back + * the moment a layout with a frame is applied. + */ + it('BOOKLAY-014: parks what a type-only cover has no room for', () => { + const next = applyBookTemplate(spread, quiet, page); + expect(next.elements.some((e) => e.kind === 'photo')).toBe(false); + expect(next.parked.some((e) => e.kind === 'photo' && e.photoId === 42)).toBe(true); + }); + + it('BOOKLAY-015: brings the parked picture back on a layout that has room', () => { + const parked = applyBookTemplate(spread, quiet, page); + const back = applyBookTemplate(parked, full, page); + const photo = back.elements.find((e) => e.kind === 'photo'); + expect(photo?.kind === 'photo' && photo.photoId).toBe(42); + }); +}); + +describe('which text plays which part', () => { + const t = (id: string, text: string, size: number) => ({ id, kind: 'text', text, size }) as BookElement; + + it('BOOKLAY-016: takes the largest type of 14 pt or more as the heading, the longest of the rest as the story', () => { + const roles = classifyBookTexts([t('m', 'MAY', 7.5), t('b', 'A long story', 10), t('h', 'Title', 22)]); + expect([roles.heading?.id, roles.body?.id, roles.meta?.id]).toEqual(['h', 'b', 'm']); + }); + + it('BOOKLAY-017: leaves the heading empty when nothing is set large', () => { + const roles = classifyBookTexts([t('a', 'Short', 9), t('b', 'Longer words', 9)]); + expect([roles.heading, roles.body?.id, roles.meta?.id]).toEqual([null, 'b', 'a']); + }); +}); diff --git a/client/src/components/Studio/templates.ts b/shared/src/book/book-layouts.ts similarity index 55% rename from client/src/components/Studio/templates.ts rename to shared/src/book/book-layouts.ts index 7836e16d3..71544aae4 100644 --- a/client/src/components/Studio/templates.ts +++ b/shared/src/book/book-layouts.ts @@ -1,9 +1,15 @@ -import type { BookElement, BookPageSetup, BookSpread } from '@trek/shared' +import { bookElementId } from './book-ids'; +import type { BookElement, BookPageSetup, BookSpread } from './book.schema'; /** * Layout templates for a spread. * - * A template is not a special mode and not a locked page — it is a set of frames + * Moved from the Studio (client/src/components/Studio/templates.ts) so the + * editor, the assistant's book tools and a chat card mean the same thing by + * "choose a layout". The arrangements and the applier are unchanged; the + * parity spec beside this file pins that. + * + * A template is not a special mode and not a locked page: it is a set of frames * with the pictures left out. Applying one keeps the photos and the words that * are already on the spread and pours them into the new arrangement, in order; * anything the new layout has room for but the old one did not fill stays an @@ -20,82 +26,101 @@ import type { BookElement, BookPageSetup, BookSpread } from '@trek/shared' * an ordinary spread. */ -export interface Template { - id: string - labelKey: string +export interface BookTemplate { + id: string; + labelKey: string; /** How many pictures the layout has room for. */ - photoSlots: number + photoSlots: number; /** * Which spreads this arrangement is for. * * A cover is one page rather than two, and it is the one page where the type - * is the subject — a layout built for a spread lands half of itself in the + * is the subject: a layout built for a spread lands half of itself in the * fold, so the two sets have to be kept apart. Everything without this is an * inner layout, which is what all of them were. */ - role?: 'inner' | 'single' + role?: 'inner' | 'single'; /** Draw the frames for a page of this size. */ - build: (page: BookPageSetup) => TemplateSlot[] + build: (page: BookPageSetup) => BookTemplateSlot[]; } -export type TemplateSlot = +export type BookTemplateSlot = | { kind: 'photo'; frame: Frame } | { kind: 'heading'; frame: Frame } | { kind: 'body'; frame: Frame } - | { kind: 'meta'; frame: Frame } + | { kind: 'meta'; frame: Frame }; -interface Frame { x: number; y: number; w: number; h: number } - -const M = 16 -const G = 6 - -function grid(page: BookPageSetup, cols: number, rows: number, opts: { bleed?: boolean } = {}): Frame[] { - const W = page.pageWidth * 2 - const H = page.pageHeight - const pad = opts.bleed ? -page.bleed : M - const availW = (opts.bleed ? W + page.bleed * 2 : W - M * 2) - G * (cols - 1) - const availH = (opts.bleed ? H + page.bleed * 2 : H - M * 2) - G * (rows - 1) - const cw = availW / cols - const ch = availH / rows - const out: Frame[] = [] - for (let r = 0; r < rows; r++) { - for (let c = 0; c < cols; c++) { - out.push({ x: pad + c * (cw + G), y: pad + r * (ch + G), w: cw, h: ch }) - } - } - return out +interface Frame { + x: number; + y: number; + w: number; + h: number; } -/** The spread layouts. `COVER_TEMPLATES` holds the single-page set. */ -export const TEMPLATES: Template[] = [ +const M = 16; +const G = 6; + +function grid(page: BookPageSetup, cols: number, rows: number, opts: { bleed?: boolean } = {}): Frame[] { + const W = page.pageWidth * 2; + const H = page.pageHeight; + const pad = opts.bleed ? -page.bleed : M; + const availW = (opts.bleed ? W + page.bleed * 2 : W - M * 2) - G * (cols - 1); + const availH = (opts.bleed ? H + page.bleed * 2 : H - M * 2) - G * (rows - 1); + const cw = availW / cols; + const ch = availH / rows; + const out: Frame[] = []; + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + out.push({ x: pad + c * (cw + G), y: pad + r * (ch + G), w: cw, h: ch }); + } + } + return out; +} + +/** The spread layouts. `BOOK_COVER_TEMPLATES` holds the single-page set. */ +export const BOOK_TEMPLATES: BookTemplate[] = [ { id: 'hero-story', labelKey: 'journey.studio.tpl.heroStory', photoSlots: 4, - build: page => { - const W = page.pageWidth - const H = page.pageHeight - const col = W + M + 6 - const colW = W - M * 2 - 6 - const gridH = H * 0.24 + build: (page) => { + const W = page.pageWidth; + const H = page.pageHeight; + const col = W + M + 6; + const colW = W - M * 2 - 6; + const gridH = H * 0.24; return [ - // Exactly up to the gutter — see the note in autoLayout.ts. + // Exactly up to the gutter: see the note in autoLayout.ts. { kind: 'photo', frame: { x: -page.bleed, y: -page.bleed, w: W + page.bleed, h: H + page.bleed * 2 } }, { kind: 'meta', frame: { x: col, y: M + 18, w: colW, h: 5 } }, { kind: 'heading', frame: { x: col, y: M + 26, w: colW, h: 12 } }, { kind: 'body', frame: { x: col, y: M + 44, w: colW, h: H - M * 2 - 44 - gridH - 10 } }, { kind: 'photo', frame: { x: col, y: H - M - gridH, w: (colW - G * 2) / 3, h: gridH } }, - { kind: 'photo', frame: { x: col + (colW - G * 2) / 3 + G, y: H - M - gridH, w: (colW - G * 2) / 3, h: gridH } }, - { kind: 'photo', frame: { x: col + ((colW - G * 2) / 3 + G) * 2, y: H - M - gridH, w: (colW - G * 2) / 3, h: gridH } }, - ] + { + kind: 'photo', + frame: { x: col + (colW - G * 2) / 3 + G, y: H - M - gridH, w: (colW - G * 2) / 3, h: gridH }, + }, + { + kind: 'photo', + frame: { x: col + ((colW - G * 2) / 3 + G) * 2, y: H - M - gridH, w: (colW - G * 2) / 3, h: gridH }, + }, + ]; }, }, { id: 'full-bleed', labelKey: 'journey.studio.tpl.fullBleed', photoSlots: 1, - build: page => [ - { kind: 'photo', frame: { x: -page.bleed, y: -page.bleed, w: page.pageWidth * 2 + page.bleed * 2, h: page.pageHeight + page.bleed * 2 } }, + build: (page) => [ + { + kind: 'photo', + frame: { + x: -page.bleed, + y: -page.bleed, + w: page.pageWidth * 2 + page.bleed * 2, + h: page.pageHeight + page.bleed * 2, + }, + }, { kind: 'heading', frame: { x: M, y: page.pageHeight - M - 22, w: page.pageWidth - M, h: 12 } }, ], }, @@ -103,161 +128,164 @@ export const TEMPLATES: Template[] = [ id: 'two-up', labelKey: 'journey.studio.tpl.twoUp', photoSlots: 2, - build: page => { - const H = page.pageHeight - const W = page.pageWidth + build: (page) => { + const H = page.pageHeight; + const W = page.pageWidth; return [ { kind: 'photo', frame: { x: -page.bleed, y: -page.bleed, w: W + page.bleed, h: H + page.bleed * 2 } }, { kind: 'photo', frame: { x: W, y: -page.bleed, w: W + page.bleed, h: H + page.bleed * 2 } }, - ] + ]; }, }, { id: 'grid-4', labelKey: 'journey.studio.tpl.grid4', photoSlots: 4, - build: page => grid(page, 2, 2).map(frame => ({ kind: 'photo' as const, frame })), + build: (page) => grid(page, 2, 2).map((frame) => ({ kind: 'photo' as const, frame })), }, { id: 'grid-6', labelKey: 'journey.studio.tpl.grid6', photoSlots: 6, - build: page => grid(page, 3, 2).map(frame => ({ kind: 'photo' as const, frame })), + build: (page) => grid(page, 3, 2).map((frame) => ({ kind: 'photo' as const, frame })), }, { id: 'strip', labelKey: 'journey.studio.tpl.strip', photoSlots: 3, - build: page => { - const H = page.pageHeight - const W = page.pageWidth * 2 - const stripH = H * 0.52 - const cw = (W + page.bleed * 2 - G * 2) / 3 + build: (page) => { + const H = page.pageHeight; + const W = page.pageWidth * 2; + const stripH = H * 0.52; + const cw = (W + page.bleed * 2 - G * 2) / 3; return [ { kind: 'photo', frame: { x: -page.bleed, y: -page.bleed, w: cw, h: stripH + page.bleed } }, { kind: 'photo', frame: { x: -page.bleed + cw + G, y: -page.bleed, w: cw, h: stripH + page.bleed } }, { kind: 'photo', frame: { x: -page.bleed + (cw + G) * 2, y: -page.bleed, w: cw, h: stripH + page.bleed } }, { kind: 'meta', frame: { x: M, y: stripH + 14, w: page.pageWidth - M * 2, h: 5 } }, { kind: 'heading', frame: { x: M, y: stripH + 22, w: page.pageWidth - M * 2, h: 12 } }, - { kind: 'body', frame: { x: page.pageWidth + M, y: stripH + 14, w: page.pageWidth - M * 2, h: H - stripH - 14 - M } }, - ] + { + kind: 'body', + frame: { x: page.pageWidth + M, y: stripH + 14, w: page.pageWidth - M * 2, h: H - stripH - 14 - M }, + }, + ]; }, }, { id: 'quiet-text', labelKey: 'journey.studio.tpl.quietText', photoSlots: 0, - build: page => { - const W = page.pageWidth - const H = page.pageHeight + build: (page) => { + const W = page.pageWidth; + const H = page.pageHeight; return [ { kind: 'meta', frame: { x: W + M, y: M + 26, w: W - M * 2, h: 5 } }, { kind: 'heading', frame: { x: W + M, y: M + 34, w: W - M * 2, h: 14 } }, { kind: 'body', frame: { x: W + M, y: M + 54, w: W - M * 2, h: H - M * 2 - 54 } }, - ] + ]; }, }, { id: 'full-text', labelKey: 'journey.studio.tpl.fullText', photoSlots: 1, - build: page => { + build: (page) => { // A picture that owns the left page and a column of story on the right, // set narrow. The arrangement for an entry with one photograph worth // looking at and something to say about it. - const W = page.pageWidth - const H = page.pageHeight + const W = page.pageWidth; + const H = page.pageHeight; return [ { kind: 'photo', frame: { x: -page.bleed, y: -page.bleed, w: W + page.bleed, h: H + page.bleed * 2 } }, { kind: 'meta', frame: { x: W + M, y: H * 0.28, w: W - M * 2, h: 5 } }, { kind: 'heading', frame: { x: W + M, y: H * 0.28 + 8, w: W - M * 2, h: 14 } }, { kind: 'body', frame: { x: W + M, y: H * 0.28 + 26, w: (W - M * 2) * 0.82, h: H * 0.4 } }, - ] + ]; }, }, { id: 'grid-9', labelKey: 'journey.studio.tpl.grid9', photoSlots: 9, - build: page => grid(page, 3, 3).map(frame => ({ kind: 'photo' as const, frame })), + build: (page) => grid(page, 3, 3).map((frame) => ({ kind: 'photo' as const, frame })), }, { id: 'mosaic', labelKey: 'journey.studio.tpl.mosaic', photoSlots: 5, - build: page => { - const W = page.pageWidth * 2 - const H = page.pageHeight - const availW = W - M * 2 - G * 2 - const big = availW * 0.5 - const small = (availW - big) / 2 - const rowH = (H - M * 2 - G) / 2 + build: (page) => { + const W = page.pageWidth * 2; + const H = page.pageHeight; + const availW = W - M * 2 - G * 2; + const big = availW * 0.5; + const small = (availW - big) / 2; + const rowH = (H - M * 2 - G) / 2; return [ { kind: 'photo', frame: { x: M, y: M, w: big, h: rowH * 2 + G } }, { kind: 'photo', frame: { x: M + big + G, y: M, w: small, h: rowH } }, { kind: 'photo', frame: { x: M + big + G * 2 + small, y: M, w: small, h: rowH } }, { kind: 'photo', frame: { x: M + big + G, y: M + rowH + G, w: small, h: rowH } }, { kind: 'photo', frame: { x: M + big + G * 2 + small, y: M + rowH + G, w: small, h: rowH } }, - ] + ]; }, }, { id: 'band-quote', labelKey: 'journey.studio.tpl.bandQuote', photoSlots: 2, - build: page => { + build: (page) => { // Two pictures with the words between them, set large. For the entry that // said something worth reading twice. - const W = page.pageWidth - const H = page.pageHeight - const band = H * 0.34 - const top = (H - band) / 2 + const W = page.pageWidth; + const H = page.pageHeight; + const band = H * 0.34; + const top = (H - band) / 2; return [ { kind: 'photo', frame: { x: -page.bleed, y: -page.bleed, w: W + page.bleed, h: top + page.bleed } }, { kind: 'photo', frame: { x: W, y: -page.bleed, w: W + page.bleed, h: top + page.bleed } }, { kind: 'heading', frame: { x: M * 2, y: top + band * 0.24, w: W * 2 - M * 4, h: 16 } }, { kind: 'body', frame: { x: W * 0.5, y: top + band * 0.62, w: W, h: band * 0.3 } }, - ] + ]; }, }, { id: 'stagger-four', labelKey: 'journey.studio.tpl.staggerFour', photoSlots: 4, - build: page => { + build: (page) => { // Alternate frames drop, which is what stops four squares in a row // reading as a contact sheet. - const W = page.pageWidth * 2 - const H = page.pageHeight - const cw = (W - M * 2 - G * 3) / 4 - const ch = H * 0.62 - return [0, 1, 2, 3].map(i => ({ + const W = page.pageWidth * 2; + const H = page.pageHeight; + const cw = (W - M * 2 - G * 3) / 4; + const ch = H * 0.62; + return [0, 1, 2, 3].map((i) => ({ kind: 'photo' as const, frame: { x: M + i * (cw + G), y: M + (i % 2 ? H * 0.16 : 0), w: cw, h: ch }, - })) + })); }, }, { id: 'portrait-pair', labelKey: 'journey.studio.tpl.portraitPair', photoSlots: 2, - build: page => { - const W = page.pageWidth - const H = page.pageHeight - const pw = W - M * 2 - const ph = H - M * 2 - 24 + build: (page) => { + const W = page.pageWidth; + const H = page.pageHeight; + const pw = W - M * 2; + const ph = H - M * 2 - 24; return [ { kind: 'photo', frame: { x: M, y: M, w: pw, h: ph } }, { kind: 'photo', frame: { x: W + M, y: M, w: pw, h: ph } }, { kind: 'meta', frame: { x: M, y: H - M - 16, w: pw, h: 5 } }, { kind: 'body', frame: { x: W + M, y: H - M - 16, w: pw, h: 12 } }, - ] + ]; }, }, -] +]; /** - * Layouts for a single page — the cover and the back. + * Layouts for a single page: the cover and the back. * * Kept apart from the spread layouts because a cover is a different problem: it * is one page, the type is the subject rather than a caption to a photograph, @@ -265,14 +293,22 @@ export const TEMPLATES: Template[] = [ * looked at while a title sits over it legibly. A spread layout applied here * would put half its frames past the edge of the page. */ -export const COVER_TEMPLATES: Template[] = [ +export const BOOK_COVER_TEMPLATES: BookTemplate[] = [ { id: 'cover-full', labelKey: 'journey.studio.tpl.coverFull', photoSlots: 1, role: 'single', - build: page => [ - { kind: 'photo', frame: { x: -page.bleed, y: -page.bleed, w: page.pageWidth + page.bleed * 2, h: page.pageHeight + page.bleed * 2 } }, + build: (page) => [ + { + kind: 'photo', + frame: { + x: -page.bleed, + y: -page.bleed, + w: page.pageWidth + page.bleed * 2, + h: page.pageHeight + page.bleed * 2, + }, + }, { kind: 'heading', frame: { x: M, y: page.pageHeight - M - 46, w: page.pageWidth - M * 2, h: 30 } }, { kind: 'meta', frame: { x: M, y: page.pageHeight - M - 12, w: page.pageWidth - M * 2, h: 8 } }, ], @@ -282,16 +318,19 @@ export const COVER_TEMPLATES: Template[] = [ labelKey: 'journey.studio.tpl.coverBand', photoSlots: 1, role: 'single', - build: page => { + build: (page) => { // The picture stops short of the foot, and the title sits on the paper - // rather than on the photograph — the safest cover there is, because it + // rather than on the photograph, the safest cover there is, because it // is legible whatever the photograph turns out to be. - const h = page.pageHeight * 0.68 + const h = page.pageHeight * 0.68; return [ - { kind: 'photo', frame: { x: -page.bleed, y: -page.bleed, w: page.pageWidth + page.bleed * 2, h: h + page.bleed } }, + { + kind: 'photo', + frame: { x: -page.bleed, y: -page.bleed, w: page.pageWidth + page.bleed * 2, h: h + page.bleed }, + }, { kind: 'heading', frame: { x: M, y: h + 16, w: page.pageWidth - M * 2, h: 26 } }, { kind: 'meta', frame: { x: M, y: h + 44, w: page.pageWidth - M * 2, h: 8 } }, - ] + ]; }, }, { @@ -299,12 +338,18 @@ export const COVER_TEMPLATES: Template[] = [ labelKey: 'journey.studio.tpl.coverWindow', photoSlots: 1, role: 'single', - build: page => { - const inset = page.pageWidth * 0.13 + build: (page) => { + const inset = page.pageWidth * 0.13; return [ - { kind: 'photo', frame: { x: inset, y: inset, w: page.pageWidth - inset * 2, h: page.pageHeight - inset * 2.6 } }, - { kind: 'heading', frame: { x: inset, y: page.pageHeight - inset * 1.4, w: page.pageWidth - inset * 2, h: 20 } }, - ] + { + kind: 'photo', + frame: { x: inset, y: inset, w: page.pageWidth - inset * 2, h: page.pageHeight - inset * 2.6 }, + }, + { + kind: 'heading', + frame: { x: inset, y: page.pageHeight - inset * 1.4, w: page.pageWidth - inset * 2, h: 20 }, + }, + ]; }, }, { @@ -312,7 +357,7 @@ export const COVER_TEMPLATES: Template[] = [ labelKey: 'journey.studio.tpl.coverQuiet', photoSlots: 0, role: 'single', - build: page => [ + build: (page) => [ { kind: 'heading', frame: { x: M, y: page.pageHeight * 0.42, w: page.pageWidth - M * 2, h: 28 } }, { kind: 'meta', frame: { x: M, y: page.pageHeight * 0.42 + 32, w: page.pageWidth - M * 2, h: 8 } }, ], @@ -322,27 +367,52 @@ export const COVER_TEMPLATES: Template[] = [ labelKey: 'journey.studio.tpl.coverHalf', photoSlots: 2, role: 'single', - build: page => { - const half = page.pageHeight * 0.5 + build: (page) => { + const half = page.pageHeight * 0.5; return [ - { kind: 'photo', frame: { x: -page.bleed, y: -page.bleed, w: page.pageWidth + page.bleed * 2, h: half + page.bleed } }, - { kind: 'photo', frame: { x: -page.bleed, y: half, w: page.pageWidth + page.bleed * 2, h: page.pageHeight - half + page.bleed } }, + { + kind: 'photo', + frame: { x: -page.bleed, y: -page.bleed, w: page.pageWidth + page.bleed * 2, h: half + page.bleed }, + }, + { + kind: 'photo', + frame: { + x: -page.bleed, + y: half, + w: page.pageWidth + page.bleed * 2, + h: page.pageHeight - half + page.bleed, + }, + }, { kind: 'heading', frame: { x: M, y: half - 34, w: page.pageWidth - M * 2, h: 26 } }, - ] + ]; }, }, -] +]; -const uid = (p: string) => `${p}-${Math.random().toString(36).slice(2, 9)}` +/** A template by id, from either set, or undefined. */ +export function findBookTemplate(id: string): BookTemplate | undefined { + return BOOK_TEMPLATES.find((t) => t.id === id) ?? BOOK_COVER_TEMPLATES.find((t) => t.id === id); +} -const HEADING = { size: 22, weight: 700 as const, leading: 1.1, tracking: -0.02, color: '#141414' } -const META = { size: 7.5, weight: 600 as const, tracking: 0.14, color: '#8a8578' } -const BODY = { size: 10, weight: 400 as const, leading: 1.6, color: '#2a2a2a' } +/** The text styles a template slot is set in, by its kind. */ +export const BOOK_TEXT_STYLES = { + heading: { size: 22, weight: 700 as const, leading: 1.1, tracking: -0.02, color: '#141414' }, + meta: { size: 7.5, weight: 600 as const, tracking: 0.14, color: '#8a8578' }, + body: { size: 10, weight: 400 as const, leading: 1.6, color: '#2a2a2a' }, +}; +const HEADING = BOOK_TEXT_STYLES.heading; +const META = BOOK_TEXT_STYLES.meta; +const BODY = BOOK_TEXT_STYLES.body; -function textEl(frame: Frame, text: string, style: typeof HEADING | typeof META | typeof BODY, source: BookElement | null): BookElement { - const base = source && source.kind === 'text' ? source : null +function textEl( + frame: Frame, + text: string, + style: typeof HEADING | typeof META | typeof BODY, + source: BookElement | null, +): BookElement { + const base = source && source.kind === 'text' ? source : null; return { - id: uid('t'), + id: bookElementId('t'), kind: 'text', frame, rotation: 0, @@ -356,7 +426,34 @@ function textEl(frame: Frame, text: string, style: typeof HEADING | typeof META binding: base?.binding ?? null, overridden: base?.overridden ?? false, ...style, - } as BookElement + } as BookElement; +} + +/** + * Which of a spread's texts is its heading, its story and its date line. + * + * Size decides the heading first, and length only sorts what is left. The + * other way round, longest is the story, then look for a heading among the + * rest, reads a spread with three texts correctly and gets a cover with one + * exactly wrong: the single 30pt title becomes "the longest text", so it is + * the story, so a cover layout with a heading slot and no body slot has + * nowhere to put it and parks the title. + */ +export function classifyBookTexts(texts: readonly BookElement[]): { + heading: BookElement | null; + body: BookElement | null; + meta: BookElement | null; +} { + const heading = + [...texts] + .filter((t) => t.kind === 'text' && t.size >= 14) + .sort((a, b) => (b.kind === 'text' ? b.size : 0) - (a.kind === 'text' ? a.size : 0))[0] ?? null; + const rest = texts.filter((t) => t !== heading); + const body = + [...rest].sort((a, b) => (b.kind === 'text' ? b.text.length : 0) - (a.kind === 'text' ? a.text.length : 0))[0] ?? + null; + const meta = rest.find((t) => t !== body) ?? null; + return { heading, body, meta }; } /** @@ -367,42 +464,25 @@ function textEl(frame: Frame, text: string, style: typeof HEADING | typeof META * binding to the journal entry, so a re-laid page still updates when the entry * is edited. */ -export function applyTemplate(spread: BookSpread, tpl: Template, page: BookPageSetup): BookSpread { - const slots = tpl.build(page) +export function applyBookTemplate(spread: BookSpread, tpl: BookTemplate, page: BookPageSetup): BookSpread { + const slots = tpl.build(page); // Placed content first, then whatever an earlier layout had to park: the // pictures on the page keep their order, and the ones that were set aside // queue up behind them. - const pool = [...spread.elements, ...(spread.parked ?? [])] - const photos = pool.filter(e => e.kind === 'photo') - const texts = pool.filter(e => e.kind === 'text') + const pool = [...spread.elements, ...(spread.parked ?? [])]; + const photos = pool.filter((e) => e.kind === 'photo'); + const texts = pool.filter((e) => e.kind === 'text'); + const { heading, body, meta } = classifyBookTexts(texts); - /* - * Which text is the heading, which the story, which the date line. - * - * Size decides the heading first, and length only sorts what is left. The - * other way round — longest is the story, then look for a heading among the - * rest — reads a spread with three texts correctly and gets a cover with one - * exactly wrong: the single 30pt title becomes "the longest text", so it is - * the story, so a cover layout with a heading slot and no body slot has - * nowhere to put it and parks the title. - */ - const heading = [...texts] - .filter(t => t.kind === 'text' && t.size >= 14) - .sort((a, b) => (b.kind === 'text' ? b.size : 0) - (a.kind === 'text' ? a.size : 0))[0] ?? null - const rest = texts.filter(t => t !== heading) - const body = [...rest] - .sort((a, b) => (b.kind === 'text' ? b.text.length : 0) - (a.kind === 'text' ? a.text.length : 0))[0] ?? null - const meta = rest.find(t => t !== body) ?? null - - let pi = 0 - const out: BookElement[] = [] - const usedText = new Set() + let pi = 0; + const out: BookElement[] = []; + const usedText = new Set(); for (const slot of slots) { if (slot.kind === 'photo') { - const src = photos[pi++] + const src = photos[pi++]; out.push({ - id: uid('p'), + id: bookElementId('p'), kind: 'photo', frame: slot.frame, rotation: 0, @@ -419,29 +499,24 @@ export function applyTemplate(spread: BookSpread, tpl: Template, page: BookPageS // its Polaroid. mask: src && src.kind === 'photo' ? src.mask : null, frameStyle: src && src.kind === 'photo' ? src.frameStyle : 'none', - } as BookElement) - continue + } as BookElement); + continue; } - const src = slot.kind === 'heading' ? heading : slot.kind === 'meta' ? meta : body - const value = src && src.kind === 'text' ? src.text : '' - if (!value) continue - if (src) usedText.add(src) - out.push(textEl( - slot.frame, - value, - slot.kind === 'heading' ? HEADING : slot.kind === 'meta' ? META : BODY, - src, - )) + const src = slot.kind === 'heading' ? heading : slot.kind === 'meta' ? meta : body; + const value = src && src.kind === 'text' ? src.text : ''; + if (!value) continue; + if (src) usedText.add(src); + out.push(textEl(slot.frame, value, slot.kind === 'heading' ? HEADING : slot.kind === 'meta' ? META : BODY, src)); } // Everything the arrangement could not take. An empty photo frame carries no // picture, so it is not worth parking; a photo with an id and a text that was // never placed are. const parked = [ - ...photos.slice(pi).filter(e => e.kind === 'photo' && e.photoId != null), - ...texts.filter(e => !usedText.has(e) && e.kind === 'text' && e.text.trim()), - ].slice(0, 60) + ...photos.slice(pi).filter((e) => e.kind === 'photo' && e.photoId != null), + ...texts.filter((e) => !usedText.has(e) && e.kind === 'text' && e.text.trim()), + ].slice(0, 60); - return { ...spread, elements: out, parked } + return { ...spread, elements: out, parked }; } diff --git a/shared/src/book/book-ops-core.ts b/shared/src/book/book-ops-core.ts new file mode 100644 index 000000000..cf39326b0 --- /dev/null +++ b/shared/src/book/book-ops-core.ts @@ -0,0 +1,174 @@ +import { bookSpreadSchema, type BookDocument, type BookSpread } from './book.schema'; + +import type { z } from 'zod'; + +/** + * What the book edit engine answers with, and the pieces its page and spread + * operations share. A refusal is a value, never an exception: nothing thrown + * in here leaves `caught`. + */ + +export const BOOK_OP_ERROR_CODES = [ + 'invalid_op', + 'spread_not_found', + 'element_not_found', + 'wrong_kind', + 'locked', + 'protected_spread', + 'index_out_of_range', + 'photo_not_in_gallery', + 'entry_not_found', + 'slot_not_found', + 'layout_not_found', + 'layout_role', + 'too_many_elements', + 'too_many_spreads', + 'parked_full', + 'empty_frame', + 'caption_missing', + 'text_required', + 'no_cover', +] as const; +export type BookOpErrorCode = (typeof BOOK_OP_ERROR_CODES)[number]; + +export interface BookOpError { + code: BookOpErrorCode; + /** One sentence a model can act on. */ + message: string; + /** Which operation of the call, from 0; null when the call as a whole was refused. */ + opIndex: number | null; + elementId?: string; +} + +/** What the engine knows about a gallery photo. */ +export interface BookPhotoInfo { + photoId: number; + /** The original's size in pixels when known. Never a thumbnail's: the print check reads it. */ + width?: number | null; + height?: number | null; + caption?: string | null; + /** A photo by another contributor, drawn as an empty frame in a page image. */ + hidden?: boolean; +} + +/** The journey's words a text can be bound to. */ +export interface BookJourneyWords { + title: string; + subtitle?: string | null; + entries?: readonly { id: number; title?: string | null; story?: string | null; location?: string | null }[]; +} + +export interface BookOpsContext { + /** The journey gallery. When given, a photo an operation brings in must be in it. */ + photos?: readonly BookPhotoInfo[]; + journey?: BookJourneyWords; + /** Mints element and spread ids; tests pass a counter. */ + newId?: (prefix: string) => string; +} + +/** + * `created` names the elements and pages a call made. `dropped` names the + * elements whose content was on the page or parked with it before and is in + * neither place after (`droppedIds`): deleted by a `remove` with `park: false`, + * or left out by a layout, which carries photos and texts and drops shapes, + * maps, badges, stats, lists and icons, exactly as the Studio's layout picker + * does. The operations on whole pages (add, move, delete, restore) answer an + * empty list; a deleted page is named in `spreadIds`. + */ +export type BookEditResult = + | { ok: true; doc: BookDocument; spreadIds: string[]; created: string[]; dropped: string[] } + | { ok: false; error: BookOpError }; + +/** A refusal on its way out of an operation, to `caught`. */ +class BookOpFailure extends Error { + constructor(readonly error: BookOpError) { + super(error.message); + } +} + +export const failOp = (code: BookOpErrorCode, message: string, opIndex: number | null, elementId?: string): Error => + new BookOpFailure({ code, message, opIndex, ...(elementId ? { elementId } : {}) }); + +/** Run `fn`, turning a refusal into a result. Anything else is a bug and is not swallowed. */ +export function caught(fn: () => BookEditResult): BookEditResult { + try { + return fn(); + } catch (e) { + if (e instanceof BookOpFailure) return { ok: false, error: e.error }; + throw e; + } +} + +/** The first issue of a failed parse, as a refusal; `opAt` is where the operation's index sits in the path. */ +export function invalidOp(error: z.ZodError, opAt: number | null): BookOpError { + const issue = error.issues[0]; + const path = issue?.path ?? []; + const at = path.length ? ` at ${path.map(String).join('.')}` : ''; + const index = opAt === null ? undefined : path[opAt]; + return { + code: 'invalid_op', + message: `${issue?.message ?? 'invalid operation'}${at}`, + opIndex: typeof index === 'number' ? index : null, + }; +} + +export const round2 = (v: number) => Math.round(v * 100) / 100; + +/** Ids present in `after` and in no spread of `before`: the elements and pages a call made. */ +export function createdIds(before: readonly BookSpread[], after: readonly BookSpread[]): string[] { + const ids = (list: readonly BookSpread[]) => + list.flatMap((s) => [s.id, ...[...s.elements, ...s.parked].map((e) => e.id)]); + const old = new Set(ids(before)); + return ids(after).filter((id) => !old.has(id)); +} + +/** + * Ids of elements in `before` whose content no spread of `after` holds any + * more, placed or parked: kept by id, or for a photo by its picture and for a + * text by its words, since a layout carries those into new frames with new + * ids. An empty frame or a blank text carries nothing and is never named. + */ +export function droppedIds(before: readonly BookSpread[], after: readonly BookSpread[]): string[] { + const all = (list: readonly BookSpread[]) => list.flatMap((s) => [...s.elements, ...s.parked]); + const kept = all(after); + const ids = new Set(kept.map((e) => e.id)); + const photos = new Set(kept.flatMap((e) => (e.kind === 'photo' && e.photoId != null ? [e.photoId] : []))); + const words = new Set(kept.flatMap((e) => (e.kind === 'text' ? [e.text] : []))); + return all(before) + .filter((e) => { + if (ids.has(e.id)) return false; + if (e.kind === 'photo') return e.photoId != null && !photos.has(e.photoId); + if (e.kind === 'text') return e.text.trim() !== '' && !words.has(e.text); + return true; + }) + .map((e) => e.id); +} + +/** + * A page the engine is about to hand back, checked against the document's own + * schema, so a call can never answer with a page the save route would refuse. + * The page itself goes back as it was built; the parse only judges it. + */ +export function checkedSpread(spread: BookSpread, opIndex: number | null): BookSpread { + const parsed = bookSpreadSchema.safeParse(spread); + if (parsed.success) return spread; + const issue = parsed.error.issues[0]; + const at = issue?.path.length ? ` at ${issue.path.map(String).join('.')}` : ''; + throw failOp('invalid_op', `the page would not be valid: ${issue?.message ?? 'invalid page'}${at}`, opIndex); +} + +/** A journey entry an operation refers to, checked against the journey when the context lists its entries. */ +export function journeyEntry(entryId: number, ctx: BookOpsContext, opIndex: number | null): void { + const entries = ctx.journey?.entries; + if (entries && !entries.some((e) => e.id === entryId)) { + throw failOp('entry_not_found', `entry ${entryId} is not in this journey`, opIndex); + } +} + +/** A photo an operation brings in, checked against the gallery when the context has one. */ +export function galleryPhoto(photoId: number, ctx: BookOpsContext, opIndex: number | null): BookPhotoInfo | undefined { + if (!ctx.photos) return undefined; + const info = ctx.photos.find((p) => p.photoId === photoId); + if (!info) throw failOp('photo_not_in_gallery', `photo ${photoId} is not in this journey's gallery`, opIndex); + return info; +} diff --git a/shared/src/book/book-ops.schema.ts b/shared/src/book/book-ops.schema.ts new file mode 100644 index 000000000..3eef390ce --- /dev/null +++ b/shared/src/book/book-ops.schema.ts @@ -0,0 +1,374 @@ +import { idSchema } from '../common/primitives.schema'; +import { BOOK_GRIDS } from './book-grids'; +import { BOOK_COVER_TEMPLATES, BOOK_TEMPLATES } from './book-layouts'; +import { + MAX_BOOK_TITLE, + MAX_SPREADS, + MAX_SPREAD_ELEMENTS, + bookPageSetupSchema, + bookPhotoElementSchema, + bookSpreadSchema, + bookTextElementSchema, +} from './book.schema'; + +import { z } from 'zod'; + +/** + * Edits to a book, as the assistant's book tools take them and as a chat card + * carries them. + * + * One page per call, a bounded list of operations on it, every field bounded, + * nothing open: the tool arguments are what a model sends, and a shape that + * accepts any object accepts whatever it imagines. The fields come from the + * document's own schema (book.schema.ts) with its defaults taken off, so an + * operation that does not name a field leaves that field as it is rather than + * resetting it. + * + * Units are the document's: millimetres from the top-left of the spread, a + * double spread twice the page width with the fold at one page width. + */ + +/** Ids as the document has them (`sp-...`, `p-...`): never invented by the model, bounded. */ +export const bookSpreadIdSchema = z.string().regex(/^[\w-]{1,40}$/); +export const bookElementIdSchema = z.string().regex(/^[\w-]{1,40}$/); + +export const BOOK_OP_LIMITS = { + /** Operations in one `edit_book_page` call. */ + opsPerCall: 20, + /** Pages one call adds or deletes. */ + pagesPerCall: 10, + /** Words one text operation sets. */ + text: 2000, + /** Words one caption sets. */ + caption: 300, + /** Elements one `remove` takes off a page. */ + removePerOp: 30, + /** Findings one answer carries. */ + findings: 40, +} as const; + +const idsOf = (list: readonly { id: string }[]) => list.map((t) => t.id) as [string, ...string[]]; + +/** The spread layouts, by id. */ +export const bookTemplateIdSchema = z.enum(idsOf(BOOK_TEMPLATES)); +/** The single-page (cover and back) layouts, by id. */ +export const bookCoverTemplateIdSchema = z.enum(idsOf(BOOK_COVER_TEMPLATES)); +/** Either set: which one fits is the page's question, answered when the operation runs. */ +export const bookAnyTemplateIdSchema = z.enum([...idsOf(BOOK_TEMPLATES), ...idsOf(BOOK_COVER_TEMPLATES)]); +export const bookGridIdSchema = z.enum(idsOf(BOOK_GRIDS)); + +const photo = bookPhotoElementSchema.shape; +const text = bookTextElementSchema.shape; + +/** A position in millimetres, inside the bounds the document takes. */ +const mmIn = z.number().finite().min(-10000).max(10000); +/** A size the document keeps after rounding to hundredths: anything under that rounds to 0, which no frame may be. */ +const sizeIn = z.number().finite().min(0.01).max(10000); +const unit01 = z.number().min(0).max(1); + +const has = (value: object, keys: readonly string[]) => + keys.some((k) => (value as Record)[k] !== undefined); + +const LOOK_KEYS = ['fit', 'focalX', 'focalY', 'mask', 'frameStyle', 'filter', 'radius'] as const; +const FRAME_KEYS = ['x', 'y', 'w', 'h', 'rotation'] as const; +const LAYOUT_KEYS = ['template', 'grid', 'saved'] as const; + +/** + * An entry's words need the entry and a photo's caption needs the photo: a + * binding that names neither reads nothing, and the ids it names are checked + * against the journey when the engine runs. + */ +function bindingNamesItsSource(b: { source: string; entryId?: number; photoId?: number } | null | undefined): boolean { + if (!b) return true; + if (b.source.startsWith('entry.') && b.entryId === undefined) return false; + return b.source !== 'photo.caption' || b.photoId !== undefined; +} + +/** One edit of one page. Discriminated on `op`. */ +export const bookPageOpSchema = z.discriminatedUnion('op', [ + /** Lay the page out again: a built-in layout, a photo grid, or one of the book's own layouts. */ + z + .strictObject({ + op: z.literal('layout'), + template: bookAnyTemplateIdSchema.optional(), + grid: bookGridIdSchema.optional(), + saved: z.string().min(1).max(40).optional(), + }) + .refine((v) => LAYOUT_KEYS.filter((k) => v[k] !== undefined).length === 1, { + message: 'give exactly one of template, grid or saved', + }), + /** Put a gallery photo into a frame: by element id, by its place among the page's frames, or a new frame. */ + z.strictObject({ + op: z.literal('place_photo'), + photoId: idSchema, + target: z.union([ + z.strictObject({ elementId: bookElementIdSchema }), + z.strictObject({ + slot: z + .number() + .int() + .min(0) + .max(MAX_SPREAD_ELEMENTS - 1), + }), + z.literal('new'), + ]), + fit: photo.fit.unwrap().optional(), + focalX: unit01.optional(), + focalY: unit01.optional(), + }), + /** How a placed photo looks: the crop (fit and focal point) and the look around it. */ + z + .strictObject({ + op: z.literal('photo_look'), + elementId: bookElementIdSchema, + fit: photo.fit.unwrap().optional(), + focalX: unit01.optional(), + focalY: unit01.optional(), + mask: photo.mask.unwrap().optional(), + frameStyle: photo.frameStyle.unwrap().optional(), + filter: photo.filter.unwrap().optional(), + radius: z.number().finite().min(0).max(500).optional(), + }) + .refine((v) => has(v, LOOK_KEYS), { message: 'name at least one of the look fields' }), + /** Exchange the pictures of two frames; the frames stay where they are. */ + z + .strictObject({ op: z.literal('swap_photos'), a: bookElementIdSchema, b: bookElementIdSchema }) + .refine((v) => v.a !== v.b, { message: 'a and b must be two different frames' }), + /** + * Set or create a text. Addressed by element id, or by the role it plays on the + * page (the largest type is the heading, the longest of the rest the body, the + * next the meta line); a role the page has no text for creates one. + */ + z + .strictObject({ + op: z.literal('text'), + elementId: bookElementIdSchema.optional(), + role: z.enum(['heading', 'body', 'meta']).optional(), + text: z.string().max(BOOK_OP_LIMITS.text).optional(), + binding: text.binding.unwrap().optional(), + font: text.font.unwrap().optional(), + size: text.size.unwrap().optional(), + weight: text.weight.unwrap().optional(), + align: text.align.unwrap().optional(), + color: text.color.unwrap().optional(), + }) + .refine((v) => v.elementId !== undefined || v.role !== undefined, { message: 'name an elementId or a role' }) + .refine((v) => bindingNamesItsSource(v.binding), { + message: 'an entry binding needs its entryId and a photo.caption binding its photoId', + path: ['binding'], + }), + /** A caption under a photo, bound to the photo's caption in the journey unless words are given. */ + z.strictObject({ + op: z.literal('caption'), + photoElementId: bookElementIdSchema, + text: z.string().max(BOOK_OP_LIMITS.caption).optional(), + }), + /** Move, resize or turn an element. */ + z + .strictObject({ + op: z.literal('frame'), + elementId: bookElementIdSchema, + x: mmIn.optional(), + y: mmIn.optional(), + w: sizeIn.optional(), + h: sizeIn.optional(), + rotation: z.number().finite().min(-360).max(360).optional(), + }) + .refine((v) => has(v, FRAME_KEYS), { message: 'name at least one of x, y, w, h or rotation' }), + /** Take elements off the page: parked by default (kept with the page, back on the next layout), or deleted. */ + z.strictObject({ + op: z.literal('remove'), + elementIds: z.array(bookElementIdSchema).min(1).max(BOOK_OP_LIMITS.removePerOp), + park: z.boolean().default(true), + }), + /** The page colour; null is the paper. */ + z.strictObject({ op: z.literal('background'), color: bookSpreadSchema.shape.background.unwrap() }), +]); +export type BookPageOp = z.infer; +export type BookPageOpInput = z.input; + +/* + * ── Tool arguments ────────────────────────────────────────────────────── + * + * Plain shapes, so a `*.mcp.ts` spreads them after its own `journeyId` + * (`inputSchema: { journeyId: idSchema, ...editBookPageArgs }`) and holds no + * `z.` of its own. Each also exists as a strict `*RequestSchema`, which is what + * `contracts:open` measures: none of them may hold an open shape. + */ + +const returnPrevious = z.boolean().optional(); + +export const editBookPageArgs = { + spreadId: bookSpreadIdSchema, + ops: z.array(bookPageOpSchema).min(1).max(BOOK_OP_LIMITS.opsPerCall), + returnPrevious, +}; +export const addBookPagesArgs = { + /** The new pages go after this one, kept inside the covers. */ + afterSpreadId: bookSpreadIdSchema, + count: z.number().int().min(1).max(BOOK_OP_LIMITS.pagesPerCall).optional(), + template: bookTemplateIdSchema.optional(), + entryId: idSchema.optional(), +}; +export const moveBookPageArgs = { + spreadId: bookSpreadIdSchema, + toIndex: z + .number() + .int() + .min(0) + .max(MAX_SPREADS - 1), +}; +export const deleteBookPagesArgs = { + spreadIds: z.array(bookSpreadIdSchema).min(1).max(BOOK_OP_LIMITS.pagesPerCall), + returnPrevious, +}; +export const setBookCoverArgs = { + template: bookCoverTemplateIdSchema.optional(), + photoId: idSchema.optional(), + title: z.string().max(MAX_BOOK_TITLE).optional(), + subtitle: z.string().max(MAX_BOOK_TITLE).optional(), + returnPrevious, +}; +export const createBookArgs = { + title: z.string().max(MAX_BOOK_TITLE).optional(), + preset: bookPageSetupSchema.shape.preset.unwrap().optional(), +}; +export const restoreBookPageArgs = { + spread: bookSpreadSchema, + /** Where it goes back when it is gone; a page still in the book is replaced where it is. */ + index: z + .number() + .int() + .min(0) + .max(MAX_SPREADS - 1), + expectedVersion: z.number().int().nonnegative(), +}; +export const describeBookPageArgs = { spreadId: bookSpreadIdSchema }; +export const listBookLayoutsArgs = { role: z.enum(['inner', 'single']).optional() }; +export const suggestBookPhotosArgs = { + entryId: idSchema.optional(), + date: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional(), + purpose: z.enum(['page', 'cover']).optional(), + limit: z.number().int().min(1).max(60).optional(), +}; + +export const editBookPageRequestSchema = z.strictObject(editBookPageArgs); +export const addBookPagesRequestSchema = z.strictObject(addBookPagesArgs); +export const moveBookPageRequestSchema = z.strictObject(moveBookPageArgs); +export const deleteBookPagesRequestSchema = z.strictObject(deleteBookPagesArgs); +export const setBookCoverRequestSchema = z.strictObject(setBookCoverArgs); +export const createBookRequestSchema = z.strictObject(createBookArgs); +export const restoreBookPageRequestSchema = z.strictObject(restoreBookPageArgs); +export const describeBookPageRequestSchema = z.strictObject(describeBookPageArgs); +export const listBookLayoutsRequestSchema = z.strictObject(listBookLayoutsArgs); +export const suggestBookPhotosRequestSchema = z.strictObject(suggestBookPhotosArgs); + +export type EditBookPageRequest = z.infer; +export type AddBookPagesRequest = z.infer; +export type MoveBookPageRequest = z.infer; +export type DeleteBookPagesRequest = z.infer; +export type SetBookCoverRequest = z.infer; +export type CreateBookRequest = z.infer; +export type RestoreBookPageRequest = z.infer; +export type SuggestBookPhotosRequest = z.infer; + +/* + * ── Answers ───────────────────────────────────────────────────────────── + * + * What the tools answer with, for the server's tests and the assistant's card + * builders. A summary of a page rather than the page: a book reaches megabytes + * and a tool answer is cut at 40 000 characters. + */ + +export const BOOK_FINDING_CODES = [ + 'overlap', + 'fold', + 'trim', + 'empty_frame', + 'low_dpi', + 'focal_cut', + 'text_overflow', + 'parked', + 'hidden_photo', + 'not_in_gallery', +] as const; +export type BookFindingCode = (typeof BOOK_FINDING_CODES)[number]; + +/** `severe` is what the assistant fixes before it proposes; `warn` it should mention; `info` is context. */ +export const bookFindingSchema = z.object({ + code: z.enum(BOOK_FINDING_CODES), + severity: z.enum(['severe', 'warn', 'info']), + elementIds: z.array(z.string()).max(8), + message: z.string().max(300), +}); +export type BookFinding = z.infer; + +/** One page as `get_book` lists it and a write answers with it. */ +export const bookSpreadSummarySchema = z.object({ + index: z.number().int().nonnegative(), + id: z.string(), + role: bookSpreadSchema.shape.role.unwrap(), + entryId: z.number().int().nullable(), + background: z.string().nullable(), + photoIds: z.array(z.number().int()), + /** The first 60 characters of each text, in paint order. */ + texts: z.array(z.string().max(60)), + elements: z.number().int().nonnegative(), + emptyFrames: z.number().int().nonnegative(), + parked: z.number().int().nonnegative(), +}); +export type BookSpreadSummary = z.infer; + +/** `get_book`: the book without its document. */ +export const bookOverviewSchema = z.object({ + exists: z.boolean(), + version: z.number().int().nonnegative().nullable(), + title: z.string(), + page: bookPageSetupSchema.pick({ preset: true, pageWidth: true, pageHeight: true, bleed: true, safe: true }), + spreads: z.array(bookSpreadSummarySchema), + layouts: z.array(z.object({ id: z.string(), name: z.string(), role: z.enum(['inner', 'single']) })), +}); +export type BookOverview = z.infer; + +/** One element as `describe_book_page` lists it, back to front. */ +export const bookElementSummarySchema = z.object({ + id: z.string(), + kind: z.string(), + frame: z.object({ x: z.number(), y: z.number(), w: z.number(), h: z.number() }), + rotation: z.number(), + locked: z.boolean(), + photoId: z.number().int().nullable().optional(), + text: z.string().max(80).optional(), + binding: z.string().optional(), +}); +export type BookElementSummary = z.infer; + +/** `describe_book_page`: the words a model without eyes checks a page by. */ +export const bookPageDescriptionSchema = z.object({ + spreadId: z.string(), + index: z.number().int().nonnegative(), + role: bookSpreadSchema.shape.role.unwrap(), + text: z.string(), + findings: z.array(bookFindingSchema).max(BOOK_OP_LIMITS.findings), + elements: z.array(bookElementSummarySchema), +}); +export type BookPageDescription = z.infer; + +/** A page as it was before a write, for the assistant's undo. */ +export const bookPreviousSpreadSchema = z.object({ + index: z.number().int().nonnegative(), + spread: bookSpreadSchema, +}); + +/** What a book write answers. `previous_spreads` only when the call asked with `returnPrevious`. */ +export const bookWriteResultSchema = z.object({ + version: z.number().int().nonnegative(), + spread_ids: z.array(z.string()), + pages: z.array(bookSpreadSummarySchema), + findings: z.array(bookFindingSchema).max(BOOK_OP_LIMITS.findings), + previous_spreads: z.array(bookPreviousSpreadSchema).optional(), +}); +export type BookWriteResult = z.infer; diff --git a/shared/src/book/book-ops.spec.ts b/shared/src/book/book-ops.spec.ts new file mode 100644 index 000000000..263dd5e7d --- /dev/null +++ b/shared/src/book/book-ops.spec.ts @@ -0,0 +1,625 @@ +import { applyBookOps } from './book-ops'; +import type { BookOpsContext } from './book-ops-core'; +import type { BookPageOpInput } from './book-ops.schema'; +import { blankBook } from './book-pages'; +import { + MAX_SPREAD_ELEMENTS, + bookLayoutSchema, + bookPhotoElementSchema, + bookShapeElementSchema, + bookSpreadSchema, + bookTextElementSchema, + type BookDocument, + type BookElement, + type BookPhotoElement, + type BookSpread, + type BookTextElement, +} from './book.schema'; + +import { describe, expect, it } from 'vitest'; + +/** + * The page edit engine: every operation, the lock, the gallery and the caps, + * and that a refused call changes nothing. + */ + +let seq = 0; +const newId = (p: string) => `${p}-n${++seq}`; + +const photo = (id: string, photoId: number | null, frame = { x: 230, y: 20, w: 80, h: 60 }, over = {}) => + bookPhotoElementSchema.parse({ id, kind: 'photo', frame, photoId, ...over }) as BookPhotoElement; +const text = (id: string, words: string, size = 10, over = {}) => + bookTextElementSchema.parse({ + id, + kind: 'text', + frame: { x: 230, y: 120, w: 160, h: 40 }, + text: words, + size, + ...over, + }) as BookTextElement; +const shape = (id: string, over = {}) => + bookShapeElementSchema.parse({ id, kind: 'shape', frame: { x: 0, y: 0, w: 420, h: 10 }, ...over }) as BookElement; + +/** A blank book whose inner spread holds `elements`. */ +function book(elements: BookElement[], over: Partial = {}): BookDocument { + const doc = blankBook({ title: 'Iceland' }); + const spreads = doc.spreads.map((s, i) => ({ + ...s, + id: ['sp-cover', 'sp-first', 'sp-in', 'sp-last', 'sp-back'][i] as string, + })); + spreads[2] = { ...(spreads[2] as BookSpread), elements, ...over }; + return { ...doc, spreads }; +} + +const inner = (doc: BookDocument) => doc.spreads[2] as BookSpread; +const el = (doc: BookDocument, id: string, spread = 2) => doc.spreads[spread]?.elements.find((e) => e.id === id); +const run = (doc: BookDocument, ops: BookPageOpInput[], ctx: BookOpsContext = {}, spreadId = 'sp-in') => + applyBookOps(doc, { spreadId, ops }, { newId, ...ctx }); + +function ok(result: ReturnType): BookDocument { + if (!result.ok) throw new Error(`refused: ${result.error.code} ${result.error.message}`); + return result.doc; +} + +const gallery = [ + { photoId: 1, caption: 'Harbour', width: 4000, height: 3000 }, + { photoId: 2, caption: null, width: 3000, height: 4000 }, + { photoId: 3, caption: 'Glacier' }, + { photoId: 4 }, +]; + +describe('layout', () => { + it('BOOKOPS-001: pours the page into a built-in layout and parks what it has no room for', () => { + const doc = book([photo('a', 1), photo('b', 2), text('h', 'Title', 22)]); + const r = run(doc, [{ op: 'layout', template: 'full-text' }]); + const out = inner(ok(r)); + expect(out.elements.filter((e) => e.kind === 'photo').map((e) => (e as BookPhotoElement).photoId)).toEqual([1]); + expect(out.parked.map((e) => (e.kind === 'photo' ? e.photoId : e.kind))).toEqual([2]); + expect(r.ok && r.created.length).toBe(out.elements.length); + }); + + it('BOOKOPS-002: refuses a cover layout on a double spread and a spread layout on the cover', () => { + const doc = book([]); + const a = run(doc, [{ op: 'layout', template: 'cover-band' }]); + expect(!a.ok && a.error).toMatchObject({ code: 'layout_role', opIndex: 0 }); + const b = run(doc, [{ op: 'layout', template: 'grid-4' }], {}, 'sp-cover'); + expect(!b.ok && b.error.code).toBe('layout_role'); + expect(ok(run(doc, [{ op: 'layout', template: 'cover-band' }], {}, 'sp-cover')).spreads[0]?.elements).toHaveLength( + 1, + ); + }); + + it('BOOKOPS-003: leaves a locked element where it is, in its place in the paint order', () => { + const panel = shape('panel', { locked: true }); + const doc = book([panel, photo('a', 1), text('h', 'Title', 22)]); + const out = inner(ok(run(doc, [{ op: 'layout', template: 'grid-4' }]))); + expect(out.elements[0]).toBe(panel); + expect(out.elements.filter((e) => e.kind === 'photo')).toHaveLength(4); + }); + + it('BOOKOPS-004: lays the photos out on a grid and leaves words and shapes where they are', () => { + const doc = book([shape('bg'), photo('a', 1), text('t', 'Words'), photo('b', 2), photo('c', 3)], { + parked: [photo('p', 4), text('pt', 'Parked')], + }); + const out = inner(ok(run(doc, [{ op: 'layout', grid: 'two-across' }]))); + expect(out.elements.map((e) => e.kind)).toEqual(['shape', 'photo', 'photo', 'text']); + expect(out.elements.filter((e) => e.kind === 'photo').map((e) => (e as BookPhotoElement).photoId)).toEqual([1, 2]); + expect(out.parked.map((e) => (e.kind === 'photo' ? e.photoId : e.id))).toEqual([3, 4, 'pt']); + expect(el(ok(run(doc, [{ op: 'layout', grid: 'grid-4' }])), 't')).toBe(doc.spreads[2]?.elements[2]); + }); + + it('BOOKOPS-005: puts a grid at the back of a page that had no photo', () => { + const out = inner(ok(run(book([text('t', 'Words')]), [{ op: 'layout', grid: 'single' }]))); + expect(out.elements.map((e) => e.kind)).toEqual(['photo', 'text']); + }); + + it("BOOKOPS-006: uses one of the book's own layouts, by id or by name, for the kind of page it was drawn on", () => { + const layout = bookLayoutSchema.parse({ + id: 'ly-1', + name: 'Hero', + role: 'inner', + pageWidth: 210, + pageHeight: 210, + elements: [photo('lp', null)], + }); + const doc = { ...book([photo('a', 1)]), layouts: [layout] }; + expect(el(ok(run(doc, [{ op: 'layout', saved: 'ly-1' }])), 'a')).toBeUndefined(); + expect(inner(ok(run(doc, [{ op: 'layout', saved: 'Hero' }]))).elements[0]).toMatchObject({ photoId: 1 }); + const missing = run(doc, [{ op: 'layout', saved: 'nope' }]); + expect(!missing.ok && missing.error.code).toBe('layout_not_found'); + const cover = run(doc, [{ op: 'layout', saved: 'ly-1' }], {}, 'sp-cover'); + expect(!cover.ok && cover.error.code).toBe('layout_role'); + }); + + it('BOOKOPS-007: asks for exactly one of template, grid or saved', () => { + const none = run(book([]), [{ op: 'layout' }]); + expect(!none.ok && none.error).toMatchObject({ code: 'invalid_op', opIndex: 0 }); + const two = run(book([]), [ + { op: 'background', color: null }, + { op: 'layout', template: 'grid-4', grid: 'single' }, + ]); + expect(!two.ok && two.error).toMatchObject({ code: 'invalid_op', opIndex: 1 }); + }); + + it('BOOKOPS-008: refuses a layout that with the locked elements would exceed the page cap', () => { + const locked = Array.from({ length: MAX_SPREAD_ELEMENTS - 2 }, (_, i) => shape(`s${i}`, { locked: true })); + const r = run(book(locked), [{ op: 'layout', template: 'grid-4' }]); + expect(!r.ok && r.error.code).toBe('too_many_elements'); + }); +}); + +describe('place_photo', () => { + it('BOOKOPS-009: fills a frame by id; a new picture starts at the centre, the same one keeps its focal point', () => { + const doc = book([photo('a', 1, undefined, { focalX: 0.2, focalY: 0.9, fit: 'contain' })]); + expect(el(ok(run(doc, [{ op: 'place_photo', photoId: 2, target: { elementId: 'a' } }])), 'a')).toMatchObject({ + photoId: 2, + focalX: 0.5, + focalY: 0.5, + fit: 'contain', + }); + expect(el(ok(run(doc, [{ op: 'place_photo', photoId: 1, target: { elementId: 'a' } }])), 'a')).toMatchObject({ + focalX: 0.2, + }); + const given = [{ op: 'place_photo', photoId: 2, target: { elementId: 'a' }, fit: 'cover', focalX: 0.1 }] as const; + expect(el(ok(run(doc, [...given])), 'a')).toMatchObject({ fit: 'cover', focalX: 0.1 }); + }); + + it("BOOKOPS-010: fills a frame by its place among the page's frames", () => { + const doc = book([photo('a', null), text('t', 'x'), photo('b', null)]); + expect(el(ok(run(doc, [{ op: 'place_photo', photoId: 3, target: { slot: 1 } }])), 'b')).toMatchObject({ + photoId: 3, + }); + const r = run(doc, [{ op: 'place_photo', photoId: 3, target: { slot: 2 } }]); + expect(!r.ok && r.error.code).toBe('slot_not_found'); + }); + + it("BOOKOPS-011: places a new frame in the photo's proportions on the right-hand page, and takes it out of parked", () => { + const doc = book([], { parked: [photo('pk', 2)] }); + const r = run(doc, [{ op: 'place_photo', photoId: 2, target: 'new' }], { photos: gallery }); + const out = inner(ok(r)); + const added = out.elements[0] as BookPhotoElement; + expect(added).toMatchObject({ id: r.ok ? r.created[0] : '', photoId: 2, frame: { y: 52.5, w: 78.75, h: 105 } }); + expect(added.frame.x).toBeGreaterThan(210); + expect(out.parked).toEqual([]); + const cover = ok(run(doc, [{ op: 'place_photo', photoId: 1, target: 'new' }], { photos: gallery }, 'sp-cover')); + expect(cover.spreads[0]?.elements[0]?.frame).toEqual({ x: 52.5, y: 65.63, w: 105, h: 78.75 }); + }); + + it('BOOKOPS-012: takes only gallery photos when it knows the gallery', () => { + const doc = book([photo('a', null)]); + const r = run(doc, [{ op: 'place_photo', photoId: 99, target: { elementId: 'a' } }], { photos: gallery }); + expect(!r.ok && r.error.code).toBe('photo_not_in_gallery'); + expect(ok(run(doc, [{ op: 'place_photo', photoId: 99, target: { elementId: 'a' } }]))).toBeTruthy(); + }); + + it('BOOKOPS-013: refuses a locked frame, a text, an unknown id and a parked one, saying which', () => { + const doc = book([photo('a', 1, undefined, { locked: true }), text('t', 'x')], { parked: [photo('pk', 2)] }); + const codes = ['a', 't', 'zz', 'pk'].map((id) => { + const r = run(doc, [{ op: 'place_photo', photoId: 3, target: { elementId: id } }]); + return r.ok ? null : [r.error.code, r.error.elementId]; + }); + expect(codes).toEqual([ + ['locked', 'a'], + ['wrong_kind', 't'], + ['element_not_found', 'zz'], + ['element_not_found', 'pk'], + ]); + const parked = run(doc, [{ op: 'place_photo', photoId: 3, target: { elementId: 'pk' } }]); + expect(!parked.ok && parked.error.message).toMatch(/parked/); + }); + + it('BOOKOPS-014: refuses a new frame on a full page', () => { + const full = Array.from({ length: MAX_SPREAD_ELEMENTS }, (_, i) => shape(`s${i}`)); + const r = run(book(full), [{ op: 'place_photo', photoId: 1, target: 'new' }]); + expect(!r.ok && r.error.code).toBe('too_many_elements'); + }); +}); + +describe('photo_look and swap_photos', () => { + it('BOOKOPS-015: sets only the look it names', () => { + const doc = book([photo('a', 1, undefined, { filter: 'bw', frameStyle: 'white' })]); + const out = el( + ok(run(doc, [{ op: 'photo_look', elementId: 'a', mask: 'heart', radius: 2.345, focalY: 0.3 }])), + 'a', + ); + expect(out).toMatchObject({ + mask: 'heart', + radius: 2.35, + focalY: 0.3, + filter: 'bw', + frameStyle: 'white', + focalX: 0.5, + }); + const all = el( + ok( + run(doc, [ + { op: 'photo_look', elementId: 'a', fit: 'contain', focalX: 0.1, frameStyle: 'none', filter: 'warm' }, + ]), + ), + 'a', + ); + expect(all).toMatchObject({ fit: 'contain', focalX: 0.1, frameStyle: 'none', filter: 'warm' }); + expect(run(doc, [{ op: 'photo_look', elementId: 'a' }]).ok).toBe(false); + }); + + it('BOOKOPS-016: swaps the pictures, keeps the frames, and moves a caption with its picture', () => { + const a = photo('a', 1, { x: 20, y: 20, w: 80, h: 60 }, { focalX: 0.2, frameStyle: 'polaroid' }); + const b = photo('b', 2, { x: 230, y: 100, w: 80, h: 40 }, { focalX: 0.8 }); + const cap = text('c', 'Harbour', 7.5, { + frame: { x: 20, y: 82, w: 80, h: 5 }, + binding: { source: 'photo.caption', photoId: 1 }, + }); + const out = ok(run(book([a, b, cap]), [{ op: 'swap_photos', a: 'a', b: 'b' }])); + expect(el(out, 'a')).toMatchObject({ photoId: 2, focalX: 0.8, frameStyle: 'polaroid', frame: a.frame }); + expect(el(out, 'b')).toMatchObject({ photoId: 1, focalX: 0.2 }); + expect(el(out, 'c')?.frame).toEqual({ x: 230, y: 142, w: 80, h: 5 }); + }); + + it('BOOKOPS-017: refuses swapping a frame with itself, a locked frame or a locked caption', () => { + const doc = book([ + photo('a', 1), + photo('b', 2, undefined, { locked: true }), + photo('c', 3), + text('cap', 'x', 7.5, { locked: true, binding: { source: 'photo.caption', photoId: 3 } }), + ]); + expect(run(doc, [{ op: 'swap_photos', a: 'a', b: 'a' }]).ok).toBe(false); + const locked = run(doc, [{ op: 'swap_photos', a: 'a', b: 'b' }]); + expect(!locked.ok && locked.error.code).toBe('locked'); + const caption = run(doc, [{ op: 'swap_photos', a: 'a', b: 'c' }]); + expect(!caption.ok && caption.error.elementId).toBe('cap'); + }); +}); + +describe('text', () => { + const journey = { + title: 'Iceland', + subtitle: 'Summer', + entries: [{ id: 7, title: 'Reykjavik', story: ' A day. ', location: 'Harbour' }], + }; + + it('BOOKOPS-018: sets the words and the type of a text by id', () => { + const doc = book([text('t', 'Old', 10)]); + const out = el( + ok( + run(doc, [ + { + op: 'text', + elementId: 't', + text: 'New', + size: 14, + font: 'serif', + align: 'center', + weight: 600, + color: '#ff0000', + }, + ]), + ), + 't', + ); + expect(out).toMatchObject({ + text: 'New', + size: 14, + font: 'serif', + align: 'center', + weight: 600, + color: '#ff0000', + overridden: false, + }); + }); + + it('BOOKOPS-019: keeps a bound text bound unless words are given that differ from its source', () => { + const bound = text('t', 'Iceland', 22, { binding: { source: 'journey.title' } }); + const doc = book([bound]); + expect(el(ok(run(doc, [{ op: 'text', elementId: 't', size: 30 }])), 't')).toMatchObject({ + overridden: false, + size: 30, + }); + expect(el(ok(run(doc, [{ op: 'text', elementId: 't', text: 'Iceland' }], { journey })), 't')).toMatchObject({ + overridden: false, + }); + expect(el(ok(run(doc, [{ op: 'text', elementId: 't', text: 'Island' }], { journey })), 't')).toMatchObject({ + overridden: true, + binding: { source: 'journey.title' }, + }); + expect(el(ok(run(doc, [{ op: 'text', elementId: 't', text: 'Iceland' }])), 't')).toMatchObject({ + overridden: true, + }); + }); + + it('BOOKOPS-020: binds a text to the journey, reading the words it can, and unbinds one', () => { + const doc = book([text('t', 'Old', 10, { overridden: true })]); + const story = el( + ok(run(doc, [{ op: 'text', elementId: 't', binding: { source: 'entry.story', entryId: 7 } }], { journey })), + 't', + ); + expect(story).toMatchObject({ text: 'A day.', overridden: false, binding: { source: 'entry.story', entryId: 7 } }); + const kept = el( + ok(run(doc, [{ op: 'text', elementId: 't', binding: { source: 'entry.story', entryId: 7 } }])), + 't', + ); + expect(kept).toMatchObject({ text: 'Old', overridden: false }); + const unbound = el( + ok( + run(book([text('b', 'x', 10, { binding: { source: 'journey.title' } })]), [ + { op: 'text', elementId: 'b', binding: null }, + ]), + ), + 'b', + ); + expect(unbound).toMatchObject({ binding: null, overridden: false, text: 'x' }); + const blank = run(book([text('e', '')]), [ + { op: 'text', elementId: 'e', binding: { source: 'entry.date', entryId: 7 } }, + ]); + expect(!blank.ok && blank.error.code).toBe('text_required'); + }); + + it('BOOKOPS-021: reads every source it can from the journey and the gallery', () => { + const ctx = { journey, photos: gallery }; + const words = (binding: object) => + ( + el( + ok(run(book([text('t', 'x')]), [{ op: 'text', elementId: 't', binding } as BookPageOpInput], ctx)), + 't', + ) as BookTextElement + ).text; + expect(words({ source: 'journey.subtitle' })).toBe('Summer'); + expect(words({ source: 'entry.title', entryId: 7 })).toBe('Reykjavik'); + expect(words({ source: 'entry.location', entryId: 7 })).toBe('Harbour'); + expect(words({ source: 'photo.caption', photoId: 1 })).toBe('Harbour'); + const dms = run(book([text('t', 'x')]), [ + { op: 'text', elementId: 't', binding: { source: 'entry.location', entryId: 7, format: 'dms' } }, + ]); + expect(!dms.ok && dms.error.code).toBe('text_required'); + const stranger = run(book([text('t', 'x')]), [ + { op: 'text', elementId: 't', binding: { source: 'entry.title', entryId: 8 } }, + ]); + expect(stranger.ok).toBe(true); + const checked = run( + book([text('t', 'x')]), + [{ op: 'text', elementId: 't', binding: { source: 'entry.title', entryId: 8 } }], + ctx, + ); + expect(!checked.ok && checked.error.code).toBe('entry_not_found'); + }); + + it('BOOKOPS-039: refuses a date or coordinates bound without words, and keeps the words given', () => { + const doc = book([text('t', 'Old')]); + for (const binding of [ + { source: 'entry.date', entryId: 7 }, + { source: 'entry.location', entryId: 7, format: 'decimal' }, + ] as const) { + const r = run(doc, [{ op: 'text', elementId: 't', binding }], { journey }); + expect(!r.ok && r.error).toMatchObject({ code: 'text_required', opIndex: 0, elementId: 't' }); + } + const dated = run(doc, [ + { op: 'text', elementId: 't', text: '12 June', binding: { source: 'entry.date', entryId: 7 } }, + ]); + expect(el(ok(dated), 't')).toMatchObject({ text: '12 June', binding: { source: 'entry.date', entryId: 7 } }); + }); + + it('BOOKOPS-040: asks a binding for the id its source needs, and checks it against the journey', () => { + const doc = book([text('t', 'x')]); + const noEntry = run(doc, [{ op: 'text', elementId: 't', binding: { source: 'entry.story' } }]); + expect(!noEntry.ok && noEntry.error.code).toBe('invalid_op'); + const noPhoto = run(doc, [{ op: 'text', elementId: 't', binding: { source: 'photo.caption' } }]); + expect(!noPhoto.ok && noPhoto.error.code).toBe('invalid_op'); + const stray = run(doc, [{ op: 'text', elementId: 't', binding: { source: 'photo.caption', photoId: 99 } }], { + photos: gallery, + }); + expect(!stray.ok && stray.error.code).toBe('photo_not_in_gallery'); + const known = run(doc, [{ op: 'text', elementId: 't', binding: { source: 'photo.caption', photoId: 1 } }], { + photos: gallery, + }); + expect(el(ok(known), 't')).toMatchObject({ text: 'Harbour', overridden: false }); + }); + + it("BOOKOPS-041: never takes a photo's caption for the page's text of a role", () => { + const caption = text('cap', 'A very long caption under the harbour photo', 7.5, { + binding: { source: 'photo.caption', photoId: 1 }, + }); + const doc = book([photo('a', 1), caption, text('h', 'Title', 22)]); + const out = ok(run(doc, [{ op: 'text', role: 'body', text: 'The story' }])); + expect(el(out, 'cap')).toBe(caption); + expect(inner(out).elements.filter((e) => e.kind === 'text')).toHaveLength(3); + }); + + it("BOOKOPS-022: finds the page's heading by its part, and creates one in the quiet slot when there is none", () => { + const doc = book([text('h', 'Title', 22), text('b', 'A long story here', 10)]); + expect(el(ok(run(doc, [{ op: 'text', role: 'heading', text: 'Lisbon' }])), 'h')).toMatchObject({ text: 'Lisbon' }); + const made = ok(run(book([]), [{ op: 'text', role: 'heading', text: 'Lisbon' }])); + expect(inner(made).elements[0]).toMatchObject({ + kind: 'text', + text: 'Lisbon', + size: 22, + weight: 700, + frame: { x: 226, y: 50 }, + }); + const meta = ok(run(book([]), [{ op: 'text', role: 'meta', binding: { source: 'journey.title' } }], { journey })); + expect(inner(meta).elements[0]).toMatchObject({ + text: 'Iceland', + size: 7.5, + binding: { source: 'journey.title' }, + overridden: false, + }); + const story = ok(run(book([]), [{ op: 'text', role: 'body', text: 'Story' }], {}, 'sp-first')); + expect(story.spreads[1]?.elements[0]?.frame).toMatchObject({ x: 16, w: 178 }); + const none = run(book([]), [{ op: 'text', role: 'body' }]); + expect(!none.ok && none.error.code).toBe('text_required'); + }); + + it('BOOKOPS-023: refuses a locked text and a text op that names nothing', () => { + const r = run(book([text('t', 'x', 10, { locked: true })]), [{ op: 'text', elementId: 't', text: 'y' }]); + expect(!r.ok && r.error.code).toBe('locked'); + expect(run(book([]), [{ op: 'text', text: 'y' }]).ok).toBe(false); + const shapeAsText = run(book([shape('s')]), [{ op: 'text', elementId: 's', text: 'y' }]); + expect(!shapeAsText.ok && shapeAsText.error.code).toBe('wrong_kind'); + }); +}); + +describe('caption', () => { + it("BOOKOPS-024: sets a caption under the photo, bound to the photo's caption in the journey", () => { + const doc = book([photo('a', 1)], { entryId: 5 }); + const out = inner(ok(run(doc, [{ op: 'caption', photoElementId: 'a' }], { photos: gallery }))); + expect(out.elements[1]).toMatchObject({ + kind: 'text', + text: 'Harbour', + size: 7.5, + overridden: false, + binding: { source: 'photo.caption', photoId: 1, entryId: 5 }, + frame: { x: 230, y: 82, w: 80, h: 5 }, + }); + }); + + it('BOOKOPS-025: takes words of its own, which stop the sync, and updates a caption already there', () => { + const doc = book([photo('a', 1), text('c', 'Old', 7.5, { binding: { source: 'photo.caption', photoId: 1 } })]); + expect( + el(ok(run(doc, [{ op: 'caption', photoElementId: 'a', text: 'At dawn' }], { photos: gallery })), 'c'), + ).toMatchObject({ + text: 'At dawn', + overridden: true, + }); + expect(el(ok(run(doc, [{ op: 'caption', photoElementId: 'a' }], { photos: gallery })), 'c')).toMatchObject({ + text: 'Harbour', + overridden: false, + }); + expect(inner(ok(run(doc, [{ op: 'caption', photoElementId: 'a' }], { photos: gallery }))).elements).toHaveLength(2); + }); + + it('BOOKOPS-026: sets the caption on a full-bleed picture in white, inside its lower edge', () => { + const doc = book([photo('a', 3, { x: -3, y: -3, w: 213, h: 216 })]); + const out = inner(ok(run(doc, [{ op: 'caption', photoElementId: 'a' }], { photos: gallery }))); + expect(out.elements[1]).toMatchObject({ text: 'Glacier', color: '#ffffff', opacity: 0.9, frame: { x: 6, y: 199 } }); + }); + + it('BOOKOPS-027: keeps a caption under a photo across the fold on its left-hand page', () => { + const doc = book([photo('a', 1, { x: 150, y: 20, w: 120, h: 60 })]); + const cap = inner(ok(run(doc, [{ op: 'caption', photoElementId: 'a' }], { photos: gallery }))).elements[1]; + expect(cap?.frame.x).toBe(150); + expect((cap?.frame.x ?? 0) + (cap?.frame.w ?? 0)).toBeLessThanOrEqual(210); + }); + + it('BOOKOPS-028: refuses an empty frame, a photo with no caption and no words, and a locked caption', () => { + const empty = run(book([photo('a', null)]), [{ op: 'caption', photoElementId: 'a' }]); + expect(!empty.ok && empty.error.code).toBe('empty_frame'); + const none = run(book([photo('a', 2)]), [{ op: 'caption', photoElementId: 'a' }], { photos: gallery }); + expect(!none.ok && none.error.code).toBe('caption_missing'); + const locked = book([ + photo('a', 1), + text('c', 'x', 7.5, { locked: true, binding: { source: 'photo.caption', photoId: 1 } }), + ]); + const r = run(locked, [{ op: 'caption', photoElementId: 'a', text: 'y' }]); + expect(!r.ok && r.error.code).toBe('locked'); + }); +}); + +describe('frame, remove and background', () => { + it('BOOKOPS-029: moves, resizes and turns an element, to the hundredth of a millimetre', () => { + const doc = book([text('t', 'x')]); + expect(el(ok(run(doc, [{ op: 'frame', elementId: 't', x: 12.345, h: 9, rotation: -5 }])), 't')).toMatchObject({ + frame: { x: 12.35, y: 120, w: 160, h: 9 }, + rotation: -5, + }); + expect(run(doc, [{ op: 'frame', elementId: 't', w: 0 }]).ok).toBe(false); + const sliver = run(doc, [{ op: 'frame', elementId: 't', w: 0.004 }]); + expect(!sliver.ok && sliver.error.code).toBe('invalid_op'); + expect(run(doc, [{ op: 'frame', elementId: 't' }]).ok).toBe(false); + const locked = run(book([text('t', 'x', 10, { locked: true })]), [{ op: 'frame', elementId: 't', x: 1 }]); + expect(!locked.ok && locked.error.code).toBe('locked'); + }); + + it('BOOKOPS-030: parks what it takes off the page, dropping empty frames and blank texts', () => { + const doc = book([photo('a', 1), photo('e', null), text('t', 'Words'), text('blank', ' '), shape('s')]); + const out = inner(ok(run(doc, [{ op: 'remove', elementIds: ['a', 'e', 't', 'blank'] }]))); + expect(out.elements.map((e) => e.id)).toEqual(['s']); + expect(out.parked.map((e) => e.id)).toEqual(['a', 't']); + }); + + it('BOOKOPS-031: deletes with park false, from the page and from the parked list', () => { + const doc = book([photo('a', 1)], { parked: [photo('pk', 2)] }); + const out = inner(ok(run(doc, [{ op: 'remove', elementIds: ['a', 'pk'], park: false }]))); + expect([out.elements, out.parked]).toEqual([[], []]); + expect(inner(ok(run(doc, [{ op: 'remove', elementIds: ['pk'] }]))).parked).toHaveLength(1); + }); + + it('BOOKOPS-032: refuses a locked element, an unknown one and a full parked list', () => { + const locked = run(book([shape('s', { locked: true })]), [{ op: 'remove', elementIds: ['s'] }]); + expect(!locked.ok && locked.error.code).toBe('locked'); + const unknown = run(book([]), [{ op: 'remove', elementIds: ['x'] }]); + expect(!unknown.ok && unknown.error.code).toBe('element_not_found'); + const parked = Array.from({ length: MAX_SPREAD_ELEMENTS }, (_, i) => shape(`k${i}`)); + const full = run(book([shape('s')], { parked }), [{ op: 'remove', elementIds: ['s'] }]); + expect(!full.ok && full.error.code).toBe('parked_full'); + }); + + it('BOOKOPS-033: sets and clears the page colour', () => { + const coloured = ok(run(book([]), [{ op: 'background', color: '#112233' }])); + expect(inner(coloured).background).toBe('#112233'); + expect(inner(ok(run(coloured, [{ op: 'background', color: null }]))).background).toBeNull(); + expect(run(book([]), [{ op: 'background', color: 'red' }]).ok).toBe(false); + }); +}); + +describe('a call as a whole', () => { + it('BOOKOPS-034: applies all or nothing, and names the operation that refused', () => { + const doc = book([photo('a', 1)]); + const r = run(doc, [ + { op: 'background', color: '#000000' }, + { op: 'frame', elementId: 'missing', x: 1 }, + ]); + expect(!r.ok && r.error).toMatchObject({ code: 'element_not_found', opIndex: 1, elementId: 'missing' }); + expect(inner(doc).background).toBeNull(); + }); + + it('BOOKOPS-035: changes only the page it was given', () => { + const doc = book([photo('a', 1)]); + const out = ok(run(doc, [{ op: 'background', color: '#000000' }])); + out.spreads.forEach((s, i) => { + if (i !== 2) expect(s).toBe(doc.spreads[i]); + }); + }); + + it('BOOKOPS-036: refuses an unknown page, an empty call and more than twenty operations', () => { + const missing = run(book([]), [{ op: 'background', color: null }], {}, 'sp-nope'); + expect(!missing.ok && missing.error.code).toBe('spread_not_found'); + expect(run(book([]), []).ok).toBe(false); + const many = Array.from({ length: 21 }, () => ({ op: 'background', color: null }) as const); + const r = run(book([]), many); + expect(!r.ok && r.error.code).toBe('invalid_op'); + }); + + it('BOOKOPS-037: edits the contents of the protected pages like any other page', () => { + const doc = book([]); + const out = ok(run(doc, [{ op: 'text', role: 'heading', text: 'Iceland' }], {}, 'sp-cover')); + expect(out.spreads[0]?.elements[0]).toMatchObject({ text: 'Iceland', frame: { x: 16 } }); + }); + + it('BOOKOPS-038: mints ids itself when the context brings none', () => { + const r = applyBookOps(book([]), { spreadId: 'sp-in', ops: [{ op: 'place_photo', photoId: 1, target: 'new' }] }); + expect(r.ok && r.created[0]).toMatch(/^p-[a-z0-9]{7}$/); + }); + + it('BOOKOPS-042: names what a layout drops and what a delete takes, and nothing for an edit that keeps all', () => { + const doc = book([photo('a', 1), text('h', 'Title', 22), shape('s')], { parked: [shape('ps')] }); + const laid = run(doc, [{ op: 'layout', template: 'full-text' }]); + expect(laid.ok && laid.dropped).toEqual(['s', 'ps']); + const gone = run(doc, [{ op: 'remove', elementIds: ['a'], park: false }]); + expect(gone.ok && gone.dropped).toEqual(['a']); + const parked = run(doc, [{ op: 'remove', elementIds: ['a'] }]); + expect(parked.ok && parked.dropped).toEqual([]); + }); + + it('BOOKOPS-043: refuses rather than answers a page the document schema would not take', () => { + const broken = { ...text('t', 'x'), color: 'red' } as BookElement; + const r = run(book([broken]), [{ op: 'background', color: '#000000' }]); + expect(!r.ok && r.error).toMatchObject({ code: 'invalid_op', opIndex: null }); + expect(!r.ok && r.error.message).toMatch(/would not be valid/); + }); + + it('BOOKOPS-044: answers a page the document schema takes after every built-in layout', () => { + const doc = book([photo('a', 1), photo('b', 2), text('h', 'Title', 22), text('b1', 'A long story here', 10)]); + for (const template of ['full-text', 'grid-4', 'band-quote', 'quiet-text'] as const) { + const out = inner(ok(run(doc, [{ op: 'layout', template }]))); + expect(bookSpreadSchema.safeParse(out).success).toBe(true); + } + }); +}); diff --git a/shared/src/book/book-ops.ts b/shared/src/book/book-ops.ts new file mode 100644 index 000000000..ec29103d5 --- /dev/null +++ b/shared/src/book/book-ops.ts @@ -0,0 +1,668 @@ +import { bookGridElements, defaultBookGridBox, findBookGrid, type BookGridDef } from './book-grids'; +import { bookElementId } from './book-ids'; +import { BOOK_TEXT_STYLES, applyBookTemplate, classifyBookTexts, findBookTemplate } from './book-layouts'; +import { + caught, + checkedSpread, + createdIds, + droppedIds, + failOp, + galleryPhoto, + invalidOp, + journeyEntry, + round2, + type BookEditResult, + type BookOpsContext, + type BookPhotoInfo, +} from './book-ops-core'; +import { BOOK_OP_LIMITS, bookPageOpSchema, type BookPageOp } from './book-ops.schema'; +import { applySavedBookLayout, bookLayoutRole } from './book-saved-layouts'; +import { + MAX_SPREAD_ELEMENTS, + MAX_TEXT_LENGTH, + type BookDocument, + type BookElement, + type BookPageSetup, + type BookPhotoElement, + type BookSpread, + type BookTextElement, +} from './book.schema'; + +import { z } from 'zod'; + +/** + * The one edit engine for a book page. + * + * Pure: a document and a list of operations in, a new document or a typed + * refusal out. The server's book tools write with it, a chat card draws its + * "after" with it, the Studio previews with it, so a change means the same + * thing in all three. Nothing here throws to the caller; a refusal names the + * operation that caused it, and nothing of a refused call is applied. + * + * Two rules hold for every operation: + * - a locked element refuses any operation that would change it (a layout + * leaves it where it is, in its place in the paint order); + * - the cover, first, last and back pages cannot be moved or deleted + * (`canEditSpread`); their contents can be edited like any page's. + */ + +interface Env { + doc: BookDocument; + page: BookPageSetup; + ctx: BookOpsContext; + newId: (prefix: string) => string; + opIndex: number; +} + +const opsSchema = z.array(bookPageOpSchema).min(1).max(BOOK_OP_LIMITS.opsPerCall); + +/** + * Apply up to twenty operations to one page, in order, all or nothing. + * + * `edit` is the `edit_book_page` arguments: the page by id and its operations, + * which are parsed here again so a caller holding unchecked data gets a + * refusal rather than a broken book. + */ +export function applyBookOps( + doc: BookDocument, + edit: { spreadId: string; ops: readonly unknown[] }, + ctx: BookOpsContext = {}, +): BookEditResult { + const parsed = opsSchema.safeParse(edit.ops); + if (!parsed.success) return { ok: false, error: invalidOp(parsed.error, 0) }; + const index = doc.spreads.findIndex((s) => s.id === edit.spreadId); + const original = doc.spreads[index]; + if (!original) { + return { ok: false, error: { code: 'spread_not_found', message: `no page ${edit.spreadId}`, opIndex: null } }; + } + return caught(() => { + const env: Env = { doc, page: doc.page, ctx, newId: ctx.newId ?? bookElementId, opIndex: 0 }; + let spread = original; + parsed.data.forEach((op, i) => { + env.opIndex = i; + spread = applyOne(spread, op, env); + }); + checkedSpread(spread, null); + const spreads = doc.spreads.slice(); + spreads[index] = spread; + return { + ok: true, + doc: { ...doc, spreads }, + spreadIds: [spread.id], + created: createdIds([original], [spread]), + dropped: droppedIds([original], [spread]), + }; + }); +} + +function applyOne(spread: BookSpread, op: BookPageOp, env: Env): BookSpread { + switch (op.op) { + case 'layout': + return opLayout(spread, op, env); + case 'place_photo': + return opPlacePhoto(spread, op, env); + case 'photo_look': + return opPhotoLook(spread, op, env); + case 'swap_photos': + return opSwapPhotos(spread, op, env); + case 'text': + return opText(spread, op, env); + case 'caption': + return opCaption(spread, op, env); + case 'frame': + return opFrame(spread, op, env); + case 'remove': + return opRemove(spread, op, env); + case 'background': + return { ...spread, background: op.color }; + } +} + +type Op = Extract; + +// ── Lookups ──────────────────────────────────────────────────────────────── + +function element(spread: BookSpread, id: string, env: Env): BookElement { + const el = spread.elements.find((e) => e.id === id); + if (el) return el; + const parked = spread.parked.some((e) => e.id === id); + throw failOp( + 'element_not_found', + parked + ? `${id} is parked, not on the page; lay the page out again to bring it back` + : `no element ${id} on this page`, + env.opIndex, + id, + ); +} + +function unlocked(el: E, env: Env): E { + if (el.locked) throw failOp('locked', `${el.id} is locked; it is left as it is`, env.opIndex, el.id); + return el; +} + +function photoEl(spread: BookSpread, id: string, env: Env): BookPhotoElement { + const el = element(spread, id, env); + if (el.kind !== 'photo') throw failOp('wrong_kind', `${id} is a ${el.kind}, not a photo frame`, env.opIndex, id); + return el; +} + +function textEl(spread: BookSpread, id: string, env: Env): BookTextElement { + const el = element(spread, id, env); + if (el.kind !== 'text') throw failOp('wrong_kind', `${id} is a ${el.kind}, not a text`, env.opIndex, id); + return el; +} + +const replaceEl = (spread: BookSpread, next: BookElement): BookSpread => ({ + ...spread, + elements: spread.elements.map((e) => (e.id === next.id ? next : e)), +}); + +function withRoom(spread: BookSpread, add: number, env: Env): void { + if (spread.elements.length + add > MAX_SPREAD_ELEMENTS) { + throw failOp('too_many_elements', `a page holds at most ${MAX_SPREAD_ELEMENTS} elements`, env.opIndex); + } +} + +const spreadWidth = (spread: BookSpread, page: BookPageSetup) => page.pageWidth * (spread.role === 'inner' ? 2 : 1); + +// ── layout ───────────────────────────────────────────────────────────────── + +function opLayout(spread: BookSpread, op: Op<'layout'>, env: Env): BookSpread { + // A locked element is left where it is, and keeps its place in the paint + // order; the layout arranges everything else. + const locked = spread.elements.flatMap((el, i) => (el.locked ? [{ el, i }] : [])); + const free: BookSpread = { ...spread, elements: spread.elements.filter((e) => !e.locked) }; + const next = layoutFree(free, op, env); + const elements = next.elements.slice(); + for (const { el, i } of locked) elements.splice(Math.min(i, elements.length), 0, el); + if (elements.length > MAX_SPREAD_ELEMENTS) { + throw failOp('too_many_elements', `the layout and the locked elements exceed ${MAX_SPREAD_ELEMENTS}`, env.opIndex); + } + return { ...next, elements }; +} + +function layoutFree(spread: BookSpread, op: Op<'layout'>, env: Env): BookSpread { + const single = spread.role !== 'inner'; + if (op.template !== undefined) { + const tpl = findBookTemplate(op.template); + if (!tpl) throw failOp('layout_not_found', `no layout ${op.template}`, env.opIndex); + if ((tpl.role === 'single') !== single) { + throw failOp( + 'layout_role', + `${tpl.id} is a ${tpl.role === 'single' ? 'single-page (cover) layout' : 'double-spread layout'}; this page is ${single ? 'a single page' : 'a double spread'}`, + env.opIndex, + ); + } + return applyBookTemplate(spread, tpl, env.page); + } + if (op.grid !== undefined) { + const grid = findBookGrid(op.grid); + if (!grid) throw failOp('layout_not_found', `no grid ${op.grid}`, env.opIndex); + return applyGridLayout(spread, grid, env.page); + } + const layout = (env.doc.layouts ?? []).find((l) => l.id === op.saved || l.name === op.saved); + if (!layout) throw failOp('layout_not_found', `this book keeps no layout ${op.saved}`, env.opIndex); + if (layout.role !== bookLayoutRole(spread)) { + throw failOp( + 'layout_role', + `layout ${layout.name} is for ${layout.role === 'inner' ? 'a double spread' : 'a single page'}`, + env.opIndex, + ); + } + return applySavedBookLayout(spread, layout, env.page); +} + +/** + * Re-pour the page's photos into a grid over its safe area. + * + * Not the Studio's grid button, which only adds empty grid frames on top of + * the page for a person to fill by hand: the assistant cannot drag, so a grid + * here is a layout like any other. Every photo of the page, placed or parked, + * goes into the grid's cells in order, keeping its look; the old frames go, + * empty ones included, and photos the grid has no cell for are parked. Words, + * shapes and travel elements stay where they are, the grid going behind them + * where the first photo was in the paint order. `listBookLayouts` says the + * same, so a model reads it before it picks one. + */ +function applyGridLayout(spread: BookSpread, grid: BookGridDef, page: BookPageSetup): BookSpread { + const cells = bookGridElements(grid, defaultBookGridBox(page, spread.role !== 'inner')); + const photos = [...spread.elements, ...spread.parked].filter( + (e): e is BookPhotoElement => e.kind === 'photo' && e.photoId != null, + ); + const filled = cells.map((cell, i) => { + const src = photos[i]; + return src && cell.kind === 'photo' + ? ({ + ...cell, + photoId: src.photoId, + fit: src.fit, + focalX: src.focalX, + focalY: src.focalY, + radius: src.radius, + filter: src.filter, + mask: src.mask, + frameStyle: src.frameStyle, + } as BookElement) + : cell; + }); + const firstPhoto = spread.elements.findIndex((e) => e.kind === 'photo'); + const rest = spread.elements.filter((e) => e.kind !== 'photo'); + const at = firstPhoto < 0 ? 0 : spread.elements.slice(0, firstPhoto).filter((e) => e.kind !== 'photo').length; + const parked = [...photos.slice(cells.length), ...spread.parked.filter((e) => e.kind !== 'photo')].slice( + 0, + MAX_SPREAD_ELEMENTS, + ); + return { ...spread, elements: [...rest.slice(0, at), ...filled, ...rest.slice(at)], parked }; +} + +// ── photos ───────────────────────────────────────────────────────────────── + +function opPlacePhoto(spread: BookSpread, op: Op<'place_photo'>, env: Env): BookSpread { + const info = galleryPhoto(op.photoId, env.ctx, env.opIndex); + // The picture is on the page now, so a parked copy of it is not waiting any more. + const parked = spread.parked.filter((e) => !(e.kind === 'photo' && e.photoId === op.photoId)); + if (op.target === 'new') { + withRoom(spread, 1, env); + const el = newPhoto(env.newId('p'), newPhotoFrame(spread, env.page, info), op); + return { ...spread, elements: [...spread.elements, el], parked }; + } + const target = + 'elementId' in op.target ? photoEl(spread, op.target.elementId, env) : photoInSlot(spread, op.target.slot, env); + unlocked(target, env); + // A new picture brings no focal point of its own: the old one was for the old picture. + const same = target.photoId === op.photoId; + return replaceEl( + { ...spread, parked }, + { + ...target, + photoId: op.photoId, + fit: op.fit ?? target.fit, + focalX: op.focalX ?? (same ? target.focalX : 0.5), + focalY: op.focalY ?? (same ? target.focalY : 0.5), + }, + ); +} + +function photoInSlot(spread: BookSpread, slot: number, env: Env): BookPhotoElement { + const frames = spread.elements.filter((e): e is BookPhotoElement => e.kind === 'photo'); + const el = frames[slot]; + if (!el) { + throw failOp('slot_not_found', `the page has ${frames.length} photo frames; slot ${slot} is not one`, env.opIndex); + } + return el; +} + +function newPhoto(id: string, frame: BookElement['frame'], op: Op<'place_photo'>): BookPhotoElement { + return { + id, + kind: 'photo', + frame, + rotation: 0, + opacity: 1, + locked: false, + photoId: op.photoId, + fit: op.fit ?? 'cover', + focalX: op.focalX ?? 0.5, + focalY: op.focalY ?? 0.5, + radius: 0, + filter: 'none', + mask: null, + frameStyle: 'none', + }; +} + +/** + * Where a new frame goes: half the short side of a page, in the photo's own + * proportions when they are known, centred on the page (the right-hand page of + * a spread, so it never lands in the fold). + */ +function newPhotoFrame(spread: BookSpread, page: BookPageSetup, info: BookPhotoInfo | undefined): BookElement['frame'] { + const side = Math.min(page.pageWidth, page.pageHeight) * 0.5; + const ratio = info?.width && info.height ? info.width / info.height : 1 / 0.72; + const w = ratio >= 1 ? side : side * ratio; + const h = ratio >= 1 ? side / ratio : side; + const origin = spread.role === 'inner' ? page.pageWidth : 0; + return { + x: round2(origin + (page.pageWidth - w) / 2), + y: round2((page.pageHeight - h) / 2), + w: round2(w), + h: round2(h), + }; +} + +function opPhotoLook(spread: BookSpread, op: Op<'photo_look'>, env: Env): BookSpread { + const el = unlocked(photoEl(spread, op.elementId, env), env); + return replaceEl(spread, { + ...el, + ...(op.fit !== undefined ? { fit: op.fit } : {}), + ...(op.focalX !== undefined ? { focalX: op.focalX } : {}), + ...(op.focalY !== undefined ? { focalY: op.focalY } : {}), + ...(op.mask !== undefined ? { mask: op.mask } : {}), + ...(op.frameStyle !== undefined ? { frameStyle: op.frameStyle } : {}), + ...(op.filter !== undefined ? { filter: op.filter } : {}), + ...(op.radius !== undefined ? { radius: round2(op.radius) } : {}), + }); +} + +/** The caption texts of a picture on this page: text bound to its caption. */ +const isCaptionOf = (el: BookElement, photoId: number | null): el is BookTextElement => + photoId != null && el.kind === 'text' && el.binding?.source === 'photo.caption' && el.binding.photoId === photoId; + +/** + * Exchange the pictures of two frames. + * + * The frames, their look and their place stay; the photo and its focal point + * move. A caption bound to either picture moves with it, by the distance + * between the two frames' lower left corners, so it stays under its picture. + */ +function opSwapPhotos(spread: BookSpread, op: Op<'swap_photos'>, env: Env): BookSpread { + const a = unlocked(photoEl(spread, op.a, env), env); + const b = unlocked(photoEl(spread, op.b, env), env); + const shift = (from: BookPhotoElement, to: BookPhotoElement) => ({ + dx: to.frame.x - from.frame.x, + dy: to.frame.y + to.frame.h - (from.frame.y + from.frame.h), + }); + const moves = new Map(); + if (a.photoId !== b.photoId) { + for (const el of spread.elements) { + const of = el.kind === 'text' && el.binding?.source === 'photo.caption' ? el.binding.photoId : undefined; + if (of !== undefined && of === a.photoId) moves.set(unlocked(el, env).id, shift(a, b)); + else if (of !== undefined && of === b.photoId) moves.set(unlocked(el, env).id, shift(b, a)); + } + } + const elements = spread.elements.map((el) => { + if (el.id === a.id) return { ...a, photoId: b.photoId, focalX: b.focalX, focalY: b.focalY }; + if (el.id === b.id) return { ...b, photoId: a.photoId, focalX: a.focalX, focalY: a.focalY }; + const move = moves.get(el.id); + if (!move) return el; + return { ...el, frame: { ...el.frame, x: round2(el.frame.x + move.dx), y: round2(el.frame.y + move.dy) } }; + }); + return { ...spread, elements }; +} + +// ── text ─────────────────────────────────────────────────────────────────── + +type Binding = NonNullable; + +/** + * The words a binding reads, from what the context knows, or undefined. + * + * Dates and coordinates are set in a reader's language and notation, which the + * engine does not know: those need their words given. + */ +function boundWords(binding: Binding, ctx: BookOpsContext): string | undefined { + const j = ctx.journey; + const pick = (v: string | null | undefined) => (v && v.trim() ? v.slice(0, MAX_TEXT_LENGTH) : undefined); + switch (binding.source) { + case 'journey.title': + return pick(j?.title); + case 'journey.subtitle': + return pick(j?.subtitle); + case 'photo.caption': + return pick(ctx.photos?.find((p) => p.photoId === binding.photoId)?.caption); + case 'entry.title': + case 'entry.story': + case 'entry.location': { + if (binding.source === 'entry.location' && binding.format) return undefined; + const entry = j?.entries?.find((e) => e.id === binding.entryId); + if (!entry) return undefined; + if (binding.source === 'entry.title') return pick(entry.title || entry.location); + return pick(binding.source === 'entry.story' ? entry.story?.trim() : entry.location); + } + default: + return undefined; + } +} + +/** + * Words given to a bound text keep it bound only when they are what the + * binding reads anyway; otherwise the text is the author's from now on, as when + * somebody types into it in the Studio. + */ +const overriddenBy = (words: string, binding: Binding | null, ctx: BookOpsContext) => + binding ? boundWords(binding, ctx) !== words : false; + +const styleOf = (op: Op<'text'>): Partial => ({ + ...(op.font !== undefined ? { font: op.font } : {}), + ...(op.size !== undefined ? { size: op.size } : {}), + ...(op.weight !== undefined ? { weight: op.weight } : {}), + ...(op.align !== undefined ? { align: op.align } : {}), + ...(op.color !== undefined ? { color: op.color } : {}), +}); + +/** Dates and coordinates: words set in a reader's notation, which the engine cannot write. */ +const isFormatted = (binding: Binding) => + binding.source === 'entry.date' || (binding.source === 'entry.location' && binding.format !== undefined); + +/** The ids a binding names must be the journey's own: a photo of its gallery, one of its entries. */ +function checkBindingIds(binding: Binding, env: Env): void { + if (binding.photoId !== undefined) galleryPhoto(binding.photoId, env.ctx, env.opIndex); + if (binding.entryId !== undefined) journeyEntry(binding.entryId, env.ctx, env.opIndex); +} + +/** A photo's caption is the photo's, not the page's: a role never lands on one. */ +const roleTexts = (spread: BookSpread) => + spread.elements.filter((e) => e.kind === 'text' && e.binding?.source !== 'photo.caption'); + +function opText(spread: BookSpread, op: Op<'text'>, env: Env): BookSpread { + if (op.binding) checkBindingIds(op.binding, env); + const existing = + op.elementId !== undefined + ? textEl(spread, op.elementId, env) + : classifyBookTexts(roleTexts(spread))[op.role ?? 'body']; + if (existing && existing.kind === 'text') return replaceEl(spread, retext(unlocked(existing, env), op, env)); + withRoom(spread, 1, env); + return { ...spread, elements: [...spread.elements, newText(spread, op, env)] }; +} + +function retext(el: BookTextElement, op: Op<'text'>, env: Env): BookTextElement { + const style = styleOf(op); + if (op.binding === null) + return { ...el, ...style, ...(op.text !== undefined ? { text: op.text } : {}), binding: null, overridden: false }; + if (op.binding !== undefined) { + if (op.text === undefined && isFormatted(op.binding)) { + throw failOp( + 'text_required', + `give the words for ${el.id}; a date or coordinates are set in the reader's notation, so they need words`, + env.opIndex, + el.id, + ); + } + const words = op.text ?? boundWords(op.binding, env.ctx) ?? (el.text.trim() ? el.text : undefined); + if (words === undefined) + throw failOp('text_required', `give the words for ${el.id}; the binding cannot be read here`, env.opIndex, el.id); + // Bound without words of its own, the text follows its source: the Studio + // re-reads the journey's title and subtitle, an entry's title, story and + // place name and a photo's caption for any text that is not overridden. + // A date or coordinates it rewrites only from a stored value this engine + // does not write, which is why those need their words given (above). + const overridden = op.text !== undefined && overriddenBy(op.text, op.binding, env.ctx); + return { ...el, ...style, text: words, binding: op.binding, overridden }; + } + if (op.text !== undefined) { + return { ...el, ...style, text: op.text, overridden: el.overridden || overriddenBy(op.text, el.binding, env.ctx) }; + } + return { ...el, ...style }; +} + +/** Where a text the page has no slot for goes: the quiet layouts' slot for its role. */ +function newTextFrame( + spread: BookSpread, + role: 'heading' | 'body' | 'meta', + page: BookPageSetup, +): BookElement['frame'] { + const single = spread.role !== 'inner'; + const tpl = findBookTemplate(single ? 'cover-quiet' : 'quiet-text'); + const slot = tpl?.build(page).find((s) => s.kind === role); + if (slot) + return { x: round2(slot.frame.x), y: round2(slot.frame.y), w: round2(slot.frame.w), h: round2(slot.frame.h) }; + // A single page's quiet layout has no story slot: the story goes under its meta line. + const top = page.pageHeight * 0.42 + 44; + return { x: 16, y: round2(top), w: round2(page.pageWidth - 32), h: round2(Math.max(10, page.pageHeight - 16 - top)) }; +} + +/** A text's look before its role's style and the operation's own choices go over it. */ +const TEXT_DEFAULTS: Pick< + BookTextElement, + 'font' | 'size' | 'weight' | 'italic' | 'align' | 'leading' | 'tracking' | 'color' +> = { + font: 'sans', + size: 11, + weight: 400, + italic: false, + align: 'left', + leading: 1.45, + tracking: 0, + color: '#1a1a1a', +}; + +function newText(spread: BookSpread, op: Op<'text'>, env: Env): BookTextElement { + const role = op.role ?? 'body'; + const binding = op.binding ?? null; + const words = op.text ?? (binding ? boundWords(binding, env.ctx) : undefined); + if (!words) throw failOp('text_required', `the page has no ${role} text; give the words for a new one`, env.opIndex); + return { + id: env.newId('t'), + kind: 'text', + frame: newTextFrame(spread, role, env.page), + rotation: 0, + opacity: 1, + locked: false, + text: words, + ...TEXT_DEFAULTS, + ...BOOK_TEXT_STYLES[role], + ...styleOf(op), + binding, + overridden: overriddenBy(words, binding, env.ctx), + }; +} + +// ── caption ──────────────────────────────────────────────────────────────── + +/** + * A caption, as the auto layout sets one: a small text bound to the photo's + * caption in the journey (`journey_photos.caption`), so editing the caption in + * the journal edits the book. Words given here are the book's own, and stop + * that sync, the way typing into it in the Studio does. + */ +function opCaption(spread: BookSpread, op: Op<'caption'>, env: Env): BookSpread { + const photo = photoEl(spread, op.photoElementId, env); + if (photo.photoId == null) { + throw failOp('empty_frame', `${photo.id} holds no photo to caption`, env.opIndex, photo.id); + } + const journal = env.ctx.photos?.find((p) => p.photoId === photo.photoId)?.caption?.trim() || undefined; + const words = op.text?.trim() ? op.text : journal; + if (!words) { + throw failOp( + 'caption_missing', + `photo ${photo.photoId} has no caption in the journey; give the words`, + env.opIndex, + photo.id, + ); + } + const overridden = op.text?.trim() ? op.text !== journal : false; + const existing = spread.elements.find((e) => isCaptionOf(e, photo.photoId)); + if (existing && existing.kind === 'text') { + return replaceEl(spread, { ...unlocked(existing, env), text: words, overridden }); + } + withRoom(spread, 1, env); + const place = captionPlace(photo, spread, env.page); + const caption: BookTextElement = { + id: env.newId('t'), + kind: 'text', + frame: place.frame, + rotation: 0, + opacity: place.inside ? 0.9 : 1, + locked: false, + text: words, + font: 'sans', + size: 7.5, + weight: 500, + italic: false, + align: 'left', + leading: 1.45, + tracking: 0, + color: place.inside ? '#ffffff' : '#4a4a4a', + binding: { + source: 'photo.caption', + photoId: photo.photoId, + ...(spread.entryId != null ? { entryId: spread.entryId } : {}), + }, + overridden, + }; + return { ...spread, elements: [...spread.elements, caption] }; +} + +/** + * Under the picture when the page has room for a line there, else on the + * picture's lower edge in white, as the auto layout sets a caption on a + * full-bleed photo. Kept on the page the picture starts on, out of the fold. + */ +function captionPlace( + photo: BookPhotoElement, + spread: BookSpread, + page: BookPageSetup, +): { frame: BookElement['frame']; inside: boolean } { + const W = spreadWidth(spread, page); + const fold = spread.role === 'inner' ? page.pageWidth : null; + const left = Math.max(photo.frame.x, page.safe); + let right = Math.min(photo.frame.x + photo.frame.w, W - page.safe); + if (fold !== null && left < fold && right > fold) right = fold - page.safe; + const below = photo.frame.y + photo.frame.h + 2; + if (right - left >= 20 && below + 5 <= page.pageHeight - page.safe) { + return { frame: { x: round2(left), y: round2(below), w: round2(right - left), h: 5 }, inside: false }; + } + const inset = Math.max(6, page.safe + 1); + const bottom = Math.min(photo.frame.y + photo.frame.h, page.pageHeight); + const x = Math.max(photo.frame.x, 0) + inset; + return { + frame: { x: round2(x), y: round2(bottom - inset - 5), w: round2(Math.max(10, right - x - inset)), h: 5 }, + inside: true, + }; +} + +// ── frame, remove ────────────────────────────────────────────────────────── + +function opFrame(spread: BookSpread, op: Op<'frame'>, env: Env): BookSpread { + const el = unlocked(element(spread, op.elementId, env), env); + const frame = { + x: round2(op.x ?? el.frame.x), + y: round2(op.y ?? el.frame.y), + w: round2(op.w ?? el.frame.w), + h: round2(op.h ?? el.frame.h), + }; + return replaceEl(spread, { ...el, frame, rotation: op.rotation ?? el.rotation } as BookElement); +} + +/** Worth keeping when parked: a frame with a picture, a text with words, anything else that is drawn. */ +const worthParking = (el: BookElement) => + el.kind === 'photo' ? el.photoId != null : el.kind === 'text' ? el.text.trim() !== '' : true; + +function opRemove(spread: BookSpread, op: Op<'remove'>, env: Env): BookSpread { + const ids = new Set(op.elementIds); + const deleteParked = new Set(); + for (const id of ids) { + const onPage = spread.elements.find((e) => e.id === id); + if (onPage) { + unlocked(onPage, env); + } else if (spread.parked.some((e) => e.id === id)) { + if (!op.park) deleteParked.add(id); + } else { + throw failOp('element_not_found', `no element ${id} on this page`, env.opIndex, id); + } + } + const taken = spread.elements.filter((e) => ids.has(e.id)); + const parked = [ + ...spread.parked.filter((e) => !deleteParked.has(e.id)), + ...(op.park ? taken.filter(worthParking) : []), + ]; + if (parked.length > MAX_SPREAD_ELEMENTS) { + throw failOp( + 'parked_full', + `a page parks at most ${MAX_SPREAD_ELEMENTS} elements; delete some with park false`, + env.opIndex, + ); + } + return { ...spread, elements: spread.elements.filter((e) => !ids.has(e.id)), parked }; +} diff --git a/shared/src/book/book-page-ops.spec.ts b/shared/src/book/book-page-ops.spec.ts new file mode 100644 index 000000000..955767589 --- /dev/null +++ b/shared/src/book/book-page-ops.spec.ts @@ -0,0 +1,276 @@ +import type { BookEditResult } from './book-ops-core'; +import { addBookPages, deleteBookPages, moveBookPage, restoreBookPage, setBookCover } from './book-page-ops'; +import { blankBook, blankSpread, bookPageSetupForPreset, canEditSpread, innerSpreadBounds } from './book-pages'; +import { + MAX_SPREADS, + bookDocumentSchema, + bookPageSetupSchema, + bookPhotoElementSchema, + bookTextElementSchema, + type BookDocument, + type BookPhotoElement, + type BookSpread, + type BookTextElement, +} from './book.schema'; + +import { describe, expect, it } from 'vitest'; + +/** + * The operations on a book's pages rather than on one page: the blank book, + * which pages are fixed, add, move, delete, the cover and the undo restore. + */ + +let seq = 0; +const newId = (p: string) => `${p}-n${++seq}`; + +/** Cover, first, `inner` inner spreads, last, back, with readable ids. */ +function book(inner = 2): BookDocument { + const doc = blankBook({ title: 'Iceland', newId }); + const spreads: BookSpread[] = [ + { ...blankSpread('cover'), id: 'sp-cover' }, + { ...blankSpread('first'), id: 'sp-first' }, + ...Array.from({ length: inner }, (_, i) => ({ ...blankSpread('inner'), id: `sp-in${i + 1}` })), + { ...blankSpread('last'), id: 'sp-last' }, + { ...blankSpread('back'), id: 'sp-back' }, + ]; + return { ...doc, spreads }; +} + +const ids = (doc: BookDocument) => doc.spreads.map((s) => s.id); + +function ok(result: BookEditResult): BookDocument { + if (result.ok === false) throw new Error(`refused: ${result.error.code} ${result.error.message}`); + return result.doc; +} + +function refused(result: BookEditResult): string { + if (result.ok) throw new Error('expected a refusal'); + return result.error.code; +} + +describe('the blank book and the fixed pages', () => { + it('BOOKPG-001: lays down cover, first, one inner spread, last and back, all empty and valid', () => { + const doc = blankBook({ title: 'Iceland', newId }); + expect(doc.spreads.map((s) => s.role)).toEqual(['cover', 'first', 'inner', 'last', 'back']); + expect(doc.spreads.every((s) => s.elements.length === 0 && s.parked.length === 0)).toBe(true); + expect(new Set(ids(doc)).size).toBe(5); + expect(bookDocumentSchema.safeParse(doc).success).toBe(true); + }); + + it('BOOKPG-002: starts the page numbers at 1 and keeps the rest of the setup it was handed', () => { + const page = bookPageSetupSchema.parse({ pageWidth: 300, pageHeight: 200 }); + const doc = blankBook({ + title: 'x'.repeat(500), + page: { ...page, pageNumbers: { ...page.pageNumbers, startAt: 2 } }, + }); + expect(doc.page.pageNumbers.startAt).toBe(1); + expect(doc.page.pageWidth).toBe(300); + expect(doc.title.length).toBeLessThan(500); + }); + + it('BOOKPG-022: lays a new book down in the format it is asked for, at that format's size', () => { + const doc = blankBook({ title: 'Iceland', preset: 'a4-landscape', newId }); + expect(doc.page).toMatchObject({ preset: 'a4-landscape', pageWidth: 297, pageHeight: 210, bleed: 3, safe: 5 }); + expect(doc.page.pageNumbers.startAt).toBe(1); + const square = blankBook({ title: 'x', newId }).page; + expect(square).toMatchObject({ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 }); + expect(bookPageSetupForPreset('a5-landscape')).toMatchObject({ pageWidth: 210, pageHeight: 148 }); + }); + + it('BOOKPG-003: counts only inner spreads as movable, and finds the range between the fixed pages', () => { + const doc = book(2); + expect(doc.spreads.map((s) => canEditSpread(s))).toEqual([false, false, true, true, false, false]); + expect(canEditSpread(undefined)).toBe(false); + expect(innerSpreadBounds(doc.spreads)).toEqual({ first: 2, limit: 4 }); + expect(innerSpreadBounds(book(0).spreads)).toEqual({ first: 2, limit: 2 }); + expect(innerSpreadBounds([])).toEqual({ first: 0, limit: 0 }); + }); +}); + +describe('add, move and delete', () => { + it('BOOKPG-004: adds empty inner spreads after a page and answers their ids', () => { + const r = addBookPages(book(1), { afterSpreadId: 'sp-in1', count: 2, entryId: 7 }, { newId }); + const doc = ok(r); + expect(doc.spreads.map((s) => s.role)).toEqual(['cover', 'first', 'inner', 'inner', 'inner', 'last', 'back']); + expect(r.ok && r.spreadIds).toEqual([doc.spreads[3]?.id, doc.spreads[4]?.id]); + expect(doc.spreads[3]?.entryId).toBe(7); + }); + + it('BOOKPG-005: keeps added pages inside the covers, whichever page they follow', () => { + expect(ok(addBookPages(book(1), { afterSpreadId: 'sp-cover' }, { newId })).spreads[2]?.role).toBe('inner'); + const atEnd = ok(addBookPages(book(1), { afterSpreadId: 'sp-back' }, { newId })); + expect(atEnd.spreads.slice(-2).map((s) => s.id)).toEqual(['sp-last', 'sp-back']); + }); + + it('BOOKPG-006: lays added pages out on a template when one is named', () => { + const doc = ok(addBookPages(book(1), { afterSpreadId: 'sp-in1', template: 'grid-9' }, { newId })); + expect(doc.spreads[3]?.elements.length).toBeGreaterThan(0); + }); + + it('BOOKPG-020: ties an added page only to an entry the journey has, when the journey is known', () => { + const journey = { title: 'Iceland', entries: [{ id: 7 }] }; + const added = ok(addBookPages(book(1), { afterSpreadId: 'sp-in1', entryId: 7 }, { newId, journey })); + expect(added.spreads[3]?.entryId).toBe(7); + const stray = addBookPages(book(1), { afterSpreadId: 'sp-in1', entryId: 8 }, { newId, journey }); + expect(refused(stray)).toBe('entry_not_found'); + }); + + it('BOOKPG-007: refuses an unknown page, more than ten pages, and a book at its page cap', () => { + expect(refused(addBookPages(book(1), { afterSpreadId: 'sp-nope' }))).toBe('spread_not_found'); + expect(refused(addBookPages(book(1), { afterSpreadId: 'sp-in1', count: 11 }))).toBe('invalid_op'); + expect(refused(addBookPages(book(MAX_SPREADS - 4), { afterSpreadId: 'sp-in1' }))).toBe('too_many_spreads'); + }); + + it('BOOKPG-008: moves an inner spread within the inner range', () => { + const doc = ok(moveBookPage(book(3), { spreadId: 'sp-in1', toIndex: 4 })); + expect(ids(doc)).toEqual(['sp-cover', 'sp-first', 'sp-in2', 'sp-in3', 'sp-in1', 'sp-last', 'sp-back']); + }); + + it('BOOKPG-009: refuses to move a fixed page or to move a spread outside the covers', () => { + for (const id of ['sp-cover', 'sp-first', 'sp-last', 'sp-back']) { + expect(refused(moveBookPage(book(2), { spreadId: id, toIndex: 2 }))).toBe('protected_spread'); + } + expect(refused(moveBookPage(book(2), { spreadId: 'sp-in1', toIndex: 0 }))).toBe('index_out_of_range'); + expect(refused(moveBookPage(book(2), { spreadId: 'sp-in1', toIndex: 4 }))).toBe('index_out_of_range'); + }); + + it('BOOKPG-010: deletes inner spreads, once each even when named twice', () => { + const r = deleteBookPages(book(3), { spreadIds: ['sp-in1', 'sp-in3', 'sp-in1'] }); + expect(ids(ok(r))).toEqual(['sp-cover', 'sp-first', 'sp-in2', 'sp-last', 'sp-back']); + expect(r.ok && r.spreadIds).toEqual(['sp-in1', 'sp-in3']); + }); + + it('BOOKPG-011: refuses to delete the cover, first, last or back page, and deletes nothing then', () => { + for (const id of ['sp-cover', 'sp-first', 'sp-last', 'sp-back']) { + expect(refused(deleteBookPages(book(2), { spreadIds: ['sp-in1', id] }))).toBe('protected_spread'); + } + expect(refused(deleteBookPages(book(2), { spreadIds: [] }))).toBe('invalid_op'); + expect(refused(deleteBookPages(book(2), { spreadIds: Array.from({ length: 11 }, (_, i) => `sp-${i}`) }))).toBe( + 'invalid_op', + ); + }); +}); + +describe('restore', () => { + it('BOOKPG-012: puts a page back in its place when the book still has it', () => { + const before = book(2); + const changed = { ...(before.spreads[2] as BookSpread), background: '#112233' }; + const doc = ok(restoreBookPage(before, { spread: changed, index: 5, expectedVersion: 3 })); + expect(ids(doc)).toEqual(ids(before)); + expect(doc.spreads[2]?.background).toBe('#112233'); + }); + + it('BOOKPG-013: re-inserts a deleted inner spread at its index, kept inside the covers', () => { + const before = book(2); + const gone = before.spreads[3] as BookSpread; + const without = ok(deleteBookPages(before, { spreadIds: [gone.id] })); + expect(ids(ok(restoreBookPage(without, { spread: gone, index: 3, expectedVersion: 1 })))).toEqual(ids(before)); + const clamped = ok(restoreBookPage(without, { spread: gone, index: 0, expectedVersion: 1 })); + expect(clamped.spreads[2]?.id).toBe(gone.id); + }); + + it('BOOKPG-014: refuses a page that is not a page', () => { + expect( + refused(restoreBookPage(book(1), { spread: { id: 'x', role: 'middle' }, index: 2, expectedVersion: 1 })), + ).toBe('invalid_op'); + }); + + it('BOOKPG-019: never makes a second cover, and never turns a page into another kind', () => { + const doc = book(1); + const asCover = { ...(doc.spreads[2] as BookSpread), role: 'cover' as const }; + expect(refused(restoreBookPage(doc, { spread: asCover, index: 2, expectedVersion: 1 }))).toBe('invalid_op'); + for (const role of ['cover', 'first', 'last', 'back'] as const) { + const extra = { ...blankSpread(role), id: `sp-extra-${role}` }; + expect(refused(restoreBookPage(doc, { spread: extra, index: 2, expectedVersion: 1 }))).toBe('protected_spread'); + } + const lost = { ...doc, spreads: doc.spreads.filter((s) => s.role !== 'back') }; + const back = doc.spreads[doc.spreads.length - 1] as BookSpread; + const restored = ok(restoreBookPage(lost, { spread: back, index: lost.spreads.length, expectedVersion: 1 })); + expect(ids(restored)).toEqual(ids(doc)); + }); +}); + +describe('the cover', () => { + const coverOf = (doc: BookDocument) => doc.spreads[0] as BookSpread; + const textsOf = (doc: BookDocument) => coverOf(doc).elements.filter((e): e is BookTextElement => e.kind === 'text'); + + it('BOOKPG-015: lays the cover out, fills its photo frame and sets the title bound to the journey', () => { + const doc = ok(setBookCover(book(1), { template: 'cover-full', photoId: 5, title: 'Iceland 2026' }, { newId })); + const photos = coverOf(doc).elements.filter((e): e is BookPhotoElement => e.kind === 'photo'); + expect(photos[0]?.photoId).toBe(5); + const title = textsOf(doc).find((t) => t.text === 'Iceland 2026'); + expect(title).toBeDefined(); + }); + + it('BOOKPG-016: keeps a bound title bound when the words are what the journey says, and the subtitle apart', () => { + const base = book(1); + const bound = bookTextElementSchema.parse({ + id: 't-title', + kind: 'text', + frame: { x: 20, y: 20, w: 170, h: 30 }, + text: 'Iceland', + size: 30, + binding: { source: 'journey.title' }, + }); + const sub = bookTextElementSchema.parse({ + id: 't-sub', + kind: 'text', + frame: { x: 20, y: 60, w: 170, h: 10 }, + text: 'Summer', + size: 9, + binding: { source: 'journey.subtitle' }, + }); + const withTexts = { + ...base, + spreads: base.spreads.map((s, i) => (i === 0 ? { ...s, elements: [bound, sub] } : s)), + }; + const ctx = { newId, journey: { title: 'Iceland', subtitle: 'Summer' } }; + const same = ok(setBookCover(withTexts, { title: 'Iceland' }, ctx)); + expect(textsOf(same).find((t) => t.id === 't-title')?.overridden).toBe(false); + const other = ok(setBookCover(withTexts, { subtitle: 'Winter' }, ctx)); + expect(textsOf(other).find((t) => t.id === 't-sub')).toMatchObject({ text: 'Winter', overridden: true }); + expect(textsOf(other).find((t) => t.id === 't-title')?.text).toBe('Iceland'); + }); + + it('BOOKPG-017: gives a cover with no free frame a full-bleed photo behind everything else', () => { + const doc = ok(setBookCover(book(1), { photoId: 9, title: 'Iceland' }, { newId })); + const first = coverOf(doc).elements[0]; + expect(first?.kind).toBe('photo'); + expect(first?.frame).toEqual({ x: -3, y: -3, w: 216, h: 216 }); + }); + + it('BOOKPG-021: fills an empty frame before one that already holds a picture', () => { + const base = book(1); + const frame = (id: string, photoId: number | null, x: number) => + bookPhotoElementSchema.parse({ id, kind: 'photo', frame: { x, y: 20, w: 80, h: 80 }, photoId }); + const elements = [frame('p-full', 1, 10), frame('p-empty', null, 110)]; + const cover = { ...(base.spreads[0] as BookSpread), elements }; + const twoFrames = { ...base, spreads: [cover, ...base.spreads.slice(1)] }; + const photos = coverOf(ok(setBookCover(twoFrames, { photoId: 2 }, { newId }))).elements as BookPhotoElement[]; + expect(photos.map((e) => [e.id, e.photoId])).toEqual([ + ['p-full', 1], + ['p-empty', 2], + ]); + }); + + it('BOOKPG-018: refuses a photo outside the gallery, a locked frame, and a book without a cover', () => { + expect(refused(setBookCover(book(1), { photoId: 9 }, { photos: [{ photoId: 1 }] }))).toBe('photo_not_in_gallery'); + const base = book(1); + const locked = bookPhotoElementSchema.parse({ + id: 'p-lock', + kind: 'photo', + frame: { x: 0, y: 0, w: 210, h: 210 }, + photoId: 1, + locked: true, + }); + const lockedCover = { + ...base, + spreads: base.spreads.map((s, i) => (i === 0 ? { ...s, elements: [locked] } : s)), + }; + // The only frame is locked, so the photo gets a new one and the locked frame stays as it was. + const doc = ok(setBookCover(lockedCover, { photoId: 2 }, { newId })); + expect(coverOf(doc).elements.find((e) => e.id === 'p-lock')).toEqual(locked); + const noCover = { ...base, spreads: base.spreads.slice(1) }; + expect(refused(setBookCover(noCover, { title: 'x' }))).toBe('no_cover'); + }); +}); diff --git a/shared/src/book/book-page-ops.ts b/shared/src/book/book-page-ops.ts new file mode 100644 index 000000000..d0a49aad7 --- /dev/null +++ b/shared/src/book/book-page-ops.ts @@ -0,0 +1,245 @@ +import { bookElementId } from './book-ids'; +import { applyBookTemplate, classifyBookTexts, findBookTemplate } from './book-layouts'; +import { applyBookOps } from './book-ops'; +import { + caught, + checkedSpread, + createdIds, + droppedIds, + failOp, + invalidOp, + journeyEntry, + type BookEditResult, + type BookOpsContext, +} from './book-ops-core'; +import { + addBookPagesRequestSchema, + deleteBookPagesRequestSchema, + moveBookPageRequestSchema, + restoreBookPageRequestSchema, + setBookCoverRequestSchema, + type BookPageOpInput, +} from './book-ops.schema'; +import { blankSpread, canEditSpread, innerSpreadBounds } from './book-pages'; +import { + MAX_SPREADS, + type BookDocument, + type BookElement, + type BookPhotoElement, + type BookSpread, +} from './book.schema'; + +/** + * Operations on the pages of a book rather than on one page: add, move, + * delete, the cover, and putting a page back for an undo. + * + * The same rules as the Studio's page rail, from the same place + * (book-pages.ts): only inner spreads move or go, and they stay between the + * first page and the last. Each takes the tool's arguments, parses them again, + * and answers a result rather than throwing. + */ + +const notFound = (id: string) => failOp('spread_not_found', `no page ${id}`, null); + +function indexOf(doc: BookDocument, spreadId: string): number { + const i = doc.spreads.findIndex((s) => s.id === spreadId); + if (i < 0) throw notFound(spreadId); + return i; +} + +/** Empty inner spreads after a page, kept inside the covers, laid out on a template when one is named. */ +export function addBookPages(doc: BookDocument, args: unknown, ctx: BookOpsContext = {}): BookEditResult { + const parsed = addBookPagesRequestSchema.safeParse(args); + if (!parsed.success) return { ok: false, error: invalidOp(parsed.error, null) }; + const { afterSpreadId, count = 1, template, entryId } = parsed.data; + return caught(() => { + const after = indexOf(doc, afterSpreadId); + if (entryId !== undefined) journeyEntry(entryId, ctx, null); + if (doc.spreads.length + count > MAX_SPREADS) { + throw failOp('too_many_spreads', `a book holds at most ${MAX_SPREADS} pages`, null); + } + const { first, limit } = innerSpreadBounds(doc.spreads); + const at = Math.min(Math.max(after + 1, first), limit); + const tpl = template === undefined ? undefined : findBookTemplate(template); + const added = Array.from({ length: count }, () => { + const empty = { ...blankSpread('inner', ctx.newId ?? bookElementId), entryId: entryId ?? null }; + return checkedSpread(tpl ? applyBookTemplate(empty, tpl, doc.page) : empty, null); + }); + const spreads = [...doc.spreads.slice(0, at), ...added, ...doc.spreads.slice(at)]; + return { + ok: true, + doc: { ...doc, spreads }, + spreadIds: added.map((s) => s.id), + created: createdIds(doc.spreads, spreads), + dropped: [], + }; + }); +} + +/** One inner spread to another place among the inner spreads. */ +export function moveBookPage(doc: BookDocument, args: unknown): BookEditResult { + const parsed = moveBookPageRequestSchema.safeParse(args); + if (!parsed.success) return { ok: false, error: invalidOp(parsed.error, null) }; + const { spreadId, toIndex } = parsed.data; + return caught(() => { + const from = indexOf(doc, spreadId); + const spread = doc.spreads[from]; + if (!canEditSpread(spread)) { + throw failOp('protected_spread', `${spreadId} is the ${spread?.role} page and keeps its place`, null); + } + const { first, limit } = innerSpreadBounds(doc.spreads); + if (toIndex < first || toIndex >= limit) { + throw failOp('index_out_of_range', `inner pages sit at index ${first} to ${limit - 1}`, null); + } + const spreads = doc.spreads.filter((_, i) => i !== from); + spreads.splice(toIndex, 0, doc.spreads[from] as BookSpread); + return { ok: true, doc: { ...doc, spreads }, spreadIds: [spreadId], created: [], dropped: [] }; + }); +} + +/** Inner spreads out of the book. The cover, first, last and back pages refuse. */ +export function deleteBookPages(doc: BookDocument, args: unknown): BookEditResult { + const parsed = deleteBookPagesRequestSchema.safeParse(args); + if (!parsed.success) return { ok: false, error: invalidOp(parsed.error, null) }; + const ids = [...new Set(parsed.data.spreadIds)]; + return caught(() => { + for (const id of ids) { + const spread = doc.spreads[indexOf(doc, id)]; + if (!canEditSpread(spread)) { + throw failOp('protected_spread', `${id} is the ${spread?.role} page and cannot be deleted`, null); + } + } + const gone = new Set(ids); + return { + ok: true, + doc: { ...doc, spreads: doc.spreads.filter((s) => !gone.has(s.id)) }, + spreadIds: ids, + created: [], + dropped: [], + }; + }); +} + +/** + * Put a page back as it was, for the assistant's undo: in its place when the + * book still has a page with its id, else inserted at `index`, kept inside the + * covers when it is an inner spread. The version check is the server's. + * + * Never a second cover: a page still in the book is only replaced by one of + * the same role, and a cover, first, last or back page comes back only into a + * book that has lost its own. Several pages deleted at once go back lowest + * index first, so each index means what it meant before the delete. + */ +export function restoreBookPage(doc: BookDocument, args: unknown): BookEditResult { + const parsed = restoreBookPageRequestSchema.safeParse(args); + if (!parsed.success) return { ok: false, error: invalidOp(parsed.error, null) }; + const { spread, index } = parsed.data; + return caught(() => { + const at = doc.spreads.findIndex((s) => s.id === spread.id); + if (at >= 0) { + const current = doc.spreads[at] as BookSpread; + if (current.role !== spread.role) { + throw failOp( + 'invalid_op', + `${spread.id} is the ${current.role} page now and cannot become the ${spread.role}`, + null, + ); + } + const spreads = doc.spreads.slice(); + spreads[at] = spread; + return { ok: true, doc: { ...doc, spreads }, spreadIds: [spread.id], created: [], dropped: [] }; + } + if (spread.role !== 'inner' && doc.spreads.some((s) => s.role === spread.role)) { + throw failOp('protected_spread', `the book already has its ${spread.role} page`, null); + } + if (doc.spreads.length >= MAX_SPREADS) + throw failOp('too_many_spreads', `a book holds at most ${MAX_SPREADS} pages`, null); + const { first, limit } = innerSpreadBounds(doc.spreads); + const to = spread.role === 'inner' ? Math.min(Math.max(index, first), limit) : Math.min(index, doc.spreads.length); + const spreads = [...doc.spreads.slice(0, to), spread, ...doc.spreads.slice(to)]; + return { ok: true, doc: { ...doc, spreads }, spreadIds: [spread.id], created: [], dropped: [] }; + }); +} + +const isBoundTo = (el: BookElement, source: 'journey.title' | 'journey.subtitle') => + el.kind === 'text' && el.binding?.source === source; + +/** + * The cover: its layout, its photo, its title and its subtitle. + * + * Each is an ordinary page operation on the cover spread, so the rules of + * `edit_book_page` hold (locks, the gallery, bound text). The title goes into + * the text bound to the journey's title, else the cover's heading, else a new + * heading bound to the title; the subtitle likewise with the meta line. A cover + * with no free photo frame gets a full-bleed one, behind everything else. + */ +export function setBookCover(doc: BookDocument, args: unknown, ctx: BookOpsContext = {}): BookEditResult { + const parsed = setBookCoverRequestSchema.safeParse(args); + if (!parsed.success) return { ok: false, error: invalidOp(parsed.error, null) }; + const { template, photoId, title, subtitle } = parsed.data; + const cover = doc.spreads.find((s) => s.role === 'cover'); + if (!cover) return { ok: false, error: { code: 'no_cover', message: 'this book has no cover page', opIndex: null } }; + return caught(() => { + let cur = doc; + const run = (ops: BookPageOpInput[]) => { + const r = applyBookOps(cur, { spreadId: cover.id, ops }, ctx); + // `=== false` rather than `!r.ok`: the client compiles this file without + // strictNullChecks, where a truthiness test does not narrow the union. + if (r.ok === false) throw failOp(r.error.code, r.error.message, null, r.error.elementId); + cur = r.doc; + }; + const page = () => cur.spreads.find((s) => s.id === cover.id) as BookSpread; + if (template !== undefined) run([{ op: 'layout', template }]); + if (photoId !== undefined) { + // An empty frame first, so a picture the cover already holds stays unless it is the only frame. + const frames = page().elements.filter((e): e is BookPhotoElement => e.kind === 'photo'); + const empty = frames.findIndex((e) => !e.locked && e.photoId == null); + const free = empty >= 0 ? empty : frames.findIndex((e) => !e.locked); + if (free >= 0) { + run([{ op: 'place_photo', photoId, target: { slot: free } }]); + } else { + run([{ op: 'place_photo', photoId, target: 'new' }]); + cur = fullBleedAtBack(cur, cover.id); + } + } + if (title !== undefined) run([coverText(page(), 'journey.title', title)]); + if (subtitle !== undefined) run([coverText(page(), 'journey.subtitle', subtitle)]); + return { + ok: true, + doc: cur, + spreadIds: [cover.id], + created: createdIds(doc.spreads, cur.spreads), + dropped: droppedIds([cover], [page()]), + }; + }); +} + +/** A cover with no frame to fill gets a full-bleed one, at the back, so the title stays on top of it. */ +function fullBleedAtBack(doc: BookDocument, coverId: string): BookDocument { + const { pageWidth, pageHeight, bleed } = doc.page; + return { + ...doc, + spreads: doc.spreads.map((s) => { + if (s.id !== coverId) return s; + const added = s.elements[s.elements.length - 1] as BookElement; + const frame = { x: -bleed, y: -bleed, w: pageWidth + bleed * 2, h: pageHeight + bleed * 2 }; + return { ...s, elements: [{ ...added, frame }, ...s.elements.slice(0, -1)] }; + }), + }; +} + +/** + * The cover's title or subtitle: the text bound to it, else the cover's + * heading (for the title) or its other text (for the subtitle), else a new + * text bound to it. + */ +function coverText(cover: BookSpread, source: 'journey.title' | 'journey.subtitle', words: string): BookPageOpInput { + const texts = cover.elements.filter((e) => e.kind === 'text'); + const other = source === 'journey.title' ? 'journey.subtitle' : 'journey.title'; + // The other line's own text is never this one's fallback. + const roles = classifyBookTexts(texts.filter((e) => !isBoundTo(e, other))); + const fallback = source === 'journey.title' ? roles.heading : (roles.meta ?? roles.body); + const target = texts.find((e) => isBoundTo(e, source)) ?? fallback; + if (target) return { op: 'text', elementId: target.id, text: words }; + return { op: 'text', role: source === 'journey.title' ? 'heading' : 'meta', text: words, binding: { source } }; +} diff --git a/shared/src/book/book-pages.ts b/shared/src/book/book-pages.ts new file mode 100644 index 000000000..2222a2658 --- /dev/null +++ b/shared/src/book/book-pages.ts @@ -0,0 +1,133 @@ +import { bookElementId } from './book-ids'; +import { + MAX_BOOK_TITLE, + bookPageSetupSchema, + type BookDocument, + type BookPageSetup, + type BookSpread, +} from './book.schema'; + +/** + * The pages of a book as a whole: which ones are fixed, where an inner spread + * may go, and the book a journey starts from. + * + * Shared because the Studio and the server's book tools must agree on all + * three: a page the editor refuses to move must be one the assistant cannot + * move either. + */ + +/** + * Whether a spread may be moved, deleted or duplicated at all. + * + * The cover and the back cover are fixed points: a book has exactly one of + * each, they are single pages rather than spreads. The single first and last + * pages inside them are fixed the same way (#2317): a bound book opens onto one + * right-hand page and closes on one left-hand page, and moving either into the + * middle of the book would put a half-width page between two spreads. So only + * the inner spreads between them count, and this is the one place that decides + * what counts as inner. + */ +export function canEditSpread(spread: BookSpread | null | undefined): boolean { + return !!spread && spread.role === 'inner'; +} + +/** + * Where an inner spread may go: after the cover and the first page, before + * the last page and the back cover. + * + * Read from the document each time rather than assumed, because a book from + * before the single first page existed has none, and a book with no covers at + * all (the tests build those) has nothing to stay inside of. The lower bound + * is not decoration either: a book with no inner spreads yet reports its last + * inner one as -1, which asked for a page at 0: in front of the cover, where + * it could not be moved back from, because a move only ever swaps with + * another inner spread. + */ +export function innerSpreadBounds(spreads: readonly BookSpread[]): { first: number; limit: number } { + let first = 0; + for (let i = 0; i < spreads.length; i++) { + const role = spreads[i]?.role; + if (role === 'cover' || role === 'first') first = i + 1; + else break; + } + let limit = spreads.length; + for (let i = spreads.length - 1; i >= 0; i--) { + const role = spreads[i]?.role; + if (role === 'back' || role === 'last') limit = i; + else break; + } + return { first, limit: Math.max(first, limit) }; +} + +/** + * A page with nothing on it, of the given kind. + * + * The first and the last page of a book are laid down empty on purpose: the + * first is where a title page or a dedication goes, the last where a colophon + * does, and neither is something a layout should decide for a person. + */ +export function blankSpread(role: BookSpread['role'], newId: (prefix: string) => string = bookElementId): BookSpread { + return { id: newId('sp'), role, background: null, elements: [], parked: [], entryId: null }; +} + +interface BookPageSize { + pageWidth: number; + pageHeight: number; + bleed: number; + safe: number; +} + +/** + * The formats a book is made in, in millimetres: what a print shop binds. + * + * The Studio's format picker (client/src/components/Studio/pagePresets.ts) + * carries the same sizes with its labels; a client test holds the two equal. + * A setup parsed from a preset name alone gets the default square page, which + * is why a new book in another format reads its size from here. + */ +export const BOOK_PAGE_PRESET_SIZES: Record = { + 'square-210': { pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 }, + 'square-300': { pageWidth: 300, pageHeight: 300, bleed: 3, safe: 5 }, + 'a4-landscape': { pageWidth: 297, pageHeight: 210, bleed: 3, safe: 5 }, + 'a4-portrait': { pageWidth: 210, pageHeight: 297, bleed: 3, safe: 5 }, + 'a5-landscape': { pageWidth: 210, pageHeight: 148, bleed: 3, safe: 5 }, + custom: { pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 }, +}; + +/** The page setup of a format, at its size. */ +export function bookPageSetupForPreset(preset: BookPageSetup['preset']): BookPageSetup { + return bookPageSetupSchema.parse({ preset, ...BOOK_PAGE_PRESET_SIZES[preset] }); +} + +/** + * A book nobody has laid out yet: a cover, the first page, one empty spread, + * the last page and the back cover. + * + * What a journey with no book opens on in the Studio, and what the assistant's + * `create_book` writes. The covers are here rather than left out because a book + * has them and there is no other way to add one: the pages rail only inserts + * inner spreads, between the two. The single first and last pages likewise + * (#2317). Empty, though, in the same way the spread between them is. + * + * `startAt` numbers the first page inside the covers. A book from before the + * single first page existed opened straight onto a spread and started at 2; + * with a first page in front of that spread the first page is page 1, so a book + * laid down here starts there, whatever the setup it was handed said. Nothing + * else of the setup is touched. Without a setup, `preset` names the format + * (the assistant's `create_book`); without either it is the default square. + */ +export function blankBook(input: { + title: string; + page?: BookPageSetup; + preset?: BookPageSetup['preset']; + newId?: (prefix: string) => string; +}): BookDocument { + const page = input.page ?? bookPageSetupForPreset(input.preset ?? 'square-210'); + const newId = input.newId ?? bookElementId; + return { + version: 1, + title: input.title.slice(0, MAX_BOOK_TITLE), + page: { ...page, pageNumbers: { ...page.pageNumbers, startAt: 1 } }, + spreads: (['cover', 'first', 'inner', 'last', 'back'] as const).map((role) => blankSpread(role, newId)), + }; +} diff --git a/shared/src/book/book-saved-layouts.ts b/shared/src/book/book-saved-layouts.ts new file mode 100644 index 000000000..678f2a79b --- /dev/null +++ b/shared/src/book/book-saved-layouts.ts @@ -0,0 +1,147 @@ +import { bookElementId } from './book-ids'; +import { + MAX_SPREAD_ELEMENTS, + type BookElement, + type BookLayout, + type BookPageSetup, + type BookSpread, +} from './book.schema'; + +/** + * A book's own layouts (#2316): a page somebody arranged, kept to lay other + * pages out the same way. + * + * Moved from the Studio (client/src/components/Studio/savedLayouts.ts) + * unchanged, so the assistant's book tools can lay a page out on a layout the + * book keeps. + * + * The built-in layouts in book-layouts.ts are frames only. A saved one is the + * whole design, panels and shapes and type included, with the pictures taken + * out; the page it is applied to brings its own. + */ + +/** Which saved layouts fit a spread: a double spread's design does not fit a single page. */ +export const bookLayoutRole = (spread: BookSpread): BookLayout['role'] => + spread.role === 'inner' ? 'inner' : 'single'; + +/** The arrangement of a spread, as a layout to keep. */ +export function bookLayoutFromSpread(spread: BookSpread, page: BookPageSetup, name: string): BookLayout { + return { + id: bookElementId('ly'), + name, + role: bookLayoutRole(spread), + pageWidth: page.pageWidth, + pageHeight: page.pageHeight, + background: spread.background, + // The design, not the pictures: every frame comes out empty. + elements: spread.elements.map((el) => (el.kind === 'photo' ? { ...el, photoId: null } : el)), + }; +} + +/** The next free "Layout n" name, so a save needs no dialog. */ +export function nextBookLayoutName(existing: readonly BookLayout[], base: string): string { + const taken = new Set(existing.map((l) => l.name)); + let n = existing.length + 1; + while (taken.has(`${base} ${n}`)) n += 1; + return `${base} ${n}`; +} + +const textSize = (el: BookElement) => (el.kind === 'text' ? el.size : 0); + +/** + * Lay a spread out on a saved layout, keeping what the spread holds. + * + * The same contract as applying a built-in layout: the spread's pictures go into + * the layout's frames in order, its words into the layout's text boxes (the + * largest into the largest, so the title stays the title), and whatever does not + * fit is parked rather than lost. The layout's own type keeps its look and takes + * the spread's words; a text box that was filled from a journal entry and finds + * nothing to take is left out, because its words belonged to the page it was + * saved from. The small marks and the pros and cons list are content too: each + * takes the values of the spread's own mark of the same kind, in order (the + * spread's day, its coordinates), and keeps the layout's look. Everything else + * is the design and is copied as drawn, scaled from the page it was drawn on to + * this one. + */ +export function applySavedBookLayout(spread: BookSpread, layout: BookLayout, page: BookPageSetup): BookSpread { + const sx = page.pageWidth / layout.pageWidth; + const sy = page.pageHeight / layout.pageHeight; + const pool = [...spread.elements, ...(spread.parked ?? [])]; + const photos = pool.filter((e) => e.kind === 'photo' && e.photoId != null); + const texts = pool.filter((e) => e.kind === 'text' && e.text.trim()).sort((a, b) => textSize(b) - textSize(a)); + + // Which of the layout's text boxes takes which of the spread's words. + const slots = layout.elements.filter((e) => e.kind === 'text').sort((a, b) => textSize(b) - textSize(a)); + const wordsFor = new Map(); + slots.forEach((slot, i) => { + const words = texts[i]; + if (words) wordsFor.set(slot, words); + }); + + // The spread's own marks and lists, queued per kind, for the layout's to take their values from. + const marks = new Map(); + for (const el of spread.elements) { + const key = el.kind === 'badge' ? `badge:${el.variant}` : el.kind === 'list' ? 'list' : null; + if (key) marks.set(key, [...(marks.get(key) ?? []), el]); + } + const takeMark = (key: string) => marks.get(key)?.shift(); + + let pi = 0; + const out: BookElement[] = []; + for (const el of layout.elements) { + const frame = { x: el.frame.x * sx, y: el.frame.y * sy, w: el.frame.w * sx, h: el.frame.h * sy }; + const base = { ...el, id: bookElementId(el.kind[0] ?? 'e'), frame } as BookElement; + if (base.kind === 'photo') { + const src = photos[pi++]; + out.push( + src && src.kind === 'photo' ? { ...base, photoId: src.photoId, focalX: src.focalX, focalY: src.focalY } : base, + ); + continue; + } + if (base.kind === 'text' && el.kind === 'text') { + const src = wordsFor.get(el); + if (src && src.kind === 'text') { + out.push({ ...base, text: src.text, binding: src.binding, overridden: src.overridden }); + } else if (!el.binding) { + out.push(base); + } + continue; + } + if (base.kind === 'badge') { + const own = takeMark(`badge:${base.variant}`); + out.push(own && own.kind === 'badge' ? { ...base, text: own.text, sub: own.sub, code: own.code } : base); + continue; + } + if (base.kind === 'list') { + const own = takeMark('list'); + out.push(own && own.kind === 'list' ? { ...base, items: own.items } : base); + continue; + } + out.push(base); + } + + const used = new Set(wordsFor.values()); + const parked = [...photos.slice(pi), ...texts.filter((t) => !used.has(t))].slice(0, MAX_SPREAD_ELEMENTS); + + return { ...spread, background: layout.background, elements: out, parked }; +} + +/** A saved layout drawn as a spread on this book's page, for its card. Nothing is filled in. */ +export function bookLayoutPreview(layout: BookLayout, page: BookPageSetup): BookSpread { + const sx = page.pageWidth / layout.pageWidth; + const sy = page.pageHeight / layout.pageHeight; + return { + id: layout.id, + role: layout.role === 'inner' ? 'inner' : 'first', + background: layout.background, + elements: layout.elements.map( + (el) => + ({ + ...el, + frame: { x: el.frame.x * sx, y: el.frame.y * sy, w: el.frame.w * sx, h: el.frame.h * sy }, + }) as BookElement, + ), + parked: [], + entryId: null, + }; +} diff --git a/shared/src/book/describe-spread.spec.ts b/shared/src/book/describe-spread.spec.ts new file mode 100644 index 000000000..7d78db8e0 --- /dev/null +++ b/shared/src/book/describe-spread.spec.ts @@ -0,0 +1,296 @@ +import { MIN_PRINT_DPI, photoDpi, spreadFindings } from './book-findings'; +import { applyBookTemplate, findBookTemplate, type BookTemplate } from './book-layouts'; +import type { BookPhotoInfo } from './book-ops-core'; +import { BOOK_OP_LIMITS, bookPageDescriptionSchema, bookOverviewSchema, type BookFinding } from './book-ops.schema'; +import { blankBook } from './book-pages'; +import { + bookPhotoElementSchema, + bookShapeElementSchema, + bookTextElementSchema, + type BookDocument, + type BookElement, + type BookPhotoElement, + type BookSpread, + type BookTextElement, +} from './book.schema'; +import { describeSpread, listBookLayouts, summarizeBook, summarizeSpread } from './describe-spread'; + +import { describe, expect, it } from 'vitest'; + +/** + * The page in words and its findings, on small fixture pages that each carry + * one fault. The page is the default square book: 210 mm pages, a 420 mm + * spread folded at 210, 3 mm bleed, 5 mm safe margin. + */ + +type Frame = { x: number; y: number; w: number; h: number }; + +const photo = (id: string, photoId: number | null, frame: Frame, over: Partial = {}) => + bookPhotoElementSchema.parse({ id, kind: 'photo', frame, photoId, ...over }) as BookPhotoElement; +const text = (id: string, words: string, frame: Frame = { x: 230, y: 120, w: 160, h: 40 }, over = {}) => + bookTextElementSchema.parse({ id, kind: 'text', frame, text: words, size: 10, ...over }) as BookElement; + +const gallery: BookPhotoInfo[] = [ + { photoId: 1, width: 4000, height: 3000, caption: 'Harbour' }, + { photoId: 2, width: 800, height: 600 }, + { photoId: 3 }, + { photoId: 4, width: 3000, height: 3000, hidden: true }, +]; + +/** A clean picture and a short text on the right-hand page. */ +const clean = (): BookElement[] => [photo('p-ok', 1, { x: 230, y: 20, w: 80, h: 60 }), text('t-ok', 'Harbour')]; + +function book(elements: BookElement[], over: Partial = {}): BookDocument { + const doc = blankBook({ title: 'Iceland', newId: (p) => `${p}-x` }); + const ids = ['sp-cover', 'sp-first', 'sp-in', 'sp-last', 'sp-back']; + const spreads = doc.spreads.map((s, i) => ({ ...s, id: ids[i] as string })); + spreads[2] = { ...(spreads[2] as BookSpread), elements, ...over }; + return { ...doc, spreads }; +} + +const findings = (elements: BookElement[], photos: BookPhotoInfo[] | undefined = gallery, over = {}) => { + const doc = book(elements, over); + return spreadFindings(doc.spreads[2] as BookSpread, doc.page, photos); +}; +const codes = (list: BookFinding[]) => list.map((f) => `${f.severity}:${f.code}`); + +describe('findings', () => { + it('BOOKDESC-001: finds nothing on a clean page', () => { + expect(findings(clean())).toEqual([]); + }); + + it('BOOKDESC-002: finds two photos on top of each other, naming both', () => { + const list = findings([ + photo('p-a', 1, { x: 230, y: 20, w: 80, h: 60 }), + photo('p-b', 1, { x: 260, y: 40, w: 80, h: 60 }), + ]); + expect(list.find((f) => f.code === 'overlap')).toMatchObject({ severity: 'severe', elementIds: ['p-a', 'p-b'] }); + }); + + it('BOOKDESC-003: lets a text sit on a photo, which is a design, not a fault', () => { + const list = findings([ + photo('p-a', 1, { x: 230, y: 20, w: 80, h: 60 }), + text('t-a', 'On it', { x: 240, y: 30, w: 50, h: 10 }), + ]); + expect(codes(list)).not.toContain('severe:overlap'); + }); + + it('BOOKDESC-004: finds words in the fold, says a photo across it, and a focal point there', () => { + expect(codes(findings([text('t-f', 'Across', { x: 180, y: 120, w: 80, h: 20 })]))).toContain('severe:fold'); + const split = findings([photo('p-f', 1, { x: 170, y: 20, w: 80, h: 60 })]).find((f) => f.code === 'fold'); + expect(split).toMatchObject({ severity: 'warn', elementIds: ['p-f'] }); + expect(split?.message).toMatch(/focal point in it/); + const aside = findings([photo('p-g', 1, { x: 170, y: 20, w: 80, h: 60 }, { focalX: 0.1 })]); + expect(aside.find((f) => f.code === 'fold')?.message).toMatch(/meant to cross/); + expect(codes(aside)).not.toContain('severe:fold'); + const wide = photo('p-w', 1, { x: -3, y: -3, w: 426, h: 216 }, { focalX: 0.5 }); + expect(codes(findings([wide]))).toContain('warn:fold'); + }); + + it('BOOKDESC-005: finds words past the trim, words in the safe margin and a picture short of the bleed', () => { + expect(codes(findings([text('t-x', 'Cut', { x: -10, y: 100, w: 60, h: 20 })]))).toContain('severe:trim'); + expect(codes(findings([text('t-s', 'Close', { x: 2, y: 100, w: 60, h: 20 })]))).toContain('warn:trim'); + const short = findings([photo('p-s', 1, { x: 229, y: -1, w: 80, h: 60 })]); + expect(short.find((f) => f.code === 'trim')?.message).toMatch(/bleed/); + const sliver = findings([photo('p-v', 1, { x: 230, y: 1, w: 80, h: 60 })]); + expect(sliver.find((f) => f.code === 'trim')?.message).toMatch(/short of the page edge/); + }); + + it('BOOKDESC-006: finds an element off the page altogether', () => { + expect(codes(findings([photo('p-o', 1, { x: 500, y: 20, w: 80, h: 60 })]))).toContain('warn:trim'); + }); + + it('BOOKDESC-007: finds empty frames, in one finding', () => { + const list = findings([ + photo('p-e1', null, { x: 230, y: 20, w: 40, h: 30 }), + photo('p-e2', null, { x: 300, y: 20, w: 40, h: 30 }), + ]); + expect(list.filter((f) => f.code === 'empty_frame')).toEqual([ + expect.objectContaining({ severity: 'severe', elementIds: ['p-e1', 'p-e2'] }), + ]); + }); + + it('BOOKDESC-008: finds a print under 200 dpi only when the original size is known', () => { + const big = photo('p-l', 2, { x: 230, y: 20, w: 160, h: 120 }); + expect(photoDpi(big, gallery[1])).toBeLessThan(MIN_PRINT_DPI); + expect(codes(findings([big]))).toContain('warn:low_dpi'); + const unknown = photo('p-u', 3, { x: 230, y: 20, w: 160, h: 120 }); + expect(photoDpi(unknown, gallery[2])).toBeNull(); + expect(codes(findings([unknown]))).not.toContain('warn:low_dpi'); + }); + + it('BOOKDESC-009: finds a focal point outside an ellipse mask or hidden under another picture', () => { + const masked = photo('p-m', 1, { x: 230, y: 20, w: 80, h: 60 }, { mask: 'ellipse', focalX: 0.95, focalY: 0.95 }); + expect(codes(findings([masked]))).toContain('warn:focal_cut'); + const under = photo('p-under', 1, { x: 230, y: 20, w: 160, h: 120 }); + const over = photo('p-over', 1, { x: 290, y: 60, w: 40, h: 40 }); + const list = findings([under, over]); + expect(list.find((f) => f.code === 'focal_cut')?.message).toMatch(/p-over/); + }); + + it('BOOKDESC-010: finds a text that will likely not fit its frame', () => { + const long = text('t-long', 'A long day on the ice. '.repeat(40), { x: 230, y: 120, w: 60, h: 12 }); + expect(findings([long]).find((f) => f.code === 'text_overflow')).toMatchObject({ severity: 'severe' }); + }); + + it('BOOKDESC-011: notes what is parked and which photos are hidden from the page image', () => { + const parked = [photo('p-park', 1, { x: 0, y: 0, w: 10, h: 10 }), text('t-park', 'Spare')]; + const list = findings([photo('p-h', 4, { x: 230, y: 20, w: 60, h: 60 })], gallery, { parked }); + expect(list.find((f) => f.code === 'parked')).toMatchObject({ severity: 'info', elementIds: ['p-park', 't-park'] }); + expect(codes(list)).toContain('info:hidden_photo'); + }); + + it('BOOKDESC-012: notes a photo the gallery does not hold, only when the gallery is given', () => { + const stray = [photo('p-st', 99, { x: 230, y: 20, w: 80, h: 60 })]; + expect(codes(findings(stray))).toContain('warn:not_in_gallery'); + const doc = book(stray); + expect(codes(spreadFindings(doc.spreads[2] as BookSpread, doc.page))).not.toContain('warn:not_in_gallery'); + }); + + it('BOOKDESC-020: measures a text with no leading of its own, as a layout sets a meta line, at the default', () => { + const meta: Partial = { + ...(text('t-m', 'June 2026', { x: 230, y: 20, w: 160, h: 5 }, { size: 7.5 }) as BookTextElement), + }; + delete meta.leading; + const fits = findings([meta as BookElement]); + expect(codes(fits)).not.toContain('severe:text_overflow'); + expect(fits.map((f) => f.message).join(' ')).not.toMatch(/NaN/); + const long = { ...meta, text: 'A long day on the ice. '.repeat(40) } as BookElement; + expect(findings([long]).find((f) => f.code === 'text_overflow')?.message).toMatch(/needs about \d+(\.\d)? mm/); + }); + + it('BOOKDESC-021: takes the band-quote layout, type set across the spread, as meant rather than a fault', () => { + const doc = book([ + photo('p-1', 1, { x: 230, y: 20, w: 80, h: 60 }), + photo('p-2', 1, { x: 320, y: 20, w: 80, h: 60 }), + text('t-h', 'Reykjavik', undefined, { size: 22 }), + text('t-b', 'The wind never stopped.'), + ]); + const tpl = findBookTemplate('band-quote') as BookTemplate; + const laid = applyBookTemplate(doc.spreads[2] as BookSpread, tpl, doc.page); + const list = codes(spreadFindings(laid, doc.page, gallery)); + expect(list).toContain('warn:fold'); + expect(list).not.toContain('severe:fold'); + }); + + it('BOOKDESC-022: notes a small picture set over a large one as an inset, not an overlap to fix', () => { + const hero = photo('p-hero', 1, { x: 210, y: -3, w: 213, h: 130 }); + const inset = photo('p-in', 1, { x: 224, y: 104, w: 52, h: 40 }); + const list = findings([hero, inset]); + expect(list.find((f) => f.code === 'overlap')).toMatchObject({ severity: 'info', elementIds: ['p-in', 'p-hero'] }); + expect(codes(list)).not.toContain('severe:overlap'); + }); + + it('BOOKDESC-013: puts severe first and stops at the cap', () => { + // 45 texts past the trim, each too small for its words: 90 severe findings before the cap. + const many = Array.from({ length: 45 }, (_, i) => text(`t-${i}`, 'Cut', { x: -10, y: 5 + i * 4, w: 40, h: 3 })); + const list = findings([...many, photo('p-e', null, { x: 230, y: 20, w: 40, h: 30 })], gallery, { + parked: [text('t-p', 'Spare')], + }); + expect(list).toHaveLength(BOOK_OP_LIMITS.findings); + expect(list.every((f) => f.severity === 'severe')).toBe(true); + const order = list.map((f) => ({ severe: 0, warn: 1, info: 2 })[f.severity]); + expect(order).toEqual([...order].sort((a, b) => a - b)); + }); +}); + +describe('describeSpread', () => { + it('BOOKDESC-014: says what the page is, every element back to front with its frame, and the findings', () => { + const doc = book([...clean(), photo('p-e', null, { x: 330, y: 20, w: 40, h: 30 })], { + background: '#fafafa', + entryId: 7, + }); + const d = describeSpread(doc, 'sp-in', gallery); + expect(bookPageDescriptionSchema.safeParse(d).success).toBe(true); + expect(d?.index).toBe(2); + expect(d?.text).toMatch(/Page 3 of 5 \(sp-in\): a double spread, 420 x 210 mm/); + expect(d?.text).toMatch(/fold at x 210/); + expect(d?.text).toMatch(/Background #fafafa\. From entry 7\./); + expect(d?.text).toMatch( + /1\. photo p-ok at 230, 20, 80 x 60: photo 1, landscape 4000 x 3000 px in a landscape frame/, + ); + expect(d?.text).toMatch(/2\. text t-ok .*"Harbour"/); + expect(d?.text).toMatch(/3\. photo p-e .*empty frame/); + expect(d?.text).toMatch(/- severe empty_frame/); + expect(d?.elements.map((e) => e.id)).toEqual(['p-ok', 't-ok', 'p-e']); + }); + + it('BOOKDESC-015: says an empty page is empty and has no findings, and answers null for an unknown page', () => { + const doc = book([]); + expect(describeSpread(doc, 'sp-cover')?.text).toMatch( + /the front cover, a single page, 210 x 210 mm[\s\S]*The page is empty\.[\s\S]*Findings: none\./, + ); + expect(describeSpread(doc, 'sp-nope')).toBeNull(); + }); + + it('BOOKDESC-016: tells a hidden photo apart, and how much of a picture a cover crop shows', () => { + const doc = book([photo('p-h', 4, { x: 230, y: 20, w: 120, h: 60 })]); + const t = describeSpread(doc, 'sp-in', gallery)?.text ?? ''; + expect(t).toMatch(/by another contributor, not shown/); + expect(t).toMatch(/cover shows 50% of the picture/); + }); + + it('BOOKDESC-017: lists the parked elements and the bindings of texts', () => { + const bound = text('t-b', 'Iceland', undefined, { binding: { source: 'journey.title' } }); + const doc = book([bound], { parked: [photo('p-park', 3, { x: 0, y: 0, w: 10, h: 10 })] }); + const d = describeSpread(doc, 'sp-in'); + expect(d?.text).toMatch(/bound to journey\.title/); + expect(d?.text).toMatch(/Parked off the page \(1\): photo 3/); + expect(d?.elements[0]).toMatchObject({ id: 't-b', binding: 'journey.title', text: 'Iceland' }); + }); +}); + +describe('summaries', () => { + it('BOOKDESC-018: sums a page up for get_book, and a book without its document', () => { + const doc = book([...clean(), photo('p-e', null, { x: 330, y: 20, w: 40, h: 30 })]); + expect(summarizeSpread(doc.spreads[2] as BookSpread, 2)).toEqual({ + index: 2, + id: 'sp-in', + role: 'inner', + entryId: null, + background: null, + photoIds: [1], + texts: ['Harbour'], + elements: 3, + emptyFrames: 1, + parked: 0, + }); + const overview = summarizeBook(doc, 4); + expect(bookOverviewSchema.safeParse(overview).success).toBe(true); + expect(overview).toMatchObject({ exists: true, version: 4, title: 'Iceland' }); + expect(summarizeBook(null, null)).toMatchObject({ exists: false, version: null, spreads: [] }); + }); + + it('BOOKDESC-019: lists the layouts a page can use, by kind', () => { + const doc = book([]); + const shape = bookShapeElementSchema.parse({ id: 's', kind: 'shape', frame: { x: 0, y: 0, w: 10, h: 10 } }); + const withSaved: BookDocument = { + ...doc, + layouts: [ + { + id: 'L1', + name: 'Mine', + role: 'single', + pageWidth: 210, + pageHeight: 210, + background: null, + elements: [shape], + }, + ], + }; + const all = listBookLayouts(withSaved, doc.page); + expect(all.inner.length).toBeGreaterThan(0); + expect(all.single.length).toBeGreaterThan(0); + expect(all.grids.length).toBeGreaterThan(0); + expect(all.saved).toEqual([{ id: 'L1', name: 'Mine', role: 'single', photoFrames: 0, texts: 0 }]); + const inner = listBookLayouts(withSaved, doc.page, 'inner'); + expect(inner.single).toEqual([]); + expect(inner.saved).toEqual([]); + }); + + it('BOOKDESC-023: says with the layouts what a grid and a template do to the page', () => { + const { notes } = listBookLayouts(null, book([]).page); + expect(notes.grids).toMatch(/re-pours every photo of the page, placed or parked/); + expect(notes.templates).toMatch(/shapes, maps, badges, stats, lists and icons are dropped/); + }); +}); diff --git a/shared/src/book/describe-spread.ts b/shared/src/book/describe-spread.ts new file mode 100644 index 000000000..b3df0e47c --- /dev/null +++ b/shared/src/book/describe-spread.ts @@ -0,0 +1,265 @@ +import { photoDpi, spreadFindings } from './book-findings'; +import { BOOK_GRIDS } from './book-grids'; +import { BOOK_COVER_TEMPLATES, BOOK_TEMPLATES, type BookTemplate } from './book-layouts'; +import type { BookPhotoInfo } from './book-ops-core'; +import type { + BookElementSummary, + BookFinding, + BookOverview, + BookPageDescription, + BookSpreadSummary, +} from './book-ops.schema'; +import { + bookPageSetupSchema, + type BookDocument, + type BookElement, + type BookPageSetup, + type BookPhotoElement, + type BookSpread, +} from './book.schema'; + +/** + * A page in words, for a model that cannot see it and as the text beside a + * page image for one that can: the page and its role, every element back to + * front with its frame in millimetres, what each photo is and how it sits in + * its frame, what each text says and how it is set, then the findings. + * + * Also the short forms a read tool answers with: one row per page for + * `get_book`, the layouts a book can use for `list_book_layouts`. + */ + +const n = (v: number) => `${Math.round(v * 10) / 10}`; +const head = (text: string, max: number) => { + const flat = text + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .join(' / '); + return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat; +}; + +const ROLE_WORDS: Record = { + cover: 'the front cover, a single page', + first: 'the first page, a single right-hand page', + inner: 'a double spread', + last: 'the last page, a single left-hand page', + back: 'the back cover, a single page', +}; + +function orientation(w: number, h: number): string { + if (Math.abs(w - h) / Math.max(w, h) < 0.05) return 'square'; + return w > h ? 'landscape' : 'portrait'; +} + +function photoWords(el: BookPhotoElement, info: BookPhotoInfo | undefined): string { + if (el.photoId == null) return 'empty frame'; + const parts = [`photo ${el.photoId}`]; + if (info?.hidden) parts.push('by another contributor, not shown'); + if (info?.width && info.height) { + const pic = orientation(info.width, info.height); + const frame = orientation(el.frame.w, el.frame.h); + parts.push(`${pic} ${info.width} x ${info.height} px in a ${frame} frame`); + const ratio = info.width / info.height / (el.frame.w / el.frame.h); + const shown = Math.min(ratio, 1 / ratio); + if (el.fit === 'cover' && shown < 0.9) parts.push(`cover shows ${Math.round(shown * 100)}% of the picture`); + } + parts.push(`${el.fit}, focal ${el.focalX.toFixed(2)}/${el.focalY.toFixed(2)}`); + const dpi = photoDpi(el, info); + if (dpi !== null) parts.push(`about ${dpi} dpi`); + if (el.filter !== 'none') parts.push(`filter ${el.filter}`); + if (el.mask) parts.push(`mask ${el.mask}`); + if (el.frameStyle !== 'none') parts.push(`frame style ${el.frameStyle}`); + if (el.radius > 0) parts.push(`radius ${n(el.radius)}`); + return parts.join(', '); +} + +function elementWords(el: BookElement, photos: readonly BookPhotoInfo[] | undefined): string { + switch (el.kind) { + case 'photo': + return photoWords( + el, + photos?.find((p) => p.photoId === el.photoId), + ); + case 'text': { + const style = `${el.font} ${n(el.size)} pt${el.weight >= 600 ? ' bold' : ''}${el.italic ? ' italic' : ''}, ${el.align}, ${el.color}`; + const bound = el.binding ? `, bound to ${el.binding.source}${el.overridden ? ' (overridden)' : ''}` : ''; + return `${style}: "${head(el.text, 80)}"${bound}`; + } + case 'shape': + return `${el.shape}, fill ${el.fill ?? 'none'}${el.stroke ? `, stroke ${el.stroke}` : ''}`; + case 'map': + return `${el.source} map, ${el.points.length} points, ${el.countries.length} countries`; + case 'stats': + return `figures: ${el.metrics.join(', ')}`; + case 'countries': + return `${el.codes.length} countries`; + case 'badge': + return `${el.variant} mark "${head(el.text, 40)}"`; + case 'list': + return `list of ${el.items.length}`; + case 'icon': + return `icon ${el.name}`; + } +} + +function elementLine(el: BookElement, i: number, photos: readonly BookPhotoInfo[] | undefined): string { + const { x, y, w, h } = el.frame; + const extra = [ + el.rotation ? `turned ${n(el.rotation)} deg` : '', + el.opacity < 1 ? `opacity ${n(el.opacity)}` : '', + el.locked ? 'locked' : '', + ].filter(Boolean); + const tail = extra.length ? `; ${extra.join(', ')}` : ''; + return `${i + 1}. ${el.kind} ${el.id} at ${n(x)}, ${n(y)}, ${n(w)} x ${n(h)}: ${elementWords(el, photos)}${tail}`; +} + +function parkedLine(spread: BookSpread): string | null { + if (!spread.parked.length) return null; + const items = spread.parked + .slice(0, 12) + .map((el) => + el.kind === 'photo' + ? `photo ${el.photoId ?? 'empty'}` + : el.kind === 'text' + ? `text "${head(el.text, 30)}"` + : el.kind, + ); + const more = spread.parked.length > 12 ? `, and ${spread.parked.length - 12} more` : ''; + return `Parked off the page (${spread.parked.length}): ${items.join(', ')}${more}`; +} + +function pageLine(doc: BookDocument, spread: BookSpread, index: number): string { + const p = doc.page; + const single = spread.role !== 'inner'; + const size = single + ? `${n(p.pageWidth)} x ${n(p.pageHeight)} mm` + : `${n(p.pageWidth * 2)} x ${n(p.pageHeight)} mm (two pages of ${n(p.pageWidth)} x ${n(p.pageHeight)}), fold at x ${n(p.pageWidth)}`; + const entry = spread.entryId != null ? ` From entry ${spread.entryId}.` : ''; + return `Page ${index + 1} of ${doc.spreads.length} (${spread.id}): ${ROLE_WORDS[spread.role]}, ${size}; bleed ${n(p.bleed)}, safe ${n(p.safe)}. Background ${spread.background ?? 'paper (none set)'}.${entry}`; +} + +function findingLine(f: BookFinding): string { + return `- ${f.severity} ${f.code}: ${f.message}`; +} + +function elementSummary(el: BookElement): BookElementSummary { + const { x, y, w, h } = el.frame; + return { + id: el.id, + kind: el.kind, + frame: { x, y, w, h }, + rotation: el.rotation, + locked: el.locked, + ...(el.kind === 'photo' ? { photoId: el.photoId } : {}), + ...(el.kind === 'text' ? { text: head(el.text, 80) } : {}), + ...(el.kind === 'text' && el.binding ? { binding: el.binding.source } : {}), + }; +} + +/** + * One page in words, with its findings and its elements. Null when the book + * has no page with that id. + * + * `photos` is the journey gallery as far as it is known: the original's size + * gives the print resolution, `hidden` marks a photo the page image leaves out. + */ +export function describeSpread( + doc: BookDocument, + spreadId: string, + photos?: readonly BookPhotoInfo[], +): BookPageDescription | null { + const index = doc.spreads.findIndex((s) => s.id === spreadId); + const spread = doc.spreads[index]; + if (!spread) return null; + const findings = spreadFindings(spread, doc.page, photos); + const lines = [pageLine(doc, spread, index)]; + if (spread.elements.length) { + lines.push('Elements, back to front (frame in mm: x, y, w x h):'); + spread.elements.forEach((el, i) => lines.push(elementLine(el, i, photos))); + } else { + lines.push('The page is empty.'); + } + const parked = parkedLine(spread); + if (parked) lines.push(parked); + lines.push(findings.length ? 'Findings:' : 'Findings: none.'); + findings.forEach((f) => lines.push(findingLine(f))); + return { + spreadId: spread.id, + index, + role: spread.role, + text: lines.join('\n'), + findings, + elements: spread.elements.map(elementSummary), + }; +} + +/** One page as a row of `get_book`. */ +export function summarizeSpread(spread: BookSpread, index: number): BookSpreadSummary { + const photos = spread.elements.filter((e): e is BookPhotoElement => e.kind === 'photo'); + return { + index, + id: spread.id, + role: spread.role, + entryId: spread.entryId, + background: spread.background, + photoIds: photos.flatMap((p) => (p.photoId == null ? [] : [p.photoId])), + texts: spread.elements.flatMap((e) => (e.kind === 'text' && e.text.trim() ? [head(e.text, 60)] : [])), + elements: spread.elements.length, + emptyFrames: photos.filter((p) => p.photoId == null).length, + parked: spread.parked.length, + }; +} + +/** `get_book`: the book without its document; `doc` null is a journey without a book. */ +export function summarizeBook(doc: BookDocument | null, version: number | null): BookOverview { + const { preset, pageWidth, pageHeight, bleed, safe } = doc?.page ?? bookPageSetupSchema.parse({}); + return { + exists: doc !== null, + version: doc ? version : null, + title: doc?.title ?? '', + page: { preset, pageWidth, pageHeight, bleed, safe }, + spreads: doc ? doc.spreads.map(summarizeSpread) : [], + layouts: (doc?.layouts ?? []).map((l) => ({ id: l.id, name: l.name, role: l.role })), + }; +} + +function templateRow(tpl: BookTemplate, page: BookPageSetup) { + const slots = tpl.build(page); + return { + id: tpl.id, + photoSlots: tpl.photoSlots, + textSlots: slots.flatMap((s) => (s.kind === 'photo' ? [] : [s.kind])), + }; +} + +/** + * What a layout does to what is on the page, said where a model picks one: a + * grid here is not the Studio's grid button (which adds empty frames on top), + * and a template or saved layout leaves out what it has no slot kind for. + */ +const BOOK_LAYOUT_NOTES = { + templates: + "A template or saved layout re-pours the photos and texts of the page into its slots and parks what it has no room for; the page's own shapes, maps, badges, stats, lists and icons are dropped (a saved layout brings its own), and the write answer names them in dropped.", + grids: + 'A grid re-pours every photo of the page, placed or parked, into its cells and parks the rest; the old frames go, empty ones too. Texts, shapes and travel elements stay where they are.', +} as const; + +/** `list_book_layouts`: what a page can be laid out on, for a double spread, a single page, or both. */ +export function listBookLayouts(doc: BookDocument | null, page: BookPageSetup, role?: 'inner' | 'single') { + const want = (r: 'inner' | 'single') => role === undefined || role === r; + return { + notes: BOOK_LAYOUT_NOTES, + inner: want('inner') ? BOOK_TEMPLATES.map((t) => templateRow(t, page)) : [], + single: want('single') ? BOOK_COVER_TEMPLATES.map((t) => templateRow(t, page)) : [], + grids: BOOK_GRIDS.map((g) => ({ id: g.id, cells: g.cells.length })), + saved: (doc?.layouts ?? []) + .filter((l) => want(l.role)) + .map((l) => ({ + id: l.id, + name: l.name, + role: l.role, + photoFrames: l.elements.filter((e) => e.kind === 'photo').length, + texts: l.elements.filter((e) => e.kind === 'text').length, + })), + }; +}