Skip to content

Commit e45ac16

Browse files
committed
feat(export): PDF export rendered by the preview renderer
1 parent 4f6dcc0 commit e45ac16

61 files changed

Lines changed: 1235 additions & 143 deletions

Some content is hidden

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

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@ The **ZPL** panel at the bottom shows the generated ZPL, **Copy** puts it on the
8282

8383
- **File → Export ZPL:** saves a `.zpl` file, or `.prn` where the save dialog offers file types
8484
- **File → Send to Zebra Printer:** opens the send dialog. It sends over the network, through the Zebra Browser Print agent in the browser, or through the system spooler or USB on desktop.
85-
- **File → Print as Image (browser):** opens the Labelary preview, then the browser print dialog
85+
- **File → Export PDF:** saves one PDF page per label page, rendered by the preview renderer. With the renderer off it holds the current page only. A batch PDF holds one page per data row.
86+
- **File → Print as Image (browser):** opens the rendered label, then the browser print dialog
8687

8788
### 5. Save the design
8889

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, expect, it } from "vitest";
2+
import { unzlibSync } from "fflate";
3+
import { writePdf, type PdfPage } from "./pdfWriter";
4+
5+
const latin1 = (bytes: Uint8Array): string => Array.from(bytes, (b) => String.fromCharCode(b)).join("");
6+
7+
const page = (fill: number): PdfPage => ({ widthMm: 50, heightMm: 30, width: 16, height: 8, mono: new Uint8Array(16).fill(fill) });
8+
9+
describe("writePdf", () => {
10+
it("lays out catalog, pages, info and one image per page with a cross-reference table whose offsets land on each object", () => {
11+
const pdf = writePdf([page(0xff), page(0x0f)], { producer: "ZPLab", subject: "Rendered with a test" });
12+
const text = latin1(pdf);
13+
expect(text.startsWith("%PDF-1.4\n")).toBe(true);
14+
expect(text.endsWith("%%EOF\n")).toBe(true);
15+
expect(text).toContain("/Count 2");
16+
expect(text).toContain("/MediaBox [0 0 141.73 85.04]");
17+
expect(text).toContain("/Producer (ZPLab) /Subject (Rendered with a test)");
18+
const xrefAt = Number(/startxref\n(\d+)\n%%EOF/.exec(text)![1]);
19+
expect(text.slice(xrefAt, xrefAt + 4)).toBe("xref");
20+
const entries = text.slice(xrefAt).split("\n").slice(2, 12);
21+
expect(entries).toHaveLength(10);
22+
entries.slice(1).forEach((entry, i) => {
23+
const offset = Number(entry.slice(0, 10));
24+
expect(text.slice(offset).startsWith(`${i + 1} 0 obj\n`)).toBe(true);
25+
});
26+
});
27+
28+
it("stores every stream with its exact length and the rows inverted for DeviceGray", () => {
29+
const pdf = writePdf([page(0xf0)], { producer: "ZPLab" });
30+
const text = latin1(pdf);
31+
const streams = [...text.matchAll(/\/Length (\d+) >>\nstream\n/g)];
32+
expect(streams).toHaveLength(2);
33+
for (const m of streams) {
34+
const start = m.index! + m[0].length;
35+
expect(text.slice(start + Number(m[1]), start + Number(m[1]) + 10)).toBe("\nendstream");
36+
}
37+
const image = streams[0]!;
38+
const start = image.index! + image[0].length;
39+
const bytes = pdf.slice(start, start + Number(image[1]));
40+
expect([...unzlibSync(bytes)]).toEqual(new Array(16).fill(0x0f));
41+
expect(text).toContain("q 141.73 0 0 85.04 0 0 cm /Im0 Do Q");
42+
});
43+
44+
it("escapes ASCII metadata, carries other scripts as UTF-16 and refuses what it cannot lay out", () => {
45+
const text = latin1(writePdf([page(0)], { producer: "ZP(L)ab\\", title: "Étiquette", subject: "ラベル" }));
46+
expect(text).toContain("/Producer (ZP\\(L\\)ab\\\\)");
47+
expect(text).toContain("/Title <FEFF00C9007400690071007500650074007400650>".slice(0, 20));
48+
expect(text).toContain("/Subject <FEFF30E930D930EB>");
49+
expect(() => writePdf([], { producer: "ZPLab" })).toThrow();
50+
expect(() => writePdf([{ ...page(0), mono: new Uint8Array(15) }], { producer: "ZPLab" })).toThrow();
51+
expect(() => writePdf([{ ...page(0), width: 0 }], { producer: "ZPLab" })).toThrow();
52+
});
53+
});

