qrforge 1.0.0__py3-none-any.whl

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.
qrforge/__init__.py ADDED
@@ -0,0 +1,51 @@
1
+ """qrforge -- generate and style QR codes with no dependencies.
2
+
3
+ import qrforge
4
+
5
+ qrforge.save("https://example.com", "hello.png", "neon", scale=12)
6
+
7
+ qr = qrforge.QR("hello", ec="h")
8
+ qr.use_style(module_shape="dot", fg=["#ff0080", "#7928ca"], logo="logo.png")
9
+ qr.save("hello.svg")
10
+
11
+ Everything (encoding, Reed-Solomon, rasterisation, the PNG codec) is written in
12
+ pure Python against the standard library. Pillow is used only when you ask for
13
+ a non-PNG format, a PIL image, or a non-PNG logo.
14
+ """
15
+
16
+ from .api import Artwork, QR, QRCode, make, png, save, svg, terminal
17
+ from .color import Color
18
+ from .exceptions import DataTooLong, QRForgeError, StyleError, UnsupportedFeature
19
+ from .gradient import Gradient, SolidFill
20
+ from .image import Pixmap
21
+ from .matrix import QRMatrix, build as build_matrix
22
+ from .shaders import Shader, expression, shader
23
+ from .shapes import Shape, custom as shape, register as register_shape
24
+ from .style import Style
25
+
26
+ __version__ = "1.0.0"
27
+
28
+ __all__ = [
29
+ "QR", "QRCode", "Artwork", "make", "save", "svg", "png", "terminal",
30
+ "Style", "Color", "Gradient", "SolidFill", "Pixmap", "QRMatrix", "build_matrix",
31
+ "Shader", "shader", "expression", "Shape", "shape", "register_shape",
32
+ "presets", "shapes", "shaders", "verify",
33
+ "QRForgeError", "DataTooLong", "StyleError", "UnsupportedFeature",
34
+ "__version__",
35
+ ]
36
+
37
+
38
+ def __getattr__(name):
39
+ """Lazily expose the heavier submodules (``qrforge.presets`` etc.)."""
40
+ if name in ("presets", "shapes", "shaders", "verify", "matrix", "encoder", "tables", "gf"):
41
+ from importlib import import_module
42
+ module = import_module(f".{name}", __name__)
43
+ globals()[name] = module
44
+ return module
45
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
46
+
47
+
48
+ def preset(name: str) -> Style:
49
+ """Shortcut for ``qrforge.presets.get(name)``."""
50
+ from .presets import get
51
+ return get(name)
qrforge/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Enable ``python -m qrforge``."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
qrforge/api.py ADDED
@@ -0,0 +1,338 @@
1
+ """The public API: :class:`QR`, :class:`Artwork` and the shortcut functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional, Tuple
6
+
7
+ from . import matrix as matrix_mod
8
+ from .exceptions import QRForgeError, UnsupportedFeature
9
+ from .image import Pixmap
10
+ from .style import Style, parse as parse_style
11
+
12
+ __all__ = ["QR", "QRCode", "Artwork", "make", "save", "svg", "png", "terminal"]
13
+
14
+
15
+ class Artwork:
16
+ """A rendered symbol: either vector markup or a pixel buffer."""
17
+
18
+ def __init__(self, kind: str, payload: Any, style: Style, matrix) -> None:
19
+ self.kind = kind # "svg" | "pixmap" | "text" | "pdf" | "bytes"
20
+ self.payload = payload
21
+ self.style = style
22
+ self.matrix = matrix
23
+
24
+ # ------------------------------------------------------------------ access
25
+ @property
26
+ def pixmap(self) -> Pixmap:
27
+ if self.kind != "pixmap":
28
+ raise QRForgeError(f"this artwork is {self.kind}, not a pixel buffer")
29
+ return self.payload
30
+
31
+ @property
32
+ def text(self) -> str:
33
+ if self.kind not in ("svg", "text"):
34
+ raise QRForgeError(f"this artwork is {self.kind}, not text")
35
+ return self.payload
36
+
37
+ @property
38
+ def width(self) -> int:
39
+ return self.pixmap.width if self.kind == "pixmap" else len(self.payload)
40
+
41
+ def __str__(self) -> str:
42
+ return self.payload if isinstance(self.payload, str) else repr(self.payload)
43
+
44
+ def __repr__(self) -> str:
45
+ if self.kind == "pixmap":
46
+ return f"<Artwork pixmap {self.payload.width}x{self.payload.height}>"
47
+ return f"<Artwork {self.kind} {len(self.payload)} bytes>"
48
+
49
+ # ------------------------------------------------------------------ output
50
+ def save(self, path: str) -> str:
51
+ """Write the artwork to ``path``; the format follows the extension."""
52
+ suffix = path.lower().rsplit(".", 1)[-1] if "." in path else "png"
53
+ if self.kind == "pixmap":
54
+ if suffix in ("png",):
55
+ with open(path, "wb") as handle:
56
+ handle.write(self.payload.to_png())
57
+ elif suffix in ("jpg", "jpeg", "webp", "bmp", "gif", "tiff"):
58
+ self.payload.save(path) # needs Pillow
59
+ elif suffix in ("svg",):
60
+ raise QRForgeError("cannot write a raster artwork as SVG; call .svg() instead")
61
+ else:
62
+ self.payload.save(path)
63
+ return path
64
+ data = self.payload
65
+ if isinstance(data, str):
66
+ data = data.encode("utf-8")
67
+ with open(path, "wb") as handle:
68
+ handle.write(data)
69
+ return path
70
+
71
+ def to_bytes(self) -> bytes:
72
+ if self.kind == "pixmap":
73
+ return self.payload.to_png()
74
+ data = self.payload
75
+ return data.encode("utf-8") if isinstance(data, str) else bytes(data)
76
+
77
+ def to_pil(self):
78
+ return self.pixmap.to_pil()
79
+
80
+ def to_data_uri(self) -> str:
81
+ import base64
82
+ if self.kind == "svg":
83
+ return "data:image/svg+xml;base64," + base64.b64encode(self.to_bytes()).decode()
84
+ return "data:image/png;base64," + base64.b64encode(self.to_bytes()).decode()
85
+
86
+ # --------------------------------------------------------- notebook magic
87
+ def _repr_png_(self): # pragma: no cover - Jupyter
88
+ return self.to_bytes() if self.kind == "pixmap" else None
89
+
90
+ def _repr_svg_(self): # pragma: no cover - Jupyter
91
+ return self.payload if self.kind == "svg" else None
92
+
93
+ def _repr_html_(self): # pragma: no cover - Jupyter
94
+ if self.kind == "svg":
95
+ return self.payload
96
+ return f'<img src="{self.to_data_uri()}"/>'
97
+
98
+
99
+ class QR:
100
+ """A QR code with its rendering style.
101
+
102
+ ::
103
+
104
+ qr = qrforge.QR("https://example.com", ec="h")
105
+ qr.style(module_shape="dot", fg=["#ff0080", "#7928ca"], logo="logo.png")
106
+ qr.save("poster.png", scale=16)
107
+ """
108
+
109
+ def __init__(self, data: Any, *, ec: str = "M", version: Optional[int] = None,
110
+ mask: Optional[int] = None, mode: str = "auto",
111
+ style: Any = None, **style_options: Any) -> None:
112
+ self.data = data
113
+ self.ec = ec.upper()
114
+ self.version = version
115
+ self.mask = mask
116
+ self.mode = mode
117
+ self._matrix = None
118
+ self._style = parse_style(style, **style_options)
119
+
120
+ # ---------------------------------------------------------------- encoding
121
+ @property
122
+ def matrix(self):
123
+ """The encoded :class:`~qrforge.matrix.QRMatrix` (built on first use)."""
124
+ if self._matrix is None:
125
+ self._matrix = matrix_mod.build(self.data, self.ec, self.version, self.mask, self.mode)
126
+ return self._matrix
127
+
128
+ @property
129
+ def size(self) -> int:
130
+ return self.matrix.size
131
+
132
+ @property
133
+ def style(self) -> Style:
134
+ return self._style
135
+
136
+ def encode(self, data: Any = None, **options: Any) -> "QR":
137
+ """Change the payload/encoding options and drop the cached matrix."""
138
+ if data is not None:
139
+ self.data = data
140
+ for key in ("ec", "version", "mask", "mode"):
141
+ if key in options:
142
+ setattr(self, key, options.pop(key))
143
+ if options:
144
+ raise QRForgeError(f"unknown encoding option(s): {', '.join(options)}")
145
+ self._matrix = None
146
+ return self
147
+
148
+ def styled(self, style: Any = None, **options: Any) -> "QR":
149
+ """Return a copy of this code with a different style."""
150
+ clone = QR(self.data, ec=self.ec, version=self.version, mask=self.mask, mode=self.mode)
151
+ clone._matrix = self._matrix
152
+ clone._style = self._resolve(style, options)
153
+ return clone
154
+
155
+ def use_style(self, style: Any = None, **options: Any) -> "QR":
156
+ """Change this code's style in place (chainable)."""
157
+ if isinstance(style, Style):
158
+ self._style = self._style.merged(style, **options)
159
+ elif isinstance(style, dict):
160
+ self._style = self._style.merged(**style, **options)
161
+ elif isinstance(style, str) and style:
162
+ from .presets import get as preset_get
163
+ self._style = preset_get(style).merged(self._style, **options)
164
+ elif options:
165
+ self._style = self._style.merged(**options)
166
+ return self
167
+
168
+ # ----------------------------------------------------------------- outputs
169
+ def render(self, style: Any = None, **options: Any) -> Artwork:
170
+ """Render to a pixel buffer (PNG-ready)."""
171
+ return self._artwork("pixmap", style, options)
172
+
173
+ def svg(self, style: Any = None, **options: Any) -> str:
174
+ """Render to an SVG document."""
175
+ return self._artwork("svg", style, options).payload
176
+
177
+ def png(self, style: Any = None, **options: Any) -> bytes:
178
+ """Render and encode as PNG bytes."""
179
+ return self._artwork("pixmap", style, options).payload.to_png()
180
+
181
+ def pdf(self, style: Any = None, **options: Any) -> bytes:
182
+ """Render to a single-page vector PDF."""
183
+ from .render.pdf import render
184
+ return render(self.matrix, self._resolve(style, options))
185
+
186
+ def terminal(self, style: Any = None, color: bool = True, **options: Any) -> str:
187
+ """Render as ANSI half-block text for a terminal."""
188
+ from .render.ansi import render
189
+ return render(self.matrix, self._resolve(style, options), color=color)
190
+
191
+ def print(self, style: Any = None, **options: Any) -> "QR":
192
+ print(self.terminal(style, **options))
193
+ return self
194
+
195
+ def ascii(self, dark: str = "██", light: str = " ") -> str:
196
+ """Plain text art of the symbol."""
197
+ return self.matrix.ascii(dark, light)
198
+
199
+ def to_pil(self, style: Any = None, **options: Any):
200
+ """Render and return a Pillow ``Image`` (requires Pillow)."""
201
+ return self.render(style, **options).to_pil()
202
+
203
+ def save(self, path: str, style: Any = None, **options: Any) -> str:
204
+ """Render and write to ``path``; format chosen by extension."""
205
+ suffix = path.lower().rsplit(".", 1)[-1] if "." in path else "png"
206
+ if suffix in ("svg", "html"):
207
+ artwork = self._artwork("svg", style, options)
208
+ payload = artwork.payload
209
+ if suffix == "html":
210
+ payload = _html_page(payload, str(self.data))
211
+ with open(path, "w", encoding="utf-8") as handle:
212
+ handle.write(payload)
213
+ return path
214
+ if suffix == "pdf":
215
+ with open(path, "wb") as handle:
216
+ handle.write(self.pdf(style, **options))
217
+ return path
218
+ if suffix in ("txt", "ansi"):
219
+ with open(path, "w", encoding="utf-8") as handle:
220
+ handle.write(self.terminal(style, color=suffix == "ansi", **options))
221
+ return path
222
+ return self.render(style, **options).save(path)
223
+
224
+ def show(self, style: Any = None, **options: Any) -> "QR": # pragma: no cover - interactive
225
+ """Open the rendered symbol in the system image viewer."""
226
+ import os
227
+ import tempfile
228
+ path = os.path.join(tempfile.gettempdir(), "qrforge.png")
229
+ self.render(style, **options).save(path)
230
+ try:
231
+ import webbrowser
232
+ webbrowser.open(f"file://{path}")
233
+ except Exception:
234
+ pass
235
+ return self
236
+
237
+ # ------------------------------------------------------------------- misc
238
+ def verify(self) -> str:
239
+ """Decode the symbol again and return the text (round-trip check)."""
240
+ from .verify import decode
241
+ return decode([list(row) for row in self.matrix.modules]).text
242
+
243
+ def info(self) -> dict:
244
+ matrix = self.matrix
245
+ return {
246
+ "data": str(self.data) if not isinstance(self.data, (bytes, bytearray)) else "<bytes>",
247
+ "version": matrix.version,
248
+ "level": matrix.level,
249
+ "mask": matrix.mask,
250
+ "size": matrix.size,
251
+ "modules": matrix.size ** 2,
252
+ "codewords": matrix.total_codewords(),
253
+ "dark_ratio": round(matrix.dark_ratio(), 4),
254
+ "penalty": matrix.penalty_score,
255
+ "segments": [(s.mode, len(s.text)) for s in matrix.segments],
256
+ }
257
+
258
+ def _resolve(self, style: Any, options: dict) -> Style:
259
+ if style is None:
260
+ return self._style.merged(**options) if options else self._style
261
+ if isinstance(style, Style):
262
+ return self._style.merged(style, **options)
263
+ if isinstance(style, dict):
264
+ return self._style.merged(**style, **options)
265
+ if isinstance(style, str):
266
+ from .presets import get as preset_get
267
+ return preset_get(style).merged(**options)
268
+ raise QRForgeError(f"cannot use {style!r} as a style")
269
+
270
+ def _artwork(self, kind: str, style: Any, options: dict) -> Artwork:
271
+ resolved = self._resolve(style, options)
272
+ matrix = self.matrix
273
+ if kind == "svg":
274
+ from .render.svg import render
275
+ return Artwork("svg", render(matrix, resolved), resolved, matrix)
276
+ from .render.raster import render as raster_render
277
+ return Artwork("pixmap", raster_render(matrix, resolved), resolved, matrix)
278
+
279
+ def __repr__(self) -> str:
280
+ return f"<QR {str(self.data)[:28]!r} ec={self.ec} style={self._style!r}>"
281
+
282
+
283
+ QRCode = QR # friendly alias
284
+
285
+
286
+ def _html_page(svg: str, title: str) -> str:
287
+ return ("<!doctype html><meta charset='utf-8'>"
288
+ f"<title>{title[:60]}</title>"
289
+ "<style>body{margin:0;display:grid;place-items:center;min-height:100vh;"
290
+ "background:#111;font:14px system-ui}"
291
+ "main{padding:32px;background:#fff;border-radius:16px;box-shadow:0 20px 60px #0008}"
292
+ "</style><main>" + svg + "</main>")
293
+
294
+
295
+ # ------------------------------------------------------------------- shortcuts
296
+ def make(data: Any, style: Any = None, **options: Any) -> Artwork:
297
+ """Render ``data`` in one call::
298
+
299
+ qrforge.make("hello", "dots", fg="#123").save("hello.png")
300
+ """
301
+ preset = style if isinstance(style, str) else None
302
+ extra = dict(style) if isinstance(style, dict) else {}
303
+ qr = QR(data)
304
+ qr._style = parse_style(preset, **{**extra, **options})
305
+ return qr.render()
306
+
307
+
308
+ def save(data: Any, path: str, style: Any = None, **options: Any) -> str:
309
+ """Render ``data`` straight to ``path``."""
310
+ preset = style if isinstance(style, str) else None
311
+ extra = dict(style) if isinstance(style, dict) else {}
312
+ qr = QR(data)
313
+ qr._style = parse_style(preset, **{**extra, **options})
314
+ return qr.save(path)
315
+
316
+
317
+ def svg(data: Any, style: Any = None, **options: Any) -> str:
318
+ preset = style if isinstance(style, str) else None
319
+ extra = dict(style) if isinstance(style, dict) else {}
320
+ qr = QR(data)
321
+ qr._style = parse_style(preset, **{**extra, **options})
322
+ return qr.svg()
323
+
324
+
325
+ def png(data: Any, style: Any = None, **options: Any) -> bytes:
326
+ preset = style if isinstance(style, str) else None
327
+ extra = dict(style) if isinstance(style, dict) else {}
328
+ qr = QR(data)
329
+ qr._style = parse_style(preset, **{**extra, **options})
330
+ return qr.png()
331
+
332
+
333
+ def terminal(data: Any, style: Any = None, **options: Any) -> str:
334
+ preset = style if isinstance(style, str) else None
335
+ extra = dict(style) if isinstance(style, dict) else {}
336
+ qr = QR(data)
337
+ qr._style = parse_style(preset, **{**extra, **options})
338
+ return qr.terminal()
qrforge/bits.py ADDED
@@ -0,0 +1,39 @@
1
+ """A tiny MSB-first bit buffer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Iterable
6
+
7
+
8
+ class BitBuffer:
9
+ __slots__ = ("_data", "length")
10
+
11
+ def __init__(self) -> None:
12
+ self._data = bytearray()
13
+ self.length = 0
14
+
15
+ def append(self, value: int, nbits: int) -> None:
16
+ if nbits < 0 or value >> nbits:
17
+ raise ValueError(f"cannot store {value} in {nbits} bits")
18
+ for i in range(nbits - 1, -1, -1):
19
+ bit = (value >> i) & 1
20
+ if not (self.length & 7):
21
+ self._data.append(0)
22
+ self._data[self.length >> 3] |= bit << (7 - (self.length & 7))
23
+ self.length += 1
24
+
25
+ def append_all(self, bits: Iterable[bool]) -> None:
26
+ for b in bits:
27
+ self.append(1 if b else 0, 1)
28
+
29
+ def bit(self, index: int) -> int:
30
+ return (self._data[index >> 3] >> (7 - (index & 7))) & 1
31
+
32
+ def to_bytes(self) -> bytes:
33
+ return bytes(self._data)
34
+
35
+ def __len__(self) -> int:
36
+ return self.length
37
+
38
+ def __repr__(self) -> str: # pragma: no cover - debugging helper
39
+ return "".join(str(self.bit(i)) for i in range(min(self.length, 64)))
qrforge/cli.py ADDED
@@ -0,0 +1,92 @@
1
+ """Command line interface: ``python -m qrforge``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from . import presets, shaders, shapes
9
+ from .api import QR
10
+
11
+
12
+ def build_parser() -> argparse.ArgumentParser:
13
+ parser = argparse.ArgumentParser(
14
+ prog="qrforge",
15
+ description="Generate and style QR codes from the command line.")
16
+ parser.add_argument("data", nargs="?", help="the text to encode")
17
+ parser.add_argument("-o", "--output", help="output file (.png/.svg/.pdf/.txt/.html)")
18
+ parser.add_argument("-p", "--preset", help="a named style; see --list-presets")
19
+ parser.add_argument("-e", "--ec", default="M", choices="LMQH", help="error correction")
20
+ parser.add_argument("-v", "--version", type=int, help="force QR version (1-40)")
21
+ parser.add_argument("-m", "--mask", type=int, help="force mask (0-7)")
22
+ parser.add_argument("-s", "--scale", type=int, default=10, help="pixels per module")
23
+ parser.add_argument("--shape", help="module shape; see --list-shapes")
24
+ parser.add_argument("--fg", help="foreground colour or comma separated gradient")
25
+ parser.add_argument("--bg", help="background colour (or 'none' for transparent)")
26
+ parser.add_argument("--logo", help="path to a logo image")
27
+ parser.add_argument("--shader", action="append", help="shader, repeatable; see --list-shaders")
28
+ parser.add_argument("--seed", type=int, help="seed for randomised effects")
29
+ parser.add_argument("--terminal", action="store_true", help="print to the terminal")
30
+ parser.add_argument("--info", action="store_true", help="show encoding info")
31
+ parser.add_argument("--list-presets", action="store_true")
32
+ parser.add_argument("--list-shapes", action="store_true")
33
+ parser.add_argument("--list-shaders", action="store_true")
34
+ parser.add_argument("--version-info", action="store_true", help="print the library version")
35
+ return parser
36
+
37
+
38
+ def main(argv=None) -> int:
39
+ args = build_parser().parse_args(argv)
40
+
41
+ if args.list_presets:
42
+ print(", ".join(presets.names()))
43
+ return 0
44
+ if args.list_shapes:
45
+ print(", ".join(shapes.names()))
46
+ return 0
47
+ if args.list_shaders:
48
+ print(", ".join(shaders.names()))
49
+ return 0
50
+ if args.version_info:
51
+ from . import __version__
52
+ print(__version__)
53
+ return 0
54
+
55
+ if not args.data:
56
+ build_parser().print_help()
57
+ return 1
58
+
59
+ options = {"ec": args.ec, "version": args.version, "mask": args.mask}
60
+ options = {k: v for k, v in options.items() if v is not None}
61
+ qr = QR(args.data, **options)
62
+
63
+ style = {}
64
+ if args.shape:
65
+ style["module_shape"] = args.shape.split(",") if "," in args.shape else args.shape
66
+ if args.fg:
67
+ style["fg"] = args.fg.split(",") if "," in args.fg else args.fg
68
+ if args.bg:
69
+ style["bg"] = None if args.bg.lower() in ("none", "transparent") else args.bg
70
+ if args.logo:
71
+ style["logo"] = args.logo
72
+ if args.shader:
73
+ style["shaders"] = tuple(args.shader)
74
+ if args.seed is not None:
75
+ style["seed"] = args.seed
76
+
77
+ if args.info:
78
+ for key, value in qr.info().items():
79
+ print(f"{key}: {value}")
80
+ return 0
81
+
82
+ if args.terminal or not args.output:
83
+ print(qr.terminal(args.preset, **style))
84
+ return 0
85
+
86
+ qr.save(args.output, args.preset, scale=args.scale, **style)
87
+ print(f"saved {args.output}")
88
+ return 0
89
+
90
+
91
+ if __name__ == "__main__": # pragma: no cover
92
+ sys.exit(main())