@a3s-lab/office 0.49.0 → 0.51.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,40 @@ and [CLI reference](./docs/latest/en/cli-reference.md).
309
309
 
310
310
  ## Current release
311
311
 
312
+ Version `0.51.0` extends Writer Compare with bounded, WPS-referenced
313
+ cross-paragraph text moves:
314
+
315
+ - A unique lexical range can move between aligned simple text paragraphs or
316
+ headings in the same section and become one paired `move` review item.
317
+ Separators travel with the range; accepting, rejecting, undoing, or reopening
318
+ the DOCX preserves exact source and revised text.
319
+ - The Review ribbon and Changes pane keep one localized **移动** card with
320
+ source/destination marks, destination navigation, author guidance, and
321
+ move-aware toast counts. Duplicate or mark-mismatched candidates, section
322
+ boundaries, rich or relationship-bound content, tables, and over-limit input
323
+ remain fail-closed ordinary revisions or diagnostics.
324
+ - The WPS 12.0 COM/UIA probe observed `CompareDocuments` returning ordinary
325
+ delete/insert records for the tested reorder. A3S therefore documents this
326
+ paired inference as a bounded local enhancement, with no claim that WPS
327
+ exposed a native move type through that API.
328
+
329
+ Version `0.50.0` extends Writer Compare with a bounded, WPS-referenced text
330
+ move workflow:
331
+
332
+ - Deterministic lexical ranges that move within one simple paragraph or heading
333
+ become one paired `move` review item. Separators travel with the range, and
334
+ accepting, rejecting, undoing, or reopening the DOCX preserves exact source
335
+ and revised text.
336
+ - The Review ribbon and Changes pane expose one localized **移动** card with
337
+ source/destination marks, destination navigation, updated author guidance,
338
+ and move-aware toast counts. Ambiguous duplicates, rich or relationship-bound
339
+ content, cross-paragraph moves, and over-limit inputs remain fail-closed
340
+ boundaries.
341
+ - The WPS 12.0 COM/UIA probe observed `CompareDocuments` returning ordinary
342
+ delete/insert records for the tested reorder. A3S therefore documents its
343
+ paired inference as a bounded local enhancement, with no claim that WPS
344
+ exposed a native move type through that API.
345
+
312
346
  Version `0.49.0` adds bounded native Writer move revisions and hardens the
313
347
  local PDF workflow:
314
348
 
