Asset QR Labels Developer Guide - edmozley/freeitsm GitHub Wiki

QR Asset Labels โ€” Developer Guide

Two identifiers, one short URL, and a page whose primary device is a phone. Shipped as #935.

The analyst-facing page is QR asset labels.


1. ๐Ÿ“ The files involved

Colour key: ๐Ÿง  shared rule ยท ๐Ÿ”Œ API ยท ๐Ÿ–ฅ๏ธ page ยท ๐Ÿ—„๏ธ schema ยท ๐Ÿ”— routing ยท ๐ŸŒ i18n ยท ๐Ÿ“„ docs

๐ŸŽจ File What it does
๐Ÿง  includes/asset_labels.php The whole rule: schema gate, token mint/resolve, per-company tag availability, and the label URL
๐Ÿ–ฅ๏ธ asset-management/scan.php Where a scan lands. Mobile-first, with the two edits worth making while stood next to the asset
๐Ÿ–ฅ๏ธ asset-management/labels.php The print sheet (four label pitches) and the CSV export for a print house
๐Ÿ–ฅ๏ธ asset-management/assign-tags.php The reverse flow: scan the serial, scan the pre-printed tag, save, repeat
๐Ÿ–ฅ๏ธ asset-management/scanner.php The continuous camera scanner (#938) โ€” Look up / Stocktake, torch, undo, manual entry
๐Ÿ“ฆ assets/js/vendor/jsQR.js, assets/js/vendor/jsQR.LICENSE QR decoder (Apache-2.0), the iOS fallback โ€” loaded only when BarcodeDetector is missing
๐Ÿ”Œ api/assets/save_asset_tag.php Sets the tag, enforcing per-company uniqueness
๐Ÿ”Œ api/assets/find_asset.php Exact-match lookup by serial / hostname / tag, for the tagging loop and the scanner's non-label path
๐Ÿ”Œ api/assets/resolve_scan.php Token โ†’ asset (#938), same shape as find_asset.php so the scanner needn't care which answered
๐Ÿ”Œ api/assets/update_asset_field.php Reused unchanged by both scan.php and the scanner, so a phone edit takes the desktop code path
๐Ÿ”Œ api/assets/get_assets.php Returns asset_tag (behind the schema gate) so the list can show the chip
๐Ÿ–ฅ๏ธ asset-management/index.php The tag field, Print label, the Ctrl/Shift batch selection, and the Scan / Assign-tags links
๐Ÿ”— .htaccess One rewrite: /a/<token> โ†’ scan.php
๐Ÿ—„๏ธ database/freeitsm.sql, includes/db_verify_schema.php, includes/db_verify_indexes.php assets.asset_tag + assets.qr_token, one index each
๐ŸŒ lang/en/asset-management.php, lang/pt-BR/asset-management.php field.asset_tag*, list.*, detail.print_label
๐Ÿ“„ CHANGELOG.local.md, README.md, this wiki logged as #935, scanner as #938

Note what is not there for generating codes: no PDF library and no QR library โ€” assets/js/qrcode.min.js was already bundled for MFA enrolment, so labels are drawn client-side with nothing new added and nothing sent to a third-party QR service.

Reading codes is the one place a dependency was unavoidable, and only for iPhones. Chrome/Android has a native BarcodeDetector; iOS Safari does not, and writing a QR decoder is not a reasonable thing to hand-roll. jsQR (Apache-2.0) is vendored and lazy-loaded only on the fallback path, so Android never downloads it. It was taken from the npm tarball after checking the published SHA-1 and SHA-512, and audited for network/eval/storage APIs โ€” it has none, which matters for a library handed every camera frame.


1b. ๐Ÿ“ท Where the scanning side is documented

This page is about getting a label made: the two identifiers, per-company tag uniqueness, and printing.

Everything about a label being read โ€” the three scanning surfaces and why there are three, the public-base-URL problem that decides whether any code works at all, the resolve and write paths, the security rules, the two mobile strategies the module runs side by side, and how to test a camera without a phone โ€” is its own deep dive:

๐Ÿ“ท Asset scanning โ€” Developer Guide


2. ๐Ÿ—„๏ธ Two identifiers, and why

`asset_tag`  VARCHAR(64) NULL,   -- the human number on the label: "LT0001"
`qr_token`   VARCHAR(64) NULL,   -- what the QR encodes: opaque, install-wide unique
asset_tag qr_token
Read by people machines
Unique per company install-wide
Set by an analyst minted on first print
In the QR? no yes

The question that forces this design: what if two companies both use LT0001? If the QR encoded the tag, it couldn't resolve. If it encoded assets.id, โ€ฆ/a/4711 would invite somebody to try 4712. So it encodes neither.

The token isn't a secret โ€” the scan page requires a login and enforces company scope like any other asset read โ€” but there is no reason to hand out an enumerable index of the estate to anyone who photographs one label.

โš ๏ธ The trap: per-company uniqueness cannot be a unique index

The obvious schema is UNIQUE (tenant_id, asset_tag). It does not work here.

MySQL treats NULLs as distinct in a unique index, and tenant_id IS NULL means the Default company. So two Default-company assets could both be LT0001 while the index sat there looking like it was guarding them โ€” and a unique index that silently doesn't apply is worse than no index at all, because everyone downstream trusts it.

Uniqueness is therefore enforced in assetTagAvailable(), using the null-safe equality operator so the Default company behaves like a named one:

"SELECT COUNT(*) FROM assets WHERE tenant_id <=> ? AND asset_tag = ?"

This is not a new idea in this schema โ€” hostname is checked the same way, for the same reason, and freeitsm.sql already said so. The index that is there (idx_assets_tag) is for lookup only, and says so in a comment.

uq_assets_qr_token is unique, safely: the token is install-wide and never NULL once minted.


3. ๐Ÿ”— Why the URL is short

RewriteRule ^a/([A-Za-z0-9]+)$ asset-management/scan.php?token=$1 [L,QSA]

Every character in a QR is another module. These print at roughly 15mm square on the side of a laptop that then spends three years being knocked about, so a coarser, more forgiving code matters more than a tidy path. /a/<token> with a 20-character token keeps the code small enough to scan when scuffed.

The absolute URL comes from publicBaseUrl() (since 3.1.0, PR #164) โ€” the install's one answer to "how does the outside world reach this install?". It reads public_base_url and still falls back to messaging_public_base_url, which is what labels used before, so a label printed before the upgrade and one printed after encode the same address. A label is printed once and lives on a laptop for years, so deriving it from whichever hostname the printing analyst happened to be using would bake that in permanently. A configured address that already carries the app's folder is handled (publicUrlWithAppPath()), so โ€ฆ/freeitsm-app/freeitsm-app/a/<token> can never reach a sticker.

โœ… Once flagged here: labels used the messaging-only setting. Done in 3.1.0 โ€” see above.


4. ๐Ÿ›ก๏ธ The schema gates

Both columns are added by Database Verification, so anything that names them has to survive an install that has pulled the update and not yet run it. assetLabelsSchemaReady() (cached per request) guards:

Caller Degrades to
get_assets.php NULL AS asset_tag โ€” critical: naming the column unconditionally would turn the whole asset list into Unknown column
find_asset.php drops the tag from both the SELECT and the WHERE
save_asset_tag.php refuses with "run Database Verification"
scan.php a plain "labels aren't set up yet" page
labels.php the same, instead of a sheet of broken codes
scanner.php renders the explanation instead of the camera and its script, so nothing starts up with nothing to find
resolve_scan.php says "run Database Verification" rather than "not recognised" โ€” otherwise you go hunting for a bad label when the real answer is a missing column

Same lesson as the snooze columns in the ticket list โ€” see Snoozing tickets โ€” Developer Guide ยง5.


5. ๐Ÿ“ฑ scan.php is mobile-FIRST, not mobile-adapted

This is the one page in FreeITSM whose primary device is a phone: you are stood in a store room holding the laptop. So it is a purpose-built narrow surface with its own small stylesheet, not the desktop asset editor put through mobile.css.

That is a deliberate departure from the mobile-friendly rollout's wrap-don't-edit rule, and the reason is simple: that rule exists to protect a desktop layout, and this page has no desktop layout to protect. The rule still applies in full to the Assets module proper.

What it does share is the hard-won detail:

  • 46px touch targets, and 16px inputs โ€” under 16px, iOS zooms on focus, the page reflows to desktop width, and mobile rules stop matching (the trap documented in Mobile-Friendly: Techniques ยง3).
  • Pinch-zoom left enabled โ€” no user-scalable=no. Reading a battered serial in a dim store room is exactly when someone needs to zoom.
  • Status and location save on change, with no Save button: the failure mode of a forgotten Save on a phone is losing the edit entirely.

Those two writes go through the module's existing update_asset_field.php, so a phone edit gets the same validation, history entry and warranty-calendar sync as a desktop one. A phone edit is not a lesser edit.

The lists are fetched, not queried

scan.php does not query the status and location lists itself. They're scoped by two different rules โ€” locations are scoped data (activeTenantFilter), status types are a config list (globals + the company's own, minus its hidden ones via tenant_config_hidden) โ€” so the page calls the module's own endpoints, which already implement both. A second copy of either would drift.


6. ๐Ÿ–จ๏ธ The print sheet

@page plus millimetre units, because millimetres are the unit label stock is sold in and the unit the printer thinks in. No PDF dependency: the browser's print dialog gives the same result with a live preview.

Four pitches are offered (65/40/24/12 per A4, assetLabelSheetSpecs()). Each label is a flex row of QR + text, break-inside: avoid so one never splits across pages, with dashed cut guides that exist only on screen.

What goes on the label (3.1.0)

Since PR #164 (Sandy) the text is configurable under Assets โ†’ Settings โ†’ Asset labels & QR (Cap::ASSETS_LABELS, its own grant โ€” whoever designs a sticker isn't necessarily allowed to change numbering): which fields and in what order (ASSET_LABEL_STANDARD_FIELDS plus custom fields as cf_<key>), a header, a footer, and whether each value is printed with its name. The asset tag is always printed, first. Field names come from lang/en/asset-management.php โ€” labels.field.* for the picker, labels.field_short.* for the sticker, where space is tight.

The logo. A logo in the middle of the QR switches the code to error-correction level H (assetLabelQrEcLevel()), which survives about 30% of the code being covered; the logo is held to about 22% on a white backing. Print one at 65-per-sheet and scan it with an ordinary phone before trusting it. The logo upload reuses the branding rules (uploadStoreFile(), UPLOAD_TYPES_IMAGE, 2 MB, brandingPathIsSafe()), and the old file is deleted only after the new settings are saved.

๐Ÿ› A bug worth remembering: the sheet keys were numeric strings ('65', '40'โ€ฆ), which PHP silently casts to integer array keys. A === comparison against the string from $_GET then never matched, so every <option> rendered unselected โ€” the browser showed the first one while the grid below used the real choice. A dropdown disagreeing with the page. Compare (string)$k.

The CSV, and a bug that would have reached paper

There's no ITAM standard for handing labels to a print house, but there is a standard process โ€” variable data printing โ€” so the deliverable is one row per label with the QR payload as a literal URL, not a picture.

๐Ÿ› PHP 8.4 deprecates relying on fputcsv()'s default $escape. With display_errors on, that notice is written into the download โ€” an HTML fragment in the middle of a CSV that a print house would merge onto 500 labels. Pass it explicitly; '' also disables backslash escaping, which is what Excel and every CSV reader expects. api/intune/dashboard_drilldown.php had the identical bug and was fixed alongside.


6b. ๐Ÿท๏ธ Label designs and the exact-size PDF (#229, 4.8.0)

Once Database Verification has made asset_label_designs and asset_label_fonts, labels.php hands over to asset-management/includes/labels_designs_view.php after loading the assets, and the sheet above is only the fallback for an install that has not run it. Design: Asset label designer - design.

File What it does
includes/asset_label_designs.php The sizes (assetLabelStocks(), assetLabelStockFrom()), the four layouts (assetLabelTemplateItems()), designs (assetLabelDesignsFor(), assetLabelDesignCreate()), fonts (assetLabelFontsFor(), assetLabelFontRef(), assetLabelFontLooksValid()), and one asset's values (assetLabelValues())
assets/js/asset-labels/render.js LabelRender: loadFonts(), drawLabel() for the preview, pdf() for the download - the same code both ways
assets/js/asset-labels/designs-admin.js The Label designs and Label fonts sections of the Asset labels & QR tab
api/assets/label_designs.php List, create, update (versioned), set default, delete; upload and delete fonts. Reads need the Assets module; writes need Cap::ASSETS_LABELS
api/assets/label_font.php Serves an uploaded font to the browser, company-scoped. system/uploads/label-fonts is not web-servable
assets/fonts/noto-sans/ Noto Sans, four styles, with OFL.txt
tests/asset-label-designs.php Sizes, layouts inside the label on every size, font checks, what the designer may save; with FREEITSM_TEST_DATABASE, designs and defaults

A design is a stock and a layout. stock_json is a tape (kind: tape, w, h - one label per page) or a sheet (page, cols, rows, left, top, h_pitch, v_pitch, and the printer offset_x / offset_y, held to ยฑ10 mm). design_json is {v, template, font}. In this slice a design's items are worked out at print time from its template, its size and the company's label settings, so changing the header or fields changes every design, as it always changed the label. A design saved in the designer (ยง6c) is {v, template: 'custom', font, items} and keeps its own items; assetLabelItemsFor() is the one place that tells the two apart - use it, never assetLabelTemplateItems() directly, wherever a design is drawn.

Items are in millimetres, font sizes in points: text ({field} tokens are filled in), field, fields (a list, the tag first and larger, empty values skipped - today's label), qr (logo inside or not), image (src: 'logo'), line, box. Text-like items carry size, min, bold, align, valign and fit (shrink | wrap | clip). Every item may have rot 0 / 90 / 180 / 270.

Fitting long values. The height is shared - every line of an item shrinks together until they fit the box's height - but the width is each line's own: a long value shrinks only its own line, down to min, then is cut with "...". So one long model name does not make one label's text smaller than its neighbours'. Nothing is drawn outside an item's box.

๐Ÿ”‘ Text is drawn by the browser. pdf() draws every text item on a canvas at 600 dpi (renderToCanvas()) and places it as a PNG; the QR is vector rectangles (one per run of dark squares), lines and boxes are vector, drawn through FreeITSM's shared PDF writer (assets/js/pdf-writer.js, on PDFKit since #254). This stays a picture on purpose, even though the writer's own text now shapes Indic and Arabic scripts: a label may use an uploaded font or a system font the writer does not have, and drawing it in the browser keeps the preview and the print the same. The browser uses Noto Sans, any uploaded font, and the system's fonts behind them (familyFor() gives the list, and the browser falls back character by character). Text with no {field} in it is the same on every label, so it is drawn once and reused through the writer's image alias.

TRAP: the sheet layout comes from the stock's published positions, not from today's CSS grid. Today's sheet started at the browser's own page margin; the PDF puts each label where Avery's figures say it is. Test on plain paper; the offset is for the last millimetre.

TRAP: a font is checked by its first bytes (00 01 00 00 / true for TTF, OTTO for OTF, wOFF, wOF2), never by fileinfo, which names fonts differently on different servers and does not know WOFF2 on older ones. A .ttc collection is refused: a browser cannot load one.

How it was checked. The PDF was captured from the real page in headless Chrome (by replacing jsPDF's save() - at the time; the writer's save() is now async), and checked for page count and page size (six 73 x 24 mm pages for a 24 mm tape; A4 for a sheet). Its pages were redrawn from the PDF's own drawing instructions, and the QR on the page was decoded with the bundled jsQR to the asset's own address. A label with an Arabic, Chinese and Hindi header was previewed to show the shaping.

6c. ๐ŸŽจ The label designer (#229 slice 2, 4.8.0)

asset-management/label-designer.php?id=<design> is a thin shell: it checks Cap::ASSETS_LABELS and that the design is the active company's (otherwise back to the settings tab), and hands everything over as window.LD - the design with its items already worked out by assetLabelItemsFor() (so a template design opens looking as it prints), the sizes, the fields offered (assetLabelAvailableFields()), the captions, the fonts, the logo, and up to 40 real assets' values plus a made-up one with long values. The QR in the designer shows a sample address: opening the designer must never mint a code for an asset, so it reads values only and never calls the token code.

File What it does
asset-management/label-designer.php The shell and window.LD
assets/js/asset-labels/designer.js The whole designer: frame, ribbon, toolbox, stage, selection, snapping, properties, undo, save
assets/css/label-designer.css The Office-style frame, the Report Pack designer's look; the label itself stays white in dark mode
api/assets/label_designs.php save_items {id, version, name, stock, font, items} -> assetLabelSanitiseItems() -> design_json {v, template: 'custom', font, items}; 409 on a stale version; returns the new version and the cleaned items

Drawing. The label is a <canvas> drawn by LabelRender.drawLabel() - the print code - at zoom x devicePixelRatio, with the grid under it; rulers are two more canvases. Selection boxes, the eight handles, the hover outline, the pink guides and the marquee are an HTML layer (.ld-overlay) over it, rebuilt on each draw. Everything is in millimetres; zoom is pixels per millimetre (100% = 96 / 25.4).

State has one way in: commit(fn, mergeKey). It pushes a JSON snapshot onto the undo stack, runs fn, marks the design unsaved and schedules one redraw per frame. Changes with the same mergeKey within 800 ms (typing, nudging, a size spinner) merge into one undo step. A drag snapshots once when it starts moving and then changes the items directly. Commands live in one table C (run, on, pressed) that the ribbon, the keyboard and the right-click menu share, so a button's enabled and pressed state can never disagree with its shortcut.

Snapping. While moving, the selection's left / centre / right (and top / middle / bottom) are compared with the label's edges and centre and every other item's edges and centres; the nearest within 6 screen pixels wins and draws a guide, otherwise the position goes to the 0.5 mm grid. Resizing snaps only the edges being dragged. Alt turns snapping off. A QR keeps itself square; Shift on a corner keeps proportions.

Copy and paste go through localStorage (freeitsm.labelClipboard), so items paste into another design; where storage is blocked (some private windows) a copy kept in the page still lets copy, paste and Duplicate work within the one design.

Saving cleans everything. assetLabelSanitiseItems() keeps only the seven item types and the properties each understands, holds every number to a range around the label, falls back on any choice outside its list (alignment, fit, caption, rotation, a colour that is not #rrggbb), keeps only fields the company offers, replaces odd or repeated ids, makes every picture the label-settings logo (never an address), and keeps at most 100 items. What the designer gets back is what was saved.

TRAP: a line only runs right and down. A line goes from (x, y) to (x + w, y + h) with w and h >= 0 (either may be 0 - level or upright). The designer clamps it while dragging and the sanitiser keeps it so; hit-testing and the selection box assume it. A line going up to the right would need a direction flag first.

TRAP: under headless Chrome's virtual time, requestAnimationFrame barely fires, so a harness that drives the designer sees every change one step late. Replace it in the frame first: w.requestAnimationFrame = cb => w.setTimeout(cb, 0).

How it was checked. tests/asset-label-designs.php part 1b: what the sanitiser keeps and drops, and that a designed label prints its own items while a template design still follows the settings. In a browser, a harness page drove the real designer on a test install with pointer and key events: click to select, a 5 mm drag, undo and redo, a QR resize staying square, Insert, typing a {field}, nudges merging into one undo step, Bold, Delete, Ctrl+A, the marquee, the right-click menu, a level line, Save (the banner goes, the design comes back as custom with its items through the real API), and a stale save refused with 409. The print page and the settings previews were then checked to draw the saved items.

6d. ๐Ÿ–จ๏ธ Readable captions and the black-only printer setting (#229, 4.9.0)

Changelog #2717 and #2718. The first real print of a 4.8.0 design, on a Brother tape printer, came out with a sharp QR code and readable values - and field captions that were a row of dots, and a logo that was speckles. Two changes came out of it: one to every label, one per design.

Why grey turns into dots

A tape or roll label printer is thermal: each dot on the print head is either heated or not, so it prints black or nothing. It has no grey. When the page it is sent contains grey, the printer driver fakes it by dithering - a grey area becomes a scattered pattern of black dots whose density stands for the shade. On a photo at A4 that reads as grey. On a caption 4 pt tall, a few dots high, the pattern is coarser than the letters, so the letters dissolve. The same happens to anything anti-aliased or tinted in a logo.

Before 4.9.0 a caption was drawn at 55% of its value's size in #444444: small and grey, the worst case on both counts.

1. Captions: the item's own colour, 70%, never below 5 pt

linesFor() in assets/js/asset-labels/render.js turns a field item into the lines it draws. Every line starts from base, which carries the item's own colour:

const base = { size: it.size || 8, bold: !!it.bold, italic: !!it.italic, color: it.color || '#000000' };

and a caption above its value is now built from base with only the text, size and weight changed - no colour of its own:

// TRAP: the caption is in the item's own colour and never below 5 pt (or the value's
// size, if that is smaller). Grey at 55% printed as dots on a tape printer (GH #229).
if (it.caption === 'above' && cap) return [Object.assign({}, base, { text: cap, size: Math.max(size * 0.7, Math.min(size, 5)), bold: false }),
                                           Object.assign({}, base, { text: val, size, bold })];

The size rule reads in two halves. size * 0.7 is the normal answer. Math.min(size, 5) is a floor of 5 pt, except that a floor bigger than the value itself would print the caption larger than what it captions, so the floor is capped at the value's size. Worked through:

Value size size * 0.7 floor min(size, 5) Caption
10 pt 7 5 7 pt
6 pt 4.2 5 5 pt
4 pt 2.8 4 4 pt (same as the value)

This applies to every label, mono or not, in the preview and the PDF, because both draw text through the same drawText(). A larger caption takes more of the item's height, and the shared-height fit described in ยง6b shrinks the whole item to suit - so a design laid out tightly before 4.9.0 may print its values slightly smaller now. Nothing is drawn outside the box.

Inline captions (Serial: ABC123) were never grey - they are part of the value's own line - and are unchanged. The no tag placeholder keeps its #888888: it is a "this asset has no tag yet" warning on screen, and on a mono design it prints black anyway (below).

2. mono: one flag, three kinds of ink

A design's design_json can carry mono: true | false. With it on, every text line, line and box is drawn in solid black whatever colour the designer gave it, and the logo is cut to pure black and white.

Where the default comes from. A design made before 4.9.0 has no mono key at all, and nobody should have to edit every tape design to fix their labels. So unset follows the size the design is printed on: tape and roll yes, A4 sheets no. The PHP side is assetLabelMono() in includes/asset_label_designs.php:

function assetLabelMono(array $design, array $stock): bool
{
    $m = $design['design']['mono'] ?? null;
    return is_bool($m) ? $m : ($stock['kind'] ?? 'sheet') !== 'sheet';
}

and the browser has a copy, monoFor() in render.js, used by the settings tab's previews:

function monoFor(design, stock) {
    return typeof (design || {}).mono === 'boolean' ? design.mono : (stock || {}).kind !== 'sheet';
}

"Tape or roll" is simply "not a sheet". The stock list builds every Brother TZe / DK and Dymo size with 'kind' => 'tape', and a size of your own is a tape too - assetLabelStockFrom() makes it ['kind' => 'tape', 'name' => '', 'w' => $w, 'h' => $h] - so a custom size is treated as a roll and gets mono by default. Only the four A4 sheets are 'kind' => 'sheet'.

TRAP: only a real boolean counts as a setting. is_bool() / typeof === 'boolean', never truthiness. '1', 1 or null in design_json all mean "unset, follow the size". The test pins this, because a loose check would turn a stray string into a forced setting nobody chose.

Where the setting is saved. The tick box is Black-only printer (#ldMono) in the design's Edit dialog on the Asset labels & QR tab. assets/js/asset-labels/designs-admin.js only sends it once somebody has touched it, so an untouched design keeps following its size:

// Sent only once somebody has ticked or unticked it, so an untouched design keeps following its size.
const monoEl = document.getElementById('ldMono');
const mono = monoEl.dataset.touched ? { mono: monoEl.checked } : {};

data-touched is set by the box's change listener, and set on opening a design that already has a boolean. Until then, changing the size in the dialog moves the tick with it (stockChanged(): if (!mono.dataset.touched) mono.checked = !st || st.kind !== 'sheet';), so the box always shows what would print.

On the server, api/assets/label_designs.php writes the key only when it was sent - create passes it to assetLabelDesignCreate() through $options, which does if (isset($options['mono'])) $design['mono'] = (bool)$options['mono'];, and update does if (isset($in['mono'])) $design['mono'] = !empty($in['mono']);. The designer's save_items rebuilds design_json from scratch, so it copies the old value across rather than dropping it:

// The black-only setting is made on the settings screen, not here: keep it.
$was = json_decode((string)$row['design_json'], true) ?: [];
if (isset($was['mono'])) $design['mono'] = (bool)$was['mono'];

TRAP: any new code path that rebuilds design_json must carry mono over the same way, or saving in that path silently returns a design to "follow the size".

3. One rule, worked out once, used by the preview and the PDF

The print page does not ask the browser to decide. asset-management/includes/labels_designs_view.php works it out in PHP against the stock actually being printed (the page lets you pick another size for a template design) and puts it in the job:

    'mono'     => assetLabelMono($design, $stock),

That one JOB.mono goes to both halves of the page: the on-screen preview (LabelRender.drawLabel(g, { ..., logo, mono: JOB.mono }, px)) and the download (LabelRender.pdf(Object.assign({}, JOB, { onProgress: ... }))). The designer gets it the same way, in window.LD.design.mono from asset-management/label-designer.php ('mono' => assetLabelMono($design, $design['stock'])). The settings tab, which draws many designs on one page, calls window.LabelRender.monoFor(d.design, d.stock) per design.

Inside render.js the flag is honoured at each place ink is chosen:

What Preview (drawLabel() / drawText()) PDF (pdf())
Text g.fillStyle = ctx.mono ? '#000000' : (l.color || '#000000'); the same drawText(), through renderToCanvas(), with ctx.mono set from !!job.mono
Lines and boxes const ink = ctx.mono ? '#000000' : (it.color || '#000000'); const c = job.mono ? [0, 0, 0] : hexRgb(it.color);
Logo (picture item, and inside the QR) if (ctx.mono && ctx.logo) ctx = Object.assign({}, ctx, { logo: monoImage(ctx.logo) }); const logo = job.mono ? monoImage(job.logo) : job.logo;

Because PDF text is drawn by the same canvas function as the preview (ยง6b), there is no second text renderer to forget. The QR itself needed nothing: it was always solid black rectangles.

monoImage(): the logo cut to black and white

const monoCache = new WeakMap();
function monoImage(img) {
    if (!img) return null;
    if (monoCache.has(img)) return monoCache.get(img);
    const nw = img.naturalWidth || img.width, nh = img.naturalHeight || img.height;
    if (!nw || !nh) return img;
    const r = 1200 / Math.max(nw, nh);
    const cv = document.createElement('canvas');
    cv.width = Math.max(1, Math.round(nw * r)); cv.height = Math.max(1, Math.round(nh * r));
    const g = cv.getContext('2d');
    g.drawImage(img, 0, 0, cv.width, cv.height);
    let out = cv;
    try {
        const d = g.getImageData(0, 0, cv.width, cv.height), p = d.data;
        for (let i = 0; i < p.length; i += 4) {
            const a = p[i + 3] / 255;
            const lum = (0.299 * p[i] + 0.587 * p[i + 1] + 0.114 * p[i + 2]) * a + 255 * (1 - a);
            p[i] = p[i + 1] = p[i + 2] = 0;
            p[i + 3] = lum < 160 ? 255 : 0;
        }
        g.putImageData(d, 0, 0);
        cv.naturalWidth = cv.width; cv.naturalHeight = cv.height;
    } catch (e) {
        out = img;   // a logo from another site cannot be read back: print it as it is
    }
    monoCache.set(img, out);
    return out;
}

Line by line:

  • Enlarge first, to 1200 px on the long side. Thresholding a small logo gives jagged steps; drawing it big first lets the browser's smoothing put the edge in the right place before the cut. 1200 px matches what pdf() asks of imageData(logo, 1200) anyway.
  • Luminance laid on white. 0.299 R + 0.587 G + 0.114 B is the usual perceived brightness. A transparent pixel is blended with white (* a + 255 * (1 - a)), because the label is white - so a half-transparent dark edge counts as mid grey, not as black.
  • The cut at 160 / 255 (about 60% grey): darker becomes opaque black, lighter becomes transparent, not white. Transparent keeps the logo's shape against whatever is under it - inside the QR that is the white backing square pdf() draws first.
  • naturalWidth / naturalHeight are set on the canvas by hand. containRect() and imageData() read those to keep proportions; a canvas does not have them, and without them the logo would be sized from undefined.
  • The catch. getImageData() throws on a tainted canvas - a logo served from another origin without CORS. The logo is then printed as it is rather than not at all. The label settings logo is uploaded into the install and served from it, so in practice this is the fallback for a case that should not occur.
  • The WeakMap. The preview redraws on every change, and the print page draws one canvas per asset; the conversion runs once per Image and is dropped with it.

What was considered and left out

Removing the soft (anti-aliased) edges from the 600 dpi text was considered and dropped: the PDF is resampled before the printer sees it (a browser's PDF viewer prints at its own resolution, then the driver resamples again), so grey edges come back whatever the PDF holds. The design note is Asset label designer - design, "Black-only printers".

Tests

tests/asset-label-designs.php, PART 1c - black-only printers (no database needed):

ok('a design with no setting prints black-only on tape', assetLabelMono(['design' => ['template' => 'standard']], $tz) === true);
ok('...and on a size of your own (a roll)', assetLabelMono(['design' => []], $own) === true);
ok('control: ...but not on an A4 sheet', assetLabelMono(['design' => ['template' => 'standard']], $a4) === false);
ok('a setting wins over the size, both ways', assetLabelMono(['design' => ['mono' => false]], $tz) === false && assetLabelMono(['design' => ['mono' => true]], $a4) === true);
ok('anything but true or false is treated as unset', assetLabelMono(['design' => ['mono' => '1']], $a4) === false);

and in PART 2, which runs only with FREEITSM_TEST_DATABASE set to a throwaway database, two round trips through assetLabelDesignCreate() and assetLabelDesignDecode(): a design made with mono => false stores false and prints colour on a tape; a design made with nothing stores no key at all and follows the size.

php tests/asset-label-designs.php
FREEITSM_TEST_DATABASE=<a throwaway database> php tests/asset-label-designs.php

Not covered by a test: the caption size and colour and monoImage() are browser code, and there is no automated test of render.js. Check them by eye: a field with caption above at 6 pt should show a 5 pt caption in the field's colour; a coloured line and a colour logo on a tape design should preview black and pure black and white, and come out the same in the downloaded PDF; untick Black-only printer and both should return to colour.


7. ๐Ÿ” assign-tags.php โ€” the reverse flow

Printing assumes FreeITSM picks the numbers. Companies that buy pre-printed sequential tags need the opposite: the sticker exists, and the database must agree with it.

The loop is built around scanning what is already on the machine โ€” the manufacturer's serial barcode โ€” because hunting a list for "which laptop is this?" is the slow part of a tagging day. A barcode scanner is a keyboard that ends with Enter, so keydown on Enter is the entire interaction model; no driver, no pairing, no app.

Three deliberate behaviours:

  • find_asset.php matches exactly, never fuzzily. The one genuinely bad outcome is a tag on the wrong machine. Ranking (tag โ†’ serial โ†’ hostname) is done in PHP over at most five rows, not in SQL โ€” ordering placeholders would have to be spliced around the tenant fragment's, and positional binding is strictly left-to-right, which is exactly how the wrong value gets bound six months later.
  • The matched asset is shown before the tag field, so a mis-scan is caught before it matters.
  • A duplicate tag keeps you on the asset rather than resetting: the tag is in your hand and needs a decision.

8. ๐Ÿ–ฑ๏ธ Batch selection, and two bugs it taught us

Picking several assets mirrors the ticket inbox (#910) rather than inventing an idiom: a plain click still opens the asset, and Ctrl/Shift build a selection on top. No checkboxes, no "selection mode" toggle โ€” both change the list for everyone to serve an occasional job.

๐Ÿ› The open asset must be IN the selection. First cut cleared the selection on a plain click, so opening one asset and Ctrl-clicking a second gave you one, not two. Explorer and Outlook treat the highlighted row as selected. The fix seeds the selection inside selectAsset() โ€” not the click handler โ€” so arriving by deep link behaves the same, and Clear returns to just the open asset rather than to nothing.

๐Ÿ› Shift-click smeared the browser's text selection across the list, and Edge popped its selection mini-menu. One cause: text selection begins on mousedown, so a user-select: none class applied in the click handler is always a beat too late for the first shift-click. preventDefault() on a shift-mousedown inside the list is what actually prevents it โ€” and the mini-menu goes with it, because that popup is triggered by having text selected. The ticket inbox had the identical latent bug (#emailList.multi-selecting was doing the too-late thing) and was fixed the same way.


9. โœ… How this was verified

  1. Tokens: minted once and stable on re-call, resolving back to the right asset; unknown and malformed tokens both resolve to null.
  2. Per-company tag uniqueness including the Default company โ€” the case the unique index could not have caught: same tag refused within a company, allowed in another, and allowed on the asset that already holds it.
  3. Company scope with positive controls โ€” a restricted analyst printing their own company's asset (control) and getting nothing for another's; the same token scanned by a restricted analyst ("not recognised") and by an all-company analyst (the asset), so the block is demonstrably the scope and not a broken token.
  4. The tagging loop driven as a scanner would โ€” type, Enter, type, Enter โ€” asserting the match, the save, the counter, the reset and focus, a duplicate keeping its place, and an unknown scan.
  5. The selection, driven with real modifier-key events: plain click leaves one selected and the bar hidden; Ctrl-click gives two; Shift spans the block; Clear returns to the open asset. Plus defaultPrevented asserted true for a shift-mousedown inside the list and false both for a plain mousedown and for a shift-mousedown outside it.
  6. The CSV re-checked for HTML leakage after the fputcsv fix.
  7. Pre-upgrade behaviour, and D005 confirming both new endpoints land on Module access: assets.

The scanner (#938) was verified against a fake camera, which turns out to be entirely practical:

  1. A QR round-trip: a real label URL rendered with the bundled generator and decoded back with the bundled decoder, asserting the text survives and the token regex extracts it โ€” plus a manufacturer serial (ABC1234) correctly yielding no token, so it falls through to find_asset.php. A blank white image as the negative control decodes to null.
  2. The whole loop, end to end. The QR module matrix was written into a Y4M video and handed to Chrome via --use-fake-device-for-media-stream --use-file-for-fake-video-capture=โ€ฆ. The page then genuinely decoded from a live video track and resolved the asset: LT0001 ยท LT-001 ยท Laptop ยท Active, zero JS errors, with jsQR confirmed lazily loaded because that Chrome has no BarcodeDetector โ€” i.e. the iPhone path is the one that was exercised.
  3. The duplicate-count bug this caught. Nine seconds resting on one label first reported "4 scanned". That is a 300-asset stocktake reporting 500. Fixed with the per-session id set; re-run reported 1 scanned, 1 line.
  4. Cross-company isolation, with a positive control โ€” and the install is single-company, so the honest test had to build the two-company case inside a transaction and roll it back: the same analyst was refused the asset when not a member of its company and allowed it when made one, with the token still resolving either way, proving the block is the access check and not a failed lookup.

โš ๏ธ Two headless traps, both hit here. --use-file-for-fake-video-capture silently does nothing without --use-fake-device-for-media-stream alongside it โ€” you get the default rolling pattern and a test that decodes nothing. And --virtual-time-budget races page timers ahead of wall-clock, so real camera I/O never completes and the probe reports videoWidth=0; hold the load event open with a deliberately slow resource instead, so real time passes. (The virtual-time trap is the same one recorded in Collision detection.)

โš ๏ธ Screenshot gotcha, rediscovered the hard way: on Windows, --window-size=360 does not give a 360px layout โ€” the OS clamps the window's minimum width, so the screenshot is cropped, which looks exactly like content overflowing the viewport. It nearly cost a "fix" for a non-bug. Render inside a 360px iframe in a larger window, and measure documentElement.scrollWidth against clientWidth before believing any narrow screenshot. Already recorded in Mobile-Friendly: Techniques ยง8.


10. Extending it

  • Auto-numbering โ€” built in 3.1.0: Asset tag numbering โ€” Developer Guide.
  • Print from the table view โ€” labels.php takes ?ids=, so it's a selection UI on table.php, nothing more.
  • Custom label sizes โ€” built in 4.8.0 as label designs: ยง6b.
  • Portal scanning (a requester scanning a label to report a fault) โ€” would need a decision about what a non-analyst may see, and is a genuinely different feature.

See also

โš ๏ธ **GitHub.com Fallback** โš ๏ธ