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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ """pdf2docx-healer: Formatting-preserving PDF-to-DOCX converter."""
2
+
3
+ from .converter import heal, HealerConfig
4
+
5
+ __version__ = "0.1.2"
6
+ __all__ = ["heal", "HealerConfig"]
@@ -0,0 +1,6 @@
1
+ """Allow `python -m docx_healer` to invoke the CLI."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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)