tracecite 0.0.2__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.
- tracecite/__init__.py +35 -0
- tracecite/__main__.py +3 -0
- tracecite/cli.py +656 -0
- tracecite/docs/__init__.py +56 -0
- tracecite/docs/build.py +381 -0
- tracecite/docs/config.py +114 -0
- tracecite/docs/contract.py +17 -0
- tracecite/docs/modes.py +240 -0
- tracecite/docs/stage.py +407 -0
- tracecite/docs/vectorize.py +457 -0
- tracecite/evidence/__init__.py +3 -0
- tracecite/evidence/chunking.py +214 -0
- tracecite/evidence/commands.py +552 -0
- tracecite/evidence/config.py +49 -0
- tracecite/evidence/embedding.py +59 -0
- tracecite/evidence/manifest.py +197 -0
- tracecite/evidence/page_extraction.py +200 -0
- tracecite/evidence/page_output.py +80 -0
- tracecite/evidence/page_selection.py +116 -0
- tracecite/evidence/parsers/__init__.py +1 -0
- tracecite/evidence/parsers/base.py +65 -0
- tracecite/evidence/parsers/markdown.py +142 -0
- tracecite/evidence/parsers/pdf.py +315 -0
- tracecite/evidence/parsers/workbook.py +334 -0
- tracecite/evidence/paths.py +94 -0
- tracecite/evidence/schema.py +570 -0
- tracecite/evidence/source_links.py +185 -0
- tracecite/evidence/sync.py +1434 -0
- tracecite/evidence/vector_backend.py +130 -0
- tracecite/evidence/verify.py +368 -0
- tracecite/skills/routing-documentation-source-links/SKILL.md +167 -0
- tracecite/skills/routing-documentation-source-links/agents/openai.yaml +3 -0
- tracecite/skills/routing-documentation-source-links/references/documenter-integration.md +71 -0
- tracecite/skills/routing-documentation-source-links/references/registry-contract.md +86 -0
- tracecite/skills/routing-documentation-source-links/references/staging-and-rewriting.md +118 -0
- tracecite/skills/routing-documentation-source-links/references/validation-and-failure-modes.md +67 -0
- tracecite/skills/using-tracecite/SKILL.md +97 -0
- tracecite/skills/using-tracecite/agents/openai.yaml +3 -0
- tracecite/skills/using-tracecite/references/architecture.md +68 -0
- tracecite/skills/using-tracecite/references/pdf-and-assets.md +67 -0
- tracecite/skills/using-tracecite/references/retrieval.md +45 -0
- tracecite/skills/using-tracecite/references/schema-and-migrations.md +52 -0
- tracecite/skills/using-tracecite/references/synchronisation.md +98 -0
- tracecite/skills/using-tracecite/references/workspace-runtime.md +44 -0
- tracecite/skills/writing-evidence-backed-reports/SKILL.md +91 -0
- tracecite/skills/writing-evidence-backed-reports/agents/openai.yaml +3 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/README.md +13 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-bad-path.md +5 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-grammar.md +8 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-missing-definition.md +3 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-page-not-indexed.md +5 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/reports/invalid-unindexed-path.md +5 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/reports/valid-grammar.md +12 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/reports/valid-report.md +27 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/source-links.toml +17 -0
- tracecite/skills/writing-evidence-backed-reports/fixtures/validate_fixtures.py +295 -0
- tracecite/skills/writing-evidence-backed-reports/references/images-and-source-links.md +34 -0
- tracecite/skills/writing-evidence-backed-reports/references/quotation-and-verification.md +43 -0
- tracecite/skills/writing-evidence-backed-reports/references/reference-style-citations.md +48 -0
- tracecite/skills/writing-evidence-backed-reports/references/report-structure.md +58 -0
- tracecite/tables/__init__.py +41 -0
- tracecite/tables/_text.py +142 -0
- tracecite/tables/document.py +582 -0
- tracecite/tables/html.py +299 -0
- tracecite/tables/models.py +119 -0
- tracecite/tables/normalise.py +786 -0
- tracecite/tables/pandoc.py +503 -0
- tracecite/tables/publish.py +238 -0
- tracecite/tables/render.py +128 -0
- tracecite/tables/site.py +457 -0
- tracecite-0.0.2.dist-info/METADATA +320 -0
- tracecite-0.0.2.dist-info/RECORD +76 -0
- tracecite-0.0.2.dist-info/WHEEL +5 -0
- tracecite-0.0.2.dist-info/entry_points.txt +2 -0
- tracecite-0.0.2.dist-info/licenses/LICENSE +21 -0
- tracecite-0.0.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Markdown parser: heading-hierarchy and paragraph-aware extraction.
|
|
2
|
+
|
|
3
|
+
Markdown has no physical pages, so this parser synthesises a single
|
|
4
|
+
retained-extraction "page" row (``physical_page=1``) holding the complete
|
|
5
|
+
document text and a structural unit list in ``layout_json``, so a
|
|
6
|
+
chunker-only or normalisation-only change can rebuild chunk-input units
|
|
7
|
+
without re-reading the file (mirroring the PDF parser's retained-page reuse).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from .base import ParsedChunkUnit, ParsedPage, ParseResult
|
|
15
|
+
|
|
16
|
+
NAME = "markdown-heading"
|
|
17
|
+
|
|
18
|
+
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$")
|
|
19
|
+
DEFAULT_MAX_UNIT_CHARS = 2400
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _split_oversized(text: str, max_chars: int) -> list[str]:
|
|
23
|
+
if len(text) <= max_chars:
|
|
24
|
+
return [text]
|
|
25
|
+
words = text.split(" ")
|
|
26
|
+
parts: list[str] = []
|
|
27
|
+
current: list[str] = []
|
|
28
|
+
current_len = 0
|
|
29
|
+
for word in words:
|
|
30
|
+
word_len = len(word) + 1
|
|
31
|
+
if current and current_len + word_len > max_chars:
|
|
32
|
+
parts.append(" ".join(current))
|
|
33
|
+
current = []
|
|
34
|
+
current_len = 0
|
|
35
|
+
current.append(word)
|
|
36
|
+
current_len += word_len
|
|
37
|
+
if current:
|
|
38
|
+
parts.append(" ".join(current))
|
|
39
|
+
return parts
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse(path, config: dict | None = None) -> ParseResult:
|
|
43
|
+
config = config or {}
|
|
44
|
+
max_unit_chars = int(config.get("max_unit_chars", DEFAULT_MAX_UNIT_CHARS))
|
|
45
|
+
|
|
46
|
+
text = path.read_text(encoding="utf-8")
|
|
47
|
+
lines = text.splitlines()
|
|
48
|
+
|
|
49
|
+
units: list[ParsedChunkUnit] = []
|
|
50
|
+
heading_path: list[str] = []
|
|
51
|
+
heading_occurrence: dict[str, int] = {}
|
|
52
|
+
heading_key = "root#0"
|
|
53
|
+
para_index = 0
|
|
54
|
+
para_lines: list[str] = []
|
|
55
|
+
para_start_line: int | None = None
|
|
56
|
+
|
|
57
|
+
def flush_paragraph(end_line: int) -> None:
|
|
58
|
+
nonlocal para_lines, para_start_line, para_index
|
|
59
|
+
if not para_lines:
|
|
60
|
+
return
|
|
61
|
+
body = "\n".join(para_lines).strip()
|
|
62
|
+
para_lines = []
|
|
63
|
+
start_line = para_start_line
|
|
64
|
+
para_start_line = None
|
|
65
|
+
if not body:
|
|
66
|
+
return
|
|
67
|
+
for sub_index, sub_body in enumerate(_split_oversized(body, max_unit_chars)):
|
|
68
|
+
key = f"{heading_key}:p{para_index:04d}"
|
|
69
|
+
if sub_index:
|
|
70
|
+
key = f"{key}:sub{sub_index:02d}"
|
|
71
|
+
units.append(
|
|
72
|
+
ParsedChunkUnit(
|
|
73
|
+
text=sub_body,
|
|
74
|
+
logical_key=key,
|
|
75
|
+
heading_path=list(heading_path),
|
|
76
|
+
symbol=None,
|
|
77
|
+
content_type="body",
|
|
78
|
+
physical_page=None,
|
|
79
|
+
line_start=start_line,
|
|
80
|
+
line_end=end_line,
|
|
81
|
+
locator={},
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
para_index += 1
|
|
85
|
+
|
|
86
|
+
for line_no, raw_line in enumerate(lines, start=1):
|
|
87
|
+
stripped = raw_line.strip()
|
|
88
|
+
heading_match = _HEADING_RE.match(raw_line)
|
|
89
|
+
if heading_match:
|
|
90
|
+
flush_paragraph(line_no - 1)
|
|
91
|
+
level = len(heading_match.group(1))
|
|
92
|
+
title = heading_match.group(2).strip()
|
|
93
|
+
heading_path = heading_path[: level - 1] + [title]
|
|
94
|
+
key_base = "/".join(heading_path)
|
|
95
|
+
occurrence = heading_occurrence.get(key_base, 0)
|
|
96
|
+
heading_occurrence[key_base] = occurrence + 1
|
|
97
|
+
heading_key = f"{key_base}#{occurrence}"
|
|
98
|
+
para_index = 0
|
|
99
|
+
units.append(
|
|
100
|
+
ParsedChunkUnit(
|
|
101
|
+
text=title,
|
|
102
|
+
logical_key=f"{heading_key}:heading",
|
|
103
|
+
heading_path=list(heading_path),
|
|
104
|
+
symbol=None,
|
|
105
|
+
content_type="heading",
|
|
106
|
+
physical_page=None,
|
|
107
|
+
line_start=line_no,
|
|
108
|
+
line_end=line_no,
|
|
109
|
+
locator={},
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
continue
|
|
113
|
+
if not stripped:
|
|
114
|
+
flush_paragraph(line_no - 1)
|
|
115
|
+
continue
|
|
116
|
+
if para_start_line is None:
|
|
117
|
+
para_start_line = line_no
|
|
118
|
+
para_lines.append(raw_line)
|
|
119
|
+
flush_paragraph(len(lines))
|
|
120
|
+
|
|
121
|
+
layout_units = [
|
|
122
|
+
{
|
|
123
|
+
"text": unit.text,
|
|
124
|
+
"logical_key": unit.logical_key,
|
|
125
|
+
"heading_path": unit.heading_path,
|
|
126
|
+
"content_type": unit.content_type,
|
|
127
|
+
"line_start": unit.line_start,
|
|
128
|
+
"line_end": unit.line_end,
|
|
129
|
+
}
|
|
130
|
+
for unit in units
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
page = ParsedPage(
|
|
134
|
+
physical_page=1,
|
|
135
|
+
printed_label=None,
|
|
136
|
+
text=text,
|
|
137
|
+
extraction_method=NAME,
|
|
138
|
+
extraction_status="ok" if units else "empty",
|
|
139
|
+
section_candidates=[u.text for u in units if u.content_type == "heading"],
|
|
140
|
+
layout={"units": layout_units},
|
|
141
|
+
)
|
|
142
|
+
return ParseResult(pages=[page], units=units)
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""PyMuPDF-backed PDF parser: page-, block-, and layout-aware extraction.
|
|
2
|
+
|
|
3
|
+
All PyMuPDF interaction (text extraction, page rendering, image crops) is
|
|
4
|
+
kept behind this module so the database and command contracts never depend
|
|
5
|
+
on the specific PDF backend.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import subprocess
|
|
12
|
+
import tempfile
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import pymupdf
|
|
17
|
+
|
|
18
|
+
from .base import ParsedChunkUnit, ParsedPage, ParseResult
|
|
19
|
+
|
|
20
|
+
NAME = "pdf-pymupdf"
|
|
21
|
+
HEADING_MAX_CHARS = 70
|
|
22
|
+
|
|
23
|
+
# A page whose extracted text is shorter than this is treated as a scanned
|
|
24
|
+
# page without a text layer and goes through the OCR fallback. 20 keeps a
|
|
25
|
+
# near-blank title page out of OCR while staying above the few stray
|
|
26
|
+
# characters PyMuPDF can extract from a rasterised page's metadata.
|
|
27
|
+
MIN_EXTRACTED_TEXT_CHARS = 20
|
|
28
|
+
DEFAULT_OCR_LANG = "eng"
|
|
29
|
+
OCR_DPI = 300
|
|
30
|
+
TESSERACT_CMD = "tesseract"
|
|
31
|
+
|
|
32
|
+
# A text block is "nearby" a figure crop when it overlaps the crop
|
|
33
|
+
# horizontally (with tolerance, since captions are often narrower than the
|
|
34
|
+
# image) and sits within 72pt (1 inch) above or below it: close enough for a
|
|
35
|
+
# caption without pulling in body text further down the page.
|
|
36
|
+
NEARBY_TEXT_MAX_VERTICAL_DISTANCE = 72.0
|
|
37
|
+
NEARBY_TEXT_HORIZONTAL_TOLERANCE = 20.0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class RenderedAsset:
|
|
42
|
+
physical_page: int
|
|
43
|
+
asset_type: str
|
|
44
|
+
image_bytes: bytes
|
|
45
|
+
ext: str
|
|
46
|
+
width: int
|
|
47
|
+
height: int
|
|
48
|
+
bbox: tuple[float, float, float, float] | None
|
|
49
|
+
label: str | None = None
|
|
50
|
+
caption: str | None = None
|
|
51
|
+
nearby_text: str | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _looks_like_heading(text: str) -> bool:
|
|
55
|
+
stripped = text.strip()
|
|
56
|
+
return (
|
|
57
|
+
bool(stripped)
|
|
58
|
+
and len(stripped) <= HEADING_MAX_CHARS
|
|
59
|
+
and not stripped.endswith((".", ",", ";"))
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _blocks_for_page(page: pymupdf.Page) -> list[dict]:
|
|
64
|
+
raw_blocks = page.get_text("blocks")
|
|
65
|
+
text_blocks = [b for b in raw_blocks if b[6] == 0 and b[4].strip()]
|
|
66
|
+
text_blocks.sort(key=lambda b: (round(b[1], 1), round(b[0], 1)))
|
|
67
|
+
|
|
68
|
+
blocks: list[dict] = []
|
|
69
|
+
heading_path: list[str] = []
|
|
70
|
+
offset = 0
|
|
71
|
+
for block in text_blocks:
|
|
72
|
+
x0, y0, x1, y1, text, _block_no, _block_type = block
|
|
73
|
+
text = text.strip()
|
|
74
|
+
is_heading = _looks_like_heading(text)
|
|
75
|
+
if is_heading:
|
|
76
|
+
heading_path = [text]
|
|
77
|
+
start = offset
|
|
78
|
+
end = start + len(text)
|
|
79
|
+
offset = end + 2 # matches the "\n\n" join used to build page text
|
|
80
|
+
blocks.append(
|
|
81
|
+
{
|
|
82
|
+
"text": text,
|
|
83
|
+
"bbox": [x0, y0, x1, y1],
|
|
84
|
+
"heading_path": list(heading_path),
|
|
85
|
+
"content_type": "heading" if is_heading else "body",
|
|
86
|
+
"start_offset": start,
|
|
87
|
+
"end_offset": end,
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
return blocks
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _nearby_text_for_bbox(
|
|
94
|
+
blocks: list[dict],
|
|
95
|
+
bbox: tuple[float, float, float, float],
|
|
96
|
+
*,
|
|
97
|
+
max_vertical_distance: float = NEARBY_TEXT_MAX_VERTICAL_DISTANCE,
|
|
98
|
+
horizontal_tolerance: float = NEARBY_TEXT_HORIZONTAL_TOLERANCE,
|
|
99
|
+
) -> str | None:
|
|
100
|
+
"""Find text blocks (from the page's already-computed
|
|
101
|
+
:func:`_blocks_for_page` output) vertically close to a figure crop's
|
|
102
|
+
bbox -- typically a caption directly above or below the figure -- and
|
|
103
|
+
join their text in reading order. Returns ``None`` if nothing qualifies.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
x0, _y0, x1, _y1 = bbox
|
|
107
|
+
matches: list[tuple[float, str]] = []
|
|
108
|
+
for block in blocks:
|
|
109
|
+
bx0, by0, bx1, by1 = block["bbox"]
|
|
110
|
+
horizontally_overlaps = (
|
|
111
|
+
bx0 <= x1 + horizontal_tolerance and bx1 >= x0 - horizontal_tolerance
|
|
112
|
+
)
|
|
113
|
+
if not horizontally_overlaps:
|
|
114
|
+
continue
|
|
115
|
+
if by1 < bbox[1]:
|
|
116
|
+
vertical_distance = bbox[1] - by1
|
|
117
|
+
elif by0 > bbox[3]:
|
|
118
|
+
vertical_distance = by0 - bbox[3]
|
|
119
|
+
else:
|
|
120
|
+
vertical_distance = 0.0
|
|
121
|
+
if vertical_distance <= max_vertical_distance:
|
|
122
|
+
matches.append((by0, block["text"]))
|
|
123
|
+
if not matches:
|
|
124
|
+
return None
|
|
125
|
+
matches.sort(key=lambda item: item[0])
|
|
126
|
+
return "\n".join(text for _, text in matches)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _pymupdf_ocr_text(page: pymupdf.Page, *, lang: str, dpi: int) -> str:
|
|
130
|
+
"""First OCR fallback stage: PyMuPDF's own built-in OCR support."""
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
textpage = page.get_textpage_ocr(
|
|
134
|
+
flags=pymupdf.TEXTFLAGS_TEXT | pymupdf.TEXT_PRESERVE_WHITESPACE,
|
|
135
|
+
language=lang,
|
|
136
|
+
dpi=dpi,
|
|
137
|
+
full=True,
|
|
138
|
+
)
|
|
139
|
+
return page.get_text("text", textpage=textpage).strip()
|
|
140
|
+
except (RuntimeError, OSError):
|
|
141
|
+
# PyMuPDF's OCR path raises if Tesseract/tessdata is not resolvable
|
|
142
|
+
# in this environment; treat that as "no text", not a hard failure,
|
|
143
|
+
# so the caller can still try the Tesseract-CLI stage.
|
|
144
|
+
return ""
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _tesseract_cli_ocr_text(page: pymupdf.Page, *, lang: str, dpi: int) -> str:
|
|
148
|
+
"""Second OCR fallback stage: run the Tesseract CLI on a rendered page image."""
|
|
149
|
+
|
|
150
|
+
pixmap = page.get_pixmap(dpi=dpi)
|
|
151
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
152
|
+
image_path = Path(tmp_dir) / "page.png"
|
|
153
|
+
pixmap.save(str(image_path))
|
|
154
|
+
try:
|
|
155
|
+
result = subprocess.run(
|
|
156
|
+
[TESSERACT_CMD, str(image_path), "stdout", "-l", lang],
|
|
157
|
+
capture_output=True,
|
|
158
|
+
text=True,
|
|
159
|
+
check=True,
|
|
160
|
+
)
|
|
161
|
+
except (OSError, subprocess.CalledProcessError):
|
|
162
|
+
return ""
|
|
163
|
+
return result.stdout.strip()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _ocr_fallback_text(page: pymupdf.Page, *, lang: str, dpi: int) -> tuple[str, str]:
|
|
167
|
+
"""Two-stage OCR fallback for a page whose normal text extraction came
|
|
168
|
+
back effectively empty: try PyMuPDF's built-in OCR first, and only if
|
|
169
|
+
that still returns too little text, fall back to the Tesseract CLI.
|
|
170
|
+
|
|
171
|
+
Returns ``(text, extraction_method)`` so the caller can record which
|
|
172
|
+
stage (if any) actually produced usable text.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
pymupdf_text = _pymupdf_ocr_text(page, lang=lang, dpi=dpi)
|
|
176
|
+
if len(pymupdf_text) >= MIN_EXTRACTED_TEXT_CHARS:
|
|
177
|
+
return pymupdf_text, f"{NAME}-ocr-pymupdf"
|
|
178
|
+
|
|
179
|
+
tesseract_text = _tesseract_cli_ocr_text(page, lang=lang, dpi=dpi)
|
|
180
|
+
if len(tesseract_text) > len(pymupdf_text):
|
|
181
|
+
return tesseract_text, f"{NAME}-ocr-tesseract-cli"
|
|
182
|
+
return pymupdf_text, f"{NAME}-ocr-pymupdf"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def parse(path: Path, config: dict | None = None) -> ParseResult:
|
|
186
|
+
config = config or {}
|
|
187
|
+
ocr_lang = config.get("ocr_lang", DEFAULT_OCR_LANG)
|
|
188
|
+
pages: list[ParsedPage] = []
|
|
189
|
+
units: list[ParsedChunkUnit] = []
|
|
190
|
+
|
|
191
|
+
with pymupdf.open(path) as document:
|
|
192
|
+
for index, page in enumerate(document):
|
|
193
|
+
physical_page = index + 1
|
|
194
|
+
blocks = _blocks_for_page(page)
|
|
195
|
+
page_text = "\n\n".join(block["text"] for block in blocks)
|
|
196
|
+
extraction_method = NAME
|
|
197
|
+
|
|
198
|
+
if len(page_text.strip()) < MIN_EXTRACTED_TEXT_CHARS:
|
|
199
|
+
ocr_text, ocr_method = _ocr_fallback_text(
|
|
200
|
+
page, lang=ocr_lang, dpi=OCR_DPI
|
|
201
|
+
)
|
|
202
|
+
ocr_text = ocr_text.strip()
|
|
203
|
+
if len(ocr_text) > len(page_text.strip()):
|
|
204
|
+
page_rect = page.rect
|
|
205
|
+
blocks = [
|
|
206
|
+
{
|
|
207
|
+
"text": ocr_text,
|
|
208
|
+
"bbox": [
|
|
209
|
+
page_rect.x0,
|
|
210
|
+
page_rect.y0,
|
|
211
|
+
page_rect.x1,
|
|
212
|
+
page_rect.y1,
|
|
213
|
+
],
|
|
214
|
+
"heading_path": [],
|
|
215
|
+
"content_type": "body",
|
|
216
|
+
"start_offset": 0,
|
|
217
|
+
"end_offset": len(ocr_text),
|
|
218
|
+
}
|
|
219
|
+
]
|
|
220
|
+
page_text = ocr_text
|
|
221
|
+
extraction_method = ocr_method
|
|
222
|
+
|
|
223
|
+
section_candidates = [
|
|
224
|
+
b["text"] for b in blocks if b["content_type"] == "heading"
|
|
225
|
+
]
|
|
226
|
+
|
|
227
|
+
pages.append(
|
|
228
|
+
ParsedPage(
|
|
229
|
+
physical_page=physical_page,
|
|
230
|
+
printed_label=None,
|
|
231
|
+
text=page_text,
|
|
232
|
+
extraction_method=extraction_method,
|
|
233
|
+
extraction_status="ok" if blocks else "empty",
|
|
234
|
+
section_candidates=section_candidates,
|
|
235
|
+
layout={"blocks": blocks},
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
units.extend(_block_units(physical_page, blocks))
|
|
239
|
+
|
|
240
|
+
return ParseResult(pages=pages, units=units)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _block_units(physical_page: int, blocks: list[dict]) -> list[ParsedChunkUnit]:
|
|
244
|
+
return [
|
|
245
|
+
ParsedChunkUnit(
|
|
246
|
+
text=block["text"],
|
|
247
|
+
logical_key=f"page{physical_page:04d}-block{block_index:03d}",
|
|
248
|
+
heading_path=block["heading_path"],
|
|
249
|
+
symbol=None,
|
|
250
|
+
content_type=block["content_type"],
|
|
251
|
+
physical_page=physical_page,
|
|
252
|
+
page_start_offset=block["start_offset"],
|
|
253
|
+
page_end_offset=block["end_offset"],
|
|
254
|
+
locator={"bbox": block["bbox"]},
|
|
255
|
+
)
|
|
256
|
+
for block_index, block in enumerate(blocks)
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def units_from_page_layout(
|
|
261
|
+
physical_page: int, layout_json: str | None
|
|
262
|
+
) -> list[ParsedChunkUnit]:
|
|
263
|
+
"""Rebuild chunk-input units from retained ``pages.layout_json``.
|
|
264
|
+
|
|
265
|
+
A chunker- or normalisation-only change rechunks from this layout
|
|
266
|
+
without reopening the PDF.
|
|
267
|
+
"""
|
|
268
|
+
return _block_units(
|
|
269
|
+
physical_page, json.loads(layout_json)["blocks"] if layout_json else []
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def render_page(path: Path, physical_page: int, zoom: float = 2.0) -> RenderedAsset:
|
|
274
|
+
with pymupdf.open(path) as document:
|
|
275
|
+
page = document[physical_page - 1]
|
|
276
|
+
matrix = pymupdf.Matrix(zoom, zoom)
|
|
277
|
+
pixmap = page.get_pixmap(matrix=matrix)
|
|
278
|
+
return RenderedAsset(
|
|
279
|
+
physical_page=physical_page,
|
|
280
|
+
asset_type="page-render",
|
|
281
|
+
image_bytes=pixmap.tobytes("png"),
|
|
282
|
+
ext="png",
|
|
283
|
+
width=pixmap.width,
|
|
284
|
+
height=pixmap.height,
|
|
285
|
+
bbox=None,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def render_figure_crops(path: Path, physical_page: int) -> list[RenderedAsset]:
|
|
290
|
+
rendered: list[RenderedAsset] = []
|
|
291
|
+
with pymupdf.open(path) as document:
|
|
292
|
+
page = document[physical_page - 1]
|
|
293
|
+
blocks = _blocks_for_page(page)
|
|
294
|
+
for image_index, image in enumerate(page.get_images(full=True)):
|
|
295
|
+
xref = image[0]
|
|
296
|
+
info = document.extract_image(xref)
|
|
297
|
+
rects = page.get_image_rects(xref)
|
|
298
|
+
bbox = tuple(rects[0]) if rects else None
|
|
299
|
+
nearby_text = (
|
|
300
|
+
_nearby_text_for_bbox(blocks, bbox) if bbox is not None else None
|
|
301
|
+
)
|
|
302
|
+
rendered.append(
|
|
303
|
+
RenderedAsset(
|
|
304
|
+
physical_page=physical_page,
|
|
305
|
+
asset_type="figure-crop",
|
|
306
|
+
image_bytes=info["image"],
|
|
307
|
+
ext=info["ext"],
|
|
308
|
+
width=info.get("width", 0),
|
|
309
|
+
height=info.get("height", 0),
|
|
310
|
+
bbox=bbox,
|
|
311
|
+
label=f"page-{physical_page:03d}-image-{image_index:02d}",
|
|
312
|
+
nearby_text=nearby_text,
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
return rendered
|