Skip to content
Merged
7 changes: 6 additions & 1 deletion qrcode/console_scripts.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"svg": "qrcode.image.svg.SvgImage",
"svg-fragment": "qrcode.image.svg.SvgFragmentImage",
"svg-path": "qrcode.image.svg.SvgPathImage",
"svg-compressed": "qrcode.image.svg.SvgCompressedImage",
# Keeping for backwards compatibility:
"pymaging": "qrcode.image.pure.PymagingImage",
}
Expand All @@ -43,7 +44,11 @@ def main(args=None):
if args is None:
args = sys.argv[1:]

version = metadata.version("qrcode")
try:
version = metadata.version("qrcode")
except metadata.PackageNotFoundError:
version = "development"

parser = optparse.OptionParser(usage=(__doc__ or "").strip(), version=version)

# Wrap parser.error in a typed NoReturn method for better typing.
Expand Down
14 changes: 14 additions & 0 deletions qrcode/image/styles/moduledrawers/svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,20 @@ def drawrect(self, box, is_active: bool):
def subpath(self, box) -> str: ...


class SvgCompressedDrawer(BaseSvgQRModuleDrawer):
img: "SvgPathImage"

def drawrect(self, box, is_active: bool):
if not is_active:
return
coords = self.coords(box)
x0 = self.img.units(coords.x0, text=False)
y0 = self.img.units(coords.y0, text=False)
assert self.img.units(coords.x1, text=False) - 1 == x0
assert self.img.units(coords.y1, text=False) - 1 == y0
self.img._points.append([int(x0), int(y0)])


class SvgPathSquareDrawer(SvgPathQRModuleDrawer):
def subpath(self, box) -> str:
coords = self.coords(box)
Expand Down
255 changes: 255 additions & 0 deletions qrcode/image/svg.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import decimal
import enum
from decimal import Decimal
from typing import List, Optional, Type, Union, overload, Literal

Expand Down Expand Up @@ -159,6 +160,260 @@ def process(self):
self._img.append(self.path)


class SvgCompressedImage(SvgImage):
"""
SVG image builder with goal of smallest possible output, at least among
algorithms with predictable fast run time.
"""

needs_processing = True
path: Optional[ET.Element] = None
default_drawer_class: Type[QRModuleDrawer] = svg_drawers.SvgCompressedDrawer

def __init__(self, *args, **kwargs):
self._points = []
super().__init__(*args, **kwargs)

def _svg(self, viewBox=None, **kwargs):
if viewBox is None:
dimension = self.units(self.pixel_size, text=False)
# Save characters by moving real pixels to start at 0,0 with a negative
# offset for the border, with more real pixels having lower digit counts.
viewBox = "-{b} -{b} {d} {d}".format(d=dimension, b=self.border)
return super()._svg(viewBox=viewBox, **kwargs)

def _generate_subpaths(self):
"""
Yield a series of paths which walk the grid, drawing squares on,
and also drawing reverse transparency holes, to complete the SVG.
"""
# what we should make, juxtaposed against what we currently have
goal = [[0] * (self.width + 2) for i in range(self.width + 2)]
curr = [[0] * (self.width + 2) for i in range(self.width + 2)]
for point in self._points:
# The +1 -1 allows the path walk logic to not worry about image edges.
goal[point[0] - self.border + 1][point[1] - self.border + 1] = 1

def abs_or_delta(cmds, curr_1, last_1, curr_2=None, last_2=None):
"""Use whichever is shorter: the absolute command, or delta command."""

def opt_join(a, b):
if b is None:
return "%d" % a
return "%d" % a + ("" if b < 0 else " ") + "%d" % b

return min(
[
cmds[0]
+ opt_join(
curr_1 - last_1, curr_2 - last_2 if curr_2 is not None else None
),
# The +1 -1 allows the path walk logic to not worry about image edges.
cmds[1]
+ opt_join(curr_1 - 1, curr_2 - 1 if curr_2 is not None else None),
],
key=len,
)

class WD(enum.IntEnum):
North = 1
South = 2
East = 3
West = 4

class PathChain:
__slots__ = ["cmds", "next"]

def __init__(self):
self.cmds = ""
self.next = None

def create_next(self):
self.next = PathChain()
return self.next

# Old cursor position allows optimizing with "m" sometimes instead of "M".
# The +1 -1 allows the path walk logic to not worry about image edges.
old_cursor = (1, 1)
fullpath_head = fullpath_tail = None
fullpath_splice_points = {}

# Go over the grid, creating the paths. This ordering seems to work fairly
# well, although it's not necessarily optimal. Unfortunately optimal is a
# traveling salesman problem, and it's not obvious whether there's any
# significantly better possible ordering in general.
for search_y in range(self.width + 2):
for search_x in range(self.width + 2):
if goal[search_x][search_y] == curr[search_x][search_y]:
continue

# Note, the 'm' here is starting from the old cursor spot, which (as per SVG
# spec) is not the close path spot. We could test for both, trying a 'z' to
# to save characters for the next 'm'. However, the mathematically first
# opportunity would be a convert of 'm1 100' to 'm1 9', so would require a
# straight line of 91 pairs of identical pixels. I believe the QR spec allows
# for that, but it is essentially impossible by chance.
(start_x, start_y) = (search_x, search_y)
subpath_head = subpath_tail = PathChain()
subpath_head.cmds = abs_or_delta(
"mM", start_x, old_cursor[0], start_y, old_cursor[1]
)
path_flips = {}
do_splice = (
False # The point where we are doing a splice, to save on 'M's.
)
subpath_splice_points = {}
paint_on = goal[start_x][start_y]
path_dir = WD.East if paint_on else WD.South
(curr_x, curr_y) = (last_x, last_y) = (start_x, start_y)

