mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-25 00:13:00 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09f286fbfe | |||
| 4cc62f4c4e | |||
| cb34c46226 | |||
| e777118272 | |||
| 3f2da7a867 | |||
| 9c3649f011 | |||
| 73fdbdc0c3 | |||
| 0879b5aecc | |||
| 9729b0b575 | |||
| 35acdb8ba1 | |||
| a19fdf6161 | |||
| 813dd7a7eb | |||
| d51b9de2b0 | |||
| 6254510e0f | |||
| c525b2f84c | |||
| b3d30a834e | |||
| 589d3354eb | |||
| 8ce3b24a01 | |||
| 06a1dcd730 |
@@ -62,7 +62,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
|
||||
<br />
|
||||
|
||||
Most of what follows is an addon an admin switches on or off. Lists, Costs, Documents, Collab, Vacay and Atlas ship on; Journey, Collections, MCP, AI Parsing and AirTrail ship off and are marked below.
|
||||
Most of what follows is an addon an admin switches on or off. Lists, Costs, Documents, Collab, Tours, Vacay and Atlas ship on; Journey, Collections, MCP, the AI assistant, AI Parsing, Road trip, Dawarich and AirTrail ship off and are marked below.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@@ -70,76 +70,110 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
|
||||
|
||||
#### 🧭 Planning
|
||||
|
||||
- **Day plans**: drag places between days and reorder inside a day, with undo. Notes and bookings drag the same way, and a map marker drops straight onto a day
|
||||
- **Maps**: Leaflet, Mapbox GL or MapLibre GL (OpenFreeMap, no token), with clustering, photo markers and route lines. 3D buildings and terrain are Mapbox only
|
||||
- **Place search**: Google Places when a key is set (photos, ratings, opening hours), otherwise OpenStreetMap with no key
|
||||
- **Place enrichment**: descriptions, facts, hours and photo candidates from OpenStreetMap, Wikipedia, Wikidata and Wikimedia Commons
|
||||
- **POI explore**: pull OpenStreetMap POIs by category for the current viewport over Overpass
|
||||
- **Import**: shared Google Maps and Naver Maps lists, plus GPX, KML and KMZ files
|
||||
- **Export**: GPX of a trip's places and tracks, and an ICS feed per trip or across all of them
|
||||
- **Routes**: auto-sort a day (nearest neighbour then 2-opt, locked stops and hotel anchors stay put), driving, walking or cycling profiles over OSRM, then open it in Google Maps or CoMaps
|
||||
- **Public transport**: door-to-door itineraries over Transitous
|
||||
- **Weather**: 16-day forecast from Open-Meteo, no key. Dates outside that window read the archive for the same date instead
|
||||
- **Day notes**: markdown body with an icon and a colour, reordered by drag and drop or moved to another day
|
||||
- **Trip dates**: move a trip and the days re-date themselves, either dragging the bookings along or re-anchoring them. Trips also copy and archive
|
||||
- **Day plans**: drag places, notes and bookings between days with undo, or drop a map marker straight onto a day
|
||||
- **Maps**: Leaflet, Mapbox GL or MapLibre GL (OpenFreeMap, no token) with clustering, photo markers, route lines and a whole-trip route overview
|
||||
- **Places**: search the TREK Places index and OpenStreetMap with no key, or Google Places and Amap with one. Descriptions, hours and photos from Wikipedia, Wikidata and Wikimedia Commons, plus a rating and a custom image per place
|
||||
- **Explore**: nearby places by category for the map view, from the TREK Places index with Overpass as fallback
|
||||
- **Routes**: auto-sort a day (nearest neighbour then 2-opt, locked stops and hotels stay put), a travel mode per leg over OSRM or Valhalla, then open the day in Google Maps or CoMaps and a place in seven navigation apps
|
||||
- **Public transport**: door-to-door itineraries over Transitous, or Google where Transitous has no data
|
||||
- **Import and export**: Google Maps and Naver Maps lists, Google Maps directions links, GPX, KML and KMZ in; GPX and an ICS feed per trip or across all trips out
|
||||
- **Weather**: 16-day forecast from Open-Meteo, no key, and the archive for dates beyond it
|
||||
- **Trip dates**: move a trip and its days re-date themselves, with the bookings along or re-anchored. Trips also copy and archive
|
||||
- **Road trip** (off by default): drives with stops, day windows and arrival times that recompute, route alternatives, hazards and EV charging with range
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🧳 Bookings and money
|
||||
#### 🥾 Tours
|
||||
|
||||
- **Reservations**: 16 booking types with status, confirmation code, travellers and attached files
|
||||
- **Flights and trains**: several legs with stopovers, per-leg times and endpoint timezones, against 4,045 bundled airports so local times resolve without a key
|
||||
- **Accommodation**: a stay spans a range of days with a check-in window, and shows on every night it covers
|
||||
- **Booking import**: EML, PDF, PKPass, HTML and TXT confirmations through [KItinerary](https://invent.kde.org/pim/kitinerary). Needs the `kitinerary-extractor` binary, which ships in the Docker image
|
||||
- **AirTrail** (off by default): link a self-hosted AirTrail instance to import flights as reservations and keep them in sync
|
||||
- **Costs**: split expenses in integer cents with equal or custom shares, several payers per expense, settle-up suggestions, a settlement log, and CSV export
|
||||
- **Currencies**: a currency per expense with the rate frozen at entry. Rates come from Frankfurter, no key
|
||||
- **Packing lists**: categories, admin-managed templates, assignees, three visibility tiers, and a packed-of-total line. Bags with weight roll-up are a separate admin switch, off by default
|
||||
- **To-dos**: assignee, due date, priority, and a reminder before one falls due
|
||||
- **Files**: attach to a trip, place, day entry or reservation. 50 MB each, 500 MB for video, with trash and restore
|
||||
- **PDF export**: cover page, place photos, day notes, bookings and costs, with an optional page break per day
|
||||
- **Seven tour types**: hike, city walk, run, bike (touring, road, gravel, MTB), paddle (kayak, canoe, SUP), horse ride, and ski and winter (alpine, cross-country, touring, snowshoe, winter hike)
|
||||
- **Routing**: legs routed over Valhalla with shaping points, hand-drawn legs, route variants, round hikes of a wished length and trip places as stops, all with undo
|
||||
- **Hikes**: routed within a maximum SAC grade (T1 to T6), the trail grades along the way, and via ferratas on the map
|
||||
- **Terrain**: elevation profile, ascent and descent, and a 3D terrain map with grade colouring and a flyover, no token needed. Topo, hiking, cycling and satellite bases with sea marks and ski pistes as overlays
|
||||
- **Multi-day tours**: one route cut into stages and spread across the trip's days
|
||||
- **Along the way**: viewpoints, huts, water, food and more within a corridor of the route, inserted as a stop with the detour it adds
|
||||
- **Ski, paddle and city walks**: a piste router by difficulty and lift type; a water router with portages, locks and a wind forecast; city walks with stays, opening hours and transit between stops
|
||||
- **Plan against reality**: link a recorded GPX to a planned day and compare distance, ascent and time. Import trails from a wanderer instance, save tours to a collection and take them into another trip
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🧳 Bookings and money
|
||||
|
||||
- **Reservations**: 18 booking types with status, confirmation code, travellers and attached files
|
||||
- **Flights and trains**: several legs with stopovers and endpoint timezones, against 4,045 bundled airports so local times resolve without a key
|
||||
- **Accommodation**: a stay spans a range of nights with a check-in window
|
||||
- **Booking import**: EML, PDF, PKPass, HTML, TXT and photos of confirmations, through [KItinerary](https://invent.kde.org/pim/kitinerary) or an AI model, with a review step before anything is saved. Prices become linked costs
|
||||
- **AirTrail** (off by default): import flights from a self-hosted AirTrail instance and keep them in sync
|
||||
- **Costs**: split in integer cents with equal or custom shares, several payers, settle-up suggestions, a per-person summary, a settlement log and CSV export. A receipt photo can fill in an expense
|
||||
- **Currencies**: one per expense with the rate frozen at entry, from Frankfurter with no key
|
||||
- **Packing lists and to-dos**: categories, templates, assignees and visibility tiers; bags with weights as an admin switch; to-dos with due dates, priorities and reminders
|
||||
- **Files**: attach to a trip, place, day or booking, 50 MB each and 500 MB for video, with trash and restore
|
||||
- **PDF export**: cover, place photos, day notes, bookings and costs, with an optional page break per day
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 👥 Collaboration
|
||||
|
||||
- **Real-time sync** (WebSocket): edits land live for everyone who has that trip open
|
||||
- **Members**: add by email or username, hand ownership to someone else, or add guests who have no login at all
|
||||
- **Permissions**: an admin maps each of 16 trip actions to admin, trip owner, trip member or everybody
|
||||
- **Invite links**: one reusable link per trip with an optional expiry. Admins can also issue signup invites with a use limit that drop the new account straight into a trip
|
||||
- **Public share**: a read-only trip page anyone can open without an account
|
||||
- **Collab**: group chat with replies, reactions and link previews, shared notes with attachments, polls, and a What's Next list of upcoming activities. Each of the four switches on its own
|
||||
- **Real-time sync** (WebSocket): edits land live for everyone who has the trip open
|
||||
- **Members**: add by email or username, hand over ownership, or add guests who have no login at all
|
||||
- **Permissions**: an admin maps each of 17 trip actions to admin, trip owner, trip member or everybody
|
||||
- **Invites**: one reusable link per trip with an optional expiry, and admin signup invites that drop a new account straight into a trip
|
||||
- **Public share**: a read-only trip page without an account, with switches for map, bookings, packing, costs and chat
|
||||
- **Collab**: group chat with replies, reactions and link previews, shared notes, links, polls and a What's Next list, each one its own switch
|
||||
- **Notifications**: an inbox grouped by day with answers right from the notification, and a per-user matrix of events against in-app, email, webhook, ntfy, Web Push and plugin channels
|
||||
|
||||
#### 📔 Journal, Atlas and Vacay
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
- **Journey** (off by default): dated entries with story, mood, weather and tags, photos and video from uploads or a linked [Immich](https://immich.app) or Synology Photos library, map views, co-authors, and a public share link
|
||||
- **Atlas**: mark countries and sub-national regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, plus a bucket list, travel stats and a consecutive-years-travelled count
|
||||
- **Vacay**: leave calendar with half days, public holidays from date.nager.at, school holiday overlays for 16 European countries, carry-over between years, and joint or read-only sharing of a plan
|
||||
- **Collections** (off by default): a place library outside any trip, with labels, ratings, a custom image per place, copy-into-a-trip, and sharing by invite
|
||||
#### 📔 Journey and TREK Studio
|
||||
|
||||
- **Journey** (off by default): dated entries with story, mood, weather and tags, photos and video from uploads, [Immich](https://immich.app) or Synology Photos, map views, co-authors, drafts and a public share link
|
||||
- **Linked journal**: a trip can show one journal as its own, with the members picked on the trip as viewers or editors
|
||||
- **Recorded days** (with Dawarich): keep a day's recorded route and turn its stays into entries
|
||||
- **TREK Studio**: turn a journal into a printable photo book. 34 print-licensed typefaces, 17 themes, 108 designed pages in twelve styles, 160 recolourable stickers, text effects, layers and a Travel tab with route maps, country stamps and trip figures
|
||||
- **Auto layout**: lays the whole book on the designed pages in the style of its theme, every photo in a frame, or rebuilds one spread
|
||||
- **Print and share**: PDF export as pages or spreads with crop marks, several people editing at once, and design files to move a spread into another book
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🗺️ Atlas, Vacay and Collections
|
||||
|
||||
- **Atlas**: countries and regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, a bucket list, travel stats, and a tour activities tab with planned against recorded totals
|
||||
- **Vacay**: leave calendar with half days, public holidays from date.nager.at, school holidays for 16 European countries, company holidays, carry-over between years, and joint or read-only sharing
|
||||
- **Collections** (off by default): a place library outside any trip with lists, labels, ratings, a status per place, cover images, GPX and file export, copy into a trip, and sharing by invite
|
||||
- **Dawarich** (off by default): link a Dawarich instance for recorded tracks, suggested stays and countries for the Atlas
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🤖 AI assistant and MCP
|
||||
|
||||
- **AI assistant** (off by default): a chat that plans with you in a trip, across all your trips, in a journal and in Studio, and works with tours, costs, bookings, packing, Vacay and the Atlas
|
||||
- **Your own model**: an API key for Anthropic, OpenAI, Gemini or OpenRouter, a ChatGPT plan, or any OpenAI-compatible endpoint, set for the instance or per user
|
||||
- **You stay in charge**: reads run at once, every change waits as a card to apply or discard and can be undone for 15 minutes. Nothing leaves before you agree to the provider, and a file's text only with your consent
|
||||
- **Attachments**: drop a booking confirmation, a GPX file or a photo into the chat, and the assistant offers what to do with it
|
||||
- **MCP server** (off by default): OAuth 2.1 with mandatory PKCE and dynamic client registration, 410 tools, 30 resources and 4 prompts, 35 scopes in 17 groups ticked on the consent screen, and every call in the audit log
|
||||
- **Addon-aware**: twelve addon gates decide which tools a session sees, and flipping one re-registers live sessions
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🧩 Plugins
|
||||
|
||||
- **Third-party plugins**: install from the TREK registry or sideload a zip, switch on per instance, with their own pages under `/plugins/<id>`
|
||||
- **Sandboxed**: one child process per plugin, 63 grantable permissions, an admin-edited outbound host allowlist, memory and RPC caps, and daily caps on AI and notification calls
|
||||
- **Trust**: registry downloads are pinned by sha256 and checked against the author's minisign key. A sideloaded zip is marked unverified, and `TREK_PLUGINS_ENABLED=false` turns the whole system off
|
||||
- **Extension points**: map markers and layers, place details, day schedules, PDF sections, Atlas layers, journal rows, trip warnings, calendar sources, route profiles and notification channels. Plugin pages run in an opaque-origin iframe
|
||||
- **SDK**: [`trek-plugin-sdk`](https://www.npmjs.com/package/trek-plugin-sdk) on npm, with a manifest validator, a mock host, and a dev-link mode that runs a local build against real data
|
||||
|
||||
#### 🤖 AI and MCP
|
||||
|
||||
- **MCP server** (off by default): OAuth 2.1 with mandatory PKCE and dynamic client registration. 199 tools, 30 resources, 4 prompts
|
||||
- **Scopes**: 29 scopes in 14 groups, each one tickable on the consent screen. Tokens are bound to the `/mcp` resource, and every tool call lands in the audit log
|
||||
- **Reach**: create trips and days, edit places, packing lists, to-dos, costs, reservations, collections and journeys, mark countries visited, all inside the scopes the token holds
|
||||
- **Prompts**: `trip-summary`, plus `packing-list` and `budget-overview` when those addons are on
|
||||
- **Addon-aware**: seven addon gates decide which tools and resources a session sees. Flipping one drops live sessions so the surface re-registers
|
||||
- **Booking extraction** (off by default): read a confirmation with a local Ollama server, any OpenAI-compatible endpoint, or Anthropic, configured instance-wide or per user
|
||||
- **Third-party plugins**: install from the TREK registry or sideload a zip, with their own pages under `/plugins/<id>`
|
||||
- **Sandboxed**: one child process per plugin, 66 grantable permissions, an admin-edited outbound host allowlist, memory and call caps
|
||||
- **Trust**: registry downloads are pinned by sha256 and checked against the author's minisign key, a sideloaded zip is marked unverified, and `TREK_PLUGINS_ENABLED=false` turns the whole system off
|
||||
- **Extension points**: map layers, place details, day schedules, PDF sections, Atlas layers, journal rows, trip warnings, search and photo providers, route profiles, notification channels and MCP tools
|
||||
- **SDK**: [`trek-plugin-sdk`](https://www.npmjs.com/package/trek-plugin-sdk) with a CLI to create, validate, run against real data, sign and publish
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -148,30 +182,26 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
|
||||
|
||||
#### 📱 Mobile and offline
|
||||
|
||||
- **Installable**: iOS and Android straight from the browser, no App Store. Runs standalone without browser chrome, with the status bar tinted per theme
|
||||
- **Phone layouts**: a separate shell below 768px with its own tokens, a bottom bar and safe-area insets. Which items sit in that bar is up to the user
|
||||
- **Offline reads**: the app shell and every route chunk are precached, and trips, places and file blobs live in a per-user IndexedDB store, so a trip opens with no network
|
||||
- **Offline writes**: mutations queue and replay with an `X-Idempotency-Key`, so a reconnect cannot double-apply. A stale edit is parked for a keep-mine or keep-theirs choice
|
||||
- **Offline maps**: pre-download a trip's raster tiles, and clear them again
|
||||
- **API responses are never cached** by the service worker. They vary per session, so they always come from the network
|
||||
- **Installable**: iOS and Android straight from the browser, no App Store, standalone with a themed status bar
|
||||
- **Phone layouts**: their own screens for every part of the app, with a bottom bar and dashboard widgets the user orders
|
||||
- **Offline reads**: the app shell is precached and trips, places, tours, files and nearby places live in a per-user IndexedDB store, so a trip opens with no network
|
||||
- **Offline writes**: changes queue and replay with an `X-Idempotency-Key`, and a stale edit waits for a keep-mine or keep-theirs choice
|
||||
- **Offline maps**: raster and vector tiles of a trip downloaded ahead, and a switch that takes the whole app offline on purpose
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### ⚙️ Admin, accounts and security
|
||||
|
||||
- **Login methods**: password login, password registration, OIDC login, OIDC registration and passkey login each switch on and off on their own. `OIDC_ONLY` leaves SSO as the only way in
|
||||
- **SSO (OIDC)**: one provider by discovery, with PKCE and `id_token` verification (Authentik, Keycloak, Google and the like)
|
||||
- **2FA**: TOTP with ten single-use backup codes, and an admin can require it instance-wide
|
||||
- **Passkeys**: WebAuthn login by fingerprint, face, PIN or security key, off until an admin enables it. A passkey also satisfies the 2FA requirement
|
||||
- **Hardening**: per-IP limits on login, password reset and 2FA attempts, a password policy, secrets encrypted at rest and masked on read, and an SSRF guard on every URL you configure
|
||||
- **Admin panel**: users and invites, the permission matrix, packing templates, categories, addons, plugins, API keys, MCP tokens and OAuth sessions, backups, storage, audit log, and GitHub releases
|
||||
- **Backups**: manual or scheduled hourly, daily, weekly or monthly, with retention in days. The zip carries the at-rest key, so a restore can decrypt its own secrets
|
||||
- **Storage**: pluggable storage backends per content category — keep everything on local disk, or add S3-compatible backends and replicate any category to them, configured entirely from the admin panel
|
||||
- **Notifications**: a per-user matrix of events against in-app, email (SMTP), webhook, ntfy and Web Push (on every device you turn it on for, even with TREK closed), plus any channel a plugin registers
|
||||
- **Appearance**: light, dark or follow the OS, seven colour schemes plus a custom accent, transparency, compact density, reduce motion, and text size per tier
|
||||
- **Sign-in**: password, OIDC and passkeys each switch on and off on their own, `OIDC_ONLY` leaves SSO as the only way in, and an OIDC claim can grant admin
|
||||
- **SSO and 2FA**: one OIDC provider by discovery with PKCE (Authentik, Keycloak, Google and the like), TOTP with backup codes that an admin can require, and passkeys that count as the second factor
|
||||
- **Sessions**: every sign-in is a session you can see and end, one by one or all others at once
|
||||
- **Hardening**: rate limits on sign-in, reset and 2FA, a password policy, secrets encrypted at rest and masked on read, and an SSRF guard on every URL you configure
|
||||
- **Admin panel**: users and the permission matrix, defaults for new users, categories and templates, addons, plugins, storage, notifications, MCP tokens, backups, audit log and releases
|
||||
- **Backups and storage**: scheduled backups with retention that restore their own secrets, and local or S3-compatible storage per content category with replicas
|
||||
- **Appearance**: light, dark or follow the OS, seven colour schemes plus a custom accent, transparency, density, reduced motion and text size
|
||||
- **27 languages**: en, de, es, fr, it, nl, hu, ru, zh, zh-TW, pl, cs, ar (right to left), br, id, tr, ja, ko, uk, gr, sv, vi, ca, az, et, sk, th
|
||||
- **In-app help**: the wiki ships inside the image and is served from disk at `/help`, so the docs match the version you are running
|
||||
- **Help center**: a ? in the top bar with 381 step-by-step guides with screenshots, searched together with the wiki that ships in the image
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"trek/no-swallowed-catch": 130,
|
||||
"max-params": 32,
|
||||
"trek/store-without-selector": 67,
|
||||
"react-hooks/refs": 556,
|
||||
"react-hooks/refs": 555,
|
||||
"react-hooks/set-state-in-effect": 194,
|
||||
"@typescript-eslint/no-floating-promises": 165,
|
||||
"react-hooks/exhaustive-deps": 116,
|
||||
|
||||
@@ -37,6 +37,11 @@ const STYLE_LOOK: Readonly<Record<BookDesignStyle, { labelKey: string }>> = {
|
||||
nordic: { labelKey: 'studio.theme.name.nordic' },
|
||||
alpine: { labelKey: 'studio.theme.name.alpine' },
|
||||
'india-stamp': { labelKey: 'studio.theme.name.indiaStamp' },
|
||||
riso: { labelKey: 'studio.theme.name.riso' },
|
||||
magazine: { labelKey: 'studio.theme.name.magazine' },
|
||||
'retro-travel': { labelKey: 'studio.theme.name.retroTravel' },
|
||||
midnight: { labelKey: 'studio.theme.name.midnight' },
|
||||
'field-notes': { labelKey: 'studio.theme.name.fieldNotes' },
|
||||
};
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-JRN-SHARE-001 to FE-JRN-SHARE-014
|
||||
// FE-JRN-SHARE-001 to FE-JRN-SHARE-015
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { http, HttpResponse, delay } from 'msw'
|
||||
@@ -245,4 +245,15 @@ describe('JourneyShareSection', () => {
|
||||
expect(plan).toHaveAttribute('aria-pressed', 'false')
|
||||
expect(screen.getByRole('button', { name: /Recorded routes/ })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-JRN-SHARE-015: a filled accent button sets its words in the colour made for it, not in the accent itself', async () => {
|
||||
server.use(http.get('/api/journeys/7/share-link', () => HttpResponse.json({ link: existingLink() })))
|
||||
render(<JourneyShareSection journeyId={JOURNEY_ID} />)
|
||||
|
||||
const filled = [await screen.findByRole('button', { name: 'Timeline' }), screen.getByRole('button', { name: 'Copy' })]
|
||||
for (const button of filled) {
|
||||
expect(button.className).toContain('text-accent-text')
|
||||
expect(button.className).not.toContain('text-accent-on')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function JourneyShareSection({ journeyId }: { journeyId: number }
|
||||
<span className="flex-1 text-caption text-content-muted truncate">{shareUrl}</span>
|
||||
<button type="button"
|
||||
onClick={copyLink}
|
||||
className="flex-shrink-0 px-3 py-1.5 rounded-full bg-accent text-accent-on text-caption font-semibold hover:bg-accent-hover transition-colors"
|
||||
className="flex-shrink-0 px-3 py-1.5 rounded-full bg-accent text-accent-text text-caption font-semibold hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
{copied ? t('journey.share.copied') : t('journey.share.copy')}
|
||||
</button>
|
||||
@@ -94,7 +94,7 @@ export default function JourneyShareSection({ journeyId }: { journeyId: number }
|
||||
onClick={() => togglePerm(key)}
|
||||
className={`flex items-center gap-2.5 px-3 py-2 rounded-xl border text-body font-medium transition-all ${
|
||||
link[key]
|
||||
? 'border-accent bg-accent text-accent-on'
|
||||
? 'border-accent bg-accent text-accent-text'
|
||||
: 'border-edge text-content-muted hover:border-content-faint'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -90,7 +90,7 @@ describe('the Templates panel', () => {
|
||||
it('narrows the set to one archetype from the style chips', () => {
|
||||
panel();
|
||||
const chips = screen.getByRole('radiogroup', { name: 'studio.templates.style' });
|
||||
expect(chips.querySelectorAll('[role="radio"]')).toHaveLength(8);
|
||||
expect(chips.querySelectorAll('[role="radio"]')).toHaveLength(13);
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'studio.templates.archetype.postcard' }));
|
||||
expect(
|
||||
screen.getByRole('radio', { name: 'studio.templates.archetype.postcard' }).getAttribute('aria-checked')
|
||||
|
||||
@@ -22,7 +22,7 @@ import { ThemeSheet } from './ThemeSheet';
|
||||
import { useStudioTheme } from './useStudioTheme';
|
||||
|
||||
/**
|
||||
* The Theme section of the rail: twelve built-in looks, the palette, the
|
||||
* The Theme section of the rail: seventeen built-in looks, the palette, the
|
||||
* typefaces and the motifs of the one being put together, and applying it to
|
||||
* the book or to the page that is open.
|
||||
*
|
||||
|
||||
@@ -1,100 +1,56 @@
|
||||
import type {
|
||||
BookBadgeStyle,
|
||||
BookBadgeVariant,
|
||||
BookColorToken,
|
||||
BookCountryLook,
|
||||
BookElement,
|
||||
BookMapElement,
|
||||
BookMapScope,
|
||||
BookMetric,
|
||||
BookPageSetup,
|
||||
BookStatsLook,
|
||||
BookTheme,
|
||||
BookTravelMapCard,
|
||||
BookTravelMarkVariant,
|
||||
JourneyStats,
|
||||
} from '@trek/shared';
|
||||
import {
|
||||
bookBadgeValue,
|
||||
bookCountryName,
|
||||
bookTravelBadge,
|
||||
bookTravelCountries,
|
||||
bookTravelMap,
|
||||
bookTravelSize,
|
||||
bookTravelStats,
|
||||
themedElement,
|
||||
BOOK_TRAVEL_COUNTRY_LOOKS,
|
||||
BOOK_TRAVEL_FIGURE_SHELVES,
|
||||
BOOK_TRAVEL_MARK_STYLES,
|
||||
BOOK_TRAVEL_MARK_VARIANTS,
|
||||
BOOK_TRAVEL_SUMMARY_LOOKS,
|
||||
bookTravelLooks,
|
||||
bookElementId as uid,
|
||||
} from '@trek/shared';
|
||||
import { studioTravelContext } from '../studioTravelContext';
|
||||
|
||||
/**
|
||||
* What the Travel panel offers, built: every card is the element its click
|
||||
* places, made by the book engine's own factories (book-travel.ts) so a card
|
||||
* and what the assistant places are one thing, then set in one of the newer
|
||||
* looks and dressed in the book's theme.
|
||||
* places, made by the book's own travel looks (book-travel-looks.ts in
|
||||
* shared), the same factory the auto layout builds its framing pages with,
|
||||
* so a card and a designed page are one thing.
|
||||
*
|
||||
* Pure, so the panel stays a list of shelves: it asks for an element and
|
||||
* renders it through the page's own renderer, and the preview and the result
|
||||
* are the same object.
|
||||
* are the same object. What is the panel's own here is where a new element
|
||||
* lands: centred on the spread on screen.
|
||||
*/
|
||||
|
||||
/** The figures a summary offers: how far, how long, where, and how much was kept. */
|
||||
const SUMMARY_METRICS: BookMetric[] = ['distance', 'days', 'countries', 'photos'];
|
||||
|
||||
/** The looks of a summary, in the order the panel offers them. */
|
||||
export const SUMMARY_LOOKS: BookStatsLook[] = [
|
||||
'poster',
|
||||
'ticket',
|
||||
'tiles',
|
||||
'sign',
|
||||
'odometer',
|
||||
'note',
|
||||
'sketch',
|
||||
'band',
|
||||
];
|
||||
export const SUMMARY_LOOKS: readonly BookStatsLook[] = BOOK_TRAVEL_SUMMARY_LOOKS;
|
||||
|
||||
/** The marks the journey fills, in the order a page is read: when, which day, how far, where. */
|
||||
export const MARK_VARIANTS = ['date', 'day', 'distance', 'coords', 'flag'] as const;
|
||||
export type MarkVariant = (typeof MARK_VARIANTS)[number];
|
||||
export const MARK_VARIANTS = BOOK_TRAVEL_MARK_VARIANTS;
|
||||
export type MarkVariant = BookTravelMarkVariant;
|
||||
|
||||
/** The newer styles a mark comes in, and which of them each mark is offered in. */
|
||||
export const MARK_STYLES: { style: BookBadgeStyle; variants: readonly MarkVariant[] }[] = [
|
||||
{ style: 'stamp', variants: ['date', 'day', 'flag'] },
|
||||
{ style: 'tag', variants: ['day', 'distance', 'coords', 'flag'] },
|
||||
{ style: 'pill', variants: ['date', 'day', 'distance', 'coords', 'flag'] },
|
||||
{ style: 'ribbon', variants: ['date', 'day', 'distance', 'flag'] },
|
||||
{ style: 'sticker', variants: ['date', 'day', 'distance', 'flag'] },
|
||||
{ style: 'patch', variants: ['day', 'distance', 'flag'] },
|
||||
{ style: 'sketch', variants: ['date', 'day', 'distance', 'coords', 'flag'] },
|
||||
];
|
||||
export const MARK_STYLES: readonly { style: BookBadgeStyle; variants: readonly MarkVariant[] }[] =
|
||||
BOOK_TRAVEL_MARK_STYLES;
|
||||
|
||||
/** The single figures, one shelf per look, with the figures each look suits. */
|
||||
export const FIGURE_SHELVES: { look: BookStatsLook; metrics: readonly BookMetric[] }[] = [
|
||||
{ look: 'medal', metrics: ['distance', 'days', 'steps', 'photos', 'countries', 'places', 'furthest'] },
|
||||
{ look: 'odometer', metrics: ['distance', 'furthest', 'days', 'photos'] },
|
||||
{ look: 'sketch', metrics: ['distance', 'days', 'countries', 'photos'] },
|
||||
];
|
||||
export const FIGURE_SHELVES: readonly { look: BookStatsLook; metrics: readonly BookMetric[] }[] =
|
||||
BOOK_TRAVEL_FIGURE_SHELVES;
|
||||
|
||||
/** The looks a country is offered in. */
|
||||
export const COUNTRY_LOOKS: BookCountryLook[] = ['stamp', 'visa', 'postcard', 'flag', 'pin', 'label', 'sketch'];
|
||||
|
||||
/** The looks written by hand take the theme's script face rather than its heading. */
|
||||
const HANDWRITTEN = new Set<string>(['note', 'sketch']);
|
||||
|
||||
/** Which of the theme's accents each country look takes, so a shelf of them is not one colour. */
|
||||
const COUNTRY_ACCENT: Partial<Record<BookCountryLook, BookColorToken>> = {
|
||||
stamp: 'accent2',
|
||||
visa: 'accent3',
|
||||
pin: 'accent',
|
||||
label: 'accent2',
|
||||
};
|
||||
|
||||
/** Which of the theme's accents each mark takes, so a page of them is not one colour. */
|
||||
const MARK_ACCENT: Record<MarkVariant, BookColorToken> = {
|
||||
date: 'accent',
|
||||
day: 'accent2',
|
||||
distance: 'accent3',
|
||||
coords: 'accent',
|
||||
flag: 'accent2',
|
||||
};
|
||||
export const COUNTRY_LOOKS: readonly BookCountryLook[] = BOOK_TRAVEL_COUNTRY_LOOKS;
|
||||
|
||||
/** The map cards: the photographed route, its stops numbered, its two ends marked, and the dark ground. */
|
||||
export const MAP_CARDS: {
|
||||
@@ -130,130 +86,19 @@ export function travelBuilders({ page, stats, path, locale, t, theme, single }:
|
||||
return { x: (W - size.w) / 2, y: (page.pageHeight - size.h) / 2, w: size.w, h: size.h };
|
||||
};
|
||||
|
||||
/**
|
||||
* An element dressed in the book's theme: its words in the theme's ink and
|
||||
* heading face, its colour one of the theme's accents, all three linked so
|
||||
* it follows when the theme changes. A book without a theme keeps the
|
||||
* engine's own colours.
|
||||
*/
|
||||
const dress = (el: BookElement, accent: BookColorToken = 'accent', hand = false): BookElement =>
|
||||
theme
|
||||
? themedElement(
|
||||
{ ...el, token: 'ink', accentToken: accent, fontRole: hand ? 'script' : 'heading' } as BookElement,
|
||||
theme
|
||||
)
|
||||
: el;
|
||||
const looks = bookTravelLooks({ page, stats, travel, theme, newId: uid, place: at });
|
||||
|
||||
/** A summary in one of the newer looks; a band is a strip, so it is set a third as tall. */
|
||||
const summaryEl = (look: BookStatsLook): BookElement => {
|
||||
const w = page.pageWidth * (look === 'note' || look === 'sign' ? 0.56 : 0.72);
|
||||
const h =
|
||||
look === 'band' ? page.pageHeight * 0.13 : look === 'ticket' ? w * 0.42 : look === 'note' ? w * 0.72 : w * 0.5;
|
||||
const el = { ...bookTravelStats(stats, { id: uid('st'), frame: at({ w, h }), metrics: SUMMARY_METRICS }), look };
|
||||
return dress(el, look === 'sign' ? 'accent3' : 'accent', HANDWRITTEN.has(look));
|
||||
return {
|
||||
summaryEl: (look: BookStatsLook) => looks.summaryEl(look),
|
||||
figureEl: (metric: BookMetric, look: BookStatsLook = 'medal') => looks.figureEl(metric, look),
|
||||
mapEl: (
|
||||
card: BookTravelMapCard,
|
||||
scope: BookMapScope = 'journey',
|
||||
source: 'vector' | 'relief' | 'satellite' = 'vector'
|
||||
) => looks.mapEl(card, scope, source),
|
||||
countryMapEl: (code: string, kind: 'shape' | 'satellite' | 'box') => looks.countryMapEl(code, kind),
|
||||
markEl: (variant: MarkVariant, style: BookBadgeStyle) => looks.markEl(variant, style),
|
||||
countryEl: (code: string, look: BookCountryLook) => looks.countryEl(code, look),
|
||||
countryName: looks.countryName,
|
||||
};
|
||||
|
||||
/**
|
||||
* One figure on its own, the mark to drop beside a photograph: as a medal, on
|
||||
* the drums of an odometer, or ringed by hand.
|
||||
*/
|
||||
const figureEl = (metric: BookMetric, look: BookStatsLook = 'medal'): BookElement => {
|
||||
const side = Math.min(page.pageWidth, page.pageHeight) * 0.3;
|
||||
const size = look === 'odometer' ? { w: side * 1.6, h: side * 0.62 } : { w: side, h: side };
|
||||
const el = { ...bookTravelStats(stats, { id: uid('st'), frame: at(size), metrics: [metric] }), look };
|
||||
return dress(el, 'accent', HANDWRITTEN.has(look));
|
||||
};
|
||||
|
||||
/**
|
||||
* A map as the engine places it, on the card's ground and with the card's
|
||||
* stops. The scope is the journey or one trip; the ground is the outlines
|
||||
* or one of the two imagery sources.
|
||||
*/
|
||||
const mapEl = (
|
||||
card: { style: BookMapElement['style']; pins?: BookMapElement['pinStyle'] },
|
||||
scope: BookMapScope = 'journey',
|
||||
source: 'vector' | 'relief' | 'satellite' = 'vector'
|
||||
): BookElement => {
|
||||
const el = bookTravelMap(travel, stats, { id: uid('mp'), frame: at(bookTravelSize('map', page)), scope, source });
|
||||
const shaped = { ...el, style: card.style, ...(card.pins ? { pinStyle: card.pins } : {}) };
|
||||
// Over imagery the line is white on purpose (book-travel.ts); only the outline maps take the theme.
|
||||
return source === 'vector' ? dress(shaped) : shaped;
|
||||
};
|
||||
|
||||
/**
|
||||
* A map of one country: its stops alone, without the way there. A journey's
|
||||
* recorded way often starts with the flight from home, and fitted to all of
|
||||
* it the country is a speck at the edge of a continent; the page a travel
|
||||
* book wants is the country, cut to its own shape on the paper or over the
|
||||
* satellite picture, or the country in a box.
|
||||
*/
|
||||
const countryMapEl = (code: string, kind: 'shape' | 'satellite' | 'box'): BookElement => {
|
||||
const own = {
|
||||
...stats,
|
||||
points: stats.points.filter((p) => p.country === code),
|
||||
countries: stats.countries.filter((c) => c.code === code),
|
||||
};
|
||||
const ctx = studioTravelContext(own, [], locale, t);
|
||||
const source = kind === 'satellite' ? 'satellite' : 'vector';
|
||||
const el = bookTravelMap(ctx, own, {
|
||||
id: uid('mp'),
|
||||
frame: at(bookTravelSize('map', page)),
|
||||
scope: 'journey',
|
||||
source,
|
||||
});
|
||||
// Cut to its shape on the paper, the land is dark, so the silhouette reads and the route in the accent stands on it.
|
||||
const style = kind === 'shape' ? 'dark' : 'minimal';
|
||||
const shaped = {
|
||||
...el,
|
||||
style,
|
||||
clip: kind === 'box' ? 'rect' : 'country',
|
||||
pinStyle: 'numbered',
|
||||
fitToCountries: true,
|
||||
} as const;
|
||||
return source === 'vector' ? dress(shaped) : shaped;
|
||||
};
|
||||
|
||||
/** A mark in one of the newer styles, at the size its variant suits; a stamp is set taller for its two rules. */
|
||||
const markEl = (variant: MarkVariant, style: BookBadgeStyle): BookElement | null => {
|
||||
const value = bookBadgeValue(travel, variant, null);
|
||||
if (!value) return null;
|
||||
const frame = at(markSize(style, value.text, value.sub));
|
||||
const el = { ...bookTravelBadge({ id: uid('bd'), frame, variant: variant as BookBadgeVariant, value }), style };
|
||||
return dress(el, MARK_ACCENT[variant], style === 'sketch');
|
||||
};
|
||||
|
||||
/**
|
||||
* How large a mark is set: as wide as its words need. A stamp stacks them in
|
||||
* a box with room for its two rules; a tag or a pill sets them in a line
|
||||
* after its eyelet or its disc, so its length follows the words.
|
||||
*/
|
||||
const markSize = (style: BookBadgeStyle, text: string, sub: string) => {
|
||||
const twoLines = !!text && !!sub && text.length > 14;
|
||||
const h = page.pageHeight * (style === 'stamp' ? 0.12 : twoLines ? 0.1 : 0.066);
|
||||
const main = text || sub;
|
||||
if (style === 'stamp') return { w: Math.max(h * 1.3, h * 0.2 * Math.max(main.length * 1.4, sub.length)), h };
|
||||
// Two lines are set smaller than one, so they need less width a letter.
|
||||
if (twoLines)
|
||||
return { w: Math.min(page.pageWidth * 0.6, h * (1.5 + 0.13 * Math.max(text.length, sub.length * 0.7))), h };
|
||||
const chars = main.length * (text ? 1 : 0.85) + (text ? sub.length * 0.5 : 0);
|
||||
return { w: Math.min(page.pageWidth * 0.6, h * (1.6 + 0.3 * chars)), h };
|
||||
};
|
||||
|
||||
/** One country as a square stamp, or as a card in the proportions of a postcard. */
|
||||
const countryEl = (code: string, look: BookCountryLook): BookElement => {
|
||||
const side = Math.min(page.pageWidth, page.pageHeight) * 0.36;
|
||||
const ratio = look === 'stamp' || look === 'pin' || look === 'sketch' ? 1 : look === 'postcard' ? 1.36 : 1.4;
|
||||
const frame = at({ w: side * ratio, h: side });
|
||||
const el = {
|
||||
...bookTravelCountries(travel, { id: uid('co'), frame, codes: [code] }),
|
||||
look,
|
||||
showFlag: look === 'pin' || look === 'postcard',
|
||||
};
|
||||
return dress(el, COUNTRY_ACCENT[look] ?? 'accent', look === 'sketch');
|
||||
};
|
||||
|
||||
/** A country's name in the reader's language, from the same CLDR data Atlas uses. */
|
||||
const countryName = (code: string): string => bookCountryName(code, locale, stats);
|
||||
|
||||
return { summaryEl, figureEl, mapEl, countryMapEl, markEl, countryEl, countryName };
|
||||
}
|
||||
|
||||
@@ -37,10 +37,13 @@ vi.mock('../../api/websocket', async importOriginal => ({
|
||||
|
||||
// The auto layout lives in @trek/shared now; the hook's call into it is watched through a partial mock.
|
||||
const shared = await vi.importActual<typeof import('@trek/shared')>('@trek/shared')
|
||||
const buildBook = vi.fn(shared.buildBook)
|
||||
const autoDesignedBook = vi.fn(shared.autoDesignedBook)
|
||||
vi.mock('@trek/shared', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('@trek/shared')>()
|
||||
return { ...actual, buildBook: (...args: Parameters<typeof actual.buildBook>) => buildBook(...args) }
|
||||
return {
|
||||
...actual,
|
||||
autoDesignedBook: (...args: Parameters<typeof actual.autoDesignedBook>) => autoDesignedBook(...args),
|
||||
}
|
||||
})
|
||||
|
||||
import { useJourneyStudio } from './useJourneyStudio'
|
||||
@@ -67,7 +70,7 @@ function seedJourney() {
|
||||
|
||||
beforeEach(() => {
|
||||
seedJourney()
|
||||
buildBook.mockClear()
|
||||
autoDesignedBook.mockClear()
|
||||
useStudioStore.setState({ doc: null, selection: [], activeSpread: 0, past: [], future: [], readOnly: false })
|
||||
vi.spyOn(journeyApi, 'getBook').mockResolvedValue({ book: null } as never)
|
||||
vi.spyOn(journeyApi, 'saveBook').mockResolvedValue({} as never)
|
||||
@@ -140,8 +143,8 @@ describe('the auto layout input', () => {
|
||||
|
||||
act(() => { result.current.relayoutBook() })
|
||||
|
||||
expect(buildBook).toHaveBeenCalled()
|
||||
const input = buildBook.mock.calls[buildBook.mock.calls.length - 1][0]
|
||||
expect(autoDesignedBook).toHaveBeenCalled()
|
||||
const input = autoDesignedBook.mock.calls[autoDesignedBook.mock.calls.length - 1][1]
|
||||
expect(input.path).toEqual([TRACK])
|
||||
})
|
||||
|
||||
@@ -176,9 +179,51 @@ describe('the auto layout input', () => {
|
||||
|
||||
act(() => { result.current.relayoutBook() })
|
||||
|
||||
const input = buildBook.mock.calls[buildBook.mock.calls.length - 1][0]
|
||||
const input = autoDesignedBook.mock.calls[autoDesignedBook.mock.calls.length - 1][1]
|
||||
expect(input.path).toEqual([])
|
||||
})
|
||||
|
||||
it("lays the whole book on the designed pages in the book's own page format, theme and title", async () => {
|
||||
const { result } = await mountStudio()
|
||||
const theme = shared.bookThemeOf('midnight')
|
||||
const page = { ...shared.bookPageSetupSchema.parse({}), pageWidth: 200 }
|
||||
act(() => {
|
||||
useStudioStore.getState().commit(d => ({ ...d, title: 'My own title', page, theme }))
|
||||
})
|
||||
|
||||
act(() => { result.current.relayoutBook() })
|
||||
|
||||
const [doc, input, opts] = autoDesignedBook.mock.calls[autoDesignedBook.mock.calls.length - 1]
|
||||
expect(input.page).toEqual(page)
|
||||
expect(opts?.theme).toEqual(theme)
|
||||
expect(doc?.title).toBe('My own title')
|
||||
const laid = useStudioStore.getState().doc
|
||||
expect(laid?.title).toBe('My own title')
|
||||
expect(laid?.theme).toEqual(theme)
|
||||
expect(laid?.page.pageWidth).toBe(200)
|
||||
expect(laid?.spreads.some(s => s.role === 'inner' && s.entryId === 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('lays this spread on another design, its id kept, and knows the cover but not the back', async () => {
|
||||
const { result } = await mountStudio()
|
||||
act(() => { result.current.relayoutBook() })
|
||||
const before = useStudioStore.getState().doc
|
||||
if (!before) throw new Error('a laid book')
|
||||
const at = before.spreads.findIndex(s => s.role === 'inner' && s.entryId === 1)
|
||||
act(() => { useStudioStore.getState().setActiveSpread(at) })
|
||||
expect(result.current.canRelayoutSpread).toBe(true)
|
||||
const was = shared.matchDecoratedTemplate(before.spreads[at], before.page)?.id
|
||||
|
||||
act(() => { result.current.relayoutCurrentSpread() })
|
||||
|
||||
const after = useStudioStore.getState().doc?.spreads[at]
|
||||
expect(after?.id).toBe(before.spreads[at].id)
|
||||
expect(shared.matchDecoratedTemplate(after as never, before.page)?.id).not.toBe(was)
|
||||
act(() => { useStudioStore.getState().setActiveSpread(0) })
|
||||
expect(result.current.canRelayoutSpread).toBe(true)
|
||||
act(() => { useStudioStore.getState().setActiveSpread(before.spreads.length - 1) })
|
||||
expect(result.current.canRelayoutSpread).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useLocation, useNavigate, useParams } from 'react-router'
|
||||
import type { AutoEntry, AutoInput, AutoPhoto, BookElement, BookPageNumbers, JourneyStats } from '@trek/shared'
|
||||
import type { AutoDesignedInput, AutoEntry, AutoPhoto, BookElement, BookPageNumbers, JourneyStats } from '@trek/shared'
|
||||
import {
|
||||
autoEntriesWithStops, bookElementId as elementId, bookPageSetupSchema, bookPath, buildBook, distributeGallery,
|
||||
emptyBook, layoutAroundLocks, relayoutSpread,
|
||||
autoDesignedBook, autoEntriesWithStops, autoRelayoutKind, autoRelayoutSpread, bookElementId as elementId,
|
||||
bookPageSetupSchema, bookPath, emptyBook,
|
||||
} from '@trek/shared'
|
||||
import { journeyApi } from '../../api/client'
|
||||
import { useJourneyStore, type GalleryPhoto, type JourneyEntry, type JourneyPhoto } from '../../store/journeyStore'
|
||||
@@ -153,7 +153,7 @@ export function useJourneyStudio() {
|
||||
* possible; rebuilding it from the journey on every click would be the same
|
||||
* work plus a chance of the two disagreeing.
|
||||
*/
|
||||
const autoInput = useRef<AutoInput | null>(null)
|
||||
const autoInput = useRef<AutoDesignedInput | null>(null)
|
||||
|
||||
const journey = current && current.id === journeyId ? current : null
|
||||
|
||||
@@ -274,15 +274,15 @@ export function useJourneyStudio() {
|
||||
})),
|
||||
}))
|
||||
|
||||
// When each loose photo was taken: the auto layout gives it to the entry of its day first.
|
||||
const gallery: AutoPhoto[] = (journey.gallery || []).map((p: GalleryPhoto) => ({
|
||||
photoId: p.photo_id,
|
||||
width: p.width ?? null,
|
||||
height: p.height ?? null,
|
||||
caption: p.caption ?? null,
|
||||
takenAt: p.taken_at ?? null,
|
||||
}))
|
||||
|
||||
const withPhotos = distributeGallery(entries, gallery)
|
||||
|
||||
/*
|
||||
* Give every entry what the journey's figures know about its stop: the
|
||||
* coordinates and country its place resolved to and which day it is, so a
|
||||
@@ -290,7 +290,7 @@ export function useJourneyStudio() {
|
||||
* is shared (autoEntriesWithStops) so the server's design tool reads the
|
||||
* same stops.
|
||||
*/
|
||||
const placed = autoEntriesWithStops(withPhotos, stats)
|
||||
const placed = autoEntriesWithStops(entries, stats)
|
||||
|
||||
const preset = PAGE_PRESETS['square-210']
|
||||
|
||||
@@ -300,6 +300,8 @@ export function useJourneyStudio() {
|
||||
subtitle: journey.subtitle ?? null,
|
||||
coverPhotoId: gallery[0]?.photoId ?? null,
|
||||
entries: placed,
|
||||
// The loose photos stay loose here: the layout shares them out itself, by day, and loses none.
|
||||
gallery,
|
||||
page: {
|
||||
preset: preset.id,
|
||||
pageWidth: preset.pageWidthMm,
|
||||
@@ -375,7 +377,7 @@ export function useJourneyStudio() {
|
||||
*
|
||||
* The build effect runs as soon as the book and the figures are in, which is
|
||||
* usually before this answers, and what it froze is what the Auto layout
|
||||
* buttons hand to buildBook afterwards. Without the patch a relayout drew
|
||||
* buttons hand to the layout afterwards. Without the patch a relayout drew
|
||||
* the straight line between stops over a route somebody had walked.
|
||||
*/
|
||||
const applyPath = (p: [number, number][][]) => {
|
||||
@@ -471,8 +473,10 @@ export function useJourneyStudio() {
|
||||
const relayoutBook = useCallback(() => {
|
||||
const input = autoInput.current
|
||||
if (!input) return
|
||||
// A new seed every press: "the whole book" twice gives two books, each one undo away.
|
||||
const seed = Date.now() % 2 ** 31
|
||||
commit(d => ({
|
||||
...buildBook({ ...input, page: d.page }),
|
||||
...autoDesignedBook(d, { ...input, page: d.page }, { theme: d.theme, seed }),
|
||||
title: d.title,
|
||||
}))
|
||||
setActiveSpread(0)
|
||||
@@ -481,22 +485,20 @@ export function useJourneyStudio() {
|
||||
const relayoutCurrentSpread = useCallback(() => {
|
||||
const input = autoInput.current
|
||||
if (!input) return
|
||||
const seed = Date.now() % 2 ** 31
|
||||
commit(d => {
|
||||
const sp = d.spreads[activeSpread]
|
||||
if (!sp) return d
|
||||
// Laid out around what is locked, as the assistant's auto layout leaves it.
|
||||
const next = layoutAroundLocks(sp, free => relayoutSpread(free, { ...input, page: d.page }))
|
||||
// Laid out around what is locked, its id kept, on another template than the one on screen.
|
||||
const next = autoRelayoutSpread(d, activeSpread, { ...input, page: d.page }, { seed })
|
||||
if (!next) return d
|
||||
return { ...d, spreads: d.spreads.map((x, i) => (i === activeSpread ? next : x)) }
|
||||
})
|
||||
select([])
|
||||
}, [commit, activeSpread, select])
|
||||
|
||||
/** Whether the spread on screen came from an entry, and so can be redone. */
|
||||
/** Whether the auto layout knows the spread on screen (an entry's page, the cover, the summary, the countries). */
|
||||
const canRelayoutSpread = !!(
|
||||
autoInput.current
|
||||
&& doc?.spreads[activeSpread]?.entryId != null
|
||||
&& doc.spreads[activeSpread].role === 'inner'
|
||||
&& autoRelayoutKind(doc?.spreads[activeSpread]) !== null
|
||||
)
|
||||
|
||||
/** Page numbers and anything else that belongs to the book rather than a page. */
|
||||
|
||||
@@ -179,12 +179,12 @@ describe('applying a theme', () => {
|
||||
});
|
||||
|
||||
describe('the Theme panel', () => {
|
||||
it('FE-STUDIO-THEME-007: shows twelve themes, applies the one picked and says so', () => {
|
||||
it('FE-STUDIO-THEME-007: shows seventeen themes, applies the one picked and says so', () => {
|
||||
render(<StudioThemePanel t={t} />);
|
||||
const cards = screen
|
||||
.getAllByRole('button', { pressed: false })
|
||||
.filter((b) => b.classList.contains('st-theme-card'));
|
||||
expect(cards).toHaveLength(11);
|
||||
expect(cards).toHaveLength(16);
|
||||
fireEvent.click(screen.getByTitle('studio.theme.descr.riviera'));
|
||||
fireEvent.click(screen.getByText('studio.theme.applyBook'));
|
||||
expect(doc().theme?.id).toBe('riviera');
|
||||
|
||||
@@ -46,6 +46,11 @@ Recipes, for a cover built or tweaked by hand, as JSON: each object one element
|
||||
- grid-badge: [{"kind":"photo","photoId":"cover","frame":[-2,-2,50.8,50.8]},{"kind":"photo","photoId":"2","frame":[51.2,-2,50.8,50.8]},{"kind":"photo","photoId":"3","frame":[-2,51.2,50.8,50.8]},{"kind":"photo","photoId":"4","frame":[51.2,51.2,50.8,50.8]},{"lockup":"circle-badge","box":[29,29,42,42]}], a cross of paper 2.4 % (5 mm) wide, the badge off every photo's focal point.
|
||||
- minimal-serif: [{"kind":"photo","photoId":"cover","frame":[8,8,84,53],"border":{"width":0.3,"color":"ink"}},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"ink","size":80,"leading":1,"frame":[8,64,84,17]},{"kind":"text","binding":{"source":"journey.subtitle"},"fontRole":"heading","token":"ink","size":11,"frame":[8,82,84,3]},{"kind":"shape","shape":"rect","token":"ink","frame":[28,88.4,20,0.15]},{"kind":"shape","shape":"sun","token":"accent","frame":[48.5,87,3,3]},{"kind":"shape","shape":"rect","token":"ink","frame":[52,88.4,20,0.15]},{"kind":"text","text":"2026","size":8,"frame":[8,92,84,3]}]
|
||||
- stamp-collage: [{"kind":"shape","shape":"rect","paint":{"type":"pattern","pattern":"checker","fg":"accent","bg":"paper","scale":1,"angle":0},"frame":[-2,-2,104,36]},{"kind":"photo","photoId":"cover","frame":[8,19,26,31],"rotation":-4,"edge":"perforated","edgeSize":3,"border":{"width":2.2,"color":"#ffffff"}},{"kind":"photo","photoId":"2","frame":[37,17,26,34],"rotation":3,"mask":"arch-tall","cutout":{"width":1.8,"color":"#ffffff"}},{"kind":"photo","photoId":"3","frame":[66,19,26,31],"rotation":-3,"edge":"perforated","edgeSize":3,"border":{"width":2.2,"color":"#ffffff"}},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"ink","size":90,"weight":800,"transform":"upper","align":"left","frame":[8,62,52,18]},{"kind":"text","text":"May 2026","size":9,"frame":[8,81,52,4]},{"lockup":"postmark","box":[62,60,30,30]}]
|
||||
- riso: [{"kind":"shape","shape":"ellipse","token":"accent2","opacity":0.85,"frame":[8,46,52,52]},{"kind":"shape","shape":"ellipse","paint":{"type":"pattern","pattern":"dots","fg":"accent","bg":null,"scale":0.4,"angle":45},"frame":[11,44,52,52]},{"kind":"shape","shape":"rect","token":"accent","frame":[37,50,52,42],"rotation":2},{"kind":"photo","photoId":"cover","frame":[39,48,52,42],"rotation":2},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"ink","size":90,"transform":"upper","leading":0.92,"align":"left","shadow":{"dx":1.6,"dy":1.6,"color":"accent","alpha":1},"frame":[8,8,84,30]},{"kind":"text","text":"2026","size":9,"highlight":{"color":"accent2"},"frame":[8,88,20,4]}], the block and the discs a few mm out of register.
|
||||
- editorial: [{"kind":"photo","photoId":"cover","frame":[-2,37,104,65]},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"ink","size":96,"weight":800,"transform":"upper","leading":0.9,"frame":[8,8,84,19]},{"kind":"shape","shape":"rect","token":"ink","frame":[8,27.5,84,0.2]},{"kind":"text","text":"May 2026","size":8,"align":"left","frame":[8,29,40,3]},{"kind":"shape","shape":"rect","token":"accent","frame":[8,74,44,18]},{"kind":"text","binding":{"source":"journey.subtitle"},"fontRole":"heading","token":"paper","size":16,"align":"left","frame":[12,78,36,10]}], the block off the photo's focal point.
|
||||
- vintage-poster: [{"kind":"shape","shape":"rect","fill":null,"strokeToken":"ink","strokeWidth":0.6,"frame":[1.7,1.7,96.6,96.6]},{"kind":"shape","shape":"rect","token":"accent3","frame":[3.3,3.3,93.4,58]},{"kind":"shape","shape":"sunburst-24","token":"accent","opacity":0.5,"frame":[24,6,52,52]},{"kind":"shape","shape":"ellipse","token":"accent","frame":[34,16,32,32]},{"kind":"shape","shape":"mountain","token":"accent2","frame":[3.3,32,64,29.3]},{"kind":"shape","shape":"mountain","token":"ink","opacity":0.8,"frame":[36,40,60.7,21.3]},{"kind":"photo","photoId":"cover","frame":[38,14,24,44],"mask":"arch-tall","cutout":{"width":2,"color":"#ffffff"}},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"ink","size":80,"transform":"upper","leading":0.92,"frame":[8,66,84,20]},{"kind":"text","text":"May 2026","size":9,"frame":[8,88,84,4]}]
|
||||
- night-film: [{"background":"ink"},{"kind":"text","text":"May 2026","font":"mono","token":"accent","size":8,"tracking":0.2,"align":"left","frame":[8,8,40,3]},{"kind":"photo","photoId":"cover","frame":[-2,14,104,40]},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"paper","size":72,"transform":"upper","tracking":0.14,"align":"left","frame":[8,58,84,16]},{"kind":"shape","shape":"rect","token":"accent","frame":[8,76,16,0.4]},{"kind":"shape","shape":"rect","token":"paper","opacity":0.12,"frame":[-2,80,104,18]},{"kind":"line","stroke":"#f2efe8","strokeWidth":1.6,"dash":"dashed","frame":[-2,81.5,104,1]},{"kind":"line","stroke":"#f2efe8","strokeWidth":1.6,"dash":"dashed","frame":[-2,95.5,104,1]},{"kind":"photo","photoId":"2","frame":[8,84,26,10]},{"kind":"photo","photoId":"3","frame":[37,84,26,10]},{"kind":"photo","photoId":"4","frame":[66,84,26,10]}], paper and ink swapped in a dark theme.
|
||||
- field-notes: [{"kind":"shape","shape":"rect","paint":{"type":"pattern","pattern":"grid","fg":"accent3","bg":"accent2","scale":0.6,"angle":0},"frame":[4,4,92,92],"rotation":-1.5},{"kind":"shape","shape":"leaf-long","token":"accent3","frame":[2,40,9,40],"rotation":-20},{"kind":"photo","photoId":"cover","frame":[10,10,48,40],"rotation":-3,"border":{"width":2.6,"color":"#ffffff"}},{"kind":"shape","shape":"rect","token":"accent2","opacity":0.72,"edge":"zigzag","edgeSize":1,"frame":[24,8,19,3.3],"rotation":4},{"kind":"photo","photoId":"2","frame":[62,17,28,27],"rotation":4,"border":{"width":2.6,"color":"#ffffff"}},{"kind":"text","binding":{"source":"journey.title"},"fontRole":"display","token":"ink","size":60,"align":"left","frame":[8,62,46,18]},{"kind":"text","binding":{"source":"journey.subtitle"},"fontRole":"script","token":"accent","size":26,"align":"left","frame":[8,80,46,8]},{"kind":"text","text":"May 2026","font":"mono","size":8,"align":"left","frame":[8,89,46,3]}]
|
||||
Lively spreads, in percent of the double page (the fold at 50); photos 1 to 4 are the page's, entry its entry id:
|
||||
- script overlay: [{"kind":"photo","photoId":"1","focalX":0.3,"frame":[-1,-2,102,104]},{"kind":"text","binding":{"source":"entry.location","entryId":"entry"},"fontRole":"script","size":120,"color":"#ffffff","outline":{"color":"#1a1a1a","width":2.9},"frame":[55,26,40,30],"rotation":-6},{"kind":"text","binding":{"source":"entry.title","entryId":"entry"},"token":"ink","size":9,"highlight":{"color":"paper"},"frame":[57,62,36,4]}], the photo's detail off the fold and the word.
|
||||
- collage: [{"kind":"photo","photoId":"1","frameStyle":"polaroid","frame":[6,10,26,36],"rotation":-5},{"kind":"photo","photoId":"2","frameStyle":"white","frame":[22,42,24,32],"rotation":4},{"kind":"photo","photoId":"3","frameStyle":"tape","frame":[56,12,30,40],"rotation":-3},{"kind":"photo","photoId":"4","frameStyle":"white","frame":[66,52,26,34],"rotation":6},{"kind":"sticker","stickerId":"motif","frame":[39,8,7,10],"rotation":8}], up to 3 prints a page turned 3 to 7 degrees either way, none across the fold or over another's focal point.
|
||||
|
||||
@@ -61,7 +61,7 @@ export class JourneyBookDesignMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'design_book',
|
||||
description: `Design the whole photo book of a journey as a draft: chapters, pacing, the pages, the photos picked for each, type and colours, with the engine's own check run on every page and repaired where it can. directives says what the person wants (style, density, page count, scope, chapters, photos, cover, type, palette, words, travel pages, what to keep) and the art direction: the brief (energy, decoration, collages, theme), the cover's archetype and lockup, and pages.overrides giving single pages of the plan another beat or layout. It answers the draft's design_id, the plan page by page (what each page becomes and its findings), the look, the photos left out and why, and the findings left. ${DRAFT_FLOW} ${LIMITS} Only the book's owner and editors can design it.`,
|
||||
description: `Design the whole photo book of a journey as a draft: chapters, pacing, the pages, the photos picked for each, type and colours, with the engine's own check run on every page and repaired where it can. directives says what the person wants (style, density, page count, scope, chapters, photos, cover, type, palette, words, travel pages, what to keep) and the art direction: the brief (energy, decoration, collages, theme), the cover's archetype and lockup, and pages.overrides giving single pages of the plan another beat or layout. With layouts: 'templates' every page is laid on the Studio's designed templates in the archetype of the book's theme, as the Studio's Auto layout does, and each plan row names its template; style classic alone is the old built-in Auto layout. It answers the draft's design_id, the plan page by page (what each page becomes and its findings), the look, the photos left out and why, and the findings left. ${DRAFT_FLOW} ${LIMITS} Only the book's owner and editors can design it.`,
|
||||
inputSchema: { journeyId: idSchema, ...designBookArgs },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: journeyAddonOn,
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
bookDesignAnswerSchema,
|
||||
bookDesignApplyResultSchema,
|
||||
bookDesignDraftResponseSchema,
|
||||
bookDocumentSchema,
|
||||
bookWriteResultSchema,
|
||||
} from '@trek/shared';
|
||||
|
||||
@@ -197,4 +198,21 @@ describe('the whole-book design over MCP and REST', () => {
|
||||
expect(names.filter((name) => DESIGN_TOOLS.includes(name))).toEqual([]);
|
||||
expect(names.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('JBDSNE2E-004: a design on the designed templates applies as a valid book GET reads back', async () => {
|
||||
const { editor, journey } = await journeyWithBook();
|
||||
const directives = { style: 'field-notes', layouts: 'templates', seed: 2 };
|
||||
const draft = bookDesignAnswerSchema.parse(
|
||||
await callTool(editor.id, 'design_book', { journeyId: journey.id, directives }),
|
||||
);
|
||||
expect(draft.plan.filter((row) => row.template).length).toBeGreaterThan(0);
|
||||
const applied = bookDesignApplyResultSchema.parse(
|
||||
await callTool(editor.id, 'apply_book_design', { journeyId: journey.id, designId: draft.design_id }),
|
||||
);
|
||||
const after = await book(journey.id, editor.id);
|
||||
expect(after.body.book.version).toBe(applied.version);
|
||||
expect(bookDocumentSchema.safeParse(after.body.book.document).success).toBe(true);
|
||||
// Applied over a stored book, the design brings its pages; the book keeps its own theme.
|
||||
expect(after.body.book.document.spreads.length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,7 +130,7 @@ describe('the art director in the Studio', () => {
|
||||
const paint = i.paint as { pattern?: string } | undefined;
|
||||
return paint?.pattern ? [paint.pattern] : [];
|
||||
});
|
||||
expect(patterns).toEqual(['checker']);
|
||||
expect(patterns).toEqual(['checker', 'dots', 'grid']);
|
||||
for (const id of patterns) expect(BOOK_PATTERNS as readonly string[]).toContain(id);
|
||||
});
|
||||
|
||||
@@ -154,7 +154,7 @@ describe('the art director in the Studio', () => {
|
||||
});
|
||||
|
||||
it('ASTRCP-004: keeps to a budget the cache carries every turn, without dashes', () => {
|
||||
expect(BOOK_RECIPES_PROMPT.length).toBeLessThan(9500);
|
||||
expect(BOOK_RECIPES_PROMPT.length).toBeLessThan(14500);
|
||||
expect(BOOK_RECIPES_PROMPT.split('\n').length).toBeLessThanOrEqual(24);
|
||||
expect(BOOK_RECIPES_PROMPT).not.toMatch(/[\u2013\u2014]/);
|
||||
expect(BOOK_RECIPES_PROMPT).not.toContain(' - ');
|
||||
|
||||
@@ -74,9 +74,9 @@ describe('decorated templates in list_book_layouts', () => {
|
||||
const all = json(await mcp.listLayouts({ journeyId: journey.id }, ctx(viewer.id)));
|
||||
const single = all.decorated.single as Row[];
|
||||
const inner = all.decorated.inner as Row[];
|
||||
expect(single).toHaveLength(21);
|
||||
expect(inner).toHaveLength(16);
|
||||
expect(new Set(single.map((r) => r.archetype)).size).toBe(7);
|
||||
expect(single).toHaveLength(48);
|
||||
expect(inner).toHaveLength(60);
|
||||
expect(new Set(single.map((r) => r.archetype)).size).toBe(12);
|
||||
expect(single.find((r) => r.id === 'grid-badge-circle')).toMatchObject({
|
||||
archetype: 'grid-badge',
|
||||
photoSlots: 4,
|
||||
@@ -86,7 +86,7 @@ describe('decorated templates in list_book_layouts', () => {
|
||||
expect(all.notes.decorated).toMatch(/whole designed page/);
|
||||
|
||||
const narrowed = json(await mcp.listLayouts({ journeyId: journey.id, role: 'single' }, ctx(owner.id)));
|
||||
expect([narrowed.decorated.single.length, narrowed.decorated.inner]).toEqual([21, []]);
|
||||
expect([narrowed.decorated.single.length, narrowed.decorated.inner]).toEqual([48, []]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
bookDesignRevisionAnswerSchema,
|
||||
bookPageDescriptionSchema,
|
||||
bookWriteResultSchema,
|
||||
designBookRequestSchema,
|
||||
} from '@trek/shared';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -236,4 +237,14 @@ describe('the design tools', () => {
|
||||
await expect(books.restoreBookLayout(putBack, ctx(viewer.id))).rejects.toThrow('Not allowed');
|
||||
expect(t.realtime.broadcastToUserMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('JBDMCP-006: design_book takes layouts templates and answers the template of each page', async () => {
|
||||
const { journey, editor } = await crew();
|
||||
const request = designBookRequestSchema.parse({ directives: { style: 'postcard', layouts: 'templates', seed: 1 } });
|
||||
const designed = await designs.designBook({ journeyId: journey.id, ...request }, ctx(editor.id));
|
||||
const draft = bookDesignAnswerSchema.parse(answered(designed));
|
||||
expect(draft.plan.some((row) => row.template !== undefined)).toBe(true);
|
||||
expect(draft.style.style).toBe('postcard');
|
||||
expect(t.realtime.broadcastToUserMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -418,3 +418,26 @@ describe('the art direction', () => {
|
||||
expect(free.findings.map((f) => f.code)).not.toContain('energy_mismatch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the designed templates', () => {
|
||||
it("JBDSN-014: layouts templates lays the draft on the designed templates and names each page's template", async () => {
|
||||
const { journey, owner, created } = await crew();
|
||||
const answer = await designs.design(journey.id, owner.id, { style: 'riso', layouts: 'templates', seed: 3 });
|
||||
|
||||
expect(bookDesignAnswerSchema.parse(answer)).toEqual(answer);
|
||||
const named = answer.plan.filter((row) => row.template);
|
||||
expect(named.length).toBeGreaterThan(1);
|
||||
expect(answer.plan[0]?.beat).toBe('cover');
|
||||
const { document } = await designs.draftView(journey.id, owner.id, answer.design_id);
|
||||
expect(document.theme?.id).toBe('riso');
|
||||
expect(answer.findings.filter((f) => f.severity === 'severe')).toEqual([]);
|
||||
expect(await version(journey.id, owner.id)).toBe(created?.version);
|
||||
expect(told()).toEqual([]);
|
||||
});
|
||||
|
||||
it('JBDSN-015: without layouts the plan names no template, as every design answered before', async () => {
|
||||
const { journey, owner } = await crew();
|
||||
const answer = await designs.design(journey.id, owner.id, { style: 'riso', seed: 3 });
|
||||
expect(answer.plan.every((row) => !('template' in row))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { bookSpreadSchema, type BookDocument, type BookSpread } from '../book.schema';
|
||||
import { matchDecoratedTemplate } from '../templates/template-match';
|
||||
import { bookThemeOf } from '../theme/book-theme';
|
||||
import { emptyBook } from './auto-book';
|
||||
import {
|
||||
AUTO_DEFAULT_THEME,
|
||||
autoDesignedBook,
|
||||
autoRelayoutKind,
|
||||
autoRelayoutSpread,
|
||||
type AutoDesignedInput,
|
||||
} from './auto-designed';
|
||||
import type { AutoPhoto } from './auto-types';
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/** The Studio's Auto layout on the designed templates: the whole book, and one spread again. */
|
||||
|
||||
interface Fixture {
|
||||
input: AutoDesignedInput;
|
||||
gallery?: AutoPhoto[];
|
||||
}
|
||||
|
||||
const here = (file: string) => fileURLToPath(new URL(file, import.meta.url));
|
||||
const fixtures = JSON.parse(readFileSync(here('./auto-layout.parity-inputs.json'), 'utf8')) as Record<string, Fixture>;
|
||||
const fx = fixtures.A as Fixture;
|
||||
|
||||
const counter = () => {
|
||||
let n = 0;
|
||||
return (p: string) => `${p}-a${++n}`;
|
||||
};
|
||||
const input = (): AutoDesignedInput => ({ ...fx.input, gallery: fx.gallery, newId: counter() });
|
||||
const book = (seed = 1, theme = bookThemeOf('riso')) => autoDesignedBook(emptyBook(input()), input(), { theme, seed });
|
||||
|
||||
/** The index of the first page of a kind, or -1. */
|
||||
const indexOf = (doc: BookDocument, kind: ReturnType<typeof autoRelayoutKind>) =>
|
||||
doc.spreads.findIndex((s) => autoRelayoutKind(s) === kind);
|
||||
|
||||
describe('the Studio auto layout on the designed templates', () => {
|
||||
it("AUTODES-001: keeps the book's page format and its theme object, one of its own included", () => {
|
||||
const own = {
|
||||
...bookThemeOf('postcard'),
|
||||
id: null,
|
||||
palette: { ...bookThemeOf('postcard').palette, ink: '#203040' },
|
||||
};
|
||||
const start = { ...emptyBook(input()), page: { ...fx.input.page, pageWidth: 200, pageHeight: 250 } };
|
||||
const doc = autoDesignedBook(start, input(), { theme: own, seed: 3 });
|
||||
expect(doc.page.pageWidth).toBe(200);
|
||||
expect(doc.page.pageHeight).toBe(250);
|
||||
expect(doc.theme).toEqual(own);
|
||||
expect(autoDesignedBook(null, input()).theme?.id).toBe(AUTO_DEFAULT_THEME);
|
||||
});
|
||||
|
||||
it('AUTODES-002: another seed lays another book, the same seed the same', () => {
|
||||
const ids = (doc: BookDocument) => doc.spreads.map((s) => matchDecoratedTemplate(s, doc.page)?.id ?? '-').join();
|
||||
expect(ids(book(1))).toBe(ids(book(1)));
|
||||
expect([2, 3, 4, 5].some((seed) => ids(book(seed)) !== ids(book(1)))).toBe(true);
|
||||
});
|
||||
|
||||
it("AUTODES-003: this spread keeps the page's id and lays an entry's page on another template than its own and its neighbours'", () => {
|
||||
const doc = book();
|
||||
const at = doc.spreads.findIndex(
|
||||
(s, i) => autoRelayoutKind(s) === 'entry' && matchDecoratedTemplate(doc.spreads[i] as BookSpread, doc.page),
|
||||
);
|
||||
const before = doc.spreads[at] as BookSpread;
|
||||
const next = autoRelayoutSpread(doc, at, input(), { seed: 1 });
|
||||
expect(next?.id).toBe(before.id);
|
||||
expect(next?.entryId).toBe(before.entryId);
|
||||
expect(bookSpreadSchema.safeParse(next).success).toBe(true);
|
||||
const was = [at - 1, at, at + 1].map(
|
||||
(i) => doc.spreads[i] && matchDecoratedTemplate(doc.spreads[i] as BookSpread, doc.page)?.id,
|
||||
);
|
||||
expect(was).not.toContain(matchDecoratedTemplate(next as BookSpread, doc.page)?.id);
|
||||
const photos = (s: BookSpread) =>
|
||||
[...s.elements, ...s.parked].flatMap((e) => (e.kind === 'photo' && e.photoId != null ? [e.photoId] : [])).sort();
|
||||
expect(photos(next as BookSpread)).toEqual(photos(before));
|
||||
});
|
||||
|
||||
it('AUTODES-004: pressed again and again, an entry page keeps moving on', () => {
|
||||
let doc = book();
|
||||
const at = indexOf(doc, 'entry');
|
||||
const seen = new Set<string>();
|
||||
for (let n = 0; n < 4; n++) {
|
||||
const next = autoRelayoutSpread(doc, at, input(), { seed: n });
|
||||
if (!next) throw new Error('an entry page lays again');
|
||||
seen.add(matchDecoratedTemplate(next, doc.page)?.id ?? 'composed');
|
||||
doc = { ...doc, spreads: doc.spreads.map((s, i) => (i === at ? next : s)) };
|
||||
}
|
||||
expect(seen.size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('AUTODES-005: cycles the cover through the covers of its archetype and then its kin', () => {
|
||||
let doc = book();
|
||||
const seen: string[] = [];
|
||||
for (let n = 0; n < 6; n++) {
|
||||
const next = autoRelayoutSpread(doc, 0, input(), { seed: 1 });
|
||||
if (!next) throw new Error('the cover lays again');
|
||||
expect(next.id).toBe(doc.spreads[0]?.id);
|
||||
seen.push(matchDecoratedTemplate(next, doc.page)?.id ?? '-');
|
||||
doc = { ...doc, spreads: doc.spreads.map((s, i) => (i === 0 ? next : s)) };
|
||||
}
|
||||
expect(new Set(seen).size).toBeGreaterThan(3);
|
||||
expect(seen.some((id) => id.startsWith('riso-'))).toBe(true);
|
||||
});
|
||||
|
||||
it('AUTODES-006: cycles the summary and the countries through their looks', () => {
|
||||
const doc = book();
|
||||
const summary = indexOf(doc, 'summary');
|
||||
const looks: string[] = [];
|
||||
let cur = doc;
|
||||
for (let n = 0; n < 3; n++) {
|
||||
const next = autoRelayoutSpread(cur, summary, input()) as BookSpread;
|
||||
const stats = next.elements.find((e) => e.kind === 'stats' && e.metrics.length > 2);
|
||||
looks.push(stats?.kind === 'stats' ? (stats.look ?? 'classic') : '-');
|
||||
cur = { ...cur, spreads: cur.spreads.map((s, i) => (i === summary ? next : s)) };
|
||||
}
|
||||
expect(new Set(looks).size).toBe(3);
|
||||
expect(looks).not.toContain('classic');
|
||||
const countries = indexOf(doc, 'countries');
|
||||
const again = autoRelayoutSpread(doc, countries, input()) as BookSpread;
|
||||
const look = (s: BookSpread) => s.elements.flatMap((e) => (e.kind === 'countries' ? [e.look] : []))[0];
|
||||
expect(look(again)).not.toBe(look(doc.spreads[countries] as BookSpread));
|
||||
});
|
||||
|
||||
it('AUTODES-007: a page the engine did not make is not laid again', () => {
|
||||
const doc = book();
|
||||
const back = doc.spreads.findIndex((s) => s.role === 'back');
|
||||
expect(autoRelayoutSpread(doc, back, input())).toBeNull();
|
||||
const stations = doc.spreads.findIndex(
|
||||
(s) => s.role === 'inner' && s.entryId === null && autoRelayoutKind(s) === null,
|
||||
);
|
||||
if (stations >= 0) expect(autoRelayoutSpread(doc, stations, input())).toBeNull();
|
||||
expect(autoRelayoutSpread(doc, 999, input())).toBeNull();
|
||||
});
|
||||
|
||||
it('AUTODES-008: keeps a locked element where it is', () => {
|
||||
const doc = book();
|
||||
const at = indexOf(doc, 'entry');
|
||||
const page = doc.spreads[at] as BookSpread;
|
||||
const lockedEl = { ...(page.elements[0] as BookSpread['elements'][number]), locked: true };
|
||||
const locked = { ...page, elements: [lockedEl, ...page.elements.slice(1)] };
|
||||
const withLock = { ...doc, spreads: doc.spreads.map((s, i) => (i === at ? locked : s)) };
|
||||
const next = autoRelayoutSpread(withLock, at, input());
|
||||
expect(next?.elements).toContainEqual(lockedEl);
|
||||
});
|
||||
|
||||
it('AUTODES-009: the whole book keeps a stored page that holds a lock', () => {
|
||||
const doc = book();
|
||||
const at = indexOf(doc, 'entry');
|
||||
const page = doc.spreads[at] as BookSpread;
|
||||
const locked = { ...page, elements: page.elements.map((e, i) => (i === 0 ? { ...e, locked: true } : e)) };
|
||||
const again = autoDesignedBook({ ...doc, spreads: doc.spreads.map((s, i) => (i === at ? locked : s)) }, input(), {
|
||||
theme: doc.theme,
|
||||
seed: 9,
|
||||
});
|
||||
expect(again.spreads).toContainEqual(locked);
|
||||
});
|
||||
|
||||
it('AUTODES-010: an old Auto layout summary is laid again in a new look', () => {
|
||||
const doc = book();
|
||||
const summary = indexOf(doc, 'summary');
|
||||
const old = doc.spreads[summary] as BookSpread;
|
||||
const classic = {
|
||||
...old,
|
||||
elements: old.elements.map((e) => (e.kind === 'stats' ? { ...e, look: undefined } : e)),
|
||||
} as BookSpread;
|
||||
const next = autoRelayoutSpread(
|
||||
{ ...doc, spreads: doc.spreads.map((s, i) => (i === summary ? classic : s)) },
|
||||
summary,
|
||||
input(),
|
||||
);
|
||||
const looks = next?.elements.flatMap((e) => (e.kind === 'stats' ? [e.look] : []));
|
||||
expect(looks?.length).toBeGreaterThan(0);
|
||||
expect(looks?.every((l) => l && l !== 'classic')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { layoutAroundLocks } from '../book-layout-locks';
|
||||
import type { BookCountryLook, BookDocument, BookElement, BookSpread, BookStatsLook } from '../book.schema';
|
||||
import { designBook, templatedSetting, type BookDesignInput, type TemplatedSetting } from '../design/design-book';
|
||||
import type { BookDesignDirectivesInput } from '../design/design-directives.schema';
|
||||
import { ARCHETYPE_KIN, type TemplatePickCtx } from '../design/design-template-pick';
|
||||
import { layEntryPage, type LaidPage, type PageStory } from '../design/design-templated';
|
||||
import {
|
||||
COUNTRY_CYCLE,
|
||||
SUMMARY_CYCLE,
|
||||
composeTravelCountries,
|
||||
composeTravelSummary,
|
||||
coverOnTemplate,
|
||||
} from '../design/design-templated-framing';
|
||||
import { decoratedTemplatesFor } from '../templates/template-catalogue';
|
||||
import { templatePhotoSlots } from '../templates/template-format';
|
||||
import { matchDecoratedTemplate } from '../templates/template-match';
|
||||
import { bookThemeOf } from '../theme/book-theme';
|
||||
import type { BookTheme, BookThemeId } from '../theme/book-theme.schema';
|
||||
import { isPortrait } from './auto-elements';
|
||||
import type { AutoEntry, AutoInput, AutoPhoto } from './auto-types';
|
||||
|
||||
/**
|
||||
* The Studio's Auto layout on the designed templates.
|
||||
*
|
||||
* "The whole book" is the design engine laying the book on the Studio's
|
||||
* decorated templates (`designBook` with `layouts: 'templates'`), in the
|
||||
* book's own theme, keeping every page that holds a locked element. "This
|
||||
* spread" lays one page again on another template than the one on screen and
|
||||
* its neighbours: an entry's page on the next best spread for its photos and
|
||||
* words, the cover on the next cover of its archetype and then of its kin,
|
||||
* the summary and the countries in their next look. The old Auto layout
|
||||
* (`buildBook`, `relayoutSpread`) stays as it was, the design engine's
|
||||
* classic style.
|
||||
*/
|
||||
|
||||
/** The theme a book without one is laid in: calm, linen, serif. */
|
||||
export const AUTO_DEFAULT_THEME: BookThemeId = 'fabric-classic';
|
||||
|
||||
/** The journey as the Studio's Auto layout takes it: the entries with their stops, and the loose gallery. */
|
||||
export interface AutoDesignedInput extends AutoInput {
|
||||
gallery?: readonly AutoPhoto[];
|
||||
}
|
||||
|
||||
export interface AutoDesignedOptions {
|
||||
/** The book's theme, a recoloured one of its own included; the default theme without one. */
|
||||
theme?: BookTheme | null;
|
||||
/** Another seed lays another book; the same one the same. */
|
||||
seed?: number;
|
||||
}
|
||||
|
||||
const designInput = (input: AutoDesignedInput, theme: BookTheme | null | undefined): BookDesignInput => ({
|
||||
...input,
|
||||
candidates: [],
|
||||
photos: [],
|
||||
theme: theme ?? bookThemeOf(AUTO_DEFAULT_THEME),
|
||||
});
|
||||
|
||||
const directivesOf = (theme: BookTheme | null | undefined, seed: number | undefined): BookDesignDirectivesInput => ({
|
||||
style: theme?.id ?? AUTO_DEFAULT_THEME,
|
||||
layouts: 'templates',
|
||||
keep: 'locked',
|
||||
...(seed === undefined ? {} : { seed }),
|
||||
});
|
||||
|
||||
/** The whole book laid on the designed templates over the stored one, its page format kept. */
|
||||
export function autoDesignedBook(
|
||||
doc: BookDocument | null,
|
||||
input: AutoDesignedInput,
|
||||
opts: AutoDesignedOptions = {},
|
||||
): BookDocument {
|
||||
const page = doc?.page ?? input.page;
|
||||
return designBook(doc, designInput({ ...input, page }, opts.theme), directivesOf(opts.theme, opts.seed)).doc;
|
||||
}
|
||||
|
||||
/** What "this spread" can lay again: an entry's page, the cover, the summary, the countries; null for the rest. */
|
||||
export type AutoRelayoutKind = 'entry' | 'cover' | 'summary' | 'countries';
|
||||
|
||||
/** The kind of page a spread of the book is for "this spread", or null for one the engine did not make. */
|
||||
export function autoRelayoutKind(spread: BookSpread | undefined): AutoRelayoutKind | null {
|
||||
if (!spread) return null;
|
||||
if (spread.role === 'cover') return 'cover';
|
||||
if (spread.role !== 'inner') return null;
|
||||
if (spread.entryId != null) return 'entry';
|
||||
const has = (kind: BookElement['kind']) => spread.elements.some((e) => e.kind === kind);
|
||||
if (has('stats') && has('map')) return 'summary';
|
||||
if (has('countries') && !has('stats')) return 'countries';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The photos a page holds, on it and parked with it, in order. */
|
||||
function photosOn(spread: BookSpread, entries: readonly AutoEntry[]): AutoPhoto[] {
|
||||
const known = new Map(entries.flatMap((e) => e.photos.map((p) => [p.photoId, p] as const)));
|
||||
return [...spread.elements, ...spread.parked].flatMap((e) =>
|
||||
e.kind === 'photo' && e.photoId != null
|
||||
? [known.get(e.photoId) ?? { photoId: e.photoId, width: null, height: null }]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
/** The story a page of this entry sets, and whether it is the whole of it. */
|
||||
function storyOn(spread: BookSpread, entryId: number): PageStory {
|
||||
for (const e of spread.elements) {
|
||||
if (e.kind === 'text' && e.binding?.source === 'entry.story' && e.binding.entryId === entryId && e.text.trim()) {
|
||||
return { words: e.text, whole: !e.overridden };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The templates of the page before and after this one, never to be laid here. */
|
||||
function neighbours(doc: BookDocument, index: number): string[] {
|
||||
return [doc.spreads[index - 1], doc.spreads[index + 1]].flatMap((s) =>
|
||||
s ? (matchDecoratedTemplate(s, doc.page)?.id ?? []) : [],
|
||||
);
|
||||
}
|
||||
|
||||
/** Templates tried for a page whose story would be cut, before one that cuts it is taken. */
|
||||
const STORY_TRIES = 4;
|
||||
|
||||
/** An entry's page on the best template but the one on screen and its neighbours'. */
|
||||
function relayEntry(s: TemplatedSetting, doc: BookDocument, index: number, free: BookSpread): BookSpread | null {
|
||||
const entry = s.placed.find((e) => e.id === free.entryId);
|
||||
const own = doc.spreads[index];
|
||||
if (!entry || !own) return null;
|
||||
const current = matchDecoratedTemplate(own, doc.page)?.id;
|
||||
const around = neighbours(doc, index);
|
||||
const avoid = [...(current ? [current] : []), ...around];
|
||||
const page = { photos: photosOn(free, s.placed), story: storyOn(free, entry.id), entries: s.placed };
|
||||
const pick = (more: readonly string[]): TemplatePickCtx => ({
|
||||
archetype: s.archetype,
|
||||
page: s.c.page,
|
||||
theme: s.c.look.theme,
|
||||
history: around.slice(0, 1),
|
||||
calm: s.calm,
|
||||
seed: s.c.directives.seed ?? 0,
|
||||
key: `relay:${entry.id}`,
|
||||
avoid: [...avoid, ...more],
|
||||
});
|
||||
let laid: LaidPage | null = null;
|
||||
const tried: string[] = [];
|
||||
for (let n = 0; n < STORY_TRIES; n++) {
|
||||
const next = layEntryPage(s.c, entry, page, pick(tried));
|
||||
laid ??= next;
|
||||
if (!next.rest || !next.template) {
|
||||
laid = next;
|
||||
break;
|
||||
}
|
||||
tried.push(next.template.id);
|
||||
}
|
||||
if (!laid) return null;
|
||||
return { ...s.finish(laid.spread, !!laid.template).spread, entryId: entry.id };
|
||||
}
|
||||
|
||||
/** The cover on the next cover template of its archetype, then of its kin, for the photos there are. */
|
||||
function relayCover(s: TemplatedSetting, doc: BookDocument, index: number, input: AutoDesignedInput) {
|
||||
const own = doc.spreads[index];
|
||||
if (!own) return null;
|
||||
const others = doc.spreads.flatMap((sp) => (sp === own ? [] : photosOn(sp, s.placed)));
|
||||
const photos = [...new Map([...photosOn(own, s.placed), ...others].map((p) => [p.photoId, p])).values()].slice(0, 8);
|
||||
const order = [s.archetype, ...ARCHETYPE_KIN[s.archetype]].flatMap((a) => decoratedTemplatesFor('single', a));
|
||||
const fits = order.filter(
|
||||
(t) => templatePhotoSlots(t) <= photos.length && (photos.length > 0 || !templatePhotoSlots(t)),
|
||||
);
|
||||
if (!fits.length) return null;
|
||||
const current = matchDecoratedTemplate(own, doc.page)?.id;
|
||||
const at = fits.findIndex((t) => t.id === current);
|
||||
// A cover the engine cannot read takes the first of the archetype's, a portrait one for a tall photo first.
|
||||
const tall = photos[0] && isPortrait(photos[0]) ? fits.find((t) => /-portrait$|^cutout-/.test(t.id)) : undefined;
|
||||
const tpl = at >= 0 ? fits[(at + 1) % fits.length] : (tall ?? fits[0]);
|
||||
if (!tpl) return null;
|
||||
const ownTitle = doc.title !== input.title ? doc.title : undefined;
|
||||
const words = { title: input.title, subtitle: input.subtitle, ownTitle };
|
||||
return s.finish(coverOnTemplate(s.c, words, { photos, entries: s.placed }, tpl), true).spread;
|
||||
}
|
||||
|
||||
/** The look after this one in its cycle; the first for a look outside it (the classic one). */
|
||||
function nextLook<T extends string>(cycle: readonly T[], look: string | undefined): T {
|
||||
const at = look ? cycle.indexOf(look as T) : -1;
|
||||
return cycle[(at + 1) % cycle.length] as T;
|
||||
}
|
||||
|
||||
/** The summary or the countries in their next look. */
|
||||
function relayTravel(s: TemplatedSetting, free: BookSpread, kind: 'summary' | 'countries'): BookSpread | null {
|
||||
if (kind === 'summary') {
|
||||
const stats = free.elements.find((e) => e.kind === 'stats' && e.metrics.length > 2);
|
||||
const look = stats?.kind === 'stats' ? stats.look : undefined;
|
||||
const next = composeTravelSummary(s.c, nextLook<BookStatsLook>(SUMMARY_CYCLE, look));
|
||||
return next ? s.finish(next).spread : null;
|
||||
}
|
||||
const country = free.elements.find((e) => e.kind === 'countries');
|
||||
const look = country?.kind === 'countries' ? country.look : undefined;
|
||||
const next = composeTravelCountries(s.c, nextLook<BookCountryLook>(COUNTRY_CYCLE, look));
|
||||
return next ? s.finish(next).spread : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of the book laid again on the designed templates, around its
|
||||
* locked elements, its id kept: null for a page the engine did not make
|
||||
* (stops, the first and last page, the back, a page made by hand), or when
|
||||
* the page and its locks would not fit.
|
||||
*/
|
||||
export function autoRelayoutSpread(
|
||||
doc: BookDocument,
|
||||
index: number,
|
||||
input: AutoDesignedInput,
|
||||
opts: Pick<AutoDesignedOptions, 'seed'> = {},
|
||||
): BookSpread | null {
|
||||
const spread = doc.spreads[index];
|
||||
const kind = autoRelayoutKind(spread);
|
||||
if (!spread || !kind) return null;
|
||||
const setting = templatedSetting(
|
||||
doc,
|
||||
designInput({ ...input, page: doc.page }, doc.theme),
|
||||
directivesOf(doc.theme, opts.seed),
|
||||
);
|
||||
return layoutAroundLocks(spread, (free) => {
|
||||
const next =
|
||||
kind === 'entry'
|
||||
? relayEntry(setting, doc, index, free)
|
||||
: kind === 'cover'
|
||||
? relayCover(setting, doc, index, input)
|
||||
: relayTravel(setting, free, kind);
|
||||
return next ? { ...next, id: spread.id } : null;
|
||||
});
|
||||
}
|
||||
@@ -16,6 +16,8 @@ export interface AutoPhoto {
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
caption?: string | null;
|
||||
/** When it was taken (ISO), where the gallery knows: the templated auto layout shares loose photos out by day. */
|
||||
takenAt?: string | null;
|
||||
}
|
||||
|
||||
export interface AutoEntry {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The seven cover archetypes a decorated book starts from: the Studio's cover
|
||||
* The twelve cover archetypes a decorated book starts from: the Studio's cover
|
||||
* templates are drawn in them, and the assistant's art direction names one.
|
||||
*
|
||||
* - spine-band: a bleed photo with a coloured band on the binding side.
|
||||
@@ -9,6 +9,11 @@
|
||||
* - grid-badge: a two by two photo grid with a round title badge at its centre.
|
||||
* - minimal-serif: a white page, a large photo with a hairline, a very large thin serif title.
|
||||
* - stamp-collage: photos in arches and stamps over a checker band, with a round postmark.
|
||||
* - riso: a risograph print, spot inks overprinted a little out of register, halftone dots, bold type.
|
||||
* - editorial: a travel magazine, a huge masthead, thin rules, an issue line, a photo edge to edge.
|
||||
* - vintage-poster: a 1950s travel poster, flat layered mountains under a sun, a stacked title, a thin frame.
|
||||
* - night-film: a dark cinematic page, a letterboxed photo, a film strip with sprocket holes, a neon accent.
|
||||
* - field-notes: a traveller's notebook, squared paper, prints taped in at an angle, pressed leaves.
|
||||
*/
|
||||
export const BOOK_COVER_ARCHETYPES = [
|
||||
'spine-band',
|
||||
@@ -18,5 +23,10 @@ export const BOOK_COVER_ARCHETYPES = [
|
||||
'grid-badge',
|
||||
'minimal-serif',
|
||||
'stamp-collage',
|
||||
'riso',
|
||||
'editorial',
|
||||
'vintage-poster',
|
||||
'night-film',
|
||||
'field-notes',
|
||||
] as const;
|
||||
export type BookCoverArchetype = (typeof BOOK_COVER_ARCHETYPES)[number];
|
||||
|
||||
@@ -93,6 +93,8 @@ export const bookDesignPlanRowSchema = z.object({
|
||||
entryId: z.number().int().positive().nullable(),
|
||||
photoIds: z.array(z.number().int().positive()).max(MAX_SPREAD_ELEMENTS),
|
||||
findings: z.object({ severe: z.number().int().nonnegative(), warn: z.number().int().nonnegative() }),
|
||||
/** The designed template the page was laid on, for a design with `layouts: 'templates'`. */
|
||||
template: z.string().max(40).optional(),
|
||||
});
|
||||
export type BookDesignPlanRowOut = z.infer<typeof bookDesignPlanRowSchema>;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { buildBook, distributeGallery } from '../auto/auto-book';
|
||||
import type { AutoEntry, AutoInput, AutoPhoto } from '../auto/auto-types';
|
||||
import type { BookCoverArchetype } from '../book-archetypes';
|
||||
import { briefFindings, type BookBriefArt } from '../book-findings-art';
|
||||
import { bookLevelFindings, sortBookFindings } from '../book-findings-design';
|
||||
import { withRealWeight } from '../book-fonts';
|
||||
@@ -11,6 +12,8 @@ import type { BookTravelContext } from '../book-travel';
|
||||
import { MAX_BOOK_TITLE, MAX_SPREADS, type BookDocument, type BookPageSetup, type BookSpread } from '../book.schema';
|
||||
import { pageFindings } from '../describe-spread';
|
||||
import type { PhotoPickCandidate } from '../photo-pick';
|
||||
import { isBookFontFamily, mutedOn, readableOn } from '../theme/book-theme';
|
||||
import type { BookTheme } from '../theme/book-theme.schema';
|
||||
import { designArt, livelyLook, type BookDesignSticker } from './design-art';
|
||||
import type { ComposeCtx } from './design-compose';
|
||||
import type { CoverMaterial } from './design-cover-kit';
|
||||
@@ -25,9 +28,18 @@ import { isLivelyBeat } from './design-lively-pacing';
|
||||
import { keepsStored, mergeDesign, type BookDesignPlanRow, type DesignedSpread } from './design-merge';
|
||||
import { applyPageOverrides } from './design-overrides';
|
||||
import type { BookDesignBeat } from './design-pacing';
|
||||
import { choosePhotos, type BookDesignLeftOut } from './design-photos';
|
||||
import {
|
||||
TEMPLATED_ENTRY_PHOTOS,
|
||||
choosePhotos,
|
||||
shareGalleryByDay,
|
||||
type BookDesignLeftOut,
|
||||
type DesignPhotoChoice,
|
||||
} from './design-photos';
|
||||
import { repairSpread } from './design-repair';
|
||||
import { designGrid, designLook, paperBackground, type BookDesignLook } from './design-style';
|
||||
import { archetypeOfTheme } from './design-template-pick';
|
||||
import { composeTemplatedInner } from './design-templated';
|
||||
import { composeTemplatedCover, composeTravelBack } from './design-templated-framing';
|
||||
import { themeDesignedSpread, unlinkDrifted } from './design-theme';
|
||||
|
||||
/**
|
||||
@@ -64,6 +76,12 @@ export interface BookDesignInput extends AutoInput {
|
||||
* for the stickers a decorated design places; without it nothing is stuck on.
|
||||
*/
|
||||
stickers?: readonly BookDesignSticker[];
|
||||
/**
|
||||
* The book's own theme, recoloured or not, for a book laid on the designed
|
||||
* templates (`layouts: 'templates'`): it is kept whole in place of the
|
||||
* style's preset. The composed pages ignore it.
|
||||
*/
|
||||
theme?: BookTheme;
|
||||
}
|
||||
|
||||
export interface BookDesignResult {
|
||||
@@ -117,6 +135,7 @@ function pageFor(doc: BookDocument | null, input: BookDesignInput, directives: B
|
||||
*/
|
||||
function classicOnly(d: BookDesignDirectives): boolean {
|
||||
if (d.style !== 'classic' || d.photos || d.palette || d.type || d.brief) return false;
|
||||
if (d.layouts === 'templates') return false;
|
||||
if (d.pages || d.cover || d.text || d.travel) return false;
|
||||
return d.chapters === 'entry' && d.density === 'balanced';
|
||||
}
|
||||
@@ -230,6 +249,7 @@ export function designBook(
|
||||
const directives = withBriefTheme(bookDesignDirectivesSchema.parse(raw));
|
||||
const entries = inScope(input.entries, directives.scope);
|
||||
if (classicOnly(directives)) return classicDesign(doc, input, directives, entries);
|
||||
if (directives.layouts === 'templates') return templatedDesign(doc, input, raw);
|
||||
|
||||
const newId = input.newId ?? bookElementId;
|
||||
const page = pageFor(doc, input, directives);
|
||||
@@ -381,3 +401,202 @@ const TITLE_PAGE_STYLES: ReadonlySet<string> = new Set(['editorial', 'journal',
|
||||
function blank(c: ComposeCtx, role: 'first' | 'last'): BookSpread {
|
||||
return { id: c.newId('sp'), role, background: paperBackground(c.look), elements: [], parked: [], entryId: null };
|
||||
}
|
||||
|
||||
// ── the book laid on the designed templates ────────────────────────────────
|
||||
|
||||
/** The look with the book's own theme kept whole: its palette, its faces, its frames. */
|
||||
function withOwnTheme(look: BookDesignLook, theme: BookTheme | undefined): BookDesignLook {
|
||||
if (!theme) return look;
|
||||
const paper = theme.palette.paper.toLowerCase();
|
||||
const ink = readableOn(paper, theme.palette.ink);
|
||||
const face = (f: string, fallback: BookDesignLook['heading']) => (isBookFontFamily(f) ? f : fallback);
|
||||
return {
|
||||
...look,
|
||||
paper,
|
||||
ink,
|
||||
muted: mutedOn(paper, ink),
|
||||
accent: theme.palette.accent,
|
||||
heading: face(theme.fonts.heading.font, look.heading),
|
||||
body: face(theme.fonts.body.font, look.body),
|
||||
frameStyle: theme.frameStyle,
|
||||
tilt: theme.tilt,
|
||||
theme,
|
||||
};
|
||||
}
|
||||
|
||||
/** The directives with each entry's photos held to what its pages on the templates take. */
|
||||
const cappedPhotos = (d: BookDesignDirectives): BookDesignDirectives => ({
|
||||
...d,
|
||||
photos: { pick: 'best', ...d.photos, perChapterMax: d.photos?.perChapterMax ?? TEMPLATED_ENTRY_PHOTOS },
|
||||
});
|
||||
|
||||
/** The cover, the first page, the inner pages, the last page and the back of a templated book, in order. */
|
||||
function templatedPages(
|
||||
c: ComposeCtx,
|
||||
input: BookDesignInput,
|
||||
parts: { placed: AutoEntry[]; usedPhotos: AutoPhoto[]; inner: DesignedSpread[]; archetype: BookCoverArchetype },
|
||||
): DesignedSpread[] {
|
||||
const { directives } = c;
|
||||
const words = {
|
||||
title: input.title,
|
||||
subtitle: input.subtitle,
|
||||
ownTitle: directives.cover?.title,
|
||||
ownSubtitle: directives.cover?.subtitle,
|
||||
};
|
||||
const cover = coverPhoto(input, directives, parts.usedPhotos);
|
||||
const material = coverMaterial(cover, parts.usedPhotos, parts.placed);
|
||||
const front = directives.cover?.template
|
||||
? { spread: composeCover(c, words, cover, material), template: null }
|
||||
: composeTemplatedCover(c, words, material, parts.archetype);
|
||||
const titlePage = TITLE_PAGE_STYLES.has(c.look.theme.id ?? directives.style);
|
||||
const first = titlePage ? composeTitlePage(c, words, parts.placed) : blank(c, 'first');
|
||||
const page = (spread: BookSpread, beat: BookDesignBeat): DesignedSpread => ({
|
||||
spread,
|
||||
beat,
|
||||
chapter: null,
|
||||
findings: [],
|
||||
});
|
||||
return [
|
||||
{ ...page(front.spread, 'cover'), ...(front.template ? { template: front.template } : {}) },
|
||||
page(first, 'first'),
|
||||
...parts.inner,
|
||||
page(blank(c, 'last'), 'last'),
|
||||
page(composeTravelBack(c, words, parts.placed), 'back'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The book laid on the Studio's designed templates (`layouts: 'templates'`):
|
||||
* the cover on a cover template of the theme's archetype, every chapter on
|
||||
* its spreads, the summary, the countries and the back cover in the newer
|
||||
* travel looks. The rest is the composer's: the scope, the photo picker, the
|
||||
* page budget, the stored pages kept, the repair and the findings.
|
||||
*/
|
||||
/** What a book on the designed templates is laid with: its context, archetype, entries and photos. */
|
||||
export interface TemplatedSetting {
|
||||
c: ComposeCtx;
|
||||
archetype: BookCoverArchetype;
|
||||
/** The book counts as calm for template choice. */
|
||||
calm: boolean;
|
||||
/** The entries in date order, the gallery shared out to them. */
|
||||
placed: AutoEntry[];
|
||||
/** Loose photos no entry had room for. */
|
||||
rest: AutoPhoto[];
|
||||
choice: DesignPhotoChoice;
|
||||
/** A page finished as the design finishes it: a templated one repaired, a composed one linked to the theme too. */
|
||||
finish: (spread: BookSpread, templated?: boolean) => { spread: BookSpread; findings: BookFinding[] };
|
||||
}
|
||||
|
||||
/**
|
||||
* The setting of a book laid on the designed templates, read from the
|
||||
* stored book, the journey and the directives: what the whole book is laid
|
||||
* with, and what laying one of its pages again needs.
|
||||
*/
|
||||
export function templatedSetting(
|
||||
doc: BookDocument | null,
|
||||
input: BookDesignInput,
|
||||
raw: BookDesignDirectivesInput,
|
||||
): TemplatedSetting {
|
||||
const directives = withBriefTheme(bookDesignDirectivesSchema.parse(raw));
|
||||
const entries = inScope(input.entries, directives.scope);
|
||||
const newId = input.newId ?? bookElementId;
|
||||
const page = pageFor(doc, input, directives);
|
||||
const shared = shareGalleryByDay(byDate(entries), input.gallery ?? [], input.candidates);
|
||||
const placed = shared.entries;
|
||||
const choice = choosePhotos(placed, input.candidates, cappedPhotos(directives), input.photos);
|
||||
const usedPhotos = [...choice.byEntry.values()].flat();
|
||||
const usedInfos = input.photos.filter((p) => usedPhotos.some((u) => u.photoId === p.photoId));
|
||||
const plain = withOwnTheme(designLook(directives, page, usedInfos), input.theme);
|
||||
const art = designArt(directives, plain.theme, input.stickers);
|
||||
const look = livelyLook(plain, art, directives);
|
||||
const c: ComposeCtx = {
|
||||
look,
|
||||
page,
|
||||
grid: designGrid(page),
|
||||
newId,
|
||||
locale: input.locale,
|
||||
infos: new Map(input.photos.map((p) => [p.photoId, p])),
|
||||
travel: travelOf(input, placed),
|
||||
directives,
|
||||
notes: [],
|
||||
art,
|
||||
};
|
||||
const archetype = directives.cover?.archetype ?? archetypeOfTheme(look.theme);
|
||||
// A rich theme designed without a brief is lively for its templates, so a poster book gets its loud pages at all.
|
||||
const calm = art.energy === 'calm' && !(look.theme.decoration === 'rich' && !art.briefed);
|
||||
const repairCtx = { page, photos: input.photos, look, newId };
|
||||
// A templated page has its colours linked to the theme by its template, so it is repaired, not relinked.
|
||||
const finish = (spread: BookSpread, templated = false) => {
|
||||
const fixed = repairSpread(templated ? spread : themeDesignedSpread(spread, look), repairCtx);
|
||||
const weighted = { ...fixed.spread, elements: fixed.spread.elements.map(withRealWeight) };
|
||||
return { spread: templated ? weighted : unlinkDrifted(weighted, look.theme), findings: fixed.findings };
|
||||
};
|
||||
return { c, archetype, calm, placed, rest: shared.rest, choice, finish };
|
||||
}
|
||||
|
||||
/**
|
||||
* The book laid on the Studio's designed templates (`layouts: 'templates'`):
|
||||
* the cover on a cover template of the theme's archetype, every chapter on
|
||||
* its spreads, the summary, the countries and the back cover in the newer
|
||||
* travel looks. The rest is the composer's: the scope, the photo picker, the
|
||||
* page budget, the stored pages kept, the repair and the findings.
|
||||
*/
|
||||
function templatedDesign(
|
||||
doc: BookDocument | null,
|
||||
input: BookDesignInput,
|
||||
raw: BookDesignDirectivesInput,
|
||||
): BookDesignResult {
|
||||
const { c, archetype, calm, placed, rest, choice, finish } = templatedSetting(doc, input, raw);
|
||||
const needs: FramingNeeds = { room: roomFor(doc, c.directives.keep), perStations: stationsPerSpread(c) };
|
||||
const inner = composeTemplatedInner(c, placed, choice, input, needs, { archetype, calm, rest });
|
||||
const usedPhotos = [...choice.byEntry.values()].flat();
|
||||
const designed = templatedPages(c, input, { placed, usedPhotos, inner: inner.spreads, archetype });
|
||||
const done = designed.map((d): DesignedSpread => ({ ...d, ...finish(d.spread, !!d.template) }));
|
||||
const leftOut = [...choice.leftOut, ...inner.leftOut];
|
||||
return templatedResult(doc, input, { c, done, finish, placed, leftOut, notes: inner.notes });
|
||||
}
|
||||
|
||||
interface TemplatedParts {
|
||||
c: ComposeCtx;
|
||||
done: DesignedSpread[];
|
||||
finish: (spread: BookSpread) => { spread: BookSpread; findings: BookFinding[] };
|
||||
placed: AutoEntry[];
|
||||
leftOut: BookDesignLeftOut[];
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
/** The templated pages over the stored book, the overrides laid on them, and what is left to say. */
|
||||
function templatedResult(doc: BookDocument | null, input: BookDesignInput, parts: TemplatedParts): BookDesignResult {
|
||||
const { c, finish, placed, notes } = parts;
|
||||
const { look, directives, page } = c;
|
||||
const designed = withinRoom(parts.done, roomFor(doc, directives.keep), notes);
|
||||
const laid = mergeDesign(doc, designed, directives.keep);
|
||||
const built: BookDocument = {
|
||||
version: 1,
|
||||
title: (directives.cover?.title ?? input.title).slice(0, MAX_BOOK_TITLE),
|
||||
page: { ...page, pageNumbers: { ...page.pageNumbers, font: look.body, startAt: 1 } },
|
||||
spreads: laid.spreads,
|
||||
...(doc?.layouts ? { layouts: doc.layouts } : {}),
|
||||
theme: look.theme,
|
||||
};
|
||||
const overrides = directives.pages?.overrides ?? [];
|
||||
const env = { c, entries: placed, finish, ops: { photos: input.photos, newId: c.newId } };
|
||||
const merged = overrides.length
|
||||
? applyPageOverrides(built, laid.plan, overrides, env)
|
||||
: { doc: built, plan: laid.plan, notes: [] };
|
||||
const residual = overrides.length
|
||||
? redesignedFindings(merged.doc, merged.plan, input.photos)
|
||||
: designed.flatMap((d) => d.findings.filter((f) => f.severity !== 'info'));
|
||||
const judged = briefOf(c);
|
||||
const brief = judged ? briefFindings(merged.doc, judged, briefScope(merged.plan)) : [];
|
||||
const used = [...new Set(merged.plan.flatMap((r) => (r.action === 'drop' ? [] : r.photoIds)))];
|
||||
return {
|
||||
doc: merged.doc,
|
||||
plan: merged.plan,
|
||||
style: { paper: look.paper, ink: look.ink, accent: look.accent, heading: look.heading, body: look.body },
|
||||
photos: { used, left_out: parts.leftOut },
|
||||
findings: sortBookFindings([...residual, ...bookLevelFindings(merged.doc), ...brief]),
|
||||
notes: [...new Set([...notes, ...c.notes, ...merged.notes])],
|
||||
...(judged ? { brief: judged } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,6 +32,11 @@ export const BOOK_COVER_ARCHETYPE_LINES: Readonly<Record<BookCoverArchetype, str
|
||||
'grid-badge': 'four photos in a grid with a white cross, a round title badge at its centre',
|
||||
'minimal-serif': 'a white page, a large photo with a hairline, a very large thin serif title and a sun ornament',
|
||||
'stamp-collage': 'photos in arches and stamps over a checker band, a round postmark',
|
||||
riso: 'spot inks overprinted out of register, halftone dots, a duotone photo, a heavy title with an ink shadow',
|
||||
editorial: 'a magazine cover: a huge masthead, a thin rule and an issue line, a photo edge to edge',
|
||||
'vintage-poster': 'a retro travel poster: flat mountains under a sun, a photo in an arch, a stacked title',
|
||||
'night-film': 'a near black page, a letterboxed photo, a film strip with sprocket holes, neon capitals',
|
||||
'field-notes': 'squared notebook paper, prints taped in at an angle, pressed leaves, a script line',
|
||||
};
|
||||
|
||||
/** The pages a lively or loud book is made of besides the calm ones (design-lively.ts). */
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('the art direction of design_book', () => {
|
||||
for (const theme of BOOK_THEME_IDS) expect(bookDesignBriefSchema.safeParse({ theme }).success, theme).toBe(true);
|
||||
});
|
||||
|
||||
it('DSNBRF-003: the cover takes an archetype of the seven and a lockup of the Text styles shelf', () => {
|
||||
it('DSNBRF-003: the cover takes an archetype of the twelve and a lockup of the Text styles shelf', () => {
|
||||
for (const archetype of BOOK_COVER_ARCHETYPES) expect(ok({ cover: { archetype } }), archetype).toBe(true);
|
||||
for (const lockup of BOOK_LOCKUPS) expect(ok({ cover: { lockup: lockup.id } }), lockup.id).toBe(true);
|
||||
expect(ok({ cover: { archetype: 'collage' } })).toBe(false);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { BookElement, BookFrame, BookPhotoElement, BookTextElement } from '../book.schema';
|
||||
import { frameOf } from './design-compose';
|
||||
import {
|
||||
addLockup,
|
||||
bandPhoto,
|
||||
coverText,
|
||||
deepened,
|
||||
inkOn,
|
||||
mmOn,
|
||||
mmToPt,
|
||||
shapeEl,
|
||||
spacedLine,
|
||||
standOut,
|
||||
titleText,
|
||||
type CoverDraft,
|
||||
type CoverKit,
|
||||
} from './design-cover-kit';
|
||||
|
||||
/**
|
||||
* Editorial: the cover of a travel magazine. The title as a huge masthead
|
||||
* across the top of the paper, a thin rule under it with the issue line (the
|
||||
* month on the left, the place on the right), the cover photo edge to edge
|
||||
* below (a field of colour without one), and the subtitle as the cover line
|
||||
* on a block of the accent in a lower corner, off the picture's detail.
|
||||
*/
|
||||
|
||||
const holds = (b: BookFrame, x: number, y: number) => x >= b.x && x <= b.x + b.w && y >= b.y && y <= b.y + b.h;
|
||||
|
||||
/** A short line of spaced capitals set flush to one side. */
|
||||
function issueLine(
|
||||
k: CoverKit,
|
||||
words: string,
|
||||
frame: BookFrame,
|
||||
color: string,
|
||||
align: 'left' | 'right',
|
||||
): BookTextElement {
|
||||
return { ...spacedLine(k, words, frame, color, 8), align };
|
||||
}
|
||||
|
||||
/** Where the cover line's block may lie: the lower left corner, else the lower right, clear of the photo's detail. */
|
||||
function coverLineBox(k: CoverKit, print: BookPhotoElement | null, w: number, h: number): BookFrame | null {
|
||||
const { W, H, m } = k;
|
||||
const corners = [frameOf(m, H - m - h, w, h), frameOf(W - m - w, H - m - h, w, h)];
|
||||
if (!print) return corners[0] ?? null;
|
||||
const fx = print.frame.x + print.focalX * print.frame.w;
|
||||
const fy = print.frame.y + print.focalY * print.frame.h;
|
||||
return corners.find((b) => !holds(frameOf(b.x - 3, b.y - 3, b.w + 6, b.h + 6), fx, fy)) ?? null;
|
||||
}
|
||||
|
||||
export function editorial(k: CoverKit): CoverDraft {
|
||||
const { c, W, H, b, m } = k;
|
||||
const draft: CoverDraft = { elements: [], groups: [] };
|
||||
const ground = c.look.paper;
|
||||
const ink = inkOn(c, ground);
|
||||
const mast = H * k.rng.range(0.24, 0.28);
|
||||
const zone = frameOf(m, m, W - m * 2, mast - m);
|
||||
const ruleY = mast + mmOn(k, 1.5);
|
||||
const lineY = ruleY + mmOn(k, 2);
|
||||
const lineH = mmOn(k, 6);
|
||||
const top = lineY + lineH + mmOn(k, 3);
|
||||
const photo = k.photos[0];
|
||||
const print = photo ? bandPhoto(k, photo, frameOf(-b, top, W + b * 2, H - top + b)) : null;
|
||||
// Without a photo its place is a field of colour, as a magazine prints a cover of type alone.
|
||||
draft.elements.push(print ?? shapeEl(k, 'rect', frameOf(-b, top, W + b * 2, H - top + b), standOut(c, ground)));
|
||||
const head: BookElement[] = [];
|
||||
const asked = addLockup(k, draft, { box: zone, ground });
|
||||
if (!asked.length && k.title.words.trim()) {
|
||||
head.push(
|
||||
titleText(k, zone, {
|
||||
max: mmToPt(zone.h * 0.82),
|
||||
color: ink,
|
||||
transform: 'upper',
|
||||
leading: 0.9,
|
||||
weight: 800,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const thin = Math.max(0.3, mmOn(k, 0.4));
|
||||
head.push(shapeEl(k, 'rect', frameOf(m, ruleY, W - m * 2, thin), ink));
|
||||
const half = (W - m * 2) / 2 - mmOn(k, 2);
|
||||
const left = k.words.dates || k.words.year || '';
|
||||
const place = k.words.place ?? '';
|
||||
if (left) head.push(issueLine(k, left, frameOf(m, lineY, half, lineH), ink, 'left'));
|
||||
if (place && place !== left) head.push(issueLine(k, place, frameOf(W - m - half, lineY, half, lineH), ink, 'right'));
|
||||
draft.elements.push(...head);
|
||||
if (head.length > 1) draft.groups.push(head.map((e) => e.id));
|
||||
// The cover line: the subtitle on a block of the accent over the photo, or on the paper without one.
|
||||
const sub = k.sub.words.trim();
|
||||
if (!sub || asked.length) return draft;
|
||||
const bw = W * k.rng.range(0.42, 0.5);
|
||||
const bh = Math.min(mmOn(k, 30), (H - top) * 0.36);
|
||||
const box = coverLineBox(k, print, bw, bh);
|
||||
if (!box) return draft;
|
||||
const block = deepened(print ? standOut(c, ground) : inkOn(c, ground));
|
||||
const pad = mmOn(k, 4);
|
||||
const words = coverText(k, sub, frameOf(box.x + pad, box.y + pad, box.w - pad * 2, box.h - pad * 2), {
|
||||
slot: 'heading',
|
||||
max: 16,
|
||||
color: inkOn(c, block),
|
||||
align: 'left',
|
||||
leading: 1.1,
|
||||
});
|
||||
const line = k.sub.bound ? { ...words, binding: { source: 'journey.subtitle' as const } } : words;
|
||||
const parts = [shapeEl(k, 'rect', box, block), line];
|
||||
draft.elements.push(...parts);
|
||||
draft.groups.push(parts.map((e) => e.id));
|
||||
return draft;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { deltaE, luminance, parseHex } from '../book-colour';
|
||||
import { solveBookTextFit } from '../book-text-fit';
|
||||
import type { BookElement, BookFrame, BookPhotoElement, BookShapeId, BookTextElement } from '../book.schema';
|
||||
import { mixHex } from '../theme/book-theme';
|
||||
import { CALM_ART } from './design-art';
|
||||
import { frameOf } from './design-compose';
|
||||
import {
|
||||
accentOn,
|
||||
addLockup,
|
||||
clearOfTrim,
|
||||
coverText,
|
||||
inkOn,
|
||||
keptTurn,
|
||||
mmOn,
|
||||
mmToPt,
|
||||
printEl,
|
||||
shapeEl,
|
||||
titleText,
|
||||
type CoverDraft,
|
||||
type CoverKit,
|
||||
} from './design-cover-kit';
|
||||
import { placeStickers, sharedArea, turnedBox } from './design-stickers';
|
||||
|
||||
/**
|
||||
* Field notes: a page of a traveller's notebook. A sheet of squared paper
|
||||
* laid a little askew on the kraft, the photos taped in as prints turned a
|
||||
* few degrees each way with a strip of tape over their top edge, pressed
|
||||
* leaves beside them, and under them the title in the display face, the
|
||||
* subtitle (or the place) in the script, and the dates in mono.
|
||||
*/
|
||||
|
||||
const WHITE = '#ffffff';
|
||||
const lum = (hex: string) => luminance(parseHex(hex) ?? { r: 255, g: 255, b: 255 });
|
||||
|
||||
/** The sheet's paper: a light colour of the palette apart from the page, else the page lightened. */
|
||||
function sheetOf(paper: string, accent2: string): string {
|
||||
const tried = [accent2, mixHex(paper, WHITE, 0.6), '#f8f4ea'];
|
||||
return tried.find((h) => lum(h) >= 0.72 && deltaE(h, paper) > 6) ?? '#f8f4ea';
|
||||
}
|
||||
|
||||
/** Where the prints go, as shares of the page: a large one, one beside it and a small one under that. */
|
||||
const SPOTS: readonly [number, number, number, number][] = [
|
||||
[0.1, 0.1, 0.48, 0.4],
|
||||
[0.62, 0.17, 0.28, 0.27],
|
||||
[0.6, 0.5, 0.3, 0.22],
|
||||
];
|
||||
|
||||
const LEAVES: readonly BookShapeId[] = ['leaf-long', 'leaf-oak', 'leaf'];
|
||||
|
||||
export function fieldNotes(k: CoverKit): CoverDraft {
|
||||
const { c, W, H, m } = k;
|
||||
const p = c.look.theme.palette;
|
||||
const draft: CoverDraft = { elements: [], groups: [] };
|
||||
const sheet = sheetOf(c.look.paper, p.accent2);
|
||||
const ink = inkOn(c, sheet);
|
||||
const tilt = -k.rng.range(1, 2.5);
|
||||
const inset = m * 0.55;
|
||||
const sheetFrame = clearOfTrim(k, frameOf(inset, inset, W - inset * 2, H - inset * 2), tilt);
|
||||
const lines = mixHex(ink, sheet, 0.86);
|
||||
const scale = Math.round(k.rng.range(0.55, 0.7) * 100) / 100;
|
||||
draft.elements.push({
|
||||
...shapeEl(k, 'rect', sheetFrame, sheet, tilt),
|
||||
paint: { type: 'pattern', pattern: 'grid', fg: lines, bg: sheet, scale, angle: 0 },
|
||||
});
|
||||
// The words take the foot of the sheet, left of the small print.
|
||||
const top = H * 0.62;
|
||||
const zone = frameOf(m, top, W * 0.54 - m, H - m - top);
|
||||
const leaf = p.accent3;
|
||||
const placed: BookFrame[] = [];
|
||||
const prints: BookPhotoElement[] = [];
|
||||
k.photos.slice(0, SPOTS.length).forEach((photo, i) => {
|
||||
const [x, y, w, h] = SPOTS[i] as [number, number, number, number];
|
||||
const turn = keptTurn(k, (i % 2 === 0 ? -1 : 1) * k.rng.range(2, 4));
|
||||
const print = printEl(k, photo, frameOf(W * x, H * y, W * w, H * h), {
|
||||
turn,
|
||||
border: { width: mmOn(k, 2.6), color: WHITE },
|
||||
shadow: true,
|
||||
});
|
||||
const box = turnedBox(print.frame, print.rotation);
|
||||
if (placed.some((o) => sharedArea(o, box) > 0) || sharedArea(box, zone) > 0) return;
|
||||
placed.push(box);
|
||||
prints.push(print);
|
||||
});
|
||||
// Pressed leaves on the sheet, behind the prints and clear of the words.
|
||||
const leaves: BookElement[] = [];
|
||||
const spots: [number, number][] = [
|
||||
[W * 0.07, H * 0.5],
|
||||
[W * 0.9, H * 0.86],
|
||||
];
|
||||
k.rng
|
||||
.shuffle(LEAVES)
|
||||
.slice(0, spots.length)
|
||||
.forEach((shape, i) => {
|
||||
const [cx, cy] = spots[i] as [number, number];
|
||||
const size = Math.min(W, H) * k.rng.range(0.16, 0.22);
|
||||
const turn = k.rng.range(-40, 40);
|
||||
const frame = clearOfTrim(k, frameOf(cx - size / 2, cy - size / 2, size, size), turn);
|
||||
if (sharedArea(turnedBox(frame, turn), zone) > 0) return;
|
||||
leaves.push({ ...shapeEl(k, shape, frame, leaf, turn), opacity: 0.9 });
|
||||
});
|
||||
draft.elements.push(...leaves);
|
||||
// Each print with its strip of tape over the top edge, the tape see-through so the picture shows under it.
|
||||
const tape = mixHex(sheet, c.look.accent, 0.22);
|
||||
for (const print of prints) {
|
||||
const tw = print.frame.w * 0.4;
|
||||
const th = mmOn(k, 7);
|
||||
const frame = frameOf(print.frame.x + (print.frame.w - tw) / 2, print.frame.y - th / 2, tw, th);
|
||||
const strip = { ...shapeEl(k, 'rect', frame, tape, print.rotation + k.rng.range(-7, 7)), opacity: 0.72 };
|
||||
const parts = [print, { ...strip, edge: 'zigzag' as const, edgeSize: mmOn(k, 1) }];
|
||||
draft.elements.push(...parts);
|
||||
draft.groups.push(parts.map((e) => e.id));
|
||||
}
|
||||
if (!addLockup(k, draft, { box: zone, ground: sheet }).length) notes(k, draft, zone, sheet, ink);
|
||||
const count = (c.art ?? CALM_ART).bare ? 0 : k.rng.int(1, 3);
|
||||
draft.elements.push(
|
||||
...placeStickers(c, draft.elements, {
|
||||
count,
|
||||
size: [0.1, 0.15],
|
||||
sides: ['single'],
|
||||
key: 'cover:field-notes',
|
||||
avoid: [zone],
|
||||
used: c.art?.used,
|
||||
}),
|
||||
);
|
||||
return draft;
|
||||
}
|
||||
|
||||
/** The notebook's own words in a box: the title, a line in the script, the dates in mono at the foot. */
|
||||
function notes(k: CoverKit, draft: CoverDraft, zone: BookFrame, sheet: string, ink: string): void {
|
||||
const words: BookTextElement[] = [];
|
||||
const script = k.sub.words.trim() || k.words.place || '';
|
||||
const dates = k.words.dates || k.words.year || '';
|
||||
const scriptH = script ? Math.min(mmOn(k, 14), zone.h * 0.26) : 0;
|
||||
const datesH = dates ? mmOn(k, 6) : 0;
|
||||
const titleH = zone.h - scriptH - datesH - mmOn(k, 2);
|
||||
if (k.title.words.trim()) {
|
||||
const frame = frameOf(zone.x, zone.y, zone.w, titleH);
|
||||
words.push(titleText(k, frame, { max: mmToPt(titleH * 0.6), color: ink, align: 'left', leading: 0.95 }));
|
||||
}
|
||||
if (script) {
|
||||
const frame = frameOf(zone.x, zone.y + titleH, zone.w, scriptH);
|
||||
const max = k.c.look.theme.fonts.script ? 26 : 14;
|
||||
const line = coverText(k, script, frame, { slot: 'script', max, color: accentOn(k.c, sheet), align: 'left' });
|
||||
const bound = script === k.sub.words.trim() && k.sub.bound;
|
||||
words.push(bound ? { ...line, binding: { source: 'journey.subtitle' } } : line);
|
||||
}
|
||||
if (dates) {
|
||||
const frame = frameOf(zone.x, zone.y + zone.h - datesH, zone.w, datesH);
|
||||
const line = coverText(k, dates, frame, { slot: 'heading', max: 8, color: ink, align: 'left', tracking: 0.12 });
|
||||
const mono: BookTextElement = { ...line, font: 'mono', weight: 400, fitMax: 8 };
|
||||
words.push({ ...mono, size: solveBookTextFit(mono, 8) });
|
||||
}
|
||||
draft.elements.push(...words);
|
||||
if (words.length > 1) draft.groups.push(words.map((e) => e.id));
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
import { bookThemeFont, isBookFontFamily, mixHex, mutedOn } from '../theme/book-theme';
|
||||
import type { BookFontSlot, BookThemeFont } from '../theme/book-theme.schema';
|
||||
import { CALM_ART, withinTilt } from './design-art';
|
||||
import { bleedPage, frameOf, photoEl, type ComposeCtx } from './design-compose';
|
||||
import { bleedPage, frameOf, photoEl, printsAt, type ComposeCtx } from './design-compose';
|
||||
import { titleWords, type CoverWords } from './design-cover-words';
|
||||
import { designRng, type DesignRng } from './design-random';
|
||||
import { turnedBox } from './design-stickers';
|
||||
@@ -171,6 +171,12 @@ export function standOut(c: ComposeCtx, ground: string, skip: readonly string[]
|
||||
return tried.find((h) => deltaE(h, ground) > 18 && contrastRatio(h, ground) >= 1.4) ?? inkOn(c, ground);
|
||||
}
|
||||
|
||||
/** An accent small words read in on a ground (4.5:1), else the ink that reads there. */
|
||||
export function accentOn(c: ComposeCtx, ground: string): string {
|
||||
const p = c.look.theme.palette;
|
||||
return [c.look.accent, p.accent3, p.accent2].find((h) => contrastRatio(h, ground) >= 4.5) ?? inkOn(c, ground);
|
||||
}
|
||||
|
||||
/**
|
||||
* A lockup's tones on a ground: the ground as its paper, an ink and a muted
|
||||
* ink that read on it, and an accent that reads on it at 4.5:1, so the words
|
||||
@@ -397,6 +403,12 @@ export function spacedLine(k: PageKit, words: string, frame: BookFrame, color: s
|
||||
});
|
||||
}
|
||||
|
||||
/** A text on a band of a colour behind its words, set in the ink that reads on the band. */
|
||||
export function onBand(k: PageKit, el: BookTextElement, band: string): BookTextElement {
|
||||
const highlight = { color: band, alpha: 1, padX: mmOn(k, 1.6), padY: mmOn(k, 0.6), radius: 0 };
|
||||
return { ...el, color: inkOn(k.c, band), highlight };
|
||||
}
|
||||
|
||||
// ── shapes and photos ──────────────────────────────────────────────────────
|
||||
|
||||
/** A filled shape, unturned unless said, with no outline. */
|
||||
@@ -456,6 +468,19 @@ export function bleedPhoto(k: CoverKit, photo: AutoPhoto): BookPhotoElement {
|
||||
return photoEl(k.c, photo, bleedPage(k.c, 'single'), { bleed: { side: 'single' } });
|
||||
}
|
||||
|
||||
/**
|
||||
* A photo across the page in a band of it, to the bleed at both sides (a
|
||||
* magazine's picture under its masthead, a letterboxed still): to the bleed
|
||||
* when it prints sharp at that size, else as a print inside the margins over
|
||||
* the same rows, as the cover photo falls back.
|
||||
*/
|
||||
export function bandPhoto(k: CoverKit, photo: AutoPhoto, band: BookFrame): BookPhotoElement {
|
||||
if (printsAt(k.c, photo, band)) return photoEl(k.c, photo, band, { bleed: { side: 'single' } });
|
||||
const y = Math.max(band.y, k.m);
|
||||
const bottom = Math.min(band.y + band.h, k.H - k.m);
|
||||
return printEl(k, photo, frameOf(k.m, y, k.W - k.m * 2, bottom - y));
|
||||
}
|
||||
|
||||
interface PrintLook {
|
||||
turn?: number;
|
||||
border?: { width: number; color: string };
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { luminance, parseHex } from '../book-colour';
|
||||
import { solveBookTextFit } from '../book-text-fit';
|
||||
import type { BookElement, BookFrame, BookLineElement, BookTextElement } from '../book.schema';
|
||||
import { mixHex } from '../theme/book-theme';
|
||||
import { frameOf } from './design-compose';
|
||||
import {
|
||||
accentOn,
|
||||
addLockup,
|
||||
bandPhoto,
|
||||
coverText,
|
||||
inkOn,
|
||||
mmOn,
|
||||
mmToPt,
|
||||
printEl,
|
||||
shapeEl,
|
||||
spacedLine,
|
||||
titleText,
|
||||
type CoverDraft,
|
||||
type CoverKit,
|
||||
} from './design-cover-kit';
|
||||
|
||||
/**
|
||||
* Night film: a dark cinematic page. The month and the place in mono in the
|
||||
* neon accent above a letterboxed still to the bleed, the title in tall
|
||||
* condensed capitals widely spaced with a short neon rule, and across the
|
||||
* foot a strip of film, its sprocket holes two dashed lines, holding the
|
||||
* next photos as frames.
|
||||
*/
|
||||
|
||||
const lum = (hex: string) => luminance(parseHex(hex) ?? { r: 255, g: 255, b: 255 });
|
||||
|
||||
/** The page's near black: the darker of the paper and the ink, taken towards black until it is one. */
|
||||
function nightOf(paper: string, ink: string): string {
|
||||
let night = lum(paper) <= lum(ink) ? paper : ink;
|
||||
for (let step = 0; step < 8 && lum(night) > 0.012; step++) night = mixHex(night, '#000000', 0.35);
|
||||
return night;
|
||||
}
|
||||
|
||||
/** A straight dashed line across a frame, as a row of sprocket holes. */
|
||||
function sprockets(k: CoverKit, frame: BookFrame, stroke: string): BookLineElement {
|
||||
return {
|
||||
id: k.c.newId('l'),
|
||||
kind: 'line',
|
||||
frame,
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
points: [
|
||||
{ x: 0, y: 0.5 },
|
||||
{ x: 1, y: 0.5 },
|
||||
],
|
||||
curve: 'straight',
|
||||
stroke,
|
||||
strokeWidth: Math.round(frame.h * 100) / 100,
|
||||
dash: 'dashed',
|
||||
squiggle: 'none',
|
||||
startCap: 'none',
|
||||
endCap: 'none',
|
||||
wobble: 0,
|
||||
ornament: 'none',
|
||||
};
|
||||
}
|
||||
|
||||
/** A line in mono, as a timecode is printed on the edge of a film. */
|
||||
function monoLine(
|
||||
k: CoverKit,
|
||||
words: string,
|
||||
frame: BookFrame,
|
||||
color: string,
|
||||
align: 'left' | 'right',
|
||||
): BookTextElement {
|
||||
const text = coverText(k, words, frame, { slot: 'heading', max: 8, color, align, tracking: 0.2 });
|
||||
const mono: BookTextElement = { ...text, font: 'mono', weight: 500, transform: 'upper', fitMax: 8 };
|
||||
return { ...mono, size: solveBookTextFit(mono, 8) };
|
||||
}
|
||||
|
||||
export function nightFilm(k: CoverKit): CoverDraft {
|
||||
const { c, W, H, b, m } = k;
|
||||
const night = nightOf(c.look.paper, c.look.ink);
|
||||
const draft: CoverDraft = { elements: [], groups: [], ...(night === c.look.paper ? {} : { background: night }) };
|
||||
const light = inkOn(c, night);
|
||||
const neon = accentOn(c, night);
|
||||
// Down the page: the timecode line, the still, the title, the strip.
|
||||
const metaH = mmOn(k, 6);
|
||||
const stillY = m + metaH + mmOn(k, 4);
|
||||
const stillH = Math.min(H * k.rng.range(0.36, 0.42), W * 0.46);
|
||||
const rest = H - m - (stillY + stillH) - mmOn(k, 12);
|
||||
const stripH = Math.min(rest * 0.5, W * 0.24);
|
||||
const stripY = H - m - stripH;
|
||||
const titleY = stillY + stillH + mmOn(k, 6);
|
||||
const titleBox = frameOf(m, titleY, W - m * 2, stripY - mmOn(k, 6) - titleY);
|
||||
const [photo, ...more] = k.photos;
|
||||
if (photo) draft.elements.push(bandPhoto(k, photo, frameOf(-b, stillY, W + b * 2, stillH)));
|
||||
// The film: a band a shade off the night, sprockets along both edges, the frames between them.
|
||||
const film = mixHex(night, light, 0.1);
|
||||
const hole = Math.max(0.6, stripH * 0.09);
|
||||
draft.elements.push(
|
||||
shapeEl(k, 'rect', frameOf(-b, stripY, W + b * 2, stripH), film),
|
||||
sprockets(k, frameOf(-b, stripY + stripH * 0.12 - hole / 2, W + b * 2, hole), mixHex(light, film, 0.3)),
|
||||
sprockets(k, frameOf(-b, stripY + stripH * 0.88 - hole / 2, W + b * 2, hole), mixHex(light, film, 0.3)),
|
||||
);
|
||||
const frames = more.slice(0, 3);
|
||||
const gap = mmOn(k, 3);
|
||||
const fw = frames.length ? (W - m * 2 - gap * (frames.length - 1)) / frames.length : 0;
|
||||
frames.forEach((p, i) => {
|
||||
draft.elements.push(printEl(k, p, frameOf(m + i * (fw + gap), stripY + stripH * 0.22, fw, stripH * 0.56)));
|
||||
});
|
||||
const meta: BookElement[] = [];
|
||||
const when = k.words.dates || k.words.year || '';
|
||||
const place = k.words.place ?? '';
|
||||
const half = (W - m * 2) / 2 - mmOn(k, 2);
|
||||
if (when) meta.push(monoLine(k, when, frameOf(m, m, half, metaH), neon, 'left'));
|
||||
if (place && place !== when) meta.push(monoLine(k, place, frameOf(W - m - half, m, half, metaH), neon, 'right'));
|
||||
draft.elements.push(...meta);
|
||||
if (meta.length > 1) draft.groups.push(meta.map((e) => e.id));
|
||||
if (addLockup(k, draft, { box: titleBox, ground: night }).length) return draft;
|
||||
const head: BookElement[] = [];
|
||||
const sub = k.sub.words.trim();
|
||||
const subH = sub ? mmOn(k, 7) : 0;
|
||||
if (k.title.words.trim()) {
|
||||
const box = frameOf(titleBox.x, titleBox.y, titleBox.w, titleBox.h - subH - mmOn(k, 3));
|
||||
head.push(
|
||||
titleText(k, box, {
|
||||
max: mmToPt(box.h * 0.85),
|
||||
color: light,
|
||||
transform: 'upper',
|
||||
tracking: 0.14,
|
||||
leading: 0.9,
|
||||
align: 'left',
|
||||
}),
|
||||
);
|
||||
}
|
||||
const ruleY = titleBox.y + titleBox.h - subH - mmOn(k, 1.5);
|
||||
head.push(shapeEl(k, 'rect', frameOf(m, ruleY, W * 0.16, Math.max(0.6, mmOn(k, 0.8))), neon));
|
||||
if (sub) {
|
||||
const line = spacedLine(k, sub, frameOf(m, titleBox.y + titleBox.h - subH, W * 0.6, subH), neon, 9);
|
||||
head.push({ ...line, align: 'left', ...(k.sub.bound ? { binding: { source: 'journey.subtitle' as const } } : {}) });
|
||||
}
|
||||
draft.elements.push(...head);
|
||||
if (head.length > 1) draft.groups.push(head.map((e) => e.id));
|
||||
return draft;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { luminance, parseHex } from '../book-colour';
|
||||
import type { BookElement, BookFrame } from '../book.schema';
|
||||
import { CALM_ART } from './design-art';
|
||||
import { frameOf } from './design-compose';
|
||||
import {
|
||||
addLockup,
|
||||
clearOfTrim,
|
||||
deepened,
|
||||
inkOn,
|
||||
keptTurn,
|
||||
mmOn,
|
||||
mmToPt,
|
||||
onBand,
|
||||
printEl,
|
||||
shapeEl,
|
||||
spacedLine,
|
||||
standOut,
|
||||
titleText,
|
||||
type CoverDraft,
|
||||
type CoverKit,
|
||||
} from './design-cover-kit';
|
||||
import { placeStickers, turnedBox } from './design-stickers';
|
||||
|
||||
/**
|
||||
* Riso print: a page printed in two or three spot inks, each a little out of
|
||||
* register with the next. A disc of one ink and a halftone disc of another
|
||||
* laid over it off centre, the cover photo as a duotone of the book's ink on
|
||||
* a block of the first ink that shows past its edge, the title in heavy
|
||||
* capitals with a hard shadow of that ink beside it, the subtitle and the
|
||||
* year on bands of their own, and a few doodles.
|
||||
*/
|
||||
|
||||
/** The darker and the lighter of two colours, for a duotone that keeps its tones the right way round in any theme. */
|
||||
function tonesOf(a: string, b: string): { dark: string; light: string } {
|
||||
const lum = (h: string) => luminance(parseHex(h) ?? { r: 255, g: 255, b: 255 });
|
||||
return lum(a) <= lum(b) ? { dark: a, light: b } : { dark: b, light: a };
|
||||
}
|
||||
|
||||
/** A box moved down until it starts below `top`, or left where it is. */
|
||||
const below = (box: BookFrame, top: number): BookFrame => (box.y >= top ? box : { ...box, y: top });
|
||||
|
||||
export function riso(k: CoverKit): CoverDraft {
|
||||
const { c, W, H, m } = k;
|
||||
const draft: CoverDraft = { elements: [], groups: [] };
|
||||
const ground = c.look.paper;
|
||||
const first = standOut(c, ground);
|
||||
const second = standOut(c, ground, [first]);
|
||||
const titleZone = frameOf(m, m, W - m * 2, H * 0.3);
|
||||
const titleFoot = titleZone.y + titleZone.h + mmOn(k, 12);
|
||||
const photo = k.photos[0];
|
||||
const pw = W * k.rng.range(0.5, 0.56);
|
||||
const ph = Math.min(H * 0.46, H - m - titleFoot);
|
||||
const at = frameOf(W - m - pw - k.rng.range(0, 0.04) * W, H - m - ph, pw, ph);
|
||||
// The two inks of the discs, the second laid over the first a few millimetres off.
|
||||
const d = Math.min(W, H) * k.rng.range(0.5, 0.6);
|
||||
const miss = mmOn(k, k.rng.range(3, 5));
|
||||
const disc = clearOfTrim(k, below(frameOf(at.x - d * 0.45, at.y + at.h * 0.35 - d / 2, d, d), titleFoot));
|
||||
const solid = shapeEl(k, 'ellipse', disc, second);
|
||||
const dots = shapeEl(k, 'ellipse', clearOfTrim(k, frameOf(disc.x + miss, disc.y - miss, d, d)), first);
|
||||
const scale = Math.round(k.rng.range(0.35, 0.5) * 100) / 100;
|
||||
draft.elements.push(
|
||||
{ ...solid, opacity: 0.85 },
|
||||
{ ...dots, paint: { type: 'pattern', pattern: 'dots', fg: first, bg: null, scale, angle: 45 } },
|
||||
);
|
||||
let photoBox: BookFrame | null = null;
|
||||
if (photo) {
|
||||
const turn = keptTurn(k, k.rng.sign() * k.rng.range(1.5, 3));
|
||||
const print = printEl(k, photo, at, { turn });
|
||||
const shift = mmOn(k, k.rng.range(2.5, 4));
|
||||
const offset = frameOf(print.frame.x - shift, print.frame.y + shift, print.frame.w, print.frame.h);
|
||||
const block = shapeEl(k, 'rect', offset, first, print.rotation);
|
||||
draft.elements.push(block, { ...print, duotone: tonesOf(c.look.ink, c.look.paper) });
|
||||
photoBox = turnedBox(offset, print.rotation);
|
||||
}
|
||||
const ink = inkOn(c, ground);
|
||||
const head: BookElement[] = [];
|
||||
if (!addLockup(k, draft, { box: titleZone, ground }).length) {
|
||||
if (k.title.words.trim()) {
|
||||
const title = titleText(k, titleZone, {
|
||||
max: mmToPt(titleZone.h / 2 / 0.95),
|
||||
color: ink,
|
||||
transform: 'upper',
|
||||
leading: 0.92,
|
||||
weight: 800,
|
||||
align: 'left',
|
||||
});
|
||||
const dx = mmOn(k, 1.6);
|
||||
head.push({ ...title, shadow: { dx, dy: dx, color: first, alpha: 1 } });
|
||||
}
|
||||
const sub = k.sub.words.trim();
|
||||
if (sub) {
|
||||
const line = spacedLine(k, sub, frameOf(m, titleZone.y + titleZone.h + mmOn(k, 3), W * 0.5, mmOn(k, 7)), ink, 10);
|
||||
const bound = k.sub.bound ? { ...line, binding: { source: 'journey.subtitle' as const } } : line;
|
||||
head.push(onBand(k, { ...bound, align: 'left' }, ground));
|
||||
}
|
||||
draft.elements.push(...head);
|
||||
if (head.length > 1) draft.groups.push(head.map((t) => t.id));
|
||||
}
|
||||
const year = k.words.year ?? '';
|
||||
// The year on a band of the second ink at the foot, left of the photo and its block, deepened
|
||||
// where a mid-tone ink would not carry a small line.
|
||||
const room = (photoBox ? photoBox.x : W - m) - m - mmOn(k, 4);
|
||||
if (year && room > mmOn(k, 18)) {
|
||||
const frame = frameOf(m, H - m - mmOn(k, 7), Math.min(room, mmOn(k, 40)), mmOn(k, 7));
|
||||
draft.elements.push(onBand(k, { ...spacedLine(k, year, frame, ink, 9), align: 'left' }, deepened(second)));
|
||||
}
|
||||
const count = (c.art ?? CALM_ART).bare ? 0 : k.rng.int(2, 4);
|
||||
draft.elements.push(
|
||||
...placeStickers(c, draft.elements, {
|
||||
count,
|
||||
size: [0.1, 0.16],
|
||||
sides: ['single'],
|
||||
key: 'cover:riso',
|
||||
avoid: [titleZone],
|
||||
used: c.art?.used,
|
||||
}),
|
||||
);
|
||||
return draft;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { deltaE } from '../book-colour';
|
||||
import type { BookElement } from '../book.schema';
|
||||
import { mixHex } from '../theme/book-theme';
|
||||
import { frameOf } from './design-compose';
|
||||
import {
|
||||
addLockup,
|
||||
inkOn,
|
||||
mmOn,
|
||||
mmToPt,
|
||||
printEl,
|
||||
shapeEl,
|
||||
spacedLine,
|
||||
titleText,
|
||||
type CoverDraft,
|
||||
type CoverKit,
|
||||
} from './design-cover-kit';
|
||||
|
||||
/**
|
||||
* Vintage poster: a travel poster of the 1950s. A thin rule framing the
|
||||
* page, and inside it a scenic panel of flat colour: a sky, a sun with its
|
||||
* rays at the horizon, two ranges of mountains and a band of sea, the cover
|
||||
* photo standing in an arch before the sun with a white cut edge, and under
|
||||
* the panel the title stacked in the display face with the month at the foot.
|
||||
*/
|
||||
|
||||
const WHITE = '#ffffff';
|
||||
|
||||
/** A colour of the palette that stands apart from another (delta E over 18), else the fallback. */
|
||||
const apart = (want: string, from: string, fallback: string) => (deltaE(want, from) > 18 ? want : fallback);
|
||||
|
||||
export function vintagePoster(k: CoverKit): CoverDraft {
|
||||
const { c, W, H, m } = k;
|
||||
const p = c.look.theme.palette;
|
||||
const draft: CoverDraft = { elements: [], groups: [] };
|
||||
const ground = c.look.paper;
|
||||
const ink = inkOn(c, ground);
|
||||
const sun = c.look.accent;
|
||||
const sky = apart(mixHex(p.accent3, ground, 0.3), sun, mixHex(p.accent2, ground, 0.45));
|
||||
const near = apart(p.accent2, sky, mixHex(ink, sky, 0.35));
|
||||
const far = mixHex(near, sky, 0.45);
|
||||
const sea = mixHex(near, ink, 0.25);
|
||||
const hair = Math.max(0.3, mmOn(k, 0.6));
|
||||
const edge = mmOn(k, 3.5);
|
||||
const border = { ...shapeEl(k, 'rect', frameOf(edge, edge, W - edge * 2, H - edge * 2), ink), fill: null };
|
||||
draft.elements.push({ ...border, stroke: ink, strokeWidth: hair });
|
||||
const inset = mmOn(k, 7);
|
||||
const panel = frameOf(inset, inset, W - inset * 2, H * k.rng.range(0.6, 0.64) - inset);
|
||||
const horizon = panel.y + panel.h * k.rng.range(0.5, 0.56);
|
||||
const rays = Math.min(panel.w, panel.h) * 0.92;
|
||||
const ry = Math.min(Math.max(horizon - rays / 2, panel.y), panel.y + panel.h - rays);
|
||||
const disc = rays * 0.6;
|
||||
draft.elements.push(
|
||||
shapeEl(k, 'rect', panel, sky),
|
||||
{ ...shapeEl(k, 'sunburst-24', frameOf(W / 2 - rays / 2, ry, rays, rays), mixHex(sun, sky, 0.5)), opacity: 0.7 },
|
||||
shapeEl(k, 'ellipse', frameOf(W / 2 - disc / 2, ry + (rays - disc) / 2, disc, disc), sun),
|
||||
);
|
||||
// The ranges from the horizon down to the panel's foot, the far one paler, the sea in front of them.
|
||||
const foot = panel.y + panel.h;
|
||||
const farW = panel.w * k.rng.range(0.6, 0.7);
|
||||
const nearW = panel.w * k.rng.range(0.62, 0.72);
|
||||
const left = k.rng.chance(0.5);
|
||||
const seaH = panel.h * 0.12;
|
||||
const farX = left ? panel.x : panel.x + panel.w - farW;
|
||||
const nearX = left ? panel.x + panel.w - nearW : panel.x;
|
||||
const nearY = horizon + (foot - horizon) * 0.25;
|
||||
draft.elements.push(
|
||||
shapeEl(k, 'mountain', frameOf(farX, horizon, farW, foot - horizon), far),
|
||||
shapeEl(k, 'mountain', frameOf(nearX, nearY, nearW, foot - nearY), near),
|
||||
shapeEl(k, 'rect', frameOf(panel.x, foot - seaH, panel.w, seaH), sea),
|
||||
);
|
||||
const photo = k.photos[0];
|
||||
if (photo) {
|
||||
const ph = panel.h * 0.74;
|
||||
const pw = Math.min(ph * 0.58, panel.w * 0.42);
|
||||
const arch = printEl(k, photo, frameOf(W / 2 - pw / 2, foot - ph - mmOn(k, 3), pw, ph), {
|
||||
mask: 'arch-tall',
|
||||
cutout: { width: mmOn(k, 2), color: WHITE },
|
||||
});
|
||||
draft.elements.push(arch);
|
||||
}
|
||||
// Under the panel: the title stacked in the display face, the month or the year at the foot.
|
||||
const line = k.words.dates || k.words.year || '';
|
||||
const lineH = line ? mmOn(k, 7) : 0;
|
||||
const top = foot + mmOn(k, 5);
|
||||
const box = frameOf(m, top, W - m * 2, H - m - top - lineH - mmOn(k, 2));
|
||||
const words: BookElement[] = [];
|
||||
if (!addLockup(k, draft, { box: frameOf(box.x, box.y, box.w, H - m - top), ground }).length) {
|
||||
if (k.title.words.trim()) {
|
||||
words.push(titleText(k, box, { max: mmToPt(box.h * 0.8), color: ink, transform: 'upper', leading: 0.92 }));
|
||||
}
|
||||
if (line) words.push(spacedLine(k, line, frameOf(m, H - m - lineH, W - m * 2, lineH), ink, 9));
|
||||
draft.elements.push(...words);
|
||||
if (words.length > 1) draft.groups.push(words.map((e) => e.id));
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BOOK_COVER_ARCHETYPES, type BookCoverArchetype } from '../book-archetypes';
|
||||
import { luminance, parseHex } from '../book-colour';
|
||||
import { bookSpreadSchema, type BookElement, type BookSpread } from '../book.schema';
|
||||
import { pageFindings } from '../describe-spread';
|
||||
import { BOOK_STICKERS } from '../stickers/sticker-catalogue';
|
||||
@@ -11,7 +12,7 @@ import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* The seven composed covers (design-cover.ts) on the fixture journeys: each
|
||||
* The twelve composed covers (design-cover.ts) on the fixture journeys: each
|
||||
* a valid page, its words inside the page, nothing left for the repair to
|
||||
* flag, its archetype's signature there, the same for one seed and another
|
||||
* for another seed, and the cover of a design without an archetype exactly
|
||||
@@ -73,6 +74,38 @@ const SIGNATURE: Record<BookCoverArchetype, (s: BookSpread, W: number, H: number
|
||||
photos(s).some((p) => p.mask === 'arch-tall') &&
|
||||
photos(s).some((p) => p.edge === 'perforated') &&
|
||||
shapes(s).some((e) => e.shape === 'ellipse' && e.fill === null),
|
||||
riso: (s) =>
|
||||
shapes(s).some((e) => e.paint?.type === 'pattern' && e.paint.pattern === 'dots') &&
|
||||
texts(s).some((t) => t.binding?.source === 'journey.title' && !!t.shadow) &&
|
||||
photos(s).some(
|
||||
(p) =>
|
||||
!!p.duotone &&
|
||||
shapes(s).some(
|
||||
(e) =>
|
||||
e.shape === 'rect' &&
|
||||
Math.abs(e.frame.w - p.frame.w) < 0.5 &&
|
||||
Math.abs(e.frame.h - p.frame.h) < 0.5 &&
|
||||
(e.frame.x !== p.frame.x || e.frame.y !== p.frame.y),
|
||||
),
|
||||
),
|
||||
editorial: (s, W, H) =>
|
||||
shapes(s).some((e) => e.shape === 'rect' && e.frame.h <= 1 && e.frame.w >= W * 0.6) &&
|
||||
texts(s).some((t) => t.binding?.source === 'journey.title' && t.frame.y + t.frame.h <= H * 0.32) &&
|
||||
photos(s).some((p) => p.frame.w >= W * 0.6),
|
||||
'vintage-poster': (s, W) =>
|
||||
shapes(s).filter((e) => e.shape === 'mountain').length >= 2 &&
|
||||
shapes(s).some((e) => e.shape === 'sunburst-24') &&
|
||||
shapes(s).some((e) => e.fill === null && !!e.stroke && e.frame.w >= W * 0.8) &&
|
||||
photos(s).every((p) => p.mask === 'arch-tall'),
|
||||
'night-film': (s, W) =>
|
||||
luminance(parseHex(s.background ?? '#ffffff') ?? { r: 255, g: 255, b: 255 }) < 0.05 &&
|
||||
kinds(s, 'line').filter((e) => e.kind === 'line' && e.dash === 'dashed').length >= 2 &&
|
||||
shapes(s).some((e) => e.shape === 'rect' && e.frame.w >= W),
|
||||
'field-notes': (s) =>
|
||||
shapes(s).some((e) => e.paint?.type === 'pattern' && e.paint.pattern === 'grid') &&
|
||||
shapes(s).some((e) => e.shape.startsWith('leaf')) &&
|
||||
photos(s).length > 0 &&
|
||||
photos(s).every((p) => !!p.border),
|
||||
};
|
||||
|
||||
/** Every word on a cover lies on the page, turned with its frame; every other element at least touches it. */
|
||||
|
||||
@@ -4,6 +4,8 @@ import { solveBookTextFit } from '../book-text-fit';
|
||||
import type { BookElement, BookSpread } from '../book.schema';
|
||||
import { frameOf, scrimEl, type ComposeCtx } from './design-compose';
|
||||
import { gridBadge, stampCollage } from './design-cover-collage';
|
||||
import { editorial } from './design-cover-editorial';
|
||||
import { fieldNotes } from './design-cover-field-notes';
|
||||
import { postcard, posterPop } from './design-cover-graphic';
|
||||
import {
|
||||
addLockup,
|
||||
@@ -13,12 +15,15 @@ import {
|
||||
type CoverKit,
|
||||
type CoverMaterial,
|
||||
} from './design-cover-kit';
|
||||
import { nightFilm } from './design-cover-night-film';
|
||||
import { cutout, minimalSerif, spineBand } from './design-cover-photo';
|
||||
import { riso } from './design-cover-riso';
|
||||
import { vintagePoster } from './design-cover-vintage-poster';
|
||||
import { titleWords, type CoverWords } from './design-cover-words';
|
||||
import { paperBackground } from './design-style';
|
||||
|
||||
/**
|
||||
* The composed covers: `cover.archetype` picks one of seven composers, each
|
||||
* The composed covers: `cover.archetype` picks one of twelve composers, each
|
||||
* building its archetype from the book's own material (its photos, its words,
|
||||
* its theme's colours, faces and motifs, the lockups) with the seed's
|
||||
* variation, deterministic for one seed. A cover without an archetype is the
|
||||
@@ -33,6 +38,11 @@ const COMPOSERS: Readonly<Record<BookCoverArchetype, (k: CoverKit) => CoverDraft
|
||||
'grid-badge': gridBadge,
|
||||
'minimal-serif': minimalSerif,
|
||||
'stamp-collage': stampCollage,
|
||||
riso,
|
||||
editorial,
|
||||
'vintage-poster': vintagePoster,
|
||||
'night-film': nightFilm,
|
||||
'field-notes': fieldNotes,
|
||||
};
|
||||
|
||||
/** A draft as the cover page: on its ground, each lockup one group. */
|
||||
|
||||
@@ -158,6 +158,17 @@ export const bookDesignDirectivesSchema = z.strictObject({
|
||||
/** Only honoured for a blank or a new book. */
|
||||
preset: bookPagePresetSchema.optional(),
|
||||
seed: z.number().int().min(0).max(2147483647).optional(),
|
||||
/**
|
||||
* What the pages are laid on: the engine's own composed pages (the default),
|
||||
* or the Studio's designed templates in the archetype of the book's theme,
|
||||
* with the summary, countries and marks in the newer travel looks.
|
||||
*/
|
||||
layouts: z
|
||||
.enum(['composed', 'templates'])
|
||||
.describe(
|
||||
"composed: the engine's own pages (default). templates: every page laid on the Studio's designed templates in the archetype of the book's theme.",
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
export type BookDesignDirectives = z.infer<typeof bookDesignDirectivesSchema>;
|
||||
export type BookDesignDirectivesInput = z.input<typeof bookDesignDirectivesSchema>;
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface BookDesignPlanRow {
|
||||
entryId: number | null;
|
||||
photoIds: number[];
|
||||
findings: { severe: number; warn: number };
|
||||
/** The decorated template the page was laid on, when the design laid it on one. */
|
||||
template?: string;
|
||||
}
|
||||
|
||||
export interface DesignedSpread {
|
||||
@@ -30,6 +32,8 @@ export interface DesignedSpread {
|
||||
beat: BookDesignBeat;
|
||||
chapter: string | null;
|
||||
findings: BookFinding[];
|
||||
/** The decorated template the page was laid on (`layouts: 'templates'`). */
|
||||
template?: string;
|
||||
}
|
||||
|
||||
const photoIdsOf = (spread: BookSpread) =>
|
||||
@@ -55,6 +59,7 @@ interface Placed {
|
||||
beat: BookDesignBeat;
|
||||
chapter: string | null;
|
||||
findings: BookFinding[];
|
||||
template?: string;
|
||||
}
|
||||
|
||||
const placedOf = (d: DesignedSpread, action: BookDesignAction, id?: string): Placed => ({
|
||||
@@ -63,6 +68,7 @@ const placedOf = (d: DesignedSpread, action: BookDesignAction, id?: string): Pla
|
||||
beat: d.beat,
|
||||
chapter: d.chapter,
|
||||
findings: d.findings,
|
||||
...(d.template ? { template: d.template } : {}),
|
||||
});
|
||||
|
||||
const keptOf = (spread: BookSpread, beat: BookDesignBeat): Placed => ({
|
||||
@@ -133,6 +139,7 @@ export function mergeDesign(
|
||||
entryId: p.spread.entryId,
|
||||
photoIds: photoIdsOf(p.spread),
|
||||
findings: { severe: count(p.findings, 'severe'), warn: count(p.findings, 'warn') },
|
||||
...(p.template ? { template: p.template } : {}),
|
||||
}));
|
||||
for (const s of dropped) {
|
||||
plan.push({
|
||||
|
||||
@@ -190,3 +190,63 @@ export function burstOf(photos: readonly AutoPhoto[], candidates: readonly Photo
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
/** The most photos one entry's pages take on the designed templates; the rest are left out as `chapter_max`. */
|
||||
export const TEMPLATED_ENTRY_PHOTOS = 12;
|
||||
|
||||
/** The day a photo was taken on, from the photo or from the picker's facts of it. */
|
||||
function dayOf(photo: AutoPhoto, candidates: readonly PhotoPickCandidate[]): string | null {
|
||||
const at = photo.takenAt ?? candidates.find((c) => c.photoId === photo.photoId)?.takenAt ?? null;
|
||||
return at ? at.slice(0, 10) : null;
|
||||
}
|
||||
|
||||
/** Days between two ISO dates, for the nearest entry to a photo; far for an undated one. */
|
||||
function daysApart(a: string | null, b: string | null): number {
|
||||
if (!a || !b) return 1e6;
|
||||
return Math.abs(Date.parse(a) - Date.parse(b)) / 86_400_000;
|
||||
}
|
||||
|
||||
/**
|
||||
* The journey's loose photos shared out to its entries, for a book laid on
|
||||
* the designed templates: each first to the entry it is linked to, then to
|
||||
* the entry of the day it was taken, then, in the order they were taken, to
|
||||
* the entry that has the fewest photos (the nearest in time of those), never
|
||||
* past `cap` photos an entry. What no entry has room for comes back as
|
||||
* `rest`, for the book's closing pages of more moments. Unlike
|
||||
* `distributeGallery` no photo is dropped on the way.
|
||||
*/
|
||||
export function shareGalleryByDay(
|
||||
entries: readonly AutoEntry[],
|
||||
gallery: readonly AutoPhoto[],
|
||||
candidates: readonly PhotoPickCandidate[],
|
||||
cap = TEMPLATED_ENTRY_PHOTOS,
|
||||
): { entries: AutoEntry[]; rest: AutoPhoto[] } {
|
||||
const out = entries.map((e) => ({ ...e, photos: [...e.photos] }));
|
||||
const placed = new Set(out.flatMap((e) => e.photos.map((p) => p.photoId)));
|
||||
const loose = gallery.filter((p) => !placed.has(p.photoId));
|
||||
const room = (e: AutoEntry) => e.photos.length < cap;
|
||||
const later: AutoPhoto[] = [];
|
||||
for (const photo of loose) {
|
||||
const linked = candidates.find((c) => c.photoId === photo.photoId)?.entryIds ?? [];
|
||||
const day = dayOf(photo, candidates);
|
||||
const home =
|
||||
out.find((e) => linked.includes(e.id) && room(e)) ?? out.find((e) => day !== null && e.date === day && room(e));
|
||||
if (home) home.photos.push(photo);
|
||||
else later.push(photo);
|
||||
}
|
||||
const dated = [...later].sort((a, b) =>
|
||||
(dayOf(a, candidates) ?? '9999').localeCompare(dayOf(b, candidates) ?? '9999'),
|
||||
);
|
||||
const rest: AutoPhoto[] = [];
|
||||
for (const photo of dated) {
|
||||
const day = dayOf(photo, candidates);
|
||||
const open = out.filter(room);
|
||||
const fewest = Math.min(...open.map((e) => e.photos.length));
|
||||
const home = open
|
||||
.filter((e) => e.photos.length === fewest)
|
||||
.sort((a, b) => daysApart(a.date, day) - daysApart(b.date, day))[0];
|
||||
if (home) home.photos.push(photo);
|
||||
else rest.push(photo);
|
||||
}
|
||||
return { entries: out, rest };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import type { AutoPhoto } from '../auto/auto-types';
|
||||
import { BOOK_COVER_ARCHETYPES } from '../book-archetypes';
|
||||
import { bookPageSetupForPreset } from '../book-pages';
|
||||
import { decoratedTemplatesFor, findDecoratedTemplate } from '../templates/template-catalogue';
|
||||
import { templatePhotoSlots, templateRoles, type BookDecoratedTemplate } from '../templates/template-format';
|
||||
import { bookThemeOf } from '../theme/book-theme';
|
||||
import { BOOK_THEME_IDS } from '../theme/book-theme.schema';
|
||||
import {
|
||||
ARCHETYPE_KIN,
|
||||
THEME_ARCHETYPE,
|
||||
archetypeOfTheme,
|
||||
bodyCapacity,
|
||||
chunkPhotos,
|
||||
chunkSizesOf,
|
||||
innerPool,
|
||||
pickCoverTemplate,
|
||||
pickInnerTemplate,
|
||||
templateEnergy,
|
||||
type TemplateChunk,
|
||||
type TemplatePickCtx,
|
||||
} from './design-template-pick';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/** Picking a decorated spread for a page's photos and words. */
|
||||
|
||||
const page = bookPageSetupForPreset('square-210');
|
||||
const wide = (photoId: number): AutoPhoto => ({ photoId, width: 4000, height: 3000 });
|
||||
const tall = (photoId: number): AutoPhoto => ({ photoId, width: 3000, height: 4000 });
|
||||
const photos = (n: number, at = 1) => Array.from({ length: n }, (_, i) => (i % 3 === 2 ? tall(at + i) : wide(at + i)));
|
||||
const STORY = 'We walked up from the harbour in the morning and found the old town still asleep. ';
|
||||
|
||||
const ctxOf = (over: Partial<TemplatePickCtx> = {}): TemplatePickCtx => ({
|
||||
archetype: 'spine-band',
|
||||
page,
|
||||
theme: bookThemeOf('fabric-classic'),
|
||||
history: [],
|
||||
calm: false,
|
||||
seed: 7,
|
||||
key: 'k',
|
||||
...over,
|
||||
});
|
||||
|
||||
const chunk = (n: number, story = '', mappable = false): TemplateChunk => ({ photos: photos(n), story, mappable });
|
||||
const roles = (t: BookDecoratedTemplate) => templateRoles(t) as readonly string[];
|
||||
|
||||
/** A book of pages picked one after the other, as the auto layout picks them. */
|
||||
function book(chunks: readonly TemplateChunk[], over: Partial<TemplatePickCtx> = {}): BookDecoratedTemplate[] {
|
||||
const out: BookDecoratedTemplate[] = [];
|
||||
chunks.forEach((c, i) => {
|
||||
const pick = pickInnerTemplate(c, ctxOf({ ...over, history: out.map((t) => t.id), key: `e${i}` }));
|
||||
if (pick) out.push(pick);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
const varied = Array.from({ length: 20 }, (_, i) =>
|
||||
chunk(i % 9, i % 3 === 0 ? STORY.repeat(1 + (i % 4)) : '', i % 5 === 0),
|
||||
);
|
||||
|
||||
describe('the template picker', () => {
|
||||
it('AUTOTPL-001: never picks a template that would leave a photo frame empty', () => {
|
||||
for (const archetype of BOOK_COVER_ARCHETYPES) {
|
||||
for (let n = 0; n <= 9; n++) {
|
||||
for (const story of ['', STORY]) {
|
||||
const pick = pickInnerTemplate(chunk(n, story), ctxOf({ archetype }));
|
||||
if (pick) expect(templatePhotoSlots(pick), `${archetype} ${n}`).toBeLessThanOrEqual(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('AUTOTPL-002: a story always lands on a template with a story frame', () => {
|
||||
for (const archetype of BOOK_COVER_ARCHETYPES) {
|
||||
for (let n = 0; n <= 8; n++) {
|
||||
const pick = pickInnerTemplate(chunk(n, STORY), ctxOf({ archetype }));
|
||||
expect(pick, `${archetype} ${n}`).toBeDefined();
|
||||
expect(roles(pick as BookDecoratedTemplate)).toContain('body');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('AUTOTPL-003: a map template only where there is a place to draw', () => {
|
||||
for (const archetype of BOOK_COVER_ARCHETYPES) {
|
||||
for (let n = 1; n <= 8; n++) {
|
||||
const pick = pickInnerTemplate(chunk(n), ctxOf({ archetype }));
|
||||
if (pick) expect(roles(pick), `${archetype} ${n}`).not.toContain('map');
|
||||
}
|
||||
}
|
||||
const mapped = [1, 2, 3, 4, 5].map((n) =>
|
||||
pickInnerTemplate(chunk(n, '', true), ctxOf({ archetype: 'field-notes' })),
|
||||
);
|
||||
expect(mapped.every((t) => t !== undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('AUTOTPL-004: never the same template twice running, never loud after busy, unless only that keeps every photo framed in the style', () => {
|
||||
const busy = (t: BookDecoratedTemplate) => ['loud', 'lively'].includes(templateEnergy(t));
|
||||
for (const archetype of BOOK_COVER_ARCHETYPES) {
|
||||
const picks = book(varied, { archetype, theme: null });
|
||||
expect(picks.length, archetype).toBe(varied.length);
|
||||
picks.forEach((t, i) => {
|
||||
const before = picks[i - 1];
|
||||
const page = varied[i];
|
||||
if (!before || !page) return;
|
||||
const keeps = (x: BookDecoratedTemplate) =>
|
||||
x.id !== before.id && !(templateEnergy(x) === 'loud' && busy(before));
|
||||
if (keeps(t)) return;
|
||||
// The rhythm gives way only where no template of the book's styles frames every photo and keeps it.
|
||||
const fits = (x: BookDecoratedTemplate) =>
|
||||
templatePhotoSlots(x) === page.photos.length &&
|
||||
(!page.story || roles(x).includes('body')) &&
|
||||
(!roles(x).includes('map') || page.mappable);
|
||||
expect(
|
||||
innerPool(archetype)
|
||||
.filter((x) => fits(x) && keeps(x))
|
||||
.map((x) => x.id),
|
||||
`${archetype} at ${i}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('AUTOTPL-005: twenty varied entries are laid on at least eight different templates', () => {
|
||||
for (const archetype of BOOK_COVER_ARCHETYPES) {
|
||||
const picks = book(varied, { archetype });
|
||||
expect(new Set(picks.map((t) => t.id)).size, archetype).toBeGreaterThanOrEqual(8);
|
||||
}
|
||||
});
|
||||
|
||||
it("AUTOTPL-006: the theme's own archetype wins over a kin one that fits as well", () => {
|
||||
for (const archetype of BOOK_COVER_ARCHETYPES) {
|
||||
for (const tpl of decoratedTemplatesFor('inner', archetype)) {
|
||||
if (roles(tpl).includes('map')) continue;
|
||||
const n = templatePhotoSlots(tpl);
|
||||
const pick = pickInnerTemplate(
|
||||
{ photos: photos(n), story: '', mappable: true },
|
||||
ctxOf({ archetype, theme: null }),
|
||||
);
|
||||
expect(pick?.archetype, `${archetype} ${n}`).toBe(archetype);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('AUTOTPL-007: the same seed picks the same templates, another seed others', () => {
|
||||
const one = book(varied, { seed: 1 }).map((t) => t.id);
|
||||
expect(book(varied, { seed: 1 }).map((t) => t.id)).toEqual(one);
|
||||
const others = [2, 3, 4, 5, 6].map((seed) =>
|
||||
book(varied, { seed })
|
||||
.map((t) => t.id)
|
||||
.join(),
|
||||
);
|
||||
expect(others.some((o) => o !== one.join())).toBe(true);
|
||||
});
|
||||
|
||||
it('AUTOTPL-008: every theme has an archetype, and every archetype kin of its own kind', () => {
|
||||
for (const id of BOOK_THEME_IDS) expect(BOOK_COVER_ARCHETYPES).toContain(THEME_ARCHETYPE[id]);
|
||||
expect(THEME_ARCHETYPE).toMatchObject({
|
||||
riso: 'riso',
|
||||
magazine: 'editorial',
|
||||
'retro-travel': 'vintage-poster',
|
||||
midnight: 'night-film',
|
||||
'field-notes': 'field-notes',
|
||||
});
|
||||
for (const a of BOOK_COVER_ARCHETYPES) {
|
||||
expect(ARCHETYPE_KIN[a].length).toBeGreaterThan(0);
|
||||
expect(ARCHETYPE_KIN[a]).not.toContain(a);
|
||||
}
|
||||
expect(ARCHETYPE_KIN['night-film']).toEqual(['editorial', 'minimal-serif']);
|
||||
});
|
||||
|
||||
it('AUTOTPL-009: a story frame holds more the larger it is drawn', () => {
|
||||
const base = findDecoratedTemplate('story-quote') as BookDecoratedTemplate;
|
||||
const grown = (k: number): BookDecoratedTemplate => ({
|
||||
...base,
|
||||
id: `story-quote-x${k}`,
|
||||
elements: base.elements.map((e) => (e.role === 'body' ? { ...e, frame: { ...e.frame, h: e.frame.h * k } } : e)),
|
||||
});
|
||||
const sizes = [0.5, 1, 1.5, 2].map((k) => bodyCapacity(grown(k), page));
|
||||
for (let i = 1; i < sizes.length; i++) expect(sizes[i] as number).toBeGreaterThanOrEqual(sizes[i - 1] as number);
|
||||
expect(sizes[3] as number).toBeGreaterThan(sizes[0] as number);
|
||||
expect(bodyCapacity(findDecoratedTemplate('grid-tight') as BookDecoratedTemplate, page)).toBe(0);
|
||||
});
|
||||
|
||||
it('AUTOTPL-010: photos are cut into pages that leave no frame empty and park as few as there can be', () => {
|
||||
const sizes = { any: new Set([0, 1, 2, 3, 4, 5, 6, 7, 8]), story: new Set([0, 1, 2, 3, 4, 6]) };
|
||||
for (let n = 1; n <= 20; n++) {
|
||||
for (const story of [false, true]) {
|
||||
const cut = chunkPhotos(n, story, sizes);
|
||||
expect(cut.sizes.reduce((a, b) => a + b, 0) + cut.parked).toBe(n);
|
||||
expect(cut.parked, `${n}`).toBe(0);
|
||||
if (story) expect(sizes.story.has(cut.sizes[0] as number)).toBe(true);
|
||||
}
|
||||
}
|
||||
expect(chunkPhotos(12, false, sizes).sizes).toEqual([6, 6]);
|
||||
expect(chunkPhotos(5, true, sizes).sizes).toEqual([2, 3]);
|
||||
// A story too long for a small page's frame goes on a page of words of its own.
|
||||
expect(chunkPhotos(2, true, { ...sizes, pageCost: (s, first) => (first && s > 0 ? 30 : 0) }).sizes).toEqual([0, 2]);
|
||||
expect(chunkPhotos(3, false, { any: new Set([2]), story: new Set() })).toEqual({ sizes: [2], parked: 1 });
|
||||
});
|
||||
|
||||
it('AUTOTPL-011: a cover template never leaves a frame empty, and with no photo only one drawn for none', () => {
|
||||
for (const archetype of BOOK_COVER_ARCHETYPES) {
|
||||
for (let n = 0; n <= 5; n++) {
|
||||
const pick = pickCoverTemplate({ archetype, photos: n, portrait: false, seed: 3 });
|
||||
if (pick) expect(templatePhotoSlots(pick)).toBeLessThanOrEqual(n);
|
||||
if (n >= 4) expect(pick, `${archetype} ${n}`).toBeDefined();
|
||||
}
|
||||
}
|
||||
expect(pickCoverTemplate({ archetype: 'postcard', photos: 0, portrait: false, seed: 1 })?.id).toBe(
|
||||
'postcard-coast',
|
||||
);
|
||||
expect(pickCoverTemplate({ archetype: 'riso', photos: 0, portrait: false, seed: 1 })).toBeUndefined();
|
||||
expect(pickCoverTemplate({ archetype: 'minimal-serif', photos: 1, portrait: true, seed: 1 })?.id).toBe(
|
||||
'minimal-serif-portrait',
|
||||
);
|
||||
});
|
||||
|
||||
it("AUTOTPL-012: a theme of the book's own takes the archetype of the theme it still sets the faces of", () => {
|
||||
expect(archetypeOfTheme({ ...bookThemeOf('midnight'), id: null })).toBe('night-film');
|
||||
const odd = bookThemeOf('riso');
|
||||
const own = { ...odd, id: null, fonts: { ...odd.fonts, display: { font: 'bodoni' }, heading: { font: 'bodoni' } } };
|
||||
expect(archetypeOfTheme(own)).toBe('spine-band');
|
||||
expect(archetypeOfTheme(null)).toBe('spine-band');
|
||||
expect(innerPool('riso').every((t) => ['riso', 'poster-pop', 'cutout'].includes(t.archetype))).toBe(true);
|
||||
expect([...chunkSizesOf(innerPool('riso')).story]).toContain(0);
|
||||
});
|
||||
|
||||
it("AUTOTPL-013: a page the book's own styles can frame every photo of stays in them, whatever the rhythm", () => {
|
||||
for (const archetype of BOOK_COVER_ARCHETYPES) {
|
||||
const kin = [archetype, ...ARCHETYPE_KIN[archetype]];
|
||||
for (let n = 0; n <= 12; n++) {
|
||||
for (const story of ['', STORY]) {
|
||||
const frames = (t: BookDecoratedTemplate) =>
|
||||
templatePhotoSlots(t) === n && (!story || roles(t).includes('body')) && !roles(t).includes('map');
|
||||
if (!innerPool(archetype).some(frames)) continue;
|
||||
const pick = pickInnerTemplate(chunk(n, story), ctxOf({ archetype, theme: null }));
|
||||
expect(pick && templatePhotoSlots(pick), `${archetype} ${n}`).toBe(n);
|
||||
expect(kin, `${archetype} ${n} ${story ? 'story' : ''}`).toContain(pick?.archetype);
|
||||
// The page before on its own is given up rather than a frame or the style.
|
||||
const again = pickInnerTemplate(chunk(n, story), ctxOf({ archetype, theme: null, history: [pick!.id] }));
|
||||
expect(again && templatePhotoSlots(again), `${archetype} ${n} again`).toBe(n);
|
||||
expect(kin).toContain(again?.archetype);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,403 @@
|
||||
import { isPortrait } from '../auto/auto-elements';
|
||||
import type { AutoPhoto } from '../auto/auto-types';
|
||||
import type { BookCoverArchetype } from '../book-archetypes';
|
||||
import { DESIGN_TEXT_HEADROOM, textHeightNeeded } from '../book-findings';
|
||||
import type { BookPageSetup, BookTextElement } from '../book.schema';
|
||||
import { layDecoratedTemplate } from '../templates/template-apply';
|
||||
import { decoratedTemplatesFor } from '../templates/template-catalogue';
|
||||
import { templatePhotoSlots, templateRoles, type BookDecoratedTemplate } from '../templates/template-format';
|
||||
import { themedElement } from '../theme/book-theme-apply';
|
||||
import type { BookTheme, BookThemeId } from '../theme/book-theme.schema';
|
||||
import { BOOK_THEME_PRESETS } from '../theme/book-themes.data';
|
||||
import { isLandscape } from './design-photos';
|
||||
import { designRng } from './design-random';
|
||||
|
||||
/**
|
||||
* Which decorated template a page of the auto layout is laid on.
|
||||
*
|
||||
* A book is in a theme, and a theme belongs to one of the twelve archetypes
|
||||
* the templates are drawn in (`THEME_ARCHETYPE`). Its pages are picked from
|
||||
* that archetype's templates and from those of the archetypes that sit well
|
||||
* beside it (`ARCHETYPE_KIN`), by what a page holds: how many photos, whether
|
||||
* a story has to fit, whether the stop can draw a map. Templates that would
|
||||
* leave an empty frame, lose the story or repeat the page before are never
|
||||
* candidates; of the rest the best is taken, the theme's own first, its
|
||||
* story fitting its body, its first frame the shape of the first photo, not
|
||||
* one used a few pages back, and calm where the book should breathe. Equal
|
||||
* ones are chosen by the design's seed, so the same journey and seed give the
|
||||
* same book and another seed another.
|
||||
*/
|
||||
|
||||
/** The archetype each built-in theme's pages are drawn in. */
|
||||
export const THEME_ARCHETYPE: Readonly<Record<BookThemeId, BookCoverArchetype>> = {
|
||||
'poster-pop': 'poster-pop',
|
||||
tropical: 'poster-pop',
|
||||
cutout: 'cutout',
|
||||
postcard: 'postcard',
|
||||
riviera: 'postcard',
|
||||
'fabric-classic': 'spine-band',
|
||||
alpine: 'spine-band',
|
||||
'minimal-serif': 'minimal-serif',
|
||||
nordic: 'minimal-serif',
|
||||
'grid-badge': 'grid-badge',
|
||||
'scrapbook-script': 'stamp-collage',
|
||||
'india-stamp': 'stamp-collage',
|
||||
riso: 'riso',
|
||||
magazine: 'editorial',
|
||||
'retro-travel': 'vintage-poster',
|
||||
midnight: 'night-film',
|
||||
'field-notes': 'field-notes',
|
||||
};
|
||||
|
||||
/** The archetypes whose spreads sit well in a book of another, nearest first. */
|
||||
export const ARCHETYPE_KIN: Readonly<Record<BookCoverArchetype, readonly BookCoverArchetype[]>> = {
|
||||
'spine-band': ['minimal-serif', 'postcard'],
|
||||
'poster-pop': ['cutout', 'riso', 'stamp-collage'],
|
||||
cutout: ['poster-pop', 'riso'],
|
||||
postcard: ['vintage-poster', 'spine-band'],
|
||||
'grid-badge': ['minimal-serif', 'stamp-collage'],
|
||||
'minimal-serif': ['editorial', 'spine-band'],
|
||||
'stamp-collage': ['field-notes', 'postcard'],
|
||||
riso: ['poster-pop', 'cutout'],
|
||||
editorial: ['minimal-serif', 'grid-badge'],
|
||||
'vintage-poster': ['postcard', 'stamp-collage'],
|
||||
'night-film': ['editorial', 'minimal-serif'],
|
||||
'field-notes': ['stamp-collage', 'spine-band'],
|
||||
};
|
||||
|
||||
/** The archetype a book without a recognisable theme takes. */
|
||||
const FALLBACK_ARCHETYPE: BookCoverArchetype = 'spine-band';
|
||||
|
||||
/**
|
||||
* The archetype of a book's theme: a built-in theme's own; a theme of the
|
||||
* book's own (no id) that of the built-in one whose faces it still sets,
|
||||
* else the calm bookish one.
|
||||
*/
|
||||
export function archetypeOfTheme(theme: BookTheme | null | undefined): BookCoverArchetype {
|
||||
if (!theme) return FALLBACK_ARCHETYPE;
|
||||
if (theme.id) return THEME_ARCHETYPE[theme.id];
|
||||
const kin = BOOK_THEME_PRESETS.find(
|
||||
(p) => p.fonts.display.font === theme.fonts.display.font && p.fonts.heading.font === theme.fonts.heading.font,
|
||||
);
|
||||
return kin ? THEME_ARCHETYPE[kin.id] : FALLBACK_ARCHETYPE;
|
||||
}
|
||||
|
||||
/** How loud a template is, from its tags: a loud poster, a lively collage, a graphic grid, a calm page. */
|
||||
type TemplateEnergy = 'loud' | 'lively' | 'medium' | 'calm';
|
||||
|
||||
/** A template's energy. Graphic pages, and the few drawn without a word for it, count as medium. */
|
||||
export function templateEnergy(tpl: Pick<BookDecoratedTemplate, 'tags'>): TemplateEnergy {
|
||||
if (tpl.tags.includes('loud')) return 'loud';
|
||||
if (tpl.tags.includes('lively')) return 'lively';
|
||||
if (tpl.tags.includes('calm') && !tpl.tags.includes('graphic')) return 'calm';
|
||||
return 'medium';
|
||||
}
|
||||
|
||||
const isLoud = (e: TemplateEnergy | undefined) => e === 'loud';
|
||||
const isBusy = (e: TemplateEnergy | undefined) => e === 'loud' || e === 'lively';
|
||||
|
||||
/** A page format and theme as a cache key: what a laid template's measures depend on. */
|
||||
const formatKey = (page: BookPageSetup, theme: BookTheme | null | undefined) =>
|
||||
`${page.pageWidth}x${page.pageHeight}|${theme?.fonts.body.font ?? '-'}|${theme?.fonts.body.weight ?? '-'}`;
|
||||
|
||||
/** Words of an ordinary length, to measure a body frame by. */
|
||||
const FILLER =
|
||||
'We left early and walked along the water while the town woke up, stopped for coffee, ' +
|
||||
'and found the road that runs up the hill to the old walls and the view over the bay. ';
|
||||
|
||||
const laidBodies = new Map<string, BookTextElement | null>();
|
||||
|
||||
/** The body text of a template as it is laid on this format in this theme, empty; null without one. */
|
||||
function laidBody(tpl: BookDecoratedTemplate, page: BookPageSetup, theme: BookTheme | null | undefined) {
|
||||
const key = `${tpl.id}|${formatKey(page, theme)}`;
|
||||
if (laidBodies.has(key)) return laidBodies.get(key) ?? null;
|
||||
let n = 0;
|
||||
const laid = layDecoratedTemplate(tpl, page, theme ?? null, (p) => `${p}${++n}`);
|
||||
const found = laid.find((l) => l.role === 'body' && l.el.kind === 'text')?.el;
|
||||
const body = found?.kind === 'text' ? (theme ? themedElement(found, theme) : found) : null;
|
||||
laidBodies.set(key, body);
|
||||
return body;
|
||||
}
|
||||
|
||||
/** Whether these words fit a laid body frame as it is set. */
|
||||
function fitsBody(body: BookTextElement, words: string): boolean {
|
||||
return textHeightNeeded({ ...body, text: words }, DESIGN_TEXT_HEADROOM) <= body.frame.h;
|
||||
}
|
||||
|
||||
const capacities = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* How many characters of ordinary running text a template's story frame
|
||||
* holds on this page format in this theme's body face: 0 for a template
|
||||
* without one. Measured once per template, format and face.
|
||||
*/
|
||||
export function bodyCapacity(tpl: BookDecoratedTemplate, page: BookPageSetup, theme?: BookTheme | null): number {
|
||||
const key = `${tpl.id}|${formatKey(page, theme)}`;
|
||||
const known = capacities.get(key);
|
||||
if (known !== undefined) return known;
|
||||
const body = laidBody(tpl, page, theme);
|
||||
let lo = 0;
|
||||
if (body) {
|
||||
let hi = 4000;
|
||||
while (lo < hi) {
|
||||
const mid = Math.ceil((lo + hi) / 2);
|
||||
if (fitsBody(body, FILLER.repeat(Math.ceil(mid / FILLER.length)).slice(0, mid))) lo = mid;
|
||||
else hi = mid - 1;
|
||||
}
|
||||
}
|
||||
capacities.set(key, lo);
|
||||
return lo;
|
||||
}
|
||||
|
||||
/** The parts a page of photos is cut into: how many photos each takes, the first one carrying the words. */
|
||||
interface PhotoChunking {
|
||||
sizes: number[];
|
||||
/** Photos that find no frame: noted and parked, never lost. */
|
||||
parked: number;
|
||||
}
|
||||
|
||||
/** What a chunking may use: the photo counts of the templates there are, those with a story frame apart. */
|
||||
export interface ChunkSizes {
|
||||
any: ReadonlySet<number>;
|
||||
story: ReadonlySet<number>;
|
||||
/**
|
||||
* What a page of this many photos costs beyond being a page: its story cut
|
||||
* short when it is the first, a template from outside the book's own pool.
|
||||
*/
|
||||
pageCost?: (size: number, first: boolean) => number;
|
||||
}
|
||||
|
||||
/** A spread costs this much, and every photo left without a frame more. */
|
||||
const SPREAD_COST = 10;
|
||||
const PARKED_COST = 15;
|
||||
/** The most spreads one entry's photos are cut into. */
|
||||
const MOST_CHUNKS = 3;
|
||||
|
||||
/**
|
||||
* An entry's photos cut into page-sized parts that leave no frame empty,
|
||||
* as few pages and as few photos without a frame as there can be: every
|
||||
* spread costs 10, every photo left over 15, and the first part, which
|
||||
* carries the story, is one a story template takes. Of two equal cuts the
|
||||
* more even one is kept, so twelve photos make six and six rather than ten
|
||||
* and two.
|
||||
*/
|
||||
export function chunkPhotos(n: number, hasStory: boolean, sizes: ChunkSizes): PhotoChunking {
|
||||
type Plan = { sizes: number[]; cost: number; parked: number };
|
||||
const firsts = [...(hasStory ? sizes.story : sizes.any)].filter((s) => s <= n);
|
||||
const rest = [...sizes.any].filter((s) => s > 0);
|
||||
let best: Plan = { sizes: [], cost: Infinity, parked: n };
|
||||
const better = (a: Plan, b: Plan) =>
|
||||
a.cost < b.cost ||
|
||||
(a.cost === b.cost && Math.max(...a.sizes) - Math.min(...a.sizes) < Math.max(...b.sizes) - Math.min(...b.sizes));
|
||||
const walk = (taken: number[], left: number, cost: number) => {
|
||||
const plan: Plan = { sizes: taken, cost: cost + left * PARKED_COST, parked: left };
|
||||
if (taken.length && (!best.sizes.length || better(plan, best))) best = plan;
|
||||
if (taken.length >= MOST_CHUNKS) return;
|
||||
const options = taken.length ? rest : firsts;
|
||||
for (const s of options) {
|
||||
if (s > left || (taken.length && s === 0)) continue;
|
||||
const extra = sizes.pageCost?.(s, !taken.length) ?? 0;
|
||||
walk([...taken, s], left - s, cost + SPREAD_COST + extra);
|
||||
}
|
||||
};
|
||||
walk([], n, 0);
|
||||
return best.sizes.length ? { sizes: best.sizes, parked: best.parked } : { sizes: [], parked: n };
|
||||
}
|
||||
|
||||
/** What a page to be laid holds, as the picker reads it. */
|
||||
export interface TemplateChunk {
|
||||
photos: readonly AutoPhoto[];
|
||||
/** The words a story frame must take; empty when the page sets none. */
|
||||
story: string;
|
||||
/** The stop or the journey has a place a map can draw. */
|
||||
mappable: boolean;
|
||||
}
|
||||
|
||||
/** What the picker knows of the book around the page. */
|
||||
export interface TemplatePickCtx {
|
||||
archetype: BookCoverArchetype;
|
||||
page: BookPageSetup;
|
||||
theme: BookTheme | null | undefined;
|
||||
/** Template ids of the spreads laid so far, in order. */
|
||||
history: readonly string[];
|
||||
/** The book's energy for template choice: a calm book keeps its busy pages few. */
|
||||
calm: boolean;
|
||||
seed: number;
|
||||
/** Names the decision for the seed: the same page of the same journey picks the same. */
|
||||
key: string;
|
||||
/** Ids never to take here (the page's own on "this spread", its neighbours'). */
|
||||
avoid?: readonly string[];
|
||||
}
|
||||
|
||||
const hasRole = (tpl: BookDecoratedTemplate, role: string) => (templateRoles(tpl) as readonly string[]).includes(role);
|
||||
|
||||
const slotsOf = new Map<string, number>();
|
||||
const slots = (tpl: BookDecoratedTemplate) => {
|
||||
const known = slotsOf.get(tpl.id);
|
||||
if (known !== undefined) return known;
|
||||
const n = templatePhotoSlots(tpl);
|
||||
slotsOf.set(tpl.id, n);
|
||||
return n;
|
||||
};
|
||||
|
||||
/** The inner templates of an archetype and of its kin, the own ones first. */
|
||||
export function innerPool(archetype: BookCoverArchetype): BookDecoratedTemplate[] {
|
||||
return [archetype, ...ARCHETYPE_KIN[archetype]].flatMap((a) => decoratedTemplatesFor('inner', a));
|
||||
}
|
||||
|
||||
/** The photo counts a pool of templates takes, and those of its templates with a story frame. */
|
||||
export function chunkSizesOf(pool: readonly BookDecoratedTemplate[]): { any: Set<number>; story: Set<number> } {
|
||||
return {
|
||||
any: new Set(pool.map(slots)),
|
||||
story: new Set(pool.filter((t) => hasRole(t, 'body')).map(slots)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether a template can take this page at all, after the one before it. */
|
||||
function admits(
|
||||
tpl: BookDecoratedTemplate,
|
||||
chunk: TemplateChunk,
|
||||
ctx: TemplatePickCtx,
|
||||
before: TemplateEnergy | undefined,
|
||||
) {
|
||||
return fitsAtAll(tpl, chunk, ctx) && !keepsApart(tpl, ctx, before);
|
||||
}
|
||||
|
||||
/** Whether a template can hold the page: no frame left empty, the story and the map where it has them. */
|
||||
function fitsAtAll(tpl: BookDecoratedTemplate, chunk: TemplateChunk, ctx: TemplatePickCtx) {
|
||||
if (tpl.kind !== 'inner' || slots(tpl) > chunk.photos.length || ctx.avoid?.includes(tpl.id)) return false;
|
||||
if (chunk.story && !hasRole(tpl, 'body')) return false;
|
||||
return !hasRole(tpl, 'map') || chunk.mappable;
|
||||
}
|
||||
|
||||
/** The rhythm a book keeps where it can: never the page before again, never loud after loud or lively. */
|
||||
function keepsApart(tpl: BookDecoratedTemplate, ctx: TemplatePickCtx, before: TemplateEnergy | undefined) {
|
||||
return ctx.history[ctx.history.length - 1] === tpl.id || (isLoud(templateEnergy(tpl)) && isBusy(before));
|
||||
}
|
||||
|
||||
/** The orientation of a template's first frame on this format: wide, tall or square. */
|
||||
function firstFrameShape(tpl: BookDecoratedTemplate, page: BookPageSetup): 'wide' | 'tall' | 'square' {
|
||||
const el = tpl.elements.find((e) => e.role === 'photo:1');
|
||||
if (!el) return 'square';
|
||||
const across = el.frame.w * page.pageWidth;
|
||||
const down = el.frame.h * page.pageHeight;
|
||||
if (across >= down * 1.2) return 'wide';
|
||||
return down >= across * 1.1 ? 'tall' : 'square';
|
||||
}
|
||||
|
||||
/** Points for the story: it fits the frame, or it loses in proportion to how far it runs over. */
|
||||
function storyScore(tpl: BookDecoratedTemplate, chunk: TemplateChunk, ctx: TemplatePickCtx): number {
|
||||
if (!chunk.story) return 0;
|
||||
const room = bodyCapacity(tpl, ctx.page, ctx.theme);
|
||||
if (room >= chunk.story.length) return 10;
|
||||
return -Math.min(40, (20 * (chunk.story.length - room)) / Math.max(room, 1));
|
||||
}
|
||||
|
||||
/** What a photo left without a frame costs a template. */
|
||||
const UNUSED_PHOTO = 60;
|
||||
/** What a template of an archetype neither the book's nor its kin costs, where only such can take the page. */
|
||||
const FOREIGN = 25;
|
||||
|
||||
/** How well a template suits the page, the higher the better. */
|
||||
function scoreOf(
|
||||
tpl: BookDecoratedTemplate,
|
||||
chunk: TemplateChunk,
|
||||
ctx: TemplatePickCtx,
|
||||
before: TemplateEnergy | undefined,
|
||||
) {
|
||||
// A photo the page has no frame for waits in the tray.
|
||||
let score = 100 - UNUSED_PHOTO * (chunk.photos.length - slots(tpl));
|
||||
if (tpl.archetype === ctx.archetype) score += 20;
|
||||
else if (ARCHETYPE_KIN[ctx.archetype].includes(tpl.archetype)) score += 8;
|
||||
else score -= FOREIGN;
|
||||
const first = chunk.photos[0];
|
||||
const shape = firstFrameShape(tpl, ctx.page);
|
||||
if (first && ((shape === 'wide' && isLandscape(first)) || (shape === 'tall' && isPortrait(first)))) score += 6;
|
||||
score += storyScore(tpl, chunk, ctx);
|
||||
const recent = ctx.history.slice(-3);
|
||||
if (recent.includes(tpl.id)) score -= 40;
|
||||
score -= 8 * ctx.history.slice(0, -3).filter((id) => id === tpl.id).length;
|
||||
const energy = templateEnergy(tpl);
|
||||
if ((ctx.calm || isBusy(before)) && isBusy(energy)) score -= 30;
|
||||
return score;
|
||||
}
|
||||
|
||||
/** Scores this close count as equal, and the seed chooses among them. */
|
||||
const TIE = 5;
|
||||
|
||||
/** The best of scored templates, equals chosen by the seed. */
|
||||
function bestOf(scored: { tpl: BookDecoratedTemplate; score: number }[], seed: number, key: string) {
|
||||
if (!scored.length) return undefined;
|
||||
const top = Math.max(...scored.map((s) => s.score));
|
||||
const near = scored.filter((s) => s.score >= top - TIE).map((s) => s.tpl);
|
||||
return designRng(seed, key).pick(near);
|
||||
}
|
||||
|
||||
/** The energy of the template a page was laid on, by its id. */
|
||||
function energyOfId(pool: readonly BookDecoratedTemplate[], id: string | undefined): TemplateEnergy | undefined {
|
||||
const tpl = id ? pool.find((t) => t.id === id) : undefined;
|
||||
return tpl ? templateEnergy(tpl) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The template a page is laid on. A frame for every photo comes first, then
|
||||
* the book's own styles (its archetype and its kin), then the rhythm:
|
||||
*
|
||||
* 1. the own styles, every photo framed, the rhythm kept;
|
||||
* 2. the own styles, every photo framed, the rhythm given up for this page;
|
||||
* 3. another archetype, every photo framed, the rhythm kept;
|
||||
* 4. the own styles with a photo left to wait in the tray;
|
||||
* 5. whatever can take the page at all.
|
||||
*
|
||||
* Undefined when nothing can.
|
||||
*/
|
||||
export function pickInnerTemplate(chunk: TemplateChunk, ctx: TemplatePickCtx): BookDecoratedTemplate | undefined {
|
||||
const all = decoratedTemplatesFor('inner');
|
||||
const own = innerPool(ctx.archetype);
|
||||
const before = energyOfId(all, ctx.history[ctx.history.length - 1]);
|
||||
const full = (t: BookDecoratedTemplate) => slots(t) === chunk.photos.length;
|
||||
const kept = (t: BookDecoratedTemplate) => admits(t, chunk, ctx, before);
|
||||
const held = (t: BookDecoratedTemplate) => fitsAtAll(t, chunk, ctx);
|
||||
const tiers: [readonly BookDecoratedTemplate[], (t: BookDecoratedTemplate) => boolean][] = [
|
||||
[own, (t) => full(t) && kept(t)],
|
||||
[own, (t) => full(t) && held(t)],
|
||||
[all, (t) => full(t) && kept(t)],
|
||||
[own, kept],
|
||||
[all, held],
|
||||
];
|
||||
for (const [pool, takes] of tiers) {
|
||||
const scored = pool.filter(takes).map((tpl) => ({ tpl, score: scoreOf(tpl, chunk, ctx, before) }));
|
||||
if (scored.length) return bestOf(scored, ctx.seed, ctx.key);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** What a cover may be laid with: how many photos there are, and whether the cover photo stands tall. */
|
||||
interface CoverPick {
|
||||
archetype: BookCoverArchetype;
|
||||
photos: number;
|
||||
portrait: boolean;
|
||||
seed: number;
|
||||
/** Ids never to take (the cover on screen, on "this spread"). */
|
||||
avoid?: readonly string[];
|
||||
}
|
||||
|
||||
const PORTRAIT_COVER = /-portrait$|^cutout-/;
|
||||
|
||||
/**
|
||||
* The cover template of an archetype for the photos there are: one that
|
||||
* leaves no frame empty and fills the most of them, a tall one for a tall
|
||||
* cover photo, equals chosen by the seed. Undefined when the archetype has
|
||||
* none to take them, for the composed cover to stand in.
|
||||
*/
|
||||
export function pickCoverTemplate(p: CoverPick): BookDecoratedTemplate | undefined {
|
||||
const scored = decoratedTemplatesFor('single', p.archetype)
|
||||
.filter((t) => slots(t) <= p.photos && !p.avoid?.includes(t.id))
|
||||
// Without a photo only a cover drawn for none stands; a photo frame left empty does not.
|
||||
.filter((t) => p.photos > 0 || slots(t) === 0)
|
||||
.map((tpl) => {
|
||||
const tall = PORTRAIT_COVER.test(tpl.id);
|
||||
return { tpl, score: slots(tpl) * 10 + (tall === p.portrait ? 8 : 0) };
|
||||
});
|
||||
return bestOf(scored, p.seed, `cover:${p.archetype}`);
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
import { summaryMetrics } from '../auto/auto-book-spreads';
|
||||
import { isPortrait } from '../auto/auto-elements';
|
||||
import type { AutoEntry } from '../auto/auto-types';
|
||||
import type { BookCoverArchetype } from '../book-archetypes';
|
||||
import {
|
||||
MAX_BOOK_COUNTRIES,
|
||||
type BookCountryLook,
|
||||
type BookElement,
|
||||
type BookFrame,
|
||||
type BookSpread,
|
||||
type BookStatsLook,
|
||||
} from '../book.schema';
|
||||
import type { JourneyStats } from '../journey-stats.schema';
|
||||
import { applyDecoratedTemplate } from '../templates/template-apply';
|
||||
import type { BookDecoratedTemplate } from '../templates/template-format';
|
||||
import { bookTemplateWords, type BookTemplateWords } from '../templates/template-words';
|
||||
import { themedElement } from '../theme/book-theme-apply';
|
||||
import { bookTravelLooks, BOOK_TRAVEL_COUNTRY_ACCENT, type BookTravelLooks } from '../travel/book-travel-looks';
|
||||
import { frameOf, liveBox, photoEl, scaled, textEl, type ComposeCtx } from './design-compose';
|
||||
import { composeArchetypeCover } from './design-cover';
|
||||
import type { CoverMaterial } from './design-cover-kit';
|
||||
import { titleWords, type CoverWords } from './design-cover-words';
|
||||
import { composeBack } from './design-framing';
|
||||
import { innerSpread } from './design-spreads';
|
||||
import { pickCoverTemplate } from './design-template-pick';
|
||||
|
||||
/**
|
||||
* The pages around the chapters when the book is laid on the designed
|
||||
* templates: a cover laid on one of the archetype's cover templates, and the
|
||||
* journey at a glance, its countries and the back cover built from the
|
||||
* travel looks the Travel panel offers (a summary as a poster, tiles or a
|
||||
* sign, countries as stamps, visas or labels, marks as stamps and tags).
|
||||
* Nothing here sets a summary, a country or a mark in the classic look.
|
||||
*/
|
||||
|
||||
/** The travel looks of this book, an element of a size centred on the right page unless a frame is named. */
|
||||
function looksOf(c: ComposeCtx): BookTravelLooks | null {
|
||||
const stats = c.travel.stats;
|
||||
if (!stats) return null;
|
||||
const box = liveBox(c, 'right');
|
||||
const place = (size: { w: number; h: number }) =>
|
||||
frameOf(box.x + (box.w - size.w) / 2, box.y + (box.h - size.h) / 2, size.w, size.h);
|
||||
return bookTravelLooks({ page: c.page, stats, travel: c.travel, theme: c.look.theme, newId: c.newId, place });
|
||||
}
|
||||
|
||||
/** A size scaled down to fit a box, its proportions kept, centred in it. */
|
||||
function fitIn(size: { w: number; h: number }, box: BookFrame): BookFrame {
|
||||
const k = Math.min(1, box.w / size.w, box.h / size.h);
|
||||
const w = size.w * k;
|
||||
const h = size.h * k;
|
||||
return frameOf(box.x + (box.w - w) / 2, box.y + (box.h - h) / 2, w, h);
|
||||
}
|
||||
|
||||
// ── the cover ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The cover's own words and pictures as a page, for a template to pour:
|
||||
* the title bound to the journey's (unless the person gave their own), the
|
||||
* subtitle bound the same way, the year it was travelled, and the photos.
|
||||
* Poured from a page, the title keeps its binding, which the template's
|
||||
* words for a cover would not give it.
|
||||
*/
|
||||
function coverSeed(c: ComposeCtx, words: CoverWords, material: CoverMaterial, slots: number): BookSpread {
|
||||
const box = liveBox(c, 'single');
|
||||
const els: BookElement[] = [];
|
||||
const title = titleWords(words.ownTitle, words.title);
|
||||
if (title.words.trim()) {
|
||||
const binding = title.bound ? { source: 'journey.title' as const } : null;
|
||||
els.push(textEl(c, box, title.words, 'heading', { binding, size: c.look.sizes.display }));
|
||||
}
|
||||
const sub = titleWords(words.ownSubtitle, words.subtitle ?? '');
|
||||
if (sub.words.trim()) {
|
||||
const binding = sub.bound ? { source: 'journey.subtitle' as const } : null;
|
||||
els.push(textEl(c, box, sub.words, 'sub', { binding }));
|
||||
}
|
||||
const year = material.entries.find((e) => e.date && /^\d{4}/.test(e.date))?.date?.slice(0, 4);
|
||||
if (year) els.push(textEl(c, box, year, 'meta'));
|
||||
els.push(...material.photos.slice(0, slots).map((p) => photoEl(c, p, box)));
|
||||
return { id: c.newId('sp'), role: 'cover', background: null, elements: els, parked: [], entryId: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* A page laid on a decorated template in the book's theme, its empty roles
|
||||
* taking the journey's words, or the words `given` for a role by name.
|
||||
*/
|
||||
export function pourTemplate(
|
||||
c: ComposeCtx,
|
||||
seed: BookSpread,
|
||||
tpl: BookDecoratedTemplate,
|
||||
entries: readonly AutoEntry[],
|
||||
given: BookTemplateWords = {},
|
||||
) {
|
||||
const words = { ...bookTemplateWords(seed, { bookTitle: '', locale: c.locale, entries }), ...given };
|
||||
return applyDecoratedTemplate(seed, tpl, c.page, {
|
||||
theme: c.look.theme,
|
||||
travel: c.travel,
|
||||
locale: c.locale,
|
||||
newId: c.newId,
|
||||
words,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The cover on one of the archetype's cover templates: one that leaves no
|
||||
* frame empty and fills the most, a tall one for a tall cover photo. An
|
||||
* archetype with none for the photos there are (none at all, or fewer than
|
||||
* its grid takes) has its cover composed instead (design-cover.ts).
|
||||
*/
|
||||
export function composeTemplatedCover(
|
||||
c: ComposeCtx,
|
||||
words: CoverWords,
|
||||
material: CoverMaterial,
|
||||
archetype: BookCoverArchetype,
|
||||
avoid: readonly string[] = [],
|
||||
): { spread: BookSpread; template: string | null } {
|
||||
const first = material.photos[0];
|
||||
const seed = c.directives.seed ?? 0;
|
||||
const tpl = pickCoverTemplate({
|
||||
archetype,
|
||||
photos: material.photos.length,
|
||||
portrait: !!first && isPortrait(first),
|
||||
seed,
|
||||
avoid,
|
||||
});
|
||||
if (!tpl) return { spread: composeArchetypeCover(c, archetype, words, material), template: null };
|
||||
return { spread: coverOnTemplate(c, words, material, tpl), template: tpl.id };
|
||||
}
|
||||
|
||||
/** The cover laid on this cover template, its title and subtitle bound to the journey's. */
|
||||
export function coverOnTemplate(
|
||||
c: ComposeCtx,
|
||||
words: CoverWords,
|
||||
material: CoverMaterial,
|
||||
tpl: BookDecoratedTemplate,
|
||||
): BookSpread {
|
||||
const laid = pourTemplate(c, coverSeed(c, words, material, material.photos.length), tpl, material.entries);
|
||||
// The photos the cover has no frame for are the book's own pages', not the cover's to park.
|
||||
return { ...laid, parked: [] };
|
||||
}
|
||||
|
||||
// ── the journey at a glance ───────────────────────────────────────────────
|
||||
|
||||
/** The summary's look for an archetype: tiles for the collage and grid books, a sign for the postcard ones. */
|
||||
export function summaryLookFor(archetype: BookCoverArchetype): BookStatsLook {
|
||||
if (archetype === 'stamp-collage' || archetype === 'grid-badge') return 'tiles';
|
||||
return archetype === 'postcard' ? 'sign' : 'poster';
|
||||
}
|
||||
|
||||
/** The summary looks "this spread" goes through, in turn. */
|
||||
export const SUMMARY_CYCLE: readonly BookStatsLook[] = ['poster', 'tiles', 'sign'];
|
||||
|
||||
/** Whether the journey adds up to a summary worth a spread: a way of two stops at least. */
|
||||
const hasSummary = (stats: JourneyStats | null): stats is JourneyStats => !!stats && stats.points.length >= 2;
|
||||
|
||||
/**
|
||||
* The journey at a glance: its map with a photo at every stop on the left
|
||||
* page; on the right the figures in the look's panel, a ticket stub of how
|
||||
* far and how long below it, and the furthest point on an odometer when the
|
||||
* journey went anywhere. Null when there is nothing to sum up.
|
||||
*/
|
||||
export function composeTravelSummary(c: ComposeCtx, look: BookStatsLook): BookSpread | null {
|
||||
const stats = c.travel.stats;
|
||||
const looks = looksOf(c);
|
||||
if (!hasSummary(stats) || !looks) return null;
|
||||
const els: BookElement[] = [looks.mapEl({ style: 'minimal' }, 'journey', 'vector', liveBox(c, 'left'))];
|
||||
const box = liveBox(c, 'right');
|
||||
const parts: { size: { w: number; h: number }; make: (f: BookFrame) => BookElement }[] = [];
|
||||
const metrics = summaryMetrics(stats);
|
||||
const main = looks.summarySize(look);
|
||||
parts.push({ size: { w: box.w, h: (box.w * main.h) / main.w }, make: (f) => looks.summaryEl(look, f, metrics) });
|
||||
const stub = looks.summarySize('ticket');
|
||||
parts.push({
|
||||
size: { w: box.w * 0.72, h: (box.w * 0.72 * stub.h) / stub.w },
|
||||
make: (f) => looks.summaryEl('ticket', f, ['distance', 'days']),
|
||||
});
|
||||
if (stats.furthest > 0) {
|
||||
const drums = looks.figureSize('odometer');
|
||||
parts.push({
|
||||
size: { w: box.w * 0.5, h: (box.w * 0.5 * drums.h) / drums.w },
|
||||
make: (f) => looks.figureEl('furthest', 'odometer', f),
|
||||
});
|
||||
}
|
||||
els.push(...stacked(c, parts, box));
|
||||
return innerSpread(c, els, null);
|
||||
}
|
||||
|
||||
/** Parts stacked down a box with a gap between, all scaled alike to fit it, each centred across. */
|
||||
function stacked(
|
||||
c: ComposeCtx,
|
||||
parts: readonly { size: { w: number; h: number }; make: (f: BookFrame) => BookElement }[],
|
||||
box: BookFrame,
|
||||
): BookElement[] {
|
||||
const gap = scaled(c, 8);
|
||||
const tall = parts.reduce((h, p) => h + p.size.h, 0);
|
||||
const k = Math.min(1, (box.h - gap * (parts.length - 1)) / tall);
|
||||
let y = box.y + (box.h - (tall * k + gap * (parts.length - 1))) / 2;
|
||||
return parts.map((p) => {
|
||||
const w = p.size.w * k;
|
||||
const h = p.size.h * k;
|
||||
const el = p.make(frameOf(box.x + (box.w - w) / 2, y, w, h));
|
||||
y += h + gap;
|
||||
return el;
|
||||
});
|
||||
}
|
||||
|
||||
// ── the countries ─────────────────────────────────────────────────────────
|
||||
|
||||
/** The country look for an archetype: stamps for the collage books, postcards, labels for the quiet ones, else visas. */
|
||||
export function countryLookFor(archetype: BookCoverArchetype): BookCountryLook {
|
||||
if (archetype === 'stamp-collage') return 'stamp';
|
||||
if (archetype === 'postcard') return 'postcard';
|
||||
return archetype === 'spine-band' || archetype === 'minimal-serif' ? 'label' : 'visa';
|
||||
}
|
||||
|
||||
/** The country looks "this spread" goes through, in turn. */
|
||||
export const COUNTRY_CYCLE: readonly BookCountryLook[] = ['stamp', 'visa', 'label', 'postcard'];
|
||||
|
||||
/** Past this many countries one panel of stamps holds them all, rather than a stamp each. */
|
||||
const MOST_COUNTRY_CARDS = 12;
|
||||
|
||||
/** A country in its look, its accent the look's or the theme's first by turns, so a grid is not one colour. */
|
||||
function countryCard(
|
||||
c: ComposeCtx,
|
||||
looks: BookTravelLooks,
|
||||
code: string,
|
||||
look: BookCountryLook,
|
||||
at: number,
|
||||
frame: BookFrame,
|
||||
) {
|
||||
const el = looks.countryEl(code, look, frame);
|
||||
const own = BOOK_TRAVEL_COUNTRY_ACCENT[look] ?? 'accent';
|
||||
if (at % 2 === 0 || own === 'accent') return el;
|
||||
return themedElement({ ...el, accentToken: 'accent' } as BookElement, c.look.theme);
|
||||
}
|
||||
|
||||
/** The cells of a grid of `n` on one page's live box, at most three across. */
|
||||
function grid(box: BookFrame, n: number, gap: number): BookFrame[] {
|
||||
const cols = Math.min(3, n);
|
||||
const rows = Math.ceil(n / cols);
|
||||
const w = (box.w - gap * (cols - 1)) / cols;
|
||||
const h = Math.min(w, (box.h - gap * (rows - 1)) / rows);
|
||||
const top = box.y + (box.h - (h * rows + gap * (rows - 1))) / 2;
|
||||
return Array.from({ length: n }, (_, i) =>
|
||||
frameOf(box.x + (i % cols) * (w + gap), top + Math.floor(i / cols) * (h + gap), w, h),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The countries of the journey: one country as its map cut to its shape
|
||||
* beside its stamp; up to twelve as a card each over both pages; more as one
|
||||
* panel of stamps. Null for a journey that crossed no border it knows of.
|
||||
*/
|
||||
export function composeTravelCountries(c: ComposeCtx, look: BookCountryLook): BookSpread | null {
|
||||
const stats = c.travel.stats;
|
||||
const looks = looksOf(c);
|
||||
const codes = stats?.countries.map((k) => k.code) ?? [];
|
||||
if (!looks || !codes.length) return null;
|
||||
const card = looks.countrySize(look);
|
||||
const [only] = codes;
|
||||
if (codes.length === 1 && only) {
|
||||
const els = [
|
||||
looks.countryMapEl(only, 'shape', liveBox(c, 'left')),
|
||||
countryCard(c, looks, only, look, 0, fitIn({ w: card.w * 1.4, h: card.h * 1.4 }, liveBox(c, 'right'))),
|
||||
];
|
||||
return innerSpread(c, els, null);
|
||||
}
|
||||
if (codes.length > MOST_COUNTRY_CARDS) {
|
||||
// One panel on the right page, every country a stamp in it, beside the journey's map.
|
||||
const map = looks.mapEl({ style: 'minimal', pins: 'numbered' }, 'journey', 'vector', liveBox(c, 'left'));
|
||||
return innerSpread(
|
||||
c,
|
||||
[map, withCodes(looks.countryEl(only ?? '', 'stamp', liveBox(c, 'right')), looks, codes)],
|
||||
null,
|
||||
);
|
||||
}
|
||||
const left = Math.ceil(codes.length / 2);
|
||||
const gap = scaled(c, 6);
|
||||
const cells = [...grid(liveBox(c, 'left'), left, gap), ...grid(liveBox(c, 'right'), codes.length - left, gap)];
|
||||
const els = codes.map((code, i) => countryCard(c, looks, code, look, i, fitIn(card, cells[i] as BookFrame)));
|
||||
return innerSpread(c, els, null);
|
||||
}
|
||||
|
||||
/** A countries panel holding every one of the journey's countries, named in the book's language. */
|
||||
function withCodes(el: BookElement, looks: BookTravelLooks, codes: readonly string[]): BookElement {
|
||||
if (el.kind !== 'countries') return el;
|
||||
const all = codes.slice(0, MAX_BOOK_COUNTRIES);
|
||||
return { ...el, codes: all.map((k) => k.toUpperCase()), names: all.map((k) => looks.countryName(k)), layout: 'grid' };
|
||||
}
|
||||
|
||||
// ── the back cover ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The back cover: the title and when it was, as the composed back sets
|
||||
* them, then a band of how long, how many places and how many photos, and a
|
||||
* row of up to four country stamps. One page: everything stays inside its
|
||||
* live area.
|
||||
*/
|
||||
export function composeTravelBack(c: ComposeCtx, words: CoverWords, entries: readonly AutoEntry[]): BookSpread {
|
||||
const back = composeBack(c, words, entries);
|
||||
const looks = looksOf(c);
|
||||
const stats = c.travel.stats;
|
||||
if (!looks || !stats) return back;
|
||||
const box = liveBox(c, 'single');
|
||||
const els: BookElement[] = [...back.elements];
|
||||
const bandH = Math.min(c.page.pageHeight * 0.13, box.h * 0.16);
|
||||
els.push(looks.summaryEl('band', frameOf(box.x, box.y + box.h * 0.66, box.w, bandH), ['days', 'places', 'photos']));
|
||||
const codes = stats.countries.slice(0, 4).map((k) => k.code);
|
||||
if (codes.length) {
|
||||
const gap = scaled(c, 4);
|
||||
const side = Math.min((box.w - gap * 3) / 4, box.y + box.h - (box.y + box.h * 0.66 + bandH + gap));
|
||||
const row = codes.length * side + gap * (codes.length - 1);
|
||||
const y = box.y + box.h - side;
|
||||
codes.forEach((code, i) => {
|
||||
els.push(looks.countryEl(code, 'stamp', frameOf(box.x + (box.w - row) / 2 + i * (side + gap), y, side, side)));
|
||||
});
|
||||
}
|
||||
return { ...back, elements: els };
|
||||
}
|
||||
|
||||
// ── a page of words ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A page of an entry's words where no template takes them (a story with no
|
||||
* photo, or the rest of one that ran over its template's frame): the story
|
||||
* in a column on the left page, and on the right the stop's map with its
|
||||
* stops numbered, its day as a stamp and its country as a tag.
|
||||
*/
|
||||
export function composeWordsPage(c: ComposeCtx, entry: AutoEntry, story: BookElement | null): BookSpread {
|
||||
const looks = looksOf(c);
|
||||
const els: BookElement[] = story ? [story] : [];
|
||||
const right = liveBox(c, 'right');
|
||||
if (looks && c.travel.stats && c.travel.stats.points.length) {
|
||||
const map = frameOf(right.x, right.y, right.w, right.h * 0.62);
|
||||
els.push(looks.mapEl({ style: 'minimal', pins: 'numbered' }, 'journey', 'vector', map));
|
||||
const marks = [looks.markEl('day', 'stamp', { entry }), looks.markEl('flag', 'tag', { entry })];
|
||||
let x = right.x;
|
||||
for (const mark of marks) {
|
||||
if (!mark) continue;
|
||||
const { w = 0, h = 0 } = mark.frame;
|
||||
const frame = fitIn({ w, h }, frameOf(x, right.y + right.h * 0.7, right.w / 2 - scaled(c, 4), right.h * 0.3));
|
||||
els.push({ ...mark, frame: frameOf(x, frame.y, frame.w, frame.h) });
|
||||
x += frame.w + scaled(c, 8);
|
||||
}
|
||||
}
|
||||
return innerSpread(c, els, entry.id);
|
||||
}
|
||||
@@ -0,0 +1,717 @@
|
||||
{
|
||||
"D": {
|
||||
"input": {
|
||||
"locale": "en-GB",
|
||||
"title": "North by the coast",
|
||||
"subtitle": null,
|
||||
"coverPhotoId": null,
|
||||
"entries": [
|
||||
{
|
||||
"id": 101,
|
||||
"title": "Stop 1",
|
||||
"story": "We walked out along the harbour wall before the light came up, and the town was still asleep behind us.",
|
||||
"location": "Bergen",
|
||||
"date": "2026-06-01",
|
||||
"photos": [],
|
||||
"lat": 60,
|
||||
"lng": 5,
|
||||
"country": "NO",
|
||||
"dayNumber": 1,
|
||||
"dayCount": 8
|
||||
},
|
||||
{
|
||||
"id": 102,
|
||||
"title": "Stop 2",
|
||||
"story": null,
|
||||
"location": "Flam",
|
||||
"date": "2026-06-02",
|
||||
"photos": [
|
||||
{
|
||||
"photoId": 500,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
}
|
||||
],
|
||||
"lat": 61,
|
||||
"lng": 6,
|
||||
"country": "NO",
|
||||
"dayNumber": 2,
|
||||
"dayCount": 8
|
||||
},
|
||||
{
|
||||
"id": 103,
|
||||
"title": "Stop 3",
|
||||
"story": "We walked out along the harbour wall before the light came up, and the town was still asleep behind us. We walked out along the harbour wall before the light came up, and the town was still asleep behind us. We walked out along the harbour wall before the light came up, and the town was still asleep behind us.",
|
||||
"location": "Alesund",
|
||||
"date": "2026-06-03",
|
||||
"photos": [
|
||||
{
|
||||
"photoId": 501,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 502,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 503,
|
||||
"width": 3000,
|
||||
"height": 4000,
|
||||
"caption": null
|
||||
}
|
||||
],
|
||||
"lat": 62,
|
||||
"lng": 7,
|
||||
"country": "NO",
|
||||
"dayNumber": 3,
|
||||
"dayCount": 8
|
||||
},
|
||||
{
|
||||
"id": 104,
|
||||
"title": "Stop 4",
|
||||
"story": null,
|
||||
"location": "Geiranger",
|
||||
"date": "2026-06-04",
|
||||
"photos": [
|
||||
{
|
||||
"photoId": 504,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 505,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 506,
|
||||
"width": 3000,
|
||||
"height": 4000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 507,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 508,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
}
|
||||
],
|
||||
"lat": 63,
|
||||
"lng": 8,
|
||||
"country": "NO",
|
||||
"dayNumber": 4,
|
||||
"dayCount": 8
|
||||
},
|
||||
{
|
||||
"id": 105,
|
||||
"title": "Stop 5",
|
||||
"story": "We walked out along the harbour wall before the light came up, and the town was still asleep behind us.",
|
||||
"location": "Trondheim",
|
||||
"date": "2026-06-05",
|
||||
"photos": [
|
||||
{
|
||||
"photoId": 509,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 510,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 511,
|
||||
"width": 3000,
|
||||
"height": 4000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 512,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 513,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 514,
|
||||
"width": 3000,
|
||||
"height": 4000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 515,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 516,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 517,
|
||||
"width": 3000,
|
||||
"height": 4000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 518,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 519,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 520,
|
||||
"width": 3000,
|
||||
"height": 4000,
|
||||
"caption": null
|
||||
}
|
||||
],
|
||||
"lat": 64,
|
||||
"lng": 9,
|
||||
"country": "NO",
|
||||
"dayNumber": 5,
|
||||
"dayCount": 8
|
||||
},
|
||||
{
|
||||
"id": 106,
|
||||
"title": "Stop 6",
|
||||
"story": null,
|
||||
"location": "Bodo",
|
||||
"date": "2026-06-06",
|
||||
"photos": [
|
||||
{
|
||||
"photoId": 521,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 522,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 523,
|
||||
"width": 3000,
|
||||
"height": 4000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 524,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 525,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 526,
|
||||
"width": 3000,
|
||||
"height": 4000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 527,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 528,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 529,
|
||||
"width": 3000,
|
||||
"height": 4000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 530,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 531,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 532,
|
||||
"width": 3000,
|
||||
"height": 4000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 533,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 534,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 535,
|
||||
"width": 3000,
|
||||
"height": 4000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 536,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 537,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 538,
|
||||
"width": 3000,
|
||||
"height": 4000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 539,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 540,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
}
|
||||
],
|
||||
"lat": 65,
|
||||
"lng": 10,
|
||||
"country": "NO",
|
||||
"dayNumber": 6,
|
||||
"dayCount": 8
|
||||
},
|
||||
{
|
||||
"id": 107,
|
||||
"title": "Stop 7",
|
||||
"story": null,
|
||||
"location": "Tromso",
|
||||
"date": "2026-06-07",
|
||||
"photos": [],
|
||||
"lat": 66,
|
||||
"lng": 11,
|
||||
"country": "NO",
|
||||
"dayNumber": 7,
|
||||
"dayCount": 8
|
||||
},
|
||||
{
|
||||
"id": 108,
|
||||
"title": "Stop 8",
|
||||
"story": "We walked out along the harbour wall before the light came up, and the town was still asleep behind us. We walked out along the harbour wall before the light came up, and the town was still asleep behind us. We walked out along the harbour wall before the light came up, and the town was still asleep behind us. We walked out along the harbour wall before the light came up, and the town was still asleep behind us.",
|
||||
"location": "Alta",
|
||||
"date": "2026-06-08",
|
||||
"photos": [
|
||||
{
|
||||
"photoId": 541,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
},
|
||||
{
|
||||
"photoId": 542,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null
|
||||
}
|
||||
],
|
||||
"lat": 67,
|
||||
"lng": 12,
|
||||
"country": "NO",
|
||||
"dayNumber": 8,
|
||||
"dayCount": 8
|
||||
}
|
||||
],
|
||||
"page": {
|
||||
"preset": "square-210",
|
||||
"pageWidth": 210,
|
||||
"pageHeight": 210,
|
||||
"bleed": 3,
|
||||
"safe": 5,
|
||||
"pageNumbers": {
|
||||
"show": false,
|
||||
"startAt": 2,
|
||||
"position": "outer",
|
||||
"margin": 12,
|
||||
"size": 8,
|
||||
"autoColor": true,
|
||||
"color": "#8a8578",
|
||||
"font": "sans"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"journeyId": 9,
|
||||
"distance": 2100000,
|
||||
"days": 8,
|
||||
"steps": 20,
|
||||
"photos": 80,
|
||||
"places": 8,
|
||||
"furthest": 1500000,
|
||||
"countries": [
|
||||
{
|
||||
"code": "NO",
|
||||
"name": "Norway",
|
||||
"places": 8,
|
||||
"firstVisit": "2026-06-01"
|
||||
}
|
||||
],
|
||||
"points": [
|
||||
{
|
||||
"lat": 60,
|
||||
"lng": 5,
|
||||
"label": "Bergen",
|
||||
"date": "2026-06-01",
|
||||
"country": "NO",
|
||||
"tripId": null,
|
||||
"photoId": null,
|
||||
"entryId": 101
|
||||
},
|
||||
{
|
||||
"lat": 61,
|
||||
"lng": 6,
|
||||
"label": "Flam",
|
||||
"date": "2026-06-02",
|
||||
"country": "NO",
|
||||
"tripId": null,
|
||||
"photoId": null,
|
||||
"entryId": 102
|
||||
},
|
||||
{
|
||||
"lat": 62,
|
||||
"lng": 7,
|
||||
"label": "Alesund",
|
||||
"date": "2026-06-03",
|
||||
"country": "NO",
|
||||
"tripId": null,
|
||||
"photoId": null,
|
||||
"entryId": 103
|
||||
},
|
||||
{
|
||||
"lat": 63,
|
||||
"lng": 8,
|
||||
"label": "Geiranger",
|
||||
"date": "2026-06-04",
|
||||
"country": "NO",
|
||||
"tripId": null,
|
||||
"photoId": null,
|
||||
"entryId": 104
|
||||
},
|
||||
{
|
||||
"lat": 64,
|
||||
"lng": 9,
|
||||
"label": "Trondheim",
|
||||
"date": "2026-06-05",
|
||||
"country": "NO",
|
||||
"tripId": null,
|
||||
"photoId": null,
|
||||
"entryId": 105
|
||||
},
|
||||
{
|
||||
"lat": 65,
|
||||
"lng": 10,
|
||||
"label": "Bodo",
|
||||
"date": "2026-06-06",
|
||||
"country": "NO",
|
||||
"tripId": null,
|
||||
"photoId": null,
|
||||
"entryId": 106
|
||||
},
|
||||
{
|
||||
"lat": 66,
|
||||
"lng": 11,
|
||||
"label": "Tromso",
|
||||
"date": "2026-06-07",
|
||||
"country": "NO",
|
||||
"tripId": null,
|
||||
"photoId": null,
|
||||
"entryId": 107
|
||||
},
|
||||
{
|
||||
"lat": 67,
|
||||
"lng": 12,
|
||||
"label": "Alta",
|
||||
"date": "2026-06-08",
|
||||
"country": "NO",
|
||||
"tripId": null,
|
||||
"photoId": null,
|
||||
"entryId": 108
|
||||
}
|
||||
],
|
||||
"trips": [],
|
||||
"excluded": [],
|
||||
"start": "2026-06-01",
|
||||
"end": "2026-06-08"
|
||||
},
|
||||
"path": [],
|
||||
"stationsLabel": "Stations",
|
||||
"dayLabel": "DAY {day}",
|
||||
"summaryLabel": "Trip summary",
|
||||
"countriesLabel": "Countries"
|
||||
},
|
||||
"gallery": [
|
||||
{
|
||||
"photoId": 900,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-01T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 901,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-02T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 902,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-03T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 903,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-04T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 904,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-05T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 905,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-06T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 906,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-07T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 907,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-08T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 908,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-09T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 909,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-01T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 910,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-02T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 911,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-03T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 912,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-04T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 913,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-05T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 914,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-06T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 915,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-07T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 916,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-08T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 917,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-09T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 918,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-01T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 919,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-02T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 920,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-03T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 921,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-04T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 922,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-05T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 923,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-06T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 924,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-07T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 925,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-08T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 926,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-09T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 927,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-01T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 928,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-02T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"photoId": 929,
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"caption": null,
|
||||
"takenAt": "2026-06-03T10:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
{
|
||||
"A/fabric-classic/head": "74bb0439e4575ed7",
|
||||
"A/fabric-classic/plan": "95b55b052b9ff07b",
|
||||
"A/fabric-classic/spread/0": "a4d090166287cbe5",
|
||||
"A/fabric-classic/spread/1": "aa637b0de2518bf5",
|
||||
"A/fabric-classic/spread/10": "febf8fa0b4cd3aef",
|
||||
"A/fabric-classic/spread/11": "26253da4f1f688a0",
|
||||
"A/fabric-classic/spread/12": "4dbf29b2c63f9231",
|
||||
"A/fabric-classic/spread/13": "5deb8ece6f27e514",
|
||||
"A/fabric-classic/spread/14": "db4f2ede14bb6023",
|
||||
"A/fabric-classic/spread/15": "7d011356ea8fc70f",
|
||||
"A/fabric-classic/spread/16": "205c5f0f86f90cb4",
|
||||
"A/fabric-classic/spread/17": "de849a3986a487c1",
|
||||
"A/fabric-classic/spread/18": "22b9cb0c78e0ff66",
|
||||
"A/fabric-classic/spread/19": "9df44ff280b76f64",
|
||||
"A/fabric-classic/spread/2": "8278d3c2507527f9",
|
||||
"A/fabric-classic/spread/20": "972976260c457cd3",
|
||||
"A/fabric-classic/spread/21": "9102a5fa147693fe",
|
||||
"A/fabric-classic/spread/22": "0bc9c54f6ee8b3e9",
|
||||
"A/fabric-classic/spread/23": "b11b5b3fb34a34eb",
|
||||
"A/fabric-classic/spread/24": "a55189e7bfd3934d",
|
||||
"A/fabric-classic/spread/3": "2ceb4890ba95c249",
|
||||
"A/fabric-classic/spread/4": "7743a5784df1d3e5",
|
||||
"A/fabric-classic/spread/5": "dbbbfb7bedd90da2",
|
||||
"A/fabric-classic/spread/6": "c36b6c75a4254fad",
|
||||
"A/fabric-classic/spread/7": "b7694f72f7cbe2bb",
|
||||
"A/fabric-classic/spread/8": "bdafe534fcbe56f0",
|
||||
"A/fabric-classic/spread/9": "c8a03b3fbdb60114",
|
||||
"A/midnight/head": "ce0d82281cbc3e1f",
|
||||
"A/midnight/plan": "7fde812131c9b570",
|
||||
"A/midnight/spread/0": "409beb5fbb1accce",
|
||||
"A/midnight/spread/1": "d9f5d78659332b45",
|
||||
"A/midnight/spread/10": "53f13a2ec3a3c5f4",
|
||||
"A/midnight/spread/11": "888c0c66730d32dc",
|
||||
"A/midnight/spread/12": "8d6c62bc4ee5ed1d",
|
||||
"A/midnight/spread/13": "fc0673a56f5b4d4b",
|
||||
"A/midnight/spread/14": "33db0be88fd469a7",
|
||||
"A/midnight/spread/15": "72105c668088bc4c",
|
||||
"A/midnight/spread/16": "bd11f4dc18e03108",
|
||||
"A/midnight/spread/17": "2e7a0f09822c3556",
|
||||
"A/midnight/spread/18": "f4264c3ee121b8d1",
|
||||
"A/midnight/spread/19": "4f27a48a457d95bb",
|
||||
"A/midnight/spread/2": "b00839cd0dc1451b",
|
||||
"A/midnight/spread/20": "dff6e523732c8481",
|
||||
"A/midnight/spread/21": "26d666dde7be6641",
|
||||
"A/midnight/spread/22": "eba329fd185958b3",
|
||||
"A/midnight/spread/23": "9664fbd24505a632",
|
||||
"A/midnight/spread/3": "742dfb4105263101",
|
||||
"A/midnight/spread/4": "ea9613c918951560",
|
||||
"A/midnight/spread/5": "8aa571b4d51a5234",
|
||||
"A/midnight/spread/6": "2275359a3c03f457",
|
||||
"A/midnight/spread/7": "3ef3c265b38598e2",
|
||||
"A/midnight/spread/8": "3a77eec4f6c9f860",
|
||||
"A/midnight/spread/9": "5bdff7027bea103c",
|
||||
"A/riso/head": "7d43d0900fbe0f57",
|
||||
"A/riso/plan": "f802ad819f1fb262",
|
||||
"A/riso/spread/0": "1f4502d29eca9017",
|
||||
"A/riso/spread/1": "8de92788de29277b",
|
||||
"A/riso/spread/10": "2a92b79192575cd0",
|
||||
"A/riso/spread/11": "5785a9cc59ddd735",
|
||||
"A/riso/spread/12": "cd4f6004153fe694",
|
||||
"A/riso/spread/13": "b1f87a92cb7f775c",
|
||||
"A/riso/spread/14": "873bf5f7a8404fe4",
|
||||
"A/riso/spread/15": "c1aab0214328ef3b",
|
||||
"A/riso/spread/16": "d58f70c381d9f6ae",
|
||||
"A/riso/spread/17": "00caa407cb2a0677",
|
||||
"A/riso/spread/18": "ed41aca064456614",
|
||||
"A/riso/spread/19": "36647f06c594e715",
|
||||
"A/riso/spread/2": "5c9a93de9e6467d6",
|
||||
"A/riso/spread/20": "8518ca61c110fd8a",
|
||||
"A/riso/spread/21": "d4ba78b2ffd5ae04",
|
||||
"A/riso/spread/22": "de5116307e12ce61",
|
||||
"A/riso/spread/23": "a748ada7a028818e",
|
||||
"A/riso/spread/24": "9abf456403732463",
|
||||
"A/riso/spread/3": "20a199a47535dea7",
|
||||
"A/riso/spread/4": "93b84be6e48097b5",
|
||||
"A/riso/spread/5": "6d5ae8e80c2f0d21",
|
||||
"A/riso/spread/6": "e7f5c928785be48d",
|
||||
"A/riso/spread/7": "b672fd04b61ea7b9",
|
||||
"A/riso/spread/8": "d9e84bbad744e450",
|
||||
"A/riso/spread/9": "017d695be27dc988",
|
||||
"B/fabric-classic/head": "6af0d11dd09eae78",
|
||||
"B/fabric-classic/plan": "cae85517611abbca",
|
||||
"B/fabric-classic/spread/0": "87325aa216fe9c6a",
|
||||
"B/fabric-classic/spread/1": "b3f8e9c5043f8302",
|
||||
"B/fabric-classic/spread/2": "5951c4e8d72b4b3f",
|
||||
"B/fabric-classic/spread/3": "110c2e11d8079468",
|
||||
"B/fabric-classic/spread/4": "6f02c19ed62524d1",
|
||||
"B/fabric-classic/spread/5": "5b0cb3424c4b894c",
|
||||
"B/fabric-classic/spread/6": "28fec4ed0f8fd482",
|
||||
"B/fabric-classic/spread/7": "9d68ef6e7d8b5f5a",
|
||||
"B/fabric-classic/spread/8": "b11b5b3fb34a34eb",
|
||||
"B/fabric-classic/spread/9": "3194c096e5c7d16f",
|
||||
"B/midnight/head": "cfd309bdf9e41811",
|
||||
"B/midnight/plan": "b3e4cb03c2d620a4",
|
||||
"B/midnight/spread/0": "7fdd20dcda48881f",
|
||||
"B/midnight/spread/1": "d9f5d78659332b45",
|
||||
"B/midnight/spread/10": "db97ebe8475e0371",
|
||||
"B/midnight/spread/2": "a094503b82019808",
|
||||
"B/midnight/spread/3": "6e3e57121d8b8683",
|
||||
"B/midnight/spread/4": "468100c3bebad30d",
|
||||
"B/midnight/spread/5": "ca669ba76ca4d9bd",
|
||||
"B/midnight/spread/6": "60b4cb974e35c55d",
|
||||
"B/midnight/spread/7": "46fe0841298e4f7b",
|
||||
"B/midnight/spread/8": "25e4510c5fb81817",
|
||||
"B/midnight/spread/9": "eba329fd185958b3",
|
||||
"B/riso/head": "69a6c3112de2a047",
|
||||
"B/riso/plan": "d8906a53b3532db8",
|
||||
"B/riso/spread/0": "2bbd6e6b4e6d1b4d",
|
||||
"B/riso/spread/1": "8de92788de29277b",
|
||||
"B/riso/spread/2": "40f8d2b093d0fc8d",
|
||||
"B/riso/spread/3": "1fc8aedc7486cbeb",
|
||||
"B/riso/spread/4": "311c9fcde888d578",
|
||||
"B/riso/spread/5": "51562d8f08f46011",
|
||||
"B/riso/spread/6": "c114a720b5c5beff",
|
||||
"B/riso/spread/7": "6f4c43c7b5f1dbc3",
|
||||
"B/riso/spread/8": "a748ada7a028818e",
|
||||
"B/riso/spread/9": "877657cfa95e3cac",
|
||||
"C/fabric-classic/head": "ddd21691c506a600",
|
||||
"C/fabric-classic/plan": "c31dcaa9b3603acc",
|
||||
"C/fabric-classic/spread/0": "92d21ac9d8e866df",
|
||||
"C/fabric-classic/spread/1": "7094bacb28cc210a",
|
||||
"C/fabric-classic/spread/10": "b11b5b3fb34a34eb",
|
||||
"C/fabric-classic/spread/11": "f7d9814c6b8649fd",
|
||||
"C/fabric-classic/spread/2": "3e514b7dd87c7d49",
|
||||
"C/fabric-classic/spread/3": "a964aa1e7f057294",
|
||||
"C/fabric-classic/spread/4": "4a275bcefa9a8eb0",
|
||||
"C/fabric-classic/spread/5": "10ed185ab3fd9756",
|
||||
"C/fabric-classic/spread/6": "7bfbef523f58c3fc",
|
||||
"C/fabric-classic/spread/7": "71330d722ab54efd",
|
||||
"C/fabric-classic/spread/8": "a7f0ed591793b5e1",
|
||||
"C/fabric-classic/spread/9": "31665bce59abeafe",
|
||||
"C/midnight/head": "f1e6077175cb2e20",
|
||||
"C/midnight/plan": "14fb454d02caf6f9",
|
||||
"C/midnight/spread/0": "79eae9202a85d430",
|
||||
"C/midnight/spread/1": "d9f5d78659332b45",
|
||||
"C/midnight/spread/10": "6694684925255e94",
|
||||
"C/midnight/spread/11": "247394453c45c63c",
|
||||
"C/midnight/spread/12": "abe0197797669d57",
|
||||
"C/midnight/spread/13": "eba329fd185958b3",
|
||||
"C/midnight/spread/14": "ebbba57a4116faa3",
|
||||
"C/midnight/spread/2": "7b99390522f39fe5",
|
||||
"C/midnight/spread/3": "7391268691e50ede",
|
||||
"C/midnight/spread/4": "4f08a3fc6da821a5",
|
||||
"C/midnight/spread/5": "6d06a2d469c2d5e2",
|
||||
"C/midnight/spread/6": "6238c8bc28c5964e",
|
||||
"C/midnight/spread/7": "8a5cc2233372bb47",
|
||||
"C/midnight/spread/8": "a445fe1b9090293d",
|
||||
"C/midnight/spread/9": "e2af9bfdb8789b4b",
|
||||
"C/riso/head": "d19997b07778d1dc",
|
||||
"C/riso/plan": "175dadeb8bb730a6",
|
||||
"C/riso/spread/0": "8453dd7b94afe68d",
|
||||
"C/riso/spread/1": "8de92788de29277b",
|
||||
"C/riso/spread/10": "6b5aa1dac73ab777",
|
||||
"C/riso/spread/11": "c20262603c86d43e",
|
||||
"C/riso/spread/12": "8e263c445c0418d2",
|
||||
"C/riso/spread/13": "a748ada7a028818e",
|
||||
"C/riso/spread/14": "81e4b4a0dcc6961a",
|
||||
"C/riso/spread/2": "5e43386b045d926c",
|
||||
"C/riso/spread/3": "9587f2fe2fc9cd04",
|
||||
"C/riso/spread/4": "edb0ebe3a843a937",
|
||||
"C/riso/spread/5": "f8a935188537c224",
|
||||
"C/riso/spread/6": "d2ca03597f3732c6",
|
||||
"C/riso/spread/7": "7a4374ec7906855f",
|
||||
"C/riso/spread/8": "2dd867bf81ca7901",
|
||||
"C/riso/spread/9": "aea9baf94aa13bc8",
|
||||
"D/fabric-classic/head": "54fa8987fea87ce4",
|
||||
"D/fabric-classic/plan": "a2d0423833838dcc",
|
||||
"D/fabric-classic/spread/0": "1136daea45c98c7d",
|
||||
"D/fabric-classic/spread/1": "931682bc5827ad8a",
|
||||
"D/fabric-classic/spread/10": "98449436f064fc7c",
|
||||
"D/fabric-classic/spread/11": "e58c5ea28b6e5d14",
|
||||
"D/fabric-classic/spread/12": "a422c04ae2d9e32f",
|
||||
"D/fabric-classic/spread/13": "b512aff5192eae71",
|
||||
"D/fabric-classic/spread/14": "b92cc5c3c532f2cb",
|
||||
"D/fabric-classic/spread/15": "b11b5b3fb34a34eb",
|
||||
"D/fabric-classic/spread/16": "c40cdcb18e2ef0bc",
|
||||
"D/fabric-classic/spread/2": "86dd9a101af346f4",
|
||||
"D/fabric-classic/spread/3": "afe19cb105dfefa0",
|
||||
"D/fabric-classic/spread/4": "80d31e5986b670e2",
|
||||
"D/fabric-classic/spread/5": "664cd81373c14243",
|
||||
"D/fabric-classic/spread/6": "c1d232a5aed51318",
|
||||
"D/fabric-classic/spread/7": "8d08e54aa79c8c74",
|
||||
"D/fabric-classic/spread/8": "18f531e0a85ff3de",
|
||||
"D/fabric-classic/spread/9": "a7ddf2eb33bb75f6",
|
||||
"D/midnight/head": "da510cf5f95bad40",
|
||||
"D/midnight/plan": "3bd0d52ed4e476c7",
|
||||
"D/midnight/spread/0": "440b7d69daa18a7a",
|
||||
"D/midnight/spread/1": "d9f5d78659332b45",
|
||||
"D/midnight/spread/10": "824d62135a0fe827",
|
||||
"D/midnight/spread/11": "03bbcf527fcbce9f",
|
||||
"D/midnight/spread/12": "d2e1ae86b69d8a5c",
|
||||
"D/midnight/spread/13": "7025aef32f444ab2",
|
||||
"D/midnight/spread/14": "581f0008497c1143",
|
||||
"D/midnight/spread/15": "f86784f4b061a31e",
|
||||
"D/midnight/spread/16": "ec6c98d1713ff353",
|
||||
"D/midnight/spread/17": "03888512d15a5895",
|
||||
"D/midnight/spread/18": "eba329fd185958b3",
|
||||
"D/midnight/spread/19": "6e6e6c877443c5e7",
|
||||
"D/midnight/spread/2": "cef4235690d44e53",
|
||||
"D/midnight/spread/3": "cd34cb85119476ee",
|
||||
"D/midnight/spread/4": "f9782906a14ae092",
|
||||
"D/midnight/spread/5": "597d729fb1cfb962",
|
||||
"D/midnight/spread/6": "0023fe5672ad9141",
|
||||
"D/midnight/spread/7": "f79ae699d0684ce5",
|
||||
"D/midnight/spread/8": "ea1a0157b2405cff",
|
||||
"D/midnight/spread/9": "5d38d38d1226fe1e",
|
||||
"D/riso/head": "1c8f99d99acdea28",
|
||||
"D/riso/plan": "f2a8704019582093",
|
||||
"D/riso/spread/0": "7b0d816ffda1dd01",
|
||||
"D/riso/spread/1": "8de92788de29277b",
|
||||
"D/riso/spread/10": "c85c0634205e3232",
|
||||
"D/riso/spread/11": "f97635aee763f93e",
|
||||
"D/riso/spread/12": "37f5d0dc381cfdb4",
|
||||
"D/riso/spread/13": "7083d486cc5556c5",
|
||||
"D/riso/spread/14": "711a3787fce72914",
|
||||
"D/riso/spread/15": "27d0db240d32de9a",
|
||||
"D/riso/spread/16": "a748ada7a028818e",
|
||||
"D/riso/spread/17": "ed87c0a4c77f241c",
|
||||
"D/riso/spread/2": "78f24755fb7ad38a",
|
||||
"D/riso/spread/3": "047741da3e1adc20",
|
||||
"D/riso/spread/4": "294d0c2a0600ed4a",
|
||||
"D/riso/spread/5": "b1f6c2832a5f5737",
|
||||
"D/riso/spread/6": "a898defea41081ec",
|
||||
"D/riso/spread/7": "027026c49ea68a27",
|
||||
"D/riso/spread/8": "4ff93cb4d24f729c",
|
||||
"D/riso/spread/9": "988a20c009c9d562"
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
import type { AutoEntry, AutoInput, AutoPhoto } from '../auto/auto-types';
|
||||
import { normalizeBookDocument } from '../book-normalize';
|
||||
import {
|
||||
MAX_SPREADS,
|
||||
MAX_SPREAD_ELEMENTS,
|
||||
bookDocumentSchema,
|
||||
type BookDocument,
|
||||
type BookSpread,
|
||||
} from '../book.schema';
|
||||
import { pageFindings } from '../describe-spread';
|
||||
import { findDecoratedTemplate } from '../templates/template-catalogue';
|
||||
import { templatePhotoSlots } from '../templates/template-format';
|
||||
import { matchDecoratedTemplate } from '../templates/template-match';
|
||||
import { bookThemeColor, bookThemeOf } from '../theme/book-theme';
|
||||
import { applyBookTheme } from '../theme/book-theme-apply';
|
||||
import { designBook, type BookDesignInput, type BookDesignResult } from './design-book';
|
||||
import type { BookDesignDirectivesInput } from './design-directives.schema';
|
||||
import { ARCHETYPE_KIN, THEME_ARCHETYPE } from './design-template-pick';
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* A book laid on the designed templates (`layouts: 'templates'`): the
|
||||
* Studio's Auto layout and `design_book` with the templates.
|
||||
*
|
||||
* AUTODSN-020 pins a digest of every page on the auto layout's three parity
|
||||
* journeys and on a fourth (`design-templated.parity-inputs.json`: entries of
|
||||
* 0, 1, 2, 3, 5, 12 and 20 photos and a gallery), in three themes. A
|
||||
* deliberate change to a layout changes its digests: say so in the change
|
||||
* and write the new ones (the failure prints the whole map as it is now).
|
||||
*/
|
||||
|
||||
interface Fixture {
|
||||
input: AutoInput & Partial<BookDesignInput>;
|
||||
gallery?: AutoPhoto[];
|
||||
}
|
||||
|
||||
const here = (file: string) => fileURLToPath(new URL(file, import.meta.url));
|
||||
const read = <T>(file: string) => JSON.parse(readFileSync(here(file), 'utf8')) as T;
|
||||
const parity = {
|
||||
...read<Record<string, Fixture>>('../auto/auto-layout.parity-inputs.json'),
|
||||
...read<Record<string, Fixture>>('./design-templated.parity-inputs.json'),
|
||||
};
|
||||
const designFixtures = read<Record<string, { input: BookDesignInput }>>('./design-book.fixtures.json');
|
||||
const expected = read<Record<string, string>>('./design-templated.parity.json');
|
||||
|
||||
/** Every journey the specs lay out, as designBook takes it, with ids from a counter. */
|
||||
function inputs(): [string, BookDesignInput][] {
|
||||
const out: [string, BookDesignInput][] = [];
|
||||
for (const [name, fx] of Object.entries(parity)) {
|
||||
out.push([name, { ...fx.input, gallery: fx.gallery, candidates: [], photos: [] }]);
|
||||
}
|
||||
for (const [name, fx] of Object.entries(designFixtures)) out.push([name, fx.input]);
|
||||
return out;
|
||||
}
|
||||
|
||||
const counter = () => {
|
||||
let n = 0;
|
||||
return (p: string) => `${p}-${++n}`;
|
||||
};
|
||||
|
||||
const THEMES = ['fabric-classic', 'riso', 'midnight'] as const;
|
||||
|
||||
function laid(input: BookDesignInput, style: string, more: BookDesignDirectivesInput = {}): BookDesignResult {
|
||||
const directives = { style, layouts: 'templates', seed: 1, ...more } as BookDesignDirectivesInput;
|
||||
return designBook(null, { ...input, newId: counter() }, directives);
|
||||
}
|
||||
|
||||
const photoIdsOn = (spreads: readonly BookSpread[], where: 'elements' | 'parked') =>
|
||||
spreads.flatMap((s) => s[where].flatMap((e) => (e.kind === 'photo' && e.photoId != null ? [e.photoId] : [])));
|
||||
|
||||
/** Every photo the journey has, on its entries and loose in its gallery. */
|
||||
const journeyPhotos = (input: BookDesignInput) => [
|
||||
...input.entries.flatMap((e) => e.photos.map((p) => p.photoId)),
|
||||
...(input.gallery ?? []).map((p) => p.photoId),
|
||||
];
|
||||
|
||||
const STORY =
|
||||
'The ferry left in the rain and we stood on deck anyway, watching the islands go by one after the other. ';
|
||||
|
||||
/** A journey of one entry with this many photos, a story or none. */
|
||||
function single(photos: number, story: boolean): BookDesignInput {
|
||||
const base = parity.A as Fixture;
|
||||
const entry: AutoEntry = {
|
||||
id: 1,
|
||||
title: 'Islands',
|
||||
story: story ? STORY.repeat(3).trim() : null,
|
||||
location: 'Lofoten',
|
||||
date: '2026-06-02',
|
||||
photos: Array.from({ length: photos }, (_, i) => ({ photoId: 700 + i, width: 4000, height: 3000 })),
|
||||
lat: 68.2,
|
||||
lng: 13.6,
|
||||
country: 'NO',
|
||||
dayNumber: 1,
|
||||
dayCount: 1,
|
||||
};
|
||||
return { ...base.input, entries: [entry], candidates: [], photos: [] };
|
||||
}
|
||||
|
||||
describe('the book on the designed templates', () => {
|
||||
it('AUTODSN-001: is a valid book, as normalised, within the spread and element caps', () => {
|
||||
for (const [name, input] of inputs()) {
|
||||
for (const theme of THEMES) {
|
||||
const { doc } = laid(input, theme);
|
||||
expect(bookDocumentSchema.safeParse(doc).success, `${name} ${theme}`).toBe(true);
|
||||
expect(normalizeBookDocument(doc), `${name} ${theme}`).toEqual(doc);
|
||||
expect(doc.spreads.length).toBeLessThanOrEqual(MAX_SPREADS);
|
||||
for (const s of doc.spreads) expect(s.elements.length).toBeLessThanOrEqual(MAX_SPREAD_ELEMENTS);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("AUTODSN-002: the cover is a cover template of the theme's archetype", () => {
|
||||
for (const [name, input] of inputs()) {
|
||||
for (const theme of THEMES) {
|
||||
const result = laid(input, theme);
|
||||
const cover = result.doc.spreads[0] as BookSpread;
|
||||
const id = result.plan[0]?.template;
|
||||
if (!id) continue; // a journey without photos for any of its covers is composed
|
||||
expect(findDecoratedTemplate(id)?.archetype, `${name} ${theme}`).toBe(THEME_ARCHETYPE[theme]);
|
||||
expect(matchDecoratedTemplate(cover, result.doc.page)?.id, `${name} ${theme}`).toBe(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('AUTODSN-003: every entry with something to show has a page, in the order of the journey', () => {
|
||||
for (const [name, input] of inputs()) {
|
||||
const { doc } = laid(input, 'fabric-classic');
|
||||
const told = [...new Set(doc.spreads.flatMap((s) => (s.entryId == null ? [] : [s.entryId])))];
|
||||
const substance = input.entries.filter((e) => e.photos.length || (e.story ?? '').trim()).map((e) => e.id);
|
||||
for (const id of substance) expect(told, `${name} ${id}`).toContain(id);
|
||||
const dated = [...input.entries].filter((e) => told.includes(e.id) && e.date);
|
||||
const order = told.filter((id) => dated.some((e) => e.id === id));
|
||||
const byDate = [...dated].sort((a, b) => (a.date ?? '').localeCompare(b.date ?? '')).map((e) => e.id);
|
||||
expect(order, name).toEqual(byDate);
|
||||
}
|
||||
});
|
||||
|
||||
it('AUTODSN-004: no photo vanishes: it is on a page, parked with one, or said to be left out', () => {
|
||||
for (const [name, input] of inputs()) {
|
||||
for (const theme of THEMES) {
|
||||
const result = laid(input, theme);
|
||||
const on = new Set([
|
||||
...photoIdsOn(result.doc.spreads, 'elements'),
|
||||
...photoIdsOn(result.doc.spreads, 'parked'),
|
||||
]);
|
||||
const left = new Set(result.photos.left_out.map((l) => l.photoId));
|
||||
const lost = journeyPhotos(input).filter((id) => !on.has(id) && !left.has(id));
|
||||
expect(lost, `${name} ${theme}`).toEqual([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('AUTODSN-005: never a classic summary, countries or mark', () => {
|
||||
for (const [name, input] of inputs()) {
|
||||
for (const theme of THEMES) {
|
||||
for (const s of laid(input, theme).doc.spreads) {
|
||||
for (const e of s.elements) {
|
||||
if (e.kind === 'stats' || e.kind === 'countries') expect(e.look ?? 'classic', name).not.toBe('classic');
|
||||
if (e.kind === 'badge') expect(['plain', 'chip'], name).not.toContain(e.style);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('AUTODSN-006: is kept in its theme, and another theme recolours the templated pages', () => {
|
||||
const input = inputs()[0]?.[1] as BookDesignInput;
|
||||
const { doc, plan } = laid(input, 'riso');
|
||||
expect(doc.theme?.id).toBe('riso');
|
||||
const night = bookThemeOf('midnight');
|
||||
const again = applyBookTheme(doc, night);
|
||||
const templated = plan.flatMap((r, i) => (r.template ? [i] : []));
|
||||
expect(templated.length).toBeGreaterThan(3);
|
||||
for (const i of templated) {
|
||||
const linked = (again.spreads[i] as BookSpread).elements.filter((e) => e.kind === 'text' && e.token === 'ink');
|
||||
for (const e of linked) if (e.kind === 'text') expect(e.color).toBe(bookThemeColor(night, 'ink'));
|
||||
}
|
||||
expect(again.spreads.some((s, i) => JSON.stringify(s) !== JSON.stringify(doc.spreads[i]))).toBe(true);
|
||||
});
|
||||
|
||||
it("AUTODSN-007: titles follow the entry's title and stories its story, a cut story going on over the next page", () => {
|
||||
const input = parity.A as Fixture;
|
||||
const { doc, notes } = laid({ ...input.input, candidates: [], photos: [] }, 'fabric-classic');
|
||||
const pages = doc.spreads.filter((s) => s.entryId != null);
|
||||
for (const s of pages) {
|
||||
const bound = s.elements.some(
|
||||
(e) => e.kind === 'text' && (e.binding?.source === 'entry.title' || e.binding?.source === 'entry.location'),
|
||||
);
|
||||
expect(bound, s.id).toBe(true);
|
||||
}
|
||||
const stories = pages.flatMap((s) =>
|
||||
s.elements.filter((e) => e.kind === 'text' && e.binding?.source === 'entry.story'),
|
||||
);
|
||||
expect(stories.length).toBeGreaterThan(0);
|
||||
const cut = stories.filter((e) => e.kind === 'text' && e.overridden);
|
||||
if (cut.length) expect(notes.some((n) => n.includes('cut to fit'))).toBe(true);
|
||||
for (const e of stories) if (e.kind === 'text' && !e.overridden) expect(e.text.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('AUTODSN-008: an entry of 0 to 20 photos, with a story or none, fills every frame it is laid on', () => {
|
||||
for (const n of [0, 1, 2, 3, 4, 5, 6, 7, 8, 12, 20]) {
|
||||
for (const story of [false, true]) {
|
||||
if (!n && !story) continue;
|
||||
const input = single(n, story);
|
||||
const result = laid(input, 'fabric-classic');
|
||||
const pages = result.doc.spreads.filter((s) => s.entryId === 1);
|
||||
expect(pages.length, `${n} ${story}`).toBeGreaterThan(0);
|
||||
for (const s of pages) {
|
||||
for (const e of s.elements) if (e.kind === 'photo') expect(e.photoId, `${n} ${story}`).not.toBeNull();
|
||||
}
|
||||
const placed = photoIdsOn(pages, 'elements').length + photoIdsOn(pages, 'parked').length;
|
||||
const capped = result.photos.left_out.filter((l) => l.reason === 'chapter_max').length;
|
||||
expect(placed + capped, `${n} ${story}`).toBe(n);
|
||||
expect(capped).toBe(Math.max(0, n - 12));
|
||||
if (story) {
|
||||
const told = pages.some((s) =>
|
||||
s.elements.some((e) => e.kind === 'text' && e.binding?.source === 'entry.story'),
|
||||
);
|
||||
expect(told, `${n}`).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("AUTODSN-009: the gallery's photos go to the entry of their day first, the rest to closing pages", () => {
|
||||
const fx = parity.D as Fixture;
|
||||
const input: BookDesignInput = { ...fx.input, gallery: fx.gallery, candidates: [], photos: [] };
|
||||
const { doc } = laid(input, 'fabric-classic');
|
||||
const of = (entryId: number) =>
|
||||
photoIdsOn(
|
||||
doc.spreads.filter((s) => s.entryId === entryId),
|
||||
'elements',
|
||||
);
|
||||
// 900 was taken on 1 June, the day of entry 101, which had no photos of its own.
|
||||
expect(of(101)).toContain(900);
|
||||
// An entry already holding all it takes leaves the loose photos to the closing pages of more moments.
|
||||
const full = single(12, false);
|
||||
const loose = Array.from({ length: 10 }, (_, i) => ({
|
||||
photoId: 800 + i,
|
||||
width: 4000,
|
||||
height: 3000,
|
||||
takenAt: null,
|
||||
}));
|
||||
const more = laid({ ...full, gallery: loose }, 'fabric-classic');
|
||||
const closing = more.doc.spreads.filter((s) => s.role === 'inner' && s.entryId === null);
|
||||
const shown = photoIdsOn(closing, 'elements');
|
||||
expect(shown.length).toBeGreaterThan(0);
|
||||
expect(shown.every((id) => id >= 800)).toBe(true);
|
||||
const left = more.photos.left_out.map((l) => l.photoId);
|
||||
expect(loose.every((p) => shown.includes(p.photoId) || left.includes(p.photoId))).toBe(true);
|
||||
});
|
||||
|
||||
it('AUTODSN-010: keeps a stored page that holds a locked element', () => {
|
||||
const input = inputs()[0]?.[1] as BookDesignInput;
|
||||
const first = laid(input, 'fabric-classic').doc;
|
||||
const page = first.spreads[5] as BookSpread;
|
||||
const locked: BookSpread = {
|
||||
...page,
|
||||
elements: page.elements.map((e, i) => (i === 0 ? { ...e, locked: true } : e)),
|
||||
};
|
||||
const stored: BookDocument = { ...first, spreads: first.spreads.map((s) => (s.id === page.id ? locked : s)) };
|
||||
const again = designBook(
|
||||
stored,
|
||||
{ ...input, newId: counter() },
|
||||
{ style: 'fabric-classic', layouts: 'templates', seed: 2 },
|
||||
);
|
||||
expect(again.doc.spreads).toContainEqual(locked);
|
||||
expect(again.plan.find((r) => r.spreadId === page.id)?.action).toBe('keep');
|
||||
});
|
||||
|
||||
it('AUTODSN-011: no page has a severe finding', () => {
|
||||
for (const [name, input] of inputs()) {
|
||||
for (const theme of THEMES) {
|
||||
const { doc } = laid(input, theme);
|
||||
doc.spreads.forEach((s, i) => {
|
||||
const severe = pageFindings(s, doc.page, input.photos).filter((f) => f.severity === 'severe');
|
||||
expect(
|
||||
severe.map((f) => f.code),
|
||||
`${name} ${theme} page ${i}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('AUTODSN-012: the plan names the template every templated page came from', () => {
|
||||
const { plan, doc } = laid(inputs()[0]?.[1] as BookDesignInput, 'midnight');
|
||||
const named = plan.filter((r) => r.template);
|
||||
expect(named.length).toBeGreaterThan(3);
|
||||
for (const r of named) {
|
||||
expect(findDecoratedTemplate(r.template ?? '')).toBeDefined();
|
||||
const s = doc.spreads.find((x) => x.id === r.spreadId) as BookSpread;
|
||||
expect(matchDecoratedTemplate(s, doc.page)?.id, r.template).toBe(r.template);
|
||||
}
|
||||
});
|
||||
|
||||
it('AUTODSN-013: the same seed lays the same book, another seed another', () => {
|
||||
const input = inputs()[0]?.[1] as BookDesignInput;
|
||||
const ids = (seed: number) =>
|
||||
laid(input, 'riso', { seed })
|
||||
.plan.map((r) => r.template ?? r.beat)
|
||||
.join();
|
||||
expect(ids(4)).toBe(ids(4));
|
||||
expect([5, 6, 7, 8].some((seed) => ids(seed) !== ids(4))).toBe(true);
|
||||
});
|
||||
|
||||
it("AUTODSN-014: a theme of the book's own is kept whole", () => {
|
||||
const own = {
|
||||
...bookThemeOf('postcard'),
|
||||
id: null,
|
||||
palette: { ...bookThemeOf('postcard').palette, accent: '#123456' },
|
||||
};
|
||||
const input = inputs()[0]?.[1] as BookDesignInput;
|
||||
const { doc } = designBook(
|
||||
null,
|
||||
{ ...input, theme: own, newId: counter() },
|
||||
{ style: 'postcard', layouts: 'templates' },
|
||||
);
|
||||
expect(doc.theme).toEqual(own);
|
||||
});
|
||||
|
||||
it('AUTODSN-015: without layouts the design is composed as before, with no template named', () => {
|
||||
const input = inputs()[0]?.[1] as BookDesignInput;
|
||||
const composed = designBook(null, { ...input, newId: counter() }, { style: 'riso', seed: 1 });
|
||||
expect(composed.plan.every((r) => r.template === undefined)).toBe(true);
|
||||
expect(composed.doc).toEqual(
|
||||
designBook(null, { ...input, newId: counter() }, { style: 'riso', seed: 1, layouts: 'composed' }).doc,
|
||||
);
|
||||
});
|
||||
|
||||
it("AUTODSN-016: bare stops are listed on a words-only template of the book's styles, every name in its story", () => {
|
||||
const base = parity.A as Fixture;
|
||||
const entries = base.input.entries.map((e) => ({ ...e, story: null, photos: [] }));
|
||||
const input = { ...base.input, entries, coverPhotoId: null, candidates: [], photos: [] };
|
||||
for (const theme of THEMES) {
|
||||
const { plan, doc } = laid(input, theme);
|
||||
const lists = plan.filter((r) => r.beat === 'stations');
|
||||
expect(lists.length, theme).toBeGreaterThan(0);
|
||||
const words: string[] = [];
|
||||
for (const r of lists) {
|
||||
const tpl = findDecoratedTemplate(r.template ?? '');
|
||||
expect(tpl && templatePhotoSlots(tpl), `${theme} ${r.template}`).toBe(0);
|
||||
expect([THEME_ARCHETYPE[theme], ...ARCHETYPE_KIN[THEME_ARCHETYPE[theme]]]).toContain(tpl?.archetype);
|
||||
const s = doc.spreads.find((x) => x.id === r.spreadId) as BookSpread;
|
||||
const texts = s.elements.flatMap((e) => (e.kind === 'text' ? [e.text] : []));
|
||||
expect(texts, theme).toContain(input.stationsLabel);
|
||||
words.push(...texts);
|
||||
}
|
||||
const listed = words.join(' ');
|
||||
for (const e of entries) {
|
||||
const name = e.title || e.location || '';
|
||||
if (name) expect(listed, `${theme} ${name}`).toContain(name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('AUTODSN-020: lays the parity journeys exactly as pinned', () => {
|
||||
const actual = digests();
|
||||
const differ = [...actual].filter(([k, v]) => expected[k] !== v).map(([k]) => k);
|
||||
const missing = Object.keys(expected).filter((k) => !actual.has(k));
|
||||
const message = `the templated auto layout changed; if that is deliberate, write design-templated.parity.json as:\n${JSON.stringify(Object.fromEntries([...actual].sort()), null, 1)}`;
|
||||
expect(differ.length + missing.length, message).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── the digests ──────────────────────────────────────────────────────────
|
||||
|
||||
/** The canonical form auto-layout.parity.spec.ts digests: keys sorted, every id read as `#new`. */
|
||||
function canonical(value: unknown): string {
|
||||
const walk = (v: unknown, key?: string): unknown => {
|
||||
if (Array.isArray(v)) return v.map((x) => walk(x));
|
||||
if (v && typeof v === 'object') {
|
||||
const o = v as Record<string, unknown>;
|
||||
return Object.fromEntries(
|
||||
Object.keys(o)
|
||||
.sort()
|
||||
.map((k) => [k, walk(o[k], k)]),
|
||||
);
|
||||
}
|
||||
if (key === 'id' && typeof v === 'string') return '#new';
|
||||
return v;
|
||||
};
|
||||
return JSON.stringify(walk(value));
|
||||
}
|
||||
|
||||
const digest = (v: unknown) => createHash('sha256').update(canonical(v)).digest('hex').slice(0, 16);
|
||||
|
||||
/** Every page of every parity journey in every theme, digested. */
|
||||
function digests(): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
for (const [name, fx] of Object.entries(parity)) {
|
||||
const input = { ...fx.input, gallery: fx.gallery, candidates: [], photos: [] };
|
||||
for (const theme of THEMES) {
|
||||
const result = laid(input, theme);
|
||||
const doc = result.doc;
|
||||
const put = (key: string, v: unknown) => out.set(`${name}/${theme}/${key}`, digest(v));
|
||||
put('head', {
|
||||
version: doc.version,
|
||||
title: doc.title,
|
||||
page: doc.page,
|
||||
theme: doc.theme,
|
||||
count: doc.spreads.length,
|
||||
});
|
||||
put(
|
||||
'plan',
|
||||
result.plan.map((r) => [r.beat, r.template ?? null, r.entryId, r.photoIds]),
|
||||
);
|
||||
doc.spreads.forEach((s, i) => put(`spread/${i}`, s));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
import type { AutoEntry, AutoPhoto } from '../auto/auto-types';
|
||||
import type { BookCoverArchetype } from '../book-archetypes';
|
||||
import { DESIGN_TEXT_HEADROOM, textHeightNeeded } from '../book-findings';
|
||||
import { formatBookDate } from '../book-text';
|
||||
import type { BookElement, BookSpread, BookTextElement } from '../book.schema';
|
||||
import { decoratedTemplatesFor } from '../templates/template-catalogue';
|
||||
import { templatePhotoSlots, templateRoles, type BookDecoratedTemplate } from '../templates/template-format';
|
||||
import type { BookTemplateWord } from '../templates/template-words';
|
||||
import type { BookDesignInput } from './design-book';
|
||||
import { cutWords, liveBox, photoEl, textEl, type ComposeCtx } from './design-compose';
|
||||
import { composeStations } from './design-framing';
|
||||
import type { FramingNeeds } from './design-inner';
|
||||
import type { DesignedSpread } from './design-merge';
|
||||
import type { BookDesignBeat } from './design-pacing';
|
||||
import { buildChapters, type DesignChapter } from './design-pacing';
|
||||
import type { BookDesignLeftOut, DesignPhotoChoice } from './design-photos';
|
||||
import { innerSpread } from './design-spreads';
|
||||
import {
|
||||
bodyCapacity,
|
||||
chunkPhotos,
|
||||
chunkSizesOf,
|
||||
innerPool,
|
||||
type ChunkSizes,
|
||||
pickInnerTemplate,
|
||||
templateEnergy,
|
||||
type TemplatePickCtx,
|
||||
} from './design-template-pick';
|
||||
import {
|
||||
composeTravelCountries,
|
||||
composeTravelSummary,
|
||||
composeWordsPage,
|
||||
countryLookFor,
|
||||
pourTemplate,
|
||||
summaryLookFor,
|
||||
} from './design-templated-framing';
|
||||
|
||||
/**
|
||||
* The inner pages of a book laid on the designed templates.
|
||||
*
|
||||
* The journey at a glance and its countries open it, in the travel looks.
|
||||
* Then every chapter: its photos cut into page-sized parts that leave no
|
||||
* frame empty (`chunkPhotos`), the first part carrying the chapter's words,
|
||||
* and each part laid on the decorated template that suits it best
|
||||
* (`pickInnerTemplate`), in the book's theme, its title, place, date and
|
||||
* story bound to the journal. A story too long for its frame is cut at a
|
||||
* sentence and goes on over a page of words. Runs of stops nobody wrote
|
||||
* about share a list as in every design. Photos of the journey's gallery that
|
||||
* found no entry with room close the book on pages of more moments.
|
||||
*/
|
||||
|
||||
interface TemplatedInnerResult {
|
||||
spreads: DesignedSpread[];
|
||||
leftOut: BookDesignLeftOut[];
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
/** What the inner pages are laid with beside the composer's context. */
|
||||
interface TemplatedPlan {
|
||||
archetype: BookCoverArchetype;
|
||||
/** The book counts as calm for template choice: its busy pages stay few. */
|
||||
calm: boolean;
|
||||
/** Loose photos of the journey no entry had room for, for the closing pages of more moments. */
|
||||
rest: readonly AutoPhoto[];
|
||||
}
|
||||
|
||||
/** The closing pages of more moments a book takes at most. */
|
||||
const MORE_MOMENTS = 3;
|
||||
/** Pages of words one story runs on to at most past its own. */
|
||||
const MOST_CONTINUED = 2;
|
||||
|
||||
/** The classic mark styles: never set by the auto layout, which sets a date mark as a stamp. */
|
||||
const CLASSIC_MARKS = new Set(['plain', 'chip', 'outline', 'stacked']);
|
||||
|
||||
/** The beat a templated page plays in the plan, read from its template's energy and photos. */
|
||||
function beatOf(tpl: BookDecoratedTemplate | null, photos: number): BookDesignBeat {
|
||||
if (!tpl) return photos ? 'grid' : 'story';
|
||||
const energy = templateEnergy(tpl);
|
||||
if (energy === 'loud') return 'hero';
|
||||
if (energy === 'lively' || energy === 'medium') return photos >= 4 ? 'grid' : photos >= 2 ? 'duo' : 'quiet';
|
||||
return photos === 0 ? 'story' : 'quiet';
|
||||
}
|
||||
|
||||
/** The words of a story the directives let a book set: all, an excerpt, or none. */
|
||||
function storyOf(c: ComposeCtx, entry: AutoEntry | undefined): string {
|
||||
const words = (entry?.story ?? '').trim();
|
||||
const mode = c.directives.text?.story ?? 'full';
|
||||
if (!words || mode === 'none') return '';
|
||||
return mode === 'excerpt' ? cutWords(words, c.directives.text?.excerptChars ?? 600) : words;
|
||||
}
|
||||
|
||||
/**
|
||||
* A page holding an entry's content for a template to pour: the story (bound
|
||||
* when it is the whole of it), the first photo's caption when captions are
|
||||
* asked of each photo, and the photos in order. The title, place and date the
|
||||
* template takes from the journal itself, bound to the entry.
|
||||
*/
|
||||
function entrySeedSpread(
|
||||
c: ComposeCtx,
|
||||
entry: AutoEntry,
|
||||
photos: readonly AutoPhoto[],
|
||||
story: { words: string; whole: boolean } | null,
|
||||
): BookSpread {
|
||||
const box = liveBox(c, 'right');
|
||||
const els: BookElement[] = [];
|
||||
if (story?.words) {
|
||||
const binding = { source: 'entry.story' as const, entryId: entry.id };
|
||||
els.push(textEl(c, box, story.words, 'body', { binding, overridden: !story.whole }));
|
||||
}
|
||||
const caption = photos[0]?.caption?.trim();
|
||||
const first = photos[0];
|
||||
if (caption && first && c.directives.text?.captions === 'photo') {
|
||||
const binding = { source: 'photo.caption' as const, photoId: first.photoId };
|
||||
els.push(textEl(c, box, caption, 'caption', { binding }));
|
||||
}
|
||||
els.push(...photos.map((p) => photoEl(c, p, box)));
|
||||
return innerSpread(c, els, entry.id);
|
||||
}
|
||||
|
||||
/** The longest start of `words` its laid frame holds, cut at a sentence where one is near. */
|
||||
function cutToFrame(el: BookTextElement, words: string): string {
|
||||
const fits = (s: string) => textHeightNeeded({ ...el, text: s }, DESIGN_TEXT_HEADROOM) <= el.frame.h;
|
||||
if (fits(words)) return words;
|
||||
let lo = 0;
|
||||
let hi = words.length;
|
||||
while (lo < hi) {
|
||||
const mid = Math.ceil((lo + hi) / 2);
|
||||
if (fits(`${words.slice(0, mid)}…`)) lo = mid;
|
||||
else hi = mid - 1;
|
||||
}
|
||||
return cutWords(words, lo);
|
||||
}
|
||||
|
||||
const isStory = (e: BookElement): e is BookTextElement => e.kind === 'text' && e.binding?.source === 'entry.story';
|
||||
|
||||
/**
|
||||
* A laid page with its story cut to its frame where it runs over: marked as
|
||||
* no longer following the journal and said in the notes, the rest handed
|
||||
* back for the next page. A story that fits stays as it is.
|
||||
*/
|
||||
function fittedStory(c: ComposeCtx, spread: BookSpread, entry: AutoEntry): { spread: BookSpread; rest: string } {
|
||||
const story = spread.elements.find(isStory);
|
||||
if (!story) return { spread, rest: '' };
|
||||
const fitting = cutToFrame(story, story.text);
|
||||
if (fitting === story.text) return { spread, rest: '' };
|
||||
c.notes.push(`the story of entry ${entry.id} is cut to fit its page and no longer follows the journal`);
|
||||
const rest = story.text.slice(fitting.replace(/…$/, '').length).trim();
|
||||
const elements = fitting
|
||||
? spread.elements.map((e) => (e === story ? { ...story, text: fitting, overridden: true } : e))
|
||||
: spread.elements.filter((e) => e !== story);
|
||||
return { spread: { ...spread, elements }, rest };
|
||||
}
|
||||
|
||||
/** A laid page as the auto layout leaves it: date marks as stamps, and only its photos parked. */
|
||||
function tidied(spread: BookSpread): BookSpread {
|
||||
const elements = spread.elements.map((e) =>
|
||||
e.kind === 'badge' && CLASSIC_MARKS.has(e.style) ? { ...e, style: 'stamp' as const } : e,
|
||||
);
|
||||
// The words a template has no role for are the journal's own, bound to it: nothing is lost leaving them.
|
||||
return { ...spread, elements, parked: spread.parked.filter((e) => e.kind === 'photo') };
|
||||
}
|
||||
|
||||
/** The pick context for the next page of the book. */
|
||||
type PickBase = Omit<TemplatePickCtx, 'history' | 'key'>;
|
||||
|
||||
interface Laying {
|
||||
c: ComposeCtx;
|
||||
base: PickBase;
|
||||
/** Template ids laid so far, in order, for the picker's variety and rhythm. */
|
||||
history: string[];
|
||||
entries: readonly AutoEntry[];
|
||||
out: DesignedSpread[];
|
||||
}
|
||||
|
||||
/** A story to set on a page: its words, and whether they are the whole of the entry's. */
|
||||
export type PageStory = { words: string; whole: boolean } | null;
|
||||
|
||||
/** One page as it is laid: the spread, the template it was laid on (none for a composed one) and the story's rest. */
|
||||
export interface LaidPage {
|
||||
spread: BookSpread;
|
||||
template: BookDecoratedTemplate | null;
|
||||
rest: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of an entry laid on the template that suits it best, in the
|
||||
* book's theme, or composed as a page of words where none takes it; the
|
||||
* story cut to its frame, its rest handed back for the next page.
|
||||
*/
|
||||
export function layEntryPage(
|
||||
c: ComposeCtx,
|
||||
entry: AutoEntry,
|
||||
page: { photos: readonly AutoPhoto[]; story: PageStory; entries: readonly AutoEntry[] },
|
||||
pick: TemplatePickCtx,
|
||||
): LaidPage {
|
||||
const mappable = (entry.lat != null && entry.lng != null) || !!c.travel.stats?.points.length;
|
||||
const chunk = { photos: page.photos, story: page.story?.words ?? '', mappable };
|
||||
const tpl = pickInnerTemplate(chunk, pick);
|
||||
if (!tpl) {
|
||||
const fitted = fittedStory(c, composeWordsPage(c, entry, wordsColumn(c, entry, page.story)), entry);
|
||||
return { ...fitted, template: null };
|
||||
}
|
||||
const laid = tidied(pourTemplate(c, entrySeedSpread(c, entry, page.photos, page.story), tpl, page.entries));
|
||||
return { ...fittedStory(c, laid, entry), template: tpl };
|
||||
}
|
||||
|
||||
/** One page of the book laid and added; the rest of its story handed back. */
|
||||
function layPage(
|
||||
l: Laying,
|
||||
entry: AutoEntry,
|
||||
photos: readonly AutoPhoto[],
|
||||
story: PageStory,
|
||||
chapter: string | null,
|
||||
): string {
|
||||
const key = `e${entry.id}:${l.out.length}`;
|
||||
const laid = layEntryPage(l.c, entry, { photos, story, entries: l.entries }, { ...l.base, history: l.history, key });
|
||||
if (laid.template) l.history.push(laid.template.id);
|
||||
l.out.push({
|
||||
spread: laid.spread,
|
||||
beat: laid.template ? beatOf(laid.template, photos.length) : 'story',
|
||||
chapter,
|
||||
findings: [],
|
||||
...(laid.template ? { template: laid.template.id } : {}),
|
||||
});
|
||||
return laid.rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* A story set in a column down the left page, where no template takes it:
|
||||
* bound to the entry while it is the whole of it, for `fittedStory` to cut.
|
||||
*/
|
||||
function wordsColumn(c: ComposeCtx, entry: AutoEntry, story: PageStory) {
|
||||
if (!story?.words) return null;
|
||||
const binding = { source: 'entry.story' as const, entryId: entry.id };
|
||||
const el = textEl(c, liveBox(c, 'left'), story.words, 'body', { binding, overridden: !story.whole });
|
||||
return { ...el, frame: liveBox(c, 'left') };
|
||||
}
|
||||
|
||||
/** The best capacity of the pool's story templates for each photo count, for what a long story costs a page. */
|
||||
function storyRoom(c: ComposeCtx, pool: readonly BookDecoratedTemplate[]): Map<number, number> {
|
||||
const room = new Map<number, number>();
|
||||
for (const t of pool) {
|
||||
const cap = bodyCapacity(t, c.page, c.look.theme);
|
||||
if (cap > 0) room.set(templatePhotoSlots(t), Math.max(room.get(templatePhotoSlots(t)) ?? 0, cap));
|
||||
}
|
||||
return room;
|
||||
}
|
||||
|
||||
/** A first page whose story frame cannot hold the story costs this much more: a page of words follows. */
|
||||
const SHORT_STORY_PAGE = 12;
|
||||
/** A page only another archetype has a template for costs this much more, so as few as can be are. */
|
||||
const FOREIGN_PAGE = 8;
|
||||
|
||||
/**
|
||||
* The photo counts a chapter's pages may take: those of the book's own pool
|
||||
* of templates, the pool the picker lays them from. `wide` adds those only
|
||||
* another archetype has, at a price, for a chapter the own pool would leave
|
||||
* a photo of without a frame.
|
||||
*/
|
||||
function chunkSizesFor(c: ComposeCtx, archetype: BookCoverArchetype, words: string, wide = false): ChunkSizes {
|
||||
const pool = innerPool(archetype);
|
||||
const own = chunkSizesOf(pool);
|
||||
const sizes = wide ? chunkSizesOf(decoratedTemplatesFor('inner')) : own;
|
||||
const room = storyRoom(c, words ? (wide ? decoratedTemplatesFor('inner') : pool) : []);
|
||||
const pageCost = (s: number, first: boolean) => {
|
||||
const foreign = !(first && words ? own.story : own.any).has(s);
|
||||
const short = first && words && (room.get(s) ?? 0) < words.length;
|
||||
return (foreign ? FOREIGN_PAGE : 0) + (short ? SHORT_STORY_PAGE : 0);
|
||||
};
|
||||
return { any: sizes.any, story: sizes.story, pageCost };
|
||||
}
|
||||
|
||||
/** A chapter's photos cut into pages of the own pool, or of any where the own pool leaves one without a frame. */
|
||||
function cutChapter(c: ComposeCtx, archetype: BookCoverArchetype, words: string, photos: number) {
|
||||
const own = chunkPhotos(photos, !!words, chunkSizesFor(c, archetype, words));
|
||||
if (!own.parked) return own;
|
||||
const wide = chunkPhotos(photos, !!words, chunkSizesFor(c, archetype, words, true));
|
||||
return wide.parked < own.parked ? wide : own;
|
||||
}
|
||||
|
||||
/** A chapter's pages: its photos cut into parts, the first taking its first entry's words, then the rest of them. */
|
||||
function chapterPages(l: Laying, chapter: DesignChapter, notes: string[]) {
|
||||
const entry = chapter.entries[0];
|
||||
if (!entry) return;
|
||||
const name = chapter.label ?? entry.title ?? null;
|
||||
const words = storyOf(l.c, entry);
|
||||
const cut = cutChapter(l.c, l.base.archetype, words, chapter.photos.length);
|
||||
let at = 0;
|
||||
let rest = '';
|
||||
cut.sizes.forEach((size, i) => {
|
||||
const story = i === 0 && words ? { words, whole: words === (entry.story ?? '').trim() } : null;
|
||||
rest = layPage(l, entry, chapter.photos.slice(at, at + size), story, name) || rest;
|
||||
at += size;
|
||||
});
|
||||
const parked = chapter.photos.slice(at);
|
||||
const last = l.out[l.out.length - 1];
|
||||
if (parked.length && last) {
|
||||
last.spread = {
|
||||
...last.spread,
|
||||
parked: [...last.spread.parked, ...parked.map((p) => photoEl(l.c, p, liveBox(l.c, 'right')))],
|
||||
};
|
||||
const what = parked.length === 1 ? '1 photo' : `${parked.length} photos`;
|
||||
notes.push(`${what} of entry ${entry.id} found no frame and wait with its last page`);
|
||||
}
|
||||
for (let n = 0; rest && n < MOST_CONTINUED; n++) rest = layPage(l, entry, [], { words: rest, whole: false }, name);
|
||||
if (rest) notes.push(`part of the story of entry ${entry.id} found no room on its pages and is left out`);
|
||||
// The further entries of a chapter of a day or a place each tell their own story on a page of words.
|
||||
for (const more of chapter.entries.slice(1)) {
|
||||
const told = storyOf(l.c, more);
|
||||
if (told) layPage(l, more, [], { words: told, whole: told === (more.story ?? '').trim() }, name);
|
||||
}
|
||||
}
|
||||
|
||||
/** The most stops one page of the list takes, so a page of them still reads as a list. */
|
||||
const STOPS_PER_PAGE = 10;
|
||||
|
||||
/** Bare stops as a list: under each date the names of the stops of that day, a blank line between days. */
|
||||
function stopList(c: ComposeCtx, stops: readonly AutoEntry[], dated: boolean): string {
|
||||
const days: { date: string; names: string[] }[] = [];
|
||||
for (const e of stops) {
|
||||
const date = e.date && dated ? formatBookDate(e.date, c.locale) : '';
|
||||
const name = (e.title || e.location || '').trim();
|
||||
if (!name) continue;
|
||||
const day = days[days.length - 1];
|
||||
if (day && day.date === date) day.names.push(name);
|
||||
else days.push({ date, names: [name] });
|
||||
}
|
||||
return days.map((d) => [d.date, ...d.names].filter(Boolean).join('\n')).join('\n\n');
|
||||
}
|
||||
|
||||
/** The one day all these stops were on, bound to the first of them, as a date role takes it; null without one. */
|
||||
function oneDay(c: ComposeCtx, stops: readonly AutoEntry[]): BookTemplateWord | null {
|
||||
const days = new Set(stops.map((e) => e.date ?? ''));
|
||||
const [day] = days;
|
||||
const first = stops[0];
|
||||
if (days.size !== 1 || !day || !first) return null;
|
||||
return { text: formatBookDate(day, c.locale), binding: { source: 'entry.date', entryId: first.id, value: day } };
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of bare stops on a words-only template of the book's styles: the
|
||||
* list's label as its title, the stops as its story (given by role, since a
|
||||
* short list that opens on a date would read as a date line), as many as its
|
||||
* story frame holds. Stops of one day set that day in the template's date
|
||||
* and only their names in the list. Undefined when no template takes a list.
|
||||
*/
|
||||
function stationsPage(l: Laying, stops: readonly AutoEntry[], label: string) {
|
||||
for (let n = Math.min(stops.length, STOPS_PER_PAGE); n > 0; n--) {
|
||||
const some = stops.slice(0, n);
|
||||
const key = `stations:${l.out.length}`;
|
||||
const pick = (list: string) =>
|
||||
pickInnerTemplate({ photos: [], story: list, mappable: false }, { ...l.base, history: l.history, key });
|
||||
const tpl = pick(stopList(l.c, some, true));
|
||||
if (!tpl) return undefined;
|
||||
const day = templateRoles(tpl).includes('date') ? oneDay(l.c, some) : null;
|
||||
const list = stopList(l.c, some, !day);
|
||||
if (bodyCapacity(tpl, l.c.page, l.c.look.theme) < list.length && n > 1) continue;
|
||||
const words = {
|
||||
title: { text: label },
|
||||
body: { text: list },
|
||||
...(day ? { date: day } : {}),
|
||||
};
|
||||
return { spread: tidied(pourTemplate(l.c, innerSpread(l.c, [], null), tpl, l.entries, words)), tpl, placed: n };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The pages of a list of bare stops: as many as the stops need, each on a words-only template where one takes it. */
|
||||
function stationsPages(l: Laying, chapter: DesignChapter, input: BookDesignInput) {
|
||||
let rest = chapter.entries;
|
||||
while (rest.length) {
|
||||
const page = stationsPage(l, rest, input.stationsLabel);
|
||||
if (page) {
|
||||
l.history.push(page.tpl.id);
|
||||
l.out.push({ spread: page.spread, beat: 'stations', chapter: null, findings: [], template: page.tpl.id });
|
||||
rest = rest.slice(page.placed);
|
||||
continue;
|
||||
}
|
||||
const set = composeStations(l.c, rest, input.stationsLabel);
|
||||
if (!set.placed) break;
|
||||
l.out.push({ spread: innerSpread(l.c, set.elements, null), beat: 'stations', chapter: null, findings: [] });
|
||||
rest = rest.slice(set.placed);
|
||||
}
|
||||
}
|
||||
|
||||
/** The closing pages of more moments: the photos no entry had room for, on grids, at most three pages. */
|
||||
function moreMoments(l: Laying, rest: readonly AutoPhoto[], leftOut: BookDesignLeftOut[]) {
|
||||
let left = [...rest];
|
||||
const sizes = chunkSizesOf(innerPool(l.base.archetype)).any;
|
||||
const biggest = Math.max(...[...sizes].filter((s) => s > 0), 1);
|
||||
for (let n = 0; left.length && n < MORE_MOMENTS; n++) {
|
||||
const take = left.slice(0, biggest);
|
||||
const tpl = pickInnerTemplate(
|
||||
{ photos: take, story: '', mappable: false },
|
||||
{ ...l.base, history: l.history, key: `more:${n}` },
|
||||
);
|
||||
if (!tpl) break;
|
||||
const used = take.slice(0, templatePhotoSlots(tpl));
|
||||
const seed = innerSpread(
|
||||
l.c,
|
||||
used.map((p) => photoEl(l.c, p, liveBox(l.c, 'right'))),
|
||||
null,
|
||||
);
|
||||
const laid = tidied(pourTemplate(l.c, seed, tpl, l.entries));
|
||||
l.history.push(tpl.id);
|
||||
l.out.push({ spread: laid, beat: beatOf(tpl, used.length), chapter: null, findings: [], template: tpl.id });
|
||||
left = left.slice(used.length);
|
||||
}
|
||||
for (const p of left) leftOut.push({ photoId: p.photoId, reason: 'page_budget' });
|
||||
}
|
||||
|
||||
/** The pages past the budget are left out from the end, their photos said to be left out for it. */
|
||||
function withinCap(body: DesignedSpread[], keep: number, leftOut: BookDesignLeftOut[], notes: string[]) {
|
||||
if (body.length <= keep) return body;
|
||||
const cut = body.slice(Math.max(0, keep));
|
||||
for (const d of cut) {
|
||||
for (const e of [...d.spread.elements, ...d.spread.parked]) {
|
||||
if (e.kind === 'photo' && e.photoId != null) leftOut.push({ photoId: e.photoId, reason: 'page_budget' });
|
||||
}
|
||||
}
|
||||
notes.push(`${cut.length} pages past the page budget are left out`);
|
||||
return body.slice(0, Math.max(0, keep));
|
||||
}
|
||||
|
||||
/** The journey at a glance and its countries, as the directives allow them. */
|
||||
function framingPages(c: ComposeCtx, archetype: BookCoverArchetype): DesignedSpread[] {
|
||||
const travel = c.directives.travel;
|
||||
const out: DesignedSpread[] = [];
|
||||
const summary = (travel?.summary ?? true) ? composeTravelSummary(c, summaryLookFor(archetype)) : null;
|
||||
if (summary) out.push({ spread: summary, beat: 'summary', chapter: null, findings: [] });
|
||||
const countries = (travel?.countries ?? true) ? composeTravelCountries(c, countryLookFor(archetype)) : null;
|
||||
if (countries) out.push({ spread: countries, beat: 'countries', chapter: null, findings: [] });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every inner page of a book laid on the designed templates, in order. */
|
||||
export function composeTemplatedInner(
|
||||
c: ComposeCtx,
|
||||
entries: readonly AutoEntry[],
|
||||
choice: DesignPhotoChoice,
|
||||
input: BookDesignInput,
|
||||
needs: FramingNeeds,
|
||||
plan: TemplatedPlan,
|
||||
): TemplatedInnerResult {
|
||||
const notes: string[] = [];
|
||||
const leftOut: BookDesignLeftOut[] = [];
|
||||
const cap = Math.max(0, Math.min(c.directives.pages?.max ?? needs.room, needs.room));
|
||||
const head = framingPages(c, plan.archetype).slice(0, Math.max(0, cap - 1));
|
||||
const base: PickBase = {
|
||||
archetype: plan.archetype,
|
||||
page: c.page,
|
||||
theme: c.look.theme,
|
||||
calm: plan.calm,
|
||||
seed: c.directives.seed ?? 0,
|
||||
};
|
||||
const l: Laying = { c, base, history: [], entries, out: [] };
|
||||
for (const chapter of buildChapters(entries, choice.byEntry, c.directives, input.candidates)) {
|
||||
if (chapter.stations) stationsPages(l, chapter, input);
|
||||
else chapterPages(l, chapter, notes);
|
||||
}
|
||||
moreMoments(l, plan.rest, leftOut);
|
||||
const body = withinCap(l.out, cap - head.length, leftOut, notes);
|
||||
return { spreads: [...head, ...body], leftOut, notes };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,844 @@
|
||||
import type { BookDecoratedTemplate } from '../template-format';
|
||||
|
||||
/**
|
||||
* More grid and badge pages: a cover of eight tiles round a red centre
|
||||
* badge, a words only spread with a large seal and the story, and a spread
|
||||
* of a hero photo with a scalloped badge beside six tall tiles. Drawn in
|
||||
* grid-badge.
|
||||
*
|
||||
* Drawn on a 210 mm page and kept as fractions of it (template-format.ts),
|
||||
* in the colours and faces of the theme named above; their links let any
|
||||
* other theme recolour them.
|
||||
*/
|
||||
export const MORE_GRID_BADGE: readonly BookDecoratedTemplate[] = [
|
||||
{
|
||||
id: 'grid-badge-tiles',
|
||||
labelKey: 'studio.templates.name.gridBadgeTiles',
|
||||
kind: 'single',
|
||||
archetype: 'grid-badge',
|
||||
tags: ['grid', 'eight-photos', 'badge', 'tiles', 'red', 'postmark', 'graphic'],
|
||||
descr:
|
||||
'Eight bleed photos three by three with 4 mm white gutters round a red centre tile holding a white hairline ring, a script line, the title in heavy capitals, a short rule and the year, an ink postmark on its corner.',
|
||||
background: null,
|
||||
backgroundToken: 'paper',
|
||||
elements: [
|
||||
{
|
||||
id: 'p1',
|
||||
role: 'photo:1',
|
||||
frame: { x: -0.0143, y: -0.0143, w: 0.3302, h: 0.3302 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
kind: 'photo',
|
||||
photoId: null,
|
||||
fit: 'cover',
|
||||
focalX: 0.5,
|
||||
focalY: 0.5,
|
||||
radius: 0,
|
||||
filter: 'none',
|
||||
mask: null,
|
||||
frameStyle: 'none',
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
role: 'photo:2',
|
||||
frame: { x: 0.3349, y: -0.0143, w: 0.3302, h: 0.3302 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
kind: 'photo',
|
||||
photoId: null,
|
||||
fit: 'cover',
|
||||
focalX: 0.5,
|
||||
focalY: 0.5,
|
||||
radius: 0,
|
||||
filter: 'none',
|
||||
mask: null,
|
||||
frameStyle: 'none',
|
||||
},
|
||||
{
|
||||
id: 'p3',
|
||||
role: 'photo:3',
|
||||
frame: { x: 0.6841, y: -0.0143, w: 0.3302, h: 0.3302 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
kind: 'photo',
|
||||
photoId: null,
|
||||
fit: 'cover',
|
||||
focalX: 0.5,
|
||||
focalY: 0.5,
|
||||
radius: 0,
|
||||
filter: 'none',
|
||||
mask: null,
|
||||
frameStyle: 'none',
|
||||
},
|
||||
{
|
||||
id: 'p4',
|
||||
role: 'photo:4',
|
||||
frame: { x: -0.0143, y: 0.3349, w: 0.3302, h: 0.3302 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
kind: 'photo',
|
||||
photoId: null,
|
||||
fit: 'cover',
|
||||
focalX: 0.5,
|
||||
focalY: 0.5,
|
||||
radius: 0,
|
||||
filter: 'none',
|
||||
mask: null,
|
||||
frameStyle: 'none',
|
||||
},
|
||||
{
|
||||
id: 'p5',
|
||||
role: 'photo:5',
|
||||
frame: { x: 0.6841, y: 0.3349, w: 0.3302, h: 0.3302 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
kind: 'photo',
|
||||
photoId: null,
|
||||
fit: 'cover',
|
||||
focalX: 0.5,
|
||||
focalY: 0.5,
|
||||
radius: 0,
|
||||
filter: 'none',
|
||||
mask: null,
|
||||
frameStyle: 'none',
|
||||
},
|
||||
{
|
||||
id: 'p6',
|
||||
role: 'photo:6',
|
||||
frame: { x: -0.0143, y: 0.6841, w: 0.3302, h: 0.3302 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
kind: 'photo',
|
||||
photoId: null,
|
||||
fit: 'cover',
|
||||
focalX: 0.5,
|
||||
focalY: 0.5,
|
||||
radius: 0,
|
||||
filter: 'none',
|
||||
mask: null,
|
||||
frameStyle: 'none',
|
||||
},
|
||||
{
|
||||
id: 'p7',
|
||||
role: 'photo:7',
|
||||
frame: { x: 0.3349, y: 0.6841, w: 0.3302, h: 0.3302 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
kind: 'photo',
|
||||
photoId: null,
|
||||
fit: 'cover',
|
||||
focalX: 0.5,
|
||||
focalY: 0.5,
|
||||
radius: 0,
|
||||
filter: 'none',
|
||||
mask: null,
|
||||
frameStyle: 'none',
|
||||
},
|
||||
{
|
||||
id: 'p8',
|
||||
role: 'photo:8',
|
||||
frame: { x: 0.6841, y: 0.6841, w: 0.3302, h: 0.3302 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
kind: 'photo',
|
||||
photoId: null,
|
||||
fit: 'cover',
|
||||
focalX: 0.5,
|
||||
focalY: 0.5,
|
||||
radius: 0,
|
||||
filter: 'none',
|
||||
mask: null,
|
||||
frameStyle: 'none',
|
||||
},
|
||||
{
|
||||
id: 's9',
|
||||
frame: { x: 0.3349, y: 0.3349, w: 0.3302, h: 0.3302 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'badge',
|
||||
kind: 'shape',
|
||||
shape: 'rect',
|
||||
fill: '#e2412f',
|
||||
gradient: 'none',
|
||||
stroke: null,
|
||||
token: 'accent',
|
||||
strokeWidth: 0,
|
||||
strokeStyle: 'solid',
|
||||
radius: 0,
|
||||
},
|
||||
{
|
||||
id: 's10',
|
||||
keep: true,
|
||||
frame: { x: 0.3619, y: 0.3619, w: 0.2762, h: 0.2762 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'badge',
|
||||
kind: 'shape',
|
||||
shape: 'ellipse',
|
||||
fill: null,
|
||||
gradient: 'none',
|
||||
stroke: '#ffffff',
|
||||
strokeToken: 'paper',
|
||||
strokeWidth: 0.0019,
|
||||
strokeStyle: 'solid',
|
||||
radius: 0,
|
||||
},
|
||||
{
|
||||
id: 't11',
|
||||
role: 'subtitle',
|
||||
keep: true,
|
||||
on: 'accent',
|
||||
frame: { x: 0.4048, y: 0.3952, w: 0.1905, h: 0.0476 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'badge',
|
||||
kind: 'text',
|
||||
text: '',
|
||||
font: 'allura',
|
||||
size: 0.0667,
|
||||
weight: 400,
|
||||
italic: false,
|
||||
align: 'center',
|
||||
leading: 1,
|
||||
tracking: 0,
|
||||
color: '#ffffff',
|
||||
token: 'paper',
|
||||
fontRole: 'script',
|
||||
binding: null,
|
||||
overridden: false,
|
||||
vAlign: 'bottom',
|
||||
fit: 'shrink',
|
||||
fitMax: 0.0667,
|
||||
},
|
||||
{
|
||||
id: 't12',
|
||||
role: 'title',
|
||||
keep: true,
|
||||
on: 'accent',
|
||||
frame: { x: 0.3857, y: 0.4476, w: 0.2286, h: 0.0857 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'badge',
|
||||
kind: 'text',
|
||||
text: '',
|
||||
font: 'montserrat',
|
||||
size: 0.0952,
|
||||
weight: 800,
|
||||
italic: false,
|
||||
align: 'center',
|
||||
leading: 0.95,
|
||||
tracking: 0,
|
||||
color: '#ffffff',
|
||||
token: 'paper',
|
||||
fontRole: 'display',
|
||||
binding: null,
|
||||
overridden: false,
|
||||
transform: 'upper',
|
||||
vAlign: 'middle',
|
||||
fit: 'shrink',
|
||||
fitMax: 0.0952,
|
||||
},
|
||||
{
|
||||
id: 's13',
|
||||
keep: true,
|
||||
frame: { x: 0.4762, y: 0.5429, w: 0.0476, h: 0.0038 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'badge',
|
||||
kind: 'shape',
|
||||
shape: 'rect',
|
||||
fill: '#ffffff',
|
||||
gradient: 'none',
|
||||
stroke: null,
|
||||
token: 'paper',
|
||||
strokeWidth: 0,
|
||||
strokeStyle: 'solid',
|
||||
radius: 0,
|
||||
},
|
||||
{
|
||||
id: 't14',
|
||||
role: 'date',
|
||||
keep: true,
|
||||
on: 'accent',
|
||||
frame: { x: 0.4048, y: 0.5571, w: 0.1905, h: 0.0333 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'badge',
|
||||
kind: 'text',
|
||||
text: '',
|
||||
font: 'montserrat',
|
||||
size: 0.0333,
|
||||
weight: 600,
|
||||
italic: false,
|
||||
align: 'center',
|
||||
leading: 1,
|
||||
tracking: 0.4,
|
||||
color: '#ffffff',
|
||||
token: 'paper',
|
||||
fontRole: 'heading',
|
||||
binding: null,
|
||||
overridden: false,
|
||||
transform: 'upper',
|
||||
vAlign: 'middle',
|
||||
fit: 'shrink',
|
||||
fitMax: 0.0333,
|
||||
},
|
||||
{
|
||||
id: 's15',
|
||||
tint: { colors: ['ink'] },
|
||||
frame: { x: 0.6, y: 0.6476, w: 0.1714, h: 0.0997 },
|
||||
rotation: -12,
|
||||
opacity: 0.85,
|
||||
locked: false,
|
||||
kind: 'sticker',
|
||||
stickerId: 'postmark',
|
||||
colors: ['#141414'],
|
||||
flipX: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'grid-badge-letter',
|
||||
labelKey: 'studio.templates.name.gridBadgeLetter',
|
||||
kind: 'inner',
|
||||
archetype: 'grid-badge',
|
||||
tags: ['words-only', 'story', 'seal', 'badge', 'squared-paper', 'airmail', 'calm'],
|
||||
descr:
|
||||
'The left page cream squared paper with a large red seal holding the subtitle round its rim, the title in heavy capitals and the date; the right page a dashed airmail line to a small plane, the long story and a passport stamp.',
|
||||
background: null,
|
||||
backgroundToken: 'paper',
|
||||
elements: [
|
||||
{
|
||||
id: 's1',
|
||||
tint: { fg: 'accent3', bg: 'accent2' },
|
||||
frame: { x: -0.0143, y: -0.0143, w: 1.0143, h: 1.0286 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
kind: 'shape',
|
||||
shape: 'rect',
|
||||
fill: '#6f6f6f',
|
||||
gradient: 'none',
|
||||
paint: { type: 'pattern', pattern: 'grid', fg: '#6f6f6f', bg: '#f2eee7', scale: 1.2, angle: 0 },
|
||||
stroke: null,
|
||||
token: 'accent3',
|
||||
strokeWidth: 0,
|
||||
strokeStyle: 'solid',
|
||||
radius: 0,
|
||||
},
|
||||
{
|
||||
id: 's2',
|
||||
keep: true,
|
||||
frame: { x: 0.1857, y: 0.1457, w: 0.6286, h: 0.6286 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'badge',
|
||||
kind: 'shape',
|
||||
shape: 'seal',
|
||||
fill: '#e2412f',
|
||||
gradient: 'none',
|
||||
stroke: null,
|
||||
token: 'accent',
|
||||
strokeWidth: 0,
|
||||
strokeStyle: 'solid',
|
||||
radius: 0,
|
||||
},
|
||||
{
|
||||
id: 's3',
|
||||
keep: true,
|
||||
frame: { x: 0.2429, y: 0.2029, w: 0.5143, h: 0.5143 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'badge',
|
||||
kind: 'shape',
|
||||
shape: 'ellipse',
|
||||
fill: null,
|
||||
gradient: 'none',
|
||||
stroke: '#ffffff',
|
||||
strokeToken: 'paper',
|
||||
strokeWidth: 0.0019,
|
||||
strokeStyle: 'solid',
|
||||
radius: 0,
|
||||
},
|
||||
{
|
||||
id: 't4',
|
||||
role: 'subtitle',
|
||||
keep: true,
|
||||
on: 'accent',
|
||||
frame: { x: 0.2619, y: 0.2219, w: 0.4762, h: 0.4762 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'badge',
|
||||
kind: 'text',
|
||||
text: '',
|
||||
font: 'montserrat',
|
||||
size: 0.0429,
|
||||
weight: 600,
|
||||
italic: false,
|
||||
align: 'center',
|
||||
leading: 1,
|
||||
tracking: 0.3,
|
||||
color: '#ffffff',
|
||||
token: 'paper',
|
||||
fontRole: 'heading',
|
||||
binding: null,
|
||||
overridden: false,
|
||||
transform: 'upper',
|
||||
vAlign: 'middle',
|
||||
curve: { type: 'circle', bend: 40, start: 0 },
|
||||
fit: 'shrink',
|
||||
fitMax: 0.0429,
|
||||
},
|
||||
{
|
||||
id: 't5',
|
||||
role: 'title',
|
||||
keep: true,
|
||||
on: 'accent',
|
||||
frame: { x: 0.3095, y: 0.3905, w: 0.381, h: 0.1238 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'badge',
|
||||
kind: 'text',
|
||||
text: '',
|
||||
font: 'montserrat',
|
||||
size: 0.1524,
|
||||
weight: 800,
|
||||
italic: false,
|
||||
align: 'center',
|
||||
leading: 0.95,
|
||||
tracking: 0,
|
||||
color: '#ffffff',
|
||||
token: 'paper',
|
||||
fontRole: 'display',
|
||||
binding: null,
|
||||
overridden: false,
|
||||
transform: 'upper',
|
||||
vAlign: 'middle',
|
||||
fit: 'shrink',
|
||||
fitMax: 0.1524,
|
||||
},
|
||||
{
|
||||
id: 's6',
|
||||
keep: true,
|
||||
frame: { x: 0.4619, y: 0.5238, w: 0.0762, h: 0.0038 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'badge',
|
||||
kind: 'shape',
|
||||
shape: 'rect',
|
||||
fill: '#ffffff',
|
||||
gradient: 'none',
|
||||
stroke: null,
|
||||
token: 'paper',
|
||||
strokeWidth: 0,
|
||||
strokeStyle: 'solid',
|
||||
radius: 0,
|
||||
},
|
||||
{
|
||||
id: 't7',
|
||||
role: 'date',
|
||||
keep: true,
|
||||
on: 'accent',
|
||||
frame: { x: 0.3333, y: 0.5381, w: 0.3333, h: 0.0381 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'badge',
|
||||
kind: 'text',
|
||||
text: '',
|
||||
font: 'montserrat',
|
||||
size: 0.0381,
|
||||
weight: 600,
|
||||
italic: false,
|
||||
align: 'center',
|
||||
leading: 1,
|
||||
tracking: 0.4,
|
||||
color: '#ffffff',
|
||||
token: 'paper',
|
||||
fontRole: 'heading',
|
||||
binding: null,
|
||||
overridden: false,
|
||||
transform: 'upper',
|
||||
vAlign: 'middle',
|
||||
fit: 'shrink',
|
||||
fitMax: 0.0381,
|
||||
},
|
||||
{
|
||||
id: 'l8',
|
||||
tint: { stroke: 'ink' },
|
||||
frame: { x: 1.0762, y: 0.0833, w: 0.6762, h: 0.0143 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'head',
|
||||
kind: 'line',
|
||||
points: [
|
||||
{ x: 0, y: 0.5 },
|
||||
{ x: 1, y: 0.5 },
|
||||
],
|
||||
curve: 'straight',
|
||||
stroke: '#141414',
|
||||
strokeWidth: 0.0029,
|
||||
dash: 'dashed',
|
||||
squiggle: 'none',
|
||||
startCap: 'none',
|
||||
endCap: 'none',
|
||||
wobble: 0,
|
||||
ornament: 'none',
|
||||
},
|
||||
{
|
||||
id: 's9',
|
||||
tint: { colors: ['accent3', 'accent', 'paper'] },
|
||||
frame: { x: 1.7714, y: 0.0476, w: 0.1524, h: 0.0912 },
|
||||
rotation: -8,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'head',
|
||||
kind: 'sticker',
|
||||
stickerId: 'airplane',
|
||||
colors: ['#6f6f6f', '#e2412f', '#ffffff'],
|
||||
flipX: false,
|
||||
},
|
||||
{
|
||||
id: 't10',
|
||||
role: 'body',
|
||||
frame: { x: 1.0762, y: 0.1619, w: 0.6667, h: 0.7333 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
kind: 'text',
|
||||
text: '',
|
||||
font: 'inter',
|
||||
size: 0.0476,
|
||||
weight: 400,
|
||||
italic: false,
|
||||
align: 'left',
|
||||
leading: 1.55,
|
||||
tracking: 0,
|
||||
color: '#141414',
|
||||
token: 'ink',
|
||||
fontRole: 'body',
|
||||
binding: null,
|
||||
overridden: false,
|
||||
vAlign: 'top',
|
||||
},
|
||||
{
|
||||
id: 's11',
|
||||
tint: { colors: ['accent'] },
|
||||
frame: { x: 1.7524, y: 0.7333, w: 0.1905, h: 0.1333 },
|
||||
rotation: 14,
|
||||
opacity: 0.9,
|
||||
locked: false,
|
||||
kind: 'sticker',
|
||||
stickerId: 'passportStamp',
|
||||
colors: ['#e2412f'],
|
||||
flipX: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'grid-badge-hero-six',
|
||||
labelKey: 'studio.templates.name.gridBadgeHeroSix',
|
||||
kind: 'inner',
|
||||
archetype: 'grid-badge',
|
||||
tags: ['grid', 'seven-photos', 'hero', 'badge', 'scalloped', 'full-bleed', 'graphic'],
|
||||
descr:
|
||||
'A bleed hero photo on the left page with a white scalloped badge low on it, a dashed red ring, a red pin, the subtitle, the title and the date; six tall photos three by two on the right page with 3 mm white gutters.',
|
||||
background: null,
|
||||
backgroundToken: 'paper',
|
||||
elements: [
|
||||
{
|
||||
id: 'p1',
|
||||
role: 'photo:1',
|
||||
frame: { x: -0.0143, y: -0.0143, w: 1.0072, h: 1.0286 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
kind: 'photo',
|
||||
photoId: null,
|
||||
fit: 'cover',
|
||||
focalX: 0.6,
|
||||
focalY: 0.5,
|
||||
radius: 0,
|
||||
filter: 'none',
|
||||
mask: null,
|
||||
frameStyle: 'none',
|
||||
},
|
||||
{
|
||||
id: 'p2',
|
||||
role: 'photo:2',
|
||||
frame: { x: 1.0071, y: -0.0143, w: 0.3262, h: 0.5071 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
kind: 'photo',
|
||||
photoId: null,
|
||||
fit: 'cover',
|
||||
focalX: 0.5,
|
||||
focalY: 0.5,
|
||||
radius: 0,
|
||||
filter: 'none',
|
||||
mask: null,
|
||||
frameStyle: 'none',
|
||||
},
|
||||
{
|
||||
id: 'p3',
|
||||
role: 'photo:3',
|
||||
frame: { x: 1.3476, y: -0.0143, w: 0.3262, h: 0.5071 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
kind: 'photo',
|
||||
photoId: null,
|
||||
fit: 'cover',
|
||||
focalX: 0.5,
|
||||
focalY: 0.5,
|
||||
radius: 0,
|
||||
filter: 'none',
|
||||
mask: null,
|
||||
frameStyle: 'none',
|
||||
},
|
||||
{
|
||||
id: 'p4',
|
||||
role: 'photo:4',
|
||||
frame: { x: 1.6881, y: -0.0143, w: 0.3262, h: 0.5071 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
kind: 'photo',
|
||||
photoId: null,
|
||||
fit: 'cover',
|
||||
focalX: 0.5,
|
||||
focalY: 0.5,
|
||||
radius: 0,
|
||||
filter: 'none',
|
||||
mask: null,
|
||||
frameStyle: 'none',
|
||||
},
|
||||
{
|
||||
id: 'p5',
|
||||
role: 'photo:5',
|
||||
frame: { x: 1.0071, y: 0.5071, w: 0.3262, h: 0.5071 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
kind: 'photo',
|
||||
photoId: null,
|
||||
fit: 'cover',
|
||||
focalX: 0.5,
|
||||
focalY: 0.5,
|
||||
radius: 0,
|
||||
filter: 'none',
|
||||
mask: null,
|
||||
frameStyle: 'none',
|
||||
},
|
||||
{
|
||||
id: 'p6',
|
||||
role: 'photo:6',
|
||||
frame: { x: 1.3476, y: 0.5071, w: 0.3262, h: 0.5071 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
kind: 'photo',
|
||||
photoId: null,
|
||||
fit: 'cover',
|
||||
focalX: 0.5,
|
||||
focalY: 0.5,
|
||||
radius: 0,
|
||||
filter: 'none',
|
||||
mask: null,
|
||||
frameStyle: 'none',
|
||||
},
|
||||
{
|
||||
id: 'p7',
|
||||
role: 'photo:7',
|
||||
frame: { x: 1.6881, y: 0.5071, w: 0.3262, h: 0.5071 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
kind: 'photo',
|
||||
photoId: null,
|
||||
fit: 'cover',
|
||||
focalX: 0.5,
|
||||
focalY: 0.5,
|
||||
radius: 0,
|
||||
filter: 'none',
|
||||
mask: null,
|
||||
frameStyle: 'none',
|
||||
},
|
||||
{
|
||||
id: 's8',
|
||||
keep: true,
|
||||
frame: { x: 0.0857, y: 0.5333, w: 0.381, h: 0.381 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'badge',
|
||||
kind: 'shape',
|
||||
shape: 'scallop-circle',
|
||||
fill: '#ffffff',
|
||||
gradient: 'none',
|
||||
stroke: null,
|
||||
token: 'paper',
|
||||
strokeWidth: 0,
|
||||
strokeStyle: 'solid',
|
||||
radius: 0,
|
||||
},
|
||||
{
|
||||
id: 's9',
|
||||
keep: true,
|
||||
frame: { x: 0.1048, y: 0.5524, w: 0.3429, h: 0.3429 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'badge',
|
||||
kind: 'shape',
|
||||
shape: 'ellipse',
|
||||
fill: null,
|
||||
gradient: 'none',
|
||||
stroke: '#e2412f',
|
||||
strokeToken: 'accent',
|
||||
strokeWidth: 0.0029,
|
||||
strokeStyle: 'dashed',
|
||||
radius: 0,
|
||||
},
|
||||
{
|
||||
id: 's10',
|
||||
tint: { colors: ['accent', 'paper'] },
|
||||
frame: { x: 0.2381, y: 0.4952, w: 0.0762, h: 0.1088 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'badge',
|
||||
kind: 'sticker',
|
||||
stickerId: 'mapPin',
|
||||
colors: ['#e2412f', '#ffffff'],
|
||||
flipX: false,
|
||||
},
|
||||
{
|
||||
id: 't11',
|
||||
role: 'subtitle',
|
||||
keep: true,
|
||||
on: 'paper',
|
||||
frame: { x: 0.1381, y: 0.6333, w: 0.2762, h: 0.0333 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'badge',
|
||||
kind: 'text',
|
||||
text: '',
|
||||
font: 'montserrat',
|
||||
size: 0.031,
|
||||
weight: 600,
|
||||
italic: false,
|
||||
align: 'center',
|
||||
leading: 1,
|
||||
tracking: 0.32,
|
||||
color: '#5b5b5b',
|
||||
token: 'muted',
|
||||
fontRole: 'heading',
|
||||
binding: null,
|
||||
overridden: false,
|
||||
transform: 'upper',
|
||||
vAlign: 'middle',
|
||||
fit: 'shrink',
|
||||
fitMax: 0.031,
|
||||
},
|
||||
{
|
||||
id: 't12',
|
||||
role: 'title',
|
||||
keep: true,
|
||||
on: 'paper',
|
||||
frame: { x: 0.1238, y: 0.6762, w: 0.3048, h: 0.1048 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'badge',
|
||||
kind: 'text',
|
||||
text: '',
|
||||
font: 'montserrat',
|
||||
size: 0.1238,
|
||||
weight: 800,
|
||||
italic: false,
|
||||
align: 'center',
|
||||
leading: 0.95,
|
||||
tracking: 0,
|
||||
color: '#141414',
|
||||
token: 'ink',
|
||||
fontRole: 'display',
|
||||
binding: null,
|
||||
overridden: false,
|
||||
transform: 'upper',
|
||||
vAlign: 'middle',
|
||||
fit: 'shrink',
|
||||
fitMax: 0.1238,
|
||||
},
|
||||
{
|
||||
id: 's13',
|
||||
keep: true,
|
||||
frame: { x: 0.2381, y: 0.7905, w: 0.0762, h: 0.0038 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'badge',
|
||||
kind: 'shape',
|
||||
shape: 'rect',
|
||||
fill: '#e2412f',
|
||||
gradient: 'none',
|
||||
stroke: null,
|
||||
token: 'accent',
|
||||
strokeWidth: 0,
|
||||
strokeStyle: 'solid',
|
||||
radius: 0,
|
||||
},
|
||||
{
|
||||
id: 't14',
|
||||
role: 'date',
|
||||
keep: true,
|
||||
on: 'paper',
|
||||
frame: { x: 0.1714, y: 0.8048, w: 0.2095, h: 0.0333 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'badge',
|
||||
kind: 'text',
|
||||
text: '',
|
||||
font: 'montserrat',
|
||||
size: 0.0333,
|
||||
weight: 600,
|
||||
italic: false,
|
||||
align: 'center',
|
||||
leading: 1,
|
||||
tracking: 0.4,
|
||||
color: '#141414',
|
||||
token: 'ink',
|
||||
fontRole: 'heading',
|
||||
binding: null,
|
||||
overridden: false,
|
||||
transform: 'upper',
|
||||
vAlign: 'middle',
|
||||
fit: 'shrink',
|
||||
fitMax: 0.0333,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,637 @@
|
||||
import type { BookDecoratedTemplate } from '../template-format';
|
||||
|
||||
/**
|
||||
* More minimal serif pages: an oval cameo cover inside a hairline frame, a
|
||||
* words only essay spread and a triptych of three numbered portrait plates.
|
||||
* Drawn in minimal-serif.
|
||||
*
|
||||
* Drawn on a 210 mm page and kept as fractions of it (template-format.ts),
|
||||
* in the colours and faces of the theme named above; their links let any
|
||||
* other theme recolour them.
|
||||
*/
|
||||
export const MORE_MINIMAL_SERIF: readonly BookDecoratedTemplate[] = [
|
||||
{
|
||||
id: 'minimal-serif-cameo',
|
||||
labelKey: 'studio.templates.name.minimalSerifCameo',
|
||||
kind: 'single',
|
||||
archetype: 'minimal-serif',
|
||||
tags: ['minimal', 'serif', 'white', 'oval', 'cameo', 'hairline', 'elegant'],
|
||||
descr:
|
||||
'A white page inside a hairline frame: the year in spaced capitals at the head, an oval photo in a fine accent ring, a large thin italic serif title, a diamond rule and the subtitle.',
|
||||
background: null,
|
||||
backgroundToken: 'paper',
|
||||
elements: [
|
||||
{
|
||||
id: 's1',
|
||||
frame: { x: 0.0476, y: 0.0476, w: 0.9048, h: 0.9048 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
kind: 'shape',
|
||||
shape: 'rect',
|
||||
fill: null,
|
||||
gradient: 'none',
|
||||
stroke: '#222222',
|
||||
strokeToken: 'ink',
|
||||
strokeWidth: 0.0012,
|
||||
strokeStyle: 'solid',
|
||||
radius: 0,
|
||||
},
|
||||
{
|
||||
id: 't2',
|
||||
role: 'date',
|
||||
frame: { x: 0.3333, y: 0.0857, w: 0.3333, h: 0.0333 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
kind: 'text',
|
||||
text: '',
|
||||
font: 'montserrat',
|
||||
size: 0.0381,
|
||||
weight: 300,
|
||||
italic: false,
|
||||
align: 'center',
|
||||
leading: 1.4,
|
||||
tracking: 0.45,
|
||||
color: '#222222',
|
||||
token: 'ink',
|
||||
fontRole: 'heading',
|
||||
binding: null,
|
||||
overridden: false,
|
||||
transform: 'upper',
|
||||
vAlign: 'middle',
|
||||
fit: 'shrink',
|
||||
fitMax: 0.0381,
|
||||
},
|
||||
{
|
||||
id: 's3',
|
||||
keep: true,
|
||||
frame: { x: 0.2952, y: 0.1429, w: 0.4095, h: 0.4952 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'picture',
|
||||
kind: 'shape',
|
||||
shape: 'ellipse',
|
||||
fill: null,
|
||||
gradient: 'none',
|
||||
stroke: '#857d72',
|
||||
strokeToken: 'accent',
|
||||
strokeWidth: 0.0012,
|
||||
strokeStyle: 'solid',
|
||||
radius: 0,
|
||||
},
|
||||
{
|
||||
id: 'p4',
|
||||
role: 'photo:1',
|
||||
keep: true,
|
||||
tint: { border: 'ink' },
|
||||
frame: { x: 0.3143, y: 0.1667, w: 0.3714, h: 0.4476 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'picture',
|
||||
kind: 'photo',
|
||||
photoId: null,
|
||||
fit: 'cover',
|
||||
focalX: 0.5,
|
||||
focalY: 0.45,
|
||||
radius: 0,
|
||||
filter: 'none',
|
||||
mask: 'ellipse',
|
||||
frameStyle: 'none',
|
||||
border: { width: 0.0014, color: '#222222' },
|
||||
},
|
||||
{
|
||||
id: 't5',
|
||||
role: 'title',
|
||||
frame: { x: 0.0952, y: 0.6619, w: 0.8095, h: 0.1476 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'title',
|
||||
kind: 'text',
|
||||
text: '',
|
||||
font: 'cormorant',
|
||||
size: 0.3333,
|
||||
weight: 300,
|
||||
italic: true,
|
||||
align: 'center',
|
||||
leading: 0.95,
|
||||
tracking: 0,
|
||||
color: '#222222',
|
||||
token: 'ink',
|
||||
fontRole: 'display',
|
||||
binding: null,
|
||||
overridden: false,
|
||||
vAlign: 'middle',
|
||||
fit: 'shrink',
|
||||
fitMax: 0.3333,
|
||||
},
|
||||
{
|
||||
id: 'l6',
|
||||
tint: { stroke: 'accent' },
|
||||
frame: { x: 0.4048, y: 0.819, w: 0.1905, h: 0.0286 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'title',
|
||||
kind: 'line',
|
||||
points: [
|
||||
{ x: 0, y: 0.5 },
|
||||
{ x: 1, y: 0.5 },
|
||||
],
|
||||
curve: 'straight',
|
||||
stroke: '#857d72',
|
||||
strokeWidth: 0.0012,
|
||||
dash: 'solid',
|
||||
squiggle: 'none',
|
||||
startCap: 'none',
|
||||
endCap: 'none',
|
||||
wobble: 0,
|
||||
ornament: 'diamond',
|
||||
},
|
||||
{
|
||||
id: 't7',
|
||||
role: 'subtitle',
|
||||
frame: { x: 0.1429, y: 0.8571, w: 0.7143, h: 0.0333 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'title',
|
||||
kind: 'text',
|
||||
text: '',
|
||||
font: 'montserrat',
|
||||
size: 0.0381,
|
||||
weight: 300,
|
||||
italic: false,
|
||||
align: 'center',
|
||||
leading: 1.4,
|
||||
tracking: 0.32,
|
||||
color: '#646464',
|
||||
token: 'muted',
|
||||
fontRole: 'heading',
|
||||
binding: null,
|
||||
overridden: false,
|
||||
transform: 'upper',
|
||||
vAlign: 'middle',
|
||||
fit: 'shrink',
|
||||
fitMax: 0.0381,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'minimal-serif-essay',
|
||||
labelKey: 'studio.templates.name.minimalSerifEssay',
|
||||
kind: 'inner',
|
||||
archetype: 'minimal-serif',
|
||||
tags: ['minimal', 'serif', 'words-only', 'essay', 'story', 'ornament', 'calm'],
|
||||
descr:
|
||||
'Words only: on the left page a fleuron, the date in spaced capitals, a large thin serif title, a diamond rule and the subtitle, centred; on the right a hairline, the long story in one column and a star rule.',
|
||||
background: null,
|
||||
backgroundToken: 'paper',
|
||||
elements: [
|
||||
{
|
||||
id: 's1',
|
||||
tint: { colors: ['accent'] },
|
||||
frame: { x: 0.4286, y: 0.1714, w: 0.1429, h: 0.1429 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'title',
|
||||
kind: 'sticker',
|
||||
stickerId: 'fleuron',
|
||||
colors: ['#857d72'],
|
||||
flipX: false,
|
||||
},
|
||||
{
|
||||
id: 't2',
|
||||
role: 'date',
|
||||
frame: { x: 0.1429, y: 0.3524, w: 0.7143, h: 0.0333 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'title',
|
||||
kind: 'text',
|
||||
text: '',
|
||||
font: 'montserrat',
|
||||
size: 0.0381,
|
||||
weight: 300,
|
||||
italic: false,
|
||||
align: 'center',
|
||||
leading: 1.4,
|
||||
tracking: 0.42,
|
||||
color: '#222222',
|
||||
token: 'ink',
|
||||
fontRole: 'heading',
|
||||
binding: null,
|
||||
overridden: false,
|
||||
transform: 'upper',
|
||||
vAlign: 'middle',
|
||||
fit: 'shrink',
|
||||
fitMax: 0.0381,
|
||||
},
|
||||
{
|
||||
id: 't3',
|
||||
role: 'title',
|
||||
frame: { x: 0.0762, y: 0.4048, w: 0.8476, h: 0.2667 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'title',
|
||||
kind: 'text',
|
||||
text: '',
|
||||
font: 'cormorant',
|
||||
size: 0.2857,
|
||||
weight: 300,
|
||||
italic: false,
|
||||
align: 'center',
|
||||
leading: 0.95,
|
||||
tracking: 0,
|
||||
color: '#222222',
|
||||
token: 'ink',
|
||||
fontRole: 'display',
|
||||
binding: null,
|
||||
overridden: false,
|
||||
vAlign: 'middle',
|
||||
fit: 'shrink',
|
||||
fitMax: 0.2857,
|
||||
},
|
||||
{
|
||||
id: 'l4',
|
||||
tint: { stroke: 'accent' },
|
||||
frame: { x: 0.381, y: 0.6952, w: 0.2381, h: 0.0286 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'title',
|
||||
kind: 'line',
|
||||
points: [
|
||||
{ x: 0, y: 0.5 },
|
||||
{ x: 1, y: 0.5 },
|
||||
],
|
||||
curve: 'straight',
|
||||
stroke: '#857d72',
|
||||
strokeWidth: 0.0012,
|
||||
dash: 'solid',
|
||||
squiggle: 'none',
|
||||
startCap: 'none',
|
||||
endCap: 'none',
|
||||
wobble: 0,
|
||||
ornament: 'diamond',
|
||||
},
|
||||
{
|
||||
id: 't5',
|
||||
role: 'subtitle',
|
||||
frame: { x: 0.1429, y: 0.7429, w: 0.7143, h: 0.0667 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'title',
|
||||
kind: 'text',
|
||||
text: '',
|
||||
font: 'montserrat',
|
||||
size: 0.0381,
|
||||
weight: 300,
|
||||
italic: false,
|
||||
align: 'center',
|
||||
leading: 1.4,
|
||||
tracking: 0.32,
|
||||
color: '#646464',
|
||||
token: 'muted',
|
||||
fontRole: 'heading',
|
||||
binding: null,
|
||||
overridden: false,
|
||||
transform: 'upper',
|
||||
vAlign: 'top',
|
||||
fit: 'shrink',
|
||||
fitMax: 0.0381,
|
||||
},
|
||||
{
|
||||
id: 'l6',
|
||||
tint: { stroke: 'ink' },
|
||||
frame: { x: 1.1429, y: 0.0857, w: 0.7143, h: 0.0286 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'column',
|
||||
kind: 'line',
|
||||
points: [
|
||||
{ x: 0, y: 0.5 },
|
||||
{ x: 1, y: 0.5 },
|
||||
],
|
||||
curve: 'straight',
|
||||
stroke: '#222222',
|
||||
strokeWidth: 0.0012,
|
||||
dash: 'solid',
|
||||
squiggle: 'none',
|
||||
startCap: 'none',
|
||||
endCap: 'none',
|
||||
wobble: 0,
|
||||
ornament: 'none',
|
||||
},
|
||||
{
|
||||
id: 't7',
|
||||
role: 'body',
|
||||
frame: { x: 1.1429, y: 0.1524, w: 0.7143, h: 0.7143 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'column',
|
||||
kind: 'text',
|
||||
text: '',
|
||||
font: 'inter',
|
||||
size: 0.0476,
|
||||
weight: 400,
|
||||
italic: false,
|
||||
align: 'left',
|
||||
leading: 1.6,
|
||||
tracking: 0,
|
||||
color: '#222222',
|
||||
token: 'ink',
|
||||
fontRole: 'body',
|
||||
binding: null,
|
||||
overridden: false,
|
||||
vAlign: 'top',
|
||||
},
|
||||
{
|
||||
id: 's8',
|
||||
tint: { colors: ['accent'] },
|
||||
frame: { x: 1.381, y: 0.9, w: 0.2381, h: 0.0408 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'column',
|
||||
kind: 'sticker',
|
||||
stickerId: 'starRule',
|
||||
colors: ['#857d72'],
|
||||
flipX: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'minimal-serif-triptych',
|
||||
labelKey: 'studio.templates.name.minimalSerifTriptych',
|
||||
kind: 'inner',
|
||||
archetype: 'minimal-serif',
|
||||
tags: ['minimal', 'serif', 'triptych', 'three-photos', 'portrait', 'hairline', 'calm'],
|
||||
descr:
|
||||
'A thin serif title, a sun rule, the subtitle, the date and the story in a narrow left column; three tall photos with hairline borders on one line across the spread, each numbered by one, two or three accent dots.',
|
||||
background: null,
|
||||
backgroundToken: 'paper',
|
||||
elements: [
|
||||
{
|
||||
id: 't1',
|
||||
role: 'title',
|
||||
frame: { x: 0.0762, y: 0.1619, w: 0.4381, h: 0.2857 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'title',
|
||||
kind: 'text',
|
||||
text: '',
|
||||
font: 'cormorant',
|
||||
size: 0.1905,
|
||||
weight: 300,
|
||||
italic: false,
|
||||
align: 'left',
|
||||
leading: 0.95,
|
||||
tracking: 0,
|
||||
color: '#222222',
|
||||
token: 'ink',
|
||||
fontRole: 'display',
|
||||
binding: null,
|
||||
overridden: false,
|
||||
vAlign: 'bottom',
|
||||
fit: 'shrink',
|
||||
fitMax: 0.1905,
|
||||
},
|
||||
{
|
||||
id: 'l2',
|
||||
tint: { stroke: 'accent' },
|
||||
frame: { x: 0.0762, y: 0.4667, w: 0.1905, h: 0.0286 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'title',
|
||||
kind: 'line',
|
||||
points: [
|
||||
{ x: 0, y: 0.5 },
|
||||
{ x: 1, y: 0.5 },
|
||||
],
|
||||
curve: 'straight',
|
||||
stroke: '#857d72',
|
||||
strokeWidth: 0.0012,
|
||||
dash: 'solid',
|
||||
squiggle: 'none',
|
||||
startCap: 'none',
|
||||
endCap: 'none',
|
||||
wobble: 0,
|
||||
ornament: 'sun',
|
||||
},
|
||||
{
|
||||
id: 't3',
|
||||
role: 'subtitle',
|
||||
frame: { x: 0.0762, y: 0.5143, w: 0.4381, h: 0.0619 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'title',
|
||||
kind: 'text',
|
||||
text: '',
|
||||
font: 'montserrat',
|
||||
size: 0.0381,
|
||||
weight: 300,
|
||||
italic: false,
|
||||
align: 'left',
|
||||
leading: 1.4,
|
||||
tracking: 0.32,
|
||||
color: '#646464',
|
||||
token: 'muted',
|
||||
fontRole: 'heading',
|
||||
binding: null,
|
||||
overridden: false,
|
||||
transform: 'upper',
|
||||
vAlign: 'top',
|
||||
fit: 'shrink',
|
||||
fitMax: 0.0381,
|
||||
},
|
||||
{
|
||||
id: 't4',
|
||||
role: 'date',
|
||||
frame: { x: 0.0762, y: 0.5857, w: 0.4381, h: 0.0333 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'title',
|
||||
kind: 'text',
|
||||
text: '',
|
||||
font: 'montserrat',
|
||||
size: 0.0381,
|
||||
weight: 300,
|
||||
italic: false,
|
||||
align: 'left',
|
||||
leading: 1.4,
|
||||
tracking: 0.4,
|
||||
color: '#222222',
|
||||
token: 'ink',
|
||||
fontRole: 'heading',
|
||||
binding: null,
|
||||
overridden: false,
|
||||
transform: 'upper',
|
||||
vAlign: 'middle',
|
||||
fit: 'shrink',
|
||||
fitMax: 0.0381,
|
||||
},
|
||||
{
|
||||
id: 't5',
|
||||
role: 'body',
|
||||
frame: { x: 0.0762, y: 0.6571, w: 0.4381, h: 0.2667 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
kind: 'text',
|
||||
text: '',
|
||||
font: 'inter',
|
||||
size: 0.0429,
|
||||
weight: 400,
|
||||
italic: false,
|
||||
align: 'left',
|
||||
leading: 1.55,
|
||||
tracking: 0,
|
||||
color: '#222222',
|
||||
token: 'ink',
|
||||
fontRole: 'body',
|
||||
binding: null,
|
||||
overridden: false,
|
||||
vAlign: 'top',
|
||||
},
|
||||
{
|
||||
id: 'p6',
|
||||
role: 'photo:1',
|
||||
tint: { border: 'ink' },
|
||||
frame: { x: 0.5762, y: 0.1619, w: 0.3476, h: 0.5714 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'plates',
|
||||
kind: 'photo',
|
||||
photoId: null,
|
||||
fit: 'cover',
|
||||
focalX: 0.5,
|
||||
focalY: 0.5,
|
||||
radius: 0,
|
||||
filter: 'none',
|
||||
mask: null,
|
||||
frameStyle: 'none',
|
||||
border: { width: 0.0014, color: '#222222' },
|
||||
},
|
||||
{
|
||||
id: 't7',
|
||||
frame: { x: 0.5762, y: 0.7619, w: 0.3476, h: 0.0333 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'plates',
|
||||
kind: 'text',
|
||||
text: '•',
|
||||
font: 'montserrat',
|
||||
size: 0.0381,
|
||||
weight: 300,
|
||||
italic: false,
|
||||
align: 'left',
|
||||
leading: 1.4,
|
||||
tracking: 0.6,
|
||||
color: '#857d72',
|
||||
token: 'accent',
|
||||
binding: null,
|
||||
overridden: false,
|
||||
vAlign: 'middle',
|
||||
},
|
||||
{
|
||||
id: 'p8',
|
||||
role: 'photo:2',
|
||||
tint: { border: 'ink' },
|
||||
frame: { x: 1.0762, y: 0.1619, w: 0.3476, h: 0.5714 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'plates',
|
||||
kind: 'photo',
|
||||
photoId: null,
|
||||
fit: 'cover',
|
||||
focalX: 0.5,
|
||||
focalY: 0.5,
|
||||
radius: 0,
|
||||
filter: 'none',
|
||||
mask: null,
|
||||
frameStyle: 'none',
|
||||
border: { width: 0.0014, color: '#222222' },
|
||||
},
|
||||
{
|
||||
id: 't9',
|
||||
frame: { x: 1.0762, y: 0.7619, w: 0.3476, h: 0.0333 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'plates',
|
||||
kind: 'text',
|
||||
text: '••',
|
||||
font: 'montserrat',
|
||||
size: 0.0381,
|
||||
weight: 300,
|
||||
italic: false,
|
||||
align: 'left',
|
||||
leading: 1.4,
|
||||
tracking: 0.6,
|
||||
color: '#857d72',
|
||||
token: 'accent',
|
||||
binding: null,
|
||||
overridden: false,
|
||||
vAlign: 'middle',
|
||||
},
|
||||
{
|
||||
id: 'p10',
|
||||
role: 'photo:3',
|
||||
tint: { border: 'ink' },
|
||||
frame: { x: 1.5762, y: 0.1619, w: 0.3476, h: 0.5714 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'plates',
|
||||
kind: 'photo',
|
||||
photoId: null,
|
||||
fit: 'cover',
|
||||
focalX: 0.5,
|
||||
focalY: 0.5,
|
||||
radius: 0,
|
||||
filter: 'none',
|
||||
mask: null,
|
||||
frameStyle: 'none',
|
||||
border: { width: 0.0014, color: '#222222' },
|
||||
},
|
||||
{
|
||||
id: 't11',
|
||||
frame: { x: 1.5762, y: 0.7619, w: 0.3476, h: 0.0333 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'plates',
|
||||
kind: 'text',
|
||||
text: '•••',
|
||||
font: 'montserrat',
|
||||
size: 0.0381,
|
||||
weight: 300,
|
||||
italic: false,
|
||||
align: 'left',
|
||||
leading: 1.4,
|
||||
tracking: 0.6,
|
||||
color: '#857d72',
|
||||
token: 'accent',
|
||||
binding: null,
|
||||
overridden: false,
|
||||
vAlign: 'middle',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,793 @@
|
||||
import type { BookDecoratedTemplate } from '../template-format';
|
||||
|
||||
/**
|
||||
* More illustrated postcard pages: a large letter cover whose title is
|
||||
* filled with the photo, an airmail letter spread of words only and a rack
|
||||
* of seven picture postcards. Drawn in postcard.
|
||||
*
|
||||
* Drawn on a 210 mm page and kept as fractions of it (template-format.ts),
|
||||
* in the colours and faces of the theme named above; their links let any
|
||||
* other theme recolour them.
|
||||
*/
|
||||
export const MORE_POSTCARD: readonly BookDecoratedTemplate[] = [
|
||||
{
|
||||
id: 'postcard-greetings',
|
||||
labelKey: 'studio.templates.name.postcardGreetings',
|
||||
kind: 'single',
|
||||
archetype: 'postcard',
|
||||
tags: ['postcard', 'large-letter', 'word-art', 'photo-filled', 'sunburst', 'one-photo', 'lively'],
|
||||
descr:
|
||||
'A large letter postcard: a soft sunburst behind a dashed border, the year, the title in huge display serif capitals filled with the photo and shadowed in coral, a script line, a star rule, the photo with a wavy edge, lemons and a postmark.',
|
||||
background: '#e3f1e8',
|
||||
backgroundToken: 'paper',
|
||||
elements: [
|
||||
{
|
||||
id: 's1',
|
||||
keep: true,
|
||||
frame: { x: -0.05, y: -0.0714, w: 1.1, h: 1.1 },
|
||||
rotation: 0,
|
||||
opacity: 0.4,
|
||||
locked: false,
|
||||
kind: 'shape',
|
||||
shape: 'sunburst-24',
|
||||
fill: '#f3d58a',
|
||||
gradient: 'none',
|
||||
stroke: null,
|
||||
token: 'accent3',
|
||||
strokeWidth: 0,
|
||||
strokeStyle: 'solid',
|
||||
radius: 0,
|
||||
},
|
||||
{
|
||||
id: 's2',
|
||||
frame: { x: 0.0476, y: 0.0476, w: 0.9048, h: 0.9048 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
kind: 'shape',
|
||||
shape: 'rect',
|
||||
fill: null,
|
||||
gradient: 'none',
|
||||
stroke: '#1f2d4d',
|
||||
strokeToken: 'ink',
|
||||
strokeWidth: 0.0024,
|
||||
strokeStyle: 'dashed',
|
||||
radius: 0.0071,
|
||||
},
|
||||
{
|
||||
id: 't3',
|
||||
role: 'date',
|
||||
frame: { x: 0.2619, y: 0.0905, w: 0.4762, h: 0.0381 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'title',
|
||||
kind: 'text',
|
||||
text: '',
|
||||
font: 'montserrat',
|
||||
size: 0.0381,
|
||||
weight: 600,
|
||||
italic: false,
|
||||
align: 'center',
|
||||
leading: 1,
|
||||
tracking: 0.45,
|
||||
color: '#1f2d4d',
|
||||
token: 'ink',
|
||||
fontRole: 'heading',
|
||||
binding: null,
|
||||
overridden: false,
|
||||
transform: 'upper',
|
||||
vAlign: 'middle',
|
||||
fit: 'shrink',
|
||||
fitMax: 0.0381,
|
||||
},
|
||||
{
|
||||
id: 't4',
|
||||
role: 'title',
|
||||
fillPhoto: 'photo:1',
|
||||
tint: { outline: 'ink', shadow: 'accent' },
|
||||
frame: { x: 0.0762, y: 0.1429, w: 0.8476, h: 0.2857 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'title',
|
||||
kind: 'text',
|
||||
text: '',
|
||||
font: 'dmserif',
|
||||
size: 0.3333,
|
||||
weight: 400,
|
||||
italic: false,
|
||||
align: 'center',
|
||||
leading: 0.9,
|
||||
tracking: 0,
|
||||
color: '#1f2d4d',
|
||||
token: 'ink',
|
||||
fontRole: 'display',
|
||||
binding: null,
|
||||
overridden: false,
|
||||
transform: 'upper',
|
||||
vAlign: 'middle',
|
||||
outline: { color: '#1f2d4d', width: 0.0033 },
|
||||
shadow: { dx: 0.0095, dy: 0.0095, color: '#c8453d', alpha: 1 },
|
||||
fit: 'shrink',
|
||||
fitMax: 0.3333,
|
||||
},
|
||||
{
|
||||
id: 't5',
|
||||
role: 'subtitle',
|
||||
frame: { x: 0.1143, y: 0.4381, w: 0.7714, h: 0.0857 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'title',
|
||||
kind: 'text',
|
||||
text: '',
|
||||
font: 'dancing',
|
||||
size: 0.1238,
|
||||
weight: 400,
|
||||
italic: false,
|
||||
align: 'center',
|
||||
leading: 1.1,
|
||||
tracking: 0,
|
||||
color: '#c8453d',
|
||||
token: 'accent',
|
||||
fontRole: 'script',
|
||||
binding: null,
|
||||
overridden: false,
|
||||
vAlign: 'middle',
|
||||
fit: 'shrink',
|
||||
fitMax: 0.1238,
|
||||
},
|
||||
{
|
||||
id: 's6',
|
||||
tint: { colors: ['ink'] },
|
||||
frame: { x: 0.3095, y: 0.5238, w: 0.381, h: 0.0653 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'title',
|
||||
kind: 'sticker',
|
||||
stickerId: 'starRule',
|
||||
colors: ['#1f2d4d'],
|
||||
flipX: false,
|
||||
},
|
||||
{
|
||||
id: 'p7',
|
||||
role: 'photo:1',
|
||||
tint: { shadow: 'ink' },
|
||||
frame: { x: 0.0952, y: 0.6, w: 0.8095, h: 0.3048 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'picture',
|
||||
kind: 'photo',
|
||||
photoId: null,
|
||||
fit: 'cover',
|
||||
focalX: 0.5,
|
||||
focalY: 0.5,
|
||||
radius: 0,
|
||||
filter: 'none',
|
||||
mask: null,
|
||||
frameStyle: 'none',
|
||||
border: { width: 0.0105, color: '#ffffff' },
|
||||
shadow: { dx: 0.0076, dy: 0.0076, color: '#1f2d4d', alpha: 0.25 },
|
||||
edge: 'wavy',
|
||||
edgeSize: 0.0095,
|
||||
},
|
||||
{
|
||||
id: 's8',
|
||||
tint: { colors: ['accent3', null, 'ink'] },
|
||||
frame: { x: 0.7476, y: 0.5143, w: 0.1905, h: 0.1905 },
|
||||
rotation: 18,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'picture',
|
||||
kind: 'sticker',
|
||||
stickerId: 'lemonBranch',
|
||||
colors: ['#f3d58a', '#3f8f4f', '#1f2d4d'],
|
||||
flipX: false,
|
||||
},
|
||||
{
|
||||
id: 's9',
|
||||
tint: { colors: ['ink'] },
|
||||
frame: { x: 0.0762, y: 0.8381, w: 0.181, h: 0.1052 },
|
||||
rotation: -8,
|
||||
opacity: 0.8,
|
||||
locked: false,
|
||||
group: 'picture',
|
||||
kind: 'sticker',
|
||||
stickerId: 'postmark',
|
||||
colors: ['#1f2d4d'],
|
||||
flipX: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'postcard-airmail',
|
||||
labelKey: 'studio.templates.name.postcardAirmail',
|
||||
kind: 'inner',
|
||||
archetype: 'postcard',
|
||||
tags: ['postcard', 'airmail', 'letter', 'illustrated', 'stamp', 'words-only', 'story', 'calm'],
|
||||
descr:
|
||||
'Airmail stationery without photos: striped coral bands at head and foot, the date, a large display serif title, a script line and a wave rule over a drawn church and a passport stamp; on the right a stamp, a postmark and the long story.',
|
||||
background: '#e3f1e8',
|
||||
backgroundToken: 'paper',
|
||||
elements: [
|
||||
{
|
||||
id: 's1',
|
||||
tint: { fg: 'accent', bg: 'paper' },
|
||||
frame: { x: -0.0143, y: -0.0143, w: 2.0286, h: 0.0572 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
kind: 'shape',
|
||||
shape: 'rect',
|
||||
fill: '#c8453d',
|
||||
gradient: 'none',
|
||||
paint: { type: 'pattern', pattern: 'stripes', fg: '#c8453d', bg: '#e3f1e8', scale: 0.6, angle: 45 },
|
||||
stroke: null,
|
||||
token: 'accent',
|
||||
strokeWidth: 0,
|
||||
strokeStyle: 'solid',
|
||||
radius: 0,
|
||||
},
|
||||
{
|
||||
id: 's2',
|
||||
tint: { fg: 'accent', bg: 'paper' },
|
||||
frame: { x: -0.0143, y: 0.9571, w: 2.0286, h: 0.0572 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
kind: 'shape',
|
||||
shape: 'rect',
|
||||
fill: '#c8453d',
|
||||
gradient: 'none',
|
||||
paint: { type: 'pattern', pattern: 'stripes', fg: '#c8453d', bg: '#e3f1e8', scale: 0.6, angle: 45 },
|
||||
stroke: null,
|
||||
token: 'accent',
|
||||
strokeWidth: 0,
|
||||
strokeStyle: 'solid',
|
||||
radius: 0,
|
||||
},
|
||||
{
|
||||
id: 's3',
|
||||
keep: true,
|
||||
frame: { x: 0.5333, y: 0.5524, w: 0.4095, h: 0.4095 },
|
||||
rotation: 0,
|
||||
opacity: 0.35,
|
||||
locked: false,
|
||||
kind: 'shape',
|
||||
shape: 'ellipse',
|
||||
fill: '#ec8f6a',
|
||||
gradient: 'none',
|
||||
stroke: null,
|
||||
token: 'accent2',
|
||||
strokeWidth: 0,
|
||||
strokeStyle: 'solid',
|
||||
radius: 0,
|
||||
},
|
||||
{
|
||||
id: 's4',
|
||||
keep: true,
|
||||
tint: { colors: [null, 'ink', 'accent2', 'accent3'] },
|
||||
frame: { x: 0.5714, y: 0.5905, w: 0.3333, h: 0.3333 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
kind: 'sticker',
|
||||
stickerId: 'santoriniChurch',
|
||||
colors: ['#fbfaf5', '#1f2d4d', '#ec8f6a', '#f3d58a'],
|
||||
flipX: false,
|
||||
},
|
||||
{
|
||||
id: 's5',
|
||||
tint: { colors: ['ink'] },
|
||||
frame: { x: 0.0857, y: 0.7143, w: 0.2857, h: 0.2 },
|
||||
rotation: -12,
|
||||
opacity: 0.7,
|
||||
locked: false,
|
||||
kind: 'sticker',
|
||||
stickerId: 'passportStamp',
|
||||
colors: ['#1f2d4d'],
|
||||
flipX: false,
|
||||
},
|
||||
{
|
||||
id: 't6',
|
||||
role: 'date',
|
||||
frame: { x: 0.0762, y: 0.1143, w: 0.8476, h: 0.0381 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'head',
|
||||
kind: 'text',
|
||||
text: '',
|
||||
font: 'montserrat',
|
||||
size: 0.0381,
|
||||
weight: 600,
|
||||
italic: false,
|
||||
align: 'left',
|
||||
leading: 1,
|
||||
tracking: 0.42,
|
||||
color: '#1f2d4d',
|
||||
token: 'ink',
|
||||
fontRole: 'heading',
|
||||
binding: null,
|
||||
overridden: false,
|
||||
transform: 'upper',
|
||||
vAlign: 'middle',
|
||||
fit: 'shrink',
|
||||
fitMax: 0.0381,
|
||||
},
|
||||
{
|
||||
id: 't7',
|
||||
role: 'title',
|
||||
frame: { x: 0.0762, y: 0.1619, w: 0.8476, h: 0.3238 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'head',
|
||||
kind: 'text',
|
||||
text: '',
|
||||
font: 'dmserif',
|
||||
size: 0.381,
|
||||
weight: 400,
|
||||
italic: false,
|
||||
align: 'left',
|
||||
leading: 0.92,
|
||||
tracking: 0,
|
||||
color: '#1f2d4d',
|
||||
token: 'ink',
|
||||
fontRole: 'display',
|
||||
binding: null,
|
||||
overridden: false,
|
||||
vAlign: 'top',
|
||||
fit: 'shrink',
|
||||
fitMax: 0.381,
|
||||
},
|
||||
{
|
||||
id: 't8',
|
||||
role: 'subtitle',
|
||||
frame: { x: 0.0762, y: 0.4952, w: 0.8476, h: 0.0952 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'head',
|
||||
kind: 'text',
|
||||
text: '',
|
||||
font: 'dancing',
|
||||
size: 0.1333,
|
||||
weight: 400,
|
||||
italic: false,
|
||||
align: 'left',
|
||||
leading: 1.1,
|
||||
tracking: 0,
|
||||
color: '#c8453d',
|
||||
token: 'accent',
|
||||
fontRole: 'script',
|
||||
binding: null,
|
||||
overridden: false,
|
||||
vAlign: 'middle',
|
||||
fit: 'shrink',
|
||||
fitMax: 0.1333,
|
||||
},
|
||||
{
|
||||
id: 's9',
|
||||
tint: { colors: ['ink'] },
|
||||
frame: { x: 0.0762, y: 0.6048, w: 0.3, h: 0.0429 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'head',
|
||||
kind: 'sticker',
|
||||
stickerId: 'waveRule',
|
||||
colors: ['#1f2d4d'],
|
||||
flipX: false,
|
||||
},
|
||||
{
|
||||
id: 's10',
|
||||
tint: { colors: ['accent2', 'ink', 'accent3', null] },
|
||||
frame: { x: 1.7524, y: 0.0952, w: 0.1524, h: 0.1905 },
|
||||
rotation: 4,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'stamp',
|
||||
kind: 'sticker',
|
||||
stickerId: 'postageStamp',
|
||||
colors: ['#ec8f6a', '#1f2d4d', '#f3d58a', '#f7edd5'],
|
||||
flipX: false,
|
||||
},
|
||||
{
|
||||
id: 's11',
|
||||
tint: { colors: ['ink'] },
|
||||
frame: { x: 1.6, y: 0.1238, w: 0.2, h: 0.1163 },
|
||||
rotation: -10,
|
||||
opacity: 0.8,
|
||||
locked: false,
|
||||
group: 'stamp',
|
||||
kind: 'sticker',
|
||||
stickerId: 'postmark',
|
||||
colors: ['#1f2d4d'],
|
||||
flipX: false,
|
||||
},
|
||||
{
|
||||
id: 'l12',
|
||||
tint: { stroke: 'accent' },
|
||||
frame: { x: 1.0762, y: 0.2524, w: 0.4571, h: 0.0095 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
kind: 'line',
|
||||
points: [
|
||||
{
|
||||
x: 0,
|
||||
y: 0.5,
|
||||
},
|
||||
{
|
||||
x: 1,
|
||||
y: 0.5,
|
||||
},
|
||||
],
|
||||
curve: 'straight',
|
||||
stroke: '#c8453d',
|
||||
strokeWidth: 0.0012,
|
||||
dash: 'solid',
|
||||
squiggle: 'none',
|
||||
startCap: 'none',
|
||||
endCap: 'none',
|
||||
wobble: 0,
|
||||
ornament: 'star',
|
||||
},
|
||||
{
|
||||
id: 't13',
|
||||
role: 'body',
|
||||
frame: { x: 1.0762, y: 0.3048, w: 0.8476, h: 0.6095 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
kind: 'text',
|
||||
text: '',
|
||||
font: 'inter',
|
||||
size: 0.0476,
|
||||
weight: 400,
|
||||
italic: false,
|
||||
align: 'left',
|
||||
leading: 1.55,
|
||||
tracking: 0,
|
||||
color: '#1f2d4d',
|
||||
token: 'ink',
|
||||
fontRole: 'body',
|
||||
binding: null,
|
||||
overridden: false,
|
||||
vAlign: 'top',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'postcard-rack',
|
||||
labelKey: 'studio.templates.name.postcardRack',
|
||||
kind: 'inner',
|
||||
archetype: 'postcard',
|
||||
tags: ['postcard', 'collage', 'pinboard', 'tape', 'tilted', 'seven-photos', 'lively'],
|
||||
descr:
|
||||
'Seven photos as picture postcards in white borders, tilted and overlapping, held by tape, one cancelled by a postmark; a pale sun behind the date, the display serif title and the script line, the story at the foot.',
|
||||
background: '#e3f1e8',
|
||||
backgroundToken: 'paper',
|
||||
elements: [
|
||||
{
|
||||
id: 's1',
|
||||
keep: true,
|
||||
frame: { x: -0.12, y: -0.14, w: 0.5, h: 0.5102 },
|
||||
rotation: 0,
|
||||
opacity: 0.55,
|
||||
locked: false,
|
||||
kind: 'shape',
|
||||
shape: 'sun-cutout',
|
||||
fill: '#f3d58a',
|
||||
gradient: 'none',
|
||||
stroke: null,
|
||||
token: 'accent3',
|
||||
strokeWidth: 0,
|
||||
strokeStyle: 'solid',
|
||||
radius: 0,
|
||||
},
|
||||
{
|
||||
id: 't2',
|
||||
role: 'date',
|
||||
frame: { x: 0.0762, y: 0.0857, w: 0.8476, h: 0.0381 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'title',
|
||||
kind: 'text',
|
||||
text: '',
|
||||
font: 'montserrat',
|
||||
size: 0.0381,
|
||||
weight: 600,
|
||||
italic: false,
|
||||
align: 'left',
|
||||
leading: 1,
|
||||
tracking: 0.42,
|
||||
color: '#1f2d4d',
|
||||
token: 'ink',
|
||||
fontRole: 'heading',
|
||||
binding: null,
|
||||
overridden: false,
|
||||
transform: 'upper',
|
||||
vAlign: 'middle',
|
||||
fit: 'shrink',
|
||||
fitMax: 0.0381,
|
||||
},
|
||||
{
|
||||
id: 't3',
|
||||
role: 'title',
|
||||
frame: { x: 0.0762, y: 0.1333, w: 0.8476, h: 0.1238 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'title',
|
||||
kind: 'text',
|
||||
text: '',
|
||||
font: 'dmserif',
|
||||
size: 0.2286,
|
||||
weight: 400,
|
||||
italic: false,
|
||||
align: 'left',
|
||||
leading: 1,
|
||||
tracking: 0,
|
||||
color: '#1f2d4d',
|
||||
token: 'ink',
|
||||
fontRole: 'display',
|
||||
binding: null,
|
||||
overridden: false,
|
||||
vAlign: 'middle',
|
||||
fit: 'shrink',
|
||||
fitMax: 0.2286,
|
||||
},
|
||||
{
|
||||
id: 't4',
|
||||
role: 'subtitle',
|
||||
frame: { x: 0.0762, y: 0.2571, w: 0.8476, h: 0.0762 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'title',
|
||||
kind: 'text',
|
||||
text: '',
|
||||
font: 'dancing',
|
||||
size: 0.1048,
|
||||
weight: 400,
|
||||
italic: false,
|
||||
align: 'left',
|
||||
leading: 1.1,
|
||||
tracking: 0,
|
||||
color: '#c8453d',
|
||||
token: 'accent',
|
||||
fontRole: 'script',
|
||||
binding: null,
|
||||
overridden: false,
|
||||
vAlign: 'middle',
|
||||
fit: 'shrink',
|
||||
fitMax: 0.1048,
|
||||
},
|
||||
{
|
||||
id: 'p5',
|
||||
role: 'photo:1',
|
||||
tint: { shadow: 'ink' },
|
||||
frame: { x: 0.0762, y: 0.381, w: 0.5238, h: 0.3714 },
|
||||
rotation: -3,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'print',
|
||||
kind: 'photo',
|
||||
photoId: null,
|
||||
fit: 'cover',
|
||||
focalX: 0.5,
|
||||
focalY: 0.5,
|
||||
radius: 0,
|
||||
filter: 'none',
|
||||
mask: null,
|
||||
frameStyle: 'none',
|
||||
border: { width: 0.0105, color: '#ffffff' },
|
||||
shadow: { dx: 0.0076, dy: 0.0076, color: '#1f2d4d', alpha: 0.25 },
|
||||
},
|
||||
{
|
||||
id: 's6',
|
||||
tint: { colors: ['accent3'] },
|
||||
frame: { x: 0.2619, y: 0.3429, w: 0.1524, h: 0.0762 },
|
||||
rotation: -8,
|
||||
opacity: 0.85,
|
||||
locked: false,
|
||||
group: 'print',
|
||||
kind: 'sticker',
|
||||
stickerId: 'maskingTape',
|
||||
colors: ['#f3d58a'],
|
||||
flipX: false,
|
||||
},
|
||||
{
|
||||
id: 'p7',
|
||||
role: 'photo:2',
|
||||
tint: { shadow: 'ink' },
|
||||
frame: { x: 0.6, y: 0.3333, w: 0.3048, h: 0.381 },
|
||||
rotation: 4,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
kind: 'photo',
|
||||
photoId: null,
|
||||
fit: 'cover',
|
||||
focalX: 0.5,
|
||||
focalY: 0.5,
|
||||
radius: 0,
|
||||
filter: 'none',
|
||||
mask: null,
|
||||
frameStyle: 'none',
|
||||
border: { width: 0.0105, color: '#ffffff' },
|
||||
shadow: { dx: 0.0076, dy: 0.0076, color: '#1f2d4d', alpha: 0.25 },
|
||||
},
|
||||
{
|
||||
id: 'p8',
|
||||
role: 'photo:3',
|
||||
tint: { shadow: 'ink' },
|
||||
frame: { x: 0.4, y: 0.6857, w: 0.3333, h: 0.2381 },
|
||||
rotation: 2,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
kind: 'photo',
|
||||
photoId: null,
|
||||
fit: 'cover',
|
||||
focalX: 0.5,
|
||||
focalY: 0.5,
|
||||
radius: 0,
|
||||
filter: 'none',
|
||||
mask: null,
|
||||
frameStyle: 'none',
|
||||
border: { width: 0.0105, color: '#ffffff' },
|
||||
shadow: { dx: 0.0076, dy: 0.0076, color: '#1f2d4d', alpha: 0.25 },
|
||||
},
|
||||
{
|
||||
id: 'p9',
|
||||
role: 'photo:4',
|
||||
tint: { shadow: 'ink' },
|
||||
frame: { x: 1.0762, y: 0.0762, w: 0.4, h: 0.2857 },
|
||||
rotation: 3,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
kind: 'photo',
|
||||
photoId: null,
|
||||
fit: 'cover',
|
||||
focalX: 0.5,
|
||||
focalY: 0.5,
|
||||
radius: 0,
|
||||
filter: 'none',
|
||||
mask: null,
|
||||
frameStyle: 'none',
|
||||
border: { width: 0.0105, color: '#ffffff' },
|
||||
shadow: { dx: 0.0076, dy: 0.0076, color: '#1f2d4d', alpha: 0.25 },
|
||||
},
|
||||
{
|
||||
id: 'p10',
|
||||
role: 'photo:5',
|
||||
tint: { shadow: 'ink' },
|
||||
frame: { x: 1.5048, y: 0.1048, w: 0.4, h: 0.2857 },
|
||||
rotation: -4,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'tape',
|
||||
kind: 'photo',
|
||||
photoId: null,
|
||||
fit: 'cover',
|
||||
focalX: 0.5,
|
||||
focalY: 0.5,
|
||||
radius: 0,
|
||||
filter: 'none',
|
||||
mask: null,
|
||||
frameStyle: 'none',
|
||||
border: { width: 0.0105, color: '#ffffff' },
|
||||
shadow: { dx: 0.0076, dy: 0.0076, color: '#1f2d4d', alpha: 0.25 },
|
||||
},
|
||||
{
|
||||
id: 's11',
|
||||
tint: { colors: ['accent3'] },
|
||||
frame: { x: 1.6286, y: 0.0667, w: 0.1524, h: 0.0762 },
|
||||
rotation: 6,
|
||||
opacity: 0.85,
|
||||
locked: false,
|
||||
group: 'tape',
|
||||
kind: 'sticker',
|
||||
stickerId: 'maskingTape',
|
||||
colors: ['#f3d58a'],
|
||||
flipX: false,
|
||||
},
|
||||
{
|
||||
id: 'p12',
|
||||
role: 'photo:6',
|
||||
tint: { shadow: 'ink' },
|
||||
frame: { x: 1.0952, y: 0.4095, w: 0.3048, h: 0.381 },
|
||||
rotation: -2,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'picture',
|
||||
kind: 'photo',
|
||||
photoId: null,
|
||||
fit: 'cover',
|
||||
focalX: 0.5,
|
||||
focalY: 0.5,
|
||||
radius: 0,
|
||||
filter: 'none',
|
||||
mask: null,
|
||||
frameStyle: 'none',
|
||||
border: { width: 0.0105, color: '#ffffff' },
|
||||
shadow: { dx: 0.0076, dy: 0.0076, color: '#1f2d4d', alpha: 0.25 },
|
||||
},
|
||||
{
|
||||
id: 's13',
|
||||
tint: { colors: ['accent2', 'paper'] },
|
||||
frame: { x: 1.0571, y: 0.3905, w: 0.1905, h: 0.054 },
|
||||
rotation: -30,
|
||||
opacity: 0.9,
|
||||
locked: false,
|
||||
group: 'picture',
|
||||
kind: 'sticker',
|
||||
stickerId: 'washiStripes',
|
||||
colors: ['#ec8f6a', '#e3f1e8'],
|
||||
flipX: false,
|
||||
},
|
||||
{
|
||||
id: 'p14',
|
||||
role: 'photo:7',
|
||||
tint: { shadow: 'ink' },
|
||||
frame: { x: 1.4381, y: 0.4286, w: 0.4667, h: 0.3143 },
|
||||
rotation: 5,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
group: 'postmark',
|
||||
kind: 'photo',
|
||||
photoId: null,
|
||||
fit: 'cover',
|
||||
focalX: 0.5,
|
||||
focalY: 0.5,
|
||||
radius: 0,
|
||||
filter: 'none',
|
||||
mask: null,
|
||||
frameStyle: 'none',
|
||||
border: { width: 0.0105, color: '#ffffff' },
|
||||
shadow: { dx: 0.0076, dy: 0.0076, color: '#1f2d4d', alpha: 0.25 },
|
||||
},
|
||||
{
|
||||
id: 's15',
|
||||
tint: { colors: ['ink'] },
|
||||
frame: { x: 1.7238, y: 0.6762, w: 0.181, h: 0.1052 },
|
||||
rotation: -12,
|
||||
opacity: 0.8,
|
||||
locked: false,
|
||||
group: 'postmark',
|
||||
kind: 'sticker',
|
||||
stickerId: 'postmark',
|
||||
colors: ['#1f2d4d'],
|
||||
flipX: false,
|
||||
},
|
||||
{
|
||||
id: 't16',
|
||||
role: 'body',
|
||||
frame: { x: 1.0762, y: 0.8286, w: 0.8476, h: 0.1143 },
|
||||
rotation: 0,
|
||||
opacity: 1,
|
||||
locked: false,
|
||||
kind: 'text',
|
||||
text: '',
|
||||
font: 'inter',
|
||||
size: 0.0429,
|
||||
weight: 400,
|
||||
italic: false,
|
||||
align: 'left',
|
||||
leading: 1.55,
|
||||
tracking: 0,
|
||||
color: '#1f2d4d',
|
||||
token: 'ink',
|
||||
fontRole: 'body',
|
||||
binding: null,
|
||||
overridden: false,
|
||||
vAlign: 'top',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,13 @@ import {
|
||||
templatePage,
|
||||
type LaidElement,
|
||||
} from './template-lay';
|
||||
import { assignTemplateTexts, textRolesOf, type BookTemplateWord, type BookTemplateWords } from './template-words';
|
||||
import {
|
||||
assignTemplateTexts,
|
||||
isTemplateFigureText,
|
||||
textRolesOf,
|
||||
type BookTemplateWord,
|
||||
type BookTemplateWords,
|
||||
} from './template-words';
|
||||
|
||||
/**
|
||||
* Laying a page out on a decorated template.
|
||||
@@ -146,7 +152,9 @@ function poolOf(spread: BookSpread): Pool {
|
||||
const all = [...spread.elements.filter((e) => !e.locked), ...spread.parked];
|
||||
return {
|
||||
photos: all.filter((e): e is BookPhotoElement => e.kind === 'photo' && e.photoId != null),
|
||||
texts: all.filter((e): e is TextEl => e.kind === 'text' && !!e.text.trim() && !isBookOrnamentText(e)),
|
||||
texts: all.filter(
|
||||
(e): e is TextEl => e.kind === 'text' && !!e.text.trim() && !isBookOrnamentText(e) && !isTemplateFigureText(e),
|
||||
),
|
||||
map: all.find((e): e is BookMapElement => e.kind === 'map'),
|
||||
marks: all.filter((e): e is BookBadgeElement => e.kind === 'badge'),
|
||||
};
|
||||
|
||||
@@ -55,12 +55,12 @@ function box(el: BookElement) {
|
||||
const camel = (id: string) => id.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase());
|
||||
|
||||
describe('the decorated template set', () => {
|
||||
it('TPLCAT-001: holds 21 covers, three in each archetype, and 16 double spreads', () => {
|
||||
expect(covers).toHaveLength(21);
|
||||
expect(spreads).toHaveLength(16);
|
||||
it('TPLCAT-001: holds 48 covers and 60 double spreads, four covers and five spreads in each archetype', () => {
|
||||
expect(covers).toHaveLength(48);
|
||||
expect(spreads).toHaveLength(60);
|
||||
for (const archetype of BOOK_COVER_ARCHETYPES) {
|
||||
expect(decoratedTemplatesFor('single', archetype), archetype).toHaveLength(3);
|
||||
expect(decoratedTemplatesFor('inner', archetype).length, archetype).toBeGreaterThanOrEqual(1);
|
||||
expect(decoratedTemplatesFor('single', archetype), archetype).toHaveLength(4);
|
||||
expect(decoratedTemplatesFor('inner', archetype), archetype).toHaveLength(5);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -190,7 +190,7 @@ describe('finding the templates', () => {
|
||||
expect(findDecoratedTemplate('no-such-template')).toBeUndefined();
|
||||
expect(decoratedTemplatesFor('single').every((t) => t.kind === 'single')).toBe(true);
|
||||
expect(decoratedTemplatesFor('inner', 'postcard').every((t) => t.archetype === 'postcard')).toBe(true);
|
||||
expect(decoratedTemplatesFor('single', null)).toHaveLength(21);
|
||||
expect(decoratedTemplatesFor('single', null)).toHaveLength(48);
|
||||
expect(isBookTemplateId('grid-badge-circle')).toBe(true);
|
||||
expect(isBookTemplateId('two-up')).toBe(true);
|
||||
expect(isBookTemplateId('cover-full')).toBe(true);
|
||||
@@ -203,10 +203,10 @@ describe('finding the templates', () => {
|
||||
expect(row.roles).toEqual(['title', 'date', 'body']);
|
||||
expect(row.tags).toContain('word-art');
|
||||
const all = listBookLayouts(null, square);
|
||||
expect(all.decorated.single).toHaveLength(21);
|
||||
expect(all.decorated.inner).toHaveLength(16);
|
||||
expect(all.decorated.single).toHaveLength(48);
|
||||
expect(all.decorated.inner).toHaveLength(60);
|
||||
expect(all.notes.decorated).toMatch(/whole designed page/);
|
||||
const inner = listBookLayouts(null, square, 'inner');
|
||||
expect([inner.decorated.single, inner.decorated.inner.length]).toEqual([[], 16]);
|
||||
expect([inner.decorated.single, inner.decorated.inner.length]).toEqual([[], 60]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,18 @@ import { INNER_COLLAGE } from './data/inner-collage.data';
|
||||
import { INNER_EPHEMERA } from './data/inner-ephemera.data';
|
||||
import { INNER_GRIDS } from './data/inner-grids.data';
|
||||
import { INNER_STORY } from './data/inner-story.data';
|
||||
import { MORE_CUTOUT } from './data/more-cutout.data';
|
||||
import { MORE_GRID_BADGE } from './data/more-grid-badge.data';
|
||||
import { MORE_MINIMAL_SERIF } from './data/more-minimal-serif.data';
|
||||
import { MORE_POSTCARD } from './data/more-postcard.data';
|
||||
import { MORE_POSTER_POP } from './data/more-poster-pop.data';
|
||||
import { MORE_SPINE_BAND } from './data/more-spine-band.data';
|
||||
import { MORE_STAMP_COLLAGE } from './data/more-stamp-collage.data';
|
||||
import { NEW_EDITORIAL } from './data/new-editorial.data';
|
||||
import { NEW_FIELD_NOTES } from './data/new-field-notes.data';
|
||||
import { NEW_NIGHT_FILM } from './data/new-night-film.data';
|
||||
import { NEW_RISO } from './data/new-riso.data';
|
||||
import { NEW_VINTAGE_POSTER } from './data/new-vintage-poster.data';
|
||||
import {
|
||||
templatePhotoSlots,
|
||||
templateRoles,
|
||||
@@ -19,26 +31,56 @@ import {
|
||||
type BookTemplateKind,
|
||||
} from './template-format';
|
||||
|
||||
/** The covers or the spreads of a data file that holds both. */
|
||||
function ofKind(set: readonly BookDecoratedTemplate[], kind: BookTemplateKind): BookDecoratedTemplate[] {
|
||||
return set.filter((t) => t.kind === kind);
|
||||
}
|
||||
|
||||
/**
|
||||
* The decorated templates the Studio offers and the assistant lays pages
|
||||
* on: 21 covers, three in each of the seven archetypes, and 16 double
|
||||
* spreads, each set in the manner of one of them. The data files hold them
|
||||
* as drawn (template-format.ts); this is the one list the Studio's panel,
|
||||
* the page operations and `list_book_layouts` read.
|
||||
* on: 48 covers, four in each of the twelve archetypes, and 60 double
|
||||
* spreads, five in the manner of each. The data files hold them as drawn
|
||||
* (template-format.ts); this is the one list the Studio's panel, the page
|
||||
* operations and `list_book_layouts` read. Its order is the panel's: the
|
||||
* covers archetype by archetype, then the spreads.
|
||||
*/
|
||||
export const BOOK_DECORATED_TEMPLATES: readonly BookDecoratedTemplate[] = [
|
||||
...COVERS_SPINE_BAND,
|
||||
...ofKind(MORE_SPINE_BAND, 'single'),
|
||||
...COVERS_POSTER_POP,
|
||||
...ofKind(MORE_POSTER_POP, 'single'),
|
||||
...COVERS_CUTOUT,
|
||||
...ofKind(MORE_CUTOUT, 'single'),
|
||||
...COVERS_POSTCARD,
|
||||
...ofKind(MORE_POSTCARD, 'single'),
|
||||
...COVERS_GRID_BADGE,
|
||||
...ofKind(MORE_GRID_BADGE, 'single'),
|
||||
...COVERS_MINIMAL_SERIF,
|
||||
...ofKind(MORE_MINIMAL_SERIF, 'single'),
|
||||
...COVERS_STAMP_COLLAGE,
|
||||
...ofKind(MORE_STAMP_COLLAGE, 'single'),
|
||||
...ofKind(NEW_RISO, 'single'),
|
||||
...ofKind(NEW_EDITORIAL, 'single'),
|
||||
...ofKind(NEW_VINTAGE_POSTER, 'single'),
|
||||
...ofKind(NEW_NIGHT_FILM, 'single'),
|
||||
...ofKind(NEW_FIELD_NOTES, 'single'),
|
||||
...INNER_STORY,
|
||||
...INNER_CHAPTERS,
|
||||
...INNER_COLLAGE,
|
||||
...INNER_EPHEMERA,
|
||||
...INNER_GRIDS,
|
||||
...ofKind(MORE_SPINE_BAND, 'inner'),
|
||||
...ofKind(MORE_POSTER_POP, 'inner'),
|
||||
...ofKind(MORE_CUTOUT, 'inner'),
|
||||
...ofKind(MORE_POSTCARD, 'inner'),
|
||||
...ofKind(MORE_GRID_BADGE, 'inner'),
|
||||
...ofKind(MORE_MINIMAL_SERIF, 'inner'),
|
||||
...ofKind(MORE_STAMP_COLLAGE, 'inner'),
|
||||
...ofKind(NEW_RISO, 'inner'),
|
||||
...ofKind(NEW_EDITORIAL, 'inner'),
|
||||
...ofKind(NEW_VINTAGE_POSTER, 'inner'),
|
||||
...ofKind(NEW_NIGHT_FILM, 'inner'),
|
||||
...ofKind(NEW_FIELD_NOTES, 'inner'),
|
||||
];
|
||||
|
||||
const BY_ID = new Map(BOOK_DECORATED_TEMPLATES.map((t) => [t.id, t]));
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { bookPageSetupForPreset } from '../book-pages';
|
||||
import { bookPhotoElementSchema, type BookSpread } from '../book.schema';
|
||||
import { bookThemeOf } from '../theme/book-theme';
|
||||
import { applyDecoratedTemplate } from './template-apply';
|
||||
import { BOOK_DECORATED_TEMPLATES } from './template-catalogue';
|
||||
import { matchDecoratedTemplate } from './template-match';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/** A laid page knows which decorated template it is. */
|
||||
|
||||
const words = {
|
||||
title: { text: 'Portugal' },
|
||||
subtitle: { text: 'Lisbon to the Algarve' },
|
||||
date: { text: '2026', binding: { source: 'entry.date' as const, entryId: 7, value: '2026-05-14' } },
|
||||
body: { text: 'We took the early train out of Lisbon and walked up through the fog to the palace.' },
|
||||
caption: { text: 'Pena Palace in the fog' },
|
||||
};
|
||||
|
||||
const photo = (id: string, photoId: number) =>
|
||||
bookPhotoElementSchema.parse({ id, kind: 'photo', frame: { x: 0, y: 0, w: 10, h: 10 }, photoId });
|
||||
|
||||
const page = (role: BookSpread['role'], photos: number): BookSpread => ({
|
||||
id: 'sp',
|
||||
role,
|
||||
background: null,
|
||||
elements: Array.from({ length: photos }, (_, i) => photo(`p${i}`, i + 1)),
|
||||
parked: [],
|
||||
entryId: null,
|
||||
});
|
||||
|
||||
let n = 0;
|
||||
const newId = (p: string) => `${p}-${++n}`;
|
||||
|
||||
describe('matchDecoratedTemplate', () => {
|
||||
it('TPLMATCH-001: recognises every shipped template laid on every page format', () => {
|
||||
for (const preset of ['square-210', 'a4-landscape', 'a4-portrait', 'a5-landscape'] as const) {
|
||||
const format = bookPageSetupForPreset(preset);
|
||||
for (const tpl of BOOK_DECORATED_TEMPLATES) {
|
||||
const laid = applyDecoratedTemplate(page(tpl.kind === 'single' ? 'cover' : 'inner', 8), tpl, format, {
|
||||
words,
|
||||
newId,
|
||||
});
|
||||
expect(matchDecoratedTemplate(laid, format)?.id, `${tpl.id} on ${preset}`).toBe(tpl.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("TPLMATCH-002: recognises it in a theme's colours and with fewer words and photos than it takes", () => {
|
||||
const format = bookPageSetupForPreset('square-210');
|
||||
const theme = bookThemeOf('midnight');
|
||||
for (const tpl of BOOK_DECORATED_TEMPLATES) {
|
||||
const laid = applyDecoratedTemplate(page(tpl.kind === 'single' ? 'cover' : 'inner', 1), tpl, format, {
|
||||
theme,
|
||||
words: { title: words.title },
|
||||
newId,
|
||||
});
|
||||
expect(matchDecoratedTemplate(laid, format)?.id, tpl.id).toBe(tpl.id);
|
||||
}
|
||||
});
|
||||
|
||||
it('TPLMATCH-003: a page rearranged by hand is no template', () => {
|
||||
const format = bookPageSetupForPreset('square-210');
|
||||
const tpl = BOOK_DECORATED_TEMPLATES.find((t) => t.id === 'stamp-row');
|
||||
if (!tpl) throw new Error('stamp-row is shipped');
|
||||
const laid = applyDecoratedTemplate(page('inner', 4), tpl, format, { words, newId });
|
||||
const moved = { ...laid, elements: laid.elements.map((e) => ({ ...e, frame: { ...e.frame, x: e.frame.x + 9 } })) };
|
||||
expect(matchDecoratedTemplate(moved, format)).toBeUndefined();
|
||||
expect(matchDecoratedTemplate({ ...laid, elements: [] }, format)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('TPLMATCH-004: a cover is matched among the covers, a spread among the spreads', () => {
|
||||
const format = bookPageSetupForPreset('square-210');
|
||||
for (const tpl of BOOK_DECORATED_TEMPLATES) {
|
||||
const laid = applyDecoratedTemplate(page(tpl.kind === 'single' ? 'back' : 'inner', 3), tpl, format, { newId });
|
||||
expect(matchDecoratedTemplate(laid, format)?.kind ?? tpl.kind).toBe(tpl.kind);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { BookElement, BookPageSetup, BookSpread } from '../book.schema';
|
||||
import { layDecoratedTemplate } from './template-apply';
|
||||
import { BOOK_DECORATED_TEMPLATES } from './template-catalogue';
|
||||
import type { BookDecoratedTemplate } from './template-format';
|
||||
|
||||
/**
|
||||
* Which decorated template a laid page is, read from the page alone.
|
||||
*
|
||||
* A template keeps its frames where it pours: the photo frames, the role
|
||||
* texts and the design (panels, shapes, lines, stickers) all land where the
|
||||
* template put them on this page's format. So a page is fingerprinted by its
|
||||
* elements' kinds and frames, rounded to the millimetre, and compared with
|
||||
* every template laid on the same format: a template is the page's when
|
||||
* nearly all of its design is there, and of those the one that explains the
|
||||
* most of the page wins. A page somebody rearranged by hand matches none.
|
||||
*
|
||||
* Nothing depends on the answer being right: the auto layout's "this spread"
|
||||
* uses it to offer another template than the one on screen, and at worst
|
||||
* offers that one again.
|
||||
*/
|
||||
|
||||
/** The share of a template's design elements a page must hold to be that template. */
|
||||
const DESIGN_SHARE = 0.8;
|
||||
/** The share of the page's own elements the template must explain: a template of little design is no excuse. */
|
||||
const PAGE_SHARE = 0.6;
|
||||
|
||||
/** A photo frame, and a map laid as one when it had nothing to draw, read alike. */
|
||||
const kindKey = (kind: BookElement['kind']) => (kind === 'map' ? 'photo' : kind);
|
||||
|
||||
const keyOf = (el: BookElement) => {
|
||||
const f = el.frame;
|
||||
return [kindKey(el.kind), Math.round(f.x), Math.round(f.y), Math.round(f.w), Math.round(f.h)].join(':');
|
||||
};
|
||||
|
||||
interface Fingerprint {
|
||||
/** Every laid element, its role's ones included. */
|
||||
all: string[];
|
||||
/** The design alone: what no page's content changes. */
|
||||
design: string[];
|
||||
}
|
||||
|
||||
const laidPrints = new Map<string, Fingerprint>();
|
||||
|
||||
/** A template's fingerprint on a page format, laid once per format. */
|
||||
function printOf(tpl: BookDecoratedTemplate, page: BookPageSetup): Fingerprint {
|
||||
const key = `${tpl.id}|${page.pageWidth}x${page.pageHeight}|${page.bleed}`;
|
||||
const known = laidPrints.get(key);
|
||||
if (known) return known;
|
||||
let n = 0;
|
||||
const laid = layDecoratedTemplate(tpl, page, null, (p) => `${p}${++n}`);
|
||||
const print = { all: laid.map((l) => keyOf(l.el)), design: laid.filter((l) => !l.role).map((l) => keyOf(l.el)) };
|
||||
laidPrints.set(key, print);
|
||||
return print;
|
||||
}
|
||||
|
||||
/** How many of `want` the multiset `have` holds. */
|
||||
function held(want: readonly string[], have: ReadonlyMap<string, number>): number {
|
||||
const left = new Map(have);
|
||||
let found = 0;
|
||||
for (const k of want) {
|
||||
const n = left.get(k) ?? 0;
|
||||
if (n > 0) {
|
||||
left.set(k, n - 1);
|
||||
found++;
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** The decorated template a page was laid on, or undefined when it reads as none. */
|
||||
export function matchDecoratedTemplate(spread: BookSpread, page: BookPageSetup): BookDecoratedTemplate | undefined {
|
||||
const own = spread.elements.filter((e) => !e.locked);
|
||||
if (!own.length) return undefined;
|
||||
const have = new Map<string, number>();
|
||||
for (const el of own) have.set(keyOf(el), (have.get(keyOf(el)) ?? 0) + 1);
|
||||
const kind = spread.role === 'inner' ? 'inner' : 'single';
|
||||
let best: { tpl: BookDecoratedTemplate; explained: number; found: number } | undefined;
|
||||
for (const tpl of BOOK_DECORATED_TEMPLATES) {
|
||||
if (tpl.kind !== kind) continue;
|
||||
const print = printOf(tpl, page);
|
||||
const design = held(print.design, have);
|
||||
if (design < print.design.length * DESIGN_SHARE) continue;
|
||||
const found = held(print.all, have);
|
||||
const explained = found / own.length;
|
||||
if (explained < PAGE_SHARE) continue;
|
||||
if (!best || explained > best.explained || (explained === best.explained && found > best.found)) {
|
||||
best = { tpl, explained, found };
|
||||
}
|
||||
}
|
||||
return best?.tpl;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { bookSpreadSchema, bookTextElementSchema, type BookSpread, type BookTextElement } from '../book.schema';
|
||||
import type { BookTemplateTextRole } from './template-format';
|
||||
import { assignTemplateTexts, bookTemplateWords } from './template-words';
|
||||
import { assignTemplateTexts, bookTemplateWords, isTemplateFigureText } from './template-words';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// TPLWORDS-001 to TPLWORDS-006
|
||||
// TPLWORDS-001 to TPLWORDS-007
|
||||
|
||||
/**
|
||||
* Which of a page's words a template's roles take: the journal's bindings
|
||||
@@ -57,6 +57,15 @@ describe('the page words in the roles', () => {
|
||||
const spare = assignTemplateTexts([text('a', 'One'), text('b', 'Two'), text('c', 'Three')], new Set(['title']));
|
||||
expect(ids(spare)).toEqual({ title: 'a' });
|
||||
});
|
||||
|
||||
it('TPLWORDS-007: takes the figures a template drew for its design, never a date or a bound text', () => {
|
||||
expect(isTemplateFigureText(text('tc', '00:12:48:03'))).toBe(true);
|
||||
expect(isTemplateFigureText(text('frame', '21'))).toBe(true);
|
||||
expect(isTemplateFigureText(text('day', '12.08.2025'))).toBe(false);
|
||||
expect(isTemplateFigureText(text('year', '2027'))).toBe(false);
|
||||
expect(isTemplateFigureText(text('word', 'REC'))).toBe(false);
|
||||
expect(isTemplateFigureText(text('bound', '14', { binding: { source: 'entry.title', entryId: 1 } }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the book words for the roles a page has none for', () => {
|
||||
|
||||
@@ -59,6 +59,15 @@ const SPARE_ORDER: readonly BookTemplateTextRole[] = ['subtitle', 'date', 'capti
|
||||
const DATE_LIKE = /\b(1[89]|20)\d{2}\b|\b\d{1,2}[./]\d{1,2}\b/;
|
||||
|
||||
const isText = (e: BookElement | null | undefined): e is TextEl => !!e && e.kind === 'text' && !!e.text.trim();
|
||||
|
||||
/**
|
||||
* Figures a template drew as its design (a film's timecode, frame numbers, a
|
||||
* plate's number): no letters and not a date, and bound to nothing. They are
|
||||
* not the page's words, so a template laid next never takes one for a title
|
||||
* or a date line.
|
||||
*/
|
||||
export const isTemplateFigureText = (e: BookElement): boolean =>
|
||||
e.kind === 'text' && !e.binding && !/\p{L}/u.test(e.text) && !DATE_LIKE.test(e.text);
|
||||
const isLong = (t: TextEl) => t.text.trim().length > SHORT;
|
||||
|
||||
/** The role a short text reads as on its own: a date line when it says a date, else a subtitle. */
|
||||
|
||||
@@ -53,6 +53,11 @@ export const BOOK_THEME_IDS = [
|
||||
'nordic',
|
||||
'alpine',
|
||||
'india-stamp',
|
||||
'riso',
|
||||
'magazine',
|
||||
'retro-travel',
|
||||
'midnight',
|
||||
'field-notes',
|
||||
] as const;
|
||||
export type BookThemeId = (typeof BOOK_THEME_IDS)[number];
|
||||
|
||||
|
||||
@@ -27,9 +27,9 @@ import { describe, expect, it } from 'vitest';
|
||||
// BKTHEME-001 to BKTHEME-010
|
||||
|
||||
describe('the built-in themes', () => {
|
||||
it('BKTHEME-001: are twelve, one per id, in the order of the ids', () => {
|
||||
it('BKTHEME-001: are seventeen, one per id, in the order of the ids', () => {
|
||||
expect(BOOK_THEME_PRESETS.map((p) => p.id)).toEqual([...BOOK_THEME_IDS]);
|
||||
expect(BOOK_THEME_IDS).toHaveLength(12);
|
||||
expect(BOOK_THEME_IDS).toHaveLength(17);
|
||||
for (const id of BOOK_THEME_IDS) expect(bookThemeOf(id).id).toBe(id);
|
||||
expect(bookThemePreset('nope')).toBeUndefined();
|
||||
expect(() => bookThemeOf('nope' as never)).toThrow();
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
} from './book-theme.schema';
|
||||
|
||||
/**
|
||||
* The twelve built-in themes, each a look of its own rather than a recolour:
|
||||
* The seventeen built-in themes, each a look of its own rather than a recolour:
|
||||
* a paper and an ink that read together, three accents that belong to the
|
||||
* same place, a display face for the cover, a heading and a body face that
|
||||
* pair, a script for the words laid over a photo, and the motifs that suit.
|
||||
@@ -253,4 +253,90 @@ export const BOOK_THEME_PRESETS: readonly BookThemePreset[] = [
|
||||
frameStyle: 'white',
|
||||
tilt: 2,
|
||||
},
|
||||
{
|
||||
id: 'riso',
|
||||
labelKey: 'studio.theme.name.riso',
|
||||
descrKey: 'studio.theme.descr.riso',
|
||||
descr: 'Riso print: cream paper, riso blue, fluo pink, yellow and teal inks overprinted, a bold grotesque.',
|
||||
palette: { paper: '#f4efe4', ink: '#23306b', accent: '#ed4981', accent2: '#ffd23f', accent3: '#00a39a' },
|
||||
fonts: {
|
||||
display: { font: 'bricolage', weight: 800 },
|
||||
heading: { font: 'montserrat', weight: 700 },
|
||||
body: { font: 'inter' },
|
||||
script: { font: 'marker', weight: 400 },
|
||||
},
|
||||
motifs: ['doodles', 'paper'],
|
||||
decoration: 'rich',
|
||||
frameStyle: 'none',
|
||||
tilt: 2,
|
||||
},
|
||||
{
|
||||
id: 'magazine',
|
||||
labelKey: 'studio.theme.name.magazine',
|
||||
descrKey: 'studio.theme.descr.magazine',
|
||||
descr: 'Travel magazine: off white paper, black ink, one signal red, a huge Bodoni masthead over Garamond.',
|
||||
palette: { paper: '#f7f4ee', ink: '#111111', accent: '#d7261e', accent2: '#e9e2d6', accent3: '#6b6b6b' },
|
||||
fonts: {
|
||||
display: { font: 'bodoni', weight: 800 },
|
||||
heading: { font: 'montserrat', weight: 600 },
|
||||
body: { font: 'garamond' },
|
||||
script: null,
|
||||
},
|
||||
motifs: ['ornaments'],
|
||||
decoration: 'light',
|
||||
frameStyle: 'none',
|
||||
tilt: 0,
|
||||
},
|
||||
{
|
||||
id: 'retro-travel',
|
||||
labelKey: 'studio.theme.name.retroTravel',
|
||||
descrKey: 'studio.theme.descr.retroTravel',
|
||||
descr:
|
||||
'Retro travel poster: aged cream paper, brown ink, orange, teal and mustard, a slab title over condensed caps.',
|
||||
palette: { paper: '#f3e7cf', ink: '#3b2a1e', accent: '#d56129', accent2: '#2f7f7a', accent3: '#f2b33d' },
|
||||
fonts: {
|
||||
display: { font: 'alfa', weight: 400 },
|
||||
heading: { font: 'oswald', weight: 500 },
|
||||
body: { font: 'serif' },
|
||||
script: { font: 'yellowtail', weight: 400 },
|
||||
},
|
||||
motifs: ['mountains', 'mediterranean', 'landmarks'],
|
||||
decoration: 'rich',
|
||||
frameStyle: 'white',
|
||||
tilt: 0,
|
||||
},
|
||||
{
|
||||
id: 'midnight',
|
||||
labelKey: 'studio.theme.name.midnight',
|
||||
descrKey: 'studio.theme.descr.midnight',
|
||||
descr: 'Midnight film: near black paper, warm white ink, neon orange and cyan, tall condensed capitals.',
|
||||
palette: { paper: '#121417', ink: '#f2efe8', accent: '#ff6b3d', accent2: '#39d0c4', accent3: '#8a8f98' },
|
||||
fonts: {
|
||||
display: { font: 'league', weight: 400 },
|
||||
heading: { font: 'oswald', weight: 500 },
|
||||
body: { font: 'inter' },
|
||||
script: { font: 'satisfy', weight: 400 },
|
||||
},
|
||||
motifs: ['doodles'],
|
||||
decoration: 'light',
|
||||
frameStyle: 'none',
|
||||
tilt: 0,
|
||||
},
|
||||
{
|
||||
id: 'field-notes',
|
||||
labelKey: 'studio.theme.name.fieldNotes',
|
||||
descrKey: 'studio.theme.descr.fieldNotes',
|
||||
descr: 'Field notes: kraft paper, moss green ink, rust and sage, a soft serif title, handwriting, tape and leaves.',
|
||||
palette: { paper: '#e9dcc3', ink: '#2f3a2b', accent: '#b5532c', accent2: '#f8f2e4', accent3: '#6f8a5a' },
|
||||
fonts: {
|
||||
display: { font: 'fraunces', weight: 700 },
|
||||
heading: { font: 'montserrat', weight: 600 },
|
||||
body: { font: 'serif' },
|
||||
script: { font: 'caveat', weight: 400 },
|
||||
},
|
||||
motifs: ['paper', 'mountains', 'nordic'],
|
||||
decoration: 'rich',
|
||||
frameStyle: 'polaroid',
|
||||
tilt: 3,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { AutoEntry } from '../auto/auto-types';
|
||||
import type { BookTravelContext } from '../book-travel';
|
||||
import { bookElementSchema, bookPageSetupSchema, type BookElement } from '../book.schema';
|
||||
import type { JourneyStats } from '../journey-stats.schema';
|
||||
import { bookThemeColor, bookThemeOf } from '../theme/book-theme';
|
||||
import {
|
||||
BOOK_TRAVEL_COUNTRY_LOOKS,
|
||||
BOOK_TRAVEL_FIGURE_SHELVES,
|
||||
BOOK_TRAVEL_MARK_STYLES,
|
||||
BOOK_TRAVEL_SUMMARY_LOOKS,
|
||||
bookTravelLooks,
|
||||
} from './book-travel-looks';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/** The travel elements in their newer looks, as the Travel panel and the auto layout both build them. */
|
||||
|
||||
type Point = JourneyStats['points'][number];
|
||||
const pt = (label: string, at: [number, number], country: string, entryId: number): Point => ({
|
||||
lat: at[0],
|
||||
lng: at[1],
|
||||
label,
|
||||
date: null,
|
||||
country,
|
||||
tripId: 1,
|
||||
photoId: null,
|
||||
entryId,
|
||||
});
|
||||
|
||||
const stats: JourneyStats = {
|
||||
journeyId: 5,
|
||||
distance: 4_000_000,
|
||||
days: 21,
|
||||
steps: 6,
|
||||
photos: 40,
|
||||
places: 12,
|
||||
furthest: 3_000_000,
|
||||
countries: [
|
||||
{ code: 'IS', name: 'Iceland', places: 2, firstVisit: '2026-06-02' },
|
||||
{ code: 'PT', name: 'Portugal', places: 1, firstVisit: '2026-09-04' },
|
||||
],
|
||||
points: [
|
||||
pt('Reykjavík', [64.14, -21.94], 'IS', 1),
|
||||
pt('Akureyri', [65.68, -18.12], 'IS', 2),
|
||||
pt('Lisboa', [38.72, -9.14], 'PT', 3),
|
||||
],
|
||||
trips: [],
|
||||
excluded: [],
|
||||
start: '2026-06-02',
|
||||
end: '2026-09-12',
|
||||
};
|
||||
|
||||
const entry: AutoEntry = {
|
||||
id: 2,
|
||||
title: 'North',
|
||||
story: null,
|
||||
location: 'Akureyri',
|
||||
date: '2026-06-06',
|
||||
photos: [],
|
||||
lat: 65.68,
|
||||
lng: -18.12,
|
||||
country: 'IS',
|
||||
dayNumber: 5,
|
||||
dayCount: 21,
|
||||
};
|
||||
|
||||
const travel: BookTravelContext = {
|
||||
locale: 'en',
|
||||
stats,
|
||||
path: [],
|
||||
entries: [entry],
|
||||
labels: {
|
||||
day: 'DAY {day}',
|
||||
stations: 'Stops',
|
||||
summary: 'Summary',
|
||||
countries: 'Countries',
|
||||
distance: 'km',
|
||||
pros: '+',
|
||||
cons: '-',
|
||||
},
|
||||
};
|
||||
|
||||
const page = bookPageSetupSchema.parse({});
|
||||
const theme = bookThemeOf('riso');
|
||||
let n = 0;
|
||||
const newId = (p: string) => `${p}-${++n}`;
|
||||
const place = (size: { w: number; h: number }) => ({ x: 1, y: 2, w: size.w, h: size.h });
|
||||
const looks = bookTravelLooks({ page, stats, travel, theme, newId, place });
|
||||
const plain = bookTravelLooks({ page, stats, travel, theme: null, newId, place });
|
||||
|
||||
const valid = (el: BookElement | null) => {
|
||||
expect(el).not.toBeNull();
|
||||
expect(bookElementSchema.safeParse(el).success).toBe(true);
|
||||
};
|
||||
|
||||
/** Every element of every shelf, as the panel offers them. */
|
||||
function everyElement(of: typeof looks): BookElement[] {
|
||||
const out: BookElement[] = [];
|
||||
for (const look of BOOK_TRAVEL_SUMMARY_LOOKS) out.push(of.summaryEl(look));
|
||||
for (const shelf of BOOK_TRAVEL_FIGURE_SHELVES) for (const m of shelf.metrics) out.push(of.figureEl(m, shelf.look));
|
||||
for (const code of ['IS', 'PT']) for (const look of BOOK_TRAVEL_COUNTRY_LOOKS) out.push(of.countryEl(code, look));
|
||||
for (const kind of ['shape', 'satellite', 'box'] as const) out.push(of.countryMapEl('IS', kind));
|
||||
out.push(of.mapEl({ style: 'minimal' }), of.mapEl({ style: 'dark', pins: 'numbered' }));
|
||||
for (const { style, variants } of BOOK_TRAVEL_MARK_STYLES) {
|
||||
for (const v of variants)
|
||||
out.push(...[of.markEl(v, style), of.markEl(v, style, { entry })].flatMap((e) => (e ? [e] : [])));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('the travel looks', () => {
|
||||
it('TRVLOOK-001: every factory answers an element the document takes, in every look it offers', () => {
|
||||
const all = everyElement(looks);
|
||||
expect(all.length).toBeGreaterThan(60);
|
||||
for (const el of all) valid(el);
|
||||
});
|
||||
|
||||
it('TRVLOOK-002: never in the classic look or a classic mark style', () => {
|
||||
for (const el of [...everyElement(looks), ...everyElement(plain)]) {
|
||||
if (el.kind === 'stats' || el.kind === 'countries') expect(el.look).toBeDefined();
|
||||
if (el.kind === 'stats' || el.kind === 'countries') expect(el.look).not.toBe('classic');
|
||||
if (el.kind === 'badge') expect(['plain', 'chip', 'outline', 'stacked']).not.toContain(el.style);
|
||||
}
|
||||
});
|
||||
|
||||
it("TRVLOOK-003: dressed in the book's theme, its links kept", () => {
|
||||
const stamp = looks.countryEl('IS', 'stamp');
|
||||
expect(stamp).toMatchObject({ token: 'ink', accentToken: 'accent2', fontRole: 'heading' });
|
||||
if (stamp.kind !== 'countries') throw new Error('a country element');
|
||||
expect(stamp.color).toBe(bookThemeColor(theme, 'ink'));
|
||||
expect(stamp.accent).toBe(bookThemeColor(theme, 'accent2'));
|
||||
expect(looks.summaryEl('note')).toMatchObject({ fontRole: 'script' });
|
||||
// Without a theme the engine's own colours stay, unlinked.
|
||||
expect(plain.countryEl('IS', 'stamp')).not.toHaveProperty('token');
|
||||
});
|
||||
|
||||
it('TRVLOOK-004: lands in the frame given, else where the caller places one of its size', () => {
|
||||
const frame = { x: 10, y: 20, w: 30, h: 40 };
|
||||
expect(looks.summaryEl('poster', frame).frame).toEqual(frame);
|
||||
expect(looks.countryEl('PT', 'visa', frame).frame).toEqual(frame);
|
||||
expect(looks.figureEl('distance', 'odometer', frame).frame).toEqual(frame);
|
||||
expect(looks.mapEl({ style: 'minimal' }, 'journey', 'vector', frame).frame).toEqual(frame);
|
||||
expect(looks.markEl('day', 'stamp', { entry, frame })?.frame).toEqual(frame);
|
||||
const placed = looks.summaryEl('ticket');
|
||||
expect(placed.frame).toMatchObject({ x: 1, y: 2, ...looks.summarySize('ticket') });
|
||||
});
|
||||
|
||||
it("TRVLOOK-005: a mark takes an entry's value when given one, the journey's when not", () => {
|
||||
const day = looks.markEl('day', 'stamp', { entry });
|
||||
const first = looks.markEl('day', 'stamp');
|
||||
expect(day).toMatchObject({ kind: 'badge', text: 'DAY 5' });
|
||||
expect(first).toMatchObject({ kind: 'badge', text: 'DAY 1' });
|
||||
expect(looks.markEl('flag', 'tag', { entry })).toMatchObject({ code: 'IS' });
|
||||
const lost = bookTravelLooks({ page, stats, travel: { ...travel, stats: null }, theme, newId, place });
|
||||
expect(lost.markEl('distance', 'pill')).toBeNull();
|
||||
});
|
||||
|
||||
it("TRVLOOK-006: a country's map holds that country's stops alone, cut to its shape", () => {
|
||||
const map = looks.countryMapEl('IS', 'shape');
|
||||
if (map.kind !== 'map') throw new Error('a map');
|
||||
expect(map.points).toHaveLength(2);
|
||||
expect(map).toMatchObject({ clip: 'country', style: 'dark', pinStyle: 'numbered' });
|
||||
expect(looks.countryMapEl('IS', 'box')).toMatchObject({ clip: 'rect', style: 'minimal' });
|
||||
expect(looks.countryName('PT')).toBe('Portugal');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
import type { AutoEntry } from '../auto/auto-types';
|
||||
import {
|
||||
bookBadgeValue,
|
||||
bookCountryName,
|
||||
bookTravelBadge,
|
||||
bookTravelCountries,
|
||||
bookTravelMap,
|
||||
bookTravelSize,
|
||||
bookTravelStats,
|
||||
type BookMapScope,
|
||||
type BookTravelContext,
|
||||
} from '../book-travel';
|
||||
import type {
|
||||
BookBadgeStyle,
|
||||
BookBadgeVariant,
|
||||
BookCountryLook,
|
||||
BookElement,
|
||||
BookFrame,
|
||||
BookMapElement,
|
||||
BookMetric,
|
||||
BookPageSetup,
|
||||
BookStatsLook,
|
||||
} from '../book.schema';
|
||||
import type { JourneyStats } from '../journey-stats.schema';
|
||||
import { themedElement } from '../theme/book-theme-apply';
|
||||
import type { BookColorToken, BookTheme } from '../theme/book-theme.schema';
|
||||
|
||||
/**
|
||||
* The travel elements in their newer looks, dressed in the book's theme: a
|
||||
* summary as a poster, a ticket or a sign, a single figure on a medal or an
|
||||
* odometer, a map of the journey or of one country, a mark as a stamp or a
|
||||
* tag, a country as a stamp, a visa or a postcard.
|
||||
*
|
||||
* One factory for the Studio's Travel panel, whose cards are the elements
|
||||
* their click places, and for the auto layout, which builds its framing pages
|
||||
* from the same looks. Both take the book engine's own factories
|
||||
* (book-travel.ts), so a card, a designed page and what the assistant places
|
||||
* are one thing. Where an element lands is the caller's: the panel centres it
|
||||
* on the spread on screen, a designed page names its frame.
|
||||
*/
|
||||
|
||||
/** The figures a summary offers: how far, how long, where, and how much was kept. */
|
||||
const SUMMARY_METRICS: readonly BookMetric[] = ['distance', 'days', 'countries', 'photos'];
|
||||
|
||||
/** The looks of a summary, in the order the panel offers them. */
|
||||
export const BOOK_TRAVEL_SUMMARY_LOOKS: readonly BookStatsLook[] = [
|
||||
'poster',
|
||||
'ticket',
|
||||
'tiles',
|
||||
'sign',
|
||||
'odometer',
|
||||
'note',
|
||||
'sketch',
|
||||
'band',
|
||||
];
|
||||
|
||||
/** The marks the journey fills, in the order a page is read: when, which day, how far, where. */
|
||||
export const BOOK_TRAVEL_MARK_VARIANTS = ['date', 'day', 'distance', 'coords', 'flag'] as const;
|
||||
export type BookTravelMarkVariant = (typeof BOOK_TRAVEL_MARK_VARIANTS)[number];
|
||||
|
||||
/** The newer styles a mark comes in, and which of them each mark is offered in. */
|
||||
export const BOOK_TRAVEL_MARK_STYLES: readonly {
|
||||
style: BookBadgeStyle;
|
||||
variants: readonly BookTravelMarkVariant[];
|
||||
}[] = [
|
||||
{ style: 'stamp', variants: ['date', 'day', 'flag'] },
|
||||
{ style: 'tag', variants: ['day', 'distance', 'coords', 'flag'] },
|
||||
{ style: 'pill', variants: ['date', 'day', 'distance', 'coords', 'flag'] },
|
||||
{ style: 'ribbon', variants: ['date', 'day', 'distance', 'flag'] },
|
||||
{ style: 'sticker', variants: ['date', 'day', 'distance', 'flag'] },
|
||||
{ style: 'patch', variants: ['day', 'distance', 'flag'] },
|
||||
{ style: 'sketch', variants: ['date', 'day', 'distance', 'coords', 'flag'] },
|
||||
];
|
||||
|
||||
/** The single figures, one shelf per look, with the figures each look suits. */
|
||||
export const BOOK_TRAVEL_FIGURE_SHELVES: readonly { look: BookStatsLook; metrics: readonly BookMetric[] }[] = [
|
||||
{ look: 'medal', metrics: ['distance', 'days', 'steps', 'photos', 'countries', 'places', 'furthest'] },
|
||||
{ look: 'odometer', metrics: ['distance', 'furthest', 'days', 'photos'] },
|
||||
{ look: 'sketch', metrics: ['distance', 'days', 'countries', 'photos'] },
|
||||
];
|
||||
|
||||
/** The looks a country is offered in. */
|
||||
export const BOOK_TRAVEL_COUNTRY_LOOKS: readonly BookCountryLook[] = [
|
||||
'stamp',
|
||||
'visa',
|
||||
'postcard',
|
||||
'flag',
|
||||
'pin',
|
||||
'label',
|
||||
'sketch',
|
||||
];
|
||||
|
||||
/** The looks written by hand take the theme's script face rather than its heading. */
|
||||
const HANDWRITTEN = new Set<string>(['note', 'sketch']);
|
||||
|
||||
/** Which of the theme's accents each country look takes, so a shelf of them is not one colour. */
|
||||
export const BOOK_TRAVEL_COUNTRY_ACCENT: Partial<Record<BookCountryLook, BookColorToken>> = {
|
||||
stamp: 'accent2',
|
||||
visa: 'accent3',
|
||||
pin: 'accent',
|
||||
label: 'accent2',
|
||||
};
|
||||
|
||||
/** Which of the theme's accents each mark takes, so a page of them is not one colour. */
|
||||
const MARK_ACCENT: Record<BookTravelMarkVariant, BookColorToken> = {
|
||||
date: 'accent',
|
||||
day: 'accent2',
|
||||
distance: 'accent3',
|
||||
coords: 'accent',
|
||||
flag: 'accent2',
|
||||
};
|
||||
|
||||
/** A map card: its ground and the pins it marks its stops with. */
|
||||
export interface BookTravelMapCard {
|
||||
style: BookMapElement['style'];
|
||||
pins?: BookMapElement['pinStyle'];
|
||||
}
|
||||
|
||||
interface BookTravelLooksInput {
|
||||
page: BookPageSetup;
|
||||
stats: JourneyStats;
|
||||
/** The journey's figures, way and words, as the engine's factories read them. */
|
||||
travel: BookTravelContext;
|
||||
/** The book's theme; without one an element keeps the engine's own colours. */
|
||||
theme: BookTheme | null | undefined;
|
||||
newId: (prefix: string) => string;
|
||||
/** Where an element of this size lands when the caller names no frame of its own. */
|
||||
place: (size: { w: number; h: number }) => BookFrame;
|
||||
}
|
||||
|
||||
/** The travel elements of one book, each in the frame given or where `place` puts one of its size. */
|
||||
export function bookTravelLooks({ page, stats, travel, theme, newId, place }: BookTravelLooksInput) {
|
||||
const frameFor = (size: { w: number; h: number }, frame?: BookFrame) => frame ?? place(size);
|
||||
|
||||
/**
|
||||
* An element dressed in the book's theme: its words in the theme's ink and
|
||||
* heading face, its colour one of the theme's accents, all three linked so
|
||||
* it follows when the theme changes. A book without a theme keeps the
|
||||
* engine's own colours.
|
||||
*/
|
||||
const dress = (el: BookElement, accent: BookColorToken = 'accent', hand = false): BookElement =>
|
||||
theme
|
||||
? themedElement(
|
||||
{ ...el, token: 'ink', accentToken: accent, fontRole: hand ? 'script' : 'heading' } as BookElement,
|
||||
theme,
|
||||
)
|
||||
: el;
|
||||
|
||||
/** The size a summary is set at in a look; a band is a strip, so it is set a third as tall. */
|
||||
const summarySize = (look: BookStatsLook) => {
|
||||
const w = page.pageWidth * (look === 'note' || look === 'sign' ? 0.56 : 0.72);
|
||||
const h =
|
||||
look === 'band' ? page.pageHeight * 0.13 : look === 'ticket' ? w * 0.42 : look === 'note' ? w * 0.72 : w * 0.5;
|
||||
return { w, h };
|
||||
};
|
||||
|
||||
/** A summary in one of the newer looks, of the figures given or the four a summary offers. */
|
||||
const summaryEl = (look: BookStatsLook, frame?: BookFrame, metrics: readonly BookMetric[] = SUMMARY_METRICS) => {
|
||||
const at = frameFor(summarySize(look), frame);
|
||||
const el = { ...bookTravelStats(stats, { id: newId('st'), frame: at, metrics }), look };
|
||||
return dress(el, look === 'sign' ? 'accent3' : 'accent', HANDWRITTEN.has(look));
|
||||
};
|
||||
|
||||
/** The size a single figure is set at: a square, or the drums of an odometer. */
|
||||
const figureSize = (look: BookStatsLook) => {
|
||||
const side = Math.min(page.pageWidth, page.pageHeight) * 0.3;
|
||||
return look === 'odometer' ? { w: side * 1.6, h: side * 0.62 } : { w: side, h: side };
|
||||
};
|
||||
|
||||
/**
|
||||
* One figure on its own, the mark to drop beside a photograph: as a medal, on
|
||||
* the drums of an odometer, or ringed by hand.
|
||||
*/
|
||||
const figureEl = (metric: BookMetric, look: BookStatsLook = 'medal', frame?: BookFrame): BookElement => {
|
||||
const at = frameFor(figureSize(look), frame);
|
||||
const el = { ...bookTravelStats(stats, { id: newId('st'), frame: at, metrics: [metric] }), look };
|
||||
return dress(el, 'accent', HANDWRITTEN.has(look));
|
||||
};
|
||||
|
||||
/**
|
||||
* A map as the engine places it, on the card's ground and with the card's
|
||||
* stops. The scope is the journey or one trip; the ground is the outlines
|
||||
* or one of the two imagery sources.
|
||||
*/
|
||||
const mapEl = (
|
||||
card: BookTravelMapCard,
|
||||
scope: BookMapScope = 'journey',
|
||||
source: 'vector' | 'relief' | 'satellite' = 'vector',
|
||||
frame?: BookFrame,
|
||||
): BookElement => {
|
||||
const at = frameFor(bookTravelSize('map', page), frame);
|
||||
const el = bookTravelMap(travel, stats, { id: newId('mp'), frame: at, scope, source });
|
||||
const shaped = { ...el, style: card.style, ...(card.pins ? { pinStyle: card.pins } : {}) };
|
||||
// Over imagery the line is white on purpose (book-travel.ts); only the outline maps take the theme.
|
||||
return source === 'vector' ? dress(shaped) : shaped;
|
||||
};
|
||||
|
||||
/**
|
||||
* A map of one country: its stops alone, without the way there. A journey's
|
||||
* recorded way often starts with the flight from home, and fitted to all of
|
||||
* it the country is a speck at the edge of a continent; the page a travel
|
||||
* book wants is the country, cut to its own shape on the paper or over the
|
||||
* satellite picture, or the country in a box.
|
||||
*/
|
||||
const countryMapEl = (code: string, kind: 'shape' | 'satellite' | 'box', frame?: BookFrame): BookElement => {
|
||||
const own: JourneyStats = {
|
||||
...stats,
|
||||
points: stats.points.filter((p) => p.country === code),
|
||||
countries: stats.countries.filter((c) => c.code === code),
|
||||
};
|
||||
const ctx: BookTravelContext = { ...travel, stats: own, path: [], entries: [] };
|
||||
const source = kind === 'satellite' ? 'satellite' : 'vector';
|
||||
const at = frameFor(bookTravelSize('map', page), frame);
|
||||
const el = bookTravelMap(ctx, own, { id: newId('mp'), frame: at, scope: 'journey', source });
|
||||
// Cut to its shape on the paper, the land is dark, so the silhouette reads and the route in the accent stands on it.
|
||||
const style = kind === 'shape' ? 'dark' : 'minimal';
|
||||
const shaped = {
|
||||
...el,
|
||||
style,
|
||||
clip: kind === 'box' ? 'rect' : 'country',
|
||||
pinStyle: 'numbered',
|
||||
fitToCountries: true,
|
||||
} as const;
|
||||
return source === 'vector' ? dress(shaped) : shaped;
|
||||
};
|
||||
|
||||
/**
|
||||
* How large a mark is set: as wide as its words need. A stamp stacks them in
|
||||
* a box with room for its two rules; a tag or a pill sets them in a line
|
||||
* after its eyelet or its disc, so its length follows the words.
|
||||
*/
|
||||
const markSize = (style: BookBadgeStyle, text: string, sub: string) => {
|
||||
const twoLines = !!text && !!sub && text.length > 14;
|
||||
const h = page.pageHeight * (style === 'stamp' ? 0.12 : twoLines ? 0.1 : 0.066);
|
||||
const main = text || sub;
|
||||
if (style === 'stamp') return { w: Math.max(h * 1.3, h * 0.2 * Math.max(main.length * 1.4, sub.length)), h };
|
||||
// Two lines are set smaller than one, so they need less width a letter.
|
||||
if (twoLines)
|
||||
return { w: Math.min(page.pageWidth * 0.6, h * (1.5 + 0.13 * Math.max(text.length, sub.length * 0.7))), h };
|
||||
const chars = main.length * (text ? 1 : 0.85) + (text ? sub.length * 0.5 : 0);
|
||||
return { w: Math.min(page.pageWidth * 0.6, h * (1.6 + 0.3 * chars)), h };
|
||||
};
|
||||
|
||||
/**
|
||||
* A mark in one of the newer styles, at the size its variant suits; a stamp
|
||||
* is set taller for its two rules. The journey's value, or an entry's when
|
||||
* one is given; null when nothing says what it would show.
|
||||
*/
|
||||
const markEl = (
|
||||
variant: BookTravelMarkVariant,
|
||||
style: BookBadgeStyle,
|
||||
of: { entry?: AutoEntry | null; frame?: BookFrame } = {},
|
||||
): BookElement | null => {
|
||||
const value = bookBadgeValue(travel, variant, of.entry ?? null);
|
||||
if (!value) return null;
|
||||
const at = frameFor(markSize(style, value.text, value.sub), of.frame);
|
||||
const el = {
|
||||
...bookTravelBadge({ id: newId('bd'), frame: at, variant: variant as BookBadgeVariant, value }),
|
||||
style,
|
||||
};
|
||||
return dress(el, MARK_ACCENT[variant], style === 'sketch');
|
||||
};
|
||||
|
||||
/** The size a country is set at: a square stamp, or a card in the proportions of a postcard. */
|
||||
const countrySize = (look: BookCountryLook) => {
|
||||
const side = Math.min(page.pageWidth, page.pageHeight) * 0.36;
|
||||
const ratio = look === 'stamp' || look === 'pin' || look === 'sketch' ? 1 : look === 'postcard' ? 1.36 : 1.4;
|
||||
return { w: side * ratio, h: side };
|
||||
};
|
||||
|
||||
/** One country as a square stamp, or as a card in the proportions of a postcard. */
|
||||
const countryEl = (code: string, look: BookCountryLook, frame?: BookFrame): BookElement => {
|
||||
const at = frameFor(countrySize(look), frame);
|
||||
const el = {
|
||||
...bookTravelCountries(travel, { id: newId('co'), frame: at, codes: [code] }),
|
||||
look,
|
||||
showFlag: look === 'pin' || look === 'postcard',
|
||||
};
|
||||
return dress(el, BOOK_TRAVEL_COUNTRY_ACCENT[look] ?? 'accent', look === 'sketch');
|
||||
};
|
||||
|
||||
/** A country's name in the reader's language, from the same CLDR data Atlas uses. */
|
||||
const countryName = (code: string): string => bookCountryName(code, travel.locale, stats);
|
||||
|
||||
return {
|
||||
summaryEl,
|
||||
summarySize,
|
||||
figureEl,
|
||||
figureSize,
|
||||
mapEl,
|
||||
countryMapEl,
|
||||
markEl,
|
||||
markSize,
|
||||
countryEl,
|
||||
countrySize,
|
||||
countryName,
|
||||
};
|
||||
}
|
||||
|
||||
/** The travel elements one book builds: what `bookTravelLooks` answers. */
|
||||
export type BookTravelLooks = ReturnType<typeof bookTravelLooks>;
|
||||
@@ -614,8 +614,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': 'يبدأ من',
|
||||
'journey.studio.folioMargin': 'الهامش',
|
||||
'journey.studio.relayoutSpread': 'هذه الصفحة',
|
||||
'journey.studio.relayoutSpreadHint': 'إعادة بنائها من مدخلها',
|
||||
'journey.studio.relayoutSpreadNone': 'هذه الصفحة ليست من مدخل',
|
||||
'journey.studio.relayoutSpreadHint': 'ضعها على تصميم آخر',
|
||||
'journey.studio.relayoutSpreadNone': 'لم ينشئ التخطيط التلقائي هذه الصفحة',
|
||||
'journey.studio.relayoutBook': 'الكتاب كامل',
|
||||
'journey.studio.relayoutBookHint': 'يستبدل كل الصفحات — يمكن التراجع',
|
||||
'journey.studio.tpl.coverFull': 'بلا حواف',
|
||||
|
||||
@@ -13,6 +13,11 @@ const studioTemplates: TranslationStrings = {
|
||||
'studio.templates.archetype.grid-badge': 'شبكة وشارة',
|
||||
'studio.templates.archetype.minimal-serif': 'بسيط',
|
||||
'studio.templates.archetype.stamp-collage': 'طوابع',
|
||||
'studio.templates.archetype.riso': 'ريزو',
|
||||
'studio.templates.archetype.editorial': 'مجلة',
|
||||
'studio.templates.archetype.vintage-poster': 'ملصق قديم',
|
||||
'studio.templates.archetype.night-film': 'فيلم ليلي',
|
||||
'studio.templates.archetype.field-notes': 'ملاحظات ميدانية',
|
||||
'studio.templates.name.spineBandStitched': 'شريط مخيط',
|
||||
'studio.templates.name.spineBandGingham': 'شريط مربعات',
|
||||
'studio.templates.name.spineBandScript': 'شريط وخط يدوي',
|
||||
@@ -50,6 +55,77 @@ const studioTemplates: TranslationStrings = {
|
||||
'studio.templates.name.gridFeature': 'شبكة بصورة رئيسية',
|
||||
'studio.templates.name.gridColumns': 'شبكة أعمدة',
|
||||
'studio.templates.name.gridMosaic': 'شبكة فسيفساء',
|
||||
'studio.templates.name.spineBandPlate': 'لوحة ملصقة',
|
||||
'studio.templates.name.posterPopSunDisc': 'قرص الشمس',
|
||||
'studio.templates.name.cutoutCircle': 'دائرة مقصوصة',
|
||||
'studio.templates.name.postcardGreetings': 'بطاقة تحية بريدية',
|
||||
'studio.templates.name.gridBadgeTiles': 'شبكة شارات',
|
||||
'studio.templates.name.minimalSerifCameo': 'نقش بيضاوي',
|
||||
'studio.templates.name.stampCollageAirmail': 'ظرف بريد جوي',
|
||||
'studio.templates.name.risoOverprint': 'ملصق بطباعة متراكبة',
|
||||
'studio.templates.name.risoHalftoneSun': 'شمس نقطية',
|
||||
'studio.templates.name.risoZineStack': 'كومة مجلات صغيرة',
|
||||
'studio.templates.name.risoPinkPress': 'طباعة وردية',
|
||||
'studio.templates.name.editorialMasthead': 'غلاف بترويسة',
|
||||
'studio.templates.name.editorialCoverStory': 'قصة الغلاف',
|
||||
'studio.templates.name.editorialSplit': 'صورة فوق العنوان',
|
||||
'studio.templates.name.editorialHeadline': 'غلاف بعنوان عريض',
|
||||
'studio.templates.name.vintagePosterPanorama': 'بانوراما ملصق',
|
||||
'studio.templates.name.vintagePosterArch': 'قوس وبحر',
|
||||
'studio.templates.name.vintagePosterSunDisc': 'شمس فوق التلال',
|
||||
'studio.templates.name.vintagePosterPeaks': 'قمم متراصة',
|
||||
'studio.templates.name.nightFilmLetterbox': 'غلاف بشاشة عريضة',
|
||||
'studio.templates.name.nightFilmStrip': 'غلاف بشريط فيلم',
|
||||
'studio.templates.name.nightFilmTitleCard': 'بطاقة العنوان',
|
||||
'studio.templates.name.nightFilmFadeOut': 'تلاشٍ إلى السواد',
|
||||
'studio.templates.name.fieldNotesTorn': 'ورقة دفتر ممزقة',
|
||||
'studio.templates.name.fieldNotesPolaroid': 'بولارويد بشريط لاصق',
|
||||
'studio.templates.name.fieldNotesSpecimens': 'عينات بشريط لاصق',
|
||||
'studio.templates.name.fieldNotesLabel': 'ملصق الدفتر',
|
||||
'studio.templates.name.spineBandClothStory': 'قصة على القماش',
|
||||
'studio.templates.name.spineBandGinghamTrio': 'ثلاثية بكعب مربعات',
|
||||
'studio.templates.name.spineBandAlbum': 'ألبوم مجلد',
|
||||
'studio.templates.name.posterPopHeadline': 'عنوان ملصق',
|
||||
'studio.templates.name.posterPopPostcards': 'صور مائلة',
|
||||
'studio.templates.name.posterPopPhotoWall': 'جدار صور',
|
||||
'studio.templates.name.cutoutStoryPage': 'قصة مقصوصة',
|
||||
'studio.templates.name.cutoutTrio': 'ثلاثية مقصوصة',
|
||||
'studio.templates.name.cutoutScatter': 'صور متناثرة',
|
||||
'studio.templates.name.cutoutMosaic': 'فسيفساء مقصوصة',
|
||||
'studio.templates.name.postcardAirmail': 'رسالة بريد جوي',
|
||||
'studio.templates.name.postcardRack': 'حامل بطاقات بريدية',
|
||||
'studio.templates.name.gridBadgeLetter': 'ختم وقصة',
|
||||
'studio.templates.name.gridBadgeHeroSix': 'صورة رئيسية وست',
|
||||
'studio.templates.name.minimalSerifEssay': 'مقالة هادئة',
|
||||
'studio.templates.name.minimalSerifTriptych': 'ثلاث لوحات',
|
||||
'studio.templates.name.stampCollageLetter': 'رسالة مختومة',
|
||||
'studio.templates.name.stampCollageArchStamps': 'قوس وطوابع',
|
||||
'studio.templates.name.stampCollageSheet': 'ورقة طوابع',
|
||||
'studio.templates.name.risoStoryPlate': 'قصة ولوحة',
|
||||
'studio.templates.name.risoOffsetPair': 'زوج مزاح',
|
||||
'studio.templates.name.risoBlobTrio': 'ثلاثية بقع',
|
||||
'studio.templates.name.risoZineGrid': 'شبكة مجلة صغيرة',
|
||||
'studio.templates.name.risoPrintScatter': 'طبعات منثورة',
|
||||
'studio.templates.name.editorialFeatureOpener': 'افتتاحية تحقيق',
|
||||
'studio.templates.name.editorialPullQuote': 'اقتباس بارز',
|
||||
'studio.templates.name.editorialFeatureColumns': 'تحقيق في أعمدة',
|
||||
'studio.templates.name.editorialPortfolio': 'صفحتا معرض أعمال',
|
||||
'studio.templates.name.editorialContactSheet': 'ورقة تلامس',
|
||||
'studio.templates.name.vintagePosterStory': 'ملصق وقصة',
|
||||
'studio.templates.name.vintagePosterTwin': 'ملصقان توأمان',
|
||||
'studio.templates.name.vintagePosterArcade': 'رواق أقواس',
|
||||
'studio.templates.name.vintagePosterPanels': 'ألواح وتلال',
|
||||
'studio.templates.name.vintagePosterLabels': 'بطاقات أمتعة',
|
||||
'studio.templates.name.nightFilmOpening': 'المشهد الافتتاحي',
|
||||
'studio.templates.name.nightFilmSubtitles': 'ترجمة الفيلم',
|
||||
'studio.templates.name.nightFilmReels': 'تلاشٍ وبكرات',
|
||||
'studio.templates.name.nightFilmContact': 'ورقة تلامس الفيلم',
|
||||
'studio.templates.name.nightFilmDoubleReel': 'بكرة مزدوجة',
|
||||
'studio.templates.name.fieldNotesDiary': 'صفحة يوميات',
|
||||
'studio.templates.name.fieldNotesTrail': 'درب بطاقات ملاحظات',
|
||||
'studio.templates.name.fieldNotesRoute': 'مسار وصور بولارويد',
|
||||
'studio.templates.name.fieldNotesContact': 'ورقة تلامس مرقمة',
|
||||
'studio.templates.name.fieldNotesHerbarium': 'أوراق معشبة',
|
||||
};
|
||||
|
||||
export default studioTemplates;
|
||||
|
||||
@@ -13,6 +13,11 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.name.nordic': 'شمالي',
|
||||
'studio.theme.name.alpine': 'جبال الألب',
|
||||
'studio.theme.name.indiaStamp': 'مجموعة طوابع',
|
||||
'studio.theme.name.riso': 'طباعة ريزو',
|
||||
'studio.theme.name.magazine': 'مجلة',
|
||||
'studio.theme.name.retroTravel': 'سفر قديم الطراز',
|
||||
'studio.theme.name.midnight': 'منتصف الليل',
|
||||
'studio.theme.name.fieldNotes': 'ملاحظات ميدانية',
|
||||
'studio.theme.descr.posterPop': 'ورق كريمي ومرجاني ودوّار الشمس وعناوين ملصقات عريضة',
|
||||
'studio.theme.descr.cutout': 'ورق كوبالتي وأشكال بلون المرجان والرمل وكتابة بيضاء',
|
||||
'studio.theme.descr.postcard': 'ورق بلون النعناع وحبر كحلي وأحمر الختم وعنوان فرعي بخط اليد',
|
||||
@@ -25,6 +30,11 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.descr.nordic': 'ورق رمادي فاتح مع لون الأردواز والمغرة والوردي',
|
||||
'studio.theme.descr.alpine': 'أبيض ثلجي مع أخضر الغابات ولون الصدأ وأزرق السماء',
|
||||
'studio.theme.descr.indiaStamp': 'ورق دافئ مع الأرجواني والزعفراني والأزرق المخضر',
|
||||
'studio.theme.descr.riso': 'ورق كريمي وأزرق ريزو ووردي فسفوري مطبوعان فوق بعضهما',
|
||||
'studio.theme.descr.magazine': 'ورق أبيض مائل للصفرة وحبر أسود ولون أحمر وترويسة كبيرة',
|
||||
'studio.theme.descr.retroTravel': 'ورق كريمي معتق بالبرتقالي والأخضر المزرق والخردلي',
|
||||
'studio.theme.descr.midnight': 'ورق شبه أسود ونص أبيض دافئ ولمسات نيون',
|
||||
'studio.theme.descr.fieldNotes': 'ورق كرافت وأخضر طحلبي وصدئ وخط يد وشريط لاصق',
|
||||
'studio.theme.panel': 'السمة',
|
||||
'studio.theme.intro': 'اختر مظهرًا للكتاب كله. الألوان والخطوط التي تتبع السمة تتغير معها، وكل ما عداها يبقى كما هو.',
|
||||
'studio.theme.themes': 'السمات',
|
||||
|
||||
@@ -640,8 +640,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioMargin': 'Kənar boşluğu',
|
||||
|
||||
'journey.studio.relayoutSpread': 'Bu açılım',
|
||||
'journey.studio.relayoutSpreadHint': 'Onu qeydindən yenidən qur',
|
||||
'journey.studio.relayoutSpreadNone': 'Bu səhifə qeyddən yaradılmayıb',
|
||||
'journey.studio.relayoutSpreadHint': 'Başqa dizayna yerləşdir',
|
||||
'journey.studio.relayoutSpreadNone': 'Bu səhifəni avtomatik tərtibat yaratmayıb',
|
||||
'journey.studio.relayoutBook': 'Bütün kitab',
|
||||
'journey.studio.relayoutBookHint': 'Bütün səhifələri əvəz edir — geri qaytarmaq mümkündür',
|
||||
|
||||
|
||||
@@ -13,6 +13,11 @@ const studioTemplates: TranslationStrings = {
|
||||
'studio.templates.archetype.grid-badge': 'Şəbəkə və nişan',
|
||||
'studio.templates.archetype.minimal-serif': 'Minimal', // same-as-en
|
||||
'studio.templates.archetype.stamp-collage': 'Markalar',
|
||||
'studio.templates.archetype.riso': 'Rizo',
|
||||
'studio.templates.archetype.editorial': 'Jurnal',
|
||||
'studio.templates.archetype.vintage-poster': 'Retro afişa',
|
||||
'studio.templates.archetype.night-film': 'Gecə filmi',
|
||||
'studio.templates.archetype.field-notes': 'Səyahət qeydləri',
|
||||
'studio.templates.name.spineBandStitched': 'Tikişli zolaq',
|
||||
'studio.templates.name.spineBandGingham': 'Damalı zolaq',
|
||||
'studio.templates.name.spineBandScript': 'Zolaq və əl yazısı',
|
||||
@@ -50,6 +55,77 @@ const studioTemplates: TranslationStrings = {
|
||||
'studio.templates.name.gridFeature': 'Əsas fotolu şəbəkə',
|
||||
'studio.templates.name.gridColumns': 'Sütunlu şəbəkə',
|
||||
'studio.templates.name.gridMosaic': 'Mozaika şəbəkəsi',
|
||||
'studio.templates.name.spineBandPlate': 'Yapışdırılmış lövhə',
|
||||
'studio.templates.name.posterPopSunDisc': 'Günəş diski',
|
||||
'studio.templates.name.cutoutCircle': 'Kəsilmiş dairə',
|
||||
'studio.templates.name.postcardGreetings': 'Salam açıqcası',
|
||||
'studio.templates.name.gridBadgeTiles': 'Nişan şəbəkəsi',
|
||||
'studio.templates.name.minimalSerifCameo': 'Oval kameya',
|
||||
'studio.templates.name.stampCollageAirmail': 'Hava poçtu zərfi',
|
||||
'studio.templates.name.risoOverprint': 'Üst-üstə çap plakatı',
|
||||
'studio.templates.name.risoHalftoneSun': 'Rastrlı günəş',
|
||||
'studio.templates.name.risoZineStack': 'Zin yığını',
|
||||
'studio.templates.name.risoPinkPress': 'Çəhrayı çap',
|
||||
'studio.templates.name.editorialMasthead': 'Başlıqlı üz qabığı',
|
||||
'studio.templates.name.editorialCoverStory': 'Üz qabığı hekayəsi',
|
||||
'studio.templates.name.editorialSplit': 'Başlıq üzərində foto',
|
||||
'studio.templates.name.editorialHeadline': 'Manşetli üz qabığı',
|
||||
'studio.templates.name.vintagePosterPanorama': 'Plakat panoraması',
|
||||
'studio.templates.name.vintagePosterArch': 'Tağ və dəniz',
|
||||
'studio.templates.name.vintagePosterSunDisc': 'Təpələr üzərində günəş',
|
||||
'studio.templates.name.vintagePosterPeaks': 'Pilləli zirvələr',
|
||||
'studio.templates.name.nightFilmLetterbox': 'Geniş ekranlı üz qabığı',
|
||||
'studio.templates.name.nightFilmStrip': 'Kino lentli üz qabığı',
|
||||
'studio.templates.name.nightFilmTitleCard': 'Başlıq kartı',
|
||||
'studio.templates.name.nightFilmFadeOut': 'Qaranlığa keçid',
|
||||
'studio.templates.name.fieldNotesTorn': 'Cırılmış dəftər vərəqi',
|
||||
'studio.templates.name.fieldNotesPolaroid': 'Lentlə yapışdırılmış polaroid',
|
||||
'studio.templates.name.fieldNotesSpecimens': 'Lentlə yapışdırılmış nümunələr',
|
||||
'studio.templates.name.fieldNotesLabel': 'Dəftər etiketi',
|
||||
'studio.templates.name.spineBandClothStory': 'Parça üzərində hekayə',
|
||||
'studio.templates.name.spineBandGinghamTrio': 'Damalı cildli üçlük',
|
||||
'studio.templates.name.spineBandAlbum': 'Cildlənmiş albom',
|
||||
'studio.templates.name.posterPopHeadline': 'Plakat manşeti',
|
||||
'studio.templates.name.posterPopPostcards': 'Əyilmiş fotolar',
|
||||
'studio.templates.name.posterPopPhotoWall': 'Foto divarı',
|
||||
'studio.templates.name.cutoutStoryPage': 'Kəsilmiş hekayə',
|
||||
'studio.templates.name.cutoutTrio': 'Kəsilmiş üçlük',
|
||||
'studio.templates.name.cutoutScatter': 'Səpələnmiş fotolar',
|
||||
'studio.templates.name.cutoutMosaic': 'Kəsilmiş mozaika',
|
||||
'studio.templates.name.postcardAirmail': 'Hava poçtu məktubu',
|
||||
'studio.templates.name.postcardRack': 'Açıqca rəfi',
|
||||
'studio.templates.name.gridBadgeLetter': 'Möhür və hekayə',
|
||||
'studio.templates.name.gridBadgeHeroSix': 'Əsas foto və altı',
|
||||
'studio.templates.name.minimalSerifEssay': 'Sakit esse',
|
||||
'studio.templates.name.minimalSerifTriptych': 'Üç lövhə',
|
||||
'studio.templates.name.stampCollageLetter': 'Möhürlənmiş məktub',
|
||||
'studio.templates.name.stampCollageArchStamps': 'Tağ və markalar',
|
||||
'studio.templates.name.stampCollageSheet': 'Marka vərəqi',
|
||||
'studio.templates.name.risoStoryPlate': 'Hekayə və lövhə',
|
||||
'studio.templates.name.risoOffsetPair': 'Sürüşmüş cüt',
|
||||
'studio.templates.name.risoBlobTrio': 'Ləkə üçlüyü',
|
||||
'studio.templates.name.risoZineGrid': 'Zin şəbəkəsi',
|
||||
'studio.templates.name.risoPrintScatter': 'Dağınıq çaplar',
|
||||
'studio.templates.name.editorialFeatureOpener': 'Reportaj girişi',
|
||||
'studio.templates.name.editorialPullQuote': 'Seçilmiş sitat',
|
||||
'studio.templates.name.editorialFeatureColumns': 'Sütunlarda reportaj',
|
||||
'studio.templates.name.editorialPortfolio': 'Portfolio səhifə cütü',
|
||||
'studio.templates.name.editorialContactSheet': 'Kontakt vərəqi',
|
||||
'studio.templates.name.vintagePosterStory': 'Plakat və hekayə',
|
||||
'studio.templates.name.vintagePosterTwin': 'Əkiz plakatlar',
|
||||
'studio.templates.name.vintagePosterArcade': 'Tağlı qalereya',
|
||||
'studio.templates.name.vintagePosterPanels': 'Panellər və təpələr',
|
||||
'studio.templates.name.vintagePosterLabels': 'Baqaj etiketləri',
|
||||
'studio.templates.name.nightFilmOpening': 'Açılış səhnəsi',
|
||||
'studio.templates.name.nightFilmSubtitles': 'Film altyazıları',
|
||||
'studio.templates.name.nightFilmReels': 'Keçid və çarxlar',
|
||||
'studio.templates.name.nightFilmContact': 'Film kontakt vərəqi',
|
||||
'studio.templates.name.nightFilmDoubleReel': 'İkiqat çarx',
|
||||
'studio.templates.name.fieldNotesDiary': 'Gündəlik səhifəsi',
|
||||
'studio.templates.name.fieldNotesTrail': 'Qeyd kartları izi',
|
||||
'studio.templates.name.fieldNotesRoute': 'Marşrut və polaroidlər',
|
||||
'studio.templates.name.fieldNotesContact': 'Nömrəli kontakt vərəqi',
|
||||
'studio.templates.name.fieldNotesHerbarium': 'Herbari vərəqləri',
|
||||
};
|
||||
|
||||
export default studioTemplates;
|
||||
|
||||
@@ -13,6 +13,11 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.name.nordic': 'Şimal',
|
||||
'studio.theme.name.alpine': 'Alp',
|
||||
'studio.theme.name.indiaStamp': 'Marka kollajı',
|
||||
'studio.theme.name.riso': 'Riso çapı',
|
||||
'studio.theme.name.magazine': 'Jurnal',
|
||||
'studio.theme.name.retroTravel': 'Retro səyahət',
|
||||
'studio.theme.name.midnight': 'Gecəyarısı',
|
||||
'studio.theme.name.fieldNotes': 'Səyahət qeydləri',
|
||||
'studio.theme.descr.posterPop': 'Krem kağız, mərcan və günəbaxan, qalın afişa başlıqları',
|
||||
'studio.theme.descr.cutout': 'Kobalt kağız, mərcan və qum rəngli fiqurlar, ağ yazı',
|
||||
'studio.theme.descr.postcard': 'Nanə rəngli kağız, tünd göy mürəkkəb, möhür qırmızısı və əl yazısı ilə alt başlıq',
|
||||
@@ -25,6 +30,11 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.descr.nordic': 'Şifer, oxra və çəhrayı ilə açıq boz kağız',
|
||||
'studio.theme.descr.alpine': 'Meşə yaşılı, pas və səma mavisi ilə qar ağı',
|
||||
'studio.theme.descr.indiaStamp': 'Bənövşəyi, zəfəran və firuzəyi ilə isti kağız',
|
||||
'studio.theme.descr.riso': 'Krem kağız, riso mavisi və neon çəhrayı üst üstə',
|
||||
'studio.theme.descr.magazine': 'Süd ağı kağız, qara mürəkkəb, bir qırmızı və böyük başlıq',
|
||||
'studio.theme.descr.retroTravel': 'Köhnəlmiş krem kağız, narıncı, firuzəyi və xardal',
|
||||
'studio.theme.descr.midnight': 'Demək olar ki qara kağız, isti ağ yazı və neon vurğular',
|
||||
'studio.theme.descr.fieldNotes': 'Kraft kağız, mamır yaşılı və pas, əl yazısı və lent',
|
||||
'studio.theme.panel': 'Mövzu',
|
||||
'studio.theme.intro':
|
||||
'Bütün kitab üçün bir görünüş seçin. Mövzunu izləyən rənglər və yazı onunla birlikdə dəyişir, qalan hər şey olduğu kimi qalır.',
|
||||
|
||||
@@ -589,8 +589,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': 'Começa em',
|
||||
'journey.studio.folioMargin': 'Margem',
|
||||
'journey.studio.relayoutSpread': 'Esta página',
|
||||
'journey.studio.relayoutSpreadHint': 'Refazer a partir da entrada',
|
||||
'journey.studio.relayoutSpreadNone': 'Esta página não vem de uma entrada',
|
||||
'journey.studio.relayoutSpreadHint': 'Usar outro design',
|
||||
'journey.studio.relayoutSpreadNone': 'O layout automático não criou esta página',
|
||||
'journey.studio.relayoutBook': 'O livro inteiro',
|
||||
'journey.studio.relayoutBookHint': 'Substitui todas as páginas — reversível',
|
||||
'journey.studio.tpl.coverFull': 'Sangria total',
|
||||
|
||||
@@ -13,6 +13,11 @@ const studioTemplates: TranslationStrings = {
|
||||
'studio.templates.archetype.grid-badge': 'Grade e selo',
|
||||
'studio.templates.archetype.minimal-serif': 'Minimalista',
|
||||
'studio.templates.archetype.stamp-collage': 'Selos',
|
||||
'studio.templates.archetype.riso': 'Riso', // same-as-en
|
||||
'studio.templates.archetype.editorial': 'Revista',
|
||||
'studio.templates.archetype.vintage-poster': 'Pôster vintage',
|
||||
'studio.templates.archetype.night-film': 'Filme noturno',
|
||||
'studio.templates.archetype.field-notes': 'Caderno de campo',
|
||||
'studio.templates.name.spineBandStitched': 'Faixa costurada',
|
||||
'studio.templates.name.spineBandGingham': 'Faixa xadrez',
|
||||
'studio.templates.name.spineBandScript': 'Faixa e caligrafia',
|
||||
@@ -50,6 +55,77 @@ const studioTemplates: TranslationStrings = {
|
||||
'studio.templates.name.gridFeature': 'Grade em destaque',
|
||||
'studio.templates.name.gridColumns': 'Grade em colunas',
|
||||
'studio.templates.name.gridMosaic': 'Grade mosaico',
|
||||
'studio.templates.name.spineBandPlate': 'Prancha colada',
|
||||
'studio.templates.name.posterPopSunDisc': 'Disco de sol',
|
||||
'studio.templates.name.cutoutCircle': 'Círculo recortado',
|
||||
'studio.templates.name.postcardGreetings': 'Cartão-postal de saudações',
|
||||
'studio.templates.name.gridBadgeTiles': 'Grade de emblemas',
|
||||
'studio.templates.name.minimalSerifCameo': 'Camafeu oval',
|
||||
'studio.templates.name.stampCollageAirmail': 'Envelope de correio aéreo',
|
||||
'studio.templates.name.risoOverprint': 'Pôster sobreposto',
|
||||
'studio.templates.name.risoHalftoneSun': 'Sol em retícula',
|
||||
'studio.templates.name.risoZineStack': 'Pilha de zines',
|
||||
'studio.templates.name.risoPinkPress': 'Prensa rosa',
|
||||
'studio.templates.name.editorialMasthead': 'Capa com cabeçalho',
|
||||
'studio.templates.name.editorialCoverStory': 'Matéria de capa',
|
||||
'studio.templates.name.editorialSplit': 'Foto sobre a manchete',
|
||||
'studio.templates.name.editorialHeadline': 'Capa de manchete',
|
||||
'studio.templates.name.vintagePosterPanorama': 'Panorama de pôster',
|
||||
'studio.templates.name.vintagePosterArch': 'Arco e mar',
|
||||
'studio.templates.name.vintagePosterSunDisc': 'Sol sobre colinas',
|
||||
'studio.templates.name.vintagePosterPeaks': 'Picos escalonados',
|
||||
'studio.templates.name.nightFilmLetterbox': 'Capa em tela larga',
|
||||
'studio.templates.name.nightFilmStrip': 'Capa com tira de filme',
|
||||
'studio.templates.name.nightFilmTitleCard': 'Cartela de título',
|
||||
'studio.templates.name.nightFilmFadeOut': 'Fusão para o preto',
|
||||
'studio.templates.name.fieldNotesTorn': 'Folha de caderno rasgada',
|
||||
'studio.templates.name.fieldNotesPolaroid': 'Polaroid com fita',
|
||||
'studio.templates.name.fieldNotesSpecimens': 'Amostras com fita',
|
||||
'studio.templates.name.fieldNotesLabel': 'Etiqueta de caderno',
|
||||
'studio.templates.name.spineBandClothStory': 'História no tecido',
|
||||
'studio.templates.name.spineBandGinghamTrio': 'Trio com lombada xadrez',
|
||||
'studio.templates.name.spineBandAlbum': 'Álbum encadernado',
|
||||
'studio.templates.name.posterPopHeadline': 'Manchete de pôster',
|
||||
'studio.templates.name.posterPopPostcards': 'Fotos inclinadas',
|
||||
'studio.templates.name.posterPopPhotoWall': 'Mural de fotos',
|
||||
'studio.templates.name.cutoutStoryPage': 'História recortada',
|
||||
'studio.templates.name.cutoutTrio': 'Trio recortado',
|
||||
'studio.templates.name.cutoutScatter': 'Fotos espalhadas',
|
||||
'studio.templates.name.cutoutMosaic': 'Mosaico recortado',
|
||||
'studio.templates.name.postcardAirmail': 'Carta por avião',
|
||||
'studio.templates.name.postcardRack': 'Expositor de postais',
|
||||
'studio.templates.name.gridBadgeLetter': 'Selo e história',
|
||||
'studio.templates.name.gridBadgeHeroSix': 'Destaque e seis',
|
||||
'studio.templates.name.minimalSerifEssay': 'Ensaio tranquilo',
|
||||
'studio.templates.name.minimalSerifTriptych': 'Três pranchas',
|
||||
'studio.templates.name.stampCollageLetter': 'Carta carimbada',
|
||||
'studio.templates.name.stampCollageArchStamps': 'Arco e selos',
|
||||
'studio.templates.name.stampCollageSheet': 'Folha de selos',
|
||||
'studio.templates.name.risoStoryPlate': 'História e prancha',
|
||||
'studio.templates.name.risoOffsetPair': 'Par deslocado',
|
||||
'studio.templates.name.risoBlobTrio': 'Trio de manchas',
|
||||
'studio.templates.name.risoZineGrid': 'Grade de zine',
|
||||
'studio.templates.name.risoPrintScatter': 'Impressões espalhadas',
|
||||
'studio.templates.name.editorialFeatureOpener': 'Abertura de reportagem',
|
||||
'studio.templates.name.editorialPullQuote': 'Citação em destaque',
|
||||
'studio.templates.name.editorialFeatureColumns': 'Reportagem em colunas',
|
||||
'studio.templates.name.editorialPortfolio': 'Página dupla de portfólio',
|
||||
'studio.templates.name.editorialContactSheet': 'Folha de contato',
|
||||
'studio.templates.name.vintagePosterStory': 'Pôster e história',
|
||||
'studio.templates.name.vintagePosterTwin': 'Pôsteres gêmeos',
|
||||
'studio.templates.name.vintagePosterArcade': 'Arcada de arcos',
|
||||
'studio.templates.name.vintagePosterPanels': 'Painéis e colinas',
|
||||
'studio.templates.name.vintagePosterLabels': 'Etiquetas de bagagem',
|
||||
'studio.templates.name.nightFilmOpening': 'Cena de abertura',
|
||||
'studio.templates.name.nightFilmSubtitles': 'Legendas de filme',
|
||||
'studio.templates.name.nightFilmReels': 'Fusão e rolos',
|
||||
'studio.templates.name.nightFilmContact': 'Folha de contato de filme',
|
||||
'studio.templates.name.nightFilmDoubleReel': 'Rolo duplo',
|
||||
'studio.templates.name.fieldNotesDiary': 'Página de diário',
|
||||
'studio.templates.name.fieldNotesTrail': 'Trilha de fichas',
|
||||
'studio.templates.name.fieldNotesRoute': 'Rota e polaroids',
|
||||
'studio.templates.name.fieldNotesContact': 'Folha de contato numerada',
|
||||
'studio.templates.name.fieldNotesHerbarium': 'Folhas de herbário',
|
||||
};
|
||||
|
||||
export default studioTemplates;
|
||||
|
||||
@@ -13,6 +13,11 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.name.nordic': 'Nórdico',
|
||||
'studio.theme.name.alpine': 'Alpino',
|
||||
'studio.theme.name.indiaStamp': 'Colagem de selos',
|
||||
'studio.theme.name.riso': 'Impressão riso',
|
||||
'studio.theme.name.magazine': 'Revista',
|
||||
'studio.theme.name.retroTravel': 'Viagem retrô',
|
||||
'studio.theme.name.midnight': 'Noite escura',
|
||||
'studio.theme.name.fieldNotes': 'Caderno de campo',
|
||||
'studio.theme.descr.posterPop': 'Papel creme, coral e girassol, títulos de pôster encorpados',
|
||||
'studio.theme.descr.cutout': 'Papel cobalto, formas em coral e areia, letras brancas',
|
||||
'studio.theme.descr.postcard': 'Papel menta, tinta azul-marinho, vermelho de carimbo e um subtítulo manuscrito',
|
||||
@@ -25,6 +30,11 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.descr.nordic': 'Papel cinza-claro com ardósia, ocre e rosa',
|
||||
'studio.theme.descr.alpine': 'Branco-neve com verde-floresta, ferrugem e azul-céu',
|
||||
'studio.theme.descr.indiaStamp': 'Papel quente com magenta, açafrão e verde-petróleo',
|
||||
'studio.theme.descr.riso': 'Papel creme, azul riso e rosa flúor sobrepostos',
|
||||
'studio.theme.descr.magazine': 'Papel branco gelo, tinta preta, um vermelho e um grande cabeçalho',
|
||||
'studio.theme.descr.retroTravel': 'Papel creme envelhecido com laranja, verde petróleo e mostarda',
|
||||
'studio.theme.descr.midnight': 'Papel quase preto, texto branco quente e toques neon',
|
||||
'studio.theme.descr.fieldNotes': 'Papel kraft, verde musgo e ferrugem, letra à mão e fita',
|
||||
'studio.theme.panel': 'Tema',
|
||||
'studio.theme.intro':
|
||||
'Escolha um visual para o livro todo. As cores e as letras que seguem o tema mudam com ele, todo o resto continua como está.',
|
||||
|
||||
@@ -588,8 +588,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': 'Comença a',
|
||||
'journey.studio.folioMargin': 'Marge',
|
||||
'journey.studio.relayoutSpread': 'Aquesta pàgina',
|
||||
'journey.studio.relayoutSpreadHint': 'Refer-la des de l’entrada',
|
||||
'journey.studio.relayoutSpreadNone': 'Aquesta pàgina no ve d’una entrada',
|
||||
'journey.studio.relayoutSpreadHint': 'Posar-la en un altre disseny',
|
||||
'journey.studio.relayoutSpreadNone': 'El disseny automàtic no ha fet aquesta pàgina',
|
||||
'journey.studio.relayoutBook': 'Tot el llibre',
|
||||
'journey.studio.relayoutBookHint': 'Reemplaça totes les pàgines — reversible',
|
||||
'journey.studio.tpl.coverFull': 'A sang',
|
||||
|
||||
@@ -13,6 +13,11 @@ const studioTemplates: TranslationStrings = {
|
||||
'studio.templates.archetype.grid-badge': 'Quadrícula i insígnia',
|
||||
'studio.templates.archetype.minimal-serif': 'Minimalista',
|
||||
'studio.templates.archetype.stamp-collage': 'Segells',
|
||||
'studio.templates.archetype.riso': 'Riso', // same-as-en
|
||||
'studio.templates.archetype.editorial': 'Revista',
|
||||
'studio.templates.archetype.vintage-poster': 'Cartell antic',
|
||||
'studio.templates.archetype.night-film': 'Cinema nocturn',
|
||||
'studio.templates.archetype.field-notes': 'Quadern de camp',
|
||||
'studio.templates.name.spineBandStitched': 'Banda cosida',
|
||||
'studio.templates.name.spineBandGingham': 'Banda de quadres',
|
||||
'studio.templates.name.spineBandScript': 'Banda i cal·ligrafia',
|
||||
@@ -50,6 +55,77 @@ const studioTemplates: TranslationStrings = {
|
||||
'studio.templates.name.gridFeature': 'Quadrícula destacada',
|
||||
'studio.templates.name.gridColumns': 'Quadrícula en columnes',
|
||||
'studio.templates.name.gridMosaic': 'Quadrícula mosaic',
|
||||
'studio.templates.name.spineBandPlate': 'Làmina enganxada',
|
||||
'studio.templates.name.posterPopSunDisc': 'Disc solar',
|
||||
'studio.templates.name.cutoutCircle': 'Cercle retallat',
|
||||
'studio.templates.name.postcardGreetings': 'Postal de salutacions',
|
||||
'studio.templates.name.gridBadgeTiles': 'Graella d’insígnies',
|
||||
'studio.templates.name.minimalSerifCameo': 'Camafeu oval',
|
||||
'studio.templates.name.stampCollageAirmail': 'Sobre per avió',
|
||||
'studio.templates.name.risoOverprint': 'Cartell sobreimprès',
|
||||
'studio.templates.name.risoHalftoneSun': 'Sol en trama',
|
||||
'studio.templates.name.risoZineStack': 'Pila de fanzins',
|
||||
'studio.templates.name.risoPinkPress': 'Premsa rosa',
|
||||
'studio.templates.name.editorialMasthead': 'Portada amb capçalera',
|
||||
'studio.templates.name.editorialCoverStory': 'Tema de portada',
|
||||
'studio.templates.name.editorialSplit': 'Foto sobre el titular',
|
||||
'studio.templates.name.editorialHeadline': 'Portada de titular',
|
||||
'studio.templates.name.vintagePosterPanorama': 'Panorama de cartell',
|
||||
'studio.templates.name.vintagePosterArch': 'Arc i mar',
|
||||
'studio.templates.name.vintagePosterSunDisc': 'Sol sobre turons',
|
||||
'studio.templates.name.vintagePosterPeaks': 'Cims esglaonats',
|
||||
'studio.templates.name.nightFilmLetterbox': 'Portada panoràmica',
|
||||
'studio.templates.name.nightFilmStrip': 'Portada amb tira de pel·lícula',
|
||||
'studio.templates.name.nightFilmTitleCard': 'Cartó de títol',
|
||||
'studio.templates.name.nightFilmFadeOut': 'Fos a negre',
|
||||
'studio.templates.name.fieldNotesTorn': 'Full de llibreta esquinçat',
|
||||
'studio.templates.name.fieldNotesPolaroid': 'Polaroid amb cinta',
|
||||
'studio.templates.name.fieldNotesSpecimens': 'Mostres amb cinta',
|
||||
'studio.templates.name.fieldNotesLabel': 'Etiqueta de llibreta',
|
||||
'studio.templates.name.spineBandClothStory': 'Relat sobre tela',
|
||||
'studio.templates.name.spineBandGinghamTrio': 'Trio amb llom de quadres',
|
||||
'studio.templates.name.spineBandAlbum': 'Àlbum enquadernat',
|
||||
'studio.templates.name.posterPopHeadline': 'Titular de cartell',
|
||||
'studio.templates.name.posterPopPostcards': 'Còpies inclinades',
|
||||
'studio.templates.name.posterPopPhotoWall': 'Mur de fotos',
|
||||
'studio.templates.name.cutoutStoryPage': 'Relat retallat',
|
||||
'studio.templates.name.cutoutTrio': 'Trio retallat',
|
||||
'studio.templates.name.cutoutScatter': 'Còpies escampades',
|
||||
'studio.templates.name.cutoutMosaic': 'Mosaic retallat',
|
||||
'studio.templates.name.postcardAirmail': 'Carta per avió',
|
||||
'studio.templates.name.postcardRack': 'Expositor de postals',
|
||||
'studio.templates.name.gridBadgeLetter': 'Segell i relat',
|
||||
'studio.templates.name.gridBadgeHeroSix': 'Foto gran i sis',
|
||||
'studio.templates.name.minimalSerifEssay': 'Assaig serè',
|
||||
'studio.templates.name.minimalSerifTriptych': 'Tres làmines',
|
||||
'studio.templates.name.stampCollageLetter': 'Carta mata-segellada',
|
||||
'studio.templates.name.stampCollageArchStamps': 'Arc i segells',
|
||||
'studio.templates.name.stampCollageSheet': 'Full de segells',
|
||||
'studio.templates.name.risoStoryPlate': 'Relat i làmina',
|
||||
'studio.templates.name.risoOffsetPair': 'Parella desplaçada',
|
||||
'studio.templates.name.risoBlobTrio': 'Trio de taques',
|
||||
'studio.templates.name.risoZineGrid': 'Graella de fanzín',
|
||||
'studio.templates.name.risoPrintScatter': 'Còpies disperses',
|
||||
'studio.templates.name.editorialFeatureOpener': 'Obertura de reportatge',
|
||||
'studio.templates.name.editorialPullQuote': 'Cita destacada',
|
||||
'studio.templates.name.editorialFeatureColumns': 'Reportatge en columnes',
|
||||
'studio.templates.name.editorialPortfolio': 'Doble pàgina de portafoli',
|
||||
'studio.templates.name.editorialContactSheet': 'Full de contactes',
|
||||
'studio.templates.name.vintagePosterStory': 'Cartell i relat',
|
||||
'studio.templates.name.vintagePosterTwin': 'Cartells bessons',
|
||||
'studio.templates.name.vintagePosterArcade': 'Arcada d’arcs',
|
||||
'studio.templates.name.vintagePosterPanels': 'Plafons i turons',
|
||||
'studio.templates.name.vintagePosterLabels': 'Etiquetes d’equipatge',
|
||||
'studio.templates.name.nightFilmOpening': 'Escena inicial',
|
||||
'studio.templates.name.nightFilmSubtitles': 'Subtítols de pel·lícula',
|
||||
'studio.templates.name.nightFilmReels': 'Fos i bobines',
|
||||
'studio.templates.name.nightFilmContact': 'Full de contactes de cinema',
|
||||
'studio.templates.name.nightFilmDoubleReel': 'Bobina doble',
|
||||
'studio.templates.name.fieldNotesDiary': 'Pàgina de diari',
|
||||
'studio.templates.name.fieldNotesTrail': 'Rastre de fitxes',
|
||||
'studio.templates.name.fieldNotesRoute': 'Ruta i polaroids',
|
||||
'studio.templates.name.fieldNotesContact': 'Full de contactes numerat',
|
||||
'studio.templates.name.fieldNotesHerbarium': 'Plecs d’herbari',
|
||||
};
|
||||
|
||||
export default studioTemplates;
|
||||
|
||||
@@ -13,6 +13,11 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.name.nordic': 'Nòrdic',
|
||||
'studio.theme.name.alpine': 'Alpí',
|
||||
'studio.theme.name.indiaStamp': 'Collage de segells',
|
||||
'studio.theme.name.riso': 'Impressió riso',
|
||||
'studio.theme.name.magazine': 'Revista',
|
||||
'studio.theme.name.retroTravel': 'Viatge retro',
|
||||
'studio.theme.name.midnight': 'Mitjanit',
|
||||
'studio.theme.name.fieldNotes': 'Quadern de camp',
|
||||
'studio.theme.descr.posterPop': 'Paper crema, corall i gira-sol, títols de pòster gruixuts',
|
||||
'studio.theme.descr.cutout': 'Paper cobalt, formes corall i sorra, lletra blanca',
|
||||
'studio.theme.descr.postcard': 'Paper menta, tinta blau marí, vermell de segell i un subtítol manuscrit',
|
||||
@@ -25,6 +30,11 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.descr.nordic': 'Paper gris clar amb pissarra, ocre i rosa',
|
||||
'studio.theme.descr.alpine': 'Blanc de neu amb verd bosc, rovell i blau cel',
|
||||
'studio.theme.descr.indiaStamp': 'Paper càlid amb magenta, safrà i verd blavós',
|
||||
'studio.theme.descr.riso': 'Paper crema, blau riso i rosa fluor sobreimpresos',
|
||||
'studio.theme.descr.magazine': 'Paper blanc trencat, tinta negra, un vermell i una gran capçalera',
|
||||
'studio.theme.descr.retroTravel': 'Paper crema envellit amb taronja, verd blavós i mostassa',
|
||||
'studio.theme.descr.midnight': 'Paper gairebé negre, lletra blanca càlida i tocs de neó',
|
||||
'studio.theme.descr.fieldNotes': 'Paper kraft, verd molsa i rovell, lletra a mà i cinta',
|
||||
'studio.theme.panel': 'Tema',
|
||||
'studio.theme.intro':
|
||||
'Tria un estil per a tot el llibre. Els colors i la lletra que segueixen el tema canvien amb ell, tota la resta es queda com està.',
|
||||
|
||||
@@ -593,8 +593,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': 'Začíná na',
|
||||
'journey.studio.folioMargin': 'Okraj',
|
||||
'journey.studio.relayoutSpread': 'Tato strana',
|
||||
'journey.studio.relayoutSpreadHint': 'Znovu sestavit ze záznamu',
|
||||
'journey.studio.relayoutSpreadNone': 'Tato strana nevznikla ze záznamu',
|
||||
'journey.studio.relayoutSpreadHint': 'Použít jiný návrh',
|
||||
'journey.studio.relayoutSpreadNone': 'Tuto stranu nevytvořilo automatické rozvržení',
|
||||
'journey.studio.relayoutBook': 'Celá kniha',
|
||||
'journey.studio.relayoutBookHint': 'Nahradí všechny strany — lze vrátit',
|
||||
'journey.studio.tpl.coverFull': 'Na spad',
|
||||
|
||||
@@ -13,6 +13,11 @@ const studioTemplates: TranslationStrings = {
|
||||
'studio.templates.archetype.grid-badge': 'Mřížka a odznak',
|
||||
'studio.templates.archetype.minimal-serif': 'Minimalismus',
|
||||
'studio.templates.archetype.stamp-collage': 'Známky',
|
||||
'studio.templates.archetype.riso': 'Riso', // same-as-en
|
||||
'studio.templates.archetype.editorial': 'Časopis',
|
||||
'studio.templates.archetype.vintage-poster': 'Retro plakát',
|
||||
'studio.templates.archetype.night-film': 'Noční film',
|
||||
'studio.templates.archetype.field-notes': 'Terénní zápisky',
|
||||
'studio.templates.name.spineBandStitched': 'Prošitý pruh',
|
||||
'studio.templates.name.spineBandGingham': 'Kostkovaný pruh',
|
||||
'studio.templates.name.spineBandScript': 'Pruh s psacím písmem',
|
||||
@@ -50,6 +55,77 @@ const studioTemplates: TranslationStrings = {
|
||||
'studio.templates.name.gridFeature': 'Mřížka s hlavní fotkou',
|
||||
'studio.templates.name.gridColumns': 'Sloupcová mřížka',
|
||||
'studio.templates.name.gridMosaic': 'Mozaiková mřížka',
|
||||
'studio.templates.name.spineBandPlate': 'Vlepená tabule',
|
||||
'studio.templates.name.posterPopSunDisc': 'Sluneční kotouč',
|
||||
'studio.templates.name.cutoutCircle': 'Vystřižený kruh',
|
||||
'studio.templates.name.postcardGreetings': 'Pozdrav z cest',
|
||||
'studio.templates.name.gridBadgeTiles': 'Mřížka odznaků',
|
||||
'studio.templates.name.minimalSerifCameo': 'Oválná kamej',
|
||||
'studio.templates.name.stampCollageAirmail': 'Obálka letecké pošty',
|
||||
'studio.templates.name.risoOverprint': 'Plakát s přetiskem',
|
||||
'studio.templates.name.risoHalftoneSun': 'Rastrové slunce',
|
||||
'studio.templates.name.risoZineStack': 'Hromádka zinů',
|
||||
'studio.templates.name.risoPinkPress': 'Růžový tisk',
|
||||
'studio.templates.name.editorialMasthead': 'Obálka s hlavičkou',
|
||||
'studio.templates.name.editorialCoverStory': 'Titulní příběh',
|
||||
'studio.templates.name.editorialSplit': 'Fotka nad titulkem',
|
||||
'studio.templates.name.editorialHeadline': 'Obálka s titulkem',
|
||||
'studio.templates.name.vintagePosterPanorama': 'Plakátové panorama',
|
||||
'studio.templates.name.vintagePosterArch': 'Oblouk a moře',
|
||||
'studio.templates.name.vintagePosterSunDisc': 'Slunce nad kopci',
|
||||
'studio.templates.name.vintagePosterPeaks': 'Vrstvené štíty',
|
||||
'studio.templates.name.nightFilmLetterbox': 'Širokoúhlá obálka',
|
||||
'studio.templates.name.nightFilmStrip': 'Obálka s filmovým pásem',
|
||||
'studio.templates.name.nightFilmTitleCard': 'Titulková karta',
|
||||
'studio.templates.name.nightFilmFadeOut': 'Stmívačka',
|
||||
'studio.templates.name.fieldNotesTorn': 'Vytržený list zápisníku',
|
||||
'studio.templates.name.fieldNotesPolaroid': 'Polaroid na pásce',
|
||||
'studio.templates.name.fieldNotesSpecimens': 'Vzorky na pásce',
|
||||
'studio.templates.name.fieldNotesLabel': 'Štítek zápisníku',
|
||||
'studio.templates.name.spineBandClothStory': 'Příběh na plátně',
|
||||
'studio.templates.name.spineBandGinghamTrio': 'Trojice s kostkovaným hřbetem',
|
||||
'studio.templates.name.spineBandAlbum': 'Vázané album',
|
||||
'studio.templates.name.posterPopHeadline': 'Plakátový titulek',
|
||||
'studio.templates.name.posterPopPostcards': 'Šikmé fotky',
|
||||
'studio.templates.name.posterPopPhotoWall': 'Stěna fotek',
|
||||
'studio.templates.name.cutoutStoryPage': 'Vystřižený příběh',
|
||||
'studio.templates.name.cutoutTrio': 'Vystřižená trojice',
|
||||
'studio.templates.name.cutoutScatter': 'Rozházené fotky',
|
||||
'studio.templates.name.cutoutMosaic': 'Vystřižená mozaika',
|
||||
'studio.templates.name.postcardAirmail': 'Dopis leteckou poštou',
|
||||
'studio.templates.name.postcardRack': 'Stojan s pohlednicemi',
|
||||
'studio.templates.name.gridBadgeLetter': 'Pečeť a příběh',
|
||||
'studio.templates.name.gridBadgeHeroSix': 'Hlavní fotka a šest',
|
||||
'studio.templates.name.minimalSerifEssay': 'Tichá esej',
|
||||
'studio.templates.name.minimalSerifTriptych': 'Tři tabule',
|
||||
'studio.templates.name.stampCollageLetter': 'Orazítkovaný dopis',
|
||||
'studio.templates.name.stampCollageArchStamps': 'Oblouk a známky',
|
||||
'studio.templates.name.stampCollageSheet': 'Arch známek',
|
||||
'studio.templates.name.risoStoryPlate': 'Příběh a tabule',
|
||||
'studio.templates.name.risoOffsetPair': 'Posunutá dvojice',
|
||||
'studio.templates.name.risoBlobTrio': 'Trojice skvrn',
|
||||
'studio.templates.name.risoZineGrid': 'Mřížka zinu',
|
||||
'studio.templates.name.risoPrintScatter': 'Rozptýlené tisky',
|
||||
'studio.templates.name.editorialFeatureOpener': 'Úvod reportáže',
|
||||
'studio.templates.name.editorialPullQuote': 'Zvýrazněný citát',
|
||||
'studio.templates.name.editorialFeatureColumns': 'Reportáž ve sloupcích',
|
||||
'studio.templates.name.editorialPortfolio': 'Dvoustrana portfolia',
|
||||
'studio.templates.name.editorialContactSheet': 'Kontaktní arch',
|
||||
'studio.templates.name.vintagePosterStory': 'Plakát a příběh',
|
||||
'studio.templates.name.vintagePosterTwin': 'Dvojčata plakátů',
|
||||
'studio.templates.name.vintagePosterArcade': 'Arkáda oblouků',
|
||||
'studio.templates.name.vintagePosterPanels': 'Panely a kopce',
|
||||
'studio.templates.name.vintagePosterLabels': 'Zavazadlové štítky',
|
||||
'studio.templates.name.nightFilmOpening': 'Úvodní scéna',
|
||||
'studio.templates.name.nightFilmSubtitles': 'Filmové titulky',
|
||||
'studio.templates.name.nightFilmReels': 'Stmívačka a cívky',
|
||||
'studio.templates.name.nightFilmContact': 'Filmový kontaktní arch',
|
||||
'studio.templates.name.nightFilmDoubleReel': 'Dvojitá cívka',
|
||||
'studio.templates.name.fieldNotesDiary': 'Stránka deníku',
|
||||
'studio.templates.name.fieldNotesTrail': 'Stopa kartiček',
|
||||
'studio.templates.name.fieldNotesRoute': 'Trasa a polaroidy',
|
||||
'studio.templates.name.fieldNotesContact': 'Číslovaný kontaktní arch',
|
||||
'studio.templates.name.fieldNotesHerbarium': 'Herbářové archy',
|
||||
};
|
||||
|
||||
export default studioTemplates;
|
||||
|
||||
@@ -13,6 +13,11 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.name.nordic': 'Severský',
|
||||
'studio.theme.name.alpine': 'Alpský',
|
||||
'studio.theme.name.indiaStamp': 'Koláž známek',
|
||||
'studio.theme.name.riso': 'Risotisk',
|
||||
'studio.theme.name.magazine': 'Časopis',
|
||||
'studio.theme.name.retroTravel': 'Retro cestování',
|
||||
'studio.theme.name.midnight': 'Půlnoc',
|
||||
'studio.theme.name.fieldNotes': 'Terénní zápisky',
|
||||
'studio.theme.descr.posterPop': 'Krémový papír, korálová a slunečnicová, výrazné plakátové nadpisy',
|
||||
'studio.theme.descr.cutout': 'Kobaltový papír, tvary v korálové a písková, bílé písmo',
|
||||
'studio.theme.descr.postcard': 'Mátový papír, námořnická modř, razítková červená a psaný podtitul',
|
||||
@@ -25,6 +30,11 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.descr.nordic': 'Světle šedý papír s břidlicovou, okrovou a růžovou',
|
||||
'studio.theme.descr.alpine': 'Sněhově bílá s lesní zelenou, rezavou a nebeskou modří',
|
||||
'studio.theme.descr.indiaStamp': 'Teplý papír s purpurovou, šafránovou a petrolejovou',
|
||||
'studio.theme.descr.riso': 'Krémový papír, modrá riso a fluo růžová přes sebe',
|
||||
'studio.theme.descr.magazine': 'Lomená bílá, černá barva, jedna červená a velké záhlaví',
|
||||
'studio.theme.descr.retroTravel': 'Zažloutlý krémový papír s oranžovou, petrolejovou a hořčicovou',
|
||||
'studio.theme.descr.midnight': 'Téměř černý papír, teple bílé písmo a neonové akcenty',
|
||||
'studio.theme.descr.fieldNotes': 'Balicí papír, mechová zeleň a rez, rukopis a páska',
|
||||
'studio.theme.panel': 'Motiv',
|
||||
'studio.theme.intro':
|
||||
'Vyberte vzhled pro celou knihu. Barvy a písmo, které motiv následují, se změní s ním, vše ostatní zůstane, jak je.',
|
||||
|
||||
@@ -590,8 +590,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': 'Beginnt bei',
|
||||
'journey.studio.folioMargin': 'Rand',
|
||||
'journey.studio.relayoutSpread': 'Diese Seite',
|
||||
'journey.studio.relayoutSpreadHint': 'Aus ihrem Eintrag neu bauen',
|
||||
'journey.studio.relayoutSpreadNone': 'Diese Seite stammt aus keinem Eintrag',
|
||||
'journey.studio.relayoutSpreadHint': 'Auf ein anderes Design legen',
|
||||
'journey.studio.relayoutSpreadNone': 'Diese Seite stammt nicht vom automatischen Anordnen',
|
||||
'journey.studio.relayoutBook': 'Das ganze Buch',
|
||||
'journey.studio.relayoutBookHint': 'Ersetzt alle Seiten — widerrufbar',
|
||||
'journey.studio.tpl.coverFull': 'Randlos',
|
||||
|
||||
@@ -13,6 +13,11 @@ const studioTemplates: TranslationStrings = {
|
||||
'studio.templates.archetype.grid-badge': 'Raster mit Plakette',
|
||||
'studio.templates.archetype.minimal-serif': 'Minimal', // same-as-en
|
||||
'studio.templates.archetype.stamp-collage': 'Briefmarken',
|
||||
'studio.templates.archetype.riso': 'Riso', // same-as-en
|
||||
'studio.templates.archetype.editorial': 'Magazin',
|
||||
'studio.templates.archetype.vintage-poster': 'Retroplakat',
|
||||
'studio.templates.archetype.night-film': 'Nachtfilm',
|
||||
'studio.templates.archetype.field-notes': 'Feldnotizen',
|
||||
'studio.templates.name.spineBandStitched': 'Genähtes Band',
|
||||
'studio.templates.name.spineBandGingham': 'Band mit Vichykaro',
|
||||
'studio.templates.name.spineBandScript': 'Band mit Schreibschrift',
|
||||
@@ -50,6 +55,77 @@ const studioTemplates: TranslationStrings = {
|
||||
'studio.templates.name.gridFeature': 'Raster mit Hauptbild',
|
||||
'studio.templates.name.gridColumns': 'Spaltenraster',
|
||||
'studio.templates.name.gridMosaic': 'Mosaikraster',
|
||||
'studio.templates.name.spineBandPlate': 'Eingeklebte Bildtafel',
|
||||
'studio.templates.name.posterPopSunDisc': 'Sonnenscheibe',
|
||||
'studio.templates.name.cutoutCircle': 'Kreis im Scherenschnitt',
|
||||
'studio.templates.name.postcardGreetings': 'Grußpostkarte',
|
||||
'studio.templates.name.gridBadgeTiles': 'Raster mit Plaketten',
|
||||
'studio.templates.name.minimalSerifCameo': 'Ovale Kamee',
|
||||
'studio.templates.name.stampCollageAirmail': 'Luftpostumschlag',
|
||||
'studio.templates.name.risoOverprint': 'Überdruck-Plakat',
|
||||
'studio.templates.name.risoHalftoneSun': 'Rastersonne',
|
||||
'studio.templates.name.risoZineStack': 'Zine-Stapel',
|
||||
'studio.templates.name.risoPinkPress': 'Pinker Druck',
|
||||
'studio.templates.name.editorialMasthead': 'Titelkopf',
|
||||
'studio.templates.name.editorialCoverStory': 'Titelgeschichte',
|
||||
'studio.templates.name.editorialSplit': 'Foto über Schlagzeile',
|
||||
'studio.templates.name.editorialHeadline': 'Schlagzeilen-Titel',
|
||||
'studio.templates.name.vintagePosterPanorama': 'Plakatpanorama',
|
||||
'studio.templates.name.vintagePosterArch': 'Bogen und Meer',
|
||||
'studio.templates.name.vintagePosterSunDisc': 'Sonne über Hügeln',
|
||||
'studio.templates.name.vintagePosterPeaks': 'Gestaffelte Gipfel',
|
||||
'studio.templates.name.nightFilmLetterbox': 'Breitbild-Titel',
|
||||
'studio.templates.name.nightFilmStrip': 'Filmstreifen-Titel',
|
||||
'studio.templates.name.nightFilmTitleCard': 'Titelkarte',
|
||||
'studio.templates.name.nightFilmFadeOut': 'Abblende',
|
||||
'studio.templates.name.fieldNotesTorn': 'Abgerissenes Notizblatt',
|
||||
'studio.templates.name.fieldNotesPolaroid': 'Polaroid mit Klebeband',
|
||||
'studio.templates.name.fieldNotesSpecimens': 'Eingeklebte Fundstücke',
|
||||
'studio.templates.name.fieldNotesLabel': 'Notizbuch-Etikett',
|
||||
'studio.templates.name.spineBandClothStory': 'Geschichte auf Leinen',
|
||||
'studio.templates.name.spineBandGinghamTrio': 'Vichy-Rücken mit drei',
|
||||
'studio.templates.name.spineBandAlbum': 'Gebundenes Album',
|
||||
'studio.templates.name.posterPopHeadline': 'Plakat-Schlagzeile',
|
||||
'studio.templates.name.posterPopPostcards': 'Schräge Abzüge',
|
||||
'studio.templates.name.posterPopPhotoWall': 'Fotowand',
|
||||
'studio.templates.name.cutoutStoryPage': 'Geschichte im Scherenschnitt',
|
||||
'studio.templates.name.cutoutTrio': 'Scherenschnitt mit drei',
|
||||
'studio.templates.name.cutoutScatter': 'Verstreute Abzüge',
|
||||
'studio.templates.name.cutoutMosaic': 'Scherenschnitt-Mosaik',
|
||||
'studio.templates.name.postcardAirmail': 'Luftpostbrief',
|
||||
'studio.templates.name.postcardRack': 'Postkartenständer',
|
||||
'studio.templates.name.gridBadgeLetter': 'Siegel und Geschichte',
|
||||
'studio.templates.name.gridBadgeHeroSix': 'Hauptfoto und sechs',
|
||||
'studio.templates.name.minimalSerifEssay': 'Stiller Essay',
|
||||
'studio.templates.name.minimalSerifTriptych': 'Drei Bildtafeln',
|
||||
'studio.templates.name.stampCollageLetter': 'Abgestempelter Brief',
|
||||
'studio.templates.name.stampCollageArchStamps': 'Bogen und Briefmarken',
|
||||
'studio.templates.name.stampCollageSheet': 'Briefmarkenbogen',
|
||||
'studio.templates.name.risoStoryPlate': 'Geschichte mit Bild',
|
||||
'studio.templates.name.risoOffsetPair': 'Versetztes Paar',
|
||||
'studio.templates.name.risoBlobTrio': 'Drei Kleckse',
|
||||
'studio.templates.name.risoZineGrid': 'Zine-Raster',
|
||||
'studio.templates.name.risoPrintScatter': 'Verstreute Drucke',
|
||||
'studio.templates.name.editorialFeatureOpener': 'Reportage-Auftakt',
|
||||
'studio.templates.name.editorialPullQuote': 'Herausgestelltes Zitat',
|
||||
'studio.templates.name.editorialFeatureColumns': 'Reportage in Spalten',
|
||||
'studio.templates.name.editorialPortfolio': 'Portfolio-Doppelseite',
|
||||
'studio.templates.name.editorialContactSheet': 'Kontaktabzug',
|
||||
'studio.templates.name.vintagePosterStory': 'Plakat und Geschichte',
|
||||
'studio.templates.name.vintagePosterTwin': 'Plakatpaar',
|
||||
'studio.templates.name.vintagePosterArcade': 'Bogengang',
|
||||
'studio.templates.name.vintagePosterPanels': 'Tafeln und Hügel',
|
||||
'studio.templates.name.vintagePosterLabels': 'Kofferanhänger',
|
||||
'studio.templates.name.nightFilmOpening': 'Eröffnungsszene',
|
||||
'studio.templates.name.nightFilmSubtitles': 'Film mit Untertiteln',
|
||||
'studio.templates.name.nightFilmReels': 'Abblende und Rollen',
|
||||
'studio.templates.name.nightFilmContact': 'Film-Kontaktabzug',
|
||||
'studio.templates.name.nightFilmDoubleReel': 'Doppelte Filmrolle',
|
||||
'studio.templates.name.fieldNotesDiary': 'Tagebuchseite',
|
||||
'studio.templates.name.fieldNotesTrail': 'Spur aus Notizkarten',
|
||||
'studio.templates.name.fieldNotesRoute': 'Route und Polaroids',
|
||||
'studio.templates.name.fieldNotesContact': 'Nummerierter Kontaktabzug',
|
||||
'studio.templates.name.fieldNotesHerbarium': 'Herbarbögen',
|
||||
};
|
||||
|
||||
export default studioTemplates;
|
||||
|
||||
@@ -13,6 +13,11 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.name.nordic': 'Nordisch',
|
||||
'studio.theme.name.alpine': 'Alpin',
|
||||
'studio.theme.name.indiaStamp': 'Briefmarken-Collage',
|
||||
'studio.theme.name.riso': 'Risodruck',
|
||||
'studio.theme.name.magazine': 'Magazin',
|
||||
'studio.theme.name.retroTravel': 'Retroreise',
|
||||
'studio.theme.name.midnight': 'Mitternacht',
|
||||
'studio.theme.name.fieldNotes': 'Feldnotizen',
|
||||
'studio.theme.descr.posterPop': 'Cremepapier, Koralle und Sonnengelb, kräftige Plakattitel',
|
||||
'studio.theme.descr.cutout': 'Kobaltpapier, Formen in Koralle und Sand, weiße Schrift',
|
||||
'studio.theme.descr.postcard': 'Mintpapier, Marineblau, Stempelrot und ein Untertitel in Schreibschrift',
|
||||
@@ -25,6 +30,11 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.descr.nordic': 'Hellgraues Papier mit Schiefer, Ocker und Rosé',
|
||||
'studio.theme.descr.alpine': 'Schneeweiß mit Waldgrün, Rost und Himmelblau',
|
||||
'studio.theme.descr.indiaStamp': 'Warmes Papier mit Magenta, Safran und Petrol',
|
||||
'studio.theme.descr.riso': 'Cremepapier, Risoblau und Neonpink übereinander gedruckt',
|
||||
'studio.theme.descr.magazine': 'Gebrochenes Weiß, schwarze Schrift, ein Rot und ein großer Titelkopf',
|
||||
'studio.theme.descr.retroTravel': 'Vergilbtes Cremepapier mit Orange, Petrol und Senfgelb',
|
||||
'studio.theme.descr.midnight': 'Fast schwarzes Papier, warmweiße Schrift und Neonakzente',
|
||||
'studio.theme.descr.fieldNotes': 'Packpapier, Moosgrün und Rost, Handschrift und Klebeband',
|
||||
'studio.theme.panel': 'Thema',
|
||||
'studio.theme.intro':
|
||||
'Wähle einen Look für das ganze Buch. Farben und Schrift, die dem Thema folgen, ändern sich mit, alles andere bleibt, wie es ist.',
|
||||
|
||||
@@ -585,8 +585,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': 'Starts at',
|
||||
'journey.studio.folioMargin': 'Margin',
|
||||
'journey.studio.relayoutSpread': 'This spread',
|
||||
'journey.studio.relayoutSpreadHint': 'Rebuild it from its entry',
|
||||
'journey.studio.relayoutSpreadNone': 'This page was not built from an entry',
|
||||
'journey.studio.relayoutSpreadHint': 'Lay it on another design',
|
||||
'journey.studio.relayoutSpreadNone': 'The auto layout did not make this page',
|
||||
'journey.studio.relayoutBook': 'The whole book',
|
||||
'journey.studio.relayoutBookHint': 'Replaces every page — undoable',
|
||||
'journey.studio.tpl.coverFull': 'Full bleed',
|
||||
|
||||
@@ -13,6 +13,11 @@ const studioTemplates: TranslationStrings = {
|
||||
'studio.templates.archetype.grid-badge': 'Grid and badge',
|
||||
'studio.templates.archetype.minimal-serif': 'Minimal',
|
||||
'studio.templates.archetype.stamp-collage': 'Stamps',
|
||||
'studio.templates.archetype.riso': 'Riso',
|
||||
'studio.templates.archetype.editorial': 'Editorial',
|
||||
'studio.templates.archetype.vintage-poster': 'Vintage poster',
|
||||
'studio.templates.archetype.night-film': 'Night film',
|
||||
'studio.templates.archetype.field-notes': 'Field notes',
|
||||
'studio.templates.name.spineBandStitched': 'Stitched band',
|
||||
'studio.templates.name.spineBandGingham': 'Gingham band',
|
||||
'studio.templates.name.spineBandScript': 'Band with script',
|
||||
@@ -50,6 +55,77 @@ const studioTemplates: TranslationStrings = {
|
||||
'studio.templates.name.gridFeature': 'Feature grid',
|
||||
'studio.templates.name.gridColumns': 'Column grid',
|
||||
'studio.templates.name.gridMosaic': 'Mosaic grid',
|
||||
'studio.templates.name.spineBandPlate': 'Tipped-in plate',
|
||||
'studio.templates.name.posterPopSunDisc': 'Sun disc',
|
||||
'studio.templates.name.cutoutCircle': 'Circle cut-out',
|
||||
'studio.templates.name.postcardGreetings': 'Greetings postcard',
|
||||
'studio.templates.name.gridBadgeTiles': 'Badge tile grid',
|
||||
'studio.templates.name.minimalSerifCameo': 'Oval cameo',
|
||||
'studio.templates.name.stampCollageAirmail': 'Airmail envelope',
|
||||
'studio.templates.name.risoOverprint': 'Overprint poster',
|
||||
'studio.templates.name.risoHalftoneSun': 'Halftone sun',
|
||||
'studio.templates.name.risoZineStack': 'Zine stack',
|
||||
'studio.templates.name.risoPinkPress': 'Pink press',
|
||||
'studio.templates.name.editorialMasthead': 'Masthead cover',
|
||||
'studio.templates.name.editorialCoverStory': 'Cover story',
|
||||
'studio.templates.name.editorialSplit': 'Photo over headline',
|
||||
'studio.templates.name.editorialHeadline': 'Headline cover',
|
||||
'studio.templates.name.vintagePosterPanorama': 'Poster panorama',
|
||||
'studio.templates.name.vintagePosterArch': 'Arch and sea',
|
||||
'studio.templates.name.vintagePosterSunDisc': 'Sun over hills',
|
||||
'studio.templates.name.vintagePosterPeaks': 'Stacked peaks',
|
||||
'studio.templates.name.nightFilmLetterbox': 'Letterbox cover',
|
||||
'studio.templates.name.nightFilmStrip': 'Film strip cover',
|
||||
'studio.templates.name.nightFilmTitleCard': 'Title card',
|
||||
'studio.templates.name.nightFilmFadeOut': 'Fade to black',
|
||||
'studio.templates.name.fieldNotesTorn': 'Torn notebook sheet',
|
||||
'studio.templates.name.fieldNotesPolaroid': 'Taped polaroid',
|
||||
'studio.templates.name.fieldNotesSpecimens': 'Taped specimens',
|
||||
'studio.templates.name.fieldNotesLabel': 'Notebook label',
|
||||
'studio.templates.name.spineBandClothStory': 'Story on cloth',
|
||||
'studio.templates.name.spineBandGinghamTrio': 'Gingham spine trio',
|
||||
'studio.templates.name.spineBandAlbum': 'Bound album',
|
||||
'studio.templates.name.posterPopHeadline': 'Poster headline',
|
||||
'studio.templates.name.posterPopPostcards': 'Tilted prints',
|
||||
'studio.templates.name.posterPopPhotoWall': 'Photo wall',
|
||||
'studio.templates.name.cutoutStoryPage': 'Cut-out story',
|
||||
'studio.templates.name.cutoutTrio': 'Cut-out trio',
|
||||
'studio.templates.name.cutoutScatter': 'Scattered prints',
|
||||
'studio.templates.name.cutoutMosaic': 'Cut-out mosaic',
|
||||
'studio.templates.name.postcardAirmail': 'Airmail letter',
|
||||
'studio.templates.name.postcardRack': 'Postcard rack',
|
||||
'studio.templates.name.gridBadgeLetter': 'Seal and story',
|
||||
'studio.templates.name.gridBadgeHeroSix': 'Hero and six',
|
||||
'studio.templates.name.minimalSerifEssay': 'Quiet essay',
|
||||
'studio.templates.name.minimalSerifTriptych': 'Three plates',
|
||||
'studio.templates.name.stampCollageLetter': 'Postmarked letter',
|
||||
'studio.templates.name.stampCollageArchStamps': 'Arch and stamps',
|
||||
'studio.templates.name.stampCollageSheet': 'Stamp sheet',
|
||||
'studio.templates.name.risoStoryPlate': 'Story plate',
|
||||
'studio.templates.name.risoOffsetPair': 'Offset pair',
|
||||
'studio.templates.name.risoBlobTrio': 'Blob trio',
|
||||
'studio.templates.name.risoZineGrid': 'Zine grid',
|
||||
'studio.templates.name.risoPrintScatter': 'Print scatter',
|
||||
'studio.templates.name.editorialFeatureOpener': 'Feature opener',
|
||||
'studio.templates.name.editorialPullQuote': 'Pull quote',
|
||||
'studio.templates.name.editorialFeatureColumns': 'Feature columns',
|
||||
'studio.templates.name.editorialPortfolio': 'Portfolio spread',
|
||||
'studio.templates.name.editorialContactSheet': 'Contact sheet',
|
||||
'studio.templates.name.vintagePosterStory': 'Poster and story',
|
||||
'studio.templates.name.vintagePosterTwin': 'Twin posters',
|
||||
'studio.templates.name.vintagePosterArcade': 'Arch arcade',
|
||||
'studio.templates.name.vintagePosterPanels': 'Panels and hills',
|
||||
'studio.templates.name.vintagePosterLabels': 'Luggage labels',
|
||||
'studio.templates.name.nightFilmOpening': 'Opening scene',
|
||||
'studio.templates.name.nightFilmSubtitles': 'Film subtitles',
|
||||
'studio.templates.name.nightFilmReels': 'Fade and reels',
|
||||
'studio.templates.name.nightFilmContact': 'Film contact sheet',
|
||||
'studio.templates.name.nightFilmDoubleReel': 'Double reel',
|
||||
'studio.templates.name.fieldNotesDiary': 'Diary page',
|
||||
'studio.templates.name.fieldNotesTrail': 'Note card trail',
|
||||
'studio.templates.name.fieldNotesRoute': 'Route and polaroids',
|
||||
'studio.templates.name.fieldNotesContact': 'Numbered contact sheet',
|
||||
'studio.templates.name.fieldNotesHerbarium': 'Herbarium sheets',
|
||||
};
|
||||
|
||||
export default studioTemplates;
|
||||
|
||||
@@ -13,6 +13,11 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.name.nordic': 'Nordic',
|
||||
'studio.theme.name.alpine': 'Alpine',
|
||||
'studio.theme.name.indiaStamp': 'Stamp collage',
|
||||
'studio.theme.name.riso': 'Riso print',
|
||||
'studio.theme.name.magazine': 'Magazine',
|
||||
'studio.theme.name.retroTravel': 'Retro travel',
|
||||
'studio.theme.name.midnight': 'Midnight',
|
||||
'studio.theme.name.fieldNotes': 'Field notes',
|
||||
'studio.theme.descr.posterPop': 'Cream paper, coral and sunflower, heavy poster titles',
|
||||
'studio.theme.descr.cutout': 'Cobalt paper, coral and sand shapes, white type',
|
||||
'studio.theme.descr.postcard': 'Mint paper, navy ink, stamp red and a script subtitle',
|
||||
@@ -25,6 +30,11 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.descr.nordic': 'Pale grey paper with slate, ochre and rose',
|
||||
'studio.theme.descr.alpine': 'Snow white with forest green, rust and sky blue',
|
||||
'studio.theme.descr.indiaStamp': 'Warm paper with magenta, saffron and teal',
|
||||
'studio.theme.descr.riso': 'Cream paper, riso blue and fluo pink overprinted',
|
||||
'studio.theme.descr.magazine': 'Off white paper, black ink, one red and a big masthead',
|
||||
'studio.theme.descr.retroTravel': 'Aged cream paper with orange, teal and mustard',
|
||||
'studio.theme.descr.midnight': 'Near black paper, warm white type and neon accents',
|
||||
'studio.theme.descr.fieldNotes': 'Kraft paper, moss green and rust, handwriting and tape',
|
||||
'studio.theme.panel': 'Theme',
|
||||
'studio.theme.intro':
|
||||
'Pick a look for the whole book. Colours and type that follow the theme change with it, everything else stays as it is.',
|
||||
|
||||
@@ -588,8 +588,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': 'Empieza en',
|
||||
'journey.studio.folioMargin': 'Margen',
|
||||
'journey.studio.relayoutSpread': 'Esta página',
|
||||
'journey.studio.relayoutSpreadHint': 'Rehacerla desde su entrada',
|
||||
'journey.studio.relayoutSpreadNone': 'Esta página no viene de una entrada',
|
||||
'journey.studio.relayoutSpreadHint': 'Ponerla en otro diseño',
|
||||
'journey.studio.relayoutSpreadNone': 'El diseño automático no hizo esta página',
|
||||
'journey.studio.relayoutBook': 'Todo el libro',
|
||||
'journey.studio.relayoutBookHint': 'Reemplaza todas las páginas — reversible',
|
||||
'journey.studio.tpl.coverFull': 'A sangre',
|
||||
|
||||
@@ -13,6 +13,11 @@ const studioTemplates: TranslationStrings = {
|
||||
'studio.templates.archetype.grid-badge': 'Cuadrícula e insignia',
|
||||
'studio.templates.archetype.minimal-serif': 'Minimalista',
|
||||
'studio.templates.archetype.stamp-collage': 'Sellos',
|
||||
'studio.templates.archetype.riso': 'Riso', // same-as-en
|
||||
'studio.templates.archetype.editorial': 'Revista',
|
||||
'studio.templates.archetype.vintage-poster': 'Cartel antiguo',
|
||||
'studio.templates.archetype.night-film': 'Cine nocturno',
|
||||
'studio.templates.archetype.field-notes': 'Cuaderno de campo',
|
||||
'studio.templates.name.spineBandStitched': 'Banda cosida',
|
||||
'studio.templates.name.spineBandGingham': 'Banda de vichy',
|
||||
'studio.templates.name.spineBandScript': 'Banda y caligrafía',
|
||||
@@ -50,6 +55,77 @@ const studioTemplates: TranslationStrings = {
|
||||
'studio.templates.name.gridFeature': 'Cuadrícula destacada',
|
||||
'studio.templates.name.gridColumns': 'Cuadrícula en columnas',
|
||||
'studio.templates.name.gridMosaic': 'Cuadrícula mosaico',
|
||||
'studio.templates.name.spineBandPlate': 'Lámina pegada',
|
||||
'studio.templates.name.posterPopSunDisc': 'Disco solar',
|
||||
'studio.templates.name.cutoutCircle': 'Círculo recortado',
|
||||
'studio.templates.name.postcardGreetings': 'Postal de saludos',
|
||||
'studio.templates.name.gridBadgeTiles': 'Cuadrícula de insignias',
|
||||
'studio.templates.name.minimalSerifCameo': 'Camafeo ovalado',
|
||||
'studio.templates.name.stampCollageAirmail': 'Sobre de correo aéreo',
|
||||
'studio.templates.name.risoOverprint': 'Póster sobreimpreso',
|
||||
'studio.templates.name.risoHalftoneSun': 'Sol en trama',
|
||||
'studio.templates.name.risoZineStack': 'Pila de fanzines',
|
||||
'studio.templates.name.risoPinkPress': 'Prensa rosa',
|
||||
'studio.templates.name.editorialMasthead': 'Portada con cabecera',
|
||||
'studio.templates.name.editorialCoverStory': 'Tema de portada',
|
||||
'studio.templates.name.editorialSplit': 'Foto sobre titular',
|
||||
'studio.templates.name.editorialHeadline': 'Portada de titular',
|
||||
'studio.templates.name.vintagePosterPanorama': 'Panorama de póster',
|
||||
'studio.templates.name.vintagePosterArch': 'Arco y mar',
|
||||
'studio.templates.name.vintagePosterSunDisc': 'Sol sobre colinas',
|
||||
'studio.templates.name.vintagePosterPeaks': 'Picos escalonados',
|
||||
'studio.templates.name.nightFilmLetterbox': 'Portada panorámica',
|
||||
'studio.templates.name.nightFilmStrip': 'Portada con tira de película',
|
||||
'studio.templates.name.nightFilmTitleCard': 'Cartón de título',
|
||||
'studio.templates.name.nightFilmFadeOut': 'Fundido a negro',
|
||||
'studio.templates.name.fieldNotesTorn': 'Hoja de cuaderno rota',
|
||||
'studio.templates.name.fieldNotesPolaroid': 'Polaroid con cinta',
|
||||
'studio.templates.name.fieldNotesSpecimens': 'Muestras con cinta',
|
||||
'studio.templates.name.fieldNotesLabel': 'Etiqueta de cuaderno',
|
||||
'studio.templates.name.spineBandClothStory': 'Relato sobre tela',
|
||||
'studio.templates.name.spineBandGinghamTrio': 'Trío con lomo vichy',
|
||||
'studio.templates.name.spineBandAlbum': 'Álbum encuadernado',
|
||||
'studio.templates.name.posterPopHeadline': 'Titular de póster',
|
||||
'studio.templates.name.posterPopPostcards': 'Copias inclinadas',
|
||||
'studio.templates.name.posterPopPhotoWall': 'Muro de fotos',
|
||||
'studio.templates.name.cutoutStoryPage': 'Relato recortado',
|
||||
'studio.templates.name.cutoutTrio': 'Trío recortado',
|
||||
'studio.templates.name.cutoutScatter': 'Copias dispersas',
|
||||
'studio.templates.name.cutoutMosaic': 'Mosaico recortado',
|
||||
'studio.templates.name.postcardAirmail': 'Carta por avión',
|
||||
'studio.templates.name.postcardRack': 'Expositor de postales',
|
||||
'studio.templates.name.gridBadgeLetter': 'Sello y relato',
|
||||
'studio.templates.name.gridBadgeHeroSix': 'Foto principal y seis',
|
||||
'studio.templates.name.minimalSerifEssay': 'Ensayo sereno',
|
||||
'studio.templates.name.minimalSerifTriptych': 'Tres láminas',
|
||||
'studio.templates.name.stampCollageLetter': 'Carta matasellada',
|
||||
'studio.templates.name.stampCollageArchStamps': 'Arco y sellos',
|
||||
'studio.templates.name.stampCollageSheet': 'Hoja de sellos',
|
||||
'studio.templates.name.risoStoryPlate': 'Relato y lámina',
|
||||
'studio.templates.name.risoOffsetPair': 'Pareja desplazada',
|
||||
'studio.templates.name.risoBlobTrio': 'Trío de manchas',
|
||||
'studio.templates.name.risoZineGrid': 'Cuadrícula de fanzine',
|
||||
'studio.templates.name.risoPrintScatter': 'Copias esparcidas',
|
||||
'studio.templates.name.editorialFeatureOpener': 'Apertura de reportaje',
|
||||
'studio.templates.name.editorialPullQuote': 'Cita destacada',
|
||||
'studio.templates.name.editorialFeatureColumns': 'Reportaje en columnas',
|
||||
'studio.templates.name.editorialPortfolio': 'Doble página de portafolio',
|
||||
'studio.templates.name.editorialContactSheet': 'Hoja de contactos',
|
||||
'studio.templates.name.vintagePosterStory': 'Póster y relato',
|
||||
'studio.templates.name.vintagePosterTwin': 'Pósteres gemelos',
|
||||
'studio.templates.name.vintagePosterArcade': 'Arcada de arcos',
|
||||
'studio.templates.name.vintagePosterPanels': 'Paneles y colinas',
|
||||
'studio.templates.name.vintagePosterLabels': 'Etiquetas de equipaje',
|
||||
'studio.templates.name.nightFilmOpening': 'Escena inicial',
|
||||
'studio.templates.name.nightFilmSubtitles': 'Subtítulos de película',
|
||||
'studio.templates.name.nightFilmReels': 'Fundido y bobinas',
|
||||
'studio.templates.name.nightFilmContact': 'Hoja de contactos de cine',
|
||||
'studio.templates.name.nightFilmDoubleReel': 'Bobina doble',
|
||||
'studio.templates.name.fieldNotesDiary': 'Página de diario',
|
||||
'studio.templates.name.fieldNotesTrail': 'Rastro de fichas',
|
||||
'studio.templates.name.fieldNotesRoute': 'Ruta y polaroids',
|
||||
'studio.templates.name.fieldNotesContact': 'Hoja de contactos numerada',
|
||||
'studio.templates.name.fieldNotesHerbarium': 'Pliegos de herbario',
|
||||
};
|
||||
|
||||
export default studioTemplates;
|
||||
|
||||
@@ -13,6 +13,11 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.name.nordic': 'Nórdico',
|
||||
'studio.theme.name.alpine': 'Alpino',
|
||||
'studio.theme.name.indiaStamp': 'Collage de sellos',
|
||||
'studio.theme.name.riso': 'Impresión riso',
|
||||
'studio.theme.name.magazine': 'Revista',
|
||||
'studio.theme.name.retroTravel': 'Viaje retro',
|
||||
'studio.theme.name.midnight': 'Medianoche',
|
||||
'studio.theme.name.fieldNotes': 'Cuaderno de campo',
|
||||
'studio.theme.descr.posterPop': 'Papel crema, coral y girasol, títulos de póster gruesos',
|
||||
'studio.theme.descr.cutout': 'Papel cobalto, formas coral y arena, letra blanca',
|
||||
'studio.theme.descr.postcard': 'Papel menta, tinta marino, rojo de sello y un subtítulo manuscrito',
|
||||
@@ -25,6 +30,11 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.descr.nordic': 'Papel gris claro con pizarra, ocre y rosa',
|
||||
'studio.theme.descr.alpine': 'Blanco nieve con verde bosque, óxido y azul cielo',
|
||||
'studio.theme.descr.indiaStamp': 'Papel cálido con magenta, azafrán y verde azulado',
|
||||
'studio.theme.descr.riso': 'Papel crema, azul riso y rosa flúor sobreimpresos',
|
||||
'studio.theme.descr.magazine': 'Papel blanco roto, tinta negra, un rojo y una gran cabecera',
|
||||
'studio.theme.descr.retroTravel': 'Papel crema envejecido con naranja, verde azulado y mostaza',
|
||||
'studio.theme.descr.midnight': 'Papel casi negro, letra blanca cálida y acentos neón',
|
||||
'studio.theme.descr.fieldNotes': 'Papel kraft, verde musgo y óxido, letra a mano y cinta',
|
||||
'studio.theme.panel': 'Tema',
|
||||
'studio.theme.intro':
|
||||
'Elige un estilo para todo el libro. Los colores y la letra que siguen al tema cambian con él, todo lo demás se queda como está.',
|
||||
|
||||
@@ -586,8 +586,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': 'Algab numbrist',
|
||||
'journey.studio.folioMargin': 'Veeris',
|
||||
'journey.studio.relayoutSpread': 'See lehepaar',
|
||||
'journey.studio.relayoutSpreadHint': 'Loo uuesti sissekande põhjal',
|
||||
'journey.studio.relayoutSpreadNone': 'See leht pole loodud sissekandest',
|
||||
'journey.studio.relayoutSpreadHint': 'Paiguta teise kujundusse',
|
||||
'journey.studio.relayoutSpreadNone': 'Seda lehte ei loonud automaatne paigutus',
|
||||
'journey.studio.relayoutBook': 'Kogu raamat',
|
||||
'journey.studio.relayoutBookHint': 'Asendab kõik lehed — saab tagasi võtta',
|
||||
'journey.studio.tpl.coverFull': 'Servani',
|
||||
|
||||
@@ -13,6 +13,11 @@ const studioTemplates: TranslationStrings = {
|
||||
'studio.templates.archetype.grid-badge': 'Võrk ja märk',
|
||||
'studio.templates.archetype.minimal-serif': 'Minimaalne',
|
||||
'studio.templates.archetype.stamp-collage': 'Margid',
|
||||
'studio.templates.archetype.riso': 'Riso', // same-as-en
|
||||
'studio.templates.archetype.editorial': 'Ajakiri',
|
||||
'studio.templates.archetype.vintage-poster': 'Retroplakat',
|
||||
'studio.templates.archetype.night-film': 'Öine film',
|
||||
'studio.templates.archetype.field-notes': 'Välimärkmed',
|
||||
'studio.templates.name.spineBandStitched': 'Õmmeldud riba',
|
||||
'studio.templates.name.spineBandGingham': 'Ruuduline riba',
|
||||
'studio.templates.name.spineBandScript': 'Riba ja käekiri',
|
||||
@@ -50,6 +55,77 @@ const studioTemplates: TranslationStrings = {
|
||||
'studio.templates.name.gridFeature': 'Esiletõstetud võrk',
|
||||
'studio.templates.name.gridColumns': 'Veergudega võrk',
|
||||
'studio.templates.name.gridMosaic': 'Mosaiikvõrk',
|
||||
'studio.templates.name.spineBandPlate': 'Kleebitud tahvel',
|
||||
'studio.templates.name.posterPopSunDisc': 'Päikeseketas',
|
||||
'studio.templates.name.cutoutCircle': 'Väljalõigatud ring',
|
||||
'studio.templates.name.postcardGreetings': 'Tervituskaart',
|
||||
'studio.templates.name.gridBadgeTiles': 'Märkide ruudustik',
|
||||
'studio.templates.name.minimalSerifCameo': 'Ovaalne kamee',
|
||||
'studio.templates.name.stampCollageAirmail': 'Lennuposti ümbrik',
|
||||
'studio.templates.name.risoOverprint': 'Ületrükitud plakat',
|
||||
'studio.templates.name.risoHalftoneSun': 'Rastripäike',
|
||||
'studio.templates.name.risoZineStack': 'Zine’ide virn',
|
||||
'studio.templates.name.risoPinkPress': 'Roosa trükk',
|
||||
'studio.templates.name.editorialMasthead': 'Päisega kaas',
|
||||
'studio.templates.name.editorialCoverStory': 'Kaanelugu',
|
||||
'studio.templates.name.editorialSplit': 'Foto pealkirja kohal',
|
||||
'studio.templates.name.editorialHeadline': 'Pealkirjaga kaas',
|
||||
'studio.templates.name.vintagePosterPanorama': 'Plakatipanoraam',
|
||||
'studio.templates.name.vintagePosterArch': 'Kaar ja meri',
|
||||
'studio.templates.name.vintagePosterSunDisc': 'Päike küngaste kohal',
|
||||
'studio.templates.name.vintagePosterPeaks': 'Astmelised tipud',
|
||||
'studio.templates.name.nightFilmLetterbox': 'Laiekraanikaas',
|
||||
'studio.templates.name.nightFilmStrip': 'Filmilindiga kaas',
|
||||
'studio.templates.name.nightFilmTitleCard': 'Tiitlikaart',
|
||||
'studio.templates.name.nightFilmFadeOut': 'Hääbumine pimedusse',
|
||||
'studio.templates.name.fieldNotesTorn': 'Rebitud märkmikuleht',
|
||||
'studio.templates.name.fieldNotesPolaroid': 'Teibitud polaroid',
|
||||
'studio.templates.name.fieldNotesSpecimens': 'Teibitud leiud',
|
||||
'studio.templates.name.fieldNotesLabel': 'Märkmiku silt',
|
||||
'studio.templates.name.spineBandClothStory': 'Lugu riidel',
|
||||
'studio.templates.name.spineBandGinghamTrio': 'Ruudulise seljaga kolmik',
|
||||
'studio.templates.name.spineBandAlbum': 'Köidetud album',
|
||||
'studio.templates.name.posterPopHeadline': 'Plakati pealkiri',
|
||||
'studio.templates.name.posterPopPostcards': 'Kaldus fotod',
|
||||
'studio.templates.name.posterPopPhotoWall': 'Fotosein',
|
||||
'studio.templates.name.cutoutStoryPage': 'Väljalõigatud lugu',
|
||||
'studio.templates.name.cutoutTrio': 'Väljalõigatud kolmik',
|
||||
'studio.templates.name.cutoutScatter': 'Laiali fotod',
|
||||
'studio.templates.name.cutoutMosaic': 'Väljalõigatud mosaiik',
|
||||
'studio.templates.name.postcardAirmail': 'Lennupostikiri',
|
||||
'studio.templates.name.postcardRack': 'Postkaardiriiul',
|
||||
'studio.templates.name.gridBadgeLetter': 'Pitser ja lugu',
|
||||
'studio.templates.name.gridBadgeHeroSix': 'Peafoto ja kuus',
|
||||
'studio.templates.name.minimalSerifEssay': 'Vaikne essee',
|
||||
'studio.templates.name.minimalSerifTriptych': 'Kolm tahvlit',
|
||||
'studio.templates.name.stampCollageLetter': 'Templiga kiri',
|
||||
'studio.templates.name.stampCollageArchStamps': 'Kaar ja margid',
|
||||
'studio.templates.name.stampCollageSheet': 'Margileht',
|
||||
'studio.templates.name.risoStoryPlate': 'Lugu ja tahvel',
|
||||
'studio.templates.name.risoOffsetPair': 'Nihkes paar',
|
||||
'studio.templates.name.risoBlobTrio': 'Laigukolmik',
|
||||
'studio.templates.name.risoZineGrid': 'Zine’i ruudustik',
|
||||
'studio.templates.name.risoPrintScatter': 'Hajutatud trükid',
|
||||
'studio.templates.name.editorialFeatureOpener': 'Reportaaži avang',
|
||||
'studio.templates.name.editorialPullQuote': 'Esiletõstetud tsitaat',
|
||||
'studio.templates.name.editorialFeatureColumns': 'Reportaaž veergudes',
|
||||
'studio.templates.name.editorialPortfolio': 'Portfoolio kaksikleht',
|
||||
'studio.templates.name.editorialContactSheet': 'Kontaktleht',
|
||||
'studio.templates.name.vintagePosterStory': 'Plakat ja lugu',
|
||||
'studio.templates.name.vintagePosterTwin': 'Kaksikplakatid',
|
||||
'studio.templates.name.vintagePosterArcade': 'Kaaregalerii',
|
||||
'studio.templates.name.vintagePosterPanels': 'Paneelid ja künkad',
|
||||
'studio.templates.name.vintagePosterLabels': 'Pagasisildid',
|
||||
'studio.templates.name.nightFilmOpening': 'Avastseen',
|
||||
'studio.templates.name.nightFilmSubtitles': 'Filmi subtiitrid',
|
||||
'studio.templates.name.nightFilmReels': 'Hääbumine ja rullid',
|
||||
'studio.templates.name.nightFilmContact': 'Filmi kontaktleht',
|
||||
'studio.templates.name.nightFilmDoubleReel': 'Topeltrull',
|
||||
'studio.templates.name.fieldNotesDiary': 'Päevikuleht',
|
||||
'studio.templates.name.fieldNotesTrail': 'Märkmekaartide rada',
|
||||
'studio.templates.name.fieldNotesRoute': 'Marsruut ja polaroidid',
|
||||
'studio.templates.name.fieldNotesContact': 'Nummerdatud kontaktleht',
|
||||
'studio.templates.name.fieldNotesHerbarium': 'Herbaariumilehed',
|
||||
};
|
||||
|
||||
export default studioTemplates;
|
||||
|
||||
@@ -13,6 +13,11 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.name.nordic': 'Põhjamaine',
|
||||
'studio.theme.name.alpine': 'Alpi',
|
||||
'studio.theme.name.indiaStamp': 'Postmargikollaaž',
|
||||
'studio.theme.name.riso': 'Risotrükk',
|
||||
'studio.theme.name.magazine': 'Ajakiri',
|
||||
'studio.theme.name.retroTravel': 'Retroreis',
|
||||
'studio.theme.name.midnight': 'Kesköö',
|
||||
'studio.theme.name.fieldNotes': 'Välimärkmed',
|
||||
'studio.theme.descr.posterPop': 'Kreemjas paber, korall ja päevalill, jõulised plakatipealkirjad',
|
||||
'studio.theme.descr.cutout': 'Koobaltpaber, korall- ja liivakarva kujundid, valge kiri',
|
||||
'studio.theme.descr.postcard': 'Mündiroheline paber, meresinine tint, templipunane ja käsikirjaline alapealkiri',
|
||||
@@ -25,6 +30,11 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.descr.nordic': 'Helehall paber kiltkivi, ookri ja roosaga',
|
||||
'studio.theme.descr.alpine': 'Lumivalge metsarohelise, rooste ja taevasinisega',
|
||||
'studio.theme.descr.indiaStamp': 'Soe paber magenta, safrani ja sinakasrohelisega',
|
||||
'studio.theme.descr.riso': 'Kreemjas paber, risosinine ja neoonroosa üksteise peal',
|
||||
'studio.theme.descr.magazine': 'Murtud valge paber, must tint, üks punane ja suur päis',
|
||||
'studio.theme.descr.retroTravel': 'Vanutatud kreemjas paber oranži, sinakasrohelise ja sinepiga',
|
||||
'studio.theme.descr.midnight': 'Peaaegu must paber, soe valge kiri ja neoonaktsendid',
|
||||
'studio.theme.descr.fieldNotes': 'Pakkepaber, samblaroheline ja rooste, käekiri ja teip',
|
||||
'studio.theme.panel': 'Teema',
|
||||
'studio.theme.intro':
|
||||
'Vali kogu raamatule üks välimus. Teemat järgivad värvid ja kiri muutuvad koos sellega, kõik muu jääb samaks.',
|
||||
|
||||
@@ -588,8 +588,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': 'Commence à',
|
||||
'journey.studio.folioMargin': 'Marge',
|
||||
'journey.studio.relayoutSpread': 'Cette page',
|
||||
'journey.studio.relayoutSpreadHint': 'La refaire depuis son entrée',
|
||||
'journey.studio.relayoutSpreadNone': 'Cette page ne vient pas d’une entrée',
|
||||
'journey.studio.relayoutSpreadHint': 'La poser sur une autre maquette',
|
||||
'journey.studio.relayoutSpreadNone': 'La mise en page auto n’a pas fait cette page',
|
||||
'journey.studio.relayoutBook': 'Tout le livre',
|
||||
'journey.studio.relayoutBookHint': 'Remplace toutes les pages — annulable',
|
||||
'journey.studio.tpl.coverFull': 'Pleine page',
|
||||
|
||||
@@ -13,6 +13,11 @@ const studioTemplates: TranslationStrings = {
|
||||
'studio.templates.archetype.grid-badge': 'Grille et badge',
|
||||
'studio.templates.archetype.minimal-serif': 'Minimal', // same-as-en
|
||||
'studio.templates.archetype.stamp-collage': 'Timbres',
|
||||
'studio.templates.archetype.riso': 'Riso', // same-as-en
|
||||
'studio.templates.archetype.editorial': 'Magazine',
|
||||
'studio.templates.archetype.vintage-poster': 'Affiche rétro',
|
||||
'studio.templates.archetype.night-film': 'Film de nuit',
|
||||
'studio.templates.archetype.field-notes': 'Carnet de terrain',
|
||||
'studio.templates.name.spineBandStitched': 'Bande cousue',
|
||||
'studio.templates.name.spineBandGingham': 'Bande vichy',
|
||||
'studio.templates.name.spineBandScript': 'Bande et script',
|
||||
@@ -50,6 +55,77 @@ const studioTemplates: TranslationStrings = {
|
||||
'studio.templates.name.gridFeature': 'Grille à la une',
|
||||
'studio.templates.name.gridColumns': 'Grille en colonnes',
|
||||
'studio.templates.name.gridMosaic': 'Grille mosaïque',
|
||||
'studio.templates.name.spineBandPlate': 'Planche contrecollée',
|
||||
'studio.templates.name.posterPopSunDisc': 'Disque solaire',
|
||||
'studio.templates.name.cutoutCircle': 'Cercle découpé',
|
||||
'studio.templates.name.postcardGreetings': 'Carte postale souvenir',
|
||||
'studio.templates.name.gridBadgeTiles': 'Grille de pastilles',
|
||||
'studio.templates.name.minimalSerifCameo': 'Camée ovale',
|
||||
'studio.templates.name.stampCollageAirmail': 'Enveloppe par avion',
|
||||
'studio.templates.name.risoOverprint': 'Affiche en surimpression',
|
||||
'studio.templates.name.risoHalftoneSun': 'Soleil en trame',
|
||||
'studio.templates.name.risoZineStack': 'Pile de fanzines',
|
||||
'studio.templates.name.risoPinkPress': 'Presse rose',
|
||||
'studio.templates.name.editorialMasthead': 'Couverture à manchette',
|
||||
'studio.templates.name.editorialCoverStory': 'Sujet de couverture',
|
||||
'studio.templates.name.editorialSplit': 'Photo sur gros titre',
|
||||
'studio.templates.name.editorialHeadline': 'Couverture gros titre',
|
||||
'studio.templates.name.vintagePosterPanorama': 'Panorama d’affiche',
|
||||
'studio.templates.name.vintagePosterArch': 'Arche et mer',
|
||||
'studio.templates.name.vintagePosterSunDisc': 'Soleil sur les collines',
|
||||
'studio.templates.name.vintagePosterPeaks': 'Sommets étagés',
|
||||
'studio.templates.name.nightFilmLetterbox': 'Couverture cinémascope',
|
||||
'studio.templates.name.nightFilmStrip': 'Couverture pellicule',
|
||||
'studio.templates.name.nightFilmTitleCard': 'Carton de titre',
|
||||
'studio.templates.name.nightFilmFadeOut': 'Fondu au noir',
|
||||
'studio.templates.name.fieldNotesTorn': 'Feuille de carnet déchirée',
|
||||
'studio.templates.name.fieldNotesPolaroid': 'Polaroid scotché',
|
||||
'studio.templates.name.fieldNotesSpecimens': 'Spécimens scotchés',
|
||||
'studio.templates.name.fieldNotesLabel': 'Étiquette de carnet',
|
||||
'studio.templates.name.spineBandClothStory': 'Récit sur toile',
|
||||
'studio.templates.name.spineBandGinghamTrio': 'Trio dos vichy',
|
||||
'studio.templates.name.spineBandAlbum': 'Album relié',
|
||||
'studio.templates.name.posterPopHeadline': 'Gros titre d’affiche',
|
||||
'studio.templates.name.posterPopPostcards': 'Tirages inclinés',
|
||||
'studio.templates.name.posterPopPhotoWall': 'Mur de photos',
|
||||
'studio.templates.name.cutoutStoryPage': 'Récit découpé',
|
||||
'studio.templates.name.cutoutTrio': 'Trio découpé',
|
||||
'studio.templates.name.cutoutScatter': 'Tirages éparpillés',
|
||||
'studio.templates.name.cutoutMosaic': 'Mosaïque découpée',
|
||||
'studio.templates.name.postcardAirmail': 'Lettre par avion',
|
||||
'studio.templates.name.postcardRack': 'Présentoir de cartes',
|
||||
'studio.templates.name.gridBadgeLetter': 'Sceau et récit',
|
||||
'studio.templates.name.gridBadgeHeroSix': 'Photo et six',
|
||||
'studio.templates.name.minimalSerifEssay': 'Essai paisible',
|
||||
'studio.templates.name.minimalSerifTriptych': 'Trois planches',
|
||||
'studio.templates.name.stampCollageLetter': 'Lettre oblitérée',
|
||||
'studio.templates.name.stampCollageArchStamps': 'Arche et timbres',
|
||||
'studio.templates.name.stampCollageSheet': 'Feuille de timbres',
|
||||
'studio.templates.name.risoStoryPlate': 'Récit et planche',
|
||||
'studio.templates.name.risoOffsetPair': 'Paire décalée',
|
||||
'studio.templates.name.risoBlobTrio': 'Trio de taches',
|
||||
'studio.templates.name.risoZineGrid': 'Grille de fanzine',
|
||||
'studio.templates.name.risoPrintScatter': 'Tirages épars',
|
||||
'studio.templates.name.editorialFeatureOpener': 'Ouverture de reportage',
|
||||
'studio.templates.name.editorialPullQuote': 'Citation en exergue',
|
||||
'studio.templates.name.editorialFeatureColumns': 'Reportage en colonnes',
|
||||
'studio.templates.name.editorialPortfolio': 'Double page portfolio',
|
||||
'studio.templates.name.editorialContactSheet': 'Planche-contact',
|
||||
'studio.templates.name.vintagePosterStory': 'Affiche et récit',
|
||||
'studio.templates.name.vintagePosterTwin': 'Affiches jumelles',
|
||||
'studio.templates.name.vintagePosterArcade': 'Galerie d’arcades',
|
||||
'studio.templates.name.vintagePosterPanels': 'Panneaux et collines',
|
||||
'studio.templates.name.vintagePosterLabels': 'Étiquettes de bagage',
|
||||
'studio.templates.name.nightFilmOpening': 'Scène d’ouverture',
|
||||
'studio.templates.name.nightFilmSubtitles': 'Sous-titres de film',
|
||||
'studio.templates.name.nightFilmReels': 'Fondu et bobines',
|
||||
'studio.templates.name.nightFilmContact': 'Planche-contact film',
|
||||
'studio.templates.name.nightFilmDoubleReel': 'Double bobine',
|
||||
'studio.templates.name.fieldNotesDiary': 'Page de journal',
|
||||
'studio.templates.name.fieldNotesTrail': 'Fil de fiches',
|
||||
'studio.templates.name.fieldNotesRoute': 'Itinéraire et polaroids',
|
||||
'studio.templates.name.fieldNotesContact': 'Planche-contact numérotée',
|
||||
'studio.templates.name.fieldNotesHerbarium': 'Planches d’herbier',
|
||||
};
|
||||
|
||||
export default studioTemplates;
|
||||
|
||||
@@ -13,6 +13,11 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.name.nordic': 'Nordique',
|
||||
'studio.theme.name.alpine': 'Alpin',
|
||||
'studio.theme.name.indiaStamp': 'Collage de timbres',
|
||||
'studio.theme.name.riso': 'Impression riso',
|
||||
'studio.theme.name.magazine': 'Magazine', // same-as-en
|
||||
'studio.theme.name.retroTravel': 'Voyage rétro',
|
||||
'studio.theme.name.midnight': 'Minuit',
|
||||
'studio.theme.name.fieldNotes': 'Carnet de terrain',
|
||||
'studio.theme.descr.posterPop': 'Papier crème, corail et tournesol, titres d’affiche épais',
|
||||
'studio.theme.descr.cutout': 'Papier cobalt, formes corail et sable, texte blanc',
|
||||
'studio.theme.descr.postcard': 'Papier menthe, encre marine, rouge tampon et sous-titre manuscrit',
|
||||
@@ -25,6 +30,11 @@ const studioThemes: TranslationStrings = {
|
||||
'studio.theme.descr.nordic': 'Papier gris pâle avec ardoise, ocre et rose',
|
||||
'studio.theme.descr.alpine': 'Blanc neige avec vert forêt, rouille et bleu ciel',
|
||||
'studio.theme.descr.indiaStamp': 'Papier chaud avec magenta, safran et bleu canard',
|
||||
'studio.theme.descr.riso': 'Papier crème, bleu riso et rose fluo en surimpression',
|
||||
'studio.theme.descr.magazine': 'Papier blanc cassé, encre noire, un rouge et un grand titre',
|
||||
'studio.theme.descr.retroTravel': 'Papier crème vieilli, orange, bleu canard et moutarde',
|
||||
'studio.theme.descr.midnight': 'Papier presque noir, texte blanc chaud et touches néon',
|
||||
'studio.theme.descr.fieldNotes': 'Papier kraft, vert mousse et rouille, écriture et ruban adhésif',
|
||||
'studio.theme.panel': 'Thème',
|
||||
'studio.theme.intro':
|
||||
'Choisissez un style pour tout le livre. Les couleurs et le texte qui suivent le thème changent avec lui, tout le reste reste tel quel.',
|
||||
|
||||
@@ -594,8 +594,8 @@ const journey: TranslationStrings = {
|
||||
'journey.studio.folioStart': 'Ξεκινά από',
|
||||
'journey.studio.folioMargin': 'Περιθώριο',
|
||||
'journey.studio.relayoutSpread': 'Αυτή η σελίδα',
|
||||
'journey.studio.relayoutSpreadHint': 'Ξαναχτίστε την από την καταχώριση',
|
||||
'journey.studio.relayoutSpreadNone': 'Αυτή η σελίδα δεν προήλθε από καταχώριση',
|
||||
'journey.studio.relayoutSpreadHint': 'Τοποθέτησέ την σε άλλο σχέδιο',
|
||||
'journey.studio.relayoutSpreadNone': 'Αυτή η σελίδα δεν φτιάχτηκε από την αυτόματη διάταξη',
|
||||
'journey.studio.relayoutBook': 'Όλο το βιβλίο',
|
||||
'journey.studio.relayoutBookHint': 'Αντικαθιστά κάθε σελίδα — αναιρέσιμο',
|
||||
'journey.studio.tpl.coverFull': 'Ολοσέλιδο',
|
||||
|
||||
@@ -13,6 +13,11 @@ const studioTemplates: TranslationStrings = {
|
||||
'studio.templates.archetype.grid-badge': 'Πλέγμα και σήμα',
|
||||
'studio.templates.archetype.minimal-serif': 'Μινιμαλιστικό',
|
||||
'studio.templates.archetype.stamp-collage': 'Γραμματόσημα',
|
||||
'studio.templates.archetype.riso': 'Ρίζο',
|
||||
'studio.templates.archetype.editorial': 'Περιοδικό',
|
||||
'studio.templates.archetype.vintage-poster': 'Ρετρό αφίσα',
|
||||
'studio.templates.archetype.night-film': 'Νυχτερινό φιλμ',
|
||||
'studio.templates.archetype.field-notes': 'Σημειώσεις πεδίου',
|
||||
'studio.templates.name.spineBandStitched': 'Ραμμένη λωρίδα',
|
||||
'studio.templates.name.spineBandGingham': 'Καρό λωρίδα',
|
||||
'studio.templates.name.spineBandScript': 'Λωρίδα και καλλιγραφικά',
|
||||
@@ -50,6 +55,77 @@ const studioTemplates: TranslationStrings = {
|
||||
'studio.templates.name.gridFeature': 'Πλέγμα με κεντρική φωτογραφία',
|
||||
'studio.templates.name.gridColumns': 'Πλέγμα σε στήλες',
|
||||
'studio.templates.name.gridMosaic': 'Πλέγμα μωσαϊκό',
|
||||
'studio.templates.name.spineBandPlate': 'Επικολλημένη εικόνα',
|
||||
'studio.templates.name.posterPopSunDisc': 'Ηλιακός δίσκος',
|
||||
'studio.templates.name.cutoutCircle': 'Κομμένος κύκλος',
|
||||
'studio.templates.name.postcardGreetings': 'Καρτ ποστάλ χαιρετισμών',
|
||||
'studio.templates.name.gridBadgeTiles': 'Πλέγμα σημάτων',
|
||||
'studio.templates.name.minimalSerifCameo': 'Οβάλ καμέα',
|
||||
'studio.templates.name.stampCollageAirmail': 'Φάκελος αεροπορικού',
|
||||
'studio.templates.name.risoOverprint': 'Αφίσα με υπερτύπωση',
|
||||
'studio.templates.name.risoHalftoneSun': 'Ράστερ ήλιος',
|
||||
'studio.templates.name.risoZineStack': 'Στοίβα ζιν',
|
||||
'studio.templates.name.risoPinkPress': 'Ροζ τύπωμα',
|
||||
'studio.templates.name.editorialMasthead': 'Εξώφυλλο με τίτλο εντύπου',
|
||||
'studio.templates.name.editorialCoverStory': 'Θέμα εξωφύλλου',
|
||||
'studio.templates.name.editorialSplit': 'Φωτογραφία πάνω από τίτλο',
|
||||
'studio.templates.name.editorialHeadline': 'Εξώφυλλο με πρωτοσέλιδο',
|
||||
'studio.templates.name.vintagePosterPanorama': 'Πανόραμα αφίσας',
|
||||
'studio.templates.name.vintagePosterArch': 'Αψίδα και θάλασσα',
|
||||
'studio.templates.name.vintagePosterSunDisc': 'Ήλιος πάνω από λόφους',
|
||||
'studio.templates.name.vintagePosterPeaks': 'Κλιμακωτές κορυφές',
|
||||
'studio.templates.name.nightFilmLetterbox': 'Ευρυοθονικό εξώφυλλο',
|
||||
'studio.templates.name.nightFilmStrip': 'Εξώφυλλο με φιλμ',
|
||||
'studio.templates.name.nightFilmTitleCard': 'Κάρτα τίτλων',
|
||||
'studio.templates.name.nightFilmFadeOut': 'Σβήσιμο στο μαύρο',
|
||||
'studio.templates.name.fieldNotesTorn': 'Σκισμένο φύλλο σημειωματάριου',
|
||||
'studio.templates.name.fieldNotesPolaroid': 'Πολαρόιντ με ταινία',
|
||||
'studio.templates.name.fieldNotesSpecimens': 'Δείγματα με ταινία',
|
||||
'studio.templates.name.fieldNotesLabel': 'Ετικέτα σημειωματάριου',
|
||||
'studio.templates.name.spineBandClothStory': 'Ιστορία σε ύφασμα',
|
||||
'studio.templates.name.spineBandGinghamTrio': 'Τριάδα με καρό ράχη',
|
||||
'studio.templates.name.spineBandAlbum': 'Δεμένο άλμπουμ',
|
||||
'studio.templates.name.posterPopHeadline': 'Τίτλος αφίσας',
|
||||
'studio.templates.name.posterPopPostcards': 'Γερτές φωτογραφίες',
|
||||
'studio.templates.name.posterPopPhotoWall': 'Τοίχος φωτογραφιών',
|
||||
'studio.templates.name.cutoutStoryPage': 'Κομμένη ιστορία',
|
||||
'studio.templates.name.cutoutTrio': 'Κομμένη τριάδα',
|
||||
'studio.templates.name.cutoutScatter': 'Σκόρπιες φωτογραφίες',
|
||||
'studio.templates.name.cutoutMosaic': 'Κομμένο ψηφιδωτό',
|
||||
'studio.templates.name.postcardAirmail': 'Αεροπορική επιστολή',
|
||||
'studio.templates.name.postcardRack': 'Σταντ καρτ ποστάλ',
|
||||
'studio.templates.name.gridBadgeLetter': 'Σφραγίδα και ιστορία',
|
||||
'studio.templates.name.gridBadgeHeroSix': 'Κεντρική και έξι',
|
||||
'studio.templates.name.minimalSerifEssay': 'Ήσυχο δοκίμιο',
|
||||
'studio.templates.name.minimalSerifTriptych': 'Τρεις εικόνες',
|
||||
'studio.templates.name.stampCollageLetter': 'Σφραγισμένη επιστολή',
|
||||
'studio.templates.name.stampCollageArchStamps': 'Αψίδα και γραμματόσημα',
|
||||
'studio.templates.name.stampCollageSheet': 'Φύλλο γραμματοσήμων',
|
||||
'studio.templates.name.risoStoryPlate': 'Ιστορία και εικόνα',
|
||||
'studio.templates.name.risoOffsetPair': 'Μετατοπισμένο ζεύγος',
|
||||
'studio.templates.name.risoBlobTrio': 'Τριάδα κηλίδων',
|
||||
'studio.templates.name.risoZineGrid': 'Πλέγμα ζιν',
|
||||
'studio.templates.name.risoPrintScatter': 'Σκόρπια τυπώματα',
|
||||
'studio.templates.name.editorialFeatureOpener': 'Άνοιγμα ρεπορτάζ',
|
||||
'studio.templates.name.editorialPullQuote': 'Αναδεδειγμένο απόσπασμα',
|
||||
'studio.templates.name.editorialFeatureColumns': 'Ρεπορτάζ σε στήλες',
|
||||
'studio.templates.name.editorialPortfolio': 'Δισέλιδο πορτφόλιο',
|
||||
'studio.templates.name.editorialContactSheet': 'Φύλλο επαφής',
|
||||
'studio.templates.name.vintagePosterStory': 'Αφίσα και ιστορία',
|
||||
'studio.templates.name.vintagePosterTwin': 'Δίδυμες αφίσες',
|
||||
'studio.templates.name.vintagePosterArcade': 'Στοά αψίδων',
|
||||
'studio.templates.name.vintagePosterPanels': 'Πάνελ και λόφοι',
|
||||
'studio.templates.name.vintagePosterLabels': 'Ετικέτες αποσκευών',
|
||||
'studio.templates.name.nightFilmOpening': 'Εναρκτήρια σκηνή',
|
||||
'studio.templates.name.nightFilmSubtitles': 'Υπότιτλοι ταινίας',
|
||||
'studio.templates.name.nightFilmReels': 'Σβήσιμο και μπομπίνες',
|
||||
'studio.templates.name.nightFilmContact': 'Φύλλο επαφής φιλμ',
|
||||
'studio.templates.name.nightFilmDoubleReel': 'Διπλή μπομπίνα',
|
||||
'studio.templates.name.fieldNotesDiary': 'Σελίδα ημερολογίου',
|
||||
'studio.templates.name.fieldNotesTrail': 'Μονοπάτι καρτών',
|
||||
'studio.templates.name.fieldNotesRoute': 'Διαδρομή και πολαρόιντ',
|
||||
'studio.templates.name.fieldNotesContact': 'Αριθμημένο φύλλο επαφής',
|
||||
'studio.templates.name.fieldNotesHerbarium': 'Φύλλα φυτολογίου',
|
||||
};
|
||||
|
||||
export default studioTemplates;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user