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.
- agent_folder_workspace/__init__.py +42 -0
- agent_folder_workspace/__main__.py +6 -0
- agent_folder_workspace/adapters/__init__.py +5 -0
- agent_folder_workspace/adapters/base.py +93 -0
- agent_folder_workspace/adapters/legacy_office.py +552 -0
- agent_folder_workspace/adapters/manager.py +178 -0
- agent_folder_workspace/adapters/odf.py +636 -0
- agent_folder_workspace/adapters/ooxml.py +645 -0
- agent_folder_workspace/adapters/pdf.py +680 -0
- agent_folder_workspace/adapters/sqlite.py +326 -0
- agent_folder_workspace/adapters/structured.py +280 -0
- agent_folder_workspace/adapters/text.py +145 -0
- agent_folder_workspace/alternative_backends.py +329 -0
- agent_folder_workspace/backend_conversion_worker.py +232 -0
- agent_folder_workspace/backend_probe_worker.py +347 -0
- agent_folder_workspace/backends.py +321 -0
- agent_folder_workspace/cache.py +395 -0
- agent_folder_workspace/cfb.py +425 -0
- agent_folder_workspace/cli.py +139 -0
- agent_folder_workspace/config.py +37 -0
- agent_folder_workspace/cursors.py +46 -0
- agent_folder_workspace/errors.py +27 -0
- agent_folder_workspace/ids.py +42 -0
- agent_folder_workspace/installer.py +180 -0
- agent_folder_workspace/legacy.py +697 -0
- agent_folder_workspace/mcp_server.py +179 -0
- agent_folder_workspace/models.py +158 -0
- agent_folder_workspace/opencode/plugin/agent-folder-workspace.ts.tmpl +41 -0
- agent_folder_workspace/opencode/skill/SKILL.md +24 -0
- agent_folder_workspace/parser_workers.py +690 -0
- agent_folder_workspace/path_safety.py +28 -0
- agent_folder_workspace/probe_fixtures/probe.doc +0 -0
- agent_folder_workspace/probe_fixtures/probe.ppt +0 -0
- agent_folder_workspace/probe_fixtures/probe.xls +0 -0
- agent_folder_workspace/py.typed +1 -0
- agent_folder_workspace/registry.py +394 -0
- agent_folder_workspace/schemas/CapabilityReportV1.schema.json +121 -0
- agent_folder_workspace/schemas/ContentPageV1.schema.json +138 -0
- agent_folder_workspace/schemas/DiagnosticV1.schema.json +49 -0
- agent_folder_workspace/schemas/NodePageV1.schema.json +340 -0
- agent_folder_workspace/schemas/NodeV1.schema.json +289 -0
- agent_folder_workspace/schemas/SearchPageV1.schema.json +95 -0
- agent_folder_workspace/workspace.py +1141 -0
- agent_folder_workspace-0.1.0.dist-info/METADATA +407 -0
- agent_folder_workspace-0.1.0.dist-info/RECORD +48 -0
- agent_folder_workspace-0.1.0.dist-info/WHEEL +4 -0
- agent_folder_workspace-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|