Add 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
|
||||
```
|
||||
Reference in New Issue
Block a user