Compare commits

..

2 Commits

Author SHA1 Message Date
Maurice ad721a1ac6 test(studio): the auto layout lays out a long journey whole 2026-10-28 12:26:06 +01:00
Maurice 87441f269d fix(studio): lay out a book as long as its journey, with no page limit
A book held at most 150 spreads. The auto layout cut everything past that
without a word, so a long journey's book ended about three quarters of the
way through, with its last page and back cover gone, and adding, copying or
applying a designed page to a full book was refused.

The spread cap is gone from the book schema, the page operations, the
design composer and its apply, the assistant's book card and the design tool
contracts. A design's own page budget (pages.max) still bounds what it lays
out. The save route's 8 MB body still bounds a book's size, which at two to
five kilobytes an auto laid page is a few thousand pages.

The templated design digests move because their counter ids shift by two;
the pages, their order and their elements are unchanged.
2026-10-28 12:06:50 +01:00
26 changed files with 233 additions and 283 deletions
+3 -3
View File
@@ -13,9 +13,9 @@ import { studioLayerActions, type StudioLayerActions } from './studioLayers'
* The book being edited.
*
* History is snapshot-based rather than patch-based, and deliberately so: a
* document is a few hundred kilobytes of plain JSON at most (the schema caps it
* at 150 spreads of 60 elements), and a snapshot stack that cannot possibly
* disagree with the document beats a patch stack that can. It is capped so a
* snapshot shares every page it did not change with the one before it, so even
* a book of hundreds of pages costs one page per step, and a snapshot stack
* that cannot possibly disagree with the document beats a patch stack that can. It is capped so a
* long session does not grow without limit.
*
* Granularity is the part that matters for how it *feels*: a drag is one undo
@@ -268,9 +268,11 @@ describe('the entry spreads', () => {
})
describe('the document as a whole', () => {
it('stays within the schema cap on spreads', () => {
it('lays out a long journey whole, its last page and back cover after the entries', () => {
const many = Array.from({ length: 200 }, (_, i) => entry({ id: i + 1 }))
expect(build({ entries: many }).spreads.length).toBeLessThanOrEqual(150)
const doc = build({ entries: many })
expect(doc.spreads.length).toBeGreaterThan(150)
expect(doc.spreads.slice(-2).map(sp => sp.role)).toEqual(['last', 'back'])
})
it('gives every spread and every element an id of its own', () => {
+4 -2
View File
@@ -177,13 +177,15 @@ export async function buildApp(): Promise<INestApplication> {
* it is the wrong one for a photo book. A Studio document is sent WHOLE on
* every autosave — deliberately, see book-store.schema.ts, because a patch
* protocol would need an ordering guarantee and a merge rule per field — and
* the contract already caps it at 150 spreads of 90 elements. A real book of
* a fortnight's journey is well past a hundred kilobytes before anybody asks
* a book has as many pages as its journey needs. A real book of a
* fortnight's journey is well past a hundred kilobytes before anybody asks
* for road geometry, and what the ceiling produces is not an error message
* but a save that quietly fails and an editor that says "not saved" without
* saying why.
*
* So the book route is measured against the size a book can actually be.
* An auto laid page is two to five kilobytes, so eight megabytes hold a
* book of a few thousand pages: the page count has no limit of its own.
*
* A list file is the same story at a smaller scale. Its contract allows a
* megabyte and the import posts it whole, yet a list of a few hundred places
+1 -2
View File
@@ -1,7 +1,6 @@
import {
bookSpreadPhotoIds,
compareCodeUnits,
MAX_SPREADS,
type BookDesignPlanRow,
type BookDesignSkipped,
type BookDocument,
@@ -176,7 +175,7 @@ export function applyDesignDraft(
if (at < 0 || !samePage(spreads[at], base.get(row.spreadId))) return skip(row.spreadId, 'changed_since');
spreads[at] = page;
} else {
if (at >= 0 || spreads.length >= MAX_SPREADS) return skip(row.spreadId, 'changed_since');
if (at >= 0) return skip(row.spreadId, 'changed_since');
spreads.splice(insertAt(spreads, draft.plan, i, page), 0, page);
}
applied.push(row.spreadId);
@@ -178,7 +178,7 @@ export class JourneyBookDesignService {
baseVersion: draft.baseVersion,
expiresAt: new Date(draft.expiresAt).toISOString(),
document: draft.doc,
plan: draft.plan.slice(0, BOOK_DESIGN_LIMITS.selection).map(planRowOut),
plan: draft.plan.map(planRowOut),
selection: draft.selection,
};
}
@@ -376,7 +376,7 @@ function answerOf(draft: BookDesignDraft): BookDesignAnswer {
expires_at: new Date(draft.expiresAt).toISOString(),
plan: rows,
more_rows: draft.plan.length - rows.length,
selected: draft.selection.slice(0, BOOK_DESIGN_LIMITS.selection),
selected: draft.selection,
style: draft.style,
photos: {
used: draft.usedPhotos,
@@ -19,7 +19,6 @@ import {
bookPhotoElementSchema,
bookShapeElementSchema,
bookTextElementSchema,
MAX_SPREADS,
type BookDesignPlanRow,
type BookDocument,
type BookSpread,
@@ -310,15 +309,15 @@ describe('applyDesignDraft', () => {
expect(out.doc.spreads).toEqual(base.spreads);
});
it('JBAPPLY-014: a book at the page limit takes no new page, the page named as changed since', () => {
const inner = Array.from({ length: MAX_SPREADS - 1 }, (_, i) => page(`i${i}`, 'inner'));
const full = book([page('c', 'cover'), ...inner]);
const draft: DraftToApply = { base: full, doc: book(pages('n')), plan: [row('n', 'insert')], selection: ['n'] };
it('JBAPPLY-014: a book of hundreds of pages still takes a new page', () => {
const inner = Array.from({ length: 400 }, (_, i) => page(`i${i}`, 'inner'));
const long = book([page('c', 'cover'), ...inner]);
const draft: DraftToApply = { base: long, doc: book(pages('n')), plan: [row('n', 'insert')], selection: ['n'] };
const out = applyDesignDraft(full, draft, new Set());
const out = applyDesignDraft(long, draft, new Set());
expect(out.skipped).toEqual([{ spreadId: 'n', reason: 'changed_since' }]);
expect(out.doc.spreads).toHaveLength(MAX_SPREADS);
expect([out.applied, out.skipped]).toEqual([['n'], []]);
expect(out.doc.spreads).toHaveLength(402);
});
it('JBAPPLY-015: a page whose letters are filled with a photo gone since stays out, as a frame of it would', () => {
+4 -8
View File
@@ -5,7 +5,7 @@ import {
bookPageOpSchema,
bookSpreadIdSchema,
} from '../../book/book-ops.schema';
import { bookSpreadSchema, MAX_BOOK_TITLE, MAX_SPREADS } from '../../book/book.schema';
import { bookSpreadSchema, MAX_BOOK_TITLE } from '../../book/book.schema';
import { idSchema } from '../../common/primitives.schema';
import { cardCountSchema, cardText } from './card-parts';
@@ -33,14 +33,10 @@ export const BOOK_PAGE_CARD_CHANGES = ['new', 'changed', 'moved', 'willDelete']
export const bookCardPageSchema = z.object({
spreadId: bookSpreadIdSchema,
/** Where it stands in the book, the cover at 0. */
index: z
.number()
.int()
.min(0)
.max(MAX_SPREADS - 1),
index: z.number().int().min(0),
role: bookSpreadSchema.shape.role.unwrap(),
/** Its place among the double pages, from 1; null for a single page (the covers, the first and the last). */
number: z.number().int().min(1).max(MAX_SPREADS).nullable(),
number: z.number().int().min(1).nullable(),
/** Photos placed on it. */
photos: cardCountSchema,
});
@@ -85,7 +81,7 @@ export const bookPageCardSchema = z.object({
/** How many pages the change adds or deletes; for a new book, its pages. */
count: cardCountSchema,
/** Where a moved page lands among the double pages, from 1. */
movedTo: z.number().int().min(1).max(MAX_SPREADS).nullable(),
movedTo: z.number().int().min(1).nullable(),
/**
* `edit_book_page`'s operations, to draw the page after where the card has
* no `after` or the Studio's book moved on from `baseVersion`; empty for
+2 -2
View File
@@ -1,6 +1,6 @@
import { bookElementId } from '../book-ids';
import { blankBook, blankSpread } from '../book-pages';
import { MAX_BOOK_TITLE, MAX_SPREADS, type BookDocument, type BookPageSetup, type BookSpread } from '../book.schema';
import { MAX_BOOK_TITLE, type BookDocument, type BookPageSetup, type BookSpread } from '../book.schema';
import type { JourneyStats } from '../journey-stats.schema';
import {
STATIONS_PER_PAGE,
@@ -155,7 +155,7 @@ export function buildBook(input: AutoInput): BookDocument {
version: 1,
title: input.title.slice(0, MAX_BOOK_TITLE),
page: withFirstPage(input.page),
spreads: spreads.slice(0, MAX_SPREADS),
spreads,
};
}
@@ -108,6 +108,21 @@ describe('the auto layout moved from the Studio', () => {
expect(new Set(ids).size).toBe(ids.length);
});
it('AUTOPAR-005: lays out every entry of a long journey, the last page and the back cover after them', () => {
const fx = fixtures.A as Fixture;
const entries = Array.from({ length: 40 }, (_, round) =>
fx.input.entries.map((e) => ({ ...e, id: e.id + 100000 * (round + 1) })),
).flat();
let n = 0;
const doc = buildBook({ ...fx.input, entries, newId: (p) => `${p}-c${++n}` });
expect(doc.spreads.length).toBeGreaterThan(300);
expect(doc.spreads.slice(-2).map((s) => s.role)).toEqual(['last', 'back']);
const laid = new Set(doc.spreads.map((s) => s.entryId));
const withPages = new Set(buildBook({ ...fx.input, newId: (p) => `${p}-d${++n}` }).spreads.map((s) => s.entryId));
const expected = entries.filter((e) => withPages.has(e.id % 100000));
expect(expected.every((e) => laid.has(e.id))).toBe(true);
});
it('AUTOPAR-004: counts the day of an entry in calendar days, a change to summer time between', () => {
const stats: JourneyStats = {
journeyId: 1,
@@ -1,5 +1,4 @@
import {
BOOK_DESIGN_LIMITS,
applyBookDesignRequestSchema,
bookDesignAnswerSchema,
bookDesignApplyResultSchema,
@@ -70,10 +69,9 @@ describe('the design tools', () => {
expect(removeBookLayoutRequestSchema.safeParse({ layoutId: 'ly-1', expectedVersion: 4 }).success).toBe(true);
});
it('BKDTOOL-004: the selection is a closed list of page ids, as long as a book and its drops', () => {
const ids = Array.from({ length: BOOK_DESIGN_LIMITS.selection }, (_, i) => `sp-${i}`);
it('BKDTOOL-004: the selection is a closed list of page ids, as long as the book it picks from', () => {
const ids = Array.from({ length: 1200 }, (_, i) => `sp-${i}`);
expect(bookDesignSelectionRequestSchema.safeParse({ spreadIds: ids }).success).toBe(true);
expect(bookDesignSelectionRequestSchema.safeParse({ spreadIds: [...ids, 'sp-x'] }).success).toBe(false);
expect(bookDesignSelectionRequestSchema.safeParse({ spreadIds: ['sp 1'] }).success).toBe(false);
expect(bookDesignSelectionRequestSchema.safeParse({ spreadIds: [], all: true }).success).toBe(false);
});
+9 -15
View File
@@ -7,7 +7,7 @@ import {
bookWriteResultSchema,
editBookPageArgs,
} from './book-ops.schema';
import { BOOK_FONTS_IDS, MAX_SPREADS, MAX_SPREAD_ELEMENTS, bookDocumentSchema } from './book.schema';
import { BOOK_FONTS_IDS, MAX_SPREAD_ELEMENTS, bookDocumentSchema } from './book.schema';
import { BOOK_DESIGN_STYLES, bookDesignDirectivesSchema } from './design/design-directives.schema';
import { BOOK_DESIGN_ACTIONS } from './design/design-merge';
import { BOOK_DESIGN_BEATS } from './design/design-pacing';
@@ -34,8 +34,6 @@ export const BOOK_DESIGN_LIMITS = {
notes: 20,
/** Pages of the book an apply answers with; every applied id is still named. */
pages: 24,
/** Pages a selection names at most: every page of a book and every page it drops. */
selection: MAX_SPREADS * 2,
/** Revisions a journey keeps, the newest first. */
revisions: 5,
} as const;
@@ -82,11 +80,7 @@ export type RestoreBookLayoutRequest = z.infer<typeof restoreBookLayoutRequestSc
/** One page of a design's plan, as an answer and the draft route carry it. */
export const bookDesignPlanRowSchema = z.object({
spreadId: bookSpreadIdSchema,
index: z
.number()
.int()
.min(0)
.max(MAX_SPREADS * 2),
index: z.number().int().min(0),
action: z.enum(BOOK_DESIGN_ACTIONS),
beat: z.enum(BOOK_DESIGN_BEATS),
chapter: z.string().max(80).nullable(),
@@ -121,7 +115,7 @@ export const bookDesignAnswerSchema = z.object({
/** Plan rows past those listed. */
more_rows: z.number().int().nonnegative(),
/** The pages apply_book_design writes unless the person takes some out. */
selected: z.array(bookSpreadIdSchema).max(BOOK_DESIGN_LIMITS.selection),
selected: z.array(bookSpreadIdSchema),
style: bookDesignStyleSchema,
photos: z.object({
used: z.number().int().nonnegative(),
@@ -159,19 +153,19 @@ export const bookDesignApplyResultSchema = bookWriteResultSchema.extend({
design_id: bookDesignIdSchema,
/** The revision that takes the apply back; null when the journey had no book before it. */
revision_id: idSchema.nullable(),
applied: z.array(bookSpreadIdSchema).max(BOOK_DESIGN_LIMITS.selection),
skipped: z.array(bookDesignSkippedSchema).max(BOOK_DESIGN_LIMITS.selection),
applied: z.array(bookSpreadIdSchema),
skipped: z.array(bookDesignSkippedSchema),
});
export type BookDesignApplyResult = z.infer<typeof bookDesignApplyResultSchema>;
/** PUT /api/journeys/:journeyId/book-designs/:designId/selection: the pages the apply writes. */
export const bookDesignSelectionRequestSchema = z.strictObject({
spreadIds: z.array(bookSpreadIdSchema).max(BOOK_DESIGN_LIMITS.selection),
spreadIds: z.array(bookSpreadIdSchema),
});
export type BookDesignSelectionRequest = z.infer<typeof bookDesignSelectionRequestSchema>;
export const bookDesignSelectionResponseSchema = z.object({
selection: z.array(bookSpreadIdSchema).max(BOOK_DESIGN_LIMITS.selection),
selection: z.array(bookSpreadIdSchema),
});
export type BookDesignSelectionResponse = z.infer<typeof bookDesignSelectionResponseSchema>;
@@ -181,7 +175,7 @@ export const bookDesignDraftResponseSchema = z.object({
baseVersion: version.nullable(),
expiresAt: z.string(),
document: bookDocumentSchema,
plan: z.array(bookDesignPlanRowSchema).max(BOOK_DESIGN_LIMITS.selection),
selection: z.array(bookSpreadIdSchema).max(BOOK_DESIGN_LIMITS.selection),
plan: z.array(bookDesignPlanRowSchema),
selection: z.array(bookSpreadIdSchema),
});
export type BookDesignDraftResponse = z.infer<typeof bookDesignDraftResponseSchema>;
-4
View File
@@ -21,7 +21,6 @@ import { bookElementPhotoIds, checkRestorablePhotos } from './book-photo-refs';
import { bookLayoutFromSpread, nextBookLayoutName } from './book-saved-layouts';
import {
MAX_BOOK_LAYOUTS,
MAX_SPREADS,
bookLayoutSchema,
type BookDocument,
type BookLayout,
@@ -107,9 +106,6 @@ export function duplicateBookPage(doc: BookDocument, args: unknown, ctx: BookOps
if (!canEditSpread(source)) {
throw failOp('protected_spread', `${source.id} is the ${source.role} page and cannot be copied`, null);
}
if (doc.spreads.length + 1 > MAX_SPREADS) {
throw failOp('too_many_spreads', `a book holds at most ${MAX_SPREADS} pages`, null);
}
const fresh = <E extends { id: string; kind: string }>(e: E): E => ({ ...e, id: newId(e.kind.charAt(0)) });
const copy = checkedSpread(
{ ...source, id: newId('sp'), elements: source.elements.map(fresh), parked: source.parked.map(fresh) },
-1
View File
@@ -32,7 +32,6 @@ export const BOOK_OP_ERROR_CODES = [
'layout_not_found',
'layout_role',
'too_many_elements',
'too_many_spreads',
'parked_full',
'empty_frame',
'caption_missing',
+3 -12
View File
@@ -20,7 +20,6 @@ import { bookElementIdSchema, bookGridIdSchema, bookSpreadIdSchema } from './boo
import { bookPaintInSchema } from './book-paint.schema';
import {
MAX_BOOK_TITLE,
MAX_SPREADS,
MAX_SPREAD_ELEMENTS,
bookBadgeElementSchema,
bookCountriesElementSchema,
@@ -273,11 +272,7 @@ export const addBookPagesArgs = {
};
export const moveBookPageArgs = {
spreadId: bookSpreadIdSchema,
toIndex: z
.number()
.int()
.min(0)
.max(MAX_SPREADS - 1),
toIndex: z.number().int().min(0),
expectedVersion,
};
export const deleteBookPagesArgs = {
@@ -302,11 +297,7 @@ export const createBookArgs = {
* it goes back when it is gone, a page still in the book is replaced where it is.
*/
export const bookPreviousSpreadSchema = z.object({
index: z
.number()
.int()
.min(0)
.max(MAX_SPREADS - 1),
index: z.number().int().min(0),
spread: bookSpreadSchema,
});
export type BookPreviousSpread = z.infer<typeof bookPreviousSpreadSchema>;
@@ -625,7 +616,7 @@ export const bookWriteResultSchema = z.object({
dropped: z.array(z.string()).max(MAX_SPREAD_ELEMENTS * 2),
previous_spreads: z.array(bookPreviousSpreadSchema).optional(),
/** The pages the write touched as it left them, for an undo that checks only those (`expectedPages`). */
page_digests: z.array(bookPageDigestSchema).max(MAX_SPREADS).optional(),
page_digests: z.array(bookPageDigestSchema).optional(),
from_index: z.number().int().nonnegative().optional(),
/** From `set_book_setup`: the setup as it was, to set it back. */
previous_setup: bookPageSetupSchema.optional(),
+5 -3
View File
@@ -2,7 +2,6 @@ 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,
@@ -129,10 +128,13 @@ describe('add, move and delete', () => {
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', () => {
it('BOOKPG-007: refuses an unknown page and more than ten pages, and grows a book of any length', () => {
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');
const long = book(400);
const grown = ok(addBookPages(long, { afterSpreadId: 'sp-in400', count: 10 }, { newId }));
expect(grown.spreads).toHaveLength(long.spreads.length + 10);
expect(grown.spreads.slice(-2).map((s) => s.role)).toEqual(['last', 'back']);
});
it('BOOKPG-008: moves an inner spread within the inner range', () => {
-7
View File
@@ -23,7 +23,6 @@ import {
import { blankSpread, canEditSpread, innerSpreadBounds } from './book-pages';
import { bookSpreadPhotoIds, checkRestorablePhotos } from './book-photo-refs';
import {
MAX_SPREADS,
bookSpreadSchema,
type BookDocument,
type BookElement,
@@ -57,9 +56,6 @@ export function addBookPages(doc: BookDocument, args: unknown, ctx: BookOpsConte
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 added = Array.from({ length: count }, () =>
@@ -184,9 +180,6 @@ function putBack(doc: BookDocument, spread: BookSpread, index: number): BookDocu
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);
return { ...doc, spreads: [...doc.spreads.slice(0, to), spread, ...doc.spreads.slice(to)] };
+4 -4
View File
@@ -20,10 +20,10 @@ import { z } from 'zod';
*
* ── Why the whole document ───────────────────────────────────────────────
*
* Not a patch. A book is a few hundred kilobytes of JSON at most — the schema
* caps it at 150 spreads of 60 elements — and a patch protocol would need an
* ordering guarantee, a merge rule per field, and a way to recover when a
* client misses one. Sending the document costs bandwidth that autosave already
* Not a patch. A book is a few kilobytes of JSON a page, a few megabytes for
* the longest journey's, and a patch protocol would need an ordering
* guarantee, a merge rule per field, and a way to recover when a client
* misses one. Sending the document costs bandwidth that autosave already
* amortises and removes an entire class of bug.
*/
+5 -2
View File
@@ -65,7 +65,6 @@ const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'expected #rrggbb');
* `MAX_SPREAD_ELEMENTS` belongs to this set and is declared with the spread it
* bounds, where the salvage pass reads it too.
*/
export const MAX_SPREADS = 150;
export const MAX_BOOK_TITLE = 200;
export const MAX_TEXT_LENGTH = 8000;
/** Codes on a map or a country page, and the names printed beside them. */
@@ -1122,7 +1121,11 @@ const bookDocumentObject = z.object({
schemaRev: z.number().int().min(1).optional().catch(undefined),
title: z.string().max(MAX_BOOK_TITLE).default(''),
page: bookPageSetupSchema.default(() => bookPageSetupSchema.parse({})),
spreads: z.array(bookSpreadSchema).max(MAX_SPREADS).default([]),
/**
* As many as the journey needs: a long trip's book runs to hundreds of pages,
* and the save route's body limit is the only bound on its size.
*/
spreads: z.array(bookSpreadSchema).default([]),
/** The book's own layouts (#2316). Optional so a document from before still reads. */
layouts: z.array(bookLayoutSchema).max(MAX_BOOK_LAYOUTS).optional(),
/** Palette, typefaces and motifs; elements link to it through the fields of theme/book-theme.schema.ts. */
+5 -6
View File
@@ -5,7 +5,6 @@ import type { BookPhotoInfo } from '../book-ops-core';
import { blankBook } from '../book-pages';
import { isStale } from '../book-travel';
import {
MAX_SPREADS,
MAX_SPREAD_ELEMENTS,
bookDocumentSchema,
bookPhotoElementSchema,
@@ -367,10 +366,10 @@ describe('design_book', () => {
expect(spread.notes.some((n) => n.includes('hero-script-word'))).toBe(true);
});
it('BOOKDSN-014: stays within the spread cap beside many kept pages, the closing pages kept', () => {
it('BOOKDSN-014: designs every page beside hundreds of kept pages, the closing pages kept', () => {
const stored = storedBook();
const lockedPage = stored.spreads[2] as BookSpread;
const many = Array.from({ length: MAX_SPREADS - 6 }, (_, i): BookSpread => ({
const many = Array.from({ length: 300 }, (_, i): BookSpread => ({
...lockedPage,
id: `sp-l${i}`,
elements: lockedPage.elements.map((e) => ({ ...e, id: `t-l${i}` })),
@@ -383,10 +382,10 @@ describe('design_book', () => {
for (const style of ['minimal', 'classic'] as const) {
const result = designBook(full, input('rich'), { style });
checkStructure(result);
expect(result.doc.spreads.length).toBeLessThanOrEqual(MAX_SPREADS);
expect(inner(result.doc).length).toBeLessThanOrEqual(MAX_SPREADS - 4);
expect(inner(result.doc).length).toBeGreaterThan(many.length);
expect(many.every((p) => result.doc.spreads.some((s) => s.id === p.id))).toBe(true);
expect(result.notes.length).toBeGreaterThan(0);
expect(result.doc.spreads.slice(-2).map((s) => s.role)).toEqual(['last', 'back']);
expect(result.notes.some((n) => n.includes('left out'))).toBe(false);
}
});
+8 -28
View File
@@ -9,7 +9,7 @@ import type { BookPhotoInfo } from '../book-ops-core';
import type { BookFinding } from '../book-ops.schema';
import { bookPageSetupForPreset } from '../book-pages';
import type { BookTravelContext } from '../book-travel';
import { MAX_BOOK_TITLE, MAX_SPREADS, type BookDocument, type BookPageSetup, type BookSpread } from '../book.schema';
import { MAX_BOOK_TITLE, type BookDocument, type BookPageSetup, type BookSpread } from '../book.schema';
import { pageFindings } from '../describe-spread';
import type { PhotoPickCandidate } from '../photo-pick';
import { isBookFontFamily, mutedOn, readableOn } from '../theme/book-theme';
@@ -25,7 +25,7 @@ import {
import { composeBack, composeCover, composeTitlePage, stationsPerSpread } from './design-framing';
import { composeDesignedInner, type FramingNeeds } from './design-inner';
import { isLivelyBeat } from './design-lively-pacing';
import { keepsStored, mergeDesign, type BookDesignPlanRow, type DesignedSpread } from './design-merge';
import { mergeDesign, type BookDesignPlanRow, type DesignedSpread } from './design-merge';
import { applyPageOverrides } from './design-overrides';
import type { BookDesignBeat } from './design-pacing';
import {
@@ -140,23 +140,6 @@ function classicOnly(d: BookDesignDirectives): boolean {
return d.chapters === 'entry' && d.density === 'balanced';
}
/**
* The designed pages within the spread cap: inner pages past `room` are left
* off the end (the framing pages always stay), and the note says how many.
*/
function withinRoom(designed: readonly DesignedSpread[], room: number, notes: string[]): DesignedSpread[] {
const inner = designed.filter((d) => d.spread.role === 'inner');
const over = inner.length - Math.max(0, room);
if (over <= 0) return [...designed];
const cut = new Set(inner.slice(inner.length - over).map((d) => d.spread.id));
notes.push(`the book holds ${MAX_SPREADS} spreads, so the last ${over} designed pages are left out`);
return designed.filter((d) => !cut.has(d.spread.id));
}
/** Inner spreads the book has room for once the stored pages it keeps are counted. */
const roomFor = (doc: BookDocument | null, keep: BookDesignDirectives['keep']) =>
MAX_SPREADS - 4 - (doc?.spreads ?? []).filter((s) => s.role === 'inner' && keepsStored(s, keep)).length;
/** The travel context the travel elements read: the journey's figures, route, entries and labels. */
function travelOf(input: BookDesignInput, entries: readonly AutoEntry[]): BookTravelContext {
return {
@@ -205,14 +188,13 @@ function classicDesign(
chapter: entries.find((e) => e.id === spread.entryId)?.title ?? null,
findings: pageFindings(spread, page, input.photos),
}));
const designed = withinRoom(all, roomFor(doc, directives.keep), notes);
const merged = mergeDesign(doc, designed, directives.keep);
const merged = mergeDesign(doc, all, directives.keep);
const finalDoc: BookDocument = {
...built,
spreads: merged.spreads,
...(doc?.layouts ? { layouts: doc.layouts } : {}),
};
const residual = designed.flatMap((d) => d.findings.filter((f) => f.severity !== 'info'));
const residual = all.flatMap((d) => d.findings.filter((f) => f.severity !== 'info'));
return {
doc: finalDoc,
plan: merged.plan,
@@ -274,7 +256,7 @@ export function designBook(
notes: [],
art,
};
const needs: FramingNeeds = { room: roomFor(doc, directives.keep), perStations: stationsPerSpread(c) };
const needs: FramingNeeds = { perStations: stationsPerSpread(c) };
const inner = composeDesignedInner(c, placed, choice, input, needs);
const words = {
@@ -547,8 +529,7 @@ function templatedDesign(
raw: BookDesignDirectivesInput,
): BookDesignResult {
const { c, archetype, calm, placed, rest, choice, finish } = templatedSetting(doc, input, raw);
const needs: FramingNeeds = { room: roomFor(doc, c.directives.keep), perStations: stationsPerSpread(c) };
const inner = composeTemplatedInner(c, placed, choice, input, needs, { archetype, calm, rest });
const inner = composeTemplatedInner(c, placed, choice, input, { archetype, calm, rest });
const usedPhotos = [...choice.byEntry.values()].flat();
const designed = templatedPages(c, input, { placed, usedPhotos, inner: inner.spreads, archetype });
const done = designed.map((d): DesignedSpread => ({ ...d, ...finish(d.spread, !!d.template) }));
@@ -569,8 +550,7 @@ interface TemplatedParts {
function templatedResult(doc: BookDocument | null, input: BookDesignInput, parts: TemplatedParts): BookDesignResult {
const { c, finish, placed, notes } = parts;
const { look, directives, page } = c;
const designed = withinRoom(parts.done, roomFor(doc, directives.keep), notes);
const laid = mergeDesign(doc, designed, directives.keep);
const laid = mergeDesign(doc, parts.done, directives.keep);
const built: BookDocument = {
version: 1,
title: (directives.cover?.title ?? input.title).slice(0, MAX_BOOK_TITLE),
@@ -586,7 +566,7 @@ function templatedResult(doc: BookDocument | null, input: BookDesignInput, parts
: { doc: built, plan: laid.plan, notes: [] };
const residual = overrides.length
? redesignedFindings(merged.doc, merged.plan, input.photos)
: designed.flatMap((d) => d.findings.filter((f) => f.severity !== 'info'));
: parts.done.flatMap((d) => d.findings.filter((f) => f.severity !== 'info'));
const judged = briefOf(c);
const brief = judged ? briefFindings(merged.doc, judged, briefScope(merged.plan)) : [];
const used = [...new Set(merged.plan.flatMap((r) => (r.action === 'drop' ? [] : r.photoIds)))];
@@ -1,7 +1,6 @@
import { BOOK_COVER_ARCHETYPES, type BookCoverArchetype } from '../book-archetypes';
import { bookPageOpSchema } from '../book-ops.schema';
import { bookLockupIdSchema } from '../book-text-ops.schema';
import { MAX_SPREADS } from '../book.schema';
import { BOOK_THEME_DECORATIONS, BOOK_THEME_IDS } from '../theme/book-theme.schema';
import { z } from 'zod';
@@ -97,12 +96,7 @@ const overrideTemplateSchema = z
export const bookDesignPageOverrideSchema = z
.strictObject({
index: z
.number()
.int()
.min(2)
.max(MAX_SPREADS - 1)
.describe('The page by its index in the plan design_book answered; the cover is 0.'),
index: z.number().int().min(2).describe('The page by its index in the plan design_book answered; the cover is 0.'),
beat: z
.enum(BOOK_DESIGN_OVERRIDE_BEATS)
.describe(
+3 -6
View File
@@ -41,8 +41,6 @@ import { decorateSpread } from './design-stickers';
*/
export interface FramingNeeds {
/** Inner spreads the book has room for, the kept stored pages taken off. */
room: number;
/** Bare stops one stations spread holds. */
perStations: number;
}
@@ -70,9 +68,8 @@ function framingInner(c: ComposeCtx): FramingKind[] {
return out;
}
/** The most inner spreads the design may make: `pages.max`, and never past the room the book has. */
const innerCap = (c: ComposeCtx, needs: FramingNeeds) =>
Math.max(0, Math.min(c.directives.pages?.max ?? needs.room, needs.room));
/** The most inner spreads the design may make: `pages.max` when the directives set one, otherwise as many as it needs. */
const innerCap = (c: ComposeCtx) => Math.max(0, c.directives.pages?.max ?? Number.POSITIVE_INFINITY);
/**
* The framing pages that fit beside at least one chapter spread: past the cap
@@ -316,7 +313,7 @@ export function composeDesignedInner(
): InnerResult {
const notes: string[] = [];
const leftOut: BookDesignLeftOut[] = [];
const cap = innerCap(c, needs);
const cap = innerCap(c);
const wanted = framingInner(c);
const framing = fittingFraming(wanted, cap);
if (framing.length < wanted.length) {
+1 -1
View File
@@ -46,7 +46,7 @@ const hasLock = (spread: BookSpread) => spread.elements.some((e) => e.locked);
* inner page only while the person keeps locked pages); with `edited` also a
* page made by hand (no entry behind it, not blank).
*/
export function keepsStored(spread: BookSpread, keep: BookDesignDirectives['keep']): boolean {
function keepsStored(spread: BookSpread, keep: BookDesignDirectives['keep']): boolean {
if (spread.role !== 'inner') return hasLock(spread);
if (keep === 'nothing') return false;
if (hasLock(spread)) return true;
@@ -1,228 +1,228 @@
{
"A/fabric-classic/head": "74bb0439e4575ed7",
"A/fabric-classic/plan": "95b55b052b9ff07b",
"A/fabric-classic/spread/0": "a4d090166287cbe5",
"A/fabric-classic/spread/0": "36ce28aa969ee78e",
"A/fabric-classic/spread/1": "aa637b0de2518bf5",
"A/fabric-classic/spread/10": "febf8fa0b4cd3aef",
"A/fabric-classic/spread/11": "26253da4f1f688a0",
"A/fabric-classic/spread/12": "4dbf29b2c63f9231",
"A/fabric-classic/spread/13": "5deb8ece6f27e514",
"A/fabric-classic/spread/14": "db4f2ede14bb6023",
"A/fabric-classic/spread/15": "7d011356ea8fc70f",
"A/fabric-classic/spread/16": "205c5f0f86f90cb4",
"A/fabric-classic/spread/17": "de849a3986a487c1",
"A/fabric-classic/spread/18": "22b9cb0c78e0ff66",
"A/fabric-classic/spread/19": "9df44ff280b76f64",
"A/fabric-classic/spread/10": "f6f5a5bfbb97c674",
"A/fabric-classic/spread/11": "c1e942bd73455a86",
"A/fabric-classic/spread/12": "c390827640a1bc65",
"A/fabric-classic/spread/13": "f49a2ec83099a556",
"A/fabric-classic/spread/14": "f13bb412f61a778c",
"A/fabric-classic/spread/15": "7ec17a9b9e9955f8",
"A/fabric-classic/spread/16": "029964006e41bbdb",
"A/fabric-classic/spread/17": "78ae60620ae287a3",
"A/fabric-classic/spread/18": "c73e813888a28cd1",
"A/fabric-classic/spread/19": "d98cc9a891bf2141",
"A/fabric-classic/spread/2": "8278d3c2507527f9",
"A/fabric-classic/spread/20": "972976260c457cd3",
"A/fabric-classic/spread/21": "9102a5fa147693fe",
"A/fabric-classic/spread/22": "0bc9c54f6ee8b3e9",
"A/fabric-classic/spread/20": "e1dfa3eeb056189b",
"A/fabric-classic/spread/21": "b18be5f5e1696caf",
"A/fabric-classic/spread/22": "98a8cab370aeb8fa",
"A/fabric-classic/spread/23": "b11b5b3fb34a34eb",
"A/fabric-classic/spread/24": "a55189e7bfd3934d",
"A/fabric-classic/spread/3": "2ceb4890ba95c249",
"A/fabric-classic/spread/4": "7743a5784df1d3e5",
"A/fabric-classic/spread/5": "dbbbfb7bedd90da2",
"A/fabric-classic/spread/6": "c36b6c75a4254fad",
"A/fabric-classic/spread/7": "b7694f72f7cbe2bb",
"A/fabric-classic/spread/8": "bdafe534fcbe56f0",
"A/fabric-classic/spread/9": "c8a03b3fbdb60114",
"A/fabric-classic/spread/4": "d0eba1475d8d9f04",
"A/fabric-classic/spread/5": "468dc734d5aae63f",
"A/fabric-classic/spread/6": "776a926951bfdbe5",
"A/fabric-classic/spread/7": "16f3ec7630e52f5d",
"A/fabric-classic/spread/8": "f64487735a6b5be1",
"A/fabric-classic/spread/9": "70dba74ad0223875",
"A/midnight/head": "ce0d82281cbc3e1f",
"A/midnight/plan": "7fde812131c9b570",
"A/midnight/spread/0": "409beb5fbb1accce",
"A/midnight/spread/0": "1ea6071685899f8f",
"A/midnight/spread/1": "d9f5d78659332b45",
"A/midnight/spread/10": "53f13a2ec3a3c5f4",
"A/midnight/spread/11": "888c0c66730d32dc",
"A/midnight/spread/12": "8d6c62bc4ee5ed1d",
"A/midnight/spread/13": "fc0673a56f5b4d4b",
"A/midnight/spread/14": "33db0be88fd469a7",
"A/midnight/spread/15": "72105c668088bc4c",
"A/midnight/spread/16": "bd11f4dc18e03108",
"A/midnight/spread/17": "2e7a0f09822c3556",
"A/midnight/spread/18": "f4264c3ee121b8d1",
"A/midnight/spread/19": "4f27a48a457d95bb",
"A/midnight/spread/10": "b42ae04363ff591c",
"A/midnight/spread/11": "a7271ec809fb43f3",
"A/midnight/spread/12": "e2a46e3248f9f7f2",
"A/midnight/spread/13": "674814c68bdfc64e",
"A/midnight/spread/14": "9131f08ae5ee964d",
"A/midnight/spread/15": "516667160a8ea844",
"A/midnight/spread/16": "7f572ace66e550eb",
"A/midnight/spread/17": "f75b6c80be848ac6",
"A/midnight/spread/18": "029d7d07ce5cb3c3",
"A/midnight/spread/19": "14c04aa51da497cd",
"A/midnight/spread/2": "b00839cd0dc1451b",
"A/midnight/spread/20": "dff6e523732c8481",
"A/midnight/spread/21": "26d666dde7be6641",
"A/midnight/spread/20": "ea8aa8da12cad00a",
"A/midnight/spread/21": "68057d81fd4903b4",
"A/midnight/spread/22": "eba329fd185958b3",
"A/midnight/spread/23": "9664fbd24505a632",
"A/midnight/spread/3": "742dfb4105263101",
"A/midnight/spread/4": "ea9613c918951560",
"A/midnight/spread/5": "8aa571b4d51a5234",
"A/midnight/spread/6": "2275359a3c03f457",
"A/midnight/spread/7": "3ef3c265b38598e2",
"A/midnight/spread/8": "3a77eec4f6c9f860",
"A/midnight/spread/9": "5bdff7027bea103c",
"A/midnight/spread/4": "6d89cdaeac29b8b6",
"A/midnight/spread/5": "25f6a66be27c2415",
"A/midnight/spread/6": "14c828e117eb72a4",
"A/midnight/spread/7": "8fac481cb6c1d93d",
"A/midnight/spread/8": "8730ae9ad717bbe5",
"A/midnight/spread/9": "9792ae63b36eff3e",
"A/riso/head": "7d43d0900fbe0f57",
"A/riso/plan": "f802ad819f1fb262",
"A/riso/spread/0": "1f4502d29eca9017",
"A/riso/spread/0": "50a4cc5fafbfad6e",
"A/riso/spread/1": "8de92788de29277b",
"A/riso/spread/10": "2a92b79192575cd0",
"A/riso/spread/11": "5785a9cc59ddd735",
"A/riso/spread/12": "cd4f6004153fe694",
"A/riso/spread/13": "b1f87a92cb7f775c",
"A/riso/spread/14": "873bf5f7a8404fe4",
"A/riso/spread/15": "c1aab0214328ef3b",
"A/riso/spread/10": "d468165710581556",
"A/riso/spread/11": "1997a6c3ae1c8570",
"A/riso/spread/12": "655767248f707020",
"A/riso/spread/13": "15e7ddcf0d397509",
"A/riso/spread/14": "88cf12b3fa6b6be9",
"A/riso/spread/15": "d2bb88a721cc8320",
"A/riso/spread/16": "d58f70c381d9f6ae",
"A/riso/spread/17": "00caa407cb2a0677",
"A/riso/spread/18": "ed41aca064456614",
"A/riso/spread/19": "36647f06c594e715",
"A/riso/spread/18": "def2a9dde53f3e26",
"A/riso/spread/19": "3cea577870cdc23d",
"A/riso/spread/2": "5c9a93de9e6467d6",
"A/riso/spread/20": "8518ca61c110fd8a",
"A/riso/spread/21": "d4ba78b2ffd5ae04",
"A/riso/spread/22": "de5116307e12ce61",
"A/riso/spread/20": "c851d704ccca068e",
"A/riso/spread/21": "be8c5194461daa01",
"A/riso/spread/22": "dcd4a1dfde54cef9",
"A/riso/spread/23": "a748ada7a028818e",
"A/riso/spread/24": "9abf456403732463",
"A/riso/spread/3": "20a199a47535dea7",
"A/riso/spread/4": "93b84be6e48097b5",
"A/riso/spread/5": "6d5ae8e80c2f0d21",
"A/riso/spread/6": "e7f5c928785be48d",
"A/riso/spread/7": "b672fd04b61ea7b9",
"A/riso/spread/8": "d9e84bbad744e450",
"A/riso/spread/9": "017d695be27dc988",
"A/riso/spread/4": "1b46e4d88f0a0db5",
"A/riso/spread/5": "dd07d5451021f0f5",
"A/riso/spread/6": "ae0f0bbad2f029e3",
"A/riso/spread/7": "5029fc77bc6a7bca",
"A/riso/spread/8": "6eecfd5fd8319fe6",
"A/riso/spread/9": "3ba63e382035e668",
"B/fabric-classic/head": "6af0d11dd09eae78",
"B/fabric-classic/plan": "cae85517611abbca",
"B/fabric-classic/spread/0": "87325aa216fe9c6a",
"B/fabric-classic/spread/0": "9da4b641b0f81264",
"B/fabric-classic/spread/1": "b3f8e9c5043f8302",
"B/fabric-classic/spread/2": "5951c4e8d72b4b3f",
"B/fabric-classic/spread/3": "110c2e11d8079468",
"B/fabric-classic/spread/4": "6f02c19ed62524d1",
"B/fabric-classic/spread/5": "5b0cb3424c4b894c",
"B/fabric-classic/spread/6": "28fec4ed0f8fd482",
"B/fabric-classic/spread/7": "9d68ef6e7d8b5f5a",
"B/fabric-classic/spread/3": "bdb01dc38f94e0d0",
"B/fabric-classic/spread/4": "4e949ebeb4098598",
"B/fabric-classic/spread/5": "fa7289a87111f6a2",
"B/fabric-classic/spread/6": "e1367526818066fb",
"B/fabric-classic/spread/7": "a5c72a73d9305e41",
"B/fabric-classic/spread/8": "b11b5b3fb34a34eb",
"B/fabric-classic/spread/9": "3194c096e5c7d16f",
"B/midnight/head": "cfd309bdf9e41811",
"B/midnight/plan": "b3e4cb03c2d620a4",
"B/midnight/spread/0": "7fdd20dcda48881f",
"B/midnight/spread/0": "ee90988cd45ab2c4",
"B/midnight/spread/1": "d9f5d78659332b45",
"B/midnight/spread/10": "db97ebe8475e0371",
"B/midnight/spread/2": "a094503b82019808",
"B/midnight/spread/3": "6e3e57121d8b8683",
"B/midnight/spread/4": "468100c3bebad30d",
"B/midnight/spread/5": "ca669ba76ca4d9bd",
"B/midnight/spread/6": "60b4cb974e35c55d",
"B/midnight/spread/7": "46fe0841298e4f7b",
"B/midnight/spread/8": "25e4510c5fb81817",
"B/midnight/spread/3": "b9fb3f915cffb57d",
"B/midnight/spread/4": "9f0467b6921de638",
"B/midnight/spread/5": "7e3d7de6ef19180c",
"B/midnight/spread/6": "d42de16c6d174c6b",
"B/midnight/spread/7": "dba5f8de6c557d9e",
"B/midnight/spread/8": "835870aa7e88f095",
"B/midnight/spread/9": "eba329fd185958b3",
"B/riso/head": "69a6c3112de2a047",
"B/riso/plan": "d8906a53b3532db8",
"B/riso/spread/0": "2bbd6e6b4e6d1b4d",
"B/riso/spread/0": "867edf084f05a873",
"B/riso/spread/1": "8de92788de29277b",
"B/riso/spread/2": "40f8d2b093d0fc8d",
"B/riso/spread/3": "1fc8aedc7486cbeb",
"B/riso/spread/4": "311c9fcde888d578",
"B/riso/spread/4": "cedfcf22d3331d4a",
"B/riso/spread/5": "51562d8f08f46011",
"B/riso/spread/6": "c114a720b5c5beff",
"B/riso/spread/6": "68783b660e0ffa6a",
"B/riso/spread/7": "6f4c43c7b5f1dbc3",
"B/riso/spread/8": "a748ada7a028818e",
"B/riso/spread/9": "877657cfa95e3cac",
"C/fabric-classic/head": "ddd21691c506a600",
"C/fabric-classic/plan": "c31dcaa9b3603acc",
"C/fabric-classic/spread/0": "92d21ac9d8e866df",
"C/fabric-classic/spread/0": "2ab15684c1a8df5a",
"C/fabric-classic/spread/1": "7094bacb28cc210a",
"C/fabric-classic/spread/10": "b11b5b3fb34a34eb",
"C/fabric-classic/spread/11": "f7d9814c6b8649fd",
"C/fabric-classic/spread/2": "3e514b7dd87c7d49",
"C/fabric-classic/spread/3": "a964aa1e7f057294",
"C/fabric-classic/spread/4": "4a275bcefa9a8eb0",
"C/fabric-classic/spread/5": "10ed185ab3fd9756",
"C/fabric-classic/spread/6": "7bfbef523f58c3fc",
"C/fabric-classic/spread/7": "71330d722ab54efd",
"C/fabric-classic/spread/2": "d0f1544330a8501f",
"C/fabric-classic/spread/3": "3600be0a98413583",
"C/fabric-classic/spread/4": "d551cdf96c385341",
"C/fabric-classic/spread/5": "177741ca8b5c1758",
"C/fabric-classic/spread/6": "2c42a8276092879e",
"C/fabric-classic/spread/7": "fd5861b762afd36b",
"C/fabric-classic/spread/8": "a7f0ed591793b5e1",
"C/fabric-classic/spread/9": "31665bce59abeafe",
"C/fabric-classic/spread/9": "2a5631a4b55fd15a",
"C/midnight/head": "f1e6077175cb2e20",
"C/midnight/plan": "14fb454d02caf6f9",
"C/midnight/spread/0": "79eae9202a85d430",
"C/midnight/spread/0": "10706dcfb990de4c",
"C/midnight/spread/1": "d9f5d78659332b45",
"C/midnight/spread/10": "6694684925255e94",
"C/midnight/spread/11": "247394453c45c63c",
"C/midnight/spread/12": "abe0197797669d57",
"C/midnight/spread/10": "29566f40839aa633",
"C/midnight/spread/11": "063bb3a68427c348",
"C/midnight/spread/12": "68223f7894a7f842",
"C/midnight/spread/13": "eba329fd185958b3",
"C/midnight/spread/14": "ebbba57a4116faa3",
"C/midnight/spread/2": "7b99390522f39fe5",
"C/midnight/spread/3": "7391268691e50ede",
"C/midnight/spread/4": "4f08a3fc6da821a5",
"C/midnight/spread/5": "6d06a2d469c2d5e2",
"C/midnight/spread/6": "6238c8bc28c5964e",
"C/midnight/spread/7": "8a5cc2233372bb47",
"C/midnight/spread/8": "a445fe1b9090293d",
"C/midnight/spread/9": "e2af9bfdb8789b4b",
"C/midnight/spread/2": "894a97de13c021ed",
"C/midnight/spread/3": "97fb835ff2105cdc",
"C/midnight/spread/4": "87961fc9d8aa3915",
"C/midnight/spread/5": "4c4a134559a3516a",
"C/midnight/spread/6": "3358ec65f7d7240a",
"C/midnight/spread/7": "b7abfc765f918ddb",
"C/midnight/spread/8": "52a02b23cbe1e01a",
"C/midnight/spread/9": "9ccf347142b626ca",
"C/riso/head": "d19997b07778d1dc",
"C/riso/plan": "175dadeb8bb730a6",
"C/riso/spread/0": "8453dd7b94afe68d",
"C/riso/spread/0": "11bb39722782ce9d",
"C/riso/spread/1": "8de92788de29277b",
"C/riso/spread/10": "6b5aa1dac73ab777",
"C/riso/spread/11": "c20262603c86d43e",
"C/riso/spread/12": "8e263c445c0418d2",
"C/riso/spread/10": "df3b209ca6a45afb",
"C/riso/spread/11": "48d8e38b39721c2b",
"C/riso/spread/12": "2326a58882d7c326",
"C/riso/spread/13": "a748ada7a028818e",
"C/riso/spread/14": "81e4b4a0dcc6961a",
"C/riso/spread/2": "5e43386b045d926c",
"C/riso/spread/3": "9587f2fe2fc9cd04",
"C/riso/spread/2": "b25ef24c052d72a3",
"C/riso/spread/3": "1edc86f0be5c9422",
"C/riso/spread/4": "edb0ebe3a843a937",
"C/riso/spread/5": "f8a935188537c224",
"C/riso/spread/6": "d2ca03597f3732c6",
"C/riso/spread/7": "7a4374ec7906855f",
"C/riso/spread/8": "2dd867bf81ca7901",
"C/riso/spread/9": "aea9baf94aa13bc8",
"C/riso/spread/5": "1c86ab49df7921b5",
"C/riso/spread/6": "66fe84b4051bbb45",
"C/riso/spread/7": "05ed926557fdc48d",
"C/riso/spread/8": "1599830b16195a51",
"C/riso/spread/9": "dafb4c953a1c6693",
"D/fabric-classic/head": "54fa8987fea87ce4",
"D/fabric-classic/plan": "a2d0423833838dcc",
"D/fabric-classic/spread/0": "1136daea45c98c7d",
"D/fabric-classic/spread/0": "58ae71edfa473b8c",
"D/fabric-classic/spread/1": "931682bc5827ad8a",
"D/fabric-classic/spread/10": "98449436f064fc7c",
"D/fabric-classic/spread/11": "e58c5ea28b6e5d14",
"D/fabric-classic/spread/12": "a422c04ae2d9e32f",
"D/fabric-classic/spread/13": "b512aff5192eae71",
"D/fabric-classic/spread/14": "b92cc5c3c532f2cb",
"D/fabric-classic/spread/10": "f53e366c56f5026d",
"D/fabric-classic/spread/11": "36816b8e9c3e9745",
"D/fabric-classic/spread/12": "1f512d9c851fd489",
"D/fabric-classic/spread/13": "8e885c24ae9c4796",
"D/fabric-classic/spread/14": "8ed5b5e96e4f5696",
"D/fabric-classic/spread/15": "b11b5b3fb34a34eb",
"D/fabric-classic/spread/16": "c40cdcb18e2ef0bc",
"D/fabric-classic/spread/2": "86dd9a101af346f4",
"D/fabric-classic/spread/3": "afe19cb105dfefa0",
"D/fabric-classic/spread/4": "80d31e5986b670e2",
"D/fabric-classic/spread/5": "664cd81373c14243",
"D/fabric-classic/spread/6": "c1d232a5aed51318",
"D/fabric-classic/spread/7": "8d08e54aa79c8c74",
"D/fabric-classic/spread/8": "18f531e0a85ff3de",
"D/fabric-classic/spread/9": "a7ddf2eb33bb75f6",
"D/fabric-classic/spread/4": "9fcb7ff2c09e7e05",
"D/fabric-classic/spread/5": "512f64afa749875d",
"D/fabric-classic/spread/6": "d909172ca9306881",
"D/fabric-classic/spread/7": "b267abc83d4faf93",
"D/fabric-classic/spread/8": "29c43d7551fdcedb",
"D/fabric-classic/spread/9": "932c06500ebaf54c",
"D/midnight/head": "da510cf5f95bad40",
"D/midnight/plan": "3bd0d52ed4e476c7",
"D/midnight/spread/0": "440b7d69daa18a7a",
"D/midnight/spread/0": "cc12862834fdbf96",
"D/midnight/spread/1": "d9f5d78659332b45",
"D/midnight/spread/10": "824d62135a0fe827",
"D/midnight/spread/11": "03bbcf527fcbce9f",
"D/midnight/spread/12": "d2e1ae86b69d8a5c",
"D/midnight/spread/13": "7025aef32f444ab2",
"D/midnight/spread/14": "581f0008497c1143",
"D/midnight/spread/15": "f86784f4b061a31e",
"D/midnight/spread/16": "ec6c98d1713ff353",
"D/midnight/spread/17": "03888512d15a5895",
"D/midnight/spread/10": "d25808fa155f157e",
"D/midnight/spread/11": "b984e665960f60b6",
"D/midnight/spread/12": "a0985fcab9c614a2",
"D/midnight/spread/13": "3f2670ca15c1797c",
"D/midnight/spread/14": "b49cc00919f7a621",
"D/midnight/spread/15": "67a512730030f450",
"D/midnight/spread/16": "90864ccfce090575",
"D/midnight/spread/17": "69924ede00c784f9",
"D/midnight/spread/18": "eba329fd185958b3",
"D/midnight/spread/19": "6e6e6c877443c5e7",
"D/midnight/spread/2": "cef4235690d44e53",
"D/midnight/spread/3": "cd34cb85119476ee",
"D/midnight/spread/4": "f9782906a14ae092",
"D/midnight/spread/5": "597d729fb1cfb962",
"D/midnight/spread/6": "0023fe5672ad9141",
"D/midnight/spread/7": "f79ae699d0684ce5",
"D/midnight/spread/8": "ea1a0157b2405cff",
"D/midnight/spread/9": "5d38d38d1226fe1e",
"D/midnight/spread/4": "f332e826ccb518b8",
"D/midnight/spread/5": "545524d900a6d68e",
"D/midnight/spread/6": "ea5db0eab1087429",
"D/midnight/spread/7": "da860dcfdcffc997",
"D/midnight/spread/8": "d4a871126c6b324e",
"D/midnight/spread/9": "533ceb3478922532",
"D/riso/head": "1c8f99d99acdea28",
"D/riso/plan": "f2a8704019582093",
"D/riso/spread/0": "7b0d816ffda1dd01",
"D/riso/spread/0": "7cded5c557f2e1ee",
"D/riso/spread/1": "8de92788de29277b",
"D/riso/spread/10": "c85c0634205e3232",
"D/riso/spread/11": "f97635aee763f93e",
"D/riso/spread/12": "37f5d0dc381cfdb4",
"D/riso/spread/13": "7083d486cc5556c5",
"D/riso/spread/14": "711a3787fce72914",
"D/riso/spread/15": "27d0db240d32de9a",
"D/riso/spread/10": "1d403f4e5dc74e1e",
"D/riso/spread/11": "7b563aff540a0a2c",
"D/riso/spread/12": "c6cf876fbc756d21",
"D/riso/spread/13": "735d4882d5230331",
"D/riso/spread/14": "8df18b93ab68235d",
"D/riso/spread/15": "4be16c00138d027d",
"D/riso/spread/16": "a748ada7a028818e",
"D/riso/spread/17": "ed87c0a4c77f241c",
"D/riso/spread/2": "78f24755fb7ad38a",
"D/riso/spread/3": "047741da3e1adc20",
"D/riso/spread/4": "294d0c2a0600ed4a",
"D/riso/spread/5": "b1f6c2832a5f5737",
"D/riso/spread/6": "a898defea41081ec",
"D/riso/spread/7": "027026c49ea68a27",
"D/riso/spread/4": "42d13c4cc22154a9",
"D/riso/spread/5": "c2dcc1ba31041587",
"D/riso/spread/6": "93846db9c48faa0f",
"D/riso/spread/7": "42f55e08792400e9",
"D/riso/spread/8": "4ff93cb4d24f729c",
"D/riso/spread/9": "988a20c009c9d562"
}
@@ -1,12 +1,6 @@
import type { AutoEntry, AutoInput, AutoPhoto } from '../auto/auto-types';
import { normalizeBookDocument } from '../book-normalize';
import {
MAX_SPREADS,
MAX_SPREAD_ELEMENTS,
bookDocumentSchema,
type BookDocument,
type BookSpread,
} from '../book.schema';
import { MAX_SPREAD_ELEMENTS, bookDocumentSchema, type BookDocument, type BookSpread } from '../book.schema';
import { pageFindings } from '../describe-spread';
import { findDecoratedTemplate } from '../templates/template-catalogue';
import { templatePhotoSlots } from '../templates/template-format';
@@ -101,13 +95,12 @@ function single(photos: number, story: boolean): BookDesignInput {
}
describe('the book on the designed templates', () => {
it('AUTODSN-001: is a valid book, as normalised, within the spread and element caps', () => {
it('AUTODSN-001: is a valid book, as normalised, within the element cap', () => {
for (const [name, input] of inputs()) {
for (const theme of THEMES) {
const { doc } = laid(input, theme);
expect(bookDocumentSchema.safeParse(doc).success, `${name} ${theme}`).toBe(true);
expect(normalizeBookDocument(doc), `${name} ${theme}`).toEqual(doc);
expect(doc.spreads.length).toBeLessThanOrEqual(MAX_SPREADS);
for (const s of doc.spreads) expect(s.elements.length).toBeLessThanOrEqual(MAX_SPREAD_ELEMENTS);
}
}
+1 -3
View File
@@ -9,7 +9,6 @@ import type { BookTemplateWord } from '../templates/template-words';
import type { BookDesignInput } from './design-book';
import { cutWords, liveBox, photoEl, textEl, type ComposeCtx } from './design-compose';
import { composeStations } from './design-framing';
import type { FramingNeeds } from './design-inner';
import type { DesignedSpread } from './design-merge';
import type { BookDesignBeat } from './design-pacing';
import { buildChapters, type DesignChapter } from './design-pacing';
@@ -438,12 +437,11 @@ export function composeTemplatedInner(
entries: readonly AutoEntry[],
choice: DesignPhotoChoice,
input: BookDesignInput,
needs: FramingNeeds,
plan: TemplatedPlan,
): TemplatedInnerResult {
const notes: string[] = [];
const leftOut: BookDesignLeftOut[] = [];
const cap = Math.max(0, Math.min(c.directives.pages?.max ?? needs.room, needs.room));
const cap = Math.max(0, c.directives.pages?.max ?? Number.POSITIVE_INFINITY);
const head = framingPages(c, plan.archetype).slice(0, Math.max(0, cap - 1));
const base: PickBase = {
archetype: plan.archetype,