Files
libreoffice-synopse-builder/pythonpath/synopse/redline_model.py
2026-09-11 12:47:00 +02:00

403 lines
13 KiB
Python

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