0
0

Compare commits

..

22 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
8 changed files with 596 additions and 228 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
SIL Open Font License 1.1.
The current bounded QR target is:
The bounded QR targets are:
- QR Code Version 3-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 53 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,10 +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.
The current font is fixed to Version 3-L, which supports 53 byte-mode
characters. Supporting multiple QR versions in one font 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 simpler strategy is one fixed QR version for all payloads.
## 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.

View File

@@ -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 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 LICENSE-OFL.txt NOTICE.md $(HOME)/Downloads/qrfont/
deploy: all
rsync -avz --delete dist/ psy:/www/qr/
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
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 3-L, 29 by 29 modules
- byte mode
- printable ASCII input, up to 53 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 53 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.
## 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"

View File

@@ -27,9 +27,10 @@ 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_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},
@@ -51,7 +52,6 @@ PARITY_BITS = EC_CODEWORDS * 8
TOTAL_BITS = DATA_BITS + PARITY_BITS
RS_GEN: list[int] = []
def configure_qr(label: str) -> None:
global QR_LABEL, QR_VERSION, QR_SIZE, ADVANCE, UNITS_PER_EM, ASCENT, DESCENT
global MAX_LEN, DATA_CODEWORDS, EC_CODEWORDS, DATA_BITS, PARITY_BITS, TOTAL_BITS, RS_GEN
@@ -418,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"
@@ -447,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
@@ -464,7 +464,7 @@ 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)
@@ -480,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}"
@@ -489,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)
@@ -499,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)
@@ -507,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)
@@ -527,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
@@ -542,6 +560,11 @@ def grouped_internal_glyphs(include_parity_circuit: bool = False) -> list[str]:
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
@@ -866,6 +889,21 @@ def generate_features(include_parity_circuit: bool = False) -> str:
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)
@@ -876,14 +914,6 @@ def generate_features(include_parity_circuit: bool = False) -> str:
all_lines.append("} rlig;")
all_lines.append("")
all_lines.append(class_line("qr_internal", grouped_internal_glyphs(include_parity_circuit)))
all_lines.append(class_line("qr_any", grouped_any_glyphs(include_parity_circuit)))
all_lines.append(class_line("qr_follow", grouped_follow_glyphs(include_parity_circuit)))
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)
@@ -918,10 +948,12 @@ def build_ttf(font_data: FontData, feature_text: str, output: Path) -> None:
"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(keepGlyphNames=True)
@@ -938,175 +970,6 @@ def build_ttf(font_data: FontData, feature_text: str, output: Path) -> None:
font.save(output)
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>
<style>
@font-face {
font-family: "QR Font 1-L";
src: url("./__FONT_1L__") format("truetype");
}
@font-face {
font-family: "QR Font 2-L";
src: url("./__FONT_2L__") format("truetype");
}
@font-face {
font-family: "QR Font 3-L";
src: url("./__FONT_3L__") format("truetype");
}
body {
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;
}
select {
margin-bottom: 14px;
padding: 8px 10px;
border: 1px solid #b7c0cc;
border-radius: 6px;
font: 15px system-ui, sans-serif;
}
.qr {
margin-top: 32px;
font-feature-settings: "rlig" 1, "kern" 1;
line-height: 1;
color: #000;
background: #fff;
display: block;
box-sizing: border-box;
width: 100%;
min-height: 222px;
padding: 0;
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-mono {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 18px;
min-height: 198px;
padding: 16px;
}
.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>
<label for="mode">Font</label>
<select id="mode">
<option value="1L">QR Font 1-L (up to 17 characters)</option>
<option value="2L" selected>QR Font 2-L (up to 32 characters)</option>
<option value="3L">QR Font 3-L (up to 53 characters)</option>
<option value="mono">Mono</option>
</select>
<label for="text">Text</label>
<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. 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!
Download this font: [http://qr.jim.sh/]</div>
<p class="links">
<a href="./__FONT_1L__">Download QR Font 1-L</a>
<a href="./__FONT_2L__">Download QR Font 2-L</a>
<a href="./__FONT_3L__">Download QR Font 3-L</a>
<a href="https://git.jim.sh/jim/qr-font.git">Source repository</a>
</p>
</main>
<script>
const input = document.getElementById("text");
const mode = document.getElementById("mode");
const qr = document.getElementById("qr");
function render() {
const value = input.value;
qr.replaceChildren(document.createTextNode(value));
qr.className = `qr mode-${mode.value}`;
}
input.addEventListener("input", render);
input.addEventListener("change", render);
input.addEventListener("keyup", render);
mode.addEventListener("change", render);
render();
</script>
</body>
</html>
"""
html = (
html.replace("__FONT_1L__", font_filenames["1L"])
.replace("__FONT_2L__", font_filenames["2L"])
.replace("__FONT_3L__", font_filenames["3L"])
)
(DIST / "demo.html").write_text(html, encoding="utf-8")
def matrix_for_text(text: str) -> set[tuple[int, int]]:
coords = data_coordinates()
@@ -1163,10 +1026,10 @@ def main() -> None:
args = parse_args()
BUILD.mkdir(exist_ok=True)
DIST.mkdir(exist_ok=True)
for stale_font in DIST.glob("qrfont*.ttf"):
stale_font.unlink()
font_filenames: dict[str, str] = {}
for label in ("1L", "2L", "3L"):
configure_qr(label)
print(f"building QR Font {label}...", flush=True)
@@ -1175,13 +1038,8 @@ def main() -> None:
(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)
font_filenames[label] = output_name
print(f"wrote {DIST / output_name}")
configure_qr("2L")
write_demo(font_filenames)
print(f"wrote {DIST / 'demo.html'}")
if __name__ == "__main__":
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()