feat(shared): the Studio's auto layout entry points

auto/auto-designed.ts holds what the Studio's Auto layout menu calls.
autoDesignedBook lays the whole book on the designed templates in the
book's own theme object (a recoloured one of its own included, the calm
fabric-classic theme for a book without one), over the stored book, its
page format kept and every page holding a locked element kept; another
seed lays another book. autoRelayoutSpread lays one page again around its
locks, its id kept: an entry's page on the best template other than the
one on screen and its neighbours', avoiding one that would cut its story
where it can; the cover on the next cover template of its archetype and
then of its kin; the summary and the countries in their next look, an old
classic summary included. Pages the engine did not make are left alone,
as before. autoRelayoutKind says which pages those are.

designBook's templated path is split so the relayout reads the same
setting (templatedSetting) as the whole book, and the page layer is
shared (layEntryPage); the parity digests are unchanged by it. buildBook
and relayoutSpread stay exported and unchanged.
This commit is contained in:
Maurice
2026-10-24 20:01:49 +02:00
parent 9729b0b575
commit 0879b5aecc
8 changed files with 528 additions and 42 deletions
+177
View File
@@ -0,0 +1,177 @@
import { bookSpreadSchema, type BookDocument, type BookSpread } from '../book.schema';
import { matchDecoratedTemplate } from '../templates/template-match';
import { bookThemeOf } from '../theme/book-theme';
import { emptyBook } from './auto-book';
import {
AUTO_DEFAULT_THEME,
autoDesignedBook,
autoRelayoutKind,
autoRelayoutSpread,
type AutoDesignedInput,
} from './auto-designed';
import type { AutoPhoto } from './auto-types';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
/** The Studio's Auto layout on the designed templates: the whole book, and one spread again. */
interface Fixture {
input: AutoDesignedInput;
gallery?: AutoPhoto[];
}
const here = (file: string) => fileURLToPath(new URL(file, import.meta.url));
const fixtures = JSON.parse(readFileSync(here('./auto-layout.parity-inputs.json'), 'utf8')) as Record<string, Fixture>;
const fx = fixtures.A as Fixture;
const counter = () => {
let n = 0;
return (p: string) => `${p}-a${++n}`;
};
const input = (): AutoDesignedInput => ({ ...fx.input, gallery: fx.gallery, newId: counter() });
const book = (seed = 1, theme = bookThemeOf('riso')) => autoDesignedBook(emptyBook(input()), input(), { theme, seed });
/** The index of the first page of a kind, or -1. */
const indexOf = (doc: BookDocument, kind: ReturnType<typeof autoRelayoutKind>) =>
doc.spreads.findIndex((s) => autoRelayoutKind(s) === kind);
describe('the Studio auto layout on the designed templates', () => {
it("AUTODES-001: keeps the book's page format and its theme object, one of its own included", () => {
const own = {
...bookThemeOf('postcard'),
id: null,
palette: { ...bookThemeOf('postcard').palette, ink: '#203040' },
};
const start = { ...emptyBook(input()), page: { ...fx.input.page, pageWidth: 200, pageHeight: 250 } };
const doc = autoDesignedBook(start, input(), { theme: own, seed: 3 });
expect(doc.page.pageWidth).toBe(200);
expect(doc.page.pageHeight).toBe(250);
expect(doc.theme).toEqual(own);
expect(autoDesignedBook(null, input()).theme?.id).toBe(AUTO_DEFAULT_THEME);
});
it('AUTODES-002: another seed lays another book, the same seed the same', () => {
const ids = (doc: BookDocument) => doc.spreads.map((s) => matchDecoratedTemplate(s, doc.page)?.id ?? '-').join();
expect(ids(book(1))).toBe(ids(book(1)));
expect([2, 3, 4, 5].some((seed) => ids(book(seed)) !== ids(book(1)))).toBe(true);
});
it("AUTODES-003: this spread keeps the page's id and lays an entry's page on another template than its own and its neighbours'", () => {
const doc = book();
const at = doc.spreads.findIndex(
(s, i) => autoRelayoutKind(s) === 'entry' && matchDecoratedTemplate(doc.spreads[i] as BookSpread, doc.page),
);
const before = doc.spreads[at] as BookSpread;
const next = autoRelayoutSpread(doc, at, input(), { seed: 1 });
expect(next?.id).toBe(before.id);
expect(next?.entryId).toBe(before.entryId);
expect(bookSpreadSchema.safeParse(next).success).toBe(true);
const was = [at - 1, at, at + 1].map(
(i) => doc.spreads[i] && matchDecoratedTemplate(doc.spreads[i] as BookSpread, doc.page)?.id,
);
expect(was).not.toContain(matchDecoratedTemplate(next as BookSpread, doc.page)?.id);
const photos = (s: BookSpread) =>
[...s.elements, ...s.parked].flatMap((e) => (e.kind === 'photo' && e.photoId != null ? [e.photoId] : [])).sort();
expect(photos(next as BookSpread)).toEqual(photos(before));
});
it('AUTODES-004: pressed again and again, an entry page keeps moving on', () => {
let doc = book();
const at = indexOf(doc, 'entry');
const seen = new Set<string>();
for (let n = 0; n < 4; n++) {
const next = autoRelayoutSpread(doc, at, input(), { seed: n });
if (!next) throw new Error('an entry page lays again');
seen.add(matchDecoratedTemplate(next, doc.page)?.id ?? 'composed');
doc = { ...doc, spreads: doc.spreads.map((s, i) => (i === at ? next : s)) };
}
expect(seen.size).toBeGreaterThan(1);
});
it('AUTODES-005: cycles the cover through the covers of its archetype and then its kin', () => {
let doc = book();
const seen: string[] = [];
for (let n = 0; n < 6; n++) {
const next = autoRelayoutSpread(doc, 0, input(), { seed: 1 });
if (!next) throw new Error('the cover lays again');
expect(next.id).toBe(doc.spreads[0]?.id);
seen.push(matchDecoratedTemplate(next, doc.page)?.id ?? '-');
doc = { ...doc, spreads: doc.spreads.map((s, i) => (i === 0 ? next : s)) };
}
expect(new Set(seen).size).toBeGreaterThan(3);
expect(seen.some((id) => id.startsWith('riso-'))).toBe(true);
});
it('AUTODES-006: cycles the summary and the countries through their looks', () => {
const doc = book();
const summary = indexOf(doc, 'summary');
const looks: string[] = [];
let cur = doc;
for (let n = 0; n < 3; n++) {
const next = autoRelayoutSpread(cur, summary, input()) as BookSpread;
const stats = next.elements.find((e) => e.kind === 'stats' && e.metrics.length > 2);
looks.push(stats?.kind === 'stats' ? (stats.look ?? 'classic') : '-');
cur = { ...cur, spreads: cur.spreads.map((s, i) => (i === summary ? next : s)) };
}
expect(new Set(looks).size).toBe(3);
expect(looks).not.toContain('classic');
const countries = indexOf(doc, 'countries');
const again = autoRelayoutSpread(doc, countries, input()) as BookSpread;
const look = (s: BookSpread) => s.elements.flatMap((e) => (e.kind === 'countries' ? [e.look] : []))[0];
expect(look(again)).not.toBe(look(doc.spreads[countries] as BookSpread));
});
it('AUTODES-007: a page the engine did not make is not laid again', () => {
const doc = book();
const back = doc.spreads.findIndex((s) => s.role === 'back');
expect(autoRelayoutSpread(doc, back, input())).toBeNull();
const stations = doc.spreads.findIndex(
(s) => s.role === 'inner' && s.entryId === null && autoRelayoutKind(s) === null,
);
if (stations >= 0) expect(autoRelayoutSpread(doc, stations, input())).toBeNull();
expect(autoRelayoutSpread(doc, 999, input())).toBeNull();
});
it('AUTODES-008: keeps a locked element where it is', () => {
const doc = book();
const at = indexOf(doc, 'entry');
const page = doc.spreads[at] as BookSpread;
const lockedEl = { ...(page.elements[0] as BookSpread['elements'][number]), locked: true };
const locked = { ...page, elements: [lockedEl, ...page.elements.slice(1)] };
const withLock = { ...doc, spreads: doc.spreads.map((s, i) => (i === at ? locked : s)) };
const next = autoRelayoutSpread(withLock, at, input());
expect(next?.elements).toContainEqual(lockedEl);
});
it('AUTODES-009: the whole book keeps a stored page that holds a lock', () => {
const doc = book();
const at = indexOf(doc, 'entry');
const page = doc.spreads[at] as BookSpread;
const locked = { ...page, elements: page.elements.map((e, i) => (i === 0 ? { ...e, locked: true } : e)) };
const again = autoDesignedBook({ ...doc, spreads: doc.spreads.map((s, i) => (i === at ? locked : s)) }, input(), {
theme: doc.theme,
seed: 9,
});
expect(again.spreads).toContainEqual(locked);
});
it('AUTODES-010: an old Auto layout summary is laid again in a new look', () => {
const doc = book();
const summary = indexOf(doc, 'summary');
const old = doc.spreads[summary] as BookSpread;
const classic = {
...old,
elements: old.elements.map((e) => (e.kind === 'stats' ? { ...e, look: undefined } : e)),
} as BookSpread;
const next = autoRelayoutSpread(
{ ...doc, spreads: doc.spreads.map((s, i) => (i === summary ? classic : s)) },
summary,
input(),
);
const looks = next?.elements.flatMap((e) => (e.kind === 'stats' ? [e.look] : []));
expect(looks?.length).toBeGreaterThan(0);
expect(looks?.every((l) => l && l !== 'classic')).toBe(true);
});
});
+225
View File
@@ -0,0 +1,225 @@
import { layoutAroundLocks } from '../book-layout-locks';
import type { BookCountryLook, BookDocument, BookElement, BookSpread, BookStatsLook } from '../book.schema';
import { designBook, templatedSetting, type BookDesignInput, type TemplatedSetting } from '../design/design-book';
import type { BookDesignDirectivesInput } from '../design/design-directives.schema';
import { ARCHETYPE_KIN, type TemplatePickCtx } from '../design/design-template-pick';
import { layEntryPage, type LaidPage, type PageStory } from '../design/design-templated';
import {
COUNTRY_CYCLE,
SUMMARY_CYCLE,
composeTravelCountries,
composeTravelSummary,
coverOnTemplate,
} from '../design/design-templated-framing';
import { decoratedTemplatesFor } from '../templates/template-catalogue';
import { templatePhotoSlots } from '../templates/template-format';
import { matchDecoratedTemplate } from '../templates/template-match';
import { bookThemeOf } from '../theme/book-theme';
import type { BookTheme, BookThemeId } from '../theme/book-theme.schema';
import { isPortrait } from './auto-elements';
import type { AutoEntry, AutoInput, AutoPhoto } from './auto-types';
/**
* The Studio's Auto layout on the designed templates.
*
* "The whole book" is the design engine laying the book on the Studio's
* decorated templates (`designBook` with `layouts: 'templates'`), in the
* book's own theme, keeping every page that holds a locked element. "This
* spread" lays one page again on another template than the one on screen and
* its neighbours: an entry's page on the next best spread for its photos and
* words, the cover on the next cover of its archetype and then of its kin,
* the summary and the countries in their next look. The old Auto layout
* (`buildBook`, `relayoutSpread`) stays as it was, the design engine's
* classic style.
*/
/** The theme a book without one is laid in: calm, linen, serif. */
export const AUTO_DEFAULT_THEME: BookThemeId = 'fabric-classic';
/** The journey as the Studio's Auto layout takes it: the entries with their stops, and the loose gallery. */
export interface AutoDesignedInput extends AutoInput {
gallery?: readonly AutoPhoto[];
}
export interface AutoDesignedOptions {
/** The book's theme, a recoloured one of its own included; the default theme without one. */
theme?: BookTheme | null;
/** Another seed lays another book; the same one the same. */
seed?: number;
}
const designInput = (input: AutoDesignedInput, theme: BookTheme | null | undefined): BookDesignInput => ({
...input,
candidates: [],
photos: [],
theme: theme ?? bookThemeOf(AUTO_DEFAULT_THEME),
});
const directivesOf = (theme: BookTheme | null | undefined, seed: number | undefined): BookDesignDirectivesInput => ({
style: theme?.id ?? AUTO_DEFAULT_THEME,
layouts: 'templates',
keep: 'locked',
...(seed === undefined ? {} : { seed }),
});
/** The whole book laid on the designed templates over the stored one, its page format kept. */
export function autoDesignedBook(
doc: BookDocument | null,
input: AutoDesignedInput,
opts: AutoDesignedOptions = {},
): BookDocument {
const page = doc?.page ?? input.page;
return designBook(doc, designInput({ ...input, page }, opts.theme), directivesOf(opts.theme, opts.seed)).doc;
}
/** What "this spread" can lay again: an entry's page, the cover, the summary, the countries; null for the rest. */
export type AutoRelayoutKind = 'entry' | 'cover' | 'summary' | 'countries';
/** The kind of page a spread of the book is for "this spread", or null for one the engine did not make. */
export function autoRelayoutKind(spread: BookSpread | undefined): AutoRelayoutKind | null {
if (!spread) return null;
if (spread.role === 'cover') return 'cover';
if (spread.role !== 'inner') return null;
if (spread.entryId != null) return 'entry';
const has = (kind: BookElement['kind']) => spread.elements.some((e) => e.kind === kind);
if (has('stats') && has('map')) return 'summary';
if (has('countries') && !has('stats')) return 'countries';
return null;
}
/** The photos a page holds, on it and parked with it, in order. */
function photosOn(spread: BookSpread, entries: readonly AutoEntry[]): AutoPhoto[] {
const known = new Map(entries.flatMap((e) => e.photos.map((p) => [p.photoId, p] as const)));
return [...spread.elements, ...spread.parked].flatMap((e) =>
e.kind === 'photo' && e.photoId != null
? [known.get(e.photoId) ?? { photoId: e.photoId, width: null, height: null }]
: [],
);
}
/** The story a page of this entry sets, and whether it is the whole of it. */
function storyOn(spread: BookSpread, entryId: number): PageStory {
for (const e of spread.elements) {
if (e.kind === 'text' && e.binding?.source === 'entry.story' && e.binding.entryId === entryId && e.text.trim()) {
return { words: e.text, whole: !e.overridden };
}
}
return null;
}
/** The templates of the page before and after this one, never to be laid here. */
function neighbours(doc: BookDocument, index: number): string[] {
return [doc.spreads[index - 1], doc.spreads[index + 1]].flatMap((s) =>
s ? (matchDecoratedTemplate(s, doc.page)?.id ?? []) : [],
);
}
/** Templates tried for a page whose story would be cut, before one that cuts it is taken. */
const STORY_TRIES = 4;
/** An entry's page on the best template but the one on screen and its neighbours'. */
function relayEntry(s: TemplatedSetting, doc: BookDocument, index: number, free: BookSpread): BookSpread | null {
const entry = s.placed.find((e) => e.id === free.entryId);
const own = doc.spreads[index];
if (!entry || !own) return null;
const current = matchDecoratedTemplate(own, doc.page)?.id;
const around = neighbours(doc, index);
const avoid = [...(current ? [current] : []), ...around];
const page = { photos: photosOn(free, s.placed), story: storyOn(free, entry.id), entries: s.placed };
const pick = (more: readonly string[]): TemplatePickCtx => ({
archetype: s.archetype,
page: s.c.page,
theme: s.c.look.theme,
history: around.slice(0, 1),
calm: s.calm,
seed: s.c.directives.seed ?? 0,
key: `relay:${entry.id}`,
avoid: [...avoid, ...more],
});
let laid: LaidPage | null = null;
const tried: string[] = [];
for (let n = 0; n < STORY_TRIES; n++) {
const next = layEntryPage(s.c, entry, page, pick(tried));
laid ??= next;
if (!next.rest || !next.template) {
laid = next;
break;
}
tried.push(next.template.id);
}
if (!laid) return null;
return { ...s.finish(laid.spread, !!laid.template).spread, entryId: entry.id };
}
/** The cover on the next cover template of its archetype, then of its kin, for the photos there are. */
function relayCover(s: TemplatedSetting, doc: BookDocument, index: number, input: AutoDesignedInput) {
const own = doc.spreads[index];
if (!own) return null;
const others = doc.spreads.flatMap((sp) => (sp === own ? [] : photosOn(sp, s.placed)));
const photos = [...new Map([...photosOn(own, s.placed), ...others].map((p) => [p.photoId, p])).values()].slice(0, 8);
const order = [s.archetype, ...ARCHETYPE_KIN[s.archetype]].flatMap((a) => decoratedTemplatesFor('single', a));
const fits = order.filter(
(t) => templatePhotoSlots(t) <= photos.length && (photos.length > 0 || !templatePhotoSlots(t)),
);
if (!fits.length) return null;
const current = matchDecoratedTemplate(own, doc.page)?.id;
const at = fits.findIndex((t) => t.id === current);
// A cover the engine cannot read takes the first of the archetype's, a portrait one for a tall photo first.
const tall = photos[0] && isPortrait(photos[0]) ? fits.find((t) => /-portrait$|^cutout-/.test(t.id)) : undefined;
const tpl = at >= 0 ? fits[(at + 1) % fits.length] : (tall ?? fits[0]);
if (!tpl) return null;
const ownTitle = doc.title !== input.title ? doc.title : undefined;
const words = { title: input.title, subtitle: input.subtitle, ownTitle };
return s.finish(coverOnTemplate(s.c, words, { photos, entries: s.placed }, tpl), true).spread;
}
/** The look after this one in its cycle; the first for a look outside it (the classic one). */
function nextLook<T extends string>(cycle: readonly T[], look: string | undefined): T {
const at = look ? cycle.indexOf(look as T) : -1;
return cycle[(at + 1) % cycle.length] as T;
}
/** The summary or the countries in their next look. */
function relayTravel(s: TemplatedSetting, free: BookSpread, kind: 'summary' | 'countries'): BookSpread | null {
if (kind === 'summary') {
const stats = free.elements.find((e) => e.kind === 'stats' && e.metrics.length > 2);
const look = stats?.kind === 'stats' ? stats.look : undefined;
const next = composeTravelSummary(s.c, nextLook<BookStatsLook>(SUMMARY_CYCLE, look));
return next ? s.finish(next).spread : null;
}
const country = free.elements.find((e) => e.kind === 'countries');
const look = country?.kind === 'countries' ? country.look : undefined;
const next = composeTravelCountries(s.c, nextLook<BookCountryLook>(COUNTRY_CYCLE, look));
return next ? s.finish(next).spread : null;
}
/**
* One page of the book laid again on the designed templates, around its
* locked elements, its id kept: null for a page the engine did not make
* (stops, the first and last page, the back, a page made by hand), or when
* the page and its locks would not fit.
*/
export function autoRelayoutSpread(
doc: BookDocument,
index: number,
input: AutoDesignedInput,
opts: Pick<AutoDesignedOptions, 'seed'> = {},
): BookSpread | null {
const spread = doc.spreads[index];
const kind = autoRelayoutKind(spread);
if (!spread || !kind) return null;
const setting = templatedSetting(
doc,
designInput({ ...input, page: doc.page }, doc.theme),
directivesOf(doc.theme, opts.seed),
);
return layoutAroundLocks(spread, (free) => {
const next =
kind === 'entry'
? relayEntry(setting, doc, index, free)
: kind === 'cover'
? relayCover(setting, doc, index, input)
: relayTravel(setting, free, kind);
return next ? { ...next, id: spread.id } : null;
});
}
+54 -10
View File
@@ -28,7 +28,13 @@ 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 { TEMPLATED_ENTRY_PHOTOS, choosePhotos, shareGalleryByDay, type BookDesignLeftOut } from './design-photos';
import {
TEMPLATED_ENTRY_PHOTOS,
choosePhotos,
shareGalleryByDay,
type BookDesignLeftOut,
type DesignPhotoChoice,
} from './design-photos';
import { repairSpread } from './design-repair';
import { designGrid, designLook, paperBackground, type BookDesignLook } from './design-style';
import { archetypeOfTheme } from './design-template-pick';
@@ -171,7 +177,7 @@ function travelOf(input: BookDesignInput, entries: readonly AutoEntry[]): BookTr
}
/** The beat a page of the Auto layout plays, read from what it holds. */
export function classicBeat(spread: BookSpread): BookDesignBeat {
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);
@@ -243,7 +249,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);
if (directives.layouts === 'templates') return templatedDesign(doc, input, raw);
const newId = input.newId ?? bookElementId;
const page = pageFor(doc, input, directives);
@@ -466,12 +472,33 @@ function templatedPages(
* 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(
/** What a book on the designed templates is laid with: its context, archetype, entries and photos. */
export interface TemplatedSetting {
c: ComposeCtx;
archetype: BookCoverArchetype;
/** The book counts as calm for template choice. */
calm: boolean;
/** The entries in date order, the gallery shared out to them. */
placed: AutoEntry[];
/** Loose photos no entry had room for. */
rest: AutoPhoto[];
choice: DesignPhotoChoice;
/** A page finished as the design finishes it: a templated one repaired, a composed one linked to the theme too. */
finish: (spread: BookSpread, templated?: boolean) => { spread: BookSpread; findings: BookFinding[] };
}
/**
* The setting of a book laid on the designed templates, read from the
* stored book, the journey and the directives: what the whole book is laid
* with, and what laying one of its pages again needs.
*/
export function templatedSetting(
doc: BookDocument | null,
input: BookDesignInput,
directives: BookDesignDirectives,
entries: AutoEntry[],
): BookDesignResult {
raw: BookDesignDirectivesInput,
): TemplatedSetting {
const directives = withBriefTheme(bookDesignDirectivesSchema.parse(raw));
const entries = inScope(input.entries, directives.scope);
const newId = input.newId ?? bookElementId;
const page = pageFor(doc, input, directives);
const shared = shareGalleryByDay(byDate(entries), input.gallery ?? [], input.candidates);
@@ -497,9 +524,6 @@ function templatedDesign(
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) => {
@@ -507,6 +531,26 @@ function templatedDesign(
const weighted = { ...fixed.spread, elements: fixed.spread.elements.map(withRealWeight) };
return { spread: templated ? weighted : unlinkDrifted(weighted, look.theme), findings: fixed.findings };
};
return { c, archetype, calm, placed, rest: shared.rest, choice, finish };
}
/**
* 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,
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 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) }));
const leftOut = [...choice.leftOut, ...inner.leftOut];
return templatedResult(doc, input, { c, done, finish, placed, leftOut, notes: inner.notes });
@@ -83,7 +83,7 @@ export function archetypeOfTheme(theme: BookTheme | null | undefined): BookCover
}
/** How loud a template is, from its tags: a loud poster, a lively collage, a graphic grid, a calm page. */
export type TemplateEnergy = 'loud' | 'lively' | 'medium' | 'calm';
type TemplateEnergy = 'loud' | 'lively' | 'medium' | 'calm';
/** A template's energy. Graphic pages, and the few drawn without a word for it, count as medium. */
export function templateEnergy(tpl: Pick<BookDecoratedTemplate, 'tags'>): TemplateEnergy {
@@ -150,7 +150,7 @@ export function bodyCapacity(tpl: BookDecoratedTemplate, page: BookPageSetup, th
}
/** The parts a page of photos is cut into: how many photos each takes, the first one carrying the words. */
export interface PhotoChunking {
interface PhotoChunking {
sizes: number[];
/** Photos that find no frame: noted and parked, never lost. */
parked: number;
@@ -346,7 +346,7 @@ export function pickInnerTemplate(chunk: TemplateChunk, ctx: TemplatePickCtx): B
}
/** What a cover may be laid with: how many photos there are, and whether the cover photo stands tall. */
export interface CoverPick {
interface CoverPick {
archetype: BookCoverArchetype;
photos: number;
portrait: boolean;
@@ -34,7 +34,7 @@ import { pickCoverTemplate } from './design-template-pick';
*/
/** The travel looks of this book, an element of a size centred on the right page unless a frame is named. */
export function looksOf(c: ComposeCtx): BookTravelLooks | null {
function looksOf(c: ComposeCtx): BookTravelLooks | null {
const stats = c.travel.stats;
if (!stats) return null;
const box = liveBox(c, 'right');
@@ -119,9 +119,19 @@ export function composeTemplatedCover(
avoid,
});
if (!tpl) return { spread: composeArchetypeCover(c, archetype, words, material), template: null };
return { spread: coverOnTemplate(c, words, material, tpl), template: tpl.id };
}
/** The cover laid on this cover template, its title and subtitle bound to the journey's. */
export function coverOnTemplate(
c: ComposeCtx,
words: CoverWords,
material: CoverMaterial,
tpl: BookDecoratedTemplate,
): BookSpread {
const laid = pourTemplate(c, coverSeed(c, words, material, material.photos.length), tpl, material.entries);
// The photos the cover has no frame for are the book's own pages', not the cover's to park.
return { spread: { ...laid, parked: [] }, template: tpl.id };
return { ...laid, parked: [] };
}
// ── the journey at a glance ───────────────────────────────────────────────
@@ -136,7 +146,7 @@ export function summaryLookFor(archetype: BookCoverArchetype): BookStatsLook {
export const SUMMARY_CYCLE: readonly BookStatsLook[] = ['poster', 'tiles', 'sign'];
/** Whether the journey adds up to a summary worth a spread: a way of two stops at least. */
export const hasSummary = (stats: JourneyStats | null): stats is JourneyStats => !!stats && stats.points.length >= 2;
const hasSummary = (stats: JourneyStats | null): stats is JourneyStats => !!stats && stats.points.length >= 2;
/**
* The journey at a glance: its map with a photo at every stop on the left
+54 -25
View File
@@ -46,14 +46,14 @@ import {
* found no entry with room close the book on pages of more moments.
*/
export interface TemplatedInnerResult {
interface TemplatedInnerResult {
spreads: DesignedSpread[];
leftOut: BookDesignLeftOut[];
notes: string[];
}
/** What the inner pages are laid with beside the composer's context. */
export interface TemplatedPlan {
interface TemplatedPlan {
archetype: BookCoverArchetype;
/** The book counts as calm for template choice: its busy pages stay few. */
calm: boolean;
@@ -92,7 +92,7 @@ function storyOf(c: ComposeCtx, entry: AutoEntry | undefined): string {
* 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(
function entrySeedSpread(
c: ComposeCtx,
entry: AutoEntry,
photos: readonly AutoPhoto[],
@@ -131,9 +131,9 @@ function cutToFrame(el: BookTextElement, words: string): string {
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.
* A laid page with its story cut to its frame where it runs over: marked as
* no longer following 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);
@@ -169,36 +169,64 @@ interface Laying {
out: DesignedSpread[];
}
/** One page laid on a template, or composed where none takes it; its story's rest handed back. */
/** A story to set on a page: its words, and whether they are the whole of the entry's. */
export type PageStory = { words: string; whole: boolean } | null;
/** One page as it is laid: the spread, the template it was laid on (none for a composed one) and the story's rest. */
export interface LaidPage {
spread: BookSpread;
template: BookDecoratedTemplate | null;
rest: string;
}
/**
* One page of an entry laid on the template that suits it best, in the
* book's theme, or composed as a page of words where none takes it; the
* story cut to its frame, its rest handed back for the next page.
*/
export function layEntryPage(
c: ComposeCtx,
entry: AutoEntry,
page: { photos: readonly AutoPhoto[]; story: PageStory; entries: readonly AutoEntry[] },
pick: TemplatePickCtx,
): LaidPage {
const mappable = (entry.lat != null && entry.lng != null) || !!c.travel.stats?.points.length;
const chunk = { photos: page.photos, story: page.story?.words ?? '', mappable };
const tpl = pickInnerTemplate(chunk, pick);
if (!tpl) {
const fitted = fittedStory(c, composeWordsPage(c, entry, wordsColumn(c, entry, page.story)), entry);
return { ...fitted, template: null };
}
const laid = tidied(pourTemplate(c, entrySeedSpread(c, entry, page.photos, page.story), tpl, page.entries));
return { ...fittedStory(c, laid, entry), template: tpl };
}
/** One page of the book laid and added; the rest of its story handed back. */
function layPage(
l: Laying,
entry: AutoEntry,
photos: readonly AutoPhoto[],
story: { words: string; whole: boolean } | null,
story: PageStory,
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;
const laid = layEntryPage(l.c, entry, { photos, story, entries: l.entries }, { ...l.base, history: l.history, key });
if (laid.template) l.history.push(laid.template.id);
l.out.push({
spread: laid.spread,
beat: laid.template ? beatOf(laid.template, photos.length) : 'story',
chapter,
findings: [],
...(laid.template ? { template: laid.template.id } : {}),
});
return laid.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) {
function wordsColumn(c: ComposeCtx, entry: AutoEntry, story: PageStory) {
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 });
@@ -260,7 +288,8 @@ function chapterPages(l: Laying, chapter: DesignChapter, notes: string[]) {
...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`);
const what = parked.length === 1 ? '1 photo' : `${parked.length} photos`;
notes.push(`${what} 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`);
@@ -313,7 +342,7 @@ function withinCap(body: DesignedSpread[], keep: number, leftOut: BookDesignLeft
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) {
for (const e of [...d.spread.elements, ...d.spread.parked]) {
if (e.kind === 'photo' && e.photoId != null) leftOut.push({ photoId: e.photoId, reason: 'page_budget' });
}
}
+1 -1
View File
@@ -116,7 +116,7 @@ export interface BookTravelMapCard {
pins?: BookMapElement['pinStyle'];
}
export interface BookTravelLooksInput {
interface BookTravelLooksInput {
page: BookPageSetup;
stats: JourneyStats;
/** The journey's figures, way and words, as the engine's factories read them. */
+1
View File
@@ -194,6 +194,7 @@ export * from './book/book-archetypes';
export * from './book/book-lockups.data';
export * from './book/auto/auto-types';
export * from './book/auto/auto-book';
export * from './book/auto/auto-designed';
export * from './book/auto/auto-templates.data';
export * from './book/auto/auto-template-apply';
export * from './book/design/design-brief.schema';