pdfdancer-client-python 0.2.17__py3-none-any.whl → 0.2.19__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 CHANGED
@@ -13,14 +13,18 @@ from .exceptions import (
13
13
  from .models import (
14
14
  ObjectRef, Position, ObjectType, Font, Color, Image, BoundingRect, Paragraph, FormFieldRef, TextObjectRef,
15
15
  PageRef, PositionMode, ShapeType, Point, StandardFonts, PageSize, Orientation, TextStatus, FontRecommendation,
16
- FontType
16
+ FontType, PathSegment, Line, Bezier, Path
17
17
  )
18
18
  from .paragraph_builder import ParagraphBuilder
19
+ from .path_builder import PathBuilder, LineBuilder, BezierBuilder
19
20
 
20
21
  __version__ = "1.0.0"
21
22
  __all__ = [
22
23
  "PDFDancer",
23
24
  "ParagraphBuilder",
25
+ "PathBuilder",
26
+ "LineBuilder",
27
+ "BezierBuilder",
24
28
  "ObjectRef",
25
29
  "Position",
26
30
  "ObjectType",
@@ -41,6 +45,10 @@ __all__ = [
41
45
  "TextStatus",
42
46
  "FontRecommendation",
43
47
  "FontType",
48
+ "PathSegment",
49
+ "Line",
50
+ "Bezier",
51
+ "Path",
44
52
  "PdfDancerException",
45
53
  "FontNotFoundException",
46
54
  "ValidationException",
pdfdancer/exceptions.py CHANGED
@@ -5,7 +5,7 @@ Mirrors the Java client exception hierarchy.
5
5
 
6
6
  from typing import Optional
7
7
 
8
- import requests
8
+ import httpx
9
9
 
10
10
 
11
11
  class PdfDancerException(Exception):
@@ -32,10 +32,10 @@ class FontNotFoundException(PdfDancerException):
32
32
  class HttpClientException(PdfDancerException):
33
33
  """
34
34
  Exception raised for HTTP client errors during API communication.
35
- Wraps requests exceptions and HTTP errors from the API.
35
+ Wraps httpx exceptions and HTTP errors from the API.
36
36
  """
37
37
 
38
- def __init__(self, message: str, response: Optional[requests.Response] = None, cause: Optional[Exception] = None):
38
+ def __init__(self, message: str, response: Optional[httpx.Response] = None, cause: Optional[Exception] = None):
39
39
  super().__init__(message, cause)
40
40
  self.response = response
41
41
  self.status_code = response.status_code if response else None
@@ -0,0 +1,121 @@
1
+ import hashlib
2
+ import locale
3
+ import os
4
+ import platform
5
+ import socket
6
+ import uuid
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+
11
+ class Fingerprint:
12
+ """Generates a fingerprint hash for API request identification."""
13
+
14
+ _SALT_FILE = Path.home() / ".pdfdancer" / "fingerprint.salt"
15
+
16
+ @classmethod
17
+ def generate(cls) -> str:
18
+ """Generate X-Fingerprint header value.
19
+
20
+ Returns:
21
+ SHA256 hash of fingerprint components
22
+ """
23
+ ip_hash = cls._get_local_ip()
24
+ uid_hash = cls._get_uid()
25
+ os_type = platform.system()
26
+ sdk_language = "python"
27
+ timezone = cls._get_timezone()
28
+ locale_str = cls._get_locale()
29
+ domain_hash = cls._get_hostname()
30
+ install_salt = cls._get_or_create_salt()
31
+
32
+ fingerprint_data = (
33
+ ip_hash +
34
+ uid_hash +
35
+ os_type +
36
+ sdk_language +
37
+ timezone +
38
+ locale_str +
39
+ domain_hash +
40
+ install_salt
41
+ )
42
+
43
+ return cls._hash(fingerprint_data)
44
+
45
+ @classmethod
46
+ def _get_local_ip(cls) -> str:
47
+ """Get local IP address."""
48
+ try:
49
+ return socket.gethostbyname(socket.gethostname())
50
+ except Exception:
51
+ return "unknown"
52
+
53
+ @classmethod
54
+ def _get_uid(cls) -> str:
55
+ """Get user login name."""
56
+ try:
57
+ return os.getlogin()
58
+ except Exception:
59
+ return "unknown"
60
+
61
+ @classmethod
62
+ def _get_timezone(cls) -> str:
63
+ """Get timezone name."""
64
+ try:
65
+ tz = datetime.now().astimezone().tzinfo
66
+ timezone_name = getattr(tz, 'key', str(tz))
67
+ return timezone_name
68
+ except Exception:
69
+ return "unknown"
70
+
71
+ @classmethod
72
+ def _get_locale(cls) -> str:
73
+ """Get default locale."""
74
+ try:
75
+ loc = locale.getlocale()[0]
76
+ return loc if loc else "en_US"
77
+ except Exception:
78
+ return "unknown"
79
+
80
+ @classmethod
81
+ def _get_hostname(cls) -> str:
82
+ """Get local hostname."""
83
+ try:
84
+ return socket.gethostname()
85
+ except Exception:
86
+ return "unknown"
87
+
88
+ @classmethod
89
+ def _get_or_create_salt(cls) -> str:
90
+ """Get or create persistent install salt.
91
+
92
+ Returns:
93
+ UUID string used as install salt
94
+ """
95
+ if cls._SALT_FILE.exists():
96
+ try:
97
+ return cls._SALT_FILE.read_text().strip()
98
+ except Exception:
99
+ pass
100
+
101
+ # Create salt file
102
+ salt = str(uuid.uuid4())
103
+ try:
104
+ cls._SALT_FILE.parent.mkdir(parents=True, exist_ok=True)
105
+ cls._SALT_FILE.write_text(salt)
106
+ except Exception:
107
+ pass
108
+
109
+ return salt
110
+
111
+ @classmethod
112
+ def _hash(cls, value: str) -> str:
113
+ """Generate SHA256 hash of value.
114
+
115
+ Args:
116
+ value: String to hash
117
+
118
+ Returns:
119
+ Hexadecimal SHA256 hash
120
+ """
121
+ return hashlib.sha256(value.encode('utf-8')).hexdigest()
pdfdancer/models.py CHANGED
@@ -164,6 +164,8 @@ class ObjectType(Enum):
164
164
  TEXT_FIELD = "TEXT_FIELD"
165
165
  CHECK_BOX = "CHECK_BOX"
166
166
  RADIO_BUTTON = "RADIO_BUTTON"
167
+ BUTTON = "BUTTON"
168
+ DROPDOWN = "DROPDOWN"
167
169
 
168
170
 
169
171
  class PositionMode(Enum):
@@ -309,10 +311,15 @@ class ObjectRef:
309
311
 
310
312
  def to_dict(self) -> dict:
311
313
  """Convert to dictionary for JSON serialization."""
314
+ # Normalize type back to API format (API uses "CHECKBOX" not "CHECK_BOX")
315
+ type_value = self.type.value
316
+ if type_value == "CHECK_BOX":
317
+ type_value = "CHECKBOX"
318
+
312
319
  return {
313
320
  "internalId": self.internal_id,
314
321
  "position": FindRequest._position_to_dict(self.position),
315
- "type": self.type.value
322
+ "type": type_value
316
323
  }
317
324
 
318
325
 
@@ -341,6 +348,117 @@ class Font:
341
348
  raise ValueError(f"Font size must be positive, got {self.size}")
342
349
 
343
350
 
351
+ @dataclass
352
+ class PathSegment:
353
+ """
354
+ Abstract base class for individual path segments within vector paths.
355
+ This class provides common properties for path elements including stroke and fill colors,
356
+ line width, and positioning. Concrete subclasses implement specific geometric shapes
357
+ like lines, curves, and bezier segments.
358
+ """
359
+ stroke_color: Optional[Color] = None
360
+ fill_color: Optional[Color] = None
361
+ stroke_width: Optional[float] = None
362
+ dash_array: Optional[List[float]] = None
363
+ dash_phase: Optional[float] = None
364
+
365
+ def get_stroke_color(self) -> Optional[Color]:
366
+ """Color used for drawing the segment's outline or stroke."""
367
+ return self.stroke_color
368
+
369
+ def get_fill_color(self) -> Optional[Color]:
370
+ """Color used for filling the segment's interior area (if applicable)."""
371
+ return self.fill_color
372
+
373
+ def get_stroke_width(self) -> Optional[float]:
374
+ """Width of the stroke line in PDF coordinate units."""
375
+ return self.stroke_width
376
+
377
+ def get_dash_array(self) -> Optional[List[float]]:
378
+ """Dash pattern for stroking the path segment. Null or empty means solid line."""
379
+ return self.dash_array
380
+
381
+ def get_dash_phase(self) -> Optional[float]:
382
+ """Dash phase (offset) into the dash pattern in user space units."""
383
+ return self.dash_phase
384
+
385
+
386
+ @dataclass
387
+ class Line(PathSegment):
388
+ """
389
+ Represents a straight line path segment between two points.
390
+ This class defines a linear path element connecting two coordinate points,
391
+ commonly used in vector graphics and geometric shapes within PDF documents.
392
+ """
393
+ p0: Optional[Point] = None
394
+ p1: Optional[Point] = None
395
+
396
+ def get_p0(self) -> Optional[Point]:
397
+ """Returns the starting point of this line segment."""
398
+ return self.p0
399
+
400
+ def get_p1(self) -> Optional[Point]:
401
+ """Returns the ending point of this line segment."""
402
+ return self.p1
403
+
404
+
405
+ @dataclass
406
+ class Bezier(PathSegment):
407
+ """
408
+ Represents a cubic Bezier curve path segment defined by four control points.
409
+ This class implements a cubic Bezier curve with start point, two control points,
410
+ and end point, providing smooth curved path segments for complex vector graphics.
411
+ """
412
+ p0: Optional[Point] = None
413
+ p1: Optional[Point] = None
414
+ p2: Optional[Point] = None
415
+ p3: Optional[Point] = None
416
+
417
+ def get_p0(self) -> Optional[Point]:
418
+ """Returns the starting point p0 of this Bezier segment."""
419
+ return self.p0
420
+
421
+ def get_p1(self) -> Optional[Point]:
422
+ """Returns the first control point p1 of this Bezier segment."""
423
+ return self.p1
424
+
425
+ def get_p2(self) -> Optional[Point]:
426
+ """Returns the second control point p2 of this Bezier segment."""
427
+ return self.p2
428
+
429
+ def get_p3(self) -> Optional[Point]:
430
+ """Returns the ending point p3 of this Bezier segment."""
431
+ return self.p3
432
+
433
+
434
+ @dataclass
435
+ class Path:
436
+ """
437
+ Represents a complex vector path consisting of multiple path segments.
438
+ This class encapsulates vector graphics data within PDF documents, composed of
439
+ various path elements like lines, curves, and shapes.
440
+ """
441
+ position: Optional[Position] = None
442
+ path_segments: Optional[List[PathSegment]] = None
443
+ even_odd_fill: Optional[bool] = None
444
+
445
+ def get_position(self) -> Optional[Position]:
446
+ """Returns the position of this path."""
447
+ return self.position
448
+
449
+ def set_position(self, position: Position) -> None:
450
+ """Sets the position of this path."""
451
+ self.position = position
452
+
453
+ def get_path_segments(self) -> Optional[List[PathSegment]]:
454
+ """Returns the list of path segments that compose this path."""
455
+ return self.path_segments
456
+
457
+ def get_even_odd_fill(self) -> Optional[bool]:
458
+ """Returns whether even-odd fill rule should be used (true) or nonzero (false)."""
459
+ return self.even_odd_fill
460
+
461
+
344
462
  @dataclass
345
463
  class Image:
346
464
  """
@@ -428,12 +546,9 @@ class DeleteRequest:
428
546
 
429
547
  def to_dict(self) -> dict:
430
548
  """Convert to dictionary for JSON serialization."""
549
+ # Use ObjectRef.to_dict() to ensure proper type normalization
431
550
  return {
432
- "objectRef": {
433
- "internalId": self.object_ref.internal_id,
434
- "position": FindRequest._position_to_dict(self.object_ref.position),
435
- "type": self.object_ref.type.value
436
- }
551
+ "objectRef": self.object_ref.to_dict()
437
552
  }
438
553
 
439
554
 
@@ -446,12 +561,9 @@ class MoveRequest:
446
561
  def to_dict(self) -> dict:
447
562
  """Convert to dictionary for JSON serialization."""
448
563
  # Server API expects the new coordinates under 'newPosition'
564
+ # Use ObjectRef.to_dict() to ensure proper type normalization
449
565
  return {
450
- "objectRef": {
451
- "internalId": self.object_ref.internal_id,
452
- "position": FindRequest._position_to_dict(self.object_ref.position),
453
- "type": self.object_ref.type.value
454
- },
566
+ "objectRef": self.object_ref.to_dict(),
455
567
  "newPosition": FindRequest._position_to_dict(self.position)
456
568
  }
457
569
 
@@ -487,7 +599,26 @@ class AddRequest:
487
599
  def _object_to_dict(self, obj: Any) -> dict:
488
600
  """Convert PDF object to dictionary for JSON serialization."""
489
601
  import base64
490
- if isinstance(obj, Image):
602
+ from .models import Path as PathModel, Line, Bezier, PathSegment
603
+
604
+ if isinstance(obj, PathModel):
605
+ # Serialize Path object
606
+ segments = []
607
+ if obj.path_segments:
608
+ for seg in obj.path_segments:
609
+ seg_dict = self._segment_to_dict(seg)
610
+ # Include per-segment position to satisfy backend validation (matches Java client)
611
+ if obj.position:
612
+ seg_dict["position"] = FindRequest._position_to_dict(obj.position)
613
+ segments.append(seg_dict)
614
+
615
+ return {
616
+ "type": "PATH",
617
+ "position": FindRequest._position_to_dict(obj.position) if obj.position else None,
618
+ "pathSegments": segments if segments else None,
619
+ "evenOddFill": obj.even_odd_fill
620
+ }
621
+ elif isinstance(obj, Image):
491
622
  size = None
492
623
  if obj.width is not None and obj.height is not None:
493
624
  size = {"width": obj.width, "height": obj.height}
@@ -538,6 +669,61 @@ class AddRequest:
538
669
  else:
539
670
  raise ValueError(f"Unsupported object type: {type(obj)}")
540
671
 
672
+ def _segment_to_dict(self, segment: 'PathSegment') -> dict:
673
+ """Convert a PathSegment (Line or Bezier) to dictionary for JSON serialization."""
674
+ from .models import Line, Bezier
675
+
676
+ result = {}
677
+
678
+ # Add common PathSegment properties
679
+ if segment.stroke_color:
680
+ result["strokeColor"] = {
681
+ "red": segment.stroke_color.r,
682
+ "green": segment.stroke_color.g,
683
+ "blue": segment.stroke_color.b,
684
+ "alpha": segment.stroke_color.a
685
+ }
686
+
687
+ if segment.fill_color:
688
+ result["fillColor"] = {
689
+ "red": segment.fill_color.r,
690
+ "green": segment.fill_color.g,
691
+ "blue": segment.fill_color.b,
692
+ "alpha": segment.fill_color.a
693
+ }
694
+
695
+ if segment.stroke_width is not None:
696
+ result["strokeWidth"] = segment.stroke_width
697
+
698
+ if segment.dash_array:
699
+ result["dashArray"] = segment.dash_array
700
+
701
+ if segment.dash_phase is not None:
702
+ result["dashPhase"] = segment.dash_phase
703
+
704
+ # Add segment-specific properties
705
+ if isinstance(segment, Line):
706
+ result["type"] = "LINE"
707
+ result["segmentType"] = "LINE"
708
+ if segment.p0:
709
+ result["p0"] = {"x": segment.p0.x, "y": segment.p0.y}
710
+ if segment.p1:
711
+ result["p1"] = {"x": segment.p1.x, "y": segment.p1.y}
712
+
713
+ elif isinstance(segment, Bezier):
714
+ result["type"] = "BEZIER"
715
+ result["segmentType"] = "BEZIER"
716
+ if segment.p0:
717
+ result["p0"] = {"x": segment.p0.x, "y": segment.p0.y}
718
+ if segment.p1:
719
+ result["p1"] = {"x": segment.p1.x, "y": segment.p1.y}
720
+ if segment.p2:
721
+ result["p2"] = {"x": segment.p2.x, "y": segment.p2.y}
722
+ if segment.p3:
723
+ result["p3"] = {"x": segment.p3.x, "y": segment.p3.y}
724
+
725
+ return result
726
+
541
727
 
542
728
  @dataclass
543
729
  class ModifyRequest:
@@ -547,12 +733,9 @@ class ModifyRequest:
547
733
 
548
734
  def to_dict(self) -> dict:
549
735
  """Convert to dictionary for JSON serialization."""
736
+ # Use ObjectRef.to_dict() to ensure proper type normalization
550
737
  return {
551
- "ref": {
552
- "internalId": self.object_ref.internal_id,
553
- "position": FindRequest._position_to_dict(self.object_ref.position),
554
- "type": self.object_ref.type.value
555
- },
738
+ "ref": self.object_ref.to_dict(),
556
739
  "newObject": AddRequest(None)._object_to_dict(self.new_object)
557
740
  }
558
741
 
@@ -565,12 +748,9 @@ class ModifyTextRequest:
565
748
 
566
749
  def to_dict(self) -> dict:
567
750
  """Convert to dictionary for JSON serialization."""
751
+ # Use ObjectRef.to_dict() to ensure proper type normalization
568
752
  return {
569
- "ref": {
570
- "internalId": self.object_ref.internal_id,
571
- "position": FindRequest._position_to_dict(self.object_ref.position),
572
- "type": self.object_ref.type.value
573
- },
753
+ "ref": self.object_ref.to_dict(),
574
754
  "newTextLine": self.new_text
575
755
  }
576
756
 
@@ -582,12 +762,9 @@ class ChangeFormFieldRequest:
582
762
 
583
763
  def to_dict(self) -> dict:
584
764
  """Convert to dictionary for JSON serialization."""
765
+ # Use ObjectRef.to_dict() to ensure proper type normalization
585
766
  return {
586
- "ref": {
587
- "internalId": self.object_ref.internal_id,
588
- "position": FindRequest._position_to_dict(self.object_ref.position),
589
- "type": self.object_ref.type.value
590
- },
767
+ "ref": self.object_ref.to_dict(),
591
768
  "value": self.value
592
769
  }
593
770
 
@@ -753,3 +930,42 @@ class CommandResult:
753
930
  @classmethod
754
931
  def empty(cls, command_name: str, element_id: str | None) -> 'CommandResult':
755
932
  return CommandResult(command_name=command_name, element_id=element_id, message=None, success=True, warning=None)
933
+
934
+
935
+ @dataclass
936
+ class PageSnapshot:
937
+ """
938
+ Snapshot of a single page containing all elements and page metadata.
939
+ """
940
+ page_ref: PageRef
941
+ elements: List[ObjectRef]
942
+
943
+ def get_page_ref(self) -> PageRef:
944
+ """Get the page reference."""
945
+ return self.page_ref
946
+
947
+ def get_elements(self) -> List[ObjectRef]:
948
+ """Get the list of elements on this page."""
949
+ return self.elements
950
+
951
+
952
+ @dataclass
953
+ class DocumentSnapshot:
954
+ """
955
+ Snapshot of the entire document containing all pages and font information.
956
+ """
957
+ page_count: int
958
+ fonts: List[FontRecommendation]
959
+ pages: List[PageSnapshot]
960
+
961
+ def get_page_count(self) -> int:
962
+ """Get the total number of pages."""
963
+ return self.page_count
964
+
965
+ def get_fonts(self) -> List[FontRecommendation]:
966
+ """Get the list of fonts used in the document."""
967
+ return self.fonts
968
+
969
+ def get_pages(self) -> List[PageSnapshot]:
970
+ """Get the list of page snapshots."""
971
+ return self.pages