pdfdancer-client-python 0.2.20__py3-none-any.whl → 0.2.21__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/models.py CHANGED
@@ -196,6 +196,7 @@ class ObjectType(Enum):
196
196
  RADIO_BUTTON = "RADIO_BUTTON"
197
197
  BUTTON = "BUTTON"
198
198
  DROPDOWN = "DROPDOWN"
199
+ TEXT_ELEMENT = "TEXT_ELEMENT"
199
200
 
200
201
 
201
202
  class PositionMode(Enum):
@@ -659,6 +660,34 @@ class Image:
659
660
  self.position = position
660
661
 
661
662
 
663
+ @dataclass
664
+ class TextLine:
665
+ """
666
+ One line of text to add to a page.
667
+
668
+ Parameters:
669
+ - position: Anchor position where the first line begins.
670
+ - text: the text
671
+ provide separate entries for multiple lines.
672
+ - font: Font to use for all text elements unless overridden later.
673
+ - color: Text color.
674
+
675
+ """
676
+ position: Optional[Position] = None
677
+ font: Optional[Font] = None
678
+ color: Optional[Color] = None
679
+ line_spacing: float = 1.2
680
+ text: str = ""
681
+
682
+ def get_position(self) -> Optional[Position]:
683
+ """Returns the position of this paragraph."""
684
+ return self.position
685
+
686
+ def set_position(self, position: Position) -> None:
687
+ """Sets the position of this paragraph."""
688
+ self.position = position
689
+
690
+
662
691
  @dataclass
663
692
  class Paragraph:
664
693
  """
@@ -687,10 +716,11 @@ class Paragraph:
687
716
  ```
688
717
  """
689
718
  position: Optional[Position] = None
690
- text_lines: Optional[List[str]] = None
719
+ text_lines: Optional[List[TextLine]] = None
691
720
  font: Optional[Font] = None
692
721
  color: Optional[Color] = None
693
722
  line_spacing: float = 1.2
723
+ line_spacings: Optional[List[float]] = None
694
724
 
695
725
  def get_position(self) -> Optional[Position]:
696
726
  """Returns the position of this paragraph."""
@@ -700,6 +730,34 @@ class Paragraph:
700
730
  """Sets the position of this paragraph."""
701
731
  self.position = position
702
732
 
733
+ def clear_lines(self) -> None:
734
+ """Removes all text lines from this paragraph."""
735
+ self.text_lines = []
736
+
737
+ def add_line(self, text_line: TextLine) -> None:
738
+ """Appends a text line to this paragraph."""
739
+ if self.text_lines is None:
740
+ self.text_lines = []
741
+ self.text_lines.append(text_line)
742
+
743
+ def get_lines(self) -> List[TextLine]:
744
+ """Returns the list of text lines, defaulting to an empty list."""
745
+ if self.text_lines is None:
746
+ self.text_lines = []
747
+ return self.text_lines
748
+
749
+ def set_lines(self, lines: List[TextLine]) -> None:
750
+ """Replaces the current text lines with the provided list."""
751
+ self.text_lines = list(lines)
752
+
753
+ def set_line_spacings(self, spacings: Optional[List[float]]) -> None:
754
+ """Sets the per-line spacing factors for this paragraph."""
755
+ self.line_spacings = list(spacings) if spacings else None
756
+
757
+ def get_line_spacings(self) -> Optional[List[float]]:
758
+ """Returns the per-line spacing factors if present."""
759
+ return list(self.line_spacings) if self.line_spacings else None
760
+
703
761
 
704
762
  # Request classes for API communication
705
763
  @dataclass
@@ -900,7 +958,7 @@ class AddRequest:
900
958
  def _object_to_dict(self, obj: Any) -> dict:
901
959
  """Convert PDF object to dictionary for JSON serialization."""
902
960
  import base64
903
- from .models import Path as PathModel, Line, Bezier, PathSegment
961
+ from .models import Path as PathModel
904
962
 
905
963
  if isinstance(obj, PathModel):
906
964
  # Serialize Path object
@@ -935,37 +993,55 @@ class AddRequest:
935
993
  "data": data_b64
936
994
  }
937
995
  elif isinstance(obj, Paragraph):
938
- # Build lines -> List<TextLine> with minimal structure required by server
939
- lines = []
996
+ def _font_to_dict(font: Optional[Font]) -> Optional[dict]:
997
+ if font:
998
+ return {"name": font.name, "size": font.size}
999
+ return None
1000
+
1001
+ def _color_to_dict(color: Optional[Color]) -> Optional[dict]:
1002
+ if color:
1003
+ return {"red": color.r, "green": color.g, "blue": color.b, "alpha": color.a}
1004
+ return None
1005
+
1006
+ lines_payload = []
940
1007
  if obj.text_lines:
941
1008
  for line in obj.text_lines:
1009
+ if isinstance(line, TextLine):
1010
+ line_text = line.text
1011
+ line_font = line.font or obj.font
1012
+ line_color = line.color or obj.color
1013
+ line_position = line.position or obj.position
1014
+ else:
1015
+ line_text = str(line)
1016
+ line_font = obj.font
1017
+ line_color = obj.color
1018
+ line_position = obj.position
1019
+
942
1020
  text_element = {
943
- "text": line,
944
- "font": {"name": obj.font.name, "size": obj.font.size} if obj.font else None,
945
- "color": {"red": obj.color.r, "green": obj.color.g, "blue": obj.color.b,
946
- "alpha": obj.color.a} if obj.color else None,
947
- "position": FindRequest._position_to_dict(obj.position) if obj.position else None
1021
+ "text": line_text,
1022
+ "font": _font_to_dict(line_font),
1023
+ "color": _color_to_dict(line_color),
1024
+ "position": FindRequest._position_to_dict(line_position) if line_position else None
948
1025
  }
949
- text_line = {
950
- "textElements": [text_element]
951
- }
952
- # TextLine has color and position
953
- if obj.color:
954
- text_line["color"] = {"red": obj.color.r, "green": obj.color.g, "blue": obj.color.b,
955
- "alpha": obj.color.a}
956
- if obj.position:
957
- text_line["position"] = FindRequest._position_to_dict(obj.position)
958
- lines.append(text_line)
1026
+ text_line = {"textElements": [text_element]}
1027
+ if line_color:
1028
+ text_line["color"] = _color_to_dict(line_color)
1029
+ if line_position:
1030
+ text_line["position"] = FindRequest._position_to_dict(line_position)
1031
+ lines_payload.append(text_line)
1032
+
959
1033
  line_spacings = None
