creamygoat

qrcode2svg.py - Convert a bi-level bit-mapped QR Code image to an SVG

Oct 28th, 2025
872
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 11.39 KB | Source Code | 0 0
  1. #!/usr/bin/python3
  2.  
  3. import os
  4. import sys
  5. import traceback
  6. import math
  7. import numpy as np
  8. import itertools
  9. import html
  10. import argparse
  11. from PIL import Image
  12.  
  13. import descale as ds
  14.  
  15.  
  16. VERSION = '1.0.0.0'
  17.  
  18.  
  19. class Error (Exception):
  20.   pass
  21.  
  22. class ArgError (Error):
  23.   pass
  24.  
  25. class FileError(Error):
  26.   pass
  27.  
  28. class DataError(Error):
  29.   pass
  30.  
  31. class CmdError(Error):
  32.   pass
  33.  
  34.  
  35. def load_qr_image(filename):
  36.   im = Image.open(filename)
  37.   if im.mode == 'P':
  38.     # Avoid the annoying warning about a transparent colour
  39.     # in a paletted image.
  40.     im = im.convert('LA')
  41.   if im.mode != 'L':
  42.     im = im.convert('L')
  43.   M = np.asarray(im)
  44.   M = ds.bilevel_array(M)
  45.   del im
  46.   M, trimmed = ds.trim_array(M)
  47.   M, margins = ds.descale_trimmed_array(M, trimmed)
  48.   return M, margins
  49.  
  50.  
  51. def svg_path_loop_data(loop):
  52.   result = []
  53.   if len(loop) > 0:
  54.     x0, y0 = loop[0]
  55.     result.append(f'M {x0},{y0}')
  56.     c = 1
  57.     for (x, y) in loop[1:]:
  58.       if False: #c % 50 == 0:
  59.         # Absolute
  60.         result.append(f'L {x},{y}')
  61.       else:
  62.         # Relative
  63.         dx = x - x0
  64.         dy = y - y0
  65.         if dy == 0:
  66.           result.append(f'h {dx}')
  67.         elif dx == 0:
  68.           result.append(f'v {dy}')
  69.         else:
  70.           result.append(f'l {dx},{dy}')
  71.       x0 = x
  72.       y0 = y
  73.       c += 1
  74.     result.append('z')
  75.   return result
  76.  
  77.  
  78. def loops_from_boolimg(M, evenodd):
  79.  
  80.   A = np.pad(M, 1)
  81.   NotA = np.logical_not(A)
  82.   Ev = np.logical_xor(A[1:, 1:], A[1:, :-1])
  83.   Eh = np.logical_xor(A[1:, 1:], A[:-1, 1:])
  84.  
  85.   # Edge flags are centred on pixel corners.
  86.   if evenodd:
  87.     Edn = np.logical_xor(A[1:, 1:], A[1:, :-1])
  88.     Eup = np.pad(Ev[:-1, :], [(1, 0), (0, 0)])
  89.     Eri = np.logical_xor(A[1:, 1:], A[:-1, 1:])
  90.     Ele = np.pad(Eh[:, :-1], [(0, 0), (1, 0)])
  91.   else:
  92.     NotA = np.logical_not(A)
  93.     Edn = np.logical_and(Ev, NotA[1:, 1:])
  94.     Eup = np.pad(np.logical_and(Ev[:-1, :], A[1:-1, 1:]), [(1, 0), (0, 0)])
  95.     Eri = np.logical_and(Eh, A[1:, 1:])
  96.     Ele = np.pad(np.logical_and(Eh[:, :-1], NotA[1:, 1:-1]), [(0, 0), (1, 0)])
  97.     del NotA
  98.   E = sum(Ed * d for Ed, d in ((Eri, 1), (Edn, 2), (Ele, 4), (Eup, 8)))
  99.   del Eri, Edn, Ele, Eup, Eh, Ev, A
  100.  
  101.   if evenodd:
  102.     search_dirs = [[x & 3 for x in (d, d + 1, d - 1)] for d in range(4)]
  103.   else:
  104.     search_dirs = [[(d + 1 - i) & 3 for i in range(3)] for d in range(4)]
  105.  
  106.   deltas = ((1, 0), (0, 1), (-1, 0), (0, -1))
  107.  
  108.   # Search for beginnings of rightward paths segments.
  109.   # The extreme bottom and right edges are excluded from
  110.   # the search but wall-following walks to find loops
  111.   # may include segments on those edges.
  112.   h = M.shape[0]
  113.   w = M.shape[1]
  114.   loops = []
  115.   loops_by_y = [list() for i in range(h)]
  116.   for sy in range(h):
  117.     right0 = False
  118.     for sx in range(w):
  119.       right = E[sy, sx] & 1
  120.       if right and not right0:
  121.         (x, y) = (sx, sy)
  122.         d = 0
  123.         loop = [(x, y)]
  124.         while True:
  125.           E[y, x] &= ~(1 << d)
  126.           dxy = deltas[d]
  127.           (x, y) = (x + dxy[0], y + dxy[1])
  128.           E[y, x] &= ~(1 << (d ^ 2))
  129.           d0 = d
  130.           d = None
  131.           if (x, y) != loop[0]:
  132.             dflags_at_xy = E[y,x]
  133.             for d in search_dirs[d0]:
  134.               if dflags_at_xy & (1 << d):
  135.                 break
  136.             if d is None:
  137.               raise Error("Dizzy at ({}, {})!".format(x, y))
  138.             if d != d0:
  139.               loop.append((x, y))
  140.           else:
  141.             break
  142.         if evenodd:
  143.           ax, ay = sx, sy
  144.         else:
  145.           # For the fill-rule of 'nonzero', ensure the vertices start at
  146.           # the leftmost part of the top extremity even for anticlockwise
  147.           # (cutting) loops. For the fill-rule of 'evenodd', all loops
  148.           # that are not self-intersecting are clockwise.
  149.           ay = min(p[1] for p in loop)
  150.           ax = min(p[0] for p in loop if p[1] == ay)
  151.           ai = loop.index((ax, ay))
  152.           loop = loop[ai:] + loop[:ai]
  153.         loops_by_y[ay].append(loop)
  154.       right0 = right
  155.     if not evenodd:
  156.       for loops in loops_by_y:
  157.         loops.sort()
  158.     loops = list(itertools.chain.from_iterable(loops_by_y))
  159.   return loops
  160.  
  161.  
  162. def svg_lines_from_loops(loops, evenodd, margins=4,
  163.                          title=None, widthstr=None,
  164.                          foreground=None, background=None,
  165.                          transparent=False, soft=False):
  166.  
  167.   if title is None:
  168.     title = '(Untitled)'
  169.   if widthstr is None:
  170.     widthstr = '60mm'
  171.   if foreground is None:
  172.     foreground = 'black'
  173.   if background is None:
  174.     background = 'white'
  175.  
  176.   if not isinstance(margins, dict):
  177.     x = margins
  178.     margins = dict(top=x, bottom=x, left=x, right=x)
  179.  
  180.   w = 0
  181.   h = 0
  182.   for loop in loops:
  183.     xs = [x for (x, y) in loop]
  184.     ys = [y for (x, y) in loop]
  185.     w = max(w, max(xs))
  186.     h = max(h, max(ys))
  187.  
  188.   if soft:
  189.     r = 0.1875
  190.     loops0 = loops
  191.     loops = []
  192.     for loop0 in loops0:
  193.       loop = []
  194.       lastp0 = loop0[0]
  195.       loop.append(lastp0)
  196.       for i in range(1, len(loop0)):
  197.         p0 = loop0[i]
  198.         nextp0 = loop0[(i + 1) % len(loop0)]
  199.         dx = r * max(-1, min(1, lastp0[0] - p0[0]))
  200.         dy = r * max(-1, min(1, lastp0[1] - p0[1]))
  201.         loop.append((p0[0] + dx, p0[1] + dy))
  202.         dx = r * max(-1, min(1, nextp0[0] - p0[0]))
  203.         dy = r * max(-1, min(1, nextp0[1] - p0[1]))
  204.         loop.append((p0[0] + dx, p0[1] + dy))
  205.         lastp0 = p0
  206.       loops.append(loop)
  207.  
  208.   vl = 0
  209.   vr = margins['left'] + w + margins['right']
  210.   vt = 0
  211.   vb = margins['top'] + h + margins['bottom']
  212.   vw = vr - vl
  213.   vh = vb - vt
  214.  
  215.   i = len(widthstr)
  216.   digits = "0123456789"
  217.   while i > 0 and widthstr[i - 1] not in digits:
  218.     i -= 1
  219.   unitstr = widthstr[i:]
  220.   svgwidth = float(widthstr[:i])
  221.   if svgwidth == '':
  222.     svgwidth = '0'
  223.   svgheight = svgwidth * vh / vw
  224.   heightstr = "{:g}{}".format(svgheight, unitstr)
  225.  
  226.   ts = html.escape(title)
  227.   ws = html.escape(widthstr)
  228.   hs = html.escape(heightstr)
  229.   fgs = html.escape(foreground)
  230.   bgs = html.escape(background)
  231.  
  232.   if evenodd:
  233.     eodescstr = 'QRCode as self-intersecting path loops'
  234.     eostr = 'evenodd'
  235.   else:
  236.     eodescstr = 'QRCode as clockwise fill loops and anticlockwise cut loops'
  237.     eostr = 'nonzero'
  238.  
  239.   SVGLines = []
  240.   SVGLines += [
  241.     '<?xml version="1.0" standalone="no"?>',
  242.     '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"',
  243.     '  "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">',
  244.     f'<svg width="{ws}" height="{hs}" '
  245.         f'viewBox="{vl} {vt} {vw} {vh}"',
  246.     '    preserveAspectRatio="xMidYMid meet"',
  247.     '    xmlns="http://www.w3.org/2000/svg" version="1.1"',
  248.     '    xmlns:xlink="http://www.w3.org/1999/xlink">',
  249.     f'<title>{ts}</title>',
  250.   ]
  251.   if not transparent:
  252.     SVGLines += [
  253.       '<!-- Background -->',
  254.       f'<rect x="{vl}" y="{vt}" width="{vw}" height="{vh}" '
  255.           f'stroke="none" fill="{bgs}"/>',
  256.     ]
  257.  
  258.   if evenodd:
  259.     eodescstr = 'QRCode as self-intersecting path loops'
  260.     eostr = 'evenodd'
  261.   else:
  262.     eodescstr = 'QRCode as clockwise fill loops and anticlockwise cut loops'
  263.     eostr = 'nonzero'
  264.  
  265.   SVGLines += [
  266.     '<g transform="translate({}, {})">'.format(
  267.         margins['left'], margins['top']),
  268.     f'  <!-- {eodescstr} -->',
  269.     f'  <path fill="{fgs}" stroke="none" fill-rule="{eostr}" d="',
  270.   ]
  271.   for loop in loops:
  272.     V = svg_path_loop_data(loop)
  273.     indent1 = 4
  274.     indent2 = 6
  275.     MLFrags = []
  276.     LFrags = []
  277.     MLFrags.append(LFrags)
  278.     indent = indent1
  279.     c = indent1 - 1
  280.     for frag in V:
  281.       if c + 1 + len(frag) > 76:
  282.         LFrags = []
  283.         MLFrags.append(LFrags)
  284.         indent = indent2
  285.         c = indent1 - 1
  286.       LFrags.append(frag)
  287.       c += 1 + len(frag)
  288.     indent = indent1
  289.     for LFrags in MLFrags:
  290.       S = ' ' * indent + ' '.join(LFrags)
  291.       SVGLines.append(S)
  292.       indent = indent2
  293.   SVGLines.append('  "/>')
  294.   SVGLines.append('</g>')
  295.  
  296.   SVGLines.append('</svg>')
  297.  
  298.   return SVGLines
  299.  
  300.  
  301. def save_lines(filename, lines):
  302.   with open(filename, 'w') as f:
  303.     for line in lines:
  304.       f.write("{}\n".format(line))
  305.  
  306.  
  307. def get_arguments():
  308.  
  309.   cmd = os.path.basename(sys.argv[0])
  310.  
  311.   parser = argparse.ArgumentParser(
  312.     prog=cmd,
  313.     add_help=False,
  314.     description="Converts a bitmapped QRCode image to an SVG."
  315.   )
  316.  
  317.   parser.add_argument(
  318.       "-h", "--help",
  319.       dest="help", action="store_true",
  320.       help="Display this message and exit.")
  321.   parser.add_argument(
  322.       "-m", "--margin", metavar="MARGIN",
  323.       dest="margin", type=int, action="store",
  324.       help=("Set margin (quiet zone) in modules."))
  325.   parser.add_argument(
  326.       "-t", "--title", metavar="TITLE",
  327.       dest="title", type=str, action="store",
  328.       help=("SVG title."))
  329.   parser.add_argument(
  330.       "-w", "--width", metavar="WIDTH",
  331.       dest="widthstr", type=str, action="store",
  332.       help=("Physical SVG width with unit."))
  333.   parser.add_argument(
  334.       "-i", "--evenodd",
  335.       dest="evenodd", action="store_true",
  336.       help="Allow self-intersections.")
  337.   parser.add_argument(
  338.       "-b", "--bevel",
  339.       dest="bevel", action="store_true",
  340.       help="Cut corners to show path loops.")
  341.   parser.add_argument(
  342.       "-x", "--transparent",
  343.       dest="transparent", action="store_true",
  344.       help="Exclude the filled background rectangle.")
  345.   parser.add_argument(
  346.       "--foreground",
  347.       dest="foreground", action="store",
  348.       help="CSS-style foreground colour.")
  349.   parser.add_argument(
  350.       "--background",
  351.       dest="background", action="store",
  352.       help="CSS-style background colour.")
  353.   parser.add_argument(
  354.       "-V", "--version",
  355.       dest="version", action="store_true",
  356.       help="Display version and exit.")
  357.  
  358.   parser.add_argument(
  359.       "filename", metavar="QRCODE",
  360.       type=str,
  361.       help=("The bitmapped QRCode image to read."))
  362.  
  363.   parser.add_argument(
  364.       "outfilename", metavar="SVGFILE",
  365.       type=str,
  366.       help=("The SVG file to create."))
  367.  
  368.   if "-h" in sys.argv or "--help" in sys.argv:
  369.     parser.print_help()
  370.     sys.exit(0)
  371.  
  372.   if "-V" in sys.argv or "--version" in sys.argv:
  373.     print(VERSION)
  374.     sys.exit(0)
  375.  
  376.   args = parser.parse_args()
  377.  
  378.   return args
  379.  
  380.  
  381. def main():
  382.  
  383.   result = 0
  384.   err_msg = ''
  385.  
  386.   cmd = os.path.basename(sys.argv[0])
  387.  
  388.   try:
  389.  
  390.     args = get_arguments()
  391.  
  392.     M, margins = load_qr_image(args.filename)
  393.     if args.margin is not None:
  394.       margins = args.margin
  395.     M = ~(np.array(M, dtype=bool))
  396.     L = loops_from_boolimg(M, args.evenodd)
  397.     S = svg_lines_from_loops(
  398.       L,
  399.       args.evenodd,
  400.       widthstr = args.widthstr,
  401.       title = args.title,
  402.       margins = margins,
  403.       foreground = args.foreground,
  404.       background = args.background,
  405.       transparent = args.transparent,
  406.       soft = args.bevel,
  407.     )
  408.     save_lines(args.outfilename, S)
  409.  
  410.   except ArgError as E:
  411.     err_msg = 'Error: ' + str(E)
  412.     result = 2
  413.   except FileError as E:
  414.     err_msg = str(E)
  415.     result = 3
  416.   except CmdError as E:
  417.     err_msg = str(E)
  418.     result = 4
  419.   except DataError as E:
  420.     err_msg = str(E)
  421.     result = 5
  422.   except Exception as E:
  423.     exc_type, exc_value, exc_traceback = sys.exc_info()
  424.     err_lines = traceback.format_exc().splitlines()
  425.     err_msg = 'Unhandled exception:\n' + '\n'.join(err_lines)
  426.     result = 1
  427.  
  428.   if err_msg != '':
  429.     print(cmd + ': ' + err_msg, file=sys.stderr)
  430.  
  431.   return result
  432.  
  433.  
  434. if __name__ == '__main__':
  435.   main()
  436.  
Advertisement