Initial commit

This commit is contained in:
2026-09-11 12:47:00 +02:00
parent a25c7b46f5
commit d108f863e0
24 changed files with 2498 additions and 15 deletions

View File

@@ -0,0 +1,282 @@
# -*- coding: utf-8 -*-
"""Vergleich zweier geoeffneter Writer-Dokumente zu einer klassischen Synopse.
Links die Textstellen der Quellfassung, in der Mitte die der Endfassung,
rechts wahlweise die Kommentare aus der Endfassung.
Die Zuordnung laeuft in zwei Stufen:
1. Absatzebene - difflib ueber die normalisierten Absatztexte
2. Wortebene - difflib innerhalb eines zugeordneten Absatzpaares
"""
import difflib
import re
from synopse import builder as B
from synopse.redline_model import extract_blocks
from synopse.i18n import t
COLOR_DEL = 0xA51D2D
COLOR_INS = 0x1A5FB4
TOKEN = re.compile(r"\S+\s*")
MATCH_MIN = 0.30 # ab dieser Aehnlichkeit gelten zwei Absaetze als Paar
LOOKAHEAD = 3 # Fenster fuer die Suche nach dem Gegenstueck
class CompareOptions(object):
comments = "column" # "column" | "inline" | "none"
highlight = True # Unterschiede auf Wortebene hervorheben
show_quotes = True
only_changed = False
numbering = True
landscape = True
class Unit(object):
"""Ein Absatz eines Dokuments samt der dort verankerten Kommentare."""
__slots__ = ("text", "norm", "notes")
def __init__(self, text, notes):
self.text = text.replace("\n", " ")
self.norm = " ".join(self.text.split())
self.notes = notes
# -- Dokumente ------------------------------------------------------------
def open_documents(ctx):
"""Alle geoeffneten Writer-Dokumente."""
desktop = ctx.ServiceManager.createInstanceWithContext(
"com.sun.star.frame.Desktop", ctx)
found = []
enum = desktop.getComponents().createEnumeration()
while enum.hasMoreElements():
comp = enum.nextElement()
try:
if comp.supportsService("com.sun.star.text.TextDocument"):
found.append(comp)
except Exception:
pass
return found
def document_title(doc):
try:
title = doc.getTitle()
except Exception:
title = ""
return title or t("text.unknown")
def to_units(doc):
"""Absaetze eines Dokuments in der Fassung 'alle Aenderungen uebernommen'."""
blocks, _edit, _note = extract_blocks(doc)
units = []
for block in blocks:
text = "".join(s.text for s in block.segments if s.in_final())
units.append(Unit(text, list(block.notes)))
return units
# -- Zuordnung ------------------------------------------------------------
def _ratio(left, right):
if left is None or right is None:
return 0.0
return difflib.SequenceMatcher(None, left.norm, right.norm).ratio()
def _pair_replace(left, right):
"""Absaetze eines veraenderten Abschnitts einander zuordnen."""
rows = []
li = ri = 0
while li < len(left) or ri < len(right):
if li >= len(left):
rows.append((None, right[ri]))
ri += 1
continue
if ri >= len(right):
rows.append((left[li], None))
li += 1
continue
if _ratio(left[li], right[ri]) >= MATCH_MIN:
rows.append((left[li], right[ri]))
li += 1
ri += 1
continue
# Gegenstueck im Fenster suchen: wurde eingefuegt oder geloescht?
span_r = range(ri, min(ri + LOOKAHEAD, len(right)))
span_l = range(li, min(li + LOOKAHEAD, len(left)))
best_r = max(span_r, key=lambda j: _ratio(left[li], right[j]))
best_l = max(span_l, key=lambda i: _ratio(left[i], right[ri]))
score_r = _ratio(left[li], right[best_r])
score_l = _ratio(left[best_l], right[ri])
if score_r >= MATCH_MIN and score_r >= score_l:
while ri < best_r: # rechts kamen Absaetze hinzu
rows.append((None, right[ri]))
ri += 1
elif score_l >= MATCH_MIN:
while li < best_l: # links fielen Absaetze weg
rows.append((left[li], None))
li += 1
else:
rows.append((left[li], right[ri]))
li += 1
ri += 1
return rows
def align(source, target):
"""Zeilen der Synopse als Liste von (Unit|None, Unit|None)."""
a = [u.norm for u in source]
b = [u.norm for u in target]
matcher = difflib.SequenceMatcher(None, a, b, autojunk=False)
rows = []
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
if tag == "equal":
for k in range(i2 - i1):
rows.append((source[i1 + k], target[j1 + k]))
elif tag == "delete":
for k in range(i1, i2):
rows.append((source[k], None))
elif tag == "insert":
for k in range(j1, j2):
rows.append((None, target[k]))
else:
rows.extend(_pair_replace(source[i1:i2], target[j1:j2]))
return rows
# -- Wortebene ------------------------------------------------------------
def _tokens(text):
found = TOKEN.findall(text)
return found if found else ([text] if text else [])
def word_runs(left, right, highlight):
"""Runs fuer die linke und die mittlere Spalte eines Absatzpaares."""
if left is None:
return [], [(right.text, "insert", COLOR_INS)]
if right is None:
return [(left.text, "delete", COLOR_DEL)], []
if not highlight or left.norm == right.norm:
return ([(left.text, "equal", None)], [(right.text, "equal", None)])
at, bt = _tokens(left.text), _tokens(right.text)
matcher = difflib.SequenceMatcher(
None, [t.strip() for t in at], [t.strip() for t in bt], autojunk=False)
lrun, rrun = [], []
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
if tag in ("equal", "delete", "replace"):
chunk = "".join(at[i1:i2])
if chunk:
lrun.append((chunk, "equal" if tag == "equal" else "delete",
None if tag == "equal" else COLOR_DEL))
if tag in ("equal", "insert", "replace"):
chunk = "".join(bt[j1:j2])
if chunk:
rrun.append((chunk, "equal" if tag == "equal" else "insert",
None if tag == "equal" else COLOR_INS))
return lrun, rrun
def _changed(left, right):
if left is None or right is None:
return True
if left.norm != right.norm:
return True
return bool(right.notes)
# -- Aufbau ---------------------------------------------------------------
def build(ctx, source_doc, target_doc, opts):
source = to_units(source_doc)
target = to_units(target_doc)
rows = align(source, target)
if opts.only_changed:
rows = [r for r in rows if _changed(*r)]
if not rows:
raise RuntimeError(t("msg.identical"))
has_notes = any(r[1] is not None and r[1].notes for r in rows)
note_authors = set()
for _l, r in rows:
if r is not None:
for note in r.notes:
note_authors.add(note.author)
mode = opts.comments
if not has_notes or mode == "none":
mode = "none" # rechte Spalte entfaellt
cols, weights = [], []
if opts.numbering:
cols.append((t("col.number"), "index"))
weights.append(0.4)
cols.append((B._head(document_title(source_doc), 40), "left"))
weights.append(3.0)
cols.append((B._head(document_title(target_doc), 40), "right"))
weights.append(3.0)
if mode == "column":
cols.append((t("col.comments"), "notes"))
weights.append(2.0)
doc = B._new_writer(ctx, opts.landscape)
body = doc.getText()
cur = body.createTextCursor()
try:
cur.ParaStyleName = "Heading 1"
except Exception:
pass
body.insertString(cur, t("doc.title.compare",
document_title(source_doc),
document_title(target_doc)), False)
body.insertControlCharacter(cur, B.PARAGRAPH_BREAK, False)
try:
cur.ParaStyleName = "Default Paragraph Style"
except Exception:
pass
table = doc.createInstance("com.sun.star.text.TextTable")
table.initialize(len(rows) + 1, len(cols))
body.insertTextContent(cur, table, False)
try:
table.RepeatHeadline = True
table.HeaderRowCount = 1
except Exception:
pass
for c, (label, _key) in enumerate(cols):
cell = table.getCellByName(B._cell_name(c, 0))
cell.BackColor = B.HEADER_BG
B._write_cell(cell, [("body", [(label, "equal", None)])],
note_authors, opts, bold=True)
for r, (left, right) in enumerate(rows, start=1):
lrun, rrun = word_runs(left, right, opts.highlight)
notes = list(right.notes) if right is not None else []
for c, (_label, key) in enumerate(cols):
cell = table.getCellByName(B._cell_name(c, r))
if key == "index":
items = [("body", [(str(r), "equal", None)])]
elif key == "left":
items = [("body", lrun)]
elif key == "right":
items = [("body", rrun)]
if mode == "inline":
items += [("note", n) for n in notes]
else:
items = [("note", n) for n in notes]
B._write_cell(cell, items, note_authors, opts)
B._set_widths(table, weights)
return doc