manage translation skills
This commit is contained in:
@@ -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": [
|
||||
"感恩(?!恩)",
|
||||
"愿我们",
|
||||
"更愿",
|
||||
"愿人们",
|
||||
"愿世界"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
Translation-related skills.
|
||||
|
||||
## For Hermes
|
||||
|
||||
For every skill here, place a symlink to it under ~/.hermes/skills/
|
||||
|
||||
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]}")
|
||||
Reference in New Issue
Block a user