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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ npm run test:watch # vitest
- `POST /convert/markdown-to-pdf` — render Markdown to PDF; returns `application/pdf` bytes,
or `{ pdfBase64, bytes }` with `Accept: application/json` / `?format=base64` (for embedding
in a GraphQL response — the shape `hadron-server`'s `nodeExport` PDF path consumes).
- `POST /convert/html-to-pdf` — render HTML to PDF; same response shapes,
auth and page geometry as `markdown-to-pdf`. JSON `{ html, options? }`, or a raw `text/html` body
when no options are needed. The HTML is rendered **verbatim** — no wrapper, no default
stylesheet — whether it is a complete document or a bare fragment Chromium completes. This
direction exists for output that is a *layout* rather than prose
(certificate, invoice, report cover), where the Markdown path's wrapper fights the caller's own
positioning. `options.css` is appended after the document's own styles; `options.title` sets the
download filename only, since the document already carries a `<title>`. Wraps the same
`htmlToPdf()` the Markdown path calls as its second stage, so it adds no rendering capability.
- `POST /convert/markdown-to-html` — render Markdown to a complete, self-contained HTML document
(inline print CSS, no external fetches); returns `text/html` bytes, or `{ html, bytes }` with
`Accept: application/json` / `?format=json`. Consumer: `hadron-server`'s `nodeExport` HTML phase
Expand Down
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ platform and why the boundary is drawn here.
| Method | Path | Purpose |
|---|---|---|
| `POST` | `/convert/markdown-to-pdf` | Render Markdown to a PDF |
| `POST` | `/convert/html-to-pdf` | Render an HTML document to a PDF |
| `POST` | `/convert/markdown-to-html` | Render Markdown to a self-contained HTML document |
| `POST` | `/convert/pdf-to-markdown` | Extract Markdown text from a PDF |
| `POST` | `/convert/html-to-markdown` | Convert content HTML to Markdown |
| `GET` | `/convert/info` | Service capabilities (no secrets) |
| `GET` | `/healthz` | Liveness |
| `GET` | `/readyz` | Readiness (+ whether Chromium is warm) |
Expand Down Expand Up @@ -51,6 +54,53 @@ curl -sS -X POST http://localhost:8080/convert/markdown-to-pdf \
-o out.pdf
```

### `POST /convert/html-to-pdf`

For documents that are a **layout** rather than prose — a certificate, an
invoice, a report cover, a badge. Routing those through Markdown means embedding
raw HTML in Markdown and fighting the converter over what it escapes, wraps or
reflows; here your HTML is rendered **verbatim**, with no wrapper and no default
stylesheet. Usually that is a complete document — a bare fragment works too,
since Chromium supplies the missing structure.

Request (JSON), or a raw `text/html` body when you don't need options:

```jsonc
{
"html": "<!DOCTYPE html><html>…</html>",
"options": {
"title": "Certificate", // filename only — the document has its own <title>
"format": "A4", // A4 | A3 | A5 | Letter | Legal | Tabloid
"landscape": true,
"printBackground": true,
"margin": { "top": "0mm", "right": "0mm", "bottom": "0mm", "left": "0mm" },
"css": "@page { size: A4 landscape }" // optional, appended AFTER your styles
}
}
```

Response: identical to `markdown-to-pdf` — `application/pdf` bytes by default,
or `{ "pdfBase64": "...", "bytes": 12345 }` with `Accept: application/json` or
`?format=base64`.

```bash
curl -sS -X POST http://localhost:8080/convert/html-to-pdf \
-H 'content-type: application/json' \
-H "authorization: Bearer $PDF_SERVICE_TOKEN" \
-d '{"html":"<h1>Hello</h1>","options":{"landscape":true}}' \
-o out.pdf
```

**Inline your assets as `data:` URIs.** Rendering runs with JavaScript disabled
and, unless `PDF_ALLOW_REMOTE=true`, every non-`data:` request is aborted — so a
background image or signature referenced by URL renders as nothing and still
returns `200`. Inlining also makes the render deterministic and offline, which
matters for a document people keep.

**Fonts are the image's, not yours.** A document naming a typeface the container
does not have falls back silently. Embed the face as a `data:` URI `@font-face`
if exact typography matters.

### `POST /convert/pdf-to-markdown`

Send the PDF either as a raw `application/pdf` body or as JSON
Expand Down
62 changes: 59 additions & 3 deletions src/lib/htmlToPdf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,41 @@ import { config } from '../config.js';
import type { PdfOptions } from '../types.js';

/**
* Render a complete HTML document to PDF bytes using the shared headless
* browser. Each call gets its own page, which is always closed.
* Render an HTML document to PDF bytes using the shared headless browser. Each
* call gets its own page, which is always closed.
*
* Security hardening: JavaScript is disabled (the document is static print
* content), and unless PDF_ALLOW_REMOTE is set, every non-inline request is
* aborted — only `data:` and the initial `about:blank`/document load proceed.
* That blocks `file://` reads and remote (SSRF) fetches via crafted Markdown.
*
* `extraCss` is applied to the PARSED DOCUMENT, after `setContent`, never by
* concatenating it to the source string. The Markdown path does not use it —
* `markdownToHtml` bakes `options.css` into the document it builds — so it is
* only the HTML path, where the source is the caller's and cannot be edited
* safely, that passes it.
*
* Why it has to be the DOM. Deciding where a `<style>` belongs in someone
* else's markup means knowing the tokenizer's state, and every string-level
* answer has a document that defeats it: a `</head>` that is really text
* (inside a comment, raw text, RCDATA, or a quoted attribute value) sends the
* block somewhere it is parsed as content, and appending at the end is
* swallowed whole by a document ending in an open context (`<textarea>text`,
* `<!-- comment`). Every one of those failures is SILENT — a 200, a well-formed
* PDF, and no styles applied. Adding a node to the parsed document skips the
* question: the browser has already resolved the markup, and the node lands in
* `<head>` after the document's own styles, which is the cascade position
* `css` is documented to have.
*
* It also retires the `</style>` neutralization that the string form needed:
* DOM text content cannot close an element, so caller CSS containing `</style>`
* is now just CSS.
*/
export async function htmlToPdf(html: string, options: PdfOptions): Promise<Buffer> {
export async function htmlToPdf(
html: string,
options: PdfOptions,
extraCss?: string,
): Promise<Buffer> {
const browser = await getBrowser();
const page = await browser.newPage();
try {
Expand All @@ -34,7 +60,37 @@ export async function htmlToPdf(html: string, options: PdfOptions): Promise<Buff
void req.abort();
});

// A caller document carrying its own Content-Security-Policy would
// otherwise block the style node added below, and block it SILENTLY — the
// injection call still succeeds and the PDF comes back unstyled. Safe here
// because the two things CSP would be protecting against are already gone:
// page scripts are disabled, and every non-`data:` request is aborted
// regardless of what any policy permits.
await page.setBypassCSP(true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve caller CSP when no CSS override is requested

Because CSP bypass is enabled for every render, even requests without options.css no longer render the supplied document verbatim: for example, a document with style-src 'none' will have its otherwise-blocked inline styles applied, and with PDF_ALLOW_REMOTE=true CSP-blocked HTTP resources may also load. Enable the bypass only when an override actually needs it, or inject that override through a mechanism that does not disable the caller's policy globally.

Useful? React with 👍 / 👎.


await page.setContent(html, { waitUntil: 'load', timeout: 30_000 });

// Applied to the PARSED document, after setContent, so there is no
// tokenizer state left to guess at.
//
// Appended to the END of the body rather than to <head>: a caller may carry
// a <style> or a stylesheet link in the body, which is later in document
// order than <head> and would therefore win at equal specificity. `css` is
// documented to come after ALL the caller's styles, so it has to be last in
// the document, not merely present.
//
// This works despite setJavaScriptEnabled(false): that disables scripts
// belonging to the PAGE, while this runs over the DevTools protocol, which
// is not page script. Pinned by end-to-end tests rather than trusted — a
// silent failure here looks exactly like success.
if (extraCss) {
await page.evaluate((cssText: string) => {
const style = document.createElement('style');
style.textContent = cssText;
(document.body ?? document.documentElement).appendChild(style);
Comment on lines +88 to +90

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wait for resources started by the injected stylesheet

When PDF_ALLOW_REMOTE=true and options.css contains a delayed @import or a remote background image, this evaluation resolves as soon as the style node is appended, not when its newly started requests finish; the code then proceeds directly to page.pdf(), which can print before those rules or images arrive and silently omit them. Wait for the injected stylesheet's relevant loads or for network idle before generating the PDF.

Useful? React with 👍 / 👎.

}, extraCss);
}

await page.emulateMediaType('print');

const pdf = await page.pdf({
Expand Down
167 changes: 167 additions & 0 deletions src/routes/convert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ describe('health + routing', () => {
const res = await request(app).get('/convert/info');
expect(res.status).toBe(200);
expect(res.body.directions).toContain('markdown-to-pdf');
expect(res.body.directions).toContain('html-to-pdf');
expect(res.body.directions).toContain('markdown-to-html');
expect(res.body.directions).toContain('pdf-to-markdown');
expect(res.body.directions).toContain('html-to-markdown');
Expand All @@ -54,6 +55,33 @@ describe('POST /convert/markdown-to-pdf validation', () => {
});
});

describe('POST /convert/html-to-pdf validation', () => {
it('rejects an empty html body', async () => {
const res = await request(app).post('/convert/html-to-pdf').send({ html: '' });
expect(res.status).toBe(400);
});

it('rejects a missing html field', async () => {
const res = await request(app).post('/convert/html-to-pdf').send({});
expect(res.status).toBe(400);
});

it('rejects an empty raw text/html body', async () => {
const res = await request(app)
.post('/convert/html-to-pdf')
.set('content-type', 'text/html')
.send('');
expect(res.status).toBe(400);
});

it('rejects an unknown paper format', async () => {
const res = await request(app)
.post('/convert/html-to-pdf')
.send({ html: '<p>hi</p>', options: { format: 'Poster' } });
expect(res.status).toBe(400);
});
});

describe('POST /convert/pdf-to-markdown validation', () => {
it('rejects a non-PDF raw body', async () => {
const res = await request(app)
Expand Down Expand Up @@ -206,4 +234,143 @@ describe('round-trip (requires Chromium)', () => {
expect(typeof res.body.pdfBase64).toBe('string');
expect(Buffer.from(res.body.pdfBase64, 'base64').subarray(0, 5).toString('latin1')).toBe('%PDF-');
});

it('html -> pdf renders the caller document verbatim, then round-trips back', async (ctx) => {
if (!browserOk) ctx.skip();

const html =
'<!DOCTYPE html><html><head><title>Ignored</title></head>' +
'<body><h1>Certificate of Completion</h1><p>Awarded to Amara Okonkwo.</p></body></html>';

const pdfRes = await request(app)
.post('/convert/html-to-pdf')
.send({ html, options: { title: 'Cert', landscape: true } })
.buffer(true)
.parse((res, cb) => {
const chunks: Buffer[] = [];
res.on('data', (c: Buffer) => chunks.push(c));
res.on('end', () => cb(null, Buffer.concat(chunks)));
});

expect(pdfRes.status).toBe(200);
expect(pdfRes.headers['content-type']).toContain('application/pdf');
expect(pdfRes.headers['content-disposition']).toContain('Cert.pdf');
const pdf = pdfRes.body as Buffer;
expect(pdf.subarray(0, 5).toString('latin1')).toBe('%PDF-');

const mdRes = await request(app)
.post('/convert/pdf-to-markdown')
.set('content-type', 'application/pdf')
.send(pdf);

expect(mdRes.status).toBe(200);
expect(mdRes.body.markdown.toLowerCase()).toContain('certificate of completion');
expect(mdRes.body.markdown).toContain('Amara Okonkwo');
});

it('accepts a raw text/html body and returns base64 on request', async (ctx) => {
if (!browserOk) ctx.skip();
const res = await request(app)
.post('/convert/html-to-pdf?format=base64')
.set('content-type', 'text/html')
.send('<h1>Raw body</h1>');
expect(res.status).toBe(200);
expect(Buffer.from(res.body.pdfBase64, 'base64').subarray(0, 5).toString('latin1')).toBe('%PDF-');
});

it('renders an inlined data: URI image with remote fetching disabled', async (ctx) => {
if (!browserOk) ctx.skip();
// The hardening aborts every non-data: request, so an inlined asset is the
// only way a layout can carry a background or a signature. Pinning it here
// because a regression would return 200 with the image silently missing.
const svg = Buffer.from(
'<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40"><rect width="40" height="40" fill="#0b5cd5"/></svg>',
).toString('base64');
const html = `<body><img src="data:image/svg+xml;base64,${svg}"><p>with art</p></body>`;

const res = await request(app).post('/convert/html-to-pdf?format=base64').send({ html });
expect(res.status).toBe(200);
expect(Buffer.from(res.body.pdfBase64, 'base64').subarray(0, 5).toString('latin1')).toBe('%PDF-');
});

it('the css option actually takes effect in the rendered PDF', async (ctx) => {
if (!browserOk) ctx.skip();

// Asserts the OUTCOME rather than where the <style> element was placed.
// That distinction is the whole point here: the failure mode this endpoint
// kept producing was a stylesheet parsed as text, which returns 200 with a
// well-formed PDF and no styles applied — indistinguishable from success
// unless something reads the rendered page back.
const html = '<html><head></head><body><p>VISIBLEMARKER</p></body></html>';

const renderThenExtract = async (options?: Record<string, unknown>): Promise<string> => {
const pdfRes = await request(app)
.post('/convert/html-to-pdf?format=base64')
.send(options ? { html, options } : { html });
expect(pdfRes.status).toBe(200);

const mdRes = await request(app)
.post('/convert/pdf-to-markdown')
.set('content-type', 'application/pdf')
.send(Buffer.from(pdfRes.body.pdfBase64, 'base64'));
expect(mdRes.status).toBe(200);
return mdRes.body.markdown;
};

expect(await renderThenExtract(), 'control: the text renders').toContain('VISIBLEMARKER');
expect(
await renderThenExtract({ css: 'p{display:none}' }),
'the caller css hid it, so the stylesheet was applied',
).not.toContain('VISIBLEMARKER');
});

/**
* The documents that defeated every string-level way of placing the <style>.
* Each contains a `</head>` that is TEXT rather than a tag, or ends in an open
* tokenizer context that swallows anything appended after it. Applying the CSS
* to the parsed document instead of the source string makes all of them
* ordinary — and each failure they used to cause was silent, so they are
* asserted on the rendered output rather than on the markup.
*/
// One test looping the cases rather than `it.each`, because `it.each` does not
// pass the test context as a trailing argument — so `ctx.skip()` throws where
// Chromium is unavailable, which is precisely the environment the skip exists
// for. It passed locally (browser present, skip branch never taken) and failed
// in CI.
it('applies the css option to documents that defeat string-level injection', async (ctx) => {
if (!browserOk) ctx.skip();

const cases: [string, string][] = [
['</head> in a comment', '<head><!-- </head> --></head><body><p>MARKER</p></body>'],
['</head> in a title', '<head><title>x </head> y</title></head><body><p>MARKER</p></body>'],
['</head> in an attribute', '<head><meta content="a </head> b"></head><body><p>MARKER</p></body>'],
['an unclosed textarea', '<body><p>MARKER</p><textarea>trailing'],
['an unterminated comment', '<body><p>MARKER</p><!-- trailing'],
// Later in document order than <head>, so it wins at equal specificity
// unless the override is appended at the END of the document.
['a body-level stylesheet', '<body><p>MARKER</p><style>p{display:block}</style></body>'],
// Would block an injected inline style, silently, without a CSP bypass.
[
'a restrictive Content-Security-Policy',
'<head><meta http-equiv="Content-Security-Policy" content="style-src \'none\'"></head>' +
'<body><p>MARKER</p></body>',
],
];

for (const [label, html] of cases) {
const res = await request(app)
.post('/convert/html-to-pdf?format=base64')
.send({ html, options: { css: 'p{display:none}' } });
expect(res.status, label).toBe(200);

const md = await request(app)
.post('/convert/pdf-to-markdown')
.set('content-type', 'application/pdf')
.send(Buffer.from(res.body.pdfBase64, 'base64'));
expect(md.status, label).toBe(200);
expect(md.body.markdown, `${label}: the stylesheet reached the document`).not.toContain(
'MARKER',
);
}
});
});
Loading
Loading