From d108f863e072419aeaab44f03df3090c8f8294f0 Mon Sep 17 00:00:00 2001 From: Marco Morath Date: Fri, 11 Sep 2026 12:47:00 +0200 Subject: [PATCH] Initial commit --- Addons.xcu | 53 +++ LICENSE | 29 +- META-INF/manifest.xml | 12 + ProtocolHandler.xcu | 12 + README.md | 228 +++++++++- build.sh | 47 +++ description-de.txt | 3 + description-en.txt | 3 + description-fr.txt | 3 + description.xml | 46 +++ icon/synopse-256.png | Bin 0 -> 9555 bytes icon/synopse.png | Bin 0 -> 1745 bytes icon/synopse_hc.png | Bin 0 -> 1574 bytes pythonpath/synopse/__init__.py | 0 pythonpath/synopse/builder.py | 618 ++++++++++++++++++++++++++++ pythonpath/synopse/compare.py | 282 +++++++++++++ pythonpath/synopse/dialog.py | 241 +++++++++++ pythonpath/synopse/i18n.py | 256 ++++++++++++ pythonpath/synopse/redline_model.py | 402 ++++++++++++++++++ registration/license-de.txt | 25 ++ registration/license-en.txt | 21 + registration/license-fr.txt | 25 ++ synopse_handler.py | 109 +++++ tools/make_icons.py | 98 +++++ 24 files changed, 2498 insertions(+), 15 deletions(-) create mode 100644 Addons.xcu create mode 100644 META-INF/manifest.xml create mode 100644 ProtocolHandler.xcu create mode 100644 build.sh create mode 100644 description-de.txt create mode 100644 description-en.txt create mode 100644 description-fr.txt create mode 100644 description.xml create mode 100644 icon/synopse-256.png create mode 100644 icon/synopse.png create mode 100644 icon/synopse_hc.png create mode 100644 pythonpath/synopse/__init__.py create mode 100644 pythonpath/synopse/builder.py create mode 100644 pythonpath/synopse/compare.py create mode 100644 pythonpath/synopse/dialog.py create mode 100644 pythonpath/synopse/i18n.py create mode 100644 pythonpath/synopse/redline_model.py create mode 100644 registration/license-de.txt create mode 100644 registration/license-en.txt create mode 100644 registration/license-fr.txt create mode 100644 synopse_handler.py create mode 100644 tools/make_icons.py diff --git a/Addons.xcu b/Addons.xcu new file mode 100644 index 0000000..0a51a6d --- /dev/null +++ b/Addons.xcu @@ -0,0 +1,53 @@ + + + + + + + Synopsis + Synopsis + Synopse + Synopse + + _self + + com.sun.star.text.TextDocument + + + + + de.example.synopse:run + + + Create synopsis… + Create synopsis… + Synopse erzeugen… + Créer une synopse… + + _self + + com.sun.star.text.TextDocument + + + + + de.example.synopse:compare + + + Compare two documents… + Compare two documents… + Zwei Dokumente vergleichen… + Comparer deux documents… + + _self + + com.sun.star.text.TextDocument + + + + + + + diff --git a/LICENSE b/LICENSE index 68ce501..a7d966d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,18 +1,21 @@ MIT License -Copyright (c) 2026 marco.morath +Copyright (c) 2026 Marco Morath -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and -associated documentation files (the "Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the -following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all copies or substantial -portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT -LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO -EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE -USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/META-INF/manifest.xml b/META-INF/manifest.xml new file mode 100644 index 0000000..ad25c75 --- /dev/null +++ b/META-INF/manifest.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/ProtocolHandler.xcu b/ProtocolHandler.xcu new file mode 100644 index 0000000..799080b --- /dev/null +++ b/ProtocolHandler.xcu @@ -0,0 +1,12 @@ + + + + + + de.example.synopse:* + + + + diff --git a/README.md b/README.md index 3cb20db..c551c50 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,227 @@ -# libreoffice-synopse-builder +# Synopse aus Änderungsverfolgung (LibreOffice Writer) -Erstellt eine Synopse aus zwei Writer-Dokumenten oder aus einem Dokument mit aktiver Änderungsverfolgung \ No newline at end of file +Erzeugt aus einem Writer-Dokument mit nachverfolgten Änderungen ein neues +Dokument mit einer Synopse-Tabelle. + +* **Spalte links:** Ursprungsfassung (alle Änderungen verworfen) +* **Rechte Seite:** wahlweise in drei Darstellungen + +| Modus | Aufbau | geeignet für | +|---|---|---| +| **kumuliert** | eine Spalte mit der Endfassung | Abnahme einer konsolidierten Fassung | +| **gestapelt** | eine Spalte, darin die Personen untereinander, optional mit Endfassung als Abschluss | viele Beteiligte | +| **getrennt** | je Person eine eigene Spalte, optional gefolgt von der Endfassung | zwei bis drei Beteiligte | + +Optional wird in beiden mehrspaltigen Modi zusätzlich die **Endfassung** +ausgewiesen: im gestapelten Modus als letzter Eintrag jeder Zelle, bei +getrennter Darstellung als zusätzliche Spalte ganz rechts. Sie zeigt den +Wortlaut, der beschlossen werden soll — ungekürzt und ohne Streichungen, da +diese in derselben Zeile ohnehin sichtbar sind. Einfügungen bleiben markiert, +damit erkennbar ist, was gegenüber der Ursprungsfassung neu ist. + +Im gestapelten Modus erscheint pro Zeile nur, wer dort tatsächlich etwas +geändert oder kommentiert hat. Jeder Eintrag beginnt mit einer dünnen +Trennlinie und dem Namen in der Autorenfarbe. Wer an einer Stelle nur +kommentiert hat, wird mit dem Zusatz „nur Anmerkung“ geführt und bekommt +keine Textfassung – sie wäre mit der Ursprungsfassung identisch. + +## Streichungen in der Endfassung + +Die Endfassung zeigt keinen durchgestrichenen Text — sonst wäre sie kein +lesbarer Beschlusstext. Damit Streichungen trotzdem erkennbar bleiben, wird +nach Fall unterschieden: + +| Fall | Darstellung | +|---|---| +| Text unmittelbar ersetzt | nur die Neufassung, hervorgehoben | +| Abschnitt ersatzlos gestrichen | kursiver Hinweis „ersatzlos gestrichen" | +| Teil eines Satzes gestrichen | letztes verbliebenes Wort davor und erstes danach fett hervorgehoben | + +Der dritte Fall macht die Nahtstelle sichtbar, ohne den Satz zu zerreißen: + + Ursprung Bei diesem Satz wird der Mittelteil, der eigentlich nicht + gebraucht wird, weggelassen. + + Endfassung Bei diesem Satz wird der **Mittelteil weggelassen.** + +Die Logik steckt in `_clean_runs()`. „Unmittelbar ersetzt" heißt dabei: der +nächste vorhandene Nachbar der Streichung ist eine Einfügung — dann trägt +diese bereits die Markierung und es wird nichts zusätzlich hervorgehoben. + +## Kürzung unveränderter Passagen + +Damit die Zellen auch bei langen Absätzen lesbar bleiben, lassen sich +unveränderte Passagen auf den Kontext um die Änderung kürzen (voreingestellt +40 Zeichen je Seite, an der Wortgrenze geschnitten, gekennzeichnet mit `…`). +Die Kürzung wirkt auf die Änderungsseite; die Spalte „Ursprungsfassung“ bleibt +vollständig, damit der Bezugstext nachlesbar ist. + + Ursprung Die Feuerwehr … hält für den Grundschutz … bereit, die + regelmäßig geprüft werden. rückt aus. Die Feuerwehr … + + Anna … die regelmäßig geprüft werden. r̶ü̶c̶k̶t̶ fährt aus. Die … + +Die Fassung einer Person ist dabei jeweils diejenige, die entstünde, wenn +ausschließlich deren Änderungen übernommen würden. Einfügungen sind +unterstrichen und in der Autorenfarbe dargestellt, Löschungen optional +durchgestrichen. + +## Anmerkungen + +Erklärungsbedürftige Änderungen lassen sich kommentieren. Berücksichtigt werden +beide Quellen, die Writer dafür anbietet: + +* **Kommentare** (`Einfügen ▸ Kommentar`, `Strg+Alt+C`) – wahlweise an einer + Stelle oder über einen markierten Bereich +* **Kommentare zu einer Änderung** (`Bearbeiten ▸ Änderungsverwaltung ▸ + Verwalten ▸ Bearbeiten…`), also das Feld `RedlineComment` der Redline + +Die Anmerkung erscheint **unterhalb** des Textes in derselben Zelle, bei +getrennter Darstellung in der Spalte der jeweiligen Person, im gestapelten +Modus unter deren Eintrag, bei kumulierter Darstellung gesammelt in der +Spalte „Endfassung“. Die Spalte +„Ursprungsfassung“ bleibt frei von Anmerkungen. + +Optisch abgesetzt wird über vier gleichzeitig wirkende Merkmale, damit die +Unterscheidung auch im Schwarzweißdruck trägt: + +| Merkmal | Wirkung | +|---|---| +| senkrechter Balken links | Absatzrahmen `LeftBorder` in Autorenfarbe | +| flächige Hinterlegung | `ParaBackColor`, auf 10 % aufgehellte Autorenfarbe | +| Einzug und Abstand | `ParaLeftMargin`, `ParaTopMargin` | +| kleinerer Schriftgrad | 8,5 pt gegenüber 10 pt im Fließtext | + +Zur Abgrenzung davon nutzt die Personen-Überschrift im gestapelten Modus eine +**waagerechte** Linie (`TopBorder`) ohne Hinterlegung – Balken links bedeutet +also stets „Anmerkung“, Linie oben „neue Person“. + +Der Kopf der Anmerkung nennt in Autorenfarbe und fett den Namen, bei +Änderungskommentaren ergänzt um „– zur Änderung“, bei erledigten Kommentaren um +„(erledigt)“. Bei Kommentaren über einen Textbereich wird die kommentierte +Stelle optional als kursives Zitat (gekürzt auf 80 Zeichen) vorangestellt. + +## Zweite Funktion: zwei Dokumente vergleichen + +**Synopse ▸ Zwei Dokumente vergleichen…** listet alle geöffneten +Writer-Dokumente auf. Auszuwählen sind Quellfassung und Endfassung; das +gerade aktive Dokument ist als Endfassung vorbelegt. + +| Spalte | Inhalt | +|---|---| +| links | Textstellen der Quellfassung, entfallene Wörter durchgestrichen (rot) | +| Mitte | Textstellen der Endfassung, neue Wörter unterstrichen (blau) | +| rechts | Kommentare aus der Endfassung | + +Die dritte Spalte entfällt automatisch, wenn die Endfassung keine Kommentare +enthält. Alternativ lassen sich die Kommentare unter die Textstelle in der +mittleren Spalte setzen, dann bleibt es bei zwei Spalten. + +Enthält eines der Dokumente selbst nachverfolgte Änderungen, wird dessen +Fassung „alle Änderungen übernommen" verglichen. + +### Zuordnung + +Anders als bei der Änderungsverfolgung gibt es hier keine Redlines, aus denen +sich die Zuordnung ablesen ließe – sie wird in zwei Stufen berechnet: + +1. **Absatzebene** – `difflib.SequenceMatcher` über die normalisierten + Absatztexte. Innerhalb geänderter Abschnitte werden Absätze über ihre + Ähnlichkeit gepaart (`MATCH_MIN = 0.30`, Suchfenster `LOOKAHEAD = 3`); + was darunter bleibt, gilt als entfallener bzw. neuer Absatz und bekommt + eine einseitige Zeile. +2. **Wortebene** – ein zweiter Durchlauf innerhalb jedes Absatzpaares + markiert die abweichenden Wörter. + +Die beiden Schwellwerte stehen am Kopf von `compare.py`. Bei stark +umformulierten Texten kann `MATCH_MIN` zu senken sinnvoll sein, bei stark +wiederholenden Texten (Formulare, Tabellen) eher zu erhöhen. + +## Bauen und Installieren + + ./build.sh + unopkg add --force synopse-0.6.1.oxt + +Danach im Writer: Menü **Synopse ▸ Synopse erzeugen…** + +Zum Entfernen: + + unopkg remove de.example.synopse + +## Aufbau + +| Datei | Zweck | +|---|---| +| `synopse_handler.py` | UNO-Komponente (Protocol Handler), Einstiegspunkt | +| `pythonpath/synopse/redline_model.py` | Extraktion von Segmenten und Anmerkungen | +| `pythonpath/synopse/builder.py` | Aufbau des Zieldokuments (Tabelle, Formatierung) | +| `pythonpath/synopse/compare.py` | Vergleich zweier Dokumente (difflib, zweistufig) | +| `pythonpath/synopse/dialog.py` | Optionsdialog | +| `Addons.xcu`, `ProtocolHandler.xcu` | Menüeintrag und Registrierung | + +## Modell + +Der Text wird einmal linear durchlaufen. Dabei wird ein Stack der gerade +offenen Redlines geführt; jedes Textstück (`Segment`) merkt sich diesen Stack. +Aus dem Stack lässt sich jede Fassung ableiten: + +| Zustand des Segments | Ursprung | Endfassung | Fassung von *X* | +|---|---|---|---| +| unverändert | ja | ja | ja | +| eingefügt von X | nein | ja | ja | +| eingefügt von Y | nein | ja | nein | +| gelöscht von X | ja | nein | nein | +| gelöscht von Y | ja | nein | **ja** | +| von Y eingefügt, von X gelöscht | nein | nein | nein | + +Absätze werden zu einer Tabellenzeile zusammengefasst, wenn die Absatzmarke +selbst Teil einer Änderung ist. Dadurch bleiben linke und rechte Spalte auch +bei zusammengeführten oder geteilten Absätzen auf gleicher Höhe. + +## Das Quelldokument bleibt unverändert + +Für die Extraktion muss `RedlineDisplayType` kurzzeitig auf „Einfügungen und +Löschungen anzeigen" stehen – nur dann liefert die Portion-Enumeration auch +den gelöschten Text. `extract_blocks()` merkt sich deshalb den vorherigen Wert +sowie `isModified()` und stellt beides in einem `finally`-Block wieder her. + +Der Zahlenwert dieser Anzeigeart wird **gemessen, nicht geraten**: Liefert der +Typmanager die Konstante nicht, probiert `_display_all_value()` die Werte 0 bis +3 durch und nimmt den, bei dem `getText().getString()` am längsten ist – denn +nur die Anzeige mit Einfügungen *und* Löschungen enthält beides. Das Ergebnis +wird für die Sitzung gemerkt. + +Hintergrund: Die Reihenfolge der Konstanten ist nicht über alle Versionen +gleich. Ein geratener Wert kann `REMOVED` treffen, und dann werden +stillschweigend alle Einfügungen ausgeblendet – samt der Kommentaranker, die +darin liegen. + +## Hinweis zu UNO-Konstanten + +Alle UNO-Konstanten werden über `redline_model.const()` aufgelöst, das bei +einem Fehlschlag auf den dokumentierten Zahlenwert zurückfällt. Grund: löst +`uno.getConstantByName()` beim Import eines Moduls eine `RuntimeException` +aus, bricht der Python-Loader die Registrierung der gesamten Erweiterung ab – +sichtbar als „Couldn't load … for reason com.sun.star.text.RedlineDisplayType +.INSERTED_AND_REMOVED". Zur Importzeit darf deshalb nichts stehen, das den +Typmanager zwingend braucht. Gleiches gilt für `uno.Enum()` und +`uno.createUnoStruct()`, die über `enum()` bzw. `_line_struct()` laufen. + +## Bekannte Grenzen + +* Reine Formatänderungen (`RedlineType == "Format"`) werden ignoriert. +* Antworten auf Kommentare erscheinen als eigenständige Anmerkungen; die + Verkettung über `ParentName` wird nicht ausgewertet. +* Personen, die ausschließlich kommentiert haben, erhalten bei getrennter + Darstellung eine eigene Spalte. Deren Textfassung entspricht dann der + Ursprungsfassung. +* Absätze aus Tabellenzellen des Quelldokuments werden in die Zeilenfolge + eingereiht, die Tabellenstruktur geht dabei verloren. +* Kopf-/Fußzeilen, Fußnoten und Rahmen werden nicht durchlaufen. +* Beim Dokumentvergleich werden reine Formatunterschiede nicht erkannt – + verglichen wird der Text. +* Verschobene Absätze erscheinen beim Dokumentvergleich als entfallen und neu, + nicht als Verschiebung. +* Absatzformate (Überschriftenebene, Nummerierung) werden nicht übernommen. +* Verschachtelte Redlines über `RedlineSuccessorData` werden nur insoweit + berücksichtigt, wie LibreOffice sie als überlappende Portions ausliefert. diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..befb18f --- /dev/null +++ b/build.sh @@ -0,0 +1,47 @@ +#!/bin/sh +# Baut das OXT-Paket. Aufruf im Projektverzeichnis: ./build.sh +# +# Die Versionsnummer wird aus description.xml gelesen - sie steht damit +# an genau einer Stelle und kann beim Bauen nicht auseinanderlaufen. +set -e + +cd "$(dirname "$0")" + +VERSION=$(sed -n 's/.*&2 + exit 1 +fi + +NAME="synopse-$VERSION.oxt" + +# Das 256er Symbol ist nur fuer das Erweiterungsverzeichnis und die +# Projektseite gedacht und gehoert nicht ins Paket. + +# Symbole erzeugen, falls sie fehlen und Pillow verfuegbar ist +if [ ! -f icon/synopse.png ] && command -v python3 > /dev/null 2>&1; then + python3 tools/make_icons.py || \ + echo "Hinweis: Symbole konnten nicht erzeugt werden." >&2 +fi + +for required in description.xml META-INF/manifest.xml synopse_handler.py \ + icon/synopse.png icon/synopse_hc.png registration/license-en.txt; do + if [ ! -f "$required" ]; then + echo "Fehler: $required fehlt." >&2 + exit 1 + fi +done + +rm -f "$NAME" +zip -r -X "$NAME" \ + META-INF \ + description.xml \ + description-de.txt description-en.txt description-fr.txt \ + ProtocolHandler.xcu Addons.xcu \ + synopse_handler.py pythonpath \ + icon registration \ + -x '*.pyc' -x '*__pycache__*' -x 'icon/synopse-256.png' > /dev/null + +echo "fertig: $NAME" diff --git a/description-de.txt b/description-de.txt new file mode 100644 index 0000000..be76599 --- /dev/null +++ b/description-de.txt @@ -0,0 +1,3 @@ +Erzeugt aus einem Writer-Dokument mit nachverfolgten Änderungen eine +Synopse: links die Ursprungsfassung, rechts wahlweise die kumulierte +Endfassung oder je bearbeitender Person eine eigene Spalte. diff --git a/description-en.txt b/description-en.txt new file mode 100644 index 0000000..9ff8d05 --- /dev/null +++ b/description-en.txt @@ -0,0 +1,3 @@ +Turns a Writer document with tracked changes into a readable synopsis: the +original version on the left, the changes on the right — cumulative, stacked +or one column per contributor. Also compares two open documents. diff --git a/description-fr.txt b/description-fr.txt new file mode 100644 index 0000000..3163410 --- /dev/null +++ b/description-fr.txt @@ -0,0 +1,3 @@ +Transforme un document Writer avec suivi des modifications en une synopse +lisible : la version initiale à gauche, les modifications à droite — cumulées, +empilées ou une colonne par participant. Compare aussi deux documents ouverts. diff --git a/description.xml b/description.xml new file mode 100644 index 0000000..7181ba8 --- /dev/null +++ b/description.xml @@ -0,0 +1,46 @@ + + + + + + + + Synopsis-Builder + Synopse-Ersteller + Générateur de synopsis + + + + Marco Morath + Marco Morath + Marco Morath + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/icon/synopse-256.png b/icon/synopse-256.png new file mode 100644 index 0000000000000000000000000000000000000000..c2d1f614c91b828b7f49fb1854623686ac43f8bc GIT binary patch literal 9555 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

on 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 Ub2Sv_1K>W|>iTN6Dt6KT1@?!ZZU6uP literal 0 HcmV?d00001 diff --git a/icon/synopse.png b/icon/synopse.png new file mode 100644 index 0000000000000000000000000000000000000000..fd2251fa94e6cf832d97e9d68458f7d5b2ada4d0 GIT binary patch literal 1745 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 shift) & 0xFF + channel = int(channel + (255 - channel) * factor) + out |= min(channel, 255) << shift + return out + + +def _line_struct(): + for name in ("com.sun.star.table.BorderLine2", + "com.sun.star.table.BorderLine"): + try: + return uno.createUnoStruct(name) + except Exception: + continue + return None + + +def _border(color, width=50): + line = _line_struct() + if line is None: + return None + line.Color = color + line.LineWidth = width # 1/100 mm + line.OuterLineWidth = width + line.InnerLineWidth = 0 + line.LineDistance = 0 + line.LineStyle = 0 # SOLID + return line + + +def _no_border(): + line = _line_struct() + if line is None: + return None + line.LineWidth = 0 + line.OuterLineWidth = 0 + try: + line.LineStyle = 5 # NONE + except Exception: + pass + return line + + +# -- Spalteninhalt -------------------------------------------------------- + +def _split_first_word(text): + """(erstes Wort mit fuehrendem Leerraum, Rest).""" + lead = len(text) - len(text.lstrip()) + head, rest = text[:lead], text[lead:] + cut = rest.find(" ") + if cut < 0: + return head + rest, "" + return head + rest[:cut], rest[cut:] + + +def _split_last_word(text): + """(Rest, letztes Wort mit nachfolgendem Leerraum).""" + body = text.rstrip() + trail = text[len(body):] + cut = body.rfind(" ") + if cut < 0: + return "", body + trail + return body[:cut + 1], body[cut + 1:] + trail + + +def _neighbour(seq, index, step): + """Naechster tatsaechlich vorhandener Nachbar mit Textinhalt.""" + j = index + step + while 0 <= j < len(seq): + entry = seq[j] + if entry["keep"] and entry["text"].strip(): + return j + j += step + return None + + +def _clean_runs(block, key, opts): + """Fassung ohne durchgestrichenen Text, aber mit lesbarer Streichung. + + - unmittelbar ersetzter Text: nur die Neufassung wird hervorgehoben + - ersatzlos gestrichener Abschnitt: Hinweis statt leerer Zelle + - Streichung innerhalb eines Satzes: das letzte verbliebene Wort davor + und das erste danach werden hervorgehoben, damit die Nahtstelle + erkennbar bleibt + """ + seq = [] + for seg in block.segments: + inserted = bool(seg.inserted_by) + if key == COL_FINAL: + keep = seg.in_final() + author = (seg.inserted_by[0] if inserted else + (seg.deleted_by[0] if seg.deleted_by else None)) + removed = not keep and seg.in_original() + else: + keep = seg.in_version_of(key) + author = key + removed = not keep and seg.in_original() and key in seg.deleted_by + seq.append({"keep": keep, "removed": removed, "text": seg.text, + "kind": "insert" if (keep and inserted) else "equal", + "author": author}) + + dropped = [i for i, e in enumerate(seq) if e["removed"]] + if not dropped: + return [(e["text"], e["kind"], e["author"]) for e in seq if e["keep"]] + + if not "".join(e["text"] for e in seq if e["keep"]).strip(): + return [(t("text.removed"), "removed", seq[dropped[0]]["author"])] + + gap_before, gap_after = {}, {} + for i in dropped: + prev_i = _neighbour(seq, i, -1) + next_i = _neighbour(seq, i, 1) + replaced = ((prev_i is not None and seq[prev_i]["kind"] == "insert") + or (next_i is not None and seq[next_i]["kind"] == "insert")) + if replaced: + continue # die Neufassung traegt die Markierung + author = seq[i]["author"] + if prev_i is not None and seq[prev_i]["kind"] == "equal": + gap_before[prev_i] = author + if next_i is not None and seq[next_i]["kind"] == "equal": + gap_after[next_i] = author + + runs = [] + for i, entry in enumerate(seq): + if not entry["keep"]: + continue + if entry["kind"] != "equal": + runs.append((entry["text"], entry["kind"], entry["author"])) + continue + after, before = gap_after.get(i), gap_before.get(i) + first, rest = (_split_first_word(entry["text"]) + if after is not None else ("", entry["text"])) + head, last = (_split_last_word(rest) + if before is not None else (rest, "")) + if first: + runs.append((first, "gap", after)) + if head: + runs.append((head, "equal", None)) + if last: + runs.append((last, "gap", before)) + return runs + + +def _runs(block, key, opts, show_deletions=None): + """Liste von (text, kind, author). kind: equal | insert | delete. + + *show_deletions* uebersteuert die Option; damit laesst sich die + Endfassung als reiner Beschlusstext ohne Streichungen ausgeben. + """ + if show_deletions is None: + show_deletions = opts.show_deletions + if key != COL_ORIGINAL and not show_deletions: + return _clean_runs(block, key, opts) + runs = [] + for seg in block.segments: + if key == COL_ORIGINAL: + if seg.in_original(): + runs.append((seg.text, "equal", None)) + continue + + if key == COL_FINAL: + keep = seg.in_final() + ins, dele = seg.inserted_by, seg.deleted_by + author = ins[0] if ins else (dele[0] if dele else None) + show_deleted = seg.in_original() + else: + keep = seg.in_version_of(key) + author = key + show_deleted = seg.in_original() and key in seg.deleted_by + + if keep: + runs.append((seg.text, "insert" if seg.inserted_by else "equal", author)) + elif show_deletions and show_deleted: + runs.append((seg.text, "delete", author)) + return runs + + +def _merge(runs): + """Benachbarte Laeufe gleicher Formatierung zusammenfassen.""" + out = [] + for text, kind, author in runs: + if out and out[-1][1] == kind and out[-1][2] == author: + out[-1][0] += text + else: + out.append([text, kind, author]) + return [tuple(r) for r in out] + + +def _head(text, n): + cut = text[:n] + space = cut.rfind(" ") + return (cut[:space] if space > n // 2 else cut).rstrip() + + +def _tail(text, n): + cut = text[-n:] + space = cut.find(" ") + return (cut[space + 1:] if 0 <= space < n // 2 else cut).lstrip() + + +def _elide(runs, context): + """Lange unveraenderte Passagen auf den Kontext um die Aenderungen kuerzen.""" + runs = _merge(runs) + if not any(k != "equal" for _t, k, _a in runs): + return runs # nichts geaendert: nichts kuerzen + out = [] + for i, (text, kind, author) in enumerate(runs): + if kind != "equal" or len(text) <= 2 * context + len(ELLIPSIS): + out.append((text, kind, author)) + continue + before = any(k != "equal" for _t, k, _a in runs[:i]) + after = any(k != "equal" for _t, k, _a in runs[i + 1:]) + if before and after: + new = _head(text, context) + ELLIPSIS + _tail(text, context) + elif after: + new = ELLIPSIS.lstrip() + _tail(text, context) + elif before: + new = _head(text, context) + ELLIPSIS.rstrip() + else: + new = text + out.append((new, kind, author)) + return out + + +def _active_authors(block, opts): + """Personen, die in diesem Block ueberhaupt in Erscheinung treten.""" + found = set() + for seg in block.segments: + for _rtype, author in seg.stack: + found.add(author) + if opts.show_notes: + for note in block.notes: + found.add(note.author) + return found + + +def _body(block, key, opts, elide=False): + runs = _runs(block, key, opts) + if elide and opts.elide: + runs = _elide(runs, opts.elide_context) + return runs + + +def _cell_items(block, key, opts, authors): + """Inhalt einer Zelle als Folge von ('body'|'heading'|'note', payload).""" + if key == COL_ORIGINAL: + return [("body", _runs(block, key, opts))] + + if key == COL_FINAL: + # Die Endfassung wird ungekuerzt gezeigt: sie ist der Wortlaut, + # der beschlossen werden soll. Streichungen entfallen dabei, sobald + # sie an anderer Stelle der Zeile ohnehin sichtbar sind. + clean = None if opts.mode == "cumulative" else False + items = [("body", _runs(block, key, opts, show_deletions=clean))] + if opts.show_notes and opts.mode == "cumulative": + # In den anderen Modi stehen die Anmerkungen bereits bei der + # jeweiligen Person und wuerden sich hier wiederholen. + items += [("note", n) for n in block.notes] + return items + + if key == COL_STACKED: + editors = set() + for seg in block.segments: + for _rtype, author in seg.stack: + editors.add(author) + items = [] + for author in sorted(_active_authors(block, opts)): + note_only = author not in editors + color = _author_color(author, authors) + suffix = t("text.noteonly") if note_only else "" + items.append(("heading", (_author_label(author), suffix, color))) + if not note_only: + # Wer hier nur kommentiert hat, bekommt keine Textfassung: + # sie waere mit der Ursprungsfassung identisch. + items.append(("body", _body(block, author, opts, elide=True))) + if opts.show_notes: + items += [("note", n) for n in block.notes_of(author)] + if items and opts.show_final: + items.append(("heading", (t("col.final"), "", FINAL_COLOR))) + items.append(("body", _runs(block, COL_FINAL, opts, + show_deletions=False))) + return items + + # eigene Spalte je Person + items = [("body", _body(block, key, opts, elide=True))] + if opts.show_notes: + items += [("note", n) for n in block.notes_of(key)] + return items + + +# -- Schreiben ------------------------------------------------------------ + +def _set_border(cur, name, line, distance=0): + """Absatzrahmen setzen; ohne verfuegbare Struktur wird er ausgelassen.""" + if line is None: + return + try: + setattr(cur, name, line) + setattr(cur, name + "Distance", distance) + except Exception: + pass + + +def _reset_para(cur): + cur.ParaBackColor = -1 + cur.ParaLeftMargin = 0 + cur.ParaRightMargin = 0 + cur.ParaTopMargin = 0 + cur.ParaBottomMargin = 0 + _set_border(cur, "LeftBorder", _no_border()) + _set_border(cur, "TopBorder", _no_border()) + + +def _heading_para(cur, color, first): + _reset_para(cur) + cur.ParaTopMargin = 0 if first else 220 + cur.ParaBottomMargin = 40 + if not first: + _set_border(cur, "TopBorder", _border(color, 20), 90) + + +def _note_para(cur, color): + _reset_para(cur) + cur.ParaBackColor = _tint(color) + cur.ParaLeftMargin = 200 + cur.ParaRightMargin = 100 + cur.ParaTopMargin = 150 + cur.ParaBottomMargin = 60 + _set_border(cur, "LeftBorder", _border(color), 120) + + +def _char(cur, color=NEUTRAL, size=BODY_SIZE, weight=100.0, + posture=UPRIGHT, underline=UL_NONE, strikeout=SO_NONE): + cur.CharColor = color + cur.CharHeight = size + cur.CharWeight = weight + if posture is not None: + cur.CharPosture = posture + cur.CharUnderline = underline + cur.CharStrikeout = strikeout + + +def _insert(text, cur, value): + for i, part in enumerate(value.split("\n")): + if i: + text.insertControlCharacter(cur, PARAGRAPH_BREAK, False) + if part: + text.insertString(cur, part, False) + + +def _write_cell(cell, items, authors, opts, bold=False): + text = cell.getText() + text.setString("") + cur = text.createTextCursor() + _reset_para(cur) + wrote = False + + for kind, payload in items: + if kind == "body": + runs = _merge(payload) + if not runs: + continue + cur.gotoEnd(False) + if wrote: + text.insertControlCharacter(cur, PARAGRAPH_BREAK, False) + _reset_para(cur) + for value, rkind, author in runs: + color = _author_color(author, authors) if author else NEUTRAL + weight = 150.0 if bold else 100.0 + if rkind == "insert": + _char(cur, color, BODY_SIZE, weight, underline=UL_SINGLE) + elif rkind == "delete": + _char(cur, color, BODY_SIZE, weight, strikeout=SO_SINGLE) + elif rkind == "gap": + # Nahtstelle einer Streichung: fett in der Autorenfarbe + _char(cur, color, BODY_SIZE, 150.0) + elif rkind == "removed": + _char(cur, color, BODY_SIZE, weight, posture=ITALIC) + else: + _char(cur, NEUTRAL, BODY_SIZE, weight) + _insert(text, cur, value) + wrote = True + + elif kind == "heading": + label, suffix, color = payload + cur.gotoEnd(False) + if wrote: + text.insertControlCharacter(cur, PARAGRAPH_BREAK, False) + _heading_para(cur, color, not wrote) + _char(cur, color, HEAD_SIZE, 150.0) + _insert(text, cur, label) + if suffix: + _char(cur, GREY, HEAD_SIZE, 100.0, posture=ITALIC) + _insert(text, cur, suffix) + wrote = True + + elif kind == "note": + note = payload + color = _author_color(note.author, authors) + cur.gotoEnd(False) + if wrote: + text.insertControlCharacter(cur, PARAGRAPH_BREAK, False) + _note_para(cur, color) + + label = _author_label(note.author) + if note.resolved: + label += t("text.resolved") + _char(cur, color, NOTE_SIZE, 150.0) + _insert(text, cur, label + ": ") + + quote = note.short_quote() if opts.show_quotes else "" + if quote: + _char(cur, GREY, NOTE_SIZE, 100.0, posture=ITALIC) + _insert(text, cur, "\u201e%s\u201c \u2013 " % quote) + + _char(cur, GREY if note.resolved else NEUTRAL, NOTE_SIZE, 100.0) + _insert(text, cur, note.content or "(ohne Text)") + wrote = True + + +def _set_widths(table, weights): + seps = table.TableColumnSeparators + if not seps: + return + total = float(sum(weights)) + pos, new = 0.0, [] + for i, sep in enumerate(seps): + if i >= len(weights) - 1: + break + pos += weights[i] / total * 10000.0 + sep.Position = int(pos) + new.append(sep) + if new: + table.TableColumnSeparators = tuple(new) + + +def _new_writer(ctx, landscape): + desktop = ctx.ServiceManager.createInstanceWithContext( + "com.sun.star.frame.Desktop", ctx) + doc = desktop.loadComponentFromURL("private:factory/swriter", "_blank", 0, ()) + if landscape: + style = doc.StyleFamilies.getByName("PageStyles").getByName("Standard") + if not style.IsLandscape: + w, h = style.Width, style.Height + style.IsLandscape = True + style.Width, style.Height = h, w + for name in ("LeftMargin", "RightMargin"): + try: + setattr(style, name, 1000) + except Exception: + pass + return doc + + +# -- Aufbau --------------------------------------------------------------- + +def _columns(opts, authors): + cols, weights = [], [] + if opts.numbering: + cols.append((t("col.number"), COL_INDEX)) + weights.append(0.4) + cols.append((t("col.original"), COL_ORIGINAL)) + weights.append(3.0) + + if opts.mode == "cumulative": + cols.append((t("col.final"), COL_FINAL)) + weights.append(3.0) + elif opts.mode == "stacked": + cols.append((t("col.changes"), COL_STACKED)) + weights.append(4.0) + else: + for author in sorted(authors): + cols.append((_author_label(author), author)) + weights.append(3.0) + if opts.show_final: + cols.append((t("col.final"), COL_FINAL)) + weights.append(3.0) + return cols, weights + + +def build(ctx, source_doc, opts): + blocks, edit_authors, note_authors = extract_blocks(source_doc) + authors = set(edit_authors) + if opts.show_notes: + authors |= note_authors + if not authors: + raise RuntimeError(t("msg.nochanges")) + + if opts.only_changed or opts.mode == "stacked": + # Im gestapelten Modus bleiben unveraenderte Absaetze ohnehin leer. + blocks = [b for b in blocks if b.changed] if opts.only_changed else blocks + if not blocks: + raise RuntimeError(t("msg.noparagraphs")) + + cols, weights = _columns(opts, authors) + + doc = _new_writer(ctx, opts.landscape) + body = doc.getText() + cur = body.createTextCursor() + try: + cur.ParaStyleName = "Heading 1" + except Exception: + pass + name = getattr(source_doc, "Title", "") or "" + body.insertString(cur, t("doc.title.named", name) if name + else t("doc.title"), False) + body.insertControlCharacter(cur, PARAGRAPH_BREAK, False) + try: + cur.ParaStyleName = "Default Paragraph Style" + except Exception: + pass + + table = doc.createInstance("com.sun.star.text.TextTable") + table.initialize(len(blocks) + 1, len(cols)) + body.insertTextContent(cur, table, False) + try: + table.RepeatHeadline = True + table.HeaderRowCount = 1 + except Exception: + pass + + for c, (label, _key) in enumerate(cols): + cell = table.getCellByName(_cell_name(c, 0)) + cell.BackColor = HEADER_BG + _write_cell(cell, [("body", [(label, "equal", None)])], + authors, opts, bold=True) + + for r, block in enumerate(blocks, start=1): + for c, (_label, key) in enumerate(cols): + cell = table.getCellByName(_cell_name(c, r)) + if key == COL_INDEX: + _write_cell(cell, [("body", [(str(r), "equal", None)])], + authors, opts) + else: + _write_cell(cell, _cell_items(block, key, opts, authors), + authors, opts) + + _set_widths(table, weights) + return doc diff --git a/pythonpath/synopse/compare.py b/pythonpath/synopse/compare.py new file mode 100644 index 0000000..69457ec --- /dev/null +++ b/pythonpath/synopse/compare.py @@ -0,0 +1,282 @@ +# -*- coding: utf-8 -*- +"""Vergleich zweier geoeffneter Writer-Dokumente zu einer klassischen Synopse. + +Links die Textstellen der Quellfassung, in der Mitte die der Endfassung, +rechts wahlweise die Kommentare aus der Endfassung. + +Die Zuordnung laeuft in zwei Stufen: + 1. Absatzebene - difflib ueber die normalisierten Absatztexte + 2. Wortebene - difflib innerhalb eines zugeordneten Absatzpaares +""" + +import difflib +import re + +from synopse import builder as B +from synopse.redline_model import extract_blocks +from synopse.i18n import t + +COLOR_DEL = 0xA51D2D +COLOR_INS = 0x1A5FB4 + +TOKEN = re.compile(r"\S+\s*") + +MATCH_MIN = 0.30 # ab dieser Aehnlichkeit gelten zwei Absaetze als Paar +LOOKAHEAD = 3 # Fenster fuer die Suche nach dem Gegenstueck + + +class CompareOptions(object): + comments = "column" # "column" | "inline" | "none" + highlight = True # Unterschiede auf Wortebene hervorheben + show_quotes = True + only_changed = False + numbering = True + landscape = True + + +class Unit(object): + """Ein Absatz eines Dokuments samt der dort verankerten Kommentare.""" + + __slots__ = ("text", "norm", "notes") + + def __init__(self, text, notes): + self.text = text.replace("\n", " ") + self.norm = " ".join(self.text.split()) + self.notes = notes + + +# -- Dokumente ------------------------------------------------------------ + +def open_documents(ctx): + """Alle geoeffneten Writer-Dokumente.""" + desktop = ctx.ServiceManager.createInstanceWithContext( + "com.sun.star.frame.Desktop", ctx) + found = [] + enum = desktop.getComponents().createEnumeration() + while enum.hasMoreElements(): + comp = enum.nextElement() + try: + if comp.supportsService("com.sun.star.text.TextDocument"): + found.append(comp) + except Exception: + pass + return found + + +def document_title(doc): + try: + title = doc.getTitle() + except Exception: + title = "" + return title or t("text.unknown") + + +def to_units(doc): + """Absaetze eines Dokuments in der Fassung 'alle Aenderungen uebernommen'.""" + blocks, _edit, _note = extract_blocks(doc) + units = [] + for block in blocks: + text = "".join(s.text for s in block.segments if s.in_final()) + units.append(Unit(text, list(block.notes))) + return units + + +# -- Zuordnung ------------------------------------------------------------ + +def _ratio(left, right): + if left is None or right is None: + return 0.0 + return difflib.SequenceMatcher(None, left.norm, right.norm).ratio() + + +def _pair_replace(left, right): + """Absaetze eines veraenderten Abschnitts einander zuordnen.""" + rows = [] + li = ri = 0 + while li < len(left) or ri < len(right): + if li >= len(left): + rows.append((None, right[ri])) + ri += 1 + continue + if ri >= len(right): + rows.append((left[li], None)) + li += 1 + continue + + if _ratio(left[li], right[ri]) >= MATCH_MIN: + rows.append((left[li], right[ri])) + li += 1 + ri += 1 + continue + + # Gegenstueck im Fenster suchen: wurde eingefuegt oder geloescht? + span_r = range(ri, min(ri + LOOKAHEAD, len(right))) + span_l = range(li, min(li + LOOKAHEAD, len(left))) + best_r = max(span_r, key=lambda j: _ratio(left[li], right[j])) + best_l = max(span_l, key=lambda i: _ratio(left[i], right[ri])) + score_r = _ratio(left[li], right[best_r]) + score_l = _ratio(left[best_l], right[ri]) + + if score_r >= MATCH_MIN and score_r >= score_l: + while ri < best_r: # rechts kamen Absaetze hinzu + rows.append((None, right[ri])) + ri += 1 + elif score_l >= MATCH_MIN: + while li < best_l: # links fielen Absaetze weg + rows.append((left[li], None)) + li += 1 + else: + rows.append((left[li], right[ri])) + li += 1 + ri += 1 + return rows + + +def align(source, target): + """Zeilen der Synopse als Liste von (Unit|None, Unit|None).""" + a = [u.norm for u in source] + b = [u.norm for u in target] + matcher = difflib.SequenceMatcher(None, a, b, autojunk=False) + rows = [] + for tag, i1, i2, j1, j2 in matcher.get_opcodes(): + if tag == "equal": + for k in range(i2 - i1): + rows.append((source[i1 + k], target[j1 + k])) + elif tag == "delete": + for k in range(i1, i2): + rows.append((source[k], None)) + elif tag == "insert": + for k in range(j1, j2): + rows.append((None, target[k])) + else: + rows.extend(_pair_replace(source[i1:i2], target[j1:j2])) + return rows + + +# -- Wortebene ------------------------------------------------------------ + +def _tokens(text): + found = TOKEN.findall(text) + return found if found else ([text] if text else []) + + +def word_runs(left, right, highlight): + """Runs fuer die linke und die mittlere Spalte eines Absatzpaares.""" + if left is None: + return [], [(right.text, "insert", COLOR_INS)] + if right is None: + return [(left.text, "delete", COLOR_DEL)], [] + if not highlight or left.norm == right.norm: + return ([(left.text, "equal", None)], [(right.text, "equal", None)]) + + at, bt = _tokens(left.text), _tokens(right.text) + matcher = difflib.SequenceMatcher( + None, [t.strip() for t in at], [t.strip() for t in bt], autojunk=False) + lrun, rrun = [], [] + for tag, i1, i2, j1, j2 in matcher.get_opcodes(): + if tag in ("equal", "delete", "replace"): + chunk = "".join(at[i1:i2]) + if chunk: + lrun.append((chunk, "equal" if tag == "equal" else "delete", + None if tag == "equal" else COLOR_DEL)) + if tag in ("equal", "insert", "replace"): + chunk = "".join(bt[j1:j2]) + if chunk: + rrun.append((chunk, "equal" if tag == "equal" else "insert", + None if tag == "equal" else COLOR_INS)) + return lrun, rrun + + +def _changed(left, right): + if left is None or right is None: + return True + if left.norm != right.norm: + return True + return bool(right.notes) + + +# -- Aufbau --------------------------------------------------------------- + +def build(ctx, source_doc, target_doc, opts): + source = to_units(source_doc) + target = to_units(target_doc) + rows = align(source, target) + + if opts.only_changed: + rows = [r for r in rows if _changed(*r)] + if not rows: + raise RuntimeError(t("msg.identical")) + + has_notes = any(r[1] is not None and r[1].notes for r in rows) + note_authors = set() + for _l, r in rows: + if r is not None: + for note in r.notes: + note_authors.add(note.author) + + mode = opts.comments + if not has_notes or mode == "none": + mode = "none" # rechte Spalte entfaellt + + cols, weights = [], [] + if opts.numbering: + cols.append((t("col.number"), "index")) + weights.append(0.4) + cols.append((B._head(document_title(source_doc), 40), "left")) + weights.append(3.0) + cols.append((B._head(document_title(target_doc), 40), "right")) + weights.append(3.0) + if mode == "column": + cols.append((t("col.comments"), "notes")) + weights.append(2.0) + + doc = B._new_writer(ctx, opts.landscape) + body = doc.getText() + cur = body.createTextCursor() + try: + cur.ParaStyleName = "Heading 1" + except Exception: + pass + body.insertString(cur, t("doc.title.compare", + document_title(source_doc), + document_title(target_doc)), False) + body.insertControlCharacter(cur, B.PARAGRAPH_BREAK, False) + try: + cur.ParaStyleName = "Default Paragraph Style" + except Exception: + pass + + table = doc.createInstance("com.sun.star.text.TextTable") + table.initialize(len(rows) + 1, len(cols)) + body.insertTextContent(cur, table, False) + try: + table.RepeatHeadline = True + table.HeaderRowCount = 1 + except Exception: + pass + + for c, (label, _key) in enumerate(cols): + cell = table.getCellByName(B._cell_name(c, 0)) + cell.BackColor = B.HEADER_BG + B._write_cell(cell, [("body", [(label, "equal", None)])], + note_authors, opts, bold=True) + + for r, (left, right) in enumerate(rows, start=1): + lrun, rrun = word_runs(left, right, opts.highlight) + notes = list(right.notes) if right is not None else [] + for c, (_label, key) in enumerate(cols): + cell = table.getCellByName(B._cell_name(c, r)) + if key == "index": + items = [("body", [(str(r), "equal", None)])] + elif key == "left": + items = [("body", lrun)] + elif key == "right": + items = [("body", rrun)] + if mode == "inline": + items += [("note", n) for n in notes] + else: + items = [("note", n) for n in notes] + B._write_cell(cell, items, note_authors, opts) + + B._set_widths(table, weights) + return doc diff --git a/pythonpath/synopse/dialog.py b/pythonpath/synopse/dialog.py new file mode 100644 index 0000000..8822c33 --- /dev/null +++ b/pythonpath/synopse/dialog.py @@ -0,0 +1,241 @@ +# -*- coding: utf-8 -*- +"""Optionsdialoge, programmatisch ueber com.sun.star.awt aufgebaut.""" + +import sys + +import uno + +from synopse.builder import Options +from synopse.i18n import t + + +def message(ctx, text, title=None): + if title is None: + title = t("doc.title") + try: + toolkit = ctx.ServiceManager.createInstanceWithContext( + "com.sun.star.awt.Toolkit", ctx) + box = toolkit.createMessageBox( + None, + uno.Enum("com.sun.star.awt.MessageBoxType", "INFOBOX"), + 1, # BUTTONS_OK + title, text) + box.execute() + box.dispose() + except Exception: + # Ohne Dialogfenster bleibt nur die Konsole + sys.stderr.write("%s: %s\n" % (title, text)) + + +def _add(model, kind, name, props): + ctrl = model.createInstance("com.sun.star.awt.UnoControl%sModel" % kind) + for key, value in props.items(): + setattr(ctrl, key, value) + model.insertByName(name, ctrl) + return ctrl + + +def _dialog(ctx, model): + dlg = ctx.ServiceManager.createInstanceWithContext( + "com.sun.star.awt.UnoControlDialog", ctx) + dlg.setModel(model) + toolkit = ctx.ServiceManager.createInstanceWithContext( + "com.sun.star.awt.Toolkit", ctx) + dlg.createPeer(toolkit, None) + return dlg + + +def _buttons(model, y): + _add(model, "Button", "btnOk", + dict(PositionX=128, PositionY=y, Width=54, Height=16, + Label=t("dlg.ok"), PushButtonType=1, DefaultButton=True)) + _add(model, "Button", "btnCancel", + dict(PositionX=186, PositionY=y, Width=54, Height=16, + Label=t("dlg.cancel"), PushButtonType=2)) + + +def show_options(ctx, edit_authors, note_authors): + """Optionen fuer die Synopse aus der Aenderungsverfolgung.""" + people = sorted(edit_authors | note_authors) + count = len(people) + names = ", ".join(people) or t("dlg.people.none") + if len(names) > 52: + names = names[:49] + "\u2026" + + model = ctx.ServiceManager.createInstanceWithContext( + "com.sun.star.awt.UnoControlDialogModel", ctx) + model.setPropertyValues( + ("PositionX", "PositionY", "Width", "Height", "Title"), + (120, 120, 248, 264, t("dlg.title"))) + + _add(model, "FixedText", "lblPeople", + dict(PositionX=8, PositionY=6, Width=232, Height=10, + Label=t("dlg.people", count, names))) + _add(model, "FixedLine", "sep0", + dict(PositionX=8, PositionY=18, Width=232, Height=6, + Label=t("dlg.group.mode"))) + + _add(model, "RadioButton", "optCum", + dict(PositionX=14, PositionY=30, Width=226, Height=12, State=1, + Label=t("dlg.mode.cumulative"))) + _add(model, "RadioButton", "optStack", + dict(PositionX=14, PositionY=43, Width=226, Height=12, + Label=t("dlg.mode.stacked"))) + _add(model, "RadioButton", "optAuthor", + dict(PositionX=14, PositionY=56, Width=226, Height=12, + Label=t("dlg.mode.separate", count))) + _add(model, "FixedText", "hint", + dict(PositionX=26, PositionY=69, Width=214, Height=18, MultiLine=True, + Label=t("dlg.mode.hint"))) + + _add(model, "FixedLine", "sep1", + dict(PositionX=8, PositionY=92, Width=232, Height=6, + Label=t("dlg.group.content"))) + _add(model, "CheckBox", "chkDel", + dict(PositionX=8, PositionY=104, Width=232, Height=12, State=1, + Label=t("dlg.opt.deletions"))) + _add(model, "CheckBox", "chkElide", + dict(PositionX=8, PositionY=118, Width=232, Height=12, State=1, + Label=t("dlg.opt.elide"))) + _add(model, "CheckBox", "chkFinal", + dict(PositionX=8, PositionY=132, Width=232, Height=12, State=1, + Label=t("dlg.opt.final"))) + _add(model, "CheckBox", "chkNotes", + dict(PositionX=8, PositionY=146, Width=232, Height=12, State=1, + Label=t("dlg.opt.notes"))) + _add(model, "CheckBox", "chkQuote", + dict(PositionX=20, PositionY=160, Width=220, Height=12, State=1, + Label=t("dlg.opt.quote"))) + _add(model, "CheckBox", "chkOnly", + dict(PositionX=8, PositionY=178, Width=232, Height=12, State=0, + Label=t("dlg.opt.onlychanged"))) + + _add(model, "FixedLine", "sep2", + dict(PositionX=8, PositionY=194, Width=232, Height=6, + Label=t("dlg.group.target"))) + _add(model, "CheckBox", "chkNum", + dict(PositionX=8, PositionY=206, Width=110, Height=12, State=1, + Label=t("dlg.opt.numbering"))) + _add(model, "CheckBox", "chkLand", + dict(PositionX=124, PositionY=206, Width=116, Height=12, State=1, + Label=t("dlg.opt.landscape"))) + + _buttons(model, 238) + + dlg = _dialog(ctx, model) + try: + if dlg.execute() != 1: + return None + opts = Options() + if model.getByName("optAuthor").State: + opts.mode = "per_author" + elif model.getByName("optStack").State: + opts.mode = "stacked" + else: + opts.mode = "cumulative" + opts.show_deletions = bool(model.getByName("chkDel").State) + opts.elide = bool(model.getByName("chkElide").State) + opts.show_final = bool(model.getByName("chkFinal").State) + opts.show_notes = bool(model.getByName("chkNotes").State) + opts.show_quotes = bool(model.getByName("chkQuote").State) + opts.only_changed = bool(model.getByName("chkOnly").State) + opts.numbering = bool(model.getByName("chkNum").State) + opts.landscape = bool(model.getByName("chkLand").State) + return opts + finally: + dlg.dispose() + + +def show_compare_options(ctx, docs, current=None): + """Auswahl der beiden Dokumente. Liefert (quelle, ziel, options).""" + from synopse.compare import CompareOptions, document_title + + titles = [document_title(d) for d in docs] + + # Vorauswahl: aktuelles Dokument als Endfassung, das andere als Quelle + target_idx = docs.index(current) if current in docs else len(docs) - 1 + source_idx = 0 if target_idx != 0 else 1 + + model = ctx.ServiceManager.createInstanceWithContext( + "com.sun.star.awt.UnoControlDialogModel", ctx) + model.setPropertyValues( + ("PositionX", "PositionY", "Width", "Height", "Title"), + (120, 120, 248, 258, t("cmp.title"))) + + _add(model, "FixedLine", "sep0", + dict(PositionX=8, PositionY=6, Width=232, Height=6, + Label=t("cmp.group.docs"))) + _add(model, "FixedText", "lblSrc", + dict(PositionX=8, PositionY=18, Width=232, Height=10, + Label=t("cmp.source"))) + _add(model, "ListBox", "lstSrc", + dict(PositionX=8, PositionY=29, Width=232, Height=12, Dropdown=True, + StringItemList=tuple(titles), SelectedItems=(source_idx,))) + _add(model, "FixedText", "lblTgt", + dict(PositionX=8, PositionY=46, Width=232, Height=10, + Label=t("cmp.target"))) + _add(model, "ListBox", "lstTgt", + dict(PositionX=8, PositionY=57, Width=232, Height=12, Dropdown=True, + StringItemList=tuple(titles), SelectedItems=(target_idx,))) + + _add(model, "FixedLine", "sep1", + dict(PositionX=8, PositionY=76, Width=232, Height=6, + Label=t("cmp.group.comments"))) + _add(model, "RadioButton", "optCol", + dict(PositionX=14, PositionY=88, Width=226, Height=12, State=1, + Label=t("cmp.comments.column"))) + _add(model, "RadioButton", "optInline", + dict(PositionX=14, PositionY=101, Width=226, Height=12, + Label=t("cmp.comments.inline"))) + _add(model, "RadioButton", "optNone", + dict(PositionX=14, PositionY=114, Width=226, Height=12, + Label=t("cmp.comments.none"))) + _add(model, "FixedText", "hint", + dict(PositionX=26, PositionY=127, Width=214, Height=18, MultiLine=True, + Label=t("cmp.comments.hint"))) + + _add(model, "FixedLine", "sep2", + dict(PositionX=8, PositionY=150, Width=232, Height=6, + Label=t("cmp.group.display"))) + _add(model, "CheckBox", "chkHi", + dict(PositionX=8, PositionY=162, Width=232, Height=12, State=1, + Label=t("cmp.opt.highlight"))) + _add(model, "CheckBox", "chkQuote", + dict(PositionX=8, PositionY=176, Width=232, Height=12, State=1, + Label=t("cmp.opt.quote"))) + _add(model, "CheckBox", "chkOnly", + dict(PositionX=8, PositionY=190, Width=232, Height=12, State=0, + Label=t("cmp.opt.onlychanged"))) + _add(model, "CheckBox", "chkNum", + dict(PositionX=8, PositionY=204, Width=110, Height=12, State=1, + Label=t("dlg.opt.numbering"))) + _add(model, "CheckBox", "chkLand", + dict(PositionX=124, PositionY=204, Width=116, Height=12, State=1, + Label=t("dlg.opt.landscape"))) + + _buttons(model, 232) + + dlg = _dialog(ctx, model) + try: + if dlg.execute() != 1: + return None + src = model.getByName("lstSrc").SelectedItems + tgt = model.getByName("lstTgt").SelectedItems + if not src or not tgt or src[0] == tgt[0]: + message(ctx, t("msg.different")) + return None + opts = CompareOptions() + if model.getByName("optInline").State: + opts.comments = "inline" + elif model.getByName("optNone").State: + opts.comments = "none" + else: + opts.comments = "column" + opts.highlight = bool(model.getByName("chkHi").State) + opts.show_quotes = bool(model.getByName("chkQuote").State) + opts.only_changed = bool(model.getByName("chkOnly").State) + opts.numbering = bool(model.getByName("chkNum").State) + opts.landscape = bool(model.getByName("chkLand").State) + return docs[src[0]], docs[tgt[0]], opts + finally: + dlg.dispose() diff --git a/pythonpath/synopse/i18n.py b/pythonpath/synopse/i18n.py new file mode 100644 index 0000000..6ce7a0d --- /dev/null +++ b/pythonpath/synopse/i18n.py @@ -0,0 +1,256 @@ +# -*- coding: utf-8 -*- +"""Oberflaechentexte in Deutsch, Englisch und Franzoesisch. + +Die Sprache richtet sich nach der Oberflaechensprache von LibreOffice +(/org.openoffice.Setup/L10N/ooLocale). Ist sie nicht hinterlegt, wird +Englisch verwendet. +""" + +import os + +import uno + +FALLBACK = "en" + +TEXTS = { + "en": { + # -- Dialog: Synopse aus der Aenderungsverfolgung ----------------- + "dlg.title": "Create synopsis", + "dlg.people": "Contributors (%d): %s", + "dlg.people.none": "none found", + "dlg.group.mode": "Display of changes", + "dlg.mode.cumulative": "Cumulative \u2013 one column with the final version", + "dlg.mode.stacked": "Stacked \u2013 one column, contributors below each other", + "dlg.mode.separate": "Separate \u2013 one column per contributor (%d)", + "dlg.mode.hint": "With more than three contributors the separate " + "display quickly becomes too narrow.", + "dlg.group.content": "Content", + "dlg.opt.deletions": "Keep deleted text, struck through", + "dlg.opt.elide": "Shorten unchanged passages to the context", + "dlg.opt.final": "Also show the final version (stacked/separate)", + "dlg.opt.notes": "Include comments and annotations", + "dlg.opt.quote": "quoting the commented passage", + "dlg.opt.onlychanged": "Only changed or commented paragraphs", + "dlg.group.target": "Target document", + "dlg.opt.numbering": "Sequential number", + "dlg.opt.landscape": "Landscape", + "dlg.ok": "OK", + "dlg.cancel": "Cancel", + + # -- Dialog: Dokumentvergleich ------------------------------------ + "cmp.title": "Compare two documents", + "cmp.group.docs": "Documents", + "cmp.source": "Source version (left column):", + "cmp.target": "Final version (middle column):", + "cmp.group.comments": "Comments in the final version", + "cmp.comments.column": "In a separate column on the right", + "cmp.comments.inline": "Below the passage in the middle column", + "cmp.comments.none": "Do not include", + "cmp.comments.hint": "If the final version contains no comments, " + "the third column is omitted automatically.", + "cmp.group.display": "Display", + "cmp.opt.highlight": "Highlight differences word by word", + "cmp.opt.quote": "Quote the commented passage", + "cmp.opt.onlychanged": "Only include differing paragraphs", + + # -- Tabelle ------------------------------------------------------- + "col.number": "No.", + "col.original": "Original version", + "col.final": "Final version", + "col.changes": "Changes", + "col.comments": "Comments", + "doc.title": "Synopsis", + "doc.title.named": "Synopsis: %s", + "doc.title.compare": "Synopsis: %s \u2192 %s", + "text.removed": "deleted without replacement", + "text.noteonly": " \u2013 comment only", + "text.resolved": " (resolved)", + "text.unknown": "(unknown)", + + # -- Meldungen ----------------------------------------------------- + "msg.nodoc": "Please bring a Writer document to the front first.", + "msg.nochanges": "The document contains neither tracked changes " + "nor comments.", + "msg.noparagraphs": "No changed paragraphs were found.", + "msg.twodocs": "At least two Writer documents must be open " + "for a comparison.", + "msg.different": "Please select two different documents.", + "msg.identical": "The two documents are identical in content.", + "msg.error.build": "Error while creating the synopsis:\n\n%s\n\n%s", + "msg.error.compare": "Error during comparison:\n\n%s\n\n%s", + }, + + "de": { + "dlg.title": "Synopse erzeugen", + "dlg.people": "Beteiligte (%d): %s", + "dlg.people.none": "keine gefunden", + "dlg.group.mode": "Darstellung der \u00c4nderungen", + "dlg.mode.cumulative": "Kumuliert \u2013 eine Spalte mit der Endfassung", + "dlg.mode.stacked": "Gestapelt \u2013 eine Spalte, Personen untereinander", + "dlg.mode.separate": "Getrennt \u2013 je Person eine eigene Spalte (%d)", + "dlg.mode.hint": "Bei mehr als drei Beteiligten wird die getrennte " + "Darstellung schnell zu schmal.", + "dlg.group.content": "Inhalt", + "dlg.opt.deletions": "Gel\u00f6schten Text durchgestrichen mitf\u00fchren", + "dlg.opt.elide": "Unver\u00e4nderte Passagen auf den Kontext k\u00fcrzen", + "dlg.opt.final": "Endfassung zus\u00e4tzlich ausweisen (gestapelt/getrennt)", + "dlg.opt.notes": "Anmerkungen und Kommentare \u00fcbernehmen", + "dlg.opt.quote": "dabei kommentierte Textstelle zitieren", + "dlg.opt.onlychanged": "Nur ge\u00e4nderte oder kommentierte Abs\u00e4tze", + "dlg.group.target": "Zieldokument", + "dlg.opt.numbering": "Laufende Nummer", + "dlg.opt.landscape": "Querformat", + "dlg.ok": "OK", + "dlg.cancel": "Abbrechen", + + "cmp.title": "Zwei Dokumente vergleichen", + "cmp.group.docs": "Dokumente", + "cmp.source": "Quellfassung (linke Spalte):", + "cmp.target": "Endfassung (mittlere Spalte):", + "cmp.group.comments": "Kommentare der Endfassung", + "cmp.comments.column": "In einer eigenen Spalte rechts", + "cmp.comments.inline": "Unter der Textstelle in der mittleren Spalte", + "cmp.comments.none": "Nicht \u00fcbernehmen", + "cmp.comments.hint": "Enth\u00e4lt die Endfassung keine Kommentare, " + "entf\u00e4llt die dritte Spalte automatisch.", + "cmp.group.display": "Darstellung", + "cmp.opt.highlight": "Unterschiede wortweise hervorheben", + "cmp.opt.quote": "Kommentierte Textstelle zitieren", + "cmp.opt.onlychanged": "Nur abweichende Abs\u00e4tze aufnehmen", + + "col.number": "Nr.", + "col.original": "Ursprungsfassung", + "col.final": "Endfassung", + "col.changes": "\u00c4nderungen", + "col.comments": "Kommentare", + "doc.title": "Synopse", + "doc.title.named": "Synopse: %s", + "doc.title.compare": "Synopse: %s \u2192 %s", + "text.removed": "ersatzlos gestrichen", + "text.noteonly": " \u2013 nur Anmerkung", + "text.resolved": " (erledigt)", + "text.unknown": "(unbekannt)", + + "msg.nodoc": "Bitte zuerst ein Writer-Dokument in den Vordergrund holen.", + "msg.nochanges": "Das Dokument enth\u00e4lt weder nachverfolgte " + "\u00c4nderungen noch Anmerkungen.", + "msg.noparagraphs": "Es wurden keine ge\u00e4nderten Abs\u00e4tze gefunden.", + "msg.twodocs": "F\u00fcr den Vergleich m\u00fcssen mindestens zwei " + "Writer-Dokumente ge\u00f6ffnet sein.", + "msg.different": "Bitte zwei verschiedene Dokumente ausw\u00e4hlen.", + "msg.identical": "Die beiden Dokumente sind inhaltlich identisch.", + "msg.error.build": "Fehler beim Erzeugen der Synopse:\n\n%s\n\n%s", + "msg.error.compare": "Fehler beim Vergleich:\n\n%s\n\n%s", + }, + + "fr": { + "dlg.title": "Cr\u00e9er une synopse", + "dlg.people": "Participants (%d) : %s", + "dlg.people.none": "aucun trouv\u00e9", + "dlg.group.mode": "Pr\u00e9sentation des modifications", + "dlg.mode.cumulative": "Cumul\u00e9e \u2013 une colonne avec la version finale", + "dlg.mode.stacked": "Empil\u00e9e \u2013 une colonne, participants l\u2019un sous l\u2019autre", + "dlg.mode.separate": "S\u00e9par\u00e9e \u2013 une colonne par participant (%d)", + "dlg.mode.hint": "Au-del\u00e0 de trois participants, la pr\u00e9sentation " + "s\u00e9par\u00e9e devient vite trop \u00e9troite.", + "dlg.group.content": "Contenu", + "dlg.opt.deletions": "Conserver le texte supprim\u00e9, barr\u00e9", + "dlg.opt.elide": "R\u00e9duire les passages inchang\u00e9s au contexte", + "dlg.opt.final": "Afficher aussi la version finale (empil\u00e9e/s\u00e9par\u00e9e)", + "dlg.opt.notes": "Reprendre les commentaires et annotations", + "dlg.opt.quote": "en citant le passage comment\u00e9", + "dlg.opt.onlychanged": "Uniquement les paragraphes modifi\u00e9s ou comment\u00e9s", + "dlg.group.target": "Document cible", + "dlg.opt.numbering": "Num\u00e9rotation", + "dlg.opt.landscape": "Paysage", + "dlg.ok": "OK", + "dlg.cancel": "Annuler", + + "cmp.title": "Comparer deux documents", + "cmp.group.docs": "Documents", + "cmp.source": "Version source (colonne de gauche) :", + "cmp.target": "Version finale (colonne du milieu) :", + "cmp.group.comments": "Commentaires de la version finale", + "cmp.comments.column": "Dans une colonne distincte \u00e0 droite", + "cmp.comments.inline": "Sous le passage dans la colonne du milieu", + "cmp.comments.none": "Ne pas reprendre", + "cmp.comments.hint": "Si la version finale ne contient aucun " + "commentaire, la troisi\u00e8me colonne est " + "supprim\u00e9e automatiquement.", + "cmp.group.display": "Pr\u00e9sentation", + "cmp.opt.highlight": "Mettre en \u00e9vidence les diff\u00e9rences mot \u00e0 mot", + "cmp.opt.quote": "Citer le passage comment\u00e9", + "cmp.opt.onlychanged": "N\u2019inclure que les paragraphes divergents", + + "col.number": "N\u00b0", + "col.original": "Version initiale", + "col.final": "Version finale", + "col.changes": "Modifications", + "col.comments": "Commentaires", + "doc.title": "Synopse", + "doc.title.named": "Synopse : %s", + "doc.title.compare": "Synopse : %s \u2192 %s", + "text.removed": "supprim\u00e9 sans remplacement", + "text.noteonly": " \u2013 commentaire seulement", + "text.resolved": " (r\u00e9solu)", + "text.unknown": "(inconnu)", + + "msg.nodoc": "Veuillez d\u2019abord placer un document Writer au " + "premier plan.", + "msg.nochanges": "Le document ne contient ni modifications suivies " + "ni commentaires.", + "msg.noparagraphs": "Aucun paragraphe modifi\u00e9 n\u2019a \u00e9t\u00e9 trouv\u00e9.", + "msg.twodocs": "Au moins deux documents Writer doivent \u00eatre " + "ouverts pour une comparaison.", + "msg.different": "Veuillez s\u00e9lectionner deux documents diff\u00e9rents.", + "msg.identical": "Les deux documents ont un contenu identique.", + "msg.error.build": "Erreur lors de la cr\u00e9ation de la synopse :" + "\n\n%s\n\n%s", + "msg.error.compare": "Erreur lors de la comparaison :\n\n%s\n\n%s", + }, +} + +_language = FALLBACK + + +def _office_locale(ctx): + """Oberflaechensprache von LibreOffice, z. B. 'de-DE'.""" + try: + provider = ctx.ServiceManager.createInstanceWithContext( + "com.sun.star.configuration.ConfigurationProvider", ctx) + argument = uno.createUnoStruct("com.sun.star.beans.PropertyValue") + argument.Name = "nodepath" + argument.Value = "/org.openoffice.Setup/L10N" + node = provider.createInstanceWithArguments( + "com.sun.star.configuration.ConfigurationAccess", (argument,)) + return node.getByName("ooLocale") or "" + except Exception: + return "" + + +def set_language(ctx=None): + """Sprache bestimmen. Ist sie nicht hinterlegt, bleibt es bei Englisch.""" + global _language + tag = _office_locale(ctx) if ctx is not None else "" + if not tag: + tag = os.environ.get("LANGUAGE") or os.environ.get("LANG") or "" + primary = tag.replace("_", "-").split("-")[0].split(".")[0].lower() + _language = primary if primary in TEXTS else FALLBACK + return _language + + +def language(): + return _language + + +def t(key, *args): + """Uebersetzten Text liefern; fehlt er, greift Englisch.""" + value = TEXTS.get(_language, {}).get(key) + if value is None: + value = TEXTS[FALLBACK].get(key, key) + if args: + try: + value = value % args + except Exception: + pass + return value diff --git a/pythonpath/synopse/redline_model.py b/pythonpath/synopse/redline_model.py new file mode 100644 index 0000000..5a6d470 --- /dev/null +++ b/pythonpath/synopse/redline_model.py @@ -0,0 +1,402 @@ +# -*- coding: utf-8 -*- +""" +Liest ein Writer-Dokument mit Aenderungsverfolgung und baut daraus ein +Modell aus Bloecken auf. + +Ein Block entspricht einer Zeile der spaeteren Synopse und enthaelt + * Segmente - Textstuecke samt der Redlines, in denen sie liegen + * Notes - Anmerkungen, die in diesem Bereich verankert sind + +Grundidee: Der Text wird einmal linear durchlaufen. Waehrend des Durchlaufs +wird ein Stack der gerade offenen Redlines gefuehrt. Jedes Textstueck wird +zusammen mit diesem Stack gespeichert. Aus dem Stack laesst sich anschliessend +jede beliebige Fassung rekonstruieren, ohne das Dokument erneut anzufassen. +""" + +import uno + + +def const(name, default): + """UNO-Konstante aufloesen, ohne beim Import zu scheitern. + + getConstantByName wirft eine RuntimeException, wenn der Typmanager den + Namen nicht kennt. Geschieht das beim Laden des Moduls, bricht der + Python-Loader die Registrierung der Erweiterung ab. Deshalb wird hier + immer auf den dokumentierten Zahlenwert zurueckgefallen. + """ + try: + return uno.getConstantByName(name) + except Exception: + return default + + +def const_opt(name): + """Wie const(), liefert aber None statt eines Ersatzwertes.""" + try: + return uno.getConstantByName(name) + except Exception: + return None + + +def enum(type_name, value): + """UNO-Enum aufloesen; liefert None, wenn der Typ unbekannt ist.""" + try: + return uno.Enum(type_name, value) + except Exception: + return None + + +RD_NAME = "com.sun.star.text.RedlineDisplayType.INSERTED_AND_REMOVED" + +# Ergebnis der einmaligen empirischen Bestimmung (siehe _display_all_value) +_display_all_cache = None + +INSERT = "Insert" +DELETE = "Delete" + +UNKNOWN_AUTHOR = "\x00unknown" # sprachneutral, Anzeige ueber i18n + +QUOTE_MAX = 80 + +# Beschreibungen, die LibreOffice selbst in RedlineComment eintraegt. Sie +# sind keine Anmerkung des Bearbeiters und wuerden die Synopse zumuellen. +AUTO_COMMENTS = frozenset(( + "kommentar hinzugef\u00fcgt", "kommentar ge\u00e4ndert", + "kommentar gel\u00f6scht", "kommentar aufgel\u00f6st", + "comment added", "comment changed", "comment deleted", "comment resolved", + "attribut ge\u00e4ndert", "attribute changed", + "absatzformat ge\u00e4ndert", "paragraph formatting changed", +)) + + +class Segment(object): + """Ein Textstueck samt der Redlines, innerhalb derer es liegt.""" + + __slots__ = ("text", "stack") + + def __init__(self, text, stack): + self.text = text + self.stack = stack # tuple((rtype, author), ...) + + @property + def inserted_by(self): + return [a for (t, a) in self.stack if t == INSERT] + + @property + def deleted_by(self): + return [a for (t, a) in self.stack if t == DELETE] + + @property + def changed(self): + return bool(self.stack) + + # -- Fassungen -------------------------------------------------------- + + def in_original(self): + """Stand das Stueck in der Ursprungsfassung?""" + return not self.inserted_by + + def in_final(self): + """Steht das Stueck in der Endfassung (alle Aenderungen uebernommen)?""" + return not self.deleted_by + + def in_version_of(self, author): + """Fassung, in der ausschliesslich die Aenderungen von *author* gelten. + + - Von jemand anderem eingefuegt -> nicht enthalten + - Von *author* eingefuegt -> enthalten + - Von *author* geloescht -> nicht enthalten + - Von jemand anderem geloescht -> bleibt stehen + """ + if any(a != author for a in self.inserted_by): + return False + if author in self.deleted_by: + return False + return True + + +class Note(object): + """Eine Anmerkung zu einer Stelle des Textes.""" + + __slots__ = ("author", "content", "quote", "kind", "resolved", "when") + + def __init__(self, author, content, kind="annotation"): + self.author = author or UNKNOWN_AUTHOR + self.content = (content or "").strip() + self.quote = "" + self.kind = kind # "annotation" | "redline" + self.resolved = False + self.when = None + + def short_quote(self): + q = " ".join(self.quote.split()) + if len(q) > QUOTE_MAX: + q = q[:QUOTE_MAX - 1].rstrip() + "\u2026" + return q + + +class Block(object): + """Eine Zeile der Synopse.""" + + __slots__ = ("segments", "notes") + + def __init__(self): + self.segments = [] + self.notes = [] + + @property + def changed(self): + return any(s.changed for s in self.segments) or bool(self.notes) + + def notes_of(self, author): + return [n for n in self.notes if n.author == author] + + +def _pop(stack, ident): + """Entfernt die Redline mit der gegebenen Kennung vom Stack.""" + for i in range(len(stack) - 1, -1, -1): + if stack[i][0] == ident: + del stack[i] + return + if stack: + stack.pop() + + +def _iter_paragraphs(text): + """Absaetze eines XText, inklusive der Absaetze in Tabellenzellen.""" + enum = text.createEnumeration() + while enum.hasMoreElements(): + el = enum.nextElement() + if el.supportsService("com.sun.star.text.Paragraph"): + yield el + elif el.supportsService("com.sun.star.text.TextTable"): + for name in el.getCellNames(): + for para in _iter_paragraphs(el.getCellByName(name)): + yield para + + +def _portion_text(portion): + try: + return portion.getString() + except Exception: + return "" + + +def _get(obj, name, default=None): + try: + return getattr(obj, name) + except Exception: + return default + + +def _is_annotation(field): + """Ist das Textfeld ein Kommentar?""" + try: + if field.supportsService("com.sun.star.text.textfield.Annotation"): + return True + except Exception: + pass + # Aeltere Versionen melden den Dienst nicht zuverlaessig zurueck + try: + return _get(field, "Content") is not None and \ + _get(field, "Author") is not None + except Exception: + return False + + +def _field_of(portion): + """Das Feld eines Portions. Der Name unterscheidet sich je nach Version.""" + for name in ("TextField", "Annotation"): + field = _get(portion, name) + if field is not None: + return field + return None + + +def _annotation(portion): + """Liefert (Note, Schluessel) zu einem Kommentar-Portion.""" + field = _field_of(portion) + if field is None or not _is_annotation(field): + return None, None + content = (_get(field, "Content", "") or "").strip() + if not content: + return None, None # leerer Kommentar sagt nichts aus + note = Note(_get(field, "Author", ""), content, kind="annotation") + note.resolved = bool(_get(field, "Resolved", False)) + note.when = _get(field, "DateTimeValue") + key = _get(field, "Name", "") or ("%s\x00%s" % (note.author, content)) + return note, key + + +def _display_all_value(doc): + """Wert von RedlineDisplayType.INSERTED_AND_REMOVED ermitteln. + + Bevorzugt wird der Typmanager. Antwortet er nicht, wird der Wert + gemessen statt geraten: nur in der Anzeigeart mit Einfuegungen *und* + Loeschungen enthaelt der Dokumenttext beides und ist damit am laengsten. + Ein geratener Wert waere gefaehrlich - die Reihenfolge der Konstanten + ist nicht ueber alle Versionen hinweg gleich, und ein Griff daneben + blendet stillschweigend die Einfuegungen aus. + """ + global _display_all_cache + + value = const_opt(RD_NAME) + if value is not None: + return value + if _display_all_cache is not None: + return _display_all_cache + + best, longest, shortest = None, -1, None + for candidate in (0, 1, 2, 3): + try: + doc.setPropertyValue("RedlineDisplayType", candidate) + length = len(doc.getText().getString()) + except Exception: + continue + if length > longest: + best, longest = candidate, length + if shortest is None or length < shortest: + shortest = length + + if best is not None and shortest is not None and longest > shortest: + # Nur aussagekraeftig, wenn das Dokument ueberhaupt Aenderungen hat + _display_all_cache = best + return best + + +def _show_all_changes(doc): + """Eingefuegten und geloeschten Text sichtbar machen. + + Nur in dieser Anzeigeart liefert die Portion-Enumeration auch den + geloeschten Text. Liefert den zuvor eingestellten Wert zurueck. + """ + try: + previous = doc.getPropertyValue("RedlineDisplayType") + except Exception: + previous = None + + value = _display_all_value(doc) + if value is not None: + try: + doc.setPropertyValue("RedlineDisplayType", value) + except Exception: + pass + return previous + + +def _restore_display(doc, previous, was_modified): + """Anzeige und Aenderungsstatus des Quelldokuments wiederherstellen.""" + if previous is not None: + try: + doc.setPropertyValue("RedlineDisplayType", previous) + except Exception: + pass + if was_modified is False: + try: + doc.setModified(False) + except Exception: + pass + + +def extract_blocks(doc): + """Liefert (blocks, edit_authors, note_authors). + + Das Quelldokument wird dabei nicht bleibend veraendert: Anzeigeart und + Aenderungsstatus werden am Ende wiederhergestellt. + """ + try: + was_modified = bool(doc.isModified()) + except Exception: + was_modified = None + previous_display = _show_all_changes(doc) + try: + return _extract(doc) + finally: + _restore_display(doc, previous_display, was_modified) + + +def _extract(doc): + stack = [] # [(identifier, rtype, author)] + edit_authors = set() + note_authors = set() + seen_comments = set() # Redline-Kommentare nur einmal uebernehmen + seen_notes = set() # Kommentare nur einmal uebernehmen + open_notes = {} # Schluessel -> Note mit noch offenem Textbereich + blocks = [] + current = Block() + + for para in _iter_paragraphs(doc.getText()): + pen = para.createEnumeration() + while pen.hasMoreElements(): + portion = pen.nextElement() + ptype = portion.TextPortionType + + if ptype == "Redline": + rtype = portion.RedlineType + if rtype not in (INSERT, DELETE): + continue # Format/Attribute: ignoriert + author = portion.RedlineAuthor or UNKNOWN_AUTHOR + ident = portion.RedlineIdentifier + edit_authors.add(author) + + comment = (_get(portion, "RedlineComment", "") or "").strip() + if (comment and ident not in seen_comments + and comment.lower() not in AUTO_COMMENTS): + seen_comments.add(ident) + note = Note(author, comment, kind="redline") + current.notes.append(note) + note_authors.add(note.author) + + if bool(_get(portion, "IsCollapsed", False)): + continue # Laenge 0 + if bool(_get(portion, "IsStart", True)): + stack.append((ident, rtype, author)) + else: + _pop(stack, ident) + + elif ptype == "Annotation": + note, key = _annotation(portion) + if note is not None and key not in seen_notes: + seen_notes.add(key) + current.notes.append(note) + note_authors.add(note.author) + open_notes[key] = note # Ende folgt ggf. spaeter + + elif ptype == "AnnotationEnd": + _note, key = _annotation(portion) + open_notes.pop(key, None) + + else: + if ptype == "TextField": + # Je nach Version meldet Writer einen Kommentar nicht als + # "Annotation", sondern als gewoehnliches Textfeld. + note, key = _annotation(portion) + if note is not None: + if key not in seen_notes: + seen_notes.add(key) + current.notes.append(note) + note_authors.add(note.author) + continue + s = _portion_text(portion) + if s: + current.segments.append( + Segment(s, tuple((t, a) for (_i, t, a) in stack))) + for note in open_notes.values(): + note.quote += s + + mark = tuple((t, a) for (_i, t, a) in stack) + if mark: + # Absatzmarke liegt innerhalb einer Aenderung -> kein Zeilenumbruch + # in der Synopse, sondern ein Segment, das nur in der jeweils + # passenden Fassung als Umbruch erscheint. + current.segments.append(Segment("\n", mark)) + else: + blocks.append(current) + current = Block() + for note in open_notes.values(): + note.quote += " " + + if current.segments or current.notes: + blocks.append(current) + + return blocks, edit_authors, note_authors diff --git a/registration/license-de.txt b/registration/license-de.txt new file mode 100644 index 0000000..4edf41c --- /dev/null +++ b/registration/license-de.txt @@ -0,0 +1,25 @@ +MIT-Lizenz + +Copyright (c) 2026 Marco Morath + +Hiermit wird unentgeltlich jeder Person, die eine Kopie dieser Software und +der zugehörigen Dokumentationsdateien (die „Software") erhält, die Erlaubnis +erteilt, sie uneingeschränkt zu nutzen, einschließlich und ohne Ausnahme des +Rechts, sie zu verwenden, zu kopieren, zu verändern, zusammenzufügen, zu +veröffentlichen, zu verbreiten, zu unterlizenzieren und/oder zu verkaufen, +und Personen, denen diese Software überlassen wird, diese Rechte zu +verschaffen, unter den folgenden Bedingungen: + +Der obige Urheberrechtsvermerk und dieser Erlaubnisvermerk sind in allen +Kopien oder wesentlichen Teilen der Software beizulegen. + +DIE SOFTWARE WIRD OHNE JEDE AUSDRÜCKLICHE ODER STILLSCHWEIGENDE GARANTIE +BEREITGESTELLT, EINSCHLIESSLICH DER GARANTIE ZUR EIGNUNG FÜR EINEN BESTIMMTEN +ZWECK ODER EINES BESTIMMTEN ZWECKES UND DER NICHTVERLETZUNG VON RECHTEN +DRITTER. IN KEINEM FALL SIND DIE AUTOREN ODER RECHTEINHABER FÜR ANSPRÜCHE, +SCHÄDEN ODER ANDERE VERPFLICHTUNGEN HAFTBAR, OB INFOLGE EINES VERTRAGES, +EINER UNERLAUBTEN HANDLUNG ODER ANDERWEITIG, DIE SICH AUS DER SOFTWARE ODER +DEREN NUTZUNG ERGEBEN. + +Hinweis: Diese Übersetzung dient allein dem Verständnis. Rechtlich maßgeblich +ist ausschließlich der englische Originaltext der MIT-Lizenz. diff --git a/registration/license-en.txt b/registration/license-en.txt new file mode 100644 index 0000000..a7d966d --- /dev/null +++ b/registration/license-en.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Marco Morath + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/registration/license-fr.txt b/registration/license-fr.txt new file mode 100644 index 0000000..030c02d --- /dev/null +++ b/registration/license-fr.txt @@ -0,0 +1,25 @@ +Licence MIT + +Copyright (c) 2026 Marco Morath + +Par la présente, une autorisation est accordée gratuitement à toute personne +obtenant une copie de ce logiciel et des fichiers de documentation associés +(le « Logiciel »), de commercialiser le Logiciel sans restriction, y compris +sans limitation les droits d'utiliser, de copier, de modifier, de fusionner, +de publier, de distribuer, de sous-licencier et/ou de vendre des copies du +Logiciel, et d'autoriser les personnes auxquelles le Logiciel est fourni à le +faire, sous réserve des conditions suivantes : + +L'avis de droit d'auteur ci-dessus et le présent avis d'autorisation doivent +être inclus dans toutes les copies ou parties substantielles du Logiciel. + +LE LOGICIEL EST FOURNI « EN L'ÉTAT », SANS GARANTIE D'AUCUNE SORTE, EXPRESSE +OU IMPLICITE, Y COMPRIS MAIS SANS S'Y LIMITER LES GARANTIES DE QUALITÉ +MARCHANDE, D'ADÉQUATION À UN USAGE PARTICULIER ET D'ABSENCE DE CONTREFAÇON. +EN AUCUN CAS LES AUTEURS OU TITULAIRES DU DROIT D'AUTEUR NE SERONT +RESPONSABLES DE TOUTE RÉCLAMATION, DOMMAGE OU AUTRE RESPONSABILITÉ, QUE CE +SOIT DANS LE CADRE D'UN CONTRAT, D'UN DÉLIT OU AUTREMENT, DÉCOULANT DU +LOGICIEL OU DE SON UTILISATION. + +Remarque : cette traduction n'est fournie qu'à titre informatif. Seul le texte +anglais original de la licence MIT fait foi. diff --git a/synopse_handler.py b/synopse_handler.py new file mode 100644 index 0000000..49dbcde --- /dev/null +++ b/synopse_handler.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +"""Protocol Handler der Erweiterung 'Synopse'.""" + +import traceback + +import unohelper +from com.sun.star.frame import XDispatchProvider, XDispatch +from com.sun.star.lang import XServiceInfo, XInitialization + +from synopse.redline_model import extract_blocks +from synopse.builder import build +from synopse import compare as compare_mod +from synopse.dialog import show_options, show_compare_options, message +from synopse.i18n import set_language, t + +IMPL_NAME = "de.example.synopse.ProtocolHandler" +PROTOCOL = "de.example.synopse:" + + +def run(ctx): + set_language(ctx) + desktop = ctx.ServiceManager.createInstanceWithContext( + "com.sun.star.frame.Desktop", ctx) + doc = desktop.getCurrentComponent() + if doc is None or not doc.supportsService("com.sun.star.text.TextDocument"): + message(ctx, t("msg.nodoc")) + return + try: + _blocks, edit_authors, note_authors = extract_blocks(doc) + if not edit_authors and not note_authors: + message(ctx, t("msg.nochanges")) + return + opts = show_options(ctx, edit_authors, note_authors) + if opts is None: + return + build(ctx, doc, opts) + except Exception as exc: # pragma: no cover + message(ctx, t("msg.error.build", exc, traceback.format_exc())) + + +def run_compare(ctx): + set_language(ctx) + desktop = ctx.ServiceManager.createInstanceWithContext( + "com.sun.star.frame.Desktop", ctx) + docs = compare_mod.open_documents(ctx) + if len(docs) < 2: + message(ctx, t("msg.twodocs")) + return + try: + choice = show_compare_options(ctx, docs, desktop.getCurrentComponent()) + if choice is None: + return + source, target, opts = choice + compare_mod.build(ctx, source, target, opts) + except Exception as exc: # pragma: no cover + message(ctx, t("msg.error.compare", exc, traceback.format_exc())) + + +class SynopseHandler(unohelper.Base, XServiceInfo, XDispatchProvider, + XDispatch, XInitialization): + + def __init__(self, ctx): + self.ctx = ctx + self.frame = None + + # XInitialization + def initialize(self, args): + if args: + self.frame = args[0] + + # XServiceInfo + def getImplementationName(self): + return IMPL_NAME + + def supportsService(self, name): + return name == "com.sun.star.frame.ProtocolHandler" + + def getSupportedServiceNames(self): + return ("com.sun.star.frame.ProtocolHandler",) + + # XDispatchProvider + def queryDispatch(self, url, target_frame, search_flags): + if url.Protocol == PROTOCOL: + return self + return None + + def queryDispatches(self, requests): + return tuple(self.queryDispatch(r.FeatureURL, r.FrameName, + r.SearchFlags) for r in requests) + + # XDispatch + def dispatch(self, url, args): + if url.Protocol != PROTOCOL: + return + if url.Path == "run": + run(self.ctx) + elif url.Path == "compare": + run_compare(self.ctx) + + def addStatusListener(self, control, url): + pass + + def removeStatusListener(self, control, url): + pass + + +g_ImplementationHelper = unohelper.ImplementationHelper() +g_ImplementationHelper.addImplementation( + SynopseHandler, IMPL_NAME, ("com.sun.star.frame.ProtocolHandler",)) diff --git a/tools/make_icons.py b/tools/make_icons.py new file mode 100644 index 0000000..47a8094 --- /dev/null +++ b/tools/make_icons.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Erzeugt die Symbole der Erweiterung. + +Gezeichnet wird achtfach vergroessert und anschliessend heruntergerechnet - +bei 42x42 Pixeln ist das der einzige Weg zu sauberen Kanten. + +Aufruf: python3 tools/make_icons.py +""" + +import os + +from PIL import Image, ImageDraw + +SCALE = 8 +TARGET = 42 +BIG = TARGET * SCALE + +PAPER = (255, 255, 255, 255) +INK = (61, 56, 70, 255) # Rahmen und Trennlinie +TEXT = (154, 153, 150, 255) # unveraenderter Text +INSERT = (26, 95, 180, 255) # Einfuegung +DELETE = (165, 29, 45, 255) # Loeschung + +BLACK = (0, 0, 0, 255) +WHITE = (255, 255, 255, 255) + +# Zeilenraster: (y, Breite links, Breite rechts, Farbrolle rechts) +ROWS = ( + (0.22, 1.00, 1.00, "equal"), + (0.38, 1.00, 0.85, "insert"), + (0.54, 1.00, 1.00, "equal"), + (0.70, 1.00, 0.55, "delete"), +) + + +def draw(colors, border): + img = Image.new("RGBA", (BIG, BIG), (0, 0, 0, 0)) + pen = ImageDraw.Draw(img) + + margin = int(BIG * 0.09) + box = (margin, margin, BIG - margin, BIG - margin) + radius = int(BIG * 0.08) + pen.rounded_rectangle(box, radius=radius, fill=colors["paper"], + outline=colors["ink"], width=border) + + middle = BIG // 2 + pen.line([(middle, margin + border), (middle, BIG - margin - border)], + fill=colors["ink"], width=max(2, border // 2)) + + pad = int(BIG * 0.16) + left_start, left_end = pad, middle - int(BIG * 0.06) + right_start, right_end = middle + int(BIG * 0.06), BIG - pad + height = int(BIG * 0.075) + bar_radius = height // 2 + + for y_rel, w_left, w_right, role in ROWS: + y = int(BIG * y_rel) + span = left_end - left_start + pen.rounded_rectangle( + (left_start, y, left_start + int(span * w_left), y + height), + radius=bar_radius, fill=colors["text"]) + + span = right_end - right_start + pen.rounded_rectangle( + (right_start, y, right_start + int(span * w_right), y + height), + radius=bar_radius, fill=colors[role]) + + return img + + +def save(img, path, size): + os.makedirs(os.path.dirname(path), exist_ok=True) + img.resize((size, size), Image.LANCZOS).save(path, "PNG", optimize=True) + print("%s %dx%d" % (path, size, size)) + + +def main(): + here = os.path.dirname(os.path.abspath(__file__)) + root = os.path.dirname(here) + + normal = draw({"paper": PAPER, "ink": INK, "text": TEXT, + "equal": TEXT, "insert": INSERT, "delete": DELETE}, + border=int(BIG * 0.022)) + + # Hoher Kontrast: nur Schwarz und Weiss, kraeftigerer Rahmen. Die + # Unterscheidung der Zeilen traegt hier allein ueber die Laenge. + contrast = draw({"paper": WHITE, "ink": BLACK, "text": BLACK, + "equal": BLACK, "insert": BLACK, "delete": BLACK}, + border=int(BIG * 0.04)) + + save(normal, os.path.join(root, "icon", "synopse.png"), TARGET) + save(contrast, os.path.join(root, "icon", "synopse_hc.png"), TARGET) + save(normal, os.path.join(root, "icon", "synopse-256.png"), 256) + + +if __name__ == "__main__": + main()