feat(studio): the book engine in shared: layouts, grids, page ops and findings

This commit is contained in:
Maurice
2026-10-22 12:14:22 +02:00
parent acd3b3aafc
commit d2a2728cde
40 changed files with 6450 additions and 961 deletions
+387
View File
@@ -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<BookElement['kind']>(['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<BookTextElement['font'], number> = {
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<string, number>();
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);
}
+180
View File
@@ -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);
});
});
+282
View File
@@ -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,
};
}
+41
View File
@@ -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)}`;
}
File diff suppressed because it is too large Load Diff
+429
View File
@@ -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"
}
+113
View File
@@ -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<string, unknown> }
).books;
const expected = JSON.parse(readFileSync(here('./book-layouts.parity.json'), 'utf8')) as Record<string, string>;
function canonical(value: unknown, known: Set<string>): 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<string, unknown>;
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<string> {
const known = new Set<string>();
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<string, string> {
const out = new Map<string, string>();
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([]);
});
});
+210
View File
@@ -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']);
});
});
+522
View File
@@ -0,0 +1,522 @@
import { bookElementId } from './book-ids';
import type { BookElement, BookPageSetup, BookSpread } from './book.schema';
/**
* Layout templates for a spread.
*
* 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
* empty frame you can drop a picture onto.
*
* Content the new layout has no room for is **parked, not deleted**. Trying a
* text-only layout and then changing your mind must not cost you the pictures:
* they come straight back the moment a layout with frames is applied. Undo would
* technically get them back too, but "I clicked the wrong card" should not need
* a rescue.
*
* That is the difference between a template system that helps and one that
* fights you: nothing here is remembered as a mode. Once applied, the spread is
* an ordinary spread.
*/
export interface BookTemplate {
id: string;
labelKey: string;
/** How many pictures the layout has room for. */
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
* 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';
/** Draw the frames for a page of this size. */
build: (page: BookPageSetup) => BookTemplateSlot[];
}
export type BookTemplateSlot =
| { kind: 'photo'; frame: Frame }
| { kind: 'heading'; frame: Frame }
| { kind: 'body'; 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;
}
/** 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;
return [
// 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 },
},
];
},
},
{
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,
},
},
{ kind: 'heading', frame: { x: M, y: page.pageHeight - M - 22, w: page.pageWidth - M, h: 12 } },
],
},
{
id: 'two-up',
labelKey: 'journey.studio.tpl.twoUp',
photoSlots: 2,
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 })),
},
{
id: 'grid-6',
labelKey: 'journey.studio.tpl.grid6',
photoSlots: 6,
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;
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 },
},
];
},
},
{
id: 'quiet-text',
labelKey: 'journey.studio.tpl.quietText',
photoSlots: 0,
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) => {
// 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;
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 })),
},
{
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;
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) => {
// 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;
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) => {
// 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) => ({
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;
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.
*
* 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,
* and the arrangements that work are the ones that leave a picture room to be
* 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 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,
},
},
{ 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 } },
],
},
{
id: 'cover-band',
labelKey: 'journey.studio.tpl.coverBand',
photoSlots: 1,
role: 'single',
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
// is legible whatever the photograph turns out to be.
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: '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 } },
];
},
},
{
id: 'cover-window',
labelKey: 'journey.studio.tpl.coverWindow',
photoSlots: 1,
role: 'single',
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 },
},
];
},
},
{
id: 'cover-quiet',
labelKey: 'journey.studio.tpl.coverQuiet',
photoSlots: 0,
role: 'single',
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 } },
],
},
{
id: 'cover-half',
labelKey: 'journey.studio.tpl.coverHalf',
photoSlots: 2,
role: 'single',
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: 'heading', frame: { x: M, y: half - 34, w: page.pageWidth - M * 2, h: 26 } },
];
},
},
];
/** 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);
}
/** 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;
return {
id: bookElementId('t'),
kind: 'text',
frame,
rotation: 0,
opacity: 1,
locked: false,
text,
font: base?.font ?? 'sans',
italic: false,
align: base?.align ?? 'left',
tracking: 0,
binding: base?.binding ?? null,
overridden: base?.overridden ?? false,
...style,
} 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 };
}
/**
* Pour the spread's existing content into a different arrangement.
*
* Order is kept, because order is the only thing that survives a layout change
* with its meaning intact: the first photo stays the first photo. Text keeps its
* binding to the journal entry, so a re-laid page still updates when the entry
* is edited.
*/
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 { heading, body, meta } = classifyBookTexts(texts);
let pi = 0;
const out: BookElement[] = [];
const usedText = new Set<BookElement>();
for (const slot of slots) {
if (slot.kind === 'photo') {
const src = photos[pi++];
out.push({
id: bookElementId('p'),
kind: 'photo',
frame: slot.frame,
rotation: 0,
opacity: 1,
locked: false,
photoId: src && src.kind === 'photo' ? src.photoId : null,
fit: src && src.kind === 'photo' ? src.fit : 'cover',
focalX: src && src.kind === 'photo' ? src.focalX : 0.5,
focalY: src && src.kind === 'photo' ? src.focalY : 0.5,
radius: src && src.kind === 'photo' ? src.radius : 0,
filter: src && src.kind === 'photo' ? src.filter : 'none',
// The cut and the decoration travel with the picture. A layout change
// rearranges a page; it is not a reason for a photograph to come out of
// its Polaroid.
mask: src && src.kind === 'photo' ? src.mask : null,
frameStyle: src && src.kind === 'photo' ? src.frameStyle : 'none',
} 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));
}
// 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);
return { ...spread, elements: out, parked };
}
+174
View File
@@ -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;
}
+374
View File
@@ -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<string, unknown>)[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<typeof bookPageOpSchema>;
export type BookPageOpInput = z.input<typeof bookPageOpSchema>;
/*
* ── 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<typeof editBookPageRequestSchema>;
export type AddBookPagesRequest = z.infer<typeof addBookPagesRequestSchema>;
export type MoveBookPageRequest = z.infer<typeof moveBookPageRequestSchema>;
export type DeleteBookPagesRequest = z.infer<typeof deleteBookPagesRequestSchema>;
export type SetBookCoverRequest = z.infer<typeof setBookCoverRequestSchema>;
export type CreateBookRequest = z.infer<typeof createBookRequestSchema>;
export type RestoreBookPageRequest = z.infer<typeof restoreBookPageRequestSchema>;
export type SuggestBookPhotosRequest = z.infer<typeof suggestBookPhotosRequestSchema>;
/*
* ── 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<typeof bookFindingSchema>;
/** 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<typeof bookSpreadSummarySchema>;
/** `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<typeof bookOverviewSchema>;
/** 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<typeof bookElementSummarySchema>;
/** `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<typeof bookPageDescriptionSchema>;
/** 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<typeof bookWriteResultSchema>;
+625
View File
@@ -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<BookSpread> = {}): 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<typeof applyBookOps>): 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);
}
});
});
+668
View File
@@ -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<K extends BookPageOp['op']> = Extract<BookPageOp, { op: K }>;
// ── 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<E extends BookElement>(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<string, { dx: number; dy: number }>();
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<BookTextElement['binding']>;
/**
* 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<BookTextElement> => ({
...(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<string>();
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 };
}
+276
View File
@@ -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');
});
});
+245
View File
@@ -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 } };
}
+133
View File
@@ -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<BookPageSetup['preset'], BookPageSize> = {
'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)),
};
}
+147
View File
@@ -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<BookElement, BookElement>();
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<string, BookElement[]>();
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,
};
}
+296
View File
@@ -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<BookPhotoElement> = {}) =>
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<BookSpread> = {}): 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<BookTextElement> = {
...(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/);
});
});
+265
View File
@@ -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<BookSpread['role'], string> = {
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,
})),
};
}