feat(studio): auto layout lists bare stops on a words-only template of the book's style

Stops nobody wrote about or photographed were set as a small list on a plain
spread, the old composed page. They now take a words-only template of the
book's styles: the list's label as its title, the stops as its story grouped
by day, as many as its story frame holds, and the day in the template's date
where all of them share one. The words go to their roles by name, since a
short list that opens on a date would otherwise read as a date line. The
composed list stays as the fallback where no template takes it.
This commit is contained in:
Maurice
2026-10-24 20:28:37 +02:00
parent 3f2da7a867
commit e777118272
4 changed files with 189 additions and 89 deletions
@@ -13,7 +13,7 @@ import {
import type { JourneyStats } from '../journey-stats.schema';
import { applyDecoratedTemplate } from '../templates/template-apply';
import type { BookDecoratedTemplate } from '../templates/template-format';
import { bookTemplateWords } from '../templates/template-words';
import { bookTemplateWords, type BookTemplateWords } from '../templates/template-words';
import { themedElement } from '../theme/book-theme-apply';
import { bookTravelLooks, BOOK_TRAVEL_COUNTRY_ACCENT, type BookTravelLooks } from '../travel/book-travel-looks';
import { frameOf, liveBox, photoEl, scaled, textEl, type ComposeCtx } from './design-compose';
@@ -79,14 +79,18 @@ function coverSeed(c: ComposeCtx, words: CoverWords, material: CoverMaterial, sl
return { id: c.newId('sp'), role: 'cover', background: null, elements: els, parked: [], entryId: null };
}
/** A page laid on a decorated template in the book's theme, its empty roles taking the journey's words. */
/**
* A page laid on a decorated template in the book's theme, its empty roles
* taking the journey's words, or the words `given` for a role by name.
*/
export function pourTemplate(
c: ComposeCtx,
seed: BookSpread,
tpl: BookDecoratedTemplate,
entries: readonly AutoEntry[],
given: BookTemplateWords = {},
) {
const words = bookTemplateWords(seed, { bookTitle: '', locale: c.locale, entries });
const words = { ...bookTemplateWords(seed, { bookTitle: '', locale: c.locale, entries }), ...given };
return applyDecoratedTemplate(seed, tpl, c.page, {
theme: c.look.theme,
travel: c.travel,
@@ -1,115 +1,121 @@
{
"A/fabric-classic/head": "74bb0439e4575ed7",
"A/fabric-classic/plan": "2e813091b20151b7",
"A/fabric-classic/spread/0": "8f7e1afe57460ae6",
"A/fabric-classic/plan": "95b55b052b9ff07b",
"A/fabric-classic/spread/0": "a4d090166287cbe5",
"A/fabric-classic/spread/1": "aa637b0de2518bf5",
"A/fabric-classic/spread/10": "e69ba5b8577eaccd",
"A/fabric-classic/spread/11": "bc296929478ee85f",
"A/fabric-classic/spread/12": "3053f53078476b2a",
"A/fabric-classic/spread/13": "e636be9ac1bfac5c",
"A/fabric-classic/spread/14": "9c9506b1446385fa",
"A/fabric-classic/spread/15": "f9c0ddb3332943e1",
"A/fabric-classic/spread/16": "a63442848952fded",
"A/fabric-classic/spread/17": "31aff9e6d65d8da4",
"A/fabric-classic/spread/18": "bf897175ec7245e3",
"A/fabric-classic/spread/19": "bf8169ba3d96440e",
"A/fabric-classic/spread/10": "febf8fa0b4cd3aef",
"A/fabric-classic/spread/11": "26253da4f1f688a0",
"A/fabric-classic/spread/12": "4dbf29b2c63f9231",
"A/fabric-classic/spread/13": "5deb8ece6f27e514",
"A/fabric-classic/spread/14": "db4f2ede14bb6023",
"A/fabric-classic/spread/15": "7d011356ea8fc70f",
"A/fabric-classic/spread/16": "205c5f0f86f90cb4",
"A/fabric-classic/spread/17": "de849a3986a487c1",
"A/fabric-classic/spread/18": "22b9cb0c78e0ff66",
"A/fabric-classic/spread/19": "9df44ff280b76f64",
"A/fabric-classic/spread/2": "8278d3c2507527f9",
"A/fabric-classic/spread/20": "7233b4dfd372d1e1",
"A/fabric-classic/spread/21": "5bb02502fc0ad047",
"A/fabric-classic/spread/22": "61f06e7b8b03a23e",
"A/fabric-classic/spread/20": "972976260c457cd3",
"A/fabric-classic/spread/21": "9102a5fa147693fe",
"A/fabric-classic/spread/22": "0bc9c54f6ee8b3e9",
"A/fabric-classic/spread/23": "b11b5b3fb34a34eb",
"A/fabric-classic/spread/24": "a55189e7bfd3934d",
"A/fabric-classic/spread/3": "2ceb4890ba95c249",
"A/fabric-classic/spread/4": "7f5a904449283690",
"A/fabric-classic/spread/5": "61024db654103dd4",
"A/fabric-classic/spread/6": "8117ccbdc6dc7f1e",
"A/fabric-classic/spread/7": "da36e9e35e753415",
"A/fabric-classic/spread/8": "198e5a3ff4c85366",
"A/fabric-classic/spread/9": "516fc6e34b8a200e",
"A/fabric-classic/spread/4": "7743a5784df1d3e5",
"A/fabric-classic/spread/5": "dbbbfb7bedd90da2",
"A/fabric-classic/spread/6": "c36b6c75a4254fad",
"A/fabric-classic/spread/7": "b7694f72f7cbe2bb",
"A/fabric-classic/spread/8": "bdafe534fcbe56f0",
"A/fabric-classic/spread/9": "c8a03b3fbdb60114",
"A/midnight/head": "ce0d82281cbc3e1f",
"A/midnight/plan": "59eb38db863e4b47",
"A/midnight/spread/0": "eb607a0fc5b592ac",
"A/midnight/plan": "7fde812131c9b570",
"A/midnight/spread/0": "409beb5fbb1accce",
"A/midnight/spread/1": "d9f5d78659332b45",
"A/midnight/spread/10": "691dd9203db87133",
"A/midnight/spread/11": "4cc728884c5e85f9",
"A/midnight/spread/12": "f2e0ed48e8d5b738",
"A/midnight/spread/13": "a05050ef05c8a754",
"A/midnight/spread/14": "b9c738456d6cbf49",
"A/midnight/spread/15": "aceb06a5507b46c0",
"A/midnight/spread/16": "f2761e7e09ff254b",
"A/midnight/spread/17": "e4d8bf042e9f24d2",
"A/midnight/spread/18": "3daa8f2074ce601b",
"A/midnight/spread/19": "e23937f6deffb3b2",
"A/midnight/spread/10": "53f13a2ec3a3c5f4",
"A/midnight/spread/11": "888c0c66730d32dc",
"A/midnight/spread/12": "8d6c62bc4ee5ed1d",
"A/midnight/spread/13": "fc0673a56f5b4d4b",
"A/midnight/spread/14": "33db0be88fd469a7",
"A/midnight/spread/15": "72105c668088bc4c",
"A/midnight/spread/16": "bd11f4dc18e03108",
"A/midnight/spread/17": "2e7a0f09822c3556",
"A/midnight/spread/18": "f4264c3ee121b8d1",
"A/midnight/spread/19": "4f27a48a457d95bb",
"A/midnight/spread/2": "b00839cd0dc1451b",
"A/midnight/spread/20": "bd539efa35e36bfc",
"A/midnight/spread/21": "38d76ca81db84db0",
"A/midnight/spread/20": "dff6e523732c8481",
"A/midnight/spread/21": "26d666dde7be6641",
"A/midnight/spread/22": "eba329fd185958b3",
"A/midnight/spread/23": "9664fbd24505a632",
"A/midnight/spread/3": "742dfb4105263101",
"A/midnight/spread/4": "98f7916fe80a1243",
"A/midnight/spread/5": "7b262b39dd640935",
"A/midnight/spread/6": "ed8bd8657c310997",
"A/midnight/spread/7": "4eb6a338f0d7fc68",
"A/midnight/spread/8": "e2d2c7791a83e1a1",
"A/midnight/spread/9": "05c31075e8a1f631",
"A/midnight/spread/4": "ea9613c918951560",
"A/midnight/spread/5": "8aa571b4d51a5234",
"A/midnight/spread/6": "2275359a3c03f457",
"A/midnight/spread/7": "3ef3c265b38598e2",
"A/midnight/spread/8": "3a77eec4f6c9f860",
"A/midnight/spread/9": "5bdff7027bea103c",
"A/riso/head": "7d43d0900fbe0f57",
"A/riso/plan": "7e50f5d2a0b80e0e",
"A/riso/spread/0": "657be2c47bd8ad61",
"A/riso/plan": "f802ad819f1fb262",
"A/riso/spread/0": "1f4502d29eca9017",
"A/riso/spread/1": "8de92788de29277b",
"A/riso/spread/10": "8f8039b3c8548358",
"A/riso/spread/11": "5dc1c12abcf36f29",
"A/riso/spread/12": "84b6af0e7a851a60",
"A/riso/spread/13": "c0aff5dc75e09532",
"A/riso/spread/14": "3d8e67e0c536334c",
"A/riso/spread/15": "29101a4941a4e9c5",
"A/riso/spread/10": "2a92b79192575cd0",
"A/riso/spread/11": "5785a9cc59ddd735",
"A/riso/spread/12": "cd4f6004153fe694",
"A/riso/spread/13": "b1f87a92cb7f775c",
"A/riso/spread/14": "873bf5f7a8404fe4",
"A/riso/spread/15": "c1aab0214328ef3b",
"A/riso/spread/16": "d58f70c381d9f6ae",
"A/riso/spread/17": "00caa407cb2a0677",
"A/riso/spread/18": "ed1fbeca6e577132",
"A/riso/spread/19": "323e6d081b913240",
"A/riso/spread/18": "ed41aca064456614",
"A/riso/spread/19": "36647f06c594e715",
"A/riso/spread/2": "5c9a93de9e6467d6",
"A/riso/spread/20": "84f564cff0148bc3",
"A/riso/spread/21": "cc4b2d6f3ad38662",
"A/riso/spread/22": "f696e1aad9a5e0f6",
"A/riso/spread/20": "8518ca61c110fd8a",
"A/riso/spread/21": "d4ba78b2ffd5ae04",
"A/riso/spread/22": "de5116307e12ce61",
"A/riso/spread/23": "a748ada7a028818e",
"A/riso/spread/24": "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": "ce87c24b384a8bf0",
"A/riso/spread/9": "6e02b9784f9d5791",
"B/fabric-classic/head": "b7377b9e48255d7b",
"B/fabric-classic/plan": "121194e82b626f79",
"A/riso/spread/4": "93b84be6e48097b5",
"A/riso/spread/5": "6d5ae8e80c2f0d21",
"A/riso/spread/6": "e7f5c928785be48d",
"A/riso/spread/7": "b672fd04b61ea7b9",
"A/riso/spread/8": "d9e84bbad744e450",
"A/riso/spread/9": "017d695be27dc988",
"B/fabric-classic/head": "6af0d11dd09eae78",
"B/fabric-classic/plan": "cae85517611abbca",
"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/3": "110c2e11d8079468",
"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": "487b2440a33b4990",
"B/fabric-classic/spread/5": "5b0cb3424c4b894c",
"B/fabric-classic/spread/6": "28fec4ed0f8fd482",
"B/fabric-classic/spread/7": "9d68ef6e7d8b5f5a",
"B/fabric-classic/spread/8": "b11b5b3fb34a34eb",
"B/fabric-classic/spread/9": "3194c096e5c7d16f",
"B/midnight/head": "cfd309bdf9e41811",
"B/midnight/plan": "b3e4cb03c2d620a4",
"B/midnight/spread/0": "7fdd20dcda48881f",
"B/midnight/spread/1": "d9f5d78659332b45",
"B/midnight/spread/10": "db97ebe8475e0371",
"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/midnight/spread/3": "6e3e57121d8b8683",
"B/midnight/spread/4": "468100c3bebad30d",
"B/midnight/spread/5": "ca669ba76ca4d9bd",
"B/midnight/spread/6": "60b4cb974e35c55d",
"B/midnight/spread/7": "46fe0841298e4f7b",
"B/midnight/spread/8": "25e4510c5fb81817",
"B/midnight/spread/9": "eba329fd185958b3",
"B/riso/head": "69a6c3112de2a047",
"B/riso/plan": "d8906a53b3532db8",
"B/riso/spread/0": "2bbd6e6b4e6d1b4d",
"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",
"B/riso/spread/3": "1fc8aedc7486cbeb",
"B/riso/spread/4": "311c9fcde888d578",
"B/riso/spread/5": "51562d8f08f46011",
"B/riso/spread/6": "c114a720b5c5beff",
"B/riso/spread/7": "6f4c43c7b5f1dbc3",
"B/riso/spread/8": "a748ada7a028818e",
"B/riso/spread/9": "877657cfa95e3cac",
"C/fabric-classic/head": "ddd21691c506a600",
"C/fabric-classic/plan": "c31dcaa9b3603acc",
"C/fabric-classic/spread/0": "92d21ac9d8e866df",
@@ -9,12 +9,13 @@ import {
} from '../book.schema';
import { pageFindings } from '../describe-spread';
import { findDecoratedTemplate } from '../templates/template-catalogue';
import { templatePhotoSlots } from '../templates/template-format';
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 { ARCHETYPE_KIN, THEME_ARCHETYPE } from './design-template-pick';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
@@ -331,6 +332,32 @@ describe('the book on the designed templates', () => {
);
});
it("AUTODSN-016: bare stops are listed on a words-only template of the book's styles, every name in its story", () => {
const base = parity.A as Fixture;
const entries = base.input.entries.map((e) => ({ ...e, story: null, photos: [] }));
const input = { ...base.input, entries, coverPhotoId: null, candidates: [], photos: [] };
for (const theme of THEMES) {
const { plan, doc } = laid(input, theme);
const lists = plan.filter((r) => r.beat === 'stations');
expect(lists.length, theme).toBeGreaterThan(0);
const words: string[] = [];
for (const r of lists) {
const tpl = findDecoratedTemplate(r.template ?? '');
expect(tpl && templatePhotoSlots(tpl), `${theme} ${r.template}`).toBe(0);
expect([THEME_ARCHETYPE[theme], ...ARCHETYPE_KIN[THEME_ARCHETYPE[theme]]]).toContain(tpl?.archetype);
const s = doc.spreads.find((x) => x.id === r.spreadId) as BookSpread;
const texts = s.elements.flatMap((e) => (e.kind === 'text' ? [e.text] : []));
expect(texts, theme).toContain(input.stationsLabel);
words.push(...texts);
}
const listed = words.join(' ');
for (const e of entries) {
const name = e.title || e.location || '';
if (name) expect(listed, `${theme} ${name}`).toContain(name);
}
}
});
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);
+65 -2
View File
@@ -1,9 +1,11 @@
import type { AutoEntry, AutoPhoto } from '../auto/auto-types';
import type { BookCoverArchetype } from '../book-archetypes';
import { DESIGN_TEXT_HEADROOM, textHeightNeeded } from '../book-findings';
import { formatBookDate } from '../book-text';
import type { BookElement, BookSpread, BookTextElement } from '../book.schema';
import { decoratedTemplatesFor } from '../templates/template-catalogue';
import { templatePhotoSlots, type BookDecoratedTemplate } from '../templates/template-format';
import { templatePhotoSlots, templateRoles, type BookDecoratedTemplate } from '../templates/template-format';
import type { BookTemplateWord } from '../templates/template-words';
import type { BookDesignInput } from './design-book';
import { cutWords, liveBox, photoEl, textEl, type ComposeCtx } from './design-compose';
import { composeStations } from './design-framing';
@@ -308,10 +310,71 @@ function chapterPages(l: Laying, chapter: DesignChapter, notes: string[]) {
}
}
/** The pages of a list of bare stops: as many stations spreads as the stops need. */
/** The most stops one page of the list takes, so a page of them still reads as a list. */
const STOPS_PER_PAGE = 10;
/** Bare stops as a list: under each date the names of the stops of that day, a blank line between days. */
function stopList(c: ComposeCtx, stops: readonly AutoEntry[], dated: boolean): string {
const days: { date: string; names: string[] }[] = [];
for (const e of stops) {
const date = e.date && dated ? formatBookDate(e.date, c.locale) : '';
const name = (e.title || e.location || '').trim();
if (!name) continue;
const day = days[days.length - 1];
if (day && day.date === date) day.names.push(name);
else days.push({ date, names: [name] });
}
return days.map((d) => [d.date, ...d.names].filter(Boolean).join('\n')).join('\n\n');
}
/** The one day all these stops were on, bound to the first of them, as a date role takes it; null without one. */
function oneDay(c: ComposeCtx, stops: readonly AutoEntry[]): BookTemplateWord | null {
const days = new Set(stops.map((e) => e.date ?? ''));
const [day] = days;
const first = stops[0];
if (days.size !== 1 || !day || !first) return null;
return { text: formatBookDate(day, c.locale), binding: { source: 'entry.date', entryId: first.id, value: day } };
}
/**
* One page of bare stops on a words-only template of the book's styles: the
* list's label as its title, the stops as its story (given by role, since a
* short list that opens on a date would read as a date line), as many as its
* story frame holds. Stops of one day set that day in the template's date
* and only their names in the list. Undefined when no template takes a list.
*/
function stationsPage(l: Laying, stops: readonly AutoEntry[], label: string) {
for (let n = Math.min(stops.length, STOPS_PER_PAGE); n > 0; n--) {
const some = stops.slice(0, n);
const key = `stations:${l.out.length}`;
const pick = (list: string) =>
pickInnerTemplate({ photos: [], story: list, mappable: false }, { ...l.base, history: l.history, key });
const tpl = pick(stopList(l.c, some, true));
if (!tpl) return undefined;
const day = templateRoles(tpl).includes('date') ? oneDay(l.c, some) : null;
const list = stopList(l.c, some, !day);
if (bodyCapacity(tpl, l.c.page, l.c.look.theme) < list.length && n > 1) continue;
const words = {
title: { text: label },
body: { text: list },
...(day ? { date: day } : {}),
};
return { spread: tidied(pourTemplate(l.c, innerSpread(l.c, [], null), tpl, l.entries, words)), tpl, placed: n };
}
return undefined;
}
/** The pages of a list of bare stops: as many as the stops need, each on a words-only template where one takes it. */
function stationsPages(l: Laying, chapter: DesignChapter, input: BookDesignInput) {
let rest = chapter.entries;
while (rest.length) {
const page = stationsPage(l, rest, input.stationsLabel);
if (page) {
l.history.push(page.tpl.id);
l.out.push({ spread: page.spread, beat: 'stations', chapter: null, findings: [], template: page.tpl.id });
rest = rest.slice(page.placed);
continue;
}
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: [] });