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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ On desktop, **Connect data** also reads an Excel worksheet. **File → Settings

- The **Objects** group selects the fonts and graphics the **Setup Script** uploads.
- It lists cached images and deletes the unused ones.
- On desktop it also reads what the printer holds. You can show a stored graphic and delete objects on the printer.
- On desktop it also reads what the printer holds. You can show a stored graphic, let the printer draw a font sample and delete objects on the printer.
- Saved designs and label exports exclude **Setup Script** values such as printer name and locale.
- **Clear** resets the **Setup Script** values and keeps the uploads.

Expand Down
29 changes: 29 additions & 0 deletions packages/core/src/lib/fontSample.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, it, expect } from "vitest";
import { FONT_SAMPLE_TEXT, fontSampleZpl } from "./fontSample";

const media = { widthMm: 100, heightMm: 150, dpmm: 8 };

describe("fontSampleZpl", () => {
it("sets the font from the printer on a label of the media size, with a line budget the text cannot exceed", () => {
expect(fontSampleZpl("E:CG_TIMES.TTF", media)).toBe(`^XA^PW800^LL1200^FO40,40^A@N,60,,E:CG_TIMES.TTF^FB720,${FONT_SAMPLE_TEXT.length},0,L^FD${FONT_SAMPLE_TEXT}^FS^XZ`);
});

it("shrinks margin and size on narrow media so the block still fits a character", () => {
expect(fontSampleZpl("E:A.FNT", { ...media, widthMm: 12 })).toContain("^PW96^LL1200^FO9,9^A@N,24,,E:A.FNT^FB78,");
});

it("keeps the field inside a short label and never asks for a zero size", () => {
expect(fontSampleZpl("E:A.FNT", { ...media, heightMm: 5 })).toContain("^PW800^LL40^FO4,4^A@N,10,,E:A.FNT^FB792,");
expect(fontSampleZpl("E:A.FNT", { widthMm: 1, heightMm: 1, dpmm: 6, jmDensity: "B" })).toContain("^FO0,0^A@N,1,,E:A.FNT^FB3,");
});

it("declares half density and sizes the field in that space while the media keeps head dots", () => {
expect(fontSampleZpl("E:A.FNT", { ...media, jmDensity: "B" })).toContain("^XA^JMB^PW800^LL1200^FO20,20^A@N,30,,E:A.FNT^FB360,");
});

it("never lets a command character into the font slot", () => {
const zpl = fontSampleZpl("E:A^XZ~DG.TTF", media);
expect(zpl).not.toContain("A^XZ");
expect(zpl).not.toContain("~DG");
});
});
29 changes: 29 additions & 0 deletions packages/core/src/lib/fontSample.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { mmToDots } from "./coordinates";
import { stripZplParamChars } from "./zplParams";
import { effectiveDpmm, type JmDensity } from "../types/LabelConfig";

export const FONT_SAMPLE_TEXT = "The quick brown fox jumps over the lazy dog 0123456789";

export interface SampleMedia {
widthMm: number;
heightMm: number;
dpmm: number;
jmDensity?: JmDensity;
}

/** A label that sets one printer font, sized to the media so the firmware renders it like a job. */
export function fontSampleZpl(fontPath: string, media: SampleMedia): string {
// ^PW and ^LL stay in head dots, the field lives in the ^JM-scaled space like every generated object.
const headWidth = mmToDots(media.widthMm, media.dpmm);
const headHeight = mmToDots(media.heightMm, media.dpmm);
const dpmm = effectiveDpmm(media);
const width = mmToDots(media.widthMm, dpmm);
// The shorter edge bounds margin and size, so a short or narrow label still holds the field.
const edge = Math.min(width, mmToDots(media.heightMm, dpmm));
const size = Math.max(1, Math.min(mmToDots(7.5, dpmm), Math.floor(edge / 4)));
const margin = Math.min(mmToDots(5, dpmm), Math.floor(edge / 10));
// Every line past the ^FB budget overprints the last one, so the budget is the text length.
const block = `^FB${Math.max(width - 2 * margin, size)},${FONT_SAMPLE_TEXT.length},0,L`;
const density = media.jmDensity === "B" ? "^JMB" : "";
return `^XA${density}^PW${headWidth}^LL${headHeight}^FO${margin},${margin}^A@N,${size},,${stripZplParamChars(fontPath)}${block}^FD${FONT_SAMPLE_TEXT}^FS^XZ`;
}
Original file line number Diff line number Diff line change
@@ -1,27 +1,24 @@
import type { HostGraphic } from "@zplab/core/lib/hostDirectory";
import { useT } from "../../hooks/useT";
import { graphicDataUrl } from "../../lib/printerObjects";
import type { PrinterOutcome } from "../../lib/printerQuery";
import { PrinterReplyDialog } from "../Output/PrinterReplyDialog";

