@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
package/package.json
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@md2nativedocx/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.1",
|
|
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
|
+
"THIRD_PARTY_NOTICES.md"
|
|
51
|
+
],
|
|
52
|
+
"scripts": {
|
|
53
|
+
"build": "echo \"cli: no build step (plain JS)\"",
|
|
54
|
+
"typecheck": "echo \"cli: no typecheck step\"",
|
|
55
|
+
"test": "node --test test/*.test.mjs",
|
|
56
|
+
"pretest:ci": "mkdir -p reports",
|
|
57
|
+
"test:ci": "node --test --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=reports/junit.xml test/*.test.mjs"
|
|
58
|
+
},
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"@md2nativedocx/pandoc-filter": "0.2.0",
|
|
61
|
+
"@md2nativedocx/pptx": "0.1.1",
|
|
62
|
+
"adm-zip": "^0.6.1"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -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,30 @@
|
|
|
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, relative, sep } 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 `..`. Compared through
|
|
21
|
+
// path.relative, not a `root + '/'` prefix: that never matched a Windows path (`\`), so every
|
|
22
|
+
// relative path was refused there (found 2026-10-08 by the .odt CLI tests on the Windows runner).
|
|
23
|
+
if (!isAbsolute(rawPath)) {
|
|
24
|
+
const rel = relative(resolve(cwd), abs);
|
|
25
|
+
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
26
|
+
throw new CliError(`Path escapes the working directory: ${rawPath}`, 2);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return abs;
|
|
30
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types of `@md2nativedocx/cli`'s library entry point (`convert.mjs`). Hand-written: the package is plain
|
|
3
|
+
* JavaScript with no build step. Keep in step with `convert.mjs` and `envOptions.mjs`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Page and typography of the bundled reference document. Ignored under {@link ConvertOptions.referenceDoc}. */
|
|
7
|
+
export interface LayoutOptions {
|
|
8
|
+
/** `A3`, `A4` (default), `Letter`, `Legal`… An unknown value falls back to A4. */
|
|
9
|
+
pageSize?: string;
|
|
10
|
+
/** `portrait` (default) or `landscape`. */
|
|
11
|
+
orientation?: string;
|
|
12
|
+
/** Word's presets: `normal` (default), `narrow`, `moderate`, `wide`. */
|
|
13
|
+
margins?: string;
|
|
14
|
+
/** Custom margins in centimetres; each side overrides the preset. */
|
|
15
|
+
marginsCustomCm?: { top?: number; right?: number; bottom?: number; left?: number };
|
|
16
|
+
headingFont?: string;
|
|
17
|
+
bodyFont?: string;
|
|
18
|
+
/** Body text size in points. */
|
|
19
|
+
fontSizePt?: number;
|
|
20
|
+
/** `single`, `1.15`, `1.5`, `double`; anything else keeps the template's spacing. */
|
|
21
|
+
lineSpacing?: string;
|
|
22
|
+
/** `both`, `right` or `center`; anything else leaves body text left-aligned. */
|
|
23
|
+
justify?: string;
|
|
24
|
+
/** Theme accent colour, `RRGGBB`. */
|
|
25
|
+
accentColor?: string;
|
|
26
|
+
/** Table header fill, `RRGGBB`. */
|
|
27
|
+
tableHeaderColor?: string;
|
|
28
|
+
/** A centred page number in the footer. */
|
|
29
|
+
footerPageNumber?: boolean;
|
|
30
|
+
/** Put each heading immediately followed by a table in its own landscape section. */
|
|
31
|
+
landscapeTables?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** How {@link convert} builds the document. Every field is optional; the defaults match the CLI's. */
|
|
35
|
+
export interface ConvertOptions {
|
|
36
|
+
/**
|
|
37
|
+
* `docx` (default) or `odt`. A `.odt` draws flowcharts as native LibreOffice shapes (other diagram types
|
|
38
|
+
* become a note for now); `layout`, `emojiFont`, SmartArt and native charts apply to `.docx` only, and
|
|
39
|
+
* `referenceDoc`, when given, must then be a `.odt`.
|
|
40
|
+
*/
|
|
41
|
+
format?: 'docx' | 'odt';
|
|
42
|
+
/** Pandoc's working directory, against which relative image paths resolve. Default: `process.cwd()`. */
|
|
43
|
+
cwd?: string;
|
|
44
|
+
/** A `.docx` template to use instead of the bundled one (Pandoc's `--reference-doc`). Must exist. */
|
|
45
|
+
referenceDoc?: string;
|
|
46
|
+
layout?: LayoutOptions;
|
|
47
|
+
/** A table of contents after the title, refreshed when Word opens the file. Default `false`. */
|
|
48
|
+
toc?: boolean;
|
|
49
|
+
/** Heading levels in the table of contents, 2 to 4. Default 3. */
|
|
50
|
+
tocDepth?: number;
|
|
51
|
+
/** Force a colour emoji font on emoji runs. Default `true`. */
|
|
52
|
+
emojiFont?: boolean;
|
|
53
|
+
/** SmartArt for the diagrams that can be one. Default `true`. */
|
|
54
|
+
smartArt?: boolean;
|
|
55
|
+
/** SmartArt look; unknown values fall back to `colorful`. */
|
|
56
|
+
smartArtStyle?: string;
|
|
57
|
+
/** Ship the pre-rendered SmartArt drawing, so Word and LibreOffice show the same geometry. Default `true`. */
|
|
58
|
+
smartArtDrawing?: boolean;
|
|
59
|
+
/** Native Word charts for `pie` / `xychart` / `radar`: `true` (default), `false`, or the types to chart. */
|
|
60
|
+
nativeCharts?: boolean | readonly string[];
|
|
61
|
+
/** Largest diagram width in EMU. Default: the page's usable width. */
|
|
62
|
+
maxDrawingCx?: number;
|
|
63
|
+
/** Largest diagram height in EMU. Default: the page's usable height. */
|
|
64
|
+
maxDrawingCy?: number;
|
|
65
|
+
/** Pandoc executable. Default `pandoc`, looked up on `PATH`. */
|
|
66
|
+
pandocBin?: string;
|
|
67
|
+
/** Node executable the Lua filter runs the diagram bridge with. Default: `process.execPath`. */
|
|
68
|
+
nodeBin?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A finished document. */
|
|
72
|
+
export interface ConvertResult {
|
|
73
|
+
/** The `.docx` (or `.odt`) file's bytes. */
|
|
74
|
+
document: Buffer;
|
|
75
|
+
/** Non-fatal problems in the diagrams (syntax ignored, a SmartArt or chart drawn as shapes instead). */
|
|
76
|
+
warnings: string[];
|
|
77
|
+
/** Everything Pandoc and the filter wrote to stderr, for a log. */
|
|
78
|
+
pandocStderr: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A conversion that produced no document. */
|
|
82
|
+
export declare class ConversionError extends Error {
|
|
83
|
+
/** `setup`: filter or reference document · `pandoc`: Pandoc failed or is missing · `postprocess` · `write`: the output file could not be written (open in Word, locked). */
|
|
84
|
+
readonly stage: 'setup' | 'pandoc' | 'postprocess' | 'write';
|
|
85
|
+
/** Pandoc's stderr, when it ran. */
|
|
86
|
+
readonly pandocStderr: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Convert a Markdown document to `.docx`: Pandoc with the md2nativedocx filter, every ```mermaid block as
|
|
91
|
+
* editable Word shapes, SmartArt or a native chart. Needs Pandoc installed. Never reads `MD2NATIVEDOCX_*`
|
|
92
|
+
* environment variables: behavior comes from `options` only.
|
|
93
|
+
*
|
|
94
|
+
* @param source Markdown text, or `{ path }` of a file for Pandoc to read (`.qmd` is read as Markdown).
|
|
95
|
+
* @throws {ConversionError} when no document could be produced.
|
|
96
|
+
*/
|
|
97
|
+
export declare function convert(source: string | { path: string }, options?: ConvertOptions): Promise<ConvertResult>;
|
package/src/convert.mjs
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `convert()`: Markdown in, finished `.docx` (or `.odt`) bytes out (docs/specs/01-v2-engine-spec.md §6,
|
|
3
|
+
* ADR 0012; `.odt`: ADR 0013).
|
|
4
|
+
*
|
|
5
|
+
* The library entry point of `@md2nativedocx/cli`, and what `bin/md2nativedocx.mjs` itself calls. It owns
|
|
6
|
+
* the whole document path: reference document build, Pandoc with the Lua filter (which renders each
|
|
7
|
+
* ```mermaid block through `renderDiagram()`), post-processing, SmartArt and chart part injection. It
|
|
8
|
+
* lives here and not in `core` because it runs Pandoc (AGENTS.md: core knows nothing about Pandoc).
|
|
9
|
+
*
|
|
10
|
+
* Behavior comes from {@link ConvertOptions} only. Every inherited `MD2NATIVEDOCX_*` variable is removed
|
|
11
|
+
* from the environment Pandoc and the filter see, then the ones the filter and the bridge read are set from
|
|
12
|
+
* the options, so a host process's environment cannot change a conversion behind its caller's back. The
|
|
13
|
+
* variables remain the CLI's interface (`envOptions.mjs` reads them).
|
|
14
|
+
*
|
|
15
|
+
* `format: 'odt'` hands Pandoc the derived `opendocument` template and the project's `reference.odt`
|
|
16
|
+
* (`assets/`, built by `scripts/build-odt-assets.mjs`), and the filter draws each flowchart as native
|
|
17
|
+
* ODF shapes. Nothing touches the `.odt` Pandoc writes (AGENTS.md rule 7). Page and typography options,
|
|
18
|
+
* SmartArt, charts and the emoji font are `.docx` features and do not apply.
|
|
19
|
+
*
|
|
20
|
+
* Security (AGENTS.md): Pandoc runs through `execFile` with an argument array (rule 4); every file is written
|
|
21
|
+
* in a fresh temporary directory removed afterwards; the post-processing is the rule 7 allowlist.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { execFile } from 'node:child_process';
|
|
25
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
26
|
+
import { tmpdir } from 'node:os';
|
|
27
|
+
import { extname, join } from 'node:path';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
import { postProcessDocx, injectSmartArtParts } from './postprocess.mjs';
|
|
30
|
+
import { unclosedFenceWarnings } from './unclosedFences.mjs';
|
|
31
|
+
import { injectChartParts } from './chartParts.mjs';
|
|
32
|
+
import { buildReferenceDoc, resolveMaxDrawingExtentEmu, resolvePageSize, resolveMargins } from './referenceDocBuilder.mjs';
|
|
33
|
+
|
|
34
|
+
/** A conversion that produced no document. `stage` says where it stopped. */
|
|
35
|
+
export class ConversionError extends Error {
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} message
|
|
38
|
+
* @param {{ stage: 'setup' | 'pandoc' | 'postprocess' | 'write', pandocStderr?: string, cause?: unknown }} details
|
|
39
|
+
*/
|
|
40
|
+
constructor(message, { stage, pandocStderr = '', cause } = {}) {
|
|
41
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
42
|
+
this.name = 'ConversionError';
|
|
43
|
+
this.stage = stage;
|
|
44
|
+
this.pandocStderr = pandocStderr;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Custom reference.docx (Word's current default look: Aptos font scheme, modern "Office" theme colors,
|
|
49
|
+
// non-bold flat heading hierarchy) instead of Pandoc's own bundled default, which is still the original
|
|
50
|
+
// 2007-2010 Office theme. See packages/cli/assets/README.md for how it was built and what it changes.
|
|
51
|
+
// `src/` and `bin/` are at the same depth, so this also holds in the VS Code extension's vendored bundle.
|
|
52
|
+
const DEFAULT_REFERENCE_DOC = fileURLToPath(new URL('../assets/reference.docx', import.meta.url));
|
|
53
|
+
// The `.odt` pair (ADR 0013): the project's own reference.odt (A4, 2.54 cm margins, diagram markers) and
|
|
54
|
+
// Pandoc's opendocument template plus the diagram-styles loop. See packages/cli/assets/README.md.
|
|
55
|
+
const DEFAULT_REFERENCE_ODT = fileURLToPath(new URL('../assets/reference.odt', import.meta.url));
|
|
56
|
+
const ODT_TEMPLATE = fileURLToPath(new URL('../assets/md2nativedocx.opendocument', import.meta.url));
|
|
57
|
+
/** 9 in in EMU: the `.docx` output's default drawing height (core's `MAX_DRAWING_CY`). */
|
|
58
|
+
const ODT_MAX_DRAWING_CY = 8229600;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Where @md2nativedocx/pandoc-filter lands varies with how the CLI was deployed (npm workspace, nested or
|
|
62
|
+
* flat install, the VS Code extension's vendored copy). import.meta.resolve() is Node's own resolution
|
|
63
|
+
* algorithm, so it finds it in every case without guessing directory depths.
|
|
64
|
+
*/
|
|
65
|
+
function resolveFilterPath() {
|
|
66
|
+
try {
|
|
67
|
+
return fileURLToPath(import.meta.resolve('@md2nativedocx/pandoc-filter/md2nativedocx.lua'));
|
|
68
|
+
} catch (err) {
|
|
69
|
+
throw new ConversionError(`could not locate @md2nativedocx/pandoc-filter: ${errorMessage(err)}`, { stage: 'setup', cause: err });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function errorMessage(err) {
|
|
74
|
+
return err instanceof Error ? err.message : String(err);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `MD2NATIVEDOCX_NATIVE_CHARTS` as the bridge reads it, or `null` for charts off. */
|
|
78
|
+
function nativeChartsEnv(setting) {
|
|
79
|
+
if (setting === undefined || setting === true) return '1';
|
|
80
|
+
if (setting === false || (Array.isArray(setting) && setting.length === 0)) return null;
|
|
81
|
+
return setting.join(',');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The environment Pandoc runs with: the caller's, minus `MD2NATIVEDOCX_*`, plus what `options` says. */
|
|
85
|
+
function pandocEnvironment(options, { smartArtDir, chartDir, maxDrawingExtentEmu, landscapeGeometry }) {
|
|
86
|
+
const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith('MD2NATIVEDOCX_')));
|
|
87
|
+
if (smartArtDir) env.MD2NATIVEDOCX_SMARTART_DIR = smartArtDir;
|
|
88
|
+
if (options.smartArtStyle !== undefined) env.MD2NATIVEDOCX_SMARTART_STYLE = options.smartArtStyle;
|
|
89
|
+
if (options.smartArtDrawing === false) env.MD2NATIVEDOCX_SMARTART_DRAWING = '0';
|
|
90
|
+
if (chartDir) {
|
|
91
|
+
env.MD2NATIVEDOCX_CHART_DIR = chartDir;
|
|
92
|
+
env.MD2NATIVEDOCX_NATIVE_CHARTS = nativeChartsEnv(options.nativeCharts);
|
|
93
|
+
}
|
|
94
|
+
// md2nativedocx.lua needs it on Windows (no shebang handling there, so it cannot run a bare .mjs). Our
|
|
95
|
+
// own interpreter is always Node: a system Node, or the VS Code extension's Electron run as Node.
|
|
96
|
+
env.MD2NATIVEDOCX_NODE_BIN = options.nodeBin ?? process.execPath;
|
|
97
|
+
// An explicit drawing cap wins over the page-derived one (e.g. a diagram meant for a half-width cell).
|
|
98
|
+
const cx = options.maxDrawingCx ?? (maxDrawingExtentEmu ? Math.round(maxDrawingExtentEmu.cx) : undefined);
|
|
99
|
+
const cy = options.maxDrawingCy ?? (maxDrawingExtentEmu ? Math.round(maxDrawingExtentEmu.cy) : undefined);
|
|
100
|
+
if (cx !== undefined) env.MD2NATIVEDOCX_MAX_DRAWING_CX = String(cx);
|
|
101
|
+
if (cy !== undefined) env.MD2NATIVEDOCX_MAX_DRAWING_CY = String(cy);
|
|
102
|
+
if (landscapeGeometry) {
|
|
103
|
+
env.MD2NATIVEDOCX_LANDSCAPE_TABLES = '1';
|
|
104
|
+
env.MD2NATIVEDOCX_PAGE_W_TWIPS = String(landscapeGeometry.pgSize.w);
|
|
105
|
+
env.MD2NATIVEDOCX_PAGE_H_TWIPS = String(landscapeGeometry.pgSize.h);
|
|
106
|
+
env.MD2NATIVEDOCX_MARGIN_TOP_TWIPS = String(landscapeGeometry.margins.top);
|
|
107
|
+
env.MD2NATIVEDOCX_MARGIN_RIGHT_TWIPS = String(landscapeGeometry.margins.right);
|
|
108
|
+
env.MD2NATIVEDOCX_MARGIN_BOTTOM_TWIPS = String(landscapeGeometry.margins.bottom);
|
|
109
|
+
env.MD2NATIVEDOCX_MARGIN_LEFT_TWIPS = String(landscapeGeometry.margins.left);
|
|
110
|
+
}
|
|
111
|
+
return env;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function runPandoc(bin, args, { cwd, env }) {
|
|
115
|
+
return new Promise((resolvePromise, reject) => {
|
|
116
|
+
execFile(bin, args, { cwd, env, maxBuffer: 64 * 1024 * 1024 }, (err, _stdout, stderr) => {
|
|
117
|
+
if (err) {
|
|
118
|
+
reject(new ConversionError(`Pandoc failed (exit ${err.code ?? '?'})`, { stage: 'pandoc', pandocStderr: stderr ?? '', cause: err }));
|
|
119
|
+
} else {
|
|
120
|
+
resolvePromise(stderr ?? '');
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Lines this project wrote to stderr (`md2nativedocx: ...`: parser warnings, SmartArt or chart fallbacks,
|
|
128
|
+
* from each bridge run the Lua filter spawned), without that prefix. Pandoc's own diagnostics share the
|
|
129
|
+
* stream but are not ours to count.
|
|
130
|
+
*/
|
|
131
|
+
function extractWarnings(stderrText) {
|
|
132
|
+
return stderrText
|
|
133
|
+
.split('\n')
|
|
134
|
+
.filter((line) => line.startsWith('md2nativedocx: ') && line.trim().length > 0)
|
|
135
|
+
.map((line) => line.slice('md2nativedocx: '.length));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Warnings about the source text itself, found before Pandoc runs (an unclosed ```mermaid fence). */
|
|
139
|
+
function sourceWarnings(source) {
|
|
140
|
+
let text;
|
|
141
|
+
try {
|
|
142
|
+
text = typeof source === 'string' ? source : readFileSync(source.path, 'utf8');
|
|
143
|
+
} catch {
|
|
144
|
+
// An unreadable input is Pandoc's error to report with its own message; this check is only advisory.
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
return unclosedFenceWarnings(text);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Where Pandoc reads from: the given file, or the text written to `work/input.md`. */
|
|
151
|
+
function inputFile(source, work) {
|
|
152
|
+
if (typeof source !== 'string') return source.path;
|
|
153
|
+
const inputPath = join(work, 'input.md');
|
|
154
|
+
writeFileSync(inputPath, source, 'utf8');
|
|
155
|
+
return inputPath;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Pandoc's reader and table-of-contents arguments, shared by both formats. */
|
|
159
|
+
function commonPandocArgs(inputPath, options) {
|
|
160
|
+
const args = [];
|
|
161
|
+
// Pandoc picks its reader from the extension and does not know `.qmd`; Quarto Markdown is Pandoc
|
|
162
|
+
// Markdown plus front matter and code chunks, so naming the reader is correct and drops its warning.
|
|
163
|
+
if (extname(inputPath).toLowerCase() === '.qmd') args.push('--from', 'markdown');
|
|
164
|
+
const tocDepth = Math.min(4, Math.max(2, Math.round(options.tocDepth ?? 3)));
|
|
165
|
+
if (options.toc) args.push('--toc', `--toc-depth=${tocDepth}`);
|
|
166
|
+
return args;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The `.odt` path: Pandoc writes the whole package, nothing is done to it afterwards (rule 7). */
|
|
170
|
+
async function convertOdt(source, options) {
|
|
171
|
+
const filterPath = resolveFilterPath();
|
|
172
|
+
const cwd = options.cwd ?? process.cwd();
|
|
173
|
+
const referenceDoc = options.referenceDoc ?? DEFAULT_REFERENCE_ODT;
|
|
174
|
+
if (!existsSync(referenceDoc)) {
|
|
175
|
+
throw new ConversionError(`reference document not found: ${referenceDoc}`, { stage: 'setup' });
|
|
176
|
+
}
|
|
177
|
+
if (extname(referenceDoc).toLowerCase() !== '.odt') {
|
|
178
|
+
throw new ConversionError(`a .odt output needs a .odt reference document, got ${referenceDoc}`, { stage: 'setup' });
|
|
179
|
+
}
|
|
180
|
+
const work = mkdtempSync(join(tmpdir(), 'md2nativedocx-convert-'));
|
|
181
|
+
try {
|
|
182
|
+
const inputPath = inputFile(source, work);
|
|
183
|
+
const outputPath = join(work, 'output.odt');
|
|
184
|
+
const pandocArgs = [inputPath, '-o', outputPath, '--lua-filter', filterPath, '--template', ODT_TEMPLATE, '--reference-doc', referenceDoc];
|
|
185
|
+
pandocArgs.push(...commonPandocArgs(inputPath, options));
|
|
186
|
+
// Diagrams are scaled to fit the bundled reference.odt's text width (A4, 2.54 cm margins) and at most
|
|
187
|
+
// 9 in high, the `.docx` default, which leaves room for a heading above a page-tall diagram instead of
|
|
188
|
+
// pushing it alone onto the next page.
|
|
189
|
+
const page = resolveMaxDrawingExtentEmu({ pageSize: 'A4' });
|
|
190
|
+
const env = pandocEnvironment(
|
|
191
|
+
{ ...options, smartArtStyle: undefined, smartArtDrawing: undefined },
|
|
192
|
+
{ smartArtDir: null, chartDir: null, maxDrawingExtentEmu: { cx: page.cx, cy: Math.min(page.cy, ODT_MAX_DRAWING_CY) }, landscapeGeometry: null },
|
|
193
|
+
);
|
|
194
|
+
const pandocStderr = await runPandoc(options.pandocBin ?? 'pandoc', pandocArgs, { cwd, env });
|
|
195
|
+
return { document: readFileSync(outputPath), warnings: [...sourceWarnings(source), ...extractWarnings(pandocStderr)], pandocStderr };
|
|
196
|
+
} finally {
|
|
197
|
+
rmSync(work, { recursive: true, force: true });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Convert a Markdown document to `.docx`, or `.odt` with `format: 'odt'`. See `convert.d.mts` for the
|
|
203
|
+
* option and result types.
|
|
204
|
+
*
|
|
205
|
+
* @param {string | { path: string }} source Markdown text, or a file for Pandoc to read (its extension picks
|
|
206
|
+
* the reader; `.qmd` is read as Markdown).
|
|
207
|
+
* @param {import('./convert.d.mts').ConvertOptions} [options]
|
|
208
|
+
* @returns {Promise<import('./convert.d.mts').ConvertResult>}
|
|
209
|
+
*/
|
|
210
|
+
export async function convert(source, options = {}) {
|
|
211
|
+
const format = options.format ?? 'docx';
|
|
212
|
+
if (format !== 'docx' && format !== 'odt') {
|
|
213
|
+
throw new ConversionError(`unknown format ${JSON.stringify(format)}; expected "docx" or "odt"`, { stage: 'setup' });
|
|
214
|
+
}
|
|
215
|
+
if (format === 'odt') return convertOdt(source, options);
|
|
216
|
+
const filterPath = resolveFilterPath();
|
|
217
|
+
const cwd = options.cwd ?? process.cwd();
|
|
218
|
+
const customReferenceDoc = options.referenceDoc;
|
|
219
|
+
if (customReferenceDoc !== undefined && !existsSync(customReferenceDoc)) {
|
|
220
|
+
throw new ConversionError(`reference document not found: ${customReferenceDoc}`, { stage: 'setup' });
|
|
221
|
+
}
|
|
222
|
+
// A custom reference document wins outright: its page setup is unknown, so page/typography options are
|
|
223
|
+
// not patched into it (spec §2.1, option (a)).
|
|
224
|
+
const layout = customReferenceDoc ? {} : (options.layout ?? {});
|
|
225
|
+
|
|
226
|
+
const work = mkdtempSync(join(tmpdir(), 'md2nativedocx-convert-'));
|
|
227
|
+
let generatedReferenceDoc = null;
|
|
228
|
+
try {
|
|
229
|
+
try {
|
|
230
|
+
generatedReferenceDoc = customReferenceDoc ? null : buildReferenceDoc(DEFAULT_REFERENCE_DOC, layout);
|
|
231
|
+
} catch (err) {
|
|
232
|
+
throw new ConversionError(`reference document setup failed: ${errorMessage(err)}`, { stage: 'setup', cause: err });
|
|
233
|
+
}
|
|
234
|
+
const referenceDoc = customReferenceDoc ?? generatedReferenceDoc?.path ?? DEFAULT_REFERENCE_DOC;
|
|
235
|
+
|
|
236
|
+
const inputPath = inputFile(source, work);
|
|
237
|
+
const outputPath = join(work, 'output.docx');
|
|
238
|
+
|
|
239
|
+
const pandocArgs = [inputPath, '-o', outputPath, '--lua-filter', filterPath];
|
|
240
|
+
if (existsSync(referenceDoc)) pandocArgs.push('--reference-doc', referenceDoc);
|
|
241
|
+
pandocArgs.push(...commonPandocArgs(inputPath, options));
|
|
242
|
+
|
|
243
|
+
// The bridge (one run per ```mermaid block, spawned by the Lua filter) hands SmartArt and chart parts
|
|
244
|
+
// back through these directories: a Lua filter cannot add package parts itself.
|
|
245
|
+
const smartArtDir = options.smartArt !== false ? mkdtempSync(join(work, 'smartart-')) : null;
|
|
246
|
+
const chartDir = nativeChartsEnv(options.nativeCharts) !== null ? mkdtempSync(join(work, 'chart-')) : null;
|
|
247
|
+
const env = pandocEnvironment(options, {
|
|
248
|
+
smartArtDir,
|
|
249
|
+
chartDir,
|
|
250
|
+
// Usable page area for the translator, whether or not the reference document needed a patch.
|
|
251
|
+
maxDrawingExtentEmu: customReferenceDoc ? null : resolveMaxDrawingExtentEmu(layout),
|
|
252
|
+
// The filter's "back to portrait" section needs the document's actual page geometry.
|
|
253
|
+
landscapeGeometry: layout.landscapeTables
|
|
254
|
+
? { pgSize: resolvePageSize(layout.pageSize, layout.orientation), margins: resolveMargins(layout.margins, layout.marginsCustomCm) }
|
|
255
|
+
: null,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const pandocStderr = await runPandoc(options.pandocBin ?? 'pandoc', pandocArgs, { cwd, env });
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
// Namespaces and drawing ids first: the SmartArt and chart injection read the fixed document.xml.
|
|
262
|
+
postProcessDocx(outputPath, { toc: Boolean(options.toc), emojiFont: options.emojiFont !== false });
|
|
263
|
+
if (smartArtDir) injectSmartArtParts(outputPath, smartArtDir);
|
|
264
|
+
if (chartDir) injectChartParts(outputPath, chartDir);
|
|
265
|
+
} catch (err) {
|
|
266
|
+
throw new ConversionError(`post-processing failed: ${errorMessage(err)}`, { stage: 'postprocess', pandocStderr, cause: err });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return { document: readFileSync(outputPath), warnings: [...sourceWarnings(source), ...extractWarnings(pandocStderr)], pandocStderr };
|
|
270
|
+
} finally {
|
|
271
|
+
rmSync(work, { recursive: true, force: true });
|
|
272
|
+
if (generatedReferenceDoc) rmSync(generatedReferenceDoc.dir, { recursive: true, force: true });
|
|
273
|
+
}
|
|
274
|
+
}
|