@a3s-lab/office 0.49.0 → 0.50.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/README.md CHANGED
@@ -309,6 +309,23 @@ and [CLI reference](./docs/latest/en/cli-reference.md).
309
309
 
310
310
  ## Current release
311
311
 
312
+ Version `0.50.0` extends Writer Compare with a bounded, WPS-referenced text
313
+ move workflow:
314
+
315
+ - Deterministic lexical ranges that move within one simple paragraph or heading
316
+ become one paired `move` review item. Separators travel with the range, and
317
+ accepting, rejecting, undoing, or reopening the DOCX preserves exact source
318
+ and revised text.
319
+ - The Review ribbon and Changes pane expose one localized **移动** card with
320
+ source/destination marks, destination navigation, updated author guidance,
321
+ and move-aware toast counts. Ambiguous duplicates, rich or relationship-bound
322
+ content, cross-paragraph moves, and over-limit inputs remain fail-closed
323
+ boundaries.
324
+ - The WPS 12.0 COM/UIA probe observed `CompareDocuments` returning ordinary
325
+ delete/insert records for the tested reorder. A3S therefore documents its
326
+ paired inference as a bounded local enhancement, with no claim that WPS
327
+ exposed a native move type through that API.
328
+
312
329
  Version `0.49.0` adds bounded native Writer move revisions and hardens the
313
330
  local PDF workflow:
314
331
 
@@ -17434,6 +17434,222 @@ function sortJsonValue(value) {
17434
17434
  sortJsonValue(child)
17435
17435
  ]));
17436
17436
  }
