207 lines
9.2 KiB
Python
207 lines
9.2 KiB
Python
"""Simplified Chinese (简体中文) editions for manufacturers. See MPM_Document_Standard.md §12.
|
||||
|
|
|
|||
|
|
A Chinese edition is a second PDF of the same document: same number, revision and geometry, body text
|
|||
|
|
in Chinese, frame labels bilingual, file name ending `_ZH_RevX.Y.pdf`. The English edition governs.
|
|||
|
|
It is built by `source/build_zh.py` next to the English `build.py`:
|
|||
|
|
|
|||
|
|
from mpmdoc import Doc, i18n
|
|||
|
|
tr = i18n.Translator({...document terms...}) # master glossary: _STANDARDS/glossary_zh.md
|
|||
|
|
doc = Doc(..., slug=<same as English>, lang="zh") # TN; Drawing(..., lang="zh") for DWG
|
|||
|
|
...
|
|||
|
|
tr.check() # fails the build on any untranslated figure text
|
|||
|
|
|
|||
|
|
What the engine does for lang="zh":
|
|||
|
|
* embeds the MPM SC font (Noto Sans SC, _ASSETS/mpm/fonts/cjk/) subset to the characters used, and adds
|
|||
|
|
it as the fallback to every font stack, so Latin text and numbers stay in the brand fonts
|
|||
|
|
* prints its own labels in Chinese (frame labels bilingual), dates as 2026年11月9日
|
|||
|
|
* adds the governing-language statement (Doc: under the masthead; Drawing: last general note on each sheet
|
|||
|
|
unless already present)
|
|||
|
|
* names the file {NUMBER}_{slug}_ZH_Rev{X.Y}.pdf and refuses to build unless the English edition at the
|
|||
|
|
same revision is already in the project folder
|
|||
|
|
"""
|
|||
|
|
import base64
|
|||
|
|
import io
|
|||
|
|
import re
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
from .brand import ASSETS
|
|||
|
|
|
|||
|
|
LANGS = ("en", "zh")
|
|||
|
|
TYPES = {"zh": {"TN", "DWG"}} # types with a defined Chinese edition (standard §12)
|
|||
|
|
SUFFIX = {"en": "", "zh": "_ZH"}
|
|||
|
|
FAMILY = "MPM SC"
|
|||
|
|
CJK_DIR = ASSETS / "fonts" / "cjk"
|
|||
|
|
GLOSSARY = Path(__file__).resolve().parent.parent / "glossary_zh.md"
|
|||
|
|
|
|||
|
|
# type code -> (header label, kicker label)
|
|||
|
|
TYPE_ZH = {"TN": ("技术说明", "技术说明"), "DWG": ("图纸", "图纸")}
|
|||
|
|
MARK_ZH = {"internal": "内部资料", "confidential": "机密"}
|
|||
|
|
|
|||
|
|
# engine labels: English -> Chinese. Frame labels a reader at MPM must find their way by stay bilingual.
|
|||
|
|
ZH = {
|
|||
|
|
"Document": "文件编号", "Revision": "版本", "Date": "日期", "Effective": "生效日期",
|
|||
|
|
"Draft": "草稿", "Released": "已发布",
|
|||
|
|
"Revision history": "修订记录 · Revision history",
|
|||
|
|
"Fig": "图", "FIG": "图", "Table": "表", "TABLE": "表",
|
|||
|
|
"Result": "结论", "Before you start": "开始之前",
|
|||
|
|
"Assumptions": "假设条件", "Open items": "待办事项", "Assumptions and open items": "假设条件与待办事项",
|
|||
|
|
"None.": "无。",
|
|||
|
|
"Prepared by": "编制", "Checked by": "校对", "Prepared and approved by": "编制及批准",
|
|||
|
|
"PASS": "合格", "FAIL": "不合格", "CHECK": "需核查",
|
|||
|
|
"TBC": "待确认", "To verify": "待核实",
|
|||
|
|
# drawing frame
|
|||
|
|
"Schedule": "部件表 · Schedule", "General notes": "总说明 · General notes",
|
|||
|
|
"Tag": "编号", "Designation": "名称", "Description": "说明", "Qty": "数量",
|
|||
|
|
"Project": "项目", "Client": "客户 Client", "Location": "地点 Location", "Scale": "比例 Scale",
|
|||
|
|
"Drawn": "制图 Drawn", "Checked": "校对 Checked", "Approved": "审核 Approved",
|
|||
|
|
"Dwg no.": "图号 Dwg no.", "Sheet": "图页 Sheet", "Rev": "版本 Rev", "Date ": "日期 Date",
|
|||
|
|
"Dwg no. · Sheet": "图号 · 图页 · 中文版", "SCALE": "比例", "N.T.S.": "不按比例", "As shown": "见图",
|
|||
|
|
"Irondale, AL": "Irondale, AL, USA",
|
|||
|
|
"Do not distribute without authorization.": "未经授权不得分发。",
|
|||
|
|
"Proprietary to Message Point Media": "MPM 专有资料",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def L(lang, key):
|
|||
|
|
return key if lang == "en" else ZH.get(key, key)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def show_date(d, lang="en"):
|
|||
|
|
from .units import show_date as _en, parse_date
|
|||
|
|
if lang == "en":
|
|||
|
|
return _en(d)
|
|||
|
|
d = parse_date(d)
|
|||
|
|
return f"{d.year}年{d.month}月{d.day}日"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def governing_note(number, rev):
|
|||
|
|
return f"本文件为 {number} Rev {rev} 英文版的中文译本;如有歧义,以英文版为准。"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def page_of(lang):
|
|||
|
|
if lang == "en":
|
|||
|
|
return 'Page <span class="pageNumber"></span> of <span class="totalPages"></span>'
|
|||
|
|
return '第 <span class="pageNumber"></span> 页 / 共 <span class="totalPages"></span> 页'
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---- fonts -------------------------------------------------------------------------------
|
|||
|
|
_CJK = re.compile(r"[⺀-鿿 -〿--ㄯ]")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def has_cjk(s):
|
|||
|
|
return bool(_CJK.search(s))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def font_css(text):
|
|||
|
|
"""@font-face rules for MPM SC, subset to the CJK characters in `text` (woff2, inlined)."""
|
|||
|
|
from fontTools.ttLib import TTFont
|
|||
|
|
from fontTools.subset import Subsetter, Options
|
|||
|
|
chars = {ord(c) for c in text if _CJK.match(c)} | {ord(c) for c in ":;,。()、"}
|
|||
|
|
rules = []
|
|||
|
|
for style, weights in (("Regular", "100 549"), ("Bold", "550 900")):
|
|||
|
|
src = CJK_DIR / f"NotoSansSC-{style}.ttf"
|
|||
|
|
if not src.exists():
|
|||
|
|
raise FileNotFoundError(f"{src} missing: run _ASSETS/mpm/fonts/cjk/make_cjk.py (or stage it)")
|
|||
|
|
f = TTFont(src)
|
|||
|
|
opts = Options(); opts.flavor = "woff2"; opts.layout_features = ["*"]; opts.notdef_outline = True
|
|||
|
|
sub = Subsetter(opts); sub.populate(unicodes=chars); sub.subset(f)
|
|||
|
|
buf = io.BytesIO(); f.flavor = "woff2"; f.save(buf)
|
|||
|
|
b64 = base64.b64encode(buf.getvalue()).decode()
|
|||
|
|
rules.append(f"@font-face{{font-family:'{FAMILY}';font-weight:{weights};font-style:normal;"
|
|||
|
|
f"src:url(data:font/woff2;base64,{b64}) format('woff2')}}")
|
|||
|
|
return "".join(rules)
|
|||
|
|
|
|||
|
|
|
|||
|
|
_FACE = re.compile(r"(@font-face\s*\{[^}]*\})")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def add_fallback(text):
|
|||
|
|
"""Append MPM SC to every font stack (CSS declarations and SVG font-family attributes),
|
|||
|
|
leaving @font-face rules alone."""
|
|||
|
|
def css(m):
|
|||
|
|
v = m.group(1)
|
|||
|
|
return m.group(0) if FAMILY in v else f"font-family:{v.rstrip()},'{FAMILY}'"
|
|||
|
|
|
|||
|
|
def attr(m):
|
|||
|
|
v = m.group(1)
|
|||
|
|
return m.group(0) if FAMILY in v else f"font-family=\"{v},'{FAMILY}'\""
|
|||
|
|
|
|||
|
|
parts = _FACE.split(text)
|
|||
|
|
for i, p in enumerate(parts):
|
|||
|
|
if i % 2 == 0:
|
|||
|
|
p = re.sub(r"font-family:\s*([^;}\"]+)", css, p)
|
|||
|
|
p = re.sub(r'font-family="([^"]+)"', attr, p)
|
|||
|
|
parts[i] = p
|
|||
|
|
return "".join(parts)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---- translation ---------------------------------------------------------------------------
|
|||
|
|
def load_glossary(path=GLOSSARY):
|
|||
|
|
"""Master MPM terms: `| English | 中文 | note |` rows in _STANDARDS/glossary_zh.md.
|
|||
|
|
Keys are matched case-insensitively against whole strings."""
|
|||
|
|
g = {}
|
|||
|
|
if not Path(path).exists():
|
|||
|
|
return g
|
|||
|
|
for line in Path(path).read_text().splitlines():
|
|||
|
|
if not line.startswith("|") or line.startswith("|---") or line.startswith("| English"):
|
|||
|
|
continue
|
|||
|
|
cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
|||
|
|
if len(cells) >= 2 and cells[0] and cells[1]:
|
|||
|
|
g[cells[0].upper()] = cells[1]
|
|||
|
|
return g
|
|||
|
|
|
|||
|
|
|
|||
|
|
UNITS = re.compile(r"(?<![A-Za-z])(mm|cm|m|in|ft|W|kW|Wh|V|VAC|VDC|A|mA|Hz|kHz|kg|g|lb|lbs|N|N·m|°C|°F|nits|cd/m²|Mbps|ms|h|min|s|px|IP\d\d)(?![A-Za-z])")
|
|||
|
|
KEEP = re.compile(r"^([A-Z]{1,3}-?\d+[A-Z]?|[A-Z]{1,3}\d+(\.\d+)?|LM-[\w.-]+|[\d\s.,:;×xX\[\]\"'/+\-−–°Ø()%=<>≤≥~]+)$")
|
|||
|
|
|
|||
|
|
|
|||
|
|
class Translator:
|
|||
|
|
"""Translates figure and table text for a Chinese edition.
|
|||
|
|
|
|||
|
|
tr = i18n.Translator({"VIEWED FROM PARKING LOT": "从停车场方向看"},
|
|||
|
|
suffixes=[(" POST CL TO CL", " 立柱中心距")], keep=r"^LM-")
|
|||
|
|
svg_zh = tr.svg(svg_en) # every <text>/<tspan> run
|
|||
|
|
s_zh = tr("BRACKET") # one string
|
|||
|
|
tr.check() # raise, listing every English string left untranslated
|
|||
|
|
|
|||
|
|
Lookup order: the document's own table (exact), then the master glossary (whole string,
|
|||
|
|
case-insensitive), then suffix rules for dimension labels ("3840 ACTIVE FACE"). Numbers, tags
|
|||
|
|
(S-1, M3), part numbers and strings already in Chinese pass through."""
|
|||
|
|
|
|||
|
|
def __init__(self, table=None, suffixes=(), keep=None, glossary=True):
|
|||
|
|
self.table = dict(table or {})
|
|||
|
|
self.gloss = load_glossary() if glossary else {}
|
|||
|
|
self.suffixes = list(suffixes)
|
|||
|
|
self.keep = re.compile(keep) if keep else None
|
|||
|
|
self.missing = []
|
|||
|
|
self._longest = sorted(self.gloss.items(), key=lambda kv: -len(kv[0]))
|
|||
|
|
|
|||
|
|
def __call__(self, s):
|
|||
|
|
if s in self.table:
|
|||
|
|
return self.table[s]
|
|||
|
|
t = s.strip()
|
|||
|
|
if not t or has_cjk(t):
|
|||
|
|
return s
|
|||
|
|
if t.upper() in self.gloss:
|
|||
|
|
return self.gloss[t.upper()]
|
|||
|
|
if t[:1].isdigit() or t[:1] in "−-[":
|
|||
|
|
for en, zh in self.suffixes:
|
|||
|
|
if s.endswith(en):
|
|||
|
|
return s[: -len(en)] + zh
|
|||
|
|
for en, zh in self._longest: # "3840 ACTIVE FACE": number + glossary term, longest first
|
|||
|
|
if t.upper().endswith(" " + en):
|
|||
|
|
return s[: len(s) - len(en)] + zh
|
|||
|
|
if KEEP.match(UNITS.sub("", t)):
|
|||
|
|
return s
|
|||
|
|
if KEEP.match(t) or (self.keep and self.keep.search(t)):
|
|||
|
|
return s
|
|||
|
|
self.missing.append(s)
|
|||
|
|
return s
|
|||
|
|
|
|||
|
|
def svg(self, svg):
|
|||
|
|
return re.sub(r">([^<>]+)</(text|tspan)>", lambda m: ">" + self(m.group(1)) + f"</{m.group(2)}>", svg)
|
|||
|
|
|
|||
|
|
def check(self):
|
|||
|
|
if self.missing:
|
|||
|
|
raise SystemExit("Untranslated text (add to the document table or glossary_zh.md): "
|
|||
|
|
+ repr(sorted(set(self.missing))))
|