surf-cli 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.
surf/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ # __init__.py
2
+ """surf-cli: heading-addressed markdown, TeX, and PDF extract."""
3
+
4
+ __version__ = "0.7.0"
5
+ __version_tag__ = f"surf-v{__version__}"
surf/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ # __main__.py
2
+ """Run `python -m surf`."""
3
+
4
+ from surf.orchestrator import main
5
+
6
+ if __name__ == "__main__":
7
+ main()
surf/adapters.py ADDED
@@ -0,0 +1,174 @@
1
+ # adapters.py
2
+ """Razor-thin filesystem I/O. Models only."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from collections.abc import Iterable, Iterator
7
+ from pathlib import Path
8
+ from typing import cast
9
+
10
+ from pypdf import PdfReader
11
+ from pypdf.generic import Destination
12
+
13
+ from surf.models import (
14
+ ByteCount,
15
+ DocumentLines,
16
+ FileRef,
17
+ HeadingText,
18
+ OutlineLevel,
19
+ OutlineRecord,
20
+ PageCount,
21
+ PageIndex,
22
+ PageText,
23
+ PdfCatalog,
24
+ PdfDocument,
25
+ RenderedBody,
26
+ TexIncludeRelPath,
27
+ )
28
+
29
+ type PypdfOutlineItem = Destination | list[PypdfOutlineItem]
30
+
31
+
32
+ class PdfIngestError(Exception):
33
+ """PDF bytes could not be read as a document outline."""
34
+
35
+
36
+ def resolve_file(file_ref: FileRef) -> Path:
37
+ path = Path(str(file_ref)).expanduser()
38
+ if path.exists():
39
+ return path
40
+ if not path.suffix:
41
+ with_md = path.with_suffix(".md")
42
+ if with_md.exists():
43
+ return with_md
44
+ raise FileNotFoundError(f"File not found: {file_ref}")
45
+
46
+
47
+ def read_document(path: Path) -> DocumentLines:
48
+ return tuple(path.read_text(encoding="utf-8-sig").splitlines())
49
+
50
+
51
+ def document_byte_count(path: Path) -> ByteCount:
52
+ return ByteCount(path.stat().st_size)
53
+
54
+
55
+ def resolve_tex_include(base_dir: Path, rel: TexIncludeRelPath) -> Path | None:
56
+ candidate = base_dir / str(rel)
57
+ if candidate.is_file():
58
+ return candidate
59
+ return None
60
+
61
+
62
+ def write_output(text: RenderedBody, dest: FileRef | None) -> None:
63
+ payload = text if text.endswith("\n") else text + "\n"
64
+ if dest is None:
65
+ print(text)
66
+ return
67
+ Path(str(dest)).write_text(payload, encoding="utf-8")
68
+
69
+
70
+ def _optional_float(value: object) -> float | None:
71
+ match value:
72
+ case None:
73
+ return None
74
+ case int() | float() | str():
75
+ try:
76
+ return float(value)
77
+ except (TypeError, ValueError):
78
+ return None
79
+ case _:
80
+ try:
81
+ return float(str(value))
82
+ except (TypeError, ValueError):
83
+ return None
84
+
85
+
86
+ def _outline_record(reader: PdfReader, item: Destination, *, level: OutlineLevel) -> OutlineRecord:
87
+ title = item.title
88
+ page_number = reader.get_destination_page_number(item)
89
+ page_index = PageIndex(page_number) if page_number is not None else None
90
+ return OutlineRecord(
91
+ level=level,
92
+ title=HeadingText("" if title is None else str(title)),
93
+ page_index=page_index,
94
+ top=_optional_float(item.top),
95
+ )
96
+
97
+
98
+ def _walk_outline(
99
+ reader: PdfReader,
100
+ items: Iterable[PypdfOutlineItem],
101
+ *,
102
+ level: OutlineLevel,
103
+ ) -> Iterator[OutlineRecord]:
104
+ for item in items:
105
+ match item:
106
+ case list():
107
+ nested = cast(list[PypdfOutlineItem], item)
108
+ yield from _walk_outline(reader, nested, level=OutlineLevel(int(level) + 1))
109
+ case Destination():
110
+ yield _outline_record(reader, item, level=level)
111
+
112
+
113
+ def _open_reader(path: Path) -> PdfReader:
114
+ reader = PdfReader(path)
115
+ if reader.is_encrypted:
116
+ raise PdfIngestError(f"could not read PDF: {path}")
117
+ return reader
118
+
119
+
120
+ def _outline_from_reader(reader: PdfReader) -> tuple[OutlineRecord, ...]:
121
+ outline_root = cast(Iterable[PypdfOutlineItem], reader.outline)
122
+ return tuple(_walk_outline(reader, outline_root, level=OutlineLevel(1)))
123
+
124
+
125
+ def _pages_from_reader(
126
+ reader: PdfReader, start: PageIndex, end: PageIndex | None
127
+ ) -> tuple[PageText, ...]:
128
+ pages = reader.pages
129
+ start_i = int(start)
130
+ end_i = len(pages) if end is None else int(end)
131
+ start_i = max(0, start_i)
132
+ end_i = min(len(pages), end_i)
133
+ if start_i >= end_i:
134
+ return ()
135
+ return tuple((pages[i].extract_text() or "") for i in range(start_i, end_i))
136
+
137
+
138
+ def read_pdf_catalog(path: Path) -> PdfCatalog:
139
+ try:
140
+ reader = _open_reader(path)
141
+ return PdfCatalog(
142
+ outline=_outline_from_reader(reader),
143
+ page_count=PageCount(len(reader.pages)),
144
+ byte_count=ByteCount(path.stat().st_size),
145
+ )
146
+ except PdfIngestError:
147
+ raise
148
+ except Exception as exc:
149
+ raise PdfIngestError(f"could not read PDF: {path}") from exc
150
+
151
+
152
+ def read_pdf_outline(path: Path) -> tuple[OutlineRecord, ...]:
153
+ return read_pdf_catalog(path).outline
154
+
155
+
156
+ def read_pdf_pages(path: Path, start: PageIndex, end: PageIndex | None) -> tuple[PageText, ...]:
157
+ try:
158
+ return _pages_from_reader(_open_reader(path), start, end)
159
+ except PdfIngestError:
160
+ raise
161
+ except Exception as exc:
162
+ raise PdfIngestError(f"could not read PDF: {path}") from exc
163
+
164
+
165
+ def read_pdf(path: Path) -> PdfDocument:
166
+ try:
167
+ reader = _open_reader(path)
168
+ outline = _outline_from_reader(reader)
169
+ pages = _pages_from_reader(reader, PageIndex(0), None)
170
+ return PdfDocument(outline=outline, pages=pages)
171
+ except PdfIngestError:
172
+ raise
173
+ except Exception as exc:
174
+ raise PdfIngestError(f"could not read PDF: {path}") from exc