Compare commits

..

4 Commits

Author SHA1 Message Date
Maurice 2c065356dc test(studio): drive the travel inspector's type through the typeface list and the weight slider 2026-10-23 20:40:40 +02:00
Maurice e0cca62347 fix(studio): declare Big Shoulders Display's bare style imports, and let a raced Studio save carry its revision 2026-10-23 20:10:31 +02:00
Maurice 1222fb3901 fix(studio): import Big Shoulders Display by its export name, which takes no .css 2026-10-23 20:10:30 +02:00
Maurice 142aa61295 feat(studio): 34 typefaces with real weights and italics, a categorised font picker
The book catalogue grows from 7 families to 34, all self-hosted through
@fontsource under the SIL OFL 1.1 or the Apache License 2.0: condensed and
poster faces, heavy retro display, elegant display serifs, Montserrat, twelve
scripts and DM Mono. Weights run from 100 to 900, each family offering only
the weights it ships, and the serifs gain real italics.

The catalogue lives in @trek/shared (book-fonts.data.ts) with each family's
roles (display, heading, body, script, mono), weights, italics and letter
width, so the Studio, the findings, the design engine and the assistant read
one list. Page operations and the design engine set a weight a family lacks
at its nearest one, describe_book_page names the role and the weight, and
font_mix no longer counts scripts and mono faces, warning above three.

