This commit is contained in:
Iliyan Angelov
2025-09-19 11:58:53 +03:00
parent 306b20e24a
commit 6b247e5b9f
11423 changed files with 1500615 additions and 778 deletions

View File

@@ -0,0 +1,226 @@
import math
from PIL import Image
class QRColorMask:
"""
QRColorMask is used to color in the QRCode.
By the time apply_mask is called, the QRModuleDrawer of the StyledPilImage
will have drawn all of the modules on the canvas (the color of these
modules will be mostly black, although antialiasing may result in
gradients) In the base class, apply_mask is implemented such that the
background color will remain, but the foreground pixels will be replaced by
a color determined by a call to get_fg_pixel. There is additional
calculation done to preserve the gradient artifacts of antialiasing.
All QRColorMask objects should be careful about RGB vs RGBA color spaces.
For examples of what these look like, see doc/color_masks.png
"""
back_color = (255, 255, 255)
has_transparency = False
paint_color = back_color
def initialize(self, styledPilImage, image):
self.paint_color = styledPilImage.paint_color
def apply_mask(self, image, use_cache=False):
width, height = image.size
pixels = image.load()
fg_color_cache = {} if use_cache else None
for x in range(width):
for y in range(height):
current_color = pixels[x, y]
if current_color == self.back_color:
continue
if use_cache and current_color in fg_color_cache:
pixels[x, y] = fg_color_cache[current_color]
continue
norm = self.extrap_color(
self.back_color, self.paint_color, current_color
)
if norm is not None:
new_color = self.interp_color(
self.get_bg_pixel(image, x, y),
self.get_fg_pixel(image, x, y),
norm,
)
pixels[x, y] = new_color
if use_cache:
fg_color_cache[current_color] = new_color
else:
pixels[x, y] = self.get_bg_pixel(image, x, y)
def get_fg_pixel(self, image, x, y):
raise NotImplementedError("QRModuleDrawer.paint_fg_pixel")
def get_bg_pixel(self, image, x, y):
return self.back_color
# The following functions are helpful for color calculation:
# interpolate a number between two numbers
def interp_num(self, n1, n2, norm):
return int(n2 * norm + n1 * (1 - norm))
# interpolate a color between two colorrs
def interp_color(self, col1, col2, norm):
return tuple(self.interp_num(col1[i], col2[i], norm) for i in range(len(col1)))
# find the interpolation coefficient between two numbers
def extrap_num(self, n1, n2, interped_num):
if n2 == n1:
return None
else:
return (interped_num - n1) / (n2 - n1)
# find the interpolation coefficient between two numbers
def extrap_color(self, col1, col2, interped_color):
normed = []
for c1, c2, ci in zip(col1, col2, interped_color):
extrap = self.extrap_num(c1, c2, ci)
if extrap is not None:
normed.append(extrap)
if not normed:
return None
return sum(normed) / len(normed)
class SolidFillColorMask(QRColorMask):
"""
Just fills in the background with one color and the foreground with another
"""
def __init__(self, back_color=(255, 255, 255), front_color=(0, 0, 0)):
self.back_color = back_color
self.front_color = front_color
self.has_transparency = len(self.back_color) == 4
def apply_mask(self, image):
if self.back_color == (255, 255, 255) and self.front_color == (0, 0, 0):
# Optimization: the image is already drawn by QRModuleDrawer in
# black and white, so if these are also our mask colors we don't
# need to do anything. This is much faster than actually applying a
# mask.
pass
else:
# TODO there's probably a way to use PIL.ImageMath instead of doing
# the individual pixel comparisons that the base class uses, which
# would be a lot faster. (In fact doing this would probably remove
# the need for the B&W optimization above.)
QRColorMask.apply_mask(self, image, use_cache=True)
def get_fg_pixel(self, image, x, y):
return self.front_color
class RadialGradiantColorMask(QRColorMask):
"""
Fills in the foreground with a radial gradient from the center to the edge
"""
def __init__(
self, back_color=(255, 255, 255), center_color=(0, 0, 0), edge_color=(0, 0, 255)
):
self.back_color = back_color
self.center_color = center_color
self.edge_color = edge_color
self.has_transparency = len(self.back_color) == 4
def get_fg_pixel(self, image, x, y):
width, _ = image.size
normedDistanceToCenter = math.sqrt(
(x - width / 2) ** 2 + (y - width / 2) ** 2
) / (math.sqrt(2) * width / 2)
return self.interp_color(
self.center_color, self.edge_color, normedDistanceToCenter
)
class SquareGradiantColorMask(QRColorMask):
"""
Fills in the foreground with a square gradient from the center to the edge
"""
def __init__(
self, back_color=(255, 255, 255), center_color=(0, 0, 0), edge_color=(0, 0, 255)
):
self.back_color = back_color
self.center_color = center_color
self.edge_color = edge_color
self.has_transparency = len(self.back_color) == 4
def get_fg_pixel(self, image, x, y):
width, _ = image.size
normedDistanceToCenter = max(abs(x - width / 2), abs(y - width / 2)) / (
width / 2
)
return self.interp_color(
self.center_color, self.edge_color, normedDistanceToCenter
)
class HorizontalGradiantColorMask(QRColorMask):
"""
Fills in the foreground with a gradient sweeping from the left to the right
"""
def __init__(
self, back_color=(255, 255, 255), left_color=(0, 0, 0), right_color=(0, 0, 255)
):
self.back_color = back_color
self.left_color = left_color
self.right_color = right_color
self.has_transparency = len(self.back_color) == 4
def get_fg_pixel(self, image, x, y):
width, _ = image.size
return self.interp_color(self.left_color, self.right_color, x / width)
class VerticalGradiantColorMask(QRColorMask):
"""
Fills in the forefround with a gradient sweeping from the top to the bottom
"""
def __init__(
self, back_color=(255, 255, 255), top_color=(0, 0, 0), bottom_color=(0, 0, 255)
):
self.back_color = back_color
self.top_color = top_color
self.bottom_color = bottom_color
self.has_transparency = len(self.back_color) == 4
def get_fg_pixel(self, image, x, y):
width, _ = image.size
return self.interp_color(self.top_color, self.bottom_color, y / width)
class ImageColorMask(QRColorMask):
"""
Fills in the foreground with pixels from another image, either passed by
path or passed by image object.
"""
def __init__(
self, back_color=(255, 255, 255), color_mask_path=None, color_mask_image=None
):
self.back_color = back_color
if color_mask_image:
self.color_img = color_mask_image
else:
self.color_img = Image.open(color_mask_path)
self.has_transparency = len(self.back_color) == 4
def initialize(self, styledPilImage, image):
self.paint_color = styledPilImage.paint_color
self.color_img = self.color_img.resize(image.size)
def get_fg_pixel(self, image, x, y):
width, _ = image.size
return self.color_img.getpixel((x, y))