@@ -17434,6 +17434,245 @@ 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
+ return inferInlineMovePairsAcrossScopes([
17500
+ {
17501
+ scope: '',
17502
+ changes
17503
+ }
17504
+ ], marksEqual);
17505
+ }
17506
+ function inferInlineMovePairsAcrossScopes(comparisons, marksEqual) {
17507
+ const fullCandidates = [];
17508
+ for (const comparison of comparisons)for (const [stepIndex, change] of comparison.changes.entries()){
17509
+ if ('equal' === change.kind) continue;
17510
+ const units = 'delete' === change.kind ? change.left : change.right;
17511
+ const candidate = inlineMoveCandidate(stepIndex, change.kind, units, comparison.scope);
17512
+ if (candidate) fullCandidates.push(candidate);
17513
+ }
17514
+ const fullPairs = pairInlineMoveCandidates(fullCandidates, marksEqual);
17515
+ const consumedSteps = new Set();
17516
+ for (const pair of fullPairs){
17517
+ consumedSteps.add(moveStepKey(pair.deletion));
17518
+ consumedSteps.add(moveStepKey(pair.insertion));
17519
+ }
17520
+ const tokenCandidates = [];
17521
+ for (const comparison of comparisons)for (const [stepIndex, change] of comparison.changes.entries()){
17522
+ if ('equal' === change.kind || consumedSteps.has(moveStepKey({
17523
+ scope: comparison.scope,
17524
+ stepIndex
17525
+ }))) continue;
17526
+ const units = 'delete' === change.kind ? change.left : change.right;
17527
+ tokenCandidates.push(...inlineMoveTokenCandidates(stepIndex, change.kind, units, comparison.scope));
17528
+ }
17529
+ return [
17530
+ ...fullPairs,
17531
+ ...pairInlineMoveCandidates(tokenCandidates, marksEqual)
17532
+ ].sort(compareMovePairs).slice(0, MAX_INFERRED_MOVES);
17533
+ }
17534
+ function appendComparisonRevisionUnits(target, units, kind, schema, factory, stripReviewMarks, appendRevisionUnits, moves) {
17535
+ if (!moves?.length) return void appendRevisionUnits(target, units, kind, factory.create(kind), schema, stripReviewMarks);
17536
+ const byUnit = new Map();
17537
+ for (const move of [
17538
+ ...moves
17539
+ ].sort((left, right)=>left.candidate.start - right.candidate.start))for(let index = move.candidate.start; index < move.candidate.end; index += 1){
17540
+ if (byUnit.has(index)) return void appendRevisionUnits(target, units, kind, factory.create(kind), schema, stripReviewMarks);
17541
+ byUnit.set(index, move);
17542
+ }
17543
+ let ordinary = [];
17544
+ const flushOrdinary = ()=>{
17545
+ if (!ordinary.length) return;
17546
+ appendRevisionUnits(target, ordinary, kind, factory.create(kind), schema, stripReviewMarks);
17547
+ ordinary = [];
17548
+ };
17549
+ for (const [index, unit] of units.entries()){
17550
+ const move = byUnit.get(index);
17551
+ if (!move) {
17552
+ ordinary.push(unit);
17553
+ continue;
17554
+ }
17555
+ const start = index === move.candidate.start ? move.candidate.startOffset : 0;
17556
+ const end = index === move.candidate.end - 1 ? move.candidate.endOffset : unit.text.length;
17557
+ const prefix = unit.text.slice(0, start);
17558
+ const marked = unit.text.slice(start, end);
17559
+ const suffix = unit.text.slice(end);
17560
+ if (prefix) ordinary.push({
17561
+ text: prefix,
17562
+ marks: unit.marks
17563
+ });
17564
+ flushOrdinary();
17565
+ if (marked) appendMoveRevisionUnits(target, [
17566
+ {
17567
+ text: marked,
17568
+ marks: unit.marks
17569
+ }
17570
+ ], move.role, move.identity, schema, stripReviewMarks);
17571
+ ordinary = suffix ? [
17572
+ {
17573
+ text: suffix,
17574
+ marks: unit.marks
17575
+ }
17576
+ ] : [];
17577
+ }
17578
+ flushOrdinary();
17579
+ }
17580
+ function pairInlineMoveCandidates(candidates, marksEqual) {
17581
+ const grouped = new Map();
17582
+ for (const candidate of candidates){
17583
+ const group = grouped.get(candidate.text) ?? {
17584
+ deletion: [],
17585
+ insertion: []
17586
+ };
17587
+ ('delete' === candidate.kind ? group.deletion : group.insertion).push(candidate);
17588
+ grouped.set(candidate.text, group);
17589
+ }
17590
+ return Array.from(grouped.values()).filter(({ deletion, insertion })=>1 === deletion.length && 1 === insertion.length && inlineMoveCandidatesEqual(deletion[0], insertion[0], marksEqual)).map(({ deletion, insertion })=>({
17591
+ deletion: deletion[0],
17592
+ insertion: insertion[0]
17593
+ })).sort(compareMovePairs).slice(0, MAX_INFERRED_MOVES);
17594
+ }
17595
+ function inlineMoveCandidate(stepIndex, kind, units, scope = '') {
17596
+ let start = 0;
17597
+ while(start < units.length && isWhitespaceUnit(units[start]))start += 1;
17598
+ let end = units.length;
17599
+ while(end > start && isWhitespaceUnit(units[end - 1]))end -= 1;
17600
+ if (start === end) return null;
17601
+ const first = units[start];
17602
+ const last = units[end - 1];
17603
+ const startOffset = first.leadingWhitespaceAttached ? 0 : first.text.length - first.text.trimStart().length;
17604
+ const endOffset = last.leadingWhitespaceAttached ? last.text.length : last.text.trimEnd().length;
17605
+ 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('');
17606
+ if (!text || text.length > MAX_INFERRED_MOVE_TEXT || !/[\p{L}\p{N}]/u.test(text)) return null;
17607
+ return {
17608
+ ...scope ? {
17609
+ scope
17610
+ } : {},
17611
+ stepIndex,
17612
+ kind,
17613
+ units,
17614
+ start,
17615
+ end,
17616
+ startOffset,
17617
+ endOffset,
17618
+ text
17619
+ };
17620
+ }
17621
+ function inlineMoveTokenCandidates(stepIndex, kind, units, scope = '') {
17622
+ const candidates = [];
17623
+ for (const [index, unit] of units.entries()){
17624
+ const leadingWhitespace = unit.text.length - unit.text.trimStart().length;
17625
+ const text = unit.text.slice(leadingWhitespace);
17626
+ if (text && !(text.length > MAX_INFERRED_MOVE_TEXT) && /[\p{L}\p{N}]/u.test(text)) candidates.push({
17627
+ ...scope ? {
17628
+ scope
17629
+ } : {},
17630
+ stepIndex,
17631
+ kind,
17632
+ units,
17633
+ start: index,
17634
+ end: index + 1,
17635
+ startOffset: leadingWhitespace,
17636
+ endOffset: unit.text.length,
17637
+ text
17638
+ });
17639
+ }
17640
+ return candidates;
17641
+ }
17642
+ function moveStepKey(candidate) {
17643
+ return `${candidate.scope ?? ''}\u0000${candidate.stepIndex}`;
17644
+ }
17645
+ function compareMovePairs(left, right) {
17646
+ return (left.deletion.scope ?? '').localeCompare(right.deletion.scope ?? '') || left.deletion.stepIndex - right.deletion.stepIndex || (left.insertion.scope ?? '').localeCompare(right.insertion.scope ?? '') || left.insertion.stepIndex - right.insertion.stepIndex || left.deletion.start - right.deletion.start;
17647
+ }
17648
+ function inlineMoveCandidatesEqual(left, right, marksEqual) {
17649
+ const leftUnits = selectedMoveUnits(left);
17650
+ const rightUnits = selectedMoveUnits(right);
17651
+ return leftUnits.length === rightUnits.length && leftUnits.every((unit, index)=>unit.text === rightUnits[index]?.text && marksEqual(unit.marks, rightUnits[index]?.marks ?? []));
17652
+ }
17653
+ function selectedMoveUnits(candidate) {
17654
+ return candidate.units.slice(candidate.start, candidate.end).map((unit, index, selected)=>({
17655
+ text: unit.text.slice(0 === index ? candidate.startOffset : 0, index === selected.length - 1 ? candidate.endOffset : unit.text.length),
17656
+ marks: unit.marks
17657
+ })).filter((unit)=>unit.text.length > 0);
17658
+ }
17659
+ function isWhitespaceUnit(unit) {
17660
+ return '' === unit.text.trim();
17661
+ }
17662
+ function appendMoveRevisionUnits(target, units, role, identity, schema, stripReviewMarks) {
17663
+ for (const unit of units)target.push(schema.text(unit.text, [
17664
+ ...stripReviewMarks(unit.marks),
17665
+ schema.marks.documentChange.create({
17666
+ kind: 'move',
17667
+ moveRole: role,
17668
+ id: identity.id,
17669
+ actorId: identity.actorId ?? '',
17670
+ author: identity.author,
17671
+ date: identity.date,
17672
+ before: ''
17673
+ })
17674
+ ]));
17675
+ }
17437
17676
  const MAX_COMPARISON_BLOCKS = 1024;
