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 +21 -0
- qrcody-1.8.1/PKG-INFO +116 -0
- qrcody-1.8.1/README.md +102 -0
- qrcody-1.8.1/pyproject.toml +30 -0
- qrcody-1.8.1/pyproject.toml.orig +31 -0
- qrcody-1.8.1/src/qrcody/__init__.py +16 -0
- qrcody-1.8.1/src/qrcody/cli.py +70 -0
- qrcody-1.8.1/src/qrcody/colors.py +10 -0
- qrcody-1.8.1/src/qrcody/defaults.py +45 -0
- qrcody-1.8.1/src/qrcody/matrix.py +27 -0
- qrcody-1.8.1/src/qrcody/png.py +11 -0
- qrcody-1.8.1/src/qrcody/py.typed +0 -0
- qrcody-1.8.1/src/qrcody/squircle.py +42 -0
- qrcody-1.8.1/src/qrcody/svg.py +447 -0
- qrcody-1.8.1/src/qrcody/svg_paths.py +19 -0
- qrcody-1.8.1/src/qrcody/xml_utils.py +13 -0
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("&", "&")
|
|
9
|
+
.replace("<", "<")
|
|
10
|
+
.replace(">", ">")
|
|
11
|
+
.replace('"', """)
|
|
12
|
+
.replace("'", "'")
|
|
13
|
+
)
|