kaxe 1.4.4.dev3__tar.gz → 1.4.4.dev4__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 (131) hide show
  1. {kaxe-1.4.4.dev3/src/kaxe.egg-info → kaxe-1.4.4.dev4}/PKG-INFO +3 -1
  2. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/pyproject.toml +4 -1
  3. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/core/svg.py +10 -1
  4. kaxe-1.4.4.dev4/src/kaxe/core/svg_pdf.py +541 -0
  5. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/core/window.py +28 -2
  6. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/plot/d3/plot3d.py +13 -5
  7. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/plot/grid.py +27 -2
  8. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4/src/kaxe.egg-info}/PKG-INFO +3 -1
  9. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe.egg-info/SOURCES.txt +1 -0
  10. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe.egg-info/requires.txt +3 -0
  11. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/LICENSE +0 -0
  12. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/MANIFEST.in +0 -0
  13. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/README.md +0 -0
  14. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/setup.cfg +0 -0
  15. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/__init__.py +0 -0
  16. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/chart/__init__.py +0 -0
  17. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/chart/bar.py +0 -0
  18. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/chart/box.py +0 -0
  19. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/chart/pie.py +0 -0
  20. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/chart/qqplot.py +0 -0
  21. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/core/__init__.py +0 -0
  22. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/core/axis.py +0 -0
  23. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/core/color.py +0 -0
  24. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/core/d3/backend.py +0 -0
  25. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/core/d3/camera.py +0 -0
  26. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/core/d3/helper.py +0 -0
  27. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/core/d3/hud.py +0 -0
  28. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/core/d3/objects/__init__.py +0 -0
  29. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/core/d3/objects/color.py +0 -0
  30. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/core/d3/objects/line.py +0 -0
  31. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/core/d3/objects/point.py +0 -0
  32. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/core/d3/objects/pointer.py +0 -0
  33. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/core/d3/objects/triangle.py +0 -0
  34. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/core/d3/openglrender.py +0 -0
  35. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/core/d3/translator.py +0 -0
  36. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/core/draw.py +0 -0
  37. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/core/fileloader.py +0 -0
  38. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/core/helper.py +0 -0
  39. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/core/legend.py +0 -0
  40. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/core/line.py +0 -0
  41. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/core/marker.py +0 -0
  42. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/core/profiler.py +0 -0
  43. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/core/round.py +0 -0
  44. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/core/shapes.py +0 -0
  45. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/core/styles.py +0 -0
  46. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/core/symbol.py +0 -0
  47. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/core/text.py +0 -0
  48. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/data/__init__.py +0 -0
  49. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/data/excel.py +0 -0
  50. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/objects/__init__.py +0 -0
  51. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/objects/_lazy.py +0 -0
  52. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/objects/d2/__init__.py +0 -0
  53. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/objects/d2/arrow.py +0 -0
  54. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/objects/d2/bubble.py +0 -0
  55. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/objects/d2/contour.py +0 -0
  56. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/objects/d2/equation.py +0 -0
  57. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/objects/d2/fill.py +0 -0
  58. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/objects/d2/function.py +0 -0
  59. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/objects/d2/map.py +0 -0
  60. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/objects/d2/parameter.py +0 -0
  61. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/objects/d2/pillar.py +0 -0
  62. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/objects/d2/point.py +0 -0
  63. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/objects/d3/__init__.py +0 -0
  64. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/objects/d3/base.py +0 -0
  65. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/objects/d3/function.py +0 -0
  66. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/objects/d3/mesh.py +0 -0
  67. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/objects/d3/point.py +0 -0
  68. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/objects/d3/potato.py +0 -0
  69. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/objects/function.py +0 -0
  70. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/objects/legend.py +0 -0
  71. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/objects/mapdata.py +0 -0
  72. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/objects/point.py +0 -0
  73. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/objects/text.py +0 -0
  74. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/plot/__init__.py +0 -0
  75. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/plot/_lazy.py +0 -0
  76. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/plot/box.py +0 -0
  77. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/plot/constants.py +0 -0
  78. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/plot/d3/__init__.py +0 -0
  79. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/plot/d3/axes.py +0 -0
  80. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/plot/d3/geometry.py +0 -0
  81. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/plot/d3/variants.py +0 -0
  82. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/plot/double.py +0 -0
  83. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/plot/empty.py +0 -0
  84. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/plot/log.py +0 -0
  85. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/plot/polar.py +0 -0
  86. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/plot/standard.py +0 -0
  87. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/plot/themes.py +0 -0
  88. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/plot/zoom.py +0 -0
  89. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/plot/zoom_connector.py +0 -0
  90. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/__init__.py +0 -0
  91. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/__init__.py +0 -0
  92. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.bright-oblique.ttf +0 -0
  93. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.bright-roman.ttf +0 -0
  94. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.bright-semibold.ttf +0 -0
  95. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.bright-semiboldoblique.ttf +0 -0
  96. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.classical-serif-italic.ttf +0 -0
  97. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.concrete-bold.ttf +0 -0
  98. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.concrete-bolditalic.ttf +0 -0
  99. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.concrete-italic.ttf +0 -0
  100. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.concrete-roman.ttf +0 -0
  101. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.sans-serif-bold.ttf +0 -0
  102. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.sans-serif-boldoblique.ttf +0 -0
  103. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.sans-serif-demi-condensed-demicondensed.ttf +0 -0
  104. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.sans-serif-medium.ttf +0 -0
  105. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.sans-serif-oblique.ttf +0 -0
  106. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.serif-bold.ttf +0 -0
  107. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.serif-bolditalic.ttf +0 -0
  108. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.serif-extra-boldslanted.ttf +0 -0
  109. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.serif-extra-romanslanted.ttf +0 -0
  110. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.serif-italic.ttf +0 -0
  111. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.serif-roman.ttf +0 -0
  112. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.serif-upright-italic-uprightitalic.ttf +0 -0
  113. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.typewriter-text-bold.ttf +0 -0
  114. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.typewriter-text-bolditalic.ttf +0 -0
  115. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.typewriter-text-italic.ttf +0 -0
  116. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.typewriter-text-light.ttf +0 -0
  117. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.typewriter-text-lightoblique.ttf +0 -0
  118. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.typewriter-text-regular.ttf +0 -0
  119. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.typewriter-text-variable-width-italic.ttf +0 -0
  120. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.typewriter-text-variable-width-medium.ttf +0 -0
  121. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/readme.txt +0 -0
  122. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/logo-small.png +0 -0
  123. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/symbolcross.png +0 -0
  124. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/symboldonut.png +0 -0
  125. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/symbollollipop.png +0 -0
  126. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe/resources/symboltriangle.png +0 -0
  127. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe.egg-info/dependency_links.txt +0 -0
  128. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/src/kaxe.egg-info/top_level.txt +0 -0
  129. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/tests/test.py +0 -0
  130. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/tests/test2.py +0 -0
  131. {kaxe-1.4.4.dev3 → kaxe-1.4.4.dev4}/tests/test3.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kaxe
