# -*- 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