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/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