Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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 `<document-page>` 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:
- `<document-page>` - Container for a set of physical PDF pages (supports `format`, `size`, `ppi` attributes)
- `<page-body>` - Main content area (spans multiple pages if needed)
- `<page-header>`, `<page-footer>`, `<page-background>` - Repeating sections
- `<physical-page select="first|last|even|odd">` - Conditional content within repeating sections
- `<current-page-number>`, `<total-pages-number>` - 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)
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
22 changes: 21 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
11 changes: 10 additions & 1 deletion src/models/document-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
24 changes: 20 additions & 4 deletions src/models/element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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})`
);
}
}
}

Expand Down Expand Up @@ -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})`
);
}
}
}
140 changes: 135 additions & 5 deletions src/utils/adapter-puppeteer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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, TrackedRequest>): 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;
Expand Down Expand Up @@ -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<string, TrackedRequest>();

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}) {
Expand Down
11 changes: 10 additions & 1 deletion src/utils/layout/build-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Binary file modified test/data/actualPdfs/a4-72-multipage.pdf
Binary file not shown.
Binary file modified test/data/actualPdfs/a4-72-standard.pdf
Binary file not shown.
Binary file modified test/data/actualPdfs/elegant.pdf
Binary file not shown.
Binary file modified test/data/actualPdfs/standard.pdf
Binary file not shown.
Binary file modified test/data/baselinePdfs/a4-72-multipage.pdf
Binary file not shown.
Loading