Files
TREK/shared/src/book/describe-spread.ts
T
Maurice d8213bfa42 feat(studio): groups, a layers panel, marquee, nudge, multi-resize, align and paste style
Elements can be grouped on a page and picked, moved, resized, aligned and
restacked as one. A layers panel lists the page front to back with lock,
visibility, rename and drag to restack. The sheet gains marquee selection,
arrow-key nudge, one box that resizes a multi-selection with Shift holding
the proportions, and the design-tool shortcuts. The inspector lines a
selection up and spaces it out, and copies and pastes a style.

The assistant gets the same through the group, ungroup, name and hide
operations, and describe_book_page lists the groups. The format gains the
optional group, name and hidden fields and a spread's group names, so
BOOK_SCHEMA_REV goes to 2.
2026-10-23 21:07:13 +02:00

346 lines
14 KiB
TypeScript

import { photoDpi, spreadFindings } from './book-findings';
import { designFindings, sortBookFindings } from './book-findings-design';
import { bookFontRole, bookWeightWord, nearestBookWeight } from './book-fonts';
import { BOOK_GRIDS } from './book-grids';
import { bookGroupName, groupMembers, spreadGroupIds } from './book-groups';
import { BOOK_COVER_TEMPLATES, BOOK_TEMPLATES, type BookTemplate } from './book-layouts';
import type { BookPhotoInfo } from './book-ops-core';
import type {
BookElementSummary,
BookFinding,
BookOverview,
BookPageDescription,
BookSpreadSummary,
} from './book-ops.schema';
import { paintWords } from './book-paint';
import { bookStickerWords } from './book-sticker';
import {
bookPageSetupSchema,
type BookDocument,
type BookElement,
type BookPageSetup,
type BookPhotoElement,
type BookSpread,
} from './book.schema';
/**
* A page in words, for a model that cannot see it and as the text beside a
* page image for one that can: the page and its role, every element back to
* front with its frame in millimetres, what each photo is and how it sits in
* its frame, what each text says and how it is set, then the findings.
*
* Also the short forms a read tool answers with: one row per page for
* `get_book`, the layouts a book can use for `list_book_layouts`.
*/
const n = (v: number) => `${Math.round(v * 10) / 10}`;
const head = (text: string, max: number) => {
const flat = text
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.join(' / ');
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
};
const ROLE_WORDS: Record<BookSpread['role'], string> = {
cover: 'the front cover, a single page',
first: 'the first page, a single right-hand page',
inner: 'a double spread',
last: 'the last page, a single left-hand page',
back: 'the back cover, a single page',
};
function orientation(w: number, h: number): string {
if (Math.abs(w - h) / Math.max(w, h) < 0.05) return 'square';
return w > h ? 'landscape' : 'portrait';
}
function photoWords(el: BookPhotoElement, info: BookPhotoInfo | undefined): string {
if (el.photoId == null) return 'empty frame';
const parts = [`photo ${el.photoId}`];
if (info?.hidden) parts.push('by another contributor, not shown');
if (info?.width && info.height) {
const pic = orientation(info.width, info.height);
const frame = orientation(el.frame.w, el.frame.h);
parts.push(`${pic} ${info.width} x ${info.height} px in a ${frame} frame`);
const ratio = info.width / info.height / (el.frame.w / el.frame.h);
const shown = Math.min(ratio, 1 / ratio);
if (el.fit === 'cover' && shown < 0.9) {
const crop = `cover shows ${Math.round(shown * 100)}% of the picture (no face detection: check people in the page image)`;
parts.push(crop);
}
}
parts.push(`${el.fit}, focal ${el.focalX.toFixed(2)}/${el.focalY.toFixed(2)}`);
const dpi = photoDpi(el, info);
if (dpi !== null) parts.push(`about ${dpi} dpi`);
if (el.filter !== 'none') parts.push(`filter ${el.filter}`);
if (el.mask) parts.push(`mask ${el.mask}`);
if (el.frameStyle !== 'none') parts.push(`frame style ${el.frameStyle}`);
if (el.radius > 0) parts.push(`radius ${n(el.radius)}`);
return parts.join(', ');
}
function elementWords(el: BookElement, photos: readonly BookPhotoInfo[] | undefined): string {
switch (el.kind) {
case 'photo':
return photoWords(
el,
photos?.find((p) => p.photoId === el.photoId),
);
case 'text': {
const weight = nearestBookWeight(el.font, el.weight);
const set = `${el.font} (${bookFontRole(el.font)}) ${n(el.size)} pt${weight === 400 ? '' : ` ${bookWeightWord(weight)}`}`;
const style = `${set}${el.italic ? ' italic' : ''}, ${el.align}, ${el.color}`;
const bound = el.binding ? `, bound to ${el.binding.source}${el.overridden ? ' (overridden)' : ''}` : '';
return `${style}: "${head(el.text, 80)}"${bound}`;
}
case 'shape':
return shapeWords(el);
case 'icon':
return `icon ${el.name}, ${el.color}, line ${n(el.lineWidth)}`;
case 'sticker':
return bookStickerWords(el);
default:
return travelWords(el);
}
}
function shapeWords(el: Extract<BookElement, { kind: 'shape' }>): string {
const parts = el.paint ? [`${el.shape}, paint ${paintWords(el.paint)}`] : [`${el.shape}, fill ${el.fill ?? 'none'}`];
if (!el.paint && el.gradient !== 'none') parts.push(`fading ${el.gradient}`);
if (el.stroke) parts.push(`stroke ${el.stroke} ${n(el.strokeWidth)} mm ${el.strokeStyle}`);
if (el.radius > 0) parts.push(`radius ${n(el.radius)}`);
return parts.join(', ');
}
type TravelElement = Extract<BookElement, { kind: 'map' | 'stats' | 'countries' | 'badge' | 'list' }>;
/** What a travel element shows, and how it is set: its face, ink, accent and size. */
function travelWords(el: TravelElement): string {
const set = `${el.font} ${bookWeightWord(nearestBookWeight(el.font, el.weight))}, ink ${el.color}, accent ${el.accent}, type x${n(el.textScale)}`;
const stale = el.stale ? '; its figures are out of date with the journey' : '';
return `${travelWhat(el)}; ${set}${stale}`;
}
function travelWhat(el: TravelElement): string {
switch (el.kind) {
case 'map': {
const scope = el.tripId != null ? `trip ${el.tripId}` : 'the whole journey';
const line = el.path.length ? `, a recorded track of ${el.path.length} segments` : '';
return `${el.style} ${el.source} map of ${scope}, ${el.points.length} stops, ${el.countries.length} countries${line}`;
}
case 'stats': {
const figures = el.metrics.map((m) => `${m} ${el.values[m] ?? 0}`).join(', ');
return `figures (${el.layout}): ${figures}`;
}
case 'countries':
return `${el.codes.length} countries (${el.codes.slice(0, 8).join(', ')}), ${el.layout}`;
case 'badge':
return `${el.variant} mark "${head(el.text, 40)}"${el.sub ? ` over "${head(el.sub, 40)}"` : ''}, ${el.style}`;
case 'list': {
const pros = el.items.filter((i) => i.tone === 'pro').length;
return `list of ${el.items.length} (${pros} pros, ${el.items.length - pros} others), ${el.layout}`;
}
}
}
function elementLine(el: BookElement, i: number, photos: readonly BookPhotoInfo[] | undefined): string {
const { x, y, w, h } = el.frame;
const extra = [
el.rotation ? `turned ${n(el.rotation)} deg` : '',
el.opacity < 1 ? `opacity ${n(el.opacity)}` : '',
el.locked ? 'locked' : '',
el.hidden ? 'hidden (not on the page or in print)' : '',
el.group ? `in group ${el.group}` : '',
el.name ? `named "${head(el.name, 40)}"` : '',
].filter(Boolean);
const tail = extra.length ? `; ${extra.join(', ')}` : '';
return `${i + 1}. ${el.kind} ${el.id} at ${n(x)}, ${n(y)}, ${n(w)} x ${n(h)}: ${elementWords(el, photos)}${tail}`;
}
/** The page's groups by id, with their names and members, so a model can move or align one as a whole. */
function groupsLine(spread: BookSpread): string | null {
const ids = spreadGroupIds(spread);
if (!ids.length) return null;
const rows = ids.map((id) => {
const name = bookGroupName(spread, id);
const members = groupMembers(spread, id).map((e) => e.id);
return `${id}${name ? ` "${head(name, 40)}"` : ''} (${members.join(', ')})`;
});
return `Groups, each picked, moved, aligned and arranged as one: ${rows.join('; ')}`;
}
function parkedLine(spread: BookSpread): string | null {
if (!spread.parked.length) return null;
const items = spread.parked
.slice(0, 12)
.map((el) =>
el.kind === 'photo'
? `photo ${el.photoId ?? 'empty'}`
: el.kind === 'text'
? `text "${head(el.text, 30)}"`
: el.kind,
);
const more = spread.parked.length > 12 ? `, and ${spread.parked.length - 12} more` : '';
return `Parked off the page (${spread.parked.length}): ${items.join(', ')}${more}`;
}
function pageLine(doc: BookDocument, spread: BookSpread, index: number): string {
const p = doc.page;
const single = spread.role !== 'inner';
const size = single
? `${n(p.pageWidth)} x ${n(p.pageHeight)} mm`
: `${n(p.pageWidth * 2)} x ${n(p.pageHeight)} mm (two pages of ${n(p.pageWidth)} x ${n(p.pageHeight)}), fold at x ${n(p.pageWidth)}`;
const entry = spread.entryId != null ? ` From entry ${spread.entryId}.` : '';
return `Page ${index + 1} of ${doc.spreads.length} (${spread.id}): ${ROLE_WORDS[spread.role]}, ${size}; bleed ${n(p.bleed)}, safe ${n(p.safe)}. Background ${backgroundWords(spread)}.${entry}`;
}
function backgroundWords(spread: BookSpread): string {
if (spread.backgroundPaint) return `${paintWords(spread.backgroundPaint)} (reads as ${spread.background ?? 'paper'})`;
return spread.background ?? 'paper (none set)';
}
function findingLine(f: BookFinding): string {
return `- ${f.severity} ${f.code}: ${f.message}`;
}
function elementSummary(el: BookElement): BookElementSummary {
const { x, y, w, h } = el.frame;
return {
id: el.id,
kind: el.kind,
frame: { x, y, w, h },
rotation: el.rotation,
locked: el.locked,
...(el.kind === 'photo' ? { photoId: el.photoId } : {}),
...(el.kind === 'text' ? { text: head(el.text, 80) } : {}),
...(el.kind === 'text' && el.binding ? { binding: el.binding.source } : {}),
...(el.group ? { group: el.group } : {}),
...(el.name ? { name: el.name } : {}),
...(el.hidden ? { hidden: true } : {}),
};
}
/**
* Every finding of one page, the print faults and the design checks
* (book-findings-design.ts) together, severe first, at most the cap.
*/
export function pageFindings(
spread: BookSpread,
page: BookPageSetup,
photos?: readonly BookPhotoInfo[],
): BookFinding[] {
return sortBookFindings([...spreadFindings(spread, page, photos), ...designFindings(spread, page, photos)]);
}
/**
* One page in words, with its findings and its elements. Null when the book
* has no page with that id.
*
* `photos` is the journey gallery as far as it is known: the original's size
* gives the print resolution, `hidden` marks a photo the page image leaves out.
*/
export function describeSpread(
doc: BookDocument,
spreadId: string,
photos?: readonly BookPhotoInfo[],
): BookPageDescription | null {
const index = doc.spreads.findIndex((s) => s.id === spreadId);
const spread = doc.spreads[index];
if (!spread) return null;
const findings = pageFindings(spread, doc.page, photos);
const lines = [pageLine(doc, spread, index)];
if (spread.elements.length) {
lines.push('Elements, back to front (frame in mm: x, y, w x h):');
spread.elements.forEach((el, i) => lines.push(elementLine(el, i, photos)));
} else {
lines.push('The page is empty.');
}
const groups = groupsLine(spread);
if (groups) lines.push(groups);
const parked = parkedLine(spread);
if (parked) lines.push(parked);
lines.push(findings.length ? 'Findings:' : 'Findings: none.');
findings.forEach((f) => lines.push(findingLine(f)));
return {
spreadId: spread.id,
index,
role: spread.role,
text: lines.join('\n'),
findings,
elements: spread.elements.map(elementSummary),
};
}
/** One page as a row of `get_book`. */
export function summarizeSpread(spread: BookSpread, index: number): BookSpreadSummary {
const photos = spread.elements.filter((e): e is BookPhotoElement => e.kind === 'photo');
return {
index,
id: spread.id,
role: spread.role,
entryId: spread.entryId,
background: spread.background,
photoIds: photos.flatMap((p) => (p.photoId == null ? [] : [p.photoId])),
texts: spread.elements.flatMap((e) => (e.kind === 'text' && e.text.trim() ? [head(e.text, 60)] : [])),
elements: spread.elements.length,
emptyFrames: photos.filter((p) => p.photoId == null).length,
parked: spread.parked.length,
};
}
/** `get_book`: the book without its document; `doc` null is a journey without a book. */
export function summarizeBook(doc: BookDocument | null, version: number | null): BookOverview {
const { preset, pageWidth, pageHeight, bleed, safe } = doc?.page ?? bookPageSetupSchema.parse({});
return {
exists: doc !== null,
version: doc ? version : null,
title: doc?.title ?? '',
page: { preset, pageWidth, pageHeight, bleed, safe },
spreads: doc ? doc.spreads.map(summarizeSpread) : [],
layouts: (doc?.layouts ?? []).map((l) => ({ id: l.id, name: l.name, role: l.role })),
};
}
function templateRow(tpl: BookTemplate, page: BookPageSetup) {
const slots = tpl.build(page);
return {
id: tpl.id,
photoSlots: tpl.photoSlots,
textSlots: slots.flatMap((s) => (s.kind === 'photo' ? [] : [s.kind])),
};
}
/**
* What a layout does to what is on the page, said where a model picks one: a
* grid here is not the Studio's grid button (which adds empty frames on top),
* and a template or saved layout leaves out what it has no slot kind for.
*/
const BOOK_LAYOUT_NOTES = {
templates:
"A template or saved layout re-pours the photos and texts of the page into its slots and parks what it has no room for; the page's own shapes, maps, badges, stats, lists, icons and stickers are dropped (a saved layout brings its own), and the write answer names them in dropped.",
grids:
'A grid re-pours every photo of the page, placed or parked, into its cells and parks the rest; the old frames go, empty ones too. Texts, shapes and travel elements stay where they are.',
} as const;
/** `list_book_layouts`: what a page can be laid out on, for a double spread, a single page, or both. */
export function listBookLayouts(doc: BookDocument | null, page: BookPageSetup, role?: 'inner' | 'single') {
const want = (r: 'inner' | 'single') => role === undefined || role === r;
return {
notes: BOOK_LAYOUT_NOTES,
inner: want('inner') ? BOOK_TEMPLATES.map((t) => templateRow(t, page)) : [],
single: want('single') ? BOOK_COVER_TEMPLATES.map((t) => templateRow(t, page)) : [],
grids: BOOK_GRIDS.map((g) => ({ id: g.id, cells: g.cells.length })),
saved: (doc?.layouts ?? [])
.filter((l) => want(l.role))
.map((l) => ({
id: l.id,
name: l.name,
role: l.role,
photoFrames: l.elements.filter((e) => e.kind === 'photo').length,
texts: l.elements.filter((e) => e.kind === 'text').length,
})),
};
}