Skip to content

PDF Generator

ProxyPrints Docs Bot edited this page Jul 29, 2026 · 12 revisions

PDF generator

Upstream PR #367 (DriveThruCards PDF export). Four real bugs were found and fixed here — the first three verified deployed and working end-to-end (Playwright, real headed Chromium + Firefox, real backend + image-cdn); the fourth (silent blank-card data loss) shipped 2026-07-17, verified via a mocked-CDN Playwright suite in a sandbox with no live-backend access — see its own entry below for what that leaves unverified.

Bugs found and fixed

  1. Phantom PDF download on opening the editor, before ever touching the PDF tab. @react-pdf/renderer eagerly instantiates a Yoga WASM binary at import time, not render time, and PDFGenerator was statically imported. Fixed via next/dynamic({ssr: false}) + mountOnEnter on the Tab.Panes that mount it (PDFGeneratorModal.tsx, FinishedMyProject.tsx, ProjectEditor.tsx).

  2. Live preview auto-downloading in Firefox / eating too much space in Chrome. The native <iframe>/<object> embed either triggers Firefox's "download instead of render" behavior for blob PDFs, or (once switched to <object>) pulls in the browser's own PDF viewer chrome (toolbar, thumbnail sidebar) that isn't controllable. Replaced entirely with a pdf.js canvas renderer (PDFCanvasPreview.tsx) — zero chrome, works identically in every browser. pdfjs-dist's worker script can't be resolved by Next's webpack via the normal import.meta.url pattern, so it's copied into public/ as a static asset by a postinstall script (frontend/scripts/copy-pdf-worker.js) — gitignored, regenerated on every npm install, always matches the installed pdfjs-dist version.

  3. PDF card images never rendered at all (both preview and the actual download — only cut lines showed), because pdfImage.ts routed thumbnail-quality previews through getBucketImageURL, and no image CDN was configured for this fork at all. See Image-CDN.

  4. Silent blank-card data loss on a failed image fetch, both in the live preview and the actual download (frontend-polish package item 1/5, 2026-07-17). @react-pdf/renderer's <Image> fetches its src URL internally and silently skips a card it can't fetch rather than failing the render — a real production risk, since a user could send a print-ready file to MakePlayingCards/PringlePrints/NotMPC with blank cards and only find out after physical printing.

    Fixed by having pdfImage.ts fetch the image itself instead of handing <Image> a bare remote URL to fetch blind (fetchAsObjectURL: a GET request, an ok check, then a blob: object URL — the same pattern the LocalFile source type already used). A genuine failure now rejects instead of resolving to a URL that might fail silently later. PDFCardImage and SCMCard catch that rejection and call reportImageFailure, a callback threaded through PDFProps and SCMPDFProps that pdf.worker.ts supplies per render call (not something any caller of the public render hooks passes in itself). renderPDF/renderPDFInWorker now return that render's failures alongside the blob, as RenderPDFResult.failures.

    The live preview in PDFGenerator.tsx shows a warning Alert naming the failed cards (test id pdf-preview-image-failures) whenever any failures came back, and a separate danger Alert (test id pdf-preview-error) when the render itself throws — that's useRenderPDF's pre-existing error value, which used to be computed and then never actually rendered anywhere. The download and Save-to-Drive paths (downloadPDF/saveToDrivePDF) block behind a window.confirm naming the failed cards before calling downloadFile/uploading, the same pattern already used for the irreversible-action confirms in DrivesPanel.tsx. Cancelling that confirm dispatches a "Download Cancelled"/"Save Cancelled" toast and returns without writing anything.

Thumbnail + full-resolution image fetching (bucket → worker fallback)

See Image-CDN's "What it does" section for the R2 bucket/Worker split. pdfImage.ts tries the R2 bucket first for small/large tiers, falling back to the Worker on a miss; full-resolution always goes through the Worker, matching upstream. As of the bug-4 fix above, both legs are a real GET + response.ok check (not a HEAD probe) — the fetched body becomes a blob: object URL handed to <Image>, so a fetch failure on either domain is something calling code can actually observe and report, rather than being resolved into an unvalidated URL for <Image> to fail on silently later. This also means the "cheap check without a body" rationale a HEAD request had is gone — the body was going to be fetched by <Image> anyway on a hit, so fetching it once ourselves is a real efficiency win, not just a correctness one.

