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/api.py ADDED
@@ -0,0 +1,1869 @@
1
+ """Small python-docx-shaped object model backed by Pages IWA storage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import IntEnum
6
+ from pathlib import Path
7
+
8
+ from .core_properties import CoreProperties
9
+ from .document import (
10
+ DOCUMENT_BOTTOM_MARGIN,
11
+ DOCUMENT_FOOTER_MARGIN,
12
+ DOCUMENT_HEADER_MARGIN,
13
+ DOCUMENT_LEFT_MARGIN,
14
+ DOCUMENT_ORIENTATION,
15
+ DOCUMENT_PAGE_HEIGHT,
16
+ DOCUMENT_PAGE_WIDTH,
17
+ DOCUMENT_RIGHT_MARGIN,
18
+ DOCUMENT_TOP_MARGIN,
19
+ SECTION_FIRST_PAGE_DIFFERENT,
20
+ DocumentLayout,
21
+ )
22
+ from .pages import PagesDocument
23
+ from .stylesheet import (
24
+ CharacterProperties,
25
+ LineSpacing,
26
+ ParagraphProperties,
27
+ ParagraphTab,
28
+ RGBColor,
29
+ character_style_properties,
30
+ paragraph_style_properties,
31
+ replace_character_background,
32
+ is_anonymous_style,
33
+ style_identifier,
34
+ style_name,
35
+ )
36
+ from .text import (
37
+ STORAGE_TABLE_CHAR_STYLE,
38
+ STORAGE_TABLE_PARA_STYLE,
39
+ TYPE_CHARACTER_STYLE,
40
+ TYPE_PARAGRAPH_STYLE,
41
+ AttributeRun,
42
+ add_object_reference,
43
+ codepoint_to_utf16,
44
+ encode_attribute_table,
45
+ parse_attribute_table,
46
+ replace_style_range,
47
+ replace_storage_text,
48
+ style_at,
49
+ utf16_to_codepoint,
50
+ )
51
+ from .components import append_attachment_run
52
+ from .tables import CellStorage, TableStore, body_table_stores, clone_body_table
53
+ from .pictures import ImageStore, body_image_stores, clone_body_image
54
+ from .hyperlinks import HyperlinkStore, storage_hyperlinks
55
+ from .comments import CommentStore, storage_comments
56
+
57
+
58
+ class Length(float):
59
+ """Length stored internally in points."""
60
+
61
+ @property
62
+ def pt(self) -> float:
63
+ return float(self)
64
+
65
+ @property
66
+ def inches(self) -> float:
67
+ return float(self) / 72.0
68
+
69
+ @property
70
+ def cm(self) -> float:
71
+ return self.inches * 2.54
72
+
73
+
74
+ class Pt(Length):
75
+ """Point measure compatible with the common ``Pt(12)`` calling shape."""
76
+
77
+ def __new__(cls, value: float):
78
+ return super().__new__(cls, float(value))
79
+
80
+ @property
81
+ def pt(self) -> float:
82
+ return float(self)
83
+
84
+
85
+ class Inches(Length):
86
+ def __new__(cls, value: float):
87
+ return super().__new__(cls, float(value) * 72.0)
88
+
89
+
90
+ class Cm(Length):
91
+ def __new__(cls, value: float):
92
+ return super().__new__(cls, float(value) * 72.0 / 2.54)
93
+
94
+
95
+ class WD_PARAGRAPH_ALIGNMENT(IntEnum):
96
+ LEFT = 0
97
+ CENTER = 1
98
+ RIGHT = 2
99
+ JUSTIFY = 3
100
+
101
+
102
+ WD_ALIGN_PARAGRAPH = WD_PARAGRAPH_ALIGNMENT
103
+
104
+
105
+ class WD_LINE_SPACING(IntEnum):
106
+ SINGLE = 0
107
+ ONE_POINT_FIVE = 1
108
+ DOUBLE = 2
109
+ AT_LEAST = 3
110
+ EXACTLY = 4
111
+ MULTIPLE = 5
112
+
113
+
114
+ class WD_TAB_ALIGNMENT(IntEnum):
115
+ LEFT = 0
116
+ CENTER = 1
117
+ RIGHT = 2
118
+ DECIMAL = 3
119
+
120
+
121
+ class WD_TAB_LEADER(IntEnum):
122
+ SPACES = 0
123
+ DOTS = 1
124
+ DASHES = 2
125
+ LINES = 3
126
+ HEAVY = 4
127
+ MIDDLE_DOT = 5
128
+
129
+
130
+ class WD_ORIENTATION(IntEnum):
131
+ PORTRAIT = 0
132
+ LANDSCAPE = 1
133
+
134
+
135
+ WD_ORIENT = WD_ORIENTATION
136
+
137
+
138
+ class WD_STYLE_TYPE(IntEnum):
139
+ PARAGRAPH = 1
140
+ CHARACTER = 2
141
+ TABLE = 3
142
+ LIST = 4
143
+
144
+
145
+ class WD_COLOR_INDEX(IntEnum):
146
+ """Standard python-docx highlight-color palette."""
147
+
148
+ INHERITED = -1
149
+ AUTO = 0
150
+ BLACK = 1
151
+ BLUE = 2
152
+ TURQUOISE = 3
153
+ BRIGHT_GREEN = 4
154
+ PINK = 5
155
+ RED = 6
156
+ YELLOW = 7
157
+ WHITE = 8
158
+ DARK_BLUE = 9
159
+ TEAL = 10
160
+ GREEN = 11
161
+ VIOLET = 12
162
+ DARK_RED = 13
163
+ DARK_YELLOW = 14
164
+ GRAY_50 = 15
165
+ GRAY_25 = 16
166
+
167
+
168
+ WD_COLOR = WD_COLOR_INDEX
169
+
170
+
171
+ _API_TO_PAGES_ALIGNMENT = {
172
+ WD_PARAGRAPH_ALIGNMENT.LEFT: 0,
173
+ WD_PARAGRAPH_ALIGNMENT.RIGHT: 1,
174
+ WD_PARAGRAPH_ALIGNMENT.CENTER: 2,
175
+ WD_PARAGRAPH_ALIGNMENT.JUSTIFY: 3,
176
+ }
177
+ _PAGES_TO_API_ALIGNMENT = {value: key for key, value in _API_TO_PAGES_ALIGNMENT.items()}
178
+ _LEADER_TO_STRING = {
179
+ WD_TAB_LEADER.SPACES: None,
180
+ WD_TAB_LEADER.DOTS: ".",
181
+ WD_TAB_LEADER.DASHES: "-",
182
+ WD_TAB_LEADER.LINES: "_",
183
+ WD_TAB_LEADER.HEAVY: "•",
184
+ WD_TAB_LEADER.MIDDLE_DOT: "·",
185
+ }
186
+ _STRING_TO_LEADER = {value: key for key, value in _LEADER_TO_STRING.items()}
187
+ _HIGHLIGHT_TO_RGB = {
188
+ WD_COLOR_INDEX.AUTO: RGBColor(0x00, 0x00, 0x00),
189
+ WD_COLOR_INDEX.BLACK: RGBColor(0x00, 0x00, 0x00),
190
+ WD_COLOR_INDEX.BLUE: RGBColor(0x00, 0x00, 0xFF),
191
+ WD_COLOR_INDEX.TURQUOISE: RGBColor(0x00, 0xFF, 0xFF),
192
+ WD_COLOR_INDEX.BRIGHT_GREEN: RGBColor(0x00, 0xFF, 0x00),
193
+ WD_COLOR_INDEX.PINK: RGBColor(0xFF, 0x00, 0xFF),
194
+ WD_COLOR_INDEX.RED: RGBColor(0xFF, 0x00, 0x00),
195
+ WD_COLOR_INDEX.YELLOW: RGBColor(0xFF, 0xFF, 0x00),
196
+ WD_COLOR_INDEX.WHITE: RGBColor(0xFF, 0xFF, 0xFF),
197
+ WD_COLOR_INDEX.DARK_BLUE: RGBColor(0x00, 0x00, 0x80),
198
+ WD_COLOR_INDEX.TEAL: RGBColor(0x00, 0x80, 0x80),
199
+ WD_COLOR_INDEX.GREEN: RGBColor(0x00, 0x80, 0x00),
200
+ WD_COLOR_INDEX.VIOLET: RGBColor(0x80, 0x00, 0x80),
201
+ WD_COLOR_INDEX.DARK_RED: RGBColor(0x80, 0x00, 0x00),
202
+ WD_COLOR_INDEX.DARK_YELLOW: RGBColor(0x80, 0x80, 0x00),
203
+ WD_COLOR_INDEX.GRAY_50: RGBColor(0x80, 0x80, 0x80),
204
+ WD_COLOR_INDEX.GRAY_25: RGBColor(0xC0, 0xC0, 0xC0),
205
+ }
206
+ _RGB_TO_HIGHLIGHT = {
207
+ rgb: color
208
+ for color, rgb in _HIGHLIGHT_TO_RGB.items()
209
+ if color is not WD_COLOR_INDEX.AUTO
210
+ }
211
+
212
+ _SCRIPT_SUPERSCRIPT = 1
213
+ _SCRIPT_SUBSCRIPT = 2
214
+
215
+
216
+ class ColorFormat:
217
+ def __init__(self, run: "Run"):
218
+ self._run = run
219
+
220
+ @property
221
+ def rgb(self) -> RGBColor | None:
222
+ return self._run._properties.color
223
+
224
+ @rgb.setter
225
+ def rgb(self, value: RGBColor | str | tuple[int, int, int] | None) -> None:
226
+ if isinstance(value, str):
227
+ value = RGBColor.from_string(value)
228
+ elif isinstance(value, tuple):
229
+ value = RGBColor(*value)
230
+ if value is not None and not isinstance(value, RGBColor):
231
+ raise TypeError("rgb must be RGBColor, a 3-tuple, a hex string, or None")
232
+ self._run._set_property("color", value)
233
+
234
+
235
+ class Font:
236
+ def __init__(self, run: "Run"):
237
+ self._run = run
238
+
239
+ @property
240
+ def name(self) -> str | None:
241
+ return self._run._properties.font_name
242
+
243
+ @name.setter
244
+ def name(self, value: str | None) -> None:
245
+ if value is not None and not isinstance(value, str):
246
+ raise TypeError("font name must be a string or None")
247
+ self._run._set_property("font_name", value)
248
+
249
+ @property
250
+ def size(self) -> Pt | None:
251
+ value = self._run._properties.font_size
252
+ return None if value is None else Pt(value)
253
+
254
+ @size.setter
255
+ def size(self, value: Pt | float | int | None) -> None:
256
+ self._run._set_property("font_size", None if value is None else float(value))
257
+
258
+ @property
259
+ def color(self) -> ColorFormat:
260
+ return ColorFormat(self._run)
261
+
262
+ @property
263
+ def bold(self) -> bool | None:
264
+ return self._run._properties.bold
265
+
266
+ @bold.setter
267
+ def bold(self, value: bool | None) -> None:
268
+ self._run._set_bool("bold", value)
269
+
270
+ @property
271
+ def italic(self) -> bool | None:
272
+ return self._run._properties.italic
273
+
274
+ @italic.setter
275
+ def italic(self, value: bool | None) -> None:
276
+ self._run._set_bool("italic", value)
277
+
278
+ @property
279
+ def underline(self) -> bool | None:
280
+ return self._run._properties.underline
281
+
282
+ @underline.setter
283
+ def underline(self, value: bool | None) -> None:
284
+ self._run._set_bool("underline", value)
285
+
286
+ @property
287
+ def strike(self) -> bool | None:
288
+ return self._run._properties.strike
289
+
290
+ @strike.setter
291
+ def strike(self, value: bool | None) -> None:
292
+ self._run._set_bool("strike", value)
293
+
294
+ @property
295
+ def subscript(self) -> bool | None:
296
+ value = self._run._properties.vertical_align
297
+ return None if value is None else value == _SCRIPT_SUBSCRIPT
298
+
299
+ @subscript.setter
300
+ def subscript(self, value: bool | None) -> None:
301
+ self._set_script(value, _SCRIPT_SUBSCRIPT, "subscript")
302
+
303
+ @property
304
+ def superscript(self) -> bool | None:
305
+ value = self._run._properties.vertical_align
306
+ return None if value is None else value == _SCRIPT_SUPERSCRIPT
307
+
308
+ @superscript.setter
309
+ def superscript(self, value: bool | None) -> None:
310
+ self._set_script(value, _SCRIPT_SUPERSCRIPT, "superscript")
311
+
312
+ def _set_script(self, value: bool | None, target: int, name: str) -> None:
313
+ if value is not None and not isinstance(value, bool):
314
+ raise TypeError(f"{name} must be True, False, or None")
315
+ current = self._run._properties.vertical_align
316
+ if value is None:
317
+ replacement = None
318
+ elif value:
319
+ replacement = target
320
+ elif current == target:
321
+ replacement = None
322
+ else:
323
+ return
324
+ self._run._set_property("vertical_align", replacement)
325
+
326
+ @property
327
+ def highlight_color(self) -> WD_COLOR_INDEX | None:
328
+ paragraph = self._run._paragraph
329
+ if paragraph is None:
330
+ return None
331
+ return paragraph.highlight_color
332
+
333
+ @highlight_color.setter
334
+ def highlight_color(self, value: WD_COLOR_INDEX | int | None) -> None:
335
+ if value is None:
336
+ color = None
337
+ else:
338
+ try:
339
+ palette = WD_COLOR_INDEX(value)
340
+ except ValueError as error:
341
+ raise ValueError(f"unsupported highlight color: {value!r}") from error
342
+ color = (
343
+ None
344
+ if palette is WD_COLOR_INDEX.INHERITED
345
+ else _HIGHLIGHT_TO_RGB[palette]
346
+ )
347
+ paragraph = self._run._paragraph
348
+ if paragraph is None:
349
+ raise NotImplementedError(
350
+ "Pages character background is paragraph-scoped; this run has no paragraph"
351
+ )
352
+ paragraph.background_color = color
353
+
354
+
355
+ class Run:
356
+ def __init__(
357
+ self,
358
+ document: "Document",
359
+ start_utf16: int,
360
+ end_utf16: int,
361
+ paragraph: "Paragraph | None" = None,
362
+ ):
363
+ self._document = document
364
+ self._start_utf16 = start_utf16
365
+ self._end_utf16 = end_utf16
366
+ self._paragraph = paragraph
367
+
368
+ @property
369
+ def text(self) -> str:
370
+ return self._document._text_between(self._start_utf16, self._end_utf16)
371
+
372
+ @text.setter
373
+ def text(self, value: str) -> None:
374
+ if "\n" in value or "\r" in value:
375
+ raise ValueError("Run.text cannot contain paragraph separators")
376
+ delta = self._document._replace_text(self._start_utf16, self._end_utf16, value)
377
+ self._end_utf16 += delta
378
+ if self._paragraph is not None:
379
+ self._paragraph._end_utf16 += delta
380
+ self._paragraph._next_start_utf16 += delta
381
+
382
+ def clear(self) -> "Run":
383
+ """Remove this run's text while retaining its character formatting."""
384
+ self.text = ""
385
+ return self
386
+
387
+ def delete(self) -> None:
388
+ """Remove this run's text from its paragraph."""
389
+ self.clear()
390
+
391
+ @property
392
+ def _properties(self) -> CharacterProperties:
393
+ return self._document._properties_at(self._start_utf16)
394
+
395
+ def _set_property(self, name: str, value) -> None:
396
+ props = self._properties.with_value(name, value)
397
+ self._document._apply_properties(self._start_utf16, self._end_utf16, props)
398
+
399
+ def _set_bool(self, name: str, value: bool | None) -> None:
400
+ if value is not None and not isinstance(value, bool):
401
+ raise TypeError(f"{name} must be True, False, or None")
402
+ self._set_property(name, value)
403
+
404
+ @property
405
+ def font(self) -> Font:
406
+ return Font(self)
407
+
408
+ @property
409
+ def bold(self) -> bool | None:
410
+ return self.font.bold
411
+
412
+ @bold.setter
413
+ def bold(self, value: bool | None) -> None:
414
+ self.font.bold = value
415
+
416
+ @property
417
+ def italic(self) -> bool | None:
418
+ return self.font.italic
419
+
420
+ @italic.setter
421
+ def italic(self, value: bool | None) -> None:
422
+ self.font.italic = value
423
+
424
+ @property
425
+ def underline(self) -> bool | None:
426
+ return self.font.underline
427
+
428
+ @underline.setter
429
+ def underline(self, value: bool | None) -> None:
430
+ self.font.underline = value
431
+
432
+ @property
433
+ def style(self) -> "Style | None":
434
+ return self._document._style_from_id(
435
+ self._document._character_style_id_at(self._start_utf16),
436
+ WD_STYLE_TYPE.CHARACTER,
437
+ )
438
+
439
+ @style.setter
440
+ def style(self, value: "Style | str | None") -> None:
441
+ identifier = self._document._coerce_style_id(
442
+ value, WD_STYLE_TYPE.CHARACTER, none_identifier=None
443
+ )
444
+ self._document._apply_character_style_id(
445
+ self._start_utf16, self._end_utf16, identifier
446
+ )
447
+
448
+
449
+ class TabStop:
450
+ def __init__(self, spec: ParagraphTab):
451
+ self._spec = spec
452
+
453
+ @property
454
+ def position(self) -> Pt:
455
+ return Pt(self._spec.position)
456
+
457
+ @property
458
+ def alignment(self) -> WD_TAB_ALIGNMENT:
459
+ return WD_TAB_ALIGNMENT(self._spec.alignment)
460
+
461
+ @property
462
+ def leader(self) -> WD_TAB_LEADER:
463
+ return _STRING_TO_LEADER.get(self._spec.leader, WD_TAB_LEADER.SPACES)
464
+
465
+
466
+ class TabStops:
467
+ def __init__(self, paragraph: "Paragraph"):
468
+ self._paragraph = paragraph
469
+
470
+ def _specs(self) -> tuple[ParagraphTab, ...]:
471
+ return self._paragraph._paragraph_properties.tabs or ()
472
+
473
+ def __len__(self) -> int:
474
+ return len(self._specs())
475
+
476
+ def __iter__(self):
477
+ return iter([TabStop(spec) for spec in self._specs()])
478
+
479
+ def __getitem__(self, index: int) -> TabStop:
480
+ return TabStop(self._specs()[index])
481
+
482
+ def add_tab_stop(
483
+ self,
484
+ position: Length | float,
485
+ alignment: WD_TAB_ALIGNMENT = WD_TAB_ALIGNMENT.LEFT,
486
+ leader: WD_TAB_LEADER = WD_TAB_LEADER.SPACES,
487
+ ) -> TabStop:
488
+ try:
489
+ alignment = WD_TAB_ALIGNMENT(alignment)
490
+ leader = WD_TAB_LEADER(leader)
491
+ except ValueError as error:
492
+ raise ValueError("unsupported tab alignment or leader") from error
493
+ spec = ParagraphTab(float(position), int(alignment), _LEADER_TO_STRING[leader])
494
+ values = tuple(sorted(self._specs() + (spec,), key=lambda item: item.position))
495
+ self._paragraph._set_paragraph_property("tabs", values)
496
+ return TabStop(spec)
497
+
498
+ def clear_all(self) -> None:
499
+ self._paragraph._set_paragraph_property("tabs", None)
500
+
501
+
502
+ class ParagraphFormat:
503
+ def __init__(self, paragraph: "Paragraph"):
504
+ self._paragraph = paragraph
505
+
506
+ @property
507
+ def alignment(self) -> WD_PARAGRAPH_ALIGNMENT | None:
508
+ value = self._paragraph._paragraph_properties.alignment
509
+ return _PAGES_TO_API_ALIGNMENT.get(value)
510
+
511
+ @alignment.setter
512
+ def alignment(self, value: WD_PARAGRAPH_ALIGNMENT | None) -> None:
513
+ if value is None:
514
+ encoded = None
515
+ else:
516
+ try:
517
+ encoded = _API_TO_PAGES_ALIGNMENT[WD_PARAGRAPH_ALIGNMENT(value)]
518
+ except (KeyError, ValueError) as error:
519
+ raise ValueError(f"unsupported paragraph alignment: {value!r}") from error
520
+ self._paragraph._set_paragraph_property("alignment", encoded)
521
+
522
+ @staticmethod
523
+ def _length(value: float | None) -> Pt | None:
524
+ return None if value is None else Pt(value)
525
+
526
+ @property
527
+ def first_line_indent(self) -> Pt | None:
528
+ return self._length(self._paragraph._paragraph_properties.first_line_indent)
529
+
530
+ @first_line_indent.setter
531
+ def first_line_indent(self, value: Length | float | None) -> None:
532
+ self._paragraph._set_paragraph_property(
533
+ "first_line_indent", None if value is None else float(value)
534
+ )
535
+
536
+ @property
537
+ def left_indent(self) -> Pt | None:
538
+ return self._length(self._paragraph._paragraph_properties.left_indent)
539
+
540
+ @left_indent.setter
541
+ def left_indent(self, value: Length | float | None) -> None:
542
+ self._paragraph._set_paragraph_property(
543
+ "left_indent", None if value is None else float(value)
544
+ )
545
+
546
+ @property
547
+ def right_indent(self) -> Pt | None:
548
+ return self._length(self._paragraph._paragraph_properties.right_indent)
549
+
550
+ @right_indent.setter
551
+ def right_indent(self, value: Length | float | None) -> None:
552
+ self._paragraph._set_paragraph_property(
553
+ "right_indent", None if value is None else float(value)
554
+ )
555
+
556
+ @property
557
+ def space_before(self) -> Pt | None:
558
+ return self._length(self._paragraph._paragraph_properties.space_before)
559
+
560
+ @space_before.setter
561
+ def space_before(self, value: Length | float | None) -> None:
562
+ self._paragraph._set_paragraph_property(
563
+ "space_before", None if value is None else float(value)
564
+ )
565
+
566
+ @property
567
+ def space_after(self) -> Pt | None:
568
+ return self._length(self._paragraph._paragraph_properties.space_after)
569
+
570
+ @space_after.setter
571
+ def space_after(self, value: Length | float | None) -> None:
572
+ self._paragraph._set_paragraph_property(
573
+ "space_after", None if value is None else float(value)
574
+ )
575
+
576
+ @property
577
+ def line_spacing(self) -> float | Pt | None:
578
+ spacing = self._paragraph._paragraph_properties.line_spacing
579
+ if spacing is None:
580
+ return None
581
+ return spacing.amount if spacing.mode == 0 else Pt(spacing.amount)
582
+
583
+ @line_spacing.setter
584
+ def line_spacing(self, value: Length | float | None) -> None:
585
+ if value is None:
586
+ spacing = None
587
+ elif isinstance(value, Length):
588
+ spacing = LineSpacing(2, float(value))
589
+ else:
590
+ spacing = LineSpacing(0, float(value))
591
+ self._paragraph._set_paragraph_property("line_spacing", spacing)
592
+
593
+ @property
594
+ def line_spacing_rule(self) -> WD_LINE_SPACING | None:
595
+ spacing = self._paragraph._paragraph_properties.line_spacing
596
+ if spacing is None:
597
+ return None
598
+ if spacing.mode == 0:
599
+ if spacing.amount == 1.0:
600
+ return WD_LINE_SPACING.SINGLE
601
+ if spacing.amount == 1.5:
602
+ return WD_LINE_SPACING.ONE_POINT_FIVE
603
+ if spacing.amount == 2.0:
604
+ return WD_LINE_SPACING.DOUBLE
605
+ return WD_LINE_SPACING.MULTIPLE
606
+ if spacing.mode == 1:
607
+ return WD_LINE_SPACING.AT_LEAST
608
+ if spacing.mode == 2:
609
+ return WD_LINE_SPACING.EXACTLY
610
+ return WD_LINE_SPACING.MULTIPLE
611
+
612
+ @line_spacing_rule.setter
613
+ def line_spacing_rule(self, value: WD_LINE_SPACING | None) -> None:
614
+ if value is None:
615
+ self._paragraph._set_paragraph_property("line_spacing", None)
616
+ return
617
+ value = WD_LINE_SPACING(value)
618
+ current = self._paragraph._paragraph_properties.line_spacing
619
+ if value == WD_LINE_SPACING.SINGLE:
620
+ spacing = LineSpacing(0, 1.0)
621
+ elif value == WD_LINE_SPACING.ONE_POINT_FIVE:
622
+ spacing = LineSpacing(0, 1.5)
623
+ elif value == WD_LINE_SPACING.DOUBLE:
624
+ spacing = LineSpacing(0, 2.0)
625
+ elif value == WD_LINE_SPACING.AT_LEAST:
626
+ spacing = LineSpacing(1, current.amount if current else 12.0)
627
+ elif value == WD_LINE_SPACING.EXACTLY:
628
+ spacing = LineSpacing(2, current.amount if current else 12.0)
629
+ else:
630
+ spacing = LineSpacing(0, current.amount if current else 1.0)
631
+ self._paragraph._set_paragraph_property("line_spacing", spacing)
632
+
633
+ @property
634
+ def tab_stops(self) -> TabStops:
635
+ return TabStops(self._paragraph)
636
+
637
+
638
+ class Paragraph:
639
+ def __init__(
640
+ self,
641
+ document: "Document",
642
+ start_utf16: int,
643
+ end_utf16: int,
644
+ next_start_utf16: int,
645
+ ):
646
+ self._document = document
647
+ self._start_utf16 = start_utf16
648
+ self._end_utf16 = end_utf16
649
+ self._next_start_utf16 = next_start_utf16
650
+
651
+ @property
652
+ def style_id(self) -> int | None:
653
+ return self._document._paragraph_style_id_at(self._start_utf16)
654
+
655
+ @property
656
+ def style(self) -> "Style | None":
657
+ return self._document._style_from_id(
658
+ self.style_id, WD_STYLE_TYPE.PARAGRAPH
659
+ )
660
+
661
+ @style.setter
662
+ def style(self, value: "Style | str | None") -> None:
663
+ default = self._document._package.stylesheet().default_paragraph_style()
664
+ identifier = self._document._coerce_style_id(
665
+ value,
666
+ WD_STYLE_TYPE.PARAGRAPH,
667
+ none_identifier=default.segment.identifier,
668
+ )
669
+ self._document._apply_paragraph_style_id(
670
+ self._start_utf16, self._next_start_utf16, identifier
671
+ )
672
+
673
+ @property
674
+ def _paragraph_properties(self) -> ParagraphProperties:
675
+ return self._document._paragraph_properties_at(self._start_utf16)
676
+
677
+ @property
678
+ def background_color(self) -> RGBColor | None:
679
+ """Direct Pages character background carried by this paragraph style."""
680
+
681
+ return self._document._paragraph_character_properties_at(
682
+ self._start_utf16
683
+ ).background_color
684
+
685
+ @background_color.setter
686
+ def background_color(
687
+ self, value: RGBColor | str | tuple[int, int, int] | None
688
+ ) -> None:
689
+ if isinstance(value, str):
690
+ value = RGBColor.from_string(value)
691
+ elif isinstance(value, tuple):
692
+ value = RGBColor(*value)
693
+ if value is not None and not isinstance(value, RGBColor):
694
+ raise TypeError(
695
+ "background_color must be RGBColor, a 3-tuple, a hex string, or None"
696
+ )
697
+ self._document._apply_paragraph_background(
698
+ self._start_utf16,
699
+ self._next_start_utf16,
700
+ value,
701
+ )
702
+
703
+ @property
704
+ def highlight_color(self) -> WD_COLOR_INDEX | None:
705
+ color = self.background_color
706
+ return None if color is None else _RGB_TO_HIGHLIGHT.get(color)
707
+
708
+ @highlight_color.setter
709
+ def highlight_color(self, value: WD_COLOR_INDEX | int | None) -> None:
710
+ if value is None:
711
+ color = None
712
+ else:
713
+ try:
714
+ palette = WD_COLOR_INDEX(value)
715
+ except ValueError as error:
716
+ raise ValueError(f"unsupported highlight color: {value!r}") from error
717
+ color = (
718
+ None
719
+ if palette is WD_COLOR_INDEX.INHERITED
720
+ else _HIGHLIGHT_TO_RGB[palette]
721
+ )
722
+ self.background_color = color
723
+
724
+ def _set_paragraph_property(self, name: str, value) -> None:
725
+ props = self._paragraph_properties.with_value(name, value)
726
+ self._document._apply_paragraph_properties(
727
+ self._start_utf16,
728
+ self._next_start_utf16,
729
+ props,
730
+ )
731
+
732
+ @property
733
+ def text(self) -> str:
734
+ return self._document._text_between(self._start_utf16, self._end_utf16)
735
+
736
+ @property
737
+ def runs(self) -> list[Run]:
738
+ if self._start_utf16 == self._end_utf16:
739
+ return []
740
+ boundaries = {self._start_utf16, self._end_utf16}
741
+ boundaries.update(
742
+ run.character_index
743
+ for run in self._document._character_runs()
744
+ if self._start_utf16 < run.character_index < self._end_utf16
745
+ )
746
+ ordered = sorted(boundaries)
747
+ return [
748
+ Run(self._document, start, end, self)
749
+ for start, end in zip(ordered, ordered[1:])
750
+ ]
751
+
752
+ @property
753
+ def hyperlinks(self) -> list["Hyperlink"]:
754
+ return [
755
+ hyperlink
756
+ for hyperlink in self._document.hyperlinks
757
+ if self._start_utf16 <= hyperlink._start_utf16
758
+ and hyperlink._end_utf16 <= self._end_utf16
759
+ ]
760
+
761
+ def add_run(self, text: str | None = None, style=None) -> Run:
762
+ value = "" if text is None else text
763
+ if not isinstance(value, str):
764
+ raise TypeError("run text must be a string or None")
765
+ if "\n" in value or "\r" in value:
766
+ raise ValueError("add_run text cannot contain paragraph separators")
767
+ start = self._end_utf16
768
+ delta = self._document._replace_text(start, start, value)
769
+ self._end_utf16 += delta
770
+ self._next_start_utf16 += delta
771
+ run = Run(self._document, start, start + delta, self)
772
+ if style is not None:
773
+ run.style = style
774
+ elif delta:
775
+ # Inserting at an existing run boundary shifts that boundary with
776
+ # the new text. A python-docx add_run() without an explicit style
777
+ # starts with inherited formatting, so restore the no-direct-style
778
+ # boundary across the inserted range.
779
+ self._document._apply_character_style_id(start, start + delta, None)
780
+ return run
781
+
782
+ def clear(self) -> "Paragraph":
783
+ """Remove paragraph content while retaining its paragraph mark and style."""
784
+ delta = self._document._replace_text(
785
+ self._start_utf16, self._end_utf16, ""
786
+ )
787
+ self._end_utf16 += delta
788
+ self._next_start_utf16 += delta
789
+ return self
790
+
791
+ def delete(self) -> None:
792
+ """Remove this paragraph and its paragraph separator when possible."""
793
+ paragraphs = self._document.paragraphs
794
+ if len(paragraphs) == 1:
795
+ self.clear()
796
+ return
797
+ position = next(
798
+ (
799
+ index
800
+ for index, paragraph in enumerate(paragraphs)
801
+ if paragraph._start_utf16 == self._start_utf16
802
+ ),
803
+ None,
804
+ )
805
+ if position is None:
806
+ raise ValueError("paragraph no longer exists in the document")
807
+ if position < len(paragraphs) - 1:
808
+ start, end = self._start_utf16, self._next_start_utf16
809
+ else:
810
+ start, end = self._start_utf16 - 1, self._end_utf16
811
+ self._document._replace_text(start, end, "")
812
+ self._end_utf16 = self._start_utf16
813
+ self._next_start_utf16 = self._start_utf16
814
+
815
+ @property
816
+ def paragraph_format(self) -> ParagraphFormat:
817
+ return ParagraphFormat(self)
818
+
819
+ @property
820
+ def alignment(self) -> WD_PARAGRAPH_ALIGNMENT | None:
821
+ return self.paragraph_format.alignment
822
+
823
+ @alignment.setter
824
+ def alignment(self, value: WD_PARAGRAPH_ALIGNMENT | None) -> None:
825
+ self.paragraph_format.alignment = value
826
+
827
+
828
+ class HeaderFooterParagraph:
829
+ """A Pages header/footer fragment exposed with paragraph text semantics."""
830
+
831
+ def __init__(self, layout: DocumentLayout, storage):
832
+ self._layout = layout
833
+ self._storage = storage
834
+
835
+ @property
836
+ def text(self) -> str:
837
+ return self._layout.storage_text(self._storage)
838
+
839
+ @text.setter
840
+ def text(self, value: str) -> None:
841
+ self._layout.set_storage_text(self._storage, value)
842
+
843
+
844
+ class HeaderFooter:
845
+ """Header/footer proxy over Pages' left, center, and right fragments."""
846
+
847
+ def __init__(self, layout: DocumentLayout, kind: str, *, footer: bool):
848
+ self._layout = layout
849
+ self._kind = kind
850
+ self._footer = footer
851
+
852
+ @property
853
+ def paragraphs(self) -> list[HeaderFooterParagraph]:
854
+ return [
855
+ HeaderFooterParagraph(self._layout, storage)
856
+ for storage in self._layout.header_footer_storages(
857
+ self._kind, footer=self._footer
858
+ )
859
+ ]
860
+
861
+ @property
862
+ def text(self) -> str:
863
+ return "\t".join(paragraph.text for paragraph in self.paragraphs)
864
+
865
+ @text.setter
866
+ def text(self, value: str) -> None:
867
+ self.paragraphs[0].text = value
868
+
869
+ @property
870
+ def is_linked_to_previous(self) -> bool:
871
+ return False
872
+
873
+ @is_linked_to_previous.setter
874
+ def is_linked_to_previous(self, value: bool) -> None:
875
+ if value:
876
+ raise NotImplementedError("Pages single-section mode has no previous section")
877
+
878
+
879
+ class Section:
880
+ """python-docx-shaped facade over the package-wide Pages page settings."""
881
+
882
+ _LENGTH_FIELDS = {
883
+ "page_width": DOCUMENT_PAGE_WIDTH,
884
+ "page_height": DOCUMENT_PAGE_HEIGHT,
885
+ "left_margin": DOCUMENT_LEFT_MARGIN,
886
+ "right_margin": DOCUMENT_RIGHT_MARGIN,
887
+ "top_margin": DOCUMENT_TOP_MARGIN,
888
+ "bottom_margin": DOCUMENT_BOTTOM_MARGIN,
889
+ "header_distance": DOCUMENT_HEADER_MARGIN,
890
+ "footer_distance": DOCUMENT_FOOTER_MARGIN,
891
+ }
892
+
893
+ def __init__(self, layout: DocumentLayout):
894
+ self._layout = layout
895
+
896
+ def _length(self, name: str) -> Pt | None:
897
+ value = self._layout.fixed32(self._LENGTH_FIELDS[name])
898
+ return None if value is None else Pt(value)
899
+
900
+ def _set_length(self, name: str, value: Length | float | None) -> None:
901
+ self._layout.set_fixed32(
902
+ self._LENGTH_FIELDS[name], None if value is None else float(value)
903
+ )
904
+
905
+ @property
906
+ def page_width(self) -> Pt | None:
907
+ return self._length("page_width")
908
+
909
+ @page_width.setter
910
+ def page_width(self, value: Length | float | None) -> None:
911
+ self._set_length("page_width", value)
912
+
913
+ @property
914
+ def page_height(self) -> Pt | None:
915
+ return self._length("page_height")
916
+
917
+ @page_height.setter
918
+ def page_height(self, value: Length | float | None) -> None:
919
+ self._set_length("page_height", value)
920
+
921
+ @property
922
+ def left_margin(self) -> Pt | None:
923
+ return self._length("left_margin")
924
+
925
+ @left_margin.setter
926
+ def left_margin(self, value: Length | float | None) -> None:
927
+ self._set_length("left_margin", value)
928
+
929
+ @property
930
+ def right_margin(self) -> Pt | None:
931
+ return self._length("right_margin")
932
+
933
+ @right_margin.setter
934
+ def right_margin(self, value: Length | float | None) -> None:
935
+ self._set_length("right_margin", value)
936
+
937
+ @property
938
+ def top_margin(self) -> Pt | None:
939
+ return self._length("top_margin")
940
+
941
+ @top_margin.setter
942
+ def top_margin(self, value: Length | float | None) -> None:
943
+ self._set_length("top_margin", value)
944
+
945
+ @property
946
+ def bottom_margin(self) -> Pt | None:
947
+ return self._length("bottom_margin")
948
+
949
+ @bottom_margin.setter
950
+ def bottom_margin(self, value: Length | float | None) -> None:
951
+ self._set_length("bottom_margin", value)
952
+
953
+ @property
954
+ def header_distance(self) -> Pt | None:
955
+ return self._length("header_distance")
956
+
957
+ @header_distance.setter
958
+ def header_distance(self, value: Length | float | None) -> None:
959
+ self._set_length("header_distance", value)
960
+
961
+ @property
962
+ def footer_distance(self) -> Pt | None:
963
+ return self._length("footer_distance")
964
+
965
+ @footer_distance.setter
966
+ def footer_distance(self, value: Length | float | None) -> None:
967
+ self._set_length("footer_distance", value)
968
+
969
+ @property
970
+ def orientation(self) -> WD_ORIENTATION:
971
+ return WD_ORIENTATION(self._layout.varint(DOCUMENT_ORIENTATION))
972
+
973
+ @orientation.setter
974
+ def orientation(self, value: WD_ORIENTATION | None) -> None:
975
+ self._layout.set_varint(
976
+ DOCUMENT_ORIENTATION,
977
+ None if value is None else int(WD_ORIENTATION(value)),
978
+ )
979
+
980
+ @property
981
+ def different_first_page_header_footer(self) -> bool:
982
+ return self._layout.section_flag(SECTION_FIRST_PAGE_DIFFERENT)
983
+
984
+ @different_first_page_header_footer.setter
985
+ def different_first_page_header_footer(self, value: bool) -> None:
986
+ if not isinstance(value, bool):
987
+ raise TypeError("different_first_page_header_footer must be bool")
988
+ self._layout.set_section_flag(SECTION_FIRST_PAGE_DIFFERENT, value)
989
+
990
+ @property
991
+ def header(self) -> HeaderFooter:
992
+ return HeaderFooter(self._layout, "primary", footer=False)
993
+
994
+ @property
995
+ def footer(self) -> HeaderFooter:
996
+ return HeaderFooter(self._layout, "primary", footer=True)
997
+
998
+ @property
999
+ def first_page_header(self) -> HeaderFooter:
1000
+ return HeaderFooter(self._layout, "first", footer=False)
1001
+
1002
+ @property
1003
+ def first_page_footer(self) -> HeaderFooter:
1004
+ return HeaderFooter(self._layout, "first", footer=True)
1005
+
1006
+ @property
1007
+ def even_page_header(self) -> HeaderFooter:
1008
+ return HeaderFooter(self._layout, "even", footer=False)
1009
+
1010
+ @property
1011
+ def even_page_footer(self) -> HeaderFooter:
1012
+ return HeaderFooter(self._layout, "even", footer=True)
1013
+
1014
+
1015
+ class TableCellFont:
1016
+ """Read-only font facade for a table cell's effective text style."""
1017
+
1018
+ def __init__(self, run: "TableCellRun"):
1019
+ self._run = run
1020
+
1021
+ @property
1022
+ def bold(self) -> bool | None:
1023
+ return self._run._properties.bold
1024
+
1025
+ @property
1026
+ def italic(self) -> bool | None:
1027
+ return self._run._properties.italic
1028
+
1029
+ @property
1030
+ def underline(self) -> bool | None:
1031
+ return self._run._properties.underline
1032
+
1033
+ @property
1034
+ def strike(self) -> bool | None:
1035
+ return self._run._properties.strike
1036
+
1037
+ @property
1038
+ def name(self) -> str | None:
1039
+ return self._run._properties.font_name
1040
+
1041
+ @property
1042
+ def size(self) -> Pt | None:
1043
+ value = self._run._properties.font_size
1044
+ return None if value is None else Pt(value)
1045
+
1046
+
1047
+ class TableCellRun:
1048
+ def __init__(self, cell: "TableCell"):
1049
+ self._cell = cell
1050
+
1051
+ @property
1052
+ def text(self) -> str:
1053
+ return self._cell.text
1054
+
1055
+ @text.setter
1056
+ def text(self, value: str) -> None:
1057
+ self._cell.text = value
1058
+
1059
+ @property
1060
+ def _properties(self) -> CharacterProperties:
1061
+ return self._cell._properties
1062
+
1063
+ @property
1064
+ def font(self) -> TableCellFont:
1065
+ return TableCellFont(self)
1066
+
1067
+ @property
1068
+ def bold(self) -> bool | None:
1069
+ return self.font.bold
1070
+
1071
+ @property
1072
+ def italic(self) -> bool | None:
1073
+ return self.font.italic
1074
+
1075
+ @property
1076
+ def underline(self) -> bool | None:
1077
+ return self.font.underline
1078
+
1079
+
1080
+ class TableCellParagraph:
1081
+ def __init__(self, cell: "TableCell"):
1082
+ self._cell = cell
1083
+
1084
+ @property
1085
+ def text(self) -> str:
1086
+ return self._cell.text
1087
+
1088
+ @property
1089
+ def runs(self) -> list[TableCellRun]:
1090
+ return [] if not self.text else [TableCellRun(self._cell)]
1091
+
1092
+
1093
+ class TableCell:
1094
+ """python-docx-shaped cell facade over one TST tile coordinate."""
1095
+
1096
+ def __init__(self, table: "Table", row: int, column: int):
1097
+ self._table = table
1098
+ self._row = row
1099
+ self._column = column
1100
+
1101
+ @property
1102
+ def _storage(self) -> CellStorage | None:
1103
+ return self._table._store.cell_storage(self._row, self._column)
1104
+
1105
+ @property
1106
+ def _properties(self) -> CharacterProperties:
1107
+ return self._table._store.character_properties(
1108
+ self._row, self._column, self._storage
1109
+ )
1110
+
1111
+ @property
1112
+ def text(self) -> str:
1113
+ storage = self._storage
1114
+ if storage is None:
1115
+ return ""
1116
+ if storage.cell_type != 3 or storage.string_id is None:
1117
+ raise NotImplementedError("this TST cell value type is not textual")
1118
+ return self._table._store.string(storage.string_id)
1119
+
1120
+ @text.setter
1121
+ def text(self, value: str) -> None:
1122
+ if not isinstance(value, str):
1123
+ raise TypeError("cell text must be a string")
1124
+ storage = self._storage
1125
+ if storage is None:
1126
+ raise NotImplementedError(
1127
+ "creating storage for an omitted empty cell is deferred"
1128
+ )
1129
+ if storage.cell_type != 3 or storage.string_id is None:
1130
+ raise NotImplementedError("retyping non-text TST cells is deferred")
1131
+ self._table._store.set_string(storage.string_id, value)
1132
+
1133
+ @property
1134
+ def paragraphs(self) -> list[TableCellParagraph]:
1135
+ return [TableCellParagraph(self)]
1136
+
1137
+ @property
1138
+ def runs(self) -> list[TableCellRun]:
1139
+ return self.paragraphs[0].runs
1140
+
1141
+
1142
+ class TableRow:
1143
+ def __init__(self, table: "Table", index: int):
1144
+ self._table = table
1145
+ self._index = index
1146
+
1147
+ @property
1148
+ def cells(self) -> list[TableCell]:
1149
+ return [
1150
+ self._table.cell(self._index, column)
1151
+ for column in range(len(self._table.columns))
1152
+ ]
1153
+
1154
+
1155
+ class TableColumn:
1156
+ def __init__(self, table: "Table", index: int):
1157
+ self._table = table
1158
+ self._index = index
1159
+
1160
+ @property
1161
+ def cells(self) -> list[TableCell]:
1162
+ return [
1163
+ self._table.cell(row, self._index)
1164
+ for row in range(len(self._table.rows))
1165
+ ]
1166
+
1167
+
1168
+ class Table:
1169
+ def __init__(self, store: TableStore):
1170
+ self._store = store
1171
+
1172
+ @property
1173
+ def name(self) -> str:
1174
+ return self._store.name
1175
+
1176
+ @property
1177
+ def rows(self) -> list[TableRow]:
1178
+ return [TableRow(self, row) for row in range(self._store.rows)]
1179
+
1180
+ @property
1181
+ def columns(self) -> list[TableColumn]:
1182
+ return [TableColumn(self, column) for column in range(self._store.columns)]
1183
+
1184
+ def cell(self, row: int, column: int) -> TableCell:
1185
+ if (
1186
+ not 0 <= row < self._store.rows
1187
+ or not 0 <= column < self._store.columns
1188
+ ):
1189
+ raise IndexError("table cell index is out of range")
1190
+ return TableCell(self, row, column)
1191
+
1192
+
1193
+ class InlineShape:
1194
+ """Inline Pages image with package-backed media access."""
1195
+
1196
+ def __init__(self, store: ImageStore):
1197
+ self._store = store
1198
+
1199
+ @property
1200
+ def filename(self) -> str:
1201
+ return self._store.filename
1202
+
1203
+ @property
1204
+ def blob(self) -> bytes:
1205
+ return self._store.blob
1206
+
1207
+ @property
1208
+ def thumbnail_blob(self) -> bytes | None:
1209
+ return self._store.thumbnail_blob
1210
+
1211
+ @property
1212
+ def width(self) -> Pt | None:
1213
+ size = self._store.natural_size
1214
+ return None if size is None else Pt(size[0])
1215
+
1216
+ @property
1217
+ def height(self) -> Pt | None:
1218
+ size = self._store.natural_size
1219
+ return None if size is None else Pt(size[1])
1220
+
1221
+ def replace(
1222
+ self,
1223
+ path: str | Path,
1224
+ thumbnail_path: str | Path | None = None,
1225
+ ) -> "InlineShape":
1226
+ thumbnail = (
1227
+ None if thumbnail_path is None else Path(thumbnail_path).read_bytes()
1228
+ )
1229
+ self._store.replace(Path(path).read_bytes(), thumbnail)
1230
+ return self
1231
+
1232
+
1233
+ class Hyperlink:
1234
+ """Text-range facade for a TSWP.HyperlinkFieldArchive."""
1235
+
1236
+ def __init__(self, document: "Document", store: HyperlinkStore):
1237
+ self._document = document
1238
+ self._store = store
1239
+ self._start_utf16 = store.start_utf16
1240
+ self._end_utf16 = store.end_utf16
1241
+
1242
+ @property
1243
+ def text(self) -> str:
1244
+ return self._document._text_between(self._start_utf16, self._end_utf16)
1245
+
1246
+ @text.setter
1247
+ def text(self, value: str) -> None:
1248
+ if not isinstance(value, str):
1249
+ raise TypeError("hyperlink text must be a string")
1250
+ if "\n" in value or "\r" in value:
1251
+ raise ValueError("hyperlink text cannot contain paragraph separators")
1252
+ delta = self._document._replace_text(
1253
+ self._start_utf16, self._end_utf16, value
1254
+ )
1255
+ self._end_utf16 += delta
1256
+
1257
+ @property
1258
+ def url(self) -> str:
1259
+ return self._store.url
1260
+
1261
+ @url.setter
1262
+ def url(self, value: str) -> None:
1263
+ self._store.url = value
1264
+
1265
+ @property
1266
+ def address(self) -> str:
1267
+ return self.url
1268
+
1269
+ @address.setter
1270
+ def address(self, value: str) -> None:
1271
+ self.url = value
1272
+
1273
+ @property
1274
+ def runs(self) -> list[Run]:
1275
+ return [Run(self._document, self._start_utf16, self._end_utf16)]
1276
+
1277
+
1278
+ class Style:
1279
+ """Named Pages style exposed with the common python-docx attributes."""
1280
+
1281
+ def __init__(self, document: "Document", obj):
1282
+ self._document = document
1283
+ self._obj = obj
1284
+
1285
+ @property
1286
+ def name(self) -> str:
1287
+ return style_name(self._obj) or str(self._obj.segment.identifier)
1288
+
1289
+ @property
1290
+ def style_id(self) -> str:
1291
+ return style_identifier(self._obj) or str(self._obj.segment.identifier)
1292
+
1293
+ @property
1294
+ def type(self) -> WD_STYLE_TYPE:
1295
+ return (
1296
+ WD_STYLE_TYPE.CHARACTER
1297
+ if self._obj.info.type_id == TYPE_CHARACTER_STYLE
1298
+ else WD_STYLE_TYPE.PARAGRAPH
1299
+ )
1300
+
1301
+
1302
+ class Styles:
1303
+ def __init__(self, document: "Document"):
1304
+ self._document = document
1305
+
1306
+ def _items(self) -> list[Style]:
1307
+ return [
1308
+ Style(self._document, obj)
1309
+ for obj in self._document._package.stylesheet().named_styles()
1310
+ ]
1311
+
1312
+ def __len__(self) -> int:
1313
+ return len(self._items())
1314
+
1315
+ def __iter__(self):
1316
+ return iter(self._items())
1317
+
1318
+ def __getitem__(self, name: str) -> Style:
1319
+ for style in self._items():
1320
+ if style.name == name:
1321
+ return style
1322
+ raise KeyError(f"style {name!r} was not found")
1323
+
1324
+
1325
+ class Comment:
1326
+ """Pages-backed comment or reply."""
1327
+
1328
+ def __init__(self, document: "Document", store: CommentStore):
1329
+ self._document = document
1330
+ self._store = store
1331
+
1332
+ @property
1333
+ def comment_id(self) -> int:
1334
+ return self._store.comment_id
1335
+
1336
+ @property
1337
+ def text(self) -> str:
1338
+ return self._store.text
1339
+
1340
+ @text.setter
1341
+ def text(self, value: str) -> None:
1342
+ self._store.text = value
1343
+
1344
+ @property
1345
+ def author(self) -> str:
1346
+ return self._store.author
1347
+
1348
+ @property
1349
+ def annotation_color(self) -> RGBColor | None:
1350
+ """Review-highlight color assigned to this comment's author."""
1351
+ return self._store.annotation_color
1352
+
1353
+ @property
1354
+ def initials(self) -> None:
1355
+ """Pages stores no separate initials value for an annotation author."""
1356
+ return None
1357
+
1358
+ @property
1359
+ def timestamp(self):
1360
+ return self._store.timestamp
1361
+
1362
+ @property
1363
+ def replies(self) -> list["Comment"]:
1364
+ return [Comment(self._document, reply) for reply in self._store.replies]
1365
+
1366
+ @property
1367
+ def anchor_start(self) -> int | None:
1368
+ return self._store.start_utf16
1369
+
1370
+ @property
1371
+ def anchor_end(self) -> int | None:
1372
+ return self._store.end_utf16
1373
+
1374
+ @property
1375
+ def anchor_text(self) -> str:
1376
+ if self.anchor_start is None or self.anchor_end is None:
1377
+ return ""
1378
+ return self._document._text_between(self.anchor_start, self.anchor_end)
1379
+
1380
+
1381
+ class Comments:
1382
+ """Collection of root comments anchored in the Pages body storage."""
1383
+
1384
+ def __init__(self, document: "Document"):
1385
+ self._document = document
1386
+
1387
+ def _items(self) -> list[Comment]:
1388
+ return [
1389
+ Comment(self._document, store)
1390
+ for store in storage_comments(
1391
+ self._document._package, self._document._storage
1392
+ )
1393
+ ]
1394
+
1395
+ def __len__(self) -> int:
1396
+ return len(self._items())
1397
+
1398
+ def __iter__(self):
1399
+ return iter(self._items())
1400
+
1401
+ def __getitem__(self, index: int) -> Comment:
1402
+ return self._items()[index]
1403
+
1404
+ def get(self, comment_id: int) -> Comment | None:
1405
+ return next(
1406
+ (item for item in self._items() if item.comment_id == comment_id),
1407
+ None,
1408
+ )
1409
+
1410
+ def add_comment(self, *args, **kwargs):
1411
+ raise NotImplementedError(
1412
+ "creating a Pages comment requires author and collaboration-state "
1413
+ "graphs beyond the anchored comment objects"
1414
+ )
1415
+
1416
+
1417
+ class AnnotationHighlight:
1418
+ """Review annotation range associated with one Pages comment."""
1419
+
1420
+ def __init__(self, comment: Comment):
1421
+ self._comment = comment
1422
+
1423
+ @property
1424
+ def start_utf16(self) -> int:
1425
+ assert self._comment.anchor_start is not None
1426
+ return self._comment.anchor_start
1427
+
1428
+ @property
1429
+ def end_utf16(self) -> int:
1430
+ assert self._comment.anchor_end is not None
1431
+ return self._comment.anchor_end
1432
+
1433
+ @property
1434
+ def text(self) -> str:
1435
+ return self._comment.anchor_text
1436
+
1437
+ @property
1438
+ def color(self) -> RGBColor | None:
1439
+ return self._comment.annotation_color
1440
+
1441
+ @property
1442
+ def comment(self) -> Comment:
1443
+ return self._comment
1444
+
1445
+
1446
+ class Document:
1447
+ """Open a Pages package and expose paragraphs and character-style runs."""
1448
+
1449
+ def __init__(self, path: str | Path | None = None):
1450
+ if path is None:
1451
+ path = Path(__file__).with_name("data") / "default.pages"
1452
+ self._package = PagesDocument.load(path)
1453
+ self._storage = self._package.body_storage()
1454
+ self._text = self._package.text(self._storage)
1455
+ self._core_properties = CoreProperties(self._package)
1456
+ self._tables: list[Table] | None = None
1457
+ self._inline_shapes: list[InlineShape] | None = None
1458
+ self._styles = Styles(self)
1459
+ self._comments = Comments(self)
1460
+
1461
+ @property
1462
+ def paragraphs(self) -> list[Paragraph]:
1463
+ result: list[Paragraph] = []
1464
+ start_cp = 0
1465
+ for end_cp in [
1466
+ index for index, char in enumerate(self._text) if char in ("\n", "\r")
1467
+ ] + [len(self._text)]:
1468
+ start = codepoint_to_utf16(self._text, start_cp)
1469
+ end = codepoint_to_utf16(self._text, end_cp)
1470
+ next_start = end + (1 if end_cp < len(self._text) else 0)
1471
+ result.append(Paragraph(self, start, end, next_start))
1472
+ start_cp = end_cp + 1
1473
+ return result
1474
+
1475
+ def save(self, path: str | Path) -> None:
1476
+ self._package.save(path)
1477
+
1478
+ @property
1479
+ def sections(self) -> list[Section]:
1480
+ return [Section(self._package.document_layout(self._storage))]
1481
+
1482
+ @property
1483
+ def core_properties(self) -> CoreProperties:
1484
+ return self._core_properties
1485
+
1486
+ @property
1487
+ def styles(self) -> Styles:
1488
+ return self._styles
1489
+
1490
+ @property
1491
+ def comments(self) -> Comments:
1492
+ return self._comments
1493
+
1494
+ @property
1495
+ def annotation_highlights(self) -> list[AnnotationHighlight]:
1496
+ return [
1497
+ AnnotationHighlight(comment)
1498
+ for comment in self.comments
1499
+ if comment.anchor_start is not None and comment.anchor_end is not None
1500
+ ]
1501
+
1502
+ def add_comment(self, *args, **kwargs):
1503
+ return self.comments.add_comment(*args, **kwargs)
1504
+
1505
+ @property
1506
+ def tables(self) -> list[Table]:
1507
+ if self._tables is None:
1508
+ self._tables = [
1509
+ Table(store)
1510
+ for store in body_table_stores(self._package, self._storage)
1511
+ ]
1512
+ return list(self._tables)
1513
+
1514
+ def add_table(self, rows: int, cols: int, style=None) -> Table:
1515
+ if (
1516
+ not isinstance(rows, int)
1517
+ or isinstance(rows, bool)
1518
+ or not isinstance(cols, int)
1519
+ or isinstance(cols, bool)
1520
+ ):
1521
+ raise TypeError("rows and cols must be integers")
1522
+ if rows <= 0 or cols <= 0:
1523
+ raise ValueError("rows and cols must be positive")
1524
+ if rows > 256:
1525
+ raise ValueError("template-backed Pages tables currently support at most 256 rows")
1526
+ if style is not None:
1527
+ raise NotImplementedError(
1528
+ "named table styles are not exposed by the Pages stylesheet facade"
1529
+ )
1530
+ store, clone = clone_body_table(self._package, self._storage, rows, cols)
1531
+ position = codepoint_to_utf16(self._text, len(self._text))
1532
+ self._replace_text(position, position, "\n\ufffc")
1533
+ append_attachment_run(self._storage, position + 1, clone.root.segment.identifier)
1534
+ table = Table(store)
1535
+ if self._tables is None:
1536
+ self._tables = [
1537
+ Table(item)
1538
+ for item in body_table_stores(self._package, self._storage)
1539
+ ]
1540
+ else:
1541
+ self._tables.append(table)
1542
+ return table
1543
+
1544
+ @property
1545
+ def inline_shapes(self) -> list[InlineShape]:
1546
+ if self._inline_shapes is None:
1547
+ self._inline_shapes = [
1548
+ InlineShape(store)
1549
+ for store in body_image_stores(self._package, self._storage)
1550
+ ]
1551
+ return list(self._inline_shapes)
1552
+
1553
+ @property
1554
+ def pictures(self) -> list[InlineShape]:
1555
+ """Convenience alias for the Pages-backed inline-shapes collection."""
1556
+ return self.inline_shapes
1557
+
1558
+ @property
1559
+ def hyperlinks(self) -> list[Hyperlink]:
1560
+ return [
1561
+ Hyperlink(self, store)
1562
+ for store in storage_hyperlinks(self._package, self._storage)
1563
+ ]
1564
+
1565
+ def add_picture(
1566
+ self,
1567
+ image_path_or_stream,
1568
+ width: Length | float | None = None,
1569
+ height: Length | float | None = None,
1570
+ ) -> InlineShape:
1571
+ if hasattr(image_path_or_stream, "read"):
1572
+ data = image_path_or_stream.read()
1573
+ filename = Path(getattr(image_path_or_stream, "name", "image.png")).name
1574
+ else:
1575
+ path = Path(image_path_or_stream)
1576
+ data = path.read_bytes()
1577
+ filename = path.name
1578
+ if not isinstance(data, bytes):
1579
+ raise TypeError("image stream must return bytes")
1580
+ for name, value in (("width", width), ("height", height)):
1581
+ if value is not None and (isinstance(value, bool) or float(value) <= 0):
1582
+ raise ValueError(f"{name} must be positive")
1583
+ store, clone = clone_body_image(
1584
+ self._package,
1585
+ self._storage,
1586
+ data,
1587
+ filename,
1588
+ width=None if width is None else float(width),
1589
+ height=None if height is None else float(height),
1590
+ )
1591
+ position = codepoint_to_utf16(self._text, len(self._text))
1592
+ self._replace_text(position, position, "\n\ufffc")
1593
+ append_attachment_run(self._storage, position + 1, clone.root.segment.identifier)
1594
+ shape = InlineShape(store)
1595
+ if self._inline_shapes is None:
1596
+ self._inline_shapes = [
1597
+ InlineShape(item)
1598
+ for item in body_image_stores(self._package, self._storage)
1599
+ ]
1600
+ else:
1601
+ self._inline_shapes.append(shape)
1602
+ return shape
1603
+
1604
+ def add_paragraph(self, text: str = "", style=None) -> Paragraph:
1605
+ if not isinstance(text, str):
1606
+ raise TypeError("paragraph text must be a string")
1607
+ if "\n" in text or "\r" in text:
1608
+ raise ValueError("add_paragraph text cannot contain paragraph separators")
1609
+ stylesheet = self._package.stylesheet()
1610
+ if style is None:
1611
+ style_obj = stylesheet.default_paragraph_style()
1612
+ elif isinstance(style, str):
1613
+ style_obj = stylesheet.named_paragraph_style(style)
1614
+ elif isinstance(style, int):
1615
+ style_obj = stylesheet.paragraph_style(style)
1616
+ if style_obj is None:
1617
+ raise KeyError(f"paragraph style {style} was not found")
1618
+ else:
1619
+ raise TypeError("style must be a paragraph style name, object ID, or None")
1620
+
1621
+ old_end = codepoint_to_utf16(self._text, len(self._text))
1622
+ value = "\n" + text
1623
+ self._replace_text(old_end, old_end, value)
1624
+ start = old_end + 1
1625
+ end = start + codepoint_to_utf16(text, len(text))
1626
+ table = self._storage.message.one(STORAGE_TABLE_PARA_STYLE)
1627
+ if table is None:
1628
+ table = self._storage.message.add_bytes(STORAGE_TABLE_PARA_STYLE, b"")
1629
+ runs = parse_attribute_table(bytes(table.value))
1630
+ runs.append(AttributeRun(start, style_obj.segment.identifier))
1631
+ runs.sort(key=lambda item: item.character_index)
1632
+ table.set_bytes(encode_attribute_table(runs))
1633
+ add_object_reference(self._storage.info, style_obj.segment.identifier)
1634
+ return Paragraph(self, start, end, end)
1635
+
1636
+ def add_heading(self, text: str = "", level: int = 1) -> Paragraph:
1637
+ """Append a heading using the closest built-in Pages heading style."""
1638
+ if not isinstance(level, int) or isinstance(level, bool):
1639
+ raise TypeError("heading level must be an integer")
1640
+ if level < 0 or level > 9:
1641
+ raise ValueError("heading level must be in range 0..9")
1642
+ style = "Title" if level == 0 else f"Heading {level}" if level > 1 else "Heading"
1643
+ try:
1644
+ return self.add_paragraph(text, style=style)
1645
+ except KeyError:
1646
+ # The supplied Pages theme defines Heading through Heading 3. Pages
1647
+ # has no fixed nine-level hierarchy, so deeper docx levels use the
1648
+ # deepest available heading style.
1649
+ return self.add_paragraph(text, style="Heading 3")
1650
+
1651
+ def add_page_break(self) -> Paragraph:
1652
+ """Append the Pages page-break control and return a post-break proxy."""
1653
+ position = codepoint_to_utf16(self._text, len(self._text))
1654
+ self._replace_text(position, position, "\x05")
1655
+ after_break = position + 1
1656
+ return Paragraph(self, after_break, after_break, after_break)
1657
+
1658
+ def _character_runs(self) -> list[AttributeRun]:
1659
+ field = self._storage.message.one(STORAGE_TABLE_CHAR_STYLE)
1660
+ return [] if field is None else parse_attribute_table(bytes(field.value))
1661
+
1662
+ def _paragraph_runs(self) -> list[AttributeRun]:
1663
+ field = self._storage.message.one(STORAGE_TABLE_PARA_STYLE)
1664
+ return [] if field is None else parse_attribute_table(bytes(field.value))
1665
+
1666
+ def _paragraph_style_id_at(self, index: int) -> int | None:
1667
+ return style_at(self._paragraph_runs(), index)
1668
+
1669
+ def _character_style_id_at(self, index: int) -> int | None:
1670
+ return style_at(self._character_runs(), index)
1671
+
1672
+ def _style_from_id(
1673
+ self, identifier: int | None, expected: WD_STYLE_TYPE
1674
+ ) -> Style | None:
1675
+ obj = (
1676
+ self._package.objects().get(identifier)
1677
+ if identifier is not None
1678
+ else None
1679
+ )
1680
+ expected_type = (
1681
+ TYPE_CHARACTER_STYLE
1682
+ if expected == WD_STYLE_TYPE.CHARACTER
1683
+ else TYPE_PARAGRAPH_STYLE
1684
+ )
1685
+ seen: set[int] = set()
1686
+ while (
1687
+ obj is not None
1688
+ and obj.info.type_id == expected_type
1689
+ and is_anonymous_style(obj)
1690
+ ):
1691
+ if obj.segment.identifier in seen:
1692
+ return None
1693
+ seen.add(obj.segment.identifier)
1694
+ try:
1695
+ parent = self._package.stylesheet().parent_identifier(obj)
1696
+ except ValueError:
1697
+ return None
1698
+ obj = self._package.objects().get(parent)
1699
+ return (
1700
+ None
1701
+ if obj is None or obj.info.type_id != expected_type
1702
+ else Style(self, obj)
1703
+ )
1704
+
1705
+ def _coerce_style_id(
1706
+ self,
1707
+ value: Style | str | None,
1708
+ expected: WD_STYLE_TYPE,
1709
+ *,
1710
+ none_identifier: int | None,
1711
+ ) -> int | None:
1712
+ if value is None:
1713
+ return none_identifier
1714
+ style = self.styles[value] if isinstance(value, str) else value
1715
+ if not isinstance(style, Style):
1716
+ raise TypeError("style must be a style name, Style, or None")
1717
+ if style.type != expected:
1718
+ raise ValueError(f"style {style.name!r} has the wrong style type")
1719
+ return style._obj.segment.identifier
1720
+
1721
+ def _apply_character_style_id(
1722
+ self, start_utf16: int, end_utf16: int, identifier: int | None
1723
+ ) -> None:
1724
+ if end_utf16 <= start_utf16:
1725
+ return
1726
+ table = self._storage.message.one(STORAGE_TABLE_CHAR_STYLE)
1727
+ if table is None:
1728
+ table = self._storage.message.add_bytes(STORAGE_TABLE_CHAR_STYLE, b"")
1729
+ runs = parse_attribute_table(bytes(table.value))
1730
+ table.set_bytes(
1731
+ encode_attribute_table(
1732
+ replace_style_range(runs, start_utf16, end_utf16, identifier)
1733
+ )
1734
+ )
1735
+ if identifier is not None:
1736
+ add_object_reference(self._storage.info, identifier)
1737
+
1738
+ def _apply_paragraph_style_id(
1739
+ self, start_utf16: int, end_utf16: int, identifier: int
1740
+ ) -> None:
1741
+ table = self._storage.message.one(STORAGE_TABLE_PARA_STYLE)
1742
+ if table is None:
1743
+ table = self._storage.message.add_bytes(STORAGE_TABLE_PARA_STYLE, b"")
1744
+ runs = parse_attribute_table(bytes(table.value))
1745
+ if end_utf16 <= start_utf16:
1746
+ updated = [run for run in runs if run.character_index != start_utf16]
1747
+ updated.append(AttributeRun(start_utf16, identifier))
1748
+ updated.sort(key=lambda run: run.character_index)
1749
+ else:
1750
+ updated = replace_style_range(
1751
+ runs, start_utf16, end_utf16, identifier, compact=False
1752
+ )
1753
+ table.set_bytes(encode_attribute_table(updated))
1754
+ add_object_reference(self._storage.info, identifier)
1755
+
1756
+ def _paragraph_properties_at(self, index: int) -> ParagraphProperties:
1757
+ style = self._package.stylesheet().paragraph_style(self._paragraph_style_id_at(index))
1758
+ return paragraph_style_properties(style)
1759
+
1760
+ def _paragraph_character_properties_at(
1761
+ self, index: int
1762
+ ) -> CharacterProperties:
1763
+ style = self._package.stylesheet().paragraph_style(
1764
+ self._paragraph_style_id_at(index)
1765
+ )
1766
+ return character_style_properties(style)
1767
+
1768
+ def _text_between(self, start_utf16: int, end_utf16: int) -> str:
1769
+ start = utf16_to_codepoint(self._text, start_utf16)
1770
+ end = utf16_to_codepoint(self._text, end_utf16)
1771
+ return self._text[start:end]
1772
+
1773
+ def _replace_text(self, start_utf16: int, end_utf16: int, value: str) -> int:
1774
+ delta = replace_storage_text(self._storage, start_utf16, end_utf16, value)
1775
+ start = utf16_to_codepoint(self._text, start_utf16)
1776
+ end = utf16_to_codepoint(self._text, end_utf16)
1777
+ self._text = self._text[:start] + value + self._text[end:]
1778
+ return delta
1779
+
1780
+ def _properties_at(self, index: int) -> CharacterProperties:
1781
+ style_id = style_at(self._character_runs(), index)
1782
+ return character_style_properties(self._package.stylesheet().style(style_id))
1783
+
1784
+ def _apply_properties(
1785
+ self, start_utf16: int, end_utf16: int, props: CharacterProperties
1786
+ ) -> None:
1787
+ table = self._storage.message.one(STORAGE_TABLE_CHAR_STYLE)
1788
+ if table is None:
1789
+ table = self._storage.message.add_bytes(STORAGE_TABLE_CHAR_STYLE, b"")
1790
+ runs = parse_attribute_table(bytes(table.value))
1791
+ style_id = None
1792
+ if props.override_count:
1793
+ style, _ = self._package.synthesize_character_style(props)
1794
+ style_id = style.segment.identifier
1795
+ add_object_reference(self._storage.info, style_id)
1796
+ table.set_bytes(
1797
+ encode_attribute_table(replace_style_range(runs, start_utf16, end_utf16, style_id))
1798
+ )
1799
+
1800
+ def _apply_paragraph_properties(
1801
+ self,
1802
+ start_utf16: int,
1803
+ next_start_utf16: int,
1804
+ props: ParagraphProperties,
1805
+ ) -> None:
1806
+ table = self._storage.message.one(STORAGE_TABLE_PARA_STYLE)
1807
+ if table is None:
1808
+ table = self._storage.message.add_bytes(STORAGE_TABLE_PARA_STYLE, b"")
1809
+ runs = parse_attribute_table(bytes(table.value))
1810
+ target_positions = [
1811
+ position
1812
+ for position, run in enumerate(runs)
1813
+ if start_utf16 <= run.character_index < next_start_utf16
1814
+ ]
1815
+ updated = list(runs)
1816
+ if not target_positions or runs[target_positions[0]].character_index != start_utf16:
1817
+ updated.append(AttributeRun(start_utf16, style_at(runs, start_utf16)))
1818
+ target_positions = [len(updated) - 1, *target_positions]
1819
+ for position in target_positions:
1820
+ run = updated[position]
1821
+ parent, character_raw, _ = self._package.stylesheet().paragraph_style_context(
1822
+ run.object_id
1823
+ )
1824
+ style, _ = self._package.synthesize_paragraph_style(
1825
+ parent,
1826
+ props,
1827
+ character_properties=character_raw,
1828
+ )
1829
+ style_id = style.segment.identifier
1830
+ updated[position] = AttributeRun(run.character_index, style_id)
1831
+ add_object_reference(self._storage.info, style_id)
1832
+ updated.sort(key=lambda item: item.character_index)
1833
+ table.set_bytes(encode_attribute_table(updated))
1834
+
1835
+ def _apply_paragraph_background(
1836
+ self,
1837
+ start_utf16: int,
1838
+ next_start_utf16: int,
1839
+ color: RGBColor | None,
1840
+ ) -> None:
1841
+ table = self._storage.message.one(STORAGE_TABLE_PARA_STYLE)
1842
+ if table is None:
1843
+ table = self._storage.message.add_bytes(STORAGE_TABLE_PARA_STYLE, b"")
1844
+ runs = parse_attribute_table(bytes(table.value))
1845
+ target_positions = [
1846
+ position
1847
+ for position, run in enumerate(runs)
1848
+ if start_utf16 <= run.character_index < next_start_utf16
1849
+ ]
1850
+ updated = list(runs)
1851
+ if not target_positions or runs[target_positions[0]].character_index != start_utf16:
1852
+ updated.append(AttributeRun(start_utf16, style_at(runs, start_utf16)))
1853
+ target_positions = [len(updated) - 1, *target_positions]
1854
+ for position in target_positions:
1855
+ run = updated[position]
1856
+ parent, character_raw, paragraph_props = (
1857
+ self._package.stylesheet().paragraph_style_context(run.object_id)
1858
+ )
1859
+ character_raw = replace_character_background(character_raw, color)
1860
+ style, _ = self._package.synthesize_paragraph_style(
1861
+ parent,
1862
+ paragraph_props,
1863
+ character_properties=character_raw,
1864
+ )
1865
+ style_id = style.segment.identifier
1866
+ updated[position] = AttributeRun(run.character_index, style_id)
1867
+ add_object_reference(self._storage.info, style_id)
1868
+ updated.sort(key=lambda item: item.character_index)
1869
+ table.set_bytes(encode_attribute_table(updated))