Files
TREK/client/src/components/Studio/printSheets.ts
T
Maurice eae8480728 feat(studio): follow-ups from using Studio on a real journey (#1984)
* feat(studio): widen the document, and stop a save from losing it

The schema gains what the elements coming after it need: an icon element, a
badge that can be restyled rather than only placed, a date and a pair of
coordinates on a text binding, and the map's route, pin and road settings.

Widening it turned up two ways a book could fail to come back.

The mood badge writes its code into the document, and the schema said that
code was exactly two characters, from a first draft where it was a country.
The real codes are words, so every save was rejected and the read that
followed parsed to an empty document: seventeen spreads in, nothing out.
Widened to what the badge actually writes, and normalizeBookDocument now
drops the elements it cannot read rather than the whole book, so one bad
element costs a picture frame instead of a year of work.

The other was the body limit. The parser allowed 100kb for everything, and a
book with a routed map measures past that, so the save came back 413 and the
editor sat on "not saved" with no way forward. Book writes get their own
parser at 8mb; everything else keeps the limit it had.

* feat(studio): a date, a pair of coordinates and an icon library

Three things that had to be drawn by hand before.

A journal entry knows when it happened and where, and neither could be put
on a page: you read the date off the entry and typed it into a text box,
where it then stayed while the entry moved. Both are chips now, next to the
title and the story, and both follow their entry the way the others do.
The formatting lives in one place so the chip, the template and the resolver
cannot drift by a character — coordinates in degrees or decimals, the date
in the reader's language.

The icons are the whole Lucide set, searchable, placed as a real element
with its own colour and stroke rather than as a character in a font. The
stroke is deliberately not called strokeWidth: three separate places rescale
a field of that name by page width, and an icon whose line thins as the book
grows is not what anyone asked for.

And the badges finally have their settings. A mood or weather badge could be
placed and then only moved: the icon's colour was fixed, the icon could not
be turned off, and there was no way to keep the icon and drop the words. All
three are switches now, with the colour following the badge unless you say
otherwise.

* fix(studio): let a placed entry field follow its journal entry again

The inspector says a bound field follows the journal entry and that editing
it here breaks the link. The first half was not true: nothing ever re-read
the entry, so renaming a title in Journey left the old words on the page for
good, and the same went for every other bound field.

The resolver runs where the journey is loaded and re-reads the fields that
still point at an entry. What it will not do is as important as what it
does. A field somebody has typed over stays typed over, an entry that is
gone leaves its element alone rather than blanking it, and an entry that
answers with nothing never wipes what is already on the page.

Formatted fields — the date, the coordinates — are only re-read when the
format itself changed, which is what keeps this from quietly rewriting
books that already exist: an entry whose date was placed as a heading and
then set in capitals keeps its capitals. Checked against the real
database before it shipped, where the first version overwrote three of them.

* feat(studio): start a new book empty, with six layouts to choose from

A new journey opened onto a book that had already been laid out, which is
the wrong first move: it decides the shape of somebody's book before they
have seen it, and undoing it means deleting pages one at a time. A new book
starts empty now, and the auto layout runs when it is asked to.

The layouts themselves went from two to six, so choosing one is a real
choice: what was there covered a photo-heavy spread and a text-heavy one and
nothing in between.

* fix(studio): hyphenate the export the way the editor does, and set the bleed

Two things the press sees and the editor did not.

A paragraph broke in different places in the PDF than on screen. The print
document is written from scratch and never carried a language, so the
browser hyphenated the editor's German and the export's nothing-in-
particular. It takes the page's own language now. The wait for the sheet to
be ready also covers the images inside SVG, with a ceiling, so a map that is
slow to draw does not print half-finished.

And the bleed and the safe margin are typeable. Both were baked into every
page preset at what a photo-book vendor usually asks for, 3mm and 5mm, with
no control anywhere. That is fine until a printer asks for 5 and 10, at
which point the book cannot be made here at all: the export carries the wrong
bleed and the crop marks sit in the wrong place, which nobody finds until
the guillotine has run.

* feat(studio): rebuild the inspector

The panel on the right had grown by accretion: every element type had added
its own controls in whatever shape suited it that day, so the same idea —
a colour, a size, a toggle — looked different depending on what you had
selected, and long panels were a wall of unlabelled fields.

It is one set of controls now, used by every element: foldable sections, a
line that holds a label and its control, radio choices, checkboxes, the
app's own toggle, and a number field that holds a draft so typing "500" is
not read as "5", clamped and written back under the cursor. Duplicated
controls were merged rather than deleted; nothing an element could do before
it cannot do now.

The head carries the element's identity and its actions, so what you have
selected is readable without counting fields, and the sections have room to
breathe: what was there was correct and unreadable.

* feat(studio): a route map worth printing

The map in a book was a straight grey line between grey dots on a tile that
was chosen for a screen. At print size those tiles are read at roughly four
times the resolution they were fetched for, which is why it looked soft next
to everything around it.

The imagery comes from sources that hold up on paper: NASA's shaded relief
and EOX's cloudless Sentinel-2, both licensed for it, picked at a zoom that
matches the printer rather than the screen. Tiles are fetched at twice the
density where the source offers it, and the seam between them scales with
the tile instead of sitting at a fixed fraction of a millimetre, which under
editor zoom fell below one device pixel and showed as white threads.

The route is drawn rather than plotted. A leg that was never recorded bows,
by an amount that grows with its length and turns away from the pole, and it
dashes, because an inferred line should not claim to be a track — a recorded
track never bows. Both strokes are laid down in two passes, casing then core,
so the line stays legible over dark sea and bright desert alike. Legs can
follow roads instead, routed leg by leg with the geometry thinned before it
is stored, which is the setting a road trip wants and the one a flight
cannot have.

Stops carry their own first photograph, cut to a circle by a stencil that
exists — the first one was written into a defs block that only appears on a
map clipped to a coastline, so on an ordinary map it clipped nothing and the
photographs sat square on the page. A stop with no photograph of its own is
numbered rather than handed one from elsewhere in the journey: a marker at
one place showing a picture taken at another is a caption that lies.

Whatever the frame, the whole route is inside it. The window takes the
frame's aspect, so the fit and the imagery agree instead of one containing
what the other covers, and a map dragged narrow no longer cuts off its end.
Each linked trip can carry its own map, and a map notices a photograph added
to a stop afterwards rather than having to be placed again.

* i18n(journey): the new Studio strings, in all 23 locales

* test(journey): pin the coverage the map work brought with it

The journey domain measures higher than its floor after this round, so the
floor follows it up. Only journey moved; the generator's suggestion would
have taken memories' functions down a point, and a ratchet that can fall is
not a ratchet.

* test(studio): cover the thinning that keeps a road route saveable

The one path in roadRoute nothing exercised, and the one that matters most:
a router answers a long drive with thousands of points at a precision no
press can resolve, and all of it goes into the document. Keeping a leg whole
is what pushed a real book past the body limit.

Both ends have to survive so the line still meets its stops, and the spacing
has to stay even, because cutting a run out of the middle would straighten
exactly the bends somebody wanted to see.
2026-08-20 15:43:27 +02:00

301 lines
13 KiB
TypeScript

/**
* Handing the book to the printer.
*
* ── Why the browser prints it ────────────────────────────────────────────
*
* The alternative was a renderer on the server — headless Chromium, or a PDF
* library drawing the pages a second time. Both were rejected for the same
* reason: TREK is self-hosted, and neither Chromium in the image nor a parallel
* renderer to proofread against is a cost worth carrying when the browser
* already produces exactly what the editor shows. It is also how TREK's other
* exports work, so this is the house style rather than a new one.
*
* What comes out is a real PDF: vector text, embedded fonts, images at full
* resolution. What does not come out is PDF metadata and the TrimBox/BleedBox
* entries — a browser sets neither. Which is why the sheets carry crop marks:
* a press reads where to cut from the marks, and has done for rather longer
* than the PDF format has existed.
*
* ── Why an iframe rather than a window ───────────────────────────────────
*
* Same reason as the Journey export: `window.open` is blocked by Safari on iOS
* inside an async callback, and `window.close` does not work reliably in a
* standalone PWA. An overlay with a `srcdoc` iframe works everywhere.
*/
/*
* The only literal colours in Studio, and deliberately so.
*
* The print view is a document of its own — a `srcdoc` iframe that inherits
* none of the app's tokens — and paper is white whatever theme the editor is
* in. Naming them here keeps the stylesheets below readable and puts the
* exception in one place instead of on every rule.
*/
const PAPER = '#ffffff' // theme-lint-disable — paper, not app chrome
const WORKTOP = '#52525b' // theme-lint-disable — the grey the sheets sit on
const BAR = '#0f172a' // theme-lint-disable — the print view's own header
const BAR_LINE = '#e4e4e7' // theme-lint-disable — and the line under it
/** Everything the print document needs, gathered before the iframe exists. */
export interface PrintSheetsInput {
/** The rendered sheets, as HTML. */
html: string
/** Sheet size in millimetres, bleed and crop-mark room included. */
sheetWidth: number
sheetHeight: number
/**
* The size of a one-page sheet, when the document also holds two-page ones.
*
* Spread mode mixes them: the covers are single leaves and everything between
* is a spread. Omitted when every sheet is the same size, which is the whole
* of page mode.
*/
singleWidth?: number
singleHeight?: number
title: string
/** Chrome for the overlay itself. */
labels: { save: string; close: string; count: string; preparing: string }
}
/**
* Every stylesheet the app is currently using, as markup for the iframe head.
*
* Copied rather than re-listed. The sheets are the editor's own components, so
* they need the editor's own CSS — including the `@font-face` rules for the
* seven bundled families, which is the difference between a book that prints in
* the typeface it was proofread in and one that prints in a substitute.
*
* In a build that is one `<link>`; under Vite it is a pile of inline `<style>`
* tags. Both are handled by taking the DOM as it is rather than guessing.
*/
function collectStyles(): string {
const out: string[] = []
for (const node of Array.from(document.querySelectorAll('style, link[rel="stylesheet"]'))) {
if (node.tagName === 'LINK') {
const href = (node as HTMLLinkElement).href
if (href) out.push(`<link rel="stylesheet" href="${escapeAttr(href)}">`)
} else {
out.push(`<style>${node.textContent ?? ''}</style>`)
}
}
return out.join('\n')
}
function escapeAttr(value: string): string {
return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
}
/**
* A page box for the single-page sheets, when the document mixes widths.
*
* A named page rule, which is how CSS says "these elements print on a different
* sheet size". The alternative was making every sheet as wide as the widest,
* and that laid a cover in the middle of a spread-sized page with white either
* side — which is not a cover, it is a cover with a mistake around it.
*/
function singleRule(input: PrintSheetsInput): string {
if (!input.singleWidth || !input.singleHeight) return ''
if (input.singleWidth === input.sheetWidth && input.singleHeight === input.sheetHeight) return ''
return ` @page single { size: ${input.singleWidth}mm ${input.singleHeight}mm; margin: 0; }
.bx-sheet.is-single { page: single; }`
}
function escapeText(value: string): string {
return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
/**
* Open the print view.
*
* Resolves once the document is on screen and ready to print — which is *after*
* its images and fonts have loaded, not merely after the markup is in place.
* Printing early is how a book comes out with blank rectangles where the
* photographs were.
*/
export function printSheets(input: PrintSheetsInput): () => void {
/*
* The document's language, carried over from the editor.
*
* Not decoration: `hyphens: auto` does nothing at all until the browser knows
* which language's rules to break the words by, and it reads that from the
* nearest `lang`. Without it a heading the editor had set as "Lorem ip-sum"
* came out unhyphenated in the export, ran past its own frame and was clipped
* — the one place where what you proofread and what you print disagreed.
* TranslationContext sets this on the app, so taking it from there is what
* makes the two agree by construction rather than by a second setting.
*/
const lang = document.documentElement.lang || 'en'
const doc = `<!doctype html>
<html lang="${escapeAttr(lang)}">
<head>
<meta charset="utf-8">
<title>${escapeText(input.title)}</title>
<base href="${escapeAttr(window.location.origin)}/">
${collectStyles()}
<style>
/*
* One page rule for every sheet — see BookSheetsView on why they are all the
* same size. No margin: the sheet already carries its own bleed and marks,
* and a printer margin on top would scale the whole thing down to fit.
*/
@page { size: ${input.sheetWidth}mm ${input.sheetHeight}mm; margin: 0; }
${singleRule(input)}
/*
* On screen the sheets are scaled to fit the window; on paper they are not.
*
* A spread sheet is over 400 mm across, which at 1:1 is wider than any
* preview pane — the book ran off the side and had to be scrolled sideways to
* read it. The --bx-fit factor is set from outside the frame (nothing runs
* inside it) and only ever shrinks, so a small book is still shown at its
* real size. zoom rather than transform: it scales the layout rather than
* painting over it, so the page does not reserve the unscaled height.
*
* (No backticks in here — this whole stylesheet is a template literal.)
*/
@media screen { .bx-book { zoom: var(--bx-fit, 1); } }
html, body { margin: 0; padding: 0; background: ${WORKTOP}; }
.bx-book { display: flex; flex-direction: column; align-items: center; gap: 16px; padding: 16px 0; }
.bx-sheet { box-shadow: 0 2px 12px rgba(0,0,0,0.35); }
@media print {
html, body { background: ${PAPER}; }
.bx-book { display: block; gap: 0; padding: 0; }
.bx-sheet { box-shadow: none; }
/* Photographs are the point. Without this the browser drops backgrounds
and colour-managed fills to save toner, which is right for a web page
and wrong for a photo book. */
* { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
}
</style>
</head>
<body>${input.html}</body>
</html>`
const overlay = document.createElement('div')
overlay.id = 'bx-overlay'
overlay.style.cssText =
'position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:9999;display:flex;align-items:center;justify-content:center;padding:8px;'
const style = document.createElement('style')
style.textContent = `
#bx-overlay .bx-card { width:100%;max-width:1100px;height:95vh;background:${PAPER};border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.35); }
#bx-overlay .bx-head { display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 16px;border-bottom:1px solid ${BAR_LINE};flex-shrink:0;background:${BAR}; }
#bx-overlay .bx-name { min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;color:rgba(255,255,255,0.45);font-weight:500;letter-spacing:0.03em; }
#bx-overlay .bx-actions { display:flex;align-items:center;gap:8px;flex:none; }
#bx-overlay .bx-btn { min-height:44px;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;white-space:nowrap; }
#bx-overlay .bx-btn[disabled] { opacity:0.5;cursor:default; }
#bx-overlay .bx-save { border:none;background:${PAPER};color:${BAR}; }
#bx-overlay .bx-close { border:1px solid rgba(255,255,255,0.15);background:rgba(255,255,255,0.1);color:rgba(255,255,255,0.7); }
@media (max-width: 640px) {
#bx-overlay { padding:0; }
#bx-overlay .bx-card { height:100dvh;max-width:none;border-radius:0; }
#bx-overlay .bx-name { display:none; }
#bx-overlay .bx-actions { flex:1; }
#bx-overlay .bx-btn { flex:1;padding:10px 12px;font-size:13px; }
}
`
const card = document.createElement('div')
card.className = 'bx-card'
const header = document.createElement('div')
header.className = 'bx-head'
header.innerHTML = `
<span class="bx-name">${escapeText(input.title)} &middot; ${escapeText(input.labels.count)}</span>
<div class="bx-actions">
<button id="bx-save" class="bx-btn bx-save" disabled>${escapeText(input.labels.preparing)}</button>
<button id="bx-close" class="bx-btn bx-close">${escapeText(input.labels.close)}</button>
</div>
`
const iframe = document.createElement('iframe')
iframe.style.cssText = `flex:1;width:100%;border:none;background:${WORKTOP};`
// Nothing inside the document runs — printing is triggered from here through
// contentWindow.print() — so allow-scripts is deliberately withheld.
iframe.setAttribute('sandbox', 'allow-same-origin allow-modals')
iframe.srcdoc = doc
card.appendChild(header)
card.appendChild(iframe)
overlay.appendChild(style)
overlay.appendChild(card)
document.body.appendChild(overlay)
const close = () => { stopFitting(); overlay.remove() }
overlay.onclick = e => { if (e.target === overlay) close() }
header.querySelector<HTMLButtonElement>('#bx-close')!.onclick = close
const save = header.querySelector<HTMLButtonElement>('#bx-save')!
save.onclick = () => { iframe.contentWindow?.print() }
/** CSS millimetres are defined against 96dpi, so this ratio is exact. */
const PX_PER_MM = 96 / 25.4
/** Shrink the preview until a sheet fits across the pane. Never enlarge. */
const fitPreview = () => {
const root = iframe.contentDocument?.documentElement
if (!root) return
const available = iframe.clientWidth - 32
if (available <= 0) return
const scale = Math.min(1, available / (input.sheetWidth * PX_PER_MM))
root.style.setProperty('--bx-fit', String(Math.round(scale * 1000) / 1000))
}
window.addEventListener('resize', fitPreview)
const stopFitting = () => window.removeEventListener('resize', fitPreview)
iframe.addEventListener('load', () => {
fitPreview()
void whenReady(iframe).then(() => {
save.disabled = false
save.textContent = input.labels.save
})
})
return close
}
/**
* When the document is actually printable.
*
* Both halves matter and neither is covered by the load event: a photograph
* still decoding prints as an empty box, and a face still loading prints in the
* fallback — the exact substitution the bundled typefaces exist to prevent.
*/
async function whenReady(iframe: HTMLIFrameElement): Promise<void> {
const win = iframe.contentWindow
const doc = iframe.contentDocument
if (!win || !doc) return
const settle = (el: Element) => new Promise<void>(resolve => {
// A picture that will not load must not hold the button hostage — better a
// book with one gap than a dialog that never finishes.
el.addEventListener('load', () => resolve(), { once: true })
el.addEventListener('error', () => resolve(), { once: true })
})
const images = Array.from(doc.images).map(img => (img.complete ? Promise.resolve() : settle(img)))
/*
* The photographs inside the map's markers are SVG `<image>`, which
* `doc.images` does not collect and which has no `complete` to ask, so there
* is nothing to do but wait on the events. Missing them meant a book could go
* to print with its route beads still blank.
*/
const svgImages = Array.from(doc.querySelectorAll?.('image') ?? []).map(settle)
/*
* And a ceiling over the lot. Without `complete` to fall back on there is no
* way to tell a picture that is still coming from one whose events already
* fired before this ran, and a Save button that never enables is worse than a
* marker that prints blank.
*/
const wait = win.setTimeout?.bind(win) ?? setTimeout
const ceiling = new Promise<void>(resolve => { wait(resolve, 8000) })
await Promise.race([
Promise.all([...images, ...svgImages, doc.fonts?.ready ?? Promise.resolve()]),
ceiling,
])
}