From 9893726c3594e251ec38e9d6d64e9cb2bcdee662 Mon Sep 17 00:00:00 2001 From: miguelrk Date: Mon, 14 Sep 2026 12:09:09 +0200 Subject: [PATCH 1/7] feat(pdf): add `@comark/pdf` package for rendering to PDF Introduces a new workspace package, `@comark/pdf`, that converts Markdown documents into print-ready paginated HTML and PDF files using `paged.js` and Playwright. This includes features for live browser previews, customizable page layouts via frontmatter, and headless PDF exports. Updates `AGENTS.md` and adds comprehensive documentation for usage and configuration. --- .../comark_pdf_renderer_f4b3c256.plan.md | 111 ++++ AGENTS.md | 74 ++- docs/content/3.rendering/9.pdf.md | 205 +++++++ package.json | 3 + packages/comark-pdf/.release-it.json | 38 ++ packages/comark-pdf/CHANGELOG.md | 3 + packages/comark-pdf/README.md | 108 ++++ packages/comark-pdf/package.json | 73 +++ packages/comark-pdf/src/css.ts | 68 +++ packages/comark-pdf/src/index.ts | 47 ++ packages/comark-pdf/src/node.ts | 100 +++ packages/comark-pdf/src/pagedjs.d.ts | 7 + packages/comark-pdf/src/parse.ts | 1 + packages/comark-pdf/src/plugins/binding.ts | 2 + packages/comark-pdf/src/plugins/math.ts | 2 + packages/comark-pdf/src/plugins/mermaid.ts | 2 + packages/comark-pdf/src/plugins/page-break.ts | 38 ++ packages/comark-pdf/src/preview.ts | 31 + packages/comark-pdf/src/render.ts | 54 ++ packages/comark-pdf/src/types.ts | 61 ++ packages/comark-pdf/src/utils/index.ts | 1 + packages/comark-pdf/test/css.test.ts | 107 ++++ packages/comark-pdf/test/index.test.ts | 89 +++ packages/comark-pdf/test/page-break.test.ts | 45 ++ packages/comark-pdf/tsconfig.json | 21 + pnpm-lock.yaml | 568 ++++++------------ pnpm-workspace.yaml | 6 + scripts/sync-plugins.mjs | 1 + test/bundle.test.ts | 11 +- 29 files changed, 1472 insertions(+), 405 deletions(-) create mode 100644 .cursor/plans/comark_pdf_renderer_f4b3c256.plan.md create mode 100644 docs/content/3.rendering/9.pdf.md create mode 100644 packages/comark-pdf/.release-it.json create mode 100644 packages/comark-pdf/CHANGELOG.md create mode 100644 packages/comark-pdf/README.md create mode 100644 packages/comark-pdf/package.json create mode 100644 packages/comark-pdf/src/css.ts create mode 100644 packages/comark-pdf/src/index.ts create mode 100644 packages/comark-pdf/src/node.ts create mode 100644 packages/comark-pdf/src/pagedjs.d.ts create mode 100644 packages/comark-pdf/src/parse.ts create mode 100644 packages/comark-pdf/src/plugins/binding.ts create mode 100644 packages/comark-pdf/src/plugins/math.ts create mode 100644 packages/comark-pdf/src/plugins/mermaid.ts create mode 100644 packages/comark-pdf/src/plugins/page-break.ts create mode 100644 packages/comark-pdf/src/preview.ts create mode 100644 packages/comark-pdf/src/render.ts create mode 100644 packages/comark-pdf/src/types.ts create mode 100644 packages/comark-pdf/src/utils/index.ts create mode 100644 packages/comark-pdf/test/css.test.ts create mode 100644 packages/comark-pdf/test/index.test.ts create mode 100644 packages/comark-pdf/test/page-break.test.ts create mode 100644 packages/comark-pdf/tsconfig.json diff --git a/.cursor/plans/comark_pdf_renderer_f4b3c256.plan.md b/.cursor/plans/comark_pdf_renderer_f4b3c256.plan.md new file mode 100644 index 00000000..cf83899c --- /dev/null +++ b/.cursor/plans/comark_pdf_renderer_f4b3c256.plan.md @@ -0,0 +1,111 @@ +--- +name: comark pdf renderer +overview: Add a first-party @comark/pdf workspace package that turns Comark documents into print-ready paged-media HTML (via frontmatter-driven @page CSS and a ::page-break component), renders live paginated previews in the browser with paged.js, and exports PDF bytes in Node via Playwright. +todos: + - id: skeleton + content: Scaffold packages/comark-pdf/ (package.json, tsconfig.json, README, CHANGELOG, .release-it.json) mirroring @comark/html with comark + @comark/html deps and optional pagedjs/playwright peers + status: completed + - id: css + content: Implement src/css.ts frontmatterToPageCss + baseCss, and src/types.ts PdfPageConfig/PdfRendererOptions + status: completed + - id: render-index + content: Implement src/render.ts (renderPdfBody + assemblePagedHtml reusing @comark/html) and src/index.ts (createPdfRenderer, renderPdf, renderPdfFromDocument) + status: completed + - id: page-break + content: Implement src/plugins/page-break.ts (PageBreak handler + no-op plugin) and re-export binding/math/mermaid handlers from @comark/html + status: completed + - id: preview + content: Implement src/preview.ts (browser paginate via pagedjs Previewer, lazy import) + status: completed + - id: node + content: Implement src/node.ts (renderPdfToBuffer/renderPdfToFile via Playwright + paged.js polyfill, injectable browser) + status: completed + - id: wiring + content: "Wire monorepo: add comark-pdf to sync-plugins.mjs, root resolutions, pagedjs catalog entry, optional dev:pdf script" + status: completed + - id: tests + content: "Add tests: css, page-break, index (deterministic) + guarded preview/node smoke tests" + status: completed + - id: build-snapshot + content: Run pnpm prepack and refresh test/bundle.test.ts snapshot; run lint/typecheck/tests + status: completed + - id: docs + content: Update AGENTS.md (new package + exports) and add docs/content/3.rendering PDF page + status: completed +isProject: false +--- + +# Add `@comark/pdf` renderer (paged.js) + +## Goal + +New workspace package `@comark/pdf` at `packages/comark-pdf/` that mirrors [`@comark/html`](packages/comark-html/package.json), reuses its HTML rendering, and adds paged-media output: frontmatter `pdf:` config to `@page` CSS, a `::page-break` component, a browser paginated preview, and a Node headless PDF export. + +## Architecture and data flow + +```mermaid +flowchart TD + md["Markdown + frontmatter pdf:"] --> parse["comark parseMarkdown"] + parse --> doc["MarkdownDocument (nodes + frontmatter)"] + doc --> body["renderHtmlFromDocument + page-break component (@comark/html)"] + doc --> css["frontmatterToPageCss(frontmatter.pdf) -> @page rules"] + body --> assemble["assemble full HTML document string (...` string. +- `css.ts` - `frontmatterToPageCss(pdf: PdfPageConfig): string`. Generates `@page { size: ; margin: ; @top-center { content: ... } @bottom-center { content: ... } }`. Translate footer/header tokens `{{ page }}` -> `counter(page)` and `{{ totalPages }}` -> `counter(pages)`. Include a small default `baseCss` (e.g. `.comark-page-break { break-after: page; }`). +- `index.ts` - public three-tier API mirroring [packages/comark-html/src/index.ts](packages/comark-html/src/index.ts): + - `createPdfRenderer(options?) => (markdown) => Promise` (returns the assembled paged-media HTML document string): builds the parser once via `createMarkdownParser`, then per call parses, computes `frontmatterToPageCss(doc.frontmatter.pdf)`, renders the body, and assembles. + - `renderPdf(markdown, options?)` one-shot wrapper. + - `renderPdfFromDocument(document, options?)` render-only path. +- `preview.ts` (browser) - `paginate(html, target, css?)` using `new Previewer()` from `pagedjs`; returns the paged flow (exposes total page count). Lazy `import('pagedjs')` so importing the package never hard-requires it. +- `node.ts` (Node) - `renderPdfToBuffer(markdown, options?) => Promise` and `renderPdfToFile(markdown, path, options?)`: + - call `renderPdf` for the HTML string; + - accept an injected `options.browser` (Playwright `Browser`) or lazy `import('playwright')` -> `chromium.launch(options.launchOptions)`; + - `page.setContent(html)`, set `window.PagedConfig = { after }` hook, `page.addScriptTag({ path: require.resolve('pagedjs/dist/paged.polyfill.js') })`, `page.waitForFunction(() => window.__pagedDone)` (proven `pagedjs-cli` pattern); + - `page.pdf({ printBackground: true, preferCSSPageSize: true, ...options.pdfOptions })`; close the browser only if this function launched it. +- `plugins/page-break.ts` - export a `PageBreak` `NodeHandler` (emits `
`, honoring an optional `type="before"|"after"` attr) and a default no-op `defineComarkPlugin` (parsing of `::page-break` already works through the core components plugin, confirmed by the AST research: it yields `['page-break', attrs, ...children]`). Re-export `binding`/`math`/`mermaid` component handlers from `@comark/html/plugins/*` so PDF documents can use them without duplicating logic. +- `utils/index.ts` - `export * from 'comark/utils'`. + +## Monorepo wiring + +- Add `'comark-pdf'` to `frameworkPackages` in [scripts/sync-plugins.mjs](scripts/sync-plugins.mjs) so all parser-only core plugins (shiki, alert, toc, emoji, ...) resolve via `@comark/pdf/plugins/*`. +- Add `"@comark/pdf": "workspace:*"` to `resolutions` in the root [package.json](package.json); optionally add a `"dev:pdf"` script. +- Add `pagedjs` to the `catalog:` in [pnpm-workspace.yaml](pnpm-workspace.yaml) (`playwright` already exists there). +- After first real build, refresh the auto-enumerated snapshot in [test/bundle.test.ts](test/bundle.test.ts) with `pnpm prepack && pnpm vitest run bundle -u` (the package is picked up automatically because it is non-private). + +## Tests (`packages/comark-pdf/test/`) + +Deterministic, no-browser tests are the core: +- `css.test.ts` - `frontmatterToPageCss` for format/orientation/margin variants and header/footer counter translation. +- `page-break.test.ts` - `::page-break` parses to `['page-break', ...]` and `PageBreak` emits the break element. +- `index.test.ts` - `renderPdf` assembles a full HTML doc with `@page` CSS from frontmatter and the rendered body. + +Browser/Node paths (`preview`, `node`) get one guarded smoke test each (CI already installs Chromium via Playwright), skipped when the peer is absent. + +## Docs + +Per repo rules, after implementation update [AGENTS.md](AGENTS.md) (new package section + Package Exports Reference) and add a `docs/content/3.rendering/` page for the PDF renderer. diff --git a/AGENTS.md b/AGENTS.md index de7eb372..184ffe2b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ This is a **monorepo** containing the Comark Markdown parser, document model, pl - Fast synchronous and async parsing via markdown-exit, a TypeScript rewrite of markdown-it - CommonMark and GitHub Flavored Markdown support - Streaming support for real-time/incremental parsing -- HTML, ANSI, Vue, React, Svelte and Angular renderers +- HTML, ANSI, Vue, React, Svelte, Angular, and PDF renderers - Component and attribute syntax plus an extensible plugin system - Syntax highlighting via Shiki - Auto-close utilities for incomplete markdown (useful for AI streaming) @@ -28,6 +28,7 @@ This is a **monorepo** containing the Comark Markdown parser, document model, pl │ ├── comark-react/ # React renderer + plugins (@comark/react) │ ├── comark-svelte/ # Svelte renderer + plugins (@comark/svelte) │ ├── comark-angular/ # Angular renderer + plugins (@comark/angular) +│ ├── comark-pdf/ # PDF renderer + paged.js preview (@comark/pdf) │ └── comark-nuxt/ # Nuxt module (@comark/nuxt) ├── examples/ # Example applications │ ├── 1.frameworks/ # Framework examples (Nuxt, Next.js, Astro, SvelteKit, ...) @@ -136,6 +137,66 @@ const html = await renderHtml(markdownString) --- +## Package: @comark/pdf + +Located at `packages/comark-pdf/`. PDF renderer powered by paged.js and Playwright. + +### Exports + +```json +{ + ".": "./dist/index.js", + "./node": "./dist/node.js", + "./preview": "./dist/preview.js", + "./css": "./dist/css.js", + "./plugins/*": "./dist/plugins/*.js", + "./utils": "./dist/utils/index.js", + "./parse": "./dist/parse.js", + "./render": "./dist/render.js" +} +``` + +### Source layout + +``` +packages/comark-pdf/src/ +├── index.ts # createPdfRenderer, renderPdf, renderPdfFromDocument +├── render.ts # renderPdfBody, assemblePagedHtml + re-exports comark/render +├── css.ts # frontmatterToPageCss, DEFAULT_BASE_CSS +├── types.ts # PdfPageConfig, PdfRendererOptions, PdfNodeOptions, PdfBrowser, PdfPage +├── parse.ts # re-export comark/parse +├── preview.ts # browser: paginate() via pagedjs Previewer (lazy import) +├── node.ts # Node: renderPdfToBuffer, renderPdfToFile via Playwright +├── pagedjs.d.ts # minimal ambient types for pagedjs (no @types/pagedjs package) +├── plugins/ +│ ├── page-break.ts # PageBreak NodeHandler + no-op plugin default export +│ ├── binding.ts # re-export @comark/html/plugins/binding +│ ├── math.ts # re-export @comark/html/plugins/math +│ └── mermaid.ts # re-export @comark/html/plugins/mermaid +└── utils/ + └── index.ts # re-export comark/utils +``` + +### Optional peers + +| Peer | Required by | +|------|-------------| +| `pagedjs` | `@comark/pdf/preview` | +| `playwright` | `@comark/pdf/node` | + +### Usage + +```typescript +import { createPdfRenderer, renderPdf, renderPdfFromDocument } from '@comark/pdf' +import { paginate } from '@comark/pdf/preview' // browser only +import { renderPdfToBuffer, renderPdfToFile } from '@comark/pdf/node' // Node only +import { PageBreak } from '@comark/pdf/plugins/page-break' +import math, { Math } from '@comark/pdf/plugins/math' +import mermaid, { Mermaid } from '@comark/pdf/plugins/mermaid' +``` + +--- + ## Package: @comark/ansi Located at `packages/comark-ansi/`. ANSI terminal renderer. @@ -474,6 +535,17 @@ import { Markdown, MarkdownDocument, defineMarkdownComponent, defineMarkdownDocu import math, { Math } from '@comark/angular/plugins/math' import mermaid, { Mermaid } from '@comark/angular/plugins/mermaid' import binding, { Binding, If } from '@comark/angular/plugins/binding' + +// PDF — parse + assemble paged-media HTML, browser preview, headless PDF export +import { createPdfRenderer, renderPdf, renderPdfFromDocument, assemblePagedHtml, renderPdfBody } from '@comark/pdf' +import { paginate } from '@comark/pdf/preview' // browser: paged.js Previewer +import { renderPdfToBuffer, renderPdfToFile } from '@comark/pdf/node' // Node: Playwright PDF export +import { frontmatterToPageCss, DEFAULT_BASE_CSS } from '@comark/pdf/css' +import { PageBreak } from '@comark/pdf/plugins/page-break' +import math, { Math } from '@comark/pdf/plugins/math' +import mermaid, { Mermaid } from '@comark/pdf/plugins/mermaid' +import binding, { Binding, If } from '@comark/pdf/plugins/binding' +import type { PdfPageConfig, PdfRendererOptions, PdfNodeOptions, PdfBrowser } from '@comark/pdf' ``` ## Coding Principles diff --git a/docs/content/3.rendering/9.pdf.md b/docs/content/3.rendering/9.pdf.md new file mode 100644 index 00000000..2434f1cd --- /dev/null +++ b/docs/content/3.rendering/9.pdf.md @@ -0,0 +1,205 @@ +--- +title: Render Comark to PDF +description: Generate print-ready paged HTML and PDF documents from Markdown using paged.js and Playwright. +navigation: + title: PDF + icon: i-lucide-file-text +links: + - label: Plugins + icon: i-lucide-puzzle + to: /plugins + color: neutral + variant: soft + - label: paged.js + icon: i-lucide-external-link + to: https://pagedjs.org + color: neutral + variant: soft +--- + +The `@comark/pdf` package converts Markdown documents into print-ready paginated HTML and PDF files. It uses [paged.js](https://pagedjs.org) to apply CSS Paged Media rules (`@page`, running headers/footers, page counters) and Playwright's Chromium to export raw PDF bytes. + +## Installation + +::code-group + +```bash [pnpm] +pnpm add @comark/pdf +# Peers: install what you use +pnpm add -D pagedjs playwright +``` + +```bash [npm] +npm install @comark/pdf +npm install -D pagedjs playwright +``` + +:: + +Both `pagedjs` and `playwright` are **optional peers**. Install only what you need: + +- `pagedjs` — required for live browser preview (`@comark/pdf/preview`). +- `playwright` — required for headless PDF export (`@comark/pdf/node`). + +## Frontmatter configuration + +Control page layout via the `pdf:` key in your document frontmatter: + +```markdown +--- +pdf: + format: A4 # A4, Letter, A3, or custom (e.g. '210mm 297mm') + orientation: portrait # portrait | landscape + margin: 20mm # shorthand, or { top, right, bottom, left } + header: "My Report" # center header; supports {{ page }} and {{ totalPages }} + headerLeft: "Draft" + headerRight: "Confidential" + footer: "Page {{ page }} of {{ totalPages }}" + footerLeft: "Company Name" + footerRight: "2026" +--- + +# Document content here +``` + +These values are converted to CSS `@page` rules automatically. + +## Paged-media HTML + +`renderPdf` is the main entry point. It returns a complete `` document with embedded `@page` CSS and the rendered markdown body. This is the universal output — usable for browser preview and headless export. + +```typescript +import { renderPdf, createPdfRenderer } from '@comark/pdf' + +// One-shot +const html = await renderPdf('# Hello\n\nWorld.') + +// Reusable renderer (parser initialized once) +const render = createPdfRenderer({ + pdf: { format: 'A4', margin: '20mm', footer: 'Page {{ page }} of {{ totalPages }}' }, +}) + +const html = await render(markdownString) +``` + +### From a pre-parsed document + +```typescript +import { parseMarkdown } from 'comark' +import { renderPdfFromDocument } from '@comark/pdf' + +const doc = await parseMarkdown('---\npdf:\n format: A4\n---\n# Hello') +const html = await renderPdfFromDocument(doc) +``` + +## Page breaks + +Use the `::page-break` component to insert explicit page breaks. The `components` plugin (on by default) handles parsing; the `PageBreak` handler converts the node to a CSS fragmentation element. + +```markdown +# Chapter 1 + +Content here. + +::page-break +:: + +# Chapter 2 + +More content. +``` + +`PageBreak` is included automatically when using `renderPdf` / `renderPdfFromDocument`. To use it in a custom renderer: + +```typescript +import { renderPdf } from '@comark/pdf' +import { PageBreak } from '@comark/pdf/plugins/page-break' + +const html = await renderPdf(markdown, { + components: { 'page-break': PageBreak }, +}) +``` + +Supports `type="before"` for `break-before: page`: + +```markdown +::page-break{type="before"} +:: +``` + +## Browser preview + +Use `paginate` from `@comark/pdf/preview` to activate paged.js on the current document in a browser environment. + +```typescript +import { paginate } from '@comark/pdf/preview' + +// Call after inserting the rendered HTML into the DOM +const container = document.getElementById('preview') +const flow = await paginate(container) +console.log(`${flow.total} pages`) +``` + +## Node PDF export + +Use `renderPdfToBuffer` or `renderPdfToFile` from `@comark/pdf/node` for headless PDF generation. Requires both `pagedjs` and `playwright` peers. + +```typescript +import { renderPdfToBuffer, renderPdfToFile } from '@comark/pdf/node' + +// Write to file +await renderPdfToFile(markdown, 'output.pdf', { + pdf: { format: 'A4', margin: '20mm', footer: 'Page {{ page }} of {{ totalPages }}' }, +}) + +// Get a Uint8Array/Buffer +const buffer = await renderPdfToBuffer(markdown) +``` + +### Reuse a browser instance + +Inject a Playwright `Browser` to avoid launching a new process on each call: + +```typescript +import { renderPdfToBuffer } from '@comark/pdf/node' +import { chromium } from 'playwright' + +const browser = await chromium.launch() + +const buffers = await Promise.all( + markdownFiles.map((md) => renderPdfToBuffer(md, { browser })) +) + +await browser.close() +``` + +### Custom Playwright PDF options + +Forward any Playwright `PDFOptions` via `pdfOptions`: + +```typescript +await renderPdfToFile(markdown, 'output.pdf', { + pdf: { format: 'A4' }, + pdfOptions: { + landscape: false, + printBackground: true, + }, +}) +``` + +## Plugins + +All core Comark plugins are available via `@comark/pdf/plugins/*`. Math and Mermaid components re-export from `@comark/html` so rendering works the same way. + +```typescript +import { renderPdf } from '@comark/pdf' +import shiki from '@comark/pdf/plugins/shiki' +import math, { Math } from '@comark/pdf/plugins/math' +import mermaid, { Mermaid } from '@comark/pdf/plugins/mermaid' +import binding, { Binding, If } from '@comark/pdf/plugins/binding' + +const html = await renderPdf(markdown, { + plugins: [shiki(), math()], + components: { Math, Mermaid, Binding, If }, +}) +``` diff --git a/package.json b/package.json index 55cfc172..a0e7326e 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "dev:twoslash": "pnpm --filter comark-vue-vite-twoslash run dev", "dev:json-render": "pnpm --filter comark-vue-vite-json-render run dev", "dev:binding": "pnpm --filter comark-vue-vite-binding run dev", + "dev:pdf": "pnpm --filter comark-pdf run dev", "docs": "nuxt dev docs", "prepack": "pnpm build && pnpm sync-plugins", "stub": "pnpm --parallel --filter './packages/**' run stub && pnpm sync-plugins", @@ -50,6 +51,7 @@ }, "devDependencies": { "@comark/ansi": "workspace:*", + "@comark/pdf": "workspace:*", "@release-it/conventional-changelog": "catalog:", "@types/node": "catalog:", "@vitejs/plugin-vue": "catalog:", @@ -77,6 +79,7 @@ "@comark/ansi": "workspace:*", "@comark/html": "workspace:*", "@comark/nuxt": "workspace:*", + "@comark/pdf": "workspace:*", "@comark/react": "workspace:*", "@comark/svelte": "workspace:*", "@comark/vue": "workspace:*", diff --git a/packages/comark-pdf/.release-it.json b/packages/comark-pdf/.release-it.json new file mode 100644 index 00000000..684bbd5e --- /dev/null +++ b/packages/comark-pdf/.release-it.json @@ -0,0 +1,38 @@ +{ + "git": { + "commitMessage": "chore(pdf): release v${version}", + "tagName": "@comark/pdf@${version}", + "tagAnnotation": "@comark/pdf v${version}", + "requireCleanWorkingDir": true + }, + "github": { + "release": true, + "releaseName": "@comark/pdf v${version}" + }, + "npm": { + "publish": true, + "publishPath": ".", + "publishPackageManager": "pnpm", + "publishArgs": ["--no-git-checks"] + }, + "plugins": { + "@release-it/conventional-changelog": { + "ignoreRecommendedBump": true, + "preset": { + "name": "conventionalcommits", + "types": [ + { "type": "feat", "section": "Features" }, + { "type": "fix", "section": "Bug Fixes" }, + { "type": "perf", "section": "Performance" } + ] + }, + "infile": "CHANGELOG.md", + "gitRawCommitsOpts": { + "path": "." + } + } + }, + "hooks": { + "before:release": ["pnpm run build"] + } +} diff --git a/packages/comark-pdf/CHANGELOG.md b/packages/comark-pdf/CHANGELOG.md new file mode 100644 index 00000000..fba75241 --- /dev/null +++ b/packages/comark-pdf/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +All notable changes to `@comark/pdf` are documented in this file. diff --git a/packages/comark-pdf/README.md b/packages/comark-pdf/README.md new file mode 100644 index 00000000..4a37b6d5 --- /dev/null +++ b/packages/comark-pdf/README.md @@ -0,0 +1,108 @@ +# @comark/pdf + +PDF renderer for Comark. Convert Markdown to print-ready paginated HTML and PDF via [paged.js](https://pagedjs.org) and Playwright. + +## Install + +```bash +pnpm add @comark/pdf +# Optional: install peers for PDF export +pnpm add -D pagedjs playwright +``` + +## Usage + +### Paged-media HTML (environment-neutral) + +```typescript +import { renderPdf } from '@comark/pdf' + +const html = await renderPdf(` +--- +pdf: + format: A4 + margin: 20mm + footer: "Page {{ page }} of {{ totalPages }}" +--- + +# My Document + +Content here. + +::page-break +:: + +# Chapter 2 + +More content. +`) + +// html is a complete document with @page CSS embedded. +// Serve it in a browser with paged.js for a live paginated preview, +// or pass it to renderPdfToBuffer() for headless PDF export. +``` + +### Node.js PDF export (requires playwright peer) + +```typescript +import { renderPdfToBuffer, renderPdfToFile } from '@comark/pdf/node' + +// Export to Buffer/Uint8Array +const buffer = await renderPdfToBuffer(markdown) +await fs.writeFile('output.pdf', buffer) + +// Or write directly to a file +await renderPdfToFile(markdown, 'output.pdf') + +// Inject your own Playwright browser for connection reuse +import { chromium } from 'playwright' +const browser = await chromium.launch() +await renderPdfToFile(markdown, 'output.pdf', { browser }) +await browser.close() +``` + +### Browser paginated preview (requires pagedjs peer) + +```typescript +import { paginate } from '@comark/pdf/preview' + +// Paginates the current document into a container element. +const container = document.getElementById('preview') +const flow = await paginate(container) +console.log(`Total pages: ${flow.total}`) +``` + +## Frontmatter configuration + +```yaml +--- +pdf: + format: A4 # page size (A4, Letter, A3, …) + orientation: portrait # portrait | landscape + margin: 20mm # or { top, right, bottom, left } + header: "My Report" # center header; tokens: {{ page }}, {{ totalPages }} + headerLeft: "Draft" + headerRight: "Confidential" + footer: "Page {{ page }} of {{ totalPages }}" + footerLeft: "Company Name" + footerRight: "2026" +--- +``` + +## Plugins + +All core Comark plugins are available via `@comark/pdf/plugins/*`: + +```typescript +import { renderPdf } from '@comark/pdf' +import shiki from '@comark/pdf/plugins/shiki' +import math, { Math } from '@comark/pdf/plugins/math' +import mermaid, { Mermaid } from '@comark/pdf/plugins/mermaid' +import binding, { Binding, If } from '@comark/pdf/plugins/binding' +import { PageBreak } from '@comark/pdf/plugins/page-break' + +const html = await renderPdf(markdown, { + plugins: [shiki(), math()], + components: { Math, Mermaid, Binding, If, 'page-break': PageBreak }, +}) +``` diff --git a/packages/comark-pdf/package.json b/packages/comark-pdf/package.json new file mode 100644 index 00000000..05f81ae3 --- /dev/null +++ b/packages/comark-pdf/package.json @@ -0,0 +1,73 @@ +{ + "name": "@comark/pdf", + "version": "0.1.0", + "description": "PDF renderer for Comark. Convert Markdown to print-ready paginated HTML and PDF via paged.js and Playwright.", + "keywords": [ + "comark", + "components", + "markdown", + "mdc", + "pdf", + "paged.js", + "print", + "renderer" + ], + "homepage": "https://comark.dev/rendering/pdf", + "bugs": { + "url": "https://github.com/comarkdown/comark/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/comarkdown/comark.git" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": "./dist/index.js", + "./node": "./dist/node.js", + "./preview": "./dist/preview.js", + "./css": "./dist/css.js", + "./plugins/*": "./dist/plugins/*.js", + "./utils": "./dist/utils/index.js", + "./parse": "./dist/parse.js", + "./render": "./dist/render.js" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "stub": "node ../../scripts/stub.mjs", + "build": "tsc --removeComments --declaration false && tsc --emitDeclarationOnly", + "dev": "tsc --watch", + "test": "vitest run", + "prepack": "tsc --removeComments --declaration false && tsc --emitDeclarationOnly", + "release": "release-it" + }, + "dependencies": { + "@comark/html": "workspace:*", + "comark": "workspace:*" + }, + "devDependencies": { + "pagedjs": "catalog:", + "playwright": "catalog:", + "vitest": "catalog:" + }, + "peerDependencies": { + "pagedjs": ">=0.4", + "playwright": ">=1.0" + }, + "peerDependenciesMeta": { + "pagedjs": { + "optional": true + }, + "playwright": { + "optional": true + } + } +} diff --git a/packages/comark-pdf/src/css.ts b/packages/comark-pdf/src/css.ts new file mode 100644 index 00000000..f291af85 --- /dev/null +++ b/packages/comark-pdf/src/css.ts @@ -0,0 +1,68 @@ +import type { PdfMargin, PdfPageConfig } from './types.ts' + +/** Minimal default CSS injected into every assembled PDF document. */ +export const DEFAULT_BASE_CSS = '.comark-page-break{break-after:page}' + +/** + * Resolve a margin config to a CSS shorthand string. + * Accepts either a string ('20mm') or a per-side object. + */ +const resolveMargin = (margin: string | PdfMargin): string => { + if (typeof margin === 'string') return margin + const { top = '0', right = top, bottom = top, left = right } = margin + return `${top} ${right} ${bottom} ${left}` +} + +/** + * Convert a header/footer template string into a CSS `content` value. + * Supports two tokens: + * {{ page }} → counter(page) + * {{ totalPages }} → counter(pages) + */ +const resolveContentValue = (text: string): string => { + const parts: string[] = [] + const re = /\{\{\s*(\w+)\s*\}\}/g + let last = 0 + let match: RegExpExecArray | null + + while ((match = re.exec(text)) !== null) { + const literal = text.slice(last, match.index) + if (literal) parts.push(`"${literal.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`) + const token = match[1] + if (token === 'page') parts.push('counter(page)') + else if (token === 'totalPages') parts.push('counter(pages)') + else parts.push(`"{{ ${token} }}"`) + last = match.index + match[0].length + } + + const remaining = text.slice(last) + if (remaining) parts.push(`"${remaining.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`) + return parts.join(' ') || 'none' +} + +/** + * Generate an `@page` CSS rule from a PdfPageConfig. + * Returns an empty string when no config is provided. + */ +export const frontmatterToPageCss = (pdf?: PdfPageConfig): string => { + if (!pdf || Object.keys(pdf).length === 0) return '' + + const { format = 'A4', orientation, margin, header, headerLeft, headerRight, footer, footerLeft, footerRight } = pdf + + const sizeDecl = orientation ? `size: ${format} ${orientation};` : `size: ${format};` + const marginDecl = margin ? `margin: ${resolveMargin(margin)};` : '' + + const marginBoxes: Array<[string, string]> = [] + if (header) marginBoxes.push(['top-center', resolveContentValue(header)]) + if (headerLeft) marginBoxes.push(['top-left', resolveContentValue(headerLeft)]) + if (headerRight) marginBoxes.push(['top-right', resolveContentValue(headerRight)]) + if (footer) marginBoxes.push(['bottom-center', resolveContentValue(footer)]) + if (footerLeft) marginBoxes.push(['bottom-left', resolveContentValue(footerLeft)]) + if (footerRight) marginBoxes.push(['bottom-right', resolveContentValue(footerRight)]) + + const inner = [sizeDecl, marginDecl, ...marginBoxes.map(([box, content]) => `@${box}{content:${content}}`)].filter( + Boolean + ) + + return `@page{${inner.join('')}}` +} diff --git a/packages/comark-pdf/src/index.ts b/packages/comark-pdf/src/index.ts new file mode 100644 index 00000000..9dbe1f77 --- /dev/null +++ b/packages/comark-pdf/src/index.ts @@ -0,0 +1,47 @@ +import { createMarkdownParser } from 'comark' +import { renderPdfFromDocument } from './render.ts' +import type { PdfRendererOptions } from './types.ts' + +export { assemblePagedHtml, renderPdfBody, renderPdfFromDocument } from './render.ts' +export type { PdfBrowser, PdfMargin, PdfNodeOptions, PdfPage, PdfPageConfig, PdfRendererOptions } from './types.ts' + +/** + * Creates a reusable parse+render function with pre-configured options. + * Returns a function that accepts markdown and produces a complete paged-media HTML document string. + * The underlying parser is initialized once and reused on every call. + * + * @example + * ```typescript + * import { createPdfRenderer } from '@comark/pdf' + * import shiki from '@comark/pdf/plugins/shiki' + * + * const renderPdf = createPdfRenderer({ + * plugins: [shiki()], + * pdf: { format: 'A4', margin: '20mm', footer: 'Page {{ page }} of {{ totalPages }}' }, + * }) + * + * const html = await renderPdf('# Hello\n\n**Bold** text.') + * ``` + */ +export const createPdfRenderer = (options?: PdfRendererOptions): ((markdown: string) => Promise) => { + const parseMarkdown = createMarkdownParser(options) + return async (markdown: string) => { + const document = await parseMarkdown(markdown) + return renderPdfFromDocument(document, options) + } +} + +/** + * Parse markdown and render it to a complete paged-media HTML document string. + * + * @example + * ```typescript + * import { renderPdf } from '@comark/pdf' + * + * const html = await renderPdf('# Hello', { + * pdf: { format: 'A4', margin: '25mm', footer: 'Page {{ page }} of {{ totalPages }}' }, + * }) + * ``` + */ +export const renderPdf = (markdown: string, options?: PdfRendererOptions): Promise => + createPdfRenderer(options)(markdown) diff --git a/packages/comark-pdf/src/node.ts b/packages/comark-pdf/src/node.ts new file mode 100644 index 00000000..5018fa76 --- /dev/null +++ b/packages/comark-pdf/src/node.ts @@ -0,0 +1,100 @@ +import { existsSync } from 'node:fs' +import { writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { renderPdf } from './index.ts' +import type { PdfBrowser, PdfNodeOptions, PdfPage } from './types.ts' + +const require = createRequire(import.meta.url) + +const launchBrowser = async (launchOptions?: Record): Promise => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { chromium } = (await import('playwright')) as any + return chromium.launch(launchOptions) +} + +const getPagedPolyfillPath = (): string => { + // pagedjs exports map blocks ./dist/* and ./package.json subpaths — resolve the entry, then walk to dist. + let polyfillPath: string + try { + polyfillPath = join(dirname(require.resolve('pagedjs')), '..', 'dist', 'paged.polyfill.js') + } catch { + throw new Error('[@comark/pdf] pagedjs is required for PDF export. Install it: pnpm add -D pagedjs') + } + if (!existsSync(polyfillPath)) { + throw new Error(`[@comark/pdf] pagedjs polyfill not found at ${polyfillPath}`) + } + return polyfillPath +} + +const runPagedInPage = async (page: PdfPage, html: string, pdfOptions: Record): Promise => { + const pagedPolyfillPath = getPagedPolyfillPath() + + // Disable paged.js auto-run so we control when pagination triggers. + await page.addInitScript('window.PagedConfig = { auto: false }') + await page.setContent(html, { waitUntil: 'load' }) + await page.addScriptTag({ path: pagedPolyfillPath }) + + // Run pagination and wait for it to complete (pagedjs-cli proven pattern). + await page.evaluate(() => (window as unknown as { PagedPolyfill: { preview(): Promise } }).PagedPolyfill.preview()) + + return page.pdf({ printBackground: true, preferCSSPageSize: true, ...pdfOptions }) +} + +/** + * Render markdown to a PDF Uint8Array using paged.js and Playwright. + * + * Requires both `pagedjs` and `playwright` optional peers to be installed. + * Pass `options.browser` to reuse an existing Playwright browser instance; when omitted, + * a Chromium instance is launched and closed automatically. + * + * @example + * ```typescript + * import { renderPdfToBuffer } from '@comark/pdf/node' + * + * const buffer = await renderPdfToBuffer('# Hello', { + * pdf: { format: 'A4', margin: '20mm', footer: 'Page {{ page }} of {{ totalPages }}' }, + * }) + * await fs.writeFile('output.pdf', buffer) + * ``` + */ +export const renderPdfToBuffer = async (markdown: string, options?: PdfNodeOptions): Promise => { + const html = await renderPdf(markdown, options) + const { browser: injectedBrowser, launchOptions, pdfOptions = {} } = options ?? {} + + let browser: PdfBrowser | undefined = injectedBrowser + let ownBrowser = false + + if (!browser) { + browser = await launchBrowser(launchOptions) + ownBrowser = true + } + + let page: PdfPage | undefined + try { + page = await browser.newPage() + return await runPagedInPage(page, html, pdfOptions) + } finally { + await page?.close() + if (ownBrowser) await browser.close() + } +} + +/** + * Render markdown to a PDF file using paged.js and Playwright. + * + * Convenience wrapper around `renderPdfToBuffer` that writes the output to `outputPath`. + * + * @example + * ```typescript + * import { renderPdfToFile } from '@comark/pdf/node' + * + * await renderPdfToFile('# Hello', 'output.pdf', { + * pdf: { format: 'A4', margin: '20mm' }, + * }) + * ``` + */ +export const renderPdfToFile = async (markdown: string, outputPath: string, options?: PdfNodeOptions): Promise => { + const buffer = await renderPdfToBuffer(markdown, options) + await writeFile(outputPath, buffer) +} diff --git a/packages/comark-pdf/src/pagedjs.d.ts b/packages/comark-pdf/src/pagedjs.d.ts new file mode 100644 index 00000000..7a7f6be5 --- /dev/null +++ b/packages/comark-pdf/src/pagedjs.d.ts @@ -0,0 +1,7 @@ +declare module 'pagedjs' { + export class Previewer { + preview(content: unknown, stylesheets: string[], target: Element): Promise<{ total: number }> + } + export class Chunker {} + export class Polisher {} +} diff --git a/packages/comark-pdf/src/parse.ts b/packages/comark-pdf/src/parse.ts new file mode 100644 index 00000000..b75726ce --- /dev/null +++ b/packages/comark-pdf/src/parse.ts @@ -0,0 +1 @@ +export * from 'comark/parse' diff --git a/packages/comark-pdf/src/plugins/binding.ts b/packages/comark-pdf/src/plugins/binding.ts new file mode 100644 index 00000000..737fed6f --- /dev/null +++ b/packages/comark-pdf/src/plugins/binding.ts @@ -0,0 +1,2 @@ +export * from '@comark/html/plugins/binding' +export { default } from '@comark/html/plugins/binding' diff --git a/packages/comark-pdf/src/plugins/math.ts b/packages/comark-pdf/src/plugins/math.ts new file mode 100644 index 00000000..7fe290ed --- /dev/null +++ b/packages/comark-pdf/src/plugins/math.ts @@ -0,0 +1,2 @@ +export * from '@comark/html/plugins/math' +export { default } from '@comark/html/plugins/math' diff --git a/packages/comark-pdf/src/plugins/mermaid.ts b/packages/comark-pdf/src/plugins/mermaid.ts new file mode 100644 index 00000000..3fde9359 --- /dev/null +++ b/packages/comark-pdf/src/plugins/mermaid.ts @@ -0,0 +1,2 @@ +export * from '@comark/html/plugins/mermaid' +export { default } from '@comark/html/plugins/mermaid' diff --git a/packages/comark-pdf/src/plugins/page-break.ts b/packages/comark-pdf/src/plugins/page-break.ts new file mode 100644 index 00000000..aa8475ad --- /dev/null +++ b/packages/comark-pdf/src/plugins/page-break.ts @@ -0,0 +1,38 @@ +import type { NodeHandler } from 'comark' + +/** + * HTML component render function for `::page-break` nodes. + * + * Parsing of `::page-break` is handled automatically by the core `components` plugin + * (enabled by default). This handler converts the parsed AST node to a CSS fragmentation + * element that paged.js and Chromium's print engine both honour. + * + * Supported attribute: + * type="after" (default) — insert a break after this element + * type="before" — insert a break before this element + * + * @example + * ```typescript + * import { renderPdf } from '@comark/pdf' + * import { PageBreak } from '@comark/pdf/plugins/page-break' + * + * const html = await renderPdf(markdown, { + * components: { 'page-break': PageBreak }, + * }) + * ``` + */ +export const PageBreak: NodeHandler = ([, attrs]) => { + const type = String(attrs.type ?? 'after') + const style = type === 'before' ? 'break-before:page' : 'break-after:page' + return `` +} + +/** + * No-op parser plugin for `::page-break`. + * + * `::page-break` is already parsed by the built-in `components` plugin so this plugin does + * not register any markdown-it rules. It is exported for symmetry with the rest of the + * plugin ecosystem so users can include it in a `plugins` array if they wish. + */ +const pageBreak = () => ({ name: 'page-break' }) +export default pageBreak diff --git a/packages/comark-pdf/src/preview.ts b/packages/comark-pdf/src/preview.ts new file mode 100644 index 00000000..bb45cee3 --- /dev/null +++ b/packages/comark-pdf/src/preview.ts @@ -0,0 +1,31 @@ +/** + * Browser-only paginated preview entry point. + * + * Uses paged.js `Previewer` to paginate the current document content into a target + * container element, producing `.pagedjs_page` wrappers that reflect how the document + * will look when printed. + * + * Requires the `pagedjs` optional peer to be installed. + * + * @example + * ```typescript + * import { paginate } from '@comark/pdf/preview' + * + * const container = document.getElementById('preview') + * const flow = await paginate(container) + * console.log(`Rendered ${flow.total} pages`) + * ``` + */ + +/** + * Paginate the current document content into a target container element using paged.js. + * + * @param target - The DOM element to render pages into. + * @param stylesheets - Optional array of extra CSS strings to apply. + * @returns A paged.js flow object with `total` page count. + */ +export const paginate = async (target: Element, stylesheets: string[] = []): Promise<{ total: number }> => { + const { Previewer } = await import('pagedjs') + const previewer = new Previewer() + return previewer.preview(undefined, stylesheets, target) +} diff --git a/packages/comark-pdf/src/render.ts b/packages/comark-pdf/src/render.ts new file mode 100644 index 00000000..3cab3a9d --- /dev/null +++ b/packages/comark-pdf/src/render.ts @@ -0,0 +1,54 @@ +import type { MarkdownDocument } from 'comark' +import { renderHtmlFromDocument } from '@comark/html/render' +import { DEFAULT_BASE_CSS, frontmatterToPageCss } from './css.ts' +import { PageBreak } from './plugins/page-break.ts' +import type { PdfPageConfig, PdfRendererOptions } from './types.ts' + +export * from 'comark/render' + +/** + * Assemble a complete paged-media HTML document from a rendered body and CSS string. + * The document is ready to be served in a browser with paged.js or passed to a headless PDF exporter. + */ +export const assemblePagedHtml = (body: string, css: string): string => + `\n\n\n\n\n\n\n${body}\n\n` + +/** + * Render a Markdown document to an HTML body string, with the `::page-break` component active. + * Merges caller-provided components so user-defined handlers take precedence. + */ +export const renderPdfBody = async ( + document: MarkdownDocument | { nodes: MarkdownDocument['nodes'] }, + options?: PdfRendererOptions +): Promise => { + const components = { 'page-break': PageBreak, ...options?.components } + return renderHtmlFromDocument(document, { ...options, components }) +} + +/** + * Render a Markdown document to a complete paged-media HTML document string. + * + * Reads `document.frontmatter.pdf` for page configuration, merges in `options.pdf`, + * and wraps the rendered body in a full HTML document with embedded `@page` CSS. + * + * @example + * ```typescript + * import { parseMarkdown } from 'comark' + * import { renderPdfFromDocument } from '@comark/pdf' + * + * const doc = await parseMarkdown('---\npdf:\n format: A4\n---\n# Hello') + * const html = await renderPdfFromDocument(doc) + * ``` + */ +export const renderPdfFromDocument = async ( + document: MarkdownDocument | { nodes: MarkdownDocument['nodes'] }, + options?: PdfRendererOptions +): Promise => { + const frontmatterPdf = ((document as MarkdownDocument).frontmatter?.pdf ?? {}) as PdfPageConfig + const pdfConfig: PdfPageConfig = { ...frontmatterPdf, ...options?.pdf } + const pageCss = frontmatterToPageCss(pdfConfig) + const baseCss = options?.baseCss ?? DEFAULT_BASE_CSS + const css = [baseCss, pageCss].filter(Boolean).join('\n') + const body = await renderPdfBody(document, options) + return assemblePagedHtml(body, css) +} diff --git a/packages/comark-pdf/src/types.ts b/packages/comark-pdf/src/types.ts new file mode 100644 index 00000000..097f094b --- /dev/null +++ b/packages/comark-pdf/src/types.ts @@ -0,0 +1,61 @@ +import type { ParserOptions, RendererOptions } from 'comark' + +export interface PdfMargin { + top?: string + right?: string + bottom?: string + left?: string +} + +export interface PdfPageConfig { + /** Named page format or custom dimensions (e.g. 'A4', 'Letter', '210mm 297mm'). Default: 'A4'. */ + format?: string + /** Page orientation. Default: 'portrait'. */ + orientation?: 'portrait' | 'landscape' + /** Page margin as a shorthand string (e.g. '20mm') or per-side object. Default: '20mm'. */ + margin?: string | PdfMargin + /** Center header text. Tokens: {{ page }}, {{ totalPages }}. */ + header?: string + /** Left header text. */ + headerLeft?: string + /** Right header text. */ + headerRight?: string + /** Center footer text. Tokens: {{ page }}, {{ totalPages }}. */ + footer?: string + /** Left footer text. */ + footerLeft?: string + /** Right footer text. */ + footerRight?: string +} + +export interface PdfRendererOptions extends ParserOptions, RendererOptions { + /** Explicit PDF page configuration. Merged over frontmatter.pdf. */ + pdf?: PdfPageConfig + /** Additional CSS injected into the document ...` string. -- `css.ts` - `frontmatterToPageCss(pdf: PdfPageConfig): string`. Generates `@page { size: ; margin: ; @top-center { content: ... } @bottom-center { content: ... } }`. Translate footer/header tokens `{{ page }}` -> `counter(page)` and `{{ totalPages }}` -> `counter(pages)`. Include a small default `baseCss` (e.g. `.comark-page-break { break-after: page; }`). -- `index.ts` - public three-tier API mirroring [packages/comark-html/src/index.ts](packages/comark-html/src/index.ts): - - `createPdfRenderer(options?) => (markdown) => Promise` (returns the assembled paged-media HTML document string): builds the parser once via `createMarkdownParser`, then per call parses, computes `frontmatterToPageCss(doc.frontmatter.pdf)`, renders the body, and assembles. - - `renderPdf(markdown, options?)` one-shot wrapper. - - `renderPdfFromDocument(document, options?)` render-only path. -- `preview.ts` (browser) - `paginate(html, target, css?)` using `new Previewer()` from `pagedjs`; returns the paged flow (exposes total page count). Lazy `import('pagedjs')` so importing the package never hard-requires it. -- `node.ts` (Node) - `renderPdfToBuffer(markdown, options?) => Promise` and `renderPdfToFile(markdown, path, options?)`: - - call `renderPdf` for the HTML string; - - accept an injected `options.browser` (Playwright `Browser`) or lazy `import('playwright')` -> `chromium.launch(options.launchOptions)`; - - `page.setContent(html)`, set `window.PagedConfig = { after }` hook, `page.addScriptTag({ path: require.resolve('pagedjs/dist/paged.polyfill.js') })`, `page.waitForFunction(() => window.__pagedDone)` (proven `pagedjs-cli` pattern); - - `page.pdf({ printBackground: true, preferCSSPageSize: true, ...options.pdfOptions })`; close the browser only if this function launched it. -- `plugins/page-break.ts` - export a `PageBreak` `NodeHandler` (emits `
`, honoring an optional `type="before"|"after"` attr) and a default no-op `defineComarkPlugin` (parsing of `::page-break` already works through the core components plugin, confirmed by the AST research: it yields `['page-break', attrs, ...children]`). Re-export `binding`/`math`/`mermaid` component handlers from `@comark/html/plugins/*` so PDF documents can use them without duplicating logic. -- `utils/index.ts` - `export * from 'comark/utils'`. - -## Monorepo wiring - -- Add `'comark-pdf'` to `frameworkPackages` in [scripts/sync-plugins.mjs](scripts/sync-plugins.mjs) so all parser-only core plugins (shiki, alert, toc, emoji, ...) resolve via `@comark/pdf/plugins/*`. -- Add `"@comark/pdf": "workspace:*"` to `resolutions` in the root [package.json](package.json); optionally add a `"dev:pdf"` script. -- Add `pagedjs` to the `catalog:` in [pnpm-workspace.yaml](pnpm-workspace.yaml) (`playwright` already exists there). -- After first real build, refresh the auto-enumerated snapshot in [test/bundle.test.ts](test/bundle.test.ts) with `pnpm prepack && pnpm vitest run bundle -u` (the package is picked up automatically because it is non-private). - -## Tests (`packages/comark-pdf/test/`) - -Deterministic, no-browser tests are the core: -- `css.test.ts` - `frontmatterToPageCss` for format/orientation/margin variants and header/footer counter translation. -- `page-break.test.ts` - `::page-break` parses to `['page-break', ...]` and `PageBreak` emits the break element. -- `index.test.ts` - `renderPdf` assembles a full HTML doc with `@page` CSS from frontmatter and the rendered body. - -Browser/Node paths (`preview`, `node`) get one guarded smoke test each (CI already installs Chromium via Playwright), skipped when the peer is absent. - -## Docs - -Per repo rules, after implementation update [AGENTS.md](AGENTS.md) (new package section + Package Exports Reference) and add a `docs/content/3.rendering/` page for the PDF renderer. From 10a924ea8f9bb69fa7ad5b895693963b39e2a764 Mon Sep 17 00:00:00 2001 From: miguelrk Date: Mon, 14 Sep 2026 12:48:06 +0200 Subject: [PATCH 3/7] test(pdf): add test coverage for browser (`paged.js`) and node (`playwright`) PDF rendering - Added optional `content` parameter to `paginate` function for improved flexibility in document pagination. - Updated `package.json` and `pnpm-lock.yaml` to include new dependencies: `@vitest/browser`, `@vitest/browser-playwright`, and `katex`. - Expanded test coverage for PDF rendering, including multi-page document structure and math rendering with the math plugin. - Updated `.gitignore` to exclude PDF test output directory. --- .gitignore | 3 + packages/comark-pdf/package.json | 3 + packages/comark-pdf/src/preview.ts | 11 ++- packages/comark-pdf/test/fixtures/markdown.ts | 74 +++++++++++++++++++ packages/comark-pdf/test/index.test.ts | 47 +++++++++++- packages/comark-pdf/test/node.test.ts | 51 +++++++++++++ .../comark-pdf/test/preview.browser.test.ts | 41 ++++++++++ packages/comark-pdf/vitest.config.ts | 31 ++++++++ pnpm-lock.yaml | 9 +++ 9 files changed, 266 insertions(+), 4 deletions(-) create mode 100644 packages/comark-pdf/test/fixtures/markdown.ts create mode 100644 packages/comark-pdf/test/node.test.ts create mode 100644 packages/comark-pdf/test/preview.browser.test.ts create mode 100644 packages/comark-pdf/vitest.config.ts diff --git a/.gitignore b/.gitignore index 90ba990f..74bcc82d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ # examples output .output + +# PDF test output +packages/comark-pdf/test/output/ .data .nuxt .nitro diff --git a/packages/comark-pdf/package.json b/packages/comark-pdf/package.json index 05f81ae3..0cb99567 100644 --- a/packages/comark-pdf/package.json +++ b/packages/comark-pdf/package.json @@ -54,6 +54,9 @@ "comark": "workspace:*" }, "devDependencies": { + "@vitest/browser": "catalog:", + "@vitest/browser-playwright": "catalog:", + "katex": "^0.17.0", "pagedjs": "catalog:", "playwright": "catalog:", "vitest": "catalog:" diff --git a/packages/comark-pdf/src/preview.ts b/packages/comark-pdf/src/preview.ts index bb45cee3..a9f5b887 100644 --- a/packages/comark-pdf/src/preview.ts +++ b/packages/comark-pdf/src/preview.ts @@ -18,14 +18,19 @@ */ /** - * Paginate the current document content into a target container element using paged.js. + * Paginate document content into a target container element using paged.js. * * @param target - The DOM element to render pages into. * @param stylesheets - Optional array of extra CSS strings to apply. + * @param content - Optional HTML string to paginate. When omitted, paged.js reads the current document body. * @returns A paged.js flow object with `total` page count. */ -export const paginate = async (target: Element, stylesheets: string[] = []): Promise<{ total: number }> => { +export const paginate = async ( + target: Element, + stylesheets: string[] = [], + content?: string, +): Promise<{ total: number }> => { const { Previewer } = await import('pagedjs') const previewer = new Previewer() - return previewer.preview(undefined, stylesheets, target) + return previewer.preview(content, stylesheets, target) } diff --git a/packages/comark-pdf/test/fixtures/markdown.ts b/packages/comark-pdf/test/fixtures/markdown.ts new file mode 100644 index 00000000..31094920 --- /dev/null +++ b/packages/comark-pdf/test/fixtures/markdown.ts @@ -0,0 +1,74 @@ +/** + * Shared markdown fixtures for @comark/pdf tests. + * BASIC_MARKDOWN: minimal two-page document with common Markdown elements. + * ADVANCED_MARKDOWN: document that uses more Comark plugins and PDF frontmatter options. + */ + +export const BASIC_MARKDOWN = `--- +pdf: + format: A4 + margin: 20mm +--- + +# Hello PDF + +This is a **basic** paragraph with _italic_ text and a [link](https://comark.dev). + +## Lists + +- Item one +- Item two +- Item three + +| Column A | Column B | +|----------|----------| +| Cell 1 | Cell 2 | +| Cell 3 | Cell 4 | + +::page-break +:: + +## Second Page + +> A blockquote on the second page. + +A final paragraph to confirm multi-page rendering. +` + +export const ADVANCED_MARKDOWN = `--- +title: Advanced PDF +pdf: + format: A4 + margin: 20mm + header: 'Advanced PDF' + footer: 'Page {{ page }} of {{ totalPages }}' +--- + +# Advanced Document + +## Code Block + +\`\`\`js +const greet = (name) => \`Hello, \${name}!\` +console.log(greet('World')) +\`\`\` + +## Math + +Inline: $E = mc^2$. + +Block: + +$$ +\\int_0^\\infty e^{-x^2}\\,dx = \\frac{\\sqrt{\\pi}}{2} +$$ + +::page-break +:: + +## Second Page + +> A blockquote on the second page. + +Additional content to verify multi-page layout and page numbering in the footer. +` diff --git a/packages/comark-pdf/test/index.test.ts b/packages/comark-pdf/test/index.test.ts index bfdc3c6f..ec9319c8 100644 --- a/packages/comark-pdf/test/index.test.ts +++ b/packages/comark-pdf/test/index.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest' -import { renderPdf, createPdfRenderer, renderPdfFromDocument } from '../src/index.ts' import { parseMarkdown } from 'comark' +import { createPdfRenderer, renderPdf, renderPdfFromDocument } from '../src/index.ts' +import math, { Math as MathComponent } from '../src/plugins/math.ts' +import { PageBreak } from '../src/plugins/page-break.ts' +import { BASIC_MARKDOWN, ADVANCED_MARKDOWN } from './fixtures/markdown.ts' describe('renderPdf', () => { it('returns a complete HTML document', async () => { @@ -59,6 +62,34 @@ describe('renderPdf', () => { }) expect(html).toContain('