pdfdancer-client-python 0.2.12__py3-none-any.whl → 0.2.14__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.

Potentially problematic release.


This version of pdfdancer-client-python might be problematic. Click here for more details.

pdfdancer/__init__.py CHANGED
@@ -12,7 +12,7 @@ from .exceptions import (
12
12
  )
13
13
  from .models import (
14
14
  ObjectRef, Position, ObjectType, Font, Color, Image, BoundingRect, Paragraph, FormFieldRef, TextObjectRef,
15
- PositionMode, ShapeType, Point, StandardFonts
15
+ PageRef, PositionMode, ShapeType, Point, StandardFonts, PageSize, Orientation
16
16
  )
17
17
  from .paragraph_builder import ParagraphBuilder
18
18
 
@@ -30,10 +30,13 @@ __all__ = [
30
30
  "Paragraph",
31
31
  "FormFieldRef",
32
32
  "TextObjectRef",
33
+ "PageRef",
33
34
  "PositionMode",
34
35
  "ShapeType",
35
36
  "Point",
36
37
  "StandardFonts",
38
+ "PageSize",
39
+ "Orientation",
37
40
  "PdfDancerException",
38
41
  "FontNotFoundException",
39
42
  "ValidationException",
pdfdancer/models.py CHANGED
@@ -1,11 +1,113 @@
1
1
  """
2
2
  Model classes for the PDFDancer Python client.
3
- Closely mirrors the Java model classes with Python conventions.
4
3
  """
5
4
 
6
5
  from dataclasses import dataclass
7
6
  from enum import Enum
8
- from typing import Optional, List, Any
7
+ from typing import Optional, List, Any, Dict, Mapping, Tuple, ClassVar, Union
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class PageSize:
12
+ """Represents a page size specification, covering both standard and custom dimensions."""
13
+
14
+ name: Optional[str]
15
+ width: float
16
+ height: float
17
+
18
+ _STANDARD_SIZES: ClassVar[Dict[str, Tuple[float, float]]] = {
19
+ "A4": (595.0, 842.0),
20
+ "LETTER": (612.0, 792.0),
21
+ "LEGAL": (612.0, 1008.0),
22
+ "TABLOID": (792.0, 1224.0),
23
+ "A3": (842.0, 1191.0),
24
+ "A5": (420.0, 595.0),
25
+ }
26
+
27
+ # Convenience aliases populated after class definition; annotated for type checkers.
28
+ A4: ClassVar['PageSize']
29
+ LETTER: ClassVar['PageSize']
30
+ LEGAL: ClassVar['PageSize']
31
+ TABLOID: ClassVar['PageSize']
32
+ A3: ClassVar['PageSize']
33
+ A5: ClassVar['PageSize']
34
+
35
+ def __post_init__(self) -> None:
36
+ if not isinstance(self.width, (int, float)) or not isinstance(self.height, (int, float)):
37
+ raise TypeError("Page width and height must be numeric")
38
+ if self.width <= 0 or self.height <= 0:
39
+ raise ValueError("Page width and height must be positive values")
40
+
41
+ width = float(self.width)
42
+ height = float(self.height)
43
+ object.__setattr__(self, 'width', width)
44
+ object.__setattr__(self, 'height', height)
45
+
46
+ if self.name is not None:
47
+ if not isinstance(self.name, str):
48
+ raise TypeError("Page size name must be a string when provided")
49
+ normalized_name = self.name.strip().upper()
50
+ object.__setattr__(self, 'name', normalized_name if normalized_name else None)
51
+
52
+ def to_dict(self) -> dict:
53
+ """Convert to dictionary for JSON serialization."""
54
+ return {
55
+ "name": self.name,
56
+ "width": self.width,
57
+ "height": self.height,
58
+ }
59
+
60
+ @classmethod
61
+ def from_name(cls, name: str) -> 'PageSize':
62
+ """Create a page size from a known standard name."""
63
+ if not name or not isinstance(name, str):
64
+ raise ValueError("Page size name must be a non-empty string")
65
+ normalized = name.strip().upper()
66
+ if normalized not in cls._STANDARD_SIZES:
67
+ raise ValueError(f"Unknown page size name: {name}")
68
+ width, height = cls._STANDARD_SIZES[normalized]
69
+ return cls(name=normalized, width=width, height=height)
70
+
71
+ @classmethod
72
+ def from_dict(cls, data: Mapping[str, Any]) -> 'PageSize':
73
+ """Create a page size from a dictionary-like object."""
74
+ width = data.get('width') if isinstance(data, Mapping) else None
75
+ height = data.get('height') if isinstance(data, Mapping) else None
76
+ if width is None or height is None:
77
+ raise ValueError("Page size dictionary must contain 'width' and 'height'")
78
+ name = data.get('name') if isinstance(data, Mapping) else None
79
+ return cls(name=name, width=width, height=height)
80
+
81
+ @classmethod
82
+ def coerce(cls, value: Union['PageSize', str, Mapping[str, Any]]) -> 'PageSize':
83
+ """Normalize various page size inputs into a PageSize instance."""
84
+ if isinstance(value, cls):
85
+ return value
86
+ if isinstance(value, str):
87
+ return cls.from_name(value)
88
+ if isinstance(value, Mapping):
89
+ return cls.from_dict(value)
90
+ raise TypeError(f"Cannot convert type {type(value)} to PageSize")
91
+
92
+ @classmethod
93
+ def standard_names(cls) -> List[str]:
94
+ """Return a list of supported standard page size names."""
95
+ return sorted(cls._STANDARD_SIZES.keys())
96
+
97
+
98
+ # Populate convenience constants for standard sizes.
99
+ PageSize.A4 = PageSize.from_name("A4")
100
+ PageSize.LETTER = PageSize.from_name("LETTER")
101
+ PageSize.LEGAL = PageSize.from_name("LEGAL")
102
+ PageSize.TABLOID = PageSize.from_name("TABLOID")
103
+ PageSize.A3 = PageSize.from_name("A3")
104
+ PageSize.A5 = PageSize.from_name("A5")
105
+
106
+
107
+ class Orientation(Enum):
108
+ """Page orientation options."""
109
+ PORTRAIT = "PORTRAIT"
110
+ LANDSCAPE = "LANDSCAPE"
9
111
 
10
112
 
11
113
  class StandardFonts(Enum):
@@ -365,6 +467,19 @@ class MoveRequest:
365
467
  }
