@md2nativedocx/cli 0.0.0-stage → 0.1.0

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/README.md CHANGED
@@ -1,3 +1,91 @@
1
- # Temporary Holding Version
1
+ # @md2nativedocx/cli
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
+ Convert **Markdown to native Word (`.docx`)** — a complete document conversion (text, tables, footnotes, math)
4
+ in which every embedded **Mermaid** diagram becomes real Word objects (editable OOXML shapes, SmartArt, charts),
5
+ not flattened PNGs. (`md` = Markdown, the input; *native* describes the Word output.) Standalone CLI counterpart to the
6
+ [md2nativedocx VS Code extension](https://marketplace.visualstudio.com/items?itemName=md2nativedocx.md2nativedocx),
7
+ useful for CI pipelines, scripts, Git hooks, or any editor other than VS Code.
8
+
9
+ Full project: <https://github.com/nicolasbridelance/md2nativedocx>
10
+
11
+ ## Requirements
12
+
13
+ - Node.js 18+
14
+ - [Pandoc](https://pandoc.org) 3.1.3+ on `PATH` (or point `MD2NATIVEDOCX_PANDOC_BIN` at a specific
15
+ binary). Unlike the VS Code extension, this standalone CLI does not auto-download Pandoc for you.
16
+
17
+ ## Install
18
+
19
+ ```sh
20
+ npm install -g @md2nativedocx/cli
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ```sh
26
+ md2nativedocx report.md -o report.docx
27
+ ```
28
+
29
+ ```
30
+ Usage: md2nativedocx <input.md> -o <output.docx> [options]
31
+
32
+ Options:
33
+ -o, --output <file> Output .docx path (required)
34
+ -h, --help Show this help
35
+ ```
36
+
37
+ Every ` ```mermaid ` code block in the Markdown becomes native, editable Word objects, never an embedded
38
+ image: a SmartArt graphic when the diagram fits one (processes, cycles, org charts, mindmaps, timelines,
39
+ kanban boards…), a Word chart with editable data for `pie`, `xychart` and `radar`, and individually
40
+ selectable shapes otherwise. Everything else (headings, tables, lists, LaTeX math, ...) is handled by
41
+ Pandoc as usual. Per-type detail:
42
+ [`docs/coverage.md`](https://github.com/nicolasbridelance/md2nativedocx/blob/main/docs/coverage.md).
43
+
44
+ | Variable | Effect |
45
+ |---|---|
46
+ | `MD2NATIVEDOCX_ENABLE_SMARTART=0` | No SmartArt: every diagram as shapes |
47
+ | `MD2NATIVEDOCX_NATIVE_CHARTS=0` (or `pie,xychart`) | No Word charts (or only those types) |
48
+ | `MD2NATIVEDOCX_SMARTART_STYLE` | SmartArt look: `simple`, `subtle`, `moderate`, `intense-accent`, `colorful` (default), `colorful-moderate`, `intense` |
49
+
50
+ ## Configuration
51
+
52
+ Page layout, typography, table of contents, and other export options are set via environment
53
+ variables (the same ones the VS Code extension's settings map to) — see
54
+ [`docs/specs/export_customization_SPEC.md`](https://github.com/nicolasbridelance/md2nativedocx/blob/main/docs/specs/export_customization_SPEC.md)
55
+ in the main repository for the full list.
56
+
57
+ ## Exit codes
58
+
59
+ | Code | Meaning |
60
+ |---|---|
61
+ | `0` | Written. Diagram warnings, if any, are on stderr and in the `.log` next to the output. |
62
+ | `1` | No document: input file not found, Pandoc missing or failed, post-processing failed. |
63
+ | `2` | Bad command line: unknown option, missing input or `-o`, a path outside the working directory. |
64
+
65
+ ## From Node: `convert()`
66
+
67
+ The same conversion as a function, for a Node application, a build script or a server, without spawning
68
+ the CLI. Pandoc must still be installed.
69
+
70
+ ```js
71
+ import { writeFile } from 'node:fs/promises';
72
+ import { convert, ConversionError } from '@md2nativedocx/cli';
73
+
74
+ const { document, warnings } = await convert('# Report\n\n```mermaid\ngraph LR\n A --> B\n```\n', {
75
+ layout: { pageSize: 'A4', margins: 'narrow' },
76
+ toc: true,
77
+ });
78
+ await writeFile('report.docx', document);
79
+ ```
80
+
81
+ `convert()` takes Markdown text, or `{ path }` of a file, and resolves to `{ document, warnings,
82
+ pandocStderr }`; `document` is the `.docx` as a `Buffer`. It reads no environment variable: every setting
83
+ is an option (types in `src/convert.d.mts`, mirroring the variables above). A failure is a
84
+ `ConversionError` whose `stage` is `setup`, `pandoc` or `postprocess`. To render a single diagram without
85
+ Pandoc, use `renderDiagram()` from `@md2nativedocx/core`.
86
+
87
+ ## License
88
+
89
+ CC0-1.0 (public domain). See the [main repository](https://github.com/nicolasbridelance/md2nativedocx)
90
+ for compliance/licensing details, including third-party notices for Pandoc (GPL-2.0-or-later,
91
+ invoked as a separate subprocess, never bundled).
@@ -0,0 +1,27 @@
1
+ # `reference.docx`
2
+
3
+ Passed to Pandoc via `--reference-doc` so generated documents use Word's current default look
4
+ (Aptos font scheme, modern "Office" theme colors, flat non-bold heading hierarchy) instead of
5
+ Pandoc's own bundled default, which is the original 2007-2010 Office theme verbatim (Calibri
6
+ headings / Cambria body, bold colored H1-H3, accent1 `#4F81BD`) — confirmed by inspecting
7
+ Pandoc 3.1.3's `--print-default-data-file reference.docx` directly, not assumed.
8
+
9
+ No real Word install was available to generate this by hand (the usual method: open Pandoc's
10
+ default reference.docx in Word, tweak styles via the UI, save). Built instead by editing the
11
+ OOXML directly:
12
+
13
+ - `word/theme/theme1.xml`: `majorFont`/`minorFont` latin typeface set to `Aptos Display`/`Aptos`;
14
+ color scheme swapped from the 2007 palette to the modern one (`accent1` `#4472C4`, etc.).
15
+ - `word/styles.xml`: `docDefaults` body text 12pt → 11pt, paragraph spacing single/10pt-after →
16
+ 1.08 line spacing/8pt-after (Word's actual `Normal.dotm` defaults since the 2013 theme, not
17
+ changed by the later Aptos font rollout); `Heading1`-`Heading3` no longer bold, `Heading4`-
18
+ `Heading5` no longer italic (the 2007 theme's bold/italic emphasis ladder isn't how the current
19
+ built-in Word style gallery renders headings); heading/hyperlink accent colors updated to match.
20
+
21
+ **Assumptions to verify in a real Word** (no Word install in this environment — verified so far
22
+ only structurally, via `xmllint`, and visually via LibreOffice, which substitutes a fallback font
23
+ since Aptos isn't installed here either): the exact non-bold/non-italic heading styling, and
24
+ whether Word's *current* built-in template still matches this reconstruction if Microsoft has
25
+ changed it again since. Regenerate by editing `word/theme/theme1.xml` and `word/styles.xml` the
26
+ same way if it needs correcting — no other part of this file matters (do not hand-edit
27
+ `document.xml`, numbering, or anything else in the package).
Binary file
@@ -0,0 +1,274 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * md2nativedocx CLI.
4
+ *
5
+ * `npx md2nativedocx rapport.md -o rapport.docx` packages the Pandoc invocation
6
+ * with the md2nativedocx Lua filter, producing a .docx with native, editable
7
+ * OOXML vector shapes for every ```mermaid block — or, for a flowchart shape
8
+ * `classifyTopology()` accepts (chain/tree/cycle, spec §4), an editable native
9
+ * SmartArt diagram instead (spec §7 step 5). See postprocess.mjs's
10
+ * `injectSmartArtParts` doc comment for how that dispatch is wired end to end.
11
+ *
12
+ * The conversion itself is `convert()` (src/convert.mjs), the package's
13
+ * library entry point; this file only reads the `MD2NATIVEDOCX_*` variables
14
+ * into its options (src/envOptions.mjs), handles files and arguments, and
15
+ * reports warnings, errors and the Word compatibility check.
16
+ *
17
+ * Security (AGENTS.md):
18
+ * * Rule #4: Pandoc is invoked via execFile with an argument array — never a
19
+ * shell string that interpolates a file path (src/convert.mjs).
20
+ * * Path traversal: input/output paths are resolved and validated against the
21
+ * expected root before any file operation.
22
+ * * Errors are typed (ParseError/TranslationError) and mapped to exit codes.
23
+ */
24
+
25
+ import { execFileSync } from 'node:child_process';
26
+ import { basename, extname } from 'node:path';
27
+ import { existsSync, writeFileSync } from 'node:fs';
28
+ import { CliError, resolveSafePath } from '../src/cliSupport.mjs';
29
+ import { ConversionError, convert } from '../src/convert.mjs';
30
+ import { hasLayoutOptions, readConvertOptionsFromEnv } from '../src/envOptions.mjs';
31
+
32
+ // `-o something.pptx` takes the Pandoc-free deck path (packages/pptx), dispatched before anything a
33
+ // .docx export needs.
34
+ {
35
+ const argv = process.argv.slice(2);
36
+ const outIndex = argv.findIndex((a) => a === '-o' || a === '--output');
37
+ if (outIndex !== -1 && /\.pptx$/i.test(argv[outIndex + 1] ?? '')) {
38
+ const { runPptxCli } = await import('../src/pptxExport.mjs');
39
+ process.exit(await runPptxCli(argv, process.cwd()));
40
+ }
41
+ }
42
+
43
+ const USAGE = `Usage: md2nativedocx <input.md> -o <output.docx|output.pptx> [options]
44
+
45
+ A .pptx output is a slide deck: one slide per \`\`\`mermaid block, titled with the nearest
46
+ preceding heading (text outside diagrams is not exported).
47
+
48
+ Options:
49
+ -o, --output <file> Output .docx or .pptx path (required)
50
+ --show-source .pptx only: show each diagram's Mermaid source beside it
51
+ -h, --help Show this help
52
+ `;
53
+
54
+ function parseArgs(argv) {
55
+ const args = { input: null, output: null };
56
+ for (let i = 0; i < argv.length; i++) {
57
+ const a = argv[i];
58
+ if (a === '-h' || a === '--help') {
59
+ args.help = true;
60
+ } else if (a === '-o' || a === '--output') {
61
+ args.output = argv[++i];
62
+ if (!args.output) throw new CliError('Missing value for --output', 2);
63
+ } else if (a.startsWith('-')) {
64
+ throw new CliError(`Unknown option: ${a}`, 2);
65
+ } else if (args.input === null) {
66
+ args.input = a;
67
+ } else {
68
+ throw new CliError(`Unexpected argument: ${a}`, 2);
69
+ }
70
+ }
71
+ return args;
72
+ }
73
+
74
+ async function main() {
75
+ const cwd = process.cwd();
76
+ const options = readConvertOptionsFromEnv(process.env);
77
+ // A custom reference document wins over page/typography options (spec §2.1, option (a)). Said as an
78
+ // info line, not with the `md2nativedocx: ` prefix that counts as a warning: nothing is wrong with
79
+ // the export, the user should just know.
80
+ if (options.referenceDoc && hasLayoutOptions(options.layout)) {
81
+ process.stderr.write(
82
+ 'md2nativedocx (info): page/typography options are ignored because a custom reference document (MD2NATIVEDOCX_REFERENCE_DOC) is set.\n',
83
+ );
84
+ }
85
+
86
+ let args;
87
+ try {
88
+ args = parseArgs(process.argv.slice(2));
89
+ } catch (err) {
90
+ if (err instanceof CliError) {
91
+ process.stderr.write(`${err.message}\n${USAGE}`);
92
+ process.exit(err.exitCode);
93
+ }
94
+ throw err;
95
+ }
96
+
97
+ if (args.help) {
98
+ process.stdout.write(USAGE);
99
+ process.exit(0);
100
+ }
101
+ if (!args.input) {
102
+ process.stderr.write(`Missing input file.\n${USAGE}`);
103
+ process.exit(2);
104
+ }
105
+ if (!args.output) {
106
+ process.stderr.write(`Missing output file (-o).\n${USAGE}`);
107
+ process.exit(2);
108
+ }
109
+
110
+ // Validate paths (anti path traversal): a path outside the working directory is a usage error (exit 2),
111
+ // as on the .pptx path.
112
+ let input;
113
+ let output;
114
+ try {
115
+ input = resolveSafePath(args.input, cwd);
116
+ output = resolveSafePath(args.output, cwd);
117
+ } catch (err) {
118
+ if (err instanceof CliError) {
119
+ process.stderr.write(`${err.message}\n`);
120
+ process.exit(err.exitCode);
121
+ }
122
+ throw err;
123
+ }
124
+ if (!existsSync(input)) {
125
+ process.stderr.write(`Input file not found: ${args.input}\n`);
126
+ process.exit(1);
127
+ }
128
+
129
+ let result;
130
+ try {
131
+ result = await convert({ path: input }, { ...options, cwd });
132
+ } catch (err) {
133
+ if (err instanceof ConversionError) {
134
+ process.stderr.write(`md2nativedocx: ${err.message}\n`);
135
+ // `exportService.ts` (VS Code) reads Pandoc's own message, e.g. to tell a missing Pandoc apart.
136
+ if (err.stage === 'pandoc' && err.pandocStderr) process.stderr.write(err.pandocStderr);
137
+ process.exit(1);
138
+ }
139
+ throw err;
140
+ }
141
+ writeFileSync(output, result.document);
142
+
143
+ // Surface the diagrams' non-fatal notices: never let a successful export silently hide something the
144
+ // author should know about.
145
+ const warnings = result.warnings.map((w) => `md2nativedocx: ${w}`);
146
+ const wordCompatibility = runWordCompatibilityCheck(output);
147
+ const logPath = writeExportLog({ input, output, warnings, rawStderr: result.pandocStderr, wordCompatibility });
148
+ if (warnings.length > 0) {
149
+ process.stdout.write(`Warnings: ${warnings.length} (see ${basename(logPath)})\n`);
150
+ for (const warning of warnings) process.stderr.write(`${warning}\n`);
151
+ }
152
+ process.stdout.write(`Wrote ${basename(output)}\n`);
153
+ }
154
+
155
+ /**
156
+ * Word compatibility check (ADR 0007 part D) — validates the generated
157
+ * `.docx` against the exact schema real Word enforces strictly, via
158
+ * `scripts/oxml-validator/`'s DLL (Microsoft's own Open XML SDK). See
159
+ * `AGENTS.md` → "Diagnosing 'Word won't open the file'" and ADR 0006 for
160
+ * why this exists: neither well-formed-XML checks nor LibreOffice (which
161
+ * this project already uses for `test:visual`) can catch a schema
162
+ * violation Word rejects outright — that gap cost 7 rounds of
163
+ * manually-compared, individually-disproven hypotheses in the SmartArt
164
+ * "cycle" corruption incident before this validator found the real cause
165
+ * in a single pass.
166
+ *
167
+ * Opt-in via `MD2NATIVEDOCX_OXML_VALIDATOR_DLL` (the vendored DLL path,
168
+ * set by the VS Code extension's `dotnetProvisioner.ts` when
169
+ * `md2nativedocx.wordCompatibilityCheck.enabled` is on) — unset for a
170
+ * standalone `npx md2nativedocx` install, which doesn't ship the DLL, so
171
+ * this is a no-op there unless a user sets both env vars by hand.
172
+ * `MD2NATIVEDOCX_DOTNET_BIN` mirrors `MD2NATIVEDOCX_PANDOC_BIN`'s own
173
+ * "defaults to the bare command name, letting a provisioner override it"
174
+ * convention.
175
+ *
176
+ * Never throws: an export that already succeeded must never be reported as
177
+ * failed because this optional, best-effort check couldn't run (`dotnet`
178
+ * missing, DLL missing, unexpected crash) — same rule already applied to
179
+ * `extractWarnings`/`readWarningCount` elsewhere in this pipeline. Returns
180
+ * `null` when not run at all (env var unset), or
181
+ * `{ ran: true, diagramErrors, otherErrors }` /
182
+ * `{ ran: false, reason }` otherwise.
183
+ */
184
+ function runWordCompatibilityCheck(docxPath) {
185
+ const dllPath = process.env.MD2NATIVEDOCX_OXML_VALIDATOR_DLL;
186
+ if (!dllPath) return null;
187
+
188
+ const dotnetBin = process.env.MD2NATIVEDOCX_DOTNET_BIN || 'dotnet';
189
+ try {
190
+ const stdout = execFileSync(dotnetBin, [dllPath, docxPath, '--json'], { encoding: 'utf8' });
191
+ const report = JSON.parse(stdout.trim().split('\n').pop());
192
+ const diagramErrors = report.errors.filter((e) => e.Part?.startsWith('/word/diagrams/'));
193
+ const otherErrors = report.errors.filter((e) => !e.Part?.startsWith('/word/diagrams/'));
194
+ return { ran: true, diagramErrors, otherErrors };
195
+ } catch (err) {
196
+ // execFileSync throws on a non-zero exit (errorCount > 0 also exits 1,
197
+ // per scripts/oxml-validator/OpenXmlValidator.cs) -- but it still wrote
198
+ // the JSON report to stdout first, captured on the error object.
199
+ const stdout = err.stdout?.toString();
200
+ if (stdout) {
201
+ try {
202
+ const report = JSON.parse(stdout.trim().split('\n').pop());
203
+ if (Array.isArray(report.errors)) {
204
+ const diagramErrors = report.errors.filter((e) => e.Part?.startsWith('/word/diagrams/'));
205
+ const otherErrors = report.errors.filter((e) => !e.Part?.startsWith('/word/diagrams/'));
206
+ return { ran: true, diagramErrors, otherErrors };
207
+ }
208
+ } catch {
209
+ // Fall through to the generic "could not run" case below.
210
+ }
211
+ }
212
+ return { ran: false, reason: err instanceof Error ? err.message : String(err) };
213
+ }
214
+ }
215
+
216
+ /** Write a plain-text log next to the output .docx (same basename, `.log`
217
+ * extension) so a click-right export (no VS Code Problems panel in view) has
218
+ * somewhere durable to point to — spec §10, "surface warnings". Written on
219
+ * every successful export, not just when there are warnings, for a
220
+ * consistent, discoverable location. */
221
+ function writeExportLog({ input, output, warnings, rawStderr, wordCompatibility }) {
222
+ const logPath = extname(output).toLowerCase() === '.docx'
223
+ ? output.slice(0, -extname(output).length) + '.log'
224
+ : `${output}.log`;
225
+ const lines = [
226
+ 'md2nativedocx export log',
227
+ `Date: ${new Date().toISOString()}`,
228
+ `Input: ${input}`,
229
+ `Output: ${output}`,
230
+ `Warnings: ${warnings.length}`,
231
+ '',
232
+ ];
233
+ if (warnings.length > 0) {
234
+ lines.push(...warnings.map((w) => `- ${w.replace(/^md2nativedocx:\s*(warning:\s*)?/, '')}`), '');
235
+ } else {
236
+ lines.push('No warnings.', '');
237
+ }
238
+ lines.push('--- Word compatibility check ---', ...formatWordCompatibility(wordCompatibility), '');
239
+ if (rawStderr && rawStderr.trim().length > 0) {
240
+ lines.push('--- Raw Pandoc/tool output ---', rawStderr.trimEnd(), '');
241
+ }
242
+ writeFileSync(logPath, lines.join('\n'), 'utf8');
243
+ return logPath;
244
+ }
245
+
246
+ /** Renders {@link runWordCompatibilityCheck}'s result as `.log` lines. Kept
247
+ * separate from that function so the "not run at all" (feature off) case
248
+ * reads as a plain, unalarming note rather than something that looks like a
249
+ * missing/failed check — most exports won't have this env var set at all
250
+ * (standalone CLI usage, or the VS Code setting turned off). */
251
+ function formatWordCompatibility(result) {
252
+ if (result === null) {
253
+ return ['Not checked (md2nativedocx.wordCompatibilityCheck is off, or this is a standalone CLI export).'];
254
+ }
255
+ if (!result.ran) {
256
+ return [`Not checked (could not run: ${result.reason}).`];
257
+ }
258
+ if (result.diagramErrors.length === 0) {
259
+ const note = result.otherErrors.length > 0
260
+ ? ` (${result.otherErrors.length} pre-existing schema note(s) elsewhere, unrelated to this export's diagram content — see TODO.md)`
261
+ : '';
262
+ return [`0 error(s) — validated against the same schema Word itself enforces.${note}`];
263
+ }
264
+ const lines = [`${result.diagramErrors.length} error(s) found in the generated diagram content:`];
265
+ for (const e of result.diagramErrors) {
266
+ lines.push(` - ${e.Path}: ${e.Description}`);
267
+ }
268
+ return lines;
269
+ }
270
+
271
+ main().catch((err) => {
272
+ process.stderr.write(`md2nativedocx: ${err instanceof Error ? err.message : String(err)}\n`);
273
+ process.exit(1);
274
+ });
package/package.json CHANGED
@@ -1,6 +1,63 @@
1
1
  {
2
2
  "name": "@md2nativedocx/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Complete Markdown to Word (.docx) converter, or PowerPoint (.pptx), with editable diagrams — all 29 Mermaid types become real shapes, SmartArt and charts that readers can change in Word, not flattened PNGs.",
5
+ "license": "CC0-1.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/nicolasbridelance/md2nativedocx",
9
+ "directory": "packages/cli"
10
+ },
11
+ "homepage": "https://github.com/nicolasbridelance/md2nativedocx#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/nicolasbridelance/md2nativedocx/issues"
14
+ },
15
+ "keywords": [
16
+ "mermaid",
17
+ "mermaid-to-docx",
18
+ "markdown-to-docx",
19
+ "markdown-to-word",
20
+ "docx",
21
+ "pptx",
22
+ "word",
23
+ "powerpoint",
24
+ "diagram",
25
+ "editable",
26
+ "pandoc",
27
+ "ooxml",
28
+ "drawingml",
29
+ "markdown"
30
+ ],
31
+ "type": "module",
32
+ "engines": {
33
+ "node": ">=18"
34
+ },
35
+ "exports": {
36
+ ".": {
37
+ "types": "./src/convert.d.mts",
38
+ "default": "./src/convert.mjs"
39
+ },
40
+ "./package.json": "./package.json"
41
+ },
42
+ "types": "./src/convert.d.mts",
43
+ "bin": {
44
+ "md2nativedocx": "bin/md2nativedocx.mjs"
45
+ },
46
+ "files": [
47
+ "bin",
48
+ "src",
49
+ "assets"
50
+ ],
51
+ "scripts": {
52
+ "build": "echo \"cli: no build step (plain JS)\"",
53
+ "typecheck": "echo \"cli: no typecheck step\"",
54
+ "test": "node --test test/*.test.mjs",
55
+ "pretest:ci": "mkdir -p reports",
56
+ "test:ci": "node --test --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=reports/junit.xml test/*.test.mjs"
57
+ },
58
+ "dependencies": {
59
+ "@md2nativedocx/pandoc-filter": "0.1.0",
60
+ "@md2nativedocx/pptx": "0.1.0",
61
+ "adm-zip": "^0.6.1"
62
+ }
63
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Native-chart hand-off (ADR 0011): completes the `c:chart` wiring for every diagram the core bridge
3
+ * dispatched to a native Word chart, and builds the embedded `.xlsx` workbook behind it.
4
+ *
5
+ * Same discipline as `postprocess.mjs`'s `injectSmartArtParts`, and within AGENTS.md rule 7's
6
+ * allowlist (new parts under `word/charts/` and `word/embeddings/`): it only **adds** parts, relationships and content types, never rewrites a part
7
+ * Pandoc wrote beyond swapping its own placeholder, and is a no-op when the document has no native
8
+ * chart. Every relationship it adds is internal (no `TargetMode`, rule 3). The workbook is a small zip
9
+ * written with `adm-zip`, already a dependency of this package, so no new dependency (rule 6).
10
+ */
11
+
12
+ import { existsSync, readFileSync } from 'node:fs';
13
+ import { join, resolve } from 'node:path';
14
+ import { AdmZip, readZipEntry, setZipEntry } from './zipUtils.mjs';
15
+
16
+ const PLACEHOLDER_RE = /CHART_PLACEHOLDER:([A-Za-z0-9-]+)/g;
17
+ const REL_BASE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
18
+ const CHART_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.drawingml.chart+xml';
19
+ const XLSX_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
20
+ const XML_DECL = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
21
+
22
+ /** Strict XML escaping of user-controlled text (AGENTS.md rule #2). */
23
+ function esc(text) {
24
+ return String(text)
25
+ .replace(/&/g, '&amp;')
26
+ .replace(/</g, '&lt;')
27
+ .replace(/>/g, '&gt;')
28
+ .replace(/"/g, '&quot;')
29
+ .replace(/'/g, '&apos;');
30
+ }
31
+
32
+ /** Column letters for a 0-based index: A..Z, AA..AZ, … */
33
+ function col(index) {
34
+ let n = index;
35
+ let out = '';
36
+ do {
37
+ out = String.fromCharCode(65 + (n % 26)) + out;
38
+ n = Math.floor(n / 26) - 1;
39
+ } while (n >= 0);
40
+ return out;
41
+ }
42
+
43
+ function textCell(ref, text) {
44
+ return `<c r="${ref}" t="inlineStr"><is><t xml:space="preserve">${esc(text)}</t></is></c>`;
45
+ }
46
+
47
+ /**
48
+ * Build the embedded workbook: one sheet, a header row, then one row per category (label, then one
49
+ * value per series; an `undefined`/`null` value is an empty cell).
50
+ *
51
+ * @param {{ sheetName: string, header: string[], rows: Array<[string, ...Array<number|null|undefined>]> }} data
52
+ * @returns {Buffer} the `.xlsx` file
53
+ */
54
+ export function buildWorkbookXlsx(data) {
55
+ const headerRow = `<row r="1">${data.header.map((h, c) => textCell(`${col(c)}1`, h)).join('')}</row>`;
56
+ const dataRows = data.rows.map((row, i) => {
57
+ const r = i + 2;
58
+ const [label, ...values] = row;
59
+ const cells = values
60
+ .map((value, c) => {
61
+ if (value === undefined || value === null) return '';
62
+ const n = Number(value);
63
+ if (!Number.isFinite(n)) throw new Error('md2nativedocx: chart value is not a finite number');
64
+ return `<c r="${col(c + 1)}${r}"><v>${n}</v></c>`;
65
+ })
66
+ .join('');
67
+ return `<row r="${r}">${textCell(`A${r}`, label)}${cells}</row>`;
68
+ });
69
+ const rows = [headerRow, ...dataRows].join('');
70
+ const main = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main';
71
+ const zip = new AdmZip();
72
+ const add = (name, xml) => zip.addFile(name, Buffer.from(XML_DECL + xml, 'utf8'));
73
+ add(
74
+ '[Content_Types].xml',
75
+ '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
76
+ '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
77
+ '<Default Extension="xml" ContentType="application/xml"/>' +
78
+ '<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' +
79
+ '<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' +
80
+ '<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>' +
81
+ '</Types>',
82
+ );
83
+ add(
84
+ '_rels/.rels',
85
+ `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="${REL_BASE}/officeDocument" Target="xl/workbook.xml"/></Relationships>`,
86
+ );
87
+ add(
88
+ 'xl/workbook.xml',
89
+ `<workbook xmlns="${main}" xmlns:r="${REL_BASE}"><sheets><sheet name="${esc(data.sheetName)}" sheetId="1" r:id="rId1"/></sheets></workbook>`,
90
+ );
91
+ add(
92
+ 'xl/_rels/workbook.xml.rels',
93
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
94
+ `<Relationship Id="rId1" Type="${REL_BASE}/worksheet" Target="worksheets/sheet1.xml"/>` +
95
+ `<Relationship Id="rId2" Type="${REL_BASE}/styles" Target="styles.xml"/>` +
96
+ '</Relationships>',
97
+ );
98
+ add('xl/worksheets/sheet1.xml', `<worksheet xmlns="${main}"><sheetData>${rows}</sheetData></worksheet>`);
99
+ add(
100
+ 'xl/styles.xml',
101
+ `<styleSheet xmlns="${main}"><fonts count="1"><font><sz val="11"/><name val="Calibri"/></font></fonts>` +
102
+ '<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>' +
103
+ '<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>' +
104
+ '<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>' +
105
+ '<cellXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/></cellXfs></styleSheet>',
106
+ );
107
+ return zip.toBuffer();
108
+ }
109
+
110
+ function nextChartNumber(zip) {
111
+ let max = 0;
112
+ for (const entry of zip.getEntries()) {
113
+ const m = /^word\/charts\/chart(\d+)\.xml$/.exec(entry.entryName);
114
+ if (m) max = Math.max(max, Number(m[1]));
115
+ }
116
+ return max + 1;
117
+ }
118
+
119
+ /**
120
+ * Complete the native-chart wiring for every placeholder in `docxPath`'s `word/document.xml`.
121
+ * Must run after `postProcessDocx` (it reads the namespace-fixed, id-renumbered document).
122
+ *
123
+ * @param {string} docxPath
124
+ * @param {string} chartDir directory the core bridge wrote `<id>/chart.xml`, `data.json`, `meta.json` into
125
+ */
126
+ export function injectChartParts(docxPath, chartDir) {
127
+ const archive = resolve(docxPath);
128
+ const zip = new AdmZip(archive);
129
+ let documentXml = readZipEntry(zip, 'word/document.xml');
130
+ const ids = [...new Set([...documentXml.matchAll(PLACEHOLDER_RE)].map((m) => m[1]))];
131
+ if (ids.length === 0) return;
132
+ if (!chartDir) throw new Error('md2nativedocx: document.xml references native charts but no chartDir was given');
133
+
134
+ let contentTypes = readZipEntry(zip, '[Content_Types].xml');
135
+ let relsXml = readZipEntry(zip, 'word/_rels/document.xml.rels');
136
+ let n = nextChartNumber(zip);
137
+ const newParts = [];
138
+
139
+ for (const id of ids) {
140
+ const dir = join(chartDir, id);
141
+ if (!existsSync(dir)) throw new Error(`md2nativedocx: no chart parts found for "${id}" in ${chartDir}`);
142
+ const chartXml = readFileSync(join(dir, 'chart.xml'), 'utf8');
143
+ const meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf8'));
144
+ const chartPath = `word/charts/chart${n}.xml`;
145
+ const relId = `rIdChart${n}`;
146
+ relsXml = relsXml.replace(
147
+ '</Relationships>',
148
+ `<Relationship Id="${relId}" Type="${REL_BASE}/chart" Target="charts/chart${n}.xml"/></Relationships>`,
149
+ );
150
+ contentTypes = contentTypes.replace('</Types>', `<Override PartName="/${chartPath}" ContentType="${CHART_CONTENT_TYPE}"/></Types>`);
151
+ newParts.push([chartPath, chartXml]);
152
+
153
+ if (meta.hasWorkbook) {
154
+ const data = JSON.parse(readFileSync(join(dir, 'data.json'), 'utf8'));
155
+ const workbookPath = `word/embeddings/Microsoft_Excel_Sheet${n}.xlsx`;
156
+ newParts.push([workbookPath, buildWorkbookXlsx(data)]);
157
+ newParts.push([
158
+ `word/charts/_rels/chart${n}.xml.rels`,
159
+ XML_DECL +
160
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
161
+ `<Relationship Id="rId1" Type="${REL_BASE}/package" Target="../embeddings/Microsoft_Excel_Sheet${n}.xlsx"/>` +
162
+ '</Relationships>',
163
+ ]);
164
+ if (!/Extension="xlsx"/i.test(contentTypes)) {
165
+ contentTypes = contentTypes.replace('</Types>', `<Default Extension="xlsx" ContentType="${XLSX_CONTENT_TYPE}"/></Types>`);
166
+ }
167
+ }
168
+ documentXml = documentXml.split(`CHART_PLACEHOLDER:${id}`).join(relId);
169
+ n += 1;
170
+ }
171
+
172
+ setZipEntry(zip, 'word/document.xml', documentXml);
173
+ setZipEntry(zip, '[Content_Types].xml', contentTypes);
174
+ setZipEntry(zip, 'word/_rels/document.xml.rels', relsXml);
175
+ for (const [path, content] of newParts) setZipEntry(zip, path, content);
176
+ zip.writeZip(archive);
177
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Small helpers shared by every CLI entry path (the Pandoc/.docx path in `bin/md2nativedocx.mjs` and
3
+ * the .pptx path in `pptxExport.mjs`).
4
+ */
5
+
6
+ import { resolve, isAbsolute } from 'node:path';
7
+
8
+ /** Typed error for CLI-level failures. */
9
+ export class CliError extends Error {
10
+ constructor(message, exitCode = 1) {
11
+ super(message);
12
+ this.name = 'CliError';
13
+ this.exitCode = exitCode;
14
+ }
15
+ }
16
+
17
+ /** Resolve and validate a path (anti path traversal via `..`). */
18
+ export function resolveSafePath(rawPath, cwd) {
19
+ const abs = isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath);
20
+ // Reject relative paths that escape the working directory via `..`.
21
+ if (!isAbsolute(rawPath)) {
22
+ const root = resolve(cwd);
23
+ if (!abs.startsWith(root + '/') && abs !== root) {
24
+ throw new CliError(`Path escapes the working directory: ${rawPath}`, 2);
25
+ }
26
+ }
27
+ return abs;
28
+ }