Add translation guide

This commit is contained in:
iacore
2026-06-09 12:28:56 +08:00
parent 99a8621617
commit 94be05447a
20 changed files with 54 additions and 53 deletions
@@ -0,0 +1,322 @@
---
name: pdf-to-docx-conversion
description: "Convert flowing text PDFs (Chinese or multi-language) to DOCX with proper fonts, styles, native bullets, lists, and embedded images. Preserves visual hierarchy from PDF font/size/color data."
version: 1.0.0
author: Claude
license: MIT
platforms: [linux, macos, windows]
metadata:
hermes:
tags: [PDF, DOCX, Documents, python-docx, pymupdf]
---
# PDF-to-DOCX Conversion
Convert PDF documents (especially flowing text documents in any language, including CJK) into well-structured DOCX files that preserve fonts, sizes, colors, and layout intent.
## Prerequisites
```bash
pip install pymupdf python-docx
```
## Features
- **Style-aware**: reads actual font, size, bold, color from PDF spans
- **Native bullets**: uses Word `List Bullet` style instead of Wingdings glyphs
- **Native numbering**: uses numbered list style for sequential items
- **Image extraction**: detects and embeds PDF images into the DOCX
- **Verse/poetry handling**: splits merged verse lines at semantic boundaries
- **Multi-language**: works with CJK, RTL, and mixed-script documents
- **Flowing text**: text flows across pages; no forced page breaks
## Quick Start
```python
from pdf_to_docx import convert_pdf_to_docx
convert_pdf_to_docx("input.pdf", "output.docx")
```
## Step-by-Step Workflow
### 1. Inspect the PDF
First, dump the PDF to understand its structure:
```bash
python3 << 'PY'
import pymupdf
doc = pymupdf.open("input.pdf")
for pi in range(len(doc)):
page = doc[pi]
blocks = page.get_text("dict")["blocks"]
for block in blocks:
if block["type"] != 0: continue
for line in block["lines"]:
for span in line["spans"]:
bbox = span["bbox"]
flags = span["flags"]
attrs = []
if flags & 2**1: attrs.append("I")
if flags & 2**4: attrs.append("B")
print(f" Y={bbox[1]:.0f} [{span['size']:.1f}pt {'+'.join(attrs) or '-'}] {span['font']} | {span['text']}")
PY
```
Key things to identify:
- **Fonts used** (map to DOCX fonts)
- **Bullet mechanism** (Wingdings? Unicode?)
- **Header hierarchy** (what size = section header vs sub-header)
- **Numbered lists** (what delimiter: `1)` `1.` `1`)
- **Images** (check `page.get_images()`)
- **Special sections** (tables, verses, forms)
### 2. Configure the Converter
Create a config dict matching your PDF's patterns:
```python
config = {
"fonts": {
"title": "STHeitiSC-Medium",
"body": "HYShuSongErKW",
"page_number": "HelveticaNeue",
},
"header_sizes": {"section": 18, "sub": 15},
"body_size": 12,
"bullet_fonts": ["Wingdings", "Wingdings 2", "Wingdings 3"],
"page_number_font": "HelveticaNeue",
"numbered_patterns": [r'^\d+\)', r'^\d+\.'], # detect numbered items
"skip_fonts": ["HelveticaNeue"], # fonts to skip (page numbers)
}
```
### 3. Run the Conversion
```python
from pdf_to_docx import PDFToDOCXConverter
converter = PDFToDOCXConverter(config)
converter.convert("input.pdf", "output.docx")
```
## Core Classes
### PDFToDOCXConverter
```python
class PDFToDOCXConverter:
def __init__(self, config=None):
self.cfg = config or self._default_config()
def convert(self, pdf_path: str, docx_path: str):
"""Main entry point."""
# 1. Extract all spans
# 2. Merge Wingdings bullets with body text
# 3. Group lines into paragraphs by Y-gap and style changes
# 4. Post-process: split compact lists, verses, merged steps
# 5. Build DOCX with proper styles
# 6. Embed images
pass
def extract_spans(self, pdf_path: str) -> list[dict]:
"""Extract all text spans with full style info."""
doc = pymupdf.open(pdf_path)
raw = []
for pi in range(len(doc)):
for block in doc[pi].get_text("dict")["blocks"]:
if block["type"] != 0: continue
for line in block["lines"]:
for span in line["spans"]:
raw.append({
"text": span["text"], "font": span["font"],
"size": span["size"], "flags": span["flags"],
"color": span["color"], "bbox": span["bbox"],
})
return raw
def detect_bullets(self, line: list) -> bool:
"""Check if first span in line is a Wingdings bullet."""
return "Wingdings" in line[0]["font"]
def group_paragraphs(self, lines: list) -> list[dict]:
"""Group raw lines into logical paragraphs."""
# Group by Y-gap threshold (typically 20-30pt)
# Break on style change (font change, size > threshold, bold toggle)
# Break on attribution lines (——节选自...)
# Break on special fonts (STHeitiSC-Light etc.)
pass
def post_process(self, paras: list) -> list[dict]:
"""Split merged compact lists and verses."""
# See examples below for common patterns
pass
```
## Common Post-Processing Patterns
### Compact Numbered Lists
When the PDF flows list items together in one paragraph:
```python
def split_compact_list(text: str) -> list[str]:
"""Split '1) foo 2) bar 3) baz' into separate items."""
parts = re.split(r'(?=\d+\))', text)
return [p for p in parts if p.strip()]
```
### Verse / Poetry Lines
When the PDF merges verse lines that should be on separate lines:
```python
def split_verse(text: str, split_markers: list[str]) -> list[str]:
"""Split verse at semantic phrase boundaries.
Example markers: ['感恩', '', '更愿']"""
pattern = '|'.join(f'(?={m})' for m in split_markers)
return [p for p in re.split(pattern, text) if p.strip()]
```
### 小组交流流程 / Process Steps
Split Chinese process steps numbered 一、二、三、etc.:
```python
def split_chinese_steps(text: str) -> list[str]:
"""Split '一、foo 二、bar' into separate items."""
parts = re.split(r'(?=[一二三四五六七八九十]、)', text)
return [p.strip() for p in parts if p.strip()]
```
## DOCX Construction
### Font Setup (East Asian fonts)
```python
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
def set_east_asian_font(run, name: str):
"""Set CJK font properly in python-docx."""
rPr = run._element.get_or_add_rPr()
rFonts = rPr.find(qn('w:rFonts'))
if rFonts is None:
rFonts = OxmlElement('w:rFonts')
rPr.insert(0, rFonts)
rFonts.set(qn('w:eastAsia'), name)
rFonts.set(qn('w:ascii'), name)
rFonts.set(qn('w:hAnsi'), name)
```
### Bullet Items
Use native Word bullets, NOT Wingdings characters:
```python
p = doc.add_paragraph(style='List Bullet')
p.clear()
run = p.add_run("Your bullet text here")
set_east_asian_font(run, font_name)
```
### Image Embedding
```python
from docx.shared import Inches
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = p.add_run()
run.add_picture(image_path, width=Inches(3.5))
```
Extract images from PDF first:
```python
doc = pymupdf.open("input.pdf")
for pi in range(len(doc)):
images = doc[pi].get_images()
for idx, img in enumerate(images):
xref = img[0]
base = doc.extract_image(xref)
with open(f"extracted_{pi}_{idx}.{base['ext']}", 'wb') as f:
f.write(base['image'])
```
## Verification Checklist
After conversion, verify the DOCX:
```bash
python3 -c "
from docx import Document
doc = Document('output.docx')
print(f'Paragraphs: {len(doc.paragraphs)}')
for i, p in enumerate(doc.paragraphs):
style = p.style.name if p.style else '-'
txt = p.text[:80]
print(f'[{i:2d}] [{style:15s}] {txt}')
"
```
Check for:
1. [ ] All sections present (count paragraphs)
2. [ ] No merged verses or lists
3. [ ] Headers are bold + larger size
4. [ ] Bullets use `List Bullet` style
5. [ ] Numbered items are separate paragraphs
6. [ ] Images present in `word/media/`
7. [ ] Attribution lines right-aligned/indented
8. [ ] No page numbers leaked into body
## Troubleshooting
| Symptom | Cause | Fix |
|---------|-------|-----|
| Text over-merged | Y-gap threshold too high | Lower gap threshold (e.g. 20 → 15) |
| Missing sections | Skipped by font filter | Add font to skip_fonts or remove filter |
| Over-split lines | Y-gap threshold too low | Raise gap threshold (e.g. 20 → 30) |
| Wingdings boxes | Unicode bullet inserted | Use `style='List Bullet'` instead |
| CJK font wrong | East Asian font not set | Use `set_east_asian_font()` helper |
| Image missing | Not extracted before DOCX build | Run `extract_images()` first |
| Verse mangled | Regex too aggressive | Tune verse splitting pattern |
## Full Example Script
See `scripts/convert_pdf_to_docx.py` for a production-ready converter with all patterns pre-configured.
```python
# scripts/convert_pdf_to_docx.py
import pymupdf, re
from docx import Document
from docx.shared import Pt, Cm, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
def convert_pdf_to_docx(pdf_path: str, docx_path: str, config: dict = None):
"""Convert a flowing text PDF to DOCX."""
cfg = config or {}
# Extract
doc_pdf = pymupdf.open(pdf_path)
raw = []
for pi in range(len(doc_pdf)):
for block in doc_pdf[pi].get_text("dict")["blocks"]:
if block["type"] != 0: continue
for line in block["lines"]:
ss = line["spans"]
is_b = any("Wingdings" in s["font"] for s in ss[:1])
text = "".join(s["text"] for s in ss)
dom = ss[1] if (is_b and len(ss) > 1) else ss[0]
raw.append(dict(text=text, font=dom["font"], size=dom["size"],
bold=bool(dom["flags"] & 2**4), color=dom["color"],
x=dom["bbox"][0], y=dom["bbox"][1], is_bullet=is_b))
# ... (merge bullets, group paragraphs, post-process, build DOCX)
# Run:
# python scripts/convert_pdf_to_docx.py input.pdf output.docx
```
@@ -0,0 +1,549 @@
#!/usr/bin/env python3
"""
Production-ready PDF-to-DOCX converter.
Handles multi-language text, mixed fonts, bullets, numbered lists, verses,
attributions, images, and flowing text across pages.
Usage:
python convert_pdf_to_docx.py input.pdf output.docx
Requires: pip install pymupdf python-docx
"""
import sys
import re
import pymupdf
from docx import Document
from docx.shared import Pt, Cm, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
# ══════════════════════════════════════════════════════════════════════════════════════
# Config —— tweak these for your PDF's style conventions
# ═════════════════════════════════════════════════════════════════════════════════════
DEFAULT_CONFIG = {
# Font names used for different roles
"fonts": {
"title": "STHeitiSC-Medium",
"body": "HYShuSongErKW",
"page_num": "HelveticaNeue",
},
# Size thresholds (pt) for paragraph classification
"thresholds": {
"section_header": 18, # —— bold, centered: 【法义】 【思考】 【练习】
"sub_header": 14, # —— bold: 一、认识感恩, 【使用说明】
"body": 12,
},
# Fonts to treat as bullets (skipped as glyphs, trigger List Bullet style)
"bullet_fonts": ["Wingdings", "Wingdings 2", "Wingdings 3", "Symbol"],
# Fonts to skip entirely (page numbers, decorative markers)
"skip_fonts": ["HelveticaNeue"],
"skip_size_max": 9.5,
# Numbered-list delimiters in the PDF text
"numbered_patterns": [
r'^\d+\)', # 1) 2) 3)
r'^\d+\.\s*', # 1. 2. 3.
r'^\d+', # 1 2 3 (full-width parens)
r'^\d+、', # 1、 2、 3、 (ideographic comma)
],
# Paragraph grouping: maximum Y-gap (pt) between lines to keep in same paragraph
"y_gap_threshold": 20,
# Indentation for body/numbered items (cm)
"indent_body": 0.8,
# Verse markers —— used to split merged poetic lines
"verse_markers": [
r'感恩(?!恩)', # 感恩 (not followed by 恩)
r'愿我们', # 愿我们
r'更愿', # 更愿
r'愿人们', # 愿人们
r'愿世界', # 愿世界
],
}
# ══════════════════════════════════════════════════════════════════════════════════════
# DOCX helpers
# ═══════════════════════════════════════════════════════════════════════════════════════
def set_east_asian_font(run, name: str):
"""Set CJK/RTL font properly in python-docx (East Asian + ascii + hAnsi)."""
run.font.name = name
rPr = run._element.get_or_add_rPr()
rFonts = rPr.find(qn('w:rFonts'))
if rFonts is None:
rFonts = OxmlElement('w:rFonts')
rPr.insert(0, rFonts)
for attr in ('eastAsia', 'ascii', 'hAnsi'):
rFonts.set(qn(f'w:{attr}'), name)
def _mk_color(val: int) -> RGBColor | None:
if val and val != 0:
return RGBColor((val >> 16) & 0xFF, (val >> 8) & 0xFF, val & 0xFF)
return None
def _is_numbered(text: str, cfg: dict) -> bool:
return any(re.match(pat, text) for pat in cfg["numbered_patterns"])
def _is_bullet_font(font: str, cfg: dict) -> bool:
return any(b in font for b in cfg["bullet_fonts"])
def _skip_span(span: dict, cfg: dict) -> bool:
"""True if this span should be dropped entirely."""
if span["font"] in cfg["skip_fonts"] and span["size"] <= cfg["skip_size_max"]:
return True
if _is_bullet_font(span["font"], cfg):
return False # bullets are handled upstream
return False
# ══════════════════════════════════════════════════════════════════════════════════════
# Extraction
# ════════════════════════════════════════════════════════════════════════════════════════════
def extract_lines(pdf_path: str, cfg: dict) -> list[dict]:
"""Return flattened list of text lines with style info."""
doc_pdf = pymupdf.open(pdf_path)
lines = []
for pi in range(len(doc_pdf)):
page = doc_pdf[pi]
for block in page.get_text("dict")["blocks"]:
if block["type"] != 0:
continue # skip images
for line in block["lines"]:
spans = line["spans"]
if not spans:
continue
# Detect bullet: first span is Wingdings / Symbol
is_bullet = _is_bullet_font(spans[0]["font"], cfg)
# Dominant span for style (skip Wingdings glyph)
dom = spans[1] if (is_bullet and len(spans) > 1) else spans[0]
# Skip decorative spans entirely
if _skip_span(dom, cfg):
continue
text = "".join(s["text"] for s in spans)
lines.append({
"text": text,
"font": dom["font"],
"size": dom["size"],
"bold": bool(dom["flags"] & 2**4),
"color": dom["color"],
"x": dom["bbox"][0],
"y": dom["bbox"][1],
"is_bullet": is_bullet,
})
return lines
def extract_images(pdf_path: str, out_dir: str = "/tmp") -> list[str]:
"""Extract all embedded images from PDF. Returns list of file paths."""
doc = pymupdf.open(pdf_path)
paths = []
for pi in range(len(doc)):
page = doc[pi]
for idx, img in enumerate(page.get_images()):
xref = img[0]
base = doc.extract_image(xref)
path = f"{out_dir}/pdf_img_p{pi}_{idx}.{base['ext']}"
with open(path, "wb") as f:
f.write(base["image"])
paths.append((pi, path))
return paths
# ═════════════════════════════════════════════════════════════════════════════════════════════
# Grouping & Classification
# ══════════════════════════════════════════════════════════════════════════════════════════════
def group_paragraphs(lines: list[dict], cfg: dict) -> list[dict]:
"""Group raw lines into logical paragraphs."""
paras = []
i = 0
gap_thresh = cfg["y_gap_threshold"]
while i < len(lines):
ln = lines[i]
# ─── Headers ───
if ln["bold"] and ln["size"] >= cfg["thresholds"]["section_header"]:
paras.append({
"text": ln["text"], "font": ln["font"], "size": ln["size"],
"bold": True, "color": ln["color"], "x": ln["x"],
"kind": "header"
})
i += 1
continue
if ln["bold"] and ln["size"] >= cfg["thresholds"]["sub_header"]:
paras.append({
"text": ln["text"], "font": ln["font"], "size": ln["size"],
"bold": True, "color": ln["color"], "x": ln["x"],
"kind": "subheader"
})
i += 1
continue
# ─── Attribution ───
if ln["text"].startswith("——"):
paras.append({
"text": ln["text"], "font": ln["font"], "size": ln["size"],
"bold": False, "color": ln["color"], "x": ln["x"],
"kind": "attribution"
})
i += 1
continue
# ─── Bullet item ───
if ln["is_bullet"]:
body = ln["text"].lstrip("\uf06c \uf0b7 \u2022 ").lstrip() # strip common bullet chars
buf = [body]
bf, bs = ln["font"], ln["size"]
i += 1
while i < len(lines):
nxt = lines[i]
if nxt["bold"] and nxt["size"] >= cfg["thresholds"]["sub_header"]:
break
if nxt["is_bullet"]:
break
if nxt["text"].startswith("——"):
break
gap = nxt["y"] - (lines[i - 1]["y"] + lines[i - 1]["size"])
if gap > gap_thresh:
break
if _is_numbered(nxt["text"], cfg) and nxt["x"] <= 115:
break # nested numbered item = new para
if nxt["font"] != bf:
break
buf.append(nxt["text"])
i += 1
paras.append({
"text": "".join(buf), "font": bf, "size": bs,
"bold": False, "color": ln["color"], "x": ln["x"],
"kind": "bullet"
})
continue
# ─── Special fonts (one-liners like STHeitiSC-Light notes) ───
if ln["font"] == "STHeitiSC-Light":
paras.append({
"text": ln["text"], "font": cfg["fonts"]["body"], "size": ln["size"],
"bold": False, "color": ln["color"], "x": ln["x"],
"kind": "special"
})
i += 1
continue
# ─── Body / numbered / exercise labels ───
buf = [ln["text"]]
bf, bs, bc, bx = ln["font"], ln["size"], ln["color"], ln["x"]
i += 1
while i < len(lines):
nxt = lines[i]
# Hard breaks
if nxt["bold"] and nxt["size"] >= cfg["thresholds"]["sub_header"]:
break
if nxt["is_bullet"]:
break
if nxt["text"].startswith("——"):
break
if nxt["font"] == "STHeitiSC-Light":
break
if _skip_span(nxt, cfg):
i += 1
continue
gap = nxt["y"] - (lines[i - 1]["y"] + lines[i - 1]["size"])
style_changed = nxt["font"] != bf or abs(nxt["size"] - bs) > 1.5
# Break on new numbered item at left margin
is_new_numbered = _is_numbered(nxt["text"], cfg) and nxt["x"] <= 115
# Break on exercise day headers
is_day = bool(re.match(r'^第\d+ 天', nxt["text"]))
is_ex_label = bool(re.match(r'^(今日感恩练习心得|感恩日记|我的练习)', nxt["text"]))
if gap > gap_thresh or style_changed or is_new_numbered or is_day or is_ex_label:
break
buf.append(nxt["text"])
i += 1
text = "".join(buf)
kind = "body"
if _is_numbered(text, cfg) and bx <= 115:
kind = "numbered"
elif bool(re.match(r'^第\d+ 天', text)):
kind = "day_header"
elif bool(re.match(r'^(今日感恩练习心得|感恩日记|我的练习)', text)):
kind = "exercise_label"
elif bx > 160:
kind = "centered_body"
paras.append({
"text": text, "font": bf, "size": bs, "bold": False,
"color": bc, "x": bx, "kind": kind
})
return paras
# ══════════════════════════════════════════════════════════════════════════════════════════════════════
# Post-processing
# ════════════════════════════════════════════════════════════════════════════════════════════════════════
def split_compact_lists(paras: list[dict], cfg: dict) -> list[dict]:
"""Split paragraphs that contain multiple numbered items."""
out = []
for p in paras:
text = p["text"]
numbers = re.findall(r'\d+\)', text)
# Only split if more than 2 numbered items in a body paragraph
if p["kind"] in ("numbered", "body") and len(numbers) > 2:
parts = re.split(r'(?=\d+\))', text)
for part in parts:
if part.strip():
out.append({
"text": part.strip(), "font": p["font"], "size": p["size"],
"bold": False, "color": p["color"], "x": p["x"], "kind": "numbered"
})
else:
out.append(p)
return out
def split_verses(paras: list[dict], cfg: dict) -> list[dict]:
"""Split merged poetic / verse lines."""
out = []
for p in paras:
text = p["text"]
markers = cfg.get("verse_markers", [])
if not markers:
out.append(p)
continue
# Heuristic: paragraph contains repeated marker phrases
total_markers = sum(len(re.findall(m, text)) for m in markers)
if total_markers < 3:
out.append(p)
continue
# Build a combined split regex from all markers
combined = '|'.join(f'(?={m})' for m in markers)
parts = re.split(combined, text)
# Also split Chinese process steps (一、二、三、) that may prefix the verse
prefix = ""
verse_start = 0
for idx, part in enumerate(parts):
if re.match(r'[一二三四五六七八九十]、', part):
prefix += part
verse_start = idx + 1
else:
break
# Emit prefix steps
if prefix:
for step in re.split(r'(?=[一二三四五六七八九十]、)', prefix):
if step.strip():
out.append({
"text": step.strip(), "font": p["font"], "size": p["size"],
"bold": False, "color": p["color"], "x": p["x"], "kind": "numbered"
})
# Emit verse lines
for part in parts[verse_start:]:
part = part.strip()
if not part:
continue
# Check for trailing process step (五、回向 etc.)
tail_match = re.search(r'([一二三四五六七八九十]、.+)$', part)
if tail_match:
main_text = part[:tail_match.start()].strip()
tail = tail_match.group(1)
if main_text:
out.append({
"text": main_text, "font": p["font"], "size": p["size"],
"bold": False, "color": p["color"], "x": p["x"] + 100, "kind": "verse_line"
})
out.append({
"text": tail, "font": p["font"], "size": p["size"],
"bold": False, "color": p["color"], "x": p["x"], "kind": "numbered"
})
else:
out.append({
"text": part, "font": p["font"], "size": p["size"],
"bold": False, "color": p["color"], "x": p["x"] + 100, "kind": "verse_line"
})
return out
def split_chinese_steps(paras: list[dict]) -> list[dict]:
"""Split merged Chinese process steps (一、二、etc.) in body paragraphs."""
out = []
for p in paras:
text = p["text"]
if p["kind"] == "body" and len(re.findall(r'[一二三四五六七八九十]、', text)) > 1:
parts = re.split(r'(?=[一二三四五六七八九十]、)', text)
for part in parts:
if part.strip():
out.append({
"text": part.strip(), "font": p["font"], "size": p["size"],
"bold": False, "color": p["color"], "x": p["x"], "kind": "numbered"
})
else:
out.append(p)
return out
# ═════════════════════════════════════════════════════════════════════════════════════════════════════════════
# DOCX building
# ═════════════════════════════════════════════════════════════════════════════════════════════════════════════
def build_docx(paras: list[dict], images: list[tuple[int, str]], cfg: dict) -> Document:
"""Build a DOCX from classified paragraphs."""
doc = Document()
section = doc.sections[0]
section.page_width = Cm(21.0)
section.page_height = Cm(29.7)
section.top_margin = Cm(2.54)
section.bottom_margin = Cm(2.54)
section.left_margin = Cm(3.18)
section.right_margin = Cm(3.18)
# Track which images have been inserted (insert after first occurrence)
inserted_images = set()
def _add_run(paragraph, text: str, font: str, size: float, bold: bool = False,
color=None, alignment=None):
if alignment is not None:
paragraph.alignment = alignment
run = paragraph.add_run(text)
run.font.size = Pt(size)
run.font.bold = bold
if color:
run.font.color.rgb = color
set_east_asian_font(run, font)
return run
def _add_para(text: str, font: str, size: float, bold: bool = False,
alignment=None, sb: int = 0, sa: int = 0,
color=None, style=None, indent: float = None):
if style:
p = doc.add_paragraph(style=style)
p.clear()
else:
p = doc.add_paragraph()
p.paragraph_format.space_before = Pt(sb)
p.paragraph_format.space_after = Pt(sa)
p.paragraph_format.line_spacing = 1.15
if indent:
p.paragraph_format.left_indent = Cm(indent)
_add_run(p, text, font, size, bold, color, alignment)
return p
# Image helper
def _insert_image(image_path: str):
ip = doc.add_paragraph()
ip.alignment = WD_ALIGN_PARAGRAPH.CENTER
ip.paragraph_format.space_before = Pt(6)
ip.paragraph_format.space_after = Pt(6)
ir = ip.add_run()
ir.add_picture(image_path, width=Inches(3.3))
for p in paras:
text = p["text"]
font = p["font"]
size = p["size"]
bold = p["bold"]
color = _mk_color(p["color"])
kind = p["kind"]
if kind == "header":
_add_para(text, font, size, bold=True,
alignment=WD_ALIGN_PARAGRAPH.CENTER, sb=10, sa=8)
elif kind == "subheader":
_add_para(text, font, size, bold=True, sb=8, sa=4)
elif kind == "bullet":
_add_para(text, font, size, style='List Bullet', sb=0, sa=1, color=color)
elif kind == "attribution":
_add_para(text, font, size,
alignment=WD_ALIGN_PARAGRAPH.RIGHT, sb=2, sa=6, color=color)
elif kind == "numbered":
_add_para(text, font, size, indent=cfg["indent_body"], sb=1, sa=1, color=color)
elif kind == "day_header":
_add_para(text, font, size, bold=True, sb=6, sa=2, color=color)
elif kind == "exercise_label":
_add_para(text, font, size, sb=2, sa=1, color=color)
elif kind == "special":
_add_para(text, font, size, sb=6, sa=4)
elif kind == "verse_line":
_add_para(text, font, size, indent=2.0, sb=0, sa=0, color=color)
elif kind == "centered_body":
_add_para(text, font, size,
alignment=WD_ALIGN_PARAGRAPH.CENTER, sb=2, sa=4, color=color)
else: # body
indent = cfg["indent_body"] if p["x"] > 105 else None
_add_para(text, font, size, indent=indent, sb=1, sa=2, color=color)
# Insert images after paragraphs containing "参考示例" or other markers
if "参考示例" in text or "示例" in text:
for pi, img_path in images:
if img_path not in inserted_images:
_insert_image(img_path)
inserted_images.add(img_path)
break
return doc
# ════════════════════════════════════════════════════════════════════════════════════════════════════════════════════
# Public API
# ════════════════════════════════════════════════════════════════════════════════════════════════════════════
def convert_pdf_to_docx(pdf_path: str, docx_path: str, config: dict = None):
"""
Convert a flowing text PDF to a well-structured DOCX.
Args:
pdf_path: Path to input PDF
docx_path: Path to output DOCX
config: Optional override dict (merged with DEFAULT_CONFIG)
"""
cfg = DEFAULT_CONFIG.copy()
if config:
cfg.update(config)
# 1. Extract images
images = extract_images(pdf_path)
# 2. Extract text lines
lines = extract_lines(pdf_path, cfg)
# 3. Group into paragraphs
paras = group_paragraphs(lines, cfg)
# 4. Post-process
paras = split_compact_lists(paras, cfg)
paras = split_verses(paras, cfg)
paras = split_chinese_steps(paras)
# 5. Build DOCX
doc = build_docx(paras, images, cfg)
doc.save(docx_path)
print(f"Saved: {docx_path} ({len(doc.paragraphs)} paragraphs)")
return docx_path
# CLI
if __name__ == "__main__":
if len(sys.argv) != 3:
print("Usage: python convert_pdf_to_docx.py <input.pdf> <output.docx>")
sys.exit(1)
convert_pdf_to_docx(sys.argv[1], sys.argv[2])
@@ -0,0 +1,31 @@
{
"_description": "Pre-configured for Chinese Sutra/study-material PDFs with STHeitiSC-HYShuSongErKW fonts",
"fonts": {
"title": "STHeitiSC-Medium",
"body": "HYShuSongErKW",
"page_num": "HelveticaNeue"
},
"thresholds": {
"section_header": 18,
"sub_header": 14,
"body": 12
},
"bullet_fonts": ["Wingdings", "Wingdings 2", "Wingdings 3", "Symbol"],
"skip_fonts": ["HelveticaNeue", "Arial-BoldMT", "ArialMT"],
"skip_size_max": 9.9,
"numbered_patterns": [
"^\\d+\\)",
"^\\d+\\.\\s*",
"^\\d+",
"^\\d+、"
],
"y_gap_threshold": 20,
"indent_body": 0.8,
"verse_markers": [
"感恩(?!恩)",
"愿我们",
"更愿",
"愿人们",
"愿世界"
]
}
+3
View File
@@ -0,0 +1,3 @@
Translation-related skills. Not good to place under ~/.hermes/skills as the skils are not general enough.
To Hermes: if you create a skill related to translation, place it here in this directory.
@@ -0,0 +1,93 @@
---
name: translation-review
description: Review Chinese↔English translation pairs for quality issues — terminology errors, grammar, consistency, formatting. Works with CSV/XLSX files and writes edit suggestions in .dj format.
---
# Translation Review
## When to use
- User has a CSV or XLSX file with `Chinese`/`English` (or similar) columns
- User asks you to "find problems," "check translations," or "review localization"
- User mentions `.dj` edit-suggestions files
## Workflow
### 1. Get the data into CSV
If the file is XLSX, have the user export to CSV (or use `openpyxl` if installed). CSV is easier and faster to process. The user may also provide XHTML — CSV is preferred.
### 2. Read the full file
Use `read_file` with offsets to get the complete CSV into context. Don't sample — issues repeat across rows and you need full coverage.
### 3. Write a systematic analysis script
Write a Python script to `/tmp/` and run it with `terminal: python3 /tmp/script.py`. Do NOT use heredocs (`<<'PYEOF'`) or `-c` — the terminal tool may block these. Always write to a temp file.
The script should:
- Parse the CSV with `csv.DictReader`
- Apply detection rules (regex-based) for each known issue category
- Collect issues with: CSV row number, page context, CN text, EN text, problem description, suggested fix
- Group/deduplicate identical issues across rows
Common detection categories for Chinese→English:
- **Buddhist terminology**: 正念→mindfulness (not "righteous thoughts"), 布施→generosity (not "alms"), 胜解→resolute conviction, etc.
- **Identity terms**: 学士/修士/胜士/智士 are practice stages, not "bachelor/monk/winner/wise man"
- **Literal machine translations**: "Is we"→"if we", "hard drive" for 硬盘 (endurance), "Walk without letting go" for 行舍不放逸
- **四摄法 terms**: 同事→"acting in harmony" (not "colleagues"), 爱语→"kind speech" (not "love words")
- **Grammar**: subject-verb agreement, "have it been"→"has it been", unbalanced quotes
- **Typos/formatting**: "AndroidAndroid", "IOS"→"iOS", unbalanced HTML tags, Chinese punctuation in English
- **UI terminology**: "Suspended"→"Paused" for media, product name consistency
- **Inconsistency**: same CN term translated differently across rows (e.g., "Bodhi Navigator" vs "Bodhi Navigation")
### 4. Deduplicate into unique issue categories
The same error pattern often repeats across many rows (e.g., "subversion" for 覆 appears in 6+ rows). Group these into single entries in the .dj file — one entry per unique problem, with a list of affected rows.
### 5. Write edit-suggestions.dj
Format:
```
# 1
original: <Chinese text or key term>
translated: <current English>
<Explanation of the problem and suggested fix.>
# 2
...
```
Each entry gets a `# N` header, `original:` and `translated:` fields, then a free-text explanation. End with suggested replacement text. Mention affected row numbers. For globally-wrong terms, note "Change globally."
Do NOT write one entry per CSV row — group by problem type.
### 6. Sanity check
Run a quick second pass to catch: empty English fields, Chinese characters leaking into English column, untranslated rows (CN == EN), trailing whitespace.
## Pitfalls
- **Don't use heredocs or `-c` for multi-line Python** — write to `/tmp/script.py` first, then `python3 /tmp/script.py`. The terminal tool may block heredocs as long-lived processes.
- **Deduplicate aggressively** — 80+ raw issues may collapse to 20-25 unique categories. Writing one .dj entry per CSV row is useless noise.
- **Buddhist terminology is technical** — don't guess. 正念 is mindfulness (sati), not "righteous thoughts." 唯识 is Yogācāra/Consciousness-Only, not "knowledge and view alone." When uncertain, flag for human review rather than confidently suggesting wrong fixes.
- **Don't delete the comparison/对照 file** — translation projects keep these as intentional work artifacts.
## .dj file format reference
```
# N
original: <source text>
translated: <current translation>
<Free-text explanation and suggestion. Can be multiple paragraphs.>
# N+1
...
```
Entries may end with `{% TK %}` to mark "to check" items. The file lives alongside the source CSV/XLSX in the same directory.
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Reference: reusable detection rules for Chinese→English translation review.
Adapt the rules list for each project's domain vocabulary."""
import csv, re, sys
def review_csv(csv_path):
rows = []
with open(csv_path) as f:
reader = csv.DictReader(f)
for r in reader:
rows.append((r.get('page', '') or '', r['Chinese'] or '', r['English'] or ''))
issues = []
def add(row_idx, cn, en, problem, suggestion):
issues.append({
'row': row_idx + 2,
'page': rows[row_idx][0],
'cn': cn, 'en': en,
'problem': problem,
'suggestion': suggestion
})
for i, (page, cn, en) in enumerate(rows):
if not cn or not en:
continue
# ── Add project-specific detection rules below ──
# Example: "Is we" → machine translation artifact
if re.search(r'\bIs we\b', en):
add(i, cn, en,
"'Is we' — literal MT of 是否/如果. Should be 'if we' or 'whether we'",
re.sub(r'\bIs we\b', 'if we', en))
# Example: Chinese punctuation in English text
if re.search(r'[,。;:!?、]', en):
add(i, cn, en,
"Chinese punctuation in English text",
"[Replace with English punctuation]")
# Example: unbalanced HTML tags
if en.count('<b>') != en.count('</b>'):
add(i, cn, en,
f"Unbalanced <b> tags (open={en.count('<b>')}, close={en.count('</b>')})",
"[Balance tags]")
# Example: unbalanced double quotes
if en.count('"') % 2 != 0:
add(i, cn, en,
f"Unbalanced quotes ({en.count(chr(34))} total)",
"[Balance quotation marks]")
# Example: term inconsistency check
# if re.search(r'TermA', en) and re.search(r'TermB', en) and ...
# ── Sanity checks ──
for i, (page, cn, en) in enumerate(rows):
if cn and not en:
print(f"WARNING row {i+2}: CN present but EN empty: {cn[:80]}")
cn_chars = re.findall(r'[\u4e00-\u9fff]', en)
if cn_chars:
print(f"WARNING row {i+2}: Chinese chars in EN: {cn_chars}")
if cn and en and cn.strip() == en.strip():
print(f"WARNING row {i+2}: CN==EN (untranslated): {cn[:60]}")
return issues
if __name__ == '__main__':
issues = review_csv(sys.argv[1])
print(f"Issues found: {len(issues)}")
for iss in issues:
print(f"\nCSV_ROW_{iss['row']} [{iss['page']}]")
print(f" CN: {iss['cn'][:120]}")
print(f" EN: {iss['en'][:120]}")
print(f" PROBLEM: {iss['problem']}")
print(f" SUGGEST: {iss['suggestion'][:150]}")