960
- if hasattr(obj, "line_spacing") and obj.line_spacing is not None:
961
- # Server expects a list
1034
+ if getattr(obj, "line_spacings", None):
1035
+ line_spacings = list(obj.line_spacings)
1036
+ elif getattr(obj, "line_spacing", None) is not None:
962
1037
  line_spacings = [obj.line_spacing]
1038
+
963
1039
  return {
964
1040
  "type": "PARAGRAPH",
965
1041
  "position": FindRequest._position_to_dict(obj.position) if obj.position else None,
966
- "lines": lines,
1042
+ "lines": lines_payload if lines_payload else None,
967
1043
  "lineSpacings": line_spacings,
968
- "font": {"name": obj.font.name, "size": obj.font.size} if obj.font else None
1044
+ "font": _font_to_dict(obj.font)
969
1045
  }
970
1046
  else:
971
1047
  raise ValueError(f"Unsupported object type: {type(obj)}")
@@ -1,108 +1,100 @@
1
1
  """
2
2
  ParagraphBuilder for the PDFDancer Python client.
3
- Closely mirrors the Java ParagraphBuilder class with Python conventions.
3
+ Mirrors the behaviour of the Java implementation while keeping Python conventions.
4
4
  """
5
5
 
6
+ from __future__ import annotations
7
+
8
+ from copy import deepcopy
6
9
  from pathlib import Path
7
- from typing import Optional, Union
10
+ from typing import List, Optional, Union
8
11
 
9
12
  from . import StandardFonts
10
13
  from .exceptions import ValidationException
11
- from .models import Paragraph, Font, Color, Position
14
+ from .models import (
15
+ Paragraph,
16
+ Font,
17
+ Color,
18
+ Position,
19
+ TextLine,
20
+ TextObjectRef,
21
+ Point,
22
+ ObjectRef,
23
+ )
24
+
25
+ DEFAULT_LINE_SPACING_FACTOR = 1.2
26
+ DEFAULT_TEXT_COLOR = Color(0, 0, 0)
27
+ _DEFAULT_BASE_FONT_SIZE = 12.0
12
28
 
13
29
 
14
30
  class ParagraphBuilder:
15
31
  """
16
- Builder class for constructing Paragraph objects with fluent interface.
17
- Mirrors the Java ParagraphBuilder class exactly.
32
+ Fluent builder used to assemble `Paragraph` instances.
33
+ Behaviour is aligned with the Java `ParagraphBuilder` so that mixed client/server
34
+ scenarios stay predictable.
18
35
  """
19
36
 
20
37
  def __init__(self, client: 'PDFDancer'):
21
- """
22
- Initialize the paragraph builder with a client reference.
23
-
24
- Args:
25
- client: The ClientV1 instance for font registration
26
- """
27
38
  if client is None:
28
39
  raise ValidationException("Client cannot be null")
29
40
 
30
41
  self._client = client
31
42
  self._paragraph = Paragraph()
32
- self._line_spacing = 1.2
33
- self._text_color = Color(0, 0, 0) # Black by default
43
+ self._line_spacing_factor: Optional[float] = None
44
+ self._text_color: Optional[Color] = None
34
45
  self._text: Optional[str] = None
35
46
  self._ttf_file: Optional[Path] = None
36
47
  self._font: Optional[Font] = None
48
+ self._font_explicitly_changed = False
49
+ self._original_paragraph_position: Optional[Position] = None
50
+ self._target_object_ref: Optional[ObjectRef] = None
51
+ self._original_font: Optional[Font] = None
52
+ self._original_color: Optional[Color] = None
53
+ self._position_changed = False
54
+
55
+ def only_text_changed(self) -> bool:
56
+ """Return True when only the text payload has been modified."""
57
+ return (
58
+ self._text is not None
59
+ and self._text_color is None
60
+ and self._ttf_file is None
61
+ and (self._font is None or not self._font_explicitly_changed)
62
+ and self._line_spacing_factor is None
63
+ )
37
64
 
38
65
  def text(self, text: str, color: Optional[Color] = None) -> 'ParagraphBuilder':
39
- """
40
- Set the text content for the paragraph.
41
- Equivalent to fromString() methods in Java ParagraphBuilder.
42
-
43
- Args:
44
- text: The text content for the paragraph
45
- color: Optional text color (uses default if not provided)
46
-
47
- Returns:
48
- Self for method chaining
49
-
50
- Raises:
51
- ValidationException: If text is None or empty
52
- """
53
66
  if text is None:
54
67
  raise ValidationException("Text cannot be null")
55
- if not text.strip():
56
- raise ValidationException("Text cannot be empty")
57
-
58
68
  self._text = text
59
69
  if color is not None:
60
- self._text_color = color
61
-
70
+ self.color(color)
62
71
  return self
63
72
 
64
- def font(self, font_name: str | StandardFonts, font_size: float) -> 'ParagraphBuilder':
73
+ def font(
74
+ self,
75
+ font: Union[Font, str, StandardFonts],
76
+ font_size: Optional[float] = None
77
+ ) -> 'ParagraphBuilder':
65
78
  """
66
- Set the font for the paragraph using an existing Font object.
67
- Equivalent to withFont(Font) in Java ParagraphBuilder.
68
-
69
- Args:
70
- font_name: The Font to use
71
- font_size: The font size
72
-
73
- Returns:
74
- Self for method chaining
75
-
76
- Raises:
77
- ValidationException: If font is None
79
+ Configure the font either by providing a `Font` instance or name + size.
78
80
  """
79
- # If font_name is an enum member, use its value
80
- if isinstance(font_name, StandardFonts):
81
- font_name = font_name.value
82
-
83
- font = Font(font_name, font_size)
84
- if font is None:
85
- raise ValidationException("Font cannot be null")
86
-
87
- self._font = font
88
- self._ttf_file = None # Clear TTF file when using existing font
81
+ if isinstance(font, Font):
82
+ resolved_font = font
83
+ else:
84
+ if isinstance(font, StandardFonts):
85
+ font = font.value
86
+ if font is None:
87
+ raise ValidationException("Font name cannot be null")
88
+ if font_size is None:
89
+ raise ValidationException("Font size must be provided when setting font by name")
90
+ resolved_font = Font(str(font), font_size)
91
+
92
+ self._font = resolved_font
93
+ self._ttf_file = None
94
+ self._font_explicitly_changed = True
89
95
  return self
90
96
 
