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/core_properties.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""python-docx-shaped core properties stored as an explicit Pages extension."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import plistlib
|
|
7
|
+
|
|
8
|
+
from .pages import PagesDocument
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
CORE_PROPERTIES_MEMBER = "Metadata/PythonPagesCoreProperties.plist"
|
|
12
|
+
LEGACY_CORE_PROPERTIES_MEMBER = "Metadata/IWALabCoreProperties.plist"
|
|
13
|
+
_STRING_PROPERTIES = (
|
|
14
|
+
"author",
|
|
15
|
+
"category",
|
|
16
|
+
"comments",
|
|
17
|
+
"content_status",
|
|
18
|
+
"identifier",
|
|
19
|
+
"keywords",
|
|
20
|
+
"language",
|
|
21
|
+
"last_modified_by",
|
|
22
|
+
"subject",
|
|
23
|
+
"title",
|
|
24
|
+
"version",
|
|
25
|
+
)
|
|
26
|
+
_DATE_PROPERTIES = ("created", "last_printed", "modified")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CoreProperties:
|
|
30
|
+
"""Dublin Core facade for concepts Pages does not natively archive.
|
|
31
|
+
|
|
32
|
+
Values live in a namespaced package member so native Pages metadata is not
|
|
33
|
+
overloaded with meanings its schema does not define.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, package: PagesDocument):
|
|
37
|
+
self._package = package
|
|
38
|
+
raw = package.package_data(CORE_PROPERTIES_MEMBER)
|
|
39
|
+
if raw is None:
|
|
40
|
+
# Read documents produced before the project was renamed. New
|
|
41
|
+
# writes always use the python-pages namespace above.
|
|
42
|
+
raw = package.package_data(LEGACY_CORE_PROPERTIES_MEMBER)
|
|
43
|
+
if raw is None:
|
|
44
|
+
self._values: dict[str, str | int] = {}
|
|
45
|
+
return
|
|
46
|
+
parsed = plistlib.loads(raw)
|
|
47
|
+
if not isinstance(parsed, dict) or parsed.get("formatVersion") != 1:
|
|
48
|
+
raise ValueError("unsupported python-pages core-properties format")
|
|
49
|
+
values = parsed.get("properties", {})
|
|
50
|
+
if not isinstance(values, dict):
|
|
51
|
+
raise ValueError("invalid python-pages core-properties payload")
|
|
52
|
+
self._values = dict(values)
|
|
53
|
+
|
|
54
|
+
def _save(self) -> None:
|
|
55
|
+
payload = {"formatVersion": 1, "properties": self._values}
|
|
56
|
+
self._package.set_package_data(
|
|
57
|
+
CORE_PROPERTIES_MEMBER,
|
|
58
|
+
plistlib.dumps(payload, fmt=plistlib.FMT_XML, sort_keys=True),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def _string(self, name: str) -> str:
|
|
62
|
+
value = self._values.get(name, "")
|
|
63
|
+
return value if isinstance(value, str) else ""
|
|
64
|
+
|
|
65
|
+
def _set_string(self, name: str, value: str) -> None:
|
|
66
|
+
if not isinstance(value, str):
|
|
67
|
+
raise TypeError(f"{name} must be a string")
|
|
68
|
+
if len(value) > 255:
|
|
69
|
+
raise ValueError(f"{name} must not exceed 255 characters")
|
|
70
|
+
self._values[name] = value
|
|
71
|
+
self._save()
|
|
72
|
+
|
|
73
|
+
def _date(self, name: str) -> dt.datetime | None:
|
|
74
|
+
value = self._values.get(name)
|
|
75
|
+
if not isinstance(value, str):
|
|
76
|
+
return None
|
|
77
|
+
try:
|
|
78
|
+
return dt.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
79
|
+
except ValueError:
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
def _set_date(self, name: str, value: dt.datetime | None) -> None:
|
|
83
|
+
if value is None:
|
|
84
|
+
self._values.pop(name, None)
|
|
85
|
+
self._save()
|
|
86
|
+
return
|
|
87
|
+
if not isinstance(value, dt.datetime):
|
|
88
|
+
raise TypeError(f"{name} must be a datetime or None")
|
|
89
|
+
if value.tzinfo is None:
|
|
90
|
+
value = value.replace(tzinfo=dt.timezone.utc)
|
|
91
|
+
else:
|
|
92
|
+
value = value.astimezone(dt.timezone.utc)
|
|
93
|
+
self._values[name] = value.replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
94
|
+
self._save()
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def revision(self) -> int:
|
|
98
|
+
value = self._values.get("revision", 0)
|
|
99
|
+
return value if isinstance(value, int) and value >= 0 else 0
|
|
100
|
+
|
|
101
|
+
@revision.setter
|
|
102
|
+
def revision(self, value: int) -> None:
|
|
103
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 1:
|
|
104
|
+
raise ValueError(f"revision property requires positive int, got {value!r}")
|
|
105
|
+
self._values["revision"] = value
|
|
106
|
+
self._save()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _string_property(name: str):
|
|
110
|
+
return property(
|
|
111
|
+
lambda self: self._string(name),
|
|
112
|
+
lambda self, value: self._set_string(name, value),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _date_property(name: str):
|
|
117
|
+
return property(
|
|
118
|
+
lambda self: self._date(name),
|
|
119
|
+
lambda self, value: self._set_date(name, value),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
for _name in _STRING_PROPERTIES:
|
|
124
|
+
setattr(CoreProperties, _name, _string_property(_name))
|
|
125
|
+
for _name in _DATE_PROPERTIES:
|
|
126
|
+
setattr(CoreProperties, _name, _date_property(_name))
|
pages/data/default.pages
ADDED
|
Binary file
|
pages/document.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Middle-layer access to TP document geometry and header/footer storage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import struct
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from .protobuf import Message
|
|
10
|
+
from .text import LocatedObject, parse_attribute_table, parse_reference, unpack_packed_varints
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .pages import PagesDocument
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
TYPE_DOCUMENT = 10000
|
|
17
|
+
TYPE_SECTION = 10011
|
|
18
|
+
TYPE_SECTION_TEMPLATE = 10143
|
|
19
|
+
|
|
20
|
+
DOCUMENT_USES_SINGLE_HEADER_FOOTER = 21
|
|
21
|
+
DOCUMENT_PAGE_WIDTH = 30
|
|
22
|
+
DOCUMENT_PAGE_HEIGHT = 31
|
|
23
|
+
DOCUMENT_LEFT_MARGIN = 32
|
|
24
|
+
DOCUMENT_RIGHT_MARGIN = 33
|
|
25
|
+
DOCUMENT_TOP_MARGIN = 34
|
|
26
|
+
DOCUMENT_BOTTOM_MARGIN = 35
|
|
27
|
+
DOCUMENT_HEADER_MARGIN = 36
|
|
28
|
+
DOCUMENT_FOOTER_MARGIN = 37
|
|
29
|
+
DOCUMENT_PAGE_SCALE = 38
|
|
30
|
+
DOCUMENT_ORIENTATION = 42
|
|
31
|
+
|
|
32
|
+
SECTION_FIRST_PAGE_DIFFERENT = 18
|
|
33
|
+
SECTION_EVEN_ODD_DIFFERENT = 19
|
|
34
|
+
SECTION_FIRST_TEMPLATE = 23
|
|
35
|
+
SECTION_EVEN_TEMPLATE = 24
|
|
36
|
+
SECTION_ODD_TEMPLATE = 25
|
|
37
|
+
|
|
38
|
+
TEMPLATE_HEADERS = 1
|
|
39
|
+
TEMPLATE_FOOTERS = 2
|
|
40
|
+
STORAGE_TEXT = 3
|
|
41
|
+
|
|
42
|
+
# These fields have ObjectAttributeTable wire shape in the supplied header and
|
|
43
|
+
# footer storage objects. Nonzero boundaries require Stage-C offset rewriting.
|
|
44
|
+
HEADER_FOOTER_ATTRIBUTE_TABLES = (5, 6, 7, 8, 9, 11, 12, 14, 24)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _float_bits(value: float) -> int:
|
|
48
|
+
return int.from_bytes(struct.pack("<f", float(value)), "little")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _bits_float(value: int) -> float:
|
|
52
|
+
return struct.unpack("<f", int(value).to_bytes(4, "little"))[0]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class PageGeometry:
|
|
57
|
+
page_width: float | None
|
|
58
|
+
page_height: float | None
|
|
59
|
+
left_margin: float | None
|
|
60
|
+
right_margin: float | None
|
|
61
|
+
top_margin: float | None
|
|
62
|
+
bottom_margin: float | None
|
|
63
|
+
header_margin: float | None
|
|
64
|
+
footer_margin: float | None
|
|
65
|
+
orientation: int
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class DocumentLayout:
|
|
69
|
+
"""Mutation facade for a package's single Pages document/section model."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, document: "PagesDocument", body_storage: LocatedObject):
|
|
72
|
+
self.document = document
|
|
73
|
+
roots = [obj for obj in document.object_messages() if obj.info.type_id == TYPE_DOCUMENT]
|
|
74
|
+
if len(roots) != 1:
|
|
75
|
+
raise ValueError(f"expected one TP.DocumentArchive, found {len(roots)}")
|
|
76
|
+
self.root = roots[0]
|
|
77
|
+
self.section = self._find_section(body_storage)
|
|
78
|
+
|
|
79
|
+
def _find_section(self, storage: LocatedObject) -> LocatedObject:
|
|
80
|
+
references = storage.info.message.one(5)
|
|
81
|
+
identifiers = (
|
|
82
|
+
unpack_packed_varints(bytes(references.value)) if references is not None else []
|
|
83
|
+
)
|
|
84
|
+
sections = [
|
|
85
|
+
self.document.objects()[identifier]
|
|
86
|
+
for identifier in identifiers
|
|
87
|
+
if identifier in self.document.objects()
|
|
88
|
+
and self.document.objects()[identifier].info.type_id == TYPE_SECTION
|
|
89
|
+
]
|
|
90
|
+
if len(sections) != 1:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
"expected body storage to reference one TP.SectionArchive, "
|
|
93
|
+
f"found {len(sections)}"
|
|
94
|
+
)
|
|
95
|
+
return sections[0]
|
|
96
|
+
|
|
97
|
+
def fixed32(self, number: int) -> float | None:
|
|
98
|
+
field = self.root.message.one(number)
|
|
99
|
+
return None if field is None else _bits_float(int(field.value))
|
|
100
|
+
|
|
101
|
+
def set_fixed32(self, number: int, value: float | None) -> None:
|
|
102
|
+
fields = self.root.message.get(number)
|
|
103
|
+
if value is None:
|
|
104
|
+
self.root.message.fields = [field for field in self.root.message.fields if field.number != number]
|
|
105
|
+
return
|
|
106
|
+
if fields:
|
|
107
|
+
fields[0].set_fixed32(_float_bits(value))
|
|
108
|
+
self.root.message.fields = [
|
|
109
|
+
field for field in self.root.message.fields if field.number != number or field is fields[0]
|
|
110
|
+
]
|
|
111
|
+
else:
|
|
112
|
+
self.root.message.add_fixed32(number, _float_bits(value))
|
|
113
|
+
|
|
114
|
+
def varint(self, number: int, default: int = 0) -> int:
|
|
115
|
+
field = self.root.message.one(number)
|
|
116
|
+
return default if field is None else int(field.value)
|
|
117
|
+
|
|
118
|
+
def set_varint(self, number: int, value: int | None) -> None:
|
|
119
|
+
fields = self.root.message.get(number)
|
|
120
|
+
if value is None:
|
|
121
|
+
self.root.message.fields = [field for field in self.root.message.fields if field.number != number]
|
|
122
|
+
return
|
|
123
|
+
if fields:
|
|
124
|
+
fields[0].set_varint(value)
|
|
125
|
+
self.root.message.fields = [
|
|
126
|
+
field for field in self.root.message.fields if field.number != number or field is fields[0]
|
|
127
|
+
]
|
|
128
|
+
else:
|
|
129
|
+
self.root.message.add_varint(number, value)
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def geometry(self) -> PageGeometry:
|
|
133
|
+
return PageGeometry(
|
|
134
|
+
self.fixed32(DOCUMENT_PAGE_WIDTH),
|
|
135
|
+
self.fixed32(DOCUMENT_PAGE_HEIGHT),
|
|
136
|
+
self.fixed32(DOCUMENT_LEFT_MARGIN),
|
|
137
|
+
self.fixed32(DOCUMENT_RIGHT_MARGIN),
|
|
138
|
+
self.fixed32(DOCUMENT_TOP_MARGIN),
|
|
139
|
+
self.fixed32(DOCUMENT_BOTTOM_MARGIN),
|
|
140
|
+
self.fixed32(DOCUMENT_HEADER_MARGIN),
|
|
141
|
+
self.fixed32(DOCUMENT_FOOTER_MARGIN),
|
|
142
|
+
self.varint(DOCUMENT_ORIENTATION),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def section_flag(self, number: int, default: bool = False) -> bool:
|
|
146
|
+
field = self.section.message.one(number)
|
|
147
|
+
return default if field is None else bool(field.value)
|
|
148
|
+
|
|
149
|
+
def set_section_flag(self, number: int, value: bool) -> None:
|
|
150
|
+
field = self.section.message.one(number)
|
|
151
|
+
if field is None:
|
|
152
|
+
self.section.message.add_varint(number, int(value))
|
|
153
|
+
else:
|
|
154
|
+
field.set_varint(int(value))
|
|
155
|
+
|
|
156
|
+
def template(self, kind: str) -> LocatedObject:
|
|
157
|
+
number = {
|
|
158
|
+
"first": SECTION_FIRST_TEMPLATE,
|
|
159
|
+
"even": SECTION_EVEN_TEMPLATE,
|
|
160
|
+
"primary": SECTION_ODD_TEMPLATE,
|
|
161
|
+
}[kind]
|
|
162
|
+
field = self.section.message.one(number)
|
|
163
|
+
identifier = parse_reference(bytes(field.value)) if field is not None else None
|
|
164
|
+
obj = self.document.objects().get(identifier) if identifier is not None else None
|
|
165
|
+
if obj is None or obj.info.type_id != TYPE_SECTION_TEMPLATE:
|
|
166
|
+
raise ValueError(f"section has no {kind} TP.SectionTemplateArchive")
|
|
167
|
+
return obj
|
|
168
|
+
|
|
169
|
+
def header_footer_storages(self, kind: str, *, footer: bool) -> list[LocatedObject]:
|
|
170
|
+
template = self.template(kind)
|
|
171
|
+
number = TEMPLATE_FOOTERS if footer else TEMPLATE_HEADERS
|
|
172
|
+
result = []
|
|
173
|
+
for field in template.message.get(number):
|
|
174
|
+
identifier = parse_reference(bytes(field.value))
|
|
175
|
+
obj = self.document.objects().get(identifier) if identifier is not None else None
|
|
176
|
+
if obj is None or obj.info.type_id != 2001:
|
|
177
|
+
raise ValueError(f"{kind} header/footer references a missing storage")
|
|
178
|
+
result.append(obj)
|
|
179
|
+
if not result:
|
|
180
|
+
raise ValueError(f"{kind} header/footer has no storage fragments")
|
|
181
|
+
return result
|
|
182
|
+
|
|
183
|
+
@staticmethod
|
|
184
|
+
def storage_text(storage: LocatedObject) -> str:
|
|
185
|
+
return "".join(
|
|
186
|
+
bytes(field.value).decode("utf-8") for field in storage.message.get(STORAGE_TEXT)
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
@staticmethod
|
|
190
|
+
def set_storage_text(storage: LocatedObject, value: str) -> None:
|
|
191
|
+
if not isinstance(value, str):
|
|
192
|
+
raise TypeError("header/footer text must be a string")
|
|
193
|
+
for number in HEADER_FOOTER_ATTRIBUTE_TABLES:
|
|
194
|
+
field = storage.message.one(number)
|
|
195
|
+
if field is None:
|
|
196
|
+
continue
|
|
197
|
+
try:
|
|
198
|
+
runs = parse_attribute_table(bytes(field.value))
|
|
199
|
+
except ValueError:
|
|
200
|
+
continue
|
|
201
|
+
if any(run.character_index != 0 for run in runs):
|
|
202
|
+
raise NotImplementedError(
|
|
203
|
+
"header/footer text with nonzero attribute boundaries requires Stage C"
|
|
204
|
+
)
|
|
205
|
+
text_fields = storage.message.get(STORAGE_TEXT)
|
|
206
|
+
encoded = value.encode("utf-8")
|
|
207
|
+
if text_fields:
|
|
208
|
+
text_fields[0].set_bytes(encoded)
|
|
209
|
+
storage.message.fields = [
|
|
210
|
+
field
|
|
211
|
+
for field in storage.message.fields
|
|
212
|
+
if field.number != STORAGE_TEXT or field is text_fields[0]
|
|
213
|
+
]
|
|
214
|
+
elif value:
|
|
215
|
+
storage.message.add_bytes(STORAGE_TEXT, encoded)
|
pages/hyperlinks.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Middle-layer access to TSWP smart-field hyperlink runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from .text import LocatedObject, parse_attribute_table
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .pages import PagesDocument
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
TYPE_HYPERLINK_FIELD = 2032
|
|
15
|
+
STORAGE_TABLE_SMART_FIELDS = 11
|
|
16
|
+
HYPERLINK_URL = 2
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class HyperlinkStore:
|
|
21
|
+
object: LocatedObject
|
|
22
|
+
start_utf16: int
|
|
23
|
+
end_utf16: int
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def url(self) -> str:
|
|
27
|
+
field = self.object.message.one(HYPERLINK_URL)
|
|
28
|
+
return "" if field is None else bytes(field.value).decode("utf-8", "replace")
|
|
29
|
+
|
|
30
|
+
@url.setter
|
|
31
|
+
def url(self, value: str) -> None:
|
|
32
|
+
if not isinstance(value, str):
|
|
33
|
+
raise TypeError("hyperlink URL must be a string")
|
|
34
|
+
field = self.object.message.one(HYPERLINK_URL)
|
|
35
|
+
if field is None:
|
|
36
|
+
self.object.message.add_bytes(HYPERLINK_URL, value.encode("utf-8"))
|
|
37
|
+
else:
|
|
38
|
+
field.set_bytes(value.encode("utf-8"))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def storage_hyperlinks(
|
|
42
|
+
document: "PagesDocument", storage: LocatedObject
|
|
43
|
+
) -> list[HyperlinkStore]:
|
|
44
|
+
field = storage.message.one(STORAGE_TABLE_SMART_FIELDS)
|
|
45
|
+
if field is None:
|
|
46
|
+
return []
|
|
47
|
+
objects = document.objects()
|
|
48
|
+
runs = parse_attribute_table(bytes(field.value))
|
|
49
|
+
result: list[HyperlinkStore] = []
|
|
50
|
+
for index, run in enumerate(runs):
|
|
51
|
+
obj = objects.get(run.object_id) if run.object_id is not None else None
|
|
52
|
+
if obj is None or obj.info.type_id != TYPE_HYPERLINK_FIELD:
|
|
53
|
+
continue
|
|
54
|
+
end = runs[index + 1].character_index if index + 1 < len(runs) else run.character_index
|
|
55
|
+
if end > run.character_index:
|
|
56
|
+
result.append(HyperlinkStore(obj, run.character_index, end))
|
|
57
|
+
return result
|
pages/iwa.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""IWA archive framing on top of raw protobuf wire messages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from .protobuf import Message, ProtobufError, encode_varint, read_varint
|
|
8
|
+
from .snappy import compress_iwork, decompress_iwork
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class MessageInfo:
|
|
13
|
+
field: object
|
|
14
|
+
message: Message
|
|
15
|
+
type_id: int
|
|
16
|
+
length: int
|
|
17
|
+
|
|
18
|
+
def update_length(self, length: int) -> None:
|
|
19
|
+
length_field = self.message.one(3)
|
|
20
|
+
if length_field is None:
|
|
21
|
+
raise ProtobufError("MessageInfo lacks required length field 3")
|
|
22
|
+
length_field.set_varint(length)
|
|
23
|
+
self.field.children = self.message
|
|
24
|
+
self.field.dirty = True
|
|
25
|
+
self.length = length
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ArchiveSegment:
|
|
30
|
+
header: Message
|
|
31
|
+
header_original: bytes
|
|
32
|
+
identifier: int
|
|
33
|
+
infos: list[MessageInfo]
|
|
34
|
+
payloads: list[Message]
|
|
35
|
+
payload_originals: list[bytes]
|
|
36
|
+
|
|
37
|
+
def encode(self) -> bytes:
|
|
38
|
+
encoded_payloads = [p.encode() for p in self.payloads]
|
|
39
|
+
for info, payload in zip(self.infos, encoded_payloads):
|
|
40
|
+
if info.length != len(payload):
|
|
41
|
+
info.update_length(len(payload))
|
|
42
|
+
header = self.header.encode()
|
|
43
|
+
return encode_varint(len(header)) + header + b"".join(encoded_payloads)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class IWAFile:
|
|
48
|
+
segments: list[ArchiveSegment]
|
|
49
|
+
decoded_sizes: list[int]
|
|
50
|
+
compressed_original: bytes = b""
|
|
51
|
+
decoded_original: bytes = b""
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def parse(cls, data: bytes) -> "IWAFile":
|
|
55
|
+
decoded, decoded_sizes = decompress_iwork(data)
|
|
56
|
+
segments: list[ArchiveSegment] = []
|
|
57
|
+
pos = 0
|
|
58
|
+
while pos < len(decoded):
|
|
59
|
+
header_length, pos = read_varint(decoded, pos)
|
|
60
|
+
if pos + header_length > len(decoded):
|
|
61
|
+
raise ProtobufError("truncated ArchiveInfo")
|
|
62
|
+
header_bytes = decoded[pos : pos + header_length]
|
|
63
|
+
pos += header_length
|
|
64
|
+
header = Message.parse(header_bytes)
|
|
65
|
+
identifier_field = header.one(1)
|
|
66
|
+
identifier = int(identifier_field.value) if identifier_field else 0
|
|
67
|
+
infos: list[MessageInfo] = []
|
|
68
|
+
for info_field in header.get(2):
|
|
69
|
+
if info_field.wire_type != 2:
|
|
70
|
+
raise ProtobufError("ArchiveInfo.message_infos is not a message")
|
|
71
|
+
info_message = Message.parse(bytes(info_field.value))
|
|
72
|
+
info_field.children = info_message
|
|
73
|
+
type_field, length_field = info_message.one(1), info_message.one(3)
|
|
74
|
+
if type_field is None or length_field is None:
|
|
75
|
+
raise ProtobufError("MessageInfo missing type or length")
|
|
76
|
+
infos.append(MessageInfo(info_field, info_message, int(type_field.value), int(length_field.value)))
|
|
77
|
+
payloads: list[Message] = []
|
|
78
|
+
originals: list[bytes] = []
|
|
79
|
+
for info in infos:
|
|
80
|
+
end = pos + info.length
|
|
81
|
+
if end > len(decoded):
|
|
82
|
+
raise ProtobufError("truncated archive payload")
|
|
83
|
+
raw = decoded[pos:end]
|
|
84
|
+
originals.append(raw)
|
|
85
|
+
payloads.append(Message.parse(raw))
|
|
86
|
+
pos = end
|
|
87
|
+
segments.append(ArchiveSegment(header, header_bytes, identifier, infos, payloads, originals))
|
|
88
|
+
return cls(segments, decoded_sizes, data, decoded)
|
|
89
|
+
|
|
90
|
+
def encode_decoded(self) -> bytes:
|
|
91
|
+
return b"".join(segment.encode() for segment in self.segments)
|
|
92
|
+
|
|
93
|
+
def encode(self) -> bytes:
|
|
94
|
+
decoded = self.encode_decoded()
|
|
95
|
+
if self.compressed_original and decoded == self.decoded_original:
|
|
96
|
+
return self.compressed_original
|
|
97
|
+
sizes = self.decoded_sizes if sum(self.decoded_sizes) == len(decoded) else None
|
|
98
|
+
return compress_iwork(decoded, sizes)
|
|
99
|
+
|
|
100
|
+
def type_ids(self) -> list[int]:
|
|
101
|
+
return [info.type_id for segment in self.segments for info in segment.infos]
|
pages/metadata.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Minimal TSP.PackageMetadata mutation for registering new component objects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
from .protobuf import Message
|
|
8
|
+
|
|
9
|
+
TYPE_PACKAGE_METADATA = 11006
|
|
10
|
+
|
|
11
|
+
PACKAGE_LAST_OBJECT_IDENTIFIER = 1
|
|
12
|
+
PACKAGE_REVISION = 2
|
|
13
|
+
PACKAGE_COMPONENTS = 3
|
|
14
|
+
|
|
15
|
+
COMPONENT_IDENTIFIER = 1
|
|
16
|
+
COMPONENT_PREFERRED_LOCATOR = 2
|
|
17
|
+
COMPONENT_LOCATOR = 3
|
|
18
|
+
COMPONENT_EXTERNAL_REFERENCES = 6
|
|
19
|
+
COMPONENT_OBJECT_UUID_MAP = 11
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def component_name(component: Message) -> str:
|
|
23
|
+
locator = component.one(COMPONENT_LOCATOR) or component.one(COMPONENT_PREFERRED_LOCATOR)
|
|
24
|
+
return bytes(locator.value).decode("utf-8") if locator is not None else ""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def object_uuid_map(component: Message) -> dict[int, tuple[int, int]]:
|
|
28
|
+
result: dict[int, tuple[int, int]] = {}
|
|
29
|
+
for raw in component.get(COMPONENT_OBJECT_UUID_MAP):
|
|
30
|
+
entry = Message.parse(bytes(raw.value))
|
|
31
|
+
identifier = entry.one(1)
|
|
32
|
+
uuid_field = entry.one(2)
|
|
33
|
+
if identifier is None or uuid_field is None:
|
|
34
|
+
continue
|
|
35
|
+
uuid_message = Message.parse(bytes(uuid_field.value))
|
|
36
|
+
first, second = uuid_message.one(1), uuid_message.one(2)
|
|
37
|
+
if first is not None and second is not None:
|
|
38
|
+
result[int(identifier.value)] = (int(first.value), int(second.value))
|
|
39
|
+
return result
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def package_revision_identifier(package: Message) -> str:
|
|
43
|
+
field = package.one(PACKAGE_REVISION)
|
|
44
|
+
if field is None:
|
|
45
|
+
return "unknown-document"
|
|
46
|
+
revision = Message.parse(bytes(field.value))
|
|
47
|
+
identifier = revision.one(2)
|
|
48
|
+
return bytes(identifier.value).decode("utf-8", "replace") if identifier else "unknown-document"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def register_component_object(
|
|
52
|
+
package: Message,
|
|
53
|
+
component_locator: str,
|
|
54
|
+
identifier: int,
|
|
55
|
+
*,
|
|
56
|
+
uuid_seed: str,
|
|
57
|
+
) -> tuple[str, int]:
|
|
58
|
+
"""Add an object UUID entry and return its RFC UUID and component ID."""
|
|
59
|
+
selected_field = None
|
|
60
|
+
selected = None
|
|
61
|
+
for field in package.get(PACKAGE_COMPONENTS):
|
|
62
|
+
component = Message.parse(bytes(field.value))
|
|
63
|
+
if component_name(component) == component_locator:
|
|
64
|
+
selected_field, selected = field, component
|
|
65
|
+
break
|
|
66
|
+
if selected_field is None or selected is None:
|
|
67
|
+
raise ValueError(f"PackageMetadata has no component {component_locator!r}")
|
|
68
|
+
if identifier in object_uuid_map(selected):
|
|
69
|
+
raise ValueError(f"object {identifier} is already registered in {component_locator}")
|
|
70
|
+
|
|
71
|
+
generated = uuid.uuid5(uuid.NAMESPACE_URL, uuid_seed)
|
|
72
|
+
uuid_message = Message()
|
|
73
|
+
uuid_message.add_varint(1, generated.int >> 64)
|
|
74
|
+
uuid_message.add_varint(2, generated.int & ((1 << 64) - 1))
|
|
75
|
+
entry = Message()
|
|
76
|
+
entry.add_varint(1, identifier)
|
|
77
|
+
entry.add_bytes(2, uuid_message.encode())
|
|
78
|
+
selected.add_bytes(COMPONENT_OBJECT_UUID_MAP, entry.encode())
|
|
79
|
+
selected_field.set_bytes(selected.encode())
|
|
80
|
+
component_id = selected.one(COMPONENT_IDENTIFIER)
|
|
81
|
+
if component_id is None:
|
|
82
|
+
raise ValueError(f"component {component_locator!r} has no identifier")
|
|
83
|
+
return str(generated), int(component_id.value)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def register_external_reference(
|
|
87
|
+
package: Message,
|
|
88
|
+
source_component_locator: str,
|
|
89
|
+
target_component_identifier: int,
|
|
90
|
+
target_object_identifier: int,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Register a strong cross-component object reference if it is absent."""
|
|
93
|
+
selected_field = None
|
|
94
|
+
selected = None
|
|
95
|
+
for field in package.get(PACKAGE_COMPONENTS):
|
|
96
|
+
component = Message.parse(bytes(field.value))
|
|
97
|
+
if component_name(component) == source_component_locator:
|
|
98
|
+
selected_field, selected = field, component
|
|
99
|
+
break
|
|
100
|
+
if selected_field is None or selected is None:
|
|
101
|
+
raise ValueError(f"PackageMetadata has no component {source_component_locator!r}")
|
|
102
|
+
for raw in selected.get(COMPONENT_EXTERNAL_REFERENCES):
|
|
103
|
+
reference = Message.parse(bytes(raw.value))
|
|
104
|
+
component = reference.one(1)
|
|
105
|
+
obj = reference.one(2)
|
|
106
|
+
if (
|
|
107
|
+
component is not None
|
|
108
|
+
and obj is not None
|
|
109
|
+
and int(component.value) == target_component_identifier
|
|
110
|
+
and int(obj.value) == target_object_identifier
|
|
111
|
+
):
|
|
112
|
+
return
|
|
113
|
+
reference = Message()
|
|
114
|
+
reference.add_varint(1, target_component_identifier)
|
|
115
|
+
reference.add_varint(2, target_object_identifier)
|
|
116
|
+
selected.add_bytes(COMPONENT_EXTERNAL_REFERENCES, reference.encode())
|
|
117
|
+
selected_field.set_bytes(selected.encode())
|