pdfdancer-client-python 0.2.25__py3-none-any.whl → 0.2.27__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
@@ -43,11 +43,13 @@ from .models import (
43
43
  from .page_builder import PageBuilder
44
44
  from .paragraph_builder import ParagraphBuilder
45
45
  from .path_builder import BezierBuilder, LineBuilder, PathBuilder
46
+ from .text_line_builder import TextLineBuilder
46
47
 
47
48
  __version__ = "1.0.0"
48
49
  __all__ = [
49
50
  "PDFDancer",
50
51
  "ParagraphBuilder",
52
+ "TextLineBuilder",
51
53
  "PageBuilder",
52
54
  "PathBuilder",
53
55
  "LineBuilder",
pdfdancer/models.py CHANGED
@@ -1102,6 +1102,53 @@ class AddRequest:
1102
1102
  "lineSpacings": line_spacings,
1103
1103
  "font": _font_to_dict(obj.font),
1104
1104
  }
1105
+ elif isinstance(obj, TextLine):
1106
+
1107
+ def _font_to_dict(font: Optional[Font]) -> Optional[dict]:
1108
+ if font:
1109
+ return {"name": font.name, "size": font.size}
1110
+ return None
1111
+
1112
+ def _color_to_dict(color: Optional[Color]) -> Optional[dict]:
1113
+ if color:
1114
+ return {
1115
+ "red": color.r,
1116
+ "green": color.g,
1117
+ "blue": color.b,
1118
+ "alpha": color.a,
1119
+ }
1120
+ return None
1121
+
1122
+ # Build textElement with only non-null fields
1123
+ text_element = {
1124
+ "text": obj.text,
1125
+ }
1126
+
1127
+ if obj.font:
1128
+ text_element["font"] = _font_to_dict(obj.font)
1129
+ if obj.color:
1130
+ text_element["color"] = _color_to_dict(obj.color)
1131
+ if obj.position:
1132
+ text_element["position"] = FindRequest._position_to_dict(obj.position)
1133
+
1134
+ # TEXT_LINE structure matches paragraph line format (textElements only)
1135
+ result = {
1136
+ "type": "TEXT_LINE",
1137
+ "position": (
1138
+ FindRequest._position_to_dict(obj.position)
1139
+ if obj.position
1140
+ else None
1141
+ ),
1142
+ "textElements": [text_element],
1143
+ }
1144
+
1145
+ # Only include top-level font/color if they are not None
1146
+ if obj.font:
1147
+ result["font"] = _font_to_dict(obj.font)
1148
+ if obj.color:
1149
+ result["color"] = _color_to_dict(obj.color)
1150
+
1151
+ return result
1105
1152
  else:
1106
1153
  raise ValueError(f"Unsupported object type: {type(obj)}")
1107
1154
 
pdfdancer/pdfdancer_v1.py CHANGED
@@ -58,6 +58,7 @@ from .models import (
58
58
  Position,
59
59
  PositionMode,
60
60
  ShapeType,
61
+ TextLine,
61
62
  TextObjectRef,
62
63
  )
63
64
  from .page_builder import PageBuilder
@@ -2203,6 +2204,33 @@ class PDFDancer:
2203
2204
  self._invalidate_snapshots()
2204
2205
  return result
2205
2206
 
2207
+ def _modify_text_line_full(
2208
+ self, object_ref: ObjectRef, new_text_line: TextLine
2209
+ ) -> CommandResult:
2210
+ """
2211
+ Modifies a text line object with full styling (font, color, position).
2212
+
2213
+ Args:
2214
+ object_ref: Reference to the text line to modify
2215
+ new_text_line: New text line object with styling
2216
+
2217
+ Returns:
2218
+ CommandResult indicating success or failure
2219
+ """
2220
+ if object_ref is None:
2221
+ raise ValidationException("Object reference cannot be null")
2222
+ if new_text_line is None:
2223
+ raise ValidationException("New text line cannot be null")
2224
+
2225
+ # Use /pdf/modify endpoint for full object modification
2226
+ request_data = ModifyRequest(object_ref, new_text_line).to_dict()
2227
+ response = self._make_request("PUT", "/pdf/modify", data=request_data)
2228
+ result = CommandResult.from_dict(response.json())
2229
+
2230
+ # Invalidate snapshot caches after mutation
2231
+ self._invalidate_snapshots()
2232
+ return result
2233
+
2206
2234
  # Font Operations
2207
2235
 
2208
2236
  def find_fonts(self, font_name: str, font_size: int) -> List[Font]:
@@ -0,0 +1,290 @@
1
+ """
2
+ TextLineBuilder for the PDFDancer Python client.
3
+ Mirrors the behaviour of ParagraphBuilder for single line text objects.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from copy import deepcopy
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING, Optional, Union
11
+
12
+ from . import StandardFonts
13
+ from .exceptions import ValidationException
14
+ from .models import (
15
+ Color,
16
+ Font,
17
+ ObjectRef,
18
+ Position,
19
+ TextLine,
20
+ TextObjectRef,
21
+ )
22
+
23
+ if TYPE_CHECKING:
24
+ from .pdfdancer_v1 import PDFDancer
25
+
26
+ DEFAULT_TEXT_COLOR = Color(0, 0, 0)
27
+ _DEFAULT_BASE_FONT_SIZE = 12.0
28
+
29
+
30
+ class TextLineBuilder:
31
+ """
32
+ Fluent builder used to assemble `TextLine` instances.
33
+ Behaviour is aligned with ParagraphBuilder but simplified for single-line text.
34
+ """
35
+
36
+ def __init__(self, client: "PDFDancer"):
37
+ if client is None:
38
+ raise ValidationException("Client cannot be null")
39
+
40
+ self._client = client
41
+ self._text_line = TextLine()
42
+ self._text_color: Optional[Color] = None
43
+ self._text: Optional[str] = None
44
+ self._ttf_file: Optional[Path] = None
45
+ self._font: Optional[Font] = None
46
+ self._font_explicitly_changed = False
47
+ self._original_text_line_position: Optional[Position] = None
48
+ self._target_object_ref: Optional[ObjectRef] = None
49
+ self._original_font: Optional[Font] = None
50
+ self._original_color: Optional[Color] = None
51
+ self._position_changed = False
52
+
53
+ def only_text_changed(self) -> bool:
54
+ """Return True when only the text payload has been modified."""
55
+ return (
56
+ self._text is not None
57
+ and self._text_color is None
58
+ and self._ttf_file is None
59
+ and (self._font is None or not self._font_explicitly_changed)
60
+ )
61
+
62
+ def text(self, text: str, color: Optional[Color] = None) -> "TextLineBuilder":
63
+ if text is None:
64
+ raise ValidationException("Text cannot be null")
65
+ self._text = text
66
+ if color is not None:
67
+ self.color(color)
68
+ return self
69
+
70
+ def font(
71
+ self, font: Union[Font, str, StandardFonts], font_size: Optional[float] = None
72
+ ) -> "TextLineBuilder":
73
+ """
74
+ Configure the font either by providing a `Font` instance or name + size.
75
+ """
76
+ if isinstance(font, Font):
77
+ resolved_font = font
78
+ else:
79
+ if isinstance(font, StandardFonts):
80
+ font = font.value
81
+ if font is None:
82
+ raise ValidationException("Font name cannot be null")
83
+ if font_size is None:
84
+ raise ValidationException(
85
+ "Font size must be provided when setting font by name"
86
+ )
87
+ resolved_font = Font(str(font), font_size)
88
+
89
+ self._font = resolved_font
90
+ self._ttf_file = None
91
+ self._font_explicitly_changed = True
92
+ return self
93
+
94
+ def font_file(
95
+ self, ttf_file: Union[Path, str], font_size: float
96
+ ) -> "TextLineBuilder":
97
+ if ttf_file is None:
98
+ raise ValidationException("TTF file cannot be null")
99
+ if font_size <= 0:
100
+ raise ValidationException(f"Font size must be positive, got {font_size}")
101
+
102
+ ttf_path = Path(ttf_file)
103
+
104
+ if not ttf_path.exists():
105
+ raise ValidationException(f"TTF file does not exist: {ttf_path}")
106
+ if not ttf_path.is_file():
107
+ raise ValidationException(f"TTF file is not a file: {ttf_path}")
108
+ if ttf_path.stat().st_size <= 0:
109
+ raise ValidationException(f"TTF file is empty: {ttf_path}")
110
+
111
+ try:
112
+ with open(ttf_path, "rb") as handle:
113
+ handle.read(1)
114
+ except (IOError, OSError) as exc:
115
+ raise ValidationException(f"TTF file is not readable: {ttf_path}") from exc
116
+
117
+ self._ttf_file = ttf_path
118
+ self._font = self._register_ttf(ttf_path, font_size)
119
+ self._font_explicitly_changed = True
120
+ return self
121
+
122
+ def set_font_explicitly_changed(self, changed: bool) -> None:
123
+ self._font_explicitly_changed = bool(changed)
124
+
125
+ def set_original_text_line_position(self, position: Position) -> None:
126
+ self._original_text_line_position = position
127
+ if position and self._text_line.position is None:
128
+ self._text_line.position = deepcopy(position)
129
+
130
+ def target(self, object_ref: ObjectRef) -> "TextLineBuilder":
131
+ if object_ref is None:
132
+ raise ValidationException("Object reference cannot be null")
133
+ self._target_object_ref = object_ref
134
+ return self
135
+
136
+ def color(self, color: Color) -> "TextLineBuilder":
137
+ if color is None:
138
+ raise ValidationException("Color cannot be null")
139
+ self._text_color = color
140
+ return self
141
+
142
+ def move_to(self, x: float, y: float) -> "TextLineBuilder":
143
+ """
144
+ Move the text line to new coordinates on the same page.
145
+ """
146
+ position = self._text_line.position
147
+ if (
148
+ position is None
149
+ and self._target_object_ref
150
+ and self._target_object_ref.position
151
+ ):
152
+ position = deepcopy(self._target_object_ref.position)
153
+ self._text_line.position = position
154
+
155
+ if position is None:
156
+ raise ValidationException(
157
+ "Cannot move text line without an existing position"
158
+ )
159
+
160
+ page_index = position.page_index
161
+ if page_index is None:
162
+ raise ValidationException(
163
+ "Text line position must include a page index to move"
164
+ )
165
+
166
+ self._position_changed = True
167
+ return self.at(page_index, x, y)
168
+
169
+ def at_position(self, position: Position) -> "TextLineBuilder":
170
+ if position is None:
171
+ raise ValidationException("Position cannot be null")
172
+ # Defensive copy so builder mutations do not alter original references
173
+ self._text_line.position = deepcopy(position)
174
+ self._position_changed = True
175
+ return self
176
+
177
+ def at(self, page_index: int, x: float, y: float) -> "TextLineBuilder":
178
+ return self.at_position(Position.at_page_coordinates(page_index, x, y))
179
+
180
+ def get_text(self) -> Optional[str]:
181
+ return self._text
182
+
183
+ def add(self) -> bool:
184
+ """
185
+ Add a new text line to the document.
186
+ Note: Text lines are typically part of paragraphs. This method is not
187
+ currently supported for standalone text lines.
188
+ """
189
+ raise NotImplementedError(
190
+ "Adding standalone text lines is not supported. "
191
+ "Text lines should be added as part of paragraphs."
192
+ )
193
+
194
+ def modify(self, object_ref: Optional[ObjectRef] = None):
195
+ target_ref = object_ref or self._target_object_ref
196
+ if target_ref is None:
197
+ raise ValidationException(
198
+ "Object reference must be provided to modify a text line"
199
+ )
200
+
201
+ if self.only_text_changed():
202
+ # Backend accepts plain text updates for simple edits
203
+ return self._client._modify_text_line(target_ref, self._text or "")
204
+
205
+ text_line = self._finalize_text_line()
206
+ # Use /pdf/modify endpoint for complex modifications
207
+ return self._client._modify_text_line_full(target_ref, text_line)
208
+
209
+ # ------------------------------------------------------------------ #
210
+ # Internal helpers
211
+ # ------------------------------------------------------------------ #
212
+
213
+ def _finalize_text_line(self) -> TextLine:
214
+ if self._text_line.position is None:
215
+ raise ValidationException("Position must be set before building text line")
216
+
217
+ if (
218
+ self._target_object_ref is None
219
+ and self._font is None
220
+ and self._text_line.font is None
221
+ ):
222
+ raise ValidationException("Font must be set before building text line")
223
+
224
+ if self._text is not None:
225
+ self._text_line.text = self._text
226
+ elif not self._text_line.text:
227
+ raise ValidationException("Text must be provided for text line")
228
+
229
+ final_font = self._font if self._font is not None else self._original_font
230
+ if final_font is None:
231
+ final_font = Font(StandardFonts.HELVETICA.value, _DEFAULT_BASE_FONT_SIZE)
232
+ self._text_line.font = final_font
233
+
234
+ if self._text_color is not None:
235
+ final_color = self._text_color
236
+ elif self._text is not None:
237
+ final_color = self._original_color or DEFAULT_TEXT_COLOR
238
+ else:
239
+ final_color = self._original_color
240
+
241
+ # Ensure color is never None
242
+ if final_color is None:
243
+ final_color = DEFAULT_TEXT_COLOR
244
+ self._text_line.color = final_color
245
+
246
+ return self._text_line
247
+
248
+ def _register_ttf(self, ttf_file: Path, font_size: float) -> Font:
249
+ try:
250
+ font_name = self._client.register_font(ttf_file)
251
+ return Font(font_name, font_size)
252
+ except Exception as exc:
253
+ raise ValidationException(
254
+ f"Failed to register font file {ttf_file}: {exc}"
255
+ ) from exc
256
+
257
+ @classmethod
258
+ def from_object_ref(
259
+ cls, client: "PDFDancer", object_ref: TextObjectRef
260
+ ) -> "TextLineBuilder":
261
+ if object_ref is None:
262
+ raise ValidationException("Object reference cannot be null")
263
+
264
+ builder = cls(client)
265
+ builder.target(object_ref)
266
+
267
+ if object_ref.position:
268
+ builder.at_position(object_ref.position)
269
+ builder.set_original_text_line_position(object_ref.position)
270
+
271
+ if object_ref.font_name and object_ref.font_size:
272
+ builder._original_font = Font(object_ref.font_name, object_ref.font_size)
273
+
274
+ if object_ref.color:
275
+ builder._original_color = object_ref.color
276
+
277
+ if object_ref.text:
278
+ builder._text_line.text = object_ref.text
279
+
280
+ return builder
281
+
282
+
283
+ class TextLinePageBuilder(TextLineBuilder):
284
+ def __init__(self, client: "PDFDancer", page_index: int):
285
+ super().__init__(client)
286
+ self._page_index: Optional[int] = page_index
287
+
288
+ # noinspection PyMethodOverriding
289
+ def at(self, x: float, y: float) -> "TextLineBuilder":
290
+ return super().at(self._page_index, x, y)
pdfdancer/types.py CHANGED
@@ -160,12 +160,23 @@ class BaseTextEdit:
160
160
 
161
161
  class TextLineEdit(BaseTextEdit):
162
162
  def apply(self) -> bool:
163
- if (
164
- self._line_spacing is None
165
- and self._font_size is None
163
+ # Text lines don't support line spacing - ignore if set
164
+ if self._line_spacing is not None:
165
+ print(
166
+ "WARNING: Line spacing is not supported for text lines and will be ignored",
167
+ file=sys.stderr,
168
+ )
169
+
170
+ # Simple text-only change
171
+ only_text_changed = (
172
+ self._new_text is not None
166
173
  and self._font_name is None
174
+ and self._font_size is None
167
175
  and self._color is None
168
- ):
176
+ and self._position is None
177
+ )
178
+
179
+ if only_text_changed:
169
180
  # noinspection PyProtectedMember
170
181
  result = self._target_obj._client._modify_text_line(
171
182
  self._object_ref, self._new_text
@@ -173,10 +184,46 @@ class TextLineEdit(BaseTextEdit):
173
184
  if result.warning:
174
185
  print(f"WARNING: {result.warning}", file=sys.stderr)
175
186
  return result
176
- else:
187
+
188
+ # Position-only change (move operation)
189
+ only_move = (
190
+ self._position is not None
191
+ and self._new_text is None
192
+ and self._font_name is None
193
+ and self._font_size is None
194
+ and self._color is None
195
+ )
196
+
197
+ if only_move:
198
+ page_index = (
199
+ self._object_ref.position.page_index
200
+ if self._object_ref.position
201
+ else None
202
+ )
203
+ if page_index is None:
204
+ raise ValidationException(
205
+ "Text line position must include a page index to move"
206
+ )
207
+
208
+ # Extract x, y from self._position
209
+ x = self._position.x()
210
+ y = self._position.y()
211
+ if x is None or y is None:
212
+ raise ValidationException("Position must have x and y coordinates")
213
+
214
+ position = Position.at_page_coordinates(page_index, x, y)
177
215
  # noinspection PyProtectedMember
178
- # return self._target_obj._client._modify_text_line(self._object_ref, new_textline)
179
- raise UnsupportedOperation("Full TextLineEdit not implemented - TODO")
216
+ result = self._target_obj._client._move(self._object_ref, position)
217
+ return result
218
+
219
+ # Text lines with font/color styling changes are not supported by the PDF API
220
+ # Text lines are components of paragraphs and can only have their text modified
221
+ # or be moved, but not have their styling changed independently
222
+ raise UnsupportedOperation(
223
+ "Text lines can only have their text content modified or be moved. "
224
+ "Font and color changes are not supported for individual text lines. "
225
+ "To modify text styling, please modify the parent paragraph instead."
226
+ )
180
227
 
181
228
 
182
229
  class ParagraphObject(PDFObjectBase):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pdfdancer-client-python
3
- Version: 0.2.25
3
+ Version: 0.2.27
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,17 @@
1
+ pdfdancer/__init__.py,sha256=DkDPiTq-u5FWGpD5FHFXpAvZ7em79_PWauQgtE5VLIw,2427
2
+ pdfdancer/exceptions.py,sha256=mJacmUJTPGUsB8Bo_FgfeXYkvZkH5OPJCVBfilBVmQo,2058
3
+ pdfdancer/fingerprint.py,sha256=eL3PHPgv-knMya7s95RXg3qzzpkAA1aevxqb6tuOb34,3061
4
+ pdfdancer/image_builder.py,sha256=MdSvZYU7-tq5HcuIpj2cfsd1iJKL9nryp87pCGlMnpM,1888
5
+ pdfdancer/models.py,sha256=Ml1eS6u8A16W0xQAdkX7h7nLFwNRMHg5HzwBoIOKz8U,50663
6
+ pdfdancer/page_builder.py,sha256=ecEK0lXk-7CoWEDOftZ-GwgRfNu5h7AD_J__LNkxJwI,3092
7
+ pdfdancer/paragraph_builder.py,sha256=yXdn2hoxpJYcUVAmSEOgoq-ApGIe9k9GWkokIIQWIJA,20489
8
+ pdfdancer/path_builder.py,sha256=2w0LPJo1u8bSjGAbYevTtOF4VD8M9B4SLe_wSLIYJx8,23917
9
+ pdfdancer/pdfdancer_v1.py,sha256=gaI9oDq8lksEi4DJAiZNebnj5XVFB8lYtGFw-ziTZTw,119130
10
+ pdfdancer/text_line_builder.py,sha256=74zc9wDPtSngHVGz_ykB3Bci_rEcwLr0dXGSEbLH5eo,10262
11
+ pdfdancer/types.py,sha256=ghIGh1zVkLDvSYQuHpvBP6WPB5YGclXdLbZ9d2FLf_I,15010
12
+ pdfdancer_client_python-0.2.27.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
13
+ pdfdancer_client_python-0.2.27.dist-info/licenses/NOTICE,sha256=xaC4l-IChAmtViNDie8ZWzUk0O6XRMyzOl0zLmVZ2HE,232
14
+ pdfdancer_client_python-0.2.27.dist-info/METADATA,sha256=Ay5WpYTSKzwGjSJFGXuGC4K9MAYMxazRjC_4n29zJ1Y,24729
15
+ pdfdancer_client_python-0.2.27.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
16
+ pdfdancer_client_python-0.2.27.dist-info/top_level.txt,sha256=ICwSVRpcCKrdBF9QlaX9Y0e_N3Nk1p7QVxadGOnbxeY,10
17
+ pdfdancer_client_python-0.2.27.dist-info/RECORD,,
@@ -1,16 +0,0 @@
1
- pdfdancer/__init__.py,sha256=8E5dsMRws_zZZpM0i8k3zc34wHauCFZOuVa5BkumuBE,2357
2
- pdfdancer/exceptions.py,sha256=mJacmUJTPGUsB8Bo_FgfeXYkvZkH5OPJCVBfilBVmQo,2058
3
- pdfdancer/fingerprint.py,sha256=eL3PHPgv-knMya7s95RXg3qzzpkAA1aevxqb6tuOb34,3061
4
- pdfdancer/image_builder.py,sha256=MdSvZYU7-tq5HcuIpj2cfsd1iJKL9nryp87pCGlMnpM,1888
5
- pdfdancer/models.py,sha256=feEZc7kr5aN2QgIHpayCWksPLhxJVnukkFfe_Ri1E_w,49003
6
- pdfdancer/page_builder.py,sha256=ecEK0lXk-7CoWEDOftZ-GwgRfNu5h7AD_J__LNkxJwI,3092
7
- pdfdancer/paragraph_builder.py,sha256=yXdn2hoxpJYcUVAmSEOgoq-ApGIe9k9GWkokIIQWIJA,20489
8
- pdfdancer/path_builder.py,sha256=2w0LPJo1u8bSjGAbYevTtOF4VD8M9B4SLe_wSLIYJx8,23917
9
- pdfdancer/pdfdancer_v1.py,sha256=pjNwBHF_tPCXv7fnjxcm-6m5g_u91773rtvz9j_W9Yg,118088
10
- pdfdancer/types.py,sha256=9F5LqgNVz48s0Q6lGgOcIfIbvE3vkPI_Vr4fOQqFk2k,13225
11
- pdfdancer_client_python-0.2.25.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
12
- pdfdancer_client_python-0.2.25.dist-info/licenses/NOTICE,sha256=xaC4l-IChAmtViNDie8ZWzUk0O6XRMyzOl0zLmVZ2HE,232
13
- pdfdancer_client_python-0.2.25.dist-info/METADATA,sha256=8N-JlJX5sli-sPJ4OhMnW7faak3mu_y0U4sy5Ynjp7I,24729
14
- pdfdancer_client_python-0.2.25.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
15
- pdfdancer_client_python-0.2.25.dist-info/top_level.txt,sha256=ICwSVRpcCKrdBF9QlaX9Y0e_N3Nk1p7QVxadGOnbxeY,10
16
- pdfdancer_client_python-0.2.25.dist-info/RECORD,,