Files
2026-09-11 12:47:00 +02:00

619 lines
21 KiB
Python

# -*- 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