pdfdancer-client-python 0.3.9__tar.gz → 0.3.10__tar.gz

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.
Files changed (83) hide show
  1. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/.gitignore +1 -0
  2. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/PKG-INFO +2 -1
  3. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/README.md +1 -0
  4. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/pyproject.toml +1 -1
  5. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/src/pdfdancer/models.py +20 -2
  6. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/src/pdfdancer/pdfdancer_v1.py +32 -0
  7. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/src/pdfdancer_client_python.egg-info/PKG-INFO +2 -1
  8. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/e2e/__init__.py +2 -0
  9. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/e2e/test_line.py +1 -1
  10. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/e2e/test_template_replace.py +52 -0
  11. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/test_models.py +95 -0
  12. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/.claude/commands/discuss.md +0 -0
  13. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/.claude/commands/implement-new-api-features.md +0 -0
  14. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/.flake8 +0 -0
  15. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/.github/workflows/ci.yml +0 -0
  16. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/.github/workflows/daily-tests.yml +0 -0
  17. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/CLAUDE.md +0 -0
  18. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/LICENSE +0 -0
  19. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/NOTICE +0 -0
  20. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/TODO.md +0 -0
  21. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/check.py +0 -0
  22. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/docs/api-schemas/v0.yml +0 -0
  23. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/docs/api-schemas/v1.yml +0 -0
  24. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/media/logo-orange-512h.webp +0 -0
  25. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/media/logo-orange-60h.webp +0 -0
  26. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/media/logo-silver-512h.webp +0 -0
  27. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/media/logo-silver-60h.webp +0 -0
  28. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/release.py +0 -0
  29. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/setup.cfg +0 -0
  30. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/src/pdfdancer/__init__.py +0 -0
  31. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/src/pdfdancer/exceptions.py +0 -0
  32. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/src/pdfdancer/fingerprint.py +0 -0
  33. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/src/pdfdancer/image_builder.py +0 -0
  34. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/src/pdfdancer/page_builder.py +0 -0
  35. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/src/pdfdancer/paragraph_builder.py +0 -0
  36. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/src/pdfdancer/path_builder.py +0 -0
  37. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/src/pdfdancer/text_line_builder.py +0 -0
  38. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/src/pdfdancer/types.py +0 -0
  39. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/src/pdfdancer_client_python.egg-info/SOURCES.txt +0 -0
  40. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/src/pdfdancer_client_python.egg-info/dependency_links.txt +0 -0
  41. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/src/pdfdancer_client_python.egg-info/requires.txt +0 -0
  42. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/src/pdfdancer_client_python.egg-info/top_level.txt +0 -0
  43. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/test.sh +0 -0
  44. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/__init__.py +0 -0
  45. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/conftest.py +0 -0
  46. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/e2e/pdf_assertions.py +0 -0
  47. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/e2e/test_acroform.py +0 -0
  48. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/e2e/test_bezier_builder.py +0 -0
  49. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/e2e/test_context_manager.py +0 -0
  50. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/e2e/test_form_x_objects.py +0 -0
  51. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/e2e/test_image.py +0 -0
  52. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/e2e/test_image_transform.py +0 -0
  53. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/e2e/test_line_builder.py +0 -0
  54. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/e2e/test_new_pdf.py +0 -0
  55. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/e2e/test_page.py +0 -0
  56. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/e2e/test_paragraph.py +0 -0
  57. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/e2e/test_path.py +0 -0
  58. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/e2e/test_path_builder.py +0 -0
  59. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/e2e/test_path_builder_rectangle.py +0 -0
  60. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/e2e/test_path_comprehensive.py +0 -0
  61. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/e2e/test_pdfdancer.py +0 -0
  62. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/e2e/test_positioning.py +0 -0
  63. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/e2e/test_rectangle_builder.py +0 -0
  64. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/e2e/test_redact.py +0 -0
  65. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/e2e/test_singular_selection.py +0 -0
  66. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/e2e/test_snapshot.py +0 -0
  67. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/e2e/test_text_line_edit.py +0 -0
  68. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/fixtures/DancingScript-Regular.ttf +0 -0
  69. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/fixtures/Empty.pdf +0 -0
  70. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/fixtures/JetBrainsMono-Regular.ttf +0 -0
  71. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/fixtures/Showcase.pdf +0 -0
  72. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/fixtures/basic-paths.pdf +0 -0
  73. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/fixtures/form-xobject-example.pdf +0 -0
  74. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/fixtures/logo-80.png +0 -0
  75. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/fixtures/mixed-form-types.pdf +0 -0
  76. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/test_anonymous_token.py +0 -0
  77. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/test_fingerprint.py +0 -0
  78. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/test_openapi_compliance.py +0 -0
  79. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/test_path_models.py +0 -0
  80. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/test_pdf_object_equality.py +0 -0
  81. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/test_rate_limit.py +0 -0
  82. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/tests/test_standard_fonts.py +0 -0
  83. {pdfdancer_client_python-0.3.9 → pdfdancer_client_python-0.3.10}/update-api-spec.sh +0 -0
