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.
@@ -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)