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 +5 -0
- surf/__main__.py +7 -0
- surf/adapters.py +174 -0
- surf/logic.py +557 -0
- surf/models.py +148 -0
- surf/orchestrator.py +300 -0
- surf_cli-0.7.0.dist-info/METADATA +177 -0
- surf_cli-0.7.0.dist-info/RECORD +11 -0
- surf_cli-0.7.0.dist-info/WHEEL +4 -0
- surf_cli-0.7.0.dist-info/entry_points.txt +2 -0
- surf_cli-0.7.0.dist-info/licenses/LICENSE +21 -0
surf/logic.py
ADDED
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
# logic.py
|
|
2
|
+
"""Pure heading parse and extract. No Path, no exists, no print."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from urllib.parse import unquote
|
|
9
|
+
|
|
10
|
+
from surf.models import (
|
|
11
|
+
ByteCount,
|
|
12
|
+
CharOffset,
|
|
13
|
+
CliTarget,
|
|
14
|
+
ClosedSpan,
|
|
15
|
+
Delimiter,
|
|
16
|
+
ExtractedOutline,
|
|
17
|
+
ExtractedSection,
|
|
18
|
+
FileRef,
|
|
19
|
+
FrontmatterSplit,
|
|
20
|
+
HeadingLevel,
|
|
21
|
+
HeadingLineCount,
|
|
22
|
+
HeadingPath,
|
|
23
|
+
HeadingPathRemainder,
|
|
24
|
+
HeadingRecord,
|
|
25
|
+
HeadingText,
|
|
26
|
+
LineCount,
|
|
27
|
+
LineIndex,
|
|
28
|
+
OutlineLevel,
|
|
29
|
+
OutlinePageSpan,
|
|
30
|
+
OutlineRecord,
|
|
31
|
+
PageCount,
|
|
32
|
+
PageIndex,
|
|
33
|
+
ParsedLink,
|
|
34
|
+
PdfDocument,
|
|
35
|
+
RenderedBody,
|
|
36
|
+
ScanBuffer,
|
|
37
|
+
TexCommand,
|
|
38
|
+
TexEnvironment,
|
|
39
|
+
TexIncludeCommand,
|
|
40
|
+
TexIncludeRelPath,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+)$")
|
|
44
|
+
_TEX_LEVEL: Mapping[TexCommand, HeadingLevel] = {
|
|
45
|
+
TexCommand.PART: HeadingLevel(1),
|
|
46
|
+
TexCommand.CHAPTER: HeadingLevel(2),
|
|
47
|
+
TexCommand.SECTION: HeadingLevel(3),
|
|
48
|
+
TexCommand.SUBSECTION: HeadingLevel(4),
|
|
49
|
+
TexCommand.SUBSUBSECTION: HeadingLevel(5),
|
|
50
|
+
TexCommand.PARAGRAPH: HeadingLevel(6),
|
|
51
|
+
TexCommand.SUBPARAGRAPH: HeadingLevel(7),
|
|
52
|
+
}
|
|
53
|
+
_TEX_COMMAND_BY_WORD: Mapping[str, TexCommand] = {command.value: command for command in TexCommand}
|
|
54
|
+
_TEX_INCLUDE_BY_WORD: Mapping[str, TexIncludeCommand] = {
|
|
55
|
+
command.value: command for command in TexIncludeCommand
|
|
56
|
+
}
|
|
57
|
+
_TEX_CONTROL_WORD_RE = re.compile(r"^\s*\\([A-Za-z]+)")
|
|
58
|
+
_TEX_UNBRACED_INCLUDE_STOP = frozenset(" \t%")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse_heading_path(remainder: HeadingPathRemainder) -> HeadingPath | None:
|
|
62
|
+
segments = tuple(HeadingText(part.strip()) for part in remainder.split("#") if part.strip())
|
|
63
|
+
if not segments:
|
|
64
|
+
return None
|
|
65
|
+
return HeadingPath(segments=segments)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parse_link(target: CliTarget) -> ParsedLink:
|
|
69
|
+
"""Parse a wikilink, markdown link, or plain path#heading into a ParsedLink.
|
|
70
|
+
|
|
71
|
+
The first `#` splits file ref from heading-path remainder.
|
|
72
|
+
"""
|
|
73
|
+
m = re.match(r"^\[\[([^|\]]+?)(?:\|[^\]]+)?\]\]$", target)
|
|
74
|
+
if m:
|
|
75
|
+
inner = m.group(1)
|
|
76
|
+
if "#" in inner:
|
|
77
|
+
raw_path, remainder = inner.split("#", 1)
|
|
78
|
+
file_ref = FileRef(raw_path.strip()) if raw_path.strip() else None
|
|
79
|
+
return ParsedLink(
|
|
80
|
+
file_ref=file_ref,
|
|
81
|
+
heading_path=parse_heading_path(HeadingPathRemainder(remainder)),
|
|
82
|
+
)
|
|
83
|
+
stripped = inner.strip()
|
|
84
|
+
return ParsedLink(file_ref=FileRef(stripped) if stripped else None, heading_path=None)
|
|
85
|
+
|
|
86
|
+
m = re.match(r"^\[([^\]]*)\]\((.+?)\)$", target)
|
|
87
|
+
if m:
|
|
88
|
+
raw = unquote(m.group(2))
|
|
89
|
+
if "#" in raw:
|
|
90
|
+
raw_path, remainder = raw.split("#", 1)
|
|
91
|
+
file_ref = FileRef(raw_path.strip()) if raw_path.strip() else None
|
|
92
|
+
return ParsedLink(
|
|
93
|
+
file_ref=file_ref,
|
|
94
|
+
heading_path=parse_heading_path(HeadingPathRemainder(remainder)),
|
|
95
|
+
)
|
|
96
|
+
stripped = raw.strip()
|
|
97
|
+
return ParsedLink(file_ref=FileRef(stripped) if stripped else None, heading_path=None)
|
|
98
|
+
|
|
99
|
+
if "#" in target:
|
|
100
|
+
raw_path, remainder = target.split("#", 1)
|
|
101
|
+
file_ref = FileRef(raw_path.strip()) if raw_path.strip() else None
|
|
102
|
+
return ParsedLink(
|
|
103
|
+
file_ref=file_ref,
|
|
104
|
+
heading_path=parse_heading_path(HeadingPathRemainder(remainder)),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
stripped = target.strip()
|
|
108
|
+
return ParsedLink(file_ref=FileRef(stripped) if stripped else None, heading_path=None)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def parse_headings(lines: Sequence[str]) -> tuple[HeadingRecord, ...]:
|
|
112
|
+
records: list[HeadingRecord] = []
|
|
113
|
+
for i, line in enumerate(lines):
|
|
114
|
+
m = _HEADING_RE.match(line)
|
|
115
|
+
if m:
|
|
116
|
+
records.append(
|
|
117
|
+
HeadingRecord(
|
|
118
|
+
level=HeadingLevel(len(m.group(1))),
|
|
119
|
+
line_index=LineIndex(i),
|
|
120
|
+
title_end_line=LineIndex(i),
|
|
121
|
+
text=HeadingText(m.group(2).strip()),
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
return tuple(records)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _delimited_span_end(
|
|
128
|
+
buffer: ScanBuffer,
|
|
129
|
+
start: CharOffset,
|
|
130
|
+
opener: Delimiter,
|
|
131
|
+
closer: Delimiter,
|
|
132
|
+
) -> CharOffset | None:
|
|
133
|
+
if start >= len(buffer) or buffer[start] != opener:
|
|
134
|
+
return None
|
|
135
|
+
depth = 0
|
|
136
|
+
for i in range(start, len(buffer)):
|
|
137
|
+
char = buffer[i]
|
|
138
|
+
if char == opener:
|
|
139
|
+
depth += 1
|
|
140
|
+
elif char == closer:
|
|
141
|
+
depth -= 1
|
|
142
|
+
if depth == 0:
|
|
143
|
+
return CharOffset(i + 1)
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _skip_horizontal(buffer: ScanBuffer, pos: CharOffset) -> CharOffset:
|
|
148
|
+
i = int(pos)
|
|
149
|
+
while i < len(buffer) and buffer[i] in " \t":
|
|
150
|
+
i += 1
|
|
151
|
+
return CharOffset(i)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _extend_until_closed(
|
|
155
|
+
lines: Sequence[str],
|
|
156
|
+
start_line: LineIndex,
|
|
157
|
+
rest: ScanBuffer,
|
|
158
|
+
pos: CharOffset,
|
|
159
|
+
opener: Delimiter,
|
|
160
|
+
closer: Delimiter,
|
|
161
|
+
) -> ClosedSpan | None:
|
|
162
|
+
end = _delimited_span_end(rest, pos, opener, closer)
|
|
163
|
+
if end is not None:
|
|
164
|
+
return ClosedSpan(buffer=rest, end=end, last_line=start_line)
|
|
165
|
+
for j in range(start_line + 1, len(lines)):
|
|
166
|
+
rest = ScanBuffer(rest + "\n" + lines[j])
|
|
167
|
+
end = _delimited_span_end(rest, pos, opener, closer)
|
|
168
|
+
if end is not None:
|
|
169
|
+
return ClosedSpan(buffer=rest, end=end, last_line=LineIndex(j))
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _parse_tex_heading_at(
|
|
174
|
+
lines: Sequence[str], line_index: LineIndex
|
|
175
|
+
) -> tuple[HeadingRecord, LineIndex] | None:
|
|
176
|
+
line = lines[line_index]
|
|
177
|
+
if line_index == 0:
|
|
178
|
+
line = line.lstrip("\ufeff")
|
|
179
|
+
m = _TEX_CONTROL_WORD_RE.match(line)
|
|
180
|
+
if m is None:
|
|
181
|
+
return None
|
|
182
|
+
command = _TEX_COMMAND_BY_WORD.get(m.group(1))
|
|
183
|
+
if command is None:
|
|
184
|
+
return _parse_tex_abstract_at(line_index, line, m)
|
|
185
|
+
rest = ScanBuffer(line[m.end() :])
|
|
186
|
+
pos = CharOffset(0)
|
|
187
|
+
if pos < len(rest) and rest[pos] == "*":
|
|
188
|
+
pos = CharOffset(pos + 1)
|
|
189
|
+
pos = _skip_horizontal(rest, pos)
|
|
190
|
+
if pos < len(rest) and rest[pos] == "[":
|
|
191
|
+
extended = _extend_until_closed(
|
|
192
|
+
lines, line_index, rest, pos, Delimiter("["), Delimiter("]")
|
|
193
|
+
)
|
|
194
|
+
if extended is None:
|
|
195
|
+
return None
|
|
196
|
+
rest = extended.buffer
|
|
197
|
+
pos = _skip_horizontal(extended.buffer, extended.end)
|
|
198
|
+
if pos >= len(rest) or rest[pos] != "{":
|
|
199
|
+
return None
|
|
200
|
+
extended = _extend_until_closed(lines, line_index, rest, pos, Delimiter("{"), Delimiter("}"))
|
|
201
|
+
if extended is None:
|
|
202
|
+
return None
|
|
203
|
+
rest = extended.buffer
|
|
204
|
+
brace_end = extended.end
|
|
205
|
+
title = re.sub(r"\s+", " ", rest[pos + 1 : brace_end - 1]).strip()
|
|
206
|
+
if not title:
|
|
207
|
+
return None
|
|
208
|
+
return (
|
|
209
|
+
HeadingRecord(
|
|
210
|
+
level=_TEX_LEVEL[command],
|
|
211
|
+
line_index=line_index,
|
|
212
|
+
title_end_line=extended.last_line,
|
|
213
|
+
text=HeadingText(title),
|
|
214
|
+
),
|
|
215
|
+
extended.last_line,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _parse_tex_abstract_at(
|
|
220
|
+
line_index: LineIndex,
|
|
221
|
+
line: str,
|
|
222
|
+
matched: re.Match[str],
|
|
223
|
+
) -> tuple[HeadingRecord, LineIndex] | None:
|
|
224
|
+
if matched.group(1) != "begin":
|
|
225
|
+
return None
|
|
226
|
+
rest = ScanBuffer(line[matched.end() :])
|
|
227
|
+
pos = _skip_horizontal(rest, CharOffset(0))
|
|
228
|
+
if pos >= len(rest) or rest[pos] != "{":
|
|
229
|
+
return None
|
|
230
|
+
end = _delimited_span_end(rest, pos, Delimiter("{"), Delimiter("}"))
|
|
231
|
+
if end is None:
|
|
232
|
+
return None
|
|
233
|
+
if rest[pos + 1 : end - 1].strip() != TexEnvironment.ABSTRACT:
|
|
234
|
+
return None
|
|
235
|
+
return (
|
|
236
|
+
HeadingRecord(
|
|
237
|
+
level=_TEX_LEVEL[TexCommand.SECTION],
|
|
238
|
+
line_index=line_index,
|
|
239
|
+
title_end_line=line_index,
|
|
240
|
+
text=HeadingText(TexEnvironment.ABSTRACT),
|
|
241
|
+
),
|
|
242
|
+
line_index,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _normalized_tex_include(raw: str) -> TexIncludeRelPath | None:
|
|
247
|
+
path = raw.strip()
|
|
248
|
+
if not path or path.startswith(("/", "~", "|")):
|
|
249
|
+
return None
|
|
250
|
+
if any(char in path for char in "\\{}|#"):
|
|
251
|
+
return None
|
|
252
|
+
segments = path.split("/")
|
|
253
|
+
if any(segment in ("", "..") for segment in segments):
|
|
254
|
+
return None
|
|
255
|
+
name = segments[-1]
|
|
256
|
+
if "." in name:
|
|
257
|
+
suffix = name.rsplit(".", 1)[1]
|
|
258
|
+
if suffix.lower() != "tex":
|
|
259
|
+
return None
|
|
260
|
+
else:
|
|
261
|
+
path = f"{path}.tex"
|
|
262
|
+
return TexIncludeRelPath(path)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def tex_include_path(line: str) -> TexIncludeRelPath | None:
|
|
266
|
+
"""Return a static \\input/\\include relative path, or None.
|
|
267
|
+
|
|
268
|
+
Comments, graphics, shell pipes, macros, absolute paths, and non-.tex
|
|
269
|
+
suffixes are not includes. A missing suffix becomes .tex.
|
|
270
|
+
"""
|
|
271
|
+
if line.lstrip(" \t").startswith("%"):
|
|
272
|
+
return None
|
|
273
|
+
matched = _TEX_CONTROL_WORD_RE.match(line)
|
|
274
|
+
if matched is None:
|
|
275
|
+
return None
|
|
276
|
+
command = _TEX_INCLUDE_BY_WORD.get(matched.group(1))
|
|
277
|
+
if command is None:
|
|
278
|
+
return None
|
|
279
|
+
rest = ScanBuffer(line[matched.end() :])
|
|
280
|
+
pos = _skip_horizontal(rest, CharOffset(0))
|
|
281
|
+
if pos < len(rest) and rest[pos] == "{":
|
|
282
|
+
end = _delimited_span_end(rest, pos, Delimiter("{"), Delimiter("}"))
|
|
283
|
+
if end is None:
|
|
284
|
+
return None
|
|
285
|
+
return _normalized_tex_include(rest[pos + 1 : end - 1])
|
|
286
|
+
if command is TexIncludeCommand.INCLUDE:
|
|
287
|
+
return None
|
|
288
|
+
if pos >= len(rest):
|
|
289
|
+
return None
|
|
290
|
+
stop = int(pos)
|
|
291
|
+
while stop < len(rest) and rest[stop] not in _TEX_UNBRACED_INCLUDE_STOP:
|
|
292
|
+
stop += 1
|
|
293
|
+
return _normalized_tex_include(rest[pos:stop])
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def relative_heading_levels(
|
|
297
|
+
headings: tuple[HeadingRecord, ...],
|
|
298
|
+
) -> tuple[HeadingRecord, ...]:
|
|
299
|
+
"""Shift ranks so the shallowest heading in the file is level 1.
|
|
300
|
+
|
|
301
|
+
LaTeX command ranks stay on the 1-7 map (part=1 ... subparagraph=7).
|
|
302
|
+
An article whose top command is \\section then lists at --level 1, matching
|
|
303
|
+
markdown's top-of-tree numbering. Gaps are preserved.
|
|
304
|
+
"""
|
|
305
|
+
if not headings:
|
|
306
|
+
return headings
|
|
307
|
+
origin = min(int(record.level) for record in headings)
|
|
308
|
+
if origin <= 1:
|
|
309
|
+
return headings
|
|
310
|
+
delta = origin - 1
|
|
311
|
+
return tuple(
|
|
312
|
+
HeadingRecord(
|
|
313
|
+
level=HeadingLevel(int(record.level) - delta),
|
|
314
|
+
line_index=record.line_index,
|
|
315
|
+
title_end_line=record.title_end_line,
|
|
316
|
+
text=record.text,
|
|
317
|
+
)
|
|
318
|
+
for record in headings
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def parse_tex_headings(lines: Sequence[str]) -> tuple[HeadingRecord, ...]:
|
|
323
|
+
records: list[HeadingRecord] = []
|
|
324
|
+
i = LineIndex(0)
|
|
325
|
+
while i < len(lines):
|
|
326
|
+
parsed = _parse_tex_heading_at(lines, i)
|
|
327
|
+
if parsed is None:
|
|
328
|
+
i = LineIndex(i + 1)
|
|
329
|
+
continue
|
|
330
|
+
record, consumed_through = parsed
|
|
331
|
+
records.append(record)
|
|
332
|
+
i = LineIndex(consumed_through + 1)
|
|
333
|
+
return relative_heading_levels(tuple(records))
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def split_frontmatter(lines: Sequence[str]) -> FrontmatterSplit:
|
|
337
|
+
body = tuple(lines)
|
|
338
|
+
if not body or body[0].rstrip() != "---":
|
|
339
|
+
return FrontmatterSplit(frontmatter=None, body=body)
|
|
340
|
+
for i in range(1, len(body)):
|
|
341
|
+
if body[i].rstrip() == "---":
|
|
342
|
+
return FrontmatterSplit(frontmatter=body[: i + 1], body=body[i + 1 :])
|
|
343
|
+
return FrontmatterSplit(frontmatter=None, body=body)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def extract_section(
|
|
347
|
+
lines: Sequence[str],
|
|
348
|
+
heading_path: HeadingPath,
|
|
349
|
+
*,
|
|
350
|
+
level_filter: HeadingLevel | None = None,
|
|
351
|
+
headings: tuple[HeadingRecord, ...] | None = None,
|
|
352
|
+
) -> ExtractedSection | None:
|
|
353
|
+
"""Extract by heading path. Same containment walk as the pre-split module.
|
|
354
|
+
|
|
355
|
+
level_filter is an exact rank, so same-named headings at different
|
|
356
|
+
levels stay distinguishable.
|
|
357
|
+
"""
|
|
358
|
+
if headings is None:
|
|
359
|
+
headings = parse_headings(lines)
|
|
360
|
+
segments = heading_path.segments
|
|
361
|
+
if not segments:
|
|
362
|
+
return None
|
|
363
|
+
|
|
364
|
+
def section_end(start_idx: LineIndex, start_level: HeadingLevel) -> int:
|
|
365
|
+
for record in headings:
|
|
366
|
+
if record.line_index > start_idx and record.level <= start_level:
|
|
367
|
+
return int(record.line_index)
|
|
368
|
+
return len(lines)
|
|
369
|
+
|
|
370
|
+
bound_start = -1
|
|
371
|
+
bound_end = len(lines)
|
|
372
|
+
prev_level = HeadingLevel(0)
|
|
373
|
+
match_idx: LineIndex | None = None
|
|
374
|
+
match_level: HeadingLevel | None = None
|
|
375
|
+
match_title_end: LineIndex | None = None
|
|
376
|
+
|
|
377
|
+
for i, segment in enumerate(segments):
|
|
378
|
+
needle = str(segment).lower()
|
|
379
|
+
is_last = i == len(segments) - 1
|
|
380
|
+
found: HeadingRecord | None = None
|
|
381
|
+
for record in headings:
|
|
382
|
+
if record.line_index <= bound_start or record.line_index >= bound_end:
|
|
383
|
+
continue
|
|
384
|
+
if i > 0 and record.level <= prev_level:
|
|
385
|
+
continue
|
|
386
|
+
if str(record.text).lower().strip() != needle:
|
|
387
|
+
continue
|
|
388
|
+
if is_last and level_filter is not None and record.level != level_filter:
|
|
389
|
+
continue
|
|
390
|
+
found = record
|
|
391
|
+
break
|
|
392
|
+
if found is None:
|
|
393
|
+
return None
|
|
394
|
+
match_level = found.level
|
|
395
|
+
match_idx = found.line_index
|
|
396
|
+
match_title_end = found.title_end_line
|
|
397
|
+
bound_start = int(match_idx)
|
|
398
|
+
prev_level = match_level
|
|
399
|
+
bound_end = section_end(match_idx, match_level)
|
|
400
|
+
|
|
401
|
+
if match_idx is None or match_level is None or match_title_end is None:
|
|
402
|
+
return None
|
|
403
|
+
return ExtractedSection(
|
|
404
|
+
level=match_level,
|
|
405
|
+
lines=tuple(lines[int(match_idx) : bound_end]),
|
|
406
|
+
heading_line_count=HeadingLineCount(int(match_title_end) - int(match_idx) + 1),
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def match_outline_span(
|
|
411
|
+
outline: Sequence[OutlineRecord],
|
|
412
|
+
heading_path: HeadingPath,
|
|
413
|
+
*,
|
|
414
|
+
level_filter: OutlineLevel | None = None,
|
|
415
|
+
) -> OutlinePageSpan | None:
|
|
416
|
+
"""Dest-to-next-dest page bounds. Does not read page text."""
|
|
417
|
+
segments = heading_path.segments
|
|
418
|
+
if not segments:
|
|
419
|
+
return None
|
|
420
|
+
|
|
421
|
+
def containment_end(start_idx: int, start_level: OutlineLevel) -> int:
|
|
422
|
+
for j in range(start_idx + 1, len(outline)):
|
|
423
|
+
if outline[j].level <= start_level:
|
|
424
|
+
return j
|
|
425
|
+
return len(outline)
|
|
426
|
+
|
|
427
|
+
def next_dest_page(after_idx: int) -> PageIndex | None:
|
|
428
|
+
for j in range(after_idx + 1, len(outline)):
|
|
429
|
+
page = outline[j].page_index
|
|
430
|
+
if page is not None:
|
|
431
|
+
return page
|
|
432
|
+
return None
|
|
433
|
+
|
|
434
|
+
bound_start = -1
|
|
435
|
+
bound_end = len(outline)
|
|
436
|
+
prev_level = OutlineLevel(0)
|
|
437
|
+
match_idx: int | None = None
|
|
438
|
+
match_level: OutlineLevel | None = None
|
|
439
|
+
|
|
440
|
+
for i, segment in enumerate(segments):
|
|
441
|
+
target = str(segment).lower()
|
|
442
|
+
is_last = i == len(segments) - 1
|
|
443
|
+
found: OutlineRecord | None = None
|
|
444
|
+
found_idx: int | None = None
|
|
445
|
+
for j, record in enumerate(outline):
|
|
446
|
+
if j <= bound_start or j >= bound_end:
|
|
447
|
+
continue
|
|
448
|
+
if i > 0 and record.level <= prev_level:
|
|
449
|
+
continue
|
|
450
|
+
if str(record.title).lower().strip() != target:
|
|
451
|
+
continue
|
|
452
|
+
if is_last and level_filter is not None and record.level != level_filter:
|
|
453
|
+
continue
|
|
454
|
+
found = record
|
|
455
|
+
found_idx = j
|
|
456
|
+
break
|
|
457
|
+
if found is None or found_idx is None:
|
|
458
|
+
return None
|
|
459
|
+
match_level = found.level
|
|
460
|
+
match_idx = found_idx
|
|
461
|
+
bound_start = match_idx
|
|
462
|
+
prev_level = match_level
|
|
463
|
+
bound_end = containment_end(match_idx, match_level)
|
|
464
|
+
|
|
465
|
+
if match_idx is None or match_level is None:
|
|
466
|
+
return None
|
|
467
|
+
return OutlinePageSpan(
|
|
468
|
+
level=match_level,
|
|
469
|
+
start_page=outline[match_idx].page_index,
|
|
470
|
+
end_page=next_dest_page(match_idx),
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def exclusive_page_end(span: OutlinePageSpan) -> PageIndex | None:
|
|
475
|
+
"""Half-open end page. Same-page next dest includes the dest page."""
|
|
476
|
+
if span.start_page is None:
|
|
477
|
+
return None
|
|
478
|
+
if span.end_page is None:
|
|
479
|
+
return None
|
|
480
|
+
if int(span.end_page) <= int(span.start_page):
|
|
481
|
+
return PageIndex(int(span.start_page) + 1)
|
|
482
|
+
return span.end_page
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def extract_outline_section(
|
|
486
|
+
document: PdfDocument,
|
|
487
|
+
heading_path: HeadingPath,
|
|
488
|
+
*,
|
|
489
|
+
level_filter: OutlineLevel | None = None,
|
|
490
|
+
) -> ExtractedOutline | None:
|
|
491
|
+
"""Slice in-memory page text for a dest-to-next-dest span."""
|
|
492
|
+
span = match_outline_span(document.outline, heading_path, level_filter=level_filter)
|
|
493
|
+
if span is None:
|
|
494
|
+
return None
|
|
495
|
+
if span.start_page is None:
|
|
496
|
+
return ExtractedOutline(level=span.level, pages=())
|
|
497
|
+
start = int(span.start_page)
|
|
498
|
+
end = exclusive_page_end(span)
|
|
499
|
+
if end is None:
|
|
500
|
+
page_slice = document.pages[start:]
|
|
501
|
+
else:
|
|
502
|
+
page_slice = document.pages[start : int(end)]
|
|
503
|
+
return ExtractedOutline(level=span.level, pages=tuple(page_slice))
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def format_empty_index(*, line_count: LineCount, byte_count: ByteCount) -> RenderedBody:
|
|
507
|
+
return RenderedBody(f"no structural index\nlines: {int(line_count)}\nbytes: {int(byte_count)}")
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def format_empty_pdf_index(*, page_count: PageCount, byte_count: ByteCount) -> RenderedBody:
|
|
511
|
+
return RenderedBody(f"no structural index\npages: {int(page_count)}\nbytes: {int(byte_count)}")
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def format_heading_list(
|
|
515
|
+
lines: Sequence[str],
|
|
516
|
+
*,
|
|
517
|
+
headings: tuple[HeadingRecord, ...] | None = None,
|
|
518
|
+
level_filter: HeadingLevel | None = None,
|
|
519
|
+
) -> RenderedBody:
|
|
520
|
+
"""List headings. level_filter is a maximum rank (1 through N)."""
|
|
521
|
+
records = parse_headings(lines) if headings is None else headings
|
|
522
|
+
if level_filter is not None:
|
|
523
|
+
records = tuple(record for record in records if record.level <= level_filter)
|
|
524
|
+
out: list[str] = []
|
|
525
|
+
for record in records:
|
|
526
|
+
indent = " " * (int(record.level) - 1)
|
|
527
|
+
out.append(f"{indent}- {record.text}")
|
|
528
|
+
return RenderedBody("\n".join(out))
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def format_outline_list(
|
|
532
|
+
outline: Sequence[OutlineRecord],
|
|
533
|
+
*,
|
|
534
|
+
level_filter: OutlineLevel | None = None,
|
|
535
|
+
) -> RenderedBody:
|
|
536
|
+
"""List outline items. level_filter is a maximum rank (1 through N)."""
|
|
537
|
+
records = outline
|
|
538
|
+
if level_filter is not None:
|
|
539
|
+
records = tuple(record for record in records if record.level <= level_filter)
|
|
540
|
+
out: list[str] = []
|
|
541
|
+
for record in records:
|
|
542
|
+
indent = " " * (int(record.level) - 1)
|
|
543
|
+
out.append(f"{indent}- {record.title}")
|
|
544
|
+
return RenderedBody("\n".join(out))
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def format_file_index(
|
|
548
|
+
split: FrontmatterSplit,
|
|
549
|
+
*,
|
|
550
|
+
headings: tuple[HeadingRecord, ...] | None = None,
|
|
551
|
+
level_filter: HeadingLevel | None = None,
|
|
552
|
+
) -> RenderedBody:
|
|
553
|
+
frontmatter = "\n".join(split.frontmatter) if split.frontmatter is not None else ""
|
|
554
|
+
heading_list = format_heading_list(split.body, headings=headings, level_filter=level_filter)
|
|
555
|
+
if frontmatter and heading_list:
|
|
556
|
+
return RenderedBody(f"{frontmatter}\n\n{heading_list}")
|
|
557
|
+
return RenderedBody(frontmatter or heading_list)
|
surf/models.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# models.py
|
|
2
|
+
"""Immutable domain values for surf. No I/O."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from typing import NewType
|
|
9
|
+
|
|
10
|
+
HeadingText = NewType("HeadingText", str)
|
|
11
|
+
FileRef = NewType("FileRef", str)
|
|
12
|
+
HeadingLevel = NewType("HeadingLevel", int)
|
|
13
|
+
OutlineLevel = NewType("OutlineLevel", int)
|
|
14
|
+
LineIndex = NewType("LineIndex", int)
|
|
15
|
+
PageIndex = NewType("PageIndex", int)
|
|
16
|
+
ScanBuffer = NewType("ScanBuffer", str)
|
|
17
|
+
CharOffset = NewType("CharOffset", int)
|
|
18
|
+
Delimiter = NewType("Delimiter", str)
|
|
19
|
+
HeadingLineCount = NewType("HeadingLineCount", int)
|
|
20
|
+
CliTarget = NewType("CliTarget", str)
|
|
21
|
+
HeadingPathRemainder = NewType("HeadingPathRemainder", str)
|
|
22
|
+
RenderedBody = NewType("RenderedBody", str)
|
|
23
|
+
ErrorMessage = NewType("ErrorMessage", str)
|
|
24
|
+
ExitCode = NewType("ExitCode", int)
|
|
25
|
+
TexIncludeRelPath = NewType("TexIncludeRelPath", str)
|
|
26
|
+
LineCount = NewType("LineCount", int)
|
|
27
|
+
ByteCount = NewType("ByteCount", int)
|
|
28
|
+
PageCount = NewType("PageCount", int)
|
|
29
|
+
|
|
30
|
+
type DocumentLines = tuple[str, ...]
|
|
31
|
+
type PageText = str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TexCommand(StrEnum):
|
|
35
|
+
PART = "part"
|
|
36
|
+
CHAPTER = "chapter"
|
|
37
|
+
SECTION = "section"
|
|
38
|
+
SUBSECTION = "subsection"
|
|
39
|
+
SUBSUBSECTION = "subsubsection"
|
|
40
|
+
PARAGRAPH = "paragraph"
|
|
41
|
+
SUBPARAGRAPH = "subparagraph"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TexIncludeCommand(StrEnum):
|
|
45
|
+
INPUT = "input"
|
|
46
|
+
INCLUDE = "include"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TexEnvironment(StrEnum):
|
|
50
|
+
ABSTRACT = "abstract"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class HeadingPath:
|
|
55
|
+
segments: tuple[HeadingText, ...]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class HeadingRecord:
|
|
60
|
+
level: HeadingLevel
|
|
61
|
+
line_index: LineIndex
|
|
62
|
+
title_end_line: LineIndex
|
|
63
|
+
text: HeadingText
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class ClosedSpan:
|
|
68
|
+
buffer: ScanBuffer
|
|
69
|
+
end: CharOffset
|
|
70
|
+
last_line: LineIndex
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True)
|
|
74
|
+
class OutlineRecord:
|
|
75
|
+
level: OutlineLevel
|
|
76
|
+
title: HeadingText
|
|
77
|
+
page_index: PageIndex | None
|
|
78
|
+
top: float | None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True, slots=True)
|
|
82
|
+
class PdfDocument:
|
|
83
|
+
outline: tuple[OutlineRecord, ...]
|
|
84
|
+
pages: tuple[PageText, ...]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True, slots=True)
|
|
88
|
+
class PdfCatalog:
|
|
89
|
+
outline: tuple[OutlineRecord, ...]
|
|
90
|
+
page_count: PageCount
|
|
91
|
+
byte_count: ByteCount
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True, slots=True)
|
|
95
|
+
class OutlinePageSpan:
|
|
96
|
+
"""Dest-to-next-dest page range. start_page None means found but no dest."""
|
|
97
|
+
|
|
98
|
+
level: OutlineLevel
|
|
99
|
+
start_page: PageIndex | None
|
|
100
|
+
end_page: PageIndex | None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True, slots=True)
|
|
104
|
+
class ParsedLink:
|
|
105
|
+
file_ref: FileRef | None
|
|
106
|
+
heading_path: HeadingPath | None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True, slots=True)
|
|
110
|
+
class FrontmatterSplit:
|
|
111
|
+
frontmatter: DocumentLines | None
|
|
112
|
+
body: DocumentLines
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True, slots=True)
|
|
116
|
+
class ExtractedSection:
|
|
117
|
+
level: HeadingLevel
|
|
118
|
+
lines: DocumentLines
|
|
119
|
+
heading_line_count: HeadingLineCount
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True, slots=True)
|
|
123
|
+
class ExtractedOutline:
|
|
124
|
+
level: OutlineLevel
|
|
125
|
+
pages: tuple[PageText, ...]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True, slots=True)
|
|
129
|
+
class CliOptions:
|
|
130
|
+
file_ref: FileRef | None
|
|
131
|
+
heading_path: HeadingPath | None
|
|
132
|
+
list_headings: bool
|
|
133
|
+
frontmatter_only: bool
|
|
134
|
+
full: bool
|
|
135
|
+
no_heading: bool
|
|
136
|
+
level_filter: int | None
|
|
137
|
+
output_ref: FileRef | None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True, slots=True)
|
|
141
|
+
class CliSuccess:
|
|
142
|
+
body: RenderedBody
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True, slots=True)
|
|
146
|
+
class CliFailure:
|
|
147
|
+
message: ErrorMessage
|
|
148
|
+
exit_code: ExitCode
|