diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ea3b800 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,79 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +declarative-pdf is a Node.js library that generates PDF documents from declarative HTML templates using Puppeteer. It allows creating multi-page PDFs with customizable headers, footers, backgrounds, and page numbering through custom HTML elements. + +## Commands + +```bash +# Install dependencies +pnpm install + +# Run all tests +pnpm test + +# Run a single test file (partial match works) +pnpm test select-section + +# Run tests with coverage +pnpm test:cov + +# Build (runs tests first, then builds) +pnpm build + +# Build library only (skip tests) +pnpm build:lib + +# Run linter +pnpm lint + +# Run example scripts +pnpm examples +``` + +## Architecture + +### Core Flow + +1. **DeclarativePDF** (`src/index.ts`) - Main entry point. Takes a Puppeteer browser instance and generates PDFs from HTML templates or Page instances. + +2. **HTMLAdapter** (`src/utils/adapter-puppeteer.ts`) - Wrapper around Puppeteer's Page API. Handles content setting, viewport management, and PDF generation. Contains a 0.75 scaling workaround for Puppeteer's PDF dimension bug. + +3. **DocumentPage** (`src/models/document-page.ts`) - Represents a `` element from the template. Manages layout calculation and body rendering. + +4. **buildPages** (`src/utils/layout/build-pages.ts`) - Assembles final PDF by combining body content with section elements (headers, footers, backgrounds) using pdf-lib. + +### Evaluators (`src/evaluators/`) + +Functions that run inside Puppeteer's page context via `page.evaluate()`: +- `template-normalize.ts` - Normalizes HTML structure, ensures proper nesting +- `template-settings.ts` - Extracts document-page settings (size, ppi) +- `section-settings.ts` - Extracts section dimensions and settings +- `prepare-section.ts` - Prepares sections for PDF rendering (sets visibility, page numbers) +- `reset-visibility.ts` - Resets element visibility after section rendering + +### Custom HTML Elements + +Templates use these custom elements: +- `` - Container for a set of physical PDF pages (supports `format`, `size`, `ppi` attributes) +- `` - Main content area (spans multiple pages if needed) +- ``, ``, `` - Repeating sections +- `` - Conditional content within repeating sections +- ``, `` - Page number placeholders + +### Path Aliases + +Configured in `tsconfig.json`: +- `@app/*` maps to `src/*` +- `@test/*` maps to `test/*` + +## Testing + +- Tests use Jest with ts-jest +- Visual tests require ImageMagick/GraphicsMagick (`brew install imagemagick graphicsmagick`) +- Visual tests (`test/visual.test.ts`) are excluded in CI due to rendering differences +- Coverage threshold is 95% for all metrics +- Evaluators are excluded from coverage (they run in browser context) diff --git a/package.json b/package.json index f9bbe7b..b3bde4a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "declarative-pdf", - "version": "1.1.1", + "version": "1.1.2", "description": "A tool for generating PDF documents from declarative HTML templates", "keywords": [ "pdf", diff --git a/src/index.ts b/src/index.ts index 7dfbdfc..555c033 100644 --- a/src/index.ts +++ b/src/index.ts @@ -133,7 +133,27 @@ export default class DeclarativePDF { * If the template is malformed or doesn't contain any document-page * elements, we throw an error. */ - if (!this.documentPages.length) throw new Error('No document pages found'); + if (!this.documentPages.length) { + const diagnostic = await this.html.page.evaluate(() => { + const body = document.body; + const childTags = Array.from(body.children) + .slice(0, 10) + .map((el) => el.tagName.toLowerCase()); + return { + bodyChildCount: body.children.length, + firstChildTags: childTags, + hasDocumentPage: !!document.querySelector('document-page'), + hasPageBody: !!document.querySelector('page-body'), + bodyInnerHTMLPreview: body.innerHTML.slice(0, 500), + }; + }); + throw new Error( + `No document pages found. Template diagnostic: ` + + `body has ${diagnostic.bodyChildCount} children [${diagnostic.firstChildTags.join(', ')}], ` + + `hasDocumentPage: ${diagnostic.hasDocumentPage}, hasPageBody: ${diagnostic.hasPageBody}. ` + + `Preview: ${diagnostic.bodyInnerHTMLPreview}...` + ); + } /** for every document page model, get from DOM what that document-page contains */ logger?.level1().start('[5] Build page layout and body'); diff --git a/src/models/document-page.ts b/src/models/document-page.ts index 204dfc2..f67e170 100644 --- a/src/models/document-page.ts +++ b/src/models/document-page.ts @@ -98,7 +98,16 @@ export class DocumentPage { const buffer = Buffer.from(uint8Array); logger?.level3().start('[5.3.2] Load body pdf as PDFDocument'); - const pdf = await PDFDocument.load(uint8Array); + let pdf; + try { + pdf = await PDFDocument.load(uint8Array); + } catch (error) { + const err = error as Error; + throw new Error( + `Failed to load body PDF for document-page[${this.index}]: ${err.message}. ` + + `Layout: ${this.layout.width}x${this.layout.body.height}px, buffer size: ${buffer.length} bytes` + ); + } logger?.level3().end(); await this.html.resetVisibility(); diff --git a/src/models/element.ts b/src/models/element.ts index fc8fae5..e590821 100644 --- a/src/models/element.ts +++ b/src/models/element.ts @@ -40,7 +40,15 @@ export class BodyElement { } async embedPageIdx(targetPage: PDFPage, idx: number) { - return await targetPage.doc.embedPdf(this.pdf, [idx]); + try { + return await targetPage.doc.embedPdf(this.pdf, [idx]); + } catch (error) { + const err = error as Error; + throw new Error( + `Failed to embed body page ${idx}: ${err.message}. ` + + `Layout: ${this.width}x${this.height}px at (${this.x}, ${this.y})` + ); + } } } @@ -105,8 +113,16 @@ export class SectionElement { async embedPage(targetPage: PDFPage) { if (this.embeddedPage) return this.embeddedPage; - const pages = await targetPage.doc.embedPdf(this.pdf); - this.embeddedPage = pages[0]; - return pages[0]; + try { + const pages = await targetPage.doc.embedPdf(this.pdf); + this.embeddedPage = pages[0]; + return pages[0]; + } catch (error) { + const err = error as Error; + throw new Error( + `Failed to embed ${this.debug.type} section for page ${this.debug.pageNumber}: ${err.message}. ` + + `Layout: ${this.width}x${this.height}px at (${this.x}, ${this.y})` + ); + } } } diff --git a/src/utils/adapter-puppeteer.ts b/src/utils/adapter-puppeteer.ts index e22738d..c373dd3 100644 --- a/src/utils/adapter-puppeteer.ts +++ b/src/utils/adapter-puppeteer.ts @@ -5,7 +5,7 @@ import evalTemplateNormalize from '@app/evaluators/template-normalize'; import evalTemplateSettings from '@app/evaluators/template-settings'; import evalResetVisibility from '@app/evaluators/reset-visibility'; -import type {Browser, Page} from 'puppeteer'; +import type {Browser, Page, HTTPRequest, HTTPResponse} from 'puppeteer'; import type {PrepareSection} from '@app/evaluators/prepare-section'; import type {NormalizeOptions} from '@app/index'; @@ -22,8 +22,64 @@ export type MinimumPage = { pdf: AnyFunction; close: AnyFunction; isClosed: AnyFunction; + on?: AnyFunction; + off?: AnyFunction; }; +interface TrackedRequest { + url: string; + method: string; + startTime: number; + endTime?: number; + status?: 'pending' | 'completed' | 'failed'; + statusCode?: number; + failureReason?: string; +} + +function formatNetworkReport(requests: Map): string { + const pending: TrackedRequest[] = []; + const completed: TrackedRequest[] = []; + const failed: TrackedRequest[] = []; + const now = Date.now(); + + for (const req of requests.values()) { + if (req.status === 'pending') pending.push(req); + else if (req.status === 'completed') completed.push(req); + else if (req.status === 'failed') failed.push(req); + } + + const lines: string[] = ['', '=== Network Request Report ===', '']; + + if (pending.length > 0) { + lines.push(`PENDING REQUESTS (${pending.length}):`); + for (const req of pending) { + const elapsed = now - req.startTime; + lines.push(` - [${req.method}] ${req.url} (waiting ${elapsed}ms)`); + } + lines.push(''); + } + + if (failed.length > 0) { + lines.push(`FAILED REQUESTS (${failed.length}):`); + for (const req of failed) { + lines.push(` - [${req.method}] ${req.url} - ${req.failureReason || 'unknown error'}`); + } + lines.push(''); + } + + if (completed.length > 0) { + lines.push(`COMPLETED REQUESTS (${completed.length}):`); + for (const req of completed) { + const duration = (req.endTime || now) - req.startTime; + lines.push(` - [${req.method}] ${req.url} (${duration}ms, status: ${req.statusCode})`); + } + lines.push(''); + } + + lines.push('=== End Report ===', ''); + return lines.join('\n'); +} + export default class HTMLAdapter { declare private _browser?: MinimumBrowser; declare private _page?: MinimumPage; @@ -66,10 +122,84 @@ export default class HTMLAdapter { this._page = undefined; } - setContent(content: string) { - return this.page.setContent(content, { - waitUntil: ['load', 'networkidle0'], - }); + async setContent(content: string) { + const page = this.page; + + // Only track requests if the page supports event listeners + if (!page.on || !page.off) { + return page.setContent(content, { + waitUntil: ['load', 'networkidle0'], + }); + } + + const requests = new Map(); + + const onRequest = (request: HTTPRequest) => { + const id = `${request.method()}-${request.url()}`; + requests.set(id, { + url: request.url(), + method: request.method(), + startTime: Date.now(), + status: 'pending', + }); + }; + + const onResponse = (response: HTTPResponse) => { + const request = response.request(); + const id = `${request.method()}-${request.url()}`; + const tracked = requests.get(id); + if (tracked) { + tracked.status = 'completed'; + tracked.endTime = Date.now(); + tracked.statusCode = response.status(); + } + }; + + const onRequestFailed = (request: HTTPRequest) => { + const id = `${request.method()}-${request.url()}`; + const tracked = requests.get(id); + if (tracked) { + tracked.status = 'failed'; + tracked.endTime = Date.now(); + tracked.failureReason = request.failure()?.errorText || 'unknown'; + } + }; + + page.on('request', onRequest); + page.on('response', onResponse); + page.on('requestfailed', onRequestFailed); + + try { + await page.setContent(content, { + waitUntil: ['load', 'networkidle0'], + }); + } catch (error) { + const report = formatNetworkReport(requests); + console.error('setContent failed with error:', (error as Error).message); + console.error(report); + + // Enhance error message with summary + const pending = [...requests.values()].filter((r) => r.status === 'pending'); + const failed = [...requests.values()].filter((r) => r.status === 'failed'); + + const enhancedMessage = [ + (error as Error).message, + `Network: ${pending.length} pending, ${failed.length} failed`, + pending.length > 0 ? `Pending: ${pending.map((r) => r.url).join(', ')}` : '', + failed.length > 0 ? `Failed: ${failed.map((r) => `${r.url} (${r.failureReason})`).join(', ')}` : '', + ] + .filter(Boolean) + .join(' | '); + + const enhancedError = new Error(enhancedMessage); + enhancedError.name = (error as Error).name; + enhancedError.stack = (error as Error).stack; + throw enhancedError; + } finally { + page.off('request', onRequest); + page.off('response', onResponse); + page.off('requestfailed', onRequestFailed); + } } setViewport(opts: {width: number; height: number}) { diff --git a/src/utils/layout/build-pages.ts b/src/utils/layout/build-pages.ts index a72e5a1..79ac9a2 100644 --- a/src/utils/layout/build-pages.ts +++ b/src/utils/layout/build-pages.ts @@ -45,7 +45,16 @@ async function createSectionElement(sectionType: SectionType, setting: SectionSe const buffer = Buffer.from(uint8Array); - const pdf = await PDFDocument.load(buffer); + let pdf; + try { + pdf = await PDFDocument.load(buffer); + } catch (error) { + const err = error as Error; + throw new Error( + `Failed to load ${sectionType} PDF for page ${currentPageNumber}: ${err.message}. ` + + `Dimensions: ${layout.width}x${setting.height}px, buffer size: ${buffer.length} bytes` + ); + } return new SectionElement({ buffer, diff --git a/test/data/actualPdfs/a4-72-multipage.pdf b/test/data/actualPdfs/a4-72-multipage.pdf index 117b73c..9de979b 100644 Binary files a/test/data/actualPdfs/a4-72-multipage.pdf and b/test/data/actualPdfs/a4-72-multipage.pdf differ diff --git a/test/data/actualPdfs/a4-72-standard.pdf b/test/data/actualPdfs/a4-72-standard.pdf index b7b5c74..6b2b4b4 100644 Binary files a/test/data/actualPdfs/a4-72-standard.pdf and b/test/data/actualPdfs/a4-72-standard.pdf differ diff --git a/test/data/actualPdfs/elegant.pdf b/test/data/actualPdfs/elegant.pdf index f88c839..fec54bc 100644 Binary files a/test/data/actualPdfs/elegant.pdf and b/test/data/actualPdfs/elegant.pdf differ diff --git a/test/data/actualPdfs/standard.pdf b/test/data/actualPdfs/standard.pdf index 7d94708..38147b5 100644 Binary files a/test/data/actualPdfs/standard.pdf and b/test/data/actualPdfs/standard.pdf differ diff --git a/test/data/baselinePdfs/a4-72-multipage.pdf b/test/data/baselinePdfs/a4-72-multipage.pdf index 3ee7a3b..c54f73e 100644 Binary files a/test/data/baselinePdfs/a4-72-multipage.pdf and b/test/data/baselinePdfs/a4-72-multipage.pdf differ diff --git a/test/utils/adapter-puppeteer.test.ts b/test/utils/adapter-puppeteer.test.ts index 97513e5..29a6896 100644 --- a/test/utils/adapter-puppeteer.test.ts +++ b/test/utils/adapter-puppeteer.test.ts @@ -219,4 +219,141 @@ describe('HTMLAdapter', () => { expect(mockPage.close).not.toHaveBeenCalled(); }); }); + + describe('setContent with network tracking', () => { + test('falls back to simple setContent when page lacks event methods', async () => { + const adapter = new HTMLAdapter(mockBrowser); + await adapter.newPage(); + // mockPage doesn't have on/off methods by default + + await adapter.setContent(''); + + expect(mockPage.setContent).toHaveBeenCalledWith('', { + waitUntil: ['load', 'networkidle0'], + }); + }); + + test('enriches timeout error with pending request info', async () => { + const listeners: Record void)[]> = {}; + const pageWithEvents = { + ...mockPage, + on: jest.fn((event: string, handler: (...args: unknown[]) => void) => { + listeners[event] = listeners[event] || []; + listeners[event].push(handler); + }), + off: jest.fn((event: string, handler: (...args: unknown[]) => void) => { + if (listeners[event]) { + listeners[event] = listeners[event].filter((h) => h !== handler); + } + }), + setContent: jest.fn(async () => { + // Simulate a request that starts but never completes + const mockRequest = { + method: () => 'GET', + url: () => 'https://example.com/slow-resource.js', + failure: () => null, + }; + listeners['request']?.forEach((h) => h(mockRequest)); + + // Then throw timeout error + throw new Error('Timed out after waiting 30000ms'); + }), + }; + + mockBrowser.newPage = jest.fn().mockResolvedValue(pageWithEvents); + const adapter = new HTMLAdapter(mockBrowser); + await adapter.newPage(); + + await expect(adapter.setContent('')).rejects.toThrow(/Network: 1 pending, 0 failed/); + await expect(adapter.setContent('')).rejects.toThrow(/Pending:.*slow-resource\.js/); + }); + + test('enriches timeout error with failed request info', async () => { + const listeners: Record void)[]> = {}; + const pageWithEvents = { + ...mockPage, + on: jest.fn((event: string, handler: (...args: unknown[]) => void) => { + listeners[event] = listeners[event] || []; + listeners[event].push(handler); + }), + off: jest.fn((event: string, handler: (...args: unknown[]) => void) => { + if (listeners[event]) { + listeners[event] = listeners[event].filter((h) => h !== handler); + } + }), + setContent: jest.fn(async () => { + // Simulate a request that fails + const mockRequest = { + method: () => 'GET', + url: () => 'https://example.com/broken.css', + failure: () => ({errorText: 'net::ERR_CONNECTION_REFUSED'}), + }; + listeners['request']?.forEach((h) => h(mockRequest)); + listeners['requestfailed']?.forEach((h) => h(mockRequest)); + + throw new Error('Timed out after waiting 30000ms'); + }), + }; + + mockBrowser.newPage = jest.fn().mockResolvedValue(pageWithEvents); + const adapter = new HTMLAdapter(mockBrowser); + await adapter.newPage(); + + await expect(adapter.setContent('')).rejects.toThrow(/Network: 0 pending, 1 failed/); + await expect(adapter.setContent('')).rejects.toThrow(/net::ERR_CONNECTION_REFUSED/); + }); + + test('cleans up event listeners after success', async () => { + const listeners: Record void)[]> = {}; + const pageWithEvents = { + ...mockPage, + on: jest.fn((event: string, handler: (...args: unknown[]) => void) => { + listeners[event] = listeners[event] || []; + listeners[event].push(handler); + }), + off: jest.fn((event: string, handler: (...args: unknown[]) => void) => { + if (listeners[event]) { + listeners[event] = listeners[event].filter((h) => h !== handler); + } + }), + setContent: jest.fn().mockResolvedValue(undefined), + }; + + mockBrowser.newPage = jest.fn().mockResolvedValue(pageWithEvents); + const adapter = new HTMLAdapter(mockBrowser); + await adapter.newPage(); + + await adapter.setContent(''); + + expect(pageWithEvents.off).toHaveBeenCalledTimes(3); + expect(pageWithEvents.off).toHaveBeenCalledWith('request', expect.any(Function)); + expect(pageWithEvents.off).toHaveBeenCalledWith('response', expect.any(Function)); + expect(pageWithEvents.off).toHaveBeenCalledWith('requestfailed', expect.any(Function)); + }); + + test('cleans up event listeners after error', async () => { + const listeners: Record void)[]> = {}; + const pageWithEvents = { + ...mockPage, + on: jest.fn((event: string, handler: (...args: unknown[]) => void) => { + listeners[event] = listeners[event] || []; + listeners[event].push(handler); + }), + off: jest.fn((event: string, handler: (...args: unknown[]) => void) => { + if (listeners[event]) { + listeners[event] = listeners[event].filter((h) => h !== handler); + } + }), + setContent: jest.fn().mockRejectedValue(new Error('timeout')), + }; + + mockBrowser.newPage = jest.fn().mockResolvedValue(pageWithEvents); + const adapter = new HTMLAdapter(mockBrowser); + await adapter.newPage(); + + await expect(adapter.setContent('')).rejects.toThrow(); + + expect(pageWithEvents.off).toHaveBeenCalledTimes(3); + }); + }); });