366
468
 
367
469
 
470
+ @dataclass
471
+ class PageMoveRequest:
472
+ """Request object for moving pages within the document."""
473
+ from_page_index: int
474
+ to_page_index: int
475
+
476
+ def to_dict(self) -> dict:
477
+ return {
478
+ "fromPageIndex": self.from_page_index,
479
+ "toPageIndex": self.to_page_index
480
+ }
481
+
482
+
368
483
  @dataclass
369
484
  class AddRequest:
370
485
  """Request object for add operations."""
@@ -547,3 +662,21 @@ class TextObjectRef(ObjectRef):
547
662
  def get_children(self) -> List['TextObjectRef']:
548
663
  """Get the child text objects."""
549
664
  return self.children
665
+
666
+
667
+ @dataclass
668
+ class PageRef(ObjectRef):
669
+ """
670
+ Represents a page reference with additional page-specific properties.
671
+ Extends ObjectRef to include page size and orientation.
672
+ """
673
+ page_size: Optional[PageSize]
674
+ orientation: Optional[Orientation]
675
+
676
+ def get_page_size(self) -> Optional[PageSize]:
677
+ """Get the page size."""
678
+ return self.page_size
679
+
680
+ def get_orientation(self) -> Optional[Orientation]:
681
+ """Get the page orientation."""
682
+ return self.orientation
@@ -6,6 +6,7 @@ Closely mirrors the Java ParagraphBuilder class with Python conventions.
6
6
  from pathlib import Path
7
7
  from typing import Optional, Union
8
8
 
9
+ from . import StandardFonts
9
10
  from .exceptions import ValidationException
10
11
  from .models import Paragraph, Font, Color, Position
11
12
 
@@ -60,7 +61,7 @@ class ParagraphBuilder:
60
61
 
61
62
  return self
62
63
 
63
- def font(self, font_name: str, font_size: float) -> 'ParagraphBuilder':
64
+ def font(self, font_name: str | StandardFonts, font_size: float) -> 'ParagraphBuilder':
64
65
  """
65
66
  Set the font for the paragraph using an existing Font object.
66
67
  Equivalent to withFont(Font) in Java ParagraphBuilder.
@@ -75,6 +76,10 @@ class ParagraphBuilder:
75
76
  Raises:
76
77
  ValidationException: If font is None
77
78
  """
79
+ # If font_name is an enum member, use its value
80
+ if isinstance(font_name, StandardFonts):
81
+ font_name = font_name.value
82
+
78
83
  font = Font(font_name, font_size)
79
84
  if font is None:
80
85
  raise ValidationException("Font cannot be null")
@@ -185,7 +190,7 @@ class ParagraphBuilder:
185
190
  self._paragraph.set_position(position)
186
191
  return self
187
192
 
