agent-folder-workspace 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.
Files changed (48) hide show
  1. agent_folder_workspace/__init__.py +42 -0
  2. agent_folder_workspace/__main__.py +6 -0
  3. agent_folder_workspace/adapters/__init__.py +5 -0
  4. agent_folder_workspace/adapters/base.py +93 -0
  5. agent_folder_workspace/adapters/legacy_office.py +552 -0
  6. agent_folder_workspace/adapters/manager.py +178 -0
  7. agent_folder_workspace/adapters/odf.py +636 -0
  8. agent_folder_workspace/adapters/ooxml.py +645 -0
  9. agent_folder_workspace/adapters/pdf.py +680 -0
  10. agent_folder_workspace/adapters/sqlite.py +326 -0
  11. agent_folder_workspace/adapters/structured.py +280 -0
  12. agent_folder_workspace/adapters/text.py +145 -0
  13. agent_folder_workspace/alternative_backends.py +329 -0
  14. agent_folder_workspace/backend_conversion_worker.py +232 -0
  15. agent_folder_workspace/backend_probe_worker.py +347 -0
  16. agent_folder_workspace/backends.py +321 -0
  17. agent_folder_workspace/cache.py +395 -0
  18. agent_folder_workspace/cfb.py +425 -0
  19. agent_folder_workspace/cli.py +139 -0
  20. agent_folder_workspace/config.py +37 -0
  21. agent_folder_workspace/cursors.py +46 -0
  22. agent_folder_workspace/errors.py +27 -0
  23. agent_folder_workspace/ids.py +42 -0
  24. agent_folder_workspace/installer.py +180 -0
  25. agent_folder_workspace/legacy.py +697 -0
  26. agent_folder_workspace/mcp_server.py +179 -0
  27. agent_folder_workspace/models.py +158 -0
  28. agent_folder_workspace/opencode/plugin/agent-folder-workspace.ts.tmpl +41 -0
  29. agent_folder_workspace/opencode/skill/SKILL.md +24 -0
  30. agent_folder_workspace/parser_workers.py +690 -0
  31. agent_folder_workspace/path_safety.py +28 -0
  32. agent_folder_workspace/probe_fixtures/probe.doc +0 -0
  33. agent_folder_workspace/probe_fixtures/probe.ppt +0 -0
  34. agent_folder_workspace/probe_fixtures/probe.xls +0 -0
  35. agent_folder_workspace/py.typed +1 -0
  36. agent_folder_workspace/registry.py +394 -0
  37. agent_folder_workspace/schemas/CapabilityReportV1.schema.json +121 -0
  38. agent_folder_workspace/schemas/ContentPageV1.schema.json +138 -0
  39. agent_folder_workspace/schemas/DiagnosticV1.schema.json +49 -0
  40. agent_folder_workspace/schemas/NodePageV1.schema.json +340 -0
  41. agent_folder_workspace/schemas/NodeV1.schema.json +289 -0
  42. agent_folder_workspace/schemas/SearchPageV1.schema.json +95 -0
  43. agent_folder_workspace/workspace.py +1141 -0
  44. agent_folder_workspace-0.1.0.dist-info/METADATA +407 -0
  45. agent_folder_workspace-0.1.0.dist-info/RECORD +48 -0
  46. agent_folder_workspace-0.1.0.dist-info/WHEEL +4 -0
  47. agent_folder_workspace-0.1.0.dist-info/entry_points.txt +2 -0
  48. agent_folder_workspace-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,552 @@