91
97
  def font_file(self, ttf_file: Union[Path, str], font_size: float) -> 'ParagraphBuilder':
92
- """
93
- Set the font for the paragraph using a TTF file.
94
- Equivalent to withFont(File, double) in Java ParagraphBuilder.
95
-
96
- Args:
97
- ttf_file: Path to the TTF font file
98
- font_size: Size of the font
99
-
100
- Returns:
101
- Self for method chaining
102
-
103
- Raises:
104
- ValidationException: If TTF file is invalid or font size is not positive
105
- """
106
98
  if ttf_file is None:
107
99
  raise ValidationException("TTF file cannot be null")
108
100
  if font_size <= 0:
@@ -110,170 +102,393 @@ class ParagraphBuilder:
110
102
 
111
103
  ttf_path = Path(ttf_file)
112
104
 
113
- # Strict validation like Java client
114
105
  if not ttf_path.exists():
115
106
  raise ValidationException(f"TTF file does not exist: {ttf_path}")
116
107
  if not ttf_path.is_file():
117
108
  raise ValidationException(f"TTF file is not a file: {ttf_path}")
118
- if not ttf_path.stat().st_size > 0:
109
+ if ttf_path.stat().st_size <= 0:
119
110
  raise ValidationException(f"TTF file is empty: {ttf_path}")
120
111
 
121
- # Check file permissions
122
112
  try:
123
- with open(ttf_path, 'rb') as f:
124
- f.read(1) # Try to read one byte to check readability
125
- except (IOError, OSError):
126
- raise ValidationException(f"TTF file is not readable: {ttf_path}")
113
+ with open(ttf_path, 'rb') as handle:
114
+ handle.read(1)
115
+ except (IOError, OSError) as exc:
116
+ raise ValidationException(f"TTF file is not readable: {ttf_path}") from exc
127
117
 
128
118
  self._ttf_file = ttf_path
129
119
  self._font = self._register_ttf(ttf_path, font_size)
120
+ self._font_explicitly_changed = True
130
121
  return self
131
122
 
132
- def line_spacing(self, spacing: float) -> 'ParagraphBuilder':
133
- """
134
- Set the line spacing for the paragraph.
135
- Equivalent to withLineSpacing() in Java ParagraphBuilder.
123
+ def set_font_explicitly_changed(self, changed: bool) -> None:
124
+ self._font_explicitly_changed = bool(changed)
136
125
 
137
- Args:
138
- spacing: Line spacing value (typically 1.0 to 2.0)
126
+ def set_original_paragraph_position(self, position: Position) -> None:
127
+ self._original_paragraph_position = position
128
+ if position and self._paragraph.get_position() is None:
129
+ self._paragraph.set_position(deepcopy(position))
139
130
 
140
- Returns:
141
- Self for method chaining
131
+ def target(self, object_ref: ObjectRef) -> 'ParagraphBuilder':
132
+ if object_ref is None:
133
+ raise ValidationException("Object reference cannot be null")
134
+ self._target_object_ref = object_ref
135
+ return self
142
136
 
143
- Raises:
144
- ValidationException: If spacing is not positive
145
- """
137
+ def line_spacing(self, spacing: float) -> 'ParagraphBuilder':
146
138
  if spacing <= 0:
147
139
  raise ValidationException(f"Line spacing must be positive, got {spacing}")
148
-
149
- self._line_spacing = spacing
140
+ self._line_spacing_factor = spacing
150
141
  return self
151
142
 
152
143
  def color(self, color: Color) -> 'ParagraphBuilder':
153
- """
154
- Set the text color for the paragraph.
155
- Equivalent to withColor() in Java ParagraphBuilder.
156
-
157
- Args:
158
- color: The Color object for the text
159
-
160
- Returns:
161
- Self for method chaining
162
-
163
- Raises:
164
- ValidationException: If color is None
165
- """
166
144
  if color is None:
167
145
  raise ValidationException("Color cannot be null")
168
-
169
146
  self._text_color = color
170
147
  return self
171
148
 
172
- def at(self, page_index: int, x: float, y: float) -> 'ParagraphBuilder':
149
+ def move_to(self, x: float, y: float) -> 'ParagraphBuilder':
150
+ """
151
+ Move the paragraph anchor to new coordinates on the same page.
173
152
  """
174
- Set the position for the paragraph.
175
- Equivalent to withPosition() in Java ParagraphBuilder.
153
+ position = self._paragraph.get_position()
154
+ if position is None and self._target_object_ref and self._target_object_ref.position:
155
+ position = deepcopy(self._target_object_ref.position)
156
+ self._paragraph.set_position(position)
176
157
 
177
- Args:
178
- position: The Position object for the paragraph
158
+ if position is None:
159
+ raise ValidationException("Cannot move paragraph without an existing position")
179
160
 
180
- Returns:
181
- Self for method chaining
161
+ page_index = position.page_index
162
+ if page_index is None:
163
+ raise ValidationException("Paragraph position must include a page index to move")
182
164
 
183
- Raises:
184
- ValidationException: If position is None
185
- """
186
- position = Position.at_page_coordinates(page_index, x, y)
165
+ self._position_changed = True
166
+ return self.at(page_index, x, y)
167
+
168
+ def at_position(self, position: Position) -> 'ParagraphBuilder':
187
169
  if position is None:
188
170
  raise ValidationException("Position cannot be null")
171
+ # Defensive copy so builder mutations do not alter original references
172
+ self._paragraph.set_position(deepcopy(position))
173
+ self._position_changed = True
174
+ return self
189
175
 
190
- self._paragraph.set_position(position)
176
+ def at(self, page_index: int, x: float, y: float) -> 'ParagraphBuilder':
177
+ return self.at_position(Position.at_page_coordinates(page_index, x, y))
178
+
179
+ def add_text_line(self, text_line: Union[TextLine, TextObjectRef, str]) -> 'ParagraphBuilder':
180
+ self._paragraph.add_line(self._coerce_text_line(text_line))
191
181
  return self
192
182
 
193
- def _build(self) -> Paragraph:
194
- """
195
- Build and return the final Paragraph object.
196
- Equivalent to build() in Java ParagraphBuilder.
183
+ def get_text(self) -> Optional[str]:
184
+ return self._text
197
185
 
198
- This method validates all required fields and constructs the final paragraph
199
- with text processing similar to ParagraphUtil.finalizeText() in Java.
186
+ def add(self) -> bool:
187
+ # noinspection PyProtectedMember
188
+ return self._client._add_paragraph(self._finalize_paragraph())
200
189
 
