llmPDF 0.7.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.
- llmpdf/__init__.py +36 -0
- llmpdf/__main__.py +3 -0
- llmpdf/agent_scheduler.py +221 -0
- llmpdf/assets_task.py +174 -0
- llmpdf/batch.py +418 -0
- llmpdf/cli.py +472 -0
- llmpdf/detection_task.py +543 -0
- llmpdf/docling_task.py +342 -0
- llmpdf/docling_worker.py +37 -0
- llmpdf/extraction_task.py +371 -0
- llmpdf/image_analysis_task.py +1210 -0
- llmpdf/images_task.py +122 -0
- llmpdf/io_utils.py +62 -0
- llmpdf/merge_rules/__init__.py +9 -0
- llmpdf/merge_rules/find_image_absorbed_blocks.py +29 -0
- llmpdf/merge_rules/find_table_absorbed_blocks.py +18 -0
- llmpdf/merge_rules/is_protected_block.py +6 -0
- llmpdf/merge_task.py +639 -0
- llmpdf/metrics_task.py +236 -0
- llmpdf/models.py +150 -0
- llmpdf/pages.py +69 -0
- llmpdf/pi_runtime.py +5 -0
- llmpdf/pipeline.py +164 -0
- llmpdf/preflight.py +97 -0
- llmpdf/progress.py +12 -0
- llmpdf/rerun.py +205 -0
- llmpdf/retention.py +185 -0
- llmpdf/review.py +43 -0
- llmpdf/review_discovery.py +54 -0
- llmpdf/review_project.py +42 -0
- llmpdf/review_server.py +114 -0
- llmpdf/review_source.py +431 -0
- llmpdf/review_static/assets/index-Ch2-xtmb.js +90 -0
- llmpdf/review_static/assets/index-DYQ2zsXw.css +1 -0
- llmpdf/review_static/assets/pdf.worker.min-Dswkl-cV.mjs +29 -0
- llmpdf/review_static/index.html +14 -0
- llmpdf/review_tables.py +101 -0
- llmpdf/run_status.py +206 -0
- llmpdf/screenshots_task.py +151 -0
- llmpdf/sdk.py +477 -0
- llmpdf/table/__init__.py +3 -0
- llmpdf/table/__main__.py +5 -0
- llmpdf/table/agent_runner.py +229 -0
- llmpdf/table/cli.py +176 -0
- llmpdf/table/io_utils.py +64 -0
- llmpdf/table/models.py +49 -0
- llmpdf/table/orchestration.py +436 -0
- llmpdf/table/pi_runtime.py +65 -0
- llmpdf/table/prepare.py +222 -0
- llmpdf/table/prompt.py +253 -0
- llmpdf/table/run_all.py +63 -0
- llmpdf/table/runner.py +41 -0
- llmpdf/table/runtime.py +170 -0
- llmpdf/table/table_guard.py +230 -0
- llmpdf/task.py +117 -0
- llmpdf/validate_task.py +309 -0
- llmpdf-0.7.0.dist-info/METADATA +488 -0
- llmpdf-0.7.0.dist-info/RECORD +62 -0
- llmpdf-0.7.0.dist-info/WHEEL +5 -0
- llmpdf-0.7.0.dist-info/entry_points.txt +3 -0
- llmpdf-0.7.0.dist-info/licenses/LICENSE +103 -0
- llmpdf-0.7.0.dist-info/top_level.txt +1 -0
llmpdf/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Task-oriented llmPDF conversion pipeline."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("llmPDF")
|
|
7
|
+
except PackageNotFoundError: # Running directly from an unpacked source tree.
|
|
8
|
+
__version__ = "0+unknown"
|
|
9
|
+
|
|
10
|
+
from .sdk import (
|
|
11
|
+
BillingSummary,
|
|
12
|
+
ConfigurationError,
|
|
13
|
+
ConversionError,
|
|
14
|
+
ConversionResult,
|
|
15
|
+
ConvertOptions,
|
|
16
|
+
DocumentConverterProtocol,
|
|
17
|
+
TaskExecutionError,
|
|
18
|
+
TokenUsage,
|
|
19
|
+
UsageSummary,
|
|
20
|
+
convert,
|
|
21
|
+
)
|
|
22
|
+
from .rerun import rerun_tables
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"BillingSummary",
|
|
26
|
+
"ConfigurationError",
|
|
27
|
+
"ConversionError",
|
|
28
|
+
"ConversionResult",
|
|
29
|
+
"ConvertOptions",
|
|
30
|
+
"DocumentConverterProtocol",
|
|
31
|
+
"TaskExecutionError",
|
|
32
|
+
"TokenUsage",
|
|
33
|
+
"UsageSummary",
|
|
34
|
+
"convert",
|
|
35
|
+
"rerun_tables",
|
|
36
|
+
]
|
llmpdf/__main__.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from collections import deque
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from concurrent.futures import Executor, Future
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
TASK_ORDER = ("find", "cross_table", "table", "image")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class _QueuedTask:
|
|
17
|
+
future: Future
|
|
18
|
+
kind: str
|
|
19
|
+
label: str
|
|
20
|
+
callback: Callable[..., Any]
|
|
21
|
+
args: tuple[Any, ...]
|
|
22
|
+
kwargs: dict[str, Any]
|
|
23
|
+
submitted_at: float
|
|
24
|
+
sequence: int
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PriorityAgentExecutor(Executor):
|
|
28
|
+
"""Fixed worker pool backed by ordered, per-task-type FIFO queues."""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
max_workers: int = 5,
|
|
33
|
+
*,
|
|
34
|
+
kind_limits: dict[str, int] | None = None,
|
|
35
|
+
progress: Callable[[str], None] | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
if not 1 <= max_workers <= 5:
|
|
38
|
+
raise ValueError("max_workers must be between 1 and 5")
|
|
39
|
+
self.max_workers = max_workers
|
|
40
|
+
configured_limits = kind_limits or {}
|
|
41
|
+
self._kind_limits = {
|
|
42
|
+
"find": max(1, int(configured_limits.get("find", max_workers))),
|
|
43
|
+
"table": max(1, int(configured_limits.get("table", max_workers))),
|
|
44
|
+
"image": max(1, int(configured_limits.get("image", max_workers))),
|
|
45
|
+
}
|
|
46
|
+
self._progress = progress
|
|
47
|
+
self._queues: dict[str, deque[_QueuedTask]] = {
|
|
48
|
+
kind: deque() for kind in TASK_ORDER
|
|
49
|
+
}
|
|
50
|
+
self._condition = threading.Condition()
|
|
51
|
+
self._shutdown = False
|
|
52
|
+
self._sequence = 0
|
|
53
|
+
self._running: dict[int, _QueuedTask] = {}
|
|
54
|
+
self._peak_running = 0
|
|
55
|
+
self._completed = 0
|
|
56
|
+
self._records: list[dict[str, Any]] = []
|
|
57
|
+
self._threads = [
|
|
58
|
+
threading.Thread(
|
|
59
|
+
target=self._worker,
|
|
60
|
+
name=f"pdf-agent-{index + 1}",
|
|
61
|
+
daemon=True,
|
|
62
|
+
)
|
|
63
|
+
for index in range(max_workers)
|
|
64
|
+
]
|
|
65
|
+
for thread in self._threads:
|
|
66
|
+
thread.start()
|
|
67
|
+
self._emit(f"queue started: workers={max_workers}")
|
|
68
|
+
|
|
69
|
+
def submit(self, fn, /, *args, **kwargs): # type: ignore[override]
|
|
70
|
+
return self.submit_task("table", getattr(fn, "__name__", "table"), fn, *args, **kwargs)
|
|
71
|
+
|
|
72
|
+
def submit_task(
|
|
73
|
+
self,
|
|
74
|
+
kind: str,
|
|
75
|
+
label: str,
|
|
76
|
+
fn: Callable[..., Any],
|
|
77
|
+
/,
|
|
78
|
+
*args: Any,
|
|
79
|
+
**kwargs: Any,
|
|
80
|
+
) -> Future:
|
|
81
|
+
if kind not in self._queues:
|
|
82
|
+
raise ValueError(f"Unknown Agent task type: {kind}")
|
|
83
|
+
future: Future = Future()
|
|
84
|
+
with self._condition:
|
|
85
|
+
if self._shutdown:
|
|
86
|
+
raise RuntimeError("cannot schedule new futures after shutdown")
|
|
87
|
+
self._sequence += 1
|
|
88
|
+
task = _QueuedTask(
|
|
89
|
+
future=future,
|
|
90
|
+
kind=kind,
|
|
91
|
+
label=label,
|
|
92
|
+
callback=fn,
|
|
93
|
+
args=args,
|
|
94
|
+
kwargs=kwargs,
|
|
95
|
+
submitted_at=time.monotonic(),
|
|
96
|
+
sequence=self._sequence,
|
|
97
|
+
)
|
|
98
|
+
self._queues[kind].append(task)
|
|
99
|
+
state = self._state_locked()
|
|
100
|
+
self._condition.notify()
|
|
101
|
+
self._emit(f"queued {kind} {label}; {state}")
|
|
102
|
+
return future
|
|
103
|
+
|
|
104
|
+
def _running_count_locked(self, kind: str) -> int:
|
|
105
|
+
if kind in {"cross_table", "table"}:
|
|
106
|
+
return sum(
|
|
107
|
+
task.kind in {"cross_table", "table"}
|
|
108
|
+
for task in self._running.values()
|
|
109
|
+
)
|
|
110
|
+
return sum(task.kind == kind for task in self._running.values())
|
|
111
|
+
|
|
112
|
+
def _can_run_locked(self, kind: str) -> bool:
|
|
113
|
+
limit_kind = "table" if kind == "cross_table" else kind
|
|
114
|
+
return self._running_count_locked(kind) < self._kind_limits[limit_kind]
|
|
115
|
+
|
|
116
|
+
def _next_locked(self) -> _QueuedTask | None:
|
|
117
|
+
for kind in TASK_ORDER:
|
|
118
|
+
if self._queues[kind] and self._can_run_locked(kind):
|
|
119
|
+
return self._queues[kind].popleft()
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
def _has_queued_locked(self) -> bool:
|
|
123
|
+
return any(self._queues[kind] for kind in TASK_ORDER)
|
|
124
|
+
|
|
125
|
+
def _has_runnable_locked(self) -> bool:
|
|
126
|
+
return any(
|
|
127
|
+
self._queues[kind] and self._can_run_locked(kind)
|
|
128
|
+
for kind in TASK_ORDER
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def _state_locked(self) -> str:
|
|
132
|
+
ready = " ".join(
|
|
133
|
+
f"{kind}={len(self._queues[kind])}" for kind in TASK_ORDER
|
|
134
|
+
)
|
|
135
|
+
discovered = self._completed + len(self._running) + sum(
|
|
136
|
+
len(queue) for queue in self._queues.values()
|
|
137
|
+
)
|
|
138
|
+
return (
|
|
139
|
+
f"running={len(self._running)}/{self.max_workers} "
|
|
140
|
+
f"ready[{ready}] completed={self._completed} discovered={discovered}"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def _emit(self, message: str) -> None:
|
|
144
|
+
if self._progress is not None:
|
|
145
|
+
self._progress(f"[scheduler] {message}")
|
|
146
|
+
|
|
147
|
+
def _worker(self) -> None:
|
|
148
|
+
while True:
|
|
149
|
+
with self._condition:
|
|
150
|
+
self._condition.wait_for(
|
|
151
|
+
lambda: self._has_runnable_locked()
|
|
152
|
+
or (self._shutdown and not self._has_queued_locked())
|
|
153
|
+
)
|
|
154
|
+
task = self._next_locked()
|
|
155
|
+
if task is None:
|
|
156
|
+
if self._shutdown and not self._has_queued_locked():
|
|
157
|
+
return
|
|
158
|
+
continue
|
|
159
|
+
if not task.future.set_running_or_notify_cancel():
|
|
160
|
+
continue
|
|
161
|
+
worker_id = threading.get_ident()
|
|
162
|
+
self._running[worker_id] = task
|
|
163
|
+
self._peak_running = max(self._peak_running, len(self._running))
|
|
164
|
+
state = self._state_locked()
|
|
165
|
+
self._emit(f"started {task.kind} {task.label}; {state}")
|
|
166
|
+
started_at = time.monotonic()
|
|
167
|
+
try:
|
|
168
|
+
result = task.callback(*task.args, **task.kwargs)
|
|
169
|
+
except BaseException as error:
|
|
170
|
+
task.future.set_exception(error)
|
|
171
|
+
status = "failed"
|
|
172
|
+
else:
|
|
173
|
+
task.future.set_result(result)
|
|
174
|
+
status = "completed"
|
|
175
|
+
finished_at = time.monotonic()
|
|
176
|
+
with self._condition:
|
|
177
|
+
self._running.pop(worker_id, None)
|
|
178
|
+
self._completed += 1
|
|
179
|
+
self._records.append(
|
|
180
|
+
{
|
|
181
|
+
"sequence": task.sequence,
|
|
182
|
+
"type": task.kind,
|
|
183
|
+
"label": task.label,
|
|
184
|
+
"status": status,
|
|
185
|
+
"queue_wait_seconds": round(started_at - task.submitted_at, 3),
|
|
186
|
+
"run_seconds": round(finished_at - started_at, 3),
|
|
187
|
+
}
|
|
188
|
+
)
|
|
189
|
+
state = self._state_locked()
|
|
190
|
+
self._condition.notify_all()
|
|
191
|
+
self._emit(
|
|
192
|
+
f"{status} {task.kind} {task.label} in "
|
|
193
|
+
f"{finished_at - started_at:.1f}s; {state}"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def snapshot(self) -> dict[str, Any]:
|
|
197
|
+
with self._condition:
|
|
198
|
+
return {
|
|
199
|
+
"task_order": list(TASK_ORDER),
|
|
200
|
+
"max_workers": self.max_workers,
|
|
201
|
+
"kind_limits": dict(self._kind_limits),
|
|
202
|
+
"completed": self._completed,
|
|
203
|
+
"running": len(self._running),
|
|
204
|
+
"peak_running": self._peak_running,
|
|
205
|
+
"ready": {
|
|
206
|
+
kind: len(self._queues[kind]) for kind in TASK_ORDER
|
|
207
|
+
},
|
|
208
|
+
"tasks": list(self._records),
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
def shutdown(self, wait: bool = True, *, cancel_futures: bool = False) -> None:
|
|
212
|
+
with self._condition:
|
|
213
|
+
self._shutdown = True
|
|
214
|
+
if cancel_futures:
|
|
215
|
+
for queue in self._queues.values():
|
|
216
|
+
while queue:
|
|
217
|
+
queue.popleft().future.cancel()
|
|
218
|
+
self._condition.notify_all()
|
|
219
|
+
if wait:
|
|
220
|
+
for thread in self._threads:
|
|
221
|
+
thread.join()
|
llmpdf/assets_task.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from .io_utils import copy_file, read_json, relativize, sha256_file, write_json
|
|
11
|
+
from .models import BBox, PipelineConfig, TaskResult
|
|
12
|
+
from .task import PipelineTask
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def infer_header_rows(name: str | None) -> int:
|
|
16
|
+
normalized = (name or "").casefold()
|
|
17
|
+
headerless_terms = ("abkürz", "abbreviation", "glossar", "glossary")
|
|
18
|
+
return 0 if any(term in normalized for term in headerless_terms) else 1
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def csv_to_markdown(
|
|
22
|
+
csv_path: Path, title: str | None, header_rows: int | None = None
|
|
23
|
+
) -> str:
|
|
24
|
+
with csv_path.open(encoding="utf-8-sig", newline="") as stream:
|
|
25
|
+
rows = list(csv.reader(stream))
|
|
26
|
+
if not rows:
|
|
27
|
+
return ""
|
|
28
|
+
width = max(len(row) for row in rows)
|
|
29
|
+
rows = [row + [""] * (width - len(row)) for row in rows]
|
|
30
|
+
|
|
31
|
+
def clean(value: str) -> str:
|
|
32
|
+
return re.sub(r"\s*[\r\n]+\s*", " ", value).replace("|", "\\|")
|
|
33
|
+
|
|
34
|
+
lines = []
|
|
35
|
+
if title:
|
|
36
|
+
lines.extend([f"**{title.strip()}**", ""])
|
|
37
|
+
header_rows = infer_header_rows(title) if header_rows is None else header_rows
|
|
38
|
+
header = rows[0] if header_rows else [""] * width
|
|
39
|
+
body = rows[1:] if header_rows else rows
|
|
40
|
+
lines.append("| " + " | ".join(clean(value) for value in header) + " |")
|
|
41
|
+
lines.append("| " + " | ".join("---" for _ in range(width)) + " |")
|
|
42
|
+
lines.extend(
|
|
43
|
+
"| " + " | ".join(clean(value) for value in row) + " |" for row in body
|
|
44
|
+
)
|
|
45
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CollectAssetsTask(PipelineTask):
|
|
49
|
+
name = "06-collect-assets"
|
|
50
|
+
dependencies = ("05-extract-tables",)
|
|
51
|
+
|
|
52
|
+
def signature_payload(self, config: PipelineConfig) -> dict:
|
|
53
|
+
value = super().signature_payload(config)
|
|
54
|
+
value["asset_logic_version"] = 6
|
|
55
|
+
summary = config.work_dir / "table-extraction" / "summary.json"
|
|
56
|
+
if summary.is_file():
|
|
57
|
+
value["extraction_summary_sha256"] = sha256_file(summary)
|
|
58
|
+
extraction = read_json(summary)
|
|
59
|
+
artifacts: dict[str, str] = {}
|
|
60
|
+
for relative_directory in extraction.get("table_directories", []):
|
|
61
|
+
directory = config.output_dir / relative_directory
|
|
62
|
+
for pattern in ("output_*.csv", "extract.py", "metadata.yaml"):
|
|
63
|
+
for path in sorted(directory.glob(pattern)):
|
|
64
|
+
artifacts[relativize(path, config.output_dir)] = sha256_file(
|
|
65
|
+
path
|
|
66
|
+
)
|
|
67
|
+
value["extraction_artifacts"] = artifacts
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
def run(self, config: PipelineConfig) -> TaskResult:
|
|
71
|
+
extraction = read_json(config.work_dir / "table-extraction" / "summary.json")
|
|
72
|
+
source_dirs = [
|
|
73
|
+
config.output_dir / path for path in extraction.get("table_directories", [])
|
|
74
|
+
]
|
|
75
|
+
continuation_by_leader = {
|
|
76
|
+
int(group["leader_page"]): [int(page) for page in group["pages"]]
|
|
77
|
+
for group in extraction.get("continuation_groups", [])
|
|
78
|
+
}
|
|
79
|
+
records = []
|
|
80
|
+
sortable = []
|
|
81
|
+
for source_dir in source_dirs:
|
|
82
|
+
metadata = yaml.safe_load(
|
|
83
|
+
(source_dir / "metadata.yaml").read_text(encoding="utf-8")
|
|
84
|
+
)
|
|
85
|
+
sortable.append(
|
|
86
|
+
(
|
|
87
|
+
int(metadata["page"]),
|
|
88
|
+
int(metadata["page_table_index"]),
|
|
89
|
+
source_dir,
|
|
90
|
+
metadata,
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
sortable.sort()
|
|
94
|
+
|
|
95
|
+
internal_root = config.work_dir / "table-assets"
|
|
96
|
+
tables_root = config.assets_dir / "tables"
|
|
97
|
+
if config.force:
|
|
98
|
+
for path in (internal_root, tables_root):
|
|
99
|
+
if path.exists():
|
|
100
|
+
shutil.rmtree(path)
|
|
101
|
+
internal_root.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
tables_root.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
for global_index, (page, page_index, source_dir, metadata) in enumerate(
|
|
104
|
+
sortable, 1
|
|
105
|
+
):
|
|
106
|
+
table_id = f"table-{global_index:04d}"
|
|
107
|
+
internal = internal_root / table_id
|
|
108
|
+
internal.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
csv_paths = sorted(source_dir.glob("output_*.csv"))
|
|
110
|
+
if not csv_paths:
|
|
111
|
+
continue
|
|
112
|
+
primary_csv = copy_file(csv_paths[0], tables_root / f"{table_id}.csv")
|
|
113
|
+
extra_csvs = []
|
|
114
|
+
for extra_index, extra in enumerate(csv_paths[1:], 2):
|
|
115
|
+
extra_csvs.append(
|
|
116
|
+
copy_file(extra, tables_root / f"{table_id}-{extra_index}.csv")
|
|
117
|
+
)
|
|
118
|
+
extractor = copy_file(source_dir / "extract.py", internal / "extract.py")
|
|
119
|
+
source_metadata = copy_file(
|
|
120
|
+
source_dir / "metadata.yaml", internal / "metadata.yaml"
|
|
121
|
+
)
|
|
122
|
+
markdown_path = internal / "table.md"
|
|
123
|
+
header_rows = infer_header_rows(metadata.get("name"))
|
|
124
|
+
markdown_path.write_text(
|
|
125
|
+
csv_to_markdown(primary_csv, metadata.get("name"), header_rows),
|
|
126
|
+
encoding="utf-8",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
bbox_data = metadata["bbox"]
|
|
130
|
+
bbox = BBox.from_dict(bbox_data)
|
|
131
|
+
source_pages = [
|
|
132
|
+
int(value) for value in metadata.get("source_pages", [page])
|
|
133
|
+
]
|
|
134
|
+
if source_pages == [page] and page in continuation_by_leader:
|
|
135
|
+
anchored = [
|
|
136
|
+
item
|
|
137
|
+
for item in sortable
|
|
138
|
+
if int(item[0]) == page and f"page-{page:04d}" in item[2].parts
|
|
139
|
+
]
|
|
140
|
+
# Older Agent outputs did not record source_pages. The fallback
|
|
141
|
+
# is safe only when the continuation leader has one anchored table.
|
|
142
|
+
if len(anchored) == 1:
|
|
143
|
+
source_pages = continuation_by_leader[page]
|
|
144
|
+
record = {
|
|
145
|
+
"id": table_id,
|
|
146
|
+
"page": page,
|
|
147
|
+
"source_pages": source_pages,
|
|
148
|
+
"page_table_index": page_index,
|
|
149
|
+
"name": metadata.get("name"),
|
|
150
|
+
"header_rows": header_rows,
|
|
151
|
+
"bbox": {
|
|
152
|
+
"coordinate_system": "pdfplumber_top_left",
|
|
153
|
+
"unit": "pt",
|
|
154
|
+
**bbox.to_dict(),
|
|
155
|
+
},
|
|
156
|
+
"csv": relativize(primary_csv, config.output_dir),
|
|
157
|
+
"extra_csvs": [
|
|
158
|
+
relativize(path, config.output_dir) for path in extra_csvs
|
|
159
|
+
],
|
|
160
|
+
"internal": {
|
|
161
|
+
"markdown": relativize(markdown_path, config.output_dir),
|
|
162
|
+
"extractor": relativize(extractor, config.output_dir),
|
|
163
|
+
"metadata": relativize(source_metadata, config.output_dir),
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
records.append(record)
|
|
167
|
+
manifest = internal_root / "tables.json"
|
|
168
|
+
write_json(manifest, {"schema_version": 2, "tables": records})
|
|
169
|
+
return TaskResult(
|
|
170
|
+
self.name,
|
|
171
|
+
"completed",
|
|
172
|
+
[relativize(manifest, config.output_dir)],
|
|
173
|
+
{"table_count": len(records)},
|
|
174
|
+
)
|