17438
17677
  const MAX_COMPARISON_TEXT = 1000000;
17439
17678
  const MAX_BLOCK_ALIGNMENT_CELLS = 1100000;
@@ -17497,7 +17736,7 @@ function planDocumentCompare(current, revised, options) {
17497
17736
  const limits = comparisonLimits(currentSections, revisedSections);
17498
17737
  if (limits) return unsupportedResult(limits);
17499
17738
  const diagnostics = [];
17500
- const factory = comparisonIdentityFactory(current, revised, options);
17739
+ const factory = createComparisonIdentityFactory(comparisonSemanticSignature(current), comparisonSemanticSignature(revised), options);
17501
17740
  const comparedSections = [];
17502
17741
  for(let sectionIndex = 0; sectionIndex < currentSections.length; sectionIndex += 1){
17503
17742
  const currentSection = currentSections[sectionIndex];
@@ -17566,7 +17805,7 @@ function planDocumentCombine(current, reviewed) {
17566
17805
  return {
17567
17806
  status: 'applied',
17568
17807
  document: document1,
17569
- summary: summarizeChanges(reviewedChanges),
17808
+ summary: summarizeComparisonChanges(reviewedChanges),
17570
17809
  diagnostics: []
17571
17810
  };
17572
17811
  }
@@ -17582,40 +17821,129 @@ function compareSectionBlocks(currentSection, revisedSection, sectionIndex, fact
17582
17821
  });
17583
17822
  return null;
17584
17823
  }