View File

@@ -0,0 +1,10 @@
# For backwards compatibility, importing the PIL drawers here.
try:
from .pil import CircleModuleDrawer # noqa: F401
from .pil import GappedSquareModuleDrawer # noqa: F401
from .pil import HorizontalBarsDrawer # noqa: F401
from .pil import RoundedModuleDrawer # noqa: F401
from .pil import SquareModuleDrawer # noqa: F401
from .pil import VerticalBarsDrawer # noqa: F401
except ImportError:
pass

View File

@@ -0,0 +1,33 @@
import abc
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from qrcode.image.base import BaseImage
class QRModuleDrawer(abc.ABC):
"""
QRModuleDrawer exists to draw the modules of the QR Code onto images.
For this, technically all that is necessary is a ``drawrect(self, box,
is_active)`` function which takes in the box in which it is to draw,
whether or not the box is "active" (a module exists there). If
``needs_neighbors`` is set to True, then the method should also accept a
``neighbors`` kwarg (the neighboring pixels).
It is frequently necessary to also implement an "initialize" function to
set up values that only the containing Image class knows about.
For examples of what these look like, see doc/module_drawers.png
"""
needs_neighbors = False
def __init__(self, **kwargs):
pass
def initialize(self, img: "BaseImage") -> None:
self.img = img
@abc.abstractmethod
def drawrect(self, box, is_active) -> None: ...

View File

