diff --git a/projects/kit/printer/src/kit-browser-pdf.spec.ts b/projects/kit/printer/src/kit-browser-pdf.spec.ts new file mode 100644 index 0000000..cf320cd --- /dev/null +++ b/projects/kit/printer/src/kit-browser-pdf.spec.ts @@ -0,0 +1,370 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { KitBrowserPdfDependencies } from './kit-browser-pdf'; +import { kitDownloadPdf, kitPreviewGeneratedPdf } from './kit-browser-pdf'; + +interface BrowserPdfTestHarness { + readonly dependencies: KitBrowserPdfDependencies; + readonly link: HTMLAnchorElement; + readonly appendChild: ReturnType; + readonly click: ReturnType; + readonly remove: ReturnType; + readonly createObjectURL: ReturnType; + readonly revokeObjectURL: ReturnType; + readonly scheduled: (() => void)[]; + readonly open: ReturnType; +} + +const createHarness = (target: Window | null = null): BrowserPdfTestHarness => { + const click = vi.fn(); + const remove = vi.fn(); + const appendChild = vi.fn(); + const link = { href: '', download: '', style: { display: '' }, click, remove } as unknown as HTMLAnchorElement; + const createObjectURL = vi.fn(() => 'blob:generated-pdf'); + const revokeObjectURL = vi.fn(); + const scheduled: (() => void)[] = []; + const open = vi.fn(() => target); + + return { + dependencies: { + document: { + body: { appendChild } as unknown as HTMLElement, + createElement: vi.fn(() => link), + defaultView: { open } as unknown as Window & typeof globalThis, + }, + url: { createObjectURL, revokeObjectURL }, + schedule: (callback) => scheduled.push(callback), + }, + link, + appendChild, + click, + remove, + createObjectURL, + revokeObjectURL, + scheduled, + open, + }; +}; + +describe('kitDownloadPdf', () => { + it('downloads without navigating and cleans up after the browser has started', () => { + const harness = createHarness(); + + kitDownloadPdf(Uint8Array.from([1, 2, 3]), { + filename: 'label.pdf', + dependencies: harness.dependencies, + }); + + expect(harness.link.href).toBe('blob:generated-pdf'); + expect(harness.link.download).toBe('label.pdf'); + expect(harness.appendChild).toHaveBeenCalledWith(harness.link); + expect(harness.click).toHaveBeenCalledOnce(); + expect(harness.remove).not.toHaveBeenCalled(); + + harness.scheduled[0](); + expect(harness.remove).toHaveBeenCalledOnce(); + expect(harness.revokeObjectURL).toHaveBeenCalledWith('blob:generated-pdf'); + }); + + it('revokes the object URL when creating the download link fails', () => { + const harness = createHarness(); + const failure = new Error('createElement failed'); + const dependencies: KitBrowserPdfDependencies = { + ...harness.dependencies, + document: { + ...harness.dependencies.document, + createElement: () => { + throw failure; + }, + }, + }; + + expect(() => kitDownloadPdf(Uint8Array.from([1]), { filename: 'label.pdf', dependencies })).toThrow(failure); + expect(harness.revokeObjectURL).toHaveBeenCalledWith('blob:generated-pdf'); + }); + + it('removes the link and revokes the object URL when appending fails', () => { + const harness = createHarness(); + const failure = new Error('append failed'); + harness.appendChild.mockImplementation(() => { + throw failure; + }); + + expect(() => + kitDownloadPdf(Uint8Array.from([1]), { + filename: 'label.pdf', + dependencies: harness.dependencies, + }), + ).toThrow(failure); + expect(harness.remove).toHaveBeenCalledOnce(); + expect(harness.revokeObjectURL).toHaveBeenCalledWith('blob:generated-pdf'); + }); + + it('still revokes the object URL when removing the temporary link fails', () => { + const harness = createHarness(); + harness.remove.mockImplementation(() => { + throw new Error('remove failed'); + }); + + kitDownloadPdf(Uint8Array.from([1]), { + filename: 'label.pdf', + dependencies: harness.dependencies, + }); + expect(() => harness.scheduled[0]()).not.toThrow(); + expect(harness.revokeObjectURL).toHaveBeenCalledWith('blob:generated-pdf'); + }); + + it('uses the global scheduler when the injected scheduler fails', () => { + const harness = createHarness(); + let cleanup: (() => void) | undefined; + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout').mockImplementation(((callback: () => void) => { + cleanup = callback; + return 1; + }) as typeof globalThis.setTimeout); + const dependencies: KitBrowserPdfDependencies = { + ...harness.dependencies, + schedule: () => { + throw new Error('schedule failed'); + }, + }; + + try { + expect(() => kitDownloadPdf(Uint8Array.from([1]), { filename: 'label.pdf', dependencies })).not.toThrow(); + expect(harness.click).toHaveBeenCalledOnce(); + cleanup?.(); + expect(harness.remove).toHaveBeenCalledOnce(); + expect(harness.revokeObjectURL).toHaveBeenCalledWith('blob:generated-pdf'); + } finally { + setTimeoutSpy.mockRestore(); + } + }); +}); + +describe('kitPreviewGeneratedPdf', () => { + it('opens the placeholder synchronously and replaces it with the generated PDF', async () => { + const replace = vi.fn(); + const target = { + closed: false, + close: vi.fn(), + document: { title: '', body: { textContent: '' } }, + location: { replace }, + opener: {} as Window, + } as unknown as Window; + const harness = createHarness(target); + + let resolvePdf!: (bytes: Uint8Array) => void; + const buildPdf = vi.fn(() => new Promise((resolve) => (resolvePdf = resolve))); + const preview = kitPreviewGeneratedPdf(buildPdf, { + title: 'PDF generating', + pendingText: 'Please wait', + fallbackFilename: 'document.pdf', + dependencies: harness.dependencies, + }); + + expect(harness.open).toHaveBeenCalledWith('', '_blank'); + expect(target.opener).toBeNull(); + expect(target.document.title).toBe('PDF generating'); + expect(target.document.body.textContent).toBe('Please wait'); + + resolvePdf(Uint8Array.from([1, 2, 3])); + await preview; + expect(replace).toHaveBeenCalledWith('blob:generated-pdf'); + expect(harness.click).not.toHaveBeenCalled(); + + harness.scheduled[0](); + expect(harness.revokeObjectURL).toHaveBeenCalledWith('blob:generated-pdf'); + }); + + it('downloads when the preview window is blocked', async () => { + const harness = createHarness(null); + await kitPreviewGeneratedPdf(async () => Uint8Array.from([1, 2, 3]), { + title: 'PDF generating', + pendingText: 'Please wait', + fallbackFilename: 'document.pdf', + dependencies: harness.dependencies, + }); + + expect(harness.link.download).toBe('document.pdf'); + expect(harness.click).toHaveBeenCalledOnce(); + }); + + it('downloads when the user closes the preview while the PDF is generating', async () => { + const target = { + closed: true, + close: vi.fn(), + document: { title: '', body: { textContent: '' } }, + location: { replace: vi.fn() }, + opener: null, + } as unknown as Window; + const harness = createHarness(target); + + await kitPreviewGeneratedPdf(async () => Uint8Array.from([1, 2, 3]), { + title: 'PDF generating', + pendingText: 'Please wait', + fallbackFilename: 'document.pdf', + dependencies: harness.dependencies, + }); + + expect(harness.link.download).toBe('document.pdf'); + expect(harness.click).toHaveBeenCalledOnce(); + }); + + it('closes the placeholder and rethrows after generation fails', async () => { + const close = vi.fn(); + const target = { + closed: false, + close, + document: { title: '', body: { textContent: '' } }, + location: { replace: vi.fn() }, + opener: null, + } as unknown as Window; + const harness = createHarness(target); + + const failure = new Error('PDF failed'); + await expect( + kitPreviewGeneratedPdf(async () => Promise.reject(failure), { + title: 'PDF generating', + pendingText: 'Please wait', + fallbackFilename: 'document.pdf', + dependencies: harness.dependencies, + }), + ).rejects.toBe(failure); + + expect(close).toHaveBeenCalledOnce(); + }); + + it('preserves the generation error when closing the placeholder fails', async () => { + const target = { + closed: false, + close: () => { + throw new Error('close failed'); + }, + document: { title: '', body: { textContent: '' } }, + location: { replace: vi.fn() }, + opener: null, + } as unknown as Window; + const harness = createHarness(target); + const failure = new Error('PDF failed'); + + await expect( + kitPreviewGeneratedPdf(async () => Promise.reject(failure), { + title: 'PDF generating', + pendingText: 'Please wait', + fallbackFilename: 'document.pdf', + dependencies: harness.dependencies, + }), + ).rejects.toBe(failure); + }); + + it('preserves the generation error when reading the closed state fails', async () => { + const target = { + get closed(): boolean { + throw new Error('closed state failed'); + }, + close: vi.fn(), + document: { title: '', body: { textContent: '' } }, + location: { replace: vi.fn() }, + opener: null, + } as unknown as Window; + const harness = createHarness(target); + const failure = new Error('PDF failed'); + + await expect( + kitPreviewGeneratedPdf(async () => Promise.reject(failure), { + title: 'PDF generating', + pendingText: 'Please wait', + fallbackFilename: 'document.pdf', + dependencies: harness.dependencies, + }), + ).rejects.toBe(failure); + }); + + it('closes an unusable placeholder and downloads the completed PDF', async () => { + const close = vi.fn(); + const pendingBody = { + set textContent(_value: string) { + throw new Error('placeholder failed'); + }, + }; + const target = { + closed: false, + close, + document: { title: '', body: pendingBody }, + location: { replace: vi.fn() }, + opener: null, + } as unknown as Window; + const harness = createHarness(target); + + await kitPreviewGeneratedPdf(async () => Uint8Array.from([1]), { + title: 'PDF generating', + pendingText: 'Please wait', + fallbackFilename: 'document.pdf', + dependencies: harness.dependencies, + }); + + expect(close).toHaveBeenCalledOnce(); + expect(harness.link.download).toBe('document.pdf'); + expect(harness.click).toHaveBeenCalledOnce(); + }); + + it('does not misclassify cleanup scheduling failure as PDF generation failure', async () => { + const replace = vi.fn(); + const target = { + closed: false, + close: vi.fn(), + document: { title: '', body: { textContent: '' } }, + location: { replace }, + opener: null, + } as unknown as Window; + const harness = createHarness(target); + const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout').mockImplementation((() => 1) as typeof globalThis.setTimeout); + const dependencies: KitBrowserPdfDependencies = { + ...harness.dependencies, + schedule: () => { + throw new Error('schedule failed'); + }, + }; + + try { + await expect( + kitPreviewGeneratedPdf(async () => Uint8Array.from([1]), { + title: 'PDF generating', + pendingText: 'Please wait', + fallbackFilename: 'document.pdf', + dependencies, + }), + ).resolves.toBeUndefined(); + expect(replace).toHaveBeenCalledWith('blob:generated-pdf'); + expect(target.close).not.toHaveBeenCalled(); + } finally { + setTimeoutSpy.mockRestore(); + } + }); + + it('downloads when navigating the prepared preview fails', async () => { + const close = vi.fn(); + const target = { + closed: false, + close, + document: { title: '', body: { textContent: '' } }, + location: { + replace: () => { + throw new Error('navigation failed'); + }, + }, + opener: null, + } as unknown as Window; + const harness = createHarness(target); + + await kitPreviewGeneratedPdf(async () => Uint8Array.from([1]), { + title: 'PDF generating', + pendingText: 'Please wait', + fallbackFilename: 'document.pdf', + dependencies: harness.dependencies, + }); + + expect(close).toHaveBeenCalledOnce(); + expect(harness.link.download).toBe('document.pdf'); + expect(harness.click).toHaveBeenCalledOnce(); + }); +}); diff --git a/projects/kit/printer/src/kit-browser-pdf.ts b/projects/kit/printer/src/kit-browser-pdf.ts new file mode 100644 index 0000000..620904e --- /dev/null +++ b/projects/kit/printer/src/kit-browser-pdf.ts @@ -0,0 +1,213 @@ +/** Browser dependencies used by the PDF output helpers. */ +export interface KitBrowserPdfDependencies { + /** Document used to create download links and preview windows. */ + readonly document: Pick; + /** Object URL implementation used for generated PDF blobs. */ + readonly url: Pick; + /** Scheduler used to delay DOM and object URL cleanup. */ + readonly schedule: (callback: () => void, delay: number) => unknown; +} + +/** Options shared by browser PDF output helpers. */ +export interface KitBrowserPdfOutputOptions { + /** Delay before temporary resources are released. Invalid values fall back to 60 seconds. */ + readonly cleanupDelayMs?: number; + /** Browser dependencies. Override in tests; production callers normally omit this. */ + readonly dependencies?: KitBrowserPdfDependencies; +} + +/** Options for {@link kitDownloadPdf}. */ +export interface KitDownloadPdfOptions extends KitBrowserPdfOutputOptions { + /** Filename presented by the browser download. */ + readonly filename: string; +} + +/** Options for {@link kitPreviewGeneratedPdf}. */ +export interface KitPreviewGeneratedPdfOptions extends KitBrowserPdfOutputOptions { + /** Title shown while the PDF is being generated. */ + readonly title: string; + /** Text shown while the PDF is being generated. */ + readonly pendingText: string; + /** Filename used when a preview window cannot be opened or has been closed. */ + readonly fallbackFilename: string; +} + +interface PdfPreview { + readonly show: (pdfBytes: Uint8Array) => void; + readonly close: () => void; +} + +const defaultDependencies = (): KitBrowserPdfDependencies => ({ + document, + url: URL, + schedule: (callback, delay) => globalThis.setTimeout(callback, delay), +}); + +const createPdfUrl = (pdfBytes: Uint8Array, dependencies: KitBrowserPdfDependencies): string => + dependencies.url.createObjectURL( + new Blob([Uint8Array.from(pdfBytes).buffer], { + type: 'application/pdf', + }), + ); + +const cleanupDelay = (value: number | undefined): number => (value !== undefined && Number.isFinite(value) && value >= 0 ? value : 60_000); + +const createCleanup = (pdfUrl: string, dependencies: KitBrowserPdfDependencies, link?: HTMLAnchorElement): (() => void) => { + let cleaned = false; + return (): void => { + if (cleaned) return; + cleaned = true; + try { + link?.remove(); + } catch { + // Cleanup is best-effort and must not turn successful PDF output into a failure. + } finally { + try { + dependencies.url.revokeObjectURL(pdfUrl); + } catch { + // Object URL cleanup is best-effort for the same reason. + } + } + }; +}; + +const scheduleCleanup = (cleanup: () => void, delay: number, dependencies: KitBrowserPdfDependencies): boolean => { + try { + dependencies.schedule(cleanup, delay); + return true; + } catch { + try { + globalThis.setTimeout(cleanup, delay); + return true; + } catch { + return false; + } + } +}; + +const isWindowClosed = (target: Window): boolean => { + try { + return target.closed; + } catch { + return true; + } +}; + +const closeWindow = (target: Window | null): void => { + try { + if (target && !target.closed) target.close(); + } catch { + // Closing a placeholder is best-effort and must never replace the original output error. + } +}; + +/** + * Download generated PDF bytes without navigating the current application page. + * + * @remarks Browser-only. Do not call during server-side rendering. + * + * The temporary anchor and object URL stay alive long enough for browsers to start the download. + * Cleanup is registered before clicking so a cleanup scheduling error cannot be mistaken for a PDF + * generation failure after the download has already started. + */ +export const kitDownloadPdf = (pdfBytes: Uint8Array, options: KitDownloadPdfOptions): void => { + const dependencies = options.dependencies ?? defaultDependencies(); + const pdfUrl = createPdfUrl(pdfBytes, dependencies); + let link: HTMLAnchorElement | undefined; + let cleanup = createCleanup(pdfUrl, dependencies); + let cleanupScheduled = false; + + try { + link = dependencies.document.createElement('a'); + cleanup = createCleanup(pdfUrl, dependencies, link); + link.href = pdfUrl; + link.download = options.filename; + link.style.display = 'none'; + dependencies.document.body.appendChild(link); + cleanupScheduled = scheduleCleanup(cleanup, cleanupDelay(options.cleanupDelayMs), dependencies); + + // Keep this as the final synchronous output operation: the browser may start immediately. + link.click(); + } catch (error) { + cleanup(); + throw error; + } finally { + if (!cleanupScheduled) cleanup(); + } +}; + +/** + * Open a placeholder window synchronously for an asynchronously generated PDF. + * + * Call this directly from the user's click handler, before awaiting PDF generation, to avoid popup + * blocking. If the preview cannot be opened or is closed while generating, showing the PDF safely + * falls back to a file download and keeps the current application page in place. + */ +const preparePdfPreview = (options: KitPreviewGeneratedPdfOptions): PdfPreview => { + const dependencies = options.dependencies ?? defaultDependencies(); + let target: Window | null = null; + + try { + target = dependencies.document.defaultView?.open('', '_blank') ?? null; + if (target) { + target.opener = null; + target.document.title = options.title; + target.document.body.textContent = options.pendingText; + } + } catch { + closeWindow(target); + target = null; + } + + return { + show: (pdfBytes): void => { + if (!target || isWindowClosed(target)) { + kitDownloadPdf(pdfBytes, { + filename: options.fallbackFilename, + cleanupDelayMs: options.cleanupDelayMs, + dependencies, + }); + return; + } + + const pdfUrl = createPdfUrl(pdfBytes, dependencies); + const cleanup = createCleanup(pdfUrl, dependencies); + const cleanupScheduled = scheduleCleanup(cleanup, cleanupDelay(options.cleanupDelayMs), dependencies); + try { + target.location.replace(pdfUrl); + } catch { + cleanup(); + closeWindow(target); + kitDownloadPdf(pdfBytes, { + filename: options.fallbackFilename, + cleanupDelayMs: options.cleanupDelayMs, + dependencies, + }); + return; + } finally { + if (!cleanupScheduled) cleanup(); + } + }, + close: (): void => closeWindow(target), + }; +}; + +/** + * Preview an asynchronously generated PDF without risking a popup-blocked result. + * + * This function opens its placeholder window synchronously, before invoking `buildPdf`. Call it + * directly from the user's click handler and pass PDF generation as the callback. Generation errors + * close the placeholder and are rethrown for the application to present with its own UI. + */ +export const kitPreviewGeneratedPdf = async ( + buildPdf: () => Promise, + options: KitPreviewGeneratedPdfOptions, +): Promise => { + const preview = preparePdfPreview(options); + try { + preview.show(await buildPdf()); + } catch (error) { + preview.close(); + throw error; + } +}; diff --git a/projects/kit/printer/src/public-api.ts b/projects/kit/printer/src/public-api.ts index de87c02..974ed35 100644 --- a/projects/kit/printer/src/public-api.ts +++ b/projects/kit/printer/src/public-api.ts @@ -3,3 +3,4 @@ // and `dom-to-image-more`; the core entry stays free of those native peers. export * from './kit-printer'; export * from './kit-pdf-printer'; +export * from './kit-browser-pdf';