PDF में पैराग्राफ, हेडिंग या टेबल जैसी कोई चीज़ नहीं होती। उसमें ग्लिफ़ होते हैं, जिनमें से हर एक पेज पर किसी x और y स्थिति पर खींचा गया है, और कुछ आयत और रेखाएँ। इसलिए PDF से Word में बदलने वाला हर कन्वर्टर असल में लेआउट विश्लेषण का एक छोटा प्रोग्राम है: ग्लिफ़ को लाइनों में समूहित करना, तय करना कि कौन-सी लाइनें साथ हैं, और नतीजे को स्टाइल वाले .docx ऑब्जेक्ट के रूप में लिखना। osFoundry डेस्कटॉप ऐप के PDF Editor में यह एक एक्सपोर्ट के रूप में है (All tools, Export a PDF, Microsoft Word)। एडिटर कंपाइल किए हुए मॉड्यूल के रूप में आता है, इसलिए उद्धृत करने के लिए सोर्स कोड नहीं है। नीचे वही काम PyMuPDF और python-docx के साथ लगभग 50 लाइनों के Python में दोबारा बनाया गया है, उसी एक पेज की सूचना पर चलाया गया है, और फिर ऐप ने उस PDF से जो .docx लिखा उससे मिलाया गया है।
माप Apple M1 पर Python 3.12, MuPDF 1.28.6 और python-docx 1.2.0 के साथ लिए गए। टेस्ट पेज एक काल्पनिक एक पेज की सूचना है (उदाहरण दस्तावेज़): कंपनी का नाम, एक शीर्षक, दो परिचय लाइनें, "Details" हेडिंग, लेबल और मान की पाँच पंक्तियाँ, और एक फ़ुटर लाइन।
फ़ाइल असल में क्या स्टोर करती है
पेज के ग्लिफ़ डंप करने पर दिखता है कि उसमें कितना कम है। शीर्षक के पहले अक्षर:
import pymupdf
page = pymupdf.open("Holiday party 2025.pdf")[0]
for b in page.get_text("rawdict")["blocks"]:
for line in b.get("lines", []):
for span in line["spans"]:
for ch in span["chars"]:
print(ch["c"], round(ch["origin"][0], 1), round(ch["origin"][1], 1), span["size"])
H 56.0 118.0 26.0
o 74.8 118.0 26.0
l 90.6 118.0 26.0
i 97.8 118.0 26.0
d 105.0 118.0 26.0
पूरा पेज 327 ग्लिफ़ का है, जिनमें 277 स्पेस नहीं हैं, और 16 लाइनों में बँटा है। कहीं नहीं लिखा कि "Holiday Party 2025" शीर्षक है। वह y = 118 पर 26 पॉइंट का टेक्स्ट है, और एकमात्र सुराग यही है। स्पेस का होना भी पक्का नहीं है। मैंने एक ऐसा PDF लिखा जिसमें हर शब्द x ऑफ़सेट पर अलग से रखा गया था, जैसा कई जनरेटर करते हैं, और फ़ाइल में एक भी स्पेस कैरेक्टर नहीं था। फिर भी page.get_text() ने Thank you all for a great year लौटाया, क्योंकि जब दो ग्लिफ़ के बीच का अंतर फ़ॉन्ट साइज़ के एक अंश से ज़्यादा होता है, तो MuPDF स्पेस जोड़ देता है।
सीधा तरीका, यानी page.get_text() और हर लाइन पर एक पैराग्राफ, इस पेज के लिए 16 पैराग्राफ देता है: लेबल "Date" और उसका मान दो पैराग्राफ बन जाते हैं, और दोनों परिचय लाइनें भी।
लाइनों को ढाँचे में समूहित करना
साफ़ पेज के लिए तीन संकेत काफ़ी हैं, और तीनों वे संख्याएँ हैं जो डंप में पहले से हैं।
फ़ॉन्ट साइज़ हेडिंग को बॉडी टेक्स्ट से अलग करता है। बॉडी का साइज़ पेज पर सबसे आम साइज़ है (यहाँ 12 pt: परिचय लाइनें और लेबल-मान की दस लाइनें), और जो भी साफ़ तौर पर बड़ा है वह हेडिंग है। साइज़ का अनुपात स्तर तय करता है।
बेसलाइन टेबल के सेल को टेक्स्ट से अलग करती है। समान y पर पर x में बड़े अंतर वाली दो टेक्स्ट लाइनें एक पंक्ति के दो सेल हैं।
लाइन की दूरी पैराग्राफ को लाइनों के ढेर से अलग करती है। समान साइज़ की लगातार दो लाइनें, जिनके बीच की दूरी फ़ॉन्ट साइज़ के दोगुने से कम है, एक पैराग्राफ हैं।
import collections, statistics, pymupdf, docx
def is_cjk(ch):
return " " <= ch <= "鿿" or "" <= ch <= ""
def join(prev, nxt):
# a line break is a space between Latin words and nothing between Japanese characters
return nxt if is_cjk(prev[-1]) or is_cjk(nxt[0]) else " " + nxt
def read_lines(page):
out = []
for b in page.get_text("dict")["blocks"]:
for ln in b.get("lines", []):
spans = [s for s in ln["spans"] if s["text"].strip()]
if spans:
out.append({"text": "".join(s["text"] for s in ln["spans"]).strip(),
"x": ln["bbox"][0], "y": ln["bbox"][1],
"size": round(spans[0]["size"], 1)})
return sorted(out, key=lambda l: (round(l["y"]), l["x"]))
def rebuild(pdf_path, out_path):
lines = read_lines(pymupdf.open(pdf_path)[0])
body = statistics.mode([l["size"] for l in lines])
rows = collections.defaultdict(list)
for l in lines:
rows[round(l["y"])].append(l)
doc, para, prev, table = docx.Document(), None, None, None
for y in sorted(rows):
cells = sorted(rows[y], key=lambda l: l["x"])
if len(cells) >= 2: # label and value on one baseline
if table is None:
table = doc.add_table(rows=0, cols=2)
r = table.add_row().cells
r[0].text, r[1].text = cells[0]["text"], cells[1]["text"]
para = prev = None
continue
l, table = cells[0], None
if l["size"] > body * 1.2: # clearly larger than body text
doc.add_heading(l["text"], level=1 if l["size"] >= body * 1.8 else 2)
para = None
elif para and l["y"] - prev["y"] < 2.0 * l["size"] and l["size"] == prev["size"]:
para.add_run(join(para.text, l["text"]))
else:
para = doc.add_paragraph(l["text"])
prev = l
doc.save(out_path)
अंग्रेज़ी सूचना पर चलाने पर यह देता है: कंपनी के नाम के लिए एक Normal पैराग्राफ, "Holiday Party 2025" के लिए Heading 1, दो परिचय लाइनों के लिए एक Normal पैराग्राफ, "Details" के लिए Heading 2, 5 पंक्तियों की टेबल, और फ़ुटर सामान्य पैराग्राफ के रूप में। उसी सूचना का जापानी संस्करण, जिसमें 15 लाइनों में 149 बिना-स्पेस ग्लिफ़ हैं, उसी रूप में निकलता है।
ऐप के एक्सपोर्ट से तुलना
मैंने उन्हीं दो PDF को ऐप से एक्सपोर्ट किया और .docx फ़ाइलें python-docx से वापस पढ़ीं। पैराग्राफ और स्टाइल मेरे बनाए हुए से लाइन-दर-लाइन मेल खाते हैं: वही टेक्स्ट, Normal, Heading 1 और Heading 2 का वही बँटवारा, दो परिचय लाइनों का वही एक पैराग्राफ, और टेबल की वही पाँच पंक्तियाँ।
| ग्लिफ़ (स्पेस छोड़कर) | लाइनें | समूहन के बाद पैराग्राफ | टेबल की पंक्तियाँ | एक्सपोर्ट का साइज़ | |
|---|---|---|---|---|---|
| अंग्रेज़ी सूचना | 277 | 16 | 5 (2 हेडिंग) | 5 | 2,737 बाइट (ऐप) |
| जापानी सूचना | 149 | 15 | 5 (2 हेडिंग) | 5 | 2,882 बाइट (ऐप) |
दोबारा बनाने में अंग्रेज़ी सूचना के लिए प्रति पेज औसत (मीडियन) 16.9 ms और जापानी के लिए 16.7 ms लगे (हर एक के 50 रन, .docx को डिस्क पर लिखना शामिल)।
जहाँ ये अनुमान टूट जाते हैं
मैंने तीन मामले आज़माए, जिनमें एक सीधा नियम ग़लत नतीजा देता है।
स्कैन किए गए पेज में कोई ग्लिफ़ ही नहीं होता। मैंने टेस्ट पेज को 150 dpi पर रास्टराइज़ किया और नए PDF में लपेट दिया: rawdict ने 0 ग्लिफ़ और 1 इमेज लौटाई। जब तक OCR इमेज को स्थिति वाले टेक्स्ट में नहीं बदल देता, ऊपर के नियमों के पास काम करने को कुछ नहीं है।
टेक्स्ट के दो कॉलम टेबल जैसे दिखते हैं। बॉडी टेक्स्ट के दो कॉलम वाला पेज, हर एक में चार लाइनें, rebuild से 4 पंक्ति और 2 कॉलम की टेबल और शून्य पैराग्राफ के रूप में निकला, क्योंकि "समान बेसलाइन, x में बड़ा अंतर" ठीक वही है जो टेबल की पंक्ति होती है। असली कन्वर्टर को किसी चीज़ को टेबल कहने से पहले कई पंक्तियों में रूलिंग लाइनें या एक जैसे कॉलम किनारे जाँचने पड़ते हैं।
लाइनें जोड़ते समय लिपि का पता होना चाहिए। हर जोड़ी गई लाइन के बीच स्पेस रखने पर, PDF में टूटा हुआ जापानी वाक्य बीच में स्पेस पा लेगा। ऊपर का join फ़ंक्शन तब स्पेस छोड़ देता है जब लाइन टूटने के किसी भी ओर जापानी अक्षर हो। मेरे टेस्ट में दो लाइन का जापानी पैराग्राफ एक अटूट वाक्य के रूप में निकला।
दोबारा बनी फ़ाइल किस काम की है
दोबारा बनाने का मकसद फ़ाइल फ़ॉर्मेट नहीं है। असली Heading 1 और Heading 2 स्टाइल और असली टेबल के साथ दस्तावेज़ खोजा जा सकता है, उसकी स्टाइल बदली जा सकती है और उसे दोबारा फ़्लो कराया जा सकता है, और किसी साल को खोजकर बदलने से हर जगह वह बदल जाता है। स्थिति के हिसाब से रखा गया टेक्स्ट इनमें से कुछ नहीं देता; PDF से कॉपी करने पर लाइनों का एक ढेर मिलता है जिसे हाथ से दोबारा फ़ॉर्मेट करना पड़ता है। ऊपर की सूचना जैसा पेज इतना छोटा भी है कि नतीजा वापस पढ़कर जाँचा जा सके, जिसके लिए python-docx की कुछ ही लाइनें चाहिए:
d = docx.Document("Holiday party 2025.docx")
print([(p.style.name, p.text) for p in d.paragraphs if p.text.strip()])
print([[c.text for c in r.cells] for r in d.tables[0].rows])
यह एक्सपोर्ट osFoundry डेस्कटॉप ऐप के PDF Editor में है, जो डाउनलोड पेज से मुफ़्त मिलता है और कंप्यूटर पर ही चलता है, उसी local-first तरीके के अनुरूप जिस पर ऐप बना है।

Top comments (0)