@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/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 () 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
|
+
"(). 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
|
-
|
|
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
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
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
|
-
|
|
3621
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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]}`,
|