201
- Returns:
202
- The constructed Paragraph object
190
+ def modify(self, object_ref: Optional[ObjectRef] = None):
191
+ target_ref = object_ref or self._target_object_ref
192
+ if target_ref is None:
193
+ raise ValidationException("Object reference must be provided to modify a paragraph")
203
194
 
204
- Raises:
205
- ValidationException: If required fields are missing or invalid
206
- """
207
- # Validate required fields
208
- if self._text is None:
209
- raise ValidationException("Text must be set before building paragraph")
210
- if self._font is None:
211
- raise ValidationException("Font must be set before building paragraph")
195
+ if self.only_text_changed():
196
+ # Backend accepts plain text updates for simple edits
197
+ return self._client._modify_paragraph(target_ref, self._text or "")
198
+
199
+ paragraph = self._finalize_paragraph()
200
+ return self._client._modify_paragraph(target_ref, paragraph)
201
+
202
+ # ------------------------------------------------------------------ #
203
+ # Internal helpers
204
+ # ------------------------------------------------------------------ #
205
+
206
+ def _finalize_paragraph(self) -> Paragraph:
212
207
  if self._paragraph.get_position() is None:
213
208
  raise ValidationException("Position must be set before building paragraph")
214
209
 
215
- # Set paragraph properties
216
- self._paragraph.font = self._font
217
- self._paragraph.color = self._text_color
218
- self._paragraph.line_spacing = self._line_spacing
219
-
220
- # Process text into lines (simplified version of ParagraphUtil.finalizeText)
221
- # In the full implementation, this would handle text wrapping, line breaks, etc.
222
- self._paragraph.text_lines = self._process_text_lines(self._text)
210
+ if self._target_object_ref is None and self._font is None and self._paragraph.font is None:
211
+ raise ValidationException("Font must be set before building paragraph")
223
212
 
213
+ if self._text is not None:
214
+ self._finalize_lines_from_text()
215
+ elif not self._paragraph.text_lines:
216
+ raise ValidationException("Either text must be provided or existing lines supplied")
217
+ else:
218
+ self._finalize_existing_lines()
219
+
220
+ self._reposition_lines()
221
+
222
+ should_skip_lines = (
223
+ self._position_changed
224
+ and self._text is None
225
+ and self._text_color is None
226
+ and (self._font is None or not self._font_explicitly_changed)
227
+ and self._line_spacing_factor is None
228
+ )
229
+ if should_skip_lines:
230
+ self._paragraph.text_lines = None
231
+ self._paragraph.set_line_spacings(None)
232
+
233
+ final_font = self._font if self._font is not None else self._original_font
234
+ if final_font is None:
235
+ final_font = Font(StandardFonts.HELVETICA.value, _DEFAULT_BASE_FONT_SIZE)
236
+ self._paragraph.font = final_font
237
+
238
+ if self._text_color is not None:
239
+ final_color = self._text_color
240
+ elif self._text is not None:
241
+ final_color = self._original_color or DEFAULT_TEXT_COLOR
242
+ else:
243
+ final_color = self._original_color
244
+ self._paragraph.color = final_color
224
245
  return self._paragraph
225
246
 
226
- def _register_ttf(self, ttf_file: Path, font_size: float) -> Font:
227
- """
228
- Register a TTF font with the client and return a Font object.
229
- Equivalent to registerTTF() private method in Java ParagraphBuilder.
230
-
231
- Args:
232
- ttf_file: Path to the TTF font file
233
- font_size: Size of the font
247
+ def _finalize_lines_from_text(self) -> None:
248
+ base_font = self._font or self._original_font
249
+ base_color = self._text_color or self._original_color or DEFAULT_TEXT_COLOR
250
+ color = base_color
251
+
252
+ if self._line_spacing_factor is not None:
253
+ spacing = self._line_spacing_factor
254
+ else:
255
+ existing_spacings = self._paragraph.get_line_spacings()
256
+ if existing_spacings:
257
+ spacing = existing_spacings[0]
258
+ elif self._paragraph.line_spacing:
259
+ spacing = self._paragraph.line_spacing
260
+ else:
261
+ spacing = DEFAULT_LINE_SPACING_FACTOR
262
+
263
+ self._paragraph.clear_lines()
264
+ lines: List[TextLine] = []
265
+ for index, line_text in enumerate(self._split_text(self._text or "")):
266
+ line_position = self._calculate_line_position(index, spacing)
267
+ lines.append(
268
+ TextLine(
269
+ position=line_position,
270
+ font=base_font,
271
+ color=color,
272
+ line_spacing=spacing,
273
+ text=line_text,
274
+ )
275
+ )
276
+ self._paragraph.set_lines(lines)
277
+ self._paragraph.set_line_spacings([spacing] * (len(lines) - 1) if len(lines) > 1 else None)
278
+ self._paragraph.line_spacing = spacing
279
+
280
+ def _finalize_existing_lines(self) -> None:
281
+ lines = self._paragraph.text_lines or []
282
+ spacing_override = self._line_spacing_factor
283
+ spacing_for_calc = spacing_override
284
+
285
+ if spacing_for_calc is None:
286
+ existing_spacings = self._paragraph.get_line_spacings()
287
+ if existing_spacings:
288
+ spacing_for_calc = existing_spacings[0]
289
+ if spacing_for_calc is None:
290
+ spacing_for_calc = self._paragraph.line_spacing or DEFAULT_LINE_SPACING_FACTOR
291
+
292
+ updated_lines: List[TextLine] = []
293
+ for index, line in enumerate(lines):
294
+ if isinstance(line, TextLine):
295
+ if spacing_override is not None:
296
+ line.line_spacing = spacing_override
297
+ if self._text_color is not None:
298
+ line.color = self._text_color
299
+ if self._font is not None and self._font_explicitly_changed:
300
+ line.font = self._font
301
+ updated_lines.append(line)
302
+ else:
303
+ line_position = self._calculate_line_position(index, spacing_for_calc)
304
+ updated_lines.append(
305
+ TextLine(
306
+ position=line_position,
307
+ font=self._font if self._font is not None else self._original_font,
308
+ color=self._text_color or self._original_color or DEFAULT_TEXT_COLOR,
309
+ line_spacing=spacing_override if spacing_override is not None else spacing_for_calc,
310
+ text=str(line),
311
+ )
312
+ )
313
+
314
+ self._paragraph.set_lines(updated_lines)
315
+
316
+ if spacing_override is not None:
317
+ self._paragraph.set_line_spacings(
318
+ [spacing_override] * (len(updated_lines) - 1) if len(updated_lines) > 1 else None
319
+ )
320
+ self._paragraph.line_spacing = spacing_override
321
+
322
+
323
+ def _reposition_lines(self) -> None:
324
+ if self._text is not None:
325
+ # Newly generated text lines already align with the updated paragraph position.
326
+ return
327
+ paragraph_pos = self._paragraph.get_position()
328
+ lines = self._paragraph.text_lines or []
329
+ if not paragraph_pos or not lines:
330
+ return
331
+
332
+ base_position = self._original_paragraph_position
333
+ if base_position is None:
334
+ for line in lines:
335
+ if isinstance(line, TextLine) and line.position is not None:
336
+ base_position = line.position
337
+ break
338
+
339
+ if base_position is None:
340
+ return
341
+
342
+ target_x = paragraph_pos.x()
343
+ target_y = paragraph_pos.y()
344
+ base_x = base_position.x()
345
+ base_y = base_position.y()
346
+ if None in (target_x, target_y, base_x, base_y):
347
+ return
348
+
349
+ dx = target_x - base_x
350
+ dy = target_y - base_y
351
+ if dx == 0 and dy == 0:
352
+ return
353
+
354
+ for line in lines:
355
+ if isinstance(line, TextLine) and line.position is not None:
356
+ current_x = line.position.x()
357
+ current_y = line.position.y()
358
+ if current_x is None or current_y is None:
359
+ continue
360
+ line.position.at_coordinates(Point(current_x + dx, current_y + dy))
361
+
362
+ def _coerce_text_line(self, source: Union[TextLine, TextObjectRef, str]) -> TextLine:
363
+ if isinstance(source, TextLine):
364
+ return source
365
+
366
+ if isinstance(source, TextObjectRef):
367
+ font = None
368
+ if source.font_name and source.font_size:
369
+ font = Font(source.font_name, source.font_size)
370
+ elif getattr(source, "children", None):
371
+ for child in source.children:
372
+ if child.font_name and child.font_size:
373
+ font = Font(child.font_name, child.font_size)
374
+ break
375
+ if font is None:
376
+ font = self._original_font
377
+
378
+ spacing = self._line_spacing_factor
379
+ if spacing is None and source.line_spacings:
380
+ spacing = source.line_spacings[0]
381
+ if spacing is None:
382
+ spacing = self._paragraph.line_spacing or DEFAULT_LINE_SPACING_FACTOR
383
+
384
+ color = source.color or self._original_color
385
+
386
+ line = TextLine(
387
+ position=deepcopy(source.position) if source.position else None,
388
+ font=font,
389
+ color=color,
390
+ line_spacing=spacing,
391
+ text=source.text or "",
392
+ )
393
+ if self._original_font is None and font is not None:
394
+ self._original_font = font
395
+ if self._original_color is None and color is not None:
396
+ self._original_color = color
397
+ return line
398
+
399
+ if isinstance(source, str):
400
+ current_index = len(self._paragraph.get_lines())
401
+ spacing = self._line_spacing_factor if self._line_spacing_factor is not None else (
402
+ self._paragraph.line_spacing or DEFAULT_LINE_SPACING_FACTOR
403
+ )
404
+ line_position = self._calculate_line_position(current_index, spacing)
405
+ return TextLine(
406
+ position=line_position,
407
+ font=self._font or self._original_font,
408
+ color=self._text_color or self._original_color or DEFAULT_TEXT_COLOR,
409
+ line_spacing=spacing,
410
+ text=source,
411
+ )
412
+
413
+ raise ValidationException(f"Unsupported text line type: {type(source)}")
414
+
415
+ def _split_text(self, text: str) -> List[str]:
416
+ processed = text.replace('\r\n', '\n').replace('\r', '\n').replace('\\n', '\n')
417
+ parts = processed.split('\n')
418
+ while parts and parts[-1] == '':
419
+ parts.pop()
420
+ if not parts:
421
+ parts = ['']
422
+ return parts
423
+
424
+ def _calculate_line_position(self, line_index: int, spacing_factor: float) -> Optional[Position]:
425
+ paragraph_position = self._paragraph.get_position()
426
+ if paragraph_position is None:
427
+ return None
428
+
429
+ page_index = paragraph_position.page_index
430
+ base_x = paragraph_position.x()
431
+ base_y = paragraph_position.y()
432
+ if page_index is None or base_x is None or base_y is None:
433
+ return None
434
+
435
+ offset = line_index * self._calculate_baseline_distance(spacing_factor)
436
+ return Position.at_page_coordinates(page_index, base_x, base_y + offset)
437
+
438
+ def _calculate_baseline_distance(self, spacing_factor: float) -> float:
439
+ factor = spacing_factor if spacing_factor > 0 else DEFAULT_LINE_SPACING_FACTOR
440
+ return self._baseline_font_size() * factor
441
+
442
+ def _baseline_font_size(self) -> float:
443
+ if self._font and self._font.size:
444
+ return self._font.size
445
+ if self._original_font and self._original_font.size:
446
+ return self._original_font.size
447
+ return _DEFAULT_BASE_FONT_SIZE
234
448
 
