@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.
@@ -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
- export function strip_custom_xml(doc: DocumentObject): string[] {
502
- const customParts = doc.pkg.parts.filter(p => p.partname.includes('/customXml'));
503
- if (customParts.length === 0) return [];
504
-
505
- const partnames = new Set(customParts.map(p => p.partname));
506
- doc.pkg.parts = doc.pkg.parts.filter(p => !partnames.has(p.partname));
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
- const removeRelationsTo = (relsPart: Part) => {
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(relsPart._element, 'Relationship')) {
511
- const target = rel.getAttribute('Target');
512
- if (target && target.includes('customXml')) toRemove.push(rel);
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
- const rootRels = doc.pkg.getPartByPath('_rels/.rels');
518
- if (rootRels) removeRelationsTo(rootRels);
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
- const docRels = doc.pkg.getOrCreateRelsPart(doc.part.partname);
521
- if (docRels) removeRelationsTo(docRels);
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] = { name, based_on, outline_level: outline_lvl, bold };
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 { name: s_id, outline_level: null, bold: false };
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 = { name: raw.name, outline_level: o_lvl, bold: bold_val };
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 && numId.getAttribute(QN_W_VAL) !== "0") {
264
- let level = 0;
265
- const ilvl = findChild(numPr, QN_W_ILVL);
266
- if (ilvl) {
267
- const valAttr = ilvl.getAttribute(QN_W_VAL);
268
- if (valAttr) level = parseInt(valAttr, 10) || 0;
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 === QN_W_COMMENTREFERENCE) {
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
+ // ![alt](docx-image:id).
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) {