Files
TREK/client/src/components/Studio/bookSheets.ts
T
Maurice 87c73e50fb feat(studio): give a book its first and its last page (#2317)
A new book was a cover, a spread and a back cover, so it opened straight
onto pages 2 and 3. A bound book does not: the inside of the cover is
blank, and the first thing facing the reader is a single right-hand page,
page 1. The same at the end, where a single left-hand page sits before the
back cover. Both are laid down now, empty, in a new book and in a laid-out
one, and they are fixed the way the covers are: an inner spread only ever
goes between them.

The page numbers follow. They were counted from a spread's position in the
array, which assumed the spread after the cover was the first, and three
places did that sum on their own. One function now counts along the book,
one page for a single leaf and two for a spread, so the sheet on screen and
the label in the print view and the folio on the page all say the same
number. A book with a first page starts at 1; a book from before the first
page existed has none and keeps the numbers it always had.
2026-09-11 11:01:15 +02:00

183 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { BookDocument, BookSpread } from '@trek/shared'
/**
* Cutting a book into printable sheets.
*
* ── Why a sheet is not a spread ──────────────────────────────────────────
*
* The document is made of spreads, because that is the unit a person designs
* in: a picture crossing the gutter is one decision, not two. A printer takes
* pages — single leaves, each with its own bleed on all four sides, in reading
* order. So the export cuts every inner spread down the middle and hands over
* the halves.
*
* The cut is a window, not a re-layout. Each sheet shows the same spread
* through an opening the size of one page, shifted by a page width for the
* right-hand leaf, which is why a photograph that crosses the gutter comes out
* of the press aligned: both halves were positioned by the same numbers.
*
* ── Why the spread mode exists anyway ────────────────────────────────────
*
* For reading, not for printing. A book PDF that opens two pages at a time is
* what you send someone to look at, and cutting it into leaves for that would
* make every photograph across the gutter look broken.
*
* ── Millimetres, all of it ───────────────────────────────────────────────
*
* Every number here is millimetres, matching the document. The renderer maps mm
* onto the PDF at a fixed ratio, so these are the sizes the press measures.
*/
/** How far a crop mark reaches out past the bleed. */
export const MARK_LENGTH = 4
/** How thick it is drawn — hairline, the way a mark should be. */
export const MARK_WEIGHT = 0.2
export type SheetMode = 'pages' | 'spreads'
export interface Sheet {
/** The spread this leaf is cut from. */
spread: BookSpread
/** Its index in the document, which is what the folios count from. */
spreadIndex: number
/**
* How far into the spread the window sits, in millimetres.
*
* 0 for a left-hand leaf, one page width for a right-hand one. It is a shift
* of the *view*, never of the contents.
*/
offset: number
/** Trim width of this leaf. */
width: number
/** Trim height, which never varies. */
height: number
/** Width of the whole spread behind the window, for positioning it. */
spreadWidth: number
/**
* Whether this leaf is one page rather than two.
*
* Covers are, in both modes. It matters to the print CSS: a document that
* mixes widths needs a second `@page` rule for the narrow sheets, or they get
* laid onto the wide page box with white either side.
*/
single: boolean
/** A label for the print view — "3", or "2 – 3" for a spread. */
label: string
}
/** How big one printed sheet is, bleed and crop marks included. */
export interface SheetBox {
/** Sheet width including bleed and any room for marks. */
width: number
height: number
/** Distance from the sheet edge to the trim edge. */
margin: number
/** The bleed itself, which is part of that margin. */
bleed: number
}
export function sheetBox(
trimWidth: number,
trimHeight: number,
bleed: number,
marks: boolean,
): SheetBox {
/*
* Marks are drawn outside the bleed, not inside it. Putting them in the bleed
* would mean printing them onto the part of the sheet that gets cut off —
* they would be trimmed away along with the thing they were marking.
*/
const margin = marks ? bleed + MARK_LENGTH : bleed
return {
width: trimWidth + margin * 2,
height: trimHeight + margin * 2,
margin,
bleed,
}
}
/**
* A book, as leaves.
*
* Covers stay whole in both modes: a cover is one page, and half a cover is
* not a thing anybody prints.
*/
export function sheetsFor(doc: BookDocument, mode: SheetMode): Sheet[] {
const { pageWidth, pageHeight, pageNumbers } = doc.page
const out: Sheet[] = []
doc.spreads.forEach((spread, spreadIndex) => {
const single = spread.role !== 'inner'
const spreadWidth = single ? pageWidth : pageWidth * 2
const folios = foliosOf(doc.spreads, spreadIndex, pageNumbers.startAt)
if (single || mode === 'spreads') {
out.push({
spread,
spreadIndex,
offset: 0,
width: spreadWidth,
height: pageHeight,
spreadWidth,
single,
label: folioLabel(folios),
})
return;
}
for (const half of [0, 1]) {
out.push({
spread,
spreadIndex,
offset: half * pageWidth,
width: pageWidth,
height: pageHeight,
spreadWidth,
single: true,
label: String(folios[half]),
})
}
})
return out
}
/** Which of a book's leaves carry a page number: everything inside the covers. */
export function isNumbered(role: BookSpread['role']): boolean {
return role !== 'cover' && role !== 'back'
}
/**
* The page numbers a spread carries, in reading order.
*
* Counted along the book rather than from the index: the covers carry none,
* a first or last page carries one, an inner spread two, and the first
* numbered page is `startAt`. A book from before the single first page
* existed has `startAt` 2 and no such page, so its first spread still reads
* 2 – 3; a book laid out since has a first page and starts at 1, so that page
* is 1 and the same spread behind it is 2 – 3 (#2317). Either way the number
* follows what is in the book, not where the spread sits in the array.
*
* The renderer and the print labels both come through here, so a number seen
* on screen is the one that is printed.
*
* Empty for a cover, and for an index past the end.
*/
export function foliosOf(spreads: readonly BookSpread[], spreadIndex: number, startAt: number): number[] {
const spread = spreads[spreadIndex]
if (!spread || !isNumbered(spread.role)) return []
let next = startAt
for (let i = 0; i < spreadIndex; i++) {
const role = spreads[i].role
if (!isNumbered(role)) continue
next += role === 'inner' ? 2 : 1
}
return spread.role === 'inner' ? [next, next + 1] : [next]
}
/** "3", or "2 – 3" for a spread; nothing for a cover. */
export function folioLabel(folios: readonly number[]): string {
return folios.length === 2 ? `${folios[0]} – ${folios[1]}` : folios.length === 1 ? String(folios[0]) : ''
}