@x12i/md-images-pdf 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,276 @@
1
- # Temporary Holding Version
1
+ # @x12i/md-images-pdf
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A Node.js package and CLI that combine **Markdown + image files into a PDF**.
4
+
5
+ - A filename such as `architecture.svg` in the Markdown is replaced with that image.
6
+ - Standard `![Description](architecture.svg)` syntax and reference-style image links work too.
7
+ - Supplied images that are never referenced go into an **Additional images** appendix.
8
+ - Returns a PDF `Buffer`, optionally writes a file, and reports every image placement.
9
+ - Supports PNG, JPEG, WebP, GIF, SVG, Markdown tables, code blocks, and RTL text.
10
+ - Runs locally. No external service or API key is required. Conversion makes no network requests.
11
+
12
+ ## Setup
13
+
14
+ Requires **Node.js 20 or newer** and Chromium. The package is provided as source and a local npm tarball; it has not been published to the npm registry.
15
+
16
+ Install the supplied tarball in your project:
17
+
18
+ ```bash
19
+ npm install ./x12i-md-images-pdf-1.0.0.tgz
20
+ npx playwright install chromium
21
+ ```
22
+
23
+ On a Linux host that needs browser system libraries:
24
+
25
+ ```bash
26
+ npx playwright install --with-deps chromium
27
+ ```
28
+
29
+ Or work directly from the source directory:
30
+
31
+ ```bash
32
+ npm ci
33
+ npx playwright install chromium
34
+ npm test
35
+ npm run test:integration
36
+ npm run example
37
+ ```
38
+
39
+ Chromium installation is a one-time setup step for each machine/container. Match the installed browser to the Playwright version in your project lockfile. To use an existing Chromium binary, pass `executablePath` or the CLI's `--executable-path` flag; compatible versions work best.
40
+
41
+ ## JavaScript API
42
+
43
+ ```js
44
+ import { markdownToPdf } from '@x12i/md-images-pdf';
45
+
46
+ const result = await markdownToPdf({
47
+ markdownFile: './input/report.md',
48
+ imageDirectories: ['./images'], // Relative to input/report.md's directory.
49
+ outputFile: './output/report.pdf',
50
+ title: 'Quarterly report',
51
+ });
52
+
53
+ console.log(result.outputFile);
54
+ console.log(result.report.images);
55
+ // result.pdf is always a Buffer, even when outputFile is supplied.
56
+ ```
57
+
58
+ This package uses ESM. From CommonJS, load it with `const { markdownToPdf } = await import('@x12i/md-images-pdf')` inside an async function.
59
+
60
+ Given this Markdown:
61
+
62
+ ```md
63
+ # Quarterly report
64
+
65
+ Here is the architecture:
66
+
67
+ architecture.svg
68
+
69
+ ## Revenue
70
+
71
+ ![Monthly revenue](revenue.png)
72
+ ```
73
+
74
+ And these supplied images:
75
+
76
+ ```text
77
+ architecture.svg
78
+ revenue.png
79
+ notes.svg
80
+ ```
81
+
82
+ The architecture and revenue images appear at their references. `notes.svg` appears at the end, under **Additional images**.
83
+
84
+ ### Markdown strings and explicit image paths
85
+
86
+ ```js
87
+ const { pdf, report } = await markdownToPdf({
88
+ markdown: '# Overview\n\narchitecture.svg\n\nEnd of document.',
89
+ baseDir: './input',
90
+ images: [
91
+ './architecture.svg',
92
+ { path: './notes.svg', caption: 'Supporting notes' },
93
+ ],
94
+ unusedImages: 'append',
95
+ });
96
+
97
+ // For example, return pdf from your HTTP handler with Content-Type: application/pdf.
98
+ ```
99
+
100
+ ### Uploaded images / in-memory input
101
+
102
+ ```js
103
+ const { pdf } = await markdownToPdf({
104
+ markdown: '# Uploaded report\n\nchart.png',
105
+ images: [
106
+ { name: 'chart.png', data: uploadedImageBuffer, caption: 'Revenue by month' },
107
+ { name: 'extra.jpg', data: anotherBuffer },
108
+ ],
109
+ resolveMarkdownImages: false,
110
+ });
111
+ ```
112
+
113
+ `data` accepts a Node.js `Buffer` or `Uint8Array`. Image type is detected from bytes; no MIME argument is needed. Every image must have a filename with a supported extension. Use `resolveMarkdownImages: false` when a service should use only explicitly supplied uploads.
114
+
115
+ ### CLI
116
+
117
+ ```bash
118
+ npx md-images-pdf report.md --images-dir ./images --output ./output/report.pdf
119
+
120
+ npx md-images-pdf report.md \
121
+ --image ./architecture.svg \
122
+ --image ./notes.svg \
123
+ --output ./output/report.pdf \
124
+ --report ./output/placements.json
125
+
126
+ npx md-images-pdf report.md -d ./images --format Letter --margin 20mm
127
+
128
+ npx md-images-pdf hebrew.md -d ./images --direction rtl --lang he
129
+
130
+ npx md-images-pdf --help
131
+ ```
132
+
133
+ **CLI image paths** resolve from your current working directory. **API image paths** resolve from `baseDir`, which defaults to the Markdown file's directory (or the current working directory for a Markdown string). Markdown image links always resolve from `baseDir`.
134
+
135
+ With no `--output`, the CLI writes a PDF next to the Markdown file using the same stem. The API writes only when `outputFile` is specified. Existing output files are replaced after successful PDF generation.
136
+
137
+ ## Placement rules
138
+
139
+ 1. Explicit Markdown image links are resolved first: `![Alt text](diagram.png)` and reference-style links work. Files under `baseDir` can be loaded automatically even when absent from `images`.
140
+ 2. In ordinary text, known supplied filenames and relative paths are replaced by image blocks at that position. Surrounding text stays in place. Put a filename on its own line for the cleanest layout.
141
+ 3. A filename-only code span, such as `` `diagram.png` ``, also inserts the image. Fenced/indented code blocks, code spans containing actual code, link labels, URLs, and image alt text are not searched for filenames.
142
+ 4. Every occurrence inserts an image. A referenced image is never also appended.
143
+ 5. Unreferenced supplied images append once each. Explicit input order is preserved; directories are scanned in sorted filename order, depth first. Hidden entries and symlinks are skipped.
144
+ 6. Filenames are case-sensitive by default. Spaces and Unicode names work. For a Markdown image URL with spaces, use `![Alt](<my chart.png>)` or a percent-encoded path.
145
+ 7. Multiple files with the same basename make an unqualified reference ambiguous and raise `AMBIGUOUS_IMAGE`. Use `first/logo.png` and `second/logo.png`, or assign unique `name` values in the image objects.
146
+ 8. Re-supplying the same canonical file path does not append it twice. Two separate in-memory image objects are treated as separate inputs.
147
+
148
+ Plain-text placement only recognizes known image aliases. A bare filename that was not supplied stays as text; an unresolved explicit Markdown image link raises an error by default.
149
+
150
+ ## Options
151
+
152
+ Supply exactly one of `markdown` (string) or `markdownFile` (file path).
153
+
154
+ | Option | Default | Purpose |
155
+ | --- | --- | --- |
156
+ | `images` | `[]` | Paths, `{path, name?, alt?, caption?}`, or `{name, data, alt?, caption?}` |
157
+ | `imageDirectories` | `[]` | Directories of supported images |
158
+ | `baseDir` | Markdown directory / cwd | Base for relative API paths and Markdown image links |
159
+ | `recursive` | `true` | Include nested image directories |
160
+ | `caseSensitive` | `true` | Filename comparison mode |
161
+ | `resolveMarkdownImages` | `true` | Load local explicit Markdown links inside `baseDir` |
162
+ | `unusedImages` | `'append'` | `'append'`, `'ignore'`, or `'error'` |
163
+ | `missingImages` | `'error'` | `'error'` or `'placeholder'`; placeholders add a warning |
164
+ | `captions` | `true` | Show image captions; defaults to the image's name |
165
+ | `appendixTitle` | `'Additional images'` | Heading before unused images |
166
+ | `title` | `'Document'` | Document/PDF title |
167
+ | `lang` | `'en'` | HTML language tag |
168
+ | `direction` | `'auto'` | `'auto'`, `'ltr'`, or `'rtl'` |
169
+ | `css` | `''` | Trusted CSS appended after built-in print styles |
170
+ | `outputFile` | unset | Optional file destination |
171
+ | `page.format` | `'A4'` | A3, A4, A5, Letter, Legal, or Tabloid |
172
+ | `page.landscape` | `false` | Landscape orientation |
173
+ | `page.margin` | `'18mm'` | A length or `{top, right, bottom, left}`; numbers are pixels |
174
+ | `browser` | unset | Reuse a caller-owned Playwright Chromium Browser |
175
+ | `executablePath` | unset | Use an existing Chromium executable |
176
+ | `timeout` | `30000` | Milliseconds per browser operation, including image readiness |
177
+ | `limits` | see below | Input/resource limits |
178
+
179
+ Built-in styles use readable typography, repeating table headers, captions, print backgrounds, and image sizing based on printable page height. The appendix starts on a new page when there is preceding document content. The renderer requests a tagged PDF and heading outline; it does not claim PDF/UA certification.
180
+
181
+ ### Custom styling
182
+
183
+ ```js
184
+ await markdownToPdf({
185
+ markdownFile: './report.md',
186
+ images: ['./chart.png'],
187
+ outputFile: './report.pdf',
188
+ page: { format: 'Letter', margin: { top: '20mm', bottom: '20mm' } },
189
+ css: 'h1 { color: #5836a4; } .mdip-caption { font-style: italic; }',
190
+ });
191
+ ```
192
+
193
+ Set `lang: 'he', direction: 'rtl'` for Hebrew documents. The host must have fonts covering the languages you need. CSS can embed locally prepared font data as a data URL; the package does not fetch fonts.
194
+
195
+ ### Reuse a browser for multiple conversions
196
+
197
+ ```js
198
+ import { chromium } from 'playwright';
199
+ import { markdownToPdf } from '@x12i/md-images-pdf';
200
+
201
+ const browser = await chromium.launch();
202
+ try {
203
+ for (const markdownFile of files) {
204
+ await markdownToPdf({ markdownFile, browser, outputFile: `${markdownFile}.pdf` });
205
+ }
206
+ } finally {
207
+ await browser.close();
208
+ }
209
+ ```
210
+
211
+ Each conversion owns its browser context and closes it on success or failure. A supplied browser remains open. Without `browser`, the package launches and closes Chromium for that job. Limit concurrency in services according to available memory.
212
+
213
+ ### HTML preview
214
+
215
+ ```js
216
+ import { renderHtml } from '@x12i/md-images-pdf';
217
+ const { html, report } = await renderHtml({ markdownFile: './report.md', images: ['./chart.png'] });
218
+ ```
219
+
220
+ The returned HTML includes inline CSS and image data URLs. It does not require a running Chromium installation. `renderHtml` validates image format signatures; actual image decoding is checked during PDF conversion.
221
+
222
+ ### Placement report
223
+
224
+ ```json
225
+ {
226
+ "images": [
227
+ { "name": "architecture.svg", "inlinePlacements": 1, "appended": false, "ignored": false },
228
+ { "name": "notes.svg", "inlinePlacements": 0, "appended": true, "ignored": false }
229
+ ],
230
+ "warnings": []
231
+ }
232
+ ```
233
+
234
+ ### Error handling
235
+
236
+ ```js
237
+ import { markdownToPdf, MarkdownPdfError } from '@x12i/md-images-pdf';
238
+ try {
239
+ await markdownToPdf({ markdownFile: './report.md' });
240
+ } catch (error) {
241
+ if (error instanceof MarkdownPdfError) console.error(error.code, error.message);
242
+ else throw error; // For example, an ordinary filesystem permission error.
243
+ }
244
+ ```
245
+
246
+ Package error codes: `INVALID_INPUT`, `MISSING_IMAGE`, `AMBIGUOUS_IMAGE`, `UNUSED_IMAGES`, `UNSUPPORTED_IMAGE`, `INVALID_IMAGE`, `LIMIT_EXCEEDED`, `BROWSER_LAUNCH_FAILED`, and `RENDER_TIMEOUT`. Native filesystem and Playwright errors retain their original types/codes.
247
+
248
+ ## Resource limits and boundaries
249
+
250
+ | Limit | Default |
251
+ | --- | --- |
252
+ | `maxMarkdownBytes` | 10 MiB |
253
+ | `maxImageBytes` | 25 MiB per image |
254
+ | `maxTotalImageBytes` | 100 MiB of unique loaded image bytes |
255
+ | `maxImages` | 200 unique images |
256
+ | `maxImagePixels` | 100,000,000 intrinsic pixels per rendered image |
257
+
258
+ Override through `limits`. These are practical input checks, not a complete sandbox for hostile uploads. Repeated references and very complex SVGs can still consume substantial memory. For untrusted service workloads, use process/container isolation and request limits.
259
+
260
+ Raw HTML in Markdown is displayed as text. Page scripts are disabled. Remote resources, `file://` resources, and external CSS/font/image requests are blocked. Only supplied images or automatically loaded local Markdown links are embedded. Automatically loaded links cannot traverse outside `baseDir`, including via symlinks. Explicit `images` and `imageDirectories` paths may be outside `baseDir`, because the caller supplied them deliberately.
261
+
262
+ GIF animation is represented by a browser-selected still frame. Mermaid, LaTeX, syntax highlighting, raw HTML layouts, OCR, image generation, PDF merging, and pixel-identical PDF bytes across runs are outside this package's scope. Export diagrams as SVG/PNG before passing them in. For a very wide table or unusually large single content block, use landscape format or adjust CSS.
263
+
264
+ ## Files and verification
265
+
266
+ - `src/index.js`: PDF conversion and browser lifecycle.
267
+ - `src/render.js`: Markdown rendering, filename placement, appendix, default styles.
268
+ - `src/assets.js`: Image inputs, alias resolution, local path boundaries, resource checks.
269
+ - `src/index.d.ts`: TypeScript declarations.
270
+ - `bin/md-images-pdf.js`: CLI.
271
+ - `test/`: Unit, CLI, and actual Chromium integration tests.
272
+ - `examples/`: Example Markdown, three image assets, and runnable API example.
273
+
274
+ Run `npm test` for tests that do not launch Chromium. Run `npm run test:integration` for actual PDF generation, RTL rendering, corruption detection, browser reuse, and the complete CLI flow. Run `npm run test:types` to check the TypeScript declarations. Set `MD_IMAGES_PDF_CHROMIUM=/path/to/chromium` to use an existing binary in integration tests or the example.
275
+
276
+ Create a distributable package with `npm pack`. Source is MIT licensed. Third-party dependencies and the browser retain their own licenses.
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import { mkdir, writeFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { markdownToPdf } from '../src/index.js';
6
+
7
+ const HELP = `Usage: md-images-pdf <document.md> [options]
8
+
9
+ Options:
10
+ -o, --output <path> Output PDF (default: document.pdf next to input)
11
+ -i, --image <path> Supply one image; repeat for multiple images
12
+ -d, --images-dir <path> Scan a directory recursively; repeat as needed
13
+ --base-dir <path> Base for image paths (default: Markdown directory)
14
+ --unused <mode> append (default), ignore, or error
15
+ --missing <mode> error (default) or placeholder
16
+ --format <size> A3, A4 (default), A5, Letter, Legal, Tabloid
17
+ --landscape Landscape orientation
18
+ --margin <length> All margins, e.g. 18mm (default)
19
+ --title <text> PDF document title
20
+ --direction <dir> auto (default), ltr, or rtl
21
+ --lang <tag> Language, e.g. en or he (default: en)
22
+ --no-captions Hide filename captions
23
+ --no-recursive Do not scan subdirectories
24
+ --no-resolve Only use supplied images; no Markdown path loading
25
+ --case-insensitive Match filenames without case sensitivity
26
+ --report <path> Also write a JSON image placement report
27
+ --executable-path <path> Use an existing Chromium executable
28
+ --timeout <ms> Browser operation timeout (default: 30000)
29
+ -h, --help Show this help
30
+ -v, --version Show version
31
+
32
+ Relative --image and --images-dir arguments resolve from the current working
33
+ directory. Markdown image links resolve from the Markdown directory or --base-dir.
34
+ Install the renderer once: npx playwright install chromium
35
+ `;
36
+
37
+ try {
38
+ const { values, positionals } = parseArgs({
39
+ allowPositionals: true, strict: true,
40
+ options: {
41
+ output: { type: 'string', short: 'o' },
42
+ image: { type: 'string', short: 'i', multiple: true },
43
+ 'images-dir': { type: 'string', short: 'd', multiple: true },
44
+ 'base-dir': { type: 'string' },
45
+ unused: { type: 'string' }, missing: { type: 'string' },
46
+ format: { type: 'string' }, landscape: { type: 'boolean' }, margin: { type: 'string' },
47
+ title: { type: 'string' }, direction: { type: 'string' }, lang: { type: 'string' },
48
+ 'no-captions': { type: 'boolean' }, 'no-recursive': { type: 'boolean' },
49
+ 'no-resolve': { type: 'boolean' }, 'case-insensitive': { type: 'boolean' },
50
+ report: { type: 'string' }, 'executable-path': { type: 'string' }, timeout: { type: 'string' },
51
+ help: { type: 'boolean', short: 'h' }, version: { type: 'boolean', short: 'v' },
52
+ },
53
+ });
54
+ if (values.help) process.stdout.write(HELP);
55
+ else if (values.version) process.stdout.write('1.0.0\n');
56
+ else {
57
+ if (positionals.length !== 1) throw new Error('Provide exactly one Markdown file. Use --help for usage.');
58
+ const markdownFile = path.resolve(positionals[0]);
59
+ const outputFile = path.resolve(values.output ?? path.join(path.dirname(markdownFile), `${path.basename(markdownFile, path.extname(markdownFile))}.pdf`));
60
+ const reportFile = values.report === undefined ? undefined : path.resolve(values.report);
61
+ if (outputFile === markdownFile || reportFile === markdownFile || reportFile === outputFile) throw new Error('Input, PDF output, and JSON report must use different paths.');
62
+ const result = await markdownToPdf({
63
+ markdownFile, outputFile,
64
+ images: (values.image ?? []).map(file => path.resolve(file)),
65
+ imageDirectories: (values['images-dir'] ?? []).map(directory => path.resolve(directory)),
66
+ ...(values['base-dir'] && { baseDir: path.resolve(values['base-dir']) }),
67
+ ...(values.unused && { unusedImages: values.unused }),
68
+ ...(values.missing && { missingImages: values.missing }),
69
+ ...(values.title !== undefined && { title: values.title }),
70
+ ...(values.direction && { direction: values.direction }),
71
+ ...(values.lang && { lang: values.lang }),
72
+ page: { ...(values.format && { format: values.format }), landscape: values.landscape ?? false, ...(values.margin && { margin: values.margin }) },
73
+ captions: !values['no-captions'], recursive: !values['no-recursive'],
74
+ caseSensitive: !values['case-insensitive'], resolveMarkdownImages: !values['no-resolve'],
75
+ ...(values['executable-path'] && { executablePath: values['executable-path'] }),
76
+ ...(values.timeout !== undefined && { timeout: Number(values.timeout) }),
77
+ });
78
+ if (reportFile) {
79
+ await mkdir(path.dirname(reportFile), { recursive: true });
80
+ await writeFile(reportFile, `${JSON.stringify(result.report, null, 2)}\n`);
81
+ }
82
+ const inline = result.report.images.reduce((sum, image) => sum + image.inlinePlacements, 0);
83
+ const appended = result.report.images.filter(image => image.appended).length;
84
+ process.stdout.write(`Created ${result.outputFile}\n${inline} inline image placement(s), ${appended} appended image(s).\n`);
85
+ for (const warning of result.report.warnings) process.stderr.write(`Warning: ${warning.message}\n`);
86
+ }
87
+ } catch (error) {
88
+ process.stderr.write(`${error.code ? `${error.code}: ` : ''}${error.message}\n`);
89
+ process.exitCode = 1;
90
+ }
@@ -0,0 +1,34 @@
1
+ # Markdown + images
2
+
3
+ One document. Images placed where they belong.
4
+
5
+ This example demonstrates filename placement, a standard Markdown image link, and an appendix for a supplied image that was never referenced.
6
+
7
+ ## 1. Filename placement
8
+
9
+ The following image is referenced by its filename on a line of its own:
10
+
11
+ architecture.svg
12
+
13
+ ## 2. Markdown image links
14
+
15
+ Standard Markdown syntax also works. This image uses a normal image link:
16
+
17
+ ![Example monthly revenue chart](images/revenue.png)
18
+
19
+ | Input | Result |
20
+ | --- | --- |
21
+ | A known image filename | Image inserted at the reference |
22
+ | A Markdown image link | Image inserted with its alt text |
23
+ | An unused supplied image | Image added to the appendix |
24
+
25
+ ## 3. Code stays readable
26
+
27
+ Filenames inside a code example remain code:
28
+
29
+ ```js
30
+ const image = 'architecture.svg';
31
+ await markdownToPdf({ markdownFile: 'report.md', images: [image] });
32
+ ```
33
+
34
+ The final page contains the extra supplied illustration.
@@ -0,0 +1,12 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import { markdownToPdf } from '../src/index.js';
3
+
4
+ const result = await markdownToPdf({
5
+ markdownFile: fileURLToPath(new URL('./document.md', import.meta.url)),
6
+ imageDirectories: ['./images'],
7
+ outputFile: fileURLToPath(new URL('../output/example.pdf', import.meta.url)),
8
+ title: 'Markdown + images: working example',
9
+ executablePath: process.env.MD_IMAGES_PDF_CHROMIUM,
10
+ });
11
+ console.log(`Created ${result.outputFile}`);
12
+ console.log(JSON.stringify(result.report, null, 2));
@@ -0,0 +1,17 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1080" height="280" viewBox="0 0 1080 280">
2
+ <rect width="1080" height="280" rx="18" fill="#edf4f7"/>
3
+ <g font-family="Arial, sans-serif" text-anchor="middle">
4
+ <rect x="34" y="82" width="230" height="104" rx="12" fill="#17324d"/>
5
+ <text x="149" y="125" fill="white" font-size="25" font-weight="bold">Markdown</text>
6
+ <text x="149" y="156" fill="#c5e3ed" font-size="17">Headings, text, tables</text>
7
+ <path d="M278 134h56m-14-10 14 10-14 10" fill="none" stroke="#2f8196" stroke-width="4"/>
8
+ <rect x="348" y="82" width="350" height="104" rx="12" fill="#2f8196"/>
9
+ <text x="523" y="125" fill="white" font-size="25" font-weight="bold">Place supplied images</text>
10
+ <text x="523" y="156" fill="#e4f3f6" font-size="17">Match filenames + append unused</text>
11
+ <path d="M712 134h56m-14-10 14 10-14 10" fill="none" stroke="#2f8196" stroke-width="4"/>
12
+ <rect x="782" y="82" width="264" height="104" rx="12" fill="#17324d"/>
13
+ <text x="914" y="125" fill="white" font-size="25" font-weight="bold">Finished PDF</text>
14
+ <text x="914" y="156" fill="#c5e3ed" font-size="17">Printable and shareable</text>
15
+ <text x="540" y="238" fill="#516474" font-size="18">All content is embedded. Conversion runs locally.</text>
16
+ </g>
17
+ </svg>
@@ -0,0 +1,15 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="920" height="600" viewBox="0 0 920 600">
2
+ <rect width="920" height="600" rx="18" fill="#edf4f7"/>
3
+ <rect x="165" y="74" width="590" height="452" rx="14" fill="white" stroke="#cdd8df" stroke-width="2"/>
4
+ <rect x="205" y="118" width="64" height="8" rx="4" fill="#2f8196"/>
5
+ <g font-family="Arial, sans-serif" fill="#17324d">
6
+ <text x="205" y="180" font-size="32" font-weight="bold">Nothing gets left behind</text>
7
+ <text x="205" y="228" font-size="20" fill="#516474">This image was supplied but never referenced.</text>
8
+ <text x="205" y="262" font-size="20" fill="#516474">The package added it to the appendix.</text>
9
+ <g font-size="22">
10
+ <circle cx="220" cy="330" r="6" fill="#2f8196"/><text x="244" y="338">Original image preserved</text>
11
+ <circle cx="220" cy="382" r="6" fill="#2f8196"/><text x="244" y="390">Scaled to fit the page</text>
12
+ <circle cx="220" cy="434" r="6" fill="#2f8196"/><text x="244" y="442">Included exactly once</text>
13
+ </g>
14
+ </g>
15
+ </svg>
Binary file
package/package.json CHANGED
@@ -1,6 +1,55 @@
1
1
  {
2
2
  "name": "@x12i/md-images-pdf",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.1",
4
+ "description": "Turn Markdown and supplied images into a PDF, placing filename references inline and appending unused images.",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "types": "./src/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.d.ts",
11
+ "import": "./src/index.js",
12
+ "default": "./src/index.js"
13
+ }
14
+ },
15
+ "bin": {
16
+ "md-images-pdf": "bin/md-images-pdf.js"
17
+ },
18
+ "files": [
19
+ "src/",
20
+ "bin/",
21
+ "examples/",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "engines": {
26
+ "node": ">=20"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "scripts": {
32
+ "test": "node --test test/render.test.js test/cli.test.js",
33
+ "test:integration": "node --test test/pdf.test.js",
34
+ "test:all": "node --test test/*.test.js",
35
+ "test:types": "tsc --noEmit --strict --target ES2022 --module NodeNext --moduleResolution NodeNext test/types.ts",
36
+ "example": "node examples/generate.js",
37
+ "check": "node --check src/index.js && node --check src/assets.js && node --check src/render.js && node --check bin/md-images-pdf.js"
38
+ },
39
+ "dependencies": {
40
+ "markdown-it": "^15.0.3",
41
+ "playwright": "^1.64.1"
42
+ },
43
+ "license": "MIT",
44
+ "keywords": [
45
+ "markdown",
46
+ "pdf",
47
+ "images",
48
+ "cli",
49
+ "playwright"
50
+ ],
51
+ "devDependencies": {
52
+ "@types/node": "^22.20.5",
53
+ "typescript": "^5.9.3"
54
+ }
55
+ }
package/src/assets.js ADDED
@@ -0,0 +1,152 @@
1
+ import { readFile, realpath, readdir, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ const EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.svg']);
5
+ export const DEFAULT_LIMITS = Object.freeze({
6
+ maxMarkdownBytes: 10 * 1024 * 1024,
7
+ maxImageBytes: 25 * 1024 * 1024,
8
+ maxTotalImageBytes: 100 * 1024 * 1024,
9
+ maxImages: 200,
10
+ maxImagePixels: 100_000_000,
11
+ });
12
+
13
+ export class MarkdownPdfError extends Error {
14
+ constructor(code, message, options) {
15
+ super(message, options);
16
+ this.name = 'MarkdownPdfError';
17
+ this.code = code;
18
+ }
19
+ }
20
+
21
+ export function fail(code, message, cause) {
22
+ throw new MarkdownPdfError(code, message, cause ? { cause } : undefined);
23
+ }
24
+
25
+ export function normalizeName(value, caseSensitive = true) {
26
+ let name = value.replaceAll('\\', '/').normalize('NFC');
27
+ while (name.startsWith('./')) name = name.slice(2);
28
+ return caseSensitive ? name : name.toLocaleLowerCase('en-US');
29
+ }
30
+
31
+ export function decodeReference(reference) {
32
+ // Strip URL suffixes before decoding, so a literal encoded # stays in the name.
33
+ const clean = reference.split(/[?#]/u, 1)[0];
34
+ try { return decodeURIComponent(clean); } catch { return clean; }
35
+ }
36
+
37
+ function isInside(root, file) {
38
+ const relative = path.relative(root, file);
39
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
40
+ }
41
+
42
+ function detectMime(data) {
43
+ if (data.length >= 8 && data.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return 'image/png';
44
+ if (data.length >= 3 && data[0] === 255 && data[1] === 216 && data[2] === 255) return 'image/jpeg';
45
+ if (/^GIF8[79]a/u.test(data.subarray(0, 6).toString('ascii'))) return 'image/gif';
46
+ if (data.subarray(0, 4).toString() === 'RIFF' && data.subarray(8, 12).toString() === 'WEBP') return 'image/webp';
47
+ if (/<svg(?:\s|>)/iu.test(data.subarray(0, 4096).toString('utf8'))) return 'image/svg+xml';
48
+ fail('UNSUPPORTED_IMAGE', 'Unsupported image bytes. Use PNG, JPEG, WebP, GIF, or SVG.');
49
+ }
50
+
51
+ export async function createAssetRegistry(options, baseDir, limits) {
52
+ const assets = [];
53
+ const aliases = new Map();
54
+ const byPath = new Map();
55
+ let totalBytes = 0;
56
+ const root = await realpath(baseDir);
57
+ const key = value => normalizeName(value, options.caseSensitive);
58
+
59
+ function addAlias(alias, asset) {
60
+ if (!alias) return;
61
+ const normalized = key(alias);
62
+ if (!aliases.has(normalized)) aliases.set(normalized, new Set());
63
+ aliases.get(normalized).add(asset);
64
+ asset.aliases.add(alias);
65
+ }
66
+
67
+ async function add(input, extraAliases = []) {
68
+ const specification = typeof input === 'string' ? { path: input } : input;
69
+ if (!specification || typeof specification !== 'object') fail('INVALID_INPUT', 'Each image must be a path or an image object.');
70
+ if ((specification.path !== undefined) === (specification.data !== undefined)) fail('INVALID_INPUT', 'Supply exactly one of image.path or image.data.');
71
+ let canonicalPath;
72
+ let data;
73
+ if (specification.path !== undefined) {
74
+ if (typeof specification.path !== 'string' || !specification.path) fail('INVALID_INPUT', 'image.path must be a non-empty string.');
75
+ canonicalPath = await realpath(path.resolve(root, specification.path));
76
+ const existing = byPath.get(canonicalPath);
77
+ if (existing) {
78
+ for (const alias of [specification.name, specification.path, ...extraAliases]) addAlias(alias, existing);
79
+ return existing;
80
+ }
81
+ const info = await stat(canonicalPath);
82
+ if (!info.isFile()) fail('INVALID_INPUT', `Image is not a file: ${specification.path}`);
83
+ if (info.size > limits.maxImageBytes) fail('LIMIT_EXCEEDED', `Image exceeds maxImageBytes: ${specification.path}`);
84
+ data = await readFile(canonicalPath);
85
+ } else {
86
+ if (!(specification.data instanceof Uint8Array)) fail('INVALID_INPUT', 'image.data must be a Buffer or Uint8Array.');
87
+ data = Buffer.from(specification.data);
88
+ }
89
+ const name = specification.name ?? path.basename(canonicalPath ?? '');
90
+ if (typeof name !== 'string' || !name || !EXTENSIONS.has(path.extname(name).toLowerCase())) fail('INVALID_INPUT', 'Each image needs a name ending in .png, .jpg, .jpeg, .webp, .gif, or .svg.');
91
+ for (const field of ['alt', 'caption']) {
92
+ if (specification[field] !== undefined && typeof specification[field] !== 'string') fail('INVALID_INPUT', `image.${field} must be a string.`);
93
+ }
94
+ if (data.length > limits.maxImageBytes || totalBytes + data.length > limits.maxTotalImageBytes || assets.length >= limits.maxImages) fail('LIMIT_EXCEEDED', `Image limits exceeded while loading ${name}.`);
95
+ const mimeType = detectMime(data);
96
+ const asset = {
97
+ id: `image-${assets.length + 1}`, name,
98
+ alt: specification.alt ?? name,
99
+ caption: specification.caption ?? name,
100
+ dataUrl: `data:${mimeType};base64,${data.toString('base64')}`,
101
+ aliases: new Set(), placements: 0,
102
+ };
103
+ totalBytes += data.length;
104
+ assets.push(asset);
105
+ if (canonicalPath) byPath.set(canonicalPath, asset);
106
+ for (const alias of [name, path.posix.basename(name.replaceAll('\\', '/')), specification.path, ...extraAliases]) addAlias(alias, asset);
107
+ if (canonicalPath && isInside(root, canonicalPath)) addAlias(path.relative(root, canonicalPath), asset);
108
+ return asset;
109
+ }
110
+
111
+ for (const image of options.images) await add(image);
112
+
113
+ async function scan(directory, relative = '') {
114
+ const entries = (await readdir(path.join(directory, relative), { withFileTypes: true })).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
115
+ for (const entry of entries) {
116
+ if (entry.name.startsWith('.')) continue;
117
+ const local = path.join(relative, entry.name);
118
+ if (entry.isDirectory() && options.recursive) await scan(directory, local);
119
+ else if (entry.isFile() && EXTENSIONS.has(path.extname(entry.name).toLowerCase())) await add({ path: path.join(directory, local) }, [local]);
120
+ // Symlinks in directory scans are intentionally skipped.
121
+ }
122
+ }
123
+ for (const directory of options.imageDirectories) {
124
+ if (typeof directory !== 'string' || !directory) fail('INVALID_INPUT', 'imageDirectories must contain non-empty directory paths.');
125
+ await scan(await realpath(path.resolve(root, directory)));
126
+ }
127
+
128
+ function lookup(reference) {
129
+ const matches = aliases.get(key(reference));
130
+ if (!matches) return undefined;
131
+ if (matches.size > 1) fail('AMBIGUOUS_IMAGE', `Multiple images match "${reference}". Use a relative path or give each image a unique name.`);
132
+ return matches.values().next().value;
133
+ }
134
+
135
+ async function resolve(reference) {
136
+ const decoded = decodeReference(reference);
137
+ const found = lookup(decoded);
138
+ if (found) return found;
139
+ if (!options.resolveMarkdownImages || /^(?:[a-z][a-z0-9+.-]*:|\/\/)/iu.test(decoded) || path.isAbsolute(decoded) || /^[a-z]:[\\/]/iu.test(decoded)) return undefined;
140
+ const candidate = path.resolve(root, decoded.replaceAll('\\', '/'));
141
+ if (!isInside(root, candidate)) return undefined;
142
+ let canonical;
143
+ try { canonical = await realpath(candidate); } catch (error) {
144
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return undefined;
145
+ throw error;
146
+ }
147
+ if (!isInside(root, canonical)) return undefined;
148
+ return add({ path: canonical }, [decoded]);
149
+ }
150
+
151
+ return { assets, aliases, lookup, resolve };
152
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,92 @@
1
+ import type { Browser } from 'playwright';
2
+
3
+ export type ImageInput = string | {
4
+ /** A local file path. Relative paths resolve from baseDir. */
5
+ path: string;
6
+ /** Optional unique Markdown reference name, including its extension. */
7
+ name?: string;
8
+ alt?: string;
9
+ caption?: string;
10
+ data?: never;
11
+ } | {
12
+ name: string;
13
+ data: Uint8Array;
14
+ alt?: string;
15
+ caption?: string;
16
+ path?: never;
17
+ };
18
+
19
+ export interface ResourceLimits {
20
+ maxMarkdownBytes: number;
21
+ maxImageBytes: number;
22
+ maxTotalImageBytes: number;
23
+ maxImages: number;
24
+ maxImagePixels: number;
25
+ }
26
+
27
+ export interface CommonOptions {
28
+ /** Supply exactly one of markdown or markdownFile. */
29
+ markdown?: string;
30
+ markdownFile?: string;
31
+ /** Defaults to the Markdown file directory, or cwd for a Markdown string. */
32
+ baseDir?: string;
33
+ images?: ImageInput[];
34
+ imageDirectories?: string[];
35
+ /** Scan imageDirectories recursively. Default true. Symlinks are skipped. */
36
+ recursive?: boolean;
37
+ /** Automatically load explicit Markdown image links inside baseDir. Default true. */
38
+ resolveMarkdownImages?: boolean;
39
+ caseSensitive?: boolean;
40
+ unusedImages?: 'append' | 'ignore' | 'error';
41
+ missingImages?: 'error' | 'placeholder';
42
+ captions?: boolean;
43
+ appendixTitle?: string;
44
+ title?: string;
45
+ direction?: 'auto' | 'ltr' | 'rtl';
46
+ lang?: string;
47
+ /** Trusted stylesheet appended after the default styles. External requests are blocked. */
48
+ css?: string;
49
+ limits?: Partial<ResourceLimits>;
50
+ }
51
+
52
+ export interface PdfOptions extends CommonOptions {
53
+ /** Optional. The PDF Buffer is always returned, even when a file is written. */
54
+ outputFile?: string;
55
+ page?: {
56
+ format?: 'A3' | 'A4' | 'A5' | 'Letter' | 'Legal' | 'Tabloid';
57
+ landscape?: boolean;
58
+ /** Pixel number or length with px/mm/cm/in. Defaults to 18mm on each side. */
59
+ margin?: string | number | Partial<Record<'top' | 'right' | 'bottom' | 'left', string | number>>;
60
+ };
61
+ /** Reuse a Chromium Browser; each job creates and closes its own isolated context. */
62
+ browser?: Browser;
63
+ executablePath?: string;
64
+ /** Timeout for browser launch, page load, image readiness, and PDF printing. Default 30000. */
65
+ timeout?: number;
66
+ }
67
+
68
+ export interface ImageReport {
69
+ name: string;
70
+ inlinePlacements: number;
71
+ appended: boolean;
72
+ ignored: boolean;
73
+ }
74
+ export interface PlacementReport {
75
+ images: ImageReport[];
76
+ warnings: { code: 'MISSING_IMAGE'; reference: string; message: string }[];
77
+ }
78
+ export interface PdfResult {
79
+ pdf: Buffer;
80
+ outputFile?: string;
81
+ report: PlacementReport;
82
+ }
83
+
84
+ export function markdownToPdf(options: PdfOptions): Promise<PdfResult>;
85
+ export function renderHtml(options: CommonOptions): Promise<{
86
+ html: string;
87
+ report: PlacementReport;
88
+ limits: ResourceLimits;
89
+ }>;
90
+ export const DEFAULT_CSS: string;
91
+ export const DEFAULT_LIMITS: Readonly<ResourceLimits>;
92
+ export class MarkdownPdfError extends Error { code: string; }
package/src/index.js ADDED
@@ -0,0 +1,99 @@
1
+ import { mkdir, writeFile, rename, rm } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { chromium } from 'playwright';
5
+ import { renderHtml } from './render.js';
6
+ import { fail, MarkdownPdfError } from './assets.js';
7
+
8
+ export { renderHtml, DEFAULT_CSS } from './render.js';
9
+ export { MarkdownPdfError, DEFAULT_LIMITS } from './assets.js';
10
+
11
+ function withTimeout(promise, timeout, operation) {
12
+ let timer;
13
+ return Promise.race([
14
+ promise,
15
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new MarkdownPdfError('RENDER_TIMEOUT', `Timed out ${operation}.`)), timeout); }),
16
+ ]).finally(() => clearTimeout(timer));
17
+ }
18
+
19
+ function pdfOptions(input) {
20
+ const page = input.page ?? {};
21
+ const format = page.format ?? 'A4';
22
+ if (!['A3', 'A4', 'A5', 'Letter', 'Legal', 'Tabloid'].includes(format)) fail('INVALID_INPUT', 'Unsupported page.format. Use A3, A4, A5, Letter, Legal, or Tabloid.');
23
+ if (page.landscape !== undefined && typeof page.landscape !== 'boolean') fail('INVALID_INPUT', 'page.landscape must be a boolean.');
24
+ const margin = typeof page.margin === 'object' && page.margin !== null ? { ...page.margin } : Object.fromEntries(['top', 'right', 'bottom', 'left'].map(side => [side, page.margin ?? '18mm']));
25
+ for (const side of ['top', 'right', 'bottom', 'left']) {
26
+ const value = margin[side] ?? '18mm';
27
+ if (!(typeof value === 'number' && Number.isFinite(value) && value >= 0) && !(typeof value === 'string' && /^\d+(?:\.\d+)?(?:px|mm|cm|in)$/u.test(value))) fail('INVALID_INPUT', `Invalid page margin: ${side}. Use a non-negative pixel number or a length such as "18mm".`);
28
+ margin[side] = value;
29
+ }
30
+ return { format, landscape: page.landscape ?? false, margin, printBackground: true, preferCSSPageSize: false, tagged: true, outline: true };
31
+ }
32
+
33
+ /** Convert Markdown and image inputs to a PDF Buffer; optionally also save it to disk. */
34
+ export async function markdownToPdf(input) {
35
+ if (input?.outputFile && input?.markdownFile && path.resolve(input.outputFile) === path.resolve(input.markdownFile)) fail('INVALID_INPUT', 'outputFile must differ from markdownFile.');
36
+ const { html, report, limits } = await renderHtml(input);
37
+ const printOptions = pdfOptions(input);
38
+ if (input.outputFile !== undefined && (typeof input.outputFile !== 'string' || !input.outputFile)) fail('INVALID_INPUT', 'outputFile must be a non-empty path.');
39
+ const timeout = input.timeout ?? 30_000;
40
+ if (!Number.isSafeInteger(timeout) || timeout <= 0) fail('INVALID_INPUT', 'timeout must be a positive integer in milliseconds.');
41
+ const ownsBrowser = !input.browser;
42
+ let browser = input.browser;
43
+ let context;
44
+ try {
45
+ if (!browser) {
46
+ try {
47
+ browser = await chromium.launch({ headless: true, timeout, executablePath: input.executablePath });
48
+ } catch (cause) {
49
+ throw new MarkdownPdfError('BROWSER_LAUNCH_FAILED', 'Could not launch Chromium. Run "npx playwright install chromium" (Linux may also need "npx playwright install --with-deps chromium"), or provide executablePath/browser.', { cause });
50
+ }
51
+ }
52
+ context = await browser.newContext({ javaScriptEnabled: false, serviceWorkers: 'block', reducedMotion: 'reduce' });
53
+ // No HTTP, file://, or other external resource access, including from custom CSS.
54
+ await context.route('**/*', route => route.abort());
55
+ const page = await context.newPage();
56
+ page.setDefaultTimeout(timeout);
57
+ await page.setContent(html, { waitUntil: 'load', timeout });
58
+ // evaluate runs Playwright's own code; document scripts remain disabled.
59
+ const imageReadiness = page.evaluate(async maxPixels => {
60
+ await document.fonts.ready;
61
+ const failures = [];
62
+ for (const image of document.images) {
63
+ try {
64
+ await image.decode();
65
+ if (!image.naturalWidth || image.naturalWidth * image.naturalHeight > maxPixels) failures.push(image.alt);
66
+ } catch { failures.push(image.alt); }
67
+ }
68
+ return failures;
69
+ }, limits.maxImagePixels);
70
+ const invalidImages = await withTimeout(imageReadiness, timeout, 'waiting for fonts and images');
71
+ if (invalidImages.length) fail('INVALID_IMAGE', `Images could not be decoded or exceeded maxImagePixels: ${invalidImages.join(', ')}`);
72
+ // Derive an image cap from the actual page content height to handle landscape/small pages.
73
+ const dimensions = { A3: [297, 420], A4: [210, 297], A5: [148, 210], Letter: [215.9, 279.4], Legal: [215.9, 355.6], Tabloid: [279.4, 431.8] };
74
+ const toMm = value => {
75
+ if (typeof value === 'number') return value * 25.4 / 96;
76
+ const amount = Number.parseFloat(value);
77
+ return amount * ({ px: 25.4 / 96, mm: 1, cm: 10, in: 25.4 }[value.match(/[a-z]+$/u)[0]]);
78
+ };
79
+ const contentHeight = dimensions[printOptions.format][printOptions.landscape ? 0 : 1] - toMm(printOptions.margin.top) - toMm(printOptions.margin.bottom);
80
+ const contentWidth = dimensions[printOptions.format][printOptions.landscape ? 1 : 0] - toMm(printOptions.margin.left) - toMm(printOptions.margin.right);
81
+ if (contentHeight < 30 || contentWidth < 30) fail('INVALID_INPUT', 'Page margins leave less than 30mm of printable width or height.');
82
+ await page.evaluate(height => document.documentElement.style.setProperty('--mdip-image-height', `${Math.max(10, height - 25)}mm`), contentHeight);
83
+ const pdf = await withTimeout(page.pdf(printOptions), timeout, 'printing the PDF');
84
+ let outputFile;
85
+ if (input.outputFile !== undefined) {
86
+ outputFile = path.resolve(input.outputFile);
87
+ await mkdir(path.dirname(outputFile), { recursive: true });
88
+ const temporary = `${outputFile}.${randomUUID()}.tmp`;
89
+ try {
90
+ await writeFile(temporary, pdf, { flag: 'wx' });
91
+ await rename(temporary, outputFile);
92
+ } finally { await rm(temporary, { force: true }); }
93
+ }
94
+ return { pdf, outputFile, report };
95
+ } finally {
96
+ try { await context?.close(); }
97
+ finally { if (ownsBrowser) await browser?.close(); }
98
+ }
99
+ }
package/src/render.js ADDED
@@ -0,0 +1,170 @@
1
+ import { readFile, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import MarkdownIt from 'markdown-it';
4
+ import { createAssetRegistry, DEFAULT_LIMITS, fail } from './assets.js';
5
+
6
+ const escape = value => String(value).replace(/[&<>"']/gu, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]);
7
+ const escapeRegex = value => value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
8
+ const isFilenameCharacter = value => value !== undefined && /[\p{L}\p{N}_.\/\\%+~@-]/u.test(value);
9
+ const isBlockedSuffix = (content, end) => isFilenameCharacter(content[end]) && !/^\.+(?:$|[^\p{L}\p{N}_.\/\\%+~@-])/u.test(content.slice(end));
10
+
11
+ function normalizeOptions(input) {
12
+ if (!input || typeof input !== 'object') fail('INVALID_INPUT', 'Provide an options object.');
13
+ if ((input.markdown !== undefined) === (input.markdownFile !== undefined)) fail('INVALID_INPUT', 'Provide exactly one of markdown or markdownFile.');
14
+ if (input.markdown !== undefined && typeof input.markdown !== 'string') fail('INVALID_INPUT', 'markdown must be a string.');
15
+ if (input.markdownFile !== undefined && (typeof input.markdownFile !== 'string' || !input.markdownFile)) fail('INVALID_INPUT', 'markdownFile must be a non-empty path.');
16
+ const options = {
17
+ images: [], imageDirectories: [], recursive: true,
18
+ caseSensitive: true, resolveMarkdownImages: true,
19
+ unusedImages: 'append', missingImages: 'error', captions: true,
20
+ appendixTitle: 'Additional images', direction: 'auto', lang: 'en',
21
+ title: 'Document', css: '', ...input,
22
+ };
23
+ for (const key of ['images', 'imageDirectories']) if (!Array.isArray(options[key])) fail('INVALID_INPUT', `${key} must be an array.`);
24
+ for (const key of ['recursive', 'caseSensitive', 'resolveMarkdownImages', 'captions']) if (typeof options[key] !== 'boolean') fail('INVALID_INPUT', `${key} must be a boolean.`);
25
+ for (const key of ['title', 'lang', 'appendixTitle', 'css']) if (typeof options[key] !== 'string') fail('INVALID_INPUT', `${key} must be a string.`);
26
+ for (const [key, choices] of Object.entries({ unusedImages: ['append', 'ignore', 'error'], missingImages: ['error', 'placeholder'], direction: ['auto', 'ltr', 'rtl'] })) if (!choices.includes(options[key])) fail('INVALID_INPUT', `${key} must be one of: ${choices.join(', ')}.`);
27
+ const limits = { ...DEFAULT_LIMITS, ...options.limits };
28
+ for (const [key, value] of Object.entries(limits)) if (!(key in DEFAULT_LIMITS) || !Number.isSafeInteger(value) || value <= 0) fail('INVALID_INPUT', `Invalid resource limit: ${key}.`);
29
+ return { options, limits };
30
+ }
31
+
32
+ export const DEFAULT_CSS = `
33
+ :root { color: #202d3b; font-family: Arial, "Noto Sans", "Noto Sans Hebrew", sans-serif; font-size: 10.5pt; line-height: 1.6; }
34
+ * { box-sizing: border-box; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
35
+ body { margin: 0; overflow-wrap: anywhere; }
36
+ h1, h2, h3, h4, h5, h6 { line-height: 1.22; color: #17324d; break-after: avoid; }
37
+ h1 { font-size: 28pt; letter-spacing: -0.7pt; margin: 0 0 18pt; padding-bottom: 12pt; border-bottom: 3px solid #2f8196; }
38
+ h2 { font-size: 18pt; margin: 24pt 0 10pt; }
39
+ h3 { font-size: 13pt; margin-top: 18pt; }
40
+ p { margin: 0 0 11pt; orphans: 3; widows: 3; }
41
+ ul, ol { padding-inline-start: 22pt; }
42
+ li { margin: 4pt 0; }
43
+ a { color: #176b87; text-decoration: underline; }
44
+ blockquote { margin: 14pt 0; border-inline-start: 3px solid #2f8196; padding: 4pt 12pt; background: #f2f7f9; }
45
+ pre, code { font-family: "DejaVu Sans Mono", "Courier New", monospace; font-size: 9pt; }
46
+ code { background: #f0f3f6; padding: 1pt 3pt; border-radius: 3pt; }
47
+ pre { background: #f0f3f6; padding: 12pt; white-space: pre-wrap; overflow-wrap: anywhere; direction: ltr; text-align: left; }
48
+ pre code { padding: 0; background: transparent; }
49
+ table { width: 100%; border-collapse: collapse; font-size: 9pt; margin: 14pt 0; table-layout: fixed; }
50
+ thead { display: table-header-group; }
51
+ th { color: #17324d; background: #eaf2f6; }
52
+ td, th { border: 1px solid #cdd8df; padding: 7pt 8pt; text-align: start; overflow-wrap: anywhere; }
53
+ tr { break-inside: avoid; }
54
+ hr { border: 0; border-top: 1px solid #cdd8df; margin: 20pt 0; }
55
+ .mdip-image { display: block; text-align: center; margin: 16pt 0; break-inside: avoid; }
56
+ .mdip-image img { display: block; max-width: 100%; max-height: var(--mdip-image-height, 190mm); width: auto; height: auto; object-fit: contain; margin: 0 auto; }
57
+ .mdip-caption { display: block; font-size: 8.5pt; color: #516474; margin-top: 6pt; line-height: 1.35; }
58
+ .mdip-missing { display: block; border: 1px dashed #b26633; padding: 12pt; color: #895024; margin: 10pt 0; }
59
+ .mdip-appendix { break-before: page; }
60
+ `;
61
+
62
+ function renderAsset(asset, alt, options, placement) {
63
+ if (placement === 'inline') asset.placements++;
64
+ return `<span class="mdip-image" data-image-id="${asset.id}"><img src="${asset.dataUrl}" alt="${escape(alt ?? asset.alt)}">${options.captions ? `<span class="mdip-caption">${escape(asset.caption)}</span>` : ''}</span>`;
65
+ }
66
+
67
+ /** Build a self-contained, offline HTML document without launching a browser. */
68
+ export async function renderHtml(input) {
69
+ const { options, limits } = normalizeOptions(input);
70
+ const markdownFile = options.markdownFile === undefined ? undefined : path.resolve(options.markdownFile);
71
+ const baseDir = path.resolve(options.baseDir ?? (markdownFile ? path.dirname(markdownFile) : process.cwd()));
72
+ let markdown = options.markdown;
73
+ if (markdownFile) {
74
+ const info = await stat(markdownFile);
75
+ if (!info.isFile()) fail('INVALID_INPUT', 'markdownFile must be a regular file.');
76
+ if (info.size > limits.maxMarkdownBytes) fail('LIMIT_EXCEEDED', 'Markdown exceeds maxMarkdownBytes.');
77
+ markdown = await readFile(markdownFile, 'utf8');
78
+ }
79
+ if (Buffer.byteLength(markdown, 'utf8') > limits.maxMarkdownBytes) fail('LIMIT_EXCEEDED', 'Markdown exceeds maxMarkdownBytes.');
80
+ const registry = await createAssetRegistry(options, baseDir, limits);
81
+ const md = new MarkdownIt({ html: false, linkify: false, typographer: false });
82
+ const environment = {};
83
+ const tokens = md.parse(markdown.replace(/^\uFEFF/u, ''), environment);
84
+ const warnings = [];
85
+
86
+ // Resolve explicit Markdown image links first. Never read arbitrary files from text mentions.
87
+ for (const block of tokens) {
88
+ for (const token of block.children ?? []) {
89
+ if (token.type !== 'image') continue;
90
+ const reference = token.attrGet('src');
91
+ const asset = await registry.resolve(reference);
92
+ const alt = md.renderer.renderInlineAsText(token.children ?? [], md.options, environment);
93
+ if (!asset) {
94
+ if (options.missingImages === 'error') fail('MISSING_IMAGE', `Cannot resolve image "${reference}". Supply the image or use a local path inside baseDir.`);
95
+ warnings.push({ code: 'MISSING_IMAGE', reference, message: `Missing image: ${reference}` });
96
+ }
97
+ token.meta = { asset, reference, alt };
98
+ }
99
+ }
100
+
101
+ // Longest aliases win; filename boundaries avoid diagram.png matching mydiagram.png.
102
+ const aliasList = [...registry.aliases.keys()].sort((a, b) => b.length - a.length || (a < b ? -1 : 1));
103
+ const pattern = aliasList.length ? new RegExp(aliasList.map(escapeRegex).join('|'), options.caseSensitive ? 'gu' : 'giu') : undefined;
104
+ for (const block of tokens) {
105
+ if (!block.children || !pattern) continue;
106
+ const children = [];
107
+ let linkDepth = 0;
108
+ for (const token of block.children) {
109
+ if (token.type === 'link_open') linkDepth++;
110
+ if (token.type === 'link_close') linkDepth--;
111
+ if (linkDepth || !['text', 'code_inline'].includes(token.type)) { children.push(token); continue; }
112
+ if (token.type === 'code_inline') {
113
+ // A filename-only code span is a placeholder; actual code examples remain code.
114
+ const asset = registry.lookup(token.content);
115
+ if (asset) {
116
+ token.type = 'image'; token.tag = 'img'; token.meta = { asset, alt: asset.alt };
117
+ }
118
+ children.push(token);
119
+ continue;
120
+ }
121
+ let offset = 0;
122
+ pattern.lastIndex = 0;
123
+ for (const match of token.content.matchAll(pattern)) {
124
+ const start = match.index;
125
+ const end = start + match[0].length;
126
+ if (isFilenameCharacter(token.content[start - 1]) || isBlockedSuffix(token.content, end)) continue;
127
+ const asset = registry.lookup(match[0]);
128
+ if (!asset) continue;
129
+ if (start > offset) {
130
+ const textToken = new token.constructor('text', '', 0);
131
+ textToken.content = token.content.slice(offset, start); children.push(textToken);
132
+ }
133
+ const imageToken = new token.constructor('image', 'img', 0);
134
+ imageToken.meta = { asset, alt: asset.alt }; children.push(imageToken);
135
+ offset = end;
136
+ }
137
+ if (offset === 0) children.push(token);
138
+ else if (offset < token.content.length) {
139
+ const tail = new token.constructor('text', '', 0);
140
+ tail.content = token.content.slice(offset); children.push(tail);
141
+ }
142
+ }
143
+ block.children = children;
144
+ }
145
+
146
+ md.renderer.rules.image = (inlineTokens, index) => {
147
+ const { asset, alt, reference } = inlineTokens[index].meta;
148
+ return asset ? renderAsset(asset, alt, options, 'inline') : `<span class="mdip-missing">${escape(`Missing image: ${reference}`)}</span>`;
149
+ };
150
+ let body = md.renderer.render(tokens, md.options, environment);
151
+ const unused = registry.assets.filter(asset => asset.placements === 0);
152
+ if (unused.length && options.unusedImages === 'error') fail('UNUSED_IMAGES', `Unreferenced images: ${unused.map(asset => asset.name).join(', ')}`);
153
+ if (unused.length && options.unusedImages === 'append') {
154
+ const figures = unused.map(asset => renderAsset(asset, asset.alt, options, 'append')).join('\n');
155
+ // Avoid a leading blank page when the input document contains only images.
156
+ const appendixClass = body.trim() ? ' class="mdip-appendix"' : '';
157
+ body += `<section${appendixClass}><h2>${escape(options.appendixTitle)}</h2>${figures}</section>`;
158
+ }
159
+ const report = {
160
+ images: registry.assets.map(asset => ({
161
+ name: asset.name, inlinePlacements: asset.placements,
162
+ appended: asset.placements === 0 && options.unusedImages === 'append',
163
+ ignored: asset.placements === 0 && options.unusedImages === 'ignore',
164
+ })),
165
+ warnings,
166
+ };
167
+ // HTML is disabled; a CSP and browser request blocking also prevent external resources.
168
+ const html = `<!doctype html><html lang="${escape(options.lang)}" dir="${options.direction}"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:; base-uri 'none'; form-action 'none'"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escape(options.title)}</title><style>${DEFAULT_CSS}\n${options.css.replace(/<\/style/giu, '<\\/style')}</style></head><body><main>${body}</main></body></html>`;
169
+ return { html, report, limits };
170
+ }