pdfdancer-client-python 0.3.13__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/types.py CHANGED
@@ -1,15 +1,23 @@
1
1
  from __future__ import annotations
2
2
 
3
- import sys
4
3
  from dataclasses import dataclass
5
- from typing import TYPE_CHECKING, Optional
6
-
7
- from . import FormFieldRef, ObjectRef, ObjectType, PathObjectRef, Point, Position, TextObjectRef
4
+ from pathlib import Path
5
+ from types import TracebackType
6
+ from typing import TYPE_CHECKING, Any, Literal, Optional, Type, cast
7
+
8
+ from . import (
9
+ FormFieldRef,
10
+ ObjectRef,
11
+ ObjectType,
12
+ PathObjectRef,
13
+ Position,
14
+ )
8
15
  from .exceptions import ValidationException
16
+ from .models import BoundingRect as ModelBoundingRect
9
17
 
10
18
  if TYPE_CHECKING:
11
- from .models import Color, CommandResult, Image, ImageFlipDirection
12
- from .pdfdancer_v1 import PDFDancer
19
+ from .models import Color, CommandResult, Image, ImageFlipDirection, PathGroupInfo
20
+ from .pdfdancer_v2 import PDFDancer
13
21
 
14
22
 
15
23
  @dataclass
@@ -27,7 +35,7 @@ class UnsupportedOperation(Exception):
27
35
 
28
36
  class PDFObjectBase:
29
37
  """
30
- Base class for all PDF objects (paths, paragraphs, text lines, etc.)
38
+ Base class for selectable PDF object references (paths, text lines, etc.)
31
39
  providing shared behavior such as position, deletion, and movement.
32
40
  """
33
41
 
@@ -46,7 +54,7 @@ class PDFObjectBase:
46
54
  @property
47
55
  def page_number(self) -> int:
48
56
  """Page index where this object resides."""
49
- return self.position.page_number
57
+ return cast(int, self.position.page_number)
50
58
 
51
59
  def object_ref(self) -> ObjectRef:
52
60
  return ObjectRef(self.internal_id, self.position, self.object_type)
@@ -62,21 +70,13 @@ class PDFObjectBase:
62
70
  """Move this object to a new position."""
63
71
  return self._client._move(
64
72
  self.object_ref(),
65
- Position.at_page_coordinates(self.position.page_number, x, y),
73
+ Position.at_page_coordinates(cast(int, self.position.page_number), x, y),
66
74
  )
67
75
 
68
76
  def clear_clipping(self) -> bool:
69
77
  """Detach any active clipping path from this object."""
70
78
  return self._client.clear_clipping(self.object_ref())
71
79
 
72
- def redact(self, replacement: str = "[REDACTED]") -> bool:
73
- """Redact this object from the PDF document."""
74
- from .models import RedactTarget
75
-
76
- target = RedactTarget(self.internal_id, replacement)
77
- result = self._client._redact([target], replacement)
78
- return result.success
79
-
80
80
 
81
81
  # -------------------------------------------------------------------
82
82
  # Subclasses
@@ -86,7 +86,7 @@ class PDFObjectBase:
86
86
  class PathObject(PDFObjectBase):
87
87
  """Represents a vector path object inside a PDF page."""
88
88
 
