@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,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.pptx` branch of the CLI (`md2nativedocx deck.md -o deck.pptx`): one slide per ```mermaid block.
|
|
3
|
+
*
|
|
4
|
+
* Pandoc is not involved: `@md2nativedocx/pptx` rewrites the diagram shapes core already produces
|
|
5
|
+
* (see docs/adr/0010-pptx-production-translator-plan.md). Non-diagram Markdown is ignored.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { basename, extname } from 'node:path';
|
|
10
|
+
import { CliError, resolveSafePath } from './cliSupport.mjs';
|
|
11
|
+
import { ConversionError } from './convert.mjs';
|
|
12
|
+
import { writeOutputSafely } from './writeOutputSafely.mjs';
|
|
13
|
+
|
|
14
|
+
const USAGE = `Usage: md2nativedocx <input.md> -o <output.pptx> [--show-source]
|
|
15
|
+
|
|
16
|
+
--show-source Put each diagram's Mermaid source in a panel beside it
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
function parsePptxArgs(argv) {
|
|
20
|
+
const args = { input: null, output: null, showSource: false };
|
|
21
|
+
for (let i = 0; i < argv.length; i++) {
|
|
22
|
+
const a = argv[i];
|
|
23
|
+
if (a === '-o' || a === '--output') {
|
|
24
|
+
args.output = argv[++i];
|
|
25
|
+
if (!args.output) throw new CliError('Missing value for --output', 2);
|
|
26
|
+
} else if (a === '--show-source') {
|
|
27
|
+
args.showSource = true;
|
|
28
|
+
} else if (a === '-h' || a === '--help') {
|
|
29
|
+
args.help = true;
|
|
30
|
+
} else if (a.startsWith('-')) {
|
|
31
|
+
throw new CliError(`Unknown option: ${a}`, 2);
|
|
32
|
+
} else if (args.input === null) {
|
|
33
|
+
args.input = a;
|
|
34
|
+
} else {
|
|
35
|
+
throw new CliError(`Unexpected argument: ${a}`, 2);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return args;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Run the deck export; resolves to the process exit code. */
|
|
42
|
+
export async function runPptxCli(argv, cwd) {
|
|
43
|
+
try {
|
|
44
|
+
const args = parsePptxArgs(argv);
|
|
45
|
+
if (args.help) {
|
|
46
|
+
process.stdout.write(USAGE);
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
if (!args.input) throw new CliError('Missing input file.', 2);
|
|
50
|
+
const input = resolveSafePath(args.input, cwd);
|
|
51
|
+
const output = resolveSafePath(args.output, cwd);
|
|
52
|
+
if (!existsSync(input)) throw new CliError(`Input file not found: ${args.input}`, 1);
|
|
53
|
+
|
|
54
|
+
const { exportPptx, PptxConversionError } = await import('@md2nativedocx/pptx');
|
|
55
|
+
let result;
|
|
56
|
+
try {
|
|
57
|
+
result = await exportPptx(readFileSync(input, 'utf8'), { title: basename(input), showSource: args.showSource });
|
|
58
|
+
} catch (err) {
|
|
59
|
+
if (err instanceof PptxConversionError) throw new CliError(`md2nativedocx: ${err.message}`, 1);
|
|
60
|
+
throw err;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
writeOutputSafely(output, result.buffer);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (err instanceof ConversionError) throw new CliError(`md2nativedocx: ${err.message}`, 2);
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
writePptxLog(input, output, result.warnings, result.slideCount);
|
|
69
|
+
if (result.warnings.length > 0) {
|
|
70
|
+
process.stdout.write(`Warnings: ${result.warnings.length}\n`);
|
|
71
|
+
for (const w of result.warnings) process.stderr.write(`md2nativedocx: ${w}\n`);
|
|
72
|
+
}
|
|
73
|
+
process.stdout.write(`Wrote ${basename(output)} (${result.slideCount} slide${result.slideCount === 1 ? '' : 's'})\n`);
|
|
74
|
+
return 0;
|
|
75
|
+
} catch (err) {
|
|
76
|
+
if (err instanceof CliError) {
|
|
77
|
+
process.stderr.write(`${err.message}\n`);
|
|
78
|
+
return err.exitCode;
|
|
79
|
+
}
|
|
80
|
+
process.stderr.write(`md2nativedocx: pptx export failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Plain-text log next to the deck (`deck.pptx` -> `deck.log`), same header as the .docx export log so
|
|
86
|
+
* the VS Code extension reads the warning count and opens it the same way. */
|
|
87
|
+
function writePptxLog(input, output, warnings, slideCount) {
|
|
88
|
+
const logPath = output.slice(0, -extname(output).length) + '.log';
|
|
89
|
+
const lines = [
|
|
90
|
+
'md2nativedocx export log',
|
|
91
|
+
`Date: ${new Date().toISOString()}`,
|
|
92
|
+
`Input: ${input}`,
|
|
93
|
+
`Output: ${output}`,
|
|
94
|
+
`Warnings: ${warnings.length}`,
|
|
95
|
+
'',
|
|
96
|
+
...(warnings.length > 0 ? warnings.map((w) => `- ${w.replace(/^md2nativedocx:\s*(warning:\s*)?/, '')}`) : ['No warnings.']),
|
|
97
|
+
'',
|
|
98
|
+
`Slides: ${slideCount}`,
|
|
99
|
+
'',
|
|
100
|
+
];
|
|
101
|
+
writeOutputSafely(logPath, lines.join('\n'));
|
|
102
|
+
}
|
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a `reference.docx` patched for the page/typography settings a user
|
|
3
|
+
* picked (`docs/specs/export_customization_SPEC.md` §1.1-1.8/1.14, §2.1 —
|
|
4
|
+
* "Lot 1"), starting from the bundled static template
|
|
5
|
+
* (`packages/cli/assets/reference.docx`) or a custom one the caller already
|
|
6
|
+
* resolved.
|
|
7
|
+
*
|
|
8
|
+
* Same `unzip`/`zip` shell-out pattern `postprocess.mjs` already uses for
|
|
9
|
+
* the final `.docx` (rule `AGENTS.md` "nouvelle dépendance → escalade" —
|
|
10
|
+
* this needs none): extract only the entries a given option set actually
|
|
11
|
+
* touches, patch them with scoped string/regex edits (not a full XML
|
|
12
|
+
* parser — same rationale as `postprocess.mjs`: the input is a known,
|
|
13
|
+
* machine-authored template, not arbitrary XML), rezip in place.
|
|
14
|
+
*
|
|
15
|
+
* Confirmed empirically (not assumed) that this is worth doing at all:
|
|
16
|
+
* Pandoc's `--reference-doc` carries the reference document's own
|
|
17
|
+
* `<w:sectPr>` (page size/margins) into the generated `.docx` verbatim —
|
|
18
|
+
* tested with a distinctive custom `w:pgSz`/`w:pgMar` round-tripped through
|
|
19
|
+
* a real `pandoc` invocation. `styles.xml`'s `w:docDefaults` is likewise
|
|
20
|
+
* confirmed to reach ordinary body paragraphs: Pandoc emits `BodyText`/
|
|
21
|
+
* `FirstParagraph` for regular paragraphs, and neither style (nor `Normal`
|
|
22
|
+
* itself, its ultimate base) overrides `w:line`/`w:jc`, so both cascade
|
|
23
|
+
* down from `w:docDefaults` unless a run/paragraph in the Markdown source
|
|
24
|
+
* overrides them explicitly.
|
|
25
|
+
*
|
|
26
|
+
* Two of the 10 Lot 1 catalog entries are *not* a simple patch here and are
|
|
27
|
+
* deliberately out of scope for this module (tracked separately in
|
|
28
|
+
* TODO.md): 1.10 (TOC — a Pandoc flag + `settings.xml` patch) and 1.11
|
|
29
|
+
* (table style presets — underspecified in the spec, no concrete preset
|
|
30
|
+
* names to switch between yet). 1.9 (dedicated landscape section for
|
|
31
|
+
* tables, "Lot 5") is mostly a new Lua filter concern (spec §2.3,
|
|
32
|
+
* `md2nativedocx.lua`) but this module still has one narrow stake in it:
|
|
33
|
+
* {@link buildReferenceDoc}'s `landscapeTables` option forces an explicit
|
|
34
|
+
* `sectPr` even with no other page option set, so the Lua filter's "return
|
|
35
|
+
* to portrait" paragraphs have a concrete page size/margins to agree with
|
|
36
|
+
* (see its own doc comment for why). 1.13 (footer page numbers) needed a
|
|
37
|
+
* brand-new `word/footer*.xml` part + relationship + content-type override,
|
|
38
|
+
* closer in shape to `postprocess.mjs`'s `injectSmartArtParts` — implemented
|
|
39
|
+
* here anyway (`patchRelsForFooter`/`patchContentTypesForFooter` below)
|
|
40
|
+
* since {@link buildReferenceDoc} was already the right place to assemble it.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
44
|
+
import { tmpdir } from 'node:os';
|
|
45
|
+
import { join } from 'node:path';
|
|
46
|
+
import { AdmZip, readZipEntry, setZipEntry } from './zipUtils.mjs';
|
|
47
|
+
|
|
48
|
+
/** Page dimensions in twips (1/20 pt), portrait orientation (w < h). Standard
|
|
49
|
+
* OOXML values used verbatim by Word/LibreOffice — not an assumption, unlike
|
|
50
|
+
* the margin presets below. */
|
|
51
|
+
export const PAGE_SIZES_TWIPS = {
|
|
52
|
+
A3: { w: 16838, h: 23811 },
|
|
53
|
+
A4: { w: 11906, h: 16838 },
|
|
54
|
+
Letter: { w: 12240, h: 15840 },
|
|
55
|
+
Legal: { w: 12240, h: 20160 },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Word margin presets in twips — Word's own actual built-in values (1 inch
|
|
60
|
+
* = 1440 twips exactly), **not** the 2.5cm/1417-twip approximation the spec's
|
|
61
|
+
* catalog (§1.3) originally stated for a metric locale. That approximation
|
|
62
|
+
* was flagged from the start as an unverified assumption; **disconfirmed by
|
|
63
|
+
* a real Word test (2026-09-06)**: with the old 1417/1077 values, Word's own
|
|
64
|
+
* Page Setup dialog showed the margins as "Personnalisées" (Custom), not
|
|
65
|
+
* "Modérées" (Moderate) — proof the values didn't match any of Word's named
|
|
66
|
+
* presets. Twips are locale-independent (Word always stores/recognizes the
|
|
67
|
+
* same internal values regardless of whether its UI displays cm or inches),
|
|
68
|
+
* so the fix is the same set of numbers everywhere, not a locale-specific
|
|
69
|
+
* one: Normal/Wide use whole inches (1"=1440), Moderate's left/right use
|
|
70
|
+
* 0.75in=1080, matching Word's real built-in "Normal"/"Moderate"/"Wide"
|
|
71
|
+
* margin presets (verified against Word's own Page Setup dialog). `narrow`
|
|
72
|
+
* (0.5in=720) already matched and needed no change.
|
|
73
|
+
*/
|
|
74
|
+
export const MARGIN_PRESETS_TWIPS = {
|
|
75
|
+
normal: { top: 1440, right: 1440, bottom: 1440, left: 1440 },
|
|
76
|
+
narrow: { top: 720, right: 720, bottom: 720, left: 720 },
|
|
77
|
+
moderate: { top: 1440, right: 1080, bottom: 1440, left: 1080 },
|
|
78
|
+
wide: { top: 1440, right: 2880, bottom: 1440, left: 2880 },
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** `w:spacing/@w:line` (in 240ths of a line) for each named preset. `'default'`
|
|
82
|
+
* (or an absent/unrecognized token) means "leave the template's own 1.08 —
|
|
83
|
+
* Word's actual current default, not one of these four presets — alone",
|
|
84
|
+
* handled by {@link resolveLineSpacing} returning `null` rather than an
|
|
85
|
+
* entry here. */
|
|
86
|
+
export const LINE_SPACING_TOKENS = {
|
|
87
|
+
single: { line: 240, rule: 'auto' },
|
|
88
|
+
'1.15': { line: 276, rule: 'auto' },
|
|
89
|
+
'1.5': { line: 360, rule: 'auto' },
|
|
90
|
+
double: { line: 480, rule: 'auto' },
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const XML_ATTR_ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
94
|
+
|
|
95
|
+
/** Escape a string for use inside an XML attribute value (rule #2,
|
|
96
|
+
* AGENTS.md) — `headingFont`/`bodyFont` are free-text VS Code settings, not
|
|
97
|
+
* validated against a fixed list, so they reach `a:latin/@typeface`
|
|
98
|
+
* user-controlled. */
|
|
99
|
+
function escapeXmlAttr(value) {
|
|
100
|
+
return String(value).replace(/[&<>"']/g, (ch) => XML_ATTR_ESCAPES[ch]);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Validate a color as 6 hex digits, uppercased; `null` if invalid. Mirrors
|
|
104
|
+
* `ooxml-translator.ts`'s `hexColor` — same reasoning: escaping alone would
|
|
105
|
+
* still let a non-hex value through into `a:srgbClr/@val`. */
|
|
106
|
+
function validHexColor(value) {
|
|
107
|
+
return /^[0-9A-Fa-f]{6}$/.test(value) ? value.toUpperCase() : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Resolve a page size + orientation into portrait/landscape twip
|
|
111
|
+
* dimensions. Unrecognized `pageSize` falls back to A4 (spec §1.1 default). */
|
|
112
|
+
export function resolvePageSize(pageSize, orientation) {
|
|
113
|
+
const base = PAGE_SIZES_TWIPS[pageSize] ?? PAGE_SIZES_TWIPS.A4;
|
|
114
|
+
return orientation === 'landscape'
|
|
115
|
+
? { w: base.h, h: base.w, orientation: 'landscape' }
|
|
116
|
+
: { w: base.w, h: base.h, orientation: 'portrait' };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const DEFAULT_PAGE_SIZE = resolvePageSize('A4', 'portrait');
|
|
120
|
+
|
|
121
|
+
/** Resolve a margin preset name, or `custom` + `customCm` ({top,right,bottom,left}
|
|
122
|
+
* in centimeters). An invalid/incomplete custom value falls back to the
|
|
123
|
+
* `normal` preset rather than writing a malformed or absurd page margin. */
|
|
124
|
+
export function resolveMargins(margins, customCm) {
|
|
125
|
+
if (margins === 'custom' && customCm) {
|
|
126
|
+
const toTwip = (cm) => Math.round((Number(cm) * 1440) / 2.54);
|
|
127
|
+
const resolved = {
|
|
128
|
+
top: toTwip(customCm.top),
|
|
129
|
+
right: toTwip(customCm.right),
|
|
130
|
+
bottom: toTwip(customCm.bottom),
|
|
131
|
+
left: toTwip(customCm.left),
|
|
132
|
+
};
|
|
133
|
+
const sane = Object.values(resolved).every((v) => Number.isFinite(v) && v > 0 && v < 50000);
|
|
134
|
+
if (sane) return resolved;
|
|
135
|
+
}
|
|
136
|
+
return MARGIN_PRESETS_TWIPS[margins] ?? MARGIN_PRESETS_TWIPS.normal;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Resolve a line-spacing token to `{line, rule}`, or `null` for `'default'`
|
|
140
|
+
* (leave the template's current value untouched) or an unrecognized token. */
|
|
141
|
+
export function resolveLineSpacing(token) {
|
|
142
|
+
if (!token || token === 'default') return null;
|
|
143
|
+
return LINE_SPACING_TOKENS[token] ?? null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Font size in points -> `w:sz`/`w:szCs` half-points, clamped to a sane
|
|
147
|
+
* 6-72pt range (spec §1.6 only exposes 9-14, but this is a defensive floor/
|
|
148
|
+
* ceiling, not the UI-facing limit). `undefined` for a non-finite/non-positive
|
|
149
|
+
* input. */
|
|
150
|
+
function resolveFontSizeHalfPt(pt) {
|
|
151
|
+
const n = Number(pt);
|
|
152
|
+
if (!Number.isFinite(n) || n <= 0) return undefined;
|
|
153
|
+
return Math.min(Math.max(Math.round(n * 2), 12), 144);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Patch `word/theme/theme1.xml`: heading/body font typefaces and the accent1
|
|
158
|
+
* color. Each field is independently optional — an absent one leaves that
|
|
159
|
+
* part of the theme untouched.
|
|
160
|
+
*/
|
|
161
|
+
export function patchTheme(themeXml, { headingFont, bodyFont, accentColor } = {}) {
|
|
162
|
+
let out = themeXml;
|
|
163
|
+
if (headingFont) {
|
|
164
|
+
out = out.replace(
|
|
165
|
+
/(<a:majorFont><a:latin typeface=")[^"]*("\s*\/>)/,
|
|
166
|
+
`$1${escapeXmlAttr(headingFont)}$2`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
if (bodyFont) {
|
|
170
|
+
out = out.replace(
|
|
171
|
+
/(<a:minorFont><a:latin typeface=")[^"]*("\s*\/>)/,
|
|
172
|
+
`$1${escapeXmlAttr(bodyFont)}$2`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
if (accentColor) {
|
|
176
|
+
const safe = validHexColor(accentColor);
|
|
177
|
+
if (safe) {
|
|
178
|
+
out = out.replace(
|
|
179
|
+
/(<a:accent1><a:srgbClr val=")[0-9A-Fa-f]{6}("\s*\/><\/a:accent1>)/,
|
|
180
|
+
`$1${safe}$2`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** `w:jc`'s (`ST_Jc`) own valid values that this project's `justify` setting
|
|
188
|
+
* maps onto 1:1 — `'left'` (the setting's default) intentionally has no
|
|
189
|
+
* entry here since it means "don't write a `w:jc` at all, Word's own
|
|
190
|
+
* default is already left-aligned", same as before `right`/`center` existed. */
|
|
191
|
+
const JUSTIFY_VALUES = new Set(['both', 'right', 'center']);
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Patch `word/styles.xml`'s `<w:docDefaults>` block (base font size, line
|
|
195
|
+
* spacing, default justification) and, independently, the literal `w:val`
|
|
196
|
+
* color fallback on every style that references the theme's `accent1`
|
|
197
|
+
* (`w:themeColor="accent1"`) plus the `Table` style's header-row shading.
|
|
198
|
+
*
|
|
199
|
+
* The `docDefaults` part is scoped to that one block (extracted, transformed,
|
|
200
|
+
* spliced back) so a `w:sz`/`w:spacing` elsewhere in the stylesheet (e.g. a
|
|
201
|
+
* heading style's own override) is never touched. The accent/table-header
|
|
202
|
+
* parts operate on the whole document since the styles they touch
|
|
203
|
+
* (`Heading1`-`Heading9`, `Hyperlink`, `Table`) live outside `docDefaults`.
|
|
204
|
+
*/
|
|
205
|
+
export function patchStyles(stylesXml, { fontSizeHalfPt, lineSpacing, justify, accentColor, tableHeaderColor } = {}) {
|
|
206
|
+
let out = stylesXml;
|
|
207
|
+
|
|
208
|
+
const match = out.match(/<w:docDefaults>[\s\S]*?<\/w:docDefaults>/);
|
|
209
|
+
if (match) {
|
|
210
|
+
let block = match[0];
|
|
211
|
+
|
|
212
|
+
if (fontSizeHalfPt !== undefined) {
|
|
213
|
+
block = block
|
|
214
|
+
.replace(/<w:sz w:val="\d+"\s*\/>/, `<w:sz w:val="${fontSizeHalfPt}" />`)
|
|
215
|
+
.replace(/<w:szCs w:val="\d+"\s*\/>/, `<w:szCs w:val="${fontSizeHalfPt}" />`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (lineSpacing) {
|
|
219
|
+
block = block.replace(
|
|
220
|
+
/<w:spacing w:after="(\d+)" w:line="\d+" w:lineRule="auto"\s*\/>/,
|
|
221
|
+
`<w:spacing w:after="$1" w:line="${lineSpacing.line}" w:lineRule="${lineSpacing.rule}" />`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (JUSTIFY_VALUES.has(justify)) {
|
|
226
|
+
block = block.replace(/(<w:pPrDefault>\s*<w:pPr>)/, `$1<w:jc w:val="${justify}" />`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
out = out.replace(match[0], block);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (accentColor) {
|
|
233
|
+
out = patchAccentColorFallbacks(out, accentColor);
|
|
234
|
+
}
|
|
235
|
+
if (tableHeaderColor) {
|
|
236
|
+
out = patchTableHeaderColor(out, tableHeaderColor);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* `Heading1`-`Heading9`/`Hyperlink`/`Title`/`TOCHeading` each carry a
|
|
244
|
+
* `<w:color w:themeColor="accent1" w:val="4472C4" .../>` — `w:val` is a
|
|
245
|
+
* cached literal fallback for a renderer that doesn't resolve theme colors
|
|
246
|
+
* live. Real Word resolves `w:themeColor` dynamically (so patching just
|
|
247
|
+
* `theme1.xml`'s `a:accent1`, which {@link patchTheme} already does, is in
|
|
248
|
+
* principle enough there), but **LibreOffice does not**: rendering a
|
|
249
|
+
* document with a patched theme but an unpatched literal fallback, the
|
|
250
|
+
* heading/hyperlink color visibly stayed the old default blue — confirmed by
|
|
251
|
+
* sampling actual rendered pixel colors, not just reading the XML. Patching
|
|
252
|
+
* every such literal `w:val` here (regardless of renderer) makes the
|
|
253
|
+
* accent color correct everywhere, not just in implementations that bother
|
|
254
|
+
* with live theme resolution.
|
|
255
|
+
*
|
|
256
|
+
* `Title`/`TOCHeading` use a *shaded* variant (`w:themeShade`, a darker tint
|
|
257
|
+
* of accent1) — recomputing Word's exact shade algorithm isn't attempted
|
|
258
|
+
* here; those two also just get the flat custom color instead of a computed
|
|
259
|
+
* darker tint, a deliberate, documented simplification (consistent color,
|
|
260
|
+
* slightly less tonal variety, rather than risking a wrong-guessed formula).
|
|
261
|
+
*/
|
|
262
|
+
function patchAccentColorFallbacks(stylesXml, accentColor) {
|
|
263
|
+
const safe = validHexColor(accentColor);
|
|
264
|
+
if (!safe) return stylesXml;
|
|
265
|
+
return stylesXml.replace(
|
|
266
|
+
/<w:color\s+[^>]*\bw:themeColor="accent1"[^>]*\/>/g,
|
|
267
|
+
(tag) => tag.replace(/w:val="[0-9A-Fa-f]{6}"/, `w:val="${safe}"`),
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Shades the header row of every table (the `Table` style's
|
|
273
|
+
* `<w:tblStylePr w:type="firstRow">` conditional formatting — the same block
|
|
274
|
+
* Pandoc's default template already ships with just a bottom border, no
|
|
275
|
+
* fill) with a user-chosen background color. Adds a `<w:shd>` if none is
|
|
276
|
+
* present yet, or replaces an existing one — idempotent either way.
|
|
277
|
+
*/
|
|
278
|
+
function patchTableHeaderColor(stylesXml, tableHeaderColor) {
|
|
279
|
+
const safe = validHexColor(tableHeaderColor);
|
|
280
|
+
if (!safe) return stylesXml;
|
|
281
|
+
return stylesXml.replace(
|
|
282
|
+
/(<w:style[^>]*\bw:styleId="Table"[^>]*>[\s\S]*?<w:tblStylePr w:type="firstRow">[\s\S]*?<w:tcPr>)([\s\S]*?)(<\/w:tcPr>)/,
|
|
283
|
+
(whole, open, inner, close) => {
|
|
284
|
+
const shd = `<w:shd w:val="clear" w:color="auto" w:fill="${safe}"/>`;
|
|
285
|
+
// Schema order inside w:tcPr puts w:shd after w:tcBorders and before w:vAlign.
|
|
286
|
+
let nextInner;
|
|
287
|
+
if (/<w:shd\b[^>]*\/>/.test(inner)) nextInner = inner.replace(/<w:shd\b[^>]*\/>/, shd);
|
|
288
|
+
else if (/<w:vAlign\b/.test(inner)) nextInner = inner.replace(/<w:vAlign\b/, `${shd}<w:vAlign`);
|
|
289
|
+
else nextInner = `${inner}${shd}`;
|
|
290
|
+
return `${open}${nextInner}${close}`;
|
|
291
|
+
},
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Patch `word/document.xml`'s final (only) `<w:sectPr />` into a full
|
|
297
|
+
* section with explicit page size/orientation/margins, and optionally a
|
|
298
|
+
* `<w:footerReference>` (spec §1.13, "Lot 1" fast-follow — `footerRId` is the
|
|
299
|
+
* relationship id {@link patchRelsForFooter} minted for `word/footer1.xml`).
|
|
300
|
+
* `pgSize`/`margins` are each `null` when not requested — falls back to A4
|
|
301
|
+
* portrait / the `normal` preset respectively for whichever one the caller
|
|
302
|
+
* didn't ask to change, since a `<w:sectPr>` needs both to be well-formed
|
|
303
|
+
* regardless of which specific option triggered building one at all.
|
|
304
|
+
* `footerReference` must be the first child when present — confirmed
|
|
305
|
+
* against a real Pandoc-processed `sectPr` (`TODO.md`), not assumed.
|
|
306
|
+
*/
|
|
307
|
+
export function patchSectPr(documentXml, { pgSize, margins, footerRId } = {}) {
|
|
308
|
+
if (!pgSize && !margins && !footerRId) return documentXml;
|
|
309
|
+
const size = pgSize ?? DEFAULT_PAGE_SIZE;
|
|
310
|
+
const mar = margins ?? MARGIN_PRESETS_TWIPS.normal;
|
|
311
|
+
const orientAttr = size.orientation === 'landscape' ? ' w:orient="landscape"' : '';
|
|
312
|
+
const footerRef = footerRId ? `<w:footerReference w:type="default" r:id="${footerRId}"/>` : '';
|
|
313
|
+
const sectPr =
|
|
314
|
+
`<w:sectPr>${footerRef}<w:pgSz w:w="${size.w}" w:h="${size.h}"${orientAttr}/>` +
|
|
315
|
+
`<w:pgMar w:top="${mar.top}" w:right="${mar.right}" w:bottom="${mar.bottom}" w:left="${mar.left}" w:header="708" w:footer="708" w:gutter="0"/></w:sectPr>`;
|
|
316
|
+
return documentXml.replace(/<w:sectPr\s*\/>/, sectPr);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Minimal footer part (spec §1.13): a single centered `PAGE` field. Written
|
|
321
|
+
* as a brand-new `word/footer1.xml` package part — confirmed empirically
|
|
322
|
+
* that, unlike `settings.xml` (see `patchSettings`'s doc comment), Pandoc
|
|
323
|
+
* *does* carry over a reference document's footer part/relationship/
|
|
324
|
+
* `footerReference` verbatim: a canary footer with a `PAGE` field, added to
|
|
325
|
+
* a copy of `reference.docx` and round-tripped through a real `pandoc`
|
|
326
|
+
* invocation, survived into the generated `.docx` and rendered a page
|
|
327
|
+
* number when opened in LibreOffice.
|
|
328
|
+
*/
|
|
329
|
+
export const FOOTER_PAGE_NUMBER_XML =
|
|
330
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
|
331
|
+
'<w:ftr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">' +
|
|
332
|
+
'<w:p><w:pPr><w:jc w:val="center"/></w:pPr>' +
|
|
333
|
+
'<w:r><w:fldChar w:fldCharType="begin"/></w:r>' +
|
|
334
|
+
'<w:r><w:instrText xml:space="preserve"> PAGE </w:instrText></w:r>' +
|
|
335
|
+
'<w:r><w:fldChar w:fldCharType="separate"/></w:r>' +
|
|
336
|
+
'<w:r><w:t>1</w:t></w:r>' +
|
|
337
|
+
'<w:r><w:fldChar w:fldCharType="end"/></w:r>' +
|
|
338
|
+
'</w:p></w:ftr>';
|
|
339
|
+
|
|
340
|
+
/** Smallest unused `rId` in a `.rels` file's `Id="rIdN"` attributes (`rId1`
|
|
341
|
+
* if there are none) — `reference.docx`'s own relationships already go up
|
|
342
|
+
* to `rId8` plus a decorative `rId30` (an external hyperlink in its
|
|
343
|
+
* placeholder body content, never reaching real output), so a fixed id
|
|
344
|
+
* would risk colliding. */
|
|
345
|
+
function nextRelationshipId(relsXml) {
|
|
346
|
+
const ids = [...relsXml.matchAll(/Id="rId(\d+)"/g)].map((m) => Number(m[1]));
|
|
347
|
+
const max = ids.length ? Math.max(...ids) : 0;
|
|
348
|
+
return `rId${max + 1}`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Add the footer relationship to `word/_rels/document.xml.rels`. Returns
|
|
352
|
+
* `{ xml, rId }` — `rId` is what {@link patchSectPr}'s `footerRId` and
|
|
353
|
+
* `patchContentTypesForFooter` need to stay consistent. */
|
|
354
|
+
export function patchRelsForFooter(relsXml) {
|
|
355
|
+
const rId = nextRelationshipId(relsXml);
|
|
356
|
+
const rel =
|
|
357
|
+
'<Relationship Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" ' +
|
|
358
|
+
`Id="${rId}" Target="footer1.xml" />`;
|
|
359
|
+
return { xml: relsXml.replace('</Relationships>', `${rel}</Relationships>`), rId };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Declare `word/footer1.xml`'s content type in `[Content_Types].xml`.
|
|
363
|
+
* Idempotent (checked by path, not just presence of the word "footer") in
|
|
364
|
+
* case a future base template already ships a footer of its own. */
|
|
365
|
+
export function patchContentTypesForFooter(contentTypesXml) {
|
|
366
|
+
if (contentTypesXml.includes('/word/footer1.xml')) return contentTypesXml;
|
|
367
|
+
const override =
|
|
368
|
+
'<Override PartName="/word/footer1.xml" ' +
|
|
369
|
+
'ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml" />';
|
|
370
|
+
return contentTypesXml.replace('</Types>', `${override}</Types>`);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Patch `word/settings.xml` to add `<w:updateFields w:val="true" />` when a
|
|
375
|
+
* TOC is requested (spec §1.10/§2.2) — without it, a Pandoc-generated TOC
|
|
376
|
+
* field shows stale/empty content until the user manually refreshes it
|
|
377
|
+
* (F9); with it, Word itself offers to update fields on open. No-op (and
|
|
378
|
+
* idempotent) when `toc` is falsy or the flag is already present.
|
|
379
|
+
*
|
|
380
|
+
* Applied by `postprocess.mjs` to the **final generated `.docx`**'s own
|
|
381
|
+
* `settings.xml`, not to the reference doc here — confirmed empirically
|
|
382
|
+
* that Pandoc synthesizes its own `word/settings.xml` from scratch and does
|
|
383
|
+
* NOT carry over the reference document's (unlike `theme1.xml`/
|
|
384
|
+
* `styles.xml`/`document.xml`'s `sectPr`, all confirmed carried over).
|
|
385
|
+
* Exported from this module anyway since it's the same kind of pure,
|
|
386
|
+
* independently-testable XML patch as the others here — just consumed by a
|
|
387
|
+
* different caller.
|
|
388
|
+
*/
|
|
389
|
+
export function patchSettings(settingsXml, { toc } = {}) {
|
|
390
|
+
if (!toc || /<w:updateFields\b/.test(settingsXml)) return settingsXml;
|
|
391
|
+
return settingsXml.replace(/(<w:settings\b[^>]*>)/, `$1\n <w:updateFields w:val="true" />`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Build a patched `reference.docx` from `basePath` for the given raw option
|
|
396
|
+
* values (as they arrive from CLI env vars / VS Code settings — every field
|
|
397
|
+
* optional, `undefined` meaning "user didn't touch this one").
|
|
398
|
+
*
|
|
399
|
+
* Returns `null` when every option is unset (the common case — no export
|
|
400
|
+
* customization active): callers should keep using `basePath` unchanged,
|
|
401
|
+
* unzip/zip is skipped entirely. Otherwise returns `{ path, dir }`: `path` is
|
|
402
|
+
* the patched `.docx` (pass it to Pandoc's `--reference-doc`), `dir` is the
|
|
403
|
+
* scratch directory the caller must `rmSync(dir, { recursive: true, force:
|
|
404
|
+
* true })` once Pandoc has run (mirrors how `md2nativedocx.mjs` already
|
|
405
|
+
* cleans up its `smartArtDir` scratch directory).
|
|
406
|
+
*/
|
|
407
|
+
export function buildReferenceDoc(basePath, rawOptions = {}) {
|
|
408
|
+
const {
|
|
409
|
+
pageSize, orientation, margins, marginsCustomCm,
|
|
410
|
+
headingFont, bodyFont, fontSizePt, lineSpacing, justify, accentColor, tableHeaderColor,
|
|
411
|
+
footerPageNumber, landscapeTables,
|
|
412
|
+
} = rawOptions;
|
|
413
|
+
|
|
414
|
+
const needsFooter = footerPageNumber === true;
|
|
415
|
+
// Lot 5 (spec §1.9/§2.3) also forces an explicit sectPr, even with no
|
|
416
|
+
// pageSize/orientation/margins of its own: the Lua filter's "return to
|
|
417
|
+
// portrait" paragraphs need a concrete, known page size/margins to write
|
|
418
|
+
// — Pandoc's own unpatched default (an empty `<w:sectPr/>`, which resolves
|
|
419
|
+
// to whatever Letter/A4 default the local Pandoc/LibreOffice install
|
|
420
|
+
// falls back to) is not something this process can introspect, and a
|
|
421
|
+
// mismatch between that and this module's own A4/normal fallback would
|
|
422
|
+
// make the non-table sections of the document silently switch page size
|
|
423
|
+
// the moment landscape tables are turned on (found manually testing this
|
|
424
|
+
// lot end to end). So enabling landscapeTables alone pins the whole
|
|
425
|
+
// document to the same A4/normal-margins default `resolvePageSize`/
|
|
426
|
+
// `resolveMargins` already use elsewhere — a real, deliberate behavior
|
|
427
|
+
// change flagged in TODO.md, not a silent one: a user who wants a
|
|
428
|
+
// different page size must combine this toggle with the Lot 1 page size
|
|
429
|
+
// setting, same as any other Lot 1 option already requires today.
|
|
430
|
+
const needsSectPr =
|
|
431
|
+
pageSize !== undefined || orientation !== undefined || margins !== undefined || needsFooter || landscapeTables === true;
|
|
432
|
+
const needsTheme = headingFont !== undefined || bodyFont !== undefined || accentColor !== undefined;
|
|
433
|
+
const resolvedLineSpacing = resolveLineSpacing(lineSpacing);
|
|
434
|
+
const needsStyles =
|
|
435
|
+
fontSizePt !== undefined ||
|
|
436
|
+
resolvedLineSpacing !== null ||
|
|
437
|
+
JUSTIFY_VALUES.has(justify) ||
|
|
438
|
+
accentColor !== undefined ||
|
|
439
|
+
tableHeaderColor !== undefined;
|
|
440
|
+
|
|
441
|
+
if (!needsSectPr && !needsTheme && !needsStyles) return null;
|
|
442
|
+
|
|
443
|
+
const dir = mkdtempSync(join(tmpdir(), 'md2nativedocx-refdoc-'));
|
|
444
|
+
const workDocx = join(dir, 'reference.docx');
|
|
445
|
+
|
|
446
|
+
try {
|
|
447
|
+
const zip = new AdmZip(basePath);
|
|
448
|
+
|
|
449
|
+
if (needsTheme) {
|
|
450
|
+
const theme = readZipEntry(zip, 'word/theme/theme1.xml');
|
|
451
|
+
setZipEntry(zip, 'word/theme/theme1.xml', patchTheme(theme, { headingFont, bodyFont, accentColor }));
|
|
452
|
+
}
|
|
453
|
+
if (needsStyles) {
|
|
454
|
+
const styles = readZipEntry(zip, 'word/styles.xml');
|
|
455
|
+
const fontSizeHalfPt = fontSizePt !== undefined ? resolveFontSizeHalfPt(fontSizePt) : undefined;
|
|
456
|
+
setZipEntry(
|
|
457
|
+
zip,
|
|
458
|
+
'word/styles.xml',
|
|
459
|
+
patchStyles(styles, {
|
|
460
|
+
fontSizeHalfPt,
|
|
461
|
+
lineSpacing: resolvedLineSpacing,
|
|
462
|
+
justify,
|
|
463
|
+
accentColor,
|
|
464
|
+
tableHeaderColor,
|
|
465
|
+
}),
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
let footerRId;
|
|
470
|
+
if (needsFooter) {
|
|
471
|
+
const rels = readZipEntry(zip, 'word/_rels/document.xml.rels');
|
|
472
|
+
const patched = patchRelsForFooter(rels);
|
|
473
|
+
footerRId = patched.rId;
|
|
474
|
+
setZipEntry(zip, 'word/_rels/document.xml.rels', patched.xml);
|
|
475
|
+
|
|
476
|
+
const contentTypes = readZipEntry(zip, '[Content_Types].xml');
|
|
477
|
+
setZipEntry(zip, '[Content_Types].xml', patchContentTypesForFooter(contentTypes));
|
|
478
|
+
|
|
479
|
+
setZipEntry(zip, 'word/footer1.xml', FOOTER_PAGE_NUMBER_XML);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (needsSectPr) {
|
|
483
|
+
const documentXml = readZipEntry(zip, 'word/document.xml');
|
|
484
|
+
const pgSize =
|
|
485
|
+
pageSize !== undefined || orientation !== undefined || landscapeTables === true
|
|
486
|
+
? resolvePageSize(pageSize, orientation)
|
|
487
|
+
: null;
|
|
488
|
+
const mar = margins !== undefined || landscapeTables === true ? resolveMargins(margins, marginsCustomCm) : null;
|
|
489
|
+
setZipEntry(zip, 'word/document.xml', patchSectPr(documentXml, { pgSize, margins: mar, footerRId }));
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
zip.writeZip(workDocx);
|
|
493
|
+
return { path: workDocx, dir };
|
|
494
|
+
} catch (err) {
|
|
495
|
+
rmSync(dir, { recursive: true, force: true });
|
|
496
|
+
throw err;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Usable page area in EMU for `TranslateOptions.maxDrawingCx`/`maxDrawingCy`
|
|
502
|
+
* (`export_customization_SPEC.md` §2.4) — the same page size/orientation/
|
|
503
|
+
* margins resolution as {@link buildReferenceDoc}, but expressed as the
|
|
504
|
+
* drawable area a diagram must fit, in EMU (1 twip = 635 EMU) rather than as
|
|
505
|
+
* XML to patch. Independent of whether a reference doc was actually built:
|
|
506
|
+
* even a `md2nativedocx.referenceDocument` custom-template user still gets a
|
|
507
|
+
* diagram sized for *some* explicit page area rather than silently keeping
|
|
508
|
+
* the Letter-portrait default when they never touched Lot 1's settings at
|
|
509
|
+
* all — so this only returns `null` (meaning "keep the translator's own
|
|
510
|
+
* built-in default") when literally none of pageSize/orientation/margins
|
|
511
|
+
* were set, same condition as `needsSectPr` above.
|
|
512
|
+
*/
|
|
513
|
+
export function resolveMaxDrawingExtentEmu({ pageSize, orientation, margins, marginsCustomCm } = {}) {
|
|
514
|
+
if (pageSize === undefined && orientation === undefined && margins === undefined) return null;
|
|
515
|
+
const size = resolvePageSize(pageSize, orientation);
|
|
516
|
+
const mar = margins !== undefined ? resolveMargins(margins, marginsCustomCm) : MARGIN_PRESETS_TWIPS.normal;
|
|
517
|
+
const TWIP_TO_EMU = 635;
|
|
518
|
+
const cx = (size.w - mar.left - mar.right) * TWIP_TO_EMU;
|
|
519
|
+
const cy = (size.h - mar.top - mar.bottom) * TWIP_TO_EMU;
|
|
520
|
+
return { cx: Math.max(1, cx), cy: Math.max(1, cy) };
|
|
521
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Unclosed ```mermaid fence detection (TODO §R1.5). Pandoc turns an unterminated fence into a code block
|
|
2
|
+
// running to the end of the file, without any warning: the diagram and everything after it silently stop
|
|
3
|
+
// being converted. This is a pre-Pandoc check so the export log can say so.
|
|
4
|
+
//
|
|
5
|
+
// Mirrors `packages/vscode-extension/src/mermaidBlocks.ts` (`findUnclosedMermaidFences`): the extension
|
|
6
|
+
// is TypeScript and bundles this package as a vendored copy, so the few lines are kept in both places.
|
|
7
|
+
|
|
8
|
+
const ANY_FENCE = /^(`{3,}|~{3,})/;
|
|
9
|
+
const OPEN_MERMAID = /^(`{3,})\s*mermaid\s*$/i;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Lines (1-based) of every ```mermaid fence that is never closed.
|
|
13
|
+
* Other fenced blocks are skipped wholesale, as Pandoc does, so a mermaid fence shown inside a longer
|
|
14
|
+
* ````-fenced example is not reported.
|
|
15
|
+
* @param {string} text
|
|
16
|
+
* @returns {number[]}
|
|
17
|
+
*/
|
|
18
|
+
export function findUnclosedMermaidFenceLines(text) {
|
|
19
|
+
const lines = text.split(/\r\n|\r|\n/);
|
|
20
|
+
const unclosed = [];
|
|
21
|
+
for (let i = 0; i < lines.length; i++) {
|
|
22
|
+
const trimmed = (lines[i] ?? '').trim();
|
|
23
|
+
const mermaid = OPEN_MERMAID.exec(trimmed);
|
|
24
|
+
const marker = mermaid?.[1] ?? ANY_FENCE.exec(trimmed)?.[1];
|
|
25
|
+
if (!marker) continue;
|
|
26
|
+
const close = new RegExp(`^${marker}\\s*$`);
|
|
27
|
+
let j = i + 1;
|
|
28
|
+
while (j < lines.length && !close.test((lines[j] ?? '').trim())) j++;
|
|
29
|
+
if (j >= lines.length) {
|
|
30
|
+
// An unterminated fence runs to the end of the file: nothing after it is parsed either.
|
|
31
|
+
if (mermaid) unclosed.push(i + 1);
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
i = j;
|
|
35
|
+
}
|
|
36
|
+
return unclosed;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Export-log warnings for every unclosed ```mermaid fence in `text`.
|
|
41
|
+
* @param {string} text
|
|
42
|
+
* @returns {string[]}
|
|
43
|
+
*/
|
|
44
|
+
export function unclosedFenceWarnings(text) {
|
|
45
|
+
return findUnclosedMermaidFenceLines(text).map(
|
|
46
|
+
(line) => `warning: line ${line}: the \`\`\`mermaid block is never closed, so Pandoc treats everything from there to the end of the file as plain code and no diagram is converted. Add a closing \`\`\` line.`,
|
|
47
|
+
);
|
|
48
|
+
}
|