import { BOOK_FONTS_IDS, BOOK_WEIGHTS, type BookFontFamily } from './book-fonts.data'; import { BOOK_LINE_FIELDS } from './book-line.schema'; import { bookPaintFieldSchema } from './book-paint.schema'; import { BOOK_PHOTO_LOOK_FIELDS } from './book-photo-look.schema'; import { BOOK_EDGE_FIELDS, BOOK_SHAPES_MORE } from './book-shapes-more'; import { BOOK_TEXT_EFFECT_FIELDS, MAX_BOOK_TEXT_SIZE } from './book-text-effects.schema'; import { MAX_STICKER_SLOTS } from './stickers/sticker-types'; import * as themed from './theme/book-theme.schema'; import { z } from 'zod'; /** * The TREK Studio book document — what a photo book *is*, independent of how it * is edited or rendered. * * Two decisions carry the rest of the format: * * 1. **Everything geometric is in millimetres.** Not pixels: a pixel means * nothing to a print shop, and it changes meaning with the device pixel * ratio and the browser zoom. CSS maps 1in onto 96px, 25.4mm and 72pt with a * fixed ratio, so the editor can render the document at `transform: scale()` * and the print renderer at 1:1 and land on the same page. Values are stored * rounded to two decimals — 10µm, far below any imagesetter — so a JSON round * trip cannot accumulate drift. * * 2. **The paint order is the array order.** `elements[0]` is at the back. No * `zIndex` field that could disagree with itself, "bring to front" is a * splice, and because the DOM paints in the same order the editor and the * renderer agree for free. * * A text element may be *bound* to a piece of the journey (an entry's title, its * story, a photo caption). A bound element re-reads its source when the book is * opened, so fixing a typo in the journal fixes it in the book — until someone * edits the text in Studio, which sets `overridden` and stops the sync. */ /** * Two decimals of a millimetre. Anything finer is below print resolution. * * Bounded at ten metres in either direction, which is four orders of magnitude * past the largest book anyone binds and still leaves room for an element * parked well off the spread. Without a bound a stored `1e9` is a legal * millimetre, and the editor and the PDF renderer both try to draw a sheet a * thousand kilometres wide. */ const mm = z .number() .finite() .min(-10000) .max(10000) .transform((v) => Math.round(v * 100) / 100); const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'expected #rrggbb'); /** * The limits a producer has to build to, named rather than written inline. * * Studio's auto layout is a producer: it pours journey data straight into a * document, and a document over any one of these is refused whole by the save * route — which the editor can only report as "couldn't save", for the rest of * the session, because autosave keeps offering the same rejected book. So the * caps are exported and the layout builds against them, instead of the two * being written down twice and drifting apart. * * `MAX_SPREAD_ELEMENTS` belongs to this set and is declared with the spread it * bounds, where the salvage pass reads it too. */ export const MAX_SPREADS = 150; export const MAX_BOOK_TITLE = 200; export const MAX_TEXT_LENGTH = 8000; /** Codes on a map or a country page, and the names printed beside them. */ export const MAX_BOOK_COUNTRIES = 80; export const MAX_COUNTRY_NAME = 80; export const MAX_MAP_POINTS = 400; export const MAX_MAP_POINT_LABEL = 120; /** A recorded track: segments, and points within one segment. */ export const MAX_PATH_SEGMENTS = 40; export const MAX_PATH_POINTS = 1200; /** * Every shape the book can draw — and, because a picture frame *is* a shape with * a photograph inside it, every mask a photo can be cut to. * * One list rather than two. Canva keeps "shapes" and "frames" in separate * panels, but underneath they are the same geometry, and duplicating it would * mean a heart that exists as a frame but not as a shape, or the two drifting * apart the first time one of them is adjusted. * * The first four entries are the shapes that existed before this list did. They * stay at the front and keep their names, so a document saved by the earlier * editor still parses. */ export const BOOK_SHAPES = [ 'rect', 'ellipse', 'line', 'triangle', 'triangle-down', 'diamond', 'parallelogram', 'trapezoid', 'pentagon', 'hexagon', 'hexagon-flat', 'heptagon', 'octagon', 'arch', 'half-circle', 'quarter-circle', 'capsule', 'squircle', 'star-4', 'star-5', 'star-6', 'star-8', 'star-12', 'burst', 'seal', 'sparkle', 'arrow-right', 'arrow-left', 'arrow-up', 'arrow-down', 'arrow-both', 'chevron-right', 'chevron-left', 'arrow-bent', 'bubble-round', 'bubble-square', 'bubble-oval', 'bubble-think', 'heart', 'cloud', 'cloud-puffy', 'drop', 'moon', 'sun', 'flower-5', 'flower-6', 'leaf', 'cross', 'plus', 'shield', 'gear', 'ticket', 'wave', 'mountain', 'compass', 'pin', 'blob-1', 'blob-2', 'blob-3', 'blob-4', 'banner-ribbon', 'banner-pennant', 'banner-bookmark', 'banner-flag', ...BOOK_SHAPES_MORE, ] as const; export type BookShapeId = (typeof BOOK_SHAPES)[number]; /** * A field that takes one id out of a long catalogue: a typeface, a shape. * * A tool's JSON schema shows it as a plain string, and the parse holds it to * the catalogue, refusing an id it lacks with the ids it has. Spelled out as an * enum it would be repeated wherever the field appears, on every element kind * of a page, because the tool schemas carry no `$ref` to share it, and that * repetition is what held the book tools over their size ceiling * (MCP-SCHEMA-003 and 004 on the server). Where a model has to choose an id, * the described enums of book-op-enums.ts still list them. */ export const bookCatalogueIdSchema = (ids: T) => z.string().pipe(z.enum(ids)); /** * 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. */ export { BOOK_FONTS_IDS, type BookFontFamily }; const bookWeightSchema = z.literal(BOOK_WEIGHTS); /** How long a group id and a layer's name may be. */ export const BOOK_GROUP_ID_MAX = 40; export const BOOK_LAYER_NAME_MAX = 60; export const bookFrameSchema = z.object({ /** Millimetres from the left edge of the spread. Negative means bleed. */ x: mm, /** Millimetres from the top edge of the spread. */ y: mm, w: mm.refine((v) => v > 0, 'width must be positive'), h: mm.refine((v) => v > 0, 'height must be positive'), }); export type BookFrame = z.infer; const elementBase = { id: z.string().min(1), frame: bookFrameSchema, /** Degrees, clockwise. */ rotation: z.number().finite().default(0), opacity: z.number().min(0).max(1).default(1), locked: z.boolean().default(false), /** * The group the element belongs to on its spread, by the group's id (see * book-groups.ts). Absent for an element on its own. Optional rather than * defaulted, so a book without groups stores what it stored before. */ group: z.string().min(1).max(BOOK_GROUP_ID_MAX).optional(), /** What the layers panel calls it. Absent means the name of its kind. */ name: z.string().max(BOOK_LAYER_NAME_MAX).optional(), /** Kept in the layers panel but left off the page, on screen and in print. */ hidden: z.boolean().optional(), }; export const bookPhotoElementSchema = z.object({ ...elementBase, kind: z.literal('photo'), /** * `trek_photos.id` — never a resolved URL, or the document would not survive a * provider change and Studio could not warn about print resolution. * * Null is an empty frame: a template lays out where the pictures go before it * knows which pictures those are, and a placeholder you can drop a photo onto * is the whole point of a template. */ photoId: z.number().int().positive().nullable().default(null), fit: z.enum(['cover', 'contain']).default('cover'), /** Where the interesting part of the picture is, 0..1 of each edge. */ focalX: z.number().min(0).max(1).default(0.5), focalY: z.number().min(0).max(1).default(0.5), radius: mm.default(0), filter: z.enum(['none', 'bw', 'warm', 'cool', 'fade', 'contrast']).default('none'), /** * Cut the picture to a shape. Null is the plain rectangle every photo starts * as — and stays, unless someone asks for a heart. * * The mask is stretched to the frame rather than kept square, which is what * makes a frame usable: you place a wide star across a spread and it is wide. * A shape you cannot resize freely is an ornament, not a frame. */ mask: bookCatalogueIdSchema(BOOK_SHAPES).nullable().default(null), /** * Decoration *around* the picture — a Polaroid's thick chin, a print's white * border, the shadow of a photo lying on a page. * * Not a mask and not a shape: it adds to the frame instead of cutting into it, * 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, /** A cut edge along the mask, or along the rectangle (book-shapes-more.ts). */ ...BOOK_EDGE_FIELDS, }); export const bookTextElementSchema = z.object({ ...elementBase, kind: z.literal('text'), text: z.string().max(MAX_TEXT_LENGTH).default(''), font: bookCatalogueIdSchema(BOOK_FONTS_IDS).default('sans'), /** Points, up to a word across a whole cover. */ size: z.number().min(4).max(MAX_BOOK_TEXT_SIZE).default(11), weight: bookWeightSchema.default(400), italic: z.boolean().default(false), align: z.enum(['left', 'center', 'right', 'justify']).default('left'), /** Multiple of the font size. */ leading: z.number().min(0.7).max(3).default(1.45), /** Ems, may be negative; wide enough for very spaced capitals. */ tracking: z.number().min(-0.2).max(1.5).default(0), color: hex.default('#1a1a1a'), ...themed.THEMED_TEXT, /** Where this text came from, when it came from the journey. */ binding: z .object({ source: z.enum([ 'journey.title', 'journey.subtitle', 'entry.title', 'entry.story', 'entry.location', 'entry.date', 'photo.caption', ]), entryId: z.number().int().optional(), photoId: z.number().int().optional(), /** * How to set the source, where it can be set more than one way. * * Only `entry.location` uses it, and only because a stop is both a name * and a point: absent means the place as it is written in the journal, * `dms` and `decimal` mean its coordinates. It is a property of the * binding rather than a second `source`, because both read the one field * the journal actually records — and because a document written with an * unknown *source* would fail the union and normalise to an empty book, * while an unknown key inside the binding is dropped and the page * survives. A print format is not worth a lost book. */ format: z.enum(['dms', 'decimal']).optional(), /** * The raw value this element was last set from — an ISO date, a * `lat,lng` pair — for the sources whose words are *formatted* rather * than copied. * * Without it, re-reading a date cannot tell a journal that changed from a * page that was set in another language, or in small caps, or as part of * a longer line: it would rewrite all four alike, in the reader's locale, * on open. With it the question is exactly the right one — has the entry * moved? — and an element from before this existed carries no value and * is therefore left alone, which is the only safe thing to do to a book * somebody has already printed. */ value: z.string().max(200).optional(), }) .nullable() .default(null), /** True once a human edited the text; stops it re-reading its source. */ overridden: z.boolean().default(false), /** Outline, shadow, band, curve, fill, case, vertical place and fit (book-text-effects.schema.ts). */ ...BOOK_TEXT_EFFECT_FIELDS, }); export const bookShapeElementSchema = z.object({ ...elementBase, kind: z.literal('shape'), shape: bookCatalogueIdSchema(BOOK_SHAPES).default('rect'), fill: hex.nullable().default('#111827'), /** * Fade the fill out towards one edge. A cover almost always needs this: a * flat panel behind the title cuts the photograph in half, while a fade lets * the picture keep going and still leaves the words readable. */ gradient: z.enum(['none', 'up', 'down']).default('none'), /** A fill beyond one flat colour (book-paint.schema.ts); `fill` keeps the colour that stands for it. */ paint: bookPaintFieldSchema, stroke: hex.nullable().default(null), ...themed.THEMED_SHAPE, strokeWidth: mm.default(0), /** * A dashed rule is a different thing from a solid one — it reads as a fold, a * route or a cut mark rather than as a line under a heading. The dash length * follows the stroke width, so it stays proportional at any size. */ strokeStyle: z.enum(['solid', 'dashed', 'dotted']).default('solid'), radius: mm.default(0), /** A cut edge along the outline (book-shapes-more.ts). */ ...BOOK_EDGE_FIELDS, }); /** A drawn line with two or more points (book-line.schema.ts). */ export const bookLineElementSchema = z.object({ ...elementBase, ...BOOK_LINE_FIELDS }); /** * ── The travel elements ──────────────────────────────────────────────────── * * A photo book of a journey is not a photo album: the trip carries facts — * which countries, how far, how long, which way round — and those facts make * pages that photographs cannot. This is what Polarsteps' printed books do * better than a generic book maker, and it is the one thing TREK is in a * position to do well, because the data is already in the trips hanging off the * journey. * * **Every one of them carries its own values.** The numbers are resolved when * the element is placed and stored in the document, and the renderer reads them * from there — it never fetches. Three reasons, and the third is the one that * decides it: * * 1. The print renderer is headless Chromium loading a document. A page that * needs a logged-in API call to know what it says is a page that prints * blank on the day the token expires. * 2. A book is a record of a trip as it was. Adding a stop next year should not * silently rewrite the distance printed in last year's book. * 3. It makes the elements ordinary. They move, resize, lock, undo and park like * everything else, because they *are* like everything else. * * `stale` is how the editor offers the other behaviour without giving up any of * that: it marks a snapshot that no longer matches the journey, and the * inspector shows a refresh button. Updating is a choice, not a surprise. */ /** What the travel elements share with text: they are typeset, not drawn. */ const typeset = { font: bookCatalogueIdSchema(BOOK_FONTS_IDS).default('sans'), color: hex.default('#1a1a1a'), /** * The one colour that carries emphasis — the figure in a stat, the route on * a map, the fill of a chip. * * Black by default, not the app's orange. A book is printed and a printed * page is ink on paper; a colour that arrives without being chosen turns * every travel element into the same accent whether or not the book wants * one. Picking a colour is one click and is what the swatches are for — and * the map's dark style overrides it anyway, because black lines on a dark * map are lines nobody can see. */ accent: hex.default('#111111'), /** Scales every piece of type in the element at once. 1 is the drawn default. */ textScale: z.number().min(0.4).max(3).default(1), /** * How heavy the type is set. * * The travel elements draw at several sizes at once — a figure over its * label, a country name over its outline — and this moves all of them * together, the way `textScale` moves their size. Without it the only way to * lighten a stats block was to not use one. */ weight: bookWeightSchema.default(700), /** Set when the journey has moved on since these values were taken. */ stale: z.boolean().default(false), ...themed.THEMED_TYPESET, }; /** Kilometres or miles. Stored metric; the element converts when it draws. */ export const bookUnitsSchema = z.enum(['metric', 'imperial']); export const bookMapElementSchema = z.object({ ...elementBase, ...typeset, kind: z.literal('map'), /** * Where the map underneath comes from. * * `vector` draws country outlines from the bundled boundaries: it carries no * licence, fetches nothing at render time, and prints sharp at any size, * which is why it is the default and why a book can be exported offline. * * `tiles` and `static` use the map provider the instance is already * configured with, for a book that wants real geography — streets, terrain, * coastline detail an outline cannot give. Both fetch at render time and both * carry an attribution the element prints, because the licence requires it * and a book is a published work. A tiled map is also raster: it is cut for a * zoom level, so printing it much larger than it was fetched goes soft. */ source: z.enum(['vector', 'tiles', 'static']).default('vector'), /** * The tile template, frozen when the element was placed. * * Kept in the document rather than read from settings at render time for the * same reason the figures are: a page that resolves its own contents from an * account's current settings is a page that changes when someone else edits * a preference. */ tileUrl: z.string().max(500).default(''), /** What the licence requires the page to say. Printed small, in a corner. */ attribution: z.string().max(200).default(''), /** * Zoom for the tiled sources. Null lets the element choose one that fits the * route, which is right almost always and wrong when someone wants the * street detail of a single city. */ zoom: z.number().int().min(0).max(19).nullable().default(null), style: z.enum(['minimal', 'outline', 'dark', 'paper']).default('minimal'), /** Country silhouettes under the route, drawn from the bundled boundaries. */ showLand: z.boolean().default(true), showRoute: z.boolean().default(true), showPins: z.boolean().default(true), showLabels: z.boolean().default(false), /** * ── How the line is drawn ──────────────────────────────────────────── * * `plain` is one stroke in the element's ink: what every map in every book * made so far already draws, and the default for exactly that reason. * * `drawn` is the printed-atlas treatment — a light line over a dark casing, * or the reverse on pale paper — which is what keeps a single route legible * across near-black bathymetry and bright desert in the same picture. It * cannot be the default: a stored book would change the day it was opened. */ routeStyle: z.enum(['plain', 'drawn']).default('plain'), /** * Whether a long leg bows. * * A straight line between two stops a continent apart is a claim about the * journey that is obviously false, and drawing it as a shallow curve is how * printed maps have always said "this part was a flight". Only ever applied * to the inferred stop-to-stop line: bowing a recorded GPS track would draw * a journey that did not happen. */ routeArc: z.enum(['straight', 'bow']).default('straight'), /** * Whether the bowed legs are dashed. * * The dash then MEANS something — this leg is inferred, not recorded — which * is why it applies to the bowed legs alone rather than to the whole line. */ routeDash: z.enum(['solid', 'arcs']).default('solid'), /** * What a stop looks like. * * `photo` draws the stop as a small round picture with a white ring, from the * photograph the point carries, and falls back to a dot where there is none. * That fallback is the designed state rather than a failure: a printed page * with a few plain dots between the pictures reads as a route, and one with * empty rings reads as a bug. * * `numbered` draws every stop as its number in the order travelled, the way * a guidebook's map refers to its text. `ends` marks where the journey began * and where it ended, and leaves the stops between them as small dots. */ pinStyle: z.enum(['dot', 'photo', 'numbered', 'ends']).default('dot'), /** ISO-3166-1 alpha-2, the countries whose outlines to draw. */ countries: z.array(z.string().length(2)).max(MAX_BOOK_COUNTRIES).default([]), /** * The route, in order. Capped where a printed line stops gaining from more * points — a book page cannot resolve four hundred stops anyway. */ points: z .array( z.object({ lat: z.number().min(-90).max(90), lng: z.number().min(-180).max(180), label: z.string().max(MAX_MAP_POINT_LABEL).default(''), /** * One photograph from this stop, for a marker that shows it. * * `trek_photos.id`, never a resolved URL, for the same reason a photo * element stores an id: a book has to survive a provider change. Null is * ordinary and means the marker is a dot. */ photoId: z.number().int().positive().nullable().default(null), }), ) .max(MAX_MAP_POINTS) .default([]), /** * The way the trip was actually travelled, where the trip knows it. * * `points` are the stops, and a line drawn straight between them is a claim * about the journey that is almost always false: it crosses mountains, * water and borders that the trip went around. TREK already stores routed * geometry per place from the planner and from imported GPX, so where that * exists the line follows the road instead of the ruler. * * Segments rather than one list, because two tracks that do not join must * not be drawn as though they did: the gap between the end of one day's * driving and the start of the next is a gap, not a leg. * * `[lat, lng]`, matching the order the tracks endpoint answers in, and thinned * when it is frozen into the document: a printed line gains nothing from a * point every fifteen metres, and a book carrying every GPS fix of a * three-week trip is a document nobody can save. */ path: z .array(z.array(z.tuple([z.number().min(-90).max(90), z.number().min(-180).max(180)])).max(MAX_PATH_POINTS)) .max(MAX_PATH_SEGMENTS) .default([]), /** * The roads each leg actually took, when somebody asked for them. * * ── Why this is not `path` ─────────────────────────────────────────── * * `path` is a recorded track: a GPS trace of where somebody went, which has * no idea which stop it started at and is drawn instead of the stop chain. * This is per leg — entry `i` is the way from stop `i` to stop `i + 1`, and * `null` is a leg with no road answer, which is most journeys' flights. * * Per leg rather than one line, because a partial road route drawn as one * would be a lie by omission: the renderer replaces the whole stop chain the * moment it has a track, so a journey with roads for three legs out of twelve * would print three legs and drop the other nine. * * Frozen into the document like every other travel figure. A page that asks a * routing service for its line at print time is a page that changes when a * road is rerouted, and prints empty when the export runs offline. */ roads: z .array( z .array(z.tuple([z.number().min(-90).max(90), z.number().min(-180).max(180)])) .max(200) .nullable(), ) .max(400) .default([]), /** * How much room to leave around what is drawn, as a share of its own extent. * * The view is fitted to the stops, not to the countries: a trip that stayed * in Berlin is a map of Berlin, not a map of Germany with two dots on it. * Country outlines are still drawn, they simply run off the edge like the * geography they are. */ /* * Half again the extent of the route, rather than a fifth. * * A map fitted tightly to its own stops is a diagram; the air around it is * what makes it a place. The reference books all leave room for the coast to * arrive and the neighbouring country to show, and a route that touches its * own frame on two sides reads as cropped rather than as composed. */ fitPadding: z.number().min(0).max(4).default(0.5), /** * Fit to the countries rather than to the stops. * * The default, because it is the picture a travel book usually wants: the * country, with the route inside it. Fitting to the stops is the other half * of the choice and matters as soon as the trip is smaller than the country * it happened in — a week in Berlin drawn as the whole of Germany is two * dots in a large empty shape. */ fitToCountries: z.boolean().default(true), /** * The shape the map is cut to. * * `rect` is a map in a box, which is what a map usually is and what a full * page wants. `country` cuts it to the outline of the countries themselves, * so the picture *is* the country: no frame, no box, the page showing through * around the coastline. That is the version worth putting next to a * photograph, and it is the one thing a rectangle can never be. * * Cutting only makes sense against the whole outline, so it fits to the * countries whatever `fitToCountries` says: a city-sized view cut to the * shape of a country is an evenly coloured rectangle with a route on it. */ clip: z.enum(['rect', 'country']).default('rect'), /** * Which trip this map is of, when it is of one. * * Null is the whole journey, which is what a map has always been. A journey * is a collection of trips, though, and printing two of them as one route * draws a line from the last stop of the first to the first stop of the * second: a leg nobody travelled, usually the longest one on the page. * * Stored rather than derived because the points are frozen into the element * when it is placed. Without it, bringing a map up to date would quietly turn * a map of Iceland back into a map of everywhere. */ tripId: z.number().int().positive().nullable().default(null), }); /** The looks of a summary, `classic` first: what every summary drew before there were looks. */ export const BOOK_STATS_LOOKS = [ 'classic', 'poster', 'ticket', 'tiles', 'band', 'medal', 'odometer', 'sign', 'note', 'sketch', ] as const; export type BookStatsLook = (typeof BOOK_STATS_LOOKS)[number]; /** The looks of a country, `classic` first for the same reason. */ export const BOOK_COUNTRY_LOOKS = ['classic', 'stamp', 'postcard', 'visa', 'flag', 'pin', 'label', 'sketch'] as const; export type BookCountryLook = (typeof BOOK_COUNTRY_LOOKS)[number]; /** * How a mark is set. The first four are what marks have always been; `stamp` * is an inked passport stamp, `tag` a luggage label with its eyelet, `pill` * the icon in a disc at the start of a capsule, `ribbon` a banner with forked * tails, `sticker` a die-cut sticker with its white edge, and `patch` an * embroidered patch with its stitching, and `sketch` a frame drawn by hand. */ export const BOOK_BADGE_STYLES = [ 'plain', 'chip', 'outline', 'stacked', 'stamp', 'tag', 'pill', 'ribbon', 'sticker', 'patch', 'sketch', ] as const; export type BookBadgeStyle = (typeof BOOK_BADGE_STYLES)[number]; export const BOOK_METRICS = ['distance', 'days', 'steps', 'photos', 'countries', 'places', 'furthest'] as const; export type BookMetric = (typeof BOOK_METRICS)[number]; export const bookStatsElementSchema = z.object({ ...elementBase, ...typeset, kind: z.literal('stats'), /** Which figures, in the order they are drawn. */ metrics: z.array(z.enum(BOOK_METRICS)).max(7).default(['distance', 'days', 'steps', 'photos']), layout: z.enum(['grid', 'row', 'column']).default('grid'), /** * How the figures are drawn. Absent, or `classic`, is the icon, figure and * caption every summary placed so far draws: optional rather than defaulted, * so a stored book reads and saves exactly as it did. The others are newer * compositions: one figure set as a poster with the rest beside it, a ticket * stub, a tile per figure, a band of figures between hairlines, a round * medal per figure with its name running round the rim, the drums of an * odometer, a road sign listing them, a handwritten note, and a pencil * sketch with a hand-drawn frame. */ look: z.enum(BOOK_STATS_LOOKS).optional(), showIcons: z.boolean().default(true), units: bookUnitsSchema.default('metric'), /** * Metric to value. Distance is metres, everything else a plain count. * * Anything that is not a metric is dropped rather than rejected: both readers * only ever index by a `BOOK_METRICS` member, so a stray key is payload that * gets persisted and re-served to every viewer without ever being drawn. * Filtered instead of typed as `z.record(z.enum(BOOK_METRICS), …)`, because on * zod 4 an enum key makes the record exhaustive — every metric would become * required and every stats element written so far would fail, then be thrown * away as unreadable. */ values: z .record(z.string(), z.number().finite()) .default({}) .transform((v) => Object.fromEntries(Object.entries(v).filter(([k]) => (BOOK_METRICS as readonly string[]).includes(k))), ), }); export const bookCountriesElementSchema = z.object({ ...elementBase, ...typeset, kind: z.literal('countries'), /** ISO-3166-1 alpha-2, in visit order. */ codes: z.array(z.string().length(2)).max(MAX_BOOK_COUNTRIES).default([]), /** Names as resolved when placed, so the page does not depend on a lookup. */ names: z.array(z.string().max(MAX_COUNTRY_NAME)).max(MAX_BOOK_COUNTRIES).default([]), layout: z.enum(['list', 'grid', 'column']).default('list'), /** The silhouette behind each name — the thing that makes the page read as a map. */ showOutline: z.boolean().default(true), showFlag: z.boolean().default(false), showName: z.boolean().default(true), align: z.enum(['left', 'center', 'right']).default('center'), /** * How a country is drawn. Absent, or `classic`, is the name over its * silhouette that every country placed so far draws, so a stored book stays * as it was. `stamp` sets it as a round passport stamp, `postcard` as * an airmail card, `visa` as an entry stamp with cut corners, `flag` as the * flag in cloth, `pin` as a map pin carrying the flag, and `label` as an old * luggage label with the name on a ribbon, `sketch` as the outline drawn by * hand and hatched, with the name written under it. */ look: z.enum(BOOK_COUNTRY_LOOKS).optional(), }); export const BOOK_BADGES = [ 'flag', 'date', 'day', 'coords', 'country', 'distance', 'weather', 'altitude', 'mood', ] as const; export type BookBadgeVariant = (typeof BOOK_BADGES)[number]; /** * The small marks: a flag, a date set as a numeral, a "DAY 5" chip, a line of * coordinates. Individually trivial, and collectively most of what makes a * printed travel page look composed rather than typed. */ export const bookBadgeElementSchema = z.object({ ...elementBase, ...typeset, kind: z.literal('badge'), variant: z.enum(BOOK_BADGES).default('date'), /** The resolved value — "13", "48°51'N 2°21'E", "ICELAND". */ text: z.string().max(200).default(''), /** The line under it — a month, a place, a unit. */ sub: z.string().max(200).default(''), /** * What the mark is *of*, when the drawing needs more than the words. * * ISO-3166-1 alpha-2 for a flag or a country silhouette, and the journal's * own key for a mood or a weather mark — `amazing`, `partly` — which is what * lets the icon and the palette come from the journey page rather than being * listed a second time here. * * It was two characters once, back when only flags used it, and a mood mark * placed against that contract took the whole book down with it: the save was * refused and the document normalised to nothing. A country code is still two * characters; the field is simply no longer only that. */ code: z.string().max(24).nullable().default(null), style: z.enum(BOOK_BADGE_STYLES).default('plain'), /** * Let the mark pick its own text colour. * * The same bargain the page numbers make: automatic until somebody chooses, * and choosing is what turns it off. A chip is a filled capsule, so its words * have to answer to the fill rather than to the page: white on ink, ink on a * pale accent. Doing that by hand means changing two colours every time you * change one, and getting it wrong means a black day counter on a black chip, * which is what happened before this existed. */ autoColor: z.boolean().default(true), /** * Whether the mark draws its icon, and whether it draws its words. * * Two flags rather than one three-valued mode, because that is what the * renderer actually asks — and because a document written before they * existed reads `undefined` as "draw it", which is what it always did. The * panel offers the three combinations that mean something (both, icon only, * words only); an empty mark is not one of them. */ showIcon: z.boolean().default(true), showLabel: z.boolean().default(true), /** * The icon's own colour, and whether it picks it. * * Automatic is a mood's palette from the journal — the pink of "amazing", * the amber of "good" — and the element's accent for everything else, which * is what these marks have always drawn. It has to stay a flag rather than a * nullable colour: every badge already in a book carries `iconColor`'s * default once it is parsed, and reading that as a choice would repaint every * mood mark in the field to a flat near-black. */ autoIconColor: z.boolean().default(true), iconColor: hex.default('#111111'), }); /** * A drawing from the icon set the app is already built out of. * * Not a member of BOOK_SHAPES, and deliberately: a shape is a filled outline * the editor scales by moving its points, while an icon is a stroked drawing of * several parts that has to keep its proportions to stay legible — a compass * stretched to twice its width stops reading as a compass. They also come from * opposite places. A shape is drawn here and lives in this file; an icon is one * of about fourteen hundred names in lucide, and listing them in the contract * would mean a document that stops parsing the day the library renames one. * * So the name travels as a bounded string and the renderer resolves it, falling * back the way the rest of TREK already does for a plugin's icon. A book that * outlives an icon loses that drawing, not the page it was on. */ export const bookIconElementSchema = z.object({ ...elementBase, kind: z.literal('icon'), /** A lucide export name, PascalCase — "Compass", "Plane", "MountainSnow". */ name: z .string() .regex(/^[A-Z][A-Za-z0-9]*$/, 'expected a lucide icon name') .max(60), color: hex.default('#111827'), ...themed.THEMED_ICON, /** * How heavy the drawing is, against lucide's own 24-unit grid — not * millimetres, which is why it is not called `strokeWidth`: three separate * places in the client rescale a field of that name by the page width when a * spread moves between books, and an icon's weight is not a length. */ lineWidth: z.number().min(0.25).max(4).default(2), }); /** * An illustration from the Studio's sticker library (stickers/, book-sticker.ts). * * The drawing is not in the document, only its id: the library is code that * ships with the app, so a book stays small and every copy of a sticker draws * the same. Like an icon, an id this build does not know draws nothing and * costs the page nothing else. `colors` repaints the sticker's slots in order, * empty for its own colours; `flipX` mirrors it, for a bird facing the other * way or an arrow pointing back. */ export const bookStickerElementSchema = z.object({ ...elementBase, kind: z.literal('sticker'), stickerId: z .string() .regex(/^[a-z][A-Za-z0-9]*$/, 'expected a sticker id') .max(48), colors: z.array(hex).max(MAX_STICKER_SLOTS).default([]), flipX: z.boolean().default(false), }); /** * A journal entry's pros and cons, set as a page. * * TREK's entries already carry them as two lists, and two lists is exactly what * a text element cannot express: run together as a paragraph they lose the * pairing, and run as one column they lose which side each line is on. Two * marked columns is how a travel diary has always printed this. */ export const bookListElementSchema = z.object({ ...elementBase, ...typeset, kind: z.literal('list'), items: z .array( z.object({ text: z.string().max(400), /** Which column the line belongs to, and which mark it gets. */ tone: z.enum(['pro', 'con', 'plain']).default('plain'), }), ) .max(60) .default([]), /** Pros beside cons, or one column with the marks inline. */ layout: z.enum(['columns', 'stacked']).default('columns'), showMarks: z.boolean().default(true), /** A heading over each column — empty for none. */ proLabel: z.string().max(80).default(''), conLabel: z.string().max(80).default(''), }); /** * How much may stand on one spread. * * A limit rather than a taste: the document is saved whole on every autosave, * and a page nobody could design is a page somebody's browser has to serialise. * * Sixty was below what this app's own layouts draw. A full itinerary list is * two columns of thirteen stops, and a stop costs three elements — its date, * its name and the rule under it — so the spread the auto layout produces is * eighty, and it was refused every time (#2085). A limit the product cannot * build to is not a limit, it is a bug with a constant in front of it. * * Ninety rather than eighty, so the next row of anything does not land here * again. It stays a serialisation limit: an element of this kind measures a few * hundred bytes, which puts the largest document the contract admits at under * four megabytes against the book route's eight (server/src/bootstrap.ts). */ export const MAX_SPREAD_ELEMENTS = 90; const bookElementUnion = z.discriminatedUnion('kind', [ bookPhotoElementSchema, bookTextElementSchema, bookShapeElementSchema, bookMapElementSchema, bookStatsElementSchema, bookCountriesElementSchema, bookBadgeElementSchema, bookListElementSchema, bookIconElementSchema, bookStickerElementSchema, bookLineElementSchema, ]); type BookElementUnion = typeof bookElementUnion; /** * The element contract under a name of its own. The declarations of every * schema that holds elements (a spread, a document, the assistant's cards and * renders) then refer to it rather than spelling out all eleven kinds again; * those copies made the server's typecheck run out of memory. The one member * keeps the interface from being empty; it is the union's own. */ export interface BookElementSchema extends BookElementUnion { readonly options: BookElementUnion['options']; } export const bookElementSchema: BookElementSchema = bookElementUnion; export type BookElement = z.infer; export type BookIconElement = z.infer; export type BookStickerElement = z.infer; export type BookLineElement = z.infer; export type BookPhotoElement = z.infer; export type BookTextElement = z.infer; export type BookShapeElement = z.infer; export type BookMapElement = z.infer; export type BookStatsElement = z.infer; export type BookCountriesElement = z.infer; export type BookBadgeElement = z.infer; export type BookListElement = z.infer; export type BookUnits = z.infer; /** A named group of a spread. */ export const bookGroupSchema = z.object({ id: z.string().min(1).max(BOOK_GROUP_ID_MAX), name: z.string().max(BOOK_LAYER_NAME_MAX).optional(), }); export type BookGroup = z.infer; const bookSpreadObject = z.object({ id: z.string().min(1), /** * Which leaf of the bound book this is. * * 'inner' is a double spread; the other four are single pages. 'cover' and * 'back' are the outside of the book. 'first' is the right-hand page a book * opens onto, and 'last' the left-hand page before the back cover: the * inside of each cover is blank in a bound book, so the first and the last * inner page stand alone rather than facing anything (#2317). Both are * optional so that a document from before they existed still reads. */ role: z.enum(['cover', 'first', 'inner', 'last', 'back']).default('inner'), background: hex.nullable().default(null), /** A page painted beyond one flat colour; `background` keeps the colour that stands for it. */ backgroundPaint: bookPaintFieldSchema, ...themed.THEMED_SPREAD, /** Back to front. */ elements: z.array(bookElementSchema).max(MAX_SPREAD_ELEMENTS).default([]), /** * Content that belongs to this spread but is not currently placed. * * Changing to a layout with fewer frames must not destroy the pictures the old * one held — the user is trying an arrangement, not deleting their photographs, * and a single wrong click on a text-only layout would otherwise cost them * everything on the page. Parked elements come back the moment a layout with * room for them is applied. */ parked: z.array(bookElementSchema).max(MAX_SPREAD_ELEMENTS).default([]), /** The journey entry this spread was generated from, if any. */ entryId: z.number().int().nullable().default(null), /** * The names of the spread's groups. Membership is on the elements (`group`); * a group without a row here is simply unnamed. A list that does not read is * dropped rather than the page. */ groups: z.array(bookGroupSchema).max(MAX_SPREAD_ELEMENTS).optional().catch(undefined), }); type BookSpreadObject = typeof bookSpreadObject; /** The spread contract under a name of its own, for the same reason as `BookElementSchema`. */ export interface BookSpreadSchema extends BookSpreadObject { readonly shape: BookSpreadObject['shape']; } export const bookSpreadSchema: BookSpreadSchema = bookSpreadObject; export type BookSpread = z.infer; /** * Page numbers. * * Set here, on the document, and filled by the renderer — which is the only * place that knows what number a given page is. Letting pdf-lib stamp them * afterwards would put a different number in the PDF than the one the editor * showed, and a book you cannot proofread on screen is not much of an editor. * * `startAt` exists because the first inner spread is rarely page 1 in the * finished object: the cover is a separate sheet, and some binders count it and * some do not. */ export const bookPageNumbersSchema = z.object({ show: z.boolean().default(false), /** Which number the first inner spread's left page carries. */ startAt: z.number().int().min(0).max(9999).default(2), position: z.enum(['outer', 'inner', 'centre']).default('outer'), /** Millimetres from the trim edge. */ margin: mm.default(12), size: z.number().min(4).max(48).default(8), /** * Pick the folio colour from what it lands on, rather than using `color`. * * A book has dark pages and light ones, and one fixed colour is wrong on half * of them — a grey folio disappears into a full-bleed night photograph and * again into a black page. The renderer decides per page; `color` is what it * uses when this is off, and choosing a colour turns it off. */ autoColor: z.boolean().default(true), color: hex.default('#8a8578'), font: bookCatalogueIdSchema(BOOK_FONTS_IDS).default('sans'), }); export type BookPageNumbers = z.infer; export const bookPageSetupSchema = z.object({ preset: z .enum(['square-210', 'square-300', 'a4-landscape', 'a4-portrait', 'a5-landscape', 'custom']) .default('square-210'), /** * A single page. A double spread is drawn twice this wide. * * These four fall back rather than fail, like `version` below. The salvage * pass only strips unreadable *elements*, so a page block the contract * cannot read takes the whole book down to an empty one — and that empty * book is what the server writes back. A nonsense dimension degrading to the * preset is the smaller loss by a long way. */ pageWidth: mm .refine((v) => v > 0, 'page width must be positive') .catch(210) .default(210), pageHeight: mm .refine((v) => v > 0, 'page height must be positive') .catch(210) .default(210), bleed: mm .refine((v) => v >= 0, 'bleed cannot be negative') .catch(3) .default(3), safe: mm .refine((v) => v >= 0, 'safe margin cannot be negative') .catch(5) .default(5), pageNumbers: bookPageNumbersSchema.default(() => bookPageNumbersSchema.parse({})), }); export type BookPageSetup = z.infer; /** How many layouts a book keeps for reuse (#2316). */ export const MAX_BOOK_LAYOUTS = 24; /** * A spread's arrangement, kept to lay other pages out with (#2316). * * The design as drawn, in the millimetres of the page it was drawn on, with the * pictures taken out: applying it to another page pours that page's photos and * words into it, and the page size it came from scales it onto a book whose * trim changed since. Stored in the book rather than per person, so everyone * editing the book sees and uses the same set. */ export const bookLayoutSchema = z.object({ id: z.string().min(1).max(40), name: z.string().trim().min(1).max(60), /** Double spread or single page: a layout only fits the kind it was drawn on. */ role: z.enum(['inner', 'single']).default('inner'), pageWidth: z.number().positive().max(2000), pageHeight: z.number().positive().max(2000), background: hex.nullable().default(null), backgroundPaint: bookPaintFieldSchema, elements: z.array(bookElementSchema).max(MAX_SPREAD_ELEMENTS).default([]), }); export type BookLayout = z.infer; const bookDocumentObject = z.object({ /** In the document, not in a column: a format bump must not need a migration. */ version: z.literal(1).catch(1).default(1), /** The format revision that wrote it (book-schema-rev.ts); absent, or unreadable and so dropped, reads as 1. */ schemaRev: z.number().int().min(1).optional().catch(undefined), title: z.string().max(MAX_BOOK_TITLE).default(''), page: bookPageSetupSchema.default(() => bookPageSetupSchema.parse({})), spreads: z.array(bookSpreadSchema).max(MAX_SPREADS).default([]), /** The book's own layouts (#2316). Optional so a document from before still reads. */ layouts: z.array(bookLayoutSchema).max(MAX_BOOK_LAYOUTS).optional(), /** Palette, typefaces and motifs; elements link to it through the fields of theme/book-theme.schema.ts. */ theme: themed.bookThemeFieldSchema, }); type BookDocumentObject = typeof bookDocumentObject; /** The document contract under a name of its own, for the same reason as `BookElementSchema`. */ export interface BookDocumentSchema extends BookDocumentObject { readonly shape: BookDocumentObject['shape']; } export const bookDocumentSchema: BookDocumentSchema = bookDocumentObject; export type BookDocument = z.infer;