Last active 1766574950

A shell script that converts a .pkpass ticket for wallet apps into a PNG image

fabio's Avatar fabio revised this gist 1766574950. Go to revision

1 file changed, 0 insertions, 0 deletions

pkpass2png renamed to pkpass2png.sh

File renamed without changes

fabio's Avatar Fabio Manganiello revised this gist 1766574867. Go to revision

1 file changed, 272 insertions

pkpass2png(file created)

@@ -0,0 +1,272 @@
1 + #!/usr/bin/env bash
2 + set -euo pipefail
3 +
4 + # Usage:
5 + # ./pkpass2png <pkpass_path_or_url> [output.png]
6 + #
7 + # Examples:
8 + # ./pkpass2png ticket.pkpass out.png
9 + # ./pkpass2png "https://example.com/ticket.pkpass" out.png
10 +
11 + INPUT="${1:-}"
12 + OUT="${2:-ticket.png}"
13 +
14 + if [[ -z "$INPUT" ]]; then
15 + echo "Usage: $0 <pkpass_path_or_url> [output.png]" >&2
16 + exit 2
17 + fi
18 +
19 + need_cmd() {
20 + command -v "$1" >/dev/null 2>&1 || {
21 + echo "Missing dependency: $1" >&2
22 + exit 2
23 + }
24 + }
25 +
26 + need_cmd jq
27 + need_cmd zint
28 + need_cmd magick
29 + need_cmd unzip
30 +
31 + # Pick a downloader if needed
32 + is_url=0
33 + if [[ "$INPUT" =~ ^https?:// ]]; then
34 + is_url=1
35 + if command -v curl >/dev/null 2>&1; then
36 + DL='curl -L --fail --silent --show-error'
37 + elif command -v wget >/dev/null 2>&1; then
38 + DL='wget -qO-'
39 + else
40 + echo "Need curl or wget to download URLs" >&2
41 + exit 2
42 + fi
43 + fi
44 +
45 + tmp=$(mktemp -d)
46 + trap 'rm -rf "$tmp"' EXIT
47 +
48 + PKPASS_FILE="$tmp/pass.pkpass"
49 + EXTRACT_DIR="$tmp/extracted"
50 + mkdir -p "$EXTRACT_DIR"
51 +
52 + # ---- Acquire pkpass (download or copy) ----
53 + if [[ "$is_url" -eq 1 ]]; then
54 + # download to file
55 + if [[ "$DL" == curl* ]]; then
56 + $DL "$INPUT" -o "$PKPASS_FILE"
57 + else
58 + $DL "$INPUT" > "$PKPASS_FILE"
59 + fi
60 + else
61 + # local file
62 + if [[ ! -f "$INPUT" ]]; then
63 + echo "Input file not found: $INPUT" >&2
64 + exit 2
65 + fi
66 + cp -f "$INPUT" "$PKPASS_FILE"
67 + fi
68 +
69 + # ---- Extract pkpass (zip) ----
70 + unzip -q "$PKPASS_FILE" -d "$EXTRACT_DIR"
71 +
72 + PASS_JSON="$EXTRACT_DIR/pass.json"
73 + if [[ ! -f "$PASS_JSON" ]]; then
74 + echo "pass.json not found inside pkpass" >&2
75 + exit 2
76 + fi
77 +
78 + # Choose base image: prefer strip.png if present, else background.png
79 + if [[ -f "$EXTRACT_DIR/strip.png" ]]; then
80 + BASE_IMG="$EXTRACT_DIR/strip.png"
81 + elif [[ -f "$EXTRACT_DIR/background.png" ]]; then
82 + BASE_IMG="$EXTRACT_DIR/background.png"
83 + else
84 + echo "Neither strip.png nor background.png found in pkpass" >&2
85 + exit 2
86 + fi
87 +
88 + # Work files
89 + MSG_FILE="$tmp/msg.txt"
90 + BARCODE_RAW="$tmp/barcode_raw.png"
91 + BARCODE_CARD="$tmp/barcode_card.png"
92 + font="DejaVu-Sans"
93 +
94 + # Find largest pointsize <= max_pt that fits within max_width pixels
95 + fit_pt() {
96 + local text="$1" max_width="$2" max_pt="$3" font="$4"
97 + local pt w
98 + for ((pt=max_pt; pt>=9; pt--)); do
99 + w=$(magick -font "$font" -pointsize "$pt" \
100 + -background none label:"$text" -format '%w' info:)
101 + if [ "$w" -le "$max_width" ]; then
102 + echo "$pt"
103 + return
104 + fi
105 + done
106 + echo 9
107 + }
108 +
109 + # ---- Extract fields ----
110 + org=$(jq -r '.organizationName // ""' "$PASS_JSON")
111 + logoText=$(jq -r '.logoText // empty' "$PASS_JSON")
112 + date=$(jq -r '.eventTicket.headerFields[]? | select(.key=="date") | .value' "$PASS_JSON" | head -n1)
113 + event=$(jq -r '.eventTicket.primaryFields[]? | select(.key=="event") | .value' "$PASS_JSON" | head -n1)
114 +
115 + loc_label=$(jq -r '.eventTicket.secondaryFields[]? | select(.key=="location") | .label' "$PASS_JSON" | head -n1)
116 + loc_value=$(jq -r '.eventTicket.secondaryFields[]? | select(.key=="location") | .value' "$PASS_JSON" | head -n1)
117 +
118 + seat_label=$(jq -r '.eventTicket.secondaryFields[]? | select(.key=="seat") | .label' "$PASS_JSON" | head -n1)
119 + seat_value=$(jq -r '.eventTicket.secondaryFields[]? | select(.key=="seat") | .value' "$PASS_JSON" | head -n1)
120 +
121 + price_label=$(jq -r '.eventTicket.auxiliaryFields[]? | select(.key=="price") | .label' "$PASS_JSON" | head -n1)
122 + price_value=$(jq -r '.eventTicket.auxiliaryFields[]? | select(.key=="price") | .value' "$PASS_JSON" | head -n1)
123 +
124 + alt_text=$(jq -r '.barcode.altText // ""' "$PASS_JSON")
125 +
126 + fg=$(jq -r '.foregroundColor // "rgb(255,255,255)"' "$PASS_JSON")
127 + label=$(jq -r '.labelColor // "rgb(200,200,200)"' "$PASS_JSON")
128 +
129 + # ---- Canvas size ----
130 + W=$(magick identify -format '%w' "$BASE_IMG") # [1]
131 + H=$(magick identify -format '%h' "$BASE_IMG") # [1]
132 +
133 + # ---- Proportional layout constants ----
134 + # Margins/padding (percentages tuned to your 360x440 case)
135 + M=$(( W * 4 / 100 )) # ~4% of width (360 -> 14)
136 + [ "$M" -lt 10 ] && M=10
137 +
138 + TOP=$(( H * 3 / 100 )) # ~3% of height (440 -> 13)
139 + COL_GAP=$(( W * 4 / 100 )) # ~4% of width
140 + [ "$COL_GAP" -lt 10 ] && COL_GAP=10
141 + COL_W=$(((W - 2*M - COL_GAP)/2))
142 +
143 + # Barcode sizing derived from width
144 + BC_SIZE=$(( W * 52 / 100 )) # 360 -> 187
145 + [ "$BC_SIZE" -lt 150 ] && BC_SIZE=150 # keep scannable
146 + BOTTOM_MARGIN=$(( H * 4 / 100 )) # 440 -> 17
147 + [ "$BOTTOM_MARGIN" -lt 10 ] && BOTTOM_MARGIN=10
148 + BAR_GAP=$(( H * 3 / 100 )) # 440 -> 13
149 + [ "$BAR_GAP" -lt 10 ] && BAR_GAP=10
150 +
151 + # Font maxima (scale with height)
152 + org_max=$(( H * 4 / 100 )) # 440 -> 17
153 + date_max=$(( H * 3 / 100 )) # 440 -> 13
154 + event_max=$(( H * 5 / 100 )) # 440 -> 22
155 + val_max=$(( H * 4 / 100 )) # 440 -> 17
156 + [ "$org_max" -lt 12 ] && org_max=12
157 + [ "$date_max" -lt 11 ] && date_max=11
158 + [ "$event_max" -lt 14 ] && event_max=14
159 + [ "$val_max" -lt 12 ] && val_max=12
160 +
161 + label_pt=$(( H * 3 / 100 )) # 440 -> 13
162 + [ "$label_pt" -lt 11 ] && label_pt=11
163 +
164 + # Vertical gaps (scale with height)
165 + gap1=$(( H * 7 / 100 )) # 440 -> 30
166 + gap2=$(( H * 7 / 100 )) # 440 -> 30
167 + gap3=$(( H * 4 / 100 )) # 440 -> 17
168 + gap4=$(( H * 6 / 100 )) # 440 -> 22
169 + gap5=$(( H * 4 / 100 )) # 440 -> 17
170 +
171 + # Baseline clamp (prevents top clipping)
172 + MIN_Y1=$(( H * 5 / 100 )) # 440 -> 30
173 + [ "$MIN_Y1" -lt 18 ] && MIN_Y1=18
174 +
175 + # ---- Barcode (Aztec) ----
176 + jq -r '.barcode.message' "$PASS_JSON" | iconv -f UTF-8 -t ISO-8859-1 > "$MSG_FILE"
177 + zint --barcode=92 --scale=5 --border=1 -o "$BARCODE_RAW" -i "$MSG_FILE" # [1]
178 +
179 + magick "$BARCODE_RAW" \
180 + -resize "${BC_SIZE}x${BC_SIZE}" \
181 + -background white -gravity south -splice 0x$((H*5/100)) \
182 + -fill black -font "$font" -pointsize $((H*3/100)) \
183 + -annotate +0+0 "$alt_text" \
184 + -bordercolor white -border $((W*3/100)) \
185 + "$BARCODE_CARD" # [1]
186 +
187 + BAR_H=$(magick identify -format '%h' "$BARCODE_CARD") # [1]
188 + TEXT_H=$(( H - BAR_H - BOTTOM_MARGIN - BAR_GAP )) # [1]
189 +
190 + # ---- Auto-fit point sizes ----
191 + # Leave room for date on the right: reserve ~40% width for it
192 + org_pt=$(fit_pt "${logoText:-$org}" $((W - 2*M - (W*40/100))) "$org_max" "$font") # [1]
193 + date_pt=$(fit_pt "$date" $((W - 2*M)) "$date_max" "$font") # [1]
194 + event_pt=$(fit_pt "$event" $((W - 2*M)) "$event_max" "$font") # [1]
195 +
196 + loc_val_pt=$(fit_pt "$loc_value" "$COL_W" "$val_max" "$font") # [1]
197 + seat_val_pt=$(fit_pt "$seat_value" "$COL_W" "$val_max" "$font") # [1]
198 + price_val_pt=$(fit_pt "$price_value" $((W - 2*M)) "$val_max" "$font") # [1]
199 +
200 + # ---- Y positions ----
201 + y1=$((TOP + (H*6/100))) # org/date baseline
202 + y2=$((y1 + gap1)) # event
203 + y3=$((y2 + gap2)) # secondary labels
204 + y4=$((y3 + gap3)) # secondary values
205 + y5=$((y4 + gap4)) # price label
206 + y6=$((y5 + gap5)) # price value
207 +
208 + # Shift up if we collide with barcode reserved area (conservative)
209 + max_y=$((y6 + (H*7/100)))
210 + if [ "$max_y" -ge "$TEXT_H" ]; then
211 + shift=$((max_y - TEXT_H + (H*1/100)))
212 + y1=$((y1 - shift))
213 + y2=$((y2 - shift))
214 + y3=$((y3 - shift))
215 + y4=$((y4 - shift))
216 + y5=$((y5 - shift))
217 + y6=$((y6 - shift))
218 + fi
219 +
220 + # Clamp top baseline so first line doesn't clip
221 + if [ "$y1" -lt "$MIN_Y1" ]; then
222 + d=$((MIN_Y1 - y1))
223 + y1=$((y1 + d))
224 + y2=$((y2 + d))
225 + y3=$((y3 + d))
226 + y4=$((y4 + d))
227 + y5=$((y5 + d))
228 + y6=$((y6 + d))
229 + fi
230 +
231 + # ---- Compose ----
232 + magick "$BASE_IMG" \
233 + -font "$font" \
234 + \
235 + -gravity northwest \
236 + -fill "$fg" -pointsize "$org_pt" \
237 + -annotate +$M+$y1 "${logoText:-$org}" \
238 + \
239 + -gravity northeast \
240 + -fill "$fg" -pointsize "$date_pt" \
241 + -annotate +$M+$y1 "$date" \
242 + \
243 + -gravity northwest \
244 + -fill "$fg" -pointsize "$event_pt" \
245 + -annotate +$M+$y2 "$event" \
246 + \
247 + -fill "$fg" -pointsize "$label_pt" \
248 + -annotate +$M+$y3 "$loc_label" \
249 + \
250 + -gravity northeast \
251 + -fill "$fg" -pointsize "$label_pt" \
252 + -annotate +$M+$y3 "$seat_label" \
253 + \
254 + -gravity northwest \
255 + -fill "$label" -pointsize "$loc_val_pt" \
256 + -annotate +$M+$y4 "$loc_value" \
257 + \
258 + -gravity northeast \
259 + -fill "$label" -pointsize "$seat_val_pt" \
260 + -annotate +$M+$y4 "$seat_value" \
261 + \
262 + -gravity northwest \
263 + -fill "$fg" -pointsize "$label_pt" \
264 + -annotate +$M+$y5 "$price_label" \
265 + \
266 + -fill "$label" -pointsize "$price_val_pt" \
267 + -annotate +$M+$y6 "$price_value" \
268 + \
269 + \( "$BARCODE_CARD" \) \
270 + -gravity south -geometry +0+$BOTTOM_MARGIN -composite \
271 + \
272 + "$OUT"
Newer Older