From e9a3c308fc576ce690c37b75267f62c0ca56cf2c Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:48:21 -0400 Subject: [PATCH] feat(skills): add PDF generation skill with native TypeScript implementation (#1718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(skills): add PDF generation skill with native TypeScript implementation - Adds /pdf bundled skill for creating PDF documents from structured content - Pure TypeScript PDF generator (~350 lines) with zero external dependencies - Supports headings, paragraphs, bullet/numbered lists, code blocks, tables, images, HRs, spacers - PDF spec 1.4 compliant with WinAnsiEncoding for common special characters - Text wrapping, font sizing, and page layout handled automatically - Uses embedded files pattern: pdfgen.ts extracted to CLAUDE_SKILL_DIR at runtime - Model writes a TS script using the library, executes via bun run - No binary dependencies, no pdfgen Rust tool, no system packages needed * fix(pdf): address review feedback — escape template interpolations, fix object numbering, remove stub merge/split * fix(pdf): address remaining review feedback — CLAUDE_SKILL_DIR substitution, image support removal, auto-pagination - Remove all ${CLAUDE_SKILL_DIR} references from prompt and getPromptForCommand; bundled skills prepend 'Base directory for this skill: ' instead of substituting this variable. Import examples now use relative './pdfgen'. - Remove unimplemented image support: - Remove { type: 'image' } from PDFElement in both prompt and pdfgen.ts - Remove ImageData interface and all image XObject handling in PDFWriter - Remove basename import (only used by image case) - Remove 'Use relative paths for images' rule from prompt - Add automatic multi-page continuation for overflowing content: - Rename buildPageStream → buildPageStreams (returns PageStreamResult[]) - When y < maxY, flush current page and continue on a new page - Code blocks that don't fit start on a new page - PDFWriter.build creates a separate page object per content stream - Replace el.rows.indexOf(row) with index variable for O(n) table rendering Addresses review feedback from jatmn (round 2): R2-P2 x3 * fix(pdf): address CodeRabbit review — fonts, header/footer, CLI safety - Create 8 distinct font objects (Helvetica/Bold/Oblique/BoldOblique + Courier/Bold/Oblique/BoldOblique) instead of one shared Helvetica. F1..F8 now reference separate objects (3..10) so bold/italic/courier variants actually render correctly. - Remove unused header/footer fields from PDFPage interface in both the prompt and pdfgen.ts. These were silently dropped since buildPageStreams never received them. - Fix CLI --spec mode: outFile is now the last non-flag argument excluding the spec file path, preventing data loss from overwriting the input JSON. Previously args.find() could pick spec.json as outFile. * fix(pdf): address R3 review — absolute import path, table cell wrapping - P2: Replace relative './pdfgen' import with '/pdfgen' placeholder in prompt example and task instructions, instructing the model to save and run scripts from the extracted skill directory - P2: Replace silent .substring(0, 50) truncation with proper text wrapping via wrapText() for table cells, with dynamic row heights based on the tallest cell in each row * fix(pdf): anchor multi-line table cells from row top to prevent downward overflow - Compute cellStartY from row top (y + rowH - 4) instead of row bottom so wrapped text flows downward within the cell boundary * fix(pdf): split table rows that are taller than one page R5-P2: Jatmn review — rows with very long wrapped cells could overflow past the page bottom because pagination only checked once per row. - Pre-compute wrapped lines for all cells in a row up front - Render row in page-sized chunks, tracking linesRendered offset - When a chunk fills the page, flushPage() and continue remaining lines on the next page, drawing per-chunk backgrounds and borders - Cells with fewer lines than the tallest cell simply have no text drawn for the excess lines (no blank-line artefacts) Fixes: Jatmn R5 finding (review 4450066815) * fix(pdf): wrap overlong tokens and preserve WinAnsi characters R6-P2 findings from Jatmn review (4451901963): 1. wrapText() now hard-splits tokens exceeding charsPerLine into chunks, preventing long URLs/IDs/hashes from rendering off-page or off-cell boundary as invisible text. 2. escapePdf() no longer calls toWinAnsi() again. The caller already passes WinAnsi-encoded text; the double-pass was dropping mapped characters (e.g. em-dashes, bullets) because the second pass treated WinAnsi byte values as unsupported Unicode and silently dropped them. * fix(pdf): encode table headers through WinAnsi and wrap long code lines R7-P2 fixes: - Table headers now pass through toWinAnsi() before escapePdf(), matching the body text path. Headers containing em dashes, bullets, euro signs, and other WinAnsi characters now render correctly instead of emitting raw Unicode codepoints into the content stream. - Code block lines are now wrapped to the available page width using the existing wrapText() helper with Courier metrics. Long URLs, hashes, minified lines, and other overlong tokens no longer extend past the page boundary. * fix(pdf): write PDF streams as latin1 bytes * fix(pdf): address remaining review feedback - empty page, table validation, header overflow * fix: resolve PDFElement type, support A3 page size, and enable Windows longpaths for worktrees * fix: address CodeRabbit feedback on PDF skill allowedTools, worktree error propagation, and unit tests * fix: restrict allowedTools for pdf skill and restore worktree files to main * fix(skills): normalize base-dir to forward slashes for Windows import safety Bundled skills receive a prompt prefix "Base directory for this skill: ". On Windows, is a backslash path like C:\Users\...\pdf, and the skill prompt instructs the model to do import { createPDF } from '/pdfgen' Embedding a backslash path into a single-quoted JS string breaks Bun resolution because backslashes are treated as escape characters. Normalize baseDir to forward slashes in prependBaseDir() before building the prefix. Forward-slash paths work cross-platform in TypeScript/Bun import statements, so the model can safely interpolate the path verbatim. Addresses jatmn's review on #1718 (Windows import path). --------- Co-authored-by: SuperDuperZed --- src/skills/bundled/index.ts | 2 + src/skills/bundled/pdf.test.ts | 313 +++++++++++++++ src/skills/bundled/pdf.ts | 714 +++++++++++++++++++++++++++++++++ src/skills/bundledSkills.ts | 8 +- 4 files changed, 1036 insertions(+), 1 deletion(-) create mode 100644 src/skills/bundled/pdf.test.ts create mode 100644 src/skills/bundled/pdf.ts diff --git a/src/skills/bundled/index.ts b/src/skills/bundled/index.ts index a8704d6b5..a436507a6 100644 --- a/src/skills/bundled/index.ts +++ b/src/skills/bundled/index.ts @@ -5,6 +5,7 @@ import { registerClaudeInChromeSkill } from './claudeInChrome.js' import { registerDebugSkill } from './debug.js' import { registerKeybindingsSkill } from './keybindings.js' import { registerLoopSkill } from './loop.js' +import { registerPdfSkill } from './pdf.js' import { registerSimplifySkill } from './simplify.js' import { registerUpdateConfigSkill } from './updateConfig.js' @@ -22,6 +23,7 @@ export function initBundledSkills(): void { registerKeybindingsSkill() registerDebugSkill() registerSimplifySkill() + registerPdfSkill() registerBatchSkill() if (feature('KAIROS') || feature('KAIROS_DREAM')) { /* eslint-disable @typescript-eslint/no-require-imports */ diff --git a/src/skills/bundled/pdf.test.ts b/src/skills/bundled/pdf.test.ts new file mode 100644 index 000000000..4de654e9d --- /dev/null +++ b/src/skills/bundled/pdf.test.ts @@ -0,0 +1,313 @@ +import { afterAll, beforeAll, expect, test } from 'bun:test' +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { randomUUID } from 'node:crypto' + +let pdfgenUrl: string +let pdfgenPath: string + +function isEscaped(source: string, index: number): boolean { + let backslashCount = 0 + for (let i = index - 1; i >= 0 && source[i] === '\\'; i--) { + backslashCount++ + } + return backslashCount % 2 === 1 +} + +function decodeTemplateContent(raw: string): string { + let decoded = '' + + for (let i = 0; i < raw.length; i++) { + if (raw[i] !== '\\' || i === raw.length - 1) { + decoded += raw[i] + continue + } + + const next = raw[++i] + if (next === '`' || next === '$' || next === '\\') { + decoded += next + } else { + decoded += `\\${next}` + } + } + + return decoded +} + +function extractPdfgenSource(source: string): string { + const assignment = 'const PDFGEN_SOURCE = ' + const assignmentStart = source.indexOf(assignment) + + expect(assignmentStart).toBeGreaterThanOrEqual(0) + + const templateStart = assignmentStart + assignment.length + expect(source[templateStart]).toBe('`') + + for (let i = templateStart + 1; i < source.length; i++) { + if (source[i] === '`' && !isEscaped(source, i)) { + return decodeTemplateContent(source.slice(templateStart + 1, i)) + } + } + + throw new Error('PDFGEN_SOURCE template literal was not terminated') +} + +beforeAll(() => { + const source = readFileSync(new URL('./pdf.ts', import.meta.url), 'utf8') + const pdfgenSource = extractPdfgenSource(source) + + pdfgenPath = join(tmpdir(), `openclaude-pdfgen-${randomUUID()}.ts`) + writeFileSync(pdfgenPath, pdfgenSource) + pdfgenUrl = pathToFileURL(pdfgenPath).href +}) + +afterAll(() => { + if (pdfgenPath) rmSync(pdfgenPath, { force: true }) +}) + +async function importPdfgen() { + return import(`${pdfgenUrl}?test=${randomUUID()}`) as Promise<{ + createPDF(opts: unknown): Promise + }> +} + +function expectStreamLengthsToMatch(pdf: Buffer): void { + const text = pdf.toString('latin1') + const streamPattern = /\/Length (\d+) >>\nstream\n/g + let match: RegExpExecArray | null + let count = 0 + + while ((match = streamPattern.exec(text)) !== null) { + count++ + const streamStart = match.index + match[0].length + const streamEnd = text.indexOf('\nendstream', streamStart) + + expect(streamEnd).toBeGreaterThan(streamStart) + expect(streamEnd - streamStart).toBe(Number(match[1])) + } + + expect(count).toBeGreaterThan(0) +} + +test('generated PDF streams use matching WinAnsi byte lengths', async () => { + const { createPDF } = await importPdfgen() + const pdf = await createPDF({ + title: 'Title — €', + author: 'Author •', + pages: [ + { + content: [ + { + type: 'paragraph', + text: 'alpha — beta • gamma €', + }, + { + type: 'table', + headers: ['Header — bullet • euro €'], + rows: [['row — bullet • euro €']], + }, + ], + }, + ], + }) + + expectStreamLengthsToMatch(pdf) + expect(pdf.includes(Buffer.from('alpha \x97 beta \x95 gamma \x80', 'latin1'))).toBe( + true, + ) + expect(pdf.includes(Buffer.from('/Title (Title \x97 \x80)', 'latin1'))).toBe( + true, + ) + expect(pdf.includes(Buffer.from([0xc2, 0x97]))).toBe(false) + expect(pdf.includes(Buffer.from([0xc2, 0x95]))).toBe(false) + expect(pdf.includes(Buffer.from([0xc2, 0x80]))).toBe(false) +}) + +test('default orientation controls generated page media box', async () => { + const { createPDF } = await importPdfgen() + const pdf = await createPDF({ + defaultPageSize: 'A4', + defaultOrientation: 'landscape', + pages: [ + { + content: [{ type: 'paragraph', text: 'landscape default' }], + }, + ], + }) + + expect(pdf.toString('latin1')).toContain('/MediaBox [0 0 842 595]') +}) + +test('bullet list markers are emitted as WinAnsi bullet bytes', async () => { + const { createPDF } = await importPdfgen() + const pdf = await createPDF({ + pages: [ + { + content: [{ type: 'bullet', items: ['hello'] }], + }, + ], + }) + const text = pdf.toString('latin1') + + expect(pdf.includes(Buffer.from('\x95 hello', 'latin1'))).toBe(true) + expect(text).not.toContain('\\2022 hello') +}) + +test('table headers wrap to fit their columns', async () => { + const { createPDF } = await importPdfgen() + const longHeader = 'supercalifragilisticexpialidocious-report-column' + const pdf = await createPDF({ + pages: [ + { + content: [ + { + type: 'table', + headers: [longHeader], + rows: [['value']], + colWidths: [60], + }, + ], + }, + ], + }) + const text = pdf.toString('latin1') + const headerLineCount = text.match(/BT \/F2 9 Tf/g)?.length ?? 0 + + expect(headerLineCount).toBeGreaterThan(1) + expect(text).not.toContain(longHeader) +}) + +test('code block background height follows wrapped lines', async () => { + const { createPDF } = await importPdfgen() + const pdf = await createPDF({ + pages: [ + { + content: [{ type: 'code', text: 'x'.repeat(500) }], + }, + ], + }) + const text = pdf.toString('latin1') + const codeLineCount = text.match(/BT \/F5 9 Tf/g)?.length ?? 0 + const rectMatch = text.match(/0\.92 0\.92 0\.92 rg 50 [-\d.]+ 495 ([\d.]+) re f/) + + expect(codeLineCount).toBeGreaterThan(1) + expect(rectMatch).not.toBeNull() + expect(Number(rectMatch?.[1])).toBeGreaterThan(24.15) +}) + +test('code blocks preserve indentation and repeated spaces', async () => { + const { createPDF } = await importPdfgen() + const pdf = await createPDF({ + pages: [ + { + content: [{ type: 'code', text: 'if (ok) {\n const value = 1\n}' }], + }, + ], + }) + const text = pdf.toString('latin1') + + expect(text).toContain('( const value = 1)') +}) + +test('empty pages or empty content arrays are supported and produce a valid blank page', async () => { + const { createPDF } = await importPdfgen() + const pdf = await createPDF({ + pages: [{ content: [] }] + }) + const text = pdf.toString('latin1') + expect(text).toContain('/Count 1') + expect(text).toContain('/MediaBox') + + await expect(createPDF({ pages: [] })).rejects.toThrow('At least one page is required.') +}) + +test('mismatched table shapes are rejected', async () => { + const { createPDF } = await importPdfgen() + + // Empty headers + await expect(createPDF({ + pages: [{ + content: [{ type: 'table', headers: [], rows: [['1', '2']] }] + }] + })).rejects.toThrow('Table must have at least one header column.') + + // Extra row cells + await expect(createPDF({ + pages: [{ + content: [{ type: 'table', headers: ['A'], rows: [['1', '2']] }] + }] + })).rejects.toThrow('Table row 0 cell count (2) does not match headers count (1).') + + // Mismatching colWidths length + await expect(createPDF({ + pages: [{ + content: [{ type: 'table', headers: ['A', 'B'], rows: [['1', '2']], colWidths: [10] }] + }] + })).rejects.toThrow('Table colWidths length (1) does not match headers length (2).') +}) + +test('table headers too tall for page are rejected', async () => { + const { createPDF } = await importPdfgen() + + // Custom margin that leaves very little room, with many wrapped header lines + await expect(createPDF({ + pages: [{ + margins: { top: 700, bottom: 100, left: 50, right: 50 }, + pageSize: 'A4', // height is 842, printable is 842 - 700 - 100 - 30 = 12pt + content: [{ + type: 'table', + headers: ['extremely long header text that wraps into multiple lines'], + rows: [['value']], + colWidths: [40] + }] + }] + })).rejects.toThrow('Table headers are too tall to fit on a single page.') +}) + +test('table rows flush before rendering below the bottom margin', async () => { + const { createPDF } = await importPdfgen() + const pdf = await createPDF({ + pages: [ + { + margins: { top: 50, right: 50, bottom: 50, left: 50 }, + content: [ + { type: 'spacer', height: 685 }, + { type: 'table', headers: ['A'], rows: [['value']] }, + ], + }, + ], + }) + const text = pdf.toString('latin1') + const rowMatch = text.match(/BT \/F1 9 Tf 54 ([\d.]+) Td \(value\) Tj ET/) + + expect(text).toContain('/Count 2') + expect(rowMatch).not.toBeNull() + expect(Number(rowMatch?.[1])).toBeGreaterThan(80) +}) + +test('importing pdfgen as a library does not run the CLI writer', async () => { + const testId = randomUUID() + const specPath = join(tmpdir(), `openclaude-pdf-spec-${testId}.json`) + const outPath = join(tmpdir(), `openclaude-pdf-output-${testId}.pdf`) + const originalArgv = process.argv + + writeFileSync( + specPath, + JSON.stringify({ + pages: [{ content: [{ type: 'paragraph', text: 'library import only' }] }], + }), + ) + + try { + // import.meta.main is the guard under test; argv makes regressions visible. + process.argv = ['bun', 'consumer.ts', '--spec', specPath, outPath] + await importPdfgen() + expect(existsSync(outPath)).toBe(false) + } finally { + process.argv = originalArgv + rmSync(specPath, { force: true }) + rmSync(outPath, { force: true }) + } +}) diff --git a/src/skills/bundled/pdf.ts b/src/skills/bundled/pdf.ts new file mode 100644 index 000000000..1cd2209a1 --- /dev/null +++ b/src/skills/bundled/pdf.ts @@ -0,0 +1,714 @@ +import { registerBundledSkill } from '../bundledSkills.js' + +const PDF_SKILL_PROMPT = `# PDF Generation Skill + +Generate PDF files entirely in TypeScript — no external binaries or system dependencies required. + +## How It Works + +When the user asks you to create a PDF, you will: + +1. **Write a TypeScript script** that uses the bundled PDF generation library (\`pdfgen.ts\` in the base directory for this skill, shown above) +2. **Execute it** via \`bun run