commit ca56e5a0676ad7df5f74fe79ddf3e677b4e727ba
Author: Pavel Sanda <[email protected]>
Date: Wed Aug 26 23:53:04 2026 +0200
Compare: collapse scattered within-word change runs (#6889)
Fixup compare scattered output (bug #6889).
Myers postprocessing to refine diff from mathematically minimal into human
readable. This adds first pass (IVa) over fragmented words and rewrites them
as a continuous text while leaving single-char typos in place.
The next planned pass (IVb) will be over words.
Assisted-by: Claude Fable 5
---
src/Compare.cpp | 317 +++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 316 insertions(+), 1 deletion(-)
diff --git a/src/Compare.cpp b/src/Compare.cpp
index 01c5c87469..8e84478073 100644
--- a/src/Compare.cpp
+++ b/src/Compare.cpp
@@ -51,7 +51,99 @@
* diffInset case ("very rare case" mentioned in diff_i), tracked as
#9395.
* This is now fixed by processSnake recursion on L_ses == 0 and closes
both
* problems above. I. should be now redundant and ripe for the waste
bin.
- */
+ *
+ * IV. Myers postprocessing refining the diff on non-CJK languages
+ * Problem:
+ * The previous steps create mathematically minimal but frequently
unreadable diff -
+ * it scatters tiny CT fragments across words and sentences. Example
from bug #6889:
+ * instead of {unique}[single] the results looks like
{u}[si]n{iqu}[gl]e.
+ * Approach:
+ * First refine for single chars and later we switch to words (for
performance reasons)
+ * while leaving the char-level diffs functional so small char-level
typos are easily seen.
+ *
+ * Prior art:
+ * - LibreOffice takes the opposite route - by default diffs whole words
(so no word-level
+ * scatter), but has a ByChar mode as well.
+ * - MS Word gives choice between char vs word level.
+ * - Google diff-match-patch keeps a char diff and post-processes it,
like us.
+ *
+ * IVa. Detector for "this word's character diff is the scattered-noise"
+ *
+ * * First some terminology:
+ * "Single continuous run" - at most one deleted block, then at most
one inserted block, in that order
+ * - {...} - pure deletion
+ * - [...] - pure insertion
+ * - {...}[...] - deletion immediately followed by insertion (a
substitution)
+ *
+ * - []{} does not occur (compare always emits delete first)
+ * - mixtures {}[]{}[] do not occur (compare always builds whole
subrange as deleted/inserted -
+ * mixture always occurs with snake inside (>=1 unchanged
character) eg {}[]s{}[], but
+ * then it's no more continuous run.
+ *
+ * "Unchanged interior" - a run of unchanged characters (="survivor")
that touches neither end
+ * of the word - i.e. it has changed text on
both sides.
+ *
+ * Note: our matcher is font- and language-aware
so every survivor is, by
+ * construction, font- and
language-identical between old and new.
+ * We deliberately do not use case of
differently formatted survivor
+ * as a signal for "keep"; it's rare and
complicates code.
+ *
+ * "Crumb" - an interior unchanged piece of 1-2 characters (this 3
chars threshold is the only knob
+ * in our collapse detector).
+ *
+ * * Now the heuristics:
+ * Keep char-diff for: edits are single continuous run (one consecutive
{del}[ins] run) OR
+ * "real shared text, not coincidence" - edits are
separated by substantial
+ * (>= 3 char)
unchanged stretches.
+ * Otherwise reject diff, collapse as whole word diff {old}[new].
+ *
+ * * Consequences (Lx stands for x-row of the examples table)
+ * 1. A single contiguous edit has only boundary (prefix/suffix)
survivors, so it is always kept ->
+ * typo fixes stay character-precise: (L1) correct[i]on.
+ * 2. A substantial unchanged interior is kept: (L2)
{d}[m]isprove{n}[d] keeps the real shared stem
+ * isprove even though both ends changed.
+ * 3. A crumb triggers a whole-word collapse: (L3) {beating}[boaring].
+ * 4. The decision is per-word all-or-nothing: one interior crumb
collapses the entire word,
+ * because a half-cleaned word still reads as fragments.
+ * 5. The trigger is the size of the interior unchanged survivor, not
the amount of change - so a
+ * small change with a solid survivor is kept (L2), while a small
change straddling a crumb is collapsed (L3).
+ *
+ * * Examples:
+ *
------------------------------------------------------------------------------------------------------
+ * | old -> new | raw character diff | unchanged interior
| verdict | result |
+ *
------------------------------------------------------------------------------------------------------
+ * | correcton -> correction | correct[i]on | none (all boundary)
| keep | correct[i]on |
+ *
------------------------------------------------------------------------------------------------------
+ * | disproven -> misproved | {d}[m]isprove{n}[d] | isprove (7)
| keep | {d}[m]isprove{n}[d] |
+ *
------------------------------------------------------------------------------------------------------
+ * | beating -> boaring | b{e}[o]a{t}[r]ing | a (1)
| collapse | {beating}[boaring] |
+ *
------------------------------------------------------------------------------------------------------
+ *
+ * * Prior art: cf. LibreOffice's CommonSubseq::IgnoreIsolatedPieces,
which drops isolated common runs
+ * <= nIgnoreLen (2 in char mode, 3 in word mode) and
exempts the common prefix/suffix -
+ * the same small 2-3 char line and boundary exemption as
our crumb. Unlike us it does no
+ * intra-word refinement, so a one-letter typo marks the
whole word; lyx keeps it precise.
+ *
+ * * Possible further work:
+ * We decide only on the interior crumb and do not consider the size
of boundary survivors.
+ * So clear case pre{heat}[boil] stays, but also less clear case
a{lpha}[ztec] stays, when
+ * "a" is likely just chance. We keep it now, as our main problem
really isn't detection of
+ * real edits, but rather fragmentation -- and unreadability, which
follows. a{lpha}[ztec]
+ * reads fine.
+ *
+ * If we implement check for this, we a) need new threshold to tune b)
valuable typo edits
+ * can be lost when mistuned. At the end we really need more
experience with this.
+ * ATM we try to win the case with easy case with strong signal (crumb
inside/collapsing typo)
+ * and leave the hard/more ambigous case left because the win is small
("still reads fine").
+ *
+ * Libreoffice situation: collapse to a whole-word replacement when
the two words are
+ * too dissimilar - "< X% similar -> replace the word"). Make sense,
but single typos
+ * in words are missed and replaced by whole word CT in default case,
BUT:
+ * Food for thought: when LO docs share the ancestry, their "Revision
Save ID" tags which
+ * are recorded per edit session can actually track "real" edits and
this whole game is suddenly
+ * solvable without guessing.
+ *
+ **/
#include <config.h>
@@ -63,6 +155,7 @@
#include "Changes.h"
#include "CutAndPaste.h"
#include "Font.h"
+#include "Language.h"
#include "insets/InsetText.h"
@@ -345,6 +438,10 @@ private:
/// recursively be applied to any InsetTexts that are within the snake.
void processSnake(DocRangePair const & rp);
+ /// Fix scattering of Myers' output (bug #6889). Called when the
+ /// paragraph list of a text level is complete (the end of diff/Inset).
+ void cleanupChangeRuns(ParagraphList & pars);
+
/// Writes the range to the destination buffer
void writeToDestBuffer(DocRange const & range,
Change::Type type = Change::UNCHANGED);
@@ -582,6 +679,204 @@ static bool traverseSnake(DocPair & p, DocRangePair const
& range,
}
+/////////////////////////////////////////////////////////////////////
+//
+// Cleanup of the scattered comparison output (bug #6889)
+//
+// Structure of the cleanup:
+//
+// diff() -------+
+// diffInset() --+--> cleanupChangeRuns(*dest_pars_)
+//
+// cleanupChangeRuns(pars)
+// |
+// +-- for each paragraph par:
+// |
+// +-- collapseClusters(par, ...) [IVa: within a word]
+// |
+// +-- for each cluster:
+// hasInteriorCrumb(cluster)? --yes--> rewriteAsRun(par,
cluster)
+// --no---> keep as is
+//
+//
+// collapseClusters (within a word): a changed region together with the
+// words it touches forms a cluster. If the characterwise diff inside a
+// cluster is unreadable (it contains an unchanged piece in the cluster
+// interior shorter than min_interior_survivor), the cluster is collapsed
+// into one continuous run: deleted old words followed by inserted new
+// words. A single contiguous edit within a word (e.g. a typo fix) only
+// has unchanged pieces touching the cluster boundary, so it is kept as
+// is.
+//
+// The collapse duplicates the affected unchanged characters (they appear
+// once in the deleted and once in the inserted part of the run), trading
+// mark precision for readability. Insets are never duplicated or moved:
+// clusters are split at inset positions, so a rewritten range never
+// contains an inset. Paragraph breaks are not crossed either, so all
+// rewrites are local to one paragraph.
+//
+// The whole pass is skipped for CJK-type languages without whitespace word
+// boundaries (Language::wordWrap() == false), where a single character
+// already is a word and the character diff needs no fixing.
+//
+/////////////////////////////////////////////////////////////////////
+
+/// A position range within a paragraph.
+struct PosRange {
+ pos_type begin;
+ pos_type end;
+};
+
+
+/// We need to preserve each character's font across the
+/// erase-and-reinsert that rewriteAsRun performs.
+struct CharWithFont {
+ char_type c;
+ Font font;
+};
+
+
+/// Called when detector for scattered noise triggers.
+/// scattered input: par[begin:end] = "{u}[si]n{iqu}[gl]e"
+/// clean output: par[begin:end2] = "{unique}[single]"
+/// Rewrites the range [begin, end) of par, which may hold any mixture of
unchanged,
+/// deleted and inserted positions, as one continuous run {old}[new], unchanged
+/// chars duplicated into both parts. The range must not contain insets.
+static void rewriteAsRun(Paragraph & par, pos_type begin, pos_type end,
+ BufferParams const & bparams, int author)
+{
+ // The callers guarantee an inset-free range.
+ LASSERT(!par.hasInsets(begin, end), return);
+
+ // what you get if you reject every change, "unique"
+ vector<CharWithFont> oldtext;
+ // what you get if you accept every change, "single"
+ vector<CharWithFont> newtext;
+
+ // 1. prepare clean range
+ for (pos_type pos = begin; pos < end; ++pos) {
+ CharWithFont const cf{par.getChar(pos),
+ par.getFontSettings(bparams, pos)};
+ Change::Type const type = par.lookupChange(pos).type;
+ if (type != Change::INSERTED) //unchanged + deleted
+ oldtext.push_back(cf);
+ if (type != Change::DELETED) //unchanged + inserted
+ newtext.push_back(cf);
+ }
+
+ // 2. delete scattered range
+ par.eraseChars(begin, end, false);
+
+ // 3. insert clean range
+ Change const deleted(Change::DELETED, author);
+ Change const inserted(Change::INSERTED, author);
+ pos_type pos = begin;
+ for (CharWithFont const & cf : oldtext)
+ par.insertChar(pos++, cf.c, cf.font, deleted);
+ for (CharWithFont const & cf : newtext)
+ par.insertChar(pos++, cf.c, cf.font, inserted);
+}
+
+
+/// In-word "real shared text, not coincidence" threshold.
+/// We need to collapse diff for crumb < threshold.
+static pos_type const min_interior_survivor = 3;
+
+
+/// Does the cluster [cbegin, cend) contain a crumb - an interior survivor
+/// (changed text on both sides) shorter than min_interior_survivor? A survivor
+/// touching a cluster edge is a boundary survivor (the cluster's shared
+/// prefix/suffix) and does not count.
+///
+/// Clear ones:
+/// Input: b{e}[o]a{t}[r]ing -> true (interior survivor "a", 1 char, is a
crumb)
+/// Input: {d}[m]isprove{n}[d] -> false (interior survivor "isprove", 7
chars, kept)
+/// Input: pre{heat}[boil] -> false (boundary prefix survivor "pre",
exempt)
+/// Input: {jump}[walk]ing -> false (boundary suffix survivor "ing",
exempt)
+///
+/// Less clear ones (see the "further work" in architecture description):
+/// Input: a{lpha}[ztec] -> false (boundary prefix survivor "a",
exempt)
+/// Input: {c}[b]at -> false (boundary suffix survivor "at",
exempt)
+static bool hasInteriorCrumb(Paragraph const & par,
+ pos_type cbegin, pos_type cend)
+{
+ pos_type pos = cbegin;
+ while (pos < cend) {
+ if (par.isChanged(pos)) {
+ ++pos;
+ continue;
+ }
+ pos_type const sbegin = pos; // survivor: [sbegin, pos)
+ while (pos < cend && !par.isChanged(pos))
+ ++pos;
+
+ // a boundary survivor is the real shared prefix/suffix, not a
crumb
+ bool interior_only = sbegin > cbegin && pos < cend;
+ // a short unchanged run is coincidental overlap, not
meaningful shared text
+ bool too_short = pos - sbegin < min_interior_survivor;
+ if (interior_only && too_short)
+ return true;
+ }
+ return false;
+}
+
+
+/// Collapse unreadable character changes at word boundaries.
+///
+/// A "cluster" here means the text span we decide in this pass (and may
collapse):
+/// one word, or several words when the separators between them were
+/// themselves changed. The cluster boundaries define interior for
hasInteriorCrumb
+/// (i.e. the specification's survivor with "changed text on both sides").
+///
+/// the {cat}[dog] sat -> one cluster = one word: the unchanged spaces
around
+/// {cat}[dog] delimit it, so "the" and "sat" stay out
+/// and are not clusters themselves either.
+/// {Some new f}[F]ootnote -> the whole string is ONE cluster across three
words:
+/// the deleted run swallows the spaces between them,
+/// so nothing delimits - which is why a cluster is
not
+/// just "a word".
+/// in -> out:
+/// "the {u}[si]n{iqu}[gl]e correct[i]on" -> "the {unique}[single]
correct[i]on"
+/// "{S}[F]o{me new fo}otnote" -> "{Some new footnote}[Footnote]"
+static void collapseClusters(Paragraph & par, BufferParams const & bparams,
+ int author)
+{
+ // A position takes part in a cluster if it is changed or if it is a
+ // word character; unchanged word separators (spaces, punctuation,
+ // ...) delimit the clusters. Insets always do, whatever their change
+ // status, because a rewrite cannot move them.
+ pos_type psize = par.size();
+ vector<PosRange> clusters;
+ pos_type pos = 0;
+ while (pos < psize) {
+ if (par.isInset(pos)
//insets are hard anchors
+ || (!par.isChanged(pos) && par.isWordSeparator(pos))) {
//*unedited* space/punctuation
+ ++pos;
+ // this position is gap between clusters, candidate for
cluster opens on next non-separator
+ continue;
+ }
+ pos_type cbegin = pos;
+ // for candidate to become real cluster it needs to contain CT
+ bool has_change = false;
+ while (pos < psize && !par.isInset(pos)
+ && (par.isChanged(pos) || !par.isWordSeparator(pos))) {
+ has_change |= par.isChanged(pos);
+ ++pos;
+ }
+ if (has_change)
+ clusters.push_back(PosRange{cbegin, pos});
+ }
+
+ // Go through the collected clusters and collapse the scattered ones.
+ // Rewrite changes par length, thus go from back to front so the
positions remain valid.
+ vector<PosRange>::const_reverse_iterator it = clusters.rbegin();
+ for (; it != clusters.rend(); ++it) {
+ if (hasInteriorCrumb(par, it->begin, it->end))
+ rewriteAsRun(par, it->begin, it->end, bparams, author);
+ }
+}
+
+
/////////////////////////////////////////////////////////////////////
//
// Compare::Impl
@@ -589,6 +884,22 @@ static bool traverseSnake(DocPair & p, DocRangePair const
& range,
/////////////////////////////////////////////////////////////////////
+void Compare::Impl::cleanupChangeRuns(ParagraphList & pars)
+{
+ BufferParams const & bparams = dest_buf_->params();
+
+ // CJK-type languages have no whitespace word boundaries
+ if (!bparams.language->wordWrap())
+ return;
+
+ int const author = compare_.options_.author;
+
+ ParagraphList::iterator it = pars.begin();
+ for (; it != pars.end(); ++it)
+ collapseClusters(*it, bparams, author);
+}
+
+
void Compare::Impl::furthestDpathKdiagonal(int D, int k,
DocRangePair const & rp, Direction direction)
{
@@ -777,6 +1088,8 @@ bool Compare::Impl::diff(Buffer const * new_buf, Buffer
const * old_buf,
if (!rp_new.o.empty() || !rp_new.n.empty())
diff_i(rp_new);
+ cleanupChangeRuns(*dest_pars_);
+
for (pit_type p = 0; p < (pit_type)dest_pars_->size(); ++p) {
(*dest_pars_)[p].setInsetBuffers(const_cast<Buffer
&>(*dest_buf));
(*dest_pars_)[p].setInsetOwner(&dest_buf_->inset());
@@ -871,6 +1184,8 @@ void Compare::Impl::diffInset(Inset * inset, DocPair const
& p)
diff_i(rp);
--nested_inset_level_;
+ cleanupChangeRuns(*dest_pars_);
+
dest_pars_ = backup_dest_pars;
}
--
lyx-cvs mailing list
[email protected]
https://lists.lyx.org/mailman/listinfo/lyx-cvs