python-pages 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.
- pages/__init__.py +65 -0
- pages/api.py +1869 -0
- pages/cli.py +43 -0
- pages/comments.py +168 -0
- pages/components.py +403 -0
- pages/core_properties.py +126 -0
- pages/data/default.pages +0 -0
- pages/document.py +215 -0
- pages/hyperlinks.py +57 -0
- pages/iwa.py +101 -0
- pages/metadata.py +117 -0
- pages/pages.py +365 -0
- pages/pictures.py +549 -0
- pages/protobuf.py +162 -0
- pages/snappy.py +140 -0
- pages/stylesheet.py +892 -0
- pages/tables.py +493 -0
- pages/text.py +294 -0
- python_pages-0.1.0.dist-info/METADATA +271 -0
- python_pages-0.1.0.dist-info/RECORD +24 -0
- python_pages-0.1.0.dist-info/WHEEL +5 -0
- python_pages-0.1.0.dist-info/entry_points.txt +2 -0
- python_pages-0.1.0.dist-info/licenses/LICENSE +21 -0
- python_pages-0.1.0.dist-info/top_level.txt +1 -0
pages/tables.py
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
"""Middle-layer reader and conservative writer for Pages TST tables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, fields
|
|
6
|
+
import struct
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
import uuid
|
|
9
|
+
|
|
10
|
+
from .components import GraphClone, clone_object_graph, replace_uuid_identities
|
|
11
|
+
from .protobuf import Message
|
|
12
|
+
from .stylesheet import CharacterProperties, character_style_properties
|
|
13
|
+
from .text import LocatedObject, parse_attribute_table, parse_reference
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from .pages import PagesDocument
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
TYPE_TABLE_INFO = 6000
|
|
20
|
+
TYPE_TABLE_MODEL = 6001
|
|
21
|
+
TYPE_TILE = 6002
|
|
22
|
+
TYPE_TABLE_DATA_LIST = 6005
|
|
23
|
+
TYPE_ATTACHMENT = 2003
|
|
24
|
+
TYPE_HEADER_STORAGE_BUCKET = 6006
|
|
25
|
+
TYPE_COLUMN_ROW_UID_MAP = 6267
|
|
26
|
+
TYPE_STROKE_SIDECAR = 6305
|
|
27
|
+
|
|
28
|
+
STORAGE_TABLE_ATTACHMENTS = 9
|
|
29
|
+
|
|
30
|
+
TABLE_INFO_MODEL = 2
|
|
31
|
+
TABLE_MODEL_DATA_STORE = 4
|
|
32
|
+
TABLE_MODEL_ROWS = 6
|
|
33
|
+
TABLE_MODEL_COLUMNS = 7
|
|
34
|
+
TABLE_MODEL_NAME = 8
|
|
35
|
+
TABLE_MODEL_HEADER_ROWS = 9
|
|
36
|
+
TABLE_MODEL_HEADER_COLUMNS = 10
|
|
37
|
+
TABLE_MODEL_FOOTER_ROWS = 11
|
|
38
|
+
TABLE_MODEL_BODY_TEXT_STYLE = 24
|
|
39
|
+
TABLE_MODEL_HEADER_ROW_TEXT_STYLE = 25
|
|
40
|
+
TABLE_MODEL_HEADER_COLUMN_TEXT_STYLE = 26
|
|
41
|
+
TABLE_MODEL_FOOTER_ROW_TEXT_STYLE = 27
|
|
42
|
+
TABLE_MODEL_COLUMN_ROW_UIDS = 46
|
|
43
|
+
TABLE_MODEL_STROKE_SIDECAR = 49
|
|
44
|
+
|
|
45
|
+
DATA_STORE_ROW_HEADERS = 1
|
|
46
|
+
DATA_STORE_COLUMN_HEADERS = 2
|
|
47
|
+
DATA_STORE_TILES = 3
|
|
48
|
+
DATA_STORE_STRING_TABLE = 4
|
|
49
|
+
DATA_STORE_STYLE_TABLE = 5
|
|
50
|
+
|
|
51
|
+
DATA_LIST_TYPE = 1
|
|
52
|
+
DATA_LIST_NEXT_KEY = 2
|
|
53
|
+
DATA_LIST_ENTRIES = 3
|
|
54
|
+
DATA_LIST_STRING = 1
|
|
55
|
+
DATA_LIST_STYLE = 4
|
|
56
|
+
LIST_ENTRY_KEY = 1
|
|
57
|
+
LIST_ENTRY_REFCOUNT = 2
|
|
58
|
+
LIST_ENTRY_STRING = 3
|
|
59
|
+
LIST_ENTRY_REFERENCE = 4
|
|
60
|
+
|
|
61
|
+
CELL_STORAGE_VERSION = 5
|
|
62
|
+
CELL_TYPE_TEXT = 3
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class CellStorage:
|
|
67
|
+
"""The portions of a version-5 TST cell buffer used by this API."""
|
|
68
|
+
|
|
69
|
+
raw: bytes
|
|
70
|
+
cell_type: int
|
|
71
|
+
string_id: int | None = None
|
|
72
|
+
rich_text_id: int | None = None
|
|
73
|
+
cell_style_id: int | None = None
|
|
74
|
+
text_style_id: int | None = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _reference(message: Message, number: int) -> int | None:
|
|
78
|
+
field = message.one(number)
|
|
79
|
+
return None if field is None else parse_reference(bytes(field.value))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _parse_cell_storage(data: bytes) -> CellStorage:
|
|
83
|
+
if len(data) < 12:
|
|
84
|
+
raise ValueError("TST cell storage is shorter than its 12-byte header")
|
|
85
|
+
if data[0] != CELL_STORAGE_VERSION:
|
|
86
|
+
raise ValueError(f"unsupported TST cell storage version {data[0]}")
|
|
87
|
+
flags = struct.unpack_from("<I", data, 8)[0]
|
|
88
|
+
offset = 12
|
|
89
|
+
identifiers: dict[int, int] = {}
|
|
90
|
+
# Every active flag contributes one fixed-width payload in increasing flag
|
|
91
|
+
# order. Numeric/date values precede the keyed identifiers.
|
|
92
|
+
widths = {
|
|
93
|
+
0x1: 16,
|
|
94
|
+
0x2: 8,
|
|
95
|
+
0x4: 8,
|
|
96
|
+
**{1 << bit: 4 for bit in range(3, 21)},
|
|
97
|
+
}
|
|
98
|
+
for flag, width in widths.items():
|
|
99
|
+
if not flags & flag:
|
|
100
|
+
continue
|
|
101
|
+
if offset + width > len(data):
|
|
102
|
+
raise ValueError("truncated TST cell storage flag payload")
|
|
103
|
+
if width == 4:
|
|
104
|
+
identifiers[flag] = struct.unpack_from("<i", data, offset)[0]
|
|
105
|
+
offset += width
|
|
106
|
+
return CellStorage(
|
|
107
|
+
raw=data,
|
|
108
|
+
cell_type=data[1],
|
|
109
|
+
string_id=identifiers.get(0x8),
|
|
110
|
+
rich_text_id=identifiers.get(0x10),
|
|
111
|
+
cell_style_id=identifiers.get(0x20),
|
|
112
|
+
text_style_id=identifiers.get(0x40),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _row_buffers(row_info: Message, columns: int) -> list[bytes | None]:
|
|
117
|
+
storage = row_info.one(6)
|
|
118
|
+
offsets_field = row_info.one(7)
|
|
119
|
+
if storage is None or offsets_field is None:
|
|
120
|
+
raise ValueError("pre-BNC TST table rows are unsupported")
|
|
121
|
+
offsets_raw = bytes(offsets_field.value)
|
|
122
|
+
if len(offsets_raw) % 2:
|
|
123
|
+
raise ValueError("TST cell offset array has an odd byte length")
|
|
124
|
+
offsets = list(struct.unpack(f"<{len(offsets_raw) // 2}h", offsets_raw))
|
|
125
|
+
wide = row_info.one(8)
|
|
126
|
+
if wide is not None and bool(wide.value):
|
|
127
|
+
offsets = [value * 4 for value in offsets]
|
|
128
|
+
payload = bytes(storage.value)
|
|
129
|
+
result: list[bytes | None] = []
|
|
130
|
+
for column in range(columns):
|
|
131
|
+
if column >= len(offsets) or offsets[column] < 0:
|
|
132
|
+
result.append(None)
|
|
133
|
+
continue
|
|
134
|
+
start = offsets[column]
|
|
135
|
+
end = next(
|
|
136
|
+
(value for value in offsets[column + 1 :] if value >= 0),
|
|
137
|
+
len(payload),
|
|
138
|
+
)
|
|
139
|
+
result.append(payload[start:end])
|
|
140
|
+
return result
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class TableStore:
|
|
144
|
+
"""Resolved TableInfo/TableModel/DataStore graph for one body table."""
|
|
145
|
+
|
|
146
|
+
def __init__(self, document: "PagesDocument", info: LocatedObject):
|
|
147
|
+
self.document = document
|
|
148
|
+
self.info = info
|
|
149
|
+
model_id = _reference(info.message, TABLE_INFO_MODEL)
|
|
150
|
+
model = document.objects().get(model_id) if model_id is not None else None
|
|
151
|
+
if model is None or model.info.type_id != TYPE_TABLE_MODEL:
|
|
152
|
+
raise ValueError(f"TST.TableInfoArchive {info.segment.identifier} has no table model")
|
|
153
|
+
self.model = model
|
|
154
|
+
data_store = model.message.one(TABLE_MODEL_DATA_STORE)
|
|
155
|
+
if data_store is None:
|
|
156
|
+
raise ValueError(f"TST.TableModelArchive {model_id} has no DataStore")
|
|
157
|
+
self.data_store = Message.parse(bytes(data_store.value))
|
|
158
|
+
self.rows = int(model.message.one(TABLE_MODEL_ROWS).value)
|
|
159
|
+
self.columns = int(model.message.one(TABLE_MODEL_COLUMNS).value)
|
|
160
|
+
name = model.message.one(TABLE_MODEL_NAME)
|
|
161
|
+
self.name = bytes(name.value).decode("utf-8", "replace") if name else ""
|
|
162
|
+
self._cells = self._load_cells()
|
|
163
|
+
|
|
164
|
+
def _load_cells(self) -> dict[tuple[int, int], CellStorage]:
|
|
165
|
+
tiles_field = self.data_store.one(DATA_STORE_TILES)
|
|
166
|
+
if tiles_field is None:
|
|
167
|
+
return {}
|
|
168
|
+
tiles = Message.parse(bytes(tiles_field.value))
|
|
169
|
+
tile_size_field = tiles.one(2)
|
|
170
|
+
tile_size = int(tile_size_field.value) if tile_size_field is not None else 256
|
|
171
|
+
result: dict[tuple[int, int], CellStorage] = {}
|
|
172
|
+
objects = self.document.objects()
|
|
173
|
+
for tile_field in tiles.get(1):
|
|
174
|
+
tile_link = Message.parse(bytes(tile_field.value))
|
|
175
|
+
tile_id_field = tile_link.one(1)
|
|
176
|
+
tile_id = int(tile_id_field.value) if tile_id_field is not None else 0
|
|
177
|
+
object_id = _reference(tile_link, 2)
|
|
178
|
+
tile = objects.get(object_id) if object_id is not None else None
|
|
179
|
+
if tile is None or tile.info.type_id != TYPE_TILE:
|
|
180
|
+
continue
|
|
181
|
+
for row_field in tile.message.get(5):
|
|
182
|
+
row_info = Message.parse(bytes(row_field.value))
|
|
183
|
+
row_index_field = row_info.one(1)
|
|
184
|
+
if row_index_field is None:
|
|
185
|
+
continue
|
|
186
|
+
row = tile_id * tile_size + int(row_index_field.value)
|
|
187
|
+
for column, raw in enumerate(_row_buffers(row_info, self.columns)):
|
|
188
|
+
if raw is not None:
|
|
189
|
+
result[(row, column)] = _parse_cell_storage(raw)
|
|
190
|
+
return result
|
|
191
|
+
|
|
192
|
+
def cell_storage(self, row: int, column: int) -> CellStorage | None:
|
|
193
|
+
return self._cells.get((row, column))
|
|
194
|
+
|
|
195
|
+
def _data_list(self, field_number: int, expected_type: int) -> LocatedObject:
|
|
196
|
+
object_id = _reference(self.data_store, field_number)
|
|
197
|
+
obj = self.document.objects().get(object_id) if object_id is not None else None
|
|
198
|
+
if obj is None or obj.info.type_id != TYPE_TABLE_DATA_LIST:
|
|
199
|
+
raise ValueError(f"table DataStore field {field_number} has no TableDataList")
|
|
200
|
+
list_type = obj.message.one(DATA_LIST_TYPE)
|
|
201
|
+
if list_type is None or int(list_type.value) != expected_type:
|
|
202
|
+
raise ValueError(f"TableDataList {object_id} has unexpected list type")
|
|
203
|
+
return obj
|
|
204
|
+
|
|
205
|
+
@staticmethod
|
|
206
|
+
def _entry(data_list: LocatedObject, key: int) -> tuple[object, Message]:
|
|
207
|
+
for raw_entry in data_list.message.get(DATA_LIST_ENTRIES):
|
|
208
|
+
entry = Message.parse(bytes(raw_entry.value))
|
|
209
|
+
key_field = entry.one(LIST_ENTRY_KEY)
|
|
210
|
+
if key_field is not None and int(key_field.value) == key:
|
|
211
|
+
return raw_entry, entry
|
|
212
|
+
raise KeyError(f"TableDataList {data_list.segment.identifier} has no key {key}")
|
|
213
|
+
|
|
214
|
+
def string(self, key: int) -> str:
|
|
215
|
+
data_list = self._data_list(DATA_STORE_STRING_TABLE, DATA_LIST_STRING)
|
|
216
|
+
_, entry = self._entry(data_list, key)
|
|
217
|
+
value = entry.one(LIST_ENTRY_STRING)
|
|
218
|
+
return "" if value is None else bytes(value.value).decode("utf-8", "replace")
|
|
219
|
+
|
|
220
|
+
def set_string(self, key: int, value: str) -> None:
|
|
221
|
+
data_list = self._data_list(DATA_STORE_STRING_TABLE, DATA_LIST_STRING)
|
|
222
|
+
raw_entry, entry = self._entry(data_list, key)
|
|
223
|
+
refcount = entry.one(LIST_ENTRY_REFCOUNT)
|
|
224
|
+
if refcount is not None and int(refcount.value) != 1:
|
|
225
|
+
raise NotImplementedError("shared TST string entries require cell-key reassignment")
|
|
226
|
+
string = entry.one(LIST_ENTRY_STRING)
|
|
227
|
+
if string is None:
|
|
228
|
+
entry.add_bytes(LIST_ENTRY_STRING, value.encode("utf-8"))
|
|
229
|
+
else:
|
|
230
|
+
string.set_bytes(value.encode("utf-8"))
|
|
231
|
+
raw_entry.set_bytes(entry.encode())
|
|
232
|
+
|
|
233
|
+
def text_style(self, row: int, column: int, cell: CellStorage | None) -> LocatedObject | None:
|
|
234
|
+
style_key = cell.text_style_id if cell is not None else None
|
|
235
|
+
if style_key is not None:
|
|
236
|
+
styles = self._data_list(DATA_STORE_STYLE_TABLE, DATA_LIST_STYLE)
|
|
237
|
+
_, entry = self._entry(styles, style_key)
|
|
238
|
+
object_id = _reference(entry, LIST_ENTRY_REFERENCE)
|
|
239
|
+
else:
|
|
240
|
+
header_rows = self.model.message.one(TABLE_MODEL_HEADER_ROWS)
|
|
241
|
+
header_columns = self.model.message.one(TABLE_MODEL_HEADER_COLUMNS)
|
|
242
|
+
footer_rows = self.model.message.one(TABLE_MODEL_FOOTER_ROWS)
|
|
243
|
+
if header_rows is not None and row < int(header_rows.value):
|
|
244
|
+
field_number = TABLE_MODEL_HEADER_ROW_TEXT_STYLE
|
|
245
|
+
elif header_columns is not None and column < int(header_columns.value):
|
|
246
|
+
field_number = TABLE_MODEL_HEADER_COLUMN_TEXT_STYLE
|
|
247
|
+
elif footer_rows is not None and row >= self.rows - int(footer_rows.value):
|
|
248
|
+
field_number = TABLE_MODEL_FOOTER_ROW_TEXT_STYLE
|
|
249
|
+
else:
|
|
250
|
+
field_number = TABLE_MODEL_BODY_TEXT_STYLE
|
|
251
|
+
object_id = _reference(self.model.message, field_number)
|
|
252
|
+
return self.document.objects().get(object_id) if object_id is not None else None
|
|
253
|
+
|
|
254
|
+
def character_properties(
|
|
255
|
+
self, row: int, column: int, cell: CellStorage | None
|
|
256
|
+
) -> CharacterProperties:
|
|
257
|
+
style = self.text_style(row, column, cell)
|
|
258
|
+
chain: list[CharacterProperties] = []
|
|
259
|
+
seen: set[int] = set()
|
|
260
|
+
while style is not None and style.segment.identifier not in seen:
|
|
261
|
+
seen.add(style.segment.identifier)
|
|
262
|
+
chain.append(character_style_properties(style))
|
|
263
|
+
super_field = style.message.one(1)
|
|
264
|
+
if super_field is None:
|
|
265
|
+
break
|
|
266
|
+
super_message = Message.parse(bytes(super_field.value))
|
|
267
|
+
parent_id = _reference(super_message, 3)
|
|
268
|
+
style = self.document.objects().get(parent_id) if parent_id is not None else None
|
|
269
|
+
values = {}
|
|
270
|
+
for item in fields(CharacterProperties):
|
|
271
|
+
value = next(
|
|
272
|
+
(getattr(props, item.name) for props in chain if getattr(props, item.name) is not None),
|
|
273
|
+
None,
|
|
274
|
+
)
|
|
275
|
+
values[item.name] = value
|
|
276
|
+
return CharacterProperties(**values)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def body_table_stores(document: "PagesDocument", storage: LocatedObject) -> list[TableStore]:
|
|
280
|
+
"""Resolve body attachment runs to their TST table graphs in document order."""
|
|
281
|
+
field = storage.message.one(STORAGE_TABLE_ATTACHMENTS)
|
|
282
|
+
if field is None:
|
|
283
|
+
return []
|
|
284
|
+
objects = document.objects()
|
|
285
|
+
result: list[TableStore] = []
|
|
286
|
+
seen: set[int] = set()
|
|
287
|
+
for run in parse_attribute_table(bytes(field.value)):
|
|
288
|
+
attachment = objects.get(run.object_id) if run.object_id is not None else None
|
|
289
|
+
if attachment is None or attachment.info.type_id != TYPE_ATTACHMENT:
|
|
290
|
+
continue
|
|
291
|
+
info_id = _reference(attachment.message, 1)
|
|
292
|
+
info = objects.get(info_id) if info_id is not None else None
|
|
293
|
+
if info is None or info.info.type_id != TYPE_TABLE_INFO or info_id in seen:
|
|
294
|
+
continue
|
|
295
|
+
seen.add(info_id)
|
|
296
|
+
result.append(TableStore(document, info))
|
|
297
|
+
return result
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _body_table_attachment(
|
|
301
|
+
document: "PagesDocument", storage: LocatedObject
|
|
302
|
+
) -> LocatedObject:
|
|
303
|
+
field = storage.message.one(STORAGE_TABLE_ATTACHMENTS)
|
|
304
|
+
objects = document.objects()
|
|
305
|
+
if field is not None:
|
|
306
|
+
for run in parse_attribute_table(bytes(field.value)):
|
|
307
|
+
attachment = objects.get(run.object_id) if run.object_id is not None else None
|
|
308
|
+
if attachment is None or attachment.info.type_id != TYPE_ATTACHMENT:
|
|
309
|
+
continue
|
|
310
|
+
info_id = _reference(attachment.message, 1)
|
|
311
|
+
info = objects.get(info_id) if info_id is not None else None
|
|
312
|
+
if info is not None and info.info.type_id == TYPE_TABLE_INFO:
|
|
313
|
+
return attachment
|
|
314
|
+
raise ValueError("document has no Pages-authored table graph to use as a template")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _set_varint(message: Message, number: int, value: int) -> None:
|
|
318
|
+
field = message.one(number)
|
|
319
|
+
if field is None:
|
|
320
|
+
message.add_varint(number, value)
|
|
321
|
+
else:
|
|
322
|
+
field.set_varint(value)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _resize_header_bucket(
|
|
326
|
+
bucket: Message, count: int, cells_per_header: int
|
|
327
|
+
) -> None:
|
|
328
|
+
source = bucket.get(2)
|
|
329
|
+
if not source:
|
|
330
|
+
return
|
|
331
|
+
template = Message.parse(bytes(source[0].value))
|
|
332
|
+
bucket.fields = [field for field in bucket.fields if field.number != 2]
|
|
333
|
+
for index in range(count):
|
|
334
|
+
header = Message.parse(template.encode())
|
|
335
|
+
_set_varint(header, 1, index)
|
|
336
|
+
_set_varint(header, 4, cells_per_header)
|
|
337
|
+
bucket.add_bytes(2, header.encode())
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _uuid_message(value: uuid.UUID) -> bytes:
|
|
341
|
+
message = Message()
|
|
342
|
+
message.add_varint(1, value.int >> 64)
|
|
343
|
+
message.add_varint(2, value.int & ((1 << 64) - 1))
|
|
344
|
+
return message.encode()
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _rebuild_uid_map(
|
|
348
|
+
uid_map: Message, rows: int, columns: int, *, seed: str
|
|
349
|
+
) -> None:
|
|
350
|
+
"""Replace the cloned 2x2 row/column UID index with the requested grid."""
|
|
351
|
+
|
|
352
|
+
def dimension(count: int, label: str) -> tuple[list[bytes], list[int], list[int]]:
|
|
353
|
+
indexed = [
|
|
354
|
+
(index, uuid.uuid5(uuid.NAMESPACE_URL, f"{seed}:{label}:{index}"))
|
|
355
|
+
for index in range(count)
|
|
356
|
+
]
|
|
357
|
+
sorted_values = sorted(indexed, key=lambda item: item[1].int)
|
|
358
|
+
index_for_uid = [index for index, _ in sorted_values]
|
|
359
|
+
uid_for_index = [0] * count
|
|
360
|
+
for uid_index, (original_index, _) in enumerate(sorted_values):
|
|
361
|
+
uid_for_index[original_index] = uid_index
|
|
362
|
+
return (
|
|
363
|
+
[_uuid_message(value) for _, value in sorted_values],
|
|
364
|
+
index_for_uid,
|
|
365
|
+
uid_for_index,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
columns_sorted, column_indexes, column_uids = dimension(columns, "column")
|
|
369
|
+
rows_sorted, row_indexes, row_uids = dimension(rows, "row")
|
|
370
|
+
uid_map.fields = []
|
|
371
|
+
for encoded in columns_sorted:
|
|
372
|
+
uid_map.add_bytes(1, encoded)
|
|
373
|
+
for value in column_indexes:
|
|
374
|
+
uid_map.add_varint(2, value)
|
|
375
|
+
for value in column_uids:
|
|
376
|
+
uid_map.add_varint(3, value)
|
|
377
|
+
for encoded in rows_sorted:
|
|
378
|
+
uid_map.add_bytes(4, encoded)
|
|
379
|
+
for value in row_indexes:
|
|
380
|
+
uid_map.add_varint(5, value)
|
|
381
|
+
for value in row_uids:
|
|
382
|
+
uid_map.add_varint(6, value)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _clear_tile(tile: Message, rows: int, columns: int) -> None:
|
|
386
|
+
tile.fields = [field for field in tile.fields if field.number != 5]
|
|
387
|
+
for number, value in ((1, 0), (2, 0), (3, 0), (4, rows), (6, 5), (7, 1), (8, 1)):
|
|
388
|
+
_set_varint(tile, number, value)
|
|
389
|
+
offsets = struct.pack(f"<{columns}h", *([-1] * columns))
|
|
390
|
+
for row_index in range(rows):
|
|
391
|
+
row = Message()
|
|
392
|
+
row.add_varint(1, row_index)
|
|
393
|
+
row.add_varint(2, 0)
|
|
394
|
+
row.add_bytes(3, "🤠".encode("utf-8"))
|
|
395
|
+
row.add_bytes(4, "🤠".encode("utf-8"))
|
|
396
|
+
row.add_varint(5, 5)
|
|
397
|
+
row.add_bytes(6, b"")
|
|
398
|
+
row.add_bytes(7, offsets)
|
|
399
|
+
row.add_varint(8, 1)
|
|
400
|
+
tile.add_bytes(5, row.encode())
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def clone_body_table(
|
|
404
|
+
document: "PagesDocument", storage: LocatedObject, rows: int, columns: int
|
|
405
|
+
) -> tuple[TableStore, GraphClone]:
|
|
406
|
+
"""Clone the fixture-authored complete TST graph as a blank body table."""
|
|
407
|
+
template_attachment = _body_table_attachment(document, storage)
|
|
408
|
+
|
|
409
|
+
def include(obj: LocatedObject) -> bool:
|
|
410
|
+
return obj.filename.startswith("Index/CalculationEngine-") or obj.filename.startswith(
|
|
411
|
+
"Index/Tables/"
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
clone = clone_object_graph(
|
|
415
|
+
document,
|
|
416
|
+
template_attachment.segment.identifier,
|
|
417
|
+
include=include,
|
|
418
|
+
)
|
|
419
|
+
objects = document.objects()
|
|
420
|
+
info_id = _reference(clone.root.message, 1)
|
|
421
|
+
info = objects.get(info_id) if info_id is not None else None
|
|
422
|
+
if info is None or info.info.type_id != TYPE_TABLE_INFO:
|
|
423
|
+
raise ValueError("cloned table attachment has no TableInfoArchive")
|
|
424
|
+
model_id = _reference(info.message, TABLE_INFO_MODEL)
|
|
425
|
+
model = objects.get(model_id) if model_id is not None else None
|
|
426
|
+
if model is None or model.info.type_id != TYPE_TABLE_MODEL:
|
|
427
|
+
raise ValueError("cloned TableInfoArchive has no TableModelArchive")
|
|
428
|
+
|
|
429
|
+
seed = f"python-pages:table:{info.segment.identifier}:{model.segment.identifier}"
|
|
430
|
+
for new_identifier in clone.identifiers.values():
|
|
431
|
+
replace_uuid_identities(objects[new_identifier].message, seed=seed)
|
|
432
|
+
table_id = model.message.one(1)
|
|
433
|
+
generated_table_id = str(uuid.uuid5(uuid.NAMESPACE_URL, seed)).upper().encode("ascii")
|
|
434
|
+
if table_id is None:
|
|
435
|
+
model.message.add_bytes(1, generated_table_id)
|
|
436
|
+
else:
|
|
437
|
+
table_id.set_bytes(generated_table_id)
|
|
438
|
+
_set_varint(model.message, TABLE_MODEL_ROWS, rows)
|
|
439
|
+
_set_varint(model.message, TABLE_MODEL_COLUMNS, columns)
|
|
440
|
+
name = model.message.one(TABLE_MODEL_NAME)
|
|
441
|
+
table_number = len(body_table_stores(document, storage)) + 1
|
|
442
|
+
encoded_name = f"Table {table_number}".encode("utf-8")
|
|
443
|
+
if name is None:
|
|
444
|
+
model.message.add_bytes(TABLE_MODEL_NAME, encoded_name)
|
|
445
|
+
else:
|
|
446
|
+
name.set_bytes(encoded_name)
|
|
447
|
+
|
|
448
|
+
data_store_field = model.message.one(TABLE_MODEL_DATA_STORE)
|
|
449
|
+
if data_store_field is None:
|
|
450
|
+
raise ValueError("cloned TableModelArchive has no DataStore")
|
|
451
|
+
data_store = Message.parse(bytes(data_store_field.value))
|
|
452
|
+
row_headers_field = data_store.one(DATA_STORE_ROW_HEADERS)
|
|
453
|
+
if row_headers_field is None:
|
|
454
|
+
raise ValueError("cloned table DataStore has no row HeaderStorage")
|
|
455
|
+
row_headers = Message.parse(bytes(row_headers_field.value))
|
|
456
|
+
row_bucket_ids = [
|
|
457
|
+
parse_reference(bytes(field.value)) for field in row_headers.get(2)
|
|
458
|
+
]
|
|
459
|
+
column_bucket_id = _reference(data_store, DATA_STORE_COLUMN_HEADERS)
|
|
460
|
+
for row_bucket_id in row_bucket_ids:
|
|
461
|
+
bucket = objects.get(row_bucket_id) if row_bucket_id is not None else None
|
|
462
|
+
if bucket is None or bucket.info.type_id != TYPE_HEADER_STORAGE_BUCKET:
|
|
463
|
+
raise ValueError("cloned table row HeaderStorageBucket is missing")
|
|
464
|
+
_resize_header_bucket(bucket.message, rows, columns)
|
|
465
|
+
column_bucket = (
|
|
466
|
+
objects.get(column_bucket_id) if column_bucket_id is not None else None
|
|
467
|
+
)
|
|
468
|
+
if column_bucket is None or column_bucket.info.type_id != TYPE_HEADER_STORAGE_BUCKET:
|
|
469
|
+
raise ValueError("cloned table column HeaderStorageBucket is missing")
|
|
470
|
+
_resize_header_bucket(column_bucket.message, columns, rows)
|
|
471
|
+
|
|
472
|
+
uid_map_id = _reference(model.message, TABLE_MODEL_COLUMN_ROW_UIDS)
|
|
473
|
+
uid_map = objects.get(uid_map_id) if uid_map_id is not None else None
|
|
474
|
+
if uid_map is None or uid_map.info.type_id != TYPE_COLUMN_ROW_UID_MAP:
|
|
475
|
+
raise ValueError("cloned TableModelArchive has no ColumnRowUIDMapArchive")
|
|
476
|
+
_rebuild_uid_map(uid_map.message, rows, columns, seed=seed)
|
|
477
|
+
|
|
478
|
+
sidecar_id = _reference(model.message, TABLE_MODEL_STROKE_SIDECAR)
|
|
479
|
+
sidecar = objects.get(sidecar_id) if sidecar_id is not None else None
|
|
480
|
+
if sidecar is None or sidecar.info.type_id != TYPE_STROKE_SIDECAR:
|
|
481
|
+
raise ValueError("cloned TableModelArchive has no StrokeSidecarArchive")
|
|
482
|
+
_set_varint(sidecar.message, 2, columns)
|
|
483
|
+
_set_varint(sidecar.message, 3, rows)
|
|
484
|
+
|
|
485
|
+
# Every owned tile is made blank. Retaining the Pages-authored list/style
|
|
486
|
+
# components is intentional: the empty tile no longer references their
|
|
487
|
+
# entries, but the complete editing graph remains available to Pages.
|
|
488
|
+
for old, new in clone.identifiers.items():
|
|
489
|
+
obj = objects[new]
|
|
490
|
+
if obj.info.type_id == TYPE_TILE:
|
|
491
|
+
_clear_tile(obj.message, rows, columns)
|
|
492
|
+
|
|
493
|
+
return TableStore(document, info), clone
|