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/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