diff --git a/docs/superpowers/specs/2026-10-04-seating-stationery-design.md b/docs/superpowers/specs/2026-10-04-seating-stationery-design.md new file mode 100644 index 00000000..2a3ce1e2 --- /dev/null +++ b/docs/superpowers/specs/2026-10-04-seating-stationery-design.md @@ -0,0 +1,194 @@ +# Place cards — the seating stationery suite: live from the room, table-sized to room-sized + +Date: 2026-10-04 +Status: direction approved by the maintainer (answers recorded below). Nothing +is built with this spec; each phase is built and reviewed on its own. +Scope: keep Place cards token-driven, and extend it from guest-sized cards to +table cards and room-sized seating boards that stay in step with Seating. + +## Why + +Place cards prints one design, from a copy of the room taken when someone +pressed a button, on A4 or Letter. A wedding needs place cards, escort cards, +table cards and a seating board at once, all saying the same thing as the room +says now. + +- **Traced** — every caller read; not run. + +| # | Finding | How established | +|---|---|---| +| F1 | The room reaches the cards by copy. *Use the room* calls `setCsv(rowsFromRoom())` (`apps/plaque/ui/panels/DataPanel.tsx:80`), which writes the rows into `stationery.rows`. A guest moved in Seating afterwards keeps the old table on their card until the button is pressed again. | Traced | +| F2 | The room's columns are First Name, Last Name, Name, Table, Dietary, Side (`apps/plaque/state/fromRoom.ts:24`). No seat. | Traced | +| F3 | Seat order is stored: `table.assignedGuestIds[i]` is seat *i* (`apps/tableaux/components/canvas/TableNode.tsx:479`). Whether a table numbers its seats is `table.seatMode === "seat"`, and the guest link already derives `seat = index + 1` from it (`lib/share/snapshot.ts:63-69`). | Traced | +| F4 | Paper is A4 or Letter only (`apps/plaque/core/units.ts:15`), and a card preset may not exceed the largest page (`core/data/cardPresets.ts:52`). Nothing room-sized can be made. | Traced | +| F5 | The `stationery` slice holds exactly one `Design` (`apps/plaque/state/design.ts`, `sliceBridge.ts`). Changing from place cards to table numbers replaces the place cards. | Traced | +| F6 | Seating prints separately: a to-scale floor-plan PDF and two fixed card layouts in jsPDF (`apps/tableaux/utils/exportPdf.ts`, `cardTemplates.ts`, `components/layout/PrintModal.tsx`). It shares no fonts, tokens or design with Place cards, so the two can disagree. | Traced | +| F7 | Rows already have stable ids, and per-row overrides hang off the id (`core/data/artefacts.ts`, `rowId`). Using guest ids as row ids lets a hand-tweaked card follow its guest through a move. | Traced | +| F8 | The guest link carries its decryption key in the fragment (`lib/share/guestLink.ts:23`), but what it decrypts is an allow-list of name, table and seat (`lib/share/snapshot.ts`) — the same facts a printed board shows. A QR code of it on a public board discloses nothing the board does not. | Traced | +| F10 | Found while building phase 2: there is no CSV import left in Place cards. `setCsv` is only ever called with the room's rows (`DataPanel.tsx`: "There is no file import"). So a piece has no "file" source to keep, and the spec's `source` field is dropped: every piece prints from the room. | Traced (every `setCsv` caller) | +| F11 | A seat is stored twice, on the table and on the guest, and the tables win: `lib/seating/normalise.ts` rebuilds the guest side from `assignedGuestIds` on load, and the validator refuses a commit where they disagree. Seat lookups read the tables. | Traced | +| F9 | `document` scope makes exactly one artefact, so a list longer than one page cannot spill onto a second (`core/data/artefacts.ts`, the "ponytail" note). The alphabetical finder needs that spill. | Traced | + +## Decisions + +The maintainer's answers, 2026-10-04. + +| # | Question | Decision | +|---|---|---| +| 1 | Which boards | **All four:** table-list board, floor-plan map, alphabetical finder, per-table card. | +| 2 | Large output | **Both:** a full-size single-page PDF for a print shop, and the same tiled across A4/Letter with overlap marks. | +| 3 | Room link | **Always live.** No *Use the room* button; rows are read from the room. Overrides keyed by guest id. | +| 4 | Several pieces | **A stationery suite:** several named pieces in the slice, shared fonts, colours and images. Existing design migrated in. | +| 5 | Seating's own print | **Retired.** Seating's Print opens Place cards on the matching piece. Its geometry is reused, not duplicated. | +| 6 | Seat numbers | **Per table, as stored**, and only where the table's `seatMode` is `"seat"`. One derivation shared with the guest link. | +| 7 | Floor-plan styling | **Styled by properties** on one `room` element, which fits itself to its box. | +| 8 | Delivery | **Phased; one commit per phase, with tests; reviewed between phases.** | +| 9 | After a print, the room changes | **Reprint just the changed:** what changed, per piece, and a PDF of only those cards. Boards reprint whole. | +| 10 | Finder order | **Surname, with letter headings;** sort is a setting. | +| 11 | Starters | **All four:** escort card with seat, table-list board, finder and floor plan, per-table card. | +| 12 | Out of scope | **Nothing.** QR codes are in. | + +## The model + +### Tokens + +The room's columns grow; they are still just columns, so the template binding, +overrides and both renderers learn nothing new. + +| Token | Value | +|---|---| +| `{{Seat}}` | `"7"`, or empty where the table's `seatMode` is not `"seat"` | +| `{{Table Number}}` | Table position by label order, `"4"` (so a named table can still carry a number) | +| `{{Table Size}}` | Seated count at the guest's table | +| `{{Initial}}` | First letter of the surname, upper-case — what the finder groups by | +| `{{Guest Link}}` | The guest-link URL, empty when none is published — what a `qr` element binds to | + +The seat lookup moves out of `lib/share/snapshot.ts` into `lib/model` as one +function both callers use. + +### Pieces + +```ts +interface Stationery { + pieces: Record; + order: PieceId[]; + activePieceId: PieceId; + /** Shared across pieces — a font uploaded once is everywhere. */ + uploadedIcons, assetNames +} +interface Piece { + id, name; + card: CardSpec; sheet: SheetSpec; template: Template; + /** Guests printed together on one card, by guest id. */ + merged: Record; + printed: PrintRecord | null; // decision 9 +} +``` + +A piece stores no rows (F10). Rows are derived from the document through the +per-document cache in `lib/model/slices.ts` (selectors must not allocate), +keyed by guest id, so per-guest tweaks and combined cards follow their people. +A save from before pieces is migrated: its stored rows go, and its tweaks and +combines are re-keyed from positions to guests by name, saying what could not +be matched. + +A stored single design migrates to one piece called "Place cards", with its +current source (`fileName === "the room"` → `room`). The migration is a +`persist.ts` version bump with a test against a stored v-current fixture. + +### Paper + +As built (phase 3). A board is a card the size of the board — A3 to A0, 50 × 70 +cm, 18 × 24 in and 24 × 36 in are card presets, and the card-size cap is A0 — +rather than a page size, so no custom page dimensions are stored: + +- `A3` joins A4 and Letter as paper to impose on. +- `FIT` — "the card's own size" — makes the page the card plus its margins: + one board per page at full size, with bleed and crop marks. That is the + **print-shop** PDF. Home-printer warnings do not apply to it. +- **Tile** cuts each full-size sheet into A4, Letter or A3 (`sheet.tilePaper`) + with a 10 mm margin and a 10 mm overlap: trim lines on the inner edges, + landing lines where the next tile lays, and the tile's name in the slug + strip. A step after imposition (`core/imposition/tile.ts`), so neither + renderer changed. + +### New elements + +- **`grid`** — repeats a sub-template once per group (by default, per table), + flowing into columns inside its box: table-list boards, per-table cards' + guest list. Shrink-to-fit applies to the whole grid, so one long table cannot + leave the others unreadable. +- **Finder** (as built, phase 5) — the grid's second layout, `columns`: blocks + per `{{Initial}}` flow down newspaper columns at the size asked for, lines + ordered by `sortBy` (surname). A list that fits one page is balanced across + its columns; a longer one is cut into one artefact per page before + imposition (`core/data/parts.ts`, `artefactsOf`), so preview, counts, + warnings and export see pages as artefacts and needed no change. This + retires the F9 limitation. +- **`room`** (as built, phase 6) — the floor plan from Seating's own geometry + pass (`layoutFloorPlan`, now exported), handed to core as a `RoomScene` + provider the way fonts and images are. It resolves into filled paths + (tables, chairs), lines (walls) and text (labels, names), so both renderers + draw it with no new code. Properties: show the whole room or just this + card's table (by `{{Table}}`), seat labels (first name / whole name / seat + number / none), font, largest name size, colours for names, tables, chairs + and walls, table labels and walls on or off. Names hang off each chair, + away from the table, as wide as the gap to the next chair, and stay upright + however a table is turned. Zones and doors are not drawn. +- **`qr`** — encodes a token (default `{{Guest Link}}`) as vector modules, so + it prints sharp at any size. + +### Reprints + +As built (phase 7). Each piece's `printed` holds when it was last exported +and a fingerprint per artefact of **what the design reads**: the columns it +binds (`columnsUsed`) and the part of the room a plan on it draws. Only what +the design reads, because renaming one table renumbers every table: a +whole-row fingerprint flagged 97 name cards for one renamed table, in the +browser, where 8 had changed. A notice names the changed cards and offers +*Print just these*; a reprint adds to the record, a full run replaces it. + +### QR codes + +`qr` elements encode any template, `{{Guest Link}}` by default — a room column +filled from the published guest link (empty, with a warning that says how to +publish one, otherwise). Encoded by `qrcode-generator` (MIT, no dependencies) +from UTF-8 bytes, drawn as one vector path through the icon pipeline. Proved +by decoding real renders with an independent decoder, accents and CJK +included. + +## Phases + +Each phase is one commit, with tests, reviewed before the next. + +1. **Suite.** Pieces in the stationery slice, piece switcher, migration of the + single design. No behaviour change otherwise. +2. **Live room.** `source: room`, derived rows, guest-id row ids, the new + tokens and the shared seat lookup. *Use the room* removed. +3. **Big paper.** New page sizes, print-shop export and tiling. +4. **Table boards.** The `grid` element; table-list board and per-table card + starters. +5. **Finder.** List spill, letter headings, surname sort; finder starter. +6. **Floor plan.** The `room` element, reusing Seating's geometry; floor-plan + starter and per-table mini map. +7. **QR, reprints, escort starter.** The `qr` element, `printed` records and + *Export only these*; the escort-card-with-seat starter. +8. **Retire Seating's print.** Its Print button opens Place cards on the right + piece; `exportPdf.ts` card paths and `cardTemplates.ts` removed, floor-plan + geometry kept where the `room` element uses it. + *As built:* Print offers floor plan, board, finder, place, escort and + table cards, each a link to `/place-cards?piece=`, which opens that + piece or makes it from the starter of the same id. **Still a second + renderer:** the wedding PDF pack draws its one-page plan with Seating's + `buildFloorPlanPdf`; moving the pack onto a Place cards piece is the + follow-up that would make it one way. + +## Open questions, to settle at the start of their phase + +- ~~Phase 4: what a grid does with an unseated guest~~ — settled: left off + the board, with a warning counting them. A board at the door is public, and + a "Still to seat" block is a job list, not signage. +- ~~Phase 6: part of the room~~ — settled for now: the whole room, or one + table. A part (one marquee of two) is a later addition. +- Phase 3: CMYK or print-shop colour profiles are **not** planned; the PDF is + RGB, as now. Say so if a print shop needs otherwise. diff --git a/docs/superpowers/specs/2026-10-05-seat-picker-design.md b/docs/superpowers/specs/2026-10-05-seat-picker-design.md new file mode 100644 index 00000000..733f4a0b --- /dev/null +++ b/docs/superpowers/specs/2026-10-05-seat-picker-design.md @@ -0,0 +1,145 @@ +# Place cards — seat by seat: tokens you pick on a map, not type + +Date: 2026-10-05 +Status: built, all four phases (as-built notes at the end). +Builds on: `2026-10-04-seating-stationery-design.md` (all eight phases built). + +## Why + +Every token today means *this card's own guest*: `{{Name}}`, `{{Table}}`, +`{{Seat}}`, `{{Place}}`. Nothing lets a design point at a chair — "whoever sits +in seat 3 of this table", or "Table 1, seat 1" — and nothing lets anyone choose +a seat by clicking it rather than typing it. + +| # | Finding | How established | +|---|---|---| +| S1 | The `room` element already lays names round a table from Seating's real geometry, upright and outside each chair, and its `show: "table"` mode is a table card's map. Table-wise auto-layout is that, with styling, not a new mechanism. | Traced (`core/template/room.ts`) | +| S2 | Chair order is `assignedGuestIds`; on a free-seating table (`seatMode: "table"`) the order exists but the seats are not real. | Traced (`lib/model/seats.ts`) | +| S4 | Found building phase 1: new tables are free-seating by default (`defaultSeatMode \|\| 'table'`), every example table is, and Seating draws a free-seating table's guests as a grid *inside* the table (`TableNode.tsx`, `tableGrid.ts`), never at chairs. Decision 8 is that convention; the floor plan, which named every chair, contradicted it. | Traced | +| S3 | Tokens resolve from a row; a chair's guest is not a column of the card's own row, so chair tokens need the room, as the `room` element gets it (`ResolveOptions.room`). | Traced | + +## Decisions + +| # | Question | Decision | +|---|---|---| +| 1 | What it is for | **A table card diagram** (one design, every table fills in its own chairs), **an illustrated board** (names placed by hand over artwork), and **restyling the plan** (the floor plan's names as editable boxes). | +| 2 | Tables of different shapes | **Auto-lay per table:** style one chair label; positions come from each table's real shape. | +| 3 | A pinned box, when people move | **Pinned to the chair:** it shows whoever sits there now. | +| 4 | Where picking happens | **A mini map in the inspector** (click a chair to insert its token) and **stamp all chairs** (an editable, bound box at every chair at once). | +| 5 | When the room changes under stamped boxes | **Depends on the mode:** restyle-the-plan boxes follow their chair; illustrated-board boxes stay where they were put. Chairs with no box are listed, one click to add. | +| 6 | What a chair label shows | ~~The full name~~ — revised the same day: **a name format the user sets**, once per design, built from the guest's own tokens (decision 9). The full name is its default, wrapping to two lines. | +| 7 | How auto-laid names sit | **Upright, outside the chair**, as the floor plan does now. | +| 8 | Free-seating tables | **No names at chairs:** the table's guests are listed ~~beside it~~ **inside it**, as Seating shows them (S4) — inside also keeps a whole-room plan clear of neighbouring tables — so a card never implies a seat that is not real. Names spread across as many columns as make them largest; the table's label moves above it. | +| 9 | Customising how names appear | **The format**, one per design, from tokens: `{{First Name}}`, `{{First Name}} {{Initial}}.`, `{{Last Name}}, {{First Name}}`. Everyone on that design follows it. | +| 10 | A guest's own name ("Grandma") | **Not in this build.** If added, it lives **on the guest** — a "name on cards" field used by every piece and the guest link, and a token of its own — not per piece. | + +## The model + +### The name format + +Each design has one: `template.chairName`, a token pattern evaluated against +the room row of whoever is in a chair — so anything a card can say about its +own guest, a chair can say about its sitter: `{{First Name}}`, `{{Name}}`, +`{{First Name}} {{Initial}}.`, even `{{Name}} · {{Dietary}}`. Default +`{{First Name}} {{Last Name}}`. Edited once, in the inspector, with a live +preview of a few real guests; every chair label on the design's plans and +every chair token follows it. It replaces the `room` element's fixed +`seatLabels` choice: "seat number" is the pattern `{{Seat}}`, "nothing" an +empty one. + +### Chair tokens + +Two forms, both resolving to whoever is in that chair now, through the +design's name format, and empty for an empty chair: + +| Form | Means | Chip shows | +|---|---|---| +| `{{At seat 3}}` | Seat 3 of *this card's* table — table-wise | `Seat 3` | +| `{{Table 1, seat 3}}` | That one chair in the room — room-wise | `Table 1 · 3` | + +Distinct from the existing `{{Seat}}` (this guest's own seat number). Resolved +in `bindings` from the room scene, not the row: the card's table comes from its +`Table`. On a free-seating table they resolve empty, with a warning naming the +table (decision 8). + +### Table-wise: the chair label + +The `room` element's `show: "table"` gains the chair-label styling the +decisions ask for — font, size, colour, how far out from the chair — and +lists a free-seating table's guests beside it instead of at chairs. One design, +every table's card right, whatever its shape (decisions 1, 2, 7, 8). + +### Room-wise and restyle: stamped boxes + +**Stamp all chairs** turns a `room` element into one ordinary text box per +chair, each bound with a chair token and placed where the plan put the name. +Each box carries where it came from: + +```ts +chair?: { table: string | null; seat: number; follow: boolean; dx: Mm; dy: Mm } +``` + +`table: null` is table-wise (`{{At seat n}}`). `follow: true` (restyle the +plan) re-places the box at its chair each time it resolves, keeping the user's +nudge as `dx, dy`; `follow: false` (illustrated board) leaves it where it was +put (decision 5). Chairs the room has and no box covers are listed in the +inspector, one click each to stamp. + +### The mini map + +In the inspector, beside any text field: the card's own table (table-wise) or +the whole room (room-wise), drawn from the same scene. Clicking a chair inserts +its token at the cursor. Chairs already used on the card are marked. + +## Phases + +1. The name format, replacing `seatLabels`, and chair tokens resolved through + it from the room, with free-seating warnings. +2. Chair-label styling and the free-seating guest list on the table map. +3. The inspector's mini map. +4. Stamp all chairs, follow or stay, and the uncovered-chairs list. + +## As built (phase 4) + +- The link is `chair: { from, table, seat, follow, dx, dy }`: `from` is the + plan the box came from, so a design with two plans keeps their boxes apart. +- `placeChairs` (core/template/room.ts) moves every following box to its + chair's cell plus its nudge. `resolveCard`, the editable canvas and the + inspector all go through it, so what is dragged on screen is what prints. + Dragging or typing a position on a following box records the move as + `dx, dy` against the preview card's cell. +- On a table's own map (`show: "table"`), boxes always follow: each table + is shaped differently, so a fixed position would be wrong on every other + table's card. +- Stamping switches the plan's new **Names at chairs** off, so no name prints + twice. Free-seating tables keep listing their guests inside the table. +- Removing the plan turns its following boxes into ones that stay, left where + they were drawn. +- Stamped boxes start at the one size the plan drew its chair names at + (`chairNameSize`, shared with the plan's own drawing), not each at the + largest it could be. Found in the browser: sized one by one, short names + filled their cells and ran into each other ("HelenDavidLucia") along a long + table, while "Rafferty" shrank. +- Chairs the plan names with no box yet are listed by table and seat, with a + chip per chair and "Add all n". New boxes follow if the existing ones do. + +## Review (2026-10-05) + +A ground-up review of the whole stationery and seat-picker build: three +reviewers over the core pipeline, the state and the UI, each finding proven by +a reproducing test before it was fixed, and a scripted pass in the browser. + +- `{{Known As}}` is the guest's own name, else the design's name format, read + through one `asKnown` by cards, page cuts and reprint checks. Greeting + starters use it; the finder keeps "Surname, First" for looking up. +- An empty value takes the punctuation that only separated it, once a value has + been said; otherwise the punctuation after it; and its brackets. +- A finder letter carried onto a new page is headed again, so each page plans + as the whole list did. Before, a guest could be dropped at a page break. +- Undo and redo keep what was printed (`lib/store/unhistoried.ts`); a print is + recorded on the piece it came from; printing the few resets to the whole run. +- Stamped boxes are placed and nudged by centre; whole-room plans are drawn + once per room and cached (225 ms to under 1 ms a card at 300 guests). +- The chair picker falls back to the room when a card has no table of its own, + keeps focus on the map (one tab stop, arrow keys), has a visible focus ring, + larger hit targets, and brings one table up close. diff --git a/package-lock.json b/package-lock.json index 90088faf..27d55ed8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -326,6 +326,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -3780,19 +3781,6 @@ "undici-types": "~8.9.0" } }, - "node_modules/@types/pako": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", - "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", - "license": "MIT" - }, - "node_modules/@types/raf": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", - "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", - "license": "MIT", - "optional": true - }, "node_modules/@types/react": { "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", @@ -3813,13 +3801,6 @@ "@types/react": "^19.3.0" } }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true - }, "node_modules/@typescript/typescript-aix-ppc64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", @@ -4631,16 +4612,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/base64-arraybuffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", - "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -4764,26 +4735,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/canvg": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", - "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", - "license": "MIT", - "optional": true, - "dependencies": { - "@babel/runtime": "^7.12.5", - "@types/raf": "^3.4.0", - "core-js": "^3.8.3", - "raf": "^3.4.1", - "regenerator-runtime": "^0.13.7", - "rgbcolor": "^1.0.1", - "stackblur-canvas": "^2.0.0", - "svg-pathdata": "^6.0.3" - }, - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/chai": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", @@ -4866,31 +4817,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/core-js": { - "version": "3.50.0", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", - "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "engines": { - "node": "*" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, - "node_modules/css-line-break": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", - "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", - "license": "MIT", - "optional": true, - "dependencies": { - "utrie": "^1.0.2" - } - }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -4912,18 +4838,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -5025,16 +4939,6 @@ "license": "MIT", "peer": true }, - "node_modules/dompurify": { - "version": "3.4.16", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", - "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optional": true, - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -5150,33 +5054,6 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, - "node_modules/fast-png": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", - "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", - "license": "MIT", - "dependencies": { - "@types/pako": "^2.0.3", - "iobuffer": "^5.3.2", - "pako": "^2.1.0" - } - }, - "node_modules/fast-png/node_modules/pako": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", - "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "(MIT AND Zlib)" - }, "node_modules/fast-uri": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", @@ -5211,18 +5088,6 @@ } } }, - "node_modules/fflate": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", - "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", - "license": "MIT" - }, - "node_modules/font-family-papandreou": { - "version": "0.2.0-patch2", - "resolved": "https://registry.npmjs.org/font-family-papandreou/-/font-family-papandreou-0.2.0-patch2.tgz", - "integrity": "sha512-l/YiRdBSH/eWv6OF3sLGkwErL+n0MqCICi9mppTZBOCL5vixWGDqCYvRcuxB2h7RGCTzaTKOHT2caHvCXQPRlw==", - "license": "MIT" - }, "node_modules/fontkit": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", @@ -5355,20 +5220,6 @@ "dev": true, "license": "MIT" }, - "node_modules/html2canvas": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", - "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", - "license": "MIT", - "optional": true, - "dependencies": { - "css-line-break": "^2.1.0", - "text-segmentation": "^1.0.3" - }, - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/iceberg-js": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", @@ -5414,12 +5265,6 @@ "node": ">=8" } }, - "node_modules/iobuffer": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", - "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", - "license": "MIT" - }, "node_modules/is-plain-object": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz", @@ -5565,23 +5410,6 @@ "node": ">=6" } }, - "node_modules/jspdf": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", - "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.6", - "fast-png": "^6.2.0", - "fflate": "^0.8.1" - }, - "optionalDependencies": { - "canvg": "^3.0.11", - "core-js": "^3.6.0", - "dompurify": "^3.3.1", - "html2canvas": "^1.0.0-rc.5" - } - }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -6304,13 +6132,6 @@ "@napi-rs/canvas": "^1.0.0" } }, - "node_modules/performance-now": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", - "license": "MIT", - "optional": true - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6499,22 +6320,18 @@ "node": ">=6" } }, + "node_modules/qrcode-generator": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz", + "integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==", + "license": "MIT" + }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, - "node_modules/raf": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", - "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", - "license": "MIT", - "optional": true, - "dependencies": { - "performance-now": "^2.1.0" - } - }, "node_modules/react": { "version": "19.3.0", "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", @@ -6567,13 +6384,6 @@ "node": ">=8" } }, - "node_modules/regenerator-runtime": { - "version": "0.13.11", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "license": "MIT", - "optional": true - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -6602,16 +6412,6 @@ "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", "license": "MIT" }, - "node_modules/rgbcolor": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", - "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", - "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", - "optional": true, - "engines": { - "node": ">= 0.8.15" - } - }, "node_modules/rolldown": { "version": "1.2.12", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", @@ -6857,15 +6657,6 @@ "source-map": "^0.6.0" } }, - "node_modules/specificity": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/specificity/-/specificity-0.4.1.tgz", - "integrity": "sha512-1klA3Gi5PD1Wv9Q0wUoOQN1IWAuPu0D1U03ThXTr0cJ20+/iq2tHSDnK7Kk/0LXJ1ztUB2/1Os0wKmfyNgUQfg==", - "license": "MIT", - "bin": { - "specificity": "bin/specificity" - } - }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -6875,16 +6666,6 @@ "node": ">= 10.x" } }, - "node_modules/stackblur-canvas": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", - "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.1.14" - } - }, "node_modules/stacktrace-parser": { "version": "0.1.11", "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", @@ -6960,40 +6741,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/svg-pathdata": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", - "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/svg2pdf.js": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/svg2pdf.js/-/svg2pdf.js-2.8.1.tgz", - "integrity": "sha512-AzXfPHjHnFt9dpdRPhWWBOKipnMSWQZRKiV3FTTPdY6wwRgT3labbA4s0HIQaskNhwAhrcI2u2rDhD8j8udfNQ==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "font-family-papandreou": "^0.2.0-patch1", - "specificity": "^0.4.1", - "svgpath": "^2.3.0" - }, - "peerDependencies": { - "jspdf": "^4.0.0 || ^3.0.0 || ^2.0.0" - } - }, - "node_modules/svgpath": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/svgpath/-/svgpath-2.6.0.tgz", - "integrity": "sha512-OIWR6bKzXvdXYyO4DK/UWa1VA1JeKq8E+0ug2DG98Y/vOmMpfZNj+TIG988HjfYSqtcy/hFOtZq/n/j5GSESNg==", - "license": "MIT", - "funding": { - "url": "https://github.com/fontello/svg2ttf?sponsor=1" - } - }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -7033,16 +6780,6 @@ "node": ">=10" } }, - "node_modules/text-segmentation": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", - "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", - "license": "MIT", - "optional": true, - "dependencies": { - "utrie": "^1.0.2" - } - }, "node_modules/thread-stream": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", @@ -7292,16 +7029,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/utrie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", - "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", - "license": "MIT", - "optional": true, - "dependencies": { - "base64-arraybuffer": "^1.0.2" - } - }, "node_modules/vite": { "version": "8.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", @@ -8050,14 +7777,13 @@ "fontkit": "^2.0.4", "framer-motion": "^13.4.5", "idb-keyval": "^6.3.0", - "jspdf": "^4.2.1", "lucide-react": "^1.48.0", "next": "^16.3.8", "pdf-lib": "^1.17.1", "pino": "^10.3.1", + "qrcode-generator": "^2.0.4", "react": "19.3.0", "react-dom": "19.3.0", - "svg2pdf.js": "^2.8.1", "zod": "^4.6.5", "zustand": "^5.0.15" }, diff --git a/suite/apps/plaque/App.tsx b/suite/apps/plaque/App.tsx index f53f0b7d..9e390452 100644 --- a/suite/apps/plaque/App.tsx +++ b/suite/apps/plaque/App.tsx @@ -1,14 +1,15 @@ "use client"; import { useDeferredValue, useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; import { useShallow } from "zustand/react/shallow"; import styles from "./App.module.css"; -import { BUNDLED_FONTS } from "./assets/fonts"; import { validateGeometry } from "./core/geometry/validate"; import { analyseArtefacts, paginate, sheetCountFor } from "./core/imposition/paginate"; -import { buildArtefacts } from "./core/data/artefacts"; +import { artefactsOf } from "./core/data/parts"; import { hasBackSide, templateForSide } from "./core/imposition/duplex"; import { templateForRow } from "./core/template/overrides"; +import { placeChairs } from "./core/template/room"; import { PAPER_WHITE, contrastIssues } from "./core/print/contrast"; import { missingAssets } from "./core/template/assets"; import { overflowIssues } from "./core/template/overflow"; @@ -16,13 +17,12 @@ import { unboundTokens } from "./core/template/rebind"; import { makeResolveOptions } from "./core/template/resolve"; import { CardCanvas, MAX_VIEW_ZOOM } from "./render/svg/CardCanvas"; import { SheetPreview } from "./render/svg/SheetPreview"; -import { loadFonts as loadStoredFonts } from "./state/blobStore"; import { loadImages, toSource } from "./state/imageStore"; import { loadPrinters } from "./state/printerStore"; -import { loadBundledFonts, registerFont } from "./state/fontLoader"; +import { loadEveryFont } from "./state/fontLoader"; import { designOf } from "./state/design"; import { writeDesign } from "./state/sliceBridge"; -import { usePlaque } from "./state/store"; +import { canOpenPiece, usePlaque } from "./state/store"; import { useKeyboard } from "./state/useKeyboard"; import { Announcer } from "./ui/Announcer"; import { ToolUndo } from "@/components/shell/ToolUndo"; @@ -31,15 +31,15 @@ import { ExportBar } from "./ui/ExportBar"; import { MissingAssets } from "./ui/MissingAssets"; import { Pagination } from "./ui/Pagination"; import { PersistenceBar } from "./ui/PersistenceBar"; +import { PiecesBar } from "./ui/PiecesBar"; import { RowsDrawer } from "./ui/RowsDrawer"; +import { printing } from "./state/printed"; +import { SincePrinted } from "./ui/SincePrinted"; import { Sidebar } from "./ui/Sidebar"; import { WarningsList } from "./ui/WarningsList"; const PLACEHOLDER_ROW = { "": "" }; -/** Absent scope means per-row: what every design written before scope existed meant. */ -const PER_ROW = { kind: "per-row" } as const; - export function App() { const [ready, setReady] = useState(false); // Place cards keep no history of their own: every design edit is on the @@ -59,12 +59,11 @@ export function App() { rows, rowIds, headers, + room, fonts, images, uploadedIcons, assetNames, - csvIssues, - fileName, page, selectedId, cropId, @@ -75,6 +74,8 @@ export function App() { printers, activePrinterId, designProblem, + printed, + printOnly, } = usePlaque( useShallow((s) => ({ card: s.card, @@ -83,22 +84,23 @@ export function App() { rows: s.rows, rowIds: s.rowIds, headers: s.headers, + room: s.room, fonts: s.fonts, images: s.images, uploadedIcons: s.uploadedIcons, assetNames: s.assetNames, - csvIssues: s.csvIssues, - fileName: s.fileName, page: s.page, selectedId: s.selectedId, cropId: s.cropId, snapEnabled: s.snapEnabled, sheetCollapsed: s.sheetCollapsed, + printOnly: s.printOnly, previewGuestIndex: s.previewGuestIndex, editingSide: s.editingSide, printers: s.printers, activePrinterId: s.activePrinterId, designProblem: s.designProblem, + printed: s.printed, })), ); @@ -122,19 +124,7 @@ export function App() { useEffect(() => { let cancelled = false; (async () => { - const bundled = await loadBundledFonts(); - const labels: Record = {}; - for (const f of BUNDLED_FONTS) labels[f.id] = f.label; - - const stored = await loadStoredFonts(); - for (const f of stored) { - try { - bundled.set(f.id, await registerFont(f.id, f.family, f.data)); - labels[f.id] = f.family; - } catch { - // A font that no longer parses should not stop the app from opening. - } - } + const { fonts: loaded, labels, uploadedIds } = await loadEveryFont(); const storedImages = await loadImages(); const { printers, activeId } = await loadPrinters(); if (cancelled) return; @@ -146,7 +136,7 @@ export function App() { Object.fromEntries(storedImages.map((i) => [i.id, i.name])), ); - usePlaque.getState().setFonts(bundled, labels, stored.map((f) => f.id)); + usePlaque.getState().setFonts(loaded, labels, uploadedIds); setReady(true); })().catch(() => setReady(true)); return () => { @@ -154,6 +144,15 @@ export function App() { }; }, []); + // Sent here for one piece — Seating's Print does this — open it, making it + // from its design if the wedding has none yet. Asked again on a reload, it is + // only opened: the piece exists by then. + const wanted = useSearchParams().get("piece"); + useEffect(() => { + // A link to a piece since removed opens the stationery as it is. + if (ready && wanted && canOpenPiece(wanted)) usePlaque.getState().openPiece(wanted); + }, [ready, wanted]); + // Esc leaves crop mode, the way it leaves every other transient mode. useEffect(() => { if (!cropId) return; @@ -165,15 +164,15 @@ export function App() { }, [cropId, setCropId]); const resolveOptions = useMemo( - () => makeResolveOptions(fonts, uploadedIcons, images, assetNames), - [fonts, uploadedIcons, images, assetNames], + () => makeResolveOptions(fonts, uploadedIcons, images, assetNames, room), + [fonts, uploadedIcons, images, assetNames, room], ); // Rows become artefacts once, here. Everything downstream counts artefacts: // 150 guests is 150 place cards, or 19 table menus, or one run-sheet. const artefacts = useMemo( - () => buildArtefacts(rows, template.rowScope ?? PER_ROW, headers, rowIds), - [rows, template.rowScope, headers, rowIds], + () => artefactsOf(template, rows, headers, rowIds), + [template, rows, headers, rowIds], ); const previewArtefact = artefacts[previewGuestIndex] ?? artefacts[0] ?? null; @@ -187,8 +186,10 @@ export function App() { // row's own overrides applied. Editing against anything else would mean the // preview and the sheet disagree, which is the one thing Plaque must not do. const sided = hasBackSide(template) ? templateForSide(template, editingSide) : template; - return previewArtefact ? templateForRow(sided, previewArtefact.rowId) : sided; - }, [template, editingSide, previewArtefact]); + const own = previewArtefact ? templateForRow(sided, previewArtefact.rowId) : sided; + // A box that follows a chair is grabbed where it prints on this card. + return placeChairs(own, room, previewRow); + }, [template, editingSide, previewArtefact, room, previewRow]); // Row-independent, so this gates export without resolving a single card. const missing = useMemo( @@ -196,10 +197,13 @@ export function App() { [template, images, fonts], ); + // The sheets show what will print: just the chosen few, when only a few are. + const onPaper = useMemo(() => printing(artefacts, printOnly), [artefacts, printOnly]); + // How many sheets the job needs, without building any of them. const sheetCount = useMemo( - () => sheetCountFor(artefacts.length, card, sheet), - [artefacts.length, card, sheet], + () => sheetCountFor(onPaper.length, card, sheet), + [onPaper.length, card, sheet], ); const pageIndex = Math.min(page, Math.max(0, sheetCount - 1)); @@ -211,8 +215,8 @@ export function App() { if (sheetCollapsed) return undefined; const range = { from: pageIndex, to: pageIndex }; const front = templateForSide(template, "front"); - return paginate(front, artefacts, card, sheet, resolveOptions, { pages: range }).sheets[0]; - }, [template, artefacts, card, sheet, resolveOptions, pageIndex, sheetCollapsed]); + return paginate(front, onPaper, card, sheet, resolveOptions, { pages: range }).sheets[0]; + }, [template, onPaper, card, sheet, resolveOptions, pageIndex, sheetCollapsed]); // The "these names do not fit" pass has to look at every guest, so it runs at // a lower priority: it may lag a drag by a frame, but it never blocks one. @@ -275,14 +279,21 @@ export function App() {
+ {saveError && ( writeDesign(designOf(usePlaque.getState()), { silent: true })} + onRetry={() => writeDesign(designOf(usePlaque.getState()), usePlaque.getState().pieceId, { silent: true })} /> )} {designProblem &&

{designProblem}

} +
diff --git a/suite/apps/plaque/core/csv/interpolate.test.ts b/suite/apps/plaque/core/csv/interpolate.test.ts index 7f0a03b8..d50d2c56 100644 --- a/suite/apps/plaque/core/csv/interpolate.test.ts +++ b/suite/apps/plaque/core/csv/interpolate.test.ts @@ -66,6 +66,38 @@ describe("interpolate", () => { }); }); + describe("a separator belongs to the value it separates", () => { + it("goes with an empty value at the end: no dash pointing at nothing", () => { + expect(interpolate("{{Last Name}}, {{First Name}} — {{Table}}", { "First Name": "Zainab", "Last Name": "Thistlewood", Table: "" }).text).toBe("Thistlewood, Zainab"); + expect(interpolate("{{Table}} · {{Name}} · {{Dietary}}", { Table: "Table 1", Name: "Ada", Dietary: "" }).text).toBe("Table 1 · Ada"); + }); + + it("goes with an empty value at the start", () => { + expect(interpolate("{{Last Name}}, {{First Name}} — {{Table}}", { "First Name": "Prince", "Last Name": "", Table: "Table 3" }).text).toBe("Prince — Table 3"); + }); + + it("goes with an empty value in the middle, leaving one between its neighbours", () => { + expect(interpolate("{{A}}, {{B}}, {{C}}", { A: "a", B: "", C: "c" }).text).toBe("a, c"); + expect(interpolate("{{A}} — {{B}} — {{C}}", { A: "", B: "", C: "c" }).text).toBe("c"); + expect(interpolate("{{A}} — {{B}} — {{C}}", { A: "a", B: "", C: "" }).text).toBe("a"); + }); + + it("never takes a separator that belongs to a value which is there", () => { + expect(interpolate("{{First Name}} {{Last Name}} — {{Table}}", { "First Name": "Ada", "Last Name": "", Table: "Table 5" }).text).toBe("Ada — Table 5"); + expect(interpolate("{{Table}} · {{First Name}} {{Last Name}} · {{Dietary}}", { Table: "Table 1", "First Name": "Ada", "Last Name": "", Dietary: "Vegan" }).text).toBe("Table 1 · Ada · Vegan"); + }); + + it("takes its brackets with it", () => { + expect(interpolate("{{Name}} ({{Dietary}})", { Name: "Ada", Dietary: "" }).text).toBe("Ada"); + expect(interpolate("{{Name}} ({{Dietary}})", { Name: "Ada", Dietary: "Vegan" }).text).toBe("Ada (Vegan)"); + }); + + it("leaves words alone: only punctuation between values is a separator", () => { + expect(interpolate("Dear {{First Name}},", { "First Name": "" }).text).toBe("Dear,"); + expect(interpolate("{{Table}} and {{Seat}}", { Table: "Table 1", Seat: "" }).text).toBe("Table 1 and"); + }); + }); + it("reports a missing column once however often it appears", () => { expect(interpolate("{{X}} {{X}}", row).missing).toEqual(["X"]); }); diff --git a/suite/apps/plaque/core/csv/interpolate.ts b/suite/apps/plaque/core/csv/interpolate.ts index 16043ded..1147bb63 100644 --- a/suite/apps/plaque/core/csv/interpolate.ts +++ b/suite/apps/plaque/core/csv/interpolate.ts @@ -19,6 +19,14 @@ export interface Interpolated { missing: string[]; } +/** Brackets an empty value takes with it: "Ada ({{Dietary}})" is "Ada", not "Ada ()". */ +const PAIRS: Record = { "(": ")", "[": "]", "{": "}", "“": "”", "‘": "’", "«": "»", '"': '"' }; + +/** Punctuation between two values, such as " — ", ", " or " · ": it belongs to the value it introduces. */ +function isSeparator(literal: string): boolean { + return /^[\s\p{P}\p{S}]+$/u.test(literal) && /[\p{P}\p{S}]/u.test(literal); +} + /** * Fills a template from a guest row. * @@ -26,44 +34,59 @@ export interface Interpolated { * card as literal `{{Nickname}}` — a stray token printed onto a hundred cards is * far worse than a gap. The name is returned in `missing` so the UI can say so. * - * When a token comes out empty it takes ONE adjacent space with it, so - * "{{First}} {{Last}}" with no surname does not leave a trailing space that - * shifts centred text off centre. Whitespace the user typed deliberately, and - * whitespace inside a value, is left exactly as it is — silently rewriting - * someone's data is not this function's job. + * An empty value takes what was only there to separate it: the punctuation + * before it once a value has been said ("Zainab — {{Table}}" is "Zainab"), + * else the punctuation after it ("{{Last Name}}, Prince" is "Prince"), and brackets + * round it. Otherwise it takes ONE adjacent space, so "{{First}} {{Last}}" with + * no surname does not leave a trailing space that shifts centred text off + * centre. Words are never taken, and whitespace the user typed deliberately, + * or inside a value, is left exactly as it is — silently rewriting someone's + * data is not this function's job. */ export function interpolate(template: string, row: GuestRow): Interpolated { const missing: string[] = []; - let out = ""; + // The template as literal, token, literal, …, literal. + const literals: string[] = []; + const tokens: Array<{ name: string; value: string }> = []; let cursor = 0; - for (const match of template.matchAll(TOKEN)) { - const start = match.index; + literals.push(template.slice(cursor, match.index)); + cursor = match.index + match[0].length; const name = (match[1] ?? "").trim(); - out += template.slice(cursor, start); - cursor = start + match[0].length; - - if (!name) continue; + const value = name ? row[name] : ""; + if (value === undefined && !missing.includes(name)) missing.push(name); + tokens.push({ name, value: value ?? "" }); + } + literals.push(template.slice(cursor)); - const value = row[name]; - if (value === undefined) { - if (!missing.includes(name)) missing.push(name); + let out = ""; + // Whether a value has been said yet: the punctuation before an empty value is + // its own only when something came before it to separate it from. + let seen = false; + tokens.forEach(({ name, value }, i) => { + let before = literals[i]!; + if (value || !name) { + out += before + value; + seen ||= Boolean(value); + return; } - - if (value) { - out += value; - continue; + const after = literals[i + 1]!; + if (PAIRS[before.slice(-1)] !== undefined && after.startsWith(PAIRS[before.slice(-1)]!)) { + before = before.slice(0, -1); + literals[i + 1] = after.slice(1); } - - // Empty or missing: absorb one space on whichever side it had one, so the - // gap it leaves behind closes up. - if (out.endsWith(" ")) { - out = out.slice(0, -1); - } else if (template[cursor] === " ") { - cursor += 1; + if (seen && isSeparator(before)) { + return; } - } - - out += template.slice(cursor); - return { text: out, missing }; + if (!seen && i < tokens.length - 1 && isSeparator(literals[i + 1]!)) { + literals[i + 1] = ""; + out += before; + return; + } + // Absorb one space on whichever side it had one, so the gap closes up. + out += before; + if (out.endsWith(" ")) out = out.slice(0, -1); + else if (literals[i + 1]!.startsWith(" ")) literals[i + 1] = literals[i + 1]!.slice(1); + }); + return { text: out + literals[tokens.length]!, missing }; } diff --git a/suite/apps/plaque/core/data/artefacts.test.ts b/suite/apps/plaque/core/data/artefacts.test.ts index e141ff75..7511d7ac 100644 --- a/suite/apps/plaque/core/data/artefacts.test.ts +++ b/suite/apps/plaque/core/data/artefacts.test.ts @@ -60,20 +60,13 @@ describe("buildArtefacts — per group", () => { expect(out.map((a) => a.label)).toEqual(["9 (2)", "2 (1)"]); }); - it("gathers rows with no value into one named group", () => { + it("makes no card for rows with nothing in the column: guests with no table have no table card", () => { const out = buildArtefacts( - [{ Table: "" }, { Table: "1" }], + [{ Table: "" }, { Table: "1" }, { Table: " " }], { kind: "per-group", byColumn: "Table" }, ["Table"], ); - expect(out).toHaveLength(2); - expect(out[0]?.label).toBe("(blank) (1)"); - }); - - it("makes one group when the column does not exist, rather than failing", () => { - const out = buildArtefacts(rows, { kind: "per-group", byColumn: "Nope" }, headers); - expect(out).toHaveLength(1); - expect(out[0]?.rows).toHaveLength(3); + expect(out.map((a) => a.label)).toEqual(["1 (1)"]); }); }); diff --git a/suite/apps/plaque/core/data/artefacts.ts b/suite/apps/plaque/core/data/artefacts.ts index 0dda6791..c8c53c62 100644 --- a/suite/apps/plaque/core/data/artefacts.ts +++ b/suite/apps/plaque/core/data/artefacts.ts @@ -98,6 +98,9 @@ export function buildArtefacts( * the user recognises, and re-sorting silently would change which table prints * first. * + * A row with nothing in the column is in no group, as on a board: a guest with + * no table yet has no table card. The data panel says how many are left out. + * * Matching is on the trimmed, case-folded value so "Table 1" and "table 1 " do * not split a table — but the ORIGINAL value is what gets printed, because * rewriting someone's data is never this function's job (S-I.2). @@ -115,6 +118,7 @@ function groupBy( for (const [index, row] of rows.entries()) { const raw = row[column] ?? ""; const key = normalise(raw); + if (!key) continue; const existing = groups.get(key); if (existing) { existing.rows.push(row); @@ -122,7 +126,7 @@ function groupBy( existing.rowIds.push(idAt(index)); } else { groups.set(key, { - label: raw || "(blank)", + label: raw, rows: [row], rowIndexes: [index], rowIds: [idAt(index)], diff --git a/suite/apps/plaque/core/data/cardPresets.test.ts b/suite/apps/plaque/core/data/cardPresets.test.ts index 7ed421f9..c01f29e2 100644 --- a/suite/apps/plaque/core/data/cardPresets.test.ts +++ b/suite/apps/plaque/core/data/cardPresets.test.ts @@ -56,7 +56,8 @@ describe("validateCardPreset", () => { ); }); - it("rejects a card larger than any paper Plaque prints on", () => { - expect(validateCardPreset(preset({ heightMm: 400 }))).toBe("widthMm"); + it("takes a board up to A0, and rejects anything a print shop would not", () => { + expect(validateCardPreset(preset({ widthMm: 841, heightMm: 1189, foldPositionMm: 400 }))).toBeNull(); + expect(validateCardPreset(preset({ heightMm: 1200, foldPositionMm: 400 }))).toBe("widthMm"); }); }); diff --git a/suite/apps/plaque/core/data/cardPresets.ts b/suite/apps/plaque/core/data/cardPresets.ts index 8ab4ea28..b8a1ac88 100644 --- a/suite/apps/plaque/core/data/cardPresets.ts +++ b/suite/apps/plaque/core/data/cardPresets.ts @@ -1,5 +1,5 @@ import raw from "../../data/card-presets.json"; -import { PAGE_SIZES_MM } from "../units"; +import { LARGEST_CARD_MM } from "../units"; import type { CardSpec, FoldAxis } from "../types"; /** @@ -50,9 +50,8 @@ export function validateCardPreset(value: unknown): string | null { if (preset.fold !== "none" && (preset.foldPositionMm <= 0 || preset.foldPositionMm >= span)) { return "foldPositionMm"; } - // Nothing here should be bigger than the largest paper Plaque can print on. - const longest = Math.max(...Object.values(PAGE_SIZES_MM).map((s) => Math.max(s.w, s.h))); - if (Math.max(preset.widthMm, preset.heightMm) > longest) return "widthMm"; + // Nothing here should be bigger than a print shop will take. + if (Math.max(preset.widthMm, preset.heightMm) > LARGEST_CARD_MM) return "widthMm"; return null; } diff --git a/suite/apps/plaque/core/data/gallery.test.ts b/suite/apps/plaque/core/data/gallery.test.ts index 6185f5de..d08da91a 100644 --- a/suite/apps/plaque/core/data/gallery.test.ts +++ b/suite/apps/plaque/core/data/gallery.test.ts @@ -1,4 +1,5 @@ import { readFileSync } from "node:fs"; +import { ROOM_COLUMNS } from "../../state/fromRoom"; import { describe, expect, it } from "vitest"; import { BUNDLED_FONTS } from "../../assets/fonts"; import { parseCsv } from "@/lib/data/csv"; @@ -8,7 +9,7 @@ import { defaultCard, defaultSheet } from "../template/defaults"; import { makeResolveOptions } from "../template/resolve"; import { unboundTokens } from "../template/rebind"; import { loadFont, type LoadedFont } from "../text/measure"; -import { buildArtefacts } from "./artefacts"; +import { artefactsOf } from "./parts"; import { GALLERY, validateGalleryTemplate } from "./gallery"; /** @@ -23,7 +24,15 @@ const fonts = new Map( ]), ); const resolveOptions = makeResolveOptions(fonts); -const { headers, rows } = parseCsv(readFileSync("fixtures/guests-150.csv", "utf8")); +const csv = parseCsv(readFileSync("fixtures/guests-150.csv", "utf8")); +// What the room adds to every guest: the designs are written against it. +const headers = [...csv.headers, "Name", "Initial", "Place"]; +const rows = csv.rows.map((row) => ({ + ...row, + Name: `${row["First Name"]} ${row["Last Name"]}`, + Initial: (row["Last Name"] ?? "").charAt(0).toUpperCase(), + Place: row["Table"] ?? "", +})); describe("the template gallery", () => { it("ships some", () => { @@ -45,22 +54,23 @@ describe("the template gallery", () => { // A gallery template referencing an uploaded font would be broken on arrival. for (const entry of GALLERY) { for (const el of entry.template.elements) { - if (el.kind !== "text" && el.kind !== "list") continue; + if (el.kind === "grid") expect([entry.id, fonts.has(el.headingFontId)]).toEqual([entry.id, true]); + if (el.kind !== "text" && el.kind !== "list" && el.kind !== "grid" && el.kind !== "room") continue; expect([entry.id, fonts.has(el.fontId)]).toEqual([entry.id, true]); } } }); - it("binds only to columns the sample guest list actually has", () => { + it("binds only to columns the room gives every card", () => { for (const entry of GALLERY) { - expect([entry.id, unboundTokens(entry.template, headers)]).toEqual([entry.id, []]); + expect([entry.id, unboundTokens(entry.template, [...ROOM_COLUMNS])]).toEqual([entry.id, []]); } }); it("produces a geometry the validator is happy with", () => { for (const entry of GALLERY) { const card = { ...defaultCard(), ...entry.card }; - expect([entry.id, hasErrors(validateGeometry(card, defaultSheet()))]).toEqual([ + expect([entry.id, hasErrors(validateGeometry(card, { ...defaultSheet(), ...entry.sheet }))]).toEqual([ entry.id, false, ]); @@ -70,8 +80,8 @@ describe("the template gallery", () => { it("renders real cards from the fixture data", () => { for (const entry of GALLERY) { const card = { ...defaultCard(), ...entry.card }; - const artefacts = buildArtefacts(rows, entry.template.rowScope ?? { kind: "per-row" }, headers); - const { sheets } = paginate(entry.template, artefacts, card, defaultSheet(), resolveOptions); + const artefacts = artefactsOf(entry.template, rows, headers); + const { sheets } = paginate(entry.template, artefacts, card, { ...defaultSheet(), ...entry.sheet }, resolveOptions); const cards = sheets.flatMap((s) => s.cards); expect([entry.id, cards.length > 0]).toEqual([entry.id, true]); @@ -89,8 +99,8 @@ describe("the template gallery", () => { // A shipped design that overflows on the sample data is a bad example. for (const entry of GALLERY) { const card = { ...defaultCard(), ...entry.card }; - const artefacts = buildArtefacts(rows, entry.template.rowScope ?? { kind: "per-row" }, headers); - const { warnings } = paginate(entry.template, artefacts, card, defaultSheet(), resolveOptions); + const artefacts = artefactsOf(entry.template, rows, headers); + const { warnings } = paginate(entry.template, artefacts, card, { ...defaultSheet(), ...entry.sheet }, resolveOptions); expect([entry.id, warnings.filter((w) => w.kind === "overflow")]).toEqual([entry.id, []]); } }); diff --git a/suite/apps/plaque/core/data/gallery.ts b/suite/apps/plaque/core/data/gallery.ts index cfdf53d5..91cc6fd2 100644 --- a/suite/apps/plaque/core/data/gallery.ts +++ b/suite/apps/plaque/core/data/gallery.ts @@ -1,5 +1,11 @@ -import type { CardSpec, Template } from "../types"; +import { rebindTemplate } from "../template/rebind"; +import type { CardSpec, SheetSpec, Template } from "../types"; +import escortCard from "../../templates/escort-card.json"; +import finder from "../../templates/finder.json"; +import floorPlan from "../../templates/floor-plan.json"; import placeCardClassic from "../../templates/place-card-classic.json"; +import seatingBoard from "../../templates/seating-board.json"; +import tableCard from "../../templates/table-card.json"; import serviceDockets from "../../templates/service-dockets.json"; import tableMenu from "../../templates/table-menu.json"; @@ -25,16 +31,23 @@ export interface GalleryTemplate { name: string; description: string; card: Pick; + /** What a design needs of the sheet, when it is more than paper — a board printed at its own size. */ + sheet?: Partial; template: Template; } /** * Named rather than globbed. Vite inlined the whole directory at build time; - * Next has no equivalent, so the three are listed — which a bundler can check, + * Next has no equivalent, so they are listed — which a bundler can check, * where a glob cannot. */ const FILES: Record = { + "escort-card.json": escortCard, + "finder.json": finder, + "floor-plan.json": floorPlan, "place-card-classic.json": placeCardClassic, + "seating-board.json": seatingBoard, + "table-card.json": tableCard, "service-dockets.json": serviceDockets, "table-menu.json": tableMenu, }; @@ -54,6 +67,7 @@ export function validateGalleryTemplate(value: unknown): string | null { const n = card[key]; if (typeof n !== "number" || !Number.isFinite(n)) return `card.${key}`; } + if (t["sheet"] !== undefined && (typeof t["sheet"] !== "object" || t["sheet"] === null)) return "sheet"; const template = t["template"] as Record | undefined; if (!template || !Array.isArray(template["elements"])) return "template.elements"; if (template["elements"].length === 0) return "template.elements"; @@ -85,3 +99,23 @@ export const GALLERY: GalleryTemplate[] = Object.entries(FILES) return [value as GalleryTemplate]; }) .sort((a, b) => a.name.localeCompare(b.name)); + +/** The columns gallery designs are written against. */ +const SAMPLE_HEADERS = ["First Name", "Last Name", "Table", "Dietary"]; + +/** + * A gallery design over a piece's card and sheet: its sizes, and its template + * rebound to the columns the data has (S-B.1), with no per-guest tweaks. + */ +export function fromGallery( + entry: GalleryTemplate, + card: CardSpec, + sheet: SheetSpec, + headers: string[], +): { card: CardSpec; sheet: SheetSpec; template: Template } { + return { + card: { ...card, ...entry.card }, + sheet: { ...sheet, ...entry.sheet }, + template: rebindTemplate({ ...entry.template, overrides: {} }, SAMPLE_HEADERS, headers).template, + }; +} diff --git a/suite/apps/plaque/core/data/parts.test.ts b/suite/apps/plaque/core/data/parts.test.ts new file mode 100644 index 00000000..8642c3f4 --- /dev/null +++ b/suite/apps/plaque/core/data/parts.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "vitest"; +import { ptToMm } from "../units"; +import type { GridElement, Template } from "../types"; +import { flowPlan, gridBlocks } from "../template/grid"; +import { artefactsOf } from "./parts"; +import { noFit, resolveCard } from "../template/bindings"; + +/** + * A finder: everyone A to Z under their letter, in newspaper columns, carrying + * on onto another page when the list is too long for one. + */ +const finder = (over: Partial = {}): GridElement => ({ + kind: "grid", + id: "names", + x: 10, + y: 10, + w: 190, + // 10 lines of 10pt × 1.2 a column, near enough: 42.3mm. + h: ptToMm(10 * 1.2) * 10, + z: 0, + groupBy: "Initial", + headingTemplate: "{{Initial}}", + itemTemplate: "{{Last Name}}, {{First Name}}", + sortBy: "Last Name", + layout: "columns", + columns: 2, + gapMm: 0, + fontId: "crimson", + fontSizePt: 10, + headingFontId: "crimson", + // A heading as tall as a line, so the arithmetic below is in whole lines. + headingScale: 1, + headingColorHex: "#000000", + align: "left", + lineHeight: 1.2, + colorHex: "#000000", + letterSpacingMm: 0, + fit: { mode: "shrink", minFontSizePt: 6, maxLines: 1, anchor: "align" }, + ...over, +}); + +const guest = (first: string, last: string) => ({ + "First Name": first, + "Last Name": last, + Initial: last.charAt(0).toUpperCase(), +}); +const headers = ["First Name", "Last Name", "Initial"]; + +describe("a finder's blocks", () => { + it("files guests under their letter, A to Z, by surname within it", () => { + const rows = [guest("Zoe", "Smith"), guest("Ann", "Byron"), guest("Bo", "Sato"), guest("Cy", "Adams")]; + const { blocks } = gridBlocks(finder(), rows); + expect(blocks.map((b) => b.heading)).toEqual(["A", "B", "S"]); + expect(blocks[2]!.lines).toEqual(["Sato, Bo", "Smith, Zoe"]); + }); +}); + +describe("flowing down columns", () => { + it("stacks blocks down a column and carries a long one over to the next, placing every line once", () => { + const runs = flowPlan(finder(), [3, 8]); + expect(runs[0]).toMatchObject({ column: 0, block: 0, heading: true, from: 0, to: 3 }); + const continued = runs.filter((run) => run.block === 1); + expect(continued[0]).toMatchObject({ column: 0, heading: true, from: 0 }); + expect(continued.at(-1)).toMatchObject({ column: 1, heading: false, to: 8 }); + expect(continued.reduce((sum, run) => sum + run.to - run.from, 0)).toBe(8); + }); + + it("balances a list that fits on one page across its columns", () => { + // Eleven lines in two columns of ten: six and five, not ten and one. + const runs = flowPlan(finder(), [10]); + const perColumn = [0, 1].map((column) => runs.filter((r) => r.column === column).reduce((sum, r) => sum + r.to - r.from + (r.heading ? 1 : 0), 0)); + expect(perColumn).toEqual([6, 5]); + }); + + it("never leaves a heading alone at the foot of a column", () => { + // Long enough for two pages, so columns fill in turn: A fills nine lines, + // and B's heading would be the tenth with nothing under it. + const runs = flowPlan(finder(), [8, 2, 30]); + expect(runs[1]).toMatchObject({ column: 1, y: 0, heading: true, from: 0, to: 2 }); + }); + + it("goes on to another page when the columns are full", () => { + const runs = flowPlan(finder(), [25]); + expect(Math.max(...runs.map((r) => r.page))).toBe(1); + }); +}); + +describe("cutting a long list into pages", () => { + const template = (el: GridElement): Template => ({ backgroundHex: null, rowScope: { kind: "document" }, elements: [el] }); + const many = Array.from({ length: 30 }, (_, i) => guest(`G${i}`, `Surname${String(i).padStart(2, "0")}`)); + + it("leaves a list that fits as one artefact", () => { + expect(artefactsOf(template(finder()), many.slice(0, 10), headers)).toHaveLength(1); + }); + + it("makes one artefact per page, each holding exactly what its page prints", () => { + // One letter, 30 names: 2 columns × 10 lines, less the heading, is 19 a page. + const parts = artefactsOf(template(finder()), many, headers); + expect(parts.map((p) => p.rows.length)).toEqual([19, 11]); + expect(parts.map((p) => p.label)).toEqual(["All 30 rows — page 1 of 2", "All 30 rows — page 2 of 2"]); + expect(new Set(parts.map((p) => p.key)).size).toBe(2); + expect(parts.flatMap((p) => p.rows)).toEqual([...many].sort((a, b) => a["Last Name"].localeCompare(b["Last Name"]))); + }); + + it("keeps the artefact's own identity on every page, so a design tweak applies to all of them", () => { + const parts = artefactsOf(template(finder()), many, headers); + expect(new Set(parts.map((p) => p.rowId)).size).toBe(1); + }); + + it("leaves a design without a flowing grid alone", () => { + expect(artefactsOf(template(finder({ layout: "cells" })), many, headers)).toHaveLength(1); + }); +}); + +describe("a finder over several pages", () => { + const card = { widthMm: 210, heightMm: 297, fold: "none", foldPositionMm: 0, invertBackPanel: false, bleedMm: 0 } as const; + /** Every name each page prints, page by page. */ + const printed = (template: Template, rows: ReturnType[]) => + artefactsOf(template, rows, headers).map((part) => + resolveCard(template, part.row, card, { fitText: noFit, iconPath: () => null }, part.rows) + .scene.elements.flatMap((el) => (el.kind === "text" ? el.lines.filter((line) => line.length > 1) : [])), + ); + + it("prints every guest once, a letter carried over to a new page under its heading again", () => { + const rows = Array.from({ length: 25 }, (_, i) => guest(`G${String(i).padStart(2, "0")}`, "Smith")); + const template: Template = { backgroundHex: null, rowScope: { kind: "document" }, elements: [finder({ columns: 1 })] }; + const pages = printed(template, rows); + expect(pages.length).toBeGreaterThan(1); + expect(pages.flat().sort()).toEqual(rows.map((r) => `Smith, ${r["First Name"]}`).sort()); + }); + + it("cuts pages by the names as they print, not as they are stored", () => { + const rows = Array.from({ length: 40 }, (_, i) => ({ ...guest(`G${String(i).padStart(2, "0")}`, "Smith"), "Known As": "" })); + const template: Template = { + backgroundHex: null, + rowScope: { kind: "document" }, + elements: [finder({ columns: 1, itemTemplate: "{{Known As}}" })], + }; + expect(printed(template, rows).flat()).toHaveLength(40); + }); +}); + +describe("a flowing grid too short for a heading and a line", () => { + it("still finishes, placing what it can and letting the fit report the rest", () => { + const runs = flowPlan({ h: 100, columns: 1, gapMm: 4, fontSizePt: 12, headingScale: 1.4, lineHeight: 1.2 }, [1]); + expect(runs.reduce((n, run) => n + run.to - run.from, 0)).toBe(1); + }, 2000); +}); diff --git a/suite/apps/plaque/core/data/parts.ts b/suite/apps/plaque/core/data/parts.ts new file mode 100644 index 00000000..fdcc4a47 --- /dev/null +++ b/suite/apps/plaque/core/data/parts.ts @@ -0,0 +1,62 @@ +import { asKnown } from "../template/chairs"; +import { flowPlan, gridBlocks } from "../template/grid"; +import type { GridElement, Template } from "../types"; +import { buildArtefacts, type Artefact } from "./artefacts"; +import type { GuestRow } from "./rows"; + +/** + * What a design prints from these rows: one artefact per row, group or + * document as its scope says, each cut into pages where it runs long. Every + * place that counts, shows or prints artefacts goes through this. + */ +export function artefactsOf(template: Template, rows: GuestRow[], headers: string[], rowIds?: string[]): Artefact[] { + return withParts(template, buildArtefacts(rows, template.rowScope ?? { kind: "per-row" }, headers, rowIds)); +} + +/** + * An artefact too long for one page, as one artefact per page. + * + * A finder of a hundred and fifty names will not fit an A4 page at any size + * worth reading; its columns carry on onto the next. Cutting here, before + * imposition, is what keeps the rest of Plaque ignorant of it: the preview, + * the counts, the warnings and the export all simply see more artefacts. + * + * The cut follows the design's flowing grid — the same plan its layout uses, + * so a page holds exactly what that page prints. Every part keeps the + * artefact's identity in its key, so a reprint of one page is a choice of one + * part. + */ +export function withParts(template: Template, artefacts: Artefact[]): Artefact[] { + const flow = template.elements.find( + (el): el is GridElement => el.kind === "grid" && el.layout === "columns", + ); + if (!flow) return artefacts; + + return artefacts.flatMap((artefact) => { + // Cut by the names as the page prints them. + const named = artefact.rows.map((row) => asKnown(template, row)); + const { blocks } = gridBlocks(flow, named); + const indexOf = new Map(named.map((row, i) => [row, i])); + const runs = flowPlan(flow, blocks.map((block) => block.rows.length)); + const pages = Math.max(0, ...runs.map((run) => run.page)) + 1; + if (pages === 1) return [artefact]; + + return Array.from({ length: pages }, (_, page) => { + const picked = runs + .filter((run) => run.page === page) + .flatMap((run) => blocks[run.block]!.rows.slice(run.from, run.to)) + .map((row) => indexOf.get(row)!); + const rows = picked.map((i) => artefact.rows[i]!); + return { + key: `${artefact.key}#${page + 1}`, + row: rows[0] ?? artefact.row, + rows, + rowIndexes: picked.map((i) => artefact.rowIndexes[i]!), + // The artefact's own: a design tweak to it applies to every page. + rowId: artefact.rowId, + rowIds: picked.map((i) => artefact.rowIds[i]!), + label: `${artefact.label} — page ${page + 1} of ${pages}`, + }; + }); + }); +} diff --git a/suite/apps/plaque/core/data/stockPresets.test.ts b/suite/apps/plaque/core/data/stockPresets.test.ts index 47f5f77f..8f2de78b 100644 --- a/suite/apps/plaque/core/data/stockPresets.test.ts +++ b/suite/apps/plaque/core/data/stockPresets.test.ts @@ -74,7 +74,7 @@ describe("validatePreset", () => { it("names the field that is wrong, so a bad contribution is findable", () => { expect(validatePreset(preset({ id: "" }))).toBe("id"); - expect(validatePreset({ ...preset(), page: "A3" })).toBe("page"); + expect(validatePreset({ ...preset(), page: "A5" })).toBe("page"); expect(validatePreset({ ...preset(), widthMm: "63.5" })).toBe("widthMm"); expect(validatePreset(preset({ columns: 0 }))).toBe("columns"); expect(validatePreset(preset({ rows: 1.5 }))).toBe("rows"); diff --git a/suite/apps/plaque/core/data/stockPresets.ts b/suite/apps/plaque/core/data/stockPresets.ts index 8fdde9ca..fa42a450 100644 --- a/suite/apps/plaque/core/data/stockPresets.ts +++ b/suite/apps/plaque/core/data/stockPresets.ts @@ -1,5 +1,5 @@ import raw from "../../data/stock-presets.json"; -import type { CardSpec, Orientation, PageSizeName, SheetSpec } from "../types"; +import type { CardSpec, Orientation, PaperName, SheetSpec } from "../types"; import { pageSizeMm } from "../units"; /** @@ -16,7 +16,7 @@ import { pageSizeMm } from "../units"; export interface StockPreset { id: string; name: string; - page: PageSizeName; + page: PaperName; orientation: Orientation; widthMm: number; heightMm: number; @@ -82,7 +82,7 @@ export function validatePreset(value: unknown): string | null { for (const key of ["id", "name"]) { if (typeof p[key] !== "string" || !p[key]) return key; } - if (p["page"] !== "A4" && p["page"] !== "LETTER") return "page"; + if (p["page"] !== "A4" && p["page"] !== "LETTER" && p["page"] !== "A3") return "page"; if (p["orientation"] !== "portrait" && p["orientation"] !== "landscape") return "orientation"; for (const key of ["widthMm", "heightMm", "gapXMm", "gapYMm"]) { const n = p[key]; diff --git a/suite/apps/plaque/core/geometry/pageLayout.test.ts b/suite/apps/plaque/core/geometry/pageLayout.test.ts index a3d5f886..f58f73c6 100644 --- a/suite/apps/plaque/core/geometry/pageLayout.test.ts +++ b/suite/apps/plaque/core/geometry/pageLayout.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { CardSpec, SheetSpec } from "../types"; -import { cardOriginOnSheet, computeLayout, fitCount, usableSize } from "./pageLayout"; +import { cardOriginOnSheet, computeLayout, fitCount, sheetPageMm, usableSize } from "./pageLayout"; const card = (over: Partial = {}): CardSpec => ({ widthMm: 85, @@ -14,6 +14,7 @@ const card = (over: Partial = {}): CardSpec => ({ const sheet = (over: Partial = {}): SheetSpec => ({ page: "A4", + tilePaper: "A4", orientation: "portrait", marginTopMm: 10, marginRightMm: 10, @@ -49,10 +50,29 @@ describe("fitCount", () => { }); }); +describe("a page the card's own size", () => { + it("is the card and its margins, so exactly one fits, at full size", () => { + const board = card({ widthMm: 594, heightMm: 841 }); + const fit = sheet({ page: "FIT", marginTopMm: 8, marginRightMm: 8, marginBottomMm: 8, marginLeftMm: 8 }); + expect(sheetPageMm(board, fit)).toEqual({ w: 610, h: 857 }); + expect(computeLayout(board, fit)).toMatchObject({ perSheet: 1, origin: { x: 8, y: 8 } }); + }); + + it("holds its card however the margins add up in floating point", () => { + const fiveInch = card({ widthMm: 127, heightMm: 127 }); + const half = sheet({ page: "FIT", marginTopMm: 12.7, marginRightMm: 12.7, marginBottomMm: 12.7, marginLeftMm: 12.7 }); + expect(computeLayout(fiveInch, half).perSheet).toBe(1); + }); + + it("is the named paper otherwise", () => { + expect(sheetPageMm(card(), sheet({ page: "A3" }))).toEqual({ w: 297, h: 420 }); + }); +}); + describe("computeLayout", () => { it("hits the spec's worked example: A4, 85x55, 10mm margins, 5mm gaps", () => { const layout = computeLayout(card(), sheet()); - expect(usableSize(sheet())).toEqual({ w: 190, h: 277 }); + expect(usableSize(card(), sheet())).toEqual({ w: 190, h: 277 }); expect(layout.cols).toBe(2); expect(layout.rows).toBe(4); expect(layout.perSheet).toBe(8); diff --git a/suite/apps/plaque/core/geometry/pageLayout.ts b/suite/apps/plaque/core/geometry/pageLayout.ts index d8b82937..ee1064f5 100644 --- a/suite/apps/plaque/core/geometry/pageLayout.ts +++ b/suite/apps/plaque/core/geometry/pageLayout.ts @@ -17,9 +17,25 @@ export interface PageLayout { gapYMm: Mm; } +/** + * The page itself. A `FIT` page is the card plus its margins, so exactly one + * fits; any other is the paper it names. + */ +export function sheetPageMm(card: CardSpec, sheet: SheetSpec): Size { + if (sheet.page !== "FIT") return pageSizeMm(sheet.page, sheet.orientation); + const footprint = cardFootprint({ w: card.widthMm, h: card.heightMm }, sheet.cardRotationDeg); + return { + w: footprint.w + sheet.marginLeftMm + sheet.marginRightMm, + h: footprint.h + sheet.marginTopMm + sheet.marginBottomMm, + }; +} + /** The rectangle inside the margins that cards may occupy. */ -export function usableSize(sheet: SheetSpec): Size { - const page = pageSizeMm(sheet.page, sheet.orientation); +export function usableSize(card: CardSpec, sheet: SheetSpec): Size { + // Exactly the card on a page made to fit it: adding the margins and taking + // them away again is not exact in floating point, and a hair short fits none. + if (sheet.page === "FIT") return cardFootprint({ w: card.widthMm, h: card.heightMm }, sheet.cardRotationDeg); + const page = sheetPageMm(card, sheet); return { w: page.w - sheet.marginLeftMm - sheet.marginRightMm, h: page.h - sheet.marginTopMm - sheet.marginBottomMm, @@ -38,9 +54,9 @@ export function fitCount(available: Mm, size: Mm, gap: Mm): number { } export function computeLayout(card: CardSpec, sheet: SheetSpec): PageLayout { - const page = pageSizeMm(sheet.page, sheet.orientation); + const page = sheetPageMm(card, sheet); const footprint = cardFootprint({ w: card.widthMm, h: card.heightMm }, sheet.cardRotationDeg); - const usable = usableSize(sheet); + const usable = usableSize(card, sheet); const cols = fitCount(usable.w, footprint.w, sheet.gapXMm); const rows = fitCount(usable.h, footprint.h, sheet.gapYMm); diff --git a/suite/apps/plaque/core/geometry/suggestLayouts.ts b/suite/apps/plaque/core/geometry/suggestLayouts.ts index 0be47564..c4dd2431 100644 --- a/suite/apps/plaque/core/geometry/suggestLayouts.ts +++ b/suite/apps/plaque/core/geometry/suggestLayouts.ts @@ -1,9 +1,9 @@ import { pageSizeMm } from "../units"; -import type { CardRotation, CardSpec, Mm, Orientation, PageSizeName, SheetSpec } from "../types"; +import type { CardRotation, CardSpec, Mm, Orientation, PaperName, SheetSpec } from "../types"; import { computeLayout } from "./pageLayout"; export interface SuggestOptions { - pages?: PageSizeName[]; + pages?: PaperName[]; /** Floor for the gap between cards. Raised automatically when bleed demands it. */ minGapMm?: Mm; /** Suggestions never place a card inside this border. */ @@ -14,7 +14,7 @@ export interface SuggestOptions { export interface LayoutSuggestion { id: string; label: string; - page: PageSizeName; + page: PaperName; orientation: Orientation; cardRotationDeg: CardRotation; cols: number; @@ -54,7 +54,7 @@ export function minimumGapMm(card: CardSpec, floor: Mm): Mm { * the user picks a size, the app works out how to waste the least card stock. */ export function suggestLayouts(card: CardSpec, opts: SuggestOptions = {}): LayoutSuggestion[] { - const pages = opts.pages ?? (["A4", "LETTER"] as PageSizeName[]); + const pages = opts.pages ?? (["A4", "LETTER"] as PaperName[]); const margin = opts.printerMarginMm ?? 5; const gap = minimumGapMm(card, opts.minGapMm ?? 5); const maxResults = opts.maxResults ?? 6; @@ -78,6 +78,7 @@ export function suggestLayouts(card: CardSpec, opts: SuggestOptions = {}): Layou const probe: SheetSpec = { ...patch, printerMarginMm: margin, + tilePaper: "A4", cropMarks: true, cutLines: true, foldGuides: true, @@ -127,14 +128,14 @@ function orientationRank(o: Orientation): number { } function describe( - page: PageSizeName, + page: PaperName, orientation: Orientation, rotation: CardRotation, cols: number, rows: number, perSheet: number, ): string { - const name = page === "A4" ? "A4" : "Letter"; + const name = page === "LETTER" ? "Letter" : page; const turned = rotation === 90 ? ", cards turned" : ""; return `${name} ${orientation}${turned} — ${cols} × ${rows}, ${perSheet} per sheet`; } diff --git a/suite/apps/plaque/core/geometry/validate.test.ts b/suite/apps/plaque/core/geometry/validate.test.ts index 25ee2be1..170c788c 100644 --- a/suite/apps/plaque/core/geometry/validate.test.ts +++ b/suite/apps/plaque/core/geometry/validate.test.ts @@ -14,6 +14,7 @@ const card = (over: Partial = {}): CardSpec => ({ const sheet = (over: Partial = {}): SheetSpec => ({ page: "A4", + tilePaper: "A4", orientation: "portrait", marginTopMm: 10, marginRightMm: 10, @@ -49,6 +50,18 @@ describe("validateGeometry", () => { expect(ids(card(), sheet({ marginLeftMm: 3, printerMarginMm: 5 }))).toContain("printer-margin"); }); + it("does not hold a print shop to a home printer's border", () => { + const board = card({ widthMm: 594, heightMm: 841, bleedMm: 3 }); + const fit = sheet({ page: "FIT", marginTopMm: 3, marginRightMm: 3, marginBottomMm: 3, marginLeftMm: 3, printerMarginMm: 5 }); + const found = ids(board, fit); + expect(found).not.toContain("printer-margin"); + expect(found).not.toContain("bleed-clipped"); + expect(found).not.toContain("no-fit"); + expect(found).not.toContain("card-larger-than-page"); + // One board, so no neighbour for its bleed to run into. + expect(found).not.toContain("bleed-overlap"); + }); + it("warns when crop marks would run off the page", () => { expect(ids(card({ bleedMm: 3 }), sheet({ marginTopMm: 6 }))).toContain("crop-marks-clipped"); expect(ids(card({ bleedMm: 0 }), sheet({ marginTopMm: 10 }))).not.toContain("crop-marks-clipped"); diff --git a/suite/apps/plaque/core/geometry/validate.ts b/suite/apps/plaque/core/geometry/validate.ts index 0ee41879..7b5c0c6a 100644 --- a/suite/apps/plaque/core/geometry/validate.ts +++ b/suite/apps/plaque/core/geometry/validate.ts @@ -1,8 +1,7 @@ -import { pageSizeMm } from "../units"; import type { CardSpec, SheetSpec } from "../types"; import { MARK_LENGTH_MM } from "./cropMarks"; import { foldPositionIsValid } from "./fold"; -import { computeLayout, usableSize } from "./pageLayout"; +import { computeLayout, sheetPageMm, usableSize } from "./pageLayout"; export type Severity = "error" | "warning"; @@ -36,8 +35,11 @@ export function validateGeometry( printer: ValidateContext = {}, ): Issue[] { const issues: Issue[] = []; - const page = pageSizeMm(sheet.page, sheet.orientation); - const usable = usableSize(sheet); + const page = sheetPageMm(card, sheet); + const usable = usableSize(card, sheet); + // A print shop prints to the edge of the sheet it is given: what a home + // printer cannot reach is not its problem. + const atHome = sheet.page !== "FIT"; const layout = computeLayout(card, sheet); if (card.widthMm <= 0 || card.heightMm <= 0) { @@ -86,7 +88,8 @@ export function validateGeometry( if (card.bleedMm > 0) { const need = card.bleedMm * 2; - if (sheet.gapXMm < need || sheet.gapYMm < need) { + // Only between cards that have a neighbour that way: a lone board has none. + if ((layout.cols > 1 && sheet.gapXMm < need) || (layout.rows > 1 && sheet.gapYMm < need)) { issues.push({ id: "bleed-overlap", severity: "warning", @@ -105,7 +108,7 @@ export function validateGeometry( typeof printer.unprintableMarginMm === "number" && Number.isFinite(printer.unprintableMarginMm) ? printer.unprintableMarginMm : null; - const unprintable = Math.max(sheet.printerMarginMm, measured ?? 0); + const unprintable = atHome ? Math.max(sheet.printerMarginMm, measured ?? 0) : 0; if (minMargin < unprintable) { const whose = measured !== null && measured >= sheet.printerMarginMm diff --git a/suite/apps/plaque/core/imposition/paginate.test.ts b/suite/apps/plaque/core/imposition/paginate.test.ts index e5fb79f8..bdbc6e55 100644 --- a/suite/apps/plaque/core/imposition/paginate.test.ts +++ b/suite/apps/plaque/core/imposition/paginate.test.ts @@ -16,6 +16,7 @@ const card = (over: Partial = {}): CardSpec => ({ const sheet = (over: Partial = {}): SheetSpec => ({ page: "A4", + tilePaper: "A4", orientation: "portrait", marginTopMm: 10, marginRightMm: 10, diff --git a/suite/apps/plaque/core/imposition/paginate.ts b/suite/apps/plaque/core/imposition/paginate.ts index cb2a392b..471e47fa 100644 --- a/suite/apps/plaque/core/imposition/paginate.ts +++ b/suite/apps/plaque/core/imposition/paginate.ts @@ -1,7 +1,7 @@ import { cardGuides } from "../geometry/cropMarks"; import { cardOriginOnSheet, computeLayout, type PageLayout } from "../geometry/pageLayout"; import { cardToSheet } from "../geometry/transform"; -import { resolveCard, type CardWarning, type ResolveOptions } from "../template/bindings"; +import { GRID_HEADING, resolveCard, type CardWarning, type ResolveOptions } from "../template/bindings"; import type { Artefact } from "../data/artefacts"; import { templateForRow } from "../template/overrides"; import type { CardSpec, ResolvedElement, Sheet, SheetGuides, SheetSpec, Template } from "../types"; @@ -88,9 +88,14 @@ function headroomOf(template: Template, elements: ResolvedElement[]): number { for (const el of elements) { if (el.kind !== "text") continue; if (el.overflowed) return 0; - const source = template.elements.find((candidate) => candidate.id === el.id); + const source = template.elements.find((candidate) => candidate.id === (el.sourceId ?? el.id)); + // A grid's headings are a multiple of its lines: measure every piece against the lines' size. const requested = - source && (source.kind === "text" || source.kind === "list") ? source.fontSizePt : el.fontSizePt; + source?.kind === "grid" + ? source.fontSizePt * (el.id.startsWith(`${source.id}${GRID_HEADING}`) ? source.headingScale : 1) + : source && (source.kind === "text" || source.kind === "list") + ? source.fontSizePt + : el.fontSizePt; if (requested > 0) tightest = Math.min(tightest, el.fontSizePt / requested); } return tightest; diff --git a/suite/apps/plaque/core/imposition/tile.test.ts b/suite/apps/plaque/core/imposition/tile.test.ts new file mode 100644 index 00000000..b7ba0fad --- /dev/null +++ b/suite/apps/plaque/core/imposition/tile.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import type { Sheet } from "../types"; +import { TILE_MARGIN_MM, TILE_OVERLAP_MM, tileSheets } from "./tile"; + +/** A board with one element at a known place, and its crop marks. */ +const board = (w: number, h: number, index = 0): Sheet => ({ + index, + pageWidthMm: w, + pageHeightMm: h, + cards: [ + { + origin: { x: 0, y: 0 }, + footprint: { w, h }, + artefactIndex: 0, + scene: { + backgroundHex: null, + elements: [{ id: "name", kind: "rect", x: 300, y: 400, w: 10, h: 10, rotationDeg: 0, z: 0, fillHex: "#000000", strokeHex: null, strokeWidthMm: 0, dashed: false }], + }, + }, + ], + guides: { cropMarks: [[{ x: 0, y: 0 }, { x: 5, y: 0 }]], cutLines: [], foldGuides: [], bleedBoxes: [] }, +}); + +describe("tiling a board onto home paper", () => { + // A4 portrait: a 190 × 277mm window, stepping 180 × 267mm. + it("covers the whole board with the fewest sheets of paper", () => { + const { sheets } = tileSheets([board(594, 841)], "A4"); + // Portrait: 4 × 4 = 16. Landscape (267 × 180 steps): 3 × 5 = 15. + expect(sheets).toHaveLength(15); + expect(sheets[0]).toMatchObject({ pageWidthMm: 297, pageHeightMm: 210 }); + }); + + it("numbers every tile in order, and labels it by row and column", () => { + const { sheets, labels } = tileSheets([board(594, 841)], "A4"); + expect(sheets.map((s) => s.index)).toEqual([...sheets.keys()]); + expect(labels[0]).toMatch(/^tile A1 of 5 × 3\./); + expect(labels[4]).toMatch(/^tile B2 of 5 × 3\./); + }); + + it("moves the board under each tile's window, overlapping the one before by the overlap", () => { + const { sheets } = tileSheets([board(594, 841)], "A4"); + const rect = (i: number) => sheets[i]!.cards[0]!.scene.elements[0]!; + // A1 shows the board from its corner, inset by the margin. + expect(rect(0)).toMatchObject({ x: 300 + TILE_MARGIN_MM, y: 400 + TILE_MARGIN_MM }); + // A2 starts one step on: the window less the overlap. + const stepX = 297 - TILE_MARGIN_MM * 2 - TILE_OVERLAP_MM; + expect(rect(1).x).toBeCloseTo(300 + TILE_MARGIN_MM - stepX); + // The board's own marks travel with it. + expect(sheets[0]!.guides.cropMarks[0]![0]).toEqual({ x: TILE_MARGIN_MM, y: TILE_MARGIN_MM }); + }); + + it("draws a trim line only where a tile has a neighbour before it, and a landing line where one follows", () => { + const { sheets } = tileSheets([board(594, 841)], "A4"); + const lines = (i: number) => sheets[i]!.guides.cutLines; + // A1: nothing before it; something to its right and below. + expect(lines(0)).toHaveLength(2); + // B2: neighbours on every side. + expect(lines(4)).toHaveLength(4); + // The last tile: only trim lines. + expect(lines(sheets.length - 1)).toHaveLength(2); + expect(lines(4)[0]).toEqual([ + { x: TILE_MARGIN_MM, y: TILE_MARGIN_MM }, + { x: TILE_MARGIN_MM, y: 210 - TILE_MARGIN_MM }, + ]); + }); + + it("does not tile what already fits on one sheet", () => { + expect(tileSheets([board(150, 200)], "A4").sheets).toHaveLength(1); + }); + + it("says which board a tile belongs to when there is more than one", () => { + const { labels } = tileSheets([board(150, 200, 0), board(150, 200, 1)], "A4"); + expect(labels).toEqual([expect.stringMatching(/^Board 1, tile A1/), expect.stringMatching(/^Board 2, tile A1/)]); + }); +}); diff --git a/suite/apps/plaque/core/imposition/tile.ts b/suite/apps/plaque/core/imposition/tile.ts new file mode 100644 index 00000000..b750dafe --- /dev/null +++ b/suite/apps/plaque/core/imposition/tile.ts @@ -0,0 +1,108 @@ +import { pageSizeMm } from "../units"; +import type { Mm, Orientation, PaperName, Segment, Sheet, Size } from "../types"; +import { offsetSheet } from "./duplex"; + +/** + * A board printed at home: each full-size sheet cut into pieces of ordinary + * paper, to trim and lay over one another. + * + * Each tile prints a view of the board inside a margin wide enough for any + * home printer's unprintable border and for the tile's label. Neighbouring + * windows overlap, so a tile trimmed along its left and top lines lays over its + * neighbours' landing lines with the artwork continuing across the join. + * + * Pure geometry after imposition: it moves sheets, and adds lines. Neither + * renderer knows tiling exists. + */ + +/** Room for the printer's border and, along the foot, the label. */ +export const TILE_MARGIN_MM = 10; +/** How far each tile runs under the next. */ +export const TILE_OVERLAP_MM = 10; + +export interface Tiled { + sheets: Sheet[]; + /** One per tile, in order: which tile it is and how it goes together. */ + labels: string[]; +} + +export function tileSheets(sheets: Sheet[], paper: PaperName): Tiled { + const out: Sheet[] = []; + const labels: string[] = []; + + for (const sheet of sheets) { + const { size, cols, rows } = bestGrid(sheet, paper); + const view = { w: size.w - TILE_MARGIN_MM * 2, h: size.h - TILE_MARGIN_MM * 2 }; + const step = { w: view.w - TILE_OVERLAP_MM, h: view.h - TILE_OVERLAP_MM }; + const board = sheets.length > 1 ? `Board ${sheet.index + 1}, ` : ""; + + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + const moved = offsetSheet(sheet, TILE_MARGIN_MM - col * step.w, TILE_MARGIN_MM - row * step.h); + const name = `${String.fromCharCode(65 + row)}${col + 1}`; + out.push({ + ...moved, + index: out.length, + pageWidthMm: size.w, + pageHeightMm: size.h, + guides: { + ...moved.guides, + cutLines: [...moved.guides.cutLines, ...joinLines(view, step, row, col, rows, cols)], + }, + }); + labels.push( + `${board}tile ${name} of ${rows} × ${cols}. Trim on the line at the left and top, and lay it over the line on the tile before.`, + ); + } + } + } + + return { sheets: out, labels }; +} + +/** The paper turned whichever way takes fewer tiles; portrait when it is a tie. */ +function bestGrid(sheet: Sheet, paper: PaperName): { size: Size; cols: number; rows: number } { + const grids = (["portrait", "landscape"] as Orientation[]).map((orientation) => { + const size = pageSizeMm(paper, orientation); + const step = { w: size.w - TILE_MARGIN_MM * 2 - TILE_OVERLAP_MM, h: size.h - TILE_MARGIN_MM * 2 - TILE_OVERLAP_MM }; + return { + size, + cols: count(sheet.pageWidthMm, step.w), + rows: count(sheet.pageHeightMm, step.h), + }; + }); + return grids.reduce((best, grid) => (grid.cols * grid.rows < best.cols * best.rows ? grid : best)); +} + +/** Tiles of `step` needed to cover `length`, the last one carrying the overlap. */ +function count(length: Mm, step: Mm): number { + return Math.max(1, Math.ceil((length - TILE_OVERLAP_MM) / step)); +} + +/** + * Where to trim and where to lay the next tile, in the tile's own coordinates: + * a trim line on the window's left and top edges unless they are the board's, + * and a landing line one overlap in from the right and bottom unless nothing + * comes after. + */ +function joinLines(view: Size, step: Size, row: number, col: number, rows: number, cols: number): Segment[] { + const left = TILE_MARGIN_MM; + const top = TILE_MARGIN_MM; + const right = left + view.w; + const bottom = top + view.h; + const vertical = (x: Mm): Segment => [ + { x, y: top }, + { x, y: bottom }, + ]; + const horizontal = (y: Mm): Segment => [ + { x: left, y }, + { x: right, y }, + ]; + + const lines: Segment[] = []; + if (col > 0) lines.push(vertical(left)); + if (row > 0) lines.push(horizontal(top)); + if (col < cols - 1) lines.push(vertical(left + step.w)); + if (row < rows - 1) lines.push(horizontal(top + step.h)); + return lines; +} diff --git a/suite/apps/plaque/core/job.test.ts b/suite/apps/plaque/core/job.test.ts index 37e6bbb7..4ce61f48 100644 --- a/suite/apps/plaque/core/job.test.ts +++ b/suite/apps/plaque/core/job.test.ts @@ -45,6 +45,28 @@ describe("buildJob", () => { expect(buildJob(input({ limit: 2 })).artefactCount).toBe(2); }); + it("prints only the chosen artefacts for a reprint of a few", () => { + const job = buildJob(input({ only: new Set(["row:r3", "row:r40"]) })); + expect(job.artefactCount).toBe(2); + expect(job.sheets).toHaveLength(1); + }); + + it("tiles a board onto home paper, and labels every tile", () => { + const board = { ...card, widthMm: 594, heightMm: 841 }; + const sheet = { ...defaultSheet(), page: "FIT" as const, marginTopMm: 8, marginRightMm: 8, marginBottomMm: 8, marginLeftMm: 8 }; + const template = { ...defaultTemplate(headers, board), rowScope: { kind: "document" as const } }; + const job = buildJob(input({ card: board, sheet, template, tile: "A4" })); + expect(job.artefactCount).toBe(1); + expect(job.sheets.length).toBeGreaterThan(1); + expect(job.sheets.every((s) => s.pageWidthMm === 297 || s.pageWidthMm === 210)).toBe(true); + expect(job.slugTexts).toHaveLength(job.sheets.length); + expect(job.slugTexts[0]).toMatch(/^tile A1/); + }); + + it("will not tile and print both sides at once", () => { + expect(() => buildJob(input({ tile: "A4", duplex: { flipEdge: "long" } }))).toThrow(/one side/); + }); + it("builds only the requested page range", () => { const job = buildJob(input({ pages: { from: 0, to: 0 } })); expect(job.sheets).toHaveLength(1); diff --git a/suite/apps/plaque/core/job.ts b/suite/apps/plaque/core/job.ts index 905a1def..3fab3673 100644 --- a/suite/apps/plaque/core/job.ts +++ b/suite/apps/plaque/core/job.ts @@ -1,4 +1,4 @@ -import { buildArtefacts } from "./data/artefacts"; +import { artefactsOf } from "./data/parts"; import type { GuestRow } from "./data/rows"; import { hasBackSide, @@ -10,10 +10,11 @@ import { type FlipEdge, } from "./imposition/duplex"; import { paginate, type GuestWarning } from "./imposition/paginate"; +import { tileSheets } from "./imposition/tile"; import { effectiveScale } from "./print/printerProfile"; import { SLUG_RULE_MM, buildFingerprint, slugText } from "./print/slug"; import type { ResolveOptions } from "./template/bindings"; -import type { CardSpec, Sheet, SheetSpec, Template } from "./types"; +import type { CardSpec, PaperName, Sheet, SheetSpec, Template } from "./types"; /** * One job, from data to sheets. @@ -40,6 +41,13 @@ export interface JobInput { pages?: { from: number; to: number }; /** Limit to the first N artefacts — the two-test-cards path. */ limit?: number; + /** Only the artefacts with these keys — a reprint of a few. */ + only?: ReadonlySet; + /** + * Cut each sheet into tiles of this paper, for a board printed at home. One + * sided: a board has no back. + */ + tile?: PaperName; } export interface JobResult { @@ -53,13 +61,9 @@ export interface JobResult { } export function buildJob(input: JobInput): JobResult { - const all = buildArtefacts( - input.rows, - input.template.rowScope ?? { kind: "per-row" }, - input.headers, - input.rowIds, - ); - const artefacts = input.limit === undefined ? all : all.slice(0, input.limit); + const all = artefactsOf(input.template, input.rows, input.headers, input.rowIds); + const chosen = input.only ? all.filter((artefact) => input.only!.has(artefact.key)) : all; + const artefacts = input.limit === undefined ? chosen : chosen.slice(0, input.limit); const pageRange = input.pages ? { pages: input.pages } : {}; const scale = effectiveScale(input.scale); @@ -72,6 +76,28 @@ export function buildJob(input: JobInput): JobResult { pageRange, ); + const buildHash = buildFingerprint({ + card: input.card, + sheet: input.sheet, + template: input.template, + rowCount: artefacts.length, + scale, + }); + + if (input.tile) { + if (input.duplex) throw new Error("A board tiled at home is printed one side only."); + const tiled = tileSheets(front.sheets, input.tile); + return { + sheets: tiled.sheets, + warnings: front.warnings, + artefactCount: artefacts.length, + // The tile's label goes where the slug would: it is what a tile needs to say. + slugTexts: tiled.labels, + slugRuleMm: SLUG_RULE_MM, + buildHash, + }; + } + let sheets = front.sheets; const wantsDuplex = input.sheet.duplex && hasBackSide(input.template) && input.duplex; if (wantsDuplex && input.duplex) { @@ -97,14 +123,6 @@ export function buildJob(input: JobInput): JobResult { sheets = interleave(front.sheets, backs); } - const buildHash = buildFingerprint({ - card: input.card, - sheet: input.sheet, - template: input.template, - rowCount: artefacts.length, - scale, - }); - const slugTexts: string[] = []; for (const s of sheets) { slugTexts[s.index] = slugText({ diff --git a/suite/apps/plaque/core/print/contrast.ts b/suite/apps/plaque/core/print/contrast.ts index 40b49730..38518111 100644 --- a/suite/apps/plaque/core/print/contrast.ts +++ b/suite/apps/plaque/core/print/contrast.ts @@ -89,7 +89,7 @@ export function contrastIssues( for (const el of elements) { // Only ink that carries meaning: text, lists and icons. A decorative rule // that is deliberately faint is not a legibility problem. - if (el.kind !== "text" && el.kind !== "list" && el.kind !== "icon") continue; + if (el.kind !== "text" && el.kind !== "list" && el.kind !== "grid" && el.kind !== "room" && el.kind !== "qr" && el.kind !== "icon") continue; if (!el.colorHex) continue; const verdict = verdictFor(el.colorHex, stockHex); if (verdict === "fine") continue; diff --git a/suite/apps/plaque/core/template/assets.test.ts b/suite/apps/plaque/core/template/assets.test.ts index 8ddfefd8..a50076c2 100644 --- a/suite/apps/plaque/core/template/assets.test.ts +++ b/suite/apps/plaque/core/template/assets.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_FONT_ID } from "../../assets/fonts"; import type { ImageElement, Template, TextElement } from "../types"; import { missingAssets } from "./assets"; +import { elementKind } from "./registry"; const text = (over: Partial = {}): TextElement => ({ id: "t1", @@ -78,3 +79,14 @@ describe("missingAssets", () => { expect(missingAssets(template([image({ imageId: null })]), nothing, nothing)).toEqual([]); }); }); + +describe("a font missing from any element that sets type", () => { + const card = { widthMm: 90, heightMm: 55, fold: "none", foldPositionMm: 0, invertBackPanel: false, bleedMm: 0 } as const; + const made = (kind: "text" | "list" | "grid" | "room") => + ({ ...elementKind(kind)!.create({ id: kind, z: 1, card, headers: [] }), fontId: "gone" }) as Template["elements"][number]; + + it.each(["text", "list", "grid", "room"] as const)("is found on a %s", (kind) => { + const found = missingAssets(template([made(kind)]), everything, nothing); + expect(found).toEqual([{ id: "gone", kind: "font", elementIds: [kind] }]); + }); +}); diff --git a/suite/apps/plaque/core/template/assets.ts b/suite/apps/plaque/core/template/assets.ts index 6a59d33f..4691a24b 100644 --- a/suite/apps/plaque/core/template/assets.ts +++ b/suite/apps/plaque/core/template/assets.ts @@ -1,5 +1,5 @@ import { BUNDLED_FONTS } from "../../assets/fonts"; -import type { ElementId, Template } from "../types"; +import type { CardElement, ElementId, Template } from "../types"; export interface MissingAsset { /** The id the design still references. */ @@ -39,10 +39,28 @@ export function missingAssets( note(el.imageId, "image", el.id); } // A bundled face that has not finished loading is not missing, it is early. - if (el.kind === "text" && !BUNDLED_FONT_IDS.has(el.fontId) && !hasFont(el.fontId)) { - note(el.fontId, "font", el.id); + for (const fontId of new Set(fontsOf(el))) { + if (!BUNDLED_FONT_IDS.has(fontId) && !hasFont(fontId)) note(fontId, "font", el.id); } } return [...found.values()]; } + +/** Every face an element sets type in. */ +function fontsOf(el: CardElement): string[] { + switch (el.kind) { + case "text": + case "list": + case "room": + return [el.fontId]; + case "grid": + return [el.fontId, el.headingFontId]; + case "icon": + case "rect": + case "line": + case "image": + case "qr": + return []; + } +} diff --git a/suite/apps/plaque/core/template/bindings.ts b/suite/apps/plaque/core/template/bindings.ts index ea8848b2..b9e30810 100644 --- a/suite/apps/plaque/core/template/bindings.ts +++ b/suite/apps/plaque/core/template/bindings.ts @@ -2,7 +2,9 @@ import type { CardScene, CardSpec, ElementId, + GridElement, ListElement, + RoomScene, Pt, ResolvedElement, ResolvedImageSource, @@ -11,8 +13,13 @@ import type { } from "../types"; import { BUNDLED_VIEW, type IconArt } from "../../assets/icons"; import type { GuestRow } from "../data/rows"; -import { interpolate } from "../csv/interpolate"; +import { interpolate, tokensIn } from "../csv/interpolate"; +import { flowPlan, gridBlocks } from "./grid"; +import { placeChairs, resolveRoom } from "./room"; +import { qrPath } from "./qr"; +import { asKnown, chairRef, chairValues } from "./chairs"; import { transformForPanel } from "../geometry/fold"; +import { ptToMm } from "../units"; import { resolveIconForRow } from "./icons"; export interface FitResult { @@ -30,6 +37,12 @@ export interface FitResult { export type FitTextFn = (element: TextElement, text: string) => FitResult; /** Block fit for a list element: the lines are already decided, only the size is not. */ export type FitBlockFn = (element: ListElement, lines: string[]) => FitResult; +/** One size for every block of a grid, fitted to its cells. */ +export type FitGridFn = ( + element: GridElement, + blocks: Array<{ heading: string; items: string[] }>, + cell: { w: number; h: number }, +) => { fontSizePt: Pt; overflowed: boolean; missingFont?: boolean }; export type IconPathFn = (iconId: string) => IconArt | null; export type ImageFn = (imageId: string) => ResolvedImageSource | null; @@ -37,6 +50,10 @@ export interface ResolveOptions { fitText: FitTextFn; /** Without it a list element renders nothing and says so. */ fitBlock?: FitBlockFn; + /** Without it a grid renders at its requested size and says the font is missing. */ + fitGrid?: FitGridFn; + /** The seating plan a room element draws. Without it, it draws nothing and says so. */ + room?: () => RoomScene | null; iconPath: IconPathFn; /** Optional: without it, image elements resolve to nothing and warn. */ image?: ImageFn; @@ -61,7 +78,9 @@ export type WarningKind = | "missing-image" | "missing-font" | "unknown-element" - | "empty-text"; + | "empty-text" + /** Rows a grid has no block for — guests with no table on a seating board. */ + | "left-out"; export interface CardWarning { elementId: ElementId; @@ -96,6 +115,23 @@ export function resolveCard( ): ResolvedCard { const warnings: CardWarning[] = []; const elements: ResolvedElement[] = []; + // Boxes that follow a chair go to that chair on this card's plan first. + template = placeChairs(template, opts.room?.() ?? null, row); + // Every guest as this design names them. + row = asKnown(template, row); + rows = rows.map((r) => asKnown(template, r)); + + // Chair tokens name whoever sits in a seat of this card's table, not a column + // of its own row: they are read from the room, once per element, and said + // nothing for when they cannot be — with the reason. + const chairsOf = (elementId: ElementId, text: string): GuestRow => { + const tokens = tokensIn(text).filter((token) => chairRef(token)); + if (tokens.length === 0) return {}; + const { values, problems } = chairValues(tokens, row, opts.room?.() ?? null, template); + for (const detail of problems) warnings.push({ elementId, kind: "missing-field", detail }); + return values; + }; + const seated = (elementId: ElementId, text: string, base: GuestRow): GuestRow => ({ ...base, ...chairsOf(elementId, text) }); for (const el of [...template.elements].sort((a, b) => a.z - b.z)) { const placed = transformForPanel({ x: el.x, y: el.y, w: el.w, h: el.h }, card); @@ -111,7 +147,7 @@ export function resolveCard( switch (el.kind) { case "text": { - const { text, missing } = interpolate(el.template, row); + const { text, missing } = interpolate(el.template, seated(el.id, el.template, row)); for (const name of missing) { warnings.push({ elementId: el.id, @@ -197,8 +233,9 @@ export function resolveCard( // the list element needed no drawing code at all. const lines: string[] = []; const missingColumns: string[] = []; + const listChairs = chairsOf(el.id, el.itemTemplate); for (const source of rows) { - const { text, missing } = interpolate(el.itemTemplate, source); + const { text, missing } = interpolate(el.itemTemplate, { ...source, ...listChairs }); for (const name of missing) { if (!missingColumns.includes(name)) missingColumns.push(name); } @@ -259,6 +296,40 @@ export function resolveCard( break; } + case "qr": { + const { text, missing } = interpolate(el.data, seated(el.id, el.data, row)); + for (const name of missing) { + warnings.push({ elementId: el.id, kind: "missing-field", detail: `No column named "${name}".` }); + } + if (!text.trim()) { + warnings.push({ + elementId: el.id, + kind: "empty-text", + detail: el.data.includes("Guest Link") + ? "There is no guest link to point at yet. Publish one from the Data menu, and the code fills itself in." + : "This code has nothing to say.", + }); + break; + } + const code = qrPath(text); + elements.push({ ...base, kind: "icon", pathD: code.pathD, cutD: null, view: code.view, colorHex: el.colorHex, cutHex: el.colorHex }); + break; + } + + case "room": { + const resolved = resolveRoom(el, row, card, opts, template); + warnings.push(...resolved.warnings); + elements.push(...resolved.elements); + break; + } + + case "grid": { + const resolved = resolveGrid(el, rows, card, opts); + warnings.push(...resolved.warnings); + elements.push(...resolved.elements); + break; + } + case "rect": elements.push({ ...base, @@ -324,6 +395,152 @@ export function resolveCard( return { scene: { elements, backgroundHex: template.backgroundHex }, warnings }; } +/** A grid's heading pieces are `/h`, its lines `/i`. */ +export const GRID_HEADING = "/h"; +const GRID_LINES = "/i"; + +/** + * A grid's blocks, cut into equal cells left to right then down, each a + * heading and a stack of lines as ordinary text. Every cell is placed through + * the fold on its own, so a grid on a folded card lands as any element would. + */ +function resolveGrid( + el: GridElement, + rows: GuestRow[], + card: CardSpec, + opts: ResolveOptions, +): { elements: ResolvedElement[]; warnings: CardWarning[] } { + const warnings: CardWarning[] = []; + const { blocks, leftOut, missing } = gridBlocks(el, rows); + if (leftOut > 0) { + warnings.push({ + elementId: el.id, + kind: "left-out", + detail: + leftOut === 1 + ? `One row has no ${el.groupBy}, so it is not on this grid.` + : `${leftOut} rows have no ${el.groupBy}, so they are not on this grid.`, + }); + } + for (const name of missing) { + warnings.push({ elementId: el.id, kind: "missing-field", detail: `No column named "${name}".` }); + } + if (blocks.length === 0) { + warnings.push({ elementId: el.id, kind: "empty-text", detail: "This grid has no blocks to show." }); + return { elements: [], warnings }; + } + + const flowing = el.layout === "columns"; + const columns = Math.max(1, Math.round(el.columns)); + const across = flowing ? columns : Math.min(columns, blocks.length); + const down = flowing ? 1 : Math.ceil(blocks.length / across); + const cell = { + w: (el.w - el.gapMm * (across - 1)) / across, + h: (el.h - el.gapMm * (down - 1)) / down, + }; + // Columns are laid out at the size asked for, so a long list carries on + // over the page rather than shrinking into illegibility; only a line too + // wide for its column makes the text smaller. + const fit = opts.fitGrid?.( + el, + blocks.map((block) => ({ heading: block.heading, items: block.lines })), + flowing ? { w: cell.w, h: Number.POSITIVE_INFINITY } : cell, + ) ?? { fontSizePt: el.fontSizePt, overflowed: false, missingFont: true }; + if (fit.missingFont) { + warnings.push({ + elementId: el.id, + kind: "missing-font", + detail: `The font "${assetLabel(opts, el.fontId)}" or "${assetLabel(opts, el.headingFontId)}" is not on this device, so this grid cannot be sized correctly.`, + }); + } + if (fit.overflowed) { + warnings.push({ + elementId: el.id, + kind: "overflow", + detail: flowing + ? `Some lines are too wide for their column at ${el.fit.minFontSizePt}pt. Try fewer columns or a bigger box.` + : `${blocks.length} blocks do not fit at ${el.fit.minFontSizePt}pt. Try more columns or a bigger box.`, + }); + } + + const headingPt = fit.fontSizePt * el.headingScale; + // Slots are planned at the size asked for; text shrunk for width sits in them. + const headingH = ptToMm((flowing ? el.fontSizePt : fit.fontSizePt) * el.headingScale * el.lineHeight); + const lineH = ptToMm(el.fontSizePt * el.lineHeight); + const elements: ResolvedElement[] = []; + const text = (id: string, box: { x: number; y: number; w: number; h: number }, piece: { + lines: string[]; + fontId: string; + fontSizePt: Pt; + colorHex: string; + }): ResolvedElement => { + const placed = transformForPanel(box, card); + return { + id: `${el.id}${id}`, + sourceId: el.id, + ...placed.box, + rotationDeg: placed.rotationDeg, + z: el.z, + kind: "text", + ...piece, + align: el.align, + vAlign: "top", + anchor: "align", + lineHeight: el.lineHeight, + letterSpacingMm: el.letterSpacingMm, + overflowed: fit.overflowed, + }; + }; + const heading = (n: number, x: number, y: number, block: { heading: string }) => + text(`${GRID_HEADING}${n}`, { x, y, w: cell.w, h: headingH }, { + lines: block.heading ? [block.heading] : [], + fontId: el.headingFontId, + fontSizePt: headingPt, + colorHex: el.headingColorHex, + }); + const body = (n: number, x: number, y: number, h: number, lines: string[]) => + text(`${GRID_LINES}${n}`, { x, y, w: cell.w, h }, { + lines, + fontId: el.fontId, + fontSizePt: fit.fontSizePt, + colorHex: el.colorHex, + }); + + if (!flowing) { + blocks.forEach((block, index) => { + const x = el.x + (index % across) * (cell.w + el.gapMm); + const y = el.y + Math.floor(index / across) * (cell.h + el.gapMm); + elements.push(heading(index, x, y, block), body(index, x, y + headingH, Math.max(0, cell.h - headingH), block.lines)); + }); + return { elements, warnings }; + } + + // What is here is one page's worth: `withParts` cut the list to fit. Anything + // the plan still puts on a later page did not fit at all, and is said so. + const runs = flowPlan(el, blocks.map((block) => block.lines.length)); + const spilt = runs.filter((run) => run.page > 0).reduce((sum, run) => sum + run.to - run.from, 0); + if (spilt > 0) { + warnings.push({ + elementId: el.id, + kind: "overflow", + detail: `${spilt} lines do not fit in these columns.`, + }); + } + runs + .filter((run) => run.page === 0) + .forEach((run, n) => { + const x = el.x + run.column * (cell.w + el.gapMm); + let y = el.y + run.y; + if (run.heading) { + elements.push(heading(n, x, y, blocks[run.block]!)); + y += headingH; + } + const lines = blocks[run.block]!.lines.slice(run.from, run.to); + if (lines.length > 0) elements.push(body(n, x, y, lines.length * lineH, lines)); + }); + return { elements, warnings }; +} + /** The filename the user knows the asset by, falling back to its id. */ function assetLabel(opts: ResolveOptions, id: string): string { return opts.assetName?.(id) ?? id; diff --git a/suite/apps/plaque/core/template/chairs.test.ts b/suite/apps/plaque/core/template/chairs.test.ts new file mode 100644 index 00000000..7be0d066 --- /dev/null +++ b/suite/apps/plaque/core/template/chairs.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; +import type { RoomScene, RoomTable, TextElement } from "../types"; +import { noFit, resolveCard } from "./bindings"; +import { chairName, chairRef, chairToken, chairValues } from "./chairs"; +import { unboundTokens } from "./rebind"; + +const sitter = (first: string, last: string) => ({ "First Name": first, "Last Name": last }); +const table = (label: string, numbered: boolean, names: Array<[string, string] | null>): RoomTable => ({ + label, + x: 0, + y: 0, + rotationDeg: 0, + pathD: "", + view: { x: 0, y: 0, w: 1, h: 1 }, + numbered, + interior: { x: 0, y: 0, w: 1, h: 1 }, + seats: names.map((name, i) => ({ x: 0, y: 0, out: { x: 0, y: 1 }, number: i + 1, row: name ? sitter(...name) : null })), +}); +const scene: RoomScene = { + bounds: { x: 0, y: 0, w: 1, h: 1 }, + walls: [], + seatRadius: 1, + tables: [ + table("Table 1", true, [["Ada", "Byron"], null, ["Grace", "Hopper"]]), + table("Table 2", true, [["Alan", "Turing"]]), + table("Table 3", false, [["Joan", "Clarke"]]), + ], +}; + +describe("naming a chair", () => { + it("reads both forms, and leaves an ordinary column alone", () => { + expect(chairRef("At seat 3")).toEqual({ table: null, seat: 3 }); + expect(chairRef("at seat 12")).toEqual({ table: null, seat: 12 }); + expect(chairRef("Table 1, seat 3")).toEqual({ table: "Table 1", seat: 3 }); + expect(chairRef("Top table,seat 2")).toEqual({ table: "Top table", seat: 2 }); + expect(chairRef("Seat")).toBeNull(); + expect(chairRef("First Name")).toBeNull(); + expect(chairToken({ table: null, seat: 3 })).toBe("At seat 3"); + expect(chairToken({ table: "Table 1", seat: 3 })).toBe("Table 1, seat 3"); + }); +}); + +describe("a guest known by a name of their own", () => { + it("is called it wherever the design's name format would name them", () => { + const format = { chairName: "{{First Name}}" }; + expect(chairName(format, { "First Name": "Josephine", "Last Name": "Clarke", "Known As": "Granny Jo" })).toBe("Granny Jo"); + expect(chairName(format, { "First Name": "Josephine", "Last Name": "Clarke", "Known As": "" })).toBe("Josephine"); + }); +}); + +describe("{{Known As}} on a card", () => { + const card = { widthMm: 90, heightMm: 55, fold: "none", foldPositionMm: 0, invertBackPanel: false, bleedMm: 0 } as const; + const text = { kind: "text", id: "t", x: 0, y: 0, w: 80, h: 20, z: 0, template: "{{Known As}}", fontId: "crimson", fontSizePt: 12, align: "center", vAlign: "middle", lineHeight: 1.2, colorHex: "#000000", letterSpacingMm: 0, fit: { mode: "shrink", minFontSizePt: 6, maxLines: 1, anchor: "align" } } as TextElement; + const say = (row: Record, chairName?: string) => + resolveCard({ backgroundHex: null, elements: [text], ...(chairName ? { chairName } : {}) }, row, card, { fitText: noFit, iconPath: () => null }).scene.elements.flatMap((el) => (el.kind === "text" ? el.lines : [])); + + it("is the guest's own name, else the design's name format", () => { + expect(say({ "First Name": "Josephine", "Last Name": "Clarke", "Known As": "Granny Jo" })).toEqual(["Granny Jo"]); + expect(say({ "First Name": "Josephine", "Last Name": "Clarke", "Known As": "" })).toEqual(["Josephine Clarke"]); + expect(say({ "First Name": "Josephine", "Last Name": "Clarke", "Known As": "" }, "{{First Name}}")).toEqual(["Josephine"]); + }); +}); + +describe("whoever sits there now", () => { + const values = (tokens: string[], cardTable: string, chairName?: string) => + chairValues(tokens, { Table: cardTable }, scene, chairName === undefined ? {} : { chairName }); + + it("is the sitter of that seat of the card's own table, named as the design names people", () => { + expect(values(["At seat 1", "At seat 3"], "Table 1").values).toEqual({ "At seat 1": "Ada Byron", "At seat 3": "Grace Hopper" }); + expect(values(["At seat 1"], "Table 2", "{{First Name}}").values).toEqual({ "At seat 1": "Alan" }); + }); + + it("is that one chair in the room, whatever table the card is for", () => { + expect(values(["Table 2, seat 1"], "Table 1").values).toEqual({ "Table 2, seat 1": "Alan Turing" }); + }); + + it("is nothing for an empty chair, or a seat a smaller table does not have, and says nothing either", () => { + const { values: got, problems } = values(["At seat 2", "At seat 9"], "Table 1"); + expect(got).toEqual({ "At seat 2": "", "At seat 9": "" }); + expect(problems).toEqual([]); + }); + + it("says so when a named chair is not there", () => { + expect(values(["Table 2, seat 5"], "Table 1").problems).toEqual(["Table 2 has one seat, so there is no seat 5."]); + expect(values(["Table 9, seat 1"], "Table 1").problems).toEqual(['The plan has no table called "Table 9".']); + }); + + it("never names a seat at a table where guests sit where they like", () => { + const { values: got, problems } = values(["At seat 1"], "Table 3"); + expect(got).toEqual({ "At seat 1": "" }); + // One message for a design, whichever table's card says it. + expect(problems).toEqual(["At tables where guests sit where they like there are no numbered seats, so a seat says nothing there."]); + expect(values(["Table 3, seat 1"], "Table 1").problems).toEqual(["Table 3 seats its guests where they like, so it has no seat 1."]); + }); +}); + +describe("on a card", () => { + const text = (template: string): TextElement => ({ + kind: "text", + id: "t", + x: 0, + y: 0, + w: 50, + h: 10, + z: 0, + template, + fontId: "crimson", + fontSizePt: 12, + align: "center", + vAlign: "middle", + lineHeight: 1.2, + colorHex: "#000000", + letterSpacingMm: 0, + fit: { mode: "shrink", minFontSizePt: 6, maxLines: 1, anchor: "align" }, + }); + const card = { widthMm: 100, heightMm: 100, fold: "none" as const, foldPositionMm: 0, invertBackPanel: false, bleedMm: 0 }; + + it("fills in on every table's card from one design", () => { + const resolve = (Table: string) => + resolveCard({ elements: [text("Opposite: {{At seat 1}}")], backgroundHex: null }, { Table }, card, { + fitText: noFit, + iconPath: () => null, + room: () => scene, + }).scene.elements[0]; + expect(resolve("Table 1")).toMatchObject({ lines: ["Opposite: Ada Byron"] }); + expect(resolve("Table 2")).toMatchObject({ lines: ["Opposite: Alan Turing"] }); + }); + + it("is not a missing column, so it never blocks a print", () => { + const template = { elements: [text("{{At seat 1}} and {{Table 2, seat 1}}")], backgroundHex: null }; + expect(unboundTokens(template, ["Name", "Table"])).toEqual([]); + // A seat of the card's own table needs the card to have a table. + expect(unboundTokens(template, ["Name"])).toEqual(["Table"]); + }); +}); diff --git a/suite/apps/plaque/core/template/chairs.ts b/suite/apps/plaque/core/template/chairs.ts new file mode 100644 index 00000000..6117a545 --- /dev/null +++ b/suite/apps/plaque/core/template/chairs.ts @@ -0,0 +1,115 @@ +import { interpolate } from "../csv/interpolate"; +import { normalise } from "../data/artefacts"; +import type { GuestRow } from "../data/rows"; +import type { RoomScene, RoomTable, Template } from "../types"; + +/** + * Chairs, by token: a design that names whoever sits in a seat rather than the + * card's own guest. + * + * `{{At seat 3}}` is seat 3 of the card's own table — one design, every table + * fills in its own. `{{Table 1, seat 3}}` is that one chair in the room. Both + * say whoever sits there now, through the design's name format, and nothing for + * an empty chair. A table where guests sit where they like has no seat 3, so + * there they say nothing and the warning says why: a card must never imply a + * seat that is not real. + */ + +/** Seat `seat` of the card's own table (`table: null`), or of the table so named. */ +export interface ChairRef { + table: string | null; + seat: number; +} + +/** The whole name, when a design has not said otherwise. */ +export const DEFAULT_CHAIR_NAME = "{{First Name}} {{Last Name}}"; + +const AT_SEAT = /^at seat (\d+)$/i; +const TABLE_SEAT = /^(.+?),\s*seat (\d+)$/i; + +/** The chair a token names, or null when it is an ordinary column. */ +export function chairRef(token: string): ChairRef | null { + const at = AT_SEAT.exec(token.trim()); + if (at) return { table: null, seat: Number(at[1]) }; + const of = TABLE_SEAT.exec(token.trim()); + if (of) return { table: of[1]!.trim(), seat: Number(of[2]) }; + return null; +} + +/** The token for a chair, as it is written into a design. */ +export function chairToken(ref: ChairRef): string { + return ref.table === null ? `At seat ${ref.seat}` : `${ref.table}, seat ${ref.seat}`; +} + +/** How a design names a sitter: the name they are known by, else its format applied to their row. */ +export function chairName(template: Pick, row: GuestRow): string { + return row["Known As"] || interpolate(template.chairName ?? DEFAULT_CHAIR_NAME, row).text; +} + +/** + * A row as a design prints it: `{{Known As}}` is the name the guest is known + * by, else the design's name format applied to them. Every card and every + * reprint check reads rows through this, so the two cannot disagree. + */ +export function asKnown(template: Pick, row: GuestRow): GuestRow { + return { ...row, "Known As": chairName(template, row) }; +} + +/** The table a chair is at: the card's own, or the one named. */ +export function chairTable(ref: ChairRef, scene: RoomScene, cardTable: string): RoomTable | null { + const wanted = normalise(ref.table ?? cardTable); + return scene.tables.find((t) => normalise(t.label) === wanted) ?? null; +} + +/** + * Each chair token in `tokens`, as the card for `row` says it. A table-wise + * seat past the end of a smaller table is simply empty — one design serves + * tables of every size; anything else that cannot be said comes back as a + * problem, once. + */ +export function chairValues( + tokens: string[], + row: GuestRow, + scene: RoomScene | null, + template: Pick, +): { values: GuestRow; problems: string[] } { + const values: GuestRow = {}; + const problems: string[] = []; + for (const token of tokens) { + const ref = chairRef(token); + if (!ref) continue; + values[token] = ""; + if (!scene) { + problems.push("There is no seating plan to read the chairs from yet."); + continue; + } + const table = chairTable(ref, scene, row["Table"] ?? ""); + if (!table) { + problems.push( + ref.table === null + ? row["Table"] + ? `The plan has no table called "${row["Table"]}".` + : "This card is not for a table, so it has no seats." + : `The plan has no table called "${ref.table}".`, + ); + continue; + } + if (!table.numbered) { + // Said once for a design, not once per table card: the same words for + // every table that leaves its guests to sit where they like. + problems.push( + ref.table === null + ? "At tables where guests sit where they like there are no numbered seats, so a seat says nothing there." + : `${table.label} seats its guests where they like, so it has no seat ${ref.seat}.`, + ); + continue; + } + const seat = table.seats[ref.seat - 1]; + if (!seat) { + if (ref.table !== null) problems.push(`${table.label} has ${table.seats.length === 1 ? "one seat" : `${table.seats.length} seats`}, so there is no seat ${ref.seat}.`); + continue; + } + if (seat.row) values[token] = chairName(template, seat.row); + } + return { values, problems: [...new Set(problems)] }; +} diff --git a/suite/apps/plaque/core/template/defaults.ts b/suite/apps/plaque/core/template/defaults.ts index 22201a26..cfa0799c 100644 --- a/suite/apps/plaque/core/template/defaults.ts +++ b/suite/apps/plaque/core/template/defaults.ts @@ -60,6 +60,7 @@ export function defaultSheet(): SheetSpec { gapYMm: 5, cardRotationDeg: 0, printerMarginMm: 5, + tilePaper: "A4", cropMarks: true, cutLines: true, foldGuides: true, diff --git a/suite/apps/plaque/core/template/grid.test.ts b/suite/apps/plaque/core/template/grid.test.ts new file mode 100644 index 00000000..04816310 --- /dev/null +++ b/suite/apps/plaque/core/template/grid.test.ts @@ -0,0 +1,152 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { BUNDLED_FONTS } from "../../assets/fonts"; +import { loadFont, type LoadedFont } from "../text/measure"; +import { fitGrid } from "../text/fit"; +import type { CardSpec, GridElement, ResolvedText } from "../types"; +import { GRID_HEADING, resolveCard } from "./bindings"; +import { makeResolveOptions } from "./resolve"; + +/** + * The seating board's grid: a block per table, every block the same size, as + * large as the fullest table allows — resolved to plain text so neither + * renderer has to know it exists. + */ +const fonts = new Map( + BUNDLED_FONTS.map((f) => [f.id, loadFont(f.id, f.family, new Uint8Array(readFileSync(`public/fonts/${f.file}`)))]), +); +const resolve = makeResolveOptions(fonts); + +const board: CardSpec = { widthMm: 594, heightMm: 841, fold: "none", foldPositionMm: 420, invertBackPanel: false, bleedMm: 0 }; + +const grid = (over: Partial = {}): GridElement => ({ + kind: "grid", + id: "g", + x: 20, + y: 20, + w: 554, + h: 800, + z: 3, + groupBy: "Table", + headingTemplate: "{{Table}}", + itemTemplate: "{{Name}}", + sortBy: "", + layout: "cells", + columns: 2, + gapMm: 10, + fontId: "crimson", + fontSizePt: 24, + headingFontId: "marcellus", + headingScale: 1.5, + headingColorHex: "#7a5c3a", + align: "center", + lineHeight: 1.3, + colorHex: "#000000", + letterSpacingMm: 0, + fit: { mode: "shrink", minFontSizePt: 6, maxLines: 1, anchor: "align" }, + ...over, +}); + +const row = (Name: string, Table: string) => ({ Name, Table }); +const rows = [row("Ada", "Table 10"), row("Bo", "Table 2"), row("Cy", "Table 2"), row("Di", "")]; + +const resolveGrid = (el: GridElement, data = rows) => { + const { scene, warnings } = resolveCard({ elements: [el], backgroundHex: null }, data[0]!, board, resolve, data); + return { pieces: scene.elements as ResolvedText[], warnings }; +}; + +describe("a seating grid", () => { + it("gives each table a heading and its guests, in the order people read table names", () => { + const { pieces } = resolveGrid(grid()); + const headings = pieces.filter((p) => p.id.startsWith(`g${GRID_HEADING}`)).map((p) => p.lines); + const lines = pieces.filter((p) => !p.id.startsWith(`g${GRID_HEADING}`)).map((p) => p.lines); + expect(headings).toEqual([["Table 2"], ["Table 10"]]); + expect(lines).toEqual([["Bo", "Cy"], ["Ada"]]); + }); + + it("names every piece after the grid, so selecting any selects the grid", () => { + const { pieces } = resolveGrid(grid()); + expect(new Set(pieces.map((p) => p.sourceId))).toEqual(new Set(["g"])); + expect(new Set(pieces.map((p) => p.id)).size).toBe(pieces.length); + }); + + it("lays blocks out in columns of equal cells, left to right then down", () => { + const { pieces } = resolveGrid(grid(), [row("A", "1"), row("B", "2"), row("C", "3")]); + const headings = pieces.filter((p) => p.id.startsWith(`g${GRID_HEADING}`)); + // Two columns of (554 - 10) / 2 = 272mm. + expect(headings.map((h) => [h.x, h.y, h.w])).toEqual([ + [20, 20, 272], + [302, 20, 272], + [20, 425, 272], + ]); + }); + + it("sets every table at one size, the heading a multiple of it", () => { + const { pieces } = resolveGrid(grid()); + const sizes = new Set(pieces.filter((p) => !p.id.startsWith(`g${GRID_HEADING}`)).map((p) => p.fontSizePt)); + expect(sizes.size).toBe(1); + const [size] = sizes; + expect(pieces.find((p) => p.id.startsWith(`g${GRID_HEADING}`))!.fontSizePt).toBeCloseTo(size! * 1.5); + }); + + it("leaves off a guest with no table, and says so rather than printing a block for nobody", () => { + const { pieces, warnings } = resolveGrid(grid()); + expect(pieces.flatMap((p) => p.lines)).not.toContain("Di"); + expect(warnings).toContainEqual(expect.objectContaining({ kind: "left-out", detail: expect.stringMatching(/One row has no Table/) })); + }); + + it("shrinks every block together until the fullest fits, and reports what will not", () => { + const crowded = Array.from({ length: 40 }, (_, i) => row(`Guest number ${i}`, "Table 1")); + const small = resolveGrid(grid({ h: 200 }), crowded); + const size = small.pieces.find((p) => !p.id.startsWith(`g${GRID_HEADING}`))!.fontSizePt; + expect(size).toBeLessThan(24); + expect(small.warnings.filter((w) => w.kind === "overflow")).toEqual([]); + + const impossible = resolveGrid(grid({ h: 40 }), crowded); + expect(impossible.warnings).toContainEqual(expect.objectContaining({ kind: "overflow" })); + }); + + it("says the font is missing rather than guessing a size", () => { + const { warnings } = resolveGrid(grid({ headingFontId: "user:gone" })); + expect(warnings).toContainEqual(expect.objectContaining({ kind: "missing-font" })); + }); +}); + +describe("fitGrid", () => { + const crimson = fonts.get("crimson")!; + const config = { mode: "shrink" as const, minFontSizePt: 6, maxLines: 1, anchor: "align" as const }; + + it("keeps the size asked for when every block fits", () => { + const blocks = [{ heading: "Table 1", items: ["Ada", "Bo"] }]; + expect( + fitGrid(crimson, crimson, { blocks, cellWMm: 100, cellHMm: 100, fontSizePt: 12, headingScale: 1.5, lineHeight: 1.2, letterSpacingMm: 0, fit: config }), + ).toEqual({ fontSizePt: 12, overflowed: false }); + }); + + it("is held back by the widest heading as much as by the longest line", () => { + const blocks = [{ heading: "The Long Table by the Window", items: ["Ada"] }]; + const { fontSizePt } = fitGrid(crimson, crimson, { blocks, cellWMm: 60, cellHMm: 100, fontSizePt: 24, headingScale: 1.5, lineHeight: 1.2, letterSpacingMm: 0, fit: config }); + expect(fontSizePt).toBeLessThan(24); + }); +}); + +describe("the order of a board's tables", () => { + it("is the room's: the top table first, then 2 before 10", async () => { + const { gridBlocks } = await import("./grid"); + const row = (Name: string, Table: string, number: string) => ({ Name, Table, "Table Number": number }); + const { blocks } = gridBlocks( + { groupBy: "Table", headingTemplate: "{{Table}}", itemTemplate: "{{Name}}", sortBy: "" }, + [row("Ada", "Table 10", "3"), row("Bo", "Top table", "1"), row("Cy", "Table 2", "2")], + ); + expect(blocks.map((b) => b.heading)).toEqual(["Top table", "Table 2", "Table 10"]); + }); + + it("is as people read them for anything else a board groups by", async () => { + const { gridBlocks } = await import("./grid"); + const { blocks } = gridBlocks( + { groupBy: "Initial", headingTemplate: "{{Initial}}", itemTemplate: "{{Name}}", sortBy: "" }, + [{ Name: "Zed", Initial: "Z" }, { Name: "Ada", Initial: "A" }], + ); + expect(blocks.map((b) => b.heading)).toEqual(["A", "Z"]); + }); +}); diff --git a/suite/apps/plaque/core/template/grid.ts b/suite/apps/plaque/core/template/grid.ts new file mode 100644 index 00000000..34a7fa25 --- /dev/null +++ b/suite/apps/plaque/core/template/grid.ts @@ -0,0 +1,158 @@ +import { interpolate } from "../csv/interpolate"; +import type { GuestRow } from "../data/rows"; +import { ptToMm } from "../units"; +import type { GridElement, Mm } from "../types"; + +export interface GridBlock { + heading: string; + /** The rows with a line to print, in the order they print. */ + rows: GuestRow[]; + lines: string[]; +} + +/** + * A grid's blocks: rows grouped by its column, tables in the room's own order + * (the top table first, then Table 2 before Table 10) and anything else in + * the order people read it (A before B), the lines in each + * ordered by `sortBy` and then by the room's own order. A row with nothing in + * the column is left out and counted; a row whose line comes out empty has + * nothing to print and is dropped. + */ +export function gridBlocks( + el: Pick, + rows: GuestRow[], +): { blocks: GridBlock[]; leftOut: number; missing: string[] } { + const groups = new Map(); + let leftOut = 0; + for (const row of rows) { + const value = (row[el.groupBy] ?? "").trim(); + if (!value) { + leftOut += 1; + continue; + } + groups.set(value, [...(groups.get(value) ?? []), row]); + } + + const missing = new Set(); + const fill = (template: string, row: GuestRow) => { + const { text, missing: names } = interpolate(template, row); + for (const name of names) missing.add(name); + return text; + }; + const byColumn = (a: GuestRow, b: GuestRow) => + el.sortBy ? (a[el.sortBy] ?? "").localeCompare(b[el.sortBy] ?? "", "en", { sensitivity: "base" }) : 0; + + const place = (members: GuestRow[]) => (el.groupBy === "Table" ? Number(members[0]!["Table Number"]) : 0); + const blocks = [...groups.entries()] + .sort(([a, am], [b, bm]) => place(am) - place(bm) || a.localeCompare(b, undefined, { numeric: true })) + .map(([, members]) => { + const printed = [...members] + .sort(byColumn) + .map((row) => ({ row, line: fill(el.itemTemplate, row) })) + .filter(({ line }) => line.trim().length > 0); + return { + heading: fill(el.headingTemplate, members[0]!), + rows: printed.map(({ row }) => row), + lines: printed.map(({ line }) => line), + }; + }); + return { blocks, leftOut, missing: [...missing] }; +} + +/** + * Where everything in a `columns` grid goes, from line heights alone. + * + * Arithmetic only — no font — so the same plan decides both how a long list is + * cut into pages (`withParts`) and where each line lands on each page + * (`resolveCard`), and the two cannot disagree. + * + * Blocks stack down a column, a gap above each heading but the column's first; + * a heading never sits alone at the foot of a column; a block too long for + * what is left carries on at the top of the next column, then the next page. + */ +export interface FlowRun { + page: number; + column: number; + /** From the top of the grid's box. */ + y: Mm; + block: number; + /** The heading, then these lines of the block, `from` inclusive, `to` exclusive. */ + heading: boolean; + from: number; + to: number; +} + +type FlowSpec = Pick; + +/** + * The plan. A list that fits on one page is balanced — laid out in the + * shortest columns that still hold it — so it does not fill two columns and + * leave the third bare; a longer one fills each page in turn. + */ +export function flowPlan(el: FlowSpec, counts: number[]): FlowRun[] { + const full = flowInto(el, counts); + if (pagesIn(full) > 1) return full; + let low = 0; + let high = el.h; + for (let i = 0; i < 24; i++) { + const mid = (low + high) / 2; + if (pagesIn(flowInto({ ...el, h: mid }, counts)) === 1) high = mid; + else low = mid; + } + return flowInto({ ...el, h: high }, counts); +} + +function pagesIn(runs: FlowRun[]): number { + return Math.max(0, ...runs.map((run) => run.page)) + 1; +} + +function flowInto(el: FlowSpec, counts: number[]): FlowRun[] { + const lineH = ptToMm(el.fontSizePt * el.lineHeight); + const headingH = ptToMm(el.fontSizePt * el.headingScale * el.lineHeight); + const columns = Math.max(1, Math.round(el.columns)); + const runs: FlowRun[] = []; + + let page = 0; + let column = 0; + let y = 0; + const nextColumn = () => { + column += 1; + if (column === columns) { + column = 0; + page += 1; + } + y = 0; + }; + const fits = (height: Mm) => y + height <= el.h + 1e-6; + + counts.forEach((count, block) => { + if (y > 0) y += el.gapMm; + // A heading needs at least its first line under it, or it moves on. + if (y > 0 && !fits(headingH + Math.min(1, count) * lineH)) nextColumn(); + let heading = true; + let from = 0; + while (from < count || heading) { + const top = y; + if (heading) y += headingH; + let to = from; + while (to < count && fits(lineH)) { + y += lineH; + to += 1; + } + // Nothing placed in an empty column means a line (with its heading) + // taller than the box: place it anyway rather than looping, and let the + // fit report it. + if (to === from && from < count && top === 0) { + y += lineH; + to += 1; + } + runs.push({ page, column, y: top, block, heading, from, to }); + from = to; + if (from < count) nextColumn(); + // A letter carried onto a new page is headed again: the reader starts + // there, and each page then plans alone exactly as it does here. + heading = from < count && column === 0; + } + }); + return runs; +} diff --git a/suite/apps/plaque/core/template/qr.test.ts b/suite/apps/plaque/core/template/qr.test.ts new file mode 100644 index 00000000..bcc4c9ff --- /dev/null +++ b/suite/apps/plaque/core/template/qr.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import type { CardSpec, QrElement, ResolvedIcon } from "../types"; +import { noFit, resolveCard } from "./bindings"; +import { QUIET_MODULES, qrPath } from "./qr"; + +const card: CardSpec = { widthMm: 90, heightMm: 50, fold: "none", foldPositionMm: 0, invertBackPanel: false, bleedMm: 0 }; +const qr = (over: Partial = {}): QrElement => ({ + kind: "qr", + id: "q", + x: 60, + y: 10, + w: 25, + h: 25, + z: 0, + data: "{{Guest Link}}", + colorHex: "#000000", + ...over, +}); +const resolve = (el: QrElement, row: Record) => + resolveCard({ elements: [el], backgroundHex: null }, row, card, { fitText: noFit, iconPath: () => null }); + +describe("a QR code", () => { + it("is a square of modules with its quiet zone, as one path", () => { + const { pathD, view } = qrPath("https://example.org/seat/abc#k=xyz"); + expect(view.w).toBe(view.h); + expect(view.x).toBe(-QUIET_MODULES); + // Version 3 at level M: 29 modules. + expect(view.w - QUIET_MODULES * 2).toBe(29); + // The top-left finder pattern starts with a seven-module run. + expect(pathD.startsWith("M0 0h7v1h-7z")).toBe(true); + }); + + it("takes any text, accents and all", () => { + expect(() => qrPath("Zoë & Søren — 结婚")).not.toThrow(); + expect(qrPath("Zoë")).not.toEqual(qrPath("Zoe")); + }); + + it("resolves to a filled shape from its row", () => { + const { scene, warnings } = resolve(qr(), { "Guest Link": "https://example.org/seat/abc" }); + expect(warnings).toEqual([]); + expect(scene.elements[0]).toMatchObject({ kind: "icon", x: 60, y: 10, w: 25, h: 25 }); + expect((scene.elements[0] as ResolvedIcon).pathD).toBeTruthy(); + }); + + it("says how to get a guest link rather than printing an empty code", () => { + const { scene, warnings } = resolve(qr(), { "Guest Link": "" }); + expect(scene.elements).toEqual([]); + expect(warnings).toContainEqual(expect.objectContaining({ kind: "empty-text", detail: expect.stringMatching(/Publish one from the Data menu/) })); + }); +}); diff --git a/suite/apps/plaque/core/template/qr.ts b/suite/apps/plaque/core/template/qr.ts new file mode 100644 index 00000000..186efddf --- /dev/null +++ b/suite/apps/plaque/core/template/qr.ts @@ -0,0 +1,40 @@ +import qrcode from "qrcode-generator"; + +/** The clear border a reader needs round a code, in modules. */ +export const QUIET_MODULES = 4; + +/** + * A QR code as one filled path, in module units, with its quiet zone in the + * view: vector, so it prints sharp at any size and is drawn by the icon + * pipeline both renderers already have. + * + * Text goes in as UTF-8 bytes. The library's own conversion keeps only the + * low byte of each character, which turns anything past Latin-1 into a + * different string that still scans. + */ +export function qrPath(text: string): { pathD: string; view: { x: number; y: number; w: number; h: number } } { + const code = qrcode(0, "M"); + code.addData(String.fromCharCode(...new TextEncoder().encode(text)), "Byte"); + code.make(); + const size = code.getModuleCount(); + + // One rectangle per run of dark modules along a row: a fraction of the + // path a square per module would make. + const parts: string[] = []; + for (let row = 0; row < size; row++) { + let col = 0; + while (col < size) { + if (!code.isDark(row, col)) { + col += 1; + continue; + } + const start = col; + while (col < size && code.isDark(row, col)) col += 1; + parts.push(`M${start} ${row}h${col - start}v1h${start - col}z`); + } + } + return { + pathD: parts.join(""), + view: { x: -QUIET_MODULES, y: -QUIET_MODULES, w: size + QUIET_MODULES * 2, h: size + QUIET_MODULES * 2 }, + }; +} diff --git a/suite/apps/plaque/core/template/rebind.test.ts b/suite/apps/plaque/core/template/rebind.test.ts index 2dc6c191..9ce4dac0 100644 --- a/suite/apps/plaque/core/template/rebind.test.ts +++ b/suite/apps/plaque/core/template/rebind.test.ts @@ -152,3 +152,11 @@ describe("unboundTokens", () => { expect(unboundTokens(template([text("{{Table}}")]), ["Table"])).toEqual([]); }); }); + +describe("rebinding a board", () => { + it("moves the column its lines are sorted by, as it moves the one they are grouped by", () => { + const grid = { kind: "grid", id: "g", x: 0, y: 0, w: 10, h: 10, z: 0, groupBy: "Table", sortBy: "Last Name", headingTemplate: "{{Table}}", itemTemplate: "{{First Name}} {{Last Name}}" } as never; + const { template } = rebindTemplate({ backgroundHex: null, elements: [grid] }, ["First Name", "Last Name", "Table"], ["First", "Surname", "Table"]); + expect(template.elements[0]).toMatchObject({ sortBy: "Surname", itemTemplate: "{{First}} {{Surname}}" }); + }); +}); diff --git a/suite/apps/plaque/core/template/rebind.ts b/suite/apps/plaque/core/template/rebind.ts index 476705fc..6048f52e 100644 --- a/suite/apps/plaque/core/template/rebind.ts +++ b/suite/apps/plaque/core/template/rebind.ts @@ -1,3 +1,4 @@ +import { chairRef } from "./chairs"; import { guessMapping, type FieldGuesses } from "../csv/guessMapping"; import { tokensIn } from "../csv/interpolate"; import type { CardElement, Template } from "../types"; @@ -63,7 +64,8 @@ export function rebindTemplate( const rewrite = (text: string): string => text.replace(/\{\{\s*([^{}]*?)\s*\}\}/g, (whole, name: string) => { - if (!name) return whole; + // A chair is not a column: it means the same whatever the columns are called. + if (!name || chairRef(name)) return whole; const target = resolve(name); if (!target) { if (!unmatched.includes(name)) unmatched.push(name); @@ -76,6 +78,22 @@ export function rebindTemplate( const elements: CardElement[] = template.elements.map((el) => { if (el.kind === "text") return { ...el, template: rewrite(el.template) }; if (el.kind === "list") return { ...el, itemTemplate: rewrite(el.itemTemplate) }; + if (el.kind === "qr") return { ...el, data: rewrite(el.data) }; + if (el.kind === "grid") { + const target = resolve(el.groupBy); + if (!target && !unmatched.includes(el.groupBy)) unmatched.push(el.groupBy); + if (target && target !== el.groupBy) renamed[el.groupBy] = target; + const sortTarget = el.sortBy ? resolve(el.sortBy) : null; + if (el.sortBy && !sortTarget && !unmatched.includes(el.sortBy)) unmatched.push(el.sortBy); + if (sortTarget && sortTarget !== el.sortBy) renamed[el.sortBy] = sortTarget; + return { + ...el, + groupBy: target ?? el.groupBy, + sortBy: sortTarget ?? el.sortBy, + headingTemplate: rewrite(el.headingTemplate), + itemTemplate: rewrite(el.itemTemplate), + }; + } if (el.kind === "icon") { const target = el.sourceField ? resolve(el.sourceField) : null; if (el.sourceField && !target && !unmatched.includes(el.sourceField)) { @@ -110,13 +128,41 @@ export function rebindTemplate( /** Tokens in a template that name no column in the given headers. */ export function unboundTokens(template: Template, headers: string[]): string[] { const live = new Set(headers); + return columnsUsed(template).filter((column) => !live.has(column)); +} + +/** + * Every column the design reads, in order: its tokens, the column an icon or a + * grid is drawn from or sorted by, and the table a table's own map looks for. + * What a card prints can change only when one of these does. + */ +export function columnsUsed(template: Template): string[] { const out = new Set(); for (const el of template.elements) { - const text = el.kind === "text" ? el.template : el.kind === "list" ? el.itemTemplate : ""; + const text = + el.kind === "text" + ? el.template + : el.kind === "list" + ? el.itemTemplate + : el.kind === "grid" + ? `${el.headingTemplate} ${el.itemTemplate}` + : el.kind === "qr" + ? el.data + : ""; for (const token of tokensIn(text)) { - if (!live.has(token)) out.add(token); + const chair = chairRef(token); + // A chair is read from the room, not the row; a seat of the card's own + // table needs to know which table that is. + if (!chair) out.add(token); + else if (chair.table === null) out.add("Table"); + } + if (el.kind === "icon" && el.sourceField) out.add(el.sourceField); + if (el.kind === "grid") { + if (el.groupBy) out.add(el.groupBy); + if (el.sortBy) out.add(el.sortBy); } - if (el.kind === "icon" && el.sourceField && !live.has(el.sourceField)) out.add(el.sourceField); + // A table's own map finds its table by the artefact's. + if (el.kind === "room" && el.show === "table") out.add("Table"); } return [...out]; } diff --git a/suite/apps/plaque/core/template/registry.test.ts b/suite/apps/plaque/core/template/registry.test.ts index 3418f91d..8f89bee8 100644 --- a/suite/apps/plaque/core/template/registry.test.ts +++ b/suite/apps/plaque/core/template/registry.test.ts @@ -12,7 +12,7 @@ describe("the element registry", () => { // entry, which would make it unreachable rather than undrawable. const kinds = ELEMENT_KINDS.map((spec) => spec.kind); expect(new Set(kinds).size).toBe(kinds.length); - const expected: CardElement["kind"][] = ["text", "list", "icon", "image", "rect", "line"]; + const expected: CardElement["kind"][] = ["text", "list", "grid", "room", "qr", "icon", "image", "rect", "line"]; expect([...kinds].sort()).toEqual([...expected].sort()); }); diff --git a/suite/apps/plaque/core/template/registry.ts b/suite/apps/plaque/core/template/registry.ts index 021f1bb6..c1b33991 100644 --- a/suite/apps/plaque/core/template/registry.ts +++ b/suite/apps/plaque/core/template/registry.ts @@ -89,6 +89,81 @@ export const ELEMENT_KINDS: ElementKindSpec[] = [ }; }, }, + { + kind: "grid", + label: "Grid", + describe: (el) => (el.kind === "grid" ? `by ${el.groupBy || "(no column)"}` : ""), + create: ({ id, z, card, headers }) => { + // Most of the card: a grid is the board, not a corner of it. + const inset = Math.min(card.widthMm, card.heightMm) * 0.08; + const groupBy = headers.includes("Table") ? "Table" : (headers[0] ?? ""); + const name = headers.includes("Name") ? "Name" : (headers[0] ?? ""); + return { + id, + z, + kind: "grid", + x: inset, + y: inset, + w: card.widthMm - inset * 2, + h: card.heightMm - inset * 2, + groupBy, + headingTemplate: groupBy ? `{{${groupBy}}}` : "", + itemTemplate: name ? `{{${name}}}` : "", + sortBy: "", + layout: "cells", + columns: 4, + gapMm: Math.max(2, inset / 2), + fontId: DEFAULT_FONT_ID, + fontSizePt: 24, + headingFontId: DEFAULT_FONT_ID, + headingScale: 1.4, + headingColorHex: "#171613", + align: "center", + lineHeight: 1.3, + colorHex: "#171613", + letterSpacingMm: 0, + fit: { ...DEFAULT_FIT, mode: "shrink", minFontSizePt: 6 }, + }; + }, + }, + { + kind: "room", + label: "Room", + describe: (el) => (el.kind === "room" ? (el.show === "room" ? "the whole room" : "this table") : ""), + create: ({ id, z, card }) => { + const inset = Math.min(card.widthMm, card.heightMm) * 0.08; + return { + id, + z, + kind: "room", + x: inset, + y: inset, + w: card.widthMm - inset * 2, + h: card.heightMm - inset * 2, + show: "room", + fontId: DEFAULT_FONT_ID, + fontSizePt: 9, + nameGap: 0.3, + namesAtChairs: true, + colorHex: "#171613", + tableHex: "#e8dfcf", + seatHex: "#c9b48f", + wallHex: "#7a6a55", + tableLabels: true, + walls: true, + }; + }, + }, + { + kind: "qr", + label: "QR code", + describe: (el) => (el.kind === "qr" ? el.data || "(empty)" : ""), + create: ({ id, z, card }) => { + const { cx, cy } = centre(card); + const side = Math.min(30, card.widthMm * 0.4, card.heightMm * 0.4); + return { id, z, kind: "qr", x: cx - side / 2, y: cy - side / 2, w: side, h: side, data: "{{Guest Link}}", colorHex: "#171613" }; + }, + }, { kind: "icon", label: "Icon", diff --git a/suite/apps/plaque/core/template/resolve.ts b/suite/apps/plaque/core/template/resolve.ts index 820e9ae7..029dfcf4 100644 --- a/suite/apps/plaque/core/template/resolve.ts +++ b/suite/apps/plaque/core/template/resolve.ts @@ -1,8 +1,8 @@ import { makeIconLookup } from "../../assets/icons"; -import { fitBlock, fitText } from "../text/fit"; +import { fitBlock, fitGrid, fitText } from "../text/fit"; import { missingGlyphs, suggestFallback } from "../text/glyphs"; import type { LoadedFont } from "../text/measure"; -import type { ResolvedImageSource } from "../types"; +import type { ResolvedImageSource, RoomScene } from "../types"; import type { ResolveOptions } from "./bindings"; /** @@ -17,12 +17,14 @@ export function makeResolveOptions( uploadedIcons: Record = {}, images: Map = new Map(), assetNames: Record = {}, + room: RoomScene | null = null, ): ResolveOptions { const iconPath = makeIconLookup(uploadedIcons); return { iconPath, image: (id) => images.get(id) ?? null, assetName: (id) => assetNames[id] ?? null, + room: () => room, missingGlyphs: (fontId, text) => { const font = fonts.get(fontId); if (!font) return { missing: [], fallback: null }; @@ -53,6 +55,21 @@ export function makeResolveOptions( fit: el.fit, }); }, + fitGrid: (el, blocks, cell) => { + const font = fonts.get(el.fontId); + const headingFont = fonts.get(el.headingFontId); + if (!font || !headingFont) return { fontSizePt: el.fontSizePt, overflowed: false, missingFont: true }; + return fitGrid(font, headingFont, { + blocks, + cellWMm: cell.w, + cellHMm: cell.h, + fontSizePt: el.fontSizePt, + headingScale: el.headingScale, + lineHeight: el.lineHeight, + letterSpacingMm: el.letterSpacingMm, + fit: el.fit, + }); + }, fitBlock: (el, lines) => { const font = fonts.get(el.fontId); if (!font) { diff --git a/suite/apps/plaque/core/template/room.test.ts b/suite/apps/plaque/core/template/room.test.ts new file mode 100644 index 00000000..59caaa53 --- /dev/null +++ b/suite/apps/plaque/core/template/room.test.ts @@ -0,0 +1,241 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { BUNDLED_FONTS } from "../../assets/fonts"; +import { loadFont, widestLineMm, type LoadedFont } from "../text/measure"; +import type { CardSpec, ResolvedElement, RoomElement, RoomScene } from "../types"; +import { resolveCard } from "./bindings"; +import { makeResolveOptions } from "./resolve"; + +/** + * The floor plan: the room scaled into its box as shapes, lines and text, so + * neither renderer knows it is drawing a plan. + */ +const fonts = new Map( + BUNDLED_FONTS.map((f) => [f.id, loadFont(f.id, f.family, new Uint8Array(readFileSync(`public/fonts/${f.file}`)))]), +); + +const sitter = (first: string, last: string, seat: string) => ({ "First Name": first, "Last Name": last, Name: `${first} ${last}`, Seat: seat }); + +// A room 400 × 200: a round table and a turned long one, both numbering their +// seats, and a round table where guests sit where they like. +const scene: RoomScene = { + bounds: { x: 0, y: 0, w: 400, h: 200 }, + walls: [ + [{ x: 0, y: 0 }, { x: 400, y: 0 }], + [{ x: 400, y: 0 }, { x: 400, y: 200 }], + ], + seatRadius: 10, + tables: [ + { + label: "Table 1", + x: 100, + y: 100, + rotationDeg: 0, + pathD: "M -40 0 A 40 40 0 1 0 40 0 A 40 40 0 1 0 -40 0 Z", + view: { x: -40, y: -40, w: 80, h: 80 }, + numbered: true, + interior: { x: -28, y: -28, w: 56, h: 56 }, + seats: [ + { x: 100, y: 45, out: { x: 0, y: -1 }, number: 1, row: sitter("Ada", "Byron", "1") }, + { x: 100, y: 155, out: { x: 0, y: 1 }, number: 2, row: null }, + ], + }, + { + label: "Top table", + x: 300, + y: 100, + rotationDeg: 30, + pathD: "M -60 -20 H 60 V 20 H -60 Z", + view: { x: -60, y: -20, w: 120, h: 40 }, + numbered: true, + interior: { x: -52, y: -12, w: 104, h: 24 }, + seats: [{ x: 300, y: 65, out: { x: 0, y: -1 }, number: 1, row: sitter("Grace", "Hopper", "1") }], + }, + ], +}; + +const freeSeating = { + label: "Table 2", + x: 200, + y: 160, + rotationDeg: 0, + pathD: "M -30 0 A 30 30 0 1 0 30 0 A 30 30 0 1 0 -30 0 Z", + view: { x: -30, y: -30, w: 60, h: 60 }, + numbered: false, + interior: { x: -20, y: -20, w: 40, h: 40 }, + seats: [ + { x: 200, y: 120, out: { x: 0, y: -1 }, number: 1, row: sitter("Alan", "Turing", "") }, + { x: 200, y: 200, out: { x: 0, y: 1 }, number: 2, row: sitter("Joan", "Clarke", "") }, + ], +}; + +const card: CardSpec = { widthMm: 420, heightMm: 297, fold: "none", foldPositionMm: 0, invertBackPanel: false, bleedMm: 0 }; + +const room = (over: Partial = {}): RoomElement => ({ + kind: "room", + id: "plan", + x: 10, + y: 10, + w: 400, + h: 277, + z: 1, + show: "room", + fontId: "crimson", + fontSizePt: 12, + nameGap: 0, + namesAtChairs: true, + colorHex: "#000000", + tableHex: "#eeeeee", + seatHex: "#cccccc", + wallHex: "#333333", + tableLabels: true, + walls: true, + ...over, +}); + +const draw = (el: RoomElement, row: Record = {}, withRoom: RoomScene | null = scene, chairName = "{{First Name}}") => { + const { scene: out, warnings } = resolveCard( + { elements: [el], backgroundHex: null, chairName }, + row, + card, + makeResolveOptions(fonts, {}, new Map(), {}, withRoom), + ); + return { elements: out.elements, warnings }; +}; +const texts = (elements: ResolvedElement[]) => elements.flatMap((el) => (el.kind === "text" ? [el.lines.join(" ")] : [])); + +describe("a floor plan", () => { + it("draws the walls, every table and every chair, and the names of the people in them", () => { + const { elements } = draw(room()); + expect(elements.filter((el) => el.kind === "line")).toHaveLength(2); + // Two tables and three chairs. + expect(elements.filter((el) => el.kind === "icon")).toHaveLength(5); + expect(texts(elements).sort()).toEqual(["Ada", "Grace", "Table 1", "Top table"]); + }); + + it("scales the room into its box, one scale both ways, centred", () => { + const { elements } = draw(room()); + // 400 × 200 into 400 × 277: scale 1, centred top to bottom. + const first = elements.find((el) => el.kind === "icon")!; + expect(first).toMatchObject({ x: 10 + 60, y: 10 + 38.5 + 60, w: 80, h: 80 }); + }); + + it("turns a table with its plan, and keeps its names upright", () => { + const { elements } = draw(room()); + const top = elements.filter((el) => el.kind === "icon")[3]!; + expect(top.rotationDeg).toBe(30); + expect(elements.filter((el) => el.kind === "text").every((el) => el.rotationDeg === 0)).toBe(true); + }); + + it("names every piece after the element, so it is grabbed as one", () => { + const { elements } = draw(room()); + expect(new Set(elements.map((el) => el.sourceId))).toEqual(new Set(["plan"])); + expect(new Set(elements.map((el) => el.id)).size).toBe(elements.length); + }); + + it("names each sitter as the design's format says, and an empty chair not at all", () => { + const names = (format: string) => texts(draw(room({ tableLabels: false }), {}, scene, format).elements).sort(); + expect(names("{{Last Name}}, {{First Name}}")).toEqual(["Byron, Ada", "Hopper, Grace"]); + expect(names("{{First Name}} {{Last Name}}")).toEqual(["Ada Byron", "Grace Hopper"]); + expect(names("{{Seat}}")).toEqual(["1", "1"]); + expect(names("")).toEqual([]); + }); + + it("names the guests of a table where they sit where they like inside it, not at chairs", () => { + const withFree = { ...scene, tables: [...scene.tables, freeSeating] }; + const { elements } = draw(room({ walls: false }), {}, withFree); + const inside = elements.find((el) => el.kind === "text" && el.lines.includes("Alan"))!; + expect(inside.kind === "text" && inside.lines).toEqual(["Alan", "Joan"]); + // Upright, within the table's interior: 40 across at scale 1, centred on the table. + expect(inside).toMatchObject({ rotationDeg: 0, w: 40, h: 40 }); + // Its name sits above it, clear of the names inside. + const label = elements.find((el) => el.kind === "text" && el.lines.includes("Table 2"))!; + expect(label.y + label.h).toBeLessThan(inside.y); + }); + + it("draws just this card's table, filling the box, when it is a table's own map", () => { + const { elements } = draw(room({ show: "table", walls: true }), { Table: "top TABLE" }); + expect(elements.filter((el) => el.kind === "line")).toHaveLength(0); + expect(texts(elements).sort()).toEqual(["Grace", "Top table"]); + const table = elements.find((el) => el.kind === "icon")!; + expect(table.w).toBeGreaterThan(120); + }); + + it("says so when the card's table is not on the plan, or there is no plan", () => { + expect(draw(room({ show: "table" }), { Table: "Table 9" }).warnings).toContainEqual( + expect.objectContaining({ kind: "missing-field", detail: expect.stringMatching(/no table called "Table 9"/) }), + ); + expect(draw(room(), {}, null).warnings).toContainEqual(expect.objectContaining({ detail: expect.stringMatching(/no seating plan/) })); + }); +}); + +describe("names inside a long table", () => { + it("spread across columns, all one size, rather than crushed into one", () => { + const long = { + label: "Top table", + x: 200, + y: 100, + rotationDeg: 0, + pathD: "M -150 -15 H 150 V 15 H -150 Z", + view: { x: -150, y: -15, w: 300, h: 30 }, + numbered: false, + interior: { x: -142, y: -7, w: 284, h: 14 }, + seats: ["Alex", "David", "Helen", "Ines", "Lucia", "Mateo"].map((first, i) => ({ + x: 200, + y: 80, + out: { x: 0, y: -1 }, + number: i + 1, + row: { "First Name": first, "Last Name": "Morgan" }, + })), + }; + const { elements } = draw(room({ walls: false, tableLabels: false }), {}, { ...scene, tables: [long] }); + const columns = elements.filter((el) => el.kind === "text"); + expect(columns.length).toBeGreaterThan(1); + expect(new Set(columns.map((el) => el.kind === "text" && el.fontSizePt)).size).toBe(1); + expect(columns.flatMap((el) => (el.kind === "text" ? el.lines : []))).toEqual(["Alex", "David", "Helen", "Ines", "Lucia", "Mateo"]); + }); +}); + +describe("names inside a round table, in columns", () => { + it("leave a gap between the columns, however long the longest name", () => { + const names = ["Eleanor", "Florence", "Devendra", "Beatrix", "Charis", "Chloé", "Anneliese", "Callum"]; + const round = { + ...freeSeating, + seats: names.map((first, i) => ({ ...freeSeating.seats[0]!, number: i + 1, row: { "First Name": first, "Last Name": "" } })), + }; + const { elements } = draw(room({ walls: false, tableLabels: false, fontSizePt: 200 }), {}, { ...scene, tables: [round] }); + const columns = elements.flatMap((el) => (el.kind === "text" ? [el] : [])); + expect(columns.length).toBe(2); + const font = fonts.get(columns[0]!.fontId)!; + const [left, right] = columns.map((c) => { + const half = widestLineMm(font, c.lines, c.fontSizePt, 0) / 2; + return { from: c.x + c.w / 2 - half, to: c.x + c.w / 2 + half }; + }); + expect(right!.from - left!.to).toBeGreaterThan(1); + }); +}); + +describe("names at chairs", () => { + it("are all one size: a long name does not read smaller than its neighbour", () => { + const crowded = { + ...scene.tables[0]!, + seats: [ + // Side by side, 20 apart: a name may be about that wide. + { x: 90, y: 45, out: { x: 0, y: -1 }, number: 1, row: sitter("Al", "Bo", "1") }, + { x: 110, y: 45, out: { x: 0, y: -1 }, number: 2, row: sitter("Bartholomew-Fortescue", "Pemberton-Blythe", "2") }, + ], + }; + const { elements } = draw(room({ tableLabels: false, walls: false }), {}, { ...scene, tables: [crowded] }, "{{First Name}}"); + const sizes = elements.flatMap((el) => (el.kind === "text" ? [el.fontSizePt] : [])); + expect(sizes).toHaveLength(2); + expect(sizes[0]).toBe(sizes[1]); + expect(sizes[0]).toBeLessThan(12); + }); + + it("sit as far out from their chair as the design says", () => { + const name = (nameGap: number) => + draw(room({ tableLabels: false, walls: false, nameGap }), {}, { ...scene, tables: [scene.tables[0]!] }).elements.find((el) => el.kind === "text")!; + // Ada's chair faces up the page: further out is higher. + expect(name(1).y).toBeLessThan(name(0).y); + }); +}); diff --git a/suite/apps/plaque/core/template/room.ts b/suite/apps/plaque/core/template/room.ts new file mode 100644 index 00000000..761f8476 --- /dev/null +++ b/suite/apps/plaque/core/template/room.ts @@ -0,0 +1,530 @@ +import { normalise } from "../data/artefacts"; +import type { GuestRow } from "../data/rows"; +import { transformForPanel } from "../geometry/fold"; +import type { + CardSpec, + ListElement, + Mm, + ResolvedElement, + RoomElement, + RoomScene, + RoomSeat, + RoomTable, + Template, + TextElement, +} from "../types"; +import { chairName } from "./chairs"; +import type { CardWarning, ResolveOptions } from "./bindings"; + +/** A seat, as a path the icon pipeline can fill: a circle of radius 1 about the origin. */ +const SEAT_PATH = "M -1 0 A 1 1 0 1 0 1 0 A 1 1 0 1 0 -1 0 Z"; +const SEAT_VIEW = { x: -1, y: -1, w: 2, h: 2 }; +/** A name's cell is this many seat radii deep, hanging off the chair away from the table. */ +const NAME_DEPTH = 3; +/** Room left round a single table so its names are not cut off. */ +const TABLE_MARGIN = NAME_DEPTH + 1.5; +/** A table's own name is set this much larger than the guests'. */ +const LABEL_SCALE = 1.3; + +type Box = { x: Mm; y: Mm; w: Mm; h: Mm }; + +/** + * The room to scale in the element's box: walls as lines, tables and chairs as + * filled shapes, and names and labels as ordinary text, so neither renderer + * knows a floor plan exists. Labels stay upright however a table is turned — + * a turned top table's names must still read. + * + * With `show: "table"` it is the one table this artefact is for — a table + * card's own little map — found by the artefact's `Table`. + */ +type Drawn = { elements: ResolvedElement[]; warnings: CardWarning[] }; + +/** + * Plans already drawn, by the measurer and the room they were drawn with. A + * plan reads nothing of the card's guest but its table, so a hundred escort + * cards with the room on them draw it once, not a hundred times. + */ +const drawnPlans = new WeakMap>>(); + +export function resolveRoom( + el: RoomElement, + row: GuestRow, + card: CardSpec, + opts: ResolveOptions, + template: Pick, +): Drawn { + const scene = opts.room?.() ?? null; + if (!scene) return drawRoom(el, row, card, opts, template, null); + const byScene = drawnPlans.get(opts.fitText) ?? new WeakMap>(); + drawnPlans.set(opts.fitText, byScene); + const plans = byScene.get(scene) ?? new Map(); + byScene.set(scene, plans); + const key = JSON.stringify([el, card, template.chairName ?? null, el.show === "table" ? (row["Table"] ?? "") : null]); + const drawn = plans.get(key) ?? drawRoom(el, row, card, opts, template, scene); + plans.set(key, drawn); + return { elements: [...drawn.elements], warnings: [...drawn.warnings] }; +} + +function drawRoom( + el: RoomElement, + row: GuestRow, + card: CardSpec, + opts: ResolveOptions, + template: Pick, + scene: RoomScene | null, +): Drawn { + const warnings: CardWarning[] = []; + const layout = planLayout(el, scene, row); + if (typeof layout === "string") { + warnings.push({ elementId: el.id, kind: scene ? "missing-field" : "empty-text", detail: layout }); + return { elements: [], warnings }; + } + const { tables, scale, at, seatR, walls } = layout; + + const elements: ResolvedElement[] = []; + let n = 0; + const place = (box: Box, rotationDeg = 0) => { + const placed = transformForPanel(box, card); + return { + id: `${el.id}/${n++}`, + sourceId: el.id, + ...placed.box, + rotationDeg: placed.rotationDeg + rotationDeg, + z: el.z, + }; + }; + + if (el.walls && el.show === "room") { + for (const [a, b] of walls) { + const p = at(a.x, a.y); + const q = at(b.x, b.y); + const length = Math.hypot(q.x - p.x, q.y - p.y); + if (length === 0) continue; + const box = { x: (p.x + q.x) / 2 - length / 2, y: (p.y + q.y) / 2, w: length, h: 0 }; + elements.push({ + ...place(box, (Math.atan2(q.y - p.y, q.x - p.x) * 180) / Math.PI), + kind: "line", + strokeHex: el.wallHex, + strokeWidthMm: 0.5, + dashed: false, + }); + } + } + + for (const table of tables) { + const centre = at(table.x, table.y); + const w = table.view.w * scale; + const h = table.view.h * scale; + // The path's box sits where its view does about the table's centre. + const box = { x: centre.x + table.view.x * scale, y: centre.y + table.view.y * scale, w, h }; + elements.push({ + ...place(box, table.rotationDeg), + kind: "icon", + pathD: table.pathD, + cutD: null, + view: table.view, + colorHex: el.tableHex, + cutHex: el.tableHex, + }); + + for (const seat of table.seats) { + const c = at(seat.x, seat.y); + elements.push({ + ...place({ x: c.x - seatR, y: c.y - seatR, w: seatR * 2, h: seatR * 2 }), + kind: "icon", + pathD: SEAT_PATH, + cutD: null, + view: SEAT_VIEW, + colorHex: el.seatHex, + cutHex: el.seatHex, + }); + } + + if (el.tableLabels && table.label) { + const size = Math.min(w, h) * 0.8; + // On the table where the names are round it; above it where they are in it. + const box = table.numbered + ? { x: centre.x - size / 2, y: centre.y - size / 4, w: size, h: size / 2 } + : { x: centre.x - size / 2, y: centre.y - extentY(table) * scale - seatR * 2 - size / 2, w: size, h: size / 2 }; + const label = fitted(el, table.label, box, el.fontSizePt * LABEL_SCALE, opts, warnings); + if (label) elements.push({ ...place(label.box), ...label.text }); + } + + const named = table.seats.flatMap((seat) => { + const words = seat.row ? chairName(template, seat.row) : ""; + return words ? [{ seat, words }] : []; + }); + if (!table.numbered && named.length > 0) { + // Guests who sit where they like are named inside the table, as Seating + // shows them: never at a chair, which would promise a seat that is not theirs. + for (const column of listed(el, named.map((n) => n.words), uprightInterior(table, centre, scale), opts, warnings)) { + elements.push({ ...place(column.box), ...column.text }); + } + } + } + + // Each name at its own chair, hanging outward, all at one size — unless the + // names have been stamped out as boxes of their own. + if (el.namesAtChairs) { + const size = chairNameSize(el, layout, opts, template, warnings); + for (const { words, box } of chairNames(el, layout, template)) { + const name = fitted(el, words, box, size, opts, warnings); + if (name) elements.push({ ...place(name.box), ...name.text }); + } + } + + return { elements, warnings }; +} + +/** Where a plan draws: its tables, scaled and centred in its box. */ +export interface PlanLayout { + tables: RoomTable[]; + scale: number; + /** The plan's own coordinates to the card's, in millimetres. */ + at: (x: number, y: number) => { x: Mm; y: Mm }; + /** A chair's radius on the card. */ + seatR: Mm; + walls: RoomScene["walls"]; +} + +/** + * How a room element lays its plan out on a card for `row` — the whole room, + * or the card's own table — or why it cannot. + */ +export function planLayout( + el: Pick, + scene: RoomScene | null, + row: GuestRow, +): PlanLayout | string { + if (!scene || scene.tables.length === 0) return "There is no seating plan to draw yet."; + let tables = scene.tables; + let bounds = scene.bounds; + if (el.show === "table") { + const wanted = normalise(row["Table"] ?? ""); + const table = scene.tables.find((t) => normalise(t.label) === wanted); + if (!table) return wanted ? `The plan has no table called "${row["Table"]}".` : "This card is not for a table."; + tables = [table]; + bounds = tableBounds(table, scene.seatRadius); + } + // The plan scaled to fit the box, centred, one scale both ways. + const scale = Math.min(el.w / bounds.w, el.h / bounds.h); + const ox = el.x + (el.w - bounds.w * scale) / 2 - bounds.x * scale; + const oy = el.y + (el.h - bounds.h * scale) / 2 - bounds.y * scale; + return { + tables, + scale, + at: (x, y) => ({ x: ox + x * scale, y: oy + y * scale }), + seatR: scene.seatRadius * scale, + walls: scene.walls, + }; +} + +/** Where each chair's name goes at a table that numbers its seats: hanging off the chair, away from the table. */ +export function tableCells(layout: PlanLayout, table: RoomTable, gap: number): Array<{ seat: RoomSeat; box: Box }> { + if (!table.numbered) return []; + const across = Math.max(layout.seatR * 2, nearestSeat(table, layout.scale) * 0.96); + return table.seats.map((seat) => ({ + seat, + box: nameCell(layout.at(seat.x, seat.y), seat.out, layout.seatR, across, gap), + })); +} + +/** Every chair a plan names, with where its name goes on the card. */ +export function chairCells( + el: Pick, + scene: RoomScene | null, + row: GuestRow, +): Array<{ table: RoomTable; seat: RoomSeat; box: Box }> { + const layout = planLayout(el, scene, row); + if (typeof layout === "string") return []; + return layout.tables.flatMap((table) => tableCells(layout, table, el.nameGap).map((cell) => ({ table, ...cell }))); +} + +/** + * The chairs a plan's names can be stamped from, each with where its box goes + * on the card on screen. On the whole room, every numbered chair. On a table's + * own map, seat n of every table: the card's own table first, then any seat + * only a bigger table has, placed as that table's card would place it. + */ +export function stampableChairs( + el: RoomElement, + scene: RoomScene | null, + row: GuestRow, +): Array<{ table: string | null; seat: number; label: string; box: Box }> { + if (el.show === "room") { + return chairCells(el, scene, row).map(({ table, seat, box }) => ({ table: table.label, seat: seat.number, label: `${table.label} ${seat.number}`, box })); + } + const own = normalise(row["Table"] ?? ""); + const tables = [...(scene?.tables ?? [])].sort((a, b) => Number(normalise(b.label) === own) - Number(normalise(a.label) === own)); + const seen = new Map(); + for (const table of tables) { + for (const { seat, box } of chairCells(el, scene, { ...row, Table: table.label })) { + if (!seen.has(seat.number)) seen.set(seat.number, box); + } + } + return [...seen.entries()] + .sort(([a], [b]) => a - b) + .map(([seat, box]) => ({ table: null, seat, label: `Seat ${seat}`, box })); +} + +/** Each name at a chair the plan draws, in the cell it is drawn in. */ +function chairNames(el: RoomElement, layout: PlanLayout, template: Pick): Array<{ words: string; box: Box }> { + return layout.tables.flatMap((table) => + tableCells(layout, table, el.nameGap).flatMap(({ seat, box }) => { + const words = seat.row ? chairName(template, seat.row) : ""; + return words ? [{ words, box }] : []; + }), + ); +} + +/** + * The one size a plan names its chairs at: the largest the tightest of them + * allows, so no guest reads smaller than the one beside them for a longer name. + */ +export function chairNameSize( + el: RoomElement, + layout: PlanLayout, + opts: Pick, + template: Pick, + warnings: CardWarning[], +): number { + const sizes = chairNames(el, layout, template).map( + ({ words, box }) => fitted(el, words, box, el.fontSizePt, opts, warnings)?.text.fontSizePt ?? el.fontSizePt, + ); + return Math.min(el.fontSizePt, ...sizes); +} + +/** + * The design with every box that follows a chair moved to that chair's name + * on this card, nudged as it was nudged. Every place a design is drawn or + * edited goes through this, so what is grabbed on screen is what prints, on + * every table's card. A box whose chair this card does not have stays put: it + * names nobody here. + */ +export function placeChairs(template: Template, scene: RoomScene | null, row: GuestRow): Template { + if (!template.elements.some((el) => el.kind === "text" && el.chair?.follow)) return template; + const cellsOf = new Map>(); + const elements = template.elements.map((el) => { + if (el.kind !== "text" || !el.chair?.follow) return el; + const link = el.chair; + const plan = template.elements.find((p): p is RoomElement => p.id === link.from && p.kind === "room"); + if (!plan) return el; + const cells = cellsOf.get(plan.id) ?? chairCells(plan, scene, row); + cellsOf.set(plan.id, cells); + const cell = cells.find( + (c) => c.seat.number === link.seat && (link.table === null || normalise(c.table.label) === normalise(link.table)), + ); + if (!cell) return el; + const at = centredOn(el, cell.box); + return { ...el, x: at.x + link.dx, y: at.y + link.dy }; + }); + return { ...template, elements }; +} + +/** + * Where a box of its own size sits centred on a chair's cell: the nudge is + * measured from here. By centre, not corner — a table's own map draws each + * table at its own scale, so the cells differ in size from card to card. + */ +export function centredOn(el: { w: Mm; h: Mm }, cell: Box): { x: Mm; y: Mm } { + return { x: cell.x + (cell.w - el.w) / 2, y: cell.y + (cell.h - el.h) / 2 }; +} + +/** How far a table reaches above and below its centre, turned as it is. */ +function extentY(table: RoomTable): number { + const rad = (table.rotationDeg * Math.PI) / 180; + return Math.abs((table.view.w / 2) * Math.sin(rad)) + Math.abs((table.view.h / 2) * Math.cos(rad)); +} + +/** + * The table's interior as an upright box on the page, however the table is + * turned: names inside it must still read. Turned square-on, the box turns + * with it; turned at an angle, a square that fits inside either way. + */ +function uprightInterior(table: RoomTable, centre: { x: Mm; y: Mm }, scale: number): Box { + const inside = table.interior; + const rad = (table.rotationDeg * Math.PI) / 180; + const quarter = Math.round(table.rotationDeg / 90); + const square = Math.abs(table.rotationDeg - quarter * 90) < 1; + const [w, h] = square + ? quarter % 2 === 0 + ? [inside.w, inside.h] + : [inside.h, inside.w] + : [Math.min(inside.w, inside.h) * 0.7, Math.min(inside.w, inside.h) * 0.7]; + // The interior's own centre, turned with the table (a half-circle's is off-centre). + const cx = inside.x + inside.w / 2; + const cy = inside.y + inside.h / 2; + const ox = cx * Math.cos(rad) - cy * Math.sin(rad); + const oy = cx * Math.sin(rad) + cy * Math.cos(rad); + return { + x: centre.x + (ox - w / 2) * scale, + y: centre.y + (oy - h / 2) * scale, + w: w * scale, + h: h * scale, + }; +} + +/** One table and its chairs, with room round them for the names. */ +function tableBounds(table: RoomTable, seatRadius: number) { + const reach = seatRadius * TABLE_MARGIN; + const xs = table.seats.map((s) => s.x).concat(table.x - table.view.w / 2, table.x + table.view.w / 2); + const ys = table.seats.map((s) => s.y).concat(table.y - table.view.h / 2, table.y + table.view.h / 2); + const x = Math.min(...xs) - reach; + const y = Math.min(...ys) - reach; + return { x, y, w: Math.max(...xs) + reach - x, h: Math.max(...ys) + reach - y }; +} + +/** The closest two chairs at a table come, on paper: how wide a name may be. */ +function nearestSeat(table: RoomTable, scale: number): Mm { + let nearest = Number.POSITIVE_INFINITY; + table.seats.forEach((a, i) => { + for (const b of table.seats.slice(i + 1)) nearest = Math.min(nearest, Math.hypot(a.x - b.x, a.y - b.y)); + }); + return Number.isFinite(nearest) ? nearest * scale : 0; +} + +/** + * A name's cell: hanging off the chair, away from the table, `gap` chair radii + * clear of it, as wide as the gap to the next chair. + */ +function nameCell(seat: { x: Mm; y: Mm }, out: { x: number; y: number }, seatR: Mm, across: Mm, gap: number): Box { + const depth = seatR * NAME_DEPTH; + const reach = seatR * (1 + gap) + depth / 2; + const cx = seat.x + out.x * reach; + const cy = seat.y + out.y * reach; + return { x: cx - across / 2, y: cy - depth / 2, w: across, h: depth }; +} + +/** Most columns a table's names are spread across. */ +const MAX_COLUMNS = 6; +/** The gutter between columns of names in a table, as a share of the table's inside: never touching. */ +const COLUMN_GAP = 0.08; + +/** + * Names fitted inside a box as Seating lays them in a table: in as many + * columns as make them largest — one down a round table, several along a long + * one — every column at the one size. + */ +function listed( + el: RoomElement, + lines: string[], + box: Box, + opts: ResolveOptions, + warnings: CardWarning[], +): Array<{ box: Box; text: Omit, "id" | "sourceId" | "x" | "y" | "w" | "h" | "rotationDeg" | "z"> }> { + if (lines.length === 0) return []; + const fitColumn = (w: Mm, column: string[]) => { + const probe: ListElement = { + kind: "list", + id: el.id, + x: 0, + y: 0, + w, + h: box.h, + z: el.z, + itemTemplate: "", + bullet: "", + skipEmpty: true, + fontId: el.fontId, + fontSizePt: el.fontSizePt, + align: "center", + vAlign: "middle", + lineHeight: 1.15, + colorHex: el.colorHex, + letterSpacingMm: 0, + fit: { mode: "shrink", minFontSizePt: 1, maxLines: 1, anchor: "align" }, + }; + return opts.fitBlock?.(probe, column) ?? { lines: column, fontSizePt: el.fontSizePt, overflowed: false, missingFont: true }; + }; + + let best: { columns: string[][]; sizePt: number; missingFont: boolean } | null = null; + for (let count = 1; count <= Math.min(lines.length, MAX_COLUMNS); count++) { + const per = Math.ceil(lines.length / count); + const columns = Array.from({ length: count }, (_, c) => lines.slice(c * per, (c + 1) * per)).filter((c) => c.length > 0); + const fits = columns.map((column) => fitColumn(columnWidth(box.w, columns.length), column)); + const sizePt = Math.min(...fits.map((fit) => fit.fontSizePt)); + // Fewer columns win a tie: a single list reads most like a list. + if (!best || sizePt > best.sizePt) best = { columns, sizePt, missingFont: fits.some((fit) => fit.missingFont) }; + } + if (best!.missingFont && !warnings.some((w) => w.kind === "missing-font")) { + warnings.push({ + elementId: el.id, + kind: "missing-font", + detail: `The font "${el.fontId}" is not on this device, so the names on the plan cannot be sized correctly.`, + }); + } + const w = columnWidth(box.w, best!.columns.length); + return best!.columns.map((column, c) => ({ + box: { x: box.x + c * (w + box.w * COLUMN_GAP), y: box.y, w, h: box.h }, + text: { + kind: "text", + lines: column, + fontId: el.fontId, + fontSizePt: best!.sizePt, + align: "center", + vAlign: "middle", + anchor: "align", + lineHeight: 1.15, + colorHex: el.colorHex, + letterSpacingMm: 0, + overflowed: false, + }, + })); +} + +/** Each of `count` columns across `width`, the gutters between them taken out. */ +function columnWidth(width: Mm, count: number): Mm { + return (width - width * COLUMN_GAP * (count - 1)) / count; +} + +/** Text fitted to a box with the element's face, or nothing when it is empty. */ +function fitted( + el: RoomElement, + words: string, + box: Box, + sizePt: number, + opts: Pick, + warnings: CardWarning[], +): { box: Box; text: Omit, "id" | "sourceId" | "x" | "y" | "w" | "h" | "rotationDeg" | "z"> } | null { + const probe: TextElement = { + kind: "text", + id: el.id, + ...box, + z: el.z, + template: words, + fontId: el.fontId, + fontSizePt: sizePt, + align: "center", + vAlign: "middle", + lineHeight: 1.1, + colorHex: el.colorHex, + letterSpacingMm: 0, + // Two lines before shrinking further: "Bartholomew Sorensen" breaks well. + fit: { mode: "shrink-then-wrap", minFontSizePt: 1, maxLines: 2, anchor: "align" }, + }; + const fit = opts.fitText(probe, words); + if (fit.missingFont && !warnings.some((w) => w.kind === "missing-font")) { + warnings.push({ + elementId: el.id, + kind: "missing-font", + detail: `The font "${el.fontId}" is not on this device, so the names on the plan cannot be sized correctly.`, + }); + } + if (fit.lines.length === 0) return null; + return { + box, + text: { + kind: "text", + lines: fit.lines, + fontId: el.fontId, + fontSizePt: fit.fontSizePt, + align: "center", + vAlign: "middle", + anchor: "align", + lineHeight: 1.1, + colorHex: el.colorHex, + letterSpacingMm: 0, + overflowed: false, + }, + }; +} diff --git a/suite/apps/plaque/core/text/fit.ts b/suite/apps/plaque/core/text/fit.ts index b2c9ba14..2c3cbb28 100644 --- a/suite/apps/plaque/core/text/fit.ts +++ b/suite/apps/plaque/core/text/fit.ts @@ -1,5 +1,5 @@ import type { FitConfig, Mm, Pt } from "../types"; -import { blockHeightMm, breakLines, widestLineMm, type LoadedFont } from "./measure"; +import { blockHeightMm, breakLines, lineHeightMm, measureWidth, widestLineMm, type LoadedFont } from "./measure"; /** Shrinking steps down in half points. Finer than a printer can show. */ const STEP_PT = 0.5; @@ -120,6 +120,48 @@ export function fitBlock(font: LoadedFont, input: FitBlockInput): FitOutcome { return { lines: input.lines, fontSizePt: round(floor), overflowed: !fits(floor) }; } +export interface FitGridInput { + blocks: Array<{ heading: string; items: string[] }>; + cellWMm: Mm; + cellHMm: Mm; + fontSizePt: Pt; + headingScale: number; + lineHeight: number; + letterSpacingMm: Mm; + fit: FitConfig; +} + +/** + * One size for every block of a grid: the largest, from the size asked for + * down to the floor, at which every heading and every line fits its cell's + * width and every block its height. Lines are never wrapped — a wrapped name + * reads as two guests. + */ +export function fitGrid( + itemFont: LoadedFont, + headingFont: LoadedFont, + input: FitGridInput, +): { fontSizePt: Pt; overflowed: boolean } { + const floor = Math.min(input.fit.minFontSizePt, input.fontSizePt); + const fits = (sizePt: Pt): boolean => { + const headingPt = sizePt * input.headingScale; + return input.blocks.every( + (block) => + measureWidth(headingFont, block.heading, headingPt, input.letterSpacingMm) <= input.cellWMm && + widestLineMm(itemFont, block.items, sizePt, input.letterSpacingMm) <= input.cellWMm && + lineHeightMm(headingPt, input.lineHeight) + blockHeightMm(block.items.length, sizePt, input.lineHeight) <= + input.cellHMm, + ); + }; + + if (fits(input.fontSizePt)) return { fontSizePt: input.fontSizePt, overflowed: false }; + if (input.fit.mode === "none") return { fontSizePt: input.fontSizePt, overflowed: true }; + for (let size = input.fontSizePt - STEP_PT; size >= floor; size -= STEP_PT) { + if (fits(size)) return { fontSizePt: round(size), overflowed: false }; + } + return { fontSizePt: round(floor), overflowed: !fits(floor) }; +} + function round(pt: Pt): Pt { return Math.round(pt * 100) / 100; } diff --git a/suite/apps/plaque/core/types.ts b/suite/apps/plaque/core/types.ts index 11fecd23..6e8fda1c 100644 --- a/suite/apps/plaque/core/types.ts +++ b/suite/apps/plaque/core/types.ts @@ -6,6 +6,8 @@ * and the PDF y-flip happens in exactly one function (see render/pdf/renderPdf). */ +import type { GuestRow } from "./data/rows"; + export type Mm = number; export type Pt = number; /** `#rrggbb`. */ @@ -30,7 +32,13 @@ export type Segment = readonly [Point, Point]; // Card and sheet // --------------------------------------------------------------------------- -export type PageSizeName = "A4" | "LETTER"; +/** Paper a printer is fed. */ +export type PaperName = "A4" | "LETTER" | "A3"; +/** + * `FIT` makes the page the card's own size plus its margins: one board or + * poster per page, at full size, which is what a print shop wants. + */ +export type PageSizeName = PaperName | "FIT"; export type Orientation = "portrait" | "landscape"; /** @@ -77,6 +85,8 @@ export interface SheetSpec { * property of the printer, not the design — see PrinterProfile. */ duplex: boolean; + /** The paper a `FIT` page is tiled onto, for printing a board at home. */ + tilePaper: PaperName; /** * PDF only. A strip along the foot of each sheet naming sizes, fold, applied * printer scale, card count and build hash, with a printed rule — so a @@ -141,8 +151,31 @@ export interface OpticalSpec { features: string[] | null; } +/** + * A text box that names whoever sits in one chair of a plan, stamped from a + * room element. It says so with a chair token in its text; this says where + * the box goes. + */ +export interface ChairLink { + /** The room element it was stamped from, whose plan it follows. */ + from: ElementId; + /** Seat `seat` of the card's own table (`table: null`), or of the table so named. */ + table: string | null; + seat: number; + /** + * True: placed at its chair's name on every card, as the plan moves, and + * nudged from there by `dx, dy` — restyling the plan. False: left wherever + * it was put — names over your own artwork. + */ + follow: boolean; + dx: Mm; + dy: Mm; +} + export interface TextElement extends ElementBase { kind: "text"; + /** Present when this box names a chair of a plan. See `ChairLink`. */ + chair?: ChairLink; /** e.g. `"{{First Name}} {{Last Name}}"`. Literal text needs no braces. */ template: string; fontId: string; @@ -241,13 +274,98 @@ export interface ListElement extends ElementBase { optical?: OpticalSpec; } +/** + * A block per group — per table — laid out in columns: the seating board. + * + * Each block is a heading from the group's first row and a line per row. One + * size serves every block, shrunk together until the fullest one fits its + * cell, so no table reads smaller than its neighbours. Like a list, it + * resolves into ordinary text, so neither renderer draws it specially. + */ +export interface GridElement extends ElementBase { + kind: "grid"; + /** The column whose values make the blocks, e.g. `"Table"`. Rows with none are left off. */ + groupBy: string; + /** e.g. `"{{Table}}"`, from the block's first row. */ + headingTemplate: string; + /** One line per row in the block, e.g. `"{{Name}}"`. */ + itemTemplate: string; + /** Orders the lines within a block, by this column; empty keeps the room's order. */ + sortBy: string; + /** + * `cells`: a block per equal cell, one size shrunk until all fit — a seating + * board. `columns`: blocks flow down newspaper columns at the size asked + * for, and a list too long for one page carries on onto the next — a + * finder, A to Z. + */ + layout: "cells" | "columns"; + columns: number; + /** Between blocks, both ways. */ + gapMm: Mm; + fontId: string; + /** The lines' size; the heading is `headingScale` times it. */ + fontSizePt: Pt; + headingFontId: string; + headingScale: number; + headingColorHex: Hex; + align: HAlign; + lineHeight: number; + colorHex: Hex; + letterSpacingMm: Mm; + /** `minFontSizePt` is the floor; a mode of `none` never shrinks. */ + fit: FitConfig; +} + +/** + * The room drawn to scale from the seating plan — every table, or the one this + * card is for — with the guests' names at their seats. It resolves into + * shapes, lines and text, so neither renderer draws a floor plan specially. + */ +export interface RoomElement extends ElementBase { + kind: "room"; + /** The whole room, or the table this artefact is for (its `{{Table}}`). */ + show: "room" | "table"; + fontId: string; + /** Names are set at this size or smaller: one size for every name at a chair. */ + fontSizePt: Pt; + /** How far a name sits out from its chair, in chair radii. */ + nameGap: number; + /** + * Whether the plan names guests at numbered chairs itself. Off once those + * names have been stamped out as boxes of their own (see `ChairLink`). + */ + namesAtChairs: boolean; + colorHex: Hex; + tableHex: Hex; + seatHex: Hex; + wallHex: Hex; + /** Each table's own name, on it. */ + tableLabels: boolean; + walls: boolean; +} + +/** + * A QR code of whatever its template says — the guest link, by default, so a + * phone held up to the board finds its table. Vector, so it is sharp at any + * size; it resolves to a filled shape. + */ +export interface QrElement extends ElementBase { + kind: "qr"; + /** e.g. `"{{Guest Link}}"`. */ + data: string; + colorHex: Hex; +} + export type CardElement = | TextElement | IconElement | RectElement | LineElement | ImageElement - | ListElement; + | ListElement + | GridElement + | RoomElement + | QrElement; /** * Any field of any element kind, except the two that establish identity. @@ -262,7 +380,10 @@ type PatchableKey = Exclude< | keyof RectElement | keyof LineElement | keyof ImageElement - | keyof ListElement, + | keyof ListElement + | keyof GridElement + | keyof RoomElement + | keyof QrElement, "kind" | "id" >; @@ -291,6 +412,13 @@ export interface Template { elements: CardElement[]; backgroundHex: Hex | null; rowScope?: RowScope; + /** + * How a guest is named wherever a design names them by their chair — a plan's + * labels, `{{At seat 3}}`, `{{Table 1, seat 3}}` — as tokens of the sitter's + * own row: `"{{First Name}}"`, `"{{Last Name}}, {{First Name}}"`. Absent means + * the whole name. See core/template/chairs. + */ + chairName?: string; /** * Sparse per-row design patches, by row id then element id. See * core/template/overrides — design, not data, so it lives here and travels in @@ -299,6 +427,48 @@ export interface Template { overrides?: Record>; } +/** + * The seating plan as a room element needs it: positions in the plan's own + * units, which the element scales into its box. Built outside core from the + * wedding (state/roomScene) and handed in, as fonts and images are. + */ +export interface RoomScene { + bounds: { x: number; y: number; w: number; h: number }; + walls: Segment[]; + /** A seat's drawn radius. */ + seatRadius: number; + tables: RoomTable[]; +} + +export interface RoomTable { + label: string; + x: number; + y: number; + rotationDeg: number; + /** The table's outline in its own coordinates, centred on 0,0, before rotation. */ + pathD: string; + view: { x: number; y: number; w: number; h: number }; + /** + * True when the table numbers its seats. Where it does not, guests sit where + * they like: they are named inside the table, as Seating shows them, never + * at a chair. + */ + numbered: boolean; + /** The largest box inside the table's outline, in its own coordinates. */ + interior: { x: number; y: number; w: number; h: number }; + seats: RoomSeat[]; +} + +export interface RoomSeat { + x: number; + y: number; + /** Away from the table, unit length: where the name hangs. */ + out: Point; + number: number; + /** The sitter's row, as their own card reads it; null for an empty chair. */ + row: GuestRow | null; +} + // --------------------------------------------------------------------------- // Resolved scene (after bindings + fitting). Renderers consume only this. // --------------------------------------------------------------------------- @@ -311,6 +481,11 @@ export interface Template { */ export interface ResolvedBase { id: ElementId; + /** + * The design element this came from, when it is one of several pieces of + * it — a grid's blocks. Selecting a piece selects the element. + */ + sourceId?: ElementId; x: Mm; y: Mm; w: Mm; diff --git a/suite/apps/plaque/core/units.ts b/suite/apps/plaque/core/units.ts index b9036d40..2fc24799 100644 --- a/suite/apps/plaque/core/units.ts +++ b/suite/apps/plaque/core/units.ts @@ -1,4 +1,4 @@ -import type { Mm, Orientation, PageSizeName, Pt, Size } from "./types"; +import type { Mm, Orientation, PageSizeName, PaperName, Pt, Size } from "./types"; /** 1 inch = 25.4mm = 72 PostScript points. */ const PT_PER_MM = 72 / 25.4; @@ -12,17 +12,31 @@ export function ptToMm(pt: Pt): Mm { } /** Portrait dimensions. Landscape is the swap, done by `pageSizeMm`. */ -export const PAGE_SIZES_MM: Record = { +export const PAGE_SIZES_MM: Record = { A4: { w: 210, h: 297 }, LETTER: { w: 215.9, h: 279.4 }, + A3: { w: 297, h: 420 }, }; export const PAGE_LABELS: Record = { A4: "A4 (210 × 297mm)", LETTER: "US Letter (8.5 × 11in)", + A3: "A3 (297 × 420mm)", + FIT: "The card's own size — for a print shop", }; -export function pageSizeMm(page: PageSizeName, orientation: Orientation): Size { +/** + * The longest side of anything Plaque makes: A0, the largest sheet a print + * shop commonly takes. + */ +export const LARGEST_CARD_MM = 1189; + +/** The paper a home printer is fed for this sheet: the sheet's own, or what a print-shop page is tiled onto. */ +export function homePaper(sheet: { page: PageSizeName; tilePaper: PaperName }): PaperName { + return sheet.page === "FIT" ? sheet.tilePaper : sheet.page; +} + +export function pageSizeMm(page: PaperName, orientation: Orientation): Size { const portrait = PAGE_SIZES_MM[page]; return orientation === "portrait" ? { ...portrait } : { w: portrait.h, h: portrait.w }; } diff --git a/suite/apps/plaque/data/card-presets.json b/suite/apps/plaque/data/card-presets.json index 618c65a8..b057f8c7 100644 --- a/suite/apps/plaque/data/card-presets.json +++ b/suite/apps/plaque/data/card-presets.json @@ -72,6 +72,70 @@ "heightMm": 277, "fold": "none", "foldPositionMm": 138.5 + }, + { + "id": "table-card-a5", + "name": "Table card — A5, 148 × 210mm", + "widthMm": 148, + "heightMm": 210, + "fold": "none", + "foldPositionMm": 105 + }, + { + "id": "board-a3", + "name": "Seating board — A3, 297 × 420mm", + "widthMm": 297, + "heightMm": 420, + "fold": "none", + "foldPositionMm": 210 + }, + { + "id": "board-a2", + "name": "Seating board — A2, 420 × 594mm", + "widthMm": 420, + "heightMm": 594, + "fold": "none", + "foldPositionMm": 297 + }, + { + "id": "board-a1", + "name": "Seating board — A1, 594 × 841mm", + "widthMm": 594, + "heightMm": 841, + "fold": "none", + "foldPositionMm": 420.5 + }, + { + "id": "board-a0", + "name": "Seating board — A0, 841 × 1189mm", + "widthMm": 841, + "heightMm": 1189, + "fold": "none", + "foldPositionMm": 594.5 + }, + { + "id": "board-50x70", + "name": "Seating board — 50 × 70cm", + "widthMm": 500, + "heightMm": 700, + "fold": "none", + "foldPositionMm": 350 + }, + { + "id": "board-18x24", + "name": "Seating board — 18 × 24in", + "widthMm": 457.2, + "heightMm": 609.6, + "fold": "none", + "foldPositionMm": 304.8 + }, + { + "id": "board-24x36", + "name": "Seating board — 24 × 36in", + "widthMm": 609.6, + "heightMm": 914.4, + "fold": "none", + "foldPositionMm": 457.2 } ] } diff --git a/suite/apps/plaque/export/packFloorPlan.test.ts b/suite/apps/plaque/export/packFloorPlan.test.ts new file mode 100644 index 00000000..79577a0c --- /dev/null +++ b/suite/apps/plaque/export/packFloorPlan.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { GALLERY } from "../core/data/gallery"; +import { ELEMENT_KINDS } from "../core/template/registry"; +import { initialSuite, newPiece } from "../state/design"; +import { packFloorPlan } from "./packFloorPlan"; + +describe("the floor plan the wedding pack prints", () => { + const card = { widthMm: 90, heightMm: 55, fold: "none", foldPositionMm: 0, invertBackPanel: false, bleedMm: 0 } as const; + const map = ELEMENT_KINDS.find((k) => k.kind === "room")!.create({ id: "map", z: 1, card, headers: [] }); + + it("is not an escort card that happens to carry a map of the room", () => { + const suite = initialSuite(); + suite.pieces[0]!.template = { ...suite.pieces[0]!.template, elements: [map] }; + expect(packFloorPlan(suite, []).name).toBe("Floor plan"); + expect(packFloorPlan(suite, []).template.rowScope).toEqual({ kind: "document" }); + }); + + it("is the wedding's own whole-list plan when it has one", () => { + const suite = initialSuite(); + const plan = { ...newPiece("our-plan", "Our plan"), template: { ...GALLERY.find((g) => g.id === "floor-plan")!.template } }; + suite.pieces.push(plan); + expect(packFloorPlan(suite, []).name).toBe("Our plan"); + }); +}); diff --git a/suite/apps/plaque/export/packFloorPlan.ts b/suite/apps/plaque/export/packFloorPlan.ts new file mode 100644 index 00000000..6752a977 --- /dev/null +++ b/suite/apps/plaque/export/packFloorPlan.ts @@ -0,0 +1,104 @@ +import type { Knotwork } from "@jfrusher/knotwork"; +import { fromGallery, GALLERY } from "../core/data/gallery"; +import { buildJob } from "../core/job"; +import { makeResolveOptions } from "../core/template/resolve"; +import type { CardSpec, SheetSpec, Template } from "../core/types"; +import { initialDesign, type Suite } from "../state/design"; +import { loadEveryFont } from "../state/fontLoader"; +import { roomRows } from "../state/fromRoom"; +import { loadImages, toSource } from "../state/imageStore"; +import { roomScene } from "../state/roomScene"; +import { readSuite } from "../state/sliceBridge"; + +/** A4, in PDF points. */ +const A4 = { short: 595.28, long: 841.89 }; + +/** + * The plan the wedding pack prints: the wedding's own floor plan — the first + * piece that is one sheet for the whole list and draws the whole room — as + * designed in Place cards. An escort card with a little map of the room is not + * a floor plan. A wedding that has not made one yet gets the floor plan design + * as it starts. + */ +export function packFloorPlan(suite: Suite, headers: string[]): { name: string; card: CardSpec; sheet: SheetSpec; template: Template } { + const piece = suite.pieces.find( + (p) => + p.template.rowScope?.kind === "document" && + p.template.elements.some((el) => el.kind === "room" && el.show === "room"), + ); + if (piece) return { name: piece.name, card: piece.card, sheet: piece.sheet, template: piece.template }; + const entry = GALLERY.find((g) => g.id === "floor-plan"); + if (!entry) throw new Error('The gallery has no "floor-plan" design.'); + const { card, sheet } = initialDesign(); + return { name: "Floor plan", ...fromGallery(entry, card, sheet, headers) }; +} + +/** + * The room for the wedding pack, on A4: the floor plan through Place cards' + * own pipeline — the same fonts, the same fitting — drawn on its own card + * with no print-shop marks, then shrunk onto the binder's page. + */ +export async function packFloorPlanPdf(wedding: { raw: Record; doc: Knotwork }): Promise { + const scene = roomScene(wedding.doc); + if (scene.tables.length === 0) return null; + const { suite } = readSuite(wedding); + const { headers, rows, rowIds } = roomRows(wedding.doc); + const plan = packFloorPlan(suite, headers); + + const [{ fonts }, images, { renderPdf }] = await Promise.all([ + loadEveryFont(), + loadImages(), + import("../render/pdf/renderPdf"), + ]); + const job = buildJob({ + template: plan.template, + card: plan.card, + // The card alone: its own page, nothing round it to cut by. + sheet: { + ...plan.sheet, + page: "FIT", + marginTopMm: 0, + marginRightMm: 0, + marginBottomMm: 0, + marginLeftMm: 0, + cropMarks: false, + cutLines: false, + foldGuides: false, + duplex: false, + slugLine: false, + }, + rows, + headers, + rowIds, + resolve: makeResolveOptions( + fonts, + suite.uploadedIcons, + new Map(images.map((i) => [i.id, toSource(i)])), + suite.assetNames, + scene, + ), + }); + const { bytes } = await renderPdf({ sheets: job.sheets, fonts, title: plan.name, scale: 1 }); + return onA4(bytes); +} + +/** Each page shrunk to fit A4, turned the way it is, centred. */ +async function onA4(bytes: Uint8Array): Promise { + const { PDFDocument } = await import("pdf-lib"); + const source = await PDFDocument.load(bytes); + const out = await PDFDocument.create(); + out.setTitle(source.getTitle() ?? "Floor plan"); + const pages = await out.embedPages(source.getPages()); + for (const page of pages) { + const landscape = page.width > page.height; + const [w, h] = landscape ? [A4.long, A4.short] : [A4.short, A4.long]; + const k = Math.min(w / page.width, h / page.height); + out.addPage([w, h]).drawPage(page, { + x: (w - page.width * k) / 2, + y: (h - page.height * k) / 2, + width: page.width * k, + height: page.height * k, + }); + } + return out.save(); +} diff --git a/suite/apps/plaque/render/pdf/calibrationPdf.ts b/suite/apps/plaque/render/pdf/calibrationPdf.ts index 1b8b0594..b4798218 100644 --- a/suite/apps/plaque/render/pdf/calibrationPdf.ts +++ b/suite/apps/plaque/render/pdf/calibrationPdf.ts @@ -1,10 +1,10 @@ import { PDFDocument, StandardFonts, rgb } from "pdf-lib"; import { REFERENCE_RULE_MM } from "../../core/print/printerProfile"; -import type { Mm, Orientation, PageSizeName } from "../../core/types"; +import type { Mm, Orientation, PaperName } from "../../core/types"; import { mmToPt, pageSizeMm } from "../../core/units"; export interface CalibrationPdfOptions { - page: PageSizeName; + page: PaperName; orientation: Orientation; printerName?: string; } diff --git a/suite/apps/plaque/render/pdf/duplexTestPdf.ts b/suite/apps/plaque/render/pdf/duplexTestPdf.ts index ed685c7f..4f4f72e1 100644 --- a/suite/apps/plaque/render/pdf/duplexTestPdf.ts +++ b/suite/apps/plaque/render/pdf/duplexTestPdf.ts @@ -1,11 +1,11 @@ import { PDFDocument, StandardFonts, rgb, type PDFFont, type PDFPage } from "pdf-lib"; import { mirrorAxisFor, type FlipEdge } from "../../core/imposition/duplex"; import { READABLE_SPAN_MM, SKEW_THRESHOLD_MM } from "../../core/print/printerProfile"; -import type { Mm, Orientation, PageSizeName, Point } from "../../core/types"; +import type { Mm, Orientation, PaperName, Point } from "../../core/types"; import { mmToPt, pageSizeMm, ptToMm } from "../../core/units"; export interface DuplexTestOptions { - page: PageSizeName; + page: PaperName; orientation: Orientation; /** The choice being tested. Page two is mirrored exactly as a real back sheet is. */ flipEdge: FlipEdge; diff --git a/suite/apps/plaque/render/svg/CardCanvas.tsx b/suite/apps/plaque/render/svg/CardCanvas.tsx index 473eff88..12c0c3ea 100644 --- a/suite/apps/plaque/render/svg/CardCanvas.tsx +++ b/suite/apps/plaque/render/svg/CardCanvas.tsx @@ -313,7 +313,8 @@ export function CardCanvas({ beginMove(e, el.id)} + // A grid's blocks are pieces of one element: grabbing any moves the grid. + onPointerDown={(e) => beginMove(e, el.sourceId ?? el.id)} onDoubleClick={() => { // Only a cropped image has anything to drag inside its box. if (el.kind === "image" && el.fit === "cover") onRequestCrop?.(el.id); diff --git a/suite/apps/plaque/state/design.ts b/suite/apps/plaque/state/design.ts index b21004ab..2bbb725b 100644 --- a/suite/apps/plaque/state/design.ts +++ b/suite/apps/plaque/state/design.ts @@ -1,26 +1,26 @@ -import type { RowIssue, GuestRow } from "../core/data/rows"; -import { defaultCard, defaultSheet } from "../core/template/defaults"; +import { defaultCard, defaultSheet, defaultTemplate } from "../core/template/defaults"; import type { CardSpec, SheetSpec, Template } from "../core/types"; +import { ROOM_COLUMNS, type Merged } from "./fromRoom"; +import { FIRST_PIECE } from "./suite"; +import type { Printed } from "./printed"; /** * What of Place cards is the wedding's: the stationery slice. Everything * else in Plaque's store — fonts, images, printers, what is selected, which * page — belongs to this window. * - * Guest data is included deliberately: losing a hundred and fifty names to an - * accidental tab close is the worst papercut this app could have. + * No guest data: every piece prints from the room as it stands, so a copy of + * the list here could only ever be a stale one. What is kept is who shares a + * card, by guest id, which the room cannot know. */ export interface Design { card: CardSpec; sheet: SheetSpec; template: Template; - headers: string[]; - rows: GuestRow[]; - /** Row identity and combines, without which per-row overrides lose their anchor. */ - rowIds: string[]; - merged: Record; - csvIssues: RowIssue[]; - fileName: string | null; + /** Guests printed together on one card. See `withMerges`. */ + merged: Merged; + /** What this piece last went to the printer as, or null if it never has. */ + printed: Printed | null; uploadedIcons: Record; /** Filenames of uploaded assets, so a lost blob can still be named (S-D1.4). */ assetNames: Record; @@ -33,34 +33,22 @@ export const DESIGN_KEYS = [ "card", "sheet", "template", - "headers", - "rows", - "rowIds", "merged", - "csvIssues", - "fileName", + "printed", "uploadedIcons", "assetNames", "snapEnabled", "sheetCollapsed", ] as const satisfies readonly (keyof Design)[]; -/** - * The starting design has an EMPTY template on purpose. Building a default - * template before any CSV exists would produce elements bound to columns that - * do not exist, and would then block `setCsv` from laying out a real one. - */ +/** A blank design: nothing on the card yet. */ export function initialDesign(): Design { return { card: defaultCard(), sheet: defaultSheet(), template: { elements: [], backgroundHex: null }, - headers: [], - rows: [], - rowIds: [], merged: {}, - csvIssues: [], - fileName: null, + printed: null, uploadedIcons: {}, assetNames: {}, snapEnabled: true, @@ -72,3 +60,62 @@ export function initialDesign(): Design { export function designOf(source: Design): Design { return Object.fromEntries(DESIGN_KEYS.map((key) => [key, source[key]])) as unknown as Design; } + +/** What each piece has of its own. Everything else in a `Design` is shared by the suite. */ +export const PIECE_KEYS = ["card", "sheet", "template", "merged", "printed"] as const satisfies readonly (keyof Design)[]; + +export type PieceDesign = Pick; +export type SharedDesign = Omit; + +/** One printed thing — place cards, table numbers, the seating board. */ +export interface Piece extends PieceDesign { + id: string; + name: string; +} + +/** The stationery slice: the pieces in order, and what they share. */ +export interface Suite extends SharedDesign { + pieces: Piece[]; +} + +/** + * A wedding's stationery before anything is made: place cards, laid out for + * the room's columns so the first thing anyone sees is a card with a name on it. + */ +export function initialSuite(): Suite { + const { pieces: _none, ...shared } = splitDesign(initialDesign()); + const placeCards = newPiece(FIRST_PIECE.id, FIRST_PIECE.name); + return { ...shared, pieces: [{ ...placeCards, template: defaultTemplate([...ROOM_COLUMNS], placeCards.card) }] }; +} + +/** An empty piece. */ +export function newPiece(id: string, name: string): Piece { + return { id, name, ...splitDesign(initialDesign()).pieces[0]! }; +} + +/** The flat view the editor works on: one piece, with what every piece shares. */ +export function designFor(suite: Suite, pieceId: string): Design { + const piece = suite.pieces.find((p) => p.id === pieceId); + if (!piece) throw new Error(`No piece "${pieceId}" in the stationery.`); + const { pieces: _pieces, ...shared } = suite; + const { id: _id, name: _name, ...own } = piece; + return { ...shared, ...own }; +} + +/** The suite with `design` written back as piece `pieceId`, and as what the pieces share. */ +export function withDesign(suite: Suite, pieceId: string, design: Design): Suite { + if (!suite.pieces.some((p) => p.id === pieceId)) throw new Error(`No piece "${pieceId}" in the stationery.`); + const { pieces: [own], ...shared } = splitDesign(design); + return { + ...shared, + pieces: suite.pieces.map((p) => (p.id === pieceId ? { id: p.id, name: p.name, ...own! } : p)), + }; +} + +function splitDesign(design: Design): { pieces: [PieceDesign] } & SharedDesign { + const own = Object.fromEntries(PIECE_KEYS.map((key) => [key, design[key]])) as unknown as PieceDesign; + const shared = Object.fromEntries( + DESIGN_KEYS.filter((key) => !(PIECE_KEYS as readonly string[]).includes(key)).map((key) => [key, design[key]]), + ) as unknown as SharedDesign; + return { ...shared, pieces: [own] }; +} diff --git a/suite/apps/plaque/state/fontLoader.ts b/suite/apps/plaque/state/fontLoader.ts index 8bb968b1..8dff2e19 100644 --- a/suite/apps/plaque/state/fontLoader.ts +++ b/suite/apps/plaque/state/fontLoader.ts @@ -1,5 +1,6 @@ import { BUNDLED_FONTS, type BundledFont } from "../assets/fonts"; import { loadFont, type LoadedFont } from "../core/text/measure"; +import { loadFonts as loadStoredFonts } from "./blobStore"; /** * Static files under `public/`, served from this app's own origin — nothing @@ -55,3 +56,27 @@ export async function loadBundledFonts(): Promise> { ); return new Map(entries); } + +/** + * Every face this device has: the bundled ones and any uploaded, with the + * names they are shown by. An upload that no longer parses is left out + * rather than stopping what wanted the rest. + */ +export async function loadEveryFont(): Promise<{ + fonts: Map; + labels: Record; + uploadedIds: string[]; +}> { + const fonts = await loadBundledFonts(); + const labels: Record = Object.fromEntries(BUNDLED_FONTS.map((f) => [f.id, f.label])); + const stored = await loadStoredFonts(); + for (const f of stored) { + try { + fonts.set(f.id, await registerFont(f.id, f.family, f.data)); + labels[f.id] = f.family; + } catch { + // A font that no longer parses should not stop the rest from loading. + } + } + return { fonts, labels, uploadedIds: stored.map((f) => f.id) }; +} diff --git a/suite/apps/plaque/state/fromRoom.test.ts b/suite/apps/plaque/state/fromRoom.test.ts index e41b2f12..03bbaa12 100644 --- a/suite/apps/plaque/state/fromRoom.test.ts +++ b/suite/apps/plaque/state/fromRoom.test.ts @@ -1,62 +1,113 @@ -// @vitest-environment jsdom -import { beforeEach, describe, expect, it } from "vitest"; -import { useKnotworkStore } from "@/lib/store/useKnotworkStore"; -import { rowsFromRoom } from "./fromRoom"; +import { describe, expect, it } from "vitest"; +import { migrate } from "@jfrusher/knotwork"; +import { ROOM_COLUMNS, roomRows, withMerges } from "./fromRoom"; /** * The point of this path is that a card cannot disagree with the seating plan, * so what is worth holding is the join: that a guest's table comes out as the - * label printed on the plan rather than an id, and that nobody quietly loses - * their card for not having a seat yet. + * label printed on the plan rather than an id, that a seat is the one the plan + * numbers, and that nobody quietly loses their card for not having a seat yet. + * + * Guests are seated as Seating seats them: on the table's own list, which is + * the record that wins (lib/seating/normalise), and mirrored on the guest. */ -const seat = (guests: Record, tables: Record) => { - useKnotworkStore.getState().replaceDocument({ guests, seating: { tables } }); -}; +type Raw = Record>; -beforeEach(() => { - useKnotworkStore.getState().replaceDocument({}); -}); +const wedding = (guests: Raw, tables: Raw, event: Record = {}) => { + const seated: Raw = { ...guests }; + for (const table of Object.values(tables)) { + for (const id of (table["assignedGuestIds"] as Array) ?? []) { + if (id) seated[id] = { ...seated[id], assignedTableId: table["id"] }; + } + } + return migrate({ guests: seated, seating: { tables }, event }); +}; describe("printing from the room", () => { it("prints the table's label, not its id", () => { - seat( - { g1: { id: "g1", firstName: "Charis", lastName: "Smith", assignedTableId: "t7" } }, - { t7: { id: "t7", label: "Top Table" } }, + const doc = wedding( + { g1: { id: "g1", firstName: "Charis", lastName: "Smith" } }, + { t7: { id: "t7", label: "Top Table", assignedGuestIds: ["g1"] } }, ); - expect(rowsFromRoom().rows[0]).toMatchObject({ + expect(roomRows(doc).rows[0]).toMatchObject({ "First Name": "Charis", "Last Name": "Smith", Name: "Charis Smith", Table: "Top Table", }); + expect(roomRows(doc).rowIds).toEqual(["g1"]); + }); + + it("numbers a seat only where the table numbers its seats, as the plan stores it", () => { + const doc = wedding( + { + g1: { id: "g1", firstName: "Charis" }, + g2: { id: "g2", firstName: "Tobias" }, + g3: { id: "g3", firstName: "Eleanor" }, + }, + { + t1: { id: "t1", label: "Table 1", seatMode: "seat", assignedGuestIds: [null, "g1", null, "g2"] }, + t2: { id: "t2", label: "Table 2", seatMode: "table", assignedGuestIds: ["g3"] }, + }, + ); + const seat = Object.fromEntries(roomRows(doc).rows.map((row) => [row["First Name"], row["Seat"]])); + expect(seat).toEqual({ Charis: "2", Tobias: "4", Eleanor: "" }); + }); + + it("numbers tables as people read their labels, and counts who sits at each", () => { + const doc = wedding( + { g1: { id: "g1", firstName: "Ann" }, g2: { id: "g2", firstName: "Bo" }, g3: { id: "g3", firstName: "Cy" } }, + { + a: { id: "a", label: "Table 10", assignedGuestIds: ["g1"] }, + b: { id: "b", label: "Table 2", assignedGuestIds: ["g2", "g3", null] }, + }, + ); + const rows = Object.fromEntries(roomRows(doc).rows.map((row) => [row["First Name"], row])); + expect(rows["Ann"]).toMatchObject({ "Table Number": "2", "Table Size": "1" }); + expect(rows["Bo"]).toMatchObject({ "Table Number": "1", "Table Size": "2" }); + }); + + it("gives each guest the letter a finder files them under", () => { + const doc = wedding( + { g1: { id: "g1", firstName: "Charis", lastName: "smith" }, g2: { id: "g2", firstName: "Prince", lastName: "" } }, + {}, + ); + expect(roomRows(doc).rows.map((row) => row["Initial"])).toEqual(["S", "P"]); + }); + + it("carries the name a guest is known by on the stationery, and nothing for one who has none", () => { + const doc = wedding( + { g1: { id: "g1", firstName: "Josephine", lastName: "Clarke", knownAs: "Granny Jo" }, g2: { id: "g2", firstName: "Ada", lastName: "Byron" } }, + {}, + ); + expect(roomRows(doc).rows.map((row) => row["Known As"])).toEqual(["", "Granny Jo"]); }); it("prints a side as the partners call it, not the id it is stored under", () => { - useKnotworkStore.getState().replaceDocument({ - event: { partners: ["Alex", "Sam"] }, - guests: { + const doc = wedding( + { g1: { id: "g1", firstName: "Charis", side: "a" }, g2: { id: "g2", firstName: "Tobias", side: "both" }, }, - seating: { tables: {} }, - }); - - expect(rowsFromRoom().rows.map((row) => row["Side"])).toEqual(["Alex’s side", "Both sides"]); + {}, + { partners: ["Alex", "Sam"] }, + ); + expect(roomRows(doc).rows.map((row) => row["Side"])).toEqual(["Alex’s side", "Both sides"]); }); it("still prints a card for someone with no table, and says so once", () => { - seat( + const doc = wedding( { - g1: { id: "g1", firstName: "Charis", assignedTableId: "t1" }, + g1: { id: "g1", firstName: "Charis" }, g2: { id: "g2", firstName: "Tobias" }, g3: { id: "g3", firstName: "Eleanor" }, }, - { t1: { id: "t1", label: "Table 1" } }, + { t1: { id: "t1", label: "Table 1", assignedGuestIds: ["g1"] } }, ); - const { rows, issues } = rowsFromRoom(); + const { rows, issues } = roomRows(doc); // Three cards, not one. An unseated guest is a job still to do; a card that // silently went missing is how somebody arrives to no place at all. expect(rows).toHaveLength(3); @@ -66,58 +117,68 @@ describe("printing from the room", () => { }); it("prints no card for someone who said they are not coming, nor counts them unseated", () => { - seat( + const doc = wedding( { - g1: { id: "g1", firstName: "Charis", assignedTableId: "t1" }, + g1: { id: "g1", firstName: "Charis" }, g2: { id: "g2", firstName: "Tobias", rsvpStatus: "declined" }, }, - { t1: { id: "t1", label: "Table 1" } }, + { t1: { id: "t1", label: "Table 1", assignedGuestIds: ["g1"] } }, ); - const { rows, issues } = rowsFromRoom(); + const { rows, issues } = roomRows(doc); expect(rows.map((row) => row["First Name"])).toEqual(["Charis"]); expect(issues).toEqual([]); }); - it("says nothing when everyone is seated", () => { - seat( - { g1: { id: "g1", firstName: "Charis", assignedTableId: "t1" } }, - { t1: { id: "t1", label: "Table 1" } }, - ); - expect(rowsFromRoom().issues).toEqual([]); + it("is read once per wedding, so a screen that asks twice gets the same rows", () => { + const doc = wedding({ g1: { id: "g1", firstName: "Charis" } }, {}); + expect(roomRows(doc)).toBe(roomRows(doc)); }); - it("leaves the table blank when the plan no longer has it", () => { - // The table was deleted in the room while the guest still pointed at it. - // Better an empty line on the card than the word "t9" printed on the table. - seat({ g1: { id: "g1", firstName: "Charis", assignedTableId: "t9" } }, {}); - expect(rowsFromRoom().rows[0]?.["Table"]).toBe(""); + it("offers the columns a card is actually set from", () => { + expect(roomRows(wedding({}, {})).headers).toEqual([...ROOM_COLUMNS]); }); +}); - it("prints only the chosen guests when reprinting a few", () => { - seat( - { - g1: { id: "g1", firstName: "Charis", assignedTableId: "t1" }, - g2: { id: "g2", firstName: "Tobias", assignedTableId: "t1" }, - g3: { id: "g3", firstName: "Eleanor", assignedTableId: "t1" }, - }, - { t1: { id: "t1", label: "Table 1" } }, - ); +describe("combined cards", () => { + const doc = wedding( + { + g1: { id: "g1", firstName: "Ada", lastName: "Byron" }, + g2: { id: "g2", firstName: "Grace", lastName: "Hopper" }, + g3: { id: "g3", firstName: "Alan", lastName: "Turing" }, + }, + { t4: { id: "t4", label: "Table 4", assignedGuestIds: ["g1", "g2", "g3"] } }, + ); - const { rows, fileName } = rowsFromRoom(new Set(["g1", "g3"])); - expect(rows.map((row) => row["First Name"])).toEqual(["Charis", "Eleanor"]); - // Still the room: a reprint must not read as cards from an imported file. - expect(fileName).toBe("the room"); + it("stands in for its people at the first of them", () => { + const { rows, rowIds } = withMerges(roomRows(doc), { "merged:x": ["g2", "g1"] }, {}); + expect(rowIds).toEqual(["merged:x", "g3"]); + expect(rows[0]).toMatchObject({ "First Name": "Grace & Ada", Table: "Table 4" }); }); - it("offers the columns a card is actually set from", () => { - seat({}, {}); - expect(rowsFromRoom().headers).toEqual([ - "First Name", - "Last Name", - "Name", - "Table", - "Dietary", - "Side", - ]); + it("leaves out somebody no longer on the list, and drops a card with nobody left", () => { + const { rowIds, rows } = withMerges(roomRows(doc), { "merged:x": ["g1", "gone"], "merged:y": ["gone"] }, {}); + // In name order, as the room lists them: Alan before Grace. + expect(rowIds).toEqual(["merged:x", "g3", "g2"]); + expect(rows[0]?.["First Name"]).toBe("Ada"); + }); + + it("is the room itself when nobody is combined", () => { + const room = roomRows(doc); + expect(withMerges(room, {}, {}).rows).toBe(room.rows); + }); + + it("names everyone on a combined card when one of them is known by a name of their own", () => { + const doc = wedding( + { g1: { id: "g1", firstName: "Charis", lastName: "Smith", knownAs: "Granny" }, g2: { id: "g2", firstName: "Eleanor", lastName: "Vane" } }, + {}, + ); + expect(withMerges(roomRows(doc), { "merged:x": ["g1", "g2"] }, {}).rows.map((row) => row["Known As"])).toEqual(["Granny & Eleanor Vane"]); + // The others are named as the design names everyone. + expect(withMerges(roomRows(doc), { "merged:x": ["g1", "g2"] }, { chairName: "{{First Name}}" }).rows.map((row) => row["Known As"])).toEqual(["Granny & Eleanor"]); + }); + + it("files someone whose surname is only spaces under their first name", () => { + const doc = wedding({ g1: { id: "g1", firstName: "Prince", lastName: " " } }, {}); + expect(roomRows(doc).rows.map((row) => [row["Initial"], row["Last Initial"]])).toEqual([["P", ""]]); }); }); diff --git a/suite/apps/plaque/state/fromRoom.ts b/suite/apps/plaque/state/fromRoom.ts index 04376fe6..c7e164ad 100644 --- a/suite/apps/plaque/state/fromRoom.ts +++ b/suite/apps/plaque/state/fromRoom.ts @@ -1,71 +1,95 @@ -import { guestName, isComing, readGuests, readSeating } from "@/lib/model/slices"; +import type { Knotwork } from "@jfrusher/knotwork"; +import { cached, guestName, isComing, readGuests } from "@/lib/model/slices"; +import { readSeats } from "@/lib/model/seats"; import { dietaryText } from "@/lib/model/dietary"; import { sideLabel } from "@/lib/model/partners"; -import { useKnotworkStore } from "@/lib/store/useKnotworkStore"; import type { RowIssue, GuestRow } from "../core/data/rows"; +import { chairName } from "../core/template/chairs"; +import type { Template } from "../core/types"; /** - * The guest list Place cards prints from: the room, and nothing else. + * The guest list Place cards prints from: the room, and nothing else, read + * fresh from the wedding every time it changes. * * Plaque was a standalone app, so the only way in was a CSV exported from * somewhere else — and a CSV exported before the last three people moved * prints three wrong tables. Here the wedding already holds the list, and - * every table number on those cards is a fact this app already has. + * every table number on those cards is a fact this app already has. Nothing is + * copied, so there is nothing to go stale. * * Going through the file shape rather than around it is deliberate. Plaque's * whole design is built on columns — you bind `{{First Name}}` to a text * element and it prints once per row — so handing it rows is handing it - * something it already knows exactly what to do with. Nothing in the store, the - * template binding or the export had to learn about seating. - * - * The columns are the ones a card actually uses. Everything else the room holds - * — coordinates, groups, RSVP state — would only appear as tokens nobody binds. + * something it already knows exactly what to do with. */ -const COLUMNS = ["First Name", "Last Name", "Name", "Table", "Dietary", "Side"] as const; +/** The columns a card can bind, and the only ones. */ +export const ROOM_COLUMNS = [ + "First Name", + "Last Name", + "Name", + "Known As", + "Initial", + "Last Initial", + "Table", + "Table Number", + "Table Size", + "Seat", + "Place", + "Dietary", + "Side", + "Guest Link", +] as const; export interface RoomRows { headers: string[]; rows: GuestRow[]; + /** Guest ids, one per row: what per-guest tweaks and combined cards hang off. */ + rowIds: string[]; issues: RowIssue[]; - fileName: string; } -/** - * Reads the wedding as it stands. Empty when there are no guests yet. - * - * `only` narrows it to some of the guests, by id, for reprinting a handful of - * cards — a misspelt name, a late change of table — without the whole run. - */ -export function rowsFromRoom(only?: ReadonlySet): RoomRows { - const { doc } = useKnotworkStore.getState(); - const everyone = readGuests(doc); - const guests = only - ? Object.fromEntries(Object.entries(everyone).filter(([id]) => only.has(id))) - : everyone; - const seating = readSeating(doc); +/** The wedding's guests as rows, once per document. */ +export function roomRows(doc: Knotwork): RoomRows { + return cached(doc, "plaque.roomRows", () => build(doc)); +} - const tableLabel = new Map(); - for (const [id, table] of Object.entries(seating.tables)) { - tableLabel.set(id, table.label || id); - } +function build(doc: Knotwork): RoomRows { + const seats = readSeats(doc); // Somebody who said they are not coming is not at the wedding — no card, // and not a guest "with no table yet". The guest link keeps the same rule. - const people = Object.values(guests) + const people = Object.values(readGuests(doc)) .filter(isComing) .sort((a, b) => guestName(a).localeCompare(guestName(b), "en")); - const rows: GuestRow[] = people.map((guest) => ({ - "First Name": guest.firstName, - "Last Name": guest.lastName, - Name: guestName(guest), - Table: guest.assignedTableId ? (tableLabel.get(guest.assignedTableId) ?? "") : "", - // What the guest said, so the card reads "Coeliac" rather than "gluten-free". - Dietary: dietaryText(guest), - // "Alex’s side", as everywhere else — the stored "a" means nothing on a card. - Side: sideLabel(guest.side, doc.event), - })); + const rows: GuestRow[] = people.map((guest) => { + const at = seats.get(guest.id); + return { + "First Name": guest.firstName, + "Last Name": guest.lastName, + Name: guestName(guest), + // Their own name for the stationery, which a design's name format gives way to. + "Known As": guest.knownAs, + // What a finder groups by: the surname's letter, or the first name's for + // someone listed by one name. + Initial: (guest.lastName.trim() || guest.firstName.trim()).charAt(0).toLocaleUpperCase("en"), + // "B." for Ada Byron, and nothing for someone known by one name: never a first name's letter. + "Last Initial": guest.lastName.trim() ? `${guest.lastName.trim().charAt(0).toLocaleUpperCase("en")}.` : "", + Table: at?.table ?? "", + "Table Number": at ? String(at.tableNumber) : "", + "Table Size": at ? String(at.tableSize) : "", + Seat: at?.seat ? String(at.seat) : "", + // Where to go, in one phrase that reads right whether or not the table numbers its seats. + Place: at ? (at.seat ? `${at.table}, seat ${at.seat}` : at.table) : "", + // What the guest said, so the card reads "Coeliac" rather than "gluten-free". + Dietary: dietaryText(guest), + // "Alex’s side", as everywhere else — the stored "a" means nothing on a card. + Side: sideLabel(guest.side, doc.event), + // Not the room's to know: the store fills it in once a link is published. + "Guest Link": "", + }; + }); /** * Someone with no table gets a card with an empty table line rather than no @@ -73,14 +97,12 @@ export function rowsFromRoom(only?: ReadonlySet): RoomRows { * and a card that silently went missing is how somebody arrives to no place * at all. Said once, as a count, rather than once per person. */ - const unseated = people.filter((guest) => !guest.assignedTableId).length; + const unseated = people.filter((guest) => !seats.has(guest.id)).length; const issues: RowIssue[] = unseated === 0 ? [] : [ { - // Not about any one row, so it has no row number — the same shape a - // problem with a whole file gets. row: null, message: unseated === 1 @@ -89,5 +111,58 @@ export function rowsFromRoom(only?: ReadonlySet): RoomRows { }, ]; - return { headers: [...COLUMNS], rows, issues, fileName: "the room" }; + return { headers: [...ROOM_COLUMNS], rows, rowIds: people.map((guest) => guest.id), issues }; +} + +/** + * Guests printed together on one card, by the combined card's id: "Ada & Grace". + * Stored as guest ids, so a combined card follows its people through the room. + */ +export type Merged = Record; + +/** + * The room's rows with each combined card in place of its people, at the first + * of them. Distinct values are joined in order: "Ada & Grace" for the names, + * "Table 4" once for the table they share. A member who has left the list is + * left out of the card; a card none of whose people remain is dropped. + */ +export function withMerges( + room: RoomRows, + merged: Merged, + design: Pick, +): Pick { + const cardOf = new Map(); + for (const [cardId, members] of Object.entries(merged)) { + for (const member of members) cardOf.set(member, cardId); + } + if (cardOf.size === 0) return { rows: room.rows, rowIds: room.rowIds }; + + const rowAt = new Map(room.rowIds.map((id, index) => [id, room.rows[index]!])); + const rows: GuestRow[] = []; + const rowIds: string[] = []; + const placed = new Set(); + room.rowIds.forEach((id, index) => { + const cardId = cardOf.get(id); + if (!cardId) { + rows.push(room.rows[index]!); + rowIds.push(id); + return; + } + if (placed.has(cardId)) return; + placed.add(cardId); + const members = merged[cardId]!.map((member) => rowAt.get(member)).filter((row): row is GuestRow => Boolean(row)); + const combined: GuestRow = {}; + for (const header of room.headers) { + combined[header] = [...new Set(members.map((row) => row[header]).filter(Boolean))].join(" & "); + } + // One of them known by a name of their own: name each of them as the + // design names guests, or the card would say only that one ("Granny Jo" + // for Granny Jo and Eleanor). + if (members.some((row) => row["Known As"])) { + combined["Known As"] = members.map((row) => chairName(design, row)).join(" & "); + } + rows.push(combined); + rowIds.push(cardId); + }); + return { rows, rowIds }; } diff --git a/suite/apps/plaque/state/history.test.ts b/suite/apps/plaque/state/history.test.ts new file mode 100644 index 00000000..11644731 --- /dev/null +++ b/suite/apps/plaque/state/history.test.ts @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("idb-keyval", () => ({ get: async () => undefined, set: async () => undefined, del: async () => undefined })); + +const { emptyKnotwork, migrate } = await import("@jfrusher/knotwork"); +const { useKnotworkStore } = await import("@/lib/store/useKnotworkStore"); +const { usePlaque } = await import("./store"); +const { initialSuite } = await import("./design"); +const { artefactsOf } = await import("../core/data/parts"); +const { printBasis } = await import("./printed"); + +/* + * What was printed is a fact about paper, not an edit: undo does not take it + * back, and a print is recorded on the piece it was made from. + */ +type Raw = Record; +const plaque = () => usePlaque.getState(); +const shared = () => useKnotworkStore.getState(); +const cards = () => artefactsOf(plaque().template, plaque().rows, plaque().headers, plaque().rowIds); +const basis = () => printBasis(plaque().template, plaque().room); + +beforeEach(() => { + const raw = { + ...(emptyKnotwork() as unknown as Raw), + guests: { + g1: { id: "g1", firstName: "Charis", lastName: "Smith" }, + g2: { id: "g2", firstName: "Eleanor", lastName: "Vane" }, + }, + stationery: { version: 3, savedAt: null, ...initialSuite() }, + }; + useKnotworkStore.setState({ status: "ready", raw, doc: migrate(raw), past: [], future: [] }); +}); + +describe("a print and the wedding's history", () => { + test("undoing an edit made before printing keeps the record of what was printed", () => { + plaque().setBackground("#ff0000"); + plaque().notePrinted(plaque().pieceId, cards(), false, basis()); + expect(plaque().printed).not.toBeNull(); + shared().undo(); + expect(plaque().template.backgroundHex).not.toBe("#ff0000"); + expect(plaque().printed).not.toBeNull(); + shared().redo(); + expect(plaque().printed).not.toBeNull(); + }); + + test("a print finished after switching pieces is recorded on the piece it was printed from", () => { + const from = plaque().pieceId; + const printedCards = cards(); + plaque().addPiece("Other"); + plaque().notePrinted(from, printedCards, false, basis()); + expect(plaque().printed).toBeNull(); + plaque().switchPiece(from); + expect(Object.keys(plaque().printed!.cards)).toHaveLength(2); + }); + + test("removing the open piece does not hand its chosen few to the next", () => { + plaque().addPiece("Second"); + const second = plaque().pieceId; + plaque().addPiece("Third"); + plaque().switchPiece(second); + plaque().setPrintOnly([cards()[0]!.key]); + plaque().removePiece(second); + expect(plaque().pieceId).not.toBe(second); + expect(plaque().printOnly).toBeNull(); + }); + + test("a piece made from a starter design is one step: one undo takes it all back", () => { + const before = plaque().pieces.length; + plaque().openPiece("floor-plan"); + expect(plaque().pieces.length).toBe(before + 1); + expect(plaque().template.elements.some((el) => el.kind === "room")).toBe(true); + shared().undo(); + expect(plaque().pieces.length).toBe(before); + }); + + test("a copy of a printed piece has not been printed", () => { + plaque().notePrinted(plaque().pieceId, cards(), false, basis()); + plaque().duplicatePiece(plaque().pieceId); + expect(plaque().printed).toBeNull(); + }); + + test("a print that finishes after its piece was removed is nothing to record, not an error", () => { + plaque().addPiece("Gone soon"); + const gone = plaque().pieceId; + const printedCards = cards(); + const before = basis(); + plaque().removePiece(gone); + expect(() => plaque().notePrinted(gone, printedCards, false, before)).not.toThrow(); + }); +}); diff --git a/suite/apps/plaque/state/live.test.ts b/suite/apps/plaque/state/live.test.ts index 3da0763d..6863b5e6 100644 --- a/suite/apps/plaque/state/live.test.ts +++ b/suite/apps/plaque/state/live.test.ts @@ -5,6 +5,7 @@ vi.mock("idb-keyval", () => ({ get: async () => undefined, set: async () => unde const { emptyKnotwork, migrate } = await import("@jfrusher/knotwork"); const { useKnotworkStore } = await import("@/lib/store/useKnotworkStore"); const { usePlaque } = await import("./store"); +const { initialSuite } = await import("./design"); /* * Place cards hold no copy of the wedding: the design is the stationery @@ -12,39 +13,53 @@ const { usePlaque } = await import("./store"); * it from anywhere is what the editor shows. */ type Raw = Record; -const HEADERS = ["First Name", "Last Name", "Table", "Dietary"]; -const ROWS = [ - { "First Name": "Charis", "Last Name": "Smith", Table: "Table 1", Dietary: "Vegetarian" }, - { "First Name": "Eleanor", "Last Name": "Vane", Table: "Table 2", Dietary: "Vegan" }, -]; +const GUESTS = { + g1: { id: "g1", firstName: "Charis", lastName: "Smith", assignedTableId: "t1" }, + g2: { id: "g2", firstName: "Eleanor", lastName: "Vane", assignedTableId: "t2" }, +}; +const TABLES = { + t1: { id: "t1", label: "Table 1", assignedGuestIds: ["g1"] }, + t2: { id: "t2", label: "Table 2", assignedGuestIds: ["g2"] }, +}; const shared = () => useKnotworkStore.getState(); const plaque = () => usePlaque.getState(); const stored = () => (shared().raw as Raw).stationery as Raw; +/** The piece on screen, as the wedding holds it. */ +const piece = () => (stored().pieces as Raw[]).find((p) => p.id === plaque().pieceId)!; beforeEach(() => { - const raw = emptyKnotwork() as unknown as Raw; + // A wedding with two guests seated, and Place cards already saved once. + const raw = { + ...(emptyKnotwork() as unknown as Raw), + guests: GUESTS, + seating: { tables: TABLES }, + stationery: { version: 3, savedAt: null, ...initialSuite() }, + }; useKnotworkStore.setState({ status: "ready", raw, doc: migrate(raw), past: [], future: [] }); - plaque().setCsv({ headers: HEADERS, rows: ROWS, issues: [], fileName: "guests.csv" }); }); test("an edit is in the wedding the moment it is made", () => { plaque().setBackground("#fdfbf7"); - expect(stored().template.backgroundHex).toBe("#fdfbf7"); + expect(piece().template.backgroundHex).toBe("#fdfbf7"); }); test("a change to the stationery from elsewhere is what the editor shows", () => { - shared().setSlice("stationery", { ...stored(), template: { ...stored().template, backgroundHex: "#112233" } }, { label: "a design from the library" }); + shared().setSlice( + "stationery", + { ...stored(), pieces: [{ ...piece(), template: { ...piece().template, backgroundHex: "#112233" } }] }, + { label: "a design from the library" }, + ); expect(plaque().template.backgroundHex).toBe("#112233"); }); test("the header's undo takes a Place cards edit back, on the wedding's one history", () => { plaque().addElement("rect"); expect(shared().past.at(-1)?.label).toBe("adding to the card"); - const count = stored().template.elements.length; + const count = piece().template.elements.length; shared().undo(); - expect(stored().template.elements.length).toBe(count - 1); + expect(piece().template.elements.length).toBe(count - 1); expect(plaque().template.elements.length).toBe(count - 1); }); @@ -80,10 +95,112 @@ test("a selected element undone out of existence is no longer selected", () => { }); test("a design the wedding holds that cannot be read is said so, and the next edit starts fresh", () => { - shared().setSlice("stationery", { ...stored(), card: { ...stored().card, widthMm: "85" } }, { silent: true }); + shared().setSlice( + "stationery", + { ...stored(), pieces: [{ ...piece(), card: { ...piece().card, widthMm: "85" } }] }, + { silent: true }, + ); expect(plaque().designProblem).toMatch(/widthMm.*Starting fresh/); plaque().setBackground("#fdfbf7"); expect(plaque().designProblem).toBeNull(); - expect(stored().card.widthMm).toBe(85); + expect(piece().card.widthMm).toBe(85); +}); + +test("a new piece opens empty, and editing it leaves the place cards as they were", () => { + const placeCards = plaque().template; + plaque().addPiece("Table numbers"); + expect(plaque().pieces.map((p) => p.name)).toEqual(["Place cards", "Table numbers"]); + expect(plaque().template.elements).toEqual([]); + + plaque().setBackground("#223344"); + expect(piece().name).toBe("Table numbers"); + plaque().switchPiece("place-cards"); + expect(plaque().template).toEqual(placeCards); +}); + +test("pieces share their uploaded assets' names", () => { + plaque().noteAssetName("user:mono", "Monogram.png"); + plaque().addPiece("Menus"); + expect(plaque().assetNames["user:mono"]).toBe("Monogram.png"); + expect(stored().assetNames).toEqual({ "user:mono": "Monogram.png" }); +}); + +test("a copied piece keeps the design and who shares a card, under its own name", () => { + plaque().combineRows([0, 1]); + const template = plaque().template; + plaque().duplicatePiece("place-cards"); + expect(plaque().pieces.map((p) => p.name)).toEqual(["Place cards", "Place cards (copy)"]); + expect(plaque().template).toEqual(template); + expect(plaque().rows.map((row) => row["Name"])).toEqual(["Charis Smith & Eleanor Vane"]); + expect(plaque().pieceId).not.toBe("place-cards"); +}); + +test("undoing a new piece takes it away and shows the first", () => { + plaque().addPiece("Escort cards"); + shared().undo(); + expect(plaque().pieces.map((p) => p.name)).toEqual(["Place cards"]); + expect(plaque().pieceId).toBe("place-cards"); +}); + +test("removing the piece on screen shows its neighbour, and the last piece cannot go", () => { + plaque().addPiece("Board"); + plaque().removePiece(plaque().pieceId); + expect(plaque().pieces.map((p) => p.name)).toEqual(["Place cards"]); + expect(plaque().pieceId).toBe("place-cards"); + expect(() => plaque().removePiece("place-cards")).toThrow(/last piece/); +}); + +test("a wedding saved with one design and a copy of the list opens from the room, keeping each guest's own change", () => { + const { pieces, ...sharedParts } = stored(); + const [{ id: _id, name: _name, merged: _merged, template, ...flat }] = pieces as Raw[]; + const name = template.elements.find((el: Raw) => el.kind === "text").id; + // Version 2 kept the rows themselves — here, Eleanor still on the table she + // has since left — and keyed a tweak to her by her position in them. + shared().setSlice( + "stationery", + { + ...sharedParts, + ...flat, + version: 2, + template: { ...template, overrides: { r1: { [name]: { fontSizePt: 11 } } } }, + headers: ["First Name", "Last Name", "Name", "Table"], + rows: [ + { "First Name": "Charis", "Last Name": "Smith", Name: "Charis Smith", Table: "Table 1" }, + { "First Name": "Eleanor", "Last Name": "Vane", Name: "Eleanor Vane", Table: "Table 9" }, + ], + rowIds: ["r0", "r1"], + fileName: "the room", + }, + { silent: true }, + ); + expect(plaque().pieces).toEqual([{ id: "place-cards", name: "Place cards" }]); + expect(plaque().rows.map((row) => row["Table"])).toEqual(["Table 1", "Table 2"]); + expect(plaque().template.overrides).toEqual({ g2: { [name]: { fontSizePt: 11 } } }); + + plaque().setBackground("#fdfbf7"); + expect(stored().version).toBe(3); + expect(piece()).not.toHaveProperty("rows"); + expect(piece().template.overrides).toEqual({ g2: { [name]: { fontSizePt: 11 } } }); +}); + +test("a guest seated in the room is on the card at once, on any piece", () => { + plaque().addPiece("Escort cards"); + const seating = { tables: { ...TABLES, t2: { ...TABLES.t2, label: "Top table" } } }; + shared().setSlice("seating", seating, { label: "the room" }); + expect(plaque().rows.map((row) => row["Table"])).toEqual(["Table 1", "Top table"]); +}); + +test("asked for a piece by its design, makes it once from the gallery, and opens it after that", () => { + plaque().openPiece("floor-plan"); + expect(plaque().pieces.map((p) => p.name)).toEqual(["Place cards", "Floor plan"]); + expect(plaque().pieceId).toBe("floor-plan"); + expect(plaque().template.elements.some((el) => el.kind === "room")).toBe(true); + expect(plaque().sheet.page).toBe("FIT"); + + plaque().switchPiece("place-cards"); + plaque().openPiece("floor-plan"); + expect(plaque().pieces).toHaveLength(2); + expect(plaque().pieceId).toBe("floor-plan"); + expect(() => plaque().openPiece("nonsense")).toThrow(/No piece or design/); }); diff --git a/suite/apps/plaque/state/persist.test.ts b/suite/apps/plaque/state/persist.test.ts index 2720e0cf..a1e48026 100644 --- a/suite/apps/plaque/state/persist.test.ts +++ b/suite/apps/plaque/state/persist.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { defaultCard, defaultSheet, defaultTemplate } from "../core/template/defaults"; -import { load, type Persisted } from "./persist"; +import { load } from "./persist"; +import { FIRST_PIECE } from "./suite"; const design = () => ({ card: defaultCard(), @@ -8,7 +9,8 @@ const design = () => ({ template: defaultTemplate(["First Name", "Last Name"]), }); -const good = (over: Partial = {}): Persisted => ({ +/** A save from before pieces: one flat design. Every one of these must still open. */ +const good = (over: Record = {}) => ({ version: 2, savedAt: "2026-08-17T13:42:00.000Z", ...design(), @@ -33,11 +35,17 @@ describe("load", () => { expect(load({})).toEqual({ status: "empty" }); }); - it("accepts a well-formed save", () => { + it("opens a single-design save as the one piece it always was", () => { const result = load(good()); expect(result.status).toBe("ok"); - expect(result.status === "ok" && result.data.rows).toHaveLength(1); - expect(result.status === "ok" && result.data.savedAt).toBe("2026-08-17T13:42:00.000Z"); + if (result.status !== "ok") return; + expect(result.data.version).toBe(3); + expect(result.data.savedAt).toBe("2026-08-17T13:42:00.000Z"); + expect(result.data.pieces).toHaveLength(1); + expect(result.data.pieces[0]).toMatchObject(FIRST_PIECE); + // The rows it printed are the room's to say now; none are kept. + expect(Object.keys(result.data.pieces[0]!).sort()).toEqual(["card", "id", "merged", "name", "printed", "sheet", "template"]); + expect(result.problem).toBeNull(); }); it("discards a save from another version rather than half-applying it", () => { @@ -88,14 +96,44 @@ describe("load", () => { it("reads a design saved when the undo history travelled with it, and leaves the history behind", () => { const result = load({ ...good(), past: [design()], future: [] }); expect(result.status).toBe("ok"); - expect(result.status === "ok" && Object.keys(result.data).sort()).toEqual(Object.keys(good()).sort()); + expect(result.status === "ok" && Object.keys(result.data)).not.toContain("past"); + expect(result.status === "ok" && Object.keys(result.data.pieces[0]!)).not.toContain("past"); + }); + + it("moves a guest's own change from the row they were on to the guest", () => { + const tweak = { fontSizePt: 11 }; + const saved = good({ template: { ...design().template, overrides: { r0: { name: tweak } } } }); + const result = load(saved, (row) => (row["First Name"] === "Charis" ? "g7" : null)); + expect(result.status === "ok" && result.data.pieces[0]!.template.overrides).toEqual({ g7: { name: tweak } }); + expect(result.status === "ok" && result.problem).toBeNull(); + }); + + it("finds the rows of a save from before rows had ids by their position", () => { + const { rowIds: _rowIds, ...older } = good({ template: { ...design().template, overrides: { r0: { name: {} } } } }); + const result = load(older, () => "g7"); + expect(result.status === "ok" && Object.keys(result.data.pieces[0]!.template.overrides!)).toEqual(["g7"]); + }); + + it("says so when a guest's own change matches nobody on the list any more", () => { + const saved = good({ template: { ...design().template, overrides: { r0: { name: { fontSizePt: 11 } } } } }); + const result = load(saved, () => null); + expect(result.status === "ok" && result.data.pieces[0]!.template.overrides).toEqual({}); + expect(result.status === "ok" && result.problem).toMatch(/One guest's own change to "Place cards"/); }); - it("gives rows positional ids when the save predates them", () => { - // Overrides keyed by those same positional ids still land after an upgrade. - const { rowIds: _rowIds, ...older } = good(); - const result = load(older); - expect(result.status === "ok" && result.data.rowIds).toEqual(["r0"]); + it("keeps two guests on one card, now by who they are", () => { + const ada = { "First Name": "Ada" }; + const grace = { "First Name": "Grace" }; + const saved = good({ + rows: [{ "First Name": "Ada & Grace" }], + rowIds: ["merged:x"], + merged: { "merged:x": { indexes: [0, 1], ids: ["r0", "r1"], rows: [ada, grace] } }, + template: { ...design().template, overrides: { "merged:x": { name: { fontSizePt: 9 } } } }, + }); + const ids: Record = { Ada: "g1", Grace: "g2" }; + const result = load(saved, (row) => ids[row["First Name"]!] ?? null); + expect(result.status === "ok" && result.data.pieces[0]!.merged).toEqual({ "merged:x": ["g1", "g2"] }); + expect(result.status === "ok" && Object.keys(result.data.pieces[0]!.template.overrides!)).toEqual(["merged:x"]); }); it("upgrades a v1 save, which had no timestamp", () => { @@ -103,8 +141,75 @@ describe("load", () => { const result = load(v1); expect(result.status).toBe("ok"); if (result.status !== "ok") return; - expect(result.data.version).toBe(2); + expect(result.data.version).toBe(3); expect(result.data.savedAt).toBeNull(); - expect(result.data.rows).toHaveLength(1); + expect(result.data.pieces).toHaveLength(1); + }); +}); + +describe("load, with pieces", () => { + const piece = (id: string, name: string, over: Record = {}) => ({ + id, + name, + ...design(), + merged: {}, + ...over, + }); + const suite = (pieces: unknown[]) => ({ + version: 3, + savedAt: null, + pieces, + uploadedIcons: { "user:leaf": "M0 0" }, + assetNames: { "user:x": "Monogram.png" }, + snapEnabled: false, + sheetCollapsed: true, + }); + + it("reads every piece in order, with what they share", () => { + const result = load(suite([piece("a", "Place cards"), piece("b", "Table numbers")])); + expect(result.status).toBe("ok"); + if (result.status !== "ok") return; + expect(result.data.pieces.map((p) => p.name)).toEqual(["Place cards", "Table numbers"]); + expect(result.data.assetNames).toEqual({ "user:x": "Monogram.png" }); + expect(result.data.uploadedIcons).toEqual({ "user:leaf": "M0 0" }); + expect(result.data.snapEnabled).toBe(false); + expect(result.data.sheetCollapsed).toBe(true); + }); + + it("leaves out a piece it cannot read, keeps the rest, and names the one it lost", () => { + const broken = piece("b", "Table numbers", { card: { ...good().card, widthMm: "100" } }); + const result = load(suite([piece("a", "Place cards"), broken])); + expect(result.status).toBe("ok"); + if (result.status !== "ok") return; + expect(result.data.pieces.map((p) => p.id)).toEqual(["a"]); + expect(result.problem).toMatch(/widthMm.*"Table numbers" was left out/); + }); + + it("leaves out a second piece under an id already read", () => { + const result = load(suite([piece("a", "Place cards"), piece("a", "Escort cards")])); + expect(result.status === "ok" && result.data.pieces.map((p) => p.name)).toEqual(["Place cards"]); + expect(result.status === "ok" && result.problem).toMatch(/Escort cards/); + }); + + it("reads who shares a card, and leaves out a piece whose combines are not guest lists", () => { + const result = load(suite([piece("a", "Place cards", { merged: { "merged:x": ["g1", "g2"] } }), piece("b", "Menus", { merged: { m: { rows: [] } } })])); + expect(result.status === "ok" && result.data.pieces.map((p) => p.merged)).toEqual([{ "merged:x": ["g1", "g2"] }]); + expect(result.status === "ok" && result.problem).toMatch(/combined cards on "Menus"/); + }); + + it("reads what a piece was last printed as, and says so when it cannot", () => { + const printed = { at: "2026-10-01T10:00:00Z", cards: { "row:g1": "abc:1" } }; + const ok = load(suite([piece("a", "Place cards", { printed })])); + expect(ok.status === "ok" && ok.data.pieces[0]!.printed).toEqual(printed); + + const garbled = load(suite([piece("a", "Place cards", { printed: { at: 3 } })])); + expect(garbled.status === "ok" && garbled.data.pieces[0]!.printed).toBeNull(); + expect(garbled.status === "ok" && garbled.problem).toMatch(/last printed as could not be read/); + }); + + it("discards a suite with no readable piece", () => { + expect(load(suite([]))).toMatchObject({ status: "discarded" }); + expect(load(suite([piece("", "Nameless")]))).toMatchObject({ status: "discarded" }); + expect(load({ ...suite([]), pieces: "nope" })).toMatchObject({ status: "discarded" }); }); }); diff --git a/suite/apps/plaque/state/persist.ts b/suite/apps/plaque/state/persist.ts index a0291410..c930dd9c 100644 --- a/suite/apps/plaque/state/persist.ts +++ b/suite/apps/plaque/state/persist.ts @@ -1,16 +1,28 @@ -import { designOf, type Design } from "./design"; +import type { GuestRow } from "../core/data/rows"; +import type { Template } from "../core/types"; +import type { Piece, Suite } from "./design"; +import type { Merged } from "./fromRoom"; +import type { Printed } from "./printed"; +import { SUITE_VERSION, isRecord, storedPieces } from "./suite"; -export const VERSION = 2; +/** + * The guest a row printed before the room was live, or null. Saves before + * version 3 stored the rows themselves and keyed per-guest tweaks by position + * in them; matching a stored row to a guest is what carries those tweaks over. + */ +export type GuestIdFor = (row: GuestRow) => string | null; + +export const VERSION = SUITE_VERSION; /** - * The stationery slice as stored: the design, versioned and stamped. It - * travels wherever the wedding does: this device, and the account when the + * The stationery slice as stored: the suite of pieces, versioned and stamped. + * It travels wherever the wedding does: this device, and the account when the * wedding is synced to one. * * Uploaded font and image binaries are NOT here. Those are separate keys in * IndexedDB — see blobStore. */ -export interface Persisted extends Design { +export interface Persisted extends Suite { version: number; /** ISO time of the write. */ savedAt: string | null; @@ -18,57 +30,165 @@ export interface Persisted extends Design { export type LoadResult = | { status: "empty" } - | { status: "ok"; data: Persisted } + /** `problem` names any piece that could not be read and was left out. */ + | { status: "ok"; data: Persisted; problem: string | null } | { status: "discarded"; reason: string }; /** - * Reads the stationery slice. Anything unreadable is discarded rather than - * partially applied: a half-loaded template would put the user in a state - * they cannot reason about or undo. + * Reads the stationery slice. A piece that cannot be read is left out whole + * rather than partially applied — a half-loaded template would put the user in + * a state they cannot reason about or undo — and named, so its loss is never + * silent. With no readable piece at all, the slice is discarded. * * A slice with no version was written by something other than Place cards — * an empty envelope, most likely — and is treated as nothing saved. */ -export function load(slice: unknown): LoadResult { +export function load(slice: unknown, guestIdFor: GuestIdFor = () => null): LoadResult { if (slice === null || slice === undefined) return { status: "empty" }; if (!isRecord(slice)) return { status: "discarded", reason: "The saved design could not be read." }; if (!("version" in slice)) return { status: "empty" }; const version = slice["version"]; - if (version !== 1 && version !== VERSION) { + if (version !== 1 && version !== 2 && version !== VERSION) { return { status: "discarded", reason: "The saved design was made by a different version of Plaque.", }; } + if (version === VERSION && !Array.isArray(slice["pieces"])) { + return { status: "discarded", reason: "The saved design was incomplete." }; + } - const bad = firstBadDesignField(slice); - if (bad) return { status: "discarded", reason: bad }; - - if (!Array.isArray(slice["rows"]) || !Array.isArray(slice["headers"])) { - return { status: "discarded", reason: "The saved guest list could not be read." }; + const pieces: Piece[] = []; + const problems: string[] = []; + for (const stored of storedPieces(slice)) { + const read = readPiece(stored, pieces, version === VERSION ? null : guestIdFor, problems); + if (typeof read === "string") problems.push(read); + else pieces.push(read); + } + if (pieces.length === 0) { + return { status: "discarded", reason: problems[0] ?? "The saved design was incomplete." }; } - const data = slice as unknown as Persisted; return { status: "ok", data: { version: VERSION, - savedAt: typeof data.savedAt === "string" ? data.savedAt : null, - ...designOf({ - ...data, - // Absent in anything written before the sheet pane could be collapsed. - sheetCollapsed: slice["sheetCollapsed"] === true, - assetNames: isRecord(slice["assetNames"]) ? (slice["assetNames"] as Record) : {}, - // Absent in anything written before per-row editing. Positional ids match - // what buildArtefacts falls back to, so overrides keyed by them still land. - rowIds: Array.isArray(slice["rowIds"]) - ? (slice["rowIds"] as string[]) - : (data.rows ?? []).map((_, i) => `r${i}`), - merged: isRecord(slice["merged"]) ? (slice["merged"] as Persisted["merged"]) : {}, - }), + savedAt: typeof slice["savedAt"] === "string" ? slice["savedAt"] : null, + pieces, + uploadedIcons: isRecord(slice["uploadedIcons"]) ? (slice["uploadedIcons"] as Record) : {}, + assetNames: isRecord(slice["assetNames"]) ? (slice["assetNames"] as Record) : {}, + snapEnabled: slice["snapEnabled"] !== false, + // Absent in anything written before the sheet pane could be collapsed. + sheetCollapsed: slice["sheetCollapsed"] === true, }, + problem: problems.length > 0 ? problems.join(" ") : null, + }; +} + +/** + * One piece, or why it cannot be read. `before` is what has been read already. + * `legacy` is set for a piece saved before version 3, whose rows go and whose + * tweaks are re-keyed from positions to guests. + */ +function readPiece( + source: Record, + before: Piece[], + legacy: GuestIdFor | null, + notes: string[], +): Piece | string { + const id = source["id"]; + const name = typeof source["name"] === "string" && source["name"] ? source["name"] : "A piece"; + if (typeof id !== "string" || !id) return `"${name}" had no id, so it was left out.`; + if (before.some((p) => p.id === id)) return `"${name}" was saved twice, so the second was left out.`; + + const bad = firstBadDesignField(source); + if (bad) return `${bad} "${name}" was left out.`; + + const design = { + id, + name, + card: source["card"] as Piece["card"], + // Absent in anything written before boards could be tiled at home. + sheet: { tilePaper: "A4", ...(source["sheet"] as object) } as Piece["sheet"], + template: source["template"] as Template, }; + if (!legacy) { + const merged = source["merged"] ?? {}; + const valid = + isRecord(merged) && + Object.values(merged).every((ids) => Array.isArray(ids) && ids.every((id) => typeof id === "string")); + if (!valid) return `The combined cards on "${name}" could not be read, so it was left out.`; + const printed = source["printed"] ?? null; + if (printed !== null && !isPrinted(printed)) { + // Bookkeeping, not design: losing it costs a list of what to reprint, + // which is not worth the piece. + notes.push(`What "${name}" was last printed as could not be read, so changes since cannot be shown.`); + } + return { ...design, merged: merged as Merged, printed: isPrinted(printed) ? printed : null }; + } + if (!Array.isArray(source["rows"])) { + return `The saved guest list for "${name}" could not be read, so it was left out.`; + } + const { lost, ...moved } = fromRows(source, design.template, legacy); + if (lost > 0) { + notes.push( + lost === 1 + ? `One guest's own change to "${name}" no longer matches anybody on the list, so it was dropped.` + : `${lost} guests' own changes to "${name}" no longer match anybody on the list, so they were dropped.`, + ); + } + return { ...design, ...moved, printed: null }; +} + +function isPrinted(value: unknown): value is Printed { + return ( + isRecord(value) && + typeof value["at"] === "string" && + isRecord(value["cards"]) && + Object.values(value["cards"]).every((print) => typeof print === "string") + ); +} + +/** + * A pre-3 piece's tweaks and combined cards, moved off the rows it stored and + * onto the guests those rows were. A tweak whose row matches no guest goes, + * and is counted in `lost` so the loss is said rather than silent. + */ +function fromRows( + source: Record, + template: Template, + guestIdFor: GuestIdFor, +): Pick & { lost: number } { + const rows = source["rows"] as GuestRow[]; + const rowIds = Array.isArray(source["rowIds"]) ? (source["rowIds"] as string[]) : rows.map((_, i) => `r${i}`); + const oldMerged = isRecord(source["merged"]) ? (source["merged"] as Record) : {}; + + const newKey = new Map(); + const merged: Merged = {}; + rows.forEach((row, index) => { + const rowId = rowIds[index] ?? `r${index}`; + const members = oldMerged[rowId]?.rows; + if (Array.isArray(members)) { + const ids = members.map(guestIdFor).filter((gid): gid is string => gid !== null); + if (ids.length > 0) { + merged[rowId] = ids; + newKey.set(rowId, rowId); + } + return; + } + const guestId = guestIdFor(row); + if (guestId) newKey.set(rowId, guestId); + }); + + const before = Object.entries(template.overrides ?? {}); + const overrides = Object.fromEntries( + before.flatMap(([rowId, patch]) => { + const key = newKey.get(rowId); + return key ? [[key, patch]] : []; + }), + ); + return { template: { ...template, overrides }, merged, lost: before.length - Object.keys(overrides).length }; } /** Returns a message naming the offending field, or null when the design is usable. */ @@ -110,7 +230,3 @@ function firstBadNumber(source: Record, keys: readonly string[] } return null; } - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/suite/apps/plaque/state/printed.test.ts b/suite/apps/plaque/state/printed.test.ts new file mode 100644 index 00000000..e96dd2dc --- /dev/null +++ b/suite/apps/plaque/state/printed.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; +import type { Artefact } from "../core/data/artefacts"; +import type { RoomScene, Template } from "../core/types"; +import { printBasis, recordPrint, sincePrinted } from "./printed"; + +const card = (key: string, Table: string, number = "1"): Artefact => ({ + key, + row: { Name: key, Table, "Table Number": number }, + rows: [{ Name: key, Table, "Table Number": number }], + rowIndexes: [0], + rowId: key, + rowIds: [key], + label: key, +}); + +const text = (template: string) => ({ + kind: "text" as const, + id: template, + x: 0, + y: 0, + w: 10, + h: 10, + z: 0, + template, + fontId: "crimson", + fontSizePt: 12, + align: "center" as const, + vAlign: "middle" as const, + lineHeight: 1.2, + colorHex: "#000000", + letterSpacingMm: 0, + fit: { mode: "shrink" as const, minFontSizePt: 6, maxLines: 1, anchor: "align" as const }, +}); +const design = (...templates: string[]): Template => ({ backgroundHex: null, elements: templates.map(text) }); +const room: RoomScene = { bounds: { x: 0, y: 0, w: 1, h: 1 }, walls: [], seatRadius: 1, tables: [] }; + +describe("what has changed since printing", () => { + const basis = printBasis(design("{{Name}}", "{{Table}}"), room); + const printed = recordPrint(null, [card("ada", "Table 1"), card("bo", "Table 2"), card("cy", "Table 3")], false, "2026-10-01T10:00:00Z", basis); + + it("is nothing straight after a print", () => { + expect(sincePrinted(printed, [card("ada", "Table 1"), card("bo", "Table 2"), card("cy", "Table 3")], basis)).toEqual({ changed: [], gone: [] }); + }); + + it("is a card whose guest moved, a guest who is new, and a card nobody needs now", () => { + const now = [card("ada", "Table 4"), card("bo", "Table 2"), card("di", "Table 1")]; + const { changed, gone } = sincePrinted(printed, now, basis); + expect(changed.map((a) => a.key)).toEqual(["ada", "di"]); + expect(gone).toEqual(["cy"]); + }); + + it("ignores what the design does not print — a table renamed renumbers every table, and a name card does not care", () => { + const names = printBasis(design("{{Name}}"), room); + const before = recordPrint(null, [card("ada", "Top table", "14"), card("bo", "Table 2", "2")], false, "x", names); + const renumbered = [card("ada", "Head table", "1"), card("bo", "Table 2", "3")]; + expect(sincePrinted(before, renumbered, names).changed).toEqual([]); + // The same change, on a design that does print the table, is the one card. + const tables = printBasis(design("{{Name}}", "{{Table}}"), room); + const printedTables = recordPrint(null, [card("ada", "Top table", "14"), card("bo", "Table 2", "2")], false, "x", tables); + expect(sincePrinted(printedTables, renumbered, tables).changed.map((a) => a.key)).toEqual(["ada"]); + }); + + it("adds a reprint of a few to what was printed, and a full run replaces it", () => { + const reprinted = recordPrint(printed, [card("ada", "Table 4")], true, "2026-10-02T10:00:00Z", basis); + expect(Object.keys(reprinted.cards).sort()).toEqual(["ada", "bo", "cy"]); + expect(sincePrinted(reprinted, [card("ada", "Table 4"), card("bo", "Table 2"), card("cy", "Table 3")], basis).changed).toEqual([]); + expect(Object.keys(recordPrint(printed, [card("ada", "Table 4")], false, "x", basis).cards)).toEqual(["ada"]); + }); +}); + +describe("a design that names guests as they are known", () => { + it("has changed when the name it prints changes, though the guest has no name of their own", () => { + const known = design("{{Known As}}"); + const row = (first: string) => ({ ...card("ada", "Table 1"), row: { "First Name": first, "Last Name": "Byron", "Known As": "" }, rows: [{ "First Name": first, "Last Name": "Byron", "Known As": "" }] }); + const printed = recordPrint(null, [row("Ada")], false, "x", printBasis(known, room)); + expect(sincePrinted(printed, [row("Augusta")], printBasis(known, room)).changed).toHaveLength(1); + }); +}); + +describe("a design that names chairs", () => { + it("has changed when someone else sits in a chair it names", () => { + const sitterRoom = (name: string): RoomScene => ({ + ...room, + tables: [ + { + label: "Table 1", + x: 0, + y: 0, + rotationDeg: 0, + pathD: "", + view: { x: 0, y: 0, w: 1, h: 1 }, + numbered: true, + interior: { x: 0, y: 0, w: 1, h: 1 }, + seats: [{ x: 0, y: 0, out: { x: 0, y: 1 }, number: 1, row: { "First Name": name, "Last Name": "" } }], + }, + ], + }); + const chairs = design("{{Table}}: {{At seat 1}}"); + const printed = recordPrint(null, [card("t1", "Table 1")], false, "x", printBasis(chairs, sitterRoom("Ada"))); + expect(sincePrinted(printed, [card("t1", "Table 1")], printBasis(chairs, sitterRoom("Ada"))).changed).toEqual([]); + expect(sincePrinted(printed, [card("t1", "Table 1")], printBasis(chairs, sitterRoom("Grace"))).changed).toHaveLength(1); + }); +}); + +describe("a plan of the room", () => { + const plan = { ...design(), elements: [{ kind: "room", id: "r", x: 0, y: 0, w: 10, h: 10, z: 0, show: "room" } as never] }; + const seated = (dietary: string, first = "Ada"): RoomScene => ({ + ...room, + tables: [ + { + label: "Table 1", x: 0, y: 0, rotationDeg: 0, pathD: "", view: { x: 0, y: 0, w: 1, h: 1 }, numbered: true, interior: { x: 0, y: 0, w: 1, h: 1 }, + seats: [{ x: 0, y: 0, out: { x: 0, y: 1 }, number: 1, row: { "First Name": first, "Last Name": "Byron", "Known As": "", Dietary: dietary } }], + }, + ], + }); + const before = recordPrint(null, [card("doc", "")], false, "x", printBasis(plan, seated(""))); + + it("has not changed for what it does not draw: a guest's dietary need", () => { + expect(sincePrinted(before, [card("doc", "")], printBasis(plan, seated("Vegan"))).changed).toEqual([]); + }); + + it("has changed when a name it draws changes", () => { + expect(sincePrinted(before, [card("doc", "")], printBasis(plan, seated("", "Augusta"))).changed).toHaveLength(1); + }); +}); + diff --git a/suite/apps/plaque/state/printed.ts b/suite/apps/plaque/state/printed.ts new file mode 100644 index 00000000..1966d49a --- /dev/null +++ b/suite/apps/plaque/state/printed.ts @@ -0,0 +1,102 @@ +import { fingerprint } from "@/lib/documents/fingerprint"; +import type { Artefact } from "../core/data/artefacts"; +import { normalise } from "../core/data/artefacts"; +import { columnsUsed } from "../core/template/rebind"; +import { asKnown, chairName, chairRef, chairValues } from "../core/template/chairs"; +import { tokensIn } from "../core/csv/interpolate"; +import type { RoomScene, RoomTable, Template } from "../core/types"; + +/** + * What a piece last went to the printer as: when, and each card's data, so a + * change to the room afterwards can say which cards it made wrong. + * + * Only what the design reads is fingerprinted: the columns it binds, and the + * part of the room a plan on it draws. A guest moving tables changes the cards + * that print the table; it does not change a card that prints only a name — + * even though every table's number shifts when one is renamed. + */ +export interface Printed { + /** ISO time of the export. */ + at: string; + /** Artefact key to its data's fingerprint. */ + cards: Record; +} + +/** What one artefact's paper depends on, for a design: the reader of its fingerprint. */ +export type PrintBasis = (artefact: Artefact) => string; + +/** What a print holds: just the chosen few when only a few are chosen, else every card. */ +export function printing(artefacts: Artefact[], printOnly: string[] | null): Artefact[] { + return printOnly ? artefacts.filter((artefact) => printOnly.includes(artefact.key)) : artefacts; +} + +export function printBasis(template: Template, room: RoomScene): PrintBasis { + const columns = columnsUsed(template); + // Whoever is in the chairs the design names, as it names them. + const chairs = [ + ...new Set( + template.elements.flatMap((el) => + tokensIn(el.kind === "text" ? el.template : el.kind === "list" ? el.itemTemplate : el.kind === "qr" ? el.data : "").filter((token) => chairRef(token)), + ), + ), + ]; + const plans = template.elements.flatMap((el) => (el.kind === "room" ? [el.show] : [])); + // The whole room is the same on every card: drawn once, not once a card. + const wholeRoom = plans.includes("room") + ? { bounds: room.bounds, walls: room.walls, tables: room.tables.map((table) => drawnTable(template, table)) } + : null; + return (artefact) => { + const drawn = plans.map((show) => + show === "room" + ? wholeRoom + : room.tables + .filter((table) => normalise(table.label) === normalise(artefact.row["Table"] ?? "")) + .map((table) => drawnTable(template, table)), + ); + const sitters = chairs.length > 0 ? chairValues(chairs, artefact.row, room, template).values : {}; + const rows = artefact.rows.map((row) => asKnown(template, row)); + return fingerprint([rows.map((row) => columns.map((column) => row[column] ?? "")), drawn, sitters]); + }; +} + +/** + * What a plan draws of a table: where it is, its shape and who sits where by + * the name the design gives them. Nothing else of a sitter's row — a dietary + * need changing does not change the plan. + */ +function drawnTable(template: Template, table: RoomTable) { + const { seats, interior: _interior, ...shape } = table; + return { ...shape, seats: seats.map((seat) => [seat.x, seat.y, seat.number, seat.row ? chairName(template, seat.row) : ""]) }; +} + +/** + * A record of these artefacts going to print now. A reprint of some adds to + * what was printed before; a full run replaces it. + */ +export function recordPrint( + previous: Printed | null, + artefacts: Artefact[], + partial: boolean, + at: string, + basis: PrintBasis, +): Printed { + const cards = Object.fromEntries(artefacts.map((artefact) => [artefact.key, basis(artefact)])); + return { at, cards: partial && previous ? { ...previous.cards, ...cards } : cards }; +} + +/** + * Since the last print: the cards that would come out differently now — a + * guest moved, renamed, or new — and the keys of cards printed then that + * nothing prints now. + */ +export function sincePrinted( + printed: Printed, + artefacts: Artefact[], + basis: PrintBasis, +): { changed: Artefact[]; gone: string[] } { + const now = new Set(artefacts.map((artefact) => artefact.key)); + return { + changed: artefacts.filter((artefact) => printed.cards[artefact.key] !== basis(artefact)), + gone: Object.keys(printed.cards).filter((key) => !now.has(key)), + }; +} diff --git a/suite/apps/plaque/state/roomScene.test.ts b/suite/apps/plaque/state/roomScene.test.ts new file mode 100644 index 00000000..5a5a90bc --- /dev/null +++ b/suite/apps/plaque/state/roomScene.test.ts @@ -0,0 +1,37 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { migrate } from "@jfrusher/knotwork"; +import { roomScene } from "./roomScene"; + +/** The example wedding, read through Seating's own geometry: what the board draws. */ +const doc = migrate(JSON.parse(readFileSync(join(process.cwd(), "public", "fixtures", "example-wedding.knotwork.json"), "utf8"))); + +test("every table of the plan, with its chairs in order and who sits in each", () => { + const scene = roomScene(doc); + expect(scene.tables).toHaveLength(14); + const top = scene.tables.find((t) => t.label === "Top table")!; + expect(top.seats.map((s) => s.number)).toEqual([...top.seats.keys()].map((i) => i + 1)); + expect(top.seats.some((s) => s.row?.["First Name"] === "Alex")).toBe(true); + // Each sitter as their own card reads them: every column a card can use. + expect(top.seats.find((s) => s.row)!.row).toHaveProperty("Place"); + // An empty table still has its chairs. + expect(scene.tables.find((t) => t.label === "Table 13")!.seats.every((s) => s.row === null)).toBe(true); + // The example's tables leave guests to sit where they like, as new tables do. + expect(scene.tables.every((t) => !t.numbered)).toBe(true); + expect(top.interior.w).toBeGreaterThan(0); +}); + +test("the walls and the bounds the room is drawn in", () => { + const scene = roomScene(doc); + expect(scene.walls.length).toBeGreaterThanOrEqual(4); + expect(scene.bounds.w).toBeGreaterThan(0); + for (const t of scene.tables) { + expect(t.x).toBeGreaterThanOrEqual(scene.bounds.x); + expect(t.x).toBeLessThanOrEqual(scene.bounds.x + scene.bounds.w); + } +}); + +test("is read once per wedding", () => { + expect(roomScene(doc)).toBe(roomScene(doc)); +}); diff --git a/suite/apps/plaque/state/roomScene.ts b/suite/apps/plaque/state/roomScene.ts new file mode 100644 index 00000000..92048b03 --- /dev/null +++ b/suite/apps/plaque/state/roomScene.ts @@ -0,0 +1,89 @@ +import type { Knotwork } from "@jfrusher/knotwork"; +import { cached } from "@/lib/model/slices"; +import { planFrom } from "@/apps/tableaux/store/plan"; +import { getWallSegs, layoutFloorPlan } from "@/apps/tableaux/utils/floorPlanSvg"; +import { SEAT_RADIUS, type TableGeometry } from "@/apps/tableaux/utils/seatPositions"; +import { getTableInterior } from "@/apps/tableaux/utils/tableGrid"; +import type { GuestRow } from "../core/data/rows"; +import type { RoomScene, RoomTable } from "../core/types"; +import { roomRows } from "./fromRoom"; + +/** + * The seating plan as a room element draws it, once per document. + * + * Read through Seating's own geometry pass — the one its editor, its printed + * chart and the guest link's plan all use — so a table on the board is where it + * is on the screen, chairs and all. Core gets plain numbers and knows nothing of + * Seating. + */ +export function roomScene(doc: Knotwork): RoomScene { + return cached(doc, "plaque.roomScene", () => build(doc)); +} + +function build(doc: Knotwork): RoomScene { + const layout = layoutFloorPlan(planFrom({ guests: doc.guests, seating: doc.seating }, doc.event)); + // Each sitter as their own card reads them, so a chair can be named with any + // token a card can use. + const room = roomRows(doc); + const rowOf = new Map(room.rowIds.map((id, i) => [id, room.rows[i]!])); + + const tables: RoomTable[] = layout.geoms.map(({ t, g }) => ({ + label: t.label, + x: t.x, + y: t.y, + rotationDeg: t.rotation || 0, + ...outline(g), + numbered: t.seatMode === "seat", + interior: interiorOf(g), + seats: layout.seats + .filter((seat) => seat.table.id === t.id) + .sort((a, b) => a.index - b.index) + .map((seat) => ({ + x: seat.x, + y: seat.y, + out: { x: seat.nx, y: seat.ny }, + number: seat.index + 1, + row: (seat.guest && rowOf.get(seat.guest.id)) ?? null, + })), + })); + + return { + bounds: { x: layout.minX, y: layout.minY, w: layout.width, h: layout.height }, + walls: layout.spaces.flatMap(getWallSegs).map((wall) => [ + { x: wall.x1, y: wall.y1 }, + { x: wall.x2, y: wall.y2 }, + ]), + seatRadius: SEAT_RADIUS, + tables, + }; +} + +/** The largest box inside a table, about its own centre: where a free-seating table's names go. */ +function interiorOf(g: TableGeometry): RoomTable["interior"] { + const inside = getTableInterior(g); + return { x: -inside.width / 2, y: inside.offsetY - inside.height / 2, w: inside.width, h: inside.height }; +} + +/** A table's shape as a path about its own centre, and the box that path fills. */ +function outline(g: TableGeometry): Pick { + const r = g.radius; + if (g.shape === "circle") { + return { + pathD: `M ${-r} 0 A ${r} ${r} 0 1 0 ${r} 0 A ${r} ${r} 0 1 0 ${-r} 0 Z`, + view: { x: -r, y: -r, w: r * 2, h: r * 2 }, + }; + } + if (g.shape === "half-circle") { + const cy = g.cy ?? r / 2; + return { + pathD: `M ${-r} ${cy} A ${r} ${r} 0 0 1 ${r} ${cy} Z`, + view: { x: -r, y: cy - r, w: r * 2, h: r }, + }; + } + const w = g.width / 2; + const h = g.height / 2; + return { + pathD: `M ${-w} ${-h} H ${w} V ${h} H ${-w} Z`, + view: { x: -w, y: -h, w: g.width, h: g.height }, + }; +} diff --git a/suite/apps/plaque/state/sliceBridge.ts b/suite/apps/plaque/state/sliceBridge.ts index c1c65dcf..9272cb1f 100644 --- a/suite/apps/plaque/state/sliceBridge.ts +++ b/suite/apps/plaque/state/sliceBridge.ts @@ -1,27 +1,102 @@ +import type { Knotwork } from "@jfrusher/knotwork"; import { useKnotworkStore, type WriteOptions } from "@/lib/store/useKnotworkStore"; -import { initialDesign, type Design } from "./design"; -import { load, VERSION } from "./persist"; +import { keepThroughHistory } from "@/lib/store/unhistoried"; +import { guestName, readGuests } from "@/lib/model/slices"; +import type { GuestRow } from "../core/data/rows"; +import { designFor, initialSuite, withDesign, type Design, type Suite } from "./design"; +import { load, VERSION, type GuestIdFor } from "./persist"; /** - * Where Place cards' design lives: the `stationery` slice of the shared - * wedding, so it travels with the backup, the sync and everything else. + * Where Place cards' designs live: the `stationery` slice of the shared + * wedding, so they travel with the backup, the sync and everything else. * - * Plaque keeps no copy of it. Its store shows what this slice holds, and every - * design edit is written here first, on the wedding's one history. + * Plaque keeps no copy of them. Its store shows one piece of this slice, and + * every design edit is written here first, on the wedding's one history. */ -/** The design the wedding holds — the starting one when it has none — and why, when it could not be read. */ -export function readDesign(raw: Record): { design: Design; problem: string | null } { - const result = load(raw["stationery"]); +/** The wedding as the shared store holds it: stored and parsed. */ +type Wedding = { raw: Record; doc: Knotwork }; + +/** The suite the wedding holds — the starting one when it has none — and why, when some or all of it could not be read. */ +export function readSuite({ raw, doc }: Wedding): { suite: Suite; problem: string | null } { + const result = load(raw["stationery"], byName(doc)); if (result.status === "ok") { - const { version: _version, savedAt: _savedAt, ...design } = result.data; - return { design, problem: null }; + const { version: _version, savedAt: _savedAt, ...suite } = result.data; + return { suite, problem: result.problem }; } - return { design: initialDesign(), problem: result.status === "discarded" ? `${result.reason} Starting fresh.` : null }; + return { suite: initialSuite(), problem: result.status === "discarded" ? `${result.reason} Starting fresh.` : null }; } -export function writeDesign(design: Design, options: WriteOptions): void { +export function writeSuite(suite: Suite, options: WriteOptions): void { useKnotworkStore .getState() - .setSlice("stationery", { version: VERSION, savedAt: new Date().toISOString(), ...design }, options); + .setSlice("stationery", { version: VERSION, savedAt: new Date().toISOString(), ...suite }, options); +} + +/** `pieceId` when the suite still has it, otherwise its first piece. */ +export function resolvePieceId(suite: Suite, pieceId: string | null): string { + return suite.pieces.some((p) => p.id === pieceId) ? pieceId! : suite.pieces[0]!.id; +} + +/** One piece of the wedding's stationery as the editor sees it. */ +export function readDesign( + wedding: Wedding, + pieceId: string | null, +): { design: Design; pieceId: string; suite: Suite; problem: string | null } { + const { suite, problem } = readSuite(wedding); + const resolved = resolvePieceId(suite, pieceId); + return { design: designFor(suite, resolved), pieceId: resolved, suite, problem }; +} + +/** Writes the editor's view of one piece back into the wedding. */ +export function writeDesign(design: Design, pieceId: string, options: WriteOptions): void { + const { suite } = readSuite(useKnotworkStore.getState()); + writeSuite(withDesign(suite, pieceId, design), options); +} + +/** + * The guest an old save's row printed, by name — the only thing a stored row + * and a guest have in common. A name two guests share matches neither. Built + * on first use: only a save from before version 3 asks. + */ +function byName(doc: Knotwork): GuestIdFor { + let index: Map | null = null; + const key = (name: string) => name.trim().toLowerCase().replace(/\s+/g, " "); + return (row: GuestRow) => { + if (!index) { + index = new Map(); + for (const guest of Object.values(readGuests(doc))) { + const name = key(guestName(guest)); + index.set(name, index.has(name) ? null : guest.id); + } + } + const name = row["Name"] || [row["First Name"], row["Last Name"]].filter(Boolean).join(" "); + return index.get(key(name ?? "")) ?? null; + }; +} + +/** + * What each piece was last printed as is a fact about paper, not an edit: + * undo and redo keep it as it is now, piece by piece. + */ +keepThroughHistory((now, restored) => { + const printedNow = new Map( + pieceList(now["stationery"]).map((p) => [p["id"], p["printed"]]), + ); + const target = restored["stationery"]; + const pieces = pieceList(target); + if (pieces.length === 0) return restored; + return { + ...restored, + stationery: { + ...(target as Record), + pieces: pieces.map((p) => (printedNow.has(p["id"]) ? { ...p, printed: printedNow.get(p["id"]) } : p)), + }, + }; +}); + +/** The pieces of a stored stationery slice, or none for any other shape. */ +function pieceList(slice: unknown): Array> { + const pieces = typeof slice === "object" && slice !== null ? (slice as Record)["pieces"] : null; + return Array.isArray(pieces) ? (pieces as Array>) : []; } diff --git a/suite/apps/plaque/state/stamp.test.ts b/suite/apps/plaque/state/stamp.test.ts new file mode 100644 index 00000000..b0e7e5b8 --- /dev/null +++ b/suite/apps/plaque/state/stamp.test.ts @@ -0,0 +1,155 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("idb-keyval", () => ({ get: async () => undefined, set: async () => undefined, del: async () => undefined })); + +const { emptyKnotwork, migrate } = await import("@jfrusher/knotwork"); +const { useKnotworkStore } = await import("@/lib/store/useKnotworkStore"); +const { usePlaque } = await import("./store"); +const { initialSuite } = await import("./design"); +const { noFit, resolveCard } = await import("../core/template/bindings"); +const { makeResolveOptions } = await import("../core/template/resolve"); +const { loadFont } = await import("../core/text/measure"); +import { readFileSync } from "node:fs"; +import type { RoomElement, TextElement } from "../core/types"; + +/* + * Stamping a plan's names out as boxes of their own: bound to their chairs, + * following the plan or staying where they are put. + */ +type Raw = Record; +const plaque = () => usePlaque.getState(); +const shared = () => useKnotworkStore.getState(); + +beforeEach(() => { + const raw = { + ...(emptyKnotwork() as unknown as Raw), + guests: { + g1: { id: "g1", firstName: "Charis", lastName: "Smith", assignedTableId: "t1" }, + g2: { id: "g2", firstName: "Eleanor", lastName: "Vane", assignedTableId: "t1" }, + }, + seating: { + tables: { + t1: { id: "t1", label: "Table 1", type: "round-8", capacity: 8, x: 300, y: 300, seatMode: "seat", assignedGuestIds: ["g1", "g2"] }, + }, + }, + stationery: { version: 3, savedAt: null, ...initialSuite() }, + }; + useKnotworkStore.setState({ status: "ready", raw, doc: migrate(raw), past: [], future: [] }); + plaque().addPiece("Plan"); + plaque().addElement("room"); +}); + +const plan = () => plaque().template.elements.find((el): el is RoomElement => el.kind === "room")!; +const boxes = () => plaque().template.elements.filter((el): el is TextElement => el.kind === "text" && Boolean(el.chair)); +/** The card as it prints: where each named chair's box lands, by its text. */ +const printed = () => { + const { scene } = resolveCard(plaque().template, plaque().rows[0]!, plaque().card, { + fitText: noFit, + iconPath: () => null, + room: () => plaque().room, + }); + return Object.fromEntries(scene.elements.flatMap((el) => (el.kind === "text" && !el.sourceId ? [[el.lines.join(" "), { x: el.x, y: el.y }]] : []))); +}; + +describe("stamping a plan's names", () => { + test("makes a box bound to each chair of a numbered table, and the plan stops naming them itself", () => { + plaque().stampChairs(plan().id, true); + expect(boxes().map((b) => b.template).slice(0, 2)).toEqual(["{{Table 1, seat 1}}", "{{Table 1, seat 2}}"]); + expect(boxes()).toHaveLength(8); + expect(plan().namesAtChairs).toBe(false); + expect(Object.keys(printed())).toEqual(expect.arrayContaining(["Charis Smith", "Eleanor Vane"])); + }); + + test("boxes that follow move with the plan; a nudge stays a nudge", () => { + plaque().stampChairs(plan().id, true); + const before = printed()["Charis Smith"]!; + const charis = boxes()[0]!; + plaque().setElementBox(charis.id, { x: before.x + 5, y: before.y, w: charis.w, h: charis.h }); + expect(printed()["Charis Smith"]!.x).toBeCloseTo(before.x + 5); + + plaque().setElementBox(plan().id, { x: plan().x + 10, y: plan().y + 3, w: plan().w, h: plan().h }); + expect(printed()["Charis Smith"]!.x).toBeCloseTo(before.x + 15); + expect(printed()["Charis Smith"]!.y).toBeCloseTo(before.y + 3); + }); + + test("boxes that stay are left where they were put when the plan moves", () => { + plaque().stampChairs(plan().id, false); + const before = printed()["Charis Smith"]!; + plaque().setElementBox(plan().id, { x: plan().x + 10, y: plan().y, w: plan().w, h: plan().h }); + expect(printed()["Charis Smith"]).toEqual(before); + }); + + test("a box says whoever sits in its chair now", () => { + plaque().stampChairs(plan().id, true); + const seatOne = printed()["Charis Smith"]!; + const seating = { tables: { t1: { ...(shared().raw.seating as Raw).tables.t1, assignedGuestIds: ["g2", "g1"] } } }; + shared().setSlice("seating", seating, { label: "the room" }); + expect(printed()["Eleanor Vane"]).toEqual(seatOne); + }); + + test("removing the plan leaves its boxes where they were, staying", () => { + plaque().stampChairs(plan().id, true); + const before = printed()["Charis Smith"]!; + plaque().removeElement(plan().id); + expect(boxes().every((b) => b.chair!.follow === false)).toBe(true); + expect(printed()["Charis Smith"]).toEqual(before); + }); + + test("one chair at a time, and never twice", () => { + plaque().stampChairs(plan().id, true, [{ table: "Table 1", seat: 2 }]); + expect(boxes().map((b) => b.template)).toEqual(["{{Table 1, seat 2}}"]); + plaque().stampChairs(plan().id, true); + expect(boxes()).toHaveLength(8); + expect(new Set(boxes().map((b) => b.template)).size).toBe(8); + }); + + test("on a table's own map, boxes always follow and say 'this card's table'", () => { + plaque().updateElement(plan().id, { show: "table" } as never); + plaque().stampChairs(plan().id, false); + expect(boxes()[0]!.template).toBe("{{At seat 1}}"); + expect(boxes().every((b) => b.chair!.follow && b.chair!.table === null)).toBe(true); + }); + + test("the boxes say the names at the one size the plan drew them, not each as big as it can be", async () => { + const crimson = loadFont("crimson", "Crimson Text", new Uint8Array(readFileSync("public/fonts/CrimsonText-Regular.ttf"))); + usePlaque.setState({ fonts: new Map([["crimson", crimson]]) }); + const long = { ...(shared().raw.guests as Raw), g2: { id: "g2", firstName: "Bartholomew", lastName: "Sorensen-Whitley", assignedTableId: "t1" } }; + shared().setSlice("guests", long, { label: "a guest" }); + const opts = makeResolveOptions(plaque().fonts, {}, new Map(), {}, plaque().room); + const drawn = resolveCard(plaque().template, plaque().rows[0]!, plaque().card, opts).scene.elements.flatMap((el) => + el.kind === "text" && el.sourceId === plan().id && el.lines.join(" ") !== "Table 1" ? [el.fontSizePt] : [], + ); + expect(drawn).toHaveLength(2); + expect(drawn[0]).toBe(drawn[1]); + expect(drawn[0]).toBeLessThan(plan().fontSizePt); + + plaque().stampChairs(plan().id, true); + expect(new Set(boxes().map((b) => b.fontSizePt))).toEqual(new Set([drawn[0]])); + }); + + test("on a table's own map, every seat any table has gets a box, not just the seats of the table on screen", () => { + const tables = (shared().raw.seating as Raw).tables; + const t2 = { id: "t2", label: "Table 2", type: "round", capacity: 10, x: 700, y: 300, seatMode: "seat", assignedGuestIds: [] }; + shared().setSlice("seating", { tables: { ...tables, t2 } }, { label: "the room" }); + expect(plaque().room.tables.map((t) => t.seats.length)).toEqual([8, 10]); + plaque().updateElement(plan().id, { show: "table" } as never); + plaque().stampChairs(plan().id, true); + expect(boxes().map((b) => b.template)).toEqual(Array.from({ length: 10 }, (_, i) => `{{At seat ${i + 1}}}`)); + }); + + test("on a table's own map, a box sits centred on its chair on every table's card, whatever the table's size", async () => { + const { chairCells, placeChairs } = await import("../core/template/room"); + const tables = (shared().raw.seating as Raw).tables; + const t2 = { id: "t2", label: "Table 2", type: "round", capacity: 4, x: 700, y: 300, seatMode: "seat", assignedGuestIds: [] }; + shared().setSlice("seating", { tables: { ...tables, t2 } }, { label: "the room" }); + plaque().updateElement(plan().id, { show: "table" } as never); + plaque().stampChairs(plan().id, true); + const row = { Table: "Table 2" }; + const placed = placeChairs(plaque().template, plaque().room, row).elements.find( + (el): el is (typeof boxes extends () => Array ? T : never) => el.kind === "text" && el.chair?.seat === 1, + )!; + const cell = chairCells(plan(), plaque().room, row).find((c) => c.seat.number === 1)!.box; + expect(placed.x + placed.w / 2).toBeCloseTo(cell.x + cell.w / 2); + expect(placed.y + placed.h / 2).toBeCloseTo(cell.y + cell.h / 2); + }); +}); diff --git a/suite/apps/plaque/state/store.test.ts b/suite/apps/plaque/state/store.test.ts index 55d25384..02c8d2e5 100644 --- a/suite/apps/plaque/state/store.test.ts +++ b/suite/apps/plaque/state/store.test.ts @@ -3,13 +3,21 @@ import { emptyKnotwork, migrate } from "@jfrusher/knotwork"; import { useKnotworkStore } from "@/lib/store/useKnotworkStore"; import { usePlaque } from "./store"; -const HEADERS = ["First Name", "Last Name", "Table", "Dietary"]; -const ROWS = [ - { "First Name": "Charis", "Last Name": "Smith", Table: "Table 1", Dietary: "Vegetarian" }, - { "First Name": "Eleanor", "Last Name": "Vane", Table: "Table 2", Dietary: "Vegan" }, -]; - -const csv = () => ({ headers: HEADERS, rows: ROWS, issues: [], fileName: "guests.csv" }); +const GUESTS = { + g1: { id: "g1", firstName: "Charis", lastName: "Smith", assignedTableId: "t1" }, + g2: { id: "g2", firstName: "Eleanor", lastName: "Vane", assignedTableId: "t2" }, +}; +const TOBIAS = { g3: { id: "g3", firstName: "Tobias", lastName: "Ashdown", assignedTableId: "t1" } }; +const TABLES = { + t1: { id: "t1", label: "Table 1", assignedGuestIds: ["g1", "g3"] }, + t2: { id: "t2", label: "Table 2", assignedGuestIds: ["g2"] }, +}; + +/** Puts guests in the room, as Seating would: Place cards reads them from there. */ +const room = (guests: Record = GUESTS, tables: Record = TABLES) => { + const raw = { ...useKnotworkStore.getState().raw, guests, seating: { tables } }; + useKnotworkStore.setState({ raw, doc: migrate(raw) }); +}; const state = () => usePlaque.getState(); // Place cards' undo is the wedding's. const undo = () => useKnotworkStore.getState().undo(); @@ -25,29 +33,41 @@ afterEach(() => { vi.useRealTimers(); }); -describe("first upload", () => { - it("starts with no elements, so the default template is not built against columns that do not exist", () => { - expect(state().template.elements).toEqual([]); +describe("a new wedding", () => { + it("starts with a place card laid out for the room's columns", () => { + const text = state().template.elements.find((el) => el.kind === "text"); + expect(text?.kind === "text" && text.template).toContain("{{First Name}}"); }); - it("lays out a real template as soon as a CSV lands", () => { - state().setCsv(csv()); - const elements = state().template.elements; - expect(elements.length).toBeGreaterThan(0); - const text = elements.find((el) => el.kind === "text"); - expect(text?.kind === "text" && text.template).toBe("{{First Name}} {{Last Name}}"); + it("prints whoever is in the room, without being asked", () => { + expect(state().rows).toEqual([]); + room(); + expect(state().rows.map((row) => row["Name"])).toEqual(["Charis Smith", "Eleanor Vane"]); + expect(state().rowIds).toEqual(["g1", "g2"]); }); - it("never overwrites a design the user has already made", () => { - state().setCsv(csv()); + it("follows a move in the room, and leaves the design alone", () => { + room(); state().addElement("rect"); - const before = state().template.elements.map((el) => el.id); - state().setCsv({ ...csv(), headers: ["Name"], fileName: "other.csv" }); - expect(state().template.elements.map((el) => el.id)).toEqual(before); + const design = state().template; + room(GUESTS, { t1: { id: "t1", label: "Table 1", assignedGuestIds: ["g1", "g2"] } }); + expect(state().rows.map((row) => row["Table"])).toEqual(["Table 1", "Table 1"]); + expect(state().template).toBe(design); + }); + + it("follows a name corrected on the guest list", () => { + room(); + room({ ...GUESTS, g2: { ...GUESTS.g2, lastName: "Vane-Ashby" } }); + expect(state().rows[1]?.["Name"]).toBe("Eleanor Vane-Ashby"); }); }); describe("elements", () => { + // A blank piece: the place cards start with a design already on them. + beforeEach(() => { + state().addPiece("Blank"); + }); + it("selects what it adds", () => { state().addElement("text"); expect(state().selectedId).toBe(state().template.elements[0]?.id); @@ -84,7 +104,7 @@ describe("elements", () => { describe("copying the front onto the back", () => { beforeEach(() => { - state().setCsv(csv()); + room(); }); it("gives the back the same design at the same card coordinates", () => { @@ -195,7 +215,7 @@ describe("card and sheet", () => { }); it("returns to sheet one whenever the layout changes underneath", () => { - state().setCsv(csv()); + room(); state().setPage(3); state().setSheet({ gapXMm: 8 }); expect(state().page).toBe(0); @@ -204,7 +224,6 @@ describe("card and sheet", () => { describe("fonts", () => { it("moves elements off a font that is removed rather than leaving them blank", () => { - state().setCsv(csv()); const font = { id: "user:x", family: "X" } as never; state().addFont(font, "X"); const textEl = state().template.elements.find((el) => el.kind === "text")!; @@ -217,61 +236,54 @@ describe("fonts", () => { }); describe("clearAll", () => { - it("wipes the guest list and the design — and can be undone", () => { - state().setCsv(csv()); + it("returns the stationery to its starting place cards — and can be undone", () => { + room(); state().addElement("rect"); - const elements = state().template.elements; + state().addPiece("Menus"); + const before = state().pieces; state().clearAll(); - expect(state().rows).toEqual([]); - expect(state().headers).toEqual([]); - expect(state().template.elements).toEqual([]); - expect(state().fileName).toBeNull(); + expect(state().pieces).toEqual([{ id: "place-cards", name: "Place cards" }]); + expect(state().template.elements.some((el) => el.kind === "rect")).toBe(false); + // The guests are the room's, not the design's: clearing the cards keeps them. + expect(state().rows).toHaveLength(2); undo(); - expect(state().template.elements).toEqual(elements); + expect(state().pieces).toEqual(before); }); }); describe("combining rows (S-I.3)", () => { - const threeRows = () => ({ - headers: HEADERS, - rows: [ - ...ROWS, - { "First Name": "Tobias", "Last Name": "Ashdown", Table: "Table 1", Dietary: "" }, - ], - issues: [], - fileName: "guests.csv", - }); + const three = () => room({ ...GUESTS, ...TOBIAS }); - it("gives every row an id, so an override can outlive a re-order", () => { - state().setCsv(csv()); - expect(state().rowIds).toHaveLength(ROWS.length); - expect(new Set(state().rowIds).size).toBe(ROWS.length); + it("gives every row its guest's id, so an override follows the guest", () => { + room(); + expect(state().rowIds).toEqual(["g1", "g2"]); }); it("joins two guests onto one row and drops the originals from the list", () => { - state().setCsv(threeRows()); + three(); state().combineRows([0, 2]); expect(state().rows).toHaveLength(2); expect(state().rows[0]?.["First Name"]).toBe("Charis & Tobias"); + expect(Object.values(state().merged)).toEqual([["g1", "g3"]]); }); it("says a shared value once rather than repeating it", () => { - state().setCsv(threeRows()); + three(); state().combineRows([0, 2]); // Both are on Table 1; the card should not read "Table 1 & Table 1". expect(state().rows[0]?.["Table"]).toBe("Table 1"); }); - it("puts the combined row where the first of its sources was", () => { - state().setCsv(threeRows()); + it("puts the combined row where the first of its guests was", () => { + three(); state().combineRows([1, 2]); expect(state().rows[0]?.["First Name"]).toBe("Charis"); expect(state().rows[1]?.["First Name"]).toBe("Eleanor & Tobias"); }); it("restores the originals exactly when split again", () => { - state().setCsv(threeRows()); + three(); const before = state().rows; const beforeIds = state().rowIds; state().combineRows([0, 2]); @@ -281,21 +293,36 @@ describe("combining rows (S-I.3)", () => { expect(state().merged).toEqual({}); }); + it("opens a combined card up rather than nesting it, when it is combined again", () => { + three(); + state().combineRows([0, 1]); + state().combineRows([0, 1]); + expect(state().rows).toHaveLength(1); + expect(Object.values(state().merged)).toEqual([["g1", "g2", "g3"]]); + }); + + it("follows its guests: one moving table moves the card's table line", () => { + three(); + state().combineRows([0, 2]); + room({ ...GUESTS, ...TOBIAS }, { t2: { id: "t2", label: "Table 2", assignedGuestIds: ["g1", "g2", "g3"] } }); + expect(state().rows[0]?.["Table"]).toBe("Table 2"); + }); + it("refuses to combine fewer than two rows", () => { - state().setCsv(csv()); + room(); state().combineRows([0]); expect(state().rows).toHaveLength(2); }); it("ignores a split of something that was never combined", () => { - state().setCsv(csv()); + room(); state().splitRow("nope"); expect(state().rows).toHaveLength(2); }); it("is taken back by undo as by its inverse, split", () => { vi.useFakeTimers(); - state().setCsv(threeRows()); + three(); const rows = state().rows; vi.advanceTimersByTime(1000); state().combineRows([0, 2]); @@ -306,19 +333,11 @@ describe("combining rows (S-I.3)", () => { state().splitRow(state().rowIds[0]!); expect(state().rows).toEqual(rows); }); - - it("drops ids and combines when a new CSV arrives", () => { - state().setCsv(threeRows()); - state().combineRows([0, 2]); - state().setCsv(csv()); - expect(state().merged).toEqual({}); - expect(state().rowIds).toHaveLength(ROWS.length); - }); }); describe("per-row overrides (D1)", () => { it("stores and clears a patch for one row", () => { - state().setCsv(csv()); + room(); state().addElement("text"); const elementId = state().template.elements[0]!.id; const rowId = state().rowIds[0]!; @@ -331,7 +350,7 @@ describe("per-row overrides (D1)", () => { }); it("is undoable, because it is design and not data", () => { - state().setCsv(csv()); + room(); state().addElement("text"); const elementId = state().template.elements[0]!.id; state().overrideForRow(state().rowIds[0]!, elementId, { fontSizePt: 11 }); @@ -340,28 +359,11 @@ describe("per-row overrides (D1)", () => { }); }); -describe("a second CSV with different headers (S-B.1)", () => { - it("re-attaches the design by column role rather than unbinding it", () => { - state().setCsv(csv()); - const before = state().template.elements.length; - expect(before).toBeGreaterThan(0); - - state().setCsv({ - headers: ["Guest First", "Guest Last", "Tbl", "Dietary Needs"], - rows: [{ "Guest First": "Ada", "Guest Last": "Lovelace", Tbl: "Table 1", "Dietary Needs": "" }], - issues: [], - fileName: "next-year.csv", - }); - - const templates = state() - .template.elements.flatMap((el) => (el.kind === "text" ? [el.template] : [])); - expect(state().template.elements).toHaveLength(before); - expect(templates.join(" ")).toContain("{{Guest First}}"); - expect(templates.join(" ")).not.toContain("{{First Name}}"); - }); - - it("builds a fresh template only when there was nothing to keep", () => { - state().setCsv(csv()); - expect(state().template.elements.length).toBeGreaterThan(0); +describe("reprinting a few", () => { + it("is forgotten when another piece is opened", () => { + room(); + state().setPrintOnly(["row:g1"]); + state().addPiece("Escort cards"); + expect(state().printOnly).toBeNull(); }); }); diff --git a/suite/apps/plaque/state/store.ts b/suite/apps/plaque/state/store.ts index 32f860df..a2065812 100644 --- a/suite/apps/plaque/state/store.ts +++ b/suite/apps/plaque/state/store.ts @@ -1,25 +1,22 @@ import { create } from "zustand"; import { DEFAULT_FONT_ID } from "../assets/fonts"; import type { RowIssue, GuestRow } from "../core/data/rows"; -import { defaultRowIds } from "../core/data/artefacts"; import { defaultFoldPosition } from "../core/geometry/fold"; import { sideOf } from "../core/imposition/duplex"; import type { LayoutSuggestion } from "../core/geometry/suggestLayouts"; -import { defaultTemplate, newId } from "../core/template/defaults"; +import { newId } from "../core/template/defaults"; import { withOverride, withoutOverride, type ElementPatch, } from "../core/template/overrides"; -import { rebindTemplate } from "../core/template/rebind"; import { elementKind } from "../core/template/registry"; -import type { GalleryTemplate } from "../core/data/gallery"; +import { fromGallery, GALLERY, type GalleryTemplate } from "../core/data/gallery"; /** * The columns the shipped gallery is written against. Rebinding maps them onto * whatever the loaded CSV calls the same roles. */ -const SAMPLE_HEADERS = ["First Name", "Last Name", "Table", "Dietary"]; import type { CardElement, CardSide, @@ -27,15 +24,33 @@ import type { ElementId, Rect, ResolvedImageSource, + RoomElement, + RoomScene, RowScope, SheetSpec, Template, + TextElement, } from "../core/types"; import type { LoadedFont } from "../core/text/measure"; import type { PrinterProfile } from "../core/print/printerProfile"; +import type { Knotwork } from "@jfrusher/knotwork"; import { useKnotworkStore, type WriteOptions } from "@/lib/store/useKnotworkStore"; -import { DESIGN_KEYS, designOf, initialDesign, type Design } from "./design"; -import { readDesign, writeDesign } from "./sliceBridge"; +import { DESIGN_KEYS, designFor, designOf, initialSuite, newPiece, withDesign, type Design, type Suite } from "./design"; +import { readDesign, readSuite, writeDesign, writeSuite } from "./sliceBridge"; +import { roomRows, withMerges, type Merged } from "./fromRoom"; +import { roomScene } from "./roomScene"; +import { printBasis, recordPrint, type PrintBasis } from "./printed"; +import { normalise, type Artefact } from "../core/data/artefacts"; +import { artefactsOf } from "../core/data/parts"; +import { makeResolveOptions } from "../core/template/resolve"; +import { centredOn, chairCells, chairNameSize, placeChairs, planLayout, stampableChairs } from "../core/template/room"; +import { chairToken } from "../core/template/chairs"; +import { linkUrl, useGuestLink } from "@/lib/share/guestLink"; + +export interface PieceSummary { + id: string; + name: string; +} /** Every kind the registry knows about — see core/template/registry. */ export type NewElementKind = CardElement["kind"]; @@ -44,10 +59,41 @@ export type NewElementKind = CardElement["kind"]; * Place cards' state: the design, which is the wedding's stationery slice and * only ever shown here (see `design.ts`), and what belongs to this window. */ -export interface PlaqueState extends Design { +/** + * What the open piece prints from: the room as it stands, with its combined + * cards in place. Worked out, never stored — see `fromRoom`. + */ +export interface RoomData { + headers: string[]; + rows: GuestRow[]; + /** A guest id, or a combined card's id, per row. */ + rowIds: string[]; + /** Said about the list as a whole, such as guests with no table yet. */ + rowIssues: RowIssue[]; + /** The seating plan, for a room element to draw. */ + room: RoomScene; +} + +export interface PlaqueState extends Design, RoomData { /** Why the wedding's saved design could not be read, when it could not. */ designProblem: string | null; + /** + * The piece on screen. This window's choice, not the wedding's: a partner + * opening the seating board does not move anybody else off the place cards, + * and undo never switches pieces. + */ + pieceId: string; + /** Every piece, in order, for the switcher. Same array until a name or the order changes. */ + pieces: PieceSummary[]; + + /** + * Artefact keys to export instead of all of them — a reprint of a few cards. + * This window's, and cleared with the piece: a selection that outlived the + * moment would quietly print a short run later. + */ + printOnly: string[] | null; + /** * Parsed faces, keyed by fontId — bundled and uploaded alike. Held in the * store rather than a context so every panel and both renderers read fonts @@ -87,11 +133,34 @@ export interface PlaqueState extends Design { page: number; previewGuestIndex: number; - setCsv: (data: { headers: string[]; rows: GuestRow[]; issues: RowIssue[]; fileName: string }) => void; + switchPiece: (id: string) => void; + /** + * Opens the piece with this id, making it from the gallery design of the + * same id first if the wedding has none: how Seating's Print sends someone + * to "the floor plan" whether or not it exists yet. + */ + openPiece: (id: string) => void; + /** A new, empty piece, opened. */ + addPiece: (name: string) => void; + /** A copy of a piece — design and data — opened. */ + duplicatePiece: (id: string) => void; + renamePiece: (id: string, name: string) => void; + /** The last piece cannot be removed: a wedding's stationery always has one. */ + removePiece: (id: string) => void; + + setPrintOnly: (keys: string[] | null) => void; + /** + * These artefacts of piece `pieceId` have gone to the printer: the record + * changes since are measured from. Named, not assumed: a print can finish + * after another piece is opened. + */ + notePrinted: (pieceId: string, artefacts: Artefact[], partial: boolean, basis: PrintBasis) => void; setCard: (patch: Partial) => void; setSheet: (patch: Partial) => void; applySuggestion: (s: LayoutSuggestion) => void; setBackground: (hex: string | null) => void; + /** How this design names a guest by their chair. See core/template/chairs. */ + setChairName: (pattern: string) => void; /** Changing scope changes how many artefacts exist, so pagination resets. */ setRowScope: (scope: RowScope) => void; /** Applies a gallery design over the current data, rebinding its tokens (F2). */ @@ -114,6 +183,13 @@ export interface PlaqueState extends Design { /** Pans or zooms the artwork inside an image element: one gesture, one undo, as a drag. */ setElementCrop: (id: ElementId, patch: { zoom?: number; focusX?: number; focusY?: number }) => void; removeElement: (id: ElementId) => void; + /** + * A text box at every chair a plan names, each bound to its chair: `follow` + * keeps them at their chairs as the room moves (always so for a table's own + * map); otherwise they stay where they are put. `only` stamps just those + * chairs. The plan stops naming those chairs itself. + */ + stampChairs: (roomId: ElementId, follow: boolean, only?: Array<{ table: string | null; seat: number }>) => void; duplicateElement: (id: ElementId) => void; /** Replaces the back with a copy of the front, for cards read from either side. */ copyFrontToBack: () => void; @@ -150,6 +226,31 @@ export interface PlaqueState extends Design { clearAll: () => void; } +type Wedding = { raw: Record; doc: Knotwork }; + +/** The last room read, so a design edit does not rebuild every row. */ +let lastLive: { inputs: unknown[]; data: RoomData } | null = null; + +/** The open piece's rows: the room, with its combined cards in place, and the guest link. */ +function live(wedding: Wedding, design: Pick): RoomData { + const { merged } = design; + const chairName = design.template.chairName; + const link = guestLinkUrl(); + const inputs = [wedding.raw["guests"], wedding.raw["seating"], wedding.raw["event"], merged, link, chairName]; + if (lastLive && inputs.every((input, i) => input === lastLive!.inputs[i])) return lastLive.data; + const room = roomRows(wedding.doc); + const merges = withMerges(room, merged, { chairName }); + const data = { + headers: room.headers, + rowIds: merges.rowIds, + rows: link ? merges.rows.map((row) => ({ ...row, "Guest Link": link })) : merges.rows, + rowIssues: room.issues, + room: roomScene(wedding.doc), + }; + lastLive = { inputs, data }; + return data; +} + export const usePlaque = create()((set, get) => { /** * A change to the design goes into the wedding, which is what this store @@ -165,7 +266,9 @@ export const usePlaque = create()((set, get) => { if ((DESIGN_KEYS as readonly string[]).includes(key)) design[key as keyof Design] = value; else local[key as keyof PlaqueState] = value; } - if (Object.keys(design).length > 0) writeDesign({ ...designOf(s), ...(design as Partial) }, options); + if (Object.keys(design).length > 0) { + writeDesign({ ...designOf(s), ...(design as Partial) }, s.pieceId, options); + } if (Object.keys(local).length > 0) set(local as Partial); }; /** An edit: shown as "Undo
+ ); +} diff --git a/suite/apps/plaque/ui/panels/ChairPicker.module.css b/suite/apps/plaque/ui/panels/ChairPicker.module.css new file mode 100644 index 00000000..64b2166d --- /dev/null +++ b/suite/apps/plaque/ui/panels/ChairPicker.module.css @@ -0,0 +1,117 @@ +.input { + width: 100%; + padding: var(--sp-1) var(--sp-2); + border: 1px solid var(--border-strong); + border-radius: var(--radius); + background: var(--grey-0); + font: inherit; + font-size: var(--text-sm); +} + +.toggle { + align-self: flex-start; + padding: 2px var(--sp-2); + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + font: inherit; + font-size: var(--text-xs); + color: var(--accent); + cursor: pointer; +} + +.picker { + display: flex; + flex-direction: column; + gap: var(--sp-2); + padding: var(--sp-2); + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-sunken); +} + +.modes { + display: flex; + gap: var(--sp-1); +} + +.modes button { + flex: 1; + padding: 2px var(--sp-2); + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + font: inherit; + font-size: var(--text-xs); + color: var(--grey-7); + cursor: pointer; +} + +/* Pressed by fill, not weight: a bold label shifts and reads loose at this size. */ +.modes button[aria-pressed="true"] { + border-color: var(--accent); + background: var(--accent-soft); + color: var(--grey-8); +} + +.modes button:disabled { + opacity: 0.4; + cursor: default; +} + +.map { + width: 100%; + max-height: 16rem; + background: var(--grey-0); + border-radius: var(--radius); +} + +.table { + fill: var(--grey-2); +} + +.zoomable { + cursor: zoom-in; +} + +.zoomable:hover { + fill: var(--grey-3); +} + +.seat { + fill: var(--grey-4); +} + +/* The target: invisible, larger than the chair it covers, so a small chair is still easy to hit. */ +.hit { + fill: transparent; + stroke: transparent; + stroke-width: 2px; + vector-effect: non-scaling-stroke; + cursor: pointer; +} + +.chair:hover .seat, +.chair:has(.hit:focus-visible) .seat { + fill: var(--accent); +} + +.hit:focus-visible { + outline: none; + stroke: var(--grey-8); +} + +.used .seat { + fill: var(--accent-soft); + stroke: var(--accent); + stroke-width: 2px; + vector-effect: non-scaling-stroke; +} + +.idle { + fill: var(--grey-3); +} + +.free { + opacity: 0.45; +} diff --git a/suite/apps/plaque/ui/panels/ChairPicker.tsx b/suite/apps/plaque/ui/panels/ChairPicker.tsx new file mode 100644 index 00000000..b5882223 --- /dev/null +++ b/suite/apps/plaque/ui/panels/ChairPicker.tsx @@ -0,0 +1,293 @@ +import { useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { tokensIn } from "../../core/csv/interpolate"; +import { normalise } from "../../core/data/artefacts"; +import { chairName, chairRef, chairToken, type ChairRef } from "../../core/template/chairs"; +import type { RoomTable } from "../../core/types"; +import { usePlaque } from "../../state/store"; +import { Field, Hint, SelectField } from "../controls"; +import { NameFormat } from "./NameFormat"; +import styles from "./ChairPicker.module.css"; + +type Mode = "table" | "room"; + +/** + * A text field whose chairs are picked on a map rather than typed: the + * template, and under it the card's own table or the whole room, a click on a + * chair putting its token where the cursor is. + */ +export function TemplateField({ + label, + value, + placeholder, + cardTable, + onChange, +}: { + label: string; + value: string; + placeholder?: string; + /** The table of the card on screen, for "this card's table"; empty when the card is for no one table. */ + cardTable: string; + onChange: (value: string) => void; +}) { + const [open, setOpen] = useState(false); + // Where a picked chair goes: the caret as the field last had it. Picking + // leaves focus on the map, so the next chair is one key away. + const caret = useRef<{ from: number; to: number } | null>(null); + const remember = (field: HTMLInputElement) => { + caret.current = { from: field.selectionStart ?? field.value.length, to: field.selectionEnd ?? field.value.length }; + }; + + const insert = (token: string) => { + const { from, to } = caret.current ?? { from: value.length, to: value.length }; + // A word of its own: a space before it when it would otherwise run on. + const text = `${from > 0 && !/\s/.test(value[from - 1]!) ? " " : ""}{{${token}}}`; + onChange(value.slice(0, from) + text + value.slice(to)); + caret.current = { from: from + text.length, to: from + text.length }; + }; + + return ( + <> + + { + onChange(e.target.value); + remember(e.target); + }} + onSelect={(e) => remember(e.currentTarget)} + /> + + + {open && } + + ); +} + +/** The smallest a chair is to click or tap, as a radius on screen. */ +const TARGET_PX = 12; + +/** + * The map: tables and their chairs, from the room as it stands. A chair at a + * table that numbers its seats is a button; one where guests sit where they + * like is not, and says why. Chairs this text already names are ringed. The + * map is one stop on Tab; arrow keys go chair to chair. + */ +export function ChairPicker({ + template, + cardTable, + onPick, +}: { + template: string; + cardTable: string; + onPick: (token: string) => void; +}) { + const { room, design } = usePlaque(useShallow((s) => ({ room: s.room, design: s.template }))); + const own = useMemo( + () => (cardTable ? (room.tables.find((t) => normalise(t.label) === normalise(cardTable)) ?? null) : null), + [room, cardTable], + ); + const [chosen, setChosen] = useState("table"); + // A card with no table of its own (or a guest with none yet) can only name the room's chairs. + const mode: Mode = chosen === "table" && own ? "table" : "room"; + // In the room, one table up close: a whole room's chairs are small to hit. + const [near, setNear] = useState(""); + const nearTable = room.tables.find((t) => t.label === near) ?? null; + const showing = mode === "table" ? [own!] : nearTable ? [nearTable] : room.tables; + + // Every chair this text names, by where it is. + const used = useMemo(() => { + const keys = new Set(); + for (const token of tokensIn(template)) { + const ref = chairRef(token); + if (!ref) continue; + const table = ref.table ?? own?.label ?? ""; + keys.add(`${normalise(table)}#${ref.seat}`); + } + return keys; + }, [template, own]); + + const bounds = useMemo(() => boundsOf(showing, room.seatRadius), [showing, room.seatRadius]); + const freeSeating = mode === "table" && !own!.numbered; + + // How big a room unit is on screen, so a chair is never too small to hit. + const svg = useRef(null); + const [drawn, setDrawn] = useState({ w: 0, h: 0 }); + useLayoutEffect(() => { + const el = svg.current; + if (!el) return; + const measure = () => { + const { width, height } = el.getBoundingClientRect(); + setDrawn({ w: width, h: height }); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, [showing.length]); + // The map is scaled to fit its box both ways (SVG's default "meet"). + const unitsPerPx = drawn.w > 0 && drawn.h > 0 ? Math.max(bounds.w / drawn.w, bounds.h / drawn.h) : 0; + + // The chairs that can be picked, in order: one tab stop, arrows between them. + const chairs = showing.flatMap((table) => (table.numbered ? table.seats.map((seat) => ({ table, seat })) : [])); + const [active, setActive] = useState(0); + const buttons = useRef>([]); + const focusChair = (index: number) => { + const next = Math.max(0, Math.min(chairs.length - 1, index)); + setActive(next); + buttons.current[next]?.focus(); + }; + + const pick = (table: RoomTable, seat: number) => { + const ref: ChairRef = { table: mode === "table" ? null : table.label, seat }; + onPick(chairToken(ref)); + }; + + return ( +
+
+ + +
+ + {!own + ? "This card is not for one table, so its chairs are picked from the room: each one says whoever sits there, on every card." + : mode === "table" + ? "Seat by seat on this card's table: every table's card fills in its own." + : "One chair in the room: it says whoever sits there, on every card."} + + + {mode === "room" && room.tables.length > 1 && ( + ({ value: t.label, label: t.label }))]} + onChange={setNear} + /> + )} + {room.tables.length === 0 ? ( + There is no seating plan yet. Add tables in Seating, and they appear here. + ) : ( + + {showing.map((table) => { + // Half the gap to the nearest chair at this table: targets grow, but never into a neighbour. + const spare = nearestGap(table) / 2; + const hit = Math.max(room.seatRadius, Math.min(TARGET_PX * unitsPerPx, spare)); + return ( + + 1 ? `${styles.table} ${styles.zoomable}` : styles.table} + // A click on a table brings it up close; "Up close" does the same from the keyboard. + onClick={showing.length > 1 ? () => setNear(table.label) : undefined} + > + {table.numbered ? table.label : `${table.label}: guests sit where they like`} + + {table.seats.map((seat) => { + if (!table.numbered) { + return ; + } + const index = chairs.findIndex((c) => c.table === table && c.seat === seat); + const key = `${normalise(table.label)}#${seat.number}`; + const who = seat.row ? chairName(design, seat.row) : "empty"; + const label = `${table.label}, seat ${seat.number}: ${who}`; + const press = () => { + setActive(index); + pick(table, seat.number); + }; + return ( + + + { + buttons.current[index] = el; + }} + cx={seat.x} + cy={seat.y} + r={hit} + className={styles.hit} + role="button" + tabIndex={index === Math.min(active, chairs.length - 1) ? 0 : -1} + aria-label={label} + onClick={press} + onKeyDown={(e: KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + press(); + } else if (e.key === "ArrowRight" || e.key === "ArrowDown") { + e.preventDefault(); + focusChair(index + 1); + } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { + e.preventDefault(); + focusChair(index - 1); + } else if (e.key === "Home") { + e.preventDefault(); + focusChair(0); + } else if (e.key === "End") { + e.preventDefault(); + focusChair(chairs.length - 1); + } + }} + > + {label} + + + ); + })} + + ); + })} + + )} + + {freeSeating && ( + + Guests at {own!.label} sit where they like, so its chairs have no names to pick. Number its seats in + Seating, and they can be picked here. + + )} + +
+ ); +} + +/** The closest two chairs at a table come, centre to centre. */ +function nearestGap(table: RoomTable): number { + let best = Infinity; + for (const [i, a] of table.seats.entries()) { + for (const b of table.seats.slice(i + 1)) best = Math.min(best, Math.hypot(a.x - b.x, a.y - b.y)); + } + return best; +} + +/** What a set of tables and their chairs cover, with a chair's width to spare. */ +function boundsOf(tables: RoomTable[], seatRadius: number) { + const xs: number[] = []; + const ys: number[] = []; + for (const t of tables) { + const reach = Math.hypot(t.view.w, t.view.h) / 2; + xs.push(t.x - reach, t.x + reach, ...t.seats.map((s) => s.x)); + ys.push(t.y - reach, t.y + reach, ...t.seats.map((s) => s.y)); + } + if (xs.length === 0) return { x: 0, y: 0, w: 1, h: 1 }; + const pad = seatRadius * 2; + const x = Math.min(...xs) - pad; + const y = Math.min(...ys) - pad; + return { x, y, w: Math.max(...xs) + pad - x, h: Math.max(...ys) + pad - y }; +} diff --git a/suite/apps/plaque/ui/panels/DataPanel.module.css b/suite/apps/plaque/ui/panels/DataPanel.module.css index 61ec87d9..8558b3b1 100644 --- a/suite/apps/plaque/ui/panels/DataPanel.module.css +++ b/suite/apps/plaque/ui/panels/DataPanel.module.css @@ -73,3 +73,21 @@ font-size: var(--text-sm); cursor: pointer; } + +.live { + margin: 0; + font-size: var(--text-sm); + color: var(--grey-7); +} + +ul.issues { + margin: 0; + padding-left: var(--sp-4); +} + +.only { + display: flex; + flex-direction: column; + gap: var(--sp-2); + align-items: flex-start; +} diff --git a/suite/apps/plaque/ui/panels/DataPanel.tsx b/suite/apps/plaque/ui/panels/DataPanel.tsx index 74b27e12..9750043e 100644 --- a/suite/apps/plaque/ui/panels/DataPanel.tsx +++ b/suite/apps/plaque/ui/panels/DataPanel.tsx @@ -1,13 +1,11 @@ import { useMemo, useState } from "react"; import { useShallow } from "zustand/react/shallow"; -import { buildArtefacts } from "../../core/data/artefacts"; +import type { Artefact } from "../../core/data/artefacts"; +import { artefactsOf } from "../../core/data/parts"; import type { RowScope } from "../../core/types"; +import { printing } from "../../state/printed"; import { usePlaque } from "../../state/store"; import { Hint, SelectField, SubGroup } from "../controls"; -import { useKnotworkStore } from "@/lib/store/useKnotworkStore"; -import { guestName, isComing, readGuests } from "@/lib/model/slices"; -import type { Guest } from "@/lib/model/types"; -import { rowsFromRoom } from "../../state/fromRoom"; import styles from "./DataPanel.module.css"; const PER_ROW: RowScope = { kind: "per-row" }; @@ -24,120 +22,83 @@ function parseScope(value: string): RowScope { } /** States the consequence in counts, because that is what the user is deciding. */ -function scopeHint(scope: RowScope, rowCount: number, artefactCount: number): string { - if (scope.kind === "per-row") return `${rowCount} rows, ${artefactCount} cards.`; +function scopeHint(scope: RowScope, rowCount: number, artefacts: Artefact[]): string { + if (scope.kind === "per-row") return `${rowCount} rows, ${artefacts.length} cards.`; if (scope.kind === "document") return `${rowCount} rows on one document.`; - return `${rowCount} rows fall into ${artefactCount} groups by "${scope.byColumn}" — one artefact each.`; + const grouped = `${rowCount} rows fall into ${artefacts.length} groups by "${scope.byColumn}" — one artefact each.`; + const leftOut = rowCount - artefacts.reduce((n, a) => n + a.rows.length, 0); + if (leftOut === 0) return grouped; + return `${grouped} ${leftOut === 1 ? "One row has" : `${leftOut} rows have`} no ${scope.byColumn}, so ${leftOut === 1 ? "is" : "are"} on none.`; } /** - * The guest list the cards print from: the room, all of it or a chosen few. + * What this piece prints from: the room, live. * - * There is no file import. A CSV exported before the last three people moved - * prints three wrong tables and looks perfectly fine doing it; the room is - * always current. + * There is no file import and nothing to press. A CSV exported before the last + * three people moved prints three wrong tables and looks perfectly fine doing + * it; the room is always current, and the cards are read from it as it changes. */ export function DataPanel() { - const { headers, rows, csvIssues, fileName, setCsv, rowScope, setRowScope } = usePlaque( + const { template, headers, rows, rowIds, rowIssues, rowScope, setRowScope, printOnly, setPrintOnly } = usePlaque( useShallow((s) => ({ + template: s.template, headers: s.headers, rows: s.rows, - csvIssues: s.csvIssues, - fileName: s.fileName, - setCsv: s.setCsv, + rowIds: s.rowIds, + rowIssues: s.rowIssues, rowScope: s.template.rowScope ?? PER_ROW, setRowScope: s.setRowScope, + printOnly: s.printOnly, + setPrintOnly: s.setPrintOnly, })), ); - // Counting is cheap and it is the only honest way to say what the scope did. - const artefactCount = buildArtefacts(rows, rowScope, headers).length; - // Subscribed to the shared wedding, not to Plaque's own store: seat someone - // in the room next door and this list has to move. Read through the - // per-document cache, so the selector returns the same record until the - // document changes and never allocates. - const everyone = useKnotworkStore((s) => readGuests(s.doc)); - // Cards for the guests who are coming, which is who `rowsFromRoom` prints: - // a count that included the ones who said no would promise cards it then - // leaves out. - const roomGuests = useMemo( - () => Object.fromEntries(Object.entries(everyone).filter(([, guest]) => isComing(guest))), - [everyone], - ); - const roomCount = Object.keys(roomGuests).length; + const artefacts = useMemo(() => artefactsOf(template, rows, headers, rowIds), [template, rows, headers, rowIds]); return ( <> - {roomCount === 0 ? ( - - No guests yet. Add them in Seating, or import a list from the Data button, and they - appear here. - - ) : ( - - )} +

+ {rows.length === 0 + ? "No guests yet. Add them in Seating, or import a list from the Data button, and they appear here." + : `Printing from the room, as it stands: ${rows.length} ${rows.length === 1 ? "guest" : "guests"}. Seat someone and their card already knows.`} +

- {roomCount > 0 && ( - setCsv(rowsFromRoom(ids))} /> - )} - - {fileName && ( - - {rows.length === roomCount - ? `Printing all ${rows.length} guests from the room.` - : `Printing ${rows.length} of the room's ${roomCount} guests.`} - - )} - - {headers.length > 0 && ( + {rows.length > 0 && ( <> ({ value: `group:${h}`, label: `Group by ${h} — menus, table cards` })), - { value: "document", label: "The whole list — run-sheet, seating list" }, + { value: "per-row", label: "Guest — place cards, escort cards, badges" }, + ...headers.map((h) => ({ value: `group:${h}`, label: `Group by ${h} — table cards, menus` })), + { value: "document", label: "The whole list — a board, a seating list" }, ]} onChange={(value) => setRowScope(parseScope(value))} /> - - {scopeHint(rowScope, rows.length, artefactCount)} - + {scopeHint(rowScope, rows.length, artefacts)} )} - {headers.length > 0 && ( - -
- {headers.map((h) => ( - - {`{{${h}}}`} - - ))} -
-
+ {(artefacts.length > 1 || printOnly) && ( + )} - {csvIssues.length > 0 && ( -
- - {csvIssues.length} {csvIssues.length === 1 ? "row needs" : "rows need"} a look - -
    - {csvIssues.slice(0, 20).map((issue, i) => ( -
  • {issue.message}
  • - ))} - {csvIssues.length > 20 &&
  • …and {csvIssues.length - 20} more.
  • } -
-
+ +
+ {headers.map((h) => ( + + {`{{${h}}}`} + + ))} +
+
+ + {/* Grouped, the count above says who is on no card; these speak of a guest's own card. */} + {rowIssues.length > 0 && rowScope.kind !== "per-group" && ( +
    + {rowIssues.map((issue) => ( +
  • {issue.message}
  • + ))} +
)} ); @@ -145,51 +106,60 @@ export function DataPanel() { /** * Reprinting a handful — a misspelt name, a late change of table — without - * running the whole list through the printer again. + * running the whole list through the printer again. Chosen by card rather than + * by guest, so it works the same for a place card, a combined card or a table. */ -function FewGuests({ - guests, - onUse, +function ReprintFew({ + artefacts, + printOnly, + onChoose, }: { - guests: Record; - onUse: (ids: ReadonlySet) => void; + artefacts: Artefact[]; + printOnly: string[] | null; + onChoose: (keys: string[] | null) => void; }) { const [query, setQuery] = useState(""); - const [chosen, setChosen] = useState>(new Set()); - const everyone = useMemo( - () => - Object.values(guests) - .map((guest) => ({ id: guest.id, name: guestName(guest) })) - .sort((a, b) => a.name.localeCompare(b.name, "en")), - [guests], - ); + const [chosen, setChosen] = useState>(new Set(printOnly ?? [])); const needle = query.trim().toLowerCase(); - const shown = needle ? everyone.filter((guest) => guest.name.toLowerCase().includes(needle)) : everyone; + const shown = needle ? artefacts.filter((a) => a.label.toLowerCase().includes(needle)) : artefacts; - const toggle = (id: string) => + const toggle = (key: string) => setChosen((current) => { const next = new Set(current); - if (next.has(id)) next.delete(id); - else next.add(id); + if (next.has(key)) next.delete(key); + else next.add(key); return next; }); + if (printOnly) { + return ( +
+ + The PDF will hold just {printing(artefacts, printOnly).length} of {artefacts.length}. + + +
+ ); + } + return ( setQuery(event.target.value)} /> -
    - {shown.map((guest) => ( -
  • +
      + {shown.map((artefact) => ( +
    • ))} @@ -198,11 +168,9 @@ function FewGuests({ type="button" className={styles.button} disabled={chosen.size === 0} - onClick={() => onUse(chosen)} + onClick={() => onChoose(artefacts.filter((a) => chosen.has(a.key)).map((a) => a.key))} > - {chosen.size === 0 - ? "Choose guests to print" - : `Print just these ${chosen.size}`} + {chosen.size === 0 ? "Choose cards to print" : `Print just these ${chosen.size}`} ); diff --git a/suite/apps/plaque/ui/panels/DuplexCard.tsx b/suite/apps/plaque/ui/panels/DuplexCard.tsx index a3943d42..18c34755 100644 --- a/suite/apps/plaque/ui/panels/DuplexCard.tsx +++ b/suite/apps/plaque/ui/panels/DuplexCard.tsx @@ -1,3 +1,4 @@ +import { homePaper } from "../../core/units"; import { useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { @@ -31,7 +32,7 @@ export function DuplexCard() { useShallow((s) => ({ printers: s.printers, activePrinterId: s.activePrinterId, - page: s.sheet.page, + page: homePaper(s.sheet), orientation: s.sheet.orientation, })), ); diff --git a/suite/apps/plaque/ui/panels/GeometryPanel.tsx b/suite/apps/plaque/ui/panels/GeometryPanel.tsx index 09ab4354..4d876fdd 100644 --- a/suite/apps/plaque/ui/panels/GeometryPanel.tsx +++ b/suite/apps/plaque/ui/panels/GeometryPanel.tsx @@ -6,11 +6,19 @@ import { suggestLayouts } from "../../core/geometry/suggestLayouts"; import { STOCK_PRESETS, applyPreset } from "../../core/data/stockPresets"; import { CARD_PRESETS, applyCardPreset } from "../../core/data/cardPresets"; import { GALLERY } from "../../core/data/gallery"; -import type { FoldAxis, Orientation, PageSizeName } from "../../core/types"; +import type { FoldAxis, Mm, Orientation, PageSizeName, PaperName } from "../../core/types"; +import { MARK_LENGTH_MM } from "../../core/geometry/cropMarks"; import { usePlaque } from "../../state/store"; import { CheckboxField, Hint, NumberField, Row, SelectField, SubGroup } from "../controls"; import styles from "./GeometryPanel.module.css"; +const uniformMargin = (mm: Mm) => ({ + marginTopMm: mm, + marginRightMm: mm, + marginBottomMm: mm, + marginLeftMm: mm, +}); + /** * FR-STA-02, the card half: any size, any fold. * @@ -187,7 +195,7 @@ export function SheetPanel() { feeding stock you paid for. - {suggestions.length > 0 && ( + {sheet.page !== "FIT" && suggestions.length > 0 && (
      {suggestions.map((s) => ( @@ -218,19 +226,49 @@ export function SheetPanel() { options={[ { value: "A4", label: "A4" }, { value: "LETTER", label: "US Letter" }, + { value: "A3", label: "A3" }, + { value: "FIT", label: "The card's own size" }, ]} - onChange={(page) => setSheet({ page })} - /> - - label="Orientation" - value={sheet.orientation} - options={[ - { value: "portrait", label: "Portrait" }, - { value: "landscape", label: "Landscape" }, - ]} - onChange={(orientation) => setSheet({ orientation })} + onChange={(page) => + setSheet( + page === "FIT" + ? // Room round the board for its bleed and crop marks, and nothing + // else: the print shop trims to the marks. + { page, ...uniformMargin(card.bleedMm + MARK_LENGTH_MM + 2), gapXMm: 0, gapYMm: 0, cardRotationDeg: 0 } + : { page }, + ) + } /> + {sheet.page === "FIT" ? ( + + label="Tile at home on" + value={sheet.tilePaper} + options={[ + { value: "A4", label: "A4" }, + { value: "LETTER", label: "US Letter" }, + { value: "A3", label: "A3" }, + ]} + onChange={(tilePaper) => setSheet({ tilePaper })} + /> + ) : ( + + label="Orientation" + value={sheet.orientation} + options={[ + { value: "portrait", label: "Portrait" }, + { value: "landscape", label: "Landscape" }, + ]} + onChange={(orientation) => setSheet({ orientation })} + /> + )} + {sheet.page === "FIT" && ( + + One per page at full size, with its bleed and crop marks: the PDF a print shop wants. To + print it yourself, tile it onto {sheet.tilePaper === "LETTER" ? "Letter" : sheet.tilePaper} from the + export bar. + + )} { - const scope = s.template.rowScope ?? { kind: "per-row" as const }; - const artefacts = buildArtefacts(s.rows, scope, s.headers, s.rowIds); + const artefacts = artefactsOf(s.template, s.rows, s.headers, s.rowIds); const artefact = artefacts[s.previewGuestIndex] ?? artefacts[0] ?? null; return { // The effective element: what this artefact actually prints, so the @@ -70,6 +78,10 @@ export function InspectorPanel() { template: s.template, rowId: artefact?.rowId ?? null, rowLabel: artefact?.label ?? "", + // A whole-list piece is one card for every table, so it has no table of its own. + cardTable: s.template.rowScope?.kind === "document" ? "" : (artefact?.row["Table"] ?? ""), + previewRow: artefact?.row ?? null, + room: s.room, card: s.card, images: s.images, cropId: s.cropId, @@ -78,6 +90,12 @@ export function InspectorPanel() { }), ); const [rowOnly, setRowOnly] = useState(false); + // A box that follows a chair, shown where it prints on the card on screen. + const element = useMemo(() => { + if (!raw || raw.kind !== "text" || !raw.chair?.follow) return raw; + const own = { ...template, elements: template.elements.map((el) => (el.id === raw.id ? raw : el)) }; + return placeChairs(own, room, previewRow ?? {}).elements.find((el) => el.id === raw.id); + }, [raw, template, room, previewRow]); if (!element) { return Click something on the card to edit it.; @@ -143,6 +161,7 @@ export function InspectorPanel() { ({ value: id, label: fontLabels[id] ?? id }))} fonts={fonts} patch={patch} @@ -153,12 +172,47 @@ export function InspectorPanel() { ({ value: id, label: fontLabels[id] ?? id }))} fonts={fonts} patch={patch} /> )} + {element.kind === "grid" && ( + ({ value: id, label: fontLabels[id] ?? id }))} + patch={patch} + /> + )} + + {element.kind === "qr" && ( + + patch({ data })} + /> + + {"{{Guest Link}}"} is the wedding's guest link — names and tables only — once it is published from + the Data menu. Anything else typed here, a website or a gift list, works as well. Keep it dark on a + light card, and at least 2cm across. + + patch({ colorHex: c ?? "#000000" })} /> + + )} + + {element.kind === "room" && ( + ({ value: id, label: fontLabels[id] ?? id }))} + patch={patch} + /> + )} + {element.kind === "icon" && ( ; + patch: (p: Partial) => void; +}) { + return ( + <> + + label="Show" + value={element.show} + options={[ + { value: "room", label: "The whole room" }, + { value: "table", label: "Just this card's table" }, + ]} + onChange={(show) => patch({ show })} + /> + + + + Drawn from Seating as it stands: move a table or a guest there and it moves here. Where a table + numbers its seats, each name sits at its chair, every one at the same size; where guests sit + where they like, they are named inside the table, as Seating shows them. + + + patch({ tableLabels })} /> + patch({ walls })} /> + + patch({ namesAtChairs })} + hint="Off once the names are boxes of their own, so they are not printed twice." + /> + + + patch({ fontId })} /> + + patch({ fontSizePt })} + /> + patch({ nameGap: Math.max(0, nameGap) })} + /> + + patch({ colorHex: c ?? "#000000" })} /> + + + + patch({ tableHex: c ?? "#e8dfcf" })} /> + patch({ seatHex: c ?? "#c9b48f" })} /> + patch({ wallHex: c ?? "#7a6a55" })} /> + + + ); +} + +/** A block per table, in columns: what makes a seating board. */ +function GridProperties({ + element, + headers, + fontOptions, + patch, +}: { + element: GridElement; + headers: string[]; + fontOptions: Array<{ value: string; label: string }>; + patch: (p: Partial) => void; +}) { + return ( + <> + + label="Lay it out as" + value={element.layout} + options={[ + { value: "cells", label: "A block each, all one size — a seating board" }, + { value: "columns", label: "Columns, onto more pages if need be — a finder" }, + ]} + onChange={(layout) => patch({ layout })} + /> + ({ value: h, label: h }))} + onChange={(groupBy) => patch({ groupBy })} + /> + ({ value: h, label: h }))]} + onChange={(sortBy) => patch({ sortBy })} + /> + patch({ headingTemplate })} + /> + patch({ itemTemplate })} + /> + + {element.layout === "cells" + ? "Every block shares one size, set as large as the fullest block allows." + : "Blocks run down each column at the size set below; a list too long for the page carries on onto the next."}{" "} + Somebody with no {element.groupBy || "value"} is left off, and the warnings say how many. + + + + + patch({ columns: Math.max(1, Math.round(columns)) })} + /> + patch({ gapMm })} + /> + + + + + patch({ headingFontId })} + /> + + patch({ headingScale })} + /> + patch({ headingColorHex: c ?? "#000000" })} + /> + + patch({ fontId })} /> + + patch({ fontSizePt })} + /> + patch({ fit: { ...element.fit, minFontSizePt } })} + /> + + + patch({ lineHeight })} + /> + + label="Align" + value={element.align} + options={[ + { value: "left", label: "Left" }, + { value: "center", label: "Centre" }, + { value: "right", label: "Right" }, + ]} + onChange={(align) => patch({ align })} + /> + + patch({ colorHex: c ?? "#000000" })} /> + + + ); +} + function ListProperties({ element, headers, + cardTable, fontOptions, fonts, patch, }: { element: ListElement; headers: string[]; + cardTable: string; fontOptions: Array<{ value: string; label: string }>; fonts: Map; patch: (p: Partial) => void; }) { return ( <> - patch({ itemTemplate })} /> @@ -513,22 +781,25 @@ function OpticalProperties({ function TextProperties({ element, headers, + cardTable, fontOptions, fonts, patch, }: { element: TextElement; headers: string[]; + cardTable: string; fontOptions: Array<{ value: string; label: string }>; fonts: Map; patch: (p: Partial) => void; }) { return ( <> - patch({ template })} /> {headers.length > 0 && ( diff --git a/suite/apps/plaque/ui/panels/NameFormat.module.css b/suite/apps/plaque/ui/panels/NameFormat.module.css new file mode 100644 index 00000000..b2ff04f2 --- /dev/null +++ b/suite/apps/plaque/ui/panels/NameFormat.module.css @@ -0,0 +1,27 @@ +.format { + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +.choices { + display: flex; + flex-wrap: wrap; + gap: var(--sp-1); +} + +.choice { + padding: 2px var(--sp-2); + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface); + font: inherit; + font-size: var(--text-xs); + color: var(--grey-7); + cursor: pointer; +} + +.active { + border-color: var(--accent); + color: var(--grey-8); +} diff --git a/suite/apps/plaque/ui/panels/NameFormat.tsx b/suite/apps/plaque/ui/panels/NameFormat.tsx new file mode 100644 index 00000000..32ce3823 --- /dev/null +++ b/suite/apps/plaque/ui/panels/NameFormat.tsx @@ -0,0 +1,55 @@ +import { useShallow } from "zustand/react/shallow"; +import { DEFAULT_CHAIR_NAME, chairName } from "../../core/template/chairs"; +import { usePlaque } from "../../state/store"; +import { Hint, TextField } from "../controls"; +import styles from "./NameFormat.module.css"; + +/** Formats people reach for, offered one click away. */ +const SUGGESTED = [ + { label: "Ada Byron", pattern: "{{First Name}} {{Last Name}}" }, + { label: "Ada", pattern: "{{First Name}}" }, + { label: "Ada B.", pattern: "{{First Name}} {{Last Initial}}" }, + { label: "Byron, Ada", pattern: "{{Last Name}}, {{First Name}}" }, +] as const; + +/** + * How this design names a guest by their chair — on its plans, and wherever it + * says `{{At seat 3}}` or `{{Table 1, seat 3}}`. One setting for the design, + * written in the guest's own tokens, shown on a few real guests as it is typed. + */ +export function NameFormat() { + const { pattern, rows, setChairName } = usePlaque( + useShallow((s) => ({ pattern: s.template.chairName ?? DEFAULT_CHAIR_NAME, rows: s.rows, setChairName: s.setChairName })), + ); + const sample = rows.slice(0, 3); + + return ( +
      + setChairName(next)} + /> +
      + {SUGGESTED.map((s) => ( + + ))} +
      + {sample.length > 0 && ( + + Reads: {sample.map((row) => chairName({ chairName: pattern }, row) || "(nothing)").join(" · ")} + + )} + A guest given a name of their own in Guests, under Known as, is called that instead. +
      + ); +} diff --git a/suite/apps/plaque/ui/panels/PrintSetupPanel.tsx b/suite/apps/plaque/ui/panels/PrintSetupPanel.tsx index e0916bf7..b938f28d 100644 --- a/suite/apps/plaque/ui/panels/PrintSetupPanel.tsx +++ b/suite/apps/plaque/ui/panels/PrintSetupPanel.tsx @@ -1,3 +1,4 @@ +import { homePaper } from "../../core/units"; import { useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { @@ -26,7 +27,7 @@ export function PrintSetupPanel() { useShallow((s) => ({ printers: s.printers, activePrinterId: s.activePrinterId, - page: s.sheet.page, + page: homePaper(s.sheet), orientation: s.sheet.orientation, })), ); diff --git a/suite/apps/plaque/ui/panels/StampChairs.module.css b/suite/apps/plaque/ui/panels/StampChairs.module.css new file mode 100644 index 00000000..4e72c840 --- /dev/null +++ b/suite/apps/plaque/ui/panels/StampChairs.module.css @@ -0,0 +1,31 @@ +.actions { + display: flex; + flex-wrap: wrap; + gap: var(--sp-1); +} + +.button { + padding: var(--sp-1) var(--sp-3); + border: 1px solid var(--border-strong); + border-radius: var(--radius); + background: var(--surface); + font: inherit; + font-size: var(--text-sm); + cursor: pointer; +} + +.button:disabled { + cursor: default; + color: var(--text-muted); +} + +.chip { + padding: 2px var(--sp-2); + border: 1px dashed var(--border-strong); + border-radius: var(--radius); + background: none; + font: inherit; + font-size: var(--text-xs); + color: var(--grey-7); + cursor: pointer; +} diff --git a/suite/apps/plaque/ui/panels/StampChairs.tsx b/suite/apps/plaque/ui/panels/StampChairs.tsx new file mode 100644 index 00000000..eeea4798 --- /dev/null +++ b/suite/apps/plaque/ui/panels/StampChairs.tsx @@ -0,0 +1,104 @@ +import { useMemo } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { normalise } from "../../core/data/artefacts"; +import { artefactsOf } from "../../core/data/parts"; +import { stampableChairs } from "../../core/template/room"; +import type { RoomElement } from "../../core/types"; +import { stampedChairs, usePlaque } from "../../state/store"; +import { Hint, SubGroup } from "../controls"; +import styles from "./StampChairs.module.css"; + +/** How many uncovered chairs are named before "and n more". */ +const NAMED = 8; + +/** + * The plan's names as boxes of their own, one per chair, to restyle one by one + * or to place over artwork — and the chairs that have none yet. + */ +export function StampChairs({ element }: { element: RoomElement }) { + const { template, room, rows, headers, rowIds, previewGuestIndex, stampChairs } = usePlaque( + useShallow((s) => ({ + template: s.template, + room: s.room, + rows: s.rows, + headers: s.headers, + rowIds: s.rowIds, + previewGuestIndex: s.previewGuestIndex, + stampChairs: s.stampChairs, + })), + ); + + const uncovered = useMemo(() => { + const artefacts = artefactsOf(template, rows, headers, rowIds); + const row = (artefacts[previewGuestIndex] ?? artefacts[0])?.row ?? {}; + const covered = new Set(stampedChairs(template, element.id)); + return stampableChairs(element, room, row).filter((c) => !covered.has(`${c.table === null ? "" : normalise(c.table)}#${c.seat}`)); + }, [template, room, rows, headers, rowIds, previewGuestIndex, element]); + + const stamped = stampedChairs(template, element.id).length; + const tableWise = element.show === "table"; + + return ( + + {stamped === 0 ? ( + <> + + A text box at every chair this plan names, each bound to its chair: restyle any of them, or move + them over your own artwork. + {tableWise ? " On a table's own map they always follow their chairs: every table is shaped differently." : ""} + +
      + + {!tableWise && ( + + )} +
      + {uncovered.length === 0 && ( + + {room.tables.length === 0 + ? "There is no seating plan yet. Add tables in Seating, and their chairs appear here." + : "No table here numbers its seats, so there are no chairs to name. Set a table to number its seats in Seating."} + + )} + + ) : uncovered.length === 0 ? ( + Every chair has its box ({stamped}). + ) : ( + <> + + {uncovered.length === 1 ? "One chair has" : `${uncovered.length} chairs have`} no box:{" "} + {uncovered + .slice(0, NAMED) + .map((c) => c.label) + .join(", ")} + {uncovered.length > NAMED ? `, and ${uncovered.length - NAMED} more` : ""}. + +
      + {uncovered.slice(0, NAMED).map((c) => ( + + ))} + +
      + + )} +
      + ); +} + +/** New boxes join the old ones: following if they follow, staying if they stay. */ +function followOf(template: { elements: Array<{ kind: string; chair?: { from: string; follow: boolean } }> }, roomId: string): boolean { + return template.elements.some((el) => el.kind === "text" && el.chair?.from === roomId && el.chair.follow); +} diff --git a/suite/apps/tableaux/components/layout/PrintModal.tsx b/suite/apps/tableaux/components/layout/PrintModal.tsx index 14ac2bd7..47e1089a 100644 --- a/suite/apps/tableaux/components/layout/PrintModal.tsx +++ b/suite/apps/tableaux/components/layout/PrintModal.tsx @@ -1,77 +1,41 @@ -import { useState } from 'react' +import Link from 'next/link' import { useStore } from '../../store/useStore' -import { exportFloorPlanPdf, exportCards, type Pages } from '../../utils/exportPdf' -import type { Plan } from '../../store/types' -import { CARD_TEMPLATE_LIST } from '../../utils/cardTemplates' import Modal from '../ui/Modal' import Icon from '../ui/Icon' import styles from './ExportModal.module.css' -const SHEET_OPTIONS: Array<{ pages: Pages; label: string; desc: string }> = [ - { - pages: 'single', - label: 'Seating chart — one page (PDF)', - desc: "Every guest's name printed on their seat, to scale on a single sheet.", - }, - { - pages: 'split', - label: 'Seating chart — split over two sheets (PDF)', - desc: 'Same chart at twice the size, tiled across two overlapping sheets.', - }, +/** + * Everything printed from this room is made in Place cards, from the room as + * it stands: one design for each piece, in the wedding's own fonts, at any + * size from a place card to a board for the door. Seating keeps no second way + * to print, so a board and its place cards cannot disagree. + */ +const PIECES: Array<{ piece: string; label: string; desc: string; icon: 'maximize' | 'layers' }> = [ + { piece: 'floor-plan', label: 'Floor plan', desc: 'This room to scale, a name at every chair.', icon: 'maximize' }, + { piece: 'seating-board', label: 'Seating board', desc: 'Every table and who sits at it, for the door.', icon: 'maximize' }, + { piece: 'finder', label: 'Finder', desc: 'Every guest A to Z, with their table.', icon: 'maximize' }, + { piece: 'place-cards', label: 'Place cards', desc: 'One for each seat, with its table.', icon: 'layers' }, + { piece: 'escort-card', label: 'Escort cards', desc: 'One for each guest: where to go.', icon: 'layers' }, + { piece: 'table-card', label: 'Table cards', desc: 'One for each table: who sits there.', icon: 'layers' }, ] export default function PrintModal() { const closeModal = useStore((s) => s.closeModal) - const addToast = useStore((s) => s.addToast) - const [busy, setBusy] = useState(false) - - const run = async (fn: (doc: Plan, name: string) => Promise) => { - if (busy) return - setBusy(true) - const s = useStore.getState() - try { - await fn(s.serialize(), s.meta.weddingName) - closeModal() - } catch (err) { - addToast({ type: 'error', message: 'Could not generate the PDF. Please try again.' }) - // eslint-disable-next-line no-console - console.error(err) - } finally { - setBusy(false) - } - } return ( - +
      - {SHEET_OPTIONS.map((o) => ( - - ))} - - {CARD_TEMPLATE_LIST.map((tpl) => ( - + + {p.label} + {p.desc} + ))}
      diff --git a/suite/apps/tableaux/store/actions.ts b/suite/apps/tableaux/store/actions.ts index 27475eaa..de78db92 100644 --- a/suite/apps/tableaux/store/actions.ts +++ b/suite/apps/tableaux/store/actions.ts @@ -498,6 +498,7 @@ export const addGuest = id, firstName: first, lastName: last, + knownAs: (partial.knownAs || '').trim(), fullName, email: (partial.email || '').trim(), dietary: partial.dietary || '', diff --git a/suite/apps/tableaux/utils/cardTemplates.ts b/suite/apps/tableaux/utils/cardTemplates.ts deleted file mode 100644 index 3c365c42..00000000 --- a/suite/apps/tableaux/utils/cardTemplates.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Multi-up card layouts for printable place cards and escort cards. Dimensions - * are in millimetres (jsPDF unit 'mm') against US Letter, with cut/fold guides - * drawn so users can trim. Approximate Avery layouts — close enough to print - * on standard perforated stock or to cut by hand. - */ -export interface CardTemplate { - label: string - kind: 'place' | 'escort' - pageW: number - pageH: number - marginX: number - marginY: number - cols: number - rows: number - cellW: number - cellH: number - gapX: number - gapY: number - fold: boolean -} - -export const CARD_TEMPLATES: Record = { - 'place-tent': { - label: 'Place cards — tent fold (2 per page)', - kind: 'place', - pageW: 215.9, - pageH: 279.4, - marginX: 18, - marginY: 24, - cols: 1, - rows: 2, - cellW: 180, - cellH: 110, - gapX: 0, - gapY: 12, - fold: true, - }, - 'escort-10up': { - label: 'Escort cards — 10 per page (Avery 5371)', - kind: 'escort', - pageW: 215.9, - pageH: 279.4, - marginX: 16, - marginY: 12.7, - cols: 2, - rows: 5, - cellW: 88.9, - cellH: 50.8, - gapX: 6, - gapY: 0, - fold: false, - }, -} - -export const CARD_TEMPLATE_LIST = Object.entries(CARD_TEMPLATES).map(([id, t]) => ({ id, ...t })) diff --git a/suite/apps/tableaux/utils/exportPdf.test.js b/suite/apps/tableaux/utils/exportPdf.test.js deleted file mode 100644 index 4c9e47ed..00000000 --- a/suite/apps/tableaux/utils/exportPdf.test.js +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { planSheets } from './exportPdf' - -// A wide plan whose names are far too small to stay legible on one A4 sheet. -const tiny = { minX: 0, minY: 0, width: 4000, height: 1000, basePx: 20 } -// A small plan that fits comfortably at full size. -const roomy = { minX: 0, minY: 0, width: 400, height: 300, basePx: 5 } -const AVAIL_W = 746 -const AVAIL_H = 547 - -describe('planSheets', () => { - it('keeps one sheet when the type is legible', () => { - expect(planSheets(roomy, AVAIL_W, AVAIL_H, 'auto').windows).toHaveLength(1) - }) - - it('tiles automatically when one sheet would go below the font floor', () => { - const { windows, basePt } = planSheets(tiny, AVAIL_W, AVAIL_H, 'auto') - expect(windows).toHaveLength(2) - expect(basePt).toBeGreaterThan(planSheets(tiny, AVAIL_W, AVAIL_H, 'single').basePt) - }) - - it('forces a single sheet even when the type is illegible', () => { - expect(planSheets(tiny, AVAIL_W, AVAIL_H, 'single').windows).toEqual([ - { minX: 0, minY: 0, width: 4000, height: 1000 }, - ]) - }) - - it('forces two overlapping sheets even when one would do', () => { - const { windows } = planSheets(roomy, AVAIL_W, AVAIL_H, 'split') - expect(windows).toHaveLength(2) - // second tile starts before the halfway line, so the seam is covered - expect(windows[1].minX).toBeLessThan(roomy.width / 2) - expect(windows[0].minX + windows[0].width).toBeGreaterThan(windows[1].minX) - }) -}) diff --git a/suite/apps/tableaux/utils/exportPdf.ts b/suite/apps/tableaux/utils/exportPdf.ts deleted file mode 100644 index 3493469c..00000000 --- a/suite/apps/tableaux/utils/exportPdf.ts +++ /dev/null @@ -1,229 +0,0 @@ -import type { jsPDF as JsPdf } from 'jspdf' -import { buildFloorPlanSvg, measureFloorPlan, type Bounds, type FloorPlanSource, type Measure } from './floorPlanSvg' -import { CARD_TEMPLATES, type CardTemplate } from './cardTemplates' -import { slug } from './download' - -// jsPDF + svg2pdf are heavy and only needed on export, so they are dynamically -// imported (kept out of the main bundle). -async function loadPdf() { - const [{ jsPDF }, { svg2pdf }] = await Promise.all([import('jspdf'), import('svg2pdf.js')]) - return { jsPDF, svg2pdf } -} - -/** How the plan is laid across sheets: fitted to one unless the type would go illegible. */ -export type Pages = 'auto' | 'single' | 'split' - -// ── one-page seating chart ────────────────────────────────────────────────── -// Flip PAGE_FORMAT to 'a3' for an entrance-board sized print; everything else -// (orientation, fit scale, type size) follows from the page dimensions. -// -// The margins are deliberately tight. This is a working sheet that gets pinned -// up or carried round a room, not a document with a house style, and every -// point given to a margin comes straight off the names. -const PAGE_FORMAT = 'a4' -const MARGIN = 8 -const HEADER = 12 // vertical band above the plan for the title -const PLAN_PAD = 8 // clear ground drawn around the plan itself -const MAX_NAME_PT = 16 -const FONT_FLOOR_PT = 6 // below this the plan is tiled across two sheets -const TILE_OVERLAP = 0.05 - -const tableByLabel = (a: { label: string }, b: { label: string }) => - String(a.label).localeCompare(String(b.label), undefined, { numeric: true }) - -/** Seated guests in table → seat order, with their table label. */ -function seatedGuests(doc: FloorPlanSource): Array<{ name: string; table: string; lastName: string }> { - const guests = doc.guests || {} - const tables = doc.tables || {} - const out: Array<{ name: string; table: string; lastName: string }> = [] - Object.values(tables) - .sort(tableByLabel) - .forEach((t) => { - for (const gid of (t.assignedGuestIds || []).filter((id): id is string => Boolean(id))) { - const g = guests[gid] - if (g) out.push({ name: g.fullName, table: t.label, lastName: g.lastName || g.fullName }) - } - }) - return out -} - -/** - * Work out how the plan is laid across sheets. - * `pages`: 'auto' splits only when a single sheet would drop below - * FONT_FLOOR_PT, 'single' always keeps one sheet (however small the type), - * 'split' always tiles across two. - * Returns the fit scale, the name point size and one window per sheet. - * - * The cell size and the type that fits it are solved against the plan itself by - * measureFloorPlan; all this does is scale that onto paper. - */ -export function planSheets( - m: Bounds & { basePx: number }, - availW: number, - availH: number, - pages: Pages = 'auto' -): { scale: number; basePt: number; windows: Bounds[] } { - const fit = (w: number, h: number) => Math.min(availW / w, availH / h) - const solve = (s: number) => Math.min(m.basePx * s, MAX_NAME_PT) - - let scale = fit(m.width, m.height) - let basePt = solve(scale) - let windows: Bounds[] = [{ minX: m.minX, minY: m.minY, width: m.width, height: m.height }] - if (pages === 'single' || (pages === 'auto' && basePt >= FONT_FLOOR_PT)) { - return { scale, basePt, windows } - } - - // Split along the longer axis into two overlapping sheets. - // ponytail: two tiles only — a plan that still can't reach the floor at 2× - // just prints small. Generalise to N tiles if that ever shows up. - const horiz = m.width >= m.height - const step = (horiz ? m.width : m.height) / 2 - const over = step * TILE_OVERLAP - const tileW = horiz ? step + over : m.width - const tileH = horiz ? m.height : step + over - const tiled = fit(tileW, tileH) - if (pages === 'auto' && tiled <= scale) return { scale, basePt, windows } - - scale = tiled - basePt = solve(scale) - windows = [0, 1].map((i) => ({ - minX: m.minX + (horiz ? Math.max(0, i * step - over) : 0), - minY: m.minY + (horiz ? 0 : Math.max(0, i * step - over)), - width: tileW, - height: tileH, - })) - return { scale, basePt, windows } -} - -/** - * A single-sheet, to-scale seating chart: every seat is a rectangle holding the - * guest's first and last name, printed where they actually sit. Every name is set - * at one size, chosen so the 90th-percentile name fits its cell — the few longer - * than that are cut rather than allowed to shrink the whole sheet. `opts.pages` picks the sheet - * count: 'auto' (default) tiles across two sheets only if one sheet would go - * illegible, 'single' forces one sheet, 'split' always tiles. - */ -/** - * The floor plan as PDF bytes. - * - * Split out from `exportFloorPlanPdf` so the same drawing can either be handed - * straight to the browser as a download or folded into the wedding pack - * alongside the run sheet and the job list. Everything below builds the page; - * only the last step differs. - */ -export async function buildFloorPlanPdf(doc: FloorPlanSource, name: string, opts: { pages?: Pages } = {}): Promise { - const { jsPDF, svg2pdf } = await loadPdf() - const planOpts = { ...opts, padPx: PLAN_PAD } - - // The plan's own shape picks the sheet's, so a tall room stops wasting half a - // landscape page. Measured with the built-in estimator because there is no - // document to measure against yet; the exact fit is redone below. - const rough = measureFloorPlan(doc, planOpts) - const orientation = rough.width >= rough.height ? 'landscape' : 'portrait' - - const pdf = new jsPDF({ orientation, unit: 'pt', format: PAGE_FORMAT }) - const pageW = pdf.internal.pageSize.getWidth() - const pageH = pdf.internal.pageSize.getHeight() - const availW = pageW - MARGIN * 2 - const availH = pageH - MARGIN * 2 - HEADER - - // Real metrics from the PDF's own Helvetica, so the fit is exact. - const measure: Measure = (text, size) => pdf.getStringUnitWidth(String(text)) * size - - const fit = (b: Bounds) => Math.min(availW / b.width, availH / b.height) - const m = measureFloorPlan(doc, { ...planOpts, measure, fit }) - const { scale, basePt, windows } = planSheets(m, availW, availH, opts.pages) - - // One cell size and one type size for the whole sheet. basePt is what actually - // prints, so divide it back out rather than re-solving and risking a disagreement. - const cells = { cellW: m.cellW, cellH: m.cellH, basePx: basePt / scale } - const title = name || 'Seating plan' - - for (const [i, win] of windows.entries()) { - if (i > 0) pdf.addPage(PAGE_FORMAT, orientation) - // Title and sheet note share one baseline: a second header line would cost - // more of the page than it tells anyone. - pdf.setFontSize(11) - pdf.setTextColor(40) - pdf.text(title, MARGIN, MARGIN + 8) - if (windows.length > 1) { - pdf.setFontSize(8) - pdf.setTextColor(130) - pdf.text(`Sheet ${i + 1} of ${windows.length}`, pageW - MARGIN, MARGIN + 8, { align: 'right' }) - } - - const { svg, width, height } = buildFloorPlanSvg(doc, { - ...planOpts, - seatLabels: 'name', - cells, - measure, - window: win, - }) - const el = new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement as unknown as SVGElement - // A to-scale plan rarely matches the page's aspect ratio, so centre it on - // both axes rather than leaving all the slack at the bottom. - await svg2pdf(el, pdf, { - x: MARGIN + (availW - width * scale) / 2, - y: MARGIN + HEADER + (availH - height * scale) / 2, - width: width * scale, - height: height * scale, - }) - } - - return pdf -} - -/** The floor plan, downloaded. */ -export async function exportFloorPlanPdf(doc: FloorPlanSource, name: string, opts: { pages?: Pages } = {}): Promise { - const pdf = await buildFloorPlanPdf(doc, name, opts) - pdf.save(`${slug(name)}-seating-chart.pdf`) -} - -function renderCards(pdf: JsPdf, items: Array<{ name: string; table: string }>, tpl: CardTemplate): void { - const perPage = tpl.cols * tpl.rows - items.forEach((item, i) => { - const onPage = i % perPage - if (i > 0 && onPage === 0) pdf.addPage() - const col = onPage % tpl.cols - const row = Math.floor(onPage / tpl.cols) - const x = tpl.marginX + col * (tpl.cellW + tpl.gapX) - const y = tpl.marginY + row * (tpl.cellH + tpl.gapY) - - pdf.setDrawColor(205) - pdf.setLineWidth(0.2) - pdf.rect(x, y, tpl.cellW, tpl.cellH) - if (tpl.fold) { - // dashed fold line across the middle for tent cards - pdf.setLineDashPattern([2, 2], 0) - pdf.line(x, y + tpl.cellH / 2, x + tpl.cellW, y + tpl.cellH / 2) - pdf.setLineDashPattern([], 0) - } - - const cx = x + tpl.cellW / 2 - const cy = y + (tpl.fold ? tpl.cellH * 0.75 : tpl.cellH / 2) - pdf.setFontSize(tpl.kind === 'escort' ? 13 : 18) - pdf.setTextColor(20) - pdf.text(String(item.name), cx, cy - 2, { align: 'center' }) - pdf.setFontSize(tpl.kind === 'escort' ? 9 : 11) - pdf.setTextColor(120) - pdf.text(String(item.table), cx, cy + (tpl.kind === 'escort' ? 6 : 9), { align: 'center' }) - }) -} - -export async function exportCards(doc: FloorPlanSource, name: string, templateId: string): Promise { - const tpl = CARD_TEMPLATES[templateId] - if (!tpl) return - const { jsPDF } = await loadPdf() - const pdf = new jsPDF({ orientation: 'portrait', unit: 'mm', format: [tpl.pageW, tpl.pageH] }) - - let items = seatedGuests(doc) - if (tpl.kind === 'escort') { - items = [...items].sort((a, b) => String(a.lastName).localeCompare(String(b.lastName))) - } - if (!items.length) { - items = [{ name: 'No seated guests yet', table: '', lastName: '' }] - } - - renderCards(pdf, items, tpl) - pdf.save(`${slug(name)}-${tpl.kind}-cards.pdf`) -} diff --git a/suite/apps/tableaux/utils/floorPlanSvg.ts b/suite/apps/tableaux/utils/floorPlanSvg.ts index d8f458dc..cb41966c 100644 --- a/suite/apps/tableaux/utils/floorPlanSvg.ts +++ b/suite/apps/tableaux/utils/floorPlanSvg.ts @@ -112,7 +112,7 @@ function buildDoorPath(we: WallElement, seg: WallSeg, scale: number): string { } /** Wall segments for a space (absolute canvas coords). */ -function getWallSegs(sp: Space): WallSeg[] { +export function getWallSegs(sp: Space): WallSeg[] { if (sp.shape === 'polygon') { return sp.vertices.map((v, i) => { const nxt = sp.vertices[(i + 1) % sp.vertices.length] @@ -350,7 +350,7 @@ const spaceBox = (sp: Space): { minX: number; minY: number; maxX: number; maxY: * out from buildFloorPlanSvg so the PDF exporter can size type against the * cells before anything is drawn. */ -function layoutFloorPlan(doc: FloorPlanSource, { ppu, padPx }: FloorPlanOptions = {}) { +export function layoutFloorPlan(doc: FloorPlanSource, { ppu, padPx }: FloorPlanOptions = {}) { const settings = doc.settings || {} const scale = ppu || settings.pixelsPerUnit || DEFAULT_PPU const guests = doc.guests || {} diff --git a/suite/components/guests/GuestsPage.tsx b/suite/components/guests/GuestsPage.tsx index ae7dc4a9..b32ec8dd 100644 --- a/suite/components/guests/GuestsPage.tsx +++ b/suite/components/guests/GuestsPage.tsx @@ -256,6 +256,9 @@ export function GuestsPage() { /> + + Known as + @@ -341,6 +344,23 @@ function GuestLine({ {name} {row.plusOne ? {row.plusOne} : null} + + {/* Keyed on the stored words, so an undo shows through. */} + { + const knownAs = event.target.value.trim(); + if (knownAs !== guest.knownAs) onChange({ knownAs }, "what they are known as"); + }} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + }} + className={`${CONTROL} w-32`} + /> +