Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 38 additions & 13 deletions src/components/Canvas/ImageObject.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from "react";
import { Group, Image as KImage, Rect, Text } from "react-konva";
import { useState, useEffect, useRef, type ReactElement } from "react";
import { Group, Image as KImage, Path, Rect } from "react-konva";
import type Konva from "konva";
import type { LabelObject } from "../../types/Group";
import { dotsToPx, pxToDots } from "../../lib/coordinates";
Expand Down Expand Up @@ -29,12 +29,13 @@ export function ImageObject({
const colors = useColorScheme();
const cached = getImage(p.imageId);
const w = dotsToPx(p.widthDots, scale, dpmm);
// Guard against a 0-width cached image: the imageCache pipeline
// doesn't normally produce one, but a malformed file could leak
// through and div-by-zero would render NaN-sized canvas nodes.
// Aspect-lock when a real PNG is cached; fall back to `heightDots` for
// recall-only placeholders so the user can shape the box freely. Guard
// against 0-width cached images (malformed file edge case) — div-by-
// zero would otherwise render NaN-sized canvas nodes.
const h = cached && cached.width > 0
? w * (cached.height / cached.width)
: w;
: dotsToPx(p.heightDots ?? p.widthDots, scale, dpmm);
const x = offsetX + dotsToPx(obj.x, scale, dpmm);
const y = offsetY + dotsToPx(obj.y, scale, dpmm);

Expand Down Expand Up @@ -120,13 +121,37 @@ export function ImageObject({
strokeWidth={isSelected ? 2 : 1}
dash={[4, 2]}
/>
<Text
x={6}
y={6}
text="🖼"
fontSize={Math.max(w * 0.3, 12)}
fill="#374151"
/>
<PlaceholderIcon w={w} h={h} />
</Group>
);
}

/** Heroicons "photo" outline rendered via Konva.Path so the placeholder
* is OS-independent. We previously used the 🖼 emoji which rendered
* inconsistently (color on macOS, monochrome on Linux, missing on
* some Windows configurations). */
const PHOTO_ICON_PATH =
"M2.25 15.75l5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z";
const PHOTO_ICON_VIEWBOX = 24;

function PlaceholderIcon({ w, h }: { w: number; h: number }): ReactElement {
// Centre the icon at 60% of the smaller dimension; clamp so a thin
// image strip still shows something visible. Stroke-only mirrors
// Heroicons' outline style.
const target = Math.max(Math.min(w, h) * 0.6, 12);
const scale = target / PHOTO_ICON_VIEWBOX;
return (
<Path
data={PHOTO_ICON_PATH}
x={(w - target) / 2}
y={(h - target) / 2}
scaleX={scale}
scaleY={scale}
stroke="#6b7280"
strokeWidth={1.5 / scale}
fillEnabled={false}
lineCap="round"
lineJoin="round"
/>
);
}
4 changes: 4 additions & 0 deletions src/components/Properties/styles.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
export const inputCls = 'w-full bg-surface-2 border border-border rounded px-2 py-1 text-xs font-mono text-text focus:border-accent focus:outline-none';
export const labelCls = 'font-mono text-[10px] text-muted uppercase tracking-wider';
/** Secondary-action button: file upload, toggle row, etc. Matches the
* surface-2 + border styling used by `inputCls` so buttons sit naturally
* next to form fields without dominating the visual hierarchy. */
export const buttonCls = 'px-3 py-1.5 rounded text-xs font-mono bg-surface-2 border border-border text-text hover:bg-border transition-colors';
21 changes: 21 additions & 0 deletions src/lib/storagePath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,27 @@
* drifting apart across the parser, emitter, and image registry.
*/

/** Storage device prefixes Zebra firmware recognises. R: volatile RAM
* (default, fastest); E: non-volatile flash; B: alternate flash;
* A: alias drive on some models. All four round-trip through `~DY` and
* `^XG`; the parser accepts them and the UI exposes them in the device
* picker. */
export const STORAGE_DEVICES = ["R", "E", "B", "A"] as const;
export type StorageDevice = (typeof STORAGE_DEVICES)[number];

/** Zebra DOS-style filename: up to 8 chars, uppercase alphanumeric +
* underscore. Both constants are exported so input filters in the UI
* and the parser stay in lockstep. */
export const MAX_STORAGE_NAME_LEN = 8;
export const STORAGE_NAME_FILTER_RE = /[^A-Z0-9_]/g;

/** Default name when the user first enables printer-storage on an image.
* Short UUID slice avoids collisions across multiple images on the same
* label without forcing the user to pick a name up front. */
export function defaultStorageName(): string {
return `IMG_${crypto.randomUUID().slice(0, 4).toUpperCase()}`;
}
Comment thread
u8array marked this conversation as resolved.

export interface StoragePath {
/** Storage device prefix without trailing colon: "R", "E", "B", "A". */
device: string;
Expand Down
31 changes: 19 additions & 12 deletions src/lib/zplGenerator.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, it, expect } from 'vitest';
import { zlibSync } from 'fflate';
import { generateZPL, generateMultiPageZPL } from './zplGenerator';
import { parseZPL } from './zplParser';
import type { LabelConfig } from '../types/ObjectType';
Expand Down Expand Up @@ -573,21 +574,27 @@ describe('generateZPL — ~DY graphic upload + ^XG recall', () => {
// Zebra firmware rejects format A with a :Z64: payload. The shared
// cache uses ^GF{format} so the format letter survives both the GF
// and DY round-trip paths.
const z64Payload = ':Z64:eJxjYAACBAAACgAB:1234';
const bytes = new Uint8Array([0, 0xff, 0xff, 0]);
const deflated = zlibSync(bytes);
let bin = '';
for (const b of deflated) bin += String.fromCharCode(b);
const b64 = btoa(bin);
function crc(s: string): string {
let c = 0;
for (const ch of s) {
c ^= ch.charCodeAt(0) << 8;
for (let j = 0; j < 8; j++)
c = (c & 0x8000) ? ((c << 1) ^ 0x1021) & 0xffff : (c << 1) & 0xffff;
}
return c.toString(16).padStart(4, '0').toUpperCase();
}
const zpl =
`~DYR:CLOGO,C,G,4,1,${z64Payload}\n` +
`~DYR:CLOGO,C,G,4,1,:Z64:${b64}:${crc(b64)}\n` +
`^XA^FO0,0^XGR:CLOGO.GRF,1,1^FS^XZ`;
const parsed = parseZPL(zpl, 8);
// ^XG only resolves if ~DY was registered; in this case the Z64
// stream is malformed (junk base64) so the upload fails — the test
// is then about the parser surfacing that as browserLimit instead
// of mis-pairing format letters. We accept either path; if it does
// round-trip, the format letter must be C.
if (parsed.objects.length > 0) {
const out = generateZPL(BASE_LABEL, parsed.objects);
expect(out).toContain('~DYR:CLOGO,C,G,');
expect(out).not.toContain('~DYR:CLOGO,A,G,');
}
const out = generateZPL(BASE_LABEL, parsed.objects);
expect(out).toContain('~DYR:CLOGO,C,G,');
expect(out).not.toContain('~DYR:CLOGO,A,G,');
});

it('deduplicates the ~DY preamble when the same upload is referenced twice', () => {
Expand Down
4 changes: 4 additions & 0 deletions src/lib/zplGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ export function generateZPL(label: LabelConfig, objects: LabelObject[]): string
if (obj.type !== 'image') continue;
const p = obj.props as ImageProps;
if (!p.storedAs || !p._gfaCache) continue;
// Recall-only mode: the printer already has the bytes (admin uploaded
// them out-of-band), so skip the ~DY preamble. Image stays a preview
// in the designer; ZPL output only carries the ^XG references.
if (p.storedAs.embedInZpl === false) continue;
const key = formatStoragePath(p.storedAs, false);
if (seenGraphics.has(key)) continue;
seenGraphics.add(key);
Expand Down
19 changes: 13 additions & 6 deletions src/lib/zplParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -676,7 +676,7 @@ describe('parseZPL — ~DY + ^XG graphic upload/recall', () => {
expect(objects).toHaveLength(1);
expect(objects[0]?.type).toBe('image');
expect(props(objects[0]).widthDots).toBe(8);
expect(props(objects[0]).storedAs).toEqual({ device: 'R', name: 'LOGO' });
expect(props(objects[0]).storedAs).toEqual({ device: 'R', name: 'LOGO', embedInZpl: true });
expect(objects[0]?.x).toBe(50);
expect(objects[0]?.y).toBe(80);
expect(importReport.browserLimit).toHaveLength(0);
Expand All @@ -690,15 +690,22 @@ describe('parseZPL — ~DY + ^XG graphic upload/recall', () => {
`^XA^FO50,80^XGR:LOGO,1,1^FS^XZ`;
const { objects, importReport } = parseZPL(zpl, 8);
expect(objects).toHaveLength(1);
expect(props(objects[0]).storedAs).toEqual({ device: 'R', name: 'LOGO' });
expect(props(objects[0]).storedAs).toEqual({ device: 'R', name: 'LOGO', embedInZpl: true });
expect(importReport.browserLimit).toHaveLength(0);
});

it('^XG without a preceding ~DY surfaces as browserLimit', () => {
it('^XG without a preceding ~DY imports as recall-only image', () => {
// Admin pre-loaded the file on the printer; we just emit the ^XG
// reference without ~DY bytes. Object is created so the user can
// position/edit it; embedInZpl=false stops the emitter from
// re-uploading bytes we never received.
const zpl = `^XA^FO0,0^XGR:MISSING.GRF,1,1^FS^XZ`;
const { objects, importReport } = parseZPL(zpl, 8);
expect(objects).toHaveLength(0);
expect(importReport.browserLimit.some((s) => s.startsWith('^XG'))).toBe(true);
expect(objects).toHaveLength(1);
expect(props(objects[0]).storedAs).toEqual({
device: 'R', name: 'MISSING', embedInZpl: false,
});
expect(importReport.partial).toContain('^XG');
});

it('accepts :Z64:-wrapped graphic payloads in ~DY (format C)', () => {
Expand All @@ -709,7 +716,7 @@ describe('parseZPL — ~DY + ^XG graphic upload/recall', () => {
`^XA^FO0,0^XG${PATH}.GRF,1,1^FS^XZ`;
const { objects, importReport } = parseZPL(zpl, 8);
expect(objects).toHaveLength(1);
expect(props(objects[0]).storedAs).toEqual({ device: 'R', name: 'LOGO' });
expect(props(objects[0]).storedAs).toEqual({ device: 'R', name: 'LOGO', embedInZpl: true });
expect(importReport.partial).not.toContain('~DY');
});
});
Expand Down
53 changes: 34 additions & 19 deletions src/lib/zplParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1468,41 +1468,56 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL {
// ── Recall stored graphic ──────────────────────────────────────────────
XG(_, rest) {
// ^XGd:f.x,mx,my — references a graphic uploaded earlier via ~DY.
// We look the path up in downloadedGraphics; if found, instantiate an
// image object at the current ^FO/^FT and tag it with storedAs so
// re-emit produces the same upload+recall pair.
// Two valid imports:
// - With preceding ~DY in the stream: full image (bytes + storedAs
// with embedInZpl=true) so re-emit produces the same upload+recall.
// - Without ~DY: the printer is assumed to host the file out-of-band
// (admin pre-loaded). Object gets storedAs.embedInZpl=false and
// no cached bitmap; the canvas falls back to a placeholder, the
// emitter skips the ~DY preamble but keeps the ^XG reference.
const firstComma = rest.indexOf(",");
const xgPath = firstComma === -1 ? rest : rest.slice(0, firstComma);
const surfaceXgFailure = (): void => {
skipped.push(`^XG${rest}`);
browserLimit.push(`^XG${rest}`);
};
// ^XG omitting the `.GRF` suffix is valid ZPL (Labelary accepts
// `^XGR:LOGO,…` for an upload stored as `R:LOGO.GRF`). Normalise
// through the storage-path helpers so the map lookup matches the
// canonical key the ~DY side wrote.
const parsed = parseStoragePath(xgPath);
if (!parsed) {
surfaceXgFailure();
skipped.push(`^XG${rest}`);
browserLimit.push(`^XG${rest}`);
return;
}
const uploaded = downloadedGraphics.get(formatStoragePath(parsed, true));
if (!uploaded) {
surfaceXgFailure();
const posType: "FT" | "FO" = positionIsFT ? "FT" : "FO";
if (uploaded) {
objects.push(
makeObj(
"image",
x,
y,
{
imageId: uploaded.imageId,
widthDots: uploaded.widthDots,
threshold: 128,
_gfaCache: uploaded.gfaCache,
storedAs: { ...parsed, embedInZpl: true },
} satisfies ImageProps,
posType,
takeComment(),
),
);
return;
}
const posType: "FT" | "FO" = positionIsFT ? "FT" : "FO";
// Recall-only: no bytes available, but the ZPL is valid and the
// printer side is assumed to resolve. Surface as partial so the
// import report flags the degraded preview.
partialCmds.add("^XG");
objects.push(
makeObj(
"image",
x,
y,
{
imageId: uploaded.imageId,
widthDots: uploaded.widthDots,
imageId: "",
widthDots: 200,
threshold: 128,
_gfaCache: uploaded.gfaCache,
storedAs: parsed,
storedAs: { ...parsed, embedInZpl: false },
} satisfies ImageProps,
posType,
takeComment(),
Expand Down
8 changes: 8 additions & 0 deletions src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,12 +295,20 @@ const ar = {
image: {
source: 'مصدر الصورة',
selectImage: 'اختر صورة…',
removeFromCache: 'إزالة من الذاكرة المؤقتة',
removeFromCacheConfirm: 'إزالة هذه الصورة من الذاكرة المؤقتة المحلية؟ ستصبح غير متاحة لجميع الملصقات التي تشير إليها.',
upload: 'رفع صورة',
uploading: 'جارٍ الرفع…',
uploadError: 'تعذر تحميل الصورة',
preview: 'معاينة',
widthDots: 'العرض (نقاط)',
threshold: 'عتبة أحادية',
storage: 'تخزين الطابعة',
storeOnPrinter: 'تفعيل',
storeOnPrinterHint: 'يشير إلى رسم مخزن في الطابعة عبر ^XG. افتراضيًا تُرسل البايتات مرة عبر ~DY ليكون العمل قائمًا بذاته؛ أوقفه عندما يكون الملف موجودًا بالفعل على الطابعة (رفع من المدير).',
embedInZpl: 'إرسال البايتات',
embedInZplHint: 'إيقاف = الطابعة لديها الملف بالفعل؛ يتم إرسال ^XG فقط. تشغيل = البايتات تُرسل مع كل مهمة عبر ~DY (افتراضي).',
storeInline: 'تضمين مباشر',
},
upca: {
content: 'المحتوى (11 رقم)',
Expand Down
8 changes: 8 additions & 0 deletions src/locales/bg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,12 +295,20 @@ const bg = {
image: {
source: 'Източник на изображение',
selectImage: 'Изберете изображение…',
removeFromCache: 'Премахни от кеша',
removeFromCacheConfirm: 'Премахни това изображение от локалния кеш? Ще стане недостъпно за всички етикети, които го реферират.',
upload: 'Качване на изображение',
uploading: 'Качване…',
uploadError: 'Изображението не може да бъде заредено',
preview: 'Преглед',
widthDots: 'Ширина (точки)',
threshold: 'Моно праг',
storage: 'Памет на принтера',
storeOnPrinter: 'Активирай',
storeOnPrinterHint: 'Реферира графика, съхранена в принтера, чрез ^XG. По подразбиране байтовете се изпращат веднъж чрез ~DY, така че заданието е самодостатъчно; изключи, когато файлът вече е на принтера (качен от администратор).',
embedInZpl: 'Изпрати байтове',
embedInZplHint: 'Изкл = принтерът вече има файла; излъчва се само ^XG. Вкл = байтовете се изпращат с всяка задача чрез ~DY (по подразбиране).',
storeInline: 'Вграждане',
},
upca: {
content: 'Съдържание (11 цифри)',
Expand Down
8 changes: 8 additions & 0 deletions src/locales/cs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,12 +295,20 @@ const cs = {
image: {
source: 'Zdroj obrázku',
selectImage: 'Vybrat obrázek…',
removeFromCache: 'Odstranit z mezipaměti',
removeFromCacheConfirm: 'Odebrat tento obrázek z místní mezipaměti? Bude nedostupný pro všechny štítky, které na něj odkazují.',
upload: 'Nahrát obrázek',
uploading: 'Nahrávání…',
uploadError: 'Obrázek nelze načíst',
preview: 'Náhled',
widthDots: 'Šířka (body)',
threshold: 'Mono práh',
storage: 'Úložiště tiskárny',
storeOnPrinter: 'Aktivovat',
storeOnPrinterHint: 'Odkazuje na grafiku uloženou v tiskárně pomocí ^XG. Ve výchozím nastavení se bajty odešlou jednou pomocí ~DY, takže úloha je samostatná; vypněte, pokud je soubor již v tiskárně (nahrál administrátor).',
embedInZpl: 'Odeslat bajty',
embedInZplHint: 'Vyp = tiskárna soubor již má; emituje se pouze ^XG. Zap = bajty se odesílají s každou úlohou pomocí ~DY (výchozí).',
storeInline: 'Vložit přímo',
},
upca: {
content: 'Obsah (11 číslic)',
Expand Down
8 changes: 8 additions & 0 deletions src/locales/da.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,12 +295,20 @@ const da = {
image: {
source: 'Billedkilde',
selectImage: 'Vælg billede…',
removeFromCache: 'Fjern fra cache',
removeFromCacheConfirm: 'Fjern dette billede fra den lokale cache? Det bliver utilgængeligt for alle etiketter, der refererer til det.',
upload: 'Upload billede',
uploading: 'Uploader…',
uploadError: 'Kunne ikke indlæse billede',
preview: 'Forhåndsvisning',
widthDots: 'Bredde (punkter)',
threshold: 'Mono-tærskel',
storage: 'Printer-lager',
storeOnPrinter: 'Aktivér',
storeOnPrinterHint: 'Refererer en grafik gemt på printeren via ^XG. Som standard sendes bytes én gang via ~DY, så jobbet er selvbærende; slå fra når filen allerede er på printeren (administrator-upload).',
embedInZpl: 'Send bytes',
embedInZplHint: 'Fra = printeren har allerede filen; kun ^XG udsendes. Til = bytes sendes med hvert job via ~DY (standard).',
storeInline: 'Indlejr direkte',
},
upca: {
content: 'Indhold (11 cifre)',
Expand Down
8 changes: 8 additions & 0 deletions src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,12 +316,20 @@ const de = {
image: {
source: 'Bildquelle',
selectImage: 'Bild auswählen…',
removeFromCache: 'Aus Cache entfernen',
removeFromCacheConfirm: 'Dieses Bild aus dem lokalen Cache entfernen? Es wird für alle Etiketten unverfügbar, die es referenzieren.',
upload: 'Bild hochladen',
uploading: 'Hochladen…',
uploadError: 'Bild konnte nicht geladen werden',
preview: 'Vorschau',
widthDots: 'Breite (Punkte)',
threshold: 'Mono-Schwellenwert',
storage: 'Drucker-Speicher',
storeOnPrinter: 'Aktivieren',
storeOnPrinterHint: 'Referenziert eine Grafik auf der Drucker-Storage via ^XG. Standardmäßig werden die Bytes einmal via ~DY mitgesendet, sodass der Job autark ist; ausschalten wenn die Datei schon auf dem Drucker liegt (Admin-Upload).',
embedInZpl: 'Bytes mitsenden',
embedInZplHint: 'Aus = Drucker hat die Datei bereits; es wird nur ^XG emittiert. An = Bytes werden mit jedem Druckjob via ~DY mitgesendet (Standard).',
storeInline: 'Inline einbetten',
},
upca: {
content: 'Inhalt (11 Ziffern)',
Expand Down
Loading