Persian text in older Iranian DWGs is not Unicode. It is a per-glyph substitution cipher designed for SHX fonts such as naskh.shx, naskhd.shx, naskh-c.shx, naskh_c2.shx, naskhs.shx and KOFI.SHX. It decodes reliably with a ~120-entry byte table, but only after canonicalizing libredwg's output. The strings are also mirrored and digit-reversed, which matters when you write English back into the drawing.
Recognize it
- STYLE table fonts named
naskh*.shx/KOFI.SHX(style names likefa,far,farsi,ND,NASKHD,KOOFI). - TEXT strings that look like Latin-1 noise:
lš\xa0©s‹¡ l‚—,v(= مهندسین مشاور, "consulting engineers"). - Every such TEXT has
text_generation_flag = 2(backward): the glyphs run leftwards frominsert.
Canonicalize before keying (libredwg 0.14 dwg2dxf --as r2018)
The same glyph byte reaches the DXF four ways: as a cp1252 character (Œ for 0x8C), as a cp1256 Arabic letter in some entities (byte 0xC1 shows as ء instead of Á), as a raw C1 control for bytes undefined in cp1252 (\x8d, \x9d), or as a %%nnn escape (%%140, %%177).
import re
ESCAPE = re.compile(r"%%(\d{3})")
def cipher_byte(ch: str) -> int:
if ord(ch) < 0x80:
return ord(ch)
for codec in ("cp1252", "cp1256"):
try:
return ch.encode(codec)[0]
except UnicodeEncodeError:
pass
if ord(ch) < 0x100:
return ord(ch) # undefined cp1252 byte passed through as C1
raise ValueError(hex(ord(ch)))
def canonical(b: int) -> str:
try:
return bytes([b]).decode("cp1252")
except UnicodeDecodeError:
return chr(b)
def normalize(text: str) -> str: # %%c/%%d/%%p are not 3-digit, so they survive
text = ESCAPE.sub(lambda m: chr(int(m.group(1))), text).strip()
return "".join(canonical(cipher_byte(c)) for c in text)Key your translation table by normalize(text). Without this step, identical Persian strings in different styles get different keys, and the decode table sprouts duplicate letters.
The cipher
- Lowercase ASCII follows the Persian standard keyboard:
h=ا,f=ب,j=ت,s=س,a=ش,l=م,k=ن,n=د,v=ر,c=ز,x=ط,,=و,d=ی,'=گ,;=ک,`=پ. - Bytes 0x80-0xFF are contextual forms (medial/final). Examples:
—and\x97= connected ا,Œ= final ی,›= final ه,µ= medial ق. >is the lam-alef ligature (لا).mis ئ._is kashida (drop it).(and)are mirrored.- Digit runs are stored reversed and
/is the decimal point:50/043means 340.05. After letter mapping, applyre.sub(r"[0-9/.]+", lambda m: m.group(0)[::-1].replace("/", "."), s).
Build the table iteratively. Decode everything with the current table, rendering unknown codes as [c]. Rank the unknowns by frequency with 5 contexts each. Assign the letter that turns at least 2 distinct strings into real words (architectural vocabulary helps: طبقه, پلان, مساحت, آسانسور). On a 250-string drawing this converged to 123 codes with 0 unknowns in three passes. Watch for typos in the source drawings, such as وروردی for ورودی. Do not "fix" the table to match them.
Writing English back (ezdxf 1.4.4)
from ezdxf.enums import TextEntityAlignment
text.dxf.text = english
flag = text.dxf.get("text_generation_flag", 0)
text.dxf.text_generation_flag = flag & ~2
if flag & 2 and text.dxf.get("halign", 0) == 0:
# mirrored Persian occupied [insert.x - width, insert.x]
text.set_placement(text.dxf.insert, align=TextEntityAlignment.BOTTOM_RIGHT)
text.dxf.style = "ROMANS"- Persian is compact, so English runs 1.5-2x longer. Expect overflow in table headers, compass labels and title blocks. Shorten translations rather than changing heights or width factors globally.
- Two Persian TEXTs that formed one RTL line come out in LTR order and can abut. Pick English that reads correctly in reversed order, or add a trailing space.
- After translating, remove the Farsi STYLE entries. Also repoint header
$TEXTSTYLE: it commonly names one of them (far), and libredwg'sdxf2dwgthen logsUnknown HEADER.TEXTSTYLE far.
Tested with libredwg 0.14 (Homebrew) and ezdxf 1.4.4.