Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions qrcode/console_scripts.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,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 Down
20 changes: 18 additions & 2 deletions qrcode/image/styles/moduledrawers/svg.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

import abc
from decimal import Decimal
from typing import TYPE_CHECKING, NamedTuple
Expand All @@ -21,7 +23,7 @@ class Coords(NamedTuple):


class BaseSvgQRModuleDrawer(QRModuleDrawer):
img: "SvgFragmentImage"
img: SvgFragmentImage

def __init__(self, *, size_ratio: Decimal = Decimal(1), **kwargs):
self.size_ratio = size_ratio
Expand Down Expand Up @@ -97,7 +99,7 @@ def el(self, box):


class SvgPathQRModuleDrawer(BaseSvgQRModuleDrawer):
img: "SvgPathImage"
img: SvgPathImage

def drawrect(self, box, is_active: bool):
if not is_active:
Expand All @@ -108,6 +110,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
262 changes: 262 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 Optional, Union, overload, Literal

Expand Down Expand Up @@ -164,6 +165,267 @@ def process(self):
self._img.append(self.path)


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

QR_PATH_STYLE = {
"fill": "#000000",
"fill-opacity": "1",
"fill-rule": "nonzero",
"stroke": "none",
}

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):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could be worth it to move this logic to a separate module altogether (moduledrawers/_svg_compressed.py?) so you could use less inner classes and enums?

"""
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()),
**self.QR_PATH_STYLE,
)
self._img.append(self.path)


class SvgFillImage(SvgImage):
"""
An SvgImage that fills the background to white.
Expand Down
7 changes: 7 additions & 0 deletions qrcode/tests/test_qrcode_svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ def test_render_svg_path():
img.save(io.BytesIO())


def test_render_svg_compressed():
qr = qrcode.QRCode()
qr.add_data(UNICODE_TEXT)
img = qr.make_image(image_factory=svg.SvgCompressedImage)
img.save(io.BytesIO())


def test_render_svg_fragment():
qr = qrcode.QRCode()
qr.add_data(UNICODE_TEXT)
Expand Down