Initial commit
This commit is contained in:
282
pythonpath/synopse/compare.py
Normal file
282
pythonpath/synopse/compare.py
Normal 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
|
||||
Reference in New Issue
Block a user