@adeu/core 1.22.0 → 1.23.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 +981 -71
- 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 +980 -71
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/diff.ts +500 -1
- package/src/docx/bridge.ts +13 -0
- package/src/domain.ts +58 -14
- package/src/engine.ts +288 -8
- 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/sanitize/core.ts +3 -0
- package/src/sanitize/transforms.ts +40 -0
- package/src/utils/docx.ts +257 -16
package/src/sanitize/core.ts
CHANGED
|
@@ -71,6 +71,9 @@ export async function finalize_document(doc: DocumentObject, options: FinalizeOp
|
|
|
71
71
|
|
|
72
72
|
const warnings = transforms.audit_hyperlinks(doc);
|
|
73
73
|
for (const w of warnings) report.warnings.push(w);
|
|
74
|
+
// Audit (non-destructive — just warnings): watermark-like VML text objects
|
|
75
|
+
// must never let the report declare the document clean (QA 2026-07-18 M3).
|
|
76
|
+
for (const w of transforms.detect_watermarks(doc)) report.warnings.push(w);
|
|
74
77
|
|
|
75
78
|
report.add_transform_lines(transforms.normalize_change_dates(doc));
|
|
76
79
|
|
|
@@ -543,6 +543,46 @@ export function strip_image_alt_text(doc: DocumentObject): string[] {
|
|
|
543
543
|
return count ? [`Image alt text: ${count} auto-generated descriptions removed`] : [];
|
|
544
544
|
}
|
|
545
545
|
|
|
546
|
+
/**
|
|
547
|
+
* Detect watermark-like embedded text objects (VML shapes with a textpath,
|
|
548
|
+
* Word's native watermark mechanism) in headers, footers and the body.
|
|
549
|
+
* Non-destructive: returns warnings so the report never declares a document
|
|
550
|
+
* fully clean while such an object silently remains (QA 2026-07-18 M3).
|
|
551
|
+
*/
|
|
552
|
+
export function detect_watermarks(doc: DocumentObject): string[] {
|
|
553
|
+
const warnings: string[] = [];
|
|
554
|
+
const seen = new Set<string>();
|
|
555
|
+
|
|
556
|
+
for (const part of doc.pkg.parts) {
|
|
557
|
+
const name = String(part.partname);
|
|
558
|
+
let location: string;
|
|
559
|
+
if (name.startsWith("/word/header")) location = "header";
|
|
560
|
+
else if (name.startsWith("/word/footer")) location = "footer";
|
|
561
|
+
else if (name === "/word/document.xml") location = "body";
|
|
562
|
+
else continue;
|
|
563
|
+
|
|
564
|
+
let textpaths: Element[];
|
|
565
|
+
try {
|
|
566
|
+
textpaths = findDescendantsByLocalName(part._element, "textpath");
|
|
567
|
+
} catch {
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
for (const tp of textpaths) {
|
|
571
|
+
const text = (tp.getAttribute("string") || "").trim();
|
|
572
|
+
const key = `${location}\x1f${text}`;
|
|
573
|
+
if (seen.has(key)) continue;
|
|
574
|
+
seen.add(key);
|
|
575
|
+
const label = text ? `"${_truncate(text, 60)}"` : "(no text)";
|
|
576
|
+
warnings.push(
|
|
577
|
+
`Watermark-like text object in ${location}: ${label} — NOT removed. ` +
|
|
578
|
+
"It remains in the document package and may render in other applications; " +
|
|
579
|
+
"review and remove it in Word if it must not reach the counterparty.",
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return warnings;
|
|
584
|
+
}
|
|
585
|
+
|
|
546
586
|
export function audit_hyperlinks(doc: DocumentObject): string[] {
|
|
547
587
|
const internal = ["sharepoint.com", "onedrive.com", ".internal", "intranet", "localhost", "10.", "192.168.", "172.16."];
|
|
548
588
|
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) {
|