mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
d8213bfa42
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.
346 lines
14 KiB
TypeScript
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,
|
|
})),
|
|
};
|
|
}
|