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.
This commit is contained in:
Maurice
2026-11-13 10:48:58 +01:00
parent 308daec14e
commit 318e71aa0c
3 changed files with 33 additions and 8 deletions
+1 -1
View File
@@ -275,7 +275,7 @@ export async function downloadTripPDF({ trip, days, places, assignments: allStor
return url ? [url] : []
})),
signal,
)
).catch(() => new Map<string, string>())
const totalAssigned = new Set(
Object.values(assignments).flatMap(a => a.map(x => x.place?.id)).filter(Boolean)
+11 -2
View File
@@ -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 () => {
+21 -5
View File
@@ -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<string | null> {
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<string |
} catch {
// Offline, timed out, aborted or undecodable: the photo prints from its URL.
return null;
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', stop);
}
}