|
| 1 | +from __future__ import annotations |
| 2 | + |
| 3 | +from pathlib import Path |
| 4 | +import re |
| 5 | +import sys |
| 6 | +import tempfile |
| 7 | +import zipfile |
| 8 | +import xml.etree.ElementTree as ET |
| 9 | + |
| 10 | +W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" |
| 11 | +NS = {"w": W} |
| 12 | +ET.register_namespace("w", W) |
| 13 | +PERSIAN_FONT = "Noto Naskh Arabic" |
| 14 | +LATIN_FONT = "Noto Sans" |
| 15 | +FA_RE = re.compile(r"[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]") |
| 16 | + |
| 17 | + |
| 18 | +def w(tag: str) -> str: |
| 19 | + return f"{{{W}}}{tag}" |
| 20 | + |
| 21 | + |
| 22 | +def ensure(parent, tag: str): |
| 23 | + node = parent.find(w(tag)) |
| 24 | + if node is None: |
| 25 | + node = ET.SubElement(parent, w(tag)) |
| 26 | + return node |
| 27 | + |
| 28 | + |
| 29 | +def set_bool(parent, tag: str, enabled: bool) -> None: |
| 30 | + node = parent.find(w(tag)) |
| 31 | + if enabled and node is None: |
| 32 | + ET.SubElement(parent, w(tag)) |
| 33 | + elif not enabled and node is not None: |
| 34 | + parent.remove(node) |
| 35 | + |
| 36 | + |
| 37 | +def set_jc(ppr, value: str) -> None: |
| 38 | + jc = ensure(ppr, "jc") |
| 39 | + jc.set(w("val"), value) |
| 40 | + |
| 41 | + |
| 42 | +def set_ind(ppr, first_line_twips: int = 0, left: int | None = None, right: int | None = None) -> None: |
| 43 | + ind = ensure(ppr, "ind") |
| 44 | + for attr in ("left", "right", "start", "end", "firstLine", "hanging"): |
| 45 | + ind.attrib.pop(w(attr), None) |
| 46 | + ind.set(w("firstLine"), str(first_line_twips)) |
| 47 | + if left is not None: |
| 48 | + ind.set(w("left"), str(left)) |
| 49 | + if right is not None: |
| 50 | + ind.set(w("right"), str(right)) |
| 51 | + |
| 52 | + |
| 53 | +def paragraph_text(p) -> str: |
| 54 | + return "".join((t.text or "") for t in p.findall(".//" + w("t"))) |
| 55 | + |
| 56 | + |
| 57 | +def style_id(p) -> str: |
| 58 | + ppr = p.find(w("pPr")) |
| 59 | + if ppr is None: |
| 60 | + return "" |
| 61 | + pstyle = ppr.find(w("pStyle")) |
| 62 | + return (pstyle.get(w("val"), "") if pstyle is not None else "") |
| 63 | + |
| 64 | + |
| 65 | +def is_code(p) -> bool: |
| 66 | + sid = style_id(p).lower() |
| 67 | + text = paragraph_text(p).strip() |
| 68 | + return "code" in sid or text.startswith(("```", "$ ", ">> ")) |
| 69 | + |
| 70 | + |
| 71 | +def has_drawing(p) -> bool: |
| 72 | + return p.find(".//" + w("drawing")) is not None |
| 73 | + |
| 74 | + |
| 75 | +def fix_paragraph(p) -> None: |
| 76 | + ppr = ensure(p, "pPr") |
| 77 | + sid = style_id(p) |
| 78 | + sid_l = sid.lower() |
| 79 | + text = paragraph_text(p) |
| 80 | + code = is_code(p) |
| 81 | + caption = sid_l == "caption" |
| 82 | + drawing = has_drawing(p) |
| 83 | + |
| 84 | + set_bool(ppr, "bidi", not code) |
| 85 | + set_bool(ppr, "widowControl", True) |
| 86 | + if sid_l.startswith("heading"): |
| 87 | + set_bool(ppr, "keepNext", True) |
| 88 | + |
| 89 | + if code: |
| 90 | + set_jc(ppr, "left") |
| 91 | + set_ind(ppr, 0) |
| 92 | + elif caption or drawing: |
| 93 | + set_jc(ppr, "center") |
| 94 | + set_ind(ppr, 0) |
| 95 | + else: |
| 96 | + set_jc(ppr, "right") |
| 97 | + if sid_l.startswith("heading") or sid_l.startswith("list"): |
| 98 | + set_ind(ppr, 0, left=110, right=310) |
| 99 | + else: |
| 100 | + set_ind(ppr, 238) |
| 101 | + |
| 102 | + for r in p.findall(".//" + w("r")): |
| 103 | + txt = "".join((t.text or "") for t in r.findall(".//" + w("t"))) |
| 104 | + rpr = ensure(r, "rPr") |
| 105 | + rtl = bool(FA_RE.search(txt)) |
| 106 | + set_bool(rpr, "rtl", rtl) |
| 107 | + lang = ensure(rpr, "lang") |
| 108 | + lang.set(w("val"), "fa-IR" if rtl else "en-US") |
| 109 | + rfonts = ensure(rpr, "rFonts") |
| 110 | + rfonts.set(w("ascii"), LATIN_FONT) |
| 111 | + rfonts.set(w("hAnsi"), LATIN_FONT) |
| 112 | + rfonts.set(w("cs"), PERSIAN_FONT if rtl else LATIN_FONT) |
| 113 | + rfonts.set(w("eastAsia"), LATIN_FONT) |
| 114 | + |
| 115 | + |
| 116 | +def fix_xml(data: bytes) -> bytes: |
| 117 | + root = ET.fromstring(data) |
| 118 | + for p in root.findall(".//" + w("p")): |
| 119 | + fix_paragraph(p) |
| 120 | + return ET.tostring(root, encoding="utf-8", xml_declaration=True) |
| 121 | + |
| 122 | + |
| 123 | +def finalize(path: Path) -> None: |
| 124 | + with tempfile.TemporaryDirectory(prefix="docx-finalize-") as td: |
| 125 | + tmp = Path(td) / path.name |
| 126 | + with zipfile.ZipFile(path, "r") as zin, zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) as zout: |
| 127 | + for item in zin.infolist(): |
| 128 | + data = zin.read(item.filename) |
| 129 | + if item.filename == "word/document.xml" or item.filename.startswith("word/header") or item.filename.startswith("word/footer"): |
| 130 | + data = fix_xml(data) |
| 131 | + zout.writestr(item, data) |
| 132 | + tmp.replace(path) |
| 133 | + |
| 134 | + |
| 135 | +if __name__ == "__main__": |
| 136 | + if len(sys.argv) != 2: |
| 137 | + raise SystemExit("Usage: finalize_docx_rtl.py <docx>") |
| 138 | + p = Path(sys.argv[1]) |
| 139 | + if not p.exists(): |
| 140 | + raise SystemExit(f"Missing DOCX: {p}") |
| 141 | + finalize(p) |
| 142 | + print(f"Finalized deterministic Persian RTL layout: {p}") |
0 commit comments