interface Props {
path: string;
read: PrinterOutcome<HostGraphic> | "reading";
read: PrinterOutcome<string | null> | "reading";
/** What to say for null, since a blank sample and an unreadable graphic differ. */
blankText: string;
onClose: () => void;
}

/** A stored graphic as the printer holds it, decoded like a ^GF field. */
export function PrinterGraphicDialog({ path, read, onClose }: Props) {
const loc = useT().printerSettings.objects;
const url = read !== "reading" && read.kind === "ok" ? graphicDataUrl(read.value) : null;
/** A stored graphic or a font sample as the printer draws it. */
export function PrinterImageDialog({ path, read, blankText, onClose }: Props) {
return (
<PrinterReplyDialog title={path} titleClassName="font-mono" portal read={read} onClose={onClose}>
{() =>
{(url) =>
url ? (
<img src={url} alt={path} className="max-w-full self-start border border-border bg-white" />
) : (
<span role="alert" className="text-[10px] font-mono text-error">
{loc.graphicUnreadable}
{blankText}
</span>
)
}
Expand Down
35 changes: 24 additions & 11 deletions src/components/PrinterSettings/PrinterObjectsSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,14 @@ import { fallbackTranslations as en } from "../../locales";
import type * as PrinterObjects from "../../lib/printerObjects";

const readPrinterObjects = vi.fn();
const readPrinterGraphic = vi.fn();
const readPrinterObjectImage = vi.fn();
const deletePrinterObject = vi.fn();
const bitmapToDataUrl = vi.fn();
vi.mock("../../lib/printerObjects", async (importOriginal) => ({
...(await importOriginal<typeof PrinterObjects>()),
readPrinterObjects: (...args: unknown[]) => readPrinterObjects(...args),
readPrinterGraphic: (...args: unknown[]) => readPrinterGraphic(...args),
readPrinterObjectImage: (...args: unknown[]) => readPrinterObjectImage(...args),
deletePrinterObject: (...args: unknown[]) => deletePrinterObject(...args),
}));
// jsdom has no canvas, so the raster-to-image step is stubbed.
vi.mock("../../lib/printerPreview", () => ({ bitmapToDataUrl: () => bitmapToDataUrl() }));

const loc = en.printerSettings.objects;
const pre = { device: "R", name: "PRE", ext: "GRF", size: 49924 };
Expand All @@ -29,14 +26,14 @@ const listing = [
{ device: "E", objects: [png, font], bytesFree: 47972352 },
];
const target = { kind: "network", host: "172.17.17.175", port: 9100 };
const media = expect.objectContaining({ dpmm: expect.any(Number) });
const listed = () => act(() => useLabelStore.setState({ printerObjects: { phase: "done", key: "net:172.17.17.175:9100", at: 0, directories: listing } }));

afterEach(cleanup);
beforeEach(() => {
readPrinterObjects.mockReset();
readPrinterGraphic.mockReset();
readPrinterObjectImage.mockReset();
deletePrinterObject.mockReset();
bitmapToDataUrl.mockReset().mockReturnValue("data:image/png;base64,x");
act(() =>
useLabelStore.setState({
printerObjects: { phase: "idle" },
Expand Down Expand Up @@ -65,26 +62,42 @@ describe("PrinterObjectsSection", () => {
const fonts = render(<PrinterObjectsSection kind="font" />);
expect(fonts.getByText("E:CG_TIMES.TTF")).toBeTruthy();
expect(fonts.queryByText("R:PRE.GRF")).toBeNull();
expect(fonts.queryByText(loc.showObject)).toBeNull();
});

it("shows a stored graphic in a dialog and names a raster it cannot draw", async () => {
listed();
readPrinterGraphic.mockResolvedValue({ kind: "ok", value: { name: "PRE", gfa: "^GFA,2,2,1,FFFF" } });
readPrinterObjectImage.mockResolvedValueOnce({ kind: "ok", value: "data:image/png;base64,x" });
const r = render(<PrinterObjectsSection kind="graphic" />);
await act(async () => {
fireEvent.click(r.getAllByText(loc.showObject)[0] as HTMLElement);
});
expect(readPrinterGraphic).toHaveBeenCalledWith(target, pre);
expect(readPrinterObjectImage).toHaveBeenCalledWith(target, pre, media);
expect((r.getByRole("img") as HTMLImageElement).alt).toBe("R:PRE.GRF");
fireEvent.click(r.getByLabelText(en.app.close));
bitmapToDataUrl.mockReturnValue(null);
readPrinterObjectImage.mockResolvedValueOnce({ kind: "ok", value: null });
await act(async () => {
fireEvent.click(r.getAllByText(loc.showObject)[0] as HTMLElement);
});
expect(r.getByRole("alert").textContent).toBe(loc.graphicUnreadable);
});

it("lets the printer draw a font sample and says when it drew nothing", async () => {
listed();
readPrinterObjectImage.mockResolvedValueOnce({ kind: "ok", value: "data:image/png;base64,x" });
const r = render(<PrinterObjectsSection kind="font" />);
await act(async () => {
fireEvent.click(r.getByText(loc.showObject));
});
expect(readPrinterObjectImage).toHaveBeenCalledWith(target, font, media);
expect((r.getByRole("img") as HTMLImageElement).alt).toBe("E:CG_TIMES.TTF");
fireEvent.click(r.getByLabelText(en.app.close));
readPrinterObjectImage.mockResolvedValueOnce({ kind: "ok", value: null });
await act(async () => {
fireEvent.click(r.getByText(loc.showObject));
});
expect(r.getByRole("alert").textContent).toBe(loc.fontSampleBlank);
});

it("asks per kind before deleting, warns when the setup script would upload it again, and reports a failed delete", async () => {
listed();
act(() => useLabelStore.setState({ printerProfile: { ...useLabelStore.getState().printerProfile, setupFonts: [{ path: "E:CG_TIMES.TTF" }] } }));
Expand Down
22 changes: 11 additions & 11 deletions src/components/PrinterSettings/PrinterObjectsSection.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState } from "react";
import { InformationCircleIcon } from "@heroicons/react/16/solid";
import { hostObjectKind, hostObjectPath, type HostGraphic, type HostObject, type HostObjectKind } from "@zplab/core/lib/hostDirectory";
import { hostObjectKind, hostObjectPath, type HostObject, type HostObjectKind } from "@zplab/core/lib/hostDirectory";
import { findSetupEntry } from "@zplab/core/lib/setupEntries";
import { useT } from "../../hooks/useT";
import { formatTemplate } from "../../lib/formatTemplate";
Expand All @@ -10,7 +10,7 @@ import { selectPrinterObjects, useLabelStore } from "../../store/labelStore";
import { ConfirmDialog } from "../ui/ConfirmDialog";
import { Tooltip } from "../ui/Tooltip";
import { buttonCls, disabledCls, sectionHeadingCls, zplCommandTagCls } from "../ui/formStyles";
import { PrinterGraphicDialog } from "./PrinterGraphicDialog";
import { PrinterImageDialog } from "./PrinterImageDialog";

type Kind = Extract<HostObjectKind, "graphic" | "font">;

Expand All @@ -25,11 +25,11 @@ export function PrinterObjectsSection({ kind }: { kind: Kind }) {
const step = useLabelStore((s) => s.printerReading);
const setupEntries = useLabelStore((s) => (kind === "graphic" ? s.printerProfile.setupGraphics : s.printerProfile.setupFonts));
const read = useLabelStore((s) => s.readPrinterObjects);
const readGraphic = useLabelStore((s) => s.readPrinterGraphic);
const readImage = useLabelStore((s) => s.readPrinterObjectImage);
const remove = useLabelStore((s) => s.deletePrinterObject);
const [pendingDelete, setPendingDelete] = useState<HostObject | null>(null);
const [issue, setIssue] = useState<string | null>(null);
const [graphic, setGraphic] = useState<{ path: string; read: PrinterOutcome<HostGraphic> | "reading" } | undefined>();
const [graphic, setGraphic] = useState<{ path: string; read: PrinterOutcome<string | null> | "reading" } | undefined>();

const reading = step !== undefined;
// A drive answering with two DIR blocks would list its objects and its free space twice.
Expand All @@ -47,7 +47,7 @@ export function PrinterObjectsSection({ kind }: { kind: Kind }) {
const show = (object: HostObject) => {
const path = hostObjectPath(object);
setGraphic({ path, read: "reading" });
void readGraphic(object).then((result) => setGraphic((current) => (current?.path === path ? { path, read: result } : current)));
void readImage(object).then((read) => setGraphic((current) => (current?.path === path ? { path, read } : current)));
};
const confirmDelete = (object: HostObject) => {
setPendingDelete(null);
Expand Down Expand Up @@ -103,11 +103,9 @@ export function PrinterObjectsSection({ kind }: { kind: Kind }) {
<span className="text-[10px] text-muted">{formatTemplate(loc.objectSizeFmt, { kb: kb(o.size) })}</span>
</span>
<span className="flex items-center gap-1 shrink-0">
{kind === "graphic" && (
<button type="button" className={`${buttonCls} ${disabledCls}`} disabled={reading} onClick={() => show(o)}>
{loc.showObject}
</button>
)}
<button type="button" className={`${buttonCls} ${disabledCls}`} disabled={reading} onClick={() => show(o)}>
{loc.showObject}
</button>
<button type="button" className={`${buttonCls} ${disabledCls}`} disabled={reading} onClick={() => setPendingDelete(o)}>
{loc.deleteObject}
</button>
Expand All @@ -128,7 +126,9 @@ export function PrinterObjectsSection({ kind }: { kind: Kind }) {
onCancel={() => setPendingDelete(null)}
/>
)}
{state.phase === "done" && graphic && <PrinterGraphicDialog path={graphic.path} read={graphic.read} onClose={() => setGraphic(undefined)} />}
{state.phase === "done" && graphic && (
<PrinterImageDialog path={graphic.path} read={graphic.read} blankText={kind === "font" ? loc.fontSampleBlank : loc.graphicUnreadable} onClose={() => setGraphic(undefined)} />
)}
</section>
);
}
39 changes: 38 additions & 1 deletion src/lib/printerObjects.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { deletePrinterObject, readPrinterGraphic, readPrinterObjects } from "./printerObjects";
import { deletePrinterObject, readPrinterFontSample, readPrinterGraphic, readPrinterObjectImage, readPrinterObjects } from "./printerObjects";
import type * as PrinterPreview from "./printerPreview";
import type * as PrinterQuery from "./printerQuery";
import type * as ZebraPrint from "./zebraPrint";
import type * as UsbPrint from "./usbPrint";

const queryPrinter = vi.fn();
const sendViaNetwork = vi.fn();
const sendZplUsb = vi.fn();
const fetchPrinterPreview = vi.fn();
vi.mock("./printerPreview", async (importOriginal) => ({
...(await importOriginal<typeof PrinterPreview>()),
fetchPrinterPreview: (...args: unknown[]) => fetchPrinterPreview(...args),
}));
vi.mock("./printerQuery", async (importOriginal) => ({
...(await importOriginal<typeof PrinterQuery>()),
queryPrinter: (...args: unknown[]) => queryPrinter(...args),
Expand All @@ -32,6 +38,37 @@ beforeEach(() => {
queryPrinter.mockReset();
sendViaNetwork.mockReset();
sendZplUsb.mockReset();
fetchPrinterPreview.mockReset();
});

const font = { device: "E", name: "CG_TIMES", ext: "TTF", size: 62252 };
const media = { widthMm: 100, heightMm: 150, dpmm: 8 };

describe("readPrinterFontSample", () => {
it("has the printer render the sample and crops it to the ink", async () => {
fetchPrinterPreview.mockResolvedValueOnce({ kind: "ok", value: { width: 16, height: 3, mono: Uint8Array.from([0, 0, 0, 0x18, 0, 0]) } });
expect(await readPrinterFontSample(network, font, media)).toEqual({ kind: "ok", value: { width: 8, height: 1, mono: Uint8Array.from([0x18]) } });
expect(fetchPrinterPreview).toHaveBeenCalledWith(network, expect.stringContaining("^A@N,60,,E:CG_TIMES.TTF"));
expect(sendViaNetwork).not.toHaveBeenCalled();
});

it("reports a blank sample as null and passes a failed render through", async () => {
fetchPrinterPreview.mockResolvedValueOnce({ kind: "ok", value: { width: 8, height: 1, mono: Uint8Array.from([0]) } });
expect(await readPrinterFontSample(network, font, media)).toEqual({ kind: "ok", value: null });
fetchPrinterPreview.mockResolvedValueOnce({ kind: "unreachable" });
expect(await readPrinterFontSample(network, font, media)).toEqual({ kind: "unreachable" });
});
});

describe("readPrinterObjectImage", () => {
it("takes the ^HG path for a graphic and the sample path for a font", async () => {
queryPrinter.mockResolvedValueOnce({ kind: "ok", value: "" });
expect(await readPrinterObjectImage(network, pre, media)).toEqual({ kind: "unparsed" });
expect(fetchPrinterPreview).not.toHaveBeenCalled();
fetchPrinterPreview.mockResolvedValueOnce({ kind: "ok", value: { width: 8, height: 1, mono: Uint8Array.from([0]) } });
expect(await readPrinterObjectImage(network, font, media)).toEqual({ kind: "ok", value: null });
expect(queryPrinter).toHaveBeenCalledTimes(1);
});
});

describe("readPrinterObjects", () => {
Expand Down
26 changes: 23 additions & 3 deletions src/lib/printerObjects.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { fontSampleZpl, type SampleMedia } from "@zplab/core/lib/fontSample";
import { rasterFromGfa } from "@zplab/core/lib/gfaDecode";
import { hostObjectPath, parseHostDirectories, parseHostGraphic, type HostDirectory, type HostGraphic, type HostObject } from "@zplab/core/lib/hostDirectory";
import { hostObjectKind, hostObjectPath, parseHostDirectories, parseHostGraphic, type HostDirectory, type HostGraphic, type HostObject } from "@zplab/core/lib/hostDirectory";
import { STORAGE_DEVICES } from "@zplab/core/lib/storagePath";
import { bitmapToDataUrl } from "./printerPreview";
import { bitmapToDataUrl, fetchPrinterPreview } from "./printerPreview";
import { queryPrinter, type PrinterOutcome, type PrinterQueryFailure } from "./printerQuery";
import type { QueryTarget } from "./printTarget";
import { sendZplUsb } from "./usbPrint";
import { sendViaNetwork } from "./zebraPrint";
import { contentBounds, cropBitmap, type PrinterBitmap } from "./zebraGraphic";

/** One ^HW per drive and per query, so a slow drive cannot cut the next one off at the idle gap. A drive that goes silent ends the walk and keeps the earlier listings. */
export async function readPrinterObjects(target: QueryTarget, onStep?: (command: string) => void): Promise<PrinterOutcome<HostDirectory[]>> {
Expand All @@ -27,11 +29,29 @@ export async function readPrinterGraphic(target: QueryTarget, object: HostObject
return graphic ? { kind: "ok", value: graphic } : { kind: "unparsed" };
}

export function graphicDataUrl(graphic: HostGraphic): string | null {
/** The printer renders the sample itself, so the font shows as the firmware draws it. Null when it drew nothing. */
export async function readPrinterFontSample(target: QueryTarget, object: HostObject, media: SampleMedia): Promise<PrinterOutcome<PrinterBitmap | null>> {
const res = await fetchPrinterPreview(target, fontSampleZpl(hostObjectPath(object), media));
if (res.kind !== "ok") return res;
const bounds = contentBounds(res.value);
return { kind: "ok", value: bounds.right > bounds.left ? cropBitmap(res.value, bounds) : null };
}

function graphicDataUrl(graphic: HostGraphic): string | null {
const raster = rasterFromGfa(graphic.gfa);
return raster ? bitmapToDataUrl({ width: raster.paddedWidth, height: raster.heightDots, mono: raster.bytes }) : null;
}

/** The picture of an object by its kind, as a data URL. Null when the printer gave nothing drawable. */
export async function readPrinterObjectImage(target: QueryTarget, object: HostObject, media: SampleMedia): Promise<PrinterOutcome<string | null>> {
if (hostObjectKind(object.ext) === "font") {
const sample = await readPrinterFontSample(target, object, media);
return sample.kind === "ok" ? { kind: "ok", value: sample.value && bitmapToDataUrl(sample.value) } : sample;
}
const graphic = await readPrinterGraphic(target, object);
return graphic.kind === "ok" ? { kind: "ok", value: graphicDataUrl(graphic.value) } : graphic;
}

/** ^ID answers nothing, so it goes the way a job goes. */
export async function deletePrinterObject(target: QueryTarget, object: HostObject): Promise<PrinterQueryFailure | undefined> {
const zpl = `^XA^ID${hostObjectPath(object)}^FS^XZ`;
Expand Down
Loading
Loading