Skip to content

Commit c55f1c4

Browse files
committed
feat(image): printer-storage UI for ~DY+^XG upload/recall
Macht das ~DY+^XG-Feature aus PR #84 für neue Bilder im Designer bedienbar — bisher griff es nur beim Import existierender Labels mit upload+recall. Properties-Panel-Section am Ende der Image-Properties, mit konsistentem 'Drucker-Speicher'-Header (Info-Icon, border-t-Trennung) der in beiden States sichtbar ist: - Off-State: 'Aktivieren'-Button. Toggle-on setzt storedAs auf { device: 'R', name: 'IMG_xxxx' } mit auto-generiertem 4-char-UUID. - On-State: Device-Dropdown (R/E/B/A mit Klartext), Name-Input (max 8 Zeichen, auto-uppercase, [A-Z0-9_]-Filter, empty-Guard gegen broken ZPL), Path-Preview, 'Bytes mitsenden'-Checkbox + Hint (entkoppelt Upload von Recall: off = nur ^XG, on = ~DY+^XG), 'Inline einbetten'-Button als Toggle-off. Parser ergänzt: ^XG ohne preceding ~DY ist jetzt valider Import (recall-only). Erzeugt Image-Objekt mit storedAs.embedInZpl=false und ohne gecachte Bytes; partial-Finding flagged die degradierte Preview. Image-Cache löschen: Trash-Button neben Bildquelle-Dropdown öffnet einen ConfirmDialog (destructive, autoFocus auf Cancel) der erklärt dass das Bild für ALLE referenzierenden Etiketten verschwindet. Vorher konnte man nur das Image-Objekt löschen (Del-Taste), nicht die Bytes selbst. Canvas-Resize via Griffe: image-Registry hatte kein commitTransform — Konva applied sx/sy als visual scale, aber nichts übersetzte das in widthDots. Jetzt: cached PNG = aspect-locked via dominantem Axis- Drag (Math.abs-Heuristik damit alle 8 Handles inward+outward funktionieren); kein Cache (Placeholder) = free-form via heightDots sodass der User die Box als Layout-Platzhalter shapen kann. Empty-Source-Selection: handleImageSelect hatte early-return bei leerem imageId. Die 'Bild auswählen…'-Placeholder-Option war funktional tot. Jetzt clearet sie imageId + _gfaCache. Canvas-Placeholder: 🖼-Emoji ersetzt durch Konva.Path mit dem Heroicons 'photo'-outline. Emoji-Rendering war OS-abhängig. Shared infrastructure die im Prozess entstanden ist: - src/lib/storagePath.ts erweitert um STORAGE_DEVICES, StorageDevice, MAX_STORAGE_NAME_LEN, STORAGE_NAME_FILTER_RE, defaultStorageName — alle Storage-Path-Begriffe in einer kanonischen Heimat. - src/components/Properties/styles.ts: neuer buttonCls für Secondary-Action-Buttons (Upload, Toggle, Inline einbetten), Tailwind-Duplikation eliminiert (vorher 4× derselbe String in image.tsx und text.tsx). ZPL-Codes (^XG, ~DY) ausschließlich in Tooltips, nicht in sichtbaren Labels. Neue Locale-Keys (storage, storeOnPrinter, storeOnPrinterHint, storeInline, embedInZpl, embedInZplHint, removeFromCache, removeFromCacheConfirm) über alle 32 Sprachen gepflegt.
1 parent 3e9a321 commit c55f1c4

41 files changed

