diff --git a/Addons.xcu b/Addons.xcu
new file mode 100644
index 0000000..0a51a6d
--- /dev/null
+++ b/Addons.xcu
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+ Synopsis
+ Synopsis
+ Synopse
+ Synopse
+
+ _self
+
+ com.sun.star.text.TextDocument
+
+
+
+
+ de.example.synopse:run
+
+
+ Create synopsis…
+ Create synopsis…
+ Synopse erzeugen…
+ Créer une synopse…
+
+ _self
+
+ com.sun.star.text.TextDocument
+
+
+
+
+ de.example.synopse:compare
+
+
+ Compare two documents…
+ Compare two documents…
+ Zwei Dokumente vergleichen…
+ Comparer deux documents…
+
+ _self
+
+ com.sun.star.text.TextDocument
+
+
+
+
+
+
+
diff --git a/LICENSE b/LICENSE
index 68ce501..a7d966d 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,18 +1,21 @@
MIT License
-Copyright (c) 2026 marco.morath
+Copyright (c) 2026 Marco Morath
-Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
-associated documentation files (the "Software"), to deal in the Software without restriction, including
-without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
-following conditions:
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
-The above copyright notice and this permission notice shall be included in all copies or substantial
-portions of the Software.
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
-LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
-EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
-IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
-USE OR OTHER DEALINGS IN THE SOFTWARE.
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/META-INF/manifest.xml b/META-INF/manifest.xml
new file mode 100644
index 0000000..ad25c75
--- /dev/null
+++ b/META-INF/manifest.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
diff --git a/ProtocolHandler.xcu b/ProtocolHandler.xcu
new file mode 100644
index 0000000..799080b
--- /dev/null
+++ b/ProtocolHandler.xcu
@@ -0,0 +1,12 @@
+
+
+
+
+
+ de.example.synopse:*
+
+
+
+
diff --git a/README.md b/README.md
index 3cb20db..c551c50 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,227 @@
-# libreoffice-synopse-builder
+# Synopse aus Änderungsverfolgung (LibreOffice Writer)
-Erstellt eine Synopse aus zwei Writer-Dokumenten oder aus einem Dokument mit aktiver Änderungsverfolgung
\ No newline at end of file
+Erzeugt aus einem Writer-Dokument mit nachverfolgten Änderungen ein neues
+Dokument mit einer Synopse-Tabelle.
+
+* **Spalte links:** Ursprungsfassung (alle Änderungen verworfen)
+* **Rechte Seite:** wahlweise in drei Darstellungen
+
+| Modus | Aufbau | geeignet für |
+|---|---|---|
+| **kumuliert** | eine Spalte mit der Endfassung | Abnahme einer konsolidierten Fassung |
+| **gestapelt** | eine Spalte, darin die Personen untereinander, optional mit Endfassung als Abschluss | viele Beteiligte |
+| **getrennt** | je Person eine eigene Spalte, optional gefolgt von der Endfassung | zwei bis drei Beteiligte |
+
+Optional wird in beiden mehrspaltigen Modi zusätzlich die **Endfassung**
+ausgewiesen: im gestapelten Modus als letzter Eintrag jeder Zelle, bei
+getrennter Darstellung als zusätzliche Spalte ganz rechts. Sie zeigt den
+Wortlaut, der beschlossen werden soll — ungekürzt und ohne Streichungen, da
+diese in derselben Zeile ohnehin sichtbar sind. Einfügungen bleiben markiert,
+damit erkennbar ist, was gegenüber der Ursprungsfassung neu ist.
+
+Im gestapelten Modus erscheint pro Zeile nur, wer dort tatsächlich etwas
+geändert oder kommentiert hat. Jeder Eintrag beginnt mit einer dünnen
+Trennlinie und dem Namen in der Autorenfarbe. Wer an einer Stelle nur
+kommentiert hat, wird mit dem Zusatz „nur Anmerkung“ geführt und bekommt
+keine Textfassung – sie wäre mit der Ursprungsfassung identisch.
+
+## Streichungen in der Endfassung
+
+Die Endfassung zeigt keinen durchgestrichenen Text — sonst wäre sie kein
+lesbarer Beschlusstext. Damit Streichungen trotzdem erkennbar bleiben, wird
+nach Fall unterschieden:
+
+| Fall | Darstellung |
+|---|---|
+| Text unmittelbar ersetzt | nur die Neufassung, hervorgehoben |
+| Abschnitt ersatzlos gestrichen | kursiver Hinweis „ersatzlos gestrichen" |
+| Teil eines Satzes gestrichen | letztes verbliebenes Wort davor und erstes danach fett hervorgehoben |
+
+Der dritte Fall macht die Nahtstelle sichtbar, ohne den Satz zu zerreißen:
+
+ Ursprung Bei diesem Satz wird der Mittelteil, der eigentlich nicht
+ gebraucht wird, weggelassen.
+
+ Endfassung Bei diesem Satz wird der **Mittelteil weggelassen.**
+
+Die Logik steckt in `_clean_runs()`. „Unmittelbar ersetzt" heißt dabei: der
+nächste vorhandene Nachbar der Streichung ist eine Einfügung — dann trägt
+diese bereits die Markierung und es wird nichts zusätzlich hervorgehoben.
+
+## Kürzung unveränderter Passagen
+
+Damit die Zellen auch bei langen Absätzen lesbar bleiben, lassen sich
+unveränderte Passagen auf den Kontext um die Änderung kürzen (voreingestellt
+40 Zeichen je Seite, an der Wortgrenze geschnitten, gekennzeichnet mit `…`).
+Die Kürzung wirkt auf die Änderungsseite; die Spalte „Ursprungsfassung“ bleibt
+vollständig, damit der Bezugstext nachlesbar ist.
+
+ Ursprung Die Feuerwehr … hält für den Grundschutz … bereit, die
+ regelmäßig geprüft werden. rückt aus. Die Feuerwehr …
+
+ Anna … die regelmäßig geprüft werden. r̶ü̶c̶k̶t̶ fährt aus. Die …
+
+Die Fassung einer Person ist dabei jeweils diejenige, die entstünde, wenn
+ausschließlich deren Änderungen übernommen würden. Einfügungen sind
+unterstrichen und in der Autorenfarbe dargestellt, Löschungen optional
+durchgestrichen.
+
+## Anmerkungen
+
+Erklärungsbedürftige Änderungen lassen sich kommentieren. Berücksichtigt werden
+beide Quellen, die Writer dafür anbietet:
+
+* **Kommentare** (`Einfügen ▸ Kommentar`, `Strg+Alt+C`) – wahlweise an einer
+ Stelle oder über einen markierten Bereich
+* **Kommentare zu einer Änderung** (`Bearbeiten ▸ Änderungsverwaltung ▸
+ Verwalten ▸ Bearbeiten…`), also das Feld `RedlineComment` der Redline
+
+Die Anmerkung erscheint **unterhalb** des Textes in derselben Zelle, bei
+getrennter Darstellung in der Spalte der jeweiligen Person, im gestapelten
+Modus unter deren Eintrag, bei kumulierter Darstellung gesammelt in der
+Spalte „Endfassung“. Die Spalte
+„Ursprungsfassung“ bleibt frei von Anmerkungen.
+
+Optisch abgesetzt wird über vier gleichzeitig wirkende Merkmale, damit die
+Unterscheidung auch im Schwarzweißdruck trägt:
+
+| Merkmal | Wirkung |
+|---|---|
+| senkrechter Balken links | Absatzrahmen `LeftBorder` in Autorenfarbe |
+| flächige Hinterlegung | `ParaBackColor`, auf 10 % aufgehellte Autorenfarbe |
+| Einzug und Abstand | `ParaLeftMargin`, `ParaTopMargin` |
+| kleinerer Schriftgrad | 8,5 pt gegenüber 10 pt im Fließtext |
+
+Zur Abgrenzung davon nutzt die Personen-Überschrift im gestapelten Modus eine
+**waagerechte** Linie (`TopBorder`) ohne Hinterlegung – Balken links bedeutet
+also stets „Anmerkung“, Linie oben „neue Person“.
+
+Der Kopf der Anmerkung nennt in Autorenfarbe und fett den Namen, bei
+Änderungskommentaren ergänzt um „– zur Änderung“, bei erledigten Kommentaren um
+„(erledigt)“. Bei Kommentaren über einen Textbereich wird die kommentierte
+Stelle optional als kursives Zitat (gekürzt auf 80 Zeichen) vorangestellt.
+
+## Zweite Funktion: zwei Dokumente vergleichen
+
+**Synopse ▸ Zwei Dokumente vergleichen…** listet alle geöffneten
+Writer-Dokumente auf. Auszuwählen sind Quellfassung und Endfassung; das
+gerade aktive Dokument ist als Endfassung vorbelegt.
+
+| Spalte | Inhalt |
+|---|---|
+| links | Textstellen der Quellfassung, entfallene Wörter durchgestrichen (rot) |
+| Mitte | Textstellen der Endfassung, neue Wörter unterstrichen (blau) |
+| rechts | Kommentare aus der Endfassung |
+
+Die dritte Spalte entfällt automatisch, wenn die Endfassung keine Kommentare
+enthält. Alternativ lassen sich die Kommentare unter die Textstelle in der
+mittleren Spalte setzen, dann bleibt es bei zwei Spalten.
+
+Enthält eines der Dokumente selbst nachverfolgte Änderungen, wird dessen
+Fassung „alle Änderungen übernommen" verglichen.
+
+### Zuordnung
+
+Anders als bei der Änderungsverfolgung gibt es hier keine Redlines, aus denen
+sich die Zuordnung ablesen ließe – sie wird in zwei Stufen berechnet:
+
+1. **Absatzebene** – `difflib.SequenceMatcher` über die normalisierten
+ Absatztexte. Innerhalb geänderter Abschnitte werden Absätze über ihre
+ Ähnlichkeit gepaart (`MATCH_MIN = 0.30`, Suchfenster `LOOKAHEAD = 3`);
+ was darunter bleibt, gilt als entfallener bzw. neuer Absatz und bekommt
+ eine einseitige Zeile.
+2. **Wortebene** – ein zweiter Durchlauf innerhalb jedes Absatzpaares
+ markiert die abweichenden Wörter.
+
+Die beiden Schwellwerte stehen am Kopf von `compare.py`. Bei stark
+umformulierten Texten kann `MATCH_MIN` zu senken sinnvoll sein, bei stark
+wiederholenden Texten (Formulare, Tabellen) eher zu erhöhen.
+
+## Bauen und Installieren
+
+ ./build.sh
+ unopkg add --force synopse-0.6.1.oxt
+
+Danach im Writer: Menü **Synopse ▸ Synopse erzeugen…**
+
+Zum Entfernen:
+
+ unopkg remove de.example.synopse
+
+## Aufbau
+
+| Datei | Zweck |
+|---|---|
+| `synopse_handler.py` | UNO-Komponente (Protocol Handler), Einstiegspunkt |
+| `pythonpath/synopse/redline_model.py` | Extraktion von Segmenten und Anmerkungen |
+| `pythonpath/synopse/builder.py` | Aufbau des Zieldokuments (Tabelle, Formatierung) |
+| `pythonpath/synopse/compare.py` | Vergleich zweier Dokumente (difflib, zweistufig) |
+| `pythonpath/synopse/dialog.py` | Optionsdialog |
+| `Addons.xcu`, `ProtocolHandler.xcu` | Menüeintrag und Registrierung |
+
+## Modell
+
+Der Text wird einmal linear durchlaufen. Dabei wird ein Stack der gerade
+offenen Redlines geführt; jedes Textstück (`Segment`) merkt sich diesen Stack.
+Aus dem Stack lässt sich jede Fassung ableiten:
+
+| Zustand des Segments | Ursprung | Endfassung | Fassung von *X* |
+|---|---|---|---|
+| unverändert | ja | ja | ja |
+| eingefügt von X | nein | ja | ja |
+| eingefügt von Y | nein | ja | nein |
+| gelöscht von X | ja | nein | nein |
+| gelöscht von Y | ja | nein | **ja** |
+| von Y eingefügt, von X gelöscht | nein | nein | nein |
+
+Absätze werden zu einer Tabellenzeile zusammengefasst, wenn die Absatzmarke
+selbst Teil einer Änderung ist. Dadurch bleiben linke und rechte Spalte auch
+bei zusammengeführten oder geteilten Absätzen auf gleicher Höhe.
+
+## Das Quelldokument bleibt unverändert
+
+Für die Extraktion muss `RedlineDisplayType` kurzzeitig auf „Einfügungen und
+Löschungen anzeigen" stehen – nur dann liefert die Portion-Enumeration auch
+den gelöschten Text. `extract_blocks()` merkt sich deshalb den vorherigen Wert
+sowie `isModified()` und stellt beides in einem `finally`-Block wieder her.
+
+Der Zahlenwert dieser Anzeigeart wird **gemessen, nicht geraten**: Liefert der
+Typmanager die Konstante nicht, probiert `_display_all_value()` die Werte 0 bis
+3 durch und nimmt den, bei dem `getText().getString()` am längsten ist – denn
+nur die Anzeige mit Einfügungen *und* Löschungen enthält beides. Das Ergebnis
+wird für die Sitzung gemerkt.
+
+Hintergrund: Die Reihenfolge der Konstanten ist nicht über alle Versionen
+gleich. Ein geratener Wert kann `REMOVED` treffen, und dann werden
+stillschweigend alle Einfügungen ausgeblendet – samt der Kommentaranker, die
+darin liegen.
+
+## Hinweis zu UNO-Konstanten
+
+Alle UNO-Konstanten werden über `redline_model.const()` aufgelöst, das bei
+einem Fehlschlag auf den dokumentierten Zahlenwert zurückfällt. Grund: löst
+`uno.getConstantByName()` beim Import eines Moduls eine `RuntimeException`
+aus, bricht der Python-Loader die Registrierung der gesamten Erweiterung ab –
+sichtbar als „Couldn't load … for reason com.sun.star.text.RedlineDisplayType
+.INSERTED_AND_REMOVED". Zur Importzeit darf deshalb nichts stehen, das den
+Typmanager zwingend braucht. Gleiches gilt für `uno.Enum()` und
+`uno.createUnoStruct()`, die über `enum()` bzw. `_line_struct()` laufen.
+
+## Bekannte Grenzen
+
+* Reine Formatänderungen (`RedlineType == "Format"`) werden ignoriert.
+* Antworten auf Kommentare erscheinen als eigenständige Anmerkungen; die
+ Verkettung über `ParentName` wird nicht ausgewertet.
+* Personen, die ausschließlich kommentiert haben, erhalten bei getrennter
+ Darstellung eine eigene Spalte. Deren Textfassung entspricht dann der
+ Ursprungsfassung.
+* Absätze aus Tabellenzellen des Quelldokuments werden in die Zeilenfolge
+ eingereiht, die Tabellenstruktur geht dabei verloren.
+* Kopf-/Fußzeilen, Fußnoten und Rahmen werden nicht durchlaufen.
+* Beim Dokumentvergleich werden reine Formatunterschiede nicht erkannt –
+ verglichen wird der Text.
+* Verschobene Absätze erscheinen beim Dokumentvergleich als entfallen und neu,
+ nicht als Verschiebung.
+* Absatzformate (Überschriftenebene, Nummerierung) werden nicht übernommen.
+* Verschachtelte Redlines über `RedlineSuccessorData` werden nur insoweit
+ berücksichtigt, wie LibreOffice sie als überlappende Portions ausliefert.
diff --git a/build.sh b/build.sh
new file mode 100644
index 0000000..befb18f
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,47 @@
+#!/bin/sh
+# Baut das OXT-Paket. Aufruf im Projektverzeichnis: ./build.sh
+#
+# Die Versionsnummer wird aus description.xml gelesen - sie steht damit
+# an genau einer Stelle und kann beim Bauen nicht auseinanderlaufen.
+set -e
+
+cd "$(dirname "$0")"
+
+VERSION=$(sed -n 's/.*&2
+ exit 1
+fi
+
+NAME="synopse-$VERSION.oxt"
+
+# Das 256er Symbol ist nur fuer das Erweiterungsverzeichnis und die
+# Projektseite gedacht und gehoert nicht ins Paket.
+
+# Symbole erzeugen, falls sie fehlen und Pillow verfuegbar ist
+if [ ! -f icon/synopse.png ] && command -v python3 > /dev/null 2>&1; then
+ python3 tools/make_icons.py || \
+ echo "Hinweis: Symbole konnten nicht erzeugt werden." >&2
+fi
+
+for required in description.xml META-INF/manifest.xml synopse_handler.py \
+ icon/synopse.png icon/synopse_hc.png registration/license-en.txt; do
+ if [ ! -f "$required" ]; then
+ echo "Fehler: $required fehlt." >&2
+ exit 1
+ fi
+done
+
+rm -f "$NAME"
+zip -r -X "$NAME" \
+ META-INF \
+ description.xml \
+ description-de.txt description-en.txt description-fr.txt \
+ ProtocolHandler.xcu Addons.xcu \
+ synopse_handler.py pythonpath \
+ icon registration \
+ -x '*.pyc' -x '*__pycache__*' -x 'icon/synopse-256.png' > /dev/null
+
+echo "fertig: $NAME"
diff --git a/description-de.txt b/description-de.txt
new file mode 100644
index 0000000..be76599
--- /dev/null
+++ b/description-de.txt
@@ -0,0 +1,3 @@
+Erzeugt aus einem Writer-Dokument mit nachverfolgten Änderungen eine
+Synopse: links die Ursprungsfassung, rechts wahlweise die kumulierte
+Endfassung oder je bearbeitender Person eine eigene Spalte.
diff --git a/description-en.txt b/description-en.txt
new file mode 100644
index 0000000..9ff8d05
--- /dev/null
+++ b/description-en.txt
@@ -0,0 +1,3 @@
+Turns a Writer document with tracked changes into a readable synopsis: the
+original version on the left, the changes on the right — cumulative, stacked
+or one column per contributor. Also compares two open documents.
diff --git a/description-fr.txt b/description-fr.txt
new file mode 100644
index 0000000..3163410
--- /dev/null
+++ b/description-fr.txt
@@ -0,0 +1,3 @@
+Transforme un document Writer avec suivi des modifications en une synopse
+lisible : la version initiale à gauche, les modifications à droite — cumulées,
+empilées ou une colonne par participant. Compare aussi deux documents ouverts.
diff --git a/description.xml b/description.xml
new file mode 100644
index 0000000..7181ba8
--- /dev/null
+++ b/description.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+ Synopsis-Builder
+ Synopse-Ersteller
+ Générateur de synopsis
+
+
+
+ Marco Morath
+ Marco Morath
+ Marco Morath
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/icon/synopse-256.png b/icon/synopse-256.png
new file mode 100644
index 0000000..c2d1f61
Binary files /dev/null and b/icon/synopse-256.png differ
diff --git a/icon/synopse.png b/icon/synopse.png
new file mode 100644
index 0000000..fd2251f
Binary files /dev/null and b/icon/synopse.png differ
diff --git a/icon/synopse_hc.png b/icon/synopse_hc.png
new file mode 100644
index 0000000..044fb4e
Binary files /dev/null and b/icon/synopse_hc.png differ
diff --git a/pythonpath/synopse/__init__.py b/pythonpath/synopse/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/pythonpath/synopse/builder.py b/pythonpath/synopse/builder.py
new file mode 100644
index 0000000..f110891
--- /dev/null
+++ b/pythonpath/synopse/builder.py
@@ -0,0 +1,618 @@
+# -*- coding: utf-8 -*-
+"""Erzeugt aus dem Block-Modell ein neues Writer-Dokument mit Synopse-Tabelle.
+
+Drei Darstellungsarten der Aenderungsseite:
+
+ cumulative - eine Spalte mit der Endfassung
+ per_author - je Person eine eigene Spalte
+ stacked - eine Spalte, darin die Fassungen der Personen untereinander
+"""
+
+import uno
+
+from synopse.redline_model import (extract_blocks, const, enum,
+ UNKNOWN_AUTHOR)
+from synopse.i18n import t
+
+PARAGRAPH_BREAK = const("com.sun.star.text.ControlCharacter.PARAGRAPH_BREAK", 0)
+UL_SINGLE = const("com.sun.star.awt.FontUnderline.SINGLE", 1)
+UL_NONE = const("com.sun.star.awt.FontUnderline.NONE", 0)
+SO_SINGLE = const("com.sun.star.awt.FontStrikeout.SINGLE", 1)
+SO_NONE = const("com.sun.star.awt.FontStrikeout.NONE", 0)
+ITALIC = enum("com.sun.star.awt.FontSlant", "ITALIC")
+UPRIGHT = enum("com.sun.star.awt.FontSlant", "NONE")
+
+COL_ORIGINAL = "\x00original"
+COL_FINAL = "\x00final"
+COL_STACKED = "\x00stacked"
+COL_INDEX = "\x00index"
+
+AUTHOR_COLORS = [0x1A5FB4, 0xA51D2D, 0x26A269, 0x9141AC,
+ 0xC64600, 0x3D3846, 0x005F5F, 0x813D9C]
+NEUTRAL = 0x000000
+GREY = 0x707070
+FINAL_COLOR = 0x5E5C64
+HEADER_BG = 0xE0E0E0
+
+NOTE_SIZE = 8.5
+HEAD_SIZE = 9.0
+BODY_SIZE = 10.0
+
+ELLIPSIS = " \u2026 "
+
+
+class Options(object):
+ mode = "cumulative" # "cumulative" | "per_author" | "stacked"
+ show_deletions = True # geloeschten Text durchgestrichen mitfuehren
+ show_notes = True # Anmerkungen uebernehmen
+ show_quotes = True # Bezugstext kommentierter Stellen zitieren
+ elide = True # unveraenderte Passagen kuerzen
+ show_final = True # Endfassung zusaetzlich ausweisen
+ elide_context = 40 # verbleibende Zeichen je Seite
+ only_changed = False # nur geaenderte Absaetze ausgeben
+ landscape = True
+ numbering = True
+
+
+# -- Hilfsfunktionen ------------------------------------------------------
+
+def _cell_name(col, row):
+ return "%s%d" % (chr(ord("A") + col), row + 1)
+
+
+def _author_color(author, authors):
+ if isinstance(author, int):
+ return author # Vergleichsmodus: feste Farbe
+ try:
+ return AUTHOR_COLORS[sorted(authors).index(author) % len(AUTHOR_COLORS)]
+ except (ValueError, AttributeError, TypeError):
+ return NEUTRAL
+
+
+def _author_label(author):
+ """Anzeigename eines Bearbeiters."""
+ return t("text.unknown") if author == UNKNOWN_AUTHOR else author
+
+
+def _tint(color, factor=0.90):
+ """Heller Farbton derselben Farbe als Hintergrund fuer Anmerkungen."""
+ out = 0
+ for shift in (16, 8, 0):
+ channel = (color >> shift) & 0xFF
+ channel = int(channel + (255 - channel) * factor)
+ out |= min(channel, 255) << shift
+ return out
+
+
+def _line_struct():
+ for name in ("com.sun.star.table.BorderLine2",
+ "com.sun.star.table.BorderLine"):
+ try:
+ return uno.createUnoStruct(name)
+ except Exception:
+ continue
+ return None
+
+
+def _border(color, width=50):
+ line = _line_struct()
+ if line is None:
+ return None
+ line.Color = color
+ line.LineWidth = width # 1/100 mm
+ line.OuterLineWidth = width
+ line.InnerLineWidth = 0
+ line.LineDistance = 0
+ line.LineStyle = 0 # SOLID
+ return line
+
+
+def _no_border():
+ line = _line_struct()
+ if line is None:
+ return None
+ line.LineWidth = 0
+ line.OuterLineWidth = 0
+ try:
+ line.LineStyle = 5 # NONE
+ except Exception:
+ pass
+ return line
+
+
+# -- Spalteninhalt --------------------------------------------------------
+
+def _split_first_word(text):
+ """(erstes Wort mit fuehrendem Leerraum, Rest)."""
+ lead = len(text) - len(text.lstrip())
+ head, rest = text[:lead], text[lead:]
+ cut = rest.find(" ")
+ if cut < 0:
+ return head + rest, ""
+ return head + rest[:cut], rest[cut:]
+
+
+def _split_last_word(text):
+ """(Rest, letztes Wort mit nachfolgendem Leerraum)."""
+ body = text.rstrip()
+ trail = text[len(body):]
+ cut = body.rfind(" ")
+ if cut < 0:
+ return "", body + trail
+ return body[:cut + 1], body[cut + 1:] + trail
+
+
+def _neighbour(seq, index, step):
+ """Naechster tatsaechlich vorhandener Nachbar mit Textinhalt."""
+ j = index + step
+ while 0 <= j < len(seq):
+ entry = seq[j]
+ if entry["keep"] and entry["text"].strip():
+ return j
+ j += step
+ return None
+
+
+def _clean_runs(block, key, opts):
+ """Fassung ohne durchgestrichenen Text, aber mit lesbarer Streichung.
+
+ - unmittelbar ersetzter Text: nur die Neufassung wird hervorgehoben
+ - ersatzlos gestrichener Abschnitt: Hinweis statt leerer Zelle
+ - Streichung innerhalb eines Satzes: das letzte verbliebene Wort davor
+ und das erste danach werden hervorgehoben, damit die Nahtstelle
+ erkennbar bleibt
+ """
+ seq = []
+ for seg in block.segments:
+ inserted = bool(seg.inserted_by)
+ if key == COL_FINAL:
+ keep = seg.in_final()
+ author = (seg.inserted_by[0] if inserted else
+ (seg.deleted_by[0] if seg.deleted_by else None))
+ removed = not keep and seg.in_original()
+ else:
+ keep = seg.in_version_of(key)
+ author = key
+ removed = not keep and seg.in_original() and key in seg.deleted_by
+ seq.append({"keep": keep, "removed": removed, "text": seg.text,
+ "kind": "insert" if (keep and inserted) else "equal",
+ "author": author})
+
+ dropped = [i for i, e in enumerate(seq) if e["removed"]]
+ if not dropped:
+ return [(e["text"], e["kind"], e["author"]) for e in seq if e["keep"]]
+
+ if not "".join(e["text"] for e in seq if e["keep"]).strip():
+ return [(t("text.removed"), "removed", seq[dropped[0]]["author"])]
+
+ gap_before, gap_after = {}, {}
+ for i in dropped:
+ prev_i = _neighbour(seq, i, -1)
+ next_i = _neighbour(seq, i, 1)
+ replaced = ((prev_i is not None and seq[prev_i]["kind"] == "insert")
+ or (next_i is not None and seq[next_i]["kind"] == "insert"))
+ if replaced:
+ continue # die Neufassung traegt die Markierung
+ author = seq[i]["author"]
+ if prev_i is not None and seq[prev_i]["kind"] == "equal":
+ gap_before[prev_i] = author
+ if next_i is not None and seq[next_i]["kind"] == "equal":
+ gap_after[next_i] = author
+
+ runs = []
+ for i, entry in enumerate(seq):
+ if not entry["keep"]:
+ continue
+ if entry["kind"] != "equal":
+ runs.append((entry["text"], entry["kind"], entry["author"]))
+ continue
+ after, before = gap_after.get(i), gap_before.get(i)
+ first, rest = (_split_first_word(entry["text"])
+ if after is not None else ("", entry["text"]))
+ head, last = (_split_last_word(rest)
+ if before is not None else (rest, ""))
+ if first:
+ runs.append((first, "gap", after))
+ if head:
+ runs.append((head, "equal", None))
+ if last:
+ runs.append((last, "gap", before))
+ return runs
+
+
+def _runs(block, key, opts, show_deletions=None):
+ """Liste von (text, kind, author). kind: equal | insert | delete.
+
+ *show_deletions* uebersteuert die Option; damit laesst sich die
+ Endfassung als reiner Beschlusstext ohne Streichungen ausgeben.
+ """
+ if show_deletions is None:
+ show_deletions = opts.show_deletions
+ if key != COL_ORIGINAL and not show_deletions:
+ return _clean_runs(block, key, opts)
+ runs = []
+ for seg in block.segments:
+ if key == COL_ORIGINAL:
+ if seg.in_original():
+ runs.append((seg.text, "equal", None))
+ continue
+
+ if key == COL_FINAL:
+ keep = seg.in_final()
+ ins, dele = seg.inserted_by, seg.deleted_by
+ author = ins[0] if ins else (dele[0] if dele else None)
+ show_deleted = seg.in_original()
+ else:
+ keep = seg.in_version_of(key)
+ author = key
+ show_deleted = seg.in_original() and key in seg.deleted_by
+
+ if keep:
+ runs.append((seg.text, "insert" if seg.inserted_by else "equal", author))
+ elif show_deletions and show_deleted:
+ runs.append((seg.text, "delete", author))
+ return runs
+
+
+def _merge(runs):
+ """Benachbarte Laeufe gleicher Formatierung zusammenfassen."""
+ out = []
+ for text, kind, author in runs:
+ if out and out[-1][1] == kind and out[-1][2] == author:
+ out[-1][0] += text
+ else:
+ out.append([text, kind, author])
+ return [tuple(r) for r in out]
+
+
+def _head(text, n):
+ cut = text[:n]
+ space = cut.rfind(" ")
+ return (cut[:space] if space > n // 2 else cut).rstrip()
+
+
+def _tail(text, n):
+ cut = text[-n:]
+ space = cut.find(" ")
+ return (cut[space + 1:] if 0 <= space < n // 2 else cut).lstrip()
+
+
+def _elide(runs, context):
+ """Lange unveraenderte Passagen auf den Kontext um die Aenderungen kuerzen."""
+ runs = _merge(runs)
+ if not any(k != "equal" for _t, k, _a in runs):
+ return runs # nichts geaendert: nichts kuerzen
+ out = []
+ for i, (text, kind, author) in enumerate(runs):
+ if kind != "equal" or len(text) <= 2 * context + len(ELLIPSIS):
+ out.append((text, kind, author))
+ continue
+ before = any(k != "equal" for _t, k, _a in runs[:i])
+ after = any(k != "equal" for _t, k, _a in runs[i + 1:])
+ if before and after:
+ new = _head(text, context) + ELLIPSIS + _tail(text, context)
+ elif after:
+ new = ELLIPSIS.lstrip() + _tail(text, context)
+ elif before:
+ new = _head(text, context) + ELLIPSIS.rstrip()
+ else:
+ new = text
+ out.append((new, kind, author))
+ return out
+
+
+def _active_authors(block, opts):
+ """Personen, die in diesem Block ueberhaupt in Erscheinung treten."""
+ found = set()
+ for seg in block.segments:
+ for _rtype, author in seg.stack:
+ found.add(author)
+ if opts.show_notes:
+ for note in block.notes:
+ found.add(note.author)
+ return found
+
+
+def _body(block, key, opts, elide=False):
+ runs = _runs(block, key, opts)
+ if elide and opts.elide:
+ runs = _elide(runs, opts.elide_context)
+ return runs
+
+
+def _cell_items(block, key, opts, authors):
+ """Inhalt einer Zelle als Folge von ('body'|'heading'|'note', payload)."""
+ if key == COL_ORIGINAL:
+ return [("body", _runs(block, key, opts))]
+
+ if key == COL_FINAL:
+ # Die Endfassung wird ungekuerzt gezeigt: sie ist der Wortlaut,
+ # der beschlossen werden soll. Streichungen entfallen dabei, sobald
+ # sie an anderer Stelle der Zeile ohnehin sichtbar sind.
+ clean = None if opts.mode == "cumulative" else False
+ items = [("body", _runs(block, key, opts, show_deletions=clean))]
+ if opts.show_notes and opts.mode == "cumulative":
+ # In den anderen Modi stehen die Anmerkungen bereits bei der
+ # jeweiligen Person und wuerden sich hier wiederholen.
+ items += [("note", n) for n in block.notes]
+ return items
+
+ if key == COL_STACKED:
+ editors = set()
+ for seg in block.segments:
+ for _rtype, author in seg.stack:
+ editors.add(author)
+ items = []
+ for author in sorted(_active_authors(block, opts)):
+ note_only = author not in editors
+ color = _author_color(author, authors)
+ suffix = t("text.noteonly") if note_only else ""
+ items.append(("heading", (_author_label(author), suffix, color)))
+ if not note_only:
+ # Wer hier nur kommentiert hat, bekommt keine Textfassung:
+ # sie waere mit der Ursprungsfassung identisch.
+ items.append(("body", _body(block, author, opts, elide=True)))
+ if opts.show_notes:
+ items += [("note", n) for n in block.notes_of(author)]
+ if items and opts.show_final:
+ items.append(("heading", (t("col.final"), "", FINAL_COLOR)))
+ items.append(("body", _runs(block, COL_FINAL, opts,
+ show_deletions=False)))
+ return items
+
+ # eigene Spalte je Person
+ items = [("body", _body(block, key, opts, elide=True))]
+ if opts.show_notes:
+ items += [("note", n) for n in block.notes_of(key)]
+ return items
+
+
+# -- Schreiben ------------------------------------------------------------
+
+def _set_border(cur, name, line, distance=0):
+ """Absatzrahmen setzen; ohne verfuegbare Struktur wird er ausgelassen."""
+ if line is None:
+ return
+ try:
+ setattr(cur, name, line)
+ setattr(cur, name + "Distance", distance)
+ except Exception:
+ pass
+
+
+def _reset_para(cur):
+ cur.ParaBackColor = -1
+ cur.ParaLeftMargin = 0
+ cur.ParaRightMargin = 0
+ cur.ParaTopMargin = 0
+ cur.ParaBottomMargin = 0
+ _set_border(cur, "LeftBorder", _no_border())
+ _set_border(cur, "TopBorder", _no_border())
+
+
+def _heading_para(cur, color, first):
+ _reset_para(cur)
+ cur.ParaTopMargin = 0 if first else 220
+ cur.ParaBottomMargin = 40
+ if not first:
+ _set_border(cur, "TopBorder", _border(color, 20), 90)
+
+
+def _note_para(cur, color):
+ _reset_para(cur)
+ cur.ParaBackColor = _tint(color)
+ cur.ParaLeftMargin = 200
+ cur.ParaRightMargin = 100
+ cur.ParaTopMargin = 150
+ cur.ParaBottomMargin = 60
+ _set_border(cur, "LeftBorder", _border(color), 120)
+
+
+def _char(cur, color=NEUTRAL, size=BODY_SIZE, weight=100.0,
+ posture=UPRIGHT, underline=UL_NONE, strikeout=SO_NONE):
+ cur.CharColor = color
+ cur.CharHeight = size
+ cur.CharWeight = weight
+ if posture is not None:
+ cur.CharPosture = posture
+ cur.CharUnderline = underline
+ cur.CharStrikeout = strikeout
+
+
+def _insert(text, cur, value):
+ for i, part in enumerate(value.split("\n")):
+ if i:
+ text.insertControlCharacter(cur, PARAGRAPH_BREAK, False)
+ if part:
+ text.insertString(cur, part, False)
+
+
+def _write_cell(cell, items, authors, opts, bold=False):
+ text = cell.getText()
+ text.setString("")
+ cur = text.createTextCursor()
+ _reset_para(cur)
+ wrote = False
+
+ for kind, payload in items:
+ if kind == "body":
+ runs = _merge(payload)
+ if not runs:
+ continue
+ cur.gotoEnd(False)
+ if wrote:
+ text.insertControlCharacter(cur, PARAGRAPH_BREAK, False)
+ _reset_para(cur)
+ for value, rkind, author in runs:
+ color = _author_color(author, authors) if author else NEUTRAL
+ weight = 150.0 if bold else 100.0
+ if rkind == "insert":
+ _char(cur, color, BODY_SIZE, weight, underline=UL_SINGLE)
+ elif rkind == "delete":
+ _char(cur, color, BODY_SIZE, weight, strikeout=SO_SINGLE)
+ elif rkind == "gap":
+ # Nahtstelle einer Streichung: fett in der Autorenfarbe
+ _char(cur, color, BODY_SIZE, 150.0)
+ elif rkind == "removed":
+ _char(cur, color, BODY_SIZE, weight, posture=ITALIC)
+ else:
+ _char(cur, NEUTRAL, BODY_SIZE, weight)
+ _insert(text, cur, value)
+ wrote = True
+
+ elif kind == "heading":
+ label, suffix, color = payload
+ cur.gotoEnd(False)
+ if wrote:
+ text.insertControlCharacter(cur, PARAGRAPH_BREAK, False)
+ _heading_para(cur, color, not wrote)
+ _char(cur, color, HEAD_SIZE, 150.0)
+ _insert(text, cur, label)
+ if suffix:
+ _char(cur, GREY, HEAD_SIZE, 100.0, posture=ITALIC)
+ _insert(text, cur, suffix)
+ wrote = True
+
+ elif kind == "note":
+ note = payload
+ color = _author_color(note.author, authors)
+ cur.gotoEnd(False)
+ if wrote:
+ text.insertControlCharacter(cur, PARAGRAPH_BREAK, False)
+ _note_para(cur, color)
+
+ label = _author_label(note.author)
+ if note.resolved:
+ label += t("text.resolved")
+ _char(cur, color, NOTE_SIZE, 150.0)
+ _insert(text, cur, label + ": ")
+
+ quote = note.short_quote() if opts.show_quotes else ""
+ if quote:
+ _char(cur, GREY, NOTE_SIZE, 100.0, posture=ITALIC)
+ _insert(text, cur, "\u201e%s\u201c \u2013 " % quote)
+
+ _char(cur, GREY if note.resolved else NEUTRAL, NOTE_SIZE, 100.0)
+ _insert(text, cur, note.content or "(ohne Text)")
+ wrote = True
+
+
+def _set_widths(table, weights):
+ seps = table.TableColumnSeparators
+ if not seps:
+ return
+ total = float(sum(weights))
+ pos, new = 0.0, []
+ for i, sep in enumerate(seps):
+ if i >= len(weights) - 1:
+ break
+ pos += weights[i] / total * 10000.0
+ sep.Position = int(pos)
+ new.append(sep)
+ if new:
+ table.TableColumnSeparators = tuple(new)
+
+
+def _new_writer(ctx, landscape):
+ desktop = ctx.ServiceManager.createInstanceWithContext(
+ "com.sun.star.frame.Desktop", ctx)
+ doc = desktop.loadComponentFromURL("private:factory/swriter", "_blank", 0, ())
+ if landscape:
+ style = doc.StyleFamilies.getByName("PageStyles").getByName("Standard")
+ if not style.IsLandscape:
+ w, h = style.Width, style.Height
+ style.IsLandscape = True
+ style.Width, style.Height = h, w
+ for name in ("LeftMargin", "RightMargin"):
+ try:
+ setattr(style, name, 1000)
+ except Exception:
+ pass
+ return doc
+
+
+# -- Aufbau ---------------------------------------------------------------
+
+def _columns(opts, authors):
+ cols, weights = [], []
+ if opts.numbering:
+ cols.append((t("col.number"), COL_INDEX))
+ weights.append(0.4)
+ cols.append((t("col.original"), COL_ORIGINAL))
+ weights.append(3.0)
+
+ if opts.mode == "cumulative":
+ cols.append((t("col.final"), COL_FINAL))
+ weights.append(3.0)
+ elif opts.mode == "stacked":
+ cols.append((t("col.changes"), COL_STACKED))
+ weights.append(4.0)
+ else:
+ for author in sorted(authors):
+ cols.append((_author_label(author), author))
+ weights.append(3.0)
+ if opts.show_final:
+ cols.append((t("col.final"), COL_FINAL))
+ weights.append(3.0)
+ return cols, weights
+
+
+def build(ctx, source_doc, opts):
+ blocks, edit_authors, note_authors = extract_blocks(source_doc)
+ authors = set(edit_authors)
+ if opts.show_notes:
+ authors |= note_authors
+ if not authors:
+ raise RuntimeError(t("msg.nochanges"))
+
+ if opts.only_changed or opts.mode == "stacked":
+ # Im gestapelten Modus bleiben unveraenderte Absaetze ohnehin leer.
+ blocks = [b for b in blocks if b.changed] if opts.only_changed else blocks
+ if not blocks:
+ raise RuntimeError(t("msg.noparagraphs"))
+
+ cols, weights = _columns(opts, authors)
+
+ doc = _new_writer(ctx, opts.landscape)
+ body = doc.getText()
+ cur = body.createTextCursor()
+ try:
+ cur.ParaStyleName = "Heading 1"
+ except Exception:
+ pass
+ name = getattr(source_doc, "Title", "") or ""
+ body.insertString(cur, t("doc.title.named", name) if name
+ else t("doc.title"), False)
+ body.insertControlCharacter(cur, PARAGRAPH_BREAK, False)
+ try:
+ cur.ParaStyleName = "Default Paragraph Style"
+ except Exception:
+ pass
+
+ table = doc.createInstance("com.sun.star.text.TextTable")
+ table.initialize(len(blocks) + 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(_cell_name(c, 0))
+ cell.BackColor = HEADER_BG
+ _write_cell(cell, [("body", [(label, "equal", None)])],
+ authors, opts, bold=True)
+
+ for r, block in enumerate(blocks, start=1):
+ for c, (_label, key) in enumerate(cols):
+ cell = table.getCellByName(_cell_name(c, r))
+ if key == COL_INDEX:
+ _write_cell(cell, [("body", [(str(r), "equal", None)])],
+ authors, opts)
+ else:
+ _write_cell(cell, _cell_items(block, key, opts, authors),
+ authors, opts)
+
+ _set_widths(table, weights)
+ return doc
diff --git a/pythonpath/synopse/compare.py b/pythonpath/synopse/compare.py
new file mode 100644
index 0000000..69457ec
--- /dev/null
+++ b/pythonpath/synopse/compare.py
@@ -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
diff --git a/pythonpath/synopse/dialog.py b/pythonpath/synopse/dialog.py
new file mode 100644
index 0000000..8822c33
--- /dev/null
+++ b/pythonpath/synopse/dialog.py
@@ -0,0 +1,241 @@
+# -*- coding: utf-8 -*-
+"""Optionsdialoge, programmatisch ueber com.sun.star.awt aufgebaut."""
+
+import sys
+
+import uno
+
+from synopse.builder import Options
+from synopse.i18n import t
+
+
+def message(ctx, text, title=None):
+ if title is None:
+ title = t("doc.title")
+ try:
+ toolkit = ctx.ServiceManager.createInstanceWithContext(
+ "com.sun.star.awt.Toolkit", ctx)
+ box = toolkit.createMessageBox(
+ None,
+ uno.Enum("com.sun.star.awt.MessageBoxType", "INFOBOX"),
+ 1, # BUTTONS_OK
+ title, text)
+ box.execute()
+ box.dispose()
+ except Exception:
+ # Ohne Dialogfenster bleibt nur die Konsole
+ sys.stderr.write("%s: %s\n" % (title, text))
+
+
+def _add(model, kind, name, props):
+ ctrl = model.createInstance("com.sun.star.awt.UnoControl%sModel" % kind)
+ for key, value in props.items():
+ setattr(ctrl, key, value)
+ model.insertByName(name, ctrl)
+ return ctrl
+
+
+def _dialog(ctx, model):
+ dlg = ctx.ServiceManager.createInstanceWithContext(
+ "com.sun.star.awt.UnoControlDialog", ctx)
+ dlg.setModel(model)
+ toolkit = ctx.ServiceManager.createInstanceWithContext(
+ "com.sun.star.awt.Toolkit", ctx)
+ dlg.createPeer(toolkit, None)
+ return dlg
+
+
+def _buttons(model, y):
+ _add(model, "Button", "btnOk",
+ dict(PositionX=128, PositionY=y, Width=54, Height=16,
+ Label=t("dlg.ok"), PushButtonType=1, DefaultButton=True))
+ _add(model, "Button", "btnCancel",
+ dict(PositionX=186, PositionY=y, Width=54, Height=16,
+ Label=t("dlg.cancel"), PushButtonType=2))
+
+
+def show_options(ctx, edit_authors, note_authors):
+ """Optionen fuer die Synopse aus der Aenderungsverfolgung."""
+ people = sorted(edit_authors | note_authors)
+ count = len(people)
+ names = ", ".join(people) or t("dlg.people.none")
+ if len(names) > 52:
+ names = names[:49] + "\u2026"
+
+ model = ctx.ServiceManager.createInstanceWithContext(
+ "com.sun.star.awt.UnoControlDialogModel", ctx)
+ model.setPropertyValues(
+ ("PositionX", "PositionY", "Width", "Height", "Title"),
+ (120, 120, 248, 264, t("dlg.title")))
+
+ _add(model, "FixedText", "lblPeople",
+ dict(PositionX=8, PositionY=6, Width=232, Height=10,
+ Label=t("dlg.people", count, names)))
+ _add(model, "FixedLine", "sep0",
+ dict(PositionX=8, PositionY=18, Width=232, Height=6,
+ Label=t("dlg.group.mode")))
+
+ _add(model, "RadioButton", "optCum",
+ dict(PositionX=14, PositionY=30, Width=226, Height=12, State=1,
+ Label=t("dlg.mode.cumulative")))
+ _add(model, "RadioButton", "optStack",
+ dict(PositionX=14, PositionY=43, Width=226, Height=12,
+ Label=t("dlg.mode.stacked")))
+ _add(model, "RadioButton", "optAuthor",
+ dict(PositionX=14, PositionY=56, Width=226, Height=12,
+ Label=t("dlg.mode.separate", count)))
+ _add(model, "FixedText", "hint",
+ dict(PositionX=26, PositionY=69, Width=214, Height=18, MultiLine=True,
+ Label=t("dlg.mode.hint")))
+
+ _add(model, "FixedLine", "sep1",
+ dict(PositionX=8, PositionY=92, Width=232, Height=6,
+ Label=t("dlg.group.content")))
+ _add(model, "CheckBox", "chkDel",
+ dict(PositionX=8, PositionY=104, Width=232, Height=12, State=1,
+ Label=t("dlg.opt.deletions")))
+ _add(model, "CheckBox", "chkElide",
+ dict(PositionX=8, PositionY=118, Width=232, Height=12, State=1,
+ Label=t("dlg.opt.elide")))
+ _add(model, "CheckBox", "chkFinal",
+ dict(PositionX=8, PositionY=132, Width=232, Height=12, State=1,
+ Label=t("dlg.opt.final")))
+ _add(model, "CheckBox", "chkNotes",
+ dict(PositionX=8, PositionY=146, Width=232, Height=12, State=1,
+ Label=t("dlg.opt.notes")))
+ _add(model, "CheckBox", "chkQuote",
+ dict(PositionX=20, PositionY=160, Width=220, Height=12, State=1,
+ Label=t("dlg.opt.quote")))
+ _add(model, "CheckBox", "chkOnly",
+ dict(PositionX=8, PositionY=178, Width=232, Height=12, State=0,
+ Label=t("dlg.opt.onlychanged")))
+
+ _add(model, "FixedLine", "sep2",
+ dict(PositionX=8, PositionY=194, Width=232, Height=6,
+ Label=t("dlg.group.target")))
+ _add(model, "CheckBox", "chkNum",
+ dict(PositionX=8, PositionY=206, Width=110, Height=12, State=1,
+ Label=t("dlg.opt.numbering")))
+ _add(model, "CheckBox", "chkLand",
+ dict(PositionX=124, PositionY=206, Width=116, Height=12, State=1,
+ Label=t("dlg.opt.landscape")))
+
+ _buttons(model, 238)
+
+ dlg = _dialog(ctx, model)
+ try:
+ if dlg.execute() != 1:
+ return None
+ opts = Options()
+ if model.getByName("optAuthor").State:
+ opts.mode = "per_author"
+ elif model.getByName("optStack").State:
+ opts.mode = "stacked"
+ else:
+ opts.mode = "cumulative"
+ opts.show_deletions = bool(model.getByName("chkDel").State)
+ opts.elide = bool(model.getByName("chkElide").State)
+ opts.show_final = bool(model.getByName("chkFinal").State)
+ opts.show_notes = bool(model.getByName("chkNotes").State)
+ opts.show_quotes = bool(model.getByName("chkQuote").State)
+ opts.only_changed = bool(model.getByName("chkOnly").State)
+ opts.numbering = bool(model.getByName("chkNum").State)
+ opts.landscape = bool(model.getByName("chkLand").State)
+ return opts
+ finally:
+ dlg.dispose()
+
+
+def show_compare_options(ctx, docs, current=None):
+ """Auswahl der beiden Dokumente. Liefert (quelle, ziel, options)."""
+ from synopse.compare import CompareOptions, document_title
+
+ titles = [document_title(d) for d in docs]
+
+ # Vorauswahl: aktuelles Dokument als Endfassung, das andere als Quelle
+ target_idx = docs.index(current) if current in docs else len(docs) - 1
+ source_idx = 0 if target_idx != 0 else 1
+
+ model = ctx.ServiceManager.createInstanceWithContext(
+ "com.sun.star.awt.UnoControlDialogModel", ctx)
+ model.setPropertyValues(
+ ("PositionX", "PositionY", "Width", "Height", "Title"),
+ (120, 120, 248, 258, t("cmp.title")))
+
+ _add(model, "FixedLine", "sep0",
+ dict(PositionX=8, PositionY=6, Width=232, Height=6,
+ Label=t("cmp.group.docs")))
+ _add(model, "FixedText", "lblSrc",
+ dict(PositionX=8, PositionY=18, Width=232, Height=10,
+ Label=t("cmp.source")))
+ _add(model, "ListBox", "lstSrc",
+ dict(PositionX=8, PositionY=29, Width=232, Height=12, Dropdown=True,
+ StringItemList=tuple(titles), SelectedItems=(source_idx,)))
+ _add(model, "FixedText", "lblTgt",
+ dict(PositionX=8, PositionY=46, Width=232, Height=10,
+ Label=t("cmp.target")))
+ _add(model, "ListBox", "lstTgt",
+ dict(PositionX=8, PositionY=57, Width=232, Height=12, Dropdown=True,
+ StringItemList=tuple(titles), SelectedItems=(target_idx,)))
+
+ _add(model, "FixedLine", "sep1",
+ dict(PositionX=8, PositionY=76, Width=232, Height=6,
+ Label=t("cmp.group.comments")))
+ _add(model, "RadioButton", "optCol",
+ dict(PositionX=14, PositionY=88, Width=226, Height=12, State=1,
+ Label=t("cmp.comments.column")))
+ _add(model, "RadioButton", "optInline",
+ dict(PositionX=14, PositionY=101, Width=226, Height=12,
+ Label=t("cmp.comments.inline")))
+ _add(model, "RadioButton", "optNone",
+ dict(PositionX=14, PositionY=114, Width=226, Height=12,
+ Label=t("cmp.comments.none")))
+ _add(model, "FixedText", "hint",
+ dict(PositionX=26, PositionY=127, Width=214, Height=18, MultiLine=True,
+ Label=t("cmp.comments.hint")))
+
+ _add(model, "FixedLine", "sep2",
+ dict(PositionX=8, PositionY=150, Width=232, Height=6,
+ Label=t("cmp.group.display")))
+ _add(model, "CheckBox", "chkHi",
+ dict(PositionX=8, PositionY=162, Width=232, Height=12, State=1,
+ Label=t("cmp.opt.highlight")))
+ _add(model, "CheckBox", "chkQuote",
+ dict(PositionX=8, PositionY=176, Width=232, Height=12, State=1,
+ Label=t("cmp.opt.quote")))
+ _add(model, "CheckBox", "chkOnly",
+ dict(PositionX=8, PositionY=190, Width=232, Height=12, State=0,
+ Label=t("cmp.opt.onlychanged")))
+ _add(model, "CheckBox", "chkNum",
+ dict(PositionX=8, PositionY=204, Width=110, Height=12, State=1,
+ Label=t("dlg.opt.numbering")))
+ _add(model, "CheckBox", "chkLand",
+ dict(PositionX=124, PositionY=204, Width=116, Height=12, State=1,
+ Label=t("dlg.opt.landscape")))
+
+ _buttons(model, 232)
+
+ dlg = _dialog(ctx, model)
+ try:
+ if dlg.execute() != 1:
+ return None
+ src = model.getByName("lstSrc").SelectedItems
+ tgt = model.getByName("lstTgt").SelectedItems
+ if not src or not tgt or src[0] == tgt[0]:
+ message(ctx, t("msg.different"))
+ return None
+ opts = CompareOptions()
+ if model.getByName("optInline").State:
+ opts.comments = "inline"
+ elif model.getByName("optNone").State:
+ opts.comments = "none"
+ else:
+ opts.comments = "column"
+ opts.highlight = bool(model.getByName("chkHi").State)
+ opts.show_quotes = bool(model.getByName("chkQuote").State)
+ opts.only_changed = bool(model.getByName("chkOnly").State)
+ opts.numbering = bool(model.getByName("chkNum").State)
+ opts.landscape = bool(model.getByName("chkLand").State)
+ return docs[src[0]], docs[tgt[0]], opts
+ finally:
+ dlg.dispose()
diff --git a/pythonpath/synopse/i18n.py b/pythonpath/synopse/i18n.py
new file mode 100644
index 0000000..6ce7a0d
--- /dev/null
+++ b/pythonpath/synopse/i18n.py
@@ -0,0 +1,256 @@
+# -*- coding: utf-8 -*-
+"""Oberflaechentexte in Deutsch, Englisch und Franzoesisch.
+
+Die Sprache richtet sich nach der Oberflaechensprache von LibreOffice
+(/org.openoffice.Setup/L10N/ooLocale). Ist sie nicht hinterlegt, wird
+Englisch verwendet.
+"""
+
+import os
+
+import uno
+
+FALLBACK = "en"
+
+TEXTS = {
+ "en": {
+ # -- Dialog: Synopse aus der Aenderungsverfolgung -----------------
+ "dlg.title": "Create synopsis",
+ "dlg.people": "Contributors (%d): %s",
+ "dlg.people.none": "none found",
+ "dlg.group.mode": "Display of changes",
+ "dlg.mode.cumulative": "Cumulative \u2013 one column with the final version",
+ "dlg.mode.stacked": "Stacked \u2013 one column, contributors below each other",
+ "dlg.mode.separate": "Separate \u2013 one column per contributor (%d)",
+ "dlg.mode.hint": "With more than three contributors the separate "
+ "display quickly becomes too narrow.",
+ "dlg.group.content": "Content",
+ "dlg.opt.deletions": "Keep deleted text, struck through",
+ "dlg.opt.elide": "Shorten unchanged passages to the context",
+ "dlg.opt.final": "Also show the final version (stacked/separate)",
+ "dlg.opt.notes": "Include comments and annotations",
+ "dlg.opt.quote": "quoting the commented passage",
+ "dlg.opt.onlychanged": "Only changed or commented paragraphs",
+ "dlg.group.target": "Target document",
+ "dlg.opt.numbering": "Sequential number",
+ "dlg.opt.landscape": "Landscape",
+ "dlg.ok": "OK",
+ "dlg.cancel": "Cancel",
+
+ # -- Dialog: Dokumentvergleich ------------------------------------
+ "cmp.title": "Compare two documents",
+ "cmp.group.docs": "Documents",
+ "cmp.source": "Source version (left column):",
+ "cmp.target": "Final version (middle column):",
+ "cmp.group.comments": "Comments in the final version",
+ "cmp.comments.column": "In a separate column on the right",
+ "cmp.comments.inline": "Below the passage in the middle column",
+ "cmp.comments.none": "Do not include",
+ "cmp.comments.hint": "If the final version contains no comments, "
+ "the third column is omitted automatically.",
+ "cmp.group.display": "Display",
+ "cmp.opt.highlight": "Highlight differences word by word",
+ "cmp.opt.quote": "Quote the commented passage",
+ "cmp.opt.onlychanged": "Only include differing paragraphs",
+
+ # -- Tabelle -------------------------------------------------------
+ "col.number": "No.",
+ "col.original": "Original version",
+ "col.final": "Final version",
+ "col.changes": "Changes",
+ "col.comments": "Comments",
+ "doc.title": "Synopsis",
+ "doc.title.named": "Synopsis: %s",
+ "doc.title.compare": "Synopsis: %s \u2192 %s",
+ "text.removed": "deleted without replacement",
+ "text.noteonly": " \u2013 comment only",
+ "text.resolved": " (resolved)",
+ "text.unknown": "(unknown)",
+
+ # -- Meldungen -----------------------------------------------------
+ "msg.nodoc": "Please bring a Writer document to the front first.",
+ "msg.nochanges": "The document contains neither tracked changes "
+ "nor comments.",
+ "msg.noparagraphs": "No changed paragraphs were found.",
+ "msg.twodocs": "At least two Writer documents must be open "
+ "for a comparison.",
+ "msg.different": "Please select two different documents.",
+ "msg.identical": "The two documents are identical in content.",
+ "msg.error.build": "Error while creating the synopsis:\n\n%s\n\n%s",
+ "msg.error.compare": "Error during comparison:\n\n%s\n\n%s",
+ },
+
+ "de": {
+ "dlg.title": "Synopse erzeugen",
+ "dlg.people": "Beteiligte (%d): %s",
+ "dlg.people.none": "keine gefunden",
+ "dlg.group.mode": "Darstellung der \u00c4nderungen",
+ "dlg.mode.cumulative": "Kumuliert \u2013 eine Spalte mit der Endfassung",
+ "dlg.mode.stacked": "Gestapelt \u2013 eine Spalte, Personen untereinander",
+ "dlg.mode.separate": "Getrennt \u2013 je Person eine eigene Spalte (%d)",
+ "dlg.mode.hint": "Bei mehr als drei Beteiligten wird die getrennte "
+ "Darstellung schnell zu schmal.",
+ "dlg.group.content": "Inhalt",
+ "dlg.opt.deletions": "Gel\u00f6schten Text durchgestrichen mitf\u00fchren",
+ "dlg.opt.elide": "Unver\u00e4nderte Passagen auf den Kontext k\u00fcrzen",
+ "dlg.opt.final": "Endfassung zus\u00e4tzlich ausweisen (gestapelt/getrennt)",
+ "dlg.opt.notes": "Anmerkungen und Kommentare \u00fcbernehmen",
+ "dlg.opt.quote": "dabei kommentierte Textstelle zitieren",
+ "dlg.opt.onlychanged": "Nur ge\u00e4nderte oder kommentierte Abs\u00e4tze",
+ "dlg.group.target": "Zieldokument",
+ "dlg.opt.numbering": "Laufende Nummer",
+ "dlg.opt.landscape": "Querformat",
+ "dlg.ok": "OK",
+ "dlg.cancel": "Abbrechen",
+
+ "cmp.title": "Zwei Dokumente vergleichen",
+ "cmp.group.docs": "Dokumente",
+ "cmp.source": "Quellfassung (linke Spalte):",
+ "cmp.target": "Endfassung (mittlere Spalte):",
+ "cmp.group.comments": "Kommentare der Endfassung",
+ "cmp.comments.column": "In einer eigenen Spalte rechts",
+ "cmp.comments.inline": "Unter der Textstelle in der mittleren Spalte",
+ "cmp.comments.none": "Nicht \u00fcbernehmen",
+ "cmp.comments.hint": "Enth\u00e4lt die Endfassung keine Kommentare, "
+ "entf\u00e4llt die dritte Spalte automatisch.",
+ "cmp.group.display": "Darstellung",
+ "cmp.opt.highlight": "Unterschiede wortweise hervorheben",
+ "cmp.opt.quote": "Kommentierte Textstelle zitieren",
+ "cmp.opt.onlychanged": "Nur abweichende Abs\u00e4tze aufnehmen",
+
+ "col.number": "Nr.",
+ "col.original": "Ursprungsfassung",
+ "col.final": "Endfassung",
+ "col.changes": "\u00c4nderungen",
+ "col.comments": "Kommentare",
+ "doc.title": "Synopse",
+ "doc.title.named": "Synopse: %s",
+ "doc.title.compare": "Synopse: %s \u2192 %s",
+ "text.removed": "ersatzlos gestrichen",
+ "text.noteonly": " \u2013 nur Anmerkung",
+ "text.resolved": " (erledigt)",
+ "text.unknown": "(unbekannt)",
+
+ "msg.nodoc": "Bitte zuerst ein Writer-Dokument in den Vordergrund holen.",
+ "msg.nochanges": "Das Dokument enth\u00e4lt weder nachverfolgte "
+ "\u00c4nderungen noch Anmerkungen.",
+ "msg.noparagraphs": "Es wurden keine ge\u00e4nderten Abs\u00e4tze gefunden.",
+ "msg.twodocs": "F\u00fcr den Vergleich m\u00fcssen mindestens zwei "
+ "Writer-Dokumente ge\u00f6ffnet sein.",
+ "msg.different": "Bitte zwei verschiedene Dokumente ausw\u00e4hlen.",
+ "msg.identical": "Die beiden Dokumente sind inhaltlich identisch.",
+ "msg.error.build": "Fehler beim Erzeugen der Synopse:\n\n%s\n\n%s",
+ "msg.error.compare": "Fehler beim Vergleich:\n\n%s\n\n%s",
+ },
+
+ "fr": {
+ "dlg.title": "Cr\u00e9er une synopse",
+ "dlg.people": "Participants (%d) : %s",
+ "dlg.people.none": "aucun trouv\u00e9",
+ "dlg.group.mode": "Pr\u00e9sentation des modifications",
+ "dlg.mode.cumulative": "Cumul\u00e9e \u2013 une colonne avec la version finale",
+ "dlg.mode.stacked": "Empil\u00e9e \u2013 une colonne, participants l\u2019un sous l\u2019autre",
+ "dlg.mode.separate": "S\u00e9par\u00e9e \u2013 une colonne par participant (%d)",
+ "dlg.mode.hint": "Au-del\u00e0 de trois participants, la pr\u00e9sentation "
+ "s\u00e9par\u00e9e devient vite trop \u00e9troite.",
+ "dlg.group.content": "Contenu",
+ "dlg.opt.deletions": "Conserver le texte supprim\u00e9, barr\u00e9",
+ "dlg.opt.elide": "R\u00e9duire les passages inchang\u00e9s au contexte",
+ "dlg.opt.final": "Afficher aussi la version finale (empil\u00e9e/s\u00e9par\u00e9e)",
+ "dlg.opt.notes": "Reprendre les commentaires et annotations",
+ "dlg.opt.quote": "en citant le passage comment\u00e9",
+ "dlg.opt.onlychanged": "Uniquement les paragraphes modifi\u00e9s ou comment\u00e9s",
+ "dlg.group.target": "Document cible",
+ "dlg.opt.numbering": "Num\u00e9rotation",
+ "dlg.opt.landscape": "Paysage",
+ "dlg.ok": "OK",
+ "dlg.cancel": "Annuler",
+
+ "cmp.title": "Comparer deux documents",
+ "cmp.group.docs": "Documents",
+ "cmp.source": "Version source (colonne de gauche) :",
+ "cmp.target": "Version finale (colonne du milieu) :",
+ "cmp.group.comments": "Commentaires de la version finale",
+ "cmp.comments.column": "Dans une colonne distincte \u00e0 droite",
+ "cmp.comments.inline": "Sous le passage dans la colonne du milieu",
+ "cmp.comments.none": "Ne pas reprendre",
+ "cmp.comments.hint": "Si la version finale ne contient aucun "
+ "commentaire, la troisi\u00e8me colonne est "
+ "supprim\u00e9e automatiquement.",
+ "cmp.group.display": "Pr\u00e9sentation",
+ "cmp.opt.highlight": "Mettre en \u00e9vidence les diff\u00e9rences mot \u00e0 mot",
+ "cmp.opt.quote": "Citer le passage comment\u00e9",
+ "cmp.opt.onlychanged": "N\u2019inclure que les paragraphes divergents",
+
+ "col.number": "N\u00b0",
+ "col.original": "Version initiale",
+ "col.final": "Version finale",
+ "col.changes": "Modifications",
+ "col.comments": "Commentaires",
+ "doc.title": "Synopse",
+ "doc.title.named": "Synopse : %s",
+ "doc.title.compare": "Synopse : %s \u2192 %s",
+ "text.removed": "supprim\u00e9 sans remplacement",
+ "text.noteonly": " \u2013 commentaire seulement",
+ "text.resolved": " (r\u00e9solu)",
+ "text.unknown": "(inconnu)",
+
+ "msg.nodoc": "Veuillez d\u2019abord placer un document Writer au "
+ "premier plan.",
+ "msg.nochanges": "Le document ne contient ni modifications suivies "
+ "ni commentaires.",
+ "msg.noparagraphs": "Aucun paragraphe modifi\u00e9 n\u2019a \u00e9t\u00e9 trouv\u00e9.",
+ "msg.twodocs": "Au moins deux documents Writer doivent \u00eatre "
+ "ouverts pour une comparaison.",
+ "msg.different": "Veuillez s\u00e9lectionner deux documents diff\u00e9rents.",
+ "msg.identical": "Les deux documents ont un contenu identique.",
+ "msg.error.build": "Erreur lors de la cr\u00e9ation de la synopse :"
+ "\n\n%s\n\n%s",
+ "msg.error.compare": "Erreur lors de la comparaison :\n\n%s\n\n%s",
+ },
+}
+
+_language = FALLBACK
+
+
+def _office_locale(ctx):
+ """Oberflaechensprache von LibreOffice, z. B. 'de-DE'."""
+ try:
+ provider = ctx.ServiceManager.createInstanceWithContext(
+ "com.sun.star.configuration.ConfigurationProvider", ctx)
+ argument = uno.createUnoStruct("com.sun.star.beans.PropertyValue")
+ argument.Name = "nodepath"
+ argument.Value = "/org.openoffice.Setup/L10N"
+ node = provider.createInstanceWithArguments(
+ "com.sun.star.configuration.ConfigurationAccess", (argument,))
+ return node.getByName("ooLocale") or ""
+ except Exception:
+ return ""
+
+
+def set_language(ctx=None):
+ """Sprache bestimmen. Ist sie nicht hinterlegt, bleibt es bei Englisch."""
+ global _language
+ tag = _office_locale(ctx) if ctx is not None else ""
+ if not tag:
+ tag = os.environ.get("LANGUAGE") or os.environ.get("LANG") or ""
+ primary = tag.replace("_", "-").split("-")[0].split(".")[0].lower()
+ _language = primary if primary in TEXTS else FALLBACK
+ return _language
+
+
+def language():
+ return _language
+
+
+def t(key, *args):
+ """Uebersetzten Text liefern; fehlt er, greift Englisch."""
+ value = TEXTS.get(_language, {}).get(key)
+ if value is None:
+ value = TEXTS[FALLBACK].get(key, key)
+ if args:
+ try:
+ value = value % args
+ except Exception:
+ pass
+ return value
diff --git a/pythonpath/synopse/redline_model.py b/pythonpath/synopse/redline_model.py
new file mode 100644
index 0000000..5a6d470
--- /dev/null
+++ b/pythonpath/synopse/redline_model.py
@@ -0,0 +1,402 @@
+# -*- 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
diff --git a/registration/license-de.txt b/registration/license-de.txt
new file mode 100644
index 0000000..4edf41c
--- /dev/null
+++ b/registration/license-de.txt
@@ -0,0 +1,25 @@
+MIT-Lizenz
+
+Copyright (c) 2026 Marco Morath
+
+Hiermit wird unentgeltlich jeder Person, die eine Kopie dieser Software und
+der zugehörigen Dokumentationsdateien (die „Software") erhält, die Erlaubnis
+erteilt, sie uneingeschränkt zu nutzen, einschließlich und ohne Ausnahme des
+Rechts, sie zu verwenden, zu kopieren, zu verändern, zusammenzufügen, zu
+veröffentlichen, zu verbreiten, zu unterlizenzieren und/oder zu verkaufen,
+und Personen, denen diese Software überlassen wird, diese Rechte zu
+verschaffen, unter den folgenden Bedingungen:
+
+Der obige Urheberrechtsvermerk und dieser Erlaubnisvermerk sind in allen
+Kopien oder wesentlichen Teilen der Software beizulegen.
+
+DIE SOFTWARE WIRD OHNE JEDE AUSDRÜCKLICHE ODER STILLSCHWEIGENDE GARANTIE
+BEREITGESTELLT, EINSCHLIESSLICH DER GARANTIE ZUR EIGNUNG FÜR EINEN BESTIMMTEN
+ZWECK ODER EINES BESTIMMTEN ZWECKES UND DER NICHTVERLETZUNG VON RECHTEN
+DRITTER. IN KEINEM FALL SIND DIE AUTOREN ODER RECHTEINHABER FÜR ANSPRÜCHE,
+SCHÄDEN ODER ANDERE VERPFLICHTUNGEN HAFTBAR, OB INFOLGE EINES VERTRAGES,
+EINER UNERLAUBTEN HANDLUNG ODER ANDERWEITIG, DIE SICH AUS DER SOFTWARE ODER
+DEREN NUTZUNG ERGEBEN.
+
+Hinweis: Diese Übersetzung dient allein dem Verständnis. Rechtlich maßgeblich
+ist ausschließlich der englische Originaltext der MIT-Lizenz.
diff --git a/registration/license-en.txt b/registration/license-en.txt
new file mode 100644
index 0000000..a7d966d
--- /dev/null
+++ b/registration/license-en.txt
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Marco Morath
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/registration/license-fr.txt b/registration/license-fr.txt
new file mode 100644
index 0000000..030c02d
--- /dev/null
+++ b/registration/license-fr.txt
@@ -0,0 +1,25 @@
+Licence MIT
+
+Copyright (c) 2026 Marco Morath
+
+Par la présente, une autorisation est accordée gratuitement à toute personne
+obtenant une copie de ce logiciel et des fichiers de documentation associés
+(le « Logiciel »), de commercialiser le Logiciel sans restriction, y compris
+sans limitation les droits d'utiliser, de copier, de modifier, de fusionner,
+de publier, de distribuer, de sous-licencier et/ou de vendre des copies du
+Logiciel, et d'autoriser les personnes auxquelles le Logiciel est fourni à le
+faire, sous réserve des conditions suivantes :
+
+L'avis de droit d'auteur ci-dessus et le présent avis d'autorisation doivent
+être inclus dans toutes les copies ou parties substantielles du Logiciel.
+
+LE LOGICIEL EST FOURNI « EN L'ÉTAT », SANS GARANTIE D'AUCUNE SORTE, EXPRESSE
+OU IMPLICITE, Y COMPRIS MAIS SANS S'Y LIMITER LES GARANTIES DE QUALITÉ
+MARCHANDE, D'ADÉQUATION À UN USAGE PARTICULIER ET D'ABSENCE DE CONTREFAÇON.
+EN AUCUN CAS LES AUTEURS OU TITULAIRES DU DROIT D'AUTEUR NE SERONT
+RESPONSABLES DE TOUTE RÉCLAMATION, DOMMAGE OU AUTRE RESPONSABILITÉ, QUE CE
+SOIT DANS LE CADRE D'UN CONTRAT, D'UN DÉLIT OU AUTREMENT, DÉCOULANT DU
+LOGICIEL OU DE SON UTILISATION.
+
+Remarque : cette traduction n'est fournie qu'à titre informatif. Seul le texte
+anglais original de la licence MIT fait foi.
diff --git a/synopse_handler.py b/synopse_handler.py
new file mode 100644
index 0000000..49dbcde
--- /dev/null
+++ b/synopse_handler.py
@@ -0,0 +1,109 @@
+# -*- coding: utf-8 -*-
+"""Protocol Handler der Erweiterung 'Synopse'."""
+
+import traceback
+
+import unohelper
+from com.sun.star.frame import XDispatchProvider, XDispatch
+from com.sun.star.lang import XServiceInfo, XInitialization
+
+from synopse.redline_model import extract_blocks
+from synopse.builder import build
+from synopse import compare as compare_mod
+from synopse.dialog import show_options, show_compare_options, message
+from synopse.i18n import set_language, t
+
+IMPL_NAME = "de.example.synopse.ProtocolHandler"
+PROTOCOL = "de.example.synopse:"
+
+
+def run(ctx):
+ set_language(ctx)
+ desktop = ctx.ServiceManager.createInstanceWithContext(
+ "com.sun.star.frame.Desktop", ctx)
+ doc = desktop.getCurrentComponent()
+ if doc is None or not doc.supportsService("com.sun.star.text.TextDocument"):
+ message(ctx, t("msg.nodoc"))
+ return
+ try:
+ _blocks, edit_authors, note_authors = extract_blocks(doc)
+ if not edit_authors and not note_authors:
+ message(ctx, t("msg.nochanges"))
+ return
+ opts = show_options(ctx, edit_authors, note_authors)
+ if opts is None:
+ return
+ build(ctx, doc, opts)
+ except Exception as exc: # pragma: no cover
+ message(ctx, t("msg.error.build", exc, traceback.format_exc()))
+
+
+def run_compare(ctx):
+ set_language(ctx)
+ desktop = ctx.ServiceManager.createInstanceWithContext(
+ "com.sun.star.frame.Desktop", ctx)
+ docs = compare_mod.open_documents(ctx)
+ if len(docs) < 2:
+ message(ctx, t("msg.twodocs"))
+ return
+ try:
+ choice = show_compare_options(ctx, docs, desktop.getCurrentComponent())
+ if choice is None:
+ return
+ source, target, opts = choice
+ compare_mod.build(ctx, source, target, opts)
+ except Exception as exc: # pragma: no cover
+ message(ctx, t("msg.error.compare", exc, traceback.format_exc()))
+
+
+class SynopseHandler(unohelper.Base, XServiceInfo, XDispatchProvider,
+ XDispatch, XInitialization):
+
+ def __init__(self, ctx):
+ self.ctx = ctx
+ self.frame = None
+
+ # XInitialization
+ def initialize(self, args):
+ if args:
+ self.frame = args[0]
+
+ # XServiceInfo
+ def getImplementationName(self):
+ return IMPL_NAME
+
+ def supportsService(self, name):
+ return name == "com.sun.star.frame.ProtocolHandler"
+
+ def getSupportedServiceNames(self):
+ return ("com.sun.star.frame.ProtocolHandler",)
+
+ # XDispatchProvider
+ def queryDispatch(self, url, target_frame, search_flags):
+ if url.Protocol == PROTOCOL:
+ return self
+ return None
+
+ def queryDispatches(self, requests):
+ return tuple(self.queryDispatch(r.FeatureURL, r.FrameName,
+ r.SearchFlags) for r in requests)
+
+ # XDispatch
+ def dispatch(self, url, args):
+ if url.Protocol != PROTOCOL:
+ return
+ if url.Path == "run":
+ run(self.ctx)
+ elif url.Path == "compare":
+ run_compare(self.ctx)
+
+ def addStatusListener(self, control, url):
+ pass
+
+ def removeStatusListener(self, control, url):
+ pass
+
+
+g_ImplementationHelper = unohelper.ImplementationHelper()
+g_ImplementationHelper.addImplementation(
+ SynopseHandler, IMPL_NAME, ("com.sun.star.frame.ProtocolHandler",))
diff --git a/tools/make_icons.py b/tools/make_icons.py
new file mode 100644
index 0000000..47a8094
--- /dev/null
+++ b/tools/make_icons.py
@@ -0,0 +1,98 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""Erzeugt die Symbole der Erweiterung.
+
+Gezeichnet wird achtfach vergroessert und anschliessend heruntergerechnet -
+bei 42x42 Pixeln ist das der einzige Weg zu sauberen Kanten.
+
+Aufruf: python3 tools/make_icons.py
+"""
+
+import os
+
+from PIL import Image, ImageDraw
+
+SCALE = 8
+TARGET = 42
+BIG = TARGET * SCALE
+
+PAPER = (255, 255, 255, 255)
+INK = (61, 56, 70, 255) # Rahmen und Trennlinie
+TEXT = (154, 153, 150, 255) # unveraenderter Text
+INSERT = (26, 95, 180, 255) # Einfuegung
+DELETE = (165, 29, 45, 255) # Loeschung
+
+BLACK = (0, 0, 0, 255)
+WHITE = (255, 255, 255, 255)
+
+# Zeilenraster: (y, Breite links, Breite rechts, Farbrolle rechts)
+ROWS = (
+ (0.22, 1.00, 1.00, "equal"),
+ (0.38, 1.00, 0.85, "insert"),
+ (0.54, 1.00, 1.00, "equal"),
+ (0.70, 1.00, 0.55, "delete"),
+)
+
+
+def draw(colors, border):
+ img = Image.new("RGBA", (BIG, BIG), (0, 0, 0, 0))
+ pen = ImageDraw.Draw(img)
+
+ margin = int(BIG * 0.09)
+ box = (margin, margin, BIG - margin, BIG - margin)
+ radius = int(BIG * 0.08)
+ pen.rounded_rectangle(box, radius=radius, fill=colors["paper"],
+ outline=colors["ink"], width=border)
+
+ middle = BIG // 2
+ pen.line([(middle, margin + border), (middle, BIG - margin - border)],
+ fill=colors["ink"], width=max(2, border // 2))
+
+ pad = int(BIG * 0.16)
+ left_start, left_end = pad, middle - int(BIG * 0.06)
+ right_start, right_end = middle + int(BIG * 0.06), BIG - pad
+ height = int(BIG * 0.075)
+ bar_radius = height // 2
+
+ for y_rel, w_left, w_right, role in ROWS:
+ y = int(BIG * y_rel)
+ span = left_end - left_start
+ pen.rounded_rectangle(
+ (left_start, y, left_start + int(span * w_left), y + height),
+ radius=bar_radius, fill=colors["text"])
+
+ span = right_end - right_start
+ pen.rounded_rectangle(
+ (right_start, y, right_start + int(span * w_right), y + height),
+ radius=bar_radius, fill=colors[role])
+
+ return img
+
+
+def save(img, path, size):
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ img.resize((size, size), Image.LANCZOS).save(path, "PNG", optimize=True)
+ print("%s %dx%d" % (path, size, size))
+
+
+def main():
+ here = os.path.dirname(os.path.abspath(__file__))
+ root = os.path.dirname(here)
+
+ normal = draw({"paper": PAPER, "ink": INK, "text": TEXT,
+ "equal": TEXT, "insert": INSERT, "delete": DELETE},
+ border=int(BIG * 0.022))
+
+ # Hoher Kontrast: nur Schwarz und Weiss, kraeftigerer Rahmen. Die
+ # Unterscheidung der Zeilen traegt hier allein ueber die Laenge.
+ contrast = draw({"paper": WHITE, "ink": BLACK, "text": BLACK,
+ "equal": BLACK, "insert": BLACK, "delete": BLACK},
+ border=int(BIG * 0.04))
+
+ save(normal, os.path.join(root, "icon", "synopse.png"), TARGET)
+ save(contrast, os.path.join(root, "icon", "synopse_hc.png"), TARGET)
+ save(normal, os.path.join(root, "icon", "synopse-256.png"), 256)
+
+
+if __name__ == "__main__":
+ main()