235
- Returns:
236
- Font object with the registered font name and size
237
- """
449
+ def _register_ttf(self, ttf_file: Path, font_size: float) -> Font:
238
450
  try:
239
451
  font_name = self._client.register_font(ttf_file)
240
452
  return Font(font_name, font_size)
241
- except Exception as e:
242
- raise ValidationException(f"Failed to register font file {ttf_file}: {e}")
453
+ except Exception as exc:
454
+ raise ValidationException(f"Failed to register font file {ttf_file}: {exc}") from exc
243
455
 
244
- def _process_text_lines(self, text: str) -> list[str]:
456
+ def _build(self) -> Paragraph:
457
+ """
458
+ Backwards-compatible alias for callers that invoked the previous `_build`.
245
459
  """
246
- Process text into lines for the paragraph.
247
- This is a simplified version - the full implementation would handle
248
- word wrapping, line breaks, and other text formatting based on the font
249
- and paragraph width.
460
+ return self._finalize_paragraph()
250
461
 
251
- Args:
252
- text: The input text to process
462
+ @classmethod
463
+ def from_object_ref(cls, client: 'PDFDancer', object_ref: TextObjectRef) -> 'ParagraphBuilder':
464
+ if object_ref is None:
465
+ raise ValidationException("Object reference cannot be null")
253
466
 
254
- Returns:
255
- List of text lines for the paragraph
256
- """
257
- # Handle escaped newlines (\\n) as actual newlines
258
- processed_text = text.replace('\\n', '\n')
467
+ builder = cls(client)
468
+ builder.target(object_ref)
259
469
 
