/**
* 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 ``; under Vite it is a pile of inline ``)
}
}
return out.join('\n')
}
function escapeAttr(value: string): string {
return value.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>')
}
/**
* 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 = `
`
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('#bx-close')!.onclick = close
const save = header.querySelector('#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 {
const win = iframe.contentWindow
const doc = iframe.contentDocument
if (!win || !doc) return
const settle = (el: Element) => new Promise(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 ``, 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(resolve => { wait(resolve, 8000) })
await Promise.race([
Promise.all([...images, ...svgImages, doc.fonts?.ready ?? Promise.resolve()]),
ceiling,
])
}