feat(shared): design_book can lay the book on the designed templates

A new optional directive, layouts: 'templates', lays every entry on the
decorated spreads of the book theme's archetype and its kin
(design-templated.ts). An entry's photos are cut into page-sized parts that
leave no frame empty, at most twelve of them, the first part carrying its
story; each part takes the template that suits it best, poured from a page
of the entry's photos and story so the title, place, date and story stay
bound to the journal. A story longer than its frame is cut at a sentence
and goes on over a page of words. Date marks are set as stamps, never in
the classic styles. Loose gallery photos go to the entry they are linked
to, then to the entry of their day, then to the entry with the fewest
(shareGalleryByDay, beside distributeGallery, which stays as it is); what
no entry has room for closes the book on up to three pages of more
moments, and anything past that is said to be left out, so no photo is
dropped without a word.

The rest is the composer's: scope, the photo picker, the page budget, the
stored pages kept, repair and findings. A book can bring its own theme
object (BookDesignInput.theme), recoloured or not, and it is kept whole.
Plan rows name the template a page was laid on. Without the directive
every design is composed exactly as before, and classic is still buildBook.

AUTODSN-001..015 cover the rules, and AUTODSN-020 pins a digest of every
page on the auto layout's parity journeys and a fourth one in three themes.
design-templated.parity.json was computed with the spec's own canonical form
and digest over these inputs, run through tsx; CI checks it from here on.
This commit is contained in:
Maurice
2026-10-24 19:51:28 +02:00
parent a19fdf6161
commit 35acdb8ba1
11 changed files with 1958 additions and 18 deletions
+2
View File
@@ -16,6 +16,8 @@ export interface AutoPhoto {
width: number | null;
height: number | null;
caption?: string | null;
/** When it was taken (ISO), where the gallery knows: the templated auto layout shares loose photos out by day. */
takenAt?: string | null;
}
export interface AutoEntry {
+177 -2
View File
@@ -1,5 +1,6 @@
import { buildBook, distributeGallery } from '../auto/auto-book';
import type { AutoEntry, AutoInput, AutoPhoto } from '../auto/auto-types';
import type { BookCoverArchetype } from '../book-archetypes';
import { briefFindings, type BookBriefArt } from '../book-findings-art';
import { bookLevelFindings, sortBookFindings } from '../book-findings-design';
import { withRealWeight } from '../book-fonts';
@@ -11,6 +12,8 @@ import type { BookTravelContext } from '../book-travel';
import { MAX_BOOK_TITLE, MAX_SPREADS, 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';
import type { BookTheme } from '../theme/book-theme.schema';
import { designArt, livelyLook, type BookDesignSticker } from './design-art';
import type { ComposeCtx } from './design-compose';
import type { CoverMaterial } from './design-cover-kit';
@@ -25,9 +28,12 @@ import { isLivelyBeat } from './design-lively-pacing';
import { keepsStored, mergeDesign, type BookDesignPlanRow, type DesignedSpread } from './design-merge';
import { applyPageOverrides } from './design-overrides';
import type { BookDesignBeat } from './design-pacing';
import { choosePhotos, type BookDesignLeftOut } from './design-photos';
import { TEMPLATED_ENTRY_PHOTOS, choosePhotos, shareGalleryByDay, type BookDesignLeftOut } from './design-photos';
import { repairSpread } from './design-repair';
import { designGrid, designLook, paperBackground, type BookDesignLook } from './design-style';
import { archetypeOfTheme } from './design-template-pick';
import { composeTemplatedInner } from './design-templated';
import { composeTemplatedCover, composeTravelBack } from './design-templated-framing';
import { themeDesignedSpread, unlinkDrifted } from './design-theme';
/**
@@ -64,6 +70,12 @@ export interface BookDesignInput extends AutoInput {
* for the stickers a decorated design places; without it nothing is stuck on.
*/
stickers?: readonly BookDesignSticker[];
/**
* The book's own theme, recoloured or not, for a book laid on the designed
* templates (`layouts: 'templates'`): it is kept whole in place of the
* style's preset. The composed pages ignore it.
*/
theme?: BookTheme;
}
export interface BookDesignResult {
@@ -117,6 +129,7 @@ function pageFor(doc: BookDocument | null, input: BookDesignInput, directives: B
*/
function classicOnly(d: BookDesignDirectives): boolean {
if (d.style !== 'classic' || d.photos || d.palette || d.type || d.brief) return false;
if (d.layouts === 'templates') return false;
if (d.pages || d.cover || d.text || d.travel) return false;
return d.chapters === 'entry' && d.density === 'balanced';
}
@@ -158,7 +171,7 @@ function travelOf(input: BookDesignInput, entries: readonly AutoEntry[]): BookTr
}
/** The beat a page of the Auto layout plays, read from what it holds. */
function classicBeat(spread: BookSpread): BookDesignBeat {
export function classicBeat(spread: BookSpread): BookDesignBeat {
if (spread.role !== 'inner') return spread.role;
if (spread.entryId != null) return 'opener';
const has = (kind: BookSpread['elements'][number]['kind']) => spread.elements.some((e) => e.kind === kind);
@@ -230,6 +243,7 @@ export function designBook(
const directives = withBriefTheme(bookDesignDirectivesSchema.parse(raw));
const entries = inScope(input.entries, directives.scope);
if (classicOnly(directives)) return classicDesign(doc, input, directives, entries);
if (directives.layouts === 'templates') return templatedDesign(doc, input, directives, entries);
const newId = input.newId ?? bookElementId;
const page = pageFor(doc, input, directives);
@@ -381,3 +395,164 @@ const TITLE_PAGE_STYLES: ReadonlySet<string> = new Set(['editorial', 'journal',
function blank(c: ComposeCtx, role: 'first' | 'last'): BookSpread {
return { id: c.newId('sp'), role, background: paperBackground(c.look), elements: [], parked: [], entryId: null };
}
// ── the book laid on the designed templates ────────────────────────────────
/** The look with the book's own theme kept whole: its palette, its faces, its frames. */
function withOwnTheme(look: BookDesignLook, theme: BookTheme | undefined): BookDesignLook {
if (!theme) return look;
const paper = theme.palette.paper.toLowerCase();
const ink = readableOn(paper, theme.palette.ink);
const face = (f: string, fallback: BookDesignLook['heading']) => (isBookFontFamily(f) ? f : fallback);
return {
...look,
paper,
ink,
muted: mutedOn(paper, ink),
accent: theme.palette.accent,
heading: face(theme.fonts.heading.font, look.heading),
body: face(theme.fonts.body.font, look.body),
frameStyle: theme.frameStyle,
tilt: theme.tilt,
theme,
};
}
/** The directives with each entry's photos held to what its pages on the templates take. */
const cappedPhotos = (d: BookDesignDirectives): BookDesignDirectives => ({
...d,
photos: { pick: 'best', ...d.photos, perChapterMax: d.photos?.perChapterMax ?? TEMPLATED_ENTRY_PHOTOS },
});
/** The cover, the first page, the inner pages, the last page and the back of a templated book, in order. */
function templatedPages(
c: ComposeCtx,
input: BookDesignInput,
parts: { placed: AutoEntry[]; usedPhotos: AutoPhoto[]; inner: DesignedSpread[]; archetype: BookCoverArchetype },
): DesignedSpread[] {
const { directives } = c;
const words = {
title: input.title,
subtitle: input.subtitle,
ownTitle: directives.cover?.title,
ownSubtitle: directives.cover?.subtitle,
};
const cover = coverPhoto(input, directives, parts.usedPhotos);
const material = coverMaterial(cover, parts.usedPhotos, parts.placed);
const front = directives.cover?.template
? { spread: composeCover(c, words, cover, material), template: null }
: composeTemplatedCover(c, words, material, parts.archetype);
const titlePage = TITLE_PAGE_STYLES.has(c.look.theme.id ?? directives.style);
const first = titlePage ? composeTitlePage(c, words, parts.placed) : blank(c, 'first');
const page = (spread: BookSpread, beat: BookDesignBeat): DesignedSpread => ({
spread,
beat,
chapter: null,
findings: [],
});
return [
{ ...page(front.spread, 'cover'), ...(front.template ? { template: front.template } : {}) },
page(first, 'first'),
...parts.inner,
page(blank(c, 'last'), 'last'),
page(composeTravelBack(c, words, parts.placed), 'back'),
];
}
/**
* The book laid on the Studio's designed templates (`layouts: 'templates'`):
* the cover on a cover template of the theme's archetype, every chapter on
* its spreads, the summary, the countries and the back cover in the newer
* travel looks. The rest is the composer's: the scope, the photo picker, the
* page budget, the stored pages kept, the repair and the findings.
*/
function templatedDesign(
doc: BookDocument | null,
input: BookDesignInput,
directives: BookDesignDirectives,
entries: AutoEntry[],
): BookDesignResult {
const newId = input.newId ?? bookElementId;
const page = pageFor(doc, input, directives);
const shared = shareGalleryByDay(byDate(entries), input.gallery ?? [], input.candidates);
const placed = shared.entries;
const choice = choosePhotos(placed, input.candidates, cappedPhotos(directives), input.photos);
const usedPhotos = [...choice.byEntry.values()].flat();
const usedInfos = input.photos.filter((p) => usedPhotos.some((u) => u.photoId === p.photoId));
const plain = withOwnTheme(designLook(directives, page, usedInfos), input.theme);
const art = designArt(directives, plain.theme, input.stickers);
const look = livelyLook(plain, art, directives);
const c: ComposeCtx = {
look,
page,
grid: designGrid(page),
newId,
locale: input.locale,
infos: new Map(input.photos.map((p) => [p.photoId, p])),
travel: travelOf(input, placed),
directives,
notes: [],
art,
};
const archetype = directives.cover?.archetype ?? archetypeOfTheme(look.theme);
// A rich theme designed without a brief is lively for its templates, so a poster book gets its loud pages at all.
const calm = art.energy === 'calm' && !(look.theme.decoration === 'rich' && !art.briefed);
const needs: FramingNeeds = { room: roomFor(doc, directives.keep), perStations: stationsPerSpread(c) };
const inner = composeTemplatedInner(c, placed, choice, input, needs, { archetype, calm, rest: shared.rest });
const designed = templatedPages(c, input, { placed, usedPhotos, inner: inner.spreads, archetype });
const repairCtx = { page, photos: input.photos, look, newId };
// A templated page has its colours linked to the theme by its template, so it is repaired, not relinked.
const finish = (spread: BookSpread, templated = false) => {
const fixed = repairSpread(templated ? spread : themeDesignedSpread(spread, look), repairCtx);
const weighted = { ...fixed.spread, elements: fixed.spread.elements.map(withRealWeight) };
return { spread: templated ? weighted : unlinkDrifted(weighted, look.theme), findings: fixed.findings };
};
const done = designed.map((d): DesignedSpread => ({ ...d, ...finish(d.spread, !!d.template) }));
const leftOut = [...choice.leftOut, ...inner.leftOut];
return templatedResult(doc, input, { c, done, finish, placed, leftOut, notes: inner.notes });
}
interface TemplatedParts {
c: ComposeCtx;
done: DesignedSpread[];
finish: (spread: BookSpread) => { spread: BookSpread; findings: BookFinding[] };
placed: AutoEntry[];
leftOut: BookDesignLeftOut[];
notes: string[];
}
/** The templated pages over the stored book, the overrides laid on them, and what is left to say. */
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 built: BookDocument = {
version: 1,
title: (directives.cover?.title ?? input.title).slice(0, MAX_BOOK_TITLE),
page: { ...page, pageNumbers: { ...page.pageNumbers, font: look.body, startAt: 1 } },
spreads: laid.spreads,
...(doc?.layouts ? { layouts: doc.layouts } : {}),
theme: look.theme,
};
const overrides = directives.pages?.overrides ?? [];
const env = { c, entries: placed, finish, ops: { photos: input.photos, newId: c.newId } };
const merged = overrides.length
? applyPageOverrides(built, laid.plan, overrides, env)
: { 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'));
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)))];
return {
doc: merged.doc,
plan: merged.plan,
style: { paper: look.paper, ink: look.ink, accent: look.accent, heading: look.heading, body: look.body },
photos: { used, left_out: parts.leftOut },
findings: sortBookFindings([...residual, ...bookLevelFindings(merged.doc), ...brief]),
notes: [...new Set([...notes, ...c.notes, ...merged.notes])],
...(judged ? { brief: judged } : {}),
};
}
@@ -158,6 +158,17 @@ export const bookDesignDirectivesSchema = z.strictObject({
/** Only honoured for a blank or a new book. */
preset: bookPagePresetSchema.optional(),
seed: z.number().int().min(0).max(2147483647).optional(),
/**
* What the pages are laid on: the engine's own composed pages (the default),
* or the Studio's designed templates in the archetype of the book's theme,
* with the summary, countries and marks in the newer travel looks.
*/
layouts: z
.enum(['composed', 'templates'])
.describe(
"composed: the engine's own pages (default). templates: every page laid on the Studio's designed templates in the archetype of the book's theme.",
)
.optional(),
});
export type BookDesignDirectives = z.infer<typeof bookDesignDirectivesSchema>;
export type BookDesignDirectivesInput = z.input<typeof bookDesignDirectivesSchema>;
+7
View File
@@ -23,6 +23,8 @@ export interface BookDesignPlanRow {
entryId: number | null;
photoIds: number[];
findings: { severe: number; warn: number };
/** The decorated template the page was laid on, when the design laid it on one. */
template?: string;
}
export interface DesignedSpread {
@@ -30,6 +32,8 @@ export interface DesignedSpread {
beat: BookDesignBeat;
chapter: string | null;
findings: BookFinding[];
/** The decorated template the page was laid on (`layouts: 'templates'`). */
template?: string;
}
const photoIdsOf = (spread: BookSpread) =>
@@ -55,6 +59,7 @@ interface Placed {
beat: BookDesignBeat;
chapter: string | null;
findings: BookFinding[];
template?: string;
}
const placedOf = (d: DesignedSpread, action: BookDesignAction, id?: string): Placed => ({
@@ -63,6 +68,7 @@ const placedOf = (d: DesignedSpread, action: BookDesignAction, id?: string): Pla
beat: d.beat,
chapter: d.chapter,
findings: d.findings,
...(d.template ? { template: d.template } : {}),
});
const keptOf = (spread: BookSpread, beat: BookDesignBeat): Placed => ({
@@ -133,6 +139,7 @@ export function mergeDesign(
entryId: p.spread.entryId,
photoIds: photoIdsOf(p.spread),
findings: { severe: count(p.findings, 'severe'), warn: count(p.findings, 'warn') },
...(p.template ? { template: p.template } : {}),
}));
for (const s of dropped) {
plan.push({
+60
View File
@@ -190,3 +190,63 @@ export function burstOf(photos: readonly AutoPhoto[], candidates: readonly Photo
});
return best;
}
/** The most photos one entry's pages take on the designed templates; the rest are left out as `chapter_max`. */
export const TEMPLATED_ENTRY_PHOTOS = 12;
/** The day a photo was taken on, from the photo or from the picker's facts of it. */
function dayOf(photo: AutoPhoto, candidates: readonly PhotoPickCandidate[]): string | null {
const at = photo.takenAt ?? candidates.find((c) => c.photoId === photo.photoId)?.takenAt ?? null;
return at ? at.slice(0, 10) : null;
}
/** Days between two ISO dates, for the nearest entry to a photo; far for an undated one. */
function daysApart(a: string | null, b: string | null): number {
if (!a || !b) return 1e6;
return Math.abs(Date.parse(a) - Date.parse(b)) / 86_400_000;
}
/**
* The journey's loose photos shared out to its entries, for a book laid on
* the designed templates: each first to the entry it is linked to, then to
* the entry of the day it was taken, then, in the order they were taken, to
* the entry that has the fewest photos (the nearest in time of those), never
* past `cap` photos an entry. What no entry has room for comes back as
* `rest`, for the book's closing pages of more moments. Unlike
* `distributeGallery` no photo is dropped on the way.
*/
export function shareGalleryByDay(
entries: readonly AutoEntry[],
gallery: readonly AutoPhoto[],
candidates: readonly PhotoPickCandidate[],
cap = TEMPLATED_ENTRY_PHOTOS,
): { entries: AutoEntry[]; rest: AutoPhoto[] } {
const out = entries.map((e) => ({ ...e, photos: [...e.photos] }));
const placed = new Set(out.flatMap((e) => e.photos.map((p) => p.photoId)));
const loose = gallery.filter((p) => !placed.has(p.photoId));
const room = (e: AutoEntry) => e.photos.length < cap;
const later: AutoPhoto[] = [];
for (const photo of loose) {
const linked = candidates.find((c) => c.photoId === photo.photoId)?.entryIds ?? [];
const day = dayOf(photo, candidates);
const home =
out.find((e) => linked.includes(e.id) && room(e)) ?? out.find((e) => day !== null && e.date === day && room(e));
if (home) home.photos.push(photo);
else later.push(photo);
}
const dated = [...later].sort((a, b) =>
(dayOf(a, candidates) ?? '9999').localeCompare(dayOf(b, candidates) ?? '9999'),
);
const rest: AutoPhoto[] = [];
for (const photo of dated) {
const day = dayOf(photo, candidates);
const open = out.filter(room);
const fewest = Math.min(...open.map((e) => e.photos.length));
const home = open
.filter((e) => e.photos.length === fewest)
.sort((a, b) => daysApart(a.date, day) - daysApart(b.date, day))[0];
if (home) home.photos.push(photo);
else rest.push(photo);
}
return { entries: out, rest };
}
@@ -179,7 +179,7 @@ describe('the template picker', () => {
expect(chunkPhotos(12, false, sizes).sizes).toEqual([6, 6]);
expect(chunkPhotos(5, true, sizes).sizes).toEqual([2, 3]);
// A story too long for a small page's frame goes on a page of words of its own.
expect(chunkPhotos(2, true, { ...sizes, storyCost: (s) => (s > 0 ? 30 : 0) }).sizes).toEqual([0, 2]);
expect(chunkPhotos(2, true, { ...sizes, pageCost: (s, first) => (first && s > 0 ? 30 : 0) }).sizes).toEqual([0, 2]);
expect(chunkPhotos(3, false, { any: new Set([2]), story: new Set() })).toEqual({ sizes: [2], parked: 1 });
});
+21 -15
View File
@@ -160,8 +160,11 @@ export interface PhotoChunking {
export interface ChunkSizes {
any: ReadonlySet<number>;
story: ReadonlySet<number>;
/** What setting the words on a first page of this many photos costs beyond a page: its story cut short. */
storyCost?: (size: number) => number;
/**
* What a page of this many photos costs beyond being a page: its story cut
* short when it is the first, a template from outside the book's own pool.
*/
pageCost?: (size: number, first: boolean) => number;
}
/** A spread costs this much, and every photo left without a frame more. */
@@ -193,7 +196,7 @@ export function chunkPhotos(n: number, hasStory: boolean, sizes: ChunkSizes): Ph
const options = taken.length ? rest : firsts;
for (const s of options) {
if (s > left || (taken.length && s === 0)) continue;
const extra = !taken.length && hasStory ? (sizes.storyCost?.(s) ?? 0) : 0;
const extra = sizes.pageCost?.(s, !taken.length) ?? 0;
walk([...taken, s], left - s, cost + SPREAD_COST + extra);
}
};
@@ -282,6 +285,11 @@ function storyScore(tpl: BookDecoratedTemplate, chunk: TemplateChunk, ctx: Templ
return -Math.min(40, (20 * (chunk.story.length - room)) / Math.max(room, 1));
}
/** What a photo left without a frame costs a template. */
const UNUSED_PHOTO = 60;
/** What a template of an archetype neither the book's nor its kin costs: taken only where those leave a photo out. */
const FOREIGN = 25;
/** How well a template suits the page, the higher the better. */
function scoreOf(
tpl: BookDecoratedTemplate,
@@ -289,9 +297,11 @@ function scoreOf(
ctx: TemplatePickCtx,
before: TemplateEnergy | undefined,
) {
let score = 100 - 12 * (chunk.photos.length - slots(tpl));
// A photo the page has no frame for waits in the tray: worse than a template of a kin archetype.
let score = 100 - UNUSED_PHOTO * (chunk.photos.length - slots(tpl));
if (tpl.archetype === ctx.archetype) score += 20;
else if (ARCHETYPE_KIN[ctx.archetype].includes(tpl.archetype)) score += 8;
else score -= FOREIGN;
const first = chunk.photos[0];
const shape = firstFrameShape(tpl, ctx.page);
if (first && ((shape === 'wide' && isLandscape(first)) || (shape === 'tall' && isPortrait(first)))) score += 6;
@@ -322,21 +332,17 @@ function energyOfId(pool: readonly BookDecoratedTemplate[], id: string | undefin
}
/**
* The template a page is laid on, of the archetype's and its kin's; any
* archetype's when none of those can take it; undefined when none at all can
* (a page of words with no words template anywhere).
* The template a page is laid on: the archetype's own and its kin's first,
* another archetype's only where theirs would leave a photo without a frame;
* undefined when none at all can take the page.
*/
export function pickInnerTemplate(chunk: TemplateChunk, ctx: TemplatePickCtx): BookDecoratedTemplate | undefined {
const all = decoratedTemplatesFor('inner');
const before = energyOfId(all, ctx.history[ctx.history.length - 1]);
for (const pool of [innerPool(ctx.archetype), all]) {
const scored = pool
.filter((t) => admits(t, chunk, ctx, before))
.map((tpl) => ({ tpl, score: scoreOf(tpl, chunk, ctx, before) }));
const pick = bestOf(scored, ctx.seed, ctx.key);
if (pick) return pick;
}
return undefined;
const scored = all
.filter((t) => admits(t, chunk, ctx, before))
.map((tpl) => ({ tpl, score: scoreOf(tpl, chunk, ctx, before) }));
return bestOf(scored, ctx.seed, ctx.key);
}
/** What a cover may be laid with: how many photos there are, and whether the cover photo stands tall. */
@@ -0,0 +1,717 @@
{
"D": {
"input": {
"locale": "en-GB",
"title": "North by the coast",
"subtitle": null,
"coverPhotoId": null,
"entries": [
{
"id": 101,
"title": "Stop 1",
"story": "We walked out along the harbour wall before the light came up, and the town was still asleep behind us.",
"location": "Bergen",
"date": "2026-06-01",
"photos": [],
"lat": 60,
"lng": 5,
"country": "NO",
"dayNumber": 1,
"dayCount": 8
},
{
"id": 102,
"title": "Stop 2",
"story": null,
"location": "Flam",
"date": "2026-06-02",
"photos": [
{
"photoId": 500,
"width": 4000,
"height": 3000,
"caption": null
}
],
"lat": 61,
"lng": 6,
"country": "NO",
"dayNumber": 2,
"dayCount": 8
},
{
"id": 103,
"title": "Stop 3",
"story": "We walked out along the harbour wall before the light came up, and the town was still asleep behind us. We walked out along the harbour wall before the light came up, and the town was still asleep behind us. We walked out along the harbour wall before the light came up, and the town was still asleep behind us.",
"location": "Alesund",
"date": "2026-06-03",
"photos": [
{
"photoId": 501,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 502,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 503,
"width": 3000,
"height": 4000,
"caption": null
}
],
"lat": 62,
"lng": 7,
"country": "NO",
"dayNumber": 3,
"dayCount": 8
},
{
"id": 104,
"title": "Stop 4",
"story": null,
"location": "Geiranger",
"date": "2026-06-04",
"photos": [
{
"photoId": 504,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 505,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 506,
"width": 3000,
"height": 4000,
"caption": null
},
{
"photoId": 507,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 508,
"width": 4000,
"height": 3000,
"caption": null
}
],
"lat": 63,
"lng": 8,
"country": "NO",
"dayNumber": 4,
"dayCount": 8
},
{
"id": 105,
"title": "Stop 5",
"story": "We walked out along the harbour wall before the light came up, and the town was still asleep behind us.",
"location": "Trondheim",
"date": "2026-06-05",
"photos": [
{
"photoId": 509,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 510,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 511,
"width": 3000,
"height": 4000,
"caption": null
},
{
"photoId": 512,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 513,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 514,
"width": 3000,
"height": 4000,
"caption": null
},
{
"photoId": 515,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 516,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 517,
"width": 3000,
"height": 4000,
"caption": null
},
{
"photoId": 518,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 519,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 520,
"width": 3000,
"height": 4000,
"caption": null
}
],
"lat": 64,
"lng": 9,
"country": "NO",
"dayNumber": 5,
"dayCount": 8
},
{
"id": 106,
"title": "Stop 6",
"story": null,
"location": "Bodo",
"date": "2026-06-06",
"photos": [
{
"photoId": 521,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 522,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 523,
"width": 3000,
"height": 4000,
"caption": null
},
{
"photoId": 524,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 525,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 526,
"width": 3000,
"height": 4000,
"caption": null
},
{
"photoId": 527,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 528,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 529,
"width": 3000,
"height": 4000,
"caption": null
},
{
"photoId": 530,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 531,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 532,
"width": 3000,
"height": 4000,
"caption": null
},
{
"photoId": 533,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 534,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 535,
"width": 3000,
"height": 4000,
"caption": null
},
{
"photoId": 536,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 537,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 538,
"width": 3000,
"height": 4000,
"caption": null
},
{
"photoId": 539,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 540,
"width": 4000,
"height": 3000,
"caption": null
}
],
"lat": 65,
"lng": 10,
"country": "NO",
"dayNumber": 6,
"dayCount": 8
},
{
"id": 107,
"title": "Stop 7",
"story": null,
"location": "Tromso",
"date": "2026-06-07",
"photos": [],
"lat": 66,
"lng": 11,
"country": "NO",
"dayNumber": 7,
"dayCount": 8
},
{
"id": 108,
"title": "Stop 8",
"story": "We walked out along the harbour wall before the light came up, and the town was still asleep behind us. We walked out along the harbour wall before the light came up, and the town was still asleep behind us. We walked out along the harbour wall before the light came up, and the town was still asleep behind us. We walked out along the harbour wall before the light came up, and the town was still asleep behind us.",
"location": "Alta",
"date": "2026-06-08",
"photos": [
{
"photoId": 541,
"width": 4000,
"height": 3000,
"caption": null
},
{
"photoId": 542,
"width": 4000,
"height": 3000,
"caption": null
}
],
"lat": 67,
"lng": 12,
"country": "NO",
"dayNumber": 8,
"dayCount": 8
}
],
"page": {
"preset": "square-210",
"pageWidth": 210,
"pageHeight": 210,
"bleed": 3,
"safe": 5,
"pageNumbers": {
"show": false,
"startAt": 2,
"position": "outer",
"margin": 12,
"size": 8,
"autoColor": true,
"color": "#8a8578",
"font": "sans"
}
},
"stats": {
"journeyId": 9,
"distance": 2100000,
"days": 8,
"steps": 20,
"photos": 80,
"places": 8,
"furthest": 1500000,
"countries": [
{
"code": "NO",
"name": "Norway",
"places": 8,
"firstVisit": "2026-06-01"
}
],
"points": [
{
"lat": 60,
"lng": 5,
"label": "Bergen",
"date": "2026-06-01",
"country": "NO",
"tripId": null,
"photoId": null,
"entryId": 101
},
{
"lat": 61,
"lng": 6,
"label": "Flam",
"date": "2026-06-02",
"country": "NO",
"tripId": null,
"photoId": null,
"entryId": 102
},
{
"lat": 62,
"lng": 7,
"label": "Alesund",
"date": "2026-06-03",
"country": "NO",
"tripId": null,
"photoId": null,
"entryId": 103
},
{
"lat": 63,
"lng": 8,
"label": "Geiranger",
"date": "2026-06-04",
"country": "NO",
"tripId": null,
"photoId": null,
"entryId": 104
},
{
"lat": 64,
"lng": 9,
"label": "Trondheim",
"date": "2026-06-05",
"country": "NO",
"tripId": null,
"photoId": null,
"entryId": 105
},
{
"lat": 65,
"lng": 10,
"label": "Bodo",
"date": "2026-06-06",
"country": "NO",
"tripId": null,
"photoId": null,
"entryId": 106
},
{
"lat": 66,
"lng": 11,
"label": "Tromso",
"date": "2026-06-07",
"country": "NO",
"tripId": null,
"photoId": null,
"entryId": 107
},
{
"lat": 67,
"lng": 12,
"label": "Alta",
"date": "2026-06-08",
"country": "NO",
"tripId": null,
"photoId": null,
"entryId": 108
}
],
"trips": [],
"excluded": [],
"start": "2026-06-01",
"end": "2026-06-08"
},
"path": [],
"stationsLabel": "Stations",
"dayLabel": "DAY {day}",
"summaryLabel": "Trip summary",
"countriesLabel": "Countries"
},
"gallery": [
{
"photoId": 900,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-01T10:00:00Z"
},
{
"photoId": 901,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-02T10:00:00Z"
},
{
"photoId": 902,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-03T10:00:00Z"
},
{
"photoId": 903,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-04T10:00:00Z"
},
{
"photoId": 904,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-05T10:00:00Z"
},
{
"photoId": 905,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-06T10:00:00Z"
},
{
"photoId": 906,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-07T10:00:00Z"
},
{
"photoId": 907,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-08T10:00:00Z"
},
{
"photoId": 908,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-09T10:00:00Z"
},
{
"photoId": 909,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-01T10:00:00Z"
},
{
"photoId": 910,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-02T10:00:00Z"
},
{
"photoId": 911,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-03T10:00:00Z"
},
{
"photoId": 912,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-04T10:00:00Z"
},
{
"photoId": 913,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-05T10:00:00Z"
},
{
"photoId": 914,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-06T10:00:00Z"
},
{
"photoId": 915,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-07T10:00:00Z"
},
{
"photoId": 916,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-08T10:00:00Z"
},
{
"photoId": 917,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-09T10:00:00Z"
},
{
"photoId": 918,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-01T10:00:00Z"
},
{
"photoId": 919,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-02T10:00:00Z"
},
{
"photoId": 920,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-03T10:00:00Z"
},
{
"photoId": 921,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-04T10:00:00Z"
},
{
"photoId": 922,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-05T10:00:00Z"
},
{
"photoId": 923,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-06T10:00:00Z"
},
{
"photoId": 924,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-07T10:00:00Z"
},
{
"photoId": 925,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-08T10:00:00Z"
},
{
"photoId": 926,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-09T10:00:00Z"
},
{
"photoId": 927,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-01T10:00:00Z"
},
{
"photoId": 928,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-02T10:00:00Z"
},
{
"photoId": 929,
"width": 4000,
"height": 3000,
"caption": null,
"takenAt": "2026-06-03T10:00:00Z"
}
]
}
}
@@ -0,0 +1,210 @@
{
"A/fabric-classic/head": "fb618faf28e68d0f",
"A/fabric-classic/plan": "2ca591e0d4dd17bb",
"A/fabric-classic/spread/0": "98d1af5f8b6090b7",
"A/fabric-classic/spread/1": "aa637b0de2518bf5",
"A/fabric-classic/spread/10": "4e2ff8d6a3dbb703",
"A/fabric-classic/spread/11": "b2194950c53cdd3c",
"A/fabric-classic/spread/12": "8fbffee2569b59e1",
"A/fabric-classic/spread/13": "71b83c5324f9e7fd",
"A/fabric-classic/spread/14": "52c76dae8b0aa332",
"A/fabric-classic/spread/15": "0056090b0878b3fa",
"A/fabric-classic/spread/16": "31003c2416a1cf66",
"A/fabric-classic/spread/17": "c2e94f7701145f81",
"A/fabric-classic/spread/18": "3f5fe08a57fe334e",
"A/fabric-classic/spread/19": "57963677a89b5ca2",
"A/fabric-classic/spread/2": "8278d3c2507527f9",
"A/fabric-classic/spread/20": "359bf9a8b179fbd4",
"A/fabric-classic/spread/21": "61f06e7b8b03a23e",
"A/fabric-classic/spread/22": "b11b5b3fb34a34eb",
"A/fabric-classic/spread/23": "a55189e7bfd3934d",
"A/fabric-classic/spread/3": "2ceb4890ba95c249",
"A/fabric-classic/spread/4": "7f5a904449283690",
"A/fabric-classic/spread/5": "61024db654103dd4",
"A/fabric-classic/spread/6": "ac8b6bb2ee46806e",
"A/fabric-classic/spread/7": "f6eaad286cbe258a",
"A/fabric-classic/spread/8": "a19d4e9230364789",
"A/fabric-classic/spread/9": "5081bc598f459c9f",
"A/midnight/head": "ce0d82281cbc3e1f",
"A/midnight/plan": "f71989d04f78b247",
"A/midnight/spread/0": "d6067998399ebc18",
"A/midnight/spread/1": "d9f5d78659332b45",
"A/midnight/spread/10": "e4508723fcc91fd2",
"A/midnight/spread/11": "b42c27c1e29b454b",
"A/midnight/spread/12": "9aef640eb48543d4",
"A/midnight/spread/13": "9298685922238183",
"A/midnight/spread/14": "a8f283b63056dc82",
"A/midnight/spread/15": "4603d240cc7fa47d",
"A/midnight/spread/16": "c7f989b59b859ef5",
"A/midnight/spread/17": "47628c608d0fcf77",
"A/midnight/spread/18": "1afcdd2152188bc5",
"A/midnight/spread/19": "990391282f4663f5",
"A/midnight/spread/2": "b00839cd0dc1451b",
"A/midnight/spread/20": "0c98113176e3f09a",
"A/midnight/spread/21": "38d76ca81db84db0",
"A/midnight/spread/22": "eba329fd185958b3",
"A/midnight/spread/23": "9664fbd24505a632",
"A/midnight/spread/3": "742dfb4105263101",
"A/midnight/spread/4": "98f7916fe80a1243",
"A/midnight/spread/5": "b57ccdf57fffaec4",
"A/midnight/spread/6": "ed8bd8657c310997",
"A/midnight/spread/7": "d19ae08326e5f88d",
"A/midnight/spread/8": "56424e21fced774a",
"A/midnight/spread/9": "303032b7354eba0c",
"A/riso/head": "f23966c329e4e7f3",
"A/riso/plan": "1b5beca655ce0cd6",
"A/riso/spread/0": "874dffde4ac89d0b",
"A/riso/spread/1": "8de92788de29277b",
"A/riso/spread/10": "ac5238ef848adccf",
"A/riso/spread/11": "651e7f318aba0dc3",
"A/riso/spread/12": "f32a719e61a22cf3",
"A/riso/spread/13": "aa0bf6589df257be",
"A/riso/spread/14": "4bdd5ea5f37f88ce",
"A/riso/spread/15": "7111cc1d2fdaee5a",
"A/riso/spread/16": "3c6fb9d26c3340d7",
"A/riso/spread/17": "d2b7fb8a739bfc1c",
"A/riso/spread/18": "71c3301e6970b43f",
"A/riso/spread/19": "3dc796729fe6e1fc",
"A/riso/spread/2": "5c9a93de9e6467d6",
"A/riso/spread/20": "8189fd41b9d13628",
"A/riso/spread/21": "f696e1aad9a5e0f6",
"A/riso/spread/22": "a748ada7a028818e",
"A/riso/spread/23": "9abf456403732463",
"A/riso/spread/3": "20a199a47535dea7",
"A/riso/spread/4": "45bfc268e430c87d",
"A/riso/spread/5": "d15be16f42113e11",
"A/riso/spread/6": "3056ef6c2b6ebdd1",
"A/riso/spread/7": "3f58daf471a89d43",
"A/riso/spread/8": "333f3efc450a109e",
"A/riso/spread/9": "4696b11649bdae79",
"B/fabric-classic/head": "b7377b9e48255d7b",
"B/fabric-classic/plan": "121194e82b626f79",
"B/fabric-classic/spread/0": "87325aa216fe9c6a",
"B/fabric-classic/spread/1": "b3f8e9c5043f8302",
"B/fabric-classic/spread/2": "5951c4e8d72b4b3f",
"B/fabric-classic/spread/3": "3c1131d279c96432",
"B/fabric-classic/spread/4": "6f02c19ed62524d1",
"B/fabric-classic/spread/5": "25cfae1fead90097",
"B/fabric-classic/spread/6": "b11b5b3fb34a34eb",
"B/fabric-classic/spread/7": "3194c096e5c7d16f",
"B/midnight/head": "1d04fe6e78b208c4",
"B/midnight/plan": "3168bafe49ffe4d6",
"B/midnight/spread/0": "35d449abcfea8ad0",
"B/midnight/spread/1": "d9f5d78659332b45",
"B/midnight/spread/2": "a094503b82019808",
"B/midnight/spread/3": "54de2d4a4b77b772",
"B/midnight/spread/4": "a480b41d506dfa5c",
"B/midnight/spread/5": "db7f87888a66afc9",
"B/midnight/spread/6": "5e937726a85c5b42",
"B/midnight/spread/7": "eba329fd185958b3",
"B/midnight/spread/8": "db97ebe8475e0371",
"B/riso/head": "30cd1c15dcaf7def",
"B/riso/plan": "357271fb7149e401",
"B/riso/spread/0": "6bf3e8b0649cf3c5",
"B/riso/spread/1": "8de92788de29277b",
"B/riso/spread/2": "40f8d2b093d0fc8d",
"B/riso/spread/3": "f79b25f04949963a",
"B/riso/spread/4": "0c5ae515506ee299",
"B/riso/spread/5": "0855b6391406b76d",
"B/riso/spread/6": "a748ada7a028818e",
"B/riso/spread/7": "877657cfa95e3cac",
"C/fabric-classic/head": "ddd21691c506a600",
"C/fabric-classic/plan": "24dedbf02c4837ad",
"C/fabric-classic/spread/0": "65b310c86982ba73",
"C/fabric-classic/spread/1": "7094bacb28cc210a",
"C/fabric-classic/spread/10": "b11b5b3fb34a34eb",
"C/fabric-classic/spread/11": "f7d9814c6b8649fd",
"C/fabric-classic/spread/2": "0f44c632cf8a2a16",
"C/fabric-classic/spread/3": "550e08d70efb38af",
"C/fabric-classic/spread/4": "1798f89f0de08592",
"C/fabric-classic/spread/5": "e270122f62d0c8a0",
"C/fabric-classic/spread/6": "1f4e718719dc5a21",
"C/fabric-classic/spread/7": "6a5148ce5980f492",
"C/fabric-classic/spread/8": "a7f0ed591793b5e1",
"C/fabric-classic/spread/9": "2622a67a8883ad77",
"C/midnight/head": "192eab52ca6d5847",
"C/midnight/plan": "f102868f1869fae5",
"C/midnight/spread/0": "d0179be86d1ad360",
"C/midnight/spread/1": "d9f5d78659332b45",
"C/midnight/spread/10": "2363fcae0008acbc",
"C/midnight/spread/11": "eba329fd185958b3",
"C/midnight/spread/12": "ebbba57a4116faa3",
"C/midnight/spread/2": "7b99390522f39fe5",
"C/midnight/spread/3": "00bbf4a300b065d9",
"C/midnight/spread/4": "41e460c85814e66c",
"C/midnight/spread/5": "aa2882594aedb3cc",
"C/midnight/spread/6": "bae5ec4f220e42bd",
"C/midnight/spread/7": "fa6b080fc5dde83e",
"C/midnight/spread/8": "3d99b3e9932ef3b4",
"C/midnight/spread/9": "921410b25c401cf0",
"C/riso/head": "13bb3a22ba5ffb72",
"C/riso/plan": "5b16a60268358e50",
"C/riso/spread/0": "81f272372d90da7b",
"C/riso/spread/1": "8de92788de29277b",
"C/riso/spread/10": "62638c6830cc5a89",
"C/riso/spread/11": "9683c291014e90a4",
"C/riso/spread/12": "a748ada7a028818e",
"C/riso/spread/13": "81e4b4a0dcc6961a",
"C/riso/spread/2": "bab7a9d12fd0aa7e",
"C/riso/spread/3": "edb0ebe3a843a937",
"C/riso/spread/4": "a4b3de47c30627ef",
"C/riso/spread/5": "7c2581b0113b5426",
"C/riso/spread/6": "5863b346ecff6743",
"C/riso/spread/7": "f4e17840337b10ef",
"C/riso/spread/8": "41c4f9a40716f107",
"C/riso/spread/9": "355adfa6c181a5e5",
"D/fabric-classic/head": "00392b522e232495",
"D/fabric-classic/plan": "9a293013918e025e",
"D/fabric-classic/spread/0": "972d99b392069408",
"D/fabric-classic/spread/1": "931682bc5827ad8a",
"D/fabric-classic/spread/10": "83ce523a523ba7ea",
"D/fabric-classic/spread/11": "27739eb8a77f0035",
"D/fabric-classic/spread/12": "e2a0171226254031",
"D/fabric-classic/spread/13": "a0bf0a056caa1f44",
"D/fabric-classic/spread/14": "b11b5b3fb34a34eb",
"D/fabric-classic/spread/15": "c40cdcb18e2ef0bc",
"D/fabric-classic/spread/2": "86dd9a101af346f4",
"D/fabric-classic/spread/3": "afe19cb105dfefa0",
"D/fabric-classic/spread/4": "80d31e5986b670e2",
"D/fabric-classic/spread/5": "8127a39f5b6a7541",
"D/fabric-classic/spread/6": "5446e309eae3c696",
"D/fabric-classic/spread/7": "1afc341fcd06a62b",
"D/fabric-classic/spread/8": "8513bc24ffc7db1d",
"D/fabric-classic/spread/9": "a33e805786bafb0f",
"D/midnight/head": "f68b1563b89dd3f8",
"D/midnight/plan": "d413165d19365a09",
"D/midnight/spread/0": "7cd2f7ff23d75a76",
"D/midnight/spread/1": "d9f5d78659332b45",
"D/midnight/spread/10": "d55a6a868ef1afd6",
"D/midnight/spread/11": "2b249dffc109e11f",
"D/midnight/spread/12": "61f2f1d0a1010041",
"D/midnight/spread/13": "084752209131d1e9",
"D/midnight/spread/14": "eba329fd185958b3",
"D/midnight/spread/15": "6e6e6c877443c5e7",
"D/midnight/spread/2": "cef4235690d44e53",
"D/midnight/spread/3": "cd34cb85119476ee",
"D/midnight/spread/4": "278b027b3ee6da5e",
"D/midnight/spread/5": "11c77cb59d466cee",
"D/midnight/spread/6": "18f3f3000e140a7f",
"D/midnight/spread/7": "2b6e0be2001fc0f6",
"D/midnight/spread/8": "7ff118f54e235945",
"D/midnight/spread/9": "d42eac1e9e169a65",
"D/riso/head": "92d1faaa7684dc71",
"D/riso/plan": "6b7a7e5621354232",
"D/riso/spread/0": "0e7a9e0446c17176",
"D/riso/spread/1": "8de92788de29277b",
"D/riso/spread/10": "3bf04d7ce7be2b8c",
"D/riso/spread/11": "72220ff9ab285b8b",
"D/riso/spread/12": "3ea9311b14cfd10b",
"D/riso/spread/13": "c7a09a0eac8e6b15",
"D/riso/spread/14": "a748ada7a028818e",
"D/riso/spread/15": "ed87c0a4c77f241c",
"D/riso/spread/2": "78f24755fb7ad38a",
"D/riso/spread/3": "047741da3e1adc20",
"D/riso/spread/4": "e5422a6420e638e8",
"D/riso/spread/5": "048d150f1354f995",
"D/riso/spread/6": "b5ec167d7af3af62",
"D/riso/spread/7": "c2dcd8909c7a12a0",
"D/riso/spread/8": "ba544d78e16ead7c",
"D/riso/spread/9": "bc85383342f55f6d"
}
@@ -0,0 +1,389 @@
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 { pageFindings } from '../describe-spread';
import { findDecoratedTemplate } from '../templates/template-catalogue';
import { matchDecoratedTemplate } from '../templates/template-match';
import { bookThemeColor, bookThemeOf } from '../theme/book-theme';
import { applyBookTheme } from '../theme/book-theme-apply';
import { designBook, type BookDesignInput, type BookDesignResult } from './design-book';
import type { BookDesignDirectivesInput } from './design-directives.schema';
import { THEME_ARCHETYPE } from './design-template-pick';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
/**
* A book laid on the designed templates (`layouts: 'templates'`): the
* Studio's Auto layout and `design_book` with the templates.
*
* AUTODSN-020 pins a digest of every page on the auto layout's three parity
* journeys and on a fourth (`design-templated.parity-inputs.json`: entries of
* 0, 1, 2, 3, 5, 12 and 20 photos and a gallery), in three themes. A
* deliberate change to a layout changes its digests: say so in the change
* and write the new ones (the failure prints the whole map as it is now).
*/
interface Fixture {
input: AutoInput & Partial<BookDesignInput>;
gallery?: AutoPhoto[];
}
const here = (file: string) => fileURLToPath(new URL(file, import.meta.url));
const read = <T>(file: string) => JSON.parse(readFileSync(here(file), 'utf8')) as T;
const parity = {
...read<Record<string, Fixture>>('../auto/auto-layout.parity-inputs.json'),
...read<Record<string, Fixture>>('./design-templated.parity-inputs.json'),
};
const designFixtures = read<Record<string, { input: BookDesignInput }>>('./design-book.fixtures.json');
const expected = read<Record<string, string>>('./design-templated.parity.json');
/** Every journey the specs lay out, as designBook takes it, with ids from a counter. */
function inputs(): [string, BookDesignInput][] {
const out: [string, BookDesignInput][] = [];
for (const [name, fx] of Object.entries(parity)) {
out.push([name, { ...fx.input, gallery: fx.gallery, candidates: [], photos: [] }]);
}
for (const [name, fx] of Object.entries(designFixtures)) out.push([name, fx.input]);
return out;
}
const counter = () => {
let n = 0;
return (p: string) => `${p}-${++n}`;
};
const THEMES = ['fabric-classic', 'riso', 'midnight'] as const;
function laid(input: BookDesignInput, style: string, more: BookDesignDirectivesInput = {}): BookDesignResult {
const directives = { style, layouts: 'templates', seed: 1, ...more } as BookDesignDirectivesInput;
return designBook(null, { ...input, newId: counter() }, directives);
}
const photoIdsOn = (spreads: readonly BookSpread[], where: 'elements' | 'parked') =>
spreads.flatMap((s) => s[where].flatMap((e) => (e.kind === 'photo' && e.photoId != null ? [e.photoId] : [])));
/** Every photo the journey has, on its entries and loose in its gallery. */
const journeyPhotos = (input: BookDesignInput) => [
...input.entries.flatMap((e) => e.photos.map((p) => p.photoId)),
...(input.gallery ?? []).map((p) => p.photoId),
];
const STORY =
'The ferry left in the rain and we stood on deck anyway, watching the islands go by one after the other. ';
/** A journey of one entry with this many photos, a story or none. */
function single(photos: number, story: boolean): BookDesignInput {
const base = parity.A as Fixture;
const entry: AutoEntry = {
id: 1,
title: 'Islands',
story: story ? STORY.repeat(3).trim() : null,
location: 'Lofoten',
date: '2026-06-02',
photos: Array.from({ length: photos }, (_, i) => ({ photoId: 700 + i, width: 4000, height: 3000 })),
lat: 68.2,
lng: 13.6,
country: 'NO',
dayNumber: 1,
dayCount: 1,
};
return { ...base.input, entries: [entry], candidates: [], photos: [] };
}
describe('the book on the designed templates', () => {
it('AUTODSN-001: is a valid book, as normalised, within the spread and element caps', () => {
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);
}
}
});
it("AUTODSN-002: the cover is a cover template of the theme's archetype", () => {
for (const [name, input] of inputs()) {
for (const theme of THEMES) {
const result = laid(input, theme);
const cover = result.doc.spreads[0] as BookSpread;
const id = result.plan[0]?.template;
if (!id) continue; // a journey without photos for any of its covers is composed
expect(findDecoratedTemplate(id)?.archetype, `${name} ${theme}`).toBe(THEME_ARCHETYPE[theme]);
expect(matchDecoratedTemplate(cover, result.doc.page)?.id, `${name} ${theme}`).toBe(id);
}
}
});
it('AUTODSN-003: every entry with something to show has a page, in the order of the journey', () => {
for (const [name, input] of inputs()) {
const { doc } = laid(input, 'fabric-classic');
const told = [...new Set(doc.spreads.flatMap((s) => (s.entryId == null ? [] : [s.entryId])))];
const substance = input.entries.filter((e) => e.photos.length || (e.story ?? '').trim()).map((e) => e.id);
for (const id of substance) expect(told, `${name} ${id}`).toContain(id);
const dated = [...input.entries].filter((e) => told.includes(e.id) && e.date);
const order = told.filter((id) => dated.some((e) => e.id === id));
const byDate = [...dated].sort((a, b) => (a.date ?? '').localeCompare(b.date ?? '')).map((e) => e.id);
expect(order, name).toEqual(byDate);
}
});
it('AUTODSN-004: no photo vanishes: it is on a page, parked with one, or said to be left out', () => {
for (const [name, input] of inputs()) {
for (const theme of THEMES) {
const result = laid(input, theme);
const on = new Set([
...photoIdsOn(result.doc.spreads, 'elements'),
...photoIdsOn(result.doc.spreads, 'parked'),
]);
const left = new Set(result.photos.left_out.map((l) => l.photoId));
const lost = journeyPhotos(input).filter((id) => !on.has(id) && !left.has(id));
expect(lost, `${name} ${theme}`).toEqual([]);
}
}
});
it('AUTODSN-005: never a classic summary, countries or mark', () => {
for (const [name, input] of inputs()) {
for (const theme of THEMES) {
for (const s of laid(input, theme).doc.spreads) {
for (const e of s.elements) {
if (e.kind === 'stats' || e.kind === 'countries') expect(e.look ?? 'classic', name).not.toBe('classic');
if (e.kind === 'badge') expect(['plain', 'chip'], name).not.toContain(e.style);
}
}
}
}
});
it('AUTODSN-006: is kept in its theme, and another theme recolours the templated pages', () => {
const input = inputs()[0]?.[1] as BookDesignInput;
const { doc, plan } = laid(input, 'riso');
expect(doc.theme?.id).toBe('riso');
const night = bookThemeOf('midnight');
const again = applyBookTheme(doc, night);
const templated = plan.flatMap((r, i) => (r.template ? [i] : []));
expect(templated.length).toBeGreaterThan(3);
for (const i of templated) {
const linked = (again.spreads[i] as BookSpread).elements.filter((e) => e.kind === 'text' && e.token === 'ink');
for (const e of linked) if (e.kind === 'text') expect(e.color).toBe(bookThemeColor(night, 'ink'));
}
expect(again.spreads.some((s, i) => JSON.stringify(s) !== JSON.stringify(doc.spreads[i]))).toBe(true);
});
it("AUTODSN-007: titles follow the entry's title and stories its story, a cut story going on over the next page", () => {
const input = parity.A as Fixture;
const { doc, notes } = laid({ ...input.input, candidates: [], photos: [] }, 'fabric-classic');
const pages = doc.spreads.filter((s) => s.entryId != null);
for (const s of pages) {
const bound = s.elements.some(
(e) => e.kind === 'text' && (e.binding?.source === 'entry.title' || e.binding?.source === 'entry.location'),
);
expect(bound, s.id).toBe(true);
}
const stories = pages.flatMap((s) =>
s.elements.filter((e) => e.kind === 'text' && e.binding?.source === 'entry.story'),
);
expect(stories.length).toBeGreaterThan(0);
const cut = stories.filter((e) => e.kind === 'text' && e.overridden);
if (cut.length) expect(notes.some((n) => n.includes('cut to fit'))).toBe(true);
for (const e of stories) if (e.kind === 'text' && !e.overridden) expect(e.text.length).toBeGreaterThan(0);
});
it('AUTODSN-008: an entry of 0 to 20 photos, with a story or none, fills every frame it is laid on', () => {
for (const n of [0, 1, 2, 3, 4, 5, 6, 7, 8, 12, 20]) {
for (const story of [false, true]) {
if (!n && !story) continue;
const input = single(n, story);
const result = laid(input, 'fabric-classic');
const pages = result.doc.spreads.filter((s) => s.entryId === 1);
expect(pages.length, `${n} ${story}`).toBeGreaterThan(0);
for (const s of pages) {
for (const e of s.elements) if (e.kind === 'photo') expect(e.photoId, `${n} ${story}`).not.toBeNull();
}
const placed = photoIdsOn(pages, 'elements').length + photoIdsOn(pages, 'parked').length;
const capped = result.photos.left_out.filter((l) => l.reason === 'chapter_max').length;
expect(placed + capped, `${n} ${story}`).toBe(n);
expect(capped).toBe(Math.max(0, n - 12));
if (story) {
const told = pages.some((s) =>
s.elements.some((e) => e.kind === 'text' && e.binding?.source === 'entry.story'),
);
expect(told, `${n}`).toBe(true);
}
}
}
});
it("AUTODSN-009: the gallery's photos go to the entry of their day first, the rest to closing pages", () => {
const fx = parity.D as Fixture;
const input: BookDesignInput = { ...fx.input, gallery: fx.gallery, candidates: [], photos: [] };
const { doc } = laid(input, 'fabric-classic');
const of = (entryId: number) =>
photoIdsOn(
doc.spreads.filter((s) => s.entryId === entryId),
'elements',
);
// 900 was taken on 1 June, the day of entry 101, which had no photos of its own.
expect(of(101)).toContain(900);
// An entry already holding all it takes leaves the loose photos to the closing pages of more moments.
const full = single(12, false);
const loose = Array.from({ length: 10 }, (_, i) => ({
photoId: 800 + i,
width: 4000,
height: 3000,
takenAt: null,
}));
const more = laid({ ...full, gallery: loose }, 'fabric-classic');
const closing = more.doc.spreads.filter((s) => s.role === 'inner' && s.entryId === null);
const shown = photoIdsOn(closing, 'elements');
expect(shown.length).toBeGreaterThan(0);
expect(shown.every((id) => id >= 800)).toBe(true);
const left = more.photos.left_out.map((l) => l.photoId);
expect(loose.every((p) => shown.includes(p.photoId) || left.includes(p.photoId))).toBe(true);
});
it('AUTODSN-010: keeps a stored page that holds a locked element', () => {
const input = inputs()[0]?.[1] as BookDesignInput;
const first = laid(input, 'fabric-classic').doc;
const page = first.spreads[5] as BookSpread;
const locked: BookSpread = {
...page,
elements: page.elements.map((e, i) => (i === 0 ? { ...e, locked: true } : e)),
};
const stored: BookDocument = { ...first, spreads: first.spreads.map((s) => (s.id === page.id ? locked : s)) };
const again = designBook(
stored,
{ ...input, newId: counter() },
{ style: 'fabric-classic', layouts: 'templates', seed: 2 },
);
expect(again.doc.spreads).toContainEqual(locked);
expect(again.plan.find((r) => r.spreadId === page.id)?.action).toBe('keep');
});
it('AUTODSN-011: no page has a severe finding', () => {
for (const [name, input] of inputs()) {
for (const theme of THEMES) {
const { doc } = laid(input, theme);
doc.spreads.forEach((s, i) => {
const severe = pageFindings(s, doc.page, input.photos).filter((f) => f.severity === 'severe');
expect(
severe.map((f) => f.code),
`${name} ${theme} page ${i}`,
).toEqual([]);
});
}
}
});
it('AUTODSN-012: the plan names the template every templated page came from', () => {
const { plan, doc } = laid(inputs()[0]?.[1] as BookDesignInput, 'midnight');
const named = plan.filter((r) => r.template);
expect(named.length).toBeGreaterThan(3);
for (const r of named) {
expect(findDecoratedTemplate(r.template ?? '')).toBeDefined();
const s = doc.spreads.find((x) => x.id === r.spreadId) as BookSpread;
expect(matchDecoratedTemplate(s, doc.page)?.id, r.template).toBe(r.template);
}
});
it('AUTODSN-013: the same seed lays the same book, another seed another', () => {
const input = inputs()[0]?.[1] as BookDesignInput;
const ids = (seed: number) =>
laid(input, 'riso', { seed })
.plan.map((r) => r.template ?? r.beat)
.join();
expect(ids(4)).toBe(ids(4));
expect([5, 6, 7, 8].some((seed) => ids(seed) !== ids(4))).toBe(true);
});
it("AUTODSN-014: a theme of the book's own is kept whole", () => {
const own = {
...bookThemeOf('postcard'),
id: null,
palette: { ...bookThemeOf('postcard').palette, accent: '#123456' },
};
const input = inputs()[0]?.[1] as BookDesignInput;
const { doc } = designBook(
null,
{ ...input, theme: own, newId: counter() },
{ style: 'postcard', layouts: 'templates' },
);
expect(doc.theme).toEqual(own);
});
it('AUTODSN-015: without layouts the design is composed as before, with no template named', () => {
const input = inputs()[0]?.[1] as BookDesignInput;
const composed = designBook(null, { ...input, newId: counter() }, { style: 'riso', seed: 1 });
expect(composed.plan.every((r) => r.template === undefined)).toBe(true);
expect(composed.doc).toEqual(
designBook(null, { ...input, newId: counter() }, { style: 'riso', seed: 1, layouts: 'composed' }).doc,
);
});
it('AUTODSN-020: lays the parity journeys exactly as pinned', () => {
const actual = digests();
const differ = [...actual].filter(([k, v]) => expected[k] !== v).map(([k]) => k);
const missing = Object.keys(expected).filter((k) => !actual.has(k));
const message = `the templated auto layout changed; if that is deliberate, write design-templated.parity.json as:\n${JSON.stringify(Object.fromEntries([...actual].sort()), null, 1)}`;
expect(differ.length + missing.length, message).toBe(0);
});
});
// ── the digests ──────────────────────────────────────────────────────────
/** The canonical form auto-layout.parity.spec.ts digests: keys sorted, every id read as `#new`. */
function canonical(value: unknown): 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') return '#new';
return v;
};
return JSON.stringify(walk(value));
}
const digest = (v: unknown) => createHash('sha256').update(canonical(v)).digest('hex').slice(0, 16);
/** Every page of every parity journey in every theme, digested. */
function digests(): Map<string, string> {
const out = new Map<string, string>();
for (const [name, fx] of Object.entries(parity)) {
const input = { ...fx.input, gallery: fx.gallery, candidates: [], photos: [] };
for (const theme of THEMES) {
const result = laid(input, theme);
const doc = result.doc;
const put = (key: string, v: unknown) => out.set(`${name}/${theme}/${key}`, digest(v));
put('head', {
version: doc.version,
title: doc.title,
page: doc.page,
theme: doc.theme,
count: doc.spreads.length,
});
put(
'plan',
result.plan.map((r) => [r.beat, r.template ?? null, r.entryId, r.photoIds]),
);
doc.spreads.forEach((s, i) => put(`spread/${i}`, s));
}
}
return out;
}
+363
View File
@@ -0,0 +1,363 @@
import type { AutoEntry, AutoPhoto } from '../auto/auto-types';
import type { BookCoverArchetype } from '../book-archetypes';
import { DESIGN_TEXT_HEADROOM, textHeightNeeded } from '../book-findings';
import type { BookElement, BookSpread, BookTextElement } from '../book.schema';
import { decoratedTemplatesFor } from '../templates/template-catalogue';
import { templatePhotoSlots, type BookDecoratedTemplate } from '../templates/template-format';
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';
import type { BookDesignLeftOut, DesignPhotoChoice } from './design-photos';
import { innerSpread } from './design-spreads';
import {
bodyCapacity,
chunkPhotos,
chunkSizesOf,
innerPool,
type ChunkSizes,
pickInnerTemplate,
templateEnergy,
type TemplatePickCtx,
} from './design-template-pick';
import {
composeTravelCountries,
composeTravelSummary,
composeWordsPage,
countryLookFor,
pourTemplate,
summaryLookFor,
} from './design-templated-framing';
/**
* The inner pages of a book laid on the designed templates.
*
* The journey at a glance and its countries open it, in the travel looks.
* Then every chapter: its photos cut into page-sized parts that leave no
* frame empty (`chunkPhotos`), the first part carrying the chapter's words,
* and each part laid on the decorated template that suits it best
* (`pickInnerTemplate`), in the book's theme, its title, place, date and
* story bound to the journal. A story too long for its frame is cut at a
* sentence and goes on over a page of words. Runs of stops nobody wrote
* about share a list as in every design. Photos of the journey's gallery that
* found no entry with room close the book on pages of more moments.
*/
export interface TemplatedInnerResult {
spreads: DesignedSpread[];
leftOut: BookDesignLeftOut[];
notes: string[];
}
/** What the inner pages are laid with beside the composer's context. */
export interface TemplatedPlan {
archetype: BookCoverArchetype;
/** The book counts as calm for template choice: its busy pages stay few. */
calm: boolean;
/** Loose photos of the journey no entry had room for, for the closing pages of more moments. */
rest: readonly AutoPhoto[];
}
/** The closing pages of more moments a book takes at most. */
const MORE_MOMENTS = 3;
/** Pages of words one story runs on to at most past its own. */
const MOST_CONTINUED = 2;
/** The classic mark styles: never set by the auto layout, which sets a date mark as a stamp. */
const CLASSIC_MARKS = new Set(['plain', 'chip', 'outline', 'stacked']);
/** The beat a templated page plays in the plan, read from its template's energy and photos. */
function beatOf(tpl: BookDecoratedTemplate | null, photos: number): BookDesignBeat {
if (!tpl) return photos ? 'grid' : 'story';
const energy = templateEnergy(tpl);
if (energy === 'loud') return 'hero';
if (energy === 'lively' || energy === 'medium') return photos >= 4 ? 'grid' : photos >= 2 ? 'duo' : 'quiet';
return photos === 0 ? 'story' : 'quiet';
}
/** The words of a story the directives let a book set: all, an excerpt, or none. */
function storyOf(c: ComposeCtx, entry: AutoEntry | undefined): string {
const words = (entry?.story ?? '').trim();
const mode = c.directives.text?.story ?? 'full';
if (!words || mode === 'none') return '';
return mode === 'excerpt' ? cutWords(words, c.directives.text?.excerptChars ?? 600) : words;
}
/**
* A page holding an entry's content for a template to pour: the story (bound
* when it is the whole of it), the first photo's caption when captions are
* asked of each photo, and the photos in order. The title, place and date the
* template takes from the journal itself, bound to the entry.
*/
export function entrySeedSpread(
c: ComposeCtx,
entry: AutoEntry,
photos: readonly AutoPhoto[],
story: { words: string; whole: boolean } | null,
): BookSpread {
const box = liveBox(c, 'right');
const els: BookElement[] = [];
if (story?.words) {
const binding = { source: 'entry.story' as const, entryId: entry.id };
els.push(textEl(c, box, story.words, 'body', { binding, overridden: !story.whole }));
}
const caption = photos[0]?.caption?.trim();
const first = photos[0];
if (caption && first && c.directives.text?.captions === 'photo') {
const binding = { source: 'photo.caption' as const, photoId: first.photoId };
els.push(textEl(c, box, caption, 'caption', { binding }));
}
els.push(...photos.map((p) => photoEl(c, p, box)));
return innerSpread(c, els, entry.id);
}
/** The longest start of `words` its laid frame holds, cut at a sentence where one is near. */
function cutToFrame(el: BookTextElement, words: string): string {
const fits = (s: string) => textHeightNeeded({ ...el, text: s }, DESIGN_TEXT_HEADROOM) <= el.frame.h;
if (fits(words)) return words;
let lo = 0;
let hi = words.length;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (fits(`${words.slice(0, mid)}…`)) lo = mid;
else hi = mid - 1;
}
return cutWords(words, lo);
}
const isStory = (e: BookElement): e is BookTextElement => e.kind === 'text' && e.binding?.source === 'entry.story';
/**
* A laid page with its story cut to its frame where it runs over: no longer
* bound to the journal and said in the notes, the rest handed back for the
* next page. A story that fits stays as it is.
*/
function fittedStory(c: ComposeCtx, spread: BookSpread, entry: AutoEntry): { spread: BookSpread; rest: string } {
const story = spread.elements.find(isStory);
if (!story) return { spread, rest: '' };
const fitting = cutToFrame(story, story.text);
if (fitting === story.text) return { spread, rest: '' };
c.notes.push(`the story of entry ${entry.id} is cut to fit its page and no longer follows the journal`);
const rest = story.text.slice(fitting.replace(/…$/, '').length).trim();
const elements = fitting
? spread.elements.map((e) => (e === story ? { ...story, text: fitting, overridden: true } : e))
: spread.elements.filter((e) => e !== story);
return { spread: { ...spread, elements }, rest };
}
/** A laid page as the auto layout leaves it: date marks as stamps, and only its photos parked. */
function tidied(spread: BookSpread): BookSpread {
const elements = spread.elements.map((e) =>
e.kind === 'badge' && CLASSIC_MARKS.has(e.style) ? { ...e, style: 'stamp' as const } : e,
);
// The words a template has no role for are the journal's own, bound to it: nothing is lost leaving them.
return { ...spread, elements, parked: spread.parked.filter((e) => e.kind === 'photo') };
}
/** The pick context for the next page of the book. */
type PickBase = Omit<TemplatePickCtx, 'history' | 'key'>;
interface Laying {
c: ComposeCtx;
base: PickBase;
/** Template ids laid so far, in order, for the picker's variety and rhythm. */
history: string[];
entries: readonly AutoEntry[];
out: DesignedSpread[];
}
/** One page laid on a template, or composed where none takes it; its story's rest handed back. */
function layPage(
l: Laying,
entry: AutoEntry,
photos: readonly AutoPhoto[],
story: { words: string; whole: boolean } | null,
chapter: string | null,
): string {
const { c } = l;
const mappable = (entry.lat != null && entry.lng != null) || !!c.travel.stats?.points.length;
const chunk = { photos, story: story?.words ?? '', mappable };
const key = `e${entry.id}:${l.out.length}`;
const tpl = pickInnerTemplate(chunk, { ...l.base, history: l.history, key });
if (!tpl) {
const fitted = fittedStory(c, composeWordsPage(c, entry, wordsColumn(c, entry, story)), entry);
l.out.push({ spread: fitted.spread, beat: 'story', chapter, findings: [] });
return fitted.rest;
}
const laid = tidied(pourTemplate(c, entrySeedSpread(c, entry, photos, story), tpl, l.entries));
const fitted = fittedStory(c, laid, entry);
l.history.push(tpl.id);
l.out.push({ spread: fitted.spread, beat: beatOf(tpl, photos.length), chapter, findings: [], template: tpl.id });
return fitted.rest;
}
/**
* A story set in a column down the left page, where no template takes it:
* bound to the entry while it is the whole of it, for `fittedStory` to cut.
*/
function wordsColumn(c: ComposeCtx, entry: AutoEntry, story: { words: string; whole: boolean } | null) {
if (!story?.words) return null;
const binding = { source: 'entry.story' as const, entryId: entry.id };
const el = textEl(c, liveBox(c, 'left'), story.words, 'body', { binding, overridden: !story.whole });
return { ...el, frame: liveBox(c, 'left') };
}
/** The best capacity of the pool's story templates for each photo count, for what a long story costs a page. */
function storyRoom(c: ComposeCtx, pool: readonly BookDecoratedTemplate[]): Map<number, number> {
const room = new Map<number, number>();
for (const t of pool) {
const cap = bodyCapacity(t, c.page, c.look.theme);
if (cap > 0) room.set(templatePhotoSlots(t), Math.max(room.get(templatePhotoSlots(t)) ?? 0, cap));
}
return room;
}
/** A page of a template from outside the book's own pool costs this much more than one of its own. */
const FOREIGN_PAGE = 8;
/** A first page whose story frame cannot hold the story costs this much more: a page of words follows. */
const SHORT_STORY_PAGE = 12;
/**
* The photo counts a chapter's pages may take: those of the book's own pool
* of templates, and those only another archetype has at a price, so a photo
* finds a frame there rather than none.
*/
function chunkSizesFor(c: ComposeCtx, archetype: BookCoverArchetype, words: string): ChunkSizes {
const pool = innerPool(archetype);
const own = chunkSizesOf(pool);
const all = chunkSizesOf(decoratedTemplatesFor('inner'));
const room = storyRoom(c, words ? decoratedTemplatesFor('inner') : []);
const pageCost = (s: number, first: boolean) => {
const foreign = !(first && words ? own.story : own.any).has(s);
const short = first && words && (room.get(s) ?? 0) < words.length;
return (foreign ? FOREIGN_PAGE : 0) + (short ? SHORT_STORY_PAGE : 0);
};
return { any: all.any, story: all.story, pageCost };
}
/** A chapter's pages: its photos cut into parts, the first taking its first entry's words, then the rest of them. */
function chapterPages(l: Laying, chapter: DesignChapter, notes: string[]) {
const entry = chapter.entries[0];
if (!entry) return;
const name = chapter.label ?? entry.title ?? null;
const words = storyOf(l.c, entry);
const sizes = chunkSizesFor(l.c, l.base.archetype, words);
const cut = chunkPhotos(chapter.photos.length, !!words, sizes);
let at = 0;
let rest = '';
cut.sizes.forEach((size, i) => {
const story = i === 0 && words ? { words, whole: words === (entry.story ?? '').trim() } : null;
rest = layPage(l, entry, chapter.photos.slice(at, at + size), story, name) || rest;
at += size;
});
const parked = chapter.photos.slice(at);
const last = l.out[l.out.length - 1];
if (parked.length && last) {
last.spread = {
...last.spread,
parked: [...last.spread.parked, ...parked.map((p) => photoEl(l.c, p, liveBox(l.c, 'right')))],
};
notes.push(`${parked.length} photos of entry ${entry.id} found no frame and wait with its last page`);
}
for (let n = 0; rest && n < MOST_CONTINUED; n++) rest = layPage(l, entry, [], { words: rest, whole: false }, name);
if (rest) notes.push(`part of the story of entry ${entry.id} found no room on its pages and is left out`);
// The further entries of a chapter of a day or a place each tell their own story on a page of words.
for (const more of chapter.entries.slice(1)) {
const told = storyOf(l.c, more);
if (told) layPage(l, more, [], { words: told, whole: told === (more.story ?? '').trim() }, name);
}
}
/** The pages of a list of bare stops: as many stations spreads as the stops need. */
function stationsPages(l: Laying, chapter: DesignChapter, input: BookDesignInput) {
let rest = chapter.entries;
while (rest.length) {
const set = composeStations(l.c, rest, input.stationsLabel);
if (!set.placed) break;
l.out.push({ spread: innerSpread(l.c, set.elements, null), beat: 'stations', chapter: null, findings: [] });
rest = rest.slice(set.placed);
}
}
/** The closing pages of more moments: the photos no entry had room for, on grids, at most three pages. */
function moreMoments(l: Laying, rest: readonly AutoPhoto[], leftOut: BookDesignLeftOut[]) {
let left = [...rest];
const sizes = chunkSizesOf(innerPool(l.base.archetype)).any;
const biggest = Math.max(...[...sizes].filter((s) => s > 0), 1);
for (let n = 0; left.length && n < MORE_MOMENTS; n++) {
const take = left.slice(0, biggest);
const tpl = pickInnerTemplate(
{ photos: take, story: '', mappable: false },
{ ...l.base, history: l.history, key: `more:${n}` },
);
if (!tpl) break;
const used = take.slice(0, templatePhotoSlots(tpl));
const seed = innerSpread(
l.c,
used.map((p) => photoEl(l.c, p, liveBox(l.c, 'right'))),
null,
);
const laid = tidied(pourTemplate(l.c, seed, tpl, l.entries));
l.history.push(tpl.id);
l.out.push({ spread: laid, beat: beatOf(tpl, used.length), chapter: null, findings: [], template: tpl.id });
left = left.slice(used.length);
}
for (const p of left) leftOut.push({ photoId: p.photoId, reason: 'page_budget' });
}
/** The pages past the budget are left out from the end, their photos said to be left out for it. */
function withinCap(body: DesignedSpread[], keep: number, leftOut: BookDesignLeftOut[], notes: string[]) {
if (body.length <= keep) return body;
const cut = body.slice(Math.max(0, keep));
for (const d of cut) {
for (const e of d.spread.elements) {
if (e.kind === 'photo' && e.photoId != null) leftOut.push({ photoId: e.photoId, reason: 'page_budget' });
}
}
notes.push(`${cut.length} pages past the page budget are left out`);
return body.slice(0, Math.max(0, keep));
}
/** The journey at a glance and its countries, as the directives allow them. */
function framingPages(c: ComposeCtx, archetype: BookCoverArchetype): DesignedSpread[] {
const travel = c.directives.travel;
const out: DesignedSpread[] = [];
const summary = (travel?.summary ?? true) ? composeTravelSummary(c, summaryLookFor(archetype)) : null;
if (summary) out.push({ spread: summary, beat: 'summary', chapter: null, findings: [] });
const countries = (travel?.countries ?? true) ? composeTravelCountries(c, countryLookFor(archetype)) : null;
if (countries) out.push({ spread: countries, beat: 'countries', chapter: null, findings: [] });
return out;
}
/** Every inner page of a book laid on the designed templates, in order. */
export function composeTemplatedInner(
c: ComposeCtx,
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 head = framingPages(c, plan.archetype).slice(0, Math.max(0, cap - 1));
const base: PickBase = {
archetype: plan.archetype,
page: c.page,
theme: c.look.theme,
calm: plan.calm,
seed: c.directives.seed ?? 0,
};
const l: Laying = { c, base, history: [], entries, out: [] };
for (const chapter of buildChapters(entries, choice.byEntry, c.directives, input.candidates)) {
if (chapter.stations) stationsPages(l, chapter, input);
else chapterPages(l, chapter, notes);
}
moreMoments(l, plan.rest, leftOut);
const body = withinCap(l.out, cap - head.length, leftOut, notes);
return { spreads: [...head, ...body], leftOut, notes };
}