260
- # Simple implementation - split on newlines
261
- # In the full version, this would implement proper text layout
262
- lines = processed_text.split('\n')
470
+ if object_ref.position:
471
+ builder.at_position(object_ref.position)
472
+ builder.set_original_paragraph_position(object_ref.position)
263
473
 
264
- # Remove empty lines at the end but preserve intentional line breaks
265
- while lines and not lines[-1].strip():
266
- lines.pop()
474
+ if object_ref.line_spacings:
475
+ builder._paragraph.set_line_spacings(object_ref.line_spacings)
476
+ builder._paragraph.line_spacing = object_ref.line_spacings[0] if object_ref.line_spacings else builder._paragraph.line_spacing
267
477
 
268
- # Ensure at least one line
269
- if not lines:
270
- lines = ['']
478
+ if object_ref.font_name and object_ref.font_size:
479
+ builder._original_font = Font(object_ref.font_name, object_ref.font_size)
271
480
 
272
- return lines
481
+ if object_ref.color:
482
+ builder._original_color = object_ref.color
273
483
 
274
- def add(self):
275
- # noinspection PyProtectedMember
276
- return self._client._add_paragraph(self._build())
484
+ if object_ref.children:
485
+ for child in object_ref.children:
486
+ builder.add_text_line(child)
487
+ elif object_ref.text:
488
+ for segment in object_ref.text.split('\n'):
489
+ builder.add_text_line(segment)
490
+
491
+ return builder
277
492
 
278
493
 
279
494
  class ParagraphPageBuilder(ParagraphBuilder):
pdfdancer/pdfdancer_v1.py CHANGED
@@ -7,6 +7,7 @@ Provides session-based PDF manipulation operations with strict validation.
7
7
 
8
8
  import gzip
9
9
  import json
10
+ import logging
10
11
  import os
11
12
  import time
12
13
  from datetime import datetime, timezone
@@ -127,7 +128,8 @@ from .exceptions import (
127
128
  from .image_builder import ImageBuilder, ImageOnPageBuilder
128
129
  from .models import (
129
130
  ObjectRef, Position, ObjectType, Font, Image, Paragraph, FormFieldRef, TextObjectRef, PageRef,
130
- FindRequest, DeleteRequest, MoveRequest, PageMoveRequest, AddPageRequest, AddRequest, ModifyRequest, ModifyTextRequest,
131
+ FindRequest, DeleteRequest, MoveRequest, PageMoveRequest, AddPageRequest, AddRequest, ModifyRequest,
132
+ ModifyTextRequest,
131
133
  ChangeFormFieldRequest, CommandResult,
132
134
  ShapeType, PositionMode, PageSize, Orientation,
133
135
  PageSnapshot, DocumentSnapshot, FontRecommendation, FontType
@@ -1850,11 +1852,14 @@ class PDFDancer:
1850
1852
  status=status
1851
1853
  )
1852
1854
 
1853
- if isinstance(obj_data.get('children'), list) and len(obj_data['children']) > 0:
1854
- text_object.children = [
1855
- self._parse_text_object_ref(child_data, f"{internal_id or 'child'}-{index}")
1856
- for index, child_data in enumerate(obj_data['children'])
1857
- ]
1855
+ try:
1856
+ if isinstance(obj_data.get('children'), list) and len(obj_data['children']) > 0:
1857
+ text_object.children = [
1858
+ self._parse_text_object_ref(child_data, f"{internal_id or 'child'}-{index}")
1859
+ for index, child_data in enumerate(obj_data['children'])
1860
+ ]
1861
+ except ValueError as e:
1862
+ logging.exception(f"Failed to parse children of {internal_id}", e)
1858
1863
 
1859
1864
  return text_object
1860
1865
 
@@ -1997,7 +2002,7 @@ class PDFDancer:
1997
2002
  even_odd_fill=even_odd_fill
1998
2003
  )
1999
2004
 
2000
- def _parse_font_recommendation(self, data: dict) -> FontRecommendation:
2005
+ def _parse_document_font_info(self, data: dict) -> FontRecommendation:
2001
2006
  """Parse JSON data into FontRecommendation instance."""
2002
2007
  font_type_str = data.get('fontType', 'SYSTEM')
2003
2008
  font_type = FontType(font_type_str)
@@ -2054,7 +2059,7 @@ class PDFDancer:
2054
2059
  def _parse_document_snapshot(self, data: dict) -> DocumentSnapshot:
2055
2060
  """Parse JSON data into DocumentSnapshot instance."""
2056
2061
  page_count = data.get('pageCount', 0)
2057
- fonts = [self._parse_font_recommendation(font_data) for font_data in data.get('fonts', [])]
2062
+ fonts = [self._parse_document_font_info(font_data) for font_data in data.get('fonts', [])]
2058
2063
  pages = [self._parse_page_snapshot(page_data) for page_data in data.get('pages', [])]
2059
2064
 
2060
2065
  return DocumentSnapshot(
pdfdancer/types.py CHANGED
@@ -1,12 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
- import statistics
4
3
  import sys
5
4
  from dataclasses import dataclass
6
- from typing import Optional, List
5
+ from typing import Optional
7
6
 
8
- from . import ObjectType, Position, ObjectRef, Point, Paragraph, Font, Color, FormFieldRef, TextObjectRef
9
- from .models import CommandResult
7
+ from . import ObjectType, Position, ObjectRef, Point, FormFieldRef, TextObjectRef
10
8
 
11
9
 
12
10
  @dataclass
@@ -95,41 +93,6 @@ class FormObject(PDFObjectBase):
95
93
  self.position == other.position)
96
94
 
97
95
 
98
- def _process_text_lines(text: str) -> List[str]:
99
- """
100
- Process text into lines for the paragraph.
101
- This is a simplified version - the full implementation would handle
102
- word wrapping, line breaks, and other text formatting based on the font
103
- and paragraph width. TODO
104
-
105
- Args:
106
- text: The input text to process
107
-
108
- Returns:
109
- List of text lines for the paragraph
110
- """
111
- # Handle escaped newlines (\\n) as actual newlines
112
- processed_text = text.replace('\\n', '\n')
113
-
114
- # Simple implementation - split on newlines
115
- # In the full version, this would implement proper text layout
116
- lines = processed_text.split('\n')
117
-
118
- # Remove empty lines at the end but preserve intentional line breaks
119
- while lines and not lines[-1].strip():
120
- lines.pop()
121
-
122
- # Ensure at least one line
123
- if not lines:
124
- lines = ['']
125
-
126
- return lines
127
-
128
-
129
- DEFAULT_LINE_SPACING = 1.2
130
- DEFAULT_COLOR = Color(0, 0, 0)
131
-
132
-
133
96
  class BaseTextEdit:
