Skip to content

Commit acc13bf

Browse files
committed
convert_pdf2qmd.py added
1 parent 2358aa2 commit acc13bf

1 file changed

Lines changed: 225 additions & 0 deletions

File tree

convert_pdf2qmd.py

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
#!/usr/bin/env python3
2+
"""
3+
convert_pdf2qmd.py
4+
5+
Uploads a PDF, parses it with ChatDOC API, downloads the parsed .qmd, and extracts inline base64 images into separate files
6+
7+
Usage:
8+
python convert_pdf2qmd.py input.pdf
9+
10+
Requirements:
11+
pip install pillow requests
12+
"""
13+
14+
import sys
15+
import re
16+
import os
17+
import base64
18+
import hashlib
19+
import requests
20+
import time
21+
from pathlib import Path
22+
from PIL import Image
23+
from io import BytesIO
24+
25+
26+
BASE_URL = "https://api.chatdoc.com/api/v2"
27+
PREFIX = "img"
28+
29+
30+
def _get_api_token():
31+
token = os.getenv("CHATDOC_API_KEY")
32+
if not token:
33+
raise RuntimeError("Environment variable CHATDOC_API_KEY is not set.")
34+
return token
35+
36+
37+
def upload_pdf_and_get_id(pdf_path):
38+
"""Upload PDF to ChatDOC API and return document ID"""
39+
pdf_path = Path(pdf_path).resolve() # Resolve to absolute path
40+
41+
if not pdf_path.exists():
42+
raise FileNotFoundError(f"PDF not found: {pdf_path}")
43+
44+
if not pdf_path.is_file():
45+
raise FileNotFoundError(f"Path is not a file: {pdf_path}")
46+
47+
token = _get_api_token()
48+
headers = {"Authorization": f"Bearer {token}"}
49+
50+
# Use string path for better Windows compatibility
51+
with open(str(pdf_path), "rb") as f:
52+
files = {"file": (pdf_path.name, f, "application/pdf")}
53+
resp = requests.post(
54+
f"{BASE_URL}/documents/upload", headers=headers, files=files
55+
)
56+
57+
if resp.status_code != 200:
58+
raise RuntimeError(f"Upload failed ({resp.status_code}): {resp.text}")
59+
60+
payload = resp.json()
61+
if payload.get("status") != "ok":
62+
raise RuntimeError(f"Upload error: {payload}")
63+
64+
upload_id = payload.get("data", {}).get("id")
65+
if not upload_id:
66+
raise RuntimeError(f"Upload response missing 'id': {payload}")
67+
68+
return upload_id
69+
70+
71+
def download_markdown_qmd(upload_id, output_qmd_path, max_wait_s=300, delay_s=15):
72+
token = _get_api_token()
73+
headers = {"Authorization": f"Bearer {token}"}
74+
75+
url = f"{BASE_URL}/pdf_parser/{upload_id}/markdown"
76+
start = time.monotonic()
77+
78+
while True:
79+
resp = requests.get(url, headers=headers)
80+
81+
if resp.status_code == 200:
82+
output_qmd_path = Path(output_qmd_path)
83+
output_qmd_path.parent.mkdir(parents=True, exist_ok=True)
84+
output_qmd_path.write_text(resp.text, encoding="utf-8")
85+
return output_qmd_path
86+
87+
if resp.status_code == 400:
88+
try:
89+
detail = resp.json().get("detail", "")
90+
except Exception:
91+
detail = ""
92+
if detail == "Document parsing not completed.":
93+
if time.monotonic() - start > max_wait_s:
94+
raise RuntimeError(
95+
"Timed out waiting for document parsing to complete."
96+
)
97+
time.sleep(delay_s)
98+
continue # try again
99+
100+
# Any other error is fatal
101+
raise RuntimeError(f"Download failed ({resp.status_code}): {resp.text}")
102+
103+
104+
def upload_pdf_and_download_qmd(pdf_path, force=False):
105+
"""
106+
If a .qmd with the same basename already exists (and is non-empty), skip upload/download.
107+
Set force=True to re-upload and overwrite the .qmd.
108+
"""
109+
qmd_path = Path(pdf_path).with_suffix(".qmd")
110+
111+
if (not force) and qmd_path.exists() and qmd_path.stat().st_size > 0:
112+
return qmd_path
113+
114+
upload_id = upload_pdf_and_get_id(pdf_path)
115+
print(f"Uploaded id: {upload_id}")
116+
return download_markdown_qmd(upload_id, qmd_path)
117+
118+
119+
def ensure_yaml_header(text):
120+
if text.lstrip().startswith("---"):
121+
# conservative: assume user already has a header
122+
return text
123+
header = f"""---
124+
title: "TITLE"
125+
subtitle: "SUBTITLE"
126+
date: "2025--14"
127+
version: 1.5
128+
129+
category: products
130+
131+
toc: true
132+
toc-title: "Content"
133+
toc-depth: 3
134+
135+
#### REMOVE THE SECTION BELOW BEFORE PUBLISHING
136+
format:
137+
html:
138+
css: ../../theme/styles.css
139+
code-fold: true
140+
self-contained: false
141+
embed-resources: true
142+
docx:
143+
toc-location: before-body
144+
toc-pagebreak: true
145+
data: false
146+
reference-doc: ../../_meta/theme/template-guideline.docx
147+
####
148+
---
149+
150+
"""
151+
return header + text
152+
153+
154+
def extract_images(qmd_path, outdir):
155+
qmd_path = Path(qmd_path)
156+
outdir = Path(outdir)
157+
outdir.mkdir(parents=True, exist_ok=True)
158+
159+
text = qmd_path.read_text(encoding="utf-8")
160+
161+
# Match data:image/<format>;base64,<data>
162+
pattern = re.compile(r"data:image/([a-zA-Z0-9\+\.-]+);base64,([A-Za-z0-9+/=]+)")
163+
164+
seen = {}
165+
count = 0
166+
167+
def replace_image(match):
168+
nonlocal count
169+
fmt = match.group(1).lower()
170+
b64data = match.group(2)
171+
binary = base64.b64decode(b64data)
172+
173+
# Create hash for deduplication
174+
h = hashlib.sha1(binary).hexdigest()
175+
filename = f"{PREFIX}-{h}.png"
176+
filepath = outdir / filename
177+
178+
if h not in seen:
179+
seen[h] = filename
180+
# Convert to PNG (even if original is JPEG/WebP)
181+
try:
182+
if fmt == "svg+xml":
183+
# Just save raw SVG without conversion
184+
filename = f"{PREFIX}-{h}.svg"
185+
filepath = outdir / filename
186+
filepath.write_bytes(binary)
187+
else:
188+
img = Image.open(BytesIO(binary))
189+
img.save(filepath, format="PNG")
190+
except Exception as e:
191+
print(f"[WARN] Failed to process {fmt}: {e}")
192+
return match.group(0) # leave as is if failed
193+
count += 1
194+
195+
# Always return Unix-style path (forward slashes)
196+
return str(filepath.relative_to(qmd_path.parent)).replace("\\", "/")
197+
198+
new_text = pattern.sub(replace_image, text)
199+
200+
# Ensure YAML header
201+
new_text = ensure_yaml_header(new_text)
202+
203+
# Backup original
204+
backup_path = qmd_path.with_suffix(qmd_path.suffix + ".bak")
205+
backup_path.write_text(text, encoding="utf-8")
206+
207+
# Write updated QMD
208+
qmd_path.write_text(new_text, encoding="utf-8")
209+
210+
print(f"✅ Extracted {count} unique images to '{outdir}'")
211+
print(f"💾 Backup saved as {backup_path}")
212+
213+
214+
if __name__ == "__main__":
215+
if len(sys.argv) < 2:
216+
print("Usage: convert_pdf2qmd.py input.pdf")
217+
sys.exit(1)
218+
219+
pdf_file = sys.argv[1]
220+
qmd_file = upload_pdf_and_download_qmd(pdf_file)
221+
222+
qmd_path = Path(qmd_file)
223+
outdir = f"{qmd_path.stem}-media"
224+
225+
extract_images(qmd_file, outdir)

0 commit comments

Comments
 (0)