feat(skills): add PDF generation skill with native TypeScript implementation (#1718)

* 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: <dir>' 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 '<skill-base-dir>/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:
<baseDir>". On Windows, <baseDir> is a backslash path like
C:\Users\...\pdf, and the skill prompt instructs the model to do
import { createPDF } from '<skill-base-dir>/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 <superduperzed@users.noreply.github.com>
This commit is contained in:
euxaristia
2026-07-07 10:48:21 +08:00
committed by GitHub
co-authored by SuperDuperZed
parent 214ee3dd2e
commit e9a3c308fc
4 changed files with 1036 additions and 1 deletions
+2
View File
@@ -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 */
+313
View File
@@ -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<Buffer>
}>
}
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 })
}
})
+714
View File
@@ -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 <script>.ts\`
The \`pdfgen.ts\` library provides these functions:
### \`createPDF(options): Promise<Buffer>\`
Creates a PDF from structured content and returns the raw bytes.
**Options:**
\`\`\`ts
interface PDFPage {
content: PDFElement[]
pageSize?: 'A4' | 'Letter' | 'A3'
orientation?: 'portrait' | 'landscape'
margins?: { top: number; right: number; bottom: number; left: number } // in points (72 = 1 inch)
}
type PDFElement =
| { type: 'heading'; text: string; level: 1 | 2 | 3 }
| { type: 'paragraph'; text: string; align?: 'left' | 'center' | 'right' }
| { type: 'bullet'; items: string[] }
| { type: 'numberedList'; items: string[] }
| { type: 'code'; text: string; language?: string }
| { type: 'hr' }
| { type: 'spacer'; height?: number } // points
| { type: 'table'; headers: string[]; rows: string[][]; colWidths?: number[] }
interface PDFCreateOptions {
title?: string
author?: string
pages: PDFPage[]
defaultPageSize?: 'A4' | 'Letter' | 'A3'
defaultOrientation?: 'portrait' | 'landscape'
defaultMargins?: { top: number; right: number; bottom: number; left: number }
}
\`\`\`
## Example Workflow
\`\`\`typescript
import { createPDF } from '<skill-base-dir>/pdfgen'
import { writeFileSync } from 'fs'
const pdf = await createPDF({
title: 'My Report',
author: 'Claude',
pages: [{
content: [
{ type: 'heading', text: 'Q4 Revenue Report', level: 1 },
{ type: 'spacer', height: 12 },
{ type: 'paragraph', text: 'This report covers financial performance for Q4 2025.' },
{ type: 'heading', text: 'Summary', level: 2 },
{ type: 'table', headers: ['Metric', 'Value', 'Change'],
rows: [['Revenue', '$4.2M', '+12%'], ['Users', '1.8M', '+8%'], ['NPS', '72', '+5']] },
]
}]
})
writeFileSync('report.pdf', pdf)
\`\`\`
## Important Rules
- ALWAYS write a standalone \`.ts\` script and run it with \`bun\`\`, never try to manually construct PDF bytes
- The pdfgen library handles all PDF spec compliance — you only provide structured content
- Content that overflows a single page is automatically continued onto additional pages
- For markdown input, parse it into PDFElement objects rather than passing raw markdown
- Default margins are 50pt (about 0.7 inches) on all sides if not specified
- Default page size is A4
- All text rendering uses built-in Helvetica font variants (Regular, Bold, Italic, BoldItalic)
- Special characters like bullets (\\u2022), em-dashes (\\u2014), and common symbols are supported via WinAnsiEncoding
- When importing \`pdfgen.ts\`, use the absolute base directory path shown above (replace \`<skill-base-dir>\` with the actual path). Do NOT use a relative \`./pdfgen\` import — the library lives in the extracted skill directory, not next to your script
`
export function registerPdfSkill(): void {
registerBundledSkill({
name: 'pdf',
description:
'Generate PDF documents from structured content. Create reports, formatted documents, tables, and more.',
whenToUse:
'Use when the user wants to create, generate, build, or produce a PDF document.',
argumentHint: '<description of PDF to generate>',
userInvocable: true,
allowedTools: ['Bash(bun *)', 'Read', 'Write'],
files: {
'pdfgen.ts': PDFGEN_SOURCE,
},
async getPromptForCommand(args) {
let prompt = PDF_SKILL_PROMPT
if (args) {
prompt += '\n\n## User Request\n\n' + args
prompt +=
'\n\n## Task\n\nWrite a TypeScript script that imports from the pdfgen.ts file using the absolute base directory path shown above (replace `<skill-base-dir>` with the actual extracted path). Save the script in a temporary location or the workspace (not in the skill directory itself), run it with bun, and report the output path.'
}
return [{ type: 'text', text: prompt }]
},
})
}
// ─── Minimal PDF generator in pure TypeScript ───
// No external dependencies. Generates valid PDF 1.4.
const PDFGEN_SOURCE = `// pdfgen.ts — Pure TypeScript PDF generation (PDF 1.4)
// No external dependencies. Supports automatic multi-page continuation.
import { readFileSync, writeFileSync } from 'fs'
// ─── Types ───
export interface PDFPage {
content: PDFElement[]
pageSize?: 'A4' | 'Letter' | 'A3'
orientation?: 'portrait' | 'landscape'
margins?: { top: number; right: number; bottom: number; left: number }
}
export interface PDFCreateOptions {
title?: string
author?: string
pages: PDFPage[]
defaultPageSize?: 'A4' | 'Letter' | 'A3'
defaultOrientation?: 'portrait' | 'landscape'
defaultMargins?: { top: number; right: number; bottom: number; left: number }
}
export type PDFElement =
| { type: 'heading'; text: string; level: 1 | 2 | 3 }
| { type: 'paragraph'; text: string; align?: 'left' | 'center' | 'right' }
| { type: 'bullet'; items: string[] }
| { type: 'numberedList'; items: string[] }
| { type: 'code'; text: string; language?: string }
| { type: 'hr' }
| { type: 'spacer'; height?: number }
| { type: 'table'; headers: string[]; rows: string[][]; colWidths?: number[] }
// ─── Constants ───
const PAGE_SIZES: Record<string, [number, number]> = {
A4: [595, 842],
Letter: [612, 792],
A3: [842, 1191],
}
const DEFAULT_MARGINS = { top: 50, right: 50, bottom: 50, left: 50 }
const FONTS = {
'Helvetica': 'F1',
'Helvetica-Bold': 'F2',
'Helvetica-Oblique': 'F3',
'Helvetica-BoldOblique': 'F4',
'Courier': 'F5',
'Courier-Bold': 'F6',
'Courier-Oblique': 'F7',
'Courier-BoldOblique': 'F8',
} as const
const FONT_SIZES: Record<number, number> = { 1: 20, 2: 15, 3: 12 }
const LINE_HEIGHT = 1.35
const CODE_FONT_SIZE = 9
const PDF_BYTE_ENCODING = 'latin1'
function pdfBytes(text: string): Buffer {
return Buffer.from(text, PDF_BYTE_ENCODING)
}
// ─── WinAnsi helpers ───
function toWinAnsi(text: string): string {
const map: Record<number, number> = {
0x2013: 0x96, 0x2014: 0x97, 0x2018: 0x91, 0x2019: 0x92,
0x201c: 0x93, 0x201d: 0x94, 0x2022: 0x95, 0x2026: 0x85,
0x201a: 0x82, 0x201e: 0x84, 0x2030: 0x89, 0x2039: 0x8b,
0x203a: 0x9b, 0x2032: 0x92, 0x2033: 0x94,
0x00a0: 0xa0, 0x00a1: 0xa1, 0x00a2: 0xa2, 0x00a3: 0xa3,
0x20ac: 0x80, 0x0160: 0x8a, 0x0161: 0x9a, 0x0178: 0x9f,
0x017d: 0x8e, 0x017e: 0x9e,
}
let out = ''
for (let i = 0; i < text.length; i++) {
const code = text.charCodeAt(i)
if (code >= 32 && code <= 126) {
out += text[i]
} else if (code === 10) {
out += '\\n'
} else if (map[code] !== undefined) {
out += String.fromCharCode(map[code])
}
// skip unsupported characters
}
return out
}
function escapePdf(str: string): string {
// str is already WinAnsi-encoded; only escape PDF reserved chars
return str
.replace(/\\\\/g, '\\\\\\\\')
.replace(/\\(/g, '\\\\(')
.replace(/\\)/g, '\\\\)')
}
// ─── PDF text measurement (approximate) ───
function measureText(text: string, fontSize: number, font: string = 'Helvetica'): number {
// Helvetica average char width is ~0.52 * fontSize
const charWidth = font.startsWith('Courier') ? 0.6 * fontSize : 0.52 * fontSize
let maxLine = 0
for (const line of text.split('\\n')) {
const w = line.length * charWidth
if (w > maxLine) maxLine = w
}
return maxLine
}
function wrapText(text: string, maxWidth: number, fontSize: number, font: string = 'Helvetica'): string[] {
const charWidth = font.startsWith('Courier') ? 0.6 * fontSize : 0.52 * fontSize
const charsPerLine = Math.max(1, Math.floor(maxWidth / charWidth))
const words = text.split(/\\s+/)
const lines: string[] = []
let current = ''
for (const word of words) {
if (!current) {
current = word
} else if ((current + ' ' + word).length > charsPerLine) {
lines.push(current)
current = word
} else {
current += ' ' + word
}
}
if (current) lines.push(current)
// Hard-split any line that still exceeds charsPerLine (handles long URLs, IDs, hashes)
const result: string[] = []
for (const line of lines) {
if (line.length <= charsPerLine) {
result.push(line)
} else {
let remaining = line
while (remaining.length > 0) {
result.push(remaining.substring(0, charsPerLine))
remaining = remaining.substring(charsPerLine)
}
}
}
return result.length ? result : ['']
}
function wrapCodeLine(line: string, maxWidth: number, fontSize: number): string[] {
const charWidth = 0.6 * fontSize
const charsPerLine = Math.max(1, Math.floor(maxWidth / charWidth))
const result: string[] = []
for (let i = 0; i < line.length; i += charsPerLine) {
result.push(line.substring(i, i + charsPerLine))
}
return result.length ? result : ['']
}
// ─── Low-level PDF builder ───
class PDFWriter {
build(opts: PDFCreateOptions): Buffer {
// Collect all body objects (object 3 and beyond).
// Object 1 = Catalog, Object 2 = Pages (hardcoded below).
const bodyObjects: string[] = []
const pageObjPdfNums: number[] = []
// Object 310: Font dictionaries (one per variant)
const fontNames = [
'Helvetica', 'Helvetica-Bold', 'Helvetica-Oblique', 'Helvetica-BoldOblique',
'Courier', 'Courier-Bold', 'Courier-Oblique', 'Courier-BoldOblique',
]
for (const name of fontNames) {
bodyObjects.push(
\`<< /Type /Font /Subtype /Type1 /BaseFont /\${name} /Encoding /WinAnsiEncoding >>\`
)
}
// Font objects are at pdf nums 3..10 (index 0..7 → obj num 3..10)
const FONT_OBJ_START = 3
if (!opts.pages || opts.pages.length === 0) {
throw new Error('At least one page is required.')
}
// Build per-page objects (with automatic overflow pagination)
for (const page of opts.pages) {
const size = PAGE_SIZES[page.pageSize || opts.defaultPageSize || 'A4']
const orientation = page.orientation || opts.defaultOrientation || 'portrait'
const [pw, ph] = orientation === 'landscape' ? [size[1], size[0]] : size
const m = page.margins || opts.defaultMargins || DEFAULT_MARGINS
const contentW = pw - m.left - m.right
// buildPageStreams returns an array of content streams — one per
// physical PDF page. Content that overflows a single page is
// automatically continued onto additional pages.
const pageStreams = buildPageStreams(page.content, ph, m, contentW)
for (const { stream } of pageStreams) {
// Content-stream object
const streamPdfNum = bodyObjects.length + 3
bodyObjects.push(stream)
// Page object — each font name maps to a distinct object (3..10)
pageObjPdfNums.push(bodyObjects.length + 3)
bodyObjects.push(
\`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 \${pw} \${ph}]\` +
\`\\n /Contents \${streamPdfNum} 0 R\` +
\`\\n /Resources << /Font << /F1 \${FONT_OBJ_START} 0 R /F2 \${FONT_OBJ_START + 1} 0 R /F3 \${FONT_OBJ_START + 2} 0 R /F4 \${FONT_OBJ_START + 3} 0 R /F5 \${FONT_OBJ_START + 4} 0 R /F6 \${FONT_OBJ_START + 5} 0 R /F7 \${FONT_OBJ_START + 6} 0 R /F8 \${FONT_OBJ_START + 7} 0 R >> >> >>\`
)
}
}
// Info dictionary
const infoPdfNum = bodyObjects.length + 3
bodyObjects.push(buildInfoDict(opts.title, opts.author))
// Assemble the final PDF
const parts: Buffer[] = []
const objPositions: number[] = []
// Header
parts.push(pdfBytes('%PDF-1.4\\n%\\xe2\\xe3\\xcf\\xd3\\n'))
// Object 1: Catalog
objPositions.push(getBufLen(parts))
parts.push(pdfBytes(\`1 0 obj\\n<< /Type /Catalog /Pages 2 0 R >>\\nendobj\\n\`))
// Object 2: Pages
objPositions.push(getBufLen(parts))
const kids = pageObjPdfNums.map(n => \`\${n} 0 R\`).join(' ')
parts.push(pdfBytes(\`2 0 obj\\n<< /Type /Pages /Kids [\${kids}] /Count \${pageObjPdfNums.length} >>\\nendobj\\n\`))
// Objects 3+: body objects (font, streams, pages, info)
for (let i = 0; i < bodyObjects.length; i++) {
const objNum = i + 3
objPositions.push(getBufLen(parts))
parts.push(pdfBytes(\`\${objNum} 0 obj\\n\`))
parts.push(pdfBytes(bodyObjects[i]))
parts.push(pdfBytes('\\nendobj\\n'))
}
const totalObjs = 2 + bodyObjects.length
// Cross-reference table
const xrefOffset = getBufLen(parts)
parts.push(pdfBytes(\`xref\\n0 \${totalObjs + 1}\\n\`))
parts.push(pdfBytes('0000000000 65535 f \\n'))
for (const pos of objPositions) {
parts.push(pdfBytes(\`\${String(pos).padStart(10, '0')} 00000 n \\n\`))
}
// Trailer
parts.push(pdfBytes(
\`trailer\\n<< /Size \${totalObjs + 1} /Root 1 0 R /Info \${infoPdfNum} 0 R >>\\nstartxref\\n\${xrefOffset}\\n%%EOF\\n\`
))
return Buffer.concat(parts)
}
}
function getBufLen(parts: Buffer[]): number {
let total = 0
for (const p of parts) total += p.length
return total
}
// Font dict is now built inline in PDFWriter.build (one object per variant)
function buildInfoDict(title?: string, author?: string): string {
let s = '<< '
if (title) s += \`/Title (\${escapePdf(toWinAnsi(title))}) \`
if (author) s += \`/Author (\${escapePdf(toWinAnsi(author))}) \`
s += '/Producer (pdfgen.ts) >>'
return s
}
interface PageStreamResult {
stream: string
}
/**
* Build content streams for a set of elements, automatically overflowing
* onto new pages when content exceeds the available vertical space.
* Returns one PageStreamResult per physical PDF page.
*/
function buildPageStreams(
elements: PDFElement[],
pageH: number,
margins: { top: number; right: number; bottom: number; left: number },
contentW: number
): PageStreamResult[] {
const results: PageStreamResult[] = []
let lines: string[] = []
let y = pageH - margins.top
const maxY = margins.bottom + 30
const flushPage = () => {
const streamContent = lines.join('\\n')
const streamBuf = pdfBytes(streamContent)
results.push({
stream: \`<< /Length \${streamBuf.length} >>\\nstream\\n\${streamContent}\\nendstream\`
})
lines = []
y = pageH - margins.top
}
for (const el of elements) {
switch (el.type) {
case 'heading': {
const size = FONT_SIZES[el.level] || 12
const font = 'Helvetica-Bold'
const wrapped = wrapText(toWinAnsi(el.text), contentW, size, font)
const lh = size * LINE_HEIGHT
y -= lh
for (const line of wrapped) {
if (y < maxY) {
flushPage()
y -= lh
}
lines.push(\`BT /F2 \${size} Tf \${margins.left} \${y} Td (\${escapePdf(line)}) Tj ET\`)
y -= lh
}
y -= 4
break
}
case 'paragraph': {
const size = 10
const align = el.align || 'left'
const wrapped = wrapText(toWinAnsi(el.text), contentW, size)
const lh = size * LINE_HEIGHT
for (const line of wrapped) {
if (y < maxY) {
flushPage()
y -= lh
}
let x = margins.left
if (align === 'center') {
const tw = measureText(line, size)
x = margins.left + (contentW - tw) / 2
} else if (align === 'right') {
const tw = measureText(line, size)
x = margins.left + contentW - tw
}
lines.push(\`BT /F1 \${size} Tf \${x.toFixed(1)} \${y.toFixed(1)} Td (\${escapePdf(line)}) Tj ET\`)
y -= lh
}
y -= 4
break
}
case 'bullet': {
const size = 10
const lh = size * LINE_HEIGHT
const bulletPrefix = toWinAnsi('• ')
y -= 2
for (const item of el.items) {
const wrapped = wrapText(toWinAnsi(item), contentW - 20, size)
for (let i = 0; i < wrapped.length; i++) {
if (y < maxY) {
flushPage()
y -= lh
}
const prefix = i === 0 ? bulletPrefix : ' '
lines.push(\`BT /F1 \${size} Tf \${margins.left} \${y.toFixed(1)} Td (\${escapePdf(prefix + wrapped[i])}) Tj ET\`)
y -= lh
}
}
y -= 4
break
}
case 'numberedList': {
const size = 10
const lh = size * LINE_HEIGHT
y -= 2
for (let idx = 0; idx < el.items.length; idx++) {
const wrapped = wrapText(toWinAnsi(el.items[idx]), contentW - 25, size)
const num = \`\${idx + 1}. \`
for (let i = 0; i < wrapped.length; i++) {
if (y < maxY) {
flushPage()
y -= lh
}
const prefix = i === 0 ? num : ' '.repeat(num.length)
lines.push(\`BT /F1 \${size} Tf \${margins.left} \${y.toFixed(1)} Td (\${escapePdf(prefix + wrapped[i])}) Tj ET\`)
y -= lh
}
}
y -= 4
break
}
case 'code': {
const size = CODE_FONT_SIZE
const lh = size * LINE_HEIGHT
const codeAvailW = contentW - 12
const codeLines = toWinAnsi(el.text).split('\\n')
const wrappedCodeLines: string[] = []
for (const rawLine of codeLines) {
wrappedCodeLines.push(...wrapCodeLine(rawLine, codeAvailW, size))
}
y -= 6
let lineIndex = 0
while (lineIndex < wrappedCodeLines.length) {
if (y - (lh + 12) < maxY) {
if (lines.length > 0) {
flushPage()
}
y -= 6
}
const maxLinesThisPage = Math.max(1, Math.floor((y - maxY - 12) / lh))
const linesToRender = Math.min(maxLinesThisPage, wrappedCodeLines.length - lineIndex)
const boxH = linesToRender * lh + 12
lines.push(\`0.92 0.92 0.92 rg \${margins.left} \${y - boxH + 6} \${contentW} \${boxH} re f\`)
y -= 6
lines.push('0 0 0 rg')
for (let i = 0; i < linesToRender; i++) {
const codeLine = wrappedCodeLines[lineIndex + i]
lines.push(\`BT /F5 \${size} Tf \${margins.left + 6} \${y.toFixed(1)} Td (\${escapePdf(codeLine)}) Tj ET\`)
y -= lh
}
lineIndex += linesToRender
y -= 6
lines.push('0 0 0 rg')
if (lineIndex < wrappedCodeLines.length) {
flushPage()
y -= 6
}
}
break
}
case 'hr': {
y -= 8
if (y < maxY) {
flushPage()
y -= 8
}
const lineY = y + 2
lines.push(\`0.8 0.8 0.8 RG 0.5 w \${margins.left} \${lineY} \${contentW} 0 re S\`)
y -= 8
break
}
case 'spacer': {
y -= el.height || 12
if (y < maxY) {
flushPage()
}
break
}
case 'table': {
const size = 9
const lh = size * LINE_HEIGHT
const cols = el.headers.length
if (cols === 0) {
throw new Error('Table must have at least one header column.')
}
if (el.colWidths && el.colWidths.length !== cols) {
throw new Error(\`Table colWidths length (\${el.colWidths.length}) does not match headers length (\${cols}).\`)
}
for (let r = 0; r < el.rows.length; r++) {
if (el.rows[r].length !== cols) {
throw new Error(\`Table row \${r} cell count (\${el.rows[r].length}) does not match headers count (\${cols}).\`)
}
}
const colW = el.colWidths || el.headers.map(() => contentW / cols)
const headerWrapped: string[][] = []
let maxHeaderLines = 1
for (let c = 0; c < cols; c++) {
const hl = wrapText(toWinAnsi(el.headers[c] || ''), colW[c] - 8, size, 'Helvetica-Bold')
headerWrapped.push(hl)
if (hl.length > maxHeaderLines) maxHeaderLines = hl.length
}
const headerH = maxHeaderLines * lh + 6
if (headerH > (pageH - margins.top - maxY)) {
throw new Error('Table headers are too tall to fit on a single page.')
}
// Header row
y -= headerH
if (y < maxY) {
flushPage()
y -= headerH
}
lines.push(\`0.15 0.15 0.15 rg \${margins.left} \${y} \${contentW} \${headerH} re f\`)
lines.push('1 1 1 rg')
let x = margins.left
const headerStartY = y + headerH - 4
for (let c = 0; c < cols; c++) {
for (let li = 0; li < headerWrapped[c].length; li++) {
lines.push(\`BT /F2 \${size} Tf \${x + 4} \${(headerStartY - li * lh).toFixed(1)} Td (\${escapePdf(headerWrapped[c][li])}) Tj ET\`)
}
x += colW[c]
}
lines.push('0 0 0 rg')
for (let r = 0; r < el.rows.length; r++) {
const row = el.rows[r]
// Pre-compute wrapped lines for each cell
const cellWrapped: string[][] = []
let maxCellLines = 1
for (let c = 0; c < cols; c++) {
const cl = wrapText(toWinAnsi(row[c] || ''), colW[c] - 8, size)
cellWrapped.push(cl)
if (cl.length > maxCellLines) maxCellLines = cl.length
}
// Render row in page-sized chunks to handle rows taller than one page
let linesRendered = 0
while (linesRendered < maxCellLines) {
if (y - (lh + 6) < maxY) {
flushPage()
}
const availH = y - maxY
const maxLinesThisPage = Math.max(1, Math.floor((availH - 6) / lh))
const linesToRender = Math.min(maxLinesThisPage, maxCellLines - linesRendered)
const chunkH = linesToRender * lh + 6
y -= chunkH
if (r % 2 === 0) {
lines.push(\`0.96 0.96 0.96 rg \${margins.left} \${y} \${contentW} \${chunkH} re f\`)
}
lines.push(\`0.85 0.85 0.85 RG 0.3 w \${margins.left} \${y} \${contentW} 0 re S\`)
lines.push('0 0 0 rg')
let x = margins.left
const cellStartY = y + chunkH - 4
for (let c = 0; c < cols; c++) {
for (let li = 0; li < linesToRender; li++) {
const cellLineIdx = linesRendered + li
if (cellLineIdx < cellWrapped[c].length) {
lines.push(\`BT /F1 \${size} Tf \${x + 4} \${(cellStartY - li * lh).toFixed(1)} Td (\${escapePdf(cellWrapped[c][cellLineIdx])}) Tj ET\`)
}
}
x += colW[c]
}
linesRendered += linesToRender
if (linesRendered < maxCellLines) {
flushPage()
}
}
}
y -= 2
lines.push(\`0.6 0.6 0.6 RG 0.5 w \${margins.left} \${y + 2} \${contentW} 0 re S\`)
y -= 6
break
}
}
}
// Flush remaining content as the last page
if (lines.length > 0) {
flushPage()
}
if (results.length === 0) {
const streamContent = ''
const streamBuf = pdfBytes(streamContent)
results.push({
stream: \`<< /Length \${streamBuf.length} >>\\nstream\\n\${streamContent}\\nendstream\`
})
}
return results
}
// ─── Public API ───
export async function createPDF(opts: PDFCreateOptions): Promise<Buffer> {
const writer = new PDFWriter()
return writer.build(opts)
}
// ─── CLI ───
if (import.meta.main) {
const args = process.argv.slice(2)
if (args.length > 0) {
// Simple CLI: bun pdfgen.ts <output.pdf>
// Reads a JSON spec from stdin or a file
const nonFlags = args.filter(a => !a.startsWith('--'))
let specFile: string | undefined
if (args.includes('--spec')) {
specFile = args[args.indexOf('--spec') + 1]
}
// outFile is the last non-flag arg that is not the spec file
const outFile = nonFlags.filter(a => a !== specFile).pop()
if (!outFile) {
process.stderr.write('Usage: bun pdfgen.ts [--spec <file>] <output.pdf>\\n')
process.exit(1)
}
let spec: PDFCreateOptions
if (specFile) {
spec = JSON.parse(readFileSync(specFile, 'utf-8'))
} else {
// Read from stdin
const input = readFileSync(0, 'utf-8')
spec = JSON.parse(input)
}
const pdf = await createPDF(spec)
writeFileSync(outFile, pdf)
process.stderr.write(\`PDF written to \${outFile} (\${pdf.length} bytes)\\n\`)
}
}
`
+7 -1
View File
@@ -222,7 +222,13 @@ function prependBaseDir(
blocks: ContentBlockParam[],
baseDir: string,
): ContentBlockParam[] {
const prefix = `Base directory for this skill: ${baseDir}\n\n`
// Normalize to forward slashes so the path is safe to embed in a JS string
// import (e.g. `from '<base-dir>/pdfgen'`). On Windows, raw backslash paths
// like `C:\Users\...\pdf` break TypeScript/Bun resolution because backslashes
// are treated as escapes inside single-quoted strings. Forward slashes work
// cross-platform.
const safeBaseDir = baseDir.split('\\').join('/')
const prefix = `Base directory for this skill: ${safeBaseDir}\n\n`
if (blocks.length > 0 && blocks[0]!.type === 'text') {
return [
{ type: 'text', text: prefix + blocks[0]!.text },