3
- Version: 1.4.4.dev3
3
+ Version: 1.4.4.dev4
4
4
  Summary: A small graphing tool for functions, points, equations and more
5
5
  Author-email: Valter Yde Daugberg <valteryde@hotmail.com>
6
6
  Project-URL: Homepage, https://github.com/valteryde/kaxe
@@ -25,6 +25,8 @@ Requires-Dist: pysdl2-dll
25
25
  Requires-Dist: numpy-stl
26
26
  Requires-Dist: psutil
27
27
  Requires-Dist: opencv-python-headless
28
+ Provides-Extra: pdf
29
+ Requires-Dist: reportlab>=4.0; extra == "pdf"
28
30
  Dynamic: license-file
29
31
 
30
32
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kaxe"
3
- version = "1.4.4.dev3"
3
+ version = "1.4.4.dev4"
4
4
  authors = [
5
5
  { name="Valter Yde Daugberg", email="valteryde@hotmail.com" },
6
6
  ]
@@ -29,6 +29,9 @@ dependencies = [
29
29
  "opencv-python-headless",
30
30
  ]
31
31
 
32
+ [project.optional-dependencies]
33
+ pdf = ["reportlab>=4.0"]
34
+
32
35
  [project.urls]
33
36
  Homepage = "https://github.com/valteryde/kaxe"
34
37
  Issues = "https://github.com/valteryde/kaxe/issues"
@@ -8,6 +8,7 @@ import io
8
8
  import math
9
9
  import os
10
10
  import xml.etree.ElementTree as ET
11
+ from io import BytesIO
11
12
  from typing import Any, Optional, Union
12
13
 
13
14
  from PIL import Image
