qrcody 1.8.1__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.
qrcody-1.8.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matt Troutman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
qrcody-1.8.1/PKG-INFO ADDED
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: qrcody
3
+ Version: 1.8.1
4
+ Summary: Standalone Python port of the qrcody SVG QR rendering engine (styled pixels/eyes/frames)
5
+ Author: Matt Troutman
6
+ Author-email: Matt Troutman <git@trtmn.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Dist: segno>=1.6
10
+ Requires-Dist: cairosvg>=2.7 ; extra == 'png'
11
+ Requires-Python: >=3.11
12
+ Provides-Extra: png
13
+ Description-Content-Type: text/markdown
14
+
15
+ # qrcody
16
+
17
+ Standalone Python port of [qrcody](https://qrcody.trtmn.io)'s styled QR code SVG
18
+ rendering engine — square/rounded/dots/blob/bars/squircle pixel styles, styled
19
+ finder eyes and pupils, gradients, and framed export (perimeter / scan-me /
20
+ scan bands). No browser, no server — pure functions in, SVG string out.
21
+
22
+ This is a hand-port of `src/lib/buildSVG.js` from the qrcody web app. The
23
+ rendering logic itself has no DOM dependency in the original (it already runs
24
+ inside a Cloudflare Worker), so the two implementations produce visually
25
+ equivalent output, though exact numeric string formatting may differ slightly
26
+ (e.g. `500` vs `500.0`) — SVG renderers treat these identically.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ uv add qrcody
32
+ # or, for PNG export too:
33
+ uv add "qrcody[png]"
34
+ ```
35
+
36
+ PNG export uses [cairosvg](https://cairosvg.org/), which needs the native
37
+ Cairo library installed on the system (`brew install cairo` on macOS,
38
+ `apt install libcairo2` on Debian/Ubuntu).
39
+
40
+ ## Library usage
41
+
42
+ ```python
43
+ from qrcody import DEFAULT, build_svg, build_framed_svg
44
+
45
+ settings = {
46
+ **DEFAULT,
47
+ "text": "https://example.com",
48
+ "pixels_style": "dots",
49
+ "eyes_style": "circle",
50
+ "frame": "scan-me",
51
+ "frame_text": "Scan me",
52
+ }
53
+
54
+ qr_svg = build_svg(settings)
55
+ framed = build_framed_svg(qr_svg, settings)
56
+ svg_string = framed["svg"] # framed["w"] / framed["h"] give the output dimensions
57
+ ```
58
+
59
+ For PNG (requires the `png` extra):
60
+
61
+ ```python
62
+ from qrcody.png import svg_to_png
63
+
64
+ png_bytes = svg_to_png(svg_string, width=1000)
65
+ ```
66
+
67
+ ## CLI
68
+
69
+ ```bash
70
+ qrcody --url "https://example.com" --pixels squircle --frame perimeter -o qr.svg
71
+ qrcody --url "https://example.com" --pixels dots --eyes circle -o qr.png
72
+ ```
73
+
74
+ Run `qrcody --help` for the full flag list — it mirrors the qrcody `/api/qr`
75
+ HTTP API parameters (see the qrcody repo's `functions/api/qr.js` and
76
+ `public/openapi.yaml`).
77
+
78
+ ## Settings dict
79
+
80
+ All keys from `qrcody.DEFAULT` (see `defaults.py`) are recognized;
81
+ unspecified keys fall back to their default. Notable ones:
82
+
83
+ | Key | Values |
84
+ |---|---|
85
+ | `pixels_style` | `square` / `rounded` / `dots` / `blob` / `bars-h` / `bars-v` / `squircle` |
86
+ | `eyes_style` | `square` / `rounded` / `circle` / `leaf` / `squircle` |
87
+ | `pupils_style` | `square` / `rounded` / `circle` / `squircle` |
88
+ | `corners_style` | `none` / `round` / `extra-round` / `squircle` |
89
+ | `frame` | `none` / `perimeter` / `scan-me` / `scan` |
90
+ | `correction_level` | `L` / `M` / `Q` / `H` |
91
+
92
+ ## Development
93
+
94
+ ```bash
95
+ uv sync --extra png
96
+ uv run pytest
97
+ uv run ruff check .
98
+ ```
99
+
100
+ ## Known gaps vs. the JS implementation
101
+
102
+ - **Logo embedding** (`logo` / `logo_scale` settings) is supported in
103
+ `build_svg` the same way as the JS version — pass a data URI as `logo`.
104
+ There is no server-side "fetch a logo URL" helper here (that lives in the
105
+ qrcody Cloudflare Worker's SSRF-hardened fetch, which is deployment-specific
106
+ and out of scope for this package).
107
+ - No automated JS/Python golden-file parity test yet — the port was done by
108
+ hand against `src/lib/buildSVG.js` and verified with a matching unit test
109
+ suite, but the two aren't diffed against each other in CI. If the JS
110
+ renderer changes, this package needs a manual re-sync.
111
+
112
+ ## Note on the package name
113
+
114
+ This package is published as `qrcody` on PyPI, distinct from the `qrcody`
115
+ npm/web app it's ported from — there's no npm/PyPI namespace collision since
116
+ they're different registries.
qrcody-1.8.1/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # qrcody
2
+
3
+ Standalone Python port of [qrcody](https://qrcody.trtmn.io)'s styled QR code SVG
4
+ rendering engine — square/rounded/dots/blob/bars/squircle pixel styles, styled
5
+ finder eyes and pupils, gradients, and framed export (perimeter / scan-me /
6
+ scan bands). No browser, no server — pure functions in, SVG string out.
7
+
8
+ This is a hand-port of `src/lib/buildSVG.js` from the qrcody web app. The
9
+ rendering logic itself has no DOM dependency in the original (it already runs
10
+ inside a Cloudflare Worker), so the two implementations produce visually
11
+ equivalent output, though exact numeric string formatting may differ slightly
12
+ (e.g. `500` vs `500.0`) — SVG renderers treat these identically.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ uv add qrcody
18
+ # or, for PNG export too:
19
+ uv add "qrcody[png]"
20
+ ```
21
+
22
+ PNG export uses [cairosvg](https://cairosvg.org/), which needs the native
23
+ Cairo library installed on the system (`brew install cairo` on macOS,
24
+ `apt install libcairo2` on Debian/Ubuntu).
25
+
26
+ ## Library usage
27
+
28
+ ```python
29
+ from qrcody import DEFAULT, build_svg, build_framed_svg
30
+
31
+ settings = {
32
+ **DEFAULT,
33
+ "text": "https://example.com",
34
+ "pixels_style": "dots",
35
+ "eyes_style": "circle",
36
+ "frame": "scan-me",
37
+ "frame_text": "Scan me",
38
+ }
39
+
40
+ qr_svg = build_svg(settings)
41
+ framed = build_framed_svg(qr_svg, settings)
42
+ svg_string = framed["svg"] # framed["w"] / framed["h"] give the output dimensions
43
+ ```
44
+
45
+ For PNG (requires the `png` extra):
46
+
47
+ ```python
48
+ from qrcody.png import svg_to_png
49
+
50
+ png_bytes = svg_to_png(svg_string, width=1000)
51
+ ```
52
+
53
+ ## CLI
54
+
55
+ ```bash
56
+ qrcody --url "https://example.com" --pixels squircle --frame perimeter -o qr.svg
57
+ qrcody --url "https://example.com" --pixels dots --eyes circle -o qr.png
58
+ ```
59
+
60
+ Run `qrcody --help` for the full flag list — it mirrors the qrcody `/api/qr`
61
+ HTTP API parameters (see the qrcody repo's `functions/api/qr.js` and
62
+ `public/openapi.yaml`).
63
+
64
+ ## Settings dict
65
+
66
+ All keys from `qrcody.DEFAULT` (see `defaults.py`) are recognized;
67
+ unspecified keys fall back to their default. Notable ones:
68
+
69
+ | Key | Values |
70
+ |---|---|
71
+ | `pixels_style` | `square` / `rounded` / `dots` / `blob` / `bars-h` / `bars-v` / `squircle` |
72
+ | `eyes_style` | `square` / `rounded` / `circle` / `leaf` / `squircle` |
73
+ | `pupils_style` | `square` / `rounded` / `circle` / `squircle` |
74
+ | `corners_style` | `none` / `round` / `extra-round` / `squircle` |
75
+ | `frame` | `none` / `perimeter` / `scan-me` / `scan` |
76
+ | `correction_level` | `L` / `M` / `Q` / `H` |
77
+
78
+ ## Development
79
+
80
+ ```bash
81
+ uv sync --extra png
82
+ uv run pytest
83
+ uv run ruff check .
84
+ ```
85
+
86
+ ## Known gaps vs. the JS implementation
87
+
88
+ - **Logo embedding** (`logo` / `logo_scale` settings) is supported in
89
+ `build_svg` the same way as the JS version — pass a data URI as `logo`.
90
+ There is no server-side "fetch a logo URL" helper here (that lives in the
91
+ qrcody Cloudflare Worker's SSRF-hardened fetch, which is deployment-specific
92
+ and out of scope for this package).
93
+ - No automated JS/Python golden-file parity test yet — the port was done by
94
+ hand against `src/lib/buildSVG.js` and verified with a matching unit test
95
+ suite, but the two aren't diffed against each other in CI. If the JS
96
+ renderer changes, this package needs a manual re-sync.
97
+
98
+ ## Note on the package name
99
+
100
+ This package is published as `qrcody` on PyPI, distinct from the `qrcody`
101
+ npm/web app it's ported from — there's no npm/PyPI namespace collision since
102
+ they're different registries.
@@ -0,0 +1,30 @@
1
+ [project]
2
+ name = "qrcody"
3
+ version = "1.8.1"
4
+ description = "Standalone Python port of the qrcody SVG QR rendering engine (styled pixels/eyes/frames)"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.11"
9
+ dependencies = ["segno>=1.6"]
10
+
11
+ [[project.authors]]
12
+ name = "Matt Troutman"
13
+ email = "git@trtmn.com"
14
+
15
+ [project.optional-dependencies]
16
+ png = ["cairosvg>=2.7"]
17
+
18
+ [project.scripts]
19
+ qrcody = "qrcody.cli:main"
20
+
21
+ [build-system]
22
+ requires = ["uv_build>=0.11.7,<0.12.0"]
23
+ build-backend = "uv_build"
24
+
25
+ [dependency-groups]
26
+ dev = [
27
+ "pytest>=8",
28
+ "ruff>=0.6",
29
+ "defusedxml>=0.7",
30
+ ]
@@ -0,0 +1,31 @@
1
+ [project]
2
+ name = "qrcody"
3
+ version = "1.8.1"
4
+ description = "Standalone Python port of the qrcody SVG QR rendering engine (styled pixels/eyes/frames)"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [
9
+ { name = "Matt Troutman", email = "git@trtmn.com" }
10
+ ]
11
+ requires-python = ">=3.11"
12
+ dependencies = [
13
+ "segno>=1.6",
14
+ ]
15
+
16
+ [project.optional-dependencies]
17
+ png = ["cairosvg>=2.7"]
18
+
19
+ [project.scripts]
20
+ qrcody = "qrcody.cli:main"
21
+
22
+ [build-system]
23
+ requires = ["uv_build>=0.11.7,<0.12.0"]
24
+ build-backend = "uv_build"
25
+
26
+ [dependency-groups]
27
+ dev = [
28
+ "pytest>=8",
29
+ "ruff>=0.6",
30
+ "defusedxml>=0.7",
31
+ ]
@@ -0,0 +1,16 @@
1
+ """qrcody-render — styled QR code SVG rendering, ported from the qrcody web app."""
2
+
3
+ from .defaults import CORNERS_STYLES, CORRECTION_LEVELS, DEFAULT, EYE_STYLES, FRAME_STYLES, PIXELS_STYLES, PUPIL_STYLES
4
+ from .svg import build_framed_svg, build_svg
5
+
6
+ __all__ = [
7
+ "build_svg",
8
+ "build_framed_svg",
9
+ "DEFAULT",
10
+ "PIXELS_STYLES",
11
+ "EYE_STYLES",
12
+ "PUPIL_STYLES",
13
+ "CORNERS_STYLES",
14
+ "FRAME_STYLES",
15
+ "CORRECTION_LEVELS",
16
+ ]
@@ -0,0 +1,70 @@
1
+ """Command-line QR generator: qrcody-render --url ... --pixels squircle -o out.svg"""
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from .defaults import CORNERS_STYLES, CORRECTION_LEVELS, DEFAULT, EYE_STYLES, FRAME_STYLES, PIXELS_STYLES, PUPIL_STYLES
7
+ from .svg import build_framed_svg, build_svg
8
+
9
+
10
+ def _build_parser() -> argparse.ArgumentParser:
11
+ p = argparse.ArgumentParser(prog="qrcody-render", description="Generate a styled QR code as SVG or PNG.")
12
+ p.add_argument("--url", required=True, help="The URL/text to encode.")
13
+ p.add_argument("-o", "--output", required=True, help="Output file path (.svg or .png).")
14
+ p.add_argument("--bg", default=DEFAULT["color"], help="Background color, hex.")
15
+ p.add_argument("--fg", default=None, help="Foreground/module color, hex. Defaults to auto contrast.")
16
+ p.add_argument("--transparent", action="store_true")
17
+ p.add_argument("--cl", default=DEFAULT["correction_level"], choices=CORRECTION_LEVELS, help="Error correction level.")
18
+ p.add_argument("--pixels", default=DEFAULT["pixels_style"], choices=PIXELS_STYLES)
19
+ p.add_argument("--eyes", default=DEFAULT["eyes_style"], choices=EYE_STYLES)
20
+ p.add_argument("--pupils", default=DEFAULT["pupils_style"], choices=PUPIL_STYLES)
21
+ p.add_argument("--corners", default=DEFAULT["corners_style"], choices=CORNERS_STYLES)
22
+ p.add_argument("--border", type=int, default=DEFAULT["border"])
23
+ p.add_argument("--frame", default=DEFAULT["frame"], choices=FRAME_STYLES)
24
+ p.add_argument("--frame-color", default=DEFAULT["frame_color"])
25
+ p.add_argument("--frame-text", default=DEFAULT["frame_text"])
26
+ p.add_argument("--frame-padding", type=int, default=DEFAULT["frame_padding"])
27
+ p.add_argument("--png-width", type=int, default=1000, help="PNG output width in px (ignored for SVG).")
28
+ return p
29
+
30
+
31
+ def main(argv: list[str] | None = None) -> int:
32
+ args = _build_parser().parse_args(argv)
33
+
34
+ settings = {
35
+ **DEFAULT,
36
+ "text": args.url,
37
+ "color": args.bg,
38
+ "fg_color": args.fg,
39
+ "transparent": args.transparent,
40
+ "correction_level": args.cl,
41
+ "pixels_style": args.pixels,
42
+ "eyes_style": args.eyes,
43
+ "pupils_style": args.pupils,
44
+ "corners_style": args.corners,
45
+ "border": args.border,
46
+ "frame": args.frame,
47
+ "frame_color": args.frame_color,
48
+ "frame_text": args.frame_text,
49
+ "frame_padding": args.frame_padding,
50
+ }
51
+
52
+ qr_svg = build_svg(settings)
53
+ framed = build_framed_svg(qr_svg, settings)
54
+ svg = framed["svg"]
55
+
56
+ if args.output.lower().endswith(".png"):
57
+ from .png import svg_to_png
58
+
59
+ with open(args.output, "wb") as f:
60
+ f.write(svg_to_png(svg, width=args.png_width))
61
+ else:
62
+ with open(args.output, "w") as f:
63
+ f.write(svg)
64
+
65
+ print(f"Wrote {args.output}", file=sys.stderr)
66
+ return 0
67
+
68
+
69
+ if __name__ == "__main__":
70
+ raise SystemExit(main())
@@ -0,0 +1,10 @@
1
+ """Contrast color helper — mirrors src/utils/color.js."""
2
+
3
+
4
+ def get_contrast_color(hex_color: str) -> str:
5
+ """Pick black or white for best contrast against a hex background color."""
6
+ r = int(hex_color[1:3], 16)
7
+ g = int(hex_color[3:5], 16)
8
+ b = int(hex_color[5:7], 16)
9
+ luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
10
+ return "#000000" if luminance > 0.45 else "#ffffff"
@@ -0,0 +1,45 @@
1
+ """Style enums and default settings — mirrors src/lib/defaults.js."""
2
+
3
+ PIXELS_STYLES = ["square", "rounded", "dots", "blob", "bars-h", "bars-v", "squircle"]
4
+ EYE_STYLES = ["square", "rounded", "circle", "leaf", "squircle"]
5
+ PUPIL_STYLES = ["square", "rounded", "circle", "squircle"]
6
+ CORNERS_STYLES = ["none", "round", "extra-round", "squircle"]
7
+ FRAME_STYLES = ["none", "perimeter", "scan-me", "scan"]
8
+ CORRECTION_LEVELS = ["L", "M", "Q", "H"]
9
+
10
+ DEFAULT = {
11
+ "text": "https://qrcody.trtmn.io",
12
+ "correction_level": "Q",
13
+ "color": "#ffffff",
14
+ "fg_color": "#000000",
15
+ "transparent": False,
16
+ "corners_style": "squircle",
17
+ "pixels_style": "squircle",
18
+ "eyes_style": "squircle",
19
+ "pupils_style": "squircle",
20
+ # 0-100: controls the superellipse exponent (corner curvature).
21
+ "corners_squircle_radius": 50,
22
+ "pixels_squircle_radius": 50,
23
+ "eyes_squircle_radius": 50,
24
+ "pupils_squircle_radius": 50,
25
+ "border": 60,
26
+ "logo": None,
27
+ "logo_scale": 0.2,
28
+ "fg_gradient": False,
29
+ "fg_gradient_from": "#0b61cb",
30
+ "fg_gradient_to": "#00a7bd",
31
+ "fg_gradient_angle": 45,
32
+ "bg_gradient": False,
33
+ "bg_gradient_from": "#0b61cb",
34
+ "bg_gradient_to": "#00a7bd",
35
+ "bg_gradient_angle": 45,
36
+ "frame_gradient": False,
37
+ "frame_gradient_from": "#0b61cb",
38
+ "frame_gradient_to": "#00a7bd",
39
+ "frame_gradient_angle": 45,
40
+ "frame": "perimeter",
41
+ "frame_color": "#000000",
42
+ "frame_text": "",
43
+ "frame_padding": 10,
44
+ "frame_text_size": 16,
45
+ }
@@ -0,0 +1,27 @@
1
+ """QR matrix generation — wraps `segno` (Python equivalent of the JS `qrcode-generator`)."""
2
+
3
+ import segno
4
+
5
+ _ERROR_LEVELS = {"L": "l", "M": "m", "Q": "q", "H": "h"}
6
+
7
+
8
+ class QRMatrix:
9
+ """A generated QR code's module grid, with the same `is_dark`/`module_count` shape as buildSVG.js expects."""
10
+
11
+ def __init__(self, text: str, correction_level: str = "Q") -> None:
12
+ level = _ERROR_LEVELS.get((correction_level or "Q").upper(), "q")
13
+ qr = segno.make(text or " ", micro=False, error=level)
14
+ self._rows = [bytearray(row) for row in qr.matrix]
15
+ self.module_count = len(self._rows)
16
+
17
+ def is_dark(self, r: int, c: int) -> bool:
18
+ if r < 0 or c < 0 or r >= self.module_count or c >= self.module_count:
19
+ return False
20
+ return bool(self._rows[r][c] & 0x1)
21
+
22
+
23
+ def make_matrix(text: str, correction_level: str = "Q") -> QRMatrix:
24
+ try:
25
+ return QRMatrix(text, correction_level)
26
+ except Exception:
27
+ return QRMatrix("https://example.com", "H")
@@ -0,0 +1,11 @@
1
+ """Optional SVG -> PNG rasterization. Requires the `png` extra (cairosvg)."""
2
+
3
+
4
+ def svg_to_png(svg: str, width: int | None = None) -> bytes:
5
+ try:
6
+ import cairosvg
7
+ except ImportError as e:
8
+ raise ImportError(
9
+ "PNG export requires the 'png' extra: pip install 'qrcody-render[png]'"
10
+ ) from e
11
+ return cairosvg.svg2png(bytestring=svg.encode("utf-8"), output_width=width)
File without changes
@@ -0,0 +1,42 @@
1
+ """Superellipse (squircle) path builder — mirrors src/lib/squircle.js.
2
+
3
+ The "radius" parameter (0-100) controls the superellipse exponent `n` directly:
4
+ radius 0 -> n = 3 (most rounded, closest to a circle)
5
+ radius 50 -> n = 6 (balanced squircle)
6
+ radius 100 -> n = 10 (tightest corner curvature, closest to a square)
7
+
8
+ This changes how sharp/soft the corners are, not the size of the squircle
9
+ within its bounding box (the path always fills the box).
10
+ """
11
+
12
+ import math
13
+
14
+ _N_MIN = 3
15
+ _N_MAX = 10
16
+
17
+
18
+ def _radius_to_n(radius: float) -> float:
19
+ t = max(0.0, min(100.0, radius)) / 100
20
+ return _N_MIN + t * (_N_MAX - _N_MIN)
21
+
22
+
23
+ def squircle_path(x: float, y: float, w: float, h: float, radius: float = 50) -> str:
24
+ n = _radius_to_n(radius)
25
+ cx = x + w / 2
26
+ cy = y + h / 2
27
+ rx = w / 2
28
+ ry = h / 2
29
+ # More steps at lower n (rounder = more arc length per corner), fewer
30
+ # at higher n (square-ish = arc length dominated by sides).
31
+ steps = 128 if n < 5 else 96
32
+ exp = 2 / n
33
+ parts = []
34
+ for i in range(steps + 1):
35
+ t = (i / steps) * math.pi * 2
36
+ cos_t, sin_t = math.cos(t), math.sin(t)
37
+ px = math.copysign(abs(cos_t) ** exp, cos_t) * rx
38
+ py = math.copysign(abs(sin_t) ** exp, sin_t) * ry
39
+ xi, yi = cx + px, cy + py
40
+ parts.append(f"M {xi:.2f},{yi:.2f}" if i == 0 else f"L {xi:.2f},{yi:.2f}")
41
+ parts.append("Z")
42
+ return " ".join(parts)
@@ -0,0 +1,447 @@
1
+ """QR SVG rendering core — mirrors src/lib/buildSVG.js.
2
+
3
+ Pure functions: a settings dict in, an SVG string out. No I/O, no rasterization.
4
+ """
5
+
6
+ import math
7
+ import re
8
+
9
+ from .colors import get_contrast_color
10
+ from .matrix import make_matrix
11
+ from .squircle import squircle_path
12
+ from .svg_paths import per_corner_path
13
+ from .xml_utils import escape_xml
14
+
15
+ CORNER_RX = {"none": 0, "round": 30, "extra-round": 70}
16
+
17
+ _QR_SIZE = 1000
18
+
19
+
20
+ def _rect_path(x: float, y: float, w: float, h: float) -> str:
21
+ return f"M {x},{y} H {x + w} V {y + h} H {x} Z"
22
+
23
+
24
+ def _rounded_path(x: float, y: float, w: float, h: float, r: float) -> str:
25
+ r = min(r, w / 2, h / 2)
26
+ return (
27
+ f"M {x + r},{y} L {x + w - r},{y} Q {x + w},{y} {x + w},{y + r} "
28
+ f"L {x + w},{y + h - r} Q {x + w},{y + h} {x + w - r},{y + h} "
29
+ f"L {x + r},{y + h} Q {x},{y + h} {x},{y + h - r} "
30
+ f"L {x},{y + r} Q {x},{y} {x + r},{y} Z"
31
+ )
32
+
33
+
34
+ def _leaf_path(x: float, y: float, w: float, h: float, r: float, pointed: str) -> str:
35
+ r_b = min(r, w / 2, h / 2)
36
+ r_t = r_b * 0.1
37
+ if pointed == "br":
38
+ return (
39
+ f"M {x + r_b},{y} L {x + w - r_b},{y} Q {x + w},{y} {x + w},{y + r_b} "
40
+ f"L {x + w},{y + h - r_t} Q {x + w},{y + h} {x + w - r_t},{y + h} "
41
+ f"L {x + r_b},{y + h} Q {x},{y + h} {x},{y + h - r_b} "
42
+ f"L {x},{y + r_b} Q {x},{y} {x + r_b},{y} Z"
43
+ )
44
+ if pointed == "bl":
45
+ return (
46
+ f"M {x + r_b},{y} L {x + w - r_b},{y} Q {x + w},{y} {x + w},{y + r_b} "
47
+ f"L {x + w},{y + h - r_b} Q {x + w},{y + h} {x + w - r_b},{y + h} "
48
+ f"L {x + r_t},{y + h} Q {x},{y + h} {x},{y + h - r_t} "
49
+ f"L {x},{y + r_b} Q {x},{y} {x + r_b},{y} Z"
50
+ )
51
+ if pointed == "tr":
52
+ return (
53
+ f"M {x + r_b},{y} L {x + w - r_t},{y} Q {x + w},{y} {x + w},{y + r_t} "
54
+ f"L {x + w},{y + h - r_b} Q {x + w},{y + h} {x + w - r_b},{y + h} "
55
+ f"L {x + r_b},{y + h} Q {x},{y + h} {x},{y + h - r_b} "
56
+ f"L {x},{y + r_b} Q {x},{y} {x + r_b},{y} Z"
57
+ )
58
+ return _rounded_path(x, y, w, h, r_b)
59
+
60
+
61
+ # ── Finder pattern rendering (eyes + pupils) ─────────────────────────────────
62
+
63
+
64
+ def _render_pupil(pupils_style, px2, py2, inner3, grad_fill, pupils_squircle_radius):
65
+ if pupils_style == "circle":
66
+ pcx = px2 + inner3 / 2
67
+ pcy = py2 + inner3 / 2
68
+ return f'<circle cx="{pcx}" cy="{pcy}" r="{inner3 * 0.52}" fill="{grad_fill}"/>'
69
+ if pupils_style == "rounded":
70
+ return f'<rect x="{px2}" y="{py2}" width="{inner3}" height="{inner3}" rx="{inner3 * 0.2}" fill="{grad_fill}"/>'
71
+ if pupils_style == "squircle":
72
+ d = squircle_path(px2, py2, inner3, inner3, pupils_squircle_radius if pupils_squircle_radius is not None else 50)
73
+ return f'<path d="{d}" fill="{grad_fill}"/>'
74
+ return f'<rect x="{px2}" y="{py2}" width="{inner3}" height="{inner3}" fill="{grad_fill}"/>'
75
+
76
+
77
+ def _render_eye(eyes_style, pupils_style, ex, ey, ms, pointed, grad_fill, grad_stroke, eyes_squircle_radius, pupils_squircle_radius):
78
+ outer = 7 * ms
79
+ inner3 = 3 * ms
80
+ sep = 5 * ms
81
+ outer_r = outer * 0.28
82
+ r_sep = max(0, outer_r - ms * 0.55)
83
+ cx = ex + outer / 2
84
+ cy = ey + outer / 2
85
+ px2 = ex + 2 * ms
86
+ py2 = ey + 2 * ms
87
+
88
+ pupil = _render_pupil(pupils_style, px2, py2, inner3, grad_fill, pupils_squircle_radius)
89
+
90
+ if eyes_style == "circle":
91
+ ring_r = outer / 2 - ms / 2
92
+ return f'<circle cx="{cx}" cy="{cy}" r="{ring_r}" fill="none" stroke="{grad_stroke}" stroke-width="{ms}"/>{pupil}'
93
+
94
+ if eyes_style == "leaf":
95
+ outer_d = _leaf_path(ex, ey, outer, outer, outer_r, pointed)
96
+ elif eyes_style == "squircle":
97
+ outer_d = squircle_path(ex, ey, outer, outer, eyes_squircle_radius if eyes_squircle_radius is not None else 50)
98
+ elif eyes_style == "rounded":
99
+ outer_d = _rounded_path(ex, ey, outer, outer, outer_r)
100
+ else:
101
+ outer_d = _rect_path(ex, ey, outer, outer)
102
+
103
+ if eyes_style == "square" or r_sep <= 0:
104
+ sep_d = _rect_path(ex + ms, ey + ms, sep, sep)
105
+ elif eyes_style == "squircle":
106
+ sep_d = squircle_path(ex + ms, ey + ms, sep, sep, eyes_squircle_radius if eyes_squircle_radius is not None else 50)
107
+ else:
108
+ sep_d = _rounded_path(ex + ms, ey + ms, sep, sep, r_sep)
109
+
110
+ return f'<path d="{outer_d} {sep_d}" fill="{grad_fill}" fill-rule="evenodd"/>{pupil}'
111
+
112
+
113
+ def _render_finder_patterns(qr, module_count, ms, pad, in_finder, get_finder_origin, eyes_style, pupils_style, grad_fill, grad_stroke, eyes_squircle_radius, pupils_squircle_radius):
114
+ out = []
115
+ drawn = set()
116
+ for r in range(module_count):
117
+ for c in range(module_count):
118
+ if not qr.is_dark(r, c) or not in_finder(r, c):
119
+ continue
120
+ origin = get_finder_origin(r, c)
121
+ key = (origin["row"], origin["col"])
122
+ if key not in drawn:
123
+ drawn.add(key)
124
+ ex = pad + origin["col"] * ms
125
+ ey = pad + origin["row"] * ms
126
+ out.append(_render_eye(eyes_style, pupils_style, ex, ey, ms, origin["pointed"], grad_fill, grad_stroke, eyes_squircle_radius, pupils_squircle_radius))
127
+ return "".join(out)
128
+
129
+
130
+ # ── Card background ──────────────────────────────────────────────────────────
131
+
132
+
133
+ def _render_background(size, corners_style, bg_fill, transparent, corners_squircle_radius):
134
+ if transparent:
135
+ return ""
136
+ if corners_style == "squircle":
137
+ d = squircle_path(0, 0, size, size, corners_squircle_radius if corners_squircle_radius is not None else 50)
138
+ return f'<path d="{d}" fill="{bg_fill}"/>'
139
+ bg_rx = CORNER_RX.get(corners_style, 0)
140
+ if bg_rx > 0:
141
+ return f'<rect width="{size}" height="{size}" rx="{bg_rx}" fill="{bg_fill}"/>'
142
+ return f'<rect width="{size}" height="{size}" fill="{bg_fill}"/>'
143
+
144
+
145
+ # ── QR data modules (all pixel styles) ───────────────────────────────────────
146
+
147
+
148
+ def _render_data_modules(dark_set, has_dark, module_count, ms, pad, pixels_style, grad_fill, pixels_squircle_radius):
149
+ out = []
150
+
151
+ if pixels_style == "bars-h":
152
+ for r in range(module_count):
153
+ start = -1
154
+ for c in range(module_count + 1):
155
+ if c < module_count and has_dark(r, c):
156
+ if start == -1:
157
+ start = c
158
+ elif start != -1:
159
+ x = pad + start * ms
160
+ w = (c - start) * ms
161
+ bh = ms * 0.62
162
+ by = pad + r * ms + (ms - bh) / 2
163
+ out.append(f'<rect x="{x}" y="{by}" width="{w}" height="{bh}" rx="{bh * 0.38}" fill="{grad_fill}"/>')
164
+ start = -1
165
+ elif pixels_style == "bars-v":
166
+ for c in range(module_count):
167
+ start = -1
168
+ for r in range(module_count + 1):
169
+ if r < module_count and has_dark(r, c):
170
+ if start == -1:
171
+ start = r
172
+ elif start != -1:
173
+ y = pad + start * ms
174
+ h = (r - start) * ms
175
+ bw = ms * 0.62
176
+ bx = pad + c * ms + (ms - bw) / 2
177
+ out.append(f'<rect x="{bx}" y="{y}" width="{bw}" height="{h}" rx="{bw * 0.38}" fill="{grad_fill}"/>')
178
+ start = -1
179
+ elif pixels_style == "blob":
180
+ blob_r = ms * 0.38
181
+ d_parts = []
182
+ for key in dark_set:
183
+ r, c = key // 1000, key % 1000
184
+ x = pad + c * ms - 0.25
185
+ y = pad + r * ms - 0.25
186
+ w = ms + 0.5
187
+ h = ms + 0.5
188
+ top = has_dark(r - 1, c)
189
+ bottom = has_dark(r + 1, c)
190
+ left = has_dark(r, c - 1)
191
+ right = has_dark(r, c + 1)
192
+ tl = 0 if (top or left) else blob_r
193
+ tr = 0 if (top or right) else blob_r
194
+ br = 0 if (bottom or right) else blob_r
195
+ bl = 0 if (bottom or left) else blob_r
196
+ d_parts.append(per_corner_path(x, y, w, h, tl, tr, br, bl))
197
+ if d_parts:
198
+ out.append(f'<path d="{" ".join(d_parts)}" fill="{grad_fill}"/>')
199
+ elif pixels_style == "dots":
200
+ for key in dark_set:
201
+ r, c = key // 1000, key % 1000
202
+ out.append(f'<circle cx="{pad + c * ms + ms / 2}" cy="{pad + r * ms + ms / 2}" r="{ms * 0.45}" fill="{grad_fill}"/>')
203
+ elif pixels_style == "rounded":
204
+ for key in dark_set:
205
+ r, c = key // 1000, key % 1000
206
+ sz = ms * 0.82
207
+ off = (ms - sz) / 2
208
+ out.append(f'<rect x="{pad + c * ms + off}" y="{pad + r * ms + off}" width="{sz}" height="{sz}" rx="{sz * 0.28}" fill="{grad_fill}"/>')
209
+ elif pixels_style == "squircle":
210
+ for key in dark_set:
211
+ r, c = key // 1000, key % 1000
212
+ sz = ms * 0.86
213
+ off = (ms - sz) / 2
214
+ d = squircle_path(pad + c * ms + off, pad + r * ms + off, sz, sz, pixels_squircle_radius if pixels_squircle_radius is not None else 50)
215
+ out.append(f'<path d="{d}" fill="{grad_fill}"/>')
216
+ else:
217
+ for r in range(module_count):
218
+ start = -1
219
+ for c in range(module_count + 1):
220
+ if c < module_count and has_dark(r, c):
221
+ if start == -1:
222
+ start = c
223
+ elif start != -1:
224
+ out.append(f'<rect x="{pad + start * ms - 1}" y="{pad + r * ms - 1}" width="{(c - start) * ms + 2}" height="{ms + 2}" fill="{grad_fill}"/>')
225
+ start = -1
226
+
227
+ return "".join(out)
228
+
229
+
230
+ # ── Center logo overlay ──────────────────────────────────────────────────────
231
+
232
+
233
+ def _render_logo(logo, draw_size, scale, center_x, center_y, clear_radius, bg_fill):
234
+ if not logo:
235
+ return ""
236
+ logo_size = draw_size * scale
237
+ logo_x = center_x - logo_size / 2
238
+ logo_y = center_y - logo_size / 2
239
+ return (
240
+ f'<circle cx="{center_x}" cy="{center_y}" r="{clear_radius}" fill="{bg_fill}"/>'
241
+ f'<image x="{logo_x}" y="{logo_y}" width="{logo_size}" height="{logo_size}" preserveAspectRatio="xMidYMid meet" href="{logo}"/>'
242
+ )
243
+
244
+
245
+ def _gradient_def(gradient_id: str, angle: float, from_color: str, to_color: str) -> str:
246
+ rad = math.radians(angle)
247
+ x1 = f"{50 - math.cos(rad) * 50}%"
248
+ y1 = f"{50 - math.sin(rad) * 50}%"
249
+ x2 = f"{50 + math.cos(rad) * 50}%"
250
+ y2 = f"{50 + math.sin(rad) * 50}%"
251
+ frm = from_color or "#0b61cb"
252
+ to = to_color or "#00a7bd"
253
+ return (
254
+ f'<linearGradient id="{gradient_id}" x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" '
255
+ f'gradientUnits="userSpaceOnUse"><stop offset="0%" stop-color="{frm}"/>'
256
+ f'<stop offset="100%" stop-color="{to}"/></linearGradient>'
257
+ )
258
+
259
+
260
+ def build_svg(settings: dict) -> str:
261
+ text = settings.get("text")
262
+ correction_level = settings.get("correction_level")
263
+ color = settings.get("color")
264
+ fg_color = settings.get("fg_color")
265
+ border = settings.get("border")
266
+ pixels_style = settings.get("pixels_style")
267
+ eyes_style = settings.get("eyes_style")
268
+ pupils_style = settings.get("pupils_style")
269
+ corners_style = settings.get("corners_style")
270
+ transparent = settings.get("transparent")
271
+ logo = settings.get("logo")
272
+ logo_scale = settings.get("logo_scale")
273
+ fg_gradient = settings.get("fg_gradient")
274
+ fg_gradient_from = settings.get("fg_gradient_from")
275
+ fg_gradient_to = settings.get("fg_gradient_to")
276
+ fg_gradient_angle = settings.get("fg_gradient_angle")
277
+ bg_gradient = settings.get("bg_gradient")
278
+ bg_gradient_from = settings.get("bg_gradient_from")
279
+ bg_gradient_to = settings.get("bg_gradient_to")
280
+ bg_gradient_angle = settings.get("bg_gradient_angle")
281
+ corners_squircle_radius = settings.get("corners_squircle_radius")
282
+ pixels_squircle_radius = settings.get("pixels_squircle_radius")
283
+ eyes_squircle_radius = settings.get("eyes_squircle_radius")
284
+ pupils_squircle_radius = settings.get("pupils_squircle_radius")
285
+
286
+ background = "none" if transparent else color
287
+ fg = fg_color or get_contrast_color(color)
288
+ data_gradient = bool(fg_gradient)
289
+ grad_fill = "url(#dg)" if data_gradient else fg
290
+ grad_stroke = "url(#dg)" if data_gradient else fg
291
+ bg_grad_enabled = bool(bg_gradient) and not transparent
292
+ bg_fill = "url(#bgGrad)" if bg_grad_enabled else background
293
+
294
+ qr = make_matrix(text or " ", correction_level or "H")
295
+
296
+ module_count = qr.module_count
297
+ size = _QR_SIZE
298
+ pad = max(int(border) if border is not None else 0, 10)
299
+ draw_size = size - pad * 2
300
+ ms = draw_size / module_count
301
+
302
+ scale = logo_scale if logo_scale is not None else 0.2
303
+ clear_radius = draw_size * scale * 0.62 if logo else 0
304
+ center_x = size / 2
305
+ center_y = size / 2
306
+
307
+ def in_finder(r, c):
308
+ return (r < 7 and c < 7) or (r < 7 and c >= module_count - 7) or (r >= module_count - 7 and c < 7)
309
+
310
+ def get_finder_origin(r, c):
311
+ if r < 7 and c < 7:
312
+ return {"row": 0, "col": 0, "pointed": "br"}
313
+ if r < 7 and c >= module_count - 7:
314
+ return {"row": 0, "col": module_count - 7, "pointed": "bl"}
315
+ return {"row": module_count - 7, "col": 0, "pointed": "tr"}
316
+
317
+ dark_set = set()
318
+ for r in range(module_count):
319
+ for c in range(module_count):
320
+ if not qr.is_dark(r, c) or in_finder(r, c):
321
+ continue
322
+ if logo and clear_radius > 0:
323
+ mx = pad + c * ms + ms / 2
324
+ my = pad + r * ms + ms / 2
325
+ if math.sqrt((mx - center_x) ** 2 + (my - center_y) ** 2) < clear_radius:
326
+ continue
327
+ dark_set.add(r * 1000 + c)
328
+
329
+ def has_dark(r, c):
330
+ return 0 <= r < module_count and 0 <= c < module_count and (r * 1000 + c) in dark_set
331
+
332
+ els = [_render_background(size, corners_style, bg_fill, transparent, corners_squircle_radius)]
333
+
334
+ els.append(_render_finder_patterns(
335
+ qr, module_count, ms, pad, in_finder, get_finder_origin,
336
+ eyes_style, pupils_style, grad_fill, grad_stroke, eyes_squircle_radius, pupils_squircle_radius,
337
+ ))
338
+
339
+ els.append(_render_data_modules(dark_set, has_dark, module_count, ms, pad, pixels_style, grad_fill, pixels_squircle_radius))
340
+
341
+ els.append(_render_logo(logo, draw_size, scale, center_x, center_y, clear_radius, bg_fill))
342
+
343
+ bg_grad_def = _gradient_def("bgGrad", bg_gradient_angle if bg_gradient_angle is not None else 45, bg_gradient_from, bg_gradient_to) if bg_grad_enabled else ""
344
+ if data_gradient:
345
+ dg_def = _gradient_def("dg", fg_gradient_angle if fg_gradient_angle is not None else 45, fg_gradient_from, fg_gradient_to)
346
+ defs = f"<defs>{dg_def}{bg_grad_def}</defs>"
347
+ elif bg_grad_enabled:
348
+ defs = f"<defs>{bg_grad_def}</defs>"
349
+ else:
350
+ defs = ""
351
+
352
+ return f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {size} {size}">{defs}{"".join(els)}</svg>'
353
+
354
+
355
+ _SVG_OPEN_RE = re.compile(r"^<svg[^>]*>")
356
+ _SVG_CLOSE_RE = re.compile(r"</svg>$")
357
+
358
+
359
+ def build_framed_svg(qr_svg: str, settings: dict) -> dict:
360
+ frame = settings.get("frame") or "none"
361
+ color = settings.get("color") or "#ffffff"
362
+ transparent = settings.get("transparent")
363
+ bg = "none" if transparent else color
364
+ fg = settings.get("fg_color") or get_contrast_color(color)
365
+ frame_gradient = bool(settings.get("frame_gradient"))
366
+ frame_fill = "url(#frameGrad)" if frame_gradient else (settings.get("frame_color") or bg)
367
+ frame_bg = frame_fill
368
+ # Label text must contrast against the frame's own background, not the QR
369
+ # body's — they're independently configurable and often differ.
370
+ if frame_gradient:
371
+ fc = get_contrast_color(settings.get("frame_gradient_from") or settings.get("frame_gradient_to") or "#000000")
372
+ else:
373
+ fc = fg if frame_bg == "none" else get_contrast_color(frame_bg)
374
+
375
+ inner = _SVG_CLOSE_RE.sub("", _SVG_OPEN_RE.sub("", qr_svg))
376
+ s = _QR_SIZE
377
+ display_fp = settings.get("frame_padding")
378
+ display_fp = 24 if display_fp is None else display_fp
379
+ sp = round(display_fp * (s / 360))
380
+ corner_rx = CORNER_RX.get(settings.get("corners_style") or "none", 0)
381
+ corners_style = settings.get("corners_style") or "none"
382
+ corners_squircle_radius = settings.get("corners_squircle_radius")
383
+
384
+ def squircle_clip(w, h, clip_id):
385
+ d = squircle_path(0, 0, w, h, corners_squircle_radius if corners_squircle_radius is not None else 50)
386
+ return f'<clipPath id="{clip_id}"><path d="{d}" /></clipPath>'
387
+
388
+ def mk_svg(w, h, body):
389
+ ns = 'xmlns="http://www.w3.org/2000/svg"'
390
+ frame_grad_def = ""
391
+ if frame_gradient:
392
+ angle = settings.get("frame_gradient_angle")
393
+ angle = 45 if angle is None else angle
394
+ frame_grad_def = _gradient_def("frameGrad", angle, settings.get("frame_gradient_from"), settings.get("frame_gradient_to"))
395
+ if corners_style == "squircle":
396
+ return f'<svg {ns} width="{w}" height="{h}" viewBox="0 0 {w} {h}"><defs>{squircle_clip(w, h, "cr")}{frame_grad_def}</defs><g clip-path="url(#cr)">{body}</g></svg>'
397
+ rx = round(corner_rx * (w / _QR_SIZE)) if corner_rx > 0 else 0
398
+ if rx > 0:
399
+ return f'<svg {ns} width="{w}" height="{h}" viewBox="0 0 {w} {h}"><defs><clipPath id="cr"><rect width="{w}" height="{h}" rx="{rx}"/></clipPath>{frame_grad_def}</defs><g clip-path="url(#cr)">{body}</g></svg>'
400
+ return f'<svg {ns} width="{w}" height="{h}" viewBox="0 0 {w} {h}"><defs>{frame_grad_def}</defs>{body}</svg>'
401
+
402
+ def bg_rect(w, h):
403
+ return f'<rect width="{w}" height="{h}" fill="{frame_bg}"/>' if frame_bg != "none" else ""
404
+
405
+ def bg_shape(w, h):
406
+ if frame_bg == "none":
407
+ return ""
408
+ if corners_style == "squircle":
409
+ d = squircle_path(0, 0, w, h, corners_squircle_radius if corners_squircle_radius is not None else 50)
410
+ return f'<path d="{d}" fill="{frame_bg}"/>'
411
+ return f'<rect width="{w}" height="{h}" fill="{frame_bg}"/>'
412
+
413
+ if frame == "none":
414
+ return {"svg": mk_svg(s, s, inner), "w": s, "h": s}
415
+
416
+ if frame == "perimeter":
417
+ w = h = s + sp * 2
418
+ body = f'{bg_shape(w, h)}<g transform="translate({sp},{sp})">{inner}</g>'
419
+ return {"svg": mk_svg(w, h, body), "w": w, "h": h}
420
+
421
+ frame_text_size = settings.get("frame_text_size")
422
+ frame_text_size = 16 if frame_text_size is None else frame_text_size
423
+ fs = round(frame_text_size * (s / 360))
424
+ # Band must be tall enough for the font regardless of frame padding.
425
+ band_h = max(round(sp * 3.2), round(fs * 1.6))
426
+
427
+ def make_icon_text_row(w, b_h, band_y, label):
428
+ cy = band_y + b_h / 2
429
+ return f'<text x="{w / 2}" y="{cy + fs * 0.38}" text-anchor="middle" font-family="system-ui,-apple-system,sans-serif" font-size="{fs}" font-weight="400" fill="{fc}">{label}</text>'
430
+
431
+ if frame == "scan-me":
432
+ hh = band_h
433
+ w, h = s + sp * 2, s + hh + sp
434
+ label = escape_xml(settings.get("frame_text") or "Scan me")
435
+ row = make_icon_text_row(w, hh, 0, label)
436
+ body = f'{bg_rect(w, h)}{row}<g transform="translate({sp},{hh})">{inner}</g>'
437
+ return {"svg": mk_svg(w, h, body), "w": w, "h": h}
438
+
439
+ if frame == "scan":
440
+ fh = band_h
441
+ w, h = s + sp * 2, s + sp + fh
442
+ label = escape_xml(settings.get("frame_text") or "Scan")
443
+ row = make_icon_text_row(w, fh, s + sp, label)
444
+ body = f'{bg_rect(w, h)}<g transform="translate({sp},{sp})">{inner}</g>{row}'
445
+ return {"svg": mk_svg(w, h, body), "w": w, "h": h}
446
+
447
+ return {"svg": mk_svg(s, s, inner), "w": s, "h": s}
@@ -0,0 +1,19 @@
1
+ """Per-corner rounded rect path builder — mirrors src/utils/svgPaths.js."""
2
+
3
+
4
+ def per_corner_path(x: float, y: float, w: float, h: float, tl: float, tr: float, br: float, bl: float) -> str:
5
+ """Build an SVG path `d` string for a rect with independently controllable corner radii.
6
+
7
+ A radius of 0 for a given corner renders it sharp instead of curved.
8
+ """
9
+ p = [f"M {x + tl},{y}"]
10
+ p.append(f"L {x + w - tr},{y}")
11
+ p.append(f"Q {x + w},{y} {x + w},{y + tr}" if tr > 0 else f"L {x + w},{y}")
12
+ p.append(f"L {x + w},{y + h - br}")
13
+ p.append(f"Q {x + w},{y + h} {x + w - br},{y + h}" if br > 0 else f"L {x + w},{y + h}")
14
+ p.append(f"L {x + bl},{y + h}")
15
+ p.append(f"Q {x},{y + h} {x},{y + h - bl}" if bl > 0 else f"L {x},{y + h}")
16
+ p.append(f"L {x},{y + tl}")
17
+ p.append(f"Q {x},{y} {x + tl},{y}" if tl > 0 else f"L {x},{y}")
18
+ p.append("Z")
19
+ return " ".join(p)
@@ -0,0 +1,13 @@
1
+ """XML escaping — mirrors src/utils/xml.js."""
2
+
3
+
4
+ def escape_xml(s: object) -> str:
5
+ """Escape XML/HTML-special characters for safe interpolation into SVG markup."""
6
+ return (
7
+ str(s)
8
+ .replace("&", "&amp;")
9
+ .replace("<", "&lt;")
10
+ .replace(">", "&gt;")
11
+ .replace('"', "&quot;")
12
+ .replace("'", "&apos;")
13
+ )