-
-
-
-
- Connect a printer to preview and print a label.
-
-
+ :style="{height: (previewTape.mediaWidthMm * tapePxPerMm) + 'px'}">
@@ -142,7 +55,7 @@
+ :style="{height: (previewTape.mediaWidthMm * tapePxPerMm) + 'px'}">
@@ -169,60 +82,97 @@
Copies
+ v-model.number="copies" min="1" max="20" :disabled="!connected">
+
+
+
+ Tape height (px)
+
-
-
- Print
+
+
+
+ {{ connected ? "Print" : "Download PNG" }}
-
+
+
+
+ {{ d.name }}
+ disconnect
+ connect
+
+
+
+ Pair new printer
+
+
+
+
+ Print from the command line instead
+
+
Brother QL-series
+
{{ brotherCliExample }}
+
Niimbot
+
{{ niimbotCliExample }}
+
+
+
+
-
- No label printer paired yet.
-
-
-
-
-
{{ d.name }}
-
connected
-
{{ d.reason }}
+
+
+ {{ varLabel(v) }}
+
+
+
+
+
+
+
Calculated
+
+
+
{{ varLabel(v) }}
+
{{ fields[v] || "—" }}
-
- Disconnect
-
-
- Connect
-
-
-
-
-
- Pair new printer
-
+
+
-
@@ -243,7 +193,6 @@ import {MultiPrinterBlob, canvasToBitmap, bitmapToCanvas} from "../../vendor/web
import {
tapeFromStatus,
drawLabel,
- drawFallbackLabel,
buildLabelContent,
buildLabelFields,
preloadQrEncoder,
@@ -268,6 +217,14 @@ const MAX_RECENT_TEMPLATES = 4;
const MAX_ZOOM = 4; /* never magnify the preview more than this */
const MAX_PREVIEW_HEIGHT_PX = 300; /* never let the on-screen preview grow taller than this */
+// A plausible printer profile for the preview/ruler when the PNG Export "printer" (see deviceRows) is the active one; its height is user-adjustable (see simulatedHeightPx), everything else about it isn't.
+const SIMULATED_DPI = 180;
+const SIMULATED_DEFAULT_HEIGHT_PX = 72; /* ~9mm at SIMULATED_DPI */
+const SIMULATED_MIN_HEIGHT_PX = 8;
+const SIMULATED_MAX_HEIGHT_PX = 1000;
+const SIMULATED_LEAD_PX = Math.round(2 * SIMULATED_DPI / 25.4); /* blank runway before content, same idea as tapeFromStatus's marginsMm */
+// deviceRows' sentinel index for the always-present PNG Export row - never a real index into `devices`.
+const PNG_EXPORT_INDEX = -1;
// Tick spacing tiers, coarser the longer the ruler runs. See docs/implementation.md#ruler-tier-selection.
const RULER_TIERS = [
{aboveMm: 0, tickMm: 1, majorEveryMm: 5},
@@ -297,7 +254,8 @@ export default {
},
data() {
return {
- usbSupported: true,
+ // Whether this browser can pair with real USB printers at all; when false, deviceRows below only ever has its PNG Export row.
+ usbSupported: "usb" in navigator,
blobReady: false,
busy: false,
error: null,
@@ -311,7 +269,7 @@ export default {
printedWidthPx: 0,
// Each "text" leaf's effective font size (see label.js's drawLabel), shown beside the tape width so a too-small-to-render field reads as a suspiciously tiny number rather than silently absent.
textSizesPx: [],
- // Scan-reliability messages from label.js's drawLabel/drawFallbackLabel (too few px per QR module, or too small in mm) - the label still rendered/prints fine, just flagged as a risk.
+ // Scan-reliability messages from label.js's drawLabel (too few px per QR module, or too small in mm) - the label still rendered/prints fine, just flagged as a risk.
warnings: [],
// One input per BASE_VARS entry; derived vars (userHandle/itemUrl/itemHandle) are calculated-only (see the `fields` computed), never stored here. Prefilled from query params but left editable. queryFields is applied last so an explicit ?text=... etc. always wins over a `kind` builder's computed value.
@@ -324,13 +282,16 @@ export default {
...Object.fromEntries(BASE_VARS.filter(v => v in this.queryFields).map(v => [v, this.queryFields[v]])),
},
copies: 1,
+ // Only sent to a real printer whose driver reports the "chain" capability (see chainSupported) - harmless to leave set when switching to a driver that ignores it.
+ chainMode: false,
+ // PNG Export's stand-in for a real tape's mediaWidthMm - there's no physical tape to measure, so this is the one thing about previewTape's simulated profile the user can adjust.
+ simulatedHeightPx: SIMULATED_DEFAULT_HEIGHT_PX,
selectedTemplate: LABEL_TEMPLATES[0].id,
// Ids of the last MAX_RECENT_TEMPLATES distinct templates printed/downloaded, most recent first. See rememberPrintedTemplate/loadRecentTemplateIds.
recentTemplateIds: [],
- // "along" reads along the tape's feed direction (usual case, width-constrained); "across" turns 90deg on that same width instead. See label.js's drawLabel/drawFallbackLabel.
+ // "along" reads along the tape's feed direction (usual case, width-constrained); "across" turns 90deg on that same width instead. See label.js's drawLabel.
orientation: "along",
- fallbackReady: false,
// TODO: replace with the real commands for our printers.
brotherCliExample: "brother_ql --model QL-000 --printer usb://0000:0000 print --label 00 label.png",
niimbotCliExample: "niimprint --model b00 --conn usb print --density 3 --image label.png",
@@ -371,11 +332,22 @@ export default {
currentTemplate() {
return LABEL_TEMPLATES.find(t => t.id === this.selectedTemplate) || LABEL_TEMPLATES[0];
},
+ // Always includes the virtual PNG Export row, so the printer list (and the preview built from
+ // whichever row is active) looks the same whether or not this browser/session has any real
+ // USB printer paired - PNG Export is simply the active row whenever no real device is.
deviceRows() {
+ const pngRow = {
+ index: PNG_EXPORT_INDEX,
+ name: "PNG Export",
+ supported: true,
+ reason: "",
+ isConnected: !this.connected,
+ isPngExport: true,
+ };
if (!this.blob) {
- return [];
+ return [pngRow];
}
- return this.devices.map((d, index) => {
+ const real = this.devices.map((d, index) => {
const info = this.blob.findDevice(d.vendorId, d.productId);
return {
index,
@@ -385,20 +357,41 @@ export default {
isConnected: d === this.connected,
};
});
+ return [pngRow, ...real];
},
- canPrint() {
- return Boolean(this.tape && this.labelBitmap && !this.busy);
+ // Print/Download button and Copies input are only meaningful with a real printer connected; PNG Export can always export once a bitmap's rendered.
+ canAct() {
+ return Boolean(this.labelBitmap && !this.busy);
+ },
+ // Whether the connected printer's driver honors chain mode - some (e.g. Brother's QL line) don't. See vendor/weblabel.js's MultiPrinterBlob.can.
+ chainSupported() {
+ return Boolean(this.connected && this.blob && this.blob.can("chain", this.connected));
+ },
+ // The connected printer's tape, or PNG Export's stand-in profile (its height user-adjustable via simulatedHeightPx) so the ruler/preview always have something realistic to show.
+ previewTape() {
+ if (this.tape) {
+ return this.tape;
+ }
+ const printAreaPx = Math.min(SIMULATED_MAX_HEIGHT_PX,
+ Math.max(SIMULATED_MIN_HEIGHT_PX, Number(this.simulatedHeightPx) || SIMULATED_DEFAULT_HEIGHT_PX));
+ return {
+ dpi: SIMULATED_DPI,
+ mediaWidthMm: Math.round(printAreaPx / SIMULATED_DPI * 25.4 * 10) / 10,
+ printAreaPx,
+ printLengthPx: 0,
+ leadPx: SIMULATED_LEAD_PX,
+ };
},
// px per real mm at current zoom; meaningful only for the tape-fed preview (the no-webusb fallback has no real tape/dpi, so no ruler).
tapePxPerMm() {
- return this.tape ? (this.tape.dpi / 25.4) * this.zoom : 0;
+ return (this.previewTape.dpi / 25.4) * this.zoom;
},
// Physical mm length each ruler axis must cover; see horizontal/verticalRulerTicks for what each measures.
horizontalTotalMm() {
- return (this.tape && this.printedWidthPx) ? this.printedWidthPx / (this.tape.dpi / 25.4) : 0;
+ return this.printedWidthPx ? this.printedWidthPx / (this.previewTape.dpi / 25.4) : 0;
},
verticalTotalMm() {
- return this.tape ? this.tape.mediaWidthMm : 0;
+ return this.previewTape.mediaWidthMm;
},
// Shared tier keyed off whichever axis is longer. See docs/implementation.md#ruler-tier-selection.
rulerTier() {
@@ -407,14 +400,14 @@ export default {
},
// Ticks along the tape's printed length, feed margins included (still real tape).
horizontalRulerTicks() {
- if (!this.tape || !this.printedWidthPx) {
+ if (!this.printedWidthPx) {
return [];
}
return this.rulerTicks(this.horizontalTotalMm);
},
// Ticks across the tape's full width, not just the printable area. See docs/implementation.md#tape-full-print-margin.
verticalRulerTicks() {
- return this.tape ? this.rulerTicks(this.verticalTotalMm) : [];
+ return this.rulerTicks(this.verticalTotalMm);
},
// e.g. "(5px, 23px)", or "" so it appends cleanly onto the tape-width
with nothing shown.
textSizesSummary() {
@@ -427,29 +420,20 @@ export default {
watch: {
varValues: {
handler() {
- if (this.usbSupported) {
- this.redraw();
- } else {
- this.redrawFallback();
- }
+ this.redraw();
},
deep: true,
},
selectedTemplate() {
- if (this.usbSupported) {
- this.redraw();
- } else {
- this.redrawFallback();
- }
+ this.redraw();
},
orientation() {
- if (this.usbSupported) {
- this.redraw();
- } else {
- this.redrawFallback();
- }
+ this.redraw();
},
- // Catches printer connect/disconnect/switch; flush:'post' since the canvas only exists once `tape` is truthy (template's v-if).
+ simulatedHeightPx() {
+ this.redraw();
+ },
+ // Catches printer connect/disconnect/switch, redrawing with the real tape or falling back to previewTape's simulated one.
tape: {
handler() {
this.redraw();
@@ -517,7 +501,7 @@ export default {
}
},
- rememberPrintedTemplate(id) {
+ rememberPrintedTemplate(id) {
const updated = [id, ...this.recentTemplateIds.filter(t => t !== id)].slice(0, MAX_RECENT_TEMPLATES);
this.recentTemplateIds = updated;
try {
@@ -583,6 +567,10 @@ export default {
},
connect(index) {
+ if (index === PNG_EXPORT_INDEX) {
+ this.guard(() => this.closeConnection());
+ return;
+ }
this.guard(() => this.connectToDevice(this.devices[index]));
},
@@ -604,9 +592,6 @@ export default {
redraw() {
this.labelBitmap = null;
- if (!this.tape) {
- return;
- }
const canvas = this.$refs.labelCanvas;
if (!canvas) {
return;
@@ -615,7 +600,7 @@ export default {
let textSizesPx, warnings;
try {
const content = templateContent(this.currentTemplate, this.fields);
- ({textSizesPx, warnings} = drawLabel(canvas, this.tape, content, this.orientation));
+ ({textSizesPx, warnings} = drawLabel(canvas, this.previewTape, content, this.orientation));
} catch (e) {
this.error = e.message;
return;
@@ -630,29 +615,8 @@ export default {
this.zoom = this.fitZoom(canvas);
},
- redrawFallback() {
- this.fallbackReady = false;
- const canvas = this.$refs.fallbackCanvas;
- if (!canvas) {
- return;
- }
- this.resizeObserver.observe(canvas.parentElement);
- let warnings;
- try {
- const content = templateContent(this.currentTemplate, this.fields);
- ({warnings} = drawFallbackLabel(canvas, content, this.orientation));
- } catch (e) {
- this.error = e.message;
- return;
- }
- this.error = null;
- this.warnings = warnings;
- this.fallbackReady = true;
- this.fitZoom(canvas);
- },
-
downloadPng() {
- const canvas = this.$refs.fallbackCanvas;
+ const canvas = this.$refs.labelCanvas;
if (!canvas) {
return;
}
@@ -681,7 +645,7 @@ export default {
print() {
this.guard(async () => {
const copies = Math.max(1, Math.min(20, Number(this.copies) || 1));
- await this.blob.printBitmap(this.labelBitmap, {copies});
+ await this.blob.printBitmap(this.labelBitmap, {copies, chain: this.chainMode});
this.rememberPrintedTemplate(this.selectedTemplate);
});
},
@@ -723,22 +687,23 @@ export default {
// Same reasoning - drawTextLeaf's bitmap-font tiers (see label.js's fontTierFor) need this
// loaded before the first real draw, same as qrReady below.
const fontsReady = Promise.all([qrReady, preloadPixelFontRenderer()]);
- if (!("usb" in navigator)) {
- this.usbSupported = false;
- await Promise.all([nextTick(), fontsReady]);
- this.redrawFallback();
- return;
- }
- try {
- this.blob = await MultiPrinterBlob.load(BLOB_URL);
- } catch (e) {
- this.error = `Could not load the printer driver: ${e.message}`;
+ const blobLoad = this.usbSupported
+ ? MultiPrinterBlob.load(BLOB_URL).catch(e => {
+ this.error = `Could not load the printer driver: ${e.message}`;
+ return null;
+ })
+ : Promise.resolve(null);
+ await fontsReady;
+ // Draws PNG Export's simulated tape right away rather than waiting on the driver/device enumeration below; the tape watcher redraws again with a real one if/once autoConnectPreferred finds it.
+ await nextTick();
+ this.redraw();
+ this.blob = await blobLoad;
+ if (!this.blob) {
return;
}
this.blobReady = true;
navigator.usb.addEventListener("connect", this.onUsbChange);
navigator.usb.addEventListener("disconnect", this.onUsbChange);
- await fontsReady;
await this.guard(async () => {
await this.refreshDevices();
await this.autoConnectPreferred();
@@ -756,6 +721,44 @@ export default {