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 ADDED
@@ -0,0 +1,7 @@
1
+ """pdf-html: convert any text-based PDF into beautiful, self-contained HTML.
2
+
3
+ Structure is inferred deterministically from font metadata and geometry —
4
+ no NLP, no LLM in the core. Text is always verbatim; images are never emitted.
5
+ """
6
+
7
+ __version__ = "0.1.0"
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
+
@@ -0,0 +1,6 @@
1
+ """Callout (highlighted aside) detection.
2
+
3
+ Generic triggers, in priority order: bold/colored lead-in ending in ":",
4
+ boxed/background regions, indentation + smaller size relative to body.
5
+ Planned — not yet implemented.
6
+ """
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
+