@adeu/core 1.21.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/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]}`,
package/src/mapper.ts CHANGED
@@ -2,6 +2,7 @@ import { DocumentObject } from "./docx/bridge.js";
2
2
  import { Paragraph, Table, Run, DocxEvent } from "./docx/primitives.js";
3
3
  import { findAllDescendants, findChild } from "./docx/dom.js";
4
4
  import { extract_comments_data } from "./comments.js";
5
+ import { RegexTimeoutError, userFindAllMatches, userSearch } from "./utils/safe-regex.js";
5
6
  import {
6
7
  _get_style_cache,
7
8
  get_paragraph_prefix,
@@ -10,7 +11,7 @@ import {
10
11
  is_heading_paragraph,
11
12
  is_native_heading,
12
13
  iter_block_items,
13
- iter_document_parts,
14
+ iter_document_parts_with_kind,
14
15
  iter_paragraph_content,
15
16
  } from "./utils/docx.js";
16
17
 
@@ -24,6 +25,12 @@ export interface TextSpan {
24
25
  del_id?: string | null;
25
26
  hyperlink_id?: string | null;
26
27
  comment_ids?: string[];
28
+ // Which OPC part (index into DocumentMapper.part_ranges) this span was
29
+ // projected from. Text edits may never resolve across two different
30
+ // parts — the QA 2026-07-18 C1 corruption wrote body text into a footer.
31
+ part_index: number;
32
+ // True for the read-only image marker projection ![alt](docx-image:N).
33
+ is_image_marker?: boolean;
27
34
  }
28
35
 
29
36
  export function renumber_snapshot_ids(
@@ -119,6 +126,11 @@ export class DocumentMapper {
119
126
  public full_text: string = "";
120
127
  public spans: TextSpan[] = [];
121
128
  public appendix_start_index: number = -1;
129
+ // [start, end, kind] per projected part, in projection order. Spans carry
130
+ // the matching index in .part_index. Together these let the engine refuse
131
+ // or re-anchor edits at OPC part boundaries (QA 2026-07-18 C1).
132
+ public part_ranges: [number, number, string][] = [];
133
+ private _current_part_index = 0;
122
134
  private _text_chunks: string[] = [];
123
135
  private _plain_projection: [string, number[]] | null = null;
124
136
 
@@ -140,9 +152,15 @@ export class DocumentMapper {
140
152
  this._text_chunks = [];
141
153
  this.full_text = "";
142
154
  this._plain_projection = null;
155
+ this.part_ranges = [];
156
+ this._current_part_index = 0;
143
157
 
144
- for (const part of iter_document_parts(this.doc)) {
158
+ let part_idx = 0;
159
+ for (const [part, part_kind] of iter_document_parts_with_kind(this.doc)) {
160
+ this._current_part_index = part_idx;
161
+ const part_start = current_offset;
145
162
  current_offset = this._map_blocks(part, current_offset);
163
+ this.part_ranges.push([part_start, current_offset, part_kind]);
146
164
 
147
165
  if (
148
166
  this.spans.length > 0 &&
@@ -151,6 +169,7 @@ export class DocumentMapper {
151
169
  this._add_virtual_text("\n\n", current_offset, null);
152
170
  current_offset += 2;
153
171
  }
172
+ part_idx++;
154
173
  }
155
174
 
156
175
  while (
@@ -165,6 +184,51 @@ export class DocumentMapper {
165
184
  this.appendix_start_index = -1;
166
185
  }
167
186
 
187
+ /** [part_index, start, end, kind] for parts that projected any text. */
188
+ private _nonempty_part_ranges(): [number, number, number, string][] {
189
+ const out: [number, number, number, string][] = [];
190
+ for (let i = 0; i < this.part_ranges.length; i++) {
191
+ const [s, e, k] = this.part_ranges[i];
192
+ if (e > s) out.push([i, s, e, k]);
193
+ }
194
+ return out;
195
+ }
196
+
197
+ public part_kind_of(part_index: number): string | null {
198
+ if (part_index >= 0 && part_index < this.part_ranges.length) {
199
+ return this.part_ranges[part_index][2];
200
+ }
201
+ return null;
202
+ }
203
+
204
+ /** Kind of the part whose projected range contains `index`, or null. */
205
+ public part_kind_at(index: number): string | null {
206
+ for (const [, start, end, kind] of this._nonempty_part_ranges()) {
207
+ if (start <= index && index <= end) return kind;
208
+ }
209
+ return null;
210
+ }
211
+
212
+ /**
213
+ * When `index` falls strictly AFTER one part's text and at-or-before the
214
+ * start of the next part's text (i.e. inside the "\n\n" separator or
215
+ * exactly at the next part's first character), returns
216
+ * [previous_part_index, next_part_index]. Returns null everywhere else —
217
+ * including index == previous part's end, which is an ordinary
218
+ * end-of-part text position, not a boundary gap.
219
+ */
220
+ public part_boundary_at(index: number): [number, number] | null {
221
+ const ranges = this._nonempty_part_ranges();
222
+ for (let j = 1; j < ranges.length; j++) {
223
+ const [prev_i, , prev_end] = ranges[j - 1];
224
+ const [next_i, next_start] = ranges[j];
225
+ if (prev_end < index && index <= next_start) {
226
+ return [prev_i, next_i];
227
+ }
228
+ }
229
+ return null;
230
+ }
231
+
168
232
  private _map_blocks(container: any, offset: number): number {
169
233
  let current = offset;
170
234
  const c_type = container.constructor.name;
@@ -334,6 +398,7 @@ export class DocumentMapper {
334
398
  text: "",
335
399
  run: null,
336
400
  paragraph,
401
+ part_index: this._current_part_index,
337
402
  };
338
403
  this.spans.push(span);
339
404
 
@@ -376,6 +441,7 @@ export class DocumentMapper {
376
441
  del_id: d_id || undefined,
377
442
  hyperlink_id: active_hyperlink_id || undefined,
378
443
  comment_ids: c_ids.length > 0 ? c_ids : undefined,
444
+ part_index: this._current_part_index,
379
445
  };
380
446
  this.spans.push(s);
381
447
  this._text_chunks.push(txt);
@@ -608,7 +674,21 @@ export class DocumentMapper {
608
674
  else if (ev.type === "del_end") delete active_del[ev.id];
609
675
  else if (ev.type === "fmt_start") active_fmt[ev.id] = ev;
610
676
  else if (ev.type === "fmt_end") delete active_fmt[ev.id];
611
- else if (ev.type === "footnote" || ev.type === "endnote") {
677
+ else if (ev.type === "image") {
678
+ // Read-only image marker (QA 2026-07-18 M5). Hidden when the run is
679
+ // filtered by the active view, exactly like its neighbouring text.
680
+ const hidden =
681
+ (this.clean_view && Object.keys(active_del).length > 0) ||
682
+ (this.original_view && Object.keys(active_ins).length > 0);
683
+ if (!hidden) {
684
+ const alt = (ev.date || "image")
685
+ .replace(/\]/g, ")")
686
+ .replace(/\n/g, " ");
687
+ const txt = `![${alt}](docx-image:${ev.id})`;
688
+ this._add_virtual_text(txt, current, paragraph, null, true);
689
+ current += txt.length;
690
+ }
691
+ } else if (ev.type === "footnote" || ev.type === "endnote") {
612
692
  flush_pending_runs();
613
693
  current_wrappers = ["", ""];
614
694
  current_style = ["", ""];
@@ -742,6 +822,7 @@ export class DocumentMapper {
742
822
  offset: number,
743
823
  context_paragraph: Paragraph | null,
744
824
  hyperlink_id: string | null = null,
825
+ is_image_marker = false,
745
826
  ) {
746
827
  const span: TextSpan = {
747
828
  start: offset,
@@ -750,6 +831,8 @@ export class DocumentMapper {
750
831
  run: null,
751
832
  paragraph: context_paragraph,
752
833
  hyperlink_id: hyperlink_id || undefined,
834
+ part_index: this._current_part_index,
835
+ is_image_marker: is_image_marker || undefined,
753
836
  };
754
837
  this.spans.push(span);
755
838
  this._text_chunks.push(text);
@@ -879,11 +962,16 @@ export class DocumentMapper {
879
962
  is_regex: boolean = false,
880
963
  ): [number, number] {
881
964
  if (is_regex) {
965
+ // User/LLM-supplied pattern: run it under a wall-clock budget so a
966
+ // catastrophic pattern cannot hang the event loop (QA 2026-07-17 F5).
967
+ // RegexTimeoutError propagates for a clean per-edit error; only
968
+ // invalid-pattern errors mean "no match" here.
882
969
  try {
883
- const pattern = new RegExp(target_text);
884
- const match = pattern.exec(this.full_text);
885
- if (match) return [match.index, match[0].length];
886
- } catch (e) {}
970
+ const match = userSearch(target_text, this.full_text);
971
+ if (match) return [match.start, match.end - match.start];
972
+ } catch (e) {
973
+ if (e instanceof RegexTimeoutError) throw e;
974
+ }
887
975
  return [-1, 0];
888
976
  }
889
977
 
@@ -923,12 +1011,13 @@ export class DocumentMapper {
923
1011
  if (!target_text) return [];
924
1012
 
925
1013
  if (is_regex) {
1014
+ // Budgeted like find_match_index above (QA 2026-07-17 F5).
926
1015
  try {
927
- const pattern = new RegExp(target_text, "g");
928
- const matches = [...this.full_text.matchAll(pattern)];
929
- if (matches.length > 0)
930
- return matches.map((m) => [m.index!, m[0].length]);
931
- } catch (e) {}
1016
+ const matches = userFindAllMatches(target_text, this.full_text);
1017
+ if (matches.length > 0) return matches.map((m) => [m.start, m.end - m.start]);
1018
+ } catch (e) {
1019
+ if (e instanceof RegexTimeoutError) throw e;
1020
+ }
932
1021
  return [];
933
1022
  }
934
1023
  // Exact tiers use plain indexOf scans, NOT RegExp: building a RegExp from
@@ -115,7 +115,8 @@ describe('apply_edits_to_markdown', () => {
115
115
  { type: 'modify', target_text: 'C', new_text: 'Z' }
116
116
  ];
117
117
  const result = apply_edits_to_markdown(text, edits, true);
118
- expect(result).toContain('[Edit:0]');
118
+ // 1-based, matching apply's "Edit N" reports (QA 2026-07-17 F10).
119
+ expect(result).toContain('[Edit:1]');
119
120
  expect(result.indexOf('{++X++}')).toBeLessThan(result.indexOf('{++Y++}'));
120
121
  expect(result.indexOf('{++Y++}')).toBeLessThan(result.indexOf('{++Z++}'));
121
122
  });