f83db2fac1
- add external validation reports and open-source comparison references - integrate Jaimini arudha/graha pada, enhanced argala, and additional synastry kutas - update skill docs and capability matrices - add smoke tests for open-source integrations
393 lines
12 KiB
Python
393 lines
12 KiB
Python
"""
|
||
Generate a bilingual (English + Chinese) paragraph-interleaved PDF for Jyotish-Prasana.
|
||
|
||
Input:
|
||
- progress/Jyotish-Prasana_pages.json (original English, 190 pages)
|
||
- progress/Jyotish-Prasana_translated.json (DeepSeek Chinese translation, 190 pages)
|
||
|
||
Output:
|
||
- output/Jyotish-Prasana_bilingual.pdf
|
||
|
||
Layout: EN paragraph → ZH paragraph → separator → repeat.
|
||
Headings auto-detected and rendered in dark blue.
|
||
|
||
Usage:
|
||
python tools/generate_bilingual_pdf.py
|
||
"""
|
||
|
||
import json
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
from fpdf import FPDF
|
||
|
||
# ---- Config ----
|
||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||
ENGLISH_JSON = PROJECT_ROOT / "progress" / "Jyotish-Prasana_pages.json"
|
||
CHINESE_JSON = PROJECT_ROOT / "progress" / "Jyotish-Prasana_translated.json"
|
||
OUTPUT_DIR = PROJECT_ROOT / "output"
|
||
OUTPUT_FILE = OUTPUT_DIR / "Jyotish-Prasana_bilingual.pdf"
|
||
|
||
FONT_YAHEI = "C:/Windows/Fonts/msyh.ttc"
|
||
FONT_YAHEI_BOLD = "C:/Windows/Fonts/msyhbd.ttc"
|
||
FONT_TNR = "C:/Windows/Fonts/times.ttf"
|
||
FONT_TNR_I = "C:/Windows/Fonts/timesi.ttf"
|
||
|
||
# Page layout
|
||
PAGE_W = 210
|
||
PAGE_H = 297
|
||
MARGIN_TOP = 18
|
||
MARGIN_BOTTOM = 18
|
||
MARGIN_SIDE = 20
|
||
BODY_W = PAGE_W - 2 * MARGIN_SIDE # 170mm
|
||
# Colors
|
||
COLOR_HEADING = (26, 82, 118) # dark blue
|
||
COLOR_EN_BODY = (50, 50, 50) # dark gray
|
||
COLOR_ZH_BODY = (0, 0, 0) # black
|
||
COLOR_SEP = (180, 180, 180) # light gray
|
||
|
||
# Font sizes
|
||
SIZE_EN_BODY = 10.5
|
||
SIZE_ZH_BODY = 10
|
||
SIZE_HEADING_EN = 11.5
|
||
SIZE_HEADING_ZH = 11
|
||
LINE_H = 5.8
|
||
|
||
# ---- Character filtering ----
|
||
|
||
def strip_unsupported(text: str) -> str:
|
||
"""Keep only chars supported by YaHei/TNR: ASCII, Latin Ext (IAST), CJK, punctuation."""
|
||
result = []
|
||
for ch in text:
|
||
cp = ord(ch)
|
||
if (
|
||
cp < 0x7F
|
||
or 0xA0 <= cp <= 0x24F
|
||
or 0x2000 <= cp <= 0x206F
|
||
or 0x2E00 <= cp <= 0x2E7F
|
||
or 0x3000 <= cp <= 0x303F
|
||
or 0x3400 <= cp <= 0x4DBF
|
||
or 0x4E00 <= cp <= 0x9FFF
|
||
or 0xFF00 <= cp <= 0xFFEF
|
||
):
|
||
result.append(ch)
|
||
return "".join(result)
|
||
|
||
|
||
IAST_MAP = str.maketrans({
|
||
"Ā": "A", "ā": "a", "Ī": "I", "ī": "i", "Ū": "U", "ū": "u",
|
||
"Ṛ": "R", "ṛ": "r", "Ṣ": "S", "ṣ": "s", "Ṭ": "T", "ṭ": "t",
|
||
"Ḍ": "D", "ḍ": "d", "Ṇ": "N", "ṇ": "n", "Ṅ": "N", "ṅ": "n",
|
||
"Ḷ": "L", "ḷ": "l", "Ḥ": "H", "ḥ": "h", "Ṃ": "M", "ṃ": "m",
|
||
"Ś": "S", "ś": "s", "Ñ": "N", "ñ": "n",
|
||
})
|
||
|
||
# ---- Text Cleaning ----
|
||
|
||
RE_IMG = re.compile(r"^\[(?:嵌入图片|插图)[^\]]*\]")
|
||
RE_ROMAN = re.compile(r"^\([ivxlcdm]+\)\s*$", re.IGNORECASE)
|
||
RE_HLINE = re.compile(r"^[\s\-_~=]+$")
|
||
RE_HEADING_EN = re.compile(
|
||
r"^(?:Chapter|CHAPTER)\s*-?\s*[IVXLCDM\d]+\b|"
|
||
r"^(?:[IVXLCDM]+\.?\s+[A-Z])|"
|
||
r"^[A-Z]{2,}(?:\s+[A-Z]+){0,4}\s*:?$|"
|
||
r"^\d+\.\s+[A-Z][a-z]"
|
||
)
|
||
RE_HEADING_ZH = re.compile(
|
||
r"^第[一二三四五六七八九十百千\d]+[章节卷]|"
|
||
r"^[一二三四五六七八九十]+[、.、]\s*\S|"
|
||
r"^([一二三四五六七八九十\d]+)"
|
||
)
|
||
|
||
|
||
def is_heading_en(line: str) -> bool:
|
||
"""Detect if an English line is a heading/title."""
|
||
s = line.strip()
|
||
if not s or len(s) > 80:
|
||
return False
|
||
if s.isupper() and len(s) > 3:
|
||
return True
|
||
if RE_HEADING_EN.match(s):
|
||
return True
|
||
return False
|
||
|
||
|
||
def is_heading_zh(line: str) -> bool:
|
||
"""Detect if a Chinese line is a heading/title."""
|
||
s = line.strip()
|
||
if not s or len(s) > 60:
|
||
return False
|
||
if RE_HEADING_ZH.match(s):
|
||
return True
|
||
# Line ending with :that contains CJK chars (section heading)
|
||
if s.endswith(":") and re.search(r"[一-鿿]", s):
|
||
return True
|
||
# Short line with Chinese + English in parens (e.g. "太阴年(Lunar Years):")
|
||
if re.match(r"^[一-鿿]{2,30}([A-Za-z\s]+)[::]?$", s):
|
||
return True
|
||
return False
|
||
|
||
|
||
def clean_english(text: str) -> str:
|
||
"""Remove artifacts from English OCR text."""
|
||
lines = text.split("\n")
|
||
out = []
|
||
for line in lines:
|
||
stripped = line.strip()
|
||
if RE_IMG.match(stripped):
|
||
continue
|
||
if RE_HLINE.match(stripped):
|
||
continue
|
||
if len(stripped) > 5:
|
||
printable = sum(1 for c in stripped if 32 <= ord(c) <= 126)
|
||
if printable / max(len(stripped), 1) < 0.4:
|
||
continue
|
||
# Detect garbled OCR: count actual English-like words (3+ alpha chars)
|
||
words = re.findall(r"[A-Za-z]{3,}", stripped)
|
||
word_chars = sum(len(w) for w in words)
|
||
alpha_chars = sum(1 for c in stripped if c.isalpha())
|
||
if alpha_chars > 10 and word_chars / max(alpha_chars, 1) < 0.3:
|
||
continue # mostly non-word garbage
|
||
line = line.replace("~", "")
|
||
line = strip_unsupported(line)
|
||
# Clean up empty parenthetical left after Devanagari removal: "( / ... →" → "(..."
|
||
line = re.sub(r"\(\s*/\s*", "(", line)
|
||
out.append(line)
|
||
return "\n".join(out)
|
||
|
||
|
||
def clean_chinese(text: str) -> str:
|
||
"""Remove artifacts from Chinese translated text."""
|
||
lines = text.split("\n")
|
||
out = []
|
||
for line in lines:
|
||
stripped = line.strip()
|
||
if RE_IMG.match(stripped):
|
||
continue
|
||
if "梵文诗句,内容待确认" in stripped:
|
||
continue
|
||
if RE_ROMAN.match(stripped):
|
||
continue
|
||
if RE_HLINE.match(stripped):
|
||
continue
|
||
line = line.replace("**", "")
|
||
line = line.replace("~", "")
|
||
line = strip_unsupported(line)
|
||
line = re.sub(r"\(\s*/\s*", "(", line)
|
||
out.append(line)
|
||
return "\n".join(out)
|
||
|
||
|
||
# ---- PDF Class ----
|
||
|
||
|
||
class BilingualPDF(FPDF):
|
||
def __init__(self):
|
||
super().__init__("P", "mm", "A4")
|
||
self.set_auto_page_break(auto=True, margin=MARGIN_BOTTOM)
|
||
self.add_font("YaHei", "", FONT_YAHEI)
|
||
self.add_font("YaHei", "B", FONT_YAHEI_BOLD)
|
||
self.add_font("TNR", "", FONT_TNR)
|
||
self.add_font("TNR", "I", FONT_TNR_I)
|
||
self._cover_done = False
|
||
|
||
def footer(self):
|
||
if not self._cover_done:
|
||
return
|
||
self.set_y(-12)
|
||
self.set_font("TNR", "", 8)
|
||
self.set_text_color(*COLOR_SEP)
|
||
self.cell(0, 8, f"- {self.page_no()} -", align="C")
|
||
|
||
def add_cover_page(self):
|
||
"""Bilingual cover page."""
|
||
self.add_page()
|
||
self.ln(45)
|
||
|
||
self.set_font("YaHei", "B", 28)
|
||
self.set_text_color(*COLOR_HEADING)
|
||
self.cell(0, 14, "MUHURTHA SINDHU", align="C", new_x="LMARGIN", new_y="NEXT")
|
||
|
||
self.set_font("YaHei", "B", 20)
|
||
self.cell(0, 12, "择时宝鉴", align="C", new_x="LMARGIN", new_y="NEXT")
|
||
self.ln(4)
|
||
|
||
self.set_font("TNR", "I", 14)
|
||
self.set_text_color(80, 80, 80)
|
||
self.cell(0, 10, "A Manual of Electional Astrology", align="C", new_x="LMARGIN", new_y="NEXT")
|
||
self.set_font("YaHei", "", 12)
|
||
self.cell(0, 10, "选举占星学手册", align="C", new_x="LMARGIN", new_y="NEXT")
|
||
self.ln(8)
|
||
|
||
self.set_font("YaHei", "", 11)
|
||
self.set_text_color(0, 0, 0)
|
||
self.cell(0, 8, "作者:Iranganti Rangacharya", align="C", new_x="LMARGIN", new_y="NEXT")
|
||
self.ln(4)
|
||
self.set_font("YaHei", "", 10)
|
||
self.set_text_color(100, 100, 100)
|
||
self.cell(0, 7, "英汉对照版 | Bilingual Edition", align="C", new_x="LMARGIN", new_y="NEXT")
|
||
self.cell(0, 7, "Chinese translation by DeepSeek AI | Typeset with fpdf2", align="C", new_x="LMARGIN", new_y="NEXT")
|
||
self.ln(14)
|
||
|
||
# Rule
|
||
self.set_draw_color(*COLOR_SEP)
|
||
self.set_line_width(0.3)
|
||
self.line(MARGIN_SIDE + 10, self.get_y(), PAGE_W - MARGIN_SIDE - 10, self.get_y())
|
||
self.ln(8)
|
||
|
||
self.set_font("YaHei", "", 8.5)
|
||
self.set_text_color(100, 100, 100)
|
||
desc = (
|
||
"本书为 Iranganti Rangacharya 所著《Muhurtha Sindhu》的英汉对照版。"
|
||
"英文原文通过 PDF 提取,中文翻译由 DeepSeek AI 完成。"
|
||
"全书共 190 页,涵盖择时占星学的完整体系。"
|
||
)
|
||
self.multi_cell(w=BODY_W, h=5, text=desc, align="C")
|
||
|
||
self._cover_done = True
|
||
|
||
|
||
# ---- Rendering ----
|
||
|
||
|
||
def write_styled_block(pdf: BilingualPDF, text: str, lang: str):
|
||
"""Write a text block with heading detection. Lines detected as headings
|
||
are rendered in dark blue bold; body text in default color."""
|
||
lines = text.split("\n")
|
||
# Normalize: merge consecutive non-heading lines for better wrapping
|
||
i = 0
|
||
while i < len(lines):
|
||
line = lines[i].strip()
|
||
if not line:
|
||
i += 1
|
||
continue
|
||
heading = (is_heading_en(line) if lang == "en" else is_heading_zh(line))
|
||
if heading:
|
||
# Render heading
|
||
pdf.ln(1.5)
|
||
if lang == "en":
|
||
pdf.set_font("TNR", "I", SIZE_HEADING_EN)
|
||
else:
|
||
pdf.set_font("YaHei", "B", SIZE_HEADING_ZH)
|
||
pdf.set_text_color(*COLOR_HEADING)
|
||
pdf.multi_cell(w=BODY_W, h=LINE_H + 1, text=line, align="L")
|
||
pdf.ln(0.5)
|
||
i += 1
|
||
else:
|
||
# Collect consecutive body lines
|
||
body_lines = []
|
||
while i < len(lines):
|
||
s = lines[i].strip()
|
||
if not s:
|
||
i += 1
|
||
continue
|
||
if is_heading_en(s) if lang == "en" else is_heading_zh(s):
|
||
break
|
||
body_lines.append(s)
|
||
i += 1
|
||
if body_lines:
|
||
body_text = " ".join(body_lines)
|
||
if lang == "en":
|
||
pdf.set_font("TNR", "", SIZE_EN_BODY)
|
||
pdf.set_text_color(*COLOR_EN_BODY)
|
||
else:
|
||
pdf.set_font("YaHei", "", SIZE_ZH_BODY)
|
||
pdf.set_text_color(*COLOR_ZH_BODY)
|
||
pdf.multi_cell(w=BODY_W, h=LINE_H, text=body_text, align="L")
|
||
pdf.ln(1)
|
||
|
||
# Check for page overflow after each element
|
||
if pdf.get_y() > PAGE_H - MARGIN_BOTTOM - 20:
|
||
break
|
||
|
||
|
||
def write_lang_separator(pdf: BilingualPDF):
|
||
"""Bold separator marking the language switch."""
|
||
pdf.ln(3)
|
||
pdf.set_draw_color(*COLOR_HEADING)
|
||
pdf.set_line_width(0.5)
|
||
y = pdf.get_y()
|
||
pdf.line(MARGIN_SIDE + 30, y, PAGE_W - MARGIN_SIDE - 30, y)
|
||
pdf.ln(2)
|
||
pdf.set_font("YaHei", "B", 8)
|
||
pdf.set_text_color(*COLOR_HEADING)
|
||
pdf.cell(BODY_W, 4, "▼ 中文翻译 ▼", align="C")
|
||
pdf.ln(6)
|
||
|
||
|
||
def render_page_pair(pdf: BilingualPDF, en_text: str, cn_text: str, page_num: int):
|
||
"""Render one source page: English block → separator → Chinese block."""
|
||
# Page badge
|
||
pdf.set_font("TNR", "I", 7)
|
||
pdf.set_text_color(*COLOR_SEP)
|
||
pdf.cell(BODY_W, 3, f"p. {page_num + 1}", align="R")
|
||
pdf.ln(5)
|
||
|
||
if not en_text.strip() and not cn_text.strip():
|
||
return
|
||
|
||
# English block
|
||
if en_text.strip():
|
||
write_styled_block(pdf, en_text, "en")
|
||
|
||
# Language separator
|
||
if en_text.strip() and cn_text.strip():
|
||
write_lang_separator(pdf)
|
||
|
||
# Chinese block
|
||
if cn_text.strip():
|
||
write_styled_block(pdf, cn_text, "zh")
|
||
|
||
|
||
# ---- Main ----
|
||
|
||
|
||
def main():
|
||
print("Loading JSON files...")
|
||
with open(ENGLISH_JSON, "r", encoding="utf-8") as f:
|
||
pages_en = json.load(f)
|
||
with open(CHINESE_JSON, "r", encoding="utf-8") as f:
|
||
pages_cn = json.load(f)
|
||
|
||
total = len(pages_en)
|
||
print(f" Source pages: {total}")
|
||
print(f" Output: {OUTPUT_FILE}")
|
||
|
||
pdf = BilingualPDF()
|
||
pdf.set_left_margin(MARGIN_SIDE)
|
||
pdf.set_right_margin(MARGIN_SIDE)
|
||
pdf.set_top_margin(MARGIN_TOP)
|
||
pdf.set_auto_page_break(auto=True, margin=MARGIN_BOTTOM)
|
||
|
||
pdf.add_cover_page()
|
||
|
||
for pg in range(total):
|
||
key = str(pg)
|
||
en_raw = pages_en.get(key, "")
|
||
cn_raw = pages_cn.get(key, "")
|
||
|
||
en_text = clean_english(en_raw)
|
||
cn_text = clean_chinese(cn_raw)
|
||
# IAST → ASCII for Chinese only (YaHei lacks extended Latin)
|
||
cn_text = cn_text.translate(IAST_MAP)
|
||
|
||
if not en_text.strip() and not cn_text.strip():
|
||
continue
|
||
|
||
pdf.add_page()
|
||
render_page_pair(pdf, en_text, cn_text, pg)
|
||
|
||
if (pg + 1) % 20 == 0:
|
||
print(f" Processed {pg + 1}/{total} pages...")
|
||
|
||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||
pdf.output(str(OUTPUT_FILE))
|
||
print(f"\nDone: {OUTPUT_FILE}")
|
||
print(f" PDF pages: {pdf.pages_count}")
|
||
print(f" Content pages: {total}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|