188
- def build(self) -> Paragraph:
193
+ def _build(self) -> Paragraph:
189
194
  """
190
195
  Build and return the final Paragraph object.
191
196
  Equivalent to build() in Java ParagraphBuilder.
@@ -267,7 +272,7 @@ class ParagraphBuilder:
267
272
  return lines
268
273
 
269
274
  def add(self):
270
- self._client._add_paragraph(self.build())
275
+ self._client._add_paragraph(self._build())
271
276
 
272
277
 
273
278
  class ParagraphPageBuilder(ParagraphBuilder):
pdfdancer/pdfdancer_v1.py CHANGED
@@ -8,9 +8,12 @@ Provides session-based PDF manipulation operations with strict validation.
8
8
  import json
9
9
  import os
10
10
  from pathlib import Path
11
- from typing import List, Optional, Union, BinaryIO
11
+ from typing import List, Optional, Union, BinaryIO, Mapping, Any
12
12
 
13
13
  import requests
14
+ from dotenv import load_dotenv
15
+
16
+ load_dotenv()
14
17
 
15
18
  from . import ParagraphBuilder
16
19
  from .exceptions import (
@@ -22,21 +25,32 @@ from .exceptions import (
22
25
  )
23
26
  from .image_builder import ImageBuilder
24
27
  from .models import (
25
- ObjectRef, Position, ObjectType, Font, Image, Paragraph, FormFieldRef, TextObjectRef,
26
- FindRequest, DeleteRequest, MoveRequest, AddRequest, ModifyRequest, ModifyTextRequest, ChangeFormFieldRequest,
27
- ShapeType, PositionMode
28
+ ObjectRef, Position, ObjectType, Font, Image, Paragraph, FormFieldRef, TextObjectRef, PageRef,
29
+ FindRequest, DeleteRequest, MoveRequest, PageMoveRequest, AddRequest, ModifyRequest, ModifyTextRequest,
30
+ ChangeFormFieldRequest,
31
+ ShapeType, PositionMode, PageSize, Orientation
28
32
  )
29
33
  from .paragraph_builder import ParagraphPageBuilder
30
34
  from .types import PathObject, ParagraphObject, TextLineObject, ImageObject, FormObject, FormFieldObject
31
35
 
32
36
 
33
37
  class PageClient:
34
- def __init__(self, page_index: int, root: "PDFDancer"):
38
+ def __init__(self, page_index: int, root: "PDFDancer", page_size: Optional[PageSize] = None,
39
+ orientation: Optional[Union[Orientation, str]] = Orientation.PORTRAIT):
35
40
  self.page_index = page_index
36
41
  self.root = root
37
42
  self.object_type = ObjectType.PAGE
38
43
  self.position = Position.at_page(page_index)
39
44
  self.internal_id = f"PAGE-{page_index}"
45
+ self.page_size = page_size
46
+ if isinstance(orientation, str):
47
+ normalized = orientation.strip().upper()
48
+ try:
49
+ self.orientation = Orientation(normalized)
50
+ except ValueError:
51
+ self.orientation = normalized
52
+ else:
53
+ self.orientation = orientation
40
54
 
41
55
  def select_paths_at(self, x: float, y: float) -> List[PathObject]:
42
56
  # noinspection PyProtectedMember
@@ -121,20 +135,71 @@ class PageClient:
121
135
  return self.root._to_form_field_objects(self.root._find_form_fields(position))
122
136
 
123
137
  @classmethod
124
- def from_ref(cls, root: 'PDFDancer', object_ref: ObjectRef) -> 'PageClient':
125
- page_client = PageClient(page_index=object_ref.position.page_index, root=root)
138
+ def from_ref(cls, root: 'PDFDancer', page_ref: PageRef) -> 'PageClient':
139
+ page_client = PageClient(
140
+ page_index=page_ref.position.page_index,
141
+ root=root,
142
+ page_size=page_ref.page_size,
143
+ orientation=page_ref.orientation
144
+ )
145
+ page_client.internal_id = page_ref.internal_id
146
+ if page_ref.position is not None:
147
+ page_client.position = page_ref.position
148
+ page_client.page_index = page_ref.position.page_index
126
149
  return page_client
127
150
 
128
151
  def delete(self) -> bool:
129
152
  # noinspection PyProtectedMember
130
153
  return self.root._delete_page(self._ref())
131
154
 
155
+ def move_to(self, target_page_index: int) -> bool:
156
+ """Move this page to a different index within the document."""
157
+ if target_page_index is None or target_page_index < 0:
158
+ raise ValidationException(f"Target page index must be >= 0, got {target_page_index}")
159
+
160
+ # noinspection PyProtectedMember
161
+ moved = self.root._move_page(self.page_index, target_page_index)
162
+ if moved:
163
+ self.page_index = target_page_index
164
+ self.position = Position.at_page(target_page_index)
165
+ return moved
166
+
132
167
  def _ref(self):
133
168
  return ObjectRef(internal_id=self.internal_id, position=self.position, type=self.object_type)
134
169
 
135
170
  def new_paragraph(self):
136
171
  return ParagraphPageBuilder(self.root, self.page_index)
137
172
 
173
+ def select_paths(self):
174
+ # noinspection PyProtectedMember
175
+ return self.root._to_path_objects(self.root._find_paths(Position.at_page(self.page_index)))
176
+
177
+ def select_elements(self):
178
+ """
179
+ Select all elements (paragraphs, images, paths, forms) on this page.
180
+
181
+ Returns:
182
+ List of all PDF objects on this page
183
+ """
184
+ result = []
185
+ result.extend(self.select_paragraphs())
186
+ result.extend(self.select_text_lines())
187
+ result.extend(self.select_images())
188
+ result.extend(self.select_paths())
189
+ result.extend(self.select_forms())
190
+ result.extend(self.select_form_fields())
191
+ return result
192
+
193
+ @property
194
+ def size(self):
195
+ """Property alias for page size."""
196
+ return self.page_size
197
+
198
+ @property
199
+ def page_orientation(self):
200
+ """Property alias for orientation."""
201
+ return self.orientation
202
+
138
203
 
139
204
  class PDFDancer:
140
205
  """
@@ -197,12 +262,57 @@ class PDFDancer:
197
262
  def new(cls,
198
263
  token: Optional[str] = None,
199
264
  base_url: Optional[str] = None,
200
- timeout: float = 30.0) -> "PDFDancer":
265
+ timeout: float = 30.0,
266
+ page_size: Optional[Union[PageSize, str, Mapping[str, Any]]] = None,
267
+ orientation: Optional[Union[Orientation, str]] = None,
268
+ initial_page_count: int = 1) -> "PDFDancer":
269
+ """
270
+ Create a new blank PDF document with optional configuration.
271
+
272
+ Args:
273
+ token: Override for the API token; falls back to `PDFDANCER_TOKEN` environment variable.
274
+ base_url: Override for the API base URL; falls back to `PDFDANCER_BASE_URL`
275
+ or defaults to `https://api.pdfdancer.com`.
276
+ timeout: HTTP read timeout in seconds.
277
+ page_size: Page size for the PDF (default: A4). Accepts `PageSize`, a standard name string, or a
278
+ mapping with `width`/`height` values.
279
+ orientation: Page orientation (default: PORTRAIT). Can be Orientation enum or string.
280
+ initial_page_count: Number of initial blank pages (default: 1).
201
281
 
282
+ Returns:
283
+ A ready-to-use `PDFDancer` client instance with a blank PDF.
284
+ """
202
285
  resolved_token = cls._resolve_token(token)
