tide-commander 1.199.6 → 1.201.0
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/CHANGELOG.md +20 -0
- package/dist/assets/{AudioPlayer-DbawH_kK.js → AudioPlayer-jjttJ6SM.js} +1 -1
- package/dist/assets/{BossLogsModal-DGR32RLh.js → BossLogsModal-DF0ar9g6.js} +1 -1
- package/dist/assets/{BossSpawnModal-1OOnwHa_.js → BossSpawnModal-rJfm0Ijw.js} +1 -1
- package/dist/assets/{ControlsModal-DFHBGDDH.js → ControlsModal-D4JLnO36.js} +1 -1
- package/dist/assets/{DockerLogsModal-CvQM5TOx.js → DockerLogsModal-ChSWCsD6.js} +1 -1
- package/dist/assets/{EmbeddedEditor-DGdihbaE.js → EmbeddedEditor-ChdfmuIg.js} +1 -1
- package/dist/assets/{FcStdViewer-BgT5FFuV.js → FcStdViewer-CdhcfLu8.js} +1 -1
- package/dist/assets/{GcodeViewer-CLAc1os2.js → GcodeViewer-CG5xftum.js} +1 -1
- package/dist/assets/{GlbViewer-DW3-EKE8.js → GlbViewer-DvmBSNPm.js} +1 -1
- package/dist/assets/{GmailOAuthSetup-ChWPdUvL.js → GmailOAuthSetup-yhSITtw7.js} +1 -1
- package/dist/assets/{GoogleOAuthSetup-QrN5TW8r.js → GoogleOAuthSetup-ugg8hhwa.js} +1 -1
- package/dist/assets/{IframeModal-DFnjMx4z.js → IframeModal-Bjo_-SSc.js} +1 -1
- package/dist/assets/{IntegrationsPanel-CxC744zM.js → IntegrationsPanel-JbfEFuTE.js} +2 -2
- package/dist/assets/{LogViewerModal-DAMjRGWX.js → LogViewerModal-CGGtQwwn.js} +1 -1
- package/dist/assets/{MonitoringModal-DjD33Q4Q.js → MonitoringModal-D7VxK2C8.js} +1 -1
- package/dist/assets/{PM2LogsModal-CpdUfSIi.js → PM2LogsModal-C-Ec4p65.js} +1 -1
- package/dist/assets/{RestoreArchivedAreaModal-Bjo5oNBo.js → RestoreArchivedAreaModal-BBZpQw8I.js} +1 -1
- package/dist/assets/{Scene2DCanvas-SVUlGMo1.js → Scene2DCanvas-Cpzgqdq1.js} +1 -1
- package/dist/assets/{SceneManager-BMNKqJTO.js → SceneManager-B9bEzlSi.js} +1 -1
- package/dist/assets/{SkillsPanel-C8onjf1H.js → SkillsPanel-crpQci9P.js} +1 -1
- package/dist/assets/{SlackMultiInstanceSetup-CgV-oGON.js → SlackMultiInstanceSetup-C1qkAM8r.js} +1 -1
- package/dist/assets/{SpawnModal-LXqc4WCm.js → SpawnModal-CIuJWY6N.js} +1 -1
- package/dist/assets/{StatisticsModal-ESAvPsy0.js → StatisticsModal-B14YGD66.js} +1 -1
- package/dist/assets/{StlViewer-NVExCnF3.js → StlViewer-D5EtQxO4.js} +1 -1
- package/dist/assets/{SubordinateAssignmentModal-DpdSOnqQ.js → SubordinateAssignmentModal-_8NWXgEl.js} +1 -1
- package/dist/assets/{ThreeAreaSelector-DAHbFYpb.js → ThreeAreaSelector-Bnb65bJt.js} +1 -1
- package/dist/assets/{ThreeViewShortcuts-BoxZeMmM.js → ThreeViewShortcuts-AI4Mj9zr.js} +1 -1
- package/dist/assets/{TriggerManagerPanel-CyaZWM4g.js → TriggerManagerPanel-aFrbWG0D.js} +1 -1
- package/dist/assets/{VideoPlayer-JEXTLcck.js → VideoPlayer-BSVjpcQa.js} +1 -1
- package/dist/assets/{WorkflowEditorPanel-DgqZRVud.js → WorkflowEditorPanel-CgFXTubk.js} +1 -1
- package/dist/assets/{abnfDiagram-VRR7QNED-Ntlrp2B1.js → abnfDiagram-VRR7QNED-LbVlypD7.js} +1 -1
- package/dist/assets/{arc-D3D4dl3j.js → arc-DDqZ78fN.js} +1 -1
- package/dist/assets/{architectureDiagram-ZJ3FMSHR-CbVTw6bL.js → architectureDiagram-ZJ3FMSHR-BLfa7hLc.js} +1 -1
- package/dist/assets/{area-logos-CYXdsKnv.js → area-logos-ml30JJUi.js} +1 -1
- package/dist/assets/{blockDiagram-677ZJIJ3-BI-pDXhB.js → blockDiagram-677ZJIJ3-CHWx5n03.js} +1 -1
- package/dist/assets/{c4Diagram-LMCZKHZV-74-UMGJT.js → c4Diagram-LMCZKHZV-DvUxK9_Q.js} +1 -1
- package/dist/assets/channel-CxofHie1.js +1 -0
- package/dist/assets/{chunk-2Q5K7J3B-D3vAVBE2.js → chunk-2Q5K7J3B-BFtzwIJU.js} +1 -1
- package/dist/assets/{chunk-32BRIVSS-BxxreJ5z.js → chunk-32BRIVSS-CPbVoclL.js} +1 -1
- package/dist/assets/{chunk-5VM5RSS4-D_bLkz8y.js → chunk-5VM5RSS4-dTYGN5E5.js} +1 -1
- package/dist/assets/{chunk-EX3LRPZG-CgG_gI5j.js → chunk-EX3LRPZG-CtqfsWRW.js} +1 -1
- package/dist/assets/{chunk-JWPE2WC7-CCD624Q4.js → chunk-JWPE2WC7-Bk6bqjv9.js} +1 -1
- package/dist/assets/{chunk-MOJQB5TN-BXc7SqUw.js → chunk-MOJQB5TN-UYfo6GD_.js} +1 -1
- package/dist/assets/{chunk-RYQCIY6F-DdE-eq8Q.js → chunk-RYQCIY6F-C8_6Iuf_.js} +1 -1
- package/dist/assets/{chunk-V7JOEXUC-Co4r2qOX.js → chunk-V7JOEXUC-CGdZK5My.js} +1 -1
- package/dist/assets/{chunk-VR4S4FIN-D823l1GL.js → chunk-VR4S4FIN-C-HH5QTv.js} +1 -1
- package/dist/assets/{chunk-XXDRQBXY-DNhI5Onf.js → chunk-XXDRQBXY-B2n8y5qo.js} +1 -1
- package/dist/assets/classDiagram-OUVF2IWQ-15eANucY.js +1 -0
- package/dist/assets/classDiagram-v2-EOCWNBFH-15eANucY.js +1 -0
- package/dist/assets/{cose-bilkent-JH36ORCC-DwwU-Vme.js → cose-bilkent-JH36ORCC-AB1HxIBy.js} +1 -1
- package/dist/assets/{cynefin-VYW2F7L2-FPusX1Pr.js → cynefin-VYW2F7L2-DTS_6pNv.js} +1 -1
- package/dist/assets/{cynefinDiagram-TSTJHNR4-D1ZQ8xBH.js → cynefinDiagram-TSTJHNR4-0oJZvAZy.js} +1 -1
- package/dist/assets/{dagre-VKFMJZFB-B6vs-yvj.js → dagre-VKFMJZFB-mOn_d4U5.js} +1 -1
- package/dist/assets/{diagram-FQU43EPY-EIEpqhAS.js → diagram-FQU43EPY-DqHifujZ.js} +1 -1
- package/dist/assets/{diagram-G47NLZAW-BgK2guk8.js → diagram-G47NLZAW-BZ7eUs_p.js} +1 -1
- package/dist/assets/{diagram-NH7WQ7WH-CpdwzkT0.js → diagram-NH7WQ7WH-C3r43VXW.js} +1 -1
- package/dist/assets/{diagram-OA4YK3LP-B1AdLJYG.js → diagram-OA4YK3LP-D2RSBPAX.js} +1 -1
- package/dist/assets/{diagram-WEI45ONY-BTLwXODW.js → diagram-WEI45ONY-B9Nkjrlq.js} +1 -1
- package/dist/assets/{ebnfDiagram-CCIWWBDH-BaFZwINa.js → ebnfDiagram-CCIWWBDH-B1RDvi6g.js} +1 -1
- package/dist/assets/{erDiagram-Q63AITRT-BHctMV_t.js → erDiagram-Q63AITRT-B38eKAG5.js} +1 -1
- package/dist/assets/{flowDiagram-23GEKE2U-FdEXIJDE.js → flowDiagram-23GEKE2U-C9txCjHm.js} +1 -1
- package/dist/assets/{ganttDiagram-NO4QXBWP-BzSKiNew.js → ganttDiagram-NO4QXBWP-BGNoppCl.js} +1 -1
- package/dist/assets/{gitGraphDiagram-IHSO6WYX-CCt1embf.js → gitGraphDiagram-IHSO6WYX-qrryYqaT.js} +1 -1
- package/dist/assets/{index-ChWT0VGM.js → index-B4sdd8Y2.js} +3 -3
- package/dist/assets/{index-YtoNwX7s.js → index-BXNPTpxx.js} +3 -3
- package/dist/assets/{index-YaKQQk9_.js → index-BdQoxlnS.js} +2 -2
- package/dist/assets/index-BoiyspB0.js +2 -0
- package/dist/assets/{index-UVAMV_H7.js → index-CKVTIr6G.js} +1 -1
- package/dist/assets/{index-DiILxoMf.js → index-CYxytlNP.js} +1 -1
- package/dist/assets/{index-CbRNk_Zg.js → index-ClvYehMs.js} +1 -1
- package/dist/assets/{index-vPlxnz-B.js → index-Drtp5UPa.js} +1 -1
- package/dist/assets/{index-rrOuVd7S.js → index-tE736Avl.js} +1 -1
- package/dist/assets/{infoDiagram-FWYZ7A6U-10zwrYDm.js → infoDiagram-FWYZ7A6U-DilAR8go.js} +1 -1
- package/dist/assets/{ishikawaDiagram-FXEZZL3T-ByRrZfDV.js → ishikawaDiagram-FXEZZL3T-B0cTGbt1.js} +1 -1
- package/dist/assets/{journeyDiagram-5HDEW3XC-CmYM751e.js → journeyDiagram-5HDEW3XC-eHbpGFin.js} +1 -1
- package/dist/assets/{kanban-definition-HUTT4EX6-Dh0ggxp3.js → kanban-definition-HUTT4EX6-BaSQRYl7.js} +1 -1
- package/dist/assets/{linear-CLAQtQPs.js → linear-BoUClmXF.js} +1 -1
- package/dist/assets/main-BdVckSM5.css +1 -0
- package/dist/assets/main-DuaHyH5t.js +341 -0
- package/dist/assets/{mermaid.core-CH7DoQlW.js → mermaid.core-CGrC54u1.js} +4 -4
- package/dist/assets/{mindmap-definition-LN4V7U3C-Bfw5UqxH.js → mindmap-definition-LN4V7U3C-BQWnb1yP.js} +1 -1
- package/dist/assets/{pegDiagram-2B236MQR-LbY5CO4b.js → pegDiagram-2B236MQR-Bs_4Go2e.js} +1 -1
- package/dist/assets/{pieDiagram-ENE6RG2P-D14z8IIZ.js → pieDiagram-ENE6RG2P-LCcCZeX5.js} +1 -1
- package/dist/assets/{quadrantDiagram-ABIIQ3AL-CdRVHJGf.js → quadrantDiagram-ABIIQ3AL-CdlG1WsA.js} +1 -1
- package/dist/assets/{railroadDiagram-RFXS5EU6-Csv_sb84.js → railroadDiagram-RFXS5EU6-CAcrzOwC.js} +1 -1
- package/dist/assets/{requirementDiagram-TGXJPOKE-CZFOgd5I.js → requirementDiagram-TGXJPOKE-zgaCBhns.js} +1 -1
- package/dist/assets/{sankeyDiagram-HTMAVEWB-BvBQTJvz.js → sankeyDiagram-HTMAVEWB-Cvn05yAi.js} +1 -1
- package/dist/assets/{sequenceDiagram-DBY2YBRQ-BVegUvqs.js → sequenceDiagram-DBY2YBRQ-C7oW7B56.js} +1 -1
- package/dist/assets/{sizeCapture-X5ZJPWSS-FtRVl68V.js → sizeCapture-X5ZJPWSS-Z61MsHMO.js} +1 -1
- package/dist/assets/{stateDiagram-2N3HPSRC-DjZEX_y5.js → stateDiagram-2N3HPSRC-CHjZ2LvZ.js} +1 -1
- package/dist/assets/stateDiagram-v2-6OUMAXLB-ePlfPVZw.js +1 -0
- package/dist/assets/{swimlanes-5IMT3BWC-D7iHNp7G.js → swimlanes-5IMT3BWC-BOxFXKKq.js} +2 -2
- package/dist/assets/swimlanesDiagram-G3AALYLV-ClgJ2Bw0.js +8 -0
- package/dist/assets/{timeline-definition-FHXFAJF6-DlAm5TB7.js → timeline-definition-FHXFAJF6-7Hw_U3c1.js} +1 -1
- package/dist/assets/{vennDiagram-L72KCM5P-B2jd5Egr.js → vennDiagram-L72KCM5P-DXOydhCz.js} +1 -1
- package/dist/assets/{wardleyDiagram-EHGQE667-BpSVMhG8.js → wardleyDiagram-EHGQE667-CHEopdXj.js} +1 -1
- package/dist/assets/{web-A7HWVS8k.js → web-BNIDHx-H.js} +1 -1
- package/dist/assets/{web-C9OGdTUE.js → web-DAWG2rda.js} +1 -1
- package/dist/assets/{web-DMfUbDpe.js → web-vmnQRY1J.js} +1 -1
- package/dist/assets/{xychartDiagram-FW5EYKEG-DUXSUD59.js → xychartDiagram-FW5EYKEG-BpNrwdIN.js} +1 -1
- package/dist/build-info.json +1 -1
- package/dist/index.html +2 -2
- package/dist/locales/en/terminal.json +26 -1
- package/dist/locales/es/terminal.json +2 -1
- package/dist/src/packages/server/routes/exec.js +64 -6
- package/dist/src/packages/server/routes/files.js +132 -1
- package/dist/src/packages/server/services/cfb-reader.js +153 -0
- package/dist/src/packages/server/services/document-doc.js +235 -0
- package/dist/src/packages/server/services/document-parse.js +1159 -0
- package/dist/src/packages/server/services/spreadsheet-biff.js +3 -135
- package/dist/src/packages/shared/document-types.js +22 -0
- package/package.json +1 -1
- package/dist/assets/channel-D9UzWg43.js +0 -1
- package/dist/assets/classDiagram-OUVF2IWQ-DEsVlshj.js +0 -1
- package/dist/assets/classDiagram-v2-EOCWNBFH-DEsVlshj.js +0 -1
- package/dist/assets/index-Ci4guETG.js +0 -2
- package/dist/assets/main-Dzc0Sg9A.css +0 -1
- package/dist/assets/main-fiQD9nPL.js +0 -334
- package/dist/assets/stateDiagram-v2-6OUMAXLB-Ct3Yc5aR.js +0 -1
- package/dist/assets/swimlanesDiagram-G3AALYLV-LdlH8jLU.js +0 -8
|
@@ -0,0 +1,1159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document parsing for the file viewers — dependency-free.
|
|
3
|
+
*
|
|
4
|
+
* - `.docx` / `.docm` (OOXML WordprocessingML): the file is a zip;
|
|
5
|
+
* `word/document.xml` is walked with a tolerant tag scanner into blocks
|
|
6
|
+
* (paragraphs with formatted runs, headings, lists, tables, images,
|
|
7
|
+
* hyperlinks, footnote refs). Styles (`word/styles.xml`) supply heading
|
|
8
|
+
* levels and run defaults, `word/numbering.xml` bullet vs ordered lists,
|
|
9
|
+
* `word/_rels/document.xml.rels` link and image targets.
|
|
10
|
+
* - `.odt` / `.fodt` (OpenDocument text): `content.xml`, same block shape;
|
|
11
|
+
* `styles.xml` + automatic styles give heading levels and list kinds.
|
|
12
|
+
* - `.doc` (Word 97-2003): see document-doc.ts — OLE2 + FIB + piece table,
|
|
13
|
+
* text and paragraph splits only (`plainTextOnly`).
|
|
14
|
+
* - `.rtf`: control-word scanner producing paragraphs with bold/italic/
|
|
15
|
+
* underline; groups that carry binary payloads are skipped.
|
|
16
|
+
*
|
|
17
|
+
* Everything is capped (`maxBlocks`) and the format is sniffed from CONTENT,
|
|
18
|
+
* so a `.doc` that is really an RTF or a `.docx` still opens.
|
|
19
|
+
*/
|
|
20
|
+
import fs from 'fs';
|
|
21
|
+
import path from 'path';
|
|
22
|
+
import { DOCX_EXTENSIONS, DOC_EXTENSIONS, ODT_EXTENSIONS, RTF_EXTENSIONS, } from '../../shared/document-types.js';
|
|
23
|
+
import { decodeTextBuffer, decodeXmlEntities, zipSourceFromBuffer, zipSourceFromFd, } from './spreadsheet-parse.js';
|
|
24
|
+
import { isCfbBuffer } from './cfb-reader.js';
|
|
25
|
+
import { parseDocBuffer } from './document-doc.js';
|
|
26
|
+
export const DOCUMENT_DEFAULT_MAX_BLOCKS = 2_000;
|
|
27
|
+
export const DOCUMENT_HARD_MAX_BLOCKS = 20_000;
|
|
28
|
+
/** Whole-file cap for the in-memory formats (.doc / .rtf). */
|
|
29
|
+
export const DOCUMENT_MAX_FILE_BYTES = 128 * 1024 * 1024;
|
|
30
|
+
/** Rows/cells kept per table — a runaway table can't blow up the payload. */
|
|
31
|
+
const MAX_TABLE_ROWS = 500;
|
|
32
|
+
const MAX_TABLE_COLS = 64;
|
|
33
|
+
export class UnsupportedDocumentError extends Error {
|
|
34
|
+
extension;
|
|
35
|
+
constructor(message, extension) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.extension = extension;
|
|
38
|
+
this.name = 'UnsupportedDocumentError';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function detectDocumentKind(filename) {
|
|
42
|
+
const ext = path.extname(filename).toLowerCase();
|
|
43
|
+
if (DOCX_EXTENSIONS.includes(ext))
|
|
44
|
+
return 'docx';
|
|
45
|
+
if (ODT_EXTENSIONS.includes(ext))
|
|
46
|
+
return 'odt';
|
|
47
|
+
if (DOC_EXTENSIONS.includes(ext))
|
|
48
|
+
return 'doc';
|
|
49
|
+
if (RTF_EXTENSIONS.includes(ext))
|
|
50
|
+
return 'rtf';
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
function clampBlocks(opts) {
|
|
54
|
+
return Math.min(DOCUMENT_HARD_MAX_BLOCKS, Math.max(1, Math.floor(opts.maxBlocks ?? DOCUMENT_DEFAULT_MAX_BLOCKS)));
|
|
55
|
+
}
|
|
56
|
+
// ── tolerant XML helpers (shared shape with the spreadsheet reader) ──────────
|
|
57
|
+
const ATTR_RE_CACHE = new Map();
|
|
58
|
+
/** Attribute value from a tag's attribute string; namespace prefix optional. */
|
|
59
|
+
export function xmlAttr(tagAttrs, name) {
|
|
60
|
+
let re = ATTR_RE_CACHE.get(name);
|
|
61
|
+
if (!re) {
|
|
62
|
+
re = new RegExp(`(?:^|\\s)(?:[\\w.-]+:)?${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`);
|
|
63
|
+
ATTR_RE_CACHE.set(name, re);
|
|
64
|
+
}
|
|
65
|
+
const m = re.exec(tagAttrs);
|
|
66
|
+
if (!m)
|
|
67
|
+
return undefined;
|
|
68
|
+
return m[1] !== undefined ? m[1] : m[2];
|
|
69
|
+
}
|
|
70
|
+
/** `<w:b/>` or `<w:b w:val="1"/>` → true; `w:val="0"|"false"|"none"` → false. */
|
|
71
|
+
function toggleOn(attrs) {
|
|
72
|
+
if (attrs === undefined)
|
|
73
|
+
return false;
|
|
74
|
+
const v = xmlAttr(attrs, 'val');
|
|
75
|
+
if (v === undefined)
|
|
76
|
+
return true;
|
|
77
|
+
return v !== '0' && v !== 'false' && v !== 'none' && v !== 'off';
|
|
78
|
+
}
|
|
79
|
+
function normalizeColor(v) {
|
|
80
|
+
if (!v)
|
|
81
|
+
return undefined;
|
|
82
|
+
const hex = v.trim().replace(/^#/, '');
|
|
83
|
+
if (/^[0-9a-fA-F]{6}$/.test(hex)) {
|
|
84
|
+
// Word's "auto" and pure black/white defaults aren't worth carrying.
|
|
85
|
+
if (/^000000$/i.test(hex))
|
|
86
|
+
return undefined;
|
|
87
|
+
return `#${hex.toLowerCase()}`;
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
/** Word highlight names → hex (w:highlight uses names, w:shd uses hex). */
|
|
92
|
+
const HIGHLIGHT_COLORS = {
|
|
93
|
+
yellow: '#ffff00', green: '#00ff00', cyan: '#00ffff', magenta: '#ff00ff',
|
|
94
|
+
blue: '#0000ff', red: '#ff0000', darkBlue: '#000080', darkCyan: '#008080',
|
|
95
|
+
darkGreen: '#008000', darkMagenta: '#800080', darkRed: '#800000',
|
|
96
|
+
darkYellow: '#808000', darkGray: '#808080', lightGray: '#c0c0c0', black: '#000000',
|
|
97
|
+
};
|
|
98
|
+
const MONO_FONTS = /consolas|courier|menlo|monaco|mono|lucida console|dejavu sans mono|source code/i;
|
|
99
|
+
/** EMU (English Metric Units) → CSS px at 96 dpi. */
|
|
100
|
+
function emuToPx(v) {
|
|
101
|
+
const n = v === undefined ? NaN : parseInt(v, 10);
|
|
102
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
103
|
+
return undefined;
|
|
104
|
+
return Math.round(n / 9525);
|
|
105
|
+
}
|
|
106
|
+
function wordsIn(text) {
|
|
107
|
+
const trimmed = text.trim();
|
|
108
|
+
if (!trimmed)
|
|
109
|
+
return 0;
|
|
110
|
+
return trimmed.split(/\s+/).length;
|
|
111
|
+
}
|
|
112
|
+
/** Word count over blocks (paragraph runs + table cells). */
|
|
113
|
+
export function countWords(blocks) {
|
|
114
|
+
let n = 0;
|
|
115
|
+
for (const b of blocks) {
|
|
116
|
+
if (b.type === 'paragraph') {
|
|
117
|
+
for (const r of b.runs)
|
|
118
|
+
n += wordsIn(r.text);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
for (const row of b.rows)
|
|
122
|
+
for (const cell of row)
|
|
123
|
+
n += countWords(cell.blocks);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return n;
|
|
127
|
+
}
|
|
128
|
+
/** Plain text of a block list — used for headers/footers and the .txt copy. */
|
|
129
|
+
export function blocksToText(blocks) {
|
|
130
|
+
const out = [];
|
|
131
|
+
for (const b of blocks) {
|
|
132
|
+
if (b.type === 'paragraph') {
|
|
133
|
+
out.push(b.runs.map((r) => r.text).join(''));
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
for (const row of b.rows)
|
|
137
|
+
out.push(row.map((c) => blocksToText(c.blocks).replace(/\n+/g, ' ')).join('\t'));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return out.join('\n');
|
|
141
|
+
}
|
|
142
|
+
/** styleId → resolved style info (heading level, default run formatting). */
|
|
143
|
+
export function parseDocxStyles(xml) {
|
|
144
|
+
const styles = new Map();
|
|
145
|
+
const re = /<w:style\b([^>]*)>([\s\S]*?)<\/w:style>/g;
|
|
146
|
+
let m;
|
|
147
|
+
while ((m = re.exec(xml)) !== null) {
|
|
148
|
+
const id = xmlAttr(m[1], 'styleId');
|
|
149
|
+
if (!id)
|
|
150
|
+
continue;
|
|
151
|
+
const body = m[2];
|
|
152
|
+
const nameAttr = /<w:name\b([^>]*)\/>/.exec(body);
|
|
153
|
+
const name = decodeXmlEntities(xmlAttr(nameAttr?.[1] ?? '', 'val') ?? id);
|
|
154
|
+
const outline = /<w:outlineLvl\b([^>]*)\/>/.exec(body);
|
|
155
|
+
const outlineLvl = outline ? parseInt(xmlAttr(outline[1], 'val') ?? '', 10) : NaN;
|
|
156
|
+
// "heading 3" / "Heading3" / outlineLvl 2 all mean level 3.
|
|
157
|
+
const byName = /^heading\s*(\d)/i.exec(name);
|
|
158
|
+
let heading;
|
|
159
|
+
if (byName)
|
|
160
|
+
heading = Math.min(6, Math.max(1, parseInt(byName[1], 10)));
|
|
161
|
+
else if (Number.isFinite(outlineLvl) && outlineLvl >= 0 && outlineLvl <= 5)
|
|
162
|
+
heading = outlineLvl + 1;
|
|
163
|
+
const basedOnTag = /<w:basedOn\b([^>]*)\/>/.exec(body);
|
|
164
|
+
const jc = /<w:jc\b([^>]*)\/>/.exec(body);
|
|
165
|
+
const fonts = /<w:rFonts\b([^>]*)\/>/.exec(body);
|
|
166
|
+
const numId = /<w:numId\b([^>]*)\/>/.exec(body);
|
|
167
|
+
styles.set(id, {
|
|
168
|
+
name,
|
|
169
|
+
heading,
|
|
170
|
+
bold: /<w:b\b[^>]*\/>/.test(body) ? toggleOn(/<w:b\b([^>]*)\/>/.exec(body)?.[1]) : undefined,
|
|
171
|
+
italic: /<w:i\b[^>]*\/>/.test(body) ? toggleOn(/<w:i\b([^>]*)\/>/.exec(body)?.[1]) : undefined,
|
|
172
|
+
mono: fonts ? MONO_FONTS.test(xmlAttr(fonts[1], 'ascii') ?? '') : undefined,
|
|
173
|
+
align: jc ? xmlAttr(jc[1], 'val') : undefined,
|
|
174
|
+
basedOn: basedOnTag ? xmlAttr(basedOnTag[1], 'val') : undefined,
|
|
175
|
+
listNumId: numId ? xmlAttr(numId[1], 'val') : undefined,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
return styles;
|
|
179
|
+
}
|
|
180
|
+
/** Resolve a style chain (basedOn) into effective values. */
|
|
181
|
+
function resolveStyle(styles, id) {
|
|
182
|
+
if (!id)
|
|
183
|
+
return undefined;
|
|
184
|
+
let cur = styles.get(id);
|
|
185
|
+
if (!cur)
|
|
186
|
+
return undefined;
|
|
187
|
+
const merged = { ...cur };
|
|
188
|
+
const seen = new Set([id]);
|
|
189
|
+
while (cur?.basedOn && !seen.has(cur.basedOn)) {
|
|
190
|
+
seen.add(cur.basedOn);
|
|
191
|
+
const parent = styles.get(cur.basedOn);
|
|
192
|
+
if (!parent)
|
|
193
|
+
break;
|
|
194
|
+
if (merged.heading === undefined)
|
|
195
|
+
merged.heading = parent.heading;
|
|
196
|
+
if (merged.bold === undefined)
|
|
197
|
+
merged.bold = parent.bold;
|
|
198
|
+
if (merged.italic === undefined)
|
|
199
|
+
merged.italic = parent.italic;
|
|
200
|
+
if (merged.mono === undefined)
|
|
201
|
+
merged.mono = parent.mono;
|
|
202
|
+
if (merged.align === undefined)
|
|
203
|
+
merged.align = parent.align;
|
|
204
|
+
if (merged.listNumId === undefined)
|
|
205
|
+
merged.listNumId = parent.listNumId;
|
|
206
|
+
cur = parent;
|
|
207
|
+
}
|
|
208
|
+
return merged;
|
|
209
|
+
}
|
|
210
|
+
/** numId → per-level list kind (from numbering.xml's abstract definitions). */
|
|
211
|
+
export function parseDocxNumbering(xml) {
|
|
212
|
+
const abstracts = new Map();
|
|
213
|
+
const absRe = /<w:abstractNum\b([^>]*)>([\s\S]*?)<\/w:abstractNum>/g;
|
|
214
|
+
let m;
|
|
215
|
+
while ((m = absRe.exec(xml)) !== null) {
|
|
216
|
+
const id = xmlAttr(m[1], 'abstractNumId');
|
|
217
|
+
if (!id)
|
|
218
|
+
continue;
|
|
219
|
+
const levels = [];
|
|
220
|
+
const lvlRe = /<w:lvl\b([^>]*)>([\s\S]*?)<\/w:lvl>/g;
|
|
221
|
+
let lm;
|
|
222
|
+
while ((lm = lvlRe.exec(m[2])) !== null) {
|
|
223
|
+
const ilvl = parseInt(xmlAttr(lm[1], 'ilvl') ?? '0', 10) || 0;
|
|
224
|
+
const fmt = /<w:numFmt\b([^>]*)\/>/.exec(lm[2]);
|
|
225
|
+
const val = fmt ? xmlAttr(fmt[1], 'val') : undefined;
|
|
226
|
+
levels[ilvl] = val === 'bullet' || val === 'none' ? 'bullet' : 'ordered';
|
|
227
|
+
}
|
|
228
|
+
abstracts.set(id, levels);
|
|
229
|
+
}
|
|
230
|
+
const nums = new Map();
|
|
231
|
+
const numRe = /<w:num\b([^>]*)>([\s\S]*?)<\/w:num>/g;
|
|
232
|
+
while ((m = numRe.exec(xml)) !== null) {
|
|
233
|
+
const id = xmlAttr(m[1], 'numId');
|
|
234
|
+
const absTag = /<w:abstractNumId\b([^>]*)\/>/.exec(m[2]);
|
|
235
|
+
const absId = absTag ? xmlAttr(absTag[1], 'val') : undefined;
|
|
236
|
+
if (id && absId && abstracts.has(absId))
|
|
237
|
+
nums.set(id, abstracts.get(absId));
|
|
238
|
+
}
|
|
239
|
+
return nums;
|
|
240
|
+
}
|
|
241
|
+
/** rId → target, from any *.rels part. */
|
|
242
|
+
export function parseRels(xml) {
|
|
243
|
+
const map = new Map();
|
|
244
|
+
const re = /<Relationship\b([^>]*?)\/?>/g;
|
|
245
|
+
let m;
|
|
246
|
+
while ((m = re.exec(xml)) !== null) {
|
|
247
|
+
const id = xmlAttr(m[1], 'Id');
|
|
248
|
+
const target = xmlAttr(m[1], 'Target');
|
|
249
|
+
if (!id || !target)
|
|
250
|
+
continue;
|
|
251
|
+
map.set(id, { target: decodeXmlEntities(target), external: (xmlAttr(m[1], 'TargetMode') ?? '') === 'External' });
|
|
252
|
+
}
|
|
253
|
+
return map;
|
|
254
|
+
}
|
|
255
|
+
/** Text of `<w:t>` runs plus tabs/breaks inside a run fragment. */
|
|
256
|
+
function runText(fragment) {
|
|
257
|
+
let out = '';
|
|
258
|
+
const re = /<w:t\b([^>]*?)(?:\/>|>([\s\S]*?)<\/w:t>)|<w:tab\b[^>]*\/>|<w:br\b[^>]*\/>|<w:noBreakHyphen\b[^>]*\/>/g;
|
|
259
|
+
let m;
|
|
260
|
+
while ((m = re.exec(fragment)) !== null) {
|
|
261
|
+
if (m[0].startsWith('<w:tab'))
|
|
262
|
+
out += '\t';
|
|
263
|
+
else if (m[0].startsWith('<w:br'))
|
|
264
|
+
out += '\n';
|
|
265
|
+
else if (m[0].startsWith('<w:noBreakHyphen'))
|
|
266
|
+
out += '-';
|
|
267
|
+
else if (m[2] !== undefined)
|
|
268
|
+
out += decodeXmlEntities(m[2]);
|
|
269
|
+
}
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
/** Build a run from `<w:r>` attrs+body, inheriting the paragraph style. */
|
|
273
|
+
function parseRun(body, base, href, ctx) {
|
|
274
|
+
const rPr = /<w:rPr\b[^>]*>([\s\S]*?)<\/w:rPr>/.exec(body)?.[1] ?? '';
|
|
275
|
+
const text = runText(body);
|
|
276
|
+
// Footnote/endnote reference: a marker with no text of its own.
|
|
277
|
+
const fnRef = /<w:(?:footnote|endnote)Reference\b([^>]*)\/>/.exec(body);
|
|
278
|
+
if (fnRef) {
|
|
279
|
+
const id = xmlAttr(fnRef[1], 'id');
|
|
280
|
+
if (id !== undefined) {
|
|
281
|
+
let ord = ctx.footnoteOrder.get(id);
|
|
282
|
+
if (ord === undefined) {
|
|
283
|
+
ord = ctx.footnoteOrder.size + 1;
|
|
284
|
+
ctx.footnoteOrder.set(id, ord);
|
|
285
|
+
}
|
|
286
|
+
return { text: String(ord), footnoteRef: ord, superscript: true };
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (text === '')
|
|
290
|
+
return null;
|
|
291
|
+
const bTag = /<w:b\b([^>]*)\/>/.exec(rPr);
|
|
292
|
+
const iTag = /<w:i\b([^>]*)\/>/.exec(rPr);
|
|
293
|
+
const uTag = /<w:u\b([^>]*)\/>/.exec(rPr);
|
|
294
|
+
const strikeTag = /<w:(?:strike|dstrike)\b([^>]*)\/>/.exec(rPr);
|
|
295
|
+
const vertTag = /<w:vertAlign\b([^>]*)\/>/.exec(rPr);
|
|
296
|
+
const colorTag = /<w:color\b([^>]*)\/>/.exec(rPr);
|
|
297
|
+
const hlTag = /<w:highlight\b([^>]*)\/>/.exec(rPr);
|
|
298
|
+
const shdTag = /<w:shd\b([^>]*)\/>/.exec(rPr);
|
|
299
|
+
const fontsTag = /<w:rFonts\b([^>]*)\/>/.exec(rPr);
|
|
300
|
+
const vert = vertTag ? xmlAttr(vertTag[1], 'val') : undefined;
|
|
301
|
+
const highlightName = hlTag ? xmlAttr(hlTag[1], 'val') : undefined;
|
|
302
|
+
const shdFill = shdTag ? xmlAttr(shdTag[1], 'fill') : undefined;
|
|
303
|
+
const run = { text };
|
|
304
|
+
const bold = bTag ? toggleOn(bTag[1]) : base?.bold;
|
|
305
|
+
const italic = iTag ? toggleOn(iTag[1]) : base?.italic;
|
|
306
|
+
const mono = fontsTag ? MONO_FONTS.test(xmlAttr(fontsTag[1], 'ascii') ?? '') : base?.mono;
|
|
307
|
+
if (bold)
|
|
308
|
+
run.bold = true;
|
|
309
|
+
if (italic)
|
|
310
|
+
run.italic = true;
|
|
311
|
+
if (uTag && (xmlAttr(uTag[1], 'val') ?? 'single') !== 'none')
|
|
312
|
+
run.underline = true;
|
|
313
|
+
if (strikeTag && toggleOn(strikeTag[1]))
|
|
314
|
+
run.strike = true;
|
|
315
|
+
if (mono)
|
|
316
|
+
run.mono = true;
|
|
317
|
+
if (vert === 'superscript')
|
|
318
|
+
run.superscript = true;
|
|
319
|
+
else if (vert === 'subscript')
|
|
320
|
+
run.subscript = true;
|
|
321
|
+
const color = normalizeColor(colorTag ? xmlAttr(colorTag[1], 'val') : undefined);
|
|
322
|
+
if (color)
|
|
323
|
+
run.color = color;
|
|
324
|
+
const highlight = highlightName
|
|
325
|
+
? HIGHLIGHT_COLORS[highlightName] ?? normalizeColor(highlightName)
|
|
326
|
+
: normalizeColor(shdFill === 'auto' ? undefined : shdFill);
|
|
327
|
+
if (highlight)
|
|
328
|
+
run.highlight = highlight;
|
|
329
|
+
if (href)
|
|
330
|
+
run.href = href;
|
|
331
|
+
return run;
|
|
332
|
+
}
|
|
333
|
+
/** Images referenced by `<w:drawing>` / `<w:pict>` inside a paragraph. */
|
|
334
|
+
function parseImages(pXml, ctx) {
|
|
335
|
+
const images = [];
|
|
336
|
+
const blipRe = /<a:blip\b([^>]*?)\/?>/g;
|
|
337
|
+
let m;
|
|
338
|
+
while ((m = blipRe.exec(pXml)) !== null) {
|
|
339
|
+
const rid = xmlAttr(m[1], 'embed') ?? xmlAttr(m[1], 'link');
|
|
340
|
+
if (!rid)
|
|
341
|
+
continue;
|
|
342
|
+
const rel = ctx.rels.get(rid);
|
|
343
|
+
if (!rel || rel.external)
|
|
344
|
+
continue;
|
|
345
|
+
const entry = rel.target.startsWith('/') ? rel.target.slice(1) : `${ctx.partDir}${rel.target.replace(/^\.\//, '')}`;
|
|
346
|
+
const ext = /<wp:extent\b([^>]*)\/>/.exec(pXml);
|
|
347
|
+
const docPr = /<wp:docPr\b([^>]*?)\/?>/.exec(pXml);
|
|
348
|
+
images.push({
|
|
349
|
+
entry: entry.replace(/\/{2,}/g, '/'),
|
|
350
|
+
width: ext ? emuToPx(xmlAttr(ext[1], 'cx')) : undefined,
|
|
351
|
+
height: ext ? emuToPx(xmlAttr(ext[1], 'cy')) : undefined,
|
|
352
|
+
alt: docPr ? decodeXmlEntities(xmlAttr(docPr[1], 'descr') ?? xmlAttr(docPr[1], 'name') ?? '') || undefined : undefined,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
// VML fallback (older docs): <v:imagedata r:id="rId7"/>
|
|
356
|
+
const vmlRe = /<v:imagedata\b([^>]*?)\/?>/g;
|
|
357
|
+
while ((m = vmlRe.exec(pXml)) !== null) {
|
|
358
|
+
const rid = xmlAttr(m[1], 'id');
|
|
359
|
+
const rel = rid ? ctx.rels.get(rid) : undefined;
|
|
360
|
+
if (!rel || rel.external)
|
|
361
|
+
continue;
|
|
362
|
+
const entry = rel.target.startsWith('/') ? rel.target.slice(1) : `${ctx.partDir}${rel.target.replace(/^\.\//, '')}`;
|
|
363
|
+
images.push({ entry: entry.replace(/\/{2,}/g, '/') });
|
|
364
|
+
}
|
|
365
|
+
return images;
|
|
366
|
+
}
|
|
367
|
+
/** One `<w:p>` → a paragraph block (null when it carries nothing at all). */
|
|
368
|
+
function parseParagraph(attrs, body, ctx) {
|
|
369
|
+
void attrs;
|
|
370
|
+
const pPr = /<w:pPr\b[^>]*>([\s\S]*?)<\/w:pPr>/.exec(body)?.[1] ?? '';
|
|
371
|
+
const styleTag = /<w:pStyle\b([^>]*)\/>/.exec(pPr);
|
|
372
|
+
const style = resolveStyle(ctx.styles, styleTag ? xmlAttr(styleTag[1], 'val') : undefined);
|
|
373
|
+
const jc = /<w:jc\b([^>]*)\/>/.exec(pPr);
|
|
374
|
+
const numPr = /<w:numPr\b[^>]*>([\s\S]*?)<\/w:numPr>/.exec(pPr)?.[1] ?? '';
|
|
375
|
+
const numIdTag = /<w:numId\b([^>]*)\/>/.exec(numPr);
|
|
376
|
+
const ilvlTag = /<w:ilvl\b([^>]*)\/>/.exec(numPr);
|
|
377
|
+
const indTag = /<w:ind\b([^>]*)\/>/.exec(pPr);
|
|
378
|
+
const outlineTag = /<w:outlineLvl\b([^>]*)\/>/.exec(pPr);
|
|
379
|
+
const runs = [];
|
|
380
|
+
// Runs, in order, including those wrapped in hyperlinks / smart tags / ins.
|
|
381
|
+
const runRe = /<w:hyperlink\b([^>]*)>([\s\S]*?)<\/w:hyperlink>|<w:r\b[^>]*>([\s\S]*?)<\/w:r>/g;
|
|
382
|
+
let m;
|
|
383
|
+
while ((m = runRe.exec(body)) !== null) {
|
|
384
|
+
if (m[3] !== undefined) {
|
|
385
|
+
const run = parseRun(m[3], style, undefined, ctx);
|
|
386
|
+
if (run)
|
|
387
|
+
runs.push(run);
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
// Hyperlink: resolve the target once, then its inner runs.
|
|
391
|
+
const rid = xmlAttr(m[1], 'id');
|
|
392
|
+
const anchor = xmlAttr(m[1], 'anchor');
|
|
393
|
+
const rel = rid ? ctx.rels.get(rid) : undefined;
|
|
394
|
+
const href = rel ? rel.target : anchor ? `#${anchor}` : undefined;
|
|
395
|
+
const innerRe = /<w:r\b[^>]*>([\s\S]*?)<\/w:r>/g;
|
|
396
|
+
let im;
|
|
397
|
+
while ((im = innerRe.exec(m[2])) !== null) {
|
|
398
|
+
const run = parseRun(im[1], style, href, ctx);
|
|
399
|
+
if (run)
|
|
400
|
+
runs.push(run);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
const images = parseImages(body, ctx);
|
|
404
|
+
const pageBreak = /<w:br\b[^>]*w:type="page"/.test(body) || /<w:lastRenderedPageBreak\b/.test(body);
|
|
405
|
+
if (runs.length === 0 && images.length === 0 && !pageBreak)
|
|
406
|
+
return null;
|
|
407
|
+
const para = { type: 'paragraph', runs };
|
|
408
|
+
const outlineLvl = outlineTag ? parseInt(xmlAttr(outlineTag[1], 'val') ?? '', 10) : NaN;
|
|
409
|
+
const heading = style?.heading ?? (Number.isFinite(outlineLvl) && outlineLvl >= 0 && outlineLvl <= 5 ? outlineLvl + 1 : undefined);
|
|
410
|
+
if (heading)
|
|
411
|
+
para.heading = heading;
|
|
412
|
+
if (style?.name)
|
|
413
|
+
para.styleName = style.name;
|
|
414
|
+
const align = (jc ? xmlAttr(jc[1], 'val') : undefined) ?? style?.align;
|
|
415
|
+
if (align === 'center' || align === 'right' || align === 'both' || align === 'justify' || align === 'left') {
|
|
416
|
+
para.align = align === 'both' ? 'justify' : align;
|
|
417
|
+
}
|
|
418
|
+
const numId = numIdTag ? xmlAttr(numIdTag[1], 'val') : style?.listNumId;
|
|
419
|
+
if (numId && numId !== '0') {
|
|
420
|
+
const level = ilvlTag ? parseInt(xmlAttr(ilvlTag[1], 'val') ?? '0', 10) || 0 : 0;
|
|
421
|
+
const kinds = ctx.numbering.get(numId);
|
|
422
|
+
para.list = { kind: kinds?.[level] ?? 'bullet', level: Math.min(8, Math.max(0, level)) };
|
|
423
|
+
}
|
|
424
|
+
else if (indTag) {
|
|
425
|
+
const left = parseInt(xmlAttr(indTag[1], 'left') ?? xmlAttr(indTag[1], 'start') ?? '', 10);
|
|
426
|
+
// 720 twips = 0.5" = one indent step.
|
|
427
|
+
if (Number.isFinite(left) && left >= 360)
|
|
428
|
+
para.indent = Math.min(6, Math.round(left / 720));
|
|
429
|
+
}
|
|
430
|
+
if (images.length > 0)
|
|
431
|
+
para.images = images;
|
|
432
|
+
if (pageBreak)
|
|
433
|
+
para.pageBreak = true;
|
|
434
|
+
return para;
|
|
435
|
+
}
|
|
436
|
+
/** `<w:tbl>` → a table block. Cells hold nested blocks (paragraphs, tables). */
|
|
437
|
+
function parseTable(body, ctx, depth) {
|
|
438
|
+
const rows = [];
|
|
439
|
+
let colCount = 0;
|
|
440
|
+
let truncated = false;
|
|
441
|
+
const rowRe = /<w:tr\b[^>]*>([\s\S]*?)<\/w:tr>/g;
|
|
442
|
+
let rm;
|
|
443
|
+
while ((rm = rowRe.exec(body)) !== null) {
|
|
444
|
+
if (rows.length >= MAX_TABLE_ROWS) {
|
|
445
|
+
truncated = true;
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
const cells = [];
|
|
449
|
+
const cellRe = /<w:tc\b[^>]*>([\s\S]*?)<\/w:tc>/g;
|
|
450
|
+
let cm;
|
|
451
|
+
let width = 0;
|
|
452
|
+
while ((cm = cellRe.exec(rm[1])) !== null) {
|
|
453
|
+
if (cells.length >= MAX_TABLE_COLS) {
|
|
454
|
+
truncated = true;
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
const cellXml = cm[1];
|
|
458
|
+
const tcPr = /<w:tcPr\b[^>]*>([\s\S]*?)<\/w:tcPr>/.exec(cellXml)?.[1] ?? '';
|
|
459
|
+
const gridSpanTag = /<w:gridSpan\b([^>]*)\/>/.exec(tcPr);
|
|
460
|
+
const vMergeTag = /<w:vMerge\b([^>]*)\/?>/.exec(tcPr);
|
|
461
|
+
const shdTag = /<w:shd\b([^>]*)\/>/.exec(tcPr);
|
|
462
|
+
const colSpan = gridSpanTag ? Math.max(1, parseInt(xmlAttr(gridSpanTag[1], 'val') ?? '1', 10) || 1) : 1;
|
|
463
|
+
// A continuation cell of a vertical merge renders as part of the one above.
|
|
464
|
+
const isVMergeContinue = !!vMergeTag && (xmlAttr(vMergeTag[1], 'val') ?? 'continue') === 'continue';
|
|
465
|
+
const cell = { blocks: isVMergeContinue ? [] : parseDocxBody(cellXml, ctx, depth + 1).blocks };
|
|
466
|
+
if (colSpan > 1)
|
|
467
|
+
cell.colSpan = colSpan;
|
|
468
|
+
const bg = normalizeColor(shdTag ? (xmlAttr(shdTag[1], 'fill') === 'auto' ? undefined : xmlAttr(shdTag[1], 'fill')) : undefined);
|
|
469
|
+
if (bg)
|
|
470
|
+
cell.background = bg;
|
|
471
|
+
cells.push(cell);
|
|
472
|
+
width += colSpan;
|
|
473
|
+
}
|
|
474
|
+
const isHeaderRow = /<w:tblHeader\b/.test(rm[1]);
|
|
475
|
+
if (isHeaderRow)
|
|
476
|
+
for (const c of cells)
|
|
477
|
+
c.header = true;
|
|
478
|
+
if (width > colCount)
|
|
479
|
+
colCount = width;
|
|
480
|
+
rows.push(cells);
|
|
481
|
+
}
|
|
482
|
+
// A first row of all-bold single-line cells reads as a header even without tblHeader.
|
|
483
|
+
if (rows.length > 1 && !rows[0].some((c) => c.header)) {
|
|
484
|
+
const first = rows[0];
|
|
485
|
+
const allBold = first.length > 0 && first.every((c) => c.blocks.every((b) => b.type !== 'paragraph' || b.runs.every((r) => r.bold || r.text.trim() === '')));
|
|
486
|
+
if (allBold)
|
|
487
|
+
for (const c of first)
|
|
488
|
+
c.header = true;
|
|
489
|
+
}
|
|
490
|
+
return { type: 'table', rows, colCount, ...(truncated ? { truncated: true } : {}) };
|
|
491
|
+
}
|
|
492
|
+
/** Walk a body fragment (document body or a table cell) into blocks. */
|
|
493
|
+
function parseDocxBody(xml, ctx, depth = 0) {
|
|
494
|
+
const blocks = [];
|
|
495
|
+
let truncated = false;
|
|
496
|
+
if (depth > 6)
|
|
497
|
+
return { blocks, truncated };
|
|
498
|
+
// Paragraphs and tables at THIS level, in document order. `<w:tbl>` bodies
|
|
499
|
+
// contain `<w:p>`, so tables are matched first and their span skipped.
|
|
500
|
+
const re = /<w:tbl\b[^>]*>([\s\S]*?)<\/w:tbl>|<w:p\b([^>]*)>([\s\S]*?)<\/w:p>|<w:p\b([^>]*)\/>/g;
|
|
501
|
+
let m;
|
|
502
|
+
let cursor = 0;
|
|
503
|
+
while ((m = re.exec(xml)) !== null) {
|
|
504
|
+
if (m.index < cursor)
|
|
505
|
+
continue; // inside a table already consumed
|
|
506
|
+
if (blocks.length >= ctx.maxBlocks) {
|
|
507
|
+
truncated = true;
|
|
508
|
+
break;
|
|
509
|
+
}
|
|
510
|
+
if (m[1] !== undefined) {
|
|
511
|
+
blocks.push(parseTable(m[1], ctx, depth));
|
|
512
|
+
cursor = m.index + m[0].length;
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
const para = m[3] !== undefined ? parseParagraph(m[2] ?? '', m[3], ctx) : null;
|
|
516
|
+
if (para)
|
|
517
|
+
blocks.push(para);
|
|
518
|
+
cursor = m.index + m[0].length;
|
|
519
|
+
}
|
|
520
|
+
return { blocks, truncated };
|
|
521
|
+
}
|
|
522
|
+
/** Footnotes/endnotes part → blocks per note id (only the referenced ones). */
|
|
523
|
+
function parseNotes(xml, ctx, wanted) {
|
|
524
|
+
if (wanted.size === 0)
|
|
525
|
+
return [];
|
|
526
|
+
const out = [];
|
|
527
|
+
const re = /<w:(?:footnote|endnote)\b([^>]*)>([\s\S]*?)<\/w:(?:footnote|endnote)>/g;
|
|
528
|
+
let m;
|
|
529
|
+
while ((m = re.exec(xml)) !== null) {
|
|
530
|
+
const id = xmlAttr(m[1], 'id');
|
|
531
|
+
if (id === undefined)
|
|
532
|
+
continue;
|
|
533
|
+
const ord = wanted.get(id);
|
|
534
|
+
if (ord === undefined)
|
|
535
|
+
continue;
|
|
536
|
+
const { blocks } = parseDocxBody(m[2], ctx);
|
|
537
|
+
out.push({ id: ord, blocks });
|
|
538
|
+
}
|
|
539
|
+
return out.sort((a, b) => a.id - b.id);
|
|
540
|
+
}
|
|
541
|
+
/** Core properties (title/author) from docProps/core.xml. */
|
|
542
|
+
function parseCoreProps(xml) {
|
|
543
|
+
const pick = (tag) => {
|
|
544
|
+
const m = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)</${tag}>`).exec(xml);
|
|
545
|
+
const v = m ? decodeXmlEntities(m[1]).trim() : '';
|
|
546
|
+
return v || undefined;
|
|
547
|
+
};
|
|
548
|
+
return { title: pick('dc:title'), author: pick('dc:creator') };
|
|
549
|
+
}
|
|
550
|
+
/** Parse a .docx/.docm from a zip source. */
|
|
551
|
+
export async function parseDocxSource(src, opts = {}) {
|
|
552
|
+
const maxBlocks = clampBlocks(opts);
|
|
553
|
+
const readText = async (name) => {
|
|
554
|
+
const b = await src.readMember(name);
|
|
555
|
+
return b ? b.toString('utf8') : null;
|
|
556
|
+
};
|
|
557
|
+
const documentXml = await readText('word/document.xml');
|
|
558
|
+
if (documentXml === null)
|
|
559
|
+
throw new Error('not a Word document (word/document.xml missing)');
|
|
560
|
+
const ctx = {
|
|
561
|
+
styles: parseDocxStyles((await readText('word/styles.xml')) ?? ''),
|
|
562
|
+
numbering: parseDocxNumbering((await readText('word/numbering.xml')) ?? ''),
|
|
563
|
+
rels: parseRels((await readText('word/_rels/document.xml.rels')) ?? ''),
|
|
564
|
+
partDir: 'word/',
|
|
565
|
+
maxBlocks,
|
|
566
|
+
footnoteOrder: new Map(),
|
|
567
|
+
};
|
|
568
|
+
const bodyXml = /<w:body\b[^>]*>([\s\S]*)<\/w:body>/.exec(documentXml)?.[1] ?? documentXml;
|
|
569
|
+
const { blocks, truncated } = parseDocxBody(bodyXml, ctx);
|
|
570
|
+
const footnotes = [
|
|
571
|
+
...parseNotes((await readText('word/footnotes.xml')) ?? '', ctx, ctx.footnoteOrder),
|
|
572
|
+
...parseNotes((await readText('word/endnotes.xml')) ?? '', ctx, ctx.footnoteOrder),
|
|
573
|
+
].sort((a, b) => a.id - b.id);
|
|
574
|
+
// Header/footer of the first section, as plain text (they repeat per page,
|
|
575
|
+
// so the viewer shows them once as document chrome).
|
|
576
|
+
const headerText = await (async () => {
|
|
577
|
+
for (const name of ['word/header1.xml', 'word/header2.xml']) {
|
|
578
|
+
const xml = await readText(name);
|
|
579
|
+
if (!xml)
|
|
580
|
+
continue;
|
|
581
|
+
const text = blocksToText(parseDocxBody(xml, { ...ctx, maxBlocks: 40 }).blocks).trim();
|
|
582
|
+
if (text)
|
|
583
|
+
return text;
|
|
584
|
+
}
|
|
585
|
+
return undefined;
|
|
586
|
+
})();
|
|
587
|
+
const footerText = await (async () => {
|
|
588
|
+
for (const name of ['word/footer1.xml', 'word/footer2.xml']) {
|
|
589
|
+
const xml = await readText(name);
|
|
590
|
+
if (!xml)
|
|
591
|
+
continue;
|
|
592
|
+
const text = blocksToText(parseDocxBody(xml, { ...ctx, maxBlocks: 40 }).blocks).trim();
|
|
593
|
+
if (text)
|
|
594
|
+
return text;
|
|
595
|
+
}
|
|
596
|
+
return undefined;
|
|
597
|
+
})();
|
|
598
|
+
const core = parseCoreProps((await readText('docProps/core.xml')) ?? '');
|
|
599
|
+
// Total block count: cheap tag count when the body was capped.
|
|
600
|
+
const blockCount = truncated
|
|
601
|
+
? (bodyXml.match(/<w:p\b/g) || []).length + (bodyXml.match(/<w:tbl\b/g) || []).length
|
|
602
|
+
: blocks.length;
|
|
603
|
+
return {
|
|
604
|
+
format: 'docx',
|
|
605
|
+
title: core.title,
|
|
606
|
+
author: core.author,
|
|
607
|
+
blocks,
|
|
608
|
+
footnotes: footnotes.length > 0 ? footnotes : undefined,
|
|
609
|
+
header: headerText,
|
|
610
|
+
footer: footerText,
|
|
611
|
+
wordCount: countWords(blocks),
|
|
612
|
+
blockCount,
|
|
613
|
+
truncated,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
export function parseDocxBuffer(buf, opts = {}) {
|
|
617
|
+
return parseDocxSource(zipSourceFromBuffer(buf), opts);
|
|
618
|
+
}
|
|
619
|
+
/** ODF style name → formatting (automatic styles + named styles). */
|
|
620
|
+
export function parseOdtStyles(xml) {
|
|
621
|
+
const styles = new Map();
|
|
622
|
+
const re = /<style:style\b([^>]*)(?:\/>|>([\s\S]*?)<\/style:style>)/g;
|
|
623
|
+
let m;
|
|
624
|
+
while ((m = re.exec(xml)) !== null) {
|
|
625
|
+
const name = xmlAttr(m[1], 'name');
|
|
626
|
+
if (!name)
|
|
627
|
+
continue;
|
|
628
|
+
const body = m[2] ?? '';
|
|
629
|
+
const textProps = /<style:text-properties\b([^>]*)\/?>/.exec(body)?.[1] ?? '';
|
|
630
|
+
const paraProps = /<style:paragraph-properties\b([^>]*)\/?>/.exec(body)?.[1] ?? '';
|
|
631
|
+
const parent = xmlAttr(m[1], 'parent-style-name');
|
|
632
|
+
const align = xmlAttr(paraProps, 'text-align');
|
|
633
|
+
const family = xmlAttr(textProps, 'font-name') ?? xmlAttr(textProps, 'font-family');
|
|
634
|
+
const info = { parent };
|
|
635
|
+
if (xmlAttr(textProps, 'font-weight') === 'bold')
|
|
636
|
+
info.bold = true;
|
|
637
|
+
if (xmlAttr(textProps, 'font-style') === 'italic')
|
|
638
|
+
info.italic = true;
|
|
639
|
+
const underline = xmlAttr(textProps, 'text-underline-style');
|
|
640
|
+
if (underline && underline !== 'none')
|
|
641
|
+
info.underline = true;
|
|
642
|
+
const strike = xmlAttr(textProps, 'text-line-through-style');
|
|
643
|
+
if (strike && strike !== 'none')
|
|
644
|
+
info.strike = true;
|
|
645
|
+
if (family && MONO_FONTS.test(family))
|
|
646
|
+
info.mono = true;
|
|
647
|
+
const color = normalizeColor(xmlAttr(textProps, 'color'));
|
|
648
|
+
if (color)
|
|
649
|
+
info.color = color;
|
|
650
|
+
const bg = xmlAttr(textProps, 'background-color');
|
|
651
|
+
if (bg && bg !== 'transparent') {
|
|
652
|
+
const hex = normalizeColor(bg);
|
|
653
|
+
if (hex)
|
|
654
|
+
info.highlight = hex;
|
|
655
|
+
}
|
|
656
|
+
if (align === 'center' || align === 'end' || align === 'right')
|
|
657
|
+
info.align = align === 'end' ? 'right' : align;
|
|
658
|
+
else if (align === 'justify')
|
|
659
|
+
info.align = 'justify';
|
|
660
|
+
// Heading styles in ODF are named "Heading_20_2" / parent "Heading".
|
|
661
|
+
const hm = /^Heading_20_(\d)$/.exec(name) ?? /^Heading(\d)$/.exec(name);
|
|
662
|
+
if (hm)
|
|
663
|
+
info.heading = Math.min(6, Math.max(1, parseInt(hm[1], 10)));
|
|
664
|
+
styles.set(name, info);
|
|
665
|
+
}
|
|
666
|
+
return styles;
|
|
667
|
+
}
|
|
668
|
+
function resolveOdtStyle(styles, name) {
|
|
669
|
+
const out = {};
|
|
670
|
+
let cur = name;
|
|
671
|
+
const seen = new Set();
|
|
672
|
+
while (cur && !seen.has(cur)) {
|
|
673
|
+
seen.add(cur);
|
|
674
|
+
const info = styles.get(cur);
|
|
675
|
+
if (!info)
|
|
676
|
+
break;
|
|
677
|
+
for (const k of ['heading', 'bold', 'italic', 'underline', 'strike', 'mono', 'color', 'highlight', 'align']) {
|
|
678
|
+
if (out[k] === undefined && info[k] !== undefined)
|
|
679
|
+
out[k] = info[k];
|
|
680
|
+
}
|
|
681
|
+
cur = info.parent;
|
|
682
|
+
}
|
|
683
|
+
return out;
|
|
684
|
+
}
|
|
685
|
+
/** Inline content of an ODF paragraph → runs (spans, links, tabs, breaks). */
|
|
686
|
+
function parseOdtInline(fragment, styles, base, href) {
|
|
687
|
+
const runs = [];
|
|
688
|
+
const push = (text, info, link) => {
|
|
689
|
+
if (text === '')
|
|
690
|
+
return;
|
|
691
|
+
const run = { text };
|
|
692
|
+
if (info.bold)
|
|
693
|
+
run.bold = true;
|
|
694
|
+
if (info.italic)
|
|
695
|
+
run.italic = true;
|
|
696
|
+
if (info.underline)
|
|
697
|
+
run.underline = true;
|
|
698
|
+
if (info.strike)
|
|
699
|
+
run.strike = true;
|
|
700
|
+
if (info.mono)
|
|
701
|
+
run.mono = true;
|
|
702
|
+
if (info.color)
|
|
703
|
+
run.color = info.color;
|
|
704
|
+
if (info.highlight)
|
|
705
|
+
run.highlight = info.highlight;
|
|
706
|
+
if (link)
|
|
707
|
+
run.href = link;
|
|
708
|
+
runs.push(run);
|
|
709
|
+
};
|
|
710
|
+
const re = /<text:span\b([^>]*)(?:\/>|>([\s\S]*?)<\/text:span>)|<text:a\b([^>]*)>([\s\S]*?)<\/text:a>|<text:s\b([^>]*)\/>|<text:tab\b[^>]*\/>|<text:line-break\b[^>]*\/>|<[^>]+>|([^<]+)/g;
|
|
711
|
+
let m;
|
|
712
|
+
while ((m = re.exec(fragment)) !== null) {
|
|
713
|
+
if (m[1] !== undefined) {
|
|
714
|
+
const info = { ...base, ...resolveOdtStyle(styles, xmlAttr(m[1], 'style-name')) };
|
|
715
|
+
runs.push(...parseOdtInline(m[2] ?? '', styles, info, href));
|
|
716
|
+
}
|
|
717
|
+
else if (m[3] !== undefined) {
|
|
718
|
+
const link = decodeXmlEntities(xmlAttr(m[3], 'href') ?? '');
|
|
719
|
+
runs.push(...parseOdtInline(m[4] ?? '', styles, base, link || href));
|
|
720
|
+
}
|
|
721
|
+
else if (m[0].startsWith('<text:s')) {
|
|
722
|
+
push(' '.repeat(parseInt(xmlAttr(m[5] ?? '', 'c') ?? '1', 10) || 1), base, href);
|
|
723
|
+
}
|
|
724
|
+
else if (m[0].startsWith('<text:tab')) {
|
|
725
|
+
push('\t', base, href);
|
|
726
|
+
}
|
|
727
|
+
else if (m[0].startsWith('<text:line-break')) {
|
|
728
|
+
push('\n', base, href);
|
|
729
|
+
}
|
|
730
|
+
else if (m[6] !== undefined) {
|
|
731
|
+
push(decodeXmlEntities(m[6]), base, href);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
return runs;
|
|
735
|
+
}
|
|
736
|
+
/** ODF list definitions → bullet vs ordered per level. */
|
|
737
|
+
function parseOdtListStyles(xml) {
|
|
738
|
+
const out = new Map();
|
|
739
|
+
const re = /<text:list-style\b([^>]*)>([\s\S]*?)<\/text:list-style>/g;
|
|
740
|
+
let m;
|
|
741
|
+
while ((m = re.exec(xml)) !== null) {
|
|
742
|
+
const name = xmlAttr(m[1], 'name');
|
|
743
|
+
if (!name)
|
|
744
|
+
continue;
|
|
745
|
+
const levels = [];
|
|
746
|
+
const lvlRe = /<text:list-level-style-(number|bullet|image)\b([^>]*)/g;
|
|
747
|
+
let lm;
|
|
748
|
+
while ((lm = lvlRe.exec(m[2])) !== null) {
|
|
749
|
+
const level = (parseInt(xmlAttr(lm[2], 'level') ?? '1', 10) || 1) - 1;
|
|
750
|
+
levels[level] = lm[1] === 'number' ? 'ordered' : 'bullet';
|
|
751
|
+
}
|
|
752
|
+
out.set(name, levels);
|
|
753
|
+
}
|
|
754
|
+
return out;
|
|
755
|
+
}
|
|
756
|
+
function parseOdtBody(xml, ctx, depth = 0, listState) {
|
|
757
|
+
const blocks = [];
|
|
758
|
+
let truncated = false;
|
|
759
|
+
if (depth > 8)
|
|
760
|
+
return { blocks, truncated };
|
|
761
|
+
const re = /<table:table\b([^>]*)>([\s\S]*?)<\/table:table>|<text:list\b([^>]*)>([\s\S]*?)<\/text:list>|<text:(h|p)\b([^>]*)(?:\/>|>([\s\S]*?)<\/text:\5>)/g;
|
|
762
|
+
let m;
|
|
763
|
+
let cursor = 0;
|
|
764
|
+
while ((m = re.exec(xml)) !== null) {
|
|
765
|
+
if (m.index < cursor)
|
|
766
|
+
continue;
|
|
767
|
+
if (blocks.length >= ctx.maxBlocks) {
|
|
768
|
+
truncated = true;
|
|
769
|
+
break;
|
|
770
|
+
}
|
|
771
|
+
cursor = m.index + m[0].length;
|
|
772
|
+
if (m[2] !== undefined) {
|
|
773
|
+
// Table: rows of cells, each cell a nested body.
|
|
774
|
+
const rows = [];
|
|
775
|
+
let colCount = 0;
|
|
776
|
+
const rowRe = /<table:table-row\b[^>]*>([\s\S]*?)<\/table:table-row>/g;
|
|
777
|
+
let rm;
|
|
778
|
+
while ((rm = rowRe.exec(m[2])) !== null) {
|
|
779
|
+
if (rows.length >= MAX_TABLE_ROWS)
|
|
780
|
+
break;
|
|
781
|
+
const cells = [];
|
|
782
|
+
const cellRe = /<table:(?:covered-)?table-cell\b([^>]*)(?:\/>|>([\s\S]*?)<\/table:(?:covered-)?table-cell>)/g;
|
|
783
|
+
let cm;
|
|
784
|
+
let width = 0;
|
|
785
|
+
while ((cm = cellRe.exec(rm[1])) !== null) {
|
|
786
|
+
if (cells.length >= MAX_TABLE_COLS)
|
|
787
|
+
break;
|
|
788
|
+
const span = Math.max(1, parseInt(xmlAttr(cm[1], 'number-columns-spanned') ?? '1', 10) || 1);
|
|
789
|
+
const cell = { blocks: cm[2] ? parseOdtBody(cm[2], ctx, depth + 1).blocks : [] };
|
|
790
|
+
if (span > 1)
|
|
791
|
+
cell.colSpan = span;
|
|
792
|
+
cells.push(cell);
|
|
793
|
+
width += span;
|
|
794
|
+
}
|
|
795
|
+
if (width > colCount)
|
|
796
|
+
colCount = width;
|
|
797
|
+
rows.push(cells);
|
|
798
|
+
}
|
|
799
|
+
if (rows.length > 1) {
|
|
800
|
+
const first = rows[0];
|
|
801
|
+
const allBold = first.length > 0 && first.every((c) => c.blocks.every((b) => b.type !== 'paragraph' || b.runs.every((r) => r.bold || r.text.trim() === '')));
|
|
802
|
+
if (allBold)
|
|
803
|
+
for (const c of first)
|
|
804
|
+
c.header = true;
|
|
805
|
+
}
|
|
806
|
+
blocks.push({ type: 'table', rows, colCount });
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
if (m[4] !== undefined) {
|
|
810
|
+
// List: each <text:list-item> holds paragraphs at this level.
|
|
811
|
+
const styleName = xmlAttr(m[3], 'style-name');
|
|
812
|
+
const kinds = styleName ? ctx.listKinds.get(styleName) : undefined;
|
|
813
|
+
const level = listState ? listState.level + 1 : 0;
|
|
814
|
+
const kind = kinds?.[level] ?? listState?.kind ?? 'bullet';
|
|
815
|
+
const inner = parseOdtBody(m[4], ctx, depth + 1, { kind, level });
|
|
816
|
+
blocks.push(...inner.blocks);
|
|
817
|
+
truncated = truncated || inner.truncated;
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
// Heading (text:h) or paragraph (text:p).
|
|
821
|
+
const isHeading = m[5] === 'h';
|
|
822
|
+
const attrs = m[6] ?? '';
|
|
823
|
+
const inner = m[7] ?? '';
|
|
824
|
+
const styleInfo = resolveOdtStyle(ctx.styles, xmlAttr(attrs, 'style-name'));
|
|
825
|
+
const runs = parseOdtInline(inner, ctx.styles, styleInfo);
|
|
826
|
+
if (runs.length === 0 && !isHeading)
|
|
827
|
+
continue;
|
|
828
|
+
const para = { type: 'paragraph', runs };
|
|
829
|
+
if (isHeading) {
|
|
830
|
+
const lvl = parseInt(xmlAttr(attrs, 'outline-level') ?? '', 10);
|
|
831
|
+
para.heading = Math.min(6, Math.max(1, Number.isFinite(lvl) ? lvl : styleInfo.heading ?? 1));
|
|
832
|
+
}
|
|
833
|
+
else if (styleInfo.heading) {
|
|
834
|
+
para.heading = styleInfo.heading;
|
|
835
|
+
}
|
|
836
|
+
if (styleInfo.align)
|
|
837
|
+
para.align = styleInfo.align;
|
|
838
|
+
if (listState)
|
|
839
|
+
para.list = { kind: listState.kind, level: Math.min(8, listState.level) };
|
|
840
|
+
if (runs.length === 0)
|
|
841
|
+
continue;
|
|
842
|
+
blocks.push(para);
|
|
843
|
+
}
|
|
844
|
+
return { blocks, truncated };
|
|
845
|
+
}
|
|
846
|
+
/** Parse the content.xml (+ styles.xml) of an OpenDocument text file. */
|
|
847
|
+
export function parseOdtContentXml(contentXml, stylesXml, opts = {}) {
|
|
848
|
+
const maxBlocks = clampBlocks(opts);
|
|
849
|
+
const styles = new Map([...parseOdtStyles(stylesXml), ...parseOdtStyles(contentXml)]);
|
|
850
|
+
const listKinds = new Map([...parseOdtListStyles(stylesXml), ...parseOdtListStyles(contentXml)]);
|
|
851
|
+
const bodyXml = /<office:text\b[^>]*>([\s\S]*)<\/office:text>/.exec(contentXml)?.[1] ?? contentXml;
|
|
852
|
+
const { blocks, truncated } = parseOdtBody(bodyXml, { styles, listKinds, maxBlocks });
|
|
853
|
+
const blockCount = truncated
|
|
854
|
+
? (bodyXml.match(/<text:(?:p|h)\b/g) || []).length + (bodyXml.match(/<table:table\b/g) || []).length
|
|
855
|
+
: blocks.length;
|
|
856
|
+
return {
|
|
857
|
+
format: 'odt',
|
|
858
|
+
blocks,
|
|
859
|
+
wordCount: countWords(blocks),
|
|
860
|
+
blockCount,
|
|
861
|
+
truncated,
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
export async function parseOdtSource(src, opts = {}) {
|
|
865
|
+
const content = await src.readMember('content.xml');
|
|
866
|
+
if (!content)
|
|
867
|
+
throw new Error('not an OpenDocument text file (content.xml missing)');
|
|
868
|
+
const stylesPart = await src.readMember('styles.xml');
|
|
869
|
+
const parsed = parseOdtContentXml(content.toString('utf8'), stylesPart ? stylesPart.toString('utf8') : '', opts);
|
|
870
|
+
const meta = await src.readMember('meta.xml');
|
|
871
|
+
if (meta) {
|
|
872
|
+
const xml = meta.toString('utf8');
|
|
873
|
+
const title = /<dc:title\b[^>]*>([\s\S]*?)<\/dc:title>/.exec(xml)?.[1];
|
|
874
|
+
const author = /<(?:dc:creator|meta:initial-creator)\b[^>]*>([\s\S]*?)<\/(?:dc:creator|meta:initial-creator)>/.exec(xml)?.[1];
|
|
875
|
+
if (title)
|
|
876
|
+
parsed.title = decodeXmlEntities(title).trim() || undefined;
|
|
877
|
+
if (author)
|
|
878
|
+
parsed.author = decodeXmlEntities(author).trim() || undefined;
|
|
879
|
+
}
|
|
880
|
+
return parsed;
|
|
881
|
+
}
|
|
882
|
+
export function parseOdtBuffer(buf, opts = {}) {
|
|
883
|
+
return parseOdtSource(zipSourceFromBuffer(buf), opts);
|
|
884
|
+
}
|
|
885
|
+
// ── rtf ─────────────────────────────────────────────────────────────────────
|
|
886
|
+
/** Minimal RTF reader: paragraphs with bold/italic/underline, unicode escapes,
|
|
887
|
+
* skipping the destinations that carry metadata or binary payloads. */
|
|
888
|
+
export function parseRtf(text, opts = {}) {
|
|
889
|
+
const maxBlocks = clampBlocks(opts);
|
|
890
|
+
const blocks = [];
|
|
891
|
+
let truncated = false;
|
|
892
|
+
let paraRuns = [];
|
|
893
|
+
let buf = '';
|
|
894
|
+
const stack = [];
|
|
895
|
+
let st = { bold: false, italic: false, underline: false, skip: false, ucSkip: 1 };
|
|
896
|
+
let blockCount = 0;
|
|
897
|
+
const flushRun = () => {
|
|
898
|
+
if (buf === '' || st.skip) {
|
|
899
|
+
buf = '';
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
const run = { text: buf };
|
|
903
|
+
if (st.bold)
|
|
904
|
+
run.bold = true;
|
|
905
|
+
if (st.italic)
|
|
906
|
+
run.italic = true;
|
|
907
|
+
if (st.underline)
|
|
908
|
+
run.underline = true;
|
|
909
|
+
paraRuns.push(run);
|
|
910
|
+
buf = '';
|
|
911
|
+
};
|
|
912
|
+
const flushPara = () => {
|
|
913
|
+
flushRun();
|
|
914
|
+
blockCount++;
|
|
915
|
+
if (paraRuns.length > 0) {
|
|
916
|
+
if (blocks.length >= maxBlocks)
|
|
917
|
+
truncated = true;
|
|
918
|
+
else
|
|
919
|
+
blocks.push({ type: 'paragraph', runs: paraRuns });
|
|
920
|
+
}
|
|
921
|
+
paraRuns = [];
|
|
922
|
+
};
|
|
923
|
+
// Destinations whose content must not be emitted as text.
|
|
924
|
+
const SKIP_DESTS = /^(fonttbl|colortbl|stylesheet|info|pict|object|themedata|colorschememapping|latentstyles|datastore|generator|xmlnstbl|listtable|listoverridetable|rsidtbl|mmath|filetbl|shppict|nonshppict|header|footer|headerl|headerr|footerl|footerr|footnote|annotation|comment|field|fldinst|bkmkstart|bkmkend|do)$/;
|
|
925
|
+
for (let i = 0; i < text.length; i++) {
|
|
926
|
+
const ch = text[i];
|
|
927
|
+
if (ch === '{') {
|
|
928
|
+
stack.push({ ...st });
|
|
929
|
+
continue;
|
|
930
|
+
}
|
|
931
|
+
if (ch === '}') {
|
|
932
|
+
flushRun();
|
|
933
|
+
const prev = stack.pop();
|
|
934
|
+
if (prev)
|
|
935
|
+
st = prev;
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
938
|
+
if (ch === '\\') {
|
|
939
|
+
const next = text[i + 1];
|
|
940
|
+
if (next === '\\' || next === '{' || next === '}') {
|
|
941
|
+
buf += next;
|
|
942
|
+
i++;
|
|
943
|
+
continue;
|
|
944
|
+
}
|
|
945
|
+
// `{\*\destination …}` — an extension destination: never document text.
|
|
946
|
+
if (next === '*') {
|
|
947
|
+
flushRun();
|
|
948
|
+
st.skip = true;
|
|
949
|
+
i++;
|
|
950
|
+
continue;
|
|
951
|
+
}
|
|
952
|
+
if (next === '\n' || next === '\r') {
|
|
953
|
+
flushPara();
|
|
954
|
+
i++;
|
|
955
|
+
continue;
|
|
956
|
+
}
|
|
957
|
+
if (next === '~') {
|
|
958
|
+
buf += ' ';
|
|
959
|
+
i++;
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
962
|
+
if (next === '-' || next === '_') {
|
|
963
|
+
i++;
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
if (next === "'") {
|
|
967
|
+
const hex = text.slice(i + 2, i + 4);
|
|
968
|
+
const code = parseInt(hex, 16);
|
|
969
|
+
if (Number.isFinite(code))
|
|
970
|
+
buf += Buffer.from([code]).toString('latin1');
|
|
971
|
+
i += 3;
|
|
972
|
+
continue;
|
|
973
|
+
}
|
|
974
|
+
// Control word: \word[-]?digits, optional trailing space.
|
|
975
|
+
const cw = /^\\([a-zA-Z]+)(-?\d+)? ?/.exec(text.slice(i));
|
|
976
|
+
if (!cw) {
|
|
977
|
+
i++;
|
|
978
|
+
continue;
|
|
979
|
+
}
|
|
980
|
+
const word = cw[1];
|
|
981
|
+
const param = cw[2] !== undefined ? parseInt(cw[2], 10) : undefined;
|
|
982
|
+
i += cw[0].length - 1;
|
|
983
|
+
switch (word) {
|
|
984
|
+
case 'par':
|
|
985
|
+
case 'line':
|
|
986
|
+
flushPara();
|
|
987
|
+
break;
|
|
988
|
+
case 'cell':
|
|
989
|
+
flushRun();
|
|
990
|
+
buf = '\t';
|
|
991
|
+
break;
|
|
992
|
+
case 'row':
|
|
993
|
+
flushPara();
|
|
994
|
+
break;
|
|
995
|
+
case 'b':
|
|
996
|
+
flushRun();
|
|
997
|
+
st.bold = param !== 0;
|
|
998
|
+
break;
|
|
999
|
+
case 'i':
|
|
1000
|
+
flushRun();
|
|
1001
|
+
st.italic = param !== 0;
|
|
1002
|
+
break;
|
|
1003
|
+
case 'ul':
|
|
1004
|
+
flushRun();
|
|
1005
|
+
st.underline = param !== 0;
|
|
1006
|
+
break;
|
|
1007
|
+
case 'ulnone':
|
|
1008
|
+
flushRun();
|
|
1009
|
+
st.underline = false;
|
|
1010
|
+
break;
|
|
1011
|
+
case 'plain':
|
|
1012
|
+
flushRun();
|
|
1013
|
+
st.bold = st.italic = st.underline = false;
|
|
1014
|
+
break;
|
|
1015
|
+
case 'uc':
|
|
1016
|
+
st.ucSkip = param ?? 1;
|
|
1017
|
+
break;
|
|
1018
|
+
case 'u': {
|
|
1019
|
+
if (param !== undefined)
|
|
1020
|
+
buf += String.fromCharCode(param < 0 ? param + 65536 : param);
|
|
1021
|
+
// The next `ucSkip` "characters" are the ANSI fallback for readers
|
|
1022
|
+
// that can't do unicode. A fallback unit is either a literal char or
|
|
1023
|
+
// a `\'xx` hex escape (LibreOffice writes `\u237\'ed`) — skipping
|
|
1024
|
+
// only literals left the escape behind as a duplicate letter.
|
|
1025
|
+
let skipped = 0;
|
|
1026
|
+
while (skipped < st.ucSkip && i + 1 < text.length) {
|
|
1027
|
+
if (text[i + 1] === '\\' && text[i + 2] === "'") {
|
|
1028
|
+
i += 4;
|
|
1029
|
+
skipped++;
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
const c = text[i + 1];
|
|
1033
|
+
if (c === '\\' || c === '{' || c === '}')
|
|
1034
|
+
break;
|
|
1035
|
+
i++;
|
|
1036
|
+
skipped++;
|
|
1037
|
+
}
|
|
1038
|
+
break;
|
|
1039
|
+
}
|
|
1040
|
+
case 'tab':
|
|
1041
|
+
buf += '\t';
|
|
1042
|
+
break;
|
|
1043
|
+
case 'emdash':
|
|
1044
|
+
buf += '—';
|
|
1045
|
+
break;
|
|
1046
|
+
case 'endash':
|
|
1047
|
+
buf += '–';
|
|
1048
|
+
break;
|
|
1049
|
+
case 'lquote':
|
|
1050
|
+
buf += '‘';
|
|
1051
|
+
break;
|
|
1052
|
+
case 'rquote':
|
|
1053
|
+
buf += '’';
|
|
1054
|
+
break;
|
|
1055
|
+
case 'ldblquote':
|
|
1056
|
+
buf += '“';
|
|
1057
|
+
break;
|
|
1058
|
+
case 'rdblquote':
|
|
1059
|
+
buf += '”';
|
|
1060
|
+
break;
|
|
1061
|
+
case 'bullet':
|
|
1062
|
+
buf += '•';
|
|
1063
|
+
break;
|
|
1064
|
+
default:
|
|
1065
|
+
if (SKIP_DESTS.test(word)) {
|
|
1066
|
+
flushRun();
|
|
1067
|
+
st.skip = true;
|
|
1068
|
+
}
|
|
1069
|
+
break;
|
|
1070
|
+
}
|
|
1071
|
+
continue;
|
|
1072
|
+
}
|
|
1073
|
+
if (ch === '\n' || ch === '\r')
|
|
1074
|
+
continue;
|
|
1075
|
+
buf += ch;
|
|
1076
|
+
}
|
|
1077
|
+
flushPara();
|
|
1078
|
+
return { format: 'rtf', blocks, wordCount: countWords(blocks), blockCount: Math.max(blockCount, blocks.length), truncated, plainTextOnly: true };
|
|
1079
|
+
}
|
|
1080
|
+
export function sniffDocumentHead(head) {
|
|
1081
|
+
if (isCfbBuffer(head))
|
|
1082
|
+
return 'cfb';
|
|
1083
|
+
if (head.length >= 4 && head[0] === 0x50 && head[1] === 0x4b && head[2] === 0x03 && head[3] === 0x04)
|
|
1084
|
+
return 'zip';
|
|
1085
|
+
const text = decodeTextBuffer(head.subarray(0, 512)).trimStart();
|
|
1086
|
+
if (text.startsWith('{\\rtf'))
|
|
1087
|
+
return 'rtf';
|
|
1088
|
+
if (text.startsWith('<?xml'))
|
|
1089
|
+
return 'xml';
|
|
1090
|
+
return 'text';
|
|
1091
|
+
}
|
|
1092
|
+
async function parseZipDocument(src, opts) {
|
|
1093
|
+
if (src.members.has('word/document.xml'))
|
|
1094
|
+
return parseDocxSource(src, opts);
|
|
1095
|
+
if (src.members.has('content.xml'))
|
|
1096
|
+
return parseOdtSource(src, opts);
|
|
1097
|
+
throw new Error('zip container is neither a Word document nor an OpenDocument text file');
|
|
1098
|
+
}
|
|
1099
|
+
/** Read a document file from disk. Throws `UnsupportedDocumentError` for
|
|
1100
|
+
* recognized-but-unparseable formats and plain Errors for corrupt files. */
|
|
1101
|
+
export async function readDocument(filePath, opts = {}) {
|
|
1102
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
1103
|
+
if (detectDocumentKind(filePath) === null)
|
|
1104
|
+
throw new UnsupportedDocumentError(`Not a document: ${ext || 'no extension'}`, ext);
|
|
1105
|
+
const fd = await fs.promises.open(filePath, 'r');
|
|
1106
|
+
try {
|
|
1107
|
+
const { size } = await fd.stat();
|
|
1108
|
+
const headLen = Math.min(size, 4096);
|
|
1109
|
+
const head = Buffer.alloc(headLen);
|
|
1110
|
+
await fd.read(head, 0, headLen, 0);
|
|
1111
|
+
const sniffed = sniffDocumentHead(head);
|
|
1112
|
+
if (sniffed === 'zip') {
|
|
1113
|
+
// Only the parts needed are inflated; the file is never read whole.
|
|
1114
|
+
return await parseZipDocument(await zipSourceFromFd(fd, size), opts);
|
|
1115
|
+
}
|
|
1116
|
+
if (size > DOCUMENT_MAX_FILE_BYTES) {
|
|
1117
|
+
throw new Error(`File too large to preview (${Math.round(size / 1024 / 1024)} MB > ${DOCUMENT_MAX_FILE_BYTES / 1024 / 1024} MB)`);
|
|
1118
|
+
}
|
|
1119
|
+
const buf = Buffer.alloc(size);
|
|
1120
|
+
let off = 0;
|
|
1121
|
+
while (off < size) {
|
|
1122
|
+
const { bytesRead } = await fd.read(buf, off, size - off, off);
|
|
1123
|
+
if (bytesRead <= 0)
|
|
1124
|
+
break;
|
|
1125
|
+
off += bytesRead;
|
|
1126
|
+
}
|
|
1127
|
+
if (sniffed === 'cfb')
|
|
1128
|
+
return parseDocBuffer(buf, clampBlocks(opts));
|
|
1129
|
+
if (sniffed === 'rtf')
|
|
1130
|
+
return parseRtf(decodeTextBuffer(buf), opts);
|
|
1131
|
+
if (sniffed === 'xml') {
|
|
1132
|
+
// Flat ODF (.fodt) keeps content and styles in one file.
|
|
1133
|
+
const xml = decodeTextBuffer(buf);
|
|
1134
|
+
if (/<office:document\b/.test(xml))
|
|
1135
|
+
return parseOdtContentXml(xml, xml, opts);
|
|
1136
|
+
}
|
|
1137
|
+
throw new UnsupportedDocumentError(`This file does not look like a document (its first bytes are neither a zip/OOXML container, an OLE2 Word file, nor RTF).`, ext);
|
|
1138
|
+
}
|
|
1139
|
+
finally {
|
|
1140
|
+
await fd.close();
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
/** Parse from memory (tests, embedded payloads). */
|
|
1144
|
+
export async function parseDocumentBuffer(buf, filePath, opts = {}) {
|
|
1145
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
1146
|
+
const sniffed = sniffDocumentHead(buf.subarray(0, 4096));
|
|
1147
|
+
if (sniffed === 'zip')
|
|
1148
|
+
return parseZipDocument(zipSourceFromBuffer(buf), opts);
|
|
1149
|
+
if (sniffed === 'cfb')
|
|
1150
|
+
return parseDocBuffer(buf, clampBlocks(opts));
|
|
1151
|
+
if (sniffed === 'rtf')
|
|
1152
|
+
return parseRtf(decodeTextBuffer(buf), opts);
|
|
1153
|
+
if (sniffed === 'xml') {
|
|
1154
|
+
const xml = decodeTextBuffer(buf);
|
|
1155
|
+
if (/<office:document\b/.test(xml))
|
|
1156
|
+
return parseOdtContentXml(xml, xml, opts);
|
|
1157
|
+
}
|
|
1158
|
+
throw new UnsupportedDocumentError('This file does not look like a document.', ext);
|
|
1159
|
+
}
|