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