@md2nativedocx/cli 0.1.0 → 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 +26 -2
- package/THIRD_PARTY_NOTICES.md +38 -0
- package/assets/README.md +62 -24
- package/assets/md2nativedocx.opendocument +171 -0
- package/assets/reference.docx +0 -0
- package/assets/reference.odt +0 -0
- package/bin/md2nativedocx.mjs +30 -95
- package/package.json +5 -4
- package/src/cliSupport.mjs +6 -4
- package/src/convert.d.mts +9 -3
- package/src/convert.mjs +88 -16
- package/src/pptxExport.mjs +10 -3
- package/src/referenceDocBuilder.mjs +5 -1
- package/src/unclosedFences.mjs +48 -0
- package/src/wordCheck.mjs +125 -0
- package/src/writeOutputSafely.mjs +69 -0
package/README.md
CHANGED
|
@@ -27,10 +27,11 @@ md2nativedocx report.md -o report.docx
|
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
```
|
|
30
|
-
Usage: md2nativedocx <input.md> -o <output.docx> [options]
|
|
30
|
+
Usage: md2nativedocx <input.md> -o <output.docx|output.odt|output.pptx> [options]
|
|
31
31
|
|
|
32
32
|
Options:
|
|
33
|
-
-o, --output <file> Output .docx path (required)
|
|
33
|
+
-o, --output <file> Output .docx, .odt or .pptx path (required)
|
|
34
|
+
--show-source .pptx only: show each diagram's Mermaid source beside it
|
|
34
35
|
-h, --help Show this help
|
|
35
36
|
```
|
|
36
37
|
|
|
@@ -47,6 +48,22 @@ Pandoc as usual. Per-type detail:
|
|
|
47
48
|
| `MD2NATIVEDOCX_NATIVE_CHARTS=0` (or `pie,xychart`) | No Word charts (or only those types) |
|
|
48
49
|
| `MD2NATIVEDOCX_SMARTART_STYLE` | SmartArt look: `simple`, `subtle`, `moderate`, `intense-accent`, `colorful` (default), `colorful-moderate`, `intense` |
|
|
49
50
|
|
|
51
|
+
### LibreOffice (`.odt`)
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
md2nativedocx report.md -o report.odt
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Writes an OpenDocument text for LibreOffice: the document as Pandoc converts it, and each flowchart as one
|
|
58
|
+
group of native LibreOffice shapes (boxes, diamonds, cylinders…) with connectors attached to them, which
|
|
59
|
+
follow a box you move (*F3* enters the group), subgraphs as containers, `classDef`/`style`/`linkStyle`
|
|
60
|
+
colours kept. Other diagram types become a note in their place for now. The page is A4 with 2.54 cm
|
|
61
|
+
margins; the page and typography variables below, SmartArt and charts apply to `.docx` only. Pandoc
|
|
62
|
+
writes the whole file, with a reference document and template shipped in `assets/` (see
|
|
63
|
+
`THIRD_PARTY_NOTICES.md` for the template's BSD-3 notice).
|
|
64
|
+
|
|
65
|
+
From code: `convert(markdown, { format: 'odt' })`.
|
|
66
|
+
|
|
50
67
|
## Configuration
|
|
51
68
|
|
|
52
69
|
Page layout, typography, table of contents, and other export options are set via environment
|
|
@@ -62,6 +79,13 @@ in the main repository for the full list.
|
|
|
62
79
|
| `1` | No document: input file not found, Pandoc missing or failed, post-processing failed. |
|
|
63
80
|
| `2` | Bad command line: unknown option, missing input or `-o`, a path outside the working directory. |
|
|
64
81
|
|
|
82
|
+
## Very large diagrams
|
|
83
|
+
|
|
84
|
+
A diagram with more than 500 nodes (subgraph boxes included) or 800 edges is not laid out: the document
|
|
85
|
+
still converts, the diagram is replaced by a grey note saying why, and a warning goes to stderr and the
|
|
86
|
+
`.log`. This keeps one pathological block from running for minutes. In `@md2nativedocx/core`, `renderDiagram()`
|
|
87
|
+
throws a `DiagramTooLargeError` instead, and takes `maxNodes`, `maxEdges` and `maxSourceLength` to change the limits.
|
|
88
|
+
|
|
65
89
|
## From Node: `convert()`
|
|
66
90
|
|
|
67
91
|
The same conversion as a function, for a Node application, a build script or a server, without spawning
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Third-party notices
|
|
2
|
+
|
|
3
|
+
Everything in this package is CC0-1.0 except the file below.
|
|
4
|
+
|
|
5
|
+
## `assets/md2nativedocx.opendocument`
|
|
6
|
+
|
|
7
|
+
Derived from Pandoc's `opendocument` template (Pandoc 3.1.3, `data/templates/default.opendocument`).
|
|
8
|
+
Pandoc's templates are dual-licensed under the GNU GPL (version 2 or later) and the BSD 3-Clause
|
|
9
|
+
license; this derived file is distributed under the BSD 3-Clause license. The only change is a loop
|
|
10
|
+
added inside `office:automatic-styles`, filled by this project's Lua filter with the styles of the
|
|
11
|
+
diagrams it draws. That change is distributed under the same license.
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
Copyright (c) 2014-2023, John MacFarlane <jgm@berkeley.edu>
|
|
15
|
+
All rights reserved.
|
|
16
|
+
|
|
17
|
+
Redistribution and use in source and binary forms, with or without modification, are permitted
|
|
18
|
+
provided that the following conditions are met:
|
|
19
|
+
|
|
20
|
+
* Redistributions of source code must retain the above copyright notice, this list of conditions
|
|
21
|
+
and the following disclaimer.
|
|
22
|
+
* Redistributions in binary form must reproduce the above copyright notice, this list of
|
|
23
|
+
conditions and the following disclaimer in the documentation and/or other materials provided
|
|
24
|
+
with the distribution.
|
|
25
|
+
* Neither the name of John MacFarlane nor the names of other contributors may be used to endorse
|
|
26
|
+
or promote products derived from this software without specific prior written permission.
|
|
27
|
+
|
|
28
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR
|
|
29
|
+
IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
|
30
|
+
FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
|
|
31
|
+
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
32
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
|
33
|
+
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
|
|
34
|
+
IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
|
|
35
|
+
THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Pandoc itself is not part of this package: it is run as a separate program (GPL-2.0-or-later).
|
package/assets/README.md
CHANGED
|
@@ -1,27 +1,65 @@
|
|
|
1
1
|
# `reference.docx`
|
|
2
2
|
|
|
3
3
|
Passed to Pandoc via `--reference-doc` so generated documents use Word's current default look
|
|
4
|
-
(Aptos font scheme, modern "Office"
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
`
|
|
4
|
+
(Aptos font scheme, the modern "Office" colour scheme, accent1 `#4472C4`, flat non-bold heading
|
|
5
|
+
hierarchy, 11 pt body text with 1.08 line spacing and 8 pt after) instead of Pandoc's own bundled
|
|
6
|
+
default, which is the 2007-2010 Office look (Calibri/Cambria, bold coloured headings).
|
|
7
|
+
|
|
8
|
+
**Written by this project, from scratch (2026-10-08).** Every part comes from
|
|
9
|
+
`packages/cli/reference-docx-src/` and is assembled by:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
node scripts/build-reference-docx.mjs
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Edit a part there, rebuild, and commit both. The build is reproducible (fixed timestamps).
|
|
16
|
+
|
|
17
|
+
Why: the previous file was derived from Pandoc's default `reference.docx`, which Pandoc distributes
|
|
18
|
+
under GPL-2+ only (its templates are dual GPL-2+/BSD-3, its other data files are not), while this
|
|
19
|
+
project is CC0. The maintainer chose to rebuild it (ADR 0013, `docs/adr/0013-odf-output-phase-0.md`).
|
|
20
|
+
The new parts keep the same visual values: a LibreOffice render of a document using every Pandoc
|
|
21
|
+
style (title block, abstract, headings, lists, footnote, code, table with caption, definition list,
|
|
22
|
+
diagram) is pixel-identical to the previous one. They contain no sample text and no relationship to
|
|
23
|
+
anything outside the package (the old file carried an unused `http://example.com` link from
|
|
24
|
+
Pandoc's sample document).
|
|
25
|
+
|
|
26
|
+
What the rest of the code relies on (`src/referenceDocBuilder.mjs`, tested in
|
|
27
|
+
`test/reference-doc-builder.test.mjs`):
|
|
28
|
+
|
|
29
|
+
- `word/theme/theme1.xml`: `<a:majorFont><a:latin typeface="…" />`, the same for `minorFont`, and
|
|
30
|
+
`<a:accent1><a:srgbClr val="RRGGBB"/></a:accent1>`;
|
|
31
|
+
- `word/styles.xml`: `w:docDefaults` with `w:sz`/`w:szCs` and a `w:pPrDefault`; accent colours as
|
|
32
|
+
`w:themeColor="accent1"` with a literal `w:val` fallback (LibreOffice does not resolve theme
|
|
33
|
+
colours); a `Table` style with a `firstRow` conditional `w:tcPr`;
|
|
34
|
+
- `word/document.xml`: an empty `<w:sectPr />`, which `patchSectPr` fills.
|
|
35
|
+
|
|
36
|
+
Styles Pandoc adds at conversion time (syntax highlighting `SourceCode`, `*Tok`) are not in this
|
|
37
|
+
file: Pandoc writes them itself.
|
|
38
|
+
|
|
39
|
+
**To verify in a real Word** (no Word in this environment; checked with LibreOffice and the Open XML
|
|
40
|
+
validator): heading styling and whether Word's current built-in template still matches.
|
|
41
|
+
|
|
42
|
+
# `reference.odt` and `md2nativedocx.opendocument`
|
|
43
|
+
|
|
44
|
+
The two files handed to Pandoc for a `.odt` (ADR 0013; `AGENTS.md` rule 7: nothing touches the
|
|
45
|
+
`.odt` Pandoc writes). Both are built by:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
node scripts/build-odt-assets.mjs
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- **`reference.odt`**, written by this project (CC0) from `packages/cli/reference-odt-src/`, never
|
|
52
|
+
Pandoc's own (GPL-2+). Pandoc keeps its `styles.xml` and writes the rest. It defines the styles
|
|
53
|
+
Pandoc's ODT writer refers to (headings, title block, code, quotations, tables, captions,
|
|
54
|
+
definition lists, footnotes, links), an A4 page with 2.54 cm margins, and the arrow markers and
|
|
55
|
+
dash pattern the diagrams use (`ODF_GRAPHIC_DEFINITIONS` in `@md2nativedocx/core`, kept equal by
|
|
56
|
+
`test/odt.test.mjs`). LibreOffice does **not** supply its built-in look for a style a document
|
|
57
|
+
references without defining (headings came out as plain text with a minimal `styles.xml`,
|
|
58
|
+
2026-10-08), so each one is written here.
|
|
59
|
+
- **`md2nativedocx.opendocument`**, Pandoc 3.1.3's `opendocument` template plus one loop inside
|
|
60
|
+
`office:automatic-styles` for the diagram styles. BSD-3-Clause, see `../THIRD_PARTY_NOTICES.md`.
|
|
61
|
+
The build refuses any other Pandoc version, and `test/odt.test.mjs` checks the file still differs
|
|
62
|
+
from `pandoc -D opendocument` by that loop only.
|
|
63
|
+
|
|
64
|
+
Known Pandoc 3.1.3 limitation, not ours: a front-matter `abstract` is written as bare text outside
|
|
65
|
+
any paragraph, which the ODF validator rejects (same with Pandoc's own reference document).
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
$-- md2nativedocx: Pandoc's opendocument template (Pandoc 3.1.3), with one addition: the
|
|
2
|
+
$-- md2n-automatic-styles loop in office:automatic-styles, filled by the md2nativedocx Lua filter.
|
|
3
|
+
$-- Copyright (c) 2014-2023, John MacFarlane. BSD-3-Clause, see THIRD_PARTY_NOTICES.md.
|
|
4
|
+
<?xml version="1.0" encoding="utf-8" ?>
|
|
5
|
+
<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0" xmlns:number="urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:chart="urn:oasis:names:tc:opendocument:xmlns:chart:1.0" xmlns:dr3d="urn:oasis:names:tc:opendocument:xmlns:dr3d:1.0" xmlns:math="http://www.w3.org/1998/Math/MathML" xmlns:form="urn:oasis:names:tc:opendocument:xmlns:form:1.0" xmlns:script="urn:oasis:names:tc:opendocument:xmlns:script:1.0" xmlns:ooo="http://openoffice.org/2004/office" xmlns:ooow="http://openoffice.org/2004/writer" xmlns:oooc="http://openoffice.org/2004/calc" xmlns:dom="http://www.w3.org/2001/xml-events" xmlns:xforms="http://www.w3.org/2002/xforms" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" office:version="1.3">
|
|
6
|
+
<office:font-face-decls>
|
|
7
|
+
<style:font-face style:name="Courier New" style:font-family-generic="modern" style:font-pitch="fixed" svg:font-family="'Courier New'" />
|
|
8
|
+
</office:font-face-decls>
|
|
9
|
+
<office:automatic-styles>
|
|
10
|
+
$automatic-styles$
|
|
11
|
+
$for(md2n-automatic-styles)$
|
|
12
|
+
$md2n-automatic-styles$
|
|
13
|
+
$endfor$
|
|
14
|
+
</office:automatic-styles>
|
|
15
|
+
$for(header-includes)$
|
|
16
|
+
$header-includes$
|
|
17
|
+
$endfor$
|
|
18
|
+
<office:body>
|
|
19
|
+
<office:text>
|
|
20
|
+
$if(title)$
|
|
21
|
+
<text:p text:style-name="Title">$title$</text:p>
|
|
22
|
+
$endif$
|
|
23
|
+
$if(subtitle)$
|
|
24
|
+
<text:p text:style-name="Subtitle">$subtitle$</text:p>
|
|
25
|
+
$endif$
|
|
26
|
+
$for(author)$
|
|
27
|
+
<text:p text:style-name="Author">$author$</text:p>
|
|
28
|
+
$endfor$
|
|
29
|
+
$if(date)$
|
|
30
|
+
<text:p text:style-name="Date">$date$</text:p>
|
|
31
|
+
$endif$
|
|
32
|
+
$if(abstract)$
|
|
33
|
+
$abstract$
|
|
34
|
+
$endif$
|
|
35
|
+
$for(include-before)$
|
|
36
|
+
$include-before$
|
|
37
|
+
$endfor$
|
|
38
|
+
$if(toc)$
|
|
39
|
+
<text:table-of-content>
|
|
40
|
+
<text:table-of-content-source text:outline-level="$toc-depth$">
|
|
41
|
+
<text:index-title-template text:style-name="Contents_20_Heading">$toc-title$</text:index-title-template>
|
|
42
|
+
<text:table-of-content-entry-template text:outline-level="1"
|
|
43
|
+
text:style-name="Contents_20_1">
|
|
44
|
+
<text:index-entry-link-start text:style-name="Internet_20_link" />
|
|
45
|
+
<text:index-entry-chapter />
|
|
46
|
+
<text:index-entry-text />
|
|
47
|
+
<text:index-entry-link-end />
|
|
48
|
+
<text:index-entry-tab-stop style:type="right"
|
|
49
|
+
style:leader-char="." />
|
|
50
|
+
<text:index-entry-link-start text:style-name="Internet_20_link" />
|
|
51
|
+
<text:index-entry-page-number />
|
|
52
|
+
<text:index-entry-link-end />
|
|
53
|
+
</text:table-of-content-entry-template>
|
|
54
|
+
<text:table-of-content-entry-template text:outline-level="2"
|
|
55
|
+
text:style-name="Contents_20_2">
|
|
56
|
+
<text:index-entry-link-start text:style-name="Internet_20_link" />
|
|
57
|
+
<text:index-entry-chapter />
|
|
58
|
+
<text:index-entry-text />
|
|
59
|
+
<text:index-entry-link-end />
|
|
60
|
+
<text:index-entry-tab-stop style:type="right"
|
|
61
|
+
style:leader-char="." />
|
|
62
|
+
<text:index-entry-link-start text:style-name="Internet_20_link" />
|
|
63
|
+
<text:index-entry-page-number />
|
|
64
|
+
<text:index-entry-link-end />
|
|
65
|
+
</text:table-of-content-entry-template>
|
|
66
|
+
<text:table-of-content-entry-template text:outline-level="3"
|
|
67
|
+
text:style-name="Contents_20_3">
|
|
68
|
+
<text:index-entry-link-start text:style-name="Internet_20_link" />
|
|
69
|
+
<text:index-entry-chapter />
|
|
70
|
+
<text:index-entry-text />
|
|
71
|
+
<text:index-entry-link-end />
|
|
72
|
+
<text:index-entry-tab-stop style:type="right"
|
|
73
|
+
style:leader-char="." />
|
|
74
|
+
<text:index-entry-link-start text:style-name="Internet_20_link" />
|
|
75
|
+
<text:index-entry-page-number />
|
|
76
|
+
<text:index-entry-link-end />
|
|
77
|
+
</text:table-of-content-entry-template>
|
|
78
|
+
<text:table-of-content-entry-template text:outline-level="4"
|
|
79
|
+
text:style-name="Contents_20_4">
|
|
80
|
+
<text:index-entry-link-start text:style-name="Internet_20_link" />
|
|
81
|
+
<text:index-entry-chapter />
|
|
82
|
+
<text:index-entry-text />
|
|
83
|
+
<text:index-entry-link-end />
|
|
84
|
+
<text:index-entry-tab-stop style:type="right"
|
|
85
|
+
style:leader-char="." />
|
|
86
|
+
<text:index-entry-link-start text:style-name="Internet_20_link" />
|
|
87
|
+
<text:index-entry-page-number />
|
|
88
|
+
<text:index-entry-link-end />
|
|
89
|
+
</text:table-of-content-entry-template>
|
|
90
|
+
<text:table-of-content-entry-template text:outline-level="5"
|
|
91
|
+
text:style-name="Contents_20_5">
|
|
92
|
+
<text:index-entry-link-start text:style-name="Internet_20_link" />
|
|
93
|
+
<text:index-entry-chapter />
|
|
94
|
+
<text:index-entry-text />
|
|
95
|
+
<text:index-entry-link-end />
|
|
96
|
+
<text:index-entry-tab-stop style:type="right"
|
|
97
|
+
style:leader-char="." />
|
|
98
|
+
<text:index-entry-link-start text:style-name="Internet_20_link" />
|
|
99
|
+
<text:index-entry-page-number />
|
|
100
|
+
<text:index-entry-link-end />
|
|
101
|
+
</text:table-of-content-entry-template>
|
|
102
|
+
<text:table-of-content-entry-template text:outline-level="6"
|
|
103
|
+
text:style-name="Contents_20_6">
|
|
104
|
+
<text:index-entry-link-start text:style-name="Internet_20_link" />
|
|
105
|
+
<text:index-entry-chapter />
|
|
106
|
+
<text:index-entry-text />
|
|
107
|
+
<text:index-entry-link-end />
|
|
108
|
+
<text:index-entry-tab-stop style:type="right"
|
|
109
|
+
style:leader-char="." />
|
|
110
|
+
<text:index-entry-link-start text:style-name="Internet_20_link" />
|
|
111
|
+
<text:index-entry-page-number />
|
|
112
|
+
<text:index-entry-link-end />
|
|
113
|
+
</text:table-of-content-entry-template>
|
|
114
|
+
<text:table-of-content-entry-template text:outline-level="7"
|
|
115
|
+
text:style-name="Contents_20_7">
|
|
116
|
+
<text:index-entry-link-start text:style-name="Internet_20_link" />
|
|
117
|
+
<text:index-entry-chapter />
|
|
118
|
+
<text:index-entry-text />
|
|
119
|
+
<text:index-entry-link-end />
|
|
120
|
+
<text:index-entry-tab-stop style:type="right"
|
|
121
|
+
style:leader-char="." />
|
|
122
|
+
<text:index-entry-link-start text:style-name="Internet_20_link" />
|
|
123
|
+
<text:index-entry-page-number />
|
|
124
|
+
<text:index-entry-link-end />
|
|
125
|
+
</text:table-of-content-entry-template>
|
|
126
|
+
<text:table-of-content-entry-template text:outline-level="8"
|
|
127
|
+
text:style-name="Contents_20_8">
|
|
128
|
+
<text:index-entry-link-start text:style-name="Internet_20_link" />
|
|
129
|
+
<text:index-entry-chapter />
|
|
130
|
+
<text:index-entry-text />
|
|
131
|
+
<text:index-entry-link-end />
|
|
132
|
+
<text:index-entry-tab-stop style:type="right"
|
|
133
|
+
style:leader-char="." />
|
|
134
|
+
<text:index-entry-link-start text:style-name="Internet_20_link" />
|
|
135
|
+
<text:index-entry-page-number />
|
|
136
|
+
<text:index-entry-link-end />
|
|
137
|
+
</text:table-of-content-entry-template>
|
|
138
|
+
<text:table-of-content-entry-template text:outline-level="9"
|
|
139
|
+
text:style-name="Contents_20_9">
|
|
140
|
+
<text:index-entry-link-start text:style-name="Internet_20_link" />
|
|
141
|
+
<text:index-entry-chapter />
|
|
142
|
+
<text:index-entry-text />
|
|
143
|
+
<text:index-entry-link-end />
|
|
144
|
+
<text:index-entry-tab-stop style:type="right"
|
|
145
|
+
style:leader-char="." />
|
|
146
|
+
<text:index-entry-link-start text:style-name="Internet_20_link" />
|
|
147
|
+
<text:index-entry-page-number />
|
|
148
|
+
<text:index-entry-link-end />
|
|
149
|
+
</text:table-of-content-entry-template>
|
|
150
|
+
<text:table-of-content-entry-template text:outline-level="10"
|
|
151
|
+
text:style-name="Contents_20_10">
|
|
152
|
+
<text:index-entry-link-start text:style-name="Internet_20_link" />
|
|
153
|
+
<text:index-entry-chapter />
|
|
154
|
+
<text:index-entry-text />
|
|
155
|
+
<text:index-entry-link-end />
|
|
156
|
+
<text:index-entry-tab-stop style:type="right"
|
|
157
|
+
style:leader-char="." />
|
|
158
|
+
<text:index-entry-link-start text:style-name="Internet_20_link" />
|
|
159
|
+
<text:index-entry-page-number />
|
|
160
|
+
<text:index-entry-link-end />
|
|
161
|
+
</text:table-of-content-entry-template>
|
|
162
|
+
</text:table-of-content-source>
|
|
163
|
+
</text:table-of-content>
|
|
164
|
+
$endif$
|
|
165
|
+
$body$
|
|
166
|
+
$for(include-after)$
|
|
167
|
+
$include-after$
|
|
168
|
+
$endfor$
|
|
169
|
+
</office:text>
|
|
170
|
+
</office:body>
|
|
171
|
+
</office:document-content>
|
package/assets/reference.docx
CHANGED
|
Binary file
|
|
Binary file
|
package/bin/md2nativedocx.mjs
CHANGED
|
@@ -22,11 +22,12 @@
|
|
|
22
22
|
* * Errors are typed (ParseError/TranslationError) and mapped to exit codes.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
import { execFileSync } from 'node:child_process';
|
|
26
25
|
import { basename, extname } from 'node:path';
|
|
27
|
-
import { existsSync
|
|
26
|
+
import { existsSync } from 'node:fs';
|
|
28
27
|
import { CliError, resolveSafePath } from '../src/cliSupport.mjs';
|
|
29
28
|
import { ConversionError, convert } from '../src/convert.mjs';
|
|
29
|
+
import { writeOutputSafely } from '../src/writeOutputSafely.mjs';
|
|
30
|
+
import { formatWordCompatibility, runWordCompatibilityCheck } from '../src/wordCheck.mjs';
|
|
30
31
|
import { hasLayoutOptions, readConvertOptionsFromEnv } from '../src/envOptions.mjs';
|
|
31
32
|
|
|
32
33
|
// `-o something.pptx` takes the Pandoc-free deck path (packages/pptx), dispatched before anything a
|
|
@@ -40,13 +41,16 @@ import { hasLayoutOptions, readConvertOptionsFromEnv } from '../src/envOptions.m
|
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
const USAGE = `Usage: md2nativedocx <input.md> -o <output.docx|output.pptx> [options]
|
|
44
|
+
const USAGE = `Usage: md2nativedocx <input.md> -o <output.docx|output.odt|output.pptx> [options]
|
|
45
|
+
|
|
46
|
+
A .odt output is for LibreOffice: flowcharts become native, editable shapes and connectors;
|
|
47
|
+
other diagram types become a note for now.
|
|
44
48
|
|
|
45
49
|
A .pptx output is a slide deck: one slide per \`\`\`mermaid block, titled with the nearest
|
|
46
50
|
preceding heading (text outside diagrams is not exported).
|
|
47
51
|
|
|
48
52
|
Options:
|
|
49
|
-
-o, --output <file> Output .docx or .pptx path (required)
|
|
53
|
+
-o, --output <file> Output .docx, .odt or .pptx path (required)
|
|
50
54
|
--show-source .pptx only: show each diagram's Mermaid source beside it
|
|
51
55
|
-h, --help Show this help
|
|
52
56
|
`;
|
|
@@ -126,9 +130,18 @@ async function main() {
|
|
|
126
130
|
process.exit(1);
|
|
127
131
|
}
|
|
128
132
|
|
|
133
|
+
const format = extname(output).toLowerCase() === '.odt' ? 'odt' : 'docx';
|
|
134
|
+
if (format === 'odt' && hasLayoutOptions(options.layout)) {
|
|
135
|
+
process.stderr.write('md2nativedocx (info): page/typography options apply to .docx only; the .odt uses A4 with 2.54 cm margins.\n');
|
|
136
|
+
}
|
|
137
|
+
// MD2NATIVEDOCX_REFERENCE_DOC names a Word template; a .odt keeps the bundled reference.odt.
|
|
138
|
+
const formatOptions = format === 'odt' && options.referenceDoc && extname(options.referenceDoc).toLowerCase() !== '.odt'
|
|
139
|
+
? { ...options, referenceDoc: undefined }
|
|
140
|
+
: options;
|
|
141
|
+
|
|
129
142
|
let result;
|
|
130
143
|
try {
|
|
131
|
-
result = await convert({ path: input }, { ...
|
|
144
|
+
result = await convert({ path: input }, { ...formatOptions, cwd, format });
|
|
132
145
|
} catch (err) {
|
|
133
146
|
if (err instanceof ConversionError) {
|
|
134
147
|
process.stderr.write(`md2nativedocx: ${err.message}\n`);
|
|
@@ -138,12 +151,20 @@ async function main() {
|
|
|
138
151
|
}
|
|
139
152
|
throw err;
|
|
140
153
|
}
|
|
141
|
-
|
|
154
|
+
try {
|
|
155
|
+
writeOutputSafely(output, result.document);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
if (err instanceof ConversionError) {
|
|
158
|
+
process.stderr.write(`md2nativedocx: ${err.message}\n`);
|
|
159
|
+
process.exit(2);
|
|
160
|
+
}
|
|
161
|
+
throw err;
|
|
162
|
+
}
|
|
142
163
|
|
|
143
164
|
// Surface the diagrams' non-fatal notices: never let a successful export silently hide something the
|
|
144
165
|
// author should know about.
|
|
145
166
|
const warnings = result.warnings.map((w) => `md2nativedocx: ${w}`);
|
|
146
|
-
const wordCompatibility = runWordCompatibilityCheck(output);
|
|
167
|
+
const wordCompatibility = format === 'docx' ? runWordCompatibilityCheck(output) : null;
|
|
147
168
|
const logPath = writeExportLog({ input, output, warnings, rawStderr: result.pandocStderr, wordCompatibility });
|
|
148
169
|
if (warnings.length > 0) {
|
|
149
170
|
process.stdout.write(`Warnings: ${warnings.length} (see ${basename(logPath)})\n`);
|
|
@@ -152,74 +173,13 @@ async function main() {
|
|
|
152
173
|
process.stdout.write(`Wrote ${basename(output)}\n`);
|
|
153
174
|
}
|
|
154
175
|
|
|
155
|
-
/**
|
|
156
|
-
* Word compatibility check (ADR 0007 part D) — validates the generated
|
|
157
|
-
* `.docx` against the exact schema real Word enforces strictly, via
|
|
158
|
-
* `scripts/oxml-validator/`'s DLL (Microsoft's own Open XML SDK). See
|
|
159
|
-
* `AGENTS.md` → "Diagnosing 'Word won't open the file'" and ADR 0006 for
|
|
160
|
-
* why this exists: neither well-formed-XML checks nor LibreOffice (which
|
|
161
|
-
* this project already uses for `test:visual`) can catch a schema
|
|
162
|
-
* violation Word rejects outright — that gap cost 7 rounds of
|
|
163
|
-
* manually-compared, individually-disproven hypotheses in the SmartArt
|
|
164
|
-
* "cycle" corruption incident before this validator found the real cause
|
|
165
|
-
* in a single pass.
|
|
166
|
-
*
|
|
167
|
-
* Opt-in via `MD2NATIVEDOCX_OXML_VALIDATOR_DLL` (the vendored DLL path,
|
|
168
|
-
* set by the VS Code extension's `dotnetProvisioner.ts` when
|
|
169
|
-
* `md2nativedocx.wordCompatibilityCheck.enabled` is on) — unset for a
|
|
170
|
-
* standalone `npx md2nativedocx` install, which doesn't ship the DLL, so
|
|
171
|
-
* this is a no-op there unless a user sets both env vars by hand.
|
|
172
|
-
* `MD2NATIVEDOCX_DOTNET_BIN` mirrors `MD2NATIVEDOCX_PANDOC_BIN`'s own
|
|
173
|
-
* "defaults to the bare command name, letting a provisioner override it"
|
|
174
|
-
* convention.
|
|
175
|
-
*
|
|
176
|
-
* Never throws: an export that already succeeded must never be reported as
|
|
177
|
-
* failed because this optional, best-effort check couldn't run (`dotnet`
|
|
178
|
-
* missing, DLL missing, unexpected crash) — same rule already applied to
|
|
179
|
-
* `extractWarnings`/`readWarningCount` elsewhere in this pipeline. Returns
|
|
180
|
-
* `null` when not run at all (env var unset), or
|
|
181
|
-
* `{ ran: true, diagramErrors, otherErrors }` /
|
|
182
|
-
* `{ ran: false, reason }` otherwise.
|
|
183
|
-
*/
|
|
184
|
-
function runWordCompatibilityCheck(docxPath) {
|
|
185
|
-
const dllPath = process.env.MD2NATIVEDOCX_OXML_VALIDATOR_DLL;
|
|
186
|
-
if (!dllPath) return null;
|
|
187
|
-
|
|
188
|
-
const dotnetBin = process.env.MD2NATIVEDOCX_DOTNET_BIN || 'dotnet';
|
|
189
|
-
try {
|
|
190
|
-
const stdout = execFileSync(dotnetBin, [dllPath, docxPath, '--json'], { encoding: 'utf8' });
|
|
191
|
-
const report = JSON.parse(stdout.trim().split('\n').pop());
|
|
192
|
-
const diagramErrors = report.errors.filter((e) => e.Part?.startsWith('/word/diagrams/'));
|
|
193
|
-
const otherErrors = report.errors.filter((e) => !e.Part?.startsWith('/word/diagrams/'));
|
|
194
|
-
return { ran: true, diagramErrors, otherErrors };
|
|
195
|
-
} catch (err) {
|
|
196
|
-
// execFileSync throws on a non-zero exit (errorCount > 0 also exits 1,
|
|
197
|
-
// per scripts/oxml-validator/OpenXmlValidator.cs) -- but it still wrote
|
|
198
|
-
// the JSON report to stdout first, captured on the error object.
|
|
199
|
-
const stdout = err.stdout?.toString();
|
|
200
|
-
if (stdout) {
|
|
201
|
-
try {
|
|
202
|
-
const report = JSON.parse(stdout.trim().split('\n').pop());
|
|
203
|
-
if (Array.isArray(report.errors)) {
|
|
204
|
-
const diagramErrors = report.errors.filter((e) => e.Part?.startsWith('/word/diagrams/'));
|
|
205
|
-
const otherErrors = report.errors.filter((e) => !e.Part?.startsWith('/word/diagrams/'));
|
|
206
|
-
return { ran: true, diagramErrors, otherErrors };
|
|
207
|
-
}
|
|
208
|
-
} catch {
|
|
209
|
-
// Fall through to the generic "could not run" case below.
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
return { ran: false, reason: err instanceof Error ? err.message : String(err) };
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
176
|
/** Write a plain-text log next to the output .docx (same basename, `.log`
|
|
217
177
|
* extension) so a click-right export (no VS Code Problems panel in view) has
|
|
218
178
|
* somewhere durable to point to — spec §10, "surface warnings". Written on
|
|
219
179
|
* every successful export, not just when there are warnings, for a
|
|
220
180
|
* consistent, discoverable location. */
|
|
221
181
|
function writeExportLog({ input, output, warnings, rawStderr, wordCompatibility }) {
|
|
222
|
-
const logPath = extname(output).toLowerCase()
|
|
182
|
+
const logPath = ['.docx', '.odt'].includes(extname(output).toLowerCase())
|
|
223
183
|
? output.slice(0, -extname(output).length) + '.log'
|
|
224
184
|
: `${output}.log`;
|
|
225
185
|
const lines = [
|
|
@@ -239,35 +199,10 @@ function writeExportLog({ input, output, warnings, rawStderr, wordCompatibility
|
|
|
239
199
|
if (rawStderr && rawStderr.trim().length > 0) {
|
|
240
200
|
lines.push('--- Raw Pandoc/tool output ---', rawStderr.trimEnd(), '');
|
|
241
201
|
}
|
|
242
|
-
|
|
202
|
+
writeOutputSafely(logPath, lines.join('\n'));
|
|
243
203
|
return logPath;
|
|
244
204
|
}
|
|
245
205
|
|
|
246
|
-
/** Renders {@link runWordCompatibilityCheck}'s result as `.log` lines. Kept
|
|
247
|
-
* separate from that function so the "not run at all" (feature off) case
|
|
248
|
-
* reads as a plain, unalarming note rather than something that looks like a
|
|
249
|
-
* missing/failed check — most exports won't have this env var set at all
|
|
250
|
-
* (standalone CLI usage, or the VS Code setting turned off). */
|
|
251
|
-
function formatWordCompatibility(result) {
|
|
252
|
-
if (result === null) {
|
|
253
|
-
return ['Not checked (md2nativedocx.wordCompatibilityCheck is off, or this is a standalone CLI export).'];
|
|
254
|
-
}
|
|
255
|
-
if (!result.ran) {
|
|
256
|
-
return [`Not checked (could not run: ${result.reason}).`];
|
|
257
|
-
}
|
|
258
|
-
if (result.diagramErrors.length === 0) {
|
|
259
|
-
const note = result.otherErrors.length > 0
|
|
260
|
-
? ` (${result.otherErrors.length} pre-existing schema note(s) elsewhere, unrelated to this export's diagram content — see TODO.md)`
|
|
261
|
-
: '';
|
|
262
|
-
return [`0 error(s) — validated against the same schema Word itself enforces.${note}`];
|
|
263
|
-
}
|
|
264
|
-
const lines = [`${result.diagramErrors.length} error(s) found in the generated diagram content:`];
|
|
265
|
-
for (const e of result.diagramErrors) {
|
|
266
|
-
lines.push(` - ${e.Path}: ${e.Description}`);
|
|
267
|
-
}
|
|
268
|
-
return lines;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
206
|
main().catch((err) => {
|
|
272
207
|
process.stderr.write(`md2nativedocx: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
273
208
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@md2nativedocx/cli",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
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
5
|
"license": "CC0-1.0",
|
|
6
6
|
"repository": {
|
|
@@ -46,7 +46,8 @@
|
|
|
46
46
|
"files": [
|
|
47
47
|
"bin",
|
|
48
48
|
"src",
|
|
49
|
-
"assets"
|
|
49
|
+
"assets",
|
|
50
|
+
"THIRD_PARTY_NOTICES.md"
|
|
50
51
|
],
|
|
51
52
|
"scripts": {
|
|
52
53
|
"build": "echo \"cli: no build step (plain JS)\"",
|
|
@@ -56,8 +57,8 @@
|
|
|
56
57
|
"test:ci": "node --test --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=reports/junit.xml test/*.test.mjs"
|
|
57
58
|
},
|
|
58
59
|
"dependencies": {
|
|
59
|
-
"@md2nativedocx/pandoc-filter": "0.
|
|
60
|
-
"@md2nativedocx/pptx": "0.1.
|
|
60
|
+
"@md2nativedocx/pandoc-filter": "0.2.0",
|
|
61
|
+
"@md2nativedocx/pptx": "0.1.1",
|
|
61
62
|
"adm-zip": "^0.6.1"
|
|
62
63
|
}
|
|
63
64
|
}
|
package/src/cliSupport.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* the .pptx path in `pptxExport.mjs`).
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { resolve, isAbsolute } from 'node:path';
|
|
6
|
+
import { resolve, isAbsolute, relative, sep } from 'node:path';
|
|
7
7
|
|
|
8
8
|
/** Typed error for CLI-level failures. */
|
|
9
9
|
export class CliError extends Error {
|
|
@@ -17,10 +17,12 @@ export class CliError extends Error {
|
|
|
17
17
|
/** Resolve and validate a path (anti path traversal via `..`). */
|
|
18
18
|
export function resolveSafePath(rawPath, cwd) {
|
|
19
19
|
const abs = isAbsolute(rawPath) ? rawPath : resolve(cwd, rawPath);
|
|
20
|
-
// Reject relative paths that escape the working directory via `..`.
|
|
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).
|
|
21
23
|
if (!isAbsolute(rawPath)) {
|
|
22
|
-
const
|
|
23
|
-
if (
|
|
24
|
+
const rel = relative(resolve(cwd), abs);
|
|
25
|
+
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
24
26
|
throw new CliError(`Path escapes the working directory: ${rawPath}`, 2);
|
|
25
27
|
}
|
|
26
28
|
}
|
package/src/convert.d.mts
CHANGED
|
@@ -33,6 +33,12 @@ export interface LayoutOptions {
|
|
|
33
33
|
|
|
34
34
|
/** How {@link convert} builds the document. Every field is optional; the defaults match the CLI's. */
|
|
35
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';
|
|
36
42
|
/** Pandoc's working directory, against which relative image paths resolve. Default: `process.cwd()`. */
|
|
37
43
|
cwd?: string;
|
|
38
44
|
/** A `.docx` template to use instead of the bundled one (Pandoc's `--reference-doc`). Must exist. */
|
|
@@ -64,7 +70,7 @@ export interface ConvertOptions {
|
|
|
64
70
|
|
|
65
71
|
/** A finished document. */
|
|
66
72
|
export interface ConvertResult {
|
|
67
|
-
/** The `.docx` file's bytes. */
|
|
73
|
+
/** The `.docx` (or `.odt`) file's bytes. */
|
|
68
74
|
document: Buffer;
|
|
69
75
|
/** Non-fatal problems in the diagrams (syntax ignored, a SmartArt or chart drawn as shapes instead). */
|
|
70
76
|
warnings: string[];
|
|
@@ -74,8 +80,8 @@ export interface ConvertResult {
|
|
|
74
80
|
|
|
75
81
|
/** A conversion that produced no document. */
|
|
76
82
|
export declare class ConversionError extends Error {
|
|
77
|
-
/** `setup`: filter or reference document · `pandoc`: Pandoc failed or is missing · `postprocess
|
|
78
|
-
readonly stage: 'setup' | 'pandoc' | 'postprocess';
|
|
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';
|
|
79
85
|
/** Pandoc's stderr, when it ran. */
|
|
80
86
|
readonly pandocStderr: string;
|
|
81
87
|
}
|
package/src/convert.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `convert()`: Markdown in, finished `.docx` bytes out (docs/specs/01-v2-engine-spec.md §6,
|
|
2
|
+
* `convert()`: Markdown in, finished `.docx` (or `.odt`) bytes out (docs/specs/01-v2-engine-spec.md §6,
|
|
3
|
+
* ADR 0012; `.odt`: ADR 0013).
|
|
3
4
|
*
|
|
4
5
|
* The library entry point of `@md2nativedocx/cli`, and what `bin/md2nativedocx.mjs` itself calls. It owns
|
|
5
6
|
* the whole document path: reference document build, Pandoc with the Lua filter (which renders each
|
|
@@ -11,6 +12,11 @@
|
|
|
11
12
|
* the options, so a host process's environment cannot change a conversion behind its caller's back. The
|
|
12
13
|
* variables remain the CLI's interface (`envOptions.mjs` reads them).
|
|
13
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
|
+
*
|
|
14
20
|
* Security (AGENTS.md): Pandoc runs through `execFile` with an argument array (rule 4); every file is written
|
|
15
21
|
* in a fresh temporary directory removed afterwards; the post-processing is the rule 7 allowlist.
|
|
16
22
|
*/
|
|
@@ -21,6 +27,7 @@ import { tmpdir } from 'node:os';
|
|
|
21
27
|
import { extname, join } from 'node:path';
|
|
22
28
|
import { fileURLToPath } from 'node:url';
|
|
23
29
|
import { postProcessDocx, injectSmartArtParts } from './postprocess.mjs';
|
|
30
|
+
import { unclosedFenceWarnings } from './unclosedFences.mjs';
|
|
24
31
|
import { injectChartParts } from './chartParts.mjs';
|
|
25
32
|
import { buildReferenceDoc, resolveMaxDrawingExtentEmu, resolvePageSize, resolveMargins } from './referenceDocBuilder.mjs';
|
|
26
33
|
|
|
@@ -28,7 +35,7 @@ import { buildReferenceDoc, resolveMaxDrawingExtentEmu, resolvePageSize, resolve
|
|
|
28
35
|
export class ConversionError extends Error {
|
|
29
36
|
/**
|
|
30
37
|
* @param {string} message
|
|
31
|
-
* @param {{ stage: 'setup' | 'pandoc' | 'postprocess', pandocStderr?: string, cause?: unknown }} details
|
|
38
|
+
* @param {{ stage: 'setup' | 'pandoc' | 'postprocess' | 'write', pandocStderr?: string, cause?: unknown }} details
|
|
32
39
|
*/
|
|
33
40
|
constructor(message, { stage, pandocStderr = '', cause } = {}) {
|
|
34
41
|
super(message, cause === undefined ? undefined : { cause });
|
|
@@ -43,6 +50,12 @@ export class ConversionError extends Error {
|
|
|
43
50
|
// 2007-2010 Office theme. See packages/cli/assets/README.md for how it was built and what it changes.
|
|
44
51
|
// `src/` and `bin/` are at the same depth, so this also holds in the VS Code extension's vendored bundle.
|
|
45
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;
|
|
46
59
|
|
|
47
60
|
/**
|
|
48
61
|
* Where @md2nativedocx/pandoc-filter lands varies with how the CLI was deployed (npm workspace, nested or
|
|
@@ -122,8 +135,72 @@ function extractWarnings(stderrText) {
|
|
|
122
135
|
.map((line) => line.slice('md2nativedocx: '.length));
|
|
123
136
|
}
|
|
124
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
|
+
|
|
125
201
|
/**
|
|
126
|
-
* Convert a Markdown document to `.docx`. See `convert.d.mts` for the
|
|
202
|
+
* Convert a Markdown document to `.docx`, or `.odt` with `format: 'odt'`. See `convert.d.mts` for the
|
|
203
|
+
* option and result types.
|
|
127
204
|
*
|
|
128
205
|
* @param {string | { path: string }} source Markdown text, or a file for Pandoc to read (its extension picks
|
|
129
206
|
* the reader; `.qmd` is read as Markdown).
|
|
@@ -131,6 +208,11 @@ function extractWarnings(stderrText) {
|
|
|
131
208
|
* @returns {Promise<import('./convert.d.mts').ConvertResult>}
|
|
132
209
|
*/
|
|
133
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);
|
|
134
216
|
const filterPath = resolveFilterPath();
|
|
135
217
|
const cwd = options.cwd ?? process.cwd();
|
|
136
218
|
const customReferenceDoc = options.referenceDoc;
|
|
@@ -151,22 +233,12 @@ export async function convert(source, options = {}) {
|
|
|
151
233
|
}
|
|
152
234
|
const referenceDoc = customReferenceDoc ?? generatedReferenceDoc?.path ?? DEFAULT_REFERENCE_DOC;
|
|
153
235
|
|
|
154
|
-
|
|
155
|
-
if (typeof source === 'string') {
|
|
156
|
-
inputPath = join(work, 'input.md');
|
|
157
|
-
writeFileSync(inputPath, source, 'utf8');
|
|
158
|
-
} else {
|
|
159
|
-
inputPath = source.path;
|
|
160
|
-
}
|
|
236
|
+
const inputPath = inputFile(source, work);
|
|
161
237
|
const outputPath = join(work, 'output.docx');
|
|
162
238
|
|
|
163
239
|
const pandocArgs = [inputPath, '-o', outputPath, '--lua-filter', filterPath];
|
|
164
|
-
// Pandoc picks its reader from the extension and does not know `.qmd`; Quarto Markdown is Pandoc
|
|
165
|
-
// Markdown plus front matter and code chunks, so naming the reader is correct and drops its warning.
|
|
166
|
-
if (extname(inputPath).toLowerCase() === '.qmd') pandocArgs.push('--from', 'markdown');
|
|
167
240
|
if (existsSync(referenceDoc)) pandocArgs.push('--reference-doc', referenceDoc);
|
|
168
|
-
|
|
169
|
-
if (options.toc) pandocArgs.push('--toc', `--toc-depth=${tocDepth}`);
|
|
241
|
+
pandocArgs.push(...commonPandocArgs(inputPath, options));
|
|
170
242
|
|
|
171
243
|
// The bridge (one run per ```mermaid block, spawned by the Lua filter) hands SmartArt and chart parts
|
|
172
244
|
// back through these directories: a Lua filter cannot add package parts itself.
|
|
@@ -194,7 +266,7 @@ export async function convert(source, options = {}) {
|
|
|
194
266
|
throw new ConversionError(`post-processing failed: ${errorMessage(err)}`, { stage: 'postprocess', pandocStderr, cause: err });
|
|
195
267
|
}
|
|
196
268
|
|
|
197
|
-
return { document: readFileSync(outputPath), warnings: extractWarnings(pandocStderr), pandocStderr };
|
|
269
|
+
return { document: readFileSync(outputPath), warnings: [...sourceWarnings(source), ...extractWarnings(pandocStderr)], pandocStderr };
|
|
198
270
|
} finally {
|
|
199
271
|
rmSync(work, { recursive: true, force: true });
|
|
200
272
|
if (generatedReferenceDoc) rmSync(generatedReferenceDoc.dir, { recursive: true, force: true });
|
package/src/pptxExport.mjs
CHANGED
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
* (see docs/adr/0010-pptx-production-translator-plan.md). Non-diagram Markdown is ignored.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { existsSync, readFileSync
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
9
9
|
import { basename, extname } from 'node:path';
|
|
10
10
|
import { CliError, resolveSafePath } from './cliSupport.mjs';
|
|
11
|
+
import { ConversionError } from './convert.mjs';
|
|
12
|
+
import { writeOutputSafely } from './writeOutputSafely.mjs';
|
|
11
13
|
|
|
12
14
|
const USAGE = `Usage: md2nativedocx <input.md> -o <output.pptx> [--show-source]
|
|
13
15
|
|
|
@@ -57,7 +59,12 @@ export async function runPptxCli(argv, cwd) {
|
|
|
57
59
|
if (err instanceof PptxConversionError) throw new CliError(`md2nativedocx: ${err.message}`, 1);
|
|
58
60
|
throw err;
|
|
59
61
|
}
|
|
60
|
-
|
|
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
|
+
}
|
|
61
68
|
writePptxLog(input, output, result.warnings, result.slideCount);
|
|
62
69
|
if (result.warnings.length > 0) {
|
|
63
70
|
process.stdout.write(`Warnings: ${result.warnings.length}\n`);
|
|
@@ -91,5 +98,5 @@ function writePptxLog(input, output, warnings, slideCount) {
|
|
|
91
98
|
`Slides: ${slideCount}`,
|
|
92
99
|
'',
|
|
93
100
|
];
|
|
94
|
-
|
|
101
|
+
writeOutputSafely(logPath, lines.join('\n'));
|
|
95
102
|
}
|
|
@@ -282,7 +282,11 @@ function patchTableHeaderColor(stylesXml, tableHeaderColor) {
|
|
|
282
282
|
/(<w:style[^>]*\bw:styleId="Table"[^>]*>[\s\S]*?<w:tblStylePr w:type="firstRow">[\s\S]*?<w:tcPr>)([\s\S]*?)(<\/w:tcPr>)/,
|
|
283
283
|
(whole, open, inner, close) => {
|
|
284
284
|
const shd = `<w:shd w:val="clear" w:color="auto" w:fill="${safe}"/>`;
|
|
285
|
-
|
|
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}`;
|
|
286
290
|
return `${open}${nextInner}${close}`;
|
|
287
291
|
},
|
|
288
292
|
);
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|