@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/src/engine.ts CHANGED
@@ -303,6 +303,21 @@ export function validate_edit_strings(
303
303
  }
304
304
  }
305
305
 
306
+ // QA 2026-07-18 M5: image markers are read-only projections of
307
+ // w:drawing elements. They cannot be fabricated, duplicated or removed
308
+ // through text replacement.
309
+ if (t_text.includes("docx-image:") || n_text.includes("docx-image:")) {
310
+ const t_imgs = (t_text.match(/!\[[^\]]*\]\(docx-image:[^)]*\)/g) || []).sort();
311
+ const n_imgs = (n_text.match(/!\[[^\]]*\]\(docx-image:[^)]*\)/g) || []).sort();
312
+ if (JSON.stringify(t_imgs) !== JSON.stringify(n_imgs)) {
313
+ errors.push(
314
+ `- Edit ${i + 1 + index_offset} Failed: image markers (![alt](docx-image:N)) are read-only ` +
315
+ "projections of embedded images. They cannot be inserted, altered, or removed " +
316
+ "via text replacement — edit the text around the image instead.",
317
+ );
318
+ }
319
+ }
320
+
306
321
  if (t_text.includes("{#") || n_text.includes("{#")) {
307
322
  const t_anchors = t_text.match(/\{#[^\}]+\}/g) || [];
308
323
  const n_anchors = n_text.match(/\{#[^\}]+\}/g) || [];
@@ -1183,6 +1198,54 @@ export class RedlineEngine {
1183
1198
  }
1184
1199
  }
1185
1200
 
1201
+ /**
1202
+ * Walks `element` to its XML root element. Word (and LibreOffice, which
1203
+ * refuses to LOAD such files) only supports comment ranges in the main
1204
+ * document story ("w:document") — never in headers, footers, footnotes or
1205
+ * endnotes (QA 2026-07-18 H4/C1).
1206
+ */
1207
+ private _comment_anchor_in_main_story(element: Element): boolean {
1208
+ let root: Element = element;
1209
+ while (root.parentNode && root.parentNode.nodeType === 1) {
1210
+ root = root.parentNode as Element;
1211
+ }
1212
+ return root.tagName === "w:document";
1213
+ }
1214
+
1215
+ /**
1216
+ * When the anchor lives outside the main document story, records a
1217
+ * user-visible warning and returns true (caller must skip the comment).
1218
+ * The tracked change itself still applies — only the bubble is dropped.
1219
+ */
1220
+ private _skip_comment_outside_main_story(
1221
+ element: Element,
1222
+ text: string,
1223
+ ): boolean {
1224
+ if (this._comment_anchor_in_main_story(element)) return false;
1225
+ let root: Element = element;
1226
+ while (root.parentNode && root.parentNode.nodeType === 1) {
1227
+ root = root.parentNode as Element;
1228
+ }
1229
+ const story =
1230
+ (
1231
+ {
1232
+ "w:ftr": "footer",
1233
+ "w:hdr": "header",
1234
+ "w:footnotes": "footnote",
1235
+ "w:endnotes": "endnote",
1236
+ } as Record<string, string>
1237
+ )[root.tagName] || "non-body";
1238
+ const msg =
1239
+ `- Warning: the comment "${text.substring(0, 60)}" was NOT attached: Word does not support ` +
1240
+ `comments inside a ${story} part, and writing one produces a document other ` +
1241
+ "applications cannot open. The tracked change itself was applied.";
1242
+ this.skipped_details.push(msg);
1243
+ console.error(
1244
+ `Comment anchor outside main story; comment dropped (story=${story})`,
1245
+ );
1246
+ return true;
1247
+ }
1248
+
1186
1249
  /**
1187
1250
  * Attaches a comment that wraps a contiguous range within a single paragraph.
1188
1251
  * start_element and end_element must both be direct children of parent_element
@@ -1196,6 +1259,8 @@ export class RedlineEngine {
1196
1259
  text: string,
1197
1260
  ) {
1198
1261
  if (!text) return;
1262
+ if (!parent_element || !start_element || !end_element) return;
1263
+ if (this._skip_comment_outside_main_story(parent_element, text)) return;
1199
1264
 
1200
1265
  const comment_id = this.comments_manager.addComment(this.author, text);
1201
1266
  const xmlDoc = parent_element.ownerDocument!;
@@ -1247,6 +1312,12 @@ export class RedlineEngine {
1247
1312
  text: string,
1248
1313
  ) {
1249
1314
  if (!text) return;
1315
+ if (!start_p || !end_p) return;
1316
+ if (
1317
+ this._skip_comment_outside_main_story(start_p, text) ||
1318
+ this._skip_comment_outside_main_story(end_p, text)
1319
+ )
1320
+ return;
1250
1321
 
1251
1322
  const comment_id = this.comments_manager.addComment(this.author, text);
1252
1323
  const xmlDocStart = start_p.ownerDocument!;
@@ -2030,6 +2101,99 @@ export class RedlineEngine {
2030
2101
  pfx,
2031
2102
  (edit.new_text || "").length - sfx,
2032
2103
  );
2104
+
2105
+ // QA 2026-07-18 C1: the projection flattens headers, body, footers
2106
+ // and notes into one string, but a text edit whose matched span
2107
+ // covers real text from two different OPC parts cannot be applied
2108
+ // without putting content in the wrong part — including the
2109
+ // insertion shape, whose anchor point at the part gap is inherently
2110
+ // ambiguous. Refuse the RAW match range outright and ask for a
2111
+ // single-part anchor. (Single-part documents skip the scan.)
2112
+ const multi_part_doc =
2113
+ target_mapper.part_ranges.filter((r) => r[1] > r[0]).length > 1;
2114
+ const raw_span_parts = multi_part_doc
2115
+ ? Array.from(
2116
+ new Set(
2117
+ target_mapper.spans
2118
+ .filter(
2119
+ (s) =>
2120
+ s.run !== null &&
2121
+ s.end > m_start &&
2122
+ s.start < m_start + m_len,
2123
+ )
2124
+ .map((s) => s.part_index),
2125
+ ),
2126
+ ).sort((a, b) => a - b)
2127
+ : [];
2128
+ if (raw_span_parts.length > 1) {
2129
+ const kinds = raw_span_parts
2130
+ .map((pi) => target_mapper.part_kind_of(pi) || "?")
2131
+ .join(" → ");
2132
+ errors.push(
2133
+ `- Edit ${i + 1 + index_offset} Failed: target_text spans a structural document-part ` +
2134
+ `boundary (${kinds}). Headers, body, footers and footnotes are separate ` +
2135
+ "Word parts — an edit cannot cross between them. Anchor the edit on text " +
2136
+ "within a single part (split it into one edit per part if both sides " +
2137
+ "must change).",
2138
+ );
2139
+ }
2140
+
2141
+ // QA 2026-07-18 M5: image markers are read-only projections. Only
2142
+ // the CHANGED span matters — markers sitting untouched in the
2143
+ // shared context are fine.
2144
+ const eff_start = m_start + pfx;
2145
+ const eff_end = m_start + m_len - sfx;
2146
+ if (eff_end > eff_start) {
2147
+ const overlapping = target_mapper.spans.filter(
2148
+ (s) =>
2149
+ s.end > eff_start &&
2150
+ s.start < eff_end &&
2151
+ (s.run !== null || s.text.trim() !== ""),
2152
+ );
2153
+ if (overlapping.some((s) => (s as any).is_image_marker)) {
2154
+ errors.push(
2155
+ `- Edit ${i + 1 + index_offset} Failed: the target overlaps a read-only image marker ` +
2156
+ "(![alt](docx-image:N)). Images cannot be edited or removed via text " +
2157
+ "replacement — target the text around the image instead.",
2158
+ );
2159
+ }
2160
+ }
2161
+
2162
+ // QA 2026-07-18 H4: comments can only be anchored in the main
2163
+ // document story. A comment-only edit (target == new) whose match
2164
+ // lives in a header/footer/footnote has no effect Word or
2165
+ // LibreOffice could render — refuse it clearly.
2166
+ if (edit.comment && (edit.new_text || "") === (edit.target_text || "")) {
2167
+ const kind_here = target_mapper.part_kind_at(m_start);
2168
+ if (kind_here !== null && kind_here !== "body") {
2169
+ errors.push(
2170
+ `- Edit ${i + 1 + index_offset} Failed: comments cannot be anchored inside a ${kind_here} ` +
2171
+ "part — Word only supports comments in the main document body. Comment on " +
2172
+ "the related body text instead.",
2173
+ );
2174
+ }
2175
+ }
2176
+
2177
+ // QA 2026-07-18 C2: a replacement may not smuggle new pipe-delimited
2178
+ // row lines into a table cell. Rows are structural; adding one
2179
+ // requires the insert_row operation.
2180
+ if (
2181
+ RedlineEngine._introduces_table_row_text(
2182
+ target_mapper,
2183
+ m_start,
2184
+ m_len,
2185
+ final_target,
2186
+ final_new,
2187
+ )
2188
+ ) {
2189
+ errors.push(
2190
+ `- Edit ${i + 1 + index_offset} Failed: new_text introduces a pipe-delimited row line inside ` +
2191
+ "a table. Text replacement cannot create table rows — use the structured " +
2192
+ `'insert_row' operation instead (e.g. {"type": "insert_row", ` +
2193
+ `"target_text": "<anchor row text>", "cells": ["...", "..."]}).`,
2194
+ );
2195
+ }
2196
+
2033
2197
  if (final_target.includes("\n\n")) {
2034
2198
  // A *balanced* multi-paragraph modification (target and replacement
2035
2199
  // carry the same number of paragraph breaks) is safe: it is split
@@ -2182,6 +2346,33 @@ export class RedlineEngine {
2182
2346
  * [start, start+length) in `mapper`, or null if that text is not inside a
2183
2347
  * table row.
2184
2348
  */
2349
+ /**
2350
+ * True when a replacement anchored in a table would ADD line-separated
2351
+ * pipe-delimited content — the text shape of a table row. Writing that
2352
+ * into a cell renders a fake row inside one cell while the real grid
2353
+ * stays unchanged (QA 2026-07-18 C2); such edits must use insert_row.
2354
+ */
2355
+ private static _introduces_table_row_text(
2356
+ mapper: DocumentMapper,
2357
+ start: number,
2358
+ length: number,
2359
+ final_target: string,
2360
+ final_new: string,
2361
+ ): boolean {
2362
+ if (!final_new.includes("\n") || !final_new.includes(" | ")) return false;
2363
+ const new_pipe_lines = final_new
2364
+ .split("\n")
2365
+ .filter((line) => line.includes(" | ")).length;
2366
+ const old_pipe_lines = final_target
2367
+ .split("\n")
2368
+ .filter((line) => line.includes(" | ")).length;
2369
+ if (new_pipe_lines <= old_pipe_lines) return false;
2370
+ return (
2371
+ RedlineEngine._column_count_at(mapper, start, Math.max(length, 1)) !==
2372
+ null
2373
+ );
2374
+ }
2375
+
2185
2376
  private static _column_count_at(
2186
2377
  mapper: DocumentMapper,
2187
2378
  start: number,
@@ -2744,7 +2935,13 @@ export class RedlineEngine {
2744
2935
 
2745
2936
  for (const [edit, orig_new] of resolved_edits) {
2746
2937
  const start = edit._resolved_start_idx || 0;
2747
- const end = start + (edit.target_text ? edit.target_text.length : 0);
2938
+ // An insert_row does not consume its anchor text — it adds an adjacent
2939
+ // row. Give it a zero-width range so several inserts sharing one
2940
+ // anchor (consecutive new rows) never flag each other as overlapping.
2941
+ const end =
2942
+ edit.type === "insert_row"
2943
+ ? start
2944
+ : start + (edit.target_text ? edit.target_text.length : 0);
2748
2945
 
2749
2946
  const overlaps = occupied_ranges.some(
2750
2947
  ([occ_start, occ_end]) => start < occ_end && end > occ_start,
@@ -3605,20 +3802,80 @@ export class RedlineEngine {
3605
3802
  return true;
3606
3803
  }
3607
3804
  if (op === "INSERTION") {
3608
- const [anchor_run, anchor_para] = active_mapper.get_insertion_anchor(
3609
- start_idx,
3610
- rebuild_map,
3611
- );
3805
+ let final_new_text = edit.new_text || "";
3806
+
3807
+ // QA 2026-07-18 C1: a MACHINE-PINNED pure insertion (diff/text
3808
+ // round-trip output: authored with an empty target and no parent
3809
+ // edit) positioned in the separator gap between the body and a
3810
+ // following part used to anchor on the NEXT part's first paragraph,
3811
+ // writing the new final body paragraph into word/footer1.xml.
3812
+ // Re-anchor it to the end of the body and force new-paragraph
3813
+ // semantics. Insertions DERIVED from a target-anchored edit (parent
3814
+ // ref set — e.g. prepending "DRAFT " to "FOOTER MARKER") keep the
3815
+ // user's chosen anchor: their context names the part they meant.
3816
+ let boundary_anchor: TextSpan | null = null;
3817
+ const boundary =
3818
+ typeof (active_mapper as any).part_boundary_at === "function"
3819
+ ? active_mapper.part_boundary_at(start_idx)
3820
+ : null;
3821
+ const is_machine_pure_insertion =
3822
+ !edit.target_text &&
3823
+ (edit._parent_edit_ref === undefined || edit._parent_edit_ref === null);
3824
+ if (boundary !== null && is_machine_pure_insertion) {
3825
+ const [prev_i, next_i] = boundary;
3826
+ const prev_kind = active_mapper.part_kind_of(prev_i);
3827
+ const next_kind = active_mapper.part_kind_of(next_i);
3828
+ if (prev_kind === "body" && next_kind !== "body") {
3829
+ const real_before = active_mapper.spans.filter(
3830
+ (s: TextSpan) => s.run !== null && s.part_index === prev_i,
3831
+ );
3832
+ if (real_before.length > 0) {
3833
+ boundary_anchor = real_before[real_before.length - 1];
3834
+ }
3835
+ }
3836
+ }
3837
+
3838
+ let anchor_run: Run | null;
3839
+ let anchor_para: Paragraph | null;
3840
+ if (boundary_anchor !== null) {
3841
+ anchor_run = boundary_anchor.run;
3842
+ anchor_para = boundary_anchor.paragraph;
3843
+ if (!final_new_text.startsWith("\n")) {
3844
+ final_new_text = "\n\n" + final_new_text;
3845
+ }
3846
+ } else {
3847
+ [anchor_run, anchor_para] = active_mapper.get_insertion_anchor(
3848
+ start_idx,
3849
+ rebuild_map,
3850
+ );
3851
+ }
3612
3852
  if (!anchor_run && !anchor_para) return false;
3613
3853
 
3854
+ // QA 2026-07-18 C2 (apply-level backstop, pinned edits bypass
3855
+ // validate_edits): refuse insertions that would write row-shaped pipe
3856
+ // text into a table cell.
3857
+ if (
3858
+ RedlineEngine._introduces_table_row_text(
3859
+ active_mapper,
3860
+ start_idx,
3861
+ 1,
3862
+ "",
3863
+ final_new_text,
3864
+ )
3865
+ ) {
3866
+ return false;
3867
+ }
3868
+
3614
3869
  // BUG-23-3: a prefix insertion whose new_text ends in a paragraph break
3615
3870
  // (e.g. "Summary\n\n" inserted before "Conclusion") must become a NEW
3616
3871
  // paragraph placed BEFORE the anchor paragraph, not inline text merged
3617
3872
  // into a neighbouring paragraph. _track_insert_multiline drops the
3618
3873
  // trailing break and inlines the remainder, which both loses the
3619
3874
  // paragraph boundary and mis-orders the content. Handle this case here.
3620
- const _bug233_new = edit.new_text || "";
3621
- const _bug233_trailing_break = /\n\s*$/.test(_bug233_new);
3875
+ // (Skipped when the C1 boundary re-anchor above took over the anchor.)
3876
+ const _bug233_new = final_new_text;
3877
+ const _bug233_trailing_break =
3878
+ boundary_anchor === null && /\n\s*$/.test(_bug233_new);
3622
3879
  let _bug233_target_para: Element | null = null;
3623
3880
  {
3624
3881
  const startingSpans = active_mapper.spans.filter(
@@ -3696,7 +3953,7 @@ export class RedlineEngine {
3696
3953
  }
3697
3954
 
3698
3955
  const result = this._track_insert_multiline(
3699
- edit.new_text || "",
3956
+ final_new_text,
3700
3957
  anchor_run,
3701
3958
  anchor_para,
3702
3959
  ins_id!,
@@ -3823,6 +4080,29 @@ export class RedlineEngine {
3823
4080
  return true;
3824
4081
  }
3825
4082
 
4083
+ // QA 2026-07-18 C1 (apply-level backstop, pinned edits bypass
4084
+ // validate_edits): a modification/deletion may never mutate real text
4085
+ // from two different OPC parts in one span. Single-part documents skip
4086
+ // the scan.
4087
+ if (
4088
+ (op === "DELETION" || op === "MODIFICATION") &&
4089
+ length &&
4090
+ active_mapper.part_ranges.filter((r: [number, number, string]) => r[1] > r[0]).length > 1
4091
+ ) {
4092
+ const crossed_parts = new Set<number>();
4093
+ for (const s of active_mapper.spans) {
4094
+ if (s.run !== null && s.end > start_idx && s.start < start_idx + length) {
4095
+ crossed_parts.add(s.part_index);
4096
+ }
4097
+ }
4098
+ if (crossed_parts.size > 1) {
4099
+ console.error(
4100
+ `Refusing edit that spans OPC part boundary (start=${start_idx}, parts=${Array.from(crossed_parts).sort().join(",")})`,
4101
+ );
4102
+ return false;
4103
+ }
4104
+ }
4105
+
3826
4106
  // DELETION / MODIFICATION
3827
4107
  const target_runs = active_mapper.find_target_runs_by_index(
3828
4108
  start_idx,
package/src/index.ts CHANGED
@@ -5,10 +5,10 @@ export function identifyEngine() {
5
5
  export { DocumentObject } from './docx/bridge.js';
6
6
  export { DocumentMapper, TextSpan } from './mapper.js';
7
7
  export { RedlineEngine, BatchValidationError, validate_edit_strings, describe_illegal_control_chars } from './engine.js';
8
- export { generate_edits_from_text, trim_common_context, create_unified_diff, create_word_patch_diff } from './diff.js';
9
- export { apply_edits_to_markdown } from './markup.js';
8
+ export { generate_edits_from_text, generate_structured_edits, trim_common_context, create_unified_diff, create_word_patch_diff, DiffEdit } from './diff.js';
9
+ export { apply_edits_to_markdown, MarkupEditReport } from './markup.js';
10
10
  export { paginate, split_structural_appendix, PaginationResult, PageInfo } from './pagination.js';
11
11
  export { extract_outline, OutlineNode } from './outline.js';
12
- export { extractTextFromBuffer, _extractTextFromDoc } from './ingest.js';
12
+ export { extractTextFromBuffer, _extractTextFromDoc, ExtractStructure, TableGeometry, RowGeometry } from './ingest.js';
13
13
  export { finalize_document, FinalizeOptions, FinalizeResult } from './sanitize/core.js';
14
14
  export { RegexTimeoutError, userFindAllMatches, userSearch, USER_PATTERN_TIMEOUT_MS } from './utils/safe-regex.js';
package/src/ingest.ts CHANGED
@@ -9,19 +9,46 @@ import {
9
9
  get_run_text,
10
10
  apply_formatting_to_segments,
11
11
  iter_block_items,
12
- iter_document_parts,
12
+ iter_document_parts_with_kind,
13
13
  iter_paragraph_content,
14
14
  } from "./utils/docx.js";
15
15
  import { findChild } from "./docx/dom.js";
16
16
  import { build_structural_appendix } from "./domain.js";
17
17
  import { extract_comments_data } from "./comments.js";
18
18
 
19
+ /** Text-space extent of one projected table row plus its cell texts. */
20
+ export interface RowGeometry {
21
+ start: number;
22
+ end: number;
23
+ cells: string[];
24
+ }
25
+
26
+ /** Text-space extent of one projected top-level table. */
27
+ export interface TableGeometry {
28
+ start: number;
29
+ end: number;
30
+ rows: RowGeometry[];
31
+ }
32
+
33
+ /**
34
+ * Structural map of a projection: which offset ranges belong to which OPC
35
+ * part, and where top-level table rows live. Produced in the SAME pass as
36
+ * the text, so offsets always agree with it. Consumed by the diff pipeline
37
+ * to keep generated edits from crossing part boundaries (QA 2026-07-18 C1)
38
+ * and to emit structured row operations for table changes (QA C2).
39
+ */
40
+ export interface ExtractStructure {
41
+ part_ranges: [number, number, string][]; // [start, end, kind]
42
+ tables: TableGeometry[];
43
+ }
44
+
19
45
  export async function extractTextFromBuffer(
20
46
  buffer: Buffer,
21
47
  cleanView = false,
48
+ includeAppendix = true,
22
49
  ): Promise<string> {
23
50
  const doc = await DocumentObject.load(buffer);
24
- return _extractTextFromDoc(doc, cleanView) as string;
51
+ return _extractTextFromDoc(doc, cleanView, includeAppendix) as string;
25
52
  }
26
53
 
27
54
  export function _extractTextFromDoc(
@@ -29,14 +56,21 @@ export function _extractTextFromDoc(
29
56
  cleanView = false,
30
57
  includeAppendix = true,
31
58
  return_paragraph_offsets = false,
32
- ): string | { text: string; paragraph_offsets: Map<any, [number, number]> } {
59
+ return_structure = false,
60
+ ):
61
+ | string
62
+ | { text: string; paragraph_offsets: Map<any, [number, number]> }
63
+ | { text: string; structure: ExtractStructure } {
33
64
  const comments_map = extract_comments_data(doc.pkg);
34
65
 
35
66
  const full_text: string[] = [];
36
67
  const paragraph_offsets = new Map<any, [number, number]>();
68
+ const structure: ExtractStructure | null = return_structure
69
+ ? { part_ranges: [], tables: [] }
70
+ : null;
37
71
  let cursor = 0;
38
72
 
39
- for (const part of iter_document_parts(doc)) {
73
+ for (const [part, part_kind] of iter_document_parts_with_kind(doc)) {
40
74
  const part_cursor = full_text.length > 0 ? cursor + 2 : cursor;
41
75
  const part_text = _extract_blocks(
42
76
  part,
@@ -44,10 +78,14 @@ export function _extractTextFromDoc(
44
78
  cleanView,
45
79
  part_cursor,
46
80
  return_paragraph_offsets ? paragraph_offsets : undefined,
81
+ structure ? structure.tables : undefined,
47
82
  );
48
83
  if (part_text) {
49
84
  if (full_text.length > 0) cursor += 2;
50
85
  full_text.push(part_text);
86
+ if (structure) {
87
+ structure.part_ranges.push([cursor, cursor + part_text.length, part_kind]);
88
+ }
51
89
  cursor += part_text.length;
52
90
  }
53
91
  }
@@ -62,15 +100,24 @@ export function _extractTextFromDoc(
62
100
  if (return_paragraph_offsets) {
63
101
  return { text: base_text, paragraph_offsets };
64
102
  }
103
+ if (structure) {
104
+ return { text: base_text, structure };
105
+ }
65
106
  return base_text;
66
107
  }
67
108
 
109
+ /**
110
+ * table_acc: optional list collecting TableGeometry for TOP-LEVEL tables.
111
+ * Deliberately not forwarded into cells — nested tables stay invisible to
112
+ * the structured row-op diff, whose row pairing assumes one flat grid.
113
+ */
68
114
  function _extract_blocks(
69
115
  container: any,
70
116
  comments_map: any,
71
117
  cleanView: boolean,
72
118
  cursor: number,
73
119
  paragraph_offsets?: Map<any, [number, number]>,
120
+ table_acc?: TableGeometry[],
74
121
  ): string {
75
122
  const part = container.part || container;
76
123
  const [style_cache, default_pstyle] = _get_style_cache(part);
@@ -129,17 +176,25 @@ function _extract_blocks(
129
176
  is_first_para = false;
130
177
  is_first_block = false;
131
178
  } else if (item instanceof Table) {
179
+ const geometry: TableGeometry | null = table_acc
180
+ ? { start: block_start, end: block_start, rows: [] }
181
+ : null;
132
182
  const table_text = extract_table(
133
183
  item,
134
184
  comments_map,
135
185
  cleanView,
136
186
  block_start,
137
187
  paragraph_offsets,
188
+ geometry,
138
189
  );
139
190
  if (table_text) {
140
191
  blocks.push(table_text);
141
192
  local_cursor = block_start + table_text.length;
142
193
  is_first_block = false;
194
+ if (geometry && table_acc) {
195
+ geometry.end = block_start + table_text.length;
196
+ table_acc.push(geometry);
197
+ }
143
198
  } else if (!is_first_block) {
144
199
  local_cursor -= 2;
145
200
  }
@@ -156,6 +211,7 @@ export function extract_table(
156
211
  cleanView: boolean,
157
212
  cursor: number,
158
213
  paragraph_offsets?: Map<any, [number, number]>,
214
+ geometry?: TableGeometry | null,
159
215
  ): string {
160
216
  const rows_text: string[] = [];
161
217
  let rows_processed = 0;
@@ -221,6 +277,13 @@ export function extract_table(
221
277
 
222
278
  rows_text.push(row_str);
223
279
  local_cursor = row_start + row_str.length;
280
+ if (geometry) {
281
+ geometry.rows.push({
282
+ start: row_start,
283
+ end: local_cursor,
284
+ cells: [...cell_texts],
285
+ });
286
+ }
224
287
  rows_processed++;
225
288
  }
226
289
 
@@ -424,7 +487,16 @@ export function build_paragraph_text(
424
487
  else if (ev.type === "del_end") delete active_del[ev.id];
425
488
  else if (ev.type === "fmt_start") active_fmt[ev.id] = ev;
426
489
  else if (ev.type === "fmt_end") delete active_fmt[ev.id];
427
- else if (ev.type === "footnote" || ev.type === "endnote") {
490
+ else if (ev.type === "image") {
491
+ // Read-only image marker (QA 2026-07-18 M5); hidden with its run in
492
+ // clean view when it sits inside an active tracked deletion.
493
+ if (!(cleanView && Object.keys(active_del).length > 0)) {
494
+ const alt = (ev.date || "image")
495
+ .replace(/\]/g, ")")
496
+ .replace(/\n/g, " ");
497
+ parts.push(`![${alt}](docx-image:${ev.id})`);
498
+ }
499
+ } else if (ev.type === "footnote" || ev.type === "endnote") {
428
500
  if (pending_text) {
429
501
  parts.push(
430
502
  `${current_wrappers[0]}${pending_text}${current_wrappers[1]}`,