Full-resolution fetches are paced + retrying (mass export-image-failure incident)

A large real export (~104 cards) failed almost every full-resolution image fetch, all reporting as blank in the confirm dialog. Root cause: the image-CDN Worker's full-resolution tier shares ONE global 3-req/s rate limiter across every caller (see Image-CDN's "What it does" section), enforced server-side with its own internal retry/backoff - but nothing on the CLIENT paced how many concurrent full-resolution fetches it fired at once. @react-pdf/renderer's own internal scheduler resolves every card's <Image src={async () => ...}> callback with its own concurrency, entirely outside this codebase's control - a large export could trigger dozens of simultaneous fetches, each independently exhausting its own server-side retry budget under that contention and coming back as a permanent per-card failure.

Fix (pdfImage.ts's fetchFullResolutionImageAsBlob, used by both getPDFImageURL's and getPDFImageBlob's full-resolution branches - the risk applies to any full-resolution export, not just Proposal B's bleed-normalized cards):

  • A shared Semaphore (common/semaphore.ts, new - a plain acquire/release concurrency gate for gating an unbounded stream of ad-hoc calls from a scheduler this codebase doesn't control, distinct from concurrencyLimit.ts's mapWithConcurrencyLimit, which needs a known, finite item list) caps client-side full-resolution fetches to FULL_RESOLUTION_FETCH_CONCURRENCY = 3, matching the server's own limit.
  • Retries a 429 or 5xx (transient) up to FULL_RESOLUTION_FETCH_MAX_RETRIES = 3 times with exponential backoff + jitter - a non-retryable 4xx (a real dead link) still fails on the first attempt, so a genuinely broken image doesn't burn retry budget that delays every other card queued behind the concurrency gate.
  • Live progress: a large export paced to 3 req/s can now take several minutes (honestly reported, not hidden) - PDFProps.reportImageProgress (mirroring the existing reportImageFailure pattern, threaded through pdf.worker.ts → comlink's onImageProgresspdfRenderServicePDFGenerator.tsx) drives progress feedback so the wait reads as working, not hung. total is an approximation (unique card count, not slot count - a duplicate card in the deck fetches once per slot, so completed can end up slightly ahead of it), intentionally not presented as an exact fraction for that reason. See "PDF-generation wait experience" below for the current UI this drives (a real ProgressBar, not the bare text line this originally shipped as).
  • In-app confirm modal, not window.confirm(): the incident's own screenshot showed Firefox's "allow notifications?" anti-spam chrome sitting next to the native confirm dialog - a browser can silently start auto-suppressing FUTURE window.confirm() calls on an origin once enough of them fire near other browser-level prompts, which would turn this safeguard off with no visible warning. ImageFailureConfirmModal (a real React-rendered Bootstrap Modal, PDFGenerator.tsx) can't be affected by that heuristic at all.

"HEAD request fails" console noise — historical, no longer applies

A cross-session report once flagged failed HEAD requests to img.proxyprints.ca/<id>-small-google_drive when opening the PDF tab live, traced to an R2 custom-domain quirk (net::ERR_FAILED on a HEAD-on-missing- object, not a clean 404) that the existing bucket→worker fallback already absorbed harmlessly. The bug-4 fix above replaced the HEAD check with a real GET, so this specific console-noise pattern no longer occurs — kept here as a historical note in case an old bug report referencing it resurfaces.

Proposal B — export-time per-side bleed normalization

Full spec + approval record: docs/proposals/proposal-b-bleed-normalization.md. Core algorithm (bleedNormalize.ts: probe-median measurement per side, IQR ambiguity, fallback + manual-override plan resolution) and canvas synthesis (bleedExtension.ts: pure crop/extend geometry + normalizeCardBleed's decode→measure→plan→draw→encode→release pipeline) are built and unit tested (12 tests across the two modules, plus 4 new pdfImage.test.ts tests for the getPDFImageBlob split). Wired into PDF.tsx's PDFCardImage: full-resolution Google Drive/local-file images run through normalization instead of the old uniform proportional rescale; SCM mode and the thumbnail tiers are untouched (out of scope per the proposal doc).

Shipped and tested: the measurement/plan/extension math end-to-end, real per-card wiring in the standard (non-SCM) render path, PDFProps.bleedPriors/bleedOverrides (both optional maps keyed by card identifier, safely defaulting to "unresolved"/"auto" when absent), the main-thread batch resolution of bleedPriors from APIGetTagConsensus (bounded concurrency, per-card failure tolerance — frontend/src/common/concurrencyLimit.ts + bleedPriorResolution.ts), the manual-override UI (Auto/Force bleed/Force trimmed per card, PDFGenerator.tsx's "Bleed Overrides" panel) with its projectSlice/localStorage persistence, and the hedged WYSIWYG preview badge ("bleed will be generated", PagePreview.tsx + willLikelyGenerateBleed). Proposal B is complete end to end — see docs/proposals/proposal-b-bleed-normalization.md's "Shipped vs. not yet built" for the full per-PR breakdown.

Not yet built (both intentionally out of scope, not silently dropped): the merge-time server-side calibration pass for the four named measurement constants, and the XML round-trip field for a persisted override (flagged per the owner's own instruction, not built).

PDF.tsx's per-card eligibility check (isBleedNormalizationEligible - full-resolution Google Drive/local-file images only) is now exported and shared, rather than re-derived: PDFGenerator.tsx's BleedOverrideSettings panel and the display page's rail Print Options section (frontend/src/features/display/PrintOptionsSection.tsx - Proposal H pane migration, left-panel unification, issue #164) both call the same function, so the two surfaces' eligibility rule can't silently drift apart.

PDFCardImage's effective-dpi derivation (imageDPI when it's set and lower than cardDocument.dpi, else cardDocument.dpi) handles the case where a lower imageDPI setting makes the Worker serve a downscaled image - measurement always converts px→mm against the resolution of what was actually decoded, not assumed.

A real crash caught only by running tests/PDFGenerator.spec.ts, not by tsc/jest: the first version skipped the old proportional rescale by setting transform: "none" when normalized. @react-pdf/renderer's own stylesheet parser (@react-pdf/stylesheet) has a bug where any single-token transform value throws deep inside its internals (see docs/lessons.md's entry for the exact mechanism) - and their custom reconciler doesn't propagate that as a rejection anywhere, so pdf(...).toBlob() just hangs forever with zero console/page error. All 3 download-path Playwright tests hung at their timeout; a stashed pre-Proposal-B baseline confirmed they pass cleanly with no other changes. Fixed by using transform: undefined (omitting the key) instead of "none" - all 4 tests pass afterward, matching baseline timing.

#301 — croppable bleed, merged bleed boxes, split-the-difference gutters

Owner decisions, 2026-07-21/29, extending Proposal B above: bleed is now a resource the layout may CROP, not a rigid box that gets a whole card dropped when it doesn't fit. #299 (Proposal B, above) still produces the resource - resolveBleedPlan/normalizeCardBleed still measure and synthesize each card's bleed to exactly the configured target (bleedEdgeMM) on every side. #301 decides how much of that carried bleed actually RENDERS, given the page - neither replaces the other.

The fit math (layout.ts): bleedEdgeMM is now a per-edge MAXIMUM, not a fixed addend. fitAxisWithBleed (replacing the old fitCardsInDimension) fits card COUNT from the bare card size alone (no bleed at all) - a crowded axis that used to drop a whole card the moment full bleed didn't fit now keeps every card and shrinks bleed instead. Whatever space is left over after the bare-card fit is handed out via a water-filling formula: every one of an axis's 2*count half-boundaries (both page edges and every shared interior gutter's two facing halves) draws from the same slack pool equally - "split the difference" at a gutter and "page edge/margin caps are just another constraint on the same clamp" both fall out of that one formula, not two code paths. computeLayout's LayoutSlot now carries a bleedMM: {top,bottom,left,right} - uniform across every slot on the page (per-AXIS, not truly per-slot - see LayoutSlot.bleedMM's own comment for why that's correct here, not a simplification), so a card can be full bleed on one axis (say top/bottom) while cropped on the other (left/right), but never asymmetric left-vs- right or top-vs-bottom within this grid's regular geometry. Regression-guarded: when there's enough slack to grant the full target everywhere (the common case), the new formula reduces to byte-identical output to the pre-#301 rigid fit (layout.test.ts's dedicated regression-guard describe block proves this against the old formula directly, not just self-consistency).

Per-edge crop windows (pdfImage.ts): computeBleedCropMM/computeRenderedBleedMM are pure mm-domain geometry - max(0, carried - available) per edge for the crop, min(carried, available) for what actually renders. computeBleedCropWindowPx converts to source-image pixels at the card's own dpi for a caller that wants a true pixel crop. A card whose plan carries no bleed at all (a trimmed source, or any card isBleedNormalizationEligible rejects) always computes a 0 crop, never negative - "up to X available," never "X or a dropped card."

Export (PDF.tsx): PDFCardImage sizes each card's destination box to CardSize + renderedBleedMM (may be less than the configured target on a crowded axis) and, for a #299-normalized (bleed-normalized) card, crops the oversized synthesized image down to that box via a CSS technique - the full-target-bleed <Image> sits at a negative offset inside an overflow: hidden wrapper sized to the rendered box, rather than a second canvas decode/ crop/re-encode pass (the visible PDF output is pixel-identical either way; this keeps the crop as pure, unit-tested geometry in pdfImage.ts with no new imperative canvas code - see that module's own boundary-of-thin-imperative-work comment, same shape as bleedExtension.ts's split). Cut lines (CutLineCorner, PDFCardCutLines, PageCutLines) mark the TRUE card edge - bleedMM.<edge> in from the slot boundary, not a flat offset - so a cut line stays correct even when its slot is cropped below the configured target. The non-normalized path (thumbnail-tier, SCM, any source isBleedNormalizationEligible rejects) keeps the pre-#301 proportional CSS rescale (a real per-edge pixel crop isn't available for that path - unchanged limitation, not a #301 regression), just targeting the new (possibly smaller) rendered box instead of the flat configured target.

Preview (PagePreview.tsx): reads computeLayout's own slot.widthMM/heightMM/bleedMM directly (previously assumed a flat CardSize + 2*bleedEdgeMM for every slot) - same computeLayout() call the exporter uses, so slot sizes and cut-line positions agree with the PDF by construction, not by parallel maintenance.

Trailing-edge / bordered-profile warning (marginProfiles.ts + MarginProfileControl.tsx): maxBleedForFourColumns's FORMULA is unchanged (it already computed exactly the per-edge bleed fitAxisWithBleed's water-filling converges to at a fixed count of 4 - the same expression, re-derived) - only what exceeding it MEANS changed. Pre-#301 it meant "the sheet drops from 4 columns to 3"; post-#301 it means "bleed on the affected edge crops to this cap, all 4 columns stay." MarginProfileControl's warning copy was rewritten from a vague "see the warning above"/ "fewer cards per row" pointer into the owner's mandated disclaimer: bleed is cropped to N mm on the affected edge, with reduced cutting tolerance there - stated outright, not implied. Still never a hard clamp on the bleed input itself (unchanged from before this round).

Known limitation, not fixed here: a #299-normalized card is ASSUMED to carry exactly bleedEdgeMM on every side for crop-window purposes (matching normalizeCardBleed's own contract) - the rare case where a too-small source hit bleedExtension.ts's own clampOpposingCrop shortfall (a real, already-documented "slightly-short bleed margin" trade there) isn't visible from outside that module without touching it, which is out of this pass's scope (bleedNormalize.ts/bleedExtension.ts are #299's contract - see this doc's own Proposal B section and those files' own header warnings about a past unilateral change to that resolution order).

Key files: frontend/src/features/pdf/layout.ts (+ layout.test.ts), frontend/src/features/pdf/pdfImage.ts (+ pdfImage.test.ts), frontend/src/features/pdf/PDF.tsx, frontend/src/features/pdf/PagePreview.tsx, frontend/src/features/display/marginProfiles.ts (+ marginProfiles.test.ts), frontend/src/features/display/MarginProfileControl.tsx (+ .test.tsx), frontend/tests/DisplayPage.spec.ts's margin-profile-control test (rewritten for the new behavior - old title ack'd in .github/coverage-acks.txt).

PDF-generation wait experience (SPEC-cardback-pdfwait.md §D, PKG2)

PDFGenerator.tsx derives a waitPhase ("idle" | "fetching" | "assembling" | "done") from the existing isDownloading/isSavingToDrive + imageFetchProgress state, rather than tracking it as independent state - fewer places that can drift out of sync. imageFetchProgress == null reads as "fetching" (nothing reported yet, not "assembling" - a real bug caught by this round's own Playwright coverage: treating the brief pre-first-callback window as null-means-assembling flashed "Assembling PDF…" before any fetch had actually started).

  • 2a - progress bar (PDFWaitPanel.tsx's PDFProgressBox, replaces the old bare pdf-image-fetch-progress text line): a real Bootstrap ProgressBar, determinate (now={min(completed/total,1)*100}, capped at 99% - never a false 100% before the phase genuinely ends) while fetching, honest indeterminate (animated striped, aria-busy) while assembling (no progress callback exists for @react-pdf/renderer's own layout/encode phase - Annex A-3/PB1: swap to determinate if a future render seam exposes one), and a green done bar once the export genuinely succeeds.
  • 2b - embedded "What's That Card?" game (PDFWaitPanel.tsx's PDFWaitGameEmbed): the right column (normally the live PDF preview) becomes a chrome frame around <QuestionFeed> rendered verbatim (next/dynamic({ssr:false}), imported only once isDownloading/isSavingToDrive flips true - never eagerly bundled while a user is still configuring the PDF, mirroring bug 1's own lazy-WASM posture above) plus a persistent build-status ribbon. No forked component, no new voting mechanic - the exact /whatsthat funnel (docs/features/printing-tags.md). Torn down (unmounted) the instant generation finishes, replaced by the existing PostExportContributionPrompt as the embed's own outro (context-aware "one nudge, not two" - the standalone bottom-of-settings mount, described below, is suppressed whenever this outro is already showing the same prompt in the right column).

Cardback reminder gate on the classic direct "Generate PDF"/"Save PDF to Google Drive" buttons

A user can reach /print directly (bookmark, refresh, any entry that skips the editor's Finish footer/usePrePrintSaveGate entirely) - so PDFGenerator.tsx's own Generate/Save-to-Drive click handlers wrap themselves in useCardbackReminderGate (frontend/src/features/display/ useCardbackReminderGate.tsx) independently of PrePrintSaveGate.tsx's own composition of the same hook. Both call sites read the same per-project sessionStorage suppression key (cardbackReminderSuppression.ts), so passing through the reminder once (from either entry) is enough for the rest of that session - see docs/features/printing-tags.md's neighbour, SPEC-cardback-pdfwait.md §C.1, for the gate's own full design.

Post-export contribution prompt (issue #166)

useDownloadPDF/useSaveToDrivePDF (this file) are the shared success signal both real export surfaces key off: PDFGenerator.tsx itself now awaits its own downloadPDF/saveToDrive button handlers and, on a genuine success, calls usePostExportContributionPrompt's notifyExportSucceeded() to show a dismissible, once-per-session prompt linking to /whatsthat (frontend/src/features/export/ postExportContributionPrompt.ts + usePostExportContributionPrompt.ts + PostExportContributionPrompt.tsx). Two different success-detection paths, because the two hooks return differently:

  • Download path: useDownloadPDF's returned promise resolves void — its own useDoFileDownload wrapper (download.ts) swallows the inner success boolean to drive the download-manager UI instead. Success is read back out of the same fileDownloads redux slice that UI already populates (wasLatestCardsPdfDownloadSuccessful, keyed off the most recently COMPLETED "cards.pdf" entry by completedTimestamp — every click enqueues a fresh entry, so this can't pick up a stale success from an earlier export).
  • Save-to-Drive path: useSaveToDrivePDF has no such wrapper — .finally() passes its .then()'s resolved boolean straight through, so await saveToDrive() already gives the real success/cancelled value directly.

This used to also be mounted from DisplayPage.tsx's own inline export (Proposal H, item 2) — issue #275 retired that pipeline entirely (the memory-heavy Generate PDF/Save-to-Drive operations now live solely here, reached from /display's Finish footer via a pre-print save gate; see docs/proposals/proposal-h-display-layout-spec.md's Finish Footer and Print-Page Funnel decisions). The later Proposal H route swap (2026-07-23, issues #231/#272) fully unrouted the classic grid ProjectEditor.tsx as well (component kept in-tree, deletion is a separate later decision) — this component's only LIVE mounts today are FinishedMyProject.tsx's PDF tab (reached solely via the standalone /print route, pages/print.tsx) and PDFGeneratorModal.tsx (mounted globally via Modals.tsx, route-independent); one implementation either way, not a forked second copy. See docs/features/printing-tags.md's own entry for the full detail (why /whatsthat and not a new route, the sessionStorage-backed "never repeats within a session" rule) and docs/features/print-export-page.md for the classic "Print!" tab's own (now unrouted) history.

Key files

  • frontend/src/features/pdf/PDFGenerator.tsx, frontend/src/features/pdf/pdfImage.ts (+ pdfImage.test.ts)
  • frontend/src/features/pdf/PDF.tsx, frontend/src/features/pdf/scm/SCMPDF.tsx (both thread reportImageFailure down to their per-card <Image>)
  • frontend/src/features/pdf/pdf.worker.ts (owns the per-render failures array — see bug 4), pdfRenderService.ts, useRenderPDF.ts
  • frontend/src/features/pdf/PDFCanvasPreview.tsx
  • frontend/scripts/copy-pdf-worker.js
  • frontend/src/features/pdf/PDFGeneratorModal.tsx, frontend/src/features/export/FinishedMyProject.tsx, frontend/src/components/ProjectEditor.tsx
  • frontend/src/features/export/postExportContributionPrompt.ts (+ postExportContributionPrompt.test.ts), frontend/src/features/export/usePostExportContributionPrompt.ts, frontend/src/features/export/PostExportContributionPrompt.tsx — issue #166's post-export contribution prompt
  • frontend/tests/PDFGenerator.spec.ts — mocked-CDN Playwright coverage for bug 4 (preview warning, confirm-gated download/cancel, and a real-image success-path regression check)
  • frontend/tests/PostExportContributionPrompt.spec.ts — issue #166 coverage against this component's one remaining live mount, /print's PDF tab (re-homed there from the classic "Print!" tab in the 2026-07-24 parked-spec port wave, issue #272 — the second, /display-inline-export surface this file used to also cover was already retired by issue #275, above)

Status

All four bugs verified fixed. The first three are deployed and confirmed live; bug 4 is verified only in a mocked sandbox (see its merge-time checklist item in the frontend-polish PR) pending a live-backend check. Upstream PR #463 (lazy WASM load fix) is open; #464 (canvas preview) and #466 (thumbnail routing) were closed after the maintainer said the existing upstream behavior is deliberate design for their codebase, not a bug — see Infrastructure for PR status details. Don't "fix" this fork's PDF tab implementation to match upstream's on those two points; both are correct for their own codebase.

See also Google-Drive-Connect for the separate "Save PDF directly to Google Drive" upload feature on this same tab.

Clone this wiki locally