Skip to content

Commit b306eb8

Browse files
MagicalTuxclaude
andcommitted
appclip: implement Apple App Clip Codes (URL codec + SVG generation)
New symbology: the circular five-ring code iOS scans to launch App Clips. Apple has never published the format; this is a from-scratch Rust port of the public reverse engineering (rs/appclipcode, MIT), covering: - URL compression: multi-context Huffman coders over Apple's trained depth-2 frequency tries (host / segmented / combined alphabets), three host formats (20 Huffman TLDs, 113 fixed-index TLDs, full-host coding), the auto-query template with its 156-word pathbook, segmented and combined path/query modes with per-component choice among context text, arbitrary-precision LEB128 decimals, a fixed 6-bit alphabet and wordbook indices, plus Apple's exact URL canonicalization rules and tie-breaks - payload codec: version selection, scramble, RS over GF(256) fcr=1 (gaps, arcs) and GF(16) (metadata), gap-majority inversion, the 128-entry spatial permutation LUT, separator/arc/extra-gap assembly, and the full structural decode (bits -> URL) with 1-2 symbol error correction - SVG generation: exact ring geometry (radii, rotations, 17/23/26/29/33 positions, per-ring gap insets), arc merging across gap positions, all 18 Apple color templates + custom palettes, camera and NFC glyphs Byte-verified against Apple's own URLCompression.framework output on 21 golden vectors, plus full ring-bit round-trips and canonicalization tests. The trained tables embed ~1.7 MB, so everything sits behind a default-on 'appclip' cargo feature; a lean default-features=false build skips it (and its Symbology::AppClipCode reports unimplemented). The demo page gains an App Clip entry (template/glyph pickers, SVG + rasterized PNG downloads) via a new 'appclip' wasm export, verified end-to-end under Node. Camera detection of printed codes is a planned follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 12f5ee2 commit b306eb8

17 files changed

Lines changed: 2618 additions & 4 deletions

File tree

‎Cargo.toml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ categories = ["encoding", "multimedia::images"]
1414
exclude = ["/web", "/.github", "/testdata"]
1515

1616
[features]
17+
default = ["appclip"]
18+
# Apple App Clip Code encode/decode. Embeds ~1.7 MB of trained Huffman frequency
19+
# tables (see src/codes/appclip); disable with `default-features = false` for a
20+
# lean build.
21+
appclip = []
1722
# The `anyd` command-line tool. Off by default so the library stays
1823
# dependency-free; enable with `cargo build --features cli`.
1924
cli = ["dep:oxideav-png"]

‎README.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ Legend: ✅ done · 🚧 in progress · ⬜ planned.
4444
| Han Xin (versions 1–3, Numeric/Text/Byte) | ✅ | ✅ | ⬜ |
4545
| DotCode (GF(113) RS) | ✅ | ✅ | ⬜ |
4646
| Grid Matrix (all 13 versions) | ✅ | ✅ | ⬜ |
47+
| Apple App Clip Code (URL codec + SVG, camera & NFC variants)⁵ | ✅ | ✅ | ⬜ |
4748

4849
### 2D — stacked
4950

@@ -103,6 +104,17 @@ affine grid sampling → RS-corrected decode. Handles upright, rotation ≤±10
103104
blur and noise; perspective keystoning is out of scope (no interior anchor). Envelope
104105
in `tests/pdf417_image.rs`.
105106

107+
⁵ Apple has never published the App Clip Code format; this is an independent,
108+
from-scratch implementation following the public reverse engineering of Apple's
109+
generator ([rs/appclipcode], MIT), byte-verified against `URLCompression.framework`
110+
output. Encode produces the circular five-ring SVG (all 18 Apple color templates,
111+
camera and NFC center glyphs); decode is structural (ring bits → URL). The trained
112+
Huffman tables embed ~1.7 MB, so the module sits behind the default-on `appclip`
113+
cargo feature (`default-features = false` for a lean build). Apple, App Clips and
114+
related marks are trademarks of Apple Inc.
115+
116+
[rs/appclipcode]: https://github.com/rs/appclipcode
117+
106118
> **Naming note.** GS1 DataBar was formerly "RSS": DataBar Omnidirectional = RSS-14,
107119
> DataBar Limited = RSS Limited, DataBar Expanded = RSS Expanded. Codabar is sometimes
108120
> called "Coda"/NW-7. These are aliases for the same `Symbology` variants above.