‎packages/core/src/lib/pdfWriter.ts‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { zlibSync } from 'fflate';
2+
3+
/** One label as a 1-bit raster: `height` rows of `ceil(width / 8)` bytes, a set bit prints black, like ^GF. */
4+
export interface PdfPage {
5+
widthMm: number;
6+
heightMm: number;
7+
width: number;
8+
height: number;
9+
mono: Uint8Array;
10+
}
11+
12+
export interface PdfMeta {
13+
producer: string;
14+
title?: string;
15+
subject?: string;
16+
}
17+
18+
const PT_PER_MM = 72 / 25.4;
19+
const encoder = new TextEncoder();
20+
21+
/** A text string: escaped ASCII when that suffices, else UTF-16BE with a byte-order mark as a hex string. */
22+
function literal(text: string): string {
23+
if (/^[\x20-\x7e]*$/.test(text)) return `(${text.replace(/[\\()]/g, (c) => `\\${c}`)})`;
24+
const units: string[] = ['FEFF'];
25+
for (let i = 0; i < text.length; i++) units.push(text.charCodeAt(i).toString(16).toUpperCase().padStart(4, '0'));
26+
return `<${units.join('')}>`;
27+
}
28+
29+
function pt(mm: number): string {
30+
return (Math.round(mm * PT_PER_MM * 100) / 100).toString();
31+
}
32+
33+
/** DeviceGray reads a set bit as white, so the rows flip before they are written. */
34+
function invert(mono: Uint8Array): Uint8Array {
35+
const out = new Uint8Array(mono.length);
36+
for (let i = 0; i < mono.length; i++) out[i] = ~(mono[i] ?? 0) & 0xff;
37+
return out;
38+
}
39+
40+
/** A minimal PDF 1.4 with one Flate-compressed 1-bit image per page and a cross-reference table with exact byte offsets. */
41+
export function writePdf(pages: readonly PdfPage[], meta: PdfMeta): Uint8Array {
42+
if (pages.length === 0) throw new Error('a PDF needs at least one page');
43+
for (const p of pages) {
44+
if (!(p.width > 0) || !(p.height > 0)) throw new Error('a page needs a width and a height in dots');
45+
if (p.mono.length !== Math.ceil(p.width / 8) * p.height) throw new Error('raster length does not match its dimensions');
46+
}
47+
const chunks: Uint8Array[] = [];
48+
const offsets: number[] = [];
49+
let length = 0;
50+
const push = (bytes: Uint8Array): void => {
51+
chunks.push(bytes);
52+
length += bytes.length;
53+
};
54+
const text = (s: string): void => push(encoder.encode(s));
55+
const object = (n: number, body: string, stream?: Uint8Array): void => {
56+
offsets[n] = length;
57+
text(`${n} 0 obj\n${body}\n`);
58+
if (stream) {
59+
text('stream\n');
60+
push(stream);
61+
text('\nendstream\n');
62+
}
63+
text('endobj\n');
64+
};
65+
66+
// Binary comment after the header, so transfer tools treat the file as binary.
67+
push(new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x34, 0x0a, 0x25, 0xe2, 0xe3, 0xcf, 0xd3, 0x0a]));
68+
const firstPage = 4;
69+
const pageIds = pages.map((_, i) => firstPage + i * 3);
70+
object(1, '<< /Type /Catalog /Pages 2 0 R >>');
71+
object(2, `<< /Type /Pages /Kids [${pageIds.map((id) => `${id} 0 R`).join(' ')}] /Count ${pages.length} >>`);
72+
const info = [`/Producer ${literal(meta.producer)}`, meta.title === undefined ? '' : `/Title ${literal(meta.title)}`, meta.subject === undefined ? '' : `/Subject ${literal(meta.subject)}`]
73+
.filter((s) => s !== '')
74+
.join(' ');
75+
object(3, `<< ${info} >>`);
76+
pages.forEach((page, i) => {
77+
const id = firstPage + i * 3;
78+
const w = pt(page.widthMm);
79+
const h = pt(page.heightMm);
80+
object(id, `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${w} ${h}] /Resources << /XObject << /Im0 ${id + 1} 0 R >> >> /Contents ${id + 2} 0 R >>`);
81+
const image = zlibSync(invert(page.mono));
82+
object(
83+
id + 1,
84+
`<< /Type /XObject /Subtype /Image /Width ${page.width} /Height ${page.height} /ColorSpace /DeviceGray /BitsPerComponent 1 /Filter /FlateDecode /Length ${image.length} >>`,
85+
image,
86+
);
87+
const content = encoder.encode(`q ${w} 0 0 ${h} 0 0 cm /Im0 Do Q`);
88+
object(id + 2, `<< /Length ${content.length} >>`, content);
89+
});
90+
91+
const count = firstPage + pages.length * 3;
92+
const xref = length;
93+
text(`xref\n0 ${count}\n0000000000 65535 f \n`);
94+
for (let n = 1; n < count; n++) text(`${String(offsets[n]).padStart(10, '0')} 00000 n \n`);
95+
text(`trailer\n<< /Size ${count} /Root 1 0 R /Info 3 0 R >>\nstartxref\n${xref}\n%%EOF\n`);
96+
97+
const out = new Uint8Array(length);
98+
let at = 0;
99+
for (const c of chunks) {
100+
out.set(c, at);
101+
at += c.length;
102+
}
103+
return out;
104+
}

