mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 08:11:03 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user