0
0

Compare commits

...

2 Commits

Author SHA1 Message Date
e6f00f6a78 Add Version 3-L QR font support and optimize GSUB lookup structure
* Add Version 3-L configurations and update documentation (AGENTS.md, README.md).
* Solve OpenType GSUB offset overflows and compiler bottlenecks:
  - Combine SetByte lookups per character instead of position/character, reducing the total lookup count from 5,300 to 510.
  - Implement useExtension on helper and scan lookups to bypass the 16-bit offset constraints.
  - Use class-to-class substitutions for XOR and NoOp lookups.
* Split CloseQR into CloseQR and CloseQR_CountTail to prevent shaper offset mismatches from Multiple Substitutions.
* Remove legacy reference.html output from the build and Makefile packaging.
* Update demo.html style and font selectors to support loading and displaying the 3L font with character limit labeling.
* Keep glyph names in setupPost() to ensure shape_debug.py outputs readable glyph names.
2026-06-27 00:27:54 -04:00
b19691a024 Make demo page public-facing 2026-06-26 21:27:06 -04:00
4 changed files with 498 additions and 244 deletions

View File

@@ -11,10 +11,10 @@ SIL Open Font License 1.1.
The current bounded QR target is: The current bounded QR target is:
- QR Code Version 1-L - QR Code Version 3-L
- byte mode - byte mode
- fixed mask pattern 0 - fixed mask pattern 0
- printable ASCII payloads up to 17 characters per bracketed block - printable ASCII payloads up to 53 characters per bracketed block
- `[` and `]` delimiters - `[` and `]` delimiters
Example: Example:
@@ -89,9 +89,8 @@ screenshots in `~/Downloads`.
## Larger QR Codes ## Larger QR Codes
Version 1-L only supports 17 byte-mode characters. Larger QR versions are The current font is fixed to Version 3-L, which supports 53 byte-mode
possible, but the current implementation hardcodes Version 1 data capacity, characters. Supporting multiple QR versions in one font is possible, but would
coordinate placement, format layout, and Reed-Solomon parity size. Extending to require branching by payload length at close-delimiter time and emitting
larger versions means generating additional version-specific coordinate maps, version-specific base patterns, coordinate maps, RS parity circuits, and
capacity tables, RS block structures, alignment patterns, and much larger advances. The current simpler strategy is one fixed QR version for all payloads.
OpenType state/parity circuits.

View File

@@ -7,13 +7,13 @@ all:
uv run tools/build_font.py uv run tools/build_font.py
mkdir -p $(HOME)/Downloads/qrfont mkdir -p $(HOME)/Downloads/qrfont
rm -f $(HOME)/Downloads/qrfont/qrfont*.ttf rm -f $(HOME)/Downloads/qrfont/qrfont*.ttf
cp dist/qrfont*.ttf dist/demo.html dist/reference.html LICENSE-OFL.txt NOTICE.md $(HOME)/Downloads/qrfont/ cp dist/qrfont*.ttf dist/demo.html LICENSE-OFL.txt NOTICE.md $(HOME)/Downloads/qrfont/
fast-placeholder: fast-placeholder:
uv run tools/build_font.py --placeholder-parity uv run tools/build_font.py --placeholder-parity
mkdir -p $(HOME)/Downloads/qrfont mkdir -p $(HOME)/Downloads/qrfont
rm -f $(HOME)/Downloads/qrfont/qrfont*.ttf rm -f $(HOME)/Downloads/qrfont/qrfont*.ttf
cp dist/qrfont*.ttf dist/demo.html dist/reference.html LICENSE-OFL.txt NOTICE.md $(HOME)/Downloads/qrfont/ cp dist/qrfont*.ttf dist/demo.html LICENSE-OFL.txt NOTICE.md $(HOME)/Downloads/qrfont/
full-parity: all full-parity: all

View File

@@ -9,9 +9,9 @@ abc[hello]ghi
The first target is intentionally bounded: The first target is intentionally bounded:
- QR Code Version 1-L, 21 by 21 modules - QR Code Version 3-L, 29 by 29 modules
- byte mode - byte mode
- printable ASCII input, up to 17 characters - printable ASCII input, up to 53 characters
- fixed mask pattern 0 - fixed mask pattern 0
- `[` and `]` as delimiters - `[` and `]` as delimiters
@@ -91,4 +91,4 @@ licensed under the SIL Open Font License, Version 1.1. See
This is a proof-of-concept font. It relies on OpenType shaping, so it needs an This is a proof-of-concept font. It relies on OpenType shaping, so it needs an
environment that applies GSUB features to the font. Inputs inside a QR block environment that applies GSUB features to the font. Inputs inside a QR block
are bounded to printable ASCII, up to 17 characters. are bounded to printable ASCII, up to 53 characters.