@@ -18,3 +18,4 @@ __pycache__/
18
18
  .Python
19
19
  /.run/
20
20
  /build
21
+ .env
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pdfdancer-client-python
3
- Version: 0.3.9
3
+ Version: 0.3.10
4
4
  Summary: Python client for PDFDancer API
5
5
  Author-email: "The Famous Cat Ltd." <hi@thefamouscat.com>
6
6
  License:
@@ -672,6 +672,7 @@ Contributions are welcome via pull request. Please:
672
672
  - [PyPI](https://pypi.org/project/pdfdancer-client-python/)
673
673
  - [Changelog](https://www.pdfdancer.com/changelog/?utm_source=github&utm_medium=readme&utm_campaign=pdfdancer-python)
674
674
  - [Status](https://status.pdfdancer.com?utm_source=github&utm_medium=readme&utm_campaign=pdfdancer-python)
675
+ - [Issue tracker](https://github.com/MenschMachine/pdfdancer)
675
676
 
676
677
  ## Related SDKs
677
678
 
@@ -433,6 +433,7 @@ Contributions are welcome via pull request. Please:
433
433
  - [PyPI](https://pypi.org/project/pdfdancer-client-python/)
434
434
  - [Changelog](https://www.pdfdancer.com/changelog/?utm_source=github&utm_medium=readme&utm_campaign=pdfdancer-python)
435
435
  - [Status](https://status.pdfdancer.com?utm_source=github&utm_medium=readme&utm_campaign=pdfdancer-python)
436
+ - [Issue tracker](https://github.com/MenschMachine/pdfdancer)
436
437
 
437
438
  ## Related SDKs
438
439
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pdfdancer-client-python"
7
- version = "0.3.9"
7
+ version = "0.3.10"
8
8
  description = "Python client for PDFDancer API"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -1686,18 +1686,22 @@ class TemplateReplacement:
1686
1686
 
1687
1687
  Parameters:
1688
1688
  - placeholder: The exact text to find and replace in the PDF.
1689
- - text: The text to replace the placeholder with.
1689
+ - text: The text to replace the placeholder with. None for image replacements.
1690
1690
  - font: Optional font for the replacement text.
1691
1691
  - color: Optional color for the replacement text.
1692
+ - image: Optional Image to replace the placeholder with. When set, text should be None.
1692
1693
  """
1693
1694
 
1694
1695
  placeholder: str
1695
- text: str
1696
+ text: Optional[str] = None
1696
1697
  font: Optional[Font] = None
1697
1698
  color: Optional[Color] = None
1699
+ image: Optional[Image] = None
1698
1700
 
1699
1701
  def to_dict(self) -> dict:
1700
1702
  """Convert to dictionary for JSON serialization."""
1703
+ import base64
1704
+
1701
1705
  result: Dict[str, Any] = {
1702
1706
  "placeholder": self.placeholder,
1703
1707
  "text": self.text,
@@ -1711,6 +1715,20 @@ class TemplateReplacement:
1711
1715
  "blue": self.color.b,
1712
1716
  "alpha": self.color.a,
1713
1717
  }
1718
+ if self.image:
1719
+ image_dict: Dict[str, Any] = {}
1720
+ if self.image.data:
1721
+ image_dict["data"] = base64.b64encode(self.image.data).decode("utf-8")
1722
+ if self.image.format:
1723
+ image_dict["format"] = self.image.format
1724
+ if self.image.width is not None or self.image.height is not None:
1725
+ size: Dict[str, float] = {}
1726
+ if self.image.width is not None:
1727
+ size["width"] = self.image.width
1728
+ if self.image.height is not None:
1729
+ size["height"] = self.image.height
1730
+ image_dict["size"] = size
1731
+ result["image"] = image_dict
1714
1732
  return result
1715
1733
 
1716
1734
 
@@ -131,6 +131,34 @@ def _dict_to_replacements(
131
131
  for placeholder, value in replacements.items():
132
132
  if isinstance(value, str):
133
133
  result.append(TemplateReplacement(placeholder=placeholder, text=value))
134
+ elif "image" in value:
135
+ image_source = value["image"]
136
+ if isinstance(image_source, Path):
137
+ image_data = image_source.read_bytes()
138
+ image_format = image_source.suffix.lstrip(".").upper()
139
+ if image_format == "JPG":
140
+ image_format = "JPEG"
141
+ elif isinstance(image_source, bytes):
142
+ image_data = image_source
143
+ image_format = None
144
+ else:
145
+ raise ValueError(
146
+ f"Unsupported image source type: {type(image_source)}. "
147
+ "Use a Path or bytes."
148
+ )
149
+ img = Image(
150
+ data=image_data,
151
+ format=value.get("format", image_format),
152
+ width=value.get("width"),
153
+ height=value.get("height"),
154
+ )
155
+ result.append(
156
+ TemplateReplacement(
157
+ placeholder=placeholder,
158
+ text=None,
159
+ image=img,
160
+ )
161
+ )
134
162
  else:
135
163
  result.append(
136
164
  TemplateReplacement(
@@ -644,6 +672,8 @@ class PageClient:
644
672
  replacements: Dict mapping placeholder strings to replacement values.
645
673
  - Simple: {"{{NAME}}": "John Doe"}
646
674
  - With options: {"{{NAME}}": {"text": "John", "font": Font(...), "color": Color(...)}}
675
+ - With image: {"{{LOGO}}": {"image": Path("logo.png")}}
676
+ - With image and size: {"{{LOGO}}": {"image": Path("logo.png"), "width": 50, "height": 50}}
647
677
  reflow_preset: Optional ReflowPreset to control text reflow behavior.
648
678
  - BEST_EFFORT: Attempt to reflow, proceed even if imperfect
649
679
  - FIT_OR_FAIL: Reflow must succeed or operation fails
@@ -2281,6 +2311,8 @@ class PDFDancer:
2281
2311
  replacements: Dict mapping placeholder strings to replacement values.
2282
2312
  - Simple: {"{{NAME}}": "John Doe"}
2283
2313
  - With options: {"{{NAME}}": {"text": "John", "font": Font(...), "color": Color(...)}}
2314
+ - With image: {"{{LOGO}}": {"image": Path("logo.png")}}
2315
+ - With image and size: {"{{LOGO}}": {"image": Path("logo.png"), "width": 50, "height": 50}}
2284
2316
  reflow_preset: Optional ReflowPreset to control text reflow behavior.
2285
2317
  - BEST_EFFORT: Attempt to reflow, proceed even if imperfect
2286
2318
  - FIT_OR_FAIL: Reflow must succeed or operation fails
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pdfdancer-client-python
3
- Version: 0.3.9
3
+ Version: 0.3.10
4
4
  Summary: Python client for PDFDancer API
5
5
  Author-email: "The Famous Cat Ltd." <hi@thefamouscat.com>
6
6
  License:
@@ -672,6 +672,7 @@ Contributions are welcome via pull request. Please:
672
672
  - [PyPI](https://pypi.org/project/pdfdancer-client-python/)
673
673
  - [Changelog](https://www.pdfdancer.com/changelog/?utm_source=github&utm_medium=readme&utm_campaign=pdfdancer-python)
674
674
  - [Status](https://status.pdfdancer.com?utm_source=github&utm_medium=readme&utm_campaign=pdfdancer-python)
675
+ - [Issue tracker](https://github.com/MenschMachine/pdfdancer)
675
676
 
676
677
  ## Related SDKs
677
678
 
@@ -4,6 +4,7 @@ from typing import Tuple
4
4
 
5
5
  import httpx
6
6
  import pytest
7
+ from dotenv import load_dotenv
7
8
 
8
9
 
9
10
  def _get_base_url():
@@ -11,6 +12,7 @@ def _get_base_url():
11
12
 
12
13
 
13
14
  def _read_token() -> str | None:
15
+ load_dotenv()
14
16
  # Check PDFDANCER_API_TOKEN first (preferred), then PDFDANCER_TOKEN (legacy)
15
17
  token = os.getenv("PDFDANCER_API_TOKEN") or os.getenv("PDFDANCER_TOKEN")
16
18
  if token:
@@ -21,7 +21,7 @@ def test_find_lines_by_position():
21
21
 
22
22
  with PDFDancer.open(pdf_path, token=token, base_url=base_url, timeout=30.0) as pdf:
23
23
  lines = pdf.select_text_lines()
24
- assert len(lines) == 35
24
+ assert len(lines) == 36
25
25
 
26
26
  first = lines[0]
27
27
  assert first.position is not None
@@ -1,5 +1,7 @@
1
1
  """E2E tests for template replacement functionality."""
2
2
 
3
+ from pathlib import Path
4
+
3
5
  import pytest
4
6
 
5
7
  from pdfdancer import Color, Font, ReflowPreset, ValidationException
@@ -222,3 +224,53 @@ def test_reflow_preset_values():
222
224
  assert ReflowPreset.BEST_EFFORT.value == "BEST_EFFORT"
223
225
  assert ReflowPreset.FIT_OR_FAIL.value == "FIT_OR_FAIL"
224
226
  assert ReflowPreset.NONE.value == "NONE"
227
+
228
+
229
+ def test_replace_template_with_image():
230
+ """Test replacing a placeholder with an image file (natural size)."""
231
+ base_url, token, pdf_path = _require_env_and_fixture("Showcase.pdf")
232
+ logo_path = Path(__file__).resolve().parent.parent / "fixtures" / "logo-80.png"
233
+ assert logo_path.exists(), "logo-80.png fixture not found"
234
+
235
+ with PDFDancer.open(pdf_path, token=token, base_url=base_url, timeout=30.0) as pdf:
236
+ # Count images before replacement
237
+ images_before = pdf.select_images()
238
+ count_before = len(images_before)
239
+
240
+ # Replace "Showcase" placeholder with an image
241
+ result = pdf.apply_replacements({
242
+ "Showcase": {"image": logo_path},
243
+ })
244
+
245
+ assert result is True
246
+
247
+ # The placeholder text should be gone
248
+ (
249
+ PDFAssertions(pdf)
250
+ .assert_textline_does_not_exist("Showcase")
251
+ )
252
+
253
+ # There should be more images now
254
+ images_after = pdf.select_images()
255
+ assert len(images_after) > count_before
256
+
257
+
258
+ def test_replace_template_with_image_explicit_size():
259
+ """Test replacing a placeholder with an image file with explicit width/height."""
260
+ base_url, token, pdf_path = _require_env_and_fixture("Showcase.pdf")
261
+ logo_path = Path(__file__).resolve().parent.parent / "fixtures" / "logo-80.png"
262
+ assert logo_path.exists(), "logo-80.png fixture not found"
263
+
264
+ with PDFDancer.open(pdf_path, token=token, base_url=base_url, timeout=30.0) as pdf:
265
+ # Replace "Showcase" placeholder with a sized image
266
+ result = pdf.apply_replacements({
267
+ "Showcase": {"image": logo_path, "width": 50, "height": 50},
268
+ })
269
+
270
+ assert result is True
271
+
272
+ # The placeholder text should be gone
273
+ (
274
+ PDFAssertions(pdf)
275
+ .assert_textline_does_not_exist("Showcase")
276
+ )
@@ -16,9 +16,13 @@ from pdfdancer import (
16
16
  PositionMode,
17
17
  ShapeType,
18
18
  )
19
+ from pdfdancer.models import TemplateReplacement
19
20
 
20
21
  # Import Point class for tests
22
+ from pathlib import Path
23
+
21
24
  from pdfdancer.models import Point
25
+ from pdfdancer.pdfdancer_v1 import _dict_to_replacements
22
26
 
23
27
 
24
28
  class TestPosition:
@@ -274,3 +278,94 @@ class TestPoint:
274
278
 
275
279
  assert point.x == 123.45
276
280
  assert point.y == 678.90
281
+
282
+
283
+ class TestTemplateReplacement:
284
+ """Test TemplateReplacement serialization."""
285
+
286
+ def test_text_replacement_to_dict(self):
287
+ """Test basic text replacement serializes correctly."""
288
+ r = TemplateReplacement(placeholder="{{NAME}}", text="John")
289
+ d = r.to_dict()
290
+ assert d == {"placeholder": "{{NAME}}", "text": "John"}
291
+
292
+ def test_text_none_with_image_to_dict(self):
293
+ """Test image replacement has text=None in serialized output."""
294
+ img = Image(data=b"\x89PNG", format="PNG")
295
+ r = TemplateReplacement(placeholder="{{LOGO}}", text=None, image=img)
296
+ d = r.to_dict()
297
+ assert d["text"] is None
298
+ assert "image" in d
299
+ assert d["image"]["format"] == "PNG"
300
+
301
+ def test_image_replacement_base64_encoding(self):
302
+ """Test image data is base64-encoded in serialized output."""
303
+ import base64
304
+
305
+ raw = b"\x89PNG\r\n\x1a\nfakedata"
306
+ img = Image(data=raw, format="PNG")
307
+ r = TemplateReplacement(placeholder="{{LOGO}}", image=img)
308
+ d = r.to_dict()
309
+ assert d["image"]["data"] == base64.b64encode(raw).decode("utf-8")
310
+
311
+ def test_image_replacement_with_size(self):
312
+ """Test image replacement with explicit width/height includes size."""
313
+ img = Image(data=b"img", format="JPEG", width=50, height=30)
314
+ r = TemplateReplacement(placeholder="{{PIC}}", image=img)
315
+ d = r.to_dict()
316
+ assert d["image"]["size"] == {"width": 50, "height": 30}
317
+
318
+ def test_image_replacement_without_size(self):
319
+ """Test image replacement without width/height omits size."""
320
+ img = Image(data=b"img", format="PNG")
321
+ r = TemplateReplacement(placeholder="{{PIC}}", image=img)
322
+ d = r.to_dict()
323
+ assert "size" not in d["image"]
324
+
325
+ def test_image_replacement_with_partial_size(self):
326
+ """Test image replacement with only width includes partial size."""
327
+ img = Image(data=b"img", format="PNG", width=100)
328
+ r = TemplateReplacement(placeholder="{{PIC}}", image=img)
329
+ d = r.to_dict()
330
+ assert d["image"]["size"] == {"width": 100}
331
+
332
+
333
+ class TestDictToReplacements:
334
+ """Test _dict_to_replacements helper."""
335
+
336
+ def test_string_value(self):
337
+ """Test simple string values produce text replacements."""
338
+ result = _dict_to_replacements({"{{A}}": "hello"})
339
+ assert len(result) == 1
340
+ assert result[0].placeholder == "{{A}}"
341
+ assert result[0].text == "hello"
342
+ assert result[0].image is None
343
+
344
+ def test_dict_with_text(self):
345
+ """Test dict values with 'text' key produce text replacements."""
346
+ result = _dict_to_replacements({"{{A}}": {"text": "hi", "font": Font("Arial", 12)}})
347
+ assert result[0].text == "hi"
348
+ assert result[0].font.name == "Arial"
349
+
350
+ def test_dict_with_image_path(self):
351
+ """Test dict values with 'image' Path produce image replacements."""
352
+ logo = Path(__file__).parent / "fixtures" / "logo-80.png"
353
+ result = _dict_to_replacements({"{{LOGO}}": {"image": logo}})
354
+ assert len(result) == 1
355
+ assert result[0].text is None
356
+ assert result[0].image is not None
357
+ assert result[0].image.data == logo.read_bytes()
358
+ assert result[0].image.format == "PNG"
359
+
360
+ def test_dict_with_image_path_and_size(self):
361
+ """Test dict values with 'image' Path and width/height."""
362
+ logo = Path(__file__).parent / "fixtures" / "logo-80.png"
363
+ result = _dict_to_replacements({"{{LOGO}}": {"image": logo, "width": 50, "height": 30}})
364
+ assert result[0].image.width == 50
365
+ assert result[0].image.height == 30
366
+
367
+ def test_dict_with_image_bytes(self):
368
+ """Test dict values with 'image' as raw bytes."""
369
+ result = _dict_to_replacements({"{{LOGO}}": {"image": b"rawdata"}})
370
+ assert result[0].image.data == b"rawdata"
371
+ assert result[0].text is None