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
12 changes: 8 additions & 4 deletions src/components/Output/ImportSummary.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describeFinding } from '../../lib/importReport';
import type { ImportResult } from '../../lib/importReport';
import type { ImportFinding, ImportFindingKind } from '@zplab/core/lib/importReport';
import { useT } from '../../hooks/useT';

export type { ImportResult };

Expand All @@ -26,12 +27,13 @@ const TONE: Record<ImportFindingKind, string> = {
};

export function FindingRow({ finding, showPage }: { finding: ImportFinding; showPage: boolean }) {
const { title, detail } = describeFinding(finding);
const t = useT();
const { title, detail } = describeFinding(finding, t.importReport);
return (
<div className="flex items-start gap-2 px-3 py-2">
{showPage && (
<span className="font-mono text-[9px] uppercase tracking-wider text-muted shrink-0 mt-0.5">
Page&nbsp;{finding.pageIndex + 1}
{t.importReport.pageFmt.replace('{n}', String(finding.pageIndex + 1))}
</span>
)}
<div className="flex flex-col gap-0.5 flex-1 min-w-0">
Expand All @@ -47,6 +49,7 @@ export function FindingRow({ finding, showPage }: { finding: ImportFinding; show
}

export function ImportSummaryBody({ result }: { result: ImportResult }) {
const t = useT();
const { objectCount, report } = result;
const { findings } = report;
// Only show per-row page badges when the import actually spanned multiple
Expand All @@ -56,8 +59,9 @@ export function ImportSummaryBody({ result }: { result: ImportResult }) {
return (
<div className="flex flex-col gap-3 p-4 flex-1 min-h-0 overflow-y-auto">
<p className="font-mono text-[10px] text-amber-400 leading-relaxed">
Imported {objectCount} object{objectCount !== 1 ? 's' : ''} with{' '}
{findings.length} issue{findings.length !== 1 ? 's' : ''}:
{t.importModal.summaryFmt
.replace('{n}', String(objectCount))
.replace('{m}', String(findings.length))}
</p>
<div className="flex flex-col">
{findings.map((f, i) => (
Expand Down
31 changes: 17 additions & 14 deletions src/components/Output/ZplImportModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export function ZplImportModal({ onClose }: Props) {
Object.keys(labelConfig).length === 0 &&
Object.keys(printerProfile).length === 0
) {
setError('No supported objects found in the ZPL code.');
setError(t.importModal.errNoObjects);
return;
}
if (replayRiskFindings(imported.report).length > 0) {
Expand All @@ -125,7 +125,7 @@ export function ZplImportModal({ onClose }: Props) {
const handleImport = () => {
setError(null);
if (!zpl.trim()) {
setError('Please paste some ZPL code first.');
setError(t.importModal.errPasteFirst);
return;
}
processImport(zpl);
Expand All @@ -141,20 +141,20 @@ export function ZplImportModal({ onClose }: Props) {
try {
text = await readFileAsText(file);
} catch {
setError('Could not read the file.');
setError(t.importModal.errFileRead);
return;
}

if (!text.trim()) {
setError('The file appears to be empty.');
setError(t.importModal.errFileEmpty);
return;
}
processImport(text);
};

const handleCopy = () => {
if (!result) return;
void copyText(formatReportAsText(result)).then((r) => {
void copyText(formatReportAsText(result, t.importReport)).then((r) => {
if (r !== 'copied') return;
setCopied(true);
setTimeout(() => setCopied(false), 1500);
Expand Down Expand Up @@ -197,8 +197,8 @@ export function ZplImportModal({ onClose }: Props) {
className="flex items-center gap-1.5 font-mono text-[10px] text-muted hover:text-text transition-colors"
>
{copied
? <><CheckIcon className="w-3.5 h-3.5" /> Copied</>
: <><ClipboardDocumentIcon className="w-3.5 h-3.5" /> Copy report</>}
? <><CheckIcon className="w-3.5 h-3.5" /> {t.importModal.copied}</>
: <><ClipboardDocumentIcon className="w-3.5 h-3.5" /> {t.importModal.copyReport}</>}
</button>
<button
onClick={onClose}
Expand All @@ -212,12 +212,15 @@ export function ZplImportModal({ onClose }: Props) {
<>
<div className="flex flex-col gap-3 p-4 flex-1 min-h-0">
<p className="font-mono text-[10px] text-muted leading-relaxed">
Import produces an{' '}
<span className="text-amber-400">editable reconstruction</span>
, not an exact replica. Simple labels import cleanly; complex or
machine-generated ZPL may lose fidelity. Use{' '}
<span className="text-text">Save design (.json)</span> as the
lossless source format.
{t.importModal.intro.split(/(\{recon\}|\{save\})/).map((part, i) =>
part === '{recon}' ? (
<span key={i} className="text-amber-400">{t.importModal.introRecon}</span>
) : part === '{save}' ? (
<span key={i} className="text-text">{t.importModal.introSave}</span>
) : (
part
),
)}
</p>
<textarea
className="flex-1 min-h-60 bg-surface-2 border border-border rounded px-3 py-2 font-mono text-xs text-text focus:border-accent focus:outline-none resize-none"
Expand Down Expand Up @@ -271,7 +274,7 @@ export function ZplImportModal({ onClose }: Props) {
disabled={!zpl.trim()}
className="px-3 py-1.5 rounded text-xs font-mono bg-accent text-bg hover:opacity-90 disabled:opacity-25 disabled:cursor-not-allowed transition-opacity"
>
Import
{t.importModal.importAction}
</button>
</div>
</div>
Expand Down
80 changes: 37 additions & 43 deletions src/lib/importReport.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
import type { ImportFinding, ImportReport } from '@zplab/core/lib/importReport';
import type { Translations } from '../locales';
import { ZPL_COMMAND_MAP } from './zplCommandSupport';

export interface ImportResult {
objectCount: number;
report: ImportReport;
}

type ReportStrings = Translations['importReport'];

/** ZPL tokens stay out of the locale strings (translator safety); the one
* loss text that names formats gets them interpolated here. */
const LOSS_SUBSTITUTIONS: readonly [string, string][] = [
['{rawFmts}', '^GFB/^GFC'],
['{hexFmt}', '^GFA'],
['{wrapFmts}', ':B64:/:Z64:'],
];

/** Returns the loss description for a partial command code, e.g. "^A@" → font face note. */
function partialLoss(cmd: string): string {
function partialLoss(cmd: string, tr: ReportStrings): string {
const key = cmd.slice(1);
const entry = ZPL_COMMAND_MAP.get(key) ?? (key[0] === 'A' ? ZPL_COMMAND_MAP.get('A@') : undefined);
return entry?.loss ?? 'imported with limitations';
if (!entry?.loss) return tr.partialFallback;
return LOSS_SUBSTITUTIONS.reduce((s, [m, v]) => s.replace(m, v), tr[entry.loss]);
}

/**
Expand All @@ -23,88 +35,70 @@ function partialLoss(cmd: string): string {
* `detail` is the secondary line (loss description for partial, raw
* token for the others).
*/
export function describeFinding(f: ImportFinding): { title: string; detail: string } {
export function describeFinding(
f: ImportFinding,
tr: ReportStrings,
): { title: string; detail: string } {
if (f.kind === 'partial') {
return {
title: `Partially imported (${f.command})`,
detail: partialLoss(f.command),
title: tr.partialTitleFmt.replace('{cmd}', f.command),
detail: partialLoss(f.command, tr),
};
}
if (f.kind === 'browserLimit') {
return {
title: 'Skipped: needs printer hardware',
detail: f.command,
};
return { title: tr.browserLimitTitle, detail: f.command };
}
if (f.kind === 'replayRisk') {
return {
title: 'Printer setup command: runs on the printer when exported/printed',
detail: f.command,
};
return { title: tr.replayRiskTitle, detail: f.command };
}
if (f.kind === 'deviceAction') {
return {
title: 'Printer device action: runs on the printer when exported/printed',
detail: f.command,
};
return { title: tr.deviceActionTitle, detail: f.command };
}
if (f.kind === 'lossyEdit') {
return {
title: 'First edit re-emits the whole label (not byte-exact)',
detail: f.command,
};
return { title: tr.lossyEditTitle, detail: f.command };
}
if (f.kind === 'fnRenumbered') {
return {
title: 'Shared ^FN slot with a different default: field moved to a free slot',
detail: f.command,
};
return { title: tr.fnRenumberedTitleFmt.replace('{fn}', '^FN'), detail: f.command };
}
if (f.kind === 'fnDefaultDropped') {
return {
title: "All ^FN slots taken: this field keeps the first page's default",
detail: f.command,
};
return { title: tr.fnDefaultDroppedTitleFmt.replace('{fn}', '^FN'), detail: f.command };
}
if (f.kind === 'mixedPageGeometry') {
return {
title: 'Multiple label sizes: later pages use the first size',
detail: `^PW/^LL differ between blocks (${f.command})`,
title: tr.mixedGeoTitle,
detail: tr.mixedGeoDetailFmt.replace('{cmds}', '^PW/^LL').replace('{detail}', f.command),
};
}
return {
title: 'Skipped: command not recognised',
detail: f.command,
};
return { title: tr.unknownTitle, detail: f.command };
}

/** Compact "Page N: " prefix when a finding originates from a multi-page
* import. Single-page reports omit it to stay terse. */
function pagePrefix(f: ImportFinding, multiPage: boolean): string {
return multiPage ? `Page ${f.pageIndex + 1}: ` : '';
function pagePrefix(f: ImportFinding, multiPage: boolean, pageFmt: string): string {
return multiPage ? `${pageFmt.replace('{n}', String(f.pageIndex + 1))}: ` : '';
}

export function formatReportAsText(result: ImportResult): string {
export function formatReportAsText(result: ImportResult, tr: ReportStrings): string {
const { objectCount, report } = result;
const findings = report.findings;
const multiPage = findings.some((f) => f.pageIndex > 0);

const lines: string[] = [
`ZPL Import Report`,
`Objects imported: ${objectCount}`,
tr.reportHeader,
tr.reportObjectsFmt.replace('{n}', String(objectCount)),
'',
];

if (findings.length === 0) {
lines.push('All commands recognised. No design information was lost.');
lines.push(tr.reportClean);
return lines.join('\n');
}

// One row per finding (per page-occurrence). Matches the UI list so the
// copied text mirrors what the user sees in the modal.
for (const f of findings) {
const { title, detail } = describeFinding(f);
lines.push(`${pagePrefix(f, multiPage)}${title}: ${detail}`);
const { title, detail } = describeFinding(f, tr);
lines.push(`${pagePrefix(f, multiPage, tr.pageFmt)}${title}: ${detail}`);
}
return lines.join('\n');
}
23 changes: 16 additions & 7 deletions src/lib/zplCommandSupport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ type ZplCommandStatus =
| 'browser-limit' // Requires printer hardware / file storage; cannot be used in the browser
| 'unsupported'; // Carries design information but not yet implemented

/** Locale key (importReport block) describing the loss; keys instead of
* prose so the import report renders in the UI language. */
export type ImportLossKey =
| 'lossFontFace'
| 'lossPrinterComms'
| 'lossGfRawBinary'
| 'lossFileStorage'
| 'lossPrinterStorage';

interface ZplCommandInfo {
/** 2-character ZPL command code, uppercase, without the leading ^ or ~ */
cmd: string;
Expand All @@ -22,7 +31,7 @@ interface ZplCommandInfo {
/** Import fidelity status */
status: ZplCommandStatus;
/** What is lost or approximated when status is 'partial' or 'browser-limit' */
loss?: string;
loss?: ImportLossKey;
}

const ZPL_COMMANDS: readonly ZplCommandInfo[] = [
Expand Down Expand Up @@ -63,7 +72,7 @@ const ZPL_COMMANDS: readonly ZplCommandInfo[] = [
{
cmd: 'A@', status: 'partial',
description: 'TrueType/OpenType font reference by device path',
loss: 'Font face is not imported; text content and point size are preserved with best-effort sizing',
loss: 'lossFontFace',
},
{ cmd: 'TB', status: 'supported', description: 'Text block: alternative to ^A + ^FB for wrapped/justified text' },
{ cmd: 'PA', status: 'supported', description: 'Advanced text properties: glyph/bidi/shaping/OpenType flags; round-trips through the Printer Settings setup script' },
Expand All @@ -72,12 +81,12 @@ const ZPL_COMMANDS: readonly ZplCommandInfo[] = [
{
cmd: 'HT', status: 'browser-limit',
description: 'Host linked font list: retrieves font data from printer',
loss: 'Requires printer communication; not available in the browser',
loss: 'lossPrinterComms',
},
{
cmd: 'LF', status: 'browser-limit',
description: 'List font links: retrieves linked font info from printer',
loss: 'Requires printer communication; not available in the browser',
loss: 'lossPrinterComms',
},

// ── Reverse / invert ──────────────────────────────────────────────────────
Expand Down Expand Up @@ -123,7 +132,7 @@ const ZPL_COMMANDS: readonly ZplCommandInfo[] = [
{
cmd: 'GF', status: 'partial',
description: 'Graphic field: embedded monochrome bitmap',
loss: 'Raw binary ^GFB/^GFC payloads are skipped; ^GFA and :B64:/:Z64:-wrapped B/C payloads decode',
loss: 'lossGfRawBinary',
},
{ cmd: 'GS', status: 'supported', description: 'Graphic symbol glyph (registered/copyright/trademark/UL/CSA)' },

Expand Down Expand Up @@ -162,12 +171,12 @@ const ZPL_COMMANDS: readonly ZplCommandInfo[] = [
{
cmd: 'IM', status: 'browser-limit',
description: 'Image recall from printer memory',
loss: 'Cannot access printer file storage from a browser; the field is skipped entirely',
loss: 'lossFileStorage',
},
{
cmd: 'DG', status: 'browser-limit',
description: 'Download graphic to printer storage (~DG)',
loss: 'Stores data on the physical printer; not relevant for canvas label design',
loss: 'lossPrinterStorage',
},

// Templates & batch merge: ^DF/^XF with R: drive path for CSV-driven batch printing.
Expand Down
5 changes: 3 additions & 2 deletions src/lib/zplImportService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { importZplText, routeSetupCommands, mergeSetupFonts } from '@zplab/core/
import { generateSetupScript } from './zplSetupScript';
import { generateMultiPageZPL } from '@zplab/core/lib/zplGenerator';
import { describeFinding } from './importReport';
import { fallbackTranslations } from '../locales';
import { replayRiskFindings, printerCommandFindings, resolveRoutedReport } from '@zplab/core/lib/importReport';
import type { PrinterProfile } from '@zplab/core/types/PrinterProfile';
import type { LabelConfig } from '@zplab/core/types/LabelConfig';
Expand Down Expand Up @@ -83,7 +84,7 @@ describe('importZplText - replay-risk findings', () => {
expect(r.report.replayRisk).toContain('^KN');
const finding = r.report.findings.find((f) => f.kind === 'replayRisk');
expect(finding).toBeDefined();
expect(describeFinding(finding!).detail).toBe('^KN');
expect(describeFinding(finding!, fallbackTranslations.importReport).detail).toBe('^KN');
});

it('does not flag a label without setup commands', () => {
Expand Down Expand Up @@ -686,7 +687,7 @@ describe('routeSetupCommands - setup-only pages', () => {
expect(imported.mixedPageGeometry).toBe(true);
const finding = imported.report.findings.find((f) => f.kind === 'mixedPageGeometry');
expect(finding).toBeDefined();
expect(describeFinding(finding!).title).toMatch(/Multiple label sizes/);
expect(describeFinding(finding!, fallbackTranslations.importReport).title).toMatch(/Multiple label sizes/);
});

it('does not flag a shared explicit size (^PW/^LL persist across ^XA)', () => {
Expand Down
Loading
Loading