ai-document-parser 0.1.101 → 0.1.102
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 +3 -2
- package/dist/convert/blocks.d.ts +14 -0
- package/dist/convert/blocks.d.ts.map +1 -0
- package/dist/convert/blocks.js +254 -0
- package/dist/convert/blocks.js.map +1 -0
- package/dist/convert/converter.d.ts.map +1 -1
- package/dist/convert/converter.js +84 -62
- package/dist/convert/converter.js.map +1 -1
- package/dist/convert/emit.d.ts +1 -0
- package/dist/convert/emit.d.ts.map +1 -1
- package/dist/convert/emit.js +32 -33
- package/dist/convert/emit.js.map +1 -1
- package/dist/edit/docx-ooxml.d.ts.map +1 -1
- package/dist/edit/docx-ooxml.js +5 -33
- package/dist/edit/docx-ooxml.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/parse/docx-blocks.d.ts +18 -0
- package/dist/parse/docx-blocks.d.ts.map +1 -0
- package/dist/parse/docx-blocks.js +654 -0
- package/dist/parse/docx-blocks.js.map +1 -0
- package/dist/parse/html-blocks.d.ts +14 -0
- package/dist/parse/html-blocks.d.ts.map +1 -0
- package/dist/parse/html-blocks.js +244 -0
- package/dist/parse/html-blocks.js.map +1 -0
- package/dist/parse/pptx.d.ts +0 -1
- package/dist/parse/pptx.d.ts.map +1 -1
- package/dist/parse/pptx.js +95 -24
- package/dist/parse/pptx.js.map +1 -1
- package/dist/parse/router.d.ts.map +1 -1
- package/dist/parse/router.js +25 -6
- package/dist/parse/router.js.map +1 -1
- package/dist/types.d.ts +37 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
import { DocumentError } from '../utils/errors.js';
|
|
2
|
+
import { flattenBlocksToParagraphs } from '../convert/blocks.js';
|
|
3
|
+
function decodeXmlEntities(s) {
|
|
4
|
+
return s
|
|
5
|
+
.replace(/</g, '<')
|
|
6
|
+
.replace(/>/g, '>')
|
|
7
|
+
.replace(/"/g, '"')
|
|
8
|
+
.replace(/'/g, "'")
|
|
9
|
+
.replace(/&/g, '&');
|
|
10
|
+
}
|
|
11
|
+
function attr(el, name) {
|
|
12
|
+
const re = new RegExp(`${name}="([^"]*)"`, 'i');
|
|
13
|
+
const m = re.exec(el);
|
|
14
|
+
if (m)
|
|
15
|
+
return m[1];
|
|
16
|
+
const re2 = new RegExp(`${name}='([^']*)'`, 'i');
|
|
17
|
+
return re2.exec(el)?.[1];
|
|
18
|
+
}
|
|
19
|
+
function extractBodyInner(xml) {
|
|
20
|
+
const m = /<w:body[^>]*>([\s\S]*)<\/w:body>/i.exec(xml);
|
|
21
|
+
return m ? m[1] : xml;
|
|
22
|
+
}
|
|
23
|
+
/** Find matching close for a start tag at `start`, handling nesting of the same local name. */
|
|
24
|
+
function findBalancedEnd(xml, start, localName) {
|
|
25
|
+
const openRe = new RegExp(`<w:${localName}\\b`, 'g');
|
|
26
|
+
const closeRe = new RegExp(`</w:${localName}>`, 'g');
|
|
27
|
+
openRe.lastIndex = start;
|
|
28
|
+
const first = openRe.exec(xml);
|
|
29
|
+
if (!first || first.index !== start)
|
|
30
|
+
return -1;
|
|
31
|
+
// Self-closing empty element: <w:p .../>
|
|
32
|
+
const gt = xml.indexOf('>', first.index);
|
|
33
|
+
if (gt < 0)
|
|
34
|
+
return -1;
|
|
35
|
+
if (xml.charAt(gt - 1) === '/')
|
|
36
|
+
return gt + 1;
|
|
37
|
+
let depth = 1;
|
|
38
|
+
let i = gt + 1;
|
|
39
|
+
while (depth > 0 && i < xml.length) {
|
|
40
|
+
openRe.lastIndex = i;
|
|
41
|
+
closeRe.lastIndex = i;
|
|
42
|
+
const nextOpen = openRe.exec(xml);
|
|
43
|
+
const nextClose = closeRe.exec(xml);
|
|
44
|
+
if (!nextClose)
|
|
45
|
+
return -1;
|
|
46
|
+
if (nextOpen && nextOpen.index < nextClose.index) {
|
|
47
|
+
const openGt = xml.indexOf('>', nextOpen.index);
|
|
48
|
+
if (openGt < 0)
|
|
49
|
+
return -1;
|
|
50
|
+
// Nested self-closing does not increase depth.
|
|
51
|
+
if (xml.charAt(openGt - 1) === '/') {
|
|
52
|
+
i = openGt + 1;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
depth++;
|
|
56
|
+
i = openGt + 1;
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
depth--;
|
|
60
|
+
i = nextClose.index + nextClose[0].length;
|
|
61
|
+
if (depth === 0)
|
|
62
|
+
return i;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return -1;
|
|
66
|
+
}
|
|
67
|
+
function iterBodyParts(body) {
|
|
68
|
+
const parts = [];
|
|
69
|
+
let i = 0;
|
|
70
|
+
while (i < body.length) {
|
|
71
|
+
const tblAt = body.indexOf('<w:tbl', i);
|
|
72
|
+
const pAt = body.indexOf('<w:p', i);
|
|
73
|
+
if (tblAt < 0 && pAt < 0)
|
|
74
|
+
break;
|
|
75
|
+
let kind;
|
|
76
|
+
let start;
|
|
77
|
+
if (tblAt >= 0 && (pAt < 0 || tblAt < pAt)) {
|
|
78
|
+
// '<w:tbl' must be followed by space, /, or > (not tblGrid / tblPr…)
|
|
79
|
+
const after = body.charAt(tblAt + 6);
|
|
80
|
+
if (after && !/[\s/>]/.test(after)) {
|
|
81
|
+
i = tblAt + 6;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
kind = 'tbl';
|
|
85
|
+
start = tblAt;
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
const after = body.charAt(pAt + 4);
|
|
89
|
+
if (after && !/[\s/>]/.test(after)) {
|
|
90
|
+
i = pAt + 4;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
kind = 'p';
|
|
94
|
+
start = pAt;
|
|
95
|
+
}
|
|
96
|
+
const end = findBalancedEnd(body, start, kind);
|
|
97
|
+
if (end < 0) {
|
|
98
|
+
// Skip malformed tag instead of aborting the rest of the document.
|
|
99
|
+
i = start + 1;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
parts.push({ kind, xml: body.slice(start, end) });
|
|
103
|
+
i = end;
|
|
104
|
+
}
|
|
105
|
+
return parts;
|
|
106
|
+
}
|
|
107
|
+
function parseRels(relsXml) {
|
|
108
|
+
const map = new Map();
|
|
109
|
+
if (!relsXml)
|
|
110
|
+
return map;
|
|
111
|
+
const re = /<Relationship\b[^>]*>/g;
|
|
112
|
+
let m;
|
|
113
|
+
while ((m = re.exec(relsXml)) !== null) {
|
|
114
|
+
const el = m[0];
|
|
115
|
+
const id = attr(el, 'Id');
|
|
116
|
+
const target = attr(el, 'Target');
|
|
117
|
+
if (id && target)
|
|
118
|
+
map.set(id, target);
|
|
119
|
+
}
|
|
120
|
+
return map;
|
|
121
|
+
}
|
|
122
|
+
function parseCharStyles(stylesXml) {
|
|
123
|
+
const map = new Map();
|
|
124
|
+
if (!stylesXml)
|
|
125
|
+
return map;
|
|
126
|
+
const styleRe = /<w:style\b[^>]*>[\s\S]*?<\/w:style>/g;
|
|
127
|
+
let sm;
|
|
128
|
+
while ((sm = styleRe.exec(stylesXml)) !== null) {
|
|
129
|
+
const block = sm[0];
|
|
130
|
+
const open = block.match(/<w:style\b[^>]*>/)?.[0] ?? '';
|
|
131
|
+
const type = attr(open, 'w:type');
|
|
132
|
+
if (type && type !== 'character' && type !== 'paragraph') {
|
|
133
|
+
// still allow character + paragraph for rPr/pPr basedOn chains
|
|
134
|
+
}
|
|
135
|
+
const styleId = attr(open, 'w:styleId') ??
|
|
136
|
+
/<w:name\b[^>]*w:val="([^"]+)"/.exec(block)?.[1];
|
|
137
|
+
if (!styleId)
|
|
138
|
+
continue;
|
|
139
|
+
const rPr = /<w:rPr\b[^>]*>[\s\S]*?<\/w:rPr>/.exec(block)?.[0] ?? '';
|
|
140
|
+
const flags = {};
|
|
141
|
+
if (isOnOffEnabled(rPr, 'b') || isOnOffEnabled(rPr, 'bCs'))
|
|
142
|
+
flags.bold = true;
|
|
143
|
+
if (isOnOffEnabled(rPr, 'i') || isOnOffEnabled(rPr, 'iCs'))
|
|
144
|
+
flags.italic = true;
|
|
145
|
+
if (isOnOffEnabled(rPr, 'strike') || isOnOffEnabled(rPr, 'dstrike')) {
|
|
146
|
+
flags.strike = true;
|
|
147
|
+
}
|
|
148
|
+
// Well-known Word character style names even if rPr empty in basedOn leaf
|
|
149
|
+
if (/^Strong$/i.test(styleId))
|
|
150
|
+
flags.bold = true;
|
|
151
|
+
if (/^(Emphasis|SubtleEmphasis|IntenseEmphasis)$/i.test(styleId))
|
|
152
|
+
flags.italic = true;
|
|
153
|
+
if (/IntenseEmphasis/i.test(styleId))
|
|
154
|
+
flags.bold = true;
|
|
155
|
+
if (flags.bold || flags.italic || flags.strike)
|
|
156
|
+
map.set(styleId, flags);
|
|
157
|
+
}
|
|
158
|
+
// Resolve basedOn once
|
|
159
|
+
const basedOn = new Map();
|
|
160
|
+
const styleRe2 = /<w:style\b[^>]*>[\s\S]*?<\/w:style>/g;
|
|
161
|
+
let sm2;
|
|
162
|
+
while ((sm2 = styleRe2.exec(stylesXml)) !== null) {
|
|
163
|
+
const block = sm2[0];
|
|
164
|
+
const open = block.match(/<w:style\b[^>]*>/)?.[0] ?? '';
|
|
165
|
+
const styleId = attr(open, 'w:styleId');
|
|
166
|
+
const base = /<w:basedOn\b[^>]*w:val="([^"]+)"/.exec(block)?.[1];
|
|
167
|
+
if (styleId && base)
|
|
168
|
+
basedOn.set(styleId, base);
|
|
169
|
+
}
|
|
170
|
+
const resolve = (id, seen = new Set()) => {
|
|
171
|
+
if (seen.has(id))
|
|
172
|
+
return map.get(id) ?? {};
|
|
173
|
+
seen.add(id);
|
|
174
|
+
const own = map.get(id) ?? {};
|
|
175
|
+
const parentId = basedOn.get(id);
|
|
176
|
+
if (!parentId)
|
|
177
|
+
return own;
|
|
178
|
+
const parent = resolve(parentId, seen);
|
|
179
|
+
return {
|
|
180
|
+
bold: own.bold || parent.bold,
|
|
181
|
+
italic: own.italic || parent.italic,
|
|
182
|
+
strike: own.strike || parent.strike,
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
for (const id of new Set([...map.keys(), ...basedOn.keys()])) {
|
|
186
|
+
const resolved = resolve(id);
|
|
187
|
+
if (resolved.bold || resolved.italic || resolved.strike)
|
|
188
|
+
map.set(id, resolved);
|
|
189
|
+
}
|
|
190
|
+
return map;
|
|
191
|
+
}
|
|
192
|
+
function parseNumbering(numberingXml) {
|
|
193
|
+
const numToAbstract = new Map();
|
|
194
|
+
const abstractLevels = new Map();
|
|
195
|
+
if (!numberingXml)
|
|
196
|
+
return { numToAbstract, abstractLevels };
|
|
197
|
+
const absRe = /<w:abstractNum\b[^>]*>[\s\S]*?<\/w:abstractNum>/g;
|
|
198
|
+
let am;
|
|
199
|
+
while ((am = absRe.exec(numberingXml)) !== null) {
|
|
200
|
+
const block = am[0];
|
|
201
|
+
const open = block.match(/<w:abstractNum\b[^>]*>/)?.[0] ?? '';
|
|
202
|
+
const absId = attr(open, 'w:abstractNumId');
|
|
203
|
+
if (absId == null)
|
|
204
|
+
continue;
|
|
205
|
+
const levels = new Map();
|
|
206
|
+
const lvlRe = /<w:lvl\b[^>]*>[\s\S]*?<\/w:lvl>/g;
|
|
207
|
+
let lm;
|
|
208
|
+
while ((lm = lvlRe.exec(block)) !== null) {
|
|
209
|
+
const lvlBlock = lm[0];
|
|
210
|
+
const lvlOpen = lvlBlock.match(/<w:lvl\b[^>]*>/)?.[0] ?? '';
|
|
211
|
+
const ilvl = Number(attr(lvlOpen, 'w:ilvl') ?? '0');
|
|
212
|
+
const fmt = /<w:numFmt\b[^>]*w:val="([^"]+)"/.exec(lvlBlock)?.[1] ??
|
|
213
|
+
/<w:numFmt\b[^>]*w:val='([^']+)'/.exec(lvlBlock)?.[1] ??
|
|
214
|
+
'bullet';
|
|
215
|
+
levels.set(ilvl, fmt);
|
|
216
|
+
}
|
|
217
|
+
abstractLevels.set(absId, levels);
|
|
218
|
+
}
|
|
219
|
+
const numRe = /<w:num\b[^>]*>[\s\S]*?<\/w:num>/g;
|
|
220
|
+
let nm;
|
|
221
|
+
while ((nm = numRe.exec(numberingXml)) !== null) {
|
|
222
|
+
const block = nm[0];
|
|
223
|
+
const open = block.match(/<w:num\b[^>]*>/)?.[0] ?? '';
|
|
224
|
+
const numId = attr(open, 'w:numId');
|
|
225
|
+
const absId = /<w:abstractNumId\b[^>]*w:val="([^"]+)"/.exec(block)?.[1] ??
|
|
226
|
+
/<w:abstractNumId\b[^>]*w:val='([^']+)'/.exec(block)?.[1];
|
|
227
|
+
if (numId != null && absId != null)
|
|
228
|
+
numToAbstract.set(numId, absId);
|
|
229
|
+
}
|
|
230
|
+
return { numToAbstract, abstractLevels };
|
|
231
|
+
}
|
|
232
|
+
function isOrderedList(paraXml, numbering) {
|
|
233
|
+
const numId = /<w:numId\b[^>]*w:val="([^"]+)"/.exec(paraXml)?.[1] ??
|
|
234
|
+
/<w:numId\b[^>]*w:val='([^']+)'/.exec(paraXml)?.[1];
|
|
235
|
+
if (numId == null)
|
|
236
|
+
return false;
|
|
237
|
+
const absId = numbering.numToAbstract.get(numId);
|
|
238
|
+
if (absId == null)
|
|
239
|
+
return false;
|
|
240
|
+
const ilvl = listLevel(paraXml);
|
|
241
|
+
const fmt = numbering.abstractLevels.get(absId)?.get(ilvl) ?? 'bullet';
|
|
242
|
+
return fmt !== 'bullet' && fmt !== 'none';
|
|
243
|
+
}
|
|
244
|
+
function extractRunTexts(inner) {
|
|
245
|
+
const texts = [];
|
|
246
|
+
// Preserve tabs (TOC page-number separator) and text runs in document order.
|
|
247
|
+
const tokenRe = /<w:tab\b[^/]*\/>|<w:tab\b[^>]*>\s*<\/w:tab>|<w:t(?:\s[^>]*)?>([^<]*)<\/w:t>/g;
|
|
248
|
+
let tm;
|
|
249
|
+
while ((tm = tokenRe.exec(inner)) !== null) {
|
|
250
|
+
if (tm[0].startsWith('<w:tab'))
|
|
251
|
+
texts.push('\t');
|
|
252
|
+
else
|
|
253
|
+
texts.push(decodeXmlEntities(tm[1] ?? ''));
|
|
254
|
+
}
|
|
255
|
+
return texts.join('');
|
|
256
|
+
}
|
|
257
|
+
function isOnOffEnabled(rPr, tag) {
|
|
258
|
+
// Word may disable with w:val="0" / "false" even when the element is present.
|
|
259
|
+
const re = new RegExp(`<w:${tag}\\b([^>]*)>`, 'i');
|
|
260
|
+
const m = re.exec(rPr);
|
|
261
|
+
if (!m)
|
|
262
|
+
return false;
|
|
263
|
+
const attrs = m[1];
|
|
264
|
+
const val = /w:val="([^"]*)"/i.exec(attrs)?.[1] ?? /w:val='([^']*)'/i.exec(attrs)?.[1];
|
|
265
|
+
if (val != null) {
|
|
266
|
+
const v = val.toLowerCase();
|
|
267
|
+
return v !== '0' && v !== 'false' && v !== 'off';
|
|
268
|
+
}
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
function flagsFromRPr(inner, charStyles) {
|
|
272
|
+
const rPr = /<w:rPr\b[^>]*>[\s\S]*?<\/w:rPr>/.exec(inner)?.[0] ?? '';
|
|
273
|
+
const styleId = /<w:rStyle\b[^>]*w:val="([^"]+)"/.exec(rPr)?.[1] ??
|
|
274
|
+
/<w:rStyle\b[^>]*w:val='([^']+)'/.exec(rPr)?.[1];
|
|
275
|
+
const fromStyle = styleId ? charStyles.get(styleId) ?? {} : {};
|
|
276
|
+
const bold = fromStyle.bold || isOnOffEnabled(rPr, 'b') || isOnOffEnabled(rPr, 'bCs');
|
|
277
|
+
const italic = fromStyle.italic || isOnOffEnabled(rPr, 'i') || isOnOffEnabled(rPr, 'iCs');
|
|
278
|
+
const strike = fromStyle.strike ||
|
|
279
|
+
isOnOffEnabled(rPr, 'strike') ||
|
|
280
|
+
isOnOffEnabled(rPr, 'dstrike');
|
|
281
|
+
return {
|
|
282
|
+
...(bold ? { bold: true } : {}),
|
|
283
|
+
...(italic ? { italic: true } : {}),
|
|
284
|
+
...(strike ? { strike: true } : {}),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
function parseRunElement(runXml, charStyles, href) {
|
|
288
|
+
// Footnote / endnote references inside a run
|
|
289
|
+
const fn = /<w:footnoteReference\b[^>]*w:id="([^"]+)"/.exec(runXml)?.[1] ??
|
|
290
|
+
/<w:footnoteReference\b[^>]*w:id='([^']+)'/.exec(runXml)?.[1];
|
|
291
|
+
if (fn != null && fn !== '0' && fn !== '-1') {
|
|
292
|
+
return { text: `[^fn${fn}]` };
|
|
293
|
+
}
|
|
294
|
+
const en = /<w:endnoteReference\b[^>]*w:id="([^"]+)"/.exec(runXml)?.[1] ??
|
|
295
|
+
/<w:endnoteReference\b[^>]*w:id='([^']+)'/.exec(runXml)?.[1];
|
|
296
|
+
if (en != null && en !== '0' && en !== '-1') {
|
|
297
|
+
return { text: `[^en${en}]` };
|
|
298
|
+
}
|
|
299
|
+
const text = extractRunTexts(runXml);
|
|
300
|
+
if (!text)
|
|
301
|
+
return null;
|
|
302
|
+
const flags = flagsFromRPr(runXml, charStyles);
|
|
303
|
+
return {
|
|
304
|
+
text,
|
|
305
|
+
...flags,
|
|
306
|
+
...(href ? { href } : {}),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Walk paragraph children in order: hyperlinks and runs (skip nested runs already inside hyperlink).
|
|
311
|
+
*/
|
|
312
|
+
function parseParagraphRuns(paraXml, charStyles, rels) {
|
|
313
|
+
const runs = [];
|
|
314
|
+
// Strip pPr to avoid matching inside props
|
|
315
|
+
const withoutPPr = paraXml.replace(/<w:pPr\b[^>]*>[\s\S]*?<\/w:pPr>/, '');
|
|
316
|
+
const tokenRe = /<w:hyperlink\b[^>]*>[\s\S]*?<\/w:hyperlink>|<w:r\b[^>]*>[\s\S]*?<\/w:r>/g;
|
|
317
|
+
let m;
|
|
318
|
+
while ((m = tokenRe.exec(withoutPPr)) !== null) {
|
|
319
|
+
const chunk = m[0];
|
|
320
|
+
if (chunk.startsWith('<w:hyperlink')) {
|
|
321
|
+
const open = chunk.match(/<w:hyperlink\b[^>]*>/)?.[0] ?? '';
|
|
322
|
+
const rId = attr(open, 'r:id');
|
|
323
|
+
const anchor = attr(open, 'w:anchor');
|
|
324
|
+
let href;
|
|
325
|
+
if (rId && rels.has(rId))
|
|
326
|
+
href = rels.get(rId);
|
|
327
|
+
else if (anchor)
|
|
328
|
+
href = `#${anchor}`;
|
|
329
|
+
const innerRunRe = /<w:r\b[^>]*>[\s\S]*?<\/w:r>/g;
|
|
330
|
+
let rm;
|
|
331
|
+
while ((rm = innerRunRe.exec(chunk)) !== null) {
|
|
332
|
+
const run = parseRunElement(rm[0], charStyles, href);
|
|
333
|
+
if (run)
|
|
334
|
+
runs.push(run);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
const run = parseRunElement(chunk, charStyles);
|
|
339
|
+
if (run)
|
|
340
|
+
runs.push(run);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (runs.length === 0) {
|
|
344
|
+
const text = extractRunTexts(withoutPPr);
|
|
345
|
+
// Also strip tab-only TOC leftovers
|
|
346
|
+
const cleaned = text.replace(/\t/g, ' ').replace(/\s+/g, ' ').trim();
|
|
347
|
+
if (cleaned)
|
|
348
|
+
runs.push({ text: cleaned });
|
|
349
|
+
}
|
|
350
|
+
return runs;
|
|
351
|
+
}
|
|
352
|
+
function paragraphStyleId(paraXml) {
|
|
353
|
+
return (/<w:pStyle\b[^>]*w:val="([^"]+)"/.exec(paraXml)?.[1] ??
|
|
354
|
+
/<w:pStyle\b[^>]*w:val='([^']+)'/.exec(paraXml)?.[1]);
|
|
355
|
+
}
|
|
356
|
+
function headingLevel(paraXml) {
|
|
357
|
+
const style = paragraphStyleId(paraXml);
|
|
358
|
+
if (style) {
|
|
359
|
+
if (/^Title$/i.test(style))
|
|
360
|
+
return 1;
|
|
361
|
+
const hm = /^Heading\s*([1-6])$/i.exec(style) || /^heading\s*([1-6])$/i.exec(style);
|
|
362
|
+
if (hm)
|
|
363
|
+
return Number(hm[1]);
|
|
364
|
+
}
|
|
365
|
+
const outline = /<w:outlineLvl\b[^>]*w:val="(\d+)"/.exec(paraXml);
|
|
366
|
+
if (outline) {
|
|
367
|
+
const lvl = Number(outline[1]) + 1;
|
|
368
|
+
if (lvl >= 1 && lvl <= 6)
|
|
369
|
+
return lvl;
|
|
370
|
+
}
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
function tocLevel(paraXml) {
|
|
374
|
+
const style = paragraphStyleId(paraXml);
|
|
375
|
+
if (!style)
|
|
376
|
+
return null;
|
|
377
|
+
if (/^TOCHeading$/i.test(style))
|
|
378
|
+
return 0;
|
|
379
|
+
const m = /^TOC([1-9])$/i.exec(style);
|
|
380
|
+
if (m)
|
|
381
|
+
return Number(m[1]) - 1;
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
function isListItem(paraXml) {
|
|
385
|
+
return /<w:numPr[\s\S]*?<\/w:numPr>/.test(paraXml) || /<w:numPr\b[^/]*\/>/.test(paraXml);
|
|
386
|
+
}
|
|
387
|
+
function listLevel(paraXml) {
|
|
388
|
+
const m = /<w:ilvl\b[^>]*w:val="(\d+)"/.exec(paraXml);
|
|
389
|
+
return m ? Number(m[1]) : 0;
|
|
390
|
+
}
|
|
391
|
+
function isDropCap(paraXml) {
|
|
392
|
+
return /w:dropCap="/.test(paraXml) || /w:dropCap='/.test(paraXml);
|
|
393
|
+
}
|
|
394
|
+
/** Compact nested table for embedding in a cell (no `|` — keeps parent GFM table stable). */
|
|
395
|
+
function compactTableText(rows) {
|
|
396
|
+
return rows
|
|
397
|
+
.map((r) => r.map((c) => c.replace(/\s+/g, ' ').trim()).filter(Boolean).join(' · '))
|
|
398
|
+
.filter(Boolean)
|
|
399
|
+
.join(' / ');
|
|
400
|
+
}
|
|
401
|
+
function parseTableRows(tblXml) {
|
|
402
|
+
const rows = [];
|
|
403
|
+
// Walk top-level rows only via balanced scan inside tbl
|
|
404
|
+
const inner = tblXml.replace(/^<w:tbl\b[^>]*>/, '').replace(/<\/w:tbl>$/, '');
|
|
405
|
+
let i = 0;
|
|
406
|
+
while (i < inner.length) {
|
|
407
|
+
const trAt = inner.indexOf('<w:tr', i);
|
|
408
|
+
if (trAt < 0)
|
|
409
|
+
break;
|
|
410
|
+
const after = inner.charAt(trAt + 5);
|
|
411
|
+
if (after && !/[\s/>]/.test(after)) {
|
|
412
|
+
i = trAt + 5;
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
const trEnd = findBalancedEnd(inner, trAt, 'tr');
|
|
416
|
+
if (trEnd < 0) {
|
|
417
|
+
i = trAt + 1;
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
const trXml = inner.slice(trAt, trEnd);
|
|
421
|
+
const cells = [];
|
|
422
|
+
let j = 0;
|
|
423
|
+
const trInner = trXml;
|
|
424
|
+
while (j < trInner.length) {
|
|
425
|
+
const tcAt = trInner.indexOf('<w:tc', j);
|
|
426
|
+
if (tcAt < 0)
|
|
427
|
+
break;
|
|
428
|
+
const afterTc = trInner.charAt(tcAt + 5);
|
|
429
|
+
if (afterTc && !/[\s/>]/.test(afterTc)) {
|
|
430
|
+
j = tcAt + 5;
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
const tcEnd = findBalancedEnd(trInner, tcAt, 'tc');
|
|
434
|
+
if (tcEnd < 0) {
|
|
435
|
+
j = tcAt + 1;
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
cells.push(parseCellText(trInner.slice(tcAt, tcEnd)));
|
|
439
|
+
j = tcEnd;
|
|
440
|
+
}
|
|
441
|
+
if (cells.length)
|
|
442
|
+
rows.push(cells);
|
|
443
|
+
i = trEnd;
|
|
444
|
+
}
|
|
445
|
+
return rows;
|
|
446
|
+
}
|
|
447
|
+
function parseCellText(tcXml) {
|
|
448
|
+
const parts = [];
|
|
449
|
+
// Remove tcPr
|
|
450
|
+
let rest = tcXml.replace(/<w:tcPr\b[^>]*>[\s\S]*?<\/w:tcPr>/, '');
|
|
451
|
+
let i = 0;
|
|
452
|
+
while (i < rest.length) {
|
|
453
|
+
const tblAt = rest.indexOf('<w:tbl', i);
|
|
454
|
+
const pAt = rest.indexOf('<w:p', i);
|
|
455
|
+
if (tblAt < 0 && pAt < 0)
|
|
456
|
+
break;
|
|
457
|
+
if (tblAt >= 0 && (pAt < 0 || tblAt < pAt)) {
|
|
458
|
+
const after = rest.charAt(tblAt + 6);
|
|
459
|
+
if (after && !/[\s/>]/.test(after)) {
|
|
460
|
+
i = tblAt + 6;
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
const end = findBalancedEnd(rest, tblAt, 'tbl');
|
|
464
|
+
if (end < 0) {
|
|
465
|
+
i = tblAt + 1;
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
const nestedRows = parseTableRows(rest.slice(tblAt, end));
|
|
469
|
+
const compact = compactTableText(nestedRows);
|
|
470
|
+
if (compact)
|
|
471
|
+
parts.push(compact);
|
|
472
|
+
i = end;
|
|
473
|
+
}
|
|
474
|
+
else {
|
|
475
|
+
const after = rest.charAt(pAt + 4);
|
|
476
|
+
if (after && !/[\s/>]/.test(after)) {
|
|
477
|
+
i = pAt + 4;
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
const end = findBalancedEnd(rest, pAt, 'p');
|
|
481
|
+
if (end < 0) {
|
|
482
|
+
i = pAt + 1;
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
const text = extractRunTexts(rest.slice(pAt, end)).replace(/\t/g, ' ').trim();
|
|
486
|
+
if (text)
|
|
487
|
+
parts.push(text);
|
|
488
|
+
i = end;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return parts.join(' ').trim();
|
|
492
|
+
}
|
|
493
|
+
function parseTable(tblXml) {
|
|
494
|
+
return { type: 'table', rows: parseTableRows(tblXml) };
|
|
495
|
+
}
|
|
496
|
+
function paraToBlock(paraXml, ctx) {
|
|
497
|
+
const runs = parseParagraphRuns(paraXml, ctx.charStyles, ctx.rels);
|
|
498
|
+
// TOC: take text before tab (page number after)
|
|
499
|
+
const tocLvl = tocLevel(paraXml);
|
|
500
|
+
if (tocLvl != null) {
|
|
501
|
+
const plain = runs
|
|
502
|
+
.map((r) => r.text)
|
|
503
|
+
.join('')
|
|
504
|
+
.split('\t')[0]
|
|
505
|
+
.replace(/\s+/g, ' ')
|
|
506
|
+
.trim();
|
|
507
|
+
if (!plain)
|
|
508
|
+
return null;
|
|
509
|
+
if (/^TOCHeading$/i.test(paragraphStyleId(paraXml) ?? '')) {
|
|
510
|
+
return { type: 'heading', level: 2, runs: [{ text: plain }] };
|
|
511
|
+
}
|
|
512
|
+
return {
|
|
513
|
+
type: 'list_item',
|
|
514
|
+
level: tocLvl,
|
|
515
|
+
runs: [{ text: plain }],
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
const plain = runs.map((r) => r.text).join('').trim();
|
|
519
|
+
if (!plain && !isListItem(paraXml))
|
|
520
|
+
return null;
|
|
521
|
+
const level = headingLevel(paraXml);
|
|
522
|
+
if (level) {
|
|
523
|
+
return { type: 'heading', level, runs: runs.length ? runs : [{ text: plain }] };
|
|
524
|
+
}
|
|
525
|
+
if (isListItem(paraXml)) {
|
|
526
|
+
const ordered = isOrderedList(paraXml, ctx.numbering);
|
|
527
|
+
return {
|
|
528
|
+
type: 'list_item',
|
|
529
|
+
level: listLevel(paraXml),
|
|
530
|
+
...(ordered ? { ordered: true } : {}),
|
|
531
|
+
runs: runs.length ? runs : [{ text: plain || ' ' }],
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
if (!plain)
|
|
535
|
+
return null;
|
|
536
|
+
return { type: 'paragraph', runs };
|
|
537
|
+
}
|
|
538
|
+
function parseNotesXml(xml, kind) {
|
|
539
|
+
if (!xml)
|
|
540
|
+
return [];
|
|
541
|
+
const tag = kind === 'footnote' ? 'footnote' : 'endnote';
|
|
542
|
+
// Do not let self-closing separator tags swallow the next real note.
|
|
543
|
+
const re = new RegExp(`<w:${tag}\\b(?![^>]*/>)[^>]*>([\\s\\S]*?)<\\/w:${tag}>`, 'g');
|
|
544
|
+
const out = [];
|
|
545
|
+
let m;
|
|
546
|
+
while ((m = re.exec(xml)) !== null) {
|
|
547
|
+
const open = m[0].slice(0, m[0].indexOf('>') + 1);
|
|
548
|
+
const type = attr(open, 'w:type');
|
|
549
|
+
if (type === 'separator' || type === 'continuationSeparator')
|
|
550
|
+
continue;
|
|
551
|
+
const id = attr(open, 'w:id');
|
|
552
|
+
if (id == null || id === '-1' || id === '0')
|
|
553
|
+
continue;
|
|
554
|
+
const text = extractRunTexts(m[1]).replace(/\s+/g, ' ').trim();
|
|
555
|
+
if (text)
|
|
556
|
+
out.push({ id, text });
|
|
557
|
+
}
|
|
558
|
+
return out;
|
|
559
|
+
}
|
|
560
|
+
/** Parse DOCX package parts into structural blocks. */
|
|
561
|
+
export function parseDocxBlocksFromPackage(parts) {
|
|
562
|
+
const ctx = {
|
|
563
|
+
charStyles: parseCharStyles(parts.stylesXml),
|
|
564
|
+
numbering: parseNumbering(parts.numberingXml),
|
|
565
|
+
rels: parseRels(parts.relsXml),
|
|
566
|
+
};
|
|
567
|
+
const body = extractBodyInner(parts.documentXml);
|
|
568
|
+
const blocks = [];
|
|
569
|
+
let dropCapBuf = '';
|
|
570
|
+
for (const part of iterBodyParts(body)) {
|
|
571
|
+
if (part.kind === 'tbl') {
|
|
572
|
+
dropCapBuf = '';
|
|
573
|
+
const table = parseTable(part.xml);
|
|
574
|
+
if (table.type === 'table' && table.rows.length)
|
|
575
|
+
blocks.push(table);
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
if (isDropCap(part.xml)) {
|
|
579
|
+
const letter = extractRunTexts(part.xml).trim();
|
|
580
|
+
dropCapBuf += letter;
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
const block = paraToBlock(part.xml, ctx);
|
|
584
|
+
if (!block)
|
|
585
|
+
continue;
|
|
586
|
+
if (dropCapBuf && (block.type === 'paragraph' || block.type === 'list_item')) {
|
|
587
|
+
const runs = [...block.runs];
|
|
588
|
+
if (runs.length) {
|
|
589
|
+
runs[0] = { ...runs[0], text: dropCapBuf + runs[0].text };
|
|
590
|
+
}
|
|
591
|
+
else {
|
|
592
|
+
runs.push({ text: dropCapBuf });
|
|
593
|
+
}
|
|
594
|
+
dropCapBuf = '';
|
|
595
|
+
blocks.push({ ...block, runs });
|
|
596
|
+
}
|
|
597
|
+
else {
|
|
598
|
+
dropCapBuf = '';
|
|
599
|
+
blocks.push(block);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
const footnotes = parseNotesXml(parts.footnotesXml, 'footnote');
|
|
603
|
+
const endnotes = parseNotesXml(parts.endnotesXml, 'endnote');
|
|
604
|
+
if (footnotes.length || endnotes.length) {
|
|
605
|
+
for (const fn of footnotes) {
|
|
606
|
+
blocks.push({
|
|
607
|
+
type: 'paragraph',
|
|
608
|
+
runs: [{ text: `[^fn${fn.id}]: ${fn.text}` }],
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
for (const en of endnotes) {
|
|
612
|
+
blocks.push({
|
|
613
|
+
type: 'paragraph',
|
|
614
|
+
runs: [{ text: `[^en${en.id}]: ${en.text}` }],
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return blocks;
|
|
619
|
+
}
|
|
620
|
+
/** Parse word/document.xml only (no styles/numbering/links/notes). */
|
|
621
|
+
export function parseDocxBlocksFromXml(xml) {
|
|
622
|
+
return parseDocxBlocksFromPackage({ documentXml: xml });
|
|
623
|
+
}
|
|
624
|
+
export function docxStructuredFromPackage(parts) {
|
|
625
|
+
const blocks = parseDocxBlocksFromPackage(parts);
|
|
626
|
+
return {
|
|
627
|
+
format: 'docx',
|
|
628
|
+
blocks,
|
|
629
|
+
paragraphs: flattenBlocksToParagraphs(blocks),
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
export function docxStructuredFromXml(xml) {
|
|
633
|
+
return docxStructuredFromPackage({ documentXml: xml });
|
|
634
|
+
}
|
|
635
|
+
export async function docxStructuredFromZip(zip) {
|
|
636
|
+
const docFile = zip.file('word/document.xml');
|
|
637
|
+
if (!docFile) {
|
|
638
|
+
throw new DocumentError('PARSE_FAILED', 'Invalid docx: missing word/document.xml');
|
|
639
|
+
}
|
|
640
|
+
const documentXml = await docFile.async('string');
|
|
641
|
+
const load = async (path) => {
|
|
642
|
+
const f = zip.file(path);
|
|
643
|
+
return f ? f.async('string') : undefined;
|
|
644
|
+
};
|
|
645
|
+
return docxStructuredFromPackage({
|
|
646
|
+
documentXml,
|
|
647
|
+
stylesXml: await load('word/styles.xml'),
|
|
648
|
+
numberingXml: await load('word/numbering.xml'),
|
|
649
|
+
relsXml: await load('word/_rels/document.xml.rels'),
|
|
650
|
+
footnotesXml: await load('word/footnotes.xml'),
|
|
651
|
+
endnotesXml: await load('word/endnotes.xml'),
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
//# sourceMappingURL=docx-blocks.js.map
|