def should_switch_to_splicing():
nonlocal do_splice, start_x, start_y, subpath_head, subpath_tail
if not do_splice and (curr_x, curr_y) in fullpath_splice_points:
subpath_head = subpath_tail = PathChain()
path_flips.clear()
subpath_splice_points.clear()
do_splice |= True
(start_x, start_y) = (curr_x, curr_y)
return True
return False

def add_to_splice_points():
nonlocal subpath_tail
if (curr_x, curr_y) in subpath_splice_points:
# we hit a splice point a second time, so topology dictates it's done
subpath_splice_points.pop((curr_x, curr_y))
else:
subpath_splice_points[curr_x, curr_y] = subpath_tail
subpath_tail = subpath_tail.create_next()

# Immediately check for a need to splice in, right from the starting point.
should_switch_to_splicing()

while True:
if path_dir == WD.East:
while goal[curr_x][curr_y] and not goal[curr_x][curr_y - 1]:
if curr_x not in path_flips:
path_flips[curr_x] = []
path_flips[curr_x].append(curr_y)
curr_x += 1
assert curr_x != last_x
path_dir = WD.North if goal[curr_x][curr_y - 1] else WD.South
if do_splice or (curr_x, curr_y) != (start_x, start_y):
subpath_tail.cmds += abs_or_delta("hH", curr_x, last_x)

# only a left turn with a hole coming up on the right is spliceable
if path_dir == WD.North and not goal[curr_x][curr_y]:
add_to_splice_points()

if (curr_x, curr_y) == (start_x, start_y):
break # subpath is done
if should_switch_to_splicing():
continue
elif path_dir == WD.West:
while (
not goal[curr_x - 1][curr_y]
and goal[curr_x - 1][curr_y - 1]
):
curr_x -= 1
if curr_x not in path_flips:
path_flips[curr_x] = []
path_flips[curr_x].append(curr_y)
assert curr_x != last_x
path_dir = WD.South if goal[curr_x - 1][curr_y] else WD.North
if do_splice or (curr_x, curr_y) != (start_x, start_y):
subpath_tail.cmds += abs_or_delta("hH", curr_x, last_x)

# only a left turn with a hole coming up on the right is spliceable
if path_dir == WD.South and not goal[curr_x - 1][curr_y - 1]:
add_to_splice_points()

if (curr_x, curr_y) == (start_x, start_y):
break # subpath is done
if should_switch_to_splicing():
continue
elif path_dir == WD.North:
while (
goal[curr_x][curr_y - 1]
and not goal[curr_x - 1][curr_y - 1]
):
curr_y -= 1
assert curr_y != last_y
path_dir = WD.West if goal[curr_x - 1][curr_y - 1] else WD.East
if do_splice or (curr_x, curr_y) != (start_x, start_y):
subpath_tail.cmds += abs_or_delta("vV", curr_y, last_y)

# only a left turn with a hole coming up on the right is spliceable
if path_dir == WD.West and not goal[curr_x][curr_y - 1]:
add_to_splice_points()

if (curr_x, curr_y) == (start_x, start_y):
break # subpath is done
if should_switch_to_splicing():
continue
elif path_dir == WD.South:
while not goal[curr_x][curr_y] and goal[curr_x - 1][curr_y]:
curr_y += 1
assert curr_y != last_y
path_dir = WD.East if goal[curr_x][curr_y] else WD.West
if do_splice or (curr_x, curr_y) != (start_x, start_y):
subpath_tail.cmds += abs_or_delta("vV", curr_y, last_y)

# only a left turn with a hole coming up on the right is spliceable
if path_dir == WD.East and not goal[curr_x - 1][curr_y]:
add_to_splice_points()

if (curr_x, curr_y) == (start_x, start_y):
break # subpath is done
if should_switch_to_splicing():
continue
else:
raise
assert (last_x, last_y) != (curr_x, curr_y), goal
(last_x, last_y) = (curr_x, curr_y)

if do_splice:
subpath_tail.next = fullpath_splice_points[start_x, start_y].next
fullpath_splice_points[start_x, start_y].next = subpath_head
else:
if not fullpath_head:
fullpath_head = subpath_head
else:
fullpath_tail.next = subpath_head
fullpath_tail = subpath_tail
old_cursor = (last_x, last_y)

for k, v in subpath_splice_points.items():
if k in fullpath_splice_points:
# we hit a splice point a second time, so topology dictates it's done
fullpath_splice_points.pop(k)
else:
# merge new splice point
fullpath_splice_points[k] = v

# Note that only one dimension (which was arbitrary chosen here as
# horizontal) needs to be evaluated to determine all of the pixel flips.
for x, ys in path_flips.items():
ys = sorted(ys, reverse=True)
while len(ys) > 1:
for y in range(ys.pop(), ys.pop()):
curr[x][y] = paint_on
assert fullpath_splice_points == {}, fullpath_splice_points
while fullpath_head:
yield fullpath_head.cmds
fullpath_head = fullpath_head.next

def process(self):
# Store the path just in case someone wants to use it again or in some
# unique way.
self.path = ET.Element(
ET.QName("path"), # type: ignore
d="".join(self._generate_subpaths()),
fill="#000",
)
self._img.append(self.path)


class SvgFillImage(SvgImage):
"""
An SvgImage that fills the background to white.
Expand Down