@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adeu/core",
3
- "version": "1.23.0",
3
+ "version": "1.25.0",
4
4
  "description": "",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
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
- const tbl_edits = generate_edits_from_text(
789
- text_orig.substring(t_o.start, t_o.end),
790
- text_mod.substring(t_m.start, t_m.end),
791
- );
792
- for (const e of tbl_edits) {
793
- e._match_start_index = (e._match_start_index || 0) + t_o.start;
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" || e.type === "delete_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
- // Drop trailing empty line. "foo\n\nbar\n\n" splits to
1428
- // ['foo', '', 'bar', '']; that trailing empty is just a terminator, not
1429
- // a real empty paragraph.
1430
- while (lines.length > 1 && lines[lines.length - 1] === "") {
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
- const match = stripped_text.match(/^\d+\.\s+/);
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
- const [anchor_run, anchor_para] = this.mapper.get_insertion_anchor(
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 === new_cells.length && actual_cells.length > 1) {
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
- let search_offset = start_idx;
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
- let actual_start = search_offset;
3419
- if (a_clean) {
3420
- actual_start = this.mapper.full_text.indexOf(a_clean, search_offset);
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
- if (a_clean) {
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
- // 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.
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) {