pdf2docx-healer 0.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pdf2docx_healer-0.1.2/PKG-INFO +49 -0
- pdf2docx_healer-0.1.2/README.md +20 -0
- pdf2docx_healer-0.1.2/pyproject.toml +45 -0
- pdf2docx_healer-0.1.2/setup.cfg +4 -0
- pdf2docx_healer-0.1.2/src/docx_healer/__init__.py +6 -0
- pdf2docx_healer-0.1.2/src/docx_healer/__main__.py +6 -0
- pdf2docx_healer-0.1.2/src/docx_healer/cli.py +60 -0
- pdf2docx_healer-0.1.2/src/docx_healer/constants.py +51 -0
- pdf2docx_healer-0.1.2/src/docx_healer/converter.py +123 -0
- pdf2docx_healer-0.1.2/src/docx_healer/font_mapper.py +88 -0
- pdf2docx_healer-0.1.2/src/docx_healer/link_injector.py +165 -0
- pdf2docx_healer-0.1.2/src/docx_healer/list_detector.py +812 -0
- pdf2docx_healer-0.1.2/src/docx_healer/numbering.py +211 -0
- pdf2docx_healer-0.1.2/src/docx_healer/ocr_pipeline.py +142 -0
- pdf2docx_healer-0.1.2/src/docx_healer/patches.py +100 -0
- pdf2docx_healer-0.1.2/src/docx_healer/post_processor.py +50 -0
- pdf2docx_healer-0.1.2/src/pdf2docx_healer.egg-info/PKG-INFO +49 -0
- pdf2docx_healer-0.1.2/src/pdf2docx_healer.egg-info/SOURCES.txt +20 -0
- pdf2docx_healer-0.1.2/src/pdf2docx_healer.egg-info/dependency_links.txt +1 -0
- pdf2docx_healer-0.1.2/src/pdf2docx_healer.egg-info/entry_points.txt +2 -0
- pdf2docx_healer-0.1.2/src/pdf2docx_healer.egg-info/requires.txt +7 -0
- pdf2docx_healer-0.1.2/src/pdf2docx_healer.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pdf2docx-healer
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Formatting-preserving PDF-to-DOCX converter that fixes bullet lists, hyperlinks, CJK fonts, and scanned PDFs
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: homepage, https://github.com/krockxz/pdf2docx-healer
|
|
7
|
+
Project-URL: repository, https://github.com/krockxz/pdf2docx-healer
|
|
8
|
+
Project-URL: issues, https://github.com/krockxz/pdf2docx-healer/issues
|
|
9
|
+
Project-URL: changelog, https://github.com/krockxz/pdf2docx-healer/releases
|
|
10
|
+
Keywords: pdf,docx,converter,pdf-to-docx,formatting,hyperlink,ocr,cjk,bullet-list
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Text Processing
|
|
20
|
+
Classifier: Topic :: Office/Business :: Office Suites
|
|
21
|
+
Requires-Python: >=3.8
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: pdf2docx>=0.5.0
|
|
24
|
+
Requires-Dist: PyMuPDF>=1.23.0
|
|
25
|
+
Requires-Dist: python-docx>=0.8.11
|
|
26
|
+
Requires-Dist: lxml
|
|
27
|
+
Provides-Extra: ocr
|
|
28
|
+
Requires-Dist: pytesseract>=0.3.10; extra == "ocr"
|
|
29
|
+
|
|
30
|
+
# pdf2docx-healer
|
|
31
|
+
|
|
32
|
+
Formatting-preserving PDF-to-DOCX converter that fixes:
|
|
33
|
+
|
|
34
|
+
- Bullet and numbered lists (proper Word list styles)
|
|
35
|
+
- Hyperlinks (extracted from PDF annotations)
|
|
36
|
+
- CJK and unavailable font fallback
|
|
37
|
+
- Scanned/image-based PDFs (via OCR)
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from docx_healer import heal
|
|
43
|
+
|
|
44
|
+
heal("input.pdf", "output.docx")
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pdf2docx-heal input.pdf -o output.docx
|
|
49
|
+
```
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# pdf2docx-healer
|
|
2
|
+
|
|
3
|
+
Formatting-preserving PDF-to-DOCX converter that fixes:
|
|
4
|
+
|
|
5
|
+
- Bullet and numbered lists (proper Word list styles)
|
|
6
|
+
- Hyperlinks (extracted from PDF annotations)
|
|
7
|
+
- CJK and unavailable font fallback
|
|
8
|
+
- Scanned/image-based PDFs (via OCR)
|
|
9
|
+
|
|
10
|
+
## Usage
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from docx_healer import heal
|
|
14
|
+
|
|
15
|
+
heal("input.pdf", "output.docx")
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pdf2docx-heal input.pdf -o output.docx
|
|
20
|
+
```
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pdf2docx-healer"
|
|
7
|
+
version = "0.1.2"
|
|
8
|
+
description = "Formatting-preserving PDF-to-DOCX converter that fixes bullet lists, hyperlinks, CJK fonts, and scanned PDFs"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.8"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"pdf2docx>=0.5.0",
|
|
14
|
+
"PyMuPDF>=1.23.0",
|
|
15
|
+
"python-docx>=0.8.11",
|
|
16
|
+
"lxml",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 3 - Alpha",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.8",
|
|
23
|
+
"Programming Language :: Python :: 3.9",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Topic :: Text Processing",
|
|
28
|
+
"Topic :: Office/Business :: Office Suites",
|
|
29
|
+
]
|
|
30
|
+
keywords = ["pdf", "docx", "converter", "pdf-to-docx", "formatting", "hyperlink", "ocr", "cjk", "bullet-list"]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
ocr = ["pytesseract>=0.3.10"]
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
pdf2docx-heal = "docx_healer.cli:main"
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
homepage = "https://github.com/krockxz/pdf2docx-healer"
|
|
40
|
+
repository = "https://github.com/krockxz/pdf2docx-healer"
|
|
41
|
+
issues = "https://github.com/krockxz/pdf2docx-healer/issues"
|
|
42
|
+
changelog = "https://github.com/krockxz/pdf2docx-healer/releases"
|
|
43
|
+
|
|
44
|
+
[tool.setuptools.packages.find]
|
|
45
|
+
where = ["src"]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Command-line interface for pdf2docx-healer."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from .converter import heal, HealerConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main():
|
|
10
|
+
parser = argparse.ArgumentParser(
|
|
11
|
+
prog="pdf2docx-heal",
|
|
12
|
+
description="Convert PDF to DOCX preserving bullet lists, hyperlinks, and CJK fonts.",
|
|
13
|
+
)
|
|
14
|
+
parser.add_argument("pdf", type=Path, help="Input PDF file path")
|
|
15
|
+
parser.add_argument("-o", "--output", type=Path, default=None,
|
|
16
|
+
help="Output DOCX file path (default: input filename with .docx extension)")
|
|
17
|
+
parser.add_argument("--ocr", action="store_true",
|
|
18
|
+
help="Enable OCR for scanned/image-based PDFs")
|
|
19
|
+
parser.add_argument("--ocr-lang", default="eng",
|
|
20
|
+
help="Tesseract language code for OCR (default: eng)")
|
|
21
|
+
parser.add_argument("--ocr-dpi", type=int, default=300,
|
|
22
|
+
help="OCR resolution in DPI (default: 300)")
|
|
23
|
+
parser.add_argument("--ocr-threshold", type=float, default=0.3,
|
|
24
|
+
help="Fraction of textless pages to trigger OCR (default: 0.3)")
|
|
25
|
+
parser.add_argument("--no-lists", action="store_true",
|
|
26
|
+
help="Skip bullet/numbered list detection and formatting")
|
|
27
|
+
parser.add_argument("--no-hyperlinks", action="store_true",
|
|
28
|
+
help="Skip hyperlink extraction")
|
|
29
|
+
parser.add_argument("--no-font-fix", action="store_true",
|
|
30
|
+
help="Skip CJK font fallback mapping")
|
|
31
|
+
parser.add_argument("--aggressive", action="store_true",
|
|
32
|
+
help="Use aggressive paragraph splitting for list detection")
|
|
33
|
+
parser.add_argument("-q", "--quiet", action="store_true",
|
|
34
|
+
help="Suppress progress output")
|
|
35
|
+
|
|
36
|
+
args = parser.parse_args()
|
|
37
|
+
|
|
38
|
+
if not args.pdf.exists():
|
|
39
|
+
print(f"Error: PDF file not found: {args.pdf}", file=sys.stderr)
|
|
40
|
+
sys.exit(1)
|
|
41
|
+
|
|
42
|
+
output = args.output or args.pdf.with_suffix(".docx")
|
|
43
|
+
|
|
44
|
+
config = HealerConfig(
|
|
45
|
+
ocr_enabled=args.ocr,
|
|
46
|
+
ocr_lang=args.ocr_lang,
|
|
47
|
+
ocr_dpi=args.ocr_dpi,
|
|
48
|
+
ocr_threshold=args.ocr_threshold,
|
|
49
|
+
fix_lists=not args.no_lists,
|
|
50
|
+
fix_hyperlinks=not args.no_hyperlinks,
|
|
51
|
+
fix_fonts=not args.no_font_fix,
|
|
52
|
+
aggressive_lists=args.aggressive,
|
|
53
|
+
verbose=not args.quiet,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
heal(str(args.pdf), str(output), config=config)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
main()
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Shared constants for docx_healer."""
|
|
2
|
+
|
|
3
|
+
# Unicode bullet codepoints
|
|
4
|
+
BULLET_CODEPOINTS = frozenset({
|
|
5
|
+
0x2022, 0x2023, 0x2043, 0x204C, 0x204D, 0x2219,
|
|
6
|
+
0x25AA, 0x25CB, 0x25CF, 0x25D8, 0x25E6, 0x2619,
|
|
7
|
+
0x2765, 0x2767, 0x29BE, 0x29BF, 0xF0B7,
|
|
8
|
+
0x2013, 0x2014, 0x00B7, 0x2713, 0x2714,
|
|
9
|
+
0x2610, 0x2611, 0x2612,
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
# Font fallback chain: PyMuPDF name -> [Windows preferred, macOS, Linux, universal]
|
|
13
|
+
FONT_FALLBACK_MAP = {
|
|
14
|
+
'SimSun': ['SimSun', '\u5b8b\u4f53', 'Microsoft YaHei', '\u5fae\u8f6f\u96c5\u9ed1', 'Arial Unicode MS', 'Noto Sans CJK SC'],
|
|
15
|
+
'SimHei': ['SimHei', '\u9ed1\u4f53', 'Microsoft YaHei', '\u5fae\u8f6f\u96c5\u9ed1', 'Arial Unicode MS', 'Noto Sans CJK SC'],
|
|
16
|
+
'KaiTi': ['KaiTi', '\u6977\u4f53', 'SimSun', 'Microsoft YaHei', 'Noto Sans CJK SC'],
|
|
17
|
+
'FangSong': ['FangSong', '\u4eff\u5b8b', 'SimSun', 'Noto Serif CJK SC'],
|
|
18
|
+
'FangSong_GB2312': ['FangSong', '\u4eff\u5b8b', 'SimSun', 'Noto Serif CJK SC'],
|
|
19
|
+
'MS-Mincho': ['MS Mincho', 'Yu Mincho', 'Meiryo', 'Noto Serif CJK JP'],
|
|
20
|
+
'MS-Gothic': ['MS Gothic', 'Yu Gothic', 'Meiryo', 'Noto Sans CJK JP'],
|
|
21
|
+
'MS-PGothic': ['MS PGothic', 'Yu Gothic', 'Meiryo', 'Noto Sans CJK JP'],
|
|
22
|
+
'HYGoThic-Medium': ['Apple SD Gothic Neo', 'Malgun Gothic', 'Gulim', 'Noto Sans CJK KR'],
|
|
23
|
+
'HYSMyeongJo-Medium': ['AppleMyungjo', 'Batang', 'Noto Serif CJK KR'],
|
|
24
|
+
'AppleMyungjo': ['Apple SD Gothic Neo', 'Batang', 'Noto Serif CJK KR'],
|
|
25
|
+
'TimesNewRomanPSMT': ['Times New Roman', 'Times', 'Liberation Serif'],
|
|
26
|
+
'TimesNewRomanPS-BoldMT': ['Times New Roman', 'Times', 'Liberation Serif'],
|
|
27
|
+
'TimesNewRomanPS-ItalicMT': ['Times New Roman', 'Times', 'Liberation Serif'],
|
|
28
|
+
'ArialMT': ['Arial', 'Helvetica', 'Liberation Sans'],
|
|
29
|
+
'Arial-BoldMT': ['Arial', 'Helvetica', 'Liberation Sans'],
|
|
30
|
+
'CourierNewPSMT': ['Courier New', 'Consolas', 'Liberation Mono'],
|
|
31
|
+
'Helvetica': ['Arial', 'Helvetica', 'Liberation Sans'],
|
|
32
|
+
'Helvetica-Bold': ['Arial', 'Helvetica', 'Liberation Sans'],
|
|
33
|
+
'Symbol': ['Symbol', 'Segoe UI Symbol'],
|
|
34
|
+
'ZapfDingbats': ['Segoe UI Symbol', 'Symbol'],
|
|
35
|
+
'UNNAMED': ['Arial', 'Helvetica', 'Liberation Sans'],
|
|
36
|
+
'helv': ['Arial', 'Helvetica', 'Liberation Sans'],
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
CHAR_RANGE_FONT_MAP = [
|
|
40
|
+
((0x4E00, 0x9FFF), 'SimSun'),
|
|
41
|
+
((0x3400, 0x4DBF), 'SimSun'),
|
|
42
|
+
((0x20000, 0x2A6DF), 'SimSun'),
|
|
43
|
+
((0x3040, 0x309F), 'Meiryo'),
|
|
44
|
+
((0x30A0, 0x30FF), 'Meiryo'),
|
|
45
|
+
((0xAC00, 0xD7AF), 'Malgun Gothic'),
|
|
46
|
+
((0x0600, 0x06FF), 'Arabic Typesetting'),
|
|
47
|
+
((0x0590, 0x05FF), 'David'),
|
|
48
|
+
((0x0E00, 0x0E7F), 'Leelawadee UI'),
|
|
49
|
+
((0x0900, 0x097F), 'Mangal'),
|
|
50
|
+
((0x0400, 0x04FF), 'Times New Roman'),
|
|
51
|
+
]
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Main ConverterHealer — orchestrates the 4-phase pipeline."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import json
|
|
5
|
+
import tempfile
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class HealerConfig:
|
|
11
|
+
"""Configuration for the pdf2docx-healer pipeline."""
|
|
12
|
+
# OCR settings
|
|
13
|
+
ocr_enabled: bool = False
|
|
14
|
+
ocr_lang: str = 'eng'
|
|
15
|
+
ocr_dpi: int = 300
|
|
16
|
+
ocr_threshold: float = 0.3
|
|
17
|
+
|
|
18
|
+
# Feature toggles
|
|
19
|
+
fix_lists: bool = True
|
|
20
|
+
fix_hyperlinks: bool = True
|
|
21
|
+
fix_fonts: bool = True
|
|
22
|
+
|
|
23
|
+
# List detection
|
|
24
|
+
aggressive_lists: bool = False
|
|
25
|
+
|
|
26
|
+
# Output
|
|
27
|
+
verbose: bool = True
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def heal(pdf_path, docx_path=None, config=None):
|
|
31
|
+
"""Convert PDF to DOCX preserving formatting (bullets, hyperlinks, fonts).
|
|
32
|
+
|
|
33
|
+
This is the main entry point running the 4-phase pipeline:
|
|
34
|
+
1. Pre-parse: OCR scanned PDFs if needed
|
|
35
|
+
2. Intercept: Monkey-patch pdf2docx for list detection
|
|
36
|
+
3. Convert: Run pdf2docx with patched internals
|
|
37
|
+
4. Post-process: List styles, hyperlinks, font mapping on final DOCX
|
|
38
|
+
"""
|
|
39
|
+
if config is None:
|
|
40
|
+
config = HealerConfig()
|
|
41
|
+
|
|
42
|
+
if docx_path is None:
|
|
43
|
+
docx_path = os.path.splitext(pdf_path)[0] + '.docx'
|
|
44
|
+
|
|
45
|
+
verbose = config.verbose
|
|
46
|
+
|
|
47
|
+
# === Phase 1: Pre-parse — OCR detection ===
|
|
48
|
+
actual_pdf = pdf_path
|
|
49
|
+
tmp_pdf = None
|
|
50
|
+
|
|
51
|
+
if config.ocr_enabled:
|
|
52
|
+
from .ocr_pipeline import needs_ocr, ocr_pdf_file
|
|
53
|
+
import fitz
|
|
54
|
+
|
|
55
|
+
src_doc = fitz.open(pdf_path)
|
|
56
|
+
try:
|
|
57
|
+
if needs_ocr(src_doc, threshold=config.ocr_threshold):
|
|
58
|
+
src_doc.close()
|
|
59
|
+
tmp_pdf = ocr_pdf_file(
|
|
60
|
+
pdf_path,
|
|
61
|
+
language=config.ocr_lang,
|
|
62
|
+
dpi=config.ocr_dpi,
|
|
63
|
+
verbose=verbose
|
|
64
|
+
)
|
|
65
|
+
actual_pdf = tmp_pdf
|
|
66
|
+
else:
|
|
67
|
+
src_doc.close()
|
|
68
|
+
except Exception as e:
|
|
69
|
+
if verbose:
|
|
70
|
+
print(f"[healer] OCR skipped: {e}")
|
|
71
|
+
try:
|
|
72
|
+
src_doc.close()
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
# === Phase 2: Intercept — Apply monkey-patches ===
|
|
78
|
+
if config.fix_lists:
|
|
79
|
+
from .patches import apply_patches
|
|
80
|
+
apply_patches(verbose=verbose)
|
|
81
|
+
|
|
82
|
+
# === Phase 3: Convert ===
|
|
83
|
+
_convert(actual_pdf, docx_path, verbose=verbose)
|
|
84
|
+
|
|
85
|
+
# === Phase 4: Post-process ===
|
|
86
|
+
from .post_processor import post_process_docx
|
|
87
|
+
post_process_docx(
|
|
88
|
+
docx_path,
|
|
89
|
+
fix_lists=config.fix_lists,
|
|
90
|
+
fix_hyperlinks=config.fix_hyperlinks,
|
|
91
|
+
fix_fonts=config.fix_fonts,
|
|
92
|
+
aggressive_lists=config.aggressive_lists,
|
|
93
|
+
verbose=verbose,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if verbose:
|
|
97
|
+
print(f"[healer] Done: {docx_path}")
|
|
98
|
+
|
|
99
|
+
return docx_path
|
|
100
|
+
|
|
101
|
+
finally:
|
|
102
|
+
if tmp_pdf and os.path.exists(tmp_pdf):
|
|
103
|
+
try:
|
|
104
|
+
os.unlink(tmp_pdf)
|
|
105
|
+
except OSError:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _convert(pdf_path, docx_path, verbose=False):
|
|
110
|
+
"""Run pdf2docx conversion with default settings."""
|
|
111
|
+
if verbose:
|
|
112
|
+
print("[healer] Converting...")
|
|
113
|
+
|
|
114
|
+
from pdf2docx import Converter
|
|
115
|
+
if not verbose:
|
|
116
|
+
import logging
|
|
117
|
+
logging.getLogger().setLevel(logging.WARNING)
|
|
118
|
+
|
|
119
|
+
cv = Converter(pdf_path)
|
|
120
|
+
try:
|
|
121
|
+
cv.convert(docx_path)
|
|
122
|
+
finally:
|
|
123
|
+
cv.close()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Font fallback mapping for CJK and unavailable fonts."""
|
|
2
|
+
|
|
3
|
+
from .constants import FONT_FALLBACK_MAP, CHAR_RANGE_FONT_MAP
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def normalize_font_name(original_name, text_sample=''):
|
|
7
|
+
"""Resolve a PyMuPDF/PDF font name to one commonly available on the target system.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
original_name: Font name from pdf2docx span (e.g., 'SimSun', 'TimesNewRomanPSMT').
|
|
11
|
+
text_sample: Sample characters to use for character-based fallback.
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
A resolved font name string.
|
|
15
|
+
"""
|
|
16
|
+
if not original_name:
|
|
17
|
+
return 'Arial'
|
|
18
|
+
|
|
19
|
+
name = original_name.strip()
|
|
20
|
+
|
|
21
|
+
# Exact match in fallback map
|
|
22
|
+
if name in FONT_FALLBACK_MAP:
|
|
23
|
+
return FONT_FALLBACK_MAP[name][0]
|
|
24
|
+
|
|
25
|
+
# Check normalized name (strip prefix like 'BAAAAA+' from subset fonts)
|
|
26
|
+
if '+' in name:
|
|
27
|
+
base_name = name.split('+', 1)[-1]
|
|
28
|
+
else:
|
|
29
|
+
base_name = name
|
|
30
|
+
|
|
31
|
+
if base_name in FONT_FALLBACK_MAP:
|
|
32
|
+
return FONT_FALLBACK_MAP[base_name][0]
|
|
33
|
+
|
|
34
|
+
# Case-insensitive lookup
|
|
35
|
+
name_upper = base_name.upper()
|
|
36
|
+
for key, fallback in FONT_FALLBACK_MAP.items():
|
|
37
|
+
if key.upper() == name_upper:
|
|
38
|
+
return fallback[0]
|
|
39
|
+
|
|
40
|
+
# Character-based fallback (check text content)
|
|
41
|
+
if text_sample:
|
|
42
|
+
for (lo, hi), fallback_font in CHAR_RANGE_FONT_MAP:
|
|
43
|
+
if any(lo <= ord(c) <= hi for c in text_sample):
|
|
44
|
+
# Look up the range-matched font in our fallback map
|
|
45
|
+
if fallback_font in FONT_FALLBACK_MAP:
|
|
46
|
+
return FONT_FALLBACK_MAP[fallback_font][0]
|
|
47
|
+
return fallback_font
|
|
48
|
+
|
|
49
|
+
# Ultimate fallback
|
|
50
|
+
return 'Arial'
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def fix_docx_fonts(docx_path, verbose=False):
|
|
54
|
+
"""Open a DOCX and fix font names using the fallback map.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
docx_path: Path to the DOCX file to fix in-place.
|
|
58
|
+
verbose: Print progress information.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Number of font names fixed.
|
|
62
|
+
"""
|
|
63
|
+
from docx import Document
|
|
64
|
+
|
|
65
|
+
doc = Document(docx_path)
|
|
66
|
+
fixed = 0
|
|
67
|
+
|
|
68
|
+
for paragraph in doc.paragraphs:
|
|
69
|
+
try:
|
|
70
|
+
for run in paragraph.runs:
|
|
71
|
+
original = run.font.name
|
|
72
|
+
if original:
|
|
73
|
+
normalized = normalize_font_name(original, run.text)
|
|
74
|
+
if normalized != original:
|
|
75
|
+
run.font.name = normalized
|
|
76
|
+
fixed += 1
|
|
77
|
+
except Exception:
|
|
78
|
+
if verbose:
|
|
79
|
+
import traceback
|
|
80
|
+
traceback.print_exc()
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
if fixed > 0:
|
|
84
|
+
doc.save(docx_path)
|
|
85
|
+
if verbose:
|
|
86
|
+
print(f"[healer] Fixed {fixed} font name(s) in {docx_path}")
|
|
87
|
+
|
|
88
|
+
return fixed
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Hyperlink injection via python-docx post-processing (not store/restore)."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from docx.oxml.ns import qn
|
|
5
|
+
from docx.oxml import OxmlElement
|
|
6
|
+
from docx.opc.constants import RELATIONSHIP_TYPE as RT
|
|
7
|
+
from lxml import etree
|
|
8
|
+
|
|
9
|
+
# URL pattern
|
|
10
|
+
_URL_PATTERN = re.compile(
|
|
11
|
+
r'https?://[^\s<>"\')\]]+|'
|
|
12
|
+
r'ftp://[^\s<>"\')\]]+|'
|
|
13
|
+
r'www\.[^\s<>"\')\]]+\.[a-zA-Z]{2,}[^\s<>"\')\]]*|'
|
|
14
|
+
r'mailto:[^\s<>"\')\]]+',
|
|
15
|
+
re.IGNORECASE
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def fix_docx_hyperlinks(docx_path, verbose=False):
|
|
20
|
+
"""Post-process: find URL text in runs and wrap them in OOXML hyperlinks.
|
|
21
|
+
|
|
22
|
+
Opens the DOCX, scans all runs for URLs, and replaces plain-text URLs
|
|
23
|
+
with clickable hyperlinks in-place.
|
|
24
|
+
|
|
25
|
+
Returns number of hyperlinks added.
|
|
26
|
+
"""
|
|
27
|
+
from docx import Document
|
|
28
|
+
doc = Document(docx_path)
|
|
29
|
+
count = 0
|
|
30
|
+
|
|
31
|
+
for paragraph in doc.paragraphs:
|
|
32
|
+
count += _hyperlink_urls_in_paragraph(paragraph)
|
|
33
|
+
|
|
34
|
+
if count > 0:
|
|
35
|
+
doc.save(docx_path)
|
|
36
|
+
if verbose:
|
|
37
|
+
print(f"[healer] Added {count} hyperlink(s) in {docx_path}")
|
|
38
|
+
|
|
39
|
+
return count
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _hyperlink_urls_in_paragraph(paragraph):
|
|
43
|
+
"""Find URLs in paragraph runs and convert them to hyperlinks.
|
|
44
|
+
|
|
45
|
+
Strategy: for each run containing URL(s), split the run into
|
|
46
|
+
text-before / hyperlink / text-after segments, processing ALL URLs
|
|
47
|
+
in the run (not just the first).
|
|
48
|
+
|
|
49
|
+
NOTE: This function modifies the paragraph XML tree by inserting and
|
|
50
|
+
removing elements. python-docx's in-memory ``paragraph.runs`` view
|
|
51
|
+
is not refreshed after these changes, so the caller must not rely on
|
|
52
|
+
it being up-to-date. Because we iterate over a snapshot (``list()``)
|
|
53
|
+
and save the document immediately afterwards, this is safe.
|
|
54
|
+
"""
|
|
55
|
+
count = 0
|
|
56
|
+
runs = list(paragraph.runs)
|
|
57
|
+
|
|
58
|
+
for run in runs:
|
|
59
|
+
if not run.text:
|
|
60
|
+
continue
|
|
61
|
+
urls = _URL_PATTERN.findall(run.text)
|
|
62
|
+
if not urls:
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
# Check if run is already inside a hyperlink
|
|
66
|
+
if run._element.getparent() is not None:
|
|
67
|
+
parent_tag = run._element.getparent().tag
|
|
68
|
+
if parent_tag == qn('w:hyperlink'):
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
run_elem = run._element
|
|
72
|
+
parent = run_elem.getparent()
|
|
73
|
+
orig_rPr = run_elem.find(qn('w:rPr'))
|
|
74
|
+
insert_pos = parent.index(run_elem)
|
|
75
|
+
parent.remove(run_elem)
|
|
76
|
+
|
|
77
|
+
remaining_text = run.text
|
|
78
|
+
|
|
79
|
+
for url_match in urls:
|
|
80
|
+
original_url = url_match
|
|
81
|
+
target_url = url_match
|
|
82
|
+
if '://' not in target_url and not target_url.startswith('mailto:'):
|
|
83
|
+
target_url = 'https://' + target_url
|
|
84
|
+
|
|
85
|
+
idx = remaining_text.find(original_url)
|
|
86
|
+
if idx < 0:
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
before = remaining_text[:idx]
|
|
90
|
+
after = remaining_text[idx + len(original_url):]
|
|
91
|
+
|
|
92
|
+
if before:
|
|
93
|
+
before_run = OxmlElement('w:r')
|
|
94
|
+
if orig_rPr is not None:
|
|
95
|
+
before_run.append(copy_rPr(orig_rPr))
|
|
96
|
+
before_run.text = before
|
|
97
|
+
parent.insert(insert_pos, before_run)
|
|
98
|
+
insert_pos += 1
|
|
99
|
+
|
|
100
|
+
hyperlink = _build_hyperlink(paragraph, target_url, run)
|
|
101
|
+
parent.insert(insert_pos, hyperlink)
|
|
102
|
+
hyperlink[0].text = original_url
|
|
103
|
+
insert_pos += 1
|
|
104
|
+
count += 1
|
|
105
|
+
|
|
106
|
+
remaining_text = after
|
|
107
|
+
|
|
108
|
+
if remaining_text:
|
|
109
|
+
after_run = OxmlElement('w:r')
|
|
110
|
+
if orig_rPr is not None:
|
|
111
|
+
after_run.append(copy_rPr(orig_rPr))
|
|
112
|
+
after_run.text = remaining_text
|
|
113
|
+
parent.insert(insert_pos, after_run)
|
|
114
|
+
|
|
115
|
+
return count
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _build_hyperlink(paragraph, url, source_run):
|
|
119
|
+
"""Build an OOXML w:hyperlink element referencing an external URL."""
|
|
120
|
+
part = paragraph.part
|
|
121
|
+
r_id = part.relate_to(url, RT.HYPERLINK, is_external=True)
|
|
122
|
+
|
|
123
|
+
hyperlink = OxmlElement('w:hyperlink')
|
|
124
|
+
hyperlink.set(qn('r:id'), r_id)
|
|
125
|
+
|
|
126
|
+
# Build the run inside the hyperlink, copying formatting
|
|
127
|
+
hl_run = OxmlElement('w:r')
|
|
128
|
+
rPr = source_run._element.find(qn('w:rPr'))
|
|
129
|
+
if rPr is not None:
|
|
130
|
+
hl_run.append(copy_rPr(rPr))
|
|
131
|
+
|
|
132
|
+
# Add hyperlink style (blue + underline)
|
|
133
|
+
_ensure_hyperlink_style(hl_run)
|
|
134
|
+
|
|
135
|
+
hyperlink.append(hl_run)
|
|
136
|
+
return hyperlink
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _ensure_hyperlink_style(run_elem):
|
|
140
|
+
"""Ensure the run inside a hyperlink has blue color and underline."""
|
|
141
|
+
rPr = run_elem.find(qn('w:rPr'))
|
|
142
|
+
if rPr is None:
|
|
143
|
+
rPr = OxmlElement('w:rPr')
|
|
144
|
+
run_elem.insert(0, rPr)
|
|
145
|
+
|
|
146
|
+
# Add underline if not present
|
|
147
|
+
if rPr.find(qn('w:u')) is None:
|
|
148
|
+
u = OxmlElement('w:u')
|
|
149
|
+
u.set(qn('w:val'), 'single')
|
|
150
|
+
rPr.append(u)
|
|
151
|
+
|
|
152
|
+
# Add blue color if not present
|
|
153
|
+
if rPr.find(qn('w:color')) is None:
|
|
154
|
+
color = OxmlElement('w:color')
|
|
155
|
+
color.set(qn('w:val'), '0563C1')
|
|
156
|
+
rPr.append(color)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def copy_rPr(source_rPr):
|
|
160
|
+
"""Deep copy a w:rPr element, with fallback if lxml serialisation fails."""
|
|
161
|
+
try:
|
|
162
|
+
return etree.fromstring(etree.tostring(source_rPr))
|
|
163
|
+
except Exception:
|
|
164
|
+
import copy
|
|
165
|
+
return copy.deepcopy(source_rPr)
|