134
97
  """Common base for text-like editable objects (Paragraph, TextLine, etc.)"""
135
98
 
@@ -178,68 +141,6 @@ class BaseTextEdit:
178
141
  raise NotImplementedError("Subclasses must implement apply()")
179
142
 
180
143
 
181
- class ParagraphEdit(BaseTextEdit):
182
- def apply(self) -> CommandResult:
183
- if (
184
- self._position is None
185
- and self._line_spacing is None
186
- and self._font_size is None
187
- and self._font_name is None
188
- and self._color is None
189
- ):
190
- # noinspection PyProtectedMember
191
- result = self._target_obj._client._modify_paragraph(self._object_ref, self._new_text)
192
- if result.warning:
193
- print(f"WARNING: {result.warning}", file=sys.stderr)
194
- return result
195
- else:
196
- new_paragraph = Paragraph(
197
- position=self._position if self._position is not None else self._object_ref.position,
198
- line_spacing=self._get_line_spacing(),
199
- font=self._get_font(),
200
- text_lines=self._get_text_lines(),
201
- color=self._get_color(),
202
- )
203
- # noinspection PyProtectedMember
204
- result = self._target_obj._client._modify_paragraph(self._object_ref, new_paragraph)
205
- if result.warning:
206
- print(f"WARNING: {result.warning}", file=sys.stderr)
207
- return result
208
-
209
- def _get_line_spacing(self) -> float:
210
- if self._line_spacing is not None:
211
- return self._line_spacing
212
- elif self._object_ref.line_spacings is not None:
213
- return statistics.mean(self._object_ref.line_spacings)
214
- else:
215
- return DEFAULT_LINE_SPACING
216
-
217
- def _get_font(self):
218
- if self._font_name is not None and self._font_size is not None:
219
- return Font(name=self._font_name, size=self._font_size)
220
- elif self._object_ref.font_name is not None and self._object_ref.font_size is not None:
221
- return Font(name=self._object_ref.font_name, size=self._object_ref.font_size)
222
- else:
223
- raise Exception("Font is none")
224
-
225
- def _get_text_lines(self):
226
- if self._new_text is not None:
227
- return _process_text_lines(self._new_text)
228
- elif self._object_ref.text is not None:
229
- # TODO this actually messes up existing text line internals
230
- return _process_text_lines(self._object_ref.text)
231
- else:
232
- raise Exception("Paragraph has no text")
233
-
234
- def _get_color(self):
235
- if self._color is not None:
236
- return self._color
237
- elif self._object_ref.color is not None:
238
- return self._object_ref.color
239
- else:
240
- return DEFAULT_COLOR
241
-
242
-
243
144
  class TextLineEdit(BaseTextEdit):
244
145
  def apply(self) -> bool:
