pdf-html 0.1.0__py3-none-any.whl
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.
- pdf_html/__init__.py +7 -0
- pdf_html/ast.py +124 -0
- pdf_html/callout_detector.py +6 -0
- pdf_html/cli.py +147 -0
- pdf_html/extractor.py +272 -0
- pdf_html/header_footer.py +118 -0
- pdf_html/list_parser.py +158 -0
- pdf_html/reading_order.py +154 -0
- pdf_html/renderer.py +220 -0
- pdf_html/structure.py +218 -0
- pdf_html/style_profiler.py +117 -0
- pdf_html/table_reconstructor.py +237 -0
- pdf_html-0.1.0.dist-info/METADATA +296 -0
- pdf_html-0.1.0.dist-info/RECORD +17 -0
- pdf_html-0.1.0.dist-info/WHEEL +4 -0
- pdf_html-0.1.0.dist-info/entry_points.txt +2 -0
- pdf_html-0.1.0.dist-info/licenses/LICENSE +21 -0
pdf_html/__init__.py
ADDED
pdf_html/ast.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Document model (AST).
|
|
2
|
+
|
|
3
|
+
Document -> Page -> blocks (Heading, Paragraph, ListBlock, TableBlock,
|
|
4
|
+
Callout). Runs carry inline style (bold, italic, color, size, mono,
|
|
5
|
+
superscript) so the renderer can emit semantic tags; text is always verbatim.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Union
|
|
12
|
+
|
|
13
|
+
# Axis-aligned bounding box in PDF user-space points: (x0, y0, x1, y1).
|
|
14
|
+
BBox = tuple[float, float, float, float]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class SpanStyle:
|
|
19
|
+
"""Inline style metadata for a span or run, from PDF font metadata."""
|
|
20
|
+
|
|
21
|
+
font: str = ""
|
|
22
|
+
size: float = 0.0
|
|
23
|
+
bold: bool = False
|
|
24
|
+
italic: bool = False
|
|
25
|
+
mono: bool = False
|
|
26
|
+
serif: bool = False
|
|
27
|
+
superscript: bool = False
|
|
28
|
+
color: int = 0 # sRGB int, 0xRRGGBB
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def css_color(self) -> str:
|
|
32
|
+
"""Return the color as a #rrggbb CSS string."""
|
|
33
|
+
return f"#{self.color & 0xFFFFFF:06x}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Span:
|
|
38
|
+
"""A contiguous run of same-styled text as extracted from the PDF."""
|
|
39
|
+
|
|
40
|
+
text: str
|
|
41
|
+
bbox: BBox
|
|
42
|
+
style: SpanStyle
|
|
43
|
+
# Writing direction of the containing line (usually (1, 0)).
|
|
44
|
+
direction: tuple[float, float] = (1.0, 0.0)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# A Run is a Span that survived page analysis into the document tree.
|
|
48
|
+
Run = Span
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class Heading:
|
|
53
|
+
level: int # 1..6
|
|
54
|
+
runs: list[Run] = field(default_factory=list)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class Paragraph:
|
|
59
|
+
runs: list[Run] = field(default_factory=list)
|
|
60
|
+
align: str = "left" # left | center | right | justify
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class ListItem:
|
|
65
|
+
runs: list[Run] = field(default_factory=list)
|
|
66
|
+
items: list["ListItem"] = field(default_factory=list) # nested children
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class ListBlock:
|
|
71
|
+
ordered: bool = False
|
|
72
|
+
items: list[ListItem] = field(default_factory=list)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class TableCell:
|
|
77
|
+
"""One table cell; holds fully classified blocks (paragraphs, lists, ...)."""
|
|
78
|
+
|
|
79
|
+
blocks: list["Block"] = field(default_factory=list)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class TableBlock:
|
|
84
|
+
rows: list[list[TableCell]] = field(default_factory=list)
|
|
85
|
+
header_rows: int = 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class Callout:
|
|
90
|
+
runs: list[Run] = field(default_factory=list)
|
|
91
|
+
kind: str = "note"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
Block = Union[Heading, Paragraph, ListBlock, TableBlock, Callout]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class Page:
|
|
99
|
+
number: int # 1-based
|
|
100
|
+
width: float
|
|
101
|
+
height: float
|
|
102
|
+
blocks: list[Block] = field(default_factory=list)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class DocumentMeta:
|
|
107
|
+
title: str = ""
|
|
108
|
+
body_size: float = 0.0
|
|
109
|
+
body_font: str = ""
|
|
110
|
+
heading_sizes: list[float] = field(default_factory=list) # h1..h6 tiers
|
|
111
|
+
palette: list[int] = field(default_factory=list) # sRGB ints
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class Document:
|
|
116
|
+
meta: DocumentMeta = field(default_factory=DocumentMeta)
|
|
117
|
+
pages: list[Page] = field(default_factory=list)
|
|
118
|
+
scanned_pages: list[int] = field(default_factory=list) # 1-based numbers
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def is_scanned(self) -> bool:
|
|
122
|
+
"""True when every page lacks extractable text."""
|
|
123
|
+
return bool(self.pages) and len(self.scanned_pages) == len(self.pages)
|
|
124
|
+
|
pdf_html/cli.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Command-line interface.
|
|
2
|
+
|
|
3
|
+
pdf-html INPUT.pdf -o out.html [--extractor pymupdf|pdftotext]
|
|
4
|
+
[--style auto|default] [--paginate] [--no-tables] [--no-callouts]
|
|
5
|
+
[--keep-headers] [--allow-scanned]
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
|
|
14
|
+
from .ast import Document, DocumentMeta, Page
|
|
15
|
+
from .extractor import PdfToTextExtractor, PyMuPDFExtractor, TextExtractor
|
|
16
|
+
from .header_footer import strip_headers_footers
|
|
17
|
+
from .renderer import render_html
|
|
18
|
+
from .style_profiler import profile_styles
|
|
19
|
+
from .table_reconstructor import merge_continuation_tables, reconstruct_page
|
|
20
|
+
|
|
21
|
+
EXIT_OK = 0
|
|
22
|
+
EXIT_SCANNED = 2
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
26
|
+
parser = argparse.ArgumentParser(
|
|
27
|
+
prog="pdf-html",
|
|
28
|
+
description=(
|
|
29
|
+
"Convert a text-based PDF into a single self-contained HTML file. "
|
|
30
|
+
"Text is verbatim; images are dropped; style is inferred from "
|
|
31
|
+
"font metadata and geometry."
|
|
32
|
+
),
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument("input", help="Path to the input PDF.")
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"-o", "--output", required=True, help="Path of the HTML file to write."
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"--extractor",
|
|
40
|
+
choices=["pymupdf", "pdftotext"],
|
|
41
|
+
default="pymupdf",
|
|
42
|
+
help="Text extractor backend (default: pymupdf).",
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"--style",
|
|
46
|
+
choices=["auto", "default"],
|
|
47
|
+
default="auto",
|
|
48
|
+
help=(
|
|
49
|
+
"'auto' derives CSS from the document's fonts/sizes/colors; "
|
|
50
|
+
"'default' uses a clean built-in theme (default: auto)."
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"--paginate",
|
|
55
|
+
action="store_true",
|
|
56
|
+
help="Wrap each PDF page in a <section class='sheet'> (off by default).",
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument(
|
|
59
|
+
"--no-tables",
|
|
60
|
+
action="store_true",
|
|
61
|
+
help=(
|
|
62
|
+
"Disable table reconstruction; table text flows as regular "
|
|
63
|
+
"paragraphs in reading order (tables are on by default)."
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"--no-callouts",
|
|
68
|
+
action="store_true",
|
|
69
|
+
help="Disable callout detection (accepted; callouts not yet implemented).",
|
|
70
|
+
)
|
|
71
|
+
parser.add_argument(
|
|
72
|
+
"--keep-headers",
|
|
73
|
+
action="store_true",
|
|
74
|
+
help="Keep repeated running headers/footers instead of stripping them.",
|
|
75
|
+
)
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"--allow-scanned",
|
|
78
|
+
action="store_true",
|
|
79
|
+
help="Convert scanned/image PDFs instead of exiting non-zero.",
|
|
80
|
+
)
|
|
81
|
+
return parser
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _extractor(name: str) -> TextExtractor:
|
|
85
|
+
if name == "pdftotext":
|
|
86
|
+
return PdfToTextExtractor() # TODO: planned, raises NotImplementedError
|
|
87
|
+
return PyMuPDFExtractor()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def convert(args: argparse.Namespace) -> Document:
|
|
91
|
+
"""Run the full pipeline for parsed CLI args and return the AST."""
|
|
92
|
+
result = _extractor(args.extractor).extract(args.input)
|
|
93
|
+
|
|
94
|
+
if result.scanned_pages and not args.allow_scanned:
|
|
95
|
+
print(
|
|
96
|
+
f"error: {len(result.scanned_pages)} of {len(result.pages)} page(s) "
|
|
97
|
+
"contain no extractable text — this looks like a scanned PDF.\n"
|
|
98
|
+
"hint: pre-run OCR (e.g. `ocrmypdf in.pdf out.pdf`) or pass "
|
|
99
|
+
"--allow-scanned to convert anyway.",
|
|
100
|
+
file=sys.stderr,
|
|
101
|
+
)
|
|
102
|
+
raise SystemExit(EXIT_SCANNED)
|
|
103
|
+
|
|
104
|
+
pages = result.pages if args.keep_headers else strip_headers_footers(result.pages)
|
|
105
|
+
profile = profile_styles(pages)
|
|
106
|
+
if args.no_tables:
|
|
107
|
+
for page in pages:
|
|
108
|
+
page.tables = [] # reconstruct_page falls back to flow classification
|
|
109
|
+
|
|
110
|
+
# TODO: callout detection (--no-callouts) once callout_detector lands.
|
|
111
|
+
doc = Document(
|
|
112
|
+
meta=DocumentMeta(
|
|
113
|
+
title=result.title,
|
|
114
|
+
body_size=profile.body_size,
|
|
115
|
+
body_font=profile.body_font,
|
|
116
|
+
heading_sizes=profile.heading_sizes,
|
|
117
|
+
palette=profile.palette,
|
|
118
|
+
),
|
|
119
|
+
scanned_pages=list(result.scanned_pages),
|
|
120
|
+
)
|
|
121
|
+
pages_blocks = [reconstruct_page(raw, profile) for raw in pages]
|
|
122
|
+
merge_continuation_tables(pages_blocks)
|
|
123
|
+
for raw, blocks in zip(pages, pages_blocks, strict=True):
|
|
124
|
+
doc.pages.append(
|
|
125
|
+
Page(
|
|
126
|
+
number=raw.number,
|
|
127
|
+
width=raw.width,
|
|
128
|
+
height=raw.height,
|
|
129
|
+
blocks=blocks,
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
return doc
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
136
|
+
args = build_parser().parse_args(argv)
|
|
137
|
+
doc = convert(args)
|
|
138
|
+
html_text = render_html(doc, style=args.style, paginate=args.paginate)
|
|
139
|
+
with open(args.output, "w", encoding="utf-8") as fh:
|
|
140
|
+
fh.write(html_text)
|
|
141
|
+
print(f"wrote {args.output} ({len(doc.pages)} page(s))")
|
|
142
|
+
return EXIT_OK
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__":
|
|
146
|
+
raise SystemExit(main())
|
|
147
|
+
|
pdf_html/extractor.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Layout-aware text extraction.
|
|
2
|
+
|
|
3
|
+
Defines the TextExtractor ABC (extension point) and the default
|
|
4
|
+
PyMuPDFExtractor, which uses page.get_text("dict") to capture per-span
|
|
5
|
+
size/flags/color/bbox. A dependency-free pdftotext fallback is planned.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
import pymupdf
|
|
16
|
+
|
|
17
|
+
from .ast import BBox, Page, Span, SpanStyle
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from .ast import Document
|
|
21
|
+
|
|
22
|
+
# Spans whose text is a run of single letters separated by whitespace are
|
|
23
|
+
# usually decorative glyphs (e.g. titles with extra letter spacing). Collapse
|
|
24
|
+
# those artificial spaces back into words when at least this many consecutive
|
|
25
|
+
# single-letter tokens appear.
|
|
26
|
+
SPACED_GLYPH_MIN_TOKENS = 5
|
|
27
|
+
|
|
28
|
+
# When a span looks like decorative spaced glyphs, a gap between surrounding
|
|
29
|
+
# non-whitespace characters larger than this fraction of the font size is
|
|
30
|
+
# treated as a word boundary; smaller gaps have their space collapsed.
|
|
31
|
+
SPACED_GLYPH_WORD_GAP_THRESHOLD = 0.35
|
|
32
|
+
|
|
33
|
+
# PyMuPDF span flag bits (see pymupdf docs for TEXT_FONT_* constants).
|
|
34
|
+
FLAG_SUPERSCRIPT = 1
|
|
35
|
+
FLAG_ITALIC = 2
|
|
36
|
+
FLAG_SERIF = 4
|
|
37
|
+
FLAG_MONO = 8
|
|
38
|
+
FLAG_BOLD = 16
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class TableRegion:
|
|
43
|
+
"""A table detected on a page: outer bbox plus per-cell bboxes by row."""
|
|
44
|
+
|
|
45
|
+
bbox: BBox
|
|
46
|
+
rows: list[list[BBox | None]] = field(default_factory=list) # None = merged cell
|
|
47
|
+
# Detector hint: 1 when the first row of `rows` is a header row.
|
|
48
|
+
header_rows: int = 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class RawPage:
|
|
53
|
+
"""Spans extracted from one page, before reading-order analysis."""
|
|
54
|
+
|
|
55
|
+
number: int # 1-based
|
|
56
|
+
width: float
|
|
57
|
+
height: float
|
|
58
|
+
spans: list[Span] = field(default_factory=list)
|
|
59
|
+
tables: list[TableRegion] = field(default_factory=list)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class ExtractionResult:
|
|
64
|
+
"""Result of extracting an entire PDF."""
|
|
65
|
+
|
|
66
|
+
title: str
|
|
67
|
+
pages: list[RawPage] = field(default_factory=list)
|
|
68
|
+
scanned_pages: list[int] = field(default_factory=list) # 1-based
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def is_scanned(self) -> bool:
|
|
72
|
+
"""True when every page lacks extractable text spans."""
|
|
73
|
+
return bool(self.pages) and len(self.scanned_pages) == len(self.pages)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TextExtractor(ABC):
|
|
77
|
+
"""Extension point: turn a PDF into per-page spans with style metadata."""
|
|
78
|
+
|
|
79
|
+
@abstractmethod
|
|
80
|
+
def extract(self, pdf_path: str) -> ExtractionResult:
|
|
81
|
+
"""Extract spans from *pdf_path*; images are never included."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def style_from_flags(
|
|
85
|
+
flags: int, *, font: str, size: float, color: int
|
|
86
|
+
) -> SpanStyle:
|
|
87
|
+
"""Build a SpanStyle from a PyMuPDF span flags bitmask."""
|
|
88
|
+
return SpanStyle(
|
|
89
|
+
font=font,
|
|
90
|
+
size=size,
|
|
91
|
+
bold=bool(flags & FLAG_BOLD),
|
|
92
|
+
italic=bool(flags & FLAG_ITALIC),
|
|
93
|
+
mono=bool(flags & FLAG_MONO),
|
|
94
|
+
serif=bool(flags & FLAG_SERIF),
|
|
95
|
+
superscript=bool(flags & FLAG_SUPERSCRIPT),
|
|
96
|
+
color=color,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _overlaps_any(bbox: BBox, rects: list[pymupdf.Rect]) -> bool:
|
|
101
|
+
"""True when *bbox* intersects any rect in *rects*."""
|
|
102
|
+
rect = pymupdf.Rect(bbox)
|
|
103
|
+
return any(rect.intersects(r) for r in rects)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _looks_spaced_glyphs(text: str) -> bool:
|
|
107
|
+
"""True when *text* looks like decorative single-glyph spacing."""
|
|
108
|
+
min_repeats = SPACED_GLYPH_MIN_TOKENS - 1
|
|
109
|
+
pattern = re.compile(r"\w(?:\s+\w){" + str(min_repeats) + r",}")
|
|
110
|
+
return bool(pattern.search(text))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _span_text_from_chars(chars: list[dict], size: float) -> str:
|
|
114
|
+
"""Build span text from raw character dicts.
|
|
115
|
+
|
|
116
|
+
Decorative headings sometimes store each glyph with explicit spaces. When
|
|
117
|
+
a span matches the spaced-glyph pattern, collapse artificial spaces whose
|
|
118
|
+
surrounding non-whitespace characters are close together, and preserve
|
|
119
|
+
larger gaps as word boundaries.
|
|
120
|
+
"""
|
|
121
|
+
if not chars:
|
|
122
|
+
return ""
|
|
123
|
+
|
|
124
|
+
raw_text = "".join(c["c"] for c in chars)
|
|
125
|
+
if not _looks_spaced_glyphs(raw_text):
|
|
126
|
+
return raw_text
|
|
127
|
+
|
|
128
|
+
threshold = size * SPACED_GLYPH_WORD_GAP_THRESHOLD
|
|
129
|
+
parts: list[str] = []
|
|
130
|
+
for i, char_info in enumerate(chars):
|
|
131
|
+
char = char_info["c"]
|
|
132
|
+
if char.strip() == "":
|
|
133
|
+
prev_idx = next(
|
|
134
|
+
(j for j in range(i - 1, -1, -1) if chars[j]["c"].strip()), None
|
|
135
|
+
)
|
|
136
|
+
next_idx = next(
|
|
137
|
+
(j for j in range(i + 1, len(chars)) if chars[j]["c"].strip()), None
|
|
138
|
+
)
|
|
139
|
+
if prev_idx is None or next_idx is None:
|
|
140
|
+
parts.append(char)
|
|
141
|
+
continue
|
|
142
|
+
prev_char = chars[prev_idx]["c"]
|
|
143
|
+
next_char = chars[next_idx]["c"]
|
|
144
|
+
if not (prev_char.isalnum() and next_char.isalnum()):
|
|
145
|
+
parts.append(" ")
|
|
146
|
+
continue
|
|
147
|
+
gap = chars[next_idx]["bbox"][0] - chars[prev_idx]["bbox"][2]
|
|
148
|
+
if gap > threshold:
|
|
149
|
+
parts.append(" ")
|
|
150
|
+
# else: collapse the artificial space
|
|
151
|
+
else:
|
|
152
|
+
parts.append(char)
|
|
153
|
+
return "".join(parts)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class PyMuPDFExtractor(TextExtractor):
|
|
157
|
+
"""Default extractor: page.get_text("rawdict") -> spans with style metadata."""
|
|
158
|
+
|
|
159
|
+
def extract(self, pdf_path: str) -> ExtractionResult:
|
|
160
|
+
result = ExtractionResult(title="")
|
|
161
|
+
with pymupdf.open(pdf_path) as doc:
|
|
162
|
+
assert doc.metadata is not None
|
|
163
|
+
result.title = doc.metadata.get("title", "") or ""
|
|
164
|
+
for page in doc:
|
|
165
|
+
image_rects = self._image_rects(page)
|
|
166
|
+
spans = self._page_spans(page, image_rects)
|
|
167
|
+
number = page.number + 1 # type: ignore
|
|
168
|
+
if not spans:
|
|
169
|
+
result.scanned_pages.append(number)
|
|
170
|
+
result.pages.append(
|
|
171
|
+
RawPage(
|
|
172
|
+
number=number,
|
|
173
|
+
width=page.rect.width,
|
|
174
|
+
height=page.rect.height,
|
|
175
|
+
spans=spans,
|
|
176
|
+
tables=self._table_regions(page) if spans else [],
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
return result
|
|
180
|
+
|
|
181
|
+
@staticmethod
|
|
182
|
+
def _table_regions(page: pymupdf.Page) -> list[TableRegion]:
|
|
183
|
+
"""Detect tables with Page.find_tables() and record their geometry."""
|
|
184
|
+
regions: list[TableRegion] = []
|
|
185
|
+
for table in page.find_tables().tables:
|
|
186
|
+
rows: list[list[BBox | None]] = [
|
|
187
|
+
[tuple(cell) if cell is not None else None for cell in row.cells]
|
|
188
|
+
for row in table.rows
|
|
189
|
+
]
|
|
190
|
+
rows = [row for row in rows if any(c is not None for c in row)]
|
|
191
|
+
if not rows:
|
|
192
|
+
continue
|
|
193
|
+
regions.append(
|
|
194
|
+
TableRegion(
|
|
195
|
+
bbox=tuple(table.bbox),
|
|
196
|
+
rows=rows,
|
|
197
|
+
header_rows=0 if table.header.external else 1,
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
return regions
|
|
201
|
+
|
|
202
|
+
@staticmethod
|
|
203
|
+
def _image_rects(page: pymupdf.Page) -> list[pymupdf.Rect]:
|
|
204
|
+
rects: list[pymupdf.Rect] = []
|
|
205
|
+
for img in page.get_images(full=True):
|
|
206
|
+
rects.extend(page.get_image_rects(img[0]))
|
|
207
|
+
return rects
|
|
208
|
+
|
|
209
|
+
@staticmethod
|
|
210
|
+
def _page_spans(
|
|
211
|
+
page: pymupdf.Page, image_rects: list[pymupdf.Rect]
|
|
212
|
+
) -> list[Span]:
|
|
213
|
+
spans: list[Span] = []
|
|
214
|
+
text_page: dict = page.get_text("rawdict") # type: ignore[assignment]
|
|
215
|
+
for block in text_page.get("blocks", []):
|
|
216
|
+
if block.get("type") != 0: # 0 = text; 1 = image (dropped)
|
|
217
|
+
continue
|
|
218
|
+
for line in block.get("lines", []):
|
|
219
|
+
direction = tuple(line.get("dir", (1.0, 0.0)))
|
|
220
|
+
for raw in line.get("spans", []):
|
|
221
|
+
text = _span_text_from_chars(
|
|
222
|
+
raw.get("chars", []), raw.get("size", 0.0)
|
|
223
|
+
)
|
|
224
|
+
if not text.strip():
|
|
225
|
+
continue
|
|
226
|
+
bbox: BBox = tuple(raw["bbox"]) # type: ignore[assignment]
|
|
227
|
+
if image_rects and _overlaps_any(bbox, image_rects):
|
|
228
|
+
continue # text painted over an image is dropped too
|
|
229
|
+
spans.append(
|
|
230
|
+
Span(
|
|
231
|
+
text=text,
|
|
232
|
+
bbox=bbox,
|
|
233
|
+
style=style_from_flags(
|
|
234
|
+
raw.get("flags", 0),
|
|
235
|
+
font=raw.get("font", ""),
|
|
236
|
+
size=raw.get("size", 0.0),
|
|
237
|
+
color=raw.get("color", 0),
|
|
238
|
+
),
|
|
239
|
+
direction=direction, # type: ignore[arg-type]
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
return spans
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class PdfToTextExtractor(TextExtractor):
|
|
246
|
+
"""Dependency-free pdftotext fallback (reduced fidelity).
|
|
247
|
+
|
|
248
|
+
Planned — not yet implemented.
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
def extract(self, pdf_path: str) -> ExtractionResult: # noqa: ARG002
|
|
252
|
+
raise NotImplementedError(
|
|
253
|
+
"PdfToTextExtractor is planned but not implemented yet; "
|
|
254
|
+
"use --extractor pymupdf."
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def extract_to_document(result: ExtractionResult) -> Document:
|
|
259
|
+
"""Adapt an ExtractionResult into the AST Document shell.
|
|
260
|
+
|
|
261
|
+
Pages carry no blocks yet; structure detection fills those in later.
|
|
262
|
+
"""
|
|
263
|
+
from .ast import Document, DocumentMeta # noqa: PLC0415
|
|
264
|
+
|
|
265
|
+
doc = Document(meta=DocumentMeta(title=result.title))
|
|
266
|
+
doc.scanned_pages = list(result.scanned_pages)
|
|
267
|
+
for raw in result.pages:
|
|
268
|
+
doc.pages.append(
|
|
269
|
+
Page(number=raw.number, width=raw.width, height=raw.height)
|
|
270
|
+
)
|
|
271
|
+
return doc
|
|
272
|
+
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Repeated header/footer stripping (geometry-based).
|
|
2
|
+
|
|
3
|
+
Removes spans whose bounding box top edge falls within the top 10% of page
|
|
4
|
+
height, or whose bottom edge falls within the bottom 10% of page height, when
|
|
5
|
+
that span text (digits normalized to #) appears on >= 60% of all pages.
|
|
6
|
+
|
|
7
|
+
Also detects right-hand sidebars that contain legal boilerplate
|
|
8
|
+
(e.g. copyright notices) and removes them so they do not interleave with
|
|
9
|
+
main-column title/body text.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
|
|
17
|
+
from .extractor import RawPage
|
|
18
|
+
|
|
19
|
+
# Vertical band at each page edge, as a fraction of page height, in which
|
|
20
|
+
# spans are header/footer candidates.
|
|
21
|
+
HEADER_FOOTER_PAGE_FRACTION = 0.10
|
|
22
|
+
|
|
23
|
+
# A candidate is stripped only when its digit-normalized text appears on at
|
|
24
|
+
# least this fraction of all pages.
|
|
25
|
+
MIN_PAGE_OCCURRENCE = 0.60
|
|
26
|
+
|
|
27
|
+
# Repetition is only meaningful when a document has more than one page;
|
|
28
|
+
# for a single page the ">= 60% of pages" rule would fire on real content
|
|
29
|
+
# (e.g. a report title sitting in the top band).
|
|
30
|
+
MIN_PAGES_FOR_STRIPPING = 2
|
|
31
|
+
|
|
32
|
+
# Fraction of page width considered the right-hand sidebar. Spans whose left
|
|
33
|
+
# edge sits to the right of (1 - RIGHT_SIDEBAR_FRACTION) * page_width are
|
|
34
|
+
# sidebar candidates.
|
|
35
|
+
RIGHT_SIDEBAR_FRACTION = 0.28
|
|
36
|
+
|
|
37
|
+
# Legal boilerplate triggers. If a right sidebar contains any of these,
|
|
38
|
+
# the whole sidebar is treated as page furniture.
|
|
39
|
+
_LEGAL_RE = re.compile(
|
|
40
|
+
r"COPYRIGHT|ALL RIGHTS RESERVED|NO PART OF THIS|PUBLICATION MAY BE|"
|
|
41
|
+
r"REPRODUCED|TRANSMITTED|PRIOR WRITTEN PERMISSION|INTERNAL REFERENCE CODE",
|
|
42
|
+
re.IGNORECASE,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
_DIGITS = re.compile(r"\d+")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def normalize_text(text: str) -> str:
|
|
49
|
+
"""Collapse whitespace and replace each digit run with # for comparison."""
|
|
50
|
+
return _DIGITS.sub("#", " ".join(text.split()))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _is_candidate(span_bbox: tuple[float, float, float, float], page_height: float) -> bool:
|
|
54
|
+
"""True when the bbox top edge is in the top band or the bottom edge is
|
|
55
|
+
in the bottom band of the page."""
|
|
56
|
+
band = page_height * HEADER_FOOTER_PAGE_FRACTION
|
|
57
|
+
top_edge, bottom_edge = span_bbox[1], span_bbox[3]
|
|
58
|
+
return top_edge <= band or bottom_edge >= page_height - band
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _legal_sidebar_spans(page: RawPage) -> set[int]:
|
|
62
|
+
"""Return the object ids of spans that form a right-hand legal sidebar.
|
|
63
|
+
|
|
64
|
+
A legal sidebar is a cluster of spans in the right margin whose combined
|
|
65
|
+
text matches legal boilerplate patterns. Removing these prevents copyright
|
|
66
|
+
fragments from interleaving with the main title/body column.
|
|
67
|
+
"""
|
|
68
|
+
threshold = page.width * (1 - RIGHT_SIDEBAR_FRACTION)
|
|
69
|
+
sidebar = [s for s in page.spans if s.bbox[0] >= threshold]
|
|
70
|
+
text = "".join(s.text for s in sidebar)
|
|
71
|
+
if not _LEGAL_RE.search(text):
|
|
72
|
+
return set()
|
|
73
|
+
return {id(s) for s in sidebar}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def repeated_boilerplate(pages: Sequence[RawPage]) -> set[str]:
|
|
77
|
+
"""Digit-normalized texts that qualify as repeated headers/footers."""
|
|
78
|
+
if len(pages) < MIN_PAGES_FOR_STRIPPING:
|
|
79
|
+
return set()
|
|
80
|
+
occurrences: dict[str, int] = {}
|
|
81
|
+
for page in pages:
|
|
82
|
+
seen_on_page: set[str] = set()
|
|
83
|
+
for span in page.spans:
|
|
84
|
+
if _is_candidate(span.bbox, page.height):
|
|
85
|
+
seen_on_page.add(normalize_text(span.text))
|
|
86
|
+
for text in seen_on_page:
|
|
87
|
+
occurrences[text] = occurrences.get(text, 0) + 1
|
|
88
|
+
threshold = MIN_PAGE_OCCURRENCE * len(pages)
|
|
89
|
+
return {t for t, count in occurrences.items() if count >= threshold}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def strip_headers_footers(pages: Sequence[RawPage]) -> list[RawPage]:
|
|
93
|
+
"""Return new RawPages with repeated headers/footers and legal sidebars removed."""
|
|
94
|
+
boilerplate = repeated_boilerplate(pages)
|
|
95
|
+
stripped: list[RawPage] = []
|
|
96
|
+
for page in pages:
|
|
97
|
+
legal_sidebar = _legal_sidebar_spans(page)
|
|
98
|
+
spans = []
|
|
99
|
+
for s in page.spans:
|
|
100
|
+
if id(s) in legal_sidebar:
|
|
101
|
+
continue
|
|
102
|
+
if (
|
|
103
|
+
_is_candidate(s.bbox, page.height)
|
|
104
|
+
and normalize_text(s.text) in boilerplate
|
|
105
|
+
):
|
|
106
|
+
continue
|
|
107
|
+
spans.append(s)
|
|
108
|
+
stripped.append(
|
|
109
|
+
RawPage(
|
|
110
|
+
number=page.number,
|
|
111
|
+
width=page.width,
|
|
112
|
+
height=page.height,
|
|
113
|
+
spans=spans,
|
|
114
|
+
tables=page.tables,
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
return stripped
|
|
118
|
+
|