From fa29ee9ea47affa3f04691e1c540c2482ba5339d Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 23 Oct 2026 04:21:01 +0000 Subject: [PATCH] feat(journey): a designed day sets the story of every one of its entries and says which words find no room A chapter of several entries used to set the story of its first entry only. Its words now flow in reading order from the opener or the panorama onto the pages after it: the first entry's story, its pros and cons, then every further entry's story under a small heading of its title, else its place or date. A quiet page without a photo runs its column on across both pages, and the planner gives a chapter as many pages for words as its stories need. A chapter that tells a story opens on a hero or a panorama only when a page for its words follows. Pros and cons with no room where they fall wait for a later page that takes words. What the pages cannot hold is said once for the whole book, named by entry, ahead of the notes on stories cut to fit, and an entry two chapters share is told once. --- shared/src/book/design/design-book.spec.ts | 253 ++++++++++++++++++++- shared/src/book/design/design-book.ts | 7 +- shared/src/book/design/design-inner.ts | 144 +++++++++--- shared/src/book/design/design-pacing.ts | 137 ++++++++--- shared/src/book/design/design-spreads.ts | 253 +++++++++++++++------ 5 files changed, 649 insertions(+), 145 deletions(-) diff --git a/shared/src/book/design/design-book.spec.ts b/shared/src/book/design/design-book.spec.ts index d2b9f0a8c..39dffa682 100644 --- a/shared/src/book/design/design-book.spec.ts +++ b/shared/src/book/design/design-book.spec.ts @@ -32,10 +32,11 @@ import { describe, expect, it } from 'vitest'; * and excerpts, chapter maps, a locked page kept. Each design is checked for * its structure (the framing in order, every chapter there, the rhythm, the * budget, the caps, a document the save route takes), for being clean (no - * severe and no warn finding left on any page or on the book) and for being - * current (no travel element out of date with the journey once placed). - * The pacing of a chapter (its bursts, the photo beats) is also read straight - * from the plan. + * severe and no warn finding left on any page or on the book), for being + * current (no travel element out of date with the journey once placed) and + * for losing no story unsaid (every entry's words on its pages, or named in + * the notes). The pacing of a chapter (its bursts, the photo beats) is also + * read straight from the plan. */ type Name = 'rich' | 'sparse' | 'nostats'; @@ -148,6 +149,53 @@ function checkCoverage(result: BookDesignResult, entryIds: readonly number[]): v expect(entryIds.filter((id) => !told.has(id))).toEqual([]); } +/** Text with its spacing taken out: so the pieces of a story set on several pages compare with the story. */ +const squashed = (words: string) => words.replace(/\s+/g, ''); + +/** What a design sets of an entry's story, its pieces in page order, the mark a cut adds taken off. */ +function storySet(result: BookDesignResult, entryId: number): string { + let words = ''; + for (const spread of result.doc.spreads) { + for (const el of spread.elements) { + if (el.kind !== 'text' || el.binding?.source !== 'entry.story' || el.binding.entryId !== entryId) continue; + words += el.text.replace(/…$/, ''); + } + } + return squashed(words); +} + +/** The entries whose stories, all of them or a part, the notes say are left out. */ +function storiesNoted(result: BookDesignResult): number[] { + const said = result.notes.filter((n) => /stor(y|ies) of /.test(n) && n.endsWith('left out')); + return said.flatMap((n) => (n.match(/\d+/g) ?? []).map(Number)); +} + +/** + * No story lost unsaid: every entry in scope has its story on its pages in + * full (its excerpt, when excerpts are asked for), or a note names it. + */ +function checkStories( + result: BookDesignResult, + entries: readonly AutoEntry[], + directives: BookDesignDirectivesInput, +): void { + const asked = bookDesignDirectivesSchema.parse(directives); + const story = asked.text?.story ?? 'full'; + const scope = asked.scope?.entryIds; + const noted = new Set(storiesNoted(result)); + for (const entry of entries) { + const words = squashed(entry.story ?? ''); + if (!words || story === 'none' || noted.has(entry.id) || (scope && !scope.includes(entry.id))) continue; + const set = storySet(result, entry.id); + const told = story === 'excerpt' ? set.length > 0 : set.includes(words); + expect({ entry: entry.id, told }).toEqual({ entry: entry.id, told: true }); + } +} + +/** The entries the texts of one source among these elements are bound to, in order. */ +const boundTo = (elements: readonly BookElement[], source: string) => + elements.flatMap((e) => (e.kind === 'text' && e.binding?.source === source ? [e.binding.entryId] : [])); + const substantive = (name: Name, ids?: readonly number[]) => fixtures[name].input.entries .filter((e) => !ids || ids.includes(e.id)) @@ -156,13 +204,14 @@ const substantive = (name: Name, ids?: readonly number[]) => describe('design_book', () => { for (const [label, name, directives] of VARIANTS) { - it(`BOOKDSN-001: designs the ${label} variant with its framing in order, its pages valid and none left with a fault`, () => { + it(`BOOKDSN-001: designs the ${label} variant with its framing in order, its pages valid, none left with a fault and no story lost unsaid`, () => { const fx = input(name); const result = designBook(null, fx, directives); checkStructure(result); checkRhythm(result); checkClean(result, fx.photos); checkCurrent(result, fx.stats); + checkStories(result, fx.entries, directives); }); } @@ -429,11 +478,13 @@ describe('design_book', () => { conLabel: 'Cons', }); } - // Entry 2 opens on a hero and has no quiet page; entry 4 opens on a panorama or on a hero with its quiet - // page; entry 12 has no photo, so its long story runs on across both pages and the list goes under it. + // Entry 2 has one spread for its story, so it opens on the opener and its list goes under its words; entry 4 + // opens on a panorama or on a hero with its quiet page; entry 12 has no photo, so its long story runs on across + // both pages and the list goes under it; entry 11 tells no story and opens on a hero with no page for its list. const more: Partial>> = { 2: { pros: ['Tram 28'] }, 4: { story: 'Over the bridge at dusk.', pros: ['The bridge'], cons: ['The climb'] }, + 11: { pros: ['The surf'] }, 12: { story: 'We walked the old town. '.repeat(130), cons: ['The rain'] }, }; const entries = input('rich').entries.map((e): AutoEntry => ({ ...e, ...more[e.id] })); @@ -443,10 +494,11 @@ describe('design_book', () => { checkStructure(result); checkRhythm(result); checkClean(result, fx.photos); + expect(listsOf(result, 2)).toHaveLength(1); expect(listsOf(result, 4)).toHaveLength(1); expect(listsOf(result, 12)).toHaveLength(1); - expect(listsOf(result, 2)).toEqual([]); - expect(result.notes).toContain('the pros and cons of entry 2 found no room on its pages and are left out'); + expect(listsOf(result, 11)).toEqual([]); + expect(result.notes).toContain('the pros and cons of entry 11 found no room on its pages and are left out'); } }); @@ -487,6 +539,189 @@ describe('design_book', () => { expect(onThree.filter((e) => e.kind === 'list')).toHaveLength(1); expect(days.notes).toContain('the pros and cons of entries 1 and 4 found no room on their pages and are left out'); }); + + it('BOOKDSN-027: sets the story of every entry of a day, each later one under its title, and names the ones the budget cuts', () => { + // Entries 7 and 8 share entry 6's day: one chapter, opened by entry 6, the stories of 7 and 8 after its own. + const moved: Partial>> = { + 7: { date: '2026-05-06' }, + 8: { date: '2026-05-06' }, + }; + const entries = input('rich').entries.map((e): AutoEntry => ({ ...e, ...moved[e.id] })); + const fx = { ...input('rich'), entries }; + const day: BookDesignDirectivesInput = { style: 'minimal', chapters: 'day' }; + const result = designBook(null, fx, day); + checkStructure(result); + checkRhythm(result); + checkClean(result, fx.photos); + checkCurrent(result, fx.stats); + checkStories(result, entries, day); + const story = (id: number) => squashed(entries.find((e) => e.id === id)?.story ?? ''); + for (const id of [6, 7, 8]) expect(storySet(result, id)).toContain(story(id)); + expect(storiesNoted(result).filter((id) => [6, 7, 8].includes(id))).toEqual([]); + // The chapter is entry 6's: its title opens it, the titles of 7 and 8 head their stories, each bound to its entry. + const onDay = result.doc.spreads.filter((s) => s.entryId === 6).flatMap((s) => s.elements); + expect(boundTo(onDay, 'entry.title')).toEqual([6, 7, 8]); + expect([...new Set(boundTo(onDay, 'entry.story'))]).toEqual([6, 7, 8]); + + // With one spread the day opens on the opener, as a hero would leave its words no page; with two, on a hero and + // a quiet page beside a photo. Either holds the stories of 6 and 7 and the start of 8, and the note says the + // rest of 8 is left out. + const scoped: BookDesignDirectivesInput = { ...day, scope: { entryIds: [6, 7, 8] } }; + const tight = designBook(null, fx, { ...scoped, pages: { max: 3 } }); + const roomier = designBook(null, fx, { ...scoped, pages: { max: 4 } }); + const dayBeats = (cut: BookDesignResult) => cut.plan.filter((r) => r.entryId === 6).map((r) => r.beat); + expect(dayBeats(tight)).toEqual(['opener']); + expect(dayBeats(roomier)).toEqual(['hero', 'quiet']); + for (const cut of [tight, roomier]) { + checkStructure(cut); + checkClean(cut, fx.photos); + checkStories(cut, entries, scoped); + for (const id of [6, 7]) expect(storySet(cut, id)).toContain(story(id)); + expect(storySet(cut, 8).length).toBeGreaterThan(0); + expect(cut.notes).toContain('part of the story of entry 8 found no room on its pages and is left out'); + } + // With no stories asked for, no page sets one and no note misses one. + const silent = designBook(null, fx, { ...day, text: { story: 'none' } }); + checkStructure(silent); + expect(silent.doc.spreads.flatMap((s) => boundTo(s.elements, 'entry.story'))).toEqual([]); + expect(storiesNoted(silent)).toEqual([]); + + // The page for words is planned from the whole day's words: entry 7's own story is short, entry 8's is long. + const byId = (id: number) => entries.find((e) => e.id === id) as AutoEntry; + const seven: AutoEntry = { ...byId(7), photos: tall(940, 4) }; + const pair: DesignChapter = { ...chapterOf(seven, []), entries: [seven, byId(8)] }; + const plans = planChapter(pair, 3, newRhythm(), planOf('balanced'), []); + expect(plans.map((p) => p.beat)).toEqual(['opener', 'quiet', 'trio']); + expect(plans.map((p) => p.photos.length)).toEqual([1, 0, 3]); + }); + + it('BOOKDSN-028: opens on a hero or a panorama only when a page for the words follows, gives a long day the pages its words want, and lets pros and cons wait for the next of them', () => { + // A hero sets no words and a panorama's half page is too small to count on: a chapter that tells a story opens + // on either only with a spread after it, one that tells none on either at once. + const bare = input('rich').entries[0] as AutoEntry; + const told: AutoEntry = { ...bare, story: 'A short day by the sea.' }; + const hero: AutoPhoto = { photoId: 10, width: 4000, height: 3000 }; + const pano: AutoPhoto = { photoId: 20, width: 4000, height: 1500 }; + const opened = (entry: AutoEntry, photo: AutoPhoto, spreads: number) => { + const chapter = chapterOf({ ...entry, photos: [photo] }, []); + return planChapter(chapter, spreads, newRhythm(), planOf('balanced'), []).map((p) => p.beat); + }; + expect(opened(told, hero, 1)).toEqual(['opener']); + expect(opened(told, hero, 2)).toEqual(['hero', 'quiet']); + expect(opened(bare, hero, 1)).toEqual(['hero']); + expect(opened(told, pano, 1)).toEqual(['opener']); + expect(opened(told, pano, 2)).toEqual(['pano', 'quiet']); + expect(opened(bare, pano, 1)).toEqual(['pano']); + // So entries 2 and 10, one photo and one spread each, open on the opener, and every story of the journey is set. + for (const style of ['minimal', 'editorial', 'scrapbook'] as const) { + const natural = designBook(null, input('rich'), { style }); + const beatsOf = (id: number) => natural.plan.filter((r) => r.entryId === id).map((r) => r.beat); + expect(beatsOf(2)).toEqual(['opener']); + expect(beatsOf(10)).toEqual(['opener']); + expect(storiesNoted(natural)).toEqual([]); + } + + // A long day: entry 6 with five pros and the start of a long story, 7 and 8 moved to its day, 7 with a story of + // its own. Its words want two pages after the hero, and the natural budget gives them: every story is set whole. + // The first holds the story of 6 with no room under it for the list, so the list waits for the second and heads + // it, before the story of 7. + const long = (input('rich').entries[2] as AutoEntry).story ?? ''; + const more: Partial>> = { + 6: { story: long.slice(0, 1750), pros: ['P1', 'P2', 'P3', 'P4', 'P5'] }, + 7: { date: '2026-05-06', story: long.slice(0, 700) }, + 8: { date: '2026-05-06' }, + }; + const entries = input('rich').entries.map((e): AutoEntry => ({ ...e, ...more[e.id] })); + const fx = { ...input('rich'), entries }; + const day: BookDesignDirectivesInput = { style: 'minimal', chapters: 'day' }; + const result = designBook(null, fx, day); + checkStructure(result); + checkRhythm(result); + checkClean(result, fx.photos); + checkCurrent(result, fx.stats); + const story = (id: number) => squashed(entries.find((e) => e.id === id)?.story ?? ''); + for (const id of [6, 7, 8]) expect(storySet(result, id)).toContain(story(id)); + expect(storiesNoted(result)).toEqual([]); + const beats = result.plan.filter((r) => r.entryId === 6).map((r) => r.beat); + expect(beats.slice(0, 3)).toEqual(['hero', 'quiet', 'quiet']); + const onDay = result.doc.spreads.filter((s) => s.entryId === 6); + expect(onDay.flatMap((s) => s.elements.filter((e) => e.kind === 'list'))).toHaveLength(1); + const second = onDay[2]?.elements ?? []; + expect(second[0]?.kind).toBe('list'); + expect(boundTo(second, 'entry.title')[0]).toBe(7); + expect(result.notes.filter((n) => n.includes('pros and cons of entry 6'))).toEqual([]); + }); + + it('BOOKDSN-029: names the words no page holds ahead of the stories cut to fit, heads a later entry by its place or date, and tells an entry two chapters by place name once', () => { + const long = 'We walked the old town. '.repeat(130); + const budgeted = (ids: number[], max: number, more: Partial>>) => { + const entries = input('rich').entries.map((e): AutoEntry => ({ ...e, ...more[e.id] })); + const directives: BookDesignDirectivesInput = { + style: 'minimal', + scope: { entryIds: ids }, + pages: { max }, + travel: { summary: false, countries: false }, + }; + return designBook(null, { ...input('rich'), entries }, directives); + }; + // One spread for entries 2 and 4: it holds the start of 2's long story and none of 4's. + const one = budgeted([2, 4], 1, { 2: { story: long } }); + const part = one.notes.indexOf('part of the story of entry 2 found no room on its pages and is left out'); + const cut = one.notes.indexOf('the story of entry 2 is cut to fit its page and no longer follows the journal'); + expect(one.notes).toContain('the story of entry 4 found no room on its pages and is left out'); + expect(part).toBeGreaterThanOrEqual(0); + expect(cut).toBeGreaterThan(part); + // A spread each for entries 3 and 8, both with long stories: each holds the start of its own. + const two = budgeted([3, 8], 2, { 3: { story: long }, 8: { story: long } }); + expect(two.notes).toContain('part of the stories of entries 3 and 8 found no room on their pages and is left out'); + // With no page after it, the words go on without a list that finds no room: 4's story follows 3's whole, and + // the note names the list. + const three = (input('rich').entries[2] as AutoEntry).story ?? ''; + const pros = ['P1', 'P2', 'P3', 'P4', 'P5', 'P6']; + const listed = budgeted([3, 4], 1, { 3: { story: three.slice(0, 1000), pros } }); + const four = input('rich').entries.find((e) => e.id === 4); + expect(storySet(listed, 4)).toBe(squashed(four?.story ?? '')); + expect(listed.notes).toContain('the pros and cons of entry 3 found no room on its pages and are left out'); + // An entry with no title, place or date runs on under the story before it, with no heading. + const nameless = budgeted([8, 12], 1, { 12: { title: null, location: null, date: null, story: 'Rain all day.' } }); + const page = nameless.doc.spreads.filter((s) => s.entryId === 8).flatMap((s) => s.elements); + expect(storySet(nameless, 12)).toBe(squashed('Rain all day.')); + expect(boundTo(page, 'entry.title')).toEqual([8]); + expect(boundTo(page, 'entry.date')).toEqual([8]); + + // A later entry of a day with no title is headed by its place, one with neither by its date, each bound to it. + const unnamed: Partial>> = { + 7: { date: '2026-05-06', title: null }, + 8: { date: '2026-05-06', title: null, location: null }, + }; + const entries = input('rich').entries.map((e): AutoEntry => ({ ...e, ...unnamed[e.id] })); + const headed = designBook(null, { ...input('rich'), entries }, { style: 'minimal', chapters: 'day' }); + checkStructure(headed); + checkClean(headed, input('rich').photos); + const onDay = headed.doc.spreads.filter((s) => s.entryId === 6).flatMap((s) => s.elements); + const heading = (id: number, source: string) => + onDay.find((e) => e.kind === 'text' && e.binding?.source === source && e.binding.entryId === id); + expect(boundTo(onDay, 'entry.title')).toEqual([6, 7]); + expect(boundTo(onDay, 'entry.date')).toEqual([6, 8]); + expect(heading(7, 'entry.title')).toMatchObject({ text: 'Oviedo' }); + const dated = { source: 'entry.date', entryId: 8, value: '2026-05-06' }; + expect(heading(8, 'entry.date')).toMatchObject({ binding: dated }); + expect([...new Set(boundTo(onDay, 'entry.story'))]).toEqual([6, 7, 8]); + + // Entry 8's last photos, taken a couple of kilometres on, make a second place of its day: two chapters by place + // open with entry 8, and its story is set in the first of them only. + const apart = (p: PhotoPickCandidate): PhotoPickCandidate => { + if (!p.entryIds.includes(8) || p.photoId <= 120 || p.lat === null) return p; + return { ...p, lat: p.lat + 0.02 }; + }; + const places = { ...input('rich'), candidates: input('rich').candidates.map(apart) }; + const twice = designBook(null, places, { style: 'bold', chapters: 'place' }); + checkStructure(twice); + const opens = twice.plan.filter((r) => r.entryId === 8 && (r.beat === 'opener' || r.beat === 'hero')); + expect(opens).toHaveLength(2); + const eight = input('rich').entries.find((e) => e.id === 8); + expect(storySet(twice, 8)).toBe(squashed(eight?.story ?? '')); + }); }); /** Photos of one shape, ids counted from `from`. */ diff --git a/shared/src/book/design/design-book.ts b/shared/src/book/design/design-book.ts index f108f1766..4d96a66d3 100644 --- a/shared/src/book/design/design-book.ts +++ b/shared/src/book/design/design-book.ts @@ -54,6 +54,11 @@ export interface BookDesignResult { photos: { used: number[]; left_out: BookDesignLeftOut[] }; /** What the repair passes could not fix, severe first. */ findings: BookFinding[]; + /** + * What the design changed or left out, said once each: the book-wide notes + * first (the budget, the words and lists no page holds), then a story cut to + * fit a page, so a reader keeping only the first few keeps what matters most. + */ notes: string[]; } @@ -270,7 +275,7 @@ export function designBook( left_out: [...choice.leftOut, ...inner.leftOut], }, findings: sortBookFindings([...residual, ...bookLevelFindings(result)]), - notes: [...new Set([...c.notes, ...inner.notes])], + notes: [...new Set([...inner.notes, ...c.notes])], }; } diff --git a/shared/src/book/design/design-inner.ts b/shared/src/book/design/design-inner.ts index dffac1259..d991840c2 100644 --- a/shared/src/book/design/design-inner.ts +++ b/shared/src/book/design/design-inner.ts @@ -29,6 +29,8 @@ import { panoFits, tripOf, type Composed, + type FlowItem, + type Words, } from './design-spreads'; /** @@ -134,17 +136,32 @@ function stationsSpreads(c: ComposeCtx, chapter: DesignChapter, input: BookDesig const chapterName = (chapter: DesignChapter) => chapter.label ?? chapter.entries[0]?.title ?? null; /** - * One planned spread, composed; the story still to set is carried from the - * opener to its quiet page, and a quiet page takes the pros and cons of - * `list`, the chapter's entry, when the pages before it had no room for them. + * What a chapter has to say, in reading order: its first entry's story (the + * chapter's title block is that entry's), that entry's pros and cons, then + * the story of every further entry (the other entries of a day, or of the + * chapters the budget combined), each under a small heading of its title. + * With no stories asked for, only the pros and cons. An entry a chapter + * before has `told` (a place may name one entry for two chapters) says + * nothing again. */ -function composePlanned( - c: ComposeCtx, - plan: PlannedSpread, - hand: 'left' | 'right', - carry: string, - list: AutoEntry | null, -): Composed { +function chapterWords(c: ComposeCtx, chapter: DesignChapter, told: ReadonlySet): FlowItem[] { + const stories = (c.directives.text?.story ?? 'full') !== 'none'; + return chapter.entries.flatMap((entry, i): FlowItem[] => { + if (told.has(entry.id)) return []; + const words = stories ? (entry.story ?? '').trim() : ''; + const story: FlowItem[] = words ? [{ kind: 'story', entry, words, begun: false, headed: i > 0 }] : []; + const list: FlowItem[] = i === 0 && bookEntryListItems(entry).length ? [{ kind: 'list', entry }] : []; + return [...story, ...list]; + }); +} + +/** + * One planned spread, composed. The pages that take words (the opener, the + * panorama, a quiet page) set what they can of the chapter's `words` and hand + * back the rest; a hero and the photo spreads take none, so the words wait + * for the next page that does. + */ +function composePlanned(c: ComposeCtx, plan: PlannedSpread, hand: 'left' | 'right', words: Words): Composed { const entry = plan.entry; const entryId = plan.chapter.entries[0]?.id ?? null; const [first, ...more] = plan.photos; @@ -153,10 +170,10 @@ function composePlanned( if (entry && first) return composeHero(c, entry, first, hand); break; case 'pano': - if (entry && first && panoFits(c, first)) return composePano(c, entry, first, more, hand); + if (entry && first && panoFits(c, first)) return composePano(c, entry, first, more, hand, words); break; case 'quiet': - return composeQuiet(c, entry, carry, first ?? null, hand, list); + return composeQuiet(c, words, first ?? null, hand, entryId); case 'duo': return composeDuo(c, plan.photos, hand, entryId); case 'trio': @@ -166,32 +183,48 @@ function composePlanned( default: break; } - if (entry) return composeOpener(c, entry, first ?? null, hand); + if (entry) return composeOpener(c, entry, first ?? null, hand, words); return composeGrid(c, plan.photos, hand, entryId); } -/** A chapter's spreads, composed from its plan, the photo page alternating across the book. */ -function chapterSpreads(c: ComposeCtx, chapter: DesignChapter, plans: readonly PlannedSpread[], start: number) { - const out: DesignedSpread[] = []; - const entry = chapter.entries[0]; - let carry = ''; - // The entry whose pros and cons still look for a page: none once they are set, or when it has none. - let unlisted = entry && bookEntryListItems(entry).length ? entry : null; +/** The entries whose stories a page sets to the end: those in its `flow` that the words it hands on, `rest`, lack. */ +function endedOn(flow: readonly FlowItem[], rest: readonly FlowItem[]): number[] { + const open = new Set(rest.flatMap((w) => (w.kind === 'story' ? [w.entry.id] : []))); + return flow.flatMap((w) => (w.kind === 'story' && !open.has(w.entry.id) ? [w.entry.id] : [])); +} + +/** + * A chapter's spreads, composed from its plan, the photo page alternating + * across the book, its words flowing from page to page as far as the pages + * that take words hold them (none of an entry a chapter before has `told`); + * and, by entry id, the page the last of each story is set on. + */ +function chapterSpreads( + c: ComposeCtx, + chapter: DesignChapter, + plans: readonly PlannedSpread[], + start: number, + told: ReadonlySet, +) { + const spreads: DesignedSpread[] = []; + const ends = new Map(); + const entryId = chapter.entries[0]?.id ?? null; + let flow = chapterWords(c, chapter, told); plans.forEach((plan, i) => { - // A quiet page with no words left to set and no photo would be an empty spread, or one holding only a list: + // A quiet page with no story left to set and no photo would be an empty spread, or one holding only a list: // it is left out (the planner gives the quiet page of an entry with pros and cons a photo for them). - if (plan.beat === 'quiet' && !carry && !plan.photos.length) return; + if (plan.beat === 'quiet' && !plan.photos.length && !flow.some((w) => w.kind === 'story')) return; const hand = (start + i + (c.directives.seed ?? 0)) % 2 === 0 ? 'left' : 'right'; - const done = composePlanned(c, plan, hand, carry, unlisted); - carry = done.rest; - if (done.listed) unlisted = null; - const spread: BookSpread = innerSpread(c, done.elements, entry?.id ?? null); - out.push({ spread, beat: plan.beat, chapter: chapterName(chapter), findings: [] }); + // Every quiet page takes words, one photo's too, so a list with no room where it falls can wait for one. + const later = plans.slice(i + 1).some((p) => p.beat === 'quiet'); + const done = composePlanned(c, plan, hand, { flow, later }); + const spread: BookSpread = innerSpread(c, done.elements, entryId); + const rest = done.rest ?? flow; + for (const id of endedOn(flow, rest)) ends.set(id, spread.id); + flow = rest; + spreads.push({ spread, beat: plan.beat, chapter: chapterName(chapter), findings: [] }); }); - if (carry && entry && (c.directives.text?.story ?? 'full') === 'full') { - c.notes.push(`part of the story of entry ${entry.id} found no page within the budget and is left out`); - } - return out; + return { spreads, ends }; } /** "entry 3", or "entries 3, 4 and 6", in a note. */ @@ -200,6 +233,41 @@ function entriesNamed(ids: readonly number[]): string { return `entries ${ids.slice(0, -1).join(', ')} and ${ids.slice(-1).join('')}`; } +/** The entry whose story a page element sets, as a list of one; empty for any other element. */ +function storyOn(e: BookElement): number[] { + const id = e.kind === 'text' && e.binding?.source === 'entry.story' ? e.binding.entryId : undefined; + return id === undefined ? [] : [id]; +} + +/** + * The stories the pages could not hold, said in the notes as the pros and + * cons are: those a page holds only a part of, and those no page holds (the + * chapter's pages full, or past the budget). `ends` holds, by entry id, the + * page the last of each story was set on. + */ +function storiesUnsaid( + c: ComposeCtx, + entries: readonly AutoEntry[], + body: readonly DesignedSpread[], + ends: ReadonlyMap, +): string[] { + if ((c.directives.text?.story ?? 'full') === 'none') return []; + const kept = new Set(body.map((d) => d.spread.id)); + const begun = new Set(body.flatMap((d) => d.spread.elements.flatMap(storyOn))); + const short = entries.filter((e) => (e.story ?? '').trim() && !kept.has(ends.get(e.id) ?? '')); + const none = short.filter((e) => !begun.has(e.id)).map((e) => e.id); + const part = short.filter((e) => begun.has(e.id)).map((e) => e.id); + const out: string[] = []; + if (none.length === 1) out.push(`the story of ${entriesNamed(none)} found no room on its pages and is left out`); + if (none.length > 1) out.push(`the stories of ${entriesNamed(none)} found no room on their pages and are left out`); + if (part.length) { + const what = part.length === 1 ? 'story' : 'stories'; + const their = part.length === 1 ? 'its' : 'their'; + out.push(`part of the ${what} of ${entriesNamed(part)} found no room on ${their} pages and is left out`); + } + return out; +} + /** * What the pages leave out of the entries, said in the notes: the pros and * cons that found no page (an entry among the stations, one after the first @@ -256,6 +324,10 @@ export function composeDesignedInner( const rhythm = newRhythm(); const plan = { directives: c.directives, page: c.page, infos: c.infos }; const body: DesignedSpread[] = []; + // By entry id, the page its story is set to the end on. + const ends = new Map(); + // The entries a chapter has told: a place may name one entry for two chapters, and its words go in the first. + const told = new Set(); chapters.forEach((chapter, i) => { if (chapter.stations) { body.push(...stationsSpreads(c, chapter, input)); @@ -263,17 +335,19 @@ export function composeDesignedInner( } const from = rhythm.beats.length; const plans = planChapter(chapter, shares[i] ?? 1, rhythm, plan, leftOut); - const made = chapterSpreads(c, chapter, plans, body.length); + const made = chapterSpreads(c, chapter, plans, body.length, told); settleRhythm( rhythm, from, - made.map((d) => d.beat), + made.spreads.map((d) => d.beat), ); - body.push(...made); + body.push(...made.spreads); + for (const [id, at] of made.ends) ends.set(id, at); + for (const entry of chapter.entries) told.add(entry.id); }); const head = framing.filter((k) => k !== 'closing').map((k) => framingSpread(c, k, input)); const tail = framing.filter((k) => k === 'closing').map((k) => framingSpread(c, k, input)); const fitted = withinCap(body, cap - head.length - tail.length, leftOut, notes); - notes.push(...leftUnsaid(c, entries, fitted)); + notes.push(...storiesUnsaid(c, entries, fitted, ends), ...leftUnsaid(c, entries, fitted)); return { spreads: [...head, ...fitted, ...tail], leftOut, notes }; } diff --git a/shared/src/book/design/design-pacing.ts b/shared/src/book/design/design-pacing.ts index 69ceb6f77..0835f9dde 100644 --- a/shared/src/book/design/design-pacing.ts +++ b/shared/src/book/design/design-pacing.ts @@ -8,8 +8,8 @@ import { burstOf, heroWorthy, isPanorama, pickEntriesOf, type BookDesignLeftOut /** * The story of the book before its pages: which chapters, how many spreads - * each gets, and the beat of every spread (opener or hero, a quiet page for a - * long story, two, three or a grid of photos, a burst as one grid, a + * each gets, and the beat of every spread (opener or hero, quiet pages for + * words that run long, two, three or a grid of photos, a burst as one grid, a * panorama), paced so the book breathes: never one photo beat three times * running, a loud spread followed by a calm one, at most one hero in four * spreads. @@ -52,7 +52,11 @@ export interface DesignChapter { export interface PlannedSpread { beat: BookDesignBeat; chapter: DesignChapter; - /** The entry whose words the spread sets, when it sets any. */ + /** + * The chapter's first entry, on its opening spread and on the pages planned + * for its words; null on a photo spread. A quiet spread of one photo still + * sets the chapter's words that wait for a page, as every quiet page does. + */ entry: AutoEntry | null; photos: AutoPhoto[]; } @@ -183,17 +187,51 @@ const DENSITY = { airy: { aim: 1.5, max: 2 }, balanced: { aim: 3, max: 4 }, rich /** Characters an opener's text page holds before the story needs a page of its own. */ const OPENER_STORY_CHARS = 1400; -/** The spreads a chapter wants with nothing holding it back. */ +/** + * Characters a page for words holds at the least: a narrow column beside a + * photo. Without one its column runs on across both pages and holds twice as + * many. + */ +const QUIET_STORY_CHARS = 1400; + +/** The room, in characters of a story, the small heading over a later entry's story takes. */ +const HEADING_CHARS = 250; + +/** The room, in characters of a story, a row of pros and cons takes. */ +const LIST_ROW_CHARS = 200; + +/** + * The room a chapter's words take, in characters of a story: every entry's + * story (its excerpt when excerpts are asked for), a small heading over each + * after the first, and its first entry's pros and cons, a row each and one + * for their labels. None when the chapter tells no story: a list alone never + * asks for a page of its own. + */ +function wordsRoom(chapter: DesignChapter, directives: BookDesignDirectives): number { + const mode = directives.text?.story ?? 'full'; + if (mode === 'none') return 0; + const most = mode === 'excerpt' ? (directives.text?.excerptChars ?? 600) : Infinity; + const told = chapter.entries.map((e) => Math.min(most, (e.story ?? '').trim().length)); + const stories = told.reduce((n, chars, i) => n + chars + (chars > 0 && i > 0 ? HEADING_CHARS : 0), 0); + if (stories === 0) return 0; + const items = chapter.entries.slice(0, 1).flatMap((e) => bookEntryListItems(e)); + const pros = items.filter((item) => item.tone === 'pro').length; + return stories + (items.length ? LIST_ROW_CHARS * (Math.max(pros, items.length - pros) + 1) : 0); +} + +/** The spreads a chapter wants with nothing holding it back: its opening, its photo spreads, its pages for words. */ export function naturalSpreads(chapter: DesignChapter, directives: BookDesignDirectives, perStations: number): number { if (chapter.stations) return Math.max(1, Math.ceil(chapter.entries.length / perStations)); const aim = DENSITY[directives.density].aim; const rest = Math.max(0, chapter.photos.length - 1); - const words = storyChars(chapter.entries) > OPENER_STORY_CHARS && directives.text?.story !== 'excerpt' ? 1 : 0; + const words = Math.ceil(Math.max(0, wordsRoom(chapter, directives) - OPENER_STORY_CHARS) / QUIET_STORY_CHARS); return 1 + Math.ceil(rest / aim) + words; } -/** The most spreads a chapter can fill: one photo each, a quiet page for its words. */ -const fullest = (chapter: DesignChapter) => (chapter.stations ? 1 : 1 + Math.max(0, chapter.photos.length - 1) + 1); +/** The most spreads a chapter can fill: one photo each and a quiet page for its words, or what its words want. */ +function fullest(chapter: DesignChapter, natural: number): number { + return chapter.stations ? 1 : Math.max(natural, 1 + Math.max(0, chapter.photos.length - 1) + 1); +} /** * Spreads per chapter for a total, by largest remainder on the chapters' @@ -208,14 +246,14 @@ export function shareSpreads(chapters: readonly DesignChapter[], natural: readon const floor = chapters.map(() => 1); const room = Math.max(0, total - chapters.length); const raw = weight.map((w) => (room * w) / sum); - const out = raw.map((r, i) => Math.min((floor[i] ?? 1) + Math.floor(r), fullest(chapters[i] as DesignChapter))); + const most = chapters.map((chapter, i) => fullest(chapter, natural[i] as number)); + const out = raw.map((r, i) => Math.min((floor[i] ?? 1) + Math.floor(r), most[i] as number)); const order = raw.map((r, i) => ({ i, rem: r - Math.floor(r) })).sort((a, b) => b.rem - a.rem || a.i - b.i); let left = total - out.reduce((a, b) => a + b, 0); for (let pass = 0; left > 0 && pass < 4; pass++) { for (const { i } of order) { - const chapter = chapters[i] as DesignChapter; if (left <= 0) break; - if ((out[i] ?? 0) >= fullest(chapter)) continue; + if ((out[i] ?? 0) >= (most[i] as number)) continue; out[i] = (out[i] ?? 0) + 1; left--; } @@ -282,15 +320,28 @@ interface PlanInput { infos: ReadonlyMap; } -/** The chapter's opening spread: a hero when it has a photo for one and the rhythm allows, else the opener. */ -function opening(chapter: DesignChapter, photos: AutoPhoto[], rhythm: Rhythm, input: PlanInput): PlannedSpread { +/** + * The chapter's opening spread, out of its `spreads`: a hero when it has a + * photo for one and the rhythm allows, else a panorama, else the opener. A + * hero sets none of the words and a panorama's half page is too small to + * count on, so a chapter that tells a story opens on either only when a + * spread for its words can follow. + */ +function opening( + chapter: DesignChapter, + photos: AutoPhoto[], + rhythm: Rhythm, + input: PlanInput, + spreads: number, +): PlannedSpread { const entry = chapter.entries[0] ?? null; - const hero = photos.find((p) => heroWorthy(p, input.infos.get(p.photoId), input.page)); + const loudFits = spreads > 1 || wordsRoom(chapter, input.directives) === 0; + const hero = loudFits ? photos.find((p) => heroWorthy(p, input.infos.get(p.photoId), input.page)) : undefined; if (entry && hero && loudAllowed(rhythm, 'hero')) { photos.splice(photos.indexOf(hero), 1); return { beat: 'hero', chapter, entry, photos: [hero] }; } - const pano = photos[0] && isPanorama(photos[0]) && loudAllowed(rhythm, 'pano') ? photos[0] : null; + const pano = loudFits && photos[0] && isPanorama(photos[0]) && loudAllowed(rhythm, 'pano') ? photos[0] : null; if (entry && pano) return { beat: 'pano', chapter, entry, photos: photos.splice(0, 3) }; const first = photos.shift(); return { beat: first ? 'opener' : 'story', chapter, entry, photos: first ? [first] : [] }; @@ -312,13 +363,14 @@ function burstGrid(chapter: DesignChapter, photos: AutoPhoto[], rhythm: Rhythm): /** * The photos a quiet page takes, out of `photos`, with `left` spreads still * to plan: none while more follow, so the words stand alone; one beside them - * on the chapter's last spread, or when the entry has pros and cons, which - * then still have a page should the opener hold the whole story. The photo is - * the first outside the burst, which stays whole for its grid. + * on the chapter's last spread, or on its `first` page for words when the + * entry has pros and cons, which then still have a page should the opening + * hold the whole story. The photo is the first outside the burst, which stays + * whole for its grid. */ -function quietPhotos(chapter: DesignChapter, photos: AutoPhoto[], left: number): AutoPhoto[] { +function quietPhotos(chapter: DesignChapter, photos: AutoPhoto[], left: number, first: boolean): AutoPhoto[] { const entry = chapter.entries[0]; - if (left > 1 && !(entry && bookEntryListItems(entry).length > 0)) return []; + if (left > 1 && !(first && entry && bookEntryListItems(entry).length > 0)) return []; const at = photos.findIndex((p) => !chapter.burst.includes(p.photoId)); return photos.splice(Math.max(0, at), 1); } @@ -337,10 +389,36 @@ function photoTake(rhythm: Rhythm, count: number): number { } /** - * The spreads of one chapter for its share of the budget: the opening, a - * quiet page when the words run long, its burst as one grid, then the photos - * spread evenly over what is left, at most the density's maximum a spread; - * photos that find no spread are left out and said. + * The quiet pages a chapter's words want after its `open`ing, out of `left` + * spreads and of `photos`: one for every page's worth of the room its words + * take (the room of all its entries' words, as `naturalSpreads` counts it) + * that the opening cannot hold, a page holding a column beside a photo or two + * without one. + */ +function wordsPages( + chapter: DesignChapter, + photos: AutoPhoto[], + open: PlannedSpread, + left: number, + directives: BookDesignDirectives, +): PlannedSpread[] { + const out: PlannedSpread[] = []; + const entry = chapter.entries[0] ?? null; + const held = open.beat === 'hero' || open.beat === 'pano' ? 0 : OPENER_STORY_CHARS; + let words = wordsRoom(chapter, directives) - held; + while (words > 0 && out.length < left) { + const beside = quietPhotos(chapter, photos, left - out.length, out.length === 0); + out.push({ beat: 'quiet', chapter, entry, photos: beside }); + words -= beside.length ? QUIET_STORY_CHARS : QUIET_STORY_CHARS * 2; + } + return out; +} + +/** + * The spreads of one chapter for its share of the budget: the opening, the + * quiet pages its words want, its burst as one grid, then the photos spread + * evenly over what is left, at most the density's maximum a spread; photos + * that find no spread are left out and said. */ export function planChapter( chapter: DesignChapter, @@ -357,14 +435,11 @@ export function planChapter( if (s.beat === 'hero') rhythm.lastHero = rhythm.beats.length; rhythm.beats.push(s.beat); }; - push(opening(chapter, photos, rhythm, input)); - const long = storyChars(chapter.entries.slice(0, 1)) > OPENER_STORY_CHARS || out[0]?.beat === 'hero'; - const wantsWords = long && (input.directives.text?.story ?? 'full') === 'full'; - let left = spreads - 1; - if (wantsWords && left > 0 && storyChars(chapter.entries.slice(0, 1)) > 0) { - push({ beat: 'quiet', chapter, entry: chapter.entries[0] ?? null, photos: quietPhotos(chapter, photos, left) }); - left--; - } + const open = opening(chapter, photos, rhythm, input, spreads); + push(open); + const pages = wordsPages(chapter, photos, open, spreads - 1, input.directives); + pages.forEach(push); + let left = spreads - 1 - pages.length; const grid = left > 0 ? burstGrid(chapter, photos, rhythm) : null; if (grid) { push(grid); diff --git a/shared/src/book/design/design-spreads.ts b/shared/src/book/design/design-spreads.ts index e2dae30f5..3c1c2dc90 100644 --- a/shared/src/book/design/design-spreads.ts +++ b/shared/src/book/design/design-spreads.ts @@ -1,4 +1,5 @@ import type { AutoEntry, AutoPhoto } from '../auto/auto-types'; +import { formatBookDate } from '../book-text'; import { bookEntryListItems, bookTravelList, bookTravelMap, bookTravelSize } from '../book-travel'; import type { BookElement, BookFrame, BookSpread, BookTextElement } from '../book.schema'; import type { JourneyStats } from '../journey-stats.schema'; @@ -25,20 +26,43 @@ import { paperBackground } from './design-style'; /** * The spreads of a chapter, one composer per beat: the opener, the hero, a - * quiet page for a long story, two, three and four to six photos, and a - * panorama across the fold. Each takes the photos the plan gave it and the - * page (left or right) its photos lead on, and hands back the elements and - * whatever of the story it could not set. An entry's pros and cons go under - * its words where they leave room: on the opener, the panorama or a quiet - * page, which also sets them beside its photo when it has no words. + * quiet page for the words that run long, two, three and four to six photos, + * and a panorama across the fold. Each takes the photos the plan gave it and + * the page (left or right) its photos lead on, and hands back the elements and + * whatever of the chapter's words it could not set. The words flow in reading + * order from the opener (or the panorama) onto the quiet pages: the first + * entry's story, its pros and cons under it where they leave room, then the + * story of every further entry of the chapter under a small heading of its + * title. Pros and cons with no room where they fall wait, with the words after + * them, for a later page that takes words. */ +/** An entry's story still to be set: the whole of it, or what a page before left of it. */ +interface Passage { + kind: 'story'; + entry: AutoEntry; + words: string; + /** Whether a page before set its start, so the part left is set unbound. */ + begun: boolean; + /** Whether a small heading of the entry's title goes over its first words: an entry after its chapter's first. */ + headed: boolean; +} + +/** What a chapter still has to set, in reading order: stories, and its first entry's pros and cons after its story. */ +export type FlowItem = Passage | { kind: 'list'; entry: AutoEntry }; + +/** A chapter's words as a page gets them. */ +export interface Words { + /** What is still to set, in reading order. */ + flow: readonly FlowItem[]; + /** Whether a later page of the chapter takes words, so pros and cons with no room where they fall wait for it. */ + later: boolean; +} + export interface Composed { elements: BookElement[]; - /** The part of the story still to be set, for the next quiet page. */ - rest: string; - /** Whether the entry's pros and cons were set on this page. */ - listed?: boolean; + /** The chapter's words still to be set, for the next page that takes words; absent on a page that takes none. */ + rest?: FlowItem[]; } type Hand = 'left' | 'right'; @@ -103,9 +127,11 @@ interface Foot { bottom: number; } -function footUnder(c: ComposeCtx, words: BookTextElement | null, column: BookFrame): Foot { - const top = words ? words.frame.y + words.frame.h + c.grid.gutter : column.y; - return { x: column.x, w: column.w, top, bottom: column.y + column.h }; +/** How tall an entry's pros and cons are as a list: a row for each on the longer side. */ +function listHeight(c: ComposeCtx, entry: AutoEntry): number { + const items = bookEntryListItems(entry); + const pros = items.filter((i) => i.tone === 'pro').length; + return bookTravelSize('list', c.page, { rows: Math.max(pros, items.length - pros) }).h; } /** @@ -116,8 +142,7 @@ function footUnder(c: ComposeCtx, words: BookTextElement | null, column: BookFra function listUnder(c: ComposeCtx, entry: AutoEntry, foot: Foot): BookElement | null { const items = bookEntryListItems(entry); if (!items.length) return null; - const pros = items.filter((i) => i.tone === 'pro').length; - const { h } = bookTravelSize('list', c.page, { rows: Math.max(pros, items.length - pros) }); + const h = listHeight(c, entry); if (foot.top + h > foot.bottom) return null; const labels = { pros: c.travel.labels.pros, cons: c.travel.labels.cons }; const list = bookTravelList(labels, { id: c.newId('li'), frame: frameOf(foot.x, foot.top, foot.w, h), items }); @@ -125,10 +150,106 @@ function listUnder(c: ComposeCtx, entry: AutoEntry, foot: Foot): BookElement | n } /** - * The chapter opens: the title block and the story on one page, a photo to - * the bleed on the other. Without a photo the story runs on across both pages. + * A small heading over a later entry's story, bound to the entry: its title, + * else its place (as the title block sets them), else its date; null when it + * has none of them. */ -export function composeOpener(c: ComposeCtx, entry: AutoEntry, photo: AutoPhoto | null, hand: Hand): Composed { +function runIn(c: ComposeCtx, entry: AutoEntry, foot: Foot): BookTextElement | null { + const at = { x: foot.x, y: foot.top, w: foot.w }; + const name = entry.title || entry.location; + if (name) return textEl(c, at, name, 'sub', { binding: { source: 'entry.title', entryId: entry.id } }); + const iso = entry.date ?? ''; + const date = formatBookDate(iso, c.locale); + if (!date) return null; + return textEl(c, at, date, 'sub', { binding: { source: 'entry.date', entryId: entry.id, value: iso } }); +} + +/** One step down a column: what it set, where that ends, the words left after it. */ +interface Step { + els: BookElement[]; + end: number; + rest: FlowItem[]; + /** Whether a story was cut: the column is full and the rest of it goes to the next page. */ + cut: boolean; +} + +/** + * As much of a story as the room holds, under its heading where it opens (a + * heading is never left without words under it), `after` the words that come + * next; null when not a line of it fits. + */ +function setPassage(c: ComposeCtx, passage: Passage, after: readonly FlowItem[], foot: Foot): Step | null { + const head = passage.headed && !passage.begun ? runIn(c, passage.entry, foot) : null; + const top = head ? head.frame.y + head.frame.h + 3 : foot.top; + const box = frameOf(foot.x, top, foot.w, foot.bottom - top); + const story = storyEl(c, passage.entry, passage.words, box, !passage.begun); + if (!story.el) return null; + const left: FlowItem[] = story.rest ? [{ ...passage, words: story.rest, begun: true }] : []; + return { + els: head ? [head, story.el] : [story.el], + end: story.el.frame.y + story.el.frame.h, + rest: [...left, ...after], + cut: left.length > 0, + }; +} + +/** + * The next of a chapter's words, `item`, in the room under those set: a + * story, or the list where it has room. A list with no room where it falls + * waits, with the words `after` it, when a later page `waits` for them and + * holds it; else the words go on without it, and the notes say it is left + * out. Null when nothing fits. + */ +function setNext(c: ComposeCtx, item: FlowItem, after: readonly FlowItem[], foot: Foot, waits: boolean): Step | null { + if (item.kind === 'story') return setPassage(c, item, after, foot); + const list = listUnder(c, item.entry, foot); + if (list) return { els: [list], end: list.frame.y + list.frame.h, rest: after, cut: false }; + if (waits && listHeight(c, item.entry) <= liveBox(c, 'left').h) return null; + const [next, ...later] = after; + return next?.kind === 'story' ? setPassage(c, next, later, foot) : null; +} + +interface Flowed { + elements: BookElement[]; + rest: FlowItem[]; +} + +/** + * A chapter's words down a column in reading order, each a gutter under the + * one before: a story as far as it fits (cut at a sentence, the rest handed + * on), every further entry's under a small heading of its title, the first + * entry's pros and cons under its story where they leave room, else on a + * later page when one `waits` for the words. + */ +function flowWords(c: ComposeCtx, flow: readonly FlowItem[], column: BookFrame, waits: boolean): Flowed { + const elements: BookElement[] = []; + let top = column.y; + let rest = [...flow]; + let item: FlowItem | undefined = rest[0]; + while (item) { + const foot = { x: column.x, w: column.w, top, bottom: column.y + column.h }; + const step = setNext(c, item, rest.slice(1), foot, waits); + if (!step) break; + elements.push(...step.els); + top = step.end + c.grid.gutter; + rest = step.rest; + item = step.cut ? undefined : rest[0]; + } + return { elements, rest }; +} + +/** + * The chapter opens: the title block and the chapter's words on one page, a + * photo to the bleed on the other. Without a photo the words run on across + * both pages. + */ +export function composeOpener( + c: ComposeCtx, + entry: AutoEntry, + photo: AutoPhoto | null, + hand: Hand, + words: Words, +): Composed { const textSide = photo ? other(hand) : 'left'; const box = liveBox(c, textSide); const head = titleBlock(c, entry, box); @@ -139,25 +260,16 @@ export function composeOpener(c: ComposeCtx, entry: AutoEntry, photo: AutoPhoto els.push(map); bottom = map.frame.y - c.grid.gutter; } - const storyBox = frameOf(box.x, head.bottom + 2, box.w, bottom - head.bottom - 2); - const first = storyEl(c, entry, (entry.story ?? '').trim(), storyBox, true); - if (first.el) els.push(first.el); - let rest = first.el ? first.rest : (entry.story ?? '').trim(); - let foot = footUnder(c, first.el, storyBox); + const column = frameOf(box.x, head.bottom + 2, box.w, bottom - head.bottom - 2); + const set = flowWords(c, words.flow, column, words.later || !photo); + els.push(...set.elements); if (photo) { els.unshift(photoEl(c, photo, bleedPage(c, hand), { bleed: { side: hand } })); - } else if (rest) { - const right = liveBox(c, 'right'); - const next = storyEl(c, entry, rest, right, false); - if (next.el) { - els.push(next.el); - foot = footUnder(c, next.el, right); - } - rest = next.el ? next.rest : rest; + return { elements: els, rest: set.rest }; } - const list = listUnder(c, entry, foot); - if (list) els.push(list); - return { elements: els, rest, listed: list !== null }; + if (!set.rest.length) return { elements: els, rest: set.rest }; + const more = flowWords(c, set.rest, liveBox(c, 'right'), words.later); + return { elements: [...els, ...more.elements], rest: more.rest }; } /** The trip a chapter's stop is on: the stop read from its entry, else the one at its place; null for none. */ @@ -187,7 +299,8 @@ function chapterMap(c: ComposeCtx, entry: AutoEntry, box: BookFrame): BookElemen /** * One photo across the whole spread, the title on a scrim at the foot of a - * page. The detail is kept off the fold; the story goes to the next page. + * page. The detail is kept off the fold; the chapter's words go to the next + * page that takes words. */ export function composeHero(c: ComposeCtx, entry: AutoEntry, photo: AutoPhoto, hand: Hand): Composed { const { pageWidth: PW, pageHeight: H, bleed: b, margin: m } = c.grid; @@ -199,40 +312,43 @@ export function composeHero(c: ComposeCtx, entry: AutoEntry, photo: AutoPhoto, h els.push(scrimEl(c, frameOf(textSide === 'left' ? -b : PW, top, PW + b, H - top + b))); const box = frameOf(pageX(c, textSide) + m, top + m * 0.6, PW - m * 2, H - top - m * 1.6); els.push(...titleBlock(c, entry, box, { onScrim: true }).els); - return { elements: els, rest: (entry.story ?? '').trim() }; + return { elements: els }; +} + +/** The narrow column of a quiet page: the page's live area, set in on both sides. */ +function quietColumn(c: ComposeCtx, side: Hand): BookFrame { + const box = liveBox(c, side); + const inset = box.w * 0.08; + return frameOf(box.x + inset, box.y, box.w - inset * 2, box.h); } /** - * A long story's next page: the words in a narrow column, a photo on the - * other page when there is one, and the pros and cons of `list` (the - * chapter's entry, when they have not found a page yet) under the words, or - * at the head of the column on a photo's page that sets none. + * A page for the chapter's words the pages before had no room for: the words + * in a narrow column, the first entry's pros and cons in their place among + * them (at the head of the column when its words were all set before), a + * photo on the other page when there is one. Without a photo the column runs + * on from the left page onto the right, so a day of several stories keeps to + * its one spread. */ export function composeQuiet( c: ComposeCtx, - entry: AutoEntry | null, - words: string, + words: Words, photo: AutoPhoto | null, hand: Hand, - list: AutoEntry | null = null, + entryId: number | null, ): Composed { - const textSide = photo ? other(hand) : 'left'; - const box = liveBox(c, textSide); - const inset = box.w * 0.08; - const column = frameOf(box.x + inset, box.y, box.w - inset * 2, box.h); + const sides: Hand[] = photo ? [other(hand)] : ['left', 'right']; const els: BookElement[] = []; - let rest = words; - let set: BookTextElement | null = null; - if (entry && words) { - const story = storyEl(c, entry, words, column, false); - set = story.el; - if (set) els.push(set); - rest = set ? story.rest : words; + let rest = [...words.flow]; + for (const side of sides) { + if (!rest.length) break; + // The left page of a spread without a photo hands on to the right one. + const set = flowWords(c, rest, quietColumn(c, side), words.later || (!photo && side === 'left')); + els.push(...set.elements); + rest = set.rest; } - const pros = list ? listUnder(c, list, footUnder(c, set, column)) : null; - if (pros) els.push(pros); - if (photo) els.push(...pagePhotos(c, [photo], hand, entry?.id ?? null)); - return { elements: els, rest, listed: pros !== null }; + if (photo) els.push(...pagePhotos(c, [photo], hand, entryId)); + return { elements: els, rest }; } /** Two photos: one to the bleed of its page, the other framed on the facing page. */ @@ -241,7 +357,7 @@ export function composeDuo(c: ComposeCtx, list: readonly AutoPhoto[], hand: Hand const els: BookElement[] = []; if (a) els.push(photoEl(c, a, bleedPage(c, hand), { bleed: { side: hand } })); els.push(...pagePhotos(c, rest.slice(0, 1), other(hand), entryId)); - return { elements: els, rest: '' }; + return { elements: els }; } /** Three photos: one to the bleed of its page, two framed on the facing page. */ @@ -250,7 +366,7 @@ export function composeTrio(c: ComposeCtx, list: readonly AutoPhoto[], hand: Han const els: BookElement[] = []; if (a) els.push(photoEl(c, a, bleedPage(c, hand), { bleed: { side: hand } })); els.push(...pagePhotos(c, rest.slice(0, 2), other(hand), entryId)); - return { elements: els, rest: '' }; + return { elements: els }; } /** Four to six photos, framed on both pages, the fuller page on the leading side. */ @@ -260,12 +376,12 @@ export function composeGrid(c: ComposeCtx, list: readonly AutoPhoto[], hand: Han ...pagePhotos(c, list.slice(0, lead), hand, entryId), ...pagePhotos(c, list.slice(lead, lead + 3), other(hand), entryId), ]; - return { elements: els, rest: '' }; + return { elements: els }; } /** - * A panorama across the top of the spread, the title and the story under it - * on one page, up to two photos under it on the other. + * A panorama across the top of the spread, the title and the chapter's words + * under it on one page, up to two photos under it on the other. */ export function composePano( c: ComposeCtx, @@ -273,6 +389,7 @@ export function composePano( pano: AutoPhoto, others: readonly AutoPhoto[], hand: Hand, + words: Words, ): Composed { const { pageWidth: PW, pageHeight: H, bleed: b, margin: m, gutter: g } = c.grid; const band = frameOf(-b, -b, PW * 2 + b * 2, H * 0.5 + b); @@ -282,11 +399,9 @@ export function composePano( const box = frameOf(pageX(c, textSide) + m, top, PW - m * 2, H - m - top); const head = titleBlock(c, entry, box); els.push(...head.els); - const storyBox = frameOf(box.x, head.bottom + 2, box.w, box.y + box.h - head.bottom - 2); - const set = storyEl(c, entry, (entry.story ?? '').trim(), storyBox, true); - if (set.el) els.push(set.el); - const list = listUnder(c, entry, footUnder(c, set.el, storyBox)); - if (list) els.push(list); + const column = frameOf(box.x, head.bottom + 2, box.w, box.y + box.h - head.bottom - 2); + const set = flowWords(c, words.flow, column, words.later); + els.push(...set.elements); const row = others.slice(0, 2); const cell = frameOf(pageX(c, hand) + m, top, PW - m * 2, H - m - top); const w = row.length > 1 ? (cell.w - g) / 2 : cell.w; @@ -294,7 +409,7 @@ export function composePano( const frame = frameOf(cell.x + i * (w + g), cell.y, w, cell.h); els.push(photoEl(c, p, frame, { turn: turnOf(c, i) })); }); - return { elements: els, rest: set.el ? set.rest : (entry.story ?? '').trim(), listed: list !== null }; + return { elements: els, rest: set.rest }; } /** Whether a panorama can run across the top of the spread at print resolution. */