Cover and Print
Verified 2026-09-06 by a real run: a new user, a template book with three written chapters, three photographs uploaded (1.5MB, 5.6MB, 8.6MB at 4600×3000), two placed on the front with different frames and filters, a back blurb written from the book’s own opening lines, then both print-ready files exported, downloaded and measured with pdf-lib — cover 12.6575 × 9.250in on one page, interior 163 pages all at 6.25 × 9.25in — and the cover page rasterised and compared to the editor’s own preview. Five defects were found by that run and fixed in it; they are named below.
What the editor does
On a desktop (/books/[id]/cover) you work on the whole printed sheet: back, spine and front side by side, with the bleed shaded, the safe area dashed, fold lines at the spine, rulers in inches, and a readout that says what the spine measures for this book. A “Front only” view zooms to the front alone.
Seven labelled tabs: Templates (12 designs, each thumbnail the real renderer filled with your own title), Text, Pictures, Colours, Layout, Layers, Print. The right-hand inspector is always on screen and always describes the one thing selected.
What you can do to a layer: drag, scale from a corner, crop and pan inside a photograph (Crop mode, or hold Alt and drag), choose a frame (square, rounded, circle, arch), apply a filter (warm, black & white, sepia, faded), set opacity, reorder, lock, hide, align to the page or the safe area, and space several evenly. Text carries all caps, an outline, a soft shadow, letter spacing, line spacing and vertical placement. Four ornaments (rule, flourish, frame corners, monogram) are drawn as inline SVG.
Keyboard: Cmd/Ctrl+Z undo, Cmd/Ctrl+Shift+Z redo, D duplicate, arrows nudge, Shift+arrows nudge ten times as far, Delete removes, Esc deselects.
On a phone the front cover is a picture you tap to edit, with the same document, the same operations and the same renderer. The back and the spine are desktop-only and the phone says so in one plain sentence — a 0.4in spine on a 390px screen is not an editing surface.
The print spec
Every number lives in lib/books/print-spec.ts and nowhere else.
| Trim | 6 Ă— 9in (default), 5.5 Ă— 8.5in, 8.5 Ă— 11in |
| Bleed | 0.125in, on the four outside edges only — there is no bleed at a fold |
| Safe area | 0.25in inside the trim |
| Paper | White 0.002252in per page, cream 0.0025in per page |
| Spine | pages Ă— per-page thickness |
| Spine text | Hidden under 80 pages — printers reject type on a spine that thin |
| Barcode reserve | 2 Ă— 1.2in, bottom-right of the back, 0.25in in from the trim (a toggle) |
| Sheet | (2 Ă— trim width + spine + 2 Ă— bleed) Ă— (trim height + 2 Ă— bleed) |
So a 163-page 6 Ă— 9 book on cream has a 0.41in spine and prints on a sheet 12.6575 Ă— 9.250in. That is the size the exported file reports, exactly: Chromium lays a page out in whole CSS pixels and rounds 12.6575in to 12.6608in, so the page box is set back to the exact figure with pdf-lib afterwards.
Page count. The spine is only as right as the page count. The editor prefers, in order: a number you typed (which is then never overwritten), the real count from the last interior you exported, and an estimate from your finished chapters at 220 words to the page. That 220 is measured, not derived — a 34,532-word book exported at 163 pages, and the arithmetic-from-geometry figure of 250 was 9% out.
The document (schema v3) and the parity rule
books.cover_design_data holds one JSON document:
{ version: 3, trim, templateId?,
print: { paper, pageCount, barcode, pageCountLocked? },
surfaces: { front, spine, back } }Each surface is { background, layers }. Positions and sizes are fractions of the surface, type is in points at the trim, and the spine is authored lying down — its box is the trim’s height by the spine’s width — then stood up with one rotation, which is why spine text needs no rotation of its own and reads top to bottom on a shelf.
The parity rule: the CSS for every layer is computed once, in lib/books/cover-schema.ts, and both components/cover/CoverSurface.tsx (the preview) and lib/pdf/cover-design.ts (the print) use it verbatim. There is no second implementation to keep in step. __tests__/components/cover/templates.test.tsx asserts this over all 12 templates and over one document that uses every property at once; a new property is not finished until it is asserted there.
v1 and v2 documents migrate on load (parseCoverDoc) and nothing writes an older version. A v2 document’s layers become surfaces.front untouched, with a back and spine built from the book’s own title.
The picture library
The Pictures tab is every image you have, from GET /api/images — a general library, not a cover-only one. Multi-file upload with progress, drag onto the canvas or press to place, delete, and a “Used on” tag where it is cheap to compute. Each row stores kind (photo | art | document) and the original pixel size, which is what lets the editor warn when a photograph is under 300dpi at the size you have placed it.
Pictures are served through /api/cover/photo/[imageId], never a Backblaze URL: the bucket is private and its own links answer 401.
Limits, honestly. An upload takes the best road available, and each fallback costs something:
- Straight to Backblaze from the browser — no body cap, every pixel kept. Needs a CORS rule on the bucket, and the bucket currently has none, so this path fails its preflight today.
/api/images/upload— through the server as a raw body, no re-encoding. Capped by the body the host accepts: about 10MB locally, about 4.5MB on Vercel./api/upload/backblaze— the old route, which downscales anything large to 1600px. About 260dpi across a 6in cover when a printer wants 300.
So the practical ceiling today is about 10MB, and a bigger photograph is refused with a plain message rather than stored broken. Until (1) works, that ceiling stands.
The two exports
POST /api/books/[id]/export
{ kind: 'interior' }— the book, unchanged.{ bleed: true }adds 0.125in to every edge, growing the margins to match so the text block does not move.{ kind: 'cover' }— one page, the full wrap, bleed included, fonts embedded, optional crop marks and an optional spec line printed in the bleed (both off by default, because KDP rejects covers carrying marks).
Photographs are fetched by the export’s headless Chromium using a short-lived signed token, and capped at 600dpi of the size they are actually placed — twice what any press resolves. That cap is not only thrift: a 4600px photograph with a CSS filter was more than Chromium’s print path would finish decoding, and it printed with its bottom fifth missing.
Sending them to a printer. KDP, Lulu and IngramSpark all want exactly these two files, separately, and all reject a cover that is the wrong size for its page count.
| Trim | Bleed | Cover | |
|---|---|---|---|
| Amazon KDP | 6 Ă— 9in among others | 0.125in outside edges | One PDF, back+spine+front, spine from their calculator |
| Lulu | 6 Ă— 9in | 0.125in | One-piece wrap; they publish a per-book template |
| IngramSpark | 6 Ă— 9in | 0.125in | One-piece wrap; they ask for a 0.0625in safety on the spine |
Make the inside pages first. The spine is page count times paper thickness, so a cover built before the interior exists is a cover sized from a guess.
What is not done
- The bucket has no CORS rule, so browser-direct upload cannot work and photographs over about 10MB cannot be uploaded at all. This is a bucket setting, not code.
- No CMYK and no ICC profile. Everything is sRGB. Print-on-demand houses accept sRGB and convert; a traditional press would want CMYK.
- No PDF/X-1a for the cover. The interior has a
pdfxoutput type; the cover does not. - The barcode area is reserved, not drawn. A white rectangle keeps it clear; the printer prints their own.
- No rotation handle on the canvas. The schema stores rotation and both renderers honour it; nothing in the UI sets it except the spine.
- Ornament colours do not follow the contrast rule the way text does; they take the palette accent and stay there.
- Crop is pan and zoom, not a free rectangle. It is
object-positionand a scale, which is what makes the preview and the print identical without re-encoding anything.