From 8d5966889a561058670e75ac63e3177143b3d743 Mon Sep 17 00:00:00 2001 From: u8array Date: Wed, 7 Oct 2026 02:06:12 +0200 Subject: [PATCH] feat(printer): the fonts tab shows a sample the printer draws --- README.md | 2 +- packages/core/src/lib/fontSample.test.ts | 29 ++++++++++++++ packages/core/src/lib/fontSample.ts | 29 ++++++++++++++ ...aphicDialog.tsx => PrinterImageDialog.tsx} | 17 ++++---- .../PrinterObjectsSection.test.tsx | 35 +++++++++++------ .../PrinterSettings/PrinterObjectsSection.tsx | 22 +++++------ src/lib/printerObjects.test.ts | 39 ++++++++++++++++++- src/lib/printerObjects.ts | 26 +++++++++++-- src/lib/printerPreview.ts | 4 +- src/lib/zebraGraphic.test.ts | 31 +++++++++------ src/lib/zebraGraphic.ts | 23 +++++++++-- src/locales/ar.ts | 1 + src/locales/bg.ts | 1 + src/locales/cs.ts | 1 + src/locales/da.ts | 1 + src/locales/de.ts | 1 + src/locales/el.ts | 1 + src/locales/en.ts | 1 + src/locales/es.ts | 1 + src/locales/et.ts | 1 + src/locales/fa.ts | 1 + src/locales/fi.ts | 1 + src/locales/fr.ts | 1 + src/locales/he.ts | 1 + src/locales/hr.ts | 1 + src/locales/hu.ts | 1 + src/locales/it.ts | 1 + src/locales/ja.ts | 1 + src/locales/ko.ts | 1 + src/locales/lt.ts | 1 + src/locales/lv.ts | 1 + src/locales/nl.ts | 1 + src/locales/no.ts | 1 + src/locales/pl.ts | 1 + src/locales/pt.ts | 1 + src/locales/ro.ts | 1 + src/locales/sk.ts | 1 + src/locales/sl.ts | 1 + src/locales/sr.ts | 1 + src/locales/sv.ts | 1 + src/locales/tr.ts | 1 + src/locales/zh-hans.ts | 1 + src/locales/zh-hant.ts | 1 + src/store/slices/printerObjectsSlice.test.ts | 27 ++++++++----- src/store/slices/printerObjectsSlice.ts | 18 ++++++--- 45 files changed, 263 insertions(+), 71 deletions(-) create mode 100644 packages/core/src/lib/fontSample.test.ts create mode 100644 packages/core/src/lib/fontSample.ts rename src/components/PrinterSettings/{PrinterGraphicDialog.tsx => PrinterImageDialog.tsx} (51%) diff --git a/README.md b/README.md index a23e9ea9..7f571346 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/packages/core/src/lib/fontSample.test.ts b/packages/core/src/lib/fontSample.test.ts new file mode 100644 index 00000000..f3eae6f1 --- /dev/null +++ b/packages/core/src/lib/fontSample.test.ts @@ -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"); + }); +}); diff --git a/packages/core/src/lib/fontSample.ts b/packages/core/src/lib/fontSample.ts new file mode 100644 index 00000000..6de44d84 --- /dev/null +++ b/packages/core/src/lib/fontSample.ts @@ -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`; +} diff --git a/src/components/PrinterSettings/PrinterGraphicDialog.tsx b/src/components/PrinterSettings/PrinterImageDialog.tsx similarity index 51% rename from src/components/PrinterSettings/PrinterGraphicDialog.tsx rename to src/components/PrinterSettings/PrinterImageDialog.tsx index 0c5106c0..2e409ed0 100644 --- a/src/components/PrinterSettings/PrinterGraphicDialog.tsx +++ b/src/components/PrinterSettings/PrinterImageDialog.tsx @@ -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 | "reading"; + read: PrinterOutcome | "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 ( - {() => + {(url) => url ? ( {path} ) : ( - {loc.graphicUnreadable} + {blankText} ) } diff --git a/src/components/PrinterSettings/PrinterObjectsSection.test.tsx b/src/components/PrinterSettings/PrinterObjectsSection.test.tsx index 91d1aba4..2a5fbcb0 100644 --- a/src/components/PrinterSettings/PrinterObjectsSection.test.tsx +++ b/src/components/PrinterSettings/PrinterObjectsSection.test.tsx @@ -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()), 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 }; @@ -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" }, @@ -65,26 +62,42 @@ describe("PrinterObjectsSection", () => { const fonts = render(); 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(); 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(); + 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" }] } })); diff --git a/src/components/PrinterSettings/PrinterObjectsSection.tsx b/src/components/PrinterSettings/PrinterObjectsSection.tsx index e99f844e..e3f42a8b 100644 --- a/src/components/PrinterSettings/PrinterObjectsSection.tsx +++ b/src/components/PrinterSettings/PrinterObjectsSection.tsx @@ -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"; @@ -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; @@ -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(null); const [issue, setIssue] = useState(null); - const [graphic, setGraphic] = useState<{ path: string; read: PrinterOutcome | "reading" } | undefined>(); + const [graphic, setGraphic] = useState<{ path: string; read: PrinterOutcome | "reading" } | undefined>(); const reading = step !== undefined; // A drive answering with two DIR blocks would list its objects and its free space twice. @@ -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); @@ -103,11 +103,9 @@ export function PrinterObjectsSection({ kind }: { kind: Kind }) { {formatTemplate(loc.objectSizeFmt, { kb: kb(o.size) })} - {kind === "graphic" && ( - - )} + @@ -128,7 +126,9 @@ export function PrinterObjectsSection({ kind }: { kind: Kind }) { onCancel={() => setPendingDelete(null)} /> )} - {state.phase === "done" && graphic && setGraphic(undefined)} />} + {state.phase === "done" && graphic && ( + setGraphic(undefined)} /> + )} ); } diff --git a/src/lib/printerObjects.test.ts b/src/lib/printerObjects.test.ts index 1c39fc35..58c6d35c 100644 --- a/src/lib/printerObjects.test.ts +++ b/src/lib/printerObjects.test.ts @@ -1,5 +1,6 @@ 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"; @@ -7,6 +8,11 @@ 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()), + fetchPrinterPreview: (...args: unknown[]) => fetchPrinterPreview(...args), +})); vi.mock("./printerQuery", async (importOriginal) => ({ ...(await importOriginal()), queryPrinter: (...args: unknown[]) => queryPrinter(...args), @@ -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", () => { diff --git a/src/lib/printerObjects.ts b/src/lib/printerObjects.ts index 391fb296..22364242 100644 --- a/src/lib/printerObjects.ts +++ b/src/lib/printerObjects.ts @@ -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> { @@ -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> { + 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> { + 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 { const zpl = `^XA^ID${hostObjectPath(object)}^FS^XZ`; diff --git a/src/lib/printerPreview.ts b/src/lib/printerPreview.ts index 6852e5a6..2747e068 100644 --- a/src/lib/printerPreview.ts +++ b/src/lib/printerPreview.ts @@ -118,9 +118,7 @@ export async function fetchPrinterPreview( const res = await queryPrinter(target, buildPrinterPreviewZpl(designZpl)); if (res.kind !== "ok") return res; const bitmap = decodeDyGraphic(res.value); - return bitmap - ? { kind: "ok", value: bitmap } - : { kind: "error", message: "no graphic in printer response" }; + return bitmap ? { kind: "ok", value: bitmap } : { kind: "unparsed" }; } catch (e) { return { kind: "error", message: errorMessage(e) }; } diff --git a/src/lib/zebraGraphic.test.ts b/src/lib/zebraGraphic.test.ts index 0393f134..7671c8fd 100644 --- a/src/lib/zebraGraphic.test.ts +++ b/src/lib/zebraGraphic.test.ts @@ -1,12 +1,6 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { zlibSync } from "fflate"; -import { - buildPrinterPreviewZpl, - contentBounds, - decodeDyGraphic, - monoToRgba, - type PrinterBitmap, -} from "./zebraGraphic"; +import { buildPrinterPreviewZpl, contentBounds, cropBitmap, decodeDyGraphic, monoToRgba, type PrinterBitmap } from "./zebraGraphic"; const b64 = (bytes: Uint8Array) => btoa(String.fromCharCode(...bytes)); @@ -23,14 +17,18 @@ const dyResponse = (mono: Uint8Array, kind: "Z" | "B", wrap = false): string => describe("buildPrinterPreviewZpl", () => { it("injects ^IS before the final ^XZ and appends the ^HY upload", () => { + const random = vi.spyOn(Math, "random").mockReturnValueOnce(0.5).mockReturnValueOnce(0.25); const out = buildPrinterPreviewZpl("^XA^FO10,10^A0N,30^FDHi^FS^XZ"); - expect(out).toContain("^ISR:PRE.GRF,N^XZ"); - expect(out).toContain("^HYR:PRE.GRF"); - expect(out.indexOf("^ISR:PRE.GRF")).toBeLessThan(out.indexOf("^HYR:PRE.GRF")); + expect(out).toContain("^ISR:ZL800000.GRF,N^XZ"); + expect(out).toContain("^HYR:ZL800000.GRF^XZ"); + expect(out.indexOf("^IS")).toBeLessThan(out.indexOf("^HY")); + expect(out.indexOf("^HY")).toBeLessThan(out.indexOf("^IDR:ZL800000.GRF^FS^XZ")); + expect(buildPrinterPreviewZpl("^XA^FDx^FS^XZ")).toContain("^ISR:ZL400000.GRF,N^XZ"); + random.mockRestore(); }); it("appends ^IS even when the design has no trailing ^XZ", () => { - expect(buildPrinterPreviewZpl("^XA^FDx^FS")).toContain("^ISR:PRE.GRF,N^XZ"); + expect(buildPrinterPreviewZpl("^XA^FDx^FS")).toMatch(/\^ISR:ZL[0-9A-F]{6}\.GRF,N\^XZ/); }); it("forces ^LL onto all media so the render matches the design length", () => { @@ -138,3 +136,12 @@ describe("monoToRgba", () => { expect([rgba[p15], rgba[p15 + 1], rgba[p15 + 2]]).toEqual([0, 0, 0]); }); }); + +describe("cropBitmap", () => { + it("widens the bounds to whole bytes and copies the rows between top and bottom", () => { + const mono = Uint8Array.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x80, 0x00, 0x00, 0x00, 0x00]); + const bmp = { width: 24, height: 4, mono }; + expect(cropBitmap(bmp, { left: 7, right: 9, top: 2, bottom: 3 })).toEqual({ width: 16, height: 1, mono: Uint8Array.from([0x01, 0x80]) }); + expect(cropBitmap(bmp, contentBounds(bmp))).toEqual({ width: 16, height: 1, mono: Uint8Array.from([0x01, 0x80]) }); + }); +}); diff --git a/src/lib/zebraGraphic.ts b/src/lib/zebraGraphic.ts index 41c76d3b..fdea77cf 100644 --- a/src/lib/zebraGraphic.ts +++ b/src/lib/zebraGraphic.ts @@ -17,10 +17,11 @@ export function buildPrinterPreviewZpl(designZpl: string): string { // `^LL` applies to continuous media only; `,Y` forces the design length onto // gap/notch media too. Preview-only; the print path respects real media. const sized = designZpl.replace(/(\^LL\d+)(?![\d,])/g, "$1,Y"); - const store = /\^XZ\s*$/.test(sized) - ? sized.replace(/\^XZ\s*$/, "^ISR:PRE.GRF,N^XZ") - : `${sized}^ISR:PRE.GRF,N^XZ`; - return `${store}\n^XA^HYR:PRE.GRF^XZ`; + // A name of its own per render, so the scratch never overwrites an object the user keeps on R:. + const scratch = `R:ZL${Math.floor(Math.random() * 0x1000000).toString(16).toUpperCase().padStart(6, "0")}.GRF`; + const store = /\^XZ\s*$/.test(sized) ? sized.replace(/\^XZ\s*$/, `^IS${scratch},N^XZ`) : `${sized}^IS${scratch},N^XZ`; + // The ^ID rides in the same transmission, so the scratch never stays on the printer. + return `${store}\n^XA^HY${scratch}^XZ^XA^ID${scratch}^FS^XZ`; } function base64ToBytes(b64: string): Uint8Array { @@ -77,6 +78,20 @@ export interface ContentBounds { bottom: number; } +/** The bounds widened to whole bytes, so the rows copy without re-packing bits. */ +export function cropBitmap(bmp: PrinterBitmap, bounds: ContentBounds): PrinterBitmap { + const firstByte = Math.floor(bounds.left / 8); + const lastByte = Math.ceil(bounds.right / 8); + const width = (lastByte - firstByte) * 8; + const height = bounds.bottom - bounds.top; + const source = bmp.width / 8; + const mono = new Uint8Array((width / 8) * height); + for (let y = 0; y < height; y++) { + mono.set(bmp.mono.subarray((bounds.top + y) * source + firstByte, (bounds.top + y) * source + lastByte), y * (width / 8)); + } + return { width, height, mono }; +} + /** Black-pixel bounding box (right/bottom exclusive). The print is padded * inside the full head raster, so the bitmap frame isn't the content. */ export function contentBounds(bmp: PrinterBitmap): ContentBounds { diff --git a/src/locales/ar.ts b/src/locales/ar.ts index bb6242d7..f9e673f6 100644 --- a/src/locales/ar.ts +++ b/src/locales/ar.ts @@ -763,6 +763,7 @@ const ar = { showObject: 'عرض', deleteObject: 'حذف', graphicUnreadable: 'لم ترسل الطابعة أي رسم قابل للقراءة.', + fontSampleBlank: 'الطابعة لم ترسم أي شيء بهذا الخط.', deleteGraphicConfirmFmt: 'هل تريد حذف {path} من الطابعة؟ الملصقات التي تستدعيه تُطبع بدونه.', deleteFontConfirmFmt: 'هل تريد حذف {path} من الطابعة؟ الملصقات التي تستخدمه تتراجع إلى خط آخر.', setupReuploadHint: 'نص الإعداد يسرده ويرفعه مرة أخرى في تشغيله التالي.', diff --git a/src/locales/bg.ts b/src/locales/bg.ts index 280e1b21..7a030bd8 100644 --- a/src/locales/bg.ts +++ b/src/locales/bg.ts @@ -763,6 +763,7 @@ const bg = { showObject: 'Показване', deleteObject: 'Изтриване', graphicUnreadable: 'Принтерът не изпрати четима графика.', + fontSampleBlank: 'Принтерът не отпечата нищо с този шрифт.', deleteGraphicConfirmFmt: 'Да се изтрие ли {path} от принтера? Етикетите, които го извикват, се отпечатват без него.', deleteFontConfirmFmt: 'Да се изтрие ли {path} от принтера? Етикетите, които го използват, преминават към друг шрифт.', setupReuploadHint: 'Скриптът за настройка го изброява и го качва отново при следващото изпълнение.', diff --git a/src/locales/cs.ts b/src/locales/cs.ts index 775fb2ca..deb4edaa 100644 --- a/src/locales/cs.ts +++ b/src/locales/cs.ts @@ -763,6 +763,7 @@ const cs = { showObject: 'Zobrazit', deleteObject: 'Smazat', graphicUnreadable: 'Tiskárna neposlala žádnou čitelnou grafiku.', + fontSampleBlank: 'Tiskárna nevykreslila nic tímto písmem.', deleteGraphicConfirmFmt: 'Smazat {path} z tiskárny? Štítky, které jej vyvolávají, se vytisknou bez něj.', deleteFontConfirmFmt: 'Smazat {path} z tiskárny? Štítky, které ho používají, přejdou na jiné písmo.', setupReuploadHint: 'Instalační skript ho uvádí v seznamu a při dalším běhu ho nahraje znovu.', diff --git a/src/locales/da.ts b/src/locales/da.ts index 5d513400..2a030577 100644 --- a/src/locales/da.ts +++ b/src/locales/da.ts @@ -763,6 +763,7 @@ const da = { showObject: 'Vis', deleteObject: 'Slet', graphicUnreadable: 'Printeren sendte ingen læsbar grafik.', + fontSampleBlank: 'Printeren tegnede ingenting med denne skrifttype.', deleteGraphicConfirmFmt: 'Slet {path} fra printeren? Etiketter, der henter det frem, udskrives uden det.', deleteFontConfirmFmt: 'Slet {path} fra printeren? Etiketter, der bruger den, falder tilbage til en anden skrifttype.', setupReuploadHint: 'Opsætningsscriptet lister den og uploader den igen ved næste kørsel.', diff --git a/src/locales/de.ts b/src/locales/de.ts index 70ab9779..1a069846 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -763,6 +763,7 @@ const de = { showObject: 'Anzeigen', deleteObject: 'Löschen', graphicUnreadable: 'Der Drucker hat keine lesbare Grafik gesendet.', + fontSampleBlank: 'Der Drucker hat mit dieser Schrift nichts gezeichnet.', deleteGraphicConfirmFmt: '{path} vom Drucker löschen? Etiketten, die es aufrufen, drucken dann ohne es.', deleteFontConfirmFmt: '{path} vom Drucker löschen? Etiketten, die ihn verwenden, weichen auf eine andere Schriftart aus.', setupReuploadHint: 'Das Setup-Skript listet es und lädt es beim nächsten Lauf erneut hoch.', diff --git a/src/locales/el.ts b/src/locales/el.ts index 252aa0eb..c8ba8132 100644 --- a/src/locales/el.ts +++ b/src/locales/el.ts @@ -763,6 +763,7 @@ const el = { showObject: 'Εμφάνιση', deleteObject: 'Διαγραφή', graphicUnreadable: 'Ο εκτυπωτής δεν έστειλε αναγνώσιμο γραφικό.', + fontSampleBlank: 'Ο εκτυπωτής δεν σχεδίασε τίποτα με αυτή τη γραμματοσειρά.', deleteGraphicConfirmFmt: 'Διαγραφή του {path} από τον εκτυπωτή; Οι ετικέτες που το ανακαλούν εκτυπώνονται χωρίς αυτό.', deleteFontConfirmFmt: 'Διαγραφή του {path} από τον εκτυπωτή; Οι ετικέτες που το χρησιμοποιούν επιστρέφουν σε άλλη γραμματοσειρά.', setupReuploadHint: 'Το σενάριο εγκατάστασης το καταγράφει και το ανεβάζει ξανά στην επόμενη εκτέλεσή του.', diff --git a/src/locales/en.ts b/src/locales/en.ts index 8b31a65c..4d992b8e 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -763,6 +763,7 @@ const en = { showObject: 'Show', deleteObject: 'Delete', graphicUnreadable: 'The printer sent no readable graphic.', + fontSampleBlank: 'The printer drew nothing with this font.', deleteGraphicConfirmFmt: 'Delete {path} from the printer? Labels that recall it print without it.', deleteFontConfirmFmt: 'Delete {path} from the printer? Labels that use it fall back to another font.', setupReuploadHint: 'The setup script lists it and uploads it again on its next run.', diff --git a/src/locales/es.ts b/src/locales/es.ts index 499b83aa..ef96e563 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -763,6 +763,7 @@ const es = { showObject: 'Mostrar', deleteObject: 'Eliminar', graphicUnreadable: 'La impresora no envió ningún gráfico legible.', + fontSampleBlank: 'La impresora no dibujó nada con esta fuente.', deleteGraphicConfirmFmt: '¿Eliminar {path} de la impresora? Las etiquetas que lo invocan se imprimen sin él.', deleteFontConfirmFmt: '¿Eliminar {path} de la impresora? Las etiquetas que lo usan recurren a otra fuente.', setupReuploadHint: 'El script de configuración lo incluye en la lista y lo vuelve a subir en su próxima ejecución.', diff --git a/src/locales/et.ts b/src/locales/et.ts index 96dba571..6d0c136e 100644 --- a/src/locales/et.ts +++ b/src/locales/et.ts @@ -763,6 +763,7 @@ const et = { showObject: 'Näita', deleteObject: 'Kustuta', graphicUnreadable: 'Printer ei saatnud loetavat graafikat.', + fontSampleBlank: 'Printer ei joonistanud selle fondiga midagi.', deleteGraphicConfirmFmt: 'Kustutada {path} printerist? Etiketid, mis selle välja kutsuvad, prinditakse selleta.', deleteFontConfirmFmt: 'Kustutada {path} printerist? Etiketid, mis seda kasutavad, lähevad üle teisele fondile.', setupReuploadHint: 'Seadistusskript loetleb selle ja laadib selle järgmisel käitamisel uuesti üles.', diff --git a/src/locales/fa.ts b/src/locales/fa.ts index 60d96d1c..12a39af0 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -763,6 +763,7 @@ const fa = { showObject: 'نمایش', deleteObject: 'حذف', graphicUnreadable: 'چاپگر هیچ گرافیک خوانایی ارسال نکرد.', + fontSampleBlank: 'چاپگر با این فونت چیزی ترسیم نکرد.', deleteGraphicConfirmFmt: '{path} از چاپگر حذف شود؟ برچسب‌هایی که آن را فراخوانی می‌کنند بدون آن چاپ می‌شوند.', deleteFontConfirmFmt: '{path} از چاپگر حذف شود؟ برچسب‌هایی که از آن استفاده می‌کنند به فونت دیگری برمی‌گردند.', setupReuploadHint: 'اسکریپت راه‌اندازی آن را فهرست می‌کند و در اجرای بعدی دوباره بارگذاری می‌کند.', diff --git a/src/locales/fi.ts b/src/locales/fi.ts index 06b6b14c..ea622831 100644 --- a/src/locales/fi.ts +++ b/src/locales/fi.ts @@ -763,6 +763,7 @@ const fi = { showObject: 'Näytä', deleteObject: 'Poista', graphicUnreadable: 'Tulostin ei lähettänyt luettavaa grafiikkaa.', + fontSampleBlank: 'Tulostin ei piirtänyt mitään tällä fontilla.', deleteGraphicConfirmFmt: 'Poistetaanko {path} tulostimesta? Etiketit, jotka hakevat sen, tulostuvat sitä ilman.', deleteFontConfirmFmt: 'Poistetaanko {path} tulostimesta? Etiketit, jotka käyttävät sitä, siirtyvät toiseen fonttiin.', setupReuploadHint: 'Asennusskripti listaa sen ja lähettää sen uudelleen seuraavalla ajolla.', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index a25260ac..c62c1be8 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -763,6 +763,7 @@ const fr = { showObject: 'Afficher', deleteObject: 'Supprimer', graphicUnreadable: "L'imprimante n'a envoyé aucun graphique lisible.", + fontSampleBlank: "L'imprimante n'a rien dessiné avec cette police.", deleteGraphicConfirmFmt: "Supprimer {path} de l'imprimante ? Les étiquettes qui le rappellent s'impriment sans lui.", deleteFontConfirmFmt: "Supprimer {path} de l'imprimante ? Les étiquettes qui l'utilisent basculent vers une autre police.", setupReuploadHint: "Le script d'installation le liste et le téléverse à nouveau lors de sa prochaine exécution.", diff --git a/src/locales/he.ts b/src/locales/he.ts index 8c92dee2..11fd58e1 100644 --- a/src/locales/he.ts +++ b/src/locales/he.ts @@ -763,6 +763,7 @@ const he = { showObject: 'הצג', deleteObject: 'מחק', graphicUnreadable: 'המדפסת לא שלחה גרפיקה קריאה.', + fontSampleBlank: 'המדפסת לא שרטטה דבר עם הגופן הזה.', deleteGraphicConfirmFmt: 'למחוק את {path} מהמדפסת? תוויות שקוראות לו יודפסו בלעדיו.', deleteFontConfirmFmt: 'למחוק את {path} מהמדפסת? תוויות שמשתמשות בו יעברו לגופן אחר.', setupReuploadHint: 'סקריפט ההתקנה מפרט אותו ומעלה אותו שוב בהפעלה הבאה שלו.', diff --git a/src/locales/hr.ts b/src/locales/hr.ts index f03f0435..35814e03 100644 --- a/src/locales/hr.ts +++ b/src/locales/hr.ts @@ -763,6 +763,7 @@ const hr = { showObject: 'Prikaži', deleteObject: 'Izbriši', graphicUnreadable: 'Pisač nije poslao nijednu čitljivu grafiku.', + fontSampleBlank: 'Pisač nije ništa nacrtao ovim fontom.', deleteGraphicConfirmFmt: 'Izbrisati {path} s pisača? Naljepnice koje ga pozivaju ispisuju se bez njega.', deleteFontConfirmFmt: 'Izbrisati {path} s pisača? Naljepnice koje ga koriste prelaze na drugi font.', setupReuploadHint: 'Instalacijska skripta ga navodi na popisu i ponovno ga učitava kod sljedećeg pokretanja.', diff --git a/src/locales/hu.ts b/src/locales/hu.ts index 1894c3c9..aa0b04ed 100644 --- a/src/locales/hu.ts +++ b/src/locales/hu.ts @@ -763,6 +763,7 @@ const hu = { showObject: 'Megjelenítés', deleteObject: 'Törlés', graphicUnreadable: 'A nyomtató nem küldött olvasható grafikát.', + fontSampleBlank: 'A nyomtató semmit nem rajzolt ezzel a betűtípussal.', deleteGraphicConfirmFmt: 'Törli a {path} elemet a nyomtatóról? A rá hivatkozó címkék nélküle nyomtatnak.', deleteFontConfirmFmt: 'Törli a {path} elemet a nyomtatóról? Az ezt használó címkék másik betűtípusra váltanak.', setupReuploadHint: 'A beállító szkript felsorolja, és a következő futásakor újra feltölti.', diff --git a/src/locales/it.ts b/src/locales/it.ts index afb74256..5e58dc1c 100644 --- a/src/locales/it.ts +++ b/src/locales/it.ts @@ -763,6 +763,7 @@ const it = { showObject: 'Mostra', deleteObject: 'Elimina', graphicUnreadable: 'La stampante non ha inviato nessuna grafica leggibile.', + fontSampleBlank: 'La stampante non ha disegnato nulla con questo font.', deleteGraphicConfirmFmt: 'Eliminare {path} dalla stampante? Le etichette che lo richiamano vengono stampate senza di esso.', deleteFontConfirmFmt: 'Eliminare {path} dalla stampante? Le etichette che lo usano passano a un altro font.', setupReuploadHint: 'Lo script di configurazione lo elenca e lo carica di nuovo alla successiva esecuzione.', diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 32d079ae..402abc30 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -763,6 +763,7 @@ const ja = { showObject: '表示', deleteObject: '削除', graphicUnreadable: 'プリンターは読み取り可能なグラフィックを送信しませんでした。', + fontSampleBlank: 'プリンターはこのフォントで何も描画しませんでした。', deleteGraphicConfirmFmt: '{path}をプリンターから削除しますか? それを呼び出すラベルはそれなしで印刷されます。', deleteFontConfirmFmt: '{path}をプリンターから削除しますか? それを使用するラベルは別のフォントに切り替わります。', setupReuploadHint: 'セットアップスクリプトがそれを一覧に含め、次回の実行時に再度アップロードします。', diff --git a/src/locales/ko.ts b/src/locales/ko.ts index e000d465..5e4c0add 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -763,6 +763,7 @@ const ko = { showObject: '표시', deleteObject: '삭제', graphicUnreadable: '프린터가 읽을 수 있는 그래픽을 보내지 않았습니다.', + fontSampleBlank: '프린터가 이 글꼴로 아무것도 그리지 않았습니다.', deleteGraphicConfirmFmt: '{path}를 프린터에서 삭제할까요? 이를 호출하는 레이블은 이것 없이 인쇄됩니다.', deleteFontConfirmFmt: '{path}를 프린터에서 삭제할까요? 이를 사용하는 레이블은 다른 글꼴로 대체됩니다.', setupReuploadHint: '설정 스크립트가 이를 목록에 포함하고 다음 실행 시 다시 업로드합니다.', diff --git a/src/locales/lt.ts b/src/locales/lt.ts index f740d10a..13a6587a 100644 --- a/src/locales/lt.ts +++ b/src/locales/lt.ts @@ -763,6 +763,7 @@ const lt = { showObject: 'Rodyti', deleteObject: 'Ištrinti', graphicUnreadable: 'Spausdintuvas nenusiuntė nuskaitomos grafikos.', + fontSampleBlank: 'Spausdintuvas šiuo šriftu nieko nepiešė.', deleteGraphicConfirmFmt: 'Ištrinti {path} iš spausdintuvo? Etiketės, kurios jį iškviečia, spausdinamos be jo.', deleteFontConfirmFmt: 'Ištrinti {path} iš spausdintuvo? Etiketės, kurios jį naudoja, persijungia į kitą šriftą.', setupReuploadHint: 'Sąrankos scenarijus jį įtraukia į sąrašą ir vėl įkelia kito paleidimo metu.', diff --git a/src/locales/lv.ts b/src/locales/lv.ts index 65700cae..27479847 100644 --- a/src/locales/lv.ts +++ b/src/locales/lv.ts @@ -763,6 +763,7 @@ const lv = { showObject: 'Rādīt', deleteObject: 'Dzēst', graphicUnreadable: 'Printeris nenosūtīja lasāmu grafiku.', + fontSampleBlank: 'Printeris ar šo fontu neko nezīmēja.', deleteGraphicConfirmFmt: 'Dzēst {path} no printera? Etiķetes, kas to izsauc, tiek drukātas bez tā.', deleteFontConfirmFmt: 'Dzēst {path} no printera? Etiķetes, kas to izmanto, pārslēdzas uz citu fontu.', setupReuploadHint: 'Iestatīšanas skripts to norāda sarakstā un augšupielādē atkārtoti nākamajā palaišanas reizē.', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 547fd592..852efbb9 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -763,6 +763,7 @@ const nl = { showObject: 'Weergeven', deleteObject: 'Verwijderen', graphicUnreadable: 'De printer heeft geen leesbare afbeelding verzonden.', + fontSampleBlank: 'De printer heeft met dit lettertype niets getekend.', deleteGraphicConfirmFmt: '{path} van de printer verwijderen? Etiketten die het oproepen, drukken zonder het af.', deleteFontConfirmFmt: '{path} van de printer verwijderen? Etiketten die het gebruiken, vallen terug op een ander lettertype.', setupReuploadHint: 'Het installatiescript vermeldt het en uploadt het opnieuw bij de volgende uitvoering.', diff --git a/src/locales/no.ts b/src/locales/no.ts index e38c628c..aa378f09 100644 --- a/src/locales/no.ts +++ b/src/locales/no.ts @@ -763,6 +763,7 @@ const no = { showObject: 'Vis', deleteObject: 'Slett', graphicUnreadable: 'Skriveren sendte ingen lesbar grafikk.', + fontSampleBlank: 'Skriveren tegnet ingenting med denne skriften.', deleteGraphicConfirmFmt: 'Slette {path} fra skriveren? Etiketter som henter det frem, skriver ut uten det.', deleteFontConfirmFmt: 'Slette {path} fra skriveren? Etiketter som bruker den, faller tilbake til en annen skrift.', setupReuploadHint: 'Oppsettsskriptet lister den opp og laster den opp igjen ved neste kjøring.', diff --git a/src/locales/pl.ts b/src/locales/pl.ts index 7ac2bf76..35ff52c3 100644 --- a/src/locales/pl.ts +++ b/src/locales/pl.ts @@ -763,6 +763,7 @@ const pl = { showObject: 'Pokaż', deleteObject: 'Usuń', graphicUnreadable: 'Drukarka nie wysłała żadnej czytelnej grafiki.', + fontSampleBlank: 'Drukarka nie narysowała niczego tą czcionką.', deleteGraphicConfirmFmt: 'Usunąć {path} z drukarki? Etykiety, które go przywołują, drukują się bez niego.', deleteFontConfirmFmt: 'Usunąć {path} z drukarki? Etykiety, które z niego korzystają, przechodzą na inną czcionkę.', setupReuploadHint: 'Skrypt konfiguracyjny wymienia go na liście i przesyła ponownie przy następnym uruchomieniu.', diff --git a/src/locales/pt.ts b/src/locales/pt.ts index d9989884..00ff0ec1 100644 --- a/src/locales/pt.ts +++ b/src/locales/pt.ts @@ -763,6 +763,7 @@ const pt = { showObject: 'Mostrar', deleteObject: 'Eliminar', graphicUnreadable: 'A impressora não enviou nenhum gráfico legível.', + fontSampleBlank: 'A impressora não desenhou nada com esta fonte.', deleteGraphicConfirmFmt: 'Eliminar {path} da impressora? As etiquetas que o chamam imprimem sem ele.', deleteFontConfirmFmt: 'Eliminar {path} da impressora? As etiquetas que o usam recorrem a outra fonte.', setupReuploadHint: 'O script de configuração lista-o e carrega-o novamente na próxima execução.', diff --git a/src/locales/ro.ts b/src/locales/ro.ts index f89278df..39c6c612 100644 --- a/src/locales/ro.ts +++ b/src/locales/ro.ts @@ -763,6 +763,7 @@ const ro = { showObject: 'Arată', deleteObject: 'Șterge', graphicUnreadable: 'Imprimanta nu a trimis nicio grafică lizibilă.', + fontSampleBlank: 'Imprimanta nu a desenat nimic cu acest font.', deleteGraphicConfirmFmt: 'Ștergeți {path} de pe imprimantă? Etichetele care îl apelează se imprimă fără el.', deleteFontConfirmFmt: 'Ștergeți {path} de pe imprimantă? Etichetele care îl folosesc trec la un alt font.', setupReuploadHint: 'Scriptul de configurare îl listează și îl încarcă din nou la următoarea rulare.', diff --git a/src/locales/sk.ts b/src/locales/sk.ts index 947f5dd1..7d2cd85b 100644 --- a/src/locales/sk.ts +++ b/src/locales/sk.ts @@ -763,6 +763,7 @@ const sk = { showObject: 'Zobraziť', deleteObject: 'Odstrániť', graphicUnreadable: 'Tlačiareň neposlala žiadnu čitateľnú grafiku.', + fontSampleBlank: 'Tlačiareň nevykreslila nič týmto písmom.', deleteGraphicConfirmFmt: 'Odstrániť {path} z tlačiarne? Štítky, ktoré ho vyvolávajú, sa vytlačia bez neho.', deleteFontConfirmFmt: 'Odstrániť {path} z tlačiarne? Štítky, ktoré ho používajú, prejdú na iné písmo.', setupReuploadHint: 'Inštalačný skript ho uvádza v zozname a pri ďalšom spustení ho znova nahrá.', diff --git a/src/locales/sl.ts b/src/locales/sl.ts index 21d57c1f..facf0489 100644 --- a/src/locales/sl.ts +++ b/src/locales/sl.ts @@ -763,6 +763,7 @@ const sl = { showObject: 'Prikaži', deleteObject: 'Izbriši', graphicUnreadable: 'Tiskalnik ni poslal berljive grafike.', + fontSampleBlank: 'Tiskalnik ni izrisal ničesar s to pisavo.', deleteGraphicConfirmFmt: 'Izbrišem {path} iz tiskalnika? Nalepke, ki ga prikličejo, se natisnejo brez njega.', deleteFontConfirmFmt: 'Izbrišem {path} iz tiskalnika? Nalepke, ki jo uporabljajo, preklopijo na drugo pisavo.', setupReuploadHint: 'Namestitveni skript ga navede na seznamu in ga znova naloži ob naslednjem zagonu.', diff --git a/src/locales/sr.ts b/src/locales/sr.ts index 79c50056..1a8cb957 100644 --- a/src/locales/sr.ts +++ b/src/locales/sr.ts @@ -763,6 +763,7 @@ const sr = { showObject: 'Прикажи', deleteObject: 'Обриши', graphicUnreadable: 'Штампач није послао читљиву графику.', + fontSampleBlank: 'Штампач није нацртао ништа овим фонтом.', deleteGraphicConfirmFmt: 'Обрисати {path} са штампача? Налепнице које га позивају штампају се без њега.', deleteFontConfirmFmt: 'Обрисати {path} са штампача? Налепнице које га користе прелазе на други фонт.', setupReuploadHint: 'Скрипта за подешавање га наводи на списку и поново га отпрема при следећем покретању.', diff --git a/src/locales/sv.ts b/src/locales/sv.ts index d20bfafd..71e30bda 100644 --- a/src/locales/sv.ts +++ b/src/locales/sv.ts @@ -763,6 +763,7 @@ const sv = { showObject: 'Visa', deleteObject: 'Ta bort', graphicUnreadable: 'Skrivaren skickade ingen läsbar grafik.', + fontSampleBlank: 'Skrivaren ritade ingenting med detta teckensnitt.', deleteGraphicConfirmFmt: 'Ta bort {path} från skrivaren? Etiketter som hämtar fram det skriver ut utan det.', deleteFontConfirmFmt: 'Ta bort {path} från skrivaren? Etiketter som använder det faller tillbaka till ett annat teckensnitt.', setupReuploadHint: 'Installationsskriptet listar det och laddar upp det igen vid nästa körning.', diff --git a/src/locales/tr.ts b/src/locales/tr.ts index a66aa157..2ed3ab8d 100644 --- a/src/locales/tr.ts +++ b/src/locales/tr.ts @@ -763,6 +763,7 @@ const tr = { showObject: 'Göster', deleteObject: 'Sil', graphicUnreadable: 'Yazıcı okunabilir bir grafik göndermedi.', + fontSampleBlank: 'Yazıcı bu yazı tipiyle hiçbir şey çizmedi.', deleteGraphicConfirmFmt: '{path} yazıcıdan silinsin mi? Onu çağıran etiketler onsuz yazdırılır.', deleteFontConfirmFmt: '{path} yazıcıdan silinsin mi? Onu kullanan etiketler başka bir yazı tipine geçer.', setupReuploadHint: 'Kurulum betiği onu listeler ve bir sonraki çalıştırmasında yeniden yükler.', diff --git a/src/locales/zh-hans.ts b/src/locales/zh-hans.ts index 5d4e43e1..4d259051 100644 --- a/src/locales/zh-hans.ts +++ b/src/locales/zh-hans.ts @@ -763,6 +763,7 @@ const zhHans = { showObject: '显示', deleteObject: '删除', graphicUnreadable: '打印机未发送可读的图形。', + fontSampleBlank: '打印机未用此字体绘制任何内容。', deleteGraphicConfirmFmt: '要从打印机中删除{path}吗?调用它的标签不带它打印。', deleteFontConfirmFmt: '要从打印机中删除{path}吗?使用它的标签回退到其他字体。', setupReuploadHint: '安装脚本将其列出,并在下次运行时重新上传。', diff --git a/src/locales/zh-hant.ts b/src/locales/zh-hant.ts index e2db01f9..ca395e16 100644 --- a/src/locales/zh-hant.ts +++ b/src/locales/zh-hant.ts @@ -763,6 +763,7 @@ const zhHant = { showObject: '顯示', deleteObject: '刪除', graphicUnreadable: '印表機未傳送可讀取的圖形。', + fontSampleBlank: '印表機未使用此字型繪製任何內容。', deleteGraphicConfirmFmt: '要從印表機中刪除{path}嗎?呼叫它的標籤不帶它列印。', deleteFontConfirmFmt: '要從印表機中刪除{path}嗎?使用它的標籤回退到其他字型。', setupReuploadHint: '安裝指令稿將其列出,並在下次執行時重新上傳。', diff --git a/src/store/slices/printerObjectsSlice.test.ts b/src/store/slices/printerObjectsSlice.test.ts index ddae2275..85ef5734 100644 --- a/src/store/slices/printerObjectsSlice.test.ts +++ b/src/store/slices/printerObjectsSlice.test.ts @@ -4,11 +4,11 @@ import { DEFAULT_PRINT_TARGET } from "../../lib/printTarget"; import { selectPrinterObjects } from "../labelStore.selectors"; const readPrinterObjects = vi.fn(); -const readPrinterGraphic = vi.fn(); +const readPrinterObjectImage = vi.fn(); const deletePrinterObject = vi.fn(); vi.mock("../../lib/printerObjects", () => ({ readPrinterObjects: (...args: unknown[]) => readPrinterObjects(...args), - readPrinterGraphic: (...args: unknown[]) => readPrinterGraphic(...args), + readPrinterObjectImage: (...args: unknown[]) => readPrinterObjectImage(...args), deletePrinterObject: (...args: unknown[]) => deletePrinterObject(...args), })); const readPrinterStatus = vi.fn(); @@ -19,11 +19,12 @@ vi.mock("../../lib/printerStatus", () => ({ const target = { kind: "network", host: "172.17.17.175", port: 9100 }; const pre = { device: "R", name: "PRE", ext: "GRF", size: 49924 }; +const font = { device: "E", name: "CG_TIMES", ext: "TTF", size: 62252 }; const listing = [{ device: "R", objects: [pre], bytesFree: 7467008 }]; beforeEach(() => { readPrinterObjects.mockReset(); - readPrinterGraphic.mockReset(); + readPrinterObjectImage.mockReset(); deletePrinterObject.mockReset(); readPrinterStatus.mockReset(); useLabelStore.setState({ printerObjects: { phase: "idle" }, printerReading: undefined, printTarget: { ...DEFAULT_PRINT_TARGET, host: "172.17.17.175" } }); @@ -36,7 +37,7 @@ describe("readPrinterObjects", () => { const pending = useLabelStore.getState().readPrinterObjects(); expect(useLabelStore.getState().printerReading).toBe("^HW"); expect(await useLabelStore.getState().checkPrinter()).toEqual({ kind: "busy" }); - expect(await useLabelStore.getState().readPrinterGraphic(pre)).toEqual({ kind: "busy" }); + expect(await useLabelStore.getState().readPrinterObjectImage(pre)).toEqual({ kind: "busy" }); expect(await useLabelStore.getState().deletePrinterObject(pre)).toEqual({ kind: "busy" }); expect(readPrinterStatus).not.toHaveBeenCalled(); expect(deletePrinterObject).not.toHaveBeenCalled(); @@ -54,11 +55,19 @@ describe("readPrinterObjects", () => { }); }); -describe("readPrinterGraphic", () => { - it("hands the graphic back without touching the listing", async () => { - readPrinterGraphic.mockResolvedValue({ kind: "ok", value: { name: "PRE", gfa: "^GFA,2,2,1,FFFF" } }); - expect(await useLabelStore.getState().readPrinterGraphic(pre)).toMatchObject({ kind: "ok", value: { name: "PRE" } }); - expect(readPrinterGraphic).toHaveBeenCalledWith(target, pre); +describe("readPrinterObjectImage", () => { + it("hands the image back on the page's media without touching the listing", async () => { + let step: string | undefined; + readPrinterObjectImage.mockImplementation(() => { + step = useLabelStore.getState().printerReading; + return Promise.resolve({ kind: "ok", value: "data:image/png;base64,x" }); + }); + expect(await useLabelStore.getState().readPrinterObjectImage(font)).toEqual({ kind: "ok", value: "data:image/png;base64,x" }); + expect(step).toBe("^IS"); + const { widthMm, heightMm, dpmm } = useLabelStore.getState().label; + expect(readPrinterObjectImage).toHaveBeenCalledWith(target, font, expect.objectContaining({ widthMm, heightMm, dpmm })); + await useLabelStore.getState().readPrinterObjectImage(pre); + expect(step).toBe("^HG"); expect(useLabelStore.getState().printerObjects).toEqual({ phase: "idle" }); expect(useLabelStore.getState().printerReading).toBeUndefined(); }); diff --git a/src/store/slices/printerObjectsSlice.ts b/src/store/slices/printerObjectsSlice.ts index 2b955a70..0f7488eb 100644 --- a/src/store/slices/printerObjectsSlice.ts +++ b/src/store/slices/printerObjectsSlice.ts @@ -1,9 +1,10 @@ import type { StateCreator } from 'zustand'; -import { hostObjectPath, type HostDirectory, type HostGraphic, type HostObject } from '@zplab/core/lib/hostDirectory'; +import { hostObjectKind, hostObjectPath, type HostDirectory, type HostObject } from '@zplab/core/lib/hostDirectory'; import type { PrinterOutcome, PrinterQueryFailure } from '../../lib/printerQuery'; -import { deletePrinterObject, readPrinterGraphic, readPrinterObjects } from '../../lib/printerObjects'; +import { deletePrinterObject, readPrinterObjectImage, readPrinterObjects } from '../../lib/printerObjects'; import { queryKeyFor, resolveQueryTarget, type QueryTarget } from '../../lib/printTarget'; import type { LabelState } from '../labelStore'; +import { currentPageLabel } from '../labelStore.selectors'; import { overPrinterChannel } from '../printerChannel'; export type PrinterObjectsState = @@ -14,7 +15,8 @@ export type PrinterObjectsState = export interface PrinterObjectsSlice { printerObjects: PrinterObjectsState; readPrinterObjects: () => Promise; - readPrinterGraphic: (object: HostObject) => Promise>; + /** A graphic as stored, or a font drawn by the printer on the page's media. */ + readPrinterObjectImage: (object: HostObject) => Promise>; /** The fresh listing tells whether the printer took the delete (spec p.246 ignores unknown names). */ deletePrinterObject: (object: HostObject) => Promise; } @@ -41,9 +43,13 @@ export const createPrinterObjectsSlice: StateCreator { - const resolved = resolveQueryTarget(get().printTarget); - return overPrinterChannel(get, set, '^HG', () => ('failure' in resolved ? Promise.resolve(resolved.failure) : readPrinterGraphic(resolved.target, object))); + readPrinterObjectImage: (object) => { + const state = get(); + const resolved = resolveQueryTarget(state.printTarget); + const step = hostObjectKind(object.ext) === 'font' ? '^IS' : '^HG'; + return overPrinterChannel(get, set, step, () => + 'failure' in resolved ? Promise.resolve(resolved.failure) : readPrinterObjectImage(resolved.target, object, currentPageLabel(state)), + ); }, deletePrinterObject: (object) => {