From 318e71aa0c94b8677f3789e6e319eecdea22b330 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 13 Nov 2026 10:48:58 +0100 Subject: [PATCH] fix(pdf): shrink place photos without AbortSignal.timeout and decode them near thumbnail size (#1501) Safari before 16 has no AbortSignal.timeout, which cost the whole export; a controller and a timer replace it. Photos decode at twice the thumbnail height and three at a time, so a long trip no longer ends the web view. --- client/src/components/PDF/TripPDF.tsx | 2 +- client/src/components/PDF/pdfImages.test.ts | 13 +++++++++-- client/src/components/PDF/pdfImages.ts | 26 +++++++++++++++++---- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx index 696ab1a99..861ee9a41 100644 --- a/client/src/components/PDF/TripPDF.tsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -275,7 +275,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor return url ? [url] : [] })), signal, - ) + ).catch(() => new Map()) const totalAssigned = new Set( Object.values(assignments).flatMap(a => a.map(x => x.place?.id)).filter(Boolean) diff --git a/client/src/components/PDF/pdfImages.test.ts b/client/src/components/PDF/pdfImages.test.ts index db41eec9d..12c6f9753 100644 --- a/client/src/components/PDF/pdfImages.test.ts +++ b/client/src/components/PDF/pdfImages.test.ts @@ -37,7 +37,10 @@ beforeEach(() => { drawn = []; inFlight = 0; mostInFlight = 0; - vi.stubGlobal('createImageBitmap', vi.fn(async () => ({ ...bitmapSize, close: vi.fn() }))); + vi.stubGlobal( + 'createImageBitmap', + vi.fn(async () => ({ ...bitmapSize, close: vi.fn() })) + ); vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation( fakeContext as unknown as HTMLCanvasElement['getContext'] ); @@ -70,6 +73,12 @@ describe('shrinkPdfImages', () => { expect(HTMLCanvasElement.prototype.toDataURL).toHaveBeenCalledWith('image/jpeg', expect.any(Number)); }); + it('FE-PDFIMAGES-001b: a photo is decoded near thumbnail size, not in full', async () => { + await shrinkPdfImages([`${origin}/uploads/places/a.jpg`]); + + expect(createImageBitmap).toHaveBeenCalledWith(expect.any(Blob), { resizeHeight: 312, resizeQuality: 'medium' }); + }); + it('FE-PDFIMAGES-002: a photo from another origin keeps its URL and is never fetched', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch'); const shrunk = await shrinkPdfImages(['https://images.example.com/a.jpg']); @@ -96,7 +105,7 @@ describe('shrinkPdfImages', () => { expect(shrunk.size).toBe(40); expect(drawn).toHaveLength(40); expect(mostInFlight).toBeGreaterThan(1); - expect(mostInFlight).toBeLessThanOrEqual(6); + expect(mostInFlight).toBeLessThanOrEqual(3); }); it('FE-PDFIMAGES-005: an aborted export starts no photo at all', async () => { diff --git a/client/src/components/PDF/pdfImages.ts b/client/src/components/PDF/pdfImages.ts index 7396f3b02..dc573f8e9 100644 --- a/client/src/components/PDF/pdfImages.ts +++ b/client/src/components/PDF/pdfImages.ts @@ -15,8 +15,13 @@ /** The short side of a copy: three times the 52 px thumbnail, sharp at print resolution. */ const THUMB_PX = 156; const JPEG_QUALITY = 0.72; -/** Photos in flight at once: a trip of hundreds should neither queue one by one nor decode all at once. */ -const CONCURRENCY = 6; +/** + * Photos in flight at once: a trip of hundreds should neither queue one by one nor + * decode all at once. Kept low for the phone, where each decode costs memory. + */ +const CONCURRENCY = 3; +/** Height a photo is decoded at: twice THUMB_PX, so a portrait one still has room to shrink. */ +const DECODE_PX = THUMB_PX * 2; const FETCH_TIMEOUT_MS = 10_000; /** Anything larger is not read into memory for a thumbnail; it keeps its URL. */ const MAX_BYTES = 20 * 1024 * 1024; @@ -57,13 +62,21 @@ function canShrink(): boolean { } async function shrinkImage(url: string, signal?: AbortSignal): Promise { - const deadline = AbortSignal.timeout(FETCH_TIMEOUT_MS); + // A controller and a timer rather than AbortSignal.timeout/any, which Safari only + // has from 16 and 17.4 on; a throw there would cost the whole export. + const fetching = new AbortController(); + const stop = () => fetching.abort(); + const timer = setTimeout(stop, FETCH_TIMEOUT_MS); + signal?.addEventListener('abort', stop); try { - const res = await fetch(url, { signal: signal ? AbortSignal.any([signal, deadline]) : deadline }); + const res = await fetch(url, { signal: fetching.signal }); if (!res.ok || Number(res.headers.get('content-length') ?? 0) > MAX_BYTES) return null; const blob = await res.blob(); if (!blob.type.startsWith('image/') || blob.size > MAX_BYTES) return null; - const bitmap = await createImageBitmap(blob); + // Decoded near thumbnail size: a 48 MP phone photo decoded in full holds about + // 190 MB, which can end the web view on a phone. Browsers without the option + // decode in full and toJpeg scales down as before. + const bitmap = await createImageBitmap(blob, { resizeHeight: DECODE_PX, resizeQuality: 'medium' }); try { return toJpeg(bitmap); } finally { @@ -72,6 +85,9 @@ async function shrinkImage(url: string, signal?: AbortSignal): Promise