Files
svg-qr-gen/qr.py
T
2026-08-26 15:54:41 +09:00

128 lines
4.4 KiB
Python

"""Command-line SVG QR code generator."""
from __future__ import annotations
import argparse
from pathlib import Path
import re
from typing import Sequence
import qrcode
import yaml
SVG_SUFFIX = ".svg"
MAX_FILENAME_LENGTH = 32
INVALID_FILENAME_CHARACTERS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
def svg_path(text: str) -> str:
"""Build an SVG containing the QR code as filled convex module paths."""
code = qrcode.QRCode(
version=None,
error_correction=qrcode.constants.ERROR_CORRECT_L,
border=4,
)
code.add_data(text)
code.make(fit=True)
matrix = code.get_matrix()
size = len(matrix)
modules = []
for row_index, row in enumerate(matrix):
for column_index, is_dark in enumerate(row):
if is_dark:
modules.append(
f"M{column_index} {row_index}H{column_index + 1}"
f"V{row_index + 1}H{column_index}Z"
)
path_data = "".join(modules)
return (
'<?xml version="1.0" encoding="UTF-8"?>\n'
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {size} {size}" '
f'width="{size}" height="{size}" role="img">\n'
f' <path fill="#000" d="{path_data}"/>\n'
"</svg>\n"
)
def write_svg(text: str, output_path: Path) -> None:
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(svg_path(text), encoding="utf-8")
def with_svg_suffix(path: Path) -> Path:
if path.suffix.lower() == SVG_SUFFIX:
return path
return path.with_name(f"{path.name}{SVG_SUFFIX}")
def automatic_output_path(text: str, directory: Path = Path(".")) -> Path:
filename = INVALID_FILENAME_CHARACTERS.sub("_", text).rstrip(". ")
if filename in {"", ".", ".."}:
filename = "_"
stem_length = MAX_FILENAME_LENGTH - len(SVG_SUFFIX)
return directory / f"{filename[:stem_length]}{SVG_SUFFIX}"
def read_yaml_items(input_path: Path) -> dict[str, str]:
try:
items = yaml.safe_load(input_path.read_text(encoding="utf-8")) or {}
except yaml.YAMLError as error:
raise ValueError(f"{input_path}: invalid YAML: {error}") from None
if not isinstance(items, dict) or not all(
isinstance(key, str) and isinstance(value, str) for key, value in items.items()
):
raise ValueError(f"{input_path}: YAML must be a flat mapping of text values")
return items
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Generate SVG QR codes from text.")
parser.add_argument("text", nargs="?", help="Text to encode (the default input mode).")
parser.add_argument("-t", "--text", dest="option_text", help="Text to encode.")
parser.add_argument("-o", "--output", type=Path, help="Path for the generated SVG.")
parser.add_argument("-i", "--input", type=Path, help="File containing one QR value per line.")
parser.add_argument("--yaml", type=Path, help="Flat YAML file containing output names and text.")
return parser
def run(arguments: Sequence[str] | None = None) -> None:
parser = build_parser()
args = parser.parse_args(arguments)
if args.input is not None or args.yaml is not None:
if args.input is not None and args.yaml is not None:
parser.error("--input and --yaml cannot be used together")
if args.text is not None or args.option_text is not None:
parser.error("text input cannot be used with batch input")
if args.input is not None:
for line in args.input.read_text(encoding="utf-8").splitlines():
write_svg(line, automatic_output_path(line, Path("output")))
else:
try:
items = read_yaml_items(args.yaml)
except ValueError as error:
parser.error(str(error))
for name, text in items.items():
write_svg(text, automatic_output_path(name, Path("output")))
return
if args.text is not None and args.option_text is not None:
parser.error("provide text either positionally or with --text, not both")
text = args.option_text if args.option_text is not None else args.text
if text is None:
parser.error("text is required unless --input is provided")
output_path = with_svg_suffix(args.output) if args.output else automatic_output_path(text)
write_svg(text, output_path)
if __name__ == "__main__":
run()