Initial commit

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

View File

View File

@@ -0,0 +1,618 @@
# -*- coding: utf-8 -*-
"""Erzeugt aus dem Block-Modell ein neues Writer-Dokument mit Synopse-Tabelle.
Drei Darstellungsarten der Aenderungsseite:
cumulative - eine Spalte mit der Endfassung
per_author - je Person eine eigene Spalte
stacked - eine Spalte, darin die Fassungen der Personen untereinander
"""
import uno
from synopse.redline_model import (extract_blocks, const, enum,
UNKNOWN_AUTHOR)
from synopse.i18n import t
PARAGRAPH_BREAK = const("com.sun.star.text.ControlCharacter.PARAGRAPH_BREAK", 0)
UL_SINGLE = const("com.sun.star.awt.FontUnderline.SINGLE", 1)
UL_NONE = const("com.sun.star.awt.FontUnderline.NONE", 0)
SO_SINGLE = const("com.sun.star.awt.FontStrikeout.SINGLE", 1)
SO_NONE = const("com.sun.star.awt.FontStrikeout.NONE", 0)
ITALIC = enum("com.sun.star.awt.FontSlant", "ITALIC")
UPRIGHT = enum("com.sun.star.awt.FontSlant", "NONE")
COL_ORIGINAL = "\x00original"
COL_FINAL = "\x00final"
COL_STACKED = "\x00stacked"
COL_INDEX = "\x00index"
AUTHOR_COLORS = [0x1A5FB4, 0xA51D2D, 0x26A269, 0x9141AC,
0xC64600, 0x3D3846, 0x005F5F, 0x813D9C]
NEUTRAL = 0x000000
GREY = 0x707070
FINAL_COLOR = 0x5E5C64
HEADER_BG = 0xE0E0E0
NOTE_SIZE = 8.5
HEAD_SIZE = 9.0
BODY_SIZE = 10.0
ELLIPSIS = " \u2026 "
class Options(object):
mode = "cumulative" # "cumulative" | "per_author" | "stacked"
show_deletions = True # geloeschten Text durchgestrichen mitfuehren
show_notes = True # Anmerkungen uebernehmen
show_quotes = True # Bezugstext kommentierter Stellen zitieren
elide = True # unveraenderte Passagen kuerzen
show_final = True # Endfassung zusaetzlich ausweisen
elide_context = 40 # verbleibende Zeichen je Seite
only_changed = False # nur geaenderte Absaetze ausgeben
landscape = True
numbering = True
# -- Hilfsfunktionen ------------------------------------------------------
def _cell_name(col, row):
return "%s%d" % (chr(ord("A") + col), row + 1)
def _author_color(author, authors):
if isinstance(author, int):
return author # Vergleichsmodus: feste Farbe
try:
return AUTHOR_COLORS[sorted(authors).index(author) % len(AUTHOR_COLORS)]
except (ValueError, AttributeError, TypeError):
return NEUTRAL
def _author_label(author):
"""Anzeigename eines Bearbeiters."""
return t("text.unknown") if author == UNKNOWN_AUTHOR else author
def _tint(color, factor=0.90):
"""Heller Farbton derselben Farbe als Hintergrund fuer Anmerkungen."""
out = 0
for shift in (16, 8, 0):
channel = (color >> shift) & 0xFF
channel = int(channel + (255 - channel) * factor)
out |= min(channel, 255) << shift
return out
def _line_struct():
for name in ("com.sun.star.table.BorderLine2",
"com.sun.star.table.BorderLine"):
try:
return uno.createUnoStruct(name)
except Exception:
continue
return None
def _border(color, width=50):
line = _line_struct()
if line is None:
return None
line.Color = color
line.LineWidth = width # 1/100 mm
line.OuterLineWidth = width
line.InnerLineWidth = 0
line.LineDistance = 0
line.LineStyle = 0 # SOLID
return line
def _no_border():
line = _line_struct()
if line is None:
return None
line.LineWidth = 0
line.OuterLineWidth = 0
try:
line.LineStyle = 5 # NONE
except Exception:
pass
return line
# -- Spalteninhalt --------------------------------------------------------
def _split_first_word(text):
"""(erstes Wort mit fuehrendem Leerraum, Rest)."""
lead = len(text) - len(text.lstrip())
head, rest = text[:lead], text[lead:]
cut = rest.find(" ")
if cut < 0:
return head + rest, ""
return head + rest[:cut], rest[cut:]
def _split_last_word(text):
"""(Rest, letztes Wort mit nachfolgendem Leerraum)."""
body = text.rstrip()
trail = text[len(body):]
cut = body.rfind(" ")
if cut < 0:
return "", body + trail
return body[:cut + 1], body[cut + 1:] + trail
def _neighbour(seq, index, step):
"""Naechster tatsaechlich vorhandener Nachbar mit Textinhalt."""
j = index + step
while 0 <= j < len(seq):
entry = seq[j]
if entry["keep"] and entry["text"].strip():
return j
j += step
return None
def _clean_runs(block, key, opts):
"""Fassung ohne durchgestrichenen Text, aber mit lesbarer Streichung.
- unmittelbar ersetzter Text: nur die Neufassung wird hervorgehoben
- ersatzlos gestrichener Abschnitt: Hinweis statt leerer Zelle
- Streichung innerhalb eines Satzes: das letzte verbliebene Wort davor
und das erste danach werden hervorgehoben, damit die Nahtstelle
erkennbar bleibt
"""
seq = []
for seg in block.segments:
inserted = bool(seg.inserted_by)
if key == COL_FINAL:
keep = seg.in_final()
author = (seg.inserted_by[0] if inserted else
(seg.deleted_by[0] if seg.deleted_by else None))
removed = not keep and seg.in_original()
else:
keep = seg.in_version_of(key)
author = key
removed = not keep and seg.in_original() and key in seg.deleted_by
seq.append({"keep": keep, "removed": removed, "text": seg.text,
"kind": "insert" if (keep and inserted) else "equal",
"author": author})
dropped = [i for i, e in enumerate(seq) if e["removed"]]
if not dropped:
return [(e["text"], e["kind"], e["author"]) for e in seq if e["keep"]]
if not "".join(e["text"] for e in seq if e["keep"]).strip():
return [(t("text.removed"), "removed", seq[dropped[0]]["author"])]
gap_before, gap_after = {}, {}
for i in dropped:
prev_i = _neighbour(seq, i, -1)
next_i = _neighbour(seq, i, 1)
replaced = ((prev_i is not None and seq[prev_i]["kind"] == "insert")
or (next_i is not None and seq[next_i]["kind"] == "insert"))
if replaced:
continue # die Neufassung traegt die Markierung
author = seq[i]["author"]
if prev_i is not None and seq[prev_i]["kind"] == "equal":
gap_before[prev_i] = author
if next_i is not None and seq[next_i]["kind"] == "equal":
gap_after[next_i] = author
runs = []
for i, entry in enumerate(seq):
if not entry["keep"]:
continue
if entry["kind"] != "equal":
runs.append((entry["text"], entry["kind"], entry["author"]))
continue
after, before = gap_after.get(i), gap_before.get(i)
first, rest = (_split_first_word(entry["text"])
if after is not None else ("", entry["text"]))
head, last = (_split_last_word(rest)
if before is not None else (rest, ""))
if first:
runs.append((first, "gap", after))
if head:
runs.append((head, "equal", None))
if last:
runs.append((last, "gap", before))
return runs
def _runs(block, key, opts, show_deletions=None):
"""Liste von (text, kind, author). kind: equal | insert | delete.
*show_deletions* uebersteuert die Option; damit laesst sich die
Endfassung als reiner Beschlusstext ohne Streichungen ausgeben.
"""
if show_deletions is None:
show_deletions = opts.show_deletions
if key != COL_ORIGINAL and not show_deletions:
return _clean_runs(block, key, opts)
runs = []
for seg in block.segments:
if key == COL_ORIGINAL:
if seg.in_original():
runs.append((seg.text, "equal", None))
continue
if key == COL_FINAL:
keep = seg.in_final()
ins, dele = seg.inserted_by, seg.deleted_by
author = ins[0] if ins else (dele[0] if dele else None)
show_deleted = seg.in_original()
else:
keep = seg.in_version_of(key)
author = key
show_deleted = seg.in_original() and key in seg.deleted_by
if keep:
runs.append((seg.text, "insert" if seg.inserted_by else "equal", author))
elif show_deletions and show_deleted:
runs.append((seg.text, "delete", author))
return runs
def _merge(runs):
"""Benachbarte Laeufe gleicher Formatierung zusammenfassen."""
out = []
for text, kind, author in runs:
if out and out[-1][1] == kind and out[-1][2] == author:
out[-1][0] += text
else:
out.append([text, kind, author])
return [tuple(r) for r in out]
def _head(text, n):
cut = text[:n]
space = cut.rfind(" ")
return (cut[:space] if space > n // 2 else cut).rstrip()
def _tail(text, n):
cut = text[-n:]
space = cut.find(" ")
return (cut[space + 1:] if 0 <= space < n // 2 else cut).lstrip()
def _elide(runs, context):
"""Lange unveraenderte Passagen auf den Kontext um die Aenderungen kuerzen."""
runs = _merge(runs)
if not any(k != "equal" for _t, k, _a in runs):
return runs # nichts geaendert: nichts kuerzen
out = []
for i, (text, kind, author) in enumerate(runs):
if kind != "equal" or len(text) <= 2 * context + len(ELLIPSIS):
out.append((text, kind, author))
continue
before = any(k != "equal" for _t, k, _a in runs[:i])
after = any(k != "equal" for _t, k, _a in runs[i + 1:])
if before and after:
new = _head(text, context) + ELLIPSIS + _tail(text, context)
elif after:
new = ELLIPSIS.lstrip() + _tail(text, context)
elif before:
new = _head(text, context) + ELLIPSIS.rstrip()
else:
new = text
out.append((new, kind, author))
return out
def _active_authors(block, opts):
"""Personen, die in diesem Block ueberhaupt in Erscheinung treten."""
found = set()
for seg in block.segments:
for _rtype, author in seg.stack:
found.add(author)
if opts.show_notes:
for note in block.notes:
found.add(note.author)
return found
def _body(block, key, opts, elide=False):
runs = _runs(block, key, opts)
if elide and opts.elide:
runs = _elide(runs, opts.elide_context)
return runs
def _cell_items(block, key, opts, authors):
"""Inhalt einer Zelle als Folge von ('body'|'heading'|'note', payload)."""
if key == COL_ORIGINAL:
return [("body", _runs(block, key, opts))]
if key == COL_FINAL:
# Die Endfassung wird ungekuerzt gezeigt: sie ist der Wortlaut,
# der beschlossen werden soll. Streichungen entfallen dabei, sobald
# sie an anderer Stelle der Zeile ohnehin sichtbar sind.
clean = None if opts.mode == "cumulative" else False
items = [("body", _runs(block, key, opts, show_deletions=clean))]
if opts.show_notes and opts.mode == "cumulative":
# In den anderen Modi stehen die Anmerkungen bereits bei der
# jeweiligen Person und wuerden sich hier wiederholen.
items += [("note", n) for n in block.notes]
return items
if key == COL_STACKED:
editors = set()
for seg in block.segments:
for _rtype, author in seg.stack:
editors.add(author)
items = []
for author in sorted(_active_authors(block, opts)):
note_only = author not in editors
color = _author_color(author, authors)
suffix = t("text.noteonly") if note_only else ""
items.append(("heading", (_author_label(author), suffix, color)))
if not note_only:
# Wer hier nur kommentiert hat, bekommt keine Textfassung:
# sie waere mit der Ursprungsfassung identisch.
items.append(("body", _body(block, author, opts, elide=True)))
if opts.show_notes:
items += [("note", n) for n in block.notes_of(author)]
if items and opts.show_final:
items.append(("heading", (t("col.final"), "", FINAL_COLOR)))
items.append(("body", _runs(block, COL_FINAL, opts,
show_deletions=False)))
return items
# eigene Spalte je Person
items = [("body", _body(block, key, opts, elide=True))]
if opts.show_notes:
items += [("note", n) for n in block.notes_of(key)]
return items
# -- Schreiben ------------------------------------------------------------
def _set_border(cur, name, line, distance=0):
"""Absatzrahmen setzen; ohne verfuegbare Struktur wird er ausgelassen."""
if line is None:
return
try:
setattr(cur, name, line)
setattr(cur, name + "Distance", distance)
except Exception:
pass
def _reset_para(cur):
cur.ParaBackColor = -1
cur.ParaLeftMargin = 0
cur.ParaRightMargin = 0
cur.ParaTopMargin = 0
cur.ParaBottomMargin = 0
_set_border(cur, "LeftBorder", _no_border())
_set_border(cur, "TopBorder", _no_border())
def _heading_para(cur, color, first):
_reset_para(cur)
cur.ParaTopMargin = 0 if first else 220
cur.ParaBottomMargin = 40
if not first:
_set_border(cur, "TopBorder", _border(color, 20), 90)
def _note_para(cur, color):
_reset_para(cur)
cur.ParaBackColor = _tint(color)
cur.ParaLeftMargin = 200
cur.ParaRightMargin = 100
cur.ParaTopMargin = 150
cur.ParaBottomMargin = 60
_set_border(cur, "LeftBorder", _border(color), 120)
def _char(cur, color=NEUTRAL, size=BODY_SIZE, weight=100.0,
posture=UPRIGHT, underline=UL_NONE, strikeout=SO_NONE):
cur.CharColor = color
cur.CharHeight = size
cur.CharWeight = weight
if posture is not None:
cur.CharPosture = posture
cur.CharUnderline = underline
cur.CharStrikeout = strikeout
def _insert(text, cur, value):
for i, part in enumerate(value.split("\n")):
if i:
text.insertControlCharacter(cur, PARAGRAPH_BREAK, False)
if part:
text.insertString(cur, part, False)
def _write_cell(cell, items, authors, opts, bold=False):
text = cell.getText()
text.setString("")
cur = text.createTextCursor()
_reset_para(cur)
wrote = False
for kind, payload in items:
if kind == "body":
runs = _merge(payload)
if not runs:
continue
cur.gotoEnd(False)
if wrote:
text.insertControlCharacter(cur, PARAGRAPH_BREAK, False)
_reset_para(cur)
for value, rkind, author in runs:
color = _author_color(author, authors) if author else NEUTRAL
weight = 150.0 if bold else 100.0
if rkind == "insert":
_char(cur, color, BODY_SIZE, weight, underline=UL_SINGLE)
elif rkind == "delete":
_char(cur, color, BODY_SIZE, weight, strikeout=SO_SINGLE)
elif rkind == "gap":
# Nahtstelle einer Streichung: fett in der Autorenfarbe
_char(cur, color, BODY_SIZE, 150.0)
elif rkind == "removed":
_char(cur, color, BODY_SIZE, weight, posture=ITALIC)
else:
_char(cur, NEUTRAL, BODY_SIZE, weight)
_insert(text, cur, value)
wrote = True
elif kind == "heading":
label, suffix, color = payload
cur.gotoEnd(False)
if wrote:
text.insertControlCharacter(cur, PARAGRAPH_BREAK, False)
_heading_para(cur, color, not wrote)
_char(cur, color, HEAD_SIZE, 150.0)
_insert(text, cur, label)
if suffix:
_char(cur, GREY, HEAD_SIZE, 100.0, posture=ITALIC)
_insert(text, cur, suffix)
wrote = True
elif kind == "note":
note = payload
color = _author_color(note.author, authors)
cur.gotoEnd(False)
if wrote:
text.insertControlCharacter(cur, PARAGRAPH_BREAK, False)
_note_para(cur, color)
label = _author_label(note.author)
if note.resolved:
label += t("text.resolved")
_char(cur, color, NOTE_SIZE, 150.0)
_insert(text, cur, label + ": ")
quote = note.short_quote() if opts.show_quotes else ""
if quote:
_char(cur, GREY, NOTE_SIZE, 100.0, posture=ITALIC)
_insert(text, cur, "\u201e%s\u201c \u2013 " % quote)
_char(cur, GREY if note.resolved else NEUTRAL, NOTE_SIZE, 100.0)
_insert(text, cur, note.content or "(ohne Text)")
wrote = True
def _set_widths(table, weights):
seps = table.TableColumnSeparators
if not seps:
return
total = float(sum(weights))
pos, new = 0.0, []
for i, sep in enumerate(seps):
if i >= len(weights) - 1:
break
pos += weights[i] / total * 10000.0
sep.Position = int(pos)
new.append(sep)
if new:
table.TableColumnSeparators = tuple(new)
def _new_writer(ctx, landscape):
desktop = ctx.ServiceManager.createInstanceWithContext(
"com.sun.star.frame.Desktop", ctx)
doc = desktop.loadComponentFromURL("private:factory/swriter", "_blank", 0, ())
if landscape:
style = doc.StyleFamilies.getByName("PageStyles").getByName("Standard")
if not style.IsLandscape:
w, h = style.Width, style.Height
style.IsLandscape = True
style.Width, style.Height = h, w
for name in ("LeftMargin", "RightMargin"):
try:
setattr(style, name, 1000)
except Exception:
pass
return doc
# -- Aufbau ---------------------------------------------------------------
def _columns(opts, authors):
cols, weights = [], []
if opts.numbering:
cols.append((t("col.number"), COL_INDEX))
weights.append(0.4)
cols.append((t("col.original"), COL_ORIGINAL))
weights.append(3.0)
if opts.mode == "cumulative":
cols.append((t("col.final"), COL_FINAL))
weights.append(3.0)
elif opts.mode == "stacked":
cols.append((t("col.changes"), COL_STACKED))
weights.append(4.0)
else:
for author in sorted(authors):
cols.append((_author_label(author), author))
weights.append(3.0)
if opts.show_final:
cols.append((t("col.final"), COL_FINAL))
weights.append(3.0)
return cols, weights
def build(ctx, source_doc, opts):
blocks, edit_authors, note_authors = extract_blocks(source_doc)
authors = set(edit_authors)
if opts.show_notes:
authors |= note_authors
if not authors:
raise RuntimeError(t("msg.nochanges"))
if opts.only_changed or opts.mode == "stacked":
# Im gestapelten Modus bleiben unveraenderte Absaetze ohnehin leer.
blocks = [b for b in blocks if b.changed] if opts.only_changed else blocks
if not blocks:
raise RuntimeError(t("msg.noparagraphs"))
cols, weights = _columns(opts, authors)
doc = _new_writer(ctx, opts.landscape)
body = doc.getText()
cur = body.createTextCursor()
try:
cur.ParaStyleName = "Heading 1"
except Exception:
pass
name = getattr(source_doc, "Title", "") or ""
body.insertString(cur, t("doc.title.named", name) if name
else t("doc.title"), False)
body.insertControlCharacter(cur, PARAGRAPH_BREAK, False)
try:
cur.ParaStyleName = "Default Paragraph Style"
except Exception:
pass
table = doc.createInstance("com.sun.star.text.TextTable")
table.initialize(len(blocks) + 1, len(cols))
body.insertTextContent(cur, table, False)
try:
table.RepeatHeadline = True
table.HeaderRowCount = 1
except Exception:
pass
for c, (label, _key) in enumerate(cols):
cell = table.getCellByName(_cell_name(c, 0))
cell.BackColor = HEADER_BG
_write_cell(cell, [("body", [(label, "equal", None)])],
authors, opts, bold=True)
for r, block in enumerate(blocks, start=1):
for c, (_label, key) in enumerate(cols):
cell = table.getCellByName(_cell_name(c, r))
if key == COL_INDEX:
_write_cell(cell, [("body", [(str(r), "equal", None)])],
authors, opts)
else:
_write_cell(cell, _cell_items(block, key, opts, authors),
authors, opts)
_set_widths(table, weights)
return doc

View File

@@ -0,0 +1,282 @@
# -*- coding: utf-8 -*-
"""Vergleich zweier geoeffneter Writer-Dokumente zu einer klassischen Synopse.
Links die Textstellen der Quellfassung, in der Mitte die der Endfassung,
rechts wahlweise die Kommentare aus der Endfassung.
Die Zuordnung laeuft in zwei Stufen:
1. Absatzebene - difflib ueber die normalisierten Absatztexte
2. Wortebene - difflib innerhalb eines zugeordneten Absatzpaares
"""
import difflib
import re
from synopse import builder as B
from synopse.redline_model import extract_blocks
from synopse.i18n import t
COLOR_DEL = 0xA51D2D
COLOR_INS = 0x1A5FB4
TOKEN = re.compile(r"\S+\s*")
MATCH_MIN = 0.30 # ab dieser Aehnlichkeit gelten zwei Absaetze als Paar
LOOKAHEAD = 3 # Fenster fuer die Suche nach dem Gegenstueck
class CompareOptions(object):
comments = "column" # "column" | "inline" | "none"
highlight = True # Unterschiede auf Wortebene hervorheben
show_quotes = True
only_changed = False
numbering = True
landscape = True
class Unit(object):
"""Ein Absatz eines Dokuments samt der dort verankerten Kommentare."""
__slots__ = ("text", "norm", "notes")
def __init__(self, text, notes):
self.text = text.replace("\n", " ")
self.norm = " ".join(self.text.split())
self.notes = notes
# -- Dokumente ------------------------------------------------------------
def open_documents(ctx):
"""Alle geoeffneten Writer-Dokumente."""
desktop = ctx.ServiceManager.createInstanceWithContext(
"com.sun.star.frame.Desktop", ctx)
found = []
enum = desktop.getComponents().createEnumeration()
while enum.hasMoreElements():
comp = enum.nextElement()
try:
if comp.supportsService("com.sun.star.text.TextDocument"):
found.append(comp)
except Exception:
pass
return found
def document_title(doc):
try:
title = doc.getTitle()
except Exception:
title = ""
return title or t("text.unknown")
def to_units(doc):
"""Absaetze eines Dokuments in der Fassung 'alle Aenderungen uebernommen'."""
blocks, _edit, _note = extract_blocks(doc)
units = []
for block in blocks:
text = "".join(s.text for s in block.segments if s.in_final())
units.append(Unit(text, list(block.notes)))
return units
# -- Zuordnung ------------------------------------------------------------
def _ratio(left, right):
if left is None or right is None:
return 0.0
return difflib.SequenceMatcher(None, left.norm, right.norm).ratio()
def _pair_replace(left, right):
"""Absaetze eines veraenderten Abschnitts einander zuordnen."""
rows = []
li = ri = 0
while li < len(left) or ri < len(right):
if li >= len(left):
rows.append((None, right[ri]))
ri += 1
continue
if ri >= len(right):
rows.append((left[li], None))
li += 1
continue
if _ratio(left[li], right[ri]) >= MATCH_MIN:
rows.append((left[li], right[ri]))
li += 1
ri += 1
continue
# Gegenstueck im Fenster suchen: wurde eingefuegt oder geloescht?
span_r = range(ri, min(ri + LOOKAHEAD, len(right)))
span_l = range(li, min(li + LOOKAHEAD, len(left)))
best_r = max(span_r, key=lambda j: _ratio(left[li], right[j]))
best_l = max(span_l, key=lambda i: _ratio(left[i], right[ri]))
score_r = _ratio(left[li], right[best_r])
score_l = _ratio(left[best_l], right[ri])
if score_r >= MATCH_MIN and score_r >= score_l:
while ri < best_r: # rechts kamen Absaetze hinzu
rows.append((None, right[ri]))
ri += 1
elif score_l >= MATCH_MIN:
while li < best_l: # links fielen Absaetze weg
rows.append((left[li], None))
li += 1
else:
rows.append((left[li], right[ri]))
li += 1
ri += 1
return rows
def align(source, target):
"""Zeilen der Synopse als Liste von (Unit|None, Unit|None)."""
a = [u.norm for u in source]
b = [u.norm for u in target]
matcher = difflib.SequenceMatcher(None, a, b, autojunk=False)
rows = []
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
if tag == "equal":
for k in range(i2 - i1):
rows.append((source[i1 + k], target[j1 + k]))
elif tag == "delete":
for k in range(i1, i2):
rows.append((source[k], None))
elif tag == "insert":
for k in range(j1, j2):
rows.append((None, target[k]))
else:
rows.extend(_pair_replace(source[i1:i2], target[j1:j2]))
return rows
# -- Wortebene ------------------------------------------------------------
def _tokens(text):
found = TOKEN.findall(text)
return found if found else ([text] if text else [])
def word_runs(left, right, highlight):
"""Runs fuer die linke und die mittlere Spalte eines Absatzpaares."""
if left is None:
return [], [(right.text, "insert", COLOR_INS)]
if right is None:
return [(left.text, "delete", COLOR_DEL)], []
if not highlight or left.norm == right.norm:
return ([(left.text, "equal", None)], [(right.text, "equal", None)])
at, bt = _tokens(left.text), _tokens(right.text)
matcher = difflib.SequenceMatcher(
None, [t.strip() for t in at], [t.strip() for t in bt], autojunk=False)
lrun, rrun = [], []
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
if tag in ("equal", "delete", "replace"):
chunk = "".join(at[i1:i2])
if chunk:
lrun.append((chunk, "equal" if tag == "equal" else "delete",
None if tag == "equal" else COLOR_DEL))
if tag in ("equal", "insert", "replace"):
chunk = "".join(bt[j1:j2])
if chunk:
rrun.append((chunk, "equal" if tag == "equal" else "insert",
None if tag == "equal" else COLOR_INS))
return lrun, rrun
def _changed(left, right):
if left is None or right is None:
return True
if left.norm != right.norm:
return True
return bool(right.notes)
# -- Aufbau ---------------------------------------------------------------
def build(ctx, source_doc, target_doc, opts):
source = to_units(source_doc)
target = to_units(target_doc)
rows = align(source, target)
if opts.only_changed:
rows = [r for r in rows if _changed(*r)]
if not rows:
raise RuntimeError(t("msg.identical"))
has_notes = any(r[1] is not None and r[1].notes for r in rows)
note_authors = set()
for _l, r in rows:
if r is not None:
for note in r.notes:
note_authors.add(note.author)
mode = opts.comments
if not has_notes or mode == "none":
mode = "none" # rechte Spalte entfaellt
cols, weights = [], []
if opts.numbering:
cols.append((t("col.number"), "index"))
weights.append(0.4)
cols.append((B._head(document_title(source_doc), 40), "left"))
weights.append(3.0)
cols.append((B._head(document_title(target_doc), 40), "right"))
weights.append(3.0)
if mode == "column":
cols.append((t("col.comments"), "notes"))
weights.append(2.0)
doc = B._new_writer(ctx, opts.landscape)
body = doc.getText()
cur = body.createTextCursor()
try:
cur.ParaStyleName = "Heading 1"
except Exception:
pass
body.insertString(cur, t("doc.title.compare",
document_title(source_doc),
document_title(target_doc)), False)
body.insertControlCharacter(cur, B.PARAGRAPH_BREAK, False)
try:
cur.ParaStyleName = "Default Paragraph Style"
except Exception:
pass
table = doc.createInstance("com.sun.star.text.TextTable")
table.initialize(len(rows) + 1, len(cols))
body.insertTextContent(cur, table, False)
try:
table.RepeatHeadline = True
table.HeaderRowCount = 1
except Exception:
pass
for c, (label, _key) in enumerate(cols):
cell = table.getCellByName(B._cell_name(c, 0))
cell.BackColor = B.HEADER_BG
B._write_cell(cell, [("body", [(label, "equal", None)])],
note_authors, opts, bold=True)
for r, (left, right) in enumerate(rows, start=1):
lrun, rrun = word_runs(left, right, opts.highlight)
notes = list(right.notes) if right is not None else []
for c, (_label, key) in enumerate(cols):
cell = table.getCellByName(B._cell_name(c, r))
if key == "index":
items = [("body", [(str(r), "equal", None)])]
elif key == "left":
items = [("body", lrun)]
elif key == "right":
items = [("body", rrun)]
if mode == "inline":
items += [("note", n) for n in notes]
else:
items = [("note", n) for n in notes]
B._write_cell(cell, items, note_authors, opts)
B._set_widths(table, weights)
return doc

View File

@@ -0,0 +1,241 @@
# -*- coding: utf-8 -*-
"""Optionsdialoge, programmatisch ueber com.sun.star.awt aufgebaut."""
import sys
import uno
from synopse.builder import Options
from synopse.i18n import t
def message(ctx, text, title=None):
if title is None:
title = t("doc.title")
try:
toolkit = ctx.ServiceManager.createInstanceWithContext(
"com.sun.star.awt.Toolkit", ctx)
box = toolkit.createMessageBox(
None,
uno.Enum("com.sun.star.awt.MessageBoxType", "INFOBOX"),
1, # BUTTONS_OK
title, text)
box.execute()
box.dispose()
except Exception:
# Ohne Dialogfenster bleibt nur die Konsole
sys.stderr.write("%s: %s\n" % (title, text))
def _add(model, kind, name, props):
ctrl = model.createInstance("com.sun.star.awt.UnoControl%sModel" % kind)
for key, value in props.items():
setattr(ctrl, key, value)
model.insertByName(name, ctrl)
return ctrl
def _dialog(ctx, model):
dlg = ctx.ServiceManager.createInstanceWithContext(
"com.sun.star.awt.UnoControlDialog", ctx)
dlg.setModel(model)
toolkit = ctx.ServiceManager.createInstanceWithContext(
"com.sun.star.awt.Toolkit", ctx)
dlg.createPeer(toolkit, None)
return dlg
def _buttons(model, y):
_add(model, "Button", "btnOk",
dict(PositionX=128, PositionY=y, Width=54, Height=16,
Label=t("dlg.ok"), PushButtonType=1, DefaultButton=True))
_add(model, "Button", "btnCancel",
dict(PositionX=186, PositionY=y, Width=54, Height=16,
Label=t("dlg.cancel"), PushButtonType=2))
def show_options(ctx, edit_authors, note_authors):
"""Optionen fuer die Synopse aus der Aenderungsverfolgung."""
people = sorted(edit_authors | note_authors)
count = len(people)
names = ", ".join(people) or t("dlg.people.none")
if len(names) > 52:
names = names[:49] + "\u2026"
model = ctx.ServiceManager.createInstanceWithContext(
"com.sun.star.awt.UnoControlDialogModel", ctx)
model.setPropertyValues(
("PositionX", "PositionY", "Width", "Height", "Title"),
(120, 120, 248, 264, t("dlg.title")))
_add(model, "FixedText", "lblPeople",
dict(PositionX=8, PositionY=6, Width=232, Height=10,
Label=t("dlg.people", count, names)))
_add(model, "FixedLine", "sep0",
dict(PositionX=8, PositionY=18, Width=232, Height=6,
Label=t("dlg.group.mode")))
_add(model, "RadioButton", "optCum",
dict(PositionX=14, PositionY=30, Width=226, Height=12, State=1,
Label=t("dlg.mode.cumulative")))
_add(model, "RadioButton", "optStack",
dict(PositionX=14, PositionY=43, Width=226, Height=12,
Label=t("dlg.mode.stacked")))
_add(model, "RadioButton", "optAuthor",
dict(PositionX=14, PositionY=56, Width=226, Height=12,
Label=t("dlg.mode.separate", count)))
_add(model, "FixedText", "hint",
dict(PositionX=26, PositionY=69, Width=214, Height=18, MultiLine=True,
Label=t("dlg.mode.hint")))
_add(model, "FixedLine", "sep1",
dict(PositionX=8, PositionY=92, Width=232, Height=6,
Label=t("dlg.group.content")))
_add(model, "CheckBox", "chkDel",
dict(PositionX=8, PositionY=104, Width=232, Height=12, State=1,
Label=t("dlg.opt.deletions")))
_add(model, "CheckBox", "chkElide",
dict(PositionX=8, PositionY=118, Width=232, Height=12, State=1,
Label=t("dlg.opt.elide")))
_add(model, "CheckBox", "chkFinal",
dict(PositionX=8, PositionY=132, Width=232, Height=12, State=1,
Label=t("dlg.opt.final")))
_add(model, "CheckBox", "chkNotes",
dict(PositionX=8, PositionY=146, Width=232, Height=12, State=1,
Label=t("dlg.opt.notes")))
_add(model, "CheckBox", "chkQuote",
dict(PositionX=20, PositionY=160, Width=220, Height=12, State=1,
Label=t("dlg.opt.quote")))
_add(model, "CheckBox", "chkOnly",
dict(PositionX=8, PositionY=178, Width=232, Height=12, State=0,
Label=t("dlg.opt.onlychanged")))
_add(model, "FixedLine", "sep2",
dict(PositionX=8, PositionY=194, Width=232, Height=6,
Label=t("dlg.group.target")))
_add(model, "CheckBox", "chkNum",
dict(PositionX=8, PositionY=206, Width=110, Height=12, State=1,
Label=t("dlg.opt.numbering")))
_add(model, "CheckBox", "chkLand",
dict(PositionX=124, PositionY=206, Width=116, Height=12, State=1,
Label=t("dlg.opt.landscape")))
_buttons(model, 238)
dlg = _dialog(ctx, model)
try:
if dlg.execute() != 1:
return None
opts = Options()
if model.getByName("optAuthor").State:
opts.mode = "per_author"
elif model.getByName("optStack").State:
opts.mode = "stacked"
else:
opts.mode = "cumulative"
opts.show_deletions = bool(model.getByName("chkDel").State)
opts.elide = bool(model.getByName("chkElide").State)
opts.show_final = bool(model.getByName("chkFinal").State)
opts.show_notes = bool(model.getByName("chkNotes").State)
opts.show_quotes = bool(model.getByName("chkQuote").State)
opts.only_changed = bool(model.getByName("chkOnly").State)
opts.numbering = bool(model.getByName("chkNum").State)
opts.landscape = bool(model.getByName("chkLand").State)
return opts
finally:
dlg.dispose()
def show_compare_options(ctx, docs, current=None):
"""Auswahl der beiden Dokumente. Liefert (quelle, ziel, options)."""
from synopse.compare import CompareOptions, document_title
titles = [document_title(d) for d in docs]
# Vorauswahl: aktuelles Dokument als Endfassung, das andere als Quelle
target_idx = docs.index(current) if current in docs else len(docs) - 1
source_idx = 0 if target_idx != 0 else 1
model = ctx.ServiceManager.createInstanceWithContext(
"com.sun.star.awt.UnoControlDialogModel", ctx)
model.setPropertyValues(
("PositionX", "PositionY", "Width", "Height", "Title"),
(120, 120, 248, 258, t("cmp.title")))
_add(model, "FixedLine", "sep0",
dict(PositionX=8, PositionY=6, Width=232, Height=6,
Label=t("cmp.group.docs")))
_add(model, "FixedText", "lblSrc",
dict(PositionX=8, PositionY=18, Width=232, Height=10,
Label=t("cmp.source")))
_add(model, "ListBox", "lstSrc",
dict(PositionX=8, PositionY=29, Width=232, Height=12, Dropdown=True,
StringItemList=tuple(titles), SelectedItems=(source_idx,)))
_add(model, "FixedText", "lblTgt",
dict(PositionX=8, PositionY=46, Width=232, Height=10,
Label=t("cmp.target")))
_add(model, "ListBox", "lstTgt",
dict(PositionX=8, PositionY=57, Width=232, Height=12, Dropdown=True,
StringItemList=tuple(titles), SelectedItems=(target_idx,)))
_add(model, "FixedLine", "sep1",
dict(PositionX=8, PositionY=76, Width=232, Height=6,
Label=t("cmp.group.comments")))
_add(model, "RadioButton", "optCol",
dict(PositionX=14, PositionY=88, Width=226, Height=12, State=1,
Label=t("cmp.comments.column")))
_add(model, "RadioButton", "optInline",
dict(PositionX=14, PositionY=101, Width=226, Height=12,
Label=t("cmp.comments.inline")))
_add(model, "RadioButton", "optNone",
dict(PositionX=14, PositionY=114, Width=226, Height=12,
Label=t("cmp.comments.none")))
_add(model, "FixedText", "hint",
dict(PositionX=26, PositionY=127, Width=214, Height=18, MultiLine=True,
Label=t("cmp.comments.hint")))
_add(model, "FixedLine", "sep2",
dict(PositionX=8, PositionY=150, Width=232, Height=6,
Label=t("cmp.group.display")))
_add(model, "CheckBox", "chkHi",
dict(PositionX=8, PositionY=162, Width=232, Height=12, State=1,
Label=t("cmp.opt.highlight")))
_add(model, "CheckBox", "chkQuote",
dict(PositionX=8, PositionY=176, Width=232, Height=12, State=1,
Label=t("cmp.opt.quote")))
_add(model, "CheckBox", "chkOnly",
dict(PositionX=8, PositionY=190, Width=232, Height=12, State=0,
Label=t("cmp.opt.onlychanged")))
_add(model, "CheckBox", "chkNum",
dict(PositionX=8, PositionY=204, Width=110, Height=12, State=1,
Label=t("dlg.opt.numbering")))
_add(model, "CheckBox", "chkLand",
dict(PositionX=124, PositionY=204, Width=116, Height=12, State=1,
Label=t("dlg.opt.landscape")))
_buttons(model, 232)
dlg = _dialog(ctx, model)
try:
if dlg.execute() != 1:
return None
src = model.getByName("lstSrc").SelectedItems
tgt = model.getByName("lstTgt").SelectedItems
if not src or not tgt or src[0] == tgt[0]:
message(ctx, t("msg.different"))
return None
opts = CompareOptions()
if model.getByName("optInline").State:
opts.comments = "inline"
elif model.getByName("optNone").State:
opts.comments = "none"
else:
opts.comments = "column"
opts.highlight = bool(model.getByName("chkHi").State)
opts.show_quotes = bool(model.getByName("chkQuote").State)
opts.only_changed = bool(model.getByName("chkOnly").State)
opts.numbering = bool(model.getByName("chkNum").State)
opts.landscape = bool(model.getByName("chkLand").State)
return docs[src[0]], docs[tgt[0]], opts
finally:
dlg.dispose()

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

@@ -0,0 +1,256 @@
# -*- coding: utf-8 -*-
"""Oberflaechentexte in Deutsch, Englisch und Franzoesisch.
Die Sprache richtet sich nach der Oberflaechensprache von LibreOffice
(/org.openoffice.Setup/L10N/ooLocale). Ist sie nicht hinterlegt, wird
Englisch verwendet.
"""
import os
import uno
FALLBACK = "en"
TEXTS = {
"en": {
# -- Dialog: Synopse aus der Aenderungsverfolgung -----------------
"dlg.title": "Create synopsis",
"dlg.people": "Contributors (%d): %s",
"dlg.people.none": "none found",
"dlg.group.mode": "Display of changes",
"dlg.mode.cumulative": "Cumulative \u2013 one column with the final version",
"dlg.mode.stacked": "Stacked \u2013 one column, contributors below each other",
"dlg.mode.separate": "Separate \u2013 one column per contributor (%d)",
"dlg.mode.hint": "With more than three contributors the separate "
"display quickly becomes too narrow.",
"dlg.group.content": "Content",
"dlg.opt.deletions": "Keep deleted text, struck through",
"dlg.opt.elide": "Shorten unchanged passages to the context",
"dlg.opt.final": "Also show the final version (stacked/separate)",
"dlg.opt.notes": "Include comments and annotations",
"dlg.opt.quote": "quoting the commented passage",
"dlg.opt.onlychanged": "Only changed or commented paragraphs",
"dlg.group.target": "Target document",
"dlg.opt.numbering": "Sequential number",
"dlg.opt.landscape": "Landscape",
"dlg.ok": "OK",
"dlg.cancel": "Cancel",
# -- Dialog: Dokumentvergleich ------------------------------------
"cmp.title": "Compare two documents",
"cmp.group.docs": "Documents",
"cmp.source": "Source version (left column):",
"cmp.target": "Final version (middle column):",
"cmp.group.comments": "Comments in the final version",
"cmp.comments.column": "In a separate column on the right",
"cmp.comments.inline": "Below the passage in the middle column",
"cmp.comments.none": "Do not include",
"cmp.comments.hint": "If the final version contains no comments, "
"the third column is omitted automatically.",
"cmp.group.display": "Display",
"cmp.opt.highlight": "Highlight differences word by word",
"cmp.opt.quote": "Quote the commented passage",
"cmp.opt.onlychanged": "Only include differing paragraphs",
# -- Tabelle -------------------------------------------------------
"col.number": "No.",
"col.original": "Original version",
"col.final": "Final version",
"col.changes": "Changes",
"col.comments": "Comments",
"doc.title": "Synopsis",
"doc.title.named": "Synopsis: %s",
"doc.title.compare": "Synopsis: %s \u2192 %s",
"text.removed": "deleted without replacement",
"text.noteonly": " \u2013 comment only",
"text.resolved": " (resolved)",
"text.unknown": "(unknown)",
# -- Meldungen -----------------------------------------------------
"msg.nodoc": "Please bring a Writer document to the front first.",
"msg.nochanges": "The document contains neither tracked changes "
"nor comments.",
"msg.noparagraphs": "No changed paragraphs were found.",
"msg.twodocs": "At least two Writer documents must be open "
"for a comparison.",
"msg.different": "Please select two different documents.",
"msg.identical": "The two documents are identical in content.",
"msg.error.build": "Error while creating the synopsis:\n\n%s\n\n%s",
"msg.error.compare": "Error during comparison:\n\n%s\n\n%s",
},
"de": {
"dlg.title": "Synopse erzeugen",
"dlg.people": "Beteiligte (%d): %s",
"dlg.people.none": "keine gefunden",
"dlg.group.mode": "Darstellung der \u00c4nderungen",
"dlg.mode.cumulative": "Kumuliert \u2013 eine Spalte mit der Endfassung",
"dlg.mode.stacked": "Gestapelt \u2013 eine Spalte, Personen untereinander",
"dlg.mode.separate": "Getrennt \u2013 je Person eine eigene Spalte (%d)",
"dlg.mode.hint": "Bei mehr als drei Beteiligten wird die getrennte "
"Darstellung schnell zu schmal.",
"dlg.group.content": "Inhalt",
"dlg.opt.deletions": "Gel\u00f6schten Text durchgestrichen mitf\u00fchren",
"dlg.opt.elide": "Unver\u00e4nderte Passagen auf den Kontext k\u00fcrzen",
"dlg.opt.final": "Endfassung zus\u00e4tzlich ausweisen (gestapelt/getrennt)",
"dlg.opt.notes": "Anmerkungen und Kommentare \u00fcbernehmen",
"dlg.opt.quote": "dabei kommentierte Textstelle zitieren",
"dlg.opt.onlychanged": "Nur ge\u00e4nderte oder kommentierte Abs\u00e4tze",
"dlg.group.target": "Zieldokument",
"dlg.opt.numbering": "Laufende Nummer",
"dlg.opt.landscape": "Querformat",
"dlg.ok": "OK",
"dlg.cancel": "Abbrechen",
"cmp.title": "Zwei Dokumente vergleichen",
"cmp.group.docs": "Dokumente",
"cmp.source": "Quellfassung (linke Spalte):",
"cmp.target": "Endfassung (mittlere Spalte):",
"cmp.group.comments": "Kommentare der Endfassung",
"cmp.comments.column": "In einer eigenen Spalte rechts",
"cmp.comments.inline": "Unter der Textstelle in der mittleren Spalte",
"cmp.comments.none": "Nicht \u00fcbernehmen",
"cmp.comments.hint": "Enth\u00e4lt die Endfassung keine Kommentare, "
"entf\u00e4llt die dritte Spalte automatisch.",
"cmp.group.display": "Darstellung",
"cmp.opt.highlight": "Unterschiede wortweise hervorheben",
"cmp.opt.quote": "Kommentierte Textstelle zitieren",
"cmp.opt.onlychanged": "Nur abweichende Abs\u00e4tze aufnehmen",
"col.number": "Nr.",
"col.original": "Ursprungsfassung",
"col.final": "Endfassung",
"col.changes": "\u00c4nderungen",
"col.comments": "Kommentare",
"doc.title": "Synopse",
"doc.title.named": "Synopse: %s",
"doc.title.compare": "Synopse: %s \u2192 %s",
"text.removed": "ersatzlos gestrichen",
"text.noteonly": " \u2013 nur Anmerkung",
"text.resolved": " (erledigt)",
"text.unknown": "(unbekannt)",
"msg.nodoc": "Bitte zuerst ein Writer-Dokument in den Vordergrund holen.",
"msg.nochanges": "Das Dokument enth\u00e4lt weder nachverfolgte "
"\u00c4nderungen noch Anmerkungen.",
"msg.noparagraphs": "Es wurden keine ge\u00e4nderten Abs\u00e4tze gefunden.",
"msg.twodocs": "F\u00fcr den Vergleich m\u00fcssen mindestens zwei "
"Writer-Dokumente ge\u00f6ffnet sein.",
"msg.different": "Bitte zwei verschiedene Dokumente ausw\u00e4hlen.",
"msg.identical": "Die beiden Dokumente sind inhaltlich identisch.",
"msg.error.build": "Fehler beim Erzeugen der Synopse:\n\n%s\n\n%s",
"msg.error.compare": "Fehler beim Vergleich:\n\n%s\n\n%s",
},
"fr": {
"dlg.title": "Cr\u00e9er une synopse",
"dlg.people": "Participants (%d) : %s",
"dlg.people.none": "aucun trouv\u00e9",
"dlg.group.mode": "Pr\u00e9sentation des modifications",
"dlg.mode.cumulative": "Cumul\u00e9e \u2013 une colonne avec la version finale",
"dlg.mode.stacked": "Empil\u00e9e \u2013 une colonne, participants l\u2019un sous l\u2019autre",
"dlg.mode.separate": "S\u00e9par\u00e9e \u2013 une colonne par participant (%d)",
"dlg.mode.hint": "Au-del\u00e0 de trois participants, la pr\u00e9sentation "
"s\u00e9par\u00e9e devient vite trop \u00e9troite.",
"dlg.group.content": "Contenu",
"dlg.opt.deletions": "Conserver le texte supprim\u00e9, barr\u00e9",
"dlg.opt.elide": "R\u00e9duire les passages inchang\u00e9s au contexte",
"dlg.opt.final": "Afficher aussi la version finale (empil\u00e9e/s\u00e9par\u00e9e)",
"dlg.opt.notes": "Reprendre les commentaires et annotations",
"dlg.opt.quote": "en citant le passage comment\u00e9",
"dlg.opt.onlychanged": "Uniquement les paragraphes modifi\u00e9s ou comment\u00e9s",
"dlg.group.target": "Document cible",
"dlg.opt.numbering": "Num\u00e9rotation",
"dlg.opt.landscape": "Paysage",
"dlg.ok": "OK",
"dlg.cancel": "Annuler",
"cmp.title": "Comparer deux documents",
"cmp.group.docs": "Documents",
"cmp.source": "Version source (colonne de gauche) :",
"cmp.target": "Version finale (colonne du milieu) :",
"cmp.group.comments": "Commentaires de la version finale",
"cmp.comments.column": "Dans une colonne distincte \u00e0 droite",
"cmp.comments.inline": "Sous le passage dans la colonne du milieu",
"cmp.comments.none": "Ne pas reprendre",
"cmp.comments.hint": "Si la version finale ne contient aucun "
"commentaire, la troisi\u00e8me colonne est "
"supprim\u00e9e automatiquement.",
"cmp.group.display": "Pr\u00e9sentation",
"cmp.opt.highlight": "Mettre en \u00e9vidence les diff\u00e9rences mot \u00e0 mot",
"cmp.opt.quote": "Citer le passage comment\u00e9",
"cmp.opt.onlychanged": "N\u2019inclure que les paragraphes divergents",
"col.number": "N\u00b0",
"col.original": "Version initiale",
"col.final": "Version finale",
"col.changes": "Modifications",
"col.comments": "Commentaires",
"doc.title": "Synopse",
"doc.title.named": "Synopse : %s",
"doc.title.compare": "Synopse : %s \u2192 %s",
"text.removed": "supprim\u00e9 sans remplacement",
"text.noteonly": " \u2013 commentaire seulement",
"text.resolved": " (r\u00e9solu)",
"text.unknown": "(inconnu)",
"msg.nodoc": "Veuillez d\u2019abord placer un document Writer au "
"premier plan.",
"msg.nochanges": "Le document ne contient ni modifications suivies "
"ni commentaires.",
"msg.noparagraphs": "Aucun paragraphe modifi\u00e9 n\u2019a \u00e9t\u00e9 trouv\u00e9.",
"msg.twodocs": "Au moins deux documents Writer doivent \u00eatre "
"ouverts pour une comparaison.",
"msg.different": "Veuillez s\u00e9lectionner deux documents diff\u00e9rents.",
"msg.identical": "Les deux documents ont un contenu identique.",
"msg.error.build": "Erreur lors de la cr\u00e9ation de la synopse :"
"\n\n%s\n\n%s",
"msg.error.compare": "Erreur lors de la comparaison :\n\n%s\n\n%s",
},
}
_language = FALLBACK
def _office_locale(ctx):
"""Oberflaechensprache von LibreOffice, z. B. 'de-DE'."""
try:
provider = ctx.ServiceManager.createInstanceWithContext(
"com.sun.star.configuration.ConfigurationProvider", ctx)
argument = uno.createUnoStruct("com.sun.star.beans.PropertyValue")
argument.Name = "nodepath"
argument.Value = "/org.openoffice.Setup/L10N"
node = provider.createInstanceWithArguments(
"com.sun.star.configuration.ConfigurationAccess", (argument,))
return node.getByName("ooLocale") or ""
except Exception:
return ""
def set_language(ctx=None):
"""Sprache bestimmen. Ist sie nicht hinterlegt, bleibt es bei Englisch."""
global _language
tag = _office_locale(ctx) if ctx is not None else ""
if not tag:
tag = os.environ.get("LANGUAGE") or os.environ.get("LANG") or ""
primary = tag.replace("_", "-").split("-")[0].split(".")[0].lower()
_language = primary if primary in TEXTS else FALLBACK
return _language
def language():
return _language
def t(key, *args):
"""Uebersetzten Text liefern; fehlt er, greift Englisch."""
value = TEXTS.get(_language, {}).get(key)
if value is None:
value = TEXTS[FALLBACK].get(key, key)
if args:
try:
value = value % args
except Exception:
pass
return value

View File

@@ -0,0 +1,402 @@
# -*- coding: utf-8 -*-
"""
Liest ein Writer-Dokument mit Aenderungsverfolgung und baut daraus ein
Modell aus Bloecken auf.
Ein Block entspricht einer Zeile der spaeteren Synopse und enthaelt
* Segmente - Textstuecke samt der Redlines, in denen sie liegen
* Notes - Anmerkungen, die in diesem Bereich verankert sind
Grundidee: Der Text wird einmal linear durchlaufen. Waehrend des Durchlaufs
wird ein Stack der gerade offenen Redlines gefuehrt. Jedes Textstueck wird
zusammen mit diesem Stack gespeichert. Aus dem Stack laesst sich anschliessend
jede beliebige Fassung rekonstruieren, ohne das Dokument erneut anzufassen.
"""
import uno
def const(name, default):
"""UNO-Konstante aufloesen, ohne beim Import zu scheitern.
getConstantByName wirft eine RuntimeException, wenn der Typmanager den
Namen nicht kennt. Geschieht das beim Laden des Moduls, bricht der
Python-Loader die Registrierung der Erweiterung ab. Deshalb wird hier
immer auf den dokumentierten Zahlenwert zurueckgefallen.
"""
try:
return uno.getConstantByName(name)
except Exception:
return default
def const_opt(name):
"""Wie const(), liefert aber None statt eines Ersatzwertes."""
try:
return uno.getConstantByName(name)
except Exception:
return None
def enum(type_name, value):
"""UNO-Enum aufloesen; liefert None, wenn der Typ unbekannt ist."""
try:
return uno.Enum(type_name, value)
except Exception:
return None
RD_NAME = "com.sun.star.text.RedlineDisplayType.INSERTED_AND_REMOVED"
# Ergebnis der einmaligen empirischen Bestimmung (siehe _display_all_value)
_display_all_cache = None
INSERT = "Insert"
DELETE = "Delete"
UNKNOWN_AUTHOR = "\x00unknown" # sprachneutral, Anzeige ueber i18n
QUOTE_MAX = 80
# Beschreibungen, die LibreOffice selbst in RedlineComment eintraegt. Sie
# sind keine Anmerkung des Bearbeiters und wuerden die Synopse zumuellen.
AUTO_COMMENTS = frozenset((
"kommentar hinzugef\u00fcgt", "kommentar ge\u00e4ndert",
"kommentar gel\u00f6scht", "kommentar aufgel\u00f6st",
"comment added", "comment changed", "comment deleted", "comment resolved",
"attribut ge\u00e4ndert", "attribute changed",
"absatzformat ge\u00e4ndert", "paragraph formatting changed",
))
class Segment(object):
"""Ein Textstueck samt der Redlines, innerhalb derer es liegt."""
__slots__ = ("text", "stack")
def __init__(self, text, stack):
self.text = text
self.stack = stack # tuple((rtype, author), ...)
@property
def inserted_by(self):
return [a for (t, a) in self.stack if t == INSERT]
@property
def deleted_by(self):
return [a for (t, a) in self.stack if t == DELETE]
@property
def changed(self):
return bool(self.stack)
# -- Fassungen --------------------------------------------------------
def in_original(self):
"""Stand das Stueck in der Ursprungsfassung?"""
return not self.inserted_by
def in_final(self):
"""Steht das Stueck in der Endfassung (alle Aenderungen uebernommen)?"""
return not self.deleted_by
def in_version_of(self, author):
"""Fassung, in der ausschliesslich die Aenderungen von *author* gelten.
- Von jemand anderem eingefuegt -> nicht enthalten
- Von *author* eingefuegt -> enthalten
- Von *author* geloescht -> nicht enthalten
- Von jemand anderem geloescht -> bleibt stehen
"""
if any(a != author for a in self.inserted_by):
return False
if author in self.deleted_by:
return False
return True
class Note(object):
"""Eine Anmerkung zu einer Stelle des Textes."""
__slots__ = ("author", "content", "quote", "kind", "resolved", "when")
def __init__(self, author, content, kind="annotation"):
self.author = author or UNKNOWN_AUTHOR
self.content = (content or "").strip()
self.quote = ""
self.kind = kind # "annotation" | "redline"
self.resolved = False
self.when = None
def short_quote(self):
q = " ".join(self.quote.split())
if len(q) > QUOTE_MAX:
q = q[:QUOTE_MAX - 1].rstrip() + "\u2026"
return q
class Block(object):
"""Eine Zeile der Synopse."""
__slots__ = ("segments", "notes")
def __init__(self):
self.segments = []
self.notes = []
@property
def changed(self):
return any(s.changed for s in self.segments) or bool(self.notes)
def notes_of(self, author):
return [n for n in self.notes if n.author == author]
def _pop(stack, ident):
"""Entfernt die Redline mit der gegebenen Kennung vom Stack."""
for i in range(len(stack) - 1, -1, -1):
if stack[i][0] == ident:
del stack[i]
return
if stack:
stack.pop()
def _iter_paragraphs(text):
"""Absaetze eines XText, inklusive der Absaetze in Tabellenzellen."""
enum = text.createEnumeration()
while enum.hasMoreElements():
el = enum.nextElement()
if el.supportsService("com.sun.star.text.Paragraph"):
yield el
elif el.supportsService("com.sun.star.text.TextTable"):
for name in el.getCellNames():
for para in _iter_paragraphs(el.getCellByName(name)):
yield para
def _portion_text(portion):
try:
return portion.getString()
except Exception:
return ""
def _get(obj, name, default=None):
try:
return getattr(obj, name)
except Exception:
return default
def _is_annotation(field):
"""Ist das Textfeld ein Kommentar?"""
try:
if field.supportsService("com.sun.star.text.textfield.Annotation"):
return True
except Exception:
pass
# Aeltere Versionen melden den Dienst nicht zuverlaessig zurueck
try:
return _get(field, "Content") is not None and \
_get(field, "Author") is not None
except Exception:
return False
def _field_of(portion):
"""Das Feld eines Portions. Der Name unterscheidet sich je nach Version."""
for name in ("TextField", "Annotation"):
field = _get(portion, name)
if field is not None:
return field
return None
def _annotation(portion):
"""Liefert (Note, Schluessel) zu einem Kommentar-Portion."""
field = _field_of(portion)
if field is None or not _is_annotation(field):
return None, None
content = (_get(field, "Content", "") or "").strip()
if not content:
return None, None # leerer Kommentar sagt nichts aus
note = Note(_get(field, "Author", ""), content, kind="annotation")
note.resolved = bool(_get(field, "Resolved", False))
note.when = _get(field, "DateTimeValue")
key = _get(field, "Name", "") or ("%s\x00%s" % (note.author, content))
return note, key
def _display_all_value(doc):
"""Wert von RedlineDisplayType.INSERTED_AND_REMOVED ermitteln.
Bevorzugt wird der Typmanager. Antwortet er nicht, wird der Wert
gemessen statt geraten: nur in der Anzeigeart mit Einfuegungen *und*
Loeschungen enthaelt der Dokumenttext beides und ist damit am laengsten.
Ein geratener Wert waere gefaehrlich - die Reihenfolge der Konstanten
ist nicht ueber alle Versionen hinweg gleich, und ein Griff daneben
blendet stillschweigend die Einfuegungen aus.
"""
global _display_all_cache
value = const_opt(RD_NAME)
if value is not None:
return value
if _display_all_cache is not None:
return _display_all_cache
best, longest, shortest = None, -1, None
for candidate in (0, 1, 2, 3):
try:
doc.setPropertyValue("RedlineDisplayType", candidate)
length = len(doc.getText().getString())
except Exception:
continue
if length > longest:
best, longest = candidate, length
if shortest is None or length < shortest:
shortest = length
if best is not None and shortest is not None and longest > shortest:
# Nur aussagekraeftig, wenn das Dokument ueberhaupt Aenderungen hat
_display_all_cache = best
return best
def _show_all_changes(doc):
"""Eingefuegten und geloeschten Text sichtbar machen.
Nur in dieser Anzeigeart liefert die Portion-Enumeration auch den
geloeschten Text. Liefert den zuvor eingestellten Wert zurueck.
"""
try:
previous = doc.getPropertyValue("RedlineDisplayType")
except Exception:
previous = None
value = _display_all_value(doc)
if value is not None:
try:
doc.setPropertyValue("RedlineDisplayType", value)
except Exception:
pass
return previous
def _restore_display(doc, previous, was_modified):
"""Anzeige und Aenderungsstatus des Quelldokuments wiederherstellen."""
if previous is not None:
try:
doc.setPropertyValue("RedlineDisplayType", previous)
except Exception:
pass
if was_modified is False:
try:
doc.setModified(False)
except Exception:
pass
def extract_blocks(doc):
"""Liefert (blocks, edit_authors, note_authors).
Das Quelldokument wird dabei nicht bleibend veraendert: Anzeigeart und
Aenderungsstatus werden am Ende wiederhergestellt.
"""
try:
was_modified = bool(doc.isModified())
except Exception:
was_modified = None
previous_display = _show_all_changes(doc)
try:
return _extract(doc)
finally:
_restore_display(doc, previous_display, was_modified)
def _extract(doc):
stack = [] # [(identifier, rtype, author)]
edit_authors = set()
note_authors = set()
seen_comments = set() # Redline-Kommentare nur einmal uebernehmen
seen_notes = set() # Kommentare nur einmal uebernehmen
open_notes = {} # Schluessel -> Note mit noch offenem Textbereich
blocks = []
current = Block()
for para in _iter_paragraphs(doc.getText()):
pen = para.createEnumeration()
while pen.hasMoreElements():
portion = pen.nextElement()
ptype = portion.TextPortionType
if ptype == "Redline":
rtype = portion.RedlineType
if rtype not in (INSERT, DELETE):
continue # Format/Attribute: ignoriert
author = portion.RedlineAuthor or UNKNOWN_AUTHOR
ident = portion.RedlineIdentifier
edit_authors.add(author)
comment = (_get(portion, "RedlineComment", "") or "").strip()
if (comment and ident not in seen_comments
and comment.lower() not in AUTO_COMMENTS):
seen_comments.add(ident)
note = Note(author, comment, kind="redline")
current.notes.append(note)
note_authors.add(note.author)
if bool(_get(portion, "IsCollapsed", False)):
continue # Laenge 0
if bool(_get(portion, "IsStart", True)):
stack.append((ident, rtype, author))
else:
_pop(stack, ident)
elif ptype == "Annotation":
note, key = _annotation(portion)
if note is not None and key not in seen_notes:
seen_notes.add(key)
current.notes.append(note)
note_authors.add(note.author)
open_notes[key] = note # Ende folgt ggf. spaeter
elif ptype == "AnnotationEnd":
_note, key = _annotation(portion)
open_notes.pop(key, None)
else:
if ptype == "TextField":
# Je nach Version meldet Writer einen Kommentar nicht als
# "Annotation", sondern als gewoehnliches Textfeld.
note, key = _annotation(portion)
if note is not None:
if key not in seen_notes:
seen_notes.add(key)
current.notes.append(note)
note_authors.add(note.author)
continue
s = _portion_text(portion)
if s:
current.segments.append(
Segment(s, tuple((t, a) for (_i, t, a) in stack)))
for note in open_notes.values():
note.quote += s
mark = tuple((t, a) for (_i, t, a) in stack)
if mark:
# Absatzmarke liegt innerhalb einer Aenderung -> kein Zeilenumbruch
# in der Synopse, sondern ein Segment, das nur in der jeweils
# passenden Fassung als Umbruch erscheint.
current.segments.append(Segment("\n", mark))
else:
blocks.append(current)
current = Block()
for note in open_notes.values():
note.quote += " "
if current.segments or current.notes:
blocks.append(current)
return blocks, edit_authors, note_authors