Lines changed: 597 additions & 67 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎src/components/Canvas/ImageObject.tsx‎

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { useState, useEffect, useRef } from "react";
2-
import { Group, Image as KImage, Rect, Text } from "react-konva";
1+
import { useState, useEffect, useRef, type ReactElement } from "react";
2+
import { Group, Image as KImage, Path, Rect } from "react-konva";
33
import type Konva from "konva";
44
import type { LabelObject } from "../../types/Group";
55
import { dotsToPx, pxToDots } from "../../lib/coordinates";
@@ -29,12 +29,13 @@ export function ImageObject({
2929
const colors = useColorScheme();
3030
const cached = getImage(p.imageId);
3131
const w = dotsToPx(p.widthDots, scale, dpmm);
32-
// Guard against a 0-width cached image: the imageCache pipeline
33-
// doesn't normally produce one, but a malformed file could leak
34-
// through and div-by-zero would render NaN-sized canvas nodes.
32+
// Aspect-lock when a real PNG is cached; fall back to `heightDots` for
33+
// recall-only placeholders so the user can shape the box freely. Guard
34+
// against 0-width cached images (malformed file edge case) — div-by-
35+
// zero would otherwise render NaN-sized canvas nodes.
3536
const h = cached && cached.width > 0
3637
? w * (cached.height / cached.width)
37-
: w;
38+
: dotsToPx(p.heightDots ?? p.widthDots, scale, dpmm);
3839
const x = offsetX + dotsToPx(obj.x, scale, dpmm);
3940
const y = offsetY + dotsToPx(obj.y, scale, dpmm);
4041

@@ -120,13 +121,37 @@ export function ImageObject({
120121
strokeWidth={isSelected ? 2 : 1}
121122
dash={[4, 2]}
122123
/>
123-
<Text
124-
x={6}
125-
y={6}
126-
text="🖼"
127-
fontSize={Math.max(w * 0.3, 12)}
128-
fill="#374151"
129-
/>
124+
<PlaceholderIcon w={w} h={h} />
130125
</Group>
131126
);
132127
}
128+
129+
/** Heroicons "photo" outline rendered via Konva.Path so the placeholder
130+
* is OS-independent. We previously used the 🖼 emoji which rendered
131+
* inconsistently (color on macOS, monochrome on Linux, missing on
132+
* some Windows configurations). */
133+
const PHOTO_ICON_PATH =
134+
"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";
135+
const PHOTO_ICON_VIEWBOX = 24;
136+
137+
function PlaceholderIcon({ w, h }: { w: number; h: number }): ReactElement {
138+
// Centre the icon at 60% of the smaller dimension; clamp so a thin
139+
// image strip still shows something visible. Stroke-only mirrors
140+
// Heroicons' outline style.
141+
const target = Math.max(Math.min(w, h) * 0.6, 12);
142+
const scale = target / PHOTO_ICON_VIEWBOX;
143+
return (
144+
<Path
145+
data={PHOTO_ICON_PATH}
146+
x={(w - target) / 2}
147+
y={(h - target) / 2}
148+
scaleX={scale}
149+
scaleY={scale}
150+
stroke="#6b7280"
151+
strokeWidth={1.5 / scale}
152+
fillEnabled={false}
153+
lineCap="round"
154+
lineJoin="round"
155+
/>
156+
);
157+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,6 @@
11
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';
22
export const labelCls = 'font-mono text-[10px] text-muted uppercase tracking-wider';
3+
/** Secondary-action button: file upload, toggle row, etc. Matches the
4+
* surface-2 + border styling used by `inputCls` so buttons sit naturally
5+
* next to form fields without dominating the visual hierarchy. */
6+
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';

‎src/lib/storagePath.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,27 @@
88
* drifting apart across the parser, emitter, and image registry.
99
*/
1010

11+
/** Storage device prefixes Zebra firmware recognises. R: volatile RAM
12+
* (default, fastest); E: non-volatile flash; B: alternate flash;
13+
* A: alias drive on some models. All four round-trip through `~DY` and
14+
* `^XG`; the parser accepts them and the UI exposes them in the device
15+
* picker. */
16+
export const STORAGE_DEVICES = ["R", "E", "B", "A"] as const;
17+
export type StorageDevice = (typeof STORAGE_DEVICES)[number];
18+
19+
/** Zebra DOS-style filename: up to 8 chars, uppercase alphanumeric +
20+
* underscore. Both constants are exported so input filters in the UI
21+
* and the parser stay in lockstep. */
22+
export const MAX_STORAGE_NAME_LEN = 8;
23+
export const STORAGE_NAME_FILTER_RE = /[^A-Z0-9_]/g;
24+
25+
/** Default name when the user first enables printer-storage on an image.
26+
* Short UUID slice avoids collisions across multiple images on the same
27+
* label without forcing the user to pick a name up front. */
28+
export function defaultStorageName(): string {
29+
return `IMG_${crypto.randomUUID().slice(0, 4).toUpperCase()}`;
30+
}
31+
1132
export interface StoragePath {
1233
/** Storage device prefix without trailing colon: "R", "E", "B", "A". */
1334
device: string;

‎src/lib/zplGenerator.test.ts‎

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, it, expect } from 'vitest';
2+
import { zlibSync } from 'fflate';
23
import { generateZPL, generateMultiPageZPL } from './zplGenerator';
34
import { parseZPL } from './zplParser';
45
import type { LabelConfig } from '../types/ObjectType';
@@ -573,21 +574,27 @@ describe('generateZPL — ~DY graphic upload + ^XG recall', () => {
573574
// Zebra firmware rejects format A with a :Z64: payload. The shared
574575
// cache uses ^GF{format} so the format letter survives both the GF
575576
// and DY round-trip paths.
576-
const z64Payload = ':Z64:eJxjYAACBAAACgAB:1234';
577+
const bytes = new Uint8Array([0, 0xff, 0xff, 0]);
578+
const deflated = zlibSync(bytes);
579+
let bin = '';
580+
for (const b of deflated) bin += String.fromCharCode(b);
581+
const b64 = btoa(bin);
582+
function crc(s: string): string {
583+
let c = 0;
584+
for (const ch of s) {
585+
c ^= ch.charCodeAt(0) << 8;
586+
for (let j = 0; j < 8; j++)
587+
c = (c & 0x8000) ? ((c << 1) ^ 0x1021) & 0xffff : (c << 1) & 0xffff;
588+
}
589+
return c.toString(16).padStart(4, '0').toUpperCase();
590+
}
577591
const zpl =
578-
`~DYR:CLOGO,C,G,4,1,${z64Payload}\n` +
592+
`~DYR:CLOGO,C,G,4,1,:Z64:${b64}:${crc(b64)}\n` +
579593
`^XA^FO0,0^XGR:CLOGO.GRF,1,1^FS^XZ`;
580594
const parsed = parseZPL(zpl, 8);
581-
// ^XG only resolves if ~DY was registered; in this case the Z64
582-
// stream is malformed (junk base64) so the upload fails — the test
583-
// is then about the parser surfacing that as browserLimit instead
584-
// of mis-pairing format letters. We accept either path; if it does
585-
// round-trip, the format letter must be C.
586-
if (parsed.objects.length > 0) {
587-
const out = generateZPL(BASE_LABEL, parsed.objects);
588-
expect(out).toContain('~DYR:CLOGO,C,G,');
589-
expect(out).not.toContain('~DYR:CLOGO,A,G,');
590-
}
595+
const out = generateZPL(BASE_LABEL, parsed.objects);
596+
expect(out).toContain('~DYR:CLOGO,C,G,');
597+
expect(out).not.toContain('~DYR:CLOGO,A,G,');
591598
});
592599

593600
it('deduplicates the ~DY preamble when the same upload is referenced twice', () => {

‎src/lib/zplGenerator.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,10 @@ export function generateZPL(label: LabelConfig, objects: LabelObject[]): string
113113
if (obj.type !== 'image') continue;
114114
const p = obj.props as ImageProps;
115115
if (!p.storedAs || !p._gfaCache) continue;
116+
// Recall-only mode: the printer already has the bytes (admin uploaded
117+
// them out-of-band), so skip the ~DY preamble. Image stays a preview
118+
// in the designer; ZPL output only carries the ^XG references.
119+
if (p.storedAs.embedInZpl === false) continue;
116120
const key = formatStoragePath(p.storedAs, false);
117121
if (seenGraphics.has(key)) continue;
118122
seenGraphics.add(key);

‎src/lib/zplParser.test.ts‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -676,7 +676,7 @@ describe('parseZPL — ~DY + ^XG graphic upload/recall', () => {
676676
expect(objects).toHaveLength(1);
677677
expect(objects[0]?.type).toBe('image');
678678
expect(props(objects[0]).widthDots).toBe(8);
679-
expect(props(objects[0]).storedAs).toEqual({ device: 'R', name: 'LOGO' });
679+
expect(props(objects[0]).storedAs).toEqual({ device: 'R', name: 'LOGO', embedInZpl: true });
680680
expect(objects[0]?.x).toBe(50);
681681
expect(objects[0]?.y).toBe(80);
682682
expect(importReport.browserLimit).toHaveLength(0);
@@ -690,15 +690,22 @@ describe('parseZPL — ~DY + ^XG graphic upload/recall', () => {
690690
`^XA^FO50,80^XGR:LOGO,1,1^FS^XZ`;
691691
const { objects, importReport } = parseZPL(zpl, 8);
692692
expect(objects).toHaveLength(1);
693-
expect(props(objects[0]).storedAs).toEqual({ device: 'R', name: 'LOGO' });
693+
expect(props(objects[0]).storedAs).toEqual({ device: 'R', name: 'LOGO', embedInZpl: true });
694694
expect(importReport.browserLimit).toHaveLength(0);
695695
});
696696

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

704711
it('accepts :Z64:-wrapped graphic payloads in ~DY (format C)', () => {
@@ -709,7 +716,7 @@ describe('parseZPL — ~DY + ^XG graphic upload/recall', () => {
709716
`^XA^FO0,0^XG${PATH}.GRF,1,1^FS^XZ`;
710717
const { objects, importReport } = parseZPL(zpl, 8);
711718
expect(objects).toHaveLength(1);
712-
expect(props(objects[0]).storedAs).toEqual({ device: 'R', name: 'LOGO' });
719+
expect(props(objects[0]).storedAs).toEqual({ device: 'R', name: 'LOGO', embedInZpl: true });
713720
expect(importReport.partial).not.toContain('~DY');
714721
});
715722
});

‎src/lib/zplParser.ts‎

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1468,41 +1468,56 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL {
14681468
// ── Recall stored graphic ──────────────────────────────────────────────
14691469
XG(_, rest) {
14701470
// ^XGd:f.x,mx,my — references a graphic uploaded earlier via ~DY.
1471-
// We look the path up in downloadedGraphics; if found, instantiate an
1472-
// image object at the current ^FO/^FT and tag it with storedAs so
1473-
// re-emit produces the same upload+recall pair.
1471+
// Two valid imports:
1472+
// - With preceding ~DY in the stream: full image (bytes + storedAs
1473+
// with embedInZpl=true) so re-emit produces the same upload+recall.
1474+
// - Without ~DY: the printer is assumed to host the file out-of-band
1475+
// (admin pre-loaded). Object gets storedAs.embedInZpl=false and
1476+
// no cached bitmap; the canvas falls back to a placeholder, the
1477+
// emitter skips the ~DY preamble but keeps the ^XG reference.
14741478
const firstComma = rest.indexOf(",");
14751479
const xgPath = firstComma === -1 ? rest : rest.slice(0, firstComma);
1476-
const surfaceXgFailure = (): void => {
1477-
skipped.push(`^XG${rest}`);
1478-
browserLimit.push(`^XG${rest}`);
1479-
};
1480-
// ^XG omitting the `.GRF` suffix is valid ZPL (Labelary accepts
1481-
// `^XGR:LOGO,…` for an upload stored as `R:LOGO.GRF`). Normalise
1482-
// through the storage-path helpers so the map lookup matches the
1483-
// canonical key the ~DY side wrote.
14841480
const parsed = parseStoragePath(xgPath);
14851481
if (!parsed) {
1486-
surfaceXgFailure();
1482+
skipped.push(`^XG${rest}`);
1483+
browserLimit.push(`^XG${rest}`);
14871484
return;
14881485
}
14891486
const uploaded = downloadedGraphics.get(formatStoragePath(parsed, true));
1490-
if (!uploaded) {
1491-
surfaceXgFailure();
1487+
const posType: "FT" | "FO" = positionIsFT ? "FT" : "FO";
1488+
if (uploaded) {
1489+
objects.push(
1490+
makeObj(
1491+
"image",
1492+
x,
1493+
y,
1494+
{
1495+
imageId: uploaded.imageId,
1496+
widthDots: uploaded.widthDots,
1497+
threshold: 128,
1498+
_gfaCache: uploaded.gfaCache,
1499+
storedAs: { ...parsed, embedInZpl: true },
1500+
} satisfies ImageProps,
1501+
posType,
1502+
takeComment(),
1503+
),
1504+
);
14921505
return;
14931506
}
1494-
const posType: "FT" | "FO" = positionIsFT ? "FT" : "FO";
1507+
// Recall-only: no bytes available, but the ZPL is valid and the
1508+
// printer side is assumed to resolve. Surface as partial so the
1509+
// import report flags the degraded preview.
1510+
partialCmds.add("^XG");
14951511
objects.push(
14961512
makeObj(
14971513
"image",
14981514
x,
14991515
y,
15001516
{
1501-
imageId: uploaded.imageId,
1502-
widthDots: uploaded.widthDots,
1517+
imageId: "",
1518+
widthDots: 200,
15031519
threshold: 128,
1504-
_gfaCache: uploaded.gfaCache,
1505-
storedAs: parsed,
1520+
storedAs: { ...parsed, embedInZpl: false },
15061521
} satisfies ImageProps,
15071522
posType,
15081523
takeComment(),

‎src/locales/ar.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,12 +295,20 @@ const ar = {
295295
image: {
296296
source: 'مصدر الصورة',
297297
selectImage: 'اختر صورة…',
298+
removeFromCache: 'إزالة من الذاكرة المؤقتة',
299+
removeFromCacheConfirm: 'إزالة هذه الصورة من الذاكرة المؤقتة المحلية؟ ستصبح غير متاحة لجميع الملصقات التي تشير إليها.',
298300
upload: 'رفع صورة',
299301
uploading: 'جارٍ الرفع…',
300302
uploadError: 'تعذر تحميل الصورة',
301303
preview: 'معاينة',
302304
widthDots: 'العرض (نقاط)',
303305
threshold: 'عتبة أحادية',
306+
storage: 'تخزين الطابعة',
307+
storeOnPrinter: 'تفعيل',
308+
storeOnPrinterHint: 'يشير إلى رسم مخزن في الطابعة عبر ^XG. افتراضيًا تُرسل البايتات مرة عبر ~DY ليكون العمل قائمًا بذاته؛ أوقفه عندما يكون الملف موجودًا بالفعل على الطابعة (رفع من المدير).',
309+
embedInZpl: 'إرسال البايتات',
310+
embedInZplHint: 'إيقاف = الطابعة لديها الملف بالفعل؛ يتم إرسال ^XG فقط. تشغيل = البايتات تُرسل مع كل مهمة عبر ~DY (افتراضي).',
311+
storeInline: 'تضمين مباشر',
304312
},
305313
upca: {
306314
content: 'المحتوى (11 رقم)',

‎src/locales/bg.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,12 +295,20 @@ const bg = {
295295
image: {
296296
source: 'Източник на изображение',
297297
selectImage: 'Изберете изображение…',
298+
removeFromCache: 'Премахни от кеша',
299+
removeFromCacheConfirm: 'Премахни това изображение от локалния кеш? Ще стане недостъпно за всички етикети, които го реферират.',
298300
upload: 'Качване на изображение',
299301
uploading: 'Качване…',
300302
uploadError: 'Изображението не може да бъде заредено',
301303
preview: 'Преглед',
302304
widthDots: 'Ширина (точки)',
303305
threshold: 'Моно праг',
306+
storage: 'Памет на принтера',
307+
storeOnPrinter: 'Активирай',
308+
storeOnPrinterHint: 'Реферира графика, съхранена в принтера, чрез ^XG. По подразбиране байтовете се изпращат веднъж чрез ~DY, така че заданието е самодостатъчно; изключи, когато файлът вече е на принтера (качен от администратор).',
309+
embedInZpl: 'Изпрати байтове',
310+
embedInZplHint: 'Изкл = принтерът вече има файла; излъчва се само ^XG. Вкл = байтовете се изпращат с всяка задача чрез ~DY (по подразбиране).',
311+
storeInline: 'Вграждане',
304312
},
305313
upca: {
306314
content: 'Съдържание (11 цифри)',

‎src/locales/cs.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,12 +295,20 @@ const cs = {
295295
image: {
296296
source: 'Zdroj obrázku',
297297
selectImage: 'Vybrat obrázek…',
298+
removeFromCache: 'Odstranit z mezipaměti',
299+
removeFromCacheConfirm: 'Odebrat tento obrázek z místní mezipaměti? Bude nedostupný pro všechny štítky, které na něj odkazují.',
298300
upload: 'Nahrát obrázek',
299301
uploading: 'Nahrávání…',
300302
uploadError: 'Obrázek nelze načíst',
301303
preview: 'Náhled',
302304
widthDots: 'Šířka (body)',
303305
threshold: 'Mono práh',
306+
storage: 'Úložiště tiskárny',
307+
storeOnPrinter: 'Aktivovat',
308+
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).',
309+
embedInZpl: 'Odeslat bajty',
310+
embedInZplHint: 'Vyp = tiskárna soubor již má; emituje se pouze ^XG. Zap = bajty se odesílají s každou úlohou pomocí ~DY (výchozí).',
311+
storeInline: 'Vložit přímo',
304312
},
305313
upca: {
306314
content: 'Obsah (11 číslic)',

0 commit comments

Comments
 (0)