Initial commit

This commit is contained in:
2026-09-11 12:47:00 +02:00
parent a25c7b46f5
commit d108f863e0
24 changed files with 2498 additions and 15 deletions

53
Addons.xcu Normal file
View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<oor:component-data xmlns:oor="http://openoffice.org/2001/registry"
xmlns:xs="http://www.w3.org/2001/XMLSchema"
oor:name="Addons" oor:package="org.openoffice.Office">
<node oor:name="AddonUI">
<node oor:name="OfficeMenuBar">
<node oor:name="de.mmorath.synopse" oor:op="replace">
<prop oor:name="Title" oor:type="xs:string">
<value>Synopsis</value>
<value xml:lang="en-US">Synopsis</value>
<value xml:lang="de">Synopse</value>
<value xml:lang="fr">Synopse</value>
</prop>
<prop oor:name="Target" oor:type="xs:string"><value>_self</value></prop>
<prop oor:name="Context" oor:type="xs:string">
<value>com.sun.star.text.TextDocument</value>
</prop>
<node oor:name="Submenu">
<node oor:name="m01" oor:op="replace">
<prop oor:name="URL" oor:type="xs:string">
<value>de.example.synopse:run</value>
</prop>
<prop oor:name="Title" oor:type="xs:string">
<value>Create synopsis…</value>
<value xml:lang="en-US">Create synopsis…</value>
<value xml:lang="de">Synopse erzeugen…</value>
<value xml:lang="fr">Créer une synopse…</value>
</prop>
<prop oor:name="Target" oor:type="xs:string"><value>_self</value></prop>
<prop oor:name="Context" oor:type="xs:string">
<value>com.sun.star.text.TextDocument</value>
</prop>
</node>
<node oor:name="m02" oor:op="replace">
<prop oor:name="URL" oor:type="xs:string">
<value>de.example.synopse:compare</value>
</prop>
<prop oor:name="Title" oor:type="xs:string">
<value>Compare two documents…</value>
<value xml:lang="en-US">Compare two documents…</value>
<value xml:lang="de">Zwei Dokumente vergleichen…</value>
<value xml:lang="fr">Comparer deux documents…</value>
</prop>
<prop oor:name="Target" oor:type="xs:string"><value>_self</value></prop>
<prop oor:name="Context" oor:type="xs:string">
<value>com.sun.star.text.TextDocument</value>
</prop>
</node>
</node>
</node>
</node>
</node>
</oor:component-data>

29
LICENSE
View File

@@ -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.

12
META-INF/manifest.xml Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<manifest:manifest xmlns:manifest="http://openoffice.org/2001/manifest">
<manifest:file-entry
manifest:media-type="application/vnd.sun.star.uno-component;type=Python"
manifest:full-path="synopse_handler.py"/>
<manifest:file-entry
manifest:media-type="application/vnd.sun.star.configuration-data"
manifest:full-path="ProtocolHandler.xcu"/>
<manifest:file-entry
manifest:media-type="application/vnd.sun.star.configuration-data"
manifest:full-path="Addons.xcu"/>
</manifest:manifest>

12
ProtocolHandler.xcu Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<oor:component-data xmlns:oor="http://openoffice.org/2001/registry"
xmlns:xs="http://www.w3.org/2001/XMLSchema"
oor:name="ProtocolHandler" oor:package="org.openoffice.Office">
<node oor:name="HandlerSet">
<node oor:name="de.example.synopse.ProtocolHandler" oor:op="replace">
<prop oor:name="Protocols" oor:type="oor:string-list">
<value>de.example.synopse:*</value>
</prop>
</node>
</node>
</oor:component-data>

228
README.md
View File

@@ -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
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.

47
build.sh Normal file
View File

@@ -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/.*<version[[:space:]]\{1,\}value="\([^"]*\)".*/\1/p' \
description.xml | head -n 1)
if [ -z "$VERSION" ]; then
echo "Fehler: keine Version in description.xml gefunden." >&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"

3
description-de.txt Normal file
View File

@@ -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.

3
description-en.txt Normal file
View File

@@ -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.

3
description-fr.txt Normal file
View File

@@ -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.

46
description.xml Normal file
View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<description xmlns="http://openoffice.org/extensions/description/2006"
xmlns:d="http://openoffice.org/extensions/description/2006"
xmlns:xlink="http://www.w3.org/1999/xlink">
<identifier value="de.mmorath.synopse"/>
<version value="0.8.0"/>
<display-name>
<name lang="en">Synopsis-Builder</name>
<name lang="de">Synopse-Ersteller</name>
<name lang="fr">Générateur de synopsis</name>
</display-name>
<publisher>
<name xlink:href="https://gitea.cloud.mmorath.de/marco.morath/libreoffice-synopse-builder" lang="en">Marco Morath</name>
<name xlink:href="https://gitea.cloud.mmorath.de/marco.morath/libreoffice-synopse-builder" lang="de">Marco Morath</name>
<name xlink:href="https://gitea.cloud.mmorath.de/marco.morath/libreoffice-synopse-builder" lang="fr">Marco Morath</name>
</publisher>
<icon>
<default xlink:href="icon/synopse.png"/>
<high-contrast xlink:href="icon/synopse_hc.png"/>
</icon>
<extension-description>
<src lang="en" xlink:href="description-en.txt"/>
<src lang="de" xlink:href="description-de.txt"/>
<src lang="fr" xlink:href="description-fr.txt"/>
</extension-description>
<registration>
<simple-license accept-by="admin" default-license-id="mit-en"
suppress-on-update="true">
<license-text xlink:href="registration/license-en.txt" lang="en"
license-id="mit-en"/>
<license-text xlink:href="registration/license-de.txt" lang="de"/>
<license-text xlink:href="registration/license-fr.txt" lang="fr"/>
</simple-license>
</registration>
<dependencies>
<OpenOffice.org-minimal-version value="4.1" d:name="LibreOffice 6.0"/>
</dependencies>
</description>

BIN
icon/synopse-256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

BIN
icon/synopse.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
icon/synopse_hc.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

View File

@@ -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

View File

@@ -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

View File

@@ -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()

256
pythonpath/synopse/i18n.py Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

109
synopse_handler.py Normal file
View File

@@ -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",))

98
tools/make_icons.py Normal file
View File

@@ -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()