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 +99 -23
- pdfdancer/paragraph_builder.py +392 -177
- pdfdancer/pdfdancer_v1.py +13 -8
- pdfdancer/types.py +112 -103
- {pdfdancer_client_python-0.2.20.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.20.dist-info/RECORD +0 -16
- {pdfdancer_client_python-0.2.20.dist-info → pdfdancer_client_python-0.2.21.dist-info}/WHEEL +0 -0
- {pdfdancer_client_python-0.2.20.dist-info → pdfdancer_client_python-0.2.21.dist-info}/licenses/LICENSE +0 -0
- {pdfdancer_client_python-0.2.20.dist-info → pdfdancer_client_python-0.2.21.dist-info}/licenses/NOTICE +0 -0
- {pdfdancer_client_python-0.2.20.dist-info → pdfdancer_client_python-0.2.21.dist-info}/top_level.txt +0 -0
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[
|
|
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
|
|
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
|
-
|
|
939
|
-
|
|
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":
|
|
944
|
-
"font":
|
|
945
|
-
"color":
|
|
946
|
-
|
|
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
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
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
|
|
961
|
-
|
|
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":
|
|
1042
|
+
"lines": lines_payload if lines_payload else None,
|
|
967
1043
|
"lineSpacings": line_spacings,
|
|
968
|
-
"font":
|
|
1044
|
+
"font": _font_to_dict(obj.font)
|
|
969
1045
|
}
|
|
970
1046
|
else:
|
|
971
1047
|
raise ValueError(f"Unsupported object type: {type(obj)}")
|
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):
|
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,
|
|
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
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
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
|
|
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.
|
|
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
|
|
5
|
+
from typing import Optional
|
|
7
6
|
|
|
8
|
-
from . import ObjectType, Position, ObjectRef, Point,
|
|
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)
|
|
277
|
-
return
|
|
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
|
|
@@ -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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pdfdancer_client_python-0.2.20.dist-info → pdfdancer_client_python-0.2.21.dist-info}/top_level.txt
RENAMED
|
File without changes
|