@@ -0,0 +1,265 @@
from typing import TYPE_CHECKING
from PIL import Image, ImageDraw
from qrcode.image.styles.moduledrawers.base import QRModuleDrawer
if TYPE_CHECKING:
from qrcode.image.styledpil import StyledPilImage
from qrcode.main import ActiveWithNeighbors
# When drawing antialiased things, make them bigger and then shrink them down
# to size after the geometry has been drawn.
ANTIALIASING_FACTOR = 4
class StyledPilQRModuleDrawer(QRModuleDrawer):
"""
A base class for StyledPilImage module drawers.
NOTE: the color that this draws in should be whatever is equivalent to
black in the color space, and the specified QRColorMask will handle adding
colors as necessary to the image
"""
img: "StyledPilImage"
class SquareModuleDrawer(StyledPilQRModuleDrawer):
"""
Draws the modules as simple squares
"""
def initialize(self, *args, **kwargs):
super().initialize(*args, **kwargs)
self.imgDraw = ImageDraw.Draw(self.img._img)
def drawrect(self, box, is_active: bool):
if is_active:
self.imgDraw.rectangle(box, fill=self.img.paint_color)
class GappedSquareModuleDrawer(StyledPilQRModuleDrawer):
"""
Draws the modules as simple squares that are not contiguous.
The size_ratio determines how wide the squares are relative to the width of
the space they are printed in
"""
def __init__(self, size_ratio=0.8):
self.size_ratio = size_ratio
def initialize(self, *args, **kwargs):
super().initialize(*args, **kwargs)
self.imgDraw = ImageDraw.Draw(self.img._img)
self.delta = (1 - self.size_ratio) * self.img.box_size / 2
def drawrect(self, box, is_active: bool):
if is_active:
smaller_box = (
box[0][0] + self.delta,
box[0][1] + self.delta,
box[1][0] - self.delta,
box[1][1] - self.delta,
)
self.imgDraw.rectangle(smaller_box, fill=self.img.paint_color)
class CircleModuleDrawer(StyledPilQRModuleDrawer):
"""
Draws the modules as circles
"""
circle = None
def initialize(self, *args, **kwargs):
super().initialize(*args, **kwargs)
box_size = self.img.box_size
fake_size = box_size * ANTIALIASING_FACTOR
self.circle = Image.new(
self.img.mode,
(fake_size, fake_size),
self.img.color_mask.back_color,
)
ImageDraw.Draw(self.circle).ellipse(
(0, 0, fake_size, fake_size), fill=self.img.paint_color
)
self.circle = self.circle.resize((box_size, box_size), Image.Resampling.LANCZOS)
def drawrect(self, box, is_active: bool):
if is_active:
self.img._img.paste(self.circle, (box[0][0], box[0][1]))
class RoundedModuleDrawer(StyledPilQRModuleDrawer):
"""
Draws the modules with all 90 degree corners replaced with rounded edges.
radius_ratio determines the radius of the rounded edges - a value of 1
means that an isolated module will be drawn as a circle, while a value of 0
means that the radius of the rounded edge will be 0 (and thus back to 90
degrees again).
"""
needs_neighbors = True
def __init__(self, radius_ratio=1):
self.radius_ratio = radius_ratio
def initialize(self, *args, **kwargs):
super().initialize(*args, **kwargs)
self.corner_width = int(self.img.box_size / 2)
self.setup_corners()
def setup_corners(self):
mode = self.img.mode
back_color = self.img.color_mask.back_color
front_color = self.img.paint_color
self.SQUARE = Image.new(
mode, (self.corner_width, self.corner_width), front_color
)
fake_width = self.corner_width * ANTIALIASING_FACTOR
radius = self.radius_ratio * fake_width
diameter = radius * 2
base = Image.new(
mode, (fake_width, fake_width), back_color
) # make something 4x bigger for antialiasing
base_draw = ImageDraw.Draw(base)
base_draw.ellipse((0, 0, diameter, diameter), fill=front_color)
base_draw.rectangle((radius, 0, fake_width, fake_width), fill=front_color)
base_draw.rectangle((0, radius, fake_width, fake_width), fill=front_color)
self.NW_ROUND = base.resize(
(self.corner_width, self.corner_width), Image.Resampling.LANCZOS
)
self.SW_ROUND = self.NW_ROUND.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
self.SE_ROUND = self.NW_ROUND.transpose(Image.Transpose.ROTATE_180)
self.NE_ROUND = self.NW_ROUND.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
def drawrect(self, box: list[list[int]], is_active: "ActiveWithNeighbors"):
if not is_active:
return
# find rounded edges
nw_rounded = not is_active.W and not is_active.N
ne_rounded = not is_active.N and not is_active.E
se_rounded = not is_active.E and not is_active.S
sw_rounded = not is_active.S and not is_active.W
nw = self.NW_ROUND if nw_rounded else self.SQUARE
ne = self.NE_ROUND if ne_rounded else self.SQUARE
se = self.SE_ROUND if se_rounded else self.SQUARE
sw = self.SW_ROUND if sw_rounded else self.SQUARE
self.img._img.paste(nw, (box[0][0], box[0][1]))
self.img._img.paste(ne, (box[0][0] + self.corner_width, box[0][1]))
self.img._img.paste(
se, (box[0][0] + self.corner_width, box[0][1] + self.corner_width)
)
self.img._img.paste(sw, (box[0][0], box[0][1] + self.corner_width))
class VerticalBarsDrawer(StyledPilQRModuleDrawer):
"""
Draws vertically contiguous groups of modules as long rounded rectangles,
with gaps between neighboring bands (the size of these gaps is inversely
proportional to the horizontal_shrink).
"""
needs_neighbors = True
def __init__(self, horizontal_shrink=0.8):
self.horizontal_shrink = horizontal_shrink
def initialize(self, *args, **kwargs):
super().initialize(*args, **kwargs)
self.half_height = int(self.img.box_size / 2)
self.delta = int((1 - self.horizontal_shrink) * self.half_height)
self.setup_edges()
def setup_edges(self):
mode = self.img.mode
back_color = self.img.color_mask.back_color
front_color = self.img.paint_color
height = self.half_height
width = height * 2
shrunken_width = int(width * self.horizontal_shrink)
self.SQUARE = Image.new(mode, (shrunken_width, height), front_color)
fake_width = width * ANTIALIASING_FACTOR
fake_height = height * ANTIALIASING_FACTOR
base = Image.new(
mode, (fake_width, fake_height), back_color
) # make something 4x bigger for antialiasing
base_draw = ImageDraw.Draw(base)
base_draw.ellipse((0, 0, fake_width, fake_height * 2), fill=front_color)
self.ROUND_TOP = base.resize((shrunken_width, height), Image.Resampling.LANCZOS)
self.ROUND_BOTTOM = self.ROUND_TOP.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
def drawrect(self, box, is_active: "ActiveWithNeighbors"):
if is_active:
# find rounded edges
top_rounded = not is_active.N
bottom_rounded = not is_active.S
top = self.ROUND_TOP if top_rounded else self.SQUARE
bottom = self.ROUND_BOTTOM if bottom_rounded else self.SQUARE
self.img._img.paste(top, (box[0][0] + self.delta, box[0][1]))
self.img._img.paste(
bottom, (box[0][0] + self.delta, box[0][1] + self.half_height)
)
class HorizontalBarsDrawer(StyledPilQRModuleDrawer):
"""
Draws horizontally contiguous groups of modules as long rounded rectangles,
with gaps between neighboring bands (the size of these gaps is inversely
proportional to the vertical_shrink).
"""
needs_neighbors = True
def __init__(self, vertical_shrink=0.8):
self.vertical_shrink = vertical_shrink
def initialize(self, *args, **kwargs):
super().initialize(*args, **kwargs)
self.half_width = int(self.img.box_size / 2)
self.delta = int((1 - self.vertical_shrink) * self.half_width)
self.setup_edges()
def setup_edges(self):
mode = self.img.mode
back_color = self.img.color_mask.back_color
front_color = self.img.paint_color
width = self.half_width
height = width * 2
shrunken_height = int(height * self.vertical_shrink)
self.SQUARE = Image.new(mode, (width, shrunken_height), front_color)
fake_width = width * ANTIALIASING_FACTOR
fake_height = height * ANTIALIASING_FACTOR
base = Image.new(
mode, (fake_width, fake_height), back_color
) # make something 4x bigger for antialiasing
base_draw = ImageDraw.Draw(base)
base_draw.ellipse((0, 0, fake_width * 2, fake_height), fill=front_color)
self.ROUND_LEFT = base.resize(
(width, shrunken_height), Image.Resampling.LANCZOS
)
self.ROUND_RIGHT = self.ROUND_LEFT.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
def drawrect(self, box, is_active: "ActiveWithNeighbors"):
if is_active:
# find rounded edges
left_rounded = not is_active.W
right_rounded = not is_active.E
left = self.ROUND_LEFT if left_rounded else self.SQUARE
right = self.ROUND_RIGHT if right_rounded else self.SQUARE
self.img._img.paste(left, (box[0][0], box[0][1] + self.delta))
self.img._img.paste(
right, (box[0][0] + self.half_width, box[0][1] + self.delta)
)

