Initial commit
This commit is contained in:
0
pythonpath/synopse/__init__.py
Normal file
0
pythonpath/synopse/__init__.py
Normal file
618
pythonpath/synopse/builder.py
Normal file
618
pythonpath/synopse/builder.py
Normal file
@@ -0,0 +1,618 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Erzeugt aus dem Block-Modell ein neues Writer-Dokument mit Synopse-Tabelle.
|
||||
|
||||
Drei Darstellungsarten der Aenderungsseite:
|
||||
|
||||
cumulative - eine Spalte mit der Endfassung
|
||||
per_author - je Person eine eigene Spalte
|
||||
stacked - eine Spalte, darin die Fassungen der Personen untereinander
|
||||
"""
|
||||
|
||||
import uno
|
||||
|
||||
from synopse.redline_model import (extract_blocks, const, enum,
|
||||
UNKNOWN_AUTHOR)
|
||||
from synopse.i18n import t
|
||||
|
||||
PARAGRAPH_BREAK = const("com.sun.star.text.ControlCharacter.PARAGRAPH_BREAK", 0)
|
||||
UL_SINGLE = const("com.sun.star.awt.FontUnderline.SINGLE", 1)
|
||||
UL_NONE = const("com.sun.star.awt.FontUnderline.NONE", 0)
|
||||
SO_SINGLE = const("com.sun.star.awt.FontStrikeout.SINGLE", 1)
|
||||
SO_NONE = const("com.sun.star.awt.FontStrikeout.NONE", 0)
|
||||
ITALIC = enum("com.sun.star.awt.FontSlant", "ITALIC")
|
||||
UPRIGHT = enum("com.sun.star.awt.FontSlant", "NONE")
|
||||
|
||||
COL_ORIGINAL = "\x00original"
|
||||
COL_FINAL = "\x00final"
|
||||
COL_STACKED = "\x00stacked"
|
||||
COL_INDEX = "\x00index"
|
||||
|
||||
AUTHOR_COLORS = [0x1A5FB4, 0xA51D2D, 0x26A269, 0x9141AC,
|
||||
0xC64600, 0x3D3846, 0x005F5F, 0x813D9C]
|
||||
NEUTRAL = 0x000000
|
||||
GREY = 0x707070
|
||||
FINAL_COLOR = 0x5E5C64
|
||||
HEADER_BG = 0xE0E0E0
|
||||
|
||||
NOTE_SIZE = 8.5
|
||||
HEAD_SIZE = 9.0
|
||||
BODY_SIZE = 10.0
|
||||
|
||||
ELLIPSIS = " \u2026 "
|
||||
|
||||
|
||||
class Options(object):
|
||||
mode = "cumulative" # "cumulative" | "per_author" | "stacked"
|
||||
show_deletions = True # geloeschten Text durchgestrichen mitfuehren
|
||||
show_notes = True # Anmerkungen uebernehmen
|
||||
show_quotes = True # Bezugstext kommentierter Stellen zitieren
|
||||
elide = True # unveraenderte Passagen kuerzen
|
||||
show_final = True # Endfassung zusaetzlich ausweisen
|
||||
elide_context = 40 # verbleibende Zeichen je Seite
|
||||
only_changed = False # nur geaenderte Absaetze ausgeben
|
||||
landscape = True
|
||||
numbering = True
|
||||
|
||||
|
||||
# -- Hilfsfunktionen ------------------------------------------------------
|
||||
|
||||
def _cell_name(col, row):
|
||||
return "%s%d" % (chr(ord("A") + col), row + 1)
|
||||
|
||||
|
||||
def _author_color(author, authors):
|
||||
if isinstance(author, int):
|
||||
return author # Vergleichsmodus: feste Farbe
|
||||
try:
|
||||
return AUTHOR_COLORS[sorted(authors).index(author) % len(AUTHOR_COLORS)]
|
||||
except (ValueError, AttributeError, TypeError):
|
||||
return NEUTRAL
|
||||
|
||||
|
||||
def _author_label(author):
|
||||
"""Anzeigename eines Bearbeiters."""
|
||||
return t("text.unknown") if author == UNKNOWN_AUTHOR else author
|
||||
|
||||
|
||||
def _tint(color, factor=0.90):
|
||||
"""Heller Farbton derselben Farbe als Hintergrund fuer Anmerkungen."""
|
||||
out = 0
|
||||
for shift in (16, 8, 0):
|
||||
channel = (color >> shift) & 0xFF
|
||||
channel = int(channel + (255 - channel) * factor)
|
||||
out |= min(channel, 255) << shift
|
||||
return out
|
||||
|
||||
|
||||
def _line_struct():
|
||||
for name in ("com.sun.star.table.BorderLine2",
|
||||
"com.sun.star.table.BorderLine"):
|
||||
try:
|
||||
return uno.createUnoStruct(name)
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _border(color, width=50):
|
||||
line = _line_struct()
|
||||
if line is None:
|
||||
return None
|
||||
line.Color = color
|
||||
line.LineWidth = width # 1/100 mm
|
||||
line.OuterLineWidth = width
|
||||
line.InnerLineWidth = 0
|
||||
line.LineDistance = 0
|
||||
line.LineStyle = 0 # SOLID
|
||||
return line
|
||||
|
||||
|
||||
def _no_border():
|
||||
line = _line_struct()
|
||||
if line is None:
|
||||
return None
|
||||
line.LineWidth = 0
|
||||
line.OuterLineWidth = 0
|
||||
try:
|
||||
line.LineStyle = 5 # NONE
|
||||
except Exception:
|
||||
pass
|
||||
return line
|
||||
|
||||
|
||||
# -- Spalteninhalt --------------------------------------------------------
|
||||
|
||||
def _split_first_word(text):
|
||||
"""(erstes Wort mit fuehrendem Leerraum, Rest)."""
|
||||
lead = len(text) - len(text.lstrip())
|
||||
head, rest = text[:lead], text[lead:]
|
||||
cut = rest.find(" ")
|
||||
if cut < 0:
|
||||
return head + rest, ""
|
||||
return head + rest[:cut], rest[cut:]
|
||||
|
||||
|
||||
def _split_last_word(text):
|
||||
"""(Rest, letztes Wort mit nachfolgendem Leerraum)."""
|
||||
body = text.rstrip()
|
||||
trail = text[len(body):]
|
||||
cut = body.rfind(" ")
|
||||
if cut < 0:
|
||||
return "", body + trail
|
||||
return body[:cut + 1], body[cut + 1:] + trail
|
||||
|
||||
|
||||
def _neighbour(seq, index, step):
|
||||
"""Naechster tatsaechlich vorhandener Nachbar mit Textinhalt."""
|
||||
j = index + step
|
||||
while 0 <= j < len(seq):
|
||||
entry = seq[j]
|
||||
if entry["keep"] and entry["text"].strip():
|
||||
return j
|
||||
j += step
|
||||
return None
|
||||
|
||||
|
||||
def _clean_runs(block, key, opts):
|
||||
"""Fassung ohne durchgestrichenen Text, aber mit lesbarer Streichung.
|
||||
|
||||
- unmittelbar ersetzter Text: nur die Neufassung wird hervorgehoben
|
||||
- ersatzlos gestrichener Abschnitt: Hinweis statt leerer Zelle
|
||||
- Streichung innerhalb eines Satzes: das letzte verbliebene Wort davor
|
||||
und das erste danach werden hervorgehoben, damit die Nahtstelle
|
||||
erkennbar bleibt
|
||||
"""
|
||||
seq = []
|
||||
for seg in block.segments:
|
||||
inserted = bool(seg.inserted_by)
|
||||
if key == COL_FINAL:
|
||||
keep = seg.in_final()
|
||||
author = (seg.inserted_by[0] if inserted else
|
||||
(seg.deleted_by[0] if seg.deleted_by else None))
|
||||
removed = not keep and seg.in_original()
|
||||
else:
|
||||
keep = seg.in_version_of(key)
|
||||
author = key
|
||||
removed = not keep and seg.in_original() and key in seg.deleted_by
|
||||
seq.append({"keep": keep, "removed": removed, "text": seg.text,
|
||||
"kind": "insert" if (keep and inserted) else "equal",
|
||||
"author": author})
|
||||
|
||||
dropped = [i for i, e in enumerate(seq) if e["removed"]]
|
||||
if not dropped:
|
||||
return [(e["text"], e["kind"], e["author"]) for e in seq if e["keep"]]
|
||||
|
||||
if not "".join(e["text"] for e in seq if e["keep"]).strip():
|
||||
return [(t("text.removed"), "removed", seq[dropped[0]]["author"])]
|
||||
|
||||
gap_before, gap_after = {}, {}
|
||||
for i in dropped:
|
||||
prev_i = _neighbour(seq, i, -1)
|
||||
next_i = _neighbour(seq, i, 1)
|
||||
replaced = ((prev_i is not None and seq[prev_i]["kind"] == "insert")
|
||||
or (next_i is not None and seq[next_i]["kind"] == "insert"))
|
||||
if replaced:
|
||||
continue # die Neufassung traegt die Markierung
|
||||
author = seq[i]["author"]
|
||||
if prev_i is not None and seq[prev_i]["kind"] == "equal":
|
||||
gap_before[prev_i] = author
|
||||
if next_i is not None and seq[next_i]["kind"] == "equal":
|
||||
gap_after[next_i] = author
|
||||
|
||||
runs = []
|
||||
for i, entry in enumerate(seq):
|
||||
if not entry["keep"]:
|
||||
continue
|
||||
if entry["kind"] != "equal":
|
||||
runs.append((entry["text"], entry["kind"], entry["author"]))
|
||||
continue
|
||||
after, before = gap_after.get(i), gap_before.get(i)
|
||||
first, rest = (_split_first_word(entry["text"])
|
||||
if after is not None else ("", entry["text"]))
|
||||
head, last = (_split_last_word(rest)
|
||||
if before is not None else (rest, ""))
|
||||
if first:
|
||||
runs.append((first, "gap", after))
|
||||
if head:
|
||||
runs.append((head, "equal", None))
|
||||
if last:
|
||||
runs.append((last, "gap", before))
|
||||
return runs
|
||||
|
||||
|
||||
def _runs(block, key, opts, show_deletions=None):
|
||||
"""Liste von (text, kind, author). kind: equal | insert | delete.
|
||||
|
||||
*show_deletions* uebersteuert die Option; damit laesst sich die
|
||||
Endfassung als reiner Beschlusstext ohne Streichungen ausgeben.
|
||||
"""
|
||||
if show_deletions is None:
|
||||
show_deletions = opts.show_deletions
|
||||
if key != COL_ORIGINAL and not show_deletions:
|
||||
return _clean_runs(block, key, opts)
|
||||
runs = []
|
||||
for seg in block.segments:
|
||||
if key == COL_ORIGINAL:
|
||||
if seg.in_original():
|
||||
runs.append((seg.text, "equal", None))
|
||||
continue
|
||||
|
||||
if key == COL_FINAL:
|
||||
keep = seg.in_final()
|
||||
ins, dele = seg.inserted_by, seg.deleted_by
|
||||
author = ins[0] if ins else (dele[0] if dele else None)
|
||||
show_deleted = seg.in_original()
|
||||
else:
|
||||
keep = seg.in_version_of(key)
|
||||
author = key
|
||||
show_deleted = seg.in_original() and key in seg.deleted_by
|
||||
|
||||
if keep:
|
||||
runs.append((seg.text, "insert" if seg.inserted_by else "equal", author))
|
||||
elif show_deletions and show_deleted:
|
||||
runs.append((seg.text, "delete", author))
|
||||
return runs
|
||||
|
||||
|
||||
def _merge(runs):
|
||||
"""Benachbarte Laeufe gleicher Formatierung zusammenfassen."""
|
||||
out = []
|
||||
for text, kind, author in runs:
|
||||
if out and out[-1][1] == kind and out[-1][2] == author:
|
||||
out[-1][0] += text
|
||||
else:
|
||||
out.append([text, kind, author])
|
||||
return [tuple(r) for r in out]
|
||||
|
||||
|
||||
def _head(text, n):
|
||||
cut = text[:n]
|
||||
space = cut.rfind(" ")
|
||||
return (cut[:space] if space > n // 2 else cut).rstrip()
|
||||
|
||||
|
||||
def _tail(text, n):
|
||||
cut = text[-n:]
|
||||
space = cut.find(" ")
|
||||
return (cut[space + 1:] if 0 <= space < n // 2 else cut).lstrip()
|
||||
|
||||
|
||||
def _elide(runs, context):
|
||||
"""Lange unveraenderte Passagen auf den Kontext um die Aenderungen kuerzen."""
|
||||
runs = _merge(runs)
|
||||
if not any(k != "equal" for _t, k, _a in runs):
|
||||
return runs # nichts geaendert: nichts kuerzen
|
||||
out = []
|
||||
for i, (text, kind, author) in enumerate(runs):
|
||||
if kind != "equal" or len(text) <= 2 * context + len(ELLIPSIS):
|
||||
out.append((text, kind, author))
|
||||
continue
|
||||
before = any(k != "equal" for _t, k, _a in runs[:i])
|
||||
after = any(k != "equal" for _t, k, _a in runs[i + 1:])
|
||||
if before and after:
|
||||
new = _head(text, context) + ELLIPSIS + _tail(text, context)
|
||||
elif after:
|
||||
new = ELLIPSIS.lstrip() + _tail(text, context)
|
||||
elif before:
|
||||
new = _head(text, context) + ELLIPSIS.rstrip()
|
||||
else:
|
||||
new = text
|
||||
out.append((new, kind, author))
|
||||
return out
|
||||
|
||||
|
||||
def _active_authors(block, opts):
|
||||
"""Personen, die in diesem Block ueberhaupt in Erscheinung treten."""
|
||||
found = set()
|
||||
for seg in block.segments:
|
||||
for _rtype, author in seg.stack:
|
||||
found.add(author)
|
||||
if opts.show_notes:
|
||||
for note in block.notes:
|
||||
found.add(note.author)
|
||||
return found
|
||||
|
||||
|
||||
def _body(block, key, opts, elide=False):
|
||||
runs = _runs(block, key, opts)
|
||||
if elide and opts.elide:
|
||||
runs = _elide(runs, opts.elide_context)
|
||||
return runs
|
||||
|
||||
|
||||
def _cell_items(block, key, opts, authors):
|
||||
"""Inhalt einer Zelle als Folge von ('body'|'heading'|'note', payload)."""
|
||||
if key == COL_ORIGINAL:
|
||||
return [("body", _runs(block, key, opts))]
|
||||
|
||||
if key == COL_FINAL:
|
||||
# Die Endfassung wird ungekuerzt gezeigt: sie ist der Wortlaut,
|
||||
# der beschlossen werden soll. Streichungen entfallen dabei, sobald
|
||||
# sie an anderer Stelle der Zeile ohnehin sichtbar sind.
|
||||
clean = None if opts.mode == "cumulative" else False
|
||||
items = [("body", _runs(block, key, opts, show_deletions=clean))]
|
||||
if opts.show_notes and opts.mode == "cumulative":
|
||||
# In den anderen Modi stehen die Anmerkungen bereits bei der
|
||||
# jeweiligen Person und wuerden sich hier wiederholen.
|
||||
items += [("note", n) for n in block.notes]
|
||||
return items
|
||||
|
||||
if key == COL_STACKED:
|
||||
editors = set()
|
||||
for seg in block.segments:
|
||||
for _rtype, author in seg.stack:
|
||||
editors.add(author)
|
||||
items = []
|
||||
for author in sorted(_active_authors(block, opts)):
|
||||
note_only = author not in editors
|
||||
color = _author_color(author, authors)
|
||||
suffix = t("text.noteonly") if note_only else ""
|
||||
items.append(("heading", (_author_label(author), suffix, color)))
|
||||
if not note_only:
|
||||
# Wer hier nur kommentiert hat, bekommt keine Textfassung:
|
||||
# sie waere mit der Ursprungsfassung identisch.
|
||||
items.append(("body", _body(block, author, opts, elide=True)))
|
||||
if opts.show_notes:
|
||||
items += [("note", n) for n in block.notes_of(author)]
|
||||
if items and opts.show_final:
|
||||
items.append(("heading", (t("col.final"), "", FINAL_COLOR)))
|
||||
items.append(("body", _runs(block, COL_FINAL, opts,
|
||||
show_deletions=False)))
|
||||
return items
|
||||
|
||||
# eigene Spalte je Person
|
||||
items = [("body", _body(block, key, opts, elide=True))]
|
||||
if opts.show_notes:
|
||||
items += [("note", n) for n in block.notes_of(key)]
|
||||
return items
|
||||
|
||||
|
||||
# -- Schreiben ------------------------------------------------------------
|
||||
|
||||
def _set_border(cur, name, line, distance=0):
|
||||
"""Absatzrahmen setzen; ohne verfuegbare Struktur wird er ausgelassen."""
|
||||
if line is None:
|
||||
return
|
||||
try:
|
||||
setattr(cur, name, line)
|
||||
setattr(cur, name + "Distance", distance)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _reset_para(cur):
|
||||
cur.ParaBackColor = -1
|
||||
cur.ParaLeftMargin = 0
|
||||
cur.ParaRightMargin = 0
|
||||
cur.ParaTopMargin = 0
|
||||
cur.ParaBottomMargin = 0
|
||||
_set_border(cur, "LeftBorder", _no_border())
|
||||
_set_border(cur, "TopBorder", _no_border())
|
||||
|
||||
|
||||
def _heading_para(cur, color, first):
|
||||
_reset_para(cur)
|
||||
cur.ParaTopMargin = 0 if first else 220
|
||||
cur.ParaBottomMargin = 40
|
||||
if not first:
|
||||
_set_border(cur, "TopBorder", _border(color, 20), 90)
|
||||
|
||||
|
||||
def _note_para(cur, color):
|
||||
_reset_para(cur)
|
||||
cur.ParaBackColor = _tint(color)
|
||||
cur.ParaLeftMargin = 200
|
||||
cur.ParaRightMargin = 100
|
||||
cur.ParaTopMargin = 150
|
||||
cur.ParaBottomMargin = 60
|
||||
_set_border(cur, "LeftBorder", _border(color), 120)
|
||||
|
||||
|
||||
def _char(cur, color=NEUTRAL, size=BODY_SIZE, weight=100.0,
|
||||
posture=UPRIGHT, underline=UL_NONE, strikeout=SO_NONE):
|
||||
cur.CharColor = color
|
||||
cur.CharHeight = size
|
||||
cur.CharWeight = weight
|
||||
if posture is not None:
|
||||
cur.CharPosture = posture
|
||||
cur.CharUnderline = underline
|
||||
cur.CharStrikeout = strikeout
|
||||
|
||||
|
||||
def _insert(text, cur, value):
|
||||
for i, part in enumerate(value.split("\n")):
|
||||
if i:
|
||||
text.insertControlCharacter(cur, PARAGRAPH_BREAK, False)
|
||||
if part:
|
||||
text.insertString(cur, part, False)
|
||||
|
||||
|
||||
def _write_cell(cell, items, authors, opts, bold=False):
|
||||
text = cell.getText()
|
||||
text.setString("")
|
||||
cur = text.createTextCursor()
|
||||
_reset_para(cur)
|
||||
wrote = False
|
||||
|
||||
for kind, payload in items:
|
||||
if kind == "body":
|
||||
runs = _merge(payload)
|
||||
if not runs:
|
||||
continue
|
||||
cur.gotoEnd(False)
|
||||
if wrote:
|
||||
text.insertControlCharacter(cur, PARAGRAPH_BREAK, False)
|
||||
_reset_para(cur)
|
||||
for value, rkind, author in runs:
|
||||
color = _author_color(author, authors) if author else NEUTRAL
|
||||
weight = 150.0 if bold else 100.0
|
||||
if rkind == "insert":
|
||||
_char(cur, color, BODY_SIZE, weight, underline=UL_SINGLE)
|
||||
elif rkind == "delete":
|
||||
_char(cur, color, BODY_SIZE, weight, strikeout=SO_SINGLE)
|
||||
elif rkind == "gap":
|
||||
# Nahtstelle einer Streichung: fett in der Autorenfarbe
|
||||
_char(cur, color, BODY_SIZE, 150.0)
|
||||
elif rkind == "removed":
|
||||
_char(cur, color, BODY_SIZE, weight, posture=ITALIC)
|
||||
else:
|
||||
_char(cur, NEUTRAL, BODY_SIZE, weight)
|
||||
_insert(text, cur, value)
|
||||
wrote = True
|
||||
|
||||
elif kind == "heading":
|
||||
label, suffix, color = payload
|
||||
cur.gotoEnd(False)
|
||||
if wrote:
|
||||
text.insertControlCharacter(cur, PARAGRAPH_BREAK, False)
|
||||
_heading_para(cur, color, not wrote)
|
||||
_char(cur, color, HEAD_SIZE, 150.0)
|
||||
_insert(text, cur, label)
|
||||
if suffix:
|
||||
_char(cur, GREY, HEAD_SIZE, 100.0, posture=ITALIC)
|
||||
_insert(text, cur, suffix)
|
||||
wrote = True
|
||||
|
||||
elif kind == "note":
|
||||
note = payload
|
||||
color = _author_color(note.author, authors)
|
||||
cur.gotoEnd(False)
|
||||
if wrote:
|
||||
text.insertControlCharacter(cur, PARAGRAPH_BREAK, False)
|
||||
_note_para(cur, color)
|
||||
|
||||
label = _author_label(note.author)
|
||||
if note.resolved:
|
||||
label += t("text.resolved")
|
||||
_char(cur, color, NOTE_SIZE, 150.0)
|
||||
_insert(text, cur, label + ": ")
|
||||
|
||||
quote = note.short_quote() if opts.show_quotes else ""
|
||||
if quote:
|
||||
_char(cur, GREY, NOTE_SIZE, 100.0, posture=ITALIC)
|
||||
_insert(text, cur, "\u201e%s\u201c \u2013 " % quote)
|
||||
|
||||
_char(cur, GREY if note.resolved else NEUTRAL, NOTE_SIZE, 100.0)
|
||||
_insert(text, cur, note.content or "(ohne Text)")
|
||||
wrote = True
|
||||
|
||||
|
||||
def _set_widths(table, weights):
|
||||
seps = table.TableColumnSeparators
|
||||
if not seps:
|
||||
return
|
||||
total = float(sum(weights))
|
||||
pos, new = 0.0, []
|
||||
for i, sep in enumerate(seps):
|
||||
if i >= len(weights) - 1:
|
||||
break
|
||||
pos += weights[i] / total * 10000.0
|
||||
sep.Position = int(pos)
|
||||
new.append(sep)
|
||||
if new:
|
||||
table.TableColumnSeparators = tuple(new)
|
||||
|
||||
|
||||
def _new_writer(ctx, landscape):
|
||||
desktop = ctx.ServiceManager.createInstanceWithContext(
|
||||
"com.sun.star.frame.Desktop", ctx)
|
||||
doc = desktop.loadComponentFromURL("private:factory/swriter", "_blank", 0, ())
|
||||
if landscape:
|
||||
style = doc.StyleFamilies.getByName("PageStyles").getByName("Standard")
|
||||
if not style.IsLandscape:
|
||||
w, h = style.Width, style.Height
|
||||
style.IsLandscape = True
|
||||
style.Width, style.Height = h, w
|
||||
for name in ("LeftMargin", "RightMargin"):
|
||||
try:
|
||||
setattr(style, name, 1000)
|
||||
except Exception:
|
||||
pass
|
||||
return doc
|
||||
|
||||
|
||||
# -- Aufbau ---------------------------------------------------------------
|
||||
|
||||
def _columns(opts, authors):
|
||||
cols, weights = [], []
|
||||
if opts.numbering:
|
||||
cols.append((t("col.number"), COL_INDEX))
|
||||
weights.append(0.4)
|
||||
cols.append((t("col.original"), COL_ORIGINAL))
|
||||
weights.append(3.0)
|
||||
|
||||
if opts.mode == "cumulative":
|
||||
cols.append((t("col.final"), COL_FINAL))
|
||||
weights.append(3.0)
|
||||
elif opts.mode == "stacked":
|
||||
cols.append((t("col.changes"), COL_STACKED))
|
||||
weights.append(4.0)
|
||||
else:
|
||||
for author in sorted(authors):
|
||||
cols.append((_author_label(author), author))
|
||||
weights.append(3.0)
|
||||
if opts.show_final:
|
||||
cols.append((t("col.final"), COL_FINAL))
|
||||
weights.append(3.0)
|
||||
return cols, weights
|
||||
|
||||
|
||||
def build(ctx, source_doc, opts):
|
||||
blocks, edit_authors, note_authors = extract_blocks(source_doc)
|
||||
authors = set(edit_authors)
|
||||
if opts.show_notes:
|
||||
authors |= note_authors
|
||||
if not authors:
|
||||
raise RuntimeError(t("msg.nochanges"))
|
||||
|
||||
if opts.only_changed or opts.mode == "stacked":
|
||||
# Im gestapelten Modus bleiben unveraenderte Absaetze ohnehin leer.
|
||||
blocks = [b for b in blocks if b.changed] if opts.only_changed else blocks
|
||||
if not blocks:
|
||||
raise RuntimeError(t("msg.noparagraphs"))
|
||||
|
||||
cols, weights = _columns(opts, authors)
|
||||
|
||||
doc = _new_writer(ctx, opts.landscape)
|
||||
body = doc.getText()
|
||||
cur = body.createTextCursor()
|
||||
try:
|
||||
cur.ParaStyleName = "Heading 1"
|
||||
except Exception:
|
||||
pass
|
||||
name = getattr(source_doc, "Title", "") or ""
|
||||
body.insertString(cur, t("doc.title.named", name) if name
|
||||
else t("doc.title"), False)
|
||||
body.insertControlCharacter(cur, PARAGRAPH_BREAK, False)
|
||||
try:
|
||||
cur.ParaStyleName = "Default Paragraph Style"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
table = doc.createInstance("com.sun.star.text.TextTable")
|
||||
table.initialize(len(blocks) + 1, len(cols))
|
||||
body.insertTextContent(cur, table, False)
|
||||
try:
|
||||
table.RepeatHeadline = True
|
||||
table.HeaderRowCount = 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for c, (label, _key) in enumerate(cols):
|
||||
cell = table.getCellByName(_cell_name(c, 0))
|
||||
cell.BackColor = HEADER_BG
|
||||
_write_cell(cell, [("body", [(label, "equal", None)])],
|
||||
authors, opts, bold=True)
|
||||
|
||||
for r, block in enumerate(blocks, start=1):
|
||||
for c, (_label, key) in enumerate(cols):
|
||||
cell = table.getCellByName(_cell_name(c, r))
|
||||
if key == COL_INDEX:
|
||||
_write_cell(cell, [("body", [(str(r), "equal", None)])],
|
||||
authors, opts)
|
||||
else:
|
||||
_write_cell(cell, _cell_items(block, key, opts, authors),
|
||||
authors, opts)
|
||||
|
||||
_set_widths(table, weights)
|
||||
return doc
|
||||
282
pythonpath/synopse/compare.py
Normal file
282
pythonpath/synopse/compare.py
Normal file
@@ -0,0 +1,282 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Vergleich zweier geoeffneter Writer-Dokumente zu einer klassischen Synopse.
|
||||
|
||||
Links die Textstellen der Quellfassung, in der Mitte die der Endfassung,
|
||||
rechts wahlweise die Kommentare aus der Endfassung.
|
||||
|
||||
Die Zuordnung laeuft in zwei Stufen:
|
||||
1. Absatzebene - difflib ueber die normalisierten Absatztexte
|
||||
2. Wortebene - difflib innerhalb eines zugeordneten Absatzpaares
|
||||
"""
|
||||
|
||||
import difflib
|
||||
import re
|
||||
|
||||
from synopse import builder as B
|
||||
from synopse.redline_model import extract_blocks
|
||||
from synopse.i18n import t
|
||||
|
||||
COLOR_DEL = 0xA51D2D
|
||||
COLOR_INS = 0x1A5FB4
|
||||
|
||||
TOKEN = re.compile(r"\S+\s*")
|
||||
|
||||
MATCH_MIN = 0.30 # ab dieser Aehnlichkeit gelten zwei Absaetze als Paar
|
||||
LOOKAHEAD = 3 # Fenster fuer die Suche nach dem Gegenstueck
|
||||
|
||||
|
||||
class CompareOptions(object):
|
||||
comments = "column" # "column" | "inline" | "none"
|
||||
highlight = True # Unterschiede auf Wortebene hervorheben
|
||||
show_quotes = True
|
||||
only_changed = False
|
||||
numbering = True
|
||||
landscape = True
|
||||
|
||||
|
||||
class Unit(object):
|
||||
"""Ein Absatz eines Dokuments samt der dort verankerten Kommentare."""
|
||||
|
||||
__slots__ = ("text", "norm", "notes")
|
||||
|
||||
def __init__(self, text, notes):
|
||||
self.text = text.replace("\n", " ")
|
||||
self.norm = " ".join(self.text.split())
|
||||
self.notes = notes
|
||||
|
||||
|
||||
# -- Dokumente ------------------------------------------------------------
|
||||
|
||||
def open_documents(ctx):
|
||||
"""Alle geoeffneten Writer-Dokumente."""
|
||||
desktop = ctx.ServiceManager.createInstanceWithContext(
|
||||
"com.sun.star.frame.Desktop", ctx)
|
||||
found = []
|
||||
enum = desktop.getComponents().createEnumeration()
|
||||
while enum.hasMoreElements():
|
||||
comp = enum.nextElement()
|
||||
try:
|
||||
if comp.supportsService("com.sun.star.text.TextDocument"):
|
||||
found.append(comp)
|
||||
except Exception:
|
||||
pass
|
||||
return found
|
||||
|
||||
|
||||
def document_title(doc):
|
||||
try:
|
||||
title = doc.getTitle()
|
||||
except Exception:
|
||||
title = ""
|
||||
return title or t("text.unknown")
|
||||
|
||||
|
||||
def to_units(doc):
|
||||
"""Absaetze eines Dokuments in der Fassung 'alle Aenderungen uebernommen'."""
|
||||
blocks, _edit, _note = extract_blocks(doc)
|
||||
units = []
|
||||
for block in blocks:
|
||||
text = "".join(s.text for s in block.segments if s.in_final())
|
||||
units.append(Unit(text, list(block.notes)))
|
||||
return units
|
||||
|
||||
|
||||
# -- Zuordnung ------------------------------------------------------------
|
||||
|
||||
def _ratio(left, right):
|
||||
if left is None or right is None:
|
||||
return 0.0
|
||||
return difflib.SequenceMatcher(None, left.norm, right.norm).ratio()
|
||||
|
||||
|
||||
def _pair_replace(left, right):
|
||||
"""Absaetze eines veraenderten Abschnitts einander zuordnen."""
|
||||
rows = []
|
||||
li = ri = 0
|
||||
while li < len(left) or ri < len(right):
|
||||
if li >= len(left):
|
||||
rows.append((None, right[ri]))
|
||||
ri += 1
|
||||
continue
|
||||
if ri >= len(right):
|
||||
rows.append((left[li], None))
|
||||
li += 1
|
||||
continue
|
||||
|
||||
if _ratio(left[li], right[ri]) >= MATCH_MIN:
|
||||
rows.append((left[li], right[ri]))
|
||||
li += 1
|
||||
ri += 1
|
||||
continue
|
||||
|
||||
# Gegenstueck im Fenster suchen: wurde eingefuegt oder geloescht?
|
||||
span_r = range(ri, min(ri + LOOKAHEAD, len(right)))
|
||||
span_l = range(li, min(li + LOOKAHEAD, len(left)))
|
||||
best_r = max(span_r, key=lambda j: _ratio(left[li], right[j]))
|
||||
best_l = max(span_l, key=lambda i: _ratio(left[i], right[ri]))
|
||||
score_r = _ratio(left[li], right[best_r])
|
||||
score_l = _ratio(left[best_l], right[ri])
|
||||
|
||||
if score_r >= MATCH_MIN and score_r >= score_l:
|
||||
while ri < best_r: # rechts kamen Absaetze hinzu
|
||||
rows.append((None, right[ri]))
|
||||
ri += 1
|
||||
elif score_l >= MATCH_MIN:
|
||||
while li < best_l: # links fielen Absaetze weg
|
||||
rows.append((left[li], None))
|
||||
li += 1
|
||||
else:
|
||||
rows.append((left[li], right[ri]))
|
||||
li += 1
|
||||
ri += 1
|
||||
return rows
|
||||
|
||||
|
||||
def align(source, target):
|
||||
"""Zeilen der Synopse als Liste von (Unit|None, Unit|None)."""
|
||||
a = [u.norm for u in source]
|
||||
b = [u.norm for u in target]
|
||||
matcher = difflib.SequenceMatcher(None, a, b, autojunk=False)
|
||||
rows = []
|
||||
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
||||
if tag == "equal":
|
||||
for k in range(i2 - i1):
|
||||
rows.append((source[i1 + k], target[j1 + k]))
|
||||
elif tag == "delete":
|
||||
for k in range(i1, i2):
|
||||
rows.append((source[k], None))
|
||||
elif tag == "insert":
|
||||
for k in range(j1, j2):
|
||||
rows.append((None, target[k]))
|
||||
else:
|
||||
rows.extend(_pair_replace(source[i1:i2], target[j1:j2]))
|
||||
return rows
|
||||
|
||||
|
||||
# -- Wortebene ------------------------------------------------------------
|
||||
|
||||
def _tokens(text):
|
||||
found = TOKEN.findall(text)
|
||||
return found if found else ([text] if text else [])
|
||||
|
||||
|
||||
def word_runs(left, right, highlight):
|
||||
"""Runs fuer die linke und die mittlere Spalte eines Absatzpaares."""
|
||||
if left is None:
|
||||
return [], [(right.text, "insert", COLOR_INS)]
|
||||
if right is None:
|
||||
return [(left.text, "delete", COLOR_DEL)], []
|
||||
if not highlight or left.norm == right.norm:
|
||||
return ([(left.text, "equal", None)], [(right.text, "equal", None)])
|
||||
|
||||
at, bt = _tokens(left.text), _tokens(right.text)
|
||||
matcher = difflib.SequenceMatcher(
|
||||
None, [t.strip() for t in at], [t.strip() for t in bt], autojunk=False)
|
||||
lrun, rrun = [], []
|
||||
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
||||
if tag in ("equal", "delete", "replace"):
|
||||
chunk = "".join(at[i1:i2])
|
||||
if chunk:
|
||||
lrun.append((chunk, "equal" if tag == "equal" else "delete",
|
||||
None if tag == "equal" else COLOR_DEL))
|
||||
if tag in ("equal", "insert", "replace"):
|
||||
chunk = "".join(bt[j1:j2])
|
||||
if chunk:
|
||||
rrun.append((chunk, "equal" if tag == "equal" else "insert",
|
||||
None if tag == "equal" else COLOR_INS))
|
||||
return lrun, rrun
|
||||
|
||||
|
||||
def _changed(left, right):
|
||||
if left is None or right is None:
|
||||
return True
|
||||
if left.norm != right.norm:
|
||||
return True
|
||||
return bool(right.notes)
|
||||
|
||||
|
||||
# -- Aufbau ---------------------------------------------------------------
|
||||
|
||||
def build(ctx, source_doc, target_doc, opts):
|
||||
source = to_units(source_doc)
|
||||
target = to_units(target_doc)
|
||||
rows = align(source, target)
|
||||
|
||||
if opts.only_changed:
|
||||
rows = [r for r in rows if _changed(*r)]
|
||||
if not rows:
|
||||
raise RuntimeError(t("msg.identical"))
|
||||
|
||||
has_notes = any(r[1] is not None and r[1].notes for r in rows)
|
||||
note_authors = set()
|
||||
for _l, r in rows:
|
||||
if r is not None:
|
||||
for note in r.notes:
|
||||
note_authors.add(note.author)
|
||||
|
||||
mode = opts.comments
|
||||
if not has_notes or mode == "none":
|
||||
mode = "none" # rechte Spalte entfaellt
|
||||
|
||||
cols, weights = [], []
|
||||
if opts.numbering:
|
||||
cols.append((t("col.number"), "index"))
|
||||
weights.append(0.4)
|
||||
cols.append((B._head(document_title(source_doc), 40), "left"))
|
||||
weights.append(3.0)
|
||||
cols.append((B._head(document_title(target_doc), 40), "right"))
|
||||
weights.append(3.0)
|
||||
if mode == "column":
|
||||
cols.append((t("col.comments"), "notes"))
|
||||
weights.append(2.0)
|
||||
|
||||
doc = B._new_writer(ctx, opts.landscape)
|
||||
body = doc.getText()
|
||||
cur = body.createTextCursor()
|
||||
try:
|
||||
cur.ParaStyleName = "Heading 1"
|
||||
except Exception:
|
||||
pass
|
||||
body.insertString(cur, t("doc.title.compare",
|
||||
document_title(source_doc),
|
||||
document_title(target_doc)), False)
|
||||
body.insertControlCharacter(cur, B.PARAGRAPH_BREAK, False)
|
||||
try:
|
||||
cur.ParaStyleName = "Default Paragraph Style"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
table = doc.createInstance("com.sun.star.text.TextTable")
|
||||
table.initialize(len(rows) + 1, len(cols))
|
||||
body.insertTextContent(cur, table, False)
|
||||
try:
|
||||
table.RepeatHeadline = True
|
||||
table.HeaderRowCount = 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for c, (label, _key) in enumerate(cols):
|
||||
cell = table.getCellByName(B._cell_name(c, 0))
|
||||
cell.BackColor = B.HEADER_BG
|
||||
B._write_cell(cell, [("body", [(label, "equal", None)])],
|
||||
note_authors, opts, bold=True)
|
||||
|
||||
for r, (left, right) in enumerate(rows, start=1):
|
||||
lrun, rrun = word_runs(left, right, opts.highlight)
|
||||
notes = list(right.notes) if right is not None else []
|
||||
for c, (_label, key) in enumerate(cols):
|
||||
cell = table.getCellByName(B._cell_name(c, r))
|
||||
if key == "index":
|
||||
items = [("body", [(str(r), "equal", None)])]
|
||||
elif key == "left":
|
||||
items = [("body", lrun)]
|
||||
elif key == "right":
|
||||
items = [("body", rrun)]
|
||||
if mode == "inline":
|
||||
items += [("note", n) for n in notes]
|
||||
else:
|
||||
items = [("note", n) for n in notes]
|
||||
B._write_cell(cell, items, note_authors, opts)
|
||||
|
||||
B._set_widths(table, weights)
|
||||
return doc
|
||||
241
pythonpath/synopse/dialog.py
Normal file
241
pythonpath/synopse/dialog.py
Normal file
@@ -0,0 +1,241 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Optionsdialoge, programmatisch ueber com.sun.star.awt aufgebaut."""
|
||||
|
||||
import sys
|
||||
|
||||
import uno
|
||||
|
||||
from synopse.builder import Options
|
||||
from synopse.i18n import t
|
||||
|
||||
|
||||
def message(ctx, text, title=None):
|
||||
if title is None:
|
||||
title = t("doc.title")
|
||||
try:
|
||||
toolkit = ctx.ServiceManager.createInstanceWithContext(
|
||||
"com.sun.star.awt.Toolkit", ctx)
|
||||
box = toolkit.createMessageBox(
|
||||
None,
|
||||
uno.Enum("com.sun.star.awt.MessageBoxType", "INFOBOX"),
|
||||
1, # BUTTONS_OK
|
||||
title, text)
|
||||
box.execute()
|
||||
box.dispose()
|
||||
except Exception:
|
||||
# Ohne Dialogfenster bleibt nur die Konsole
|
||||
sys.stderr.write("%s: %s\n" % (title, text))
|
||||
|
||||
|
||||
def _add(model, kind, name, props):
|
||||
ctrl = model.createInstance("com.sun.star.awt.UnoControl%sModel" % kind)
|
||||
for key, value in props.items():
|
||||
setattr(ctrl, key, value)
|
||||
model.insertByName(name, ctrl)
|
||||
return ctrl
|
||||
|
||||
|
||||
def _dialog(ctx, model):
|
||||
dlg = ctx.ServiceManager.createInstanceWithContext(
|
||||
"com.sun.star.awt.UnoControlDialog", ctx)
|
||||
dlg.setModel(model)
|
||||
toolkit = ctx.ServiceManager.createInstanceWithContext(
|
||||
"com.sun.star.awt.Toolkit", ctx)
|
||||
dlg.createPeer(toolkit, None)
|
||||
return dlg
|
||||
|
||||
|
||||
def _buttons(model, y):
|
||||
_add(model, "Button", "btnOk",
|
||||
dict(PositionX=128, PositionY=y, Width=54, Height=16,
|
||||
Label=t("dlg.ok"), PushButtonType=1, DefaultButton=True))
|
||||
_add(model, "Button", "btnCancel",
|
||||
dict(PositionX=186, PositionY=y, Width=54, Height=16,
|
||||
Label=t("dlg.cancel"), PushButtonType=2))
|
||||
|
||||
|
||||
def show_options(ctx, edit_authors, note_authors):
|
||||
"""Optionen fuer die Synopse aus der Aenderungsverfolgung."""
|
||||
people = sorted(edit_authors | note_authors)
|
||||
count = len(people)
|
||||
names = ", ".join(people) or t("dlg.people.none")
|
||||
if len(names) > 52:
|
||||
names = names[:49] + "\u2026"
|
||||
|
||||
model = ctx.ServiceManager.createInstanceWithContext(
|
||||
"com.sun.star.awt.UnoControlDialogModel", ctx)
|
||||
model.setPropertyValues(
|
||||
("PositionX", "PositionY", "Width", "Height", "Title"),
|
||||
(120, 120, 248, 264, t("dlg.title")))
|
||||
|
||||
_add(model, "FixedText", "lblPeople",
|
||||
dict(PositionX=8, PositionY=6, Width=232, Height=10,
|
||||
Label=t("dlg.people", count, names)))
|
||||
_add(model, "FixedLine", "sep0",
|
||||
dict(PositionX=8, PositionY=18, Width=232, Height=6,
|
||||
Label=t("dlg.group.mode")))
|
||||
|
||||
_add(model, "RadioButton", "optCum",
|
||||
dict(PositionX=14, PositionY=30, Width=226, Height=12, State=1,
|
||||
Label=t("dlg.mode.cumulative")))
|
||||
_add(model, "RadioButton", "optStack",
|
||||
dict(PositionX=14, PositionY=43, Width=226, Height=12,
|
||||
Label=t("dlg.mode.stacked")))
|
||||
_add(model, "RadioButton", "optAuthor",
|
||||
dict(PositionX=14, PositionY=56, Width=226, Height=12,
|
||||
Label=t("dlg.mode.separate", count)))
|
||||
_add(model, "FixedText", "hint",
|
||||
dict(PositionX=26, PositionY=69, Width=214, Height=18, MultiLine=True,
|
||||
Label=t("dlg.mode.hint")))
|
||||
|
||||
_add(model, "FixedLine", "sep1",
|
||||
dict(PositionX=8, PositionY=92, Width=232, Height=6,
|
||||
Label=t("dlg.group.content")))
|
||||
_add(model, "CheckBox", "chkDel",
|
||||
dict(PositionX=8, PositionY=104, Width=232, Height=12, State=1,
|
||||
Label=t("dlg.opt.deletions")))
|
||||
_add(model, "CheckBox", "chkElide",
|
||||
dict(PositionX=8, PositionY=118, Width=232, Height=12, State=1,
|
||||
Label=t("dlg.opt.elide")))
|
||||
_add(model, "CheckBox", "chkFinal",
|
||||
dict(PositionX=8, PositionY=132, Width=232, Height=12, State=1,
|
||||
Label=t("dlg.opt.final")))
|
||||
_add(model, "CheckBox", "chkNotes",
|
||||
dict(PositionX=8, PositionY=146, Width=232, Height=12, State=1,
|
||||
Label=t("dlg.opt.notes")))
|
||||
_add(model, "CheckBox", "chkQuote",
|
||||
dict(PositionX=20, PositionY=160, Width=220, Height=12, State=1,
|
||||
Label=t("dlg.opt.quote")))
|
||||
_add(model, "CheckBox", "chkOnly",
|
||||
dict(PositionX=8, PositionY=178, Width=232, Height=12, State=0,
|
||||
Label=t("dlg.opt.onlychanged")))
|
||||
|
||||
_add(model, "FixedLine", "sep2",
|
||||
dict(PositionX=8, PositionY=194, Width=232, Height=6,
|
||||
Label=t("dlg.group.target")))
|
||||
_add(model, "CheckBox", "chkNum",
|
||||
dict(PositionX=8, PositionY=206, Width=110, Height=12, State=1,
|
||||
Label=t("dlg.opt.numbering")))
|
||||
_add(model, "CheckBox", "chkLand",
|
||||
dict(PositionX=124, PositionY=206, Width=116, Height=12, State=1,
|
||||
Label=t("dlg.opt.landscape")))
|
||||
|
||||
_buttons(model, 238)
|
||||
|
||||
dlg = _dialog(ctx, model)
|
||||
try:
|
||||
if dlg.execute() != 1:
|
||||
return None
|
||||
opts = Options()
|
||||
if model.getByName("optAuthor").State:
|
||||
opts.mode = "per_author"
|
||||
elif model.getByName("optStack").State:
|
||||
opts.mode = "stacked"
|
||||
else:
|
||||
opts.mode = "cumulative"
|
||||
opts.show_deletions = bool(model.getByName("chkDel").State)
|
||||
opts.elide = bool(model.getByName("chkElide").State)
|
||||
opts.show_final = bool(model.getByName("chkFinal").State)
|
||||
opts.show_notes = bool(model.getByName("chkNotes").State)
|
||||
opts.show_quotes = bool(model.getByName("chkQuote").State)
|
||||
opts.only_changed = bool(model.getByName("chkOnly").State)
|
||||
opts.numbering = bool(model.getByName("chkNum").State)
|
||||
opts.landscape = bool(model.getByName("chkLand").State)
|
||||
return opts
|
||||
finally:
|
||||
dlg.dispose()
|
||||
|
||||
|
||||
def show_compare_options(ctx, docs, current=None):
|
||||
"""Auswahl der beiden Dokumente. Liefert (quelle, ziel, options)."""
|
||||
from synopse.compare import CompareOptions, document_title
|
||||
|
||||
titles = [document_title(d) for d in docs]
|
||||
|
||||
# Vorauswahl: aktuelles Dokument als Endfassung, das andere als Quelle
|
||||
target_idx = docs.index(current) if current in docs else len(docs) - 1
|
||||
source_idx = 0 if target_idx != 0 else 1
|
||||
|
||||
model = ctx.ServiceManager.createInstanceWithContext(
|
||||
"com.sun.star.awt.UnoControlDialogModel", ctx)
|
||||
model.setPropertyValues(
|
||||
("PositionX", "PositionY", "Width", "Height", "Title"),
|
||||
(120, 120, 248, 258, t("cmp.title")))
|
||||
|
||||
_add(model, "FixedLine", "sep0",
|
||||
dict(PositionX=8, PositionY=6, Width=232, Height=6,
|
||||
Label=t("cmp.group.docs")))
|
||||
_add(model, "FixedText", "lblSrc",
|
||||
dict(PositionX=8, PositionY=18, Width=232, Height=10,
|
||||
Label=t("cmp.source")))
|
||||
_add(model, "ListBox", "lstSrc",
|
||||
dict(PositionX=8, PositionY=29, Width=232, Height=12, Dropdown=True,
|
||||
StringItemList=tuple(titles), SelectedItems=(source_idx,)))
|
||||
_add(model, "FixedText", "lblTgt",
|
||||
dict(PositionX=8, PositionY=46, Width=232, Height=10,
|
||||
Label=t("cmp.target")))
|
||||
_add(model, "ListBox", "lstTgt",
|
||||
dict(PositionX=8, PositionY=57, Width=232, Height=12, Dropdown=True,
|
||||
StringItemList=tuple(titles), SelectedItems=(target_idx,)))
|
||||
|
||||
_add(model, "FixedLine", "sep1",
|
||||
dict(PositionX=8, PositionY=76, Width=232, Height=6,
|
||||
Label=t("cmp.group.comments")))
|
||||
_add(model, "RadioButton", "optCol",
|
||||
dict(PositionX=14, PositionY=88, Width=226, Height=12, State=1,
|
||||
Label=t("cmp.comments.column")))
|
||||
_add(model, "RadioButton", "optInline",
|
||||
dict(PositionX=14, PositionY=101, Width=226, Height=12,
|
||||
Label=t("cmp.comments.inline")))
|
||||
_add(model, "RadioButton", "optNone",
|
||||
dict(PositionX=14, PositionY=114, Width=226, Height=12,
|
||||
Label=t("cmp.comments.none")))
|
||||
_add(model, "FixedText", "hint",
|
||||
dict(PositionX=26, PositionY=127, Width=214, Height=18, MultiLine=True,
|
||||
Label=t("cmp.comments.hint")))
|
||||
|
||||
_add(model, "FixedLine", "sep2",
|
||||
dict(PositionX=8, PositionY=150, Width=232, Height=6,
|
||||
Label=t("cmp.group.display")))
|
||||
_add(model, "CheckBox", "chkHi",
|
||||
dict(PositionX=8, PositionY=162, Width=232, Height=12, State=1,
|
||||
Label=t("cmp.opt.highlight")))
|
||||
_add(model, "CheckBox", "chkQuote",
|
||||
dict(PositionX=8, PositionY=176, Width=232, Height=12, State=1,
|
||||
Label=t("cmp.opt.quote")))
|
||||
_add(model, "CheckBox", "chkOnly",
|
||||
dict(PositionX=8, PositionY=190, Width=232, Height=12, State=0,
|
||||
Label=t("cmp.opt.onlychanged")))
|
||||
_add(model, "CheckBox", "chkNum",
|
||||
dict(PositionX=8, PositionY=204, Width=110, Height=12, State=1,
|
||||
Label=t("dlg.opt.numbering")))
|
||||
_add(model, "CheckBox", "chkLand",
|
||||
dict(PositionX=124, PositionY=204, Width=116, Height=12, State=1,
|
||||
Label=t("dlg.opt.landscape")))
|
||||
|
||||
_buttons(model, 232)
|
||||
|
||||
dlg = _dialog(ctx, model)
|
||||
try:
|
||||
if dlg.execute() != 1:
|
||||
return None
|
||||
src = model.getByName("lstSrc").SelectedItems
|
||||
tgt = model.getByName("lstTgt").SelectedItems
|
||||
if not src or not tgt or src[0] == tgt[0]:
|
||||
message(ctx, t("msg.different"))
|
||||
return None
|
||||
opts = CompareOptions()
|
||||
if model.getByName("optInline").State:
|
||||
opts.comments = "inline"
|
||||
elif model.getByName("optNone").State:
|
||||
opts.comments = "none"
|
||||
else:
|
||||
opts.comments = "column"
|
||||
opts.highlight = bool(model.getByName("chkHi").State)
|
||||
opts.show_quotes = bool(model.getByName("chkQuote").State)
|
||||
opts.only_changed = bool(model.getByName("chkOnly").State)
|
||||
opts.numbering = bool(model.getByName("chkNum").State)
|
||||
opts.landscape = bool(model.getByName("chkLand").State)
|
||||
return docs[src[0]], docs[tgt[0]], opts
|
||||
finally:
|
||||
dlg.dispose()
|
||||
256
pythonpath/synopse/i18n.py
Normal file
256
pythonpath/synopse/i18n.py
Normal file
@@ -0,0 +1,256 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Oberflaechentexte in Deutsch, Englisch und Franzoesisch.
|
||||
|
||||
Die Sprache richtet sich nach der Oberflaechensprache von LibreOffice
|
||||
(/org.openoffice.Setup/L10N/ooLocale). Ist sie nicht hinterlegt, wird
|
||||
Englisch verwendet.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import uno
|
||||
|
||||
FALLBACK = "en"
|
||||
|
||||
TEXTS = {
|
||||
"en": {
|
||||
# -- Dialog: Synopse aus der Aenderungsverfolgung -----------------
|
||||
"dlg.title": "Create synopsis",
|
||||
"dlg.people": "Contributors (%d): %s",
|
||||
"dlg.people.none": "none found",
|
||||
"dlg.group.mode": "Display of changes",
|
||||
"dlg.mode.cumulative": "Cumulative \u2013 one column with the final version",
|
||||
"dlg.mode.stacked": "Stacked \u2013 one column, contributors below each other",
|
||||
"dlg.mode.separate": "Separate \u2013 one column per contributor (%d)",
|
||||
"dlg.mode.hint": "With more than three contributors the separate "
|
||||
"display quickly becomes too narrow.",
|
||||
"dlg.group.content": "Content",
|
||||
"dlg.opt.deletions": "Keep deleted text, struck through",
|
||||
"dlg.opt.elide": "Shorten unchanged passages to the context",
|
||||
"dlg.opt.final": "Also show the final version (stacked/separate)",
|
||||
"dlg.opt.notes": "Include comments and annotations",
|
||||
"dlg.opt.quote": "quoting the commented passage",
|
||||
"dlg.opt.onlychanged": "Only changed or commented paragraphs",
|
||||
"dlg.group.target": "Target document",
|
||||
"dlg.opt.numbering": "Sequential number",
|
||||
"dlg.opt.landscape": "Landscape",
|
||||
"dlg.ok": "OK",
|
||||
"dlg.cancel": "Cancel",
|
||||
|
||||
# -- Dialog: Dokumentvergleich ------------------------------------
|
||||
"cmp.title": "Compare two documents",
|
||||
"cmp.group.docs": "Documents",
|
||||
"cmp.source": "Source version (left column):",
|
||||
"cmp.target": "Final version (middle column):",
|
||||
"cmp.group.comments": "Comments in the final version",
|
||||
"cmp.comments.column": "In a separate column on the right",
|
||||
"cmp.comments.inline": "Below the passage in the middle column",
|
||||
"cmp.comments.none": "Do not include",
|
||||
"cmp.comments.hint": "If the final version contains no comments, "
|
||||
"the third column is omitted automatically.",
|
||||
"cmp.group.display": "Display",
|
||||
"cmp.opt.highlight": "Highlight differences word by word",
|
||||
"cmp.opt.quote": "Quote the commented passage",
|
||||
"cmp.opt.onlychanged": "Only include differing paragraphs",
|
||||
|
||||
# -- Tabelle -------------------------------------------------------
|
||||
"col.number": "No.",
|
||||
"col.original": "Original version",
|
||||
"col.final": "Final version",
|
||||
"col.changes": "Changes",
|
||||
"col.comments": "Comments",
|
||||
"doc.title": "Synopsis",
|
||||
"doc.title.named": "Synopsis: %s",
|
||||
"doc.title.compare": "Synopsis: %s \u2192 %s",
|
||||
"text.removed": "deleted without replacement",
|
||||
"text.noteonly": " \u2013 comment only",
|
||||
"text.resolved": " (resolved)",
|
||||
"text.unknown": "(unknown)",
|
||||
|
||||
# -- Meldungen -----------------------------------------------------
|
||||
"msg.nodoc": "Please bring a Writer document to the front first.",
|
||||
"msg.nochanges": "The document contains neither tracked changes "
|
||||
"nor comments.",
|
||||
"msg.noparagraphs": "No changed paragraphs were found.",
|
||||
"msg.twodocs": "At least two Writer documents must be open "
|
||||
"for a comparison.",
|
||||
"msg.different": "Please select two different documents.",
|
||||
"msg.identical": "The two documents are identical in content.",
|
||||
"msg.error.build": "Error while creating the synopsis:\n\n%s\n\n%s",
|
||||
"msg.error.compare": "Error during comparison:\n\n%s\n\n%s",
|
||||
},
|
||||
|
||||
"de": {
|
||||
"dlg.title": "Synopse erzeugen",
|
||||
"dlg.people": "Beteiligte (%d): %s",
|
||||
"dlg.people.none": "keine gefunden",
|
||||
"dlg.group.mode": "Darstellung der \u00c4nderungen",
|
||||
"dlg.mode.cumulative": "Kumuliert \u2013 eine Spalte mit der Endfassung",
|
||||
"dlg.mode.stacked": "Gestapelt \u2013 eine Spalte, Personen untereinander",
|
||||
"dlg.mode.separate": "Getrennt \u2013 je Person eine eigene Spalte (%d)",
|
||||
"dlg.mode.hint": "Bei mehr als drei Beteiligten wird die getrennte "
|
||||
"Darstellung schnell zu schmal.",
|
||||
"dlg.group.content": "Inhalt",
|
||||
"dlg.opt.deletions": "Gel\u00f6schten Text durchgestrichen mitf\u00fchren",
|
||||
"dlg.opt.elide": "Unver\u00e4nderte Passagen auf den Kontext k\u00fcrzen",
|
||||
"dlg.opt.final": "Endfassung zus\u00e4tzlich ausweisen (gestapelt/getrennt)",
|
||||
"dlg.opt.notes": "Anmerkungen und Kommentare \u00fcbernehmen",
|
||||
"dlg.opt.quote": "dabei kommentierte Textstelle zitieren",
|
||||
"dlg.opt.onlychanged": "Nur ge\u00e4nderte oder kommentierte Abs\u00e4tze",
|
||||
"dlg.group.target": "Zieldokument",
|
||||
"dlg.opt.numbering": "Laufende Nummer",
|
||||
"dlg.opt.landscape": "Querformat",
|
||||
"dlg.ok": "OK",
|
||||
"dlg.cancel": "Abbrechen",
|
||||
|
||||
"cmp.title": "Zwei Dokumente vergleichen",
|
||||
"cmp.group.docs": "Dokumente",
|
||||
"cmp.source": "Quellfassung (linke Spalte):",
|
||||
"cmp.target": "Endfassung (mittlere Spalte):",
|
||||
"cmp.group.comments": "Kommentare der Endfassung",
|
||||
"cmp.comments.column": "In einer eigenen Spalte rechts",
|
||||
"cmp.comments.inline": "Unter der Textstelle in der mittleren Spalte",
|
||||
"cmp.comments.none": "Nicht \u00fcbernehmen",
|
||||
"cmp.comments.hint": "Enth\u00e4lt die Endfassung keine Kommentare, "
|
||||
"entf\u00e4llt die dritte Spalte automatisch.",
|
||||
"cmp.group.display": "Darstellung",
|
||||
"cmp.opt.highlight": "Unterschiede wortweise hervorheben",
|
||||
"cmp.opt.quote": "Kommentierte Textstelle zitieren",
|
||||
"cmp.opt.onlychanged": "Nur abweichende Abs\u00e4tze aufnehmen",
|
||||
|
||||
"col.number": "Nr.",
|
||||
"col.original": "Ursprungsfassung",
|
||||
"col.final": "Endfassung",
|
||||
"col.changes": "\u00c4nderungen",
|
||||
"col.comments": "Kommentare",
|
||||
"doc.title": "Synopse",
|
||||
"doc.title.named": "Synopse: %s",
|
||||
"doc.title.compare": "Synopse: %s \u2192 %s",
|
||||
"text.removed": "ersatzlos gestrichen",
|
||||
"text.noteonly": " \u2013 nur Anmerkung",
|
||||
"text.resolved": " (erledigt)",
|
||||
"text.unknown": "(unbekannt)",
|
||||
|
||||
"msg.nodoc": "Bitte zuerst ein Writer-Dokument in den Vordergrund holen.",
|
||||
"msg.nochanges": "Das Dokument enth\u00e4lt weder nachverfolgte "
|
||||
"\u00c4nderungen noch Anmerkungen.",
|
||||
"msg.noparagraphs": "Es wurden keine ge\u00e4nderten Abs\u00e4tze gefunden.",
|
||||
"msg.twodocs": "F\u00fcr den Vergleich m\u00fcssen mindestens zwei "
|
||||
"Writer-Dokumente ge\u00f6ffnet sein.",
|
||||
"msg.different": "Bitte zwei verschiedene Dokumente ausw\u00e4hlen.",
|
||||
"msg.identical": "Die beiden Dokumente sind inhaltlich identisch.",
|
||||
"msg.error.build": "Fehler beim Erzeugen der Synopse:\n\n%s\n\n%s",
|
||||
"msg.error.compare": "Fehler beim Vergleich:\n\n%s\n\n%s",
|
||||
},
|
||||
|
||||
"fr": {
|
||||
"dlg.title": "Cr\u00e9er une synopse",
|
||||
"dlg.people": "Participants (%d) : %s",
|
||||
"dlg.people.none": "aucun trouv\u00e9",
|
||||
"dlg.group.mode": "Pr\u00e9sentation des modifications",
|
||||
"dlg.mode.cumulative": "Cumul\u00e9e \u2013 une colonne avec la version finale",
|
||||
"dlg.mode.stacked": "Empil\u00e9e \u2013 une colonne, participants l\u2019un sous l\u2019autre",
|
||||
"dlg.mode.separate": "S\u00e9par\u00e9e \u2013 une colonne par participant (%d)",
|
||||
"dlg.mode.hint": "Au-del\u00e0 de trois participants, la pr\u00e9sentation "
|
||||
"s\u00e9par\u00e9e devient vite trop \u00e9troite.",
|
||||
"dlg.group.content": "Contenu",
|
||||
"dlg.opt.deletions": "Conserver le texte supprim\u00e9, barr\u00e9",
|
||||
"dlg.opt.elide": "R\u00e9duire les passages inchang\u00e9s au contexte",
|
||||
"dlg.opt.final": "Afficher aussi la version finale (empil\u00e9e/s\u00e9par\u00e9e)",
|
||||
"dlg.opt.notes": "Reprendre les commentaires et annotations",
|
||||
"dlg.opt.quote": "en citant le passage comment\u00e9",
|
||||
"dlg.opt.onlychanged": "Uniquement les paragraphes modifi\u00e9s ou comment\u00e9s",
|
||||
"dlg.group.target": "Document cible",
|
||||
"dlg.opt.numbering": "Num\u00e9rotation",
|
||||
"dlg.opt.landscape": "Paysage",
|
||||
"dlg.ok": "OK",
|
||||
"dlg.cancel": "Annuler",
|
||||
|
||||
"cmp.title": "Comparer deux documents",
|
||||
"cmp.group.docs": "Documents",
|
||||
"cmp.source": "Version source (colonne de gauche) :",
|
||||
"cmp.target": "Version finale (colonne du milieu) :",
|
||||
"cmp.group.comments": "Commentaires de la version finale",
|
||||
"cmp.comments.column": "Dans une colonne distincte \u00e0 droite",
|
||||
"cmp.comments.inline": "Sous le passage dans la colonne du milieu",
|
||||
"cmp.comments.none": "Ne pas reprendre",
|
||||
"cmp.comments.hint": "Si la version finale ne contient aucun "
|
||||
"commentaire, la troisi\u00e8me colonne est "
|
||||
"supprim\u00e9e automatiquement.",
|
||||
"cmp.group.display": "Pr\u00e9sentation",
|
||||
"cmp.opt.highlight": "Mettre en \u00e9vidence les diff\u00e9rences mot \u00e0 mot",
|
||||
"cmp.opt.quote": "Citer le passage comment\u00e9",
|
||||
"cmp.opt.onlychanged": "N\u2019inclure que les paragraphes divergents",
|
||||
|
||||
"col.number": "N\u00b0",
|
||||
"col.original": "Version initiale",
|
||||
"col.final": "Version finale",
|
||||
"col.changes": "Modifications",
|
||||
"col.comments": "Commentaires",
|
||||
"doc.title": "Synopse",
|
||||
"doc.title.named": "Synopse : %s",
|
||||
"doc.title.compare": "Synopse : %s \u2192 %s",
|
||||
"text.removed": "supprim\u00e9 sans remplacement",
|
||||
"text.noteonly": " \u2013 commentaire seulement",
|
||||
"text.resolved": " (r\u00e9solu)",
|
||||
"text.unknown": "(inconnu)",
|
||||
|
||||
"msg.nodoc": "Veuillez d\u2019abord placer un document Writer au "
|
||||
"premier plan.",
|
||||
"msg.nochanges": "Le document ne contient ni modifications suivies "
|
||||
"ni commentaires.",
|
||||
"msg.noparagraphs": "Aucun paragraphe modifi\u00e9 n\u2019a \u00e9t\u00e9 trouv\u00e9.",
|
||||
"msg.twodocs": "Au moins deux documents Writer doivent \u00eatre "
|
||||
"ouverts pour une comparaison.",
|
||||
"msg.different": "Veuillez s\u00e9lectionner deux documents diff\u00e9rents.",
|
||||
"msg.identical": "Les deux documents ont un contenu identique.",
|
||||
"msg.error.build": "Erreur lors de la cr\u00e9ation de la synopse :"
|
||||
"\n\n%s\n\n%s",
|
||||
"msg.error.compare": "Erreur lors de la comparaison :\n\n%s\n\n%s",
|
||||
},
|
||||
}
|
||||
|
||||
_language = FALLBACK
|
||||
|
||||
|
||||
def _office_locale(ctx):
|
||||
"""Oberflaechensprache von LibreOffice, z. B. 'de-DE'."""
|
||||
try:
|
||||
provider = ctx.ServiceManager.createInstanceWithContext(
|
||||
"com.sun.star.configuration.ConfigurationProvider", ctx)
|
||||
argument = uno.createUnoStruct("com.sun.star.beans.PropertyValue")
|
||||
argument.Name = "nodepath"
|
||||
argument.Value = "/org.openoffice.Setup/L10N"
|
||||
node = provider.createInstanceWithArguments(
|
||||
"com.sun.star.configuration.ConfigurationAccess", (argument,))
|
||||
return node.getByName("ooLocale") or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def set_language(ctx=None):
|
||||
"""Sprache bestimmen. Ist sie nicht hinterlegt, bleibt es bei Englisch."""
|
||||
global _language
|
||||
tag = _office_locale(ctx) if ctx is not None else ""
|
||||
if not tag:
|
||||
tag = os.environ.get("LANGUAGE") or os.environ.get("LANG") or ""
|
||||
primary = tag.replace("_", "-").split("-")[0].split(".")[0].lower()
|
||||
_language = primary if primary in TEXTS else FALLBACK
|
||||
return _language
|
||||
|
||||
|
||||
def language():
|
||||
return _language
|
||||
|
||||
|
||||
def t(key, *args):
|
||||
"""Uebersetzten Text liefern; fehlt er, greift Englisch."""
|
||||
value = TEXTS.get(_language, {}).get(key)
|
||||
if value is None:
|
||||
value = TEXTS[FALLBACK].get(key, key)
|
||||
if args:
|
||||
try:
|
||||
value = value % args
|
||||
except Exception:
|
||||
pass
|
||||
return value
|
||||
402
pythonpath/synopse/redline_model.py
Normal file
402
pythonpath/synopse/redline_model.py
Normal file
@@ -0,0 +1,402 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Liest ein Writer-Dokument mit Aenderungsverfolgung und baut daraus ein
|
||||
Modell aus Bloecken auf.
|
||||
|
||||
Ein Block entspricht einer Zeile der spaeteren Synopse und enthaelt
|
||||
* Segmente - Textstuecke samt der Redlines, in denen sie liegen
|
||||
* Notes - Anmerkungen, die in diesem Bereich verankert sind
|
||||
|
||||
Grundidee: Der Text wird einmal linear durchlaufen. Waehrend des Durchlaufs
|
||||
wird ein Stack der gerade offenen Redlines gefuehrt. Jedes Textstueck wird
|
||||
zusammen mit diesem Stack gespeichert. Aus dem Stack laesst sich anschliessend
|
||||
jede beliebige Fassung rekonstruieren, ohne das Dokument erneut anzufassen.
|
||||
"""
|
||||
|
||||
import uno
|
||||
|
||||
|
||||
def const(name, default):
|
||||
"""UNO-Konstante aufloesen, ohne beim Import zu scheitern.
|
||||
|
||||
getConstantByName wirft eine RuntimeException, wenn der Typmanager den
|
||||
Namen nicht kennt. Geschieht das beim Laden des Moduls, bricht der
|
||||
Python-Loader die Registrierung der Erweiterung ab. Deshalb wird hier
|
||||
immer auf den dokumentierten Zahlenwert zurueckgefallen.
|
||||
"""
|
||||
try:
|
||||
return uno.getConstantByName(name)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def const_opt(name):
|
||||
"""Wie const(), liefert aber None statt eines Ersatzwertes."""
|
||||
try:
|
||||
return uno.getConstantByName(name)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def enum(type_name, value):
|
||||
"""UNO-Enum aufloesen; liefert None, wenn der Typ unbekannt ist."""
|
||||
try:
|
||||
return uno.Enum(type_name, value)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
RD_NAME = "com.sun.star.text.RedlineDisplayType.INSERTED_AND_REMOVED"
|
||||
|
||||
# Ergebnis der einmaligen empirischen Bestimmung (siehe _display_all_value)
|
||||
_display_all_cache = None
|
||||
|
||||
INSERT = "Insert"
|
||||
DELETE = "Delete"
|
||||
|
||||
UNKNOWN_AUTHOR = "\x00unknown" # sprachneutral, Anzeige ueber i18n
|
||||
|
||||
QUOTE_MAX = 80
|
||||
|
||||
# Beschreibungen, die LibreOffice selbst in RedlineComment eintraegt. Sie
|
||||
# sind keine Anmerkung des Bearbeiters und wuerden die Synopse zumuellen.
|
||||
AUTO_COMMENTS = frozenset((
|
||||
"kommentar hinzugef\u00fcgt", "kommentar ge\u00e4ndert",
|
||||
"kommentar gel\u00f6scht", "kommentar aufgel\u00f6st",
|
||||
"comment added", "comment changed", "comment deleted", "comment resolved",
|
||||
"attribut ge\u00e4ndert", "attribute changed",
|
||||
"absatzformat ge\u00e4ndert", "paragraph formatting changed",
|
||||
))
|
||||
|
||||
|
||||
class Segment(object):
|
||||
"""Ein Textstueck samt der Redlines, innerhalb derer es liegt."""
|
||||
|
||||
__slots__ = ("text", "stack")
|
||||
|
||||
def __init__(self, text, stack):
|
||||
self.text = text
|
||||
self.stack = stack # tuple((rtype, author), ...)
|
||||
|
||||
@property
|
||||
def inserted_by(self):
|
||||
return [a for (t, a) in self.stack if t == INSERT]
|
||||
|
||||
@property
|
||||
def deleted_by(self):
|
||||
return [a for (t, a) in self.stack if t == DELETE]
|
||||
|
||||
@property
|
||||
def changed(self):
|
||||
return bool(self.stack)
|
||||
|
||||
# -- Fassungen --------------------------------------------------------
|
||||
|
||||
def in_original(self):
|
||||
"""Stand das Stueck in der Ursprungsfassung?"""
|
||||
return not self.inserted_by
|
||||
|
||||
def in_final(self):
|
||||
"""Steht das Stueck in der Endfassung (alle Aenderungen uebernommen)?"""
|
||||
return not self.deleted_by
|
||||
|
||||
def in_version_of(self, author):
|
||||
"""Fassung, in der ausschliesslich die Aenderungen von *author* gelten.
|
||||
|
||||
- Von jemand anderem eingefuegt -> nicht enthalten
|
||||
- Von *author* eingefuegt -> enthalten
|
||||
- Von *author* geloescht -> nicht enthalten
|
||||
- Von jemand anderem geloescht -> bleibt stehen
|
||||
"""
|
||||
if any(a != author for a in self.inserted_by):
|
||||
return False
|
||||
if author in self.deleted_by:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class Note(object):
|
||||
"""Eine Anmerkung zu einer Stelle des Textes."""
|
||||
|
||||
__slots__ = ("author", "content", "quote", "kind", "resolved", "when")
|
||||
|
||||
def __init__(self, author, content, kind="annotation"):
|
||||
self.author = author or UNKNOWN_AUTHOR
|
||||
self.content = (content or "").strip()
|
||||
self.quote = ""
|
||||
self.kind = kind # "annotation" | "redline"
|
||||
self.resolved = False
|
||||
self.when = None
|
||||
|
||||
def short_quote(self):
|
||||
q = " ".join(self.quote.split())
|
||||
if len(q) > QUOTE_MAX:
|
||||
q = q[:QUOTE_MAX - 1].rstrip() + "\u2026"
|
||||
return q
|
||||
|
||||
|
||||
class Block(object):
|
||||
"""Eine Zeile der Synopse."""
|
||||
|
||||
__slots__ = ("segments", "notes")
|
||||
|
||||
def __init__(self):
|
||||
self.segments = []
|
||||
self.notes = []
|
||||
|
||||
@property
|
||||
def changed(self):
|
||||
return any(s.changed for s in self.segments) or bool(self.notes)
|
||||
|
||||
def notes_of(self, author):
|
||||
return [n for n in self.notes if n.author == author]
|
||||
|
||||
|
||||
def _pop(stack, ident):
|
||||
"""Entfernt die Redline mit der gegebenen Kennung vom Stack."""
|
||||
for i in range(len(stack) - 1, -1, -1):
|
||||
if stack[i][0] == ident:
|
||||
del stack[i]
|
||||
return
|
||||
if stack:
|
||||
stack.pop()
|
||||
|
||||
|
||||
def _iter_paragraphs(text):
|
||||
"""Absaetze eines XText, inklusive der Absaetze in Tabellenzellen."""
|
||||
enum = text.createEnumeration()
|
||||
while enum.hasMoreElements():
|
||||
el = enum.nextElement()
|
||||
if el.supportsService("com.sun.star.text.Paragraph"):
|
||||
yield el
|
||||
elif el.supportsService("com.sun.star.text.TextTable"):
|
||||
for name in el.getCellNames():
|
||||
for para in _iter_paragraphs(el.getCellByName(name)):
|
||||
yield para
|
||||
|
||||
|
||||
def _portion_text(portion):
|
||||
try:
|
||||
return portion.getString()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _get(obj, name, default=None):
|
||||
try:
|
||||
return getattr(obj, name)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _is_annotation(field):
|
||||
"""Ist das Textfeld ein Kommentar?"""
|
||||
try:
|
||||
if field.supportsService("com.sun.star.text.textfield.Annotation"):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
# Aeltere Versionen melden den Dienst nicht zuverlaessig zurueck
|
||||
try:
|
||||
return _get(field, "Content") is not None and \
|
||||
_get(field, "Author") is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _field_of(portion):
|
||||
"""Das Feld eines Portions. Der Name unterscheidet sich je nach Version."""
|
||||
for name in ("TextField", "Annotation"):
|
||||
field = _get(portion, name)
|
||||
if field is not None:
|
||||
return field
|
||||
return None
|
||||
|
||||
|
||||
def _annotation(portion):
|
||||
"""Liefert (Note, Schluessel) zu einem Kommentar-Portion."""
|
||||
field = _field_of(portion)
|
||||
if field is None or not _is_annotation(field):
|
||||
return None, None
|
||||
content = (_get(field, "Content", "") or "").strip()
|
||||
if not content:
|
||||
return None, None # leerer Kommentar sagt nichts aus
|
||||
note = Note(_get(field, "Author", ""), content, kind="annotation")
|
||||
note.resolved = bool(_get(field, "Resolved", False))
|
||||
note.when = _get(field, "DateTimeValue")
|
||||
key = _get(field, "Name", "") or ("%s\x00%s" % (note.author, content))
|
||||
return note, key
|
||||
|
||||
|
||||
def _display_all_value(doc):
|
||||
"""Wert von RedlineDisplayType.INSERTED_AND_REMOVED ermitteln.
|
||||
|
||||
Bevorzugt wird der Typmanager. Antwortet er nicht, wird der Wert
|
||||
gemessen statt geraten: nur in der Anzeigeart mit Einfuegungen *und*
|
||||
Loeschungen enthaelt der Dokumenttext beides und ist damit am laengsten.
|
||||
Ein geratener Wert waere gefaehrlich - die Reihenfolge der Konstanten
|
||||
ist nicht ueber alle Versionen hinweg gleich, und ein Griff daneben
|
||||
blendet stillschweigend die Einfuegungen aus.
|
||||
"""
|
||||
global _display_all_cache
|
||||
|
||||
value = const_opt(RD_NAME)
|
||||
if value is not None:
|
||||
return value
|
||||
if _display_all_cache is not None:
|
||||
return _display_all_cache
|
||||
|
||||
best, longest, shortest = None, -1, None
|
||||
for candidate in (0, 1, 2, 3):
|
||||
try:
|
||||
doc.setPropertyValue("RedlineDisplayType", candidate)
|
||||
length = len(doc.getText().getString())
|
||||
except Exception:
|
||||
continue
|
||||
if length > longest:
|
||||
best, longest = candidate, length
|
||||
if shortest is None or length < shortest:
|
||||
shortest = length
|
||||
|
||||
if best is not None and shortest is not None and longest > shortest:
|
||||
# Nur aussagekraeftig, wenn das Dokument ueberhaupt Aenderungen hat
|
||||
_display_all_cache = best
|
||||
return best
|
||||
|
||||
|
||||
def _show_all_changes(doc):
|
||||
"""Eingefuegten und geloeschten Text sichtbar machen.
|
||||
|
||||
Nur in dieser Anzeigeart liefert die Portion-Enumeration auch den
|
||||
geloeschten Text. Liefert den zuvor eingestellten Wert zurueck.
|
||||
"""
|
||||
try:
|
||||
previous = doc.getPropertyValue("RedlineDisplayType")
|
||||
except Exception:
|
||||
previous = None
|
||||
|
||||
value = _display_all_value(doc)
|
||||
if value is not None:
|
||||
try:
|
||||
doc.setPropertyValue("RedlineDisplayType", value)
|
||||
except Exception:
|
||||
pass
|
||||
return previous
|
||||
|
||||
|
||||
def _restore_display(doc, previous, was_modified):
|
||||
"""Anzeige und Aenderungsstatus des Quelldokuments wiederherstellen."""
|
||||
if previous is not None:
|
||||
try:
|
||||
doc.setPropertyValue("RedlineDisplayType", previous)
|
||||
except Exception:
|
||||
pass
|
||||
if was_modified is False:
|
||||
try:
|
||||
doc.setModified(False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def extract_blocks(doc):
|
||||
"""Liefert (blocks, edit_authors, note_authors).
|
||||
|
||||
Das Quelldokument wird dabei nicht bleibend veraendert: Anzeigeart und
|
||||
Aenderungsstatus werden am Ende wiederhergestellt.
|
||||
"""
|
||||
try:
|
||||
was_modified = bool(doc.isModified())
|
||||
except Exception:
|
||||
was_modified = None
|
||||
previous_display = _show_all_changes(doc)
|
||||
try:
|
||||
return _extract(doc)
|
||||
finally:
|
||||
_restore_display(doc, previous_display, was_modified)
|
||||
|
||||
|
||||
def _extract(doc):
|
||||
stack = [] # [(identifier, rtype, author)]
|
||||
edit_authors = set()
|
||||
note_authors = set()
|
||||
seen_comments = set() # Redline-Kommentare nur einmal uebernehmen
|
||||
seen_notes = set() # Kommentare nur einmal uebernehmen
|
||||
open_notes = {} # Schluessel -> Note mit noch offenem Textbereich
|
||||
blocks = []
|
||||
current = Block()
|
||||
|
||||
for para in _iter_paragraphs(doc.getText()):
|
||||
pen = para.createEnumeration()
|
||||
while pen.hasMoreElements():
|
||||
portion = pen.nextElement()
|
||||
ptype = portion.TextPortionType
|
||||
|
||||
if ptype == "Redline":
|
||||
rtype = portion.RedlineType
|
||||
if rtype not in (INSERT, DELETE):
|
||||
continue # Format/Attribute: ignoriert
|
||||
author = portion.RedlineAuthor or UNKNOWN_AUTHOR
|
||||
ident = portion.RedlineIdentifier
|
||||
edit_authors.add(author)
|
||||
|
||||
comment = (_get(portion, "RedlineComment", "") or "").strip()
|
||||
if (comment and ident not in seen_comments
|
||||
and comment.lower() not in AUTO_COMMENTS):
|
||||
seen_comments.add(ident)
|
||||
note = Note(author, comment, kind="redline")
|
||||
current.notes.append(note)
|
||||
note_authors.add(note.author)
|
||||
|
||||
if bool(_get(portion, "IsCollapsed", False)):
|
||||
continue # Laenge 0
|
||||
if bool(_get(portion, "IsStart", True)):
|
||||
stack.append((ident, rtype, author))
|
||||
else:
|
||||
_pop(stack, ident)
|
||||
|
||||
elif ptype == "Annotation":
|
||||
note, key = _annotation(portion)
|
||||
if note is not None and key not in seen_notes:
|
||||
seen_notes.add(key)
|
||||
current.notes.append(note)
|
||||
note_authors.add(note.author)
|
||||
open_notes[key] = note # Ende folgt ggf. spaeter
|
||||
|
||||
elif ptype == "AnnotationEnd":
|
||||
_note, key = _annotation(portion)
|
||||
open_notes.pop(key, None)
|
||||
|
||||
else:
|
||||
if ptype == "TextField":
|
||||
# Je nach Version meldet Writer einen Kommentar nicht als
|
||||
# "Annotation", sondern als gewoehnliches Textfeld.
|
||||
note, key = _annotation(portion)
|
||||
if note is not None:
|
||||
if key not in seen_notes:
|
||||
seen_notes.add(key)
|
||||
current.notes.append(note)
|
||||
note_authors.add(note.author)
|
||||
continue
|
||||
s = _portion_text(portion)
|
||||
if s:
|
||||
current.segments.append(
|
||||
Segment(s, tuple((t, a) for (_i, t, a) in stack)))
|
||||
for note in open_notes.values():
|
||||
note.quote += s
|
||||
|
||||
mark = tuple((t, a) for (_i, t, a) in stack)
|
||||
if mark:
|
||||
# Absatzmarke liegt innerhalb einer Aenderung -> kein Zeilenumbruch
|
||||
# in der Synopse, sondern ein Segment, das nur in der jeweils
|
||||
# passenden Fassung als Umbruch erscheint.
|
||||
current.segments.append(Segment("\n", mark))
|
||||
else:
|
||||
blocks.append(current)
|
||||
current = Block()
|
||||
for note in open_notes.values():
|
||||
note.quote += " "
|
||||
|
||||
if current.segments or current.notes:
|
||||
blocks.append(current)
|
||||
|
||||
return blocks, edit_authors, note_authors
|
||||
Reference in New Issue
Block a user