@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.
- package/dist/index.cjs +1157 -115
- 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 +1156 -115
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/diff.ts +515 -1
- package/src/docx/bridge.ts +13 -0
- package/src/domain.ts +58 -14
- package/src/engine.ts +411 -38
- 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/repro_qa_report_v6.test.ts +486 -0
- package/src/sanitize/core.ts +60 -1
- package/src/sanitize/report.ts +5 -3
- package/src/sanitize/sanitize.test.ts +5 -4
- package/src/sanitize/transforms.ts +134 -16
- package/src/utils/docx.ts +257 -16
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
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
is_heading_paragraph,
|
|
12
12
|
is_native_heading,
|
|
13
13
|
iter_block_items,
|
|
14
|
-
|
|
14
|
+
iter_document_parts_with_kind,
|
|
15
15
|
iter_paragraph_content,
|
|
16
16
|
} from "./utils/docx.js";
|
|
17
17
|
|
|
@@ -25,6 +25,12 @@ export interface TextSpan {
|
|
|
25
25
|
del_id?: string | null;
|
|
26
26
|
hyperlink_id?: string | null;
|
|
27
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;
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
export function renumber_snapshot_ids(
|
|
@@ -120,6 +126,11 @@ export class DocumentMapper {
|
|
|
120
126
|
public full_text: string = "";
|
|
121
127
|
public spans: TextSpan[] = [];
|
|
122
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;
|
|
123
134
|
private _text_chunks: string[] = [];
|
|
124
135
|
private _plain_projection: [string, number[]] | null = null;
|
|
125
136
|
|
|
@@ -141,9 +152,15 @@ export class DocumentMapper {
|
|
|
141
152
|
this._text_chunks = [];
|
|
142
153
|
this.full_text = "";
|
|
143
154
|
this._plain_projection = null;
|
|
155
|
+
this.part_ranges = [];
|
|
156
|
+
this._current_part_index = 0;
|
|
144
157
|
|
|
145
|
-
|
|
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;
|
|
146
162
|
current_offset = this._map_blocks(part, current_offset);
|
|
163
|
+
this.part_ranges.push([part_start, current_offset, part_kind]);
|
|
147
164
|
|
|
148
165
|
if (
|
|
149
166
|
this.spans.length > 0 &&
|
|
@@ -152,6 +169,7 @@ export class DocumentMapper {
|
|
|
152
169
|
this._add_virtual_text("\n\n", current_offset, null);
|
|
153
170
|
current_offset += 2;
|
|
154
171
|
}
|
|
172
|
+
part_idx++;
|
|
155
173
|
}
|
|
156
174
|
|
|
157
175
|
while (
|
|
@@ -166,6 +184,51 @@ export class DocumentMapper {
|
|
|
166
184
|
this.appendix_start_index = -1;
|
|
167
185
|
}
|
|
168
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
|
+
|
|
169
232
|
private _map_blocks(container: any, offset: number): number {
|
|
170
233
|
let current = offset;
|
|
171
234
|
const c_type = container.constructor.name;
|
|
@@ -335,6 +398,7 @@ export class DocumentMapper {
|
|
|
335
398
|
text: "",
|
|
336
399
|
run: null,
|
|
337
400
|
paragraph,
|
|
401
|
+
part_index: this._current_part_index,
|
|
338
402
|
};
|
|
339
403
|
this.spans.push(span);
|
|
340
404
|
|
|
@@ -377,6 +441,7 @@ export class DocumentMapper {
|
|
|
377
441
|
del_id: d_id || undefined,
|
|
378
442
|
hyperlink_id: active_hyperlink_id || undefined,
|
|
379
443
|
comment_ids: c_ids.length > 0 ? c_ids : undefined,
|
|
444
|
+
part_index: this._current_part_index,
|
|
380
445
|
};
|
|
381
446
|
this.spans.push(s);
|
|
382
447
|
this._text_chunks.push(txt);
|
|
@@ -609,7 +674,21 @@ export class DocumentMapper {
|
|
|
609
674
|
else if (ev.type === "del_end") delete active_del[ev.id];
|
|
610
675
|
else if (ev.type === "fmt_start") active_fmt[ev.id] = ev;
|
|
611
676
|
else if (ev.type === "fmt_end") delete active_fmt[ev.id];
|
|
612
|
-
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") {
|
|
613
692
|
flush_pending_runs();
|
|
614
693
|
current_wrappers = ["", ""];
|
|
615
694
|
current_style = ["", ""];
|
|
@@ -743,6 +822,7 @@ export class DocumentMapper {
|
|
|
743
822
|
offset: number,
|
|
744
823
|
context_paragraph: Paragraph | null,
|
|
745
824
|
hyperlink_id: string | null = null,
|
|
825
|
+
is_image_marker = false,
|
|
746
826
|
) {
|
|
747
827
|
const span: TextSpan = {
|
|
748
828
|
start: offset,
|
|
@@ -751,6 +831,8 @@ export class DocumentMapper {
|
|
|
751
831
|
run: null,
|
|
752
832
|
paragraph: context_paragraph,
|
|
753
833
|
hyperlink_id: hyperlink_id || undefined,
|
|
834
|
+
part_index: this._current_part_index,
|
|
835
|
+
is_image_marker: is_image_marker || undefined,
|
|
754
836
|
};
|
|
755
837
|
this.spans.push(span);
|
|
756
838
|
this._text_chunks.push(text);
|
package/src/markup.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { trim_common_context } from "./diff.js";
|
|
2
2
|
import { ModifyText } from "./models.js";
|
|
3
|
+
import { RegexTimeoutError, userFindAllMatches } from "./utils/safe-regex.js";
|
|
4
|
+
|
|
5
|
+
/** One entry per input edit in apply_edits_to_markdown's edit_reports. */
|
|
6
|
+
export interface MarkupEditReport {
|
|
7
|
+
index: number;
|
|
8
|
+
status: "applied" | "failed";
|
|
9
|
+
error: string | null;
|
|
10
|
+
occurrences: number;
|
|
11
|
+
}
|
|
3
12
|
export const AMBIGUITY_EXAMPLES_CAP = 5;
|
|
4
13
|
export const AMBIGUITY_CONTEXT_CHARS = 50;
|
|
5
14
|
function _should_strip_markers(text: string, marker: string): boolean {
|
|
@@ -51,6 +60,45 @@ export function _replace_smart_quotes(text: string): string {
|
|
|
51
60
|
.replace(/’/g, "'");
|
|
52
61
|
}
|
|
53
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Strips markdown formatting markers and builds a position map.
|
|
65
|
+
* Returns [stripped_text, position_map] where position_map[i] = original
|
|
66
|
+
* index. Mirrors Python markup._strip_markdown_for_matching.
|
|
67
|
+
*/
|
|
68
|
+
function _strip_markdown_for_matching(text: string): [string, number[]] {
|
|
69
|
+
const result: string[] = [];
|
|
70
|
+
const position_map: number[] = [];
|
|
71
|
+
let i = 0;
|
|
72
|
+
|
|
73
|
+
while (i < text.length) {
|
|
74
|
+
// Skip ** or __
|
|
75
|
+
const pair = text.substring(i, i + 2);
|
|
76
|
+
if (i < text.length - 1 && (pair === "**" || pair === "__")) {
|
|
77
|
+
i += 2;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
// Skip single * or _ that look like markdown (at word boundaries)
|
|
81
|
+
if (text[i] === "*" || text[i] === "_") {
|
|
82
|
+
const prev_char = i > 0 ? text[i - 1] : " ";
|
|
83
|
+
const next_char = i < text.length - 1 ? text[i + 1] : " ";
|
|
84
|
+
// If at boundary (space or start/end), likely markdown
|
|
85
|
+
if (
|
|
86
|
+
[" ", "\n", "\t"].includes(prev_char) ||
|
|
87
|
+
[" ", "\n", "\t"].includes(next_char)
|
|
88
|
+
) {
|
|
89
|
+
i += 1;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
position_map.push(i);
|
|
95
|
+
result.push(text[i]);
|
|
96
|
+
i += 1;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return [result.join(""), position_map];
|
|
100
|
+
}
|
|
101
|
+
|
|
54
102
|
function _find_safe_boundaries(
|
|
55
103
|
text: string,
|
|
56
104
|
start: number,
|
|
@@ -198,35 +246,107 @@ export function _find_match_in_text(
|
|
|
198
246
|
text: string,
|
|
199
247
|
target: string,
|
|
200
248
|
): [number, number] {
|
|
201
|
-
|
|
249
|
+
const matches = _find_all_matches_in_text(text, target);
|
|
250
|
+
if (matches.length > 0) return matches[0];
|
|
251
|
+
return [-1, -1];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Every non-overlapping match of `target` in `text` as [start, end] pairs,
|
|
256
|
+
* using the SAME strategy ladder as the apply engine's
|
|
257
|
+
* DocumentMapper.find_all_match_indices: regex (when requested) or
|
|
258
|
+
* exact → smart-quote-normalized → fuzzy. Markup previews must resolve
|
|
259
|
+
* matching identically to apply, or the preview lies (QA 2026-07-18 M1).
|
|
260
|
+
*/
|
|
261
|
+
export function _find_all_matches_in_text(
|
|
262
|
+
text: string,
|
|
263
|
+
target: string,
|
|
264
|
+
is_regex = false,
|
|
265
|
+
): [number, number][] {
|
|
266
|
+
if (!target) return [];
|
|
267
|
+
|
|
268
|
+
if (is_regex) {
|
|
269
|
+
// Same semantics as the mapper: budgeted user regex; an invalid pattern
|
|
270
|
+
// simply produces no matches (surfaced as "not found").
|
|
271
|
+
// RegexTimeoutError propagates for a clean per-edit error.
|
|
272
|
+
try {
|
|
273
|
+
return userFindAllMatches(target, text).map((m) => [m.start, m.end]);
|
|
274
|
+
} catch (e) {
|
|
275
|
+
if (e instanceof RegexTimeoutError) throw e;
|
|
276
|
+
return [];
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Literal indexOf scans (no RegExp over an arbitrarily long escaped
|
|
281
|
+
// target, mirroring the mapper's exact tiers).
|
|
282
|
+
const findAllLiteral = (
|
|
283
|
+
haystack: string,
|
|
284
|
+
needle: string,
|
|
285
|
+
): [number, number][] => {
|
|
286
|
+
const out: [number, number][] = [];
|
|
287
|
+
let from = 0;
|
|
288
|
+
while (true) {
|
|
289
|
+
const idx = haystack.indexOf(needle, from);
|
|
290
|
+
if (idx === -1) break;
|
|
291
|
+
out.push([idx, idx + needle.length]);
|
|
292
|
+
from = idx + needle.length;
|
|
293
|
+
}
|
|
294
|
+
return out;
|
|
295
|
+
};
|
|
202
296
|
|
|
203
|
-
|
|
204
|
-
|
|
297
|
+
// 1. Exact matches
|
|
298
|
+
let spans = findAllLiteral(text, target);
|
|
299
|
+
if (spans.length > 0) {
|
|
300
|
+
return spans.map(([s, e]) => _find_safe_boundaries(text, s, e));
|
|
301
|
+
}
|
|
205
302
|
|
|
303
|
+
// 2. Smart quote normalization
|
|
206
304
|
const norm_text = _replace_smart_quotes(text);
|
|
207
305
|
const norm_target = _replace_smart_quotes(target);
|
|
208
|
-
|
|
209
|
-
if (
|
|
210
|
-
return _find_safe_boundaries(text,
|
|
306
|
+
spans = findAllLiteral(norm_text, norm_target);
|
|
307
|
+
if (spans.length > 0) {
|
|
308
|
+
return spans.map(([s, e]) => _find_safe_boundaries(text, s, e));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// 3. Markdown-stripped match, mirroring the mapper's strip-markdown and
|
|
312
|
+
// plain-projection rungs: a plain target must find text whose projection
|
|
313
|
+
// carries **bold**/_italic_ markers (even mid-word), and a marked target
|
|
314
|
+
// must find plain text.
|
|
315
|
+
const [stripped_text, pos_map] = _strip_markdown_for_matching(norm_text);
|
|
316
|
+
const [stripped_target] = _strip_markdown_for_matching(norm_target);
|
|
317
|
+
if (
|
|
318
|
+
stripped_target &&
|
|
319
|
+
(stripped_text !== norm_text || stripped_target !== norm_target)
|
|
320
|
+
) {
|
|
321
|
+
const results: [number, number][] = [];
|
|
322
|
+
for (const [p_start, p_end] of findAllLiteral(stripped_text, stripped_target)) {
|
|
323
|
+
const raw_start = pos_map[p_start];
|
|
324
|
+
const raw_end = pos_map[p_end - 1] + 1;
|
|
325
|
+
results.push(_find_safe_boundaries(text, raw_start, raw_end));
|
|
326
|
+
}
|
|
327
|
+
if (results.length > 0) return results;
|
|
328
|
+
}
|
|
211
329
|
|
|
330
|
+
// 4. Fuzzy regex matches (handles markdown noise, list markers, etc.).
|
|
331
|
+
// The [*_]* character classes in _make_fuzzy_regex prevent catastrophic
|
|
332
|
+
// backtracking.
|
|
212
333
|
try {
|
|
213
|
-
const pattern = new RegExp(_make_fuzzy_regex(target));
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
const raw_start = match.index;
|
|
217
|
-
const raw_end = match.index + match[0].length;
|
|
334
|
+
const pattern = new RegExp(_make_fuzzy_regex(target), "g");
|
|
335
|
+
const results: [number, number][] = [];
|
|
336
|
+
for (const match of text.matchAll(pattern)) {
|
|
218
337
|
const [refined_start, refined_end] = _refine_match_boundaries(
|
|
219
338
|
text,
|
|
220
|
-
|
|
221
|
-
|
|
339
|
+
match.index!,
|
|
340
|
+
match.index! + match[0].length,
|
|
222
341
|
);
|
|
223
|
-
|
|
342
|
+
results.push(_find_safe_boundaries(text, refined_start, refined_end));
|
|
224
343
|
}
|
|
344
|
+
if (results.length > 0) return results;
|
|
225
345
|
} catch (e) {
|
|
226
346
|
// Ignore regex compilation errors from edge cases
|
|
227
347
|
}
|
|
228
348
|
|
|
229
|
-
return [
|
|
349
|
+
return [];
|
|
230
350
|
}
|
|
231
351
|
|
|
232
352
|
export function _build_critic_markup(
|
|
@@ -283,31 +403,93 @@ export function _build_critic_markup(
|
|
|
283
403
|
return parts.join("");
|
|
284
404
|
}
|
|
285
405
|
|
|
406
|
+
/**
|
|
407
|
+
* Applies edits to Markdown text and returns CriticMarkup-annotated output.
|
|
408
|
+
*
|
|
409
|
+
* Edit resolution follows the SAME semantics as `apply` (QA 2026-07-18 M1):
|
|
410
|
+
* - `regex: true` targets match as regular expressions
|
|
411
|
+
* - `match_mode` is honored: "strict" (default) refuses ambiguous targets,
|
|
412
|
+
* "first" marks the first occurrence, "all" marks every occurrence
|
|
413
|
+
* - a missing target is a per-edit failure, never a silent skip
|
|
414
|
+
*
|
|
415
|
+
* When `edit_reports` is provided, one entry per input edit is appended:
|
|
416
|
+
* { index: 0-based input position, status: "applied"|"failed",
|
|
417
|
+
* error: string|null, occurrences: number }.
|
|
418
|
+
*/
|
|
286
419
|
export function apply_edits_to_markdown(
|
|
287
420
|
markdown_text: string,
|
|
288
421
|
edits: ModifyText[],
|
|
289
422
|
include_index = false,
|
|
290
423
|
highlight_only = false,
|
|
424
|
+
edit_reports?: MarkupEditReport[],
|
|
291
425
|
): string {
|
|
292
426
|
if (!edits || edits.length === 0) return markdown_text;
|
|
293
427
|
|
|
428
|
+
const _report = (
|
|
429
|
+
idx: number,
|
|
430
|
+
status: "applied" | "failed",
|
|
431
|
+
error: string | null = null,
|
|
432
|
+
occurrences = 0,
|
|
433
|
+
) => {
|
|
434
|
+
if (edit_reports) edit_reports.push({ index: idx, status, error, occurrences });
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
// Step 1: Find match positions for each edit
|
|
294
438
|
const matched_edits: [number, number, string, ModifyText, number][] = [];
|
|
295
439
|
|
|
296
440
|
for (let idx = 0; idx < edits.length; idx++) {
|
|
297
441
|
const edit = edits[idx];
|
|
298
442
|
const target = edit.target_text || "";
|
|
443
|
+
const match_mode = (edit as any).match_mode || "strict";
|
|
444
|
+
const is_regex = Boolean((edit as any).regex);
|
|
299
445
|
|
|
300
446
|
if (!target) {
|
|
447
|
+
_report(
|
|
448
|
+
idx,
|
|
449
|
+
"failed",
|
|
450
|
+
`- Edit ${idx + 1} Failed: target_text is empty. Pure insertions are expressed as a ` +
|
|
451
|
+
"replacement: put the text immediately around the insertion point in target_text and " +
|
|
452
|
+
"repeat it (plus the new text) in new_text.",
|
|
453
|
+
);
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
let spans: [number, number][];
|
|
458
|
+
try {
|
|
459
|
+
spans = _find_all_matches_in_text(markdown_text, target, is_regex);
|
|
460
|
+
} catch (e) {
|
|
461
|
+
if (!(e instanceof RegexTimeoutError)) throw e;
|
|
462
|
+
_report(idx, "failed", `- Edit ${idx + 1} Failed: ${e.message}`);
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (spans.length === 0) {
|
|
467
|
+
_report(
|
|
468
|
+
idx,
|
|
469
|
+
"failed",
|
|
470
|
+
`- Edit ${idx + 1} Failed: Target text not found in document:\n "${target.substring(0, 80)}"`,
|
|
471
|
+
);
|
|
301
472
|
continue;
|
|
302
473
|
}
|
|
303
474
|
|
|
304
|
-
|
|
305
|
-
|
|
475
|
+
if (spans.length > 1 && match_mode === "strict") {
|
|
476
|
+
_report(
|
|
477
|
+
idx,
|
|
478
|
+
"failed",
|
|
479
|
+
format_ambiguity_error(idx + 1, target, markdown_text, spans),
|
|
480
|
+
);
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
306
483
|
|
|
307
|
-
const
|
|
308
|
-
|
|
484
|
+
const selected =
|
|
485
|
+
match_mode === "strict" || match_mode === "first" ? spans.slice(0, 1) : spans;
|
|
486
|
+
for (const [start, end] of selected) {
|
|
487
|
+
matched_edits.push([start, end, markdown_text.substring(start, end), edit, idx]);
|
|
488
|
+
}
|
|
489
|
+
_report(idx, "applied", null, selected.length);
|
|
309
490
|
}
|
|
310
491
|
|
|
492
|
+
// Step 2: Check for overlapping edits
|
|
311
493
|
const matched_edits_filtered: [number, number, string, ModifyText, number][] =
|
|
312
494
|
[];
|
|
313
495
|
const occupied_ranges: [number, number][] = [];
|
|
@@ -319,6 +501,16 @@ export function apply_edits_to_markdown(
|
|
|
319
501
|
for (const [occ_start, occ_end] of occupied_ranges) {
|
|
320
502
|
if (start < occ_end && end > occ_start) {
|
|
321
503
|
overlaps = true;
|
|
504
|
+
if (edit_reports) {
|
|
505
|
+
const msg = `- Edit ${orig_idx + 1} Failed: overlaps with a previously matched edit.`;
|
|
506
|
+
for (const r of edit_reports) {
|
|
507
|
+
if (r.index === orig_idx) {
|
|
508
|
+
r.status = "failed";
|
|
509
|
+
r.error = msg;
|
|
510
|
+
r.occurrences = 0;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
322
514
|
break;
|
|
323
515
|
}
|
|
324
516
|
}
|