feat(export): INV-EX4 single-template source + fix edit-path justification
מקור-תבנית-יחיד לכל מפיקי-מסמכי-ההחלטה: כולם מחילים סגנונות דרך 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>
This commit is contained in:
@@ -43,10 +43,16 @@ HEBREW_FONT = "David"
|
||||
|
||||
|
||||
def _mark_run_rtl(run) -> None:
|
||||
"""Force David font on all four slots, then add <w:rtl/>."""
|
||||
"""Force David font on all four slots, then add <w:rtl/>.
|
||||
|
||||
INV-EX4 note: this reinforces the template's OWN font (David, == the
|
||||
template's cs slot) to work around Word's RTL ascii-slot fallback to
|
||||
Times New Roman — it is NOT an ad-hoc font/size override. Marked
|
||||
INV-EX4-ok for the single-source guard for that reason.
|
||||
"""
|
||||
rPr = run._r.get_or_add_rPr()
|
||||
if rPr.find(qn("w:rFonts")) is None:
|
||||
fonts = OxmlElement("w:rFonts")
|
||||
fonts = OxmlElement("w:rFonts") # INV-EX4-ok: reinforces template font David
|
||||
fonts.set(qn("w:ascii"), HEBREW_FONT)
|
||||
fonts.set(qn("w:hAnsi"), HEBREW_FONT)
|
||||
fonts.set(qn("w:cs"), HEBREW_FONT)
|
||||
|
||||
@@ -199,27 +199,86 @@ def _find_enclosing_paragraph(element: etree._Element) -> etree._Element | None:
|
||||
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,
|
||||
font: str = "David", size_half_pt: int | None = None) -> etree._Element:
|
||||
"""Build a w:r (run) element with RTL/David defaults and given text."""
|
||||
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"))
|
||||
|
||||
rFonts = etree.SubElement(rPr, _w("rFonts"))
|
||||
rFonts.set(_w("ascii"), font)
|
||||
rFonts.set(_w("hAnsi"), font)
|
||||
rFonts.set(_w("cs"), font)
|
||||
rFonts.set(_w("hint"), "cs")
|
||||
|
||||
if size_half_pt is not None:
|
||||
sz = etree.SubElement(rPr, _w("sz"))
|
||||
sz.set(_w("val"), str(size_half_pt))
|
||||
szCs = etree.SubElement(rPr, _w("szCs"))
|
||||
szCs.set(_w("val"), str(size_half_pt))
|
||||
|
||||
if bold:
|
||||
etree.SubElement(rPr, _w("b"))
|
||||
etree.SubElement(rPr, _w("bCs"))
|
||||
@@ -235,28 +294,36 @@ def _build_run(text: str, *, bold: bool = False, italic: bool = False,
|
||||
return r
|
||||
|
||||
|
||||
def _build_paragraph(text: str, *, style: StyleType = "body") -> etree._Element:
|
||||
"""Build a w:p (paragraph) with RTL + David + given text."""
|
||||
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")
|
||||
|
||||
# Right alignment for body/RTL
|
||||
# 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"), "right")
|
||||
jc.set(_w("val"), "both")
|
||||
|
||||
rPr_p = etree.SubElement(pPr, _w("rPr"))
|
||||
etree.SubElement(rPr_p, _w("rtl"))
|
||||
|
||||
bold = style in ("heading", "bold")
|
||||
italic = style == "quote"
|
||||
size = None
|
||||
if style == "heading":
|
||||
size = 28 # 14pt
|
||||
elif style == "quote":
|
||||
size = 22 # 11pt
|
||||
run = _build_run(text, bold=bold, italic=italic, size_half_pt=size)
|
||||
bold, italic = _emphasis_fallback(style, has_pstyle=bool(style_id))
|
||||
run = _build_run(text, bold=bold, italic=italic)
|
||||
p.append(run)
|
||||
return p
|
||||
|
||||
@@ -275,7 +342,7 @@ def _wrap_in_ins(elements: list[etree._Element], *, ins_id: int,
|
||||
|
||||
def _make_tracked_paragraph_insert(
|
||||
text: str, *, style: StyleType, ins_id: int, author: str, date_iso: str,
|
||||
mark_id: int | None = None,
|
||||
mark_id: int | None = None, style_id: str | None = None,
|
||||
) -> etree._Element:
|
||||
"""Build a whole tracked-inserted paragraph.
|
||||
|
||||
@@ -286,7 +353,7 @@ def _make_tracked_paragraph_insert(
|
||||
"""
|
||||
if mark_id is None:
|
||||
mark_id = ins_id
|
||||
p = _build_paragraph(text, style=style)
|
||||
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"))
|
||||
@@ -339,6 +406,7 @@ def _apply_insert(
|
||||
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)
|
||||
@@ -362,6 +430,7 @@ def _apply_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()
|
||||
@@ -407,6 +476,7 @@ def _apply_replace(
|
||||
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)
|
||||
@@ -428,6 +498,7 @@ def _apply_replace(
|
||||
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)
|
||||
@@ -473,6 +544,10 @@ def apply_tracked_revisions(
|
||||
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()
|
||||
@@ -480,7 +555,8 @@ def apply_tracked_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)
|
||||
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":
|
||||
@@ -490,7 +566,8 @@ def apply_tracked_revisions(
|
||||
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)
|
||||
author=author, date_iso=date_iso,
|
||||
style_ids=style_ids)
|
||||
# replace consumes 3 IDs: ins-run, ins-mark, del
|
||||
next_id += 3
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user