@md2nativedocx/cli 0.0.0-stage → 0.2.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.
@@ -0,0 +1,125 @@
1
+ // Word compatibility check (ADR 0007 part D, TODO §R1.4). Validates the generated `.docx` against the
2
+ // schema real Word enforces (Open XML SDK) and says, part by part, what was actually checked. It never
3
+ // claims an effect on how Word opens the file: the validator cannot know that.
4
+ import { execFileSync } from 'node:child_process';
5
+
6
+ /** Pandoc release the "known Pandoc" signatures below were recorded against (2026-11-13). */
7
+ export const PANDOC_SIGNATURES_VERSION = '3.1.3';
8
+
9
+ // Parts only this project writes (diagrams, charts and their embedded workbooks).
10
+ const OWN_PARTS = ['/word/diagrams/', '/word/charts/', '/word/embeddings/'];
11
+ // Elements inside `word/document.xml` that only this project writes: the inline drawing canvas, its
12
+ // shapes, the SmartArt/chart frames and the alternate-content wrappers around them.
13
+ const OWN_PATH = /\/(wpc|wps|wp|a|pic|dgm|c|mc|w14|wp14):|\/w:drawing\b/;
14
+
15
+ /**
16
+ * Known noise from Pandoc's own `.docx` writer: a closed list of signatures, each pinned to a part and to
17
+ * the shape of the validator message. Anything not matching is reported as an error.
18
+ * @type {ReadonlyArray<{ name: string, part: string, path: RegExp, description?: RegExp }>}
19
+ */
20
+ const PANDOC_SIGNATURES = [
21
+ { name: 'syntax-highlighting styles', part: '/word/styles.xml', path: /\/w:style\[\d+\]\/w:rPr\[1\]$/, description: /unexpected child element/ },
22
+ { name: 'list numbering ids (w:nsid)', part: '/word/numbering.xml', path: /\/w:nsid\[1\]$/ },
23
+ { name: 'settings (w:doNotTrackMoves)', part: '/word/settings.xml', path: /^\/w:settings\[1\]$/, description: /doNotTrackMoves/ },
24
+ { name: 'table properties', part: '/word/document.xml', path: /\/w:tbl\[\d+\]\/(w:tblPr\[1\](\/w:jc\[1\])?|w:tr\[\d+\]\/w:trPr\[1\]\/w:tblHeader\[1\])$/ },
25
+ { name: 'list paragraph properties', part: '/word/document.xml', path: /^\/w:document\[1\]\/w:body\[1\]\/w:p\[\d+\]\/w:pPr\[1\]$/, description: /unexpected child element .*pStyle/ },
26
+ ];
27
+
28
+ /**
29
+ * Sort validator errors into what this project writes, Pandoc's known noise, and everything else.
30
+ * Own output wins over a known signature, so a diagram error can never be hidden as Pandoc noise.
31
+ *
32
+ * @param {ReadonlyArray<{ Part?: string, Path?: string, Description?: string }>} errors
33
+ * @returns {{ own: object[], pandoc: object[], other: object[] }}
34
+ */
35
+ export function classifyValidatorErrors(errors) {
36
+ const own = [];
37
+ const pandoc = [];
38
+ const other = [];
39
+ for (const e of errors) {
40
+ const part = e.Part ?? '';
41
+ const path = e.Path ?? '';
42
+ if (OWN_PARTS.some((p) => part.startsWith(p)) || (part === '/word/document.xml' && OWN_PATH.test(path))) {
43
+ own.push(e);
44
+ } else if (PANDOC_SIGNATURES.some((sig) => sig.part === part && sig.path.test(path) && (!sig.description || sig.description.test(e.Description ?? '')))) {
45
+ pandoc.push(e);
46
+ } else {
47
+ other.push(e);
48
+ }
49
+ }
50
+ return { own, pandoc, other };
51
+ }
52
+
53
+ /** @param {string} stdout */
54
+ function parseReport(stdout) {
55
+ const report = JSON.parse(stdout.trim().split('\n').pop());
56
+ if (!Array.isArray(report.errors)) throw new TypeError('validator report has no errors list');
57
+ return { ran: true, ...classifyValidatorErrors(report.errors) };
58
+ }
59
+
60
+ /**
61
+ * Run the validator on `docxPath` when `MD2NATIVEDOCX_OXML_VALIDATOR_DLL` is set.
62
+ *
63
+ * Never throws: an export that already succeeded must never be reported as failed because this
64
+ * optional check could not run. Returns `null` when off, `{ ran: true, own, pandoc, other }`, or
65
+ * `{ ran: false, reason }`.
66
+ * @param {string} docxPath
67
+ */
68
+ export function runWordCompatibilityCheck(docxPath) {
69
+ const dllPath = process.env.MD2NATIVEDOCX_OXML_VALIDATOR_DLL;
70
+ if (!dllPath) return null;
71
+ const dotnetBin = process.env.MD2NATIVEDOCX_DOTNET_BIN || 'dotnet';
72
+ try {
73
+ return parseReport(execFileSync(dotnetBin, [dllPath, docxPath, '--json'], { encoding: 'utf8' }));
74
+ } catch (err) {
75
+ // A non-zero exit (errorCount > 0) still wrote the JSON report to stdout first.
76
+ const stdout = err?.stdout?.toString();
77
+ if (stdout) {
78
+ try {
79
+ return parseReport(stdout);
80
+ } catch {
81
+ // Not a usable report: fall through to the "could not run" case.
82
+ }
83
+ }
84
+ return { ran: false, reason: err instanceof Error ? err.message : String(err) };
85
+ }
86
+ }
87
+
88
+ /** @param {{ Path?: string, Description?: string }} e */
89
+ const errorLine = (e) => ` - ${e.Path}: ${e.Description}`;
90
+
91
+ /**
92
+ * Render a check result as `.log` lines: one statement per part of the document, saying what was
93
+ * validated and what was found, never what Word will do.
94
+ * @param {ReturnType<typeof runWordCompatibilityCheck>} result
95
+ * @returns {string[]}
96
+ */
97
+ export function formatWordCompatibility(result) {
98
+ if (result === null) {
99
+ return ['Not checked (md2nativedocx.wordCompatibilityCheck is off, or this is a standalone CLI export).'];
100
+ }
101
+ if (!result.ran) return [`Not checked (could not run: ${result.reason}).`];
102
+
103
+ const lines = [];
104
+ lines.push(
105
+ result.own.length === 0
106
+ ? 'Diagrams and charts (written by md2nativedocx): no schema error found.'
107
+ : `Diagrams and charts (written by md2nativedocx): ${result.own.length} schema error(s):`,
108
+ ...result.own.map(errorLine),
109
+ );
110
+ const rest = [];
111
+ if (result.pandoc.length > 0) {
112
+ const names = [...new Set(PANDOC_SIGNATURES.filter((sig) => result.pandoc.some((e) => e.Part === sig.part && sig.path.test(e.Path ?? ''))).map((sig) => sig.name))];
113
+ rest.push(`${result.pandoc.length} known schema deviation(s) written by Pandoc itself (${names.join('; ')}; signatures recorded with Pandoc ${PANDOC_SIGNATURES_VERSION}).`);
114
+ }
115
+ if (result.other.length > 0) {
116
+ rest.push(`${result.other.length} other schema error(s):`);
117
+ }
118
+ lines.push(
119
+ rest.length === 0 ? 'Rest of the document (written by Pandoc): no schema error found.' : `Rest of the document (written by Pandoc): ${rest.shift()}`,
120
+ ...rest,
121
+ ...result.other.map(errorLine),
122
+ );
123
+ lines.push('This is a validation against the Open XML schema; it does not predict how Word will behave.');
124
+ return lines;
125
+ }
@@ -0,0 +1,69 @@
1
+ // Safe output writing (TODO §R1.1). Word keeps an exclusive handle on an open document and drops a
2
+ // `~$` lock file beside it; overwriting under it (notably on OneDrive) corrupts or loses the file.
3
+ // This module refuses in those cases, and otherwise swaps the new file in atomically so the previous
4
+ // target is never truncated.
5
+ import { closeSync, existsSync, fsyncSync, openSync, renameSync, rmSync, writeSync } from 'node:fs';
6
+ import { basename, dirname, join } from 'node:path';
7
+ import { ConversionError } from './convert.mjs';
8
+
9
+ /**
10
+ * Names of the lock files Word creates for `fileName`: `~$` + the full name, and, for long names,
11
+ * `~$` + the name without its first 2 characters.
12
+ * @param {string} fileName
13
+ * @returns {string[]}
14
+ */
15
+ export function wordLockFileNames(fileName) {
16
+ return [...new Set([`~$${fileName}`, `~$${fileName.slice(2)}`])];
17
+ }
18
+
19
+ /** @param {string} message @param {unknown} [cause] */
20
+ function writeError(message, cause) {
21
+ return new ConversionError(message, { stage: 'write', cause });
22
+ }
23
+
24
+ /**
25
+ * Write `data` to `target` without ever truncating an existing file.
26
+ *
27
+ * Refuses (throws a {@link ConversionError} with `stage: 'write'`) when a Word lock file sits beside the
28
+ * target, or when the existing target cannot be opened for writing. Otherwise writes
29
+ * `<target>.<pid>.tmp` in the same folder, fsyncs it, and renames it over the target; the temporary is
30
+ * removed on failure.
31
+ *
32
+ * @param {string} target absolute output path
33
+ * @param {Buffer | string} data
34
+ */
35
+ export function writeOutputSafely(target, data) {
36
+ const dir = dirname(target);
37
+ const name = basename(target);
38
+
39
+ for (const lock of wordLockFileNames(name)) {
40
+ if (existsSync(join(dir, lock))) {
41
+ throw writeError(`${name} is open in Word: close it or export under another name.`);
42
+ }
43
+ }
44
+
45
+ if (existsSync(target)) {
46
+ try {
47
+ closeSync(openSync(target, 'r+'));
48
+ } catch (err) {
49
+ throw writeError(`${name} cannot be overwritten (it is probably open in Word or another program): close it or export under another name.`, err);
50
+ }
51
+ }
52
+
53
+ const tmp = `${target}.${process.pid}.tmp`;
54
+ let fd;
55
+ try {
56
+ fd = openSync(tmp, 'w');
57
+ writeSync(fd, data);
58
+ fsyncSync(fd);
59
+ closeSync(fd);
60
+ fd = undefined;
61
+ renameSync(tmp, target);
62
+ } catch (err) {
63
+ if (fd !== undefined) {
64
+ try { closeSync(fd); } catch { /* already failing; the original error is the one to report */ }
65
+ }
66
+ rmSync(tmp, { force: true });
67
+ throw writeError(`could not write ${name}: ${err instanceof Error ? err.message : String(err)}`, err);
68
+ }
69
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Small AdmZip helpers shared by `referenceDocBuilder.mjs` and
3
+ * `postprocess.mjs` — both patch a handful of named entries inside an
4
+ * existing `.docx` (itself a zip archive) while leaving every other part
5
+ * byte-for-byte untouched.
6
+ *
7
+ * Replaces this project's former `execFileSync('unzip'/'zip', ...)` calls:
8
+ * neither binary exists on stock Windows, and since `md2nativedocx.
9
+ * layout.pageSize`/`.orientation` default to non-empty values
10
+ * (`buildReferenceDoc`'s `needsSectPr`), that path ran on *every* export —
11
+ * confirmed 2026-09-08 by a from-scratch Windows test machine where the
12
+ * very first export crashed with `spawnSync unzip ENOENT`, misclassified as
13
+ * "Pandoc missing" (see exportService.ts's `runCli` for that separate fix).
14
+ */
15
+ import AdmZip from 'adm-zip';
16
+
17
+ export { AdmZip };
18
+
19
+ /** Read a text entry from `zip`, throwing a clear error if it's missing —
20
+ * mirrors the old code's behaviour of a `readFileSync` on a temp file that
21
+ * `unzip` had failed to extract. */
22
+ export function readZipEntry(zip, entryName) {
23
+ const buf = zip.readFile(entryName);
24
+ if (!buf) throw new Error(`md2nativedocx: missing ${entryName} in the .docx archive`);
25
+ return buf.toString('utf8');
26
+ }
27
+
28
+ /** Add `entryName` if it isn't already present, otherwise replace its
29
+ * content. `AdmZip#updateFile` silently no-ops on an entry that doesn't
30
+ * exist yet, so a plain `updateFile` isn't safe for entries this project
31
+ * adds for the first time (e.g. `word/footer1.xml`, new SmartArt diagram
32
+ * parts) — found while porting away from `zip`, which handles both cases
33
+ * transparently. */
34
+ export function setZipEntry(zip, entryName, content) {
35
+ const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8');
36
+ if (zip.getEntry(entryName)) zip.updateFile(entryName, buf);
37
+ else zip.addFile(entryName, buf);
38
+ }