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/stylesheet.py ADDED
@@ -0,0 +1,892 @@
1
+ """TSS stylesheet registration and TSWP character-style synthesis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field as dataclass_field, fields
6
+ import struct
7
+ from typing import TYPE_CHECKING
8
+
9
+ from .iwa import ArchiveSegment, MessageInfo
10
+ from .metadata import (
11
+ package_revision_identifier,
12
+ register_component_object,
13
+ register_external_reference,
14
+ )
15
+ from .protobuf import Message
16
+ from .text import (
17
+ STYLE_CHAR_PROPERTIES,
18
+ STYLE_SUPER,
19
+ TYPE_CHARACTER_STYLE,
20
+ TYPE_PARAGRAPH_STYLE,
21
+ LocatedObject,
22
+ add_object_reference,
23
+ make_reference,
24
+ parse_reference,
25
+ )
26
+
27
+ if TYPE_CHECKING:
28
+ from .pages import PagesDocument
29
+
30
+
31
+ TYPE_STYLESHEET = 401
32
+ STYLESHEET_STYLES = 1
33
+ STYLESHEET_PARENT_TO_CHILDREN = 5
34
+ STYLE_PARENT = 3
35
+ STYLE_ANONYMOUS = 4
36
+ STYLE_PARA_PROPERTIES = 12
37
+
38
+ CHAR_BOLD = 1
39
+ CHAR_ITALIC = 2
40
+ CHAR_FONT_SIZE = 3
41
+ CHAR_FONT_NAME_NULL = 4
42
+ CHAR_FONT_NAME = 5
43
+ CHAR_FONT_COLOR_NULL = 6
44
+ CHAR_FONT_COLOR = 7
45
+ CHAR_SUPERSCRIPT = 10
46
+ CHAR_UNDERLINE = 11
47
+ CHAR_STRIKE = 12
48
+ CHAR_BASELINE_SHIFT = 14
49
+ CHAR_BACKGROUND_COLOR_NULL = 25
50
+ CHAR_BACKGROUND_COLOR = 26
51
+ CHAR_TSD_STROKE_NULL = 43
52
+ CHAR_TSD_STROKE = 44
53
+ CHAR_TSD_FILL_NULL = 45
54
+ CHAR_TSD_FILL = 46
55
+ CHAR_TSD_FILL_SHOULD_FILL_TEXT_CONTAINER = 47
56
+
57
+ KNOWN_CHARACTER_FIELDS = {
58
+ CHAR_BOLD,
59
+ CHAR_ITALIC,
60
+ CHAR_FONT_SIZE,
61
+ CHAR_FONT_NAME_NULL,
62
+ CHAR_FONT_NAME,
63
+ CHAR_FONT_COLOR_NULL,
64
+ CHAR_FONT_COLOR,
65
+ CHAR_SUPERSCRIPT,
66
+ CHAR_UNDERLINE,
67
+ CHAR_STRIKE,
68
+ CHAR_BASELINE_SHIFT,
69
+ CHAR_BACKGROUND_COLOR_NULL,
70
+ CHAR_BACKGROUND_COLOR,
71
+ CHAR_TSD_FILL_NULL,
72
+ CHAR_TSD_FILL,
73
+ CHAR_TSD_FILL_SHOULD_FILL_TEXT_CONTAINER,
74
+ }
75
+ CACHEABLE_CHARACTER_FIELDS = KNOWN_CHARACTER_FIELDS - {
76
+ CHAR_FONT_NAME_NULL,
77
+ CHAR_FONT_COLOR_NULL,
78
+ CHAR_BACKGROUND_COLOR_NULL,
79
+ CHAR_TSD_FILL_NULL,
80
+ }
81
+
82
+ PARA_ALIGNMENT = 1
83
+ PARA_FIRST_LINE_INDENT = 7
84
+ PARA_LEFT_INDENT = 11
85
+ PARA_LINE_SPACING_NULL = 12
86
+ PARA_LINE_SPACING = 13
87
+ PARA_PAGE_BREAK_BEFORE = 14
88
+ PARA_RIGHT_INDENT = 19
89
+ PARA_SPACE_AFTER = 20
90
+ PARA_SPACE_BEFORE = 21
91
+ PARA_TABS_NULL = 24
92
+ PARA_TABS = 25
93
+
94
+ KNOWN_PARAGRAPH_FIELDS = {
95
+ PARA_ALIGNMENT,
96
+ PARA_FIRST_LINE_INDENT,
97
+ PARA_LEFT_INDENT,
98
+ PARA_LINE_SPACING,
99
+ PARA_PAGE_BREAK_BEFORE,
100
+ PARA_RIGHT_INDENT,
101
+ PARA_SPACE_AFTER,
102
+ PARA_SPACE_BEFORE,
103
+ PARA_TABS,
104
+ }
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class RGBColor:
109
+ """An sRGB color represented by 8-bit red, green, and blue channels."""
110
+
111
+ red: int
112
+ green: int
113
+ blue: int
114
+
115
+ def __post_init__(self) -> None:
116
+ if any(channel < 0 or channel > 255 for channel in self):
117
+ raise ValueError("RGB channels must be in range 0..255")
118
+
119
+ def __iter__(self):
120
+ return iter((self.red, self.green, self.blue))
121
+
122
+ def __str__(self) -> str:
123
+ return f"{self.red:02X}{self.green:02X}{self.blue:02X}"
124
+
125
+ @classmethod
126
+ def from_string(cls, value: str) -> "RGBColor":
127
+ value = value.removeprefix("#")
128
+ if len(value) != 6:
129
+ raise ValueError("RGB color must contain six hexadecimal digits")
130
+ return cls(*(int(value[index : index + 2], 16) for index in (0, 2, 4)))
131
+
132
+
133
+ @dataclass(frozen=True)
134
+ class CharacterProperties:
135
+ bold: bool | None = None
136
+ italic: bool | None = None
137
+ underline: bool | None = None
138
+ strike: bool | None = None
139
+ font_name: str | None = None
140
+ font_size: float | None = None
141
+ color: RGBColor | None = None
142
+ vertical_align: int | None = None
143
+ baseline_shift: float | None = None
144
+ background_color: RGBColor | None = None
145
+
146
+ @property
147
+ def override_count(self) -> int:
148
+ return sum(getattr(self, item.name) is not None for item in fields(self))
149
+
150
+ def with_value(self, name: str, value) -> "CharacterProperties":
151
+ if name not in {item.name for item in fields(self)}:
152
+ raise AttributeError(name)
153
+ values = {item.name: getattr(self, item.name) for item in fields(self)}
154
+ values[name] = value
155
+ return CharacterProperties(**values)
156
+
157
+
158
+ @dataclass(frozen=True)
159
+ class LineSpacing:
160
+ mode: int
161
+ amount: float
162
+ baseline_rule: float | None = None
163
+
164
+
165
+ @dataclass(frozen=True)
166
+ class ParagraphTab:
167
+ position: float
168
+ alignment: int = 0
169
+ leader: str | None = None
170
+
171
+
172
+ @dataclass(frozen=True)
173
+ class ParagraphProperties:
174
+ alignment: int | None = None
175
+ line_spacing: LineSpacing | None = None
176
+ page_break_before: bool | None = None
177
+ space_before: float | None = None
178
+ space_after: float | None = None
179
+ left_indent: float | None = None
180
+ right_indent: float | None = None
181
+ first_line_indent: float | None = None
182
+ tabs: tuple[ParagraphTab, ...] | None = None
183
+ unknown_fields: tuple[bytes, ...] = dataclass_field(default=(), repr=False)
184
+
185
+ @property
186
+ def override_count(self) -> int:
187
+ known = sum(
188
+ getattr(self, item.name) is not None
189
+ for item in fields(self)
190
+ if item.name != "unknown_fields"
191
+ )
192
+ return known + len(self.unknown_fields)
193
+
194
+ def with_value(self, name: str, value) -> "ParagraphProperties":
195
+ public = {item.name for item in fields(self) if item.name != "unknown_fields"}
196
+ if name not in public:
197
+ raise AttributeError(name)
198
+ values = {item.name: getattr(self, item.name) for item in fields(self)}
199
+ values[name] = value
200
+ null_marker = {
201
+ "line_spacing": PARA_LINE_SPACING_NULL,
202
+ "tabs": PARA_TABS_NULL,
203
+ }.get(name)
204
+ if null_marker is not None:
205
+ values["unknown_fields"] = tuple(
206
+ raw
207
+ for raw in self.unknown_fields
208
+ if Message.parse(raw).fields[0].number != null_marker
209
+ )
210
+ return ParagraphProperties(**values)
211
+
212
+
213
+ def _float_bits(value: float) -> int:
214
+ return int.from_bytes(struct.pack("<f", float(value)), "little")
215
+
216
+
217
+ def _bits_float(value: int) -> float:
218
+ return struct.unpack("<f", int(value).to_bytes(4, "little"))[0]
219
+
220
+
221
+ def encode_color(color: RGBColor) -> bytes:
222
+ message = Message()
223
+ message.add_varint(1, 1) # RGB model
224
+ message.add_fixed32(3, _float_bits(color.red / 255.0))
225
+ message.add_fixed32(4, _float_bits(color.green / 255.0))
226
+ message.add_fixed32(5, _float_bits(color.blue / 255.0))
227
+ message.add_fixed32(6, _float_bits(1.0))
228
+ message.add_varint(12, 1) # sRGB
229
+ # Pages 15 writes this additional 1.0 field in every sample RGB color.
230
+ message.add_fixed32(13, _float_bits(1.0))
231
+ return message.encode()
232
+
233
+
234
+ def decode_color(data: bytes) -> RGBColor | None:
235
+ message = Message.parse(data)
236
+ model = message.one(1)
237
+ channels = [message.one(number) for number in (3, 4, 5)]
238
+ if model is None or int(model.value) != 1 or any(item is None for item in channels):
239
+ return None
240
+ return RGBColor(*(max(0, min(255, round(_bits_float(item.value) * 255))) for item in channels))
241
+
242
+
243
+ def encode_fill(color: RGBColor) -> bytes:
244
+ fill = Message()
245
+ fill.add_bytes(1, encode_color(color))
246
+ return fill.encode()
247
+
248
+
249
+ def decode_fill(data: bytes) -> RGBColor | None:
250
+ fill = Message.parse(data)
251
+ color = fill.one(1)
252
+ return None if color is None else decode_color(bytes(color.value))
253
+
254
+
255
+ def encode_character_properties(props: CharacterProperties) -> bytes:
256
+ message = Message()
257
+ if props.bold is not None:
258
+ message.add_varint(CHAR_BOLD, int(props.bold))
259
+ if props.italic is not None:
260
+ message.add_varint(CHAR_ITALIC, int(props.italic))
261
+ if props.font_size is not None:
262
+ message.add_fixed32(CHAR_FONT_SIZE, _float_bits(props.font_size))
263
+ if props.font_name is not None:
264
+ message.add_bytes(CHAR_FONT_NAME, props.font_name.encode("utf-8"))
265
+ if props.color is not None:
266
+ message.add_bytes(CHAR_FONT_COLOR, encode_color(props.color))
267
+ if props.vertical_align is not None:
268
+ message.add_varint(CHAR_SUPERSCRIPT, props.vertical_align)
269
+ if props.underline is not None:
270
+ message.add_varint(CHAR_UNDERLINE, int(props.underline))
271
+ if props.strike is not None:
272
+ message.add_varint(CHAR_STRIKE, int(props.strike))
273
+ if props.baseline_shift is not None:
274
+ message.add_fixed32(CHAR_BASELINE_SHIFT, _float_bits(props.baseline_shift))
275
+ if props.background_color is not None:
276
+ # The Pages-authored character-background fixture contains field 26
277
+ # alone: no null marker and no TSD fill/container fields.
278
+ message.add_bytes(CHAR_BACKGROUND_COLOR, encode_color(props.background_color))
279
+ # Foreground character color uses field 7 plus a synchronized TSD fill.
280
+ if props.color is not None:
281
+ message.add_bytes(CHAR_TSD_FILL, encode_fill(props.color))
282
+ return message.encode()
283
+
284
+
285
+ def decode_character_properties(data: bytes) -> CharacterProperties:
286
+ message = Message.parse(data)
287
+
288
+ def boolean(number: int) -> bool | None:
289
+ field = message.one(number)
290
+ return None if field is None else bool(field.value)
291
+
292
+ size = message.one(CHAR_FONT_SIZE)
293
+ name = message.one(CHAR_FONT_NAME)
294
+ color = message.one(CHAR_FONT_COLOR)
295
+ vertical_align = message.one(CHAR_SUPERSCRIPT)
296
+ baseline_shift = message.one(CHAR_BASELINE_SHIFT)
297
+ background_color = message.one(CHAR_BACKGROUND_COLOR)
298
+ font_color_null = message.one(CHAR_FONT_COLOR_NULL)
299
+ background_color_null = message.one(CHAR_BACKGROUND_COLOR_NULL)
300
+ fill = message.one(CHAR_TSD_FILL)
301
+ fill_null = message.one(CHAR_TSD_FILL_NULL)
302
+ fill_container = message.one(CHAR_TSD_FILL_SHOULD_FILL_TEXT_CONTAINER)
303
+ fill_color = (
304
+ None
305
+ if fill_null is not None and bool(fill_null.value)
306
+ else decode_fill(bytes(fill.value))
307
+ if fill
308
+ else None
309
+ )
310
+ fills_background = bool(fill_container.value) if fill_container else False
311
+ decoded_font_color = None
312
+ if font_color_null is None or not bool(font_color_null.value):
313
+ if color is not None:
314
+ decoded_font_color = decode_color(bytes(color.value))
315
+ elif not fills_background:
316
+ decoded_font_color = fill_color
317
+ decoded_background_color = None
318
+ if background_color_null is None or not bool(background_color_null.value):
319
+ if background_color is not None:
320
+ decoded_background_color = decode_color(bytes(background_color.value))
321
+ elif fills_background:
322
+ decoded_background_color = fill_color
323
+ return CharacterProperties(
324
+ bold=boolean(CHAR_BOLD),
325
+ italic=boolean(CHAR_ITALIC),
326
+ underline=boolean(CHAR_UNDERLINE),
327
+ strike=boolean(CHAR_STRIKE),
328
+ font_name=bytes(name.value).decode("utf-8", "replace") if name else None,
329
+ font_size=_bits_float(size.value) if size else None,
330
+ color=decoded_font_color,
331
+ vertical_align=int(vertical_align.value) if vertical_align else None,
332
+ baseline_shift=(
333
+ _bits_float(baseline_shift.value) if baseline_shift else None
334
+ ),
335
+ background_color=decoded_background_color,
336
+ )
337
+
338
+
339
+ def replace_character_background(
340
+ data: bytes, color: RGBColor | None
341
+ ) -> bytes:
342
+ """Replace only the Pages character-background fields in raw properties.
343
+
344
+ Paragraph styles can carry character properties in field 11. Keeping the
345
+ raw message here is important: changing a paragraph background must not
346
+ discard character-property fields that this library does not yet model.
347
+ """
348
+
349
+ message = Message.parse(data)
350
+ message.fields = [
351
+ field
352
+ for field in message.fields
353
+ if field.number not in (CHAR_BACKGROUND_COLOR_NULL, CHAR_BACKGROUND_COLOR)
354
+ ]
355
+ if color is not None:
356
+ message.add_bytes(CHAR_BACKGROUND_COLOR, encode_color(color))
357
+ return message.encode()
358
+
359
+
360
+ def character_style_properties(style: LocatedObject | None) -> CharacterProperties:
361
+ if style is None:
362
+ return CharacterProperties()
363
+ field = style.message.one(STYLE_CHAR_PROPERTIES)
364
+ return CharacterProperties() if field is None else decode_character_properties(bytes(field.value))
365
+
366
+
367
+ def encode_paragraph_properties(props: ParagraphProperties) -> bytes:
368
+ message = Message()
369
+ if props.alignment is not None:
370
+ message.add_varint(PARA_ALIGNMENT, props.alignment)
371
+ if props.first_line_indent is not None:
372
+ message.add_fixed32(PARA_FIRST_LINE_INDENT, _float_bits(props.first_line_indent))
373
+ if props.left_indent is not None:
374
+ message.add_fixed32(PARA_LEFT_INDENT, _float_bits(props.left_indent))
375
+ if props.line_spacing is not None:
376
+ spacing = Message()
377
+ spacing.add_varint(1, props.line_spacing.mode)
378
+ spacing.add_fixed32(2, _float_bits(props.line_spacing.amount))
379
+ if props.line_spacing.baseline_rule is not None:
380
+ spacing.add_fixed32(3, _float_bits(props.line_spacing.baseline_rule))
381
+ message.add_bytes(PARA_LINE_SPACING, spacing.encode())
382
+ if props.page_break_before is not None:
383
+ message.add_varint(PARA_PAGE_BREAK_BEFORE, int(props.page_break_before))
384
+ if props.right_indent is not None:
385
+ message.add_fixed32(PARA_RIGHT_INDENT, _float_bits(props.right_indent))
386
+ if props.space_after is not None:
387
+ message.add_fixed32(PARA_SPACE_AFTER, _float_bits(props.space_after))
388
+ if props.space_before is not None:
389
+ message.add_fixed32(PARA_SPACE_BEFORE, _float_bits(props.space_before))
390
+ if props.tabs is not None:
391
+ tabs = Message()
392
+ for tab in props.tabs:
393
+ item = Message()
394
+ item.add_fixed32(1, _float_bits(tab.position))
395
+ if tab.alignment:
396
+ item.add_varint(2, tab.alignment)
397
+ if tab.leader:
398
+ item.add_bytes(3, tab.leader.encode("utf-8"))
399
+ tabs.add_bytes(1, item.encode())
400
+ message.add_bytes(PARA_TABS, tabs.encode())
401
+ for raw in props.unknown_fields:
402
+ message.fields.extend(Message.parse(raw).fields)
403
+ return message.encode()
404
+
405
+
406
+ def decode_paragraph_properties(data: bytes) -> ParagraphProperties:
407
+ message = Message.parse(data)
408
+
409
+ def floating(number: int) -> float | None:
410
+ item = message.one(number)
411
+ return None if item is None else _bits_float(item.value)
412
+
413
+ alignment = message.one(PARA_ALIGNMENT)
414
+ page_break_before = message.one(PARA_PAGE_BREAK_BEFORE)
415
+ spacing_field = message.one(PARA_LINE_SPACING)
416
+ spacing = None
417
+ if spacing_field is not None:
418
+ spacing_message = Message.parse(bytes(spacing_field.value))
419
+ mode = spacing_message.one(1)
420
+ amount = spacing_message.one(2)
421
+ baseline = spacing_message.one(3)
422
+ if mode is not None and amount is not None:
423
+ spacing = LineSpacing(
424
+ int(mode.value),
425
+ _bits_float(amount.value),
426
+ _bits_float(baseline.value) if baseline else None,
427
+ )
428
+
429
+ tabs_field = message.one(PARA_TABS)
430
+ tabs = None
431
+ if tabs_field is not None:
432
+ parsed_tabs = Message.parse(bytes(tabs_field.value))
433
+ values = []
434
+ for raw in parsed_tabs.get(1):
435
+ tab = Message.parse(bytes(raw.value))
436
+ position = tab.one(1)
437
+ if position is None:
438
+ continue
439
+ alignment_field = tab.one(2)
440
+ leader = tab.one(3)
441
+ values.append(
442
+ ParagraphTab(
443
+ _bits_float(position.value),
444
+ int(alignment_field.value) if alignment_field else 0,
445
+ bytes(leader.value).decode("utf-8", "replace") if leader else None,
446
+ )
447
+ )
448
+ tabs = tuple(values)
449
+
450
+ unknown = tuple(
451
+ item.original or item.encode()
452
+ for item in message.fields
453
+ if item.number not in KNOWN_PARAGRAPH_FIELDS
454
+ )
455
+ return ParagraphProperties(
456
+ alignment=int(alignment.value) if alignment else None,
457
+ line_spacing=spacing,
458
+ page_break_before=(
459
+ bool(page_break_before.value) if page_break_before is not None else None
460
+ ),
461
+ space_before=floating(PARA_SPACE_BEFORE),
462
+ space_after=floating(PARA_SPACE_AFTER),
463
+ left_indent=floating(PARA_LEFT_INDENT),
464
+ right_indent=floating(PARA_RIGHT_INDENT),
465
+ first_line_indent=floating(PARA_FIRST_LINE_INDENT),
466
+ tabs=tabs,
467
+ unknown_fields=unknown,
468
+ )
469
+
470
+
471
+ def is_anonymous_style(style: LocatedObject | None) -> bool:
472
+ if style is None:
473
+ return False
474
+ super_field = style.message.one(STYLE_SUPER)
475
+ if super_field is None:
476
+ return False
477
+ marker = Message.parse(bytes(super_field.value)).one(STYLE_ANONYMOUS)
478
+ return marker is not None and int(marker.value) == 1
479
+
480
+
481
+ def style_name(style: LocatedObject | None) -> str | None:
482
+ if style is None:
483
+ return None
484
+ super_field = style.message.one(STYLE_SUPER)
485
+ if super_field is None:
486
+ return None
487
+ name = Message.parse(bytes(super_field.value)).one(1)
488
+ return None if name is None else bytes(name.value).decode("utf-8", "replace")
489
+
490
+
491
+ def style_identifier(style: LocatedObject | None) -> str | None:
492
+ if style is None:
493
+ return None
494
+ super_field = style.message.one(STYLE_SUPER)
495
+ if super_field is None:
496
+ return None
497
+ identifier = Message.parse(bytes(super_field.value)).one(2)
498
+ return (
499
+ None
500
+ if identifier is None
501
+ else bytes(identifier.value).decode("utf-8", "replace")
502
+ )
503
+
504
+
505
+ def paragraph_style_properties(style: LocatedObject | None) -> ParagraphProperties:
506
+ if style is None or not is_anonymous_style(style):
507
+ return ParagraphProperties()
508
+ field = style.message.one(STYLE_PARA_PROPERTIES)
509
+ return ParagraphProperties() if field is None else decode_paragraph_properties(bytes(field.value))
510
+
511
+
512
+ class Stylesheet:
513
+ """Mutation facade for a document's TSS.StylesheetArchive."""
514
+
515
+ def __init__(self, document: "PagesDocument"):
516
+ self.document = document
517
+ matches = [obj for obj in document.object_messages() if obj.info.type_id == TYPE_STYLESHEET]
518
+ if len(matches) != 1:
519
+ raise ValueError(f"expected one TSS.StylesheetArchive, found {len(matches)}")
520
+ self.archive = matches[0]
521
+ self._cache: dict[CharacterProperties, LocatedObject] = {}
522
+ self._paragraph_cache: dict[
523
+ tuple[int, bytes, ParagraphProperties], LocatedObject
524
+ ] = {}
525
+ self._index_styles()
526
+
527
+ def _index_styles(self) -> None:
528
+ registered = {
529
+ parse_reference(bytes(field.value)) for field in self.archive.message.get(STYLESHEET_STYLES)
530
+ }
531
+ for obj in self.document.object_messages():
532
+ if obj.info.type_id != TYPE_CHARACTER_STYLE or obj.segment.identifier not in registered:
533
+ continue
534
+ raw = obj.message.one(STYLE_CHAR_PROPERTIES)
535
+ if raw is None:
536
+ continue
537
+ message = Message.parse(bytes(raw.value))
538
+ if all(field.number in CACHEABLE_CHARACTER_FIELDS for field in message.fields):
539
+ self._cache.setdefault(decode_character_properties(bytes(raw.value)), obj)
540
+ for obj in self.document.object_messages():
541
+ if obj.info.type_id != TYPE_PARAGRAPH_STYLE or obj.segment.identifier not in registered:
542
+ continue
543
+ if not is_anonymous_style(obj):
544
+ continue
545
+ try:
546
+ parent = self.parent_identifier(obj)
547
+ except ValueError:
548
+ continue
549
+ character = obj.message.one(STYLE_CHAR_PROPERTIES)
550
+ character_raw = bytes(character.value) if character else b""
551
+ paragraph = obj.message.one(STYLE_PARA_PROPERTIES)
552
+ props = (
553
+ decode_paragraph_properties(bytes(paragraph.value))
554
+ if paragraph
555
+ else ParagraphProperties()
556
+ )
557
+ self._paragraph_cache.setdefault((parent, character_raw, props), obj)
558
+
559
+ def style(self, identifier: int | None) -> LocatedObject | None:
560
+ if identifier is None:
561
+ return None
562
+ obj = self.document.objects().get(identifier)
563
+ return obj if obj is not None and obj.info.type_id == TYPE_CHARACTER_STYLE else None
564
+
565
+ def paragraph_style(self, identifier: int | None) -> LocatedObject | None:
566
+ if identifier is None:
567
+ return None
568
+ obj = self.document.objects().get(identifier)
569
+ return obj if obj is not None and obj.info.type_id == TYPE_PARAGRAPH_STYLE else None
570
+
571
+ @staticmethod
572
+ def parent_identifier(style: LocatedObject) -> int:
573
+ super_field = style.message.one(STYLE_SUPER)
574
+ if super_field is None:
575
+ raise ValueError("style lacks TSS.StyleArchive super")
576
+ super_message = Message.parse(bytes(super_field.value))
577
+ parent = super_message.one(STYLE_PARENT)
578
+ if parent is None:
579
+ raise ValueError("style template has no parent style")
580
+ identifier = parse_reference(bytes(parent.value))
581
+ if identifier is None:
582
+ raise ValueError("style parent reference is empty")
583
+ return identifier
584
+
585
+ def _template(self) -> LocatedObject:
586
+ choices: list[LocatedObject] = []
587
+ for obj in self.document.object_messages():
588
+ if obj.info.type_id != TYPE_CHARACTER_STYLE:
589
+ continue
590
+ super_field = obj.message.one(STYLE_SUPER)
591
+ if super_field is None:
592
+ continue
593
+ super_message = Message.parse(bytes(super_field.value))
594
+ anonymous = super_message.one(STYLE_ANONYMOUS)
595
+ if anonymous is not None and int(anonymous.value) == 1 and super_message.one(STYLE_PARENT):
596
+ choices.append(obj)
597
+ if not choices:
598
+ raise ValueError("document has no anonymous character style with a parent")
599
+ choices.sort(key=lambda obj: obj.segment.identifier)
600
+ return choices[0]
601
+
602
+ def _paragraph_template(self) -> LocatedObject:
603
+ choices = [
604
+ obj
605
+ for obj in self.document.object_messages()
606
+ if obj.info.type_id == TYPE_PARAGRAPH_STYLE
607
+ and is_anonymous_style(obj)
608
+ and obj.message.one(STYLE_SUPER)
609
+ ]
610
+ choices = [obj for obj in choices if Message.parse(bytes(obj.message.one(1).value)).one(3)]
611
+ if not choices:
612
+ raise ValueError("document has no anonymous paragraph style with a parent")
613
+ choices.sort(key=lambda obj: obj.segment.identifier)
614
+ return choices[0]
615
+
616
+ def default_paragraph_style(self) -> LocatedObject:
617
+ named = []
618
+ for obj in self.document.object_messages():
619
+ if obj.info.type_id != TYPE_PARAGRAPH_STYLE or is_anonymous_style(obj):
620
+ continue
621
+ super_field = obj.message.one(STYLE_SUPER)
622
+ if super_field is None:
623
+ continue
624
+ name = Message.parse(bytes(super_field.value)).one(1)
625
+ if name is not None:
626
+ named.append((bytes(name.value).decode("utf-8", "replace"), obj))
627
+ for name, obj in named:
628
+ if name == "Body":
629
+ return obj
630
+ if not named:
631
+ raise ValueError("document has no named paragraph style")
632
+ return named[0][1]
633
+
634
+ def named_paragraph_style(self, name: str) -> LocatedObject:
635
+ for obj in self.document.object_messages():
636
+ if obj.info.type_id != TYPE_PARAGRAPH_STYLE or is_anonymous_style(obj):
637
+ continue
638
+ super_field = obj.message.one(STYLE_SUPER)
639
+ if super_field is None:
640
+ continue
641
+ name_field = Message.parse(bytes(super_field.value)).one(1)
642
+ if name_field is not None and bytes(name_field.value).decode("utf-8", "replace") == name:
643
+ return obj
644
+ raise KeyError(f"paragraph style {name!r} was not found")
645
+
646
+ def named_character_style(self, name: str) -> LocatedObject:
647
+ for obj in self.document.object_messages():
648
+ if obj.info.type_id != TYPE_CHARACTER_STYLE or is_anonymous_style(obj):
649
+ continue
650
+ if style_name(obj) == name:
651
+ return obj
652
+ raise KeyError(f"character style {name!r} was not found")
653
+
654
+ def named_styles(self) -> list[LocatedObject]:
655
+ registered = {
656
+ parse_reference(bytes(field.value))
657
+ for field in self.archive.message.get(STYLESHEET_STYLES)
658
+ }
659
+ return [
660
+ obj
661
+ for obj in self.document.object_messages()
662
+ if obj.segment.identifier in registered
663
+ and obj.info.type_id in (TYPE_CHARACTER_STYLE, TYPE_PARAGRAPH_STYLE)
664
+ and not is_anonymous_style(obj)
665
+ and style_name(obj) is not None
666
+ ]
667
+
668
+ def register_style(self, style: LocatedObject, parent_identifier: int) -> None:
669
+ identifier = style.segment.identifier
670
+ existing = {
671
+ parse_reference(bytes(field.value)) for field in self.archive.message.get(STYLESHEET_STYLES)
672
+ }
673
+ if identifier not in existing:
674
+ self.archive.message.add_bytes(STYLESHEET_STYLES, make_reference(identifier))
675
+
676
+ selected_field = None
677
+ selected = None
678
+ for field in self.archive.message.get(STYLESHEET_PARENT_TO_CHILDREN):
679
+ entry = Message.parse(bytes(field.value))
680
+ parent = entry.one(1)
681
+ if parent and parse_reference(bytes(parent.value)) == parent_identifier:
682
+ selected_field, selected = field, entry
683
+ break
684
+ if selected is None:
685
+ selected = Message()
686
+ selected.add_bytes(1, make_reference(parent_identifier))
687
+ selected_field = self.archive.message.add_bytes(
688
+ STYLESHEET_PARENT_TO_CHILDREN, selected.encode()
689
+ )
690
+ children = {parse_reference(bytes(field.value)) for field in selected.get(2)}
691
+ if identifier not in children:
692
+ selected.add_bytes(2, make_reference(identifier))
693
+ selected_field.set_bytes(selected.encode())
694
+
695
+ add_object_reference(self.archive.info, identifier)
696
+
697
+ def synthesize_character_style(
698
+ self,
699
+ props: CharacterProperties,
700
+ *,
701
+ experimental_allow_paragraph_background: bool = False,
702
+ ) -> tuple[LocatedObject, dict]:
703
+ if props.override_count == 0:
704
+ raise ValueError("cannot synthesize an empty character style")
705
+ if (
706
+ props.background_color is not None
707
+ and not experimental_allow_paragraph_background
708
+ ):
709
+ raise ValueError(
710
+ "Pages field-26 background is paragraph-scoped; use "
711
+ "Paragraph.background_color or Paragraph.highlight_color"
712
+ )
713
+ cached = self._cache.get(props)
714
+ if cached is not None:
715
+ return cached, {"style_cache_hit": True, "style_id": cached.segment.identifier}
716
+
717
+ template = self._template()
718
+ package = self.document.package_metadata()
719
+ identifier = self.document.allocate_object_identifier()
720
+ payload = Message.parse(template.message.encode())
721
+ properties = payload.one(STYLE_CHAR_PROPERTIES)
722
+ encoded_properties = encode_character_properties(props)
723
+ if properties is None:
724
+ payload.add_bytes(STYLE_CHAR_PROPERTIES, encoded_properties)
725
+ else:
726
+ properties.set_bytes(encoded_properties)
727
+ override_count = payload.one(10)
728
+ if override_count is None:
729
+ payload.add_varint(10, props.override_count)
730
+ else:
731
+ override_count.set_varint(props.override_count)
732
+
733
+ header = Message.parse(template.segment.header.encode())
734
+ header_identifier = header.one(1)
735
+ if header_identifier is None:
736
+ raise ValueError("style ArchiveInfo lacks identifier")
737
+ header_identifier.set_varint(identifier)
738
+ info_fields = header.get(2)
739
+ if len(info_fields) != 1:
740
+ raise ValueError("style template must contain exactly one MessageInfo")
741
+ info_field = info_fields[0]
742
+ info_message = Message.parse(bytes(info_field.value))
743
+ length_field = info_message.one(3)
744
+ if length_field is None:
745
+ raise ValueError("style MessageInfo lacks payload length")
746
+ new_info = MessageInfo(info_field, info_message, TYPE_CHARACTER_STYLE, int(length_field.value))
747
+ new_info.update_length(len(payload.encode()))
748
+ segment = ArchiveSegment(header, b"", identifier, [new_info], [payload], [b""])
749
+ self.document.iwa_files[template.filename].segments.append(segment)
750
+ created = LocatedObject(template.filename, segment, new_info, payload)
751
+
752
+ parent_identifier = self.parent_identifier(created)
753
+ self.register_style(created, parent_identifier)
754
+ revision = package_revision_identifier(package.message)
755
+ signature = repr(props)
756
+ object_uuid, component_id = register_component_object(
757
+ package.message,
758
+ "DocumentStylesheet",
759
+ identifier,
760
+ uuid_seed=f"python-pages:{revision}:{identifier}:{signature}",
761
+ )
762
+ register_external_reference(package.message, "Document", component_id, identifier)
763
+ self._cache[props] = created
764
+ return created, {
765
+ "style_cache_hit": False,
766
+ "style_template_id": template.segment.identifier,
767
+ "style_id": identifier,
768
+ "parent_style_id": parent_identifier,
769
+ "override_count": props.override_count,
770
+ "object_uuid": object_uuid,
771
+ "component_id": component_id,
772
+ }
773
+
774
+ def paragraph_style_context(
775
+ self, identifier: int | None
776
+ ) -> tuple[int, bytes, ParagraphProperties]:
777
+ style = self.paragraph_style(identifier)
778
+ if style is None:
779
+ return self.default_paragraph_style().segment.identifier, b"", ParagraphProperties()
780
+ if not is_anonymous_style(style):
781
+ return style.segment.identifier, b"", ParagraphProperties()
782
+ parent = self.parent_identifier(style)
783
+ character = style.message.one(STYLE_CHAR_PROPERTIES)
784
+ character_raw = bytes(character.value) if character else b""
785
+ return parent, character_raw, paragraph_style_properties(style)
786
+
787
+ def synthesize_paragraph_style(
788
+ self,
789
+ parent_identifier: int,
790
+ props: ParagraphProperties,
791
+ *,
792
+ character_properties: bytes = b"",
793
+ ) -> tuple[LocatedObject, dict]:
794
+ key = (parent_identifier, character_properties, props)
795
+ cached = self._paragraph_cache.get(key)
796
+ if cached is not None:
797
+ return cached, {"style_cache_hit": True, "style_id": cached.segment.identifier}
798
+
799
+ if props.override_count == 0 and not character_properties:
800
+ parent = self.paragraph_style(parent_identifier)
801
+ if parent is None:
802
+ raise ValueError(f"paragraph parent style {parent_identifier} was not found")
803
+ return parent, {"style_cache_hit": True, "style_id": parent_identifier}
804
+
805
+ template = self._paragraph_template()
806
+ package = self.document.package_metadata()
807
+ identifier = self.document.allocate_object_identifier()
808
+ payload = Message.parse(template.message.encode())
809
+ super_field = payload.one(STYLE_SUPER)
810
+ if super_field is None:
811
+ raise ValueError("paragraph style template lacks TSS.StyleArchive super")
812
+ super_message = Message.parse(bytes(super_field.value))
813
+ parent_field = super_message.one(STYLE_PARENT)
814
+ if parent_field is None:
815
+ super_message.add_bytes(STYLE_PARENT, make_reference(parent_identifier))
816
+ else:
817
+ parent_field.set_bytes(make_reference(parent_identifier))
818
+ anonymous = super_message.one(STYLE_ANONYMOUS)
819
+ if anonymous is None:
820
+ super_message.add_varint(STYLE_ANONYMOUS, 1)
821
+ else:
822
+ anonymous.set_varint(1)
823
+ super_field.set_bytes(super_message.encode())
824
+
825
+ payload.fields = [
826
+ item for item in payload.fields if item.number not in (10, 11, STYLE_PARA_PROPERTIES)
827
+ ]
828
+ total_overrides = props.override_count
829
+ if character_properties:
830
+ total_overrides += len(Message.parse(character_properties).fields)
831
+ payload.add_varint(10, total_overrides)
832
+ if character_properties:
833
+ payload.add_bytes(STYLE_CHAR_PROPERTIES, character_properties)
834
+ if props.override_count:
835
+ payload.add_bytes(STYLE_PARA_PROPERTIES, encode_paragraph_properties(props))
836
+ elif character_properties:
837
+ # Pages-authored anonymous type-2022 styles with character-only
838
+ # overrides retain an explicit empty ParagraphStylePropertiesArchive
839
+ # field. Match that observed wire shape exactly.
840
+ payload.add_bytes(STYLE_PARA_PROPERTIES, b"")
841
+
842
+ header = Message.parse(template.segment.header.encode())
843
+ header_identifier = header.one(1)
844
+ if header_identifier is None:
845
+ raise ValueError("paragraph style ArchiveInfo lacks identifier")
846
+ header_identifier.set_varint(identifier)
847
+ info_fields = header.get(2)
848
+ if len(info_fields) != 1:
849
+ raise ValueError("paragraph style template must contain exactly one MessageInfo")
850
+ info_field = info_fields[0]
851
+ info_message = Message.parse(bytes(info_field.value))
852
+ type_field = info_message.one(1)
853
+ length_field = info_message.one(3)
854
+ if (
855
+ type_field is None
856
+ or int(type_field.value) != TYPE_PARAGRAPH_STYLE
857
+ or length_field is None
858
+ ):
859
+ raise ValueError("paragraph style template has incompatible MessageInfo")
860
+ new_info = MessageInfo(
861
+ info_field,
862
+ info_message,
863
+ TYPE_PARAGRAPH_STYLE,
864
+ int(length_field.value),
865
+ )
866
+ new_info.update_length(len(payload.encode()))
867
+ segment = ArchiveSegment(header, b"", identifier, [new_info], [payload], [b""])
868
+ self.document.iwa_files[template.filename].segments.append(segment)
869
+ created = LocatedObject(template.filename, segment, new_info, payload)
870
+
871
+ self.register_style(created, parent_identifier)
872
+ revision = package_revision_identifier(package.message)
873
+ object_uuid, component_id = register_component_object(
874
+ package.message,
875
+ "DocumentStylesheet",
876
+ identifier,
877
+ uuid_seed=(
878
+ f"python-pages:{revision}:{identifier}:paragraph:"
879
+ f"{parent_identifier}:{character_properties.hex()}:{props!r}"
880
+ ),
881
+ )
882
+ register_external_reference(package.message, "Document", component_id, identifier)
883
+ self._paragraph_cache[key] = created
884
+ return created, {
885
+ "style_cache_hit": False,
886
+ "style_template_id": template.segment.identifier,
887
+ "style_id": identifier,
888
+ "parent_style_id": parent_identifier,
889
+ "override_count": total_overrides,
890
+ "object_uuid": object_uuid,
891
+ "component_id": component_id,
892
+ }