89
- def __init__(self, client: "PDFDancer", object_ref):
89
+ def __init__(self, client: "PDFDancer", object_ref: ObjectRef):
90
90
  """
91
91
  Initialize a PathObject.
92
92
 
@@ -100,7 +100,7 @@ class PathObject(PDFObjectBase):
100
100
  self._object_ref = object_ref
101
101
 
102
102
  @property
103
- def bounding_box(self) -> Optional[BoundingRect]:
103
+ def bounding_box(self) -> Optional[ModelBoundingRect]:
104
104
  """Optional bounding rectangle (if available)."""
105
105
  return self.position.bounding_rect
106
106
 
@@ -108,7 +108,7 @@ class PathObject(PDFObjectBase):
108
108
  """Start a fluent editing session to modify path colors."""
109
109
  return PathEditSession(self._client, self.object_ref())
110
110
 
111
- def object_ref(self):
111
+ def object_ref(self) -> ObjectRef:
112
112
  """Return an ObjectRef for this path."""
113
113
  return self._object_ref
114
114
 
@@ -124,7 +124,7 @@ class PathObject(PDFObjectBase):
124
124
  return self._object_ref.get_fill_color()
125
125
  return None
126
126
 
127
- def __eq__(self, other):
127
+ def __eq__(self, other: object) -> bool:
128
128
  if not isinstance(other, PathObject):
129
129
  return False
130
130
  return (
@@ -137,6 +137,24 @@ class PathObject(PDFObjectBase):
137
137
  class ImageObject(PDFObjectBase):
138
138
  """Represents an image object inside a PDF page."""
139
139
 
140
+ @property
141
+ def width(self) -> Optional[float]:
142
+ return (
143
+ self.position.bounding_rect.width if self.position.bounding_rect else None
144
+ )
145
+
146
+ @property
147
+ def height(self) -> Optional[float]:
148
+ return (
149
+ self.position.bounding_rect.height if self.position.bounding_rect else None
150
+ )
151
+
152
+ @property
153
+ def aspect_ratio(self) -> Optional[float]:
154
+ return (
155
+ self.width / self.height if self.width is not None and self.height else None
156
+ )
157
+
140
158
  def scale(self, factor: float) -> "CommandResult":
141
159
  """Scale this image by a factor.
142
160
 
@@ -182,7 +200,7 @@ class ImageObject(PDFObjectBase):
182
200
  """Rotate this image by a specified angle.
183
201
 
184
202
  Args:
185
- angle: Rotation angle in degrees (positive = counter-clockwise)
203
+ angle: Rotation angle in degrees (positive = clockwise)
186
204
 
187
205
  Returns:
188
206
  CommandResult indicating success or failure
@@ -263,6 +281,16 @@ class ImageObject(PDFObjectBase):
263
281
  )
264
282
  return self._client._transform_image(request)
265
283
 
284
+ def flip_horizontal(self) -> "CommandResult":
285
+ from .models import ImageFlipDirection
286
+
287
+ return self.flip(ImageFlipDirection.HORIZONTAL)
288
+
289
+ def flip_vertical(self) -> "CommandResult":
290
+ from .models import ImageFlipDirection
291
+
292
+ return self.flip(ImageFlipDirection.VERTICAL)
293
+
266
294
  def replace(self, new_image: "Image") -> "CommandResult":
267
295
  """Replace this image with a new image.
268
296
 
@@ -281,6 +309,17 @@ class ImageObject(PDFObjectBase):
281
309
  )
282
310
  return self._client._transform_image(request)
283
311
 
312
+ def replace_from_file(self, image_path: Path) -> "CommandResult":
313
+ from .models import Image
314
+
315
+ path = Path(image_path)
316
+ if not path.is_file():
317
+ raise ValidationException(f"Image file not found: {path}")
318
+ data = path.read_bytes()
319
+ if not data:
320
+ raise ValidationException("Image file cannot be empty")
321
+ return self.replace(Image(format=path.suffix.lstrip(".").upper(), data=data))
322
+
284
323
  def fill_region(
285
324
  self, x: int, y: int, width: int, height: int, color: "Color"
286
325
  ) -> "CommandResult":
@@ -319,7 +358,7 @@ class ImageObject(PDFObjectBase):
319
358
  )
320
359
  return self._client._transform_image(request)
321
360
 
322
- def __eq__(self, other):
361
+ def __eq__(self, other: object) -> bool:
323
362
  if not isinstance(other, ImageObject):
324
363
  return False