203
286
  resolved_base_url = cls._resolve_base_url(base_url)
204
287
 
205
- raise Exception("Unsupported Operation Exception: TODO")
288
+ # Create a new instance that will call _create_blank_pdf_session
289
+ instance = object.__new__(cls)
290
+
291
+ # Initialize instance variables
292
+ if not resolved_token or not resolved_token.strip():
293
+ raise ValidationException("Authentication token cannot be null or empty")
294
+
295
+ instance._token = resolved_token.strip()
296
+ instance._base_url = resolved_base_url.rstrip('/')
297
+ instance._read_timeout = timeout
298
+
299
+ # Create HTTP session for connection reuse
300
+ instance._session = requests.Session()
301
+ instance._session.headers.update({
302
+ 'Authorization': f'Bearer {instance._token}'
303
+ })
304
+
305
+ # Create blank PDF session
306
+ instance._session_id = instance._create_blank_pdf_session(
307
+ page_size=page_size,
308
+ orientation=orientation,
309
+ initial_page_count=initial_page_count
310
+ )
311
+
312
+ # Set pdf_bytes to None since we don't have the PDF bytes yet
313
+ instance._pdf_bytes = None
314
+
315
+ return instance
206
316
 
207
317
  def __init__(self, token: str, pdf_data: Union[bytes, Path, str, BinaryIO],
208
318
  base_url: str, read_timeout: float = 0):
@@ -335,6 +445,22 @@ class PDFDancer:
335
445
  f"Server response: {details}"
336
446
  )
337
447
 
448
+ @staticmethod
449
+ def _cleanup_url_path(base_url: str, path: str) -> str:
450
+ """
451
+ Combine base_url and path, ensuring no double slashes.
452
+
453
+ Args:
454
+ base_url: Base URL (may or may not have trailing slash)
455
+ path: Path segment (may or may not have leading slash)
456
+
457
+ Returns:
458
+ Combined URL with no double slashes
459
+ """
460
+ base = base_url.rstrip('/')
461
+ path = path.lstrip('/')
462
+ return f"{base}/{path}"
463
+
338
464
  def _create_session(self) -> str:
339
465
  """
340
466
  Creates a new PDF processing session by uploading the PDF data.
@@ -345,7 +471,7 @@ class PDFDancer:
345
471
  }
346
472
 
347
473
  response = self._session.post(
348
- f"{self._base_url}/session/create",
474
+ self._cleanup_url_path(self._base_url, "/session/create"),
349
475
  files=files,
350
476
  timeout=self._read_timeout if self._read_timeout > 0 else None
351
477
  )
@@ -365,6 +491,76 @@ class PDFDancer:
365
491
  raise HttpClientException(f"Failed to create session: {error_message}",
366
492
  response=getattr(e, 'response', None), cause=e) from None
367
493
 
494
+ def _create_blank_pdf_session(self,
495
+ page_size: Optional[Union[PageSize, str, Mapping[str, Any]]] = None,
496
+ orientation: Optional[Union[Orientation, str]] = None,
497
+ initial_page_count: int = 1) -> str:
498
+ """
499
+ Creates a new PDF processing session with a blank PDF document.
500
+
501
+ Args:
502
+ page_size: Page size (default: A4). Accepts `PageSize`, a standard name string, or a
503
+ mapping with `width`/`height` values.
504
+ orientation: Page orientation (default: PORTRAIT). Can be Orientation enum or string.
505
+ initial_page_count: Number of initial pages (default: 1)
506
+
507
+ Returns:
508
+ Session ID for the newly created blank PDF
509
+
510
+ Raises:
511
+ SessionException: If session creation fails
512
+ HttpClientException: If HTTP communication fails
513
+ """
514
+ try:
515
+ # Build request payload
516
+ request_data = {}
517
+
518
+ # Handle page_size - convert to type-safe object with dimensions
519
+ if page_size is not None:
520
+ try:
521
+ request_data['pageSize'] = PageSize.coerce(page_size).to_dict()
522
+ except ValueError as exc:
523
+ raise ValidationException(str(exc)) from exc
524
+ except TypeError:
525
+ raise ValidationException(f"Invalid page_size type: {type(page_size)}")
526
+
527
+ # Handle orientation
528
+ if orientation is not None:
529
+ if isinstance(orientation, Orientation):
530
+ request_data['orientation'] = orientation.value
531
+ elif isinstance(orientation, str):
532
+ request_data['orientation'] = orientation
533
+ else:
534
+ raise ValidationException(f"Invalid orientation type: {type(orientation)}")
535
+
536
+ # Handle initial_page_count with validation
537
+ if initial_page_count < 1:
538
+ raise ValidationException(f"Initial page count must be at least 1, got {initial_page_count}")
539
+ request_data['initialPageCount'] = initial_page_count
540
+
541
+ headers = {'Content-Type': 'application/json'}
542
+ response = self._session.post(
543
+ self._cleanup_url_path(self._base_url, "/session/new"),
544
+ json=request_data,
545
+ headers=headers,
546
+ timeout=self._read_timeout if self._read_timeout > 0 else None
547
+ )
548
+
549
+ self._handle_authentication_error(response)
550
+ response.raise_for_status()
551
+ session_id = response.text.strip()
552
+
553
+ if not session_id:
554
+ raise SessionException("Server returned empty session ID")
555
+
556
+ return session_id
557
+
558
+ except requests.exceptions.RequestException as e:
559
+ self._handle_authentication_error(getattr(e, 'response', None))
560
+ error_message = self._extract_error_message(getattr(e, 'response', None))
561
+ raise HttpClientException(f"Failed to create blank PDF session: {error_message}",
562
+ response=getattr(e, 'response', None), cause=e) from None
563
+
368
564
  def _make_request(self, method: str, path: str, data: Optional[dict] = None,
369
565
  params: Optional[dict] = None) -> requests.Response:
370
566
  """
