@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/package.json
CHANGED
package/src/diff.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import diff_match_patch from "diff-match-patch";
|
|
2
|
-
import { ModifyText } from "./models.js";
|
|
2
|
+
import { DeleteTableRow, InsertTableRow, ModifyText } from "./models.js";
|
|
3
|
+
import type {
|
|
4
|
+
ExtractStructure,
|
|
5
|
+
RowGeometry,
|
|
6
|
+
TableGeometry,
|
|
7
|
+
} from "./ingest.js";
|
|
8
|
+
|
|
9
|
+
export type DiffEdit = ModifyText | InsertTableRow | DeleteTableRow;
|
|
3
10
|
|
|
4
11
|
function _count_standalone_underscores(s: string): number {
|
|
5
12
|
let count = 0;
|
|
@@ -346,6 +353,513 @@ export function generate_edits_from_text(
|
|
|
346
353
|
|
|
347
354
|
return edits;
|
|
348
355
|
}
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
// Structured (part- and table-aware) diff — QA 2026-07-18 C1/C2.
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
type Opcode = [
|
|
361
|
+
tag: "equal" | "replace" | "delete" | "insert",
|
|
362
|
+
i1: number,
|
|
363
|
+
i2: number,
|
|
364
|
+
j1: number,
|
|
365
|
+
j2: number,
|
|
366
|
+
];
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* difflib.SequenceMatcher.get_opcodes() equivalent over string arrays,
|
|
370
|
+
* LCS-based. The row-key arrays this runs on are tiny (table row counts), so
|
|
371
|
+
* the O(n*m) table is negligible. Adjacent non-equal steps coalesce into one
|
|
372
|
+
* "replace"/"delete"/"insert" block exactly like difflib's opcodes.
|
|
373
|
+
*/
|
|
374
|
+
function _sequence_opcodes(a: string[], b: string[]): Opcode[] {
|
|
375
|
+
const n = a.length;
|
|
376
|
+
const m = b.length;
|
|
377
|
+
// dp[i][j] = LCS length of a[i:] vs b[j:]
|
|
378
|
+
const dp: Int32Array[] = [];
|
|
379
|
+
for (let i = 0; i <= n; i++) dp.push(new Int32Array(m + 1));
|
|
380
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
381
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
382
|
+
dp[i][j] =
|
|
383
|
+
a[i] === b[j]
|
|
384
|
+
? dp[i + 1][j + 1] + 1
|
|
385
|
+
: Math.max(dp[i + 1][j], dp[i][j + 1]);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const ops: Opcode[] = [];
|
|
390
|
+
let i = 0;
|
|
391
|
+
let j = 0;
|
|
392
|
+
let pend_i1 = 0;
|
|
393
|
+
let pend_j1 = 0;
|
|
394
|
+
let pend_del = 0;
|
|
395
|
+
let pend_ins = 0;
|
|
396
|
+
|
|
397
|
+
const flushPending = () => {
|
|
398
|
+
if (pend_del === 0 && pend_ins === 0) return;
|
|
399
|
+
const tag =
|
|
400
|
+
pend_del > 0 && pend_ins > 0
|
|
401
|
+
? "replace"
|
|
402
|
+
: pend_del > 0
|
|
403
|
+
? "delete"
|
|
404
|
+
: "insert";
|
|
405
|
+
ops.push([tag, pend_i1, pend_i1 + pend_del, pend_j1, pend_j1 + pend_ins]);
|
|
406
|
+
pend_del = 0;
|
|
407
|
+
pend_ins = 0;
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
while (i < n || j < m) {
|
|
411
|
+
if (i < n && j < m && a[i] === b[j]) {
|
|
412
|
+
flushPending();
|
|
413
|
+
const i1 = i;
|
|
414
|
+
const j1 = j;
|
|
415
|
+
while (i < n && j < m && a[i] === b[j]) {
|
|
416
|
+
i++;
|
|
417
|
+
j++;
|
|
418
|
+
}
|
|
419
|
+
ops.push(["equal", i1, i, j1, j]);
|
|
420
|
+
} else {
|
|
421
|
+
if (pend_del === 0 && pend_ins === 0) {
|
|
422
|
+
pend_i1 = i;
|
|
423
|
+
pend_j1 = j;
|
|
424
|
+
}
|
|
425
|
+
if (j < m && (i === n || dp[i][j + 1] >= dp[i + 1][j])) {
|
|
426
|
+
pend_ins++;
|
|
427
|
+
j++;
|
|
428
|
+
} else {
|
|
429
|
+
pend_del++;
|
|
430
|
+
i++;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
flushPending();
|
|
435
|
+
return ops;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const _IMAGE_MARKER_RE = /!\[[^\]]*\]\(docx-image:[^)]*\)/g;
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Removes generated edits whose image-marker multisets differ between
|
|
442
|
+
* target and new text. The engine (validate_edit_strings) rightly refuses
|
|
443
|
+
* such edits — markers are read-only projections — so emitting them would
|
|
444
|
+
* make the diff pipeline reject its own output. An added/removed image is
|
|
445
|
+
* reported as a warning instead of an unappliable edit.
|
|
446
|
+
*/
|
|
447
|
+
function _drop_image_marker_hunks(
|
|
448
|
+
edits: ModifyText[],
|
|
449
|
+
warnings: string[],
|
|
450
|
+
): ModifyText[] {
|
|
451
|
+
const _normalized = (s: string): string =>
|
|
452
|
+
s.replace(_IMAGE_MARKER_RE, "").replace(/\s+/g, " ").trim();
|
|
453
|
+
|
|
454
|
+
const kept: ModifyText[] = [];
|
|
455
|
+
for (const e of edits) {
|
|
456
|
+
const t_imgs = ((e.target_text || "").match(_IMAGE_MARKER_RE) || []).sort();
|
|
457
|
+
const n_imgs = ((e.new_text || "").match(_IMAGE_MARKER_RE) || []).sort();
|
|
458
|
+
if (JSON.stringify(t_imgs) === JSON.stringify(n_imgs)) {
|
|
459
|
+
kept.push(e);
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
if (_normalized(e.target_text || "") === _normalized(e.new_text || "")) {
|
|
463
|
+
warnings.push(
|
|
464
|
+
"An inline image was added or removed between the documents. Images cannot be " +
|
|
465
|
+
"transferred by text edits — the image-only difference was skipped; apply it " +
|
|
466
|
+
"manually in Word.",
|
|
467
|
+
);
|
|
468
|
+
} else {
|
|
469
|
+
warnings.push(
|
|
470
|
+
"A text change overlapping an added/removed inline image was skipped — images " +
|
|
471
|
+
"cannot be transferred by text edits. Apply that section manually in Word.",
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return kept;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* True when every projected row reads exactly as its cells joined by " | ".
|
|
480
|
+
* Rows wrapped in tracked-row CriticMarkup ({++ … ++} / {-- … --}) do not,
|
|
481
|
+
* and such tables are diffed as plain text rather than as row structures.
|
|
482
|
+
*/
|
|
483
|
+
function _rows_are_plain(text: string, table: TableGeometry): boolean {
|
|
484
|
+
for (const row of table.rows) {
|
|
485
|
+
if (text.substring(row.start, row.end) !== row.cells.join(" | ")) {
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const _ROW_KEY_SEP = "\x1f";
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Row-level alignment opcodes between two tables, or null when the row sets
|
|
496
|
+
* differ only cell-internally (no rows added/removed) — the caller then
|
|
497
|
+
* keeps fine-grained word-level text edits instead of row operations.
|
|
498
|
+
*/
|
|
499
|
+
function _table_row_opcodes(
|
|
500
|
+
rows_o: RowGeometry[],
|
|
501
|
+
rows_m: RowGeometry[],
|
|
502
|
+
): Opcode[] | null {
|
|
503
|
+
const keys_o = rows_o.map((r) => r.cells.join(_ROW_KEY_SEP));
|
|
504
|
+
const keys_m = rows_m.map((r) => r.cells.join(_ROW_KEY_SEP));
|
|
505
|
+
const opcodes = _sequence_opcodes(keys_o, keys_m);
|
|
506
|
+
for (const [tag, i1, i2, j1, j2] of opcodes) {
|
|
507
|
+
if (
|
|
508
|
+
tag === "insert" ||
|
|
509
|
+
tag === "delete" ||
|
|
510
|
+
(tag === "replace" && i2 - i1 !== j2 - j1)
|
|
511
|
+
) {
|
|
512
|
+
return opcodes;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Emits structured operations (delete_row / insert_row / per-row modify)
|
|
520
|
+
* that transform table_o's row set into table_m's, following the alignment
|
|
521
|
+
* `opcodes` from _table_row_opcodes (QA 2026-07-18 C2: a generic text edit
|
|
522
|
+
* cannot add or remove rows — it writes fake pipe text into one cell or is
|
|
523
|
+
* rejected by the cell-count validator).
|
|
524
|
+
*/
|
|
525
|
+
function _row_ops_for_table(
|
|
526
|
+
table_o: TableGeometry,
|
|
527
|
+
table_m: TableGeometry,
|
|
528
|
+
opcodes: Opcode[],
|
|
529
|
+
warnings: string[],
|
|
530
|
+
): DiffEdit[] {
|
|
531
|
+
const rows_o = table_o.rows;
|
|
532
|
+
const rows_m = table_m.rows;
|
|
533
|
+
const keys_o = rows_o.map((r) => r.cells.join(_ROW_KEY_SEP));
|
|
534
|
+
|
|
535
|
+
const row_text = (r: RowGeometry): string => r.cells.join(" | ");
|
|
536
|
+
|
|
537
|
+
// Duplicate row texts make text-anchored row operations ambiguous. The
|
|
538
|
+
// engine fails closed with a strict-mode ambiguity error at apply time;
|
|
539
|
+
// tell the user up front so the rejection is not a surprise.
|
|
540
|
+
if (new Set(keys_o).size !== keys_o.length) {
|
|
541
|
+
warnings.push(
|
|
542
|
+
"A table contains rows with identical text; the generated row operations anchor by " +
|
|
543
|
+
"row text and may be rejected as ambiguous at apply time. If that happens, apply the " +
|
|
544
|
+
"row changes with explicit insert_row/delete_row edits.",
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Rows removed by the transformation (deletes + surplus rows of shrinking
|
|
549
|
+
// replaces) can never anchor an insert.
|
|
550
|
+
const removed = new Set<number>();
|
|
551
|
+
const replaced_new_text: Record<number, string> = {};
|
|
552
|
+
for (const [tag, i1, i2, j1, j2] of opcodes) {
|
|
553
|
+
if (tag === "delete") {
|
|
554
|
+
for (let k = i1; k < i2; k++) removed.add(k);
|
|
555
|
+
} else if (tag === "replace") {
|
|
556
|
+
const pairs = Math.min(i2 - i1, j2 - j1);
|
|
557
|
+
for (let k = i1 + pairs; k < i2; k++) removed.add(k);
|
|
558
|
+
for (let k = 0; k < pairs; k++) {
|
|
559
|
+
replaced_new_text[i1 + k] = row_text(rows_m[j1 + k]);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const surviving: number[] = [];
|
|
565
|
+
for (let k = 0; k < rows_o.length; k++) {
|
|
566
|
+
if (!removed.has(k)) surviving.push(k);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const anchor_text = (orig_idx: number): string => {
|
|
570
|
+
// Anchor on the row's FINAL text: modified rows are matched via the
|
|
571
|
+
// engine's clean-view fallback after their own edit applies.
|
|
572
|
+
return replaced_new_text[orig_idx] !== undefined
|
|
573
|
+
? replaced_new_text[orig_idx]
|
|
574
|
+
: row_text(rows_o[orig_idx]);
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
const insert_ops = (
|
|
578
|
+
new_rows: RowGeometry[],
|
|
579
|
+
at_orig_index: number,
|
|
580
|
+
): DiffEdit[] => {
|
|
581
|
+
const ops: DiffEdit[] = [];
|
|
582
|
+
const before = surviving.filter((k) => k < at_orig_index);
|
|
583
|
+
const after = surviving.filter((k) => k >= at_orig_index);
|
|
584
|
+
if (before.length > 0) {
|
|
585
|
+
// Insert below the preceding surviving row. Emitting in reverse keeps
|
|
586
|
+
// the final order: below-A(B), then below-A(C) yields A,C,B — so emit
|
|
587
|
+
// C first.
|
|
588
|
+
const anchor_idx = before[before.length - 1];
|
|
589
|
+
const anchor = anchor_text(anchor_idx);
|
|
590
|
+
for (const r of [...new_rows].reverse()) {
|
|
591
|
+
ops.push({
|
|
592
|
+
type: "insert_row",
|
|
593
|
+
target_text: anchor,
|
|
594
|
+
position: "below",
|
|
595
|
+
cells: [...r.cells],
|
|
596
|
+
// Pin to the anchor row's offset: text anchors alone are
|
|
597
|
+
// ambiguous when tables share identical rows. Pins do not
|
|
598
|
+
// survive JSON round-trips (the strict text match applies then,
|
|
599
|
+
// failing closed with the duplicate-row warning above).
|
|
600
|
+
_match_start_index: rows_o[anchor_idx].start,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
} else if (after.length > 0) {
|
|
604
|
+
const anchor_idx = after[0];
|
|
605
|
+
const anchor = anchor_text(anchor_idx);
|
|
606
|
+
for (const r of new_rows) {
|
|
607
|
+
ops.push({
|
|
608
|
+
type: "insert_row",
|
|
609
|
+
target_text: anchor,
|
|
610
|
+
position: "above",
|
|
611
|
+
cells: [...r.cells],
|
|
612
|
+
_match_start_index: rows_o[anchor_idx].start,
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
} else {
|
|
616
|
+
warnings.push(
|
|
617
|
+
"A table gained rows but no original row survives to anchor them; " +
|
|
618
|
+
"these row insertions were skipped — add them with explicit insert_row operations.",
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
return ops;
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
const ops: DiffEdit[] = [];
|
|
625
|
+
for (const [tag, i1, i2, j1, j2] of opcodes) {
|
|
626
|
+
if (tag === "equal") continue;
|
|
627
|
+
if (tag === "replace") {
|
|
628
|
+
const pairs = Math.min(i2 - i1, j2 - j1);
|
|
629
|
+
for (let k = 0; k < pairs; k++) {
|
|
630
|
+
const o_txt = row_text(rows_o[i1 + k]);
|
|
631
|
+
const m_txt = row_text(rows_m[j1 + k]);
|
|
632
|
+
if (o_txt !== m_txt) {
|
|
633
|
+
ops.push({
|
|
634
|
+
type: "modify",
|
|
635
|
+
target_text: o_txt,
|
|
636
|
+
new_text: m_txt,
|
|
637
|
+
comment: "Diff: Table row modified",
|
|
638
|
+
_is_table_edit: true,
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
for (let k = i1 + pairs; k < i2; k++) {
|
|
643
|
+
ops.push({
|
|
644
|
+
type: "delete_row",
|
|
645
|
+
target_text: row_text(rows_o[k]),
|
|
646
|
+
_match_start_index: rows_o[k].start,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
const surplus_new = rows_m.slice(j1 + pairs, j2);
|
|
650
|
+
if (surplus_new.length > 0) {
|
|
651
|
+
ops.push(...insert_ops(surplus_new, i2));
|
|
652
|
+
}
|
|
653
|
+
} else if (tag === "delete") {
|
|
654
|
+
for (let k = i1; k < i2; k++) {
|
|
655
|
+
ops.push({
|
|
656
|
+
type: "delete_row",
|
|
657
|
+
target_text: row_text(rows_o[k]),
|
|
658
|
+
_match_start_index: rows_o[k].start,
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
} else if (tag === "insert") {
|
|
662
|
+
ops.push(...insert_ops(rows_m.slice(j1, j2), i1));
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
return ops;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* DOCX-to-DOCX diff over projections extracted with return_structure=true.
|
|
670
|
+
*
|
|
671
|
+
* Improvements over a flat generate_edits_from_text pass:
|
|
672
|
+
* - each OPC part (header/body/footer/notes) diffs against its
|
|
673
|
+
* counterpart, so no edit can span a part boundary and content that
|
|
674
|
+
* moved between parts is reported instead of hidden (QA 2026-07-18 C1);
|
|
675
|
+
* - table row insertions/deletions become structured insert_row /
|
|
676
|
+
* delete_row operations instead of pipe-text edits (QA C2).
|
|
677
|
+
*
|
|
678
|
+
* Every ModifyText keeps its _match_start_index pinned into text_orig, which
|
|
679
|
+
* the engine consumes positionally (the Node engine has no
|
|
680
|
+
* make_edits_self_contained JSON round trip like the Python CLI).
|
|
681
|
+
*
|
|
682
|
+
* Returns { edits, warnings }. Warnings describe fallbacks the caller should
|
|
683
|
+
* surface (differing part layouts, unanchorable rows, …).
|
|
684
|
+
*/
|
|
685
|
+
export function generate_structured_edits(
|
|
686
|
+
text_orig: string,
|
|
687
|
+
struct_orig: ExtractStructure,
|
|
688
|
+
text_mod: string,
|
|
689
|
+
struct_mod: ExtractStructure,
|
|
690
|
+
): { edits: DiffEdit[]; warnings: string[] } {
|
|
691
|
+
const warnings: string[] = [];
|
|
692
|
+
const edits: DiffEdit[] = [];
|
|
693
|
+
|
|
694
|
+
const kinds_o = struct_orig.part_ranges
|
|
695
|
+
.filter(([s, e]) => e > s)
|
|
696
|
+
.map(([, , k]) => k);
|
|
697
|
+
const kinds_m = struct_mod.part_ranges
|
|
698
|
+
.filter(([s, e]) => e > s)
|
|
699
|
+
.map(([, , k]) => k);
|
|
700
|
+
|
|
701
|
+
if (JSON.stringify(kinds_o) !== JSON.stringify(kinds_m)) {
|
|
702
|
+
warnings.push(
|
|
703
|
+
`The documents have different part layouts (${kinds_o.join(" + ") || "none"} vs ` +
|
|
704
|
+
`${kinds_m.join(" + ") || "none"}); comparing flattened text instead. Header/footer ` +
|
|
705
|
+
"additions or removals cannot be expressed as text edits.",
|
|
706
|
+
);
|
|
707
|
+
const flat = _drop_image_marker_hunks(
|
|
708
|
+
generate_edits_from_text(text_orig, text_mod),
|
|
709
|
+
warnings,
|
|
710
|
+
);
|
|
711
|
+
return { edits: [...flat], warnings };
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const ranges_o = struct_orig.part_ranges
|
|
715
|
+
.filter(([s, e]) => e > s)
|
|
716
|
+
.map(([s, e]) => [s, e] as [number, number]);
|
|
717
|
+
const ranges_m = struct_mod.part_ranges
|
|
718
|
+
.filter(([s, e]) => e > s)
|
|
719
|
+
.map(([s, e]) => [s, e] as [number, number]);
|
|
720
|
+
|
|
721
|
+
for (let p = 0; p < ranges_o.length; p++) {
|
|
722
|
+
const [po_start, po_end] = ranges_o[p];
|
|
723
|
+
const [pm_start, pm_end] = ranges_m[p];
|
|
724
|
+
|
|
725
|
+
const tables_o = struct_orig.tables.filter(
|
|
726
|
+
(t) => po_start <= t.start && t.end <= po_end,
|
|
727
|
+
);
|
|
728
|
+
const tables_m = struct_mod.tables.filter(
|
|
729
|
+
(t) => pm_start <= t.start && t.end <= pm_end,
|
|
730
|
+
);
|
|
731
|
+
|
|
732
|
+
const tables_alignable =
|
|
733
|
+
tables_o.length === tables_m.length &&
|
|
734
|
+
tables_o.every((t) => _rows_are_plain(text_orig, t)) &&
|
|
735
|
+
tables_m.every((t) => _rows_are_plain(text_mod, t));
|
|
736
|
+
if (tables_o.length !== tables_m.length) {
|
|
737
|
+
warnings.push(
|
|
738
|
+
`A ${kinds_o[p]} part has ${tables_o.length} table(s) in the ` +
|
|
739
|
+
`original but ${tables_m.length} in the modified document; its tables were compared as plain ` +
|
|
740
|
+
"text. Adding or removing whole tables is not supported via diff/apply.",
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (!tables_alignable) {
|
|
745
|
+
const part_edits = generate_edits_from_text(
|
|
746
|
+
text_orig.substring(po_start, po_end),
|
|
747
|
+
text_mod.substring(pm_start, pm_end),
|
|
748
|
+
);
|
|
749
|
+
for (const e of part_edits) {
|
|
750
|
+
e._match_start_index = (e._match_start_index || 0) + po_start;
|
|
751
|
+
}
|
|
752
|
+
edits.push(...part_edits);
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Walk interleaved segments: text-before-table, table, text-after…
|
|
757
|
+
const boundaries_o: [number, number][] = [
|
|
758
|
+
[po_start, po_start],
|
|
759
|
+
...tables_o.map((t) => [t.start, t.end] as [number, number]),
|
|
760
|
+
[po_end, po_end],
|
|
761
|
+
];
|
|
762
|
+
const boundaries_m: [number, number][] = [
|
|
763
|
+
[pm_start, pm_start],
|
|
764
|
+
...tables_m.map((t) => [t.start, t.end] as [number, number]),
|
|
765
|
+
[pm_end, pm_end],
|
|
766
|
+
];
|
|
767
|
+
|
|
768
|
+
for (let seg_idx = 0; seg_idx < boundaries_o.length - 1; seg_idx++) {
|
|
769
|
+
const seg_o_start = boundaries_o[seg_idx][1];
|
|
770
|
+
const seg_o_end = boundaries_o[seg_idx + 1][0];
|
|
771
|
+
const seg_m_start = boundaries_m[seg_idx][1];
|
|
772
|
+
const seg_m_end = boundaries_m[seg_idx + 1][0];
|
|
773
|
+
const seg_edits = generate_edits_from_text(
|
|
774
|
+
text_orig.substring(seg_o_start, seg_o_end),
|
|
775
|
+
text_mod.substring(seg_m_start, seg_m_end),
|
|
776
|
+
);
|
|
777
|
+
for (const e of seg_edits) {
|
|
778
|
+
e._match_start_index = (e._match_start_index || 0) + seg_o_start;
|
|
779
|
+
}
|
|
780
|
+
edits.push(...seg_edits);
|
|
781
|
+
|
|
782
|
+
if (seg_idx < tables_o.length) {
|
|
783
|
+
const t_o = tables_o[seg_idx];
|
|
784
|
+
const t_m = tables_m[seg_idx];
|
|
785
|
+
const row_opcodes = _table_row_opcodes(t_o.rows, t_m.rows);
|
|
786
|
+
if (row_opcodes !== null) {
|
|
787
|
+
edits.push(..._row_ops_for_table(t_o, t_m, row_opcodes, warnings));
|
|
788
|
+
} else {
|
|
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
|
+
});
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// Row operations anchor by row text (pins do not survive JSON): if the
|
|
815
|
+
// anchor text also appears elsewhere in the document — e.g. two tables
|
|
816
|
+
// sharing a header row — the strict text match at apply time is
|
|
817
|
+
// ambiguous. In-process consumers ride the pinned offsets; JSON consumers
|
|
818
|
+
// fail closed, so tell the user why up front.
|
|
819
|
+
const countOccurrences = (haystack: string, needle: string): number => {
|
|
820
|
+
let count = 0;
|
|
821
|
+
let from = 0;
|
|
822
|
+
while (true) {
|
|
823
|
+
const idx = haystack.indexOf(needle, from);
|
|
824
|
+
if (idx === -1) break;
|
|
825
|
+
count++;
|
|
826
|
+
from = idx + needle.length;
|
|
827
|
+
}
|
|
828
|
+
return count;
|
|
829
|
+
};
|
|
830
|
+
let ambiguous_anchor_warned = false;
|
|
831
|
+
for (const e of edits) {
|
|
832
|
+
if (
|
|
833
|
+
(e.type === "insert_row" ||
|
|
834
|
+
e.type === "delete_row" ||
|
|
835
|
+
(e as any)._is_table_edit) &&
|
|
836
|
+
!ambiguous_anchor_warned
|
|
837
|
+
) {
|
|
838
|
+
if (e.target_text && countOccurrences(text_orig, e.target_text) > 1) {
|
|
839
|
+
warnings.push(
|
|
840
|
+
`The row anchor "${e.target_text.substring(0, 60)}" appears more than once in the document. ` +
|
|
841
|
+
"Applying this diff from its JSON output may be rejected as ambiguous — " +
|
|
842
|
+
"make the anchor rows unique, or apply the row changes with explicit " +
|
|
843
|
+
"insert_row/delete_row edits.",
|
|
844
|
+
);
|
|
845
|
+
ambiguous_anchor_warned = true;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// Our own output must never trip the engine's read-only image-marker
|
|
851
|
+
// validation: an added/removed image becomes a warning, not an edit.
|
|
852
|
+
const modify_edits = edits.filter(
|
|
853
|
+
(e): e is ModifyText => e.type === "modify",
|
|
854
|
+
);
|
|
855
|
+
const kept_modifies = new Set(_drop_image_marker_hunks(modify_edits, warnings));
|
|
856
|
+
const final_edits = edits.filter(
|
|
857
|
+
(e) => e.type !== "modify" || kept_modifies.has(e as ModifyText),
|
|
858
|
+
);
|
|
859
|
+
|
|
860
|
+
return { edits: final_edits, warnings };
|
|
861
|
+
}
|
|
862
|
+
|
|
349
863
|
export function create_unified_diff(
|
|
350
864
|
original_text: string,
|
|
351
865
|
modified_text: string,
|
package/src/docx/bridge.ts
CHANGED
|
@@ -234,6 +234,19 @@ export class DocumentObject {
|
|
|
234
234
|
public async save(): Promise<Buffer> {
|
|
235
235
|
for (const part of this.pkg.parts) {
|
|
236
236
|
let xmlStr = serializeXml(part._element.ownerDocument || part._element);
|
|
237
|
+
// Lazily declare the w16du namespace on any part that picked up a
|
|
238
|
+
// tracked change (w16du:dateUtc) without a root declaration — a
|
|
239
|
+
// tracked edit in a header/footer/footnotes part would otherwise
|
|
240
|
+
// serialize an undeclared prefix no parser (including ours) accepts.
|
|
241
|
+
// Unmodified parts never contain "w16du:" and stay untouched.
|
|
242
|
+
// Mirrors the Python engine's _inject_w16du_if_needed at save time.
|
|
243
|
+
if (xmlStr.includes("w16du:") && !xmlStr.includes("xmlns:w16du=")) {
|
|
244
|
+
part._element.setAttribute(
|
|
245
|
+
"xmlns:w16du",
|
|
246
|
+
"http://schemas.microsoft.com/office/word/2023/wordml/word16du",
|
|
247
|
+
);
|
|
248
|
+
xmlStr = serializeXml(part._element.ownerDocument || part._element);
|
|
249
|
+
}
|
|
237
250
|
if (!xmlStr.startsWith("<?xml")) {
|
|
238
251
|
xmlStr =
|
|
239
252
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n' + xmlStr;
|
package/src/domain.ts
CHANGED
|
@@ -42,6 +42,62 @@ function _get_paragraph_text(p: Paragraph): string {
|
|
|
42
42
|
return text;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
const _TERM_BODY = "[A-Z][A-Za-z0-9\\s\\-&'’]{1,60}";
|
|
46
|
+
// Definition typography, matched repeatedly within a paragraph (QA 2026-07-18
|
|
47
|
+
// M7 — a paragraph defining Alpha, Beta AND Gamma must yield all three):
|
|
48
|
+
// 1. paragraph-leading quoted term (optionally after a numbering token)
|
|
49
|
+
// 2. sentence-leading quoted term (after . ; : ! ?)
|
|
50
|
+
// 3. parenthesized inline definition — (the "Term")
|
|
51
|
+
// The 'd' flag (ES2022 match indices) recovers each term's exact position for
|
|
52
|
+
// position-keyed dedupe, mirroring Python's m.start(1).
|
|
53
|
+
const _LEADING_TERM_RE = new RegExp(
|
|
54
|
+
`^(?:[\\d.\\-()a-zA-Z]+\\s*)?["“](${_TERM_BODY})["”]`,
|
|
55
|
+
"d",
|
|
56
|
+
);
|
|
57
|
+
// Like the leading pattern, a sentence-start definition may carry a numbering
|
|
58
|
+
// token ('… the product. 2.2 "Beta" means …').
|
|
59
|
+
const _SENTENCE_TERM_RE = new RegExp(
|
|
60
|
+
`(?<=[.;:!?])\\s+(?:[\\d.\\-()a-zA-Z]+\\s+)?["“](${_TERM_BODY})["”]`,
|
|
61
|
+
"dg",
|
|
62
|
+
);
|
|
63
|
+
const _INLINE_TERM_RE = new RegExp(
|
|
64
|
+
`\\([^)]*?["“](${_TERM_BODY})["”][^)]*?\\)`,
|
|
65
|
+
"dg",
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* All defined terms declared in one paragraph, in appearance order,
|
|
70
|
+
* deduplicated. Language-agnostic: keyed on quoting typography, never on
|
|
71
|
+
* English phrases like "means".
|
|
72
|
+
*/
|
|
73
|
+
export function extract_terms_from_paragraph(text: string): string[] {
|
|
74
|
+
const found: [number, string][] = [];
|
|
75
|
+
const leading = _LEADING_TERM_RE.exec(text) as RegExpExecArray & {
|
|
76
|
+
indices?: Array<[number, number]>;
|
|
77
|
+
};
|
|
78
|
+
if (leading && leading.indices && leading.indices[1]) {
|
|
79
|
+
found.push([leading.indices[1][0], leading[1].trim()]);
|
|
80
|
+
}
|
|
81
|
+
for (const m of text.matchAll(_SENTENCE_TERM_RE)) {
|
|
82
|
+
const indices = (m as any).indices as Array<[number, number]> | undefined;
|
|
83
|
+
if (indices && indices[1]) found.push([indices[1][0], m[1].trim()]);
|
|
84
|
+
}
|
|
85
|
+
for (const m of text.matchAll(_INLINE_TERM_RE)) {
|
|
86
|
+
const indices = (m as any).indices as Array<[number, number]> | undefined;
|
|
87
|
+
if (indices && indices[1]) found.push([indices[1][0], m[1].trim()]);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const terms: string[] = [];
|
|
91
|
+
const seen_positions = new Set<number>();
|
|
92
|
+
found.sort((a, b) => a[0] - b[0]);
|
|
93
|
+
for (const [pos, term] of found) {
|
|
94
|
+
if (seen_positions.has(pos)) continue;
|
|
95
|
+
seen_positions.add(pos);
|
|
96
|
+
terms.push(term);
|
|
97
|
+
}
|
|
98
|
+
return terms;
|
|
99
|
+
}
|
|
100
|
+
|
|
45
101
|
export function extract_all_domain_metadata(
|
|
46
102
|
doc: DocumentObject,
|
|
47
103
|
base_text: string,
|
|
@@ -58,26 +114,14 @@ export function extract_all_domain_metadata(
|
|
|
58
114
|
> = {};
|
|
59
115
|
const raw_references: [string, string][] = [];
|
|
60
116
|
|
|
61
|
-
const leading_re =
|
|
62
|
-
/^(?:[\d.\-()a-zA-Z]+\s*)?["“]([A-Z][A-Za-z0-9\s\-&'’]{1,60})["”]/;
|
|
63
|
-
const inline_re = /\([^)]*?["“]([A-Z][A-Za-z0-9\s\-&'’]{1,60})["”][^)]*?\)/g;
|
|
64
|
-
|
|
65
117
|
for (const item of iter_block_items(doc)) {
|
|
66
118
|
if (!(item instanceof Paragraph)) continue;
|
|
67
119
|
|
|
68
120
|
const text = _get_paragraph_text(item).trim();
|
|
69
121
|
if (!text) continue;
|
|
70
122
|
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
if (leading_match) extracted_terms.push(leading_match[1].trim());
|
|
74
|
-
|
|
75
|
-
const inline_matches = text.matchAll(inline_re);
|
|
76
|
-
for (const m of inline_matches) {
|
|
77
|
-
extracted_terms.push(m[1].trim());
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
for (const term of extracted_terms) {
|
|
123
|
+
// 1. Extract Definitions (every declaration in the paragraph — QA M7)
|
|
124
|
+
for (const term of extract_terms_from_paragraph(text)) {
|
|
81
125
|
if (definitions[term]) duplicates.add(term);
|
|
82
126
|
else definitions[term] = { count: 0 };
|
|
83
127
|
}
|