‎src/codes/appclip/codec.rs‎

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
//! Payload codec: 16-byte compressed URL ⇄ the on-ring bit vector.
2+
//!
3+
//! Encoding: pick a version by trimming leading zero bytes (≤14 bytes → v0 with 4
4+
//! gap-parity symbols, else v1 with 2), scramble (reverse + XOR `0xA5`), split into
5+
//! the gaps and arcs blocks, RS-encode each plus a GF(16) metadata block carrying the
6+
//! version and a gap-inversion flag, assemble `[meta 16][gaps 104][template 0x2A]`
7+
//! into 128 bits, spatially permute them with the fixed LUT, then append a separator
8+
//! bit, the 56 arc bits and the extra gap bits. The gap bits are inverted when needed
9+
//! so the visible rings always carry more arcs than gaps.
10+
11+
use super::gf::{Gf, rs_decode, rs_encode};
12+
use super::tables::GAPS_BITS_ORDER_LUT;
13+
use crate::error::{Error, Result};
14+
15+
struct FormatParams {
16+
gaps_data: usize,
17+
gaps_parity: usize,
18+
arcs_data: usize,
19+
arcs_parity: usize,
20+
}
21+
22+
fn format_params(version: usize) -> FormatParams {
23+
if version == 0 {
24+
FormatParams {
25+
gaps_data: 9,
26+
gaps_parity: 4,
27+
arcs_data: 5,
28+
arcs_parity: 2,
29+
}
30+
} else {
31+
FormatParams {
32+
gaps_data: 11,
33+
gaps_parity: 2,
34+
arcs_data: 5,
35+
arcs_parity: 2,
36+
}
37+
}
38+
}
39+
40+
/// The template byte `0x2A`, stored LSB-first.
41+
const TEMPLATE_BITS: [bool; 8] = [false, true, false, true, false, true, false, false];
42+
43+
/// Encode a compressed-URL payload (≤ 16 bytes) into the final ring bit vector.
44+
pub fn encode_payload(payload: &[u8]) -> Result<Vec<bool>> {
45+
let trimmed: &[u8] = {
46+
let start = payload.iter().position(|&b| b != 0).unwrap_or(payload.len());
47+
&payload[start..]
48+
};
49+
if trimmed.len() > 16 {
50+
return Err(Error::capacity("App Clip payload exceeds 16 bytes"));
51+
}
52+
let version = usize::from(trimmed.len() > 14);
53+
let fp = format_params(version);
54+
let total = fp.gaps_data + fp.arcs_data;
55+
56+
let mut padded = vec![0u8; total];
57+
padded[total - trimmed.len()..].copy_from_slice(trimmed);
58+
59+
// Scramble: reverse byte order and XOR 0xA5.
60+
let scrambled: Vec<u8> = (0..total).map(|i| padded[total - 1 - i] ^ 0xA5).collect();
61+
62+
let gf256 = Gf::f256();
63+
let gaps_syms: Vec<usize> = scrambled[..fp.gaps_data].iter().map(|&b| b as usize).collect();
64+
let mut gaps_bits = symbols_to_bits(&rs_encode(&gf256, &gaps_syms, fp.gaps_parity), 8);
65+
66+
// Invert so the gaps ring always holds more zero (visible) than one bits.
67+
let gap_zeros = gaps_bits.iter().filter(|&&b| !b).count();
68+
let inverted = gap_zeros <= 51;
69+
if inverted {
70+
for b in &mut gaps_bits {
71+
*b = !*b;
72+
}
73+
}
74+
75+
let gf16 = Gf::f16();
76+
let meta = [version >> 3, usize::from(inverted) | ((version & 7) << 1)];
77+
let meta_bits = symbols_to_bits(&rs_encode(&gf16, &meta, 2), 4);
78+
79+
let arcs_syms: Vec<usize> = scrambled[total - fp.arcs_data..]
80+
.iter()
81+
.map(|&b| b as usize)
82+
.collect();
83+
let arcs_bits = symbols_to_bits(&rs_encode(&gf256, &arcs_syms, fp.arcs_parity), 8);
84+
85+
// Assemble [meta 16][gaps 104][template 8] and permute through the LUT.
86+
let mut pre = [false; 128];
87+
pre[..16].copy_from_slice(&meta_bits);
88+
pre[16..120].copy_from_slice(&gaps_bits);
89+
pre[120..].copy_from_slice(&TEMPLATE_BITS);
90+
91+
let zero_count = pre.iter().filter(|&&b| !b).count();
92+
let mut out = vec![false; 129 + zero_count];
93+
for (i, &b) in pre.iter().enumerate() {
94+
out[GAPS_BITS_ORDER_LUT[i]] = b;
95+
}
96+
97+
// Separator (always 0) + arc bits + extra gap bits.
98+
out[129..129 + 56].copy_from_slice(&arcs_bits);
99+
let extra = zero_count.saturating_sub(56);
100+
if extra > 0 && extra <= gaps_bits.len() {
101+
out[185..185 + extra].copy_from_slice(&gaps_bits[..extra]);
102+
}
103+
Ok(out)
104+
}
105+
106+
/// Decode a ring bit vector back to the right-aligned 16-byte payload.
107+
pub fn decode_payload(bits: &[bool]) -> Result<[u8; 16]> {
108+
if bits.len() < 128 {
109+
return Err(Error::undecodable("App Clip bit vector shorter than 128"));
110+
}
111+
112+
// Reverse the LUT permutation.
113+
let mut pre = [false; 128];
114+
for (i, p) in pre.iter_mut().enumerate() {
115+
*p = bits[GAPS_BITS_ORDER_LUT[i]];
116+
}
117+
118+
// Metadata: four GF(16) symbols, RS(4,2).
119+
let gf16 = Gf::f16();
120+
let meta_cw: Vec<usize> = (0..4)
121+
.map(|i| bits_to_symbol(&pre[i * 4..i * 4 + 4]))
122+
.collect();
123+
let meta = rs_decode(&gf16, &meta_cw, 2)
124+
.ok_or_else(|| Error::undecodable("App Clip metadata RS failed"))?;
125+
let version = (meta[0] << 3) | (meta[1] >> 1);
126+
let inverted = meta[1] & 1 == 1;
127+
if version > 1 {
128+
return Err(Error::undecodable("invalid App Clip codec version"));
129+
}
130+
let fp = format_params(version);
131+
132+
// Gaps: thirteen GF(256) symbols.
133+
let mut gap_bits: Vec<bool> = pre[16..120].to_vec();
134+
if inverted {
135+
for b in &mut gap_bits {
136+
*b = !*b;
137+
}
138+
}
139+
let gap_cw: Vec<usize> = (0..13)
140+
.map(|i| bits_to_symbol(&gap_bits[i * 8..i * 8 + 8]))
141+
.collect();
142+
let gf256 = Gf::f256();
143+
let gap_syms = rs_decode(&gf256, &gap_cw, fp.gaps_parity)
144+
.ok_or_else(|| Error::undecodable("App Clip gaps RS failed"))?;
145+
146+
// Arcs, when the color stream is present.
147+
let total = fp.gaps_data + fp.arcs_data;
148+
let mut scrambled = vec![0u8; total];
149+
for i in 0..fp.gaps_data {
150+
scrambled[i] = gap_syms[i] as u8;
151+
}
152+
if bits.len() >= 128 + 57 {
153+
if bits[128] {
154+
return Err(Error::undecodable("invalid App Clip separator bit"));
155+
}
156+
let arc_cw: Vec<usize> = (0..7)
157+
.map(|i| bits_to_symbol(&bits[129 + i * 8..129 + i * 8 + 8]))
158+
.collect();
159+
let arc_syms = rs_decode(&gf256, &arc_cw, fp.arcs_parity)
160+
.ok_or_else(|| Error::undecodable("App Clip arcs RS failed"))?;
161+
for i in 0..fp.arcs_data {
162+
scrambled[fp.gaps_data + i] = arc_syms[i] as u8;
163+
}
164+
}
165+
166+
// Unscramble and right-align into 16 bytes.
167+
let mut payload = [0u8; 16];
168+
for i in 0..total {
169+
payload[16 - total + (total - 1 - i)] = scrambled[i] ^ 0xA5;
170+
}
171+
Ok(payload)
172+
}
173+
174+
/// Symbols to bits, MSB-first per symbol.
175+
fn symbols_to_bits(symbols: &[usize], bits_per: usize) -> Vec<bool> {
176+
let mut out = Vec::with_capacity(symbols.len() * bits_per);
177+
for &s in symbols {
178+
for j in (0..bits_per).rev() {
179+
out.push((s >> j) & 1 == 1);
180+
}
181+
}
182+
out
183+
}
184+
185+
fn bits_to_symbol(bits: &[bool]) -> usize {
186+
bits.iter().fold(0usize, |acc, &b| (acc << 1) | usize::from(b))
187+
}
188+
189+
#[cfg(test)]
190+
mod tests {
191+
use super::*;
192+
193+
#[test]
194+
fn payload_roundtrip_both_versions() {
195+
// ≤14 significant bytes → version 0; 15-16 → version 1.
196+
let mut short = [0u8; 16];
197+
short[14] = 0x42;
198+
short[15] = 0xAA;
199+
let mut long = [0u8; 16];
200+
for (i, b) in long.iter_mut().enumerate() {
201+
*b = (i as u8).wrapping_mul(17).wrapping_add(1);
202+
}
203+
for payload in [short, long] {
204+
let bits = encode_payload(&payload).unwrap();
205+
assert!(bits.len() >= 129, "vector too short: {}", bits.len());
206+
let back = decode_payload(&bits).unwrap();
207+
assert_eq!(back, payload);
208+
}
209+
}
210+
211+
#[test]
212+
fn gap_ring_majority_zero() {
213+
// The inversion step guarantees more zero (arc) than one (gap) bits among the
214+
// 104 gap bits, whatever the payload.
215+
for seed in 0u8..16 {
216+
let payload = [seed.wrapping_mul(37); 16];
217+
let bits = encode_payload(&payload).unwrap();
218+
// Count zeros in the whole 128-bit permuted block: ≥ the guarantee on the
219+
// gaps portion alone minus meta/template variance; the strict per-spec
220+
// check is on the pre-permutation gaps, so re-derive them.
221+
let mut pre = [false; 128];
222+
for (i, p) in pre.iter_mut().enumerate() {
223+
*p = bits[GAPS_BITS_ORDER_LUT[i]];
224+
}
225+
let zeros = pre[16..120].iter().filter(|&&b| !b).count();
226+
assert!(zeros >= 52, "gap zeros {zeros} < 52 for seed {seed}");
227+
}
228+
}
229+
}

‎src/codes/appclip/data/cpq.data‎

835 KB
Binary file not shown.

‎src/codes/appclip/data/h.data‎

119 KB
Binary file not shown.

‎src/codes/appclip/data/spq.data‎

709 KB
Binary file not shown.

0 commit comments

Comments
 (0)