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 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"![Figure]({block.get('text', 'figure')})\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)