import itertools import math from typing import List, Optional import qrcode.util from qrcode.image.base import BaseImage from pyhanko.pdf_utils.content import PdfContent from pyhanko.pdf_utils.misc import rd class PdfStreamQRImage(BaseImage): """ Quick-and-dirty implementation of the Image interface required by the qrcode package. """ kind = "PDF" allowed_kinds = ("PDF",) qr_color = (0, 0, 0) def new_image(self, **kwargs): return [] def drawrect(self, row, col): self._img.append((row, col)) def append_single_rect(self, command_stream, row, col): command_stream.append(b'%g %g 1 1 re' % (col, row)) def format_qr_color(self): return (b"%g %g %g rg\n" % self.qr_color) + ( b"%g %g %g RG" % self.qr_color ) def setup_drawing_area(self): # start a command stream with fill colour set to black (default) # and transform the coordinate system to line up with our grid brd = rd(self.border * self.box_size) ydiff = rd(self.width * self.box_size) cm = f"{rd(self.box_size)} 0 0 {-rd(self.box_size)} {brd} {brd + ydiff} cm" return b"%s\n%s" % (self.format_qr_color(), cm.encode('ascii')) def render_command_stream(self): command_stream = [self.setup_drawing_area()] for row, col in self._img: # paint a rectangle self.append_single_rect(command_stream, row, col) command_stream.append(b"f") return b'\n'.join(command_stream) def save(self, stream, kind=None): raise NotImplementedError def process(self): raise NotImplementedError def drawrect_context(self, row, col, active, context): return self.drawrect(row, col) # pragma: nocover class PdfFancyQRImage(PdfStreamQRImage): centerpiece_corner_radius = 0.2 def __init__( self, border, width, box_size, *_args, version, center_image: Optional[PdfContent] = None, **kwargs, ): super().__init__(border, width, box_size, **kwargs) self._version = version self._centerpiece = center_image def save(self, stream, kind=None): raise NotImplementedError def process(self): raise NotImplementedError def append_single_rect(self, command_stream, row, col): if self.is_position_pattern(row, col): return command_stream.extend(rounded_square(col, row, 0.9, 0.3)) def is_major_position_pattern(self, row, col): return ( (row < 7 and col < 7) or ((row > self.width - 8) and col < 7) or (row < 7 and (col > self.width - 8)) ) def _enumerate_alignment_patterns(self): adj_ptns = qrcode.util.pattern_position(self._version) for pr, pc in itertools.product(adj_ptns, adj_ptns): # don't consider the bits that fall within the "big" position # patterns if self.is_major_position_pattern(pr, pc): continue yield pr, pc def is_position_pattern(self, row, col): if self.is_major_position_pattern(row, col): return True # check whether this pixel is part of an alignment pattern return any( abs(row - pr) <= 2 and abs(col - pc) <= 2 for pr, pc in self._enumerate_alignment_patterns() ) def draw_position_patterns(self): # draw the surrounding squares of the location patterns first # -> stroked paths (outer squares) # (these require drawing at module midpoints) command_stream = [b'q\n1 0 0 1 0.5 0.5 cm\n0.7 w'] sz = self.width command_stream.extend(rounded_square(0, 0, 6, 1)) command_stream.extend(rounded_square(0, sz - 7, 6, 1)) command_stream.extend(rounded_square(sz - 7, 0, 6, 1)) for pr, pc in self._enumerate_alignment_patterns(): command_stream.extend(rounded_square(pr - 2, pc - 2, 4, 0.7)) command_stream.append(b"S\nQ") # draw the inner squares of the location patterns command_stream.extend(rounded_square(2, 2, 3, 0.6)) command_stream.extend(rounded_square(2, sz - 7 + 2, 3, 0.6)) command_stream.extend(rounded_square(sz - 7 + 2, 2, 3, 0.6)) for pr, pc in self._enumerate_alignment_patterns(): command_stream.extend(rounded_square(pr, pc, 1, 0.1)) command_stream.append(b"f") return b"\n".join(command_stream) def draw_centerpiece(self): c_x, c_y, c_sz = self._measure_out_centerpiece() # better hope it's square c_w = self._centerpiece.box.width c_h = self._centerpiece.box.height # draw the border of the centerpiece centerpiece_commands = [b"q", b"0.2 w"] centerpiece_commands.extend( rounded_square( c_x, c_y, c_sz, self.centerpiece_corner_radius * c_sz ) ) centerpiece_commands.append(b"S\nQ\nq") # transform back into the internal coordinates of the centerpiece # (including any y-axis reversals) # -> f"{x_scale} 0 0 {-y_scale} {c_x} {c_y + c_sz} cm" x_scale = rd(c_sz / c_w) y_scale = rd(c_sz / c_h) # COMBINED WITH: # we slightly shrink and offset the centerpiece to create # a border of sorts # -> f"{shrink} 0 0 {shrink} {x_shift} {y_shift} cm" shrink = 0.85 x_shift = rd((1 - shrink) * c_w / 2) y_shift = rd((1 - shrink) * c_h / 2) centerpiece_commands.append( f"{x_scale * shrink} 0 0 {-y_scale * shrink} " f"{c_x + x_shift * x_scale} {c_y + c_sz - y_shift * y_scale} " f"cm".encode('ascii') ) # resource management left up to the caller centerpiece_commands.append(self._centerpiece.render()) centerpiece_commands.append(b"Q") return b"\n".join(centerpiece_commands) def _measure_out_centerpiece(self): # Centerpiece area takes up a square in the QR code with a side of # about 28% the total size of the QR code itself c_sz = 0.28 * self.width c_x = (self.width - c_sz) / 2 c_y = (self.width - c_sz) / 2 return rd(c_x), rd(c_y), rd(c_sz) def setup_drawing_area(self): basic_setup = super().setup_drawing_area() commands = [basic_setup] if self._centerpiece is not None: # we need to clip out the centerpiece area. # we'll do that by creating a clip path that goes around the # entire QR code, and a rounded rectangle where the centerpiece # would go using opposite orientation. # -> clockwise rectangle w = rd(self.width) # save the state to undo the clipping later commands.append(b"q") commands.append(b"0.2 w") commands.append( f"0 0 m 0 {w} l {w} {w} l {w} 0 l h".encode("ascii"), ) c_x, c_y, c_sz = self._measure_out_centerpiece() commands.extend( rounded_square( c_x, c_y, c_sz, self.centerpiece_corner_radius * c_sz ) ) commands.append(b"W n") return b"\n".join(commands) def render_command_stream(self): parts = [super().render_command_stream(), self.draw_position_patterns()] if self._centerpiece: parts.append(b"Q") # undo clipping parts.append(self.draw_centerpiece()) return b"\n".join(parts) def rounded_square( x_pos: float, y_pos: float, sz: float, rad: float ) -> List[bytes]: """ Add a subpath of a square with rounded corners at the given position. Doesn't include any painting or clipping operations. The path is drawn counterclockwise. :param x_pos: The x-coordinate of the enveloping square's lower left corner. :param y_pos: The y-coordinate of the enveloping square's lower left corner. :param sz: The side length of the enveloping square. :param rad: The corner radius. :return: A list of graphics operators. """ c_off = (4 * (math.sqrt(2) - 1) / 3) * rad result = [] def fmt(x, y): px = rd(x_pos + x) py = rd(y_pos + y) result.append(f"{px} {py} ".encode('ascii')) def op(pts, opc: str): for x, y in pts: fmt(x, y) result.append(opc.encode('ascii')) def uop(x, y, opc): op([(x, y)], opc) uop(rad, 0, "m") uop(sz - rad, 0, "l") op([(sz - c_off, 0), (sz, c_off), (sz, rad)], "c") uop(sz, sz - rad, "l") op([(sz, sz - c_off), (sz - c_off, sz), (sz - rad, sz)], "c") uop(rad, sz, "l") op([(c_off, sz), (0, sz - c_off), (0, sz - rad)], "c") uop(0, rad, "l") op([(0, c_off), (c_off, 0), (rad, 0)], "c") result.append(b"h") return result