Initial commit
This commit is contained in:
53
Addons.xcu
Normal file
53
Addons.xcu
Normal 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
29
LICENSE
@@ -1,18 +1,21 @@
|
|||||||
MIT License
|
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
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
in the Software without restriction, including without limitation the rights
|
||||||
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
following conditions:
|
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
|
The above copyright notice and this permission notice shall be included in all
|
||||||
portions of the Software.
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
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
12
META-INF/manifest.xml
Normal 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
12
ProtocolHandler.xcu
Normal 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
228
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
|
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
47
build.sh
Normal 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
3
description-de.txt
Normal 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
3
description-en.txt
Normal 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
3
description-fr.txt
Normal 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
46
description.xml
Normal 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
BIN
icon/synopse-256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.3 KiB |
BIN
icon/synopse.png
Normal file
BIN
icon/synopse.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
BIN
icon/synopse_hc.png
Normal file
BIN
icon/synopse_hc.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
0
pythonpath/synopse/__init__.py
Normal file
0
pythonpath/synopse/__init__.py
Normal file
618
pythonpath/synopse/builder.py
Normal file
618
pythonpath/synopse/builder.py
Normal 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
|
||||||
282
pythonpath/synopse/compare.py
Normal file
282
pythonpath/synopse/compare.py
Normal 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
|
||||||
241
pythonpath/synopse/dialog.py
Normal file
241
pythonpath/synopse/dialog.py
Normal 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
256
pythonpath/synopse/i18n.py
Normal 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
|
||||||
402
pythonpath/synopse/redline_model.py
Normal file
402
pythonpath/synopse/redline_model.py
Normal 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
|
||||||
25
registration/license-de.txt
Normal file
25
registration/license-de.txt
Normal 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.
|
||||||
21
registration/license-en.txt
Normal file
21
registration/license-en.txt
Normal 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.
|
||||||
25
registration/license-fr.txt
Normal file
25
registration/license-fr.txt
Normal 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
109
synopse_handler.py
Normal 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
98
tools/make_icons.py
Normal 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()
|
||||||
Reference in New Issue
Block a user