@adeu/core 1.23.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 +185 -53
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +185 -53
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/diff.ts +23 -8
- package/src/engine.ts +132 -39
- package/src/repro_qa_report_v6.test.ts +486 -0
- package/src/sanitize/core.ts +57 -1
- package/src/sanitize/report.ts +5 -3
- package/src/sanitize/sanitize.test.ts +5 -4
- package/src/sanitize/transforms.ts +94 -16
package/package.json
CHANGED
package/src/diff.ts
CHANGED
|
@@ -635,6 +635,7 @@ function _row_ops_for_table(
|
|
|
635
635
|
target_text: o_txt,
|
|
636
636
|
new_text: m_txt,
|
|
637
637
|
comment: "Diff: Table row modified",
|
|
638
|
+
_is_table_edit: true,
|
|
638
639
|
});
|
|
639
640
|
}
|
|
640
641
|
}
|
|
@@ -785,14 +786,26 @@ export function generate_structured_edits(
|
|
|
785
786
|
if (row_opcodes !== null) {
|
|
786
787
|
edits.push(..._row_ops_for_table(t_o, t_m, row_opcodes, warnings));
|
|
787
788
|
} else {
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
789
|
+
// Cell-internal changes only (row sets align 1:1). Emit one
|
|
790
|
+
// ROW-LEVEL edit per differing row — the engine splits it into
|
|
791
|
+
// per-cell sub-edits along the " | " boundaries. A word-level diff
|
|
792
|
+
// over the whole table span produces hunks that start or end
|
|
793
|
+
// inside a cell separator, which apply into the wrong cell or
|
|
794
|
+
// write literal pipe text. Unpinned like every other table edit:
|
|
795
|
+
// pinned application bypasses the cell splitter, and the full row
|
|
796
|
+
// text is the anchor contract.
|
|
797
|
+
for (let k = 0; k < t_o.rows.length; k++) {
|
|
798
|
+
const o_txt = t_o.rows[k].cells.join(" | ");
|
|
799
|
+
const m_txt = t_m.rows[k].cells.join(" | ");
|
|
800
|
+
if (o_txt === m_txt) continue;
|
|
801
|
+
edits.push({
|
|
802
|
+
type: "modify",
|
|
803
|
+
target_text: o_txt,
|
|
804
|
+
new_text: m_txt,
|
|
805
|
+
comment: "Diff: Table row modified",
|
|
806
|
+
_is_table_edit: true,
|
|
807
|
+
});
|
|
794
808
|
}
|
|
795
|
-
edits.push(...tbl_edits);
|
|
796
809
|
}
|
|
797
810
|
}
|
|
798
811
|
}
|
|
@@ -817,7 +830,9 @@ export function generate_structured_edits(
|
|
|
817
830
|
let ambiguous_anchor_warned = false;
|
|
818
831
|
for (const e of edits) {
|
|
819
832
|
if (
|
|
820
|
-
(e.type === "insert_row" ||
|
|
833
|
+
(e.type === "insert_row" ||
|
|
834
|
+
e.type === "delete_row" ||
|
|
835
|
+
(e as any)._is_table_edit) &&
|
|
821
836
|
!ambiguous_anchor_warned
|
|
822
837
|
) {
|
|
823
838
|
if (e.target_text && countOccurrences(text_orig, e.target_text) > 1) {
|
package/src/engine.ts
CHANGED
|
@@ -1394,6 +1394,11 @@ export class RedlineEngine {
|
|
|
1394
1394
|
anchor_run: Run | null,
|
|
1395
1395
|
anchor_paragraph: Paragraph | null,
|
|
1396
1396
|
reuse_id: string,
|
|
1397
|
+
// The attached DOM element the insertion physically follows. anchor_run
|
|
1398
|
+
// supplies STYLING and may already be detached (the deletion step clones
|
|
1399
|
+
// runs into <w:del> and replaces the originals); suffix relocation for
|
|
1400
|
+
// paragraph-splitting insertions keys on this element instead.
|
|
1401
|
+
positional_anchor_el: Element | null = null,
|
|
1397
1402
|
): {
|
|
1398
1403
|
first_node: Element | null;
|
|
1399
1404
|
last_p: Element | null;
|
|
@@ -1424,10 +1429,48 @@ export class RedlineEngine {
|
|
|
1424
1429
|
current_p = walker;
|
|
1425
1430
|
}
|
|
1426
1431
|
|
|
1427
|
-
//
|
|
1428
|
-
//
|
|
1429
|
-
//
|
|
1430
|
-
|
|
1432
|
+
// Suffix nodes: content that follows the anchor inside current_p. When
|
|
1433
|
+
// the inserted text carries paragraph breaks, this content belongs in
|
|
1434
|
+
// the LAST new paragraph. The positional anchor is attached to the
|
|
1435
|
+
// DOM, and the insertion lands immediately after it, so its following
|
|
1436
|
+
// child-of-paragraph siblings are exactly the suffix.
|
|
1437
|
+
const suffix_nodes: Element[] = [];
|
|
1438
|
+
const pos_source =
|
|
1439
|
+
positional_anchor_el && positional_anchor_el.parentNode
|
|
1440
|
+
? positional_anchor_el
|
|
1441
|
+
: anchor_run !== null && anchor_run._element.parentNode
|
|
1442
|
+
? anchor_run._element
|
|
1443
|
+
: null;
|
|
1444
|
+
if (current_p !== null && pos_source !== null) {
|
|
1445
|
+
let pos_anchor: Element | null = pos_source;
|
|
1446
|
+
while (pos_anchor && pos_anchor.parentNode !== current_p) {
|
|
1447
|
+
pos_anchor = pos_anchor.parentNode as Element | null;
|
|
1448
|
+
if (pos_anchor === current_p) {
|
|
1449
|
+
pos_anchor = null;
|
|
1450
|
+
break;
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
if (pos_anchor) {
|
|
1454
|
+
const relocatable = new Set(["w:r", "w:ins", "w:del"]);
|
|
1455
|
+
let nxt = pos_anchor.nextSibling;
|
|
1456
|
+
while (nxt) {
|
|
1457
|
+
if (nxt.nodeType === 1 && relocatable.has((nxt as Element).tagName)) {
|
|
1458
|
+
suffix_nodes.push(nxt as Element);
|
|
1459
|
+
}
|
|
1460
|
+
nxt = nxt.nextSibling;
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
// Drop the trailing empty line ONLY when there is no suffix to relocate.
|
|
1466
|
+
// "foo\n\nbar\n\n" splits to ['foo', '', 'bar', '']; without a suffix
|
|
1467
|
+
// the trailing empty is just a terminator, but with one it is the fresh
|
|
1468
|
+
// destination paragraph the suffix moves into.
|
|
1469
|
+
while (
|
|
1470
|
+
lines.length > 1 &&
|
|
1471
|
+
lines[lines.length - 1] === "" &&
|
|
1472
|
+
suffix_nodes.length === 0
|
|
1473
|
+
) {
|
|
1431
1474
|
lines.pop();
|
|
1432
1475
|
}
|
|
1433
1476
|
if (lines.length === 0) {
|
|
@@ -1555,6 +1598,16 @@ export class RedlineEngine {
|
|
|
1555
1598
|
}
|
|
1556
1599
|
}
|
|
1557
1600
|
|
|
1601
|
+
// Relocate the suffix into the last new paragraph: the paragraph break
|
|
1602
|
+
// the insertion introduced splits current_p at the anchor, so everything
|
|
1603
|
+
// after the anchor continues in the final inserted paragraph.
|
|
1604
|
+
if (!block_mode && last_p && suffix_nodes.length > 0) {
|
|
1605
|
+
for (const node of suffix_nodes) {
|
|
1606
|
+
node.parentNode?.removeChild(node);
|
|
1607
|
+
last_p.appendChild(node);
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1558
1611
|
return { first_node, last_p, last_ins, used_block_mode: block_mode };
|
|
1559
1612
|
}
|
|
1560
1613
|
|
|
@@ -1628,7 +1681,13 @@ export class RedlineEngine {
|
|
|
1628
1681
|
return [stripped_text.substring(2).trim(), "List Paragraph"];
|
|
1629
1682
|
}
|
|
1630
1683
|
|
|
1631
|
-
|
|
1684
|
+
// Numbered lists: the projection emits ordered items with a CONSTANT
|
|
1685
|
+
// "1. " marker (Markdown renumbers), so only that exact shape converts
|
|
1686
|
+
// back into a list style. Any other leading number ("2024. Year in
|
|
1687
|
+
// review", "3. Clause text") is literal document text. Continuation
|
|
1688
|
+
// items inside an existing list anchor keep full "\d+." handling via
|
|
1689
|
+
// the list-anchored insertion path.
|
|
1690
|
+
const match = stripped_text.match(/^1\.\s+/);
|
|
1632
1691
|
if (match) {
|
|
1633
1692
|
return [stripped_text.substring(match[0].length).trim(), "List Number"];
|
|
1634
1693
|
}
|
|
@@ -2841,15 +2900,22 @@ export class RedlineEngine {
|
|
|
2841
2900
|
resolved_edits.push([edit, edit.new_text || null]);
|
|
2842
2901
|
} else if (edit.type === "insert_row" || edit.type === "delete_row") {
|
|
2843
2902
|
let matches = this.mapper.find_all_match_indices(edit.target_text);
|
|
2903
|
+
let resolved_mapper = this.mapper;
|
|
2844
2904
|
if (matches.length === 0) {
|
|
2845
2905
|
if (!this.clean_mapper) {
|
|
2846
2906
|
this.clean_mapper = new DocumentMapper(this.doc, true);
|
|
2847
2907
|
}
|
|
2848
2908
|
matches = this.clean_mapper.find_all_match_indices(edit.target_text);
|
|
2909
|
+
resolved_mapper = this.clean_mapper;
|
|
2849
2910
|
}
|
|
2850
2911
|
|
|
2851
2912
|
if (matches.length > 0) {
|
|
2913
|
+
// Record WHICH mapper produced the offset: a clean-view index
|
|
2914
|
+
// resolved against the raw mapper lands rows at the wrong position
|
|
2915
|
+
// once earlier edits in the batch put tracked changes in the
|
|
2916
|
+
// anchor row.
|
|
2852
2917
|
edit._resolved_start_idx = matches[0][0];
|
|
2918
|
+
edit._active_mapper_ref = resolved_mapper;
|
|
2853
2919
|
resolved_edits.push([edit, null]);
|
|
2854
2920
|
} else {
|
|
2855
2921
|
skipped++;
|
|
@@ -3160,7 +3226,11 @@ export class RedlineEngine {
|
|
|
3160
3226
|
edit._resolved_start_idx !== null
|
|
3161
3227
|
? edit._resolved_start_idx
|
|
3162
3228
|
: edit._match_start_index || 0;
|
|
3163
|
-
|
|
3229
|
+
// The offset must be looked up in the coordinate space it was resolved
|
|
3230
|
+
// in: a clean-view offset applied to the raw
|
|
3231
|
+
// mapper points at earlier text once tracked changes exist.
|
|
3232
|
+
const active_mapper: DocumentMapper = edit._active_mapper_ref || this.mapper;
|
|
3233
|
+
const [anchor_run, anchor_para] = active_mapper.get_insertion_anchor(
|
|
3164
3234
|
start_idx,
|
|
3165
3235
|
rebuild_map,
|
|
3166
3236
|
);
|
|
@@ -3396,9 +3466,22 @@ export class RedlineEngine {
|
|
|
3396
3466
|
const actual_cells = actual_doc_text.split("|");
|
|
3397
3467
|
const new_cells = current_effective_new_text.split("|");
|
|
3398
3468
|
|
|
3399
|
-
if (actual_cells.length
|
|
3469
|
+
if (actual_cells.length !== new_cells.length) {
|
|
3470
|
+
throw new BatchValidationError([
|
|
3471
|
+
`Target text spans ${actual_cells.length} table cells, but replacement provides ${new_cells.length}. To modify text without altering table structure (rows or columns), ensure the replacement contains the exact same number of '|' separators (e.g., replace with 'CellC | ' to empty the second cell).`
|
|
3472
|
+
]);
|
|
3473
|
+
}
|
|
3474
|
+
|
|
3475
|
+
if (actual_cells.length > 1) {
|
|
3400
3476
|
const sub_edits: any[] = [];
|
|
3401
|
-
|
|
3477
|
+
|
|
3478
|
+
// actual_doc_text IS the document slice at
|
|
3479
|
+
// [start_idx, start_idx + len): per-cell offsets are exact
|
|
3480
|
+
// arithmetic over that slice — never a search of mapper.full_text,
|
|
3481
|
+
// which cannot distinguish repeated cell text and lands in the
|
|
3482
|
+
// wrong cell when the matched range starts inside a " | "
|
|
3483
|
+
// separator.
|
|
3484
|
+
let cell_start_in_target = 0;
|
|
3402
3485
|
|
|
3403
3486
|
// Determine which cell receives the comment
|
|
3404
3487
|
let target_comment_idx = 0;
|
|
@@ -3414,14 +3497,10 @@ export class RedlineEngine {
|
|
|
3414
3497
|
const n_cell = new_cells[cell_idx];
|
|
3415
3498
|
const a_clean = a_cell.trim();
|
|
3416
3499
|
const n_clean = n_cell.trim();
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
if (actual_start === -1 || actual_start > search_offset + 10) {
|
|
3422
|
-
actual_start = search_offset;
|
|
3423
|
-
}
|
|
3424
|
-
}
|
|
3500
|
+
const actual_start =
|
|
3501
|
+
start_idx +
|
|
3502
|
+
cell_start_in_target +
|
|
3503
|
+
(a_clean ? a_cell.indexOf(a_clean) : 0);
|
|
3425
3504
|
|
|
3426
3505
|
const should_attach_comment = (edit.comment !== null && edit.comment !== undefined) && (cell_idx === target_comment_idx);
|
|
3427
3506
|
|
|
@@ -3441,27 +3520,18 @@ export class RedlineEngine {
|
|
|
3441
3520
|
}
|
|
3442
3521
|
}
|
|
3443
3522
|
|
|
3444
|
-
|
|
3445
|
-
search_offset = actual_start + a_clean.length;
|
|
3446
|
-
}
|
|
3447
|
-
|
|
3448
|
-
const next_pipe = this.mapper.full_text.indexOf(" | ", search_offset);
|
|
3449
|
-
if (next_pipe !== -1 && next_pipe <= search_offset + 10) {
|
|
3450
|
-
search_offset = next_pipe + 3;
|
|
3451
|
-
} else {
|
|
3452
|
-
search_offset += a_cell.length + 1;
|
|
3453
|
-
}
|
|
3523
|
+
cell_start_in_target += a_cell.length + 1; // +1 for the '|'
|
|
3454
3524
|
}
|
|
3455
3525
|
|
|
3456
3526
|
for (const sub of sub_edits) {
|
|
3457
3527
|
all_sub_edits.push(sub);
|
|
3458
3528
|
}
|
|
3459
3529
|
continue;
|
|
3460
|
-
} else {
|
|
3461
|
-
throw new BatchValidationError([
|
|
3462
|
-
`Target text spans ${actual_cells.length} table cells, but replacement provides ${new_cells.length}. To modify text without altering table structure (rows or columns), ensure the replacement contains the exact same number of '|' separators (e.g., replace with 'CellC | ' to empty the second cell).`
|
|
3463
|
-
]);
|
|
3464
3530
|
}
|
|
3531
|
+
// Exactly one "cell": the target merely brushes a separator (its
|
|
3532
|
+
// match range starts or ends inside " | ") without crossing into
|
|
3533
|
+
// another cell's text. That is an ordinary in-cell edit — fall
|
|
3534
|
+
// through to the standard resolution.
|
|
3465
3535
|
}
|
|
3466
3536
|
|
|
3467
3537
|
let has_markdown = false;
|
|
@@ -3575,6 +3645,26 @@ export class RedlineEngine {
|
|
|
3575
3645
|
}
|
|
3576
3646
|
}
|
|
3577
3647
|
|
|
3648
|
+
// After trimming shared context, an edit whose target remainder is
|
|
3649
|
+
// EMPTY is a pure insertion with exactly one hunk. Resolve it
|
|
3650
|
+
// directly at the effective offset instead of word-diffing the full
|
|
3651
|
+
// strings: dmp's alignment can cross-match punctuation between the
|
|
3652
|
+
// shared context and the inserted text (pairing the period of "two."
|
|
3653
|
+
// with "marker.") and split the insertion apart.
|
|
3654
|
+
if (!final_target && final_new) {
|
|
3655
|
+
all_sub_edits.push({
|
|
3656
|
+
type: "modify",
|
|
3657
|
+
target_text: "",
|
|
3658
|
+
new_text: final_new,
|
|
3659
|
+
comment: edit.comment,
|
|
3660
|
+
_resolved_start_idx: effective_start_idx,
|
|
3661
|
+
_match_start_index: effective_start_idx,
|
|
3662
|
+
_internal_op: "INSERTION",
|
|
3663
|
+
_active_mapper_ref: active_mapper,
|
|
3664
|
+
});
|
|
3665
|
+
continue;
|
|
3666
|
+
}
|
|
3667
|
+
|
|
3578
3668
|
const sub_edits = this._word_diff_sub_edits(
|
|
3579
3669
|
actual_doc_text,
|
|
3580
3670
|
current_effective_new_text,
|
|
@@ -3804,15 +3894,15 @@ export class RedlineEngine {
|
|
|
3804
3894
|
if (op === "INSERTION") {
|
|
3805
3895
|
let final_new_text = edit.new_text || "";
|
|
3806
3896
|
|
|
3807
|
-
//
|
|
3808
|
-
//
|
|
3809
|
-
//
|
|
3810
|
-
//
|
|
3811
|
-
//
|
|
3812
|
-
//
|
|
3813
|
-
//
|
|
3814
|
-
//
|
|
3815
|
-
//
|
|
3897
|
+
// A MACHINE-PINNED pure insertion (diff/text round-trip output:
|
|
3898
|
+
// authored with an empty target and no parent edit) positioned in the
|
|
3899
|
+
// separator gap between the body and a following part anchors to the
|
|
3900
|
+
// end of the BODY with forced new-paragraph semantics — anchoring on
|
|
3901
|
+
// the next part's first paragraph writes the new final body paragraph
|
|
3902
|
+
// into word/footer1.xml. Insertions DERIVED from a target-anchored
|
|
3903
|
+
// edit (parent ref set — e.g. prepending "DRAFT " to "FOOTER MARKER")
|
|
3904
|
+
// keep the user's chosen anchor: their context names the part they
|
|
3905
|
+
// meant.
|
|
3816
3906
|
let boundary_anchor: TextSpan | null = null;
|
|
3817
3907
|
const boundary =
|
|
3818
3908
|
typeof (active_mapper as any).part_boundary_at === "function"
|
|
@@ -4168,6 +4258,9 @@ export class RedlineEngine {
|
|
|
4168
4258
|
style_source_run,
|
|
4169
4259
|
mod_anchor_para,
|
|
4170
4260
|
ins_id!,
|
|
4261
|
+
// The insertion physically follows the deletion block; the style
|
|
4262
|
+
// run was detached when the deletion cloned it into <w:del>.
|
|
4263
|
+
last_del,
|
|
4171
4264
|
);
|
|
4172
4265
|
|
|
4173
4266
|
if (result.first_node) {
|