fibrum-pdf 1.2.0__py3-none-win_amd64.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.
- fibrum_pdf/__init__.py +53 -0
- fibrum_pdf/_block_converter.py +153 -0
- fibrum_pdf/_cffi.py +82 -0
- fibrum_pdf/api.py +183 -0
- fibrum_pdf/lib/libgcc_s_seh-1.dll +0 -0
- fibrum_pdf/lib/libmupdf.dll +0 -0
- fibrum_pdf/lib/libtomd.dll +0 -0
- fibrum_pdf/lib/libwinpthread-1.dll +0 -0
- fibrum_pdf/main.py +70 -0
- fibrum_pdf/models.py +85 -0
- fibrum_pdf-1.2.0.dist-info/METADATA +221 -0
- fibrum_pdf-1.2.0.dist-info/RECORD +16 -0
- fibrum_pdf-1.2.0.dist-info/WHEEL +5 -0
- fibrum_pdf-1.2.0.dist-info/entry_points.txt +2 -0
- fibrum_pdf-1.2.0.dist-info/licenses/LICENSE +668 -0
- fibrum_pdf-1.2.0.dist-info/top_level.txt +1 -0
fibrum_pdf/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Fast, structured PDF extraction for Python."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from importlib import metadata
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .api import ConversionResult, ExtractionError, to_json, to_markdown
|
|
11
|
+
from .models import Block, ListItem, Page, Pages, Span, TableCell, TableRow
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Block",
|
|
15
|
+
"ListItem",
|
|
16
|
+
"Page",
|
|
17
|
+
"Pages",
|
|
18
|
+
"Span",
|
|
19
|
+
"TableCell",
|
|
20
|
+
"TableRow",
|
|
21
|
+
"ExtractionError",
|
|
22
|
+
"to_json",
|
|
23
|
+
"to_markdown",
|
|
24
|
+
"ConversionResult",
|
|
25
|
+
"__version__",
|
|
26
|
+
]
|
|
27
|
+
logging.getLogger(__name__).addHandler(logging.NullHandler())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def __getattr__(name: str) -> Any:
|
|
31
|
+
if name in {"to_json", "to_markdown"}:
|
|
32
|
+
from . import api
|
|
33
|
+
|
|
34
|
+
return getattr(api, name)
|
|
35
|
+
if name == "ExtractionError":
|
|
36
|
+
from .api import ExtractionError
|
|
37
|
+
|
|
38
|
+
return ExtractionError
|
|
39
|
+
if name == "ConversionResult":
|
|
40
|
+
from .api import ConversionResult
|
|
41
|
+
|
|
42
|
+
return ConversionResult
|
|
43
|
+
if name in {"Block", "ListItem", "Page", "Pages", "Span", "TableCell", "TableRow"}:
|
|
44
|
+
from . import models
|
|
45
|
+
|
|
46
|
+
return getattr(models, name)
|
|
47
|
+
raise AttributeError(name)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
__version__ = metadata.version("fibrum-pdf")
|
|
52
|
+
except metadata.PackageNotFoundError:
|
|
53
|
+
__version__ = "0.0.0"
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Block to Markdown conversion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
BULLETS = frozenset("•‣⁃⁌⁍∙▪▫●○◦■□▶▸◆◇♦➤\uf0b7\ufffd")
|
|
12
|
+
FMT_MARKERS = ("**", "*", "`", "~~")
|
|
13
|
+
PUNCT = " \n\t.,;:)]/\\-?!"
|
|
14
|
+
STYLES = [
|
|
15
|
+
("bold", "**"),
|
|
16
|
+
("italic", "*"),
|
|
17
|
+
("monospace", "`"),
|
|
18
|
+
("strikeout", "~~"),
|
|
19
|
+
("subscript", "~"),
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _style_span(span: dict[str, Any]) -> str:
|
|
24
|
+
if not (text := span.get("text", "")):
|
|
25
|
+
return ""
|
|
26
|
+
link = (
|
|
27
|
+
next((span.get(k) for k in ["link", "uri"] if isinstance(span.get(k), str)), "")
|
|
28
|
+
or ""
|
|
29
|
+
)
|
|
30
|
+
if span.get("superscript"):
|
|
31
|
+
s = text.strip()
|
|
32
|
+
text = f"[{s}]" if s.isdigit() or re.match(r"^\d+[,\s\d]*$", s) else f"^{text}^"
|
|
33
|
+
return f"[{text}]({link})" if link else text
|
|
34
|
+
for key, fmt in STYLES:
|
|
35
|
+
if span.get(key):
|
|
36
|
+
text = f"{fmt}{text}{fmt}"
|
|
37
|
+
return f"[{text}]({link})" if link else text
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _join_spans(spans: list[dict[str, Any]]) -> str:
|
|
41
|
+
parts = []
|
|
42
|
+
for i, span in enumerate(spans):
|
|
43
|
+
if not (styled := _style_span(span)):
|
|
44
|
+
continue
|
|
45
|
+
if (
|
|
46
|
+
parts
|
|
47
|
+
and any(styled.startswith(m) for m in FMT_MARKERS)
|
|
48
|
+
and parts[-1][-1:] not in " \n\t([/"
|
|
49
|
+
):
|
|
50
|
+
parts.append(" ")
|
|
51
|
+
parts.append(styled)
|
|
52
|
+
if (
|
|
53
|
+
i + 1 < len(spans)
|
|
54
|
+
and (nxt := spans[i + 1].get("text", ""))
|
|
55
|
+
and any(styled.endswith(m) for m in FMT_MARKERS)
|
|
56
|
+
and nxt[0] not in PUNCT
|
|
57
|
+
):
|
|
58
|
+
parts.append(" ")
|
|
59
|
+
return "".join(parts)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _cell_text(cell: dict[str, Any]) -> str:
|
|
63
|
+
spans = cast(list[dict[str, Any]], cell.get("spans") or [])
|
|
64
|
+
text = (
|
|
65
|
+
" ".join(s.get("text", "") for s in spans).strip()
|
|
66
|
+
if spans
|
|
67
|
+
else cell.get("text", "").strip()
|
|
68
|
+
)
|
|
69
|
+
return text.replace("|", "\\|").replace("\n", "<br>")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _table(rows: list[dict[str, Any]]) -> str:
|
|
73
|
+
if (
|
|
74
|
+
not rows
|
|
75
|
+
or not (
|
|
76
|
+
matrix := [[_cell_text(c) for c in row.get("cells", [])] for row in rows]
|
|
77
|
+
)
|
|
78
|
+
or not any(matrix[0])
|
|
79
|
+
):
|
|
80
|
+
return ""
|
|
81
|
+
hdr = matrix[0]
|
|
82
|
+
lines = [
|
|
83
|
+
"| " + " | ".join(hdr) + " |",
|
|
84
|
+
"| " + " | ".join("---" for _ in hdr) + " |",
|
|
85
|
+
]
|
|
86
|
+
for row in matrix[1:]:
|
|
87
|
+
row = (row + [""] * len(hdr))[: len(hdr)]
|
|
88
|
+
lines.append("| " + " | ".join(row) + " |")
|
|
89
|
+
return "\n".join(lines) + "\n"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _list(block: dict[str, Any], text: str) -> str:
|
|
93
|
+
if items := block.get("items"):
|
|
94
|
+
lines = []
|
|
95
|
+
for item in items:
|
|
96
|
+
if not (body := _join_spans(item.get("spans", [])).strip()):
|
|
97
|
+
continue
|
|
98
|
+
prefix = item.get("prefix")
|
|
99
|
+
marker = (
|
|
100
|
+
prefix
|
|
101
|
+
if isinstance(prefix, str) and prefix
|
|
102
|
+
else "1."
|
|
103
|
+
if item.get("list_type") == "numbered"
|
|
104
|
+
else "-"
|
|
105
|
+
)
|
|
106
|
+
indent = " " * max(int(item.get("indent", 0)), 0)
|
|
107
|
+
lines.append(f"{indent}{marker} {body}")
|
|
108
|
+
return "\n".join(lines) + "\n" if lines else ""
|
|
109
|
+
return (
|
|
110
|
+
"\n".join(f"- {ln.strip()}" for ln in text.split("\n") if ln.strip()) + "\n"
|
|
111
|
+
if text
|
|
112
|
+
else ""
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _plain_text(block: dict[str, Any]) -> str:
|
|
117
|
+
return (block.get("text") or "").strip() or "".join(
|
|
118
|
+
str(span.get("text", "")) for span in block.get("spans", [])
|
|
119
|
+
).strip()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _heading(block: dict[str, Any]) -> str:
|
|
123
|
+
level = max(1, min(int(block.get("level") or 1), 6))
|
|
124
|
+
return f"{'#' * level} {_plain_text(block)}\n"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _code(block: dict[str, Any]) -> str:
|
|
128
|
+
text = _plain_text(block)
|
|
129
|
+
longest_run = max((len(run) for run in re.findall(r"`+", text)), default=0)
|
|
130
|
+
fence = "`" * max(3, longest_run + 1)
|
|
131
|
+
return f"{fence}\n{text}\n{fence}\n"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def block_to_markdown(block: dict[str, Any]) -> str:
|
|
135
|
+
typ = block.get("type", "")
|
|
136
|
+
text = block.get("text", "").strip() or _join_spans(block.get("spans", []))
|
|
137
|
+
if not text and typ not in ("table", "list"):
|
|
138
|
+
return ""
|
|
139
|
+
|
|
140
|
+
if typ == "heading":
|
|
141
|
+
return _heading(block)
|
|
142
|
+
if typ in ("paragraph", "text"):
|
|
143
|
+
return f"{text}\n"
|
|
144
|
+
if typ == "code":
|
|
145
|
+
return _code(block)
|
|
146
|
+
if typ == "table":
|
|
147
|
+
return _table(block.get("rows", []))
|
|
148
|
+
if typ == "list":
|
|
149
|
+
return _list(block, text)
|
|
150
|
+
if typ == "figure":
|
|
151
|
+
return f"})\n"
|
|
152
|
+
log.debug("skipping block type=%s", typ)
|
|
153
|
+
return ""
|
fibrum_pdf/_cffi.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""ctypes bindings and native library discovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ctypes
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger(__name__)
|
|
13
|
+
_dll_directory_handles: list[object] = []
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _library_name() -> str:
|
|
17
|
+
return {
|
|
18
|
+
"darwin": "libtomd.dylib",
|
|
19
|
+
"win32": "libtomd.dll",
|
|
20
|
+
}.get(sys.platform, "libtomd.so")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@lru_cache(maxsize=1)
|
|
24
|
+
def find_library() -> Path | None:
|
|
25
|
+
"""Find the bundled, locally built, or explicitly configured bridge."""
|
|
26
|
+
if configured := os.environ.get("FIBRUMPDF_LIB"):
|
|
27
|
+
path = Path(configured).expanduser()
|
|
28
|
+
if path.is_file():
|
|
29
|
+
return path.resolve()
|
|
30
|
+
|
|
31
|
+
package = Path(__file__).resolve().parent
|
|
32
|
+
project, name = package.parent, _library_name()
|
|
33
|
+
candidates = [package / "lib" / name, project / "lib" / name]
|
|
34
|
+
candidates.extend((project / "build").glob(f"lib*/fibrum_pdf/lib/{name}"))
|
|
35
|
+
for path in candidates:
|
|
36
|
+
if path.is_file():
|
|
37
|
+
log.debug("found native library: %s", path)
|
|
38
|
+
return path.resolve()
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _prepare_dependencies(path: Path) -> None:
|
|
43
|
+
directories = (
|
|
44
|
+
path.parent,
|
|
45
|
+
Path(__file__).resolve().parent.parent / "lib" / "mupdf",
|
|
46
|
+
)
|
|
47
|
+
if sys.platform == "win32":
|
|
48
|
+
for directory in directories:
|
|
49
|
+
if directory.is_dir() and hasattr(os, "add_dll_directory"):
|
|
50
|
+
_dll_directory_handles.append(os.add_dll_directory(str(directory)))
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
pattern = "libmupdf*.dylib*" if sys.platform == "darwin" else "libmupdf.so*"
|
|
54
|
+
for directory in directories:
|
|
55
|
+
for dependency in sorted(directory.glob(pattern), reverse=True):
|
|
56
|
+
try:
|
|
57
|
+
ctypes.CDLL(str(dependency), mode=ctypes.RTLD_GLOBAL)
|
|
58
|
+
return
|
|
59
|
+
except OSError:
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _configure_abi(library: ctypes.CDLL) -> None:
|
|
64
|
+
library.pdf_to_json.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
|
|
65
|
+
library.pdf_to_json.restype = ctypes.c_int
|
|
66
|
+
if hasattr(library, "pdf_to_json_with_error"):
|
|
67
|
+
library.pdf_to_json_with_error.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
|
|
68
|
+
library.pdf_to_json_with_error.restype = ctypes.c_void_p
|
|
69
|
+
library.free_string.argtypes = [ctypes.c_void_p]
|
|
70
|
+
library.free_string.restype = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@lru_cache(maxsize=1)
|
|
74
|
+
def load_library(path: Path) -> ctypes.CDLL:
|
|
75
|
+
"""Load and configure the native bridge at ``path``."""
|
|
76
|
+
_prepare_dependencies(path)
|
|
77
|
+
try:
|
|
78
|
+
library = ctypes.CDLL(str(path))
|
|
79
|
+
except OSError as error:
|
|
80
|
+
raise RuntimeError(f"failed to load libtomd: {error}") from error
|
|
81
|
+
_configure_abi(library)
|
|
82
|
+
return library
|
fibrum_pdf/api.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""public api for pdf to json extraction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import threading
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from functools import cached_property, lru_cache
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from collections.abc import Generator
|
|
15
|
+
from typing import TYPE_CHECKING, Any, Iterator, cast
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from .models import Page, Pages
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger(__name__)
|
|
21
|
+
_legacy_capture_lock = threading.Lock()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ExtractionError(Exception):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@contextmanager
|
|
29
|
+
def _redirect_legacy_c_output() -> Generator[int]:
|
|
30
|
+
"""Capture output from native libraries predating direct error returns."""
|
|
31
|
+
with _legacy_capture_lock, tempfile.TemporaryFile() as capture:
|
|
32
|
+
saved = os.dup(1), os.dup(2)
|
|
33
|
+
try:
|
|
34
|
+
os.dup2(capture.fileno(), 1)
|
|
35
|
+
os.dup2(capture.fileno(), 2)
|
|
36
|
+
yield capture.fileno()
|
|
37
|
+
finally:
|
|
38
|
+
sys.stdout.flush()
|
|
39
|
+
sys.stderr.flush()
|
|
40
|
+
os.dup2(saved[0], 1)
|
|
41
|
+
os.dup2(saved[1], 2)
|
|
42
|
+
os.close(saved[0])
|
|
43
|
+
os.close(saved[1])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _extract(lib: Any, pdf: Path, output: Path) -> str | None:
|
|
47
|
+
pdf_bytes, output_bytes = os.fsencode(pdf), os.fsencode(output)
|
|
48
|
+
if hasattr(lib, "pdf_to_json_with_error"):
|
|
49
|
+
import ctypes
|
|
50
|
+
|
|
51
|
+
error = lib.pdf_to_json_with_error(pdf_bytes, output_bytes)
|
|
52
|
+
if not error:
|
|
53
|
+
return None
|
|
54
|
+
try:
|
|
55
|
+
return ctypes.string_at(error).decode(errors="replace")
|
|
56
|
+
finally:
|
|
57
|
+
lib.free_string(error)
|
|
58
|
+
|
|
59
|
+
with _redirect_legacy_c_output() as capture_fd:
|
|
60
|
+
rc = lib.pdf_to_json(pdf_bytes, output_bytes)
|
|
61
|
+
if rc == 0:
|
|
62
|
+
return None
|
|
63
|
+
os.lseek(capture_fd, 0, os.SEEK_SET)
|
|
64
|
+
return os.read(capture_fd, 1 << 20).decode(errors="replace").strip() or None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@lru_cache(maxsize=1)
|
|
68
|
+
def _lib(path: Path | None = None):
|
|
69
|
+
from ._cffi import find_library, load_library
|
|
70
|
+
|
|
71
|
+
p = path or find_library()
|
|
72
|
+
if not p or not p.exists():
|
|
73
|
+
raise ExtractionError(
|
|
74
|
+
"libtomd not found; reinstall fibrum-pdf or set FIBRUMPDF_LIB"
|
|
75
|
+
)
|
|
76
|
+
log.info("using library: %s", p)
|
|
77
|
+
return load_library(p)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ConversionResult:
|
|
81
|
+
"""Lazy PDF conversion result backed by the generated JSON file."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, path: Path):
|
|
84
|
+
"""Reference an extraction result at ``path``."""
|
|
85
|
+
self.path = path
|
|
86
|
+
log.debug("result at %s", path)
|
|
87
|
+
|
|
88
|
+
def _load(self) -> list[Any]:
|
|
89
|
+
with open(self.path, encoding="utf-8") as f:
|
|
90
|
+
data: object = json.load(f)
|
|
91
|
+
return cast(list[Any], data) if isinstance(data, list) else []
|
|
92
|
+
|
|
93
|
+
@cached_property
|
|
94
|
+
def markdown(self) -> str:
|
|
95
|
+
return "\n---\n\n".join(page.markdown for page in self if page.markdown)
|
|
96
|
+
|
|
97
|
+
def write_markdown(self, output: str | Path) -> Path:
|
|
98
|
+
"""Stream Markdown to ``output`` and replace it atomically."""
|
|
99
|
+
target = Path(output).resolve()
|
|
100
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
temporary: Path | None = None
|
|
102
|
+
try:
|
|
103
|
+
with tempfile.NamedTemporaryFile(
|
|
104
|
+
mode="w",
|
|
105
|
+
encoding="utf-8",
|
|
106
|
+
dir=target.parent,
|
|
107
|
+
prefix=f".{target.name}.",
|
|
108
|
+
delete=False,
|
|
109
|
+
) as stream:
|
|
110
|
+
temporary = Path(stream.name)
|
|
111
|
+
first = True
|
|
112
|
+
for page in self:
|
|
113
|
+
if not (markdown := page.markdown):
|
|
114
|
+
continue
|
|
115
|
+
if not first:
|
|
116
|
+
stream.write("\n---\n\n")
|
|
117
|
+
stream.write(markdown)
|
|
118
|
+
first = False
|
|
119
|
+
temporary.replace(target)
|
|
120
|
+
finally:
|
|
121
|
+
if temporary is not None:
|
|
122
|
+
temporary.unlink(missing_ok=True)
|
|
123
|
+
return target
|
|
124
|
+
|
|
125
|
+
def collect(self) -> "Pages":
|
|
126
|
+
from .models import Page, Pages
|
|
127
|
+
|
|
128
|
+
pages = Pages([Page(p["data"]) for p in self._load()])
|
|
129
|
+
log.info("collected %d pages", len(pages))
|
|
130
|
+
return pages
|
|
131
|
+
|
|
132
|
+
def __iter__(self) -> Iterator["Page"]:
|
|
133
|
+
"""Stream parsed pages without loading the entire document."""
|
|
134
|
+
import ijson
|
|
135
|
+
from .models import Page
|
|
136
|
+
|
|
137
|
+
with open(self.path, "rb") as f:
|
|
138
|
+
for i, p in enumerate(ijson.items(f, "item")):
|
|
139
|
+
log.debug("page %d", i + 1)
|
|
140
|
+
yield Page(p["data"])
|
|
141
|
+
|
|
142
|
+
def __repr__(self) -> str:
|
|
143
|
+
"""Return a concise representation containing the output path."""
|
|
144
|
+
return f"ConversionResult({self.path})"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def to_json(
|
|
148
|
+
pdf_path: str | Path,
|
|
149
|
+
output: str | Path | None = None,
|
|
150
|
+
*,
|
|
151
|
+
lib_path: Path | None = None,
|
|
152
|
+
) -> ConversionResult:
|
|
153
|
+
pdf = Path(pdf_path).resolve()
|
|
154
|
+
if not pdf.exists():
|
|
155
|
+
raise FileNotFoundError(f"pdf not found: {pdf}")
|
|
156
|
+
out = Path(output).resolve() if output else pdf.with_suffix(".json")
|
|
157
|
+
if out.exists() and os.path.samefile(pdf, out):
|
|
158
|
+
raise ValueError("input and output must be different files")
|
|
159
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
log.info("extracting %s -> %s", pdf, out)
|
|
161
|
+
if error := _extract(_lib(lib_path), pdf, out):
|
|
162
|
+
raise ExtractionError(f"extraction failed: {error}")
|
|
163
|
+
log.info("done")
|
|
164
|
+
return ConversionResult(out)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def to_markdown(
|
|
168
|
+
pdf_path: str | Path,
|
|
169
|
+
output: str | Path | None = None,
|
|
170
|
+
*,
|
|
171
|
+
lib_path: Path | None = None,
|
|
172
|
+
) -> Path:
|
|
173
|
+
"""Extract ``pdf_path`` to a Markdown file and return its path."""
|
|
174
|
+
pdf = Path(pdf_path).resolve()
|
|
175
|
+
target = Path(output).resolve() if output else pdf.with_suffix(".md")
|
|
176
|
+
if pdf == target:
|
|
177
|
+
raise ValueError("input and output must be different files")
|
|
178
|
+
with tempfile.TemporaryDirectory(prefix="fibrum-pdf-") as directory:
|
|
179
|
+
result = to_json(pdf, Path(directory) / "document.json", lib_path=lib_path)
|
|
180
|
+
return result.write_markdown(target)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
__all__ = ["ExtractionError", "to_json", "to_markdown", "ConversionResult"]
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
fibrum_pdf/main.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""cli entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated, Optional
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from .api import ExtractionError, to_json, to_markdown
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OutputFormat(str, Enum):
|
|
16
|
+
"""Supported CLI output formats."""
|
|
17
|
+
|
|
18
|
+
json = "json"
|
|
19
|
+
markdown = "markdown"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run(
|
|
23
|
+
pdf_path: Annotated[
|
|
24
|
+
Path,
|
|
25
|
+
typer.Argument(
|
|
26
|
+
exists=True,
|
|
27
|
+
dir_okay=False,
|
|
28
|
+
readable=True,
|
|
29
|
+
resolve_path=True,
|
|
30
|
+
help="PDF file to extract",
|
|
31
|
+
),
|
|
32
|
+
],
|
|
33
|
+
output: Annotated[
|
|
34
|
+
Optional[Path],
|
|
35
|
+
typer.Argument(
|
|
36
|
+
help="Output path (defaults to the input name with a new suffix)"
|
|
37
|
+
),
|
|
38
|
+
] = None,
|
|
39
|
+
output_format: Annotated[
|
|
40
|
+
OutputFormat,
|
|
41
|
+
typer.Option("--format", "-f", help="Output format"),
|
|
42
|
+
] = OutputFormat.json,
|
|
43
|
+
verbose: Annotated[
|
|
44
|
+
bool, typer.Option("--verbose", "-v", help="Enable verbose logging")
|
|
45
|
+
] = False,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Extract structured PDF content to JSON or Markdown."""
|
|
48
|
+
logging.basicConfig(
|
|
49
|
+
level=logging.DEBUG if verbose else logging.INFO,
|
|
50
|
+
format="%(levelname)s: %(message)s",
|
|
51
|
+
force=True,
|
|
52
|
+
)
|
|
53
|
+
try:
|
|
54
|
+
if output_format is OutputFormat.markdown:
|
|
55
|
+
path = to_markdown(pdf_path, output)
|
|
56
|
+
else:
|
|
57
|
+
path = to_json(pdf_path, output).path
|
|
58
|
+
logging.getLogger(__name__).info("wrote %s", path)
|
|
59
|
+
except (FileNotFoundError, ValueError, ExtractionError) as e:
|
|
60
|
+
typer.secho(f"Error: {e}", fg=typer.colors.RED, err=True)
|
|
61
|
+
raise typer.Exit(1) from e
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main() -> None:
|
|
65
|
+
"""CLI entry point."""
|
|
66
|
+
typer.run(run)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
if __name__ == "__main__":
|
|
70
|
+
main()
|
fibrum_pdf/models.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Models for output of FibrumPDF."""
|
|
2
|
+
|
|
3
|
+
# ruff: noqa: D101, D102, D107
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from functools import cached_property
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Span(BaseModel):
|
|
16
|
+
text: str
|
|
17
|
+
font_size: float
|
|
18
|
+
bold: bool = False
|
|
19
|
+
italic: bool = False
|
|
20
|
+
monospace: bool = False
|
|
21
|
+
strikeout: bool = False
|
|
22
|
+
superscript: bool = False
|
|
23
|
+
subscript: bool = False
|
|
24
|
+
link: str | bool = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TableCell(BaseModel):
|
|
28
|
+
bbox: list[float]
|
|
29
|
+
spans: list[Span] = Field(default_factory=lambda: list[Span]())
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TableRow(BaseModel):
|
|
33
|
+
bbox: list[float]
|
|
34
|
+
cells: list[TableCell] = Field(default_factory=lambda: list[TableCell]())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ListItem(BaseModel):
|
|
38
|
+
spans: list[Span] = Field(default_factory=lambda: list[Span]())
|
|
39
|
+
list_type: str
|
|
40
|
+
indent: int = 0
|
|
41
|
+
prefix: str | bool = False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Block(BaseModel):
|
|
45
|
+
model_config = ConfigDict(extra="allow")
|
|
46
|
+
type: str
|
|
47
|
+
bbox: list[float]
|
|
48
|
+
spans: list[Span] = Field(default_factory=lambda: list[Span]())
|
|
49
|
+
length: int = 0
|
|
50
|
+
lines: int | None = None
|
|
51
|
+
level: int | None = None
|
|
52
|
+
row_count: int | None = None
|
|
53
|
+
col_count: int | None = None
|
|
54
|
+
cell_count: int | None = None
|
|
55
|
+
rows: list[TableRow] | None = None
|
|
56
|
+
items: list[ListItem] | None = None
|
|
57
|
+
|
|
58
|
+
@cached_property
|
|
59
|
+
def markdown(self) -> str:
|
|
60
|
+
from ._block_converter import block_to_markdown
|
|
61
|
+
|
|
62
|
+
return block_to_markdown(self.model_dump())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Page(list[Block]):
|
|
66
|
+
def __init__(self, items: list[Block | dict[str, Any]] | dict[str, Any]):
|
|
67
|
+
super().__init__()
|
|
68
|
+
if isinstance(items, dict) and "data" in items:
|
|
69
|
+
items = items["data"]
|
|
70
|
+
for item in items or []:
|
|
71
|
+
self.append(Block(**item) if isinstance(item, dict) else item) # type: ignore
|
|
72
|
+
log.debug("page: %d blocks", len(self))
|
|
73
|
+
|
|
74
|
+
@cached_property
|
|
75
|
+
def markdown(self) -> str:
|
|
76
|
+
return "\n".join(b.markdown for b in self if b.markdown)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Pages(list[Page]):
|
|
80
|
+
def __init__(self, pages: list[Page] | None = None):
|
|
81
|
+
super().__init__(pages or [])
|
|
82
|
+
|
|
83
|
+
@cached_property
|
|
84
|
+
def markdown(self) -> str:
|
|
85
|
+
return "\n---\n\n".join(p.markdown for p in self if p.markdown)
|