@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 +90 -2
- package/assets/README.md +27 -0
- package/assets/reference.docx +0 -0
- package/bin/md2nativedocx.mjs +274 -0
- package/package.json +61 -4
- package/src/chartParts.mjs +177 -0
- package/src/cliSupport.mjs +28 -0
- package/src/convert.d.mts +91 -0
- package/src/convert.mjs +202 -0
- package/src/envOptions.mjs +88 -0
- package/src/postprocess.mjs +628 -0
- package/src/pptxExport.mjs +95 -0
- package/src/referenceDocBuilder.mjs +517 -0
- package/src/zipUtils.mjs +38 -0
package/README.md
CHANGED
|
@@ -1,3 +1,91 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @md2nativedocx/cli
|
|
2
2
|
|
|
3
|
-
|
|
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).
|
package/assets/README.md
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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, '&')
|
|
26
|
+
.replace(/</g, '<')
|
|
27
|
+
.replace(/>/g, '>')
|
|
28
|
+
.replace(/"/g, '"')
|
|
29
|
+
.replace(/'/g, ''');
|
|
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
|
+
}
|