This commit is contained in:
j3d1 2026-08-21 21:37:12 +02:00
parent 7d9f67a77a
commit 3299c97392
13 changed files with 1407 additions and 327 deletions

View file

@ -1,5 +1,36 @@
import QRCode from "qrcode";
import {encodeHandleForUrl} from "@/handle-url";
import {loadAnyDCode} from "../vendor/anyd-qr.js";
import {encodeHandleForUrl} from "@/router"
// anyd-qr.js's own loadAnyDCode() memoizes the wasm instantiation itself, so calling it more
// than once (each of Print.vue and LabelLayoutPreview.vue does, on mount) is free - `anyd` just
// mirrors its resolved value so buildRenderTree below can use it synchronously. Until it
// resolves, a "qrcode" leaf throws (see encodeQr) the same way an oversized value already does -
// callers already have to handle layoutContent throwing, so this reuses that path rather than
// adding a second failure mode.
let anyd = null;
export function preloadQrEncoder() {
return loadAnyDCode().then(instance => { anyd = instance; });
}
// The three symbologies anyd-qr.js exposes (see its CodeType) - Print.vue's code-type selector
// offers exactly these. rMQR's matrix isn't square (see encodeQr's width/height below), unlike
// qr/micro-qr, which always are.
export const QR_CODE_TYPES = ["qr", "micro-qr", "rmqr"];
function encodeQr(text, codeType) {
if (!anyd) {
throw new Error("The QR encoder is still loading — try again in a moment.");
}
// BitMatrix-alike view over anyd's row-major Uint8Array, matching the shape drawQrLeaf/
// snapQrToCrispSize below expect (they predate this and were written against the "qrcode"
// package's own modules.size/get()). anyd's matrix already excludes the quiet zone from
// width/height (see its ModuleMatrix type), same as the old library's BitMatrix. width/height
// are kept separate rather than a single `size` (the old library's own shape, always square)
// since rMQR symbols are rectangular.
const {width, height, modules} = anyd.encode(codeType, new TextEncoder().encode(text), {ecc: "M"}).matrix;
return {width, height, get: (row, col) => modules[row * width + col] !== 0};
}
const TRAILING_PADDING_PX = 3; /* blank columns after the cut, same idea as the leading margin */
@ -39,27 +70,46 @@ export function tapeFromStatus(status) {
See label-layouts.js's LABEL_TEMPLATES for concrete trees.
*/
const QR_ASPECT = 1; /* a QR code is always square */
const TEXT_REFERENCE_PX = 100; /* font size text leaves measure their natural aspect ratio at */
// Below 10px, a general-purpose sans-serif gets blurry/illegible, so drawTextLeaf switches to
// whichever of these bitmap-style fonts (see ../scss/_pixel-fonts.scss) is designed closest to -
// and no smaller than - the box's actual effective size. Ordered smallest first; fontFamilyFor
// below picks the first tier whose belowPx clears the requested size, sans-serif once none do.
// Below 10px, a general-purpose sans-serif gets blurry/illegible, so drawTextLeaf switches to one
// of these bitmap-style fonts instead (see ../scss/_pixel-fonts.scss) - Tom Thumb for the smallest
// sizes, Silkscreen once there's enough room for its more conventional letterforms.
//
// Both were chosen only after rendering single letters in a real browser *at raw canvas pixel
// sizes* and inspecting the actual pixels - checking that fillText was merely *called* doesn't
// confirm anything legible got drawn, and neither does a DPI-adjusted size that was never the
// number actually handed to ctx.font. Silkscreen confirmed clean at 8px+. A third candidate,
// PICO-8, also rendered cleanly across the whole range, but has no lowercase glyphs at all - it
// silently draws lowercase input as uppercase - which rules it out for real label content (item
// handles, URLs) that isn't reliably all-caps. Tom Thumb's declared ascent/descent (0 / ~fontPx,
// backwards from a normal font) turned out not to be a centering quirk: its actual visible ink is
// only ~1/3.2 of its own nominal font-size (confirmed both by measuring actualBoundingBox at
// several sizes and by a live-browser check - "16px" reads as roughly 5px of real glyph height),
// hence the `scale` below - whatever logical size is requested, the font is actually drawn that
// many times larger so its real ink comes out at the intended size. Silkscreen's declared size
// already matches its ink, so it has no `scale` (equivalent to 1).
//
// belowPx and MIN_READABLE_TEXT_PX below are both compared against the *logical* (unscaled)
// fontPx, deliberately not adjusted for the tape's dpi: a browser's font rasterizer only ever
// sees a raw pixel count, with no notion of "physical size" at all, so that's what determines
// whether a glyph's fine detail survives - confirmed by the same real-Chromium testing, where a
// raw 4.35px render was a solid blob regardless of what a dpi-scaled version of that number would
// have implied.
const PIXEL_FONT_TIERS = [
{belowPx: 6, family: "Tom Thumb"}, /* designed for ~5px */
{belowPx: 7, family: "PICO-8"}, /* designed for ~6px */
{belowPx: 10, family: "Silkscreen"}, /* designed for ~7-8px */
{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";
return PIXEL_FONT_TIERS.find(t => fontPx < t.belowPx) ?? {family: "sans-serif"};
}
// A pixel font (see PIXEL_FONT_TIERS above) is what makes a size down here still legible - a
// plain sans-serif this small would fail the old MIN_READABLE_TEXT_PX=8 floor that predates them.
// Below this, drawTextLeaf leaves that one field blank rather than drawing illegible ink - see
// there for why that's a quieter failure than rejecting the whole label over it.
// Tom Thumb (see PIXEL_FONT_TIERS above) held up down to 5px in the same real-Chromium pixel-level
// verification - a plain sans-serif this small would fail the old MIN_READABLE_TEXT_PX=8 floor
// that predates it. Below this, drawTextLeaf leaves that one field blank rather than drawing
// illegible ink - see there for why that's a quieter failure than rejecting the whole label over
// it.
const MIN_READABLE_TEXT_PX = 5;
function isSplit(node) {
@ -90,8 +140,8 @@ function parseMm(value, key) {
requesting the direction a split doesn't naturally combine in just inverts its own relation. */
function relation(node, ownAxis, wantWidth, pxPerMm) {
if (!isSplit(node)) {
if (node.type === "qrcode" && node.crispSize !== undefined) {
return {a: 0, b: node.crispSize};
if (node.type === "qrcode" && node.crispWidth !== undefined) {
return {a: 0, b: wantWidth ? node.crispWidth : node.crispHeight};
}
if (node.type === "qrcode" || node.type === "text") {
const aspect = node.aspect;
@ -156,27 +206,29 @@ function positionTree(node, ownAxis, x, y) {
/* A QR code needs an integer number of pixels per module to render crisply rather than blurring
at a fractional scale, so its true size is whatever that rounds down to - almost never the
scale-free square a bare aspect ratio of 1 would suggest. Called once every qrcode leaf has a
provisional (scale-free) box from a first layoutTree pass, this pins each one's real box.width
(in row context) or box.height (in column context - whichever axis its box doesn't already
share with its siblings) as `crispSize`, so relation() above starts treating it as a fixed
size, the same as an "empty" leaf, instead of one that scales with whatever height/width it's
offered. A second relation()/layoutTree() pass (see layoutContent) then resizes everything
scale-free box its aspect ratio alone would suggest. Called once every qrcode leaf has a
provisional (scale-free) box from a first layoutTree pass, this pins each one's real
box.width/box.height as `crispWidth`/`crispHeight`, so relation() above starts treating it as a
fixed size, the same as an "empty" leaf, instead of one that scales with whatever height/width
it's offered. A second relation()/layoutTree() pass (see layoutContent) then resizes everything
else around that real footprint, so nothing downstream reserves - and leaves unfilled - room
for a squarer QR code than what actually gets drawn. */
for a squarer/differently-shaped code than what actually gets drawn. Kept as two independent
dimensions rather than one `crispSize` (as when every code here was a square QR) since an rMQR
symbol isn't square - see encodeQr. */
function snapQrToCrispSize(node) {
if (isSplit(node)) {
node.forEach(snapQrToCrispSize);
return;
}
if (node.type === "qrcode") {
const modules = node.qr.modules.size;
const scale = Math.floor(Math.min(node.box.width, node.box.height) / modules);
const {width: modulesW, height: modulesH} = node.qr;
const scale = Math.floor(Math.min(node.box.width / modulesW, node.box.height / modulesH));
if (!(scale >= 1)) {
throw new Error("This text needs a bigger QR code than the tape allows — "
throw new Error("This text needs a bigger code than the tape allows — "
+ "try a shorter value or a wider tape.");
}
node.crispSize = modules * scale;
node.crispWidth = modulesW * scale;
node.crispHeight = modulesH * scale;
}
}
@ -194,18 +246,22 @@ function measureTextBlock(ctx, lines, referencePx) {
}
/* Turns a resolved content tree (see templateContent below - leaf objects carry a `value`
rather than a `content` function) into one ready for layout: a QR leaf gets its actual
QRCode.create() object, a text leaf gets its measured natural aspect ratio, and an "empty"
leaf passes through untouched. Multi-line text (`value` is an array) measures as one leaf,
not one per line - splitting it into a column of independently-sized leaves would let each
line grow to its own full width, ending up at a different font size than its neighbors, which
is legible but not what "one text field" should look like. */
function buildRenderTree(ctx, node, referencePx) {
rather than a `content` function) into one ready for layout: a QR leaf gets its actual encoded
modules (see encodeQr) and an aspect ratio taken from their real width/height - 1 (square) for
qr/micro-qr, but not for rMQR, whose symbols are rectangular - a text leaf gets its measured
natural aspect ratio, and an "empty" leaf passes through untouched. Multi-line text (`value` is
an array) measures as one leaf, not one per line - splitting it into a column of independently-
sized leaves would let each line grow to its own full width, ending up at a different font size
than its neighbors, which is legible but not what "one text field" should look like. `codeType`
is Print.vue's global qr/micro-qr/rmqr choice - see QR_CODE_TYPES - applied to every qrcode leaf
in the tree alike, the same way `orientation` applies to the whole tree in layoutContent. */
function buildRenderTree(ctx, node, referencePx, codeType) {
if (isSplit(node)) {
return node.map(child => buildRenderTree(ctx, child, referencePx));
return node.map(child => buildRenderTree(ctx, child, referencePx, codeType));
}
if (node.type === "qrcode") {
return {type: "qrcode", aspect: QR_ASPECT, qr: QRCode.create(node.value)};
const qr = encodeQr(node.value, codeType);
return {type: "qrcode", aspect: qr.width / qr.height, qr};
}
if (node.type === "text") {
const lines = Array.isArray(node.value) ? node.value : [node.value];
@ -216,22 +272,24 @@ function buildRenderTree(ctx, node, referencePx) {
}
function drawQrLeaf(ctx, node) {
const modules = node.qr.modules.size;
const scale = Math.floor(Math.min(node.box.width, node.box.height) / modules);
const size = modules * scale;
const left = node.box.x + Math.floor((node.box.width - size) / 2);
const top = node.box.y + Math.floor((node.box.height - size) / 2);
for (let row = 0; row < modules; row++) {
for (let col = 0; col < modules; col++) {
if (node.qr.modules.get(row, col)) {
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 font size drawn at (or that would have been, if it's too small to draw -
// see below) - drawTree collects these into drawLabel/drawFallbackLabel's textSizesPx.
// Returns the effective (raw, un-normalized) font size drawn at - or that would have been, if
// it's too small to draw, see below - drawTree collects these into drawLabel/drawFallbackLabel's
// textSizesPx.
function drawTextLeaf(ctx, node, referencePx) {
const fontPx = referencePx * (node.box.height / node.naturalHeight);
// Even the smallest PIXEL_FONT_TIERS entry stops being legible below this - rather than
@ -240,27 +298,51 @@ function drawTextLeaf(ctx, node, referencePx) {
if (fontPx < MIN_READABLE_TEXT_PX) {
return fontPx;
}
const lineHeight = node.box.height / node.lines.length;
const family = fontFamilyFor(fontPx);
ctx.font = `${fontPx}px "${family}"`;
const {family, scale = 1} = fontFamilyFor(fontPx);
const isPixelFont = family !== "sans-serif";
// A pixel font's glyphs are meant to land exactly on the pixel grid - node.box.x/y are
// ordinary layout math (sums/quotients of affine-solved sizes) and essentially never land on
// a whole pixel, so drawing at their exact fractional size/position would misalign a pixel
// font's 1px-wide strokes the same as it would any other font. Snapping size and position to
// the nearest whole pixel fixes that; sans-serif is left at its exact fractional fit, since
// ordinary anti-aliased text is expected to (and looks fine) regardless of position.
const snap = isPixelFont ? Math.round : (v) => v;
const drawFontPx = snap(fontPx);
// `scale` (Tom Thumb only, see PIXEL_FONT_TIERS above) corrects for a font whose declared
// size doesn't match its real visible ink - the size actually handed to ctx.font, not
// drawFontPx itself, which stays the logical size everything else here (box centering, line
// stacking) is measured against.
ctx.font = `${drawFontPx * scale}px "${family}"`;
// A @font-face family already in use elsewhere on the page loads in time for this, but canvas
// text silently falls back to the next font in the stack (there isn't one here, so the
// browser default) if drawn before its first-ever load finishes - unlike DOM text, a canvas
// fillText never waits or repaints on its own once the real font arrives. Kicking off the load
// here means only that very first draw at a given size risks the fallback; every redraw after
// it (Print.vue's live preview redraws on every keystroke) picks up the real font.
if (family !== "sans-serif") {
if (isPixelFont) {
document.fonts.load(ctx.font);
}
ctx.textBaseline = "middle";
ctx.textAlign = "center";
const centerX = node.box.x + node.box.width / 2;
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 - keeps a multi-line field reading as
// one unit rather than drifting apart.
let y = node.box.y + lineHeight / 2;
let sliceTop = node.box.y;
for (const line of node.lines) {
ctx.fillText(line, centerX, y);
y += lineHeight;
if (isPixelFont) {
// textBaseline:"middle" centers on the font's *declared* ascent/descent (its line
// height) - Tom Thumb's are backwards (see PIXEL_FONT_TIERS above) and would center on
// nonsense. Centering on actualBoundingBox{Ascent,Descent} instead - this specific
// string's real rendered ink (its character height) - costs nothing and stays correct
// regardless of whether a pixel font's declared metrics can be trusted.
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;
}
@ -301,61 +383,98 @@ function drawTree(ctx, node, referencePx, textSizesPx) {
}
}
/* Builds, sizes and validates the tree for a fixed `height` (the tape's cross-web printAreaPx, or
the fallback preview's reference height) - the one dimension every layout scales from, plus
`pxPerMm` to turn "empty" leaves' physical sizes into pixels. `height` and the tree's content
fully determine its overall width; `maxLength`, when finite (a fixed-length/die-cut tape),
rejects content that doesn't fit rather than shrinking it.
/* Builds, sizes and validates the tree for a fixed `fixedSize` (the tape's cross-web printAreaPx,
or the fallback preview's reference height) - the one dimension every layout scales from, plus
`pxPerMm` to turn "empty" leaves' physical sizes into pixels. `fixedSize` and the tree's content
fully determine its overall size along the other, growing axis (the one that runs along the
tape as it feeds); `maxLength`, when finite (a fixed-length/die-cut tape), rejects content that
doesn't fit rather than shrinking it.
Sizing runs twice: a first pass treats every qrcode leaf as the scale-free square its aspect
ratio of 1 suggests, purely to find out how much room each one would actually be offered; from
that, snapQrToCrispSize pins each one's real (smaller, crisp-pixel) size. The second pass then
resolves the whole tree again with that real size fixed in, so every sibling and the overall
width reflect what's actually drawn rather than the idealized square no QR code ever quite
fills. */
function layoutContent(ctx, content, height, maxLength, referencePx, pxPerMm) {
const tree = buildRenderTree(ctx, content, referencePx);
`orientation` picks which axis `fixedSize` binds to: "along" (the default) fixes the tree's
height - the tape's cross-web width - and grows its width along the feed direction, same as a
plain read top-to-bottom design. "across" fixes the tree's width instead and grows its height,
so the design is built turned 90deg from how it'd read "along" - drawLabel/drawFallbackLabel
are what actually rotate the drawing back into the physical raster's fixed orientation; nothing
here needs to know about that rotation, since relation()/layoutTree() below already solve the
tree in either direction symmetrically.
const measured = relation(tree, false, true, pxPerMm);
layoutTree(tree, false, measured.a * height + measured.b, height, pxPerMm);
Sizing runs twice: a first pass treats every qrcode leaf as the scale-free box its real
width/height ratio suggests, purely to find out how much room each one would actually be
offered; from that, snapQrToCrispSize pins each one's real (smaller, crisp-pixel) size. The
second pass then resolves the whole tree again with that real size fixed in, so every sibling
and the overall size reflect what's actually drawn rather than the idealized box no code ever
quite fills. `codeType`, see buildRenderTree. */
function layoutContent(ctx, content, fixedSize, maxLength, referencePx, pxPerMm, orientation, codeType) {
const tree = buildRenderTree(ctx, content, referencePx, codeType);
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);
snapQrToCrispSize(tree);
const {a, b} = relation(tree, false, true, pxPerMm);
const width = a * height + b;
if (maxLength !== Infinity && width > maxLength) {
({width, height} = solve());
const length = alongTape ? width : height;
if (maxLength !== Infinity && length > maxLength) {
throw new Error("This doesn't fit on this tape — "
+ "try a shorter value, a different layout, or a bigger label.");
}
layoutTree(tree, false, width, height, pxPerMm);
return {tree, width};
return {tree, length};
}
/* The tape-fed layout - draws a fully resolved content tree (see templateContent) at the tape's
real pixel dimensions. See DEBUG_LEAF_BORDERS above to outline every leaf's box. Returns
real pixel dimensions. `orientation` is "along" (the default) to lay the design out reading
along the tape's feed direction, or "across" to turn it 90deg so it reads across the tape
instead - either way the physical raster this returns is still exactly
printedLength x tape.printAreaPx (that's fixed by the tape/print head, not a choice this
makes); "across" just draws the (now width-fixed, see layoutContent) tree through a rotated
canvas transform so it lands correctly in that same raster, rather than transposing every box
the tree itself computed. See DEBUG_LEAF_BORDERS above to outline every leaf's box. Returns
{textSizesPx}: each "text" leaf's effective font size, in the tree's own left-to-right,
top-to-bottom order. */
export function drawLabel(canvas, tape, content) {
top-to-bottom order. `codeType` (default "qr"), see buildRenderTree/QR_CODE_TYPES. */
export function drawLabel(canvas, tape, content, orientation = "along", codeType = "qr") {
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, width: contentWidth} = layoutContent(
measureCtx, content, tape.printAreaPx, maxLength, TEXT_REFERENCE_PX, pxPerMm);
const {tree, length: contentLength} = layoutContent(
measureCtx, content, tape.printAreaPx, maxLength, TEXT_REFERENCE_PX, pxPerMm, orientation, codeType);
const width = tape.printLengthPx || Math.ceil(contentWidth + tape.leadPx + TRAILING_PADDING_PX);
canvas.width = width;
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, width, canvas.height);
ctx.fillRect(0, 0, printedLength, canvas.height);
ctx.fillStyle = "#000";
const originX = tape.leadPx + Math.floor((width - tape.leadPx - TRAILING_PADDING_PX - contentWidth) / 2);
positionTree(tree, false, originX, 0);
const origin = tape.leadPx
+ Math.floor((printedLength - tape.leadPx - TRAILING_PADDING_PX - contentLength) / 2);
const textSizesPx = [];
drawTree(ctx, tree, TEXT_REFERENCE_PX, textSizesPx);
if (orientation === "across") {
// The tree was solved width-fixed (see layoutContent) - its width already exactly fills
// tape.printAreaPx, so only its (growing) height needs the same along-the-feed centering
// originX got above; translate+rotate then carries that tree-local (x, y) box straight
// into the physical (printedLength x printAreaPx) raster, a quarter turn at a time.
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};
}
@ -365,14 +484,15 @@ const FALLBACK_DPI = 203; /* reference resolution for turning "empty" leaves' m
/* The no-webusb preview/PNG - same layout tree and renderer as drawLabel, just scaled from a
fixed reference height instead of a real tape's, and with no maxLength (there's no physical
tape to run out of, so the canvas just grows to fit) and no printer feed margin, since there's
no real print head here to keep clear of. Returns {textSizesPx}, see drawLabel. */
export function drawFallbackLabel(canvas, content) {
no real print head here to keep clear of. `orientation`/`codeType`, see drawLabel. Returns
{textSizesPx}, see drawLabel. */
export function drawFallbackLabel(canvas, content, orientation = "along", codeType = "qr") {
const measureCtx = canvas.getContext("2d");
const pxPerMm = FALLBACK_DPI / 25.4;
const {tree, width: contentWidth} = layoutContent(
measureCtx, content, FALLBACK_LABEL_HEIGHT_PX, Infinity, TEXT_REFERENCE_PX, pxPerMm);
const {tree, length: contentLength} = layoutContent(
measureCtx, content, FALLBACK_LABEL_HEIGHT_PX, Infinity, TEXT_REFERENCE_PX, pxPerMm, orientation, codeType);
canvas.width = Math.ceil(contentWidth);
canvas.width = Math.ceil(contentLength);
canvas.height = FALLBACK_LABEL_HEIGHT_PX;
const ctx = canvas.getContext("2d", {willReadFrequently: true});
@ -380,9 +500,18 @@ export function drawFallbackLabel(canvas, content) {
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#000";
positionTree(tree, false, 0, 0);
const textSizesPx = [];
drawTree(ctx, tree, TEXT_REFERENCE_PX, 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};
}