Skip to content

Commit 46d8af7

Browse files
authored
Merge pull request #56 from jantman/qr-code-printing
Resolution-aware QR label printing (per-device DPI)
2 parents 7ba748a + 95ba4e7 commit 46d8af7

12 files changed

Lines changed: 589 additions & 47 deletions

File tree

‎_bmad-output/implementation-artifacts/tech-spec-resolution-aware-qr-printing.md‎

Lines changed: 280 additions & 0 deletions
Large diffs are not rendered by default.

‎docs/images/qr-generation.png‎

13.1 KB
Loading

‎docs/manual_testing.md‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -310,18 +310,22 @@ Eleven scenarios for manual verification of the Equipment Status Board's core us
310310
3. Verify the "Generate QR Code" button is enabled (not greyed out).
311311
4. Click the button to open `/equipment/<id>/qr`.
312312
5. Select each size preset in turn (1"–4" stickers, Avery 5160, Avery 5163, US Letter) and verify the live preview updates to match.
313-
6. Toggle the "Include equipment name" and "Include target URL" checkboxes and verify the preview updates accordingly.
314-
7. Download the label at the 2" sticker preset; open the PNG and verify it is 300 DPI.
315-
8. Print (or view at actual size) the downloaded label and scan it with a phone camera.
316-
9. Download the US Letter preset and verify the PNG is 2550×3300 px.
317-
10. Stop the server, unset `ESB_BASE_URL`, restart, and reload the equipment detail page.
318-
11. On an archived equipment item, confirm the QR button does not render and that `GET /equipment/<id>/qr` returns 404.
313+
6. Select each **Printer / device** preset in turn (Laser/Inkjet 300/600/1200 dpi, Thermal Label 203 dpi, Brother P-Touch 180 dpi) and verify the live preview updates.
314+
7. Toggle the "Include equipment name" and "Include target URL" checkboxes and verify the preview updates accordingly.
315+
8. For each device preset, download a label and confirm the PNG's pixel dimensions equal `int(size_inches × device_dpi + 0.5)` — e.g. a 4" sticker at Thermal Label (203 dpi) → 812×812 px; at Laser/Inkjet (1200 dpi) → 4800×4800 px.
316+
9. Confirm the PNG embeds matching DPI metadata. With ImageMagick: `identify -verbose file.png | grep Resolution`. Dependency-free: `python -c "from PIL import Image; print(Image.open('file.png').info['dpi'])"`. Note that a 203 dpi label reports approximately `(202.9968, 202.9968)` — this is expected rational rounding, not a bug.
317+
10. Confirm a 4" label at **Thermal Label (203 dpi)** prints full-size (no clipping) on a 203-dpi thermal printer, and that selecting **US Letter** at **Laser/Inkjet (1200 dpi)** shows a friendly "too large to render" error instead of downloading.
318+
11. Print (or view at actual size) a downloaded label and scan it with a phone camera.
319+
12. At the default Laser/Inkjet (300 dpi) device, download the US Letter preset and verify the PNG is 2550×3300 px.
320+
13. Stop the server, unset `ESB_BASE_URL`, restart, and reload the equipment detail page.
321+
14. On an archived equipment item, confirm the QR button does not render and that `GET /equipment/<id>/qr` returns 404.
319322

320323
**Expected Results:**
321324

322-
- The live preview on `/equipment/<id>/qr` reflects size and label-content options in real time.
325+
- The live preview on `/equipment/<id>/qr` reflects size, device, and label-content options in real time.
323326
- Scanning the downloaded/printed QR code resolves to `${ESB_BASE_URL}/public/equipment/<id>`.
324-
- Rendered PNG dimensions match the selected preset (e.g., letter → 2550×3300 px at 300 DPI).
327+
- Rendered PNG dimensions match the selected size × device DPI (e.g., at the default Laser/Inkjet 300 dpi device, US Letter → 2550×3300 px), and the PNG carries embedded DPI metadata matching the selected device.
328+
- US Letter at Laser/Inkjet (1200 dpi) is rejected with a friendly "too large" danger message rather than rendering.
325329
- When `ESB_BASE_URL` is unset, the "Generate QR Code" button renders as disabled with tooltip "ESB_BASE_URL not configured"; hitting the route directly redirects to the equipment detail page with a danger flash describing the validation failure.
326330
- Invalid `ESB_BASE_URL` values (non-http(s) schemes, embedded credentials, whitespace, paths/queries/fragments) are rejected with a specific flashed error instead of being rendered into the QR payload.
327331
- Archived equipment hides the QR button and the QR route returns 404.

