返回 ppt-master
pdf_to_md.py
1 #!/usr/bin/env python3
2 """
3 PDF to Markdown Converter
4 Uses PyMuPDF to extract PDF text content and convert to Markdown format.
5 Supports heading levels, bold, italic, and list detection.
6 """
7
8 import argparse
9 import hashlib
10 import json
11 import os
12 import re
13 import sys
14 from pathlib import Path
15 from collections import Counter
16
17 _SCRIPTS_DIR = Path(__file__).resolve().parents[1]
18 if str(_SCRIPTS_DIR) not in sys.path:
19 sys.path.insert(0, str(_SCRIPTS_DIR))
20
21 from console_encoding import configure_utf8_stdio # noqa: E402
22 from _batch import run_path_batch # noqa: E402
23 from _conversion_profile import write_conversion_profile_best_effort # noqa: E402
24
25 configure_utf8_stdio()
26
27 try:
28 import fitz # PyMuPDF
29 except ImportError:
30 print("[ERROR] PyMuPDF not installed. Run: pip install PyMuPDF", file=sys.stderr)
31 sys.exit(1)
32
33 FONT_BODY_SIZE = 12
34 FONT_H1_SIZE = 24
35 FONT_H2_SIZE = 18
36 FONT_H3_SIZE = 14
37 HEADER_FOOTER_SAMPLE_LIMIT = 40
38 HEADER_FOOTER_EDGE_SAMPLE_SIZE = 20
39 CONTROL_CHARS_RE = re.compile(r'[\x00-\x08\x0b\x0c\x0e-\x1f]')
40
41
42 def analyze_font_sizes(doc: fitz.Document) -> dict[str, float]:
43 """Analyze font size distribution to infer heading levels.
44
45 Args:
46 doc: Open PDF document.
47
48 Returns:
49 A size mapping containing body and inferred heading sizes.
50 """
51 size_counter = Counter()
52
53 for page in doc:
54 blocks = page.get_text("dict")["blocks"]
55 for block in blocks:
56 if block["type"] == 0:
57 for line in block["lines"]:
58 for span in line["spans"]:
59 size = round(span["size"], 1)
60 text = span["text"].strip()
61 if text:
62 size_counter[size] += len(text)
63
64 if not size_counter:
65 return {
66 "body": FONT_BODY_SIZE,
67 "h1": FONT_H1_SIZE,
68 "h2": FONT_H2_SIZE,
69 "h3": FONT_H3_SIZE,
70 }
71
72 sorted_sizes = sorted(size_counter.items(), key=lambda x: x[1], reverse=True)
73 body_size = sorted_sizes[0][0]
74
75 all_sizes = sorted(size_counter.keys(), reverse=True)
76 larger_sizes = [s for s in all_sizes if s > body_size + 1]
77
78 size_map = {"body": body_size}
79 if len(larger_sizes) >= 1:
80 size_map["h1"] = larger_sizes[0]
81 if len(larger_sizes) >= 2:
82 size_map["h2"] = larger_sizes[1]
83 if len(larger_sizes) >= 3:
84 size_map["h3"] = larger_sizes[2]
85
86 return size_map
87
88
89 def get_heading_level(size: float, size_map: dict, text: str = "",
90 flags: int = 0, strict: bool = True) -> int:
91 """
92 Determine heading level using multiple heuristics.
93
94 Args:
95 size: Font size
96 size_map: Font size mapping
97 text: Text content (used for additional heuristics)
98 flags: Font flags (bit 4 = bold)
99 strict: Strict mode, requires more conditions to be met
100
101 Returns:
102 Heading level (0 = body text, 1-3 = H1-H3)
103 """
104 # Initial determination based on font size
105 level = 0
106 if "h1" in size_map and size >= size_map["h1"] - 0.5:
107 level = 1
108 elif "h2" in size_map and size >= size_map["h2"] - 0.5:
109 level = 2
110 elif "h3" in size_map and size >= size_map["h3"] - 0.5:
111 level = 3
112
113 if level == 0:
114 return 0
115
116 # Non-strict mode returns directly (backward compatible)
117 if not strict or not text:
118 return level
119
120 # Strict mode: additional validation conditions
121 text = text.strip()
122
123 # Exclusion: text too long is unlikely to be a heading
124 if len(text) > 80:
125 return 0
126
127 # Exclusion: complete sentences ending with punctuation
128 sentence_endings = '.。!!??'
129 if text and text[-1] in sentence_endings:
130 # But keep numbered headings like "1. Overview" or "Chapter 1."
131 if not re.match(r'^[\d第]+[.、章节]', text):
132 return 0
133
134 # Bonus: bold text is more likely to be a heading
135 is_bold = flags & 16
136 if not is_bold and level >= 2:
137 # Non-bold subheadings require a larger font size difference
138 body_size = size_map.get("body", 12)
139 if size < body_size + 2:
140 return 0
141
142 return level
143
144 def is_monospace_font(font_name: str) -> bool:
145 """
146 Determine if the font is monospace (typically used for code).
147 """
148 if not font_name:
149 return False
150 font_lower = font_name.lower()
151 mono_fonts = [
152 'courier', 'consolas', 'monaco', 'menlo', 'monospace',
153 'source code', 'fira code', 'jetbrains', 'inconsolata',
154 'dejavu sans mono', 'liberation mono', 'ubuntu mono',
155 'roboto mono', 'robotomono', 'sf mono', 'cascadia', 'hack'
156 ]
157 return any(f in font_lower for f in mono_fonts)
158
159
160 def format_span_text(text: str, flags: int) -> str:
161 """Format text based on font flags (bold, italic)."""
162 text = CONTROL_CHARS_RE.sub('', text)
163 text = text.strip()
164 if not text:
165 return ""
166
167 is_bold = flags & 16
168 is_italic = flags & 2
169
170 if is_bold and is_italic:
171 return f"***{text}***"
172 elif is_bold:
173 return f"**{text}**"
174 elif is_italic:
175 return f"*{text}*"
176 return text
177
178
179 def detect_list_item(text: str) -> tuple:
180 """Detect if the text is a list item. Returns (is_list, list_type, content)."""
181 text = text.strip()
182
183 ul_patterns = [
184 (r'^[•●○◦▪▸►]\s*', '-'),
185 (r'^[-–—]\s+', '-'),
186 (r'^\*\s+', '-'),
187 ]
188 for pattern, marker in ul_patterns:
189 match = re.match(pattern, text)
190 if match:
191 return (True, 'ul', marker + ' ' + text[match.end():])
192
193 ol_pattern = r'^(\d+)[.、)]\s*'
194 match = re.match(ol_pattern, text)
195 if match:
196 num = match.group(1)
197 return (True, 'ol', f"{num}. " + text[match.end():])
198
199 return (False, None, text)
200
201
202 def remove_page_footer(text: str) -> str:
203 """
204 Remove page number patterns from footers, e.g. 'November 2025 8' or '2025年11月 8'.
205 """
206 # English month + year + page number
207 months_en = r'(?:January|February|March|April|May|June|July|August|September|October|November|December)'
208 pattern_en = rf'\s*{months_en}\s+\d{{4}}\s+\d{{1,3}}\s*$'
209 text = re.sub(pattern_en, '', text, flags=re.IGNORECASE)
210
211 # Chinese format: 2025年11月 8
212 pattern_cn = r'\s*\d{4}年\d{1,2}月\s+\d{1,3}\s*$'
213 text = re.sub(pattern_cn, '', text)
214
215 return text.rstrip()
216
217
218 def detect_headers_footers(doc: fitz.Document, threshold_ratio: float = 0.6) -> set[str]:
219 """
220 Detect headers and footers statistically.
221
222 Principle: Headers and footers typically appear at fixed positions (top or bottom)
223 on each page with the same content. We collect top and bottom text from all pages,
224 and if certain text appears more frequently than the threshold, it is treated as noise.
225 """
226 if len(doc) < 3:
227 return set()
228
229 headers = []
230 footers = []
231
232 # Sample first 20 and last 20 pages (avoid processing too slowly)
233 pages_to_scan = list(range(len(doc)))
234 if len(doc) > HEADER_FOOTER_SAMPLE_LIMIT:
235 pages_to_scan = (
236 pages_to_scan[:HEADER_FOOTER_EDGE_SAMPLE_SIZE]
237 + pages_to_scan[-HEADER_FOOTER_EDGE_SAMPLE_SIZE:]
238 )
239
240 for i in pages_to_scan:
241 page = doc[i]
242 rect = page.rect
243 h = rect.height
244
245 # Define top and bottom regions (15% each)
246 top_rect = fitz.Rect(0, 0, rect.width, h * 0.15)
247 bottom_rect = fitz.Rect(0, h * 0.85, rect.width, h)
248
249 # Extract text blocks
250 blocks = page.get_text("blocks")
251 for b in blocks:
252 b_rect = fitz.Rect(b[:4])
253 text = b[4].strip()
254 if not text:
255 continue
256
257 # Simple spatial determination
258 if b_rect.intersects(top_rect):
259 headers.append(text)
260 elif b_rect.intersects(bottom_rect):
261 footers.append(text)
262
263 # Count frequencies
264 noise_texts = set()
265 total_scanned = len(pages_to_scan)
266
267 for collection in [headers, footers]:
268 counter = Counter(collection)
269 for text, count in counter.items():
270 # if text appears in > 60% of scanned pages, mark as noise
271 if count / total_scanned > threshold_ratio:
272 noise_texts.add(text)
273
274 return noise_texts
275
276
277 def merge_adjacent_headings(elements: list) -> list:
278 """
279 Merge adjacent same-level short headings.
280 Example: '# Agent Tools &' + '# Interoperability' -> '# Agent Tools & Interoperability'
281 """
282 if not elements:
283 return elements
284
285 merged = []
286 i = 0
287
288 while i < len(elements):
289 el = elements[i]
290
291 # Only process heading elements
292 if el.get("type") != 0 or not el.get("is_heading"):
293 merged.append(el)
294 i += 1
295 continue
296
297 content = el["content"]
298 # Extract heading level
299 match = re.match(r'^(#{1,6})\s+(.+)$', content)
300 if not match:
301 merged.append(el)
302 i += 1
303 continue
304
305 level = match.group(1)
306 title_text = match.group(2)
307
308 # If heading is short and the next one is also the same level, try to merge
309 j = i + 1
310 while j < len(elements) and len(title_text) < 60:
311 next_el = elements[j]
312 if next_el.get("type") != 0 or not next_el.get("is_heading"):
313 break
314
315 next_match = re.match(r'^(#{1,6})\s+(.+)$', next_el["content"])
316 if not next_match or next_match.group(1) != level:
317 break
318
319 next_text = next_match.group(2)
320 # Only merge short heading fragments
321 if len(next_text) > 40:
322 break
323
324 # Merge
325 title_text += " " + next_text
326 j += 1
327
328 # Create merged element
329 merged_el = el.copy()
330 merged_el["content"] = f"{level} {title_text}"
331 merged.append(merged_el)
332 i = j
333
334 return merged
335
336
337 # Image filtering thresholds
338 MIN_IMAGE_PIXELS = 100 # Minimum pixel dimension (width AND height)
339 MIN_IMAGE_AREA = 30000 # Minimum pixel area (e.g. 200x150)
340 MIN_IMAGE_BYTES = 2048 # Minimum image data size (2KB)
341 MIN_PAGE_RATIO = 0.05 # Minimum render size relative to page (5%)
342 MIN_VISIBLE_IMAGE_WIDTH = 40
343 MIN_VISIBLE_IMAGE_HEIGHT = 40
344 MIN_VISIBLE_IMAGE_AREA_RATIO = 0.01
345 MAX_ASPECT_RATIO = 12 # Maximum aspect ratio (filters decorative bars)
346 MAX_LOW_INFO_BPP = 0.08 # Bytes-per-pixel threshold for low-info images
347 MAX_LOW_INFO_AREA = 500000 # Area threshold: only apply bpp filter below this
348 MIN_VECTOR_FIGURE_WIDTH = 100
349 MIN_VECTOR_FIGURE_HEIGHT = 80
350 MIN_VECTOR_FIGURE_AREA = 30000
351 MAX_VECTOR_FIGURE_ASPECT_RATIO = 8
352 VECTOR_FIGURE_PADDING = 4
353 VECTOR_FIGURE_DPI = 180
354 VECTOR_CAPTION_SEARCH_HEIGHT = 380
355 VECTOR_CAPTION_HORIZONTAL_GAP = 90
356 MAX_VECTOR_BACKGROUND_AREA_RATIO = 1.9
357 # Caption delimiters: ``Figure 1:`` / ``Figure 1.`` (classic) and ``Figure 1 |``
358 # (the DeepMind / Distill / Nature house style used by many ML papers, incl.
359 # full-width ``|``). Without the pipe variants, captioned vector figures route
360 # to the generic per-drawing fallback, which discards fine-grained line plots
361 # (each plot is hundreds of tiny primitives, none large enough on its own).
362 FIGURE_CAPTION_RE = re.compile(r'^(?:Figure|Fig\.?)\s*\d+\s*[:.||]', re.IGNORECASE)
363
364
365 TABLE_CAPTION_RE = re.compile(
366 r'^\u8868\s*\d+(?:\.\d+)*\s+(?!(?:\u7684|\u5217\u793a|\u6240\u793a|\u4e3a)).+'
367 )
368 TABLE_REFERENCE_PROSE_RE = re.compile(
369 r'^\u8868\s*\d+(?:\.\d+)*\s*(?:\u7684|\u5217\u793a|\u6240\u793a|\u4e3a)'
370 )
371 SECTION_HEADING_RE = re.compile(r'^\d+(?:\.\d+){1,3}\s+\S')
372 NUMBER_RE = re.compile(r'[-+]?\d+(?:\.\d+)?')
373 MODEL_COLUMN_RE = re.compile(r'^[((]\d+[))]$')
374 TABLE_NOTE_PREFIX = '\u6ce8'
375 TABLE_CONTINUATION_Y_RATIO = 0.88
376 TABLE_SCAN_BOTTOM_RATIO = 0.92
377
378
379 def should_keep_image(
380 block: dict[str, object],
381 page_rect: fitz.Rect,
382 seen_hashes: set[str] | None = None,
383 ) -> bool:
384 """Filter out small, decorative, or duplicate images.
385
386 Args:
387 block: Image block extracted from PyMuPDF.
388 page_rect: Current page rectangle.
389 seen_hashes: Optional set used to deduplicate image payloads.
390
391 Returns:
392 Whether the image should be kept in the Markdown output.
393 """
394 w, h = block.get("width", 0), block.get("height", 0)
395 bbox = block.get("bbox", (0, 0, 0, 0))
396 render_w = bbox[2] - bbox[0]
397 render_h = bbox[3] - bbox[1]
398 page_area = page_rect.width * page_rect.height
399 render_area_ratio = (render_w * render_h) / page_area if page_area > 0 else 0
400 visibly_placed = (
401 render_w >= MIN_VISIBLE_IMAGE_WIDTH
402 and render_h >= MIN_VISIBLE_IMAGE_HEIGHT
403 and render_area_ratio >= MIN_VISIBLE_IMAGE_AREA_RATIO
404 )
405
406 # Pixel dimension filter
407 if not visibly_placed and (w < MIN_IMAGE_PIXELS or h < MIN_IMAGE_PIXELS):
408 return False
409
410 # Pixel area filter
411 area = w * h
412 if not visibly_placed and area < MIN_IMAGE_AREA:
413 return False
414
415 image_data = block.get("image", b"")
416 if not visibly_placed and len(image_data) < MIN_IMAGE_BYTES:
417 return False
418
419 # Deduplicate tiny repeats, but preserve visibly placed logos / charts on
420 # distinct pages. Academic PDFs often reuse a small logo on title pages.
421 if seen_hashes is not None:
422 img_hash = hashlib.md5(image_data).hexdigest()
423 if img_hash in seen_hashes and not visibly_placed:
424 return False
425 seen_hashes.add(img_hash)
426
427 # Check render size relative to page
428 page_w = page_rect.width
429 page_h = page_rect.height
430 if page_w > 0 and page_h > 0:
431 if render_w / page_w < MIN_PAGE_RATIO and render_h / page_h < MIN_PAGE_RATIO:
432 return False
433
434 # Filter extreme aspect ratios (decorative bars/separators)
435 aspect = max(w, h) / max(min(w, h), 1)
436 if aspect > MAX_ASPECT_RATIO:
437 return False
438
439 # Filter low-info images: solid color blocks / gradients have very high
440 # compression ratios (low bytes-per-pixel). Only apply to smaller images
441 # to avoid filtering large photos with dark/uniform backgrounds.
442 bpp = len(image_data) / area
443 if bpp < MAX_LOW_INFO_BPP and area < MAX_LOW_INFO_AREA and not visibly_placed:
444 return False
445
446 return True
447
448
449 def _clip_rect_to_page(rect: fitz.Rect, page_rect: fitz.Rect) -> fitz.Rect:
450 """Clamp a rectangle to the current PDF page."""
451 return fitz.Rect(
452 max(page_rect.x0, rect.x0),
453 max(page_rect.y0, rect.y0),
454 min(page_rect.x1, rect.x1),
455 min(page_rect.y1, rect.y1),
456 )
457
458
459 def _is_rect_contained(inner: fitz.Rect, outer: fitz.Rect, threshold: float = 0.9) -> bool:
460 """Return whether ``inner`` is mostly covered by ``outer``."""
461 inner_area = inner.get_area()
462 if inner_area <= 0:
463 return False
464 return ((inner & outer).get_area() / inner_area) >= threshold
465
466
467 def _overlaps_table(rect: fitz.Rect, tab_rects: list[fitz.Rect]) -> bool:
468 """Skip vector regions that are already handled as extracted tables."""
469 rect_area = rect.get_area()
470 if rect_area <= 0:
471 return False
472 for tab_rect in tab_rects:
473 intersection = rect & tab_rect
474 if intersection.get_area() > 0.5 * min(rect_area, tab_rect.get_area()):
475 return True
476 return False
477
478
479 def _is_white(color: tuple[float, ...] | None) -> bool:
480 """Return whether a PDF drawing color is visually white."""
481 if color is None:
482 return False
483 return all(channel >= 0.98 for channel in color[:3])
484
485
486 def _is_background_drawing(drawing: dict[str, object]) -> bool:
487 """Identify white background rectangles that should not drive crops."""
488 return _is_white(drawing.get("fill")) and drawing.get("color") is None
489
490
491 def find_figure_caption_rects(page: fitz.Page) -> list[fitz.Rect]:
492 """Return text-line rectangles that look like figure captions."""
493 caption_rects = []
494 for block in page.get_text("dict")["blocks"]:
495 if block.get("type") != 0:
496 continue
497 for line in block["lines"]:
498 text = "".join(span["text"] for span in line["spans"]).strip()
499 if FIGURE_CAPTION_RE.match(text):
500 caption_rects.append(fitz.Rect(line["bbox"]))
501 return caption_rects
502
503
504 def _expand_rect(rect: fitz.Rect, padding: float, page_rect: fitz.Rect) -> fitz.Rect:
505 """Pad a rectangle and clamp it to the current PDF page."""
506 return _clip_rect_to_page(
507 fitz.Rect(
508 rect.x0 - padding,
509 rect.y0 - padding,
510 rect.x1 + padding,
511 rect.y1 + padding,
512 ),
513 page_rect,
514 )
515
516
517 def _union_rects(rects: list[fitz.Rect]) -> fitz.Rect:
518 """Return the bounding union for one or more rectangles."""
519 result = fitz.Rect(rects[0])
520 for rect in rects[1:]:
521 result |= rect
522 return result
523
524
525 def _find_captioned_vector_figures(
526 page: fitz.Page,
527 drawing_rects: list[fitz.Rect],
528 background_rects: list[fitz.Rect],
529 caption_rects: list[fitz.Rect],
530 ) -> list[fitz.Rect]:
531 """Build tight figure crops from non-background drawings above captions."""
532 figure_rects = []
533 page_rect = page.rect
534
535 for caption_rect in caption_rects:
536 related = []
537 for rect in drawing_rects:
538 horizontal_gap = max(caption_rect.x0 - rect.x1, rect.x0 - caption_rect.x1, 0)
539 if horizontal_gap > VECTOR_CAPTION_HORIZONTAL_GAP:
540 continue
541 if rect.y0 > caption_rect.y0:
542 continue
543 if caption_rect.y0 - rect.y1 > VECTOR_CAPTION_SEARCH_HEIGHT:
544 continue
545 related.append(rect)
546
547 if not related:
548 continue
549
550 content_rect = _union_rects(related)
551 rect = content_rect
552 for background_rect in background_rects:
553 background_rect = _clip_rect_to_page(background_rect, page_rect)
554 if not _is_rect_contained(content_rect, background_rect, threshold=0.95):
555 continue
556 if background_rect.get_area() > content_rect.get_area() * MAX_VECTOR_BACKGROUND_AREA_RATIO:
557 continue
558 rect = background_rect
559 break
560
561 rect = _expand_rect(rect, 10, page_rect)
562 rect.y1 = min(rect.y1, caption_rect.y0 - 2)
563 if rect.width >= MIN_VECTOR_FIGURE_WIDTH and rect.height >= MIN_VECTOR_FIGURE_HEIGHT:
564 figure_rects.append(rect)
565
566 return figure_rects
567
568
569 def detect_vector_figure_rects(page: fitz.Page, tab_rects: list[fitz.Rect]) -> list[fitz.Rect]:
570 """Detect large vector drawing regions that should be rasterized as figures.
571
572 Some academic PDFs store charts and diagrams as vector drawing commands,
573 not image XObjects. ``page.get_text("dict")`` exposes only raster image
574 blocks, so those figures need a separate drawing-region fallback.
575 """
576 candidates = []
577 page_rect = page.rect
578 caption_rects = find_figure_caption_rects(page)
579 drawing_rects = []
580 background_rects = []
581
582 for drawing in page.get_drawings():
583 rect = drawing.get("rect")
584 if not rect:
585 continue
586
587 rect = fitz.Rect(rect)
588 if rect.is_empty:
589 continue
590
591 if _is_background_drawing(drawing):
592 background_rects.append(rect)
593 continue
594
595 drawing_rects.append(rect)
596
597 if caption_rects:
598 return _find_captioned_vector_figures(page, drawing_rects, background_rects, caption_rects)
599
600 for rect in drawing_rects:
601 rect = _expand_rect(rect, VECTOR_FIGURE_PADDING, page_rect)
602 rect = _clip_rect_to_page(rect, page_rect)
603
604 width = rect.width
605 height = rect.height
606 if width < MIN_VECTOR_FIGURE_WIDTH or height < MIN_VECTOR_FIGURE_HEIGHT:
607 continue
608
609 area = rect.get_area()
610 if area < MIN_VECTOR_FIGURE_AREA:
611 continue
612
613 aspect = max(width, height) / max(min(width, height), 1)
614 if aspect > MAX_VECTOR_FIGURE_ASPECT_RATIO:
615 continue
616
617 if _overlaps_table(rect, tab_rects):
618 continue
619
620 candidates.append(rect)
621
622 candidates.sort(key=lambda r: r.get_area(), reverse=True)
623
624 kept = []
625 for rect in candidates:
626 if any(_is_rect_contained(rect, existing) for existing in kept):
627 continue
628 kept.append(rect)
629
630 return sorted(kept, key=lambda r: (r.y0, r.x0))
631
632
633 def clean_text(text: str) -> str:
634 """Clean extracted text."""
635 lines = text.split('\n')
636 cleaned_lines = []
637 prev_empty = False
638
639 for line in lines:
640 line = line.rstrip()
641 is_empty = len(line.strip()) == 0
642
643 if is_empty:
644 if not prev_empty:
645 cleaned_lines.append('')
646 prev_empty = True
647 else:
648 cleaned_lines.append(line)
649 prev_empty = False
650
651 return '\n'.join(cleaned_lines)
652
653
654 def _extract_text_lines(page: fitz.Page) -> list[tuple[fitz.Rect, str]]:
655 """Return text lines with their page rectangles in reading order."""
656 lines = []
657 for block in page.get_text("dict")["blocks"]:
658 if block.get("type") != 0:
659 continue
660 for line in block["lines"]:
661 text = "".join(span["text"] for span in line["spans"]).strip()
662 if text:
663 lines.append((fitz.Rect(line["bbox"]), text))
664 return sorted(lines, key=lambda item: (item[0].y0, item[0].x0))
665
666
667 def _is_table_caption(text: str) -> bool:
668 """Return whether a line is a real table caption, not prose citing a table."""
669 return bool(TABLE_CAPTION_RE.match(text.strip()))
670
671
672 def _caption_table_start_y(
673 caption_rect: fitz.Rect,
674 lines: list[tuple[fitz.Rect, str]],
675 ) -> float:
676 """Start below a caption and its adjacent English translation line."""
677 start_y = caption_rect.y1 + 1
678 for rect, text in lines:
679 if rect.y0 < caption_rect.y1 - 1 or rect.y0 > caption_rect.y1 + 35:
680 continue
681 if text.startswith("Table"):
682 start_y = max(start_y, rect.y1 + 1)
683 return start_y
684
685
686 def _looks_like_numeric_table_line(text: str) -> bool:
687 """Detect long data rows so they are not mistaken for prose boundaries."""
688 numbers = NUMBER_RE.findall(text)
689 if len(numbers) >= 3:
690 return True
691 tokens = [token for token in re.split(r'\s+', text.strip()) if token]
692 return len(tokens) >= 4 and len(numbers) >= 2
693
694
695 def _is_table_region_boundary(
696 rect: fitz.Rect,
697 text: str,
698 page: fitz.Page,
699 start_y: float,
700 ) -> bool:
701 """Return whether a line likely starts prose after a text-detected table."""
702 text = text.strip()
703 if rect.y0 < start_y + 45:
704 return False
705 if text.startswith(TABLE_NOTE_PREFIX):
706 return True
707 if SECTION_HEADING_RE.match(text):
708 return True
709 if TABLE_REFERENCE_PROSE_RE.match(text):
710 return True
711 if _looks_like_numeric_table_line(text):
712 return False
713
714 width_ratio = rect.width / page.rect.width if page.rect.width > 0 else 0
715 return len(text) >= 26 and width_ratio > 0.52 and rect.x0 < page.rect.width * 0.25
716
717
718 def _table_region_bottom(
719 page: fitz.Page,
720 lines: list[tuple[fitz.Rect, str]],
721 start_y: float,
722 ) -> float:
723 """Find a conservative bottom edge for a caption-guided text table scan."""
724 for rect, text in lines:
725 if rect.y0 <= start_y:
726 continue
727 if _is_table_region_boundary(rect, text, page, start_y):
728 return max(start_y + 20, rect.y0 - 2)
729 return page.rect.height * TABLE_SCAN_BOTTOM_RATIO
730
731
732 def _normalize_table_cell(value: object) -> str:
733 """Normalize one extracted table cell for Markdown output."""
734 if value is None:
735 return ""
736 text = CONTROL_CHARS_RE.sub('', str(value))
737 text = re.sub(r'\s*\n\s*', '<br>', text.strip())
738 text = re.sub(r'[ \t]+', ' ', text)
739 return text.replace('|', r'\|')
740
741
742 def _clean_table_rows(rows: list[list[object]]) -> list[list[str]]:
743 """Remove empty rows / columns from PyMuPDF table extraction output."""
744 normalized = [[_normalize_table_cell(cell) for cell in row] for row in rows]
745 normalized = [row for row in normalized if any(cell for cell in row)]
746 if not normalized:
747 return []
748
749 max_cols = max(len(row) for row in normalized)
750 padded = [row + [""] * (max_cols - len(row)) for row in normalized]
751 keep_cols = [
752 idx
753 for idx in range(max_cols)
754 if any(row[idx] for row in padded)
755 ]
756 if len(keep_cols) < 2:
757 return []
758 return _postprocess_table_rows([[row[idx] for idx in keep_cols] for row in padded])
759
760
761 def _nonempty_cell_indexes(row: list[str]) -> list[int]:
762 """Return indexes of non-empty cells in a row."""
763 return [idx for idx, cell in enumerate(row) if cell]
764
765
766 def _merge_label_underscore_rows(rows: list[list[str]]) -> list[list[str]]:
767 """Join rows where PDF extraction split a leading underscore from a label."""
768 merged = []
769 for row in rows:
770 nonempty = _nonempty_cell_indexes(row)
771 if nonempty == [0] and row[0] == "_" and merged and merged[-1][0]:
772 merged[-1][0] = f"_{merged[-1][0]}"
773 continue
774 merged.append(row)
775 return merged
776
777
778 def _merge_single_cell_continuations(rows: list[list[str]]) -> list[list[str]]:
779 """Merge wrapped single-cell table rows into the previous row."""
780 merged: list[list[str]] = []
781 for row in rows:
782 nonempty = _nonempty_cell_indexes(row)
783 if (
784 len(nonempty) == 1
785 and nonempty[0] > 0
786 and merged
787 and not MODEL_COLUMN_RE.match(row[nonempty[0]])
788 ):
789 index = nonempty[0]
790 separator = "<br>" if merged[-1][index] else ""
791 merged[-1][index] = f"{merged[-1][index]}{separator}{row[index]}"
792 continue
793 merged.append(row)
794 return merged
795
796
797 def _looks_like_model_row(row: list[str]) -> bool:
798 """Return whether a row contains model-number table headings."""
799 values = [cell for cell in row[1:] if cell]
800 return len(values) >= 2 and all(MODEL_COLUMN_RE.match(cell) for cell in values)
801
802
803 def _looks_like_outcome_row(row: list[str]) -> bool:
804 """Return whether a row contains regression outcome labels."""
805 values = [cell for cell in row[1:] if cell]
806 if len(values) < 2:
807 return False
808 short_values = [cell for cell in values if len(cell) <= 12 and not NUMBER_RE.search(cell)]
809 return len(short_values) == len(values)
810
811
812 def _regression_group_labels(row: list[str], data_cols: int) -> list[str]:
813 """Infer repeated group labels for common regression-table headings."""
814 compact = "".join(row)
815 if "总样本" in compact and "国有" in compact and "非国有" in compact and data_cols == 7:
816 return ["总样本"] * 3 + ["国有企业"] * 2 + ["非国有企业"] * 2
817 return [""] * data_cols
818
819
820 def _flatten_regression_header(rows: list[list[str]]) -> list[list[str]]:
821 """Flatten multi-line regression headings into one Markdown header row."""
822 if len(rows) < 3:
823 return rows
824
825 if (
826 len(rows) >= 2
827 and rows[0][0]
828 and not any(rows[0][1:])
829 and _looks_like_model_row(rows[1])
830 ):
831 outcome = rows[0][0]
832 header = ["变量"]
833 header.extend(
834 f"{model} {outcome}".strip()
835 for model in rows[1][1:]
836 )
837 return [header] + rows[2:]
838
839 header_offset = 0
840 groups = [""] * (len(rows[0]) - 1)
841 if not _looks_like_model_row(rows[0]) and _looks_like_model_row(rows[1]):
842 header_offset = 1
843 groups = _regression_group_labels(rows[0], len(rows[1]) - 1)
844
845 if not _looks_like_model_row(rows[header_offset]):
846 return rows
847 if len(rows) <= header_offset + 1 or not _looks_like_outcome_row(rows[header_offset + 1]):
848 return rows
849
850 model_row = rows[header_offset]
851 outcome_row = rows[header_offset + 1]
852 header = ["变量"]
853 for idx, model in enumerate(model_row[1:]):
854 pieces = []
855 if idx < len(groups) and groups[idx]:
856 pieces.append(groups[idx])
857 if model:
858 pieces.append(model)
859 if idx + 1 < len(outcome_row) and outcome_row[idx + 1]:
860 pieces.append(outcome_row[idx + 1])
861 header.append(" ".join(pieces).strip())
862 return [header] + rows[header_offset + 2:]
863
864
865 def _fix_paired_sample_t_table(rows: list[list[str]]) -> list[list[str]]:
866 """Collapse multi-row paired-sample T-test headings into readable columns."""
867 if not rows or not any("成对差分" in cell for cell in rows[0]):
868 return rows
869 body = [row for row in rows if row and row[0].startswith("对")]
870 if len(body) < 1:
871 return rows
872 header = [
873 "配对",
874 "变量",
875 "均值",
876 "标准差",
877 "均值的标准误",
878 "差分95%置信区间下限",
879 "差分95%置信区间上限",
880 "t",
881 "Df",
882 "Sig.(双侧)",
883 ]
884 fixed_rows = [header]
885 for row in body:
886 fixed_rows.append(row[:len(header)] + [""] * max(0, len(header) - len(row)))
887 return fixed_rows
888
889
890 def _fix_variable_definition_table(rows: list[list[str]]) -> list[list[str]]:
891 """Repeat variable-category labels for common variable definition tables."""
892 if not rows or rows[0] != ["变量类型", "变量名称", "符号", "变量说明"]:
893 return rows
894
895 fixed = [rows[0]]
896 for row in rows[1:]:
897 if not any(row):
898 continue
899 name = row[1] if len(row) > 1 else ""
900 symbol = row[2] if len(row) > 2 else ""
901 description = row[3] if len(row) > 3 else ""
902 if not name or not symbol:
903 continue
904
905 if symbol in {"R&D", "Fixed", "Hc"}:
906 category = "被解释变量"
907 elif symbol == "Vat":
908 category = "解释变量"
909 else:
910 category = "控制变量"
911 fixed.append([category, name, symbol, description])
912 return fixed
913
914
915 def _fix_correlation_triangle(rows: list[list[str]]) -> list[list[str]]:
916 """Restore the missing last self-correlation column in triangular tables."""
917 if len(rows) < 4 or not rows[0] or rows[0][0] != "变量":
918 return rows
919 body_names = [row[0] for row in rows[1:] if row and row[0]]
920 header_names = rows[0][1:]
921 if len(body_names) != len(header_names) + 1:
922 return rows
923 missing_name = body_names[-1]
924 fixed = [rows[0] + [missing_name]]
925 for row in rows[1:-1]:
926 fixed.append(row + [""])
927 fixed.append(rows[-1] + ["1"])
928 return fixed
929
930
931 def _postprocess_table_rows(rows: list[list[str]]) -> list[list[str]]:
932 """Apply Markdown-oriented cleanup to extracted table rows."""
933 rows = _merge_label_underscore_rows(rows)
934 rows = _merge_single_cell_continuations(rows)
935 rows = _fix_variable_definition_table(rows)
936 rows = _fix_paired_sample_t_table(rows)
937 rows = _flatten_regression_header(rows)
938 rows = _fix_correlation_triangle(rows)
939 return rows
940
941
942 def _rows_to_markdown(rows: list[list[str]]) -> str:
943 """Convert cleaned table rows to GitHub-flavored Markdown."""
944 if len(rows) < 2:
945 return ""
946 col_count = max(len(row) for row in rows)
947 padded = [row + [""] * (col_count - len(row)) for row in rows]
948 header = padded[0]
949 body = padded[1:]
950 lines = [
951 "|" + "|".join(header) + "|",
952 "|" + "|".join(["---"] * col_count) + "|",
953 ]
954 lines.extend("|" + "|".join(row) + "|" for row in body)
955 return "\n".join(lines)
956
957
958 def _table_to_markdown(tab: object) -> str:
959 """Convert a PyMuPDF table object to cleaned Markdown."""
960 try:
961 rows = tab.extract() or []
962 except Exception:
963 return ""
964 return _rows_to_markdown(_clean_table_rows(rows))
965
966
967 def _is_valid_table_markdown(markdown: str) -> bool:
968 """Return whether generated Markdown contains a minimally useful table."""
969 return markdown.count("\n") >= 2 and markdown.startswith("|")
970
971
972 def _markdown_col_count(markdown: str) -> int:
973 """Return the column count implied by the first Markdown table row."""
974 first_line = markdown.splitlines()[0] if markdown else ""
975 return max(0, first_line.count("|") - 1)
976
977
978 def _append_table_markdown_candidate(
979 candidates: list[dict[str, object]],
980 bbox: fitz.Rect,
981 markdown: str,
982 method: str,
983 replace_narrow: bool = False,
984 ) -> None:
985 """Append or replace a table candidate after overlap deduplication."""
986 if not _is_valid_table_markdown(markdown):
987 return
988
989 for index, candidate in enumerate(candidates):
990 existing = candidate["bbox"]
991 if not isinstance(existing, fitz.Rect):
992 continue
993 overlap = (bbox & existing).get_area()
994 if overlap <= 0.8 * min(bbox.get_area(), existing.get_area()):
995 continue
996
997 existing_markdown = str(candidate.get("content", ""))
998 can_replace = (
999 replace_narrow
1000 and bbox.width > existing.width * 1.4
1001 and _markdown_col_count(markdown) >= _markdown_col_count(existing_markdown)
1002 )
1003 if can_replace:
1004 candidates[index] = {
1005 "bbox": bbox,
1006 "content": markdown,
1007 "method": method,
1008 }
1009 return
1010
1011 candidates.append({
1012 "bbox": bbox,
1013 "content": markdown,
1014 "method": method,
1015 })
1016
1017
1018 def _add_table_candidate(
1019 candidates: list[dict[str, object]],
1020 tab: object,
1021 method: str,
1022 ) -> None:
1023 """Append a table candidate if it has useful Markdown and is not duplicate."""
1024 markdown = _table_to_markdown(tab)
1025 if not _is_valid_table_markdown(markdown):
1026 return
1027
1028 bbox = fitz.Rect(tab.bbox)
1029 _append_table_markdown_candidate(candidates, bbox, markdown, method)
1030
1031
1032 def _merge_word_runs(words: list[tuple]) -> list[dict[str, object]]:
1033 """Group PyMuPDF words into row-level text runs."""
1034 rows: list[list[tuple]] = []
1035 for word in sorted(words, key=lambda item: (item[1], item[0])):
1036 if not rows or abs(rows[-1][0][1] - word[1]) > 4:
1037 rows.append([word])
1038 else:
1039 rows[-1].append(word)
1040
1041 runs = []
1042 for row in rows:
1043 row_runs = []
1044 for word in sorted(row, key=lambda item: item[0]):
1045 x0, y0, x1, y1, text = word[:5]
1046 if row_runs and x0 - row_runs[-1]["x1"] <= 8:
1047 row_runs[-1]["x1"] = x1
1048 row_runs[-1]["y0"] = min(row_runs[-1]["y0"], y0)
1049 row_runs[-1]["y1"] = max(row_runs[-1]["y1"], y1)
1050 row_runs[-1]["text"] = f"{row_runs[-1]['text']} {text}"
1051 else:
1052 row_runs.append({
1053 "x0": x0,
1054 "y0": y0,
1055 "x1": x1,
1056 "y1": y1,
1057 "text": text,
1058 })
1059 runs.extend(row_runs)
1060 return runs
1061
1062
1063 def _cluster_word_columns(runs: list[dict[str, object]]) -> list[float]:
1064 """Infer stable table columns from word-run centers."""
1065 centers = sorted((float(run["x0"]) + float(run["x1"])) / 2 for run in runs)
1066 columns: list[float] = []
1067 for center in centers:
1068 if not columns or abs(center - columns[-1]) > 14:
1069 columns.append(center)
1070 else:
1071 columns[-1] = (columns[-1] + center) / 2
1072 return columns
1073
1074
1075 def _word_runs_to_rows(
1076 runs: list[dict[str, object]],
1077 columns: list[float],
1078 ) -> list[list[str]]:
1079 """Place word runs into inferred columns and return table rows."""
1080 row_groups: list[list[dict[str, object]]] = []
1081 for run in sorted(runs, key=lambda item: (float(item["y0"]), float(item["x0"]))):
1082 if not row_groups or abs(float(row_groups[-1][0]["y0"]) - float(run["y0"])) > 4:
1083 row_groups.append([run])
1084 else:
1085 row_groups[-1].append(run)
1086
1087 rows = []
1088 for group in row_groups:
1089 row = [""] * len(columns)
1090 for run in group:
1091 center = (float(run["x0"]) + float(run["x1"])) / 2
1092 col_index = min(range(len(columns)), key=lambda idx: abs(columns[idx] - center))
1093 text = _normalize_table_cell(run["text"])
1094 row[col_index] = f"{row[col_index]} {text}".strip() if row[col_index] else text
1095 rows.append(row)
1096 return rows
1097
1098
1099 def _words_to_markdown_table(
1100 page: fitz.Page,
1101 clip: fitz.Rect,
1102 ) -> tuple[fitz.Rect, str] | None:
1103 """Build a simple table from word coordinates inside a clipped region."""
1104 words = page.get_text("words", clip=clip)
1105 if len(words) < 6:
1106 return None
1107
1108 runs = _merge_word_runs(words)
1109 if len(runs) < 6:
1110 return None
1111
1112 columns = _cluster_word_columns(runs)
1113 if len(columns) < 3:
1114 return None
1115
1116 rows = _word_runs_to_rows(runs, columns)
1117 cleaned_rows = _clean_table_rows(rows)
1118 markdown = _rows_to_markdown(cleaned_rows)
1119 if not _is_valid_table_markdown(markdown):
1120 return None
1121
1122 x0 = min(float(run["x0"]) for run in runs)
1123 y0 = min(float(run["y0"]) for run in runs)
1124 x1 = max(float(run["x1"]) for run in runs)
1125 y1 = max(float(run["y1"]) for run in runs)
1126 return fitz.Rect(x0, y0, x1, y1), markdown
1127
1128
1129 def _find_tables_in_clip(
1130 page: fitz.Page,
1131 clip: fitz.Rect,
1132 ) -> list[object]:
1133 """Find text-strategy tables inside a clipped page region."""
1134 if clip.height < 20 or clip.width < 80:
1135 return []
1136 try:
1137 return list(page.find_tables(strategy="text", clip=clip))
1138 except Exception:
1139 return []
1140
1141
1142 def find_page_tables(
1143 page: fitz.Page,
1144 include_top_continuation: bool = False,
1145 ) -> tuple[list[dict[str, object]], bool]:
1146 """Find line-detected tables plus caption-guided text tables on one page."""
1147 candidates: list[dict[str, object]] = []
1148 try:
1149 for tab in page.find_tables():
1150 _add_table_candidate(candidates, tab, "lines")
1151 except Exception:
1152 pass
1153
1154 lines = _extract_text_lines(page)
1155 for rect, text in lines:
1156 if not _is_table_caption(text):
1157 continue
1158 start_y = _caption_table_start_y(rect, lines)
1159 bottom_y = _table_region_bottom(page, lines, start_y)
1160 clip = fitz.Rect(0, start_y, page.rect.width, bottom_y)
1161 for tab in _find_tables_in_clip(page, clip):
1162 _add_table_candidate(candidates, tab, "caption-text")
1163 word_table = _words_to_markdown_table(page, clip)
1164 if word_table:
1165 bbox, markdown = word_table
1166 _append_table_markdown_candidate(
1167 candidates,
1168 bbox,
1169 markdown,
1170 "caption-words",
1171 replace_narrow=True,
1172 )
1173
1174 if include_top_continuation:
1175 start_y = page.rect.height * 0.08
1176 bottom_y = _table_region_bottom(page, lines, start_y)
1177 clip = fitz.Rect(0, start_y, page.rect.width, bottom_y)
1178 for tab in _find_tables_in_clip(page, clip):
1179 _add_table_candidate(candidates, tab, "continuation-text")
1180 word_table = _words_to_markdown_table(page, clip)
1181 if word_table:
1182 bbox, markdown = word_table
1183 _append_table_markdown_candidate(
1184 candidates,
1185 bbox,
1186 markdown,
1187 "continuation-words",
1188 replace_narrow=True,
1189 )
1190
1191 candidates.sort(key=lambda candidate: candidate["bbox"].y0)
1192 table_continues = any(
1193 isinstance(candidate["bbox"], fitz.Rect)
1194 and candidate["bbox"].y1 >= page.rect.height * TABLE_CONTINUATION_Y_RATIO
1195 for candidate in candidates
1196 )
1197 return candidates, table_continues
1198
1199
1200 def _is_markdown_table_line(line: str) -> bool:
1201 """Return whether a Markdown line belongs to a pipe table."""
1202 return line.startswith("|")
1203
1204
1205 def _is_markdown_separator_line(line: str) -> bool:
1206 """Return whether a Markdown table line is the separator row."""
1207 cells = [cell.strip() for cell in line.strip("|").split("|")]
1208 return bool(cells) and all(cell and set(cell) <= {"-", ":"} for cell in cells)
1209
1210
1211 def _compatible_table_headers(first: list[str], second: list[str]) -> bool:
1212 """Return whether two Markdown table blocks have the same flattened header."""
1213 if len(first) < 2 or len(second) < 2:
1214 return False
1215 if not _is_markdown_separator_line(first[1]) or not _is_markdown_separator_line(second[1]):
1216 return False
1217 return first[0] == second[0] and _markdown_col_count(first[0]) > 2
1218
1219
1220 def _is_table_continuation_noise(line: str) -> bool:
1221 """Allow only page/header noise between split table parts."""
1222 text = line.strip()
1223 if not text:
1224 return True
1225 if text.startswith("<!-- Page ") and text.endswith("-->"):
1226 return True
1227 if re.fullmatch(r'\d+', text):
1228 return True
1229 if "重庆大学硕士学位论文" in text:
1230 return True
1231 continuation_labels = [
1232 "营改增",
1233 "深化增值税改革",
1234 "国有企业",
1235 "非国有企业",
1236 ]
1237 return any(label in text for label in continuation_labels)
1238
1239
1240 def _read_markdown_table_block(lines: list[str], start: int) -> tuple[list[str], int]:
1241 """Read a contiguous Markdown table block from ``start``."""
1242 end = start
1243 while end < len(lines) and _is_markdown_table_line(lines[end]):
1244 end += 1
1245 return lines[start:end], end
1246
1247
1248 def merge_markdown_continuation_tables(markdown: str) -> str:
1249 """Merge split cross-page Markdown tables with repeated headers."""
1250 lines = markdown.splitlines()
1251 result = []
1252 index = 0
1253
1254 while index < len(lines):
1255 if not _is_markdown_table_line(lines[index]):
1256 result.append(lines[index])
1257 index += 1
1258 continue
1259
1260 table, table_end = _read_markdown_table_block(lines, index)
1261 search = table_end
1262 while True:
1263 between_start = search
1264 while search < len(lines) and not _is_markdown_table_line(lines[search]):
1265 if not _is_table_continuation_noise(lines[search]):
1266 break
1267 search += 1
1268
1269 if search >= len(lines) or not _is_markdown_table_line(lines[search]):
1270 break
1271 if any(
1272 not _is_table_continuation_noise(line)
1273 for line in lines[between_start:search]
1274 ):
1275 break
1276
1277 next_table, next_end = _read_markdown_table_block(lines, search)
1278 if not _compatible_table_headers(table, next_table):
1279 break
1280
1281 table.extend(next_table[2:])
1282 search = next_end
1283
1284 result.extend(table)
1285 index = search if search != table_end else table_end
1286
1287 return "\n".join(result)
1288
1289
1290 def merge_adjacent_formatting(text: str) -> str:
1291 """Merge adjacent same-style formatted spans split across PDF tokens.
1292
1293 PyMuPDF often emits a phrase as several spans, so per-span wrapping in
1294 ``format_span_text`` produces ``**X****Y**`` (bold) or ``***X******Y***``
1295 (bold-italic) where one phrase is intended. Collapse the abutting markers
1296 so the run reads as a single phrase: ``**X Y**`` / ``***X Y***``.
1297
1298 Italic-italic adjacency (``*X**Y*``) is indistinguishable from a plain
1299 bold span's open/close pair and is left alone — merging it would corrupt
1300 every legitimate ``**bold**`` phrase on the page. The previous regexes
1301 ``\\*\\s*\\*`` and ``\\*\\*\\*\\s*\\*\\*\\*`` did exactly that, deleting
1302 all bold formatting from the converted Markdown.
1303 """
1304 # Bold-italic adjacency first so the inner ``******`` isn't half-eaten
1305 # by the bold pass.
1306 text = re.sub(r'\*{6}', ' ', text)
1307 text = re.sub(r'\*{4}', ' ', text)
1308 return text
1309
1310
1311 def is_sentence_end(text: str) -> bool:
1312 """Check if the text ends with sentence-ending punctuation."""
1313 text = text.rstrip()
1314 if not text:
1315 return True
1316 end_puncts = '.。!!??::;;'
1317 return text[-1] in end_puncts
1318
1319
1320 def should_merge_lines(current: dict, next_line: dict) -> bool:
1321 """Determine if two lines should be merged into the same paragraph."""
1322 if current.get("is_heading") or next_line.get("is_heading"):
1323 return False
1324 if current.get("is_list") or next_line.get("is_list"):
1325 return False
1326 if is_sentence_end(current.get("content", "")):
1327 return False
1328 return True
1329
1330
1331 def extract_pdf_to_markdown(
1332 pdf_path: str,
1333 output_path: str = None,
1334 images: str = "filtered",
1335 render_vector_figures: bool = False,
1336 vector_figure_dpi: int = VECTOR_FIGURE_DPI,
1337 ) -> str:
1338 """Extract text, images, and tables from a PDF and convert to Markdown.
1339
1340 Args:
1341 pdf_path: Path to the PDF file.
1342 output_path: Optional output path for the Markdown file.
1343 images: Image extraction mode.
1344 "filtered" = apply size/quality filters (default),
1345 "all" = extract all images without filtering,
1346 "none" = skip all images.
1347 render_vector_figures: Rasterize large vector drawing regions as PNGs.
1348 vector_figure_dpi: DPI used for rendered vector figure PNGs.
1349 """
1350 try:
1351 doc = fitz.open(pdf_path)
1352 except Exception as e:
1353 print(f"[ERROR] Failed to open PDF file: {e}")
1354 return ""
1355
1356 if len(doc) >= 200:
1357 print(f"[HINT] {len(doc)} pages — for very large PDFs, consider splitting "
1358 f"the source by chapter beforehand (e.g. with pdftk / qpdf / PyPDF2) "
1359 f"and converting each part individually.")
1360
1361 filename = Path(pdf_path).stem
1362 title = re.sub(r'^\d+-', '', filename).strip()
1363
1364 print(f"[INFO] Analyzing document structure...")
1365 size_map = analyze_font_sizes(doc)
1366 print(f" Font size mapping: body={size_map.get('body', 'N/A')}, " +
1367 f"H1={size_map.get('h1', 'N/A')}, H2={size_map.get('h2', 'N/A')}, H3={size_map.get('h3', 'N/A')}")
1368
1369 print(f"[INFO] Detecting repeated headers/footers...")
1370 noise_texts = detect_headers_footers(doc)
1371 if noise_texts:
1372 print(f" Found {len(noise_texts)} repeated noise texts (will be removed):")
1373 for t in list(noise_texts)[:3]:
1374 print(f" - {t[:30]}...")
1375
1376 markdown_content = f"# {title}\n\n"
1377 seen_image_hashes = set() # Track seen image hashes for deduplication
1378
1379 img_dir = None
1380 rel_img_dir = None
1381 if output_path:
1382 output_path = Path(output_path)
1383 rel_img_dir = f"{output_path.stem}_files"
1384 img_dir = output_path.parent / rel_img_dir
1385
1386 img_count = 0
1387 image_manifest: list[dict[str, object]] = []
1388 previous_table_continues = False
1389
1390 for page_num, page in enumerate(doc, 1):
1391 if page_num > 1:
1392 # Add page break marker to help LLM understand context segmentation
1393 markdown_content += f"\n\n<!-- Page {page_num} -->\n\n"
1394
1395 table_candidates, previous_table_continues = find_page_tables(
1396 page,
1397 include_top_continuation=previous_table_continues,
1398 )
1399 tab_rects = [
1400 candidate["bbox"]
1401 for candidate in table_candidates
1402 if isinstance(candidate["bbox"], fitz.Rect)
1403 ]
1404
1405 page_elements = []
1406
1407 for table in table_candidates:
1408 bbox = table["bbox"]
1409 if not isinstance(bbox, fitz.Rect):
1410 continue
1411 page_elements.append({
1412 "y0": bbox.y0,
1413 "type": 2,
1414 "content": table["content"]
1415 })
1416 print(f" [OK] Found table: P{page_num} ({table['method']})")
1417
1418 if render_vector_figures:
1419 for figure_rect in detect_vector_figure_rects(page, tab_rects):
1420 page_elements.append({
1421 "y0": figure_rect.y0,
1422 "type": 3,
1423 "content": figure_rect,
1424 })
1425 print(f" [OK] Found vector figure region: P{page_num} {tuple(round(v, 1) for v in figure_rect)}")
1426
1427 blocks = page.get_text("dict")["blocks"]
1428
1429 for block in blocks:
1430 block_rect = fitz.Rect(block["bbox"])
1431
1432 # Check if this is table content
1433 is_in_table = False
1434 for tab_rect in tab_rects:
1435 intersect = block_rect & tab_rect
1436 if intersect.get_area() > 0.6 * block_rect.get_area():
1437 is_in_table = True
1438 break
1439
1440 if is_in_table:
1441 continue
1442
1443 if block["type"] == 0:
1444 # Check if this is noise text to be filtered (whole block match)
1445 block_text_full = "".join([span["text"] for line in block["lines"] for span in line["spans"]]).strip()
1446 if block_text_full in noise_texts:
1447 continue
1448
1449 for line in block["lines"]:
1450 line_text = ""
1451 line_size = 0
1452 line_flags = 0
1453 span_count = 0
1454 is_code_line = False
1455
1456 formatted_spans = []
1457 for span in line["spans"]:
1458 span_text = CONTROL_CHARS_RE.sub('', span["text"])
1459 if not span_text.strip():
1460 if span_text:
1461 formatted_spans.append(span_text)
1462 continue
1463
1464 span_size = span["size"]
1465 span_flags = span["flags"]
1466
1467 line_size = max(line_size, span_size)
1468 line_flags |= span_flags
1469 span_count += 1
1470
1471 heading_level = get_heading_level(span_size, size_map, span_text, span_flags)
1472
1473 # Detect code font
1474 font_name = span.get("font", "")
1475 if is_monospace_font(font_name):
1476 is_code_line = True
1477 formatted_spans.append(span_text) # No formatting for code
1478 elif heading_level > 0:
1479 formatted_spans.append(span_text.strip())
1480 else:
1481 formatted_spans.append(format_span_text(span_text, span_flags))
1482
1483 line_text = ''.join(formatted_spans).strip()
1484 if not line_text:
1485 continue
1486
1487 # Secondary check: line-level noise match (sometimes blocks are split)
1488 if line_text in noise_texts:
1489 continue
1490
1491 line_text = merge_adjacent_formatting(line_text)
1492
1493 heading_level = get_heading_level(line_size, size_map, line_text, line_flags)
1494
1495 is_list, list_type, list_content = detect_list_item(line_text)
1496
1497 if heading_level > 0:
1498 prefix = '#' * heading_level + ' '
1499 clean_line = re.sub(r'\*+([^*]+)\*+', r'\1', line_text)
1500 final_text = prefix + clean_line
1501 elif is_list:
1502 final_text = list_content
1503 else:
1504 final_text = line_text
1505
1506 page_elements.append({
1507 "y0": line["bbox"][1],
1508 "type": 0,
1509 "content": final_text,
1510 "is_heading": heading_level > 0,
1511 "is_list": is_list,
1512 "is_code": is_code_line
1513 })
1514
1515 elif block["type"] == 1:
1516 if images == "none":
1517 pass
1518 elif images == "all" or should_keep_image(block, page.rect, seen_image_hashes):
1519 page_elements.append({
1520 "y0": block["bbox"][1],
1521 "type": 1,
1522 "content": block
1523 })
1524 else:
1525 w, h = block.get("width", 0), block.get("height", 0)
1526 print(f" [SKIP] Filtered small/decorative image: {w}x{h}px, {len(block.get('image', b''))} bytes")
1527
1528 page_elements.sort(key=lambda x: x["y0"])
1529
1530 # Merge adjacent same-level short headings
1531 page_elements = merge_adjacent_headings(page_elements)
1532
1533 merged_elements = []
1534 i = 0
1535 while i < len(page_elements):
1536 el = page_elements[i]
1537 if el["type"] == 0 and not el.get("is_heading") and not el.get("is_list"):
1538 merged_content = el["content"]
1539 j = i + 1
1540 while j < len(page_elements):
1541 next_el = page_elements[j]
1542 if next_el["type"] != 0:
1543 break
1544 if not should_merge_lines({"content": merged_content, "is_heading": False, "is_list": False}, next_el):
1545 break
1546 merged_content += " " + next_el["content"]
1547 j += 1
1548 merged_elements.append({
1549 "type": 0,
1550 "content": remove_page_footer(merged_content),
1551 "is_heading": False,
1552 "is_list": False
1553 })
1554 i = j
1555 else:
1556 merged_elements.append(el)
1557 i += 1
1558
1559 prev_was_list = False
1560 prev_was_code = False
1561 code_block_lines = []
1562
1563 def flush_code_block():
1564 """Flush accumulated code block."""
1565 nonlocal code_block_lines, markdown_content
1566 if code_block_lines:
1567 markdown_content += "```\n"
1568 markdown_content += "\n".join(code_block_lines) + "\n"
1569 markdown_content += "```\n\n"
1570 code_block_lines = []
1571
1572 for el in merged_elements:
1573 if el["type"] == 0:
1574 is_list = el.get("is_list", False)
1575 is_heading = el.get("is_heading", False)
1576 is_code = el.get("is_code", False)
1577
1578 if is_code:
1579 # Accumulate code lines
1580 if prev_was_list:
1581 markdown_content += "\n"
1582 prev_was_list = False
1583 code_block_lines.append(el["content"])
1584 prev_was_code = True
1585 else:
1586 # Non-code line, flush accumulated code block first
1587 if prev_was_code:
1588 flush_code_block()
1589 prev_was_code = False
1590
1591 if is_heading:
1592 if prev_was_list:
1593 markdown_content += "\n"
1594 markdown_content += el["content"] + "\n\n"
1595 prev_was_list = False
1596 elif is_list:
1597 markdown_content += el["content"] + "\n"
1598 prev_was_list = True
1599 else:
1600 if prev_was_list:
1601 markdown_content += "\n"
1602 markdown_content += el["content"] + "\n\n"
1603 prev_was_list = False
1604
1605 elif el["type"] == 2:
1606 if prev_was_code:
1607 flush_code_block()
1608 prev_was_code = False
1609 if prev_was_list:
1610 markdown_content += "\n"
1611 markdown_content += el["content"] + "\n\n"
1612 prev_was_list = False
1613
1614 elif el["type"] == 1:
1615 if prev_was_code:
1616 flush_code_block()
1617 prev_was_code = False
1618 if img_dir:
1619 block = el["content"]
1620 ext = block["ext"]
1621 image_data = block["image"]
1622 safe_filename = filename.replace(" ", "_")
1623 image_name = f"{safe_filename}_p{page_num}_{img_count}.{ext}"
1624 image_path = img_dir / image_name
1625
1626 try:
1627 img_dir.mkdir(parents=True, exist_ok=True)
1628 with open(image_path, "wb") as f:
1629 f.write(image_data)
1630
1631 if prev_was_list:
1632 markdown_content += "\n"
1633 markdown_content += f"![{image_name}]({rel_img_dir}/{image_name})\n\n"
1634 width = int(block.get("width", 0) or 0)
1635 height = int(block.get("height", 0) or 0)
1636 ratio = width / height if width > 0 and height > 0 else None
1637 image_manifest.append({
1638 "index": len(image_manifest) + 1,
1639 "filename": image_name,
1640 "original_filename": image_name,
1641 "asset_kind": "bitmap",
1642 "svg_renderable": True,
1643 "pptx_native_supported": True,
1644 "source_kind": "pdf_image",
1645 "source_ext": f".{ext}",
1646 "page_index": page_num,
1647 "occurrence_index": img_count + 1,
1648 "pixel_width": width or None,
1649 "pixel_height": height or None,
1650 "pixel_ratio": round(ratio, 6) if ratio else None,
1651 "display_ratio": round(ratio, 6) if ratio else None,
1652 "source_sha256": hashlib.sha256(image_data).hexdigest(),
1653 "bbox": list(block.get("bbox", [])),
1654 })
1655 img_count += 1
1656 prev_was_list = False
1657 print(f" [OK] Extracted image: {image_name}")
1658 except Exception as e:
1659 print(f" [WARN] Failed to save image: {e}")
1660
1661 elif el["type"] == 3:
1662 if prev_was_code:
1663 flush_code_block()
1664 prev_was_code = False
1665 if img_dir:
1666 figure_rect = el["content"]
1667 safe_filename = filename.replace(" ", "_")
1668 image_name = f"{safe_filename}_p{page_num}_figure_{img_count}.png"
1669 image_path = img_dir / image_name
1670
1671 try:
1672 img_dir.mkdir(parents=True, exist_ok=True)
1673 scale = vector_figure_dpi / 72
1674 pix = page.get_pixmap(
1675 matrix=fitz.Matrix(scale, scale),
1676 clip=figure_rect,
1677 alpha=False,
1678 )
1679 pix.save(str(image_path))
1680
1681 if prev_was_list:
1682 markdown_content += "\n"
1683 markdown_content += f"![{image_name}]({rel_img_dir}/{image_name})\n\n"
1684 ratio = pix.width / pix.height if pix.width > 0 and pix.height > 0 else None
1685 image_manifest.append({
1686 "index": len(image_manifest) + 1,
1687 "filename": image_name,
1688 "original_filename": image_name,
1689 "asset_kind": "bitmap",
1690 "svg_renderable": True,
1691 "pptx_native_supported": True,
1692 "source_kind": "pdf_vector_figure",
1693 "source_ext": ".png",
1694 "page_index": page_num,
1695 "occurrence_index": img_count + 1,
1696 "pixel_width": pix.width,
1697 "pixel_height": pix.height,
1698 "pixel_ratio": round(ratio, 6) if ratio else None,
1699 "display_ratio": round(ratio, 6) if ratio else None,
1700 "bbox": [
1701 figure_rect.x0,
1702 figure_rect.y0,
1703 figure_rect.x1,
1704 figure_rect.y1,
1705 ],
1706 })
1707 img_count += 1
1708 prev_was_list = False
1709 print(f" [OK] Rendered vector figure: {image_name}")
1710 except Exception as e:
1711 print(f" [WARN] Failed to render vector figure: {e}")
1712
1713 # Flush code block at end of page
1714 if prev_was_code:
1715 flush_code_block()
1716
1717 doc.close()
1718
1719 markdown_content = merge_markdown_continuation_tables(markdown_content)
1720 markdown_content = CONTROL_CHARS_RE.sub('', markdown_content)
1721 markdown_content = re.sub(r'\n{3,}', '\n\n', markdown_content)
1722 markdown_content = markdown_content.strip() + "\n"
1723
1724 if output_path:
1725 os.makedirs(os.path.dirname(output_path) or '.', exist_ok=True)
1726 with open(output_path, 'w', encoding='utf-8') as f:
1727 f.write(markdown_content)
1728 if img_dir and image_manifest:
1729 (img_dir / "image_manifest.json").write_text(
1730 json.dumps(image_manifest, ensure_ascii=False, indent=2) + "\n",
1731 encoding="utf-8",
1732 )
1733 profile_path = write_conversion_profile_best_effort(
1734 input_path=pdf_path,
1735 markdown_path=output_path,
1736 converter="pdf_to_md.py",
1737 conversion_type="pdf",
1738 asset_dir=img_dir,
1739 )
1740 print(f"[OK] Saved Markdown to: {output_path}")
1741 if profile_path:
1742 print(f" Wrote conversion profile -> {profile_path}")
1743
1744 return markdown_content
1745
1746
1747 def main() -> int:
1748 """Run the CLI entry point."""
1749 parser = argparse.ArgumentParser(
1750 description='PDF to Markdown converter (with structure detection and LLM optimization)',
1751 formatter_class=argparse.RawDescriptionHelpFormatter,
1752 epilog='''
1753 Examples:
1754 python pdf_to_md.py book.pdf # Convert a single file
1755 python pdf_to_md.py book.pdf appendix.pdf # Convert multiple files
1756 python pdf_to_md.py ./pdfs -o ./markdown # Convert PDFs in a directory
1757 python pdf_to_md.py book.pdf -o output.md # Specify output file
1758 python pdf_to_md.py book.pdf --render-vector-figures
1759
1760 Structure detection features:
1761 - Auto-detect heading levels (based on font size)
1762 - Detect bold and italic text
1763 - Detect ordered and unordered lists
1764 - Extract tables and convert to Markdown format (with deduplication)
1765 - [New] Smart detection and removal of repeated page headers/footers
1766 - [New] Add <!-- Page N --> page break markers to help LLM understanding
1767 '''
1768 )
1769
1770 parser.add_argument('inputs', nargs='+', help='PDF file(s) or directories')
1771 parser.add_argument(
1772 '-o',
1773 '--output',
1774 help='Output Markdown file for one input, or output directory for multiple inputs/directories',
1775 )
1776 parser.add_argument(
1777 '--images',
1778 choices=['all', 'filtered', 'none'],
1779 default='filtered',
1780 help='Image extraction mode: filtered=apply size/quality filters (default), all=no filtering, none=skip images',
1781 )
1782 parser.add_argument(
1783 '--render-vector-figures',
1784 action='store_true',
1785 help='Render large vector drawing regions as PNG figure assets',
1786 )
1787 parser.add_argument(
1788 '--vector-figure-dpi',
1789 type=int,
1790 default=VECTOR_FIGURE_DPI,
1791 help=f'DPI for --render-vector-figures output (default: {VECTOR_FIGURE_DPI})',
1792 )
1793
1794 args = parser.parse_args()
1795
1796 return run_path_batch(
1797 args.inputs,
1798 {'.pdf'},
1799 args.output,
1800 lambda source, output: bool(
1801 extract_pdf_to_markdown(
1802 str(source),
1803 str(output),
1804 images=args.images,
1805 render_vector_figures=args.render_vector_figures,
1806 vector_figure_dpi=args.vector_figure_dpi,
1807 )
1808 ),
1809 )
1810
1811
1812 if __name__ == '__main__':
1813 raise SystemExit(main())
1814
1814 lines PYTHON