17437
+ function createComparisonIdentityFactory(currentSignature, revisedSignature, options) {
17438
+ const seed = stableHash(`${options.mode}\u0000${currentSignature}\u0000${revisedSignature}`);
17439
+ const author = boundedMetadata(options.author, 256) || 'A3S Work user';
17440
+ const date = normalizedDate(options.date);
17441
+ const summary = emptyComparisonSummary();
17442
+ let sequence = 0;
17443
+ return {
17444
+ summary,
17445
+ create (kind) {
17446
+ sequence += 1;
17447
+ if ('insertion' === kind) summary.insertions += 1;
17448
+ else if ('deletion' === kind) summary.deletions += 1;
17449
+ else if ('formatting' === kind) summary.formatting += 1;
17450
+ else if ('move' === kind) summary.moves = (summary.moves ?? 0) + 1;
17451
+ else summary.paragraphFormatting += 1;
17452
+ return {
17453
+ id: `compare-${seed}-${kind}-${sequence.toString(36)}`,
17454
+ author,
17455
+ date
17456
+ };
17457
+ },
17458
+ paragraphIdentity (channel) {
17459
+ return {
17460
+ paragraphId: wordParagraphId(`${seed}:${channel}:paragraph`),
17461
+ textId: wordParagraphId(`${seed}:${channel}:text`)
17462
+ };
17463
+ }
17464
+ };
17465
+ }
17466
+ function summarizeComparisonChanges(changes) {
17467
+ const summary = emptyComparisonSummary();
17468
+ for (const change of changes)if ('insertion' === change.kind) summary.insertions += 1;
17469
+ else if ('deletion' === change.kind) summary.deletions += 1;
17470
+ else if ('formatting' === change.kind) summary.formatting += 1;
17471
+ else if ('move' === change.kind) summary.moves = (summary.moves ?? 0) + 1;
17472
+ else summary.paragraphFormatting += 1;
17473
+ return summary;
17474
+ }
17475
+ function emptyComparisonSummary() {
17476
+ return {
17477
+ deletions: 0,
17478
+ formatting: 0,
17479
+ insertions: 0,
17480
+ paragraphFormatting: 0
17481
+ };
17482
+ }
17483
+ const MAX_INFERRED_MOVE_TEXT = 65536;
17484
+ const MAX_INFERRED_MOVES = 256;
17485
+ function work_document_compare_moves_appendRevisionUnits(target, units, kind, identity, schema, stripReviewMarks) {
17486
+ for (const unit of units)target.push(schema.text(unit.text, [
17487
+ ...stripReviewMarks(unit.marks),
17488
+ schema.marks.documentChange.create({
17489
+ kind,
17490
+ id: identity.id,
17491
+ actorId: identity.actorId ?? '',
17492
+ author: identity.author,
17493
+ date: identity.date,
17494
+ before: ''
17495
+ })
17496
+ ]));
17497
+ }
17498
+ function inferInlineMovePairs(changes, marksEqual) {
17499
+ const fullCandidates = [];
17500
+ for (const [stepIndex, change] of changes.entries()){
17501
+ if ('equal' === change.kind) continue;
17502
+ const units = 'delete' === change.kind ? change.left : change.right;
17503
+ const candidate = inlineMoveCandidate(stepIndex, change.kind, units);
17504
+ if (candidate) fullCandidates.push(candidate);
17505
+ }
17506
+ const fullPairs = pairInlineMoveCandidates(fullCandidates, marksEqual);
17507
+ const consumedSteps = new Set();
17508
+ for (const pair of fullPairs){
17509
+ consumedSteps.add(pair.deletion.stepIndex);
17510
+ consumedSteps.add(pair.insertion.stepIndex);
17511
+ }
17512
+ const tokenCandidates = [];
17513
+ for (const [stepIndex, change] of changes.entries()){
17514
+ if ('equal' === change.kind || consumedSteps.has(stepIndex)) continue;
17515
+ const units = 'delete' === change.kind ? change.left : change.right;
17516
+ tokenCandidates.push(...inlineMoveTokenCandidates(stepIndex, change.kind, units));
17517
+ }
17518
+ return [
17519
+ ...fullPairs,
17520
+ ...pairInlineMoveCandidates(tokenCandidates, marksEqual)
17521
+ ].sort((left, right)=>left.deletion.stepIndex - right.deletion.stepIndex || left.insertion.stepIndex - right.insertion.stepIndex || left.deletion.start - right.deletion.start).slice(0, MAX_INFERRED_MOVES);
17522
+ }
17523
+ function appendComparisonRevisionUnits(target, units, kind, schema, factory, stripReviewMarks, appendRevisionUnits, moves) {
17524
+ if (!moves?.length) return void appendRevisionUnits(target, units, kind, factory.create(kind), schema, stripReviewMarks);
17525
+ const byUnit = new Map();
17526
+ for (const move of [
17527
+ ...moves
17528
+ ].sort((left, right)=>left.candidate.start - right.candidate.start))for(let index = move.candidate.start; index < move.candidate.end; index += 1){
17529
+ if (byUnit.has(index)) return void appendRevisionUnits(target, units, kind, factory.create(kind), schema, stripReviewMarks);
17530
+ byUnit.set(index, move);
17531
+ }
17532
+ let ordinary = [];
17533
+ const flushOrdinary = ()=>{
17534
+ if (!ordinary.length) return;
17535
+ appendRevisionUnits(target, ordinary, kind, factory.create(kind), schema, stripReviewMarks);
17536
+ ordinary = [];
17537
+ };
17538
+ for (const [index, unit] of units.entries()){
17539
+ const move = byUnit.get(index);
17540
+ if (!move) {
17541
+ ordinary.push(unit);
17542
+ continue;
17543
+ }
17544
+ const start = index === move.candidate.start ? move.candidate.startOffset : 0;
17545
+ const end = index === move.candidate.end - 1 ? move.candidate.endOffset : unit.text.length;
17546
+ const prefix = unit.text.slice(0, start);
17547
+ const marked = unit.text.slice(start, end);
17548
+ const suffix = unit.text.slice(end);
17549
+ if (prefix) ordinary.push({
17550
+ text: prefix,
17551
+ marks: unit.marks
17552
+ });
17553
+ flushOrdinary();
17554
+ if (marked) appendMoveRevisionUnits(target, [
17555
+ {
17556
+ text: marked,
17557
+ marks: unit.marks
17558
+ }
17559
+ ], move.role, move.identity, schema, stripReviewMarks);
17560
+ ordinary = suffix ? [
17561
+ {
17562
+ text: suffix,
17563
+ marks: unit.marks
17564
+ }
17565
+ ] : [];
17566
+ }
17567
+ flushOrdinary();
17568
+ }
17569
+ function pairInlineMoveCandidates(candidates, marksEqual) {
17570
+ const grouped = new Map();
17571
+ for (const candidate of candidates){
17572
+ const group = grouped.get(candidate.text) ?? {
17573
+ deletion: [],
17574
+ insertion: []
17575
+ };
17576
+ ('delete' === candidate.kind ? group.deletion : group.insertion).push(candidate);
17577
+ grouped.set(candidate.text, group);
17578
+ }
17579
+ return Array.from(grouped.values()).filter(({ deletion, insertion })=>1 === deletion.length && 1 === insertion.length && inlineMoveCandidatesEqual(deletion[0], insertion[0], marksEqual)).map(({ deletion, insertion })=>({
17580
+ deletion: deletion[0],
17581
+ insertion: insertion[0]
17582
+ })).sort((left, right)=>left.deletion.stepIndex - right.deletion.stepIndex || left.insertion.stepIndex - right.insertion.stepIndex).slice(0, MAX_INFERRED_MOVES);
17583
+ }
17584
+ function inlineMoveCandidate(stepIndex, kind, units) {
17585
+ let start = 0;
17586
+ while(start < units.length && isWhitespaceUnit(units[start]))start += 1;
17587
+ let end = units.length;
17588
+ while(end > start && isWhitespaceUnit(units[end - 1]))end -= 1;
17589
+ if (start === end) return null;
17590
+ const first = units[start];
17591
+ const last = units[end - 1];
17592
+ const startOffset = first.leadingWhitespaceAttached ? 0 : first.text.length - first.text.trimStart().length;
17593
+ const endOffset = last.leadingWhitespaceAttached ? last.text.length : last.text.trimEnd().length;
17594
+ const text = units.slice(start, end).map((unit, index, selected)=>unit.text.slice(0 === index ? startOffset : 0, index === selected.length - 1 ? endOffset : unit.text.length)).join('');
17595
+ if (!text || text.length > MAX_INFERRED_MOVE_TEXT || !/[\p{L}\p{N}]/u.test(text)) return null;
17596
+ return {
17597
+ stepIndex,
17598
+ kind,
17599
+ units,
17600
+ start,
17601
+ end,
17602
+ startOffset,
17603
+ endOffset,
17604
+ text
17605
+ };
17606
+ }
17607
+ function inlineMoveTokenCandidates(stepIndex, kind, units) {
17608
+ const candidates = [];
17609
+ for (const [index, unit] of units.entries()){
17610
+ const leadingWhitespace = unit.text.length - unit.text.trimStart().length;
17611
+ const text = unit.text.slice(leadingWhitespace);
17612
+ if (text && !(text.length > MAX_INFERRED_MOVE_TEXT) && /[\p{L}\p{N}]/u.test(text)) candidates.push({
17613
+ stepIndex,
17614
+ kind,
17615
+ units,
17616
+ start: index,
17617
+ end: index + 1,
17618
+ startOffset: leadingWhitespace,
17619
+ endOffset: unit.text.length,
17620
+ text
17621
+ });
17622
+ }
17623
+ return candidates;
17624
+ }
17625
+ function inlineMoveCandidatesEqual(left, right, marksEqual) {
17626
+ const leftUnits = selectedMoveUnits(left);
17627
+ const rightUnits = selectedMoveUnits(right);
17628
+ return leftUnits.length === rightUnits.length && leftUnits.every((unit, index)=>unit.text === rightUnits[index]?.text && marksEqual(unit.marks, rightUnits[index]?.marks ?? []));
17629
+ }
17630
+ function selectedMoveUnits(candidate) {
17631
+ return candidate.units.slice(candidate.start, candidate.end).map((unit, index, selected)=>({
17632
+ text: unit.text.slice(0 === index ? candidate.startOffset : 0, index === selected.length - 1 ? candidate.endOffset : unit.text.length),
17633
+ marks: unit.marks
17634
+ })).filter((unit)=>unit.text.length > 0);
17635
+ }
17636
+ function isWhitespaceUnit(unit) {
17637
+ return '' === unit.text.trim();
17638
+ }
17639
+ function appendMoveRevisionUnits(target, units, role, identity, schema, stripReviewMarks) {
17640
+ for (const unit of units)target.push(schema.text(unit.text, [
17641
+ ...stripReviewMarks(unit.marks),
17642
+ schema.marks.documentChange.create({
17643
+ kind: 'move',
17644
+ moveRole: role,
17645
+ id: identity.id,
17646
+ actorId: identity.actorId ?? '',
17647
+ author: identity.author,
17648
+ date: identity.date,
17649
+ before: ''
17650
+ })
17651
+ ]));
17652
+ }
17437
17653
  const MAX_COMPARISON_BLOCKS = 1024;