‎docs/staff.md‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -142,14 +142,16 @@ Any logged-in user (Staff or Technician) can generate QR codes from the equipmen
142142
1. Open the equipment detail page for the item you want to label.
143143
2. Click **Generate QR Code**.
144144
3. Pick a size from the dropdown. Choices include square stickers (1", 1.5", 2", 3", 4"), Avery label sizes (5160 and 5163), and a US Letter full-page option.
145-
4. Optionally enable **Include equipment name above QR** to print the equipment's name at the top of the label.
146-
5. Optionally enable **Include URL below QR** to print the scan target URL at the bottom of the label.
147-
6. Watch the live preview update as you change options.
148-
7. Click **Download QR Code** to download a PNG file ready to print.
145+
4. Pick a **Printer / device** matching the printer you will print on: **Thermal Label (203 dpi)** for direct-thermal label printers, **Brother P-Touch (180 dpi)** for P-Touch label makers, or **Laser/Inkjet** at **300**, **600**, or **1200 dpi** for office printers. This setting makes the printed label come out at the correct physical size for the chosen printer. **Laser/Inkjet (300 dpi)** is the safe default for most office printers. (Very large sizes at very high resolution — e.g. US Letter at 1200 dpi — are rejected with a "too large" message; pick a lower resolution or smaller size.)
146+
5. Optionally choose a **WiFi Info** option to print a network reminder above the QR code: **None**, **Header** (a "Must be on WiFi" banner), **SSID** (banner + network name), or **Password** (banner + network name + password). Options only appear when an administrator has configured the corresponding WiFi settings.
147+
6. Optionally enable **Include equipment name above QR** to print the equipment's name at the top of the label.
148+
7. Optionally enable **Include URL below QR** to print the scan target URL at the bottom of the label.
149+
8. Watch the live preview update as you change options.
150+
9. Click **Download QR Code** to download a PNG file ready to print.
149151

150-
All output is rendered at 300 DPI so labels print sharply at any of the listed sizes.
152+
Output is rendered at the selected device's resolution, with that DPI embedded in the PNG, so printing "actual size" reproduces the intended physical dimensions on that printer.
151153

152-
> **Tip:** URL text below the QR is most useful at label/page sizes (Avery 5163, US Letter). On small stickers (≤ 2") the URL is usually truncated and adds little value — the live preview shows the result; uncheck **Include URL below QR** if it isn't legible.
154+
> **Tip:** URL text below the QR is most useful at label/page sizes (Avery 5163, US Letter). On small stickers (≤ 2") the URL is usually truncated and adds little value — the live preview shows the result; uncheck **Include URL below QR** if it isn't legible. On **low-resolution devices (180/203 dpi)**, the QR modules on tiny stickers (≤ 1") may be too small to scan reliably — prefer a larger sticker or a higher-resolution printer for tiny labels.
153155
154156
![QR Code Generation](images/qr-generation.png)
155157

‎esb/forms/equipment_forms.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from wtforms.validators import URL, DataRequired, Length, NumberRange, Optional
1616

1717
from esb.models.document import DOCUMENT_CATEGORIES
18-
from esb.services.qr_service import QR_SIZE_PRESETS
18+
from esb.services.qr_service import DEFAULT_DEVICE_KEY, QR_DEVICE_PRESETS, QR_SIZE_PRESETS
1919

2020
FORM_DOCUMENT_CATEGORIES = [('', '-- Select Category --')] + DOCUMENT_CATEGORIES
2121

@@ -136,6 +136,12 @@ class QRGenerateForm(FlaskForm):
136136
validators=[DataRequired()],
137137
default='sticker_2',
138138
)
139+
device = SelectField(
140+
'Printer / device',
141+
choices=[(p.key, p.label) for p in QR_DEVICE_PRESETS],
142+
validators=[DataRequired()],
143+
default=DEFAULT_DEVICE_KEY,
144+
)
139145
wifi_info = SelectField('WiFi Info', default='none')
140146
include_name = BooleanField('Include equipment name above QR', default=False)
141147
include_url = BooleanField('Include URL below QR', default=False)

‎esb/services/qr_service.py‎

Lines changed: 53 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
from flask import current_app
1414
from PIL import Image, ImageDraw, ImageFont
1515

16-
_DPI = 300
1716
_MIN_FONT_PT = 8
1817

1918

@@ -45,18 +44,41 @@ class QRSizePreset:
4544
QR_PRESETS_BY_KEY: dict[str, QRSizePreset] = {p.key: p for p in QR_SIZE_PRESETS}
4645

4746

48-
def _px(inches: float) -> int:
49-
return int(inches * _DPI + 0.5)
47+
@dataclass(frozen=True)
48+
class QRDevicePreset:
49+
key: str
50+
label: str
51+
dpi: int
52+
53+
54+
QR_DEVICE_PRESETS: tuple[QRDevicePreset, ...] = (
55+
QRDevicePreset('laser_300', 'Laser/Inkjet (300 dpi)', 300),
56+
QRDevicePreset('laser_600', 'Laser/Inkjet (600 dpi)', 600),
57+
QRDevicePreset('laser_1200', 'Laser/Inkjet (1200 dpi)', 1200),
58+
QRDevicePreset('thermal_203', 'Thermal Label (203 dpi)', 203),
59+
QRDevicePreset('ptouch_180', 'Brother P-Touch (180 dpi)', 180),
60+
)
61+
QR_DEVICES_BY_KEY: dict[str, QRDevicePreset] = {p.key: p for p in QR_DEVICE_PRESETS}
62+
DEFAULT_DEVICE_KEY = 'laser_300'
5063

64+
# High DPI × large physical size can produce enormous canvases (US Letter @ 1200 dpi
65+
# ≈ 134 MP). Cap total pixels to avoid OOM; this is a deliberate, adjustable default.
66+
MAX_CANVAS_PX = 50_000_000
5167

52-
def _pt_to_px(pt: int) -> int:
53-
return int(pt * _DPI / 72 + 0.5)
68+
69+
def _px(inches: float, dpi: int) -> int:
70+
return int(inches * dpi + 0.5)
71+
72+
73+
def _pt_to_px(pt: int, dpi: int) -> int:
74+
return int(pt * dpi / 72 + 0.5)
5475

5576

5677
def render_qr_png(
5778
equipment,
5879
preset: QRSizePreset,
5980
*,
81+
dpi: int = 300,
6082
include_name: bool = False,
6183
include_url: bool = False,
6284
base_url: str,
@@ -69,8 +91,16 @@ def render_qr_png(
6991
The service trusts `base_url` — it must be validated upstream via
7092
`esb.utils.text.get_normalized_base_url`.
7193
"""
72-
canvas_w_px = _px(preset.width_in)
73-
canvas_h_px = _px(preset.height_in)
94+
if dpi <= 0:
95+
raise ValueError(f'dpi must be positive, got {dpi}.')
96+
canvas_w_px = _px(preset.width_in, dpi)
97+
canvas_h_px = _px(preset.height_in, dpi)
98+
99+
if canvas_w_px * canvas_h_px > MAX_CANVAS_PX:
100+
raise ValueError(
101+
f'{preset.label} at {dpi} dpi is too large to render — '
102+
'choose a lower-resolution printer or a smaller size.'
103+
)
74104

75105
wifi_rows = _wifi_row_texts(wifi_info, wifi_ssid, wifi_password)
76106
num_wifi_rows = len(wifi_rows)
@@ -79,7 +109,7 @@ def render_qr_png(
79109
reserved_bottom = int(canvas_h_px * 0.15) if include_url else 0
80110

81111
if num_wifi_rows > 0:
82-
min_row_px = _pt_to_px(_MIN_FONT_PT) + 4
112+
min_row_px = _pt_to_px(_MIN_FONT_PT, dpi) + 4
83113
wifi_budget = int(canvas_h_px * 0.30)
84114
max_text_px = int(canvas_h_px * 0.55)
85115
effective_wifi_rows = num_wifi_rows
@@ -155,30 +185,30 @@ def render_qr_png(
155185
if row_info['type'] == 'header':
156186
_draw_wifi_header_row(
157187
canvas, row_top=row_top, row_height=wifi_row_height,
158-
max_width_px=wifi_max_text_width,
188+
max_width_px=wifi_max_text_width, dpi=dpi,
159189
)
160190
else:
161191
_draw_text_row(
162192
canvas, row_info['text'],
163193
row_top=row_top, row_height=wifi_row_height,
164-
max_width_px=wifi_max_text_width,
194+
max_width_px=wifi_max_text_width, dpi=dpi,
165195
)
166196

167197
if include_name:
168198
_draw_text_row(
169199
canvas, equipment.name,
170-
row_top=reserved_wifi, row_height=reserved_top, max_width_px=max_text_width,
200+
row_top=reserved_wifi, row_height=reserved_top, max_width_px=max_text_width, dpi=dpi,
171201
)
172202
if include_url:
173203
_draw_text_row(
174204
canvas, qr_url,
175205
row_top=canvas_h_px - reserved_bottom,
176206
row_height=reserved_bottom,
177-
max_width_px=max_text_width,
207+
max_width_px=max_text_width, dpi=dpi,
178208
)
179209

180210
buf = io.BytesIO()
181-
canvas.save(buf, format='PNG')
211+
canvas.save(buf, format='PNG', dpi=(dpi, dpi))
182212
return buf.getvalue()
183213

184214

@@ -200,7 +230,7 @@ def _wifi_row_texts(wifi_info, wifi_ssid, wifi_password):
200230
return rows
201231

202232

203-
def _draw_wifi_header_row(canvas, *, row_top, row_height, max_width_px):
233+
def _draw_wifi_header_row(canvas, *, row_top, row_height, max_width_px, dpi: int = 300):
204234
"""Draw the WiFi emoji + 'Must be on WiFi' text centered in a row."""
205235
text_font_path = os.path.join(current_app.static_folder, 'fonts', 'DejaVuSans-Bold.ttf') # noqa: PTH118
206236
emoji_font_path = os.path.join(current_app.static_folder, 'fonts', 'NotoEmoji-Bold.ttf') # noqa: PTH118
@@ -210,13 +240,13 @@ def _draw_wifi_header_row(canvas, *, row_top, row_height, max_width_px):
210240
'QR WiFi emoji font missing at %s — falling back to text-only header.',
211241
emoji_font_path,
212242
)
213-
_draw_text_row(canvas, label, row_top=row_top, row_height=row_height, max_width_px=max_width_px)
243+
_draw_text_row(canvas, label, row_top=row_top, row_height=row_height, max_width_px=max_width_px, dpi=dpi)
214244
return
215245
gap = max(4, row_height // 10)
216246

217247
text_font, rendered = _fit_text(
218248
label, max_width_px=int(max_width_px * 0.75),
219-
max_height_px=row_height, font_path=text_font_path,
249+
max_height_px=row_height, font_path=text_font_path, dpi=dpi,
220250
)
221251
if rendered == '':
222252
return
@@ -235,17 +265,17 @@ def _draw_wifi_header_row(canvas, *, row_top, row_height, max_width_px):
235265
text_h = t_bottom - t_top
236266

237267
if emoji_w + gap >= max_width_px:
238-
_draw_text_row(canvas, label, row_top=row_top, row_height=row_height, max_width_px=max_width_px)
268+
_draw_text_row(canvas, label, row_top=row_top, row_height=row_height, max_width_px=max_width_px, dpi=dpi)
239269
return
240270

241271
total_w = emoji_w + gap + text_w
242272
if total_w > max_width_px:
243273
text_font, rendered = _fit_text(
244274
label, max_width_px=max_width_px - emoji_w - gap,
245-
max_height_px=row_height, font_path=text_font_path,
275+
max_height_px=row_height, font_path=text_font_path, dpi=dpi,
246276
)
247277
if rendered == '':
248-
_draw_text_row(canvas, label, row_top=row_top, row_height=row_height, max_width_px=max_width_px)
278+
_draw_text_row(canvas, label, row_top=row_top, row_height=row_height, max_width_px=max_width_px, dpi=dpi)
249279
return
250280
t_left, t_top, t_right, t_bottom = draw.textbbox((0, 0), rendered, font=text_font)
251281
text_w = t_right - t_left
@@ -260,7 +290,7 @@ def _draw_wifi_header_row(canvas, *, row_top, row_height, max_width_px):
260290
draw.text((group_x + emoji_w + gap - t_left, text_y), rendered, fill='black', font=text_font)
261291

262292

263-
def _draw_text_row(canvas, text, *, row_top, row_height, max_width_px):
293+
def _draw_text_row(canvas, text, *, row_top, row_height, max_width_px, dpi: int = 300):
264294
"""Draw centered text in a horizontal row region of the canvas."""
265295
font_path = os.path.join(current_app.static_folder, 'fonts', 'DejaVuSans-Bold.ttf') # noqa: PTH118
266296
if not os.path.isfile(font_path):
@@ -273,6 +303,7 @@ def _draw_text_row(canvas, text, *, row_top, row_height, max_width_px):
273303
max_width_px=max_width_px,
274304
max_height_px=row_height,
275305
font_path=font_path,
306+
dpi=dpi,
276307
)
277308
if rendered == '':
278309
return
@@ -285,14 +316,14 @@ def _draw_text_row(canvas, text, *, row_top, row_height, max_width_px):
285316
draw.text((x, y), rendered, fill='black', font=font)
286317

287318

288-
def _fit_text(text, *, max_width_px, max_height_px, font_path):
319+
def _fit_text(text, *, max_width_px, max_height_px, font_path, dpi: int = 300):
289320
"""Return (font, rendered_text) fitting within max_width_px at ≤ max_height_px.
290321
291322
Shrinks font toward the 8 pt floor; if still too wide at the floor,
292323
truncates with an ellipsis (binary-search); if even ellipsis alone
293324
doesn't fit, returns the min-size font and an empty string.
294325
"""
295-
min_px = _pt_to_px(_MIN_FONT_PT)
326+
min_px = _pt_to_px(_MIN_FONT_PT, dpi)
296327
size_px = max_height_px
297328
step = max(1, int(max_height_px * 0.05))
298329
scratch = ImageDraw.Draw(Image.new('RGB', (1, 1)))

‎esb/static/js/app.js‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,14 +241,27 @@ document.addEventListener('DOMContentLoaded', function () {
241241
var form = document.getElementById('qr-form');
242242
if (!form) return;
243243
var img = document.getElementById('qr-preview');
244+
var errEl = document.getElementById('qr-preview-error');
244245
var base = form.getAttribute('data-preview-base');
245246
var timer = null;
247+
// Attach once so they persist across every debounced img.src reassignment.
248+
// A 400 (e.g. oversized canvas) degrades to a readable message, not a broken icon.
249+
img.onerror = function () {
250+
img.classList.add('d-none');
251+
if (errEl) errEl.classList.remove('d-none');
252+
};
253+
img.onload = function () {
254+
img.classList.remove('d-none');
255+
if (errEl) errEl.classList.add('d-none');
256+
};
246257
function update() {
247258
var size = form.querySelector('[name="size"]').value;
248259
var incName = form.querySelector('[name="include_name"]').checked ? '1' : '';
249260
var incUrl = form.querySelector('[name="include_url"]').checked ? '1' : '';
250261
var wifiInfo = form.querySelector('[name="wifi_info"]');
262+
var device = form.querySelector('[name="device"]');
251263
var params = new URLSearchParams({ size: size });
264+
if (device) params.set('device', device.value);
252265
if (wifiInfo) params.set('wifi_info', wifiInfo.value);
253266
if (incName) params.set('include_name', '1');
254267
if (incUrl) params.set('include_url', '1');

‎esb/templates/equipment/qr.html‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ <h1>Generate QR Code &mdash; {{ equipment.name }}</h1>
2323
<label for="{{ form.size.id }}" class="form-label">{{ form.size.label.text }}</label>
2424
{{ form.size(class="form-select") }}
2525
</div>
26+
<div class="mb-3">
27+
<label for="{{ form.device.id }}" class="form-label">{{ form.device.label.text }}</label>
28+
{{ form.device(class="form-select") }}
29+
</div>
2630
<div class="mb-3">
2731
<label for="{{ form.wifi_info.id }}" class="form-label">{{ form.wifi_info.label.text }}</label>
2832
{{ form.wifi_info(class="form-select") }}
@@ -43,10 +47,11 @@ <h1>Generate QR Code &mdash; {{ equipment.name }}</h1>
4347
<h5>Preview</h5>
4448
<img id="qr-preview"
4549
alt="QR preview"
46-
src="{{ url_for('equipment.qr_preview', id=equipment.id, size=form.size.data or 'sticker_2', include_name=form.include_name.data, include_url=form.include_url.data, wifi_info=form.wifi_info.data or 'none') }}"
50+
src="{{ url_for('equipment.qr_preview', id=equipment.id, size=form.size.data or 'sticker_2', device=form.device.data or default_device_key, include_name=form.include_name.data, include_url=form.include_url.data, wifi_info=form.wifi_info.data or 'none') }}"
4751
class="img-fluid border"
4852
style="max-width: 400px;">
49-
<p class="text-muted small mt-2">Preview is scaled to fit; downloaded PNG is at 300 DPI.</p>
53+
<span id="qr-preview-error" role="alert" aria-live="assertive" class="text-danger small d-none">Preview unavailable for this combination — the label may be too large to render at this resolution, or the URL too long for this size. Try a lower resolution or a larger size.</span>
54+
<p class="text-muted small mt-2">Preview is scaled to fit. The downloaded PNG is sized to your selected printer resolution and physical size.</p>
5055
</div>
5156
</div>
5257
{% endblock %}

0 commit comments

Comments
 (0)