This document provides a detailed technical overview of how docs-to-pdf generates PDFs from documentation websites, including HTML extraction with Puppeteer, PDF creation, and PDF manipulation with bookmarks/outlines.
- Overview
- Architecture Diagram
- Phase 1: HTML Extraction with Puppeteer
- Phase 2: HTML Processing and Restructuring
- Phase 3: PDF Generation
- Phase 4: PDF Manipulation and Outline Injection
- Key Components
- Data Flow
- Technical Details
The docs-to-pdf tool converts documentation websites (particularly Docusaurus sites) into PDF files with full support for navigation through bookmarks/outlines. The process involves four main phases:
- HTML Extraction: Using Puppeteer to crawl and extract content
- HTML Processing: Restructuring and combining content from multiple pages
- PDF Generation: Converting HTML to PDF using Chrome's PDF engine
- PDF Manipulation: Adding bookmarks/outlines using pdf-lib
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β PHASE 1: HTML EXTRACTION β
β (Puppeteer) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β Documentation Website(s) β
β β β
β ββββββββββββββββββββ β
β β Launch Browser β β Puppeteer with Chrome/Chromium β
β ββββββββββ¬ββββββββββ β
β β β
β ββββββββββββββββββββ β
β β Navigate to URLs β β Process initialDocURLs β
β ββββββββββ¬ββββββββββ β
β β β
β ββββββββββββββββββββ β
β β Extract Content β β contentSelector, excludeSelectors β
β ββββββββββ¬ββββββββββ β
β β β
β ββββββββββββββββββββ β
β β Follow Paginationβ β paginationSelector β
β ββββββββββ¬ββββββββββ β
β β β
β ββββββββββββββββββββ β
β β Open <details> β β Expand collapsible elements β
β ββββββββββ¬ββββββββββ β
β β β
β ββββββββββββββββββββ β
β β Extract iframes β β Optional iframe content extraction β
β ββββββββββ¬ββββββββββ β
β β β
β HTML Content (String) β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β PHASE 2: HTML PROCESSING & RESTRUCTURING β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β HTML Content (from Phase 1) β
β β β
β ββββββββββββββββββββ β
β β Generate Cover β β coverTitle, coverImage, coverSub β
β ββββββββββ¬ββββββββββ β
β β β
β ββββββββββββββββββββ β
β β Extract Headers β β Parse h1-h6 tags β
β ββββββββββ¬ββββββββββ β
β β β
β ββββββββββββββββββββ β
β β Generate TOC β β Create table of contents β
β ββββββββββ¬ββββββββββ β
β β β
β ββββββββββββββββββββ β
β β Assign Header IDsβ β Ensure all headers have unique IDs β
β ββββββββββ¬ββββββββββ β
β β β
β ββββββββββββββββββββ β
β β Combine HTML β β Cover + TOC + Content β
β ββββββββββ¬ββββββββββ β
β β β
β ββββββββββββββββββββ β
β β Inject into Page β β Load combined HTML into Puppeteer page β
β ββββββββββ¬ββββββββββ β
β β β
β ββββββββββββββββββββ β
β β Apply CSS Styles β β cssStyle option β
β ββββββββββ¬ββββββββββ β
β β β
β ββββββββββββββββββββ β
β β Scroll to Bottom β β Force lazy-loaded images to load β
β ββββββββββ¬ββββββββββ β
β β β
β Fully Rendered HTML Page β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β PHASE 3: PDF GENERATION β
β (Puppeteer + Chrome PDF Engine) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β Fully Rendered HTML Page (from Phase 2) β
β β β
β ββββββββββββββββββββ β
β β Get Page β β Calculate document dimensions β
β β Dimensions β (width, height in pixels) β
β ββββββββββ¬ββββββββββ β
β β β
β ββββββββββββββββββββ β
β β Extract Outline β β Parse h1-h6 with positions β
β β Structure β (getOutline function) β
β ββββββββββ¬ββββββββββ β
β β β
β β Returns: OutlineNode[] β
β β - title: heading text β
β β - destination: heading ID β
β β - yPosition: Y coordinate from top (pixels) β
β β - children: nested headings β
β β - depth: heading level (h1=0, h2=1, etc.) β
β β β
β ββββββββββββββββββββ β
β β Call page.pdf() β β Chrome's Print to PDF β
β β β Options: β
β β β - paperFormat (A4, Letter, etc.) β
β β β - margins β
β β β - headerTemplate/footerTemplate β
β β β - printBackground: true β
β ββββββββββ¬ββββββββββ β
β β β
β PDF Buffer (Uint8Array) β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β PHASE 4: PDF MANIPULATION & OUTLINE INJECTION β
β (pdf-lib) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β PDF Buffer + Outline Structure (from Phase 3) β
β β β
β ββββββββββββββββββββ β
β β Load PDF with β β PDFDocument.load(buffer) β
β β pdf-lib β β
β ββββββββββ¬ββββββββββ β
β β β
β ββββββββββββββββββββ β
β β Get PDF Page β β pdfDoc.getPage(0) β
β β Dimensions β (height in points for calculations) β
β ββββββββββ¬ββββββββββ β
β β β
β ββββββββββββββββββββ β
β β Map HTML β β Convert pixel coordinates to PDF points β
β β Coordinates to β β
β β PDF Points β Formula: β
β β β pageIndex = floor(yPixels / docHeight β
β β β * pdfPageCount) β
β β β yPoints = pdfPageHeight - β
β β β (yPixels % pageHeight) * β
β β β (pdfPageHeight / pageHeight) β
β ββββββββββ¬ββββββββββ β
β β β
β ββββββββββββββββββββ β
β β Build Outline β β Create PDF outline objects β
β β Objects β - PDFRef for each outline item β
β β β - Parent/child relationships β
β β β - Explicit destinations [page, /XYZ, x, y] β
β ββββββββββ¬ββββββββββ β
β β β
β ββββββββββββββββββββ β
β β Add Outline to β β pdfDoc.catalog.set('Outlines', ...) β
β β PDF Catalog β β
β ββββββββββ¬ββββββββββ β
β β β
β ββββββββββββββββββββ β
β β Save PDF β β pdfDoc.save() returns buffer β
β ββββββββββ¬ββββββββββ β
β β β
β ββββββββββββββββββββ β
β β Write to File β β fs.writeFile(filename, buffer) β
β ββββββββββ¬ββββββββββ β
β β β
β Final PDF with Bookmarks β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
The first phase uses Puppeteer (a headless Chrome/Chromium automation library) to extract HTML content from documentation websites.
-
Browser Launch
const browser = await puppeteer.launch({ headless: true, executablePath: chromeExecPath(), args: puppeteerArgs, protocolTimeout: protocolTimeout, });
-
Page Navigation and Content Extraction
- Iterate through
initialDocURLs - Follow pagination links using
paginationSelector - Extract content using
contentSelector - Track visited URLs to prevent infinite loops
- Iterate through
-
Content Processing
// Open collapsible <details> elements await utils.openDetails(page); // Extract HTML content (optionally including iframes) contentHTML += await utils.getHtmlContent( page, contentSelector, extractIframes );
-
Special Handling
- HTTP Basic Auth: Authenticate using
page.authenticate() - Request Interception: Block PDF files to prevent navigation issues
- Details Elements: Automatically expand
<details>tags - Iframe Extraction: Optionally extract same-origin iframe content
- HTTP Basic Auth: Authenticate using
Pages are filtered based on:
excludeURLs: Explicit URL exclusion listfilterKeyword: Meta keywords filteringexcludePaths: Path-based exclusionrestrictPaths: Limit to specific path patterns
const visitedURLs = new Set<string>();
if (visitedURLs.has(nextPageURL)) {
console.log('Skipping already visited URL (circular pagination detected)');
break;
}
visitedURLs.add(nextPageURL);After extracting HTML from all pages, the content is restructured into a single document.
-
Cover Generation
const coverImageHtml = generateImageHtml(image.base64, image.type); const coverHTML = generateCoverHtml(coverTitle, coverImageHtml, coverSub);
-
Table of Contents (TOC) Generation
const { modifiedContentHTML, tocHTML } = generateToc(contentHTML, { tocTitle, maxLevel: 4, // Default: h1 to h4 });
- Parses all headers (h1-h6)
- Generates unique IDs for headers without IDs
- Creates hierarchical TOC HTML
- Links TOC entries to headers via anchor tags
-
Header ID Assignment
function replaceHeader(match: string): string { if (match.includes('id=')) { // Replace existing ID return match.replace(/id="[^"]*"/, `id="${newId}"`); } else { // Add new ID return match.replace('>', ` id="${newId}">`); } }
-
HTML Combination
await page.evaluate( concatHtml, coverHTML, tocHTML, modifiedContentHTML, disableTOC, disableCover, baseUrl );
- Injects combined HTML into the page's DOM
- Adds base URL if specified
- Optionally excludes cover or TOC
-
CSS Application
await page.addStyleTag({ content: cssStyle });
-
Image Loading
await scrollPageToBottom(page, {});
- Scrolls to bottom to trigger lazy-loaded images
This phase converts the rendered HTML page into a PDF using Chrome's built-in PDF rendering engine.
-
Get Page Dimensions
const pageDimensions = await page.evaluate(() => { return { width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight, }; });
- Captures full document dimensions in pixels
- Used later for coordinate mapping
-
Extract Outline Structure
const outline = await getOutline(page, [ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' ]);
The
getOutlinefunction:- Runs in browser context via
page.evaluate() - Queries all heading elements
- Captures for each heading:
title: Inner text of the headingdestination: The heading's ID attribute (encoded)yPosition: Y coordinate from document top (in pixels)depth: Heading level (h1=0, h2=1, etc.)
- Builds hierarchical tree structure matching heading nesting
Example outline structure:
[ { title: "Introduction", destination: "introduction", yPosition: 150, depth: 0, children: [ { title: "Getting Started", destination: "getting-started", yPosition: 300, depth: 1, children: [] } ] } ]
- Runs in browser context via
-
Generate PDF
const pdf = await page.pdf({ path: outputPDFFilename, format: paperFormat, margin: pdfMargin, headerTemplate: headerTemplate, footerTemplate: footerTemplate, displayHeaderFooter: !!(headerTemplate || footerTemplate), printBackground: true, timeout: 0, });
- Uses Chrome's print-to-PDF functionality
- Returns PDF as a
Uint8Arraybuffer - Supports custom paper formats (A4, Letter, Legal, etc.)
- Includes headers/footers if provided
Contains utility functions for outline extraction and manipulation.
Executed in browser context to extract heading structure:
await page.evaluate((tags, outlineSelector) => {
// Query all heading elements
const tagsToProcess = Array.from(
document.querySelectorAll(outlineSelector)
).reverse();
// Build hierarchical tree
const root = { children: [], depth: -1 };
let currentOutlineNode = root;
while (tagsToProcess.length > 0) {
const tag = tagsToProcess.pop();
const orderDepth = tags.indexOf(tag.tagName.toLowerCase());
// Get Y position from top of document
const rect = tag.getBoundingClientRect();
const yPosition = window.scrollY + rect.top;
// Create node and manage hierarchy
const newNode = {
title: tag.innerText.trim(),
destination: encodeURIComponent(tag.id),
yPosition: yPosition,
children: [],
depth: orderDepth,
};
// Add to appropriate parent based on depth
// ...
}
return root.children;
}, tags, selectors);This phase uses pdf-lib to manipulate the PDF and inject bookmark/outline metadata.
-
Load PDF
const pdfDoc = await PDFDocument.load(pdf);
- Parses the PDF buffer into a modifiable document
-
Get PDF Page Dimensions
const pdfPage = pdfDoc.getPage(0); const pdfPageHeight = pdfPage.getHeight();
- Gets page height in points (1 point = 1/72 inch)
- Assumes all pages have same dimensions
-
Coordinate Mapping
The most complex part: mapping HTML pixel coordinates to PDF page coordinates.
Problem:
- HTML document is measured in pixels from top (Y increases downward)
- PDF pages are measured in points from bottom (Y increases upward)
- HTML is one continuous scroll, PDF has discrete pages
Solution:
// Calculate which PDF page this heading is on const pageIndex = Math.floor( (item.yPosition / documentHeightInPixels) * pdfDoc.getPageCount() ); // Calculate Y position within that page const pageLocalYPixels = item.yPosition % documentHeightInPixels; const yPositionInPoints = pdfPageHeightInPoints - pageLocalYPixels * (pdfPageHeightInPoints / documentHeightInPixels);
Limitations:
- Assumes uniform content distribution across pages
- May be inaccurate for complex layouts with page breaks
- Works well for continuous documentation content
-
Create PDF References
function addRefsForOutlineItems( outlines: OutlineNode[], context: PDFContext, parentRef: PDFRef ): OutlineRef[] { return outlines.map((item) => { const itemRef = context.nextRef(); return { ...item, ref: itemRef, parentRef, children: addRefsForOutlineItems(item.children, context, itemRef), }; }); }
- Recursively assigns PDF references to each outline item
- Maintains parent-child relationships
-
Build PDF Outline Objects
function buildPdfObjectsForOutline( outlinesWithRef: OutlineRef[], context: PDFContext, pdfDoc: PDFDocument, pageHeightInPixels: number, pdfPageHeightInPoints: number ) { for (const [i, item] of outlinesWithRef.entries()) { const pdfObject: DictMap = new Map(); // Set title pdfObject.set( PDFName.of('Title'), PDFHexString.fromText(decode(item.title)) ); // Create explicit destination const page = pdfDoc.getPage(pageIndex); const pageRef = page.ref; const destArray = PDFArray.withContext(context); destArray.push(pageRef); // Target page destArray.push(PDFName.of('XYZ')); // Destination type destArray.push(PDFNumber.of(0)); // Left (X) destArray.push(PDFNumber.of(yPositionInPoints)); // Top (Y) destArray.push(PDFNumber.of(0)); // Zoom (0 = keep current) pdfObject.set(PDFName.of('Dest'), destArray); pdfObject.set(PDFName.of('Parent'), item.parentRef); // Set previous/next siblings if (prev) pdfObject.set(PDFName.of('Prev'), prev.ref); if (next) pdfObject.set(PDFName.of('Next'), next.ref); // Set first/last children if (item.children.length > 0) { pdfObject.set(PDFName.of('First'), item.children[0].ref); pdfObject.set(PDFName.of('Last'), item.children[...].ref); pdfObject.set(PDFName.of('Count'), PDFNumber.of(childCount)); } context.assign(item.ref, PDFDict.fromMapWithContext(pdfObject, context)); // Recursively process children buildPdfObjectsForOutline(item.children, ...); } }
-
Add Outline to PDF Catalog
const outlineObject: DictMap = new Map(); outlineObject.set(PDFName.of('Type'), PDFName.of('Outlines')); outlineObject.set(PDFName.of('First'), outlinesWithRef[0].ref); outlineObject.set(PDFName.of('Last'), outlinesWithRef[last].ref); outlineObject.set(PDFName.of('Count'), PDFNumber.of(totalCount)); pdfDoc.context.assign(rootOutlineRef, PDFDict.fromMapWithContext(...)); pdfDoc.catalog.set(PDFName.of('Outlines'), rootOutlineRef);
-
Save and Write PDF
const buffer = await pdfDoc.save(); await writeFile(outputPDFFilename, buffer);
The generated PDF follows the PDF specification for outlines:
PDF Document
βββ Catalog
β βββ Pages
β β βββ Page 1
β β βββ Page 2
β β βββ ...
β βββ Outlines (Root)
β βββ First β OutlineItem 1
β βββ Last β OutlineItem N
β βββ Count β Total items
β
βββ Outline Items
βββ OutlineItem 1
β βββ Title: "Introduction"
β βββ Dest: [Page 1, /XYZ, 0, 500, 0]
β βββ Parent: Root
β βββ Next: OutlineItem 2
β βββ First: OutlineItem 1.1
β βββ Count: 2 (children)
β
βββ OutlineItem 1.1
β βββ Title: "Getting Started"
β βββ Dest: [Page 1, /XYZ, 0, 350, 0]
β βββ Parent: OutlineItem 1
β βββ Next: OutlineItem 1.2
β
βββ ...
Entry point for PDF generation:
- Orchestrates all phases
- Manages browser lifecycle
- Handles errors and cleanup
HTML processing utilities:
getHtmlContent(): Extract content from pagegenerateToc(): Create table of contentsopenDetails(): Expand collapsible elementsconcatHtml(): Combine HTML sections
PDF generation coordination:
PDFclass: Main PDF generation interface- Bridges Puppeteer and pdf-lib
Outline/bookmark management:
getOutline(): Extract heading structure from HTMLsetOutline(): Inject outline into PDFformatOutlineContainerSelector(): Selector utilities
Docusaurus-specific logic:
- Version detection
- Build directory handling
- Server management for local builds
User Command (CLI)
β
generatePDF(options)
β
βββββββββββββββββββββββββββββββββββββββββ
β Launch Puppeteer Browser β
β - Chrome/Chromium β
β - Headless mode β
βββββββββββββ¬ββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββ
β For each initialDocURL: β
β βββββββββββββββββββββββββββββββββββ β
β β Navigate to URL β β
β β Extract content (contentSelector)β β
β β Follow pagination β β
β β Accumulate HTML β β
β βββββββββββββββββββββββββββββββββββ β
βββββββββββββ¬ββββββββββββββββββββββββββββ
β
contentHTML (accumulated)
β
βββββββββββββββββββββββββββββββββββββββββ
β Generate Cover β
β Generate TOC from headers β
β Assign IDs to headers β
β Combine: cover + TOC + content β
βββββββββββββ¬ββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββ
β Inject combined HTML into page β
β Apply CSS styles β
β Scroll to load images β
βββββββββββββ¬ββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββ
β PDF.generate(page) β
β βββββββββββββββββββββββββββββββββββ β
β β Get page dimensions β β
β β Extract outline (getOutline) β β
β β Call page.pdf() β β
β β Load PDF with pdf-lib β β
β β Add outline (setOutline) β β
β β Save PDF β β
β βββββββββββββββββββββββββββββββββββ β
βββββββββββββ¬ββββββββββββββββββββββββββββ
β
Final PDF File with Bookmarks
{
headless: true, // Run without GUI
executablePath: chromeExecPath(), // Chrome/Chromium path
args: puppeteerArgs, // Custom Chrome flags
protocolTimeout: protocolTimeout, // DevTools Protocol timeout
}{
path: outputPDFFilename, // Output file path
format: paperFormat, // A4, Letter, Legal, etc.
margin: pdfMargin, // { top, right, bottom, left }
headerTemplate: '...', // HTML template for header
footerTemplate: '...', // HTML template for footer
displayHeaderFooter: true, // Enable headers/footers
printBackground: true, // Include background colors/images
timeout: 0, // Disable timeout
}interface OutlineNode {
title: string; // Heading text
destination: string; // Heading ID (URL encoded)
yPosition: number; // Y coordinate in pixels
children: OutlineNode[]; // Nested headings
depth: number; // Heading level (0-5 for h1-h6)
parent?: OutlineNode; // Parent node reference
italic?: boolean; // Text styling
bold?: boolean; // Text styling
color?: number[]; // RGB color [0-1, 0-1, 0-1]
}HTML Coordinates β PDF Coordinates
-
Page Index Calculation:
pageIndex = floor((yPixels / documentHeightPixels) * pageCount) -
Y Position Calculation:
pageLocalYPixels = yPixels % documentHeightPixels yPoints = pdfPageHeight - (pageLocalYPixels * (pdfPageHeight / pageHeightPixels)) -
Coordinate Systems:
- HTML: Origin at top-left, Y increases downward, measured in pixels
- PDF: Origin at bottom-left, Y increases upward, measured in points
Explicit destinations use the /XYZ format:
[pageRef, /XYZ, left, top, zoom]
pageRef: Reference to target page/XYZ: Destination type (explicit coordinates)left: X coordinate (0 = left margin)top: Y coordinate from bottomzoom: Zoom level (0 = retain current zoom)
- puppeteer / puppeteer-core: Headless browser automation
- pdf-lib: PDF manipulation and outline injection
- html-entities: HTML entity decoding for special characters
- chalk: Colored console output
- fs-extra: Enhanced file system operations
-
Puppeteer:
- Industry-standard for browser automation
- Direct access to Chrome's PDF rendering engine
- Full control over page rendering and JavaScript execution
-
pdf-lib:
- Pure JavaScript PDF manipulation
- No external dependencies
- Full PDF specification support
- Can modify existing PDFs (crucial for outline injection)
-
html-entities:
- Properly decodes HTML entities in heading text
- Ensures bookmark titles display correctly
- Large documentation sites can require significant memory
- Chrome process memory scales with page size
- pdf-lib loads entire PDF into memory
- Incremental Processing: Process pages one at a time
- Browser Cleanup: Close browser after PDF generation
- Image Loading: Only load visible images initially
- Cleanup: Remove temporary Chrome data directories
Typical generation times:
- Small site (< 10 pages): 10-30 seconds
- Medium site (10-50 pages): 1-3 minutes
- Large site (> 50 pages): 3-10+ minutes
try {
await browser.close();
} finally {
// Always cleanup temp directory
if (chromeTmpDataDir) {
fs.removeSync(chromeTmpDataDir);
}
}const pdf = await page.pdf(options).catch((err) => {
console.error(chalk.red(err));
throw err; // Preserve stack trace
});- Gracefully handles missing destinations
- Warns about inaccessible heading IDs
- Returns unmodified PDF if outline is empty
The current page index calculation assumes uniform content distribution:
const pageIndex = Math.floor(
(item.yPosition / pageHeightInPixels) * pdfDoc.getPageCount()
);Potential Improvements:
- Capture actual page break positions during PDF generation
- Use Chrome DevTools Protocol to query page boundaries
- Add integration tests with multi-page documents
- Implement fallback strategies for complex layouts
- Stream PDF generation instead of loading entire document
- Parallel page processing where possible
- Caching of unchanged pages
- Optional Outline: Add
disableOutlineoption - Custom Outline Depth: Allow limiting bookmark depth
- Outline Styling: Support bold/italic/colors in bookmarks
- Progress Reporting: Real-time progress updates for large sites
The outline generation code is adapted from asciidoctor-web-pdf by Guillaume Grossetie, licensed under the MIT License.