pdfdancer-client-python 0.3.14__py3-none-any.whl → 3.0.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.
@@ -0,0 +1,1472 @@
1
+ """Selector-based text editing models for the PDFDancer v2 API.
2
+
3
+ The public builders mirror the Java SDK while using Python ``snake_case`` names.
4
+ Every request validates before serialization and omits fields whose value is
5
+ ``None``, matching Jackson's ``NON_NULL`` serialization in the Java client.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import math
12
+ from dataclasses import dataclass, replace
13
+ from enum import Enum
14
+ from pathlib import Path
15
+ from typing import Any, Optional, Sequence, cast
16
+
17
+ from typing_extensions import Self
18
+
19
+ from .exceptions import ValidationException
20
+
21
+
22
+ def _require_finite(value: float, name: str) -> float:
23
+ result = float(value)
24
+ if not math.isfinite(result):
25
+ raise ValidationException(f"{name} must be finite")
26
+ return result
27
+
28
+
29
+ def _without_none(values: dict[str, Any]) -> dict[str, Any]:
30
+ return {key: value for key, value in values.items() if value is not None}
31
+
32
+
33
+ def _validated_pages(pages: Optional[Sequence[int]]) -> Optional[tuple[int, ...]]:
34
+ if pages is None:
35
+ return None
36
+ normalized = tuple(pages)
37
+ if any(
38
+ page is None or isinstance(page, bool) or not isinstance(page, int) or page < 1
39
+ for page in normalized
40
+ ):
41
+ raise ValidationException("pages must contain only page numbers >= 1")
42
+ return normalized
43
+
44
+
45
+ class PdfColorSpace(Enum):
46
+ RGB = "rgb"
47
+ CMYK = "cmyk"
48
+ GRAY = "gray"
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class PdfColorRequest:
53
+ space: PdfColorSpace
54
+ components: tuple[float, ...]
55
+ alpha_value: Optional[float] = None
56
+
57
+ @classmethod
58
+ def rgb(cls, red: float, green: float, blue: float) -> "PdfColorRequest":
59
+ return cls(PdfColorSpace.RGB, (red, green, blue)).validated()
60
+
61
+ @classmethod
62
+ def cmyk(
63
+ cls, cyan: float, magenta: float, yellow: float, black: float
64
+ ) -> "PdfColorRequest":
65
+ return cls(PdfColorSpace.CMYK, (cyan, magenta, yellow, black)).validated()
66
+
67
+ @classmethod
68
+ def gray(cls, gray: float) -> "PdfColorRequest":
69
+ return cls(PdfColorSpace.GRAY, (gray,)).validated()
70
+
71
+ def alpha(self, alpha: float) -> "PdfColorRequest":
72
+ return replace(self, alpha_value=alpha).validated()
73
+
74
+ def validated(self) -> "PdfColorRequest":
75
+ if not isinstance(self.space, PdfColorSpace):
76
+ raise ValidationException("color space must not be null")
77
+ expected = {
78
+ PdfColorSpace.RGB: 3,
79
+ PdfColorSpace.CMYK: 4,
80
+ PdfColorSpace.GRAY: 1,
81
+ }[self.space]
82
+ if len(self.components) != expected:
83
+ raise ValidationException(
84
+ f"color space {self.space.value} requires {expected} components"
85
+ )
86
+ for component in self.components:
87
+ self._validate_normalized(component, "color component")
88
+ if self.alpha_value is not None:
89
+ self._validate_normalized(self.alpha_value, "alpha")
90
+ return self
91
+
92
+ @staticmethod
93
+ def _validate_normalized(value: float, name: str) -> None:
94
+ if not isinstance(value, (int, float)) or isinstance(value, bool):
95
+ raise ValidationException(f"{name} must be finite and between 0.0 and 1.0")
96
+ value = float(value)
97
+ if not math.isfinite(value) or value < 0.0 or value > 1.0:
98
+ raise ValidationException(f"{name} must be finite and between 0.0 and 1.0")
99
+
100
+ def to_dict(self) -> dict[str, Any]:
101
+ self.validated()
102
+ return _without_none(
103
+ {
104
+ "space": self.space.value,
105
+ "components": [float(value) for value in self.components],
106
+ "alpha": self.alpha_value,
107
+ }
108
+ )
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class PdfAffineTransform:
113
+ """Immutable PDF six-number affine transformation ``[a,b,c,d,e,f]``."""
114
+
115
+ a: float
116
+ b: float
117
+ c: float
118
+ d: float
119
+ e: float
120
+ f: float
121
+
122
+ def __post_init__(self) -> None:
123
+ for coefficient in self.to_pdf_matrix():
124
+ _require_finite(coefficient, "PDF affine matrix coefficient")
125
+
126
+ @classmethod
127
+ def from_pdf_matrix(cls, coefficients: Sequence[float]) -> "PdfAffineTransform":
128
+ if coefficients is None or len(coefficients) != 6:
129
+ raise ValidationException(
130
+ "PDF affine matrix must contain exactly 6 coefficients"
131
+ )
132
+ return cls(*(float(value) for value in coefficients))
133
+
134
+ @classmethod
135
+ def builder(cls) -> "PdfAffineTransformBuilder":
136
+ return PdfAffineTransformBuilder()
137
+
138
+ def to_pdf_matrix(self) -> tuple[float, float, float, float, float, float]:
139
+ return self.a, self.b, self.c, self.d, self.e, self.f
140
+
141
+ def _followed_by(
142
+ self, next_transform: "PdfAffineTransform"
143
+ ) -> "PdfAffineTransform":
144
+ return PdfAffineTransform(
145
+ next_transform.a * self.a + next_transform.c * self.b,
146
+ next_transform.b * self.a + next_transform.d * self.b,
147
+ next_transform.a * self.c + next_transform.c * self.d,
148
+ next_transform.b * self.c + next_transform.d * self.d,
149
+ next_transform.a * self.e + next_transform.c * self.f + next_transform.e,
150
+ next_transform.b * self.e + next_transform.d * self.f + next_transform.f,
151
+ )
152
+
153
+
154
+ class PdfAffineTransformBuilder:
155
+ """Build a six-value PDF affine transformation matrix."""
156
+
157
+ def __init__(self) -> None:
158
+ self._transform = PdfAffineTransform(1, 0, 0, 1, 0, 0)
159
+
160
+ def scale(self, scale_x: float, scale_y: float) -> "PdfAffineTransformBuilder":
161
+ self._transform = self._transform._followed_by(
162
+ PdfAffineTransform(scale_x, 0, 0, scale_y, 0, 0)
163
+ )
164
+ return self
165
+
166
+ def shear(self, shear_x: float, shear_y: float) -> "PdfAffineTransformBuilder":
167
+ self._transform = self._transform._followed_by(
168
+ PdfAffineTransform(1, shear_y, shear_x, 1, 0, 0)
169
+ )
170
+ return self
171
+
172
+ def rotate_degrees(self, degrees: float) -> "PdfAffineTransformBuilder":
173
+ degrees = _require_finite(degrees, "rotation degrees")
174
+ radians = math.radians(degrees)
175
+ cosine, sine = math.cos(radians), math.sin(radians)
176
+ self._transform = self._transform._followed_by(
177
+ PdfAffineTransform(cosine, sine, -sine, cosine, 0, 0)
178
+ )
179
+ return self
180
+
181
+ def translate(
182
+ self, translate_x: float, translate_y: float
183
+ ) -> "PdfAffineTransformBuilder":
184
+ self._transform = self._transform._followed_by(
185
+ PdfAffineTransform(1, 0, 0, 1, translate_x, translate_y)
186
+ )
187
+ return self
188
+
189
+ def build(self) -> PdfAffineTransform:
190
+ return self._transform
191
+
192
+
193
+ @dataclass(frozen=True)
194
+ class TextSelectorRequest:
195
+ literal: Optional[str] = None
196
+ regex: Optional[str] = None
197
+ case_sensitive: Optional[bool] = None
198
+ whole_words: Optional[bool] = None
199
+ max_matches: Optional[int] = None
200
+
201
+ def validated(self) -> "TextSelectorRequest":
202
+ if (self.literal is None) == (self.regex is None):
203
+ raise ValidationException(
204
+ "Exactly one of literal or regex must be provided"
205
+ )
206
+ selected = self.literal if self.literal is not None else self.regex
207
+ if selected is None or not selected.strip():
208
+ name = "literal" if self.literal is not None else "regex"
209
+ raise ValidationException(f"{name} must not be blank")
210
+ if self.max_matches is not None and self.max_matches <= 0:
211
+ raise ValidationException("maxMatches must be positive")
212
+ return self
213
+
214
+ def to_dict(self) -> dict[str, Any]:
215
+ self.validated()
216
+ return _without_none(
217
+ {
218
+ "literal": self.literal,
219
+ "regex": self.regex,
220
+ "caseSensitive": self.case_sensitive,
221
+ "wholeWords": self.whole_words,
222
+ "maxMatches": self.max_matches,
223
+ }
224
+ )
225
+
226
+
227
+ class TextLayoutMode(Enum):
228
+ SOURCE_ANCHORED = "sourceAnchored"
229
+ REFLOW_WHEN_SUPPORTED = "reflowWhenSupported"
230
+ REQUIRE_REFLOW = "requireReflow"
231
+
232
+
233
+ class TextLayoutProfile(Enum):
234
+ DEFAULT = "default"
235
+ BODY_TEXT = "bodyText"
236
+ NO_REFLOW = "noReflow"
237
+
238
+
239
+ @dataclass(frozen=True)
240
+ class TextLayoutRequest:
241
+ mode: Optional[TextLayoutMode] = None
242
+ profile: Optional[TextLayoutProfile] = None
243
+ hyphenation_enabled: Optional[bool] = None
244
+
245
+ @classmethod
246
+ def source_anchored(cls) -> "TextLayoutRequest":
247
+ return cls(mode=TextLayoutMode.SOURCE_ANCHORED)
248
+
249
+ @classmethod
250
+ def reflow_when_supported(cls, profile: TextLayoutProfile) -> "TextLayoutRequest":
251
+ return cls(mode=TextLayoutMode.REFLOW_WHEN_SUPPORTED, profile=profile)
252
+
253
+ @classmethod
254
+ def require_reflow(cls, profile: TextLayoutProfile) -> "TextLayoutRequest":
255
+ return cls(mode=TextLayoutMode.REQUIRE_REFLOW, profile=profile)
256
+
257
+ def with_hyphenation_enabled(self, enabled: bool) -> "TextLayoutRequest":
258
+ return replace(self, hyphenation_enabled=enabled)
259
+
260
+ def validated(self) -> "TextLayoutRequest":
261
+ mode = self.mode or TextLayoutMode.SOURCE_ANCHORED
262
+ if mode is TextLayoutMode.SOURCE_ANCHORED and self.profile is not None:
263
+ raise ValidationException("sourceAnchored layout must not specify profile")
264
+ if (
265
+ mode is TextLayoutMode.SOURCE_ANCHORED
266
+ and self.hyphenation_enabled is not None
267
+ ):
268
+ raise ValidationException(
269
+ "layout.hyphenationEnabled is not valid when layout.mode is sourceAnchored"
270
+ )
271
+ if (
272
+ mode
273
+ in (
274
+ TextLayoutMode.REFLOW_WHEN_SUPPORTED,
275
+ TextLayoutMode.REQUIRE_REFLOW,
276
+ )
277
+ and self.profile is None
278
+ ):
279
+ raise ValidationException(
280
+ f"{mode.value} profile must be one of default, bodyText, noReflow"
281
+ )
282
+ return self
283
+
284
+ def to_dict(self) -> dict[str, Any]:
285
+ self.validated()
286
+ return _without_none(
287
+ {
288
+ "mode": self.mode.value if self.mode else None,
289
+ "profile": self.profile.value if self.profile else None,
290
+ "hyphenationEnabled": self.hyphenation_enabled,
291
+ }
292
+ )
293
+
294
+
295
+ @dataclass(frozen=True)
296
+ class TextStyleNumericFilterRequest:
297
+ eq: float
298
+ tolerance: Optional[float] = None
299
+
300
+ @classmethod
301
+ def equal_to(
302
+ cls, eq: float, tolerance: Optional[float] = None
303
+ ) -> "TextStyleNumericFilterRequest":
304
+ return cls(eq, tolerance).validated()
305
+
306
+ def validated(self) -> "TextStyleNumericFilterRequest":
307
+ _require_finite(self.eq, "eq")
308
+ if self.tolerance is not None:
309
+ _require_finite(self.tolerance, "tolerance")
310
+ if self.tolerance < 0:
311
+ raise ValidationException("tolerance must be >= 0")
312
+ return self
313
+
314
+ def to_dict(self) -> dict[str, Any]:
315
+ self.validated()
316
+ return _without_none({"eq": self.eq, "tolerance": self.tolerance})
317
+
318
+
319
+ @dataclass(frozen=True)
320
+ class TextStyleRunFilterRequest:
321
+ text_contains: Optional[str] = None
322
+ font: Optional[str] = None
323
+ size: Optional[TextStyleNumericFilterRequest] = None
324
+ fill_color: Optional[PdfColorRequest] = None
325
+ stroke_color: Optional[PdfColorRequest] = None
326
+ character_spacing: Optional[TextStyleNumericFilterRequest] = None
327
+ word_spacing: Optional[TextStyleNumericFilterRequest] = None
328
+ contains_unmapped_glyphs: Optional[bool] = None
329
+
330
+ def validated(self) -> "TextStyleRunFilterRequest":
331
+ if not any(value is not None for value in self.__dict__.values()):
332
+ raise ValidationException("runs.where must set at least one field")
333
+ if self.text_contains is not None and not self.text_contains.strip():
334
+ raise ValidationException("textContains must not be blank")
335
+ if self.font is not None and not self.font.strip():
336
+ raise ValidationException("font must not be blank")
337
+ for numeric_filter in (self.size, self.character_spacing, self.word_spacing):
338
+ if numeric_filter is not None:
339
+ numeric_filter.validated()
340
+ for color in (self.fill_color, self.stroke_color):
341
+ if color is not None:
342
+ color.validated()
343
+ return self
344
+
345
+ def to_dict(self) -> dict[str, Any]:
346
+ self.validated()
347
+ return _without_none(
348
+ {
349
+ "textContains": self.text_contains,
350
+ "font": self.font,
351
+ "size": self.size.to_dict() if self.size else None,
352
+ "fillColor": self.fill_color.to_dict() if self.fill_color else None,
353
+ "strokeColor": (
354
+ self.stroke_color.to_dict() if self.stroke_color else None
355
+ ),
356
+ "characterSpacing": (
357
+ self.character_spacing.to_dict() if self.character_spacing else None
358
+ ),
359
+ "wordSpacing": (
360
+ self.word_spacing.to_dict() if self.word_spacing else None
361
+ ),
362
+ "containsUnmappedGlyphs": self.contains_unmapped_glyphs,
363
+ }
364
+ )
365
+
366
+
367
+ @dataclass(frozen=True)
368
+ class TextStyleRunsSelectorRequest:
369
+ where: TextStyleRunFilterRequest
370
+ max_matches: Optional[int] = None
371
+
372
+ def validated(self) -> "TextStyleRunsSelectorRequest":
373
+ if self.where is None:
374
+ raise ValidationException("runs.where must not be null")
375
+ self.where.validated()
376
+ if self.max_matches is not None and self.max_matches <= 0:
377
+ raise ValidationException("runs.maxMatches must be positive")
378
+ return self
379
+
380
+ def to_dict(self) -> dict[str, Any]:
381
+ self.validated()
382
+ return _without_none(
383
+ {"where": self.where.to_dict(), "maxMatches": self.max_matches}
384
+ )
385
+
386
+
387
+ @dataclass(frozen=True)
388
+ class TextStyleSelectorRequest:
389
+ literal: Optional[str] = None
390
+ regex: Optional[str] = None
391
+ case_sensitive: Optional[bool] = None
392
+ whole_words: Optional[bool] = None
393
+ max_matches: Optional[int] = None
394
+ runs: Optional[TextStyleRunsSelectorRequest] = None
395
+
396
+ @classmethod
397
+ def from_text_selector(
398
+ cls, selector: TextSelectorRequest
399
+ ) -> "TextStyleSelectorRequest":
400
+ return cls(
401
+ literal=selector.literal,
402
+ regex=selector.regex,
403
+ case_sensitive=selector.case_sensitive,
404
+ whole_words=selector.whole_words,
405
+ max_matches=selector.max_matches,
406
+ )
407
+
408
+ def validated(self) -> "TextStyleSelectorRequest":
409
+ text_selector = self.literal is not None or self.regex is not None
410
+ if text_selector == (self.runs is not None):
411
+ raise ValidationException(
412
+ "Exactly one of literal, regex, or runs must be provided"
413
+ )
414
+ if text_selector:
415
+ TextSelectorRequest(
416
+ self.literal,
417
+ self.regex,
418
+ self.case_sensitive,
419
+ self.whole_words,
420
+ self.max_matches,
421
+ ).validated()
422
+ else:
423
+ assert self.runs is not None
424
+ self.runs.validated()
425
+ if any(
426
+ value is not None
427
+ for value in (
428
+ self.case_sensitive,
429
+ self.whole_words,
430
+ self.max_matches,
431
+ )
432
+ ):
433
+ raise ValidationException(
434
+ "caseSensitive, wholeWords, and top-level maxMatches are not valid with runs"
435
+ )
436
+ return self
437
+
438
+ def to_dict(self) -> dict[str, Any]:
439
+ self.validated()
440
+ return _without_none(
441
+ {
442
+ "literal": self.literal,
443
+ "regex": self.regex,
444
+ "caseSensitive": self.case_sensitive,
445
+ "wholeWords": self.whole_words,
446
+ "maxMatches": self.max_matches,
447
+ "runs": self.runs.to_dict() if self.runs else None,
448
+ }
449
+ )
450
+
451
+
452
+ @dataclass(frozen=True)
453
+ class _TextStyleFields:
454
+ font: Optional[str] = None
455
+ size: Optional[float] = None
456
+ fill_color: Optional[PdfColorRequest] = None
457
+ stroke_color: Optional[PdfColorRequest] = None
458
+ character_spacing: Optional[float] = None
459
+ word_spacing: Optional[float] = None
460
+
461
+ def _validate_optional_fields(self) -> None:
462
+ if self.font is not None and not self.font.strip():
463
+ raise ValidationException("font must not be blank")
464
+ if self.size is not None:
465
+ _require_finite(self.size, "size")
466
+ if self.size <= 0:
467
+ raise ValidationException("size must be > 0")
468
+ for name, value in (
469
+ ("characterSpacing", self.character_spacing),
470
+ ("wordSpacing", self.word_spacing),
471
+ ):
472
+ if value is not None:
473
+ _require_finite(value, name)
474
+ for color in (self.fill_color, self.stroke_color):
475
+ if color is not None:
476
+ color.validated()
477
+
478
+ def _fields_dict(self) -> dict[str, Any]:
479
+ return _without_none(
480
+ {
481
+ "font": self.font,
482
+ "size": self.size,
483
+ "fillColor": self.fill_color.to_dict() if self.fill_color else None,
484
+ "strokeColor": (
485
+ self.stroke_color.to_dict() if self.stroke_color else None
486
+ ),
487
+ "characterSpacing": self.character_spacing,
488
+ "wordSpacing": self.word_spacing,
489
+ }
490
+ )
491
+
492
+
493
+ @dataclass(frozen=True)
494
+ class TextStylePatchRequest(_TextStyleFields):
495
+ @classmethod
496
+ def builder(cls) -> "TextStylePatchBuilder":
497
+ return TextStylePatchBuilder()
498
+
499
+ def validated(self) -> "TextStylePatchRequest":
500
+ if not any(value is not None for value in self.__dict__.values()):
501
+ raise ValidationException("style patch must set at least one field")
502
+ self._validate_optional_fields()
503
+ return self
504
+
505
+ def to_dict(self) -> dict[str, Any]:
506
+ self.validated()
507
+ return self._fields_dict()
508
+
509
+
510
+ @dataclass(frozen=True)
511
+ class TextStyleSetRequest(_TextStyleFields):
512
+ reset_spacing_overrides: Optional[bool] = None
513
+
514
+ @classmethod
515
+ def builder(cls) -> "TextStyleSetBuilder":
516
+ return TextStyleSetBuilder()
517
+
518
+ def validated(self) -> "TextStyleSetRequest":
519
+ self._validate_optional_fields()
520
+ if not any(value is not None for value in self.__dict__.values()):
521
+ raise ValidationException("style must set at least one field")
522
+ if self.reset_spacing_overrides is False:
523
+ raise ValidationException("resetSpacingOverrides must be true when present")
524
+ if self.reset_spacing_overrides is True and (
525
+ self.character_spacing is not None or self.word_spacing is not None
526
+ ):
527
+ raise ValidationException(
528
+ "resetSpacingOverrides cannot be combined with characterSpacing or wordSpacing"
529
+ )
530
+ return self
531
+
532
+ def to_dict(self) -> dict[str, Any]:
533
+ self.validated()
534
+ return _without_none(
535
+ {
536
+ **self._fields_dict(),
537
+ "resetSpacingOverrides": self.reset_spacing_overrides,
538
+ }
539
+ )
540
+
541
+
542
+ class _TextStyleBuilderBase:
543
+ def __init__(self) -> None:
544
+ self._font: Optional[str] = None
545
+ self._size: Optional[float] = None
546
+ self._fill_color: Optional[PdfColorRequest] = None
547
+ self._stroke_color: Optional[PdfColorRequest] = None
548
+ self._character_spacing: Optional[float] = None
549
+ self._word_spacing: Optional[float] = None
550
+
551
+ def font(self, font: str) -> Self:
552
+ self._font = font
553
+ return self
554
+
555
+ def size(self, size: float) -> Self:
556
+ self._size = size
557
+ return self
558
+
559
+ def fill_color(self, color: PdfColorRequest) -> Self:
560
+ self._fill_color = color
561
+ return self
562
+
563
+ def stroke_color(self, color: PdfColorRequest) -> Self:
564
+ self._stroke_color = color
565
+ return self
566
+
567
+ def character_spacing(self, spacing: float) -> Self:
568
+ self._character_spacing = spacing
569
+ return self
570
+
571
+ def word_spacing(self, spacing: float) -> Self:
572
+ self._word_spacing = spacing
573
+ return self
574
+
575
+ def _kwargs(self) -> dict[str, Any]:
576
+ return {
577
+ "font": self._font,
578
+ "size": self._size,
579
+ "fill_color": self._fill_color,
580
+ "stroke_color": self._stroke_color,
581
+ "character_spacing": self._character_spacing,
582
+ "word_spacing": self._word_spacing,
583
+ }
584
+
585
+
586
+ class TextStylePatchBuilder(_TextStyleBuilderBase):
587
+ @classmethod
588
+ def from_style(
589
+ cls, style: Optional[TextStylePatchRequest]
590
+ ) -> "TextStylePatchBuilder":
591
+ builder = cls()
592
+ if style is not None:
593
+ builder._font = style.font
594
+ builder._size = style.size
595
+ builder._fill_color = style.fill_color
596
+ builder._stroke_color = style.stroke_color
597
+ builder._character_spacing = style.character_spacing
598
+ builder._word_spacing = style.word_spacing
599
+ return builder
600
+
601
+ def build(self) -> TextStylePatchRequest:
602
+ return TextStylePatchRequest(**self._kwargs()).validated()
603
+
604
+
605
+ class TextStyleSetBuilder(_TextStyleBuilderBase):
606
+ def __init__(self) -> None:
607
+ super().__init__()
608
+ self._reset_spacing_overrides: Optional[bool] = None
609
+
610
+ @classmethod
611
+ def from_style(cls, style: Optional[TextStyleSetRequest]) -> "TextStyleSetBuilder":
612
+ builder = cls()
613
+ if style is not None:
614
+ builder._font = style.font
615
+ builder._size = style.size
616
+ builder._fill_color = style.fill_color
617
+ builder._stroke_color = style.stroke_color
618
+ builder._character_spacing = style.character_spacing
619
+ builder._word_spacing = style.word_spacing
620
+ builder._reset_spacing_overrides = style.reset_spacing_overrides
621
+ return builder
622
+
623
+ def reset_spacing_overrides(self) -> Self:
624
+ self._reset_spacing_overrides = True
625
+ return self
626
+
627
+ def build(self) -> TextStyleSetRequest:
628
+ return TextStyleSetRequest(
629
+ **self._kwargs(), reset_spacing_overrides=self._reset_spacing_overrides
630
+ ).validated()
631
+
632
+
633
+ @dataclass(frozen=True)
634
+ class TextReplacementImageRequest:
635
+ data: bytes
636
+ transformation: PdfAffineTransform
637
+
638
+ def validated(self) -> "TextReplacementImageRequest":
639
+ if not self.data:
640
+ raise ValidationException(
641
+ "replaceWithImage image data is required and must not be empty"
642
+ )
643
+ if self.transformation is None:
644
+ raise ValidationException("replaceWithImage transformation is required")
645
+ return self
646
+
647
+ def to_dict(self) -> dict[str, Any]:
648
+ self.validated()
649
+ return {
650
+ "data": base64.b64encode(self.data).decode("ascii"),
651
+ "transformationMatrix": list(self.transformation.to_pdf_matrix()),
652
+ }
653
+
654
+
655
+ class _SelectorLayoutBuilder:
656
+ def __init__(self) -> None:
657
+ self._pages: Optional[tuple[int, ...]] = None
658
+ self._literal: Optional[str] = None
659
+ self._regex: Optional[str] = None
660
+ self._case_sensitive: Optional[bool] = None
661
+ self._whole_words: Optional[bool] = None
662
+ self._max_matches: Optional[int] = None
663
+ self._layout: Optional[TextLayoutRequest] = None
664
+ self._hyphenation_enabled: Optional[bool] = None
665
+
666
+ def pages(self, *pages: int | Sequence[int]) -> Self:
667
+ values: Sequence[int]
668
+ if (
669
+ len(pages) == 1
670
+ and isinstance(pages[0], Sequence)
671
+ and not isinstance(pages[0], (str, bytes))
672
+ ):
673
+ values = pages[0]
674
+ else:
675
+ values = cast(Sequence[int], pages)
676
+ self._pages = tuple(values)
677
+ return self
678
+
679
+ def literal(self, literal: str) -> Self:
680
+ self._literal, self._regex = literal, None
681
+ return self
682
+
683
+ def regex(self, regex: str) -> Self:
684
+ self._regex, self._literal = regex, None
685
+ return self
686
+
687
+ def case_sensitive(self, case_sensitive: bool) -> Self:
688
+ self._case_sensitive = case_sensitive
689
+ return self
690
+
691
+ def whole_words(self, whole_words: bool) -> Self:
692
+ self._whole_words = whole_words
693
+ return self
694
+
695
+ def max_matches(self, max_matches: int) -> Self:
696
+ self._max_matches = max_matches
697
+ return self
698
+
699
+ def source_anchored(self) -> Self:
700
+ self._layout = TextLayoutRequest.source_anchored()
701
+ self._hyphenation_enabled = None
702
+ return self
703
+
704
+ def reflow_when_supported(self, profile: TextLayoutProfile) -> Self:
705
+ self._layout = TextLayoutRequest.reflow_when_supported(profile)
706
+ return self
707
+
708
+ def require_reflow(self, profile: TextLayoutProfile) -> Self:
709
+ self._layout = TextLayoutRequest.require_reflow(profile)
710
+ return self
711
+
712
+ def hyphenation_enabled(self, enabled: bool) -> Self:
713
+ self._hyphenation_enabled = enabled
714
+ return self
715
+
716
+ def layout(self, layout: Optional[TextLayoutRequest]) -> Self:
717
+ self._layout = layout
718
+ self._hyphenation_enabled = (
719
+ None if layout is None else layout.hyphenation_enabled
720
+ )
721
+ return self
722
+
723
+ def _selector(self) -> TextSelectorRequest:
724
+ return TextSelectorRequest(
725
+ self._literal,
726
+ self._regex,
727
+ self._case_sensitive,
728
+ self._whole_words,
729
+ self._max_matches,
730
+ )
731
+
732
+ def _resolved_layout(self) -> Optional[TextLayoutRequest]:
733
+ if self._layout is None:
734
+ return (
735
+ None
736
+ if self._hyphenation_enabled is None
737
+ else TextLayoutRequest(hyphenation_enabled=self._hyphenation_enabled)
738
+ )
739
+ return replace(self._layout, hyphenation_enabled=self._hyphenation_enabled)
740
+
741
+
742
+ @dataclass(frozen=True)
743
+ class TextReplaceRequest:
744
+ pages: Optional[tuple[int, ...]]
745
+ select: TextSelectorRequest
746
+ replace_with: Optional[str] = None
747
+ replace_with_image: Optional[TextReplacementImageRequest] = None
748
+ style: Optional[TextStyleSetRequest] = None
749
+ layout: Optional[TextLayoutRequest] = None
750
+
751
+ @classmethod
752
+ def literal(cls, text: str, replace_with: str) -> "TextReplaceBuilder":
753
+ return TextReplaceBuilder().literal(text).replace_with(replace_with)
754
+
755
+ @classmethod
756
+ def regex(cls, regex: str, replace_with: str) -> "TextReplaceBuilder":
757
+ return TextReplaceBuilder().regex(regex).replace_with(replace_with)
758
+
759
+ @classmethod
760
+ def builder(cls) -> "TextReplaceBuilder":
761
+ return TextReplaceBuilder()
762
+
763
+ def with_pages(self, pages: Sequence[int]) -> "TextReplaceRequest":
764
+ return replace(self, pages=tuple(pages)).validated()
765
+
766
+ def validated(self) -> "TextReplaceRequest":
767
+ _validated_pages(self.pages)
768
+ self.select.validated()
769
+ if (self.replace_with is None) == (self.replace_with_image is None):
770
+ raise ValidationException(
771
+ "Exactly one of replaceWith or replaceWithImage is required"
772
+ )
773
+ if self.replace_with_image is not None:
774
+ self.replace_with_image.validated()
775
+ if self.style is not None:
776
+ raise ValidationException("style is not valid with replaceWithImage")
777
+ if self.layout and self.layout.mode not in (
778
+ None,
779
+ TextLayoutMode.SOURCE_ANCHORED,
780
+ ):
781
+ raise ValidationException(
782
+ "replaceWithImage supports only sourceAnchored layout"
783
+ )
784
+ if self.style is not None:
785
+ self.style.validated()
786
+ if self.layout is not None:
787
+ self.layout.validated()
788
+ return self
789
+
790
+ def to_dict(self) -> dict[str, Any]:
791
+ self.validated()
792
+ return _without_none(
793
+ {
794
+ "pages": list(self.pages) if self.pages is not None else None,
795
+ "select": self.select.to_dict(),
796
+ "replaceWith": self.replace_with,
797
+ "replaceWithImage": (
798
+ self.replace_with_image.to_dict()
799
+ if self.replace_with_image
800
+ else None
801
+ ),
802
+ "style": self.style.to_dict() if self.style else None,
803
+ "layout": self.layout.to_dict() if self.layout else None,
804
+ }
805
+ )
806
+
807
+
808
+ class TextReplaceBuilder(_SelectorLayoutBuilder, _TextStyleBuilderBase):
809
+ """Build a validated text-replacement request."""
810
+
811
+ def __init__(self) -> None:
812
+ _SelectorLayoutBuilder.__init__(self)
813
+ _TextStyleBuilderBase.__init__(self)
814
+ self._replace_with: Optional[str] = None
815
+ self._replace_with_image: Optional[TextReplacementImageRequest] = None
816
+ self._style: Optional[TextStyleSetRequest] = None
817
+ self._reset_spacing_overrides: Optional[bool] = None
818
+
819
+ def replace_with(self, value: str) -> "TextReplaceBuilder":
820
+ self._replace_with, self._replace_with_image = value, None
821
+ return self
822
+
823
+ def replace_with_image(
824
+ self,
825
+ image: bytes | bytearray | Path | str,
826
+ transformation: PdfAffineTransform,
827
+ ) -> "TextReplaceBuilder":
828
+ if isinstance(image, (str, Path)):
829
+ data = Path(image).read_bytes()
830
+ else:
831
+ data = bytes(image)
832
+ self._replace_with = None
833
+ self._replace_with_image = TextReplacementImageRequest(data, transformation)
834
+ return self
835
+
836
+ def style(self, style: TextStyleSetRequest) -> "TextReplaceBuilder":
837
+ self._style = style
838
+ self._font = style.font
839
+ self._size = style.size
840
+ self._fill_color = style.fill_color
841
+ self._stroke_color = style.stroke_color
842
+ self._character_spacing = style.character_spacing
843
+ self._word_spacing = style.word_spacing
844
+ self._reset_spacing_overrides = style.reset_spacing_overrides
845
+ return self
846
+
847
+ def reset_spacing_overrides(self) -> "TextReplaceBuilder":
848
+ self._reset_spacing_overrides = True
849
+ return self
850
+
851
+ def build(self) -> TextReplaceRequest:
852
+ style_values = {
853
+ **self._kwargs(),
854
+ "reset_spacing_overrides": self._reset_spacing_overrides,
855
+ }
856
+ style = (
857
+ TextStyleSetRequest(**style_values)
858
+ if any(value is not None for value in style_values.values())
859
+ else None
860
+ )
861
+ layout = self._resolved_layout()
862
+ if self._replace_with_image is not None and layout is None:
863
+ layout = TextLayoutRequest.source_anchored()
864
+ return TextReplaceRequest(
865
+ self._pages,
866
+ self._selector(),
867
+ self._replace_with,
868
+ self._replace_with_image,
869
+ style,
870
+ layout,
871
+ ).validated()
872
+
873
+
874
+ @dataclass(frozen=True)
875
+ class TextDeleteRequest:
876
+ pages: Optional[tuple[int, ...]]
877
+ select: TextSelectorRequest
878
+ layout: Optional[TextLayoutRequest] = None
879
+
880
+ @classmethod
881
+ def literal(cls, text: str) -> "TextDeleteBuilder":
882
+ return TextDeleteBuilder().literal(text)
883
+
884
+ @classmethod
885
+ def regex(cls, regex: str) -> "TextDeleteBuilder":
886
+ return TextDeleteBuilder().regex(regex)
887
+
888
+ @classmethod
889
+ def builder(cls) -> "TextDeleteBuilder":
890
+ return TextDeleteBuilder()
891
+
892
+ def with_pages(self, pages: Sequence[int]) -> "TextDeleteRequest":
893
+ return replace(self, pages=tuple(pages)).validated()
894
+
895
+ def validated(self) -> "TextDeleteRequest":
896
+ _validated_pages(self.pages)
897
+ self.select.validated()
898
+ if self.layout:
899
+ self.layout.validated()
900
+ return self
901
+
902
+ def to_dict(self) -> dict[str, Any]:
903
+ self.validated()
904
+ return _without_none(
905
+ {
906
+ "pages": list(self.pages) if self.pages is not None else None,
907
+ "select": self.select.to_dict(),
908
+ "layout": self.layout.to_dict() if self.layout else None,
909
+ }
910
+ )
911
+
912
+
913
+ class TextDeleteBuilder(_SelectorLayoutBuilder):
914
+ """Build a validated text-deletion request."""
915
+
916
+ def build(self) -> TextDeleteRequest:
917
+ return TextDeleteRequest(
918
+ self._pages, self._selector(), self._resolved_layout()
919
+ ).validated()
920
+
921
+
922
+ class TextInsertCaret(Enum):
923
+ BEFORE = "before"
924
+ AFTER = "after"
925
+
926
+
927
+ class TextInsertStyleFrom(Enum):
928
+ ANCHOR = "anchor"
929
+
930
+
931
+ @dataclass(frozen=True)
932
+ class TextInsertAnchorTargetRequest:
933
+ pages: Optional[tuple[int, ...]]
934
+ select: TextSelectorRequest
935
+ caret: TextInsertCaret
936
+
937
+ def to_dict(self) -> dict[str, Any]:
938
+ _validated_pages(self.pages)
939
+ self.select.validated()
940
+ if self.caret is None:
941
+ raise ValidationException("target.anchor.caret must not be null")
942
+ return _without_none(
943
+ {
944
+ "pages": list(self.pages) if self.pages is not None else None,
945
+ "select": self.select.to_dict(),
946
+ "caret": self.caret.value,
947
+ }
948
+ )
949
+
950
+
951
+ @dataclass(frozen=True)
952
+ class TextInsertCoordinateTargetRequest:
953
+ page: Optional[int]
954
+ x: float
955
+ y: float
956
+ rotation_degrees: Optional[float] = None
957
+
958
+ def validated(
959
+ self, allow_missing_page: bool = False
960
+ ) -> "TextInsertCoordinateTargetRequest":
961
+ if self.page is None and not allow_missing_page:
962
+ raise ValidationException("target.coordinate.page must be >= 1")
963
+ if self.page is not None and self.page < 1:
964
+ raise ValidationException("target.coordinate.page must be >= 1")
965
+ _require_finite(self.x, "target.coordinate.x")
966
+ _require_finite(self.y, "target.coordinate.y")
967
+ if self.rotation_degrees is not None:
968
+ _require_finite(self.rotation_degrees, "target.coordinate.rotationDegrees")
969
+ return self
970
+
971
+ def to_dict(self) -> dict[str, Any]:
972
+ self.validated()
973
+ return _without_none(
974
+ {
975
+ "page": self.page,
976
+ "x": self.x,
977
+ "y": self.y,
978
+ "rotationDegrees": self.rotation_degrees,
979
+ }
980
+ )
981
+
982
+
983
+ @dataclass(frozen=True)
984
+ class TextInsertTargetRequest:
985
+ anchor: Optional[TextInsertAnchorTargetRequest] = None
986
+ coordinate: Optional[TextInsertCoordinateTargetRequest] = None
987
+
988
+ def to_dict(self) -> dict[str, Any]:
989
+ if (self.anchor is None) == (self.coordinate is None):
990
+ raise ValidationException(
991
+ "Exactly one of target.anchor or target.coordinate must be provided"
992
+ )
993
+ return _without_none(
994
+ {
995
+ "anchor": self.anchor.to_dict() if self.anchor else None,
996
+ "coordinate": self.coordinate.to_dict() if self.coordinate else None,
997
+ }
998
+ )
999
+
1000
+
1001
+ @dataclass(frozen=True)
1002
+ class TextInsertStyleRequest:
1003
+ from_style: Optional[TextInsertStyleFrom]
1004
+ patch: Optional[TextStylePatchRequest]
1005
+
1006
+ def to_dict(self) -> dict[str, Any]:
1007
+ if self.patch is not None:
1008
+ self.patch.validated()
1009
+ return _without_none(
1010
+ {
1011
+ "from": self.from_style.value if self.from_style else None,
1012
+ "patch": self.patch.to_dict() if self.patch else None,
1013
+ }
1014
+ )
1015
+
1016
+
1017
+ @dataclass(frozen=True)
1018
+ class TextInsertRequest:
1019
+ target: TextInsertTargetRequest
1020
+ insert: str
1021
+ style: TextInsertStyleRequest
1022
+ layout: Optional[TextLayoutRequest] = None
1023
+
1024
+ @classmethod
1025
+ def after(cls, anchor_literal: str, insert: str) -> "TextInsertBuilder":
1026
+ return TextInsertBuilder().literal(anchor_literal).insert(insert).after()
1027
+
1028
+ @classmethod
1029
+ def before(cls, anchor_literal: str, insert: str) -> "TextInsertBuilder":
1030
+ return TextInsertBuilder().literal(anchor_literal).insert(insert).before()
1031
+
1032
+ @classmethod
1033
+ def after_regex(cls, anchor_regex: str, insert: str) -> "TextInsertBuilder":
1034
+ return TextInsertBuilder().regex(anchor_regex).insert(insert).after()
1035
+
1036
+ @classmethod
1037
+ def before_regex(cls, anchor_regex: str, insert: str) -> "TextInsertBuilder":
1038
+ return TextInsertBuilder().regex(anchor_regex).insert(insert).before()
1039
+
1040
+ @classmethod
1041
+ def at(cls, page: int, x: float, y: float, insert: str) -> "TextInsertBuilder":
1042
+ return TextInsertBuilder().coordinate(page, x, y).insert(insert)
1043
+
1044
+ @classmethod
1045
+ def builder(cls) -> "TextInsertBuilder":
1046
+ return TextInsertBuilder()
1047
+
1048
+ def with_pages(self, pages: Sequence[int]) -> "TextInsertRequest":
1049
+ normalized = _validated_pages(pages)
1050
+ assert normalized is not None
1051
+ if self.target.anchor is not None:
1052
+ target = replace(
1053
+ self.target, anchor=replace(self.target.anchor, pages=normalized)
1054
+ )
1055
+ elif len(normalized) == 1 and self.target.coordinate is not None:
1056
+ target = replace(
1057
+ self.target,
1058
+ coordinate=replace(self.target.coordinate, page=normalized[0]),
1059
+ )
1060
+ else:
1061
+ target = self.target
1062
+ return replace(self, target=target).validated()
1063
+
1064
+ def validated(self) -> "TextInsertRequest":
1065
+ return self._validated(allow_missing_coordinate_page=False)
1066
+
1067
+ def _validated(self, allow_missing_coordinate_page: bool) -> "TextInsertRequest":
1068
+ if self.target is None:
1069
+ raise ValidationException("target must not be null")
1070
+ has_anchor = self.target.anchor is not None
1071
+ if has_anchor == (self.target.coordinate is not None):
1072
+ raise ValidationException(
1073
+ "Exactly one of target.anchor or target.coordinate must be provided"
1074
+ )
1075
+ if has_anchor:
1076
+ assert self.target.anchor is not None
1077
+ self.target.anchor.to_dict()
1078
+ else:
1079
+ assert self.target.coordinate is not None
1080
+ self.target.coordinate.validated(allow_missing_coordinate_page)
1081
+ if self.insert is None or self.insert == "":
1082
+ raise ValidationException("insert must not be null or empty")
1083
+ if self.style is None:
1084
+ raise ValidationException("style must not be null")
1085
+ if has_anchor and self.style.from_style is not TextInsertStyleFrom.ANCHOR:
1086
+ raise ValidationException("style.from must be anchor")
1087
+ if not has_anchor and self.style.from_style is not None:
1088
+ raise ValidationException(
1089
+ "style.from is not valid for coordinate insertion"
1090
+ )
1091
+ patch = self.style.patch
1092
+ if patch is not None:
1093
+ patch.validated()
1094
+ if not has_anchor and patch is None:
1095
+ raise ValidationException(
1096
+ "style.patch must not be null for coordinate insertion"
1097
+ )
1098
+ if (
1099
+ not has_anchor
1100
+ and patch is not None
1101
+ and (patch.font is None or patch.size is None)
1102
+ ):
1103
+ raise ValidationException(
1104
+ "coordinate insertion style.patch requires font and size"
1105
+ )
1106
+ if self.layout:
1107
+ self.layout.validated()
1108
+ return self
1109
+
1110
+ def to_dict(self) -> dict[str, Any]:
1111
+ self.validated()
1112
+ return _without_none(
1113
+ {
1114
+ "target": self.target.to_dict(),
1115
+ "insert": self.insert,
1116
+ "style": self.style.to_dict(),
1117
+ "layout": self.layout.to_dict() if self.layout else None,
1118
+ }
1119
+ )
1120
+
1121
+
1122
+ class TextInsertBuilder(_SelectorLayoutBuilder, _TextStyleBuilderBase):
1123
+ """Build a validated text-insertion request."""
1124
+
1125
+ def __init__(self) -> None:
1126
+ _SelectorLayoutBuilder.__init__(self)
1127
+ _TextStyleBuilderBase.__init__(self)
1128
+ self._caret: Optional[TextInsertCaret] = None
1129
+ self._coordinate: Optional[TextInsertCoordinateTargetRequest] = None
1130
+ self._insert: Optional[str] = None
1131
+
1132
+ def before(self) -> "TextInsertBuilder":
1133
+ self._caret, self._coordinate = TextInsertCaret.BEFORE, None
1134
+ return self
1135
+
1136
+ def after(self) -> "TextInsertBuilder":
1137
+ self._caret, self._coordinate = TextInsertCaret.AFTER, None
1138
+ return self
1139
+
1140
+ def caret(self, caret: TextInsertCaret) -> "TextInsertBuilder":
1141
+ self._caret, self._coordinate = caret, None
1142
+ return self
1143
+
1144
+ def coordinate(self, *args: float) -> "TextInsertBuilder":
1145
+ if len(args) == 3:
1146
+ page, x, y = args
1147
+ self._coordinate = TextInsertCoordinateTargetRequest(int(page), x, y)
1148
+ elif len(args) == 2:
1149
+ x, y = args
1150
+ self._coordinate = TextInsertCoordinateTargetRequest(None, x, y)
1151
+ else:
1152
+ raise TypeError("coordinate() requires (page, x, y) or (x, y)")
1153
+ self._caret = None
1154
+ return self
1155
+
1156
+ def rotation_degrees(self, degrees: float) -> "TextInsertBuilder":
1157
+ if self._coordinate is None:
1158
+ raise ValidationException("coordinate target must be set before rotation")
1159
+ self._coordinate = replace(self._coordinate, rotation_degrees=degrees)
1160
+ return self
1161
+
1162
+ def insert(self, insert: str) -> "TextInsertBuilder":
1163
+ self._insert = insert
1164
+ return self
1165
+
1166
+ def style_patch(self, patch: TextStylePatchRequest) -> "TextInsertBuilder":
1167
+ self._font = patch.font
1168
+ self._size = patch.size
1169
+ self._fill_color = patch.fill_color
1170
+ self._stroke_color = patch.stroke_color
1171
+ self._character_spacing = patch.character_spacing
1172
+ self._word_spacing = patch.word_spacing
1173
+ return self
1174
+
1175
+ def build(self) -> TextInsertRequest:
1176
+ if self._coordinate is not None:
1177
+ coordinate = self._coordinate
1178
+ if coordinate.page is None and self._pages and len(self._pages) == 1:
1179
+ coordinate = replace(coordinate, page=self._pages[0])
1180
+ target = TextInsertTargetRequest(coordinate=coordinate)
1181
+ from_style = None
1182
+ else:
1183
+ target = TextInsertTargetRequest(
1184
+ anchor=TextInsertAnchorTargetRequest(
1185
+ self._pages, self._selector(), self._caret # type: ignore[arg-type]
1186
+ )
1187
+ )
1188
+ from_style = TextInsertStyleFrom.ANCHOR
1189
+ patch_values = self._kwargs()
1190
+ patch = (
1191
+ TextStylePatchRequest(**patch_values)
1192
+ if any(value is not None for value in patch_values.values())
1193
+ else None
1194
+ )
1195
+ style = TextInsertStyleRequest(from_style, patch)
1196
+ request = TextInsertRequest(
1197
+ target, self._insert, style, self._resolved_layout() # type: ignore[arg-type]
1198
+ )
1199
+ return request._validated(
1200
+ allow_missing_coordinate_page=(
1201
+ self._coordinate is not None and self._coordinate.page is None
1202
+ )
1203
+ )
1204
+
1205
+
1206
+ @dataclass(frozen=True)
1207
+ class TextStyleRequest:
1208
+ pages: Optional[tuple[int, ...]]
1209
+ select: TextStyleSelectorRequest
1210
+ style: TextStyleSetRequest
1211
+ layout: Optional[TextLayoutRequest] = None
1212
+
1213
+ @classmethod
1214
+ def literal(cls, text: str) -> "TextStyleBuilder":
1215
+ return TextStyleBuilder().literal(text)
1216
+
1217
+ @classmethod
1218
+ def regex(cls, regex: str) -> "TextStyleBuilder":
1219
+ return TextStyleBuilder().regex(regex)
1220
+
1221
+ @classmethod
1222
+ def runs_where(cls) -> "TextStyleBuilder":
1223
+ return TextStyleBuilder().runs_where()
1224
+
1225
+ @classmethod
1226
+ def builder(cls) -> "TextStyleBuilder":
1227
+ return TextStyleBuilder()
1228
+
1229
+ def with_pages(self, pages: Sequence[int]) -> "TextStyleRequest":
1230
+ return replace(self, pages=tuple(pages)).validated()
1231
+
1232
+ def validated(self) -> "TextStyleRequest":
1233
+ _validated_pages(self.pages)
1234
+ if self.select is None:
1235
+ raise ValidationException("select must not be null")
1236
+ self.select.validated()
1237
+ if self.style is None:
1238
+ raise ValidationException("style must not be null")
1239
+ self.style.validated()
1240
+ if self.layout:
1241
+ self.layout.validated()
1242
+ return self
1243
+
1244
+ def to_dict(self) -> dict[str, Any]:
1245
+ self.validated()
1246
+ return _without_none(
1247
+ {
1248
+ "pages": list(self.pages) if self.pages is not None else None,
1249
+ "select": self.select.to_dict(),
1250
+ "style": self.style.to_dict(),
1251
+ "layout": self.layout.to_dict() if self.layout else None,
1252
+ }
1253
+ )
1254
+
1255
+
1256
+ class TextStyleBuilder(_SelectorLayoutBuilder, _TextStyleBuilderBase):
1257
+ """Build a validated request that changes existing text appearance."""
1258
+
1259
+ def __init__(self) -> None:
1260
+ _SelectorLayoutBuilder.__init__(self)
1261
+ _TextStyleBuilderBase.__init__(self)
1262
+ self._runs_where = False
1263
+ self._where_text_contains: Optional[str] = None
1264
+ self._where_font: Optional[str] = None
1265
+ self._where_size: Optional[TextStyleNumericFilterRequest] = None
1266
+ self._where_fill_color: Optional[PdfColorRequest] = None
1267
+ self._where_stroke_color: Optional[PdfColorRequest] = None
1268
+ self._where_character_spacing: Optional[TextStyleNumericFilterRequest] = None
1269
+ self._where_word_spacing: Optional[TextStyleNumericFilterRequest] = None
1270
+ self._where_contains_unmapped_glyphs: Optional[bool] = None
1271
+ self._reset_spacing_overrides: Optional[bool] = None
1272
+
1273
+ def literal(self, literal: str) -> "TextStyleBuilder":
1274
+ super().literal(literal)
1275
+ self._runs_where = False
1276
+ return self
1277
+
1278
+ def regex(self, regex: str) -> "TextStyleBuilder":
1279
+ super().regex(regex)
1280
+ self._runs_where = False
1281
+ return self
1282
+
1283
+ def runs_where(self) -> "TextStyleBuilder":
1284
+ self._runs_where = True
1285
+ self._literal = self._regex = None
1286
+ return self
1287
+
1288
+ def where_text_contains(self, text: str) -> "TextStyleBuilder":
1289
+ self.runs_where()
1290
+ self._where_text_contains = text
1291
+ return self
1292
+
1293
+ def where_font(self, font: str) -> "TextStyleBuilder":
1294
+ self.runs_where()
1295
+ self._where_font = font
1296
+ return self
1297
+
1298
+ def where_size(
1299
+ self, eq: float, tolerance: Optional[float] = None
1300
+ ) -> "TextStyleBuilder":
1301
+ self.runs_where()
1302
+ self._where_size = TextStyleNumericFilterRequest.equal_to(eq, tolerance)
1303
+ return self
1304
+
1305
+ def where_fill_color(self, color: PdfColorRequest) -> "TextStyleBuilder":
1306
+ self.runs_where()
1307
+ self._where_fill_color = color
1308
+ return self
1309
+
1310
+ def where_stroke_color(self, color: PdfColorRequest) -> "TextStyleBuilder":
1311
+ self.runs_where()
1312
+ self._where_stroke_color = color
1313
+ return self
1314
+
1315
+ def where_character_spacing(
1316
+ self, eq: float, tolerance: Optional[float] = None
1317
+ ) -> "TextStyleBuilder":
1318
+ self.runs_where()
1319
+ self._where_character_spacing = TextStyleNumericFilterRequest.equal_to(
1320
+ eq, tolerance
1321
+ )
1322
+ return self
1323
+
1324
+ def where_word_spacing(
1325
+ self, eq: float, tolerance: Optional[float] = None
1326
+ ) -> "TextStyleBuilder":
1327
+ self.runs_where()
1328
+ self._where_word_spacing = TextStyleNumericFilterRequest.equal_to(eq, tolerance)
1329
+ return self
1330
+
1331
+ def where_contains_unmapped_glyphs(self, value: bool) -> "TextStyleBuilder":
1332
+ self.runs_where()
1333
+ self._where_contains_unmapped_glyphs = value
1334
+ return self
1335
+
1336
+ def reset_spacing_overrides(self, value: bool = True) -> "TextStyleBuilder":
1337
+ self._reset_spacing_overrides = value
1338
+ return self
1339
+
1340
+ def build(self) -> TextStyleRequest:
1341
+ if self._runs_where:
1342
+ run_filter = TextStyleRunFilterRequest(
1343
+ self._where_text_contains,
1344
+ self._where_font,
1345
+ self._where_size,
1346
+ self._where_fill_color,
1347
+ self._where_stroke_color,
1348
+ self._where_character_spacing,
1349
+ self._where_word_spacing,
1350
+ self._where_contains_unmapped_glyphs,
1351
+ )
1352
+ selector = TextStyleSelectorRequest(
1353
+ runs=TextStyleRunsSelectorRequest(run_filter, self._max_matches)
1354
+ )
1355
+ else:
1356
+ selector = TextStyleSelectorRequest.from_text_selector(self._selector())
1357
+ style = TextStyleSetRequest(
1358
+ **self._kwargs(), reset_spacing_overrides=self._reset_spacing_overrides
1359
+ )
1360
+ return TextStyleRequest(
1361
+ self._pages, selector, style, self._resolved_layout()
1362
+ ).validated()
1363
+
1364
+
1365
+ @dataclass(frozen=True)
1366
+ class TextOperationDiagnostic:
1367
+ page: Optional[int]
1368
+ code: Optional[str]
1369
+ message: Optional[str]
1370
+ element_ids: Optional[tuple[str, ...]]
1371
+ reflow_unit_ids: Optional[tuple[str, ...]]
1372
+
1373
+ @classmethod
1374
+ def from_dict(cls, data: dict[str, Any]) -> "TextOperationDiagnostic":
1375
+ return cls(
1376
+ data.get("page"),
1377
+ data.get("code"),
1378
+ data.get("message"),
1379
+ None if data.get("elementIds") is None else tuple(data["elementIds"]),
1380
+ (
1381
+ None
1382
+ if data.get("reflowUnitIds") is None
1383
+ else tuple(data["reflowUnitIds"])
1384
+ ),
1385
+ )
1386
+
1387
+
1388
+ @dataclass(frozen=True)
1389
+ class TextEditChangeDiagnostic:
1390
+ page: Optional[int]
1391
+ operation: Optional[str]
1392
+ source_text: Optional[str]
1393
+ result_text: Optional[str]
1394
+ requested_layout_mode: Optional[str]
1395
+ requested_layout_profile: Optional[str]
1396
+ effective_hyphenation_enabled: bool
1397
+ applied_layout_mode: Optional[str]
1398
+ element_ids: Optional[tuple[str, ...]]
1399
+ generated_element_ids: Optional[tuple[str, ...]]
1400
+ reflow_unit_ids: Optional[tuple[str, ...]]
1401
+
1402
+ @classmethod
1403
+ def from_dict(cls, data: dict[str, Any]) -> "TextEditChangeDiagnostic":
1404
+ return cls(
1405
+ data.get("page"),
1406
+ data.get("operation"),
1407
+ data.get("sourceText"),
1408
+ data.get("resultText"),
1409
+ data.get("requestedLayoutMode"),
1410
+ data.get("requestedLayoutProfile"),
1411
+ bool(data.get("effectiveHyphenationEnabled", False)),
1412
+ data.get("appliedLayoutMode"),
1413
+ None if data.get("elementIds") is None else tuple(data["elementIds"]),
1414
+ (
1415
+ None
1416
+ if data.get("generatedElementIds") is None
1417
+ else tuple(data["generatedElementIds"])
1418
+ ),
1419
+ (
1420
+ None
1421
+ if data.get("reflowUnitIds") is None
1422
+ else tuple(data["reflowUnitIds"])
1423
+ ),
1424
+ )
1425
+
1426
+
1427
+ @dataclass(frozen=True)
1428
+ class TextEditResponse:
1429
+ matched: Optional[int]
1430
+ changed: Optional[int]
1431
+ pages_changed: Optional[tuple[int, ...]]
1432
+ change: Optional[tuple[TextEditChangeDiagnostic, ...]]
1433
+ warnings: Optional[tuple[TextOperationDiagnostic, ...]]
1434
+ errors: Optional[tuple[TextOperationDiagnostic, ...]]
1435
+
1436
+ @classmethod
1437
+ def from_dict(cls, data: dict[str, Any]) -> "TextEditResponse":
1438
+ return cls(
1439
+ data.get("matched"),
1440
+ data.get("changed"),
1441
+ (None if data.get("pagesChanged") is None else tuple(data["pagesChanged"])),
1442
+ (
1443
+ None
1444
+ if data.get("change") is None
1445
+ else tuple(
1446
+ TextEditChangeDiagnostic.from_dict(item) for item in data["change"]
1447
+ )
1448
+ ),
1449
+ (
1450
+ None
1451
+ if data.get("warnings") is None
1452
+ else tuple(
1453
+ TextOperationDiagnostic.from_dict(item) for item in data["warnings"]
1454
+ )
1455
+ ),
1456
+ (
1457
+ None
1458
+ if data.get("errors") is None
1459
+ else tuple(
1460
+ TextOperationDiagnostic.from_dict(item) for item in data["errors"]
1461
+ )
1462
+ ),
1463
+ )
1464
+
1465
+
1466
+ TextLayoutRequest.Mode = TextLayoutMode # type: ignore[attr-defined]
1467
+ TextLayoutRequest.Profile = TextLayoutProfile # type: ignore[attr-defined]
1468
+
1469
+
1470
+ __all__ = [
1471
+ name for name in globals() if name.startswith("Text") or name.startswith("Pdf")
1472
+ ]