The Studio gets StudioFontPicker: the current family set in itself, a
searchable list grouped by kind with every family in its own face and the
book's own families first, and a weight slider over the weights the family
has. Faces load only when used; the assistant's page snapshot embeds only the
faces a page draws, the service worker installs only the latin files of the
original seven families, and the faces of an opened book are cached for
offline use. BOOK_SCHEMA_REV goes to 2.
2026-10-23 20:10:30 +02:00
188 changed files with 4176 additions and 3479 deletions
+51
View File
@@ -53,3 +53,54 @@ own switch in the admin panel. https://cloud.google.com/maps-platform/terms
`server/assets/airports.json` is built from **OurAirports**
(https://ourairports.com/data/), released into the public domain.
## Typefaces: Studio books and the app
Every typeface a book can be set in, and the app's own (Poppins, MuseoModerno,
Geist), is self-hosted from the **@fontsource** npm packages, which redistribute
the families published on Google Fonts split into subsets by script. The catalogue of book faces is
`shared/src/book/book-fonts.data.ts`. Each package carries its licence and the
copyright notice of the family's authors in its `LICENSE` file.
Both licences allow a font to be bundled with software and embedded in a
document made with it, so a printed or exported book may carry the faces it is
set in. No family is renamed, redrawn or sold on its own. SIL OFL 1.1: https://openfontlicense.org. Apache 2.0:
https://www.apache.org/licenses/LICENSE-2.0
| Family | Package | Licence |
|---|---|---|
| Abril Fatface | `@fontsource/abril-fatface` | SIL Open Font License 1.1 |
| Alfa Slab One | `@fontsource/alfa-slab-one` | SIL Open Font License 1.1 |
| Allura | `@fontsource/allura` | SIL Open Font License 1.1 |
| Anton | `@fontsource/anton` | SIL Open Font License 1.1 |
| Archivo Black | `@fontsource/archivo-black` | SIL Open Font License 1.1 |
| Bebas Neue | `@fontsource/bebas-neue` | SIL Open Font License 1.1 |
| Big Shoulders Display | `@fontsource/big-shoulders-display` | SIL Open Font License 1.1 |
| Bodoni Moda | `@fontsource/bodoni-moda` | SIL Open Font License 1.1 |
| Bricolage Grotesque | `@fontsource/bricolage-grotesque` | SIL Open Font License 1.1 |
| Caveat | `@fontsource/caveat` | SIL Open Font License 1.1 |
| Cormorant Garamond | `@fontsource/cormorant-garamond` | SIL Open Font License 1.1 |
| Dancing Script | `@fontsource/dancing-script` | SIL Open Font License 1.1 |
| DM Mono | `@fontsource/dm-mono` | SIL Open Font License 1.1 |
| DM Serif Display | `@fontsource/dm-serif-display` | SIL Open Font License 1.1 |
| EB Garamond | `@fontsource/eb-garamond` | SIL Open Font License 1.1 |
| Fraunces | `@fontsource/fraunces` | SIL Open Font License 1.1 |
| Great Vibes | `@fontsource/great-vibes` | SIL Open Font License 1.1 |
| Homemade Apple | `@fontsource/homemade-apple` | Apache License 2.0 |
| Inter | `@fontsource/inter` | SIL Open Font License 1.1 |
| Kaushan Script | `@fontsource/kaushan-script` | SIL Open Font License 1.1 |
| League Gothic | `@fontsource/league-gothic` | SIL Open Font License 1.1 |
| Lora | `@fontsource/lora` | SIL Open Font License 1.1 |
| Montserrat | `@fontsource/montserrat` | SIL Open Font License 1.1 |
| MuseoModerno | `@fontsource/museomoderno` | SIL Open Font License 1.1 |
| Oswald | `@fontsource/oswald` | SIL Open Font License 1.1 |
| Pacifico | `@fontsource/pacifico` | SIL Open Font License 1.1 |
| Parisienne | `@fontsource/parisienne` | SIL Open Font License 1.1 |
| Permanent Marker | `@fontsource/permanent-marker` | Apache License 2.0 |
| Playfair Display | `@fontsource/playfair-display` | SIL Open Font License 1.1 |
| Poppins | `@fontsource/poppins` | SIL Open Font License 1.1 |
| Sacramento | `@fontsource/sacramento` | SIL Open Font License 1.1 |
| Satisfy | `@fontsource/satisfy` | Apache License 2.0 |
| Shrikhand | `@fontsource/shrikhand` | SIL Open Font License 1.1 |
| Yellowtail | `@fontsource/yellowtail` | Apache License 2.0 |
| Geist Sans | `@fontsource/geist-sans` | SIL Open Font License 1.1 |
+67
View File
@@ -0,0 +1,67 @@
import { BOOK_FONT_FACES, BOOK_FONTS_PRECACHED } from '@trek/shared';
/**
* Which font files the service worker installs with the app.
*
* The precache takes every woff and woff2 the build emits, and with the book's
* catalogue at thirty four families that is every weight, both styles and
* every script subset of each: megabytes fetched at install by every user,
* most of it for faces no book of theirs is set in. So the book's faces are
* held to this:
*
* - The faces the app itself draws (Poppins and MuseoModerno for the chrome,
* imported in src/main.tsx) stay exactly as they were.
* - The faces the book shipped with before the catalogue grew
* (`BOOK_FONTS_PRECACHED`) keep their latin woff2, so a book set in them
* still opens offline before Studio was ever opened online.
* - Every other file of a book family, woff, other subsets, new families,
* real italics, is left to the `book-fonts` runtime cache in vite.config.js,
* which the Studio fills with the faces of each book it opens
* (src/components/Studio/bookFontWarm.ts).
*
* Shared by vite.config.js and its test, which reads the filter against file
* names as the build writes them.
*/
/** The faces src/main.tsx imports for the app chrome, which the book shares; bookFontPrecache.test.ts checks this copy. */
export const APP_FACES = {
poppins: [300, 400, 500, 600, 700],
museomoderno: [400, 700, 800],
};
/** The book packages, longest name first, so `dm-serif-display` is never read as `dm-…`. */
const BOOK_PACKAGES = [...new Set(Object.values(BOOK_FONT_FACES).map((f) => f.pkg))].sort(
(a, b) => b.length - a.length
);
const PRECACHED = new Map(
Object.entries(BOOK_FONTS_PRECACHED).map(([id, weights]) => [BOOK_FONT_FACES[id].pkg, new Set(weights)])
);
/**
* A font file as the build names it, `<package>-<subset>-<weight>-<style>-<hash>.<ext>`,
* read against the book's packages; null for any other file.
*/
export function bookFontFile(url) {
const name = url.split('/').pop() ?? '';
const match = /^(.+)-(\d{3})-(normal|italic)-[\w-]+\.(woff2?)$/.exec(name);
if (!match) return null;
const [, head, weight, style, ext] = match;
const pkg = BOOK_PACKAGES.find((p) => head.startsWith(`${p}-`));
if (!pkg) return null;
return { pkg, subset: head.slice(pkg.length + 1), weight: Number(weight), italic: style === 'italic', ext };
}
/** Whether a file of the build goes into the install-time precache. */
export function keepInPrecache(url) {
const file = bookFontFile(url);
if (!file) return true;
if (!file.italic && APP_FACES[file.pkg]?.includes(file.weight)) return true;
const kept = PRECACHED.get(file.pkg);
return !file.italic && file.subset === 'latin' && file.ext === 'woff2' && !!kept?.has(file.weight);
}
/** The precache manifest without the book faces left to the runtime cache: a Workbox `manifestTransforms` entry. */
export function withoutRuntimeFonts(entries) {
return { manifest: entries.filter((entry) => keepInPrecache(entry.url)), warnings: [] };
}
+27
View File
@@ -47,14 +47,41 @@
},
"dependencies": {
"@assistant-ui/react": "0.15.25",
"@fontsource/abril-fatface": "^5.3.0",
"@fontsource/alfa-slab-one": "^5.3.0",
"@fontsource/allura": "^5.3.0",
"@fontsource/anton": "^5.3.0",
"@fontsource/archivo-black": "^5.3.0",
"@fontsource/bebas-neue": "^5.3.0",
"@fontsource/big-shoulders-display": "^5.3.0",
"@fontsource/bodoni-moda": "^5.3.0",
"@fontsource/bricolage-grotesque": "^5.3.0",
"@fontsource/caveat": "^5.3.0",
"@fontsource/cormorant-garamond": "^5.3.0",
"@fontsource/dancing-script": "^5.3.0",
"@fontsource/dm-mono": "^5.3.0",
"@fontsource/dm-serif-display": "^5.3.0",
"@fontsource/eb-garamond": "^5.3.0",
"@fontsource/fraunces": "^5.3.0",
"@fontsource/geist-sans": "^5.3.0",
"@fontsource/great-vibes": "^5.3.0",
"@fontsource/homemade-apple": "^5.3.0",
"@fontsource/inter": "^5.3.0",
"@fontsource/kaushan-script": "^5.3.0",
"@fontsource/league-gothic": "^5.3.0",
"@fontsource/lora": "^5.3.0",
"@fontsource/montserrat": "^5.3.0",
"@fontsource/museomoderno": "^5.3.0",
"@fontsource/oswald": "^5.3.0",
"@fontsource/pacifico": "^5.3.0",
"@fontsource/parisienne": "^5.3.0",
"@fontsource/permanent-marker": "^5.3.0",
"@fontsource/playfair-display": "^5.3.0",
"@fontsource/poppins": "^5.3.0",
"@fontsource/sacramento": "^5.3.0",
"@fontsource/satisfy": "^5.3.0",
"@fontsource/shrikhand": "^5.3.0",
"@fontsource/yellowtail": "^5.3.0",
"@mapbox/mapbox-gl-rtl-text": "^0.4.0",
"@maplibre/maplibre-gl-leaflet": "0.1.4",
"@simplewebauthn/browser": "^13.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 402,
"@typescript-eslint/no-non-null-assertion": 404,
"@typescript-eslint/no-misused-promises": 854,
"no-console": 88,
"complexity": 226,
+1 -2
View File
@@ -52,8 +52,7 @@
"components/Studio/BookSheetsView.tsx": 3,
"components/Studio/PageNumbers.tsx": 1,
"components/Studio/PeerCursors.tsx": 1,
"components/Studio/PhotoView.tsx": 4,
"components/Studio/SpreadView.tsx": 4,
"components/Studio/SpreadView.tsx": 8,
"components/Studio/StudioCanvas.tsx": 22,
"components/Studio/StudioElementsPanel.tsx": 1,
"components/Studio/StudioExport.tsx": 1,
+1 -1
View File
@@ -26,5 +26,5 @@
"src/pages/atlas/useAtlas.ts": 1211,
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
"src/pages/tripPlanner/useTripPlanner.test.tsx": 3270,
"src/styles/studio.css": 2087
"src/styles/studio.css": 2057
}
+1 -2
View File
@@ -100,8 +100,7 @@
"components/Roadtrip/serviceMarker.ts": 1,
"components/Settings/useAppearanceEditor.ts": 5,
"components/Studio/BookSheetsView.tsx": 2,
"components/Studio/PhotoView.tsx": 2,
"components/Studio/SpreadView.tsx": 1,
"components/Studio/SpreadView.tsx": 3,
"components/Studio/StudioElementsPanel.tsx": 8,
"components/Studio/StudioInspector.tsx": 2,
"components/Studio/StudioShell.tsx": 2,
+1 -2
View File
@@ -40,8 +40,7 @@
"components/Settings/AppearanceSettingsTab.tsx": 1,
"components/Settings/PasskeysSection.tsx": 1,
"components/Studio/BookSheetsView.tsx": 1,
"components/Studio/PhotoView.tsx": 1,
"components/Studio/SpreadView.tsx": 1,
"components/Studio/SpreadView.tsx": 2,
"components/Studio/StudioElementsPanel.tsx": 2,
"components/Studio/StudioSwatches.tsx": 1,
"components/Studio/TravelElements.tsx": 6,
@@ -57,7 +57,6 @@ const FINDING_LOOK: Readonly<Record<BookFindingCode, { labelKey: string }>> = {
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' },
};
/** A page as the Studio names it: the cover, the first page, double page 3. */
+2 -2
View File
@@ -1,5 +1,5 @@
import type { BookPageSetup, BookSpread } from '@trek/shared'
import { fontStack } from './bookFonts'
import { fontStack, nearestWeight } from './bookFonts'
import { folioInk } from './folioColour'
import { isNumbered } from './bookSheets'
@@ -94,7 +94,7 @@ export function PageNumbers({
textAlign: at.align,
fontFamily: fontStack(cfg.font),
fontSize: `${size}pt`,
fontWeight: 500,
fontWeight: nearestWeight(cfg.font, 500),
letterSpacing: '0.08em',
lineHeight: 1,
color: ink.color,
-429
View File
@@ -1,429 +0,0 @@
import { photoLookOf, type BookFrame, type BookPhotoElement, type BookShapeId, type PhotoLook } from '@trek/shared';
import { useId, type CSSProperties, type ReactNode } from 'react';
import { fontStack } from './bookFonts';
import { photoSrc } from './bookRender';
import { pictureFilter, silhouetteShadows, svgFilterMatrices, svgId, zoomPlacement } from './photo/photoLook';
import { HOLED_SHAPES, SHAPE_PATHS, scalePath, unitPath } from './shapes';
/**
* A picture on the page: possibly cut to a shape, possibly sitting in
* something, possibly wearing a look (book-photo-look.schema.ts in shared).
*
* Split out of SpreadView.tsx, which draws every kind and had no room left
* for the look. It is still the one renderer: the editor, the page
* thumbnails and the print path all draw a photo through this.
*
* The mask and the decoration are independent on purpose: a heart in a Polaroid
* is a silly thing to want and there is no reason to stop anyone wanting it, and
* keeping them separate means neither has to know about the other.
*
* ── The look, and how each part prints ───────────────────────────────────
*
* Border, hard shadow and cutout are flat shapes of one colour, so they stay
* vector in the PDF. On a rectangular frame (no mask, or a card such as a
* Polaroid around it) the cutout and the shadow are box shadows without blur,
* which follow the corner radius; on a frame cut to a shape they are that
* shape's path, drawn behind the picture. The border is drawn over the edge of
* the picture, inside the frame, so the frame stays the box the fold and trim
* checks measure. Zoom and mirror move the picture inside its clip and change
* nothing around it. Adjustments, warmth and a duotone are filters and print
* as a raster, which the inspector says.
*
* Every SVG id is per instance (useId): the page view draws each spread twice,
* and two clip paths with one id cut the second picture with the first one's
* shape.
*/
/** Two decimals of a millimetre, matching what the document itself stores. */
const round2 = (n: number) => Math.round(n * 100) / 100;
const PHOTO_FILTERS: Record<string, string | undefined> = {
none: undefined,
bw: 'grayscale(1) contrast(1.05)',
warm: 'saturate(1.1) sepia(0.16)',
cool: 'saturate(1.05) hue-rotate(-8deg) brightness(1.02)',
// Faded, the way a print left in the light goes: lifted blacks, less colour.
fade: 'saturate(0.72) contrast(0.88) brightness(1.08)',
contrast: 'contrast(1.22) saturate(1.06)',
};
/**
* How much of the frame the decoration around a picture eats, as a fraction of
* the shorter side.
*
* A Polaroid's chin is the reason this is a table rather than one number: the
* bottom border is roughly three times the others, and getting that ratio wrong
* is the difference between a Polaroid and a photo with a white edge.
*/
const FRAME_INSET: Record<string, { pad: number; bottom: number }> = {
none: { pad: 0, bottom: 0 },
polaroid: { pad: 0.055, bottom: 0.17 },
white: { pad: 0.035, bottom: 0.035 },
shadow: { pad: 0, bottom: 0 },
film: { pad: 0.075, bottom: 0.075 },
tape: { pad: 0, bottom: 0 },
};
/** The frame styles that draw a card around the picture, which makes the frame's outline a rectangle. */
const CARDS = new Set(['polaroid', 'white', 'film']);
/** The cutout and the hard shadow of a frame cut to a shape: its path, grown and offset, behind the picture. */
function ShapeSilhouette({ frame, mask, look }: { frame: BookFrame; mask: BookShapeId; look: PhotoLook }) {
const { w, h } = frame;
const d = scalePath(SHAPE_PATHS[mask], w, h);
const rule = HOLED_SHAPES.has(mask) ? 'evenodd' : undefined;
const grow = look.cutout?.width ?? 0;
const path = (color: string, dx = 0, dy = 0) => (
<path
d={d}
transform={dx || dy ? `translate(${dx} ${dy})` : undefined}
fill={color}
fillRule={rule}
stroke={grow ? color : undefined}
strokeWidth={grow ? grow * 2 : undefined}
strokeLinejoin="round"
/>
);
return (
<svg
aria-hidden
width={`${w}mm`}
height={`${h}mm`}
viewBox={`0 0 ${w} ${h}`}
style={{ position: 'absolute', inset: 0, overflow: 'visible', display: 'block' }}
>
{look.shadow && <g opacity={look.shadow.alpha}>{path(look.shadow.color, look.shadow.dx, look.shadow.dy)}</g>}
{look.cutout && path(look.cutout.color)}
</svg>
);
}
/** The border: a band along the inside of the picture's edge, following its shape. */
function BorderBand({
mask,
radius,
look,
width,
height,
}: {
mask: BookShapeId | null;
radius: number;
look: PhotoLook;
width: number;
height: number;
}) {
const border = look.border;
if (!border || width <= 0 || height <= 0) return null;
if (mask && mask !== 'rect') {
// Stroked twice as wide as the band and cut by the picture's own clip, so
// exactly the inner half shows and the band follows every curve.
return (
<svg
aria-hidden
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="none"
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', display: 'block' }}
>
<path
d={scalePath(SHAPE_PATHS[mask], width, height)}
fill="none"
stroke={border.color}
strokeWidth={border.width * 2}
strokeLinejoin="round"
/>
</svg>
);
}
return (
<div
aria-hidden
style={{
position: 'absolute',
inset: 0,
borderStyle: 'solid',
borderWidth: `${border.width}mm`,
borderColor: border.color,
borderRadius: radius ? `${radius}mm` : undefined,
boxSizing: 'border-box',
}}
/>
);
}
/** The picture itself, zoomed about its focal point and mirrored where the look says so. */
function Picture({
el,
photoId,
look,
big,
print,
filterId,
}: {
el: BookPhotoElement;
photoId: number;
look: PhotoLook;
big: boolean;
print: boolean;
filterId: string | null;
}) {
const zoom = zoomPlacement(look.zoom, el.focalX, el.focalY);
const img = (
<img
src={photoSrc(photoId, big)}
alt=""
draggable={false}
// Lazy on the canvas, where a long book scrolls past; eager in print,
// where every sheet is laid out at once and a picture the browser put
// off is a picture missing from the PDF.
loading={print ? 'eager' : 'lazy'}
style={{
width: zoom?.size ?? '100%',
height: zoom?.size ?? '100%',
transform: zoom?.transform,
objectFit: el.fit,
objectPosition: `${el.focalX * 100}% ${el.focalY * 100}%`,
filter: pictureFilter(PHOTO_FILTERS[el.filter], look, filterId),
display: 'block',
}}
/>
);
if (!look.flipX) return img;
// The picture is mirrored inside a box the size of the frame, so the part
// the crop shows stays the same part and the mask is not mirrored with it.
return <div style={{ position: 'absolute', inset: 0, transform: 'scaleX(-1)' }}>{img}</div>;
}
/** What an empty frame shows while editing: a hatch, and where there is room, the instruction. */
function Hatch({ clipped, label, labelSize }: { clipped: boolean; label: ReactNode; labelSize: number }) {
return (
<div
style={{
position: 'absolute',
inset: 0,
/*
* Opaque, and that is the point.
*
* The hatch used to be laid over nothing, so an empty frame showed
* whatever was behind it and every attempt to arrange a shape *under*
* one looked like it had done nothing: the frame appeared to sit at the
* bottom of the stack no matter where it was, right up until a
* photograph landed in it and it suddenly covered things. A frame is a
* promise that a picture goes here, and a picture is not see-through.
*/
background: 'repeating-linear-gradient(45deg, rgba(0,0,0,.055) 0 6px, rgba(0,0,0,.025) 6px 12px), #ffffff', // theme-lint-disable: paper, not app chrome
// A dashed border would be cut in half lengthwise by the clip, so a
// masked placeholder shows its shape through the hatch alone.
border: clipped ? undefined : '1px dashed rgba(0,0,0,.16)',
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2mm',
}}
>
{label && (
<span
style={{
fontFamily: fontStack('sans'),
fontSize: `${labelSize}mm`,
fontWeight: 600,
letterSpacing: '0.16em',
lineHeight: 1.5,
textAlign: 'center',
textTransform: 'uppercase',
color: 'rgba(0,0,0,.34)',
whiteSpace: 'pre-line',
userSelect: 'none',
}}
>
{label}
</span>
)}
</div>
);
}
export function PhotoView({
el,
big,
print,
dropLabel,
frameStyle,
}: {
el: BookPhotoElement;
big: boolean;
print: boolean;
dropLabel: string;
/** The positioned box every element sits in, from SpreadView. */
frameStyle: CSSProperties;
}) {
const uid = useId();
const deco = FRAME_INSET[el.frameStyle] ?? FRAME_INSET.none;
const side = Math.min(el.frame.w, el.frame.h);
// Rounded to a hundredth of a millimetre, the same precision the document
// stores. Without it a fraction of the frame lands in the CSS as
// `10.200000000000001mm`, which is not wrong but is float noise in a
// stylesheet that a person may well end up reading.
const pad = round2(deco.pad * side);
const bottom = round2(deco.bottom * side);
const clipId = svgId('c', uid);
// The shape the picture is cut to; a rect mask is no mask at all.
const mask = el.mask && el.mask !== 'rect' ? el.mask : null;
const clipped = mask !== null;
// An empty frame wears the frame style but none of the look: a border or a
// shadow around a hatch would read as part of the picture to come.
const empty = el.photoId == null;
const look = photoLookOf(empty ? {} : el);
const matrices = svgFilterMatrices(look);
const filterId = matrices.length ? svgId('f', uid) : null;
const card = CARDS.has(el.frameStyle);
/*
* An empty frame is a template's promise: it says where a picture goes before
* anyone has chosen which, and it says it *in the frame it will wear*.
*
* This used to render on its own, ignoring frameStyle entirely, so a Polaroid
* placeholder was an ordinary dashed rectangle until a photograph landed in
* it and the chin appeared from nowhere. The two branches have been folded
* together: the surround is drawn once, and only what goes inside it differs.
*/
// In the printed book an unfilled frame is nothing at all: a hatch and an
// instruction on a page someone paid to have bound would be a defect.
if (empty && print) return null;
// The label is sized in millimetres so it scales with the zoom exactly as the
// page does, and it steps aside on a frame too small to hold it.
const labelSize = Math.max(2.4, Math.min(4.6, side * 0.085));
const roomy = side - pad * 2 > 22 && el.frame.w - pad * 2 > 34;
// A photograph lying on a page, rather than printed into it. Soft and low: a
// hard shadow reads as a UI card, not as paper. The look's own cutout and
// hard shadow join it when the frame's outline is a rectangle.
const soft = el.frameStyle === 'shadow' || el.frameStyle === 'polaroid' ? '0 1.2mm 3mm rgba(0,0,0,.22)' : null;
const boxShadows = [
...(clipped && !card ? [] : silhouetteShadows(look.shadow, look.cutout)),
...(soft ? [soft] : []),
];
const picture = (
<div
style={{
position: 'absolute',
left: `${pad}mm`,
top: `${pad}mm`,
right: `${pad}mm`,
bottom: `${bottom || pad}mm`,
overflow: 'hidden',
borderRadius: !clipped && el.radius ? `${el.radius}mm` : undefined,
clipPath: clipped ? `url(#${clipId})` : undefined,
}}
>
{el.photoId == null ? (
<Hatch clipped={clipped} label={roomy && dropLabel} labelSize={labelSize} />
) : (
<Picture el={el} photoId={el.photoId} look={look} big={big} print={print} filterId={filterId} />
)}
{!empty && (
<BorderBand
mask={el.mask}
radius={el.radius}
look={look}
width={el.frame.w - pad * 2}
height={el.frame.h - pad - (bottom || pad)}
/>
)}
</div>
);
return (
<div
style={{
...frameStyle,
background:
el.frameStyle === 'polaroid' || el.frameStyle === 'white'
? '#ffffff'
: el.frameStyle === 'film'
? '#141414'
: undefined,
borderRadius: !card && !clipped && el.radius ? `${el.radius}mm` : undefined,
boxShadow: boxShadows.length ? boxShadows.join(', ') : undefined,
}}
>
{mask && !card && !empty && (look.shadow || look.cutout) && (
<ShapeSilhouette frame={el.frame} mask={mask} look={look} />
)}
{(mask || filterId) && (
<svg width="0" height="0" style={{ position: 'absolute' }} aria-hidden>
<defs>
{mask && (
<clipPath id={clipId} clipPathUnits="objectBoundingBox">
<path d={unitPath(SHAPE_PATHS[mask])} />
</clipPath>
)}
{filterId && (
<filter id={filterId} colorInterpolationFilters="sRGB">
{matrices.map((values, i) => (
<feColorMatrix key={i} type="matrix" values={values} />
))}
</filter>
)}
</defs>
</svg>
)}
{picture}
{/* Sprocket holes. Drawn as a repeating gradient rather than as elements
so the count follows the width instead of being fixed at a size that
only looks right on one frame. */}
{el.frameStyle === 'film' &&
[0, 1].map((row) => (
<div
key={row}
style={{
position: 'absolute',
left: `${pad * 0.35}mm`,
right: `${pad * 0.35}mm`,
[row ? 'bottom' : 'top']: `${pad * 0.22}mm`,
height: `${pad * 0.42}mm`,
background:
'repeating-linear-gradient(90deg,' +
` #f4f4f4 0 ${pad * 0.5}mm, transparent ${pad * 0.5}mm ${pad * 1.1}mm)`,
}}
/>
))}
{/*
Two strips, one over each top corner, each centred *on* its corner so it
overhangs the picture evenly on both sides, which is what a piece of
tape does. Offsetting by a fraction of the strip instead left them
sitting beside the corners rather than across them.
*/}
{el.frameStyle === 'tape' &&
([-1, 1] as const).map((dir) => {
const tapeW = side * 0.26;
const tapeH = side * 0.075;
return (
<div
key={dir}
style={{
position: 'absolute',
[dir < 0 ? 'left' : 'right']: `${round2(-tapeW / 2)}mm`,
top: `${round2(-tapeH / 2)}mm`,
width: `${round2(tapeW)}mm`,
height: `${round2(tapeH)}mm`,
background: 'rgba(236,228,206,.82)',
boxShadow: '0 .3mm .6mm rgba(0,0,0,.12)',
// Away from the corner on each side: anticlockwise on the left,
// clockwise on the right, so the pair reads as symmetric.
transform: `rotate(${dir * 45}deg)`,
}}
/>
);
})}
</div>
);
}
+237 -7
View File
@@ -1,11 +1,11 @@
import type { CSSProperties } from 'react'
import type {
BookElement, BookIconElement, BookPageSetup, BookShapeElement, BookSpread,
BookElement, BookIconElement, BookPageSetup, BookPhotoElement, BookShapeElement, BookSpread,
} from '@trek/shared'
import { fontStack } from './bookFonts'
import { photoSrc } from './bookRender'
import { fontStack, nearestWeight } from './bookFonts'
import { iconComponent } from './iconLibrary'
import { PhotoView } from './PhotoView'
import { HOLED_SHAPES, SHAPE_PATHS, scalePath } from './shapes'
import { HOLED_SHAPES, SHAPE_PATHS, scalePath, unitPath } from './shapes'
import { TravelElementView } from './TravelElements'
import { PageNumbers } from './PageNumbers'
@@ -34,12 +34,42 @@ function frameStyle(el: BookElement): CSSProperties {
}
}
/** Two decimals of a millimetre, matching what the document itself stores. */
const round2 = (n: number) => Math.round(n * 100) / 100
/** #rrggbb plus an alpha, for the fades a cover panel needs. */
function hexToRgba(hex: string, alpha: number): string {
const n = Number.parseInt(hex.slice(1), 16)
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`
}
const FILTERS: Record<string, string | undefined> = {
none: undefined,
bw: 'grayscale(1) contrast(1.05)',
warm: 'saturate(1.1) sepia(0.16)',
cool: 'saturate(1.05) hue-rotate(-8deg) brightness(1.02)',
// Faded, the way a print left in the light goes: lifted blacks, less colour.
fade: 'saturate(0.72) contrast(0.88) brightness(1.08)',
contrast: 'contrast(1.22) saturate(1.06)',
}
/**
* How much of the frame the decoration around a picture eats, as a fraction of
* the shorter side.
*
* A Polaroid's chin is the reason this is a table rather than one number: the
* bottom border is roughly three times the others, and getting that ratio wrong
* is the difference between a Polaroid and a photo with a white edge.
*/
const FRAME_INSET: Record<string, { pad: number; bottom: number }> = {
none: { pad: 0, bottom: 0 },
polaroid: { pad: 0.055, bottom: 0.17 },
white: { pad: 0.035, bottom: 0.035 },
shadow: { pad: 0, bottom: 0 },
film: { pad: 0.075, bottom: 0.075 },
tape: { pad: 0, bottom: 0 },
}
/**
* A shape, drawn.
*
@@ -123,6 +153,206 @@ function ShapeView({ el }: { el: BookShapeElement }) {
)
}
/**
* A picture — possibly cut to a shape, possibly sitting in something.
*
* The mask and the decoration are independent on purpose: a heart in a Polaroid
* is a silly thing to want and there is no reason to stop anyone wanting it, and
* keeping them separate means neither has to know about the other.
*/
function PhotoView({ el, big, print, dropLabel }: {
el: BookPhotoElement; big: boolean; print: boolean; dropLabel: string
}) {
const deco = FRAME_INSET[el.frameStyle] ?? FRAME_INSET.none
const side = Math.min(el.frame.w, el.frame.h)
// Rounded to a hundredth of a millimetre — the same precision the document
// stores. Without it a fraction of the frame lands in the CSS as
// `10.200000000000001mm`, which is not wrong but is float noise in a
// stylesheet that a person may well end up reading.
const pad = round2(deco.pad * side)
const bottom = round2(deco.bottom * side)
const clipId = `c-${el.id}`
const clipped = el.mask && el.mask !== 'rect'
/*
* An empty frame is a template's promise: it says where a picture goes before
* anyone has chosen which — and it says it *in the frame it will wear*.
*
* This used to render on its own, ignoring frameStyle entirely, so a Polaroid
* placeholder was an ordinary dashed rectangle until a photograph landed in
* it and the chin appeared from nowhere. The two branches have been folded
* together: the surround is drawn once, and only what goes inside it differs.
*/
const empty = el.photoId == null
// In the printed book an unfilled frame is nothing at all — a hatch and an
// instruction on a page someone paid to have bound would be a defect.
if (empty && print) return null
// The label is sized in millimetres so it scales with the zoom exactly as the
// page does, and it steps aside on a frame too small to hold it.
const labelSize = Math.max(2.4, Math.min(4.6, side * 0.085))
const roomy = side - pad * 2 > 22 && el.frame.w - pad * 2 > 34
const hatch = (
<div
style={{
position: 'absolute',
inset: 0,
/*
* Opaque, and that is the point.
*
* The hatch used to be laid over nothing, so an empty frame showed
* whatever was behind it and every attempt to arrange a shape *under*
* one looked like it had done nothing: the frame appeared to sit at the
* bottom of the stack no matter where it was, right up until a
* photograph landed in it and it suddenly covered things. A frame is a
* promise that a picture goes here, and a picture is not see-through.
*/
background:
'repeating-linear-gradient(45deg, rgba(0,0,0,.055) 0 6px, rgba(0,0,0,.025) 6px 12px), #ffffff', // theme-lint-disable — paper, not app chrome
// A dashed border would be cut in half lengthwise by the clip, so a
// masked placeholder shows its shape through the hatch alone.
border: clipped ? undefined : '1px dashed rgba(0,0,0,.16)',
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2mm',
}}
>
{roomy && dropLabel && (
<span
style={{
fontFamily: fontStack('sans'),
fontSize: `${labelSize}mm`,
fontWeight: 600,
letterSpacing: '0.16em',
lineHeight: 1.5,
textAlign: 'center',
textTransform: 'uppercase',
color: 'rgba(0,0,0,.34)',
whiteSpace: 'pre-line',
userSelect: 'none',
}}
>
{dropLabel}
</span>
)}
</div>
)
const picture = (
<div
style={{
position: 'absolute',
left: `${pad}mm`,
top: `${pad}mm`,
right: `${pad}mm`,
bottom: `${bottom || pad}mm`,
overflow: 'hidden',
borderRadius: !clipped && el.radius ? `${el.radius}mm` : undefined,
clipPath: clipped ? `url(#${clipId})` : undefined,
}}
>
{empty ? hatch : (
<img
src={photoSrc(el.photoId!, big)}
alt=""
draggable={false}
// Lazy on the canvas, where a long book scrolls past; eager in print,
// where every sheet is laid out at once and a picture the browser put
// off is a picture missing from the PDF.
loading={print ? 'eager' : 'lazy'}
style={{
width: '100%',
height: '100%',
objectFit: el.fit,
objectPosition: `${el.focalX * 100}% ${el.focalY * 100}%`,
filter: FILTERS[el.filter],
display: 'block',
}}
/>
)}
</div>
)
return (
<div
style={{
...frameStyle(el),
background: el.frameStyle === 'polaroid' || el.frameStyle === 'white' ? '#ffffff'
: el.frameStyle === 'film' ? '#141414'
: undefined,
// A photograph lying on a page, rather than printed into it. Soft and
// low: a hard shadow reads as a UI card, not as paper.
boxShadow: el.frameStyle === 'shadow' || el.frameStyle === 'polaroid'
? '0 1.2mm 3mm rgba(0,0,0,.22)'
: undefined,
}}
>
{clipped && (
<svg width="0" height="0" style={{ position: 'absolute' }} aria-hidden>
<defs>
<clipPath id={clipId} clipPathUnits="objectBoundingBox">
<path d={unitPath(SHAPE_PATHS[el.mask!])} />
</clipPath>
</defs>
</svg>
)}
{picture}
{/* Sprocket holes. Drawn as a repeating gradient rather than as elements
so the count follows the width instead of being fixed at a size that
only looks right on one frame. */}
{el.frameStyle === 'film' && [0, 1].map(row => (
<div
key={row}
style={{
position: 'absolute',
left: `${pad * 0.35}mm`,
right: `${pad * 0.35}mm`,
[row ? 'bottom' : 'top']: `${pad * 0.22}mm`,
height: `${pad * 0.42}mm`,
background: 'repeating-linear-gradient(90deg,'
+ ` #f4f4f4 0 ${pad * 0.5}mm, transparent ${pad * 0.5}mm ${pad * 1.1}mm)`,
}}
/>
))}
{/* Two strips of tape, held at an angle across opposite corners. */}
{/*
Two strips, one over each top corner, each centred *on* its corner so it
overhangs the picture evenly on both sides — which is what a piece of
tape does. Offsetting by a fraction of the strip instead left them
sitting beside the corners rather than across them.
*/}
{el.frameStyle === 'tape' && ([-1, 1] as const).map(dir => {
const tapeW = side * 0.26
const tapeH = side * 0.075
return (
<div
key={dir}
style={{
position: 'absolute',
[dir < 0 ? 'left' : 'right']: `${round2(-tapeW / 2)}mm`,
top: `${round2(-tapeH / 2)}mm`,
width: `${round2(tapeW)}mm`,
height: `${round2(tapeH)}mm`,
background: 'rgba(236,228,206,.82)',
boxShadow: '0 .3mm .6mm rgba(0,0,0,.12)',
// Away from the corner on each side: anticlockwise on the left,
// clockwise on the right, so the pair reads as symmetric.
transform: `rotate(${dir * 45}deg)`,
}}
/>
)
})}
</div>
)
}
/**
* A lucide drawing, at the size the frame gives it.
*
@@ -159,9 +389,9 @@ export function ElementView({
// A photo left out is drawn as its empty frame, kept even in print, so the
// page still shows where a picture sits without showing the picture.
if (el.photoId != null && hiddenPhotoIds?.has(el.photoId)) {
return <PhotoView el={{ ...el, photoId: null }} big={big} print={false} dropLabel="" frameStyle={frameStyle(el)} />
return <PhotoView el={{ ...el, photoId: null }} big={big} print={false} dropLabel="" />
}
return <PhotoView el={el} big={big} print={print} dropLabel={dropLabel} frameStyle={frameStyle(el)} />
return <PhotoView el={el} big={big} print={print} dropLabel={dropLabel} />
}
if (el.kind === 'shape') return <ShapeView el={el} />
@@ -187,7 +417,7 @@ export function ElementView({
// the conversion exactly.
fontSize: `${el.size}pt`,
fontFamily: fontStack(el.font),
fontWeight: el.weight,
fontWeight: nearestWeight(el.font, el.weight),
fontStyle: el.italic ? 'italic' : undefined,
lineHeight: el.leading,
letterSpacing: `${el.tracking}em`,
@@ -5,7 +5,7 @@ import { SpreadFold, SpreadView } from './SpreadView'
import { PeerCursors } from './PeerCursors'
import type { PeerCursor } from './useBookPresence'
import { bookElementId as elementId } from '@trek/shared'
import { fontStack } from './bookFonts'
import { fontStack, nearestWeight } from './bookFonts'
import { useSpreadInteraction, type HandleId } from './useSpreadInteraction'
import { useStudioStore } from '../../store/studioStore'
import { useTranslation } from '../../i18n'
@@ -325,7 +325,7 @@ export function StudioCanvas({
// The document is in pt; the overlay is on screen at the same zoom.
fontSize: `${el.size * (96 / 72) * zoom}px`,
fontFamily: fontStack(el.font),
fontWeight: el.weight,
fontWeight: nearestWeight(el.font, el.weight),
fontStyle: el.italic ? 'italic' : undefined,
lineHeight: el.leading,
letterSpacing: `${el.tracking}em`,
@@ -0,0 +1,112 @@
import { blankBook, bookTextElementSchema, type BookDocument, type BookElement, type BookSpread } from '@trek/shared';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, within } from '../../../tests/helpers/render';
import { useStudioStore } from '../../store/studioStore';
import { BOOK_FONT_ORDER } from './bookFonts';
import { matchFonts, pickFont } from './fontPicker';
import { StudioFontPicker } from './StudioFontPicker';
/**
* The typeface picker (FE-STUDIO-FONTPICK): the current family set in itself,
* a list grouped by kind with every family in its own face, the book's own
* families first, a search, and a weight that lands on one the family has.
*/
const t = (k: string, params?: Record<string, string | number>) =>
params ? `${k} ${Object.values(params).join(' ')}` : k;
const groupName = (c: string) => `journey.studio.fonts.cat.${c}`;
const frame = { x: 10, y: 10, w: 80, h: 20 };
function bookIn(...fonts: string[]): BookDocument {
const doc = blankBook({ title: 'Porto', newId: (p) => `${p}-x` });
const elements = fonts.map(
(font, i) => bookTextElementSchema.parse({ id: `t${i}`, kind: 'text', frame, text: 'Porto', font }) as BookElement
);
const [cover, ...rest] = doc.spreads as [BookSpread, ...BookSpread[]];
return {
...doc,
page: { ...doc.page, pageNumbers: { ...doc.page.pageNumbers, show: false } },
spreads: [{ ...cover, elements }, ...rest],
};
}
const open = () => fireEvent.click(screen.getByRole('button', { name: /journey\.studio\.fonts\.change/ }));
afterEach(() => {
useStudioStore.setState({ doc: null });
});
describe('choosing a family', () => {
it('FE-STUDIO-FONTPICK-001: moves the weight onto one the new family has', () => {
expect(pickFont('anton', 700)).toEqual({ font: 'anton', weight: 400 });
expect(pickFont('sans', 700)).toEqual({ font: 'sans', weight: 700 });
expect(pickFont('oswald', 900)).toEqual({ font: 'oswald', weight: 700 });
});
it('FE-STUDIO-FONTPICK-002: finds families by name, id and group, in picker order', () => {
expect(matchFonts('', groupName)).toEqual(BOOK_FONT_ORDER);
expect(matchFonts(' GARA ', groupName)).toEqual(['garamond', 'cormorant']);
expect(matchFonts('mono', groupName)).toEqual(['mono']);
expect(matchFonts('cat.script', groupName)).toContain('pacifico');
expect(matchFonts('nothing like it', groupName)).toEqual([]);
});
});
describe('the picker', () => {
it('FE-STUDIO-FONTPICK-003: shows the current family set in itself and opens a grouped list with the book families first', () => {
act(() => useStudioStore.setState({ doc: bookIn('caveat', 'serif') }));
render(<StudioFontPicker font="serif" weight={400} onPick={vi.fn()} t={t} />);
const current = screen.getByRole('button', { name: 'journey.studio.fonts.change: Lora' });
expect(current.getAttribute('aria-expanded')).toBe('false');
expect(within(current).getByText('Lora').getAttribute('style')).toContain('Lora');
open();
expect(current.getAttribute('aria-expanded')).toBe('true');
expect(document.activeElement).toBe(screen.getByRole('textbox', { name: 'journey.studio.fonts.search' }));
const groups = screen.getAllByRole('group');
expect(groups[0]?.getAttribute('aria-label')).toBe('journey.studio.fonts.used');
const used = within(groups[0] as HTMLElement).getAllByRole('option');
expect(used.map((o) => o.querySelector('.st-fontpick-face')?.textContent)).toEqual(['Lora', 'Caveat']);
expect(used[1]?.textContent).toContain('journey.studio.fonts.range 400 700');
// Every family, once per group it is in, each set in its own face.
const rows = screen.getAllByRole('option');
expect(rows).toHaveLength(BOOK_FONT_ORDER.length + 2);
const anton = rows.find((r) => r.textContent?.startsWith('Anton'));
expect(anton?.querySelector('span')?.getAttribute('style')).toContain('Anton');
expect(anton?.textContent).toContain('400');
expect(rows.filter((r) => r.getAttribute('aria-selected') === 'true')).toHaveLength(2);
});
it('FE-STUDIO-FONTPICK-004: picks a family at a weight it has and closes, back on the button', () => {
const onPick = vi.fn();
render(<StudioFontPicker font="sans" weight={700} onPick={onPick} t={t} />);
open();
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'anton' } });
expect(screen.queryByRole('group', { name: 'journey.studio.fonts.used' })).toBeNull();
fireEvent.click(screen.getByText('Anton'));
expect(onPick).toHaveBeenCalledWith({ font: 'anton', weight: 400 });
expect(screen.queryByRole('listbox')).toBeNull();
expect(document.activeElement?.getAttribute('aria-expanded')).toBe('false');
});
it('FE-STUDIO-FONTPICK-005: says when nothing matches, clears the search, and closes on Escape', () => {
render(<StudioFontPicker font="sans" weight={400} onPick={vi.fn()} t={t} />);
open();
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'zzz' } });
expect(screen.getByText('journey.studio.fonts.none')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'common.clear' }));
expect((screen.getByRole('textbox') as HTMLInputElement).value).toBe('');
expect(screen.queryByText('journey.studio.fonts.none')).toBeNull();
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Escape' });
expect(screen.queryByRole('listbox')).toBeNull();
// A second click on the open button closes it too.
open();
open();
expect(screen.queryByRole('listbox')).toBeNull();
});
it('FE-STUDIO-FONTPICK-006: reads a family this build does not know as the default one', () => {
render(<StudioFontPicker font="gone" weight={400} onPick={vi.fn()} t={t} />);
expect(screen.getByRole('button', { name: 'journey.studio.fonts.change: Poppins' })).toBeTruthy();
});
});
@@ -0,0 +1,198 @@
import { BOOK_FONT_CATEGORIES, bookFontsUsed, type BookFontCategory } from '@trek/shared';
import { Check, ChevronDown, Search, X } from 'lucide-react';
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from 'react';
import { useStudioStore } from '../../store/studioStore';
import '../../styles/studio-fonts.css';
import { BOOK_FONTS, nearestWeight, type BookFontId } from './bookFonts';
import { matchFonts, pickFont, weightSpan, type FontPick, type FontT } from './fontPicker';
/**
* The typeface picker, shared by the text inspector and the travel inspector.
*
* Thirty four families do not fit as a wall of buttons the way seven did, and
* a plain list of names says nothing about how a face looks, which is the only
* thing anyone is choosing between. So the current family sits on one line,
* set in itself, and opens a list grouped the way people look for type
* (a sans, a serif, something for a poster, a script to lay over a photo)
* where every family is again set in its own face. The families the book is
* already set in come first, because the second text of a book is usually set
* like the first.
*
* The list opens inside the inspector rather than floating over the page: the
* inspector scrolls, and a popover pinned to a control that scrolls away is a
* popover pointing at nothing.
*/
function FontRow({
id,
on,
weight,
onPick,
t,
}: {
id: BookFontId;
on: boolean;
weight: number;
onPick: () => void;
t: FontT;
}) {
const face = BOOK_FONTS[id];
return (
<button
type="button"
role="option"
aria-selected={on}
className={`st-fontpick-row ${on ? 'is-on' : ''}`}
onClick={onPick}
title={face.name}
>
<span className="st-fontpick-face" style={{ fontFamily: face.stack, fontWeight: nearestWeight(id, weight) }}>
{face.name}
</span>
<span className="st-fontpick-span">{weightSpan(id, t)}</span>
<span className="st-fontpick-tick">{on && <Check size={13} />}</span>
</button>
);
}
function FontGroup({
label,
ids,
font,
weight,
onPick,
t,
}: {
label: string;
ids: BookFontId[];
font: string;
weight: number;
onPick: (id: BookFontId) => void;
t: FontT;
}) {
if (!ids.length) return null;
return (
<div className="st-fontpick-group" role="group" aria-label={label}>
<div className="st-fontpick-label">{label}</div>
{ids.map((id) => (
<FontRow key={id} id={id} on={font === id} weight={weight} onPick={() => onPick(id)} t={t} />
))}
</div>
);
}
/** The families the open book is set in, read from the document the Studio holds. */
function useFontsInBook(): BookFontId[] {
const doc = useStudioStore((s) => s.doc);
return useMemo(() => (doc ? bookFontsUsed(doc) : []), [doc]);
}
export function StudioFontPicker({
font,
weight,
onPick,
t,
}: {
font: string;
weight: number;
onPick: (patch: FontPick) => void;
t: FontT;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const trigger = useRef<HTMLButtonElement>(null);
const search = useRef<HTMLInputElement>(null);
const listId = useId();
const inBook = useFontsInBook();
const current = BOOK_FONTS[font as BookFontId] ?? BOOK_FONTS.sans;
const groupName = (c: BookFontCategory) => t(`journey.studio.fonts.cat.${c}`);
const found = matchFonts(query, groupName);
// Opened on purpose, so typing goes straight to the search.
useEffect(() => {
if (open) search.current?.focus();
}, [open]);
const close = () => {
setOpen(false);
setQuery('');
trigger.current?.focus();
};
const choose = (id: BookFontId) => {
onPick(pickFont(id, weight));
close();
};
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
e.stopPropagation();
close();
};
return (
<div className={`st-fontpick ${open ? 'is-open' : ''}`} onKeyDown={onKey}>
<button
ref={trigger}
type="button"
className="st-fontpick-current"
aria-expanded={open}
aria-controls={listId}
aria-label={`${t('journey.studio.fonts.change')}: ${current.name}`}
onClick={() => (open ? close() : setOpen(true))}
>
<span
className="st-fontpick-face"
style={{ fontFamily: current.stack, fontWeight: nearestWeight(current.id, weight) }}
>
{current.name}
</span>
<span className="st-fontpick-span">{groupName(current.category)}</span>
<ChevronDown size={14} className="st-fontpick-caret" />
</button>
{open && (
<div className="st-fontpick-panel">
<div className="st-search st-fontpick-search">
<Search size={14} />
<input
ref={search}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('journey.studio.fonts.search')}
aria-label={t('journey.studio.fonts.search')}
aria-controls={listId}
spellCheck={false}
/>
{query && (
<button type="button" onClick={() => setQuery('')} aria-label={t('common.clear')}>
<X size={13} />
</button>
)}
</div>
<div id={listId} className="st-fontpick-list" role="listbox" aria-label={t('journey.studio.fonts.list')}>
{!query && (
<FontGroup
label={t('journey.studio.fonts.used')}
ids={inBook}
font={font}
weight={weight}
onPick={choose}
t={t}
/>
)}
{BOOK_FONT_CATEGORIES.map((category) => (
<FontGroup
key={category}
label={groupName(category)}
ids={found.filter((id) => BOOK_FONTS[id].category === category)}
font={font}
weight={weight}
onPick={choose}
t={t}
/>
))}
{!found.length && <p className="st-hint st-fontpick-none">{t('journey.studio.fonts.none')}</p>}
</div>
</div>
)}
</div>
);
}
@@ -13,8 +13,9 @@ import { Swatches } from './StudioSwatches'
import { formatBookCoords } from '@trek/shared'
import { iconComponent, iconLabel, searchIcons } from './iconLibrary'
import { Choice, Line, NumField, Section, Switch } from './StudioControls'
import { FontButtons, WeightLine } from './StudioTypeControls'
import { PhotoCropExtras, PhotoLookSections } from './photo/StudioPhotoLook'
import { StudioFontPicker } from './StudioFontPicker'
import { WeightLine } from './StudioTypeControls'
import { italicTitle } from './fontPicker'
import type { JourneySource } from './StudioSidebar'
import { Tooltip } from '../shared/Tooltip'
@@ -324,7 +325,7 @@ export function StudioInspector({
nothing about a typeface — and grouped, because "which serif"
is a different question from "serif or sans".
*/}
<FontButtons font={el.font} weight={el.weight} onPick={patch => set(patch)} />
<StudioFontPicker font={el.font} weight={el.weight} onPick={patch => set(patch)} t={t} />
<div style={{ marginTop: 10 }}>
<WeightLine font={el.font} weight={el.weight} onPick={weight => set({ weight })} t={t} />
<Line label={t('journey.studio.align')}>
@@ -345,7 +346,7 @@ export function StudioInspector({
<button type="button"
className={`st-chip is-icon ${el.italic ? 'is-on' : ''}`}
onClick={() => set({ italic: !el.italic })}
title={t('journey.studio.italic')}
title={italicTitle(el.font, el.weight, t)}
aria-label={t('journey.studio.italic')}
aria-pressed={el.italic}
>
@@ -413,7 +414,6 @@ export function StudioInspector({
) : (
<p className="st-hint" style={{ paddingTop: 8 }}>{t('journey.studio.emptyFrame')}</p>
)}
<PhotoCropExtras el={el} set={set} live={live} t={t} />
</Section>
<Section label={t('journey.studio.look')}>
@@ -460,8 +460,6 @@ export function StudioInspector({
)}
</Section>
<PhotoLookSections el={el} set={set} live={live} t={t} />
<Section label={t('journey.studio.mask')} defaultOpen={false}>
<div className="st-mini-shapes">
<button type="button"
@@ -26,6 +26,7 @@ import { useStudioAssistant, type StudioAssistant } from './useStudioAssistant'
import '../../styles/dashboard.css'
import '../../styles/studio.css'
import './bookFontFaces'
import { useWarmBookFaces } from './bookFontWarm'
import HelpButton from '../Help/HelpButton'
/**
@@ -45,6 +46,8 @@ export default function StudioShell() {
const [bookView, setBookView] = useState(true)
const [exporting, setExporting] = useState(false)
const assistant = useStudioAssistant({ journeyId: s.journeyId, flush: s.flushBook })
// The book's faces into the offline cache as it opens (bookFontWarm.ts).
useWarmBookFaces()
if (s.isMobile) {
return createPortal(
@@ -5,7 +5,8 @@ import type {
} from '@trek/shared'
import { BOOK_METRICS, MAX_PATH_POINTS, MAX_PATH_SEGMENTS, isStale, refreshPatch } from '@trek/shared'
import { Swatches } from './StudioSwatches'
import { FontButtons, WeightLine } from './StudioTypeControls'
import { StudioFontPicker } from './StudioFontPicker'
import { WeightLine } from './StudioTypeControls'
import { sourceIdOf, useMapSources } from './mapSources'
import { printDpi, tileView } from './mapTiles'
import { useCartoApiKey } from '../../hooks/useTileUrl'
@@ -82,7 +83,7 @@ export function TravelInspector({ el, stats, path, set, t }: TravelInspectorProp
* wrong typeface and no way to fix it.
*/}
<Section label={t('journey.studio.typography')} defaultOpen={false}>
<FontButtons font={el.font} weight={el.weight} onPick={patch => set(patch as Partial<BookElement>)} />
<StudioFontPicker font={el.font} weight={el.weight} onPick={patch => set(patch as Partial<BookElement>)} t={t} />
<div style={{ marginTop: 10 }}>
<WeightLine
font={el.font}
@@ -1,50 +1,47 @@
import { describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../tests/helpers/render';
import { BOOK_FONT_ORDER } from './bookFonts';
import { FontButtons, WeightLine } from './StudioTypeControls';
import { italicTitle } from './fontPicker';
import { WeightLine } from './StudioTypeControls';
const t = (k: string) => k;
describe('FontButtons', () => {
it('offers every bundled family in the picker order and marks the current one', () => {
render(<FontButtons font="serif" weight={400} onPick={vi.fn()} />);
const buttons = screen.getAllByRole('button');
expect(buttons).toHaveLength(BOOK_FONT_ORDER.length);
expect(screen.getByRole('button', { name: 'Lora' }).className).toContain('is-on');
expect(screen.getByRole('button', { name: 'Poppins' }).className).not.toContain('is-on');
});
it('keeps the weight when the new family ships it', () => {
const onPick = vi.fn();
render(<FontButtons font="sans" weight={700} onPick={onPick} />);
fireEvent.click(screen.getByRole('button', { name: 'Inter' }));
expect(onPick).toHaveBeenCalledWith({ font: 'inter', weight: 700 });
});
it('moves the weight to one the new family really has', () => {
const onPick = vi.fn();
render(<FontButtons font="sans" weight={700} onPick={onPick} />);
fireEvent.click(screen.getByRole('button', { name: 'Bebas Neue' }));
expect(onPick).toHaveBeenCalledWith({ font: 'bebas', weight: 400 });
});
});
describe('WeightLine', () => {
it('greys the weights the family does not ship and says why', () => {
render(<WeightLine font="bebas" weight={400} onPick={vi.fn()} t={t} />);
it('steps through only the weights the family has and says the drawn one by name and number', () => {
render(<WeightLine font="oswald" weight={700} onPick={vi.fn()} t={t} />);
expect(screen.getByText('journey.studio.weight')).toBeTruthy();
const bold = screen.getByRole('radio', { name: '700' }) as HTMLButtonElement;
expect(bold.disabled).toBe(true);
expect(bold.title).toBe('journey.studio.weightMissing');
const regular = screen.getByRole('radio', { name: '400' }) as HTMLButtonElement;
expect(regular.disabled).toBe(false);
expect(regular.getAttribute('aria-checked')).toBe('true');
const slider = screen.getByRole('slider') as HTMLInputElement;
// Oswald ships 200 to 700: six stops, the last one drawn.
expect([slider.min, slider.max, slider.value]).toEqual(['0', '5', '5']);
expect(slider.getAttribute('aria-valuetext')).toBe('journey.studio.weight.700 700');
expect(screen.getByText('journey.studio.weight.700 700')).toBeTruthy();
});
it('hands the picked weight back', () => {
it('hands back the weight at the stop picked', () => {
const onPick = vi.fn();
render(<WeightLine font="sans" weight={400} onPick={onPick} t={t} />);
fireEvent.click(screen.getByRole('radio', { name: '600' }));
expect(onPick).toHaveBeenCalledWith(600);
fireEvent.change(screen.getByRole('slider'), { target: { value: '0' } });
expect(onPick).toHaveBeenCalledWith(100);
fireEvent.change(screen.getByRole('slider'), { target: { value: '8' } });
expect(onPick).toHaveBeenLastCalledWith(900);
});
it('shows the one weight of a family that has one, with nothing to drag, and the weight it really draws', () => {
render(<WeightLine font="bebas" weight={700} onPick={vi.fn()} t={t} />);
expect(screen.queryByRole('slider')).toBeNull();
expect(screen.getByText('journey.studio.weightOnly')).toBeTruthy();
expect(screen.getByText('journey.studio.weight.400 400')).toBeTruthy();
});
it('reads a family this build does not know as the default one', () => {
render(<WeightLine font="gone" weight={500} onPick={vi.fn()} t={t} />);
expect((screen.getByRole('slider') as HTMLInputElement).value).toBe('4');
});
});
describe('italicTitle', () => {
it('says when the browser slants a family that has no italic of its own', () => {
expect(italicTitle('garamond', 400, t)).toBe('journey.studio.italic');
expect(italicTitle('sans', 400, t)).toBe('journey.studio.italicSlanted');
expect(italicTitle('playfair', 300, t)).toBe('journey.studio.italic');
});
});
@@ -1,58 +1,27 @@
import type { BookTextElement } from '@trek/shared';
import type { BookFontId } from './bookFonts';
import { BOOK_FONTS, BOOK_FONT_ORDER, hasWeight, nearestWeight } from './bookFonts';
import { Choice, Line } from './StudioControls';
import type { BookWeight } from '@trek/shared';
import { BOOK_FONTS, nearestWeight, type BookFontId } from './bookFonts';
import { Line } from './StudioControls';
/**
* The typeface buttons and the weight line, shared by the text inspector and
* the travel inspector so both set type the same way.
* The weight line, shared by the text inspector and the travel inspector so
* both set type the same way. The typeface itself is picked in
* StudioFontPicker.tsx, the italic's title comes from fontPicker.ts.
*/
/** The weight set the book schema allows, taken from it so there is one list. */
type BookWeight = BookTextElement['weight'];
type T = (key: string) => string;
const WEIGHTS = [400, 500, 600, 700] as const;
/** The weights a family ships, lightest first; Poppins' for a family this build does not know. */
const weightsOf = (font: string) => (BOOK_FONTS[font as BookFontId] ?? BOOK_FONTS.sans).weights;
/** One button per bundled family, each set in its own face. */
export function FontButtons({
font,
weight,
onPick,
}: {
font: string;
weight: BookWeight;
onPick: (patch: { font: BookFontId; weight: BookWeight }) => void;
}) {
return (
<div className="st-fonts">
{BOOK_FONT_ORDER.map((id) => {
const face = BOOK_FONTS[id];
return (
<button
type="button"
key={id}
className={`st-font ${font === id ? 'is-on' : ''}`}
style={{ fontFamily: face.stack }}
onClick={() =>
onPick({
font: id,
// A family that does not ship this weight would render a
// synthesised bold, a smeared regular in print, so the
// weight moves to the nearest one it really has.
weight: nearestWeight(id, weight) as BookWeight,
})
}
title={face.name}
>
{face.name}
</button>
);
})}
</div>
);
}
/** The four weights, with the ones the family does not ship greyed out. */
/**
* A slider over the weights the family really has, and only those.
*
* Nine chips from 100 to 900 with most of them greyed out read as a broken
* control for a family with two weights, and as a wall for one with nine.
* The slider steps through what exists, says the weight by its name and its
* number, and a family with a single weight shows that weight and nothing to
* drag.
*/
export function WeightLine({
font,
weight,
@@ -60,22 +29,36 @@ export function WeightLine({
t,
}: {
font: string;
weight: BookWeight;
weight: number;
onPick: (weight: BookWeight) => void;
t: (k: string) => string;
t: T;
}) {
const weights = weightsOf(font);
const drawn = nearestWeight(font, weight);
const at = Math.max(0, weights.indexOf(drawn as BookWeight));
const word = `${t(`journey.studio.weight.${drawn}`)} ${drawn}`;
return (
<Line label={t('journey.studio.weight')}>
<Choice
value={weight}
options={WEIGHTS.map((w) => ({
value: w,
label: String(w),
disabled: !hasWeight(font, w),
title: hasWeight(font, w) ? undefined : t('journey.studio.weightMissing'),
}))}
onPick={onPick}
/>
<div className="st-field st-weight">
{weights.length > 1 ? (
<input
type="range"
min={0}
max={weights.length - 1}
step={1}
value={at}
aria-label={t('journey.studio.weight')}
aria-valuetext={word}
onChange={(e) => {
const next = weights[Number(e.target.value)];
if (next !== undefined) onPick(next);
}}
/>
) : (
<span className="st-weight-only">{t('journey.studio.weightOnly')}</span>
)}
<span className="st-weight-value">{word}</span>
</div>
</Line>
);
}
@@ -7,7 +7,7 @@ import {
Camera, CalendarDays, Footprints, Globe, MapPin, Navigation, Route,
type LucideIcon,
} from 'lucide-react'
import { fontStack } from './bookFonts'
import { fontStack, lighterWeight, nearestWeight } from './bookFonts'
import { haversineKm } from '../../utils/geo'
import { brightness } from './folioColour'
import { COUNTRY_SHAPES, countryParts, countryWorldPath, projectMercator, unprojectMercator } from './countryShapes'
@@ -140,8 +140,8 @@ export function TravelElementView({ el, frameStyle, big = false }: {
* `textScale` moves their size. Hard-coded 700s meant the only way to lighten a
* stats block was to not use one.
*/
const heavy = (el: { weight: number }) => el.weight
const light = (el: { weight: number }) => Math.max(400, el.weight - 100)
const heavy = (el: { font: string; weight: number }) => nearestWeight(el.font, el.weight)
const light = (el: { font: string; weight: number }) => lighterWeight(el.font, el.weight)
const MAP_PALETTES = {
minimal: { land: '#e9e5dc', border: '#d6d0c4', onDark: false },
+184 -14
View File
@@ -1,43 +1,213 @@
/**
* The @font-face rules behind the book's typefaces.
*
* bookFonts.ts names the seven families; this is where the faces they need
* arrive. Poppins comes with the app chrome in main.tsx and is not repeated
* here, and neither are MuseoModerno's 400 and 700: importing a face twice only
* duplicates every rule. Its 500 and 600 are the book's alone, which is why
* they are down here rather than up there.
* The catalogue in @trek/shared (book-fonts.data.ts) names the families, the
* weights each ships and the weights it has a real italic for; this is where
* those faces arrive, one import per face. Poppins 300 to 700 and
* MuseoModerno 400, 700 and 800 come with the app chrome in main.tsx and are
* not repeated here: importing a face twice only duplicates every rule.
*
* Pulled in by StudioShell rather than by main.tsx: nothing outside the editor
* is set in Lora or Bebas Neue, so the faces travel with the Studio chunk and
* cost the app's first paint nothing. Once that chunk is loaded they are
* ordinary stylesheets on the document, which is what lets printSheets copy
* them into the print frame with everything else.
* is set in Lora or Anton, so the rules travel with the Studio chunk and cost
* the app's first paint nothing. Once that chunk is loaded they are ordinary
* stylesheets on the document, which is what lets printSheets copy them into
* the print frame with everything else.
*
* Upright only, as everywhere else in the app: the editor's italic toggle has
* always been the browser's synthetic slant, and a second file per weight is a
* lot of download to change that.
* A rule is not a download. The browser fetches a face's file only when text
* on the page is set in it, and only the subsets (latin, latin-ext, cyrillic
* and so on) whose characters that text uses, so a book set in two families
* loads two families however long this list grows. The print frame loads the
* same way, and the assistant's page snapshot embeds only the faces a page
* draws (snapshotFonts.ts).
*
* Real italics for the serifs, where the browser's slant reads as a mistake;
* the sans faces keep the synthetic oblique they always had, which saves a
* second file per weight nobody would tell apart.
*
* Big Shoulders Display is imported without `.css`: its package maps `./*` to
* `./*.css` and has no `./*.css` entry of its own, so `100.css` would resolve
* to `100.css.css` and fail the build.
*/
import '@fontsource/poppins/100.css'
import '@fontsource/poppins/200.css'
import '@fontsource/poppins/800.css'
import '@fontsource/poppins/900.css'
import '@fontsource/inter/100.css'
import '@fontsource/inter/200.css'
import '@fontsource/inter/300.css'
import '@fontsource/inter/400.css'
import '@fontsource/inter/500.css'
import '@fontsource/inter/600.css'
import '@fontsource/inter/700.css'
import '@fontsource/inter/800.css'
import '@fontsource/inter/900.css'
import '@fontsource/montserrat/100.css'
import '@fontsource/montserrat/200.css'
import '@fontsource/montserrat/300.css'
import '@fontsource/montserrat/400.css'
import '@fontsource/montserrat/500.css'
import '@fontsource/montserrat/600.css'
import '@fontsource/montserrat/700.css'
import '@fontsource/montserrat/800.css'
import '@fontsource/montserrat/900.css'
import '@fontsource/lora/400.css'
import '@fontsource/lora/500.css'
import '@fontsource/lora/600.css'
import '@fontsource/lora/700.css'
import '@fontsource/lora/400-italic.css'
import '@fontsource/lora/500-italic.css'
import '@fontsource/lora/600-italic.css'
import '@fontsource/lora/700-italic.css'
import '@fontsource/eb-garamond/400.css'
import '@fontsource/eb-garamond/500.css'
import '@fontsource/eb-garamond/600.css'
import '@fontsource/eb-garamond/700.css'
import '@fontsource/eb-garamond/800.css'
import '@fontsource/eb-garamond/400-italic.css'
import '@fontsource/eb-garamond/500-italic.css'
import '@fontsource/eb-garamond/600-italic.css'
import '@fontsource/eb-garamond/700-italic.css'
import '@fontsource/eb-garamond/800-italic.css'
import '@fontsource/playfair-display/400.css'
import '@fontsource/playfair-display/500.css'
import '@fontsource/playfair-display/600.css'
import '@fontsource/playfair-display/700.css'
import '@fontsource/playfair-display/800.css'
import '@fontsource/playfair-display/900.css'
import '@fontsource/playfair-display/400-italic.css'
import '@fontsource/playfair-display/500-italic.css'
import '@fontsource/playfair-display/600-italic.css'
import '@fontsource/playfair-display/700-italic.css'
import '@fontsource/playfair-display/800-italic.css'
import '@fontsource/playfair-display/900-italic.css'
import '@fontsource/museomoderno/500.css'
import '@fontsource/museomoderno/600.css'
import '@fontsource/fraunces/100.css'
import '@fontsource/fraunces/200.css'
import '@fontsource/fraunces/300.css'
import '@fontsource/fraunces/400.css'
import '@fontsource/fraunces/500.css'
import '@fontsource/fraunces/600.css'
import '@fontsource/fraunces/700.css'
import '@fontsource/fraunces/800.css'
import '@fontsource/fraunces/900.css'
import '@fontsource/fraunces/100-italic.css'
import '@fontsource/fraunces/200-italic.css'
import '@fontsource/fraunces/300-italic.css'
import '@fontsource/fraunces/400-italic.css'
import '@fontsource/fraunces/500-italic.css'
import '@fontsource/fraunces/600-italic.css'
import '@fontsource/fraunces/700-italic.css'
import '@fontsource/fraunces/800-italic.css'
import '@fontsource/fraunces/900-italic.css'
import '@fontsource/cormorant-garamond/300.css'
import '@fontsource/cormorant-garamond/400.css'
import '@fontsource/cormorant-garamond/500.css'
import '@fontsource/cormorant-garamond/600.css'
import '@fontsource/cormorant-garamond/700.css'
import '@fontsource/cormorant-garamond/300-italic.css'
import '@fontsource/cormorant-garamond/400-italic.css'
import '@fontsource/cormorant-garamond/500-italic.css'
import '@fontsource/cormorant-garamond/600-italic.css'
import '@fontsource/cormorant-garamond/700-italic.css'
import '@fontsource/dm-serif-display/400.css'
import '@fontsource/dm-serif-display/400-italic.css'
import '@fontsource/bodoni-moda/400.css'
import '@fontsource/bodoni-moda/500.css'
import '@fontsource/bodoni-moda/600.css'
import '@fontsource/bodoni-moda/700.css'
import '@fontsource/bodoni-moda/800.css'
import '@fontsource/bodoni-moda/900.css'
import '@fontsource/bodoni-moda/400-italic.css'
import '@fontsource/bodoni-moda/500-italic.css'
import '@fontsource/bodoni-moda/600-italic.css'
import '@fontsource/bodoni-moda/700-italic.css'
import '@fontsource/bodoni-moda/800-italic.css'
import '@fontsource/bodoni-moda/900-italic.css'
import '@fontsource/bebas-neue/400.css'
import '@fontsource/anton/400.css'
import '@fontsource/oswald/200.css'
import '@fontsource/oswald/300.css'
import '@fontsource/oswald/400.css'
import '@fontsource/oswald/500.css'
import '@fontsource/oswald/600.css'
import '@fontsource/oswald/700.css'
import '@fontsource/league-gothic/400.css'
import '@fontsource/big-shoulders-display/100'
import '@fontsource/big-shoulders-display/200'
import '@fontsource/big-shoulders-display/300'
import '@fontsource/big-shoulders-display/400'
import '@fontsource/big-shoulders-display/500'
import '@fontsource/big-shoulders-display/600'
import '@fontsource/big-shoulders-display/700'
import '@fontsource/big-shoulders-display/800'
import '@fontsource/big-shoulders-display/900'
import '@fontsource/archivo-black/400.css'
import '@fontsource/museomoderno/100.css'
import '@fontsource/museomoderno/200.css'
import '@fontsource/museomoderno/300.css'
import '@fontsource/museomoderno/500.css'
import '@fontsource/museomoderno/600.css'
import '@fontsource/museomoderno/900.css'
import '@fontsource/shrikhand/400.css'
import '@fontsource/alfa-slab-one/400.css'
import '@fontsource/bricolage-grotesque/200.css'
import '@fontsource/bricolage-grotesque/300.css'
import '@fontsource/bricolage-grotesque/400.css'
import '@fontsource/bricolage-grotesque/500.css'
import '@fontsource/bricolage-grotesque/600.css'
import '@fontsource/bricolage-grotesque/700.css'
import '@fontsource/bricolage-grotesque/800.css'
import '@fontsource/abril-fatface/400.css'
import '@fontsource/caveat/400.css'
import '@fontsource/caveat/500.css'
import '@fontsource/caveat/600.css'
import '@fontsource/caveat/700.css'
import '@fontsource/dancing-script/400.css'
import '@fontsource/dancing-script/500.css'
import '@fontsource/dancing-script/600.css'
import '@fontsource/dancing-script/700.css'
import '@fontsource/pacifico/400.css'
import '@fontsource/great-vibes/400.css'
import '@fontsource/sacramento/400.css'
import '@fontsource/kaushan-script/400.css'
import '@fontsource/allura/400.css'
import '@fontsource/parisienne/400.css'
import '@fontsource/satisfy/400.css'
import '@fontsource/yellowtail/400.css'
import '@fontsource/homemade-apple/400.css'
import '@fontsource/permanent-marker/400.css'
import '@fontsource/dm-mono/300.css'
import '@fontsource/dm-mono/400.css'
import '@fontsource/dm-mono/500.css'
@@ -0,0 +1,79 @@
import { act, renderHook } from '@testing-library/react';
import { blankBook, bookTextElementSchema, type BookDocument, type BookElement, type BookSpread } from '@trek/shared';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useStudioStore } from '../../store/studioStore';
import { bookSample, faceSpec, useWarmBookFaces, warmBookFaces } from './bookFontWarm';
/**
* The open book's faces go into the offline cache as it opens
* (FE-STUDIO-FONTWARM): each face it uses is asked for once, with the book's
* own characters, and a face that does not load costs nothing.
*/
const frame = { x: 10, y: 10, w: 80, h: 20 };
const text = (id: string, words: string, over: Record<string, unknown> = {}) =>
bookTextElementSchema.parse({ id, kind: 'text', frame, text: words, ...over }) as BookElement;
function book(elements: BookElement[]): BookDocument {
const doc = blankBook({ title: 'Kraków', newId: (p) => `${p}-x` });
const [cover, ...rest] = doc.spreads as [BookSpread, ...BookSpread[]];
return { ...doc, spreads: [{ ...cover, elements }, ...rest] };
}
afterEach(() => {
useStudioStore.setState({ doc: null });
});
describe('the faces of a book', () => {
it('FE-STUDIO-FONTWARM-001: samples each character of the book once, the title and the figures with it', () => {
const sample = bookSample(book([text('t1', 'Łódź Łódź'), text('t2', ' ')]));
expect(sample).toContain('Ł');
expect(sample).toContain('ó');
expect(sample).toContain('K');
expect(sample.startsWith('0123456789')).toBe(true);
expect(new Set(sample).size).toBe(sample.length);
expect(sample).not.toContain(' ');
});
it('FE-STUDIO-FONTWARM-002: names a face the way document.fonts reads it', () => {
expect(faceSpec({ font: 'serif', weight: 700, italic: true })).toBe('italic 700 16px "Lora"');
expect(faceSpec({ font: 'caveat', weight: 400, italic: false })).toBe('400 16px "Caveat"');
});
it('FE-STUDIO-FONTWARM-003: asks for every face once, counts the ones that loaded and never rejects', async () => {
const doc = book([
text('t1', 'Porto', { font: 'caveat', weight: 700 }),
text('t2', 'Douro', { font: 'serif', italic: true }),
]);
const load = vi.fn(async (spec: string, _text?: string) => (spec.includes('Caveat') ? [{} as FontFace] : []));
expect(await warmBookFaces(doc, { load })).toBe(1);
const specs = load.mock.calls.map(([spec]) => spec);
expect(specs).toEqual(expect.arrayContaining(['700 16px "Caveat"', 'italic 400 16px "Lora"']));
expect(load.mock.calls[0]?.[1]).toContain('P');
const broken = vi.fn(async () => {
throw new Error('offline');
});
expect(await warmBookFaces(doc, { load: broken })).toBe(0);
expect(await warmBookFaces(doc, undefined)).toBe(0);
});
it('FE-STUDIO-FONTWARM-004: warms when the set of faces changes, not on every edit of the words', async () => {
const load = vi.fn(async () => [] as FontFace[]);
Object.defineProperty(document, 'fonts', { configurable: true, value: { load } });
const doc = book([text('t1', 'Porto', { font: 'anton' })]);
act(() => useStudioStore.setState({ doc }));
renderHook(() => useWarmBookFaces());
await act(async () => {});
const first = load.mock.calls.length;
expect(first).toBeGreaterThan(0);
const retyped = book([text('t1', 'Porto, again', { font: 'anton' })]);
await act(async () => useStudioStore.setState({ doc: retyped }));
expect(load.mock.calls.length).toBe(first);
const another = book([text('t1', 'Porto', { font: 'anton' }), text('t2', 'Lisboa', { font: 'vibes' })]);
await act(async () => useStudioStore.setState({ doc: another }));
expect(load.mock.calls.length).toBeGreaterThan(first);
});
});
@@ -0,0 +1,64 @@
import { BOOK_FONT_FACES, bookFacesUsed, type BookDocument, type BookFaceUse } from '@trek/shared';
import { useEffect, useMemo } from 'react';
import { useStudioStore } from '../../store/studioStore';
/**
* The faces of the open book, loaded as soon as it opens, so it reads in its
* own type offline as well.
*
* The service worker installs the latin files of the seven families the book
* shipped with (vite.config.js); every other face is cached by its runtime
* rule the first time it is fetched. A face the browser only fetches once
* text is drawn in it would be cached only for the pages someone happened to
* look at, so a book opened online and read offline later would show its
* third chapter in the fallback. Asking for every face the book uses, with the
* characters it sets, fetches each one now, through the worker, into that
* cache: a face nobody uses is never downloaded.
*/
/** The book's own characters, for asking the right subsets of each face: its words, its title, the figures. */
export function bookSample(doc: BookDocument): string {
const seen = new Set<string>('0123456789');
const take = (s: string) => {
for (const ch of s) if (ch.trim()) seen.add(ch);
};
take(doc.title);
for (const spread of doc.spreads) {
for (const el of spread.elements) if (el.kind === 'text') take(el.text);
}
// A face's subsets are chosen by its unicode ranges, so one character of each is enough; the cap keeps it short.
return [...seen].slice(0, 512).join('');
}
/** One face as the CSS font shorthand `document.fonts.load` takes. */
export function faceSpec(use: BookFaceUse): string {
const family = BOOK_FONT_FACES[use.font].name;
return `${use.italic ? 'italic ' : ''}${use.weight} 16px "${family}"`;
}
type FontLoader = Pick<FontFaceSet, 'load'>;
/** Fetch every face the book needs; resolves with how many loaded, never rejects. */
export async function warmBookFaces(
doc: BookDocument,
fonts: FontLoader | undefined = globalThis.document?.fonts
): Promise<number> {
if (!fonts) return 0;
const sample = bookSample(doc);
const loads = await Promise.allSettled(bookFacesUsed(doc).map((use) => fonts.load(faceSpec(use), sample)));
return loads.filter((r) => r.status === 'fulfilled' && r.value.length > 0).length;
}
/**
* Warms the faces of the book the Studio holds whenever the set of faces it
* uses changes; typing into a text does not, so an edit costs nothing here.
*/
export function useWarmBookFaces(): void {
const doc = useStudioStore((s) => s.doc);
const key = useMemo(() => (doc ? bookFacesUsed(doc).map(faceSpec).sort().join('|') : ''), [doc]);
useEffect(() => {
const current = useStudioStore.getState().doc;
// A face that will not load is drawn in its fallback, which the picker already shows; nothing to tell anyone.
if (key && current) void warmBookFaces(current);
}, [key]);
}
+38 -105
View File
@@ -1,118 +1,51 @@
import {
BOOK_FONT_CATEGORIES, BOOK_FONT_FACES, BOOK_FONTS_IDS, bookFontHasItalic, bookFontHasWeight, bookLighterWeight,
nearestBookWeight,
type BookFontCategory, type BookFontFace, type BookFontFamily,
} from '@trek/shared'
/**
* The book's typefaces.
* The book's typefaces, as the Studio draws them.
*
* ── Why this file exists ──────────────────────────────────────────────────
*
* The font stack used to name Georgia for its serif. Georgia is a system font:
* it is on most desktops and on no Linux container, so the print renderer would
* quietly substitute something else and a book proofread in one typeface would
* print in another. The existing Journey PDF export has the same bug today — it
* asks for Inter while only Poppins, Geist and MuseoModerno are bundled.
* print in another.
*
* So every family here is self-hosted through @fontsource, all of them OFL, and
* So every family is self-hosted through @fontsource, OFL or Apache 2.0, and
* every stack ends in a generic rather than in another named font nobody
* shipped. What the editor draws is what the renderer has.
*
* The faces themselves are loaded next door in bookFontFaces.ts. A family named
* here and not imported there falls back to exactly the system font this file
* was written against, which is how five of the seven shipped (#2183).
*
* ── Why these seven ───────────────────────────────────────────────────────
*
* A photo book needs more than one voice and fewer than a menu. Two workhorse
* sans faces, three serifs across the range from bookish to high-contrast, and
* two display faces for a cover. Anything past that is a font picker rather
* than a design decision.
* The catalogue itself (names, stacks, weights, italics, roles) lives in
* @trek/shared (book-fonts.data.ts), because the findings, the design engine
* and the assistant read the same families. This file only gives the Studio
* its view of it; the faces are loaded next door in bookFontFaces.ts.
*/
export interface BookFont {
export type BookFontId = BookFontFamily
export interface BookFont extends BookFontFace {
id: BookFontId
/** What it is called, shown as itself in the picker. */
name: string
/** The CSS stack. Ends generic — never in a font that is not bundled. */
stack: string
/** Where it belongs, for grouping the picker. */
kind: 'sans' | 'serif' | 'display'
/**
* Weights actually loaded. Asking for 700 on a family that ships one weight
* gets a synthesised bold, which prints as a smeared version of the regular.
*/
weights: number[]
}
export type BookFontId =
| 'sans' | 'serif' | 'display'
| 'inter' | 'garamond' | 'playfair' | 'bebas'
/**
* `sans`, `serif` and `display` keep their names because documents already
* contain them — the three original slots, now pointing at bundled families
* rather than at whatever the machine happened to have.
*/
export const BOOK_FONTS: Record<BookFontId, BookFont> = {
sans: {
id: 'sans',
name: 'Poppins',
stack: '"Poppins", system-ui, sans-serif',
kind: 'sans',
weights: [400, 500, 600, 700],
},
inter: {
id: 'inter',
name: 'Inter',
stack: '"Inter", system-ui, sans-serif',
kind: 'sans',
weights: [400, 500, 600, 700],
},
serif: {
id: 'serif',
name: 'Lora',
stack: '"Lora", Georgia, serif',
kind: 'serif',
weights: [400, 500, 600, 700],
},
garamond: {
id: 'garamond',
name: 'EB Garamond',
stack: '"EB Garamond", Georgia, serif',
kind: 'serif',
weights: [400, 500, 600, 700],
},
playfair: {
id: 'playfair',
name: 'Playfair Display',
stack: '"Playfair Display", Georgia, serif',
kind: 'serif',
weights: [400, 500, 600, 700],
},
display: {
id: 'display',
name: 'MuseoModerno',
stack: '"MuseoModerno", "Poppins", system-ui, sans-serif',
kind: 'display',
weights: [400, 500, 600, 700],
},
bebas: {
id: 'bebas',
name: 'Bebas Neue',
stack: '"Bebas Neue", Impact, sans-serif',
kind: 'display',
// One weight only. Asking for bold gets a synthesised one, which is why
// the inspector greys the weights this family does not have.
weights: [400],
},
}
export const BOOK_FONTS = Object.fromEntries(
BOOK_FONTS_IDS.map(id => [id, { id, ...BOOK_FONT_FACES[id] }]),
) as unknown as Record<BookFontId, BookFont>
// Geist Sans is deliberately absent: it is TREK's own subtext face, part of the
// app's voice rather than the book's, and a book set in the interface font
// reads as a screenshot of the app.
/** The picker's order: workhorses first, display last. */
export const BOOK_FONT_ORDER: BookFontId[] = [
'sans', 'inter',
'serif', 'garamond', 'playfair',
'display', 'bebas',
]
/** The picker's order: by category (sans first, mono last), the catalogue's order within one. */
export const BOOK_FONT_ORDER: BookFontId[] = BOOK_FONT_CATEGORIES.flatMap(category =>
BOOK_FONTS_IDS.filter(id => BOOK_FONT_FACES[id].category === category))
/** The families of one picker group, in picker order. */
export function fontsIn(category: BookFontCategory): BookFontId[] {
return BOOK_FONT_ORDER.filter(id => BOOK_FONTS[id].category === category)
}
/**
* The CSS stack for a font id.
@@ -126,20 +59,20 @@ export function fontStack(id: string): string {
}
/** Whether a family actually ships the weight being asked for. */
export function hasWeight(id: string, weight: number): boolean {
const font = BOOK_FONTS[id as BookFontId]
return font ? font.weights.includes(weight) : true
}
export const hasWeight = bookFontHasWeight
/** Whether a family has a real italic at that weight, rather than the browser's slant. */
export const hasItalic = bookFontHasItalic
/**
* The nearest weight a family really has.
*
* A synthesised bold is a smeared regular in print, so a family with one weight
* gets that weight rather than a request the renderer has to fake.
* gets that weight rather than a request the renderer has to fake. The
* renderer draws every element through this as well, so a book whose weight
* was set before its family changed still prints in a face that exists.
*/
export function nearestWeight(id: string, weight: number): number {
const font = BOOK_FONTS[id as BookFontId]
if (!font || font.weights.includes(weight)) return weight
return font.weights.reduce((best, w) =>
(Math.abs(w - weight) < Math.abs(best - weight) ? w : best), font.weights[0])
}
export const nearestWeight = nearestBookWeight
/** The lighter weight a travel element sets beside its figures, one the family has. */
export const lighterWeight = bookLighterWeight
@@ -0,0 +1,47 @@
import { BOOK_FONT_CATEGORIES, type BookFontCategory, type BookWeight } from '@trek/shared';
import { BOOK_FONTS, fontsIn, hasItalic, nearestWeight, type BookFontId } from './bookFonts';
/**
* The type controls' decisions, apart from their markup: which families a
* search finds, the weight a newly picked family is set at, and what the row
* and the italic button say. StudioFontPicker.tsx and StudioTypeControls.tsx
* draw them.
*/
export type FontT = (key: string, params?: Record<string, string | number>) => string;
export interface FontPick {
font: BookFontId;
weight: BookWeight;
}
/** The family, its weight moved onto one the new family has. */
export function pickFont(id: BookFontId, weight: number): FontPick {
// A family that does not ship this weight would render a synthesised bold,
// a smeared regular in print, so the weight moves to the nearest one it has.
return { font: id, weight: nearestWeight(id, weight) as BookWeight };
}
/** The families that match a search, by name, id or group, in picker order. */
export function matchFonts(query: string, groupName: (c: BookFontCategory) => string): BookFontId[] {
const q = query.trim().toLowerCase();
return BOOK_FONT_CATEGORIES.flatMap((category) => {
const group = groupName(category).toLowerCase();
return fontsIn(category).filter(
(id) => !q || BOOK_FONTS[id].name.toLowerCase().includes(q) || id.includes(q) || group.includes(q)
);
});
}
/** The weights a family has, as its row says them: one number, or the first and the last. */
export function weightSpan(id: BookFontId, t: FontT): string {
const ws = BOOK_FONTS[id].weights;
const first = ws[0] ?? 400;
const last = ws[ws.length - 1] ?? first;
return first === last ? String(first) : t('journey.studio.fonts.range', { from: first, to: last });
}
/** The italic button's words: plain where the family has a real italic, the reason where the browser slants it. */
export function italicTitle(font: string, weight: number, t: FontT): string {
return hasItalic(font, nearestWeight(font, weight)) ? t('journey.studio.italic') : t('journey.studio.italicSlanted');
}
@@ -1,4 +1,5 @@
import { ASSISTANT_PAGE_IMAGE, type BookPageSetup, type BookSpread } from '@trek/shared';
import { snapshotFontCss } from './snapshotFonts';
/**
* One Studio page as a JPEG, for the assistant to look at (journey-studio.md
@@ -126,6 +127,8 @@ export async function snapshotSpread(
hiddenPhotoIds: ReadonlySet<number>
): Promise<PageSnapshot | null> {
await picturesLoaded(root, hiddenPhotoIds);
// Only the faces the page draws travel with it (snapshotFonts.ts); without any, the library looks for itself.
const fontCss = await snapshotFontCss(root);
// A single page is drawn up to the long edge too: a larger scale renders the page sharper, it stretches no pixels.
const scale = ASSISTANT_PAGE_IMAGE.longEdge / Math.max(size.width, size.height);
const { domToBlob } = await import('modern-screenshot');
@@ -137,6 +140,7 @@ export async function snapshotSpread(
quality: ASSISTANT_PAGE_IMAGE.jpegQuality,
filter: (node) => keepNode(node, hiddenPhotoIds),
timeout: LOAD_TIMEOUT_MS,
...(fontCss ? { font: { cssText: fontCss } } : {}),
fetch: { requestInit: { credentials: 'same-origin', cache: 'force-cache' } },
});
if (image.size > ASSISTANT_PAGE_IMAGE.maxBytes) return null;
@@ -1,159 +0,0 @@
import { PHOTO_DUOTONES, PHOTO_LOOK_STARTS, bookPhotoElementSchema, type BookPhotoElement } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
import { PhotoCropExtras, PhotoLookSections } from './StudioPhotoLook';
/**
* The inspector rows of the photo look: what each control writes, that a part
* turned off is left out of the document, and the print resolution beside
* the crop with its warning.
*/
const pixels = vi.hoisted(() => ({ value: null as { width: number; height: number } | null }));
vi.mock('./usePhotoPixels', () => ({ usePhotoPixels: () => pixels.value }));
const t = (k: string, params?: Record<string, string | number>) =>
params ? `${k} ${Object.values(params).join(' ')}` : k;
const photo = (over: Record<string, unknown> = {}): BookPhotoElement =>
bookPhotoElementSchema.parse({ id: 'p1', kind: 'photo', frame: { x: 0, y: 0, w: 80, h: 60 }, photoId: 7, ...over });
function mount(ui: 'crop' | 'look', el: BookPhotoElement) {
const set = vi.fn();
const live = vi.fn();
const View = ui === 'crop' ? PhotoCropExtras : PhotoLookSections;
render(<View el={el} set={set} live={live} t={t} />);
return { set, live };
}
beforeEach(() => {
pixels.value = null;
});
describe('beside the crop', () => {
it('FE-STUDIO-LOOKUI-001: zooms live while dragging and commits once, and goes back to no zoom at 100%', () => {
const { set, live } = mount('crop', photo());
const slider = screen.getByRole('slider');
fireEvent.change(slider, { target: { value: '1.5' } });
expect(live).toHaveBeenCalledWith({ zoom: 1.5 });
expect(set).not.toHaveBeenCalled();
fireEvent.pointerUp(slider);
expect(set).toHaveBeenCalledWith({ zoom: 1.5 });
fireEvent.change(slider, { target: { value: '1' } });
fireEvent.blur(slider);
expect(set).toHaveBeenLastCalledWith({ zoom: undefined });
});
it('FE-STUDIO-LOOKUI-002: mirrors the picture and takes the mirror away again', () => {
const plain = mount('crop', photo());
fireEvent.click(screen.getByRole('button', { name: /journey.studio.photo.flip/ }));
expect(plain.set).toHaveBeenCalledWith({ flipX: true });
});
it('FE-STUDIO-LOOKUI-003: takes a mirrored picture back to as shot', () => {
const mirrored = mount('crop', photo({ flipX: true }));
const button = screen.getByRole('button', { name: /journey.studio.photo.flip/ });
expect(button.getAttribute('aria-pressed')).toBe('true');
fireEvent.click(button);
expect(mirrored.set).toHaveBeenCalledWith({ flipX: undefined });
});
it('FE-STUDIO-LOOKUI-004: shows the print resolution once the original is measured, and warns under 200 dpi', () => {
pixels.value = { width: 4000, height: 3000 };
mount('crop', photo());
expect(screen.getByText('journey.studio.photo.dpi 1270')).toBeTruthy();
expect(screen.queryByText(/journey.studio.photo.dpiLow/)).toBeNull();
});
it('FE-STUDIO-LOOKUI-005: warns when the zoomed picture prints soft', () => {
pixels.value = { width: 800, height: 600 };
mount('crop', photo({ zoom: 2 }));
expect(screen.getByText('journey.studio.photo.dpi 127')).toBeTruthy();
expect(screen.getByText('journey.studio.photo.dpiLow 200')).toBeTruthy();
expect(screen.getByRole('status').className).toContain('is-low');
});
it('FE-STUDIO-LOOKUI-006: shows nothing for an empty frame', () => {
pixels.value = { width: 4000, height: 3000 };
mount('crop', photo({ photoId: null }));
expect(screen.queryByRole('slider')).toBeNull();
});
});
describe('the look sections', () => {
it('FE-STUDIO-LOOKUI-007: turns a border on with the print border and off by leaving it out', () => {
const off = mount('look', photo());
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.photo.edges' }));
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.photo.border', pressed: false }));
expect(off.set).toHaveBeenCalledWith({ border: { ...PHOTO_LOOK_STARTS.border } });
});
it('FE-STUDIO-LOOKUI-008: edits a border that is on, and removes it', () => {
const { set } = mount('look', photo({ border: { width: 4, color: '#ffffff' } }));
const width = screen.getByRole('spinbutton', { name: /journey.studio.photo.width/ });
fireEvent.change(width, { target: { value: '6' } });
fireEvent.blur(width);
expect(set).toHaveBeenCalledWith({ border: { width: 6, color: '#ffffff' } });
fireEvent.click(screen.getByRole('button', { name: '#c2410c' }));
expect(set).toHaveBeenCalledWith({ border: { width: 4, color: '#c2410c' } });
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.photo.border', pressed: true }));
expect(set).toHaveBeenLastCalledWith({ border: undefined });
});
it('FE-STUDIO-LOOKUI-009: sets how far a hard shadow falls and how strong it is', () => {
const shadow = { dx: 2, dy: 2, color: '#1a1a1a', alpha: 0.35 };
const { set, live } = mount('look', photo({ shadow }));
const across = screen.getByRole('spinbutton', { name: /journey.studio.photo.dx/ });
fireEvent.change(across, { target: { value: '-3' } });
fireEvent.blur(across);
expect(set).toHaveBeenCalledWith({ shadow: { ...shadow, dx: -3 } });
const strength = screen.getByRole('slider', { name: /journey.studio.opacity/ });
fireEvent.change(strength, { target: { value: '0.6' } });
expect(live).toHaveBeenCalledWith({ shadow: { ...shadow, alpha: 0.6 } });
fireEvent.keyUp(strength);
expect(set).toHaveBeenLastCalledWith({ shadow: { ...shadow, alpha: 0.6 } });
});
it('FE-STUDIO-LOOKUI-010: turns the shadow and the cutout on from their starting values', () => {
const { set } = mount('look', photo());
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.photo.edges' }));
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.photo.shadow', pressed: false }));
expect(set).toHaveBeenCalledWith({ shadow: { ...PHOTO_LOOK_STARTS.shadow } });
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.photo.cutout', pressed: false }));
expect(set).toHaveBeenCalledWith({ cutout: { ...PHOTO_LOOK_STARTS.cutout } });
});
it('FE-STUDIO-LOOKUI-011: adjusts one value at a time, says it prints as a raster, and resets', () => {
const { set } = mount('look', photo({ adjust: { contrast: 0.2 } }));
expect(screen.getAllByText('journey.studio.photo.rasterHint').length).toBeGreaterThan(0);
const brightness = screen.getByRole('slider', { name: /journey.studio.photo.brightness/ });
fireEvent.change(brightness, { target: { value: '0.3' } });
fireEvent.pointerUp(brightness);
expect(set).toHaveBeenCalledWith({ adjust: { brightness: 0.3, contrast: 0.2, saturation: 0, warmth: 0 } });
const contrast = screen.getByRole('slider', { name: /journey.studio.photo.contrast/ });
fireEvent.change(contrast, { target: { value: '0' } });
fireEvent.pointerUp(contrast);
expect(set).toHaveBeenLastCalledWith({ adjust: undefined });
fireEvent.click(screen.getByRole('button', { name: /journey.studio.photo.reset/ }));
expect(set).toHaveBeenLastCalledWith({ adjust: undefined });
});
it('FE-STUDIO-LOOKUI-012: picks a ready duotone pair and the inks by hand', () => {
const { set } = mount('look', photo({ duotone: { ...PHOTO_DUOTONES[0] } }));
const pairs = screen.getAllByRole('radio');
expect(pairs).toHaveLength(PHOTO_DUOTONES.length);
expect(pairs[0]?.getAttribute('aria-checked')).toBe('true');
fireEvent.click(pairs[2] as HTMLElement);
expect(set).toHaveBeenCalledWith({ duotone: { ...PHOTO_DUOTONES[2] } });
fireEvent.click(screen.getAllByRole('button', { name: '#9f1239' })[0] as HTMLElement);
expect(set).toHaveBeenLastCalledWith({ duotone: { ...PHOTO_DUOTONES[0], dark: '#9f1239' } });
});
it('FE-STUDIO-LOOKUI-013: turns the duotone on and off', () => {
const { set } = mount('look', photo());
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.photo.duotone', expanded: false }));
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.photo.duotone', pressed: false }));
expect(set).toHaveBeenCalledWith({ duotone: { ...PHOTO_LOOK_STARTS.duotone } });
});
});
@@ -1,292 +0,0 @@
import {
MIN_PRINT_DPI,
PHOTO_ADJUSTMENTS,
PHOTO_DUOTONES,
PHOTO_EDGE_MAX,
PHOTO_LOOK_STARTS,
PHOTO_SHADOW_REACH,
PHOTO_ZOOM,
photoDpi,
photoLookOf,
type BookPhotoAdjust,
type BookPhotoEdge,
type BookPhotoElement,
type PhotoAdjustment,
} from '@trek/shared';
import { FlipHorizontal2, Printer, RotateCcw, TriangleAlert } from 'lucide-react';
import '../../../styles/studio-photo.css';
import { Line, NumField, Section, Switch } from '../StudioControls';
import { Swatches } from '../StudioSwatches';
import { StudioSlider } from './StudioSlider';
import { usePhotoPixels } from './usePhotoPixels';
/**
* The inspector rows of the photo look (book-photo-look.schema.ts in shared):
* zoom, mirror and the print resolution beside the crop, then the border, the
* hard shadow and the cutout, the adjustments and the duotone.
*
* Its own file so the inspector carries two hooks for it and nothing else;
* every other Studio package edits that panel too.
*
* A part that is off is left out of the document (undefined), never stored as
* a neutral value, so a photo nobody restyled stays exactly what it was.
*/
type Patch = Partial<BookPhotoElement>;
type T = (k: string, params?: Record<string, string | number>) => string;
interface RowProps {
el: BookPhotoElement;
/** One undo step. */
set: (patch: Patch) => void;
/** While a slider moves: the page follows, nothing is committed. */
live: (patch: Patch) => void;
t: T;
}
const percent = (v: number) => `${Math.round(v * 100)}%`;
const signed = (v: number) => {
const n = Math.round(v * 100);
return n > 0 ? `+${n}` : String(n);
};
/** What prints when the photo leaves the printer: about this many dots per inch, and a warning under the threshold. */
function PrintResolution({ el, t }: { el: BookPhotoElement; t: T }) {
const pixels = usePhotoPixels(el.photoId);
if (el.photoId == null || !pixels) return null;
const dpi = photoDpi(el, { photoId: el.photoId, ...pixels });
if (dpi === null) return null;
const low = dpi < MIN_PRINT_DPI;
return (
<div className={`st-photo-dpi ${low ? 'is-low' : ''}`} role="status">
{low ? <TriangleAlert size={14} aria-hidden /> : <Printer size={14} aria-hidden />}
<div>
<p className="st-photo-dpi-value">{t('journey.studio.photo.dpi', { dpi })}</p>
{low && <p className="st-photo-dpi-hint">{t('journey.studio.photo.dpiLow', { min: MIN_PRINT_DPI })}</p>}
</div>
</div>
);
}
/** Beside the crop: how far the picture is zoomed about its focal point, whether it is mirrored, and how it prints. */
export function PhotoCropExtras({ el, set, live, t }: RowProps) {
// An empty frame has no picture to zoom, mirror or print.
if (el.photoId == null) return null;
const look = photoLookOf(el);
const zoomOf = (v: number) => (v > PHOTO_ZOOM.min ? Math.round(v * 100) / 100 : undefined);
return (
<div className="st-photo-crop">
<StudioSlider
label={t('journey.studio.photo.zoom')}
value={look.zoom}
min={PHOTO_ZOOM.min}
max={PHOTO_ZOOM.max}
step={0.05}
format={percent}
onLive={(v) => live({ zoom: zoomOf(v) })}
onCommit={(v) => set({ zoom: zoomOf(v) })}
/>
<button
type="button"
className={`st-chip ${look.flipX ? 'is-on' : ''}`}
aria-pressed={look.flipX}
onClick={() => set({ flipX: look.flipX ? undefined : true })}
>
<FlipHorizontal2 size={13} aria-hidden />
{t('journey.studio.photo.flip')}
</button>
<PrintResolution el={el} t={t} />
</div>
);
}
/** A border or a cutout: on or off, how deep, what colour. */
function EdgeRow({
label,
value,
start,
onSet,
t,
}: {
label: string;
value: BookPhotoEdge | null;
start: BookPhotoEdge;
onSet: (edge: BookPhotoEdge | undefined) => void;
t: T;
}) {
return (
<div className="st-photo-part">
<Switch label={label} on={!!value} onToggle={() => onSet(value ? undefined : { ...start })} />
{value && (
<div className="st-photo-part-body">
<NumField
label={t('journey.studio.photo.width')}
value={value.width}
min={0.1}
max={PHOTO_EDGE_MAX}
step={0.5}
unit="mm"
onChange={(width) => onSet({ ...value, width })}
/>
<Line label={t('journey.studio.colour')} wide>
<Swatches value={value.color} onPick={(color) => onSet({ ...value, color })} />
</Line>
</div>
)}
</div>
);
}
/** The hard shadow: on or off, how far it falls, how strong, what colour. */
function ShadowRow({ el, set, live, t }: RowProps) {
const shadow = photoLookOf(el).shadow;
return (
<div className="st-photo-part">
<Switch
label={t('journey.studio.photo.shadow')}
on={!!shadow}
onToggle={() => set({ shadow: shadow ? undefined : { ...PHOTO_LOOK_STARTS.shadow } })}
/>
{shadow && (
<div className="st-photo-part-body">
<div className="st-grid2">
{(['dx', 'dy'] as const).map((axis) => (
<NumField
key={axis}
label={t(`journey.studio.photo.${axis}`)}
value={shadow[axis]}
min={-PHOTO_SHADOW_REACH}
max={PHOTO_SHADOW_REACH}
step={0.5}
unit="mm"
onChange={(v) => set({ shadow: { ...shadow, [axis]: v } })}
/>
))}
</div>
<StudioSlider
label={t('journey.studio.opacity')}
value={shadow.alpha}
min={0}
max={1}
step={0.05}
format={percent}
onLive={(alpha) => live({ shadow: { ...shadow, alpha } })}
onCommit={(alpha) => set({ shadow: { ...shadow, alpha } })}
/>
<Line label={t('journey.studio.colour')} wide>
<Swatches value={shadow.color} onPick={(color) => set({ shadow: { ...shadow, color } })} />
</Line>
</div>
)}
</div>
);
}
/** Brightness, contrast, saturation and warmth, each from -100 to +100, and one press back to the picture as shot. */
function AdjustRows({ el, set, live, t }: RowProps) {
const adjust = photoLookOf(el).adjust;
const next = (key: PhotoAdjustment, v: number): BookPhotoAdjust | undefined => {
const all = { ...(adjust ?? {}), [key]: Math.round(v * 100) / 100 };
return PHOTO_ADJUSTMENTS.some((k) => all[k]) ? all : undefined;
};
return (
<>
{PHOTO_ADJUSTMENTS.map((key) => (
<StudioSlider
key={key}
label={t(`journey.studio.photo.${key}`)}
value={adjust?.[key] ?? 0}
min={-1}
max={1}
step={0.05}
format={signed}
onLive={(v) => live({ adjust: next(key, v) })}
onCommit={(v) => set({ adjust: next(key, v) })}
/>
))}
{adjust && (
<button type="button" className="st-chip st-photo-reset" onClick={() => set({ adjust: undefined })}>
<RotateCcw size={13} aria-hidden />
{t('journey.studio.photo.reset')}
</button>
)}
</>
);
}
/** The duotone: on or off, one of the ready pairs, or the two inks picked by hand. */
function DuotoneRows({ el, set, t }: Omit<RowProps, 'live'>) {
const duotone = photoLookOf(el).duotone;
return (
<>
<Switch
label={t('journey.studio.photo.duotone')}
on={!!duotone}
onToggle={() => set({ duotone: duotone ? undefined : { ...PHOTO_LOOK_STARTS.duotone } })}
/>
{duotone && (
<div className="st-photo-part-body">
<div className="st-photo-duos" role="radiogroup" aria-label={t('journey.studio.photo.duotone')}>
{PHOTO_DUOTONES.map((pair) => {
const on = pair.dark === duotone.dark && pair.light === duotone.light;
return (
<button
type="button"
key={`${pair.dark}${pair.light}`}
role="radio"
aria-checked={on}
aria-label={`${pair.dark} ${pair.light}`}
className={`st-photo-duo ${on ? 'is-on' : ''}`}
// A swatch shows the two inks themselves, half and half.
style={{ backgroundImage: `linear-gradient(135deg, ${pair.dark} 50%, ${pair.light} 50%)` }}
onClick={() => set({ duotone: { ...pair } })}
/>
);
})}
</div>
<Line label={t('journey.studio.photo.duotoneDark')} wide>
<Swatches value={duotone.dark} onPick={(dark) => set({ duotone: { ...duotone, dark } })} />
</Line>
<Line label={t('journey.studio.photo.duotoneLight')} wide>
<Swatches value={duotone.light} onPick={(light) => set({ duotone: { ...duotone, light } })} />
</Line>
</div>
)}
</>
);
}
/** The look's own sections, after the inspector's Look section. */
export function PhotoLookSections(props: RowProps) {
const { el, set, t } = props;
const look = photoLookOf(el);
const raster = t('journey.studio.photo.rasterHint');
return (
<>
<Section label={t('journey.studio.photo.edges')} defaultOpen={!!(look.border || look.shadow || look.cutout)}>
<EdgeRow
label={t('journey.studio.photo.border')}
value={look.border}
start={PHOTO_LOOK_STARTS.border}
onSet={(border) => set({ border })}
t={t}
/>
<ShadowRow {...props} />
<EdgeRow
label={t('journey.studio.photo.cutout')}
value={look.cutout}
start={PHOTO_LOOK_STARTS.cutout}
onSet={(cutout) => set({ cutout })}
t={t}
/>
</Section>
<Section label={t('journey.studio.photo.adjust')} hint={raster} defaultOpen={!!look.adjust}>
<AdjustRows {...props} />
</Section>
<Section label={t('journey.studio.photo.duotone')} hint={raster} defaultOpen={!!look.duotone}>
<DuotoneRows el={el} set={set} t={t} />
</Section>
</>
);
}
@@ -1,61 +0,0 @@
import { useState } from 'react';
/**
* A number set by dragging, for the values a person judges by eye: a zoom, a
* brightness, a shadow's strength.
*
* The page follows the thumb while it moves (`onLive`, no undo step), and the
* value is committed once, when the thumb is let go or the keys are released
* (`onCommit`, one undo step), the way the focal point pad works.
*/
export function StudioSlider({
label,
value,
min,
max,
step,
format,
onLive,
onCommit,
}: {
label: string;
value: number;
min: number;
max: number;
step: number;
/** What the value reads as beside the track. */
format: (v: number) => string;
onLive: (v: number) => void;
onCommit: (v: number) => void;
}) {
const [draft, setDraft] = useState<number | null>(null);
const shown = draft ?? value;
const end = () => {
if (draft === null) return;
setDraft(null);
onCommit(draft);
};
return (
<label className="st-slider">
<span className="st-slider-label">{label}</span>
<input
type="range"
min={min}
max={max}
step={step}
value={shown}
aria-valuetext={format(shown)}
onChange={(e) => {
const v = Number(e.target.value);
setDraft(v);
onLive(v);
}}
onPointerUp={end}
onKeyUp={end}
onBlur={end}
/>
<output className="st-slider-value">{format(shown)}</output>
</label>
);
}
@@ -1,98 +0,0 @@
import { photoLookOf, type PhotoLook } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import {
adjustFilter,
duotoneMatrix,
pictureFilter,
rgba,
silhouetteShadows,
svgFilterMatrices,
svgId,
warmthMatrix,
zoomPlacement,
} from './photoLook';
/**
* The numbers the photo look prints with: the CSS filter chain, the colour
* matrices of the SVG filter, the box shadows of a rectangular frame and the
* placement of a zoomed picture.
*/
const look = (over: Partial<PhotoLook> = {}): PhotoLook => ({ ...photoLookOf({}), ...over });
const adjust = (a: Partial<NonNullable<PhotoLook['adjust']>>) => ({
brightness: 0,
contrast: 0,
saturation: 0,
warmth: 0,
...a,
});
describe('photo look rendering', () => {
it('FE-STUDIO-LOOK-001: turns adjustments into filter functions, leaving out what does not move', () => {
expect(adjustFilter(null)).toEqual([]);
expect(adjustFilter(adjust({ brightness: 0.2, contrast: -1, saturation: -1 }))).toEqual([
'brightness(1.1)',
'contrast(0.5)',
'saturate(0)',
]);
expect(adjustFilter(adjust({ warmth: 0.5 }))).toEqual([]);
});
it('FE-STUDIO-LOOK-002: chains the preset, the adjustments and the SVG filter, and draws nothing for none', () => {
expect(pictureFilter(undefined, look(), null)).toBeUndefined();
expect(pictureFilter('grayscale(1)', look({ adjust: adjust({ contrast: 0.4 }) }), 'f-x')).toBe(
'grayscale(1) contrast(1.2) url(#f-x)'
);
});
it('FE-STUDIO-LOOK-003: warms by raising red and lowering blue, and cools the other way', () => {
expect(warmthMatrix(0)).toBeNull();
expect(warmthMatrix(1)).toBe('1.12 0 0 0 0 0 1.03 0 0 0 0 0 0.88 0 0 0 0 0 1 0');
expect(warmthMatrix(-1)).toBe('0.88 0 0 0 0 0 0.97 0 0 0 0 0 1.12 0 0 0 0 0 1 0');
});
it('FE-STUDIO-LOOK-004: maps black to the dark ink and white to the light one', () => {
const values = duotoneMatrix({ dark: '#000000', light: '#ffffff' }).split(' ').map(Number);
// Each colour row reads the luminance weights and starts from the dark ink.
expect(values.slice(0, 5)).toEqual([0.213, 0.715, 0.072, 0, 0]);
expect(values.slice(15)).toEqual([0, 0, 0, 1, 0]);
const navy = duotoneMatrix({ dark: '#ff0000', light: '#ff0000' }).split(' ').map(Number);
// The same ink at both ends is that ink, whatever the picture.
expect(navy.slice(0, 5)).toEqual([0, 0, 0, 0, 1]);
expect(navy.slice(5, 10)).toEqual([0, 0, 0, 0, 0]);
});
it('FE-STUDIO-LOOK-005: needs an SVG filter only for warmth or a duotone, warmth first', () => {
expect(svgFilterMatrices(look())).toEqual([]);
expect(svgFilterMatrices(look({ adjust: adjust({ brightness: 0.3 }) }))).toEqual([]);
const both = svgFilterMatrices(
look({ adjust: adjust({ warmth: 1 }), duotone: { dark: '#000000', light: '#ffffff' } })
);
expect(both).toHaveLength(2);
expect(both[0]).toBe(warmthMatrix(1));
});
it('FE-STUDIO-LOOK-006: draws the cutout and a shadow grown by it as flat box shadows', () => {
expect(silhouetteShadows(null, null)).toEqual([]);
expect(silhouetteShadows({ dx: 2, dy: 3, color: '#102030', alpha: 0.5 }, null)).toEqual([
'2mm 3mm 0 0mm rgba(16, 32, 48, 0.5)',
]);
expect(silhouetteShadows({ dx: -1, dy: 1, color: '#000000', alpha: 1 }, { width: 3, color: '#ffffff' })).toEqual([
'0 0 0 3mm #ffffff',
'-1mm 1mm 0 3mm rgba(0, 0, 0, 1)',
]);
expect(rgba('#ff8000', 0.25)).toBe('rgba(255, 128, 0, 0.25)');
});
it('FE-STUDIO-LOOK-007: places a zoomed picture so the focal point stays where it was', () => {
expect(zoomPlacement(1, 0.5, 0.5)).toBeNull();
expect(zoomPlacement(2, 0.5, 0.5)).toEqual({ size: '200%', transform: 'translate(-25%, -25%)' });
expect(zoomPlacement(4, 0, 1)).toEqual({ size: '400%', transform: 'translate(0%, -75%)' });
});
it('FE-STUDIO-LOOK-008: makes an id a url() can reference from what useId returns', () => {
expect(svgId('c', ':r1:')).toBe('c-r1');
expect(svgId('f', '«r2»')).toBe('f-r2');
});
});
@@ -1,120 +0,0 @@
import type { BookPhotoDuotone, BookPhotoEdge, BookPhotoShadow, PhotoLook } from '@trek/shared';
/**
* The photo look (book-photo-look.schema.ts in shared) turned into what the
* page draws: CSS for the picture, the colour matrices of its SVG filter, and
* the box shadows a rectangular frame wears.
*
* Pure, and apart from PhotoView, because these are the numbers the look
* prints with and a rendered component is an awkward place to prove them.
*
* What prints as vector and what does not is decided here: a border, a hard
* shadow and a cutout are boxes and paths of one colour, while adjustments,
* warmth and a duotone are filters on the picture, which the printer receives
* as a raster the way it already received the filter presets.
*/
/** #rrggbb with an alpha, for a shadow drawn at its own strength. */
export function rgba(hex: string, alpha: number): string {
const n = Number.parseInt(hex.slice(1), 16);
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
}
const round3 = (v: number) => Math.round(v * 1000) / 1000;
/**
* Brightness, contrast and saturation as CSS filter functions, after the
* preset filter. Half a step either way for the first two, which keeps the
* ends of the slider usable; saturation runs from grey to twice the colour.
*/
export function adjustFilter(adjust: PhotoLook['adjust']): string[] {
if (!adjust) return [];
const out: string[] = [];
if (adjust.brightness) out.push(`brightness(${round3(1 + adjust.brightness * 0.5)})`);
if (adjust.contrast) out.push(`contrast(${round3(1 + adjust.contrast * 0.5)})`);
if (adjust.saturation) out.push(`saturate(${round3(1 + adjust.saturation)})`);
return out;
}
/** The filter on the picture: the preset, the adjustments, then the SVG filter when there is one. */
export function pictureFilter(
preset: string | undefined,
look: PhotoLook,
svgFilterId: string | null
): string | undefined {
const parts = [preset, ...adjustFilter(look.adjust), svgFilterId ? `url(#${svgFilterId})` : undefined].filter(
(p): p is string => !!p
);
return parts.length ? parts.join(' ') : undefined;
}
/**
* Warmth as a colour matrix: red up and blue down for warm, the reverse for
* cool, green a little with red so a warm picture does not turn pink.
*/
export function warmthMatrix(warmth: number): string | null {
if (!warmth) return null;
const r = round3(1 + warmth * 0.12);
const g = round3(1 + warmth * 0.03);
const b = round3(1 - warmth * 0.12);
return `${r} 0 0 0 0 0 ${g} 0 0 0 0 0 ${b} 0 0 0 0 0 1 0`;
}
const channels = (hex: string) => {
const n = Number.parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((c) => c / 255) as [number, number, number];
};
/** Rec. 709 luminance weights, the brightness the duotone maps from. */
const LUMA = [0.2126, 0.7152, 0.0722] as const;
/** A duotone as a colour matrix: the picture's luminance mapped from `dark` (black) to `light` (white). */
export function duotoneMatrix(duotone: BookPhotoDuotone): string {
const dark = channels(duotone.dark);
const light = channels(duotone.light);
const rows = dark.map((d, i) => {
const span = (light[i] ?? 0) - d;
return `${LUMA.map((w) => round3(w * span)).join(' ')} 0 ${round3(d)}`;
});
return `${rows.join(' ')} 0 0 0 1 0`;
}
/** The colour matrices the picture's SVG filter applies, in order; empty when it needs none. */
export function svgFilterMatrices(look: PhotoLook): string[] {
const warm = warmthMatrix(look.adjust?.warmth ?? 0);
return [warm, look.duotone ? duotoneMatrix(look.duotone) : null].filter((m): m is string => m !== null);
}
/**
* The die-cut edge and the hard shadow of a rectangular frame, as box
* shadows: no blur, so each is one flat shape, drawn behind the frame and
* following its corner radius. The shadow is grown by the cutout too, so it
* falls from the whole sticker rather than from the picture inside it.
*/
export function silhouetteShadows(shadow: BookPhotoShadow | null, cutout: BookPhotoEdge | null): string[] {
const out: string[] = [];
const grow = cutout?.width ?? 0;
if (cutout) out.push(`0 0 0 ${cutout.width}mm ${cutout.color}`);
if (shadow) out.push(`${shadow.dx}mm ${shadow.dy}mm 0 ${grow}mm ${rgba(shadow.color, shadow.alpha)}`);
return out;
}
/**
* Where the picture sits inside a zoomed frame, as the image's own size and a
* shift in its own percentages: z times the frame, moved so the focal point
* stays where the frame put it at zoom 1.
*/
export function zoomPlacement(
zoom: number,
focalX: number,
focalY: number
): { size: string; transform: string } | null {
if (zoom <= 1) return null;
const shift = (f: number) => round3((-(zoom - 1) * f * 100) / zoom);
return { size: `${round3(zoom * 100)}%`, transform: `translate(${shift(focalX)}%, ${shift(focalY)}%)` };
}
/** A safe SVG id from React's useId, which carries characters a url() reference would need escaped. */
export function svgId(prefix: string, reactId: string): string {
return `${prefix}-${reactId.replace(/[^A-Za-z0-9_-]/g, '')}`;
}
@@ -1,68 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { usePhotoPixels } from './usePhotoPixels';
/**
* The original's pixel size, read off the original itself for the photo the
* inspector shows: nothing until it has loaded, nothing for another photo
* than the one asked about, nothing for an empty frame.
*/
class FakeImage {
static made: FakeImage[] = [];
onload: (() => void) | null = null;
naturalWidth = 0;
naturalHeight = 0;
src = '';
constructor() {
FakeImage.made.push(this);
}
load(width: number, height: number) {
this.naturalWidth = width;
this.naturalHeight = height;
this.onload?.();
}
}
beforeEach(() => {
FakeImage.made = [];
vi.stubGlobal('Image', FakeImage);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('usePhotoPixels', () => {
it('FE-STUDIO-PIXELS-001: loads the original and answers its size once it is in', () => {
const { result } = renderHook(() => usePhotoPixels(7));
expect(result.current).toBeNull();
const img = FakeImage.made[0] as FakeImage;
expect(img.src).toBe('/api/photos/7/original');
act(() => img.load(4000, 3000));
expect(result.current).toEqual({ width: 4000, height: 3000 });
});
it('FE-STUDIO-PIXELS-002: forgets the size when another photo is asked about, and ignores the old one landing late', () => {
const { result, rerender } = renderHook(({ id }: { id: number | null }) => usePhotoPixels(id), {
initialProps: { id: 7 as number | null },
});
const first = FakeImage.made[0] as FakeImage;
rerender({ id: 8 });
expect(first.onload).toBeNull();
act(() => first.load(100, 100));
expect(result.current).toBeNull();
act(() => (FakeImage.made[1] as FakeImage).load(600, 400));
expect(result.current).toEqual({ width: 600, height: 400 });
rerender({ id: null });
expect(result.current).toBeNull();
expect(FakeImage.made).toHaveLength(2);
});
it('FE-STUDIO-PIXELS-003: says nothing for a picture that loads without a size', () => {
const { result } = renderHook(() => usePhotoPixels(9));
act(() => (FakeImage.made[0] as FakeImage).load(0, 0));
expect(result.current).toBeNull();
});
});
@@ -1,40 +0,0 @@
import { useEffect, useState } from 'react';
import { photoSrc } from '../bookRender';
export interface PhotoPixels {
width: number;
height: number;
}
/**
* The pixel size of a photo's original, measured by loading it.
*
* The journey the Studio holds knows which pictures there are but not how
* large their originals are (the server measures that for the book engine and
* keeps it there), and a print resolution is nothing without it. So the
* inspector reads it off the original itself, for the one photo selected: the
* canvas loads that same original from a zoom of a third up, so most of the
* time the browser already has it. Null until it has loaded, and for an empty
* frame or a picture that does not load, which says nothing rather than guess.
*/
export function usePhotoPixels(photoId: number | null): PhotoPixels | null {
const [seen, setSeen] = useState<{ photoId: number; pixels: PhotoPixels } | null>(null);
useEffect(() => {
if (photoId == null) return undefined;
let cancelled = false;
const img = new Image();
img.onload = () => {
if (cancelled || !img.naturalWidth || !img.naturalHeight) return;
setSeen({ photoId, pixels: { width: img.naturalWidth, height: img.naturalHeight } });
};
img.src = photoSrc(photoId, true);
return () => {
cancelled = true;
img.onload = null;
};
}, [photoId]);
return seen && seen.photoId === photoId ? seen.pixels : null;
}
@@ -0,0 +1,190 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
coversAny,
cssWeight,
faceRulesOf,
faceUsesOf,
parseFaceRule,
parseUnicodeRange,
pickFaceRules,
snapshotFontCss,
type FaceRule,
} from './snapshotFonts';
/**
* The faces a page snapshot carries (FE-STUDIO-SNAPFONT): what the page draws
* is read off the DOM, the browser's choice of rule is repeated, and only the
* faces it would pick go into the image, each with its file inline.
*/
const BASE = 'https://trek.example/assets/app.css';
const LATIN = 'U+0000-00FF, U+0131, U+0152-0153';
const EXT = 'U+0100-02BA, U+1E00-1E9F';
const rule = (family: string, weight: number, italic: boolean, range: string, file: string): FaceRule => ({
family,
weight,
italic,
unicodeRange: range,
url: `https://trek.example/assets/${file}`,
});
const RULES = [
rule('Lora', 400, false, LATIN, 'lora-latin-400-normal.woff2'),
rule('Lora', 400, false, EXT, 'lora-latin-ext-400-normal.woff2'),
rule('Lora', 700, false, LATIN, 'lora-latin-700-normal.woff2'),
rule('Lora', 400, true, LATIN, 'lora-latin-400-italic.woff2'),
rule('Caveat', 400, false, LATIN, 'caveat-latin-400-normal.woff2'),
rule('Anton', 400, false, LATIN, 'anton-latin-400-normal.woff2'),
];
const chars = (s: string) => new Set([...s].map((c) => c.codePointAt(0) ?? 0));
const files = (picked: FaceRule[]) => picked.map((r) => r.url.split('/').pop()).sort();
function page(html: string): HTMLElement {
const root = document.createElement('div');
root.innerHTML = html;
document.body.appendChild(root);
return root;
}
afterEach(() => {
document.body.innerHTML = '';
});
describe('reading a rule', () => {
it('FE-STUDIO-SNAPFONT-001: reads family, weight, slant, range and the woff2 file of an @font-face rule', () => {
const css =
"@font-face { font-family: 'Lora'; font-style: italic; font-display: swap; font-weight: 700; " +
'src: url(./files/lora-latin-700-italic.woff2) format("woff2"), url(./files/lora-latin-700-italic.woff) format("woff"); ' +
`unicode-range: ${LATIN}; }`;
expect(parseFaceRule(css, BASE)).toEqual({
family: 'Lora',
weight: 700,
italic: true,
unicodeRange: LATIN,
url: 'https://trek.example/assets/files/lora-latin-700-italic.woff2',
});
const plain = '@font-face { font-family: "Anton"; src: url("/assets/anton.woff") format("woff"); }';
expect(parseFaceRule(plain, BASE)).toMatchObject({
weight: 400,
italic: false,
unicodeRange: '',
url: 'https://trek.example/assets/anton.woff',
});
expect(parseFaceRule('.x { color: red; }', BASE)).toBeNull();
expect(parseFaceRule('@font-face { font-family: "NoFile"; }', BASE)).toBeNull();
expect(parseFaceRule('@font-face { src: url(a.woff2); }', BASE)).toBeNull();
});
it('FE-STUDIO-SNAPFONT-002: reads weights and unicode ranges as CSS does', () => {
expect([cssWeight('bold'), cssWeight('normal'), cssWeight('300'), cssWeight('5000')]).toEqual([700, 400, 300, 400]);
expect(parseUnicodeRange('U+0000-00FF, U+0131, U+4??')).toEqual([
[0, 0xff],
[0x131, 0x131],
[0x400, 0x4ff],
]);
expect(parseUnicodeRange('U+zz, ')).toEqual([]);
expect(coversAny(LATIN, chars('Lisboa'))).toBe(true);
expect(coversAny(EXT, chars('Lisboa'))).toBe(false);
expect(coversAny(EXT, chars('Łódź'))).toBe(true);
expect(coversAny('', chars('anything'))).toBe(true);
});
});
describe('choosing the faces', () => {
it('FE-STUDIO-SNAPFONT-003: picks one rule per subset the text reaches, at the weight and slant it is set in', () => {
const uses = new Map([
['lora|700|false', chars('Porto')],
['caveat|400|false', chars('travel')],
]);
expect(files(pickFaceRules(RULES, uses))).toEqual(['caveat-latin-400-normal.woff2', 'lora-latin-700-normal.woff2']);
// Polish needs the extended subset beside the basic one.
const polish = new Map([['lora|400|false', chars('Kraków Łódź')]]);
expect(files(pickFaceRules(RULES, polish))).toEqual([
'lora-latin-400-normal.woff2',
'lora-latin-ext-400-normal.woff2',
]);
});
it('FE-STUDIO-SNAPFONT-004: falls back the way the browser does: the nearest weight, the upright face, nothing for an unknown family', () => {
expect(files(pickFaceRules(RULES, new Map([['lora|600|false', chars('a')]])))).toEqual([
'lora-latin-700-normal.woff2',
]);
expect(files(pickFaceRules(RULES, new Map([['lora|400|true', chars('a')]])))).toEqual([
'lora-latin-400-italic.woff2',
]);
expect(files(pickFaceRules(RULES, new Map([['anton|400|true', chars('a')]])))).toEqual([
'anton-latin-400-normal.woff2',
]);
expect(pickFaceRules(RULES, new Map([['georgia|400|false', chars('a')]]))).toEqual([]);
});
});
describe('reading the page', () => {
it('FE-STUDIO-SNAPFONT-005: reads every face the text under the root is set in, with its characters', () => {
const root = page(
'<p style="font-family: \'Lora\', Georgia, serif; font-weight: 700">Porto</p>' +
'<svg><text style="font-family: Caveat, cursive; font-style: italic">ab</text></svg>' +
'<p style="font-family: Lora; font-weight: bold">Té</p>' +
'<p style="font-family: Anton"> </p>'
);
const uses = faceUsesOf(root);
expect([...uses.keys()].sort()).toEqual(['caveat|400|true', 'lora|700|false']);
expect(uses.get('lora|700|false')).toEqual(chars('PortoTé'));
});
it('FE-STUDIO-SNAPFONT-006: reads the face rules of the stylesheets it may read, inside grouping rules too', () => {
const face = (family: string) => ({
cssText: `@font-face { font-family: "${family}"; src: url(/assets/${family}.woff2) format("woff2"); }`,
});
const fake = {
baseURI: 'https://trek.example/',
styleSheets: [
{
href: null,
cssRules: [face('Lora'), { cssText: '.x{}' }, { cssText: '@media print {}', cssRules: [face('Anton')] }],
},
{
href: 'https://cdn.example/a.css',
get cssRules(): never {
throw new DOMException('cross origin', 'SecurityError');
},
},
],
} as unknown as Document;
expect(faceRulesOf(fake).map((r) => [r.family, r.url])).toEqual([
['Lora', 'https://trek.example/assets/Lora.woff2'],
['Anton', 'https://trek.example/assets/Anton.woff2'],
]);
});
});
describe('the snapshot css', () => {
it('FE-STUDIO-SNAPFONT-007: inlines exactly the faces the page draws, and leaves out one that will not load', async () => {
const root = page(
'<p style="font-family: Lora; font-weight: 700">Porto</p><p style="font-family: Caveat">travel</p>'
);
const load = vi.fn(async (url: string) =>
url.includes('caveat') ? null : new Blob(['font'], { type: 'font/woff2' })
);
const css = await snapshotFontCss(root, load, RULES);
expect(load.mock.calls.map(([url]) => url.split('/').pop()).sort()).toEqual([
'caveat-latin-400-normal.woff2',
'lora-latin-700-normal.woff2',
]);
expect(css).toContain('@font-face{font-family:"Lora";font-style:normal;font-weight:700;unicode-range:U+0000-00FF');
expect(css).toContain('src:url(data:font/woff2;base64,');
expect(css).not.toContain('Caveat');
});
it('FE-STUDIO-SNAPFONT-008: answers null when the page draws no bundled face or none of its files load, so the library looks for itself', async () => {
const root = page('<p style="font-family: Georgia">Porto</p>');
expect(await snapshotFontCss(root, vi.fn(), RULES)).toBeNull();
const lora = page('<p style="font-family: Lora">Porto</p>');
const failing = vi.fn(async () => {
throw new Error('offline');
});
expect(await snapshotFontCss(lora, failing, RULES)).toBeNull();
});
});
@@ -0,0 +1,233 @@
/**
* The typefaces a page snapshot for the assistant carries, and only those.
*
* modern-screenshot draws the page as an SVG image, and an SVG image cannot
* reach the document's fonts, so every face it shows has to travel inside it.
* Left to itself the library embeds every @font-face rule of every family the
* page names: all nine weights of Montserrat, both styles, every script
* subset, woff and woff2. With thirty four book families that is megabytes of
* fonts per page for a picture whose text uses one weight of two families.
*
* So the snapshot reads what the page really draws (family, weight, slant and
* the characters set in it), picks the one rule per subset the browser would
* pick for that, and hands the library exactly those faces as data URLs. A
* page set in Lora and Caveat embeds the latin files of Lora and Caveat, and
* nothing else.
*/
/** One @font-face rule, as far as the choice of face goes. */
export interface FaceRule {
family: string;
weight: number;
italic: boolean;
/** The CSS unicode-range, or '' for a face that covers everything. */
unicodeRange: string;
/** The woff2 file, else the first file the rule names, resolved to a full URL. */
url: string;
}
/** What the page draws in one face: the characters set in it. */
type FaceUses = Map<string, Set<number>>;
const faceKey = (family: string, weight: number, italic: boolean) => `${family.toLowerCase()}|${weight}|${italic}`;
const unquote = (v: string) => v.trim().replace(/^["']|["']$/g, '');
/** A rule's descriptors by name, read from its text: `font-weight: 700` and the like. */
function descriptors(css: string): Map<string, string> {
const body = css.slice(css.indexOf('{') + 1, css.lastIndexOf('}'));
const out = new Map<string, string>();
for (const part of body.split(';')) {
const at = part.indexOf(':');
if (at > 0) out.set(part.slice(0, at).trim().toLowerCase(), part.slice(at + 1).trim());
}
return out;
}
/** A CSS weight as a number: the keywords as CSS reads them, anything else as 400. */
export function cssWeight(value: string): number {
if (value === 'bold') return 700;
const n = Number.parseInt(value, 10);
return Number.isFinite(n) && n >= 1 && n <= 1000 ? n : 400;
}
/** The file a rule's src names, the woff2 one first, resolved against where the stylesheet came from. */
function sourceOf(src: string, base: string): string | null {
const urls = [...src.matchAll(/url\((["']?)([^"')]+)\1\) ?(?:format\(["']?([\w-]+)["']?\))?/g)];
const pick = urls.find((m) => m[3] === 'woff2') ?? urls[0];
if (!pick?.[2]) return null;
try {
return new URL(pick[2], base).href;
} catch {
// A source no URL can be made of is a face the snapshot does without.
return null;
}
}
/** One @font-face rule read from its text, or null for any other rule or one that names no file. */
export function parseFaceRule(css: string, base: string): FaceRule | null {
if (!css.trimStart().toLowerCase().startsWith('@font-face')) return null;
const d = descriptors(css);
const family = unquote(d.get('font-family') ?? '');
const url = sourceOf(d.get('src') ?? '', base);
if (!family || !url) return null;
return {
family,
weight: cssWeight(d.get('font-weight') || '400'),
italic: /italic|oblique/i.test(d.get('font-style') ?? ''),
unicodeRange: d.get('unicode-range') ?? '',
url,
};
}
/** A unicode-range as code point spans: `U+0000-00FF, U+0131, U+4??` all read. */
export function parseUnicodeRange(range: string): [number, number][] {
return range
.split(',')
.map((part) => part.trim().replace(/^u\+/i, ''))
.filter(Boolean)
.flatMap((part): [number, number][] => {
if (part.includes('?')) {
return [[Number.parseInt(part.replace(/\?/g, '0'), 16), Number.parseInt(part.replace(/\?/g, 'F'), 16)]];
}
const [from, to] = part.split('-');
const a = Number.parseInt(from ?? '', 16);
const b = to === undefined ? a : Number.parseInt(to, 16);
return Number.isFinite(a) && Number.isFinite(b) ? [[a, b]] : [];
});
}
/** Whether a rule's range takes any of the characters; a rule without one takes them all. */
export function coversAny(range: string, chars: ReadonlySet<number>): boolean {
if (!range) return true;
const spans = parseUnicodeRange(range);
for (const c of chars) if (spans.some(([a, b]) => c >= a && c <= b)) return true;
return false;
}
/**
* The rules the browser would draw these uses with: per family and slant, the
* nearest weight it has (the renderer only ever asks for weights a family
* ships, so this is the exact one in practice), an upright face where there is
* no italic, and of those only the subsets whose range the text reaches.
*/
export function pickFaceRules(rules: readonly FaceRule[], uses: FaceUses): FaceRule[] {
const picked = new Map<string, FaceRule>();
for (const [key, chars] of uses) {
const [family = '', weightText = '400', italicText = 'false'] = key.split('|');
const weight = Number(weightText);
const ofFamily = rules.filter((r) => r.family.toLowerCase() === family);
const slanted = ofFamily.filter((r) => r.italic === (italicText === 'true'));
const styled = slanted.length ? slanted : ofFamily.filter((r) => !r.italic);
if (!styled.length) continue;
const nearest = styled.reduce((best, r) =>
Math.abs(r.weight - weight) < Math.abs(best.weight - weight) ? r : best
).weight;
for (const rule of styled) {
if (rule.weight === nearest && coversAny(rule.unicodeRange, chars)) picked.set(rule.url, rule);
}
}
return [...picked.values()];
}
/** The faces the text under `root` is set in, each with the characters it sets. */
export function faceUsesOf(root: Element): FaceUses {
const uses: FaceUses = new Map();
const view = root.ownerDocument.defaultView;
if (!view) return uses;
const walker = root.ownerDocument.createTreeWalker(root, 4 /* NodeFilter.SHOW_TEXT */);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const words = node.textContent ?? '';
const parent = node.parentElement;
if (!parent || !words.trim()) continue;
const style = view.getComputedStyle(parent);
const family = unquote(style.fontFamily.split(',')[0] ?? '');
if (!family) continue;
const key = faceKey(family, cssWeight(style.fontWeight), /italic|oblique/.test(style.fontStyle));
const chars = uses.get(key) ?? new Set<number>();
for (const ch of words) {
const code = ch.codePointAt(0);
if (code !== undefined && code > 32) chars.add(code);
}
uses.set(key, chars);
}
return uses;
}
/** Every @font-face rule the document's stylesheets hold, inside grouping rules too. */
export function faceRulesOf(doc: Document): FaceRule[] {
const out: FaceRule[] = [];
const walk = (list: CSSRuleList, base: string) => {
for (const rule of Array.from(list)) {
const face = parseFaceRule(rule.cssText, base);
if (face) out.push(face);
else if ('cssRules' in rule) walk((rule as CSSGroupingRule).cssRules, base);
}
};
for (const sheet of Array.from(doc.styleSheets)) {
let rules: CSSRuleList;
try {
rules = sheet.cssRules;
} catch {
// A stylesheet from another origin cannot be read, and holds no book face.
continue;
}
walk(rules, sheet.href ?? doc.baseURI);
}
return out;
}
/** A file as a data URL. */
function asDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error ?? new Error('font read failed'));
reader.readAsDataURL(blob);
});
}
type FetchFont = (url: string) => Promise<Blob | null>;
/** A face file from this server, out of the cache when the page has already drawn it. */
const fetchFont: FetchFont = async (url) => {
if (new URL(url).origin !== window.location.origin) return null;
const res = await fetch(url, {
credentials: 'same-origin',
cache: 'force-cache',
signal: AbortSignal.timeout(8_000),
});
return res.ok ? res.blob() : null;
};
/** One face as a rule the snapshot can carry: its file inline. */
async function inlineRule(rule: FaceRule, load: FetchFont): Promise<string | null> {
try {
const blob = await load(rule.url);
if (!blob) return null;
const range = rule.unicodeRange ? `unicode-range:${rule.unicodeRange};` : '';
const family = rule.family.replace(/"/g, '');
const style = rule.italic ? 'italic' : 'normal';
return `@font-face{font-family:"${family}";font-style:${style};font-weight:${rule.weight};${range}src:url(${await asDataUrl(blob)});}`;
} catch {
// A face that cannot be fetched is drawn in its fallback; the page still goes.
return null;
}
}
/**
* The @font-face rules for the faces the page under `root` draws, with their
* files inline, ready for modern-screenshot's `font.cssText`. Null when there
* is nothing to carry, so the caller lets the library look for itself.
*/
export async function snapshotFontCss(
root: Element,
load: FetchFont = fetchFont,
available: readonly FaceRule[] = faceRulesOf(root.ownerDocument)
): Promise<string | null> {
const rules = pickFaceRules(available, faceUsesOf(root));
if (!rules.length) return null;
const inlined = await Promise.all(rules.map((rule) => inlineRule(rule, load)));
const css = inlined.filter((r): r is string => r !== null);
return css.length ? css.join('\n') : null;
}
+174
View File
@@ -0,0 +1,174 @@
/* ============================================================================
TREK Studio: the typeface picker and the weight slider.
Its own file beside studio.css, which is at its size ceiling. Same rules as
there: every selector is scoped under `.trek-dash` (dashboard.css resets
`button` at a specificity a bare class loses to), and every colour is one of
the dashboard tokens, so the picker follows the user's theme and accent.
The family names are set in their own faces and at the user's text size,
which is why they scale with --fs-scale-body rather than sitting at a fixed
pixel size: a picker is read, and a name too small to read is a name that
tells you nothing about its face.
============================================================================ */
.trek-dash .st-fontpick {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.trek-dash .st-fontpick-current,
.trek-dash .st-fontpick-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
text-align: start;
color: var(--ink);
transition:
background 0.14s,
border-color 0.14s,
color 0.14s;
}
.trek-dash .st-fontpick-current {
height: 40px;
padding-inline: 10px 8px;
border-radius: 8px;
border: 1px solid var(--line-2);
background: var(--surface);
}
.trek-dash .st-fontpick-current:hover {
border-color: color-mix(in oklch, var(--accent) 40%, var(--line-2));
}
.trek-dash .st-fontpick-current:focus-visible,
.trek-dash .st-fontpick-row:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.trek-dash .st-fontpick.is-open .st-fontpick-current {
border-color: var(--accent);
}
.trek-dash .st-fontpick-face {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.25;
font-size: calc(16px * var(--fs-scale-body, 1));
}
.trek-dash .st-fontpick-span {
flex: none;
font-size: calc(11px * var(--fs-scale-caption, 1));
color: var(--ink-3);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.trek-dash .st-fontpick-caret {
flex: none;
color: var(--ink-3);
transition: transform 0.18s;
}
.trek-dash .st-fontpick.is-open .st-fontpick-caret {
transform: rotate(180deg);
}
/* The open list. It sits in the inspector's flow and scrolls on its own, so
the inspector around it keeps its place. */
.trek-dash .st-fontpick-panel {
display: flex;
flex-direction: column;
border-radius: 12px;
border: 1px solid var(--line);
background: color-mix(in oklch, var(--surface) 70%, transparent);
overflow: hidden;
}
.trek-dash .st-fontpick-search {
margin: 8px 8px 4px;
}
.trek-dash .st-fontpick-list {
max-height: 320px;
overflow-y: auto;
overscroll-behavior: contain;
padding: 2px 4px 6px;
}
.trek-dash .st-fontpick-group + .st-fontpick-group {
margin-top: 4px;
}
.trek-dash .st-fontpick-label {
position: sticky;
top: 0;
z-index: 1;
padding: 8px 8px 4px;
font-size: calc(10.5px * var(--fs-scale-caption, 1));
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--ink-3);
background: color-mix(in oklch, var(--surface) 92%, transparent);
}
.trek-dash .st-fontpick-row {
min-height: 38px;
padding-inline: 8px;
border-radius: 8px;
color: var(--ink-2);
}
.trek-dash .st-fontpick-row:hover {
background: var(--surface);
color: var(--ink);
}
.trek-dash .st-fontpick-row.is-on {
color: var(--ink);
background: color-mix(in oklch, var(--accent) 10%, transparent);
}
.trek-dash .st-fontpick-tick {
flex: none;
width: 14px;
display: grid;
place-items: center;
color: var(--accent);
}
.trek-dash .st-fontpick-none {
padding: 10px 8px;
margin: 0;
}
/* ----------------- weight ----------------- */
.trek-dash .st-weight {
width: 100%;
}
.trek-dash .st-weight > .st-weight-value,
.trek-dash .st-weight > .st-weight-only {
font-size: calc(11.5px * var(--fs-scale-caption, 1));
color: var(--ink-2);
white-space: nowrap;
}
.trek-dash .st-weight > .st-weight-value {
flex: none;
min-width: 84px;
text-align: end;
font-variant-numeric: tabular-nums;
}
.trek-dash .st-weight > .st-weight-only {
flex: 1;
color: var(--ink-3);
}
@media (prefers-reduced-motion: reduce) {
.trek-dash .st-fontpick-caret,
.trek-dash .st-fontpick-current,
.trek-dash .st-fontpick-row {
transition: none;
}
}
-130
View File
@@ -1,130 +0,0 @@
/*
* The photo look in the Studio inspector: the zoom and mirror row beside the
* crop, the print resolution, the border, shadow and cutout parts, the
* adjustment sliders and the duotone pairs. Everything reads the inspector's
* own tokens from studio.css, so it follows the user's scheme and accent.
*/
/* ----------------- a slider with its value ----------------- */
.trek-dash .st-slider {
display: grid;
grid-template-columns: 64px minmax(0, 1fr) 40px;
align-items: center;
gap: 8px;
min-height: 28px;
}
.trek-dash .st-slider + .st-slider {
margin-top: 4px;
}
.trek-dash .st-slider-label {
font-size: 11px;
color: var(--ink-3);
line-height: 1.25;
}
.trek-dash .st-slider input[type='range'] {
width: 100%;
min-width: 0;
accent-color: var(--accent);
cursor: pointer;
}
.trek-dash .st-slider-value {
font-size: 11px;
color: var(--ink-2);
text-align: end;
font-variant-numeric: tabular-nums;
}
/* ----------------- beside the crop ----------------- */
.trek-dash .st-photo-crop {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
margin-top: 10px;
}
.trek-dash .st-photo-crop > .st-slider {
width: 100%;
}
/* The print resolution: quiet when it is fine, a warning tint when it is not. */
.trek-dash .st-photo-dpi {
display: flex;
align-items: flex-start;
gap: 8px;
width: 100%;
padding: 8px 10px;
border-radius: 10px;
background: color-mix(in oklch, var(--ink) 4%, transparent);
color: var(--ink-2);
}
.trek-dash .st-photo-dpi svg {
flex: none;
margin-top: 1px;
}
.trek-dash .st-photo-dpi.is-low {
background: var(--warning-soft);
color: var(--warning);
}
.trek-dash .st-photo-dpi-value {
font-size: 12px;
font-weight: 500;
line-height: 1.4;
}
.trek-dash .st-photo-dpi-hint {
margin-top: 2px;
font-size: 11.5px;
line-height: 1.5;
color: var(--ink-2);
}
/* ----------------- one part of the look: a switch and what it opens ----------------- */
.trek-dash .st-photo-part + .st-photo-part {
border-top: 1px solid var(--line);
margin-top: 4px;
padding-top: 4px;
}
.trek-dash .st-photo-part-body {
display: flex;
flex-direction: column;
gap: 10px;
padding: 6px 0 8px;
}
.trek-dash .st-photo-part-body .st-line + .st-line {
margin-top: 0;
}
.trek-dash .st-photo-reset {
margin-top: 10px;
}
/* ----------------- duotone pairs ----------------- */
.trek-dash .st-photo-duos {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.trek-dash .st-photo-duo {
width: 30px;
height: 30px;
border-radius: 8px;
border: 1px solid var(--line-2);
box-shadow: var(--sh-xs);
cursor: pointer;
transition:
transform 0.14s,
box-shadow 0.14s;
}
.trek-dash .st-photo-duo:hover {
transform: scale(1.06);
}
.trek-dash .st-photo-duo.is-on {
box-shadow: 0 0 0 2px var(--accent);
}
.trek-dash .st-photo-duo:focus {
outline: none;
}
.trek-dash .st-photo-duo:focus-visible {
box-shadow:
0 0 0 2px var(--accent-soft),
0 0 0 3px var(--accent);
}
+1 -31
View File
@@ -1583,37 +1583,7 @@
flex: none;
}
/* ----------------- font picker ----------------- *
* Each family set in itself. A row of chips reading "Poppins / Lora / Bebas" in
* the interface font tells you their names and nothing about how they look,
* which is the only thing anyone is choosing between.
*/
.trek-dash .st-fonts {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px;
}
.trek-dash .st-font {
height: 32px;
padding: 0 8px;
border-radius: 8px;
border: 1px solid var(--line);
background: color-mix(in oklch, var(--surface) 55%, transparent);
color: var(--ink-2);
font-size: 14px;
line-height: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
transition: background .14s, border-color .14s, color .14s;
}
.trek-dash .st-font:hover { background: var(--surface); color: var(--ink); }
.trek-dash .st-font.is-on {
color: var(--ink);
border-color: color-mix(in oklch, var(--accent) 50%, transparent);
background: color-mix(in oklch, var(--accent) 10%, transparent);
}
/* The typeface picker lives in studio-fonts.css. */
.trek-dash .st-chip:disabled { opacity: .32; cursor: default; }
/* ----------------- save state ----------------- *
+10
View File
@@ -3,3 +3,13 @@
/** The version this bundle was built as (client/package.json), set by `define` in vite.config.js. */
declare const __TREK_UI_VERSION__: string;
/**
* Big Shoulders Display's weights, imported for their stylesheets alone
* (components/Studio/bookFontFaces.ts). Its package maps `./*` to `./*.css`
* and has no `./*.css` entry, so the import names `100` rather than
* `100.css`, and vite/client's `*.css` declaration does not cover a bare
* name. Declared for this one package only, so a mistyped import of any
* other module still fails the check.
*/
declare module '@fontsource/big-shoulders-display/*';
@@ -0,0 +1,93 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import { APP_FACES, bookFontFile, keepInPrecache, withoutRuntimeFonts } from '../../../bookFontPrecache.js';
/**
* Which font files the service worker installs (FE-SW-FONTS): the app's own
* faces as before, the latin woff2 of the seven book families that shipped
* first, and nothing else of the book's catalogue, which waits in the
* book-fonts runtime cache for a book that uses it.
*/
// Vitest runs with the client package as its root, so cwd is stable here.
const read = (rel: string) => readFileSync(resolve(process.cwd(), rel), 'utf8');
const asset = (name: string) => `assets/${name}`;
describe('reading a font file name', () => {
it('FE-SW-FONTS-001: reads package, subset, weight and style as the build names them, and nothing else as a book face', () => {
expect(bookFontFile(asset('dm-serif-display-latin-ext-400-italic-Ab12_cD3.woff2'))).toEqual({
pkg: 'dm-serif-display',
subset: 'latin-ext',
weight: 400,
italic: true,
ext: 'woff2',
});
expect(bookFontFile(asset('dm-mono-latin-500-normal-xyz.woff'))).toMatchObject({ pkg: 'dm-mono', subset: 'latin' });
expect(bookFontFile(asset('geist-sans-latin-400-normal-abc.woff2'))).toBeNull();
expect(bookFontFile(asset('index-abc.js'))).toBeNull();
expect(bookFontFile(asset('lora.woff2'))).toBeNull();
});
});
describe('the precache', () => {
it('FE-SW-FONTS-002: keeps the faces the app draws, whatever their subset and format', () => {
expect(keepInPrecache(asset('poppins-latin-500-normal-abc.woff'))).toBe(true);
expect(keepInPrecache(asset('poppins-devanagari-700-normal-abc.woff2'))).toBe(true);
expect(keepInPrecache(asset('museomoderno-latin-800-normal-abc.woff2'))).toBe(true);
expect(keepInPrecache(asset('geist-sans-latin-600-normal-abc.woff2'))).toBe(true);
expect(keepInPrecache(asset('index-abc.js'))).toBe(true);
});
it('FE-SW-FONTS-003: keeps only the latin woff2 of the seven book families that shipped first', () => {
expect(keepInPrecache(asset('lora-latin-400-normal-abc.woff2'))).toBe(true);
expect(keepInPrecache(asset('bebas-neue-latin-400-normal-abc.woff2'))).toBe(true);
expect(keepInPrecache(asset('museomoderno-latin-500-normal-abc.woff2'))).toBe(true);
expect(keepInPrecache(asset('lora-latin-400-normal-abc.woff'))).toBe(false);
expect(keepInPrecache(asset('lora-cyrillic-400-normal-abc.woff2'))).toBe(false);
expect(keepInPrecache(asset('lora-latin-400-italic-abc.woff2'))).toBe(false);
expect(keepInPrecache(asset('eb-garamond-latin-800-normal-abc.woff2'))).toBe(false);
expect(keepInPrecache(asset('poppins-latin-900-normal-abc.woff2'))).toBe(false);
});
it('FE-SW-FONTS-004: leaves every file of the new families to the runtime cache', () => {
for (const name of [
'anton-latin-400-normal-abc.woff2',
'caveat-latin-700-normal-abc.woff2',
'fraunces-latin-ext-900-italic-abc.woff',
]) {
expect(keepInPrecache(asset(name)), name).toBe(false);
}
const manifest = [
{ url: asset('index-abc.js'), revision: null },
{ url: asset('anton-latin-400-normal-abc.woff2'), revision: null },
{ url: asset('inter-latin-400-normal-abc.woff2'), revision: null },
];
expect(withoutRuntimeFonts(manifest)).toEqual({ manifest: [manifest[0], manifest[2]], warnings: [] });
});
it('FE-SW-FONTS-005: knows the app faces main.tsx imports, so the chrome never loses one offline', () => {
const imported = [...read('src/main.tsx').matchAll(/@fontsource\/([a-z0-9-]+)\/(\d+)\.css/g)].map(
(m) => `${m[1]}/${m[2]}`
);
const declared = Object.entries(APP_FACES).flatMap(([pkg, weights]) => weights.map((w) => `${pkg}/${w}`));
expect(imported.filter((face) => !face.startsWith('geist-sans/')).sort()).toEqual(declared.sort());
});
it('FE-SW-FONTS-006: is wired into the worker, with the runtime rule the Studio fills', () => {
const config = read('vite.config.js');
expect(config).toContain('manifestTransforms: [withoutRuntimeFonts]');
const rule =
/urlPattern:\s*(\/[^\n]*woff2[^\n]*\/i),\s*\n\s*handler:\s*'CacheFirst',\s*\n\s*options:\s*\{\s*\n\s*cacheName:\s*'book-fonts'/.exec(
config
);
expect(rule).not.toBeNull();
const literal = rule?.[1] ?? '//';
const pattern = new RegExp(literal.slice(1, literal.lastIndexOf('/')), 'i');
expect(pattern.test('https://trek.example/assets/anton-latin-400-normal-abc.woff2')).toBe(true);
expect(pattern.test('https://trek.example/assets/lora-latin-400-normal-abc.woff')).toBe(true);
expect(pattern.test('https://trek.example/assets/index-abc.js')).toBe(false);
expect(pattern.test('https://trek.example/api/photos/1/file.woff2')).toBe(false);
});
});
+19 -9
View File
@@ -23,30 +23,40 @@ const STUDIO = 'src/components/Studio/bookFontFaces.ts'
// Vitest runs with the client package as its root, so cwd is stable here.
const read = (rel: string) => readFileSync(resolve(process.cwd(), rel), 'utf8')
/** Every `@fontsource/<package>/<weight>.css` a file imports. */
/** Every `@fontsource/<package>/<weight>[-italic][.css]` a file imports (see bookFontFaces.ts for the bare form). */
function facesIn(rel: string): string[] {
return [...read(rel).matchAll(/@fontsource\/([a-z0-9-]+)\/(\d+)\.css/g)]
return [...read(rel).matchAll(/@fontsource\/([a-z0-9-]+)\/(\d+(?:-italic)?)(?:\.css)?'/g)]
.map(m => `${m[1]}/${m[2]}`)
}
/** @fontsource names its packages after the family, lowercased and hyphenated. */
function faceOf(id: BookFontId, weight: number): string {
return `${BOOK_FONTS[id].name.toLowerCase().replace(/ /g, '-')}/${weight}`
/** A face as the import names it: the family's package, the weight and the italic. */
function faceOf(id: BookFontId, weight: number, italic = false): string {
return `${BOOK_FONTS[id].pkg}/${weight}${italic ? '-italic' : ''}`
}
/** Every face the catalogue declares: each weight upright and each real italic. */
const declared = BOOK_FONTS_IDS.flatMap(id => [
...BOOK_FONTS[id].weights.map(w => faceOf(id, w)),
...BOOK_FONTS[id].italics.map(w => faceOf(id, w, true)),
])
describe('the bundled faces', () => {
it('loads every family the picker offers, in the weights it offers', () => {
const loaded = [...facesIn(APP), ...facesIn(STUDIO)]
for (const face of declared) {
expect(loaded, face).toContain(face)
}
})
/* @fontsource names its packages after the family, lowercased and hyphenated. */
it('takes every family from the package named after it', () => {
for (const id of BOOK_FONTS_IDS) {
for (const weight of BOOK_FONTS[id].weights) {
expect(loaded, `${BOOK_FONTS[id].name} ${weight}`).toContain(faceOf(id, weight))
}
expect(BOOK_FONTS[id].pkg, id).toBe(BOOK_FONTS[id].name.toLowerCase().replace(/ /g, '-'))
}
})
/* A weight nothing can ask for is a download nobody wanted. */
it('loads no weight the registry does not declare', () => {
const declared = BOOK_FONTS_IDS.flatMap(id => BOOK_FONTS[id].weights.map(w => faceOf(id, w)))
for (const face of facesIn(STUDIO)) {
expect(declared, face).toContain(face)
}
+15 -5
View File
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest'
import { BOOK_FONTS_IDS, bookTextElementSchema } from '@trek/shared'
import {
BOOK_FONTS, BOOK_FONT_ORDER, fontStack, hasWeight, nearestWeight,
BOOK_FONTS, BOOK_FONT_ORDER, fontStack, fontsIn, hasItalic, hasWeight, nearestWeight,
} from '../../../src/components/Studio/bookFonts'
/**
@@ -21,9 +21,13 @@ describe('the registry', () => {
}
})
it('offers every family in the picker, in one order', () => {
it('offers every family in the picker, in one order, grouped by category', () => {
expect([...BOOK_FONT_ORDER].sort()).toEqual([...BOOK_FONTS_IDS].sort())
expect(new Set(BOOK_FONT_ORDER).size).toBe(BOOK_FONT_ORDER.length)
expect(BOOK_FONT_ORDER[0]).toBe('sans')
expect(BOOK_FONT_ORDER[BOOK_FONT_ORDER.length - 1]).toBe('mono')
expect(fontsIn('script')).toContain('caveat')
expect(fontsIn('script').every(id => BOOK_FONTS[id].category === 'script')).toBe(true)
})
/*
@@ -34,17 +38,17 @@ describe('the registry', () => {
it('ends every stack in a generic family', () => {
for (const id of BOOK_FONTS_IDS) {
const last = BOOK_FONTS[id].stack.split(',').pop()!.trim()
expect(['sans-serif', 'serif', 'monospace'], `${id}: ${last}`).toContain(last)
expect(['sans-serif', 'serif', 'cursive', 'monospace'], `${id}: ${last}`).toContain(last)
}
})
it('names a bundled family first in every stack', () => {
// The first entry is the one that will actually be used, so it is the one
// that has to be self-hosted.
const bundled = ['Poppins', 'Inter', 'Lora', 'EB Garamond', 'Playfair Display', 'MuseoModerno', 'Bebas Neue']
// bookFontFaces.test.ts holds every family to the package it is loaded from.
for (const id of BOOK_FONTS_IDS) {
const first = BOOK_FONTS[id].stack.split(',')[0].replace(/"/g, '').trim()
expect(bundled, `${id}: ${first}`).toContain(first)
expect(first, id).toBe(BOOK_FONTS[id].name)
}
})
@@ -98,6 +102,12 @@ describe('weights', () => {
it('moves to the nearest weight a family really has', () => {
expect(nearestWeight('bebas', 700)).toBe(400)
expect(nearestWeight('sans', 700)).toBe(700)
expect(nearestWeight('oswald', 900)).toBe(700)
})
it('knows which families have a real italic', () => {
expect(hasItalic('garamond', 400)).toBe(true)
expect(hasItalic('sans', 400)).toBe(false)
})
it('leaves an unknown family alone rather than guessing', () => {
@@ -101,6 +101,20 @@ const tick = (name: string) => fireEvent.click(screen.getByRole('checkbox', { na
/** A switch, or any plain button: both answer to their accessible name. */
const press = (name: string) => fireEvent.click(screen.getByRole('button', { name }))
/** Opens the typeface list from its button and picks a family by the name its row is set in. */
function pickFamily(name: string) {
fireEvent.click(screen.getByRole('button', { name: /^journey\.studio\.fonts\.change: / }))
const row = screen.getAllByRole('option').find(o => o.querySelector('.st-fontpick-face')?.textContent === name)
if (!row) throw new Error(`no typeface row named ${name}`)
fireEvent.click(row)
}
/** Drags the weight slider to the stop of a weight among those the family ships. */
function slideWeight(weights: readonly number[], weight: number) {
const slider = screen.getByRole('slider', { name: 'journey.studio.weight' })
fireEvent.change(slider, { target: { value: String(weights.indexOf(weight)) } })
}
/** A section by its heading, since a label can repeat across sections. */
function section(container: HTMLElement, label: string): HTMLElement {
const found = [...container.querySelectorAll<HTMLElement>('.st-section')]
@@ -512,7 +526,7 @@ describe('the tail all five share', () => {
// Bebas ships one weight; a bold it does not have prints as a smeared
// regular, so the request moves rather than being passed through.
press('Bebas Neue')
pickFamily('Bebas Neue')
expect(saved().font).toBe('bebas')
expect(saved().weight).toBe(400)
@@ -522,7 +536,7 @@ describe('the tail all five share', () => {
const { container } = show(statsEl({ font: 'sans', weight: 500 }))
open(container, 'journey.studio.typography')
press('Lora')
pickFamily('Lora')
expect(saved().font).toBe('serif')
expect(saved().weight).toBe(500)
@@ -532,7 +546,8 @@ describe('the tail all five share', () => {
const { container } = show(countriesEl())
open(container, 'journey.studio.typography')
pick('500')
// Poppins ships 100 to 900, so 500 is the fifth stop.
slideWeight([100, 200, 300, 400, 500, 600, 700, 800, 900], 500)
expect(saved().weight).toBe(500)
})
@@ -1,82 +0,0 @@
import type { BookDocument, BookElement, BookPhotoElement } from '@trek/shared';
import { PHOTO_LOOK_STARTS, bookPageSetupSchema, bookPhotoElementSchema } from '@trek/shared';
import { beforeEach, describe, expect, it } from 'vitest';
import { StudioInspector } from '../../../src/components/Studio/StudioInspector';
import type { JourneySource } from '../../../src/components/Studio/StudioSidebar';
import { useStudioStore } from '../../../src/store/studioStore';
import { fireEvent, render, screen } from '../../helpers/render';
/**
* The photo look through the inspector itself, written into the book the
* store holds: the rows sit where the panel puts them and every one of them
* is one undo step.
*/
const source: JourneySource = { entries: [], photos: [], photoEntries: {} };
const page = bookPageSetupSchema.parse({});
function load(over: Record<string, unknown> = {}) {
const el = bookPhotoElementSchema.parse({
id: 'p1',
kind: 'photo',
frame: { x: 20, y: 30, w: 60, h: 40 },
photoId: 12,
...over,
}) as BookElement;
useStudioStore.getState().load({
version: 1,
title: 'T',
page,
spreads: [{ id: 'sp1', role: 'inner', background: null, elements: [el], parked: [], entryId: null }],
} as BookDocument);
useStudioStore.getState().select(['p1']);
}
const photo = () => useStudioStore.getState().doc?.spreads[0]?.elements[0] as BookPhotoElement;
function draw() {
return render(
<StudioInspector
spreadIndex={0}
page={page}
stats={null}
path={[]}
source={source}
setPageNumbers={() => {}}
t={(k: string) => k}
locale="en"
/>
);
}
beforeEach(() => load());
describe('the photo look in the inspector', () => {
it('FE-STUDIO-LOOKINSP-001: zooms and mirrors from the crop section', () => {
draw();
const zoom = screen.getByRole('slider', { name: /journey.studio.photo.zoom/ });
fireEvent.change(zoom, { target: { value: '2' } });
fireEvent.pointerUp(zoom);
fireEvent.click(screen.getByRole('button', { name: /journey.studio.photo.flip/ }));
expect(photo()).toMatchObject({ zoom: 2, flipX: true });
});
it('FE-STUDIO-LOOKINSP-002: puts a border on and takes it off again, leaving nothing behind', () => {
draw();
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.photo.edges' }));
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.photo.border', pressed: false }));
expect(photo().border).toEqual(PHOTO_LOOK_STARTS.border);
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.photo.border', pressed: true }));
expect(JSON.parse(JSON.stringify(photo()))).not.toHaveProperty('border');
});
it('FE-STUDIO-LOOKINSP-003: each look change is one undo step', () => {
draw();
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.photo.duotone', expanded: false }));
fireEvent.click(screen.getByRole('button', { name: 'journey.studio.photo.duotone', pressed: false }));
expect(photo().duotone).toEqual(PHOTO_LOOK_STARTS.duotone);
useStudioStore.getState().undo();
expect(photo().duotone).toBeUndefined();
});
});
-168
View File
@@ -1,168 +0,0 @@
import type { BookElement, BookPageSetup, BookSpread } from '@trek/shared';
import { bookPageSetupSchema } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { SpreadView } from '../../../src/components/Studio/SpreadView';
import { render } from '../../helpers/render';
/**
* The photo look as the page draws it, through SpreadView, the one renderer
* the editor, the thumbnails and the print path share: border, hard shadow,
* cutout, zoom, mirror, adjustments and duotone, and ids that stay unique
* when a spread is drawn twice.
*/
const page: BookPageSetup = bookPageSetupSchema.parse({
preset: 'square-210',
pageWidth: 210,
pageHeight: 210,
bleed: 3,
safe: 5,
});
const frame = { x: 10, y: 20, w: 80, h: 60 };
const photo = (over: Record<string, unknown> = {}): BookElement =>
({
frame,
rotation: 0,
opacity: 1,
locked: false,
id: 'p1',
kind: 'photo',
photoId: 42,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius: 0,
filter: 'none',
mask: null,
frameStyle: 'none',
...over,
}) as BookElement;
const spread = (elements: BookElement[]): BookSpread => ({
id: 's1',
role: 'inner',
background: null,
elements,
parked: [],
entryId: null,
});
const draw = (elements: BookElement[], print = false) =>
render(<SpreadView spread={spread(elements)} page={page} print={print} />);
/** The photo's own box: the sheet's first child. */
const outer = (container: HTMLElement) => container.firstElementChild?.firstElementChild as HTMLElement;
const styleOf = (el: Element | null | undefined) => el?.getAttribute('style') ?? '';
describe('the photo look on the page', () => {
it('FE-STUDIO-PHOTO-001: draws a plain photo exactly as before, with no look layers', () => {
const { container } = draw([photo()]);
expect(outer(container).querySelector('svg')).toBeNull();
const img = container.querySelector('img') as HTMLImageElement;
expect(img.style.width).toBe('100%');
expect(img.style.transform).toBe('');
expect(styleOf(outer(container))).not.toContain('box-shadow');
});
it('FE-STUDIO-PHOTO-002: lays a border band over the edge of a rectangular picture, rounded with it', () => {
const { container } = draw([photo({ border: { width: 4, color: '#ffffff' }, radius: 3 })]);
const band = container.querySelector('img')?.parentElement?.querySelector('div[aria-hidden]') as HTMLElement;
expect(band.style.borderWidth).toBe('4mm');
expect(band.style.borderStyle).toBe('solid');
expect(band.style.borderRadius).toBe('3mm');
});
it('FE-STUDIO-PHOTO-003: strokes the border along a mask, twice as wide so its inner half shows', () => {
const { container } = draw([photo({ mask: 'heart', border: { width: 2.5, color: '#102030' } })]);
const stroked = [...container.querySelectorAll('path')].find((p) => p.getAttribute('fill') === 'none');
expect(stroked?.getAttribute('stroke')).toBe('#102030');
expect(stroked?.getAttribute('stroke-width')).toBe('5');
});
it('FE-STUDIO-PHOTO-004: gives a rectangular frame its cutout and hard shadow as flat box shadows', () => {
const { container } = draw([
photo({ shadow: { dx: 2, dy: 3, color: '#000000', alpha: 0.5 }, cutout: { width: 3, color: '#ffffff' } }),
]);
const css = styleOf(outer(container));
expect(css).toContain('0 0 0 3mm');
expect(css).toContain('2mm 3mm 0 3mm');
expect(outer(container).querySelector('svg')).toBeNull();
});
it('FE-STUDIO-PHOTO-005: draws the cutout and shadow of a shaped frame as its path, behind the picture', () => {
const { container } = draw([
photo({
mask: 'star-5',
shadow: { dx: 1, dy: 1, color: '#222222', alpha: 0.4 },
cutout: { width: 2, color: '#fafafa' },
}),
]);
const box = outer(container);
const silhouette = box.firstElementChild as SVGElement;
expect(silhouette.tagName.toLowerCase()).toBe('svg');
const paths = silhouette.querySelectorAll('path');
expect(paths).toHaveLength(2);
expect(paths[0]?.getAttribute('transform')).toBe('translate(1 1)');
expect(paths[0]?.parentElement?.getAttribute('opacity')).toBe('0.4');
expect(paths[1]?.getAttribute('stroke-width')).toBe('4');
expect(styleOf(box)).not.toContain('box-shadow');
});
it('FE-STUDIO-PHOTO-006: a Polaroid keeps a rectangular shadow even with a mask, beside its soft one', () => {
const { container } = draw([
photo({ frameStyle: 'polaroid', mask: 'heart', shadow: { dx: 2, dy: 2, color: '#000000', alpha: 1 } }),
]);
const css = styleOf(outer(container));
expect(css).toContain('2mm 2mm 0 0mm');
expect(css).toContain('1.2mm 3mm');
});
it('FE-STUDIO-PHOTO-007: zooms the picture about its focal point and mirrors it inside its frame', () => {
const { container } = draw([photo({ zoom: 2, focalX: 0.5, focalY: 0.5, flipX: true })]);
const img = container.querySelector('img') as HTMLImageElement;
expect(img.style.width).toBe('200%');
expect(img.style.transform).toBe('translate(-25%, -25%)');
expect(img.parentElement?.style.transform).toBe('scaleX(-1)');
});
it('FE-STUDIO-PHOTO-008: filters the picture with the adjustments and an SVG filter for warmth and duotone', () => {
const { container } = draw([
photo({
filter: 'bw',
adjust: { brightness: 0.2, warmth: 0.5 },
duotone: { dark: '#000000', light: '#ffffff' },
}),
]);
const filter = container.querySelector('filter') as SVGFilterElement;
expect(filter.querySelectorAll('feColorMatrix')).toHaveLength(2);
const css = styleOf(container.querySelector('img'));
expect(css).toContain('grayscale(1)');
expect(css).toContain('brightness(1.1)');
expect(css).toContain(`url(#${filter.id})`);
});
it('FE-STUDIO-PHOTO-009: gives every drawing of a spread its own clip and filter ids', () => {
const el = photo({ mask: 'heart', duotone: { dark: '#000000', light: '#ffffff' } });
const { container } = render(
<>
<SpreadView spread={spread([el])} page={page} />
<SpreadView spread={spread([el])} page={page} />
</>
);
const ids = [...container.querySelectorAll('clipPath, filter')].map((n) => n.id);
expect(ids).toHaveLength(4);
expect(new Set(ids).size).toBe(4);
});
it('FE-STUDIO-PHOTO-010: an empty frame wears no look, and prints nothing', () => {
const look = { border: { width: 4, color: '#ffffff' }, cutout: { width: 2, color: '#ffffff' }, mask: 'heart' };
const { container } = draw([photo({ photoId: null, ...look })]);
expect(container.querySelector('img')).toBeNull();
expect([...container.querySelectorAll('path')].some((p) => p.getAttribute('fill') === 'none')).toBe(false);
const { container: printed } = draw([photo({ photoId: null, ...look })], true);
expect(printed.querySelector('[style*="left: 10mm"]')).toBeNull();
});
});
@@ -1,7 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { act, renderHook } from '@testing-library/react'
import type { BookDocument, BookRecord } from '@trek/shared'
import { BOOK_SCHEMA_REV } from '@trek/shared'
import { BOOK_SCHEMA_REV, type BookDocument, type BookRecord } from '@trek/shared'
/**
* Autosaving a Studio book (#1973).
@@ -852,8 +851,8 @@ describe('a book a newer Studio saved', () => {
expect(result.current.state.status).toBe('newer')
})
/* A book goes out stamped with the revision this build writes, so an older Studio opens it read-only. */
it('FE-STUDIO-REV-006: sends a book stamped with the revision this build writes', async () => {
/* A book goes out stamped with the revision of the Studio that wrote it, so an older one cannot save over it. */
it('FE-STUDIO-REV-006: sends a book stamped with the revision of this build', async () => {
const { result } = await mount()
act(() => { result.current.queueSave(doc('a'), 'T') })
+21
View File
@@ -3,6 +3,7 @@ import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';
import { visualizer } from 'rollup-plugin-visualizer';
import { rtlTextAlias, plyrSpriteAlias } from './rtlTextAlias.js';
import { withoutRuntimeFonts } from './bookFontPrecache.js';
import { readFileSync } from 'node:fs';
// The version this bundle is built as, baked in at build time. The Docker build
@@ -77,6 +78,11 @@ export default defineConfig(({ mode }) => ({
// matching the globs below is fetched at service-worker install by every
// user, whether or not they ever reach the code.
globPatterns: ['**/*.{js,css,html,svg,png,woff,woff2,ttf}'],
// Except most of the book's typefaces: of thirty four families only the
// latin files of the seven the book first shipped with are installed,
// the rest wait in the book-fonts rule below for a book that uses them
// (bookFontPrecache.js).
manifestTransforms: [withoutRuntimeFonts],
// build:analyze drops a treemap next to the app; it must never end up in a
// precache manifest if someone ships that build by accident.
// The assistant's chat (@assistant-ui, about 110 kB gzip) only runs online, so
@@ -92,6 +98,21 @@ export default defineConfig(({ mode }) => ({
/^\/plugin-frame\//,
],
runtimeCaching: [
{
// The book's typefaces the precache leaves out (bookFontPrecache.js):
// fetched the first time a page is set in them, and up front for
// every face of a book the Studio opens (bookFontWarm.ts), so the
// book still reads in its own type offline. Same-origin build
// assets only; the files are content-hashed, so a cached one never
// goes stale. First in the list so no broader rule takes them.
urlPattern: /\/assets\/[^/?#]+\.woff2?(?:\?.*)?$/i,
handler: 'CacheFirst',
options: {
cacheName: 'book-fonts',
expiration: { maxEntries: 600, maxAgeSeconds: 365 * 24 * 60 * 60 },
cacheableResponse: { statuses: [200] },
},
},
{
// Carto map tiles (default provider)
// maxEntries MUST stay >= MAX_TILES in src/sync/tilePrefetcher.ts
+270
View File
@@ -33,14 +33,41 @@
"hasInstallScript": true,
"dependencies": {
"@assistant-ui/react": "0.15.25",
"@fontsource/abril-fatface": "^5.3.0",
"@fontsource/alfa-slab-one": "^5.3.0",
"@fontsource/allura": "^5.3.0",
"@fontsource/anton": "^5.3.0",
"@fontsource/archivo-black": "^5.3.0",
"@fontsource/bebas-neue": "^5.3.0",
"@fontsource/big-shoulders-display": "^5.3.0",
"@fontsource/bodoni-moda": "^5.3.0",
"@fontsource/bricolage-grotesque": "^5.3.0",
"@fontsource/caveat": "^5.3.0",
"@fontsource/cormorant-garamond": "^5.3.0",
"@fontsource/dancing-script": "^5.3.0",
"@fontsource/dm-mono": "^5.3.0",
"@fontsource/dm-serif-display": "^5.3.0",
"@fontsource/eb-garamond": "^5.3.0",
"@fontsource/fraunces": "^5.3.0",
"@fontsource/geist-sans": "^5.3.0",
"@fontsource/great-vibes": "^5.3.0",
"@fontsource/homemade-apple": "^5.3.0",
"@fontsource/inter": "^5.3.0",
"@fontsource/kaushan-script": "^5.3.0",
"@fontsource/league-gothic": "^5.3.0",
"@fontsource/lora": "^5.3.0",
"@fontsource/montserrat": "^5.3.0",
"@fontsource/museomoderno": "^5.3.0",
"@fontsource/oswald": "^5.3.0",
"@fontsource/pacifico": "^5.3.0",
"@fontsource/parisienne": "^5.3.0",
"@fontsource/permanent-marker": "^5.3.0",
"@fontsource/playfair-display": "^5.3.0",
"@fontsource/poppins": "^5.3.0",
"@fontsource/sacramento": "^5.3.0",
"@fontsource/satisfy": "^5.3.0",
"@fontsource/shrikhand": "^5.3.0",
"@fontsource/yellowtail": "^5.3.0",
"@mapbox/mapbox-gl-rtl-text": "^0.4.0",
"@maplibre/maplibre-gl-leaflet": "0.1.4",
"@simplewebauthn/browser": "^13.3.0",
@@ -3366,6 +3393,51 @@
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT"
},
"node_modules/@fontsource/abril-fatface": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/abril-fatface/-/abril-fatface-5.3.0.tgz",
"integrity": "sha512-voKCJEhGGZNdlk+OS3uFRSDV/HWbs6TAy7qKe9wXq96Uzwadfksyk5eyXLa1T5FkEazBgpHRMRSFBKfA3rbKbg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/alfa-slab-one": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/alfa-slab-one/-/alfa-slab-one-5.3.0.tgz",
"integrity": "sha512-J/ANFZ7S9CbZClr/QhMjmBtpFmmKipVkEXHj20aurR5uqJPCBChVPJn1/kx6vtrwnfnDY0kQaYSTYasSt4GruA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/allura": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/allura/-/allura-5.3.0.tgz",
"integrity": "sha512-UqloNNVxl03+psvtIXCYuo7/v2mD9XmX2HGiczhK6NmDDwbFJJDjrbHZ5tpNuVXKsMEQPc46tZu3hewYN82/YA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/anton": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/anton/-/anton-5.3.0.tgz",
"integrity": "sha512-OR1D5n124n2XRs66SbX7IjcOSptljEJE8FcDNnlni2y1/M6KpTx7cU2PIWzTSsBj4WFkHPw24On8qw3qkQZuTw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/archivo-black": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/archivo-black/-/archivo-black-5.3.0.tgz",
"integrity": "sha512-7EsTIQ+Blf96ktpzTUpI7msi+u9/AJxM/GZxOSkEubVd1jSTrFSTNxMgfpX/8rQsjZ+19a+qvRbtcTEp5J/72g==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/bebas-neue": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/bebas-neue/-/bebas-neue-5.3.0.tgz",
@@ -3375,6 +3447,78 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/big-shoulders-display": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/big-shoulders-display/-/big-shoulders-display-5.3.0.tgz",
"integrity": "sha512-d4+Tcsq/WfoKeqWfB6wFdeNDsSYYnilJbSNNpf5w53MlWrGHAc9YvkAjC6id6PgFDiGaFKsXfP0RFAQtpt0bog==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/bodoni-moda": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/bodoni-moda/-/bodoni-moda-5.3.0.tgz",
"integrity": "sha512-Wt2XBCHivYxkTP3ydq/Ax+7VshwsOh/6ayA5om2t+yClCkGwLPj2Mi6OnyMANjyLcqDUWs4rlOHnc8SBLf6/pA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/bricolage-grotesque": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/bricolage-grotesque/-/bricolage-grotesque-5.3.0.tgz",
"integrity": "sha512-MdOVb/5in11IfN/IQJOExMuu6AroCIQbirl0yX7NUvKk+h0HYIKfGaVAXRS/gCPil4aFxWhjZnOw28tUwv7tFw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/caveat": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/caveat/-/caveat-5.3.0.tgz",
"integrity": "sha512-eHTfzQxFSW9ABERRLNruHoNJmKed2J7pqTUc5lp4a4POMgXGKpxmWj3d9Hz10kmAg6iuTTLe6UagbA4bnsmfQw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/cormorant-garamond": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/cormorant-garamond/-/cormorant-garamond-5.3.0.tgz",
"integrity": "sha512-weuGsCirGVWBWqpt6YUp0yLThTe4G8YO45noq8wxeJCRvEpq5lFrxNMFSTxcyOyV5k5otzJ8guDzYegdYlcLMQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/dancing-script": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/dancing-script/-/dancing-script-5.3.0.tgz",
"integrity": "sha512-/FFbz4ZCNN+eG2a6phkZB41iKmyCC6rq2blA/kG9OFeI3/tpzLNazND1bBdEnogLhpfg7o4NfauTbEEYSpSUGg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/dm-mono": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/dm-mono/-/dm-mono-5.3.0.tgz",
"integrity": "sha512-OINjI8C1S/wpchhQxl7njZdMn4+hnDCpQ4YtvvOpKNARo+0J8O1x1IcrChxNjHOhfVv1by8C/FQoy3hXK+C1Ug==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/dm-serif-display": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/dm-serif-display/-/dm-serif-display-5.3.0.tgz",
"integrity": "sha512-ecRclUcYeto9fB7ghkB36H4MtUoaUYFjjornWYYeLCMHzaWEDPk2kIWgZOq0mKBNRxclRSieKcFeLonlMYDTKg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/eb-garamond": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/eb-garamond/-/eb-garamond-5.3.0.tgz",
@@ -3384,6 +3528,15 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/fraunces": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/fraunces/-/fraunces-5.3.0.tgz",
"integrity": "sha512-LEHTOBiZufQzAvnAe2rZ8ZknbDS28Q9kzwb0L+gZJEmjLGr8hhZ/5NuHXwDuHBWhVXYoiGGXmrb0H6Ko/RrHTg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/geist-sans": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/geist-sans/-/geist-sans-5.3.0.tgz",
@@ -3393,6 +3546,24 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/great-vibes": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/great-vibes/-/great-vibes-5.3.0.tgz",
"integrity": "sha512-1w60cXzXnC9V4y943po8044O1mqRQBiTTK75h7OpZE+1VQtx+KlAAzoIyEE5KNzu05gUpxaCQg7cbL7ITSwegQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/homemade-apple": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/homemade-apple/-/homemade-apple-5.3.0.tgz",
"integrity": "sha512-awJSYTbSY3SroC8nirEhDauHvERVvm0LI3FpUHsPZXl9Tht1xrqhiI8ro4yf3ROcvQ7CfFJ/2+DLcOHBOXhJKw==",
"license": "Apache-2.0",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/inter": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.3.0.tgz",
@@ -3402,6 +3573,24 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/kaushan-script": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/kaushan-script/-/kaushan-script-5.3.0.tgz",
"integrity": "sha512-Kdcvr76wOGp2jgKkkmAOETmPcw/a34RU/qcGW2+gSKzkKyr03vEaLWSbSABoVKWuyuoqWoulcmC4571vq3BYvg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/league-gothic": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/league-gothic/-/league-gothic-5.3.0.tgz",
"integrity": "sha512-gqedetX+UlaiJgLiPnOzWGB1sY3OtNhS8YMjTy2D73AcsagIqpdIizssr1MStcUYf3D/zzanECuXawIgMGZxDg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/lora": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/lora/-/lora-5.3.0.tgz",
@@ -3411,6 +3600,15 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/montserrat": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/montserrat/-/montserrat-5.3.0.tgz",
"integrity": "sha512-iQPDtZOnmM5ih4JBGaMSXisRap0ixb9bAUw2hDtY7EjVfaqiiNlKtVuJl9XclxUwtwMMHNcWXfQR6zI9x05+Tg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/museomoderno": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/museomoderno/-/museomoderno-5.3.0.tgz",
@@ -3420,6 +3618,42 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/oswald": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/oswald/-/oswald-5.3.0.tgz",
"integrity": "sha512-16r2aHActe0pH+9FLdk/74/YxXM7HpGak05kAzx3RvSToiJwGvyhv6GvckPz+YuZmvL1nxWbEop2jAXRcXYPRg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/pacifico": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/pacifico/-/pacifico-5.3.0.tgz",
"integrity": "sha512-bWv0fHR1n36p7scRn+maaEaVeQCOq9yvfVfjyAeUzKrK4Y/3qiRAEj4hJgfxj/sjgZZGkeHyKPrbWdDaZtpCVw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/parisienne": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/parisienne/-/parisienne-5.3.0.tgz",
"integrity": "sha512-YbfjZDYy7FMTVE0HRgejkGX3T5iFfgagOYtAAAymq9GklviFAZ1GqIdlyviNe23TiR20f2K7zxsL4AECtWnkEQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/permanent-marker": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/permanent-marker/-/permanent-marker-5.3.0.tgz",
"integrity": "sha512-ZO/NtYW4SCc/iY8N3dmjWyDITcXJ/vhWZFgnwRcvzwCKKnScMrM/a2/6VHlsjCVuIgmPzNrCFbedEYhv+/jJBg==",
"license": "Apache-2.0",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/playfair-display": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/playfair-display/-/playfair-display-5.3.0.tgz",
@@ -3438,6 +3672,42 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/sacramento": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/sacramento/-/sacramento-5.3.0.tgz",
"integrity": "sha512-KkqN52aTsJ0pSBva/NREqFljs0M1TwMIpNcqdSQc2EKR/mVU1CvM2dazf9eG19rHMTIssjbia83nGG705SlD1w==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/satisfy": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/satisfy/-/satisfy-5.3.0.tgz",
"integrity": "sha512-dQ+Cy85XdwYnwlzWcbkmA2Qkpxam0ZHDaGm/qoGHlyHKRVgM/BJN8RLHpRm31R0REFBQe5Yl8Ke5oTdpcKdPIA==",
"license": "Apache-2.0",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/shrikhand": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/shrikhand/-/shrikhand-5.3.0.tgz",
"integrity": "sha512-AWCCj1oNNpMK5hXfFo4JO5KyLhPrdBmpbA0wyIl4IUB05WTU0CGP2UgGX7Lcq/N8nS2+hcxzCPMSQ1R55FuMPg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/yellowtail": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/yellowtail/-/yellowtail-5.3.0.tgz",
"integrity": "sha512-atz4CIdbP7otppxRaObJiygnkyJzIuLFpPRjWspXvXhBWLB0sk35cCu5r6gSkDomRtEWakJ8//bWbyBjJbilGg==",
"license": "Apache-2.0",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@hexagon/base64": {
"version": "1.1.28",
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
@@ -28,8 +28,8 @@ The energy decides the rest:
- Vary the beats, never three alike in a row; a quiet page for a long story, grids for bursts.
Type scale, in points for a 210 mm page and scaled with the page width:
- Cover title in a display face, 72 to 180 pt, 12 to 30 % of the page height; title to subtitle at least 3:1. For a big cover raise type.display (up to 6.5, about 180 pt); keep type.scale between 0.9 and 1.1 so body stays 9 to 11 pt.
- Date lines in spaced caps, 7 to 9 pt, tracking 0.25 to 0.4; an accent word over a title in the contrasting face (playfair italic or display), 1.2 to 1.6 times its cap height, with contrast, an outline or a shadow.
- Body 9 to 11 pt; captions 6.5 to 8 pt with tracking 0.05 to 0.1; a display face and a body face, two typefaces at most.
- Date lines in spaced caps, 7 to 9 pt, tracking 0.25 to 0.4; an accent word over a title in a script (caveat, pacifico, vibes, marker) or the contrasting face (playfair italic), 1.2 to 1.6 times its cap height, with contrast, an outline or a shadow.
- Body 9 to 11 pt in a face whose role is body; captions 6.5 to 8 pt with tracking 0.05 to 0.1; a display face, a body face and one more at most, a script or a mono line not counted; a family sets only the weights its font id lists.
Look, judge, revise:
- Check every changed page with describe_book_page and the designId, and look at the pages that matter most with preview_book_page and the designId when you can see images; preview the cover at least twice, the first draft and the final one.
- Before every proposal answer silently: Is there one dominant element? Does the title read at thumbnail size? Does the energy match the brief? Is the palette the book's own? Is anything touching or overlapping by accident? Is text on a busy area without contrast? Does the decoration suit the energy? Does the page differ from the one before? Fix what fails with revise_book_design.
+1 -1
View File
@@ -177,7 +177,7 @@ export class JourneyBookMcp {
@Tool({
name: 'edit_book_page',
description: `Change one page with 1 to ${BOOK_OP_LIMITS.opsPerCall} operations, run in order, all or nothing: layout (a template, grid, saved layout, or auto to lay the page out again from its entry), place_photo (a photo of the journey's gallery into a frame by elementId, by its slot among the page's frames, or into a new frame), photo_look (the crop as fit and focal point, mask, frame style, filter, radius), swap_photos, text (by elementId, or by role heading, body or meta, which creates the text when the page has none), caption (under a photo, bound to its caption in the journal unless words are given), frame (move, resize, turn), remove (parked with the page by default, park false deletes), background (null is the paper), add (a new photo frame, text, shape, icon, map, figures, country outlines, mark or list; the journey's own values such as the route, figures and countries come from the journey, never from you), style (the look of one element of its kind: opacity, colours, fonts, strokes and the like, and for a photo its border, hard shadow, zoom, mirror, adjustments, duotone and cutout), arrange (to front, forward, backward, to back), align and distribute (several elements by their edges or centres, against each other, the page or the safe area), lock, duplicate, fill (a photo to the bleed of its page or the spread, behind everything), copy_from (elements of another page), refresh_travel (travel elements to the journey's current figures) and add_grid (a photo grid as empty frames in a box). A locked element refuses any operation that would change it but lock. The road a map draws between stops is a straight or bowed line; roads are not routed. ${LAYOUT_NOTES.templates} ${LAYOUT_NOTES.grids} ${UNITS} With returnPrevious the answer also holds the page as it was, under previous_spreads, and a digest of each page as the write left it under page_digests (for restore_book_page expectedPages). ${WRITE_ANSWER}`,
description: `Change one page with 1 to ${BOOK_OP_LIMITS.opsPerCall} operations, run in order, all or nothing: layout (a template, grid, saved layout, or auto to lay the page out again from its entry), place_photo (a photo of the journey's gallery into a frame by elementId, by its slot among the page's frames, or into a new frame), photo_look (the crop as fit and focal point, mask, frame style, filter, radius), swap_photos, text (by elementId, or by role heading, body or meta, which creates the text when the page has none), caption (under a photo, bound to its caption in the journal unless words are given), frame (move, resize, turn), remove (parked with the page by default, park false deletes), background (null is the paper), add (a new photo frame, text, shape, icon, map, figures, country outlines, mark or list; the journey's own values such as the route, figures and countries come from the journey, never from you), style (the look of one element of its kind: opacity, colours, fonts, strokes and the like), arrange (to front, forward, backward, to back), align and distribute (several elements by their edges or centres, against each other, the page or the safe area), lock, duplicate, fill (a photo to the bleed of its page or the spread, behind everything), copy_from (elements of another page), refresh_travel (travel elements to the journey's current figures) and add_grid (a photo grid as empty frames in a box). A locked element refuses any operation that would change it but lock. The road a map draws between stops is a straight or bowed line; roads are not routed. ${LAYOUT_NOTES.templates} ${LAYOUT_NOTES.grids} ${UNITS} With returnPrevious the answer also holds the page as it was, under previous_spreads, and a digest of each page as the write left it under page_digests (for restore_book_page expectedPages). ${WRITE_ANSWER}`,
inputSchema: { journeyId: idSchema, ...editBookPageArgs },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
when: journeyAddonOn,
+7 -32
View File
@@ -125,6 +125,9 @@ async function journeyWithBook() {
return { owner, editor, viewer, journey, inner, everyone: [owner.id, editor.id, viewer.id].sort((a, b) => a - b) };
}
/** A revision one past what this build reads. */
const NEWER = BOOK_SCHEMA_REV + 1;
describe('Studio book over MCP and REST', () => {
it('JBE2E-001: an MCP write is what GET reads next, and a viewer is refused in the words a tool answers', async () => {
const { editor, viewer, journey, inner } = await journeyWithBook();
@@ -209,7 +212,7 @@ describe('Studio book over MCP and REST', () => {
.get(`/api/journeys/${journey.id}/book`)
.set('Cookie', authCookie(owner.id));
const row = await findRow(orm, JourneyBooks, { journey: journey.id });
const newer = JSON.stringify({ ...JSON.parse(String(row?.document)), schemaRev: BOOK_SCHEMA_REV + 1 });
const newer = JSON.stringify({ ...JSON.parse(String(row?.document)), schemaRev: NEWER });
await updateRows(orm, JourneyBooks, { journey: journey.id }, { document: newer });
const saved = await request(app.getHttpServer())
@@ -220,7 +223,7 @@ describe('Studio book over MCP and REST', () => {
expect(saved.body).toEqual({
error: 'Book was saved by a newer version of TREK Studio',
code: 'book_schema_newer',
schemaRev: BOOK_SCHEMA_REV + 1,
schemaRev: NEWER,
});
const tool = await callTool(owner.id, 'edit_book_page', {
@@ -249,43 +252,15 @@ describe('Studio book over MCP and REST', () => {
const saved = await request(app.getHttpServer())
.put(`/api/journeys/${journey.id}/book`)
.set('Cookie', authCookie(owner.id))
.send({
title: before.body.book.title,
document: { ...document, schemaRev: BOOK_SCHEMA_REV + 1 },
baseVersion: 1,
});
.send({ title: before.body.book.title, document: { ...document, schemaRev: NEWER }, baseVersion: 1 });
expect(saved.status).toBe(409);
expect(saved.body).toEqual({
error: 'Book was saved by a newer version of TREK Studio',
code: 'book_schema_newer',
schemaRev: BOOK_SCHEMA_REV + 1,
schemaRev: NEWER,
});
expect(sent()).toEqual([]);
const after = await findRow(orm, JourneyBooks, { journey: journey.id });
expect(after?.version).toBe(1);
});
it('JBE2E-007: the photo look set over MCP is what GET reads next, stamped with the revision that wrote it', async () => {
const { owner, journey, inner } = await journeyWithBook();
await callTool(owner.id, 'edit_book_page', {
journeyId: journey.id,
spreadId: inner,
ops: [{ op: 'add', element: { kind: 'photo' } }],
});
const read = async () =>
(await request(app.getHttpServer()).get(`/api/journeys/${journey.id}/book`).set('Cookie', authCookie(owner.id)))
.body.book.document as { schemaRev?: number; spreads: Array<{ elements: Array<Record<string, unknown>> }> };
const frame = (await read()).spreads[2]?.elements.find((e) => e.kind === 'photo');
const look = { border: { width: 5, color: '#ffffff' }, zoom: 1.5, flipX: true, adjust: { warmth: 0.4 } };
const styled = await callTool(owner.id, 'edit_book_page', {
journeyId: journey.id,
spreadId: inner,
ops: [{ op: 'style', elementId: frame?.id, set: { kind: 'photo', ...look } }],
});
expect(bookWriteResultSchema.safeParse(styled).success).toBe(true);
const after = await read();
expect(after.schemaRev).toBe(BOOK_SCHEMA_REV);
expect(after.spreads[2]?.elements.find((e) => e.id === frame?.id)).toMatchObject(look);
});
});
@@ -28,6 +28,7 @@ import {
undoReadsOf,
} from '../../../../../../src/nest/assistant/cards/registry';
import {
BOOK_FONTS_IDS,
bookDesignCardSchema,
MAX_CARD_CHARS,
type AssistantChangePreview,
@@ -250,7 +251,11 @@ describe('the designer in the Studio', () => {
// The cover tier carries a big title alone, and the faces named are faces the book has.
expect(BOOK_DESIGN_PROMPT).toContain('type.display (up to 6.5');
expect(BOOK_DESIGN_PROMPT).toContain('type.scale between 0.9 and 1.1');
expect(BOOK_DESIGN_PROMPT).not.toMatch(/script/i);
for (const face of ['caveat', 'pacifico', 'vibes', 'marker', 'playfair']) {
expect(BOOK_FONTS_IDS, face).toContain(face);
expect(BOOK_DESIGN_PROMPT, face).toContain(face);
}
expect(BOOK_DESIGN_PROMPT).toContain('a script or a mono line not counted');
expect(BOOK_DESIGN_PROMPT).toContain('210 mm page and scaled with the page width');
const rubric = BOOK_DESIGN_PROMPT.slice(BOOK_DESIGN_PROMPT.indexOf('answer silently'));
expect(rubric.slice(0, rubric.indexOf('Fix what fails')).match(/\?/g)).toHaveLength(8);
@@ -561,9 +561,11 @@ describe('editBook', () => {
const journey = createJourney(testDb, user.id);
await books.saveBook(journey.id, user.id, { title: 'T', document: doc('one') });
// The Studio stamps its revision on every save, as the tool's write does, so the race is about the version alone.
const fromStudio = { ...doc('studio'), schemaRev: BOOK_SCHEMA_REV };
const [tool, studio] = await Promise.all([
catchDomainError(() => books.editBook(journey.id, user.id, edited('tool'), { expectedVersion: 1 })),
books.saveBook(journey.id, user.id, { title: 'T', document: doc('studio'), baseVersion: 1 }),
books.saveBook(journey.id, user.id, { title: 'T', document: fromStudio, baseVersion: 1 }),
]);
const toolLost = tool instanceof DomainError && tool.getStatus() === 409;
@@ -629,6 +631,9 @@ describe('editBook', () => {
* of a server that cannot read the book whole.
*/
describe('format revisions', () => {
/** A revision one past what this build reads. */
const NEWER = BOOK_SCHEMA_REV + 1;
/** A journey whose stored book says it was written by revision `schemaRev`. */
async function storedAt(schemaRev: number) {
const { user } = createUser(testDb);
@@ -646,7 +651,7 @@ describe('format revisions', () => {
thrown instanceof DomainError ? [thrown.getStatus(), thrown.code, thrown.toBody().code] : thrown;
it('JBOOK-011: refuses a save from a writer older than the stored book with 409 and the code, writing and sending nothing', async () => {
const { user, journey } = await storedAt(2);
const { user, journey } = await storedAt(NEWER);
const rec = await recordSends();
try {
const thrown = await catchDomainError(() =>
@@ -655,11 +660,11 @@ describe('format revisions', () => {
expect(refusalOf(thrown)).toEqual([409, 'book_schema_newer', 'book_schema_newer']);
expect(thrown instanceof DomainError && thrown.toBody()).toMatchObject({
error: 'Book was saved by a newer version of TREK Studio',
schemaRev: 2,
schemaRev: NEWER,
});
expect(rec.sent).toEqual([]);
const stored = await books.getBook(journey.id, user.id);
expect([stored?.version, stored?.document.title, stored?.document.schemaRev]).toEqual([1, 'newer', 2]);
expect([stored?.version, stored?.document.title, stored?.document.schemaRev]).toEqual([1, 'newer', NEWER]);
} finally {
rec.restore();
}
@@ -697,7 +702,7 @@ describe('format revisions', () => {
});
it('JBOOK-014: the book tools leave a book this server cannot read whole alone, before any edit runs', async () => {
const { user, journey } = await storedAt(BOOK_SCHEMA_REV + 1);
const { user, journey } = await storedAt(NEWER);
const rec = await recordSends();
try {
const edit = vi.fn(() => ({ title: 'T', document: normalizeBookDocument(doc('tool')), result: null }));
@@ -716,7 +721,7 @@ describe('format revisions', () => {
const { user, journey } = await storedAt(1);
const rec = await recordSends();
try {
const ahead = normalizeBookDocument({ ...doc('ahead'), schemaRev: BOOK_SCHEMA_REV + 1 });
const ahead = normalizeBookDocument({ ...doc('ahead'), schemaRev: NEWER });
const thrown = await catchDomainError(() =>
books.editBook(journey.id, user.id, () => ({ title: 'T', document: ahead, result: null })),
);
+3 -4
View File
@@ -25,9 +25,9 @@ import {
bookStrokeStyleSchema,
bookTextAlignSchema,
bookTypesetFontSchema,
bookWeightSchema,
} from './book-op-enums';
import { bookElementIdSchema, bookGridIdSchema, bookSpreadIdSchema } from './book-op-ids';
import { BOOK_PHOTO_LOOK_PROPS } from './book-photo-look.schema';
import {
MAX_BOOK_COUNTRIES,
bookBadgeElementSchema,
@@ -100,7 +100,7 @@ const PHOTO_PROPS = {
export const TEXT_STYLE_FIELDS = {
font: bookFontIdSchema.optional(),
size: text.size.unwrap().optional(),
weight: text.weight.unwrap().optional(),
weight: bookWeightSchema.optional(),
italic: text.italic.unwrap().optional(),
align: bookTextAlignSchema.optional(),
leading: text.leading.unwrap().optional(),
@@ -180,8 +180,7 @@ const METRICS = z.array(bookMetricSchema).min(1).max(7);
* resolves them from the journey, exactly as the Travel panel does.
*/
export const bookElementPropsSchema = z.discriminatedUnion('kind', [
// The photo look only here, not on `add`: a new frame is styled after it exists, and the field list stays out of the add schema twice over.
z.strictObject({ kind: z.literal('photo'), ...PHOTO_PROPS, ...BOOK_PHOTO_LOOK_PROPS }),
z.strictObject({ kind: z.literal('photo'), ...PHOTO_PROPS }),
z.strictObject({ kind: z.literal('text'), ...TEXT_PROPS }),
z.strictObject({ kind: z.literal('shape'), shape: bookShapeIdSchema.optional(), ...SHAPE_PROPS }),
z.strictObject({ kind: z.literal('icon'), ...ICON_PROPS }),
@@ -140,6 +140,12 @@ describe('book level and the page in words', () => {
const mixOf = (pages: typeof fonts) => bookLevelFindings(bookOf(pages)).filter((f) => f.code === 'font_mix');
expect(mixOf(fonts.slice(0, 3))).toEqual([]);
expect(mixOf(fonts.slice(0, 2))).toEqual([]);
// A script laid over a title and a mono line on a ticket dress a page; they are not a fourth voice.
const dressed = ['caveat', 'mono'].map((font, i) => [
text(`d-${i}`, 'Hi', { x: 230, y: 40, w: 60, h: 10 }, { font }),
]);
expect(mixOf([...fonts.slice(0, 3), ...dressed])).toEqual([]);
expect(mixOf([...fonts, ...dressed])[0]?.message).toMatch(/4 typefaces \(bebas, playfair, sans, serif\)/);
});
it('BOOKQA-010: notes colours outside the book own and the same page three times running', () => {
+14 -19
View File
@@ -1,8 +1,8 @@
import { compareCodeUnits } from '../common/compare';
import { contrastOfLuminance, deltaE, greyLuminance, luminance, parseHex, requiredContrast } from './book-colour';
import { bookFontCountsInMix, bookFontsUsed } from './book-fonts';
import type { BookPhotoInfo } from './book-ops-core';
import { BOOK_OP_LIMITS, type BookFinding } from './book-ops.schema';
import { photoZoomBox, photoZoomOf } from './book-photo-look';
import type { BookDocument, BookElement, BookFrame, BookPageSetup, BookPhotoElement, BookSpread } from './book.schema';
/**
@@ -41,7 +41,7 @@ const centreIn = (b: Box, px: number, py: number) => px >= b.x && px <= b.x + b.
*/
export function bookPhotoCrop(el: BookPhotoElement, info: BookPhotoInfo | undefined): Box | null {
if (!info?.width || !info.height) return null;
const f = photoZoomBox(el.frame, photoZoomOf(el), el.focalX, el.focalY);
const f = el.frame;
const pick = el.fit === 'contain' ? Math.min : Math.max;
const scale = pick(f.w / info.width, f.h / info.height);
const w = info.width * scale;
@@ -63,9 +63,7 @@ export function bookBusyUnder(el: BookPhotoElement, info: BookPhotoInfo | undefi
const crop = bookPhotoCrop(el, info);
const grid = info?.busy;
if (!crop || !grid || grid.length !== 64) return null;
// A mirrored picture shows under the box what lies under its mirror image.
const under = el.flipX ? { ...box, x: 2 * el.frame.x + el.frame.w - box.x - box.w } : box;
const onPhoto = overlap(under, el.frame);
const onPhoto = overlap(box, el.frame);
const area = onPhoto && overlap(onPhoto, crop);
if (!area) return null;
const cw = crop.w / 8;
@@ -309,16 +307,13 @@ export function designFindings(
return sortBookFindings(ctx.out);
}
/** The typefaces a book is set in: its texts, its travel elements and its page numbers when shown. */
/**
* The typefaces that make a book's voice: its texts, its travel elements and
* its page numbers when shown, leaving out the scripts and mono faces, which
* dress a page rather than set it.
*/
function familiesOf(doc: BookDocument): string[] {
const used = new Set<string>();
for (const spread of doc.spreads) {
for (const el of spread.elements) {
if ('font' in el && (el.kind !== 'text' || el.text.trim())) used.add(el.font);
}
}
if (doc.page.pageNumbers.show) used.add(doc.page.pageNumbers.font);
return [...used].sort(compareCodeUnits);
return bookFontsUsed(doc).filter(bookFontCountsInMix).sort(compareCodeUnits);
}
/** The colours a book paints with: its type and its filled shapes, with how often each is used. */
@@ -345,10 +340,10 @@ function signatureOf(spread: BookSpread): string {
/**
* How many typefaces a book may be set in before it reads as a type sampler:
* a display face, a body face and one more, a script laid over a title or a
* face for figures. Two was the rule while the book had seven faces and no
* scripts, and it told every lively book that set its title apart to stop.
* Once fonts carry roles, script and mono faces will not count towards it.
* a display face, a body face and one more for figures or a subtitle. Two was
* the rule while the book had seven faces and no scripts, and it told every
* lively book that set its title apart to stop. Script and mono faces do not
* count towards it (`bookFontCountsInMix`).
*/
const MAX_BOOK_FAMILIES = 3;
@@ -361,7 +356,7 @@ export function bookLevelFindings(doc: BookDocument): BookFinding[] {
code: 'font_mix',
severity: 'warn',
elementIds: [],
message: `the book is set in ${families.length} typefaces (${families.join(', ')}); keep to three: a display face, a body face and one more`,
message: `the book is set in ${families.length} typefaces (${families.join(', ')}); keep to three: a display face, a body face and one more; scripts and mono faces do not count`,
});
}
const colours = [...coloursOf(doc)].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
-38
View File
@@ -1,38 +0,0 @@
import type { BookFinding } from './book-ops.schema';
import { photoLookOf } from './book-photo-look';
import type { BookPageSetup, BookSpread } from './book.schema';
/**
* What the photo look (book-photo-look.schema.ts) is worth knowing about on a
* proof.
*
* Adjustments and a duotone are drawn through filters, and a filtered picture
* goes to the printer as a raster the browser made rather than as the
* original's own pixels. On a small picture nobody sees the difference; on a
* picture a quarter of a page or larger it can print softer than the same
* picture without the filter, so the finding says so, as context.
*/
/** The share of one page a filtered picture covers before the finding speaks. */
const RASTER_EFFECT_SHARE = 0.25;
export function photoLookChecks(spread: BookSpread, page: BookPageSetup): BookFinding[] {
const pageArea = page.pageWidth * page.pageHeight;
if (pageArea <= 0) return [];
const out: BookFinding[] = [];
for (const el of spread.elements) {
if (el.kind !== 'photo' || el.photoId == null) continue;
const look = photoLookOf(el);
const what = [look.adjust ? 'adjustments' : null, look.duotone ? 'a duotone' : null].filter(Boolean);
if (!what.length) continue;
const share = (el.frame.w * el.frame.h) / pageArea;
if (share < RASTER_EFFECT_SHARE) continue;
out.push({
code: 'raster_effect',
severity: 'info',
elementIds: [el.id],
message: `photo ${el.id} carries ${what.join(' and ')} over ${Math.round(share * 100)}% of a page; filters print as a raster and can look softer than the original, drop them where the picture should stay sharp`,
});
}
return out;
}
+5 -16
View File
@@ -1,8 +1,7 @@
import { bookVisiblePart, sortBookFindings } from './book-findings-design';
import { photoLookChecks } from './book-findings-photo';
import { bookFontFace } from './book-fonts';
import type { BookPhotoInfo } from './book-ops-core';
import type { BookFinding } from './book-ops.schema';
import { photoZoomOf } from './book-photo-look';
import type { BookElement, BookPageSetup, BookPhotoElement, BookSpread, BookTextElement } from './book.schema';
/**
@@ -65,20 +64,11 @@ export function photoDpi(el: BookPhotoElement, info: BookPhotoInfo | undefined):
el.fit === 'contain'
? Math.min(el.frame.w / info.width, el.frame.h / info.height)
: Math.max(el.frame.w / info.width, el.frame.h / info.height);
// A zoomed picture spreads each pixel over that many times the paper.
return Math.round(25.4 / (mmPerPx * photoZoomOf(el)));
return Math.round(25.4 / mmPerPx);
}
/** 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.507,
inter: 0.478,
serif: 0.469,
garamond: 0.383,
playfair: 0.454,
display: 0.492,
bebas: 0.339,
};
/** Average letter width per em at weight 400, per family: the catalogue's measure (book-fonts.data.ts). */
const letterEm = (font: string) => bookFontFace(font)?.letterEm ?? 0.5;
/**
* The headroom on letter width the design engine sizes a text frame with. A
@@ -124,7 +114,7 @@ function wrappedLines(para: string, perLine: number): number {
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) * headroom + tracking);
const letter = sizeMm * (letterEm(el.font) * (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 + wrappedLines(para.trimEnd(), perLine), 0);
return lines * sizeMm * orDefault(el.leading, 1.45);
@@ -509,7 +499,6 @@ export function spreadFindings(
emptyFrames(ctx);
overflow(ctx);
photoChecks(ctx);
ctx.out.push(...photoLookChecks(spread, page));
parked(ctx);
return sortBookFindings(ctx.out);
}
+481
View File
@@ -0,0 +1,481 @@
/**
* The typefaces a book may be set in, and what each of them is.
*
* One catalogue for every reader of a book: the schema takes its ids, the
* Studio draws its picker and its stacks from it, the findings count its
* roles, the design engine checks its weights, and the assistant reads the
* same lines in the tool schemas. A family named in one of those places and
* missing from another is how five of the first seven fonts shipped without
* their faces (#2183), so nothing here is copied anywhere else.
*
* Every family is self-hosted through @fontsource and licensed under the SIL
* Open Font License 1.1 or the Apache License 2.0 (the typefaces in NOTICE.md),
* which both allow embedding in a printed or exported book.
*
* This file holds data only, so book.schema.ts can take the ids from it
* without importing the logic in book-fonts.ts.
*/
/** Every weight CSS names. A family offers the ones it really ships. */
export const BOOK_WEIGHTS = [100, 200, 300, 400, 500, 600, 700, 800, 900] as const;
export type BookWeight = (typeof BOOK_WEIGHTS)[number];
/**
* What a family is for, most suited first.
*
* `display` sets a cover title or a poster word, `heading` a page's title,
* `body` running text at 9 to 11 pt. A `script` is laid over a title or a
* photo, a `mono` sets tickets and coordinates. Neither of the last two
* counts towards the faces a book is set in (`font_mix`), because they are
* ornaments of a page rather than its voice.
*/
export const BOOK_FONT_ROLES = ['display', 'heading', 'body', 'script', 'mono'] as const;
export type BookFontRole = (typeof BOOK_FONT_ROLES)[number];
/** The picker's groups, in the order it shows them. */
export const BOOK_FONT_CATEGORIES = ['sans', 'serif', 'elegant', 'condensed', 'retro', 'script', 'mono'] as const;
export type BookFontCategory = (typeof BOOK_FONT_CATEGORIES)[number];
export interface BookFontFace {
/** What it is called, shown as itself in the picker. */
name: string;
/** The CSS stack. Ends in a generic, never in a font that is not bundled. */
stack: string;
/** The @fontsource package the faces come from. */
pkg: string;
category: BookFontCategory;
/** What it suits, the first being what it is for. */
roles: readonly BookFontRole[];
/**
* Weights actually loaded. Asking for 700 on a family that ships one weight
* gets a synthesised bold, which prints as a smeared version of the regular.
*/
weights: readonly BookWeight[];
/** The weights it has a real italic for. Any other italic is the browser's slant. */
italics: readonly BookWeight[];
/**
* Average letter width per em at weight 400, measured on story text from
* the face's own advance widths, for the text measure in book-findings.ts.
*/
letterEm: number;
/** Set in capitals whatever is typed. */
caps?: true;
}
const ALL = BOOK_WEIGHTS;
const ONE = [400] as const;
const MID = [400, 500, 600, 700] as const;
/**
* The families. `sans`, `serif` and `display` are the three the format started
* with and keep their names, because documents already contain them.
*/
export const BOOK_FONT_FACES = {
// ── sans ──
sans: {
name: 'Poppins',
stack: '"Poppins", system-ui, sans-serif',
pkg: 'poppins',
category: 'sans',
roles: ['heading', 'body', 'display'],
weights: ALL,
italics: [],
letterEm: 0.507,
},
inter: {
name: 'Inter',
stack: '"Inter", system-ui, sans-serif',
pkg: 'inter',
category: 'sans',
roles: ['body', 'heading'],
weights: ALL,
italics: [],
letterEm: 0.478,
},
montserrat: {
name: 'Montserrat',
stack: '"Montserrat", system-ui, sans-serif',
pkg: 'montserrat',
category: 'sans',
roles: ['heading', 'body', 'display'],
weights: ALL,
italics: [],
letterEm: 0.507,
},
// ── serif ──
serif: {
name: 'Lora',
stack: '"Lora", Georgia, serif',
pkg: 'lora',
category: 'serif',
roles: ['body', 'heading'],
weights: MID,
italics: MID,
letterEm: 0.469,
},
garamond: {
name: 'EB Garamond',
stack: '"EB Garamond", Georgia, serif',
pkg: 'eb-garamond',
category: 'serif',
roles: ['body', 'heading'],
weights: [400, 500, 600, 700, 800],
italics: [400, 500, 600, 700, 800],
letterEm: 0.383,
},
// ── elegant display serifs ──
playfair: {
name: 'Playfair Display',
stack: '"Playfair Display", Georgia, serif',
pkg: 'playfair-display',
category: 'elegant',
roles: ['display', 'heading'],
weights: [400, 500, 600, 700, 800, 900],
italics: [400, 500, 600, 700, 800, 900],
letterEm: 0.454,
},
fraunces: {
name: 'Fraunces',
stack: '"Fraunces", Georgia, serif',
pkg: 'fraunces',
category: 'elegant',
roles: ['display', 'heading', 'body'],
weights: ALL,
italics: ALL,
letterEm: 0.467,
},
cormorant: {
name: 'Cormorant Garamond',
stack: '"Cormorant Garamond", Georgia, serif',
pkg: 'cormorant-garamond',
category: 'elegant',
roles: ['display', 'heading'],
weights: [300, 400, 500, 600, 700],
italics: [300, 400, 500, 600, 700],
letterEm: 0.393,
},
dmserif: {
name: 'DM Serif Display',
stack: '"DM Serif Display", Georgia, serif',
pkg: 'dm-serif-display',
category: 'elegant',
roles: ['display'],
weights: ONE,
italics: ONE,
letterEm: 0.448,
},
bodoni: {
name: 'Bodoni Moda',
stack: '"Bodoni Moda", Georgia, serif',
pkg: 'bodoni-moda',
category: 'elegant',
roles: ['display', 'heading'],
weights: [400, 500, 600, 700, 800, 900],
italics: [400, 500, 600, 700, 800, 900],
letterEm: 0.463,
},
// ── condensed and poster ──
bebas: {
name: 'Bebas Neue',
stack: '"Bebas Neue", Impact, sans-serif',
pkg: 'bebas-neue',
category: 'condensed',
roles: ['display'],
weights: ONE,
italics: [],
letterEm: 0.339,
caps: true,
},
anton: {
name: 'Anton',
stack: '"Anton", Impact, sans-serif',
pkg: 'anton',
category: 'condensed',
roles: ['display'],
weights: ONE,
italics: [],
letterEm: 0.409,
},
oswald: {
name: 'Oswald',
stack: '"Oswald", Impact, sans-serif',
pkg: 'oswald',
category: 'condensed',
roles: ['display', 'heading'],
weights: [200, 300, 400, 500, 600, 700],
italics: [],
letterEm: 0.365,
},
league: {
name: 'League Gothic',
stack: '"League Gothic", Impact, sans-serif',
pkg: 'league-gothic',
category: 'condensed',
roles: ['display'],
weights: ONE,
italics: [],
letterEm: 0.266,
},
shoulders: {
name: 'Big Shoulders Display',
stack: '"Big Shoulders Display", Impact, sans-serif',
pkg: 'big-shoulders-display',
category: 'condensed',
roles: ['display', 'heading'],
weights: ALL,
italics: [],
letterEm: 0.304,
},
archivo: {
name: 'Archivo Black',
stack: '"Archivo Black", Impact, sans-serif',
pkg: 'archivo-black',
category: 'condensed',
roles: ['display'],
weights: ONE,
italics: [],
letterEm: 0.557,
},
// ── heavy and retro display ──
display: {
name: 'MuseoModerno',
stack: '"MuseoModerno", "Poppins", system-ui, sans-serif',
pkg: 'museomoderno',
category: 'retro',
roles: ['display', 'heading'],
weights: ALL,
italics: [],
letterEm: 0.492,
},
shrikhand: {
name: 'Shrikhand',
stack: '"Shrikhand", system-ui, sans-serif',
pkg: 'shrikhand',
category: 'retro',
roles: ['display'],
weights: ONE,
italics: [],
letterEm: 0.529,
},
alfa: {
name: 'Alfa Slab One',
stack: '"Alfa Slab One", Georgia, serif',
pkg: 'alfa-slab-one',
category: 'retro',
roles: ['display'],
weights: ONE,
italics: [],
letterEm: 0.54,
},
bricolage: {
name: 'Bricolage Grotesque',
stack: '"Bricolage Grotesque", system-ui, sans-serif',
pkg: 'bricolage-grotesque',
category: 'retro',
roles: ['display', 'heading', 'body'],
weights: [200, 300, 400, 500, 600, 700, 800],
italics: [],
letterEm: 0.471,
},
abril: {
name: 'Abril Fatface',
stack: '"Abril Fatface", Georgia, serif',
pkg: 'abril-fatface',
category: 'retro',
roles: ['display'],
weights: ONE,
italics: [],
letterEm: 0.46,
},
// ── script and handwriting ──
caveat: {
name: 'Caveat',
stack: '"Caveat", cursive',
pkg: 'caveat',
category: 'script',
roles: ['script'],
weights: MID,
italics: [],
letterEm: 0.344,
},
dancing: {
name: 'Dancing Script',
stack: '"Dancing Script", cursive',
pkg: 'dancing-script',
category: 'script',
roles: ['script'],
weights: MID,
italics: [],
letterEm: 0.363,
},
pacifico: {
name: 'Pacifico',
stack: '"Pacifico", cursive',
pkg: 'pacifico',
category: 'script',
roles: ['script'],
weights: ONE,
italics: [],
letterEm: 0.419,
},
vibes: {
name: 'Great Vibes',
stack: '"Great Vibes", cursive',
pkg: 'great-vibes',
category: 'script',
roles: ['script'],
weights: ONE,
italics: [],
letterEm: 0.284,
},
sacramento: {
name: 'Sacramento',
stack: '"Sacramento", cursive',
pkg: 'sacramento',
category: 'script',
roles: ['script'],
weights: ONE,
italics: [],
letterEm: 0.308,
},
kaushan: {
name: 'Kaushan Script',
stack: '"Kaushan Script", cursive',
pkg: 'kaushan-script',
category: 'script',
roles: ['script'],
weights: ONE,
italics: [],
letterEm: 0.396,
},
allura: {
name: 'Allura',
stack: '"Allura", cursive',
pkg: 'allura',
category: 'script',
roles: ['script'],
weights: ONE,
italics: [],
letterEm: 0.33,
},
parisienne: {
name: 'Parisienne',
stack: '"Parisienne", cursive',
pkg: 'parisienne',
category: 'script',
roles: ['script'],
weights: ONE,
italics: [],
letterEm: 0.361,
},
satisfy: {
name: 'Satisfy',
stack: '"Satisfy", cursive',
pkg: 'satisfy',
category: 'script',
roles: ['script'],
weights: ONE,
italics: [],
letterEm: 0.382,
},
yellowtail: {
name: 'Yellowtail',
stack: '"Yellowtail", cursive',
pkg: 'yellowtail',
category: 'script',
roles: ['script'],
weights: ONE,
italics: [],
letterEm: 0.333,
},
homemade: {
name: 'Homemade Apple',
stack: '"Homemade Apple", cursive',
pkg: 'homemade-apple',
category: 'script',
roles: ['script'],
weights: ONE,
italics: [],
letterEm: 0.561,
},
marker: {
name: 'Permanent Marker',
stack: '"Permanent Marker", cursive',
pkg: 'permanent-marker',
category: 'script',
roles: ['script', 'display'],
weights: ONE,
italics: [],
letterEm: 0.516,
},
// ── mono ──
mono: {
name: 'DM Mono',
stack: '"DM Mono", ui-monospace, monospace',
pkg: 'dm-mono',
category: 'mono',
roles: ['mono'],
weights: [300, 400, 500],
italics: [],
letterEm: 0.613,
},
} as const satisfies Record<string, BookFontFace>;
export type BookFontFamily = keyof typeof BOOK_FONT_FACES;
/**
* The families a book may be set in, as the schema takes them. The first
* seven keep the order they shipped in; new ones follow.
*/
export const BOOK_FONTS_IDS = [
'sans',
'inter',
'serif',
'garamond',
'playfair',
'display',
'bebas',
'montserrat',
'fraunces',
'cormorant',
'dmserif',
'bodoni',
'anton',
'oswald',
'league',
'shoulders',
'archivo',
'shrikhand',
'alfa',
'bricolage',
'abril',
'caveat',
'dancing',
'pacifico',
'vibes',
'sacramento',
'kaushan',
'allura',
'parisienne',
'satisfy',
'yellowtail',
'homemade',
'marker',
'mono',
] as const satisfies readonly BookFontFamily[];
/**
* The faces whose latin files the service worker installs with the app, so a
* book set in them opens offline before Studio was ever opened online: the
* seven families and weights the book shipped with before this catalogue grew.
* Every other face is cached the first time a book that uses it is opened.
*/
export const BOOK_FONTS_PRECACHED: Readonly<Partial<Record<BookFontFamily, readonly BookWeight[]>>> = {
sans: MID,
inter: MID,
serif: MID,
garamond: MID,
playfair: MID,
display: MID,
bebas: ONE,
};
+207
View File
@@ -0,0 +1,207 @@
import {
bookFacesUsed,
bookFontCountsInMix,
bookFontFace,
bookFontHasItalic,
bookFontHasWeight,
bookFontIndex,
bookFontRole,
bookFontSuits,
bookFontsUsed,
bookWeightWord,
isBookFontId,
nearestBookWeight,
withRealWeight,
} from './book-fonts';
import { BOOK_FONT_FACES, BOOK_FONTS_IDS, BOOK_FONTS_PRECACHED, BOOK_WEIGHTS } from './book-fonts.data';
import { bookFontIdSchema, bookWeightSchema } from './book-op-enums';
import { applyBookOps } from './book-ops';
import { blankBook } from './book-pages';
import {
bookStatsElementSchema,
bookTextElementSchema,
type BookDocument,
type BookElement,
type BookSpread,
} from './book.schema';
import { describeSpread } from './describe-spread';
import { describe, expect, it } from 'vitest';
// BKFONT-001 to BKFONT-010
/**
* The book's typefaces as one catalogue: the schema's ids, the weights and
* italics each family really ships, what each is for, and the snap the
* Studio and the page operations share, so the assistant never sets a weight
* the editor would not.
*/
const frame = { x: 230, y: 40, w: 120, h: 30 };
const text = (id: string, over: Record<string, unknown> = {}) =>
bookTextElementSchema.parse({ id, kind: 'text', frame, text: 'Lisbon', ...over }) as BookElement;
function book(elements: BookElement[]): BookDocument {
const doc = blankBook({ title: 'Lisbon', newId: (p) => `${p}-x` });
const ids = ['sp-cover', 'sp-first', 'sp-in', 'sp-last', 'sp-back'];
const spreads = doc.spreads.map((s, i) => ({ ...s, id: ids[i] as string }));
const inner = { ...(spreads[2] as BookSpread), role: 'inner' as const, elements };
return { ...doc, spreads: [spreads[0], spreads[1], inner, spreads[3], spreads[4]] as BookSpread[] };
}
let seq = 0;
const run = (doc: BookDocument, ops: Parameters<typeof applyBookOps>[1]['ops']) =>
applyBookOps(doc, { spreadId: 'sp-in', ops }, { newId: (p) => `${p}-n${++seq}` });
const inner = (doc: BookDocument) => doc.spreads[2] as BookSpread;
describe('the catalogue', () => {
it('BKFONT-001: names every family the schema takes, once, with a face, a stack ending in a generic and a weight', () => {
expect([...BOOK_FONTS_IDS].sort()).toEqual(Object.keys(BOOK_FONT_FACES).sort());
expect(new Set(BOOK_FONTS_IDS).size).toBe(BOOK_FONTS_IDS.length);
expect(BOOK_FONTS_IDS.slice(0, 7)).toEqual(['sans', 'inter', 'serif', 'garamond', 'playfair', 'display', 'bebas']);
for (const id of BOOK_FONTS_IDS) {
const face = BOOK_FONT_FACES[id];
expect(face.stack.startsWith(`"${face.name}"`), id).toBe(true);
expect(['sans-serif', 'serif', 'cursive', 'monospace'], id).toContain(face.stack.split(',').pop()?.trim());
expect(face.weights.length, id).toBeGreaterThan(0);
expect(
[...face.weights].sort((a, b) => a - b),
id,
).toEqual([...face.weights]);
for (const w of face.italics) expect(face.weights, `${id} italic ${w}`).toContain(w);
expect(face.letterEm, id).toBeGreaterThan(0.2);
expect(face.letterEm, id).toBeLessThan(0.7);
expect(face.roles.length, id).toBeGreaterThan(0);
}
});
it('BKFONT-002: has real italics for the serifs that used to be slanted by the browser', () => {
for (const id of ['serif', 'garamond', 'playfair', 'cormorant']) {
expect(bookFontHasItalic(id, 400), id).toBe(true);
}
expect(bookFontHasItalic('sans', 400)).toBe(false);
expect(bookFontHasItalic('playfair', 300)).toBe(false);
expect(bookFontHasItalic('nothing', 400)).toBe(false);
});
it('BKFONT-003: installs offline only the faces the book shipped with, each a weight the family has', () => {
for (const [id, weights] of Object.entries(BOOK_FONTS_PRECACHED)) {
for (const w of weights ?? []) expect(bookFontHasWeight(id, w), `${id} ${w}`).toBe(true);
}
expect(Object.keys(BOOK_FONTS_PRECACHED).sort()).toEqual(BOOK_FONTS_IDS.slice(0, 7).sort());
});
it('BKFONT-004: reads a family and its roles, and answers an unknown id without guessing', () => {
expect(bookFontFace('anton')?.name).toBe('Anton');
expect(bookFontFace('toString')).toBeUndefined();
expect([isBookFontId('caveat'), isBookFontId('comic')]).toEqual([true, false]);
expect([bookFontRole('caveat'), bookFontRole('mono'), bookFontRole('anton'), bookFontRole('nothing')]).toEqual([
'script',
'mono',
'display',
'body',
]);
expect([bookFontSuits('garamond', 'body'), bookFontSuits('anton', 'body')]).toEqual([true, false]);
expect([bookFontCountsInMix('caveat'), bookFontCountsInMix('mono'), bookFontCountsInMix('serif')]).toEqual([
false,
false,
true,
]);
});
});
describe('weights', () => {
it('BKFONT-005: snaps to the nearest weight a family has, the lighter on a tie, and leaves an unknown family alone', () => {
expect(nearestBookWeight('anton', 900)).toBe(400);
expect(nearestBookWeight('oswald', 100)).toBe(200);
expect(nearestBookWeight('oswald', 900)).toBe(700);
expect(nearestBookWeight('mono', 700)).toBe(500);
expect(nearestBookWeight('cormorant', 200)).toBe(300);
expect(nearestBookWeight('sans', 900)).toBe(900);
expect(nearestBookWeight('unknown', 600)).toBe(600);
expect([bookFontHasWeight('bebas', 700), bookFontHasWeight('unknown', 650)]).toEqual([false, true]);
expect(BOOK_WEIGHTS.map(bookWeightWord)).toEqual([
'thin',
'extralight',
'light',
'regular',
'medium',
'semibold',
'bold',
'extrabold',
'black',
]);
expect(bookWeightWord(450)).toBe('450');
});
it('BKFONT-006: moves a typeset element onto a real weight and hands every other element back as it was', () => {
const heavy = text('t', { font: 'anton', weight: 700 });
expect(withRealWeight(heavy)).toMatchObject({ weight: 400 });
const fine = text('t', { font: 'sans', weight: 700 });
expect(withRealWeight(fine)).toBe(fine);
const stats = bookStatsElementSchema.parse({ id: 's', kind: 'stats', frame, font: 'league' }) as BookElement;
expect(withRealWeight(stats)).toMatchObject({ weight: 400 });
const shape = { id: 'x', kind: 'shape', frame } as unknown as BookElement;
expect(withRealWeight(shape)).toBe(shape);
});
it('BKFONT-007: takes every weight from 100 to 900 in a document and nothing between them', () => {
expect(bookTextElementSchema.safeParse({ id: 't', kind: 'text', frame, weight: 100 }).success).toBe(true);
expect(bookTextElementSchema.safeParse({ id: 't', kind: 'text', frame, weight: 900 }).success).toBe(true);
expect(bookTextElementSchema.safeParse({ id: 't', kind: 'text', frame, weight: 450 }).success).toBe(false);
expect(bookWeightSchema.safeParse(800).success).toBe(true);
});
});
describe('a book and its faces', () => {
it('BKFONT-008: lists the families a book is set in and the faces it needs drawn, each once', () => {
const doc = book([
text('t1', { font: 'caveat', weight: 700 }),
text('t2', { font: 'serif', italic: true }),
text('t3', { font: 'sans', italic: true }),
text('t4', { font: 'anton', text: ' ' }),
bookStatsElementSchema.parse({ id: 's', kind: 'stats', frame, font: 'oswald' }) as BookElement,
]);
const used = bookFontsUsed(doc);
expect(used).toEqual(expect.arrayContaining(['caveat', 'serif', 'oswald']));
expect(used).not.toContain('anton');
expect(used.indexOf('serif')).toBeLessThan(used.indexOf('caveat'));
const faces = bookFacesUsed(doc);
expect(faces).toContainEqual({ font: 'caveat', weight: 700, italic: false });
expect(faces).toContainEqual({ font: 'serif', weight: 400, italic: true });
// Poppins has no italic file: its slant is the browser's, so the upright face is what loads.
expect(faces).toContainEqual({ font: 'sans', weight: 400, italic: false });
expect(faces).toContainEqual({ font: 'oswald', weight: 700, italic: false });
expect(faces).toContainEqual({ font: 'oswald', weight: 600, italic: false });
const keys = faces.map((f) => `${f.font}|${f.weight}|${f.italic}`);
expect(new Set(keys).size).toBe(keys.length);
});
});
describe('the assistant reads the same type', () => {
it('BKFONT-009: describes every family with its role and weights in the id the tools take', () => {
const index = bookFontIndex();
for (const id of BOOK_FONTS_IDS) expect(index, id).toContain(id);
expect(index).toMatch(/^display: /);
expect(index).toContain('script: caveat 400-700');
expect(index).toContain('bebas 400 caps');
expect(index).toContain('serif Lora 400-700 i');
expect(index).not.toMatch(/[\u2013\u2014\n]/);
expect(String(bookFontIdSchema.description)).toContain(index);
});
it('BKFONT-010: sets a style or a text change at a weight the family has, and says so in the page description', () => {
const doc = book([text('t1')]);
const styled = run(doc, [{ op: 'style', elementId: 't1', set: { kind: 'text', font: 'anton', weight: 900 } }]);
if (!styled.ok) throw new Error(styled.error.message);
expect(inner(styled.doc).elements[0]).toMatchObject({ font: 'anton', weight: 400 });
const retexted = run(doc, [{ op: 'text', elementId: 't1', font: 'mono', weight: 700 }]);
if (!retexted.ok) throw new Error(retexted.error.message);
expect(inner(retexted.doc).elements[0]).toMatchObject({ font: 'mono', weight: 500 });
const page = describeSpread(retexted.doc, 'sp-in')?.text ?? '';
expect(page).toContain('mono (mono) 11 pt medium');
const light = run(doc, [{ op: 'style', elementId: 't1', set: { kind: 'text', font: 'fraunces', weight: 300 } }]);
if (!light.ok) throw new Error(light.error.message);
expect(describeSpread(light.doc, 'sp-in')?.text).toContain('fraunces (display) 11 pt light');
});
});
+188
View File
@@ -0,0 +1,188 @@
import {
BOOK_FONT_CATEGORIES,
BOOK_FONT_FACES,
BOOK_FONT_ROLES,
BOOK_FONTS_IDS,
BOOK_FONTS_PRECACHED,
BOOK_WEIGHTS,
type BookFontCategory,
type BookFontFace,
type BookFontFamily,
type BookFontRole,
type BookWeight,
} from './book-fonts.data';
import type { BookDocument, BookElement } from './book.schema';
/**
* What the book's typefaces can do, read off the catalogue in
* book-fonts.data.ts: which weights and italics a family really has, what it
* is for, and which faces a book uses. Shared by the Studio, the findings,
* the design engine and the page operations, so the assistant and the editor
* snap a weight the same way.
*/
export {
BOOK_FONT_CATEGORIES,
BOOK_FONT_FACES,
BOOK_FONT_ROLES,
BOOK_FONTS_PRECACHED,
BOOK_WEIGHTS,
type BookFontCategory,
type BookFontFace,
type BookFontRole,
type BookWeight,
};
/** The family behind an id, or undefined for an id this build does not know. */
export function bookFontFace(id: string): BookFontFace | undefined {
return Object.prototype.hasOwnProperty.call(BOOK_FONT_FACES, id) ? BOOK_FONT_FACES[id as BookFontFamily] : undefined;
}
/** Whether `id` is a family of the catalogue. */
export const isBookFontId = (id: string): id is BookFontFamily => bookFontFace(id) !== undefined;
/** Whether a family ships the weight. An unknown family is not second-guessed. */
export function bookFontHasWeight(id: string, weight: number): boolean {
const face = bookFontFace(id);
return face ? (face.weights as readonly number[]).includes(weight) : true;
}
/**
* The nearest weight a family really has, the lighter one on a tie.
*
* A synthesised bold is a smeared regular in print, so a family with one
* weight gets that weight rather than a request the renderer has to fake. An
* unknown family keeps what was asked.
*/
export function nearestBookWeight(id: string, weight: number): number {
const face = bookFontFace(id);
if (!face || (face.weights as readonly number[]).includes(weight)) return weight;
return face.weights.reduce<number>(
(best, w) => (Math.abs(w - weight) < Math.abs(best - weight) ? w : best),
face.weights[0] ?? weight,
);
}
/**
* The lighter weight a travel element sets beside its figures (a unit after
* a number, a caption under it): a step under the weight, never under 400
* unless the weight itself is, and one the family has.
*/
export function bookLighterWeight(id: string, weight: number): number {
return nearestBookWeight(id, Math.min(weight, Math.max(400, weight - 100)));
}
/** Whether a family has a real italic at that weight, rather than the browser's slant. */
export function bookFontHasItalic(id: string, weight: number): boolean {
const face = bookFontFace(id);
return !!face && (face.italics as readonly number[]).includes(weight);
}
/** What a family is for: the first of its roles, `body` for an unknown one. */
export const bookFontRole = (id: string): BookFontRole => bookFontFace(id)?.roles[0] ?? 'body';
/** Whether a family suits a role at all, not only as its first. */
export function bookFontSuits(id: string, role: BookFontRole): boolean {
return bookFontFace(id)?.roles.includes(role) ?? false;
}
/**
* Whether a family counts towards the faces a book is set in. A script laid
* over a title and a mono line on a ticket are ornaments, not a third voice.
*/
export function bookFontCountsInMix(id: string): boolean {
const role = bookFontRole(id);
return role !== 'script' && role !== 'mono';
}
/** The element with its weight moved onto one its family has, when it is set in type at all. */
export function withRealWeight<T extends BookElement>(el: T): T {
if (!('font' in el) || !('weight' in el)) return el;
const weight = nearestBookWeight(el.font, el.weight);
return weight === el.weight ? el : { ...el, weight: weight as BookWeight };
}
/** One face a book is set in: the family, the weight the renderer draws and the slant. */
export interface BookFaceUse {
font: BookFontFamily;
weight: number;
italic: boolean;
}
/** The type-setting elements on a book's pages: everything that names a font, an empty text aside. */
function typeset(doc: BookDocument): Extract<BookElement, { font: string }>[] {
const out: Extract<BookElement, { font: string }>[] = [];
for (const spread of doc.spreads) {
for (const el of spread.elements) {
if ('font' in el && (el.kind !== 'text' || el.text.trim())) out.push(el);
}
}
return out;
}
/** The families a book is set in, page numbers included when shown, in catalogue order. */
export function bookFontsUsed(doc: BookDocument): BookFontFamily[] {
const used = new Set<string>(typeset(doc).map((el) => el.font));
if (doc.page.pageNumbers.show) used.add(doc.page.pageNumbers.font);
return BOOK_FONTS_IDS.filter((id) => used.has(id));
}
/**
* Every face a book needs drawn, each once: what the Studio warms into the
* offline cache when a book opens, so the book still reads in its own type
* without a connection. Travel elements set a lighter weight beside their
* figures, which is why both of those are named.
*/
export function bookFacesUsed(doc: BookDocument): BookFaceUse[] {
const seen = new Map<string, BookFaceUse>();
const add = (font: BookFontFamily, weight: number, italic: boolean) => {
const use = { font, weight: nearestBookWeight(font, weight), italic: italic && bookFontHasItalic(font, weight) };
seen.set(`${use.font}|${use.weight}|${use.italic}`, use);
};
for (const el of typeset(doc)) {
add(el.font, el.weight, el.kind === 'text' && el.italic);
if (el.kind !== 'text') add(el.font, bookLighterWeight(el.font, el.weight), false);
}
if (doc.page.pageNumbers.show) add(doc.page.pageNumbers.font, 500, false);
return [...seen.values()];
}
/** A weight as the assistant reads it, the CSS name beside the number. */
const WEIGHT_WORDS: Record<BookWeight, string> = {
100: 'thin',
200: 'extralight',
300: 'light',
400: 'regular',
500: 'medium',
600: 'semibold',
700: 'bold',
800: 'extrabold',
900: 'black',
};
export const bookWeightWord = (weight: number): string => WEIGHT_WORDS[weight as BookWeight] ?? String(weight);
/**
* The catalogue as one line per role, for the font id's description in the
* tool schemas: an id, its face and the weights it has, an i for a real
* italic, so a model picks a script for an overlay and never asks Anton for
* bold.
*/
export function bookFontIndex(): string {
const span = (ws: readonly number[]) => (ws.length === 1 ? `${ws[0]}` : `${ws[0]}-${ws[ws.length - 1]}`);
return BOOK_FONT_ROLES.map((role) => {
const ids = BOOK_FONTS_IDS.filter((id) => bookFontRole(id) === role);
if (!ids.length) return '';
const listed = ids.map((id) => {
const face = BOOK_FONT_FACES[id];
const italic = face.italics.length ? ' i' : '';
const caps = 'caps' in face ? ' caps' : '';
// The face is named only where the id does not already say it, for the size of the tool schemas.
const named = face.name.toLowerCase().startsWith(id) ? '' : ` ${face.name}`;
return `${id}${named} ${span(face.weights)}${italic}${caps}`;
});
return `${role}: ${listed.join(', ')}.`;
})
.filter(Boolean)
.join(' ');
}
+11 -4
View File
@@ -1,3 +1,4 @@
import { bookFontIndex } from './book-fonts';
import { BOOK_ICON_NAMES } from './book-icons';
import {
BOOK_BADGES,
@@ -37,14 +38,20 @@ const text = bookTextElementSchema.shape;
const shape = bookShapeElementSchema.shape;
const map = bookMapElementSchema.shape;
/** The book's typefaces, with the face each id sets. */
/**
* The book's typefaces by what they are for, each with its face, the weights
* it has and an i where it has a real italic. A weight a family lacks is set
* at its nearest one, as in the Studio.
*/
export const bookFontIdSchema = z
.enum(BOOK_FONTS_IDS)
.describe(
'Typeface: sans Poppins, inter Inter, serif Lora, garamond EB Garamond, playfair Playfair Display, display MuseoModerno, bebas Bebas Neue (caps, one weight).',
);
.describe(`Typeface by role; weights a family lacks snap to its nearest. ${bookFontIndex()}`);
/** The typeface of a travel element, where the full line above would repeat ten times. */
export const bookTypesetFontSchema = z.enum(BOOK_FONTS_IDS).describe('Typeface id, as for text.');
/** How heavy type is set, 100 thin to 900 black. */
export const bookWeightSchema = bookTextElementSchema.shape.weight
.unwrap()
.describe('Weight 100 thin to 900 black, 400 regular, 700 bold; snapped to one the family has.');
/** Every shape the Studio draws, and so every mask a photo can be cut to. */
export const bookShapeIdSchema = z
+7 -9
View File
@@ -1,6 +1,7 @@
import { relayoutSpread } from './auto/auto-book';
import type { AutoEntry, AutoInput } from './auto/auto-types';
import { bookElementPropsSchema, type BOOK_ELEMENT_OP_SCHEMAS, type BookNewElement } from './book-element-ops.schema';
import { withRealWeight } from './book-fonts';
import { bookGridElements, defaultBookGridBox, findBookGrid } from './book-grids';
import {
bookBoundWords,
@@ -13,7 +14,6 @@ import {
unlockedElement,
type BookOpsContext,
} from './book-ops-core';
import { BOOK_PHOTO_LOOK_KEYS } from './book-photo-look.schema';
import {
bookBadgeValue,
bookCountryMark,
@@ -133,28 +133,26 @@ function statsOf(env: ElementOpEnv) {
}
/** The look fields a kind takes, from the props schema, so `add` and `style` set exactly what `style` may set. */
const PHOTO_LOOK_KEYS = new Set<string>(BOOK_PHOTO_LOOK_KEYS);
const PROP_KEYS = new Map(bookElementPropsSchema.options.map((o) => [o.shape.kind.value, lookKeys(o.shape)]));
function lookKeys(shape: object): string[] {
return Object.keys(shape).filter((k) => k !== 'kind');
}
/** The element with the given look fields set; millimetre fields rounded as the document stores them. */
/**
* The element with the given look fields set; millimetre fields rounded as the
* document stores them, and the weight moved onto one its family has, as the
* Studio's weight control does.
*/
function withProps(el: BookElement, props: Record<string, unknown>): BookElement {
const keys = PROP_KEYS.get(el.kind) ?? [];
const next: Record<string, unknown> = { ...el };
for (const key of keys) {
const value = props[key];
if (value === undefined) continue;
// A photo look part set to null is turned off, which leaves it out of the document.
if (value === null && PHOTO_LOOK_KEYS.has(key)) {
delete next[key];
continue;
}
next[key] = (key === 'radius' || key === 'strokeWidth') && typeof value === 'number' ? round2(value) : value;
}
return next as unknown as BookElement;
return withRealWeight(next as unknown as BookElement);
}
// ── add ────────────────────────────────────────────────────────────────────
-1
View File
@@ -403,7 +403,6 @@ export const BOOK_FINDING_CODES = [
'font_mix',
'palette_drift',
'repeat_layout',
'raster_effect',
] as const;
export type BookFindingCode = (typeof BOOK_FINDING_CODES)[number];
+5 -2
View File
@@ -1,3 +1,4 @@
import { withRealWeight } from './book-fonts';
import { bookGridElements, defaultBookGridBox, findBookGrid, type BookGridDef } from './book-grids';
import { bookElementId } from './book-ids';
import { BOOK_TEXT_STYLES, applyBookTemplate, classifyBookTexts, findBookTemplate } from './book-layouts';
@@ -413,9 +414,11 @@ function opText(spread: BookSpread, op: Op<'text'>, env: Env): BookSpread {
op.elementId !== undefined
? textEl(spread, op.elementId, env)
: classifyBookTexts(roleTexts(spread))[op.role ?? 'body'];
if (existing && existing.kind === 'text') return replaceEl(spread, retext(unlocked(existing, env), op, env));
// The weight lands on one the family has, whichever of the two a change names.
if (existing && existing.kind === 'text')
return replaceEl(spread, withRealWeight(retext(unlocked(existing, env), op, env)));
withRoom(spread, 1, env);
return { ...spread, elements: [...spread.elements, newText(spread, op, env)] };
return { ...spread, elements: [...spread.elements, withRealWeight(newText(spread, op, env))] };
}
function retext(el: BookTextElement, op: Op<'text'>, env: Env): BookTextElement {
-104
View File
@@ -1,104 +0,0 @@
import { z } from 'zod';
/**
* The photo look: what a picture wears beyond its crop, mask and frame style.
*
* - `border`: a band of one colour along the inside of the frame, `width`
* millimetres deep, following the mask when the picture is cut to a shape
* (the 3 to 6 mm white border of a postcard print).
* - `shadow`: a hard copy of the picture's outline in one colour, offset by
* `dx`/`dy` millimetres and drawn behind it at `alpha`. No blur, so it prints
* as vector like everything else on the page.
* - `zoom`: 1 to 4 times the crop the fit gives, centred on the focal point,
* so the focal point keeps meaning what it meant.
* - `flipX`: the picture mirrored left to right inside its frame; the frame,
* the mask and the border stay as they are.
* - `adjust`: brightness, contrast, saturation and warmth, each -1 to 1 with 0
* as shot. Drawn as filters, so the picture prints as a raster.
* - `duotone`: the picture's tones mapped from `dark` to `light`, also a
* filter and also raster in print.
* - `cutout`: a die-cut edge, the outline of the mask (or of the rounded
* rectangle) grown by `width` millimetres and filled behind the picture: the
* sticker look.
*
* Every field is optional and null means off, so a document from before them
* reads as it always did and an older reader that drops them still draws the
* picture. A value the reader cannot read falls back to off rather than
* failing the photo or its page.
*/
const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'expected #rrggbb');
/** The zoom range, against the crop the fit gives on its own. */
export const PHOTO_ZOOM = { min: 1, max: 4 } as const;
/** How deep a border or a cutout may be, in millimetres. */
export const PHOTO_EDGE_MAX = 30;
/** How far a hard shadow may fall, in millimetres either way. */
export const PHOTO_SHADOW_REACH = 30;
type ObjectFactory = typeof z.object | typeof z.strictObject;
/**
* The parts, built once for the document (plain objects, which drop a field
* they do not know, as the rest of the document does) and once for the
* operations (strict, as every operation is), so the two cannot drift.
*/
function lookSchemas(obj: ObjectFactory) {
const edge = obj({ width: z.number().min(0.1).max(PHOTO_EDGE_MAX), color: hex });
const reach = z.number().finite().min(-PHOTO_SHADOW_REACH).max(PHOTO_SHADOW_REACH);
const signed = z.number().min(-1).max(1).optional();
return {
edge,
shadow: obj({ dx: reach, dy: reach, color: hex, alpha: z.number().min(0).max(1) }),
zoom: z.number().min(PHOTO_ZOOM.min).max(PHOTO_ZOOM.max),
adjust: obj({ brightness: signed, contrast: signed, saturation: signed, warmth: signed }),
duotone: obj({ dark: hex, light: hex }),
};
}
const doc = lookSchemas(z.object);
export const bookPhotoEdgeSchema = doc.edge;
export const bookPhotoShadowSchema = doc.shadow;
export const bookPhotoAdjustSchema = doc.adjust;
export const bookPhotoDuotoneSchema = doc.duotone;
export type BookPhotoEdge = z.infer<typeof bookPhotoEdgeSchema>;
export type BookPhotoShadow = z.infer<typeof bookPhotoShadowSchema>;
export type BookPhotoAdjust = z.infer<typeof bookPhotoAdjustSchema>;
export type BookPhotoDuotone = z.infer<typeof bookPhotoDuotoneSchema>;
/** Optional, null for off, and off again when the stored value cannot be read. */
const field = <T extends z.ZodType>(schema: T) => schema.nullable().optional().catch(undefined);
/** The fields a photo element carries, spread into its schema in book.schema.ts. */
export const BOOK_PHOTO_LOOK_FIELDS = {
border: field(doc.edge),
shadow: field(doc.shadow),
zoom: doc.zoom.optional().catch(undefined),
flipX: z.boolean().optional().catch(undefined),
adjust: field(doc.adjust),
duotone: field(doc.duotone),
cutout: field(doc.edge),
};
/** The names of the look fields, for the operations that take any of them. */
export const BOOK_PHOTO_LOOK_KEYS = Object.keys(BOOK_PHOTO_LOOK_FIELDS) as (keyof typeof BOOK_PHOTO_LOOK_FIELDS)[];
const ops = lookSchemas(z.strictObject);
/**
* The look fields as the `style` operation takes them. Short descriptions on
* purpose: the page edit's tool schema is held under a size ceiling.
*/
export const BOOK_PHOTO_LOOK_PROPS = {
border: ops.edge.nullable().optional().describe('A band inside the frame edge, width mm; null removes it.'),
shadow: ops.shadow.nullable().optional().describe('A hard offset shadow, dx/dy mm, alpha 0..1; null removes it.'),
zoom: ops.zoom.optional().describe('1 to 4 times the crop, about the focal point.'),
flipX: z.boolean().optional().describe('Mirror the picture left to right.'),
adjust: ops.adjust
.nullable()
.optional()
.describe('Each -1..1, 0 or left out is as shot; prints as raster. null resets.'),
duotone: ops.duotone.nullable().optional().describe('Tones mapped from dark to light; prints as raster.'),
cutout: ops.edge.nullable().optional().describe('A die-cut edge grown width mm around the shape, behind the photo.'),
};
-247
View File
@@ -1,247 +0,0 @@
import { photoDpi, spreadFindings } from './book-findings';
import { bookBusyUnder, bookPhotoCrop, bookVisiblePart } from './book-findings-design';
import { photoLookChecks } from './book-findings-photo';
import { normalizeBookDocument } from './book-normalize';
import { applyBookOps } from './book-ops';
import { bookPageOpSchema } from './book-ops.schema';
import { blankBook } from './book-pages';
import {
PHOTO_LOOK_STARTS,
photoAdjustOf,
photoLookIsRaster,
photoLookOf,
photoLookWords,
photoZoomBox,
photoZoomOf,
} from './book-photo-look';
import { BOOK_PHOTO_LOOK_KEYS, PHOTO_ZOOM } from './book-photo-look.schema';
import {
bookPageSetupSchema,
bookPhotoElementSchema,
type BookDocument,
type BookPhotoElement,
type BookSpread,
} from './book.schema';
import { describeSpread } from './describe-spread';
import { describe, expect, it } from 'vitest';
/**
* The photo look: border, hard shadow, zoom, mirror, adjustments, duotone and
* cutout. How the document reads them (and what it does with one it cannot
* read), how the findings and the words for a model take them into account,
* and how the page edit sets and clears them.
*/
const frame = { x: 230, y: 20, w: 80, h: 60 };
const photo = (over: Record<string, unknown> = {}): BookPhotoElement =>
bookPhotoElementSchema.parse({ id: 'p1', kind: 'photo', frame, photoId: 1, ...over });
const page = bookPageSetupSchema.parse({});
const info = { photoId: 1, width: 4000, height: 3000 };
function book(elements: BookPhotoElement[]): BookDocument {
const doc = blankBook({ title: 'Iceland', newId: (p) => `${p}-x` });
const spreads = doc.spreads.map((s, i) => ({ ...s, id: i === 2 ? 'sp-in' : `sp-${i}` }));
spreads[2] = { ...(spreads[2] as BookSpread), elements };
return { ...doc, spreads };
}
const inner = (doc: BookDocument) => doc.spreads[2] as BookSpread;
const style = (doc: BookDocument, set: Record<string, unknown>) =>
applyBookOps(
doc,
{ spreadId: 'sp-in', ops: [{ op: 'style', elementId: 'p1', set: { kind: 'photo', ...set } }] },
{
newId: (p) => `${p}-n`,
photos: [info],
},
);
describe('the photo look in the document', () => {
it('BKLOOK-001: a photo from before the look reads with every part off', () => {
const plain = photo();
for (const key of BOOK_PHOTO_LOOK_KEYS) expect(plain[key]).toBeUndefined();
expect(photoLookOf(plain)).toEqual({
border: null,
shadow: null,
zoom: 1,
flipX: false,
adjust: null,
duotone: null,
cutout: null,
});
expect(photoLookIsRaster(plain)).toBe(false);
expect(photoLookWords(plain)).toEqual([]);
});
it('BKLOOK-002: keeps every part it can read, and turns off a part it cannot rather than losing the photo', () => {
const look = {
border: { width: 4, color: '#ffffff' },
shadow: { dx: 2, dy: -1.5, color: '#1a1a1a', alpha: 0.4 },
zoom: 1.8,
flipX: true,
adjust: { brightness: 0.2, warmth: -0.3 },
duotone: { dark: '#1e3a8a', light: '#f5e6c8' },
cutout: { width: 3, color: '#ffeedd' },
};
expect(photo(look)).toMatchObject(look);
const odd = photo({
border: { width: 900, color: '#fff' },
zoom: 9,
flipX: 'yes',
adjust: { brightness: 4 },
cutout: 'thick',
duotone: null,
});
expect(odd.border).toBeUndefined();
expect(odd.zoom).toBeUndefined();
expect(odd.flipX).toBeUndefined();
expect(odd.adjust).toBeUndefined();
expect(odd.cutout).toBeUndefined();
expect(odd.duotone).toBeNull();
expect(odd.photoId).toBe(1);
});
it('BKLOOK-003: a stored book with a look survives normalising whole', () => {
const doc = book([photo({ border: { width: 5, color: '#ffffff' }, zoom: 2 })]);
const read = normalizeBookDocument(JSON.parse(JSON.stringify(doc)));
expect(inner(read).elements[0]).toMatchObject({ border: { width: 5, color: '#ffffff' }, zoom: 2 });
});
});
describe('reading the look', () => {
it('BKLOOK-004: an adjustment that moves nothing is none, and a partial one names every part', () => {
expect(photoAdjustOf(undefined)).toBeNull();
expect(photoAdjustOf({ brightness: 0, contrast: 0 })).toBeNull();
expect(photoAdjustOf({ contrast: 0.5 })).toEqual({ brightness: 0, contrast: 0.5, saturation: 0, warmth: 0 });
expect(photoLookIsRaster(photo({ adjust: { saturation: -1 } }))).toBe(true);
expect(photoLookIsRaster(photo({ duotone: PHOTO_LOOK_STARTS.duotone }))).toBe(true);
expect(photoLookIsRaster(photo({ border: PHOTO_LOOK_STARTS.border, cutout: PHOTO_LOOK_STARTS.cutout }))).toBe(
false,
);
});
it('BKLOOK-005: the zoom stays inside its range, and the zoom box keeps the focal point where it was', () => {
expect(photoZoomOf({ zoom: undefined })).toBe(PHOTO_ZOOM.min);
expect(photoZoomOf({ zoom: Number.NaN })).toBe(PHOTO_ZOOM.min);
expect(photoZoomOf({ zoom: 2.5 })).toBe(2.5);
expect(photoZoomOf({ zoom: 12 })).toBe(PHOTO_ZOOM.max);
expect(photoZoomBox(frame, 1, 0.3, 0.7)).toBe(frame);
const box = photoZoomBox(frame, 2, 0.25, 0.75);
expect(box).toEqual({ x: 210, y: -25, w: 160, h: 120 });
// The focal point, as a fraction of the zoomed box, lands on the same place of the frame.
expect(box.x + box.w * 0.25).toBe(frame.x + frame.w * 0.25);
expect(box.y + box.h * 0.75).toBe(frame.y + frame.h * 0.75);
});
it('BKLOOK-006: says every part a photo wears, with the filters marked as raster in print', () => {
const words = photoLookWords(
photo({
border: { width: 4, color: '#ffffff' },
shadow: { dx: 2, dy: 2.04, color: '#1a1a1a', alpha: 0.35 },
zoom: 1.5,
flipX: true,
adjust: { brightness: 0.2, contrast: -0.1 },
duotone: { dark: '#000000', light: '#ffffff' },
cutout: { width: 3, color: '#ffffff' },
}),
);
expect(words).toEqual([
'border 4 mm #ffffff',
'hard shadow 2/2 mm #1a1a1a at 35%',
'zoom x1.5',
'mirrored',
'adjusted brightness +0.2 contrast -0.1 (raster in print)',
'duotone #000000 to #ffffff (raster in print)',
'cutout 3 mm #ffffff',
]);
});
});
describe('the findings with a look', () => {
it('BKLOOK-007: a zoomed picture prints at the resolution its zoom leaves it', () => {
const plain = photoDpi(photo(), info);
const zoomed = photoDpi(photo({ zoom: 2 }), info);
expect(plain).toBe(1270);
expect(zoomed).toBe(635);
const big = photo({ frame: { x: 0, y: 0, w: 400, h: 300 }, zoom: 2 });
expect(spreadFindings({ ...inner(book([big])) }, page, [info]).map((f) => f.code)).toContain('low_dpi');
});
it('BKLOOK-008: the crop and the visible part follow the zoom, and a mirrored picture reads its busy grid mirrored', () => {
const zoomed = photo({ zoom: 2, focalX: 0, focalY: 0 });
expect(bookPhotoCrop(zoomed, info)).toEqual({ x: 230, y: 20, w: 160, h: 120 });
expect(bookVisiblePart(zoomed, info)).toEqual({ x: 0, y: 0, w: 0.5, h: 0.5 });
// Busy in the left quarter of the picture, calm everywhere else.
const busy = 'ff000000'.repeat(8);
const left = { x: 230, y: 20, w: 15, h: 60 };
const square = { photoId: 1, width: 3000, height: 3000, busy };
const sq = { frame: { x: 230, y: 20, w: 60, h: 60 } };
expect(bookBusyUnder(photo(sq), square, left)).toBe(15);
expect(bookBusyUnder(photo({ ...sq, flipX: true }), square, left)).toBe(0);
});
it('BKLOOK-009: a filtered picture over a quarter of a page is worth a word, a small one and an unfiltered one are not', () => {
const large = photo({ frame: { x: 230, y: 20, w: 150, h: 150 }, adjust: { contrast: 0.3 } });
const both = photo({
id: 'p2',
frame: { x: 0, y: 0, w: 210, h: 210 },
adjust: { warmth: 1 },
duotone: PHOTO_LOOK_STARTS.duotone,
});
const small = photo({ id: 'p3', adjust: { contrast: 0.3 } });
const plain = photo({ id: 'p4', frame: { x: 0, y: 0, w: 210, h: 210 } });
const empty = photo({ id: 'p5', photoId: null, frame: { x: 0, y: 0, w: 210, h: 210 }, adjust: { contrast: 1 } });
const spread = inner(book([large, both, small, plain, empty]));
const found = photoLookChecks(spread, page);
expect(found.map((f) => f.elementIds[0])).toEqual(['p1', 'p2']);
expect(found[0]).toMatchObject({ code: 'raster_effect', severity: 'info' });
expect(found[1]?.message).toContain('adjustments and a duotone');
expect(found.every((f) => f.message.length <= 300)).toBe(true);
expect(photoLookChecks(spread, { ...page, pageWidth: 0 })).toEqual([]);
expect(spreadFindings(inner(book([large])), page).map((f) => f.code)).toContain('raster_effect');
});
it('BKLOOK-010: describe-spread lists the look beside the crop', () => {
const doc = book([photo({ border: { width: 4, color: '#ffffff' }, flipX: true })]);
const text = describeSpread(doc, 'sp-in', [info])?.text ?? '';
expect(text).toContain('border 4 mm #ffffff');
expect(text).toContain('mirrored');
});
});
describe('setting the look from the page edit', () => {
it('BKLOOK-011: style sets every part, and null turns a part off by leaving it out', () => {
const set = {
border: { width: 6, color: '#ffffff' },
shadow: { dx: 3, dy: 3, color: '#000000', alpha: 0.5 },
zoom: 1.4,
flipX: true,
adjust: { brightness: 0.1 },
duotone: { dark: '#112233', light: '#ddeeff' },
cutout: { width: 2, color: '#ffffff' },
};
const result = style(book([photo()]), set);
if (!result.ok) throw new Error(result.error.message);
expect(inner(result.doc).elements[0]).toMatchObject(set);
const off = style(result.doc, { border: null, shadow: null, adjust: null, duotone: null, cutout: null });
if (!off.ok) throw new Error(off.error.message);
const el = inner(off.doc).elements[0] as BookPhotoElement;
for (const key of ['border', 'shadow', 'adjust', 'duotone', 'cutout']) expect(key in el).toBe(false);
expect(el).toMatchObject({ zoom: 1.4, flipX: true });
});
it('BKLOOK-012: the page edit refuses a look outside the format', () => {
const op = (set: Record<string, unknown>) =>
bookPageOpSchema.safeParse({ op: 'style', elementId: 'p1', set: { kind: 'photo', ...set } }).success;
expect(op({ zoom: 2 })).toBe(true);
expect(op({ zoom: 5 })).toBe(false);
expect(op({ border: { width: 40, color: '#ffffff' } })).toBe(false);
expect(op({ shadow: { dx: 2, dy: 2, color: '#000000', alpha: 2 } })).toBe(false);
expect(op({ adjust: { brightness: 0.5, glow: 1 } })).toBe(false);
expect(op({ duotone: { dark: 'navy', light: '#ffffff' } })).toBe(false);
// The look is styled onto a frame that exists, so a new one takes none of it.
const add = { op: 'add', element: { kind: 'photo', zoom: 2 } };
expect(bookPageOpSchema.safeParse(add).success).toBe(false);
});
});
-138
View File
@@ -1,138 +0,0 @@
import {
BOOK_PHOTO_LOOK_KEYS,
PHOTO_ZOOM,
type BookPhotoAdjust,
type BookPhotoDuotone,
type BookPhotoEdge,
type BookPhotoShadow,
} from './book-photo-look.schema';
import type { BookFrame, BookPhotoElement } from './book.schema';
/**
* Reading the photo look (book-photo-look.schema.ts) the way the renderer,
* the findings and the words for a model all read it: off where a field is
* missing or null, an adjustment that changes nothing counted as none, the
* zoom clamped to what the format allows.
*/
/** The four adjustments, in the order the inspector lists them. */
export const PHOTO_ADJUSTMENTS = ['brightness', 'contrast', 'saturation', 'warmth'] as const;
export type PhotoAdjustment = (typeof PHOTO_ADJUSTMENTS)[number];
/** The look of one photo, every part resolved. */
export interface PhotoLook {
border: BookPhotoEdge | null;
shadow: BookPhotoShadow | null;
zoom: number;
flipX: boolean;
/** Null when every adjustment is 0. */
adjust: Required<BookPhotoAdjust> | null;
duotone: BookPhotoDuotone | null;
cutout: BookPhotoEdge | null;
}
type LookSource = Pick<BookPhotoElement, (typeof BOOK_PHOTO_LOOK_KEYS)[number]>;
/** The adjustments with every one named, or null when none of them moves the picture. */
export function photoAdjustOf(adjust: BookPhotoAdjust | null | undefined): Required<BookPhotoAdjust> | null {
if (!adjust) return null;
const full = {
brightness: adjust.brightness ?? 0,
contrast: adjust.contrast ?? 0,
saturation: adjust.saturation ?? 0,
warmth: adjust.warmth ?? 0,
};
return PHOTO_ADJUSTMENTS.some((k) => full[k] !== 0) ? full : null;
}
/** The zoom a photo is drawn at: 1 when unset, inside the format's range otherwise. */
export function photoZoomOf(el: Pick<BookPhotoElement, 'zoom'>): number {
const z = el.zoom;
if (typeof z !== 'number' || !Number.isFinite(z)) return PHOTO_ZOOM.min;
return Math.min(PHOTO_ZOOM.max, Math.max(PHOTO_ZOOM.min, z));
}
export function photoLookOf(el: LookSource): PhotoLook {
return {
border: el.border ?? null,
shadow: el.shadow ?? null,
zoom: photoZoomOf(el),
flipX: el.flipX === true,
adjust: photoAdjustOf(el.adjust),
duotone: el.duotone ?? null,
cutout: el.cutout ?? null,
};
}
/** Whether the look draws the picture through a filter, which prints it as a raster. */
export function photoLookIsRaster(el: LookSource): boolean {
const look = photoLookOf(el);
return look.adjust !== null || look.duotone !== null;
}
/**
* The box the picture is fitted into, in the frame's own coordinates: the
* frame itself at zoom 1, and at zoom z a box z times its size placed so the
* focal point sits where it sat. That is what keeps a focal point meaning the
* same part of the picture at every zoom.
*/
export function photoZoomBox(frame: BookFrame, zoom: number, focalX: number, focalY: number): BookFrame {
if (zoom <= 1) return frame;
return {
x: frame.x - (zoom - 1) * frame.w * focalX,
y: frame.y - (zoom - 1) * frame.h * focalY,
w: frame.w * zoom,
h: frame.h * zoom,
};
}
const n = (v: number) => `${Math.round(v * 10) / 10}`;
const signed = (v: number) => `${v > 0 ? '+' : ''}${Math.round(v * 100) / 100}`;
/** The look in words, for a model reading a page; empty when the photo wears none of it. */
export function photoLookWords(el: LookSource): string[] {
const look = photoLookOf(el);
const parts: string[] = [];
if (look.border) parts.push(`border ${n(look.border.width)} mm ${look.border.color}`);
if (look.shadow) {
const s = look.shadow;
parts.push(`hard shadow ${n(s.dx)}/${n(s.dy)} mm ${s.color} at ${Math.round(s.alpha * 100)}%`);
}
if (look.zoom > 1) parts.push(`zoom x${Math.round(look.zoom * 100) / 100}`);
if (look.flipX) parts.push('mirrored');
if (look.adjust) {
const a = look.adjust;
const moved = PHOTO_ADJUSTMENTS.filter((k) => a[k] !== 0).map((k) => `${k} ${signed(a[k])}`);
parts.push(`adjusted ${moved.join(' ')} (raster in print)`);
}
if (look.duotone) parts.push(`duotone ${look.duotone.dark} to ${look.duotone.light} (raster in print)`);
if (look.cutout) parts.push(`cutout ${n(look.cutout.width)} mm ${look.cutout.color}`);
return parts;
}
/**
* Where a part of the look starts when someone turns it on: a white print
* border, a soft-black shadow down and to the right, a white die-cut edge,
* and a navy to sand duotone. Print ink, not app chrome, which is why they
* live here rather than in the theme.
*/
export const PHOTO_LOOK_STARTS = {
border: { width: 4, color: '#ffffff' },
shadow: { dx: 2, dy: 2, color: '#1a1a1a', alpha: 0.35 },
cutout: { width: 3, color: '#ffffff' },
duotone: { dark: '#1e3a8a', light: '#f5e6c8' },
} as const satisfies {
border: BookPhotoEdge;
shadow: BookPhotoShadow;
cutout: BookPhotoEdge;
duotone: BookPhotoDuotone;
};
/** The duotones the inspector offers in one press, dark ink first: navy on sand, ink on paper, plum, forest, wine. */
export const PHOTO_DUOTONES = [
{ dark: '#1e3a8a', light: '#f5e6c8' },
{ dark: '#141414', light: '#f4efe6' },
{ dark: '#4c1d95', light: '#fbcfb8' },
{ dark: '#0f3d2e', light: '#e6efd6' },
{ dark: '#9f1239', light: '#fde68a' },
] as const satisfies readonly BookPhotoDuotone[];
+4 -3
View File
@@ -20,7 +20,8 @@ import { describe, expect, it } from 'vitest';
*/
const frame = { x: 12, y: 18, w: 60, h: 40 };
/** A revision newer than this build reads. */
/** A revision one past what this build reads. */
const NEWER = BOOK_SCHEMA_REV + 1;
const doc = (over: Record<string, unknown> = {}) => ({
@@ -42,6 +43,7 @@ const doc = (over: Record<string, unknown> = {}) => ({
describe('the revision a document was written by', () => {
it('BKREV-001: reads 1 for every document from before the field, and for anything that is not a whole number', () => {
// Raised for the first time by the wider type: new families and weights an older reader drops.
expect(BOOK_SCHEMA_REV).toBeGreaterThanOrEqual(2);
expect(bookSchemaRev(doc())).toBe(1);
expect(bookSchemaRev(null)).toBe(1);
@@ -58,7 +60,7 @@ describe('the revision a document was written by', () => {
expect(odd.success && odd.data.spreads[0]?.elements).toHaveLength(1);
});
it('BKREV-003: a writer of revision 1 leaves a book as it was, and this build stamps its own revision', () => {
it('BKREV-003: a writer at revision 1 leaves a book as it was, with no revision written into it', () => {
const today = normalizeBookDocument(doc());
expect('schemaRev' in today).toBe(false);
expect(stampBookSchemaRev(today, 1)).toBe(today);
@@ -70,7 +72,6 @@ describe('the revision a document was written by', () => {
describe('a reader older than the document', () => {
it('BKREV-004: knows the document is newer than it, read from the raw value even when the rest will not parse', () => {
expect(isBookNewerThanReader(doc({ schemaRev: NEWER }))).toBe(true);
expect(isBookNewerThanReader(doc({ schemaRev: BOOK_SCHEMA_REV }))).toBe(false);
expect(isBookNewerThanReader(doc({ schemaRev: 1 }))).toBe(false);
expect(isBookNewerThanReader(doc())).toBe(false);
// A reader of a later revision reads the same document as its own.
-5
View File
@@ -28,11 +28,6 @@ import type { BookDocument } from './book.schema';
* saves only once that is above 1, so a book saved today stores exactly what
* it stored before this existed.
*/
/**
* The revisions so far:
*
* - 2: the photo look (`border`, `shadow`, `zoom`, `flipX`, `adjust`, `duotone`, `cutout`, book-photo-look.schema.ts).
*/
export const BOOK_SCHEMA_REV = 2;
/** The `code` a refused save carries in its body when the stored book is newer than the writer. */
+9 -11
View File
@@ -1,4 +1,4 @@
import { BOOK_PHOTO_LOOK_FIELDS } from './book-photo-look.schema';
import { BOOK_FONTS_IDS, BOOK_WEIGHTS, type BookFontFamily } from './book-fonts.data';
import { z } from 'zod';
@@ -159,15 +159,15 @@ export const BOOK_SHAPES = [
export type BookShapeId = (typeof BOOK_SHAPES)[number];
/**
* The families a book may be set in.
* The families a book may be set in, and the weights type is set at. The
* catalogue is book-fonts.data.ts: which face each id names, its weights, its
* real italics and what it is for.
*
* `sans`, `serif` and `display` are the three the format started with and are
* kept by name, so every document written so far still parses — they now point
* at bundled faces rather than at whatever the rendering machine happened to
* have installed. See client/src/components/Studio/bookFonts.ts.
* kept by name, so every document written so far still parses.
*/
export const BOOK_FONTS_IDS = ['sans', 'inter', 'serif', 'garamond', 'playfair', 'display', 'bebas'] as const;
export type BookFontFamily = (typeof BOOK_FONTS_IDS)[number];
export { BOOK_FONTS_IDS, type BookFontFamily };
const bookWeightSchema = z.literal(BOOK_WEIGHTS);
export const bookFrameSchema = z.object({
/** Millimetres from the left edge of the spread. Negative means bleed. */
@@ -223,8 +223,6 @@ export const bookPhotoElementSchema = z.object({
* and it is the one thing you cannot express with either.
*/
frameStyle: z.enum(['none', 'polaroid', 'white', 'shadow', 'film', 'tape']).default('none'),
/** Border, hard shadow, zoom, mirror, adjustments, duotone and cutout (book-photo-look.schema.ts). */
...BOOK_PHOTO_LOOK_FIELDS,
});
export const bookTextElementSchema = z.object({
@@ -234,7 +232,7 @@ export const bookTextElementSchema = z.object({
font: z.enum(BOOK_FONTS_IDS).default('sans'),
/** Points. */
size: z.number().min(4).max(200).default(11),
weight: z.union([z.literal(400), z.literal(500), z.literal(600), z.literal(700)]).default(400),
weight: bookWeightSchema.default(400),
italic: z.boolean().default(false),
align: z.enum(['left', 'center', 'right', 'justify']).default('left'),
/** Multiple of the font size. */
@@ -366,7 +364,7 @@ const typeset = {
* together, the way `textScale` moves their size. Without it the only way to
* lighten a stats block was to not use one.
*/
weight: z.union([z.literal(400), z.literal(500), z.literal(600), z.literal(700)]).default(700),
weight: bookWeightSchema.default(700),
/** Set when the journey has moved on since these values were taken. */
stale: z.boolean().default(false),
};
+5 -4
View File
@@ -1,5 +1,6 @@
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 { BOOK_COVER_TEMPLATES, BOOK_TEMPLATES, type BookTemplate } from './book-layouts';
import type { BookPhotoInfo } from './book-ops-core';
@@ -10,7 +11,6 @@ import type {
BookPageDescription,
BookSpreadSummary,
} from './book-ops.schema';
import { photoLookWords } from './book-photo-look';
import {
bookPageSetupSchema,
type BookDocument,
@@ -75,7 +75,6 @@ function photoWords(el: BookPhotoElement, info: BookPhotoInfo | undefined): stri
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)}`);
parts.push(...photoLookWords(el));
return parts.join(', ');
}
@@ -87,7 +86,9 @@ function elementWords(el: BookElement, photos: readonly BookPhotoInfo[] | undefi
photos?.find((p) => p.photoId === el.photoId),
);
case 'text': {
const style = `${el.font} ${n(el.size)} pt${el.weight >= 600 ? ' bold' : ''}${el.italic ? ' italic' : ''}, ${el.align}, ${el.color}`;
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}`;
}
@@ -112,7 +113,7 @@ type TravelElement = Extract<BookElement, { kind: 'map' | 'stats' | 'countries'
/** 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} ${el.weight >= 600 ? 'bold' : 'regular'}, ink ${el.color}, accent ${el.accent}, type x${n(el.textScale)}`;
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}`;
}
+4 -1
View File
@@ -1,6 +1,7 @@
import { buildBook, distributeGallery } from '../auto/auto-book';
import type { AutoEntry, AutoInput, AutoPhoto } from '../auto/auto-types';
import { bookLevelFindings, sortBookFindings } from '../book-findings-design';
import { withRealWeight } from '../book-fonts';
import { bookElementId } from '../book-ids';
import type { BookPhotoInfo } from '../book-ops-core';
import type { BookFinding } from '../book-ops.schema';
@@ -252,7 +253,9 @@ export function designBook(
const repairCtx = { page, photos: input.photos, look, newId };
const designed = [...framing, ...inner.spreads, ...closing].map((d): DesignedSpread => {
const fixed = repairSpread(d.spread, repairCtx);
return { ...d, spread: fixed.spread, findings: fixed.findings };
// Every face at a weight its family has: a travel element's bold in a one-weight poster face would be faked.
const spread = { ...fixed.spread, elements: fixed.spread.elements.map(withRealWeight) };
return { ...d, spread, findings: fixed.findings };
});
const merged = mergeDesign(doc, designed, directives.keep);
+6 -3
View File
@@ -1,5 +1,6 @@
import type { AutoEntry, AutoPhoto } from '../auto/auto-types';
import { DESIGN_TEXT_HEADROOM, MIN_PRINT_DPI, textHeightNeeded } from '../book-findings';
import { nearestBookWeight } from '../book-fonts';
import type { BookPhotoInfo } from '../book-ops-core';
import { formatBookDate } from '../book-text';
import { bookBadgeValue, bookTravelBadge, type BookTravelContext } from '../book-travel';
@@ -158,6 +159,7 @@ type TextAt = Pick<BookFrame, 'x' | 'y' | 'w'>;
/** A text in the book's look, its frame as tall as its words need. */
export function textEl(c: ComposeCtx, at: TextAt, words: string, role: TextRole, opts: TextOpts = {}): BookTextElement {
const defaultColour = role === 'caption' || role === 'meta' ? c.look.muted : c.look.ink;
const font = role === 'heading' || role === 'sub' ? c.look.heading : c.look.body;
const el: BookTextElement = {
id: c.newId('t'),
kind: 'text',
@@ -166,9 +168,10 @@ export function textEl(c: ComposeCtx, at: TextAt, words: string, role: TextRole,
opacity: 1,
locked: false,
text: words,
font: role === 'heading' || role === 'sub' ? c.look.heading : c.look.body,
font,
size: opts.size ?? c.look.sizes[role],
weight: WEIGHT[role],
// A family that lacks the role's weight is set at its nearest, never at a synthesised one.
weight: nearestBookWeight(font, WEIGHT[role]) as BookTextElement['weight'],
italic: false,
align: opts.align ?? 'left',
leading: LEADING[role],
@@ -258,7 +261,7 @@ export function titleBlock(
if (value) {
const chip = frameOf(box.x, y, scaled(c, 24), scaled(c, 7));
const badge = bookTravelBadge({ id: c.newId('bd'), frame: chip, variant: 'day', value });
els.push({ ...badge, font: c.look.heading, accent: c.look.accent });
els.push({ ...badge, font: c.look.figures, accent: c.look.accent });
y += chip.h + 4;
}
}
+3 -3
View File
@@ -161,7 +161,7 @@ export function composeSummary(
const textSide = mapSide === 'left' ? 'right' : 'left';
const els: BookElement[] = [];
const map = bookTravelMap(c.travel, stats, { id: c.newId('mp'), frame: liveBox(c, mapSide), scope: 'journey' });
els.push({ ...map, style: c.look.mapStyle, font: c.look.heading, color: c.look.ink, accent: c.look.accent });
els.push({ ...map, style: c.look.mapStyle, font: c.look.figures, color: c.look.ink, accent: c.look.accent });
const head = label(c, words, textSide);
els.push(...head.els);
const metrics = metricsOf(c, stats);
@@ -169,7 +169,7 @@ export function composeSummary(
const box = liveBox(c, textSide);
const frame = frameOf(box.x, head.bottom, box.w, Math.min(box.h * 0.6, box.y + box.h - head.bottom));
const figures = bookTravelStats(stats, { id: c.newId('st'), frame, metrics });
els.push({ ...figures, font: c.look.heading, color: c.look.ink, accent: c.look.accent });
els.push({ ...figures, font: c.look.figures, color: c.look.ink, accent: c.look.accent });
}
return els;
}
@@ -179,7 +179,7 @@ export function composeCountries(c: ComposeCtx, words: string): BookElement[] {
const head = label(c, words, 'left');
const box = liveBox(c, 'right');
const countries = bookTravelCountries(c.travel, { id: c.newId('co'), frame: box });
return [...head.els, { ...countries, font: c.look.heading, color: c.look.ink, accent: c.look.accent }];
return [...head.els, { ...countries, font: c.look.figures, color: c.look.ink, accent: c.look.accent }];
}
/**
+2 -2
View File
@@ -146,7 +146,7 @@ function listUnder(c: ComposeCtx, entry: AutoEntry, foot: Foot): BookElement | n
if (foot.top + h > foot.bottom) return null;
const labels = { pros: c.travel.labels.pros, cons: c.travel.labels.cons };
const list = bookTravelList(labels, { id: c.newId('li'), frame: frameOf(foot.x, foot.top, foot.w, h), items });
return { ...list, font: c.look.heading, color: c.look.ink, accent: c.look.accent };
return { ...list, font: c.look.figures, color: c.look.ink, accent: c.look.accent };
}
/**
@@ -293,7 +293,7 @@ function chapterMap(c: ComposeCtx, entry: AutoEntry, box: BookFrame): BookElemen
const h = box.h * 0.3;
const frame = frameOf(box.x, box.y + box.h - h, box.w, h);
const map = bookTravelMap(c.travel, stats, { id: c.newId('mp'), frame, scope: { tripId } });
const look = { style: c.look.mapStyle, font: c.look.heading, color: c.look.ink, accent: c.look.accent };
const look = { style: c.look.mapStyle, font: c.look.figures, color: c.look.ink, accent: c.look.accent };
return { ...map, ...look, pinStyle: 'dot' };
}
+28 -1
View File
@@ -1,3 +1,4 @@
import { bookFontHasWeight } from '../book-fonts';
import { bookPageSetupSchema, type BookDocument, type BookTextElement } from '../book.schema';
import { designBook, type BookDesignInput } from './design-book';
import { bookDesignDirectivesSchema, type BookDesignDirectivesInput } from './design-directives.schema';
@@ -7,7 +8,7 @@ import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
// BOOKSTY-001 to BOOKSTY-004
// BOOKSTY-001 to BOOKSTY-007
/**
* The type scale of a designed book: `type.scale` reaches a poster's sizes,
@@ -78,3 +79,29 @@ describe('the cover title', () => {
expect((big?.frame.y ?? 0) + (big?.frame.h ?? 0)).toBeLessThanOrEqual(page.pageHeight);
});
});
describe('the typefaces by their roles', () => {
it('BOOKSTY-005: keeps a body face that sets running text, and falls back to the style for one that does not', () => {
expect(lookOf({ style: 'classic', type: { body: 'garamond' } }).body).toBe('garamond');
// A poster face and a script are a title's, never a story's.
expect(lookOf({ style: 'classic', type: { body: 'anton' } }).body).toBe('sans');
expect(lookOf({ style: 'editorial', type: { body: 'caveat' } }).body).toBe('garamond');
});
it('BOOKSTY-006: sets the travel elements in the heading face, unless that is a script or a mono face', () => {
expect(lookOf({ style: 'bold' }).figures).toBe('bebas');
const script = lookOf({ style: 'journal', type: { heading: 'caveat' } });
expect([script.heading, script.figures]).toEqual(['caveat', 'garamond']);
expect(lookOf({ style: 'classic', type: { heading: 'mono', body: 'inter' } }).figures).toBe('inter');
});
it('BOOKSTY-007: sets every text and travel element at a weight its family really has', () => {
const { doc } = designBook(null, rich(), { style: 'bold', type: { heading: 'anton' } });
const typeset = doc.spreads.flatMap((s) => s.elements.filter((e) => 'weight' in e && 'font' in e));
expect(typeset.length).toBeGreaterThan(0);
for (const el of typeset) {
if ('weight' in el && 'font' in el) expect(bookFontHasWeight(el.font, el.weight), el.id).toBe(true);
}
expect(typeset.some((e) => 'font' in e && e.font === 'anton')).toBe(true);
});
});
+13 -3
View File
@@ -1,4 +1,5 @@
import { accentFromPalettes, contrastRatio, parseHex, readPalette, readableInk, toHex } from '../book-colour';
import { bookFontCountsInMix, bookFontSuits } from '../book-fonts';
import type { BookPhotoInfo } from '../book-ops-core';
import type { BookFontFamily, BookPageSetup, BookPhotoElement } from '../book.schema';
import type { BookDesignDirectives, BookDesignStyle } from './design-directives.schema';
@@ -7,8 +8,12 @@ import type { BookDesignDirectives, BookDesignStyle } from './design-directives.
* The look of a designed book: its paper, ink, accent, the two typefaces,
* the type sizes and the page grid, from the style and the directives.
*
* At most two families per book; badges, figures, maps and lists take the
* heading face. Sizes are set for a 210 mm page and scale with the page
* At most two families per book, read by their roles (book-fonts.data.ts):
* the body face has to be one that sets running text, so a poster face or a
* script asked for as the body falls back to the style's own. Badges,
* figures, maps and lists take the heading face, unless that is a script or
* a mono face, which sets a title well and a column of figures badly; then
* they take the body face. Sizes are set for a 210 mm page and scale with the page
* width, times `type.scale`, never under what a reader can read and never
* over what a text element takes. The cover title has a tier of its own,
* `display`, the heading size times `type.display`: 1 by default, so a book
@@ -31,6 +36,8 @@ export interface BookDesignLook {
onScrim: string;
heading: BookFontFamily;
body: BookFontFamily;
/** The face of the travel elements: figures, marks, maps and lists. */
figures: BookFontFamily;
/** Points. `display` is the cover title's, the heading's unless `type.display` raises it. */
sizes: { display: number; heading: number; sub: number; body: number; caption: number; meta: number };
/** The frame decoration and the slight turn a scrapbook gives its pictures. */
@@ -172,6 +179,8 @@ export function designLook(
const size = (role: keyof typeof BASE_SIZES, times = 1) =>
Math.min(MAX_PT, Math.max(FLOOR[role], round1(BASE_SIZES[role] * scale * times)));
const heading = directives.type?.heading ?? preset.heading;
const asked = directives.type?.body;
const body = asked && bookFontSuits(asked, 'body') ? asked : preset.body;
return {
style: directives.style,
paper: colours.paper,
@@ -182,7 +191,8 @@ export function designLook(
scrimOpacity: 0.6,
onScrim: '#ffffff',
heading,
body: directives.type?.body ?? preset.body,
body,
figures: bookFontCountsInMix(heading) ? heading : body,
sizes: {
display: size('heading', directives.type?.display ?? 1),
heading: size('heading'),
+2 -2
View File
@@ -58,7 +58,7 @@ import settings from './settings';
import share from './share';
import shared from './shared';
import storage from './storage';
import studioPhoto from './studioPhoto';
import studioFonts from './studioFonts';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
@@ -115,7 +115,7 @@ const locale = {
...todo,
...notif,
...journey,
...studioPhoto,
...studioFonts,
...oauth,
...system_notice,
...transport,
-1
View File
@@ -611,7 +611,6 @@ const journey: TranslationStrings = {
'journey.studio.tpl.mosaic': 'فسيفساء',
'journey.studio.tpl.bandQuote': 'كلمات في الوسط',
'journey.studio.tpl.staggerFour': 'أربع متدرجة',
'journey.studio.weightMissing': 'هذا الخط لا يوفر هذا الوزن',
'journey.studio.mapSource': 'مصدر الخريطة',
'journey.studio.mapSourceVector': 'المخططات',
'journey.studio.mapSourceRelief': 'تضاريس',
+31
View File
@@ -0,0 +1,31 @@
import type { TranslationStrings } from '../types';
/** The Studio's typeface picker and its weights. */
const studioFonts: TranslationStrings = {
'journey.studio.fonts.change': 'تغيير الخط',
'journey.studio.fonts.search': 'البحث عن خطوط',
'journey.studio.fonts.list': 'الخطوط',
'journey.studio.fonts.used': 'مستخدمة في هذا الكتاب',
'journey.studio.fonts.none': 'لا يوجد خط مطابق',
'journey.studio.fonts.range': 'من {from} إلى {to}',
'journey.studio.fonts.cat.sans': 'بدون تذييل',
'journey.studio.fonts.cat.serif': 'مذيّلة',
'journey.studio.fonts.cat.elegant': 'عناوين أنيقة',
'journey.studio.fonts.cat.condensed': 'ضيقة وملصقات',
'journey.studio.fonts.cat.retro': 'ثقيلة وريترو',
'journey.studio.fonts.cat.script': 'خط يد وزخرفي',
'journey.studio.fonts.cat.mono': 'ثابتة العرض',
'journey.studio.weight.100': 'رفيع',
'journey.studio.weight.200': 'خفيف جدًا',
'journey.studio.weight.300': 'خفيف',
'journey.studio.weight.400': 'عادي',
'journey.studio.weight.500': 'متوسط',
'journey.studio.weight.600': 'شبه عريض',
'journey.studio.weight.700': 'عريض',
'journey.studio.weight.800': 'عريض جدًا',
'journey.studio.weight.900': 'أسود',
'journey.studio.weightOnly': 'وزن واحد فقط',
'journey.studio.italicSlanted': 'مائل بالإمالة: لا يملك هذا الخط مائلًا خاصًا به',
};
export default studioFonts;
-29
View File
@@ -1,29 +0,0 @@
import type { TranslationStrings } from '../types';
/** TREK Studio: the photo look, its border, shadow, zoom, adjustments and duotone, and the print resolution. */
const studioPhoto: TranslationStrings = {
'journey.studio.photo.zoom': 'التكبير',
'journey.studio.photo.flip': 'عكس الصورة',
'journey.studio.photo.dpi': 'تُطبع بدقة {dpi} نقطة لكل بوصة تقريبًا',
'journey.studio.photo.dpiLow': 'دون {min} نقطة لكل بوصة تبدو الطباعة باهتة. صغّر الإطار أو قلّل التكبير.',
'journey.studio.photo.edges': 'الحد والظل',
'journey.studio.photo.border': 'الحد',
'journey.studio.photo.width': 'العرض',
'journey.studio.photo.shadow': 'ظل حاد',
'journey.studio.photo.dx': 'أفقيًا',
'journey.studio.photo.dy': 'عموديًا',
'journey.studio.photo.cutout': 'حافة القص',
'journey.studio.photo.adjust': 'ضبط',
'journey.studio.photo.brightness': 'السطوع',
'journey.studio.photo.contrast': 'التباين',
'journey.studio.photo.saturation': 'التشبع',
'journey.studio.photo.warmth': 'الدفء',
'journey.studio.photo.reset': 'إعادة الضبط',
'journey.studio.photo.rasterHint': 'تُطبع كصورة نقطية، مثل المرشحات.',
'journey.studio.photo.duotone': 'لونان',
'journey.studio.photo.duotoneDark': 'الظلال',
'journey.studio.photo.duotoneLight': 'الإضاءات',
'assistant.card.book.finding.raster_effect': 'مرشحات على صورة كبيرة',
};
export default studioPhoto;
+2 -2
View File
@@ -58,7 +58,7 @@ import settings from './settings';
import share from './share';
import shared from './shared';
import storage from './storage';
import studioPhoto from './studioPhoto';
import studioFonts from './studioFonts';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
@@ -115,7 +115,7 @@ const locale = {
...todo,
...notif,
...journey,
...studioPhoto,
...studioFonts,
...oauth,
...system_notice,
...transport,
-2
View File
@@ -639,8 +639,6 @@ const journey: TranslationStrings = {
'journey.studio.tpl.bandQuote': 'Arada sitat',
'journey.studio.tpl.staggerFour': 'Pilləli dördlük',
'journey.studio.weightMissing': 'Bu şriftdə həmin qalınlıq mövcud deyil',
'journey.studio.mapSource': 'Xəritə mənbəyi',
'journey.studio.mapSourceVector': 'Konturlar',
'journey.studio.mapSourceRelief': 'Relyef',
+31
View File
@@ -0,0 +1,31 @@
import type { TranslationStrings } from '../types';
/** The Studio's typeface picker and its weights. */
const studioFonts: TranslationStrings = {
'journey.studio.fonts.change': 'Şrifti dəyiş',
'journey.studio.fonts.search': 'Şrift axtar',
'journey.studio.fonts.list': 'Şriftlər',
'journey.studio.fonts.used': 'Bu kitabda istifadə olunanlar',
'journey.studio.fonts.none': 'Uyğun şrift yoxdur',
'journey.studio.fonts.range': '{from} ilə {to} arası',
'journey.studio.fonts.cat.sans': 'Dırnaqsız',
'journey.studio.fonts.cat.serif': 'Dırnaqlı',
'journey.studio.fonts.cat.elegant': 'Zərif başlıq',
'journey.studio.fonts.cat.condensed': 'Dar və afişa',
'journey.studio.fonts.cat.retro': 'Qalın və retro',
'journey.studio.fonts.cat.script': 'Yazı və əl yazısı',
'journey.studio.fonts.cat.mono': 'Sabit enli',
'journey.studio.weight.100': 'Nazik',
'journey.studio.weight.200': 'Çox yüngül',
'journey.studio.weight.300': 'Yüngül',
'journey.studio.weight.400': 'Adi',
'journey.studio.weight.500': 'Orta',
'journey.studio.weight.600': 'Yarımqalın',
'journey.studio.weight.700': 'Qalın',
'journey.studio.weight.800': 'Çox qalın',
'journey.studio.weight.900': 'Qara',
'journey.studio.weightOnly': 'Yalnız bir qalınlıq',
'journey.studio.italicSlanted': 'Kursiv, maili: bu şriftin öz kursivi yoxdur',
};
export default studioFonts;
-29
View File
@@ -1,29 +0,0 @@
import type { TranslationStrings } from '../types';
/** TREK Studio: the photo look, its border, shadow, zoom, adjustments and duotone, and the print resolution. */
const studioPhoto: TranslationStrings = {
'journey.studio.photo.zoom': 'Böyütmə',
'journey.studio.photo.flip': 'Güzgü kimi çevir',
'journey.studio.photo.dpi': 'Təxminən {dpi} dpi ilə çap olunur',
'journey.studio.photo.dpiLow': '{min} dpi-dən aşağı çap yumşaq görünür. Çərçivəni kiçildin və ya uzaqlaşdırın.',
'journey.studio.photo.edges': 'Haşiyə və kölgə',
'journey.studio.photo.border': 'Haşiyə',
'journey.studio.photo.width': 'En',
'journey.studio.photo.shadow': 'Sərt kölgə',
'journey.studio.photo.dx': 'Üfüqi',
'journey.studio.photo.dy': 'Şaquli',
'journey.studio.photo.cutout': 'Kəsim haşiyəsi',
'journey.studio.photo.adjust': 'Tənzimlə',
'journey.studio.photo.brightness': 'Parlaqlıq',
'journey.studio.photo.contrast': 'Kontrast',
'journey.studio.photo.saturation': 'Doyğunluq',
'journey.studio.photo.warmth': 'İstilik',
'journey.studio.photo.reset': 'Sıfırla',
'journey.studio.photo.rasterHint': 'Filtrlər kimi raster şəkil olaraq çap olunur.',
'journey.studio.photo.duotone': 'İkiton',
'journey.studio.photo.duotoneDark': 'Kölgələr',
'journey.studio.photo.duotoneLight': 'İşıqlı yerlər',
'assistant.card.book.finding.raster_effect': 'Böyük fotoda filtrlər',
};
export default studioPhoto;
+2 -2
View File
@@ -58,7 +58,7 @@ import settings from './settings';
import share from './share';
import shared from './shared';
import storage from './storage';
import studioPhoto from './studioPhoto';
import studioFonts from './studioFonts';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
@@ -115,7 +115,7 @@ const locale = {
...todo,
...notif,
...journey,
...studioPhoto,
...studioFonts,
...oauth,
...system_notice,
...transport,
-1
View File
@@ -585,7 +585,6 @@ const journey: TranslationStrings = {
'journey.studio.tpl.mosaic': 'Mosaico',
'journey.studio.tpl.bandQuote': 'Palavras no meio',
'journey.studio.tpl.staggerFour': 'Quatro escalonadas',
'journey.studio.weightMissing': 'Esta fonte não tem esse peso',
'journey.studio.mapSource': 'Origem do mapa',
'journey.studio.mapSourceVector': 'Contornos',
'journey.studio.mapSourceRelief': 'Relevo',
+31
View File
@@ -0,0 +1,31 @@
import type { TranslationStrings } from '../types';
/** The Studio's typeface picker and its weights. */
const studioFonts: TranslationStrings = {
'journey.studio.fonts.change': 'Trocar fonte',
'journey.studio.fonts.search': 'Buscar fontes',
'journey.studio.fonts.list': 'Fontes',
'journey.studio.fonts.used': 'Usadas neste livro',
'journey.studio.fonts.none': 'Nenhuma fonte corresponde',
'journey.studio.fonts.range': '{from} a {to}',
'journey.studio.fonts.cat.sans': 'Sem serifa',
'journey.studio.fonts.cat.serif': 'Com serifa',
'journey.studio.fonts.cat.elegant': 'Títulos elegantes',
'journey.studio.fonts.cat.condensed': 'Estreita e cartaz',
'journey.studio.fonts.cat.retro': 'Pesada e retrô',
'journey.studio.fonts.cat.script': 'Caligráfica e manuscrita',
'journey.studio.fonts.cat.mono': 'Monoespaçada',
'journey.studio.weight.100': 'Ultrafino',
'journey.studio.weight.200': 'Extrafino',
'journey.studio.weight.300': 'Fino',
'journey.studio.weight.400': 'Normal',
'journey.studio.weight.500': 'Médio',
'journey.studio.weight.600': 'Seminegrito',
'journey.studio.weight.700': 'Negrito',
'journey.studio.weight.800': 'Extranegrito',
'journey.studio.weight.900': 'Preto',
'journey.studio.weightOnly': 'Apenas um peso',
'journey.studio.italicSlanted': 'Itálico, inclinado: esta fonte não tem itálico próprio',
};
export default studioFonts;
-30
View File
@@ -1,30 +0,0 @@
import type { TranslationStrings } from '../types';
/** TREK Studio: the photo look, its border, shadow, zoom, adjustments and duotone, and the print resolution. */
const studioPhoto: TranslationStrings = {
'journey.studio.photo.zoom': 'Zoom', // same-as-en
'journey.studio.photo.flip': 'Espelhar',
'journey.studio.photo.dpi': 'Imprime a cerca de {dpi} dpi',
'journey.studio.photo.dpiLow':
'Abaixo de {min} dpi a impressão fica suave demais. Diminua a moldura ou afaste o zoom.',
'journey.studio.photo.edges': 'Borda e sombra',
'journey.studio.photo.border': 'Borda',
'journey.studio.photo.width': 'Largura',
'journey.studio.photo.shadow': 'Sombra dura',
'journey.studio.photo.dx': 'Horizontal',
'journey.studio.photo.dy': 'Vertical',
'journey.studio.photo.cutout': 'Borda recortada',
'journey.studio.photo.adjust': 'Ajustar',
'journey.studio.photo.brightness': 'Brilho',
'journey.studio.photo.contrast': 'Contraste',
'journey.studio.photo.saturation': 'Saturação',
'journey.studio.photo.warmth': 'Calor',
'journey.studio.photo.reset': 'Redefinir',
'journey.studio.photo.rasterHint': 'É impressa como imagem rasterizada, como os filtros.',
'journey.studio.photo.duotone': 'Duotônico',
'journey.studio.photo.duotoneDark': 'Sombras',
'journey.studio.photo.duotoneLight': 'Realces',
'assistant.card.book.finding.raster_effect': 'Filtros em uma foto grande',
};
export default studioPhoto;
+2 -2
View File
@@ -58,7 +58,7 @@ import settings from './settings';
import share from './share';
import shared from './shared';
import storage from './storage';
import studioPhoto from './studioPhoto';
import studioFonts from './studioFonts';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
@@ -116,7 +116,7 @@ const locale = {
...todo,
...notif,
...journey,
...studioPhoto,
...studioFonts,
...oauth,
...system_notice,
...transport,
-1
View File
@@ -585,7 +585,6 @@ const journey: TranslationStrings = {
'journey.studio.tpl.mosaic': 'Mosaic',
'journey.studio.tpl.bandQuote': 'Paraules al mig',
'journey.studio.tpl.staggerFour': 'Quatre esglaonades',
'journey.studio.weightMissing': 'Aquesta tipografia no té aquest gruix',
'journey.studio.mapSource': 'Origen del mapa',
'journey.studio.mapSourceVector': 'Contorns',
'journey.studio.mapSourceRelief': 'Relleu',
+31
View File
@@ -0,0 +1,31 @@
import type { TranslationStrings } from '../types';
/** The Studio's typeface picker and its weights. */
const studioFonts: TranslationStrings = {
'journey.studio.fonts.change': 'Canvia la tipografia',
'journey.studio.fonts.search': 'Cerca tipografies',
'journey.studio.fonts.list': 'Tipografies',
'journey.studio.fonts.used': 'Utilitzades en aquest llibre',
'journey.studio.fonts.none': 'Cap tipografia coincideix',
'journey.studio.fonts.range': '{from} a {to}',
'journey.studio.fonts.cat.sans': 'Sense serifa',
'journey.studio.fonts.cat.serif': 'Amb serifa',
'journey.studio.fonts.cat.elegant': 'Títols elegants',
'journey.studio.fonts.cat.condensed': 'Estreta i cartell',
'journey.studio.fonts.cat.retro': 'Gruixuda i retro',
'journey.studio.fonts.cat.script': 'Cal·ligràfica i manuscrita',
'journey.studio.fonts.cat.mono': 'Monoespaiada',
'journey.studio.weight.100': 'Ultrafina',
'journey.studio.weight.200': 'Extrafina',
'journey.studio.weight.300': 'Fina',
'journey.studio.weight.400': 'Normal',
'journey.studio.weight.500': 'Mitjana',
'journey.studio.weight.600': 'Seminegreta',
'journey.studio.weight.700': 'Negreta',
'journey.studio.weight.800': 'Extranegreta',
'journey.studio.weight.900': 'Negra',
'journey.studio.weightOnly': 'Només un pes',
'journey.studio.italicSlanted': 'Cursiva, inclinada: aquesta tipografia no té cursiva pròpia',
};
export default studioFonts;
-30
View File
@@ -1,30 +0,0 @@
import type { TranslationStrings } from '../types';
/** TREK Studio: the photo look, its border, shadow, zoom, adjustments and duotone, and the print resolution. */
const studioPhoto: TranslationStrings = {
'journey.studio.photo.zoom': 'Zoom', // same-as-en
'journey.studio.photo.flip': 'Emmiralla',
'journey.studio.photo.dpi': 'S’imprimeix a uns {dpi} ppp',
'journey.studio.photo.dpiLow':
'Per sota de {min} ppp una impressió es veu tova. Fes el marc més petit o allunya el zoom.',
'journey.studio.photo.edges': 'Vora i ombra',
'journey.studio.photo.border': 'Vora',
'journey.studio.photo.width': 'Amplada',
'journey.studio.photo.shadow': 'Ombra dura',
'journey.studio.photo.dx': 'Horitzontal',
'journey.studio.photo.dy': 'Vertical',
'journey.studio.photo.cutout': 'Vora retallada',
'journey.studio.photo.adjust': 'Ajusta',
'journey.studio.photo.brightness': 'Brillantor',
'journey.studio.photo.contrast': 'Contrast', // same-as-en
'journey.studio.photo.saturation': 'Saturació',
'journey.studio.photo.warmth': 'Calidesa',
'journey.studio.photo.reset': 'Restableix',
'journey.studio.photo.rasterHint': 'S’imprimeix com a imatge ràster, com els filtres.',
'journey.studio.photo.duotone': 'Duotò',
'journey.studio.photo.duotoneDark': 'Ombres',
'journey.studio.photo.duotoneLight': 'Llums',
'assistant.card.book.finding.raster_effect': 'Filtres en una foto gran',
};
export default studioPhoto;
+2 -2
View File
@@ -58,7 +58,7 @@ import settings from './settings';
import share from './share';
import shared from './shared';
import storage from './storage';
import studioPhoto from './studioPhoto';
import studioFonts from './studioFonts';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
@@ -115,7 +115,7 @@ const locale = {
...todo,
...notif,
...journey,
...studioPhoto,
...studioFonts,
...oauth,
...system_notice,
...transport,
-1
View File
@@ -589,7 +589,6 @@ const journey: TranslationStrings = {
'journey.studio.tpl.mosaic': 'Mozaika',
'journey.studio.tpl.bandQuote': 'Slova uprostřed',
'journey.studio.tpl.staggerFour': 'Čtyři přesazené',
'journey.studio.weightMissing': 'Toto písmo tento řez nemá',
'journey.studio.mapSource': 'Zdroj mapy',
'journey.studio.mapSourceVector': 'Obrysy',
'journey.studio.mapSourceRelief': 'Reliéf',
+31
View File
@@ -0,0 +1,31 @@
import type { TranslationStrings } from '../types';
/** The Studio's typeface picker and its weights. */
const studioFonts: TranslationStrings = {
'journey.studio.fonts.change': 'Změnit písmo',
'journey.studio.fonts.search': 'Hledat písma',
'journey.studio.fonts.list': 'Písma',
'journey.studio.fonts.used': 'Použitá v této knize',
'journey.studio.fonts.none': 'Žádné písmo neodpovídá',
'journey.studio.fonts.range': '{from} až {to}',
'journey.studio.fonts.cat.sans': 'Bezpatková',
'journey.studio.fonts.cat.serif': 'Patková',
'journey.studio.fonts.cat.elegant': 'Elegantní titulková',
'journey.studio.fonts.cat.condensed': 'Úzká a plakátová',
'journey.studio.fonts.cat.retro': 'Těžká a retro',
'journey.studio.fonts.cat.script': 'Psací a ručně psaná',
'journey.studio.fonts.cat.mono': 'Neproporcionální',
'journey.studio.weight.100': 'Tenké',
'journey.studio.weight.200': 'Extra lehké',
'journey.studio.weight.300': 'Lehké',
'journey.studio.weight.400': 'Běžné',
'journey.studio.weight.500': 'Střední',
'journey.studio.weight.600': 'Polotučné',
'journey.studio.weight.700': 'Tučné',
'journey.studio.weight.800': 'Extra tučné',
'journey.studio.weight.900': 'Černé',
'journey.studio.weightOnly': 'Jen jeden řez',
'journey.studio.italicSlanted': 'Kurzíva, skloněná: toto písmo nemá vlastní kurzívu',
};
export default studioFonts;
-29
View File
@@ -1,29 +0,0 @@
import type { TranslationStrings } from '../types';
/** TREK Studio: the photo look, its border, shadow, zoom, adjustments and duotone, and the print resolution. */
const studioPhoto: TranslationStrings = {
'journey.studio.photo.zoom': 'Přiblížení',
'journey.studio.photo.flip': 'Zrcadlit',
'journey.studio.photo.dpi': 'Tiskne se zhruba v {dpi} dpi',
'journey.studio.photo.dpiLow': 'Pod {min} dpi vypadá tisk měkce. Zmenšete rámeček nebo oddalte.',
'journey.studio.photo.edges': 'Okraj a stín',
'journey.studio.photo.border': 'Okraj',
'journey.studio.photo.width': 'Šířka',
'journey.studio.photo.shadow': 'Ostrý stín',
'journey.studio.photo.dx': 'Vodorovně',
'journey.studio.photo.dy': 'Svisle',
'journey.studio.photo.cutout': 'Vysekaný okraj',
'journey.studio.photo.adjust': 'Úpravy',
'journey.studio.photo.brightness': 'Jas',
'journey.studio.photo.contrast': 'Kontrast',
'journey.studio.photo.saturation': 'Sytost',
'journey.studio.photo.warmth': 'Teplota',
'journey.studio.photo.reset': 'Obnovit',
'journey.studio.photo.rasterHint': 'Tiskne se jako rastrový obrázek, stejně jako filtry.',
'journey.studio.photo.duotone': 'Duotón',
'journey.studio.photo.duotoneDark': 'Stíny',
'journey.studio.photo.duotoneLight': 'Světla',
'assistant.card.book.finding.raster_effect': 'Filtry na velké fotce',
};
export default studioPhoto;
+2 -2
View File
@@ -58,7 +58,7 @@ import settings from './settings';
import share from './share';
import shared from './shared';
import storage from './storage';
import studioPhoto from './studioPhoto';
import studioFonts from './studioFonts';
import system_notice from './system_notice';
import todo from './todo';
import tourCollection from './tourCollection';
@@ -115,7 +115,7 @@ const locale = {
...todo,
...notif,
...journey,
...studioPhoto,
...studioFonts,
...oauth,
...system_notice,
...transport,

Some files were not shown because too many files have changed in this diff Show More