17585
- const result = [];
17824
+ const slots = [];
17586
17825
  for (const [blockIndex, step] of alignment.entries()){
17587
17826
  if ('equal' === step.kind) {
17588
- result.push(step.left);
17827
+ slots.push({
17828
+ kind: 'node',
17829
+ node: step.left
17830
+ });
17589
17831
  continue;
17590
17832
  }
17591
17833
  if ('delete' === step.kind) {
17592
17834
  const deleted = structuralChangeBlock(step.left, 'deletion', factory, `section-${sectionIndex}-delete-${blockIndex}`);
17593
- if (deleted) result.push(deleted);
17835
+ if (deleted) slots.push({
17836
+ kind: 'node',
17837
+ node: deleted
17838
+ });
17594
17839
  else diagnostics.push(structuralBlockDiagnostic(step.left, sectionIndex, blockIndex));
17595
17840
  continue;
17596
17841
  }
17597
17842
  if ('insert' === step.kind) {
17598
17843
  const inserted = structuralChangeBlock(step.right, 'insertion', factory, `section-${sectionIndex}-insert-${blockIndex}`);
17599
- if (inserted) result.push(inserted);
17844
+ if (inserted) slots.push({
17845
+ kind: 'node',
17846
+ node: inserted
17847
+ });
17600
17848
  else diagnostics.push(structuralBlockDiagnostic(step.right, sectionIndex, blockIndex));
17601
17849
  continue;
17602
17850
  }
17603
- const compared = comparePairedBlocks(step.left, step.right, factory);
17604
- if (compared) {
17605
- result.push(compared);
17851
+ const prepared = preparePairedBlock(step.left, step.right, `section-${sectionIndex}-block-${blockIndex}`);
17852
+ if (prepared) {
17853
+ slots.push({
17854
+ kind: 'paired',
17855
+ comparison: prepared
17856
+ });
17606
17857
  continue;
17607
17858
  }
17608
17859
  const deleted = structuralChangeBlock(step.left, 'deletion', factory, `section-${sectionIndex}-replace-delete-${blockIndex}`);
17609
17860
  const inserted = structuralChangeBlock(step.right, 'insertion', factory, `section-${sectionIndex}-replace-insert-${blockIndex}`);
17610
- if (deleted && inserted) result.push(deleted, inserted);
17611
- else diagnostics.push(structuralBlockDiagnostic(step.left, sectionIndex, blockIndex));
17861
+ if (deleted) slots.push({
17862
+ kind: 'node',
17863
+ node: deleted
17864
+ });
17865
+ if (inserted) slots.push({
17866
+ kind: 'node',
17867
+ node: inserted
17868
+ });
17869
+ if (!deleted || !inserted) diagnostics.push(structuralBlockDiagnostic(step.left, sectionIndex, blockIndex));
17612
17870
  }
17613
- return result;
17871
+ const pairedSlots = slots.filter((slot)=>'paired' === slot.kind);
17872
+ const initialComparisons = pairedSlots.map(({ comparison })=>({
17873
+ scope: comparison.scope,
17874
+ changes: comparison.inline.variant.changes
17875
+ }));
17876
+ const initialMovePairs = inferInlineMovePairsAcrossScopes(initialComparisons, work_document_compare_marksEqual);
17877
+ const attachedComparisons = pairedSlots.map(({ comparison })=>({
17878
+ scope: comparison.scope,
17879
+ changes: comparison.inline.attached?.changes ?? comparison.inline.variant.changes
17880
+ }));
17881
+ const attachedMovePairs = inferInlineMovePairsAcrossScopes(attachedComparisons, work_document_compare_marksEqual);
17882
+ const pairedByScope = new Map(pairedSlots.map((slot)=>[
17883
+ slot.comparison.scope,
17884
+ slot.comparison
17885
+ ]));
17886
+ const attachedScopes = new Set();
17887
+ for (const pair of [
17888
+ ...initialMovePairs,
17889
+ ...attachedMovePairs
17890
+ ]){
17891
+ const deletionScope = pair.deletion.scope ?? '';
17892
+ const insertionScope = pair.insertion.scope ?? '';
17893
+ if (deletionScope === insertionScope) continue;
17894
+ const deletionBlock = pairedByScope.get(deletionScope);
17895
+ const insertionBlock = pairedByScope.get(insertionScope);
17896
+ if (deletionBlock?.inline.attached && insertionBlock?.inline.attached) {
17897
+ attachedScopes.add(deletionScope);
17898
+ attachedScopes.add(insertionScope);
17899
+ }
17900
+ }
17901
+ const selectedComparisons = pairedSlots.map(({ comparison })=>({
17902
+ scope: comparison.scope,
17903
+ changes: (attachedScopes.has(comparison.scope) ? comparison.inline.attached : comparison.inline.variant)?.changes ?? comparison.inline.variant.changes
17904
+ }));
17905
+ const movePairs = inferInlineMovePairsAcrossScopes(selectedComparisons, work_document_compare_marksEqual);
17906
+ const useAttachedVariants = attachedScopes.size > 0 && movePairs.length >= initialMovePairs.length;
17907
+ const movesByScope = new Map();
17908
+ for (const pair of useAttachedVariants ? movePairs : initialMovePairs){
17909
+ const identity = factory.create('move');
17910
+ appendMoveAssignment(movesByScope, pair.deletion, 'from', identity);
17911
+ appendMoveAssignment(movesByScope, pair.insertion, 'to', identity);
17912
+ }
17913
+ return slots.flatMap((slot)=>{
17914
+ if ('node' === slot.kind) return [
17915
+ slot.node
17916
+ ];
17917
+ const compared = renderPairedBlock(slot.comparison, factory, movesByScope.get(slot.comparison.scope), useAttachedVariants && attachedScopes.has(slot.comparison.scope) ? slot.comparison.inline.attached ?? slot.comparison.inline.variant : slot.comparison.inline.variant);
17918
+ return compared ? [
17919
+ compared
17920
+ ] : [];
17921
+ });
17922
+ }
17923
+ function appendMoveAssignment(target, candidate, role, identity) {
17924
+ const scope = candidate.scope ?? '';
17925
+ const assignments = target.get(scope) ?? [];
17926
+ assignments.push({
17927
+ role,
17928
+ candidate,
17929
+ identity
17930
+ });
17931
+ target.set(scope, assignments);
17614
17932
  }