‎src/components/AppShell.tsx‎

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ import {
4242
MoonIcon,
4343
GlobeAltIcon,
4444
} from "@heroicons/react/16/solid";
45-
import { useLabelStore, useHistory, selectLabelaryNoticeRequired, selectEditorFrozen, selectSourceEditing, selectSourceEditDirty, selectDocumentEmits, selectBatchPrintCount } from "../store/labelStore";
45+
import { useLabelStore, useHistory, selectLabelaryNoticeRequired, selectEffectivePreviewProvider, selectEditorFrozen, selectSourceEditing, selectSourceEditDirty, selectDocumentEmits, selectBatchPrintCount } from "../store/labelStore";
4646
import { datasetTimestamp } from "@zplab/core/types/DataSource";
4747
import { isCurrentDataContext, settleDatasetReplace } from "../store/datasetActions";
4848
import { ConfirmDialog } from "./ui/ConfirmDialog";
@@ -54,6 +54,8 @@ import { isDesktopShell, isMacDesktop } from "../lib/platform";
5454
import { acceptAttr, DESIGN_FILTER, CSV_FILTER } from "../lib/fileDialogs";
5555
import { openExternal, REPO_URL } from "../lib/openExternal";
5656
import { LabelaryNoticeModal } from "./Output/LabelaryNoticeModal";
57+
import { PdfExportDialog } from "./Output/PdfExportDialog";
58+
import { usePdfExport } from "../hooks/usePdfExport";
5759
import { PrinterSettingsModal } from "./PrinterSettings/PrinterSettingsModal";
5860
import { Gs1ContentModal } from "./Barcode/Gs1ContentModal";
5961
import { ContentBuilderModal } from "./Barcode/ContentBuilderModal";
@@ -106,6 +108,8 @@ const MENU_ICONS: Partial<Record<MenuItemId, ComponentType<SVGProps<SVGSVGElemen
106108
settings: Cog6ToothIcon,
107109
exportZpl: ArrowDownTrayIcon,
108110
exportBatch: ArrowDownTrayIcon,
111+
exportPdf: ArrowDownTrayIcon,
112+
exportBatchPdf: ArrowDownTrayIcon,
109113
openDesign: FolderOpenIcon,
110114
saveDesign: DocumentArrowDownIcon,
111115
importCsv: TableCellsIcon,
@@ -135,9 +139,12 @@ export function AppShell() {
135139
const restoreReplacedDesign = useLabelStore((s) => s.restoreReplacedDesign);
136140
const dismissReplacedDesign = useLabelStore((s) => s.dismissReplacedDesign);
137141
const sourceEditDirty = useLabelStore(selectSourceEditDirty);
138-
const labelaryEnabled = useLabelStore((s) => s.thirdParty.labelary);
139142
const noticeRequired = useLabelStore(selectLabelaryNoticeRequired);
140-
const [showPrintNotice, setShowPrintNotice] = useState(false);
143+
const renderer = useLabelStore(selectEffectivePreviewProvider);
144+
const pageCount = useLabelStore((s) => s.pages.length);
145+
// The Labelary notice gates every Labelary render, so the action waits here until the user continues.
146+
const [afterNotice, setAfterNotice] = useState<(() => void) | null>(null);
147+
const withNotice = (action: () => void) => (renderer === "labelary" && noticeRequired ? setAfterNotice(() => action) : action());
141148
const appUpdate = useLabelStore((s) => s.appUpdate);
142149
const checkForAppUpdate = useLabelStore((s) => s.checkForAppUpdate);
143150
const installAppUpdate = useLabelStore((s) => s.installAppUpdate);
@@ -205,6 +212,9 @@ export function AppShell() {
205212
// timestamp is unique per import/fetch, so it also catches re-loading the
206213
// same file or table (which a name key would miss).
207214
const datasetKey = useLabelStore((s) => s.dataset && datasetTimestamp(s.dataset.source));
215+
// Imperative handle to the canvas: live render bboxes for the properties panel and the label capture for print and PDF.
216+
const canvasRef = useRef<LabelCanvasHandle>(null);
217+
const { progress: pdfProgress, exportPdf, exportBatchPdf, cancel: cancelPdfExport } = usePdfExport(canvasRef);
208218
const {
209219
showZplImport,
210220
openZplImport,
@@ -218,7 +228,7 @@ export function AppShell() {
218228
canBatchExport,
219229
batchRowCount,
220230
handlePrint,
221-
} = useZplImportExport();
231+
} = useZplImportExport(canvasRef);
222232
const outputPanel = useOutputPanel(OUTPUT_DEFAULT_H, sourceEditing);
223233
const leftPanel = useCollapsiblePanel("zpl-panel-left");
224234
const rightPanel = useCollapsiblePanel("zpl-panel-right");
@@ -233,7 +243,8 @@ export function AppShell() {
233243
batchRowCount,
234244
batchPrintCount,
235245
connectDataWizard: isDesktopShell,
236-
labelaryEnabled,
246+
canBatchPdf: renderer !== "none",
247+
pdfCurrentPageOnly: renderer === "none" && pageCount > 1,
237248
canUndo,
238249
canRedo,
239250
// On macOS quit lives in the app submenu (Cmd+Q), not the File section.
@@ -246,24 +257,20 @@ export function AppShell() {
246257
settings: () => setPrinterSettingsTab("appSettings"),
247258
exportZpl: handleDownload,
248259
exportBatch: handleExportBatch,
260+
exportPdf: () => withNotice(exportPdf),
261+
exportBatchPdf: () => withNotice(exportBatchPdf),
249262
openDesign: handleOpen,
250263
saveDesign: handleSave,
251264
importCsv: openCsvPicker,
252265
connectData: openConnectWizard,
253-
// Print routes through Labelary; clicking before the notice has been
254-
// acknowledged opens the disclosure first, then prints.
255-
print: () => (noticeRequired ? setShowPrintNotice(true) : void handlePrint()),
266+
print: () => withNotice(() => void handlePrint()),
256267
sendToZebra: openZebraPrint,
257268
undo: () => undo(),
258269
redo: () => redo(),
259270
github: () => openExternal(REPO_URL),
260271
// Desktop-only item (includeQuit); never reached on web.
261272
quit: () => void quitApp(),
262273
};
263-
// Imperative handle to the canvas for actions PropertiesPanel needs live
264-
// render bboxes for (e.g. align-to-label centring).
265-
const canvasRef = useRef<LabelCanvasHandle>(null);
266-
267274
return (
268275
<div className="flex flex-col h-screen overflow-hidden bg-bg text-text font-sans">
269276
<SourceShadowSync />
@@ -595,15 +602,16 @@ export function AppShell() {
595602
{showZebraPrint && (
596603
<PrintToZebraDialog zpl={currentZpl()} onClose={closeZebraPrint} />
597604
)}
598-
{showPrintNotice && (
605+
{afterNotice && (
599606
<LabelaryNoticeModal
600-
onClose={() => setShowPrintNotice(false)}
607+
onClose={() => setAfterNotice(null)}
601608
onContinue={() => {
602-
setShowPrintNotice(false);
603-
handlePrint();
609+
setAfterNotice(null);
610+
afterNotice();
604611
}}
605612
/>
606613
)}
614+
{pdfProgress && <PdfExportDialog progress={pdfProgress} onCancel={cancelPdfExport} />}
607615
<PrinterSettingsModal />
608616
<Gs1ContentModal />
609617
<ContentBuilderModal />

‎src/components/Canvas/BarcodeObject.tsx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { barcodeFtAnchorOffset, qrPrintsAsGraphic, rightAnchorShiftDots } from "
88
import { qrByHeight } from "@zplab/core/registry/qrcode";
99
import { useColorScheme, CANVAS_WARNING } from "../../hooks/useColorScheme";
1010
import { useFontCacheVersion } from "../../hooks/useFontCacheVersion";
11-
import { selectionHandlers, useBlankFieldWarns, PLACEHOLDER_DASH, PLACEHOLDER_STROKE_PX, type KonvaObjectProps } from "./konvaObjectProps";
11+
import { selectionHandlers, useBlankFieldWarns, CAPTURE_CHROME, PLACEHOLDER_DASH, PLACEHOLDER_STROKE_PX, type KonvaObjectProps } from "./konvaObjectProps";
1212
import { setMeasuredBounds, clearMeasuredBounds } from "../../lib/measuredBoundsCache";
1313
import {
1414
getDisplaySize,
@@ -82,6 +82,7 @@ export function StateFrame({
8282
}) {
8383
return (
8484
<Shape
85+
name={CAPTURE_CHROME}
8586
listening={false}
8687
sceneFunc={(ctx) => {
8788
// Scale by the inherited alpha (a plain Rect would); the drop ghost
@@ -447,7 +448,7 @@ export function BarcodeObject({
447448
onDragMove={handleDragMove}
448449
onDragEnd={handleDragEnd}
449450
>
450-
<Group x={innerTr.x} y={innerTr.y} rotation={innerTr.rotation}>
451+
<Group x={innerTr.x} y={innerTr.y} rotation={innerTr.rotation} name={showSample ? CAPTURE_CHROME : undefined}>
451452
<KImage
452453
x={ub.barLeftPx}
453454
y={ub.barTopPx}
@@ -512,7 +513,7 @@ export function BarcodeObject({
512513
onDragMove={handleDragMove}
513514
onDragEnd={handleDragEnd}
514515
>
515-
<Group x={defaultInnerTr.x} y={defaultInnerTr.y} rotation={defaultInnerTr.rotation}>
516+
<Group x={defaultInnerTr.x} y={defaultInnerTr.y} rotation={defaultInnerTr.rotation} name={showSample ? CAPTURE_CHROME : undefined}>
516517
<KImage
517518
x={ub.barLeftPx}
518519
y={ub.barTopPx}

‎src/components/Canvas/KonvaObject.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ function PlaceholderRect({
133133
return (
134134
<Rect
135135
key={fontVersion}
136+
name={CAPTURE_CHROME}
136137
x={x}
137138
y={y}
138139
width={width}

0 commit comments

Comments
 (0)