Compare commits
24 Commits
c451e8a9c0
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| eabb9ba3ad | |||
| 403689b935 | |||
| d45fdae0da | |||
| 5d3d7c0411 | |||
| 82e8104370 | |||
| 9bd7f22a34 | |||
| 676253405e | |||
| 962d81a32f | |||
| e3ab75787d | |||
| 9f891eb4f5 | |||
| 608aacd7c6 | |||
| b2497b2214 | |||
| 210b53745c | |||
| 57cf048af1 | |||
| 3494f815c3 | |||
| a339c881ba | |||
| 896ce461b1 | |||
| 1189dd3b25 | |||
| 0b0e029d55 | |||
| 3e25ab8e26 | |||
| be254ca9e3 | |||
| 532d83d937 | |||
| e6f00f6a78 | |||
| b19691a024 |
39
.github/workflows/release.yml
vendored
Normal file
39
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install fonts-liberation
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y fonts-liberation
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Build assets
|
||||
run: make
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
dist/qrfont-1L.ttf
|
||||
dist/qrfont-2L.ttf
|
||||
dist/qrfont-3L.ttf
|
||||
dist/index.html
|
||||
dist/favicon.ico
|
||||
33
AGENTS.md
33
AGENTS.md
@@ -9,12 +9,13 @@ text as QR Code symbols while keeping surrounding text readable. The generated
|
||||
font is a Modified Version of Liberation Sans Regular and is licensed under the
|
||||
SIL Open Font License 1.1.
|
||||
|
||||
The current bounded QR target is:
|
||||
The bounded QR targets are:
|
||||
|
||||
- QR Code Version 1-L
|
||||
- **QR Code Version 1-L** (up to 17 characters per block)
|
||||
- **QR Code Version 2-L** (up to 32 characters per block)
|
||||
- **QR Code Version 3-L** (up to 53 characters per block)
|
||||
- byte mode
|
||||
- fixed mask pattern 0
|
||||
- printable ASCII payloads up to 17 characters per bracketed block
|
||||
- `[` and `]` delimiters
|
||||
|
||||
Example:
|
||||
@@ -32,8 +33,9 @@ Use `uv` for Python commands and dependency management.
|
||||
make
|
||||
```
|
||||
|
||||
`make` runs the full Reed-Solomon parity build, then copies the font, demo,
|
||||
reference page, license, and notice into `~/Downloads/qrfont/`.
|
||||
`make` runs the full Reed-Solomon parity build and compiles the fonts and `index.html` demo into the `dist/` directory.
|
||||
|
||||
Use `make deploy` to synchronize the built `dist/` assets directly to `psy:/www/qr/` (which cleans up stale/experimental remote files via `--delete`).
|
||||
|
||||
Do not use the placeholder parity path for normal verification. It exists only
|
||||
as a legacy layout-debug target:
|
||||
@@ -87,11 +89,18 @@ Browser rendering is useful but time-consuming. The user asked to skip routine
|
||||
Firefox rendering for now. If visual alignment regresses, inspect recent
|
||||
screenshots in `~/Downloads`.
|
||||
|
||||
## Larger QR Codes
|
||||
We build three separate font families (`1-L`, `2-L`, `3-L`), each fixed to its respective QR version. Supporting multiple QR versions dynamically in a single font file is possible, but would require branching by payload length at close-delimiter time and emitting version-specific base patterns, coordinate maps, RS parity circuits, and advances. The current strategy compiles separate fonts for each version.
|
||||
|
||||
Version 1-L only supports 17 byte-mode characters. Larger QR versions are
|
||||
possible, but the current implementation hardcodes Version 1 data capacity,
|
||||
coordinate placement, format layout, and Reed-Solomon parity size. Extending to
|
||||
larger versions means generating additional version-specific coordinate maps,
|
||||
capacity tables, RS block structures, alignment patterns, and much larger
|
||||
OpenType state/parity circuits.
|
||||
## Browser Layout, Alignment, and Line-Breaking Details
|
||||
|
||||
### Firefox Alignment Issue
|
||||
- **Symptom:** Horizontal shifting/slicing between the top/bottom (parity) and middle (data) sections of the QR code when resizing the font in Firefox.
|
||||
- **Cause:** Parity/state glyphs were classified as GDEF Marks and had zero-advance in `hmtx`. Firefox applies subpixel snapping/rounding to zero-advance GDEF Marks differently from standard spacing Base glyphs, causing horizontal coordinate drift.
|
||||
- **Solution:** We omit the GDEF table and configure all intermediate QR-related glyphs (`header_bits`, `byte_XX`, `pXX`, `sXX`) to have native `0` advance in the `hmtx` table. The closing base glyph (`qr_base_NN` or `qr_base_p55_NN`) is the only glyph with a positive `ADVANCE` width. Because there are no GPOS positioning adjustments, the browser treats them all as zero-advance Base glyphs, eliminating horizontal misalignment entirely.
|
||||
|
||||
### Line-Breaking Limitations
|
||||
- **Symptom:** QR codes containing spaces, dots, or slashes split across lines. In Chrome, the second half of the split QR code renders as plain text (e.g., `coded]`). In Firefox, it splits the shaped QR code.
|
||||
- **Cause:** Web browsers run line-breaking algorithms (Unicode UAX #14) on the raw Unicode text BEFORE they run the font shaper (HarfBuzz). Because line-breaking is decided on Unicode characters before GSUB/GPOS run, font-level ligatures designed to ligate breaking characters to their neighbors cannot prevent browser wrapping.
|
||||
- **Chrome Behavior:** Chrome splits the string into two independent lines and shapes them separately. Since the second line lacks `[`, it remains plain text.
|
||||
- **Firefox Behavior:** Firefox shapes first and then splits the shaped glyph run.
|
||||
- **Rule of Thumb:** Always wrap QR-coded elements in a CSS container with `white-space: nowrap` or `display: inline-block` to avoid breaks, as the font itself cannot override Unicode line-breaking properties.
|
||||
|
||||
21
Makefile
21
Makefile
@@ -1,19 +1,24 @@
|
||||
.PHONY: all clean
|
||||
.PHONY: all clean deploy
|
||||
.PHONY: fast-placeholder full-parity
|
||||
|
||||
export UV_CACHE_DIR := .uv-cache
|
||||
|
||||
all:
|
||||
FONTS = dist/qrfont-1L.ttf dist/qrfont-2L.ttf dist/qrfont-3L.ttf
|
||||
INDEX = dist/index.html
|
||||
|
||||
all: $(FONTS) $(INDEX)
|
||||
|
||||
$(FONTS): tools/build_font.py
|
||||
uv run tools/build_font.py
|
||||
mkdir -p $(HOME)/Downloads/qrfont
|
||||
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/
|
||||
|
||||
$(INDEX): tools/build_html.py
|
||||
uv run tools/build_html.py
|
||||
|
||||
fast-placeholder:
|
||||
uv run tools/build_font.py --placeholder-parity
|
||||
mkdir -p $(HOME)/Downloads/qrfont
|
||||
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/
|
||||
|
||||
deploy: all
|
||||
rsync -avz --delete dist/ psy:/www/qr/
|
||||
|
||||
full-parity: all
|
||||
|
||||
|
||||
36
README.md
36
README.md
@@ -1,4 +1,11 @@
|
||||
# QR Font
|
||||
# Jim's TrueType QR Code Font
|
||||
|
||||
Live Demo: https://qr.jim.sh/
|
||||
|
||||
Downloads:
|
||||
- [qrfont-1L.ttf](https://github.com/jimparis/qr-font/releases/latest/download/qrfont-1L.ttf) (21x21 modules, up to 17 characters)
|
||||
- [qrfont-2L.ttf](https://github.com/jimparis/qr-font/releases/latest/download/qrfont-2L.ttf) (25x25 modules, up to 32 characters)
|
||||
- [qrfont-3L.ttf](https://github.com/jimparis/qr-font/releases/latest/download/qrfont-3L.ttf) (29x29 modules, up to 53 characters)
|
||||
|
||||
This repo generates an experimental OpenType font that turns bracket-delimited
|
||||
text into a QR Code symbol while leaving surrounding text readable.
|
||||
@@ -7,23 +14,21 @@ text into a QR Code symbol while leaving surrounding text readable.
|
||||
abc[hello]ghi
|
||||
```
|
||||
|
||||
The first target is intentionally bounded:
|
||||
|
||||
- QR Code Version 1-L, 21 by 21 modules
|
||||
- byte mode
|
||||
- printable ASCII input, up to 17 characters
|
||||
- fixed mask pattern 0
|
||||
All fonts use:
|
||||
- Byte mode
|
||||
- Printable ASCII input
|
||||
- Fixed mask pattern 0
|
||||
- `[` and `]` as delimiters
|
||||
|
||||
The font is generated rather than hand-authored. The build script emits glyph
|
||||
outlines and GSUB feature logic, then compiles them into `dist/qrfont.ttf`.
|
||||
The fonts are generated rather than hand-authored. The build script emits glyph
|
||||
outlines and GSUB feature logic, then compiles them into `dist/qrfont-*.ttf`.
|
||||
The default build compiles the delimiter parser, byte expansion, Reed-Solomon
|
||||
parity circuit, QR module placement, and fixed mask rendering.
|
||||
|
||||
Printable ASCII glyphs are copied from Liberation Sans Regular, scaled into the
|
||||
QR Font em square, so text outside bracketed QR blocks renders as ordinary text
|
||||
in the same font. `Liberation` is a reserved font name under the source font
|
||||
license, so the generated family is named `QR Font`.
|
||||
license, so the generated families are named `QR Font 1-L`, `QR Font 2-L`, and `QR Font 3-L`.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -73,11 +78,11 @@ uv run tools/shape_debug.py '[a]' '[b]'
|
||||
|
||||
Outputs:
|
||||
|
||||
- `dist/qrfont.ttf`
|
||||
- `dist/demo.html`
|
||||
- `build/qrfont.fea`
|
||||
- `dist/qrfont-*.ttf` (1-L, 2-L, and 3-L font files)
|
||||
- `dist/index.html` (interactive web demo)
|
||||
- `build/qrfont-*.fea` (generated OpenType feature files)
|
||||
|
||||
Open `dist/demo.html` in a browser and type bracketed text such as `[hello]`.
|
||||
Open `dist/index.html` in a browser and type bracketed text such as `[hello]`.
|
||||
Mixed text such as `abc[def]ghi` should render as normal text, then a QR code
|
||||
for `def`, then normal text.
|
||||
|
||||
@@ -91,4 +96,5 @@ 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
|
||||
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 17, 32, or 53 characters depending on the
|
||||
selected font version.
|
||||
|
||||
12
design.md
12
design.md
@@ -250,6 +250,18 @@ A more complete font could add:
|
||||
|
||||
Automatic mask selection is possible using more unrolled Boolean circuitry, but it would probably exceed the Reed–Solomon portion in complexity. For the initial font, choosing a fixed mask and baking its format bits and coordinate inversions into the glyphs is far simpler.
|
||||
|
||||
## Browser Layout & Rendering Caveats
|
||||
|
||||
### Firefox Subpixel Alignment
|
||||
In layout engines (particularly Gecko/Firefox), zero-advance glyphs classified as GDEF Marks undergo subpixel rounding and snapping relative to their nearest Base glyph. If a font mixes Base glyphs (e.g., data bits with positive `hmtx` advance kerned back via GPOS) and Mark glyphs (e.g., parity bits with zero `hmtx` advance), the subpixel rounding logic causes the Mark sections to drift horizontally from the Base sections as the font size changes.
|
||||
* **Solution:** We omit the GDEF table and configure all intermediate QR glyphs (`header_bits`, `byte_XX`, `pXX`, `sXX`) to have native `0` advance in the `hmtx` table. The closing base glyph (`qr_base_NN` or `qr_base_p55_NN`) is the only glyph with a positive `ADVANCE` width. Because there are no GPOS positioning adjustments, the browser treats them all as zero-advance Base glyphs, eliminating horizontal misalignment entirely.
|
||||
|
||||
### Line-Breaking Limitations
|
||||
Web browsers execute line-breaking algorithms (such as Unicode UAX #14) on the raw Unicode character sequence *before* invoking the font shaper (HarfBuzz).
|
||||
* **Chrome (Blink):** Splits the Unicode text at break opportunities (like spaces, dots, or slashes) into separate text runs, and then shapes each run independently. Because the second run lacks the opening delimiter `[`, it renders as plain text (e.g., `coded]`).
|
||||
* **Firefox (Gecko):** Shapes the text and then splits the shaped glyph run. Because line-breaking happens prior to font shaper execution, font-level GSUB ligatures designed to fuse breaking characters with their neighbors cannot prevent the browser from wrapping the line.
|
||||
* **Mitigation:** For reliable rendering in HTML, the bracketed block must be wrapped in a container that disables wrapping (e.g., `white-space: nowrap` or `display: inline-block`).
|
||||
|
||||
So the basic answer is: **compile a fixed-capacity QR encoder into thousands of ordered contextual substitutions, represent every intermediate bit as a glyph identity, and overlay position-specific square outlines to produce the final matrix.** It is not an unrestricted program running inside ordinary OpenType, but for a bounded input size it is a legitimate and implementable finite computation.
|
||||
|
||||
[1]: https://learn.microsoft.com/en-us/typography/opentype/otspec180/gsub?utm_source=chatgpt.com "GSUB — Glyph Substitution Table (OpenType 1.8) - Typography | Microsoft Learn"
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
@@ -24,21 +23,54 @@ DEFAULT_BASE_FONT = Path("/usr/share/fonts/truetype/liberation/LiberationSans-Re
|
||||
|
||||
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_BITS = 152
|
||||
PARITY_BITS = 56
|
||||
DATA_CODEWORDS = 19
|
||||
EC_CODEWORDS = 7
|
||||
DATA_BITS = DATA_CODEWORDS * 8
|
||||
PARITY_BITS = EC_CODEWORDS * 8
|
||||
TOTAL_BITS = DATA_BITS + PARITY_BITS
|
||||
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 = 0.20
|
||||
LATIN_Y_SHIFT = 220
|
||||
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:
|
||||
@@ -108,10 +140,9 @@ def rs_generator(ec_count: int) -> list[int]:
|
||||
return gen
|
||||
|
||||
|
||||
RS_GEN = rs_generator(7)
|
||||
|
||||
|
||||
def rs_encode(data: list[int], ec_count: int = 7) -> list[int]:
|
||||
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]
|
||||
@@ -121,6 +152,9 @@ def rs_encode(data: list[int], ec_count: int = 7) -> list[int]:
|
||||
return ecc
|
||||
|
||||
|
||||
configure_qr("1L")
|
||||
|
||||
|
||||
def data_bits_for_text(text: str) -> list[int]:
|
||||
payload: list[int] = []
|
||||
payload.extend([0, 1, 0, 0])
|
||||
@@ -216,6 +250,27 @@ def finder_modules(top: int, left: int) -> set[tuple[int, int]]:
|
||||
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)]
|
||||
|
||||
@@ -223,11 +278,16 @@ def reserved_matrix() -> list[list[bool]]:
|
||||
if 0 <= row < QR_SIZE and 0 <= col < QR_SIZE:
|
||||
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 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)
|
||||
@@ -241,11 +301,11 @@ def reserved_matrix() -> list[list[bool]]:
|
||||
for row in range(QR_SIZE - 7, QR_SIZE):
|
||||
reserve(row, 8)
|
||||
|
||||
reserve(13, 8)
|
||||
reserve(4 * QR_VERSION + 9, 8)
|
||||
return reserved
|
||||
|
||||
|
||||
def data_coordinates() -> list[tuple[int, int]]:
|
||||
def all_data_coordinates() -> list[tuple[int, int]]:
|
||||
reserved = reserved_matrix()
|
||||
coords: list[tuple[int, int]] = []
|
||||
upward = True
|
||||
@@ -260,11 +320,20 @@ def data_coordinates() -> list[tuple[int, int]]:
|
||||
coords.append((row, c))
|
||||
upward = not upward
|
||||
col -= 2
|
||||
if len(coords) != TOTAL_BITS:
|
||||
raise RuntimeError(f"expected {TOTAL_BITS} data coordinates, got {len(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]:
|
||||
# EC level L is 01; mask pattern 0 is 000.
|
||||
data = (0b01 << 3) | MASK
|
||||
@@ -280,21 +349,26 @@ def format_bits() -> list[int]:
|
||||
def base_black_modules() -> set[tuple[int, int]]:
|
||||
black: set[tuple[int, int]] = set()
|
||||
black |= finder_modules(0, 0)
|
||||
black |= finder_modules(0, 14)
|
||||
black |= finder_modules(14, 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, 13):
|
||||
for i in range(8, QR_SIZE - 8):
|
||||
if i % 2 == 0:
|
||||
black.add((6, i))
|
||||
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()
|
||||
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 = [(20, 8), (19, 8), (18, 8), (17, 8), (16, 8), (15, 8), (14, 8),
|
||||
(8, 13), (8, 14), (8, 15), (8, 16), (8, 17), (8, 18), (8, 19), (8, 20)]
|
||||
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)
|
||||
@@ -344,14 +418,14 @@ def copy_base_glyph(
|
||||
source_width, _ = base_font["hmtx"][source_name]
|
||||
scaled_width = round(source_width * scale)
|
||||
if scaled_width > 0:
|
||||
data.advance_widths[target_name] = max(MODULE, round(scaled_width / MODULE) * MODULE)
|
||||
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 = (ADVANCE / base_font["head"].unitsPerEm) * LATIN_SCALE
|
||||
scale = (UNITS_PER_EM / base_font["head"].unitsPerEm) * LATIN_SCALE
|
||||
cmap = base_font.getBestCmap()
|
||||
|
||||
notdef_name = ".notdef"
|
||||
@@ -373,7 +447,7 @@ def add_printable_base_glyphs(data: FontData, base_font_path: Path) -> None:
|
||||
target_name = printable_names[code]
|
||||
source_name = cmap.get(code)
|
||||
if source_name is None:
|
||||
add_empty(target_name, data, round(ADVANCE * LATIN_SCALE) if code == 32 else 0)
|
||||
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
|
||||
@@ -390,12 +464,15 @@ def build_font_data(base_font_path: Path = DEFAULT_BASE_FONT) -> FontData:
|
||||
data.glyphs["header_bits"] = bit_group_glyph(
|
||||
((i, bit) for i, bit in enumerate([0, 1, 0, 0])), coords
|
||||
)
|
||||
data.advance_widths["header_bits"] = ADVANCE
|
||||
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)
|
||||
@@ -403,7 +480,7 @@ def build_font_data(base_font_path: Path = DEFAULT_BASE_FONT) -> FontData:
|
||||
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] = ADVANCE
|
||||
data.advance_widths[name] = 0
|
||||
|
||||
for length in range(MAX_LEN + 1):
|
||||
count_name = f"count_{length:02d}"
|
||||
@@ -412,7 +489,7 @@ def build_font_data(base_font_path: Path = DEFAULT_BASE_FONT) -> FontData:
|
||||
((4 + i, bit) for i, bit in enumerate(bits_of(length, 8))),
|
||||
coords,
|
||||
)
|
||||
data.advance_widths[count_name] = ADVANCE
|
||||
data.advance_widths[count_name] = 0
|
||||
|
||||
used = 12 + length * 8
|
||||
tail = close_tail_bits(length)
|
||||
@@ -422,7 +499,7 @@ def build_font_data(base_font_path: Path = DEFAULT_BASE_FONT) -> FontData:
|
||||
((used + i, bit) for i, bit in enumerate(tail)),
|
||||
coords,
|
||||
)
|
||||
data.advance_widths[tail_name] = ADVANCE
|
||||
data.advance_widths[tail_name] = 0
|
||||
|
||||
parity_name = f"parity_zero_{length:02d}"
|
||||
data.glyph_order.append(parity_name)
|
||||
@@ -430,7 +507,7 @@ def build_font_data(base_font_path: Path = DEFAULT_BASE_FONT) -> FontData:
|
||||
((DATA_BITS + i, 0) for i in range(PARITY_BITS)),
|
||||
coords,
|
||||
)
|
||||
data.advance_widths[parity_name] = ADVANCE
|
||||
data.advance_widths[parity_name] = 0
|
||||
|
||||
base_name = f"qr_base_{length:02d}"
|
||||
data.glyph_order.append(base_name)
|
||||
@@ -450,6 +527,24 @@ def build_font_data(base_font_path: Path = DEFAULT_BASE_FONT) -> FontData:
|
||||
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
|
||||
|
||||
|
||||
@@ -457,23 +552,38 @@ def class_line(name: str, members: Iterable[str]) -> str:
|
||||
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"]
|
||||
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}", 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}")
|
||||
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() -> list[str]:
|
||||
names = grouped_internal_glyphs()
|
||||
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() -> list[str]:
|
||||
return ["empty", *grouped_any_glyphs()]
|
||||
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:
|
||||
@@ -511,190 +621,300 @@ def close_tail_bits(length: int) -> list[int]:
|
||||
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 bit_close_payload(length: int) -> list[str]:
|
||||
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 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 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
|
||||
parts.extend(data_part(i) for i in range(start, start + 8))
|
||||
used = 12 + length * 8
|
||||
parts.append("empty")
|
||||
parts.extend(data_part(i) for i in range(used, DATA_BITS))
|
||||
return parts
|
||||
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;", ""]
|
||||
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):
|
||||
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("")
|
||||
|
||||
all_lines.append(class_line(f"byte_{pos:02d}", (g_byte(pos, c) for c in SUPPORTED_CODES)))
|
||||
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:
|
||||
open_replacement = "header_bits len_00"
|
||||
|
||||
lines.extend([
|
||||
"lookup OpenQR {",
|
||||
main_lines.extend([
|
||||
"lookup OpenQR useExtension {",
|
||||
f" sub open_delim by {open_replacement};",
|
||||
"} OpenQR;",
|
||||
"",
|
||||
])
|
||||
|
||||
helper_lookups: list[str] = []
|
||||
feature_lookups: list[str] = ["OpenQR"]
|
||||
|
||||
for pos in range(MAX_LEN):
|
||||
scan_name = f"Scan{pos:02d}"
|
||||
hide_name = f"HideScanLen{pos:02d}"
|
||||
lines.append(f"lookup {scan_name} {{")
|
||||
for code in SUPPORTED_CODES:
|
||||
a = f"SetByte{pos:02d}_{code:03d}"
|
||||
helper_lookups.extend([
|
||||
f"lookup {a} {{ sub {g_char(code)} by {g_byte(pos, code)} {g_len(pos + 1)}; }} {a};",
|
||||
])
|
||||
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] = []
|
||||
for length in range(MAX_LEN + 1):
|
||||
length_bits = bits_of(length, 8)
|
||||
for cpos, bit in enumerate(length_bits):
|
||||
name = f"SetCount{length:02d}_{cpos:02d}"
|
||||
count_lookups.append(f"lookup {name} {{ sub {g_c(cpos)} by {g_d(4 + cpos, bit)}; }} {name};")
|
||||
|
||||
# Scan{pos}
|
||||
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:
|
||||
lines.append(f" sub {g_c(cpos)}' lookup {setter};")
|
||||
lines.append(f"}} {lookup_name};")
|
||||
lines.append("")
|
||||
feature_lookups.append(lookup_name)
|
||||
|
||||
lines.append("lookup ExpandBytes {")
|
||||
for pos in range(MAX_LEN):
|
||||
start = 12 + pos * 8
|
||||
for code in SUPPORTED_CODES:
|
||||
names = " ".join(g_d(start + i, bit) for i, bit in enumerate(bits_of(code, 8)))
|
||||
lines.append(f" sub {g_byte(pos, code)} by {names};")
|
||||
lines.append("} ExpandBytes;")
|
||||
lines.append("")
|
||||
|
||||
lines.append("lookup HideLen {")
|
||||
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:
|
||||
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()
|
||||
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):
|
||||
for data_index, parity_column in enumerate(matrix):
|
||||
toggles = [i for i, bit in enumerate(parity_column) if bit]
|
||||
if not toggles:
|
||||
continue
|
||||
name = f"ApplyL{length:02d}D{data_index:03d}"
|
||||
toggle_name = f"ToggleL{length:02d}D{data_index:03d}"
|
||||
lines.append(f"lookup {toggle_name} {{")
|
||||
for p in toggles:
|
||||
lines.append(f" sub {g_p(p, 0)} by {g_p(p, 1)};")
|
||||
lines.append(f" sub {g_p(p, 1)} by {g_p(p, 0)};")
|
||||
lines.append(f"}} {toggle_name};")
|
||||
lines.append("")
|
||||
lines.append(f"lookup {name} {{")
|
||||
parts = data_context_parts(length, data_index)
|
||||
for p in range(PARITY_BITS):
|
||||
parts.append(f"@p{p:02d}' lookup {toggle_name}")
|
||||
parts.append(f"qr_base_{length:02d}")
|
||||
lines.append(f" sub {' '.join(parts)};")
|
||||
lines.append(f"}} {name};")
|
||||
lines.append("")
|
||||
feature_lookups.append(name)
|
||||
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")
|
||||
|
||||
all_lines = [*languagesystems]
|
||||
all_lines.extend(helper_lookups)
|
||||
all_lines.extend(count_lookups)
|
||||
all_lines.extend(lines[2:])
|
||||
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("")
|
||||
if not include_parity_circuit:
|
||||
all_lines.append(class_line("qr_internal", grouped_internal_glyphs()))
|
||||
all_lines.append(class_line("qr_any", grouped_any_glyphs()))
|
||||
all_lines.append(class_line("qr_follow", grouped_follow_glyphs()))
|
||||
all_lines.append("feature kern {")
|
||||
all_lines.append(f" pos @qr_internal <0 0 {-ADVANCE} 0> @qr_follow;")
|
||||
all_lines.append("} kern;")
|
||||
all_lines.append("")
|
||||
|
||||
|
||||
return "\n".join(all_lines)
|
||||
|
||||
|
||||
@@ -719,22 +939,24 @@ def build_ttf(font_data: FontData, feature_text: str, output: Path) -> None:
|
||||
)
|
||||
fb.setupNameTable(
|
||||
{
|
||||
"familyName": "QR Font",
|
||||
"familyName": f"QR Font {QR_LABEL}",
|
||||
"styleName": "Regular",
|
||||
"uniqueFontIdentifier": "QR Font Regular 0.1",
|
||||
"fullName": "QR Font Regular",
|
||||
"psName": "QRFont-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."
|
||||
"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()
|
||||
fb.setupPost(keepGlyphNames=True)
|
||||
font = fb.font
|
||||
gasp = newTable("gasp")
|
||||
gasp.gaspRange = {
|
||||
@@ -748,95 +970,6 @@ def build_ttf(font_data: FontData, feature_text: str, output: Path) -> None:
|
||||
font.save(output)
|
||||
|
||||
|
||||
def write_demo(font_filename: str) -> None:
|
||||
html = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>QR Font Demo</title>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "QR Font";
|
||||
src: url("./__FONT_FILENAME__") format("truetype");
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, sans-serif;
|
||||
background: #f6f7f9;
|
||||
color: #17202a;
|
||||
}
|
||||
main {
|
||||
max-width: 900px;
|
||||
margin: 48px auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
textarea {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 92px;
|
||||
resize: vertical;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #b7c0cc;
|
||||
border-radius: 6px;
|
||||
font: 18px system-ui, sans-serif;
|
||||
}
|
||||
.qr {
|
||||
margin-top: 32px;
|
||||
font-family: "QR Font";
|
||||
font-feature-settings: "rlig" 1, "kern" 1;
|
||||
font-size: 203px;
|
||||
line-height: 1;
|
||||
color: #000;
|
||||
background: #fff;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 203px;
|
||||
padding: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.meta {
|
||||
margin-top: 10px;
|
||||
color: #4b5563;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<label for="text">Text</label>
|
||||
<textarea id="text" autocomplete="off" spellcheck="false">Hello [QR coded] world!
|
||||
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>
|
||||
<div id="qr" class="qr">Hello [QR coded] world!
|
||||
Download this font: [http://qr.jim.sh/]</div>
|
||||
</main>
|
||||
<script>
|
||||
const input = document.getElementById("text");
|
||||
const qr = document.getElementById("qr");
|
||||
function render() {
|
||||
const value = input.value;
|
||||
qr.replaceChildren(document.createTextNode(value));
|
||||
}
|
||||
input.addEventListener("input", render);
|
||||
input.addEventListener("change", render);
|
||||
input.addEventListener("keyup", render);
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""".replace("__FONT_FILENAME__", font_filename)
|
||||
(DIST / "demo.html").write_text(html, encoding="utf-8")
|
||||
|
||||
|
||||
def matrix_for_text(text: str) -> set[tuple[int, int]]:
|
||||
coords = data_coordinates()
|
||||
@@ -865,27 +998,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:
|
||||
@@ -913,19 +1026,19 @@ def main() -> None:
|
||||
args = parse_args()
|
||||
BUILD.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()
|
||||
font_data = build_font_data(args.base_font)
|
||||
feature_text = generate_features(include_parity_circuit=not args.placeholder_parity)
|
||||
(BUILD / "qrfont.fea").write_text(feature_text, encoding="utf-8")
|
||||
build_ttf(font_data, feature_text, DIST / "qrfont.ttf")
|
||||
font_version = hashlib.sha256((DIST / "qrfont.ttf").read_bytes()).hexdigest()[:16]
|
||||
font_filename = f"qrfont-{font_version}.ttf"
|
||||
(DIST / font_filename).write_bytes((DIST / "qrfont.ttf").read_bytes())
|
||||
write_demo(font_filename)
|
||||
write_reference()
|
||||
print(f"wrote {DIST / 'qrfont.ttf'}")
|
||||
print(f"wrote {DIST / 'demo.html'}")
|
||||
|
||||
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__":
|
||||
|
||||
331
tools/build_html.py
Normal file
331
tools/build_html.py
Normal file
@@ -0,0 +1,331 @@
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
|
||||
DIST = Path(__file__).resolve().parent.parent / "dist"
|
||||
|
||||
def file_hash(filepath: Path) -> str:
|
||||
if not filepath.exists():
|
||||
return "unknown"
|
||||
h = hashlib.sha256()
|
||||
with open(filepath, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(65536), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()[:8]
|
||||
|
||||
def write_demo(font_filenames: dict[str, str]) -> None:
|
||||
html = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Jim's TrueType QR Code Font</title>
|
||||
<link rel="icon" type="image/x-icon" href="./favicon.ico">
|
||||
<link rel="preload" href="./__FONT_1L__" as="font" type="font/ttf" crossorigin>
|
||||
<link rel="preload" href="./__FONT_2L__" as="font" type="font/ttf" crossorigin>
|
||||
<link rel="preload" href="./__FONT_3L__" as="font" type="font/ttf" crossorigin>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "QR Font 1-L";
|
||||
src: url("./__FONT_1L__") format("truetype");
|
||||
font-display: block;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "QR Font 2-L";
|
||||
src: url("./__FONT_2L__") format("truetype");
|
||||
font-display: block;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "QR Font 3-L";
|
||||
src: url("./__FONT_3L__") format("truetype");
|
||||
font-display: block;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, sans-serif;
|
||||
background: #f6f7f9;
|
||||
color: #17202a;
|
||||
}
|
||||
main {
|
||||
max-width: 900px;
|
||||
margin: 48px auto;
|
||||
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 {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
textarea {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 92px;
|
||||
resize: vertical;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #b7c0cc;
|
||||
border-radius: 6px;
|
||||
font: 18px system-ui, sans-serif;
|
||||
}
|
||||
textarea.apply-qr {
|
||||
font-feature-settings: "rlig" 1, "kern" 1;
|
||||
}
|
||||
textarea.apply-qr.mode-1L {
|
||||
font-family: "QR Font 1-L", system-ui, sans-serif;
|
||||
}
|
||||
textarea.apply-qr.mode-2L {
|
||||
font-family: "QR Font 2-L", system-ui, sans-serif;
|
||||
}
|
||||
textarea.apply-qr.mode-3L {
|
||||
font-family: "QR Font 3-L", system-ui, sans-serif;
|
||||
}
|
||||
textarea.apply-qr.mode-sans {
|
||||
font-family: "Liberation Sans", system-ui, sans-serif;
|
||||
}
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #b7c0cc;
|
||||
border-radius: 6px;
|
||||
font: 15px system-ui, sans-serif;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.control-row {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.control-group {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
.slider-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 38px;
|
||||
}
|
||||
input[type="range"] {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: #cbd5e1;
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #0f5fb8;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
input[type="range"]::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
input[type="number"] {
|
||||
width: 65px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #b7c0cc;
|
||||
border-radius: 6px;
|
||||
font: 15px system-ui, sans-serif;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.font-size-control {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.qr {
|
||||
margin-top: 32px;
|
||||
font-feature-settings: "rlig" 1, "kern" 1;
|
||||
line-height: 1.2;
|
||||
color: #000;
|
||||
background: #fff;
|
||||
display: block;
|
||||
box-sizing: content-box;
|
||||
width: 100%;
|
||||
min-height: 222px;
|
||||
padding: 12px 16px;
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
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-sans {
|
||||
font-family: "Liberation Sans", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
.meta {
|
||||
margin-top: 10px;
|
||||
color: #4b5563;
|
||||
font-size: 14px;
|
||||
}
|
||||
.links {
|
||||
margin-top: 28px;
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
a {
|
||||
color: #0f5fb8;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
<p class="intro"><strong>Browser Line-Wrapping Note:</strong> Because layout engines perform line-breaking on the Unicode text before shaping, browsers may split a QR code across lines if it contains break opportunities (like spaces, dots, or slashes) and hits the edge of a text container. For reliable rendering in HTML, wrap the bracketed block in a container styled with <code>white-space: nowrap;</code> or <code>display: inline-block;</code>.</p>
|
||||
<div class="control-row">
|
||||
<div class="control-group">
|
||||
<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="sans">Plain Sans (no QR parsing)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="control-group font-size-control">
|
||||
<label for="size-slider">Font Size</label>
|
||||
<div class="slider-container">
|
||||
<input type="range" id="size-slider" min="10" max="300" value="100">
|
||||
<input type="number" id="size-input" min="10" max="300" value="100">
|
||||
<span style="font-size: 15px; color: #4b5563;">px</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label for="apply-input-font">Direct Input Font</label>
|
||||
<div style="height: 38px; display: flex; align-items: center;">
|
||||
<label style="font-weight: normal; margin-bottom: 0; display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
||||
<input type="checkbox" id="apply-input-font">
|
||||
Apply QR font to textbox
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label for="text">Text</label>
|
||||
<textarea id="text" autocomplete="off" spellcheck="false">Hello [QR coded] world!
|
||||
This page: [http://qr.jim.sh/]</textarea>
|
||||
<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 mode-2L">Hello [QR coded] world!
|
||||
This page: [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://github.com/jimparis/qr-font">Source repository</a>
|
||||
</p>
|
||||
</main>
|
||||
<script>
|
||||
const input = document.getElementById("text");
|
||||
const mode = document.getElementById("mode");
|
||||
const qr = document.getElementById("qr");
|
||||
const sizeSlider = document.getElementById("size-slider");
|
||||
const sizeInput = document.getElementById("size-input");
|
||||
const sizeControl = document.querySelector(".font-size-control");
|
||||
|
||||
function updateSize(value) {
|
||||
sizeSlider.value = value;
|
||||
sizeInput.value = value;
|
||||
qr.style.fontSize = value + "px";
|
||||
qr.style.minHeight = value + "px";
|
||||
if (applyInputFont.checked) {
|
||||
input.style.fontSize = value + "px";
|
||||
}
|
||||
}
|
||||
|
||||
sizeSlider.addEventListener("input", (e) => updateSize(e.target.value));
|
||||
sizeInput.addEventListener("input", (e) => {
|
||||
let val = parseInt(e.target.value, 10);
|
||||
if (isNaN(val)) return;
|
||||
if (val < 10) val = 10;
|
||||
if (val > 300) val = 300;
|
||||
updateSize(val);
|
||||
});
|
||||
|
||||
function render() {
|
||||
const value = input.value;
|
||||
qr.replaceChildren(document.createTextNode(value));
|
||||
qr.className = `qr mode-${mode.value}`;
|
||||
qr.style.fontSize = sizeSlider.value + "px";
|
||||
qr.style.minHeight = sizeSlider.value + "px";
|
||||
}
|
||||
|
||||
input.addEventListener("input", render);
|
||||
input.addEventListener("change", render);
|
||||
input.addEventListener("keyup", render);
|
||||
mode.addEventListener("change", render);
|
||||
|
||||
const applyInputFont = document.getElementById("apply-input-font");
|
||||
function updateInputFont() {
|
||||
if (applyInputFont.checked) {
|
||||
input.className = `apply-qr mode-${mode.value}`;
|
||||
input.style.fontSize = sizeSlider.value + "px";
|
||||
input.style.lineHeight = "1.2";
|
||||
qr.style.display = "none";
|
||||
} else {
|
||||
input.className = "";
|
||||
input.style.fontSize = "";
|
||||
input.style.lineHeight = "";
|
||||
qr.style.display = "block";
|
||||
}
|
||||
}
|
||||
applyInputFont.addEventListener("change", updateInputFont);
|
||||
mode.addEventListener("change", updateInputFont);
|
||||
|
||||
render();
|
||||
updateInputFont();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
for label, filename in font_filenames.items():
|
||||
font_path = DIST / filename
|
||||
h = file_hash(font_path) if font_path.exists() else "0"
|
||||
html = html.replace(f"./__FONT_{label}__", f"./{filename}?h={h}")
|
||||
html = html.replace(f"__FONT_{label}__", filename)
|
||||
(DIST / "index.html").write_text(html, encoding="utf-8")
|
||||
|
||||
def main() -> None:
|
||||
font_filenames = {
|
||||
"1L": "qrfont-1L.ttf",
|
||||
"2L": "qrfont-2L.ttf",
|
||||
"3L": "qrfont-3L.ttf",
|
||||
}
|
||||
DIST.mkdir(exist_ok=True)
|
||||
write_demo(font_filenames)
|
||||
print(f"wrote {DIST / 'index.html'}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
107
tools/test_visual.py
Normal file
107
tools/test_visual.py
Normal file
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
|
||||
DIST = Path(__file__).resolve().parent.parent / "dist"
|
||||
# Fallback for artifacts folder if home directory is different
|
||||
home_dir = Path.home()
|
||||
ARTIFACTS = home_dir / ".gemini/antigravity-cli/brain/b99818b3-5da0-4033-8da1-3a74b472fb71"
|
||||
if not ARTIFACTS.exists():
|
||||
ARTIFACTS = Path("/tmp")
|
||||
PROFILE_DIR = Path("/tmp/ff_test_prof")
|
||||
TEST_HTML_PATH = Path("/tmp/test_visual.html")
|
||||
|
||||
def run_visual_test():
|
||||
# Clean old file from dist to avoid deploying it
|
||||
old_dist_html = DIST / "test_visual.html"
|
||||
if old_dist_html.exists():
|
||||
old_dist_html.unlink()
|
||||
|
||||
# 1. Create test_visual.html in /tmp
|
||||
html = f"""<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
@font-face {{
|
||||
font-family: 'QR Font 1-L';
|
||||
src: url('file://{DIST.absolute()}/qrfont-1L.ttf') format('truetype');
|
||||
}}
|
||||
@font-face {{
|
||||
font-family: 'QR Font 2-L';
|
||||
src: url('file://{DIST.absolute()}/qrfont-2L.ttf') format('truetype');
|
||||
}}
|
||||
body {{
|
||||
background: white;
|
||||
color: black;
|
||||
font-family: sans-serif;
|
||||
padding: 20px;
|
||||
margin: 0;
|
||||
}}
|
||||
.test-row {{
|
||||
margin-bottom: 40px;
|
||||
}}
|
||||
.label {{
|
||||
font-size: 16px;
|
||||
color: #555;
|
||||
margin-bottom: 5px;
|
||||
}}
|
||||
.qr {{
|
||||
font-family: 'QR Font 2-L';
|
||||
font-feature-settings: 'rlig' 1, 'kern' 1;
|
||||
background: white;
|
||||
color: black;
|
||||
line-height: 1.0;
|
||||
display: block;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="test-row">
|
||||
<div class="label">Font Size: 50px</div>
|
||||
<div class="qr" style="font-size: 50px;">Hello [QR coded] world!</div>
|
||||
</div>
|
||||
<div class="test-row">
|
||||
<div class="label">Font Size: 100px</div>
|
||||
<div class="qr" style="font-size: 100px;">Hello [QR coded] world!</div>
|
||||
</div>
|
||||
<div class="test-row">
|
||||
<div class="label">Font Size: 150px</div>
|
||||
<div class="qr" style="font-size: 150px;">Hello [QR coded] world!</div>
|
||||
</div>
|
||||
<div class="test-row">
|
||||
<div class="label">Font Size: 200px</div>
|
||||
<div class="qr" style="font-size: 200px;">Hello [QR coded] world!</div>
|
||||
</div>
|
||||
<div class="test-row">
|
||||
<div class="label">Font Size: 250px</div>
|
||||
<div class="qr" style="font-size: 250px;">Hello [QR coded] world!</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
TEST_HTML_PATH.write_text(html, encoding="utf-8")
|
||||
|
||||
if PROFILE_DIR.exists():
|
||||
shutil.rmtree(PROFILE_DIR)
|
||||
PROFILE_DIR.mkdir()
|
||||
|
||||
screenshot_path = ARTIFACTS / "screenshot_ff.png"
|
||||
cmd = [
|
||||
"firefox",
|
||||
"--headless",
|
||||
"--window-size=1200,1600",
|
||||
"-no-remote",
|
||||
"-profile",
|
||||
str(PROFILE_DIR),
|
||||
"--screenshot",
|
||||
str(screenshot_path),
|
||||
f"file://{TEST_HTML_PATH.absolute()}"
|
||||
]
|
||||
print("Running:", " ".join(cmd))
|
||||
subprocess.run(cmd, check=True)
|
||||
print(f"Screenshot saved to {screenshot_path}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_visual_test()
|
||||
Reference in New Issue
Block a user