0
0

Compare commits

...

24 Commits

Author SHA1 Message Date
eabb9ba3ad Update README.md header to match website title 2026-06-27 20:23:12 -04:00
403689b935 Fix hardcoded absolute paths to make builds portable for GitHub Actions 2026-06-27 20:20:27 -04:00
d45fdae0da Remove duplicated font version list from README.md 2026-06-27 20:17:26 -04:00
5d3d7c0411 Update README downloads formatting to GitHub Release URLs and add release workflow 2026-06-27 20:16:05 -04:00
82e8104370 Add TTF download links to README.md 2026-06-27 20:12:46 -04:00
9bd7f22a34 Update README.md and AGENTS.md to document multi-version font target support 2026-06-27 20:11:00 -04:00
676253405e Update source repository URL to GitHub and add prominent demo link to README.md 2026-06-27 20:07:56 -04:00
962d81a32f Direct input text size matching and sample toggling
- When checkbox is checked, hide the bottom '#qr' rendering sample.
- When checkbox is checked, apply the font size slider value directly to the textbox.
2026-06-27 20:04:08 -04:00
e3ab75787d Restore 3L build, update Makefile targets, and add direct textbox QR styling toggle
- Re-enables the 3L version of the font in tools/build_font.py and Makefile.
- Removes /Downloads/qrfont copy targets from Makefile.
- Adds the --delete flag to the Makefile deploy target for remote cleanup.
- Adds an interactive checkbox to index.html ('Apply QR font to textbox') allowing users to type directly in the QR font and observe zero-advance cursor/shaping behavior.
2026-06-27 20:02:17 -04:00
9f891eb4f5 Zero-advance layout: fix Firefox alignment, remove LigateBreak experiment
- All intermediate QR glyphs (header_bits, byte_XX, count, tail,
  parity, data bits, parity bits) use native 0 advance in hmtx.
- Only closing base glyphs (qr_base_NN, qr_base_p55_bit_NN) have
  positive ADVANCE width.
