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,636 @@
|
|
|
1
|
+
"""Safe, direct ODF ZIP/XML hierarchy without a LibreOffice runtime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path, PurePosixPath
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
from zipfile import BadZipFile, ZipFile, ZipInfo
|
|
8
|
+
|
|
9
|
+
from defusedxml import ElementTree as SafeElementTree # type: ignore[import-untyped]
|
|
10
|
+
from defusedxml.common import DefusedXmlException # type: ignore[import-untyped]
|
|
11
|
+
|
|
12
|
+
from agent_folder_workspace.adapters.base import BaseAdapter, ReadResult, VirtualNodeSpec
|
|
13
|
+
from agent_folder_workspace.config import WorkspaceConfig
|
|
14
|
+
from agent_folder_workspace.errors import InputValidationError, ResourceLimitError
|
|
15
|
+
from agent_folder_workspace.models import NodeKind, NodeV1
|
|
16
|
+
|
|
17
|
+
_OFFICE = "urn:oasis:names:tc:opendocument:xmlns:office:1.0"
|
|
18
|
+
_TEXT = "urn:oasis:names:tc:opendocument:xmlns:text:1.0"
|
|
19
|
+
_TABLE = "urn:oasis:names:tc:opendocument:xmlns:table:1.0"
|
|
20
|
+
_DRAW = "urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
|
|
21
|
+
_MANIFEST = "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"
|
|
22
|
+
_BLOCK_SIZE = 1_000
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class OdfAdapter(BaseAdapter):
|
|
26
|
+
"""Expose ODT, ODS, and ODP semantics alongside every package part."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, family: str) -> None:
|
|
29
|
+
if family not in {"text", "spreadsheet", "presentation"}:
|
|
30
|
+
raise ValueError(f"unsupported ODF family: {family}")
|
|
31
|
+
self.family = family
|
|
32
|
+
|
|
33
|
+
def _infos(self, source: Path, config: WorkspaceConfig) -> list[ZipInfo]:
|
|
34
|
+
try:
|
|
35
|
+
with ZipFile(source) as package:
|
|
36
|
+
infos = package.infolist()
|
|
37
|
+
except (BadZipFile, OSError) as error:
|
|
38
|
+
raise InputValidationError(f"invalid ODF package: {source.name}") from error
|
|
39
|
+
if len(infos) > config.max_zip_entries:
|
|
40
|
+
raise ResourceLimitError(f"ODF package exceeds {config.max_zip_entries} entries")
|
|
41
|
+
if sum(info.file_size for info in infos) > config.max_zip_uncompressed_bytes:
|
|
42
|
+
raise ResourceLimitError(
|
|
43
|
+
f"ODF package exceeds {config.max_zip_uncompressed_bytes} uncompressed bytes"
|
|
44
|
+
)
|
|
45
|
+
seen: set[str] = set()
|
|
46
|
+
for info in infos:
|
|
47
|
+
name = info.filename
|
|
48
|
+
path = PurePosixPath(name)
|
|
49
|
+
if name.startswith("/") or "\\" in name or ".." in path.parts:
|
|
50
|
+
raise InputValidationError(f"unsafe ODF package part name: {name}")
|
|
51
|
+
if name in seen:
|
|
52
|
+
raise InputValidationError(f"duplicate ODF package part: {name}")
|
|
53
|
+
seen.add(name)
|
|
54
|
+
if info.flag_bits & 0x1:
|
|
55
|
+
raise InputValidationError("encrypted ODF package parts are not supported")
|
|
56
|
+
if info.compress_size and info.file_size / info.compress_size > 1_000:
|
|
57
|
+
raise ResourceLimitError("ODF package contains a compression bomb candidate")
|
|
58
|
+
manifest = next((info for info in infos if info.filename == "META-INF/manifest.xml"), None)
|
|
59
|
+
if manifest is not None and not manifest.is_dir():
|
|
60
|
+
if manifest.file_size > config.max_xml_bytes:
|
|
61
|
+
raise ResourceLimitError("ODF manifest exceeds the XML size limit")
|
|
62
|
+
try:
|
|
63
|
+
with ZipFile(source) as package:
|
|
64
|
+
manifest_root = SafeElementTree.fromstring(package.read(manifest))
|
|
65
|
+
except (
|
|
66
|
+
BadZipFile,
|
|
67
|
+
OSError,
|
|
68
|
+
RuntimeError,
|
|
69
|
+
SafeElementTree.ParseError,
|
|
70
|
+
DefusedXmlException,
|
|
71
|
+
ValueError,
|
|
72
|
+
) as error:
|
|
73
|
+
raise InputValidationError("invalid ODF package manifest") from error
|
|
74
|
+
if manifest_root.find(f".//{{{_MANIFEST}}}encryption-data") is not None:
|
|
75
|
+
raise InputValidationError("encrypted ODF content requires a credential")
|
|
76
|
+
return infos
|
|
77
|
+
|
|
78
|
+
def _info(self, source: Path, name: str, config: WorkspaceConfig) -> ZipInfo:
|
|
79
|
+
info = next(
|
|
80
|
+
(candidate for candidate in self._infos(source, config) if candidate.filename == name),
|
|
81
|
+
None,
|
|
82
|
+
)
|
|
83
|
+
if info is None or info.is_dir():
|
|
84
|
+
raise InputValidationError(f"ODF part not found: {name}")
|
|
85
|
+
return info
|
|
86
|
+
|
|
87
|
+
def _part(
|
|
88
|
+
self,
|
|
89
|
+
source: Path,
|
|
90
|
+
name: str,
|
|
91
|
+
config: WorkspaceConfig,
|
|
92
|
+
*,
|
|
93
|
+
maximum: int | None = None,
|
|
94
|
+
) -> bytes:
|
|
95
|
+
info = self._info(source, name, config)
|
|
96
|
+
if maximum is not None and info.file_size > maximum:
|
|
97
|
+
raise ResourceLimitError(f"ODF part {name} exceeds {maximum} bytes")
|
|
98
|
+
try:
|
|
99
|
+
with ZipFile(source) as package:
|
|
100
|
+
return package.read(info)
|
|
101
|
+
except (BadZipFile, OSError, RuntimeError) as error:
|
|
102
|
+
raise InputValidationError(f"unable to read ODF part: {name}") from error
|
|
103
|
+
|
|
104
|
+
def _xml(self, source: Path, name: str, config: WorkspaceConfig) -> Any:
|
|
105
|
+
payload = self._part(source, name, config, maximum=config.max_xml_bytes)
|
|
106
|
+
try:
|
|
107
|
+
return SafeElementTree.fromstring(payload)
|
|
108
|
+
except (SafeElementTree.ParseError, DefusedXmlException, ValueError) as error:
|
|
109
|
+
raise InputValidationError(f"invalid ODF XML part: {name}") from error
|
|
110
|
+
|
|
111
|
+
def _names(self, source: Path, config: WorkspaceConfig) -> list[str]:
|
|
112
|
+
return sorted(info.filename for info in self._infos(source, config) if not info.is_dir())
|
|
113
|
+
|
|
114
|
+
@staticmethod
|
|
115
|
+
def _local_name(tag: str) -> str:
|
|
116
|
+
return tag.rsplit("}", 1)[-1]
|
|
117
|
+
|
|
118
|
+
def _element_text(self, element: Any, config: WorkspaceConfig) -> str:
|
|
119
|
+
pieces: list[str] = []
|
|
120
|
+
byte_count = 0
|
|
121
|
+
visited = 0
|
|
122
|
+
stack: list[tuple[str, Any]] = [("element", element)]
|
|
123
|
+
while stack:
|
|
124
|
+
operation, current = stack.pop()
|
|
125
|
+
if operation == "text":
|
|
126
|
+
piece = str(current)
|
|
127
|
+
pieces.append(piece)
|
|
128
|
+
byte_count += len(piece.encode("utf-8"))
|
|
129
|
+
if byte_count > config.max_content_cache_bytes:
|
|
130
|
+
raise ResourceLimitError("ODF text exceeds the configured content limit")
|
|
131
|
+
continue
|
|
132
|
+
visited += 1
|
|
133
|
+
if visited > config.max_index_nodes_per_file:
|
|
134
|
+
raise ResourceLimitError("ODF XML exceeds the structural element limit")
|
|
135
|
+
child_operations: list[tuple[str, Any]] = []
|
|
136
|
+
if current.text:
|
|
137
|
+
child_operations.append(("text", str(current.text)))
|
|
138
|
+
for child in current:
|
|
139
|
+
if child.tag == f"{{{_TEXT}}}s":
|
|
140
|
+
repeated = self._repeat(
|
|
141
|
+
child.attrib.get(f"{{{_TEXT}}}c", "1"), config, "spaces"
|
|
142
|
+
)
|
|
143
|
+
child_operations.append(("text", " " * repeated))
|
|
144
|
+
elif child.tag == f"{{{_TEXT}}}tab":
|
|
145
|
+
child_operations.append(("text", "\t"))
|
|
146
|
+
elif child.tag == f"{{{_TEXT}}}line-break":
|
|
147
|
+
child_operations.append(("text", "\n"))
|
|
148
|
+
else:
|
|
149
|
+
child_operations.append(("element", child))
|
|
150
|
+
if child.tail:
|
|
151
|
+
child_operations.append(("text", str(child.tail)))
|
|
152
|
+
stack.extend(reversed(child_operations))
|
|
153
|
+
return "".join(pieces)
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def _repeat(raw: str, config: WorkspaceConfig, label: str) -> int:
|
|
157
|
+
try:
|
|
158
|
+
value = int(raw)
|
|
159
|
+
except ValueError as error:
|
|
160
|
+
raise InputValidationError(f"invalid ODF {label} repetition") from error
|
|
161
|
+
if value < 1:
|
|
162
|
+
raise InputValidationError(f"invalid ODF {label} repetition")
|
|
163
|
+
if value > config.max_zip_entries:
|
|
164
|
+
raise ResourceLimitError(f"ODF {label} repetition exceeds structural limit")
|
|
165
|
+
return value
|
|
166
|
+
|
|
167
|
+
def _content_root(self, source: Path, config: WorkspaceConfig) -> Any:
|
|
168
|
+
return self._xml(source, "content.xml", config)
|
|
169
|
+
|
|
170
|
+
def _paragraph_elements(self, source: Path, config: WorkspaceConfig) -> list[Any]:
|
|
171
|
+
root = self._content_root(source, config)
|
|
172
|
+
body = root.find(f".//{{{_OFFICE}}}text")
|
|
173
|
+
if body is None:
|
|
174
|
+
raise InputValidationError("ODT document has no text body")
|
|
175
|
+
paragraphs: list[Any] = []
|
|
176
|
+
visited = 0
|
|
177
|
+
pending = list(reversed(list(body)))
|
|
178
|
+
while pending:
|
|
179
|
+
element = pending.pop()
|
|
180
|
+
visited += 1
|
|
181
|
+
if visited > config.max_index_nodes_per_file:
|
|
182
|
+
raise ResourceLimitError("ODF XML exceeds the structural element limit")
|
|
183
|
+
if element.tag == f"{{{_TABLE}}}table":
|
|
184
|
+
continue
|
|
185
|
+
if element.tag in {f"{{{_TEXT}}}h", f"{{{_TEXT}}}p"}:
|
|
186
|
+
paragraphs.append(element)
|
|
187
|
+
continue
|
|
188
|
+
pending.extend(reversed(list(element)))
|
|
189
|
+
return paragraphs
|
|
190
|
+
|
|
191
|
+
def _tables(self, source: Path, config: WorkspaceConfig) -> list[Any]:
|
|
192
|
+
return cast(list[Any], self._content_root(source, config).findall(f".//{{{_TABLE}}}table"))
|
|
193
|
+
|
|
194
|
+
def _sheets(self, source: Path, config: WorkspaceConfig) -> list[Any]:
|
|
195
|
+
return cast(list[Any], self._content_root(source, config).findall(f".//{{{_TABLE}}}table"))
|
|
196
|
+
|
|
197
|
+
def _slides(self, source: Path, config: WorkspaceConfig) -> list[Any]:
|
|
198
|
+
return cast(list[Any], self._content_root(source, config).findall(f".//{{{_DRAW}}}page"))
|
|
199
|
+
|
|
200
|
+
def _metadata(self, source: Path, config: WorkspaceConfig) -> dict[str, str]:
|
|
201
|
+
if "meta.xml" not in self._names(source, config):
|
|
202
|
+
return {}
|
|
203
|
+
root = self._xml(source, "meta.xml", config)
|
|
204
|
+
result: dict[str, str] = {}
|
|
205
|
+
for element in root.findall(f".//{{{_OFFICE}}}meta//*"):
|
|
206
|
+
text = self._element_text(element, config).strip()
|
|
207
|
+
if text and len(element) == 0:
|
|
208
|
+
result[self._local_name(str(element.tag))] = text
|
|
209
|
+
return result
|
|
210
|
+
|
|
211
|
+
def _root_categories(self, source: Path, config: WorkspaceConfig) -> list[VirtualNodeSpec]:
|
|
212
|
+
infos = {info.filename: info for info in self._infos(source, config) if not info.is_dir()}
|
|
213
|
+
labels = {
|
|
214
|
+
"text": ("metadata", "paragraphs", "tables", "package_parts"),
|
|
215
|
+
"spreadsheet": ("metadata", "sheets", "package_parts"),
|
|
216
|
+
"presentation": ("metadata", "slides", "package_parts"),
|
|
217
|
+
}[self.family]
|
|
218
|
+
counts = {
|
|
219
|
+
"metadata": 1,
|
|
220
|
+
"paragraphs": len(self._paragraph_elements(source, config))
|
|
221
|
+
if self.family == "text"
|
|
222
|
+
else 0,
|
|
223
|
+
"tables": len(self._tables(source, config)) if self.family == "text" else 0,
|
|
224
|
+
"sheets": len(self._sheets(source, config)) if self.family == "spreadsheet" else 0,
|
|
225
|
+
"slides": len(self._slides(source, config)) if self.family == "presentation" else 0,
|
|
226
|
+
"package_parts": len(infos),
|
|
227
|
+
}
|
|
228
|
+
semantic_labels = {"paragraphs", "tables", "sheets", "slides"}
|
|
229
|
+
result = []
|
|
230
|
+
for label in labels:
|
|
231
|
+
source_part = (
|
|
232
|
+
"meta.xml"
|
|
233
|
+
if label == "metadata" and "meta.xml" in infos
|
|
234
|
+
else "content.xml"
|
|
235
|
+
if label in semantic_labels
|
|
236
|
+
else None
|
|
237
|
+
)
|
|
238
|
+
result.append(
|
|
239
|
+
VirtualNodeSpec(
|
|
240
|
+
name=label,
|
|
241
|
+
kind=NodeKind.METADATA if label == "metadata" else NodeKind.CONTAINER,
|
|
242
|
+
logical_path=f"/odf/{self.family}/{label}",
|
|
243
|
+
child_count=counts[label] if label != "metadata" else 0,
|
|
244
|
+
metadata={"odf_category": label},
|
|
245
|
+
source_part=source_part,
|
|
246
|
+
source_offset=0 if source_part is not None else None,
|
|
247
|
+
source_length=(
|
|
248
|
+
infos[source_part].file_size if source_part is not None else None
|
|
249
|
+
),
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
return result
|
|
253
|
+
|
|
254
|
+
def _package_part_specs(
|
|
255
|
+
self, node: NodeV1, source: Path, config: WorkspaceConfig
|
|
256
|
+
) -> list[VirtualNodeSpec]:
|
|
257
|
+
infos = {info.filename: info for info in self._infos(source, config)}
|
|
258
|
+
return [
|
|
259
|
+
VirtualNodeSpec(
|
|
260
|
+
name=name,
|
|
261
|
+
kind=NodeKind.PACKAGE_PART,
|
|
262
|
+
logical_path=f"{node.logical_path}/{name}",
|
|
263
|
+
size=infos[name].file_size,
|
|
264
|
+
metadata={"binary_part": name, "odf_category": "package_parts"},
|
|
265
|
+
source_part=name,
|
|
266
|
+
source_offset=0,
|
|
267
|
+
source_length=infos[name].file_size,
|
|
268
|
+
)
|
|
269
|
+
for name in self._names(source, config)
|
|
270
|
+
]
|
|
271
|
+
|
|
272
|
+
@staticmethod
|
|
273
|
+
def _block_specs(
|
|
274
|
+
logical_path: str,
|
|
275
|
+
label: str,
|
|
276
|
+
count: int,
|
|
277
|
+
block_type: str,
|
|
278
|
+
source_part: str | None = None,
|
|
279
|
+
source_length: int | None = None,
|
|
280
|
+
**metadata: Any,
|
|
281
|
+
) -> list[VirtualNodeSpec]:
|
|
282
|
+
return [
|
|
283
|
+
VirtualNodeSpec(
|
|
284
|
+
name=f"{label} {start + 1}-{min(start + _BLOCK_SIZE, count)}",
|
|
285
|
+
kind=NodeKind.ROW_BLOCK,
|
|
286
|
+
logical_path=f"{logical_path}/{start}:{min(start + _BLOCK_SIZE, count)}",
|
|
287
|
+
metadata={
|
|
288
|
+
"block_type": block_type,
|
|
289
|
+
"start": start,
|
|
290
|
+
"end": min(start + _BLOCK_SIZE, count),
|
|
291
|
+
**metadata,
|
|
292
|
+
},
|
|
293
|
+
source_part=source_part,
|
|
294
|
+
source_offset=0 if source_part is not None else None,
|
|
295
|
+
source_length=source_length,
|
|
296
|
+
)
|
|
297
|
+
for start in range(0, count, _BLOCK_SIZE)
|
|
298
|
+
]
|
|
299
|
+
|
|
300
|
+
def children(
|
|
301
|
+
self, node: NodeV1, source: Path, config: WorkspaceConfig
|
|
302
|
+
) -> list[VirtualNodeSpec]:
|
|
303
|
+
if node.logical_path == "/":
|
|
304
|
+
return self._root_categories(source, config)
|
|
305
|
+
category = node.metadata.get("odf_category")
|
|
306
|
+
if category == "package_parts" and node.kind == NodeKind.CONTAINER:
|
|
307
|
+
return self._package_part_specs(node, source, config)
|
|
308
|
+
if category == "paragraphs" and node.kind == NodeKind.CONTAINER:
|
|
309
|
+
count = len(self._paragraph_elements(source, config))
|
|
310
|
+
return self._block_specs(
|
|
311
|
+
node.logical_path,
|
|
312
|
+
"Paragraphs",
|
|
313
|
+
count,
|
|
314
|
+
"odf_paragraph",
|
|
315
|
+
source_part="content.xml",
|
|
316
|
+
source_length=self._info(source, "content.xml", config).file_size,
|
|
317
|
+
)
|
|
318
|
+
if category == "tables" and node.kind == NodeKind.CONTAINER:
|
|
319
|
+
return [
|
|
320
|
+
VirtualNodeSpec(
|
|
321
|
+
name=str(table.attrib.get(f"{{{_TABLE}}}name", f"Table {index + 1}")),
|
|
322
|
+
kind=NodeKind.TABLE,
|
|
323
|
+
logical_path=f"{node.logical_path}/{index}",
|
|
324
|
+
metadata={"odf_table_index": index},
|
|
325
|
+
source_part="content.xml",
|
|
326
|
+
source_offset=0,
|
|
327
|
+
source_length=self._info(source, "content.xml", config).file_size,
|
|
328
|
+
)
|
|
329
|
+
for index, table in enumerate(self._tables(source, config))
|
|
330
|
+
]
|
|
331
|
+
if category == "sheets" and node.kind == NodeKind.CONTAINER:
|
|
332
|
+
return [
|
|
333
|
+
VirtualNodeSpec(
|
|
334
|
+
name=str(sheet.attrib.get(f"{{{_TABLE}}}name", f"Sheet {index + 1}")),
|
|
335
|
+
kind=NodeKind.SHEET,
|
|
336
|
+
logical_path=f"{node.logical_path}/{index}",
|
|
337
|
+
metadata={"odf_sheet_index": index},
|
|
338
|
+
source_part="content.xml",
|
|
339
|
+
source_offset=0,
|
|
340
|
+
source_length=self._info(source, "content.xml", config).file_size,
|
|
341
|
+
)
|
|
342
|
+
for index, sheet in enumerate(self._sheets(source, config))
|
|
343
|
+
]
|
|
344
|
+
if category == "slides" and node.kind == NodeKind.CONTAINER:
|
|
345
|
+
return [
|
|
346
|
+
VirtualNodeSpec(
|
|
347
|
+
name=str(slide.attrib.get(f"{{{_DRAW}}}name", f"Slide {index + 1}")),
|
|
348
|
+
kind=NodeKind.SLIDE,
|
|
349
|
+
logical_path=f"{node.logical_path}/{index}",
|
|
350
|
+
metadata={"odf_slide_index": index},
|
|
351
|
+
source_part="content.xml",
|
|
352
|
+
source_offset=0,
|
|
353
|
+
source_length=self._info(source, "content.xml", config).file_size,
|
|
354
|
+
)
|
|
355
|
+
for index, slide in enumerate(self._slides(source, config))
|
|
356
|
+
]
|
|
357
|
+
if node.kind == NodeKind.SHEET and "odf_sheet_index" in node.metadata:
|
|
358
|
+
row_count = self._sheet_row_count(source, int(node.metadata["odf_sheet_index"]), config)
|
|
359
|
+
return self._block_specs(
|
|
360
|
+
node.logical_path,
|
|
361
|
+
"Rows",
|
|
362
|
+
row_count,
|
|
363
|
+
"odf_sheet_rows",
|
|
364
|
+
source_part=node.source_ref.part if node.source_ref is not None else None,
|
|
365
|
+
source_length=node.source_ref.length if node.source_ref is not None else None,
|
|
366
|
+
sheet_index=int(node.metadata["odf_sheet_index"]),
|
|
367
|
+
)
|
|
368
|
+
return []
|
|
369
|
+
|
|
370
|
+
def _cell_value(self, cell: Any, config: WorkspaceConfig) -> tuple[Any, str]:
|
|
371
|
+
text = self._element_text(cell, config).strip()
|
|
372
|
+
value_type = str(cell.attrib.get(f"{{{_OFFICE}}}value-type", "string"))
|
|
373
|
+
raw_value = cell.attrib.get(f"{{{_OFFICE}}}value")
|
|
374
|
+
if value_type in {"float", "currency", "percentage"} and raw_value is not None:
|
|
375
|
+
try:
|
|
376
|
+
return float(raw_value), text
|
|
377
|
+
except ValueError:
|
|
378
|
+
return raw_value, text
|
|
379
|
+
if value_type == "boolean":
|
|
380
|
+
return cell.attrib.get(f"{{{_OFFICE}}}boolean-value") == "true", text
|
|
381
|
+
if value_type in {"date", "time"}:
|
|
382
|
+
return (
|
|
383
|
+
cell.attrib.get(f"{{{_OFFICE}}}{value_type}-value", raw_value or text),
|
|
384
|
+
text,
|
|
385
|
+
)
|
|
386
|
+
return cell.attrib.get(f"{{{_OFFICE}}}string-value", text), text
|
|
387
|
+
|
|
388
|
+
def _row_cells(
|
|
389
|
+
self, row: Any, row_number: int, config: WorkspaceConfig
|
|
390
|
+
) -> list[dict[str, Any]]:
|
|
391
|
+
cells: list[dict[str, Any]] = []
|
|
392
|
+
for cell in row:
|
|
393
|
+
if cell.tag not in {
|
|
394
|
+
f"{{{_TABLE}}}table-cell",
|
|
395
|
+
f"{{{_TABLE}}}covered-table-cell",
|
|
396
|
+
}:
|
|
397
|
+
continue
|
|
398
|
+
repeated = self._repeat(
|
|
399
|
+
cell.attrib.get(f"{{{_TABLE}}}number-columns-repeated", "1"),
|
|
400
|
+
config,
|
|
401
|
+
"column",
|
|
402
|
+
)
|
|
403
|
+
if len(cells) + repeated > config.max_zip_entries:
|
|
404
|
+
raise ResourceLimitError("ODF row exceeds the structural item limit")
|
|
405
|
+
value, text = self._cell_value(cell, config)
|
|
406
|
+
for _ in range(repeated):
|
|
407
|
+
cells.append(
|
|
408
|
+
{
|
|
409
|
+
"column": len(cells) + 1,
|
|
410
|
+
"row": row_number,
|
|
411
|
+
"value": value,
|
|
412
|
+
"text": text,
|
|
413
|
+
"value_type": cell.attrib.get(f"{{{_OFFICE}}}value-type"),
|
|
414
|
+
"formula": cell.attrib.get(f"{{{_TABLE}}}formula"),
|
|
415
|
+
}
|
|
416
|
+
)
|
|
417
|
+
return cells
|
|
418
|
+
|
|
419
|
+
def _row_cell_count(self, row: Any, config: WorkspaceConfig) -> int:
|
|
420
|
+
count = 0
|
|
421
|
+
for cell in row:
|
|
422
|
+
if cell.tag not in {
|
|
423
|
+
f"{{{_TABLE}}}table-cell",
|
|
424
|
+
f"{{{_TABLE}}}covered-table-cell",
|
|
425
|
+
}:
|
|
426
|
+
continue
|
|
427
|
+
repeated = self._repeat(
|
|
428
|
+
cell.attrib.get(f"{{{_TABLE}}}number-columns-repeated", "1"),
|
|
429
|
+
config,
|
|
430
|
+
"column",
|
|
431
|
+
)
|
|
432
|
+
if repeated > config.max_index_nodes_per_file - count:
|
|
433
|
+
raise ResourceLimitError("ODF exceeds the expanded cell limit")
|
|
434
|
+
count += repeated
|
|
435
|
+
return count
|
|
436
|
+
|
|
437
|
+
def _expanded_row_count(self, container: Any, config: WorkspaceConfig) -> int:
|
|
438
|
+
row_count = 0
|
|
439
|
+
cell_count = 0
|
|
440
|
+
budget = config.max_index_nodes_per_file
|
|
441
|
+
for row in container.findall(f".//{{{_TABLE}}}table-row"):
|
|
442
|
+
repeated = self._repeat(
|
|
443
|
+
row.attrib.get(f"{{{_TABLE}}}number-rows-repeated", "1"), config, "row"
|
|
444
|
+
)
|
|
445
|
+
cells_per_row = self._row_cell_count(row, config)
|
|
446
|
+
if repeated > budget - row_count:
|
|
447
|
+
raise ResourceLimitError("ODF exceeds the expanded row limit")
|
|
448
|
+
if cells_per_row and repeated > (budget - cell_count) // cells_per_row:
|
|
449
|
+
raise ResourceLimitError("ODF exceeds the expanded cell limit")
|
|
450
|
+
row_count += repeated
|
|
451
|
+
cell_count += repeated * cells_per_row
|
|
452
|
+
return row_count
|
|
453
|
+
|
|
454
|
+
def _sheet(self, source: Path, sheet_index: int, config: WorkspaceConfig) -> Any:
|
|
455
|
+
sheets = self._sheets(source, config)
|
|
456
|
+
if sheet_index < 0 or sheet_index >= len(sheets):
|
|
457
|
+
raise InputValidationError("ODF sheet index is out of range")
|
|
458
|
+
return sheets[sheet_index]
|
|
459
|
+
|
|
460
|
+
def _sheet_row_count(self, source: Path, sheet_index: int, config: WorkspaceConfig) -> int:
|
|
461
|
+
return self._expanded_row_count(self._sheet(source, sheet_index, config), config)
|
|
462
|
+
|
|
463
|
+
def _sheet_rows(
|
|
464
|
+
self,
|
|
465
|
+
source: Path,
|
|
466
|
+
sheet_index: int,
|
|
467
|
+
start: int,
|
|
468
|
+
end: int,
|
|
469
|
+
config: WorkspaceConfig,
|
|
470
|
+
) -> list[dict[str, Any]]:
|
|
471
|
+
sheet = self._sheet(source, sheet_index, config)
|
|
472
|
+
self._expanded_row_count(sheet, config)
|
|
473
|
+
result: list[dict[str, Any]] = []
|
|
474
|
+
current = 0
|
|
475
|
+
for row in sheet.findall(f".//{{{_TABLE}}}table-row"):
|
|
476
|
+
repeated = self._repeat(
|
|
477
|
+
row.attrib.get(f"{{{_TABLE}}}number-rows-repeated", "1"), config, "row"
|
|
478
|
+
)
|
|
479
|
+
overlap_start = max(start, current)
|
|
480
|
+
overlap_end = min(end, current + repeated)
|
|
481
|
+
for row_offset in range(overlap_start, overlap_end):
|
|
482
|
+
row_number = row_offset + 1
|
|
483
|
+
result.append(
|
|
484
|
+
{"row": row_number, "cells": self._row_cells(row, row_number, config)}
|
|
485
|
+
)
|
|
486
|
+
current += repeated
|
|
487
|
+
if current >= end:
|
|
488
|
+
break
|
|
489
|
+
return result
|
|
490
|
+
|
|
491
|
+
def _table(self, source: Path, table_index: int, config: WorkspaceConfig) -> Any:
|
|
492
|
+
tables = self._tables(source, config)
|
|
493
|
+
if table_index < 0 or table_index >= len(tables):
|
|
494
|
+
raise InputValidationError("ODF table index is out of range")
|
|
495
|
+
return tables[table_index]
|
|
496
|
+
|
|
497
|
+
def _table_rows(
|
|
498
|
+
self,
|
|
499
|
+
source: Path,
|
|
500
|
+
table_index: int,
|
|
501
|
+
start: int,
|
|
502
|
+
end: int,
|
|
503
|
+
config: WorkspaceConfig,
|
|
504
|
+
) -> tuple[list[list[str]], int]:
|
|
505
|
+
table = self._table(source, table_index, config)
|
|
506
|
+
total = self._expanded_row_count(table, config)
|
|
507
|
+
result: list[list[str]] = []
|
|
508
|
+
current = 0
|
|
509
|
+
for row in table.findall(f".//{{{_TABLE}}}table-row"):
|
|
510
|
+
repeated = self._repeat(
|
|
511
|
+
row.attrib.get(f"{{{_TABLE}}}number-rows-repeated", "1"), config, "row"
|
|
512
|
+
)
|
|
513
|
+
values = [
|
|
514
|
+
str(cell["value"] if cell["value"] is not None else "")
|
|
515
|
+
for cell in self._row_cells(row, current + 1, config)
|
|
516
|
+
]
|
|
517
|
+
overlap = max(0, min(end, current + repeated) - max(start, current))
|
|
518
|
+
result.extend([list(values) for _ in range(overlap)])
|
|
519
|
+
current += repeated
|
|
520
|
+
if current >= end:
|
|
521
|
+
break
|
|
522
|
+
return result, total
|
|
523
|
+
|
|
524
|
+
def _slide_content(
|
|
525
|
+
self, source: Path, slide_index: int, config: WorkspaceConfig
|
|
526
|
+
) -> list[dict[str, Any]]:
|
|
527
|
+
slides = self._slides(source, config)
|
|
528
|
+
if slide_index < 0 or slide_index >= len(slides):
|
|
529
|
+
raise InputValidationError("ODF slide index is out of range")
|
|
530
|
+
return [
|
|
531
|
+
{
|
|
532
|
+
"paragraph": index + 1,
|
|
533
|
+
"text": self._element_text(paragraph, config),
|
|
534
|
+
}
|
|
535
|
+
for index, paragraph in enumerate(slides[slide_index].findall(f".//{{{_TEXT}}}p"))
|
|
536
|
+
]
|
|
537
|
+
|
|
538
|
+
def read(
|
|
539
|
+
self,
|
|
540
|
+
node: NodeV1,
|
|
541
|
+
source: Path,
|
|
542
|
+
offset: int,
|
|
543
|
+
limit: int,
|
|
544
|
+
config: WorkspaceConfig,
|
|
545
|
+
) -> ReadResult:
|
|
546
|
+
category = node.metadata.get("odf_category")
|
|
547
|
+
if category == "metadata":
|
|
548
|
+
items = [self._metadata(source, config)]
|
|
549
|
+
return ReadResult(items=items[offset : offset + limit], total=1)
|
|
550
|
+
if node.metadata.get("block_type") == "odf_paragraph":
|
|
551
|
+
start = int(node.metadata["start"])
|
|
552
|
+
end = int(node.metadata["end"])
|
|
553
|
+
paragraphs = [
|
|
554
|
+
{
|
|
555
|
+
"paragraph": index + 1,
|
|
556
|
+
"kind": "heading" if element.tag == f"{{{_TEXT}}}h" else "paragraph",
|
|
557
|
+
"text": self._element_text(element, config),
|
|
558
|
+
"style": element.attrib.get(f"{{{_TEXT}}}style-name"),
|
|
559
|
+
}
|
|
560
|
+
for index, element in enumerate(
|
|
561
|
+
self._paragraph_elements(source, config)[start:end], start
|
|
562
|
+
)
|
|
563
|
+
]
|
|
564
|
+
return ReadResult(items=paragraphs[offset : offset + limit], total=len(paragraphs))
|
|
565
|
+
if node.metadata.get("block_type") == "odf_sheet_rows":
|
|
566
|
+
start = int(node.metadata["start"])
|
|
567
|
+
end = int(node.metadata["end"])
|
|
568
|
+
selected_start = min(start + offset, end)
|
|
569
|
+
selected_end = min(selected_start + limit, end)
|
|
570
|
+
rows = self._sheet_rows(
|
|
571
|
+
source,
|
|
572
|
+
int(node.metadata["sheet_index"]),
|
|
573
|
+
selected_start,
|
|
574
|
+
selected_end,
|
|
575
|
+
config,
|
|
576
|
+
)
|
|
577
|
+
return ReadResult(items=rows, total=end - start)
|
|
578
|
+
if node.kind == NodeKind.TABLE and "odf_table_index" in node.metadata:
|
|
579
|
+
table_rows, total = self._table_rows(
|
|
580
|
+
source,
|
|
581
|
+
int(node.metadata["odf_table_index"]),
|
|
582
|
+
offset,
|
|
583
|
+
offset + limit,
|
|
584
|
+
config,
|
|
585
|
+
)
|
|
586
|
+
return ReadResult(items=table_rows, total=total)
|
|
587
|
+
if node.kind == NodeKind.SLIDE and "odf_slide_index" in node.metadata:
|
|
588
|
+
content = self._slide_content(source, int(node.metadata["odf_slide_index"]), config)
|
|
589
|
+
selected = content[offset : offset + limit]
|
|
590
|
+
return ReadResult(
|
|
591
|
+
items=selected,
|
|
592
|
+
total=len(content),
|
|
593
|
+
text="\n".join(str(item["text"]) for item in selected),
|
|
594
|
+
)
|
|
595
|
+
if node.kind == NodeKind.PACKAGE_PART:
|
|
596
|
+
name = str(node.metadata["binary_part"])
|
|
597
|
+
info = self._info(source, name, config)
|
|
598
|
+
part_items: list[dict[str, Any]] = [
|
|
599
|
+
{
|
|
600
|
+
"part": name,
|
|
601
|
+
"size": info.file_size,
|
|
602
|
+
"content_access": "read_binary",
|
|
603
|
+
}
|
|
604
|
+
]
|
|
605
|
+
return ReadResult(items=part_items[offset : offset + limit], total=1)
|
|
606
|
+
return ReadResult(items=[], total=0)
|
|
607
|
+
|
|
608
|
+
def read_binary_part(
|
|
609
|
+
self,
|
|
610
|
+
node: NodeV1,
|
|
611
|
+
source: Path,
|
|
612
|
+
offset: int,
|
|
613
|
+
length: int,
|
|
614
|
+
config: WorkspaceConfig,
|
|
615
|
+
) -> tuple[bytes, int]:
|
|
616
|
+
part = node.metadata.get("binary_part")
|
|
617
|
+
materialized_root = (
|
|
618
|
+
node.metadata.get("materialized_source") is True
|
|
619
|
+
and node.metadata.get("adapter_logical_path") == "/"
|
|
620
|
+
)
|
|
621
|
+
if (
|
|
622
|
+
not isinstance(part, str)
|
|
623
|
+
and node.source_ref is not None
|
|
624
|
+
and node.source_ref.part not in {None, "original"}
|
|
625
|
+
and not materialized_root
|
|
626
|
+
):
|
|
627
|
+
part = node.source_ref.part
|
|
628
|
+
if not isinstance(part, str):
|
|
629
|
+
return super().read_binary_part(node, source, offset, length, config)
|
|
630
|
+
info = self._info(source, part, config)
|
|
631
|
+
try:
|
|
632
|
+
with ZipFile(source) as package, package.open(info) as member:
|
|
633
|
+
member.seek(offset)
|
|
634
|
+
return member.read(length), info.file_size
|
|
635
|
+
except (BadZipFile, OSError, RuntimeError) as error:
|
|
636
|
+
raise InputValidationError(f"unable to read ODF part: {part}") from error
|