-
Notifications
You must be signed in to change notification settings - Fork 0
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.
-
Phantom PDF download on opening the editor, before ever touching the PDF tab.
@react-pdf/renderereagerly instantiates a Yoga WASM binary at import time, not render time, andPDFGeneratorwas statically imported. Fixed vianext/dynamic({ssr: false})+mountOnEnteron theTab.Panes that mount it (PDFGeneratorModal.tsx,FinishedMyProject.tsx,ProjectEditor.tsx). -
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 normalimport.meta.urlpattern, so it's copied intopublic/as a static asset by a postinstall script (frontend/scripts/copy-pdf-worker.js) — gitignored, regenerated on everynpm install, always matches the installedpdfjs-distversion. -
PDF card images never rendered at all (both preview and the actual download — only cut lines showed), because
pdfImage.tsrouted thumbnail-quality previews throughgetBucketImageURL, and no image CDN was configured for this fork at all. See Image-CDN. -
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 itssrcURL 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.tsfetch the image itself instead of handing<Image>a bare remote URL to fetch blind (fetchAsObjectURL: a GET request, anokcheck, then ablob:object URL — the same pattern theLocalFilesource type already used). A genuine failure now rejects instead of resolving to a URL that might fail silently later.PDFCardImageandSCMCardcatch that rejection and callreportImageFailure, a callback threaded throughPDFPropsandSCMPDFPropsthatpdf.worker.tssupplies per render call (not something any caller of the public render hooks passes in itself).renderPDF/renderPDFInWorkernow return that render's failures alongside the blob, asRenderPDFResult.failures.The live preview in
PDFGenerator.tsxshows a warningAlertnaming the failed cards (test idpdf-preview-image-failures) whenever any failures came back, and a separate dangerAlert(test idpdf-preview-error) when the render itself throws — that'suseRenderPDF's pre-existingerrorvalue, which used to be computed and then never actually rendered anywhere. The download and Save-to-Drive paths (downloadPDF/saveToDrivePDF) block behind awindow.confirmnaming the failed cards before callingdownloadFile/uploading, the same pattern already used for the irreversible-action confirms inDrivesPanel.tsx. Cancelling that confirm dispatches a "Download Cancelled"/"Save Cancelled" toast and returns without writing anything.
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.
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 fromconcurrencyLimit.ts'smapWithConcurrencyLimit, which needs a known, finite item list) caps client-side full-resolution fetches toFULL_RESOLUTION_FETCH_CONCURRENCY = 3, matching the server's own limit. - Retries a 429 or 5xx (transient) up to
FULL_RESOLUTION_FETCH_MAX_RETRIES = 3times 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 existingreportImageFailurepattern, threaded throughpdf.worker.ts→ comlink'sonImageProgress→pdfRenderService→PDFGenerator.tsx) drives progress feedback so the wait reads as working, not hung.totalis an approximation (unique card count, not slot count - a duplicate card in the deck fetches once per slot, socompletedcan 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 realProgressBar, 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 FUTUREwindow.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 BootstrapModal,PDFGenerator.tsx) can't be affected by that heuristic at all.
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.
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.
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).
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'sPDFProgressBox, replaces the old barepdf-image-fetch-progresstext line): a real BootstrapProgressBar, 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 greendonebar once the export genuinely succeeds. -
2b - embedded "What's That Card?" game (
PDFWaitPanel.tsx'sPDFWaitGameEmbed): the right column (normally the live PDF preview) becomes a chrome frame around<QuestionFeed>rendered verbatim (next/dynamic({ssr:false}), imported only onceisDownloading/isSavingToDriveflips 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/whatsthatfunnel (docs/features/printing-tags.md). Torn down (unmounted) the instant generation finishes, replaced by the existingPostExportContributionPromptas 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).
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.
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 resolvesvoid— its ownuseDoFileDownloadwrapper (download.ts) swallows the inner success boolean to drive the download-manager UI instead. Success is read back out of the samefileDownloadsredux slice that UI already populates (wasLatestCardsPdfDownloadSuccessful, keyed off the most recently COMPLETED"cards.pdf"entry bycompletedTimestamp— every click enqueues a fresh entry, so this can't pick up a stale success from an earlier export). -
Save-to-Drive path:
useSaveToDrivePDFhas no such wrapper —.finally()passes its.then()'s resolved boolean straight through, soawait 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.
-
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 threadreportImageFailuredown to their per-card<Image>) -
frontend/src/features/pdf/pdf.worker.ts(owns the per-renderfailuresarray — see bug 4),pdfRenderService.ts,useRenderPDF.ts frontend/src/features/pdf/PDFCanvasPreview.tsxfrontend/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)
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.
Understanding the system
- Overview
- Documentation-Process
- Theory
- Identification-Pipeline
- Pipeline-Fidelity-Gate
- Federation-v1
- Vote-System
- Readiness-Audit
- License-Provenance
- Upstreaming-Conventions
- Drift-Log
- Upstream-Wiki-Drift
- Printing-Tags
- Catalog-Completion-Plan
- Moderation
- Card-DOM-API
- PDF-Generator
- Print-Export-Page
- Google-Drive-Connect
- Grid-Selector
- Image-CDN
- Local-File-Source
Using it
Operating it
Folded into other pages