fix(journey): a designed story keeps its last lines, its frame measured with lines broken at spaces

The design engine sized a text frame by counting characters against the
letters a line holds, as if a line ended mid word. A line breaks at a space,
so a long story needed a line or more than its frame held and the Studio and
the print cut its last sentence off. The measure now wraps the words as the
Studio does, takes each family's letter width as measured on story text,
and sizes the engine's own frames with a few percent of headroom; measured
against the Studio on 1512 texts it falls short in under one percent of
them, where it fell short in 27. The overflow finding keeps the best
estimate, so a frame a person sized is not flagged for a few percent.
This commit is contained in:
Maurice
2026-10-23 05:07:47 +00:00
parent 08d7c37217
commit bb4412fae5
5 changed files with 180 additions and 21 deletions
+49 -14
View File
@@ -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<BookTextElement['font'], number> = {
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);
}
+83 -1
View File
@@ -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 })], {
+40 -1
View File
@@ -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`. */
+2 -2
View File
@@ -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. */
+6 -3
View File
@@ -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<string>): 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);