View File

@@ -0,0 +1,139 @@
import abc
from decimal import Decimal
from typing import TYPE_CHECKING, NamedTuple
from qrcode.image.styles.moduledrawers.base import QRModuleDrawer
from qrcode.compat.etree import ET
if TYPE_CHECKING:
from qrcode.image.svg import SvgFragmentImage, SvgPathImage
ANTIALIASING_FACTOR = 4
class Coords(NamedTuple):
x0: Decimal
y0: Decimal
x1: Decimal
y1: Decimal
xh: Decimal
yh: Decimal
class BaseSvgQRModuleDrawer(QRModuleDrawer):
img: "SvgFragmentImage"
def __init__(self, *, size_ratio: Decimal = Decimal(1), **kwargs):
self.size_ratio = size_ratio
def initialize(self, *args, **kwargs) -> None:
super().initialize(*args, **kwargs)
self.box_delta = (1 - self.size_ratio) * self.img.box_size / 2
self.box_size = Decimal(self.img.box_size) * self.size_ratio
self.box_half = self.box_size / 2
def coords(self, box) -> Coords:
row, col = box[0]
x = row + self.box_delta
y = col + self.box_delta
return Coords(
x,
y,
x + self.box_size,
y + self.box_size,
x + self.box_half,
y + self.box_half,
)
class SvgQRModuleDrawer(BaseSvgQRModuleDrawer):
tag = "rect"
def initialize(self, *args, **kwargs) -> None:
super().initialize(*args, **kwargs)
self.tag_qname = ET.QName(self.img._SVG_namespace, self.tag)
def drawrect(self, box, is_active: bool):
if not is_active:
return
self.img._img.append(self.el(box))
@abc.abstractmethod
def el(self, box): ...
class SvgSquareDrawer(SvgQRModuleDrawer):
def initialize(self, *args, **kwargs) -> None:
super().initialize(*args, **kwargs)
self.unit_size = self.img.units(self.box_size)
def el(self, box):
coords = self.coords(box)
return ET.Element(
self.tag_qname, # type: ignore
x=self.img.units(coords.x0),
y=self.img.units(coords.y0),
width=self.unit_size,
height=self.unit_size,
)
class SvgCircleDrawer(SvgQRModuleDrawer):
tag = "circle"
def initialize(self, *args, **kwargs) -> None:
super().initialize(*args, **kwargs)
self.radius = self.img.units(self.box_half)
def el(self, box):
coords = self.coords(box)
return ET.Element(
self.tag_qname, # type: ignore
cx=self.img.units(coords.xh),
cy=self.img.units(coords.yh),
r=self.radius,
)
class SvgPathQRModuleDrawer(BaseSvgQRModuleDrawer):
img: "SvgPathImage"
def drawrect(self, box, is_active: bool):
if not is_active:
return
self.img._subpaths.append(self.subpath(box))
@abc.abstractmethod
def subpath(self, box) -> str: ...
class SvgPathSquareDrawer(SvgPathQRModuleDrawer):
def subpath(self, box) -> str:
coords = self.coords(box)
x0 = self.img.units(coords.x0, text=False)
y0 = self.img.units(coords.y0, text=False)
x1 = self.img.units(coords.x1, text=False)
y1 = self.img.units(coords.y1, text=False)
return f"M{x0},{y0}H{x1}V{y1}H{x0}z"
class SvgPathCircleDrawer(SvgPathQRModuleDrawer):
def initialize(self, *args, **kwargs) -> None:
super().initialize(*args, **kwargs)
def subpath(self, box) -> str:
coords = self.coords(box)
x0 = self.img.units(coords.x0, text=False)
yh = self.img.units(coords.yh, text=False)
h = self.img.units(self.box_half - self.box_delta, text=False)
x1 = self.img.units(coords.x1, text=False)
# rx,ry is the centerpoint of the arc
# 1? is the x-axis-rotation
# 2? is the large-arc-flag
# 3? is the sweep flag
# x,y is the point the arc is drawn to
return f"M{x0},{yh}A{h},{h} 0 0 0 {x1},{yh}A{h},{h} 0 0 0 {x0},{yh}z"