docrev 0.10.2 → 0.11.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/CHANGELOG.md +13 -0
- package/README.md +2 -0
- package/dist/lib/anchor-match.d.ts +3 -1
- package/dist/lib/anchor-match.d.ts.map +1 -1
- package/dist/lib/anchor-match.js +5 -7
- package/dist/lib/anchor-match.js.map +1 -1
- package/dist/lib/annotations.d.ts +0 -6
- package/dist/lib/annotations.d.ts.map +1 -1
- package/dist/lib/annotations.js +26 -8
- package/dist/lib/annotations.js.map +1 -1
- package/dist/lib/build.d.ts +52 -0
- package/dist/lib/build.d.ts.map +1 -1
- package/dist/lib/build.js +135 -6
- package/dist/lib/build.js.map +1 -1
- package/dist/lib/commands/build.d.ts.map +1 -1
- package/dist/lib/commands/build.js +39 -12
- package/dist/lib/commands/build.js.map +1 -1
- package/dist/lib/commands/doi.d.ts.map +1 -1
- package/dist/lib/commands/doi.js +14 -2
- package/dist/lib/commands/doi.js.map +1 -1
- package/dist/lib/commands/sync.d.ts.map +1 -1
- package/dist/lib/commands/sync.js +24 -11
- package/dist/lib/commands/sync.js.map +1 -1
- package/dist/lib/comment-realign.d.ts +12 -21
- package/dist/lib/comment-realign.d.ts.map +1 -1
- package/dist/lib/comment-realign.js +40 -348
- package/dist/lib/comment-realign.js.map +1 -1
- package/dist/lib/dependencies.d.ts +10 -0
- package/dist/lib/dependencies.d.ts.map +1 -1
- package/dist/lib/dependencies.js +41 -9
- package/dist/lib/dependencies.js.map +1 -1
- package/dist/lib/doi.d.ts.map +1 -1
- package/dist/lib/doi.js +68 -80
- package/dist/lib/doi.js.map +1 -1
- package/dist/lib/errors.d.ts.map +1 -1
- package/dist/lib/errors.js +3 -1
- package/dist/lib/errors.js.map +1 -1
- package/dist/lib/import.d.ts +7 -4
- package/dist/lib/import.d.ts.map +1 -1
- package/dist/lib/import.js +58 -62
- package/dist/lib/import.js.map +1 -1
- package/dist/lib/ooxml.d.ts +207 -0
- package/dist/lib/ooxml.d.ts.map +1 -0
- package/dist/lib/ooxml.js +634 -0
- package/dist/lib/ooxml.js.map +1 -0
- package/dist/lib/rate-limiter.d.ts +8 -0
- package/dist/lib/rate-limiter.d.ts.map +1 -1
- package/dist/lib/rate-limiter.js +41 -4
- package/dist/lib/rate-limiter.js.map +1 -1
- package/dist/lib/trackchanges.d.ts +41 -25
- package/dist/lib/trackchanges.d.ts.map +1 -1
- package/dist/lib/trackchanges.js +90 -151
- package/dist/lib/trackchanges.js.map +1 -1
- package/dist/lib/types.d.ts +9 -8
- package/dist/lib/types.d.ts.map +1 -1
- package/dist/lib/word-extraction.d.ts.map +1 -1
- package/dist/lib/word-extraction.js +48 -307
- package/dist/lib/word-extraction.js.map +1 -1
- package/dist/lib/word.d.ts.map +1 -1
- package/dist/lib/word.js +80 -175
- package/dist/lib/word.js.map +1 -1
- package/dist/lib/wordcomments.d.ts.map +1 -1
- package/dist/lib/wordcomments.js +53 -92
- package/dist/lib/wordcomments.js.map +1 -1
- package/lib/anchor-match.ts +6 -7
- package/lib/annotations.ts +28 -8
- package/lib/build.ts +196 -6
- package/lib/commands/build.ts +42 -12
- package/lib/commands/doi.ts +17 -1
- package/lib/commands/sync.ts +26 -13
- package/lib/comment-realign.ts +46 -464
- package/lib/dependencies.ts +43 -9
- package/lib/doi.ts +76 -94
- package/lib/errors.ts +5 -1
- package/lib/import.ts +65 -70
- package/lib/ooxml.ts +768 -0
- package/lib/rate-limiter.ts +37 -4
- package/lib/trackchanges.ts +102 -183
- package/lib/types.ts +9 -13
- package/lib/word-extraction.ts +49 -330
- package/lib/word.ts +86 -203
- package/lib/wordcomments.ts +53 -104
- package/package.json +137 -137
- package/skill/REFERENCE.md +14 -3
- package/skill/SKILL.md +12 -5
- package/types/index.d.ts +16 -10
package/lib/word-extraction.ts
CHANGED
|
@@ -6,6 +6,7 @@ import * as fs from 'fs';
|
|
|
6
6
|
import * as path from 'path';
|
|
7
7
|
import { exec } from 'child_process';
|
|
8
8
|
import { promisify } from 'util';
|
|
9
|
+
import { buildDocTextModel, buildCommentAnchorModel, extractComments, openDocx, readPartText } from './ooxml.js';
|
|
9
10
|
|
|
10
11
|
const execAsync = promisify(exec);
|
|
11
12
|
|
|
@@ -99,130 +100,19 @@ export interface ExtractFromWordResult {
|
|
|
99
100
|
* Extract comments directly from Word docx comments.xml
|
|
100
101
|
*/
|
|
101
102
|
export async function extractWordComments(docxPath: string): Promise<WordComment[]> {
|
|
102
|
-
const AdmZip = (await import('adm-zip')).default;
|
|
103
|
-
const { parseStringPromise } = await import('xml2js');
|
|
104
|
-
|
|
105
|
-
const comments: WordComment[] = [];
|
|
106
|
-
|
|
107
|
-
// Validate file exists
|
|
108
103
|
if (!fs.existsSync(docxPath)) {
|
|
109
104
|
throw new Error(`File not found: ${docxPath}`);
|
|
110
105
|
}
|
|
111
106
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if (!commentsEntry) {
|
|
123
|
-
return comments;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
let commentsXml;
|
|
127
|
-
try {
|
|
128
|
-
commentsXml = commentsEntry.getData().toString('utf8');
|
|
129
|
-
} catch (err: any) {
|
|
130
|
-
throw new Error(`Failed to read comments from document: ${err.message}`);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const parsed = await parseStringPromise(commentsXml, { explicitArray: false });
|
|
134
|
-
|
|
135
|
-
const commentsRoot = parsed['w:comments'];
|
|
136
|
-
if (!commentsRoot || !commentsRoot['w:comment']) {
|
|
137
|
-
return comments;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// Ensure it's an array
|
|
141
|
-
const commentNodes = Array.isArray(commentsRoot['w:comment'])
|
|
142
|
-
? commentsRoot['w:comment']
|
|
143
|
-
: [commentsRoot['w:comment']];
|
|
144
|
-
|
|
145
|
-
// Map every paraId that lives inside a comment back to that comment's id.
|
|
146
|
-
// Word's commentsExtended.xml expresses threading via w15:paraIdParent,
|
|
147
|
-
// which references the parent's first <w:p>. Replies use a secondary
|
|
148
|
-
// (often-empty) <w:p>, so each comment may contribute multiple paraIds.
|
|
149
|
-
const paraIdToCommentId = new Map<string, string>();
|
|
150
|
-
|
|
151
|
-
for (const comment of commentNodes) {
|
|
152
|
-
const id = comment.$?.['w:id'] || '';
|
|
153
|
-
const author = comment.$?.['w:author'] || 'Unknown';
|
|
154
|
-
const date = comment.$?.['w:date'] || '';
|
|
155
|
-
|
|
156
|
-
// Extract text from nested w:p/w:r/w:t elements and record paraIds.
|
|
157
|
-
let text = '';
|
|
158
|
-
const extractText = (node: any): void => {
|
|
159
|
-
if (!node) return;
|
|
160
|
-
if (typeof node === 'string') {
|
|
161
|
-
text += node;
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
if (node['w:t']) {
|
|
165
|
-
const t = node['w:t'];
|
|
166
|
-
text += typeof t === 'string' ? t : (t._ || t);
|
|
167
|
-
}
|
|
168
|
-
if (node['w:r']) {
|
|
169
|
-
const runs = Array.isArray(node['w:r']) ? node['w:r'] : [node['w:r']];
|
|
170
|
-
runs.forEach(extractText);
|
|
171
|
-
}
|
|
172
|
-
if (node['w:p']) {
|
|
173
|
-
const paras = Array.isArray(node['w:p']) ? node['w:p'] : [node['w:p']];
|
|
174
|
-
for (const para of paras) {
|
|
175
|
-
const paraId = para?.$?.['w14:paraId'];
|
|
176
|
-
if (paraId && id) paraIdToCommentId.set(paraId, id);
|
|
177
|
-
extractText(para);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
};
|
|
181
|
-
extractText(comment);
|
|
182
|
-
|
|
183
|
-
comments.push({ id, author, date: date.slice(0, 10), text: text.trim() });
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Resolve parent links from commentsExtended.xml. Missing entry just
|
|
187
|
-
// means the docx has no threading metadata (e.g. legacy/non-Word source).
|
|
188
|
-
const extendedEntry = zip.getEntry('word/commentsExtended.xml');
|
|
189
|
-
if (extendedEntry && paraIdToCommentId.size > 0) {
|
|
190
|
-
let extendedXml = '';
|
|
191
|
-
try {
|
|
192
|
-
extendedXml = extendedEntry.getData().toString('utf8');
|
|
193
|
-
} catch {
|
|
194
|
-
// Unreadable threading metadata is non-fatal; skip parent linking.
|
|
195
|
-
}
|
|
196
|
-
if (extendedXml) {
|
|
197
|
-
const parentByCommentId = new Map<string, string>();
|
|
198
|
-
const exPattern = /<w15:commentEx\b([^>]*?)\/>/g;
|
|
199
|
-
let m: RegExpExecArray | null;
|
|
200
|
-
while ((m = exPattern.exec(extendedXml)) !== null) {
|
|
201
|
-
const attrs = m[1] ?? '';
|
|
202
|
-
const paraIdMatch = attrs.match(/w15:paraId="([^"]+)"/);
|
|
203
|
-
const parentMatch = attrs.match(/w15:paraIdParent="([^"]+)"/);
|
|
204
|
-
if (!paraIdMatch || !parentMatch) continue;
|
|
205
|
-
const childCommentId = paraIdToCommentId.get(paraIdMatch[1]);
|
|
206
|
-
const parentCommentId = paraIdToCommentId.get(parentMatch[1]);
|
|
207
|
-
if (childCommentId && parentCommentId && childCommentId !== parentCommentId) {
|
|
208
|
-
parentByCommentId.set(childCommentId, parentCommentId);
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
for (const c of comments) {
|
|
212
|
-
const parent = parentByCommentId.get(c.id);
|
|
213
|
-
if (parent) c.parentId = parent;
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
} catch (err: any) {
|
|
218
|
-
// Re-throw with more context if it's already an Error we created
|
|
219
|
-
if (err.message.includes('Invalid Word document') || err.message.includes('File not found')) {
|
|
220
|
-
throw err;
|
|
221
|
-
}
|
|
222
|
-
throw new Error(`Error extracting comments from ${path.basename(docxPath)}: ${err.message}`);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
return comments;
|
|
107
|
+
const zip = openDocx(docxPath);
|
|
108
|
+
// Word truncates the stored date to its day for display; keep that contract.
|
|
109
|
+
return extractComments(zip).map((c) => ({
|
|
110
|
+
id: c.id,
|
|
111
|
+
author: c.author,
|
|
112
|
+
date: c.date.slice(0, 10),
|
|
113
|
+
text: c.text,
|
|
114
|
+
parentId: c.parentId,
|
|
115
|
+
}));
|
|
226
116
|
}
|
|
227
117
|
|
|
228
118
|
/**
|
|
@@ -231,159 +121,41 @@ export async function extractWordComments(docxPath: string): Promise<WordComment
|
|
|
231
121
|
* Also returns fullDocText for section boundary matching
|
|
232
122
|
*/
|
|
233
123
|
export async function extractCommentAnchors(docxPath: string): Promise<CommentAnchorsResult> {
|
|
234
|
-
const AdmZip = (await import('adm-zip')).default;
|
|
235
124
|
const anchors = new Map<string, CommentAnchorData>();
|
|
236
|
-
let fullDocText = '';
|
|
237
|
-
|
|
238
|
-
try {
|
|
239
|
-
const zip = new AdmZip(docxPath);
|
|
240
|
-
const docEntry = zip.getEntry('word/document.xml');
|
|
241
|
-
|
|
242
|
-
if (!docEntry) {
|
|
243
|
-
return { anchors, fullDocText };
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
const docXml = docEntry.getData().toString('utf8');
|
|
247
|
-
|
|
248
|
-
// ========================================
|
|
249
|
-
// STEP 1: Build text position mapping
|
|
250
|
-
// ========================================
|
|
251
|
-
const textNodePattern = /<w:t[^>]*>([^<]*)<\/w:t>/g;
|
|
252
|
-
const textNodes: TextNode[] = [];
|
|
253
|
-
let textPosition = 0;
|
|
254
|
-
let nodeMatch;
|
|
255
|
-
|
|
256
|
-
while ((nodeMatch = textNodePattern.exec(docXml)) !== null) {
|
|
257
|
-
const rawText = nodeMatch[1] ?? '';
|
|
258
|
-
const decodedText = decodeXmlEntities(rawText);
|
|
259
|
-
textNodes.push({
|
|
260
|
-
xmlStart: nodeMatch.index,
|
|
261
|
-
xmlEnd: nodeMatch.index + nodeMatch[0].length,
|
|
262
|
-
textStart: textPosition,
|
|
263
|
-
textEnd: textPosition + decodedText.length,
|
|
264
|
-
text: decodedText
|
|
265
|
-
});
|
|
266
|
-
textPosition += decodedText.length;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
fullDocText = textNodes.map(n => n.text).join('');
|
|
270
125
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
if (xmlPos >= node.xmlStart && xmlPos < node.xmlEnd) {
|
|
277
|
-
return node.textStart;
|
|
278
|
-
}
|
|
279
|
-
if (xmlPos < node.xmlStart) {
|
|
280
|
-
return node.textStart;
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
const lastNode = textNodes[textNodes.length - 1];
|
|
284
|
-
return lastNode ? lastNode.textEnd : 0;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// Helper: extract context before a position
|
|
288
|
-
function getContextBefore(position: number, maxLength: number = 150): string {
|
|
289
|
-
const beforeText = fullDocText.slice(Math.max(0, position - maxLength), position);
|
|
290
|
-
const sentenceStart = beforeText.search(/[.!?]\s+[A-Z][^.!?]*$/);
|
|
291
|
-
return sentenceStart >= 0
|
|
292
|
-
? beforeText.slice(sentenceStart + 2).trim()
|
|
293
|
-
: beforeText.slice(-80).trim();
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// Helper: extract context after a position
|
|
297
|
-
function getContextAfter(position: number, maxLength: number = 150): string {
|
|
298
|
-
const afterText = fullDocText.slice(position, position + maxLength);
|
|
299
|
-
const sentenceEnd = afterText.search(/[.!?]\s/);
|
|
300
|
-
return sentenceEnd >= 0
|
|
301
|
-
? afterText.slice(0, sentenceEnd + 1).trim()
|
|
302
|
-
: afterText.slice(0, 80).trim();
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// ========================================
|
|
306
|
-
// STEP 2: Collect all start/end markers separately
|
|
307
|
-
// ========================================
|
|
308
|
-
const startPattern = /<w:commentRangeStart[^>]*w:id="(\d+)"[^>]*\/?>/g;
|
|
309
|
-
const endPattern = /<w:commentRangeEnd[^>]*w:id="(\d+)"[^>]*\/?>/g;
|
|
310
|
-
|
|
311
|
-
const starts = new Map<string, number>(); // id -> position after start tag
|
|
312
|
-
const ends = new Map<string, number>(); // id -> position before end tag
|
|
313
|
-
|
|
314
|
-
let match;
|
|
315
|
-
while ((match = startPattern.exec(docXml)) !== null) {
|
|
316
|
-
const id = match[1];
|
|
317
|
-
if (!starts.has(id)) {
|
|
318
|
-
starts.set(id, match.index + match[0].length);
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
while ((match = endPattern.exec(docXml)) !== null) {
|
|
323
|
-
const id = match[1];
|
|
324
|
-
if (!ends.has(id)) {
|
|
325
|
-
ends.set(id, match.index);
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// ========================================
|
|
330
|
-
// STEP 3: Process each comment range by ID
|
|
331
|
-
// ========================================
|
|
332
|
-
for (const [id, startXmlPos] of starts) {
|
|
333
|
-
const endXmlPos = ends.get(id);
|
|
334
|
-
|
|
335
|
-
// Missing end marker - skip with warning
|
|
336
|
-
if (endXmlPos === undefined) {
|
|
337
|
-
console.warn(`Comment ${id}: missing end marker`);
|
|
338
|
-
continue;
|
|
339
|
-
}
|
|
126
|
+
const zip = openDocx(docxPath);
|
|
127
|
+
const { fullDocText, comments } = buildCommentAnchorModel(zip);
|
|
128
|
+
if (!fullDocText && comments.length === 0) {
|
|
129
|
+
return { anchors, fullDocText: '' };
|
|
130
|
+
}
|
|
340
131
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
docPosition,
|
|
351
|
-
docLength: fullDocText.length,
|
|
352
|
-
isEmpty: true
|
|
353
|
-
});
|
|
354
|
-
continue;
|
|
355
|
-
}
|
|
132
|
+
// Context surrounding the anchor, taken from the same plain-text coordinate
|
|
133
|
+
// system as docPosition so the placement engine can compare like with like.
|
|
134
|
+
function getContextBefore(position: number, maxLength: number = 150): string {
|
|
135
|
+
const beforeText = fullDocText.slice(Math.max(0, position - maxLength), position);
|
|
136
|
+
const sentenceStart = beforeText.search(/[.!?]\s+[A-Z][^.!?]*$/);
|
|
137
|
+
return sentenceStart >= 0
|
|
138
|
+
? beforeText.slice(sentenceStart + 2).trim()
|
|
139
|
+
: beforeText.slice(-80).trim();
|
|
140
|
+
}
|
|
356
141
|
|
|
357
|
-
|
|
358
|
-
|
|
142
|
+
function getContextAfter(position: number, maxLength: number = 150): string {
|
|
143
|
+
const afterText = fullDocText.slice(position, position + maxLength);
|
|
144
|
+
const sentenceEnd = afterText.search(/[.!?]\s/);
|
|
145
|
+
return sentenceEnd >= 0
|
|
146
|
+
? afterText.slice(0, sentenceEnd + 1).trim()
|
|
147
|
+
: afterText.slice(0, 80).trim();
|
|
148
|
+
}
|
|
359
149
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
// Get context
|
|
370
|
-
const anchorLength = anchorText.length;
|
|
371
|
-
const before = getContextBefore(docPosition);
|
|
372
|
-
const after = getContextAfter(docPosition + anchorLength);
|
|
373
|
-
|
|
374
|
-
// ALWAYS add entry (even if anchor is empty)
|
|
375
|
-
anchors.set(id, {
|
|
376
|
-
anchor: anchorText.trim(),
|
|
377
|
-
before,
|
|
378
|
-
after,
|
|
379
|
-
docPosition,
|
|
380
|
-
docLength: fullDocText.length,
|
|
381
|
-
isEmpty: !anchorText.trim()
|
|
382
|
-
});
|
|
383
|
-
}
|
|
384
|
-
} catch (err: any) {
|
|
385
|
-
console.error('Error extracting comment anchors:', err.message);
|
|
386
|
-
return { anchors, fullDocText: '' };
|
|
150
|
+
for (const range of comments) {
|
|
151
|
+
anchors.set(range.id, {
|
|
152
|
+
anchor: range.anchor,
|
|
153
|
+
before: getContextBefore(range.start),
|
|
154
|
+
after: getContextAfter(range.end),
|
|
155
|
+
docPosition: range.start,
|
|
156
|
+
docLength: fullDocText.length,
|
|
157
|
+
isEmpty: range.isEmpty,
|
|
158
|
+
});
|
|
387
159
|
}
|
|
388
160
|
|
|
389
161
|
return { anchors, fullDocText };
|
|
@@ -402,73 +174,20 @@ export async function extractCommentAnchors(docxPath: string): Promise<CommentAn
|
|
|
402
174
|
* concatenated.
|
|
403
175
|
*/
|
|
404
176
|
export async function extractHeadings(docxPath: string): Promise<DocxHeading[]> {
|
|
405
|
-
const AdmZip = (await import('adm-zip')).default;
|
|
406
|
-
|
|
407
177
|
if (!fs.existsSync(docxPath)) {
|
|
408
178
|
throw new Error(`File not found: ${docxPath}`);
|
|
409
179
|
}
|
|
410
180
|
|
|
411
|
-
const zip =
|
|
412
|
-
const
|
|
413
|
-
if (
|
|
414
|
-
const xml = docEntry.getData().toString('utf8');
|
|
415
|
-
|
|
416
|
-
// Build the same xml-pos → text-pos mapping that extractCommentAnchors does
|
|
417
|
-
const textNodePattern = /<w:t[^>]*>([^<]*)<\/w:t>/g;
|
|
418
|
-
const nodes: Array<{ xmlStart: number; xmlEnd: number; textStart: number; textEnd: number }> = [];
|
|
419
|
-
let textPos = 0;
|
|
420
|
-
let m;
|
|
421
|
-
while ((m = textNodePattern.exec(xml)) !== null) {
|
|
422
|
-
const decoded = decodeXmlEntities(m[1] ?? '');
|
|
423
|
-
nodes.push({
|
|
424
|
-
xmlStart: m.index,
|
|
425
|
-
xmlEnd: m.index + m[0].length,
|
|
426
|
-
textStart: textPos,
|
|
427
|
-
textEnd: textPos + decoded.length,
|
|
428
|
-
});
|
|
429
|
-
textPos += decoded.length;
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
function xmlToTextPos(xmlPos: number): number {
|
|
433
|
-
for (const n of nodes) {
|
|
434
|
-
if (xmlPos >= n.xmlStart && xmlPos < n.xmlEnd) return n.textStart;
|
|
435
|
-
if (xmlPos < n.xmlStart) return n.textStart;
|
|
436
|
-
}
|
|
437
|
-
return nodes.length ? nodes[nodes.length - 1].textEnd : 0;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
const headings: DocxHeading[] = [];
|
|
441
|
-
const paraPattern = /<w:p\b[^>]*>([\s\S]*?)<\/w:p>/g;
|
|
442
|
-
let pm;
|
|
443
|
-
while ((pm = paraPattern.exec(xml)) !== null) {
|
|
444
|
-
const inner = pm[1];
|
|
445
|
-
const styleMatch = inner.match(/<w:pStyle[^>]*w:val="([^"]+)"/);
|
|
446
|
-
if (!styleMatch) continue;
|
|
447
|
-
const style = styleMatch[1];
|
|
448
|
-
if (!/heading/i.test(style)) continue;
|
|
449
|
-
|
|
450
|
-
// Concatenate text runs; include w:delText so a heading inside a tracked
|
|
451
|
-
// deletion is still surfaced (verifying anchors against an original draft)
|
|
452
|
-
const textInRange = /<w:t[^>]*>([^<]*)<\/w:t>|<w:delText[^>]*>([^<]*)<\/w:delText>/g;
|
|
453
|
-
let txt = '';
|
|
454
|
-
let tm;
|
|
455
|
-
while ((tm = textInRange.exec(inner)) !== null) {
|
|
456
|
-
txt += decodeXmlEntities(tm[1] || tm[2] || '');
|
|
457
|
-
}
|
|
458
|
-
const trimmed = txt.trim();
|
|
459
|
-
if (!trimmed) continue;
|
|
460
|
-
|
|
461
|
-
const levelMatch = style.match(/(\d+)/);
|
|
462
|
-
const level = levelMatch ? parseInt(levelMatch[1], 10) : 0;
|
|
463
|
-
headings.push({
|
|
464
|
-
style,
|
|
465
|
-
level,
|
|
466
|
-
text: trimmed,
|
|
467
|
-
docPosition: xmlToTextPos(pm.index),
|
|
468
|
-
});
|
|
469
|
-
}
|
|
181
|
+
const zip = openDocx(docxPath);
|
|
182
|
+
const docXml = readPartText(zip, 'word/document.xml');
|
|
183
|
+
if (docXml === null) return [];
|
|
470
184
|
|
|
471
|
-
return headings
|
|
185
|
+
return buildDocTextModel(docXml).headings.map((h) => ({
|
|
186
|
+
style: h.style,
|
|
187
|
+
level: h.level,
|
|
188
|
+
text: h.text,
|
|
189
|
+
docPosition: h.position,
|
|
190
|
+
}));
|
|
472
191
|
}
|
|
473
192
|
|
|
474
193
|
/**
|