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/pictures.py
ADDED
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
"""Middle-layer access to inline TSD image attachments and package media."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from hashlib import sha1
|
|
6
|
+
import binascii
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import struct
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
import zlib
|
|
11
|
+
|
|
12
|
+
from .components import GraphClone, clone_object_graph, remap_payload_data_references
|
|
13
|
+
from .metadata import component_name
|
|
14
|
+
from .protobuf import Message
|
|
15
|
+
from .text import LocatedObject, parse_attribute_table, parse_reference
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from .pages import PagesDocument
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
TYPE_ATTACHMENT = 2003
|
|
22
|
+
TYPE_IMAGE = 3005
|
|
23
|
+
|
|
24
|
+
STORAGE_TABLE_ATTACHMENTS = 9
|
|
25
|
+
IMAGE_DATA = 11
|
|
26
|
+
IMAGE_THUMBNAIL_DATA = 12
|
|
27
|
+
IMAGE_NATURAL_SIZE = 9
|
|
28
|
+
|
|
29
|
+
PACKAGE_DATAS = 4
|
|
30
|
+
DATA_IDENTIFIER = 1
|
|
31
|
+
DATA_DIGEST = 2
|
|
32
|
+
DATA_PREFERRED_FILENAME = 3
|
|
33
|
+
DATA_FILENAME = 4
|
|
34
|
+
DATA_MATERIALIZED_LENGTH = 18
|
|
35
|
+
DATA_ATTRIBUTES = 10
|
|
36
|
+
DATA_ATTRIBUTES_IMAGE = 100
|
|
37
|
+
IMAGE_ATTRIBUTES_PIXEL_SIZE = 1
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _reference(message: Message, number: int) -> int | None:
|
|
41
|
+
field = message.one(number)
|
|
42
|
+
return None if field is None else parse_reference(bytes(field.value))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ImageStore:
|
|
46
|
+
"""Resolved ImageArchive plus its full-size and thumbnail DataInfo entries."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, document: "PagesDocument", image: LocatedObject):
|
|
49
|
+
self.document = document
|
|
50
|
+
self.image = image
|
|
51
|
+
self.data_identifier = _reference(image.message, IMAGE_DATA)
|
|
52
|
+
self.thumbnail_identifier = _reference(image.message, IMAGE_THUMBNAIL_DATA)
|
|
53
|
+
if self.data_identifier is None:
|
|
54
|
+
raise ValueError(f"TSD.ImageArchive {image.segment.identifier} has no image data")
|
|
55
|
+
|
|
56
|
+
def _data_info(self, identifier: int):
|
|
57
|
+
package = self.document.package_metadata()
|
|
58
|
+
for raw in package.message.get(PACKAGE_DATAS):
|
|
59
|
+
info = Message.parse(bytes(raw.value))
|
|
60
|
+
field = info.one(DATA_IDENTIFIER)
|
|
61
|
+
if field is not None and int(field.value) == identifier:
|
|
62
|
+
return raw, info
|
|
63
|
+
raise KeyError(f"PackageMetadata has no DataInfo {identifier}")
|
|
64
|
+
|
|
65
|
+
def _member_name(self, identifier: int) -> str:
|
|
66
|
+
_, info = self._data_info(identifier)
|
|
67
|
+
filename = info.one(DATA_FILENAME) or info.one(DATA_PREFERRED_FILENAME)
|
|
68
|
+
if filename is None:
|
|
69
|
+
raise ValueError(f"DataInfo {identifier} has no package filename")
|
|
70
|
+
return "Data/" + bytes(filename.value).decode("utf-8", "replace")
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def filename(self) -> str:
|
|
74
|
+
_, info = self._data_info(self.data_identifier)
|
|
75
|
+
filename = info.one(DATA_PREFERRED_FILENAME) or info.one(DATA_FILENAME)
|
|
76
|
+
return "" if filename is None else bytes(filename.value).decode("utf-8", "replace")
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def blob(self) -> bytes:
|
|
80
|
+
name = self._member_name(self.data_identifier)
|
|
81
|
+
data = self.document.package_data(name)
|
|
82
|
+
if data is None:
|
|
83
|
+
raise KeyError(f"image package member {name!r} is missing")
|
|
84
|
+
return data
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def thumbnail_blob(self) -> bytes | None:
|
|
88
|
+
if self.thumbnail_identifier is None:
|
|
89
|
+
return None
|
|
90
|
+
name = self._member_name(self.thumbnail_identifier)
|
|
91
|
+
return self.document.package_data(name)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def natural_size(self) -> tuple[float, float] | None:
|
|
95
|
+
field = self.image.message.one(IMAGE_NATURAL_SIZE)
|
|
96
|
+
if field is None:
|
|
97
|
+
return None
|
|
98
|
+
size = Message.parse(bytes(field.value))
|
|
99
|
+
width, height = size.one(1), size.one(2)
|
|
100
|
+
if width is None or height is None or width.wire_type != 5 or height.wire_type != 5:
|
|
101
|
+
return None
|
|
102
|
+
return tuple(
|
|
103
|
+
struct.unpack("<f", int(item.value).to_bytes(4, "little"))[0]
|
|
104
|
+
for item in (width, height)
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def _float_bits(value: float) -> int:
|
|
109
|
+
return int.from_bytes(struct.pack("<f", float(value)), "little")
|
|
110
|
+
|
|
111
|
+
def _set_natural_size(self, width: int, height: int) -> None:
|
|
112
|
+
field = self.image.message.one(IMAGE_NATURAL_SIZE)
|
|
113
|
+
size = Message.parse(bytes(field.value)) if field is not None else Message()
|
|
114
|
+
for number, value in ((1, width), (2, height)):
|
|
115
|
+
item = size.one(number)
|
|
116
|
+
if item is None:
|
|
117
|
+
size.add_fixed32(number, self._float_bits(value))
|
|
118
|
+
else:
|
|
119
|
+
item.set_fixed32(self._float_bits(value))
|
|
120
|
+
if field is None:
|
|
121
|
+
self.image.message.add_bytes(IMAGE_NATURAL_SIZE, size.encode())
|
|
122
|
+
else:
|
|
123
|
+
field.set_bytes(size.encode())
|
|
124
|
+
|
|
125
|
+
@classmethod
|
|
126
|
+
def _set_pixel_size(cls, info: Message, width: int, height: int) -> None:
|
|
127
|
+
attributes_field = info.one(DATA_ATTRIBUTES)
|
|
128
|
+
if attributes_field is None:
|
|
129
|
+
return
|
|
130
|
+
attributes = Message.parse(bytes(attributes_field.value))
|
|
131
|
+
image_field = attributes.one(DATA_ATTRIBUTES_IMAGE)
|
|
132
|
+
if image_field is None:
|
|
133
|
+
return
|
|
134
|
+
image_attributes = Message.parse(bytes(image_field.value))
|
|
135
|
+
size_field = image_attributes.one(IMAGE_ATTRIBUTES_PIXEL_SIZE)
|
|
136
|
+
if size_field is None:
|
|
137
|
+
return
|
|
138
|
+
size = Message.parse(bytes(size_field.value))
|
|
139
|
+
for number, value in ((1, width), (2, height)):
|
|
140
|
+
item = size.one(number)
|
|
141
|
+
if item is None:
|
|
142
|
+
size.add_fixed32(number, cls._float_bits(value))
|
|
143
|
+
else:
|
|
144
|
+
item.set_fixed32(cls._float_bits(value))
|
|
145
|
+
size_field.set_bytes(size.encode())
|
|
146
|
+
image_field.set_bytes(image_attributes.encode())
|
|
147
|
+
attributes_field.set_bytes(attributes.encode())
|
|
148
|
+
|
|
149
|
+
def _replace_data(
|
|
150
|
+
self, identifier: int, data: bytes, pixel_size: tuple[int, int] | None = None
|
|
151
|
+
) -> None:
|
|
152
|
+
raw_info, info = self._data_info(identifier)
|
|
153
|
+
if pixel_size is not None:
|
|
154
|
+
self._set_pixel_size(info, *pixel_size)
|
|
155
|
+
digest = info.one(DATA_DIGEST)
|
|
156
|
+
if digest is None:
|
|
157
|
+
info.add_bytes(DATA_DIGEST, sha1(data).digest())
|
|
158
|
+
else:
|
|
159
|
+
digest.set_bytes(sha1(data).digest())
|
|
160
|
+
length = info.one(DATA_MATERIALIZED_LENGTH)
|
|
161
|
+
if length is None:
|
|
162
|
+
info.add_varint(DATA_MATERIALIZED_LENGTH, len(data))
|
|
163
|
+
else:
|
|
164
|
+
length.set_varint(len(data))
|
|
165
|
+
raw_info.set_bytes(info.encode())
|
|
166
|
+
self.document.set_package_data(self._member_name(identifier), data)
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
def _png_size(data: bytes) -> tuple[int, int]:
|
|
170
|
+
if not data.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
171
|
+
raise ValueError("fixture-safe image replacement currently requires PNG data")
|
|
172
|
+
if len(data) < 24 or data[12:16] != b"IHDR":
|
|
173
|
+
raise ValueError("PNG replacement has no complete IHDR")
|
|
174
|
+
pixel_size = struct.unpack(">II", data[16:24])
|
|
175
|
+
if not all(pixel_size):
|
|
176
|
+
raise ValueError("PNG replacement dimensions must be non-zero")
|
|
177
|
+
return pixel_size
|
|
178
|
+
|
|
179
|
+
def replace(self, data: bytes, thumbnail_data: bytes | None = None) -> None:
|
|
180
|
+
pixel_size = self._png_size(data)
|
|
181
|
+
selected_thumbnail = thumbnail_data
|
|
182
|
+
if self.thumbnail_identifier is not None and selected_thumbnail is None:
|
|
183
|
+
# A small source is already a valid editing thumbnail. For larger
|
|
184
|
+
# sources, preserve Pages' existing 256px thumbnail rather than
|
|
185
|
+
# violating the distinct full/thumbnail dimension contract.
|
|
186
|
+
if max(pixel_size) <= 256:
|
|
187
|
+
selected_thumbnail = data
|
|
188
|
+
if selected_thumbnail is not None:
|
|
189
|
+
thumbnail_size = self._png_size(selected_thumbnail)
|
|
190
|
+
scale = min(1.0, 256.0 / max(pixel_size))
|
|
191
|
+
expected = tuple(max(1, round(value * scale)) for value in pixel_size)
|
|
192
|
+
if thumbnail_size != expected:
|
|
193
|
+
raise ValueError(
|
|
194
|
+
f"thumbnail dimensions {thumbnail_size} do not match expected {expected}"
|
|
195
|
+
)
|
|
196
|
+
self._set_natural_size(*pixel_size)
|
|
197
|
+
self._replace_data(self.data_identifier, data, pixel_size)
|
|
198
|
+
if self.thumbnail_identifier is not None and selected_thumbnail is not None:
|
|
199
|
+
self._replace_data(self.thumbnail_identifier, selected_thumbnail)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _set_varint(message: Message, number: int, value: int) -> None:
|
|
203
|
+
field = message.one(number)
|
|
204
|
+
if field is None:
|
|
205
|
+
message.add_varint(number, value)
|
|
206
|
+
else:
|
|
207
|
+
field.set_varint(value)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _set_bytes(message: Message, number: int, value: bytes) -> None:
|
|
211
|
+
field = message.one(number)
|
|
212
|
+
if field is None:
|
|
213
|
+
message.add_bytes(number, value)
|
|
214
|
+
else:
|
|
215
|
+
field.set_bytes(value)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _png_chunks(data: bytes) -> list[tuple[bytes, bytes]]:
|
|
219
|
+
ImageStore._png_size(data)
|
|
220
|
+
chunks: list[tuple[bytes, bytes]] = []
|
|
221
|
+
position = 8
|
|
222
|
+
while position + 12 <= len(data):
|
|
223
|
+
length = struct.unpack(">I", data[position : position + 4])[0]
|
|
224
|
+
kind = data[position + 4 : position + 8]
|
|
225
|
+
end = position + 12 + length
|
|
226
|
+
if end > len(data):
|
|
227
|
+
raise ValueError("PNG has a truncated chunk")
|
|
228
|
+
payload = data[position + 8 : position + 8 + length]
|
|
229
|
+
chunks.append((kind, payload))
|
|
230
|
+
position = end
|
|
231
|
+
if kind == b"IEND":
|
|
232
|
+
break
|
|
233
|
+
return chunks
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _paeth(left: int, above: int, upper_left: int) -> int:
|
|
237
|
+
estimate = left + above - upper_left
|
|
238
|
+
distances = (
|
|
239
|
+
abs(estimate - left),
|
|
240
|
+
abs(estimate - above),
|
|
241
|
+
abs(estimate - upper_left),
|
|
242
|
+
)
|
|
243
|
+
return (left, above, upper_left)[distances.index(min(distances))]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _thumbnail_png(data: bytes) -> bytes:
|
|
247
|
+
"""Create a dependency-free nearest-neighbor PNG with a 256px maximum."""
|
|
248
|
+
width, height = ImageStore._png_size(data)
|
|
249
|
+
if max(width, height) <= 256:
|
|
250
|
+
return data
|
|
251
|
+
chunks = _png_chunks(data)
|
|
252
|
+
ihdr = next(payload for kind, payload in chunks if kind == b"IHDR")
|
|
253
|
+
_, _, depth, color_type, compression, filtering, interlace = struct.unpack(
|
|
254
|
+
">IIBBBBB", ihdr
|
|
255
|
+
)
|
|
256
|
+
if depth != 8 or color_type not in (2, 6) or compression or filtering or interlace:
|
|
257
|
+
raise ValueError(
|
|
258
|
+
"automatic thumbnail creation supports non-interlaced 8-bit RGB/RGBA PNG"
|
|
259
|
+
)
|
|
260
|
+
channels = 3 if color_type == 2 else 4
|
|
261
|
+
encoded = zlib.decompress(
|
|
262
|
+
b"".join(payload for kind, payload in chunks if kind == b"IDAT")
|
|
263
|
+
)
|
|
264
|
+
stride = width * channels
|
|
265
|
+
rows: list[bytearray] = []
|
|
266
|
+
position = 0
|
|
267
|
+
previous = bytearray(stride)
|
|
268
|
+
for _ in range(height):
|
|
269
|
+
filter_type = encoded[position]
|
|
270
|
+
position += 1
|
|
271
|
+
raw = encoded[position : position + stride]
|
|
272
|
+
position += stride
|
|
273
|
+
row = bytearray(stride)
|
|
274
|
+
for index, value in enumerate(raw):
|
|
275
|
+
left = row[index - channels] if index >= channels else 0
|
|
276
|
+
above = previous[index]
|
|
277
|
+
upper_left = previous[index - channels] if index >= channels else 0
|
|
278
|
+
if filter_type == 0:
|
|
279
|
+
predictor = 0
|
|
280
|
+
elif filter_type == 1:
|
|
281
|
+
predictor = left
|
|
282
|
+
elif filter_type == 2:
|
|
283
|
+
predictor = above
|
|
284
|
+
elif filter_type == 3:
|
|
285
|
+
predictor = (left + above) // 2
|
|
286
|
+
elif filter_type == 4:
|
|
287
|
+
predictor = _paeth(left, above, upper_left)
|
|
288
|
+
else:
|
|
289
|
+
raise ValueError(f"PNG uses unknown scanline filter {filter_type}")
|
|
290
|
+
row[index] = (value + predictor) & 0xFF
|
|
291
|
+
rows.append(row)
|
|
292
|
+
previous = row
|
|
293
|
+
|
|
294
|
+
scale = 256.0 / max(width, height)
|
|
295
|
+
target_width = max(1, round(width * scale))
|
|
296
|
+
target_height = max(1, round(height * scale))
|
|
297
|
+
resized = bytearray()
|
|
298
|
+
for y in range(target_height):
|
|
299
|
+
source_y = min(height - 1, int(y * height / target_height))
|
|
300
|
+
resized.append(0)
|
|
301
|
+
row = rows[source_y]
|
|
302
|
+
for x in range(target_width):
|
|
303
|
+
source_x = min(width - 1, int(x * width / target_width))
|
|
304
|
+
start = source_x * channels
|
|
305
|
+
resized.extend(row[start : start + channels])
|
|
306
|
+
|
|
307
|
+
def chunk(kind: bytes, payload: bytes) -> bytes:
|
|
308
|
+
return (
|
|
309
|
+
struct.pack(">I", len(payload))
|
|
310
|
+
+ kind
|
|
311
|
+
+ payload
|
|
312
|
+
+ struct.pack(">I", binascii.crc32(kind + payload) & 0xFFFFFFFF)
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
result = bytearray(b"\x89PNG\r\n\x1a\n")
|
|
316
|
+
result.extend(
|
|
317
|
+
chunk(
|
|
318
|
+
b"IHDR",
|
|
319
|
+
struct.pack(
|
|
320
|
+
">IIBBBBB", target_width, target_height, 8, color_type, 0, 0, 0
|
|
321
|
+
),
|
|
322
|
+
)
|
|
323
|
+
)
|
|
324
|
+
for kind, payload in chunks:
|
|
325
|
+
if kind in (b"pHYs", b"sRGB", b"gAMA", b"cHRM", b"iCCP"):
|
|
326
|
+
result.extend(chunk(kind, payload))
|
|
327
|
+
result.extend(chunk(b"IDAT", zlib.compress(bytes(resized))))
|
|
328
|
+
result.extend(chunk(b"IEND", b""))
|
|
329
|
+
return bytes(result)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _data_info(document: "PagesDocument", identifier: int) -> Message:
|
|
333
|
+
for raw in document.package_metadata().message.get(PACKAGE_DATAS):
|
|
334
|
+
info = Message.parse(bytes(raw.value))
|
|
335
|
+
field = info.one(DATA_IDENTIFIER)
|
|
336
|
+
if field is not None and int(field.value) == identifier:
|
|
337
|
+
return info
|
|
338
|
+
raise KeyError(f"PackageMetadata has no DataInfo {identifier}")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _append_component_data_references(
|
|
342
|
+
document: "PagesDocument",
|
|
343
|
+
old_image_identifier: int,
|
|
344
|
+
new_image_identifier: int,
|
|
345
|
+
data_identifiers: dict[int, int],
|
|
346
|
+
) -> None:
|
|
347
|
+
package = document.package_metadata().message
|
|
348
|
+
for raw_component in package.get(3):
|
|
349
|
+
component = Message.parse(bytes(raw_component.value))
|
|
350
|
+
if component_name(component) != "Document":
|
|
351
|
+
continue
|
|
352
|
+
original = list(component.get(7))
|
|
353
|
+
for raw in original:
|
|
354
|
+
reference = Message.parse(bytes(raw.value))
|
|
355
|
+
data = reference.one(1)
|
|
356
|
+
if data is None or int(data.value) not in data_identifiers:
|
|
357
|
+
continue
|
|
358
|
+
data.set_varint(data_identifiers[int(data.value)])
|
|
359
|
+
for raw_object in reference.get(2):
|
|
360
|
+
object_reference = Message.parse(bytes(raw_object.value))
|
|
361
|
+
identifier = object_reference.one(1)
|
|
362
|
+
if identifier is not None and int(identifier.value) == old_image_identifier:
|
|
363
|
+
identifier.set_varint(new_image_identifier)
|
|
364
|
+
raw_object.set_bytes(object_reference.encode())
|
|
365
|
+
encoded = reference.encode()
|
|
366
|
+
if all(bytes(item.value) != encoded for item in component.get(7)):
|
|
367
|
+
component.add_bytes(7, encoded)
|
|
368
|
+
raw_component.set_bytes(component.encode())
|
|
369
|
+
return
|
|
370
|
+
raise ValueError("PackageMetadata has no Document component")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _set_logical_size(image: Message, width: float, height: float) -> None:
|
|
374
|
+
def set_size(size: Message) -> None:
|
|
375
|
+
for number, value in ((1, width), (2, height)):
|
|
376
|
+
bits = ImageStore._float_bits(value)
|
|
377
|
+
field = size.one(number)
|
|
378
|
+
if field is None:
|
|
379
|
+
size.add_fixed32(number, bits)
|
|
380
|
+
else:
|
|
381
|
+
field.set_fixed32(bits)
|
|
382
|
+
|
|
383
|
+
original = image.one(4)
|
|
384
|
+
original_size = Message.parse(bytes(original.value)) if original is not None else Message()
|
|
385
|
+
set_size(original_size)
|
|
386
|
+
if original is None:
|
|
387
|
+
image.add_bytes(4, original_size.encode())
|
|
388
|
+
else:
|
|
389
|
+
original.set_bytes(original_size.encode())
|
|
390
|
+
|
|
391
|
+
drawable_field = image.one(1)
|
|
392
|
+
if drawable_field is None:
|
|
393
|
+
return
|
|
394
|
+
drawable = Message.parse(bytes(drawable_field.value))
|
|
395
|
+
geometry_field = drawable.one(1)
|
|
396
|
+
if geometry_field is None:
|
|
397
|
+
return
|
|
398
|
+
geometry = Message.parse(bytes(geometry_field.value))
|
|
399
|
+
size_field = geometry.one(2)
|
|
400
|
+
size = Message.parse(bytes(size_field.value)) if size_field is not None else Message()
|
|
401
|
+
set_size(size)
|
|
402
|
+
if size_field is None:
|
|
403
|
+
geometry.add_bytes(2, size.encode())
|
|
404
|
+
else:
|
|
405
|
+
size_field.set_bytes(size.encode())
|
|
406
|
+
geometry_field.set_bytes(geometry.encode())
|
|
407
|
+
drawable_field.set_bytes(drawable.encode())
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _logical_size(image: Message) -> tuple[float, float] | None:
|
|
411
|
+
field = image.one(4)
|
|
412
|
+
if field is None:
|
|
413
|
+
return None
|
|
414
|
+
size = Message.parse(bytes(field.value))
|
|
415
|
+
values = []
|
|
416
|
+
for number in (1, 2):
|
|
417
|
+
item = size.one(number)
|
|
418
|
+
if item is None or item.wire_type != 5:
|
|
419
|
+
return None
|
|
420
|
+
values.append(struct.unpack("<f", int(item.value).to_bytes(4, "little"))[0])
|
|
421
|
+
return values[0], values[1]
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def clone_body_image(
|
|
425
|
+
document: "PagesDocument",
|
|
426
|
+
storage: LocatedObject,
|
|
427
|
+
data: bytes,
|
|
428
|
+
filename: str,
|
|
429
|
+
*,
|
|
430
|
+
width: float | None = None,
|
|
431
|
+
height: float | None = None,
|
|
432
|
+
) -> tuple[ImageStore, GraphClone]:
|
|
433
|
+
"""Clone a body image graph, then install independent full/thumbnail data."""
|
|
434
|
+
stores = body_image_stores(document, storage)
|
|
435
|
+
if not stores:
|
|
436
|
+
raise ValueError("document has no Pages-authored image graph to use as a template")
|
|
437
|
+
template = stores[0]
|
|
438
|
+
objects = document.objects()
|
|
439
|
+
template_attachment = None
|
|
440
|
+
field = storage.message.one(STORAGE_TABLE_ATTACHMENTS)
|
|
441
|
+
if field is not None:
|
|
442
|
+
for run in parse_attribute_table(bytes(field.value)):
|
|
443
|
+
attachment = objects.get(run.object_id) if run.object_id is not None else None
|
|
444
|
+
if attachment is None or attachment.info.type_id != TYPE_ATTACHMENT:
|
|
445
|
+
continue
|
|
446
|
+
if _reference(attachment.message, 1) == template.image.segment.identifier:
|
|
447
|
+
template_attachment = attachment
|
|
448
|
+
break
|
|
449
|
+
if template_attachment is None:
|
|
450
|
+
raise ValueError("image template attachment was not found")
|
|
451
|
+
|
|
452
|
+
clone = clone_object_graph(
|
|
453
|
+
document,
|
|
454
|
+
template_attachment.segment.identifier,
|
|
455
|
+
include=lambda obj: obj.filename == template.image.filename
|
|
456
|
+
and obj.info.type_id in (TYPE_IMAGE, 3097),
|
|
457
|
+
)
|
|
458
|
+
cloned_image_id = _reference(clone.root.message, 1)
|
|
459
|
+
cloned_image = document.objects().get(cloned_image_id) if cloned_image_id is not None else None
|
|
460
|
+
if cloned_image is None or cloned_image.info.type_id != TYPE_IMAGE:
|
|
461
|
+
raise ValueError("cloned attachment has no ImageArchive")
|
|
462
|
+
|
|
463
|
+
package = document.package_metadata().message
|
|
464
|
+
existing_data_ids = [
|
|
465
|
+
int(Message.parse(bytes(raw.value)).one(DATA_IDENTIFIER).value)
|
|
466
|
+
for raw in package.get(PACKAGE_DATAS)
|
|
467
|
+
]
|
|
468
|
+
full_id = max(existing_data_ids, default=0) + 1
|
|
469
|
+
thumbnail_id = full_id + 1
|
|
470
|
+
data_identifiers = {
|
|
471
|
+
template.data_identifier: full_id,
|
|
472
|
+
template.thumbnail_identifier: thumbnail_id,
|
|
473
|
+
}
|
|
474
|
+
data_identifiers = {old: new for old, new in data_identifiers.items() if old is not None}
|
|
475
|
+
thumbnail = _thumbnail_png(data)
|
|
476
|
+
stem = Path(filename).stem or "image"
|
|
477
|
+
preferred_full = f"{stem}.png"
|
|
478
|
+
preferred_thumbnail = f"{stem}-small.png"
|
|
479
|
+
stored_full = f"{stem}-{full_id}.png"
|
|
480
|
+
stored_thumbnail = f"{stem}-small-{thumbnail_id}.png"
|
|
481
|
+
for old, new, preferred, stored in (
|
|
482
|
+
(template.data_identifier, full_id, preferred_full, stored_full),
|
|
483
|
+
(template.thumbnail_identifier, thumbnail_id, preferred_thumbnail, stored_thumbnail),
|
|
484
|
+
):
|
|
485
|
+
if old is None:
|
|
486
|
+
continue
|
|
487
|
+
info = _data_info(document, old)
|
|
488
|
+
_set_varint(info, DATA_IDENTIFIER, new)
|
|
489
|
+
_set_bytes(info, DATA_PREFERRED_FILENAME, preferred.encode("utf-8"))
|
|
490
|
+
_set_bytes(info, DATA_FILENAME, stored.encode("utf-8"))
|
|
491
|
+
package.add_bytes(PACKAGE_DATAS, info.encode())
|
|
492
|
+
|
|
493
|
+
remap_payload_data_references(cloned_image, data_identifiers)
|
|
494
|
+
_append_component_data_references(
|
|
495
|
+
document,
|
|
496
|
+
template.image.segment.identifier,
|
|
497
|
+
cloned_image.segment.identifier,
|
|
498
|
+
data_identifiers,
|
|
499
|
+
)
|
|
500
|
+
document.set_package_data("Data/" + stored_full, data)
|
|
501
|
+
document.set_package_data("Data/" + stored_thumbnail, thumbnail)
|
|
502
|
+
store = ImageStore(document, cloned_image)
|
|
503
|
+
store.replace(data, thumbnail)
|
|
504
|
+
|
|
505
|
+
pixel_width, pixel_height = ImageStore._png_size(data)
|
|
506
|
+
if width is None and height is None:
|
|
507
|
+
template_size = _logical_size(cloned_image.message)
|
|
508
|
+
if template_size is not None:
|
|
509
|
+
maximum = max(template_size)
|
|
510
|
+
if pixel_width >= pixel_height:
|
|
511
|
+
width = maximum
|
|
512
|
+
height = maximum * pixel_height / pixel_width
|
|
513
|
+
else:
|
|
514
|
+
height = maximum
|
|
515
|
+
width = maximum * pixel_width / pixel_height
|
|
516
|
+
if width is not None or height is not None:
|
|
517
|
+
logical_width = (
|
|
518
|
+
float(width)
|
|
519
|
+
if width is not None
|
|
520
|
+
else float(height) * pixel_width / pixel_height
|
|
521
|
+
)
|
|
522
|
+
logical_height = (
|
|
523
|
+
float(height)
|
|
524
|
+
if height is not None
|
|
525
|
+
else float(width) * pixel_height / pixel_width
|
|
526
|
+
)
|
|
527
|
+
_set_logical_size(cloned_image.message, logical_width, logical_height)
|
|
528
|
+
return store, clone
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def body_image_stores(document: "PagesDocument", storage: LocatedObject) -> list[ImageStore]:
|
|
532
|
+
"""Resolve body attachment runs to ImageArchive objects in text order."""
|
|
533
|
+
field = storage.message.one(STORAGE_TABLE_ATTACHMENTS)
|
|
534
|
+
if field is None:
|
|
535
|
+
return []
|
|
536
|
+
objects = document.objects()
|
|
537
|
+
result: list[ImageStore] = []
|
|
538
|
+
seen: set[int] = set()
|
|
539
|
+
for run in parse_attribute_table(bytes(field.value)):
|
|
540
|
+
attachment = objects.get(run.object_id) if run.object_id is not None else None
|
|
541
|
+
if attachment is None or attachment.info.type_id != TYPE_ATTACHMENT:
|
|
542
|
+
continue
|
|
543
|
+
image_id = _reference(attachment.message, 1)
|
|
544
|
+
image = objects.get(image_id) if image_id is not None else None
|
|
545
|
+
if image is None or image.info.type_id != TYPE_IMAGE or image_id in seen:
|
|
546
|
+
continue
|
|
547
|
+
seen.add(image_id)
|
|
548
|
+
result.append(ImageStore(document, image))
|
|
549
|
+
return result
|