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/cli.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from zipfile import ZipFile
|
|
7
|
+
|
|
8
|
+
from .pages import PagesDocument
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main() -> None:
|
|
12
|
+
parser = argparse.ArgumentParser(prog="python-pages")
|
|
13
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
14
|
+
|
|
15
|
+
inspect = sub.add_parser("inspect", help="parse and summarize a .pages file")
|
|
16
|
+
inspect.add_argument("input", type=Path)
|
|
17
|
+
|
|
18
|
+
bold = sub.add_parser("bold", help="make a body-text substring bold")
|
|
19
|
+
bold.add_argument("input", type=Path)
|
|
20
|
+
bold.add_argument("output", type=Path)
|
|
21
|
+
bold.add_argument("substring")
|
|
22
|
+
bold.add_argument("--occurrence", type=int, default=1)
|
|
23
|
+
|
|
24
|
+
args = parser.parse_args()
|
|
25
|
+
document = PagesDocument.load(args.input)
|
|
26
|
+
if args.command == "inspect":
|
|
27
|
+
storage = document.body_storage()
|
|
28
|
+
result = document.validate() | {
|
|
29
|
+
"storage_id": storage.segment.identifier,
|
|
30
|
+
"body_codepoints": len(document.text(storage)),
|
|
31
|
+
}
|
|
32
|
+
else:
|
|
33
|
+
result = document.make_substring_bold(args.substring, occurrence=args.occurrence)
|
|
34
|
+
document.save(args.output)
|
|
35
|
+
with ZipFile(args.output) as archive:
|
|
36
|
+
bad_member = archive.testzip()
|
|
37
|
+
reparsed = PagesDocument.load(args.output)
|
|
38
|
+
result |= reparsed.validate() | {"zip_test": "pass" if bad_member is None else bad_member}
|
|
39
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
if __name__ == "__main__":
|
|
43
|
+
main()
|
pages/comments.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Middle-layer access to Pages text comments and their replies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
import struct
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from .protobuf import Message
|
|
11
|
+
from .stylesheet import RGBColor, decode_color
|
|
12
|
+
from .text import LocatedObject, parse_reference
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from .pages import PagesDocument
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
TYPE_ANNOTATION_AUTHOR = 212
|
|
19
|
+
TYPE_HIGHLIGHT = 2013
|
|
20
|
+
TYPE_COMMENT_STORAGE = 3056
|
|
21
|
+
|
|
22
|
+
STORAGE_TABLE_OVERLAPPING_HIGHLIGHT = 25
|
|
23
|
+
HIGHLIGHT_COMMENT_STORAGE = 1
|
|
24
|
+
COMMENT_TEXT = 1
|
|
25
|
+
COMMENT_CREATION_DATE = 2
|
|
26
|
+
COMMENT_AUTHOR = 3
|
|
27
|
+
COMMENT_REPLIES = 4
|
|
28
|
+
AUTHOR_NAME = 1
|
|
29
|
+
|
|
30
|
+
_APPLE_EPOCH = datetime(2001, 1, 1, tzinfo=timezone.utc)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _reference(message: Message, number: int) -> int | None:
|
|
34
|
+
field = message.one(number)
|
|
35
|
+
return None if field is None else parse_reference(bytes(field.value))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _date(message: Message) -> datetime | None:
|
|
39
|
+
field = message.one(COMMENT_CREATION_DATE)
|
|
40
|
+
if field is None:
|
|
41
|
+
return None
|
|
42
|
+
value = Message.parse(bytes(field.value)).one(1)
|
|
43
|
+
if value is None or value.wire_type != 1:
|
|
44
|
+
return None
|
|
45
|
+
seconds = struct.unpack("<d", int(value.value).to_bytes(8, "little"))[0]
|
|
46
|
+
return _APPLE_EPOCH + timedelta(seconds=seconds)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class CommentStore:
|
|
51
|
+
"""One type-3056 comment storage, optionally attached to a text range."""
|
|
52
|
+
|
|
53
|
+
document: "PagesDocument"
|
|
54
|
+
object: LocatedObject
|
|
55
|
+
highlight: LocatedObject | None = None
|
|
56
|
+
start_utf16: int | None = None
|
|
57
|
+
end_utf16: int | None = None
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def text(self) -> str:
|
|
61
|
+
field = self.object.message.one(COMMENT_TEXT)
|
|
62
|
+
return "" if field is None else bytes(field.value).decode("utf-8", "replace")
|
|
63
|
+
|
|
64
|
+
@text.setter
|
|
65
|
+
def text(self, value: str) -> None:
|
|
66
|
+
if not isinstance(value, str):
|
|
67
|
+
raise TypeError("comment text must be a string")
|
|
68
|
+
field = self.object.message.one(COMMENT_TEXT)
|
|
69
|
+
if field is None:
|
|
70
|
+
self.object.message.add_bytes(COMMENT_TEXT, value.encode("utf-8"))
|
|
71
|
+
else:
|
|
72
|
+
field.set_bytes(value.encode("utf-8"))
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def author_object(self) -> LocatedObject | None:
|
|
76
|
+
identifier = _reference(self.object.message, COMMENT_AUTHOR)
|
|
77
|
+
author = self.document.objects().get(identifier) if identifier is not None else None
|
|
78
|
+
if author is None or author.info.type_id != TYPE_ANNOTATION_AUTHOR:
|
|
79
|
+
return None
|
|
80
|
+
return author
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def author(self) -> str:
|
|
84
|
+
author = self.author_object
|
|
85
|
+
if author is None:
|
|
86
|
+
return ""
|
|
87
|
+
field = author.message.one(AUTHOR_NAME)
|
|
88
|
+
return "" if field is None else bytes(field.value).decode("utf-8", "replace")
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def annotation_color(self) -> RGBColor | None:
|
|
92
|
+
author = self.author_object
|
|
93
|
+
if author is None:
|
|
94
|
+
return None
|
|
95
|
+
field = author.message.one(2)
|
|
96
|
+
return None if field is None else decode_color(bytes(field.value))
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def timestamp(self) -> datetime | None:
|
|
100
|
+
return _date(self.object.message)
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def identifier(self) -> int:
|
|
104
|
+
return self.object.segment.identifier
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def comment_id(self) -> int:
|
|
108
|
+
return (
|
|
109
|
+
self.highlight.segment.identifier
|
|
110
|
+
if self.highlight is not None
|
|
111
|
+
else self.object.segment.identifier
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def replies(self) -> list["CommentStore"]:
|
|
116
|
+
objects = self.document.objects()
|
|
117
|
+
result: list[CommentStore] = []
|
|
118
|
+
for field in self.object.message.get(COMMENT_REPLIES):
|
|
119
|
+
identifier = parse_reference(bytes(field.value))
|
|
120
|
+
reply = objects.get(identifier) if identifier is not None else None
|
|
121
|
+
if reply is not None and reply.info.type_id == TYPE_COMMENT_STORAGE:
|
|
122
|
+
result.append(CommentStore(self.document, reply))
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def storage_comments(
|
|
127
|
+
document: "PagesDocument", storage: LocatedObject
|
|
128
|
+
) -> list[CommentStore]:
|
|
129
|
+
"""Return comments anchored by storage field 25 in document order."""
|
|
130
|
+
table_field = storage.message.one(STORAGE_TABLE_OVERLAPPING_HIGHLIGHT)
|
|
131
|
+
if table_field is None:
|
|
132
|
+
return []
|
|
133
|
+
objects = document.objects()
|
|
134
|
+
result: list[CommentStore] = []
|
|
135
|
+
table = Message.parse(bytes(table_field.value))
|
|
136
|
+
for raw in table.get(1):
|
|
137
|
+
entry = Message.parse(bytes(raw.value))
|
|
138
|
+
range_field = entry.one(1)
|
|
139
|
+
highlight_ref = entry.one(2)
|
|
140
|
+
if range_field is None or highlight_ref is None:
|
|
141
|
+
continue
|
|
142
|
+
range_message = Message.parse(bytes(range_field.value))
|
|
143
|
+
location = range_message.one(1)
|
|
144
|
+
length = range_message.one(2)
|
|
145
|
+
highlight_id = parse_reference(bytes(highlight_ref.value))
|
|
146
|
+
highlight = objects.get(highlight_id) if highlight_id is not None else None
|
|
147
|
+
if (
|
|
148
|
+
location is None
|
|
149
|
+
or length is None
|
|
150
|
+
or highlight is None
|
|
151
|
+
or highlight.info.type_id != TYPE_HIGHLIGHT
|
|
152
|
+
):
|
|
153
|
+
continue
|
|
154
|
+
comment_id = _reference(highlight.message, HIGHLIGHT_COMMENT_STORAGE)
|
|
155
|
+
comment = objects.get(comment_id) if comment_id is not None else None
|
|
156
|
+
if comment is None or comment.info.type_id != TYPE_COMMENT_STORAGE:
|
|
157
|
+
continue
|
|
158
|
+
start = int(location.value)
|
|
159
|
+
result.append(
|
|
160
|
+
CommentStore(
|
|
161
|
+
document,
|
|
162
|
+
comment,
|
|
163
|
+
highlight=highlight,
|
|
164
|
+
start_utf16=start,
|
|
165
|
+
end_utf16=start + int(length.value),
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
return sorted(result, key=lambda item: (item.start_utf16 or 0, item.comment_id))
|
pages/components.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""Schema-light cloning for complete iWork component subgraphs.
|
|
2
|
+
|
|
3
|
+
The high-level table and image insertion APIs use this module to duplicate a
|
|
4
|
+
Pages-authored object graph. Shared stylesheet objects remain shared while
|
|
5
|
+
owned archive objects, standalone component locators, UUID registrations, and
|
|
6
|
+
cross-component references receive fresh identities.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
import uuid
|
|
13
|
+
from zipfile import ZipInfo
|
|
14
|
+
|
|
15
|
+
from .iwa import ArchiveSegment, IWAFile, MessageInfo
|
|
16
|
+
from .metadata import (
|
|
17
|
+
COMPONENT_EXTERNAL_REFERENCES,
|
|
18
|
+
COMPONENT_IDENTIFIER,
|
|
19
|
+
COMPONENT_LOCATOR,
|
|
20
|
+
COMPONENT_OBJECT_UUID_MAP,
|
|
21
|
+
COMPONENT_PREFERRED_LOCATOR,
|
|
22
|
+
PACKAGE_COMPONENTS,
|
|
23
|
+
component_name,
|
|
24
|
+
object_uuid_map,
|
|
25
|
+
package_revision_identifier,
|
|
26
|
+
register_component_object,
|
|
27
|
+
)
|
|
28
|
+
from .pages import PagesDocument, StoredEntry
|
|
29
|
+
from .protobuf import Message, ProtobufError, encode_varint, read_varint
|
|
30
|
+
from .text import (
|
|
31
|
+
AttributeRun,
|
|
32
|
+
LocatedObject,
|
|
33
|
+
add_object_reference,
|
|
34
|
+
encode_attribute_table,
|
|
35
|
+
parse_attribute_table,
|
|
36
|
+
unpack_packed_varints,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class GraphClone:
|
|
42
|
+
"""The old-to-new identity map and cloned root object."""
|
|
43
|
+
|
|
44
|
+
root: LocatedObject
|
|
45
|
+
identifiers: dict[int, int]
|
|
46
|
+
component_identifiers: dict[int, int]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _set_varint(message: Message, number: int, value: int) -> None:
|
|
50
|
+
field = message.one(number)
|
|
51
|
+
if field is None:
|
|
52
|
+
message.add_varint(number, value)
|
|
53
|
+
else:
|
|
54
|
+
field.set_varint(value)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _set_bytes(message: Message, number: int, value: bytes) -> None:
|
|
58
|
+
field = message.one(number)
|
|
59
|
+
if field is None:
|
|
60
|
+
message.add_bytes(number, value)
|
|
61
|
+
else:
|
|
62
|
+
field.set_bytes(value)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _pack_varints(values: list[int]) -> bytes:
|
|
66
|
+
return b"".join(encode_varint(value) for value in values)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _info_references(obj: LocatedObject) -> list[int]:
|
|
70
|
+
result: list[int] = []
|
|
71
|
+
for field in obj.info.message.get(5):
|
|
72
|
+
result.extend(unpack_packed_varints(bytes(field.value)))
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _remap_nested_references(
|
|
77
|
+
message: Message,
|
|
78
|
+
object_identifiers: dict[int, int],
|
|
79
|
+
data_identifiers: dict[int, int] | None = None,
|
|
80
|
+
) -> bool:
|
|
81
|
+
"""Remap embedded TSP.Reference/DataReference messages recursively."""
|
|
82
|
+
changed = False
|
|
83
|
+
data_identifiers = data_identifiers or {}
|
|
84
|
+
for field in message.fields:
|
|
85
|
+
if field.wire_type != 2:
|
|
86
|
+
continue
|
|
87
|
+
try:
|
|
88
|
+
child = Message.parse(bytes(field.value))
|
|
89
|
+
except ProtobufError:
|
|
90
|
+
continue
|
|
91
|
+
first = child.one(1)
|
|
92
|
+
reference_shape = (
|
|
93
|
+
first is not None
|
|
94
|
+
and first.wire_type == 0
|
|
95
|
+
and all(item.wire_type == 0 and item.number in (1, 2, 3) for item in child.fields)
|
|
96
|
+
)
|
|
97
|
+
if reference_shape and int(first.value) in object_identifiers:
|
|
98
|
+
first.set_varint(object_identifiers[int(first.value)])
|
|
99
|
+
field.set_bytes(child.encode())
|
|
100
|
+
changed = True
|
|
101
|
+
continue
|
|
102
|
+
if reference_shape and int(first.value) in data_identifiers:
|
|
103
|
+
first.set_varint(data_identifiers[int(first.value)])
|
|
104
|
+
field.set_bytes(child.encode())
|
|
105
|
+
changed = True
|
|
106
|
+
continue
|
|
107
|
+
if _remap_nested_references(child, object_identifiers, data_identifiers):
|
|
108
|
+
field.set_bytes(child.encode())
|
|
109
|
+
changed = True
|
|
110
|
+
return changed
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _remap_message_info(
|
|
114
|
+
info: Message,
|
|
115
|
+
object_identifiers: dict[int, int],
|
|
116
|
+
data_identifiers: dict[int, int] | None = None,
|
|
117
|
+
) -> None:
|
|
118
|
+
data_identifiers = data_identifiers or {}
|
|
119
|
+
for field in info.get(5):
|
|
120
|
+
values = unpack_packed_varints(bytes(field.value))
|
|
121
|
+
field.set_bytes(_pack_varints([object_identifiers.get(value, value) for value in values]))
|
|
122
|
+
for field in info.get(6):
|
|
123
|
+
values = unpack_packed_varints(bytes(field.value))
|
|
124
|
+
field.set_bytes(_pack_varints([data_identifiers.get(value, value) for value in values]))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def remap_payload_data_references(
|
|
128
|
+
obj: LocatedObject, data_identifiers: dict[int, int]
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Update data references in an already-cloned payload and MessageInfo."""
|
|
131
|
+
_remap_nested_references(obj.message, {}, data_identifiers)
|
|
132
|
+
_remap_message_info(obj.info.message, {}, data_identifiers)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _clone_segment(
|
|
136
|
+
source: LocatedObject,
|
|
137
|
+
identifier: int,
|
|
138
|
+
object_identifiers: dict[int, int],
|
|
139
|
+
) -> ArchiveSegment:
|
|
140
|
+
if len(source.segment.infos) != 1:
|
|
141
|
+
raise ValueError("component graph templates must use one-message archive segments")
|
|
142
|
+
payload = Message.parse(source.message.encode())
|
|
143
|
+
_remap_nested_references(payload, object_identifiers)
|
|
144
|
+
|
|
145
|
+
header = Message.parse(source.segment.header.encode())
|
|
146
|
+
header_identifier = header.one(1)
|
|
147
|
+
if header_identifier is None:
|
|
148
|
+
raise ValueError("component graph ArchiveInfo lacks an identifier")
|
|
149
|
+
header_identifier.set_varint(identifier)
|
|
150
|
+
info_fields = header.get(2)
|
|
151
|
+
if len(info_fields) != 1:
|
|
152
|
+
raise ValueError("component graph ArchiveInfo must contain one MessageInfo")
|
|
153
|
+
info_field = info_fields[0]
|
|
154
|
+
info_message = Message.parse(bytes(info_field.value))
|
|
155
|
+
_remap_message_info(info_message, object_identifiers)
|
|
156
|
+
length = info_message.one(3)
|
|
157
|
+
type_field = info_message.one(1)
|
|
158
|
+
if length is None or type_field is None:
|
|
159
|
+
raise ValueError("component graph MessageInfo is incomplete")
|
|
160
|
+
info = MessageInfo(info_field, info_message, int(type_field.value), int(length.value))
|
|
161
|
+
info.update_length(len(payload.encode()))
|
|
162
|
+
return ArchiveSegment(header, b"", identifier, [info], [payload], [b""])
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _component_records(package: Message) -> dict[int, tuple[object, Message]]:
|
|
166
|
+
result: dict[int, tuple[object, Message]] = {}
|
|
167
|
+
for field in package.get(PACKAGE_COMPONENTS):
|
|
168
|
+
component = Message.parse(bytes(field.value))
|
|
169
|
+
identifier = component.one(COMPONENT_IDENTIFIER)
|
|
170
|
+
if identifier is not None:
|
|
171
|
+
result[int(identifier.value)] = (field, component)
|
|
172
|
+
return result
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _component_for_filename(
|
|
176
|
+
filename: str, components: dict[int, tuple[object, Message]]
|
|
177
|
+
) -> int | None:
|
|
178
|
+
locator = filename.removeprefix("Index/").removesuffix(".iwa")
|
|
179
|
+
for identifier, (_, component) in components.items():
|
|
180
|
+
if component_name(component) == locator:
|
|
181
|
+
return identifier
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _remap_external_reference(
|
|
186
|
+
reference: Message,
|
|
187
|
+
object_identifiers: dict[int, int],
|
|
188
|
+
component_identifiers: dict[int, int],
|
|
189
|
+
) -> bool:
|
|
190
|
+
changed = False
|
|
191
|
+
component = reference.one(1)
|
|
192
|
+
if component is not None and int(component.value) in component_identifiers:
|
|
193
|
+
component.set_varint(component_identifiers[int(component.value)])
|
|
194
|
+
changed = True
|
|
195
|
+
obj = reference.one(2)
|
|
196
|
+
if obj is not None and int(obj.value) in object_identifiers:
|
|
197
|
+
obj.set_varint(object_identifiers[int(obj.value)])
|
|
198
|
+
changed = True
|
|
199
|
+
return changed
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _append_external_reference(component: Message, reference: Message) -> None:
|
|
203
|
+
encoded = reference.encode()
|
|
204
|
+
if all(bytes(field.value) != encoded for field in component.get(COMPONENT_EXTERNAL_REFERENCES)):
|
|
205
|
+
component.add_bytes(COMPONENT_EXTERNAL_REFERENCES, encoded)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def clone_object_graph(
|
|
209
|
+
document: PagesDocument,
|
|
210
|
+
root_identifier: int,
|
|
211
|
+
*,
|
|
212
|
+
include,
|
|
213
|
+
) -> GraphClone:
|
|
214
|
+
"""Clone the reference closure selected by ``include``.
|
|
215
|
+
|
|
216
|
+
``include`` receives each referenced :class:`LocatedObject`. It is used
|
|
217
|
+
to retain shared styles while selecting all objects owned by the template
|
|
218
|
+
table or drawable.
|
|
219
|
+
"""
|
|
220
|
+
source_objects = document.objects()
|
|
221
|
+
root = source_objects.get(root_identifier)
|
|
222
|
+
if root is None:
|
|
223
|
+
raise KeyError(f"component graph root {root_identifier} was not found")
|
|
224
|
+
|
|
225
|
+
selected: dict[int, LocatedObject] = {}
|
|
226
|
+
queue = [root]
|
|
227
|
+
while queue:
|
|
228
|
+
obj = queue.pop(0)
|
|
229
|
+
identifier = obj.segment.identifier
|
|
230
|
+
if identifier in selected:
|
|
231
|
+
continue
|
|
232
|
+
selected[identifier] = obj
|
|
233
|
+
for reference in _info_references(obj):
|
|
234
|
+
candidate = source_objects.get(reference)
|
|
235
|
+
if candidate is not None and include(candidate):
|
|
236
|
+
queue.append(candidate)
|
|
237
|
+
|
|
238
|
+
object_identifiers = {
|
|
239
|
+
old: document.allocate_object_identifier() for old in sorted(selected)
|
|
240
|
+
}
|
|
241
|
+
package = document.package_metadata()
|
|
242
|
+
components = _component_records(package.message)
|
|
243
|
+
standalone_components: dict[int, int] = {}
|
|
244
|
+
for old, obj in selected.items():
|
|
245
|
+
component_id = _component_for_filename(obj.filename, components)
|
|
246
|
+
if component_id == old:
|
|
247
|
+
standalone_components[old] = object_identifiers[old]
|
|
248
|
+
|
|
249
|
+
# Clone standalone component records first so their new IWA locators are
|
|
250
|
+
# available when archive segments are placed.
|
|
251
|
+
new_locators: dict[int, str] = {}
|
|
252
|
+
for old_component, new_component in standalone_components.items():
|
|
253
|
+
source_component = components[old_component][1]
|
|
254
|
+
component = Message.parse(source_component.encode())
|
|
255
|
+
_set_varint(component, COMPONENT_IDENTIFIER, new_component)
|
|
256
|
+
preferred = component.one(COMPONENT_PREFERRED_LOCATOR)
|
|
257
|
+
if preferred is None:
|
|
258
|
+
raise ValueError(f"component {old_component} has no preferred locator")
|
|
259
|
+
base = bytes(preferred.value).decode("utf-8")
|
|
260
|
+
locator = f"{base}-{new_component}-2"
|
|
261
|
+
_set_bytes(component, COMPONENT_LOCATOR, locator.encode("utf-8"))
|
|
262
|
+
for field in component.get(COMPONENT_EXTERNAL_REFERENCES):
|
|
263
|
+
reference = Message.parse(bytes(field.value))
|
|
264
|
+
_remap_external_reference(reference, object_identifiers, standalone_components)
|
|
265
|
+
field.set_bytes(reference.encode())
|
|
266
|
+
for field in component.get(COMPONENT_OBJECT_UUID_MAP):
|
|
267
|
+
entry = Message.parse(bytes(field.value))
|
|
268
|
+
identifier = entry.one(1)
|
|
269
|
+
if identifier is not None and int(identifier.value) in object_identifiers:
|
|
270
|
+
identifier.set_varint(object_identifiers[int(identifier.value)])
|
|
271
|
+
field.set_bytes(entry.encode())
|
|
272
|
+
package.message.add_bytes(PACKAGE_COMPONENTS, component.encode())
|
|
273
|
+
new_locators[old_component] = locator
|
|
274
|
+
|
|
275
|
+
cloned: dict[int, LocatedObject] = {}
|
|
276
|
+
for old, source in selected.items():
|
|
277
|
+
new = object_identifiers[old]
|
|
278
|
+
segment = _clone_segment(source, new, object_identifiers)
|
|
279
|
+
if old in standalone_components:
|
|
280
|
+
filename = "Index/" + new_locators[old] + ".iwa"
|
|
281
|
+
document.entries.append(
|
|
282
|
+
StoredEntry(ZipInfo(filename), IWAFile([segment], []))
|
|
283
|
+
)
|
|
284
|
+
else:
|
|
285
|
+
filename = source.filename
|
|
286
|
+
document.iwa_files[filename].segments.append(segment)
|
|
287
|
+
cloned[new] = LocatedObject(
|
|
288
|
+
filename, segment, segment.infos[0], segment.payloads[0]
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# Mirror cross-component links from existing owning components. Links in
|
|
292
|
+
# newly cloned standalone records were handled above.
|
|
293
|
+
for component_id, (raw_component, source_component) in components.items():
|
|
294
|
+
if component_id in standalone_components:
|
|
295
|
+
continue
|
|
296
|
+
component = Message.parse(bytes(raw_component.value))
|
|
297
|
+
for raw_reference in source_component.get(COMPONENT_EXTERNAL_REFERENCES):
|
|
298
|
+
reference = Message.parse(bytes(raw_reference.value))
|
|
299
|
+
if not _remap_external_reference(
|
|
300
|
+
reference, object_identifiers, standalone_components
|
|
301
|
+
):
|
|
302
|
+
continue
|
|
303
|
+
_append_external_reference(component, reference)
|
|
304
|
+
raw_component.set_bytes(component.encode())
|
|
305
|
+
|
|
306
|
+
# Existing components use an explicit UUID map only for addressable top
|
|
307
|
+
# objects. Mirror precisely the source map's membership.
|
|
308
|
+
revision = package_revision_identifier(package.message)
|
|
309
|
+
for component_id, (raw_component, source_component) in components.items():
|
|
310
|
+
if component_id in standalone_components:
|
|
311
|
+
continue
|
|
312
|
+
registered = object_uuid_map(source_component)
|
|
313
|
+
locator = component_name(source_component)
|
|
314
|
+
for old in selected:
|
|
315
|
+
if old not in registered:
|
|
316
|
+
continue
|
|
317
|
+
register_component_object(
|
|
318
|
+
package.message,
|
|
319
|
+
locator,
|
|
320
|
+
object_identifiers[old],
|
|
321
|
+
uuid_seed=f"python-pages:{revision}:clone:{old}:{object_identifiers[old]}",
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
return GraphClone(
|
|
325
|
+
cloned[object_identifiers[root_identifier]],
|
|
326
|
+
object_identifiers,
|
|
327
|
+
standalone_components,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def replace_uuid_identities(message: Message, *, seed: str) -> None:
|
|
332
|
+
"""Give cloned table UUID/CFUUID identities deterministic fresh values."""
|
|
333
|
+
uuid_pairs: dict[tuple[int, int], tuple[int, int]] = {}
|
|
334
|
+
cfuuid_parts: dict[tuple[int, int, int, int], tuple[int, int, int, int]] = {}
|
|
335
|
+
|
|
336
|
+
def visit(current: Message, path: str) -> None:
|
|
337
|
+
fields = current.fields
|
|
338
|
+
if (
|
|
339
|
+
len(fields) == 2
|
|
340
|
+
and {field.number for field in fields} == {1, 2}
|
|
341
|
+
and all(field.wire_type == 0 for field in fields)
|
|
342
|
+
):
|
|
343
|
+
first, second = current.one(1), current.one(2)
|
|
344
|
+
key = (int(first.value), int(second.value))
|
|
345
|
+
if min(key) > 0xFFFFFFFF:
|
|
346
|
+
replacement = uuid_pairs.get(key)
|
|
347
|
+
if replacement is None:
|
|
348
|
+
generated = uuid.uuid5(uuid.NAMESPACE_URL, f"{seed}:uuid:{key}")
|
|
349
|
+
replacement = (
|
|
350
|
+
generated.int >> 64,
|
|
351
|
+
generated.int & ((1 << 64) - 1),
|
|
352
|
+
)
|
|
353
|
+
uuid_pairs[key] = replacement
|
|
354
|
+
first.set_varint(replacement[0])
|
|
355
|
+
second.set_varint(replacement[1])
|
|
356
|
+
return
|
|
357
|
+
if (
|
|
358
|
+
len(fields) == 4
|
|
359
|
+
and {field.number for field in fields} == {2, 3, 4, 5}
|
|
360
|
+
and all(field.wire_type == 0 for field in fields)
|
|
361
|
+
):
|
|
362
|
+
key = tuple(int(current.one(number).value) for number in (2, 3, 4, 5))
|
|
363
|
+
replacement = cfuuid_parts.get(key)
|
|
364
|
+
if replacement is None:
|
|
365
|
+
raw = uuid.uuid5(uuid.NAMESPACE_URL, f"{seed}:cfuuid:{key}").bytes
|
|
366
|
+
replacement = tuple(
|
|
367
|
+
int.from_bytes(raw[offset : offset + 4], "big")
|
|
368
|
+
for offset in range(0, 16, 4)
|
|
369
|
+
)
|
|
370
|
+
cfuuid_parts[key] = replacement
|
|
371
|
+
for number, value in zip((2, 3, 4, 5), replacement):
|
|
372
|
+
current.one(number).set_varint(value)
|
|
373
|
+
return
|
|
374
|
+
for index, field in enumerate(fields):
|
|
375
|
+
if field.wire_type != 2:
|
|
376
|
+
continue
|
|
377
|
+
try:
|
|
378
|
+
child = Message.parse(bytes(field.value))
|
|
379
|
+
except ProtobufError:
|
|
380
|
+
continue
|
|
381
|
+
before = child.encode()
|
|
382
|
+
visit(child, f"{path}.{field.number}[{index}]")
|
|
383
|
+
after = child.encode()
|
|
384
|
+
if after != before:
|
|
385
|
+
field.set_bytes(after)
|
|
386
|
+
|
|
387
|
+
visit(message, "root")
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def append_attachment_run(
|
|
391
|
+
storage: LocatedObject, position_utf16: int, attachment_identifier: int
|
|
392
|
+
) -> None:
|
|
393
|
+
"""Add an attachment boundary to StorageArchive field 9."""
|
|
394
|
+
field = storage.message.one(9)
|
|
395
|
+
if field is None:
|
|
396
|
+
field = storage.message.add_bytes(9, b"")
|
|
397
|
+
runs = parse_attribute_table(bytes(field.value))
|
|
398
|
+
if any(run.character_index == position_utf16 for run in runs):
|
|
399
|
+
raise ValueError(f"attachment boundary already exists at UTF-16 {position_utf16}")
|
|
400
|
+
runs.append(AttributeRun(position_utf16, attachment_identifier))
|
|
401
|
+
runs.sort(key=lambda run: run.character_index)
|
|
402
|
+
field.set_bytes(encode_attribute_table(runs))
|
|
403
|
+
add_object_reference(storage.info, attachment_identifier)
|