@adeu/core 1.22.0 → 1.25.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/dist/index.cjs +1157 -115
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +114 -8
- package/dist/index.d.ts +114 -8
- package/dist/index.js +1156 -115
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/diff.ts +515 -1
- package/src/docx/bridge.ts +13 -0
- package/src/domain.ts +58 -14
- package/src/engine.ts +411 -38
- package/src/index.ts +3 -3
- package/src/ingest.ts +77 -5
- package/src/mapper.ts +85 -3
- package/src/markup.ts +211 -19
- package/src/repro_qa_report_2026_07_18.test.ts +1244 -0
- package/src/repro_qa_report_v6.test.ts +486 -0
- package/src/sanitize/core.ts +60 -1
- package/src/sanitize/report.ts +5 -3
- package/src/sanitize/sanitize.test.ts +5 -4
- package/src/sanitize/transforms.ts +134 -16
- package/src/utils/docx.ts +257 -16
|
@@ -435,12 +435,17 @@ export function scrub_doc_properties(doc: DocumentObject): string[] {
|
|
|
435
435
|
// Classification-style properties are textbook leak vectors ("Project
|
|
436
436
|
// Falcon", "confidential,merger,..."). Unlike title they carry no
|
|
437
437
|
// legitimate outbound formatting value, so strip them and say so.
|
|
438
|
+
// dc:identifier, dc:language and cp:version are scrubbed with them:
|
|
439
|
+
// identifiers are DMS/matter-ID carriers.
|
|
438
440
|
const leakFields: Array<[string, string]> = [
|
|
439
441
|
['category', 'Category'],
|
|
440
442
|
['keywords', 'Keywords'],
|
|
441
443
|
['subject', 'Subject'],
|
|
442
444
|
['contentStatus', 'Content status'],
|
|
443
445
|
['description', 'Description/comments'],
|
|
446
|
+
['identifier', 'Identifier'],
|
|
447
|
+
['language', 'Language'],
|
|
448
|
+
['version', 'Version'],
|
|
444
449
|
];
|
|
445
450
|
for (const [local, label] of leakFields) {
|
|
446
451
|
findDescendantsByLocalName(corePart._element, local).forEach(c => {
|
|
@@ -498,33 +503,106 @@ export function scrub_timestamps(doc: DocumentObject): string[] {
|
|
|
498
503
|
return modified ? ["Timestamps normalized to epoch"] : [];
|
|
499
504
|
}
|
|
500
505
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
506
|
+
/**
|
|
507
|
+
* Fully ejects package members whose path matches `matcher`: the parsed part
|
|
508
|
+
* list, the raw `pkg.unzipped` bytes (save() re-zips EVERY unzipped
|
|
509
|
+
* member, so skipping this ships the original bytes in the output), the
|
|
510
|
+
* [Content_Types].xml overrides, and every .rels Relationship whose
|
|
511
|
+
* target matches `relTargetMatcher`.
|
|
512
|
+
*/
|
|
513
|
+
function ejectPackageMembers(
|
|
514
|
+
doc: DocumentObject,
|
|
515
|
+
matcher: (path: string) => boolean,
|
|
516
|
+
relTargetMatcher: (target: string) => boolean,
|
|
517
|
+
) {
|
|
518
|
+
const pkg = doc.pkg;
|
|
519
|
+
const normalized = (p: string) => (p.startsWith('/') ? p.substring(1) : p);
|
|
507
520
|
|
|
508
|
-
|
|
521
|
+
// 1. Sever relationships from every .rels part (and the in-memory rels maps)
|
|
522
|
+
for (const part of pkg.parts) {
|
|
523
|
+
if (!part.partname.endsWith('.rels')) continue;
|
|
509
524
|
const toRemove: Element[] = [];
|
|
510
|
-
for (const rel of findAllDescendants(
|
|
511
|
-
const target = rel.getAttribute('Target');
|
|
512
|
-
if (target
|
|
525
|
+
for (const rel of findAllDescendants(part._element, 'Relationship')) {
|
|
526
|
+
const target = rel.getAttribute('Target') || '';
|
|
527
|
+
if (relTargetMatcher(target)) {
|
|
528
|
+
toRemove.push(rel);
|
|
529
|
+
const sourcePath = part.partname.replace('/_rels/', '/').replace('.rels', '');
|
|
530
|
+
const sourcePart = pkg.getPartByPath(sourcePath);
|
|
531
|
+
if (sourcePart) {
|
|
532
|
+
const relId = rel.getAttribute('Id');
|
|
533
|
+
if (relId) sourcePart.rels.delete(relId);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
513
536
|
}
|
|
514
537
|
toRemove.forEach(r => r.parentNode?.removeChild(r));
|
|
515
|
-
}
|
|
538
|
+
}
|
|
516
539
|
|
|
517
|
-
|
|
518
|
-
|
|
540
|
+
// 2. Remove [Content_Types].xml overrides
|
|
541
|
+
const ctPart = pkg.getPartByPath('[Content_Types].xml');
|
|
542
|
+
if (ctPart) {
|
|
543
|
+
const toRemove: Element[] = [];
|
|
544
|
+
for (const override of findAllDescendants(ctPart._element, 'Override')) {
|
|
545
|
+
const partName = override.getAttribute('PartName') || '';
|
|
546
|
+
if (matcher(normalized(partName))) toRemove.push(override);
|
|
547
|
+
}
|
|
548
|
+
toRemove.forEach(o => o.parentNode?.removeChild(o));
|
|
549
|
+
}
|
|
519
550
|
|
|
520
|
-
|
|
521
|
-
|
|
551
|
+
// 3. Drop the parts from the serialization list AND the raw zip map
|
|
552
|
+
pkg.parts = pkg.parts.filter(p => !matcher(normalized(p.partname)));
|
|
553
|
+
for (const key of Object.keys(pkg.unzipped)) {
|
|
554
|
+
if (matcher(normalized(key))) delete pkg.unzipped[key];
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export function strip_custom_xml(doc: DocumentObject): string[] {
|
|
559
|
+
const isCustomXml = (path: string) => path.startsWith('customXml/');
|
|
560
|
+
const customParts = doc.pkg.parts.filter(p =>
|
|
561
|
+
isCustomXml(p.partname.startsWith('/') ? p.partname.substring(1) : p.partname),
|
|
562
|
+
);
|
|
563
|
+
const leakedMembers = Object.keys(doc.pkg.unzipped).filter(isCustomXml);
|
|
564
|
+
if (customParts.length === 0 && leakedMembers.length === 0) return [];
|
|
565
|
+
|
|
566
|
+
ejectPackageMembers(doc, isCustomXml, target => target.includes('customXml'));
|
|
522
567
|
|
|
523
568
|
for (const sdtPr of findAllDescendants(doc.element, 'w:sdtPr')) {
|
|
524
569
|
findChildren(sdtPr, 'w:dataBinding').forEach(b => sdtPr.removeChild(b));
|
|
525
570
|
}
|
|
526
571
|
|
|
527
|
-
return [`Custom XML parts: ${customParts.length} removed`];
|
|
572
|
+
return [`Custom XML parts: ${Math.max(customParts.length, leakedMembers.length)} removed`];
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const CUSTOM_PROPS_PATH = 'docProps/custom.xml';
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* Remove the custom document properties part (docProps/custom.xml) and its
|
|
579
|
+
* package relationship. Custom properties are a standard home for matter
|
|
580
|
+
* numbers, client names, DMS identifiers and workflow secrets; a package
|
|
581
|
+
* that keeps them can never be reported CLEAN.
|
|
582
|
+
*/
|
|
583
|
+
export function strip_custom_properties(doc: DocumentObject): string[] {
|
|
584
|
+
const part = doc.pkg.getPartByPath(CUSTOM_PROPS_PATH);
|
|
585
|
+
const leaked = Object.keys(doc.pkg.unzipped).includes(CUSTOM_PROPS_PATH);
|
|
586
|
+
if (!part && !leaked) return [];
|
|
587
|
+
|
|
588
|
+
// Enumerate what is being removed so the report discloses it.
|
|
589
|
+
const lines: string[] = [];
|
|
590
|
+
if (part) {
|
|
591
|
+
for (const prop of findDescendantsByLocalName(part._element, 'property')) {
|
|
592
|
+
const name = prop.getAttribute('name') || '(unnamed)';
|
|
593
|
+
const value = (prop.textContent || '').trim();
|
|
594
|
+
lines.push(` Custom property removed: ${name} = "${_truncate(value, 60)}"`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
ejectPackageMembers(
|
|
599
|
+
doc,
|
|
600
|
+
path => path === CUSTOM_PROPS_PATH,
|
|
601
|
+
target => target.includes('docProps/custom.xml'),
|
|
602
|
+
);
|
|
603
|
+
|
|
604
|
+
const count = Math.max(lines.length, 1);
|
|
605
|
+
return [`Custom document properties: ${count} removed (docProps/custom.xml)`].concat(lines);
|
|
528
606
|
}
|
|
529
607
|
|
|
530
608
|
export function strip_image_alt_text(doc: DocumentObject): string[] {
|
|
@@ -543,6 +621,46 @@ export function strip_image_alt_text(doc: DocumentObject): string[] {
|
|
|
543
621
|
return count ? [`Image alt text: ${count} auto-generated descriptions removed`] : [];
|
|
544
622
|
}
|
|
545
623
|
|
|
624
|
+
/**
|
|
625
|
+
* Detect watermark-like embedded text objects (VML shapes with a textpath,
|
|
626
|
+
* Word's native watermark mechanism) in headers, footers and the body.
|
|
627
|
+
* Non-destructive: returns warnings so the report never declares a document
|
|
628
|
+
* fully clean while such an object silently remains (QA 2026-07-18 M3).
|
|
629
|
+
*/
|
|
630
|
+
export function detect_watermarks(doc: DocumentObject): string[] {
|
|
631
|
+
const warnings: string[] = [];
|
|
632
|
+
const seen = new Set<string>();
|
|
633
|
+
|
|
634
|
+
for (const part of doc.pkg.parts) {
|
|
635
|
+
const name = String(part.partname);
|
|
636
|
+
let location: string;
|
|
637
|
+
if (name.startsWith("/word/header")) location = "header";
|
|
638
|
+
else if (name.startsWith("/word/footer")) location = "footer";
|
|
639
|
+
else if (name === "/word/document.xml") location = "body";
|
|
640
|
+
else continue;
|
|
641
|
+
|
|
642
|
+
let textpaths: Element[];
|
|
643
|
+
try {
|
|
644
|
+
textpaths = findDescendantsByLocalName(part._element, "textpath");
|
|
645
|
+
} catch {
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
for (const tp of textpaths) {
|
|
649
|
+
const text = (tp.getAttribute("string") || "").trim();
|
|
650
|
+
const key = `${location}\x1f${text}`;
|
|
651
|
+
if (seen.has(key)) continue;
|
|
652
|
+
seen.add(key);
|
|
653
|
+
const label = text ? `"${_truncate(text, 60)}"` : "(no text)";
|
|
654
|
+
warnings.push(
|
|
655
|
+
`Watermark-like text object in ${location}: ${label} — NOT removed. ` +
|
|
656
|
+
"It remains in the document package and may render in other applications; " +
|
|
657
|
+
"review and remove it in Word if it must not reach the counterparty.",
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
return warnings;
|
|
662
|
+
}
|
|
663
|
+
|
|
546
664
|
export function audit_hyperlinks(doc: DocumentObject): string[] {
|
|
547
665
|
const internal = ["sharepoint.com", "onedrive.com", ".internal", "intranet", "localhost", "10.", "192.168.", "172.16."];
|
|
548
666
|
const warnings: string[] = [];
|
package/src/utils/docx.ts
CHANGED
|
@@ -48,9 +48,35 @@ export const QN_W_OUTLINELVL = "w:outlineLvl";
|
|
|
48
48
|
export const QN_W_NUMPR = "w:numPr";
|
|
49
49
|
export const QN_W_NUMID = "w:numId";
|
|
50
50
|
export const QN_W_ILVL = "w:ilvl";
|
|
51
|
+
export const QN_W_DRAWING = "w:drawing";
|
|
52
|
+
export const QN_W_OBJECT = "w:object";
|
|
53
|
+
export const QN_W_PICT = "w:pict";
|
|
54
|
+
export const QN_WP_DOCPR = "wp:docPr";
|
|
55
|
+
export const QN_V_IMAGEDATA = "v:imagedata";
|
|
56
|
+
export const QN_O_TITLE = "o:title";
|
|
51
57
|
|
|
52
58
|
const _CUSTOM_HEADING_NAME_RE = /Heading[ ]?([1-6])(?![0-9])/;
|
|
53
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Resolves the DocxPackage owning `obj`, whatever wrapper it is (Part,
|
|
62
|
+
* DocumentObject, Cell/Row/Table/NotesPart chains). Returns null when no
|
|
63
|
+
* package is reachable.
|
|
64
|
+
*/
|
|
65
|
+
function _resolve_package(obj: any): any {
|
|
66
|
+
let cur = obj;
|
|
67
|
+
const seen = new Set<any>();
|
|
68
|
+
while (cur && !seen.has(cur)) {
|
|
69
|
+
seen.add(cur);
|
|
70
|
+
if (cur.package) return cur.package;
|
|
71
|
+
if (cur.pkg) return cur.pkg;
|
|
72
|
+
if (cur.part && (cur.part.package || cur.part.pkg)) {
|
|
73
|
+
return cur.part.package || cur.part.pkg;
|
|
74
|
+
}
|
|
75
|
+
cur = cur._parent || cur.part || null;
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
54
80
|
export function _get_style_cache(
|
|
55
81
|
part: any,
|
|
56
82
|
): [Record<string, any>, string | null] {
|
|
@@ -97,6 +123,8 @@ export function _get_style_cache(
|
|
|
97
123
|
const based_on = based_on_el ? based_on_el.getAttribute("w:val") : null;
|
|
98
124
|
|
|
99
125
|
let outline_lvl: number | null = null;
|
|
126
|
+
let num_id: string | null = null;
|
|
127
|
+
let num_ilvl: number | null = null;
|
|
100
128
|
const pPr = findChild(s, "w:pPr");
|
|
101
129
|
if (pPr) {
|
|
102
130
|
const oLvl = findChild(pPr, "w:outlineLvl");
|
|
@@ -104,6 +132,24 @@ export function _get_style_cache(
|
|
|
104
132
|
const val = oLvl.getAttribute("w:val");
|
|
105
133
|
if (val && /^\d+$/.test(val)) outline_lvl = parseInt(val, 10);
|
|
106
134
|
}
|
|
135
|
+
// Style-level list binding: Word's built-in "List Bullet" /
|
|
136
|
+
// "List Number" styles carry <w:numPr> in styles.xml, not on the
|
|
137
|
+
// paragraph. Without this, style-based lists project as plain
|
|
138
|
+
// paragraphs and the agent loses ordered-vs-unordered semantics
|
|
139
|
+
// (QA 2026-07-18 M4).
|
|
140
|
+
const numPr = findChild(pPr, "w:numPr");
|
|
141
|
+
if (numPr) {
|
|
142
|
+
const numId_el = findChild(numPr, "w:numId");
|
|
143
|
+
if (numId_el) {
|
|
144
|
+
const n_val = numId_el.getAttribute("w:val");
|
|
145
|
+
if (n_val && n_val !== "0") num_id = n_val;
|
|
146
|
+
}
|
|
147
|
+
const ilvl_el = findChild(numPr, "w:ilvl");
|
|
148
|
+
if (ilvl_el) {
|
|
149
|
+
const i_val = ilvl_el.getAttribute("w:val");
|
|
150
|
+
if (i_val && /^\d+$/.test(i_val)) num_ilvl = parseInt(i_val, 10);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
107
153
|
}
|
|
108
154
|
|
|
109
155
|
let bold: boolean | null = null;
|
|
@@ -116,13 +162,26 @@ export function _get_style_cache(
|
|
|
116
162
|
}
|
|
117
163
|
}
|
|
118
164
|
|
|
119
|
-
raw_styles[s_id] = {
|
|
165
|
+
raw_styles[s_id] = {
|
|
166
|
+
name,
|
|
167
|
+
based_on,
|
|
168
|
+
outline_level: outline_lvl,
|
|
169
|
+
bold,
|
|
170
|
+
num_id,
|
|
171
|
+
num_ilvl,
|
|
172
|
+
};
|
|
120
173
|
}
|
|
121
174
|
|
|
122
175
|
const resolve_style = (s_id: string, visited: Set<string>): any => {
|
|
123
176
|
if (cache[s_id]) return cache[s_id];
|
|
124
177
|
if (visited.has(s_id) || !raw_styles[s_id])
|
|
125
|
-
return {
|
|
178
|
+
return {
|
|
179
|
+
name: s_id,
|
|
180
|
+
outline_level: null,
|
|
181
|
+
bold: false,
|
|
182
|
+
num_id: null,
|
|
183
|
+
num_ilvl: null,
|
|
184
|
+
};
|
|
126
185
|
|
|
127
186
|
visited.add(s_id);
|
|
128
187
|
const raw = raw_styles[s_id];
|
|
@@ -130,14 +189,24 @@ export function _get_style_cache(
|
|
|
130
189
|
|
|
131
190
|
let o_lvl = raw.outline_level;
|
|
132
191
|
let bold_val = raw.bold !== null ? raw.bold : false;
|
|
192
|
+
let n_id = raw.num_id;
|
|
193
|
+
let n_ilvl = raw.num_ilvl;
|
|
133
194
|
|
|
134
195
|
if (based_on_id) {
|
|
135
196
|
const parent = resolve_style(based_on_id, visited);
|
|
136
197
|
if (o_lvl === null) o_lvl = parent.outline_level;
|
|
137
198
|
if (raw.bold === null) bold_val = parent.bold;
|
|
199
|
+
if (n_id === null) n_id = parent.num_id ?? null;
|
|
200
|
+
if (n_ilvl === null) n_ilvl = parent.num_ilvl ?? null;
|
|
138
201
|
}
|
|
139
202
|
|
|
140
|
-
const resolved = {
|
|
203
|
+
const resolved = {
|
|
204
|
+
name: raw.name,
|
|
205
|
+
outline_level: o_lvl,
|
|
206
|
+
bold: bold_val,
|
|
207
|
+
num_id: n_id,
|
|
208
|
+
num_ilvl: n_ilvl,
|
|
209
|
+
};
|
|
141
210
|
cache[s_id] = resolved;
|
|
142
211
|
return resolved;
|
|
143
212
|
};
|
|
@@ -149,6 +218,94 @@ export function _get_style_cache(
|
|
|
149
218
|
return result;
|
|
150
219
|
}
|
|
151
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Parses word/numbering.xml once per package into
|
|
223
|
+
* {numId: {ilvl: numFmt}} (e.g. {"5": {0: "decimal", 1: "lowerLetter"}}).
|
|
224
|
+
*
|
|
225
|
+
* Used to distinguish bullet lists from ordered lists in the projection
|
|
226
|
+
* (QA 2026-07-18 M4). Missing part / malformed XML yields an empty cache,
|
|
227
|
+
* which projects every list with the bullet marker (the historical default).
|
|
228
|
+
*/
|
|
229
|
+
export function _get_numbering_cache(
|
|
230
|
+
part: any,
|
|
231
|
+
): Record<string, Record<number, string>> {
|
|
232
|
+
const pkg = _resolve_package(part);
|
|
233
|
+
if (!pkg) return {};
|
|
234
|
+
if (pkg._adeu_numbering_cache) return pkg._adeu_numbering_cache;
|
|
235
|
+
|
|
236
|
+
const cache: Record<string, Record<number, string>> = {};
|
|
237
|
+
let numbering_root: Element | null = null;
|
|
238
|
+
try {
|
|
239
|
+
const numberingPart = (pkg.parts || []).find((p: any) =>
|
|
240
|
+
String(p.partname).endsWith("/numbering.xml"),
|
|
241
|
+
);
|
|
242
|
+
if (numberingPart) numbering_root = numberingPart._element;
|
|
243
|
+
} catch {
|
|
244
|
+
numbering_root = null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (numbering_root) {
|
|
248
|
+
const abstract_fmts: Record<string, Record<number, string>> = {};
|
|
249
|
+
for (const abstract of findAllDescendants(numbering_root, "w:abstractNum")) {
|
|
250
|
+
const a_id = abstract.getAttribute("w:abstractNumId");
|
|
251
|
+
if (a_id === null) continue;
|
|
252
|
+
const lvl_map: Record<number, string> = {};
|
|
253
|
+
for (const lvl of findAllDescendants(abstract, "w:lvl")) {
|
|
254
|
+
const ilvl_val = lvl.getAttribute("w:ilvl");
|
|
255
|
+
const fmt_el = findChild(lvl, "w:numFmt");
|
|
256
|
+
if (ilvl_val !== null && /^-?\d+$/.test(ilvl_val) && fmt_el) {
|
|
257
|
+
const fmt = fmt_el.getAttribute("w:val");
|
|
258
|
+
if (fmt) lvl_map[parseInt(ilvl_val, 10)] = fmt;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
abstract_fmts[a_id] = lvl_map;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
for (const num of findAllDescendants(numbering_root, "w:num")) {
|
|
265
|
+
const n_id = num.getAttribute("w:numId");
|
|
266
|
+
const a_ref = findChild(num, "w:abstractNumId");
|
|
267
|
+
if (n_id === null || !a_ref) continue;
|
|
268
|
+
const a_id = a_ref.getAttribute("w:val");
|
|
269
|
+
if (a_id !== null && abstract_fmts[a_id] !== undefined) {
|
|
270
|
+
cache[n_id] = abstract_fmts[a_id];
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
pkg._adeu_numbering_cache = cache;
|
|
276
|
+
return cache;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Markdown marker for a list paragraph: '* ' for bullets, '1. ' for every
|
|
281
|
+
* numbered format (Markdown renderers renumber sequentially). Unknown
|
|
282
|
+
* numbering (no numbering.xml entry) keeps the historical '* '.
|
|
283
|
+
*/
|
|
284
|
+
export function get_list_marker(
|
|
285
|
+
paragraph_part: any,
|
|
286
|
+
num_id: string | null,
|
|
287
|
+
ilvl: number,
|
|
288
|
+
): string {
|
|
289
|
+
let fmt: string | null = null;
|
|
290
|
+
if (num_id !== null && num_id !== undefined) {
|
|
291
|
+
const lvl_map = _get_numbering_cache(paragraph_part)[num_id];
|
|
292
|
+
if (lvl_map && Object.keys(lvl_map).length > 0) {
|
|
293
|
+
fmt = lvl_map[ilvl] !== undefined ? lvl_map[ilvl] : null;
|
|
294
|
+
if (fmt === null) {
|
|
295
|
+
// Fall back to the nearest defined level at or below ilvl.
|
|
296
|
+
for (let lookup = ilvl; lookup >= 0; lookup--) {
|
|
297
|
+
if (lvl_map[lookup] !== undefined) {
|
|
298
|
+
fmt = lvl_map[lookup];
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (fmt !== null && fmt !== "bullet") return "1. ";
|
|
306
|
+
return "* ";
|
|
307
|
+
}
|
|
308
|
+
|
|
152
309
|
function _detect_heading_level_from_name(name: string): number | null {
|
|
153
310
|
if (!name) return null;
|
|
154
311
|
const match = name.match(_CUSTOM_HEADING_NAME_RE);
|
|
@@ -256,21 +413,54 @@ export function get_paragraph_prefix(
|
|
|
256
413
|
|
|
257
414
|
if (style_name === "Title") return "# ";
|
|
258
415
|
|
|
416
|
+
// Check for List Formatting (direct paragraph numPr first, then the
|
|
417
|
+
// style chain — Word's built-in List Bullet/List Number styles keep their
|
|
418
|
+
// numPr in styles.xml, QA 2026-07-18 M4).
|
|
419
|
+
let list_num_id: string | null = null;
|
|
420
|
+
let list_ilvl: number | null = null;
|
|
421
|
+
let numbering_disabled = false;
|
|
259
422
|
if (pPr) {
|
|
260
423
|
const numPr = findChild(pPr, QN_W_NUMPR);
|
|
261
424
|
if (numPr) {
|
|
262
425
|
const numId = findChild(numPr, QN_W_NUMID);
|
|
263
|
-
if (numId
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
426
|
+
if (numId) {
|
|
427
|
+
const val = numId.getAttribute(QN_W_VAL);
|
|
428
|
+
if (val === "0") {
|
|
429
|
+
// ECMA-376 §17.9.15: a direct numId of 0 REMOVES the numbering a
|
|
430
|
+
// style would otherwise apply.
|
|
431
|
+
numbering_disabled = true;
|
|
432
|
+
} else if (val) {
|
|
433
|
+
list_num_id = val;
|
|
434
|
+
const ilvl = findChild(numPr, QN_W_ILVL);
|
|
435
|
+
if (ilvl) {
|
|
436
|
+
const valAttr = ilvl.getAttribute(QN_W_VAL);
|
|
437
|
+
if (valAttr !== null && /^\d+$/.test(valAttr)) {
|
|
438
|
+
list_ilvl = parseInt(valAttr, 10);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
269
441
|
}
|
|
270
|
-
return " ".repeat(level) + "* ";
|
|
271
442
|
}
|
|
272
443
|
}
|
|
273
444
|
}
|
|
445
|
+
if (list_num_id === null && !numbering_disabled && style_info) {
|
|
446
|
+
const style_num_id = style_info.num_id;
|
|
447
|
+
if (style_num_id) {
|
|
448
|
+
list_num_id = style_num_id;
|
|
449
|
+
if (list_ilvl === null) {
|
|
450
|
+
list_ilvl =
|
|
451
|
+
style_info.num_ilvl !== undefined ? style_info.num_ilvl : null;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (list_num_id !== null) {
|
|
456
|
+
const level = list_ilvl !== null ? list_ilvl : 0;
|
|
457
|
+
const marker = get_list_marker(
|
|
458
|
+
paragraph._parent ? paragraph._parent.part || paragraph._parent : null,
|
|
459
|
+
list_num_id,
|
|
460
|
+
level,
|
|
461
|
+
);
|
|
462
|
+
return " ".repeat(level) + marker;
|
|
463
|
+
}
|
|
274
464
|
|
|
275
465
|
if (style_name && style_name !== "Normal") {
|
|
276
466
|
const custom_level = _detect_heading_level_from_name(style_name);
|
|
@@ -415,6 +605,17 @@ export function* iter_block_items(
|
|
|
415
605
|
child.getAttribute("w:type") === "continuationSeparator"
|
|
416
606
|
)
|
|
417
607
|
continue;
|
|
608
|
+
// Word reserves non-positive note ids (-1 separator, 0 continuation
|
|
609
|
+
// separator). Some generators omit the w:type attribute on them, so
|
|
610
|
+
// filter by id as well — they must never surface as user footnotes
|
|
611
|
+
// like "[^fn--1]:" (QA 2026-07-18 M6).
|
|
612
|
+
const note_id = child.getAttribute("w:id");
|
|
613
|
+
if (
|
|
614
|
+
note_id !== null &&
|
|
615
|
+
/^\s*[-+]?\d+\s*$/.test(note_id) &&
|
|
616
|
+
parseInt(note_id, 10) <= 0
|
|
617
|
+
)
|
|
618
|
+
continue;
|
|
418
619
|
yield new FootnoteItem(child, parent, parent.note_type);
|
|
419
620
|
}
|
|
420
621
|
return;
|
|
@@ -433,16 +634,34 @@ export function* iter_block_items(
|
|
|
433
634
|
}
|
|
434
635
|
|
|
435
636
|
export function* iter_document_parts(doc: any): Generator<any> {
|
|
637
|
+
for (const [container] of iter_document_parts_with_kind(doc)) {
|
|
638
|
+
yield container;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Like iter_document_parts, but yields [container, kind] where kind is one
|
|
644
|
+
* of "header" / "body" / "footer" / "footnotes" / "endnotes".
|
|
645
|
+
*
|
|
646
|
+
* The kind sequence defines the document's structural part layout. The
|
|
647
|
+
* projection flattens all parts into one string, so diff/apply need these
|
|
648
|
+
* kinds to refuse (or correctly re-anchor) edits that would otherwise cross
|
|
649
|
+
* an OPC part boundary — the QA 2026-07-18 C1 failure wrote a final body
|
|
650
|
+
* paragraph into word/footer1.xml.
|
|
651
|
+
*/
|
|
652
|
+
export function* iter_document_parts_with_kind(
|
|
653
|
+
doc: any,
|
|
654
|
+
): Generator<[any, string]> {
|
|
436
655
|
// 1. Headers
|
|
437
656
|
const headers = doc.pkg.parts.filter(
|
|
438
657
|
(p: any) =>
|
|
439
658
|
p.contentType ===
|
|
440
659
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml",
|
|
441
660
|
);
|
|
442
|
-
for (const h of headers) yield h;
|
|
661
|
+
for (const h of headers) yield [h, "header"];
|
|
443
662
|
|
|
444
663
|
// 2. Main Document Body
|
|
445
|
-
yield doc;
|
|
664
|
+
yield [doc, "body"];
|
|
446
665
|
|
|
447
666
|
// 3. Footers
|
|
448
667
|
const footers = doc.pkg.parts.filter(
|
|
@@ -450,14 +669,14 @@ export function* iter_document_parts(doc: any): Generator<any> {
|
|
|
450
669
|
p.contentType ===
|
|
451
670
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml",
|
|
452
671
|
);
|
|
453
|
-
for (const f of footers) yield f;
|
|
672
|
+
for (const f of footers) yield [f, "footer"];
|
|
454
673
|
|
|
455
674
|
// 4. Notes
|
|
456
675
|
const fnPart = doc.pkg.getPartByPath("word/footnotes.xml");
|
|
457
676
|
const enPart = doc.pkg.getPartByPath("word/endnotes.xml");
|
|
458
677
|
|
|
459
|
-
if (fnPart) yield new NotesPart(fnPart, "fn");
|
|
460
|
-
if (enPart) yield new NotesPart(enPart, "en");
|
|
678
|
+
if (fnPart) yield [new NotesPart(fnPart, "fn"), "footnotes"];
|
|
679
|
+
if (enPart) yield [new NotesPart(enPart, "en"), "endnotes"];
|
|
461
680
|
}
|
|
462
681
|
|
|
463
682
|
function _is_page_instr(instr: string): boolean {
|
|
@@ -505,7 +724,29 @@ export function* iter_paragraph_content(
|
|
|
505
724
|
if (child.nodeType !== 1) continue;
|
|
506
725
|
|
|
507
726
|
const tag = child.tagName;
|
|
508
|
-
if (tag ===
|
|
727
|
+
if (tag === QN_W_DRAWING || tag === QN_W_OBJECT) {
|
|
728
|
+
// Inline image/object: project a read-only marker so the agent can
|
|
729
|
+
// see that a material element exists here (QA 2026-07-18 M5).
|
|
730
|
+
// id/date carry (docPr id, alt text) — the marker renders as
|
|
731
|
+
// .
|
|
732
|
+
const doc_pr = findAllDescendants(child, QN_WP_DOCPR)[0] || null;
|
|
733
|
+
let alt = "";
|
|
734
|
+
let img_id = "0";
|
|
735
|
+
if (doc_pr) {
|
|
736
|
+
alt = doc_pr.getAttribute("descr") || doc_pr.getAttribute("title") || "";
|
|
737
|
+
img_id = doc_pr.getAttribute("id") || "0";
|
|
738
|
+
}
|
|
739
|
+
yield { type: "image", id: img_id, date: alt };
|
|
740
|
+
} else if (tag === QN_W_PICT) {
|
|
741
|
+
// Legacy VML picture. Only actual images (v:imagedata) get a
|
|
742
|
+
// marker; textpath-only shapes (watermarks) are reported by
|
|
743
|
+
// sanitize's watermark audit instead of polluting the text.
|
|
744
|
+
const imagedata = findAllDescendants(child, QN_V_IMAGEDATA)[0] || null;
|
|
745
|
+
if (imagedata) {
|
|
746
|
+
const alt = imagedata.getAttribute(QN_O_TITLE) || "";
|
|
747
|
+
yield { type: "image", id: "vml", date: alt };
|
|
748
|
+
}
|
|
749
|
+
} else if (tag === QN_W_COMMENTREFERENCE) {
|
|
509
750
|
const ref_id = child.getAttribute(QN_W_ID);
|
|
510
751
|
if (ref_id) yield { type: "ref", id: ref_id };
|
|
511
752
|
} else if (tag === QN_W_FOOTNOTEREFERENCE) {
|