diff --git a/src/components/Canvas/BarcodeObject.tsx b/src/components/Canvas/BarcodeObject.tsx index 4864c430..d4af854e 100644 --- a/src/components/Canvas/BarcodeObject.tsx +++ b/src/components/Canvas/BarcodeObject.tsx @@ -66,7 +66,11 @@ export function BarcodeObject({ bwipjs.toCanvas(canvas, opts as unknown as Parameters[1]); barcodeCanvas = canvas; } catch (e) { - errorMsg = e instanceof Error ? e.message : String(e); + const raw = e instanceof Error ? e.message : String(e); + // bwip-js prefixes its errors like `bwip-js: bwipp.code39badCharacter: ...` + // Strip the library/identifier head so the placeholder shows only the + // human-readable reason. + errorMsg = raw.replace(/^bwip-js:\s*/i, '').replace(/^bwipp\.[^:]+:\s*/i, ''); } } @@ -589,9 +593,13 @@ export function BarcodeObject({ ); diff --git a/src/components/Canvas/bwipHelpers.ts b/src/components/Canvas/bwipHelpers.ts index cf20ac20..aee1ab7e 100644 --- a/src/components/Canvas/bwipHelpers.ts +++ b/src/components/Canvas/bwipHelpers.ts @@ -204,7 +204,15 @@ export function buildBwipOptions( case "codabar": case "plessey": { const p = obj.props; - opts = { bcid, text: p.content || "0", scale: scale1D, height: 10 }; + // Code39/Codabar/Plessey only encode uppercase letters. Zebra firmware + // (and Labelary) silently uppercase lowercase input; bwip-js does not and + // throws instead, which would crash the canvas for any imported ZPL with + // lowercase content. Uppercase here so the lib matches firmware behaviour + // without rewriting the user's source. + const needsUpper = obj.type === "code39" || obj.type === "codabar" || obj.type === "plessey"; + const raw = p.content || "0"; + const text = needsUpper ? raw.toUpperCase() : raw; + opts = { bcid, text, scale: scale1D, height: 10 }; break; } case "msi": { @@ -221,9 +229,10 @@ export function buildBwipOptions( } case "logmars": { const p = obj.props; + // LOGMARS is a Code39 subset; same uppercase rule applies. opts = { bcid, - text: p.content || "0", + text: (p.content || "0").toUpperCase(), scale: scale1D, height: 10, includecheck: true, diff --git a/src/registry/aztec.tsx b/src/registry/aztec.tsx index dddf0f66..b759d4c7 100644 --- a/src/registry/aztec.tsx +++ b/src/registry/aztec.tsx @@ -1,7 +1,7 @@ import type { ObjectTypeDefinition } from "../types/ObjectType"; import { useT } from "../lib/useT"; import { inputCls, labelCls } from "../components/Properties/styles"; -import { fieldPos } from "./zplHelpers"; +import { fieldPos, fdField } from "./zplHelpers"; export interface AztecProps { content: string; @@ -27,7 +27,7 @@ export const aztec: ObjectTypeDefinition = { return [ fieldPos(obj), `^B0N,${p.magnification},N,N,N,N`, - `^FD${p.content}^FS`, + fdField(p.content), ].join(""); }, diff --git a/src/registry/barcode1d.tsx b/src/registry/barcode1d.tsx index 6ee04215..7c09932f 100644 --- a/src/registry/barcode1d.tsx +++ b/src/registry/barcode1d.tsx @@ -1,8 +1,9 @@ import type { ObjectTypeDefinition, ObjectGroup, LabelObjectBase } from '../types/ObjectType'; import { useT } from '../lib/useT'; import { inputCls, labelCls } from '../components/Properties/styles'; -import { fieldPos } from './zplHelpers'; +import { fieldPos, fdField } from './zplHelpers'; import { commitHeightTransform } from './transformHelpers'; +import { filterContent, type ContentSpec } from './contentSpec'; export interface Barcode1DProps { content: string; @@ -16,7 +17,6 @@ interface Barcode1DConfig { label: string; icon: string; defaultContent: string; - contentMaxLength?: number; hasCheckDigit: boolean; /** Build the ZPL barcode command (e.g. `^BUN,100,Y,N,N`). */ zplCommand: (p: Barcode1DProps) => string; @@ -34,6 +34,8 @@ interface Barcode1DConfig { heightLocked?: boolean; /** See {@link ObjectTypeDefinition.interpretationLocked}. */ interpretationLocked?: boolean; + /** Restrict allowed input characters; see {@link ContentSpec}. */ + contentSpec?: ContentSpec; } interface BarcodeLocale { @@ -77,7 +79,7 @@ export function createBarcode1D(config: Barcode1DConfig): ObjectTypeDefinition onChange({ content: e.target.value })} + onChange={(e) => onChange({ content: filterContent(e.target.value, config.contentSpec) })} /> diff --git a/src/registry/codabar.tsx b/src/registry/codabar.tsx index 58edac8a..7a3efefb 100644 --- a/src/registry/codabar.tsx +++ b/src/registry/codabar.tsx @@ -8,6 +8,7 @@ export const codabar = createBarcode1D({ hasCheckDigit: true, localeKey: "codabar", group: 'code-1d', + contentSpec: { charset: '0-9A-Da-d\\-$:/.+' }, zplCommand: (p) => { const interp = p.printInterpretation ? "Y" : "N"; const check = p.checkDigit ? "Y" : "N"; diff --git a/src/registry/codablock.tsx b/src/registry/codablock.tsx index 4769322d..70af76dd 100644 --- a/src/registry/codablock.tsx +++ b/src/registry/codablock.tsx @@ -1,7 +1,7 @@ import type { ObjectTypeDefinition } from "../types/ObjectType"; import { useT } from "../lib/useT"; import { inputCls, labelCls } from "../components/Properties/styles"; -import { fieldPos } from "./zplHelpers"; +import { fieldPos, fdField } from "./zplHelpers"; import { commitStacked2DTransform } from "./transformHelpers"; export interface CodablockProps { @@ -32,7 +32,7 @@ export const codablock: ObjectTypeDefinition = { `^BY${p.moduleWidth}`, fieldPos(obj), `^BBN,${p.rowHeight},${p.securityLevel}`, - `^FD${p.content}^FS`, + fdField(p.content), ] .filter(Boolean) .join(""); diff --git a/src/registry/code11.tsx b/src/registry/code11.tsx index 3a87fe06..70d02f6c 100644 --- a/src/registry/code11.tsx +++ b/src/registry/code11.tsx @@ -8,6 +8,7 @@ export const code11 = createBarcode1D({ hasCheckDigit: true, localeKey: "code11", group: 'code-1d', + contentSpec: { charset: '0-9\\-' }, zplCommand: (p) => { const interp = p.printInterpretation ? "Y" : "N"; const check = p.checkDigit ? "Y" : "N"; diff --git a/src/registry/code128.tsx b/src/registry/code128.tsx index dd3a8dd8..4391724c 100644 --- a/src/registry/code128.tsx +++ b/src/registry/code128.tsx @@ -1,7 +1,7 @@ import type { ObjectTypeDefinition } from '../types/ObjectType'; import { useT } from '../lib/useT'; import { inputCls, labelCls } from '../components/Properties/styles'; -import { fieldPos } from './zplHelpers'; +import { fieldPos, fdField } from './zplHelpers'; import { commitHeightTransform } from './transformHelpers'; export interface Code128Props { @@ -35,7 +35,7 @@ export const code128: ObjectTypeDefinition = { `^BY${p.moduleWidth}`, fieldPos(obj), `^BCN,${p.height},${interp},N,${check}`, - `^FD${p.content}^FS`, + fdField(p.content), ].filter(Boolean).join(''); }, diff --git a/src/registry/code39.tsx b/src/registry/code39.tsx index 78461428..114a5143 100644 --- a/src/registry/code39.tsx +++ b/src/registry/code39.tsx @@ -1,8 +1,11 @@ import type { ObjectTypeDefinition } from '../types/ObjectType'; import { useT } from '../lib/useT'; import { inputCls, labelCls } from '../components/Properties/styles'; -import { fieldPos } from './zplHelpers'; +import { fieldPos, fdField } from './zplHelpers'; import { commitHeightTransform } from './transformHelpers'; +import { filterContent, type ContentSpec } from './contentSpec'; + +const code39Spec: ContentSpec = { charset: '0-9A-Za-z\\-. $/+%' }; export interface Code39Props { content: string; @@ -35,7 +38,7 @@ export const code39: ObjectTypeDefinition = { `^BY${p.moduleWidth}`, fieldPos(obj), `^B3N,${check},${p.height},${interp},N`, - `^FD${p.content}^FS`, + fdField(p.content), ].filter(Boolean).join(''); }, @@ -49,7 +52,7 @@ export const code39: ObjectTypeDefinition = { onChange({ content: e.target.value })} + onChange={(e) => onChange({ content: filterContent(e.target.value, code39Spec) })} /> diff --git a/src/registry/contentSpec.ts b/src/registry/contentSpec.ts new file mode 100644 index 00000000..f33940bb --- /dev/null +++ b/src/registry/contentSpec.ts @@ -0,0 +1,30 @@ +/** + * Per-symbology content rules. Applied on input so the canvas preview + * (bwip-js) does not throw on characters the renderer rejects. + * + * Case normalisation is intentionally not done here: the source ZPL + * is preserved verbatim, and `bwipHelpers` uppercases at the lib call + * site for the symbologies that require it. + */ +export interface ContentSpec { + /** Character-class body (no surrounding `[]`), e.g. `0-9` or `0-9A-Z\\-. $/+%`. */ + charset: string; + maxLength?: number; +} + +const rejectCache = new WeakMap(); + +function rejectPattern(spec: ContentSpec): RegExp { + let re = rejectCache.get(spec); + if (!re) { + re = new RegExp(`[^${spec.charset}]`, 'g'); + rejectCache.set(spec, re); + } + return re; +} + +export function filterContent(raw: string, spec?: ContentSpec): string { + if (!spec) return raw; + const filtered = raw.replace(rejectPattern(spec), ''); + return spec.maxLength ? filtered.slice(0, spec.maxLength) : filtered; +} diff --git a/src/registry/datamatrix.tsx b/src/registry/datamatrix.tsx index 25e5d810..3eb583f0 100644 --- a/src/registry/datamatrix.tsx +++ b/src/registry/datamatrix.tsx @@ -1,7 +1,7 @@ import type { ObjectTypeDefinition } from '../types/ObjectType'; import { useT } from '../lib/useT'; import { inputCls, labelCls } from '../components/Properties/styles'; -import { fieldPos } from './zplHelpers'; +import { fieldPos, fdField } from './zplHelpers'; import { clamp } from './transformHelpers'; export interface DataMatrixProps { @@ -30,7 +30,7 @@ export const datamatrix: ObjectTypeDefinition = { return [ fieldPos(obj), `^BXN,${p.dimension},${p.quality}`, - `^FD${p.content}^FS`, + fdField(p.content), ].join(''); }, diff --git a/src/registry/ean13.tsx b/src/registry/ean13.tsx index cc0bd197..e9244a8f 100644 --- a/src/registry/ean13.tsx +++ b/src/registry/ean13.tsx @@ -1,8 +1,11 @@ import type { ObjectTypeDefinition } from '../types/ObjectType'; import { useT } from '../lib/useT'; import { inputCls, labelCls } from '../components/Properties/styles'; -import { fieldPos } from './zplHelpers'; +import { fieldPos, fdField } from './zplHelpers'; import { commitHeightTransform } from './transformHelpers'; +import { filterContent, type ContentSpec } from './contentSpec'; + +const ean13Spec: ContentSpec = { charset: '0-9', maxLength: 12 }; export interface Ean13Props { content: string; // 12 digits — ZPL appends the check digit automatically @@ -32,7 +35,7 @@ export const ean13: ObjectTypeDefinition = { `^BY${p.moduleWidth}`, fieldPos(obj), `^BEN,${p.height},${interp},N`, - `^FD${p.content}^FS`, + fdField(p.content), ].filter(Boolean).join(''); }, @@ -48,7 +51,7 @@ export const ean13: ObjectTypeDefinition = { value={p.content} maxLength={12} placeholder={t.registry.ean13.placeholder} - onChange={(e) => onChange({ content: e.target.value })} + onChange={(e) => onChange({ content: filterContent(e.target.value, ean13Spec) })} /> diff --git a/src/registry/ean8.tsx b/src/registry/ean8.tsx index b8756853..64a6b26e 100644 --- a/src/registry/ean8.tsx +++ b/src/registry/ean8.tsx @@ -5,10 +5,10 @@ export const ean8 = createBarcode1D({ label: 'EAN-8', icon: 'E8', defaultContent: '1234567', - contentMaxLength: 7, hasCheckDigit: false, localeKey: 'ean8', group: 'code-1d', + contentSpec: { charset: '0-9', maxLength: 7 }, zplCommand: (p) => { const interp = p.printInterpretation ? 'Y' : 'N'; return `^B8N,${p.height},${interp},N`; diff --git a/src/registry/gs1databar.tsx b/src/registry/gs1databar.tsx index ac1727a1..80a02376 100644 --- a/src/registry/gs1databar.tsx +++ b/src/registry/gs1databar.tsx @@ -8,6 +8,7 @@ export const gs1databar = createBarcode1D({ hasCheckDigit: false, localeKey: "gs1databar", group: 'code-1d', + contentSpec: { charset: '0-9' }, // GS1 Databar Omnidirectional has a symbology-fixed height; Zebra/Labelary // ignore the ^BR height parameter for this variant. Disabling resize and the // height input keeps the designer honest about what affects the print. diff --git a/src/registry/industrial2of5.tsx b/src/registry/industrial2of5.tsx index 04c9a129..43647221 100644 --- a/src/registry/industrial2of5.tsx +++ b/src/registry/industrial2of5.tsx @@ -8,6 +8,7 @@ export const industrial2of5 = createBarcode1D({ hasCheckDigit: false, localeKey: "industrial2of5", group: 'code-1d', + contentSpec: { charset: '0-9' }, zplCommand: (p) => { const interp = p.printInterpretation ? "Y" : "N"; return `^BIN,${p.height},${interp},N`; diff --git a/src/registry/interleaved2of5.tsx b/src/registry/interleaved2of5.tsx index af8f5805..8006a911 100644 --- a/src/registry/interleaved2of5.tsx +++ b/src/registry/interleaved2of5.tsx @@ -8,6 +8,7 @@ export const interleaved2of5 = createBarcode1D({ hasCheckDigit: true, localeKey: 'interleaved2of5', group: 'code-1d', + contentSpec: { charset: '0-9' }, zplCommand: (p) => { const interp = p.printInterpretation ? 'Y' : 'N'; const check = p.checkDigit ? 'Y' : 'N'; diff --git a/src/registry/logmars.tsx b/src/registry/logmars.tsx index 5e47e5a3..583ccef3 100644 --- a/src/registry/logmars.tsx +++ b/src/registry/logmars.tsx @@ -8,6 +8,7 @@ export const logmars = createBarcode1D({ hasCheckDigit: false, localeKey: "logmars", group: 'code-1d', + contentSpec: { charset: '0-9A-Za-z\\-. $/+%' }, zplCommand: (p) => { const interp = p.printInterpretation ? "Y" : "N"; return `^BLN,${p.height},${interp}`; diff --git a/src/registry/micropdf417.tsx b/src/registry/micropdf417.tsx index e7cd20c0..c44214d2 100644 --- a/src/registry/micropdf417.tsx +++ b/src/registry/micropdf417.tsx @@ -1,7 +1,7 @@ import type { ObjectTypeDefinition } from "../types/ObjectType"; import { useT } from "../lib/useT"; import { inputCls, labelCls } from "../components/Properties/styles"; -import { fieldPos } from "./zplHelpers"; +import { fieldPos, fdField } from "./zplHelpers"; import { commitStacked2DTransform } from "./transformHelpers"; export interface MicroPdf417Props { @@ -32,7 +32,7 @@ export const micropdf417: ObjectTypeDefinition = { `^BY${p.moduleWidth}`, fieldPos(obj), `^BFN,${p.rowHeight},${p.mode}`, - `^FD${p.content}^FS`, + fdField(p.content), ] .filter(Boolean) .join(""); diff --git a/src/registry/msi.tsx b/src/registry/msi.tsx index 82998c10..f3f1f0e1 100644 --- a/src/registry/msi.tsx +++ b/src/registry/msi.tsx @@ -8,6 +8,7 @@ export const msi = createBarcode1D({ hasCheckDigit: true, localeKey: "msi", group: 'code-1d', + contentSpec: { charset: '0-9' }, // MSI standard specifies a 2:1 wide:narrow ratio, which bwip-js hardcodes // internally. ZPL ^BY defaults to 3.0, so we must override to keep canvas // and Labelary preview in sync. diff --git a/src/registry/pdf417.tsx b/src/registry/pdf417.tsx index 39a07f4f..9a22d7ef 100644 --- a/src/registry/pdf417.tsx +++ b/src/registry/pdf417.tsx @@ -1,7 +1,7 @@ import type { ObjectTypeDefinition } from "../types/ObjectType"; import { useT } from "../lib/useT"; import { inputCls, labelCls } from "../components/Properties/styles"; -import { fieldPos } from "./zplHelpers"; +import { fieldPos, fdField } from "./zplHelpers"; import { commitStacked2DTransform } from "./transformHelpers"; export interface Pdf417Props { @@ -33,7 +33,7 @@ export const pdf417: ObjectTypeDefinition = { `^BY${p.moduleWidth}`, fieldPos(obj), `^B7N,${p.rowHeight},${p.securityLevel},${p.columns},,,`, - `^FD${p.content}^FS`, + fdField(p.content), ] .filter(Boolean) .join(""); diff --git a/src/registry/planet.tsx b/src/registry/planet.tsx index d8185236..f1c58520 100644 --- a/src/registry/planet.tsx +++ b/src/registry/planet.tsx @@ -8,6 +8,7 @@ export const planet = createBarcode1D({ hasCheckDigit: false, localeKey: "planet", group: 'code-postal', + contentSpec: { charset: '0-9' }, zplCommand: (p) => { const interp = p.printInterpretation ? "Y" : "N"; return `^B5N,${p.height},${interp},N`; diff --git a/src/registry/plessey.tsx b/src/registry/plessey.tsx index 99385eb7..e592f98e 100644 --- a/src/registry/plessey.tsx +++ b/src/registry/plessey.tsx @@ -8,6 +8,7 @@ export const plessey = createBarcode1D({ hasCheckDigit: true, localeKey: "plessey", group: 'code-1d', + contentSpec: { charset: '0-9A-Fa-f' }, // Plessey uses 2:1 wide:narrow ratio (same as MSI); override ZPL default of 3.0 byRatio: 2, zplCommand: (p) => { diff --git a/src/registry/postal.tsx b/src/registry/postal.tsx index b9949ba7..0179d435 100644 --- a/src/registry/postal.tsx +++ b/src/registry/postal.tsx @@ -8,6 +8,7 @@ export const postal = createBarcode1D({ hasCheckDigit: false, localeKey: "postal", group: 'code-postal', + contentSpec: { charset: '0-9' }, zplCommand: (p) => { const interp = p.printInterpretation ? "Y" : "N"; // ^BZ{orientation},{height},{interp},{startStop} diff --git a/src/registry/qrcode.tsx b/src/registry/qrcode.tsx index 29d75887..022ede6d 100644 --- a/src/registry/qrcode.tsx +++ b/src/registry/qrcode.tsx @@ -1,7 +1,7 @@ import type { ObjectTypeDefinition } from '../types/ObjectType'; import { useT } from '../lib/useT'; import { inputCls, labelCls } from '../components/Properties/styles'; -import { fieldPos } from './zplHelpers'; +import { fieldPos, fdField } from './zplHelpers'; import { clamp } from './transformHelpers'; export interface QrCodeProps { @@ -30,7 +30,7 @@ export const qrcode: ObjectTypeDefinition = { return [ fieldPos(obj), `^BQN,2,${p.magnification}`, - `^FD${p.errorCorrection}A,${p.content}^FS`, + fdField(`${p.errorCorrection}A,${p.content}`), ].join(''); }, diff --git a/src/registry/serial.tsx b/src/registry/serial.tsx index d65ad2ac..18ec0ac0 100644 --- a/src/registry/serial.tsx +++ b/src/registry/serial.tsx @@ -2,6 +2,9 @@ import type { ObjectTypeDefinition } from '../types/ObjectType'; import { useT } from '../lib/useT'; import { inputCls, labelCls } from '../components/Properties/styles'; import { fieldPos } from './zplHelpers'; +import { filterContent, type ContentSpec } from './contentSpec'; + +const serialSpec: ContentSpec = { charset: '0-9A-Za-z' }; export interface SerialProps { content: string; @@ -48,7 +51,7 @@ export const serial: ObjectTypeDefinition = { onChange({ content: e.target.value })} + onChange={(e) => onChange({ content: filterContent(e.target.value, serialSpec) })} />
diff --git a/src/registry/standard2of5.tsx b/src/registry/standard2of5.tsx index 37d7d835..67b95c12 100644 --- a/src/registry/standard2of5.tsx +++ b/src/registry/standard2of5.tsx @@ -8,6 +8,7 @@ export const standard2of5 = createBarcode1D({ hasCheckDigit: false, localeKey: "standard2of5", group: 'code-1d', + contentSpec: { charset: '0-9' }, zplCommand: (p) => { const interp = p.printInterpretation ? "Y" : "N"; return `^BJN,${p.height},${interp},N`; diff --git a/src/registry/text.tsx b/src/registry/text.tsx index 83012f7f..863b2ac5 100644 --- a/src/registry/text.tsx +++ b/src/registry/text.tsx @@ -2,7 +2,7 @@ import { useRef, useState, useCallback } from 'react'; import type { ObjectTypeDefinition } from '../types/ObjectType'; import { useT } from '../lib/useT'; import { inputCls, labelCls } from '../components/Properties/styles'; -import { fieldPos } from './zplHelpers'; +import { fieldPos, fdField } from './zplHelpers'; import { getFont, getAllFonts, loadFontFile } from '../lib/fontCache'; import { useFontCacheVersion } from '../hooks/useFontCacheVersion'; @@ -50,7 +50,7 @@ export const text: ObjectTypeDefinition = { fieldPos(obj), fontCmd, fbCmd, - `^FD${p.content}^FS`, + fdField(p.content), p.reverse ? '^LRN' : '', ].filter(Boolean).join(''); }, diff --git a/src/registry/upca.tsx b/src/registry/upca.tsx index f377414d..fd6c7433 100644 --- a/src/registry/upca.tsx +++ b/src/registry/upca.tsx @@ -5,10 +5,10 @@ export const upca = createBarcode1D({ label: 'UPC-A', icon: 'UPC', defaultContent: '01234567890', - contentMaxLength: 11, hasCheckDigit: false, localeKey: 'upca', group: 'code-1d', + contentSpec: { charset: '0-9', maxLength: 11 }, zplCommand: (p) => { const interp = p.printInterpretation ? 'Y' : 'N'; return `^BUN,${p.height},${interp},N,N`; diff --git a/src/registry/upce.tsx b/src/registry/upce.tsx index 0002b654..f1ea270b 100644 --- a/src/registry/upce.tsx +++ b/src/registry/upce.tsx @@ -5,10 +5,10 @@ export const upce = createBarcode1D({ label: 'UPC-E', icon: 'UPE', defaultContent: '012345', - contentMaxLength: 6, hasCheckDigit: false, localeKey: 'upce', group: 'code-1d', + contentSpec: { charset: '0-9', maxLength: 6 }, zplCommand: (p) => { const interp = p.printInterpretation ? 'Y' : 'N'; return `^B9N,${p.height},${interp},N`; diff --git a/src/registry/zplHelpers.ts b/src/registry/zplHelpers.ts index e4f1483b..e0008520 100644 --- a/src/registry/zplHelpers.ts +++ b/src/registry/zplHelpers.ts @@ -5,3 +5,25 @@ export function fieldPos(obj: LabelObjectBase): string { const cmd = obj.positionType === 'FT' ? 'FT' : 'FO'; return `^${cmd}${obj.x},${obj.y}`; } + +const FH_DELIM = '_'; +const NEEDS_FH = /[\^~]/; + +function hex(ch: string): string { + return FH_DELIM + ch.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0'); +} + +/** + * Build a `^FD…^FS` block, hex-escaping `^` and `~` so user content cannot + * smuggle ZPL commands. Caret/tilde are the format and command prefixes; if a + * label payload contains them raw, the firmware will reinterpret them as new + * commands and the field truncates or the whole block becomes malformed. + * + * When escape is needed we prefix `^FH_` and hex-encode `^`, `~`, and the + * delimiter `_` itself (so literal underscores in the payload survive). + */ +export function fdField(payload: string): string { + if (!NEEDS_FH.test(payload)) return `^FD${payload}^FS`; + const escaped = payload.replace(/[\^~_]/g, hex); + return `^FH${FH_DELIM}^FD${escaped}^FS`; +}