kaxe 1.4.4.dev2__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.
- {kaxe-1.4.4.dev2/src/kaxe.egg-info → kaxe-1.4.4.dev4}/PKG-INFO +3 -1
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/pyproject.toml +4 -1
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/core/svg.py +12 -1
- kaxe-1.4.4.dev4/src/kaxe/core/svg_pdf.py +541 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/core/window.py +28 -2
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/plot/d3/plot3d.py +13 -5
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/plot/grid.py +27 -2
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4/src/kaxe.egg-info}/PKG-INFO +3 -1
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe.egg-info/SOURCES.txt +1 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe.egg-info/requires.txt +3 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/LICENSE +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/MANIFEST.in +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/README.md +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/setup.cfg +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/__init__.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/chart/__init__.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/chart/bar.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/chart/box.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/chart/pie.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/chart/qqplot.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/core/__init__.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/core/axis.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/core/color.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/core/d3/backend.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/core/d3/camera.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/core/d3/helper.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/core/d3/hud.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/core/d3/objects/__init__.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/core/d3/objects/color.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/core/d3/objects/line.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/core/d3/objects/point.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/core/d3/objects/pointer.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/core/d3/objects/triangle.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/core/d3/openglrender.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/core/d3/translator.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/core/draw.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/core/fileloader.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/core/helper.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/core/legend.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/core/line.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/core/marker.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/core/profiler.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/core/round.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/core/shapes.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/core/styles.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/core/symbol.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/core/text.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/data/__init__.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/data/excel.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/objects/__init__.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/objects/_lazy.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/objects/d2/__init__.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/objects/d2/arrow.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/objects/d2/bubble.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/objects/d2/contour.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/objects/d2/equation.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/objects/d2/fill.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/objects/d2/function.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/objects/d2/map.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/objects/d2/parameter.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/objects/d2/pillar.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/objects/d2/point.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/objects/d3/__init__.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/objects/d3/base.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/objects/d3/function.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/objects/d3/mesh.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/objects/d3/point.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/objects/d3/potato.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/objects/function.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/objects/legend.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/objects/mapdata.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/objects/point.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/objects/text.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/plot/__init__.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/plot/_lazy.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/plot/box.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/plot/constants.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/plot/d3/__init__.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/plot/d3/axes.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/plot/d3/geometry.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/plot/d3/variants.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/plot/double.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/plot/empty.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/plot/log.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/plot/polar.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/plot/standard.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/plot/themes.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/plot/zoom.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/plot/zoom_connector.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/__init__.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/__init__.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.bright-oblique.ttf +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.bright-roman.ttf +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.bright-semibold.ttf +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.bright-semiboldoblique.ttf +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.classical-serif-italic.ttf +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.concrete-bold.ttf +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.concrete-bolditalic.ttf +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.concrete-italic.ttf +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.concrete-roman.ttf +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.sans-serif-bold.ttf +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.sans-serif-boldoblique.ttf +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.sans-serif-demi-condensed-demicondensed.ttf +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.sans-serif-medium.ttf +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.sans-serif-oblique.ttf +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.serif-bold.ttf +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.serif-bolditalic.ttf +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.serif-extra-boldslanted.ttf +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.serif-extra-romanslanted.ttf +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.serif-italic.ttf +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.serif-roman.ttf +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.serif-upright-italic-uprightitalic.ttf +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.typewriter-text-bold.ttf +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.typewriter-text-bolditalic.ttf +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.typewriter-text-italic.ttf +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.typewriter-text-light.ttf +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.typewriter-text-lightoblique.ttf +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.typewriter-text-regular.ttf +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.typewriter-text-variable-width-italic.ttf +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/cmu.typewriter-text-variable-width-medium.ttf +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/computer-modern-family/readme.txt +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/logo-small.png +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/symbolcross.png +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/symboldonut.png +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/symbollollipop.png +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe/resources/symboltriangle.png +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe.egg-info/dependency_links.txt +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/src/kaxe.egg-info/top_level.txt +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/tests/test.py +0 -0
- {kaxe-1.4.4.dev2 → kaxe-1.4.4.dev4}/tests/test2.py +0 -0
- {kaxe-1.4.4.dev2 → 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.
|
|
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.
|
|
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
|
|
@@ -365,8 +366,16 @@ class SvgDocument:
|
|
|
365
366
|
for el in self._elements:
|
|
366
367
|
root.append(el)
|
|
367
368
|
body = ET.tostring(root, encoding="unicode")
|
|
369
|
+
from fondi.backends.svg import _ascii_safe_svg_markup
|
|
370
|
+
body = _ascii_safe_svg_markup(body)
|
|
368
371
|
return '<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n' + body
|
|
369
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
|
+
|
|
370
379
|
|
|
371
380
|
def parse_svg_root(xml: str) -> ET.Element:
|
|
372
381
|
"""Parse SVG XML and return the root element."""
|
|
@@ -431,7 +440,7 @@ def infer_format(fname: Union[str, os.PathLike, Any], format: Optional[str] = No
|
|
|
431
440
|
"""Infer save format from explicit format, file extension, or default to png."""
|
|
432
441
|
if format is not None:
|
|
433
442
|
fmt = format.lower().lstrip(".")
|
|
434
|
-
if fmt in ("svg", "png"):
|
|
443
|
+
if fmt in ("svg", "png", "pdf"):
|
|
435
444
|
return fmt
|
|
436
445
|
raise ValueError(f"Unsupported format: {format}")
|
|
437
446
|
|
|
@@ -441,5 +450,7 @@ def infer_format(fname: Union[str, os.PathLike, Any], format: Optional[str] = No
|
|
|
441
450
|
return "svg"
|
|
442
451
|
if lower.endswith(".png"):
|
|
443
452
|
return "png"
|
|
453
|
+
if lower.endswith(".pdf"):
|
|
454
|
+
return "pdf"
|
|
444
455
|
|
|
445
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 ``"
|
|
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
|
|
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:
|