@@ -378,7 +574,7 @@ class PDFDancer:
378
574
  try:
379
575
  response = self._session.request(
380
576
  method=method,
381
- url=f"{self._base_url}{path}",
577
+ url=self._cleanup_url_path(self._base_url, path),
382
578
  json=data,
383
579
  params=params,
384
580
  headers=headers,
@@ -528,22 +724,36 @@ class PDFDancer:
528
724
  return self._to_textline_objects(self._find_text_lines(None))
529
725
 
530
726
  def page(self, page_index: int) -> PageClient:
531
- return PageClient(page_index, self)
727
+ """
728
+ Get a specific page by index, fetching page properties from the server.
729
+
730
+ Args:
731
+ page_index: The 0-based page index
732
+
733
+ Returns:
734
+ PageClient with page properties populated
735
+ """
736
+ page_ref = self._get_page(page_index)
737
+ if page_ref:
738
+ return PageClient.from_ref(self, page_ref)
739
+ else:
740
+ # Fallback to basic PageClient if page not found
741
+ return PageClient(page_index, self)
532
742
 
533
743
  # Page Operations
534
744
 
535
745
  def pages(self) -> List[PageClient]:
536
746
  return self._to_page_objects(self._get_pages())
537
747
 
538
- def _get_pages(self) -> List[ObjectRef]:
748
+ def _get_pages(self) -> List[PageRef]:
539
749
  """
540
750
  Retrieves references to all pages in the PDF document.
541
751
  """
542
752
  response = self._make_request('POST', '/pdf/page/find')
543
753
  pages_data = response.json()
544
- return [self._parse_object_ref(page_data) for page_data in pages_data]
754
+ return [self._parse_page_ref(page_data) for page_data in pages_data]
545
755
 
546
- def _get_page(self, page_index: int) -> Optional[ObjectRef]:
756
+ def _get_page(self, page_index: int) -> Optional[PageRef]:
547
757
  """
548
758
  Retrieves a reference to a specific page by its page index.
549
759
 
@@ -551,7 +761,7 @@ class PDFDancer:
551
761
  page_index: The page index to retrieve (1-based indexing)
552
762
 
553
763
  Returns:
554
- Object reference for the specified page, or None if not found
764
+ Page reference for the specified page, or None if not found
555
765
  """
556
766
  if page_index < 0:
557
767
  raise ValidationException(f"Page index must be >= 0, got {page_index}")
@@ -563,7 +773,7 @@ class PDFDancer:
563
773
  if not pages_data:
564
774
  return None
565
775
 
566
- return self._parse_object_ref(pages_data[0])
776
+ return self._parse_page_ref(pages_data[0])
567
777
 
568
778
  def _delete_page(self, page_ref: ObjectRef) -> bool:
569
779
  """
@@ -583,6 +793,25 @@ class PDFDancer:
583
793
  response = self._make_request('DELETE', '/pdf/page/delete', data=request_data)
584
794
  return response.json()
585
795
 
796
+ def move_page(self, from_page_index: int, to_page_index: int) -> bool:
797
+ """Move a page to a different index within the document."""
798
+ return self._move_page(from_page_index, to_page_index)
799
+
800
+ def _move_page(self, from_page_index: int, to_page_index: int) -> bool:
801
+ """Internal helper to perform the page move operation."""
802
+ for value, label in ((from_page_index, "from_page_index"), (to_page_index, "to_page_index")):
803
+ if value is None:
804
+ raise ValidationException(f"{label} cannot be null")
805
+ if not isinstance(value, int):
806
+ raise ValidationException(f"{label} must be an integer, got {type(value)}")
807
+ if value < 0:
808
+ raise ValidationException(f"{label} must be >= 0, got {value}")
809
+
810
+ request_data = PageMoveRequest(from_page_index, to_page_index).to_dict()
811
+ response = self._make_request('PUT', '/pdf/page/move', data=request_data)
812
+ result = response.json()
813
+ return bool(result)
814
+
586
815
  # Manipulation Operations
587
816
 
588
817
  def _delete(self, object_ref: ObjectRef) -> bool:
@@ -678,6 +907,10 @@ class PDFDancer:
678
907
  def new_paragraph(self) -> ParagraphBuilder:
679
908
  return ParagraphBuilder(self)
680
909
 
910
+ def new_page(self):
911
+ response = self._make_request('POST', '/pdf/page/add', data=None)
912
+ return self._parse_page_ref(response.json())
913
+
681
914
  def new_image(self) -> ImageBuilder:
682
915
  return ImageBuilder(self)
683
916
 
@@ -809,7 +1042,7 @@ class PDFDancer:
809
1042
 
810
1043
  headers = {'X-Session-Id': self._session_id}
811
1044
  response = self._session.post(
812
- f"{self._base_url}/font/register",
1045
+ self._cleanup_url_path(self._base_url, "/font/register"),
813
1046
  files=files,
814
1047
  headers=headers,
815
1048
  timeout=30
@@ -957,6 +1190,42 @@ class PDFDancer:
957
1190
 
958
1191
  return text_object
959
1192
 
1193
+ def _parse_page_ref(self, obj_data: dict) -> PageRef:
1194
+ """Parse JSON object data into PageRef instance with page-specific properties."""
1195
+ position_data = obj_data.get('position', {})
1196
+ position = self._parse_position(position_data) if position_data else None
1197
+
1198
+ object_type = ObjectType(obj_data['type'])
1199
+
1200
+ # Parse page size if present
1201
+ page_size = None
1202
+ if 'pageSize' in obj_data and isinstance(obj_data['pageSize'], dict):
1203
+ page_size_data = obj_data['pageSize']
1204
+ try:
1205
+ page_size = PageSize.from_dict(page_size_data)
1206
+ except ValueError:
1207
+ page_size = None
1208
+
1209
+ # Parse orientation if present
1210
+ orientation_value = obj_data.get('orientation')
1211
+ orientation = None
1212
+ if isinstance(orientation_value, str):
1213
+ normalized = orientation_value.strip().upper()
1214
+ try:
1215
+ orientation = Orientation(normalized)
1216
+ except ValueError:
1217
+ orientation = None
1218
+ elif isinstance(orientation_value, Orientation):
1219
+ orientation = orientation_value
1220
+
1221
+ return PageRef(
1222
+ internal_id=obj_data.get('internalId'),
1223
+ position=position,
1224
+ type=object_type,
1225
+ page_size=page_size,
1226
+ orientation=orientation
1227
+ )
1228
+
960
1229
  # Builder Pattern Support
961
1230
 
962
1231
  def _paragraph_builder(self) -> 'ParagraphBuilder':
@@ -997,8 +1266,59 @@ class PDFDancer:
997
1266
  return [FormFieldObject(self, ref.internal_id, ref.type, ref.position, ref.name, ref.value) for ref in
998
1267
  refs]
999
1268
 
1000
- def _to_page_objects(self, refs: List[ObjectRef]) -> List[PageClient]:
1269
+ def _to_page_objects(self, refs: List[PageRef]) -> List[PageClient]:
1001
1270
  return [PageClient.from_ref(self, ref) for ref in refs]
1002
1271
 
1003
- def _to_page_object(self, ref: ObjectRef) -> PageClient:
1272
+ def _to_page_object(self, ref: PageRef) -> PageClient:
1004
1273
  return PageClient.from_ref(self, ref)
1274
+
1275
+ def _to_mixed_objects(self, refs: List[ObjectRef]) -> List:
1276
+ """
1277
+ Convert a list of ObjectRefs to their appropriate object types.
1278
+ Handles mixed object types by checking the type of each ref.
1279
+ """
1280
+ result = []
1281
+ for ref in refs:
1282
+ if ref.type == ObjectType.PARAGRAPH:
1283
+ # Need to convert to TextObjectRef first
1284
+ if isinstance(ref, TextObjectRef):
1285
+ result.append(ParagraphObject(self, ref))
1286
+ else:
1287
+ # Re-fetch with proper type
1288
+ text_refs = self._find_paragraphs(ref.position)
1289
+ result.extend(self._to_paragraph_objects(text_refs))
1290
+ elif ref.type == ObjectType.TEXT_LINE:
1291
+ if isinstance(ref, TextObjectRef):
1292
+ result.append(TextLineObject(self, ref))
1293
+ else:
1294
+ text_refs = self._find_text_lines(ref.position)
1295
+ result.extend(self._to_textline_objects(text_refs))
1296
+ elif ref.type == ObjectType.IMAGE:
1297
+ result.append(ImageObject(self, ref.internal_id, ref.type, ref.position))
1298
+ elif ref.type == ObjectType.PATH:
1299
+ result.append(PathObject(self, ref.internal_id, ref.type, ref.position))
1300
+ elif ref.type == ObjectType.FORM_X_OBJECT:
1301
+ result.append(FormObject(self, ref.internal_id, ref.type, ref.position))
1302
+ elif ref.type == ObjectType.FORM_FIELD:
1303
+ if isinstance(ref, FormFieldRef):
1304
+ result.append(FormFieldObject(self, ref.internal_id, ref.type, ref.position, ref.name, ref.value))
1305
+ else:
1306
+ form_refs = self._find_form_fields(ref.position)
1307
+ result.extend(self._to_form_field_objects(form_refs))
1308
+ return result
1309
+
1310
+ def select_elements(self):
1311
+ """
1312
+ Select all elements (paragraphs, images, paths, forms) in the document.
1313
+
1314
+ Returns:
1315
+ List of all PDF objects in the document
1316
+ """
1317
+ result = []
1318
+ result.extend(self.select_paragraphs())
1319
+ result.extend(self.select_text_lines())
1320
+ result.extend(self.select_images())
1321
+ result.extend(self.select_paths())
1322
+ result.extend(self.select_forms())
1323
+ result.extend(self.select_form_fields())
1324
+ return result
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pdfdancer-client-python
3
- Version: 0.2.12
3
+ Version: 0.2.14
4
4
  Summary: Python client for PDFDancer API
5
5
  Author-email: "The Famous Cat Ltd." <hi@thefamouscat.com>
6
6
  License: MIT
@@ -9,28 +9,39 @@ Project-URL: Repository, https://github.com/MenschMachine/pdfdancer-client-pytho
9
9
  Classifier: Development Status :: 4 - Beta
10
10
  Classifier: Intended Audience :: Developers
11
11
  Classifier: License :: OSI Approved :: MIT License
12
- Classifier: Programming Language :: Python :: 3.9
13
12
  Classifier: Programming Language :: Python :: 3.10
14
13
  Classifier: Programming Language :: Python :: 3.11
15
14
  Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Requires-Python: >=3.10
16
17
  Description-Content-Type: text/markdown
17
18
  Requires-Dist: requests>=2.25.0
18
19
  Requires-Dist: pydantic>=1.8.0
19
20
  Requires-Dist: typing-extensions>=4.0.0
21
+ Requires-Dist: python-dotenv>=0.19.0
20
22
  Provides-Extra: dev
21
23
  Requires-Dist: pytest>=7.0; extra == "dev"
22
24
  Requires-Dist: pytest-cov>=4.0; extra == "dev"
25
+ Requires-Dist: pytest-mock>=3.10.0; extra == "dev"
23
26
  Requires-Dist: black>=22.0; extra == "dev"
24
27
  Requires-Dist: flake8>=5.0; extra == "dev"
25
28
  Requires-Dist: mypy>=1.0; extra == "dev"
29
+ Requires-Dist: isort>=5.10.0; extra == "dev"
30
+ Requires-Dist: build>=0.8.0; extra == "dev"
31
+ Requires-Dist: twine>=4.0.0; extra == "dev"
26
32
 
27
33
  # PDFDancer Python Client
28
34
 
29
- Automate PDF clean-up, redaction, form filling, and content injection against the PDFDancer API from Python. The client gives you page-scoped selectors, fluent editors, and builders so you can read, modify, and export PDFs programmatically in just a few lines.
35
+ Automate PDF clean-up, redaction, form filling, and content injection against the PDFDancer API from Python. The client
36
+ gives you page-scoped selectors, fluent editors, and builders so you can read, modify, and export PDFs programmatically
37
+ in just a few lines.
38
+
39
+ Latest schema version available at https://bucket.pdfdancer.com/api-doc/development-0.0.yml.
30
40
 
31
41
  ## Highlights
32
42
 
33
- - Locate anything inside a PDF—paragraphs, text lines, images, vector paths, pages, AcroForm fields—by page, coordinates, or text prefixes
43
+ - Locate anything inside a PDF—paragraphs, text lines, images, vector paths, pages, AcroForm fields—by page,
44
+ coordinates, or text prefixes
34
45
  - Edit or delete existing content with fluent paragraph/text editors and safe apply-on-exit context managers
35
46
  - Fill or update form fields and propagate the changes back to the document instantly
36
47
  - Add brand-new content with paragraph/image builders, custom fonts, and precise page positioning
@@ -47,7 +58,7 @@ Automate PDF clean-up, redaction, form filling, and content injection against th
47
58
 
48
59
  ## Requirements
49
60
 
50
- - Python 3.9 or newer
61
+ - Python 3.10 or newer
51
62
  - A PDFDancer API token (set `PDFDANCER_TOKEN` or pass `token=...`)
52
63
  - Network access to a PDFDancer service (defaults to `https://api.pdfdancer.com`; override with `PDFDANCER_BASE_URL`)
53
64
 
@@ -67,21 +78,21 @@ from pathlib import Path
67
78
  from pdfdancer import Color, PDFDancer
68
79
 
69
80
  with PDFDancer.open(
70
- pdf_data=Path("input.pdf"),
71
- token="your-api-token", # optional when PDFDANCER_TOKEN is set
72
- base_url="https://api.pdfdancer.com",
81
+ pdf_data=Path("input.pdf"),
82
+ token="your-api-token", # optional when PDFDANCER_TOKEN is set
83
+ base_url="https://api.pdfdancer.com",
73
84
  ) as pdf:
74
85
  # Locate existing content
75
86
  heading = pdf.page(0).select_paragraphs_starting_with("Executive Summary")[0]
76
87
  heading.edit().replace("Overview").apply()
77
88
 
78
89
  # Add a new paragraph using the fluent builder
79
- pdf.new_paragraph() \
80
- .text("Generated with PDFDancer") \
81
- .font("Helvetica", 12) \
82
- .color(Color(70, 70, 70)) \
83
- .line_spacing(1.4) \
84
- .at(page_index=0, x=72, y=520) \
90
+ pdf.new_paragraph()
91
+ .text("Generated with PDFDancer")
92
+ .font("Helvetica", 12)
93
+ .color(Color(70, 70, 70))
94
+ .line_spacing(1.4)
95
+ .at(page_index=0, x=72, y=520)
85
96
  .add()
86
97
 
87
98
  # Persist the modified document
@@ -107,7 +118,8 @@ with PDFDancer.open("report.pdf") as pdf: # environment variables provide token
107
118
  print(page.internal_id, page.position.bounding_rect)
108
119
  ```
109
120
 
110
- Selectors return rich objects (`ParagraphObject`, `TextLineObject`, `ImageObject`, `FormFieldObject`, etc.) with helpers such as `delete()`, `move_to(x, y)`, or `edit()` depending on the object type.
121
+ Selectors return rich objects (`ParagraphObject`, `TextLineObject`, `ImageObject`, `FormFieldObject`, etc.) with helpers
122
+ such as `delete()`, `move_to(x, y)`, or `edit()` depending on the object type.
111
123
 
112
124
  ## Editing Text and Forms
113
125
 
@@ -116,11 +128,11 @@ with PDFDancer.open("report.pdf") as pdf:
116
128
  paragraph = pdf.page(0).select_paragraphs_starting_with("Disclaimer")[0]
117
129
 
118
130
  # Chain updates explicitly…
119
- paragraph.edit() \
120
- .replace("Updated disclaimer text") \
121
- .font("Roboto-Regular", 11) \
122
- .line_spacing(1.1) \
123
- .move_to(72, 140) \
131
+ paragraph.edit()
132
+ .replace("Updated disclaimer text")
133
+ .font("Roboto-Regular", 11)
134
+ .line_spacing(1.1)
135
+ .move_to(72, 140)
124
136
  .apply()
125
137
 
126
138
  # …or use the context manager to auto-apply on success
@@ -141,16 +153,16 @@ with PDFDancer.open("report.pdf") as pdf:
141
153
  pdf.register_font("/path/to/custom.ttf")
142
154
 
143
155
  # Paragraphs
144
- pdf.new_paragraph() \
145
- .text("Greetings from PDFDancer!") \
146
- .font(fonts[0].name, fonts[0].size) \
147
- .at(page_index=0, x=220, y=480) \
156
+ pdf.new_paragraph()
157
+ .text("Greetings from PDFDancer!")
158
+ .font(fonts[0].name, fonts[0].size)
159
+ .at(page_index=0, x=220, y=480)
148
160
  .add()
149
161
 
150
162
  # Raster images
151
- pdf.new_image() \
152
- .from_file(Path("logo.png")) \
153
- .at(page=0, x=48, y=700) \
163
+ pdf.new_image()
164
+ .from_file(Path("logo.png"))
165
+ .at(page=0, x=48, y=700)
154
166
  .add()
155
167
  ```
156
168
 
@@ -175,8 +187,7 @@ Wrap complex workflows in `try/except` blocks to surface actionable errors to yo
175
187
  ```bash
176
188
  python -m venv venv
177
189
  source venv/bin/activate # Windows: venv\Scripts\activate
178
- pip install -e .
179
- pip install -r requirements-dev.txt
190
+ pip install -e ".[dev]"
180
191
 
181
192
  pytest -q # run the fast unit suite
182
193
  pytest tests/e2e # integration tests (requires live API + fixtures)
@@ -0,0 +1,11 @@
1
+ pdfdancer/__init__.py,sha256=STOBUkVrBG7SbgoT6wM6tfwBVbjUiQ9JTpmznJwBF94,1158
2
+ pdfdancer/exceptions.py,sha256=Y5zwNVZprsv2hvKX304cXWobJt11nrEhCzLklu2wiO8,1567
3
+ pdfdancer/image_builder.py,sha256=Omxc2LcieJ1MbvWBXR5_sfia--eAucTUe0KWgr22HYo,842
4
+ pdfdancer/models.py,sha256=yhatfgMWxYareL7J20Wz_6-V7oCzrqX35oZdNJ8UFJM,22984
5
+ pdfdancer/paragraph_builder.py,sha256=pgFTkyhYrx4VQDKy4Vhp-042OMlJOD8D0MW9flkvC7Y,9410
6
+ pdfdancer/pdfdancer_v1.py,sha256=uHcn-Thh5dQA8FnC-YojwkfzdGokYeQEUyiz41lut2c,53296
7
+ pdfdancer/types.py,sha256=SOmYP49XPVy6DZ4JXSJrfy0Aww-Tv7QjZCDnOB8VTT4,11860
8
+ pdfdancer_client_python-0.2.14.dist-info/METADATA,sha256=Pqhnjgfz_SoLD0zOJNAGg-otbszgdc4HG39v1iRGgf4,7060
9
+ pdfdancer_client_python-0.2.14.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
10
+ pdfdancer_client_python-0.2.14.dist-info/top_level.txt,sha256=ICwSVRpcCKrdBF9QlaX9Y0e_N3Nk1p7QVxadGOnbxeY,10
11
+ pdfdancer_client_python-0.2.14.dist-info/RECORD,,
@@ -1,11 +0,0 @@
1
- pdfdancer/__init__.py,sha256=71HwLjHHPsCQMTUtLHYAwzslhF3PqN5g1QwMr4HbKSQ,1076
2
- pdfdancer/exceptions.py,sha256=Y5zwNVZprsv2hvKX304cXWobJt11nrEhCzLklu2wiO8,1567
3
- pdfdancer/image_builder.py,sha256=Omxc2LcieJ1MbvWBXR5_sfia--eAucTUe0KWgr22HYo,842
4
- pdfdancer/models.py,sha256=ZoB5ZP1jaZsubqzhMr9W9nsIUirVUty_FkRiPZWq8vY,18276
5
- pdfdancer/paragraph_builder.py,sha256=mjV36-XOqcYATfIjSOy7_SBO0EKXjsAtMqYL8IaowGU,9218
6
- pdfdancer/pdfdancer_v1.py,sha256=XgcyKHPOBMI5vNM86ZRhRvJFA3wB7DTcWwDt6tHQxpI,39851
7
- pdfdancer/types.py,sha256=SOmYP49XPVy6DZ4JXSJrfy0Aww-Tv7QjZCDnOB8VTT4,11860
8
- pdfdancer_client_python-0.2.12.dist-info/METADATA,sha256=XHEG0LuL-bi7MQyYVUX2RWrq6mutyVlLcYSlqiUzMAg,6770
9
- pdfdancer_client_python-0.2.12.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
10
- pdfdancer_client_python-0.2.12.dist-info/top_level.txt,sha256=ICwSVRpcCKrdBF9QlaX9Y0e_N3Nk1p7QVxadGOnbxeY,10
11
- pdfdancer_client_python-0.2.12.dist-info/RECORD,,