- Re-enables the 3L version of the font in tools/build_font.py and Makefile.
- Removes /Downloads/qrfont copy targets from Makefile.
- Adds the --delete flag to the Makefile deploy target for remote cleanup.
- Adds an interactive checkbox to index.html ('Apply QR font to textbox') allowing users to type directly in the QR font and observe zero-advance cursor/shaping behavior.
1046 lines
35 KiB
Python
1046 lines
35 KiB
Python
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Iterable
|
|
|
|
from fontTools.feaLib.builder import addOpenTypeFeaturesFromString
|
|
from fontTools.fontBuilder import FontBuilder
|
|
from fontTools.misc.transform import Transform
|
|
from fontTools.pens.ttGlyphPen import TTGlyphPen
|
|
from fontTools.pens.transformPen import TransformPen
|
|
from fontTools.ttLib import TTFont, newTable
|
|
from fontTools.ttLib.tables import _g_a_s_p
|
|
from fontTools.ttLib.tables.ttProgram import Program
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
BUILD = ROOT / "build"
|
|
DIST = ROOT / "dist"
|
|
DEFAULT_BASE_FONT = Path("/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf")
|
|
|
|
MODULE = 100
|
|
QUIET = 4
|
|
MASK = 0
|
|
RENDER_X_BIAS = 0
|
|
MODULE_OVERPAINT = 8
|
|
SUPPORTED_CODES = [c for c in range(32, 127) if c not in (ord("["), ord("]"))]
|
|
LATIN_SCALE = 1.00
|
|
LATIN_Y_SHIFT = 220
|
|
|
|
|
|
QR_CONFIGS = {
|
|
"1L": {"version": 1, "size": 21, "data_codewords": 19, "ec_codewords": 7, "max_len": 17},
|
|
"2L": {"version": 2, "size": 25, "data_codewords": 34, "ec_codewords": 10, "max_len": 32},
|
|
"3L": {"version": 3, "size": 29, "data_codewords": 55, "ec_codewords": 15, "max_len": 53},
|
|
}
|
|
|
|
QR_LABEL = "1L"
|
|
QR_VERSION = 1
|
|
QR_SIZE = 21
|
|
ADVANCE = (QR_SIZE + QUIET * 2) * MODULE
|
|
UNITS_PER_EM = ADVANCE
|
|
ASCENT = ADVANCE
|
|
DESCENT = 0
|
|
MAX_LEN = 17
|
|
DATA_CODEWORDS = 19
|
|
EC_CODEWORDS = 7
|
|
DATA_BITS = DATA_CODEWORDS * 8
|
|
PARITY_BITS = EC_CODEWORDS * 8
|
|
TOTAL_BITS = DATA_BITS + PARITY_BITS
|
|
RS_GEN: list[int] = []
|
|
|
|
def configure_qr(label: str) -> None:
|
|
global QR_LABEL, QR_VERSION, QR_SIZE, ADVANCE, UNITS_PER_EM, ASCENT, DESCENT
|
|
global MAX_LEN, DATA_CODEWORDS, EC_CODEWORDS, DATA_BITS, PARITY_BITS, TOTAL_BITS, RS_GEN
|
|
|
|
config = QR_CONFIGS[label]
|
|
QR_LABEL = label
|
|
QR_VERSION = config["version"]
|
|
QR_SIZE = config["size"]
|
|
ADVANCE = (QR_SIZE + QUIET * 2) * MODULE
|
|
UNITS_PER_EM = ADVANCE
|
|
ASCENT = ADVANCE
|
|
DESCENT = 0
|
|
MAX_LEN = config["max_len"]
|
|
DATA_CODEWORDS = config["data_codewords"]
|
|
EC_CODEWORDS = config["ec_codewords"]
|
|
DATA_BITS = DATA_CODEWORDS * 8
|
|
PARITY_BITS = EC_CODEWORDS * 8
|
|
TOTAL_BITS = DATA_BITS + PARITY_BITS
|
|
RS_GEN = rs_generator(EC_CODEWORDS)
|
|
|
|
|
|
def g_char(code: int) -> str:
|
|
return f"char_{code:03d}"
|
|
|
|
|
|
def g_byte(pos: int, code: int) -> str:
|
|
return f"byte_{pos:02d}_{code:03d}"
|
|
|
|
|
|
def g_d(pos: int, bit: int) -> str:
|
|
return f"d{pos:03d}_{bit}"
|
|
|
|
|
|
def g_p(pos: int, bit: int) -> str:
|
|
return f"p{pos:02d}_{bit}"
|
|
|
|
|
|
def g_c(pos: int) -> str:
|
|
return f"c{pos:02d}"
|
|
|
|
|
|
def g_len(n: int) -> str:
|
|
return f"len_{n:02d}"
|
|
|
|
|
|
def bits_of(value: int, width: int) -> list[int]:
|
|
return [(value >> shift) & 1 for shift in range(width - 1, -1, -1)]
|
|
|
|
|
|
def gf_tables() -> tuple[list[int], list[int]]:
|
|
exp = [0] * 512
|
|
log = [0] * 256
|
|
x = 1
|
|
for i in range(255):
|
|
exp[i] = x
|
|
log[x] = i
|
|
x <<= 1
|
|
if x & 0x100:
|
|
x ^= 0x11D
|
|
for i in range(255, 512):
|
|
exp[i] = exp[i - 255]
|
|
return exp, log
|
|
|
|
|
|
GF_EXP, GF_LOG = gf_tables()
|
|
|
|
|
|
def gf_mul(a: int, b: int) -> int:
|
|
if a == 0 or b == 0:
|
|
return 0
|
|
return GF_EXP[GF_LOG[a] + GF_LOG[b]]
|
|
|
|
|
|
def poly_mul(a: list[int], b: list[int]) -> list[int]:
|
|
out = [0] * (len(a) + len(b) - 1)
|
|
for i, av in enumerate(a):
|
|
for j, bv in enumerate(b):
|
|
out[i + j] ^= gf_mul(av, bv)
|
|
return out
|
|
|
|
|
|
def rs_generator(ec_count: int) -> list[int]:
|
|
gen = [1]
|
|
for i in range(ec_count):
|
|
gen = poly_mul(gen, [1, GF_EXP[i]])
|
|
return gen
|
|
|
|
|
|
def rs_encode(data: list[int], ec_count: int | None = None) -> list[int]:
|
|
if ec_count is None:
|
|
ec_count = EC_CODEWORDS
|
|
ecc = [0] * ec_count
|
|
for value in data:
|
|
factor = value ^ ecc[0]
|
|
ecc = ecc[1:] + [0]
|
|
for i in range(ec_count):
|
|
ecc[i] ^= gf_mul(RS_GEN[i + 1], factor)
|
|
return ecc
|
|
|
|
|
|
configure_qr("1L")
|
|
|
|
|
|
def data_bits_for_text(text: str) -> list[int]:
|
|
payload: list[int] = []
|
|
payload.extend([0, 1, 0, 0])
|
|
payload.extend(bits_of(len(text), 8))
|
|
for ch in text:
|
|
payload.extend(bits_of(ord(ch), 8))
|
|
|
|
remaining = DATA_BITS - len(payload)
|
|
payload.extend([0] * min(4, remaining))
|
|
while len(payload) % 8:
|
|
payload.append(0)
|
|
pad_words = (0xEC, 0x11)
|
|
i = 0
|
|
while len(payload) < DATA_BITS:
|
|
payload.extend(bits_of(pad_words[i % 2], 8))
|
|
i += 1
|
|
return payload[:DATA_BITS]
|
|
|
|
|
|
def bytes_from_bits(bit_values: list[int]) -> list[int]:
|
|
return [
|
|
int("".join(str(b) for b in bit_values[i : i + 8]), 2)
|
|
for i in range(0, len(bit_values), 8)
|
|
]
|
|
|
|
|
|
def parity_bits_for_data(data_bits: list[int]) -> list[int]:
|
|
parity = rs_encode(bytes_from_bits(data_bits))
|
|
out: list[int] = []
|
|
for value in parity:
|
|
out.extend(bits_of(value, 8))
|
|
return out
|
|
|
|
|
|
def derive_parity_matrix() -> list[list[int]]:
|
|
columns: list[list[int]] = []
|
|
for i in range(DATA_BITS):
|
|
data = [0] * DATA_BITS
|
|
data[i] = 1
|
|
columns.append(parity_bits_for_data(data))
|
|
return columns
|
|
|
|
|
|
def is_masked(row: int, col: int) -> bool:
|
|
if MASK == 0:
|
|
return (row + col) % 2 == 0
|
|
raise ValueError("Only mask 0 is implemented")
|
|
|
|
|
|
def draw_square(pen: TTGlyphPen, row: int, col: int, x_shift: int = 0) -> None:
|
|
x0 = (QUIET + col) * MODULE + x_shift + RENDER_X_BIAS - MODULE_OVERPAINT
|
|
y1 = (QUIET + QR_SIZE - row) * MODULE + MODULE_OVERPAINT
|
|
x1 = x0 + MODULE + MODULE_OVERPAINT * 2
|
|
y0 = y1 - MODULE - MODULE_OVERPAINT * 2
|
|
pen.moveTo((x0, y0))
|
|
pen.lineTo((x1, y0))
|
|
pen.lineTo((x1, y1))
|
|
pen.lineTo((x0, y1))
|
|
pen.closePath()
|
|
|
|
|
|
def empty_glyph():
|
|
return TTGlyphPen(None).glyph()
|
|
|
|
|
|
def square_glyph(row: int, col: int, x_shift: int = 0):
|
|
pen = TTGlyphPen(None)
|
|
draw_square(pen, row, col, x_shift)
|
|
return pen.glyph()
|
|
|
|
|
|
def draw_bit_square(pen: TTGlyphPen, bit_index: int, bit: int, coords: list[tuple[int, int]]) -> None:
|
|
row, col = coords[bit_index]
|
|
if bit ^ is_masked(row, col):
|
|
draw_square(pen, row, col)
|
|
|
|
|
|
def bit_group_glyph(bits: Iterable[tuple[int, int]], coords: list[tuple[int, int]], x_shift: int = 0):
|
|
pen = TTGlyphPen(None)
|
|
for bit_index, bit in bits:
|
|
row, col = coords[bit_index]
|
|
if bit ^ is_masked(row, col):
|
|
draw_square(pen, row, col, x_shift)
|
|
return pen.glyph()
|
|
|
|
|
|
def finder_modules(top: int, left: int) -> set[tuple[int, int]]:
|
|
black: set[tuple[int, int]] = set()
|
|
for r in range(7):
|
|
for c in range(7):
|
|
if r in (0, 6) or c in (0, 6) or (2 <= r <= 4 and 2 <= c <= 4):
|
|
black.add((top + r, left + c))
|
|
return black
|
|
|
|
|
|
def alignment_modules(center_row: int, center_col: int) -> set[tuple[int, int]]:
|
|
black: set[tuple[int, int]] = set()
|
|
for row in range(center_row - 2, center_row + 3):
|
|
for col in range(center_col - 2, center_col + 3):
|
|
dr = abs(row - center_row)
|
|
dc = abs(col - center_col)
|
|
if dr == 2 or dc == 2 or (dr == 0 and dc == 0):
|
|
black.add((row, col))
|
|
return black
|
|
|
|
|
|
def alignment_centers() -> list[tuple[int, int]]:
|
|
if QR_VERSION == 1:
|
|
return []
|
|
if QR_VERSION == 2:
|
|
return [(18, 18)]
|
|
if QR_VERSION == 3:
|
|
return [(22, 22)]
|
|
raise ValueError("Only QR versions 1, 2, and 3 are implemented")
|
|
|
|
|
|
def reserved_matrix() -> list[list[bool]]:
|
|
reserved = [[False] * QR_SIZE for _ in range(QR_SIZE)]
|
|
|
|
def reserve(row: int, col: int) -> None:
|
|
if 0 <= row < QR_SIZE and 0 <= col < QR_SIZE:
|
|
reserved[row][col] = True
|
|
|
|
for top, left in ((0, 0), (0, QR_SIZE - 7), (QR_SIZE - 7, 0)):
|
|
for row in range(top - 1, top + 8):
|
|
for col in range(left - 1, left + 8):
|
|
reserve(row, col)
|
|
|
|
for center_row, center_col in alignment_centers():
|
|
for row in range(center_row - 2, center_row + 3):
|
|
for col in range(center_col - 2, center_col + 3):
|
|
reserve(row, col)
|
|
|
|
for i in range(QR_SIZE):
|
|
reserve(6, i)
|
|
reserve(i, 6)
|
|
|
|
for col in range(0, 9):
|
|
reserve(8, col)
|
|
for row in range(0, 9):
|
|
reserve(row, 8)
|
|
for col in range(QR_SIZE - 8, QR_SIZE):
|
|
reserve(8, col)
|
|
for row in range(QR_SIZE - 7, QR_SIZE):
|
|
reserve(row, 8)
|
|
|
|
reserve(4 * QR_VERSION + 9, 8)
|
|
return reserved
|
|
|
|
|
|
def all_data_coordinates() -> list[tuple[int, int]]:
|
|
reserved = reserved_matrix()
|
|
coords: list[tuple[int, int]] = []
|
|
upward = True
|
|
col = QR_SIZE - 1
|
|
while col > 0:
|
|
if col == 6:
|
|
col -= 1
|
|
rows = range(QR_SIZE - 1, -1, -1) if upward else range(QR_SIZE)
|
|
for row in rows:
|
|
for c in (col, col - 1):
|
|
if not reserved[row][c]:
|
|
coords.append((row, c))
|
|
upward = not upward
|
|
col -= 2
|
|
return coords
|
|
|
|
|
|
def data_coordinates() -> list[tuple[int, int]]:
|
|
coords = all_data_coordinates()
|
|
if len(coords) < TOTAL_BITS:
|
|
raise RuntimeError(f"expected at least {TOTAL_BITS} data coordinates, got {len(coords)}")
|
|
return coords[:TOTAL_BITS]
|
|
|
|
|
|
def remainder_coordinates() -> list[tuple[int, int]]:
|
|
return all_data_coordinates()[TOTAL_BITS:]
|
|
|
|
|
|
def format_bits() -> list[int]:
|
|
# EC level L is 01; mask pattern 0 is 000.
|
|
data = (0b01 << 3) | MASK
|
|
value = data << 10
|
|
poly = 0x537
|
|
for shift in range(14, 9, -1):
|
|
if value & (1 << shift):
|
|
value ^= poly << (shift - 10)
|
|
encoded = ((data << 10) | value) ^ 0x5412
|
|
return bits_of(encoded, 15)
|
|
|
|
|
|
def base_black_modules() -> set[tuple[int, int]]:
|
|
black: set[tuple[int, int]] = set()
|
|
black |= finder_modules(0, 0)
|
|
black |= finder_modules(0, QR_SIZE - 7)
|
|
black |= finder_modules(QR_SIZE - 7, 0)
|
|
for center_row, center_col in alignment_centers():
|
|
black |= alignment_modules(center_row, center_col)
|
|
|
|
for i in range(8, QR_SIZE - 8):
|
|
if i % 2 == 0:
|
|
black.add((6, i))
|
|
black.add((i, 6))
|
|
|
|
black.add((4 * QR_VERSION + 9, 8))
|
|
for row, col in remainder_coordinates():
|
|
if is_masked(row, col):
|
|
black.add((row, col))
|
|
|
|
fmt = format_bits()
|
|
first = [(8, 0), (8, 1), (8, 2), (8, 3), (8, 4), (8, 5), (8, 7), (8, 8),
|
|
(7, 8), (5, 8), (4, 8), (3, 8), (2, 8), (1, 8), (0, 8)]
|
|
second = [(QR_SIZE - 1 - i, 8) for i in range(7)]
|
|
second.extend((8, QR_SIZE - 8 + i) for i in range(8))
|
|
for bit, coord in zip(fmt, first):
|
|
if bit:
|
|
black.add(coord)
|
|
for bit, coord in zip(fmt, second):
|
|
if bit:
|
|
black.add(coord)
|
|
return black
|
|
|
|
|
|
def base_glyph(x_shift: int = 0):
|
|
pen = TTGlyphPen(None)
|
|
for row, col in sorted(base_black_modules()):
|
|
draw_square(pen, row, col, x_shift)
|
|
return pen.glyph()
|
|
|
|
|
|
@dataclass
|
|
class FontData:
|
|
glyph_order: list[str]
|
|
glyphs: dict[str, object]
|
|
advance_widths: dict[str, int]
|
|
cmap: dict[int, str]
|
|
|
|
|
|
def add_empty(name: str, data: FontData, width: int = 0) -> None:
|
|
if name not in data.glyphs:
|
|
data.glyph_order.append(name)
|
|
data.glyphs[name] = empty_glyph()
|
|
data.advance_widths[name] = width
|
|
|
|
|
|
def copy_base_glyph(
|
|
base_font: TTFont,
|
|
source_name: str,
|
|
target_name: str,
|
|
data: FontData,
|
|
scale: float,
|
|
y_shift: int,
|
|
) -> None:
|
|
glyph_set = base_font.getGlyphSet()
|
|
source_glyph = glyph_set[source_name]
|
|
pen = TTGlyphPen(None)
|
|
transform_pen = TransformPen(pen, Transform(scale, 0, 0, scale, 0, y_shift))
|
|
source_glyph.draw(transform_pen)
|
|
data.glyph_order.append(target_name)
|
|
data.glyphs[target_name] = pen.glyph()
|
|
source_width, _ = base_font["hmtx"][source_name]
|
|
scaled_width = round(source_width * scale)
|
|
if scaled_width > 0:
|
|
data.advance_widths[target_name] = scaled_width
|
|
else:
|
|
data.advance_widths[target_name] = 0
|
|
|
|
|
|
def add_printable_base_glyphs(data: FontData, base_font_path: Path) -> None:
|
|
base_font = TTFont(base_font_path)
|
|
scale = (UNITS_PER_EM / base_font["head"].unitsPerEm) * LATIN_SCALE
|
|
cmap = base_font.getBestCmap()
|
|
|
|
notdef_name = ".notdef"
|
|
if notdef_name in base_font.getGlyphOrder():
|
|
copy_base_glyph(base_font, notdef_name, ".notdef", data, scale, LATIN_Y_SHIFT)
|
|
else:
|
|
data.glyph_order.append(".notdef")
|
|
data.glyphs[".notdef"] = empty_glyph()
|
|
data.advance_widths[".notdef"] = ADVANCE
|
|
|
|
printable_names = {
|
|
32: "space",
|
|
ord("["): "open_delim",
|
|
ord("]"): "close_delim",
|
|
}
|
|
printable_names.update({code: g_char(code) for code in SUPPORTED_CODES})
|
|
|
|
for code in range(32, 127):
|
|
target_name = printable_names[code]
|
|
source_name = cmap.get(code)
|
|
if source_name is None:
|
|
add_empty(target_name, data, round(base_font["head"].unitsPerEm * 0.25 * scale) if code == 32 else 0)
|
|
else:
|
|
copy_base_glyph(base_font, source_name, target_name, data, scale, LATIN_Y_SHIFT)
|
|
data.cmap[code] = target_name
|
|
|
|
|
|
def build_font_data(base_font_path: Path = DEFAULT_BASE_FONT) -> FontData:
|
|
data = FontData([], {}, {}, {})
|
|
add_printable_base_glyphs(data, base_font_path)
|
|
coords = data_coordinates()
|
|
|
|
add_empty("empty", data)
|
|
|
|
data.glyph_order.append("header_bits")
|
|
data.glyphs["header_bits"] = bit_group_glyph(
|
|
((i, bit) for i, bit in enumerate([0, 1, 0, 0])), coords
|
|
)
|
|
data.advance_widths["header_bits"] = 0
|
|
|
|
for i in range(8):
|
|
add_empty(g_c(i), data)
|
|
for n in range(MAX_LEN + 1):
|
|
add_empty(g_len(n), data)
|
|
for j in range(EC_CODEWORDS):
|
|
for val in range(256):
|
|
add_empty(f"s{j}_{val:03d}", data)
|
|
for pos in range(MAX_LEN):
|
|
for code in SUPPORTED_CODES:
|
|
name = g_byte(pos, code)
|
|
start = 12 + pos * 8
|
|
bits = ((start + i, bit) for i, bit in enumerate(bits_of(code, 8)))
|
|
data.glyph_order.append(name)
|
|
data.glyphs[name] = bit_group_glyph(bits, coords)
|
|
data.advance_widths[name] = 0
|
|
|
|
for length in range(MAX_LEN + 1):
|
|
count_name = f"count_{length:02d}"
|
|
data.glyph_order.append(count_name)
|
|
data.glyphs[count_name] = bit_group_glyph(
|
|
((4 + i, bit) for i, bit in enumerate(bits_of(length, 8))),
|
|
coords,
|
|
)
|
|
data.advance_widths[count_name] = 0
|
|
|
|
used = 12 + length * 8
|
|
tail = close_tail_bits(length)
|
|
tail_name = f"tail_{length:02d}"
|
|
data.glyph_order.append(tail_name)
|
|
data.glyphs[tail_name] = bit_group_glyph(
|
|
((used + i, bit) for i, bit in enumerate(tail)),
|
|
coords,
|
|
)
|
|
data.advance_widths[tail_name] = 0
|
|
|
|
parity_name = f"parity_zero_{length:02d}"
|
|
data.glyph_order.append(parity_name)
|
|
data.glyphs[parity_name] = bit_group_glyph(
|
|
((DATA_BITS + i, 0) for i in range(PARITY_BITS)),
|
|
coords,
|
|
)
|
|
data.advance_widths[parity_name] = 0
|
|
|
|
base_name = f"qr_base_{length:02d}"
|
|
data.glyph_order.append(base_name)
|
|
data.glyphs[base_name] = base_glyph()
|
|
data.advance_widths[base_name] = ADVANCE
|
|
|
|
for i, (row, col) in enumerate(coords[:DATA_BITS]):
|
|
for bit in (0, 1):
|
|
name = g_d(i, bit)
|
|
data.glyph_order.append(name)
|
|
data.glyphs[name] = square_glyph(row, col) if (bit ^ is_masked(row, col)) else empty_glyph()
|
|
data.advance_widths[name] = 0
|
|
for i, (row, col) in enumerate(coords[DATA_BITS:]):
|
|
for bit in (0, 1):
|
|
name = g_p(i, bit)
|
|
data.glyph_order.append(name)
|
|
data.glyphs[name] = square_glyph(row, col) if (bit ^ is_masked(row, col)) else empty_glyph()
|
|
data.advance_widths[name] = 0
|
|
|
|
# Fused glyphs: p55_bit + qr_base_NN merged into one glyph.
|
|
# These are produced by the MergeAll ligature lookup.
|
|
last_parity_idx = PARITY_BITS - 1
|
|
last_row, last_col = coords[DATA_BITS + last_parity_idx]
|
|
for bit in (0, 1):
|
|
p55_draws = bool(bit ^ is_masked(last_row, last_col))
|
|
for length in range(MAX_LEN + 1):
|
|
name = f"qr_base_p55_{bit}_{length:02d}"
|
|
pen = TTGlyphPen(None)
|
|
for r, c in sorted(base_black_modules()):
|
|
draw_square(pen, r, c)
|
|
if p55_draws:
|
|
draw_square(pen, last_row, last_col)
|
|
data.glyph_order.append(name)
|
|
data.glyphs[name] = pen.glyph()
|
|
data.advance_widths[name] = ADVANCE
|
|
|
|
|
|
return data
|
|
|
|
|
|
def class_line(name: str, members: Iterable[str]) -> str:
|
|
return f"@{name} = [{' '.join(members)}];"
|
|
|
|
|
|
def grouped_internal_glyphs(include_parity_circuit: bool = False) -> list[str]:
|
|
names = ["header_bits"]
|
|
for pos in range(MAX_LEN):
|
|
names.extend(g_byte(pos, code) for code in SUPPORTED_CODES)
|
|
for length in range(MAX_LEN + 1):
|
|
names.extend((f"count_{length:02d}", f"tail_{length:02d}"))
|
|
if not include_parity_circuit:
|
|
names.append(f"parity_zero_{length:02d}")
|
|
if include_parity_circuit:
|
|
for j in range(EC_CODEWORDS):
|
|
names.extend(f"s{j}_{val:03d}" for val in range(256))
|
|
for i in range(PARITY_BITS):
|
|
names.extend((g_p(i, 0), g_p(i, 1)))
|
|
return names
|
|
|
|
|
|
def grouped_any_glyphs(include_parity_circuit: bool = False) -> list[str]:
|
|
names = grouped_internal_glyphs(include_parity_circuit)
|
|
names.extend(f"qr_base_{length:02d}" for length in range(MAX_LEN + 1))
|
|
return names
|
|
|
|
|
|
def grouped_follow_glyphs(include_parity_circuit: bool = False) -> list[str]:
|
|
names = ["empty", *grouped_any_glyphs(include_parity_circuit)]
|
|
if include_parity_circuit:
|
|
for j in range(EC_CODEWORDS):
|
|
for val in range(256):
|
|
names.append(f"s{j}_{val:03d}")
|
|
for i in range(PARITY_BITS):
|
|
for bit in (0, 1):
|
|
names.append(g_p(i, bit))
|
|
return names
|
|
|
|
|
|
def is_qr_render_glyph(name: str) -> bool:
|
|
return (
|
|
name == "header_bits"
|
|
or name.startswith("byte_")
|
|
or name.startswith("count_")
|
|
or name.startswith("tail_")
|
|
or name.startswith("parity_zero_")
|
|
or name.startswith("qr_base_")
|
|
or name.startswith("d")
|
|
or name.startswith("p")
|
|
)
|
|
|
|
|
|
def add_qr_noop_programs(font_data: FontData) -> None:
|
|
program = Program()
|
|
program.fromAssembly(["PUSHB[1] 0", "POP[]"])
|
|
for name, glyph in font_data.glyphs.items():
|
|
if is_qr_render_glyph(name) and getattr(glyph, "numberOfContours", 0):
|
|
glyph.program = program
|
|
|
|
|
|
def close_tail_bits(length: int) -> list[int]:
|
|
used = 12 + length * 8
|
|
bits: list[int] = []
|
|
remaining = DATA_BITS - used
|
|
bits.extend([0] * min(4, remaining))
|
|
while (used + len(bits)) % 8:
|
|
bits.append(0)
|
|
pads = [0xEC, 0x11]
|
|
i = 0
|
|
while used + len(bits) < DATA_BITS:
|
|
bits.extend(bits_of(pads[i % 2], 8))
|
|
i += 1
|
|
return bits
|
|
|
|
|
|
def grouped_close_payload(length: int) -> list[str]:
|
|
return [f"count_{length:02d}", f"tail_{length:02d}", f"parity_zero_{length:02d}", f"qr_base_{length:02d}"]
|
|
|
|
|
|
def parity_contribution_for_byte(pos: int, code: int, matrix: list[list[int]]) -> list[int]:
|
|
start = 12 + pos * 8
|
|
bits = bits_of(code, 8)
|
|
contribution = [0] * PARITY_BITS
|
|
for i, bit in enumerate(bits):
|
|
if bit:
|
|
column = matrix[start + i]
|
|
for p in range(PARITY_BITS):
|
|
contribution[p] ^= column[p]
|
|
return contribution
|
|
|
|
|
|
def fixed_parity_contribution(length: int, matrix: list[list[int]]) -> list[int]:
|
|
fixed_bits = [0] * DATA_BITS
|
|
|
|
# Mode: 0, 1, 0, 0
|
|
mode = [0, 1, 0, 0]
|
|
for i in range(4):
|
|
fixed_bits[i] = mode[i]
|
|
|
|
# Length:
|
|
len_bits = bits_of(length, 8)
|
|
for i in range(8):
|
|
fixed_bits[4 + i] = len_bits[i]
|
|
|
|
# Tail/padding:
|
|
tail = close_tail_bits(length)
|
|
start = 12 + length * 8
|
|
for i, bit in enumerate(tail):
|
|
fixed_bits[start + i] = bit
|
|
|
|
# XOR sum of columns of the matrix for all fixed bits that are 1:
|
|
contribution = [0] * PARITY_BITS
|
|
for i in range(DATA_BITS):
|
|
if fixed_bits[i]:
|
|
column = matrix[i]
|
|
for p in range(PARITY_BITS):
|
|
contribution[p] ^= column[p]
|
|
return contribution
|
|
|
|
|
|
def generate_features(include_parity_circuit: bool = False) -> str:
|
|
languagesystems = ["languagesystem DFLT dflt;", "languagesystem latn dflt;", ""]
|
|
|
|
# We will build all_lines from the ground up
|
|
all_lines: list[str] = [*languagesystems]
|
|
|
|
# 1. Define classes first!
|
|
for pos in range(MAX_LEN):
|
|
all_lines.append(class_line(f"byte_{pos:02d}", (g_byte(pos, c) for c in SUPPORTED_CODES)))
|
|
if include_parity_circuit:
|
|
# Define parity classes
|
|
for i in range(PARITY_BITS):
|
|
all_lines.append(class_line(f"p{i:02d}", (g_p(i, 0), g_p(i, 1))))
|
|
# Define state classes and their XOR permutations
|
|
for j in range(EC_CODEWORDS):
|
|
all_lines.append(class_line(f"s{j}", (f"s{j}_{val:03d}" for val in range(256))))
|
|
for contrib in range(1, 256):
|
|
permuted_glyphs = [f"s{j}_{val ^ contrib:03d}" for val in range(256)]
|
|
all_lines.append(class_line(f"s{j}_x{contrib:03d}", permuted_glyphs))
|
|
|
|
all_lines.append(class_line("SUPPORTED_CHARS", (g_char(c) for c in SUPPORTED_CODES)))
|
|
all_lines.append("")
|
|
|
|
# 2. Generate NoOp, XorS, and helper lookups
|
|
helper_lookups: list[str] = []
|
|
if include_parity_circuit:
|
|
# Generate NoOp lookup using class-to-class identity substitutions and useExtension
|
|
helper_lookups.append("lookup NoOp useExtension {")
|
|
for j in range(EC_CODEWORDS):
|
|
helper_lookups.append(f" sub @s{j} by @s{j};")
|
|
for pos in range(MAX_LEN):
|
|
helper_lookups.append(f" sub @byte_{pos:02d} by @byte_{pos:02d};")
|
|
helper_lookups.append("} NoOp;")
|
|
helper_lookups.append("")
|
|
|
|
# Generate Xor lookups using class-to-class substitutions and useExtension
|
|
for contrib in range(1, 256):
|
|
lookup_name = f"Xor_{contrib:03d}"
|
|
helper_lookups.append(f"lookup {lookup_name} useExtension {{")
|
|
for j in range(EC_CODEWORDS):
|
|
helper_lookups.append(f" sub @s{j} by @s{j}_x{contrib:03d};")
|
|
helper_lookups.append(f"}} {lookup_name};")
|
|
helper_lookups.append("")
|
|
|
|
|
|
|
|
# Generate Scan helper lookups
|
|
if include_parity_circuit:
|
|
# Pre-generate SetByte lookups (combined per character to reduce lookup count)
|
|
for code in SUPPORTED_CODES:
|
|
a = f"SetByte_{code:03d}"
|
|
helper_lookups.append(
|
|
f"lookup {a} useExtension {{ "
|
|
+ " ".join(f"sub len_{pos:02d} by {g_byte(pos, code)};" for pos in range(MAX_LEN))
|
|
+ f" }} {a};"
|
|
)
|
|
# Pre-generate SetLen lookups
|
|
for pos in range(MAX_LEN):
|
|
b = f"SetLen{pos + 1:02d}"
|
|
helper_lookups.append(
|
|
f"lookup {b} useExtension {{ sub @SUPPORTED_CHARS by len_{pos+1:02d}; }} {b};"
|
|
)
|
|
else:
|
|
# Placeholder Scan helper lookups (combined to reduce lookup count)
|
|
for code in SUPPORTED_CODES:
|
|
a = f"SetByte_{code:03d}"
|
|
b = f"SetLen_{code:03d}"
|
|
helper_lookups.extend([
|
|
f"lookup {a} useExtension {{ "
|
|
+ " ".join(f"sub {g_len(pos)} by {g_byte(pos, code)};" for pos in range(MAX_LEN))
|
|
+ f" }} {a};",
|
|
f"lookup {b} useExtension {{ "
|
|
+ " ".join(f"sub {g_char(code)} by {g_len(pos + 1)};" for pos in range(MAX_LEN))
|
|
+ f" }} {b};",
|
|
])
|
|
|
|
# Generate Close helper lookups
|
|
if include_parity_circuit:
|
|
# Pre-generate SetCountTail lookups
|
|
for length in range(MAX_LEN + 1):
|
|
helper_lookups.append(
|
|
f"lookup SetCountTail_{length:02d} useExtension {{ "
|
|
f"sub len_{length:02d} by count_{length:02d} tail_{length:02d}; "
|
|
f"}} SetCountTail_{length:02d};"
|
|
)
|
|
# Pre-generate SetBase lookups
|
|
for length in range(MAX_LEN + 1):
|
|
helper_lookups.append(
|
|
f"lookup SetBase_{length:02d} useExtension {{ "
|
|
f"sub close_delim by qr_base_{length:02d}; "
|
|
f"}} SetBase_{length:02d};"
|
|
)
|
|
else:
|
|
# Placeholder Close helper lookups
|
|
helper_lookups.append("lookup HideClose useExtension {")
|
|
helper_lookups.append(" sub close_delim by empty;")
|
|
helper_lookups.append("} HideClose;")
|
|
helper_lookups.append("")
|
|
for length in range(MAX_LEN + 1):
|
|
name = f"Close{length:02d}"
|
|
helper_lookups.append(f"lookup {name} useExtension {{")
|
|
close_payload = grouped_close_payload(length)
|
|
helper_lookups.append(f" sub {g_len(length)} by {' '.join(close_payload)};")
|
|
helper_lookups.append(f"}} {name};")
|
|
helper_lookups.append("")
|
|
|
|
# Add all helper lookups to all_lines
|
|
all_lines.extend(helper_lookups)
|
|
|
|
# 3. Main lookups (OpenQR, Scan{pos}, CloseQR)
|
|
main_lines: list[str] = []
|
|
|
|
# OpenQR
|
|
if include_parity_circuit:
|
|
open_replacement = "header_bits len_00 " + " ".join(f"s{j}_000" for j in range(EC_CODEWORDS))
|
|
else:
|
|
open_replacement = "header_bits len_00"
|
|
main_lines.extend([
|
|
"lookup OpenQR useExtension {",
|
|
f" sub open_delim by {open_replacement};",
|
|
"} OpenQR;",
|
|
"",
|
|
])
|
|
feature_lookups: list[str] = ["OpenQR"]
|
|
|
|
# Scan{pos}
|
|
if include_parity_circuit:
|
|
matrix = derive_parity_matrix()
|
|
for pos in range(MAX_LEN):
|
|
scan_name = f"Scan{pos:02d}"
|
|
main_lines.append(f"lookup {scan_name} useExtension {{")
|
|
for code in SUPPORTED_CODES:
|
|
bit_contrib = parity_contribution_for_byte(pos, code, matrix)
|
|
byte_contrib = bytes_from_bits(bit_contrib)
|
|
rule_parts = []
|
|
|
|
if pos == 0:
|
|
rule_parts.append(f"len_{pos:02d}' lookup SetByte_{code:03d}")
|
|
for j in range(EC_CODEWORDS):
|
|
contrib = byte_contrib[j]
|
|
if contrib:
|
|
rule_parts.append(f"@s{j}' lookup Xor_{contrib:03d}")
|
|
else:
|
|
rule_parts.append(f"@s{j}' lookup NoOp")
|
|
rule_parts.append(f"{g_char(code)}' lookup SetLen{pos + 1:02d}")
|
|
else:
|
|
for j in range(EC_CODEWORDS):
|
|
contrib = byte_contrib[j]
|
|
if contrib:
|
|
rule_parts.append(f"@s{j}' lookup Xor_{contrib:03d}")
|
|
else:
|
|
rule_parts.append(f"@s{j}' lookup NoOp")
|
|
for k in range(1, pos):
|
|
rule_parts.append(f"@byte_{k:02d}' lookup NoOp")
|
|
rule_parts.append(f"len_{pos:02d}' lookup SetByte_{code:03d}")
|
|
rule_parts.append(f"{g_char(code)}' lookup SetLen{pos + 1:02d}")
|
|
|
|
main_lines.append(f" sub {' '.join(rule_parts)};")
|
|
main_lines.append(f"}} {scan_name};")
|
|
main_lines.append("")
|
|
feature_lookups.append(scan_name)
|
|
else:
|
|
for pos in range(MAX_LEN):
|
|
scan_name = f"Scan{pos:02d}"
|
|
main_lines.append(f"lookup {scan_name} useExtension {{")
|
|
for code in SUPPORTED_CODES:
|
|
a = f"SetByte_{code:03d}"
|
|
b = f"SetLen_{code:03d}"
|
|
main_lines.append(f" sub {g_len(pos)}' lookup {a} {g_char(code)}' lookup {b};")
|
|
main_lines.append(f"}} {scan_name};")
|
|
main_lines.append("")
|
|
feature_lookups.append(scan_name)
|
|
|
|
# CloseQR
|
|
if include_parity_circuit:
|
|
matrix = derive_parity_matrix()
|
|
main_lines.append("lookup CloseQR useExtension {")
|
|
for length in range(MAX_LEN + 1):
|
|
bit_fixed = fixed_parity_contribution(length, matrix)
|
|
byte_fixed = bytes_from_bits(bit_fixed)
|
|
rule_parts = []
|
|
for j in range(EC_CODEWORDS):
|
|
contrib = byte_fixed[j]
|
|
if contrib:
|
|
rule_parts.append(f"@s{j}' lookup Xor_{contrib:03d}")
|
|
else:
|
|
rule_parts.append(f"@s{j}' lookup NoOp")
|
|
for k in range(1, length):
|
|
rule_parts.append(f"@byte_{k:02d}' lookup NoOp")
|
|
rule_parts.append(f"len_{length:02d}' lookup NoOp")
|
|
rule_parts.append(f"close_delim' lookup SetBase_{length:02d}")
|
|
main_lines.append(f" sub {' '.join(rule_parts)};")
|
|
main_lines.append("} CloseQR;")
|
|
main_lines.append("")
|
|
feature_lookups.append("CloseQR")
|
|
|
|
main_lines.append("lookup CloseQR_CountTail useExtension {")
|
|
for length in range(MAX_LEN + 1):
|
|
main_lines.append(f" sub len_{length:02d}' lookup SetCountTail_{length:02d} qr_base_{length:02d};")
|
|
main_lines.append("} CloseQR_CountTail;")
|
|
main_lines.append("")
|
|
feature_lookups.append("CloseQR_CountTail")
|
|
else:
|
|
main_lines.append("lookup CloseQR useExtension {")
|
|
for length in range(MAX_LEN + 1):
|
|
main_lines.append(f" sub {g_len(length)}' lookup Close{length:02d} close_delim' lookup HideClose;")
|
|
main_lines.append("} CloseQR;")
|
|
main_lines.append("")
|
|
feature_lookups.append("CloseQR")
|
|
|
|
# Add ExpandState to main_lines and feature_lookups
|
|
if include_parity_circuit:
|
|
main_lines.append("lookup ExpandState useExtension {")
|
|
for j in range(EC_CODEWORDS):
|
|
for val in range(256):
|
|
bits = bits_of(val, 8)
|
|
target_glyphs = [g_p(j * 8 + b, bit) for b, bit in enumerate(bits)]
|
|
main_lines.append(f" sub s{j}_{val:03d} by {' '.join(target_glyphs)};")
|
|
main_lines.append("} ExpandState;")
|
|
main_lines.append("")
|
|
feature_lookups.append("ExpandState")
|
|
|
|
# MergeAll
|
|
main_lines.append("lookup MergeAll {")
|
|
for bit in (0, 1):
|
|
for length in range(MAX_LEN + 1):
|
|
p55_name = g_p(PARITY_BITS - 1, bit)
|
|
fused_name = f"qr_base_p55_{bit}_{length:02d}"
|
|
main_lines.append(
|
|
f" sub {p55_name} qr_base_{length:02d} by {fused_name};"
|
|
)
|
|
main_lines.append("} MergeAll;")
|
|
main_lines.append("")
|
|
feature_lookups.append("MergeAll")
|
|
|
|
|
|
|
|
# Add main lines to all_lines
|
|
all_lines.extend(main_lines)
|
|
|
|
# 4. Feature sections
|
|
all_lines.append("feature rlig {")
|
|
for name in feature_lookups:
|
|
all_lines.append(f" lookup {name};")
|
|
all_lines.append("} rlig;")
|
|
all_lines.append("")
|
|
|
|
|
|
return "\n".join(all_lines)
|
|
|
|
|
|
def build_ttf(font_data: FontData, feature_text: str, output: Path) -> None:
|
|
add_qr_noop_programs(font_data)
|
|
fb = FontBuilder(UNITS_PER_EM, isTTF=True)
|
|
fb.setupGlyphOrder(font_data.glyph_order)
|
|
fb.setupCharacterMap(font_data.cmap)
|
|
fb.setupGlyf(font_data.glyphs)
|
|
metrics = {}
|
|
for name in font_data.glyph_order:
|
|
glyph = font_data.glyphs[name]
|
|
left_side_bearing = getattr(glyph, "xMin", 0) or 0
|
|
metrics[name] = (font_data.advance_widths[name], left_side_bearing)
|
|
fb.setupHorizontalMetrics(metrics)
|
|
fb.setupHorizontalHeader(ascent=ASCENT, descent=DESCENT)
|
|
fb.setupOS2(
|
|
sTypoAscender=ASCENT,
|
|
sTypoDescender=DESCENT,
|
|
usWinAscent=ASCENT,
|
|
usWinDescent=DESCENT,
|
|
)
|
|
fb.setupNameTable(
|
|
{
|
|
"familyName": f"QR Font {QR_LABEL}",
|
|
"styleName": "Regular",
|
|
"uniqueFontIdentifier": f"QR Font {QR_LABEL} Regular 0.1",
|
|
"fullName": f"QR Font {QR_LABEL} Regular",
|
|
"psName": f"QRFont-{QR_LABEL}-Regular",
|
|
"version": "Version 0.1",
|
|
"copyright": (
|
|
"Derived from Liberation Sans: digitized data copyright (c) 2010 "
|
|
"Google Corporation; copyright (c) 2012 Red Hat, Inc. QR Font "
|
|
"additions copyright their contributors. See https://qr.jim.sh/ for updates."
|
|
),
|
|
"licenseDescription": "Licensed under the SIL Open Font License, Version 1.1.",
|
|
"licenseInfoURL": "https://scripts.sil.org/OFL",
|
|
"designerURL": "https://qr.jim.sh/",
|
|
"vendorURL": "https://qr.jim.sh/",
|
|
}
|
|
)
|
|
fb.setupPost(keepGlyphNames=True)
|
|
font = fb.font
|
|
gasp = newTable("gasp")
|
|
gasp.gaspRange = {
|
|
0xFFFF: (
|
|
_g_a_s_p.GASP_DOGRAY
|
|
| _g_a_s_p.GASP_SYMMETRIC_SMOOTHING
|
|
)
|
|
}
|
|
font["gasp"] = gasp
|
|
addOpenTypeFeaturesFromString(font, feature_text)
|
|
font.save(output)
|
|
|
|
|
|
|
|
def matrix_for_text(text: str) -> set[tuple[int, int]]:
|
|
coords = data_coordinates()
|
|
black = set(base_black_modules())
|
|
data = data_bits_for_text(text)
|
|
parity = parity_bits_for_data(data)
|
|
for i, bit in enumerate(data + parity):
|
|
row, col = coords[i]
|
|
if bit ^ is_masked(row, col):
|
|
black.add((row, col))
|
|
return black
|
|
|
|
|
|
def svg_for_text(text: str, size: int = 150) -> str:
|
|
scale = size / (QR_SIZE + QUIET * 2)
|
|
rects = []
|
|
for row, col in sorted(matrix_for_text(text)):
|
|
x = (QUIET + col) * scale
|
|
y = (QUIET + row) * scale
|
|
rects.append(f'<rect x="{x:.3f}" y="{y:.3f}" width="{scale:.3f}" height="{scale:.3f}"/>')
|
|
return (
|
|
f'<svg viewBox="0 0 {size} {size}" width="{size}" height="{size}" '
|
|
'xmlns="http://www.w3.org/2000/svg">'
|
|
'<rect width="100%" height="100%" fill="white"/>'
|
|
f'<g fill="black">{"".join(rects)}</g></svg>'
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument(
|
|
"--base-font",
|
|
type=Path,
|
|
default=DEFAULT_BASE_FONT,
|
|
help="TrueType font to copy printable glyphs from",
|
|
)
|
|
parser.add_argument(
|
|
"--full-parity-circuit",
|
|
action="store_true",
|
|
help="deprecated no-op; full Reed-Solomon parity is emitted by default",
|
|
)
|
|
parser.add_argument(
|
|
"--placeholder-parity",
|
|
action="store_true",
|
|
help="use zero parity bits for a faster layout-only build",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> None:
|
|
args = parse_args()
|
|
BUILD.mkdir(exist_ok=True)
|
|
DIST.mkdir(exist_ok=True)
|
|
|
|
for stale_font in DIST.glob("qrfont*.ttf"):
|
|
stale_font.unlink()
|
|
|
|
for label in ("1L", "2L", "3L"):
|
|
configure_qr(label)
|
|
print(f"building QR Font {label}...", flush=True)
|
|
font_data = build_font_data(args.base_font)
|
|
feature_text = generate_features(include_parity_circuit=not args.placeholder_parity)
|
|
(BUILD / f"qrfont-{label}.fea").write_text(feature_text, encoding="utf-8")
|
|
output_name = f"qrfont-{label}.ttf"
|
|
build_ttf(font_data, feature_text, DIST / output_name)
|
|
print(f"wrote {DIST / output_name}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|