@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/dist/index.cjs +1150 -89
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +156 -12
- package/dist/index.d.ts +156 -12
- package/dist/index.js +1142 -88
- 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 +68 -15
- package/src/engine.ts +391 -28
- package/src/index.ts +6 -5
- package/src/ingest.ts +77 -5
- package/src/mapper.ts +101 -12
- package/src/markup.test.ts +2 -1
- package/src/markup.ts +214 -20
- package/src/repro_qa_report_2026_07_18.test.ts +1244 -0
- package/src/repro_qa_report_v5.test.ts +399 -0
- package/src/sanitize/core.ts +3 -0
- package/src/sanitize/transforms.ts +66 -2
- package/src/utils/docx.ts +257 -16
- package/src/utils/safe-regex.ts +90 -0
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
|
-
|
|
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
|
-
|
|
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
|
|
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 === "
|
|
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(``);
|
|
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
|
-
|
|
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 .
|
|
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
|
-
|
|
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 === "
|
|
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 = ``;
|
|
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
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
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
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
}
|
|
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
|
package/src/markup.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
});
|