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
pdfdancer/paragraph_builder.py
DELETED
|
@@ -1,554 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
ParagraphBuilder for the PDFDancer Python client.
|
|
3
|
-
Mirrors the behaviour of the Java implementation while keeping Python conventions.
|
|
4
|
-
"""
|
|
5
|
-
|
|
6
|
-
from __future__ import annotations
|
|
7
|
-
|
|
8
|
-
from copy import deepcopy
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
from typing import TYPE_CHECKING, List, Optional, Union
|
|
11
|
-
|
|
12
|
-
from . import StandardFonts
|
|
13
|
-
from .exceptions import ValidationException
|
|
14
|
-
from .models import (
|
|
15
|
-
Color,
|
|
16
|
-
Font,
|
|
17
|
-
ObjectRef,
|
|
18
|
-
Paragraph,
|
|
19
|
-
Point,
|
|
20
|
-
Position,
|
|
21
|
-
TextLine,
|
|
22
|
-
TextObjectRef,
|
|
23
|
-
)
|
|
24
|
-
|
|
25
|
-
if TYPE_CHECKING:
|
|
26
|
-
from .pdfdancer_v1 import PDFDancer
|
|
27
|
-
|
|
28
|
-
DEFAULT_LINE_SPACING_FACTOR = 1.2
|
|
29
|
-
DEFAULT_TEXT_COLOR = Color(0, 0, 0)
|
|
30
|
-
_DEFAULT_BASE_FONT_SIZE = 12.0
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
class ParagraphBuilder:
|
|
34
|
-
"""
|
|
35
|
-
Fluent builder used to assemble `Paragraph` instances.
|
|
36
|
-
Behaviour is aligned with the Java `ParagraphBuilder` so that mixed client/server
|
|
37
|
-
scenarios stay predictable.
|
|
38
|
-
"""
|
|
39
|
-
|
|
40
|
-
def __init__(self, client: "PDFDancer"):
|
|
41
|
-
if client is None:
|
|
42
|
-
raise ValidationException("Client cannot be null")
|
|
43
|
-
|
|
44
|
-
self._client = client
|
|
45
|
-
self._paragraph = Paragraph()
|
|
46
|
-
self._line_spacing_factor: Optional[float] = None
|
|
47
|
-
self._text_color: Optional[Color] = None
|
|
48
|
-
self._text: Optional[str] = None
|
|
49
|
-
self._ttf_file: Optional[Path] = None
|
|
50
|
-
self._font: Optional[Font] = None
|
|
51
|
-
self._font_explicitly_changed = False
|
|
52
|
-
self._original_paragraph_position: Optional[Position] = None
|
|
53
|
-
self._target_object_ref: Optional[ObjectRef] = None
|
|
54
|
-
self._original_font: Optional[Font] = None
|
|
55
|
-
self._original_color: Optional[Color] = None
|
|
56
|
-
self._position_changed = False
|
|
57
|
-
|
|
58
|
-
def only_text_changed(self) -> bool:
|
|
59
|
-
"""Return True when only the text payload has been modified."""
|
|
60
|
-
return (
|
|
61
|
-
self._text is not None
|
|
62
|
-
and self._text_color is None
|
|
63
|
-
and self._ttf_file is None
|
|
64
|
-
and (self._font is None or not self._font_explicitly_changed)
|
|
65
|
-
and self._line_spacing_factor is None
|
|
66
|
-
)
|
|
67
|
-
|
|
68
|
-
def text(self, text: str, color: Optional[Color] = None) -> "ParagraphBuilder":
|
|
69
|
-
if text is None:
|
|
70
|
-
raise ValidationException("Text cannot be null")
|
|
71
|
-
self._text = text
|
|
72
|
-
if color is not None:
|
|
73
|
-
self.color(color)
|
|
74
|
-
return self
|
|
75
|
-
|
|
76
|
-
def font(
|
|
77
|
-
self, font: Union[Font, str, StandardFonts], font_size: Optional[float] = None
|
|
78
|
-
) -> "ParagraphBuilder":
|
|
79
|
-
"""
|
|
80
|
-
Configure the font either by providing a `Font` instance or name + size.
|
|
81
|
-
"""
|
|
82
|
-
if isinstance(font, Font):
|
|
83
|
-
resolved_font = font
|
|
84
|
-
else:
|
|
85
|
-
if isinstance(font, StandardFonts):
|
|
86
|
-
font = font.value
|
|
87
|
-
if font is None:
|
|
88
|
-
raise ValidationException("Font name cannot be null")
|
|
89
|
-
if font_size is None:
|
|
90
|
-
raise ValidationException(
|
|
91
|
-
"Font size must be provided when setting font by name"
|
|
92
|
-
)
|
|
93
|
-
resolved_font = Font(str(font), font_size)
|
|
94
|
-
|
|
95
|
-
self._font = resolved_font
|
|
96
|
-
self._ttf_file = None
|
|
97
|
-
self._font_explicitly_changed = True
|
|
98
|
-
return self
|
|
99
|
-
|
|
100
|
-
def font_file(
|
|
101
|
-
self, ttf_file: Union[Path, str], font_size: float
|
|
102
|
-
) -> "ParagraphBuilder":
|
|
103
|
-
if ttf_file is None:
|
|
104
|
-
raise ValidationException("TTF file cannot be null")
|
|
105
|
-
if font_size <= 0:
|
|
106
|
-
raise ValidationException(f"Font size must be positive, got {font_size}")
|
|
107
|
-
|
|
108
|
-
ttf_path = Path(ttf_file)
|
|
109
|
-
|
|
110
|
-
if not ttf_path.exists():
|
|
111
|
-
raise ValidationException(f"TTF file does not exist: {ttf_path}")
|
|
112
|
-
if not ttf_path.is_file():
|
|
113
|
-
raise ValidationException(f"TTF file is not a file: {ttf_path}")
|
|
114
|
-
if ttf_path.stat().st_size <= 0:
|
|
115
|
-
raise ValidationException(f"TTF file is empty: {ttf_path}")
|
|
116
|
-
|
|
117
|
-
try:
|
|
118
|
-
with open(ttf_path, "rb") as handle:
|
|
119
|
-
handle.read(1)
|
|
120
|
-
except (IOError, OSError) as exc:
|
|
121
|
-
raise ValidationException(f"TTF file is not readable: {ttf_path}") from exc
|
|
122
|
-
|
|
123
|
-
self._ttf_file = ttf_path
|
|
124
|
-
self._font = self._register_ttf(ttf_path, font_size)
|
|
125
|
-
self._font_explicitly_changed = True
|
|
126
|
-
return self
|
|
127
|
-
|
|
128
|
-
def set_font_explicitly_changed(self, changed: bool) -> None:
|
|
129
|
-
self._font_explicitly_changed = bool(changed)
|
|
130
|
-
|
|
131
|
-
def set_original_paragraph_position(self, position: Position) -> None:
|
|
132
|
-
self._original_paragraph_position = position
|
|
133
|
-
if position and self._paragraph.get_position() is None:
|
|
134
|
-
self._paragraph.set_position(deepcopy(position))
|
|
135
|
-
|
|
136
|
-
def target(self, object_ref: ObjectRef) -> "ParagraphBuilder":
|
|
137
|
-
if object_ref is None:
|
|
138
|
-
raise ValidationException("Object reference cannot be null")
|
|
139
|
-
self._target_object_ref = object_ref
|
|
140
|
-
return self
|
|
141
|
-
|
|
142
|
-
def line_spacing(self, spacing: float) -> "ParagraphBuilder":
|
|
143
|
-
if spacing <= 0:
|
|
144
|
-
raise ValidationException(f"Line spacing must be positive, got {spacing}")
|
|
145
|
-
self._line_spacing_factor = spacing
|
|
146
|
-
return self
|
|
147
|
-
|
|
148
|
-
def color(self, color: Color) -> "ParagraphBuilder":
|
|
149
|
-
if color is None:
|
|
150
|
-
raise ValidationException("Color cannot be null")
|
|
151
|
-
self._text_color = color
|
|
152
|
-
return self
|
|
153
|
-
|
|
154
|
-
def move_to(self, x: float, y: float) -> "ParagraphBuilder":
|
|
155
|
-
"""
|
|
156
|
-
Move the paragraph anchor to new coordinates on the same page.
|
|
157
|
-
"""
|
|
158
|
-
position = self._paragraph.get_position()
|
|
159
|
-
if (
|
|
160
|
-
position is None
|
|
161
|
-
and self._target_object_ref
|
|
162
|
-
and self._target_object_ref.position
|
|
163
|
-
):
|
|
164
|
-
position = deepcopy(self._target_object_ref.position)
|
|
165
|
-
self._paragraph.set_position(position)
|
|
166
|
-
|
|
167
|
-
if position is None:
|
|
168
|
-
raise ValidationException(
|
|
169
|
-
"Cannot move paragraph without an existing position"
|
|
170
|
-
)
|
|
171
|
-
|
|
172
|
-
page_number = position.page_number
|
|
173
|
-
if page_number is None:
|
|
174
|
-
raise ValidationException(
|
|
175
|
-
"Paragraph position must include a page number to move"
|
|
176
|
-
)
|
|
177
|
-
|
|
178
|
-
self._position_changed = True
|
|
179
|
-
return self.at(page_number, x, y)
|
|
180
|
-
|
|
181
|
-
def at_position(self, position: Position) -> "ParagraphBuilder":
|
|
182
|
-
if position is None:
|
|
183
|
-
raise ValidationException("Position cannot be null")
|
|
184
|
-
# Defensive copy so builder mutations do not alter original references
|
|
185
|
-
self._paragraph.set_position(deepcopy(position))
|
|
186
|
-
self._position_changed = True
|
|
187
|
-
return self
|
|
188
|
-
|
|
189
|
-
def at(self, page_number: int, x: float, y: float) -> "ParagraphBuilder":
|
|
190
|
-
return self.at_position(Position.at_page_coordinates(page_number, x, y))
|
|
191
|
-
|
|
192
|
-
def add_text_line(
|
|
193
|
-
self, text_line: Union[TextLine, TextObjectRef, str]
|
|
194
|
-
) -> "ParagraphBuilder":
|
|
195
|
-
self._paragraph.add_line(self._coerce_text_line(text_line))
|
|
196
|
-
return self
|
|
197
|
-
|
|
198
|
-
def get_text(self) -> Optional[str]:
|
|
199
|
-
return self._text
|
|
200
|
-
|
|
201
|
-
def add(self) -> bool:
|
|
202
|
-
# noinspection PyProtectedMember
|
|
203
|
-
return self._client._add_paragraph(self._finalize_paragraph())
|
|
204
|
-
|
|
205
|
-
def modify(self, object_ref: Optional[ObjectRef] = None):
|
|
206
|
-
target_ref = object_ref or self._target_object_ref
|
|
207
|
-
if target_ref is None:
|
|
208
|
-
raise ValidationException(
|
|
209
|
-
"Object reference must be provided to modify a paragraph"
|
|
210
|
-
)
|
|
211
|
-
|
|
212
|
-
if self.only_text_changed():
|
|
213
|
-
# Backend accepts plain text updates for simple edits
|
|
214
|
-
return self._client._modify_paragraph(target_ref, self._text or "")
|
|
215
|
-
|
|
216
|
-
paragraph = self._finalize_paragraph()
|
|
217
|
-
return self._client._modify_paragraph(target_ref, paragraph)
|
|
218
|
-
|
|
219
|
-
# ------------------------------------------------------------------ #
|
|
220
|
-
# Internal helpers
|
|
221
|
-
# ------------------------------------------------------------------ #
|
|
222
|
-
|
|
223
|
-
def _finalize_paragraph(self) -> Paragraph:
|
|
224
|
-
if self._paragraph.get_position() is None:
|
|
225
|
-
raise ValidationException("Position must be set before building paragraph")
|
|
226
|
-
|
|
227
|
-
if (
|
|
228
|
-
self._target_object_ref is None
|
|
229
|
-
and self._font is None
|
|
230
|
-
and self._paragraph.font is None
|
|
231
|
-
):
|
|
232
|
-
raise ValidationException("Font must be set before building paragraph")
|
|
233
|
-
|
|
234
|
-
if self._text is not None:
|
|
235
|
-
self._finalize_lines_from_text()
|
|
236
|
-
elif not self._paragraph.text_lines:
|
|
237
|
-
raise ValidationException(
|
|
238
|
-
"Either text must be provided or existing lines supplied"
|
|
239
|
-
)
|
|
240
|
-
else:
|
|
241
|
-
self._finalize_existing_lines()
|
|
242
|
-
|
|
243
|
-
self._reposition_lines()
|
|
244
|
-
|
|
245
|
-
should_skip_lines = (
|
|
246
|
-
self._position_changed
|
|
247
|
-
and self._text is None
|
|
248
|
-
and self._text_color is None
|
|
249
|
-
and (self._font is None or not self._font_explicitly_changed)
|
|
250
|
-
and self._line_spacing_factor is None
|
|
251
|
-
)
|
|
252
|
-
if should_skip_lines:
|
|
253
|
-
self._paragraph.text_lines = None
|
|
254
|
-
self._paragraph.set_line_spacings(None)
|
|
255
|
-
|
|
256
|
-
final_font = self._font if self._font is not None else self._original_font
|
|
257
|
-
if final_font is None:
|
|
258
|
-
final_font = Font(StandardFonts.HELVETICA.value, _DEFAULT_BASE_FONT_SIZE)
|
|
259
|
-
self._paragraph.font = final_font
|
|
260
|
-
|
|
261
|
-
if self._text_color is not None:
|
|
262
|
-
final_color = self._text_color
|
|
263
|
-
elif self._text is not None:
|
|
264
|
-
final_color = self._original_color or DEFAULT_TEXT_COLOR
|
|
265
|
-
else:
|
|
266
|
-
final_color = self._original_color
|
|
267
|
-
self._paragraph.color = final_color
|
|
268
|
-
return self._paragraph
|
|
269
|
-
|
|
270
|
-
def _finalize_lines_from_text(self) -> None:
|
|
271
|
-
base_font = self._font or self._original_font
|
|
272
|
-
base_color = self._text_color or self._original_color or DEFAULT_TEXT_COLOR
|
|
273
|
-
color = base_color
|
|
274
|
-
|
|
275
|
-
if self._line_spacing_factor is not None:
|
|
276
|
-
spacing = self._line_spacing_factor
|
|
277
|
-
else:
|
|
278
|
-
existing_spacings = self._paragraph.get_line_spacings()
|
|
279
|
-
if existing_spacings:
|
|
280
|
-
spacing = existing_spacings[0]
|
|
281
|
-
elif self._paragraph.line_spacing:
|
|
282
|
-
spacing = self._paragraph.line_spacing
|
|
283
|
-
else:
|
|
284
|
-
spacing = DEFAULT_LINE_SPACING_FACTOR
|
|
285
|
-
|
|
286
|
-
self._paragraph.clear_lines()
|
|
287
|
-
lines: List[TextLine] = []
|
|
288
|
-
for index, line_text in enumerate(self._split_text(self._text or "")):
|
|
289
|
-
line_position = self._calculate_line_position(index, spacing)
|
|
290
|
-
lines.append(
|
|
291
|
-
TextLine(
|
|
292
|
-
position=line_position,
|
|
293
|
-
font=base_font,
|
|
294
|
-
color=color,
|
|
295
|
-
line_spacing=spacing,
|
|
296
|
-
text=line_text,
|
|
297
|
-
)
|
|
298
|
-
)
|
|
299
|
-
self._paragraph.set_lines(lines)
|
|
300
|
-
self._paragraph.set_line_spacings(
|
|
301
|
-
[spacing] * (len(lines) - 1) if len(lines) > 1 else None
|
|
302
|
-
)
|
|
303
|
-
self._paragraph.line_spacing = spacing
|
|
304
|
-
|
|
305
|
-
def _finalize_existing_lines(self) -> None:
|
|
306
|
-
lines = self._paragraph.text_lines or []
|
|
307
|
-
spacing_override = self._line_spacing_factor
|
|
308
|
-
spacing_for_calc = spacing_override
|
|
309
|
-
|
|
310
|
-
if spacing_for_calc is None:
|
|
311
|
-
existing_spacings = self._paragraph.get_line_spacings()
|
|
312
|
-
if existing_spacings:
|
|
313
|
-
spacing_for_calc = existing_spacings[0]
|
|
314
|
-
if spacing_for_calc is None:
|
|
315
|
-
spacing_for_calc = (
|
|
316
|
-
self._paragraph.line_spacing or DEFAULT_LINE_SPACING_FACTOR
|
|
317
|
-
)
|
|
318
|
-
|
|
319
|
-
updated_lines: List[TextLine] = []
|
|
320
|
-
for index, line in enumerate(lines):
|
|
321
|
-
if isinstance(line, TextLine):
|
|
322
|
-
if spacing_override is not None:
|
|
323
|
-
line.line_spacing = spacing_override
|
|
324
|
-
if self._text_color is not None:
|
|
325
|
-
line.color = self._text_color
|
|
326
|
-
if self._font is not None and self._font_explicitly_changed:
|
|
327
|
-
line.font = self._font
|
|
328
|
-
updated_lines.append(line)
|
|
329
|
-
else:
|
|
330
|
-
line_position = self._calculate_line_position(index, spacing_for_calc)
|
|
331
|
-
updated_lines.append(
|
|
332
|
-
TextLine(
|
|
333
|
-
position=line_position,
|
|
334
|
-
font=(
|
|
335
|
-
self._font
|
|
336
|
-
if self._font is not None
|
|
337
|
-
else self._original_font
|
|
338
|
-
),
|
|
339
|
-
color=self._text_color
|
|
340
|
-
or self._original_color
|
|
341
|
-
or DEFAULT_TEXT_COLOR,
|
|
342
|
-
line_spacing=(
|
|
343
|
-
spacing_override
|
|
344
|
-
if spacing_override is not None
|
|
345
|
-
else spacing_for_calc
|
|
346
|
-
),
|
|
347
|
-
text=str(line),
|
|
348
|
-
)
|
|
349
|
-
)
|
|
350
|
-
|
|
351
|
-
self._paragraph.set_lines(updated_lines)
|
|
352
|
-
|
|
353
|
-
if spacing_override is not None:
|
|
354
|
-
self._paragraph.set_line_spacings(
|
|
355
|
-
[spacing_override] * (len(updated_lines) - 1)
|
|
356
|
-
if len(updated_lines) > 1
|
|
357
|
-
else None
|
|
358
|
-
)
|
|
359
|
-
self._paragraph.line_spacing = spacing_override
|
|
360
|
-
|
|
361
|
-
def _reposition_lines(self) -> None:
|
|
362
|
-
if self._text is not None:
|
|
363
|
-
# Newly generated text lines already align with the updated paragraph position.
|
|
364
|
-
return
|
|
365
|
-
paragraph_pos = self._paragraph.get_position()
|
|
366
|
-
lines = self._paragraph.text_lines or []
|
|
367
|
-
if not paragraph_pos or not lines:
|
|
368
|
-
return
|
|
369
|
-
|
|
370
|
-
base_position = self._original_paragraph_position
|
|
371
|
-
if base_position is None:
|
|
372
|
-
for line in lines:
|
|
373
|
-
if isinstance(line, TextLine) and line.position is not None:
|
|
374
|
-
base_position = line.position
|
|
375
|
-
break
|
|
376
|
-
|
|
377
|
-
if base_position is None:
|
|
378
|
-
return
|
|
379
|
-
|
|
380
|
-
target_x = paragraph_pos.x()
|
|
381
|
-
target_y = paragraph_pos.y()
|
|
382
|
-
base_x = base_position.x()
|
|
383
|
-
base_y = base_position.y()
|
|
384
|
-
if None in (target_x, target_y, base_x, base_y):
|
|
385
|
-
return
|
|
386
|
-
|
|
387
|
-
dx = target_x - base_x
|
|
388
|
-
dy = target_y - base_y
|
|
389
|
-
if dx == 0 and dy == 0:
|
|
390
|
-
return
|
|
391
|
-
|
|
392
|
-
for line in lines:
|
|
393
|
-
if isinstance(line, TextLine) and line.position is not None:
|
|
394
|
-
current_x = line.position.x()
|
|
395
|
-
current_y = line.position.y()
|
|
396
|
-
if current_x is None or current_y is None:
|
|
397
|
-
continue
|
|
398
|
-
line.position.at_coordinates(Point(current_x + dx, current_y + dy))
|
|
399
|
-
|
|
400
|
-
def _coerce_text_line(
|
|
401
|
-
self, source: Union[TextLine, TextObjectRef, str]
|
|
402
|
-
) -> TextLine:
|
|
403
|
-
if isinstance(source, TextLine):
|
|
404
|
-
return source
|
|
405
|
-
|
|
406
|
-
if isinstance(source, TextObjectRef):
|
|
407
|
-
font = None
|
|
408
|
-
if source.font_name and source.font_size:
|
|
409
|
-
font = Font(source.font_name, source.font_size)
|
|
410
|
-
elif getattr(source, "children", None):
|
|
411
|
-
for child in source.children:
|
|
412
|
-
if child.font_name and child.font_size:
|
|
413
|
-
font = Font(child.font_name, child.font_size)
|
|
414
|
-
break
|
|
415
|
-
if font is None:
|
|
416
|
-
font = self._original_font
|
|
417
|
-
|
|
418
|
-
spacing = self._line_spacing_factor
|
|
419
|
-
if spacing is None and source.line_spacings:
|
|
420
|
-
spacing = source.line_spacings[0]
|
|
421
|
-
if spacing is None:
|
|
422
|
-
spacing = self._paragraph.line_spacing or DEFAULT_LINE_SPACING_FACTOR
|
|
423
|
-
|
|
424
|
-
color = source.color or self._original_color
|
|
425
|
-
|
|
426
|
-
line = TextLine(
|
|
427
|
-
position=deepcopy(source.position) if source.position else None,
|
|
428
|
-
font=font,
|
|
429
|
-
color=color,
|
|
430
|
-
line_spacing=spacing,
|
|
431
|
-
text=source.text or "",
|
|
432
|
-
)
|
|
433
|
-
if self._original_font is None and font is not None:
|
|
434
|
-
self._original_font = font
|
|
435
|
-
if self._original_color is None and color is not None:
|
|
436
|
-
self._original_color = color
|
|
437
|
-
return line
|
|
438
|
-
|
|
439
|
-
if isinstance(source, str):
|
|
440
|
-
current_index = len(self._paragraph.get_lines())
|
|
441
|
-
spacing = (
|
|
442
|
-
self._line_spacing_factor
|
|
443
|
-
if self._line_spacing_factor is not None
|
|
444
|
-
else (self._paragraph.line_spacing or DEFAULT_LINE_SPACING_FACTOR)
|
|
445
|
-
)
|
|
446
|
-
line_position = self._calculate_line_position(current_index, spacing)
|
|
447
|
-
return TextLine(
|
|
448
|
-
position=line_position,
|
|
449
|
-
font=self._font or self._original_font,
|
|
450
|
-
color=self._text_color or self._original_color or DEFAULT_TEXT_COLOR,
|
|
451
|
-
line_spacing=spacing,
|
|
452
|
-
text=source,
|
|
453
|
-
)
|
|
454
|
-
|
|
455
|
-
raise ValidationException(f"Unsupported text line type: {type(source)}")
|
|
456
|
-
|
|
457
|
-
def _split_text(self, text: str) -> List[str]:
|
|
458
|
-
processed = text.replace("\r\n", "\n").replace("\r", "\n").replace("\\n", "\n")
|
|
459
|
-
parts = processed.split("\n")
|
|
460
|
-
while parts and parts[-1] == "":
|
|
461
|
-
parts.pop()
|
|
462
|
-
if not parts:
|
|
463
|
-
parts = [""]
|
|
464
|
-
return parts
|
|
465
|
-
|
|
466
|
-
def _calculate_line_position(
|
|
467
|
-
self, line_index: int, spacing_factor: float
|
|
468
|
-
) -> Optional[Position]:
|
|
469
|
-
paragraph_position = self._paragraph.get_position()
|
|
470
|
-
if paragraph_position is None:
|
|
471
|
-
return None
|
|
472
|
-
|
|
473
|
-
page_number = paragraph_position.page_number
|
|
474
|
-
base_x = paragraph_position.x()
|
|
475
|
-
base_y = paragraph_position.y()
|
|
476
|
-
if page_number is None or base_x is None or base_y is None:
|
|
477
|
-
return None
|
|
478
|
-
|
|
479
|
-
offset = line_index * self._calculate_baseline_distance(spacing_factor)
|
|
480
|
-
return Position.at_page_coordinates(page_number, base_x, base_y + offset)
|
|
481
|
-
|
|
482
|
-
def _calculate_baseline_distance(self, spacing_factor: float) -> float:
|
|
483
|
-
factor = spacing_factor if spacing_factor > 0 else DEFAULT_LINE_SPACING_FACTOR
|
|
484
|
-
return self._baseline_font_size() * factor
|
|
485
|
-
|
|
486
|
-
def _baseline_font_size(self) -> float:
|
|
487
|
-
if self._font and self._font.size:
|
|
488
|
-
return self._font.size
|
|
489
|
-
if self._original_font and self._original_font.size:
|
|
490
|
-
return self._original_font.size
|
|
491
|
-
return _DEFAULT_BASE_FONT_SIZE
|
|
492
|
-
|
|
493
|
-
def _register_ttf(self, ttf_file: Path, font_size: float) -> Font:
|
|
494
|
-
try:
|
|
495
|
-
font_name = self._client.register_font(ttf_file)
|
|
496
|
-
return Font(font_name, font_size)
|
|
497
|
-
except Exception as exc:
|
|
498
|
-
raise ValidationException(
|
|
499
|
-
f"Failed to register font file {ttf_file}: {exc}"
|
|
500
|
-
) from exc
|
|
501
|
-
|
|
502
|
-
def _build(self) -> Paragraph:
|
|
503
|
-
"""
|
|
504
|
-
Backwards-compatible alias for callers that invoked the previous `_build`.
|
|
505
|
-
"""
|
|
506
|
-
return self._finalize_paragraph()
|
|
507
|
-
|
|
508
|
-
@classmethod
|
|
509
|
-
def from_object_ref(
|
|
510
|
-
cls, client: "PDFDancer", object_ref: TextObjectRef
|
|
511
|
-
) -> "ParagraphBuilder":
|
|
512
|
-
if object_ref is None:
|
|
513
|
-
raise ValidationException("Object reference cannot be null")
|
|
514
|
-
|
|
515
|
-
builder = cls(client)
|
|
516
|
-
builder.target(object_ref)
|
|
517
|
-
|
|
518
|
-
if object_ref.position:
|
|
519
|
-
builder.at_position(object_ref.position)
|
|
520
|
-
builder.set_original_paragraph_position(object_ref.position)
|
|
521
|
-
|
|
522
|
-
if object_ref.line_spacings:
|
|
523
|
-
builder._paragraph.set_line_spacings(object_ref.line_spacings)
|
|
524
|
-
builder._paragraph.line_spacing = (
|
|
525
|
-
object_ref.line_spacings[0]
|
|
526
|
-
if object_ref.line_spacings
|
|
527
|
-
else builder._paragraph.line_spacing
|
|
528
|
-
)
|
|
529
|
-
|
|
530
|
-
if object_ref.font_name and object_ref.font_size:
|
|
531
|
-
builder._original_font = Font(object_ref.font_name, object_ref.font_size)
|
|
532
|
-
|
|
533
|
-
if object_ref.color:
|
|
534
|
-
builder._original_color = object_ref.color
|
|
535
|
-
|
|
536
|
-
if object_ref.children:
|
|
537
|
-
for child in object_ref.children:
|
|
538
|
-
builder.add_text_line(child)
|
|
539
|
-
elif object_ref.text:
|
|
540
|
-
for segment in object_ref.text.split("\n"):
|
|
541
|
-
builder.add_text_line(segment)
|
|
542
|
-
|
|
543
|
-
return builder
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
class ParagraphPageBuilder(ParagraphBuilder):
|
|
547
|
-
|
|
548
|
-
def __init__(self, client: "PDFDancer", page_number: int):
|
|
549
|
-
super().__init__(client)
|
|
550
|
-
self._page_number: Optional[int] = page_number
|
|
551
|
-
|
|
552
|
-
# noinspection PyMethodOverriding
|
|
553
|
-
def at(self, x: float, y: float) -> "ParagraphBuilder":
|
|
554
|
-
return super().at(self._page_number, x, y)
|