325
364
  return (
@@ -332,7 +371,9 @@ class ImageObject(PDFObjectBase):
332
371
  class PathGroupObject:
333
372
  """Represents a group of vector paths that can be manipulated as a unit."""
334
373
 
335
- def __init__(self, client: "PDFDancer", page_index: int, info):
374
+ def __init__(
375
+ self, client: "PDFDancer", page_index: int, info: "PathGroupInfo"
376
+ ) -> None:
336
377
  self._client = client
337
378
  self._page_index = page_index
338
379
  self._info = info
@@ -346,7 +387,7 @@ class PathGroupObject:
346
387
  return self._info.path_count
347
388
 
348
389
  @property
349
- def bounding_box(self):
390
+ def bounding_box(self) -> Optional[dict[str, Any]]:
350
391
  return self._info.bounding_box
351
392
 
352
393
  @property
@@ -358,36 +399,33 @@ class PathGroupObject:
358
399
  return self._info.y
359
400
 
360
401
  def move_to(self, x: float, y: float) -> bool:
361
- self._client._move_path_group(self._page_index, self.group_id, x, y)
362
- return True
402
+ return self._client._move_path_group(self._page_index, self.group_id, x, y)
363
403
 
364
404
  def scale(self, factor: float) -> bool:
365
- self._client._scale_path_group(self._page_index, self.group_id, factor)
366
- return True
405
+ return self._client._scale_path_group(self._page_index, self.group_id, factor)
367
406
 
368
407
  def rotate(self, degrees: float) -> bool:
369
- self._client._rotate_path_group(self._page_index, self.group_id, degrees)
370
- return True
408
+ return self._client._rotate_path_group(self._page_index, self.group_id, degrees)
371
409
 
372
410
  def resize(self, width: float, height: float) -> bool:
373
- self._client._resize_path_group(self._page_index, self.group_id, width, height)
374
- return True
411
+ return self._client._resize_path_group(
412
+ self._page_index, self.group_id, width, height
413
+ )
375
414
 
376
415
  def remove(self) -> bool:
377
- self._client._remove_path_group(self._page_index, self.group_id)
378
- return True
416
+ return self._client._remove_path_group(self._page_index, self.group_id)
379
417
 
380
418
  def clear_clipping(self) -> bool:
381
419
  return self._client.clear_path_group_clipping(
382
420
  self._page_index + 1, self.group_id
383
421
  )
384
422
 
385
- def __repr__(self):
423
+ def __repr__(self) -> str:
386
424
  return f"PathGroupObject(group_id={self.group_id!r}, path_count={self.path_count}, page_index={self._page_index})"
387
425
 
388
426
 
389
427
  class FormObject(PDFObjectBase):
390
- def __eq__(self, other):
428
+ def __eq__(self, other: object) -> bool:
391
429
  if not isinstance(other, FormObject):
392
430
  return False
393
431
  return (
@@ -397,357 +435,6 @@ class FormObject(PDFObjectBase):
397
435
  )
398
436
 
399
437
 
400
- class BaseTextEdit:
401
- """Common base for text-like editable objects (Paragraph, TextLine, etc.)"""
402
-
403
- def __init__(self, target_obj, object_ref):
404
- self._color = None
405
- self._position = None
406
- self._font_size = None
407
- self._font_name = None
408
- self._new_text = None
409
- self._target_obj = target_obj
410
- self._object_ref = object_ref
411
-
412
- def __enter__(self):
413
- return self
414
-
415
- def __exit__(self, exc_type, exc_val, exc_tb):
416
- if not exc_type:
417
- self.apply()
418
-
419
- # --- Common fluent configuration methods ---
420
-
421
- def replace(self, text: str):
422
- self._new_text = text
423
- return self
424
-
425
- def font(self, font_name: str, font_size: float):
426
- self._font_name = font_name
427
- self._font_size = font_size
428
- return self
429
-
430
- def color(self, color):
431
- self._color = color
432
- return self
433
-
434
- def move_to(self, x: float, y: float):
435
- self._position = Position().at_coordinates(Point(x, y))
436
- return self
437
-
438
- # --- Abstract method: implemented by subclass ---
439
- def apply(self):
440
- raise NotImplementedError("Subclasses must implement apply()")
441
-
442
-
443
- class TextLineEdit(BaseTextEdit):
444
- def apply(self) -> bool:
445
- # If only text changed (no font, color, or position), use simple text modification
446
- only_text_changed = (
447
- self._new_text is not None
448
- and self._font_name is None
449
- and self._font_size is None
450
- and self._color is None
451
- and self._position is None
452
- )
453
-
454
- if only_text_changed:
455
- # noinspection PyProtectedMember
456
- result = self._target_obj._client._modify_text_line(
457
- self._object_ref, self._new_text
458
- )
459
- if result.warning:
460
- print(f"WARNING: {result.warning}", file=sys.stderr)
461
- return result
462
-
463
- # If only position changed (move operation)
464
- only_move = (
465
- self._position is not None
466
- and self._new_text is None
467
- and self._font_name is None
468
- and self._font_size is None
469
- and self._color is None
470
- )
471
-
472
- if only_move:
473
- page_number = (
474
- self._object_ref.position.page_number
475
- if self._object_ref.position
476
- else None
477
- )
478
- if page_number is None:
479
- raise ValidationException(
480
- "Text line position must include a page number to move"
481
- )
482
-
483
- # Extract x, y from self._position
484
- x = self._position.x()
485
- y = self._position.y()
486
- if x is None or y is None:
487
- raise ValidationException("Position must have x and y coordinates")
488
-
489
- position = Position.at_page_coordinates(page_number, x, y)
490
- # noinspection PyProtectedMember
491
- result = self._target_obj._client._move(self._object_ref, position)
492
- return result
493
-
494
- # For font/color changes or combined operations, use TextLineBuilder
495
- # This ensures proper handling of font/color fallbacks just like ParagraphEditSession
496
- from .text_line_builder import TextLineBuilder
497
-
498
- builder = TextLineBuilder.from_object_ref(
499
- self._target_obj._client, self._object_ref
500
- )
501
-
502
- # Apply modifications to builder
503
- # IMPORTANT: Always explicitly set text to ensure it's preserved
504
- if self._new_text is not None:
505
- builder.text(self._new_text)
506
- elif hasattr(self._object_ref, "text") and self._object_ref.text:
507
- # Preserve original text when only changing font/color/position
508
- builder.text(self._object_ref.text)
509
-
510
- # IMPORTANT: Always explicitly set font to ensure it's preserved
511
- if self._font_name is not None and self._font_size is not None:
512
- builder.font(self._font_name, self._font_size)
513
- elif hasattr(self._object_ref, "font_name") and hasattr(
514
- self._object_ref, "font_size"
515
- ):
516
- if self._object_ref.font_name and self._object_ref.font_size:
517
- # Preserve original font when only changing color/position
518
- builder.font(self._object_ref.font_name, self._object_ref.font_size)
519
-
520
- if self._color is not None:
521
- builder.color(self._color)
522
- if self._position is not None:
523
- x = self._position.x()
524
- y = self._position.y()
525
- if x is None or y is None:
526
- raise ValidationException("Position must have x and y coordinates")
527
- page_number = (
528
- self._object_ref.position.page_number
529
- if self._object_ref.position
530
- else None
531
- )
532
- if page_number is None:
533
- raise ValidationException(
534
- "Text line position must include a page number"
535
- )
536
- builder.at(page_number, x, y)
537
-
538
- # Use builder's modify method which handles all the complexity
539
- result = builder.modify(self._object_ref)
540
- if result.warning:
541
- print(f"WARNING: {result.warning}", file=sys.stderr)
542
- return result
543
-
544
-
545
- class ParagraphObject(PDFObjectBase):
546
- """Represents a paragraph text block inside a PDF page."""
547
-
548
- def __init__(self, client: "PDFDancer", object_ref: TextObjectRef):
549
- super().__init__(
550
- client, object_ref.internal_id, object_ref.type, object_ref.position
551
- )
552
- self._object_ref = object_ref
553
-
554
- def __getattr__(self, name):
555
- """
556
- Automatically delegate attribute/method lookup to _object_ref
557
- if it's not found on this object.
558
- """
559
- return getattr(self._object_ref, name)
560
-
561
- def edit(self):
562
- return ParagraphEditSession(self._client, self.object_ref())
563
-
564
- def object_ref(self) -> TextObjectRef:
565
- return self._object_ref
566
-
567
- def __eq__(self, other):
568
- if not isinstance(other, ParagraphObject):
569
- return False
570
- return (
571
- self.internal_id == other.internal_id
572
- and self.object_type == other.object_type
573
- and self.position == other.position
574
- and self._object_ref.text == other._object_ref.text
575
- and self._object_ref.font_name == other._object_ref.font_name
576
- and self._object_ref.font_size == other._object_ref.font_size
577
- and self._object_ref.line_spacings == other._object_ref.line_spacings
578
- and self._object_ref.color == other._object_ref.color
579
- and self._object_ref.children == other._object_ref.children
580
- )
581
-
582
-
583
- class TextLineObject(PDFObjectBase):
584
- """Represents a single line of text inside a PDF page."""
585
-
586
- def __init__(self, client: "PDFDancer", object_ref: TextObjectRef):
587
- super().__init__(
588
- client, object_ref.internal_id, object_ref.type, object_ref.position
589
- )
590
- self._object_ref = object_ref
591
-
592
- def __getattr__(self, name):
593
- """
594
- Automatically delegate attribute/method lookup to _object_ref
595
- if it's not found on this object.
596
- """
597
- return getattr(self._object_ref, name)
598
-
599
- def edit(self) -> TextLineEdit:
600
- return TextLineEdit(self, self.object_ref())
601
-
602
- def object_ref(self) -> TextObjectRef:
603
- return self._object_ref
604
-
605
- def __eq__(self, other):
606
- if not isinstance(other, TextLineObject):
607
- return False
608
- return (
609
- self.internal_id == other.internal_id
610
- and self.object_type == other.object_type
611
- and self.position == other.position
612
- and self._object_ref.text == other._object_ref.text
613
- and self._object_ref.font_name == other._object_ref.font_name
614
- and self._object_ref.font_size == other._object_ref.font_size
615
- and self._object_ref.line_spacings == other._object_ref.line_spacings
616
- and self._object_ref.color == other._object_ref.color
617
- and self._object_ref.children == other._object_ref.children
618
- )
619
-
620
-
621
- class ParagraphEditSession:
622
- """
623
- Fluent editing helper that reuses ParagraphBuilder for modifications while preserving
624
- the legacy context-manager workflow (replace/font/color/etc.).
625
- """
626
-
627
- def __init__(self, client: "PDFDancer", object_ref: TextObjectRef):
628
- self._client = client
629
- self._object_ref = object_ref
630
- self._new_text = None
631
- self._font_name = None
632
- self._font_size = None
633
- self._color = None
634
- self._line_spacing = None
635
- self._new_position = None
636
- self._has_changes = False
637
-
638
- def __enter__(self):
639
- return self
640
-
641
- def __exit__(self, exc_type, exc_val, exc_tb):
642
- if exc_type:
643
- return False
644
- self.apply()
645
- return False
646
-
647
- def replace(self, text: str):
648
- self._new_text = text
649
- self._has_changes = True
650
- return self
651
-
652
- def font(self, font_name, font_size: float):
653
- self._font_name = font_name
654
- self._font_size = font_size
655
- self._has_changes = True
656
- return self
657
-
658
- def color(self, color):
659
- self._color = color
660
- self._has_changes = True
661
- return self
662
-
663
- def line_spacing(self, spacing: float):
664
- self._line_spacing = spacing
665
- self._has_changes = True
666
- return self
667
-
668
- def move_to(self, x: float, y: float):
669
- self._new_position = (x, y)
670
- self._has_changes = True
671
- return self
672
-
673
- def apply(self):
674
- if not self._has_changes:
675
- return self._client._modify_paragraph(self._object_ref, None)
676
-
677
- only_text_changed = (
678
- self._new_text is not None
679
- and self._font_name is None
680
- and self._font_size is None
681
- and self._color is None
682
- and self._line_spacing is None
683
- and self._new_position is None
684
- )
685
-
686
- if only_text_changed:
687
- result = self._client._modify_paragraph(self._object_ref, self._new_text)
688
- self._has_changes = False
689
- return result
690
-
691
- only_move = (
692
- self._new_position is not None
693
- and self._new_text is None
694
- and self._font_name is None
695
- and self._font_size is None
696
- and self._color is None
697
- and self._line_spacing is None
698
- )
699
-
700
- if only_move:
701
- page_number = (
702
- self._object_ref.position.page_number
703
- if self._object_ref.position
704
- else None
705
- )
706
- if page_number is None:
707
- raise ValidationException(
708
- "Paragraph position must include a page number to move"
709
- )
710
- position = Position.at_page_coordinates(page_number, *self._new_position)
711
- result = self._client._move(self._object_ref, position)
712
- self._has_changes = False
713
- return result
714
-
715
- from .paragraph_builder import ParagraphBuilder
716
-
717
- builder = ParagraphBuilder.from_object_ref(self._client, self._object_ref)
718
-
719
- if self._new_text is not None:
720
- builder.text(self._new_text)
721
- if self._font_name is not None and self._font_size is not None:
722
- builder.font(self._font_name, self._font_size)
723
- if self._color is not None:
724
- builder.color(self._color)
725
- if self._line_spacing is not None:
726
- builder.line_spacing(self._line_spacing)
727
- if self._new_position is not None:
728
- builder.move_to(*self._new_position)
729
-
730
- result = builder.modify(self._object_ref)
731
- self._has_changes = False
732
- return result
733
-
734
-
735
- class FormFieldEdit:
736
- def __init__(self, form_field: "FormFieldObject", object_ref: FormFieldRef):
737
- self.form_field = form_field
738
- self.object_ref = object_ref
739
-
740
- def value(self, new_value: str) -> "FormFieldEdit":
741
- self.form_field.value = new_value
742
- return self
743
-
744
- def apply(self) -> bool:
745
- # noinspection PyProtectedMember
746
- return self.form_field._client._change_form_field(
747
- self.object_ref, self.form_field.value
748
- )
749
-
750
-
751
438
  class FormFieldObject(PDFObjectBase):
752
439
  def __init__(
753
440
  self,
@@ -762,8 +449,11 @@ class FormFieldObject(PDFObjectBase):
762
449
  self.name = field_name
763
450
  self.value = field_value
764
451
 
765
- def edit(self) -> FormFieldEdit:
766
- return FormFieldEdit(self, self.object_ref())
452
+ def set_value(self, value: str) -> bool:
453
+ result = self._client._change_form_field(self.object_ref(), value)
454
+ if result:
455
+ self.value = value
456
+ return result
767
457
 
768
458
  def object_ref(self) -> FormFieldRef:
769
459
  ref = FormFieldRef(self.internal_id, self.position, self.object_type)
@@ -771,7 +461,7 @@ class FormFieldObject(PDFObjectBase):
771
461
  ref.value = self.value
772
462
  return ref
773
463
 
774
- def __eq__(self, other):
464
+ def __eq__(self, other: object) -> bool:
775
465
  if not isinstance(other, FormFieldObject):
776
466
  return False
777
467
  return (
@@ -788,22 +478,27 @@ class PathEditSession:
788
478
  Fluent editing helper for modifying path stroke and fill colors.
789
479
  """
790
480
 
791
- def __init__(self, client: "PDFDancer", object_ref):
481
+ def __init__(self, client: "PDFDancer", object_ref: ObjectRef) -> None:
792
482
  self._client = client
793
483
  self._object_ref = object_ref
794
- self._stroke_color = None
795
- self._fill_color = None
484
+ self._stroke_color: Optional["Color"] = None
485
+ self._fill_color: Optional["Color"] = None
796
486
 
797
- def __enter__(self):
487
+ def __enter__(self) -> "PathEditSession":
798
488
  return self
799
489
 
800
- def __exit__(self, exc_type, exc_val, exc_tb):
490
+ def __exit__(
491
+ self,
492
+ exc_type: Optional[Type[BaseException]],
493
+ exc_val: Optional[BaseException],
494
+ exc_tb: Optional[TracebackType],
495
+ ) -> Literal[False]:
801
496
  if exc_type:
802
497
  return False
803
498
  self.apply()
804
499
  return False
805
500
 
806
- def stroke_color(self, color) -> "PathEditSession":
501
+ def stroke_color(self, color: "Color") -> "PathEditSession":
807
502
  """
808
503
  Set the stroke/outline color.
809
504
 
@@ -816,7 +511,7 @@ class PathEditSession:
816
511
  self._stroke_color = color
817
512
  return self
818
513
 
819
- def fill_color(self, color) -> "PathEditSession":
514
+ def fill_color(self, color: "Color") -> "PathEditSession":
820
515
  """
821
516
  Set the fill color.
822
517
 
@@ -829,7 +524,7 @@ class PathEditSession:
829
524
  self._fill_color = color
830
525
  return self
831
526
 
832
- def apply(self):
527
+ def apply(self) -> "CommandResult":
833
528
  """
834
529
  Apply the color modifications to the path.
835
530