17615
- function comparePairedBlocks(current, revised, factory) {
17933
+ function preparePairedBlock(current, revised, scope) {
17616
17934
  if (!isSimpleTextBlock(current) || !isSimpleTextBlock(revised)) return null;
17617
17935
  if (current.type !== revised.type || blockStructuralSignature(current) !== blockStructuralSignature(revised)) return null;
17618
- const inline = compareInlineContent(current, revised, factory);
17936
+ const comparison = prepareInlineComparison(current, revised);
17937
+ return comparison ? {
17938
+ current,
17939
+ revised,
17940
+ scope,
17941
+ inline: comparison
17942
+ } : null;
17943
+ }
17944
+ function renderPairedBlock(comparison, factory, moves, variant) {
17945
+ const { current, revised } = comparison;
17946
+ const inline = renderInlineComparison(current, variant, factory, moves);
17619
17947
  if (!inline) return null;
17620
17948
  const before = serializeDocumentParagraphFormatting(current.attrs);
17621
17949
  const after = serializeDocumentParagraphFormatting(revised.attrs);
@@ -17637,31 +17965,65 @@ function comparePairedBlocks(current, revised, factory) {
17637
17965
  }
17638
17966
  return current.type.create(attributes, inline);
17639
17967
  }
17640
- function compareInlineContent(current, revised, factory) {
17641
- const currentUnits = inlineUnits(current);
17642
- const revisedUnits = inlineUnits(revised);
17643
- if (!currentUnits || !revisedUnits) return null;
17644
- const diff = boundedDocumentSequenceDiff(currentUnits, revisedUnits, (left, right)=>left.text === right.text, MAX_INLINE_DIFF_CELLS);
17645
- const changes = diff ?? [
17646
- {
17647
- kind: 'delete',
17648
- left: currentUnits,
17649
- right: []
17650
- },
17651
- {
17652
- kind: 'insert',
17653
- left: [],
17654
- right: revisedUnits
17968
+ function prepareInlineComparison(current, revised) {
17969
+ const baseCurrentUnits = inlineUnits(current);
17970
+ const baseRevisedUnits = inlineUnits(revised);
17971
+ if (!baseCurrentUnits || !baseRevisedUnits) return null;
17972
+ const diff = boundedDocumentSequenceDiff(baseCurrentUnits, baseRevisedUnits, (left, right)=>left.text === right.text, MAX_INLINE_DIFF_CELLS);
17973
+ const baseVariant = {
17974
+ currentUnits: baseCurrentUnits,
17975
+ revisedUnits: baseRevisedUnits,
17976
+ changes: diff ?? [
17977
+ {
17978
+ kind: 'delete',
17979
+ left: baseCurrentUnits,
17980
+ right: []
17981
+ },
17982
+ {
17983
+ kind: 'insert',
17984
+ left: [],
17985
+ right: baseRevisedUnits
17986
+ }
17987
+ ]
17988
+ };
17989
+ const variant = baseVariant;
17990
+ const baseMovePairs = inferInlineMovePairs(variant.changes, work_document_compare_marksEqual);
17991
+ const moveCurrentUnits = inlineUnits(current, true);
17992
+ const moveRevisedUnits = inlineUnits(revised, true);
17993
+ if (moveCurrentUnits && moveRevisedUnits && current.textContent.length + revised.textContent.length <= 131072) {
17994
+ const moveDiff = boundedDocumentSequenceDiff(moveCurrentUnits, moveRevisedUnits, (left, right)=>left.text === right.text, MAX_INLINE_DIFF_CELLS);
17995
+ if (moveDiff) {
17996
+ const moveChanges = moveDiff;
17997
+ const moveVariant = {
17998
+ currentUnits: moveCurrentUnits,
17999
+ revisedUnits: moveRevisedUnits,
18000
+ changes: moveChanges
18001
+ };
18002
+ const movePairs = inferInlineMovePairs(moveChanges, work_document_compare_marksEqual);
18003
+ if (movePairs.length >= baseMovePairs.length && movePairs.length > 0) return {
18004
+ variant: moveVariant,
18005
+ attached: moveVariant
18006
+ };
18007
+ return {
18008
+ variant,
18009
+ attached: moveVariant
18010
+ };
17655
18011
  }
17656
- ];
18012
+ }
18013
+ return {
18014
+ variant
18015
+ };
18016
+ }
18017
+ function renderInlineComparison(current, variant, factory, moves) {
18018
+ const { changes } = variant;
17657
18019
  const nodes = [];
17658
- for (const change of changes){
18020
+ for (const [stepIndex, change] of changes.entries()){
17659
18021
  if ('delete' === change.kind) {
17660
- appendRevisionUnits(nodes, change.left, 'deletion', factory.create('deletion'), current.type.schema);
18022
+ appendComparisonRevisionUnits(nodes, change.left, 'deletion', current.type.schema, factory, withoutReviewMarks, work_document_compare_moves_appendRevisionUnits, moves?.filter((move)=>move.candidate.stepIndex === stepIndex));
17661
18023
  continue;
17662
18024
  }
17663
18025
  if ('insert' === change.kind) {
17664
- appendRevisionUnits(nodes, change.right, 'insertion', factory.create('insertion'), current.type.schema);
18026
+ appendComparisonRevisionUnits(nodes, change.right, 'insertion', current.type.schema, factory, withoutReviewMarks, work_document_compare_moves_appendRevisionUnits, moves?.filter((move)=>move.candidate.stepIndex === stepIndex));
17665
18027
  continue;
17666
18028
  }
17667
18029
  let formattingIdentity = null;
@@ -17669,7 +18031,7 @@ function compareInlineContent(current, revised, factory) {
17669
18031
  for(let index = 0; index < change.left.length; index += 1){
17670
18032
  const left = change.left[index];
17671
18033
  const right = change.right[index];
17672
- if (marksEqual(left.marks, right.marks)) {
18034
+ if (work_document_compare_marksEqual(left.marks, right.marks)) {
17673
18035
  nodes.push(current.type.schema.text(left.text, [
17674
18036
  ...left.marks
17675
18037
  ]));
@@ -17693,12 +18055,12 @@ function compareInlineContent(current, revised, factory) {
17693
18055
  }
17694
18056
  const deletion = factory.create('deletion');
17695
18057
  const insertion = factory.create('insertion');
17696
- appendRevisionUnits(nodes, [
18058
+ work_document_compare_moves_appendRevisionUnits(nodes, [
17697
18059
  left
17698
- ], 'deletion', deletion, current.type.schema);
17699
- appendRevisionUnits(nodes, [
18060
+ ], 'deletion', deletion, current.type.schema, withoutReviewMarks);
18061
+ work_document_compare_moves_appendRevisionUnits(nodes, [
17700
18062
  right
17701
- ], 'insertion', insertion, current.type.schema);
18063
+ ], 'insertion', insertion, current.type.schema, withoutReviewMarks);
17702
18064
  formattingIdentity = null;
17703
18065
  formattingSignature = '';
17704
18066
  }
@@ -17713,9 +18075,9 @@ function structuralChangeBlock(block, kind, factory, identityChannel) {
17713
18075
  textId: block.attrs.textId
17714
18076
  };
17715
18077
  const nodes = [];
17716
- for (const unit of inlineUnits(block) ?? [])appendRevisionUnits(nodes, [
18078
+ for (const unit of inlineUnits(block) ?? [])work_document_compare_moves_appendRevisionUnits(nodes, [
17717
18079
  unit
17718
- ], kind, identity, block.type.schema);
18080
+ ], kind, identity, block.type.schema, withoutReviewMarks);
17719
18081
  return block.type.create({
17720
18082
  ...block.attrs,
17721
18083
  ...paragraphIdentity,
@@ -17726,19 +18088,6 @@ function structuralChangeBlock(block, kind, factory, identityChannel) {
17726
18088
  blockChangeDate: identity.date
17727
18089
  }, model_Fragment.fromArray(nodes));
17728
18090
  }
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
18091
  function comparisonMark(node, kind, identity, before) {
17743
18092
  return node.type.schema.marks.documentChange.create({
17744
18093
  kind,
@@ -17822,19 +18171,35 @@ function hasStructuralBlockRevisions(document1) {
17822
18171
  });
17823
18172
  return found;
17824
18173
  }
17825
- function inlineUnits(node) {
18174
+ function inlineUnits(node, attachLeadingWhitespace = false) {
17826
18175
  if (!isSimpleTextBlock(node)) return null;
17827
18176
  const units = [];
17828
18177
  node.forEach((child)=>{
17829
18178
  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
18179
+ let pendingWhitespace = '';
18180
+ for (const part of text.match(/\s+|[\p{L}\p{N}_]+|[^\s\p{L}\p{N}_]/gu) ?? []){
18181
+ if (attachLeadingWhitespace && '' === part.trim()) {
18182
+ pendingWhitespace += part;
18183
+ continue;
18184
+ }
18185
+ units.push({
18186
+ text: attachLeadingWhitespace ? `${pendingWhitespace}${part}` : part,
18187
+ marks: child.marks,
18188
+ ...attachLeadingWhitespace ? {
18189
+ leadingWhitespaceAttached: true
18190
+ } : {}
18191
+ });
18192
+ pendingWhitespace = '';
18193
+ }
18194
+ if (attachLeadingWhitespace && pendingWhitespace) units.push({
18195
+ text: pendingWhitespace,
18196
+ marks: child.marks,
18197
+ leadingWhitespaceAttached: true
17833
18198
  });
17834
18199
  });
17835
18200
  return units;
17836
18201
  }
17837
- function marksEqual(left, right) {
18202
+ function work_document_compare_marksEqual(left, right) {
17838
18203
  const normalizedLeft = withoutReviewMarks(left);
17839
18204
  const normalizedRight = withoutReviewMarks(right);
17840
18205
  return normalizedLeft.length === normalizedRight.length && normalizedLeft.every((mark, index)=>mark.eq(normalizedRight[index]));
@@ -17920,42 +18285,6 @@ function transferCombineIdentities(current, reviewed) {
17920
18285
  if ('documentSection' === reviewed.type.name) attributes.id = current.attrs.id;
17921
18286
  return reviewed.isText ? reviewed : reviewed.type.create(attributes, model_Fragment.fromArray(content), reviewed.marks);
17922
18287
  }
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
18288
  function structuralBlockDiagnostic(block, section, blockIndex) {
17960
18289
  return 'paragraph' === block.type.name || 'heading' === block.type.name ? {
17961
18290
  code: 'empty-structural-change',
@@ -17978,14 +18307,6 @@ function unsupportedResult(diagnostic) {
17978
18307
  ]
17979
18308
  };
17980
18309
  }
17981
- function emptyComparisonSummary() {
17982
- return {
17983
- deletions: 0,
17984
- formatting: 0,
17985
- insertions: 0,
17986
- paragraphFormatting: 0
17987
- };
17988
- }
17989
18310
  function childNodes(node) {
17990
18311
  const children = [];
17991
18312
  node.forEach((child)=>{
@@ -18185,7 +18506,7 @@ function DocumentCompareDialog({ initialMode, restoreFocusTarget, onApplied, onC
18185
18506
  }
18186
18507
  }),
18187
18508
  /*#__PURE__*/ jsx("small", {
18188
- children: "该名称会显示在生成的插入、删除与格式修订中。"
18509
+ children: "该名称会显示在生成的插入、删除、移动与格式修订中。"
18189
18510
  })
18190
18511
  ]
18191
18512
  }),
@@ -18223,7 +18544,7 @@ function ComparisonBoundary({ mode }) {
18223
18544
  children: 'compare' === mode ? '确定性比较边界' : '安全合并边界'
18224
18545
  }),
18225
18546
  /*#__PURE__*/ jsx("p", {
18226
- children: 'compare' === mode ? '支持同一分节布局中的段落、标题、文字和格式差异;复杂对象或节布局变化会明确停止。' : '审阅副本必须包含修订,且拒绝全部修订后与当前文档一致;现有修订须先处理。'
18547
+ children: 'compare' === mode ? '支持同一分节布局中的段落、标题、文字、格式差异,以及同一段内可安全识别的文本移动;复杂对象或节布局变化会明确停止。' : '审阅副本必须包含修订,且拒绝全部修订后与当前文档一致;现有修订须先处理。'
18227
18548
  })
18228
18549
  ]
18229
18550
  });
@@ -18294,7 +18615,7 @@ function useDocumentComparison({ editor, onApplied }) {
18294
18615
  }
18295
18616
  };
18296
18617
  const applied = (result)=>{
18297
- const count = result.summary.insertions + result.summary.deletions + result.summary.formatting + result.summary.paragraphFormatting;
18618
+ const count = result.summary.insertions + result.summary.deletions + result.summary.formatting + result.summary.paragraphFormatting + (result.summary.moves ?? 0);
18298
18619
  close();
18299
18620
  onApplied();
18300
18621
  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,72 @@
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
+ /** Stable comparison scope (for example, the containing paragraph). */
24
+ scope?: string;
25
+ stepIndex: number;
26
+ kind: 'delete' | 'insert';
27
+ units: InlineUnit[];
28
+ start: number;
29
+ end: number;
30
+ startOffset: number;
31
+ endOffset: number;
32
+ text: string;
33
+ }
34
+ export interface InlineMovePair {
35
+ deletion: InlineMoveCandidate;
36
+ insertion: InlineMoveCandidate;
37
+ }
38
+ export interface InlineMoveComparison {
39
+ scope: string;
40
+ changes: readonly InlineDiffStep[];
41
+ }
42
+ export interface InlineMoveAssignment {
43
+ role: WorkDocumentMoveRole;
44
+ candidate: InlineMoveCandidate;
45
+ identity: WorkDocumentChangeIdentity;
46
+ }
47
+ export interface ComparisonMoveIdentityFactory {
48
+ create(kind: WorkDocumentChangeKind): WorkDocumentChangeIdentity;
49
+ }
50
+ type MarksEqual = (left: readonly ProseMirrorMark[], right: readonly ProseMirrorMark[]) => boolean;
51
+ type StripReviewMarks = (marks: readonly ProseMirrorMark[]) => ProseMirrorMark[];
52
+ type AppendRevisionUnits = (target: ProseMirrorNode[], units: readonly InlineUnit[], kind: 'insertion' | 'deletion', identity: WorkDocumentChangeIdentity, schema: Schema, stripReviewMarks: StripReviewMarks) => void;
53
+ export declare const MAX_INFERRED_MOVE_TEXT = 65536;
54
+ export declare function appendRevisionUnits(target: ProseMirrorNode[], units: readonly InlineUnit[], kind: 'insertion' | 'deletion', identity: WorkDocumentChangeIdentity, schema: Schema, stripReviewMarks: StripReviewMarks): void;
55
+ /**
56
+ * Pairs deterministic same-text ranges. A comparison may contain several
57
+ * simple blocks, which lets Compare represent a bounded move between
58
+ * paragraphs while still rejecting duplicate or mark-mismatched candidates.
59
+ */
60
+ export declare function inferInlineMovePairs(changes: readonly InlineDiffStep[], marksEqual: MarksEqual): InlineMovePair[];
61
+ /**
62
+ * Infers moves across a bounded set of simple-block diffs. Candidate scopes
63
+ * are retained on each side so callers can put the resulting marks back into
64
+ * the right paragraph without flattening the document tree.
65
+ */
66
+ export declare function inferInlineMovePairsAcrossScopes(comparisons: readonly InlineMoveComparison[], marksEqual: MarksEqual): InlineMovePair[];
67
+ /**
68
+ * Splits an ordinary diff step around paired move ranges while retaining the
69
+ * exact whitespace and mark boundaries needed to reconstruct either version.
70
+ */
71
+ export declare function appendComparisonRevisionUnits(target: ProseMirrorNode[], units: InlineUnit[], kind: 'insertion' | 'deletion', schema: Schema, factory: ComparisonMoveIdentityFactory, stripReviewMarks: StripReviewMarks, appendRevisionUnits: AppendRevisionUnits, moves?: readonly InlineMoveAssignment[]): void;
72
+ 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,12 +296,19 @@ 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
304
- reviewable when they already exist.
299
+ revisions. A bounded move pass may pair a lexical range that occurs exactly
300
+ once in a delete chunk and once in an insert chunk when its marks and carried
301
+ separators match. The pass retains a stable paragraph scope, so it can pair
302
+ aligned simple text blocks within one document section without flattening the
303
+ document tree; section boundaries, rich blocks, and structural objects remain
304
+ outside this inference. The result is one `move` identity with source and
305
+ destination roles. Stable semantic signatures seed generated identities, so the
306
+ same inputs and options produce the same ordering and IDs. Only a fully
307
+ admitted plan replaces the mounted document, and that replacement is one
308
+ transaction, one controlled publication, and one Undo record. Duplicate,
309
+ mark-mismatched, section-boundary, rich, and over-limit candidates remain
310
+ ordinary revisions or diagnostics rather than being guessed into moves; native
311
+ paired moves remain reviewable when they already exist.
305
312
 
306
313
  Combine validates rather than guesses. It rejects every imported revision on
307
314
  an immutable reviewed snapshot and compares the resulting semantic signature
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a3s-lab/office",
3
- "version": "0.49.0",
3
+ "version": "0.51.0",
4
4
  "description": "Open-source collaborative browser editors for documents, Markdown, spreadsheets, presentations, and PDFs.",
5
5
  "keywords": [
6
6
  "office",