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.
- pdfdancer/__init__.py +77 -22
- pdfdancer/_runtime_version.py +8 -3
- pdfdancer/_version.py +2 -2
- pdfdancer/image_builder.py +23 -3
- pdfdancer/models.py +127 -470
- pdfdancer/page_builder.py +6 -17
- pdfdancer/path_builder.py +127 -6
- pdfdancer/{pdfdancer_v1.py → pdfdancer_v2.py} +848 -1310
- pdfdancer/text_editing.py +1472 -0
- pdfdancer/types.py +94 -399
- {pdfdancer_client_python-0.3.14.dist-info → pdfdancer_client_python-3.0.0.dist-info}/METADATA +181 -137
- pdfdancer_client_python-3.0.0.dist-info/RECORD +18 -0
- {pdfdancer_client_python-0.3.14.dist-info → pdfdancer_client_python-3.0.0.dist-info}/WHEEL +1 -1
- pdfdancer/paragraph_builder.py +0 -554
- pdfdancer/text_line_builder.py +0 -290
- pdfdancer_client_python-0.3.14.dist-info/RECORD +0 -19
- {pdfdancer_client_python-0.3.14.dist-info → pdfdancer_client_python-3.0.0.dist-info}/licenses/LICENSE +0 -0
- {pdfdancer_client_python-0.3.14.dist-info → pdfdancer_client_python-3.0.0.dist-info}/licenses/NOTICE +0 -0
- {pdfdancer_client_python-0.3.14.dist-info → pdfdancer_client_python-3.0.0.dist-info}/top_level.txt +0 -0
|
@@ -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
|
+
]
|