Files
TREK/client/src/components/Assistant/cards/book/bookCardModel.ts
T
Maurice 75d0593198 feat(studio): text effects, fit to frame and 24 lockups in a Text styles shelf
- A text can be cased (capitals, lower case, small caps), placed at the top,
  middle or foot of its frame, outlined, given a hard shadow or a marker band,
  bent on an arc or round a circle, filled with a gallery photo or left
  hollow, and shrunk to fit its frame. Every effect prints as vector: copies
  and strokes for HTML type, textPath and a clip over an <image> for SVG.
- Fitting is solved when the text is edited, in the Studio and in the edit
  engine, and stored; the chosen size is kept as the ceiling.
- Text sizes reach 400 pt and tracking 1.5 em.
- The Elements panel opens on a Text tab: the plain styles and 24 lockups
  (titles, badges and stamps, chapter marks, quotes, captions, places), each
  drawn with the book's own title and placed as one group.
- The assistant sets every effect through style and add, places a lockup with
  add_lockup, reads the effects in describe_book_page and gets two new
  findings (curve_overflow, effect_heavy) beside photo-fill and band checks.
- Book format revision 6.
2026-10-23 22:08:13 +00:00

185 lines
8.7 KiB
TypeScript

import {
applyBookOps,
type BookCardCover,
type BookCardFinding,
type BookCardLayout,
type BookCardPage,
type BookDocument,
type BookFindingCode,
type BookPageCard,
type BookSpread,
findBookGrid,
findBookTemplate,
setBookCover,
} from '@trek/shared';
import type { ChangeState } from '../../assistantParts';
/**
* The pure side of the book's page card: how a page is named, what a layout
* and a finding are called, and which spreads the card draws. The page before
* a change comes from the Studio's own book; the page after is the one the
* server's trial of the change made with the journey's photos, words and
* figures, which the card carries, so it shows the page as the server would
* write it, while the Studio's book is at the version the trial ran on. A
* card without that page, or a book that moved on since, runs the card's
* operations on the Studio's book with the shared engine, the one the server
* tried the change with: the change will run on that book.
*/
type Translate = (key: string, params?: Record<string, string | number>) => string;
/** The single pages by their role; a double page is named by its number. */
const ROLE_LOOK = {
cover: { labelKey: 'assistant.card.book.cover' },
first: { labelKey: 'assistant.card.book.first' },
last: { labelKey: 'assistant.card.book.last' },
back: { labelKey: 'assistant.card.book.back' },
} as const satisfies Record<Exclude<BookCardPage['role'], 'inner'>, { labelKey: string }>;
/** What each finding of the server's check is called. A table, so a new code fails to compile until it has words. */
const FINDING_LOOK: Readonly<Record<BookFindingCode, { labelKey: string }>> = {
overlap: { labelKey: 'assistant.card.book.finding.overlap' },
fold: { labelKey: 'assistant.card.book.finding.fold' },
trim: { labelKey: 'assistant.card.book.finding.trim' },
empty_frame: { labelKey: 'assistant.card.book.finding.empty_frame' },
low_dpi: { labelKey: 'assistant.card.book.finding.low_dpi' },
focal_cut: { labelKey: 'assistant.card.book.finding.focal_cut' },
text_overflow: { labelKey: 'assistant.card.book.finding.text_overflow' },
parked: { labelKey: 'assistant.card.book.finding.parked' },
hidden_photo: { labelKey: 'assistant.card.book.finding.hidden_photo' },
not_in_gallery: { labelKey: 'assistant.card.book.finding.not_in_gallery' },
text_on_busy: { labelKey: 'assistant.card.book.finding.text_on_busy' },
contrast_low: { labelKey: 'assistant.card.book.finding.contrast_low' },
small_text: { labelKey: 'assistant.card.book.finding.small_text' },
crowded: { labelKey: 'assistant.card.book.finding.crowded' },
off_grid: { labelKey: 'assistant.card.book.finding.off_grid' },
font_mix: { labelKey: 'assistant.card.book.finding.font_mix' },
palette_drift: { labelKey: 'assistant.card.book.finding.palette_drift' },
repeat_layout: { labelKey: 'assistant.card.book.finding.repeat_layout' },
raster_effect: { labelKey: 'assistant.card.book.finding.raster_effect' },
hairline: { labelKey: 'assistant.card.book.finding.hairline' },
edge_unused: { labelKey: 'assistant.card.book.finding.edge_unused' },
curve_overflow: { labelKey: 'assistant.card.book.finding.curve_overflow' },
effect_heavy: { labelKey: 'assistant.card.book.finding.effect_heavy' },
};
/** A page as the Studio names it: the cover, the first page, double page 3. */
export function pageLabel(page: BookCardPage, t: Translate): string {
if (page.role !== 'inner') return t(ROLE_LOOK[page.role].labelKey);
return page.number === null
? t('assistant.card.book.spreadAny')
: t('assistant.card.book.spread', { page: page.number });
}
/** What the change is about: its pages, or a new book. */
export function subjectOf(card: BookPageCard, t: Translate): string {
if (card.pages.length === 0) return t('assistant.card.book.newBook');
return card.pages.map((page) => pageLabel(page, t)).join(', ');
}
/** The name of the layout a change lays its page out on; null when there is none or this build does not know it. */
export function layoutLabel(layout: BookCardLayout | null, t: Translate): string | null {
if (!layout) return null;
if (layout.kind === 'auto') return t('assistant.card.book.layoutAuto');
if (layout.kind === 'saved') return layout.name ?? layout.id;
if (layout.kind === 'grid') {
const grid = findBookGrid(layout.id);
return grid ? t('assistant.card.book.grid', { count: grid.cells.length }) : null;
}
const template = findBookTemplate(layout.id);
return template ? t(template.labelKey) : null;
}
/** The name of one finding. */
export function findingLabel(finding: BookCardFinding, t: Translate): string {
return t(FINDING_LOOK[finding.code].labelKey);
}
/** The findings worth a look (severe ones and warnings, those past the card counted), and whether one is a fault. */
export function findingsToCheck(card: BookPageCard): { count: number; severe: boolean } {
const shown = card.findings.filter((finding) => finding.severity !== 'info').length;
const severe = card.findings.some((finding) => finding.severity === 'severe');
return { count: shown + card.moreFindings, severe };
}
/** Each kind of finding once, the most severe first, as the server ordered them. */
export function distinctFindings(card: BookPageCard): BookCardFinding[] {
const seen = new Set<BookFindingCode>();
return card.findings.filter((finding) => {
if (seen.has(finding.code)) return false;
seen.add(finding.code);
return true;
});
}
/** What a page card draws of one page: where it stands, and the page before the change and after it. */
export interface BookPageView {
index: number;
before: BookSpread | null;
after: BookSpread | null;
}
const GONE: ReadonlySet<ChangeState> = new Set(['failed', 'discarded', 'expired']);
/** `set_book_cover`'s arguments from the card, without the parts it leaves as they are. */
function coverArgs(cover: BookCardCover): Record<string, unknown> {
return Object.fromEntries(Object.entries(cover).filter(([, value]) => value !== null));
}
/**
* The page as the change would leave it: the one the server's trial made,
* when the card carries it for this page and the Studio's book is still at
* the version the trial ran on; else run on the Studio's book without the
* journey's photos, words and figures a binding or a travel element reads.
* A book that moved on (another change applied, a save by hand or by someone
* else) is the book the change will run on, so the trial's page would show
* the page as it no longer is. Null when the card carries nothing to run.
*/
function afterOf(card: BookPageCard, doc: BookDocument, spreadId: string, version: number | null): BookSpread | null {
const atTrial = card.baseVersion !== null && card.baseVersion === version;
if (atTrial && card.after?.id === spreadId) return card.after;
let made: ReturnType<typeof applyBookOps> | null = null;
if (card.cover) made = setBookCover(doc, coverArgs(card.cover));
else if (card.ops.length > 0) made = applyBookOps(doc, { spreadId, ops: card.ops });
if (!made || made.ok === false) return null;
return made.doc.spreads.find((spread) => spread.id === spreadId) ?? null;
}
/** Where a page stands in the book on screen, and the page; null where the book does not hold it. */
function spreadAt(doc: BookDocument, spreadId: string): { index: number; current: BookSpread } | null {
const index = doc.spreads.findIndex((spread) => spread.id === spreadId);
const current = doc.spreads[index];
return current ? { index, current } : null;
}
/**
* The spreads a page card draws from the Studio's book, one view per page:
* while the change waits, its page as it is and as the change would leave it
* (a move shows its page alone, a delete each page it takes alone); once
* applied, the page as it is now. Nothing for new pages not made yet, for a
* change that did not run, or where the book on screen does not hold the
* page (another book, or none open). `version` is the stored book's version
* the book on screen stands on, null where the Studio does not say.
*/
export function pageViewOf(
card: BookPageCard,
doc: BookDocument | null,
state: ChangeState,
version: number | null = null
): BookPageView[] {
if (!doc || GONE.has(state)) return [];
if (card.change === 'willDelete' && state !== 'applied') {
return card.pages.flatMap((page) => {
const found = spreadAt(doc, page.spreadId);
return found ? [{ index: found.index, before: found.current, after: null }] : [];
});
}
const spreadId = card.pages[0]?.spreadId;
const found = spreadId ? spreadAt(doc, spreadId) : null;
if (!spreadId || !found) return [];
if (state === 'applied') return [{ index: found.index, before: null, after: found.current }];
if (card.change === 'new') return [];
return [{ index: found.index, before: found.current, after: afterOf(card, doc, spreadId, version) }];
}