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