- No GDEF table, no GPOS positioning adjustments.
- Removes LigateBreak ligature experiment (didn't prevent splitting).
- Fixes Firefox horizontal misalignment caused by subpixel snapping
  of GPOS-adjusted or GDEF Mark-classified glyphs.
- Known limitation: Chrome and Firefox may split QR codes across lines
  at Unicode break points (spaces, dots, slashes). CSS white-space:
  nowrap on QR-containing elements is recommended.
- Temporarily disables 3L font build for faster iteration.
- Separates HTML generation into tools/build_html.py.
- Updates AGENTS.md and design.md with browser layout documentation.
2026-06-27 18:50:06 -04:00
608aacd7c6 Revert "Anchor qr_base to header_bits at the beginning of the QR block to prevent wrapping splits"
This reverts commit b2497b2214.
2026-06-27 01:19:59 -04:00
b2497b2214 Anchor qr_base to header_bits at the beginning of the QR block to prevent wrapping splits
* Modify SetBase_{length} to substitute header_bits (at the position of "[") with qr_base_{length} instead of substituting close_delim.
* Update CloseQR chaining rules to target header_bits at the start and replace it with SetBase_{length}, and target close_delim at the end and replace it with HideClose (empty).
* Update the matched context to correctly include len_00 (for length == 0) and @byte_00 (for length > 0) between header_bits and @s0.
2026-06-27 01:15:38 -04:00
210b53745c Set line-height of .qr class to 1.2 to prevent descender vertical clipping 2026-06-27 01:07:48 -04:00
57cf048af1 Revert QR code height back to 1.0 EM and set CSS line-height back to 1.0
* Revert UNITS_PER_EM = ADVANCE in tools/build_font.py to restore the QR code height to exactly 1.0 EM.
* Revert CSS line-height of the .qr block in tools/build_html.py back to 1.0.
2026-06-27 01:06:17 -04:00
3494f815c3 Set font size slider to 10-300px, default to 100px, and increase line-height to 1.5
* Change size-slider and size-input range limits from 10-400px to 10-300px, defaulting to 100px.
* Increase .qr line-height from 1 to 1.5 in CSS to accommodate the 1.5 EM high QR glyphs and prevent line overlaps.
2026-06-27 01:03:23 -04:00
a339c881ba Scale QR code heights to 1.5 EM relative to EM square height
* Modify configure_qr() and global initialization to define UNITS_PER_EM = round(ADVANCE / 1.5). This sets the EM coordinates scale to be smaller than the QR block height (ADVANCE), causing the QR code to render at exactly 1.5 EM high while keeping the Latin text at its standard 1.0 EM size.
2026-06-27 01:01:13 -04:00
896ce461b1 Correct Latin text scaling, implement FOUT mitigation, and update size slider limits
* Restore the base-font EM-proportional scaling calculation (scale = (UNITS_PER_EM / unitsPerEm) * LATIN_SCALE) in tools/build_font.py to ensure the Latin text height stays perfectly consistent across all QR versions and matches Plain Sans size.
* Add HTML link rel="preload" tags to load all three fonts in parallel when index.html is loaded.
* Add font-display: block to @font-face declarations to mitigate FOUT when switching between fonts.
* Implement query parameter cache-busting hashes (?h=<sha256_hash>) for the font references generated dynamically during HTML build.
* Expand the Font Size slider minimum limit down to 10px (previously 20px).
2026-06-27 00:57:38 -04:00
1189dd3b25 Replace Mono option with Plain Sans and keep font size slider enabled globally
* Replace the "Mono" option in the dropdown with "Plain Sans (no QR parsing)".
* Add .qr.mode-sans CSS styling to use Liberation Sans.
* Simplify JS size logic to keep the font size slider always enabled and active for all modes.
2026-06-27 00:49:36 -04:00
0b0e029d55 Set Latin scale to 1.0, fix descender clipping, and update demo text
* Increase LATIN_SCALE to 1.00 to render Latin text at its full native baseline size.
* Change .qr CSS box-sizing to content-box and add 12px 16px padding to prevent descender vertical clipping.
* Change default demo text from "Download this font:" to "This page:".
2026-06-27 00:48:17 -04:00
3e25ab8e26 Make Latin text scale uniform and remove snap-to-grid rounding
* Set scale = LATIN_SCALE directly to ensure Latin characters keep the exact same visual size across 1-L, 2-L, and 3-L fonts.
* Set LATIN_SCALE to 0.70.
* Remove the snap-to-grid advance width constraint so that Latin characters spacing is natural.
2026-06-27 00:44:25 -04:00
be254ca9e3 Increase Latin scale, add metadata URL, and add webpage size controls
* Add qr.jim.sh URL to the font copyright, designerURL, and vendorURL metadata.
* Increase LATIN_SCALE from 0.20 to 0.50 so surrounding Latin text is larger and more readable.
* Add a styled Font Size range slider and number input box to the web page control panel to support dynamic visual sizing (20px to 400px, centering at 200px).
2026-06-27 00:42:08 -04:00
532d83d937 Rename demo.html -> index.html, separate HTML generation, and add deploy target
* Rename demo.html to index.html in the build and Makefile output.
* Separate HTML page generation into tools/build_html.py, removing the HTML template and write_demo logic from tools/build_font.py.
* Define correct dependency targets in the Makefile so that changes to tools/build_html.py only regenerate index.html and do not trigger a slow font build.
* Add a `make deploy` target that runs `rsync -avz dist/ psy:/www/qr/`.
2026-06-27 00:38:36 -04:00
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
8 changed files with 976 additions and 354 deletions

39
.github/workflows/release.yml vendored Normal file
View 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

View File

@@ -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 font is a Modified Version of Liberation Sans Regular and is licensed under the
SIL Open Font License 1.1. 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 - byte mode
- fixed mask pattern 0 - fixed mask pattern 0
- printable ASCII payloads up to 17 characters per bracketed block
- `[` and `]` delimiters - `[` and `]` delimiters
Example: Example:
@@ -32,8 +33,9 @@ Use `uv` for Python commands and dependency management.
make make
``` ```
`make` runs the full Reed-Solomon parity build, then copies the font, demo, `make` runs the full Reed-Solomon parity build and compiles the fonts and `index.html` demo into the `dist/` directory.
reference page, license, and notice into `~/Downloads/qrfont/`.
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 Do not use the placeholder parity path for normal verification. It exists only
as a legacy layout-debug target: 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 Firefox rendering for now. If visual alignment regresses, inspect recent
screenshots in `~/Downloads`. 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 ## Browser Layout, Alignment, and Line-Breaking Details
possible, but the current implementation hardcodes Version 1 data capacity,
coordinate placement, format layout, and Reed-Solomon parity size. Extending to ### Firefox Alignment Issue
larger versions means generating additional version-specific coordinate maps, - **Symptom:** Horizontal shifting/slicing between the top/bottom (parity) and middle (data) sections of the QR code when resizing the font in Firefox.
capacity tables, RS block structures, alignment patterns, and much larger - **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.
OpenType state/parity circuits. - **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.

View File

@@ -1,19 +1,24 @@
.PHONY: all clean .PHONY: all clean deploy
.PHONY: fast-placeholder full-parity .PHONY: fast-placeholder full-parity
export UV_CACHE_DIR := .uv-cache 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 uv run tools/build_font.py
mkdir -p $(HOME)/Downloads/qrfont
rm -f $(HOME)/Downloads/qrfont/qrfont*.ttf $(INDEX): tools/build_html.py
cp dist/qrfont*.ttf dist/demo.html dist/reference.html LICENSE-OFL.txt NOTICE.md $(HOME)/Downloads/qrfont/ uv run tools/build_html.py
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
rm -f $(HOME)/Downloads/qrfont/qrfont*.ttf deploy: all
cp dist/qrfont*.ttf dist/demo.html dist/reference.html LICENSE-OFL.txt NOTICE.md $(HOME)/Downloads/qrfont/ rsync -avz --delete dist/ psy:/www/qr/
full-parity: all full-parity: all

View File

@@ -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 This repo generates an experimental OpenType font that turns bracket-delimited
text into a QR Code symbol while leaving surrounding text readable. 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 abc[hello]ghi
``` ```
The first target is intentionally bounded: All fonts use:
- Byte mode
- QR Code Version 1-L, 21 by 21 modules - Printable ASCII input
- byte mode - Fixed mask pattern 0
- printable ASCII input, up to 17 characters
- fixed mask pattern 0
- `[` and `]` as delimiters - `[` and `]` as delimiters
The font is generated rather than hand-authored. The build script emits glyph 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`. outlines and GSUB feature logic, then compiles them into `dist/qrfont-*.ttf`.
The default build compiles the delimiter parser, byte expansion, Reed-Solomon The default build compiles the delimiter parser, byte expansion, Reed-Solomon
parity circuit, QR module placement, and fixed mask rendering. parity circuit, QR module placement, and fixed mask rendering.
Printable ASCII glyphs are copied from Liberation Sans Regular, scaled into the 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 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 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 ## Build
@@ -73,11 +78,11 @@ uv run tools/shape_debug.py '[a]' '[b]'
Outputs: Outputs:
- `dist/qrfont.ttf` - `dist/qrfont-*.ttf` (1-L, 2-L, and 3-L font files)
- `dist/demo.html` - `dist/index.html` (interactive web demo)
- `build/qrfont.fea` - `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 Mixed text such as `abc[def]ghi` should render as normal text, then a QR code
for `def`, then normal text. 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 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 17, 32, or 53 characters depending on the
selected font version.

View File

@@ -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 ReedSolomon 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. Automatic mask selection is possible using more unrolled Boolean circuitry, but it would probably exceed the ReedSolomon 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. 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" [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"

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,21 +23,54 @@ DEFAULT_BASE_FONT = Path("/usr/share/fonts/truetype/liberation/LiberationSans-Re
MODULE = 100 MODULE = 100
QUIET = 4 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 QR_SIZE = 21
ADVANCE = (QR_SIZE + QUIET * 2) * MODULE ADVANCE = (QR_SIZE + QUIET * 2) * MODULE
UNITS_PER_EM = ADVANCE UNITS_PER_EM = ADVANCE
ASCENT = ADVANCE ASCENT = ADVANCE
DESCENT = 0 DESCENT = 0
MAX_LEN = 17 MAX_LEN = 17
DATA_BITS = 152 DATA_CODEWORDS = 19
PARITY_BITS = 56 EC_CODEWORDS = 7
DATA_BITS = DATA_CODEWORDS * 8
PARITY_BITS = EC_CODEWORDS * 8
TOTAL_BITS = DATA_BITS + PARITY_BITS TOTAL_BITS = DATA_BITS + PARITY_BITS
MASK = 0 RS_GEN: list[int] = []
RENDER_X_BIAS = 0
MODULE_OVERPAINT = 8 def configure_qr(label: str) -> None:
SUPPORTED_CODES = [c for c in range(32, 127) if c not in (ord("["), ord("]"))] global QR_LABEL, QR_VERSION, QR_SIZE, ADVANCE, UNITS_PER_EM, ASCENT, DESCENT
LATIN_SCALE = 0.20 global MAX_LEN, DATA_CODEWORDS, EC_CODEWORDS, DATA_BITS, PARITY_BITS, TOTAL_BITS, RS_GEN
LATIN_Y_SHIFT = 220
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:
@@ -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)
@@ -344,14 +418,14 @@ def copy_base_glyph(
source_width, _ = base_font["hmtx"][source_name] source_width, _ = base_font["hmtx"][source_name]
scaled_width = round(source_width * scale) scaled_width = round(source_width * scale)
if scaled_width > 0: if scaled_width > 0:
data.advance_widths[target_name] = max(MODULE, round(scaled_width / MODULE) * MODULE) data.advance_widths[target_name] = scaled_width
else: else:
data.advance_widths[target_name] = 0 data.advance_widths[target_name] = 0
def add_printable_base_glyphs(data: FontData, base_font_path: Path) -> None: def add_printable_base_glyphs(data: FontData, base_font_path: Path) -> None:
base_font = TTFont(base_font_path) 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() cmap = base_font.getBestCmap()
notdef_name = ".notdef" notdef_name = ".notdef"
@@ -373,7 +447,7 @@ def add_printable_base_glyphs(data: FontData, base_font_path: Path) -> None:
target_name = printable_names[code] target_name = printable_names[code]
source_name = cmap.get(code) source_name = cmap.get(code)
if source_name is None: 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: else:
copy_base_glyph(base_font, source_name, target_name, data, scale, LATIN_Y_SHIFT) copy_base_glyph(base_font, source_name, target_name, data, scale, LATIN_Y_SHIFT)
data.cmap[code] = target_name 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( data.glyphs["header_bits"] = bit_group_glyph(
((i, bit) for i, bit in enumerate([0, 1, 0, 0])), coords ((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): for i in range(8):
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)
@@ -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))) bits = ((start + i, bit) for i, bit in enumerate(bits_of(code, 8)))
data.glyph_order.append(name) data.glyph_order.append(name)
data.glyphs[name] = bit_group_glyph(bits, coords) 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): for length in range(MAX_LEN + 1):
count_name = f"count_{length:02d}" 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))), ((4 + i, bit) for i, bit in enumerate(bits_of(length, 8))),
coords, coords,
) )
data.advance_widths[count_name] = ADVANCE data.advance_widths[count_name] = 0
used = 12 + length * 8 used = 12 + length * 8
tail = close_tail_bits(length) 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)), ((used + i, bit) for i, bit in enumerate(tail)),
coords, coords,
) )
data.advance_widths[tail_name] = ADVANCE data.advance_widths[tail_name] = 0
parity_name = f"parity_zero_{length:02d}" parity_name = f"parity_zero_{length:02d}"
data.glyph_order.append(parity_name) 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)), ((DATA_BITS + i, 0) for i in range(PARITY_BITS)),
coords, coords,
) )
data.advance_widths[parity_name] = ADVANCE data.advance_widths[parity_name] = 0
base_name = f"qr_base_{length:02d}" base_name = f"qr_base_{length:02d}"
data.glyph_order.append(base_name) 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.glyphs[name] = square_glyph(row, col) if (bit ^ is_masked(row, col)) else empty_glyph()
data.advance_widths[name] = 0 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 return data
@@ -457,23 +552,38 @@ 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}")
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 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 +621,300 @@ 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 start = 12 + pos * 8
payload = [g_d(used + i, bit) for i, bit in enumerate(close_tail_bits(length))] bits = bits_of(code, 8)
payload.extend(g_p(i, 0) for i in range(PARITY_BITS)) contribution = [0] * PARITY_BITS
payload.append(f"qr_base_{length:02d}") for i, bit in enumerate(bits):
return payload 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 fixed_parity_contribution(length: int, matrix: list[list[int]]) -> list[int]:
def data_part(index: int) -> str: fixed_bits = [0] * DATA_BITS
return g_d(index, 1) if index == focus_index else f"@d{index:03d}"
parts = [data_part(i) for i in range(12)] # Mode: 0, 1, 0, 0
for pos in range(length): mode = [0, 1, 0, 0]
parts.append("empty") for i in range(4):
start = 12 + pos * 8 fixed_bits[i] = mode[i]
parts.extend(data_part(i) for i in range(start, start + 8))
used = 12 + length * 8 # Length:
parts.append("empty") len_bits = bits_of(length, 8)
parts.extend(data_part(i) for i in range(used, DATA_BITS)) for i in range(8):
return parts 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"]
for pos in range(MAX_LEN): # Scan{pos}
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};")
if include_parity_circuit: 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() 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 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")
# 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 {") 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_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) return "\n".join(all_lines)
@@ -719,22 +939,24 @@ 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 "
"Google Corporation; copyright (c) 2012 Red Hat, Inc. QR Font " "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.", "licenseDescription": "Licensed under the SIL Open Font License, Version 1.1.",
"licenseInfoURL": "https://scripts.sil.org/OFL", "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 font = fb.font
gasp = newTable("gasp") gasp = newTable("gasp")
gasp.gaspRange = { gasp.gaspRange = {
@@ -748,95 +970,6 @@ 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:
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]]: def matrix_for_text(text: str) -> set[tuple[int, int]]:
coords = data_coordinates() 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: def parse_args() -> argparse.Namespace:
@@ -913,19 +1026,19 @@ 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_data = build_font_data(args.base_font)
feature_text = generate_features(include_parity_circuit=not args.placeholder_parity) for label in ("1L", "2L", "3L"):
(BUILD / "qrfont.fea").write_text(feature_text, encoding="utf-8") configure_qr(label)
build_ttf(font_data, feature_text, DIST / "qrfont.ttf") print(f"building QR Font {label}...", flush=True)
font_version = hashlib.sha256((DIST / "qrfont.ttf").read_bytes()).hexdigest()[:16] font_data = build_font_data(args.base_font)
font_filename = f"qrfont-{font_version}.ttf" feature_text = generate_features(include_parity_circuit=not args.placeholder_parity)
(DIST / font_filename).write_bytes((DIST / "qrfont.ttf").read_bytes()) (BUILD / f"qrfont-{label}.fea").write_text(feature_text, encoding="utf-8")
write_demo(font_filename) output_name = f"qrfont-{label}.ttf"
write_reference() build_ttf(font_data, feature_text, DIST / output_name)
print(f"wrote {DIST / 'qrfont.ttf'}") print(f"wrote {DIST / output_name}")
print(f"wrote {DIST / 'demo.html'}")
if __name__ == "__main__": if __name__ == "__main__":

331
tools/build_html.py Normal file
View 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
View 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()