Skip to content

Commit 5a81792

Browse files
committed
Add deterministic final DOCX RTL/layout pass
1 parent 8852dc5 commit 5a81792

1 file changed

Lines changed: 142 additions & 0 deletions

File tree

tools/finalize_docx_rtl.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
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

Comments
 (0)