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. */