1
+ """Virtual hierarchy for DOC/XLS/PPT backed by the in-package CFB reader."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from pathlib import Path
7
+ from typing import Any, Literal
8
+
9
+ from agent_folder_workspace.adapters.base import BaseAdapter, ReadResult, VirtualNodeSpec
10
+ from agent_folder_workspace.cfb import CfbDirectoryEntry, CfbFile
11
+ from agent_folder_workspace.config import WorkspaceConfig
12
+ from agent_folder_workspace.errors import InputValidationError, ResourceLimitError
13
+ from agent_folder_workspace.legacy import (
14
+ BiffParseResult,
15
+ BinaryRecord,
16
+ DocParseResult,
17
+ PptParseResult,
18
+ parse_biff,
19
+ parse_doc,
20
+ parse_ppt,
21
+ )
22
+ from agent_folder_workspace.models import NodeKind, NodeV1
23
+
24
+ LegacyFamily = Literal["doc", "xls", "ppt"]
25
+
26
+
27
+ class LegacyOfficeAdapter(BaseAdapter):
28
+ """Expose semantics and a lossless container/record tree lazily."""
29
+
30
+ _ROOTS = ("semantic", "storages", "streams", "records", "directory_entries")
31
+
32
+ def __init__(self, family: LegacyFamily) -> None:
33
+ self.family = family
34
+
35
+ def children(
36
+ self, node: NodeV1, source: Path, config: WorkspaceConfig
37
+ ) -> list[VirtualNodeSpec]:
38
+ logical = node.logical_path
39
+ if logical == "/":
40
+ return [
41
+ VirtualNodeSpec(name, NodeKind.CONTAINER, f"/legacy/{name}") for name in self._ROOTS
42
+ ]
43
+ with CfbFile.open(source, max_directory_entries=config.max_index_nodes_per_file) as cfb:
44
+ if logical == "/legacy/streams":
45
+ return [self._stream_spec(entry) for entry in cfb.streams]
46
+ if logical == "/legacy/storages":
47
+ return [
48
+ VirtualNodeSpec(
49
+ entry.name,
50
+ NodeKind.CONTAINER,
51
+ f"/legacy/storages/{entry.index}",
52
+ metadata=self._entry_metadata(entry),
53
+ )
54
+ for entry in cfb.storages
55
+ ]
56
+ if logical == "/legacy/directory_entries":
57
+ return [
58
+ VirtualNodeSpec(
59
+ entry.name or f"Empty {entry.index}",
60
+ NodeKind.STREAM if entry.is_stream else NodeKind.CONTAINER,
61
+ f"/legacy/directory_entries/{entry.index}",
62
+ size=entry.size,
63
+ metadata=self._entry_metadata(entry),
64
+ source_part=f"cfb:{entry.path}" if entry.is_stream else None,
65
+ source_offset=0 if entry.is_stream else None,
66
+ source_length=entry.size if entry.is_stream else None,
67
+ )
68
+ for entry in cfb.entries
69
+ if entry.object_type
70
+ ]
71
+ if logical == "/legacy/records":
72
+ return [
73
+ VirtualNodeSpec(
74
+ entry.name,
75
+ NodeKind.CONTAINER,
76
+ f"/legacy/records/{entry.index}",
77
+ metadata={"stream_path": entry.path, "stream_index": entry.index},
78
+ source_part=f"cfb:{entry.path}",
79
+ source_offset=0,
80
+ source_length=entry.size,
81
+ )
82
+ for entry in self._record_streams(cfb)
83
+ ]
84
+ if logical.startswith("/legacy/records/"):
85
+ return self._record_children(node, cfb, config)
86
+ if logical == "/legacy/semantic":
87
+ return self._semantic_children(cfb, config)
88
+ if logical.startswith("/legacy/semantic/sheet/") and node.kind == NodeKind.SHEET:
89
+ return self._sheet_blocks(node, cfb, config)
90
+ return []
91
+
92
+ def read(
93
+ self,
94
+ node: NodeV1,
95
+ source: Path,
96
+ offset: int,
97
+ limit: int,
98
+ config: WorkspaceConfig,
99
+ ) -> ReadResult:
100
+ with CfbFile.open(source, max_directory_entries=config.max_index_nodes_per_file) as cfb:
101
+ logical = node.logical_path
102
+ if "/legacy/semantic/sheet/" in logical and "/rows/" in logical:
103
+ parsed = self._parse_xls(cfb, config)
104
+ sheet_cells = self._sheet_cells(parsed, int(node.metadata.get("sheet_index", 0)))
105
+ start = int(node.metadata.get("cell_start", 0)) + offset
106
+ end = min(start + limit, int(node.metadata.get("cell_end", len(sheet_cells))))
107
+ items = sheet_cells[start:end]
108
+ return ReadResult(
109
+ items=items,
110
+ total=int(node.metadata.get("cell_end", 0))
111
+ - int(node.metadata.get("cell_start", 0)),
112
+ )
113
+ if logical.startswith("/legacy/semantic/paragraph/"):
114
+ item = {
115
+ "text": node.metadata.get("text", ""),
116
+ "cp_start": node.metadata.get("cp_start"),
117
+ "cp_end": node.metadata.get("cp_end"),
118
+ "source_ranges": node.metadata.get("source_ranges", []),
119
+ }
120
+ return ReadResult(
121
+ items=[item] if offset == 0 else [], total=1, text=str(item["text"])
122
+ )
123
+ if logical.startswith("/legacy/semantic/slide/"):
124
+ item = {
125
+ "index": node.metadata.get("slide_index"),
126
+ "text": node.metadata.get("text", ""),
127
+ "source_ref": node.metadata.get("source_ref"),
128
+ }
129
+ return ReadResult(
130
+ items=[item] if offset == 0 else [], total=1, text=str(item["text"])
131
+ )
132
+ if logical.startswith("/legacy/records/") and node.kind in {
133
+ NodeKind.RECORD,
134
+ NodeKind.UNKNOWN_RECORD,
135
+ }:
136
+ fields = dict(node.metadata.get("fields", {}))
137
+ item = {
138
+ "record_type": node.metadata.get("record_type"),
139
+ "name": node.name,
140
+ "offset": node.metadata.get("record_offset"),
141
+ "length": node.metadata.get("record_length"),
142
+ "fields": fields,
143
+ }
144
+ return ReadResult(items=[item] if offset == 0 else [], total=1)
145
+ if logical.startswith("/legacy/streams/"):
146
+ stream_path = str(node.metadata.get("stream_path", ""))
147
+ entry = cfb.find(stream_path)
148
+ item = self._entry_metadata(entry)
149
+ return ReadResult(items=[item] if offset == 0 else [], total=1)
150
+ return ReadResult(items=[], total=0)
151
+
152
+ def read_binary_part(
153
+ self,
154
+ node: NodeV1,
155
+ source: Path,
156
+ offset: int,
157
+ length: int,
158
+ config: WorkspaceConfig,
159
+ ) -> tuple[bytes, int]:
160
+ part = node.source_ref.part if node.source_ref is not None else None
161
+ if not part:
162
+ return super().read_binary_part(node, source, offset, length, config)
163
+ with CfbFile.open(source, max_directory_entries=config.max_index_nodes_per_file) as cfb:
164
+ if part.startswith("cfb:"):
165
+ return cfb.read_stream_range(part[4:], offset, length)
166
+ if part.startswith("record:"):
167
+ stream_path, record_offset, record_length = self._split_record_part(part)
168
+ if offset >= record_length:
169
+ return b"", record_length
170
+ requested = min(length, record_length - offset)
171
+ payload, _stream_length = cfb.read_stream_range(
172
+ stream_path,
173
+ record_offset + offset,
174
+ requested,
175
+ )
176
+ return payload, record_length
177
+ return super().read_binary_part(node, source, offset, length, config)
178
+
179
+ @staticmethod
180
+ def _entry_metadata(entry: CfbDirectoryEntry) -> dict[str, Any]:
181
+ return {
182
+ "directory_index": entry.index,
183
+ "path": entry.path,
184
+ "object_type": entry.object_type,
185
+ "start_sector": entry.start_sector,
186
+ "size": entry.size,
187
+ }
188
+
189
+ def _stream_spec(self, entry: CfbDirectoryEntry) -> VirtualNodeSpec:
190
+ return VirtualNodeSpec(
191
+ entry.name,
192
+ NodeKind.STREAM,
193
+ f"/legacy/streams/{entry.index}",
194
+ size=entry.size,
195
+ metadata={**self._entry_metadata(entry), "stream_path": entry.path},
196
+ source_part=f"cfb:{entry.path}",
197
+ source_offset=0,
198
+ source_length=entry.size,
199
+ )
200
+
201
+ def _record_streams(self, cfb: CfbFile) -> list[CfbDirectoryEntry]:
202
+ names = {
203
+ "xls": {"workbook", "book"},
204
+ "doc": {"worddocument", "0table", "1table", "data"},
205
+ "ppt": {"powerpoint document", "current user"},
206
+ }[self.family]
207
+ return [entry for entry in cfb.streams if entry.name.casefold() in names]
208
+
209
+ def _record_children(
210
+ self, node: NodeV1, cfb: CfbFile, config: WorkspaceConfig
211
+ ) -> list[VirtualNodeSpec]:
212
+ stream_path = str(
213
+ node.metadata.get("record_stream") or node.metadata.get("stream_path") or ""
214
+ )
215
+ if not stream_path:
216
+ return []
217
+ data = self._bounded_stream(cfb, stream_path, config)
218
+ records: list[BinaryRecord]
219
+ if self.family == "xls":
220
+ records = parse_biff(data, max_records=config.max_index_nodes_per_file).records
221
+ elif self.family == "ppt":
222
+ parsed = parse_ppt(data, max_records=config.max_index_nodes_per_file)
223
+ parent_offset = node.metadata.get("record_offset")
224
+ if parent_offset is None:
225
+ records = parsed.records
226
+ else:
227
+ parent = self._find_ppt_record(parsed, int(parent_offset))
228
+ records = parent.children if parent is not None else []
229
+ else:
230
+ if node.metadata.get("record_offset") is not None:
231
+ return []
232
+ return self._doc_record_specs(stream_path, data, node.logical_path)
233
+ return [self._record_spec(stream_path, record, node.logical_path) for record in records]
234
+
235
+ def _record_spec(
236
+ self, stream_path: str, record: BinaryRecord, parent_logical: str
237
+ ) -> VirtualNodeSpec:
238
+ return VirtualNodeSpec(
239
+ f"{record.name} @ {record.offset}",
240
+ NodeKind.UNKNOWN_RECORD if record.kind == "unknown_record" else NodeKind.RECORD,
241
+ f"{parent_logical}/{record.offset}",
242
+ child_count=len(record.children) or None,
243
+ size=len(record.raw),
244
+ metadata={
245
+ "record_stream": stream_path,
246
+ "record_offset": record.offset,
247
+ "record_length": len(record.raw),
248
+ "record_type": record.record_type,
249
+ "fields": record.fields,
250
+ },
251
+ source_part=f"record:{stream_path}:{record.offset}:{len(record.raw)}",
252
+ source_offset=record.offset,
253
+ source_length=len(record.raw),
254
+ )
255
+
256
+ @staticmethod
257
+ def _doc_record_specs(
258
+ stream_path: str, data: bytes, parent_logical: str
259
+ ) -> list[VirtualNodeSpec]:
260
+ if stream_path.casefold() != "worddocument" or len(data) < 32:
261
+ return [
262
+ VirtualNodeSpec(
263
+ "unknown stream bytes",
264
+ NodeKind.UNKNOWN_RECORD,
265
+ f"{parent_logical}/0",
266
+ size=len(data),
267
+ metadata={
268
+ "record_stream": stream_path,
269
+ "record_offset": 0,
270
+ "record_length": len(data),
271
+ "record_type": "unknown_stream_bytes",
272
+ "fields": {},
273
+ },
274
+ source_part=f"record:{stream_path}:0:{len(data)}",
275
+ source_offset=0,
276
+ source_length=len(data),
277
+ )
278
+ ]
279
+ fc_min = int.from_bytes(data[24:28], "little")
280
+ fib_length = min(max(fc_min, 32), len(data))
281
+ specs = [
282
+ VirtualNodeSpec(
283
+ "FIB and pre-text structures",
284
+ NodeKind.RECORD,
285
+ f"{parent_logical}/0",
286
+ size=fib_length,
287
+ metadata={
288
+ "record_stream": stream_path,
289
+ "record_offset": 0,
290
+ "record_length": fib_length,
291
+ "record_type": "FIB",
292
+ "fields": {"fcMin": fc_min},
293
+ },
294
+ source_part=f"record:{stream_path}:0:{fib_length}",
295
+ source_offset=0,
296
+ source_length=fib_length,
297
+ )
298
+ ]
299
+ block_size = 64 * 1024
300
+ for start in range(fib_length, len(data), block_size):
301
+ end = min(start + block_size, len(data))
302
+ specs.append(
303
+ VirtualNodeSpec(
304
+ f"unknown_record @ {start}",
305
+ NodeKind.UNKNOWN_RECORD,
306
+ f"{parent_logical}/{start}",
307
+ size=end - start,
308
+ metadata={
309
+ "record_stream": stream_path,
310
+ "record_offset": start,
311
+ "record_length": end - start,
312
+ "record_type": "unknown_record",
313
+ "fields": {},
314
+ },
315
+ source_part=f"record:{stream_path}:{start}:{end - start}",
316
+ source_offset=start,
317
+ source_length=end - start,
318
+ )
319
+ )
320
+ return specs
321
+
322
+ @staticmethod
323
+ def _find_ppt_record(parsed: PptParseResult, offset: int) -> BinaryRecord | None:
324
+ pending = list(parsed.records)
325
+ while pending:
326
+ record = pending.pop()
327
+ if record.offset == offset:
328
+ return record
329
+ pending.extend(record.children)
330
+ return None
331
+
332
+ @staticmethod
333
+ def _split_record_part(part: str) -> tuple[str, int, int]:
334
+ body = part[len("record:") :]
335
+ stream_path, offset_text, length_text = body.rsplit(":", 2)
336
+ return stream_path, int(offset_text), int(length_text)
337
+
338
+ def _semantic_children(self, cfb: CfbFile, config: WorkspaceConfig) -> list[VirtualNodeSpec]:
339
+ if self.family == "xls":
340
+ parsed_xls = self._parse_xls(cfb, config)
341
+ if any(record.record_type == 0x002F for record in parsed_xls.records):
342
+ raise InputValidationError("encrypted legacy XLS content requires a credential")
343
+ sheets = parsed_xls.sheets or [
344
+ {
345
+ "name": "Workbook",
346
+ "stream_offset": 0,
347
+ "stream_end": sum(len(record.raw) for record in parsed_xls.records),
348
+ "stream_length": sum(len(record.raw) for record in parsed_xls.records),
349
+ "substream_valid": True,
350
+ "substream_complete": False,
351
+ "cells": parsed_xls.cells,
352
+ }
353
+ ]
354
+ return [
355
+ VirtualNodeSpec(
356
+ str(sheet.get("name") or f"Sheet {index + 1}"),
357
+ NodeKind.SHEET,
358
+ f"/legacy/semantic/sheet/{index}",
359
+ child_count=max(
360
+ 1,
361
+ math.ceil(
362
+ len(self._sheet_cells(parsed_xls, index))
363
+ / max(config.max_page_items, 1)
364
+ ),
365
+ ),
366
+ metadata={
367
+ "sheet_index": index,
368
+ **{key: value for key, value in sheet.items() if key != "cells"},
369
+ },
370
+ source_part=self._sheet_source_part(parsed_xls, sheet),
371
+ source_offset=int(sheet.get("stream_offset", 0)),
372
+ source_length=int(sheet.get("stream_length", 0)),
373
+ )
374
+ for index, sheet in enumerate(sheets)
375
+ ]
376
+ if self.family == "doc":
377
+ parsed_doc = self._parse_doc(cfb, config)
378
+ if parsed_doc.fib_fields.get("encrypted") is True:
379
+ raise InputValidationError("encrypted legacy DOC content requires a credential")
380
+ return self._paragraph_specs(parsed_doc)
381
+ parsed_ppt = self._parse_ppt(cfb, config)
382
+ return [
383
+ VirtualNodeSpec(
384
+ f"Slide {slide.index + 1}",
385
+ NodeKind.SLIDE,
386
+ f"/legacy/semantic/slide/{slide.index}",
387
+ metadata={
388
+ "slide_index": slide.index,
389
+ "text": slide.text,
390
+ "source_ref": {
391
+ "stream": "PowerPoint Document",
392
+ "offset": slide.record_offset,
393
+ "length": slide.record_length,
394
+ },
395
+ },
396
+ source_part=(
397
+ f"record:PowerPoint Document:{slide.record_offset}:{slide.record_length}"
398
+ ),
399
+ source_offset=slide.record_offset,
400
+ source_length=slide.record_length,
401
+ )
402
+ for slide in parsed_ppt.slides
403
+ ]
404
+
405
+ def _sheet_blocks(
406
+ self, node: NodeV1, cfb: CfbFile, config: WorkspaceConfig
407
+ ) -> list[VirtualNodeSpec]:
408
+ parsed = self._parse_xls(cfb, config)
409
+ sheet_index = int(node.metadata.get("sheet_index", 0))
410
+ cells = self._sheet_cells(parsed, sheet_index)
411
+ block_size = max(1, config.max_page_items)
412
+ if not cells:
413
+ ranges = [(0, 0)]
414
+ else:
415
+ ranges = [
416
+ (start, min(start + block_size, len(cells)))
417
+ for start in range(0, len(cells), block_size)
418
+ ]
419
+ return [
420
+ VirtualNodeSpec(
421
+ f"Cells {start}:{end}",
422
+ NodeKind.ROW_BLOCK,
423
+ f"{node.logical_path}/rows/{start}:{end}",
424
+ child_count=0,
425
+ metadata={
426
+ "sheet_index": sheet_index,
427
+ "cell_start": start,
428
+ "cell_end": end,
429
+ },
430
+ source_part=node.source_ref.part if node.source_ref is not None else None,
431
+ source_offset=node.source_ref.offset if node.source_ref is not None else None,
432
+ source_length=node.source_ref.length if node.source_ref is not None else None,
433
+ )
434
+ for start, end in ranges
435
+ ]
436
+
437
+ @staticmethod
438
+ def _sheet_cells(parsed: BiffParseResult, index: int) -> list[dict[str, Any]]:
439
+ if 0 <= index < len(parsed.sheets):
440
+ cells = parsed.sheets[index].get("cells")
441
+ if isinstance(cells, list):
442
+ return cells
443
+ return parsed.cells if not parsed.sheets and index == 0 else []
444
+
445
+ @staticmethod
446
+ def _sheet_source_part(parsed: BiffParseResult, sheet: dict[str, Any]) -> str | None:
447
+ if not sheet.get("substream_valid"):
448
+ return None
449
+ start = int(sheet.get("stream_offset", 0))
450
+ length = int(sheet.get("stream_length", 0))
451
+ return f"record:{parsed.stream_name}:{start}:{length}"
452
+
453
+ @staticmethod
454
+ def _parse_xls(cfb: CfbFile, config: WorkspaceConfig) -> BiffParseResult:
455
+ for name in ("Workbook", "Book"):
456
+ try:
457
+ return parse_biff(
458
+ LegacyOfficeAdapter._bounded_stream(cfb, name, config),
459
+ stream_name=name,
460
+ max_records=config.max_index_nodes_per_file,
461
+ )
462
+ except KeyError:
463
+ continue
464
+ return parse_biff(
465
+ b"",
466
+ stream_name="Workbook",
467
+ max_records=config.max_index_nodes_per_file,
468
+ )
469
+
470
+ @staticmethod
471
+ def _parse_doc(cfb: CfbFile, config: WorkspaceConfig) -> DocParseResult:
472
+ word = LegacyOfficeAdapter._bounded_stream(cfb, "WordDocument", config)
473
+ table: bytes | None = None
474
+ if len(word) >= 12:
475
+ flags = int.from_bytes(word[10:12], "little")
476
+ table_name = "1Table" if flags & 0x0200 else "0Table"
477
+ try:
478
+ table = LegacyOfficeAdapter._bounded_stream(cfb, table_name, config)
479
+ except KeyError:
480
+ table = None
481
+ return parse_doc(
482
+ word,
483
+ table,
484
+ max_pieces=config.max_index_nodes_per_file,
485
+ max_paragraphs=config.max_index_nodes_per_file,
486
+ )
487
+
488
+ @staticmethod
489
+ def _parse_ppt(cfb: CfbFile, config: WorkspaceConfig) -> PptParseResult:
490
+ return parse_ppt(
491
+ LegacyOfficeAdapter._bounded_stream(cfb, "PowerPoint Document", config),
492
+ max_records=config.max_index_nodes_per_file,
493
+ )
494
+
495
+ @staticmethod
496
+ def _bounded_stream(cfb: CfbFile, stream_path: str, config: WorkspaceConfig) -> bytes:
497
+ entry = cfb.find(stream_path)
498
+ if entry.size > config.max_legacy_stream_bytes:
499
+ raise ResourceLimitError(
500
+ f"legacy stream exceeds {config.max_legacy_stream_bytes} bytes"
501
+ )
502
+ return cfb.read_stream(entry)
503
+
504
+ @staticmethod
505
+ def _paragraph_specs(parsed: DocParseResult) -> list[VirtualNodeSpec]:
506
+ specs: list[VirtualNodeSpec] = []
507
+ for index, paragraph in enumerate(parsed.paragraphs):
508
+ ranges: list[dict[str, Any]] = []
509
+ for piece in parsed.pieces:
510
+ overlap_start = max(paragraph.cp_start, piece.cp_start)
511
+ overlap_end = min(paragraph.cp_end, piece.cp_end)
512
+ if overlap_start >= overlap_end:
513
+ continue
514
+ width = 2 if piece.encoding == "utf-16le" else 1
515
+ ranges.append(
516
+ {
517
+ "stream": "WordDocument",
518
+ "offset": piece.stream_offset + (overlap_start - piece.cp_start) * width,
519
+ "length": (overlap_end - overlap_start) * width,
520
+ }
521
+ )
522
+ source_start = min((int(item["offset"]) for item in ranges), default=None)
523
+ source_end = max(
524
+ (int(item["offset"]) + int(item["length"]) for item in ranges),
525
+ default=None,
526
+ )
527
+ source_length = (
528
+ source_end - source_start
529
+ if source_start is not None and source_end is not None
530
+ else None
531
+ )
532
+ specs.append(
533
+ VirtualNodeSpec(
534
+ f"Paragraph {index + 1}",
535
+ NodeKind.PARAGRAPH,
536
+ f"/legacy/semantic/paragraph/{index}",
537
+ metadata={
538
+ "text": paragraph.text,
539
+ "cp_start": paragraph.cp_start,
540
+ "cp_end": paragraph.cp_end,
541
+ "source_ranges": ranges,
542
+ },
543
+ source_part=(
544
+ f"record:WordDocument:{source_start}:{source_length}"
545
+ if source_start is not None and source_length is not None
546
+ else "cfb:WordDocument"
547
+ ),
548
+ source_offset=source_start,
549
+ source_length=source_length,
550
+ )
551
+ )
552
+ return specs