283 lines
9.1 KiB
Python
283 lines
9.1 KiB
Python
# -*- 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
|