מקור-תבנית-יחיד לכל מפיקי-מסמכי-ההחלטה: כולם מחילים סגנונות דרך skills/docx/decision_template.docx בלבד; אסור hand-setting של פונט/גודל. - docs/spec/06-export.md — INV-EX4 חדש (מקור-תבנית-יחיד, →G2) - docx_reviser: מסלול-העריכה (revise_draft/apply_user_edit) הכניס פסקאות עם jc=right קשיח + David/sz ידני → איבד יישור דו-צדדי של התבנית. עכשיו מחיל pStyle מן-הטמפלט (Normal/Heading 2/Quote) + jc=both קנוני, בלי rFonts/sz ידני (הגופן/גודל מגיעים מהסגנון). - שומר-CI test_docx_template_single_source — נכשל על בניית rFonts/sz במפיקים מחוץ לטמפלט (חריג מסומן # INV-EX4-ok ל-_mark_run_rtl, שמחזק את גופן-התבנית David כעקיפת באג-RTL של Word). - אומת: build_party_claims_summary_docx (סיכום-מנהלים, WS3) + export_decision (סופי/טיוטת-ביניים) כבר תבניתיים ותואמי-INV-EX4. - ארכוב scripts/exec_summary_1043.py (one-off; הוחלף ב-build_party_claims_summary_docx). - עדכון 2 טסטים שאימתו את ההתנהגות הישנה (rFonts על run מוכנס). Invariants: מקיים INV-EX4 (חדש), INV-G2 (מקור-יחיד, אין מסלול-סגנון מקביל), INV-EX1 (DOCX נגזר). נוגע ב-docx_reviser/docx_exporter/analysis_docx_exporter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
617 lines
22 KiB
Python
617 lines
22 KiB
Python
"""עריכת DOCX עם Track Changes אמיתיים של Word.
|
|
|
|
השירות מיועד לקבל DOCX קיים (עם bookmarks שזיהו אנקורים) ולהחיל עליו
|
|
עריכות מסומנות כ-w:ins / w:del, שבאים לידי ביטוי ב-Word כ-Track Changes
|
|
שהמשתמש יכול Accept/Reject.
|
|
|
|
אסטרטגיית אנקורים: bookmarks בשמות כגון 'block-yod', 'block-yod-para-3'
|
|
שמוכנסים בזמן הייצוא הראשוני (docx_exporter.py) או רטרואקטיבית
|
|
(docx_retrofit.py).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import shutil
|
|
|
|
from legal_mcp import config
|
|
from legal_mcp.services import storage
|
|
import zipfile
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from io import BytesIO
|
|
from pathlib import Path
|
|
from typing import Literal
|
|
|
|
from lxml import etree
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ── XML namespaces ─────────────────────────────────────────────────
|
|
|
|
W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
|
NSMAP = {"w": W_NS}
|
|
|
|
|
|
def _w(tag: str) -> str:
|
|
"""Build a fully qualified tag name in the w: namespace."""
|
|
return f"{{{W_NS}}}{tag}"
|
|
|
|
|
|
# ── Data models ────────────────────────────────────────────────────
|
|
|
|
|
|
RevisionType = Literal["insert_after", "insert_before", "replace", "delete"]
|
|
StyleType = Literal["body", "quote", "heading", "bold"]
|
|
|
|
|
|
@dataclass
|
|
class Revision:
|
|
"""A single tracked change to apply to the DOCX."""
|
|
|
|
id: str
|
|
type: RevisionType
|
|
anchor_bookmark: str
|
|
content: str = ""
|
|
style: StyleType = "body"
|
|
reason: str = ""
|
|
anchor_position: Literal["start", "end"] = "end"
|
|
|
|
|
|
@dataclass
|
|
class RevisionResult:
|
|
"""Result of applying a single revision."""
|
|
|
|
id: str
|
|
status: Literal["applied", "failed"]
|
|
error: str | None = None
|
|
ins_id: int | None = None
|
|
|
|
|
|
@dataclass
|
|
class RevisionBatchResult:
|
|
"""Aggregate result of applying a revision batch."""
|
|
|
|
applied: int = 0
|
|
failed: int = 0
|
|
results: list[RevisionResult] = field(default_factory=list)
|
|
output_path: str = ""
|
|
|
|
|
|
# ── XML helpers ────────────────────────────────────────────────────
|
|
|
|
|
|
def _load_docx_xml(docx_path: Path) -> tuple[dict[str, bytes], etree._Element, etree._Element]:
|
|
"""Load a DOCX as a dict of zip members + parsed document/settings trees."""
|
|
members: dict[str, bytes] = {}
|
|
with zipfile.ZipFile(docx_path, "r") as zf:
|
|
for name in zf.namelist():
|
|
members[name] = zf.read(name)
|
|
|
|
if "word/document.xml" not in members:
|
|
raise ValueError(f"{docx_path}: missing word/document.xml")
|
|
|
|
document_tree = etree.fromstring(members["word/document.xml"])
|
|
settings_bytes = members.get("word/settings.xml")
|
|
if settings_bytes:
|
|
settings_tree = etree.fromstring(settings_bytes)
|
|
else:
|
|
settings_tree = etree.Element(_w("settings"), nsmap=NSMAP)
|
|
|
|
return members, document_tree, settings_tree
|
|
|
|
|
|
_DOCX_CTYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
|
|
|
|
def _persist_docx_sync(output_path: Path, data: bytes) -> None:
|
|
"""Persist DOCX bytes through the storage layer (INV-STG1); fall back to a
|
|
direct disk write when output_path is outside DATA_DIR (caller-provided)."""
|
|
out = Path(output_path)
|
|
try:
|
|
key = out.resolve().relative_to(Path(config.DATA_DIR).resolve()).as_posix()
|
|
storage.put_bytes_sync(key, data, bucket=storage.Bucket.DOCUMENTS,
|
|
content_type=_DOCX_CTYPE)
|
|
except ValueError:
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
out.write_bytes(data) # noqa: STG1 — storage fallback
|
|
|
|
|
|
def _save_docx_xml(
|
|
members: dict[str, bytes],
|
|
document_tree: etree._Element,
|
|
settings_tree: etree._Element,
|
|
output_path: Path,
|
|
) -> None:
|
|
"""Write a DOCX back to disk with updated document/settings XML."""
|
|
members = dict(members)
|
|
members["word/document.xml"] = etree.tostring(
|
|
document_tree, xml_declaration=True, encoding="UTF-8", standalone=True
|
|
)
|
|
members["word/settings.xml"] = etree.tostring(
|
|
settings_tree, xml_declaration=True, encoding="UTF-8", standalone=True
|
|
)
|
|
|
|
buffer = BytesIO()
|
|
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
for name, data in members.items():
|
|
zf.writestr(name, data)
|
|
_persist_docx_sync(output_path, buffer.getvalue())
|
|
|
|
|
|
def _ensure_track_revisions(settings_tree: etree._Element) -> None:
|
|
"""Ensure <w:trackRevisions/> is present in settings.xml.
|
|
|
|
Note: This enables *display* of track changes — actual w:ins/w:del nodes
|
|
are rendered as tracked regardless. Word respects trackRevisions for
|
|
recording further user edits too.
|
|
"""
|
|
existing = settings_tree.find(_w("trackRevisions"))
|
|
if existing is None:
|
|
el = etree.SubElement(settings_tree, _w("trackRevisions"))
|
|
el.set(_w("val"), "true")
|
|
|
|
|
|
def _next_revision_id(document_tree: etree._Element) -> int:
|
|
"""Find max existing w:id on w:ins/w:del/w:bookmarkStart and return next."""
|
|
max_id = 0
|
|
for xpath in (
|
|
".//w:ins", ".//w:del", ".//w:bookmarkStart", ".//w:bookmarkEnd",
|
|
".//w:commentRangeStart", ".//w:comment",
|
|
):
|
|
for el in document_tree.iterfind(xpath, NSMAP):
|
|
val = el.get(_w("id"))
|
|
if val:
|
|
try:
|
|
max_id = max(max_id, int(val))
|
|
except ValueError:
|
|
pass
|
|
return max_id + 1
|
|
|
|
|
|
def _find_bookmark(
|
|
document_tree: etree._Element, name: str
|
|
) -> tuple[etree._Element | None, etree._Element | None]:
|
|
"""Find w:bookmarkStart and w:bookmarkEnd elements by bookmark name."""
|
|
start = None
|
|
end = None
|
|
for el in document_tree.iterfind(".//w:bookmarkStart", NSMAP):
|
|
if el.get(_w("name")) == name:
|
|
start = el
|
|
break
|
|
if start is None:
|
|
return None, None
|
|
bm_id = start.get(_w("id"))
|
|
for el in document_tree.iterfind(".//w:bookmarkEnd", NSMAP):
|
|
if el.get(_w("id")) == bm_id:
|
|
end = el
|
|
break
|
|
return start, end
|
|
|
|
|
|
def _find_enclosing_paragraph(element: etree._Element) -> etree._Element | None:
|
|
"""Walk up from an element to find its enclosing w:p."""
|
|
cur = element
|
|
while cur is not None:
|
|
if cur.tag == _w("p"):
|
|
return cur
|
|
cur = cur.getparent()
|
|
return None
|
|
|
|
|
|
# ── Template style resolution (INV-EX4) ────────────────────────────
|
|
# Every inserted paragraph carries a <w:pStyle> pointing at one of the
|
|
# template's named styles — font (David), size, and indent come from
|
|
# styles.xml, NOT from hand-set rFonts/sz here. Single template = single
|
|
# style source (INV-EX4 / G2). docx_exporter.py applies the same styles by
|
|
# name via python-docx; here (raw lxml) we resolve name → styleId.
|
|
|
|
# StyleType → candidate style names in the template (case-insensitive).
|
|
# Mirrors the names docx_exporter uses (paragraph.style = "Heading 2"/"Normal"/"Quote").
|
|
_STYLE_NAME_CANDIDATES: dict[str, tuple[str, ...]] = {
|
|
"body": ("Normal", "Default Paragraph Font", "Body Text"),
|
|
"bold": ("Normal", "Body Text"),
|
|
"heading": ("Heading 2", "heading 2"),
|
|
"quote": ("Quote", "quote", "Intense Quote"),
|
|
}
|
|
|
|
|
|
def _resolve_style_ids(members: dict[str, bytes]) -> dict[str, str]:
|
|
"""Map each StyleType → the template's w:styleId, read from styles.xml.
|
|
|
|
Returns {} when styles.xml is absent — callers then fall back to
|
|
minimal inline emphasis (so a missing style never crashes rendering).
|
|
"""
|
|
raw = members.get("word/styles.xml")
|
|
if not raw:
|
|
return {}
|
|
try:
|
|
styles_tree = etree.fromstring(raw)
|
|
except etree.XMLSyntaxError: # pragma: no cover - corrupt template
|
|
return {}
|
|
# Build a lowercase lookup over both the style's display name and its id.
|
|
by_key: dict[str, str] = {}
|
|
for style in styles_tree.iterfind(".//w:style", NSMAP):
|
|
sid = style.get(_w("styleId"))
|
|
if not sid:
|
|
continue
|
|
by_key.setdefault(sid.lower(), sid)
|
|
name_el = style.find(_w("name"))
|
|
if name_el is not None:
|
|
nm = name_el.get(_w("val"))
|
|
if nm:
|
|
by_key.setdefault(nm.lower(), sid)
|
|
resolved: dict[str, str] = {}
|
|
for style_type, candidates in _STYLE_NAME_CANDIDATES.items():
|
|
for cand in candidates:
|
|
sid = by_key.get(cand.lower())
|
|
if sid is not None:
|
|
resolved[style_type] = sid
|
|
break
|
|
return resolved
|
|
|
|
|
|
def _emphasis_fallback(style: StyleType, *, has_pstyle: bool) -> tuple[bool, bool]:
|
|
"""(bold, italic) for a run. When the paragraph carries a resolved
|
|
template pStyle, emphasis comes from the style (return no inline override),
|
|
except 'bold' which is inline emphasis with no dedicated style. When no
|
|
pStyle resolved, fall back to minimal inline emphasis so heading/quote
|
|
stay visually distinct."""
|
|
if style == "bold":
|
|
return True, False
|
|
if has_pstyle:
|
|
return False, False
|
|
return (style == "heading"), (style == "quote")
|
|
|
|
|
|
# ── Paragraph builders ─────────────────────────────────────────────
|
|
|
|
|
|
def _build_run(text: str, *, bold: bool = False, italic: bool = False) -> etree._Element:
|
|
"""Build a w:r (run): RTL marker + optional bold/italic + text.
|
|
|
|
Font (David) and size are NOT set here — they derive from the paragraph's
|
|
template style (INV-EX4). The <w:rtl/> marker is still required so Word
|
|
picks the style's complex-script (cs=David) slot rather than the ascii
|
|
(Times New Roman) slot for Hebrew (same reason as _mark_run_rtl in
|
|
analysis_docx_exporter.py).
|
|
"""
|
|
r = etree.Element(_w("r"))
|
|
rPr = etree.SubElement(r, _w("rPr"))
|
|
|
|
if bold:
|
|
etree.SubElement(rPr, _w("b"))
|
|
etree.SubElement(rPr, _w("bCs"))
|
|
if italic:
|
|
etree.SubElement(rPr, _w("i"))
|
|
etree.SubElement(rPr, _w("iCs"))
|
|
|
|
etree.SubElement(rPr, _w("rtl"))
|
|
|
|
t = etree.SubElement(r, _w("t"))
|
|
t.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
|
|
t.text = text
|
|
return r
|
|
|
|
|
|
def _build_paragraph(text: str, *, style: StyleType = "body",
|
|
style_id: str | None = None) -> etree._Element:
|
|
"""Build a w:p (paragraph) carrying the template's named style.
|
|
|
|
The paragraph references `style_id` (resolved from the template's
|
|
styles.xml) via <w:pStyle> — font/size/indent come from there, not from
|
|
hand-set rFonts/sz (INV-EX4). The only explicit paragraph-level override
|
|
is the canonical body alignment jc=both, matching docx_exporter's
|
|
_set_paragraph_jc (which forces both over style-center headings).
|
|
"""
|
|
p = etree.Element(_w("p"))
|
|
pPr = etree.SubElement(p, _w("pPr"))
|
|
|
|
if style_id:
|
|
pStyle = etree.SubElement(pPr, _w("pStyle"))
|
|
pStyle.set(_w("val"), style_id)
|
|
|
|
bidi = etree.SubElement(pPr, _w("bidi"))
|
|
bidi.set(_w("val"), "1")
|
|
|
|
# Canonical body alignment (two-sided justification) — overrides any
|
|
# style-center, exactly as docx_exporter._set_paragraph_jc does.
|
|
jc = etree.SubElement(pPr, _w("jc"))
|
|
jc.set(_w("val"), "both")
|
|
|
|
rPr_p = etree.SubElement(pPr, _w("rPr"))
|
|
etree.SubElement(rPr_p, _w("rtl"))
|
|
|
|
bold, italic = _emphasis_fallback(style, has_pstyle=bool(style_id))
|
|
run = _build_run(text, bold=bold, italic=italic)
|
|
p.append(run)
|
|
return p
|
|
|
|
|
|
def _wrap_in_ins(elements: list[etree._Element], *, ins_id: int,
|
|
author: str, date_iso: str) -> etree._Element:
|
|
"""Wrap a list of *run-level* elements in a single <w:ins>."""
|
|
ins = etree.Element(_w("ins"))
|
|
ins.set(_w("id"), str(ins_id))
|
|
ins.set(_w("author"), author)
|
|
ins.set(_w("date"), date_iso)
|
|
for el in elements:
|
|
ins.append(el)
|
|
return ins
|
|
|
|
|
|
def _make_tracked_paragraph_insert(
|
|
text: str, *, style: StyleType, ins_id: int, author: str, date_iso: str,
|
|
mark_id: int | None = None, style_id: str | None = None,
|
|
) -> etree._Element:
|
|
"""Build a whole tracked-inserted paragraph.
|
|
|
|
DOCX convention for a fully-inserted paragraph:
|
|
1. All <w:r> runs are wrapped in a single <w:ins> (own id).
|
|
2. The paragraph's pPr/rPr gets an <w:ins> marker for the paragraph
|
|
mark itself (pilcrow) — this uses its *own* id.
|
|
"""
|
|
if mark_id is None:
|
|
mark_id = ins_id
|
|
p = _build_paragraph(text, style=style, style_id=style_id)
|
|
pPr = p.find(_w("pPr"))
|
|
assert pPr is not None
|
|
rPr = pPr.find(_w("rPr"))
|
|
if rPr is None:
|
|
rPr = etree.SubElement(pPr, _w("rPr"))
|
|
ins_mark = etree.SubElement(rPr, _w("ins"))
|
|
ins_mark.set(_w("id"), str(mark_id))
|
|
ins_mark.set(_w("author"), author)
|
|
ins_mark.set(_w("date"), date_iso)
|
|
|
|
runs = [child for child in list(p) if child.tag == _w("r")]
|
|
if runs:
|
|
for r in runs:
|
|
p.remove(r)
|
|
ins = _wrap_in_ins(runs, ins_id=ins_id, author=author, date_iso=date_iso)
|
|
p.append(ins)
|
|
return p
|
|
|
|
|
|
def _mark_runs_as_deleted(paragraph: etree._Element, *, del_id: int,
|
|
author: str, date_iso: str) -> None:
|
|
"""Convert all <w:r> in a paragraph to <w:del>-wrapped runs.
|
|
|
|
Within a <w:del>, <w:t> must become <w:delText>.
|
|
"""
|
|
runs = [child for child in list(paragraph) if child.tag == _w("r")]
|
|
if not runs:
|
|
return
|
|
# Convert <w:t> → <w:delText> inside each run
|
|
for r in runs:
|
|
for t in r.findall(_w("t")):
|
|
t.tag = _w("delText")
|
|
paragraph.remove(r)
|
|
wrapper = etree.Element(_w("del"))
|
|
wrapper.set(_w("id"), str(del_id))
|
|
wrapper.set(_w("author"), author)
|
|
wrapper.set(_w("date"), date_iso)
|
|
for r in runs:
|
|
wrapper.append(r)
|
|
paragraph.append(wrapper)
|
|
|
|
|
|
# ── Revision application ───────────────────────────────────────────
|
|
|
|
|
|
def _apply_insert(
|
|
document_tree: etree._Element,
|
|
revision: Revision,
|
|
*,
|
|
ins_id: int,
|
|
author: str,
|
|
date_iso: str,
|
|
style_ids: dict[str, str] | None = None,
|
|
) -> RevisionResult:
|
|
"""Apply insert_after / insert_before relative to a bookmark."""
|
|
start, end = _find_bookmark(document_tree, revision.anchor_bookmark)
|
|
if start is None:
|
|
return RevisionResult(id=revision.id, status="failed",
|
|
error=f"bookmark '{revision.anchor_bookmark}' not found")
|
|
|
|
# Pick anchor element based on position
|
|
if revision.type == "insert_before":
|
|
anchor = start
|
|
else: # insert_after — default
|
|
anchor = end if end is not None else start
|
|
|
|
enclosing_p = _find_enclosing_paragraph(anchor)
|
|
if enclosing_p is None:
|
|
return RevisionResult(id=revision.id, status="failed",
|
|
error="anchor has no enclosing paragraph")
|
|
|
|
# Build new tracked paragraph. ins_id for run wrapper, ins_id+1 for mark.
|
|
new_p = _make_tracked_paragraph_insert(
|
|
revision.content, style=revision.style,
|
|
ins_id=ins_id, mark_id=ins_id + 1,
|
|
author=author, date_iso=date_iso,
|
|
style_id=(style_ids or {}).get(revision.style),
|
|
)
|
|
|
|
parent = enclosing_p.getparent()
|
|
if parent is None:
|
|
return RevisionResult(id=revision.id, status="failed",
|
|
error="enclosing paragraph has no parent")
|
|
idx = list(parent).index(enclosing_p)
|
|
insert_idx = idx if revision.type == "insert_before" else idx + 1
|
|
parent.insert(insert_idx, new_p)
|
|
|
|
return RevisionResult(id=revision.id, status="applied", ins_id=ins_id)
|
|
|
|
|
|
def _apply_delete(
|
|
document_tree: etree._Element,
|
|
revision: Revision,
|
|
*,
|
|
del_id: int,
|
|
author: str,
|
|
date_iso: str,
|
|
) -> RevisionResult:
|
|
"""Mark the paragraph enclosed by a bookmark as deleted."""
|
|
start, end = _find_bookmark(document_tree, revision.anchor_bookmark)
|
|
if start is None:
|
|
return RevisionResult(id=revision.id, status="failed",
|
|
error=f"bookmark '{revision.anchor_bookmark}' not found")
|
|
|
|
enclosing_p = _find_enclosing_paragraph(start)
|
|
if enclosing_p is None:
|
|
return RevisionResult(id=revision.id, status="failed",
|
|
error="anchor has no enclosing paragraph")
|
|
|
|
_mark_runs_as_deleted(enclosing_p, del_id=del_id,
|
|
author=author, date_iso=date_iso)
|
|
return RevisionResult(id=revision.id, status="applied", ins_id=del_id)
|
|
|
|
|
|
def _apply_replace(
|
|
document_tree: etree._Element,
|
|
revision: Revision,
|
|
*,
|
|
ins_id: int,
|
|
del_id: int,
|
|
author: str,
|
|
date_iso: str,
|
|
style_ids: dict[str, str] | None = None,
|
|
) -> RevisionResult:
|
|
"""Replace = delete the existing paragraph + insert new one after it."""
|
|
start, end = _find_bookmark(document_tree, revision.anchor_bookmark)
|
|
if start is None:
|
|
return RevisionResult(id=revision.id, status="failed",
|
|
error=f"bookmark '{revision.anchor_bookmark}' not found")
|
|
|
|
enclosing_p = _find_enclosing_paragraph(start)
|
|
if enclosing_p is None:
|
|
return RevisionResult(id=revision.id, status="failed",
|
|
error="anchor has no enclosing paragraph")
|
|
|
|
parent = enclosing_p.getparent()
|
|
if parent is None:
|
|
return RevisionResult(id=revision.id, status="failed",
|
|
error="enclosing paragraph has no parent")
|
|
|
|
new_p = _make_tracked_paragraph_insert(
|
|
revision.content, style=revision.style,
|
|
ins_id=ins_id, mark_id=ins_id + 1,
|
|
author=author, date_iso=date_iso,
|
|
style_id=(style_ids or {}).get(revision.style),
|
|
)
|
|
idx = list(parent).index(enclosing_p)
|
|
parent.insert(idx + 1, new_p)
|
|
|
|
_mark_runs_as_deleted(enclosing_p, del_id=del_id,
|
|
author=author, date_iso=date_iso)
|
|
return RevisionResult(id=revision.id, status="applied", ins_id=ins_id)
|
|
|
|
|
|
# ── Public API ─────────────────────────────────────────────────────
|
|
|
|
|
|
def apply_tracked_revisions(
|
|
source_path: str | Path,
|
|
output_path: str | Path,
|
|
revisions: list[Revision],
|
|
*,
|
|
author: str = "מערכת AI",
|
|
date: datetime | None = None,
|
|
) -> RevisionBatchResult:
|
|
"""Apply a batch of tracked revisions to a DOCX, producing a new DOCX.
|
|
|
|
The source file is never mutated. Output is a new DOCX with <w:ins> /
|
|
<w:del> markers that Word renders as Track Changes (Accept/Reject).
|
|
|
|
Args:
|
|
source_path: existing DOCX (e.g. עריכה-v1.docx) — retains user edits.
|
|
output_path: where to write the revised DOCX (e.g. טיוטה-v6.docx).
|
|
revisions: list of Revision objects. Anchors are bookmark names.
|
|
author: displayed as the revision author in Word.
|
|
date: revision timestamp (defaults to now, UTC).
|
|
|
|
Returns:
|
|
RevisionBatchResult with per-revision status.
|
|
"""
|
|
source_path = Path(source_path)
|
|
output_path = Path(output_path)
|
|
|
|
if date is None:
|
|
date = datetime.now(timezone.utc)
|
|
date_iso = date.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
members, doc_tree, settings_tree = _load_docx_xml(source_path)
|
|
_ensure_track_revisions(settings_tree)
|
|
|
|
# Resolve template style ids once (INV-EX4) — inserted paragraphs inherit
|
|
# font/size/indent from these named styles instead of hand-set rFonts/sz.
|
|
style_ids = _resolve_style_ids(members)
|
|
|
|
next_id = _next_revision_id(doc_tree)
|
|
|
|
batch = RevisionBatchResult()
|
|
for rev in revisions:
|
|
try:
|
|
if rev.type in ("insert_after", "insert_before"):
|
|
result = _apply_insert(doc_tree, rev, ins_id=next_id,
|
|
author=author, date_iso=date_iso,
|
|
style_ids=style_ids)
|
|
# insert consumes 2 IDs: run-wrapper + paragraph-mark
|
|
next_id += 2
|
|
elif rev.type == "delete":
|
|
result = _apply_delete(doc_tree, rev, del_id=next_id,
|
|
author=author, date_iso=date_iso)
|
|
next_id += 1
|
|
elif rev.type == "replace":
|
|
result = _apply_replace(doc_tree, rev,
|
|
ins_id=next_id, del_id=next_id + 2,
|
|
author=author, date_iso=date_iso,
|
|
style_ids=style_ids)
|
|
# replace consumes 3 IDs: ins-run, ins-mark, del
|
|
next_id += 3
|
|
else:
|
|
result = RevisionResult(id=rev.id, status="failed",
|
|
error=f"unknown type: {rev.type}")
|
|
except Exception as e: # pragma: no cover - defensive
|
|
logger.exception("revision %s failed", rev.id)
|
|
result = RevisionResult(id=rev.id, status="failed", error=str(e))
|
|
|
|
batch.results.append(result)
|
|
if result.status == "applied":
|
|
batch.applied += 1
|
|
else:
|
|
batch.failed += 1
|
|
|
|
_save_docx_xml(members, doc_tree, settings_tree, output_path)
|
|
batch.output_path = str(output_path)
|
|
logger.info("applied %d revisions (failed %d) → %s",
|
|
batch.applied, batch.failed, output_path)
|
|
return batch
|
|
|
|
|
|
def list_bookmarks(docx_path: str | Path) -> list[str]:
|
|
"""Return bookmark names present in the DOCX (excluding '_' internal ones)."""
|
|
docx_path = Path(docx_path)
|
|
members, doc_tree, _ = _load_docx_xml(docx_path)
|
|
names: list[str] = []
|
|
for el in doc_tree.iterfind(".//w:bookmarkStart", NSMAP):
|
|
name = el.get(_w("name"))
|
|
if name and not name.startswith("_"):
|
|
names.append(name)
|
|
return names
|
|
|
|
|
|
def copy_with_revisions(
|
|
source_path: str | Path, output_path: str | Path,
|
|
) -> None:
|
|
"""Copy source → output unchanged (used when revisions list is empty)."""
|
|
out = Path(output_path)
|
|
try:
|
|
key = out.resolve().relative_to(Path(config.DATA_DIR).resolve()).as_posix()
|
|
storage.put_file_sync(source_path, key, bucket=storage.Bucket.DOCUMENTS,
|
|
content_type=_DOCX_CTYPE)
|
|
except ValueError:
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(str(source_path), str(out)) # noqa: STG1 — storage fallback
|