@@ -369,6 +370,12 @@ class SvgDocument:
369
370
  body = _ascii_safe_svg_markup(body)
370
371
  return '<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n' + body
371
372
 
373
+ def to_pdf(self, fname: Optional[Union[str, BytesIO]] = None) -> bytes:
374
+ """Render this document to PDF bytes via ReportLab."""
375
+ from .svg_pdf import write_pdf
376
+
377
+ return write_pdf(self, fname)
378
+
372
379
 
373
380
  def parse_svg_root(xml: str) -> ET.Element:
374
381
  """Parse SVG XML and return the root element."""
@@ -433,7 +440,7 @@ def infer_format(fname: Union[str, os.PathLike, Any], format: Optional[str] = No
433
440
  """Infer save format from explicit format, file extension, or default to png."""
434
441
  if format is not None:
435
442
  fmt = format.lower().lstrip(".")
436
- if fmt in ("svg", "png"):
443
+ if fmt in ("svg", "png", "pdf"):
437
444
  return fmt
438
445
  raise ValueError(f"Unsupported format: {format}")
439
446
 
@@ -443,5 +450,7 @@ def infer_format(fname: Union[str, os.PathLike, Any], format: Optional[str] = No
443
450
  return "svg"
444
451
  if lower.endswith(".png"):
445
452
  return "png"
453
+ if lower.endswith(".pdf"):
454
+ return "pdf"
446
455
 
447
456
  return "png"
@@ -0,0 +1,541 @@
1
+ """Convert Kaxe SvgDocument trees to vector PDF via ReportLab."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import io
7
+ import logging
8
+ import math
9
+ import os
10
+ import re
11
+ import xml.etree.ElementTree as ET
12
+ from dataclasses import dataclass
13
+ from typing import TYPE_CHECKING, Any, Optional, Union
14
+
15
+ from PIL import Image
16
+
17
+ from .svg import SVG_NS, XLINK_NS, is_file_path
18
+
19
+ if TYPE_CHECKING:
20
+ from .svg import SvgDocument
21
+
22
+ _PDF_INSTALL_HINT = "PDF export requires reportlab. Install with: pip install kaxe[pdf]"
23
+ _FONDI_FONTS_REGISTERED = False
24
+
25
+ _TRANSFORM_RE = re.compile(
26
+ r"(translate|rotate|matrix)\s*\(([^)]*)\)"
27
+ )
28
+ _PATH_CMD_RE = re.compile(r"([MLAZ])\s*([^MLAZ]*)", re.IGNORECASE)
29
+
30
+
31
+ def _require_reportlab():
32
+ try:
33
+ import reportlab # noqa: F401
34
+ except ImportError as exc:
35
+ raise ImportError(_PDF_INSTALL_HINT) from exc
36
+
37
+
38
+ def _local_tag(tag: str) -> str:
39
+ return tag.split("}")[-1] if "}" in tag else tag
40
+
41
+
42
+ def _attr(el: ET.Element, name: str) -> Optional[str]:
43
+ if name in el.attrib:
44
+ return el.attrib[name]
45
+ for key, value in el.attrib.items():
46
+ if key.split("}")[-1] == name:
47
+ return value
48
+ return None
49
+
50
+
51
+ def _parse_float(value: Optional[str], default: float = 0.0) -> float:
52
+ if value is None or value == "":
53
+ return default
54
+ return float(value)
55
+
56
+
57
+ def _parse_rgb(value: Optional[str]) -> tuple[float, float, float]:
58
+ if not value or value == "none":
59
+ return 0.0, 0.0, 0.0
60
+ match = re.match(r"rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)", value)
61
+ if match:
62
+ return float(match.group(1)), float(match.group(2)), float(match.group(3))
63
+ return 0.0, 0.0, 0.0
64
+
65
+
66
+ def _rl_color(value: Optional[str], opacity: Optional[str] = None):
67
+ from reportlab.lib.colors import Color
68
+
69
+ r, g, b = _parse_rgb(value)
70
+ alpha = float(opacity) if opacity is not None else 1.0
71
+ return Color(r / 255.0, g / 255.0, b / 255.0, alpha=alpha)
72
+
73
+
74
+ def _register_fondi_fonts() -> None:
75
+ global _FONDI_FONTS_REGISTERED
76
+ if _FONDI_FONTS_REGISTERED:
77
+ return
78
+
79
+ _require_reportlab()
80
+ import fondi
81
+ from pathlib import Path
82
+ from reportlab.pdfbase import pdfmetrics
83
+ from reportlab.pdfbase.ttfonts import TTFont
84
+
85
+ resources = Path(fondi.__file__).parent / "resources"
86
+ regular_ttf = resources / "cmu.serif-roman.ttf"
87
+ if not regular_ttf.is_file():
88
+ logging.warning("Fondi regular TTF not found; PDF math labels will use Helvetica")
89
+ _FONDI_FONTS_REGISTERED = True
90
+ return
91
+
92
+ pdfmetrics.registerFont(TTFont("FondiNewCM", str(regular_ttf)))
93
+ pdfmetrics.registerFontFamily(
94
+ "FondiNewCM",
95
+ normal="FondiNewCM",
96
+ italic="FondiNewCM",
97
+ )
98
+ _FONDI_FONTS_REGISTERED = True
99
+
100
+
101
+ def _resolve_font_name(family: Optional[str], style: Optional[str]) -> str:
102
+ if family == "FondiNewCM":
103
+ return "FondiNewCM"
104
+ if family:
105
+ logging.warning("Unknown PDF font family %r; using Helvetica", family)
106
+ return "Helvetica"
107
+
108
+
109
+ @dataclass
110
+ class _Matrix:
111
+ a: float = 1.0
112
+ b: float = 0.0
113
+ c: float = 0.0
114
+ d: float = 1.0
115
+ e: float = 0.0
116
+ f: float = 0.0
117
+
118
+ def multiply(self, other: "_Matrix") -> "_Matrix":
119
+ return _Matrix(
120
+ a=self.a * other.a + self.c * other.b,
121
+ b=self.b * other.a + self.d * other.b,
122
+ c=self.a * other.c + self.c * other.d,
123
+ d=self.b * other.c + self.d * other.d,
124
+ e=self.a * other.e + self.c * other.f + self.e,
125
+ f=self.b * other.e + self.d * other.f + self.f,
126
+ )
127
+
128
+ def map_point(self, x: float, y: float) -> tuple[float, float]:
129
+ return (
130
+ self.a * x + self.c * y + self.e,
131
+ self.b * x + self.d * y + self.f,
132
+ )
133
+
134
+
135
+ def _parse_transform(transform: Optional[str]) -> _Matrix:
136
+ matrix = _Matrix()
137
+ if not transform:
138
+ return matrix
139
+
140
+ for match in _TRANSFORM_RE.finditer(transform.strip()):
141
+ name = match.group(1).lower()
142
+ parts = [p.strip() for p in re.split(r"[\s,]+", match.group(2).strip()) if p.strip()]
143
+
144
+ if name == "translate":
145
+ tx = float(parts[0]) if parts else 0.0
146
+ ty = float(parts[1]) if len(parts) > 1 else 0.0
147
+ matrix = matrix.multiply(_Matrix(e=tx, f=ty))
148
+ elif name == "rotate":
149
+ angle = math.radians(float(parts[0]))
150
+ cx = float(parts[1]) if len(parts) > 1 else 0.0
151
+ cy = float(parts[2]) if len(parts) > 2 else 0.0
152
+ cos_a = math.cos(angle)
153
+ sin_a = math.sin(angle)
154
+ rot = _Matrix(a=cos_a, b=sin_a, c=-sin_a, d=cos_a)
155
+ to_origin = _Matrix(e=-cx, f=-cy)
156
+ back = _Matrix(e=cx, f=cy)
157
+ matrix = matrix.multiply(back.multiply(rot.multiply(to_origin)))
158
+ elif name == "matrix" and len(parts) >= 6:
159
+ matrix = matrix.multiply(
160
+ _Matrix(
161
+ a=float(parts[0]),
162
+ b=float(parts[1]),
163
+ c=float(parts[2]),
164
+ d=float(parts[3]),
165
+ e=float(parts[4]),
166
+ f=float(parts[5]),
167
+ )
168
+ )
169
+ return matrix
170
+
171
+
172
+ class _PdfBuilder:
173
+ def __init__(self, width: int, height: int):
174
+ _require_reportlab()
175
+ from reportlab.graphics.shapes import Drawing
176
+
177
+ self.height = height
178
+ self.drawing = Drawing(width, height)
179
+ self._shapes: list[Any] = []
180
+
181
+ def _to_rl(self, matrix: _Matrix, x: float, y: float) -> tuple[float, float]:
182
+ sx, sy = matrix.map_point(x, y)
183
+ return sx, self.height - sy
184
+
185
+ def _add(self, shape) -> None:
186
+ self._shapes.append(shape)
187
+
188
+ def _render_element(self, el: ET.Element, matrix: _Matrix) -> None:
189
+ from reportlab.graphics.shapes import (
190
+ Circle,
191
+ Group,
192
+ Image as RlImage,
193
+ Line,
194
+ Path,
195
+ Polygon,
196
+ PolyLine,
197
+ Rect,
198
+ String,
199
+ )
200
+ from reportlab.lib.utils import ImageReader
201
+
202
+ tag = _local_tag(el.tag)
203
+ opacity = _attr(el, "opacity")
204
+
205
+ if tag == "g":
206
+ child_matrix = matrix.multiply(_parse_transform(_attr(el, "transform")))
207
+ group = Group()
208
+ child_shapes: list[Any] = []
209
+ saved = self._shapes
210
+ self._shapes = child_shapes
211
+ for child in el:
212
+ self._render_element(child, child_matrix)
213
+ self._shapes = saved
214
+ for shape in child_shapes:
215
+ group.add(shape)
216
+ if child_shapes:
217
+ self._add(group)
218
+ return
219
+
220
+ if tag == "rect":
221
+ x = _parse_float(_attr(el, "x"))
222
+ y = _parse_float(_attr(el, "y"))
223
+ w = _parse_float(_attr(el, "width"))
224
+ h = _parse_float(_attr(el, "height"))
225
+ x0, y0 = self._to_rl(matrix, x, y)
226
+ x1, y1 = self._to_rl(matrix, x + w, y + h)
227
+ rect = Rect(
228
+ min(x0, x1),
229
+ min(y0, y1),
230
+ abs(x1 - x0),
231
+ abs(y1 - y0),
232
+ fillColor=_rl_color(_attr(el, "fill"), opacity),
233
+ strokeColor=None,
234
+ )
235
+ self._add(rect)
236
+ return
237
+
238
+ if tag == "line":
239
+ x1, y1 = self._to_rl(matrix, _parse_float(_attr(el, "x1")), _parse_float(_attr(el, "y1")))
240
+ x2, y2 = self._to_rl(matrix, _parse_float(_attr(el, "x2")), _parse_float(_attr(el, "y2")))
241
+ stroke = _rl_color(_attr(el, "stroke"), opacity)
242
+ width = _parse_float(_attr(el, "stroke-width"), 1.0)
243
+ self._add(Line(x1, y1, x2, y2, strokeColor=stroke, strokeWidth=width))
244
+ return
245
+
246
+ if tag == "circle":
247
+ cx, cy = self._to_rl(matrix, _parse_float(_attr(el, "cx")), _parse_float(_attr(el, "cy")))
248
+ radius = _parse_float(_attr(el, "r"))
249
+ fill = _attr(el, "fill")
250
+ if fill and fill != "none":
251
+ self._add(
252
+ Circle(
253
+ cx,
254
+ cy,
255
+ radius,
256
+ fillColor=_rl_color(fill, opacity),
257
+ strokeColor=None,
258
+ )
259
+ )
260
+ else:
261
+ self._add(
262
+ Circle(
263
+ cx,
264
+ cy,
265
+ radius,
266
+ fillColor=None,
267
+ strokeColor=_rl_color(_attr(el, "stroke"), opacity),
268
+ strokeWidth=_parse_float(_attr(el, "stroke-width"), 1.0),
269
+ )
270
+ )
271
+ return
272
+
273
+ if tag == "polygon":
274
+ points = self._parse_points(_attr(el, "points"), matrix)
275
+ self._add(
276
+ Polygon(
277
+ points,
278
+ fillColor=_rl_color(_attr(el, "fill"), opacity),
279
+ strokeColor=None,
280
+ )
281
+ )
282
+ return
283
+
284
+ if tag == "polyline":
285
+ points = self._parse_points(_attr(el, "points"), matrix)
286
+ self._add(
287
+ PolyLine(
288
+ points,
289
+ fillColor=None,
290
+ strokeColor=_rl_color(_attr(el, "stroke"), opacity),
291
+ strokeWidth=_parse_float(_attr(el, "stroke-width"), 1.0),
292
+ )
293
+ )
294
+ return
295
+
296
+ if tag == "path":
297
+ path = self._parse_path(_attr(el, "d"), matrix)
298
+ if path is not None:
299
+ path.fillColor = _rl_color(_attr(el, "fill"), opacity)
300
+ path.strokeColor = None
301
+ self._add(path)
302
+ return
303
+
304
+ if tag == "image":
305
+ href = _attr(el, "href") or _attr(el, f"{{{XLINK_NS}}}href")
306
+ if not href or not href.startswith("data:image"):
307
+ return
308
+ header, encoded = href.split(",", 1)
309
+ raw = base64.b64decode(encoded)
310
+ img = Image.open(io.BytesIO(raw))
311
+ x = _parse_float(_attr(el, "x"))
312
+ y = _parse_float(_attr(el, "y"))
313
+ w = _parse_float(_attr(el, "width"), img.width)
314
+ h = _parse_float(_attr(el, "height"), img.height)
315
+
316
+ image_matrix = matrix.multiply(_parse_transform(_attr(el, "transform")))
317
+ x0, y0 = self._to_rl(image_matrix, x, y)
318
+ x1, y1 = self._to_rl(image_matrix, x + w, y + h)
319
+ rl_img = RlImage(
320
+ min(x0, x1),
321
+ min(y0, y1),
322
+ abs(x1 - x0),
323
+ abs(y1 - y0),
324
+ path=ImageReader(io.BytesIO(raw)),
325
+ )
326
+ self._add(rl_img)
327
+ return
328
+
329
+ if tag == "text":
330
+ x = _parse_float(_attr(el, "x"))
331
+ y = _parse_float(_attr(el, "y"))
332
+ rl_x, rl_y = self._to_rl(matrix, x, y)
333
+ text = (el.text or "").strip()
334
+ if not text:
335
+ text = "".join(el.itertext())
336
+ font_name = _resolve_font_name(_attr(el, "font-family"), _attr(el, "font-style"))
337
+ font_size = _parse_float(_attr(el, "font-size"), 12.0)
338
+ anchor = _attr(el, "text-anchor") or "start"
339
+ text_anchor = {"middle": "middle", "end": "end"}.get(anchor, "start")
340
+ self._add(
341
+ String(
342
+ rl_x,
343
+ rl_y,
344
+ text,
345
+ fontName=font_name,
346
+ fontSize=font_size,
347
+ fillColor=_rl_color(_attr(el, "fill"), opacity),
348
+ textAnchor=text_anchor,
349
+ )
350
+ )
351
+ return
352
+
353
+ def _parse_points(self, value: Optional[str], matrix: _Matrix) -> list[float]:
354
+ if not value:
355
+ return []
356
+ nums = [float(n) for n in re.split(r"[\s,]+", value.strip()) if n]
357
+ points: list[float] = []
358
+ for i in range(0, len(nums) - 1, 2):
359
+ x, y = self._to_rl(matrix, nums[i], nums[i + 1])
360
+ points.extend([x, y])
361
+ return points
362
+
363
+ def _parse_path(self, d: Optional[str], matrix: _Matrix):
364
+ from reportlab.graphics.shapes import Path
365
+
366
+ if not d:
367
+ return None
368
+
369
+ path = Path()
370
+ current_x = 0.0
371
+ current_y = 0.0
372
+ start_x = 0.0
373
+ start_y = 0.0
374
+ subpath_start_x = 0.0
375
+ subpath_start_y = 0.0
376
+
377
+ for match in _PATH_CMD_RE.finditer(d):
378
+ cmd = match.group(1).upper()
379
+ nums = [float(n) for n in re.split(r"[\s,]+", match.group(2).strip()) if n]
380
+
381
+ if cmd == "M" and len(nums) >= 2:
382
+ current_x, current_y = nums[0], nums[1]
383
+ start_x, start_y = current_x, current_y
384
+ subpath_start_x, subpath_start_y = current_x, current_y
385
+ x, y = self._to_rl(matrix, current_x, current_y)
386
+ path.moveTo(x, y)
387
+ elif cmd == "L" and len(nums) >= 2:
388
+ current_x, current_y = nums[0], nums[1]
389
+ x, y = self._to_rl(matrix, current_x, current_y)
390
+ path.lineTo(x, y)
391
+ elif cmd == "A" and len(nums) >= 7:
392
+ rx, ry, _, large_arc, sweep, end_x, end_y = nums[:7]
393
+ start_x, start_y = current_x, current_y
394
+ self._append_circular_arc(
395
+ path,
396
+ matrix,
397
+ start_x,
398
+ start_y,
399
+ end_x,
400
+ end_y,
401
+ rx,
402
+ bool(int(large_arc)),
403
+ bool(int(sweep)),
404
+ )
405
+ current_x, current_y = end_x, end_y
406
+ elif cmd == "Z":
407
+ current_x, current_y = subpath_start_x, subpath_start_y
408
+ x, y = self._to_rl(matrix, current_x, current_y)
409
+ path.lineTo(x, y)
410
+ path.closePath()
411
+
412
+ return path
413
+
414
+ def _append_circular_arc(
415
+ self,
416
+ path,
417
+ matrix: _Matrix,
418
+ x1: float,
419
+ y1: float,
420
+ x2: float,
421
+ y2: float,
422
+ radius: float,
423
+ large_arc: bool,
424
+ sweep: bool,
425
+ ) -> None:
426
+ dx = x2 - x1
427
+ dy = y2 - y1
428
+ dist = math.hypot(dx, dy)
429
+ if dist == 0 or radius == 0:
430
+ x, y = self._to_rl(matrix, x2, y2)
431
+ path.lineTo(x, y)
432
+ return
433
+
434
+ dist = min(dist, 2 * radius)
435
+ mid_x = (x1 + x2) / 2
436
+ mid_y = (y1 + y2) / 2
437
+ chord = dist / 2
438
+ sagitta_sq = max(radius * radius - chord * chord, 0.0)
439
+ sagitta = math.sqrt(sagitta_sq)
440
+
441
+ perp_x = -(y2 - y1) / dist
442
+ perp_y = (x2 - x1) / dist
443
+
444
+ cx1 = mid_x + perp_x * sagitta
445
+ cy1 = mid_y + perp_y * sagitta
446
+ cx2 = mid_x - perp_x * sagitta
447
+ cy2 = mid_y - perp_y * sagitta
448
+
449
+ start1 = math.atan2(y1 - cy1, x1 - cx1)
450
+ end1 = math.atan2(y2 - cy1, x2 - cx1)
451
+ start2 = math.atan2(y1 - cy2, x1 - cx2)
452
+ end2 = math.atan2(y2 - cy2, x2 - cx2)
453
+
454
+ def arc_span(start: float, end: float) -> float:
455
+ span = end - start
456
+ if sweep and span < 0:
457
+ span += 2 * math.pi
458
+ if not sweep and span > 0:
459
+ span -= 2 * math.pi
460
+ return abs(span)
461
+
462
+ use_first = arc_span(start1, end1) > math.pi
463
+ if large_arc:
464
+ cx, cy, start, end = (
465
+ (cx1, cy1, start1, end1) if use_first else (cx2, cy2, start2, end2)
466
+ )
467
+ else:
468
+ cx, cy, start, end = (
469
+ (cx1, cy1, start1, end1) if not use_first else (cx2, cy2, start2, end2)
470
+ )
471
+
472
+ if sweep and end < start:
473
+ end += 2 * math.pi
474
+ if not sweep and end > start:
475
+ end -= 2 * math.pi
476
+
477
+ steps = max(8, int(abs(end - start) / (math.pi / 16)))
478
+ for i in range(1, steps + 1):
479
+ t = start + (end - start) * (i / steps)
480
+ px = cx + radius * math.cos(t)
481
+ py = cy + radius * math.sin(t)
482
+ x, y = self._to_rl(matrix, px, py)
483
+ path.lineTo(x, y)
484
+
485
+ def build(self, elements: list[ET.Element]):
486
+ identity = _Matrix()
487
+ for el in elements:
488
+ self._render_element(el, identity)
489
+ for shape in self._shapes:
490
+ self.drawing.add(shape)
491
+ return self.drawing
492
+
493
+
494
+ def document_to_pdf(doc: "SvgDocument") -> bytes:
495
+ _register_fondi_fonts()
496
+ from reportlab.graphics import renderPDF
497
+
498
+ builder = _PdfBuilder(doc.width, doc.height)
499
+ drawing = builder.build(doc._elements)
500
+ buf = io.BytesIO()
501
+ renderPDF.drawToFile(drawing, buf)
502
+ return buf.getvalue()
503
+
504
+
505
+ def write_pdf(doc: "SvgDocument", fname: Optional[Union[str, io.BytesIO]] = None) -> bytes:
506
+ pdf_bytes = document_to_pdf(doc)
507
+ if fname is not None:
508
+ if is_file_path(fname):
509
+ with open(os.fspath(fname), "wb") as f:
510
+ f.write(pdf_bytes)
511
+ else:
512
+ fname.write(pdf_bytes)
513
+ return pdf_bytes
514
+
515
+
516
+ def image_to_pdf_page(img: Image.Image, fname: Optional[Union[str, io.BytesIO]] = None) -> bytes:
517
+ """Wrap a raster image in a single-page PDF."""
518
+ _require_reportlab()
519
+ from reportlab.lib.utils import ImageReader
520
+ from reportlab.pdfgen import canvas
521
+
522
+ width, height = img.size
523
+ buf = io.BytesIO()
524
+ if img.mode != "RGB":
525
+ img = img.convert("RGB")
526
+ png_buf = io.BytesIO()
527
+ img.save(png_buf, format="PNG")
528
+ png_buf.seek(0)
529
+
530
+ pdf = canvas.Canvas(buf, pagesize=(width, height))
531
+ pdf.drawImage(ImageReader(png_buf), 0, 0, width=width, height=height)
532
+ pdf.save()
533
+ pdf_bytes = buf.getvalue()
534
+
535
+ if fname is not None:
536
+ if is_file_path(fname):
537
+ with open(os.fspath(fname), "wb") as f:
538
+ f.write(pdf_bytes)
539
+ else:
540
+ fname.write(pdf_bytes)
541
+ return pdf_bytes
@@ -513,9 +513,34 @@ class Window(AttrObject):
513
513
  return xml
514
514
 
515
515
 
516
+ def __pdfPaint__(self, fname=None) -> bytes:
517
+ startTime = time.time()
518
+ if self.showProgressBar: pbar = tqdm.tqdm(total=len(self.shapes), desc='Decorating PDF')
519
+
520
+ winSize = self.width+self.padding[0]+self.padding[2], self.height+self.padding[1]+self.padding[3]
521
+ doc = SvgDocument(winSize)
522
+
523
+ if self.getAttr('backgroundColor')[3] != 0:
524
+ background = shapes.Rectangle(0, 0, winSize[0], winSize[1], color=self.getAttr('backgroundColor'))
525
+ background.draw(doc)
526
+
527
+ for shape in self.shapes:
528
+ shape.draw(doc)
529
+ if self.showProgressBar: pbar.update()
530
+
531
+ pdf_bytes = doc.to_pdf(fname)
532
+
533
+ if self.showProgressBar: pbar.close()
534
+ if self.printDebugInfo: logging.info('Painted PDF in {}s'.format(str(round(time.time() - startTime, 4))))
535
+
536
+ return pdf_bytes
537
+
538
+
516
539
  def __paint__(self, fname=None, format: str = "png"):
517
540
  if format == "svg":
518
541
  return self.__svgPaint__(fname)
542
+ if format == "pdf":
543
+ return self.__pdfPaint__(fname)
519
544
  return self.__pillowPaint__(fname)
520
545
 
521
546
 
@@ -532,12 +557,13 @@ class Window(AttrObject):
532
557
  fname : str | BytesIO
533
558
  The filename where the image will be saved or a BytesIO object to save the image in memory.
534
559
  format : str, optional
535
- Output format: ``"png"`` or ``"svg"``. Inferred from ``fname`` extension when omitted.
560
+ Output format: ``"png"``, ``"svg"``, or ``"pdf"``. Inferred from ``fname`` extension when omitted.
536
561
 
537
562
  Examples
538
563
  --------
539
564
  >>> plt.save( path/where/image/saved.png )
540
565
  >>> plt.save( path/where/image/saved.svg )
566
+ >>> plt.save( path/where/image/saved.pdf )
541
567
 
542
568
  """
543
569
 
@@ -554,7 +580,7 @@ class Window(AttrObject):
554
580
  totStartTime = time.time()
555
581
 
556
582
  self.__bake__()
557
- result = self.__paint__(fname if fmt == "svg" else None, format=fmt)
583
+ result = self.__paint__(fname if fmt in ("svg", "pdf") else None, format=fmt)
558
584
 
559
585
  if fmt == "png":
560
586
  if fname is not None: