diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f660b6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# artifacts +output/ +*.svg +uv.lock +.venv/ +__pycache__/ diff --git a/README.md b/README.md index a6f36fe..c830459 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,18 @@ Generates SVG QR codes from any text input. -## usage +## Usage ``` -python qr.py -o -``` \ No newline at end of file +uv run svgqr "Text to encode" +uv run svgqr -t "Text to encode" -o path/to/code.svg +uv run svgqr -i values.txt +uv run svgqr --toml values.toml +``` + +Text can be passed positionally or with `-t`. Without `-o`, the program uses a +safe, truncated form of the text as the SVG filename. Batch mode writes SVGs to +`output/` and ignores `-o`. + +`--toml` accepts a flat mapping of output name to text. Standard TOML uses +`name = "text"`; the documented `name: text` form is also accepted. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ff9f2a5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "svg-qr-gen" +version = "0.1.0" +description = "Generate SVG QR codes from text." +readme = "README.md" +requires-python = ">=3.9" +dependencies = [ + "qrcode>=8.0", + "tomli>=2.0; python_version < '3.11'", +] + +[project.scripts] +svgqr = "qr:run" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +only-include = ["qr.py"] diff --git a/qr.py b/qr.py index 601c049..a5efc79 100644 --- a/qr.py +++ b/qr.py @@ -1,41 +1,139 @@ -import qrcode +"""Command-line SVG QR code generator.""" + +from __future__ import annotations + import argparse -import os +from pathlib import Path +import re +from typing import Sequence -def main(input_text, output_filename): - # Add .png extension if not present - if not output_filename.endswith(".png"): - output_filename += ".png" +import qrcode - # Create a QR code object - qr = qrcode.QRCode( - version=1, +try: + import tomllib +except ModuleNotFoundError: + import tomli as tomllib + + +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, - box_size=10, - border=0, + 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 ( + '\n' + f'\n' + f' \n' + "\n" ) - # Add the input data to the QR code object - qr.add_data(input_text) - # Make the QR code - qr.make(fit=True) +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") - # Create an image object to store the generated QR code - img = qr.make_image(fill_color="black", back_color="white") - # Save the QR code image to a file - img.save(output_filename) +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_toml_items(input_path: Path) -> dict[str, str]: + content = input_path.read_text(encoding="utf-8") + try: + items = tomllib.loads(content) + except tomllib.TOMLDecodeError: + items = {} + for line_number, line in enumerate(content.splitlines(), start=1): + if not line.strip() or line.lstrip().startswith("#"): + continue + key, separator, value = line.partition(":") + if not separator or not key.strip(): + raise ValueError( + f"{input_path}:{line_number}: expected a flat TOML item" + ) from None + items[key.strip()] = value.strip() + + if not all(isinstance(key, str) and isinstance(value, str) for key, value in items.items()): + raise ValueError(f"{input_path}: TOML values must be top-level text strings") + 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("--toml", type=Path, help="Flat TOML 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.toml is not None: + if args.input is not None and args.toml is not None: + parser.error("--input and --toml 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_toml_items(args.toml) + 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__": - # Set up the argument parser - parser = argparse.ArgumentParser(description="Generate a QR code from input text.") - parser.add_argument("-i", "--input", required=True, help="Text to be embedded in the QR code.") - parser.add_argument("-o", "--output", required=True, help="Filename for the output image.") - - # Parse the command-line arguments - args = parser.parse_args() - - # Call the main function with the provided arguments - main(args.input, args.output) \ No newline at end of file + run()