View File

@@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import hashlib
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Iterable from typing import Iterable
@@ -24,15 +23,6 @@ DEFAULT_BASE_FONT = Path("/usr/share/fonts/truetype/liberation/LiberationSans-Re
MODULE = 100 MODULE = 100
QUIET = 4 QUIET = 4
QR_SIZE = 21
ADVANCE = (QR_SIZE + QUIET * 2) * MODULE
UNITS_PER_EM = ADVANCE
ASCENT = ADVANCE
DESCENT = 0
MAX_LEN = 17
DATA_BITS = 152
PARITY_BITS = 56
TOTAL_BITS = DATA_BITS + PARITY_BITS
MASK = 0 MASK = 0
RENDER_X_BIAS = 0 RENDER_X_BIAS = 0
MODULE_OVERPAINT = 8 MODULE_OVERPAINT = 8
@@ -40,6 +30,48 @@ SUPPORTED_CODES = [c for c in range(32, 127) if c not in (ord("["), ord("]"))]
LATIN_SCALE = 0.20 LATIN_SCALE = 0.20
LATIN_Y_SHIFT = 220 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: def g_char(code: int) -> str:
return f"char_{code:03d}" return f"char_{code:03d}"
@@ -108,10 +140,9 @@ def rs_generator(ec_count: int) -> list[int]:
return gen return gen
RS_GEN = rs_generator(7) def rs_encode(data: list[int], ec_count: int | None = None) -> list[int]:
if ec_count is None:
ec_count = EC_CODEWORDS
def rs_encode(data: list[int], ec_count: int = 7) -> list[int]:
ecc = [0] * ec_count ecc = [0] * ec_count
for value in data: for value in data:
factor = value ^ ecc[0] factor = value ^ ecc[0]
@@ -121,6 +152,9 @@ def rs_encode(data: list[int], ec_count: int = 7) -> list[int]:
return ecc return ecc
configure_qr("1L")
def data_bits_for_text(text: str) -> list[int]: def data_bits_for_text(text: str) -> list[int]:
payload: list[int] = [] payload: list[int] = []
payload.extend([0, 1, 0, 0]) payload.extend([0, 1, 0, 0])
@@ -216,6 +250,27 @@ def finder_modules(top: int, left: int) -> set[tuple[int, int]]:
return black 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]]: def reserved_matrix() -> list[list[bool]]:
reserved = [[False] * QR_SIZE for _ in range(QR_SIZE)] reserved = [[False] * QR_SIZE for _ in range(QR_SIZE)]
@@ -223,11 +278,16 @@ def reserved_matrix() -> list[list[bool]]:
if 0 <= row < QR_SIZE and 0 <= col < QR_SIZE: if 0 <= row < QR_SIZE and 0 <= col < QR_SIZE:
reserved[row][col] = True reserved[row][col] = True
for top, left in ((0, 0), (0, 14), (14, 0)): for top, left in ((0, 0), (0, QR_SIZE - 7), (QR_SIZE - 7, 0)):
for row in range(top - 1, top + 8): for row in range(top - 1, top + 8):
for col in range(left - 1, left + 8): for col in range(left - 1, left + 8):
reserve(row, col) 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): for i in range(QR_SIZE):
reserve(6, i) reserve(6, i)
reserve(i, 6) reserve(i, 6)
@@ -241,11 +301,11 @@ def reserved_matrix() -> list[list[bool]]:
for row in range(QR_SIZE - 7, QR_SIZE): for row in range(QR_SIZE - 7, QR_SIZE):
reserve(row, 8) reserve(row, 8)
reserve(13, 8) reserve(4 * QR_VERSION + 9, 8)
return reserved return reserved
def data_coordinates() -> list[tuple[int, int]]: def all_data_coordinates() -> list[tuple[int, int]]:
reserved = reserved_matrix() reserved = reserved_matrix()
coords: list[tuple[int, int]] = [] coords: list[tuple[int, int]] = []
upward = True upward = True
@@ -260,11 +320,20 @@ def data_coordinates() -> list[tuple[int, int]]:
coords.append((row, c)) coords.append((row, c))
upward = not upward upward = not upward
col -= 2 col -= 2
if len(coords) != TOTAL_BITS:
raise RuntimeError(f"expected {TOTAL_BITS} data coordinates, got {len(coords)}")
return coords 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]: def format_bits() -> list[int]:
# EC level L is 01; mask pattern 0 is 000. # EC level L is 01; mask pattern 0 is 000.
data = (0b01 << 3) | MASK data = (0b01 << 3) | MASK
@@ -280,21 +349,26 @@ def format_bits() -> list[int]:
def base_black_modules() -> set[tuple[int, int]]: def base_black_modules() -> set[tuple[int, int]]:
black: set[tuple[int, int]] = set() black: set[tuple[int, int]] = set()
black |= finder_modules(0, 0) black |= finder_modules(0, 0)
black |= finder_modules(0, 14) black |= finder_modules(0, QR_SIZE - 7)
black |= finder_modules(14, 0) 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, 13): for i in range(8, QR_SIZE - 8):
if i % 2 == 0: if i % 2 == 0:
black.add((6, i)) black.add((6, i))
black.add((i, 6)) black.add((i, 6))
black.add((13, 8)) 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() fmt = format_bits()
first = [(8, 0), (8, 1), (8, 2), (8, 3), (8, 4), (8, 5), (8, 7), (8, 8), 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)] (7, 8), (5, 8), (4, 8), (3, 8), (2, 8), (1, 8), (0, 8)]
second = [(20, 8), (19, 8), (18, 8), (17, 8), (16, 8), (15, 8), (14, 8), second = [(QR_SIZE - 1 - i, 8) for i in range(7)]
(8, 13), (8, 14), (8, 15), (8, 16), (8, 17), (8, 18), (8, 19), (8, 20)] second.extend((8, QR_SIZE - 8 + i) for i in range(8))
for bit, coord in zip(fmt, first): for bit, coord in zip(fmt, first):
if bit: if bit:
black.add(coord) black.add(coord)
@@ -396,6 +470,9 @@ def build_font_data(base_font_path: Path = DEFAULT_BASE_FONT) -> FontData:
add_empty(g_c(i), data) add_empty(g_c(i), data)
for n in range(MAX_LEN + 1): for n in range(MAX_LEN + 1):
add_empty(g_len(n), data) 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 pos in range(MAX_LEN):
for code in SUPPORTED_CODES: for code in SUPPORTED_CODES:
name = g_byte(pos, code) name = g_byte(pos, code)
@@ -457,23 +534,33 @@ def class_line(name: str, members: Iterable[str]) -> str:
return f"@{name} = [{' '.join(members)}];" return f"@{name} = [{' '.join(members)}];"
def grouped_internal_glyphs() -> list[str]: def grouped_internal_glyphs(include_parity_circuit: bool = False) -> list[str]:
names = ["header_bits"] names = ["header_bits"]
for pos in range(MAX_LEN): for pos in range(MAX_LEN):
names.extend(g_byte(pos, code) for code in SUPPORTED_CODES) names.extend(g_byte(pos, code) for code in SUPPORTED_CODES)
for length in range(MAX_LEN + 1): for length in range(MAX_LEN + 1):
names.extend((f"count_{length:02d}", f"tail_{length:02d}", f"parity_zero_{length:02d}")) names.extend((f"count_{length:02d}", f"tail_{length:02d}"))
if not include_parity_circuit:
names.append(f"parity_zero_{length:02d}")
return names return names
def grouped_any_glyphs() -> list[str]: def grouped_any_glyphs(include_parity_circuit: bool = False) -> list[str]:
names = grouped_internal_glyphs() names = grouped_internal_glyphs(include_parity_circuit)
names.extend(f"qr_base_{length:02d}" for length in range(MAX_LEN + 1)) names.extend(f"qr_base_{length:02d}" for length in range(MAX_LEN + 1))
return names return names
def grouped_follow_glyphs() -> list[str]: def grouped_follow_glyphs(include_parity_circuit: bool = False) -> list[str]:
return ["empty", *grouped_any_glyphs()] 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: def is_qr_render_glyph(name: str) -> bool:
@@ -511,190 +598,293 @@ def close_tail_bits(length: int) -> list[int]:
i += 1 i += 1
return bits return bits
def grouped_close_payload(length: int) -> list[str]: 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}"] return [f"count_{length:02d}", f"tail_{length:02d}", f"parity_zero_{length:02d}", f"qr_base_{length:02d}"]
def bit_close_payload(length: int) -> list[str]: def parity_contribution_for_byte(pos: int, code: int, matrix: list[list[int]]) -> list[int]:
used = 12 + length * 8
payload = [g_d(used + i, bit) for i, bit in enumerate(close_tail_bits(length))]
payload.extend(g_p(i, 0) for i in range(PARITY_BITS))
payload.append(f"qr_base_{length:02d}")
return payload
def data_context_parts(length: int, focus_index: int) -> list[str]:
def data_part(index: int) -> str:
return g_d(index, 1) if index == focus_index else f"@d{index:03d}"
parts = [data_part(i) for i in range(12)]
for pos in range(length):
parts.append("empty")
start = 12 + pos * 8 start = 12 + pos * 8
parts.extend(data_part(i) for i in range(start, start + 8)) bits = bits_of(code, 8)
used = 12 + length * 8 contribution = [0] * PARITY_BITS
parts.append("empty") for i, bit in enumerate(bits):
parts.extend(data_part(i) for i in range(used, DATA_BITS)) if bit:
return parts 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: def generate_features(include_parity_circuit: bool = False) -> str:
languagesystems = ["languagesystem DFLT dflt;", "languagesystem latn dflt;", ""] languagesystems = ["languagesystem DFLT dflt;", "languagesystem latn dflt;", ""]
lines: list[str] = [*languagesystems]
# We will build all_lines from the ground up
all_lines: list[str] = [*languagesystems]
# 1. Define classes first!
for pos in range(MAX_LEN): for pos in range(MAX_LEN):
lines.append(class_line(f"byte_{pos:02d}", (g_byte(pos, c) for c in SUPPORTED_CODES))) all_lines.append(class_line(f"byte_{pos:02d}", (g_byte(pos, c) for c in SUPPORTED_CODES)))
for i in range(DATA_BITS):
lines.append(class_line(f"d{i:03d}", (g_d(i, 0), g_d(i, 1))))
for i in range(PARITY_BITS):
lines.append(class_line(f"p{i:02d}", (g_p(i, 0), g_p(i, 1))))
lines.append("")
if include_parity_circuit: if include_parity_circuit:
open_replacement = "d000_0 d001_1 d002_0 d003_0 c00 c01 c02 c03 c04 c05 c06 c07 len_00" # 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: else:
open_replacement = "header_bits len_00" open_replacement = "header_bits len_00"
main_lines.extend([
lines.extend([ "lookup OpenQR useExtension {",
"lookup OpenQR {",
f" sub open_delim by {open_replacement};", f" sub open_delim by {open_replacement};",
"} OpenQR;", "} OpenQR;",
"", "",
]) ])
helper_lookups: list[str] = []
feature_lookups: list[str] = ["OpenQR"] feature_lookups: list[str] = ["OpenQR"]
# Scan{pos}
if include_parity_circuit:
matrix = derive_parity_matrix()
for pos in range(MAX_LEN): for pos in range(MAX_LEN):
scan_name = f"Scan{pos:02d}" scan_name = f"Scan{pos:02d}"
hide_name = f"HideScanLen{pos:02d}" main_lines.append(f"lookup {scan_name} useExtension {{")
lines.append(f"lookup {scan_name} {{")
for code in SUPPORTED_CODES: for code in SUPPORTED_CODES:
a = f"SetByte{pos:02d}_{code:03d}" bit_contrib = parity_contribution_for_byte(pos, code, matrix)
helper_lookups.extend([ byte_contrib = bytes_from_bits(bit_contrib)
f"lookup {a} {{ sub {g_char(code)} by {g_byte(pos, code)} {g_len(pos + 1)}; }} {a};", rule_parts = []
])
lines.append(f" sub {g_len(pos)} {g_char(code)}' lookup {a};")
lines.append(f"}} {scan_name};")
lines.append("")
lines.append(f"lookup {hide_name} {{")
lines.append(f" sub {g_len(pos)}' @byte_{pos:02d} {g_len(pos + 1)} by empty;")
lines.append(f"}} {hide_name};")
lines.append("")
feature_lookups.append(scan_name)
feature_lookups.append(hide_name)
count_lookups: list[str] = [] if pos == 0:
for length in range(MAX_LEN + 1): rule_parts.append(f"len_{pos:02d}' lookup SetByte_{code:03d}")
length_bits = bits_of(length, 8) for j in range(EC_CODEWORDS):
for cpos, bit in enumerate(length_bits): contrib = byte_contrib[j]
name = f"SetCount{length:02d}_{cpos:02d}" if contrib:
count_lookups.append(f"lookup {name} {{ sub {g_c(cpos)} by {g_d(4 + cpos, bit)}; }} {name};") rule_parts.append(f"@s{j}' lookup Xor_{contrib:03d}")
if include_parity_circuit:
for cpos in range(8):
lookup_name = f"FillCount{cpos:02d}"
lines.append(f"lookup {lookup_name} {{")
for length in range(MAX_LEN + 1):
tail = [g_c(i) for i in range(cpos + 1, 8)]
for i in range(length):
tail.append("empty")
tail.append(f"@byte_{i:02d}")
tail.append(g_len(length))
context_tail = " ".join(tail)
setter = f"SetCount{length:02d}_{cpos:02d}"
if context_tail:
lines.append(f" sub {g_c(cpos)}' lookup {setter} {context_tail};")
else: else:
lines.append(f" sub {g_c(cpos)}' lookup {setter};") rule_parts.append(f"@s{j}' lookup NoOp")
lines.append(f"}} {lookup_name};") rule_parts.append(f"{g_char(code)}' lookup SetLen{pos + 1:02d}")
lines.append("") else:
feature_lookups.append(lookup_name) 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}")
lines.append("lookup ExpandBytes {") 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): for pos in range(MAX_LEN):
start = 12 + pos * 8 scan_name = f"Scan{pos:02d}"
main_lines.append(f"lookup {scan_name} useExtension {{")
for code in SUPPORTED_CODES: for code in SUPPORTED_CODES:
names = " ".join(g_d(start + i, bit) for i, bit in enumerate(bits_of(code, 8))) a = f"SetByte_{code:03d}"
lines.append(f" sub {g_byte(pos, code)} by {names};") b = f"SetLen_{code:03d}"
lines.append("} ExpandBytes;") main_lines.append(f" sub {g_len(pos)}' lookup {a} {g_char(code)}' lookup {b};")
lines.append("") main_lines.append(f"}} {scan_name};")
main_lines.append("")
feature_lookups.append(scan_name)
lines.append("lookup HideLen {") # CloseQR
for length in range(MAX_LEN + 1):
lines.append(f" sub {g_len(length)} by empty;")
lines.append("} HideLen;")
lines.append("")
for length in range(MAX_LEN + 1):
name = f"Close{length:02d}"
lines.append(f"lookup {name} {{")
close_payload = bit_close_payload(length) if include_parity_circuit else grouped_close_payload(length)
lines.append(f" sub close_delim by {' '.join(close_payload)};")
lines.append(f"}} {name};")
lines.append("")
lines.append("lookup CloseQR {")
for length in range(MAX_LEN + 1):
lines.append(f" sub {g_len(length)}' lookup HideLen close_delim' lookup Close{length:02d};")
lines.append("} CloseQR;")
lines.append("")
feature_lookups.append("CloseQR")
if include_parity_circuit: if include_parity_circuit:
feature_lookups.append("ExpandBytes")
if include_parity_circuit:
for i in range(PARITY_BITS):
lines.extend([
f"lookup ToggleP{i:02d} {{",
f" sub {g_p(i, 0)} by {g_p(i, 1)};",
f" sub {g_p(i, 1)} by {g_p(i, 0)};",
f"}} ToggleP{i:02d};",
"",
])
matrix = derive_parity_matrix() matrix = derive_parity_matrix()
main_lines.append("lookup CloseQR useExtension {")
for length in range(MAX_LEN + 1): for length in range(MAX_LEN + 1):
for data_index, parity_column in enumerate(matrix): bit_fixed = fixed_parity_contribution(length, matrix)
toggles = [i for i, bit in enumerate(parity_column) if bit] byte_fixed = bytes_from_bits(bit_fixed)
if not toggles: rule_parts = []
continue for j in range(EC_CODEWORDS):
name = f"ApplyL{length:02d}D{data_index:03d}" contrib = byte_fixed[j]
toggle_name = f"ToggleL{length:02d}D{data_index:03d}" if contrib:
lines.append(f"lookup {toggle_name} {{") rule_parts.append(f"@s{j}' lookup Xor_{contrib:03d}")
for p in toggles: else:
lines.append(f" sub {g_p(p, 0)} by {g_p(p, 1)};") rule_parts.append(f"@s{j}' lookup NoOp")
lines.append(f" sub {g_p(p, 1)} by {g_p(p, 0)};") for k in range(1, length):
lines.append(f"}} {toggle_name};") rule_parts.append(f"@byte_{k:02d}' lookup NoOp")
lines.append("") rule_parts.append(f"len_{length:02d}' lookup NoOp")
lines.append(f"lookup {name} {{") rule_parts.append(f"close_delim' lookup SetBase_{length:02d}")
parts = data_context_parts(length, data_index) main_lines.append(f" sub {' '.join(rule_parts)};")
for p in range(PARITY_BITS): main_lines.append("} CloseQR;")
parts.append(f"@p{p:02d}' lookup {toggle_name}") main_lines.append("")
parts.append(f"qr_base_{length:02d}") feature_lookups.append("CloseQR")
lines.append(f" sub {' '.join(parts)};")
lines.append(f"}} {name};")
lines.append("")
feature_lookups.append(name)
all_lines = [*languagesystems] main_lines.append("lookup CloseQR_CountTail useExtension {")
all_lines.extend(helper_lookups) for length in range(MAX_LEN + 1):
all_lines.extend(count_lookups) main_lines.append(f" sub len_{length:02d}' lookup SetCountTail_{length:02d} qr_base_{length:02d};")
all_lines.extend(lines[2:]) 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")
# Add main lines to all_lines
all_lines.extend(main_lines)
# 4. Feature sections
all_lines.append("feature rlig {") all_lines.append("feature rlig {")
for name in feature_lookups: for name in feature_lookups:
all_lines.append(f" lookup {name};") all_lines.append(f" lookup {name};")
all_lines.append("} rlig;") all_lines.append("} rlig;")
all_lines.append("") all_lines.append("")
if not include_parity_circuit:
all_lines.append(class_line("qr_internal", grouped_internal_glyphs())) all_lines.append(class_line("qr_internal", grouped_internal_glyphs(include_parity_circuit)))
all_lines.append(class_line("qr_any", grouped_any_glyphs())) all_lines.append(class_line("qr_any", grouped_any_glyphs(include_parity_circuit)))
all_lines.append(class_line("qr_follow", grouped_follow_glyphs())) all_lines.append(class_line("qr_follow", grouped_follow_glyphs(include_parity_circuit)))
all_lines.append("feature kern {") all_lines.append("feature kern {")
all_lines.append(f" pos @qr_internal <0 0 {-ADVANCE} 0> @qr_follow;") all_lines.append(f" pos @qr_internal <0 0 {-ADVANCE} 0> @qr_follow;")
all_lines.append("} kern;") all_lines.append("} kern;")
all_lines.append("") all_lines.append("")
return "\n".join(all_lines) return "\n".join(all_lines)
@@ -719,11 +909,11 @@ def build_ttf(font_data: FontData, feature_text: str, output: Path) -> None:
) )
fb.setupNameTable( fb.setupNameTable(
{ {
"familyName": "QR Font", "familyName": f"QR Font {QR_LABEL}",
"styleName": "Regular", "styleName": "Regular",
"uniqueFontIdentifier": "QR Font Regular 0.1", "uniqueFontIdentifier": f"QR Font {QR_LABEL} Regular 0.1",
"fullName": "QR Font Regular", "fullName": f"QR Font {QR_LABEL} Regular",
"psName": "QRFont-Regular", "psName": f"QRFont-{QR_LABEL}-Regular",
"version": "Version 0.1", "version": "Version 0.1",
"copyright": ( "copyright": (
"Derived from Liberation Sans: digitized data copyright (c) 2010 " "Derived from Liberation Sans: digitized data copyright (c) 2010 "
@@ -734,7 +924,7 @@ def build_ttf(font_data: FontData, feature_text: str, output: Path) -> None:
"licenseInfoURL": "https://scripts.sil.org/OFL", "licenseInfoURL": "https://scripts.sil.org/OFL",
} }
) )
fb.setupPost() fb.setupPost(keepGlyphNames=True)
font = fb.font font = fb.font
gasp = newTable("gasp") gasp = newTable("gasp")
gasp.gaspRange = { gasp.gaspRange = {
@@ -748,17 +938,25 @@ def build_ttf(font_data: FontData, feature_text: str, output: Path) -> None:
font.save(output) font.save(output)
def write_demo(font_filename: str) -> None: def write_demo(font_filenames: dict[str, str]) -> None:
html = """<!doctype html> html = """<!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>QR Font Demo</title> <title>Jim's TrueType QR Code Font</title>
<style> <style>
@font-face { @font-face {
font-family: "QR Font"; font-family: "QR Font 1-L";
src: url("./__FONT_FILENAME__") format("truetype"); src: url("./__FONT_1L__") format("truetype");
}
@font-face {
font-family: "QR Font 2-L";
src: url("./__FONT_2L__") format("truetype");
}
@font-face {
font-family: "QR Font 3-L";
src: url("./__FONT_3L__") format("truetype");
} }
body { body {
margin: 0; margin: 0;
@@ -771,6 +969,19 @@ main {
margin: 48px auto; margin: 48px auto;
padding: 0 24px; padding: 0 24px;
} }
h1 {
margin: 0 0 14px;
font-size: 34px;
line-height: 1.15;
}
p {
line-height: 1.5;
}
.intro {
max-width: 760px;
margin: 0 0 28px;
color: #334155;
}
label { label {
display: block; display: block;
font-size: 14px; font-size: 14px;
@@ -787,54 +998,113 @@ textarea {
border-radius: 6px; border-radius: 6px;
font: 18px system-ui, sans-serif; font: 18px system-ui, sans-serif;
} }
select {
margin-bottom: 14px;
padding: 8px 10px;
border: 1px solid #b7c0cc;
border-radius: 6px;
font: 15px system-ui, sans-serif;
}
.qr { .qr {
margin-top: 32px; margin-top: 32px;
font-family: "QR Font";
font-feature-settings: "rlig" 1, "kern" 1; font-feature-settings: "rlig" 1, "kern" 1;
font-size: 203px;
line-height: 1; line-height: 1;
color: #000; color: #000;
background: #fff; background: #fff;
display: block; display: block;
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
min-height: 203px; min-height: 222px;
padding: 0; padding: 0;
overflow-x: auto; overflow-x: auto;
overflow-y: auto; overflow-y: auto;
white-space: pre-wrap; white-space: pre-wrap;
} }
.qr.mode-1L {
font-family: "QR Font 1-L";
font-size: 203px;
min-height: 203px;
}
.qr.mode-2L {
font-family: "QR Font 2-L";
font-size: 198px;
min-height: 198px;
}
.qr.mode-3L {
font-family: "QR Font 3-L";
font-size: 222px;
min-height: 222px;
}
.qr.mode-mono {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 18px;
min-height: 198px;
padding: 16px;
}
.meta { .meta {
margin-top: 10px; margin-top: 10px;
color: #4b5563; color: #4b5563;
font-size: 14px; font-size: 14px;
} }
.links {
margin-top: 28px;
display: flex;
gap: 18px;
flex-wrap: wrap;
}
a {
color: #0f5fb8;
}
</style> </style>
</head> </head>
<body> <body>
<main> <main>
<h1>Jim's TrueType QR Code Font</h1>
<p class="intro">This is a real TrueType/OpenType font that turns bracketed text into QR codes during text shaping. There is no separate image generation or preprocessing step: type text like <code>[hello]</code>, apply the font, and the font's built-in OpenType rules render the QR code.</p>
<p class="intro">Because the QR code is still text, you can copy and paste the rendered QR block as ordinary characters, store it in plain text, or mix it inline with regular Latin text. Text outside brackets remains readable.</p>
<label for="mode">Font</label>
<select id="mode">
<option value="1L">QR Font 1-L (up to 17 characters)</option>
<option value="2L" selected>QR Font 2-L (up to 32 characters)</option>
<option value="3L">QR Font 3-L (up to 53 characters)</option>
<option value="mono">Mono</option>
</select>
<label for="text">Text</label> <label for="text">Text</label>
<textarea id="text" autocomplete="off" spellcheck="false">Hello [QR coded] world! <textarea id="text" autocomplete="off" spellcheck="false">Hello [QR coded] world!
Download this font: [http://qr.jim.sh/]</textarea> Download this font: [http://qr.jim.sh/]</textarea>
<p class="meta">Use printable ASCII inside square brackets, up to 17 characters per QR block. Text outside brackets remains ordinary Liberation Sans-derived text.</p> <p class="meta">Use printable ASCII inside square brackets. QR Font 1-L supports up to 17 characters per block; QR Font 2-L supports up to 32; QR Font 3-L supports up to 53. Text outside brackets remains ordinary Liberation Sans-derived text.</p>
<div id="qr" class="qr">Hello [QR coded] world! <div id="qr" class="qr mode-2L">Hello [QR coded] world!
Download this font: [http://qr.jim.sh/]</div> Download this font: [http://qr.jim.sh/]</div>
<p class="links">
<a href="./__FONT_1L__">Download QR Font 1-L</a>
<a href="./__FONT_2L__">Download QR Font 2-L</a>
<a href="./__FONT_3L__">Download QR Font 3-L</a>
<a href="https://git.jim.sh/jim/qr-font.git">Source repository</a>
</p>
</main> </main>
<script> <script>
const input = document.getElementById("text"); const input = document.getElementById("text");
const mode = document.getElementById("mode");
const qr = document.getElementById("qr"); const qr = document.getElementById("qr");
function render() { function render() {
const value = input.value; const value = input.value;
qr.replaceChildren(document.createTextNode(value)); qr.replaceChildren(document.createTextNode(value));
qr.className = `qr mode-${mode.value}`;
} }
input.addEventListener("input", render); input.addEventListener("input", render);
input.addEventListener("change", render); input.addEventListener("change", render);
input.addEventListener("keyup", render); input.addEventListener("keyup", render);
mode.addEventListener("change", render);
render(); render();
</script> </script>
</body> </body>
</html> </html>
""".replace("__FONT_FILENAME__", font_filename) """
html = (
html.replace("__FONT_1L__", font_filenames["1L"])
.replace("__FONT_2L__", font_filenames["2L"])
.replace("__FONT_3L__", font_filenames["3L"])
)
(DIST / "demo.html").write_text(html, encoding="utf-8") (DIST / "demo.html").write_text(html, encoding="utf-8")
@@ -865,27 +1135,7 @@ def svg_for_text(text: str, size: int = 150) -> str:
) )
def write_reference() -> None:
samples = ["hello", "world", "asdfasdfasdfasdf"]
cards = []
for sample in samples:
cards.append(f"<section><code>[{sample}]</code>{svg_for_text(sample)}</section>")
html = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>QR Font Reference</title>
<style>
body {{ margin: 40px; font-family: system-ui, sans-serif; background: #f6f7f9; }}
main {{ display: flex; gap: 32px; flex-wrap: wrap; align-items: flex-start; }}
section {{ display: grid; gap: 8px; }}
code {{ font: 14px ui-monospace, SFMono-Regular, Consolas, monospace; }}
</style>
</head>
<body><main>{''.join(cards)}</main></body>
</html>
"""
(DIST / "reference.html").write_text(html, encoding="utf-8")
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
@@ -913,18 +1163,23 @@ def main() -> None:
args = parse_args() args = parse_args()
BUILD.mkdir(exist_ok=True) BUILD.mkdir(exist_ok=True)
DIST.mkdir(exist_ok=True) DIST.mkdir(exist_ok=True)
for stale_font in DIST.glob("qrfont-*.ttf"): for stale_font in DIST.glob("qrfont*.ttf"):
stale_font.unlink() stale_font.unlink()
font_filenames: dict[str, str] = {}
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) font_data = build_font_data(args.base_font)
feature_text = generate_features(include_parity_circuit=not args.placeholder_parity) feature_text = generate_features(include_parity_circuit=not args.placeholder_parity)
(BUILD / "qrfont.fea").write_text(feature_text, encoding="utf-8") (BUILD / f"qrfont-{label}.fea").write_text(feature_text, encoding="utf-8")
build_ttf(font_data, feature_text, DIST / "qrfont.ttf") output_name = f"qrfont-{label}.ttf"
font_version = hashlib.sha256((DIST / "qrfont.ttf").read_bytes()).hexdigest()[:16] build_ttf(font_data, feature_text, DIST / output_name)
font_filename = f"qrfont-{font_version}.ttf" font_filenames[label] = output_name
(DIST / font_filename).write_bytes((DIST / "qrfont.ttf").read_bytes()) print(f"wrote {DIST / output_name}")
write_demo(font_filename)
write_reference() configure_qr("2L")
print(f"wrote {DIST / 'qrfont.ttf'}") write_demo(font_filenames)
print(f"wrote {DIST / 'demo.html'}") print(f"wrote {DIST / 'demo.html'}")