@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.
- package/README.md +114 -2
- package/THIRD_PARTY_NOTICES.md +38 -0
- package/assets/README.md +65 -0
- package/assets/md2nativedocx.opendocument +171 -0
- package/assets/reference.docx +0 -0
- package/assets/reference.odt +0 -0
- package/bin/md2nativedocx.mjs +209 -0
- package/package.json +62 -4
- package/src/chartParts.mjs +177 -0
- package/src/cliSupport.mjs +30 -0
- package/src/convert.d.mts +97 -0
- package/src/convert.mjs +274 -0
- package/src/envOptions.mjs +88 -0
- package/src/postprocess.mjs +628 -0
- package/src/pptxExport.mjs +102 -0
- package/src/referenceDocBuilder.mjs +521 -0
- package/src/unclosedFences.mjs +48 -0
- package/src/wordCheck.mjs +125 -0
- package/src/writeOutputSafely.mjs +69 -0
- package/src/zipUtils.mjs +38 -0
|
@@ -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
|
+
}
|
package/src/zipUtils.mjs
ADDED
|
@@ -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
|
+
}
|