# -*- coding: utf-8 -*- """ Liest ein Writer-Dokument mit Aenderungsverfolgung und baut daraus ein Modell aus Bloecken auf. Ein Block entspricht einer Zeile der spaeteren Synopse und enthaelt * Segmente - Textstuecke samt der Redlines, in denen sie liegen * Notes - Anmerkungen, die in diesem Bereich verankert sind Grundidee: Der Text wird einmal linear durchlaufen. Waehrend des Durchlaufs wird ein Stack der gerade offenen Redlines gefuehrt. Jedes Textstueck wird zusammen mit diesem Stack gespeichert. Aus dem Stack laesst sich anschliessend jede beliebige Fassung rekonstruieren, ohne das Dokument erneut anzufassen. """ import uno def const(name, default): """UNO-Konstante aufloesen, ohne beim Import zu scheitern. getConstantByName wirft eine RuntimeException, wenn der Typmanager den Namen nicht kennt. Geschieht das beim Laden des Moduls, bricht der Python-Loader die Registrierung der Erweiterung ab. Deshalb wird hier immer auf den dokumentierten Zahlenwert zurueckgefallen. """ try: return uno.getConstantByName(name) except Exception: return default def const_opt(name): """Wie const(), liefert aber None statt eines Ersatzwertes.""" try: return uno.getConstantByName(name) except Exception: return None def enum(type_name, value): """UNO-Enum aufloesen; liefert None, wenn der Typ unbekannt ist.""" try: return uno.Enum(type_name, value) except Exception: return None RD_NAME = "com.sun.star.text.RedlineDisplayType.INSERTED_AND_REMOVED" # Ergebnis der einmaligen empirischen Bestimmung (siehe _display_all_value) _display_all_cache = None INSERT = "Insert" DELETE = "Delete" UNKNOWN_AUTHOR = "\x00unknown" # sprachneutral, Anzeige ueber i18n QUOTE_MAX = 80 # Beschreibungen, die LibreOffice selbst in RedlineComment eintraegt. Sie # sind keine Anmerkung des Bearbeiters und wuerden die Synopse zumuellen. AUTO_COMMENTS = frozenset(( "kommentar hinzugef\u00fcgt", "kommentar ge\u00e4ndert", "kommentar gel\u00f6scht", "kommentar aufgel\u00f6st", "comment added", "comment changed", "comment deleted", "comment resolved", "attribut ge\u00e4ndert", "attribute changed", "absatzformat ge\u00e4ndert", "paragraph formatting changed", )) class Segment(object): """Ein Textstueck samt der Redlines, innerhalb derer es liegt.""" __slots__ = ("text", "stack") def __init__(self, text, stack): self.text = text self.stack = stack # tuple((rtype, author), ...) @property def inserted_by(self): return [a for (t, a) in self.stack if t == INSERT] @property def deleted_by(self): return [a for (t, a) in self.stack if t == DELETE] @property def changed(self): return bool(self.stack) # -- Fassungen -------------------------------------------------------- def in_original(self): """Stand das Stueck in der Ursprungsfassung?""" return not self.inserted_by def in_final(self): """Steht das Stueck in der Endfassung (alle Aenderungen uebernommen)?""" return not self.deleted_by def in_version_of(self, author): """Fassung, in der ausschliesslich die Aenderungen von *author* gelten. - Von jemand anderem eingefuegt -> nicht enthalten - Von *author* eingefuegt -> enthalten - Von *author* geloescht -> nicht enthalten - Von jemand anderem geloescht -> bleibt stehen """ if any(a != author for a in self.inserted_by): return False if author in self.deleted_by: return False return True class Note(object): """Eine Anmerkung zu einer Stelle des Textes.""" __slots__ = ("author", "content", "quote", "kind", "resolved", "when") def __init__(self, author, content, kind="annotation"): self.author = author or UNKNOWN_AUTHOR self.content = (content or "").strip() self.quote = "" self.kind = kind # "annotation" | "redline" self.resolved = False self.when = None def short_quote(self): q = " ".join(self.quote.split()) if len(q) > QUOTE_MAX: q = q[:QUOTE_MAX - 1].rstrip() + "\u2026" return q class Block(object): """Eine Zeile der Synopse.""" __slots__ = ("segments", "notes") def __init__(self): self.segments = [] self.notes = [] @property def changed(self): return any(s.changed for s in self.segments) or bool(self.notes) def notes_of(self, author): return [n for n in self.notes if n.author == author] def _pop(stack, ident): """Entfernt die Redline mit der gegebenen Kennung vom Stack.""" for i in range(len(stack) - 1, -1, -1): if stack[i][0] == ident: del stack[i] return if stack: stack.pop() def _iter_paragraphs(text): """Absaetze eines XText, inklusive der Absaetze in Tabellenzellen.""" enum = text.createEnumeration() while enum.hasMoreElements(): el = enum.nextElement() if el.supportsService("com.sun.star.text.Paragraph"): yield el elif el.supportsService("com.sun.star.text.TextTable"): for name in el.getCellNames(): for para in _iter_paragraphs(el.getCellByName(name)): yield para def _portion_text(portion): try: return portion.getString() except Exception: return "" def _get(obj, name, default=None): try: return getattr(obj, name) except Exception: return default def _is_annotation(field): """Ist das Textfeld ein Kommentar?""" try: if field.supportsService("com.sun.star.text.textfield.Annotation"): return True except Exception: pass # Aeltere Versionen melden den Dienst nicht zuverlaessig zurueck try: return _get(field, "Content") is not None and \ _get(field, "Author") is not None except Exception: return False def _field_of(portion): """Das Feld eines Portions. Der Name unterscheidet sich je nach Version.""" for name in ("TextField", "Annotation"): field = _get(portion, name) if field is not None: return field return None def _annotation(portion): """Liefert (Note, Schluessel) zu einem Kommentar-Portion.""" field = _field_of(portion) if field is None or not _is_annotation(field): return None, None content = (_get(field, "Content", "") or "").strip() if not content: return None, None # leerer Kommentar sagt nichts aus note = Note(_get(field, "Author", ""), content, kind="annotation") note.resolved = bool(_get(field, "Resolved", False)) note.when = _get(field, "DateTimeValue") key = _get(field, "Name", "") or ("%s\x00%s" % (note.author, content)) return note, key def _display_all_value(doc): """Wert von RedlineDisplayType.INSERTED_AND_REMOVED ermitteln. Bevorzugt wird der Typmanager. Antwortet er nicht, wird der Wert gemessen statt geraten: nur in der Anzeigeart mit Einfuegungen *und* Loeschungen enthaelt der Dokumenttext beides und ist damit am laengsten. Ein geratener Wert waere gefaehrlich - die Reihenfolge der Konstanten ist nicht ueber alle Versionen hinweg gleich, und ein Griff daneben blendet stillschweigend die Einfuegungen aus. """ global _display_all_cache value = const_opt(RD_NAME) if value is not None: return value if _display_all_cache is not None: return _display_all_cache best, longest, shortest = None, -1, None for candidate in (0, 1, 2, 3): try: doc.setPropertyValue("RedlineDisplayType", candidate) length = len(doc.getText().getString()) except Exception: continue if length > longest: best, longest = candidate, length if shortest is None or length < shortest: shortest = length if best is not None and shortest is not None and longest > shortest: # Nur aussagekraeftig, wenn das Dokument ueberhaupt Aenderungen hat _display_all_cache = best return best def _show_all_changes(doc): """Eingefuegten und geloeschten Text sichtbar machen. Nur in dieser Anzeigeart liefert die Portion-Enumeration auch den geloeschten Text. Liefert den zuvor eingestellten Wert zurueck. """ try: previous = doc.getPropertyValue("RedlineDisplayType") except Exception: previous = None value = _display_all_value(doc) if value is not None: try: doc.setPropertyValue("RedlineDisplayType", value) except Exception: pass return previous def _restore_display(doc, previous, was_modified): """Anzeige und Aenderungsstatus des Quelldokuments wiederherstellen.""" if previous is not None: try: doc.setPropertyValue("RedlineDisplayType", previous) except Exception: pass if was_modified is False: try: doc.setModified(False) except Exception: pass def extract_blocks(doc): """Liefert (blocks, edit_authors, note_authors). Das Quelldokument wird dabei nicht bleibend veraendert: Anzeigeart und Aenderungsstatus werden am Ende wiederhergestellt. """ try: was_modified = bool(doc.isModified()) except Exception: was_modified = None previous_display = _show_all_changes(doc) try: return _extract(doc) finally: _restore_display(doc, previous_display, was_modified) def _extract(doc): stack = [] # [(identifier, rtype, author)] edit_authors = set() note_authors = set() seen_comments = set() # Redline-Kommentare nur einmal uebernehmen seen_notes = set() # Kommentare nur einmal uebernehmen open_notes = {} # Schluessel -> Note mit noch offenem Textbereich blocks = [] current = Block() for para in _iter_paragraphs(doc.getText()): pen = para.createEnumeration() while pen.hasMoreElements(): portion = pen.nextElement() ptype = portion.TextPortionType if ptype == "Redline": rtype = portion.RedlineType if rtype not in (INSERT, DELETE): continue # Format/Attribute: ignoriert author = portion.RedlineAuthor or UNKNOWN_AUTHOR ident = portion.RedlineIdentifier edit_authors.add(author) comment = (_get(portion, "RedlineComment", "") or "").strip() if (comment and ident not in seen_comments and comment.lower() not in AUTO_COMMENTS): seen_comments.add(ident) note = Note(author, comment, kind="redline") current.notes.append(note) note_authors.add(note.author) if bool(_get(portion, "IsCollapsed", False)): continue # Laenge 0 if bool(_get(portion, "IsStart", True)): stack.append((ident, rtype, author)) else: _pop(stack, ident) elif ptype == "Annotation": note, key = _annotation(portion) if note is not None and key not in seen_notes: seen_notes.add(key) current.notes.append(note) note_authors.add(note.author) open_notes[key] = note # Ende folgt ggf. spaeter elif ptype == "AnnotationEnd": _note, key = _annotation(portion) open_notes.pop(key, None) else: if ptype == "TextField": # Je nach Version meldet Writer einen Kommentar nicht als # "Annotation", sondern als gewoehnliches Textfeld. note, key = _annotation(portion) if note is not None: if key not in seen_notes: seen_notes.add(key) current.notes.append(note) note_authors.add(note.author) continue s = _portion_text(portion) if s: current.segments.append( Segment(s, tuple((t, a) for (_i, t, a) in stack))) for note in open_notes.values(): note.quote += s mark = tuple((t, a) for (_i, t, a) in stack) if mark: # Absatzmarke liegt innerhalb einer Aenderung -> kein Zeilenumbruch # in der Synopse, sondern ein Segment, das nur in der jeweils # passenden Fassung als Umbruch erscheint. current.segments.append(Segment("\n", mark)) else: blocks.append(current) current = Block() for note in open_notes.values(): note.quote += " " if current.segments or current.notes: blocks.append(current) return blocks, edit_authors, note_authors