diff --git a/shared/src/book/book-findings.ts b/shared/src/book/book-findings.ts index bf92b6ff0..c90073f48 100644 --- a/shared/src/book/book-findings.ts +++ b/shared/src/book/book-findings.ts @@ -66,29 +66,64 @@ export function photoDpi(el: BookPhotoElement, info: BookPhotoInfo | undefined): return Math.round(25.4 / mmPerPx); } -/** Average letter width per em, per family: enough to tell one line from three. */ +/** Average letter width per em at weight 400, per family: measured on story text as the Studio sets each family. */ const LETTER_EM: Record = { - sans: 0.5, - inter: 0.52, - serif: 0.47, - garamond: 0.44, - playfair: 0.5, - display: 0.48, - bebas: 0.38, + sans: 0.507, + inter: 0.478, + serif: 0.469, + garamond: 0.383, + playfair: 0.454, + display: 0.492, + bebas: 0.339, }; +/** + * The headroom on letter width the design engine sizes a text frame with. A + * frame the engine sizes must hold its words, and a few percent of width + * covers the letters wider than the average: measured against the Studio on + * 1512 texts, the lines counted with it fall short in under 1% of them, where + * counting characters fell short in 27%. The findings keep the best estimate, + * so a frame a person sized is not flagged for a few percent of width. + */ +export const DESIGN_TEXT_HEADROOM = 1.05; + /** A number the text carries, or the schema's default when it carries none (a layout's meta line sets no leading). */ const orDefault = (v: number | undefined, fallback: number) => (Number.isFinite(v) ? (v as number) : fallback); -/** How many millimetres a text needs to be set at its frame's width, roughly. */ -export function textHeightNeeded(el: BookTextElement): number { +/** + * How many lines a paragraph takes at `perLine` letters a line, broken at + * spaces as the Studio breaks it: a word that does not fit the rest of a line + * starts the next one, a word longer than a line breaks inside it, and an + * empty paragraph is one line. + */ +function wrappedLines(para: string, perLine: number): number { + let lines = 1; + let used = 0; + for (const word of para.split(' ')) { + if (used > 0 && used + 1 + word.length <= perLine) { + used += 1 + word.length; + continue; + } + if (used > 0) lines += 1; + lines += Math.max(0, Math.ceil(word.length / perLine) - 1); + used = word.length % perLine || Math.min(word.length, perLine); + } + return lines; +} + +/** + * How many millimetres a text needs to be set at its frame's width, roughly: + * its words wrapped at the family's average letter width times `headroom`, + * plus its tracking, which is the same for every letter and so takes no + * headroom. The default, 1, is the best estimate; the design engine sizes its + * frames with `DESIGN_TEXT_HEADROOM`. + */ +export function textHeightNeeded(el: BookTextElement, headroom = 1): number { const sizeMm = (orDefault(el.size, 11) * 25.4) / 72; const tracking = orDefault(el.tracking, 0); - const letter = sizeMm * ((LETTER_EM[el.font] ?? 0.5) * (el.weight >= 600 ? 1.06 : 1) + tracking); + const letter = sizeMm * ((LETTER_EM[el.font] ?? 0.5) * (el.weight >= 600 ? 1.06 : 1) * headroom + tracking); const perLine = Math.max(1, Math.floor(el.frame.w / Math.max(letter, 0.1))); - const lines = el.text - .split('\n') - .reduce((sum, para) => sum + Math.max(1, Math.ceil(para.trimEnd().length / perLine)), 0); + const lines = el.text.split('\n').reduce((sum, para) => sum + wrappedLines(para.trimEnd(), perLine), 0); return lines * sizeMm * orDefault(el.leading, 1.45); } diff --git a/shared/src/book/describe-spread.spec.ts b/shared/src/book/describe-spread.spec.ts index 266b3947b..420ee43ef 100644 --- a/shared/src/book/describe-spread.spec.ts +++ b/shared/src/book/describe-spread.spec.ts @@ -1,9 +1,10 @@ -import { MIN_PRINT_DPI, photoDpi, spreadFindings } from './book-findings'; +import { DESIGN_TEXT_HEADROOM, MIN_PRINT_DPI, photoDpi, spreadFindings, textHeightNeeded } from './book-findings'; import { applyBookTemplate, findBookTemplate, type BookTemplate } from './book-layouts'; import type { BookPhotoInfo } from './book-ops-core'; import { BOOK_OP_LIMITS, bookPageDescriptionSchema, bookOverviewSchema, type BookFinding } from './book-ops.schema'; import { blankBook } from './book-pages'; import { + BOOK_FONTS_IDS, bookBadgeElementSchema, bookCountriesElementSchema, bookIconElementSchema, @@ -200,6 +201,87 @@ describe('findings', () => { }); }); +/** A story of 898 characters the Studio sets in ten lines across 178.5 mm, at 10 pt in sans. */ +const LISBON = [ + 'Wir stehen früh auf und laufen von der Sé hinauf in die Alfama, bevor die Busse kommen. Die Gassen', + 'sind noch nass vom Wischen, aus den Fenstern hängt Wäsche, und eine ältere Frau stellt Geranien auf', + 'die Treppe. Am Largo das Portas do Sol trinken wir den ersten Galão und schauen auf die Dächer, die', + 'bis zum Tejo hinunterfallen. Ein Straßenmusiker spielt Fado auf einer portugiesischen Gitarre,', + 'leise, als wolle er niemanden wecken. Danach verlaufen wir uns absichtlich. Jede Treppe führt zu', + 'einem kleinen Platz, jeder Platz zu einer neuen Aussicht. In einer Tasca essen wir gegrillte', + 'Sardinen mit Brot, und der Wirt erklärt uns mit Händen und Füßen, warum man sie nicht entgräten', + 'muss. Gegen Mittag wird es warm, die Azulejos glänzen in der Sonne, und wir setzen uns in den', + 'Schatten des Castelo de São Jorge. Von oben sieht die Stadt aus wie ein Modell, die Brücke des 25.', + 'April schimmert im Dunst.', +].join(' '); + +/** A text as the design engine sets a story: sans at 10 pt, weight 400, a leading of 1.5, no tracking. */ +const story = (words: string, w: number, h = 40) => + bookTextElementSchema.parse({ + id: 't-m', + kind: 'text', + frame: { x: 225.75, y: 64, w, h }, + text: words, + font: 'sans', + size: 10, + weight: 400, + leading: 1.5, + tracking: 0, + }) as BookTextElement; + +/** One line of such a story, in millimetres. */ +const LINE = ((10 * 25.4) / 72) * 1.5; + +describe('the measure of a text', () => { + it('BOOKMEAS-001: measures the story the Studio sets in ten lines at ten lines, and flags a frame two lines short', () => { + expect(LISBON).toHaveLength(898); + expect(textHeightNeeded(story(LISBON, 178.5), DESIGN_TEXT_HEADROOM)).toBeCloseTo(10 * LINE, 6); + expect(textHeightNeeded(story(LISBON, 178.5))).toBeCloseTo(10 * LINE, 6); + // The findings allow a tenth over a frame: the nine lines the engine gave the story before pass, eight do not. + const overflow = (h: number) => findings([story(LISBON, 178.5, h)]).find((f) => f.code === 'text_overflow'); + expect(overflow(48.83)).toBeUndefined(); + expect(overflow(8 * LINE + 1.2)).toMatchObject({ severity: 'severe', elementIds: ['t-m'] }); + }); + + it('BOOKMEAS-002: breaks lines at spaces, a word longer than a line inside it, and sets each paragraph on lines of its own', () => { + // Ten letters a line at 18 mm. + const lines = (words: string) => textHeightNeeded(story(words, 18)) / LINE; + expect(lines('aaaaaa bbbbbb cccccc')).toBeCloseTo(3, 6); + expect(lines('abcdefghijklmnopqrstuvwxy')).toBeCloseTo(3, 6); + expect(lines('ab abcdefghijklmnopqrstuvwxy')).toBeCloseTo(4, 6); + expect(lines('abcdefghij k')).toBeCloseTo(2, 6); + expect(lines('ab cd')).toBeCloseTo(1, 6); + expect(lines('')).toBeCloseTo(1, 6); + expect(lines('ab\ncd')).toBeCloseTo(2, 6); + expect(lines('ab\n\ncd ')).toBeCloseTo(3, 6); + }); + + it('BOOKMEAS-003: never counts fewer lines with the design headroom than the best estimate, and a line more where one is tight', () => { + const texts = [LISBON, 'A long day on the ice. '.repeat(12), 'Harbour', '']; + const sized = [30, 60, 120, 178.5].flatMap((w) => [7, 10, 14].map((size) => ({ w, size }))); + const sets = BOOK_FONTS_IDS.flatMap((font) => sized.map((set) => ({ ...set, font }))); + for (const { font, w, size } of sets) { + for (const words of texts) { + for (const weight of [400, 700] as const) { + const el = { ...story(words, w), font, size, weight }; + expect(textHeightNeeded(el, DESIGN_TEXT_HEADROOM)).toBeGreaterThanOrEqual(textHeightNeeded(el)); + } + } + } + expect(textHeightNeeded(story(LISBON, 150))).toBeCloseTo(11 * LINE, 6); + expect(textHeightNeeded(story(LISBON, 150), DESIGN_TEXT_HEADROOM)).toBeCloseTo(12 * LINE, 6); + }); + + it('BOOKMEAS-004: widens the letters by the design headroom and leaves the tracking as it is', () => { + // With 0.1 em of tracking a letter takes 0.507 em times the headroom plus 0.1 em, 2.231 mm at 10 pt: forty fit + // 89.5 mm but not 89 mm. Were the tracking widened too, 89.5 mm would hold 39. + const tracked = (w: number) => ({ ...story('a'.repeat(40), w), tracking: 0.1 }); + expect(textHeightNeeded(tracked(89.5), DESIGN_TEXT_HEADROOM)).toBeCloseTo(LINE, 6); + expect(textHeightNeeded(tracked(89), DESIGN_TEXT_HEADROOM)).toBeCloseTo(2 * LINE, 6); + expect(textHeightNeeded(tracked(89))).toBeCloseTo(LINE, 6); + }); +}); + describe('describeSpread', () => { it('BOOKDESC-014: says what the page is, every element back to front with its frame, and the findings', () => { const doc = book([...clean(), photo('p-e', null, { x: 330, y: 20, w: 40, h: 30 })], { diff --git a/shared/src/book/design/design-book.spec.ts b/shared/src/book/design/design-book.spec.ts index 39dffa682..03843d8c7 100644 --- a/shared/src/book/design/design-book.spec.ts +++ b/shared/src/book/design/design-book.spec.ts @@ -1,4 +1,5 @@ import type { AutoEntry, AutoPhoto } from '../auto/auto-types'; +import { DESIGN_TEXT_HEADROOM, textHeightNeeded } from '../book-findings'; import { bookLevelFindings } from '../book-findings-design'; import type { BookPhotoInfo } from '../book-ops-core'; import { blankBook } from '../book-pages'; @@ -13,6 +14,7 @@ import { type BookListElement, type BookMapElement, type BookSpread, + type BookTextElement, } from '../book.schema'; import { pageFindings } from '../describe-spread'; import type { PhotoPickCandidate } from '../photo-pick'; @@ -20,6 +22,8 @@ import { designBook, type BookDesignInput, type BookDesignResult } from './desig import { bookDesignDirectivesSchema, type BookDesignDirectivesInput } from './design-directives.schema'; import { newRhythm, planChapter, type DesignChapter } from './design-pacing'; import { burstOf, type BookDesignLeftOut } from './design-photos'; +import { repairSpread } from './design-repair'; +import { designLook } from './design-style'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; @@ -36,7 +40,8 @@ import { describe, expect, it } from 'vitest'; * 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. + * read straight from the plan, and the repair of words that run long straight + * from a page. */ type Name = 'rich' | 'sparse' | 'nostats'; @@ -722,6 +727,40 @@ describe('design_book', () => { const eight = input('rich').entries.find((e) => e.id === 8); expect(storySet(twice, 8)).toBe(squashed(eight?.story ?? '')); }); + + it('BOOKDSN-030: cuts words the findings say run long at a sentence, to what the engine measures their frame to hold', () => { + const page = input('rich').page; + const look = designLook(bookDesignDirectivesSchema.parse({ style: 'classic' }), page, []); + // At the floor of a story's size already, so the repair cuts the words rather than setting them smaller. + const words = 'A long day on the ice. '.repeat(40).trim(); + const frame = { x: 230, y: 20, w: 160, h: 30 }; + const long = bookTextElementSchema.parse({ + id: 't-long', + kind: 'text', + frame, + text: words, + size: 9, + leading: 1.5, + }) as BookTextElement; + const spread: BookSpread = { + id: 'sp-in', + role: 'inner', + background: '#ffffff', + elements: [long], + parked: [], + entryId: null, + }; + expect(pageFindings(spread, page, []).map((f) => f.code)).toContain('text_overflow'); + let n = 0; + const fixed = repairSpread(spread, { page, photos: [], look, newId: (p) => `${p}-${++n}` }); + expect(fixed.findings.map((f) => f.code)).not.toContain('text_overflow'); + // Six lines of 94 letters hold 24 of its sentences; at the best estimate's 99 letters a line, 25 would be set. + const cut = fixed.spread.elements[0] as BookTextElement; + expect(cut.text).toBe('A long day on the ice. '.repeat(24).trim()); + expect(textHeightNeeded(cut, DESIGN_TEXT_HEADROOM)).toBeLessThanOrEqual(frame.h); + const more = { ...cut, text: `${cut.text} A long day on the ice.` }; + expect(textHeightNeeded(more, DESIGN_TEXT_HEADROOM)).toBeGreaterThan(frame.h); + }); }); /** Photos of one shape, ids counted from `from`. */ diff --git a/shared/src/book/design/design-compose.ts b/shared/src/book/design/design-compose.ts index 84ee1f3e0..35319089f 100644 --- a/shared/src/book/design/design-compose.ts +++ b/shared/src/book/design/design-compose.ts @@ -1,5 +1,5 @@ import type { AutoEntry, AutoPhoto } from '../auto/auto-types'; -import { MIN_PRINT_DPI, textHeightNeeded } from '../book-findings'; +import { DESIGN_TEXT_HEADROOM, MIN_PRINT_DPI, textHeightNeeded } from '../book-findings'; import type { BookPhotoInfo } from '../book-ops-core'; import { formatBookDate } from '../book-text'; import { bookBadgeValue, bookTravelBadge, type BookTravelContext } from '../book-travel'; @@ -177,7 +177,7 @@ export function textEl(c: ComposeCtx, at: TextAt, words: string, role: TextRole, binding: opts.binding ?? null, overridden: opts.overridden ?? false, }; - return { ...el, frame: frameOf(at.x, at.y, at.w, textHeightNeeded(el) + 1.2) }; + return { ...el, frame: frameOf(at.x, at.y, at.w, textHeightNeeded(el, DESIGN_TEXT_HEADROOM) + 1.2) }; } /** A heading that fits `maxH`: set smaller, down to the sub size, until it does. */ diff --git a/shared/src/book/design/design-repair.ts b/shared/src/book/design/design-repair.ts index bcc91789e..7b8631457 100644 --- a/shared/src/book/design/design-repair.ts +++ b/shared/src/book/design/design-repair.ts @@ -1,5 +1,5 @@ import { readableInk } from '../book-colour'; -import { MIN_PRINT_DPI, photoDpi, textHeightNeeded } from '../book-findings'; +import { DESIGN_TEXT_HEADROOM, MIN_PRINT_DPI, photoDpi, textHeightNeeded } from '../book-findings'; import type { BookPhotoInfo } from '../book-ops-core'; import type { BookFinding } from '../book-ops.schema'; import { @@ -89,7 +89,10 @@ const dropIds = (spread: BookSpread, ids: ReadonlySet): BookSpread => ({ elements: spread.elements.filter((e) => !ids.has(e.id)), }); -/** A text that overflows: half a point smaller down to its floor, then its words cut to the frame. */ +/** + * A text that overflows: half a point smaller down to its floor, then its + * words cut to what the frame holds as the engine measures a frame it sizes. + */ function fitText(el: BookTextElement, ctx: RepairCtx): BookTextElement { const floor = el.text.length > 80 ? ctx.look.sizes.body * 0.9 : ctx.look.sizes.caption; if (el.size - 0.5 >= floor) return { ...el, size: r2(el.size - 0.5) }; @@ -98,7 +101,7 @@ function fitText(el: BookTextElement, ctx: RepairCtx): BookTextElement { while (lo < hi) { const mid = Math.ceil((lo + hi) / 2); const tried = { ...el, text: cutWords(el.text, mid) }; - if (textHeightNeeded(tried) <= el.frame.h) lo = mid; + if (textHeightNeeded(tried, DESIGN_TEXT_HEADROOM) <= el.frame.h) lo = mid; else hi = mid - 1; } const text = cutWords(el.text, lo);