import {loadAnyDCode} from "../vendor/anyd-qr.js"; import {encodeHandleForUrl} from "@/router" // Mirrors loadAnyDCode()'s memoized wasm instance for synchronous use in buildRenderTree. See docs/implementation.md#qr-encoder-loading. let anyd = null; export function preloadQrEncoder() { return loadAnyDCode().then(instance => { anyd = instance; }); } // Maps label-layouts.js leaf types to anyd-qr.js symbology/ecc/size options, via a naming // convention (plain id = anyd defaults; "-" suffix names an ecc letter or rMQR size // strategy). See docs/implementation.md#qr-leaf-type-mapping. const QR_LEAF_TYPES = { "qr-l": {codeType: "qr", ecc: "L"}, qr: {codeType: "qr", ecc: "M"}, "qr-q": {codeType: "qr", ecc: "Q"}, "qr-h": {codeType: "qr", ecc: "H"}, "mqr-d": {codeType: "micro-qr", ecc: "Detection"}, "mqr-l": {codeType: "micro-qr", ecc: "L"}, mqr: {codeType: "micro-qr", ecc: "M"}, "mqr-q": {codeType: "micro-qr", ecc: "Q"}, rmqr: {codeType: "rmqr", ecc: "M"}, "rmqr-min": {codeType: "rmqr", ecc: "M", size: "min"}, "rmqr-max": {codeType: "rmqr", ecc: "M", size: "max"}, "rmqr-h": {codeType: "rmqr", ecc: "H"}, "rmqr-h-min": {codeType: "rmqr", ecc: "H", size: "min"}, "rmqr-h-max": {codeType: "rmqr", ecc: "H", size: "max"}, }; function isQrLeaf(node) { return node.type in QR_LEAF_TYPES; } function encodeQr(text, codeType, options) { if (!anyd) { const err = new Error("The QR encoder is still loading — try again in a moment."); err.short = "loading…"; throw err; } // BitMatrix-alike shim over anyd's row-major matrix, matching the old "qrcode" package's // modules.size/get() shape that drawQrLeaf/snapQrToCrispSize expect. See // docs/implementation.md#qr-module-matrix-shim. try { const {width, height, modules} = anyd.encode(codeType, new TextEncoder().encode(text), options).matrix; return {width, height, get: (row, col) => modules[row * width + col] !== 0}; } catch (e) { // anyd's own errors (e.g. "capacity exceeded: …") have no `short` of their own - every // failure here comes down to the content not fitting the chosen QR variant's capacity. if (e && typeof e === "object" && !("short" in e)) { e.short = "too long"; } throw e; } } const TRAILING_PADDING_PX = 3; /* blank columns after the cut, same idea as the leading margin */ /* Sizing a label needs numbers only the driver can supply. */ export function tapeFromStatus(status) { const printAreaPx = status?.tape?.printAreaPx; const dpi = status?.printer?.dpi; if (!(printAreaPx > 0) || !(dpi > 0)) { return null; } return { printAreaPx, dpi, mediaWidthMm: status.tape.mediaWidthMm, printLengthPx: status.tape.printLengthPx > 0 ? status.tape.printLengthPx : 0, /* Brother's documented margin for the mounted tape, in raster columns. */ leadPx: status.tape.marginsMm ? Math.round(status.tape.marginsMm * dpi / 25.4) : TRAILING_PADDING_PX, }; } // A layout tree alternates row/column split nodes by nesting depth, with QR/text/empty leaves. // See docs/implementation.md#layout-tree-structure. const TEXT_REFERENCE_PX = 100; /* font size text leaves measure their natural aspect ratio at */ // Below 10px, a general-purpose sans-serif gets illegible, so drawTextLeaf switches to a bitmap // font (Tom Thumb/Silkscreen) instead; see the empirical rationale (font choice, `scale`, and why // sizes aren't dpi-adjusted) at docs/implementation.md#pixel-font-selection. const PIXEL_FONT_TIERS = [ {belowPx: 8, family: "Tom Thumb", scale: 3.2}, {belowPx: 10, family: "Silkscreen"}, ]; function fontFamilyFor(fontPx) { return PIXEL_FONT_TIERS.find(t => fontPx < t.belowPx) ?? {family: "sans-serif"}; } // Below this font size (px) or physical height (mm) - Tom Thumb's real-Chromium-tested legibility // floor - text is a hard failure, the same two-metric shape as MIN_RECOMMENDED_QR_PX_PER_MODULE/ // MIN_RECOMMENDED_QR_MODULE_MM below (see checkTextSizes). No longer just silently blanked. const MIN_TEXT_PX = 5; const MIN_TEXT_MM = 0.5; // Below this, still legible but a soft warning - flagged rather than blocking, same idea as // MIN_RECOMMENDED_QR_PX_PER_MODULE/MIN_RECOMMENDED_QR_MODULE_MM. const MIN_RECOMMENDED_TEXT_PX = 8; const MIN_RECOMMENDED_TEXT_MM = 1; // Below this many pixels per module, a QR-family code still technically fits (see // snapQrToCrispSize's scale >= 1 hard requirement) but risks blurring together on a real thermal // printer's dot pitch - a soft warning rather than the outright rejection scale < 1 gets. const MIN_RECOMMENDED_QR_PX_PER_MODULE = 3; // Below this per-module physical size (in mm), a QR-family code is a rule-of-thumb risk for a // phone camera to resolve at normal scanning distance even when crisply printed at full // resolution - also a soft warning, independent of the pixels-per-module check above (a printer // can hit that check's px/module floor at any dpi, but only a high enough dpi keeps modules this // physically small still legible). const MIN_RECOMMENDED_QR_MODULE_MM = 0.5; function isSplit(node) { return Array.isArray(node); } function parseMm(value, key) { const match = typeof value === "string" && /^([\d.]+)mm$/.exec(value); if (!match) { throw new Error(`An "empty" layout node needs a "${key}" like "2mm", got ${JSON.stringify(value)}.`); } return parseFloat(match[1]); } // Every node's width/height relate affinely (width = A*height + B, or symmetrically); `ownAxis` // and `wantWidth` pick which direction and against which split axis. See // docs/implementation.md#affine-width-height-relations. function relation(node, ownAxis, wantWidth, pxPerMm) { if (!isSplit(node)) { if (isQrLeaf(node) && node.crispWidth !== undefined) { return {a: 0, b: wantWidth ? node.crispWidth : node.crispHeight}; } if (isQrLeaf(node) || node.type === "text") { const aspect = node.aspect; return wantWidth ? {a: aspect, b: 0} : {a: 1 / aspect, b: 0}; } const key = ownAxis ? "min-width" : "min-height"; return {a: 0, b: parseMm(node[key], key) * pxPerMm}; } const axis = !ownAxis; // this split's own row(true)/column(false) axis const combinesAsWidth = axis; // a row sums widths for a shared height const parts = node.map(child => relation(child, axis, combinesAsWidth, pxPerMm)); const a = parts.reduce((sum, p) => sum + p.a, 0); const b = parts.reduce((sum, p) => sum + p.b, 0); if (combinesAsWidth === wantWidth) { return {a, b}; } if (a === 0) { // Every child is fixed-size (a === 0) in the combining direction, so inverting would // divide by zero. See docs/implementation.md#fixed-size-relation-edge-case. const otherParts = node.map(child => relation(child, axis, wantWidth, pxPerMm)); return {a: 0, b: Math.max(...otherParts.map(p => p.b))}; } return {a: 1 / a, b: -b / a}; // invert: solve the affine relation the other way } // Top-down: assigns the fixed (width, height) box `node` must exactly fill, recursively, to // every descendant; `ownAxis` carries the same meaning as in relation() above. function layoutTree(node, ownAxis, width, height, pxPerMm) { node.box = {width, height}; if (!isSplit(node)) { return; } const axis = !ownAxis; for (const child of node) { if (axis) { const {a, b} = relation(child, axis, true, pxPerMm); layoutTree(child, axis, a * height + b, height, pxPerMm); } else { const {a, b} = relation(child, axis, false, pxPerMm); layoutTree(child, axis, width, a * width + b, pxPerMm); } } } // Second top-down pass: turns each already-sized box into an absolute (x, y) position; kept // separate from layoutTree since a node's size doesn't depend on its position. function positionTree(node, ownAxis, x, y) { node.box.x = x; node.box.y = y; if (!isSplit(node)) { return; } const axis = !ownAxis; let cursor = axis ? x : y; for (const child of node) { if (axis) { positionTree(child, axis, cursor, y); cursor += child.box.width; } else { positionTree(child, axis, x, cursor); cursor += child.box.height; } } } // Pins each QR-family leaf's real crisp-pixel box.width/box.height so relation() above starts // treating it as fixed-size. Appends a {short, message} entry to `warnings` for each of // MIN_RECOMMENDED_QR_PX_PER_MODULE/MIN_RECOMMENDED_QR_MODULE_MM the leaf falls short of (still // printable, just flagged as a scan-reliability risk), and unconditionally appends its raw // {scale, moduleMm} to `qrInfo` - callers needing to show those numbers regardless of whether // they tripped a threshold (see LabelLayoutPreview.vue's debug line) shouldn't have to re-derive // them. See docs/implementation.md#crisp-qr-sizing. function snapQrToCrispSize(node, pxPerMm, warnings, qrInfo) { if (isSplit(node)) { node.forEach(child => snapQrToCrispSize(child, pxPerMm, warnings, qrInfo)); return; } if (isQrLeaf(node)) { const {width: modulesW, height: modulesH} = node.qr; const scale = Math.floor(Math.min(node.box.width / modulesW, node.box.height / modulesH)); if (!(scale >= 1)) { const err = new Error("This text needs a bigger code than the tape allows — " + "try a shorter value or a wider tape."); err.short = "too big"; throw err; } node.crispWidth = modulesW * scale; node.crispHeight = modulesH * scale; const moduleMm = scale / pxPerMm; qrInfo.push({scale, moduleMm}); if (scale < MIN_RECOMMENDED_QR_PX_PER_MODULE) { warnings.push({ short: `${scale}px/mod`, message: `This code's modules are only ${scale}px wide - they may blur together ` + "when printed; consider a bigger label or shorter content.", }); } if (moduleMm < MIN_RECOMMENDED_QR_MODULE_MM) { warnings.push({ short: `${moduleMm.toFixed(2)}mm/mod`, message: `This code's modules are only ${moduleMm.toFixed(2)}mm across - it may ` + "be too small to scan reliably; consider a bigger label or shorter content.", }); } } } // The rendered font size, floored to a whole pixel - same reasoning as snapQrToCrispSize's // integer module scale (rounding up could overflow the box a fractional size would have fit // exactly). checkTextSizes and drawTextLeaf both call this rather than each computing their own // raw fraction, so what gets measured/threshold-checked is exactly what gets drawn. function effectiveFontPx(node, referencePx) { return Math.floor(referencePx * (node.box.height / node.naturalHeight)); } // Same shape as snapQrToCrispSize but for "text" leaves: throws below MIN_TEXT_PX/MIN_TEXT_MM // (too small to read at all), appends a {short, message} warning to `warnings` for each of // MIN_RECOMMENDED_TEXT_PX/MIN_RECOMMENDED_TEXT_MM it falls short of, and unconditionally appends // its raw {fontPx, fontMm} to `textInfo`. Must run after the tree's *final* layoutTree pass (unlike // snapQrToCrispSize, which runs before solve() re-resolves the tree) - a text leaf's box.height // isn't stable until then, since (unlike a QR leaf's crispWidth/crispHeight) it doesn't feed back // into that re-resolve. function checkTextSizes(node, referencePx, pxPerMm, warnings, textInfo) { if (isSplit(node)) { node.forEach(child => checkTextSizes(child, referencePx, pxPerMm, warnings, textInfo)); return; } if (node.type !== "text") { return; } const fontPx = effectiveFontPx(node, referencePx); const fontMm = fontPx / pxPerMm; if (fontPx < MIN_TEXT_PX || fontMm < MIN_TEXT_MM) { const err = new Error(`This text only fits at ${fontPx}px ` + `(${fontMm.toFixed(2)}mm) - too small to read; try a shorter value, a different ` + "layout, or a bigger label."); err.short = "too small"; throw err; } textInfo.push({fontPx, fontMm}); if (fontPx < MIN_RECOMMENDED_TEXT_PX) { warnings.push({ short: `${fontPx}px text`, message: `This text renders at only ${fontPx}px - it may be hard to read; ` + "consider a bigger label or shorter content.", }); } if (fontMm < MIN_RECOMMENDED_TEXT_MM) { warnings.push({ short: `${fontMm.toFixed(2)}mm text`, message: `This text renders at only ${fontMm.toFixed(2)}mm tall - it may be hard to ` + "read; consider a bigger label or shorter content.", }); } } // Always measures in sans-serif at the reference size, since the eventual font (see // PIXEL_FONT_TIERS) isn't known until layoutTree sizes the box this aspect ratio feeds into; the // resulting mismatch is invisible in practice, and checkTextSizes still catches real failures. function measureTextBlock(ctx, lines, referencePx) { ctx.font = `${referencePx}px sans-serif`; const width = Math.max(...lines.map(line => ctx.measureText(line).width)); const height = referencePx * 1.15 * lines.length; return {width, height}; } // Converts a resolved content tree (see templateContent) into one ready for layout. See // docs/implementation.md#render-tree-construction. function buildRenderTree(ctx, node, referencePx) { if (isSplit(node)) { return node.map(child => buildRenderTree(ctx, child, referencePx)); } if (isQrLeaf(node)) { const {codeType, ...options} = QR_LEAF_TYPES[node.type]; const qr = encodeQr(node.value, codeType, options); return {type: node.type, aspect: qr.width / qr.height, qr}; } if (node.type === "text") { const lines = Array.isArray(node.value) ? node.value : [node.value]; const {width, height} = measureTextBlock(ctx, lines, referencePx); return {type: "text", aspect: width / height, naturalHeight: height, lines}; } return {type: "empty", "min-width": node["min-width"], "min-height": node["min-height"]}; } function drawQrLeaf(ctx, node) { const {width: modulesW, height: modulesH} = node.qr; const scale = Math.floor(Math.min(node.box.width / modulesW, node.box.height / modulesH)); const w = modulesW * scale; const h = modulesH * scale; const left = node.box.x + Math.floor((node.box.width - w) / 2); const top = node.box.y + Math.floor((node.box.height - h) / 2); for (let row = 0; row < modulesH; row++) { for (let col = 0; col < modulesW; col++) { if (node.qr.get(row, col)) { ctx.fillRect(left + col * scale, top + row * scale, scale, scale); } } } } // Returns the effective (un-normalized) font size drawn at; drawTree collects these into // drawLabel/drawFallbackLabel's textSizesPx. Always draws - checkTextSizes (run earlier, on the // same tree, before any of this) already rejected anything below MIN_TEXT_PX/MIN_TEXT_MM, so // there's no "too small to draw" case left to special-case here. function drawTextLeaf(ctx, node, referencePx) { const fontPx = effectiveFontPx(node, referencePx); const {family, scale = 1} = fontFamilyFor(fontPx); const isPixelFont = family !== "sans-serif"; // Position (not size - fontPx is already a whole pixel) still needs snapping for pixel fonts // to stay grid-aligned, since node.box.x/y are ordinary (fractional) layout math; sans-serif is // left exact since anti-aliasing handles fractional positions fine. const snap = isPixelFont ? Math.round : (v) => v; // `scale` (Tom Thumb only, see PIXEL_FONT_TIERS above) corrects the size handed to ctx.font // for its real ink; fontPx itself stays the logical size used for centering/stacking math. ctx.font = `${fontPx * scale}px "${family}"`; // Canvas text silently falls back if drawn before a not-yet-loaded font resolves, unlike DOM // text. See docs/implementation.md#canvas-font-loading. if (isPixelFont) { document.fonts.load(ctx.font); } ctx.textAlign = "center"; const centerX = snap(node.box.x + node.box.width / 2); const lineHeight = node.box.height / node.lines.length; // Lines stack as a block, each centered under the last, so a multi-line field reads as one unit. let sliceTop = node.box.y; for (const line of node.lines) { if (isPixelFont) { // textBaseline:"middle" centers on declared ascent/descent, which is backwards for // Tom Thumb; centering on actualBoundingBox{Ascent,Descent} instead measures this // string's real rendered ink and stays correct regardless. ctx.textBaseline = "alphabetic"; const {actualBoundingBoxAscent: up, actualBoundingBoxDescent: down} = ctx.measureText(line); ctx.fillText(line, centerX, snap(sliceTop + (lineHeight + up - down) / 2)); } else { ctx.textBaseline = "middle"; ctx.fillText(line, centerX, sliceTop + lineHeight / 2); } sliceTop += lineHeight; } return fontPx; } // Manual debug toggle (flip in a debugger) to outline every leaf's box, including // normally-invisible "empty" ones, in a color that can't be mistaken for real label ink; never // wired up to any UI. let DEBUG_LEAF_BORDERS = false; function drawDebugBorder(ctx, node) { ctx.save(); ctx.strokeStyle = "red"; ctx.lineWidth = 1; // Inset by half a pixel so the 1px stroke lands crisply on-pixel instead of straddling the edge. ctx.strokeRect(node.box.x + 0.5, node.box.y + 0.5, node.box.width - 1, node.box.height - 1); ctx.restore(); } // Collects each text leaf's effective font size so callers (Print.vue) can spot a suspiciously // small field (checkTextSizes' warnings/textInfo cover the same numbers in more structured form). function drawTree(ctx, node, referencePx, textSizesPx) { if (isSplit(node)) { node.forEach(child => drawTree(ctx, child, referencePx, textSizesPx)); return; } if (isQrLeaf(node)) { drawQrLeaf(ctx, node); } else if (node.type === "text") { textSizesPx.push(drawTextLeaf(ctx, node, referencePx)); } // "empty" leaves carry no ink - their box just reserves the space. if (DEBUG_LEAF_BORDERS) { drawDebugBorder(ctx, node); } } // Builds, sizes and validates the tree for a fixed dimension plus pxPerMm; runs sizing twice so // QR-family leaves' real crisp size is known before the tree is finally resolved, then checks // every text leaf's final font size. See docs/implementation.md#label-content-layout. function layoutContent(ctx, content, fixedSize, maxLength, referencePx, pxPerMm, orientation) { const tree = buildRenderTree(ctx, content, referencePx); const alongTape = orientation !== "across"; const solve = () => { const {a, b} = relation(tree, false, alongTape, pxPerMm); return alongTape ? {width: a * fixedSize + b, height: fixedSize} : {width: fixedSize, height: a * fixedSize + b}; }; let {width, height} = solve(); layoutTree(tree, false, width, height, pxPerMm); const warnings = []; const qrInfo = []; snapQrToCrispSize(tree, pxPerMm, warnings, qrInfo); ({width, height} = solve()); const length = alongTape ? width : height; if (maxLength !== Infinity && length > maxLength) { const err = new Error("This doesn't fit on this tape — " + "try a shorter value, a different layout, or a bigger label."); err.short = "too big"; throw err; } layoutTree(tree, false, width, height, pxPerMm); const textInfo = []; checkTextSizes(tree, referencePx, pxPerMm, warnings, textInfo); return {tree, length, warnings, qrInfo, textInfo}; } // The tape-fed layout: draws a fully resolved content tree (see templateContent) at the tape's // real pixel dimensions. See docs/implementation.md#tape-fed-label-drawing. Returns // {textSizesPx}: each "text" leaf's effective font size, in the tree's own left-to-right, // top-to-bottom order; {warnings}: {short, message} scan-reliability entries from // snapQrToCrispSize/checkTextSizes, if any; {qrInfo}: each QR-family leaf's raw // {scale, moduleMm}; {textInfo}: each text leaf's raw {fontPx, fontMm} - both regardless of // whether they tripped a warning. export function drawLabel(canvas, tape, content, orientation = "along") { const maxLength = tape.printLengthPx ? tape.printLengthPx - tape.leadPx - TRAILING_PADDING_PX : Infinity; const measureCtx = canvas.getContext("2d"); const pxPerMm = tape.dpi / 25.4; const {tree, length: contentLength, warnings, qrInfo, textInfo} = layoutContent( measureCtx, content, tape.printAreaPx, maxLength, TEXT_REFERENCE_PX, pxPerMm, orientation); const printedLength = tape.printLengthPx || Math.ceil(contentLength + tape.leadPx + TRAILING_PADDING_PX); canvas.width = printedLength; canvas.height = tape.printAreaPx; const ctx = canvas.getContext("2d", {willReadFrequently: true}); ctx.fillStyle = "#fff"; ctx.fillRect(0, 0, printedLength, canvas.height); ctx.fillStyle = "#000"; const origin = tape.leadPx + Math.floor((printedLength - tape.leadPx - TRAILING_PADDING_PX - contentLength) / 2); const textSizesPx = []; if (orientation === "across") { // Rotates/translates the width-fixed tree into the physical raster a quarter turn at a // time. See docs/implementation.md#across-orientation-rotation. positionTree(tree, false, 0, origin); ctx.save(); ctx.translate(0, tape.printAreaPx); ctx.rotate(-Math.PI / 2); drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx); ctx.restore(); } else { positionTree(tree, false, origin, 0); drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx); } return {textSizesPx, warnings, qrInfo, textInfo}; } const FALLBACK_LABEL_HEIGHT_PX = 200; /* reference height the no-webusb preview/PNG scales from */ const FALLBACK_DPI = 203; /* reference resolution for turning "empty" leaves' mm sizes into px */ // The no-webusb preview/PNG: same layout tree/renderer as drawLabel, scaled from a fixed // reference height instead. See docs/implementation.md#fallback-label-preview. `orientation` and // the {textSizesPx, warnings, qrInfo, textInfo} return, see drawLabel. export function drawFallbackLabel(canvas, content, orientation = "along") { const measureCtx = canvas.getContext("2d"); const pxPerMm = FALLBACK_DPI / 25.4; const {tree, length: contentLength, warnings, qrInfo, textInfo} = layoutContent( measureCtx, content, FALLBACK_LABEL_HEIGHT_PX, Infinity, TEXT_REFERENCE_PX, pxPerMm, orientation); canvas.width = Math.ceil(contentLength); canvas.height = FALLBACK_LABEL_HEIGHT_PX; const ctx = canvas.getContext("2d", {willReadFrequently: true}); ctx.fillStyle = "#fff"; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = "#000"; const textSizesPx = []; if (orientation === "across") { positionTree(tree, false, 0, 0); ctx.save(); ctx.translate(0, FALLBACK_LABEL_HEIGHT_PX); ctx.rotate(-Math.PI / 2); drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx); ctx.restore(); } else { positionTree(tree, false, 0, 0); drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx); } return {textSizesPx, warnings, qrInfo, textInfo}; } // Turns a {kind, components} prefill (see Print.vue's `prefill` prop) into the literal string a // print label should show/encode; keyed by `kind` so each kind's format is defined in one place. export const LABEL_CONTENT_BUILDERS = { // The self-contained Item URL (see docs/design-in-progress/items-labels.md), built from just // the prefill's {userHandle, id}; the short link (Print.vue's `shortUrl`) needs an async store // lookup, so it stays a separate field/template rather than being baked in here. "item": ({userHandle, id}) => `${window.location.origin}/i/${encodeHandleForUrl(userHandle)}/${id}`, // Storage locations have no long-form URL route yet (see router.js), so `text` starts blank; // the short link and any future location template still work via the base vars below. }; export function buildLabelContent(prefill) { if (!prefill) { return ""; } const build = LABEL_CONTENT_BUILDERS[prefill.kind]; return build ? build(prefill.components) : ""; } // Splits a prefill's {userHandle, id} into label-layouts.js's user/domain base vars (the same way // store.js's lookupServer does), tagging on whichever id field the resource's templates key // required_vars by. function splitUserHandle(userHandle) { if (!userHandle) { return null; } const at = userHandle.indexOf("@"); return { user: at === -1 ? userHandle : userHandle.slice(0, at), domain: at === -1 ? "" : userHandle.slice(at + 1), }; } // Seeds label-layouts.js's BASE_VARS, keyed by `kind`; derived vars (userHandle, itemUrl, …) are // computed live elsewhere (see DERIVED_VARS, Print.vue's `shortUrl`), and omitting a field // (rather than leaving it present-but-empty) signals "not available" to templateIsAvailable. const LABEL_FIELD_BUILDERS = { "item": ({userHandle, id}) => { const split = splitUserHandle(userHandle); if (!split || !id) { return {}; } return {...split, itemId: String(id)}; }, "storage-location": ({userHandle, id}) => { const split = splitUserHandle(userHandle); if (!split || !id) { return {}; } return {...split, locationId: String(id)}; }, }; export function buildLabelFields(prefill) { if (!prefill) { return {}; } const build = LABEL_FIELD_BUILDERS[prefill.kind]; return build ? build(prefill.components) : {}; }