pdfdancer-client-python 0.2.19__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/__init__.py +2 -0
- pdfdancer/image_builder.py +30 -0
- pdfdancer/models.py +545 -58
- pdfdancer/page_builder.py +92 -0
- pdfdancer/paragraph_builder.py +392 -177
- pdfdancer/path_builder.py +252 -0
- pdfdancer/pdfdancer_v1.py +61 -22
- pdfdancer/types.py +112 -103
- {pdfdancer_client_python-0.2.19.dist-info → pdfdancer_client_python-0.2.21.dist-info}/METADATA +1 -1
- pdfdancer_client_python-0.2.21.dist-info/RECORD +16 -0
- pdfdancer_client_python-0.2.19.dist-info/RECORD +0 -15
- {pdfdancer_client_python-0.2.19.dist-info → pdfdancer_client_python-0.2.21.dist-info}/WHEEL +0 -0
- {pdfdancer_client_python-0.2.19.dist-info → pdfdancer_client_python-0.2.21.dist-info}/licenses/LICENSE +0 -0
- {pdfdancer_client_python-0.2.19.dist-info → pdfdancer_client_python-0.2.21.dist-info}/licenses/NOTICE +0 -0
- {pdfdancer_client_python-0.2.19.dist-info → pdfdancer_client_python-0.2.21.dist-info}/top_level.txt +0 -0
pdfdancer/paragraph_builder.py
CHANGED
|
@@ -1,108 +1,100 @@
|
|
|
1
1
|
"""
|
|
2
2
|
ParagraphBuilder for the PDFDancer Python client.
|
|
3
|
-
|
|
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
|
|
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
|
-
|
|
17
|
-
|
|
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.
|
|
33
|
-
self._text_color
|
|
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.
|
|
61
|
-
|
|
70
|
+
self.color(color)
|
|
62
71
|
return self
|
|
63
72
|
|
|
64
|
-
def font(
|
|
73
|
+
def font(
|
|
74
|
+
self,
|
|
75
|
+
font: Union[Font, str, StandardFonts],
|
|
76
|
+
font_size: Optional[float] = None
|
|
77
|
+
) -> 'ParagraphBuilder':
|
|
65
78
|
"""
|
|
66
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
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
|
|
124
|
-
|
|
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
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
178
|
-
|
|
158
|
+
if position is None:
|
|
159
|
+
raise ValidationException("Cannot move paragraph without an existing position")
|
|
179
160
|
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
199
|
-
|
|
186
|
+
def add(self) -> bool:
|
|
187
|
+
# noinspection PyProtectedMember
|
|
188
|
+
return self._client._add_paragraph(self._finalize_paragraph())
|
|
200
189
|
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
216
|
-
|
|
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
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
|
242
|
-
raise ValidationException(f"Failed to register font file {ttf_file}: {
|
|
453
|
+
except Exception as exc:
|
|
454
|
+
raise ValidationException(f"Failed to register font file {ttf_file}: {exc}") from exc
|
|
243
455
|
|
|
244
|
-
def
|
|
456
|
+
def _build(self) -> Paragraph:
|
|
457
|
+
"""
|
|
458
|
+
Backwards-compatible alias for callers that invoked the previous `_build`.
|
|
245
459
|
"""
|
|
246
|
-
|
|
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
|
-
|
|
252
|
-
|
|
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
|
-
|
|
255
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
470
|
+
if object_ref.position:
|
|
471
|
+
builder.at_position(object_ref.position)
|
|
472
|
+
builder.set_original_paragraph_position(object_ref.position)
|
|
263
473
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
269
|
-
|
|
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
|
-
|
|
481
|
+
if object_ref.color:
|
|
482
|
+
builder._original_color = object_ref.color
|
|
273
483
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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):
|