17438
17654
  const MAX_COMPARISON_TEXT = 1000000;
17439
17655
  const MAX_BLOCK_ALIGNMENT_CELLS = 1100000;
@@ -17497,7 +17713,7 @@ function planDocumentCompare(current, revised, options) {
17497
17713
  const limits = comparisonLimits(currentSections, revisedSections);
17498
17714
  if (limits) return unsupportedResult(limits);
17499
17715
  const diagnostics = [];
17500
- const factory = comparisonIdentityFactory(current, revised, options);
17716
+ const factory = createComparisonIdentityFactory(comparisonSemanticSignature(current), comparisonSemanticSignature(revised), options);
17501
17717
  const comparedSections = [];
17502
17718
  for(let sectionIndex = 0; sectionIndex < currentSections.length; sectionIndex += 1){
17503
17719
  const currentSection = currentSections[sectionIndex];
@@ -17566,7 +17782,7 @@ function planDocumentCombine(current, reviewed) {
17566
17782
  return {
17567
17783
  status: 'applied',
17568
17784
  document: document1,
17569
- summary: summarizeChanges(reviewedChanges),
17785
+ summary: summarizeComparisonChanges(reviewedChanges),
17570
17786
  diagnostics: []
17571
17787
  };
17572
17788
  }
@@ -17638,11 +17854,11 @@ function comparePairedBlocks(current, revised, factory) {
17638
17854
  return current.type.create(attributes, inline);
17639
17855
  }
17640
17856
  function compareInlineContent(current, revised, factory) {
17641
- const currentUnits = inlineUnits(current);
17642
- const revisedUnits = inlineUnits(revised);
17857
+ let currentUnits = inlineUnits(current);
17858
+ let revisedUnits = inlineUnits(revised);
17643
17859
  if (!currentUnits || !revisedUnits) return null;
17644
17860
  const diff = boundedDocumentSequenceDiff(currentUnits, revisedUnits, (left, right)=>left.text === right.text, MAX_INLINE_DIFF_CELLS);
17645
- const changes = diff ?? [
17861
+ let changes = diff ?? [
17646
17862
  {
17647
17863
  kind: 'delete',
17648
17864
  left: currentUnits,
@@ -17654,14 +17870,48 @@ function compareInlineContent(current, revised, factory) {
17654
17870
  right: revisedUnits
17655
17871
  }
17656
17872
  ];
17873
+ const baseMovePairs = inferInlineMovePairs(changes, work_document_compare_marksEqual);
17874
+ const moveCurrentUnits = inlineUnits(current, true);
17875
+ const moveRevisedUnits = inlineUnits(revised, true);
17876
+ if (moveCurrentUnits && moveRevisedUnits && current.textContent.length + revised.textContent.length <= 131072) {
17877
+ const moveDiff = boundedDocumentSequenceDiff(moveCurrentUnits, moveRevisedUnits, (left, right)=>left.text === right.text, MAX_INLINE_DIFF_CELLS);
17878
+ if (moveDiff) {
17879
+ const moveChanges = moveDiff;
17880
+ const movePairs = inferInlineMovePairs(moveChanges, work_document_compare_marksEqual);
17881
+ if (movePairs.length >= baseMovePairs.length && movePairs.length > 0) {
17882
+ currentUnits = moveCurrentUnits;
17883
+ revisedUnits = moveRevisedUnits;
17884
+ changes = moveChanges;
17885
+ }
17886
+ }
17887
+ }
17888
+ const movePairs = inferInlineMovePairs(changes, work_document_compare_marksEqual);
17889
+ const movesByStep = new Map();
17890
+ for (const pair of movePairs){
17891
+ const identity = factory.create('move');
17892
+ const deletion = movesByStep.get(pair.deletion.stepIndex) ?? [];
17893
+ deletion.push({
17894
+ role: 'from',
17895
+ candidate: pair.deletion,
17896
+ identity
17897
+ });
17898
+ movesByStep.set(pair.deletion.stepIndex, deletion);
17899
+ const insertion = movesByStep.get(pair.insertion.stepIndex) ?? [];
17900
+ insertion.push({
17901
+ role: 'to',
17902
+ candidate: pair.insertion,
17903
+ identity
17904
+ });
17905
+ movesByStep.set(pair.insertion.stepIndex, insertion);
17906
+ }
17657
17907
  const nodes = [];
17658
- for (const change of changes){
17908
+ for (const [stepIndex, change] of changes.entries()){
17659
17909
  if ('delete' === change.kind) {
17660
- appendRevisionUnits(nodes, change.left, 'deletion', factory.create('deletion'), current.type.schema);
17910
+ appendComparisonRevisionUnits(nodes, change.left, 'deletion', current.type.schema, factory, withoutReviewMarks, work_document_compare_moves_appendRevisionUnits, movesByStep.get(stepIndex));
17661
17911
  continue;
17662
17912
  }
17663
17913
  if ('insert' === change.kind) {
17664
- appendRevisionUnits(nodes, change.right, 'insertion', factory.create('insertion'), current.type.schema);
17914
+ appendComparisonRevisionUnits(nodes, change.right, 'insertion', current.type.schema, factory, withoutReviewMarks, work_document_compare_moves_appendRevisionUnits, movesByStep.get(stepIndex));
17665
17915
  continue;
17666
17916
  }
17667
17917
  let formattingIdentity = null;
@@ -17669,7 +17919,7 @@ function compareInlineContent(current, revised, factory) {
17669
17919
  for(let index = 0; index < change.left.length; index += 1){
17670
17920
  const left = change.left[index];
17671
17921
  const right = change.right[index];
17672
- if (marksEqual(left.marks, right.marks)) {
17922
+ if (work_document_compare_marksEqual(left.marks, right.marks)) {
17673
17923
  nodes.push(current.type.schema.text(left.text, [
17674
17924
  ...left.marks
17675
17925
  ]));
@@ -17693,12 +17943,12 @@ function compareInlineContent(current, revised, factory) {
17693
17943
  }
17694
17944
  const deletion = factory.create('deletion');
17695
17945
  const insertion = factory.create('insertion');
17696
- appendRevisionUnits(nodes, [
17946
+ work_document_compare_moves_appendRevisionUnits(nodes, [
17697
17947
  left
17698
- ], 'deletion', deletion, current.type.schema);
17699
- appendRevisionUnits(nodes, [
17948
+ ], 'deletion', deletion, current.type.schema, withoutReviewMarks);
17949
+ work_document_compare_moves_appendRevisionUnits(nodes, [
17700
17950
  right
17701
- ], 'insertion', insertion, current.type.schema);
17951
+ ], 'insertion', insertion, current.type.schema, withoutReviewMarks);
17702
17952
  formattingIdentity = null;
17703
17953
  formattingSignature = '';
17704
17954
  }
@@ -17713,9 +17963,9 @@ function structuralChangeBlock(block, kind, factory, identityChannel) {
17713
17963
  textId: block.attrs.textId
17714
17964
  };
17715
17965
  const nodes = [];
17716
- for (const unit of inlineUnits(block) ?? [])appendRevisionUnits(nodes, [
17966
+ for (const unit of inlineUnits(block) ?? [])work_document_compare_moves_appendRevisionUnits(nodes, [
17717
17967
  unit
17718
- ], kind, identity, block.type.schema);
17968
+ ], kind, identity, block.type.schema, withoutReviewMarks);
17719
17969
  return block.type.create({
17720
17970
  ...block.attrs,
17721
17971
  ...paragraphIdentity,
@@ -17726,19 +17976,6 @@ function structuralChangeBlock(block, kind, factory, identityChannel) {
17726
17976
  blockChangeDate: identity.date
17727
17977
  }, model_Fragment.fromArray(nodes));
17728
17978
  }
17729
- function appendRevisionUnits(target, units, kind, identity, schema) {
17730
- for (const unit of units)target.push(schema.text(unit.text, [
17731
- ...withoutReviewMarks(unit.marks),
17732
- schema.marks.documentChange.create({
17733
- kind,
17734
- id: identity.id,
17735
- actorId: identity.actorId ?? '',
17736
- author: identity.author,
17737
- date: identity.date,
17738
- before: ''
17739
- })
17740
- ]));
17741
- }
17742
17979
  function comparisonMark(node, kind, identity, before) {
17743
17980
  return node.type.schema.marks.documentChange.create({
17744
17981
  kind,
@@ -17822,19 +18059,35 @@ function hasStructuralBlockRevisions(document1) {
17822
18059
  });
17823
18060
  return found;
17824
18061
  }
17825
- function inlineUnits(node) {
18062
+ function inlineUnits(node, attachLeadingWhitespace = false) {
17826
18063
  if (!isSimpleTextBlock(node)) return null;
17827
18064
  const units = [];
17828
18065
  node.forEach((child)=>{
17829
18066
  const text = child.text ?? '';
17830
- for (const part of text.match(/\s+|[\p{L}\p{N}_]+|[^\s\p{L}\p{N}_]/gu) ?? [])units.push({
17831
- text: part,
17832
- marks: child.marks
18067
+ let pendingWhitespace = '';
18068
+ for (const part of text.match(/\s+|[\p{L}\p{N}_]+|[^\s\p{L}\p{N}_]/gu) ?? []){
18069
+ if (attachLeadingWhitespace && '' === part.trim()) {
18070
+ pendingWhitespace += part;
18071
+ continue;
18072
+ }
18073
+ units.push({
18074
+ text: attachLeadingWhitespace ? `${pendingWhitespace}${part}` : part,
18075
+ marks: child.marks,
18076
+ ...attachLeadingWhitespace ? {
18077
+ leadingWhitespaceAttached: true
18078
+ } : {}
18079
+ });
18080
+ pendingWhitespace = '';
18081
+ }
18082
+ if (attachLeadingWhitespace && pendingWhitespace) units.push({
18083
+ text: pendingWhitespace,
18084
+ marks: child.marks,
18085
+ leadingWhitespaceAttached: true
17833
18086
  });
17834
18087
  });
17835
18088
  return units;
17836
18089
  }
17837
- function marksEqual(left, right) {
18090
+ function work_document_compare_marksEqual(left, right) {
17838
18091
  const normalizedLeft = withoutReviewMarks(left);
17839
18092
  const normalizedRight = withoutReviewMarks(right);
17840
18093
  return normalizedLeft.length === normalizedRight.length && normalizedLeft.every((mark, index)=>mark.eq(normalizedRight[index]));
@@ -17920,42 +18173,6 @@ function transferCombineIdentities(current, reviewed) {
17920
18173
  if ('documentSection' === reviewed.type.name) attributes.id = current.attrs.id;
17921
18174
  return reviewed.isText ? reviewed : reviewed.type.create(attributes, model_Fragment.fromArray(content), reviewed.marks);
17922
18175
  }
17923
- function comparisonIdentityFactory(current, revised, options) {
17924
- const seed = stableHash(`${options.mode}\u0000${comparisonSemanticSignature(current)}\u0000${comparisonSemanticSignature(revised)}`);
17925
- const author = boundedMetadata(options.author, 256) || 'A3S Work user';
17926
- const date = normalizedDate(options.date);
17927
- const summary = emptyComparisonSummary();
17928
- let sequence = 0;
17929
- return {
17930
- summary,
17931
- create (kind) {
17932
- sequence += 1;
17933
- if ('insertion' === kind) summary.insertions += 1;
17934
- else if ('deletion' === kind) summary.deletions += 1;
17935
- else if ('formatting' === kind) summary.formatting += 1;
17936
- else summary.paragraphFormatting += 1;
17937
- return {
17938
- id: `compare-${seed}-${kind}-${sequence.toString(36)}`,
17939
- author,
17940
- date
17941
- };
17942
- },
17943
- paragraphIdentity (channel) {
17944
- return {
17945
- paragraphId: wordParagraphId(`${seed}:${channel}:paragraph`),
17946
- textId: wordParagraphId(`${seed}:${channel}:text`)
17947
- };
17948
- }
17949
- };
17950
- }
17951
- function summarizeChanges(changes) {
17952
- const summary = emptyComparisonSummary();
17953
- for (const change of changes)if ('insertion' === change.kind) summary.insertions += 1;
17954
- else if ('deletion' === change.kind) summary.deletions += 1;
17955
- else if ('formatting' === change.kind) summary.formatting += 1;
17956
- else summary.paragraphFormatting += 1;
17957
- return summary;
17958
- }
17959
18176
  function structuralBlockDiagnostic(block, section, blockIndex) {
17960
18177
  return 'paragraph' === block.type.name || 'heading' === block.type.name ? {
17961
18178
  code: 'empty-structural-change',
@@ -17978,14 +18195,6 @@ function unsupportedResult(diagnostic) {
17978
18195
  ]
17979
18196
  };
17980
18197
  }
17981
- function emptyComparisonSummary() {
17982
- return {
17983
- deletions: 0,
17984
- formatting: 0,
17985
- insertions: 0,
17986
- paragraphFormatting: 0
17987
- };
17988
- }
17989
18198
  function childNodes(node) {
17990
18199
  const children = [];
17991
18200
  node.forEach((child)=>{
@@ -18185,7 +18394,7 @@ function DocumentCompareDialog({ initialMode, restoreFocusTarget, onApplied, onC
18185
18394
  }
18186
18395
  }),
18187
18396
  /*#__PURE__*/ jsx("small", {
18188
- children: "该名称会显示在生成的插入、删除与格式修订中。"
18397
+ children: "该名称会显示在生成的插入、删除、移动与格式修订中。"
18189
18398
  })
18190
18399
  ]
18191
18400
  }),
@@ -18223,7 +18432,7 @@ function ComparisonBoundary({ mode }) {
18223
18432
  children: 'compare' === mode ? '确定性比较边界' : '安全合并边界'
18224
18433
  }),
18225
18434
  /*#__PURE__*/ jsx("p", {
18226
- children: 'compare' === mode ? '支持同一分节布局中的段落、标题、文字和格式差异;复杂对象或节布局变化会明确停止。' : '审阅副本必须包含修订,且拒绝全部修订后与当前文档一致;现有修订须先处理。'
18435
+ children: 'compare' === mode ? '支持同一分节布局中的段落、标题、文字、格式差异,以及同一段内可安全识别的文本移动;复杂对象或节布局变化会明确停止。' : '审阅副本必须包含修订,且拒绝全部修订后与当前文档一致;现有修订须先处理。'
18227
18436
  })
18228
18437
  ]
18229
18438
  });
@@ -18294,7 +18503,7 @@ function useDocumentComparison({ editor, onApplied }) {
18294
18503
  }
18295
18504
  };
18296
18505
  const applied = (result)=>{
18297
- const count = result.summary.insertions + result.summary.deletions + result.summary.formatting + result.summary.paragraphFormatting;
18506
+ const count = result.summary.insertions + result.summary.deletions + result.summary.formatting + result.summary.paragraphFormatting + (result.summary.moves ?? 0);
18298
18507
  close();
18299
18508
  onApplied();
18300
18509
  showToast(`已生成 ${count} 项可审阅修订`, 'success');
@@ -0,0 +1,28 @@
1
+ import type { WorkDocumentChange, WorkDocumentChangeIdentity } from './work-document-changes';
2
+ import type { WorkDocumentChangeKind } from './work-types';
3
+ export type DocumentComparisonMode = 'compare' | 'combine';
4
+ export interface DocumentComparisonSummary {
5
+ insertions: number;
6
+ deletions: number;
7
+ formatting: number;
8
+ paragraphFormatting: number;
9
+ /** Number of paired text moves inferred by Compare (when non-zero). */
10
+ moves?: number;
11
+ }
12
+ export interface DocumentComparisonOptions {
13
+ mode: DocumentComparisonMode;
14
+ author: string;
15
+ date: string;
16
+ sourceName: string;
17
+ }
18
+ export interface ComparisonIdentityFactory {
19
+ create(kind: WorkDocumentChangeKind, before?: string): WorkDocumentChangeIdentity;
20
+ paragraphIdentity(channel: string): {
21
+ paragraphId: string;
22
+ textId: string;
23
+ };
24
+ summary: DocumentComparisonSummary;
25
+ }
26
+ export declare function createComparisonIdentityFactory(currentSignature: string, revisedSignature: string, options: DocumentComparisonOptions): ComparisonIdentityFactory;
27
+ export declare function summarizeComparisonChanges(changes: readonly WorkDocumentChange[]): DocumentComparisonSummary;
28
+ export declare function emptyComparisonSummary(): DocumentComparisonSummary;
@@ -0,0 +1,60 @@
1
+ import type { Mark as ProseMirrorMark, Node as ProseMirrorNode, Schema } from '@tiptap/pm/model';
2
+ import type { WorkDocumentChangeIdentity, WorkDocumentChangeKind } from './work-document-changes';
3
+ import type { WorkDocumentMoveRole } from './work-types';
4
+ export interface InlineUnit {
5
+ text: string;
6
+ marks: readonly ProseMirrorMark[];
7
+ leadingWhitespaceAttached?: boolean;
8
+ }
9
+ export type InlineDiffStep = {
10
+ kind: 'equal';
11
+ left: InlineUnit[];
12
+ right: InlineUnit[];
13
+ } | {
14
+ kind: 'delete';
15
+ left: InlineUnit[];
16
+ right: [];
17
+ } | {
18
+ kind: 'insert';
19
+ left: [];
20
+ right: InlineUnit[];
21
+ };
22
+ export interface InlineMoveCandidate {
23
+ stepIndex: number;
24
+ kind: 'delete' | 'insert';
25
+ units: InlineUnit[];
26
+ start: number;
27
+ end: number;
28
+ startOffset: number;
29
+ endOffset: number;
30
+ text: string;
31
+ }
32
+ export interface InlineMovePair {
33
+ deletion: InlineMoveCandidate;
34
+ insertion: InlineMoveCandidate;
35
+ }
36
+ export interface InlineMoveAssignment {
37
+ role: WorkDocumentMoveRole;
38
+ candidate: InlineMoveCandidate;
39
+ identity: WorkDocumentChangeIdentity;
40
+ }
41
+ export interface ComparisonMoveIdentityFactory {
42
+ create(kind: WorkDocumentChangeKind): WorkDocumentChangeIdentity;
43
+ }
44
+ type MarksEqual = (left: readonly ProseMirrorMark[], right: readonly ProseMirrorMark[]) => boolean;
45
+ type StripReviewMarks = (marks: readonly ProseMirrorMark[]) => ProseMirrorMark[];
46
+ type AppendRevisionUnits = (target: ProseMirrorNode[], units: readonly InlineUnit[], kind: 'insertion' | 'deletion', identity: WorkDocumentChangeIdentity, schema: Schema, stripReviewMarks: StripReviewMarks) => void;
47
+ export declare const MAX_INFERRED_MOVE_TEXT = 65536;
48
+ export declare function appendRevisionUnits(target: ProseMirrorNode[], units: readonly InlineUnit[], kind: 'insertion' | 'deletion', identity: WorkDocumentChangeIdentity, schema: Schema, stripReviewMarks: StripReviewMarks): void;
49
+ /**
50
+ * Pairs only deterministic same-text ranges inside one block. Duplicate
51
+ * candidates and ranges whose marks differ are deliberately left as ordinary
52
+ * insertions/deletions so Compare never guesses at an ambiguous move.
53
+ */
54
+ export declare function inferInlineMovePairs(changes: readonly InlineDiffStep[], marksEqual: MarksEqual): InlineMovePair[];
55
+ /**
56
+ * Splits an ordinary diff step around paired move ranges while retaining the
57
+ * exact whitespace and mark boundaries needed to reconstruct either version.
58
+ */
59
+ export declare function appendComparisonRevisionUnits(target: ProseMirrorNode[], units: InlineUnit[], kind: 'insertion' | 'deletion', schema: Schema, factory: ComparisonMoveIdentityFactory, stripReviewMarks: StripReviewMarks, appendRevisionUnits: AppendRevisionUnits, moves?: readonly InlineMoveAssignment[]): void;
60
+ export {};
@@ -1,5 +1,6 @@
1
1
  import { type Content, type Editor } from '@tiptap/core';
2
- export type DocumentComparisonMode = 'compare' | 'combine';
2
+ import { type DocumentComparisonOptions, type DocumentComparisonSummary } from './work-document-compare-identities';
3
+ export type { DocumentComparisonMode, DocumentComparisonOptions, DocumentComparisonSummary, } from './work-document-compare-identities';
3
4
  export type DocumentComparisonDiagnosticCode = 'changed-complex-structure' | 'combine-baseline-mismatch' | 'combine-resolution-invalid' | 'combine-structural-revisions' | 'combine-without-revisions' | 'comparison-limit-exceeded' | 'current-revisions-present' | 'empty-structural-change' | 'invalid-revised-content' | 'revised-revisions-present' | 'section-layout-mismatch' | 'unsupported-inline-review-state';
4
5
  export interface DocumentComparisonDiagnostic {
5
6
  code: DocumentComparisonDiagnosticCode;
@@ -7,18 +8,6 @@ export interface DocumentComparisonDiagnostic {
7
8
  section?: number;
8
9
  block?: number;
9
10
  }
10
- export interface DocumentComparisonSummary {
11
- insertions: number;
12
- deletions: number;
13
- formatting: number;
14
- paragraphFormatting: number;
15
- }
16
- export interface DocumentComparisonOptions {
17
- mode: DocumentComparisonMode;
18
- author: string;
19
- date: string;
20
- sourceName: string;
21
- }
22
11
  export interface DocumentComparisonApplyResult {
23
12
  status: 'applied' | 'unchanged' | 'unsupported';
24
13
  summary: DocumentComparisonSummary;
@@ -296,11 +296,15 @@ The current and imported TipTap/ProseMirror trees remain immutable while a
296
296
  deterministic weighted block alignment pairs related paragraphs and headings;
297
297
  an LCS token diff then produces insertion and deletion marks, while exact mark
298
298
  and paragraph-property snapshots produce character- and paragraph-formatting
299
- revisions. Stable semantic signatures seed generated identities, so the same
300
- inputs and options produce the same ordering and IDs. Only a fully admitted
301
- plan replaces the mounted document, and that replacement is one transaction,
302
- one controlled publication, and one Undo record. The planner does not infer
303
- move pairs from arbitrary delete/insert similarities; native paired moves remain
299
+ revisions. A bounded same-paragraph pass may pair a lexical range that occurs
300
+ exactly once in a delete chunk and once in an insert chunk when its marks and
301
+ carried separators match; the result is one `move` identity with source and
302
+ destination roles. Stable semantic signatures seed generated identities, so the
303
+ same inputs and options produce the same ordering and IDs. Only a fully
304
+ admitted plan replaces the mounted document, and that replacement is one
305
+ transaction, one controlled publication, and one Undo record. Duplicate,
306
+ rich, cross-paragraph, and over-limit candidates remain ordinary revisions or
307
+ diagnostics rather than being guessed into moves; native paired moves remain
304
308
  reviewable when they already exist.
305
309
 
306
310
  Combine validates rather than guesses. It rejects every imported revision on
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a3s-lab/office",
3
- "version": "0.49.0",
3
+ "version": "0.50.0",
4
4
  "description": "Open-source collaborative browser editors for documents, Markdown, spreadsheets, presentations, and PDFs.",
5
5
  "keywords": [
6
6
  "office",