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/pages.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Container-level Pages document loader/saver."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from io import BytesIO
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
|
|
9
|
+
|
|
10
|
+
from .iwa import IWAFile
|
|
11
|
+
from .metadata import (
|
|
12
|
+
PACKAGE_LAST_OBJECT_IDENTIFIER,
|
|
13
|
+
TYPE_PACKAGE_METADATA,
|
|
14
|
+
)
|
|
15
|
+
from .protobuf import Message
|
|
16
|
+
from .stylesheet import CharacterProperties, ParagraphProperties, Stylesheet
|
|
17
|
+
from .document import DocumentLayout
|
|
18
|
+
from .text import (
|
|
19
|
+
CHAR_BOLD,
|
|
20
|
+
CHAR_FONT_NAME,
|
|
21
|
+
DEFAULT_BOLD_FONT_NAME,
|
|
22
|
+
STYLE_CHAR_PROPERTIES,
|
|
23
|
+
STYLE_SUPER,
|
|
24
|
+
STORAGE_TABLE_CHAR_STYLE,
|
|
25
|
+
STORAGE_TEXT,
|
|
26
|
+
TYPE_CHARACTER_STYLE,
|
|
27
|
+
TYPE_STORAGE,
|
|
28
|
+
LocatedObject,
|
|
29
|
+
add_object_reference,
|
|
30
|
+
codepoint_to_utf16,
|
|
31
|
+
encode_attribute_table,
|
|
32
|
+
parse_attribute_table,
|
|
33
|
+
replace_style_range,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class StoredEntry:
|
|
39
|
+
info: ZipInfo
|
|
40
|
+
data: bytes | IWAFile
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ObjectIndex(dict[int, LocatedObject]):
|
|
44
|
+
"""Object-ID index retaining every payload in multi-message segments."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, objects: list[LocatedObject]):
|
|
47
|
+
grouped: dict[int, list[LocatedObject]] = {}
|
|
48
|
+
for obj in objects:
|
|
49
|
+
grouped.setdefault(obj.segment.identifier, []).append(obj)
|
|
50
|
+
super().__init__((identifier, messages[0]) for identifier, messages in grouped.items())
|
|
51
|
+
self._grouped = grouped
|
|
52
|
+
|
|
53
|
+
def all(self, identifier: int) -> tuple[LocatedObject, ...]:
|
|
54
|
+
return tuple(self._grouped.get(identifier, ()))
|
|
55
|
+
|
|
56
|
+
def messages(self) -> tuple[LocatedObject, ...]:
|
|
57
|
+
return tuple(obj for messages in self._grouped.values() for obj in messages)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class PagesDocument:
|
|
61
|
+
def __init__(self, entries: list[StoredEntry], source: Path):
|
|
62
|
+
self.entries = entries
|
|
63
|
+
self.source = source
|
|
64
|
+
self._stylesheet: Stylesheet | None = None
|
|
65
|
+
self._document_layout: DocumentLayout | None = None
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def load(cls, path: str | Path) -> "PagesDocument":
|
|
69
|
+
source = Path(path)
|
|
70
|
+
entries: list[StoredEntry] = []
|
|
71
|
+
with ZipFile(source, "r", metadata_encoding="utf-8") as outer:
|
|
72
|
+
bad = outer.testzip()
|
|
73
|
+
if bad:
|
|
74
|
+
raise ValueError(f"bad outer ZIP member: {bad}")
|
|
75
|
+
for info in outer.infolist():
|
|
76
|
+
blob = outer.read(info)
|
|
77
|
+
if info.filename.lower().endswith("index.zip"):
|
|
78
|
+
nested = BytesIO(blob)
|
|
79
|
+
with ZipFile(nested, "r", metadata_encoding="utf-8") as index:
|
|
80
|
+
for nested_info in index.infolist():
|
|
81
|
+
nested_blob = index.read(nested_info)
|
|
82
|
+
item = IWAFile.parse(nested_blob) if nested_info.filename.endswith(".iwa") else nested_blob
|
|
83
|
+
synthetic = ZipInfo("@INDEXZIP@/" + nested_info.filename, nested_info.date_time)
|
|
84
|
+
synthetic.compress_type = nested_info.compress_type
|
|
85
|
+
entries.append(StoredEntry(synthetic, item))
|
|
86
|
+
else:
|
|
87
|
+
item = IWAFile.parse(blob) if info.filename.endswith(".iwa") else blob
|
|
88
|
+
entries.append(StoredEntry(info, item))
|
|
89
|
+
return cls(entries, source)
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def iwa_files(self) -> dict[str, IWAFile]:
|
|
93
|
+
return {entry.info.filename: entry.data for entry in self.entries if isinstance(entry.data, IWAFile)}
|
|
94
|
+
|
|
95
|
+
def save(self, path: str | Path) -> None:
|
|
96
|
+
path = Path(path)
|
|
97
|
+
nested_entries = [e for e in self.entries if e.info.filename.startswith("@INDEXZIP@/")]
|
|
98
|
+
direct_entries = [e for e in self.entries if not e.info.filename.startswith("@INDEXZIP@/")]
|
|
99
|
+
if nested_entries:
|
|
100
|
+
nested_buf = BytesIO()
|
|
101
|
+
with ZipFile(nested_buf, "w", ZIP_DEFLATED) as nested:
|
|
102
|
+
for entry in nested_entries:
|
|
103
|
+
name = entry.info.filename.removeprefix("@INDEXZIP@/")
|
|
104
|
+
blob = entry.data.encode() if isinstance(entry.data, IWAFile) else entry.data
|
|
105
|
+
nested.writestr(name, blob)
|
|
106
|
+
direct_entries.append(StoredEntry(ZipInfo("Index.zip"), nested_buf.getvalue()))
|
|
107
|
+
with ZipFile(path, "w", ZIP_DEFLATED) as outer:
|
|
108
|
+
for entry in direct_entries:
|
|
109
|
+
blob = entry.data.encode() if isinstance(entry.data, IWAFile) else entry.data
|
|
110
|
+
info = ZipInfo(entry.info.filename, entry.info.date_time)
|
|
111
|
+
info.external_attr = entry.info.external_attr
|
|
112
|
+
info.comment = entry.info.comment
|
|
113
|
+
info.extra = entry.info.extra
|
|
114
|
+
info.flag_bits = entry.info.flag_bits
|
|
115
|
+
info.compress_type = ZIP_DEFLATED
|
|
116
|
+
outer.writestr(info, blob)
|
|
117
|
+
|
|
118
|
+
def validate(self) -> dict[str, int]:
|
|
119
|
+
reparsed = 0
|
|
120
|
+
segments = 0
|
|
121
|
+
for iwa in self.iwa_files.values():
|
|
122
|
+
fresh = IWAFile.parse(iwa.encode())
|
|
123
|
+
reparsed += 1
|
|
124
|
+
segments += len(fresh.segments)
|
|
125
|
+
return {"iwa_files": reparsed, "segments": segments}
|
|
126
|
+
|
|
127
|
+
def package_data(self, name: str) -> bytes | None:
|
|
128
|
+
"""Return a non-IWA package member, or ``None`` when it is absent."""
|
|
129
|
+
for entry in self.entries:
|
|
130
|
+
if entry.info.filename == name:
|
|
131
|
+
if isinstance(entry.data, IWAFile):
|
|
132
|
+
raise TypeError(f"{name} is an IWA member")
|
|
133
|
+
return entry.data
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
def set_package_data(self, name: str, data: bytes) -> None:
|
|
137
|
+
"""Add or replace a non-IWA member without disturbing package peers."""
|
|
138
|
+
for entry in self.entries:
|
|
139
|
+
if entry.info.filename != name:
|
|
140
|
+
continue
|
|
141
|
+
if isinstance(entry.data, IWAFile):
|
|
142
|
+
raise TypeError(f"{name} is an IWA member")
|
|
143
|
+
entry.data = data
|
|
144
|
+
return
|
|
145
|
+
self.entries.append(StoredEntry(ZipInfo(name), data))
|
|
146
|
+
|
|
147
|
+
def object_messages(self) -> list[LocatedObject]:
|
|
148
|
+
"""Return every MessageInfo/payload, including multi-message segments."""
|
|
149
|
+
result: list[LocatedObject] = []
|
|
150
|
+
for filename, iwa in self.iwa_files.items():
|
|
151
|
+
for segment in iwa.segments:
|
|
152
|
+
for info, message in zip(segment.infos, segment.payloads):
|
|
153
|
+
result.append(LocatedObject(filename, segment, info, message))
|
|
154
|
+
return result
|
|
155
|
+
|
|
156
|
+
def objects(self) -> ObjectIndex:
|
|
157
|
+
return ObjectIndex(self.object_messages())
|
|
158
|
+
|
|
159
|
+
def body_storage(self, storage_id: int | None = None) -> LocatedObject:
|
|
160
|
+
candidates: list[LocatedObject] = []
|
|
161
|
+
for obj in self.objects().values():
|
|
162
|
+
if obj.info.type_id != TYPE_STORAGE or not obj.message.get(STORAGE_TEXT):
|
|
163
|
+
continue
|
|
164
|
+
if storage_id is not None and obj.segment.identifier == storage_id:
|
|
165
|
+
return obj
|
|
166
|
+
kind = obj.message.one(1)
|
|
167
|
+
if kind is not None and int(kind.value) == 0:
|
|
168
|
+
candidates.append(obj)
|
|
169
|
+
if storage_id is not None:
|
|
170
|
+
raise KeyError(
|
|
171
|
+
f"TSWP.StorageArchive {storage_id} was not found or has no text payload"
|
|
172
|
+
)
|
|
173
|
+
if not candidates:
|
|
174
|
+
storage_count = sum(
|
|
175
|
+
obj.info.type_id == TYPE_STORAGE for obj in self.object_messages()
|
|
176
|
+
)
|
|
177
|
+
raise ValueError(
|
|
178
|
+
"no BODY TSWP.StorageArchive with text was found "
|
|
179
|
+
f"({storage_count} storage object(s) parsed)"
|
|
180
|
+
)
|
|
181
|
+
return max(candidates, key=lambda obj: len(self.text(obj)))
|
|
182
|
+
|
|
183
|
+
@staticmethod
|
|
184
|
+
def text(storage: LocatedObject) -> str:
|
|
185
|
+
return "".join(bytes(field.value).decode("utf-8") for field in storage.message.get(STORAGE_TEXT))
|
|
186
|
+
|
|
187
|
+
def find_bold_character_style(self) -> LocatedObject:
|
|
188
|
+
named: list[LocatedObject] = []
|
|
189
|
+
anonymous: list[LocatedObject] = []
|
|
190
|
+
for obj in self.objects().values():
|
|
191
|
+
if obj.info.type_id != TYPE_CHARACTER_STYLE:
|
|
192
|
+
continue
|
|
193
|
+
props_field = obj.message.one(STYLE_CHAR_PROPERTIES)
|
|
194
|
+
if props_field is None:
|
|
195
|
+
continue
|
|
196
|
+
props = Message.parse(bytes(props_field.value))
|
|
197
|
+
bold = props.one(CHAR_BOLD)
|
|
198
|
+
if bold is None or int(bold.value) != 1:
|
|
199
|
+
continue
|
|
200
|
+
# A bold-only style is safest: it leaves font, color and size inherited.
|
|
201
|
+
if len(props.fields) != 1:
|
|
202
|
+
continue
|
|
203
|
+
super_field = obj.message.one(STYLE_SUPER)
|
|
204
|
+
super_message = Message.parse(bytes(super_field.value)) if super_field else Message()
|
|
205
|
+
name_field = super_message.one(1)
|
|
206
|
+
if name_field and bytes(name_field.value).decode("utf-8", "replace") == "Emphasis":
|
|
207
|
+
named.append(obj)
|
|
208
|
+
else:
|
|
209
|
+
anonymous.append(obj)
|
|
210
|
+
choices = named or anonymous
|
|
211
|
+
if not choices:
|
|
212
|
+
raise ValueError("document has no reusable bold-only CharacterStyleArchive")
|
|
213
|
+
return choices[0]
|
|
214
|
+
|
|
215
|
+
def find_anonymous_character_style_template(self) -> LocatedObject:
|
|
216
|
+
"""Return an anonymous type-2021 style, preferring a bold-only one."""
|
|
217
|
+
anonymous: list[tuple[bool, LocatedObject]] = []
|
|
218
|
+
for obj in self.objects().values():
|
|
219
|
+
if obj.info.type_id != TYPE_CHARACTER_STYLE:
|
|
220
|
+
continue
|
|
221
|
+
super_field = obj.message.one(STYLE_SUPER)
|
|
222
|
+
props_field = obj.message.one(STYLE_CHAR_PROPERTIES)
|
|
223
|
+
if super_field is None or props_field is None:
|
|
224
|
+
continue
|
|
225
|
+
super_message = Message.parse(bytes(super_field.value))
|
|
226
|
+
anonymous_marker = super_message.one(4)
|
|
227
|
+
if anonymous_marker is None or int(anonymous_marker.value) != 1:
|
|
228
|
+
continue
|
|
229
|
+
props = Message.parse(bytes(props_field.value))
|
|
230
|
+
bold = props.one(CHAR_BOLD)
|
|
231
|
+
bold_only = bold is not None and int(bold.value) == 1 and len(props.fields) == 1
|
|
232
|
+
anonymous.append((bold_only, obj))
|
|
233
|
+
if not anonymous:
|
|
234
|
+
raise ValueError("document has no anonymous CharacterStyleArchive template")
|
|
235
|
+
anonymous.sort(key=lambda item: (not item[0], item[1].segment.identifier))
|
|
236
|
+
return anonymous[0][1]
|
|
237
|
+
|
|
238
|
+
def package_metadata(self) -> LocatedObject:
|
|
239
|
+
matches = [obj for obj in self.objects().values() if obj.info.type_id == TYPE_PACKAGE_METADATA]
|
|
240
|
+
if len(matches) != 1:
|
|
241
|
+
raise ValueError(f"expected one TSP.PackageMetadata, found {len(matches)}")
|
|
242
|
+
return matches[0]
|
|
243
|
+
|
|
244
|
+
def allocate_object_identifier(self) -> int:
|
|
245
|
+
package = self.package_metadata()
|
|
246
|
+
last_field = package.message.one(PACKAGE_LAST_OBJECT_IDENTIFIER)
|
|
247
|
+
if last_field is None:
|
|
248
|
+
raise ValueError("PackageMetadata lacks last_object_identifier")
|
|
249
|
+
identifier = max(int(last_field.value), max(self.objects())) + 1
|
|
250
|
+
last_field.set_varint(identifier)
|
|
251
|
+
return identifier
|
|
252
|
+
|
|
253
|
+
def stylesheet(self) -> Stylesheet:
|
|
254
|
+
if self._stylesheet is None:
|
|
255
|
+
self._stylesheet = Stylesheet(self)
|
|
256
|
+
return self._stylesheet
|
|
257
|
+
|
|
258
|
+
def document_layout(self, body_storage: LocatedObject | None = None) -> DocumentLayout:
|
|
259
|
+
if self._document_layout is None:
|
|
260
|
+
self._document_layout = DocumentLayout(self, body_storage or self.body_storage())
|
|
261
|
+
return self._document_layout
|
|
262
|
+
|
|
263
|
+
def synthesize_character_style(
|
|
264
|
+
self,
|
|
265
|
+
props: CharacterProperties,
|
|
266
|
+
*,
|
|
267
|
+
experimental_allow_paragraph_background: bool = False,
|
|
268
|
+
) -> tuple[LocatedObject, dict[str, int | str | bool]]:
|
|
269
|
+
return self.stylesheet().synthesize_character_style(
|
|
270
|
+
props,
|
|
271
|
+
experimental_allow_paragraph_background=(
|
|
272
|
+
experimental_allow_paragraph_background
|
|
273
|
+
),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def synthesize_paragraph_style(
|
|
277
|
+
self,
|
|
278
|
+
parent_identifier: int,
|
|
279
|
+
props: ParagraphProperties,
|
|
280
|
+
*,
|
|
281
|
+
character_properties: bytes = b"",
|
|
282
|
+
) -> tuple[LocatedObject, dict[str, int | str | bool]]:
|
|
283
|
+
return self.stylesheet().synthesize_paragraph_style(
|
|
284
|
+
parent_identifier,
|
|
285
|
+
props,
|
|
286
|
+
character_properties=character_properties,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
def synthesize_bold_character_style(
|
|
290
|
+
self, font_name: str = DEFAULT_BOLD_FONT_NAME
|
|
291
|
+
) -> tuple[LocatedObject, dict[str, int | str]]:
|
|
292
|
+
"""Compatibility wrapper around generalized style synthesis."""
|
|
293
|
+
style, result = self.synthesize_character_style(
|
|
294
|
+
CharacterProperties(bold=True, font_name=font_name)
|
|
295
|
+
)
|
|
296
|
+
return style, result | {
|
|
297
|
+
"bold_style_id": style.segment.identifier,
|
|
298
|
+
"font_name": font_name,
|
|
299
|
+
"referencing_component": "Document",
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
def make_bold(
|
|
303
|
+
self,
|
|
304
|
+
start: int,
|
|
305
|
+
length: int,
|
|
306
|
+
*,
|
|
307
|
+
storage_id: int | None = None,
|
|
308
|
+
coordinates: str = "codepoint",
|
|
309
|
+
font_name: str | None = DEFAULT_BOLD_FONT_NAME,
|
|
310
|
+
) -> dict[str, int | str]:
|
|
311
|
+
storage = self.body_storage(storage_id)
|
|
312
|
+
text = self.text(storage)
|
|
313
|
+
if start < 0 or length <= 0 or start + length > len(text):
|
|
314
|
+
raise ValueError("requested range is outside body text")
|
|
315
|
+
if coordinates == "codepoint":
|
|
316
|
+
wire_start = codepoint_to_utf16(text, start)
|
|
317
|
+
wire_end = codepoint_to_utf16(text, start + length)
|
|
318
|
+
elif coordinates == "utf16":
|
|
319
|
+
wire_start, wire_end = start, start + length
|
|
320
|
+
else:
|
|
321
|
+
raise ValueError("coordinates must be 'codepoint' or 'utf16'")
|
|
322
|
+
table_field = storage.message.one(STORAGE_TABLE_CHAR_STYLE)
|
|
323
|
+
if table_field is None:
|
|
324
|
+
table_field = storage.message.add_bytes(STORAGE_TABLE_CHAR_STYLE, b"")
|
|
325
|
+
runs = parse_attribute_table(bytes(table_field.value))
|
|
326
|
+
props = CharacterProperties(bold=True, font_name=font_name)
|
|
327
|
+
bold, creation = self.synthesize_character_style(props)
|
|
328
|
+
creation = creation | {"font_name": font_name or "inherited"}
|
|
329
|
+
updated = replace_style_range(runs, wire_start, wire_end, bold.segment.identifier)
|
|
330
|
+
table_field.set_bytes(encode_attribute_table(updated))
|
|
331
|
+
add_object_reference(storage.info, bold.segment.identifier)
|
|
332
|
+
return creation | {
|
|
333
|
+
"storage_id": storage.segment.identifier,
|
|
334
|
+
"bold_style_id": bold.segment.identifier,
|
|
335
|
+
"start_utf16": wire_start,
|
|
336
|
+
"end_utf16": wire_end,
|
|
337
|
+
"runs_before": len(runs),
|
|
338
|
+
"runs_after": len(updated),
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
def make_substring_bold(
|
|
342
|
+
self,
|
|
343
|
+
substring: str,
|
|
344
|
+
*,
|
|
345
|
+
occurrence: int = 1,
|
|
346
|
+
storage_id: int | None = None,
|
|
347
|
+
font_name: str | None = DEFAULT_BOLD_FONT_NAME,
|
|
348
|
+
) -> dict[str, int | str]:
|
|
349
|
+
storage = self.body_storage(storage_id)
|
|
350
|
+
text = self.text(storage)
|
|
351
|
+
if occurrence < 1:
|
|
352
|
+
raise ValueError("occurrence is one-based")
|
|
353
|
+
start = -1
|
|
354
|
+
search_from = 0
|
|
355
|
+
for _ in range(occurrence):
|
|
356
|
+
start = text.find(substring, search_from)
|
|
357
|
+
if start < 0:
|
|
358
|
+
raise ValueError(f"substring occurrence {occurrence} not found")
|
|
359
|
+
search_from = start + len(substring)
|
|
360
|
+
return self.make_bold(
|
|
361
|
+
start,
|
|
362
|
+
len(substring),
|
|
363
|
+
storage_id=storage.segment.identifier,
|
|
364
|
+
font_name=font_name,
|
|
365
|
+
)
|