245
146
  if (
@@ -273,8 +174,8 @@ class ParagraphObject(PDFObjectBase):
273
174
  """
274
175
  return getattr(self._object_ref, name)
275
176
 
276
- def edit(self) -> ParagraphEdit:
277
- return ParagraphEdit(self, self.object_ref())
177
+ def edit(self):
178
+ return ParagraphEditSession(self._client, self.object_ref())
278
179
 
279
180
  def object_ref(self) -> TextObjectRef:
280
181
  return self._object_ref
@@ -320,6 +221,114 @@ class TextLineObject(PDFObjectBase):
320
221
  self._object_ref.children == other._object_ref.children)
321
222
 
322
223
 
224
+ class ParagraphEditSession:
225
+ """
226
+ Fluent editing helper that reuses ParagraphBuilder for modifications while preserving
227
+ the legacy context-manager workflow (replace/font/color/etc.).
228
+ """
229
+
230
+ def __init__(self, client: 'PDFDancer', object_ref: TextObjectRef):
231
+ self._client = client
232
+ self._object_ref = object_ref
233
+ self._new_text = None
234
+ self._font_name = None
235
+ self._font_size = None
236
+ self._color = None
237
+ self._line_spacing = None
238
+ self._new_position = None
239
+ self._has_changes = False
240
+
241
+ def __enter__(self):
242
+ return self
243
+
244
+ def __exit__(self, exc_type, exc_val, exc_tb):
245
+ if exc_type:
246
+ return False
247
+ self.apply()
248
+ return False
249
+
250
+ def replace(self, text: str):
251
+ self._new_text = text
252
+ self._has_changes = True
253
+ return self
254
+
255
+ def font(self, font_name, font_size: float):
256
+ self._font_name = font_name
257
+ self._font_size = font_size
258
+ self._has_changes = True
259
+ return self
260
+
261
+ def color(self, color):
262
+ self._color = color
263
+ self._has_changes = True
264
+ return self
265
+
266
+ def line_spacing(self, spacing: float):
267
+ self._line_spacing = spacing
268
+ self._has_changes = True
269
+ return self
270
+
271
+ def move_to(self, x: float, y: float):
272
+ self._new_position = (x, y)
273
+ self._has_changes = True
274
+ return self
275
+
276
+ def apply(self):
277
+ if not self._has_changes:
278
+ return self._client._modify_paragraph(self._object_ref, None)
279
+
280
+ only_text_changed = (
281
+ self._new_text is not None and
282
+ self._font_name is None and
283
+ self._font_size is None and
284
+ self._color is None and
285
+ self._line_spacing is None and
286
+ self._new_position is None
287
+ )
288
+
289
+ if only_text_changed:
290
+ result = self._client._modify_paragraph(self._object_ref, self._new_text)
291
+ self._has_changes = False
292
+ return result
293
+
294
+ only_move = (
295
+ self._new_position is not None and
296
+ self._new_text is None and
297
+ self._font_name is None and
298
+ self._font_size is None and
299
+ self._color is None and
300
+ self._line_spacing is None
301
+ )
302
+
303
+ if only_move:
304
+ page_index = self._object_ref.position.page_index if self._object_ref.position else None
305
+ if page_index is None:
306
+ raise ValidationException("Paragraph position must include a page index to move")
307
+ position = Position.at_page_coordinates(page_index, *self._new_position)
308
+ result = self._client._move(self._object_ref, position)
309
+ self._has_changes = False
310
+ return result
311
+
312
+ from .paragraph_builder import ParagraphBuilder
313
+
314
+ builder = ParagraphBuilder.from_object_ref(self._client, self._object_ref)
315
+
316
+ if self._new_text is not None:
317
+ builder.text(self._new_text)
318
+ if self._font_name is not None and self._font_size is not None:
319
+ builder.font(self._font_name, self._font_size)
320
+ if self._color is not None:
321
+ builder.color(self._color)
322
+ if self._line_spacing is not None:
323
+ builder.line_spacing(self._line_spacing)
324
+ if self._new_position is not None:
325
+ builder.move_to(*self._new_position)
326
+
327
+ result = builder.modify(self._object_ref)
328
+ self._has_changes = False
329
+ return result
330
+
331
+
323
332
  class FormFieldEdit:
324
333
  def __init__(self, form_field: 'FormFieldObject', object_ref: FormFieldRef):
325
334
  self.form_field = form_field
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pdfdancer-client-python
3
- Version: 0.2.20
3
+ Version: 0.2.21
4
4
  Summary: Python client for PDFDancer API
5
5
  Author-email: "The Famous Cat Ltd." <hi@thefamouscat.com>
6
6
  License:
@@ -0,0 +1,16 @@
1
+ pdfdancer/__init__.py,sha256=NKvSJY10p4TCc4uRC9wkDnkYJdvSdm2ry_D-fBgGtX8,2207
2
+ pdfdancer/exceptions.py,sha256=WAcyTacykJwjiaURrQamEgizLxv0vSlSio6NMikg4D0,1558
3
+ pdfdancer/fingerprint.py,sha256=Ue5QzpqsKlbYefvKU0ULV4NgMU3AOTcseeV9HfiPJXI,3093
4
+ pdfdancer/image_builder.py,sha256=ee-y7IzjZqpMN8O8ZzEm8-lOnOnqoQ7LQTzVWg4mHWg,1760
5
+ pdfdancer/models.py,sha256=vxxDiZ7db3NOcYNZCgLJL_N5y_y8Y0mVMZjt9fEuXgs,48204
6
+ pdfdancer/page_builder.py,sha256=HiMEPYWhaIWS9dZ1jxIA-XIeAwgJVXMoEAYsm3TxOt8,2969
7
+ pdfdancer/paragraph_builder.py,sha256=TDBxUd72IVC2uupmOk41nrna_8y5FXzQ2XIjyugAPF0,19634
8
+ pdfdancer/path_builder.py,sha256=G5vpVAH9OB3zV5gaSA0MKWIygmieVhwQ8dxnt_hgF7A,23693
9
+ pdfdancer/pdfdancer_v1.py,sha256=UnSMjQmJ6wGTi_G_E1Jn7f1qdaZb8cPZYCZ4xwdudMw,88861
10
+ pdfdancer/types.py,sha256=wmbmLLioNWwk4vxJc2EWuA16R0pGHlGU07TS-WjViF0,12585
11
+ pdfdancer_client_python-0.2.21.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
12
+ pdfdancer_client_python-0.2.21.dist-info/licenses/NOTICE,sha256=xaC4l-IChAmtViNDie8ZWzUk0O6XRMyzOl0zLmVZ2HE,232
13
+ pdfdancer_client_python-0.2.21.dist-info/METADATA,sha256=Jo-0PNQmuLLFD94s-7kVHcp9LxqXmnyrSUjuiQP6sXo,24668
14
+ pdfdancer_client_python-0.2.21.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
15
+ pdfdancer_client_python-0.2.21.dist-info/top_level.txt,sha256=ICwSVRpcCKrdBF9QlaX9Y0e_N3Nk1p7QVxadGOnbxeY,10
16
+ pdfdancer_client_python-0.2.21.dist-info/RECORD,,
@@ -1,16 +0,0 @@
1
- pdfdancer/__init__.py,sha256=NKvSJY10p4TCc4uRC9wkDnkYJdvSdm2ry_D-fBgGtX8,2207
2
- pdfdancer/exceptions.py,sha256=WAcyTacykJwjiaURrQamEgizLxv0vSlSio6NMikg4D0,1558
3
- pdfdancer/fingerprint.py,sha256=Ue5QzpqsKlbYefvKU0ULV4NgMU3AOTcseeV9HfiPJXI,3093
4
- pdfdancer/image_builder.py,sha256=ee-y7IzjZqpMN8O8ZzEm8-lOnOnqoQ7LQTzVWg4mHWg,1760
5
- pdfdancer/models.py,sha256=uyVYlswBUcHMSXSRjeQOa9WBc2tJU3-Q6nKquwlbT2c,45712
6
- pdfdancer/page_builder.py,sha256=HiMEPYWhaIWS9dZ1jxIA-XIeAwgJVXMoEAYsm3TxOt8,2969
7
- pdfdancer/paragraph_builder.py,sha256=x5XhPo-GUeovQuGbrH7MyyenITcy6in7bz6CD9r-ZqU,9458
8
- pdfdancer/path_builder.py,sha256=G5vpVAH9OB3zV5gaSA0MKWIygmieVhwQ8dxnt_hgF7A,23693
9
- pdfdancer/pdfdancer_v1.py,sha256=WMRdxQwpfnOvotsaP6jVcLw1_TYe4XG7M0AAoWeSkdo,88700
10
- pdfdancer/types.py,sha256=fMYNmT73ism5bN8ij8G8hELKWQA8-8o7AlIX6YFcSEw,12652
11
- pdfdancer_client_python-0.2.20.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
12
- pdfdancer_client_python-0.2.20.dist-info/licenses/NOTICE,sha256=xaC4l-IChAmtViNDie8ZWzUk0O6XRMyzOl0zLmVZ2HE,232
13
- pdfdancer_client_python-0.2.20.dist-info/METADATA,sha256=vup4o7ROABx7YHuxTlA1oSlmg3KYS9Oarl9rOELaj4g,24668
14
- pdfdancer_client_python-0.2.20.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
15
- pdfdancer_client_python-0.2.20.dist-info/top_level.txt,sha256=ICwSVRpcCKrdBF9QlaX9Y0e_N3Nk1p7QVxadGOnbxeY,10
16
- pdfdancer_client_python-0.2.20.dist-info/RECORD,,