viparse 0.1.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.
viparse/__init__.py ADDED
@@ -0,0 +1,106 @@
1
+ """viparse — a Vietnamese-first document loader for RAG."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from viparse.api import load, load_batch
6
+ from viparse.cache import Cache, DiskCache, MemoryCache
7
+ from viparse.chunk import ChunkOptions, chunk_document
8
+ from viparse.config import Settings, load_settings
9
+ from viparse.detect import DetectedFormat, detect_format
10
+ from viparse.engines.docx import DocxEngine
11
+ from viparse.engines.legacy import LegacyOfficeEngine
12
+ from viparse.engines.ocr import OcrEngine
13
+ from viparse.engines.pdf import PdfEngine
14
+ from viparse.engines.xlsx import XlsxEngine
15
+ from viparse.errors import (
16
+ ConfigError,
17
+ EncodingError,
18
+ EngineUnavailable,
19
+ ExtractionError,
20
+ MissingDependency,
21
+ UnsafeInput,
22
+ UnsupportedFormat,
23
+ ViparseError,
24
+ )
25
+ from viparse.integrations import to_langchain_documents, to_llamaindex_documents
26
+ from viparse.model import (
27
+ SCHEMA_VERSION,
28
+ Block,
29
+ Chunk,
30
+ Document,
31
+ DocumentMetadata,
32
+ Heading,
33
+ NormalizedDoc,
34
+ Paragraph,
35
+ RawExtraction,
36
+ Table,
37
+ )
38
+ from viparse.normalize.normalizer import VietnameseNormalizer
39
+ from viparse.observability import MetricsHook, PipelineMetrics
40
+ from viparse.options import LoadOptions, NormalizeForm, OutputFormat
41
+ from viparse.pipeline import Pipeline
42
+ from viparse.protocols import (
43
+ DEFAULT_PRIORITY,
44
+ Engine,
45
+ Normalizer,
46
+ Renderer,
47
+ Source,
48
+ )
49
+ from viparse.registry import EngineRegistry
50
+ from viparse.structure import DocumentRenderer
51
+
52
+ __version__ = "0.1.0"
53
+
54
+ __all__ = [
55
+ "DEFAULT_PRIORITY",
56
+ "Block",
57
+ "Cache",
58
+ "Chunk",
59
+ "ChunkOptions",
60
+ "chunk_document",
61
+ "ConfigError",
62
+ "DetectedFormat",
63
+ "DiskCache",
64
+ "MemoryCache",
65
+ "DocumentRenderer",
66
+ "DocxEngine",
67
+ "Document",
68
+ "DocumentMetadata",
69
+ "EncodingError",
70
+ "Engine",
71
+ "EngineRegistry",
72
+ "EngineUnavailable",
73
+ "ExtractionError",
74
+ "Heading",
75
+ "LegacyOfficeEngine",
76
+ "LoadOptions",
77
+ "load",
78
+ "load_batch",
79
+ "MetricsHook",
80
+ "MissingDependency",
81
+ "NormalizeForm",
82
+ "NormalizedDoc",
83
+ "Normalizer",
84
+ "OcrEngine",
85
+ "OutputFormat",
86
+ "Paragraph",
87
+ "PdfEngine",
88
+ "Pipeline",
89
+ "PipelineMetrics",
90
+ "RawExtraction",
91
+ "Renderer",
92
+ "SCHEMA_VERSION",
93
+ "Settings",
94
+ "Source",
95
+ "Table",
96
+ "load_settings",
97
+ "to_langchain_documents",
98
+ "to_llamaindex_documents",
99
+ "UnsafeInput",
100
+ "UnsupportedFormat",
101
+ "VietnameseNormalizer",
102
+ "ViparseError",
103
+ "XlsxEngine",
104
+ "__version__",
105
+ "detect_format",
106
+ ]
viparse/api.py ADDED
@@ -0,0 +1,250 @@
1
+ """The public :func:`load` / :func:`load_batch` API — the surface most callers use.
2
+
3
+ These are thin ergonomic wrappers over :class:`~viparse.pipeline.Pipeline`, assembled
4
+ with the built-in engines, the Vietnamese normalizer, and the document renderer. The
5
+ keyword-only parameters map onto :class:`~viparse.options.LoadOptions`; keeping them
6
+ keyword-only lets the surface grow (SemVer-safely) without breaking callers.
7
+
8
+ ``load`` returns a ``list[Document]`` — one entry today, but the list shape reserves
9
+ room for sources that fan out into several documents (e.g. one per spreadsheet sheet)
10
+ without a signature change.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections import deque
16
+ from collections.abc import Iterable, Iterator
17
+ from concurrent.futures import Future, ThreadPoolExecutor
18
+
19
+ from viparse.cache import Cache, cache_key
20
+ from viparse.chunk import ChunkOptions
21
+ from viparse.config import UNSET, Settings, _Unset, load_settings
22
+ from viparse.engines.docx import DocxEngine
23
+ from viparse.engines.legacy import LegacyOfficeEngine
24
+ from viparse.engines.ocr import OcrEngine
25
+ from viparse.engines.pdf import PdfEngine
26
+ from viparse.engines.xlsx import XlsxEngine
27
+ from viparse.errors import ViparseError
28
+ from viparse.model import Document, NormalizedDoc
29
+ from viparse.normalize.normalizer import VietnameseNormalizer
30
+ from viparse.options import LoadOptions, NormalizeForm, OutputFormat
31
+ from viparse.pipeline import Pipeline
32
+ from viparse.protocols import Engine, Source
33
+ from viparse.registry import EngineRegistry
34
+ from viparse.structure import DocumentRenderer
35
+
36
+ # Content type for a batch error Document whose real type was never determined (matches
37
+ # the pipeline's own unknown-type sentinel).
38
+ _UNKNOWN_CONTENT_TYPE = "application/octet-stream"
39
+
40
+
41
+ def _default_engines() -> list[Engine]:
42
+ """The engines registered by default.
43
+
44
+ Each is a thin adapter whose heavy parse library is imported lazily at extraction
45
+ time, so listing one here never pulls its dependency at import — ``core`` stays light.
46
+ """
47
+ return [DocxEngine(), XlsxEngine(), PdfEngine(), OcrEngine(), LegacyOfficeEngine()]
48
+
49
+
50
+ def _build_pipeline() -> Pipeline:
51
+ """Assemble the default pipeline: built-in engines → normalizer → renderer."""
52
+ registry = EngineRegistry()
53
+ for engine in _default_engines():
54
+ registry.register(engine)
55
+ return Pipeline(registry, VietnameseNormalizer(), DocumentRenderer())
56
+
57
+
58
+ def _resolve_options(
59
+ settings: Settings | None,
60
+ output: OutputFormat | _Unset,
61
+ encoding: str | None | _Unset,
62
+ ocr: bool | None | _Unset,
63
+ normalize: NormalizeForm | _Unset,
64
+ max_bytes: int | _Unset,
65
+ chunk: ChunkOptions | None,
66
+ ) -> LoadOptions:
67
+ """Layer the public keyword parameters over the resolved :class:`Settings`.
68
+
69
+ An argument left as :data:`~viparse.config.UNSET` falls back to ``settings`` (which,
70
+ when not supplied, is resolved from ``VIPARSE_*`` env vars and ``viparse.toml``); an
71
+ explicit argument always wins. ``chunk`` is per-call only and never layered.
72
+ """
73
+ base = settings if settings is not None else load_settings()
74
+ return LoadOptions(
75
+ fmt=base.output if output is UNSET else output,
76
+ encoding=base.encoding if encoding is UNSET else encoding,
77
+ ocr=base.ocr if ocr is UNSET else ocr,
78
+ normalize_form=base.normalize if normalize is UNSET else normalize,
79
+ max_bytes=base.max_bytes if max_bytes is UNSET else max_bytes,
80
+ chunk=chunk,
81
+ )
82
+
83
+
84
+ def load(
85
+ source: Source,
86
+ *,
87
+ output: OutputFormat | _Unset = UNSET,
88
+ encoding: str | None | _Unset = UNSET,
89
+ ocr: bool | None | _Unset = UNSET,
90
+ normalize: NormalizeForm | _Unset = UNSET,
91
+ max_bytes: int | _Unset = UNSET,
92
+ cache: Cache | None = None,
93
+ chunk: ChunkOptions | None = None,
94
+ settings: Settings | None = None,
95
+ ) -> list[Document]:
96
+ """Parse ``source`` into a list of Unicode-**NFC** :class:`Document` objects.
97
+
98
+ The ``output`` / ``encoding`` / ``ocr`` / ``normalize`` / ``max_bytes`` options are
99
+ **layered** (SPEC-5 E5.4): an argument left unset falls back to ``VIPARSE_*`` env vars,
100
+ then ``viparse.toml``, then the built-in default; an explicit argument always wins.
101
+
102
+ :param source: path to the document (``str`` or :class:`~pathlib.Path`).
103
+ :param output: rendered output format — ``"markdown"`` (default), ``"text"``, or ``"json"``.
104
+ :param encoding: force a legacy source encoding (e.g. ``"tcvn3"``), or ``"auto"`` to
105
+ opt into content-based detection when the source carries no font signal.
106
+ :param ocr: force OCR on/off; the router otherwise decides from the file.
107
+ :param normalize: target Unicode normalization form (default ``"NFC"``).
108
+ :param max_bytes: reject an input larger than this many bytes (default 100 MiB).
109
+ :param cache: optional content-hash :class:`~viparse.cache.Cache`; a cache hit skips
110
+ re-parsing an unchanged file (SPEC-7 E7.3).
111
+ :param chunk: optional :class:`~viparse.chunk.ChunkOptions`; when given, the returned
112
+ Document's ``chunks`` are populated with retrieval-sized pieces (SPEC-4 E4.2).
113
+ :param settings: pre-resolved :class:`~viparse.config.Settings` to layer under the
114
+ arguments; when omitted, settings are resolved from the environment and config file
115
+ **on every call** (a ``viparse.toml`` stat/read + an env scan). When loading many
116
+ files, resolve once with :func:`~viparse.load_settings` and pass ``settings=`` here
117
+ (or use :func:`load_batch`, which resolves once for the whole batch).
118
+ """
119
+ options = _resolve_options(settings, output, encoding, ocr, normalize, max_bytes, chunk)
120
+ return [_load_one(_build_pipeline(), source, options, cache)]
121
+
122
+
123
+ def _load_one(
124
+ pipeline: Pipeline, source: Source, options: LoadOptions, cache: Cache | None
125
+ ) -> Document:
126
+ """Run one source through the pipeline, consulting/populating ``cache`` if given."""
127
+ if cache is None:
128
+ return pipeline.run(source, options)
129
+ key = cache_key(source, options)
130
+ cached = cache.get(key)
131
+ if cached is not None:
132
+ return cached
133
+ document = pipeline.run(source, options)
134
+ cache.set(key, document)
135
+ return document
136
+
137
+
138
+ def _error_document(source: Source, error: Exception, fmt: OutputFormat) -> Document:
139
+ """A best-effort Document for a source that failed to load in a batch.
140
+
141
+ Rendered through the normal renderer so its ``text`` is valid for the requested format
142
+ (e.g. a parseable JSON payload under ``output="json"``, not an empty string), with the
143
+ failure in ``metadata.warnings`` and the unknown-content sentinel as the content type.
144
+ """
145
+ normalized = NormalizedDoc(
146
+ source=str(source),
147
+ content_type=_UNKNOWN_CONTENT_TYPE,
148
+ text="",
149
+ warnings=[f"failed to load: {error}"],
150
+ )
151
+ return DocumentRenderer().render(normalized, fmt)
152
+
153
+
154
+ def _batch_load(
155
+ pipeline: Pipeline, source: Source, options: LoadOptions, cache: Cache | None
156
+ ) -> Document:
157
+ """Load one source for a batch, isolating a per-source *failure* into an error Document.
158
+
159
+ Only extraction/format failures (:class:`~viparse.errors.ViparseError`) and file-access
160
+ errors (:class:`OSError`) are isolated so one bad file can't sink the batch. Programming
161
+ bugs (``TypeError`` etc.) deliberately propagate — matching the pipeline's rule that
162
+ they must never be silently downgraded to a warning.
163
+ """
164
+ try:
165
+ return _load_one(pipeline, source, options, cache)
166
+ except (ViparseError, OSError) as error:
167
+ return _error_document(source, error, options.fmt)
168
+
169
+
170
+ def load_batch(
171
+ sources: Iterable[Source],
172
+ *,
173
+ output: OutputFormat | _Unset = UNSET,
174
+ encoding: str | None | _Unset = UNSET,
175
+ ocr: bool | None | _Unset = UNSET,
176
+ normalize: NormalizeForm | _Unset = UNSET,
177
+ max_bytes: int | _Unset = UNSET,
178
+ cache: Cache | None = None,
179
+ workers: int | None = None,
180
+ chunk: ChunkOptions | None = None,
181
+ settings: Settings | None = None,
182
+ ) -> Iterator[list[Document]]:
183
+ """Lazily load each source, yielding its result list **in input order**.
184
+
185
+ A generator (not a materialized list), so results stream out as they're ready and the
186
+ caller controls consumption. Unlike :func:`load`, a batch **isolates failures**: a
187
+ source that errors yields a best-effort Document recording the failure (its
188
+ ``metadata.warnings``) instead of sinking the whole batch (SPEC-7 T7.2.3).
189
+
190
+ ``workers`` (>1) parses up to that many sources concurrently on a thread pool —
191
+ extraction is largely IO / subprocess bound (LibreOffice, Tesseract, file reads). Only
192
+ ``workers`` sources are ever in flight at once (backpressure), and output order still
193
+ matches the input. ``None``/1 runs sequentially. Abandoning the iterator early waits for
194
+ the (at most ``workers``) in-flight parses to finish before the pool shuts down.
195
+
196
+ ``chunk`` behaves as in :func:`load`: when set, each result Document's ``chunks`` are
197
+ populated with retrieval-sized pieces. ``output`` / ``encoding`` / ``ocr`` / ``normalize`` /
198
+ ``max_bytes`` are layered over ``settings`` exactly as in :func:`load`.
199
+
200
+ Option resolution (and any :class:`~viparse.errors.ConfigError` from bad env/config) happens
201
+ **eagerly at call time**, not on first iteration — so a misconfiguration surfaces to the
202
+ caller here rather than mid-stream, past the per-source error isolation.
203
+ """
204
+ options = _resolve_options(settings, output, encoding, ocr, normalize, max_bytes, chunk)
205
+ pipeline = _build_pipeline()
206
+ return _iter_batch(pipeline, sources, options, cache, workers)
207
+
208
+
209
+ def _iter_batch(
210
+ pipeline: Pipeline,
211
+ sources: Iterable[Source],
212
+ options: LoadOptions,
213
+ cache: Cache | None,
214
+ workers: int | None,
215
+ ) -> Iterator[list[Document]]:
216
+ """The lazy per-source generator behind :func:`load_batch` (options already resolved)."""
217
+ if workers is None or workers <= 1:
218
+ for source in sources:
219
+ yield [_batch_load(pipeline, source, options, cache)]
220
+ return
221
+ yield from _parallel_load_batch(pipeline, sources, options, cache, workers)
222
+
223
+
224
+ def _parallel_load_batch(
225
+ pipeline: Pipeline,
226
+ sources: Iterable[Source],
227
+ options: LoadOptions,
228
+ cache: Cache | None,
229
+ workers: int,
230
+ ) -> Iterator[list[Document]]:
231
+ """Parse sources on a bounded thread pool, yielding results in input order."""
232
+ pending: deque[Future[Document]] = deque()
233
+ source_iter = iter(sources)
234
+ with ThreadPoolExecutor(max_workers=workers) as executor:
235
+
236
+ def submit_next() -> bool:
237
+ try:
238
+ source = next(source_iter)
239
+ except StopIteration:
240
+ return False
241
+ pending.append(executor.submit(_batch_load, pipeline, source, options, cache))
242
+ return True
243
+
244
+ for _ in range(workers): # prime the in-flight window
245
+ if not submit_next():
246
+ break
247
+ while pending:
248
+ document = pending.popleft().result() # oldest first → input order
249
+ submit_next() # keep the window full as each result drains
250
+ yield [document]
viparse/cache.py ADDED
@@ -0,0 +1,120 @@
1
+ """Content-hash caching so unchanged files are not re-parsed (SPEC-7 E7.3).
2
+
3
+ A cache is keyed by the file's content hash *plus* the options that affect the output
4
+ (format, encoding, OCR, normalization) and the :data:`~viparse.model.SCHEMA_VERSION`, so
5
+ a changed file, different options, or a new pipeline version all miss the cache and never
6
+ serve a stale result. Caching is opt-in — pass a :class:`Cache` to :func:`viparse.load`.
7
+
8
+ Two stores ship: :class:`MemoryCache` (per-process) and :class:`DiskCache` (persists
9
+ across runs). Both satisfy the small :class:`Cache` Protocol, so callers can supply their
10
+ own (Redis, S3, …).
11
+
12
+ .. note::
13
+ The key is computed by hashing the file *before* the pipeline re-reads it to parse, so
14
+ rewriting a file in place *during* a load is a TOCTOU race that can cache the new bytes
15
+ under the old hash. Don't mutate files in place while loading them; write a new file
16
+ (atomic rename) instead.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import os
23
+ import pickle
24
+ import tempfile
25
+ from pathlib import Path
26
+ from typing import Protocol, runtime_checkable
27
+
28
+ from viparse.model import SCHEMA_VERSION, Document
29
+ from viparse.options import LoadOptions
30
+ from viparse.protocols import Source
31
+
32
+ _HASH_CHUNK = 1024 * 1024 # hash the file in 1 MiB chunks (bounded memory)
33
+
34
+
35
+ @runtime_checkable
36
+ class Cache(Protocol):
37
+ """A content-addressed store of parsed :class:`Document` results."""
38
+
39
+ def get(self, key: str) -> Document | None:
40
+ """Return the cached document for ``key``, or ``None`` on a miss."""
41
+ ...
42
+
43
+ def set(self, key: str, document: Document) -> None:
44
+ """Store ``document`` under ``key``."""
45
+ ...
46
+
47
+
48
+ class MemoryCache:
49
+ """A simple in-process cache backed by a dict."""
50
+
51
+ def __init__(self) -> None:
52
+ self._store: dict[str, Document] = {}
53
+
54
+ def get(self, key: str) -> Document | None:
55
+ return self._store.get(key)
56
+
57
+ def set(self, key: str, document: Document) -> None:
58
+ self._store[key] = document
59
+
60
+
61
+ class DiskCache:
62
+ """A cache that persists pickled documents under a directory, one file per key."""
63
+
64
+ def __init__(self, directory: Source) -> None:
65
+ self._dir = Path(directory)
66
+ self._dir.mkdir(parents=True, exist_ok=True)
67
+
68
+ def _path(self, key: str) -> Path:
69
+ # Hash the key into the filename so an arbitrary key can never traverse out of the
70
+ # cache directory, and any string is a valid (fixed-length, filesystem-safe) name.
71
+ name = hashlib.sha256(key.encode("utf-8")).hexdigest()
72
+ return self._dir / f"{name}.pickle"
73
+
74
+ def get(self, key: str) -> Document | None:
75
+ try:
76
+ with self._path(key).open("rb") as fh:
77
+ # Trusted input: the cache directory holds only this library's own writes.
78
+ document: Document = pickle.load(fh)
79
+ except FileNotFoundError:
80
+ return None # a plain miss
81
+ except Exception: # noqa: BLE001 — any corrupt/unreadable entry is treated as a miss
82
+ return None
83
+ return document
84
+
85
+ def set(self, key: str, document: Document) -> None:
86
+ # Write to a temp file in the same directory, then atomically rename into place, so
87
+ # a crash or a concurrent writer never leaves a half-written entry to be read back.
88
+ fd, tmp = tempfile.mkstemp(dir=self._dir, suffix=".tmp")
89
+ with os.fdopen(fd, "wb") as fh:
90
+ pickle.dump(document, fh)
91
+ os.replace(tmp, self._path(key))
92
+
93
+
94
+ def cache_key(source: Source, options: LoadOptions) -> str:
95
+ """A stable key from the file's content hash, its path, and the output-affecting options.
96
+
97
+ The path is part of the key so a hit only ever returns the document parsed from *that*
98
+ source (its ``metadata.source`` stays correct — two byte-identical files never share an
99
+ entry). The options are fingerprinted with :func:`repr` of a tuple so ``None`` and the
100
+ string ``"None"`` (and any field containing a delimiter) can never collide. ``max_bytes``
101
+ and ``strict`` are omitted because they do not change the parsed output; ``chunk`` is
102
+ included because it changes the document (its ``chunks``), and its frozen ``repr`` is stable.
103
+ """
104
+ digest = hashlib.sha256()
105
+ with Path(source).open("rb") as fh:
106
+ for block in iter(lambda: fh.read(_HASH_CHUNK), b""):
107
+ digest.update(block)
108
+ fingerprint = repr(
109
+ (
110
+ str(source),
111
+ SCHEMA_VERSION,
112
+ options.fmt,
113
+ options.encoding,
114
+ options.ocr,
115
+ options.normalize_form,
116
+ options.chunk,
117
+ )
118
+ )
119
+ digest.update(fingerprint.encode("utf-8"))
120
+ return digest.hexdigest()
viparse/chunk.py ADDED
@@ -0,0 +1,128 @@
1
+ """RAG chunking: split a normalized document into retrieval-sized pieces (SPEC-4 E4.2).
2
+
3
+ Chunking works on the **block structure** (headings / paragraphs / table rows), not the
4
+ flat text, so it can:
5
+
6
+ - keep every chunk within a single section — any section change (a heading, or an empty
7
+ heading that merely resets the section) starts a new chunk, so a chunk's ``section``
8
+ metadata is unambiguous (it never straddles a section boundary);
9
+ - **never split a table row** — each row is an atomic unit, and a large table is split at
10
+ row boundaries;
11
+ - carry per-chunk metadata (``section`` and inherited ``page`` / ``sheet``) plus the
12
+ chunk's ordinal :attr:`~viparse.model.Chunk.index` for downstream provenance.
13
+
14
+ Token counts are **approximate and tiktoken-free** (whitespace-delimited words), which is
15
+ enough to bound chunk size without pinning to any one model's tokenizer. A single block
16
+ larger than the target is emitted whole (sub-splitting long paragraphs is a future
17
+ refinement); table rows are always kept intact regardless of size.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from collections.abc import Iterator
23
+ from dataclasses import dataclass
24
+
25
+ from viparse.model import Block, Chunk, Heading, NormalizedDoc, Table, blocks_of
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class ChunkOptions:
30
+ """Chunking knobs: target size and overlap, both in approximate tokens (words)."""
31
+
32
+ max_tokens: int = 512
33
+ overlap_tokens: int = 64
34
+
35
+
36
+ def estimate_tokens(text: str) -> int:
37
+ """Approximate token count for ``text`` (tiktoken-free: whitespace-delimited words)."""
38
+ return len(text.split())
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class _Unit:
43
+ """An atomic chunkable piece — a heading, a paragraph, or a single table row."""
44
+
45
+ text: str
46
+ section: str
47
+ is_heading: bool
48
+
49
+
50
+ def chunk_document(document: NormalizedDoc, options: ChunkOptions) -> list[Chunk]:
51
+ """Split ``document`` into overlapping, single-section :class:`~viparse.model.Chunk`s."""
52
+ units = list(_iter_units(blocks_of(document)))
53
+ if not units:
54
+ return []
55
+
56
+ chunks: list[Chunk] = []
57
+ start = 0
58
+ count = len(units)
59
+ while True: # always terminates via the `end >= count` break below (count >= 1 here)
60
+ end = start
61
+ tokens = 0
62
+ while end < count:
63
+ if end > start and _crosses_section(units, start, end):
64
+ break # a new section starts here — never split one across chunks
65
+ unit_tokens = estimate_tokens(units[end].text)
66
+ if end > start and tokens + unit_tokens > options.max_tokens:
67
+ break # this unit would overflow — leave it for the next chunk
68
+ tokens += unit_tokens
69
+ end += 1
70
+ chunks.append(_make_chunk(units[start:end], len(chunks), document))
71
+ if end >= count:
72
+ break
73
+ # Overlap stays within a section: a section boundary starts the next chunk fresh.
74
+ if _crosses_section(units, start, end):
75
+ start = end
76
+ else:
77
+ start = _next_start(units, start, end, options)
78
+ return chunks
79
+
80
+
81
+ def _crosses_section(units: list[_Unit], start: int, end: int) -> bool:
82
+ """Whether ``units[end]`` opens a new section relative to the chunk started at ``start``.
83
+
84
+ True at an explicit heading unit *and* when the section label merely changes (an
85
+ empty-text heading resets the section without emitting a unit of its own), so a chunk
86
+ can never straddle a section boundary regardless of how it was introduced.
87
+ """
88
+ return units[end].is_heading or units[end].section != units[start].section
89
+
90
+
91
+ def _iter_units(blocks: list[Block]) -> Iterator[_Unit]:
92
+ """Flatten blocks into atomic units, tracking the section each unit belongs to."""
93
+ section = ""
94
+ for block in blocks:
95
+ if isinstance(block, Heading):
96
+ section = block.text
97
+ if block.text:
98
+ yield _Unit(text=block.text, section=section, is_heading=True)
99
+ elif isinstance(block, Table):
100
+ for row in block.rows:
101
+ if any(cell.strip() for cell in row): # skip a row whose every cell is blank
102
+ yield _Unit(text="\t".join(row), section=section, is_heading=False)
103
+ elif block.text: # Paragraph
104
+ yield _Unit(text=block.text, section=section, is_heading=False)
105
+
106
+
107
+ def _make_chunk(group: list[_Unit], index: int, document: NormalizedDoc) -> Chunk:
108
+ metadata: dict[str, object] = {
109
+ "section": group[0].section, # every unit in the group shares one section
110
+ "page": document.page,
111
+ "sheet": document.sheet,
112
+ }
113
+ return Chunk(text="\n".join(unit.text for unit in group), metadata=metadata, index=index)
114
+
115
+
116
+ def _next_start(units: list[_Unit], start: int, end: int, options: ChunkOptions) -> int:
117
+ """Where the next chunk begins: back up from ``end`` to repeat ~``overlap_tokens``.
118
+
119
+ Never returns ``<= start``, so the scan always makes progress (an overlap larger than
120
+ the chunk simply repeats everything but the first unit). ``units[start:end]`` share one
121
+ section, so this overlap never crosses a heading boundary.
122
+ """
123
+ cursor = end
124
+ tokens = 0
125
+ while cursor > start + 1 and tokens < options.overlap_tokens:
126
+ cursor -= 1
127
+ tokens += estimate_tokens(units[cursor].text)
128
+ return cursor