Port NiimBlue label designer to Fichero D11s with local BLE protocol library

This commit is contained in:
Hamza
2026-03-01 22:47:16 +01:00
parent b1ff403594
commit 774b02bb99
102 changed files with 16333 additions and 951 deletions

3
web/.env Normal file
View File

@@ -0,0 +1,3 @@
VITE_DEPLOY_BASE_URL=https://fichero.app
VITE_PAGE_TITLE=Fichero
VITE_PAGE_DESCRIPTION=Fichero D11s label printer web app. Design and print labels directly from your browser!

5
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
/.vscode
/dist
/node_modules
.idea
CLAUDE.md

View File

@@ -1,951 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fichero D11s Label Printer</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #1a1a2e;
--surface: #16213e;
--surface2: #0f3460;
--accent: #e94560;
--text: #eee;
--text2: #aab;
--ok: #4ade80;
--warn: #fbbf24;
--err: #f87171;
--radius: 8px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
}
.container {
width: 100%;
max-width: 560px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
/* Top bar */
.topbar {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--surface);
border-radius: var(--radius);
}
.topbar h1 {
font-size: 16px;
font-weight: 600;
flex: 1;
}
.status-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--err);
}
.status-dot.connected { background: var(--ok); }
.battery {
font-size: 13px;
color: var(--text2);
display: none;
}
.battery.visible { display: inline; }
button {
background: var(--accent);
color: #fff;
border: none;
padding: 8px 16px;
border-radius: var(--radius);
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: opacity .15s;
}
button:hover { opacity: .85; }
button:disabled { opacity: .4; cursor: not-allowed; }
.btn-sm { padding: 6px 12px; font-size: 13px; }
.btn-outline {
background: transparent;
border: 1px solid var(--accent);
color: var(--accent);
}
/* Tabs */
.tabs {
display: flex;
gap: 0;
background: var(--surface);
border-radius: var(--radius);
overflow: hidden;
}
.tab-btn {
flex: 1;
background: transparent;
color: var(--text2);
border-radius: 0;
padding: 10px;
font-size: 14px;
}
.tab-btn.active {
background: var(--surface2);
color: var(--text);
}
.tab-panel { display: none; }
.tab-panel.active { display: flex; flex-direction: column; gap: 12px; }
/* Main panels */
.panel {
background: var(--surface);
border-radius: var(--radius);
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.panel h2 {
font-size: 14px;
font-weight: 600;
color: var(--text2);
text-transform: uppercase;
letter-spacing: .5px;
}
textarea {
width: 100%;
background: var(--bg);
color: var(--text);
border: 1px solid var(--surface2);
border-radius: var(--radius);
padding: 10px;
font-size: 16px;
resize: vertical;
min-height: 60px;
font-family: inherit;
}
textarea:focus { outline: 1px solid var(--accent); border-color: var(--accent); }
input[type="range"] {
width: 100%;
accent-color: var(--accent);
}
.range-row {
display: flex;
align-items: center;
gap: 10px;
}
.range-row label { font-size: 13px; color: var(--text2); min-width: 80px; }
.range-row span { font-size: 13px; color: var(--text); min-width: 32px; text-align: right; }
/* Preview */
.preview-wrap {
display: flex;
justify-content: center;
padding: 12px;
background: #fff;
border-radius: var(--radius);
min-height: 60px;
}
.preview-wrap canvas {
image-rendering: pixelated;
}
/* Image drop zone */
.dropzone {
border: 2px dashed var(--surface2);
border-radius: var(--radius);
padding: 32px 16px;
text-align: center;
color: var(--text2);
font-size: 14px;
cursor: pointer;
transition: border-color .2s, background .2s;
}
.dropzone.dragover {
border-color: var(--accent);
background: rgba(233, 69, 96, .08);
}
.dropzone input { display: none; }
/* Settings grid */
.settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.setting-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.setting-item label { font-size: 12px; color: var(--text2); }
.setting-item select, .setting-item input[type="number"] {
background: var(--bg);
color: var(--text);
border: 1px solid var(--surface2);
border-radius: 6px;
padding: 6px 8px;
font-size: 14px;
}
.setting-item select:focus, .setting-item input[type="number"]:focus {
outline: 1px solid var(--accent);
border-color: var(--accent);
}
/* Device info */
.info-grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 12px;
font-size: 13px;
}
.info-grid .label { color: var(--text2); }
.info-grid .value { color: var(--text); font-family: "SF Mono", "Menlo", monospace; font-size: 12px; }
/* Log */
.log {
font-family: "SF Mono", "Menlo", monospace;
font-size: 11px;
color: var(--text2);
max-height: 120px;
overflow-y: auto;
background: var(--bg);
border-radius: 6px;
padding: 8px;
line-height: 1.6;
}
.print-actions {
display: flex;
gap: 8px;
align-items: center;
}
.print-actions button { flex: 1; }
.print-actions .copies-input {
display: flex;
align-items: center;
gap: 4px;
}
.print-actions .copies-input label { font-size: 12px; color: var(--text2); }
.print-actions .copies-input input {
width: 50px;
background: var(--bg);
color: var(--text);
border: 1px solid var(--surface2);
border-radius: 6px;
padding: 6px;
font-size: 13px;
text-align: center;
}
</style>
</head>
<body>
<div class="container">
<!-- Top bar -->
<div class="topbar">
<h1>Fichero D11s</h1>
<span class="battery" id="battery"></span>
<span class="status-dot" id="statusDot"></span>
<button id="connectBtn" onclick="toggleConnect()">Connect</button>
</div>
<!-- Tabs -->
<div class="tabs">
<button class="tab-btn active" data-tab="text" onclick="switchTab('text')">Text</button>
<button class="tab-btn" data-tab="image" onclick="switchTab('image')">Image</button>
</div>
<!-- Text tab -->
<div class="tab-panel active" id="tab-text">
<div class="panel">
<textarea id="textInput" placeholder="Type your label text..." rows="2">Hello</textarea>
<div class="range-row">
<label>Font size</label>
<input type="range" id="fontSize" min="10" max="72" value="30">
<span id="fontSizeVal">30</span>
</div>
<div class="preview-wrap">
<canvas id="textPreview"></canvas>
</div>
<div class="print-actions">
<button id="textPrintBtn" onclick="printText()" disabled>Print</button>
<div class="copies-input">
<label>x</label>
<input type="number" id="textCopies" value="1" min="1" max="99">
</div>
</div>
</div>
</div>
<!-- Image tab -->
<div class="tab-panel" id="tab-image">
<div class="panel">
<div class="dropzone" id="dropzone">
<div>Drop an image here, or click to browse</div>
<input type="file" id="fileInput" accept="image/*">
</div>
<label style="font-size:13px;color:var(--text2);display:flex;align-items:center;gap:6px">
<input type="checkbox" id="imgDither" checked> Floyd-Steinberg dithering
</label>
<div class="preview-wrap" id="imgPreviewWrap" style="display:none">
<canvas id="imgPreview"></canvas>
</div>
<div class="print-actions">
<button id="imgPrintBtn" onclick="printImage()" disabled>Print</button>
<div class="copies-input">
<label>x</label>
<input type="number" id="imgCopies" value="1" min="1" max="99">
</div>
</div>
</div>
</div>
<!-- Settings -->
<div class="panel">
<h2>Settings</h2>
<div class="settings-grid">
<div class="setting-item">
<label>Density</label>
<select id="density">
<option value="0">Light</option>
<option value="1">Medium</option>
<option value="2" selected>Thick</option>
</select>
</div>
<div class="setting-item">
<label>Paper type</label>
<select id="paperType">
<option value="0" selected>Gap / label</option>
<option value="1">Black mark</option>
<option value="2">Continuous</option>
</select>
</div>
<div class="setting-item">
<label>Label length (mm)</label>
<select id="labelLength">
<option value="30" selected>30 mm</option>
<option value="40">40 mm</option>
<option value="50">50 mm</option>
</select>
</div>
<div class="setting-item">
<label>Shutdown (min)</label>
<input type="number" id="shutdownTime" value="20" min="1" max="999">
</div>
<div class="setting-item">
<button class="btn-sm btn-outline" onclick="applySettings()" disabled id="applyBtn">Apply</button>
</div>
</div>
</div>
<!-- Device info -->
<div class="panel" id="devicePanel" style="display:none">
<h2>Device</h2>
<div class="info-grid" id="infoGrid"></div>
</div>
<!-- Log -->
<div class="panel">
<h2>Log</h2>
<div class="log" id="log"></div>
</div>
</div>
<script>
// ---- Constants ----
const SERVICE_UUID = '000018f0-0000-1000-8000-00805f9b34fb';
const WRITE_UUID = '00002af1-0000-1000-8000-00805f9b34fb';
const NOTIFY_UUID = '00002af0-0000-1000-8000-00805f9b34fb';
const PRINTHEAD_PX = 96;
const BYTES_PER_ROW = 12;
const CHUNK_SIZE = 200;
const CHUNK_DELAY_MS = 20;
// ---- State ----
let device = null;
let server = null;
let writeChar = null;
let notifyChar = null;
let notifyBuf = [];
let notifyResolve = null;
let connected = false;
let uploadedCanvas = null; // prepared 1-bit canvas from image upload
let lastUploadedImg = null; // original Image element for re-processing
// ---- DOM refs ----
const $ = id => document.getElementById(id);
const logEl = $('log');
function log(msg) {
const t = new Date().toLocaleTimeString('en-GB', { hour12: false });
logEl.textContent += `[${t}] ${msg}\n`;
logEl.scrollTop = logEl.scrollHeight;
}
// ---- BLE layer ----
function onNotify(event) {
const val = new Uint8Array(event.target.value.buffer);
notifyBuf.push(...val);
if (notifyResolve) {
notifyResolve();
notifyResolve = null;
}
}
function waitForNotify(timeout = 2000) {
return new Promise(resolve => {
const timer = setTimeout(() => {
notifyResolve = null;
resolve();
}, timeout);
notifyResolve = () => {
clearTimeout(timer);
resolve();
};
});
}
async function send(data, wait = false, timeout = 2000) {
if (wait) {
notifyBuf = [];
}
await writeChar.writeValueWithoutResponse(new Uint8Array(data));
if (wait) {
await waitForNotify(timeout);
await sleep(50);
}
return new Uint8Array(notifyBuf);
}
async function sendChunked(data) {
const arr = data instanceof Uint8Array ? data : new Uint8Array(data);
for (let i = 0; i < arr.length; i += CHUNK_SIZE) {
const chunk = arr.slice(i, i + CHUNK_SIZE);
await writeChar.writeValueWithoutResponse(chunk);
await sleep(CHUNK_DELAY_MS);
}
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
function decodeResponse(buf) {
return new TextDecoder().decode(new Uint8Array(buf)).trim();
}
// ---- Connect / disconnect ----
async function toggleConnect() {
if (connected) {
await doDisconnect();
} else {
await doConnect();
}
}
async function doConnect() {
log('Requesting Bluetooth device...');
try {
device = await navigator.bluetooth.requestDevice({
filters: [
{ namePrefix: 'FICHERO' },
{ namePrefix: 'D11s_' },
],
optionalServices: [SERVICE_UUID],
});
} catch (e) {
log('Cancelled or no device found.');
return;
}
device.addEventListener('gattserverdisconnected', onDisconnected);
log(`Connecting to ${device.name}...`);
try {
server = await device.gatt.connect();
const service = await server.getPrimaryService(SERVICE_UUID);
writeChar = await service.getCharacteristic(WRITE_UUID);
notifyChar = await service.getCharacteristic(NOTIFY_UUID);
await notifyChar.startNotifications();
notifyChar.addEventListener('characteristicvaluechanged', onNotify);
} catch (e) {
log(`Connection failed: ${e.message}`);
return;
}
connected = true;
updateUI();
log(`Connected to ${device.name}`);
await fetchDeviceInfo();
}
async function doDisconnect() {
if (device && device.gatt.connected) {
device.gatt.disconnect();
}
onDisconnected();
}
function onDisconnected() {
connected = false;
server = null;
writeChar = null;
notifyChar = null;
updateUI();
log('Disconnected.');
}
function updateUI() {
$('statusDot').classList.toggle('connected', connected);
$('connectBtn').textContent = connected ? 'Disconnect' : 'Connect';
$('textPrintBtn').disabled = !connected;
$('imgPrintBtn').disabled = !connected || !uploadedCanvas;
$('applyBtn').disabled = !connected;
$('devicePanel').style.display = connected ? '' : 'none';
}
// ---- Info commands ----
async function fetchDeviceInfo() {
log('Fetching device info...');
const info = {};
let r = await send([0x10, 0xFF, 0x20, 0xF0], true);
info.Model = decodeResponse(r);
r = await send([0x10, 0xFF, 0x20, 0xF1], true);
info.Firmware = decodeResponse(r);
r = await send([0x10, 0xFF, 0x20, 0xF2], true);
info.Serial = decodeResponse(r);
r = await send([0x10, 0xFF, 0x50, 0xF1], true);
if (r.length >= 2) {
const pct = r[r.length - 1];
const charging = r[r.length - 2] !== 0;
info.Battery = `${pct}%${charging ? ' (charging)' : ''}`;
$('battery').textContent = `${pct}%`;
$('battery').classList.add('visible');
}
r = await send([0x10, 0xFF, 0x40], true);
if (r.length > 0) {
info.Status = parseStatus(r[r.length - 1]);
}
r = await send([0x10, 0xFF, 0x13], true);
if (r.length >= 2) {
const mins = (r[0] << 8) | r[1];
info['Shutdown'] = `${mins} min`;
$('shutdownTime').value = mins;
}
const grid = $('infoGrid');
grid.innerHTML = '';
for (const [k, v] of Object.entries(info)) {
grid.innerHTML += `<span class="label">${k}</span><span class="value">${v}</span>`;
}
log('Device info loaded.');
}
function parseStatus(byte) {
const flags = [];
if (byte & 0x01) flags.push('printing');
if (byte & 0x02) flags.push('cover open');
if (byte & 0x04) flags.push('no paper');
if (byte & 0x08) flags.push('low battery');
if (byte & 0x10 || byte & 0x40) flags.push('overheated');
if (byte & 0x20) flags.push('charging');
return flags.length ? flags.join(', ') : 'ready';
}
// ---- Settings ----
async function applySettings() {
if (!connected) return;
const density = parseInt($('density').value);
const paper = parseInt($('paperType').value);
const shutdown = parseInt($('shutdownTime').value);
let r = await send([0x10, 0xFF, 0x10, 0x00, density], true);
log(`Set density=${density}: ${decodeResponse(r)}`);
r = await send([0x10, 0xFF, 0x84, paper], true);
log(`Set paper=${paper}: ${decodeResponse(r)}`);
const hi = (shutdown >> 8) & 0xFF;
const lo = shutdown & 0xFF;
r = await send([0x10, 0xFF, 0x12, hi, lo], true);
log(`Set shutdown=${shutdown}min: ${decodeResponse(r)}`);
}
// ---- Image processing ----
function getLabelHeight() {
const mm = parseInt($('labelLength').value) || 30;
return Math.round(mm * 8); // 203 DPI = 8 dots/mm
}
function textToCanvas(text, fontSize) {
// Render text in landscape orientation, then rotate 90 CW for label
const landscapeW = getLabelHeight();
const landscapeH = PRINTHEAD_PX;
const tmp = document.createElement('canvas');
tmp.width = landscapeW;
tmp.height = landscapeH;
const ctx = tmp.getContext('2d');
// White background
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, landscapeW, landscapeH);
// Render text centered
ctx.fillStyle = '#000';
ctx.font = `${fontSize}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const centerX = landscapeW / 2;
const lines = text.split('\n');
const lineHeight = fontSize * 1.2;
const totalH = lines.length * lineHeight;
const startY = (landscapeH - totalH) / 2 + lineHeight / 2;
for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i], centerX, startY + i * lineHeight);
}
// Rotate 90 CW
const out = document.createElement('canvas');
out.width = PRINTHEAD_PX;
out.height = landscapeW;
const octx = out.getContext('2d');
octx.translate(PRINTHEAD_PX, 0);
octx.rotate(Math.PI / 2);
octx.drawImage(tmp, 0, 0);
return threshold(out);
}
function prepareImageCanvas(img) {
// Resize to 96px wide, proportional height, cap to label length
const scale = PRINTHEAD_PX / img.width;
const maxH = getLabelHeight();
let newH = Math.round(img.height * scale);
if (newH > maxH) newH = maxH;
const c = document.createElement('canvas');
c.width = PRINTHEAD_PX;
c.height = newH;
const ctx = c.getContext('2d');
ctx.drawImage(img, 0, 0, PRINTHEAD_PX, newH);
const useDither = $('imgDither').checked;
return useDither ? floydSteinbergDither(c) : threshold(c);
}
function threshold(canvas) {
// Convert to 1-bit (black/white) on a new canvas
const ctx = canvas.getContext('2d');
const data = ctx.getImageData(0, 0, canvas.width, canvas.height);
const px = data.data;
for (let i = 0; i < px.length; i += 4) {
// Grayscale via luminance
const gray = 0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2];
const bw = gray < 160 ? 0 : 255;
px[i] = px[i + 1] = px[i + 2] = bw;
px[i + 3] = 255;
}
ctx.putImageData(data, 0, 0);
return canvas;
}
function floydSteinbergDither(canvas) {
// Floyd-Steinberg error-diffusion dithering.
// Same algorithm as PrinterImageProcessor.ditherFloydSteinberg() in the
// decompiled Fichero APK (7/16, 3/16, 5/16, 1/16 error distribution).
const ctx = canvas.getContext('2d');
const data = ctx.getImageData(0, 0, canvas.width, canvas.height);
const px = data.data;
const w = canvas.width;
const h = canvas.height;
// Build grayscale float buffer
const gray = new Float32Array(w * h);
for (let i = 0; i < w * h; i++) {
const j = i * 4;
gray[i] = 0.299 * px[j] + 0.587 * px[j + 1] + 0.114 * px[j + 2];
}
// Dither
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const idx = y * w + x;
const old = gray[idx];
const val = old < 128 ? 0 : 255;
gray[idx] = val;
const err = old - val;
if (x + 1 < w) gray[idx + 1] += err * 7 / 16;
if (y + 1 < h) {
if (x - 1 >= 0) gray[idx + w - 1] += err * 3 / 16;
gray[idx + w] += err * 5 / 16;
if (x + 1 < w) gray[idx + w + 1] += err * 1 / 16;
}
}
}
// Write back
for (let i = 0; i < w * h; i++) {
const v = gray[i] < 128 ? 0 : 255;
const j = i * 4;
px[j] = px[j + 1] = px[j + 2] = v;
px[j + 3] = 255;
}
ctx.putImageData(data, 0, 0);
return canvas;
}
function canvasToRaster(canvas) {
// Pack pixels into MSB-first bytes. 1 = black, 0 = white.
const ctx = canvas.getContext('2d');
const data = ctx.getImageData(0, 0, canvas.width, canvas.height);
const px = data.data;
const rows = canvas.height;
const bytesPerRow = BYTES_PER_ROW;
const out = new Uint8Array(rows * bytesPerRow);
for (let y = 0; y < rows; y++) {
for (let byteIdx = 0; byteIdx < bytesPerRow; byteIdx++) {
let byte = 0;
for (let bit = 0; bit < 8; bit++) {
const x = byteIdx * 8 + bit;
if (x < canvas.width) {
const i = (y * canvas.width + x) * 4;
// Black pixel (0) -> bit = 1 (heater on)
if (px[i] === 0) {
byte |= (0x80 >> bit);
}
}
}
out[y * bytesPerRow + byteIdx] = byte;
}
}
return out;
}
// ---- Preview rendering ----
function showPreview(canvas, targetEl) {
const target = typeof targetEl === 'string' ? $(targetEl) : targetEl;
// Display scaled up for visibility (2x or 3x)
const scale = Math.max(1, Math.floor(280 / canvas.width));
target.width = canvas.width * scale;
target.height = canvas.height * scale;
const ctx = target.getContext('2d');
ctx.imageSmoothingEnabled = false;
ctx.drawImage(canvas, 0, 0, target.width, target.height);
}
function updateTextPreview() {
const text = $('textInput').value || ' ';
const size = parseInt($('fontSize').value);
$('fontSizeVal').textContent = size;
const canvas = textToCanvas(text, size);
showPreview(canvas, 'textPreview');
}
// ---- Print ----
async function doPrint(canvas, copies) {
if (!connected) { log('Not connected.'); return; }
const rows = canvas.height;
const raster = canvasToRaster(canvas);
const density = parseInt($('density').value);
const paper = parseInt($('paperType').value);
log(`Printing ${canvas.width}x${rows}, ${raster.length} bytes, ${copies} copies, density=${density}`);
// Check status
const statusResp = await send([0x10, 0xFF, 0x40], true);
if (statusResp.length > 0) {
const sb = statusResp[statusResp.length - 1];
if (sb & 0x02) { log('ERROR: Cover open'); return; }
if (sb & 0x04) { log('ERROR: No paper'); return; }
if (sb & 0x50) { log('ERROR: Overheated'); return; }
}
// Set density
await send([0x10, 0xFF, 0x10, 0x00, density], true);
await sleep(100);
for (let c = 0; c < copies; c++) {
if (copies > 1) log(`Copy ${c + 1}/${copies}...`);
// Paper type
await send([0x10, 0xFF, 0x84, paper], true);
await sleep(50);
// Wake up (12 null bytes)
await send(new Array(12).fill(0));
await sleep(50);
// Enable printer (AiYin)
await send([0x10, 0xFF, 0xFE, 0x01]);
await sleep(50);
// Raster header: GS v 0 mode xL xH yL yH
const yL = rows & 0xFF;
const yH = (rows >> 8) & 0xFF;
const header = new Uint8Array([0x1D, 0x76, 0x30, 0x00, BYTES_PER_ROW, 0x00, yL, yH]);
// Combine header + raster data
const payload = new Uint8Array(header.length + raster.length);
payload.set(header, 0);
payload.set(raster, header.length);
await sendChunked(payload);
await sleep(500);
// Form feed
await send([0x1D, 0x0C]);
await sleep(300);
// Stop print, wait for 0xAA or OK
const stopResp = await send([0x10, 0xFF, 0xFE, 0x45], true, 60000);
if (stopResp.length > 0) {
if (stopResp[0] === 0xAA || decodeResponse(stopResp) === 'OK') {
log(copies > 1 ? `Copy ${c + 1} done.` : 'Print complete.');
} else {
log(`WARNING: unexpected stop response: [${Array.from(stopResp).map(b => b.toString(16)).join(' ')}]`);
}
} else {
log('WARNING: no response from stop command.');
}
}
// Refresh battery after print
const battResp = await send([0x10, 0xFF, 0x50, 0xF1], true);
if (battResp.length >= 2) {
$('battery').textContent = `${battResp[battResp.length - 1]}%`;
}
}
async function printText() {
const text = $('textInput').value || ' ';
const size = parseInt($('fontSize').value);
const canvas = textToCanvas(text, size);
const copies = parseInt($('textCopies').value) || 1;
await doPrint(canvas, copies);
}
async function printImage() {
if (!uploadedCanvas) { log('No image loaded.'); return; }
const copies = parseInt($('imgCopies').value) || 1;
await doPrint(uploadedCanvas, copies);
}
// ---- Image upload / drop ----
function handleFile(file) {
if (!file || !file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = () => {
const img = new Image();
img.onload = () => {
lastUploadedImg = img;
reprocessImage();
log(`Image loaded: ${img.width}x${img.height} -> ${uploadedCanvas.width}x${uploadedCanvas.height}`);
};
img.src = reader.result;
};
reader.readAsDataURL(file);
}
function reprocessImage() {
if (!lastUploadedImg) return;
uploadedCanvas = prepareImageCanvas(lastUploadedImg);
showPreview(uploadedCanvas, 'imgPreview');
$('imgPreviewWrap').style.display = '';
$('imgPrintBtn').disabled = !connected;
}
// ---- Event listeners ----
$('textInput').addEventListener('input', updateTextPreview);
$('fontSize').addEventListener('input', updateTextPreview);
$('labelLength').addEventListener('change', updateTextPreview);
// File input
$('fileInput').addEventListener('change', e => {
if (e.target.files[0]) handleFile(e.target.files[0]);
});
// Re-process image when dither toggle or label length changes
$('imgDither').addEventListener('change', () => { if (lastUploadedImg) reprocessImage(); });
$('labelLength').addEventListener('change', () => { if (lastUploadedImg) reprocessImage(); });
// Dropzone click -> trigger file input
$('dropzone').addEventListener('click', () => $('fileInput').click());
// Drag and drop
$('dropzone').addEventListener('dragover', e => {
e.preventDefault();
$('dropzone').classList.add('dragover');
});
$('dropzone').addEventListener('dragleave', () => {
$('dropzone').classList.remove('dragover');
});
$('dropzone').addEventListener('drop', e => {
e.preventDefault();
$('dropzone').classList.remove('dragover');
if (e.dataTransfer.files[0]) handleFile(e.dataTransfer.files[0]);
});
// Tab switching
function switchTab(name) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === name));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.toggle('active', p.id === `tab-${name}`));
}
// Init preview on load
updateTextPreview();
log('Ready. Click Connect to pair with your printer.');
</script>
</body>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="%VITE_PAGE_DESCRIPTION%"/>
<meta property="og:site_name" content="%VITE_PAGE_TITLE%" />
<meta property="og:type" content="website" />
<meta property="og:title" content="%VITE_PAGE_TITLE%">
<meta property="og:description" content="%VITE_PAGE_DESCRIPTION%">
<meta property="og:image" content="%VITE_DEPLOY_BASE_URL%/logo.png">
<link rel="canonical" href="%VITE_DEPLOY_BASE_URL%/">
<link rel="icon" href="/logo.png" type="image/png">
<link rel="manifest" href="fichero.webmanifest">
<!-- Preload fonts to make canvas.js happy -->
<link rel="preload" as="font" type="font/woff2" crossorigin="anonymous"
href="node_modules/@fontsource-variable/noto-sans/files/noto-sans-latin-wght-normal.woff2"/>
<link rel="preload" as="font" type="font/woff2" crossorigin="anonymous"
href="node_modules/@fontsource-variable/noto-sans/files/noto-sans-cyrillic-wght-normal.woff2"/>
<link rel="preload" as="font" type="font/woff2" crossorigin="anonymous"
href="node_modules/material-icons/iconfont/material-icons.woff2"/>
<title>%VITE_PAGE_TITLE%</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/index.ts"></script>
</body>
</html>

2834
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

48
web/package.json Normal file
View File

@@ -0,0 +1,48 @@
{
"name": "fichero-web",
"private": true,
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json"
},
"dependencies": {
"@capacitor/core": "^7.4.5",
"@capacitor/filesystem": "^7.1.6",
"@capacitor/share": "^7.0.3",
"@fontsource-variable/noto-sans": "^5.2.10",
"@formatjs/intl-localematcher": "^0.6.2",
"@popperjs/core": "^2.11.8",
"bootstrap": "5.3.8",
"d3-dsv": "^3.0.1",
"dayjs": "^1.11.19",
"fabric": "^6.7.1",
"material-icons": "^1.13.14",
"qrcode-generator": "2.0.4",
"toastify-js": "^1.12.0",
"zod": "^4.1.12"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tsconfig/svelte": "^5.0.5",
"@types/bootstrap": "^5.2.10",
"@types/d3-dsv": "^3.0.7",
"@types/node": "^24.10.0",
"@types/qrcode-svg": "^1.1.5",
"@types/toastify-js": "^1.12.4",
"sass": "1.77.6",
"svelte": "^5.43.2",
"svelte-check": "^4.3.3",
"tslib": "^2.8.1",
"typescript": "^5.9.3",
"vite": "^7.1.12"
},
"overrides": {
"fabric": {
"canvas": "npm:uninstall"
}
}
}

View File

@@ -0,0 +1,16 @@
{
"name": "Fichero",
"short_name": "fichero",
"description": "Fichero D11s label printer web app. Design and print labels directly from your browser!",
"icons": [
{
"src": "logo.png",
"sizes": "512x512",
"type": "image/png"
}
],
"start_url": "/",
"display": "standalone",
"theme_color": "#212529",
"background_color": "#212529"
}

BIN
web/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

5
web/src/App.svelte Normal file
View File

@@ -0,0 +1,5 @@
<script lang="ts">
import MainPage from "$/components/MainPage.svelte";
</script>
<MainPage />

View File

@@ -0,0 +1,37 @@
<script lang="ts">
import AppModal from "$/components/basic/AppModal.svelte";
import { appConfig } from "$/stores";
import { tr } from "$/utils/i18n";
import { FICHERO_CLIENT_DEFAULTS } from "$/lib/fichero";
let { show = $bindable() } = $props();
</script>
<AppModal title={$tr("debug.title")} bind:show>
<div class="mb-1">
{$tr("debug.packet_interval.help")}
</div>
<div class="input-group flex-nowrap input-group-sm mb-3">
<input
class="form-control"
type="number"
min="1"
placeholder={`${FICHERO_CLIENT_DEFAULTS.packetIntervalMs}`}
bind:value={$appConfig.packetIntervalMs} />
<span class="input-group-text">ms</span>
<button class="btn btn-outline-secondary" onclick={() => ($appConfig.packetIntervalMs = undefined)}
>{$tr("debug.reset")}</button>
</div>
<div class="mb-1">
{$tr("debug.page_delay.help")}
</div>
<div class="input-group flex-nowrap input-group-sm mb-3" role="group">
<input class="form-control" type="number" min="0" placeholder="0" bind:value={$appConfig.pageDelay} />
<span class="input-group-text">ms</span>
<button class="btn btn-outline-secondary" onclick={() => ($appConfig.pageDelay = undefined)}
>{$tr("debug.reset")}</button>
</div>
</AppModal>

View File

@@ -0,0 +1,563 @@
<script lang="ts">
import Dropdown from "bootstrap/js/dist/dropdown";
import * as fabric from "fabric";
import { onDestroy, onMount, tick } from "svelte";
import { Barcode } from "$/fabric-object/barcode";
import { QRCode } from "$/fabric-object/qrcode";
import { iconCodepoints, type MaterialIcon } from "$/styles/mdi_icons";
import { automation, connectionState, csvData, loadedFonts } from "$/stores";
import {
type ExportedLabelTemplate,
type FabricJson,
type LabelProps,
type MoveDirection,
type OjectType,
} from "$/types";
import { FileUtils } from "$/utils/file_utils";
import { tr } from "$/utils/i18n";
import { LabelDesignerObjectHelper } from "$/utils/label_designer_object_helper";
import { LocalStoragePersistence } from "$/utils/persistence";
import { Toasts } from "$/utils/toasts";
import { UndoRedo, type UndoState } from "$/utils/undo_redo";
import BarcodeParamsPanel from "$/components/designer-controls/BarcodeParamsControls.svelte";
import CsvControl from "$/components/designer-controls/CsvControl.svelte";
import GenericObjectParamsControls from "$/components/designer-controls/GenericObjectParamsControls.svelte";
import IconPicker from "$/components/designer-controls/IconPicker.svelte";
import LabelPropsEditor from "$/components/designer-controls/LabelPropsEditor.svelte";
import MdIcon from "$/components/basic/MdIcon.svelte";
import ObjectPicker from "$/components/designer-controls/ObjectPicker.svelte";
import PrintPreview from "$/components/PrintPreview.svelte";
import QrCodeParamsPanel from "$/components/designer-controls/QRCodeParamsControls.svelte";
import TextParamsControls from "$/components/designer-controls/TextParamsControls.svelte";
import VariableInsertControl from "$/components/designer-controls/VariableInsertControl.svelte";
import { DEFAULT_LABEL_PROPS, GRID_SIZE } from "$/defaults";
import { LabelDesignerUtils } from "$/utils/label_designer_utils";
import SavedLabelsMenu from "$/components/designer-controls/SavedLabelsMenu.svelte";
import { CustomCanvas } from "$/fabric-object/custom_canvas";
import VectorParamsControls from "$/components/designer-controls/VectorParamsControls.svelte";
import { CanvasUtils } from "$/utils/canvas_utils";
let htmlCanvas: HTMLCanvasElement;
let fabricCanvas = $state<CustomCanvas>();
let labelProps = $state<LabelProps>(DEFAULT_LABEL_PROPS);
let previewOpened = $state<boolean>(false);
let selectedObject = $state<fabric.FabricObject | undefined>(undefined);
let selectedCount = $state<number>(0);
let editRevision = $state<number>(0);
let printNow = $state<boolean>(false);
let csvEnabled = $state<boolean>(false);
let windowWidth = $state<number>(0);
let undoState = $state<UndoState>({ undoDisabled: false, redoDisabled: false });
const undo = new UndoRedo();
const discardSelection = () => {
fabricCanvas!.discardActiveObject();
fabricCanvas!.requestRenderAll();
selectedObject = undefined;
selectedCount = 0;
editRevision = 0;
};
const loadLabelData = async (data: ExportedLabelTemplate) => {
undo.paused = true;
onUpdateLabelProps(data.label);
if (data.csv) {
$csvData = data.csv;
csvEnabled = true;
}
await FileUtils.loadCanvasState(fabricCanvas!, data.canvas);
undo.paused = false;
};
undo.onLabelUpdate = loadLabelData;
undo.onStateUpdate = (state: UndoState) => {
undoState = state;
};
const deleteSelected = () => {
LabelDesignerUtils.deleteSelection(fabricCanvas!);
discardSelection();
};
const cloneSelected = () => {
LabelDesignerUtils.cloneSelection(fabricCanvas!).then(() => undo.push(fabricCanvas!, labelProps));
};
const moveSelected = (direction: MoveDirection, ctrl?: boolean) => {
LabelDesignerUtils.moveSelection(fabricCanvas!, direction, ctrl);
undo.push(fabricCanvas!, labelProps);
};
const onKeyDown = (e: KeyboardEvent) => {
const key: string = e.key.toLowerCase();
// windows and linux users are used to ctrl, mac users use cmd
const cmdOrCtrl = e.metaKey || e.ctrlKey;
// Esc
if (key === "escape") {
discardSelection();
return;
}
if (LabelDesignerUtils.isAnyInputFocused(fabricCanvas!)) {
return;
}
// Arrows
if (key.startsWith("arrow")) {
moveSelected(key.slice("arrow".length) as MoveDirection, cmdOrCtrl);
return;
}
if (e.repeat) {
return;
}
// Ctrl + D
if (cmdOrCtrl && key === "d") {
e.preventDefault();
cloneSelected();
return;
}
// Ctrl + Y, Ctrl + Shift + Z
if ((cmdOrCtrl && key === "y") || (cmdOrCtrl && e.shiftKey && key === "z")) {
e.preventDefault();
if (!undoState.redoDisabled) {
undo.redo();
}
return;
}
// Ctrl + Z
if (cmdOrCtrl && key === "z") {
e.preventDefault();
if (!undoState.undoDisabled) {
undo.undo();
}
return;
}
// Del
if (key === "delete" || key === "backspace") {
deleteSelected();
return;
}
};
const onUpdateLabelProps = (newProps: LabelProps) => {
labelProps = newProps;
fabricCanvas!.setDimensions(labelProps.size);
fabricCanvas!.virtualZoom(fabricCanvas!.getVirtualZoom());
try {
LocalStoragePersistence.saveLastLabelProps(labelProps);
undo.push(fabricCanvas!, labelProps);
} catch (e) {
Toasts.zodErrors(e, "Label parameters save error:");
}
};
const exportCurrentLabel = (): ExportedLabelTemplate => {
return FileUtils.makeExportedLabel(fabricCanvas!, labelProps, csvEnabled);
};
const onLoadRequested = (label: ExportedLabelTemplate) => {
loadLabelData(label).then(() => undo.push(fabricCanvas!, labelProps));
};
const zplImageReady = async (img: Blob) => {
await LabelDesignerObjectHelper.addImageBlob(fabricCanvas!, img);
undo.push(fabricCanvas!, labelProps);
};
const onObjectPicked = (objectType: OjectType) => {
const obj = LabelDesignerObjectHelper.addObject(fabricCanvas!, objectType);
if (obj !== undefined) {
fabricCanvas!.setActiveObject(obj);
undo.push(fabricCanvas!, labelProps);
}
};
const onIconPicked = (i: MaterialIcon) => {
// todo: icon is not vertically centered
LabelDesignerObjectHelper.addStaticText(fabricCanvas!, String.fromCodePoint(iconCodepoints[i]), {
fontFamily: "Material Icons",
fontSize: 100,
});
undo.push(fabricCanvas!, labelProps);
};
const onSvgIconPicked = (i: string) => {
LabelDesignerObjectHelper.addSvg(fabricCanvas!, i);
undo.push(fabricCanvas!, labelProps);
};
const openPreview = () => {
printNow = false;
previewOpened = true;
};
const openPreviewAndPrint = () => {
printNow = true;
previewOpened = true;
};
const controlValueUpdated = () => {
if (selectedObject) {
selectedObject.setCoords();
selectedObject.dirty = true;
undo.push(fabricCanvas!, labelProps);
}
fabricCanvas!.requestRenderAll();
// trigger reactivity for controls
editRevision++;
};
const getCanvasForPreview = (): FabricJson => {
return fabricCanvas!.toJSON();
};
const onCsvPlaceholderPicked = (name: string) => {
const obj = LabelDesignerObjectHelper.addText(fabricCanvas!, `{${name}}`, {
textAlign: "left",
originX: "left",
originY: "top",
});
fabricCanvas!.setActiveObject(obj);
undo.push(fabricCanvas!, labelProps);
};
const onPaste = async (event: ClipboardEvent) => {
if (LabelDesignerUtils.isAnyInputFocused(fabricCanvas!)) {
return;
}
const openedDropdowns = document.querySelectorAll(".dropdown-menu.show");
if (openedDropdowns.length > 0) {
return;
}
if (event.clipboardData != null) {
event.preventDefault();
const obj = await LabelDesignerObjectHelper.addObjectFromClipboard(fabricCanvas!, event.clipboardData);
if (obj !== undefined) {
fabricCanvas!.setActiveObject(obj);
undo.push(fabricCanvas!, labelProps);
}
}
};
const clearCanvas = () => {
if (!confirm($tr("editor.clear.confirm"))) {
return;
}
undo.push(fabricCanvas!, labelProps);
fabricCanvas!.clear();
};
const loadDefaultLabel = async () => {
try {
const urlTemplate = await FileUtils.readLabelFromUrl();
if (urlTemplate !== null && confirm($tr("params.saved_labels.load.url.warn"))) {
onLoadRequested(urlTemplate);
Toasts.message($tr("params.saved_labels.load.url.loaded"));
return;
}
} catch (e) {
Toasts.error(e);
}
try {
const defaultTemplate = LocalStoragePersistence.loadDefaultTemplate();
if (defaultTemplate !== null) {
onLoadRequested(defaultTemplate);
return;
}
} catch (e) {
Toasts.error(e);
}
LabelDesignerObjectHelper.addText(fabricCanvas!, $tr("editor.default_text"));
};
const renderOnFontsChanged = () => {
fabricCanvas?.forEachObject((o) => {
if (o instanceof fabric.Textbox) {
o.dirty = true;
}
});
fabricCanvas?.requestRenderAll();
};
onMount(async () => {
try {
const savedLabelProps = LocalStoragePersistence.loadLastLabelProps();
if (savedLabelProps !== null) {
labelProps = savedLabelProps;
}
} catch (e) {
Toasts.zodErrors(e, "Label parameters load error:");
}
fabricCanvas = new CustomCanvas(htmlCanvas, {
width: labelProps.size.width,
height: labelProps.size.height,
});
fabricCanvas.setLabelProps(labelProps);
await loadDefaultLabel();
undo.push(fabricCanvas, labelProps);
// force close dropdowns on touch devices
fabricCanvas.on("mouse:down", (): void => {
const dropdowns = document.querySelectorAll("[data-bs-toggle='dropdown']");
dropdowns.forEach((el) => new Dropdown(el).hide());
});
fabricCanvas.on("object:moving", (e): void => {
if (e.target && e.target.left !== undefined && e.target.top !== undefined) {
e.target.set({
left: Math.round(e.target.left / GRID_SIZE) * GRID_SIZE,
top: Math.round(e.target.top / GRID_SIZE) * GRID_SIZE,
});
}
});
fabricCanvas.on("object:modified", (): void => {
undo.push(fabricCanvas!, labelProps);
});
fabricCanvas.on("text:changed", () => {
editRevision++;
});
fabricCanvas.on("object:removed", (): void => {
undo.push(fabricCanvas!, labelProps);
});
fabricCanvas.on("selection:created", (e): void => {
selectedCount = e.selected?.length ?? 0;
selectedObject = e.selected?.length === 1 ? e.selected[0] : undefined;
editRevision++;
});
fabricCanvas.on("selection:updated", (e): void => {
selectedCount = e.selected?.length ?? 0;
selectedObject = e.selected?.length === 1 ? e.selected[0] : undefined;
editRevision++;
});
fabricCanvas.on("selection:cleared", (): void => {
selectedObject = undefined;
selectedCount = 0;
editRevision++;
});
fabricCanvas.on("dragover", (e): void => {
e.e.preventDefault();
});
fabricCanvas.on("drop:after", async (e): Promise<void> => {
const dragEvt = e.e as DragEvent;
dragEvt.preventDefault();
let dropped = false;
if (dragEvt.dataTransfer?.files) {
for (const file of dragEvt.dataTransfer.files) {
try {
await LabelDesignerObjectHelper.addImageFile(fabricCanvas!, file);
dropped = true;
} catch (e) {
Toasts.error(e);
}
}
if (dropped) {
undo.push(fabricCanvas!, labelProps);
}
}
});
fabricCanvas.on("object:scaling", (e): void => {
if (!e.target) {
return;
}
CanvasUtils.fixFabricObjectScale(e.target);
});
// userFonts.subscribe((e) => {console.log(e); renderOnFontsChanged();});
if ($automation !== undefined) {
if ($automation.startPrint !== undefined) {
if ($automation.startPrint === "immediately") {
openPreview();
} else if ($automation.startPrint === "after_connect") {
const unsubscribe = connectionState.subscribe((st) => {
if (st === "connected") {
tick().then(() => unsubscribe());
openPreviewAndPrint();
}
});
}
}
}
});
onDestroy(() => {
fabricCanvas!.dispose();
});
$effect(() => {
fabricCanvas?.setLabelProps(labelProps);
});
$effect(() => {
if (!previewOpened) {
printNow = false;
}
});
$effect(() => {
if ($loadedFonts) {
renderOnFontsChanged();
}
});
</script>
<svelte:window bind:innerWidth={windowWidth} onkeydown={onKeyDown} onpaste={onPaste} />
<div class="image-editor">
<div class="row mb-3">
<div class="col d-flex {windowWidth === 0 || labelProps.size.width < windowWidth ? 'justify-content-center' : ''}">
<div class="canvas-wrapper print-start-{labelProps.printDirection}">
<canvas bind:this={htmlCanvas}></canvas>
</div>
</div>
</div>
<div class="row mb-1">
<div class="col d-flex justify-content-center">
<div class="toolbar d-flex flex-wrap gap-1 justify-content-center align-items-center">
<LabelPropsEditor {labelProps} onChange={onUpdateLabelProps} />
<button class="btn btn-sm btn-secondary" onclick={clearCanvas} title={$tr("editor.clear")}>
<MdIcon icon="cancel_presentation" />
</button>
<SavedLabelsMenu
canvas={fabricCanvas!}
onRequestLabelTemplate={exportCurrentLabel}
{onLoadRequested}
{csvEnabled} />
<button
class="btn btn-sm btn-secondary"
disabled={undoState.undoDisabled}
onclick={() => undo.undo()}
title={$tr("editor.undo")}>
<MdIcon icon="undo" />
</button>
<button
class="btn btn-sm btn-secondary"
disabled={undoState.redoDisabled}
onclick={() => undo.redo()}
title={$tr("editor.redo")}>
<MdIcon icon="redo" />
</button>
<CsvControl bind:enabled={csvEnabled} onPlaceholderPicked={onCsvPlaceholderPicked} />
<IconPicker onSubmit={onIconPicked} onSubmitSvg={onSvgIconPicked} />
<ObjectPicker onSubmit={onObjectPicked} {labelProps} {zplImageReady} />
<button class="btn btn-sm btn-primary ms-1" onclick={openPreview}>
<MdIcon icon="visibility" />
{$tr("editor.preview")}
</button>
<button
title="Print with default or saved parameters"
class="btn btn-sm btn-primary ms-1"
onclick={openPreviewAndPrint}
disabled={$connectionState !== "connected"}><MdIcon icon="print" /> {$tr("editor.print")}</button>
</div>
</div>
</div>
<div class="row mb-1">
<div class="col d-flex justify-content-center">
<div class="toolbar d-flex flex-wrap gap-1 justify-content-center align-items-center">
{#if selectedCount > 0}
<button class="btn btn-sm btn-danger me-1" onclick={deleteSelected} title={$tr("editor.delete")}>
<MdIcon icon="delete" />
</button>
{/if}
{#if selectedCount > 0}
<button class="btn btn-sm btn-secondary me-1" onclick={cloneSelected} title={$tr("editor.clone")}>
<MdIcon icon="content_copy" />
</button>
{/if}
{#if selectedObject && selectedCount === 1}
<GenericObjectParamsControls {selectedObject} {editRevision} valueUpdated={controlValueUpdated} />
{/if}
{#if selectedObject}
<VectorParamsControls {selectedObject} {editRevision} valueUpdated={controlValueUpdated} />
{/if}
{#if selectedObject instanceof fabric.IText}
<TextParamsControls selectedText={selectedObject} {editRevision} valueUpdated={controlValueUpdated} />
{/if}
{#if selectedObject instanceof QRCode}
<QrCodeParamsPanel selectedQRCode={selectedObject} {editRevision} valueUpdated={controlValueUpdated} />
{/if}
{#if selectedObject instanceof Barcode}
<BarcodeParamsPanel selectedBarcode={selectedObject} {editRevision} valueUpdated={controlValueUpdated} />
{/if}
{#if selectedObject instanceof fabric.IText || selectedObject instanceof QRCode || (selectedObject instanceof Barcode && selectedObject.encoding === "CODE128B")}
<VariableInsertControl {selectedObject} valueUpdated={controlValueUpdated} />
{/if}
</div>
</div>
</div>
{#if previewOpened}
<PrintPreview
bind:show={previewOpened}
canvasCallback={getCanvasForPreview}
{labelProps}
{printNow}
{csvEnabled}
csvData={$csvData.data} />
{/if}
</div>
<style>
.canvas-wrapper {
border: 1px solid rgba(0, 0, 0, 0.4);
background-color: rgba(60, 55, 63, 0.5);
}
.canvas-wrapper.print-start-left {
border-left: 2px solid #ff4646;
}
.canvas-wrapper.print-start-top {
border-top: 2px solid #ff4646;
}
.canvas-wrapper canvas {
image-rendering: pixelated;
}
</style>

View File

@@ -0,0 +1,91 @@
<script lang="ts">
import BrowserWarning from "$/components/basic/BrowserWarning.svelte";
import LabelDesigner from "$/components/LabelDesigner.svelte";
import PrinterConnector from "$/components/PrinterConnector.svelte";
import { locale, locales, tr } from "$/utils/i18n";
import DebugStuff from "$/components/DebugStuff.svelte";
import MdIcon from "$/components/basic/MdIcon.svelte";
// eslint-disable-next-line no-undef
const appCommit = __APP_COMMIT__;
// eslint-disable-next-line no-undef
const buildDate = __BUILD_DATE__;
let debugStuffShow = $state<boolean>(false);
</script>
<div class="container my-2">
<div class="row align-items-center mb-3">
<div class="col">
<h1 class="title">
<img src="{import.meta.env.BASE_URL}logo.png" alt="Fichero" class="logo" />
</h1>
</div>
<div class="col-md-3">
<PrinterConnector />
</div>
</div>
<div class="row">
<div class="col">
<BrowserWarning />
</div>
</div>
<div class="row">
<div class="col">
<LabelDesigner />
</div>
</div>
</div>
<div class="footer text-end text-secondary p-3">
<div>
<select class="form-select form-select-sm text-secondary d-inline-block w-auto" bind:value={$locale}>
{#each Object.entries(locales) as [key, name] (key)}
<option value={key}>{name}</option>
{/each}
</select>
</div>
<div>
{#if appCommit}
<a class="text-secondary" href="https://github.com/mohamedha/fichero-printer/commit/{appCommit}">
{appCommit.slice(0, 6)}
</a>
{/if}
{$tr("main.built")}
{buildDate}
</div>
<div>
<a class="text-secondary" href="https://github.com/mohamedha/fichero-printer">{$tr("main.code")}</a>
<button class="text-secondary btn btn-link p-0" onclick={() => debugStuffShow = true}>
<MdIcon icon="bug_report" />
</button>
</div>
</div>
{#if debugStuffShow}
<DebugStuff bind:show={debugStuffShow} />
{/if}
<style>
.logo {
height: 1.4em;
vertical-align: middle;
margin-right: 0.2em;
border-radius: 4px;
}
.footer {
position: absolute;
bottom: 0;
right: 0;
z-index: -1;
}
@media only screen and (max-device-width: 540px) {
.footer {
position: relative !important;
z-index: 0 !important;
}
}
</style>

View File

@@ -0,0 +1,616 @@
<script lang="ts">
import { onMount } from "svelte";
import { derived } from "svelte/store";
import { appConfig, connectionState, printerClient, printerMeta, refreshRfidInfo } from "$/stores";
import { copyImageData, threshold, atkinson, invert, bayer } from "$/utils/post_process";
import {
type EncodedImage,
ImageEncoder,
LabelType,
printTaskNames,
type PrintProgressEvent,
type PrintTaskName,
AbstractPrintTask,
Utils,
} from "$/lib/fichero";
import type { LabelProps, PostProcessType, FabricJson, PreviewProps, PreviewPropsOffset } from "$/types";
import ParamLockButton from "$/components/basic/ParamLockButton.svelte";
import { tr, type TranslationKey } from "$/utils/i18n";
import { canvasPreprocess } from "$/utils/canvas_preprocess";
import { type DSVRowArray, csvParse } from "d3-dsv";
import { LocalStoragePersistence } from "$/utils/persistence";
import MdIcon from "$/components/basic/MdIcon.svelte";
import { Toasts } from "$/utils/toasts";
import { CustomCanvas } from "$/fabric-object/custom_canvas";
import { FileUtils } from "$/utils/file_utils";
import AppModal from "$/components/basic/AppModal.svelte";
interface Props {
labelProps: LabelProps;
canvasCallback: () => FabricJson;
printNow?: boolean;
csvData: string;
csvEnabled: boolean;
show: boolean;
}
let { labelProps, canvasCallback, printNow = false, csvData, csvEnabled, show = $bindable() }: Props = $props();
let previewCanvas: HTMLCanvasElement;
let printState = $state<"idle" | "sending" | "printing">("idle");
let printProgress = $state<number>(0); // todo: more progress data
let density = $state<number>($printerMeta?.densityDefault ?? 3);
let speed = $state<0 | 1>(1);
let quantity = $state<number>(1);
let postProcessType = $state<PostProcessType>();
let postProcessInvert = $state<boolean>(false);
let thresholdValue = $state<number>(140);
let originalImage: ImageData;
let previewContext: CanvasRenderingContext2D;
let printTaskName = $state<PrintTaskName>("B1");
let labelType = $state<LabelType>(LabelType.WithGaps);
// eslint-disable-next-line no-undef
let statusTimer: NodeJS.Timeout | undefined = undefined;
let error = $state<string>("");
let detectedPrintTaskName: PrintTaskName | undefined = $printerClient?.getPrintTaskType();
let csvParsed: DSVRowArray<string>;
let page = $state<number>(0);
let pagesTotal = $state<number>(1);
let offset = $state<PreviewPropsOffset>({ x: 0, y: 0, offsetType: "inner" });
let offsetWarning = $state<string>("");
let currentPrintTask: AbstractPrintTask | undefined;
let savedProps = $state<PreviewProps>({});
let modalRef: AppModal;
const disconnected = derived(connectionState, ($connectionState) => $connectionState !== "connected");
const labelTypeTranslationKey = (labelType: string): TranslationKey =>
`preview.label_type.${labelType}` as TranslationKey;
const endPrint = async () => {
clearInterval(statusTimer);
if (!$disconnected && printState !== "idle") {
if (currentPrintTask !== undefined) {
await currentPrintTask.printEnd();
} else {
console.warn("Print task undefined, falling back to PrintEnd command");
await $printerClient.abstraction.printEnd();
}
refreshRfidInfo();
$printerClient.startHeartbeat();
}
printState = "idle";
printProgress = 0;
};
const onPrintOnSystemPrinter = async () => {
const sources: string[] = [];
for (let curPage = 0; curPage < pagesTotal; curPage++) {
page = curPage;
await generatePreviewData(page);
sources.push(previewCanvas.toDataURL("image/png"));
}
FileUtils.printImageUrls(sources);
};
const onPrint = async () => {
printState = "sending";
error = "";
// do it in a stupid way (multi-page print not finished yet)
for (let curPage = 0; curPage < pagesTotal; curPage++) {
$printerClient.stopHeartbeat();
currentPrintTask = $printerClient.abstraction.newPrintTask(printTaskName, {
totalPages: quantity,
density,
speed,
labelType,
statusPollIntervalMs: 100,
statusTimeoutMs: 8_000,
});
page = curPage;
console.log("Printing page", page);
await generatePreviewData(page);
try {
const encoded: EncodedImage = ImageEncoder.encodeCanvas(previewCanvas, labelProps.printDirection);
await currentPrintTask.printInit();
await currentPrintTask.printPage(encoded, quantity);
} catch (e) {
error = `${e}`;
console.error(e);
return;
}
printState = "printing";
const listener = (e: PrintProgressEvent) => {
printProgress = Math.floor((e.page / quantity) * ((e.pagePrintProgress + e.pageFeedProgress) / 2));
};
$printerClient.on("printprogress", listener);
try {
await currentPrintTask.waitForFinished();
} catch (e) {
error = `${e}`;
console.error(e);
}
$printerClient.off("printprogress", listener);
await endPrint();
if (
$appConfig.pageDelay !== undefined &&
$appConfig.pageDelay > 0 &&
pagesTotal > 1 &&
curPage < pagesTotal - 1
) {
await Utils.sleep($appConfig.pageDelay);
}
}
printState = "idle";
$printerClient.startHeartbeat();
if (printNow && !error) {
modalRef.hide();
}
};
const updatePreview = () => {
let iData: ImageData = copyImageData(originalImage);
if (postProcessType === "threshold") {
iData = threshold(iData, thresholdValue);
} else if (postProcessType === "dither") {
iData = atkinson(iData, thresholdValue);
} else if (postProcessType === "bayer") {
iData = bayer(iData, thresholdValue);
}
if (postProcessInvert) {
iData = invert(iData);
}
offsetWarning = "";
if (offset.offsetType === "inner") {
previewCanvas.width = originalImage.width;
previewCanvas.height = originalImage.height;
previewContext.fillStyle = "white";
previewContext.fillRect(0, 0, previewCanvas.width, previewCanvas.height);
previewContext.putImageData(iData, offset.x, offset.y);
} else {
previewCanvas.width = originalImage.width + Math.abs(offset.x);
previewCanvas.height = originalImage.height + Math.abs(offset.y);
previewContext.fillStyle = "white";
previewContext.fillRect(0, 0, previewCanvas.width, previewCanvas.height);
previewContext.putImageData(iData, Math.max(offset.x, 0), Math.max(offset.y, 0));
}
if ($printerMeta !== undefined) {
const headSize = labelProps.printDirection == "left" ? previewCanvas.height : previewCanvas.width;
if (headSize > $printerMeta.printheadPixels) {
offsetWarning += $tr("params.label.warning.width") + " ";
offsetWarning += `(${headSize} > ${$printerMeta.printheadPixels})`;
offsetWarning += "\n";
}
}
};
const toggleSavedProp = (key: string, value: any) => {
const keyObj = key as keyof typeof savedProps;
savedProps[keyObj] = savedProps[keyObj] === undefined ? value : undefined;
try {
LocalStoragePersistence.savePreviewProps(savedProps);
} catch (e) {
Toasts.zodErrors(e, "Preview parameters save error:");
}
};
const updateSavedProp = (key: string, value: any, refreshPreview: boolean = false) => {
const keyObj = key as keyof typeof savedProps;
if (savedProps[keyObj] !== undefined) {
savedProps[keyObj] = value;
try {
LocalStoragePersistence.savePreviewProps(savedProps);
} catch (e) {
Toasts.zodErrors(e, "Preview parameters save error:");
}
}
if (refreshPreview) {
updatePreview();
}
};
const loadProps = () => {
try {
const saved = LocalStoragePersistence.loadSavedPreviewProps();
if (saved === null) {
return;
}
savedProps = saved;
if (saved.postProcess !== undefined) postProcessType = saved.postProcess;
if (saved.postProcessInvert !== undefined) postProcessInvert = saved.postProcessInvert;
if (saved.threshold !== undefined) thresholdValue = saved.threshold;
if (saved.quantity !== undefined) quantity = saved.quantity;
if (saved.density !== undefined) density = saved.density;
if (saved.speed !== undefined) speed = saved.speed;
if (saved.labelType !== undefined) labelType = saved.labelType;
if (saved.printTaskName !== undefined) printTaskName = saved.printTaskName;
if (saved.offset !== undefined) offset = saved.offset;
} catch (e) {
Toasts.zodErrors(e, "Preview parameters load error:");
}
};
const pageDown = () => {
if (!csvEnabled) {
page = 0;
return;
}
page = Math.max(0, Math.min(csvParsed.length - 1, page - 1));
generatePreviewData(page);
};
const pageUp = () => {
if (!csvEnabled) {
page = 0;
return;
}
page = Math.min(csvParsed.length - 1, page + 1);
generatePreviewData(page);
};
const generatePreviewData = async (page: number): Promise<void> => {
const fabricTempCanvas = new CustomCanvas(undefined, {
width: labelProps.size.width,
height: labelProps.size.height,
});
fabricTempCanvas.setCustomBackground(false);
fabricTempCanvas.setHighlightMirror(false);
fabricTempCanvas.setLabelProps(labelProps);
await fabricTempCanvas.loadFromJSON(canvasCallback());
let variables = {};
if (csvEnabled) {
if (page >= 0 && page < csvParsed.length) {
variables = csvParsed[page];
} else {
console.warn(`Page ${page} is out of csv bounds (csv length is ${csvParsed.length})`);
}
}
console.log("Page variables:", variables);
canvasPreprocess(fabricTempCanvas, variables);
await fabricTempCanvas.createMirroredObjects();
fabricTempCanvas.requestRenderAll();
const preRenderedCanvas = fabricTempCanvas.toCanvasElement();
const ctx = preRenderedCanvas.getContext("2d")!;
previewCanvas.width = preRenderedCanvas.width;
previewCanvas.height = preRenderedCanvas.height;
previewContext = previewCanvas.getContext("2d")!;
originalImage = ctx.getImageData(0, 0, preRenderedCanvas.width, preRenderedCanvas.height);
updatePreview();
fabricTempCanvas.dispose();
};
const onModalClose = () => {
endPrint();
};
onMount(async () => {
if (csvEnabled) {
const parseResult = csvParse(csvData);
const spread: DSVRowArray<string> = Object.assign([], { columns: parseResult.columns });
for (let row of parseResult) {
for (const k of Object.keys(row)) {
row[k] = row[k].replaceAll("\\n", "\n");
}
let times = 1;
if ("$times" in row && row["$times"] !== "") {
try {
times = parseInt(row["$times"]);
} catch (e) {
console.warn("$times parse error", e);
}
}
if (times < 0) {
times = 0;
}
for (let i = 0; i < times; i++) {
spread.push(row);
}
}
csvParsed = spread;
pagesTotal = csvParsed.length;
}
if (detectedPrintTaskName !== undefined) {
console.log(`Detected print task version: ${detectedPrintTaskName}`);
printTaskName = detectedPrintTaskName;
}
loadProps();
await generatePreviewData(page);
if (printNow && !$disconnected && printState === "idle") {
onPrint();
}
});
</script>
<AppModal title={$tr("preview.title")} onClose={onModalClose} bind:show bind:this={modalRef}>
<div class="d-flex justify-content-center">
{#if pagesTotal > 1}
<button disabled={printState !== "idle"} class="btn w-100 fs-1" onclick={pageDown}>
<MdIcon icon="chevron_left" />
</button>
{/if}
<canvas class="print-start-{labelProps.printDirection}" bind:this={previewCanvas}></canvas>
{#if pagesTotal > 1}
<button disabled={printState !== "idle"} class="btn w-100 fs-1" onclick={pageUp}>
<MdIcon icon="chevron_right" />
</button>
{/if}
</div>
<div class="text-center">
{#if pagesTotal > 1}<div>Page {page + 1} / {pagesTotal}</div>{/if}
{#if printState === "sending"}
<div>Sending...</div>
{/if}
{#if printState === "printing"}
<div>
Printing...
<div class="progress" role="progressbar">
<div class="progress-bar" style="width: {printProgress}%">{printProgress}%</div>
</div>
</div>
{/if}
{#if error}
<div class="alert alert-danger" role="alert">{error}</div>
{/if}
</div>
{#snippet footer()}
<div class="input-group input-group-sm">
<span class="input-group-text">{$tr("preview.postprocess")}</span>
<select
class="form-select"
bind:value={postProcessType}
onchange={() => updateSavedProp("postProcess", postProcessType, true)}>
<option value="threshold">{$tr("preview.postprocess.threshold")}</option>
<option value="dither">{$tr("preview.postprocess.atkinson")}</option>
<option value="bayer">{$tr("preview.postprocess.bayer")}</option>
</select>
<ParamLockButton
propName="postProcess"
value={postProcessType}
savedValue={savedProps.postProcess}
onClick={toggleSavedProp} />
<button
class="btn btn-sm {postProcessInvert ? 'btn-secondary' : 'btn-outline-secondary'}"
onclick={() => {
postProcessInvert = !postProcessInvert;
updatePreview();
}}>
<MdIcon icon="invert_colors" />
</button>
</div>
<div class="input-group input-group-sm">
<span class="input-group-text">{$tr("preview.threshold")}</span>
<input
type="range"
id="threshold"
class="form-range"
min="1"
max="255"
bind:value={thresholdValue}
onchange={() => updateSavedProp("threshold", thresholdValue, true)} />
<span class="input-group-text">{thresholdValue}</span>
<ParamLockButton
propName="threshold"
value={thresholdValue}
savedValue={savedProps.threshold}
onClick={toggleSavedProp} />
</div>
<div class="input-group flex-nowrap input-group-sm">
<span class="input-group-text">{$tr("preview.copies")}</span>
<input
class="form-control"
type="number"
min="1"
bind:value={quantity}
onchange={() => updateSavedProp("quantity", quantity)} />
<ParamLockButton
propName="quantity"
value={quantity}
savedValue={savedProps.quantity}
onClick={toggleSavedProp} />
</div>
<div class="input-group flex-nowrap input-group-sm">
<span class="input-group-text">{$tr("preview.density")}</span>
<input
class="form-control"
type="number"
min={$printerMeta?.densityMin ?? 1}
max={$printerMeta?.densityMax ?? 20}
bind:value={density}
onchange={() => updateSavedProp("density", density)} />
<ParamLockButton propName="density" value={density} savedValue={savedProps.density} onClick={toggleSavedProp} />
</div>
{#if printTaskName === "D110M_V4"}
<div class="input-group flex-nowrap input-group-sm">
<span class="input-group-text">{$tr("preview.speed")}</span>
<select class="form-select" bind:value={speed} onchange={() => updateSavedProp("speed", speed, true)}>
<option value={0}>{$tr("preview.speed.0")}</option>
<option value={1}>{$tr("preview.speed.1")}</option>
</select>
<ParamLockButton propName="speed" value={speed} savedValue={savedProps.speed} onClick={toggleSavedProp} />
</div>
{/if}
<div class="input-group input-group-sm">
<span class="input-group-text">{$tr("preview.label_type")}</span>
<select class="form-select" bind:value={labelType} onchange={() => updateSavedProp("labelType", labelType)}>
{#each Object.values(LabelType) as lt (lt)}
{#if typeof lt !== "string"}
<option value={lt}>
{#if $printerMeta?.paperTypes.includes(lt)}{/if}
{$tr(labelTypeTranslationKey(LabelType[lt]))}
</option>
{/if}
{/each}
</select>
<ParamLockButton
propName="labelType"
value={labelType}
savedValue={savedProps.labelType}
onClick={toggleSavedProp} />
</div>
<div class="input-group input-group-sm">
<span class="input-group-text">{$tr("preview.print_task")}</span>
<select
class="form-select"
bind:value={printTaskName}
onchange={() => updateSavedProp("printTaskName", printTaskName)}>
{#each printTaskNames as name (name)}
<option value={name}>
{#if detectedPrintTaskName === name}{/if}
{name}
</option>
{/each}
</select>
<ParamLockButton
propName="printTaskName"
value={printTaskName}
savedValue={savedProps.printTaskName}
onClick={toggleSavedProp} />
</div>
<div class="input-group input-group-sm">
<span class="input-group-text">{$tr("preview.offset")}</span>
{#if offsetWarning}
<span class="input-group-text text-warning" title={offsetWarning}><MdIcon icon="warning" /></span>
{/if}
<span class="input-group-text"><MdIcon icon="unfold_more" class="r-90" /></span>
<input
class="form-control"
type="number"
bind:value={offset.x}
onchange={() => updateSavedProp("offset", offset, true)} />
<span class="input-group-text"><MdIcon icon="unfold_more" /></span>
<input
class="form-control"
type="number"
bind:value={offset.y}
onchange={() => updateSavedProp("offset", offset, true)} />
<select
class="form-select"
bind:value={offset.offsetType}
onchange={() => updateSavedProp("offset", offset, true)}>
<option value="inner">{$tr("preview.offset.inner")}</option>
<option value="outer">{$tr("preview.offset.outer")}</option>
</select>
<ParamLockButton propName="offset" value={offset} savedValue={savedProps.offset} onClick={toggleSavedProp} />
</div>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{$tr("preview.close")}</button>
{#if printState !== "idle"}
<button type="button" class="btn btn-primary" disabled={$disconnected} onclick={endPrint}>
{$tr("preview.print.cancel")}
</button>
{/if}
<button
type="button"
class="btn btn-secondary"
title={$tr("preview.print.system")}
onclick={onPrintOnSystemPrinter}>
<MdIcon icon="print" />
</button>
<button type="button" class="btn btn-primary" disabled={$disconnected || printState !== "idle"} onclick={onPrint}>
{#if $disconnected}
{$tr("preview.not_connected")}
{:else}
<MdIcon icon="print" /> {$tr("preview.print")}
{/if}
</button>
{/snippet}
</AppModal>
<style>
canvas {
image-rendering: pixelated;
border: 1px solid #6d6d6d;
max-width: 100%;
}
canvas.print-start-left {
border-left: 2px solid #ff4646;
}
canvas.print-start-top {
border-top: 2px solid #ff4646;
}
.progress-bar {
transition: none;
}
.input-group .form-range {
flex-grow: 1;
width: 1%;
height: unset;
padding: 0 1rem;
}
</style>

View File

@@ -0,0 +1,235 @@
<script lang="ts">
import { SoundSettingsItemType, Utils, type AvailableTransports } from "$/lib/fichero";
import {
printerClient,
connectedPrinterName,
connectionState,
initClient,
heartbeatData,
printerInfo,
printerMeta,
heartbeatFails,
rfidInfo,
ribbonRfidInfo,
refreshRfidInfo,
} from "$/stores";
import { tr } from "$/utils/i18n";
import MdIcon from "$/components/basic/MdIcon.svelte";
import { Toasts } from "$/utils/toasts";
import { onMount } from "svelte";
import type { MaterialIcon } from "material-icons";
import FirmwareUpdater from "$/components/basic/FirmwareUpdater.svelte";
let featureSupport = $state<AvailableTransports>({ webBluetooth: false });
const onConnectClicked = async () => {
initClient();
connectionState.set("connecting");
try {
await $printerClient.connect();
} catch (e) {
connectionState.set("disconnected");
Toasts.error(e);
}
};
const onDisconnectClicked = () => {
$printerClient.disconnect();
};
const startHeartbeat = async () => {
$printerClient.startHeartbeat();
};
const stopHeartbeat = async () => {
$printerClient.stopHeartbeat();
};
const soundOn = async () => {
await $printerClient.abstraction.setSoundEnabled(SoundSettingsItemType.BluetoothConnectionSound, true);
await $printerClient.abstraction.setSoundEnabled(SoundSettingsItemType.PowerSound, true);
};
const soundOff = async () => {
await $printerClient.abstraction.setSoundEnabled(SoundSettingsItemType.BluetoothConnectionSound, false);
await $printerClient.abstraction.setSoundEnabled(SoundSettingsItemType.PowerSound, false);
};
const fetchInfo = async () => {
await $printerClient.fetchPrinterInfo();
};
const reset = async () => {
await $printerClient.abstraction.printerReset();
};
const batteryIcon = (value: number): MaterialIcon => {
if (value > 4) {
value = Math.min(4, Math.max(1, Math.ceil(value / 25)));
}
if (value === 4) {
return "battery_full";
} else if (value === 3) {
return "battery_5_bar";
} else if (value === 2) {
return "battery_3_bar";
} else if (value === 1) {
return "battery_2_bar";
}
return "battery_0_bar";
};
onMount(() => {
featureSupport = Utils.getAvailableTransports();
});
</script>
<div class="input-group w-auto input-group-sm flex-nowrap justify-content-end">
{#if $connectionState === "connected"}
<button class="btn btn-secondary" data-bs-toggle="dropdown" data-bs-auto-close="outside">
<MdIcon icon="settings" />
</button>
<div class="dropdown-menu p-1">
{#if $printerInfo}
<div>
Printer info:
<ul>
{#each Object.entries($printerInfo) as [key, value] (key)}
<li>{key}: <strong>{value ?? "-"}</strong></li>
{/each}
</ul>
</div>
{/if}
{#if $printerMeta}
<button
class="btn btn-sm btn-outline-secondary d-block w-100 mt-1"
type="button"
data-bs-toggle="collapse"
data-bs-target="#modelMeta">
Model metadata <MdIcon icon="expand_more" />
</button>
<div class="collapse" id="modelMeta">
<ul>
{#each Object.entries($printerMeta) as [key, value] (key)}
<li>{key}: <strong>{value ?? "-"}</strong></li>
{/each}
</ul>
</div>
{/if}
{#if $rfidInfo}
<button
class="btn btn-sm btn-outline-secondary d-block w-100 mt-1"
type="button"
data-bs-toggle="collapse"
data-bs-target="#rfidInfo">
RFID info <MdIcon icon="expand_more" />
</button>
<div class="collapse" id="rfidInfo">
<button class="btn btn-outline-secondary btn-sm mt-1" onclick={refreshRfidInfo}>Update</button>
<ul>
{#each Object.entries($rfidInfo) as [key, value] (key)}
<li>{key}: <strong>{value ?? "-"}</strong></li>
{/each}
</ul>
</div>
{/if}
{#if $ribbonRfidInfo}
<button
class="btn btn-sm btn-outline-secondary d-block w-100 mt-1"
type="button"
data-bs-toggle="collapse"
data-bs-target="#ribbonRfidInfo">
Ribbon RFID info <MdIcon icon="expand_more" />
</button>
<div class="collapse" id="ribbonRfidInfo">
<button class="btn btn-outline-secondary btn-sm mt-1" onclick={refreshRfidInfo}>Update</button>
<ul>
{#each Object.entries($ribbonRfidInfo) as [key, value] (key)}
<li>{key}: <strong>{value ?? "-"}</strong></li>
{/each}
</ul>
</div>
{/if}
{#if $heartbeatData}
<button
class="btn btn-sm btn-outline-secondary d-block w-100 mt-1"
type="button"
data-bs-toggle="collapse"
data-bs-target="#heartbeatData">
Heartbeat data <MdIcon icon="expand_more" />
</button>
<div class="collapse" id="heartbeatData">
<ul>
{#each Object.entries($heartbeatData) as [key, value] (key)}
<li>{key}: <strong>{value ?? "-"}</strong></li>
{/each}
</ul>
</div>
{/if}
<FirmwareUpdater />
<button
class="btn btn-sm btn-outline-secondary d-block w-100 mt-1"
type="button"
data-bs-toggle="collapse"
data-bs-target="#tests">
Tests <MdIcon icon="expand_more" />
</button>
<div class="collapse" id="tests">
<div class="d-flex flex-wrap gap-1 mt-1">
<button class="btn btn-sm btn-primary" onclick={startHeartbeat}>Heartbeat on</button>
<button class="btn btn-sm btn-primary" onclick={stopHeartbeat}>Heartbeat off</button>
<button class="btn btn-sm btn-primary" onclick={soundOn}>Sound on</button>
<button class="btn btn-sm btn-primary" onclick={soundOff}>Sound off</button>
<button class="btn btn-sm btn-primary" onclick={fetchInfo}>Fetch info again</button>
<button class="btn btn-sm btn-primary" onclick={reset}>Reset</button>
</div>
</div>
</div>
<span class="input-group-text">
<MdIcon icon="bluetooth" />
</span>
<span class="input-group-text {$heartbeatFails > 0 ? 'text-warning' : ''}">
{$printerMeta?.model ?? $connectedPrinterName}
</span>
{#if $heartbeatData?.chargeLevel}
<span class="input-group-text">
<MdIcon icon={batteryIcon($heartbeatData.chargeLevel)} class="r-90"></MdIcon>
</span>
{/if}
{:else}
<button
class="btn btn-primary"
disabled={$connectionState === "connecting" || !featureSupport.webBluetooth}
onclick={onConnectClicked}>
<MdIcon icon="bluetooth" />
</button>
{/if}
{#if $connectionState === "connected"}
<button class="btn btn-danger" onclick={onDisconnectClicked}>
<MdIcon icon="power_off" />
</button>
{/if}
</div>
<style>
.dropdown-menu {
width: 100vw;
max-width: 300px;
}
</style>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="82mm" height="82mm" version="1.1" viewBox="0 0 82 82" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g transform="translate(-21.444 -18.585)"><path transform="rotate(90)" d="m44.437-102.88h30.674c5.3533 0 9.663 4.3097 9.663 9.663v61.38c0 5.3533-4.3097 9.663-9.663 9.663h-30.674c-5.3533 0-9.663-4.3097-9.663-9.663v-61.38c0-5.3533 4.3097-9.663 9.663-9.663z" fill="#fbfbfb" stroke-linecap="round" stroke-width="1.2811"/><path d="m62.523 34.774v50" fill="#ff922a" stroke="#797979" stroke-dasharray="5.29199982,2.64599991" stroke-width="1.323" style="paint-order:stroke markers fill"/><g transform="matrix(-.063538 0 0 .063538 58.461 56.16)">
<path class="st0" d="m500.32 211.66-34.024-54.143c-11.508-18.302-31.61-29.402-53.216-29.402h-158.44c-26.654 0-52.195 10.719-70.849 29.745l-45.216 46.107-107.84 24.965c-18.005 4.177-30.738 20.214-30.738 38.682v42.348c0 9.122 7.406 16.538 16.538 16.538h57.336c-0.074 1.141-0.185 2.274-0.185 3.425 0 29.8 24.167 53.958 53.977 53.958 29.792 0 53.958-24.158 53.958-53.958 0-1.151-0.111-2.284-0.185-3.425h169.67c-0.074 1.141-0.185 2.274-0.185 3.425 0 29.8 24.166 53.958 53.958 53.958 29.81 0 53.958-24.158 53.958-53.958 0-1.151-0.092-2.284-0.166-3.425h36.789c9.132 0 16.538-7.416 16.538-16.538v-57.81c-1e-3 -14.329-4.047-28.352-11.676-40.492zm-372.66 139.77c-11.879 0-21.494-9.643-21.494-21.504 0-11.871 9.615-21.495 21.494-21.495 11.86 0 21.494 9.624 21.494 21.495 0 11.86-9.634 21.504-21.494 21.504zm136.46-135.68h-97.188l37.198-37.93c13.216-13.476 31.628-21.198 50.505-21.198h9.486v59.128zm110.87 0h-85.94v-59.128h85.94zm29.884 135.68c-11.86 0-21.494-9.643-21.494-21.504 0-11.871 9.634-21.495 21.494-21.495 11.879 0 21.494 9.624 21.494 21.495 0 11.86-9.615 21.504-21.494 21.504zm-4.938-135.68v-59.128h13.142c11.879 0 22.756 6.004 29.067 16.065l27.062 43.063z"/>
</g><g transform="matrix(-.063538 0 0 .063538 99.736 56.16)">
<path class="st0" d="m500.32 211.66-34.024-54.143c-11.508-18.302-31.61-29.402-53.216-29.402h-158.44c-26.654 0-52.195 10.719-70.849 29.745l-45.216 46.107-107.84 24.965c-18.005 4.177-30.738 20.214-30.738 38.682v42.348c0 9.122 7.406 16.538 16.538 16.538h57.336c-0.074 1.141-0.185 2.274-0.185 3.425 0 29.8 24.167 53.958 53.977 53.958 29.792 0 53.958-24.158 53.958-53.958 0-1.151-0.111-2.284-0.185-3.425h169.67c-0.074 1.141-0.185 2.274-0.185 3.425 0 29.8 24.166 53.958 53.958 53.958 29.81 0 53.958-24.158 53.958-53.958 0-1.151-0.092-2.284-0.166-3.425h36.789c9.132 0 16.538-7.416 16.538-16.538v-57.81c-1e-3 -14.329-4.047-28.352-11.676-40.492zm-372.66 139.77c-11.879 0-21.494-9.643-21.494-21.504 0-11.871 9.615-21.495 21.494-21.495 11.86 0 21.494 9.624 21.494 21.495 0 11.86-9.634 21.504-21.494 21.504zm136.46-135.68h-97.188l37.198-37.93c13.216-13.476 31.628-21.198 50.505-21.198h9.486v59.128zm110.87 0h-85.94v-59.128h85.94zm29.884 135.68c-11.86 0-21.494-9.643-21.494-21.504 0-11.871 9.634-21.495 21.494-21.495 11.879 0 21.494 9.624 21.494 21.495 0 11.86-9.615 21.504-21.494 21.504zm-4.938-135.68v-59.128h13.142c11.879 0 22.756 6.004 29.067 16.065l27.062 43.063z"/>
</g></g><style type="text/css">
.st0{fill:#000000;}
</style></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="82mm" height="82mm" version="1.1" viewBox="0 0 82 82" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g transform="translate(-21.444 -18.585)"><path transform="rotate(90)" d="m44.437-102.88h30.674c5.3533 0 9.663 4.3097 9.663 9.663v61.38c0 5.3533-4.3097 9.663-9.663 9.663h-30.674c-5.3533 0-9.663-4.3097-9.663-9.663v-61.38c0-5.3533 4.3097-9.663 9.663-9.663z" fill="#fbfbfb" stroke-linecap="round" stroke-width="1.2811"/><path d="m62.523 34.774v50" fill="#ff922a" stroke="#797979" stroke-dasharray="5.29199982,2.64599991" stroke-width="1.323" style="paint-order:stroke markers fill"/><g transform="matrix(-.063538 0 0 .063538 58.461 56.16)">
<path class="st0" d="m500.32 211.66-34.024-54.143c-11.508-18.302-31.61-29.402-53.216-29.402h-158.44c-26.654 0-52.195 10.719-70.849 29.745l-45.216 46.107-107.84 24.965c-18.005 4.177-30.738 20.214-30.738 38.682v42.348c0 9.122 7.406 16.538 16.538 16.538h57.336c-0.074 1.141-0.185 2.274-0.185 3.425 0 29.8 24.167 53.958 53.977 53.958 29.792 0 53.958-24.158 53.958-53.958 0-1.151-0.111-2.284-0.185-3.425h169.67c-0.074 1.141-0.185 2.274-0.185 3.425 0 29.8 24.166 53.958 53.958 53.958 29.81 0 53.958-24.158 53.958-53.958 0-1.151-0.092-2.284-0.166-3.425h36.789c9.132 0 16.538-7.416 16.538-16.538v-57.81c-1e-3 -14.329-4.047-28.352-11.676-40.492zm-372.66 139.77c-11.879 0-21.494-9.643-21.494-21.504 0-11.871 9.615-21.495 21.494-21.495 11.86 0 21.494 9.624 21.494 21.495 0 11.86-9.634 21.504-21.494 21.504zm136.46-135.68h-97.188l37.198-37.93c13.216-13.476 31.628-21.198 50.505-21.198h9.486v59.128zm110.87 0h-85.94v-59.128h85.94zm29.884 135.68c-11.86 0-21.494-9.643-21.494-21.504 0-11.871 9.634-21.495 21.494-21.495 11.879 0 21.494 9.624 21.494 21.495 0 11.86-9.615 21.504-21.494 21.504zm-4.938-135.68v-59.128h13.142c11.879 0 22.756 6.004 29.067 16.065l27.062 43.063z"/>
</g><g transform="matrix(.063538 0 0 -.063538 67.169 62.608)">
<path class="st0" d="m500.32 211.66-34.024-54.143c-11.508-18.302-31.61-29.402-53.216-29.402h-158.44c-26.654 0-52.195 10.719-70.849 29.745l-45.216 46.107-107.84 24.965c-18.005 4.177-30.738 20.214-30.738 38.682v42.348c0 9.122 7.406 16.538 16.538 16.538h57.336c-0.074 1.141-0.185 2.274-0.185 3.425 0 29.8 24.167 53.958 53.977 53.958 29.792 0 53.958-24.158 53.958-53.958 0-1.151-0.111-2.284-0.185-3.425h169.67c-0.074 1.141-0.185 2.274-0.185 3.425 0 29.8 24.166 53.958 53.958 53.958 29.81 0 53.958-24.158 53.958-53.958 0-1.151-0.092-2.284-0.166-3.425h36.789c9.132 0 16.538-7.416 16.538-16.538v-57.81c-1e-3 -14.329-4.047-28.352-11.676-40.492zm-372.66 139.77c-11.879 0-21.494-9.643-21.494-21.504 0-11.871 9.615-21.495 21.494-21.495 11.86 0 21.494 9.624 21.494 21.495 0 11.86-9.634 21.504-21.494 21.504zm136.46-135.68h-97.188l37.198-37.93c13.216-13.476 31.628-21.198 50.505-21.198h9.486v59.128zm110.87 0h-85.94v-59.128h85.94zm29.884 135.68c-11.86 0-21.494-9.643-21.494-21.504 0-11.871 9.634-21.495 21.494-21.495 11.879 0 21.494 9.624 21.494 21.495 0 11.86-9.615 21.504-21.494 21.504zm-4.938-135.68v-59.128h13.142c11.879 0 22.756 6.004 29.067 16.065l27.062 43.063z"/>
</g></g><style type="text/css">
.st0{fill:#000000;}
</style></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="82mm" height="82mm" version="1.1" viewBox="0 0 82 82" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g transform="translate(-21.444 -18.585)"><path transform="rotate(90)" d="m44.437-102.88h30.674c5.3533 0 9.663 4.3097 9.663 9.663v61.38c0 5.3533-4.3097 9.663-9.663 9.663h-30.674c-5.3533 0-9.663-4.3097-9.663-9.663v-61.38c0-5.3533 4.3097-9.663 9.663-9.663z" fill="#fbfbfb" stroke-linecap="round" stroke-width="1.2811"/><path d="m62.523 34.774v50" fill="#ff922a" stroke="#797979" stroke-dasharray="5.29199982,2.64599991" stroke-width="1.323" style="paint-order:stroke markers fill"/><g transform="matrix(-.063538 0 0 .063538 58.461 56.16)">
<path class="st0" d="m500.32 211.66-34.024-54.143c-11.508-18.302-31.61-29.402-53.216-29.402h-158.44c-26.654 0-52.195 10.719-70.849 29.745l-45.216 46.107-107.84 24.965c-18.005 4.177-30.738 20.214-30.738 38.682v42.348c0 9.122 7.406 16.538 16.538 16.538h57.336c-0.074 1.141-0.185 2.274-0.185 3.425 0 29.8 24.167 53.958 53.977 53.958 29.792 0 53.958-24.158 53.958-53.958 0-1.151-0.111-2.284-0.185-3.425h169.67c-0.074 1.141-0.185 2.274-0.185 3.425 0 29.8 24.166 53.958 53.958 53.958 29.81 0 53.958-24.158 53.958-53.958 0-1.151-0.092-2.284-0.166-3.425h36.789c9.132 0 16.538-7.416 16.538-16.538v-57.81c-1e-3 -14.329-4.047-28.352-11.676-40.492zm-372.66 139.77c-11.879 0-21.494-9.643-21.494-21.504 0-11.871 9.615-21.495 21.494-21.495 11.86 0 21.494 9.624 21.494 21.495 0 11.86-9.634 21.504-21.494 21.504zm136.46-135.68h-97.188l37.198-37.93c13.216-13.476 31.628-21.198 50.505-21.198h9.486v59.128zm110.87 0h-85.94v-59.128h85.94zm29.884 135.68c-11.86 0-21.494-9.643-21.494-21.504 0-11.871 9.634-21.495 21.494-21.495 11.879 0 21.494 9.624 21.494 21.495 0 11.86-9.615 21.504-21.494 21.504zm-4.938-135.68v-59.128h13.142c11.879 0 22.756 6.004 29.067 16.065l27.062 43.063z"/>
</g></g><style type="text/css">
.st0{fill:#000000;}
</style></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="82mm" height="82mm" version="1.1" viewBox="0 0 82 82" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-21.444 -18.585)">
<rect x="34.269" y="34.711" width="50" height="49.747" ry="8.5343" fill="#f00"/>
<rect x="40.09" y="34.585" width="50" height="50" ry="8.5776" fill="#fbfbfb"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 362 B

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="82mm" height="82mm" version="1.1" viewBox="0 0 82 82" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-21.444 -18.585)">
<rect x="37.444" y="34.585" width="50" height="50" ry="25" fill="#fbfbfb"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 276 B

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="82mm" height="82mm" version="1.1" viewBox="0 0 82 82" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-21.444 -18.585)">
<rect x="37.444" y="34.585" width="50" height="50" ry="0" fill="#fbfbfb"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 275 B

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="82mm" height="82mm" version="1.1" viewBox="0 0 82 82" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-21.444 -18.585)">
<rect x="37.444" y="34.585" width="50" height="50" ry="8.5776" fill="#fbfbfb"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 280 B

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="82mm" height="82mm" version="1.1" viewBox="0 0 82 82" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-21.444 -18.585)" fill="#fbfbfb">
<rect x="37.444" y="60.568" width="50" height="24.096" ry="8.5776"/>
<rect x="37.444" y="34.885" width="50" height="24.096" ry="8.5776"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 355 B

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="82mm" height="82mm" version="1.1" viewBox="0 0 82 82" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-21.444 -18.585)">
<rect x="21.623" y="34.585" width="50" height="50" ry="8.5776" fill="#fbfbfb"/>
<rect x="71.623" y="51.365" width="31.451" height="16.44" ry="0" fill="#ea868f"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 364 B

View File

@@ -0,0 +1,66 @@
<script lang="ts">
import Modal from "bootstrap/js/dist/modal";
import { onDestroy, onMount, type Snippet } from "svelte";
interface Props {
show: boolean;
title: string;
onClose?: () => void;
children: Snippet;
footer?: Snippet;
}
let { show = $bindable(), title, onClose, children, footer }: Props = $props();
let modalEl: HTMLElement;
let modal: Modal;
onMount(() => {
modal = new Modal(modalEl);
modal.show();
modalEl.addEventListener('hide.bs.modal', () => {
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
});
modalEl.addEventListener("hidden.bs.modal", () => {
if (onClose) onClose();
show = false;
});
});
onDestroy(() => {
if (modal) {
modal.hide();
modal.dispose();
}
});
export const hide = () => {
if (modal) {
modal.hide();
}
};
</script>
<div bind:this={modalEl} class="modal fade" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5">{title}</h1>
<button aria-label="Dismiss" type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
{@render children()}
</div>
{#if footer}
<div class="modal-footer">
{@render footer()}
</div>
{/if}
</div>
</div>
</div>

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import { Utils } from "$/lib/fichero";
import { tr } from "$/utils/i18n";
import MdIcon from "$/components/basic/MdIcon.svelte";
import { detectAntiFingerprinting } from "$/utils/browsers";
let caps = Utils.getAvailableTransports();
let antiFingerprinting = detectAntiFingerprinting();
let isMobile = typeof navigator !== "undefined" && /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
</script>
{#if !caps.webBluetooth}
<div class="alert alert-danger text-center" role="alert">
{#if isMobile}
<MdIcon icon="computer" />
Open on a desktop browser (Chrome, Edge, or Opera) to connect to your printer.
{:else}
<div>
{$tr("browser_warning.lines.first")}
</div>
<div>
{$tr("browser_warning.lines.second")}
</div>
{/if}
</div>
{/if}
{#if antiFingerprinting}
<div class="alert alert-danger" role="alert">
{$tr("browser_warning.fingerprinting")}
</div>
{/if}
<style>
</style>

View File

@@ -0,0 +1,87 @@
<script lang="ts">
import type { FirmwareProgressEvent } from "$/lib/fichero";
import { printerClient } from "$/stores";
import { Toasts } from "$/utils/toasts";
import { FileUtils } from "$/utils/file_utils";
let fwVersion = $state<string>("");
let fwVersionValid: boolean = $derived(/^\d+\.\d+$/.test(fwVersion));
let fwProgress = $state<string>("");
let fwData = $state<Uint8Array>();
let fwName = $state<string>("");
const browseFw = async () => {
const file = await FileUtils.pickAndReadBinaryFile("bin");
fwData = new Uint8Array(file.data);
fwName = file.name;
const match = fwName.match(/(\d+\.\d+)/);
// For modern firmware images version is stored in header
if (fwData.length >= 0x1C && fwData[0] === 0x18) {
const verNumber = (fwData[0x15] << 8) + fwData[0x14];
fwVersion = (verNumber / 100).toFixed(2);
} else if (match) {
fwVersion = match[1];
} else {
fwVersion = "";
}
};
const upgradeFw = async () => {
if (fwData === undefined) {
return;
}
if (!confirm("Flashing wrong firmware can make your printer dead. Are you sure?")) {
return;
}
const listener = (e: FirmwareProgressEvent) => {
fwProgress = `${e.currentChunk}/${e.totalChunks}`;
};
$printerClient.stopHeartbeat();
try {
$printerClient.on("firmwareprogress", listener);
fwProgress = "...";
await $printerClient.abstraction.firmwareUpgrade(fwData, fwVersion);
$printerClient.off("firmwareprogress", listener);
await $printerClient.disconnect();
Toasts.message("Flashing is finished, the printer will shut down now");
fwData = undefined;
fwName = "";
fwVersion = "";
} catch (e) {
$printerClient.startHeartbeat();
$printerClient.off("firmwareprogress", listener);
Toasts.error(e);
}
fwProgress = "";
};
</script>
<div class="firmware-updater">
Firmware flashing
<div class="input-group input-group-sm mt-1">
{#if fwProgress}
<span class="input-group-text">Uploading {fwProgress}</span>
{:else}
<span class="input-group-text">To</span>
<button class="btn btn-sm btn-secondary" title={fwName} onclick={browseFw} disabled={!!fwProgress}>
{fwName.length > 0 ? fwName.slice(0, 8) + "..." : "Browse..."}
</button>
<span class="input-group-text">ver.</span>
<input class="form-control" placeholder="x.x" type="text" size="6" bind:value={fwVersion} />
<button
class="btn btn-sm btn-danger"
onclick={upgradeFw}
disabled={!!fwProgress || !fwVersionValid || fwData === undefined}>Burn</button>
{/if}
</div>
</div>

View File

@@ -0,0 +1,39 @@
<script lang="ts">
import { iconCodepoints, type MaterialIcon } from "$/styles/mdi_icons";
interface Props {
icon: MaterialIcon;
class?: string;
}
let { icon, class: className = "" }: Props = $props();
</script>
<span class="mdi {className}">
{String.fromCodePoint(iconCodepoints[icon])}
</span>
<style>
.mdi {
font-family: "Material Icons";
font-weight: normal;
font-style: normal;
line-height: 1;
font-size: 1.5em;
vertical-align: -0.24em;
/*vertical-align: middle;*/
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
font-feature-settings: "liga";
}
.mdi.r-90 {
transform: rotate(90deg);
}
</style>

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import MdIcon from "$/components/basic/MdIcon.svelte";
interface Props {
propName: string;
savedValue: any;
value: any;
onClick: (key: string, value: any) => void;
}
let {
propName,
savedValue,
value,
onClick
}: Props = $props();
</script>
<button class="btn btn-sm btn-outline-secondary param-lock-btn" onclick={() => onClick(propName, value)}>
{#if savedValue !== undefined}
<MdIcon icon="lock" class="text-warning" />
{:else}
<MdIcon icon="lock_open" />
{/if}
</button>

View File

@@ -0,0 +1,105 @@
<script lang="ts">
import { Barcode } from "$/fabric-object/barcode";
import { tr } from "$/utils/i18n";
import MdIcon from "$/components/basic/MdIcon.svelte";
interface Props {
selectedBarcode: Barcode;
editRevision: number;
valueUpdated: () => void;
}
let { selectedBarcode, editRevision, valueUpdated }: Props = $props();
</script>
<input type="hidden" value={editRevision}>
<div class="input-group input-group-sm flex-nowrap">
<span class="input-group-text" title={$tr("params.barcode.encoding")}><MdIcon icon="code" /></span>
<select
class="form-select"
value={selectedBarcode.encoding}
onchange={(e) => {
selectedBarcode?.set("encoding", e.currentTarget.value ?? "EAN13");
valueUpdated();
}}>
<option value="EAN13">EAN13</option>
<option value="CODE128B">Code128 B</option>
</select>
</div>
<div class="input-group input-group-sm flex-nowrap">
<span class="input-group-text" title={$tr("params.barcode.scale")}>
<MdIcon icon="settings_ethernet" />
</span>
<input
class="barcode-width form-control"
type="number"
min="1"
value={selectedBarcode.scaleFactor}
oninput={(e) => {
selectedBarcode?.set("scaleFactor", e.currentTarget.valueAsNumber ?? 1);
valueUpdated();
}} />
</div>
<button
class="btn btn-sm {selectedBarcode.printText ? 'btn-secondary' : ''}"
title={$tr("params.barcode.enable_caption")}
onclick={() => {
selectedBarcode?.set("printText", !selectedBarcode.printText);
valueUpdated();
}}>
123
</button>
<div class="input-group input-group-sm flex-nowrap">
<span class="input-group-text" title={$tr("params.barcode.font_size")}>
<MdIcon icon="format_size" />
</span>
<input
class="barcode-width form-control"
type="number"
min="1"
value={selectedBarcode.fontSize}
oninput={(e) => {
selectedBarcode?.set("fontSize", e.currentTarget.valueAsNumber ?? 12);
valueUpdated();
}} />
</div>
{#if selectedBarcode.encoding === "EAN13"}
<div class="input-group input-group-sm flex-nowrap">
<span class="input-group-text" title={$tr("params.barcode.content")}><MdIcon icon="view_week" /></span>
<input
class="barcode-content form-control"
maxlength="12"
value={selectedBarcode.text}
oninput={(e) => {
selectedBarcode?.set("text", e.currentTarget.value);
valueUpdated();
}} />
</div>
{:else}
<textarea
class="barcode-content form-control"
value={selectedBarcode.text}
oninput={(e) => {
selectedBarcode?.set("text", e.currentTarget.value);
valueUpdated();
}}></textarea>
{/if}
<style>
.input-group {
width: fit-content;
}
textarea.barcode-content {
height: 100px;
}
input.barcode-width {
max-width: 64px;
}
</style>

View File

@@ -0,0 +1,75 @@
<script lang="ts">
import { tr } from "$/utils/i18n";
import { csvParse } from "d3-dsv";
import MdIcon from "$/components/basic/MdIcon.svelte";
import { type CsvParams } from "$/types";
import { csvData } from "$/stores";
interface Props {
enabled: boolean;
onPlaceholderPicked: (name: string) => void;
}
let { enabled = $bindable(), onPlaceholderPicked }: Props = $props();
let placeholders = $state<string[]>([]);
let rows = $state<number>(0);
const parse = (csv: CsvParams) => {
const result = csvParse(csv.data);
placeholders = result.columns;
rows = result.length;
};
$effect(() => {
parse($csvData);
});
</script>
<div class="dropdown">
<button
class="btn btn-sm btn-{enabled ? 'warning' : 'secondary'}"
data-bs-toggle="dropdown"
data-bs-auto-close="outside"
title={$tr("params.csv.title")}>
<MdIcon icon="dataset" />
</button>
<div class="dropdown-menu">
<h6 class="dropdown-header">{$tr("params.csv.title")}</h6>
<div class="p-3 text-body-secondary">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="enabled" bind:checked={enabled} />
<label class="form-check-label" for="enabled">{$tr("params.csv.enabled")}</label>
</div>
<div class="mt-3">
{$tr("params.csv.tip")}
</div>
<textarea class="dsv form-control my-3" bind:value={$csvData.data} oninput={() => (enabled = true)}></textarea>
<div class="placeholders pt-1">
{$tr("params.csv.rowsfound")} <strong>{rows}</strong>
</div>
<div class="placeholders pt-1">
{$tr("params.csv.placeholders")}
{#each placeholders as p (p)}
<button class="btn btn-sm btn-outline-info px-1 py-0" onclick={() => onPlaceholderPicked(p)}
>{`{${p}}`}
</button>
{/each}
</div>
</div>
</div>
</div>
<style>
.dropdown-menu {
width: 100vw;
max-width: 450px;
}
textarea.dsv {
font-family: monospace;
min-height: 240px;
}
</style>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { tr } from "$/utils/i18n";
interface Props {
value: number;
}
let { value = $bindable() }: Props = $props();
</script>
<div class="input-group flex-nowrap input-group-sm mb-2">
<span class="input-group-text">{$tr("params.label.head_density")}</span>
<select class="form-select" bind:value>
<option value={8}>203dpi</option>
<option value={11.81}>300dpi</option>
</select>
<input class="form-control" type="number" min="1" bind:value />
<span class="input-group-text cursor-help" title={$tr("params.label.head_density.help")}>
{$tr("params.label.dpmm")}
</span>
</div>

View File

@@ -0,0 +1,121 @@
<script lang="ts">
import { onMount } from "svelte";
import { OBJECT_DEFAULTS_TEXT } from "$/defaults";
import { tr } from "$/utils/i18n";
import { Toasts } from "$/utils/toasts";
import MdIcon from "$/components/basic/MdIcon.svelte";
import { LocalStoragePersistence } from "$/utils/persistence";
import { fontCache, userFonts } from "$/stores";
import FontsMenu from "$/components/designer-controls/FontsMenu.svelte";
interface Props {
editRevision?: number;
value: string;
valueUpdated: (v: string) => void;
}
let { value, valueUpdated, editRevision }: Props = $props();
let fontQuerySupported = typeof queryLocalFonts !== "undefined";
const getSystemFonts = async () => {
try {
const fonts = await queryLocalFonts();
const fontListSorted = [OBJECT_DEFAULTS_TEXT.fontFamily, ...new Set(fonts.map((f: FontData) => f.family))].sort();
fontCache.update(() => fontListSorted);
LocalStoragePersistence.saveCachedFonts(fontListSorted);
} catch (e) {
Toasts.error(e);
}
};
onMount(() => {
try {
let stored = LocalStoragePersistence.loadCachedFonts();
if (stored.length > 0) {
const uniqueFonts = new Set([OBJECT_DEFAULTS_TEXT.fontFamily, ...stored]);
fontCache.update(() => [...uniqueFonts].sort());
}
} catch (e) {
Toasts.error(e);
}
});
</script>
<div class="input-group flex-nowrap input-group-sm font-family-picker">
<span class="input-group-text" title={$tr("params.text.font_family")}>
<MdIcon icon="text_format" />
</span>
<input
type="text"
class="form-control font-family-input"
data-ver={editRevision}
{value}
oninput={(e) => valueUpdated(e.currentTarget.value)} />
<!-- svelte-ignore a11y_consider_explicit_label -->
{#if $fontCache.length > 0 || $userFonts.length > 0}
<button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown"></button>
<div class="dropdown-menu">
{#if $userFonts.length > 0}
<h6 class="dropdown-header">{$tr("params.text.user_fonts")}</h6>
{#each $userFonts as font (font.family)}
<button
class="dropdown-item"
style="font-family: {font.family}"
type="button"
onclick={() => valueUpdated(font.family)}>
{font.family}
</button>
{/each}
{/if}
{#if $fontCache.length > 0}
<h6 class="dropdown-header">{$tr("params.text.system_fonts")}</h6>
{#each $fontCache as family (family)}
<button
class="dropdown-item"
style="font-family: {family}"
type="button"
onclick={() => valueUpdated(family)}>
{family}
</button>
{/each}
{/if}
</div>
{/if}
{#if fontQuerySupported}
<button class="btn {$fontCache.length <= 1 ? 'btn-primary pulse' : 'btn-outline-secondary'}" onclick={getSystemFonts} title={$tr("params.text.fetch_fonts")}>
<MdIcon icon="refresh" />
</button>
{/if}
<FontsMenu />
</div>
<style>
.font-family-picker {
width: unset;
}
.font-family-input {
width: 14em;
}
.dropdown-menu {
max-height: 240px;
overflow-y: auto;
}
.pulse {
animation: pulse 1.5s ease-in-out 3;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>

View File

@@ -0,0 +1,99 @@
<script lang="ts">
import AppModal from "$/components/basic/AppModal.svelte";
import MdIcon from "$/components/basic/MdIcon.svelte";
import { userFonts } from "$/stores";
import { FileUtils } from "$/utils/file_utils";
import { tr } from "$/utils/i18n";
import { LocalStoragePersistence } from "$/utils/persistence";
import { Toasts } from "$/utils/toasts";
let show = $state<boolean>(false);
let usedSpace = $state<number>(0);
let selectExt = $state<"ttf" | "woff2">("ttf");
let overrideFamily = $state<string>("");
const calcUsedSpace = () => {
usedSpace = LocalStoragePersistence.usedSpace();
};
const browseFont = async () => {
const result = await FileUtils.pickAndReadBinaryFile(selectExt);
let fontName = result.name.split(".")[0];
const mime = `text/${selectExt}`;
if (overrideFamily.trim() !== "") {
fontName = overrideFamily.trim();
}
if ($userFonts.some((e) => e.family == fontName)) {
Toasts.error(`${fontName} already loaded`);
return;
}
const compressed = await FileUtils.compressData(result.data);
const b64data = await FileUtils.base64buf(compressed);
userFonts.update((prev) => [...prev, { gzippedDataB64: b64data, family: fontName, mimeType: mime }]);
calcUsedSpace();
overrideFamily = "";
};
const removeFont = (family: string) => {
userFonts.update((prev) => prev.filter((e) => e.family !== family));
calcUsedSpace();
};
$effect(() => {
if (show) calcUsedSpace();
});
</script>
<button
class="btn btn-outline-secondary"
onclick={() => {
show = true;
}}>
<MdIcon icon="settings" />
</button>
{#if show}
<AppModal title={$tr("fonts.title")} bind:show>
<div class="mb-1">
{#each $userFonts as font (font.family)}
<div class="input-group input-group-sm mb-1">
<span class="input-group-text fs-5" style="font-family: {font.family}">{font.family}</span>
<button class="btn btn-sm btn-danger" onclick={() => removeFont(font.family)}>
<MdIcon icon="delete" />
</button>
</div>
{:else}
👀
{/each}
</div>
<hr />
<div class="input-group input-group-sm">
<span class="input-group-text">{$tr("fonts.add")}</span>
<select class="form-select" bind:value={selectExt}>
<option value="ttf">ttf</option>
<option value="woff2">woff2</option>
</select>
<input type="text" class="form-control w-25" placeholder={$tr("fonts.title_override")} bind:value={overrideFamily} />
<button class="btn btn-sm btn-secondary" onclick={browseFont}>{$tr("fonts.browse")}</button>
</div>
{#snippet footer()}
<div class="text-secondary">
{usedSpace}
{$tr("params.saved_labels.kb_used")} |
<a class="text-secondary" href="https://fonts.google.com">{$tr("fonts.gfonts")}</a>
</div>
{/snippet}
</AppModal>
{/if}

View File

@@ -0,0 +1,123 @@
<script lang="ts">
import * as fabric from "fabric";
import { tr } from "$/utils/i18n";
import { appConfig } from "$/stores";
import MdIcon from "$/components/basic/MdIcon.svelte";
import ObjectPositionControls from "$/components/designer-controls/ObjectPositionControls.svelte";
interface Props {
selectedObject: fabric.FabricObject;
editRevision: number;
valueUpdated: () => void;
}
let { selectedObject, editRevision, valueUpdated }: Props = $props();
const putToCenterV = () => {
selectedObject.canvas!.centerObjectV(selectedObject);
valueUpdated();
};
const putToCenterH = () => {
selectedObject.canvas!.centerObjectH(selectedObject);
valueUpdated();
};
const bringTo = (to: "top" | "bottom") => {
if (to === "top") {
selectedObject.canvas?.bringObjectToFront(selectedObject);
} else if (to === "bottom") {
selectedObject.canvas?.sendObjectToBack(selectedObject);
}
};
const fit = () => {
const imageRatio = selectedObject.width / selectedObject.height;
const canvasRatio = selectedObject.canvas!.width / selectedObject.canvas!.height;
if ($appConfig.fitMode === "ratio_min") {
if (imageRatio > canvasRatio) {
selectedObject.scaleToWidth(selectedObject.canvas!.width);
} else {
selectedObject.scaleToHeight(selectedObject.canvas!.height);
}
selectedObject.canvas!.centerObject(selectedObject);
} else if ($appConfig.fitMode === "ratio_max") {
if (imageRatio > canvasRatio) {
selectedObject.scaleToHeight(selectedObject.canvas!.height);
} else {
selectedObject.scaleToWidth(selectedObject.canvas!.width);
}
selectedObject.canvas!.centerObject(selectedObject);
} else {
selectedObject.set({
left: 0,
top: 0,
scaleX: selectedObject.canvas!.width / selectedObject.width,
scaleY: selectedObject.canvas!.height / selectedObject.height,
});
}
valueUpdated();
};
const fitModeChanged = (e: Event & { currentTarget: HTMLSelectElement }) => {
const fitMode = e.currentTarget.value as "stretch" | "ratio_min" | "ratio_max";
appConfig.update((v) => ({ ...v, fitMode: fitMode }));
};
</script>
<input type="hidden" value={editRevision}>
<button class="btn btn-sm btn-secondary" onclick={putToCenterV} title={$tr("params.generic.center.vertical")}>
<MdIcon icon="vertical_distribute" />
</button>
<button class="btn btn-sm btn-secondary" onclick={putToCenterH} title={$tr("params.generic.center.horizontal")}>
<MdIcon icon="horizontal_distribute" />
</button>
<ObjectPositionControls {selectedObject} />
<div class="dropdown">
<button
class="btn btn-sm btn-secondary dropdown-toggle"
type="button"
data-bs-toggle="dropdown"
title={$tr("params.generic.arrange")}>
<MdIcon icon="segment" />
</button>
<div class="dropdown-menu arrangement p-2">
<button class="btn btn-sm" onclick={() => bringTo("top")}>
{$tr("params.generic.arrange.top")}
</button>
<button class="btn btn-sm" onclick={() => bringTo("bottom")}>
{$tr("params.generic.arrange.bottom")}
</button>
</div>
</div>
{#if selectedObject instanceof fabric.FabricImage}
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-secondary" onclick={fit} title={$tr("params.generic.fit")}>
<MdIcon icon="fit_screen" />
</button>
<button
aria-label="Toggle"
type="button"
class="btn btn-secondary dropdown-toggle dropdown-toggle-split px-1"
data-bs-toggle="dropdown"></button>
<div class="dropdown-menu p-1">
<select class="form-select form-select-sm" value={$appConfig.fitMode ?? "stretch"} onchange={fitModeChanged}>
<option value="stretch">{$tr("params.generic.fit.mode.stretch")}</option>
<option value="ratio_min">{$tr("params.generic.fit.mode.ratio_min")}</option>
<option value="ratio_max">{$tr("params.generic.fit.mode.ratio_max")}</option>
</select>
</div>
</div>
{/if}
<style>
.dropdown-menu.arrangement {
text-align: center;
}
</style>

View File

@@ -0,0 +1,152 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { tr } from "$/utils/i18n";
import { iconCodepoints, type MaterialIcon } from "$/styles/mdi_icons";
import MdIcon from "$/components/basic/MdIcon.svelte";
import { appConfig, userIcons } from "$/stores";
import { FileUtils } from "$/utils/file_utils";
import { Toasts } from "$/utils/toasts";
interface Props {
onSubmit: (i: MaterialIcon) => void;
onSubmitSvg: (i: string) => void;
}
let { onSubmit, onSubmitSvg }: Props = $props();
let iconNames = $state<MaterialIcon[]>([]);
let search = $state<string>("");
let deleteMode = $state<boolean>(false);
let dropdown: HTMLDivElement;
const onShow = () => {
if (iconNames.length === 0) {
iconNames = Object.keys(iconCodepoints) as MaterialIcon[];
}
};
const addOwn = async () => {
try {
let counter = 0;
const xmls = await FileUtils.pickAndReadTextFile("svg", true);
const iconsToAdd = xmls.map((xml) => ({
name: `i_${FileUtils.timestampFloat()}_${counter++}`,
data: xml,
}));
userIcons.update((prev) => [...prev, ...iconsToAdd]);
} catch (e) {
Toasts.error(e);
}
};
const svgClicked = (name: string, data: string) => {
if (deleteMode) {
userIcons.update((prev) => prev.filter((e) => e.name !== name));
return;
}
onSubmitSvg(data);
};
const iconClicked = (i: MaterialIcon) => {
if (deleteMode) {
return;
}
onSubmit(i);
};
onMount(() => {
dropdown?.addEventListener("show.bs.dropdown", onShow);
});
onDestroy(() => {
dropdown?.removeEventListener("show.bs.dropdown", onShow);
});
</script>
<div class="dropdown" bind:this={dropdown}>
<button class="btn btn-sm btn-secondary" data-bs-toggle="dropdown" data-bs-auto-close="outside">
<MdIcon icon="emoji_emotions" />
<MdIcon icon="add" />
</button>
<div class="dropdown-menu">
<h6 class="dropdown-header">{$tr("editor.iconpicker.title")}</h6>
<div class="p-3">
<input
disabled={$appConfig.iconListMode === "user"}
type="text"
class="form-control mb-1"
placeholder={$tr("editor.iconpicker.search")}
bind:value={search} />
<div class="input-group input-group-sm mb-1">
<span class="input-group-text">{$tr("editor.iconpicker.show")}</span>
<select class="form-select form-select-sm" bind:value={$appConfig.iconListMode}>
<option value="both">{$tr("editor.iconpicker.show.both")}</option>
<option value="user">{$tr("editor.iconpicker.show.user")}</option>
<option value="pack">{$tr("editor.iconpicker.show.pack")}</option>
</select>
</div>
<div class="icons mb-1">
{#if $appConfig.iconListMode === "both" || $appConfig.iconListMode === "user"}
{#each $userIcons as { name, data } (name)}
<button
class="btn {deleteMode ? 'btn-danger' : 'btn-light'} me-1 mb-1 user-icon"
onclick={() => svgClicked(name, data)}>
<img src="data:image/svg+xml;base64,{FileUtils.base64str(data)}" alt="user-svg" />
</button>
{/each}
{/if}
{#if $appConfig.iconListMode === "both" || $appConfig.iconListMode === "pack"}
{#each iconNames as name (name)}
{#if !search || name.includes(search.toLowerCase())}
<button class="btn me-1" title={name} onclick={() => iconClicked(name)}>
<MdIcon icon={name} />
</button>
{/if}
{/each}
{/if}
</div>
<div class="input-group input-group-sm mb-1">
<button class="btn btn-outline-secondary" onclick={addOwn}>
<MdIcon icon="add" />
{$tr("editor.iconpicker.add")}
</button>
<button
class="btn {deleteMode ? 'btn-danger' : 'btn-outline-secondary'}"
onclick={() => (deleteMode = !deleteMode)}>
<MdIcon icon="delete" />
{$tr("editor.iconpicker.delete_mode")}
</button>
</div>
<a
href="https://fonts.google.com/icons?icon.set=Material+Icons&icon.style=Filled"
target="_blank"
class="text-secondary">
{$tr("editor.iconpicker.mdi_link_title")}
</a>
</div>
</div>
</div>
<style>
.dropdown-menu {
width: 100vw;
max-width: 450px;
}
.icons {
max-height: 400px;
overflow-y: scroll;
}
.user-icon img {
width: 24px;
}
</style>

View File

@@ -0,0 +1,122 @@
<script lang="ts">
import type { LabelPreset } from "$/types";
import { tr } from "$/utils/i18n";
import MdIcon from "$/components/basic/MdIcon.svelte";
interface Props {
onItemSelected: (index: number) => void;
onItemDelete: (index: number) => void;
presets: LabelPreset[];
class?: string;
}
let { class: className = "", onItemDelete, onItemSelected, presets }: Props = $props();
let deleteIndex = $state<number>(-1);
const scaleDimensions = (preset: LabelPreset): { width: number; height: number } => {
const scaleFactor = Math.min(100 / preset.width, 100 / preset.height);
return {
width: Math.round(preset.width * scaleFactor),
height: Math.round(preset.height * scaleFactor),
};
};
const deleteConfirmed = (e: MouseEvent, idx: number) => {
e.stopPropagation();
deleteIndex = -1;
onItemDelete(idx);
};
const deleteRejected = (e: MouseEvent) => {
e.stopPropagation();
deleteIndex = -1;
};
const deleteRequested = (e: MouseEvent, idx: number) => {
e.stopPropagation();
deleteIndex = idx;
};
</script>
<div class="preset-browser overflow-y-auto border d-flex p-2 gap-1 flex-wrap {className}">
<!-- fixme: key -->
{#each presets as item, idx (item)}
<div
role="button"
class="btn p-0 card-wrapper d-flex justify-content-center align-items-center"
tabindex="0"
onkeydown={() => onItemSelected(idx)}
onclick={() => onItemSelected(idx)}>
<div
class="card print-start-{item.printDirection} d-flex justify-content-center align-items-center"
style="width: {scaleDimensions(item).width}%; height: {scaleDimensions(item).height}%;">
<div class="remove d-flex">
{#if deleteIndex === idx}
<button class="remove btn text-danger-emphasis" onclick={(e) => deleteConfirmed(e, idx)}>
<MdIcon icon="delete" />
</button>
<button class="remove btn text-success" onclick={(e) => deleteRejected(e)}>
<MdIcon icon="close" />
</button>
{:else}
<button class="remove btn text-danger-emphasis" onclick={(e) => deleteRequested(e, idx)}>
<MdIcon icon="delete" />
</button>
{/if}
</div>
<span class="label p-1">
{#if item.title}
{item.title}
{:else}
{item.width}x{item.height}{#if item.unit === "mm"}{$tr("params.label.mm")}{:else if item.unit === "px"}{$tr(
"params.label.px",
)}{/if}
{/if}
</span>
</div>
</div>
{/each}
</div>
<style>
.preset-browser {
max-height: 200px;
max-width: 100%;
min-height: 96px;
}
.card-wrapper {
width: 96px;
height: 96px;
}
.card {
background-color: white;
position: relative;
}
.card > .remove {
position: absolute;
top: 0;
right: 0;
}
.card > .remove > button {
padding: 0;
line-height: 100%;
}
.card > .label {
background-color: rgba(255, 255, 255, 0.8);
color: black;
border-radius: 8px;
}
.card.print-start-left {
border-left: 2px solid #ff4646;
}
.card.print-start-top {
border-top: 2px solid #ff4646;
}
</style>

View File

@@ -0,0 +1,509 @@
<script lang="ts">
import {
LabelPresetSchema,
type LabelPreset,
type LabelProps,
type LabelShape,
type LabelSplit,
type LabelUnit,
type MirrorType,
type TailPosition,
} from "$/types";
import LabelPresetsBrowser from "$/components/designer-controls/LabelPresetsBrowser.svelte";
import { printerMeta } from "$/stores";
import { tr } from "$/utils/i18n";
import { DEFAULT_LABEL_PRESETS } from "$/defaults";
import { onMount, tick } from "svelte";
import { LocalStoragePersistence } from "$/utils/persistence";
import type { PrintDirection } from "$/lib/fichero";
import MdIcon from "$/components/basic/MdIcon.svelte";
import { Toasts } from "$/utils/toasts";
import { FileUtils } from "$/utils/file_utils";
import { z } from "zod";
import DpiSelector from "$/components/designer-controls/DpiSelector.svelte";
interface Props {
labelProps: LabelProps;
onChange: (newProps: LabelProps) => void;
}
let { labelProps, onChange }: Props = $props();
const tailPositions: TailPosition[] = ["right", "bottom", "left", "top"];
const printDirections: PrintDirection[] = ["left", "top"];
const labelShapes: LabelShape[] = ["rect", "rounded_rect", "circle"];
const labelSplits: LabelSplit[] = ["none", "vertical", "horizontal"];
const mirrorTypes: MirrorType[] = ["none", "flip", "copy"];
let labelPresets = $state<LabelPreset[]>(DEFAULT_LABEL_PRESETS);
let title = $state<string | undefined>("");
let prevUnit: LabelUnit = "mm";
let unit = $state<LabelUnit>("mm");
let dpmm = $state<number>(8);
let width = $state<number>(0);
let height = $state<number>(0);
let printDirection = $state<PrintDirection>("left");
let shape = $state<LabelShape>("rect");
let split = $state<LabelSplit>("none");
let splitParts = $state<number>(2);
let tailLength = $state<number>(0);
let tailPos = $state<TailPosition>("right");
let mirror = $state<MirrorType>("none");
let error = $derived.by<string>(() => {
let error = "";
const headSize = labelProps.printDirection == "left" ? labelProps.size.height : labelProps.size.width;
if ($printerMeta !== undefined) {
if (headSize > $printerMeta.printheadPixels) {
error += $tr("params.label.warning.width") + " ";
error += `(${headSize} > ${$printerMeta.printheadPixels})`;
error += "\n";
}
if ($printerMeta.printDirection !== labelProps.printDirection) {
error += $tr("params.label.warning.direction") + " ";
if ($printerMeta.printDirection == "left") {
error += $tr("params.label.direction.left");
} else {
error += $tr("params.label.direction.top");
}
}
}
if (headSize % 8 !== 0) {
error += $tr("params.label.warning.div8");
}
return error;
});
const onApply = () => {
let newWidth = width;
let newHeight = height;
let newTailLength = tailLength;
// mm to px
if (unit === "mm") {
newWidth *= dpmm;
newHeight *= dpmm;
newTailLength *= dpmm;
}
// limit min size
newWidth = newWidth < dpmm ? dpmm : newWidth;
newHeight = newHeight < dpmm ? dpmm : newHeight;
// width must me multiple of 8
if (printDirection === "left") {
newHeight -= newHeight % 8;
} else {
newWidth -= newWidth % 8;
}
onChange({
printDirection: printDirection,
size: {
width: Math.floor(newWidth),
height: Math.floor(newHeight),
},
shape,
split,
splitParts,
tailPos,
tailLength: Math.floor(newTailLength),
mirror,
});
};
const onLabelPresetSelected = (index: number) => {
const preset = labelPresets[index];
if (preset !== undefined) {
dpmm = preset.dpmm;
prevUnit = preset.unit;
unit = preset.unit;
printDirection = preset.printDirection;
width = preset.width;
height = preset.height;
title = preset.title ?? "";
shape = preset.shape ?? "rect";
split = preset.split ?? "none";
splitParts = preset.splitParts ?? 2;
tailPos = preset.tailPos ?? "right";
tailLength = preset.tailLength ?? 0;
mirror = preset.mirror ?? "none";
}
onApply();
};
const onLabelPresetDelete = (idx: number) => {
const result = [...labelPresets];
result.splice(idx, 1);
labelPresets = result;
LocalStoragePersistence.saveLabelPresets(labelPresets);
};
const onLabelPresetAdd = () => {
const newPreset: LabelPreset = {
dpmm,
printDirection,
unit,
width,
height,
title,
shape,
split,
splitParts,
tailPos,
tailLength,
mirror,
};
const newPresets = [...labelPresets, newPreset];
try {
LocalStoragePersistence.saveLabelPresets(newPresets);
labelPresets = newPresets;
} catch (e) {
Toasts.zodErrors(e, "Presets save error:");
}
};
const onFlip = () => {
let widthTmp = width;
width = height;
height = widthTmp;
printDirection = printDirection === "top" ? "left" : "top";
};
const onUnitChange = () => {
if (prevUnit === "mm" && unit === "px") {
width = Math.floor(width * dpmm);
height = Math.floor(height * dpmm);
tailLength = Math.floor(tailLength * dpmm);
} else if (prevUnit === "px" && unit === "mm") {
width = Math.floor(width / dpmm);
height = Math.floor(height / dpmm);
tailLength = Math.floor(tailLength / dpmm);
}
prevUnit = unit;
};
const fillWithCurrentParams = () => {
prevUnit = "px";
width = labelProps.size.width;
height = labelProps.size.height;
printDirection = labelProps.printDirection;
shape = labelProps.shape ?? "rect";
split = labelProps.split ?? "none";
splitParts = labelProps.splitParts ?? 2;
tailPos = labelProps.tailPos ?? "right";
tailLength = labelProps.tailLength ?? 0;
mirror = labelProps.mirror ?? "none";
onUnitChange();
};
const onImportClicked = async () => {
const contents = await FileUtils.pickAndReadSingleTextFile("json");
const rawData = JSON.parse(contents);
if (!confirm($tr("params.label.warning.import"))) {
return;
}
try {
const presets = z.array(LabelPresetSchema).parse(rawData);
LocalStoragePersistence.saveLabelPresets(presets);
labelPresets = presets;
} catch (e) {
Toasts.zodErrors(e, "Presets load error:");
}
};
const onExportClicked = () => {
try {
FileUtils.saveLabelPresetsAsJson(labelPresets);
} catch (e) {
Toasts.zodErrors(e, "Presets save error:");
}
};
onMount(() => {
const defaultPreset: LabelPreset = DEFAULT_LABEL_PRESETS[0];
width = defaultPreset.width;
height = defaultPreset.height;
prevUnit = defaultPreset.unit;
unit = defaultPreset.unit;
printDirection = defaultPreset.printDirection;
shape = defaultPreset.shape ?? "rect";
split = defaultPreset.split ?? "none";
tailPos = defaultPreset.tailPos ?? "right";
tailLength = defaultPreset.tailLength ?? 0;
mirror = defaultPreset.mirror ?? "none";
try {
const savedPresets: LabelPreset[] | null = LocalStoragePersistence.loadLabelPresets();
if (savedPresets !== null) {
labelPresets = savedPresets;
}
} catch (e) {
Toasts.zodErrors(e, "Presets load error:");
}
tick().then(() => fillWithCurrentParams());
});
$effect(() => {
if (shape === "circle" && split !== "none") split = "none";
});
$effect(() => {
if (split === "none" || tailLength < 0) tailLength = 0;
});
$effect(() => {
if (mirror === "flip" && splitParts !== 2) mirror = "copy";
});
</script>
<div class="dropdown">
<button class="btn btn-sm btn-secondary" data-bs-toggle="dropdown" data-bs-auto-close="outside">
<MdIcon icon="settings" />
</button>
<div class="dropdown-menu">
<h6 class="dropdown-header">{$tr("params.label.menu_title")}</h6>
<div class="px-3">
<div class="p-1">
<button class="btn btn-sm btn-outline-secondary" onclick={onImportClicked}>
<MdIcon icon="data_object" />
{$tr("params.label.import")}
</button>
<button class="btn btn-sm btn-outline-secondary" onclick={onExportClicked}>
<MdIcon icon="data_object" />
{$tr("params.label.export")}
</button>
</div>
<div class="mb-3 {error ? 'cursor-help text-warning' : 'text-secondary'}" title={error}>
{$tr("params.label.current")}
{labelProps.size.width}x{labelProps.size.height}
{$tr("params.label.px")}
{#if labelProps.printDirection === "top"}
({$tr("params.label.direction")} {$tr("params.label.direction.top")})
{:else if labelProps.printDirection === "left"}
({$tr("params.label.direction")} {$tr("params.label.direction.left")})
{/if}
<button class="btn btn-sm" onclick={fillWithCurrentParams}><MdIcon icon="arrow_downward" /></button>
</div>
<LabelPresetsBrowser
class="mb-1"
presets={labelPresets}
onItemSelected={onLabelPresetSelected}
onItemDelete={onLabelPresetDelete} />
<div class="input-group flex-nowrap input-group-sm mb-2">
<span class="input-group-text">{$tr("params.label.size")}</span>
<input class="form-control" type="number" min="1" step={unit === "px" ? 8 : 1} bind:value={width} />
<button class="btn btn-sm btn-secondary" onclick={onFlip}><MdIcon icon="swap_horiz" /></button>
<input class="form-control" type="number" min="1" step={unit === "px" ? 8 : 1} bind:value={height} />
<select class="form-select" bind:value={unit} onchange={onUnitChange}>
<option value="mm"> {$tr("params.label.mm")}</option>
<option value="px"> {$tr("params.label.px")}</option>
</select>
</div>
{#if unit !== "px"}
<DpiSelector bind:value={dpmm} />
{/if}
<div class="input-group flex-nowrap input-group-sm print-dir-switch mb-2" role="group">
<span class="input-group-text w-100">{$tr("params.label.direction")}</span>
{#each printDirections as v (v)}
<input
type="radio"
class="btn-check"
name="print-dir"
id="print-dir-{v}"
autocomplete="off"
bind:group={printDirection}
value={v} />
<label class="btn btn-outline-secondary px-3" for="print-dir-{v}">
<div class="svg-icon"></div>
</label>
{/each}
</div>
<div class="input-group flex-nowrap input-group-sm label-shape-switch mb-2" role="group">
<span class="input-group-text w-100">{$tr("params.label.shape")}</span>
{#each labelShapes as v (v)}
<input
type="radio"
class="btn-check"
name="label-shape"
id="label-shape-{v}"
autocomplete="off"
bind:group={shape}
value={v} />
<label class="btn btn-outline-secondary px-3" for="label-shape-{v}">
<div class="svg-icon"></div>
</label>
{/each}
</div>
{#if shape !== "circle"}
<div class="input-group flex-nowrap input-group-sm label-split-switch mb-2" role="group">
<span class="input-group-text w-100">{$tr("params.label.split")}</span>
{#each labelSplits as v (v)}
<input
type="radio"
class="btn-check"
name="label-split"
id="label-split-{v}"
autocomplete="off"
bind:group={split}
value={v} />
<label class="btn btn-outline-secondary px-3" for="label-split-{v}">
<div class="svg-icon"></div>
</label>
{/each}
</div>
{#if split !== "none"}
<div class="input-group flex-nowrap input-group-sm mb-2">
<span class="input-group-text">{$tr("params.label.split.count")}</span>
<input class="form-control" type="number" min="1" bind:value={splitParts} />
</div>
{/if}
{/if}
{#if split !== "none"}
<div class="input-group flex-nowrap input-group-sm mirror-switch mb-2" role="group">
<span class="input-group-text w-100">{$tr("params.label.mirror")}</span>
{#each mirrorTypes as v (v)}
<input
type="radio"
class="btn-check"
name="mirror"
id="mirror-{v}"
autocomplete="off"
bind:group={mirror}
value={v} />
<label class="btn btn-outline-secondary px-3" for="mirror-{v}">
<div class="svg-icon"></div>
</label>
{/each}
</div>
<div class="input-group flex-nowrap input-group-sm tail-pos-switch mb-2" role="group">
<span class="input-group-text w-100">{$tr("params.label.tail.position")}</span>
{#each tailPositions as v (v)}
<input
type="radio"
class="btn-check"
name="tail-pos"
id="tail-{v}"
autocomplete="off"
bind:group={tailPos}
value={v} />
<label class="btn btn-outline-secondary px-3" for="tail-{v}">
<div class="svg-icon"></div>
</label>
{/each}
</div>
<div class="input-group flex-nowrap input-group-sm mb-2">
<span class="input-group-text">{$tr("params.label.tail.length")}</span>
<input class="form-control" type="number" min="1" bind:value={tailLength} />
<span class="input-group-text">
{#if unit === "mm"}{$tr("params.label.mm")}{/if}
{#if unit === "px"}{$tr("params.label.px")}{/if}
</span>
</div>
{/if}
<div class="input-group flex-nowrap input-group-sm mb-2">
<span class="input-group-text">{$tr("params.label.label_title")}</span>
<input class="form-control" type="text" bind:value={title} />
</div>
<div class="text-end">
<button class="btn btn-sm btn-secondary" onclick={onLabelPresetAdd}>
{$tr("params.label.save_template")}
</button>
<button class="btn btn-sm btn-primary" onclick={onApply}>{$tr("params.label.apply")}</button>
</div>
</div>
</div>
</div>
<style>
.dropdown-menu {
width: 100vw;
max-width: 450px;
}
.cursor-help {
cursor: help;
}
.svg-icon {
height: 1.5em;
width: 1.5em;
background-size: cover;
}
.tail-pos-switch .svg-icon {
background-image: url("../assets/tail-pos.svg");
}
.tail-pos-switch label[for="tail-bottom"] .svg-icon {
transform: rotate(90deg);
}
.tail-pos-switch label[for="tail-bottom"] .svg-icon {
transform: rotate(90deg);
}
.tail-pos-switch label[for="tail-left"] .svg-icon {
transform: rotate(180deg);
}
.tail-pos-switch label[for="tail-top"] .svg-icon {
transform: rotate(270deg);
}
.print-dir-switch .svg-icon {
background-image: url("../assets/print-dir.svg");
}
.print-dir-switch label[for="print-dir-top"] .svg-icon {
transform: rotate(90deg);
}
.label-shape-switch label[for="label-shape-rect"] .svg-icon {
background-image: url("../assets/shape-rect.svg");
}
.label-shape-switch label[for="label-shape-rounded_rect"] .svg-icon {
background-image: url("../assets/shape-rrect.svg");
}
.label-shape-switch label[for="label-shape-circle"] .svg-icon {
background-image: url("../assets/shape-circle.svg");
}
.label-split-switch label[for="label-split-none"] .svg-icon {
background-image: url("../assets/shape-rrect.svg");
}
.label-split-switch label[for="label-split-vertical"] .svg-icon {
background-image: url("../assets/split-vertical.svg");
transform: rotate(90deg);
}
.label-split-switch label[for="label-split-horizontal"] .svg-icon {
background-image: url("../assets/split-vertical.svg");
}
.mirror-switch label[for="mirror-none"] .svg-icon {
background-image: url("../assets/mirror-none.svg");
}
.mirror-switch label[for="mirror-copy"] .svg-icon {
background-image: url("../assets/mirror-copy.svg");
}
.mirror-switch label[for="mirror-flip"] .svg-icon {
background-image: url("../assets/mirror-flip.svg");
}
</style>

View File

@@ -0,0 +1,65 @@
<script lang="ts">
import { type LabelProps, type OjectType } from "$/types";
import { tr } from "$/utils/i18n";
import MdIcon from "$/components/basic/MdIcon.svelte";
import ZplImportButton from "$/components/designer-controls/ZplImportButton.svelte";
interface Props {
onSubmit: (i: OjectType) => void;
labelProps: LabelProps;
zplImageReady: (img: Blob) => void;
}
let { onSubmit, labelProps, zplImageReady }: Props = $props();
</script>
<div class="dropdown">
<button class="btn btn-sm btn-secondary" data-bs-toggle="dropdown" data-bs-auto-close="outside">
<MdIcon icon="format_shapes" />
<MdIcon icon="add" />
</button>
<div class="dropdown-menu">
<h6 class="dropdown-header">{$tr("editor.objectpicker.title")}</h6>
<div class="p-3">
<button class="btn me-1" onclick={() => onSubmit("text")}>
<MdIcon icon="title" />
{$tr("editor.objectpicker.text")}
</button>
<button class="btn me-1" onclick={() => onSubmit("line")}>
<MdIcon icon="remove" />
{$tr("editor.objectpicker.line")}
</button>
<button class="btn me-1" onclick={() => onSubmit("rectangle")}>
<MdIcon icon="crop_square" />
{$tr("editor.objectpicker.rectangle")}
</button>
<button class="btn me-1" onclick={() => onSubmit("circle")}>
<MdIcon icon="radio_button_unchecked" />
{$tr("editor.objectpicker.circle")}
</button>
<button class="btn me-1" onclick={() => onSubmit("image")}>
<MdIcon icon="image" />
{$tr("editor.objectpicker.image")}
</button>
<button class="btn me-1" onclick={() => onSubmit("qrcode")}>
<MdIcon icon="qr_code_2" />
{$tr("editor.objectpicker.qrcode")}
</button>
<button class="btn me-1" onclick={() => onSubmit("barcode")}>
<MdIcon icon="view_week" />
{$tr("editor.objectpicker.barcode")}
</button>
<ZplImportButton {labelProps} onImageReady={zplImageReady} text={$tr("editor.import.zpl")} />
</div>
</div>
</div>
<style>
.dropdown-menu {
width: 100vw;
max-width: 450px;
}
</style>

View File

@@ -0,0 +1,86 @@
<script lang="ts">
import MdIcon from "$/components/basic/MdIcon.svelte";
import { tr } from "$/utils/i18n";
import * as fabric from "fabric";
import { onDestroy } from "svelte";
import QRCode from "$/fabric-object/qrcode";
import Barcode from "$/fabric-object/barcode";
interface Props {
selectedObject: fabric.FabricObject;
}
let { selectedObject }: Props = $props();
let prevObject: fabric.FabricObject | undefined;
let x = $state<number>();
let y = $state<number>();
let width = $state<number>();
let height = $state<number>();
const objectDimensionsChanged = () => {
const pos = selectedObject.getPointByOrigin("left", "top");
x = pos.x;
y = pos.y;
width = selectedObject.width;
height = selectedObject.height;
};
const objectChanged = (newObject: fabric.FabricObject) => {
if (prevObject !== undefined) {
prevObject.off("modified", objectDimensionsChanged);
}
newObject.on("modified", objectDimensionsChanged);
objectDimensionsChanged();
prevObject = newObject;
};
const updateObject = () => {
const newPos = new fabric.Point(Math.round(x!), Math.round(y!));
selectedObject.setPositionByOrigin(newPos, "left", "top");
selectedObject.set({
width: Math.round(Math.max(width!, 1)),
height: Math.round(Math.max(height!, 1)),
});
selectedObject.setCoords();
selectedObject.canvas?.requestRenderAll();
};
onDestroy(() => selectedObject.off("modified", objectDimensionsChanged));
$effect(() => {
objectChanged(selectedObject);
});
</script>
<div class="dropdown">
<button
class="btn btn-sm btn-secondary dropdown-toggle"
type="button"
data-bs-toggle="dropdown"
title={$tr("params.generic.position")}>
<MdIcon icon="control_camera" />
</button>
<div class="dropdown-menu arrangement p-2">
<div class="input-group flex-nowrap input-group-sm mb-2">
<span class="input-group-text">x</span>
<input class="form-control" type="number" bind:value={x} onchange={updateObject} />
</div>
<div class="input-group flex-nowrap input-group-sm mb-2">
<span class="input-group-text">y</span>
<input class="form-control" type="number" bind:value={y} onchange={updateObject} />
</div>
{#if !(selectedObject instanceof fabric.FabricText || selectedObject instanceof fabric.FabricImage || selectedObject instanceof QRCode || selectedObject instanceof Barcode)}
<div class="input-group flex-nowrap input-group-sm mb-2">
<input class="form-control" type="number" min="1" bind:value={width} onchange={updateObject} />
<span class="input-group-text">x</span>
<input class="form-control" type="number" min="1" bind:value={height} onchange={updateObject} />
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,87 @@
<script lang="ts">
import { QRCode } from "$/fabric-object/qrcode";
import { tr } from "$/utils/i18n";
import MdIcon from "$/components/basic/MdIcon.svelte";
interface Props {
selectedQRCode: QRCode;
editRevision: number;
valueUpdated: () => void;
}
let { selectedQRCode, editRevision, valueUpdated }: Props = $props();
</script>
<input type="hidden" value={editRevision}>
<div class="input-group input-group-sm flex-nowrap">
<span class="input-group-text" title={$tr("params.qrcode.ecl")}>
<MdIcon icon="auto_fix_high" />
</span>
<select
class="form-select"
value={selectedQRCode.ecl}
onchange={(e) => {
selectedQRCode?.set("ecl", e.currentTarget.value);
valueUpdated();
}}>
<option value="L">Level L</option>
<option value="M">Level M</option>
<option value="Q">Level Q</option>
<option value="H">Level H</option>
</select>
</div>
<div class="input-group input-group-sm flex-nowrap">
<span class="input-group-text" title={$tr("params.qrcode.mode")}>
<MdIcon icon="abc" />
</span>
<select
class="form-select"
value={selectedQRCode.mode}
onchange={(e) => {
selectedQRCode?.set("mode", e.currentTarget.value);
valueUpdated();
}}>
<option value="Byte">Byte</option>
<option value="Numeric">Numeric</option>
<option value="Alphanumeric">Alphanumeric</option>
<option value="Kanji">Kanji</option>
</select>
</div>
<div class="input-group input-group-sm flex-nowrap">
<span class="input-group-text" title={$tr("params.qrcode.version")}>
<MdIcon icon="123" />
</span>
<select
class="form-select"
value={selectedQRCode.qrVersion}
onchange={(e) => {
selectedQRCode?.set("qrVersion", parseInt(e.currentTarget.value));
valueUpdated();
}}>
<option value={0}>Auto</option>
{#each { length: 40 }, i (i)}
<option value={i + 1}>{i + 1}</option>
{/each}
</select>
</div>
<textarea
class="qrcode-content form-control"
value={selectedQRCode.text}
oninput={(e) => {
selectedQRCode?.set("text", e.currentTarget.value);
valueUpdated();
}}></textarea>
<style>
.input-group {
width: fit-content;
}
.qrcode-content {
height: 100px;
}
</style>

View File

@@ -0,0 +1,146 @@
<script lang="ts">
import type { ExportedLabelTemplate, LabelProps } from "$/types";
import { tr } from "$/utils/i18n";
import MdIcon from "$/components/basic/MdIcon.svelte";
interface Props {
onItemClicked: (index: number) => void;
onItemDelete: (index: number) => void;
onItemExport: (index: number) => void;
labels: ExportedLabelTemplate[];
selectedIndex?: number;
class?: string;
}
let { onItemClicked, onItemDelete, onItemExport, labels, selectedIndex = -1, class: className }: Props = $props();
let deleteIndex = $state<number>(-1);
const scaleDimensions = (preset: LabelProps): { width: number; height: number } => {
const scaleFactor = Math.min(100 / preset.size.width, 100 / preset.size.height);
return {
width: Math.round(preset.size.width * scaleFactor),
height: Math.round(preset.size.height * scaleFactor),
};
};
const deleteConfirmed = (e: MouseEvent, idx: number) => {
e.stopPropagation();
deleteIndex = -1;
onItemDelete(idx);
};
const deleteRejected = (e: MouseEvent) => {
e.stopPropagation();
deleteIndex = -1;
};
const deleteRequested = (e: MouseEvent, idx: number) => {
e.stopPropagation();
deleteIndex = idx;
};
const exportRequested = (e: MouseEvent, idx: number) => {
e.stopPropagation();
onItemExport(idx);
};
</script>
<div class="labels-browser overflow-y-auto border d-flex p-2 gap-1 flex-wrap {className}">
{#each labels as item, idx (item.id ?? item.timestamp)}
<div
tabindex="0"
class="btn p-0 card-wrapper d-flex justify-content-center align-items-center {selectedIndex === idx
? 'border-primary'
: ''}"
onkeydown={() => onItemClicked(idx)}
onclick={() => onItemClicked(idx)}
role="button">
<div
class="card print-start-{item.label.printDirection} d-flex justify-content-center align-items-center"
style="width: {scaleDimensions(item.label).width}%; height: {scaleDimensions(item.label).height}%;">
<div class="buttons d-flex">
<button
class="btn text-primary-emphasis"
onclick={(e) => exportRequested(e, idx)}
title={$tr("params.saved_labels.save.json")}>
<MdIcon icon="download" />
</button>
{#if deleteIndex === idx}
<button class="remove btn text-danger-emphasis" onclick={(e) => deleteConfirmed(e, idx)}>
<MdIcon icon="delete" />
</button>
<button class="remove btn text-success" onclick={(e) => deleteRejected(e)}>
<MdIcon icon="close" />
</button>
{:else}
<button class="remove btn text-danger-emphasis" onclick={(e) => deleteRequested(e, idx)}>
<MdIcon icon="delete" />
</button>
{/if}
</div>
{#if item.thumbnailBase64}
<img class="thumbnail" src={item.thumbnailBase64} alt="thumbnail" />
{/if}
{#if item.title}
<span class="label p-1">
{item.title}
</span>
{/if}
</div>
</div>
{/each}
</div>
<style>
.labels-browser {
max-height: 200px;
max-width: 100%;
min-height: 96px;
}
.card-wrapper {
width: 96px;
height: 96px;
}
.card {
background-color: white;
position: relative;
}
.card > .buttons {
position: absolute;
top: 0;
right: 0;
z-index: 2;
}
.card > .buttons > button {
padding: 0;
line-height: 100%;
}
.card > .label {
background-color: rgba(255, 255, 255, 0.8);
color: black;
border-radius: 8px;
z-index: 1;
}
.card.print-start-left {
border-left: 2px solid #ff4646;
}
.card.print-start-top {
border-top: 2px solid #ff4646;
}
.card .thumbnail {
width: 100%;
height: 100%;
position: absolute;
}
</style>

View File

@@ -0,0 +1,304 @@
<script lang="ts">
import { tr } from "$/utils/i18n";
import { onMount } from "svelte";
import MdIcon from "$/components/basic/MdIcon.svelte";
import SavedLabelsBrowser from "$/components/designer-controls/SavedLabelsBrowser.svelte";
import { ExportedLabelTemplateSchema, type ExportedLabelTemplate } from "$/types";
import { LocalStoragePersistence } from "$/utils/persistence";
import { Toasts } from "$/utils/toasts";
import Dropdown from "bootstrap/js/dist/dropdown";
import { FileUtils } from "$/utils/file_utils";
import * as fabric from "fabric";
interface Props {
onRequestLabelTemplate: () => ExportedLabelTemplate;
onLoadRequested: (label: ExportedLabelTemplate) => void;
canvas: fabric.Canvas;
csvEnabled: boolean;
}
let { onRequestLabelTemplate, onLoadRequested, canvas, csvEnabled }: Props = $props();
let dropdownRef: HTMLDivElement;
let savedLabels = $state<ExportedLabelTemplate[]>([]);
let selectedIndex = $state<number>(-1);
let title = $state<string>("");
let usedSpace = $state<number>(0);
let customDefaultTemplate = $state<boolean>(LocalStoragePersistence.hasCustomDefaultTemplate());
const calcUsedSpace = () => {
usedSpace = LocalStoragePersistence.usedSpace();
};
const onLabelSelected = (index: number) => {
selectedIndex = index;
title = savedLabels[index]?.title ?? "";
};
const onLabelExport = (idx: number) => {
try {
FileUtils.saveLabelAsJson(savedLabels[idx]);
} catch (e) {
Toasts.zodErrors(e, "Canvas save error:");
}
};
const onLabelDelete = (idx: number) => {
selectedIndex = -1;
const result = [...savedLabels];
result.splice(idx, 1);
LocalStoragePersistence.saveLabels(result);
savedLabels = result;
title = "";
calcUsedSpace();
};
const saveLabels = (labels: ExportedLabelTemplate[]) => {
const { zodErrors, otherErrors } = LocalStoragePersistence.saveLabels(labels);
zodErrors.forEach((e) => Toasts.zodErrors(e, "Label save error"));
otherErrors.forEach((e) => Toasts.error(e));
if (zodErrors.length === 0 && otherErrors.length === 0) {
savedLabels = labels;
}
calcUsedSpace();
};
const onSaveReplaceClicked = () => {
if (selectedIndex === -1) {
return;
}
if (!confirm($tr("editor.warning.save"))) {
return;
}
const label = onRequestLabelTemplate();
label.title = title;
const result = [...savedLabels];
result[selectedIndex] = label;
saveLabels(result);
};
const onMakeDefaultClicked = () => {
const label = onRequestLabelTemplate();
label.title = title;
label.thumbnailBase64 = undefined;
LocalStoragePersistence.saveDefaultTemplate(label);
customDefaultTemplate = true;
calcUsedSpace();
};
const onRemoveDefaultClicked = () => {
LocalStoragePersistence.saveDefaultTemplate(undefined);
customDefaultTemplate = false;
calcUsedSpace();
};
const onSaveClicked = () => {
const label = onRequestLabelTemplate();
label.title = title;
const result = [...savedLabels, label];
saveLabels(result);
};
const onLoadClicked = () => {
if (selectedIndex === -1) {
return;
}
const label = savedLabels[selectedIndex];
let message = $tr("editor.warning.load");
if (label.csv) {
message += "\n" + $tr("editor.warning.load.csv");
}
if (!confirm(message)) {
return;
}
onLoadRequested(label);
new Dropdown(dropdownRef).hide();
};
const onImportClicked = async () => {
const contents = await FileUtils.pickAndReadSingleTextFile("json");
const rawData = JSON.parse(contents);
try {
const label = ExportedLabelTemplateSchema.parse(rawData);
let message = $tr("editor.warning.load");
if (label.csv) {
message += "\n" + $tr("editor.warning.load.csv");
}
if (!confirm(message)) {
return;
}
onLoadRequested(label);
if (label.title) {
title = label.title;
}
new Dropdown(dropdownRef).hide();
} catch (e) {
Toasts.zodErrors(e, "Canvas load error:");
}
};
const onExportClicked = () => {
try {
const label = onRequestLabelTemplate();
if (title) {
label.title = title.replaceAll(/[\\/:*?"<>|]/g, "_");
}
FileUtils.saveLabelAsJson(label);
} catch (e) {
Toasts.zodErrors(e, "Canvas save error:");
}
};
const onExportPngClicked = () => {
try {
FileUtils.saveCanvasAsPng(canvas);
} catch (e) {
Toasts.zodErrors(e, "Canvas save error:");
}
};
const onExportUrlClicked = async () => {
try {
const label = onRequestLabelTemplate();
const url = await FileUtils.makeLabelUrl(label);
if (url.length > 2000 && !confirm($tr("params.saved_labels.save.url.warn"))) {
return;
}
navigator.clipboard.writeText(url);
Toasts.message($tr("params.saved_labels.save.url.copied"));
} catch (e) {
Toasts.error(e);
}
};
onMount(() => {
savedLabels = LocalStoragePersistence.loadLabels();
calcUsedSpace();
});
</script>
<div class="dropdown">
<button class="btn btn-sm btn-secondary" data-bs-toggle="dropdown" data-bs-auto-close="outside">
<MdIcon icon="sd_storage" />
</button>
<div class="saved-labels dropdown-menu" bind:this={dropdownRef}>
<h6 class="dropdown-header text-wrap">
{$tr("params.saved_labels.menu_title")} - {usedSpace}
{$tr("params.saved_labels.kb_used")}
{#if csvEnabled}
<div class="pt-3 text-warning">
{$tr("params.saved_labels.save.withcsv")}
</div>
{/if}
</h6>
<div class="px-3">
<div class="p-1">
<button class="btn btn-sm btn-outline-secondary" onclick={onImportClicked}>
<MdIcon icon="data_object" />
{$tr("params.saved_labels.load.json")}
</button>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary" onclick={onExportClicked}>
<MdIcon icon="data_object" />
{$tr("params.saved_labels.save.json")}
</button>
<button
type="button"
aria-label="dropdown"
class="btn btn-outline-secondary dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown">
</button>
<ul class="dropdown-menu">
<li>
<button class="dropdown-item" onclick={onExportPngClicked}>PNG</button>
</li>
<li>
<button class="dropdown-item" onclick={onExportUrlClicked}
>{$tr("params.saved_labels.save.url")}</button>
</li>
</ul>
</div>
</div>
<SavedLabelsBrowser
class="mb-1"
{selectedIndex}
labels={savedLabels}
onItemClicked={onLabelSelected}
onItemDelete={onLabelDelete}
onItemExport={onLabelExport} />
<div class="input-group flex-nowrap input-group-sm mb-3">
<span class="input-group-text">{$tr("params.saved_labels.label_title")}</span>
<input
class="form-control"
type="text"
placeholder={$tr("params.saved_labels.label_title.placeholder")}
bind:value={title} />
</div>
<div class="d-flex gap-1 flex-wrap justify-content-end">
<div class="btn-group btn-group-sm make-default">
<button class="btn text-secondary" onclick={onMakeDefaultClicked}>
{$tr("params.saved_labels.make_default")}
</button>
{#if customDefaultTemplate}
<button class="btn text-secondary" onclick={onRemoveDefaultClicked}>
<MdIcon icon="close" />
</button>
{/if}
</div>
<button class="btn btn-sm btn-secondary" onclick={onSaveClicked}>
<MdIcon icon="save" />
{$tr("params.saved_labels.save.browser")}
</button>
{#if selectedIndex !== -1}
<button class="btn btn-sm btn-secondary" onclick={onSaveReplaceClicked}>
<MdIcon icon="edit_note" />
{$tr("params.saved_labels.save.browser.replace")}
</button>
<button class="btn btn-sm btn-primary" onclick={onLoadClicked}>
<MdIcon icon="folder" />
{$tr("params.saved_labels.load.browser")}
</button>
{/if}
</div>
</div>
</div>
</div>
<style>
.saved-labels.dropdown-menu {
width: 100vw;
max-width: 450px;
}
.make-default {
margin-right: auto;
}
</style>

View File

@@ -0,0 +1,287 @@
<script lang="ts">
import * as fabric from "fabric";
import { tr } from "$/utils/i18n";
import MdIcon from "$/components/basic/MdIcon.svelte";
import FontFamilyPicker from "$/components/designer-controls/FontFamilyPicker.svelte";
import { TextboxExt } from "$/fabric-object/textbox-ext";
interface Props {
selectedText: fabric.IText;
editRevision: number;
valueUpdated: () => void;
}
let { selectedText, editRevision, valueUpdated }: Props = $props();
let sizeMin: number = 1;
let sizeMax: number = 999;
const setXAlign = (align: fabric.TOriginX) => {
selectedText.set({ textAlign: align });
valueUpdated();
};
const setYAlign = (align: fabric.TOriginY) => {
// change object origin, but keep position
const pos = selectedText.getPointByOrigin("left", "top");
selectedText.set({ originY: align });
selectedText.setPositionByOrigin(pos, "left", "top");
valueUpdated();
};
const toggleBold = () => {
if (selectedText.fontWeight === "bold") {
selectedText.fontWeight = "normal";
} else {
selectedText.fontWeight = "bold";
}
valueUpdated();
};
const toggleItalic = () => {
if (selectedText.fontStyle === "italic") {
selectedText.fontStyle = "normal";
} else {
selectedText.fontStyle = "italic";
}
valueUpdated();
};
const toggleFontAutoSize = () => {
if (selectedText instanceof TextboxExt) {
selectedText.set({ fontAutoSize: !selectedText.fontAutoSize });
}
valueUpdated();
};
const updateFontFamily = (v: string) => {
selectedText.set({ fontFamily: v });
valueUpdated();
};
const fontSizeUp = () => {
let s = selectedText.fontSize;
selectedText.set({ fontSize: Math.min(s > 40 ? Math.round(s * 1.1) : s + 2, sizeMax) });
valueUpdated();
};
const fontSizeDown = () => {
let s = selectedText.fontSize;
selectedText.set({ fontSize: Math.max(s > 40 ? Math.round(s * 0.9) : s - 2, sizeMin) });
valueUpdated();
};
const lineHeightChange = (v: number) => {
v = isNaN(v) ? 1 : v;
selectedText.set({ lineHeight: v });
valueUpdated();
};
const fontSizeChange = (v: number) => {
v = isNaN(v) ? 1 : Math.min(Math.max(v, sizeMin), sizeMax);
selectedText.set({ fontSize: v });
valueUpdated();
};
const fillChanged = (value: string) => {
selectedText.set({ fill: value });
valueUpdated();
};
const splitChanged = (value: string) => {
if (selectedText instanceof fabric.Textbox) {
selectedText.set({ splitByGrapheme: value === "grapheme" });
valueUpdated();
}
};
const backgroundColorChanged = (value: string) => {
selectedText.set({ backgroundColor: value });
valueUpdated();
};
const editInPopup = () => {
const text = prompt($tr("params.text.edit.title"), selectedText.text);
if (text !== null) {
selectedText.set({ text });
selectedText.isEditing = false;
valueUpdated();
}
};
</script>
<!-- Fix component not updating when selectedText changes. I didn't find a better way to do this. -->
<input type="hidden" value={editRevision}>
<button
title={$tr("params.text.align.left")}
class="btn btn-sm {selectedText.textAlign === 'left' ? 'btn-secondary' : ''}"
onclick={() => setXAlign("left")}><MdIcon icon="format_align_left" /></button>
<button
title={$tr("params.text.align.center")}
class="btn btn-sm {selectedText.textAlign === 'center' ? 'btn-secondary' : ''}"
onclick={() => setXAlign("center")}><MdIcon icon="format_align_center" /></button>
<button
title={$tr("params.text.align.right")}
class="btn btn-sm {selectedText.textAlign === 'right' ? 'btn-secondary' : ''}"
onclick={() => setXAlign("right")}><MdIcon icon="format_align_right" /></button>
<div class="dropdown">
<button class="btn btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown" title={$tr("params.text.vorigin")}>
{#if selectedText.originY === "top"}
<MdIcon icon="vertical_align_top" />
{:else if selectedText.originY === "center"}
<MdIcon icon="vertical_align_center" />
{:else if selectedText.originY === "bottom"}
<MdIcon icon="vertical_align_bottom" />
{/if}
</button>
<div class="dropdown-menu p-2">
<button
class="btn btn-sm {selectedText.originY === 'top' ? 'btn-secondary' : ''}"
onclick={() => setYAlign("top")}
title={$tr("params.text.vorigin.top")}>
<MdIcon icon="vertical_align_top" />
</button>
<button
class="btn btn-sm {selectedText.originY === 'center' ? 'btn-secondary' : ''}"
onclick={() => setYAlign("center")}
title={$tr("params.text.vorigin.center")}>
<MdIcon icon="vertical_align_center" />
</button>
<button
class="btn btn-sm {selectedText.originY === 'bottom' ? 'btn-secondary' : ''}"
onclick={() => setYAlign("bottom")}
title={$tr("params.text.vorigin.bottom")}>
<MdIcon icon="vertical_align_bottom" />
</button>
</div>
</div>
<button
class="btn btn-sm {selectedText.fontWeight === 'bold' ? 'btn-secondary' : ''}"
title={$tr("params.text.bold")}
onclick={toggleBold}>
<MdIcon icon="format_bold" />
</button>
<button
class="btn btn-sm {selectedText.fontStyle === 'italic' ? 'btn-secondary' : ''}"
title={$tr("params.text.italic")}
onclick={toggleItalic}>
<MdIcon icon="format_italic" />
</button>
<div class="dropdown">
<button class="btn btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown" title={$tr("params.color")}>
<MdIcon icon="format_color_fill" />
</button>
<div class="dropdown-menu arrangement p-2">
<div class="input-group input-group-sm flex-nowrap color pb-2">
<span class="input-group-text">
<MdIcon icon="format_color_text" />
</span>
<select class="form-select" value={selectedText.fill} onchange={(e) => fillChanged(e.currentTarget.value)}>
<option value="white">{$tr("params.color.white")}</option>
<option value="black">{$tr("params.color.black")}</option>
</select>
</div>
<div class="input-group input-group-sm flex-nowrap color pb-2">
<span class="input-group-text">
<MdIcon icon="format_color_fill" />
</span>
<select
class="form-select"
value={selectedText.backgroundColor || "transparent"}
onchange={(e) => backgroundColorChanged(e.currentTarget.value)}>
<option value="white">{$tr("params.color.white")}</option>
<option value="black">{$tr("params.color.black")}</option>
<option value="transparent">{$tr("params.color.transparent")}</option>
</select>
</div>
</div>
</div>
{#if selectedText instanceof fabric.Textbox}
<div class="dropdown">
<button class="btn btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown" title={$tr("params.params.text.split")}>
<MdIcon icon="wrap_text" />
</button>
<div class="dropdown-menu arrangement p-2">
<div class="input-group input-group-sm flex-nowrap split pb-2">
<select class="form-select" value={selectedText.splitByGrapheme ? "grapheme" : "space"} onchange={(e) => splitChanged(e.currentTarget.value)}>
<option value="space">{$tr("params.params.text.split.spaces")}</option>
<option value="grapheme">{$tr("params.params.text.split.grapheme")}</option>
</select>
</div>
</div>
</div>
{/if}
{#if selectedText instanceof TextboxExt}
<!-- fixme: Custom property not auto-rendered for some reason -->
<button
class="btn btn-sm {selectedText.fontAutoSize ? 'btn-secondary' : ''}"
title={$tr("params.text.autosize")}
data-ver={editRevision}
onclick={toggleFontAutoSize}>
<MdIcon icon="expand" class="r-90" />
</button>
{/if}
<div class="input-group flex-nowrap input-group-sm font-size">
<span class="input-group-text" title={$tr("params.text.font_size")}><MdIcon icon="format_size" /></span>
<input
type="number"
min={sizeMin}
max={sizeMax}
step="2"
class="form-control"
value={selectedText.fontSize}
oninput={(e) => fontSizeChange(e.currentTarget.valueAsNumber)} />
<button class="btn btn-secondary" title={$tr("params.text.font_size.up")} onclick={fontSizeUp}>
<MdIcon icon="text_increase" />
</button>
<button class="btn btn-secondary" title={$tr("params.text.font_size.down")} onclick={fontSizeDown}>
<MdIcon icon="text_decrease" />
</button>
</div>
<div class="input-group flex-nowrap input-group-sm">
<span class="input-group-text" title={$tr("params.text.line_height")}>
<MdIcon icon="density_medium" />
</span>
<input
type="number"
min="0.1"
step="0.1"
max="10"
class="form-control"
value={selectedText.lineHeight}
oninput={(e) => lineHeightChange(e.currentTarget.valueAsNumber)} />
</div>
<FontFamilyPicker {editRevision} value={selectedText.fontFamily} valueUpdated={updateFontFamily} />
<button class="btn btn-sm btn-secondary" onclick={editInPopup} title={$tr("params.text.edit")}>
<MdIcon icon="edit" />
</button>
<style>
.input-group {
width: 7em;
}
.font-size {
width: 12em;
}
.input-group.color {
width: 12em;
}
.input-group.split {
width: 14em;
}
</style>

View File

@@ -0,0 +1,55 @@
<script lang="ts">
import * as fabric from "fabric";
import { tr } from "$/utils/i18n";
import QRCode from "$/fabric-object/qrcode";
import Barcode from "$/fabric-object/barcode";
import MdIcon from "$/components/basic/MdIcon.svelte";
interface Props {
selectedObject: fabric.FabricObject;
valueUpdated: () => void;
}
let { selectedObject, valueUpdated }: Props = $props();
const insertDateTime = (format?: string) => {
let value = "{dt}";
if (format) {
value = `{dt|${format}}`;
}
if (selectedObject instanceof fabric.IText) {
selectedObject.exitEditing();
selectedObject.set({ text: `${selectedObject.text}${value}` });
} else if (selectedObject instanceof QRCode) {
selectedObject.set({ text: `${selectedObject.text}${value}` });
} else if (selectedObject instanceof Barcode) {
selectedObject.set({ text: `${selectedObject.text}${value}` });
}
valueUpdated();
};
</script>
<div class="btn-group btn-group-sm" role="group" title={$tr("params.variables.insert")}>
<button class="btn btn-sm btn-secondary dropdown-toggle" data-bs-toggle="dropdown" data-bs-auto-close="outside">
<MdIcon icon="data_object" />
</button>
<div class="dropdown-menu px-2">
<div class="d-flex gap-1 flex-wrap">
<button class="btn btn-secondary btn-sm" onclick={() => insertDateTime()}>
<MdIcon icon="calendar_today" />
{$tr("params.variables.insert.datetime")}
</button>
<button class="btn btn-secondary btn-sm" onclick={() => insertDateTime("YYYY-MM-DD")}>
<MdIcon icon="calendar_today" />
{$tr("params.variables.insert.date")}
</button>
<button class="btn btn-secondary btn-sm" onclick={() => insertDateTime("HH:mm:ss")}>
<MdIcon icon="schedule" />
{$tr("params.variables.insert.time")}
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,88 @@
<script lang="ts">
import { tr } from "$/utils/i18n";
import MdIcon from "$/components/basic/MdIcon.svelte";
import * as fabric from "fabric";
interface Props {
selectedObject: fabric.FabricObject;
editRevision: number;
valueUpdated: () => void;
}
let { selectedObject, editRevision, valueUpdated }: Props = $props();
const roundRadiusChanged = (value: number) => {
const rect = selectedObject as fabric.Rect;
rect.set({
rx: value,
ry: value,
});
valueUpdated();
};
const strokeWidthChanged = (value: number) => {
selectedObject.set({ strokeWidth: value });
valueUpdated();
};
const fillChanged = (value: string) => {
selectedObject.set({ fill: value });
valueUpdated();
};
</script>
<input type="hidden" value={editRevision}>
{#if selectedObject instanceof fabric.Rect}
<div class="input-group flex-nowrap input-group-sm">
<span class="input-group-text" title={$tr("params.vector.round_radius")}>
<MdIcon icon="rounded_corner" />
</span>
<input
type="number"
min="0"
max={Math.min(selectedObject.width, selectedObject.height) / 2}
class="form-control"
value={selectedObject.rx}
oninput={(e) => roundRadiusChanged(e.currentTarget.valueAsNumber)} />
</div>
{/if}
{#if selectedObject instanceof fabric.Rect || selectedObject instanceof fabric.Circle || selectedObject instanceof fabric.Line}
<div class="input-group flex-nowrap input-group-sm">
<span class="input-group-text" title={$tr("params.vector.stroke_width")}>
<MdIcon icon="line_weight" />
</span>
<input
type="number"
min="1"
class="form-control"
value={selectedObject.strokeWidth}
oninput={(e) => strokeWidthChanged(e.currentTarget.valueAsNumber)} />
</div>
{/if}
{#if selectedObject instanceof fabric.Rect || selectedObject instanceof fabric.Circle}
<div class="input-group input-group-sm flex-nowrap fill">
<span class="input-group-text" title={$tr("params.vector.fill")}>
<MdIcon icon="format_color_fill" />
</span>
<select
class="form-select"
value={selectedObject.fill}
onchange={(e) => fillChanged(e.currentTarget.value)}>
<option value="transparent">{$tr("params.color.transparent")}</option>
<option value="white">{$tr("params.color.white")}</option>
<option value="black">{$tr("params.color.black")}</option>
</select>
</div>
{/if}
<style>
.input-group {
width: 7em;
}
.input-group.fill {
width: 12em;
}
</style>

View File

@@ -0,0 +1,60 @@
<script lang="ts">
import type { LabelProps } from "$/types";
import { FileUtils } from "$/utils/file_utils";
import MdIcon from "$/components/basic/MdIcon.svelte";
interface Props {
text: string;
labelProps: LabelProps;
onImageReady: (img: Blob) => void;
}
let { text, labelProps, onImageReady }: Props = $props();
let importState = $state<"idle" | "processing" | "error">("idle");
const onImportClicked = async () => {
const mmToInchCoeff = 25.4;
const dpmm = 8; // todo: may vary, make it configurable
const widthInches = labelProps.size.width / dpmm / mmToInchCoeff;
const heightInches = labelProps.size.height / dpmm / mmToInchCoeff;
const contents = await FileUtils.pickAndReadSingleTextFile("zpl");
importState = "processing";
try {
const response = await fetch(
`https://api.labelary.com/v1/printers/${dpmm}dpmm/labels/${widthInches}x${heightInches}/0/`,
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "image/png",
"X-Quality": "bitonal",
},
body: contents,
},
);
if (response.ok) {
const img = await response.blob();
onImageReady(img);
importState = "idle";
} else {
importState = "error";
}
} catch (e) {
importState = "error";
console.error(e);
}
};
</script>
<button class="btn btn-sm" onclick={onImportClicked}>
<MdIcon icon="receipt_long" />
{text}
{#if importState === "processing"}
<MdIcon icon="hourglass_top" />
{:else if importState === "error"}
<MdIcon icon="warning" class="text-warning" />
{/if}
</button>

88
web/src/defaults.ts Normal file
View File

@@ -0,0 +1,88 @@
import * as fabric from "fabric";
import type { AppConfig, LabelPreset, LabelProps } from "$/types";
import { TextboxExt } from "$/fabric-object/textbox-ext";
export const configureFabric = () => {
fabric.config.disableStyleCopyPaste = true;
fabric.classRegistry.setClass(TextboxExt, "Textbox");
fabric.Line.prototype.setControlsVisibility({
tl: false,
bl: false,
tr: false,
br: false,
mt: false,
mb: false,
});
};
/** Default presets for LabelPropsEditor */
export const DEFAULT_LABEL_PRESETS: LabelPreset[] = [
// D11s 203dpi
{ width: 29, height: 12, unit: "mm", dpmm: 8, printDirection: "left", shape: "rect", title: "29x12mm" },
{ width: 40, height: 12, unit: "mm", dpmm: 8, printDirection: "left", shape: "rect" },
{ width: 50, height: 30, unit: "mm", dpmm: 8, printDirection: "top", shape: "rect" },
// 300dpi
{ width: 40, height: 12, unit: "mm", dpmm: 11.81, printDirection: "left", shape: "rect", title: "40x12mm 300dpi" },
{ width: 50, height: 30, unit: "mm", dpmm: 11.81, printDirection: "top", shape: "rect", title: "50x30mm 300dpi" },
];
/** Default canvas dimensions */
export const DEFAULT_LABEL_PROPS: LabelProps = {
printDirection: "left",
size: {
width: 232,
height: 96,
},
};
/** Object movement snapping */
export const GRID_SIZE: number = 5;
/** Newly created Fabric object dimensions */
export const OBJECT_SIZE_DEFAULTS = {
width: 64,
height: 64,
};
/** Newly created Fabric object common properties */
export const OBJECT_DEFAULTS = {
snapAngle: 10,
top: 10,
left: 10,
};
/** Newly created Fabric vector object properties */
export const OBJECT_DEFAULTS_VECTOR = {
...OBJECT_DEFAULTS,
fill: "transparent",
stroke: "black",
strokeWidth: 3,
strokeUniform: true,
};
/** Newly created Fabric text object properties */
export const OBJECT_DEFAULTS_TEXT = {
...OBJECT_DEFAULTS,
fill: "black",
fontFamily: "Noto Sans Variable",
textAlign: "center" as CanvasTextAlign,
originX: "center" as fabric.TOriginX,
originY: "center" as fabric.TOriginY,
lineHeight: 1,
};
/** Scale image to this height when making a label thumbnail */
export const THUMBNAIL_HEIGHT = 48;
/** Generate thumbnail in jpeg format with this quality */
export const THUMBNAIL_QUALITY = 0.7;
export const APP_CONFIG_DEFAULTS: AppConfig = {
fitMode: "stretch",
iconListMode: "both"
};
export const CSV_DEFAULT = "var1,var2\n123,456\n777,888";

View File

@@ -0,0 +1,287 @@
import * as fabric from "fabric";
import { code128b, ean13 } from "$/utils/barcode";
import { CanvasUtils } from "$/utils/canvas_utils";
import { OBJECT_DEFAULTS_TEXT } from "$/defaults";
const EAN13_LONG_BAR_INDEXES: number[] = [
0, 1, 2, 45, 46, 47, 48, 49, 92, 93, 94,
];
export type BarcodeCoding = "EAN13" | "CODE128B";
export const barcodeDefaultValues: Partial<fabric.TClassProperties<Barcode>> = {
text: "",
encoding: "EAN13",
printText: true,
scaleFactor: 1,
fontSize: 12,
fontFamily: OBJECT_DEFAULTS_TEXT.fontFamily,
};
interface UniqueBarcodeProps {
text: string;
encoding: BarcodeCoding;
printText: boolean;
scaleFactor: number;
fontSize: number;
fontFamily: string;
}
export interface BarcodeProps
extends fabric.FabricObjectProps,
UniqueBarcodeProps {}
export interface SerializedBarcodeProps
extends fabric.SerializedObjectProps,
UniqueBarcodeProps {}
const BARCODE_PROPS = [
"text",
"encoding",
"printText",
"scaleFactor",
"fontSize",
"fontFamily",
] as const;
export class Barcode<
Props extends fabric.TOptions<BarcodeProps> = Partial<BarcodeProps>,
SProps extends SerializedBarcodeProps = SerializedBarcodeProps,
EventSpec extends fabric.ObjectEvents = fabric.ObjectEvents,
>
extends fabric.FabricObject<Props, SProps, EventSpec>
implements BarcodeProps
{
static override type = "Barcode";
/**
* Barcode text
* @type string
* @default ""
*/
declare text: string;
/**
* Barcode encoding
* @type BarcodeCoding
* @default "EAN13"
*/
declare encoding: BarcodeCoding;
/**
* Print text
* @type boolean
* @default true
*/
declare printText: boolean;
/**
* Scale factor
* @type number
* @default 1
*/
declare scaleFactor: number;
/**
* Font size
* @type number
* @default 12
*/
declare fontSize: number;
/**
* Font family
* @type string
* @default "Noto Sans Variable"
*/
declare fontFamily: string;
private barcodeEncoded: string = "";
private displayText: string = "";
constructor(options?: Props) {
super();
Object.assign(this, barcodeDefaultValues);
const { text, ...other } = options ?? {};
this.setOptions(other); // Must be set separately because the encoding needs to be set first
this.set("text", text);
this.setControlsVisibility({
tl: false,
tr: false,
bl: false,
br: false,
ml: false,
mr: false,
mtr: false,
});
this.objectCaching = false;
this._createBandCode();
}
override _set(key: string, value?: any): this {
super._set(key, value);
if (key === "text" || key == "encoding") {
this._createBandCode();
}
if (
this.barcodeEncoded &&
(BARCODE_PROPS.includes(key as any) || key == "canvas")
) {
const letterWidth = this._measureLetterWidth();
let barcodeWidth = (this.scaleFactor ?? 1) * this.barcodeEncoded.length;
if (this.encoding === "EAN13") {
barcodeWidth += letterWidth * 2; // side margins
}
super.set("width", barcodeWidth);
this.setCoords();
}
return this;
}
_createBandCode() {
if (this.encoding === "EAN13") {
const { text, bandcode } = ean13(this.text);
this.displayText = text;
this.barcodeEncoded = bandcode;
} else {
this.displayText = this.text;
this.barcodeEncoded = code128b(this.text);
}
}
_getFont(): string {
return `bold ${this.fontSize}px ${this.fontFamily}`;
}
// parent canvas is needed for this operation
_measureLetterWidth(): number {
const ctx = this.canvas?.getContext();
let w = 0;
if (ctx !== undefined) {
ctx.save();
ctx.font = this._getFont();
w = ctx.measureText("0").width;
ctx.restore();
}
return Math.ceil(w);
}
override _render(ctx: CanvasRenderingContext2D) {
if (this.barcodeEncoded === "") {
super._render(ctx);
return;
}
const letterWidth = this._measureLetterWidth();
ctx.save();
ctx.translate(-this.width / 2, -this.height / 2); // make top-left origin
ctx.translate(0.5, 0.5); // blurry rendering fix
ctx.font = this._getFont();
ctx.textBaseline = "bottom";
const longBarHeight = this.height;
let shortBarHeight = this.height;
const barcodeStartPos = this.encoding === "EAN13" ? letterWidth : 0;
if (this.printText) {
shortBarHeight -= this.fontSize * 1.2;
} else if (this.encoding === "EAN13") {
shortBarHeight -= 8;
}
let blackStartPosition = -1;
let blackCount = 0;
let isLongBar = false;
// render barcode
for (let i = 0; i < this.barcodeEncoded.length; i++) {
const isBlack = this.barcodeEncoded[i] === "1";
const xPos = barcodeStartPos + i * this.scaleFactor;
if (isBlack) {
blackCount++;
if (blackStartPosition == -1) {
blackStartPosition = xPos;
}
if (this.encoding === "EAN13" && EAN13_LONG_BAR_INDEXES.includes(i)) {
isLongBar = true;
}
if (blackStartPosition != -1 && i === this.barcodeEncoded.length - 1) {
// last index
ctx.fillRect(
blackStartPosition,
0,
this.scaleFactor * blackCount,
isLongBar ? longBarHeight : shortBarHeight,
);
}
} else {
ctx.fillRect(
blackStartPosition,
0,
this.scaleFactor * blackCount,
isLongBar ? longBarHeight : shortBarHeight,
);
blackStartPosition = -1;
blackCount = 0;
isLongBar = false;
}
}
// render text
if (this.printText) {
if (this.encoding === "EAN13") {
const parts = [
this.displayText[0],
this.displayText.slice(1, 7),
this.displayText.slice(7, 13),
">",
];
const midPartWidth = 40;
const longBars1End = 4;
const longBars2End = 50;
ctx.fillText(parts[0], 0, this.height); // first digit
CanvasUtils.equalSpacingFillText(
ctx,
parts[1],
letterWidth + longBars1End * this.scaleFactor,
this.height,
midPartWidth * this.scaleFactor,
); // part 1
CanvasUtils.equalSpacingFillText(
ctx,
parts[2],
letterWidth + longBars2End * this.scaleFactor,
this.height,
midPartWidth * this.scaleFactor,
); // part 2
ctx.fillText(parts[3], this.width - letterWidth, this.height); // last digit
} else {
CanvasUtils.equalSpacingFillText(
ctx,
this.displayText,
barcodeStartPos,
this.height,
this.width,
);
}
}
ctx.restore();
super._render(ctx);
}
override toObject(propertiesToInclude: any[] = []) {
return super.toObject([...BARCODE_PROPS, ...propertiesToInclude]);
}
}
fabric.classRegistry.setClass(Barcode, "Barcode");
export default Barcode;

View File

@@ -0,0 +1,458 @@
import * as fabric from "fabric";
import { DEFAULT_LABEL_PROPS } from "$/defaults";
import type { LabelProps } from "$/types";
type LabelBounds = {
startX: number;
startY: number;
endX: number;
endY: number;
width: number;
height: number;
};
type FoldSegment = { start: number; end: number };
type FoldInfo = {
axis: "vertical" | "horizontal" | "none";
points: number[];
segments: FoldSegment[];
};
type MirrorInfo = { pos: fabric.Point; flip: boolean };
export class CustomCanvas extends fabric.Canvas {
private labelProps: LabelProps = DEFAULT_LABEL_PROPS;
private readonly SEPARATOR_LINE_WIDTH = 2;
private readonly ROUND_RADIUS = 10;
private readonly TAIL_WIDTH = 40;
private readonly GRAY = "#CFCFCF";
private readonly MIRROR_GHOST_COLOR = "rgba(0, 0, 0, 0.3)";
private customBackground: boolean = true;
private highlightMirror: boolean = true;
private virtualZoomRatio: number = 1;
constructor(
el?: string | HTMLCanvasElement,
options?: fabric.TOptions<fabric.CanvasOptions>,
) {
super(el, options);
this.setupZoom();
this.preserveObjectStacking = true;
}
private setupZoom() {
this.on("mouse:wheel", (opt) => {
const event = opt.e as WheelEvent;
event.preventDefault();
const delta = event.deltaY;
if (delta > 0) {
this.virtualZoomOut();
} else {
this.virtualZoomIn();
}
});
this.on("mouse:down:before", (opt) => {
const event = opt.e as MouseEvent;
if (event.button == 1) {
event.preventDefault();
this.resetVirtualZoom();
}
});
}
public virtualZoom(newZoom: number) {
this.virtualZoomRatio = Math.min(Math.max(0.25, newZoom), 4);
this.setDimensions(
{
width: this.virtualZoomRatio * this.getWidth() + "px",
height: this.virtualZoomRatio * this.getHeight() + "px",
},
{ cssOnly: true },
);
}
public virtualZoomIn() {
this.virtualZoom(this.virtualZoomRatio * 1.05);
}
public virtualZoomOut() {
this.virtualZoom(this.virtualZoomRatio * 0.95);
}
public getVirtualZoom(): number {
return this.virtualZoomRatio;
}
public resetVirtualZoom() {
this.virtualZoom(1);
}
setLabelProps(value: LabelProps) {
this.labelProps = value;
this.requestRenderAll();
}
setCustomBackground(value: boolean) {
this.customBackground = value;
}
setHighlightMirror(value: boolean) {
this.highlightMirror = value;
}
/** Get label bounds without tail */
getLabelBounds(): LabelBounds {
let endX = this.width ?? 1;
let endY = this.height ?? 1;
let startX = 0;
let startY = 0;
if (this.labelProps.tailPos === "right") {
endX -= this.labelProps.tailLength ?? 0;
} else if (this.labelProps.tailPos === "bottom") {
endY -= this.labelProps.tailLength ?? 0;
} else if (this.labelProps.tailPos === "left") {
startX += this.labelProps.tailLength ?? 0;
} else if (this.labelProps.tailPos === "top") {
startY += this.labelProps.tailLength ?? 0;
}
const width = endX - startX;
const height = endY - startY;
return { startX, startY, endX, endY, width, height };
}
/** Get fold line position for splitted labels */
getFoldInfo(): FoldInfo {
const bb = this.getLabelBounds();
const points: number[] = [];
const segments: FoldSegment[] = [];
const splitParts = this.labelProps.splitParts ?? 2;
if (splitParts < 2) {
return { axis: "none", points, segments };
}
if (this.labelProps.split === "horizontal") {
const segmentHeight = bb.height / splitParts;
let lastY: number = bb.startY;
for (let i = 1; i < splitParts; i++) {
const y =
bb.startY + segmentHeight * i - this.SEPARATOR_LINE_WIDTH / 2 + 1;
points.push(y);
segments.push({ start: lastY, end: y });
lastY = y;
}
segments.push({ start: lastY, end: bb.endY });
return { axis: "horizontal", points, segments };
} else if (this.labelProps.split === "vertical") {
const segmentWidth = bb.width / splitParts;
let lastX: number = bb.startX;
for (let i = 1; i < splitParts; i++) {
const x =
bb.startX + segmentWidth * i - this.SEPARATOR_LINE_WIDTH / 2 + 1;
points.push(x);
segments.push({ start: lastX, end: x });
lastX = x;
}
segments.push({ start: lastX, end: bb.endX });
return { axis: "vertical", points, segments };
}
return { axis: "none", points, segments };
}
override _renderBackground(ctx: CanvasRenderingContext2D) {
if (this.width === undefined || this.height === undefined) {
return;
}
ctx.save();
ctx.fillStyle = "white";
// Draw simple white background and exit
if (!this.customBackground) {
ctx.fillRect(0, 0, this.width, this.height);
ctx.restore();
return;
}
// Disable further actions for circle labels, just render
if (this.labelProps.shape === "circle") {
ctx.beginPath();
ctx.arc(this.width / 2, this.height / 2, this.height / 2, 0, 2 * Math.PI);
ctx.fill();
ctx.restore();
return;
}
let roundRadius = this.ROUND_RADIUS;
const bb = this.getLabelBounds();
const fold = this.getFoldInfo();
if (this.labelProps.shape !== "rounded_rect") {
roundRadius = 0;
}
// Draw tail
ctx.fillStyle = this.GRAY;
ctx.beginPath();
if (
this.labelProps.tailLength !== undefined &&
this.labelProps.tailLength > 0
) {
if (this.labelProps.tailPos === "right") {
ctx.rect(
bb.endX - roundRadius,
bb.endY / 2 - this.TAIL_WIDTH / 2,
this.width - bb.endX + roundRadius,
this.TAIL_WIDTH,
);
} else if (this.labelProps.tailPos === "bottom") {
ctx.rect(
bb.endX / 2 - this.TAIL_WIDTH / 2,
bb.endY - roundRadius,
this.TAIL_WIDTH,
this.height - bb.endY + roundRadius,
);
} else if (this.labelProps.tailPos === "left") {
ctx.rect(
0,
bb.endY / 2 - this.TAIL_WIDTH / 2,
bb.startX + roundRadius,
this.TAIL_WIDTH,
);
} else if (this.labelProps.tailPos === "top") {
ctx.rect(
bb.endX / 2 - this.TAIL_WIDTH / 2,
0,
this.TAIL_WIDTH,
bb.startY + roundRadius,
);
}
}
ctx.fill();
// Draw label(s)
ctx.fillStyle = "white";
ctx.beginPath();
const splitParts = this.labelProps.splitParts ?? 2;
if (this.labelProps.shape === "rounded_rect") {
if (this.labelProps.split === "horizontal") {
const segmentHeight = bb.height / splitParts;
ctx.roundRect(
bb.startX,
bb.startY,
bb.width,
segmentHeight,
roundRadius,
); // First part
fold.points.forEach((y) =>
ctx.roundRect(bb.startX, y, bb.width, segmentHeight, roundRadius),
); // Other parts
} else if (this.labelProps.split === "vertical") {
const segmentWidth = bb.width / splitParts;
ctx.roundRect(
bb.startX,
bb.startY,
segmentWidth,
bb.height,
roundRadius,
); // First part
fold.points.forEach((x) =>
ctx.roundRect(x, bb.startY, segmentWidth, bb.height, roundRadius),
); // Other parts
} else {
ctx.roundRect(0, 0, this.width, this.height, roundRadius);
}
} else {
ctx.rect(bb.startX, bb.startY, bb.width, bb.height);
}
ctx.fill();
// Draw separator
ctx.strokeStyle = this.GRAY;
ctx.lineWidth = this.SEPARATOR_LINE_WIDTH;
ctx.setLineDash([8, 8]);
ctx.beginPath();
if (fold.axis === "horizontal") {
fold.points.forEach((x) => {
ctx.moveTo(bb.startX + roundRadius, x);
ctx.lineTo(bb.endX - roundRadius, x);
});
} else if (fold.axis === "vertical") {
fold.points.forEach((y) => {
ctx.moveTo(y, bb.startY + roundRadius);
ctx.lineTo(y, bb.endY - roundRadius);
});
}
ctx.stroke();
ctx.restore();
}
override _renderObjects(
ctx: CanvasRenderingContext2D,
objects: fabric.FabricObject[],
) {
super._renderObjects(ctx, objects);
if (!this.highlightMirror || this.getActiveObjects().length > 1) {
return;
}
ctx.save();
objects.forEach((obj) => {
const infos = this.getMirroredObjectCoords(obj);
infos.forEach((info) => {
const bbox = obj.getBoundingRect();
ctx.fillStyle = this.MIRROR_GHOST_COLOR;
ctx.fillRect(
info.pos.x - bbox.width / 2,
info.pos.y - bbox.height / 2,
bbox.width,
bbox.height,
);
ctx.restore();
});
});
ctx.restore();
}
/**
* Return new object positions (origin is center) if object needs mirroring
**/
getMirroredObjectCoords(obj: fabric.FabricObject): MirrorInfo[] {
const fold = this.getFoldInfo();
const result: MirrorInfo[] = [];
if (
fold.axis === "none" ||
!(this.labelProps.mirror === "flip" || this.labelProps.mirror === "copy")
) {
return result;
}
const bounds = this.getLabelBounds();
if (fold.axis === "vertical") {
if (this.labelProps.mirror === "copy") {
fold.points.forEach((x) => {
const pos = obj.getPointByOrigin("center", "center");
pos.setX(x + (pos.x - bounds.startX));
result.push({ pos, flip: false });
});
} else if (
this.labelProps.mirror === "flip" &&
fold.points.length === 1
) {
// Half split only supported
const axisX = fold.points[0];
const pos = obj.getPointByOrigin("center", "center");
pos.setX(axisX + (axisX - pos.x));
pos.setY(bounds.startY + bounds.endY - pos.y);
result.push({ pos, flip: true });
}
} else if (fold.axis === "horizontal") {
if (this.labelProps.mirror === "copy") {
fold.points.forEach((y) => {
const pos = obj.getPointByOrigin("center", "center");
pos.setY(y + (pos.y - bounds.startY));
result.push({ pos, flip: false });
});
} else if (
this.labelProps.mirror === "flip" &&
fold.points.length === 1
) {
// Half split only supported
const axisY = fold.points[0];
const pos = obj.getPointByOrigin("center", "center");
pos.setY(axisY + (axisY - pos.y));
pos.setX(bounds.startX + bounds.endX - pos.x);
result.push({ pos, flip: true });
}
}
return result;
}
/** Clone mirrored objects and add them to canvas */
async createMirroredObjects() {
const objects = this.getObjects();
for (const obj of objects) {
const infos = this.getMirroredObjectCoords(obj);
for (const info of infos) {
const newObj = await obj.clone();
newObj.setPositionByOrigin(info.pos, "center", "center");
if (info.flip) {
newObj.centeredRotation = true;
newObj.rotate((newObj.angle + 180) % 360);
}
this.add(newObj);
}
}
}
/** Centers object horizontally in the canvas or label part */
override centerObjectH(object: fabric.FabricObject): void {
if ((this.labelProps.split ?? "none") !== "none") {
const pos = object.getPointByOrigin("center", "center");
const bounds = this.getLabelBounds();
const fold = this.getFoldInfo();
let centerX = bounds.startX + bounds.width / 2;
if (fold.axis !== "horizontal") {
fold.segments.forEach((seg) => {
if (pos.x >= seg.start && pos.x <= seg.end) {
centerX = seg.start + (seg.end - seg.start) / 2;
}
});
}
pos.setX(centerX);
object.setPositionByOrigin(pos, "center", "center");
return;
}
super.centerObjectH(object);
}
/** Centers object vertically in the canvas or label part */
override centerObjectV(object: fabric.FabricObject): void {
if ((this.labelProps.split ?? "none") !== "none") {
const pos = object.getPointByOrigin("center", "center");
const bounds = this.getLabelBounds();
const fold = this.getFoldInfo();
let centerY = bounds.startY + bounds.height / 2;
if (fold.axis !== "vertical") {
fold.segments.forEach((seg) => {
if (pos.y >= seg.start && pos.y <= seg.end) {
centerY = seg.start + (seg.end - seg.start) / 2;
}
});
}
pos.setY(centerY);
object.setPositionByOrigin(pos, "center", "center");
return;
}
super.centerObjectV(object);
}
}

View File

@@ -0,0 +1,159 @@
import QRCodeFactory from "qrcode-generator";
import * as fabric from "fabric";
import { OBJECT_DEFAULTS_TEXT, OBJECT_SIZE_DEFAULTS } from "$/defaults";
import { Range } from "$/types";
export type ErrorCorrectionLevel = "L" | "M" | "Q" | "H";
export type Mode = "Numeric" | "Alphanumeric" | "Byte" /* Default */ | "Kanji";
export type QrVersion = Range<41>; // 0-40, 0 is automatic
export const qrCodeDefaultValues: Partial<fabric.TClassProperties<QRCode>> = {
text: "Text",
ecl: "M",
stroke: "#000000",
fill: "#ffffff",
mode: "Byte",
qrVersion: 0,
...OBJECT_SIZE_DEFAULTS,
};
interface UniqueQRCodeProps {
text: string;
ecl: ErrorCorrectionLevel;
mode: Mode;
qrVersion: QrVersion;
}
export interface QRCodeProps
extends fabric.FabricObjectProps,
UniqueQRCodeProps {}
export interface SerializedQRCodeProps
extends fabric.SerializedObjectProps,
UniqueQRCodeProps {}
const QRCODE_PROPS = ["text", "ecl", "size", "mode", "qrVersion"] as const;
export class QRCode<
Props extends fabric.TOptions<QRCodeProps> = Partial<QRCodeProps>,
SProps extends SerializedQRCodeProps = SerializedQRCodeProps,
EventSpec extends fabric.ObjectEvents = fabric.ObjectEvents,
>
extends fabric.FabricObject<Props, SProps, EventSpec>
implements QRCodeProps
{
static override readonly type = "QRCode";
/**
* QRCode text
* @type string
* @default "Text"
*/
declare text: string;
/**
* Error Correction Level
* @type ErrorCorrectionLevel
* @default "M"
*/
declare ecl: ErrorCorrectionLevel;
/**
* Mode
* @type Mode
* @default "M"
*/
declare mode: Mode;
/**
* Version
* @type Mode
* @default "M"
*/
declare qrVersion: QrVersion;
constructor(options?: Props) {
super();
Object.assign(this, qrCodeDefaultValues);
this.setOptions(options);
this.lockScalingFlip = true;
this.setControlsVisibility({
ml: false,
mt: false,
mr: false,
mb: false,
tl: false,
tr: false,
bl: false,
});
}
override _set(key: string, value: any): this {
super._set(key, value);
if (key === "text" || key === "ecl") {
this.dirty = true;
}
return this;
}
renderError(ctx: CanvasRenderingContext2D): void {
ctx.save();
ctx.fillStyle = "black";
ctx.translate(-this.width / 2, -this.height / 2); // make top-left origin
ctx.translate(-0.5, -0.5); // blurry rendering fix
ctx.fillRect(0, 0, this.width + 1, this.height + 1);
ctx.restore();
ctx.save();
ctx.fillStyle = "white";
ctx.textAlign = "center";
ctx.font = `16px ${OBJECT_DEFAULTS_TEXT.fontFamily}`;
ctx.fillText("ERR", 0, 0);
ctx.restore();
}
override _render(ctx: CanvasRenderingContext2D): void {
if (!this.text) {
this.renderError(ctx);
super._render(ctx);
return;
}
const qr = QRCodeFactory(this.qrVersion, this.ecl);
try {
qr.addData(this.text, this.mode);
qr.make();
} catch (e) {
console.error(e);
this.renderError(ctx);
super._render(ctx);
return;
}
const qrScale = Math.floor(this.width / qr.getModuleCount());
let qrWidth = qrScale * qr.getModuleCount();
qrWidth -= qrWidth % 2; // avoid half-pixel rendering
if (qrScale < 1 || qrWidth > this.width) {
this.renderError(ctx);
super._render(ctx);
return;
}
ctx.save();
ctx.translate(-qrWidth / 2, -qrWidth / 2); // make top-left origin
ctx.translate(-0.5, -0.5); // blurry rendering fix
qr.renderTo2dContext(ctx, qrScale);
ctx.restore();
super._render(ctx);
}
override toObject(propertiesToInclude: any[] = []) {
return super.toObject([...QRCODE_PROPS, ...propertiesToInclude]);
}
}
fabric.classRegistry.setClass(QRCode, "QRCode");
export default QRCode;

View File

@@ -0,0 +1,89 @@
import * as fabric from "fabric";
interface UniqueTextboxExtProps {
fontAutoSize: boolean;
}
const TEXTBOX_PROPS: Array<keyof UniqueTextboxExtProps> = ["fontAutoSize"];
export const textboxExtDefaultValues: Partial<fabric.TClassProperties<TextboxExt>> = {
fontAutoSize: false,
};
export interface TextboxExtProps extends fabric.TextboxProps, UniqueTextboxExtProps {}
export interface SerializedTextboxExtProps extends fabric.SerializedTextboxProps, UniqueTextboxExtProps {}
export class TextboxExt<
Props extends fabric.TOptions<TextboxExtProps> = Partial<TextboxExtProps>,
SProps extends SerializedTextboxExtProps = SerializedTextboxExtProps,
EventSpec extends fabric.ITextEvents = fabric.ITextEvents,
>
extends fabric.Textbox<Props, SProps, EventSpec>
implements UniqueTextboxExtProps
{
declare fontAutoSize: boolean;
private widthBeforeEditing?: number;
constructor(text: string, options?: Props) {
super(text, options);
Object.assign(this, textboxExtDefaultValues);
this.setOptions(options);
this.setControlsVisibility({
mb: false,
mt: false,
});
}
/** Set text and reduce fontSize until text fits to the given width */
setAndShrinkText(text: string, maxWidth: number, maxLines?: number) {
const linesLimit = maxLines ?? this._splitTextIntoLines(this.text).lines.length;
let linesCount = this._splitTextIntoLines(text).lines.length;
this.set({ text });
while ((linesCount > linesLimit || this.width > maxWidth) && this.fontSize > 2) {
this.fontSize -= 1;
this.set({ text, width: maxWidth });
linesCount = this._splitTextIntoLines(text).lines.length;
}
}
/** Reduce fontSize until text fits to the given width */
shrinkText(maxWidth: number, maxLines: number) {
let linesCount = this._splitTextIntoLines(this.text).lines.length;
while ((linesCount > maxLines || this.width > maxWidth) && this.fontSize > 2) {
this.fontSize -= 1;
this.set({ width: maxWidth });
linesCount = this._splitTextIntoLines(this.text).lines.length;
}
}
override enterEditingImpl() {
super.enterEditingImpl();
this.widthBeforeEditing = this.width;
}
override exitEditingImpl() {
super.exitEditingImpl();
this.widthBeforeEditing = undefined;
}
override updateFromTextArea(): void {
super.updateFromTextArea();
if (this.widthBeforeEditing !== undefined && this.fontAutoSize) {
const lines = this.text.split("\n").length;
this.shrinkText(this.widthBeforeEditing, lines);
}
}
override toObject<T extends Omit<Props & fabric.TClassProperties<this>, keyof SProps>, K extends keyof T = never>(
propertiesToInclude: K[] = [],
): Pick<T, K> & SProps {
return super.toObject([...propertiesToInclude, ...TEXTBOX_PROPS] as (keyof T)[]);
}
}

16
web/src/index.ts Normal file
View File

@@ -0,0 +1,16 @@
import "$/styles/style.scss";
import "@popperjs/core";
import "toastify-js/src/toastify.css";
import "bootstrap/js/dist/dropdown";
import "bootstrap/js/dist/collapse";
import App from "$/App.svelte";
import { mount } from "svelte";
import { configureFabric } from "$/defaults";
configureFabric();
const app = mount(App, {
target: document.getElementById("app")!,
});
export default app;

View File

@@ -0,0 +1,309 @@
import {
SERVICE_UUID,
WRITE_CHAR_UUID,
NOTIFY_CHAR_UUID,
CHUNK_SIZE,
CHUNK_DELAY_MS,
CMD,
} from "./constants";
import { TypedEventEmitter } from "./emitter";
import { Utils } from "./utils";
import { FicheroPrintTask } from "./print_task";
import type {
ConnectionInfo,
HeartbeatData,
PrinterInfo,
PrinterModelMeta,
PrintProgressEvent,
FirmwareProgressEvent,
RfidInfo,
Packet,
LabelType,
PrintTaskName,
} from "./types";
interface ClientEventMap {
connect: { info: ConnectionInfo };
disconnect: void;
printerinfofetched: { info: PrinterInfo };
heartbeat: { data: HeartbeatData };
heartbeatfailed: { failedAttempts: number };
printprogress: PrintProgressEvent;
firmwareprogress: FirmwareProgressEvent;
packetsent: { packet: Packet };
packetreceived: { packet: Packet };
}
function makePacket(data: readonly number[] | number[] | Uint8Array): Packet {
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
const cmd = bytes.length >= 2 ? (bytes[0] << 8) | bytes[1] : bytes[0] ?? 0;
return {
command: cmd,
toBytes: () => bytes,
};
}
export class FicheroClient extends TypedEventEmitter<ClientEventMap> {
private device: BluetoothDevice | null = null;
private writeChar: BluetoothRemoteGATTCharacteristic | null = null;
private notifyChar: BluetoothRemoteGATTCharacteristic | null = null;
private notifyBuf: number[] = [];
private notifyResolve: (() => void) | null = null;
private heartbeatTimer: ReturnType<typeof setInterval> | undefined;
private heartbeatFailCount = 0;
private packetIntervalMs = 20;
private info: PrinterInfo = {};
readonly abstraction = {
newPrintTask: (
_name: PrintTaskName,
opts: {
totalPages: number;
density: number;
speed: number;
labelType: LabelType;
statusPollIntervalMs: number;
statusTimeoutMs: number;
},
) => {
return new FicheroPrintTask(
opts,
(data, wait, timeout) => this.sendCommand(data, wait, timeout),
(data) => this.sendChunked(data),
(e) => this.emit("printprogress", e),
);
},
printEnd: async () => {
// No-op for D11s - stop is handled per-copy in print task
},
printerReset: async () => {
await this.sendCommand(CMD.factoryReset, true);
},
rfidInfo: async (): Promise<RfidInfo> => {
return {};
},
rfidInfo2: async (): Promise<RfidInfo> => {
return {};
},
setSoundEnabled: async (_type: number, _enabled: boolean) => {
// Not supported on D11s
},
firmwareUpgrade: async (_data: Uint8Array, _version: string) => {
throw new Error("Firmware upgrade not implemented");
},
};
async connect(opts?: { deviceId?: string }): Promise<void> {
let device: BluetoothDevice;
try {
device = await navigator.bluetooth.requestDevice({
filters: [{ namePrefix: "FICHERO" }, { namePrefix: "D11s_" }],
optionalServices: [SERVICE_UUID],
});
} catch {
throw new Error("No device selected");
}
device.addEventListener("gattserverdisconnected", () => this.onDisconnected());
const server = await device.gatt!.connect();
const service = await server.getPrimaryService(SERVICE_UUID);
this.writeChar = await service.getCharacteristic(WRITE_CHAR_UUID);
this.notifyChar = await service.getCharacteristic(NOTIFY_CHAR_UUID);
await this.notifyChar.startNotifications();
this.notifyChar.addEventListener("characteristicvaluechanged", (e: Event) => this.onNotify(e));
this.device = device;
this.emit("connect", { info: { deviceName: device.name } });
await this.fetchPrinterInfo();
this.startHeartbeat();
}
disconnect(): void {
this.stopHeartbeat();
if (this.device?.gatt?.connected) {
this.device.gatt.disconnect();
}
this.onDisconnected();
}
private onDisconnected(): void {
this.stopHeartbeat();
this.device = null;
this.writeChar = null;
this.notifyChar = null;
this.info = {};
this.emit("disconnect", undefined as unknown as void);
}
private onNotify(event: Event): void {
const target = event.target as BluetoothRemoteGATTCharacteristic;
const val = new Uint8Array(target.value!.buffer);
this.notifyBuf.push(...val);
if (this.notifyResolve) {
this.notifyResolve();
this.notifyResolve = null;
}
}
private waitForNotify(timeout = 2000): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(() => {
this.notifyResolve = null;
resolve();
}, timeout);
this.notifyResolve = () => {
clearTimeout(timer);
resolve();
};
});
}
async sendCommand(
data: readonly number[] | number[] | Uint8Array,
wait = false,
timeout = 2000,
): Promise<Uint8Array> {
if (!this.writeChar) throw new Error("Not connected");
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
this.emit("packetsent", { packet: makePacket(bytes) });
if (wait) {
this.notifyBuf = [];
}
await this.writeChar.writeValueWithoutResponse(bytes);
if (wait) {
await this.waitForNotify(timeout);
await Utils.sleep(50);
}
const response = new Uint8Array(this.notifyBuf);
if (wait && response.length > 0) {
this.emit("packetreceived", { packet: makePacket(response) });
}
return response;
}
async sendChunked(data: Uint8Array): Promise<void> {
if (!this.writeChar) throw new Error("Not connected");
for (let i = 0; i < data.length; i += CHUNK_SIZE) {
const chunk = data.slice(i, i + CHUNK_SIZE);
await this.writeChar.writeValueWithoutResponse(chunk);
await Utils.sleep(CHUNK_DELAY_MS);
}
}
private decodeResponse(buf: number[] | Uint8Array): string {
return new TextDecoder().decode(new Uint8Array(buf)).trim();
}
async fetchPrinterInfo(): Promise<void> {
const info: PrinterInfo = {};
let r = await this.sendCommand(CMD.getModel, true);
info.modelId = this.decodeResponse(r);
r = await this.sendCommand(CMD.getFirmware, true);
info.firmware = this.decodeResponse(r);
r = await this.sendCommand(CMD.getSerial, true);
info.serial = this.decodeResponse(r);
r = await this.sendCommand(CMD.getBattery, true);
if (r.length >= 2) {
info.battery = r[r.length - 1];
info.charging = r[r.length - 2] !== 0;
}
r = await this.sendCommand(CMD.getStatus, true);
if (r.length > 0) {
const sb = r[r.length - 1];
info.status = this.parseStatusByte(sb);
}
this.info = info;
this.emit("printerinfofetched", { info });
}
private parseStatusByte(byte: number): string {
const flags: string[] = [];
if (byte & 0x01) flags.push("printing");
if (byte & 0x02) flags.push("cover open");
if (byte & 0x04) flags.push("no paper");
if (byte & 0x08) flags.push("low battery");
if (byte & 0x10 || byte & 0x40) flags.push("overheated");
if (byte & 0x20) flags.push("charging");
return flags.length ? flags.join(", ") : "ready";
}
getModelMetadata(): PrinterModelMeta {
return {
model: this.info.modelId ?? "D11s",
printheadPixels: 96,
printDirection: "left" as const,
densityMin: 0,
densityMax: 2,
densityDefault: 2,
paperTypes: [1, 2, 3], // LabelType values
};
}
getPrintTaskType(): PrintTaskName {
return "B1";
}
setPacketInterval(ms: number): void {
this.packetIntervalMs = ms;
}
startHeartbeat(): void {
this.stopHeartbeat();
this.heartbeatFailCount = 0;
this.heartbeatTimer = setInterval(async () => {
try {
const r = await this.sendCommand(CMD.getBattery, true);
if (r.length >= 2) {
const data: HeartbeatData = {
chargeLevel: r[r.length - 1],
charging: r[r.length - 2] !== 0,
};
this.heartbeatFailCount = 0;
this.emit("heartbeat", { data });
}
} catch {
this.heartbeatFailCount++;
this.emit("heartbeatfailed", { failedAttempts: this.heartbeatFailCount });
}
}, 5000);
}
stopHeartbeat(): void {
if (this.heartbeatTimer !== undefined) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = undefined;
}
}
}
export class FicheroBluetoothClient extends FicheroClient {}
export function instantiateClient(_type?: string): FicheroClient {
return new FicheroBluetoothClient();
}

View File

@@ -0,0 +1,28 @@
export const SERVICE_UUID = "000018f0-0000-1000-8000-00805f9b34fb";
export const WRITE_CHAR_UUID = "00002af1-0000-1000-8000-00805f9b34fb";
export const NOTIFY_CHAR_UUID = "00002af0-0000-1000-8000-00805f9b34fb";
export const PRINTHEAD_PX = 96;
export const BYTES_PER_ROW = 12;
export const CHUNK_SIZE = 200;
export const CHUNK_DELAY_MS = 20;
export const CMD = {
getModel: [0x10, 0xff, 0x20, 0xf0],
getFirmware: [0x10, 0xff, 0x20, 0xf1],
getSerial: [0x10, 0xff, 0x20, 0xf2],
getBattery: [0x10, 0xff, 0x50, 0xf1],
getStatus: [0x10, 0xff, 0x40],
getShutdownTime: [0x10, 0xff, 0x13],
setDensity: (level: number) => [0x10, 0xff, 0x10, 0x00, level],
setPaperType: (type: number) => [0x10, 0xff, 0x84, type],
setShutdownTime: (mins: number) => [0x10, 0xff, 0x12, (mins >> 8) & 0xff, mins & 0xff],
enablePrinter: [0x10, 0xff, 0xfe, 0x01],
stopPrint: [0x10, 0xff, 0xfe, 0x45],
formFeed: [0x1d, 0x0c],
factoryReset: [0x10, 0xff, 0x04],
} as const;
export const FICHERO_CLIENT_DEFAULTS = {
packetIntervalMs: 20,
} as const;

View File

@@ -0,0 +1,27 @@
type Listener<T> = (event: T) => void;
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export class TypedEventEmitter<EventMap extends {}> {
private listeners = new Map<keyof EventMap, Set<Listener<any>>>();
on<K extends keyof EventMap>(event: K, listener: Listener<EventMap[K]>): void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(listener);
}
off<K extends keyof EventMap>(event: K, listener: Listener<EventMap[K]>): void {
this.listeners.get(event)?.delete(listener);
}
emit<K extends keyof EventMap>(event: K, data: EventMap[K]): void {
this.listeners.get(event)?.forEach((fn) => {
try {
fn(data);
} catch (e) {
console.error(`Event listener error [${String(event)}]:`, e);
}
});
}
}

View File

@@ -0,0 +1,56 @@
import { BYTES_PER_ROW, PRINTHEAD_PX } from "./constants";
import type { EncodedImage, PrintDirection } from "./types";
export class ImageEncoder {
static encodeCanvas(canvas: HTMLCanvasElement, direction: PrintDirection): EncodedImage {
let source = canvas;
if (direction === "left") {
source = ImageEncoder.rotateCW90(canvas);
}
const rowsData = ImageEncoder.canvasToRaster(source);
return {
cols: BYTES_PER_ROW,
rows: source.height,
rowsData,
};
}
private static rotateCW90(canvas: HTMLCanvasElement): HTMLCanvasElement {
const rotated = document.createElement("canvas");
rotated.width = canvas.height;
rotated.height = canvas.width;
const ctx = rotated.getContext("2d")!;
ctx.translate(rotated.width, 0);
ctx.rotate(Math.PI / 2);
ctx.drawImage(canvas, 0, 0);
return rotated;
}
private static canvasToRaster(canvas: HTMLCanvasElement): Uint8Array {
const ctx = canvas.getContext("2d")!;
const data = ctx.getImageData(0, 0, canvas.width, canvas.height);
const px = data.data;
const rows = canvas.height;
const out = new Uint8Array(rows * BYTES_PER_ROW);
for (let y = 0; y < rows; y++) {
for (let byteIdx = 0; byteIdx < BYTES_PER_ROW; byteIdx++) {
let byte = 0;
for (let bit = 0; bit < 8; bit++) {
const x = byteIdx * 8 + bit;
if (x < canvas.width) {
const i = (y * canvas.width + x) * 4;
if (px[i] === 0) {
byte |= 0x80 >> bit;
}
}
}
out[y * BYTES_PER_ROW + byteIdx] = byte;
}
}
return out;
}
}

View File

@@ -0,0 +1,35 @@
export { FICHERO_CLIENT_DEFAULTS } from "./constants";
export {
LabelType,
SoundSettingsItemType,
RequestCommandId,
ResponseCommandId,
printTaskNames,
} from "./types";
export type {
PrintDirection,
PrinterInfo,
PrinterModelMeta,
HeartbeatData,
RfidInfo,
AvailableTransports,
ConnectionInfo,
EncodedImage,
PrintProgressEvent,
FirmwareProgressEvent,
PrintTaskName,
Packet,
} from "./types";
export { TypedEventEmitter } from "./emitter";
export { Utils } from "./utils";
export { ImageEncoder } from "./image_encoder";
export { AbstractPrintTask } from "./print_task";
export {
FicheroClient,
FicheroBluetoothClient,
instantiateClient,
} from "./client";

View File

@@ -0,0 +1,116 @@
import { BYTES_PER_ROW, CHUNK_DELAY_MS, CHUNK_SIZE, CMD } from "./constants";
import { Utils } from "./utils";
import type { EncodedImage, LabelType, PrintProgressEvent } from "./types";
export interface PrintTaskOptions {
totalPages: number;
density: number;
speed: number;
labelType: LabelType;
statusPollIntervalMs: number;
statusTimeoutMs: number;
}
export abstract class AbstractPrintTask {
abstract printInit(): Promise<void>;
abstract printPage(image: EncodedImage, quantity: number): Promise<void>;
abstract waitForFinished(): Promise<void>;
abstract printEnd(): Promise<void>;
}
type SendFn = (data: readonly number[] | number[] | Uint8Array, wait?: boolean, timeout?: number) => Promise<Uint8Array>;
type SendChunkedFn = (data: Uint8Array) => Promise<void>;
type EmitProgressFn = (event: PrintProgressEvent) => void;
export class FicheroPrintTask extends AbstractPrintTask {
private opts: PrintTaskOptions;
private sendCmd: SendFn;
private sendChunked: SendChunkedFn;
private emitProgress: EmitProgressFn;
constructor(
opts: PrintTaskOptions,
sendCmd: SendFn,
sendChunked: SendChunkedFn,
emitProgress: EmitProgressFn,
) {
super();
this.opts = opts;
this.sendCmd = sendCmd;
this.sendChunked = sendChunked;
this.emitProgress = emitProgress;
}
async printInit(): Promise<void> {
// Check status
const statusResp = await this.sendCmd(CMD.getStatus, true);
if (statusResp.length > 0) {
const sb = statusResp[statusResp.length - 1];
if (sb & 0x02) throw new Error("Cover is open");
if (sb & 0x04) throw new Error("No paper loaded");
if (sb & 0x50) throw new Error("Printer overheated");
}
// Set density
await this.sendCmd(CMD.setDensity(this.opts.density), true);
await Utils.sleep(100);
}
async printPage(image: EncodedImage, quantity: number): Promise<void> {
const paperTypeMap: Record<number, number> = { 1: 0, 2: 1, 3: 2 };
const paperByte = paperTypeMap[this.opts.labelType] ?? 0;
for (let copy = 0; copy < quantity; copy++) {
// Paper type
await this.sendCmd(CMD.setPaperType(paperByte), true);
await Utils.sleep(50);
// Wake up (12 null bytes)
await this.sendCmd(new Array(12).fill(0));
await Utils.sleep(50);
// Enable printer
await this.sendCmd(Array.from(CMD.enablePrinter));
await Utils.sleep(50);
// Raster header: GS v 0 mode xL xH yL yH
const yL = image.rows & 0xff;
const yH = (image.rows >> 8) & 0xff;
const header = new Uint8Array([0x1d, 0x76, 0x30, 0x00, BYTES_PER_ROW, 0x00, yL, yH]);
const payload = new Uint8Array(header.length + image.rowsData.length);
payload.set(header, 0);
payload.set(image.rowsData, header.length);
await this.sendChunked(payload);
await Utils.sleep(500);
// Form feed
await this.sendCmd(Array.from(CMD.formFeed));
await Utils.sleep(300);
this.emitProgress({
page: copy + 1,
pagePrintProgress: 100,
pageFeedProgress: 0,
});
// Stop print and wait for response
await this.sendCmd(Array.from(CMD.stopPrint), true, 60000);
this.emitProgress({
page: copy + 1,
pagePrintProgress: 100,
pageFeedProgress: 100,
});
}
}
async waitForFinished(): Promise<void> {
// Already waited during printPage for each copy's stop response
}
async printEnd(): Promise<void> {
// No additional cleanup needed
}
}

View File

@@ -0,0 +1,96 @@
export enum LabelType {
WithGaps = 1,
Black = 2,
Continuous = 3,
}
export enum SoundSettingsItemType {
BluetoothConnectionSound = 1,
PowerSound = 2,
}
export enum RequestCommandId {
GetModel = 0x20f0,
GetFirmware = 0x20f1,
GetSerial = 0x20f2,
GetBattery = 0x50f1,
GetStatus = 0x0040,
SetDensity = 0x1000,
SetPaperType = 0x0084,
EnablePrinter = 0xfe01,
StopPrint = 0xfe45,
FormFeed = 0x1d0c,
RasterData = 0x7630,
}
export enum ResponseCommandId {
Ok = 0x4f4b,
Ack = 0x00aa,
Error = 0x00ff,
}
export type PrintDirection = "left" | "top";
export interface PrinterInfo {
modelId?: string;
firmware?: string;
serial?: string;
battery?: number;
charging?: boolean;
[key: string]: string | number | boolean | undefined;
}
export interface PrinterModelMeta {
model: string;
printheadPixels: number;
printDirection: PrintDirection;
densityMin: number;
densityMax: number;
densityDefault: number;
paperTypes: LabelType[];
}
export interface HeartbeatData {
chargeLevel?: number;
charging?: boolean;
statusByte?: number;
paperRfidSuccess?: boolean;
ribbonRfidSuccess?: boolean;
}
export interface RfidInfo {
[key: string]: string | number | boolean | undefined;
}
export interface AvailableTransports {
webBluetooth: boolean;
}
export interface ConnectionInfo {
deviceName?: string;
}
export interface EncodedImage {
cols: number;
rows: number;
rowsData: Uint8Array;
}
export interface PrintProgressEvent {
page: number;
pagePrintProgress: number;
pageFeedProgress: number;
}
export interface FirmwareProgressEvent {
currentChunk: number;
totalChunks: number;
}
export interface Packet {
command: number;
toBytes(): Uint8Array;
}
export const printTaskNames = ["B1", "D110M_V4"] as const;
export type PrintTaskName = (typeof printTaskNames)[number];

View File

@@ -0,0 +1,18 @@
import type { AvailableTransports } from "./types";
export class Utils {
static bufToHex(buf: Uint8Array | number[], separator: string = " "): string {
const arr = buf instanceof Uint8Array ? Array.from(buf) : buf;
return arr.map((b) => b.toString(16).padStart(2, "0")).join(separator);
}
static sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
static getAvailableTransports(): AvailableTransports {
return {
webBluetooth: typeof navigator !== "undefined" && "bluetooth" in navigator,
};
}
}

61
web/src/lib/fichero/web-bluetooth.d.ts vendored Normal file
View File

@@ -0,0 +1,61 @@
interface BluetoothRequestDeviceFilter {
services?: BluetoothServiceUUID[];
name?: string;
namePrefix?: string;
}
interface RequestDeviceOptions {
filters?: BluetoothRequestDeviceFilter[];
optionalServices?: BluetoothServiceUUID[];
acceptAllDevices?: boolean;
}
type BluetoothServiceUUID = number | string;
type BluetoothCharacteristicUUID = number | string;
interface BluetoothRemoteGATTServer {
connected: boolean;
device: BluetoothDevice;
connect(): Promise<BluetoothRemoteGATTServer>;
disconnect(): void;
getPrimaryService(service: BluetoothServiceUUID): Promise<BluetoothRemoteGATTService>;
}
interface BluetoothRemoteGATTService {
device: BluetoothDevice;
uuid: string;
getCharacteristic(
characteristic: BluetoothCharacteristicUUID,
): Promise<BluetoothRemoteGATTCharacteristic>;
}
interface BluetoothRemoteGATTCharacteristic extends EventTarget {
service: BluetoothRemoteGATTService;
uuid: string;
value: DataView | null;
readValue(): Promise<DataView>;
writeValue(value: Uint8Array | ArrayBuffer): Promise<void>;
writeValueWithoutResponse(value: Uint8Array | ArrayBuffer): Promise<void>;
writeValueWithResponse(value: Uint8Array | ArrayBuffer): Promise<void>;
startNotifications(): Promise<BluetoothRemoteGATTCharacteristic>;
stopNotifications(): Promise<BluetoothRemoteGATTCharacteristic>;
addEventListener(type: "characteristicvaluechanged", listener: (event: Event) => void): void;
removeEventListener(type: "characteristicvaluechanged", listener: (event: Event) => void): void;
}
interface BluetoothDevice extends EventTarget {
id: string;
name?: string;
gatt?: BluetoothRemoteGATTServer;
addEventListener(type: "gattserverdisconnected", listener: (event: Event) => void): void;
removeEventListener(type: "gattserverdisconnected", listener: (event: Event) => void): void;
}
interface Bluetooth {
getAvailability(): Promise<boolean>;
requestDevice(options?: RequestDeviceOptions): Promise<BluetoothDevice>;
}
interface Navigator {
bluetooth: Bluetooth;
}

View File

@@ -0,0 +1,144 @@
{
"lang.name": "العربية",
"browser_warning.fingerprinting": "تنبيه! يبدو أن متصفحك يُشوّه الصورة للحماية من التعقب. يُرجى تعطيل هذه الحماية، لأنها قد تُسبب تشوهات في الملصقات.",
"browser_warning.lines.first": "لا لا! متصفحك لا يدعم البلوتوث والاتصالات التسلسلية.",
"browser_warning.lines.second": "على أي حال، لا زال بإمكانك رسم ملصقات.",
"connector.bluetooth": "بلوتوث",
"connector.disconnect.heartbeat": "انقطع الاتصال (الطابعة لا تستجيب)",
"connector.serial": "تسلسلي (USB)",
"editor.clear": "مسح اللوحة",
"editor.clear.confirm": "هل ترغب بمسح محتويات اللوحة؟",
"editor.clone": "نسخة مطابقة",
"editor.default_text": "النص",
"editor.delete": "إزالة",
"editor.iconpicker.mdi_link_title": "انظر لقائمة مفصلة هنا",
"editor.iconpicker.search": "بحث",
"editor.iconpicker.title": "أضف أيقونة",
"editor.import.zpl": "استيراد ZPL",
"editor.objectpicker.barcode": "باركود",
"editor.objectpicker.circle": "دائرة",
"editor.objectpicker.image": "صورة",
"editor.objectpicker.line": "خط",
"editor.objectpicker.qrcode": "QR Code",
"editor.objectpicker.rectangle": "مستطيل",
"editor.objectpicker.text": "نص",
"editor.objectpicker.title": "أضف عنصر",
"editor.preview": "عرض",
"editor.print": "طباعة",
"editor.redo": "إعادة",
"editor.undo": "تراجع",
"editor.warning.load": "سيتم استبدال محتوى اللوحة بالمحفوظات",
"editor.warning.save": "سيتم استبدال المحفوظات. حفظ؟",
"params.barcode.content": "المحتوى",
"params.barcode.enable_caption": "تفعيل الأحرف الكبيرة",
"params.barcode.encoding": "الترميز",
"params.barcode.font_size": "حجم الخط",
"params.barcode.scale": "نسبة التكبير",
"params.csv.enabled": "تمكين",
"params.csv.placeholders": "متغيرات:",
"params.csv.rowsfound": "صفوف البيانات الموجودة:",
"params.csv.tip": "الصف الأول هو العنوان. يستخدم كعنوان لقائمة متغيرات. الفواصل تستخدم للفصل.",
"params.csv.title": "بيانات ملصق متغيرة ( ملف CSV)",
"params.generic.arrange": "ترتيب",
"params.generic.arrange.top": "انقل للمقدمة",
"params.generic.arrange.bottom": "انقل للخلف",
"params.generic.center.horizontal": "توسيط افقي",
"params.generic.center.vertical": "توسيط عمودي",
"params.generic.fit": "بحجم الصفحة",
"params.generic.fit.mode.stretch": "تمديد",
"params.generic.fit.mode.ratio_min": "ملائمة",
"params.generic.fit.mode.ratio_max": "إملأ",
"params.generic.position": "تموضع",
"params.label.apply": "تطبيق",
"params.label.current": "المقاييس الحالية:",
"params.label.direction.left": "يسار",
"params.label.direction.top": "أعلى",
"params.label.direction": "اتجاه الطباعة",
"params.label.dpmm": "الدقة (نقطة لكل ملم)",
"params.label.export": "تصدير",
"params.label.head_density.help": "حسبة: DPI/ 25.4",
"params.label.head_density": "كثافة النقاط",
"params.label.import": "استيراد",
"params.label.label_title": "عنوان معدل",
"params.label.menu_title": "خصائص الملصق",
"params.label.mm": "ملم",
"params.label.px": "بكسل",
"params.label.save_template": "حفظ كقالب",
"params.label.shape": "شكل الملصق",
"params.label.size": "الحجم",
"params.label.split": "تقسيم الملصق",
"params.label.split.count": "أجزاء التقسيم",
"params.label.tail.length": "طول الذيل",
"params.label.tail.position": "موقع الذيل",
"params.label.mirror": "صورة معكوسة للعنصر",
"params.label.warning.direction": "الاتجاه الموصى به لطابعتك:",
"params.label.warning.import": "سيتم استبدال جميع القوالب",
"params.label.warning.width": "عرض الملصق كبير جداً لطابعتك:",
"params.label.warning.div8": "عرض الملصق بالبيكسل يجب أن يكون عدد زوجي بالتقسيم على 8",
"params.qrcode.ecl": "خطأ في مستوى التصحيح",
"params.qrcode.mode": "نمط",
"params.qrcode.version": "نسخة",
"params.saved_labels.kb_used": "الكيلوبايت المستخدم",
"params.saved_labels.label_title.placeholder": "(اختياري)",
"params.saved_labels.label_title": "عنوان",
"params.saved_labels.load.browser": "تحميل",
"params.saved_labels.load.json": "استيراد",
"params.saved_labels.make_default": "تعيين كافتراضي",
"params.saved_labels.menu_title": "احفظ/حمل (تخزين المتصفح)",
"params.saved_labels.save.browser.replace": "احفظ (بدل)",
"params.saved_labels.save.browser": "احفظ",
"params.saved_labels.save.json": "تصدير",
"params.text.align.center": "محاذاة النص: توسيط",
"params.text.align.left": "محاذاة النص: يسار",
"params.text.align.right": "محاذاة النص: يمين",
"params.text.bold": "عريض",
"params.text.edit.title": "تحرير النص",
"params.text.edit": "تحرير في نافذة منبثقة",
"params.text.fetch_fonts": "جلب خطوط",
"params.text.font_family": "عائلة الخط",
"params.text.font_size.down": "تقليل حجم الخط",
"params.text.font_size.up": "زيادة حجم الخط",
"params.text.font_size": "حجم الخط",
"params.text.italic": "مائل",
"params.text.line_height": "ارتفاع الخط",
"params.params.text.split": "تقسيم النص (حزم)",
"params.params.text.split.spaces": "مسافات",
"params.params.text.split.grapheme": "قرافيم (رموز)",
"params.text.vorigin.bottom": "أسفل",
"params.text.vorigin.center": "وسط",
"params.text.vorigin.top": "أعلى",
"params.text.vorigin": "الأصل عمودي",
"params.variables.insert.date": "التاريخ",
"params.variables.insert.datetime": "التاريخ والوقت",
"params.variables.insert.time": "الوقت",
"params.variables.insert": "ادخل متغير",
"params.vector.round_radius": "محور دائري",
"params.vector.stroke_width": "عرض الخط الوسطي",
"params.vector.fill": "ملء",
"params.color": "لون",
"params.color.transparent": "شفاف",
"params.color.white": "ابيض",
"params.color.black": "اسود",
"preview.close": "اغلاق",
"preview.copies": "عدد النسخ",
"preview.density": "الكثافة",
"preview.label_type.Black": "اسود",
"preview.label_type.BlackMarkGap": "فواصل علامات سوداء",
"preview.label_type.Continuous": "مستمر",
"preview.label_type.HeatShrinkTube": "اوعية تقلص بالتسخين",
"preview.label_type.Invalid": "غير صحيح",
"preview.label_type.Perforated": "مثقب",
"preview.label_type.PvcTag": "علامة PVC",
"preview.label_type.Transparent": "شفاف",
"preview.label_type.WithGaps": "بفواصل",
"preview.label_type": "نوع الملصق",
"preview.not_connected": "الطابعة غير متصلة",
"preview.offset.inner": "داخلي",
"preview.offset.outer": "خارجي",
"preview.postprocess": "معالجة مؤجلة",
"preview.print_task": "بروتوكول الطباعة",
"preview.print.cancel": "الغاء الطباعة",
"preview.print.system": "الطباعة باستخدام طابعة النظام",
"preview.print": "طباعة",
"preview.title": "عرض الطباعة"
}

View File

@@ -0,0 +1,156 @@
{
"lang.name": "Čeština",
"browser_warning.lines.first": "Aj, váš prohlížeč nepodporuje bluetooth a sériovou komunikaci",
"browser_warning.lines.second": "I tak můžete upravovat štítky.",
"connector.bluetooth": "Bluetooth",
"connector.disconnect.heartbeat": "Odpojeno (tiskárna neodpovídá)",
"connector.serial": "Sériové (USB)",
"editor.clone": "Klonovat",
"editor.default_text": "Text",
"editor.delete": "Smazat",
"editor.iconpicker.mdi_link_title": "Podrobný seznam",
"editor.iconpicker.search": "Hledat",
"editor.iconpicker.title": "Přidat ikonu",
"editor.import.zpl": "Import ZPL",
"editor.objectpicker.barcode": "Čárový kód",
"editor.objectpicker.circle": "Kruh",
"editor.objectpicker.image": "Obrázek",
"editor.objectpicker.line": "Čára",
"editor.objectpicker.qrcode": "QR Kód",
"editor.objectpicker.rectangle": "Obdélník",
"editor.objectpicker.text": "Text",
"editor.objectpicker.title": "Přidat objekt",
"editor.preview": "Náhled",
"editor.print": "Tisk",
"editor.redo": "Zpět",
"editor.undo": "Znovu",
"editor.warning.load": "Plátno bude přepsáno uloženými daty",
"editor.warning.save": "Uložená data budou přepsána. Uložit?",
"main.built": "sestaveno",
"main.code": "Kód",
"params.barcode.content": "Obsah",
"params.barcode.enable_caption": "Povolit titulek",
"params.barcode.encoding": "Kódování",
"params.barcode.font_size": "Velikost fontu",
"params.barcode.scale": "Škálování",
"params.csv.enabled": "Povoleno",
"params.csv.placeholders": "Proměnné:",
"params.csv.rowsfound": "Nalezeno záznamů:",
"params.csv.tip": "První řádek je hlavička. Je použit jako názvy proměnných. Jako oddělovače slouží čárky.",
"params.csv.title": "Dynamická data štítku (CSV)",
"params.generic.center.horizontal": "Vystředit vodorovně",
"params.generic.center.vertical": "Vystředit svisle",
"params.generic.fit": "Přizpůsobit stránce",
"params.generic.fit.mode.stretch": "Roztáhnout",
"params.generic.fit.mode.ratio_min": "Zmenšit",
"params.generic.fit.mode.ratio_max": "Vyplnit",
"params.label.apply": "Použít",
"params.label.current": "Aktuální nastavení:",
"params.label.direction.left": "Zleva",
"params.label.direction.top": "Shora",
"params.label.direction": "Směr tisku",
"params.label.dpmm": "px/mm",
"params.label.export": "Export",
"params.label.head_density.help": "Výpočet: DPI / 25.4",
"params.label.head_density": "Rozlišení",
"params.label.import": "Import",
"params.label.label_title": "Vlastní titulek",
"params.label.menu_title": "Vlastnosti štítku",
"params.label.mm": "mm",
"params.label.px": "px",
"params.label.save_template": "Uložit jako šablonu",
"params.label.shape": "Tvar štítku",
"params.label.size": "Velikost",
"params.label.split": "Rozdělení štítku",
"params.label.tail.length": "Délka ocásku",
"params.label.tail.position": "Umístění ocásku",
"params.label.mirror": "Zrcadlení objektu",
"params.label.warning.direction": "Doporučený směr pro vaši tiskárnu:",
"params.label.warning.import": "Všechny uložené šablony budou přepsány",
"params.label.warning.width": "Šířka štítku je příliš velká pro vaši tiskárnu:",
"params.qrcode.ecl": "Úroveň opravy chyb",
"params.saved_labels.kb_used": "kB využito",
"params.saved_labels.label_title.placeholder": "(volitelný)",
"params.saved_labels.label_title": "Titulek",
"params.saved_labels.load.browser": "Načíst",
"params.saved_labels.load.json": "Importovat",
"params.saved_labels.make_default": "Nastavit jako výchozí",
"params.saved_labels.menu_title": "Uložit/načíst (úložiště prohlížeče)",
"params.saved_labels.save.browser.replace": "Uložit (nahradit)",
"params.saved_labels.save.browser": "Uložit",
"params.saved_labels.save.json": "Exportovat",
"params.text.align.center": "Zarovnání textu: Na střed",
"params.text.align.left": "Zarovnání textu: Vlevo",
"params.text.align.right": "Zarovnání textu: Vpravo",
"params.text.bold": "Tučné",
"params.text.edit.title": "Úprava textu",
"params.text.edit": "Upravit v okně",
"params.text.fetch_fonts": "Načíst fonty",
"params.text.font_family": "Rodina písma",
"params.text.font_size.down": "Zmenšit písmo",
"params.text.font_size.up": "Zvětšit písmo",
"params.text.font_size": "Velikost písma",
"params.text.line_height": "Výška řádku",
"params.text.vorigin.bottom": "Spodek",
"params.text.vorigin.center": "Střed",
"params.text.vorigin.top": "Vršek",
"params.text.vorigin": "Svislý počátek",
"params.variables.insert.date": "Datum",
"params.variables.insert.datetime": "Datum/čas",
"params.variables.insert.time": "Čas",
"params.variables.insert": "Vložit proměnnou",
"preview.close": "Zavřít",
"preview.copies": "Kopie",
"preview.density": "Hustota",
"preview.label_type.Black": "Černá",
"preview.label_type.BlackMarkGap": "Mezery s černou značkou",
"preview.label_type.Continuous": "Průběžný",
"preview.label_type.HeatShrinkTube": "Smršťovací trubička",
"preview.label_type.Invalid": "Neplatný",
"preview.label_type.Perforated": "Perforovaný",
"preview.label_type.PvcTag": "PVC Štítek",
"preview.label_type.Transparent": "Průhledný",
"preview.label_type.WithGaps": "S mezerami",
"preview.label_type": "Typ štítku",
"preview.not_connected": "Tiskárna není připojena",
"preview.not_implemented": "NEIMPLEMENTOVÁNO",
"preview.offset.inner": "Vnitřní",
"preview.offset.outer": "Vnější",
"preview.offset": "Offset",
"preview.postprocess.atkinson": "Prokládání (Atkinson)",
"preview.postprocess.threshold": "Prahování",
"preview.postprocess": "Post-process",
"preview.print_task": "Vytisknout úlohu",
"preview.print.cancel": "Zrušit tisk",
"preview.print.system": "Vytisknout systémovou tiskárnou",
"preview.print": "Tisknout",
"preview.threshold": "Práh",
"preview.title": "Náhled",
"editor.clear.confirm": "Opravdu vymazat obsah?",
"editor.clear": "Vymazat obsah",
"params.vector.round_radius": "Poloměr zaoblení",
"params.vector.stroke_width": "Tloušťka tahu",
"params.label.split.count": "Rozdělit části",
"params.vector.fill": "Výplň",
"params.color.transparent": "průhledná",
"params.color.white": "bílá",
"params.color.black": "černá",
"params.generic.arrange": "Uspořádat",
"params.generic.arrange.top": "Přesunout do popředí",
"params.generic.arrange.bottom": "Přesunout do pozadí",
"params.color": "Barva",
"params.label.warning.div8": "Šířka štítku musí být dělitelná 8",
"params.text.italic": "Kurzíva",
"params.generic.position": "Pozice",
"browser_warning.fingerprinting": "Ups! Vypadá to, že váš prohlížeč deformuje vykreslování jako ochranu proti snímání. Prosím vypněte tuto ochranu, může způsobovat artefakty na štítcích.",
"params.qrcode.mode": "Režim",
"params.qrcode.version": "Verze",
"params.params.text.split": "Dělení textu (zalamování)",
"params.params.text.split.spaces": "Mezery",
"params.params.text.split.grapheme": "Znaky",
"params.saved_labels.save.url": "Zkopírovat odkaz",
"params.saved_labels.load.url.loaded": "Štítek byl načten z URL",
"params.saved_labels.save.url.copied": "URL štítku byla zkopírována do schránky",
"params.saved_labels.save.url.warn": "Data štítku jsou příliš velká a URL by nemusela fungovat ve všech prohlížečích.\nPokuste se zmenšit velikost štítku (například odstraněním obrázků).\nPokračovat?",
"params.saved_labels.load.url.warn": "Načíst štítek z URL? Tady budou draci"
}

View File

@@ -0,0 +1,167 @@
{
"lang.name": "Deutsch",
"browser_warning.lines.first": "Oh nein, Dein Browser unterstützt kein Bluetooth und keine serielle Schnittstelle",
"browser_warning.lines.second": "Du kannst trotzdem Etiketten erstellen.",
"connector.bluetooth": "Bluetooth",
"connector.disconnect.heartbeat": "Getrennt (Drucker reagiert nicht)",
"connector.serial": "Seriell (USB)",
"editor.clone": "Klonen",
"editor.default_text": "Text",
"editor.delete": "Löschen",
"editor.iconpicker.mdi_link_title": "Siehe detaillierte Liste hier",
"editor.iconpicker.search": "Suchen",
"editor.iconpicker.title": "Icon hinzufügen",
"editor.import.zpl": "ZPL Importieren",
"editor.objectpicker.barcode": "Barcode",
"editor.objectpicker.circle": "Kreis",
"editor.objectpicker.image": "Grafik",
"editor.objectpicker.line": "Linie",
"editor.objectpicker.qrcode": "QR-Code",
"editor.objectpicker.rectangle": "Rechteck",
"editor.objectpicker.text": "Text",
"editor.objectpicker.title": "Objekt hinzufügen",
"editor.preview": "Vorschau",
"editor.print": "Drucken",
"editor.redo": "Wiederholen",
"editor.undo": "Rückgängig machen",
"editor.warning.load": "Zeichenfläche wird durch gespeicherte Daten ersetzt",
"editor.warning.save": "Gespeicherte Daten werden überschrieben. Speichern?",
"main.built": "erstellt am",
"main.code": "Quellcode",
"params.barcode.content": "Inhalt",
"params.barcode.enable_caption": "Beschriftung aktivieren",
"params.barcode.encoding": "Kodierung",
"params.barcode.font_size": "Schriftgröße",
"params.barcode.scale": "Skalierungsfaktor",
"params.csv.enabled": "Aktiviert",
"params.csv.placeholders": "Variablen:",
"params.csv.rowsfound": "Gefundene Zeilen:",
"params.csv.tip": "Die erste Zeile ist eine Kopfzeile. Sie wird als Variablenname verwendet. Kommas werden als Trennzeichen verwendet.",
"params.csv.title": "Dynamische Etikettendaten (CSV)",
"params.generic.center.horizontal": "Horizontal zentrieren",
"params.generic.center.vertical": "Vertikal zentrieren",
"params.label.apply": "Anwenden",
"params.label.current": "Aktuelle Parameter:",
"params.label.direction.left": "Links",
"params.label.direction.top": "Oben",
"params.label.direction": "Druckausrichtung",
"params.label.dpmm": "dpmm",
"params.label.export": "Export",
"params.label.head_density.help": "Kalkulation: DPI / 25.4",
"params.label.head_density": "Pixeldichte",
"params.label.import": "Import",
"params.label.label_title": "Benutzerdefinierter Titel",
"params.label.menu_title": "Label Eigenschaften",
"params.label.mm": "mm",
"params.label.px": "px",
"params.label.save_template": "Als Vorlage speichern",
"params.label.shape": "Etikettenform",
"params.label.size": "Größe",
"params.label.split": "Geteiltes Etikett",
"params.label.tail.length": "Länge der Teilung",
"params.label.tail.position": "Position der Teilung",
"params.label.warning.direction": "Empfohlene Druckrichtung:",
"params.label.warning.import": "Alle gespeicherten Vorlagen werden überschrieben",
"params.label.warning.width": "Die Etikettenbreite ist zu groß für den Drucker:",
"params.qrcode.ecl": "Fehlerkorrekturstufe",
"params.saved_labels.kb_used": "kB benutzt",
"params.saved_labels.label_title.placeholder": "(optional)",
"params.saved_labels.label_title": "Titel",
"params.saved_labels.load.browser": "Laden",
"params.saved_labels.load.json": "Import",
"params.saved_labels.make_default": "Standard machen",
"params.saved_labels.menu_title": "Speichern/Laden (Browserspeicher)",
"params.saved_labels.save.browser.replace": "Speichern (Ersetzen)",
"params.saved_labels.save.browser": "Speichern",
"params.saved_labels.save.json": "Export",
"params.text.align.center": "Text ausrichten: Zentriert",
"params.text.align.left": "Text ausrichten: Links",
"params.text.align.right": "Text ausrichten: Rechts",
"params.text.bold": "Fett",
"params.text.edit.title": "Text bearbeiten",
"params.text.edit": "Im Popup bearbeiten",
"params.text.fetch_fonts": "Schriftarten laden",
"params.text.font_family": "Schriftart",
"params.text.font_size.down": "Schriftgröße verringern",
"params.text.font_size.up": "Schriftgröße erhöhen",
"params.text.font_size": "Schriftgröße",
"params.text.line_height": "Linienhöhe",
"params.text.vorigin.bottom": "Unten",
"params.text.vorigin.center": "Zentriert",
"params.text.vorigin.top": "Oben",
"params.text.vorigin": "Vertikaler Ursprung",
"params.variables.insert.date": "Datum",
"params.variables.insert.datetime": "Datum/Zeit",
"params.variables.insert.time": "Zeit",
"params.variables.insert": "Variable einfügen",
"preview.close": "Schließen",
"preview.copies": "Kopien",
"preview.density": "Dichte",
"preview.label_type.Black": "Schwarz",
"preview.label_type.BlackMarkGap": "Schwarze Markierungslücke",
"preview.label_type.Continuous": "Fortführend",
"preview.label_type.HeatShrinkTube": "Schrumpfschlauch",
"preview.label_type.Invalid": "Ungültig",
"preview.label_type.Perforated": "Perforiert",
"preview.label_type.PvcTag": "PVC-Anhänger",
"preview.label_type.Transparent": "Transparent",
"preview.label_type.WithGaps": "Mit Lücken",
"preview.label_type": "Etikettenpapier",
"preview.not_connected": "Drucker ist nicht verbunden",
"preview.not_implemented": "NICHT IMPLEMENTIERT",
"preview.offset.inner": "Innen",
"preview.offset.outer": "Außen",
"preview.offset": "Versatz",
"preview.postprocess.atkinson": "Rastern (Atkinson)",
"preview.postprocess.threshold": "Schwellenwert",
"preview.postprocess": "Nachbearbeitung",
"preview.print_task": "Drucker",
"preview.print.cancel": "Druck abbrechen",
"preview.print": "Drucken",
"preview.threshold": "Schwellenwert",
"preview.title": "Druckvorschau",
"params.label.mirror": "Objektspiegelung",
"params.generic.fit.mode.stretch": "Strecken",
"params.generic.fit.mode.ratio_max": "Füllen",
"params.generic.fit.mode.ratio_min": "Anpassen",
"params.generic.fit": "An Seite anpassen",
"preview.print.system": "Drucken mit Betriebssystem Drucker",
"params.color": "Farbe",
"params.color.transparent": "transparent",
"params.color.white": "weiß",
"params.qrcode.version": "Version",
"params.color.black": "schwarz",
"params.label.warning.div8": "Etikettenbreite in Pixeln muss ein Vielfaches von 8 sein",
"params.text.italic": "Kursiv",
"params.params.text.split.spaces": "Leerzeichen",
"params.vector.fill": "Füllung",
"params.generic.position": "Position",
"params.qrcode.mode": "Modus",
"browser_warning.fingerprinting": "Oh nein! Sieht aus, als würde Dein Browser das Canvas-Element verzerren, um Dich vor Fingerprinting zu schützen. Bitte deaktiviere diesen Schutz, da er zu Artefakten auf den Etiketten führen kann.",
"editor.clear": "Arbeitsbereich zurücksetzen",
"editor.clear.confirm": "Inhalte des Arbeitsbereichs löschen?",
"params.generic.arrange": "Ausrichten",
"params.generic.arrange.top": "Nach vorne verschieben",
"params.generic.arrange.bottom": "In den Hintergrund verschieben",
"params.vector.round_radius": "Rundungsradius",
"params.vector.stroke_width": "Linienbreite",
"params.params.text.split": "Zeilenumbruch",
"debug.page_delay.help": "Fügen Sie eine Verzögerung hinzu, wenn Sie Probleme beim Drucken mehrseitiger Seiten haben",
"editor.iconpicker.add": "Neu hinzufügen",
"editor.iconpicker.delete_mode": "Löschmodus",
"editor.iconpicker.show": "Anzeigen",
"editor.iconpicker.show.user": "nur eigene Icons",
"editor.iconpicker.show.pack": "nur Paket-Icons",
"editor.iconpicker.show.both": "eigene + Paket-Icons",
"editor.warning.load.csv": "CSV-Daten werden auch ersetzt",
"params.label.split.count": "Einzelteile",
"params.saved_labels.save.withcsv": "Dynamische Daten (CSV) sind aktiviert und werden mit dem Etikett gespeichert",
"params.saved_labels.save.url": "Link kopieren",
"params.saved_labels.load.url.warn": "Etikett von URL laden? Dies kann gefährlich sein.",
"params.saved_labels.load.url.loaded": "Etikett von URL geladen",
"params.saved_labels.save.url.copied": "URL des Etikett in Zwischenablage kopiert",
"params.saved_labels.save.url.warn": "Die Etiketten-Daten sind sehr groß und die URL funktioniert eventuell nicht in allen Browser.\nVersuchen Sie, die Daten-Größe zu reduzieren (z.B. Bilder löschen).\nFortfahren?",
"params.text.autosize": "Textgröße an Feldbreite anpassen",
"params.params.text.split.grapheme": "Graphem (Zeichen)",
"preview.postprocess.bayer": "Rastern (Bayer)"
}

View File

@@ -0,0 +1,180 @@
{
"lang.name": "English",
"browser_warning.fingerprinting": "Whoa! It looks like your browser is distorting the canvas to protect against fingerprinting. Please disable this protection, as it can cause artifacts on labels.",
"browser_warning.lines.first": "Your browser does not support Web Bluetooth. Use Chrome, Edge, or Opera to connect to your printer.",
"browser_warning.lines.second": "You can still design labels without connecting.",
"connector.bluetooth": "Bluetooth",
"connector.disconnect.heartbeat": "Disconnected (printer does not respond)",
"connector.serial": "Serial (USB)",
"debug.page_delay.help": "Add delay (1000, for example) if you have multi-page print problems.",
"debug.packet_interval.help": "Override the packet interval. A page reload is required. Something can break if the value is too low.",
"debug.title": "Debug stuff",
"debug.reset": "Reset",
"editor.clear": "Clear canvas",
"editor.clear.confirm": "Clear canvas contents?",
"editor.clone": "Clone",
"editor.default_text": "Text",
"editor.delete": "Delete",
"editor.iconpicker.add": "Add new",
"editor.iconpicker.delete_mode": "Delete mode",
"editor.iconpicker.search": "Search",
"editor.iconpicker.show": "Show",
"editor.iconpicker.show.user": "user icons only",
"editor.iconpicker.show.pack": "pack icons only",
"editor.iconpicker.show.both": "user + pack icons",
"editor.iconpicker.mdi_link_title": "See detailed list here",
"editor.iconpicker.title": "Add icon",
"editor.import.zpl": "Import ZPL",
"editor.objectpicker.barcode": "Barcode",
"editor.objectpicker.circle": "Circle",
"editor.objectpicker.image": "Image",
"editor.objectpicker.line": "Line",
"editor.objectpicker.qrcode": "QR Code",
"editor.objectpicker.rectangle": "Rectangle",
"editor.objectpicker.text": "Text",
"editor.objectpicker.title": "Add object",
"editor.preview": "Preview",
"editor.print": "Print",
"editor.redo": "Redo",
"editor.undo": "Undo",
"editor.warning.load": "Canvas will be replaced with saved data",
"editor.warning.load.csv": "CSV data will also be replaced",
"editor.warning.save": "Saved data will be overwritten. Save?",
"main.built": "built at",
"main.code": "Code",
"params.barcode.content": "Content",
"params.barcode.enable_caption": "Enable caption",
"params.barcode.encoding": "Encoding",
"params.barcode.font_size": "Font size",
"params.barcode.scale": "Scale factor",
"params.csv.enabled": "Enabled",
"params.csv.placeholders": "Variables:",
"params.csv.rowsfound": "Data rows found:",
"params.csv.tip": "First row is a header. It used as variable names. Commas are used as separators.",
"params.csv.title": "Dynamic label data (CSV)",
"params.generic.arrange": "Arrange",
"params.generic.arrange.top": "Bring to front",
"params.generic.arrange.bottom": "Send to back",
"params.generic.center.horizontal": "Center horizontally",
"params.generic.center.vertical": "Center vertically",
"params.generic.fit": "Fit to page",
"params.generic.fit.mode.stretch": "Stretch",
"params.generic.fit.mode.ratio_min": "Fit",
"params.generic.fit.mode.ratio_max": "Fill",
"params.generic.position": "Position",
"params.label.apply": "Apply",
"params.label.current": "Current parameters:",
"params.label.direction.left": "Left",
"params.label.direction.top": "Top",
"params.label.direction": "Print direction",
"params.label.dpmm": "dpmm",
"params.label.export": "Export",
"params.label.head_density.help": "Calculation: DPI / 25.4",
"params.label.head_density": "Pixel density",
"params.label.import": "Import",
"params.label.label_title": "Custom title",
"params.label.menu_title": "Label properties",
"params.label.mm": "mm",
"params.label.px": "px",
"params.label.save_template": "Save as template",
"params.label.shape": "Label shape",
"params.label.size": "Size",
"params.label.split": "Label split",
"params.label.split.count": "Split parts",
"params.label.tail.length": "Tail length",
"params.label.tail.position": "Tail position",
"params.label.mirror": "Object mirroring",
"params.label.warning.direction": "Recommended direction for your printer:",
"params.label.warning.import": "All saved templates will be overwritten",
"params.label.warning.width": "Label width is too big for your printer:",
"params.label.warning.div8": "Label width in pixels must be evenly divisible by 8",
"params.qrcode.ecl": "Error Correction Level",
"params.qrcode.mode": "Mode",
"params.qrcode.version": "Version",
"params.saved_labels.kb_used": "kB used",
"params.saved_labels.label_title.placeholder": "(optional)",
"params.saved_labels.label_title": "Title",
"params.saved_labels.load.browser": "Load",
"params.saved_labels.load.json": "Import",
"params.saved_labels.make_default": "Make default",
"params.saved_labels.menu_title": "Save/load (browser storage)",
"params.saved_labels.save.withcsv": "Dynamic data (CSV) is enabled so it will be included in the saved label",
"params.saved_labels.save.browser.replace": "Save (replace)",
"params.saved_labels.save.browser": "Save",
"params.saved_labels.save.json": "Export",
"params.saved_labels.save.url": "Copy link",
"params.saved_labels.load.url.warn": "Load label from URL? Here be dragons.",
"params.saved_labels.load.url.loaded": "Label loaded from URL",
"params.saved_labels.save.url.copied": "Label URL copied to clipboard",
"params.saved_labels.save.url.warn": "Label data is quite large and URL may not work in all browsers.\nTry to reduce label size (remove images, for example).\nProceed?",
"params.text.align.center": "Align text: Center",
"params.text.align.left": "Align text: Left",
"params.text.align.right": "Align text: Right",
"params.text.autosize": "Adjust text size to fit field width",
"params.text.bold": "Bold",
"params.text.edit.title": "Editing text",
"params.text.edit": "Edit in popup",
"params.text.fetch_fonts": "Fetch fonts",
"params.text.font_family": "Font family",
"params.text.user_fonts": "User fonts",
"params.text.system_fonts": "System fonts",
"params.text.font_size.down": "Decrease font size",
"params.text.font_size.up": "Increase font size",
"params.text.font_size": "Font size",
"params.text.italic": "Italic",
"params.text.line_height": "Line height",
"params.params.text.split": "Text split (wrap)",
"params.params.text.split.spaces": "Spaces",
"params.params.text.split.grapheme": "Grapheme (characters)",
"params.text.vorigin.bottom": "Bottom",
"params.text.vorigin.center": "Center",
"params.text.vorigin.top": "Top",
"params.text.vorigin": "Vertical Origin",
"params.variables.insert.date": "Date",
"params.variables.insert.datetime": "Datetime",
"params.variables.insert.time": "Time",
"params.variables.insert": "Insert variable",
"params.vector.round_radius": "Round radius",
"params.vector.stroke_width": "Stroke width",
"params.vector.fill": "Fill",
"params.color": "Color",
"params.color.transparent": "transparent",
"params.color.white": "white",
"params.color.black": "black",
"preview.close": "Close",
"preview.copies": "Copies",
"preview.density": "Density",
"preview.speed": "Print speed",
"preview.speed.0": "Low (higher quality)",
"preview.speed.1": "Normal",
"preview.label_type.Black": "Black",
"preview.label_type.BlackMarkGap": "Black mark gap",
"preview.label_type.Continuous": "Continuous",
"preview.label_type.HeatShrinkTube": "Heat shrink tube",
"preview.label_type.Invalid": "Invalid",
"preview.label_type.Perforated": "Perforated",
"preview.label_type.PvcTag": "PVC Tag",
"preview.label_type.Transparent": "Transparent",
"preview.label_type.WithGaps": "WithGaps",
"preview.label_type": "Label type",
"preview.not_connected": "Printer is not connected",
"preview.not_implemented": "NOT IMPLEMENTED",
"preview.offset.inner": "Inner",
"preview.offset.outer": "Outer",
"preview.offset": "Offset",
"preview.postprocess.atkinson": "Dither (Atkinson)",
"preview.postprocess.bayer": "Dither (Bayer)",
"preview.postprocess.threshold": "Threshold",
"preview.postprocess": "Post-process",
"preview.print_task": "Print task",
"preview.print.cancel": "Cancel print",
"preview.print.system": "Print with OS printer",
"preview.print": "Print",
"preview.threshold": "Threshold",
"preview.title": "Print preview",
"fonts.title": "Custom fonts",
"fonts.add": "Add",
"fonts.browse": "Browse...",
"fonts.gfonts": "Get fonts",
"fonts.title_override": "Override name"
}

View File

@@ -0,0 +1,166 @@
{
"lang.name": "Español",
"browser_warning.fingerprinting": "¡Vaya! Parece que tu navegador está distorsionando el lienzo para protegerte del fingerprinting. Por favor, desactiva esta protección, ya que puede provocar artefactos en las etiquetas.",
"browser_warning.lines.first": "Oh no, tu navegador no admite comunicaciones Bluetooth ni serie",
"browser_warning.lines.second": "En cualquier caso, aún puedes dibujar etiquetas.",
"connector.bluetooth": "Bluetooth",
"connector.disconnect.heartbeat": "Desconectado (la impresora no responde)",
"connector.serial": "Serie (USB)",
"editor.clear": "Limpiar lienzo",
"editor.clear.confirm": "¿Limpiar el contenido del lienzo?",
"editor.clone": "Clonar",
"editor.default_text": "Texto",
"editor.delete": "Borrar",
"editor.iconpicker.mdi_link_title": "Ver la lista detallada aquí",
"editor.iconpicker.search": "Buscar",
"editor.iconpicker.title": "Añadir icono",
"editor.import.zpl": "Importar ZPL",
"editor.objectpicker.barcode": "Código de barras",
"editor.objectpicker.circle": "Círculo",
"editor.objectpicker.image": "Imagen",
"editor.objectpicker.line": "Línea",
"editor.objectpicker.qrcode": "Código QR",
"editor.objectpicker.rectangle": "Rectángulo",
"editor.objectpicker.text": "Texto",
"editor.objectpicker.title": "Añadir objeto",
"editor.preview": "Previsualizar",
"editor.print": "Imprimir",
"editor.redo": "Rehacer",
"editor.undo": "Deshacer",
"editor.warning.load": "El lienzo se va a reemplazar con datos guardados",
"editor.warning.save": "Los datos guardados se van a sobreescribir. ¿Guardar?",
"main.built": "compilado el",
"main.code": "Código",
"params.barcode.content": "Contenido",
"params.barcode.enable_caption": "Activar leyenda",
"params.barcode.encoding": "Codificación",
"params.barcode.font_size": "Tamaño de fuente",
"params.barcode.scale": "Factor de escala",
"params.csv.enabled": "Activado",
"params.csv.placeholders": "Variables:",
"params.csv.rowsfound": "Filas de datos encontradas:",
"params.csv.tip": "La primera fila es un encabezado. Se utiliza como nombres de variables. Las comas se usan como separadores.",
"params.csv.title": "Datos dinámicos de etiqueta (CSV)",
"params.generic.arrange": "Colocar",
"params.generic.arrange.top": "Traer al frente",
"params.generic.arrange.bottom": "Enviar al fondo",
"params.generic.center.horizontal": "Centrar horizontalmente",
"params.generic.center.vertical": "Centrar verticalmente",
"params.generic.fit": "Ajustar a la página",
"params.generic.fit.mode.stretch": "Estrechar",
"params.generic.fit.mode.ratio_min": "Ajustar",
"params.generic.fit.mode.ratio_max": "Llenar",
"params.generic.position": "Posición",
"params.label.apply": "Aplicar",
"params.label.current": "Parámetros actuales:",
"params.label.direction.left": "Izquierda",
"params.label.direction.top": "Arriba",
"params.label.direction": "Dirección de impresión",
"params.label.dpmm": "dpmm",
"params.label.export": "Exportar",
"params.label.head_density.help": "Cálculo: DPI / 25,4",
"params.label.head_density": "Densidad de píxeles",
"params.label.import": "Importar",
"params.label.label_title": "Título personalizado",
"params.label.menu_title": "Propiedades de la etiqueta",
"params.label.mm": "mm",
"params.label.px": "px",
"params.label.save_template": "Guardar como plantilla",
"params.label.shape": "Forma de etiqueta",
"params.label.size": "Tamaño",
"params.label.split": "División de etiqueta",
"params.label.split.count": "Partes divididas",
"params.label.tail.length": "Longitud de la cola",
"params.label.tail.position": "Posición de la cola",
"params.label.mirror": "Espejado de objeto",
"params.label.warning.direction": "Dirección recomendada para tu impresora:",
"params.label.warning.import": "Todas las plantillas guardadas se sobrescribirán",
"params.label.warning.width": "El ancho de la etiqueta es demasiado grande para tu impresora:",
"params.label.warning.div8": "El ancho de la etiqueta en píxeles debe ser divisible uniformemente por 8",
"params.qrcode.ecl": "Nivel de corrección de errores",
"params.qrcode.mode": "Modo",
"params.qrcode.version": "Versión",
"params.saved_labels.kb_used": "kB usados",
"params.saved_labels.label_title.placeholder": "(opcional)",
"params.saved_labels.label_title": "Título",
"params.saved_labels.load.browser": "Cargar",
"params.saved_labels.load.json": "Importar",
"params.saved_labels.make_default": "Definir por defecto",
"params.saved_labels.menu_title": "Guardar/cargar (almacenamiento del navegador)",
"params.saved_labels.save.browser.replace": "Guardar (reemplazar)",
"params.saved_labels.save.browser": "Guardar",
"params.saved_labels.save.json": "Exportar",
"params.text.align.center": "Alinear texto: centrar",
"params.text.align.left": "Alinear texto: izquierda",
"params.text.align.right": "Alinear texto: derecha",
"params.text.bold": "Negrita",
"params.text.edit.title": "Editar texto",
"params.text.edit": "Editar en popup",
"params.text.fetch_fonts": "Buscar fuentes",
"params.text.font_family": "Familia de fuente",
"params.text.font_size.down": "Decrementar tamaño de fuente",
"params.text.font_size.up": "Incrementar tamaño de fuente",
"params.text.font_size": "Tamaño de fuente",
"params.text.italic": "Cursiva",
"params.text.line_height": "Altura de linea",
"params.params.text.split": "División de texto (ajuste de línea)",
"params.params.text.split.spaces": "Espacios",
"params.params.text.split.grapheme": "Grafema (caracteres)",
"params.text.vorigin.bottom": "Inferior",
"params.text.vorigin.center": "Centro",
"params.text.vorigin.top": "Superior",
"params.text.vorigin": "Origen vertical",
"params.variables.insert.date": "Fecha",
"params.variables.insert.datetime": "Fecha y hora",
"params.variables.insert.time": "Hora",
"params.variables.insert": "Insertar variable",
"params.vector.round_radius": "Radio de redondeo",
"params.vector.stroke_width": "Grosor de trazo",
"params.vector.fill": "Llenar",
"params.color": "Color",
"params.color.transparent": "transparente",
"params.color.white": "blanco",
"params.color.black": "negro",
"preview.close": "Cerrar",
"preview.copies": "Copias",
"preview.density": "Densidad",
"preview.label_type.Black": "Negro",
"preview.label_type.BlackMarkGap": "Separación de la marca negra",
"preview.label_type.Continuous": "Continuo",
"preview.label_type.HeatShrinkTube": "Tubo termorretráctil",
"preview.label_type.Invalid": "Inválido",
"preview.label_type.Perforated": "Perforado",
"preview.label_type.PvcTag": "Etiqueta PVC",
"preview.label_type.Transparent": "Transparente",
"preview.label_type.WithGaps": "Con espacios",
"preview.label_type": "Tipo de etiqueta",
"preview.not_connected": "La impresora no esta conectada",
"preview.not_implemented": "NO IMPLEMENTADO",
"preview.offset.inner": "Interno",
"preview.offset.outer": "Externo",
"preview.offset": "Offset",
"preview.postprocess.atkinson": "Tramado (Atkinson)",
"preview.postprocess.threshold": "Límite",
"preview.postprocess": "Postprocesado",
"preview.print_task": "Tarea de impresión",
"preview.print.cancel": "Cancelar impresión",
"preview.print.system": "Imprimir con impresora del SO",
"preview.print": "Imprimir",
"preview.threshold": "Límite",
"preview.title": "Previsualización de impresión",
"debug.page_delay.help": "Añadir retraso si tienes problemas con multiples paginas",
"editor.iconpicker.add": "Nuevo",
"editor.iconpicker.delete_mode": "Modo borrar",
"editor.iconpicker.show": "Ver",
"editor.iconpicker.show.user": "Iconos de usuario",
"editor.iconpicker.show.pack": "Paquetes de iconos",
"editor.iconpicker.show.both": "Usuario + Paquetes de iconos",
"editor.warning.load.csv": "CSV datos serán reemplazados",
"params.saved_labels.save.withcsv": "Data dinamica (CSV) esta activada, será añadida en la etiqueta guardada",
"params.saved_labels.save.url": "Copiar Link",
"params.saved_labels.load.url.warn": "Cargar data de URL? Precaución.",
"params.saved_labels.load.url.loaded": "Etiqueta cargada desde URL",
"params.saved_labels.save.url.copied": "URL copiado al portapapeles",
"params.saved_labels.save.url.warn": "Los datos de la etiqueta son bastante grandes y la URL puede no funcionar en todos los navegadores.",
"preview.postprocess.bayer": "Tramado (Bayer)"
}

View File

@@ -0,0 +1,131 @@
{
"lang.name": "Français",
"browser_warning.lines.first": "Oh non, votre navigateur ne prend pas en charge les communications Bluetooth et série",
"browser_warning.lines.second": "Quoi qu'il en soit, vous pouvez toujours dessiner des étiquettes.",
"connector.bluetooth": "Bluetooth",
"connector.disconnect.heartbeat": "Déconnecté (l'imprimante ne répond pas)",
"connector.serial": "Série (USB)",
"editor.clear": "Effacer le canevas",
"editor.clear.confirm": "Effacer le contenu du canevas ?",
"editor.clone": "Dupliquer",
"editor.default_text": "Texte",
"editor.delete": "Supprimer",
"editor.iconpicker.mdi_link_title": "Voir la liste détaillée ici",
"editor.iconpicker.search": "Rechercher",
"editor.iconpicker.title": "Ajouter une icône",
"editor.import.zpl": "Importer ZPL",
"editor.objectpicker.barcode": "Code-barres",
"editor.objectpicker.circle": "Cercle",
"editor.objectpicker.image": "Image",
"editor.objectpicker.line": "Ligne",
"editor.objectpicker.qrcode": "QR Code",
"editor.objectpicker.rectangle": "Rectangle",
"editor.objectpicker.text": "Texte",
"editor.objectpicker.title": "Ajouter un objet",
"editor.preview": "Aperçu",
"editor.print": "Imprimer",
"editor.redo": "Rétablir",
"editor.undo": "Annuler",
"editor.warning.load": "Le canevas sera remplacé par les données enregistrées",
"editor.warning.save": "Les données enregistrées seront écrasées. Enregistrer ?",
"main.built": "construit le",
"main.code": "Code",
"params.barcode.content": "Contenu",
"params.barcode.enable_caption": "Activer la légende",
"params.barcode.encoding": "Codage",
"params.barcode.font_size": "Taille de la police",
"params.barcode.scale": "Facteur d'échelle",
"params.csv.enabled": "Activé",
"params.csv.placeholders": "Variables :",
"params.csv.rowsfound": "Lignes de données trouvées :",
"params.csv.tip": "La première ligne est un en-tête. Elle est utilisée comme noms de variables. Les virgules sont utilisées comme séparateurs.",
"params.csv.title": "Données d'étiquettes dynamiques (CSV)",
"params.generic.center.horizontal": "Centrer horizontalement",
"params.generic.center.vertical": "Centrer verticalement",
"params.generic.fit": "Ajuster à la page",
"params.generic.fit.mode.stretch": "Étirement",
"params.generic.fit.mode.ratio_min": "Ajuster",
"params.generic.fit.mode.ratio_max": "Remplir",
"params.label.apply": "Appliquer",
"params.label.current": "Paramètres actuels :",
"params.label.direction.left": "Gauche",
"params.label.direction.top": "Haut",
"params.label.direction": "Sens d'impression",
"params.label.dpmm": "ppmm",
"params.label.export": "Exporter",
"params.label.head_density.help": "Calcul : DPI / 25,4",
"params.label.head_density": "Densité de pixels",
"params.label.import": "Importer",
"params.label.label_title": "Titre personnalisé",
"params.label.menu_title": "Propriétés de l'étiquette",
"params.label.mm": "mm",
"params.label.px": "px",
"params.label.save_template": "Enregistrer comme modèle",
"params.label.shape": "Forme de l'étiquette",
"params.label.size": "Taille",
"params.label.split": "Fractionnement de l'étiquette",
"params.label.tail.length": "Longueur du trait",
"params.label.tail.position": "Position du trait",
"params.label.mirror": "Miroir de l'objet",
"params.label.warning.direction": "Sens recommandé pour votre imprimante :",
"params.label.warning.import": "Tous les modèles enregistrés seront écrasés",
"params.label.warning.width": "La largeur de l'étiquette est trop grande pour votre imprimante :",
"params.qrcode.ecl": "Niveau de correction d'erreur",
"params.saved_labels.kb_used": "ko utilisés",
"params.saved_labels.label_title.placeholder": "(facultatif)",
"params.saved_labels.label_title": "Titre",
"params.saved_labels.load.browser": "Charger",
"params.saved_labels.load.json": "Importer",
"params.saved_labels.make_default": "Définir par défaut",
"params.saved_labels.menu_title": "Enregistrer/charger (stockage du navigateur)",
"params.saved_labels.save.browser.replace": "Enregistrer (remplacer)",
"params.saved_labels.save.browser": "Enregistrer",
"params.saved_labels.save.json": "Exporter",
"params.text.align.center": "Aligner le texte : centré",
"params.text.align.left": "Aligner le texte : à gauche",
"params.text.align.right": "Aligner le texte : à droite",
"params.text.bold": "Gras",
"params.text.edit.title": "Modifier le texte",
"params.text.edit": "Modifier dans la fenêtre",
"params.text.fetch_fonts": "Récupérer les polices",
"params.text.font_family": "Police de caractères",
"params.text.font_size.down": "Réduire la taille de la police",
"params.text.font_size.up": "Augmenter la taille de la police",
"params.text.font_size": "Taille de la police",
"params.text.line_height": "Hauteur de ligne",
"params.text.vorigin.bottom": "Bas",
"params.text.vorigin.center": "Centre",
"params.text.vorigin.top": "Haut",
"params.text.vorigin": "Origine verticale",
"params.variables.insert.date": "Date",
"params.variables.insert.datetime": "Date et heure",
"params.variables.insert.time": "Heure",
"params.variables.insert": "Insérer une variable",
"preview.close": "Fermer",
"preview.copies": "Copies",
"preview.density": "Densité",
"preview.label_type.Black": "Noir",
"preview.label_type.BlackMarkGap": "Marque noire (gap)",
"preview.label_type.Continuous": "Continue",
"preview.label_type.HeatShrinkTube": "Manchon thermorétractable",
"preview.label_type.Invalid": "Invalide",
"preview.label_type.Perforated": "Perforée",
"preview.label_type.PvcTag": "Étiquette PVC",
"preview.label_type.Transparent": "Transparent",
"preview.label_type.WithGaps": "Avec espaces",
"preview.label_type": "Type d'étiquette",
"preview.not_connected": "Imprimante non connectée",
"preview.not_implemented": "NON IMPLÉMENTÉ",
"preview.offset.inner": "Intérieur",
"preview.offset.outer": "Extérieur",
"preview.offset": "Décalage",
"preview.postprocess.atkinson": "Tramage (Atkinson)",
"preview.postprocess.threshold": "Seuillage",
"preview.postprocess": "Post-traitement",
"preview.print_task": "Tâche d'impression",
"preview.print.cancel": "Annuler l'impression",
"preview.print.system": "Imprimer avec l'imprimante du système",
"preview.print": "Imprimer",
"preview.threshold": "Seuil",
"preview.title": "Aperçu d'impression"
}

View File

@@ -0,0 +1,180 @@
{
"lang.name": "हिंदी",
"browser_warning.fingerprinting": "अरे! ऐसा लगता है कि आपका ब्राउज़र फ़िंगरप्रिंटिंग से सुरक्षा के लिए कैनवास को विकृत कर रहा है। कृपया इस सुरक्षा को अक्षम करें, क्योंकि इससे लेबल पर त्रुटियाँ या विकृति उत्पन्न हो सकती हैं।",
"browser_warning.lines.first": "ओह नहीं, आपका ब्राउज़र ब्लूटूथ और सीरियल संचार का समर्थन नहीं करता है",
"browser_warning.lines.second": "इसके बावजूद, आप अभी भी लेबल बना सकते हैं।",
"connector.bluetooth": "ब्लूटूथ",
"connector.disconnect.heartbeat": "डिस्कनेक्टेड (प्रिंटर प्रतिक्रिया नहीं दे रहा है)",
"connector.serial": "सीरियल (यूएसबी)",
"debug.page_delay.help": "यदि आपको बहु-पृष्ठ प्रिंट में समस्या हो रही है, तो विलंब (उदाहरण के लिए 1000) जोड़ें।",
"editor.clear": "कैनवास साफ़ करें",
"editor.clear.confirm": "क्या कैनवास का कंटेंट साफ़ करें?",
"editor.clone": "क्लोन",
"editor.default_text": "टेक्स्ट",
"editor.delete": "हटाएँ",
"editor.iconpicker.add": "नया जोड़ें",
"editor.iconpicker.delete_mode": "डिलीट मोड",
"editor.iconpicker.search": "खोजें",
"editor.iconpicker.show": "दिखाएँ",
"editor.iconpicker.show.user": "केवल यूज़र आइकन",
"editor.iconpicker.show.pack": "केवल पैक आइकन",
"editor.iconpicker.show.both": "यूज़र + पैक आइकन",
"editor.iconpicker.mdi_link_title": "विस्तृत सूची यहाँ देखें",
"editor.iconpicker.title": "आइकन जोड़ें",
"editor.import.zpl": "ZPL इम्पोर्ट करें",
"editor.objectpicker.barcode": "बारकोड",
"editor.objectpicker.circle": "वृत्त",
"editor.objectpicker.image": "इमेज",
"editor.objectpicker.line": "लाइन",
"editor.objectpicker.qrcode": "क्यूआर कोड",
"editor.objectpicker.rectangle": "आयत (Rectangle)",
"editor.objectpicker.text": "टेक्स्ट",
"editor.objectpicker.title": "ऑब्जेक्ट जोड़ें",
"editor.preview": "पूर्वावलोकन",
"editor.print": "प्रिंट करें",
"editor.redo": "फिर से करें",
"editor.undo": "पूर्ववत करें",
"editor.warning.load": "कैनवास को सहेजे गए डेटा से बदल दिया जाएगा",
"editor.warning.load.csv": "CSV डेटा भी बदल दिया जाएगा",
"main.built": "बनाया गया",
"main.code": "कोड",
"params.barcode.content": "कंटेंट",
"params.barcode.enable_caption": "कैप्शन सक्षम करें",
"params.barcode.encoding": "एन्कोडिंग",
"params.barcode.font_size": "फ़ॉन्ट आकार",
"params.barcode.scale": "स्केल गुणांक",
"params.csv.enabled": "सक्षम",
"params.csv.rowsfound": "प्राप्त डेटा पंक्तियाँ:",
"params.csv.tip": "पहली पंक्ति हेडर होती है। इसे चर नामों के रूप में उपयोग किया जाता है। अल्पविराम (,) को विभाजक के रूप में उपयोग किया जाता है।",
"params.csv.title": "नवीनतम लेबल डेटा (सीएसवी)",
"params.generic.arrange": "व्यवस्थित करें",
"params.generic.arrange.top": "सामने लाएँ",
"params.generic.arrange.bottom": "पीछे भेजें",
"params.generic.center.horizontal": "क्षैतिज रूप से केंद्र में रखें",
"params.generic.center.vertical": "ऊर्ध्वाधर रूप से केंद्र में रखें",
"params.generic.fit": "पृष्ठ के अनुसार फिट करें",
"params.generic.fit.mode.stretch": "खींचें",
"params.generic.fit.mode.ratio_min": "फिट करें",
"params.generic.fit.mode.ratio_max": "भरें",
"params.generic.position": "स्थिति",
"params.label.apply": "लागू करें",
"params.label.current": "वर्तमान पैरामीटर:",
"params.label.direction.left": "बाएँ",
"params.label.direction.top": "ऊपर",
"params.label.direction": "प्रिंट दिशा",
"params.label.dpmm": "डीपीएमएम",
"params.label.export": "निर्यात करें",
"params.label.head_density.help": "गणना: DPI / 25.4",
"params.label.head_density": "पिक्सेल घनत्व",
"params.label.import": "आयात करें",
"params.label.label_title": "कस्टम शीर्षक",
"params.label.mm": "एमएम",
"params.label.px": "पीएक्स",
"params.label.shape": "लेबल आकार",
"params.label.size": "आकार",
"params.label.split": "लेबल विभाजन",
"params.label.split.count": "विभाजन के भाग",
"params.label.tail.length": "टेल की लंबाई",
"params.label.tail.position": "टेल की स्थिति",
"params.label.mirror": "ऑब्जेक्ट मिररिंग (प्रतिबिंब)",
"params.label.warning.direction": "आपके प्रिंटर के लिए अनुशंसित दिशा:",
"params.label.warning.import": "सभी सहेजे गए टेम्पलेट अधिलेखित हो जाएंगे",
"params.label.warning.width": "लेबल की चौड़ाई आपके प्रिंटर के लिए बहुत अधिक है:",
"params.label.warning.div8": "पिक्सेल में लेबल की चौड़ाई 8 से पूरी तरह विभाज्य होनी चाहिए",
"params.qrcode.ecl": "त्रुटि सुधार स्तर",
"params.qrcode.mode": "मोड",
"params.qrcode.version": "संस्करण",
"params.saved_labels.kb_used": "प्रयुक्त kB",
"params.saved_labels.label_title.placeholder": "(वैकल्पिक)",
"params.saved_labels.label_title": "शीर्षक",
"params.saved_labels.load.browser": "लोड करें",
"params.saved_labels.load.json": "आयात करें",
"params.saved_labels.make_default": "डिफ़ॉल्ट बनाएं",
"params.saved_labels.menu_title": "सहेजें/लोड करें (ब्राउज़र स्टोरेज)",
"params.saved_labels.save.withcsv": "डायनामिक डेटा (सीएसवी) सक्षम है, इसलिए इसे सहेजे गए लेबल में शामिल किया जाएगा",
"params.saved_labels.save.browser.replace": "सहेजें (प्रतिस्थापित करें)",
"params.saved_labels.save.browser": "सहेजें",
"params.saved_labels.save.json": "निर्यात करें",
"params.saved_labels.save.url": "लिंक कॉपी करें",
"params.saved_labels.load.url.warn": "यूआरएल से लेबल लोड करें? यह जोखिम भरा हो सकता है।",
"params.saved_labels.load.url.loaded": "लेबल यूआरएल से लोड किया गया",
"params.saved_labels.save.url.copied": "लेबल यूआरएल क्लिपबोर्ड में कॉपी किया गया",
"params.saved_labels.save.url.warn": "लेबल डेटा काफ़ी बड़ा है और URL सभी ब्राउज़रों में काम नहीं कर सकता।\nलेबल का आकार कम करने का प्रयास करें (उदाहरण के लिए, चित्र हटाएँ)।\nजारी रखें?",
"params.text.align.left": "टेक्स्ट संरेखण: बाएँ",
"params.text.align.center": "टेक्स्ट संरेखण: मध्य",
"params.text.align.right": "टेक्स्ट संरेखण: दाएँ",
"params.text.autosize": "फ़ील्ड की चौड़ाई के अनुसार टेक्स्ट आकार समायोजित करें",
"params.text.bold": "बोल्ड",
"params.text.edit.title": "टेक्स्ट संपादन",
"params.text.edit": "पॉपअप में संपादित करें",
"params.text.fetch_fonts": "फ़ॉन्ट प्राप्त करें",
"params.text.font_family": "फ़ॉन्ट परिवार",
"params.text.font_size.down": "फ़ॉन्ट आकार घटाएँ",
"params.text.font_size.up": "फ़ॉन्ट आकार बढ़ाएँ",
"params.text.font_size": "फ़ॉन्ट आकार",
"params.text.italic": "इटैलिक",
"params.text.line_height": "लाइन की ऊँचाई",
"params.params.text.split": "टेक्स्ट विभाजन (रैप)",
"params.params.text.split.spaces": "स्पेस",
"params.params.text.split.grapheme": "ग्राफ़ीम (अक्षर)",
"params.text.vorigin.bottom": "नीचे",
"params.text.vorigin.center": "मध्य",
"params.text.vorigin.top": "ऊपर",
"params.text.vorigin": "ऊर्ध्वाधर मूल बिंदु",
"params.variables.insert.date": "तारीख",
"params.variables.insert.datetime": "तारीख और समय",
"params.variables.insert.time": "समय",
"params.variables.insert": "चर सम्मिलित करें",
"params.vector.round_radius": "गोलाई त्रिज्या",
"params.vector.stroke_width": "आउटलाइन की मोटाई",
"params.vector.fill": "भराव",
"params.color": "रंग",
"params.color.transparent": "पारदर्शी",
"params.color.white": "सफेद",
"params.color.black": "काला",
"preview.close": "बंद करें",
"preview.copies": "प्रतियाँ",
"preview.density": "घनत्व",
"preview.label_type.Black": "काला",
"preview.label_type.BlackMarkGap": "ब्लैक मार्क गैप",
"preview.label_type.Continuous": "निरंतर",
"preview.label_type.HeatShrinkTube": "हीट श्रिंक ट्यूब",
"preview.label_type.Invalid": "अमान्य",
"preview.label_type.Perforated": "छिद्रित",
"preview.label_type.PvcTag": "पीवीसी टैग",
"preview.label_type.Transparent": "पारदर्शी",
"preview.label_type.WithGaps": "गैप के साथ",
"preview.label_type": "लेबल प्रकार",
"preview.not_connected": "प्रिंटर कनेक्ट नहीं है",
"preview.not_implemented": "लागू नहीं किया गया",
"preview.offset.inner": "भीतरी",
"preview.offset.outer": "बाहरी",
"preview.offset": "ऑफसेट",
"preview.postprocess.atkinson": "डिथर (Atkinson)",
"preview.postprocess.bayer": "डिथर (Bayer)",
"preview.postprocess.threshold": "थ्रेशहोल्ड",
"preview.postprocess": "पोस्ट-प्रोसेस",
"preview.print_task": "प्रिंट कार्य",
"preview.print.cancel": "प्रिंट रद्द करें",
"preview.print.system": "ओएस प्रिंटर से प्रिंट करें",
"preview.print": "प्रिंट करें",
"preview.threshold": "थ्रेशहोल्ड",
"preview.title": "प्रिंट पूर्वावलोकन",
"editor.warning.save": "सहेजा गया डेटा ओवरराइट हो जाएगा। सहेजें?",
"params.csv.placeholders": "चर:",
"params.label.menu_title": "लेबल गुण",
"params.label.save_template": "टेम्पलेट के रूप में सहेजें",
"debug.packet_interval.help": "पैकेट अंतराल को ओवरराइड करें। इसके लिए पेज को रीलोड करना आवश्यक है। यदि मान बहुत कम है, तो कुछ गड़बड़ हो सकती है।",
"debug.title": "डिबग से संबंधित चीज़ें",
"debug.reset": "रीसेट करें",
"params.text.user_fonts": "यूज़र फ़ॉन्ट्स",
"params.text.system_fonts": "प्रणाली फ़ॉन्ट्स",
"preview.speed": "प्रिंट गति",
"preview.speed.0": "कम (उच्च गुणवत्ता)",
"preview.speed.1": "साधारण",
"fonts.title": "कस्टम फ़ॉन्ट्स",
"fonts.add": "जोड़ें",
"fonts.browse": "खोजें…",
"fonts.gfonts": "फ़ॉन्ट्स प्राप्त करें",
"fonts.title_override": "नाम ओवरराइड करें"
}

View File

@@ -0,0 +1,167 @@
{
"lang.name": "Hrvatski",
"browser_warning.fingerprinting": "Opa! Izgleda da vaš preglednik iskrivljuje platno da bi vas zaštitio od fingerprintinga. Molimo onemogućite ovu zaštitu jer može uzrokovati artefakte na etiketama.",
"browser_warning.lines.first": "O, ne! Vaš preglednik ne podržava bluetooth i serijsku komunikaciju",
"connector.bluetooth": "Bluetooth",
"connector.serial": "Serijski (USB)",
"editor.clear": "Očisti platno",
"editor.clear.confirm": "Želite li izbrisati sav sadržaj platna?",
"editor.clone": "Kloniraj",
"editor.default_text": "Tekst",
"editor.delete": "Izbriši",
"editor.iconpicker.mdi_link_title": "Ovdje pogledajte detaljan popis",
"editor.iconpicker.search": "Traži",
"editor.iconpicker.title": "Dodaj ikonu",
"editor.import.zpl": "Uvoz ZPL-a",
"editor.objectpicker.barcode": "Stupčasti kod",
"editor.objectpicker.circle": "Krug",
"editor.objectpicker.image": "Slika",
"editor.objectpicker.line": "Linija",
"editor.objectpicker.qrcode": "QR kod",
"editor.objectpicker.rectangle": "Pravokutnik",
"editor.objectpicker.text": "Tekst",
"editor.objectpicker.title": "Dodaj objekt",
"editor.preview": "Pretpregled",
"editor.print": "Ispis",
"editor.undo": "Vrati",
"editor.warning.load": "Platno će biti prepisano spremljenim podatcima",
"browser_warning.lines.second": "Unatoč tome možete barem crtati etikete.",
"connector.disconnect.heartbeat": "Prekinuta veza (pisač ne odgovara na upite)",
"editor.redo": "Ponovi",
"editor.warning.save": "Spremljeni podatci će biti prepisani. Spremi?",
"main.built": "međuverzija od",
"main.code": "Kod",
"params.barcode.content": "Sadržaj",
"params.barcode.enable_caption": "Omogući natpis",
"params.barcode.encoding": "Kodiranje",
"params.barcode.font_size": "Veličina fonta",
"params.barcode.scale": "Faktor skaliranja",
"params.csv.enabled": "Omogućeno",
"params.csv.placeholders": "Varijable:",
"params.csv.rowsfound": "Pronađeni redovi podataka:",
"params.csv.tip": "Prvi redak je zaglavlje. Koristi se za nazive varijabli. Zarezi služe kao razdjelnici.",
"params.csv.title": "Dinamični podatci etikete (CSV)",
"params.generic.arrange": "Složi",
"params.generic.arrange.top": "Pomakni na vrh",
"params.generic.arrange.bottom": "Pošalji na dno",
"params.generic.center.horizontal": "Centriraj vodoravno",
"params.generic.center.vertical": "Centriraj okomito",
"params.generic.fit": "Prilagodi veličini stranice",
"params.generic.fit.mode.stretch": "Razvuci",
"params.generic.fit.mode.ratio_min": "Prilagodi da stane",
"params.generic.fit.mode.ratio_max": "Popuni",
"params.generic.position": "Položaj",
"params.label.apply": "Primijeni",
"params.label.current": "Trenutni parametri:",
"params.label.direction.left": "Lijevo",
"params.label.direction.top": "Vrh",
"params.label.direction": "Smjer ispisa",
"params.label.dpmm": "točaka po mm",
"params.label.export": "Izvoz",
"params.label.head_density.help": "Izračun: DPI / 25.4",
"params.label.head_density": "Gustoća piksela",
"params.label.import": "Uvoz",
"params.label.label_title": "Proizvoljni naslov",
"params.label.menu_title": "Svojstva etikete",
"params.label.mm": "mm",
"params.label.px": "px",
"params.label.save_template": "Spremi kao predložak",
"params.label.shape": "Oblik etikete",
"params.label.size": "Veličina",
"params.label.split": "Podjela etikete",
"params.label.split.count": "Podijeljeni dijelovi",
"params.label.tail.position": "Položaj repa",
"params.label.mirror": "Zrcaljenje objekta",
"params.label.warning.direction": "Preporučeni smjer za vaš pisač:",
"params.label.warning.import": "Svi spremljeni predlošci bit će prepisani",
"params.qrcode.ecl": "Razina ispravka pogrešaka",
"params.qrcode.mode": "Način rada",
"params.qrcode.version": "Verzija",
"params.saved_labels.kb_used": "iskorišteno kB",
"params.saved_labels.label_title.placeholder": "(neobavezno)",
"params.saved_labels.label_title": "Naslov",
"params.saved_labels.load.browser": "Učitaj",
"params.saved_labels.load.json": "Uvezi",
"params.saved_labels.menu_title": "Spremi/učitaj (pohrana u pregledniku)",
"params.saved_labels.save.browser.replace": "Spremi (zamijeni)",
"params.saved_labels.save.browser": "Spremi",
"params.saved_labels.save.json": "Izvezi",
"params.text.align.center": "Poravnanje teksta: sredina",
"params.text.align.left": "Poravnanje teksta: lijevo",
"params.text.align.right": "Poravnanje teksta: desno",
"params.text.bold": "Podebljano",
"params.text.edit.title": "Uređivanje teksta",
"params.text.edit": "Uredi u iskočnom prozoru",
"params.text.fetch_fonts": "Dohvati fontove",
"params.text.font_family": "Obitelj fontova",
"params.text.font_size.up": "Povećaj font",
"params.text.font_size.down": "Smanji font",
"params.text.font_size": "Veličina fonta",
"params.text.italic": "Kurziv",
"params.params.text.split": "Podijeli tekst (prelomi)",
"params.params.text.split.spaces": "Razmaci",
"params.params.text.split.grapheme": "Grafemi (znakovi)",
"params.text.vorigin.bottom": "Dno",
"params.text.vorigin.center": "Sredina",
"params.text.vorigin.top": "Vrh",
"params.text.vorigin": "Okomito polazište",
"params.variables.insert.date": "Datum",
"params.variables.insert.datetime": "Datum i vrijeme",
"params.variables.insert.time": "Vrijeme",
"params.variables.insert": "Umetni varijablu",
"params.vector.round_radius": "Radijus zaobljenja",
"params.vector.stroke_width": "Širina linije",
"params.vector.fill": "Ispuna",
"params.color": "Boja",
"params.color.transparent": "prozirno",
"params.color.white": "bijela",
"params.color.black": "crna",
"preview.close": "Zatvori",
"preview.copies": "Kopija",
"preview.density": "Gustoća",
"preview.label_type.Black": "Crna",
"preview.label_type.BlackMarkGap": "Razmak s crnom oznakom",
"preview.label_type.Continuous": "Neprekinuta",
"preview.label_type.HeatShrinkTube": "Termoskupljajuća cijev",
"preview.label_type.Invalid": "Neispravno",
"preview.label_type.Perforated": "Perforirana",
"preview.label_type.PvcTag": "PVC etiketa",
"preview.label_type.Transparent": "Prozirna",
"preview.label_type.WithGaps": "Sa zazorima",
"preview.label_type": "Vrsta etikete",
"preview.not_connected": "Pisač nije povezan",
"preview.offset.inner": "Unutarnji",
"preview.offset.outer": "Vanjski",
"preview.offset": "Ofset",
"preview.postprocess.atkinson": "Dither (Atkinson)",
"preview.postprocess.threshold": "Prag",
"preview.postprocess": "Naknadna obrada",
"preview.print_task": "Zadatak ispisa",
"preview.print.cancel": "Prekini ispis",
"preview.print.system": "Ispiši pomoću pisača OS-a",
"preview.print": "Ispis",
"preview.threshold": "Prag",
"preview.title": "Pretpregled ispisa",
"params.label.tail.length": "Duljina repa",
"params.label.warning.div8": "Širina etikete u pikselima mora biti djeljiva s 8 bez ostatka",
"params.text.line_height": "Visina retka",
"params.label.warning.width": "Širina etikete prevelika je za vaš pisač:",
"params.saved_labels.make_default": "Postavi kao zadano",
"preview.not_implemented": "NIJE IMPLEMENTIRANO",
"editor.warning.load.csv": "I CSV podatci bit će zamijenjeni",
"params.saved_labels.save.withcsv": "Omogućeni su dinamični podatci (CSV) i bit će ugrađeni u spremljenu etiketu",
"params.saved_labels.save.url": "Kopiraj poveznicu",
"params.saved_labels.load.url.warn": "Želite li učitati etiketu s URL-a? Opasnost vreba!",
"params.saved_labels.load.url.loaded": "Etiketa učitana s URL-a",
"params.saved_labels.save.url.copied": "URL etikete kopiran je u međuspremnik",
"params.saved_labels.save.url.warn": "Podatci etikete vrlo su veliki i URL možda neće raditi u svim preglednicima.\nPokušajte smanjiti veličinu etikete (uklonite slike, primjerice).\nŽelite li nastaviti?",
"debug.page_delay.help": "Dodajte vremensku odgodu u slučaju problema s višestraničnim ispisom",
"editor.iconpicker.add": "Dodaj novu",
"editor.iconpicker.delete_mode": "Modus za brisanje",
"editor.iconpicker.show": "Pokaži",
"editor.iconpicker.show.user": "samo korisničke ikone",
"editor.iconpicker.show.pack": "samo ikone iz paketa",
"editor.iconpicker.show.both": "korisničke + ikone iz paketa",
"preview.postprocess.bayer": "Dither (Bayer)",
"params.text.autosize": "Prilagodi veličinu teksta širini polja"
}

View File

@@ -0,0 +1,151 @@
{
"lang.name": "Magyar",
"browser_warning.fingerprinting": "Whoa! It looks like your browser is distorting the canvas to protect against fingerprinting. Please disable this protection, as it can cause artifacts on labels.",
"browser_warning.lines.first": "Úgy tűnik, hogy ez a böngésző nem támogatja egyik kommunikációs protokollt sem",
"browser_warning.lines.second": "Szerkesztésre továbbra is van lehetőség.",
"connector.bluetooth": "Bluetooth",
"connector.disconnect.heartbeat": "Kapcsolat bontva (nyomtató nem válaszol)",
"connector.serial": "USB-kapcsolat",
"editor.clear": "Vászon törlése",
"editor.clear.confirm": "Töröl a címkéről mindent?",
"editor.clone": "Másolat",
"editor.default_text": "Szöveg",
"editor.delete": "Törlés",
"editor.iconpicker.mdi_link_title": "Teljes lista megtekintése",
"editor.iconpicker.search": "Keresés",
"editor.iconpicker.title": "Ikon beszúrása",
"editor.import.zpl": "Import ZPL",
"editor.objectpicker.barcode": "Vonalkód",
"editor.objectpicker.circle": "Kör",
"editor.objectpicker.image": "Kép",
"editor.objectpicker.line": "Vonal",
"editor.objectpicker.qrcode": "QR-kód",
"editor.objectpicker.rectangle": "Négyzet",
"editor.objectpicker.text": "Szöveg",
"editor.objectpicker.title": "Elem beszúrása",
"editor.preview": "Előnézet",
"editor.print": "Nyomtatás",
"editor.redo": "Ismét",
"editor.undo": "Visszavonás",
"editor.warning.load": "Canvas will be replaced with saved data",
"editor.warning.save": "Saved data will be overwritten. Save?",
"main.built": "összeállítva:",
"main.code": "Forráskód",
"params.barcode.content": "Tartalom",
"params.barcode.enable_caption": "Felirat bekapcsolása",
"params.barcode.encoding": "Kódolás",
"params.barcode.font_size": "Betűméret",
"params.barcode.scale": "Méretarány",
"params.csv.enabled": "Engedélyezve",
"params.csv.placeholders": "Változók:",
"params.csv.rowsfound": "Talált adatsorok:",
"params.csv.tip": "First row is a header. It used as variable names. Commas are used as separators.",
"params.csv.title": "Dynamic label data (CSV)",
"params.generic.arrange": "Elhelyezés",
"params.generic.arrange.top": "Előtérbe helyezés",
"params.generic.arrange.bottom": "Háttérbe helyezés",
"params.generic.center.horizontal": "Középre igazítás vízszintesen",
"params.generic.center.vertical": "Középre igazítás függőlegesen",
"params.generic.fit": "Kép illesztése",
"params.generic.fit.mode.stretch": "Nyújtás",
"params.generic.fit.mode.ratio_min": "Igazítás",
"params.generic.fit.mode.ratio_max": "Kitöltés",
"params.generic.position": "Elhelyezés",
"params.label.apply": "Alkalmazás",
"params.label.current": "Jelenlegi tulajdonságok:",
"params.label.direction.left": "Balról",
"params.label.direction.top": "Fentről",
"params.label.direction": "Nyomtatási irány",
"params.label.dpmm": "dpmm",
"params.label.export": "Mentés",
"params.label.head_density.help": "Calculation: DPI / 25.4",
"params.label.head_density": "Képpontsűrűség",
"params.label.import": "Betöltés",
"params.label.label_title": "Egyedi cím",
"params.label.menu_title": "Címke tulajdonságai",
"params.label.mm": "mm",
"params.label.px": "px",
"params.label.save_template": "Mentés sablonként",
"params.label.shape": "Címke formája",
"params.label.size": "Méret",
"params.label.split": "Címke felosztása",
"params.label.split.count": "Split parts",
"params.label.tail.length": "Tail length",
"params.label.tail.position": "Tail position",
"params.label.mirror": "Elem tükrözése",
"params.label.warning.direction": "Ajánlott nyomtatási irány a nyomtatóhoz:",
"params.label.warning.import": "Minden mentett sablon felülírásra kerül",
"params.label.warning.width": "Label width is too big for your printer:",
"params.label.warning.div8": "Label width in pixels must be evenly divisible by 8",
"params.qrcode.ecl": "Hibajavítás szintje",
"params.qrcode.mode": "Mode",
"params.qrcode.version": "Verzió",
"params.saved_labels.kb_used": "kB felhasználva",
"params.saved_labels.label_title.placeholder": "(tetszőleges)",
"params.saved_labels.label_title": "Cím",
"params.saved_labels.load.browser": "Betöltés",
"params.saved_labels.load.json": "Betöltés",
"params.saved_labels.make_default": "Make default",
"params.saved_labels.menu_title": "Save/load (browser storage)",
"params.saved_labels.save.browser.replace": "Save (replace)",
"params.saved_labels.save.browser": "Mentés",
"params.saved_labels.save.json": "Mentés",
"params.text.align.center": "Szöveg középre igazítása",
"params.text.align.left": "Szöveg balra igazítása",
"params.text.align.right": "Szöveg jobbra igazítása",
"params.text.bold": "Félkövér",
"params.text.edit.title": "Szöveg szerkesztése",
"params.text.edit": "Szerkesztés felugró ablakban",
"params.text.fetch_fonts": "Betűtípusok betöltése",
"params.text.font_family": "Betűtípus",
"params.text.font_size.down": "Betűméret csökkentése",
"params.text.font_size.up": "Betűméret növelése",
"params.text.font_size": "Betűméret",
"params.text.italic": "Dőlt",
"params.text.line_height": "Line height",
"params.params.text.split": "Text split (wrap)",
"params.params.text.split.spaces": "Spaces",
"params.params.text.split.grapheme": "Grapheme (characters)",
"params.text.vorigin.bottom": "Bottom",
"params.text.vorigin.center": "Center",
"params.text.vorigin.top": "Top",
"params.text.vorigin": "Vertical Origin",
"params.variables.insert.date": "Dátum",
"params.variables.insert.datetime": "Dátum és idő",
"params.variables.insert.time": "Idő",
"params.variables.insert": "Változó beszúrása",
"params.vector.round_radius": "Round radius",
"params.vector.stroke_width": "Vonalvastagság",
"params.vector.fill": "Kitöltés",
"params.color": "Szín",
"params.color.transparent": "átlátszó",
"params.color.white": "fehér",
"params.color.black": "fekete",
"preview.close": "Bezárás",
"preview.copies": "Példány",
"preview.density": "Density",
"preview.label_type.Black": "Fekete",
"preview.label_type.BlackMarkGap": "Black mark gap",
"preview.label_type.Continuous": "Continuous",
"preview.label_type.HeatShrinkTube": "Heat shrink tube",
"preview.label_type.Invalid": "Invalid",
"preview.label_type.Perforated": "Perforated",
"preview.label_type.PvcTag": "PVC Tag",
"preview.label_type.Transparent": "Átlátszó",
"preview.label_type.WithGaps": "WithGaps",
"preview.label_type": "Címke típusa",
"preview.not_connected": "Nyomtató nem elérhető",
"preview.not_implemented": "NINCS IMPLEMENTÁLVA",
"preview.offset.inner": "Inner",
"preview.offset.outer": "Outer",
"preview.offset": "Offset",
"preview.postprocess.atkinson": "Dither (Atkinson)",
"preview.postprocess.threshold": "Threshold",
"preview.postprocess": "Post-process",
"preview.print_task": "Print task",
"preview.print.cancel": "Nyomtatás megszakítása",
"preview.print.system": "Rendszer nyomtatójának használata",
"preview.print": "Nyomtatás",
"preview.threshold": "Kontraszt",
"preview.title": "Nyomtatási előnézet"
}

View File

@@ -0,0 +1,125 @@
{
"lang.name": "Italiano",
"browser_warning.lines.first": "Oh no, il tuo browser non supporta le comunicazioni Bluetooth e seriali",
"browser_warning.lines.second": "Comunque, puoi disegnare etichette.",
"connector.bluetooth": "Bluetooth",
"connector.disconnect.heartbeat": "Disconnesso (la stampante non risponde)",
"connector.serial": "Seriale (USB)",
"editor.clone": "Clona",
"editor.default_text": "Testo",
"editor.delete": "Elimina",
"editor.iconpicker.mdi_link_title": "Vedi elenco dettagliato",
"editor.iconpicker.search": "Cerca",
"editor.iconpicker.title": "Aggiungi icona",
"editor.import.zpl": "Importa ZPL",
"editor.objectpicker.barcode": "Codice a barre",
"editor.objectpicker.circle": "Cerchio",
"editor.objectpicker.image": "Immagine",
"editor.objectpicker.line": "Linea",
"editor.objectpicker.qrcode": "Codice QR",
"editor.objectpicker.rectangle": "Rettangolo",
"editor.objectpicker.text": "Testo",
"editor.objectpicker.title": "Aggiungi oggetto",
"editor.preview": "Anteprima",
"editor.print": "Stampa",
"editor.redo": "Ripristina",
"editor.undo": "Annulla",
"editor.warning.load": "La tela verrà sostituita con i dati salvati",
"editor.warning.save": "I dati salvati verranno sovrascritti. Salvare?",
"main.built": "costruito in",
"main.code": "Versione",
"params.barcode.content": "Contenuto",
"params.barcode.enable_caption": "Abilita didascalia",
"params.barcode.encoding": "Codifica",
"params.barcode.font_size": "Dimensione carattere",
"params.barcode.scale": "Fattore di scala",
"params.csv.enabled": "Abilitato",
"params.csv.placeholders": "Variabili:",
"params.csv.rowsfound": "Righe di dati trovate:",
"params.csv.tip": "La prima riga è un'header. Le virgole sono utilizzate come separatori.",
"params.csv.title": "CSV",
"params.generic.center.horizontal": "Centra orizzontalmente",
"params.generic.center.vertical": "Centra verticalmente",
"params.label.apply": "Applica",
"params.label.current": "Parametri correnti:",
"params.label.direction.left": "Sinistra",
"params.label.direction.top": "In alto",
"params.label.direction": "Direzione stampa",
"params.label.dpmm": "dpmm",
"params.label.export": "Esporta",
"params.label.head_density.help": "Calcolo: DPI / 25,4",
"params.label.head_density": "Densità pixel",
"params.label.import": "Importa",
"params.label.label_title": "Titolo personalizzato",
"params.label.menu_title": "Proprietà etichetta",
"params.label.mm": "mm",
"params.label.px": "px",
"params.label.save_template": "Salva come modello",
"params.label.shape": "Forma dell'etichetta",
"params.label.size": "Dimensione",
"params.label.split": "Etichetta divisa",
"params.label.tail.length": "Lunghezza della divisone",
"params.label.tail.position": "Posizione della divisone",
"params.label.warning.direction": "Direzione consigliata per la stampante:",
"params.label.warning.import": "Tutti i modelli salvati verranno sovrascritti",
"params.label.warning.width": "La larghezza dell'etichetta è troppo grande per la stampante:",
"params.qrcode.ecl": "Livello di correzione degli errori",
"params.saved_labels.kb_used": "kB utilizzati",
"params.saved_labels.label_title.placeholder": "(facoltativo)",
"params.saved_labels.label_title": "Titolo",
"params.saved_labels.load.browser": "Carica",
"params.saved_labels.load.json": "Importa",
"params.saved_labels.make_default": "Rendere predefinito",
"params.saved_labels.menu_title": "Salva/carica (salva in browser)",
"params.saved_labels.save.browser.replace": "Salva (sostituisci)",
"params.saved_labels.save.browser": "Salva",
"params.saved_labels.save.json": "Esporta",
"params.text.align.center": "Allinea il testo: al centro",
"params.text.align.left": "Allinea il testo: a sinistra",
"params.text.align.right": "Allinea testo: a destra",
"params.text.bold": "Grassetto",
"params.text.edit.title": "Modifica testo",
"params.text.edit": "Modifica in popup",
"params.text.fetch_fonts": "Recupera font",
"params.text.font_family": "Famiglia di font",
"params.text.font_size.down": "Diminuisci dimensione font",
"params.text.font_size.up": "Aumenta dimensione font",
"params.text.font_size": "Dimensione font",
"params.text.line_height": "Altezza riga",
"params.text.vorigin.bottom": "Basso",
"params.text.vorigin.center": "Centro",
"params.text.vorigin.top": "Alto",
"params.text.vorigin": "Origine verticale",
"params.variables.insert.date": "Data",
"params.variables.insert.datetime": "Data e ora",
"params.variables.insert.time": "Ora",
"params.variables.insert": "Inserisci variabile",
"preview.close": "Chiudi",
"preview.copies": "Copie",
"preview.density": "Densità",
"preview.label_type.Black": "Nero",
"preview.label_type.BlackMarkGap": "Spazio segno nero",
"preview.label_type.Continuous": "Continuo",
"preview.label_type.HeatShrinkTube": "Tubo termoretraibile",
"preview.label_type.Invalid": "Non valido",
"preview.label_type.Perforated": "Perforato",
"preview.label_type.PvcTag": "Etichetta in PVC",
"preview.label_type.Transparent": "Trasparente",
"preview.label_type.WithGaps": "With Gaps",
"preview.label_type": "Tipo di etichetta",
"preview.not_connected": "La stampante non è connessa",
"preview.not_implemented": "NON IMPLEMENTATO",
"preview.offset.inner": "Interno",
"preview.offset.outer": "Esterno",
"preview.offset": "Offset",
"preview.postprocess.atkinson": "Dither (Atkinson)",
"preview.postprocess.threshold": "Soglia",
"preview.postprocess": "Post-processo",
"preview.print_task": "Stampante",
"preview.print.cancel": "Annulla stampa",
"preview.print": "Stampa",
"preview.threshold": "Soglia",
"preview.title": "Anteprima",
"params.color.white": "bianco",
"params.color.black": "nero"
}

View File

@@ -0,0 +1,151 @@
{
"lang.name": "한국어",
"browser_warning.fingerprinting": "브라우저가 지문 추적을 방지하기 위해 캔버스를 왜곡하고 있는 것 같습니다. 이 보호 기능을 해제하세요. 그렇지 않으면 라벨에 이상한 자국이 생길 수 있습니다.",
"browser_warning.lines.first": "앗, 브라우저가 블루투스 및 USB(시리얼) 통신을 지원하지 않습니다.",
"browser_warning.lines.second": "어쨌든 라벨을 계속 그릴 수 있습니다.",
"connector.bluetooth": "블루투스",
"connector.disconnect.heartbeat": "연결 끊김 (프린터가 응답하지 않음)",
"connector.serial": "시리얼 (USB)",
"editor.clear": "캔버스 지우기",
"editor.clear.confirm": "캔버스의 내용을 모두 지우시겠습니까?",
"editor.clone": "복제",
"editor.default_text": "텍스트",
"editor.delete": "삭제",
"editor.iconpicker.mdi_link_title": "자세한 목록 보기",
"editor.iconpicker.search": "검색",
"editor.iconpicker.title": "아이콘 추가",
"editor.import.zpl": "ZPL 가져오기",
"editor.objectpicker.barcode": "바코드",
"editor.objectpicker.circle": "원",
"editor.objectpicker.image": "이미지",
"editor.objectpicker.line": "선",
"editor.objectpicker.qrcode": "QR 코드",
"editor.objectpicker.rectangle": "사각형",
"editor.objectpicker.text": "텍스트",
"editor.objectpicker.title": "개체 추가",
"editor.preview": "미리보기",
"editor.print": "인쇄",
"editor.redo": "다시 실행",
"editor.undo": "실행 취소",
"editor.warning.load": "캔버스가 저장된 데이터로 대체됩니다.",
"editor.warning.save": "저장된 데이터가 덮어씌워집니다. 저장하시겠습니까?",
"main.built": "빌드 시간:",
"main.code": "코드",
"params.barcode.content": "내용",
"params.barcode.enable_caption": "캡션 사용",
"params.barcode.encoding": "인코딩",
"params.barcode.font_size": "글꼴 크기",
"params.barcode.scale": "배율",
"params.csv.enabled": "사용",
"params.csv.placeholders": "변수:",
"params.csv.rowsfound": "데이터 행 수:",
"params.csv.tip": "첫 번째 행은 헤더(변수 이름)로 사용됩니다. 쉼표(,)로 구분합니다.",
"params.csv.title": "동적 라벨 데이터 (CSV)",
"params.generic.arrange": "정렬",
"params.generic.arrange.top": "맨 앞으로 보내기",
"params.generic.arrange.bottom": "맨 뒤로 보내기",
"params.generic.center.horizontal": "가로 중앙 정렬",
"params.generic.center.vertical": "세로 중앙 정렬",
"params.generic.fit": "페이지에 맞추기",
"params.generic.fit.mode.stretch": "늘이기",
"params.generic.fit.mode.ratio_min": "맞추기",
"params.generic.fit.mode.ratio_max": "채우기",
"params.generic.position": "위치",
"params.label.apply": "적용",
"params.label.current": "현재 설정:",
"params.label.direction.left": "왼쪽",
"params.label.direction.top": "위쪽",
"params.label.direction": "인쇄 방향",
"params.label.dpmm": "dpmm",
"params.label.export": "내보내기",
"params.label.head_density.help": "계산: DPI / 25.4",
"params.label.head_density": "픽셀 밀도",
"params.label.import": "가져오기",
"params.label.label_title": "사용자 지정 제목",
"params.label.menu_title": "레이블 속성",
"params.label.mm": "mm",
"params.label.px": "px",
"params.label.save_template": "템플릿으로 저장",
"params.label.shape": "레이블 모양",
"params.label.size": "크기",
"params.label.split": "레이블 분할",
"params.label.split.count": "분할 개수",
"params.label.tail.length": "테일 길이",
"params.label.tail.position": "테일 위치",
"params.label.mirror": "개체 반전",
"params.label.warning.direction": "프린터 권장 방향:",
"params.label.warning.import": "모든 저장된 템플릿이 덮어씌워집니다.",
"params.label.warning.width": "레이블 폭이 프린터 허용 범위를 초과합니다:",
"params.label.warning.div8": "레이블 폭(픽셀 단위)은 8의 배수여야 합니다.",
"params.qrcode.ecl": "오류 수정 수준",
"params.qrcode.mode": "모드",
"params.qrcode.version": "버전",
"params.saved_labels.kb_used": "사용된 kB",
"params.saved_labels.label_title.placeholder": "(선택 사항)",
"params.saved_labels.label_title": "제목",
"params.saved_labels.load.browser": "불러오기",
"params.saved_labels.load.json": "가져오기",
"params.saved_labels.make_default": "기본값으로 설정",
"params.saved_labels.menu_title": "저장/불러오기 (브라우저 저장소)",
"params.saved_labels.save.browser.replace": "저장 (덮어쓰기)",
"params.saved_labels.save.browser": "저장",
"params.saved_labels.save.json": "내보내기",
"params.text.align.center": "텍스트 정렬: 가운데",
"params.text.align.left": "텍스트 정렬: 왼쪽",
"params.text.align.right": "텍스트 정렬: 오른쪽",
"params.text.bold": "굵게",
"params.text.edit.title": "텍스트 편집",
"params.text.edit": "팝업에서 편집",
"params.text.fetch_fonts": "글꼴 가져오기",
"params.text.font_family": "글꼴 계열",
"params.text.font_size.down": "글꼴 크기 감소",
"params.text.font_size.up": "글꼴 크기 증가",
"params.text.font_size": "글꼴 크기",
"params.text.italic": "기울임",
"params.text.line_height": "줄 높이",
"params.params.text.split": "텍스트 줄 바꿈",
"params.params.text.split.spaces": "공백",
"params.params.text.split.grapheme": "문자 단위",
"params.text.vorigin.bottom": "아래쪽",
"params.text.vorigin.center": "가운데",
"params.text.vorigin.top": "위쪽",
"params.text.vorigin": "수직 기준",
"params.variables.insert.date": "날짜",
"params.variables.insert.datetime": "날짜 및 시간",
"params.variables.insert.time": "시간",
"params.variables.insert": "변수 삽입",
"params.vector.round_radius": "둥근 모서리 반경",
"params.vector.stroke_width": "선 두께",
"params.vector.fill": "채우기",
"params.color": "색상",
"params.color.transparent": "투명",
"params.color.white": "흰색",
"params.color.black": "검정색",
"preview.close": "닫기",
"preview.copies": "매수",
"preview.density": "농도",
"preview.label_type.Black": "검정",
"preview.label_type.BlackMarkGap": "검정 마크 간격",
"preview.label_type.Continuous": "연속",
"preview.label_type.HeatShrinkTube": "열수축 튜브",
"preview.label_type.Invalid": "알 수 없음",
"preview.label_type.Perforated": "천공",
"preview.label_type.PvcTag": "PVC 태그",
"preview.label_type.Transparent": "투명",
"preview.label_type.WithGaps": "간격 있음",
"preview.label_type": "레이블 유형",
"preview.not_connected": "프린터가 연결되지 않았습니다",
"preview.not_implemented": "구현되지 않음",
"preview.offset.inner": "내부",
"preview.offset.outer": "외부",
"preview.offset": "오프셋",
"preview.postprocess.atkinson": "디더링 (앳킨슨)",
"preview.postprocess.threshold": "임계값",
"preview.postprocess": "후처리",
"preview.print_task": "인쇄 작업",
"preview.print.cancel": "인쇄 취소",
"preview.print.system": "OS 기본 프린터로 인쇄",
"preview.print": "인쇄",
"preview.threshold": "임계값",
"preview.title": "인쇄 미리보기"
}

View File

@@ -0,0 +1,180 @@
{
"lang.name": "मराठी",
"browser_warning.fingerprinting": "अरे! असे दिसत आहे की तुमचा ब्राउझर फिंगरप्रिंटिंगपासून संरक्षण करण्यासाठी कॅनव्हासमध्ये बदल करत आहे. कृपया हे संरक्षण बंद करा, कारण यामुळे लेबलवर त्रुटी निर्माण होऊ शकतात.",
"browser_warning.lines.first": "अरे नाही, तुमचा ब्राउझर ब्लूटूथ आणि सिरीयल संप्रेषणाला समर्थन देत नाही",
"browser_warning.lines.second": "तरीही, तुम्ही लेबल तयार करू शकता.",
"connector.bluetooth": "ब्लूटूथ",
"connector.disconnect.heartbeat": "कनेक्शन तुटले आहे (प्रिंटर प्रतिसाद देत नाही)",
"connector.serial": "सिरीयल (USB)",
"debug.page_delay.help": "मल्टी-पेज प्रिंटमध्ये समस्या असल्यास विलंब (उदाहरणार्थ, १०००) जोडा.",
"editor.clear": "कॅनव्हास साफ करा",
"editor.clear.confirm": "कॅनव्हासमधील कंटेंट साफ करायची का?",
"editor.clone": "प्रतिलिपी तयार करा",
"editor.default_text": "मजकूर",
"editor.delete": "हटवा",
"editor.iconpicker.add": "नवीन जोडा",
"editor.iconpicker.delete_mode": "हटविण्याचा मोड",
"editor.iconpicker.search": "शोधा",
"editor.iconpicker.show": "दाखवा",
"editor.iconpicker.show.user": "फक्त वापरकर्ता आयकॉन",
"editor.iconpicker.show.pack": "फक्त पॅक आयकॉन",
"editor.iconpicker.show.both": "वापरकर्ता आणि पॅक आयकॉन",
"editor.iconpicker.mdi_link_title": "सविस्तर यादी येथे पहा",
"editor.iconpicker.title": "आयकॉन जोडा",
"editor.import.zpl": "ZPL आयात करा",
"editor.objectpicker.barcode": "बारकोड",
"editor.objectpicker.circle": "वर्तुळ",
"editor.objectpicker.image": "प्रतिमा",
"editor.objectpicker.line": "रेषा",
"editor.objectpicker.qrcode": "QR कोड",
"editor.objectpicker.rectangle": "आयत",
"editor.objectpicker.text": "मजकूर",
"editor.objectpicker.title": "ऑब्जेक्ट जोडा",
"editor.preview": "पूर्वावलोकन",
"editor.print": "प्रिंट करा",
"editor.redo": "पुन्हा करा",
"editor.undo": "पूर्ववत करा",
"editor.warning.load": "कॅनव्हास जतन केलेल्या डेटाने बदलला जाईल",
"editor.warning.load.csv": "CSV डेटा देखील बदलला जाईल",
"editor.warning.save": "जतन केलेला डेटा अधिलेखित होईल. जतन करायचे का?",
"params.label.menu_title": "लेबल गुणधर्म",
"params.label.label_title": "सानुकूल शीर्षक",
"params.label.shape": "लेबलचा आकार",
"params.label.size": "आकार",
"params.label.tail.length": "शेपटीची लांबी",
"params.label.tail.position": "शेपटीची स्थिती",
"params.label.mirror": "ऑब्जेक्ट मिररिंग",
"params.label.warning.direction": "तुमच्या प्रिंटरसाठी शिफारस केलेली दिशा:",
"params.csv.title": "डायनॅमिक लेबल डेटा (CSV)",
"params.csv.placeholders": "चल:",
"params.csv.rowsfound": "सापडलेल्या डेटा ओळी:",
"params.csv.tip": "पहिली ओळ ही हेडर असते. ती चलांच्या नावांसाठी वापरली जाते. स्वल्पविराम (,) विभाजक म्हणून वापरले जातात.",
"params.barcode.font_size": "फॉन्ट आकार",
"params.text.font_size": "फॉन्ट आकार",
"params.text.font_size.up": "फॉन्ट आकार वाढवा",
"params.text.font_size.down": "फॉन्ट आकार कमी करा",
"params.text.bold": "ठळक",
"params.text.italic": "तिरपे",
"params.text.line_height": "ओळ उंची",
"params.text.align.center": "मजकूर: मध्यभागी संरेखित करा",
"params.text.align.left": "मजकूर: डावीकडे संरेखित करा",
"params.text.align.right": "मजकूर: उजवीकडे संरेखित करा",
"params.text.autosize": "फील्डच्या रुंदीप्रमाणे मजकूराचा आकार समायोजित करा",
"params.text.edit": "पॉपअपमध्ये संपादित करा",
"params.text.fetch_fonts": "फॉन्ट मिळवा",
"preview.title": "प्रिंट पूर्वावलोकन",
"preview.not_connected": "प्रिंटर जोडलेला नाही",
"preview.not_implemented": "अद्याप लागू केलेले नाही",
"preview.offset.inner": "आतील",
"preview.offset.outer": "बाहेरील",
"preview.offset": "ऑफसेट",
"preview.postprocess": "पोस्ट-प्रोसेस",
"preview.print_task": "प्रिंट कार्य",
"preview.print.cancel": "प्रिंट रद्द करा",
"preview.print.system": "OS प्रिंटरद्वारे प्रिंट करा",
"preview.print": "प्रिंट",
"preview.copies": "प्रती",
"preview.density": "घनता",
"params.color.black": "काळा",
"preview.label_type.Black": "काळा",
"params.color.white": "पांढरा",
"params.color.transparent": "पारदर्शक",
"preview.label_type.Transparent": "पारदर्शक",
"preview.label_type.Invalid": "अवैध",
"preview.label_type.Continuous": "सतत",
"preview.label_type.Perforated": "छिद्रित",
"preview.label_type.PvcTag": "PVC टॅग",
"main.built": "निर्मिती वेळ",
"main.code": "कोड",
"params.barcode.content": "कंटेंट",
"params.barcode.enable_caption": "कॅप्शन सक्षम करा",
"params.barcode.encoding": "एन्कोडिंग",
"params.barcode.scale": "स्केल गुणांक",
"params.csv.enabled": "सक्षम",
"params.generic.arrange": "मांडणी करा",
"params.generic.arrange.top": "समोर आणा",
"params.generic.arrange.bottom": "मागे पाठवा",
"params.generic.center.horizontal": "आडव्या दिशेने मध्यभागी ठेवा",
"params.generic.center.vertical": "उभ्या दिशेने मध्यभागी ठेवा",
"params.generic.fit": "पानानुसार बसवा",
"params.generic.fit.mode.stretch": "ताणा",
"params.generic.fit.mode.ratio_min": "बसवा",
"params.generic.fit.mode.ratio_max": "भरून घ्या",
"params.generic.position": "स्थिती",
"params.label.apply": "लागू करा",
"params.label.current": "सध्याचे पॅरामीटर्स:",
"params.label.direction.left": "डावे",
"params.label.direction.top": "वर",
"params.label.direction": "प्रिंट दिशा",
"params.label.dpmm": "डीपीएमएम",
"params.label.export": "निर्यात करा",
"params.label.head_density.help": "गणना: DPI / 25.4",
"params.label.head_density": "पिक्सेल घनता",
"params.label.import": "आयात करा",
"params.label.mm": "एमएम",
"params.label.px": "पीएक्स",
"params.label.save_template": "टेम्पलेट म्हणून जतन करा",
"params.label.split": "लेबल विभाजन",
"params.label.split.count": "विभाजनाचे भाग",
"params.label.warning.import": "सर्व जतन केलेले टेम्पलेट अधिलेखित होतील",
"params.label.warning.width": "लेबलची रुंदी तुमच्या प्रिंटरसाठी खूप मोठी आहे:",
"params.label.warning.div8": "पिक्सेलमधील लेबलची रुंदी 8 ने पूर्णपणे भाग जाणारी असावी",
"params.qrcode.ecl": "त्रुटी सुधार स्तर",
"params.qrcode.mode": "मोड",
"params.qrcode.version": "आवृत्ती",
"params.saved_labels.kb_used": "वापरलेले kB",
"params.saved_labels.label_title.placeholder": "(ऐच्छिक)",
"params.saved_labels.label_title": "शीर्षक",
"params.saved_labels.load.browser": "लोड करा",
"params.saved_labels.load.json": "आयात करा",
"params.saved_labels.make_default": "डीफॉल्ट करा",
"params.saved_labels.menu_title": "जतन/लोड करा (ब्राउझर स्टोरेज)",
"params.saved_labels.save.withcsv": "डायनॅमिक डेटा (CSV) सक्षम आहे, त्यामुळे तो जतन केलेल्या लेबलमध्ये समाविष्ट केला जाईल",
"params.saved_labels.save.browser.replace": "जतन करा (बदला)",
"params.saved_labels.save.browser": "जतन करा",
"params.saved_labels.save.json": "निर्यात करा",
"params.saved_labels.save.url": "लिंक कॉपी करा",
"params.saved_labels.load.url.warn": "URL वरून लेबल लोड करायचे का? यामध्ये धोका असू शकतो.",
"params.saved_labels.load.url.loaded": "लेबल URL वरून लोड केले गेले",
"params.saved_labels.save.url.copied": "लेबल URL क्लिपबोर्डवर कॉपी केले गेले",
"params.saved_labels.save.url.warn": "लेबल डेटा खूप मोठा आहे आणि URL सर्व ब्राउझरमध्ये काम करेलच असे नाही.\nलेबलचा आकार कमी करण्याचा प्रयत्न करा (उदा. प्रतिमा काढा).\nपुढे जायचे का?",
"params.text.edit.title": "मजकूर संपादन",
"params.text.font_family": "फॉन्ट कुटुंब",
"params.params.text.split": "मजकूर विभाजन (रॅप)",
"params.params.text.split.spaces": "स्पेस",
"params.params.text.split.grapheme": "ग्राफिम (अक्षरे)",
"params.text.vorigin.bottom": "खाली",
"params.text.vorigin.center": "मध्यभागी",
"params.text.vorigin.top": "वर",
"params.text.vorigin": "उभा मूळ बिंदू",
"params.variables.insert.date": "दिनांक",
"params.variables.insert.datetime": "दिनांक आणि वेळ",
"params.variables.insert.time": "वेळ",
"params.variables.insert": "चल समाविष्ट करा",
"params.vector.round_radius": "गोलाई त्रिज्या",
"params.vector.stroke_width": "रेषेची जाडी",
"params.vector.fill": "भराव",
"params.color": "रंग",
"preview.close": "बंद करा",
"preview.threshold": "थ्रेशहोल्ड",
"preview.label_type.BlackMarkGap": "ब्लॅक मार्क गॅप",
"preview.label_type.HeatShrinkTube": "हीट श्रिंक ट्यूब",
"preview.label_type.WithGaps": "गॅपसह",
"preview.label_type": "लेबल प्रकार",
"preview.postprocess.atkinson": "डिथर (Atkinson)",
"preview.postprocess.bayer": "डिथर (Bayer)",
"preview.postprocess.threshold": "थ्रेशहोल्ड",
"debug.packet_interval.help": "पॅकेट इंटरव्हल ओव्हरराइड करा. पेज पुन्हा लोड करणे आवश्यक आहे. जर मूल्य खूप कमी असेल तर काहीतरी बिघडू शकते.",
"debug.title": "डिबग संबंधित गोष्टी",
"debug.reset": "रीसेट",
"params.text.user_fonts": "यूझर फॉन्ट्स",
"params.text.system_fonts": "प्रणाली फॉन्ट्स",
"preview.speed": "मुद्रण गती",
"preview.speed.0": "कमी (उच्च गुणवत्ता)",
"preview.speed.1": "साधारण",
"fonts.title": "कस्टम फॉन्ट्स",
"fonts.add": "जोडा",
"fonts.browse": "शोधा…",
"fonts.gfonts": "फॉन्ट्स प्राप्त करा",
"fonts.title_override": "नाव ओवरराईड करा"
}

View File

@@ -0,0 +1,143 @@
{
"lang.name": "Polski",
"browser_warning.lines.first": "O nie, Twoja przeglądarka nie obsługuje komunikacji Bluetooth i szeregowej",
"browser_warning.lines.second": "Mimo tego możesz nadal projektować etykiety.",
"connector.bluetooth": "Bluetooth",
"connector.disconnect.heartbeat": "Rozłączono (drukarka nie odpowiada)",
"connector.serial": "Port szeregowy (USB)",
"editor.clone": "Klonuj",
"editor.default_text": "Tekst",
"editor.delete": "Usuń",
"editor.iconpicker.mdi_link_title": "Zobacz szczegółową listę tutaj",
"editor.iconpicker.search": "Szukaj",
"editor.iconpicker.title": "Dodaj ikonę",
"editor.import.zpl": "Importuj ZPL",
"editor.objectpicker.barcode": "Kod kreskowy",
"editor.objectpicker.circle": "Okrąg",
"editor.objectpicker.image": "Obraz",
"editor.objectpicker.line": "Linia",
"editor.objectpicker.qrcode": "Kod QR",
"editor.objectpicker.rectangle": "Prostokąt",
"editor.objectpicker.text": "Tekst",
"editor.objectpicker.title": "Dodaj obiekt",
"editor.preview": "Podgląd",
"editor.print": "Drukuj",
"editor.redo": "Ponów",
"editor.undo": "Cofnij",
"editor.warning.load": "Płótno zostanie zastąpione zapisanymi danymi",
"editor.warning.save": "Zapisane dane zostaną nadpisane. Czy napewno chcesz zapisać?",
"main.built": "zbudowano",
"main.code": "Kod",
"params.barcode.content": "Zawartość",
"params.barcode.enable_caption": "Włącz podpis",
"params.barcode.encoding": "Kodowanie",
"params.barcode.font_size": "Rozmiar czcionki",
"params.barcode.scale": "Współczynnik skali",
"params.csv.enabled": "Włączone",
"params.csv.placeholders": "Zmienne:",
"params.csv.rowsfound": "Znalezione wiersze danych:",
"params.csv.tip": "Pierwszy wiersz to nagłówek. Jest używany jako nazwy zmiennych. Separatorem są przecinki.",
"params.csv.title": "Dynamiczne dane etykiet (CSV)",
"params.generic.center.horizontal": "Wyśrodkuj poziomo",
"params.generic.center.vertical": "Wyśrodkuj pionowo",
"params.generic.fit": "Dopasuj do strony",
"params.generic.fit.mode.stretch": "Rozciągnij",
"params.generic.fit.mode.ratio_min": "Dopasuj",
"params.generic.fit.mode.ratio_max": "Wypełnij",
"params.label.apply": "Zastosuj",
"params.label.current": "Bieżące parametry:",
"params.label.direction.left": "Lewo",
"params.label.direction.top": "Góra",
"params.label.direction": "Kierunek druku",
"params.label.dpmm": "dpmm",
"params.label.export": "Eksportuj",
"params.label.head_density.help": "Obliczenia: DPI / 25,4",
"params.label.head_density": "Gęstość pikseli",
"params.label.import": "Importuj",
"params.label.label_title": "Własny tytuł",
"params.label.menu_title": "Właściwości etykiety",
"params.label.mm": "mm",
"params.label.px": "px",
"params.label.save_template": "Zapisz jako szablon",
"params.label.shape": "Kształt etykiety",
"params.label.size": "Rozmiar",
"params.label.split": "Podział etykiety",
"params.label.tail.length": "Długość ogona",
"params.label.tail.position": "Pozycja ogona",
"params.label.mirror": "Odbicie obiektu",
"params.label.warning.direction": "Zalecany kierunek dla Twojej drukarki:",
"params.label.warning.import": "Wszystkie zapisane szablony zostaną nadpisane",
"params.label.warning.width": "Szerokość etykiety jest zbyt duża dla twojego modelu drukarki:",
"params.qrcode.ecl": "Poziom korekcji błędów",
"params.saved_labels.kb_used": "kB użyto",
"params.saved_labels.label_title.placeholder": "(opcjonalnie)",
"params.saved_labels.label_title": "Tytuł",
"params.saved_labels.load.browser": "Wczytaj",
"params.saved_labels.load.json": "Importuj",
"params.saved_labels.make_default": "Ustaw jako domyślne",
"params.saved_labels.menu_title": "Zapis/odczyt (pamięć przeglądarki)",
"params.saved_labels.save.browser.replace": "Zapisz (zastąp)",
"params.saved_labels.save.browser": "Zapisz",
"params.saved_labels.save.json": "Eksportuj",
"params.text.align.center": "Wyrównaj tekst: Środkowo",
"params.text.align.left": "Wyrównaj tekst: Lewo",
"params.text.align.right": "Wyrównaj tekst: Prawo",
"params.text.bold": "Pogrubienie",
"params.text.edit.title": "Edytowanie tekstu",
"params.text.edit": "Edytuj w oknie popup",
"params.text.fetch_fonts": "Pobierz czcionki",
"params.text.font_family": "Rodzina czcionek",
"params.text.font_size.down": "Zmniejsz rozmiar czcionki",
"params.text.font_size.up": "Zwiększ rozmiar czcionki",
"params.text.font_size": "Rozmiar czcionki",
"params.text.line_height": "Wysokość linii",
"params.text.vorigin.bottom": "Dół",
"params.text.vorigin.center": "Środkowo",
"params.text.vorigin.top": "Góra",
"params.text.vorigin": "Pochodzenie pionowe",
"params.variables.insert.date": "Date",
"params.variables.insert.datetime": "Datetime",
"params.variables.insert.time": "Time",
"params.variables.insert": "Insert variable",
"preview.close": "Zamknij",
"preview.copies": "Kopie",
"preview.density": "Gęstość",
"preview.label_type.Black": "Czarny",
"preview.label_type.BlackMarkGap": "Czarna przerwa znakowa",
"preview.label_type.Continuous": "Ciągła",
"preview.label_type.HeatShrinkTube": "Rurka termokurczliwa",
"preview.label_type.Invalid": "Nieprawidłowy",
"preview.label_type.Perforated": "Perforowana",
"preview.label_type.PvcTag": "Etykieta PVC",
"preview.label_type.Transparent": "Przezroczysta",
"preview.label_type.WithGaps": "Z przerwami",
"preview.label_type": "Rodzaj etykiety",
"preview.not_connected": "Drukarka nie jest podłączona",
"preview.not_implemented": "NIE ZAIMPLEMENTOWANO",
"preview.offset.inner": "Wewnętrzny",
"preview.offset.outer": "Zewnętrzny",
"preview.offset": "Przesunięcie",
"preview.postprocess.atkinson": "Roztrząsanie (Atkinson)",
"preview.postprocess.threshold": "Próg",
"preview.postprocess": "Postproces",
"preview.print_task": "Zadanie drukowania",
"preview.print.cancel": "Anuluj drukowanie",
"preview.print.system": "Drukuj za pomocą systemowej drukarki",
"preview.print": "Drukuj",
"preview.threshold": "Próg",
"preview.title": "Podgląd druku",
"editor.clear": "Wyczyść etykietę",
"editor.clear.confirm": "Wyczyścić zawartość etykiety?",
"debug.page_delay.help": "Dodaj opóźnienie, jeśli masz problemy z drukowaniem wielu stron",
"editor.iconpicker.add": "Dodaj nowy",
"editor.iconpicker.delete_mode": "Tryb usuwania",
"editor.iconpicker.show": "Pokaż",
"editor.iconpicker.show.user": "tylko ikony użytkownika",
"editor.iconpicker.show.pack": "tylko ikony z pakietu",
"editor.iconpicker.show.both": "Ikony użytkownika i pakietu",
"editor.warning.load.csv": "Dane CSV zostaną również zastąpione",
"params.generic.arrange": "Uporządkuj",
"params.generic.arrange.top": "Przenieś na wierzch",
"params.generic.arrange.bottom": "Przenieś na tył",
"params.generic.position": "Pozycja"
}

View File

@@ -0,0 +1,150 @@
{
"lang.name": "Português",
"browser_warning.lines.first": "Ah não, seu navegador não suporta comunicações bluetooth e seriais",
"browser_warning.lines.second": "Mesmo assim, você ainda pode desenhar etiquetas.",
"connector.bluetooth": "Bluetooth",
"connector.disconnect.heartbeat": "Desconectado (impressora não responde)",
"connector.serial": "Serial (USB)",
"editor.clear": "Limpar tela",
"editor.clear.confirm": "Limpar o conteúdo da tela?",
"editor.clone": "Clonar",
"editor.default_text": "Texto",
"editor.delete": "Excluir",
"editor.iconpicker.mdi_link_title": "Veja a lista detalhada aqui",
"editor.iconpicker.search": "Buscar",
"editor.iconpicker.title": "Adicionar ícone",
"editor.import.zpl": "Importar ZPL",
"editor.objectpicker.barcode": "Código de barras",
"editor.objectpicker.circle": "Círculo",
"editor.objectpicker.image": "Imagem",
"editor.objectpicker.line": "Linha",
"editor.objectpicker.qrcode": "QR Code",
"editor.objectpicker.rectangle": "Retângulo",
"editor.objectpicker.text": "Texto",
"editor.objectpicker.title": "Adicionar objeto",
"editor.preview": "Pré-visualizar",
"editor.print": "Imprimir",
"editor.redo": "Refazer",
"editor.undo": "Desfazer",
"editor.warning.load": "A tela será substituída pelos dados salvos",
"editor.warning.save": "Os dados salvos serão sobrescritos. Salvar?",
"main.built": "compilado em",
"main.code": "Código",
"params.barcode.content": "Conteúdo",
"params.barcode.enable_caption": "Habilitar legenda",
"params.barcode.encoding": "Codificação",
"params.barcode.font_size": "Tamanho da fonte",
"params.barcode.scale": "Fator de escala",
"params.csv.enabled": "Habilitado",
"params.csv.placeholders": "Variáveis:",
"params.csv.rowsfound": "Linhas de dados encontradas:",
"params.csv.tip": "A primeira linha é um cabeçalho. É usada como nomes de variáveis. Vírgulas são usadas como separadores.",
"params.csv.title": "Dados dinâmicos da etiqueta (CSV)",
"params.generic.arrange": "Organizar",
"params.generic.arrange.top": "Trazer para frente",
"params.generic.arrange.bottom": "Enviar para trás",
"params.generic.center.horizontal": "Centralizar horizontalmente",
"params.generic.center.vertical": "Centralizar verticalmente",
"params.generic.fit": "Ajustar à página",
"params.generic.fit.mode.stretch": "Esticar",
"params.generic.fit.mode.ratio_min": "Ajustar",
"params.generic.fit.mode.ratio_max": "Preencher",
"params.generic.position": "Posição",
"params.label.apply": "Aplicar",
"params.label.current": "Parâmetros atuais:",
"params.label.direction.left": "Esquerda",
"params.label.direction.top": "Topo",
"params.label.direction": "Direção de impressão",
"params.label.dpmm": "dpmm",
"params.label.export": "Exportar",
"params.label.head_density.help": "Cálculo: DPI / 25.4",
"params.label.head_density": "Densidade de pixels",
"params.label.import": "Importar",
"params.label.label_title": "Título personalizado",
"params.label.menu_title": "Propriedades da etiqueta",
"params.label.mm": "mm",
"params.label.px": "px",
"params.label.save_template": "Salvar como modelo",
"params.label.shape": "Formato da etiqueta",
"params.label.size": "Tamanho",
"params.label.split": "Divisão da etiqueta",
"params.label.split.count": "Partes divididas",
"params.label.tail.length": "Comprimento da aba",
"params.label.tail.position": "Posição da aba",
"params.label.mirror": "Espelhamento de objeto",
"params.label.warning.direction": "Direção recomendada para sua impressora:",
"params.label.warning.import": "Todos os modelos salvos serão sobrescritos",
"params.label.warning.width": "A largura da etiqueta é muito grande para sua impressora:",
"params.label.warning.div8": "A largura da etiqueta em pixels deve ser divisível por 8",
"params.qrcode.ecl": "Nível de Correção de Erro",
"params.qrcode.mode": "Modo",
"params.qrcode.version": "Versão",
"params.saved_labels.kb_used": "kB usados",
"params.saved_labels.label_title.placeholder": "(opcional)",
"params.saved_labels.label_title": "Título",
"params.saved_labels.load.browser": "Carregar",
"params.saved_labels.load.json": "Importar",
"params.saved_labels.make_default": "Definir como padrão",
"params.saved_labels.menu_title": "Salvar/carregar (armazenamento do navegador)",
"params.saved_labels.save.browser.replace": "Salvar (substituir)",
"params.saved_labels.save.browser": "Salvar",
"params.saved_labels.save.json": "Exportar",
"params.text.align.center": "Alinhar texto: Centro",
"params.text.align.left": "Alinhar texto: Esquerda",
"params.text.align.right": "Alinhar texto: Direita",
"params.text.bold": "Negrito",
"params.text.edit.title": "Editando texto",
"params.text.edit": "Editar em popup",
"params.text.fetch_fonts": "Buscar fontes",
"params.text.font_family": "Família da fonte",
"params.text.font_size.down": "Diminuir tamanho da fonte",
"params.text.font_size.up": "Aumentar tamanho da fonte",
"params.text.font_size": "Tamanho da fonte",
"params.text.italic": "Itálico",
"params.text.line_height": "Altura da linha",
"params.params.text.split": "Divisão de texto (quebra)",
"params.params.text.split.spaces": "Espaços",
"params.params.text.split.grapheme": "Grapema (caracteres)",
"params.text.vorigin.bottom": "Inferior",
"params.text.vorigin.center": "Centro",
"params.text.vorigin.top": "Superior",
"params.text.vorigin": "Origem vertical",
"params.variables.insert.date": "Data",
"params.variables.insert.datetime": "Data e hora",
"params.variables.insert.time": "Hora",
"params.variables.insert": "Inserir variável",
"params.vector.round_radius": "Raio arredondado",
"params.vector.stroke_width": "Espessura do traço",
"params.vector.fill": "Preenchimento",
"params.color": "Cor",
"params.color.transparent": "transparente",
"params.color.white": "branco",
"params.color.black": "preto",
"preview.close": "Fechar",
"preview.copies": "Cópias",
"preview.density": "Densidade",
"preview.label_type.Black": "Preto",
"preview.label_type.BlackMarkGap": "Marca preta com espaço",
"preview.label_type.Continuous": "Contínua",
"preview.label_type.HeatShrinkTube": "Tubo termo retrátil",
"preview.label_type.Invalid": "Inválido",
"preview.label_type.Perforated": "Perfurada",
"preview.label_type.PvcTag": "Etiqueta PVC",
"preview.label_type.Transparent": "Transparente",
"preview.label_type.WithGaps": "Com espaços",
"preview.label_type": "Tipo de etiqueta",
"preview.not_connected": "Impressora não está conectada",
"preview.not_implemented": "NÃO IMPLEMENTADO",
"preview.offset.inner": "Interno",
"preview.offset.outer": "Externo",
"preview.offset": "Deslocamento",
"preview.postprocess.atkinson": "Dither (Atkinson)",
"preview.postprocess.threshold": "Limite",
"preview.postprocess": "Pós-processamento",
"preview.print_task": "Tarefa de impressão",
"preview.print.cancel": "Cancelar impressão",
"preview.print.system": "Imprimir com impressora do sistema",
"preview.print": "Imprimir",
"preview.threshold": "Limite",
"preview.title": "Pré-visualização de impressão"
}

View File

@@ -0,0 +1,180 @@
{
"lang.name": "Русский",
"browser_warning.fingerprinting": "Воу! Похоже, что ваш браузер искажает холст для борьбы с цифровыми отпечатками. Отключите эту защиту, так как она может вызвать артефакты на этикетках.",
"browser_warning.lines.first": "О нет, ваш браузер не поддерживает Bluetooth и последовательный порт",
"browser_warning.lines.second": "В любом случае, вы можете рисовать этикетки.",
"connector.bluetooth": "Bluetooth",
"connector.disconnect.heartbeat": "Отключено (принтер не отвечает)",
"connector.serial": "Посл. порт (USB)",
"debug.page_delay.help": "Добавьте задержку (например, 1000), если у вас возникают проблемы при многостраничной печати.",
"debug.packet_interval.help": "Переопределить интервал пакетов. Требуется обновление страницы. Что-то может сломаться при слишком низких значениях.",
"debug.title": "Отладочные штуки",
"debug.reset": "Сбросить",
"editor.clear": "Очистить холст",
"editor.clear.confirm": "Очистить содержимое холста?",
"editor.clone": "Клонировать",
"editor.default_text": "Текст",
"editor.delete": "Удалить",
"editor.iconpicker.add": "Добавить свою иконку",
"editor.iconpicker.delete_mode": "Режим удаления",
"editor.iconpicker.search": "Поиск",
"editor.iconpicker.show": "Показывать",
"editor.iconpicker.show.user": "только свои иконки",
"editor.iconpicker.show.pack": "только пакет иконок",
"editor.iconpicker.show.both": "свои иконки + пакет",
"editor.iconpicker.mdi_link_title": "Подробный список здесь",
"editor.iconpicker.title": "Добавить иконку",
"editor.import.zpl": "Импорт ZPL",
"editor.objectpicker.barcode": "Штрих-код",
"editor.objectpicker.circle": "Круг",
"editor.objectpicker.image": "Картинка",
"editor.objectpicker.line": "Линия",
"editor.objectpicker.qrcode": "QR Код",
"editor.objectpicker.rectangle": "Прямоугольник",
"editor.objectpicker.text": "Текст",
"editor.objectpicker.title": "Добавить объект",
"editor.preview": "Предпросмотр",
"editor.print": "Печать",
"editor.redo": "Повторить",
"editor.undo": "Отменить",
"editor.warning.load": "Холст будет заменён на сохранённый",
"editor.warning.load.csv": "Данные CSV также будут заменены",
"editor.warning.save": "Сохранённые данные будут перезаписаны. Сохранить?",
"main.built": "собрано",
"main.code": "Исходный код",
"params.barcode.content": "Содержимое",
"params.barcode.enable_caption": "Показывать надпись",
"params.barcode.encoding": "Тип",
"params.barcode.font_size": "Размер шрифта",
"params.barcode.scale": "Масштаб",
"params.csv.enabled": "Включить",
"params.csv.placeholders": "Переменные:",
"params.csv.rowsfound": "Найдено строк c данными:",
"params.csv.tip": "Первая строка - заголовок. Он используется для имён переменных. В качестве разделителей используются запятые.",
"params.csv.title": "Динамические данные (CSV)",
"params.generic.arrange": "Порядок",
"params.generic.arrange.top": "На передний план",
"params.generic.arrange.bottom": "На задний план",
"params.generic.center.horizontal": "Выровнять горизонтально",
"params.generic.center.vertical": "Выровнять вертикально",
"params.generic.fit": "Растянуть под размер страницы",
"params.generic.fit.mode.stretch": "Растянуть",
"params.generic.fit.mode.ratio_min": "Подогнать",
"params.generic.fit.mode.ratio_max": "Заполнить",
"params.generic.position": "Расположение",
"params.label.apply": "Применить",
"params.label.current": "Текущие параметры:",
"params.label.direction.left": "Слева",
"params.label.direction.top": "Сверху",
"params.label.direction": "Направление печати",
"params.label.dpmm": "пикс/мм",
"params.label.export": "Экспорт",
"params.label.head_density.help": "Вычисление: DPI / 25.4",
"params.label.head_density": "Плотность пикселей",
"params.label.import": "Импорт",
"params.label.label_title": "Своё название",
"params.label.menu_title": "Настройки этикетки",
"params.label.mm": "мм",
"params.label.px": "пикс.",
"params.label.save_template": "Сохранить как шаблон",
"params.label.shape": "Форма этикетки",
"params.label.size": "Размер",
"params.label.split": "Разделение этикетки",
"params.label.split.count": "Частей для разделения",
"params.label.tail.length": "Длина хвостовика",
"params.label.tail.position": "Положение хвостовика",
"params.label.warning.direction": "Рекомендуемое направление печати для вашего принтера:",
"params.label.warning.import": "Все сохранённые шаблоны будут перезаписаны",
"params.label.warning.width": "Ширина этикетки слишком велика для принтера:",
"params.label.warning.div8": "Ширина этикетки в пикселях должна нацело делиться на 8",
"params.qrcode.ecl": "Уровень коррекции ошибок",
"params.qrcode.mode": "Режим",
"params.qrcode.version": "Версия",
"params.saved_labels.kb_used": "кБ использовано",
"params.saved_labels.label_title.placeholder": "(необязательно)",
"params.saved_labels.label_title": "Название",
"params.saved_labels.load.browser": "Открыть",
"params.saved_labels.load.json": "Импорт",
"params.saved_labels.make_default": "Сделать базовым",
"params.saved_labels.menu_title": "Сохранить/загрузить (хранилище браузера)",
"params.saved_labels.save.withcsv": "Динамические данные (CSV) активированы, поэтому они будут включены в сохранённую этикетку",
"params.saved_labels.save.browser.replace": "Сохранить (заменить)",
"params.saved_labels.save.browser": "Сохранить",
"params.saved_labels.save.json": "Экспорт",
"params.saved_labels.save.url": "Копировать ссылку",
"params.saved_labels.load.url.warn": "Загрузить этикетку из URL? Это может быть опасно.",
"params.saved_labels.load.url.loaded": "Этикетка загружена из URL",
"params.saved_labels.save.url.copied": "Ссылка на этикетку URL скопирована в буфер обмена",
"params.saved_labels.save.url.warn": "Размер данных этикетки слишком большой и ссылка может не работать во всех браузерах.\nПопробуйте уменьшить размер данных (например, удалить изображения).\nПродолжить?",
"params.text.align.center": "Выравнивание текста: По центру",
"params.text.align.left": "Выравнивание текста: Слева",
"params.text.align.right": "Выравнивание текста: Справа",
"params.text.autosize": "Подгонять размер текста под ширину поля",
"params.text.bold": "Полужирный",
"params.text.edit.title": "Редактирование текста",
"params.text.edit": "Редактировать во всплывающем диалоге",
"params.text.fetch_fonts": "Получить список шрифтов",
"params.text.font_family": "Шрифт",
"params.text.user_fonts": "Шрифты пользователя",
"params.text.system_fonts": "Системные шрифты",
"params.text.font_size.down": "Уменьшить размер шрифта",
"params.text.font_size.up": "Увеличить размер шрифта",
"params.text.font_size": "Размер шрифта",
"params.text.italic": "Курсив",
"params.text.line_height": "Межстрочный интервал",
"params.params.text.split": "Перенос текста",
"params.params.text.split.spaces": "По пробелам",
"params.params.text.split.grapheme": "По графемам (символам)",
"params.text.vorigin.bottom": "Снизу",
"params.text.vorigin.center": "По центру",
"params.text.vorigin.top": "Сверху",
"params.text.vorigin": "Вертикальная привязка",
"params.variables.insert.date": "Дата",
"params.variables.insert.datetime": "Дата и время",
"params.variables.insert.time": "Время",
"params.variables.insert": "Вставить переменную",
"params.vector.round_radius": "Радиус скругления",
"params.vector.stroke_width": "Толщина обводки",
"params.vector.fill": "Заливка",
"params.color": "Цвет",
"params.color.transparent": "прозрачный",
"params.color.white": "белый",
"params.color.black": "чёрный",
"preview.close": "Закрыть",
"preview.copies": "Копии",
"preview.density": "Плотность",
"preview.speed": "Скорость печати",
"preview.speed.0": "Медленнее (выше качество)",
"preview.speed.1": "Нормально",
"preview.label_type.Black": "Чёрный",
"preview.label_type.BlackMarkGap": "С чёрными метками",
"preview.label_type.Continuous": "Неразрывный",
"preview.label_type.HeatShrinkTube": "Термоусадочная трубка",
"preview.label_type.Invalid": "Некорректный",
"preview.label_type.Perforated": "С отверстиями",
"preview.label_type.PvcTag": "ПВХ",
"preview.label_type.Transparent": "Прозрачный",
"preview.label_type.WithGaps": "С промежутками",
"preview.label_type": "Тип этикетки",
"preview.not_connected": "Принтер не подключен",
"preview.not_implemented": "НЕ РЕАЛИЗОВАНО",
"preview.offset.inner": "Внутри",
"preview.offset.outer": "Снаружи",
"preview.offset": "Смещение",
"preview.postprocess.atkinson": "Дизеринг (Аткинсон)",
"preview.postprocess.bayer": "Дизеринг (Байер)",
"preview.postprocess.threshold": "Порог",
"preview.postprocess": "Постобработка",
"preview.print_task": "Задача печати",
"preview.print.cancel": "Отменить печать",
"preview.print.system": "Печатать с помощью принтера ОС",
"preview.print": "Печать",
"preview.threshold": "Порог",
"preview.title": "Предпросмотр печати",
"params.label.mirror": "Зеркалирование объектов",
"fonts.title": "Пользовательские шрифты",
"fonts.add": "Добавить",
"fonts.browse": "Обзор...",
"fonts.gfonts": "Найти шрифты",
"fonts.title_override": "Своё название"
}

View File

@@ -0,0 +1,165 @@
{
"lang.name": "Türkçe",
"browser_warning.fingerprinting": "Dikkat! Tarayıcınız parmak izi koruması için tuvali bozuyor gibi görünüyor. Lütfen bu korumayı devre dışı bırakın, etiketlerde bozulmalara neden olabilir.",
"browser_warning.lines.first": "Maalesef tarayıcınız Bluetooth ve seri iletişimi desteklemiyor",
"browser_warning.lines.second": "Yine de etiket tasarlayabilirsiniz.",
"connector.bluetooth": "Bluetooth",
"connector.disconnect.heartbeat": "Bağlantı kesildi (yazıcı yanıt vermiyor)",
"connector.serial": "Seri (USB)",
"editor.clear": "Tuvali temizle",
"editor.clear.confirm": "Tuval içeriği temizlensin mi?",
"editor.clone": "Kopyala",
"editor.default_text": "Metin",
"editor.delete": "Sil",
"editor.iconpicker.mdi_link_title": "Detaylı listeyi burada görün",
"editor.iconpicker.search": "Ara",
"editor.iconpicker.title": "Simge ekle",
"editor.import.zpl": "ZPL İçe Aktar",
"editor.objectpicker.barcode": "Barkod",
"editor.objectpicker.circle": "Daire",
"editor.objectpicker.image": "Resim",
"editor.objectpicker.line": "Çizgi",
"editor.objectpicker.qrcode": "QR Kod",
"editor.objectpicker.rectangle": "Dikdörtgen",
"editor.objectpicker.text": "Metin",
"editor.objectpicker.title": "Nesne ekle",
"editor.preview": "Önizleme",
"editor.print": "Yazdır",
"editor.redo": "Yinele",
"editor.undo": "Geri al",
"editor.warning.load": "Tuval kaydedilen verilerle değiştirilecek",
"editor.warning.save": "Kaydedilen veriler üzerine yazılacak. Kaydedilsin mi?",
"main.built": "derleme tarihi",
"main.code": "Kod",
"params.barcode.content": "İçerik",
"params.barcode.enable_caption": "Başlığı etkinleştir",
"params.barcode.encoding": "Kodlama",
"params.barcode.font_size": "Yazı boyutu",
"params.barcode.scale": "Ölçek faktörü",
"params.csv.enabled": "Etkin",
"params.csv.placeholders": "Değişkenler:",
"params.csv.rowsfound": "Bulunan veri satırları:",
"params.csv.tip": "İlk satır başlıktır. Değişken adları olarak kullanılır. Ayırıcı olarak virgül kullanılır.",
"params.csv.title": "Dinamik etiket verileri (CSV)",
"params.generic.arrange": "Düzenle",
"params.generic.arrange.top": "Öne getir",
"params.generic.arrange.bottom": "Arkaya gönder",
"params.generic.center.horizontal": "Yatay ortala",
"params.generic.center.vertical": "Dikey ortala",
"params.generic.fit": "Sayfaya sığdır",
"params.generic.fit.mode.stretch": "Uzat",
"params.generic.fit.mode.ratio_min": "Sığdır",
"params.generic.fit.mode.ratio_max": "Doldur",
"params.generic.position": "Konum",
"params.label.apply": "Uygula",
"params.label.current": "Mevcut parametreler:",
"params.label.direction.left": "Sol",
"params.label.direction.top": "Üst",
"params.label.direction": "Yazdırma yönü",
"params.label.dpmm": "dpmm",
"params.label.export": "Dışa Aktar",
"params.label.head_density.help": "Hesaplama: DPI / 25.4",
"params.label.head_density": "Piksel yoğunluğu",
"params.label.import": "İçe Aktar",
"params.label.label_title": "Özel başlık",
"params.label.menu_title": "Etiket özellikleri",
"params.label.mm": "mm",
"params.label.px": "px",
"params.label.save_template": "Şablon olarak kaydet",
"params.label.shape": "Etiket şekli",
"params.label.size": "Boyut",
"params.label.split": "Etiket bölme",
"params.label.split.count": "Bölüm sayısı",
"params.label.tail.length": "Kuyruk uzunluğu",
"params.label.tail.position": "Kuyruk konumu",
"params.label.mirror": "Nesne yansıtma",
"params.label.warning.direction": "Yazıcınız için önerilen yön:",
"params.label.warning.import": "Tüm kaydedilmiş şablonların üzerine yazılacak",
"params.label.warning.width": "Etiket genişliği yazıcınız için çok büyük:",
"params.label.warning.div8": "Piksel cinsinden etiket genişliği 8'e tam bölünebilir olmalıdır",
"params.qrcode.ecl": "Hata Düzeltme Seviyesi",
"params.qrcode.mode": "Mod",
"params.qrcode.version": "Versiyon",
"params.saved_labels.kb_used": "kB kullanıldı",
"params.saved_labels.label_title.placeholder": "(isteğe bağlı)",
"params.saved_labels.label_title": "Başlık",
"params.saved_labels.load.browser": "Yükle",
"params.saved_labels.load.json": "İçe Aktar",
"params.saved_labels.make_default": "Varsayılan yap",
"params.saved_labels.menu_title": "Kaydet/Yükle (tarayıcı depolama)",
"params.saved_labels.save.browser.replace": "Kaydet (üzerine yaz)",
"params.saved_labels.save.browser": "Kaydet",
"params.saved_labels.save.json": "Dışa Aktar",
"params.text.align.center": "Metin hizala: Orta",
"params.text.align.left": "Metin hizala: Sol",
"params.text.align.right": "Metin hizala: Sağ",
"params.text.bold": "Kalın",
"params.text.edit.title": "Metin düzenleniyor",
"params.text.edit": "Açılır pencerede düzenle",
"params.text.fetch_fonts": "Yazı tiplerini getir",
"params.text.font_family": "Yazı tipi ailesi",
"params.text.font_size.down": "Yazı boyutunu küçült",
"params.text.font_size.up": "Yazı boyutunu büyüt",
"params.text.font_size": "Yazı boyutu",
"params.text.italic": "İtalik",
"params.text.line_height": "Satır yüksekliği",
"params.params.text.split": "Metin bölme (kaydırma)",
"params.params.text.split.spaces": "Boşluklar",
"params.params.text.split.grapheme": "Grafem (karakterler)",
"params.text.vorigin.bottom": "Alt",
"params.text.vorigin.center": "Orta",
"params.text.vorigin.top": "Üst",
"params.text.vorigin": "Dikey Başlangıç",
"params.variables.insert.date": "Tarih",
"params.variables.insert.datetime": "Tarih ve saat",
"params.variables.insert.time": "Saat",
"params.variables.insert": "Değişken ekle",
"params.vector.round_radius": "Yuvarlatma yarıçapı",
"params.vector.stroke_width": "Çizgi kalınlığı",
"params.vector.fill": "Dolgu",
"params.color": "Renk",
"params.color.transparent": "şeffaf",
"params.color.white": "beyaz",
"params.color.black": "siyah",
"preview.close": "Kapat",
"preview.copies": "Kopya sayısı",
"preview.density": "Yoğunluk",
"preview.label_type.Black": "Siyah",
"preview.label_type.BlackMarkGap": "Siyah işaret boşluğu",
"preview.label_type.Continuous": "Sürekli",
"preview.label_type.HeatShrinkTube": "Isı ile büzüşen tüp",
"preview.label_type.Invalid": "Geçersiz",
"preview.label_type.Perforated": "Delikli",
"preview.label_type.PvcTag": "PVC Etiket",
"preview.label_type.Transparent": "Şeffaf",
"preview.label_type.WithGaps": "Boşluklu",
"preview.label_type": "Etiket türü",
"preview.not_connected": "Yazıcı bağlı değil",
"preview.not_implemented": "UYGULANMADI",
"preview.offset.inner": "İç",
"preview.offset.outer": "Dış",
"preview.offset": "Ofset",
"preview.postprocess.atkinson": "Dither (Atkinson)",
"preview.postprocess.threshold": "Eşik",
"preview.postprocess": "Son işlem",
"preview.print_task": "Yazdırma görevi",
"preview.print.cancel": "Yazdırmayı iptal et",
"preview.print.system": "İşletim sistemi yazıcısıyla yazdır",
"preview.print": "Yazdır",
"preview.threshold": "Eşik",
"preview.title": "Yazdırma önizlemesi",
"debug.page_delay.help": "Çok sayfalı yazdırma sorunları var ise gecikme ekleyin",
"editor.iconpicker.add": "Yeni ekle",
"editor.iconpicker.delete_mode": "Silme modu",
"editor.iconpicker.show": "Görüntüle",
"editor.iconpicker.show.user": "Sadece kullanıcı simgeleri",
"editor.iconpicker.show.pack": "Sadece paket simgeleri",
"editor.iconpicker.show.both": "kullanıcı + paket simgeleri",
"editor.warning.load.csv": "CSV verileri de değiştirilecek",
"params.saved_labels.save.withcsv": "Dinamik veri (CSV) etkinleştirildi, kaydedilen etikete dahil edilecek",
"params.saved_labels.save.url": "Linki kopyala",
"params.saved_labels.load.url.warn": "Etiket URLden yüklensin mi? Zindana girmeye hazır ol.",
"params.saved_labels.load.url.loaded": "Etiket URLden yüklendi",
"params.saved_labels.save.url.copied": "Etiket URLsi panoya kopyalandı",
"preview.postprocess.bayer": "Dither (Bayer)"
}

View File

@@ -0,0 +1,160 @@
{
"lang.name": "简体中文",
"browser_warning.lines.first": "哦豁,你的浏览器貌似不支持蓝牙和串口通讯",
"browser_warning.lines.second": "但是你依然可以使用标签编辑功能",
"connector.bluetooth": "蓝牙",
"connector.disconnect.heartbeat": "已断开(打印机无响应)",
"connector.serial": "串口USB",
"editor.clone": "复制",
"editor.default_text": "文本",
"editor.delete": "删除",
"editor.iconpicker.mdi_link_title": "在这里查看详细列表",
"editor.iconpicker.search": "搜索",
"editor.iconpicker.title": "添加图标",
"editor.import.zpl": "导入 ZPL 文件",
"editor.objectpicker.barcode": "条码",
"editor.objectpicker.circle": "圆形",
"editor.objectpicker.image": "图片",
"editor.objectpicker.line": "线条",
"editor.objectpicker.qrcode": "二维码",
"editor.objectpicker.rectangle": "矩形",
"editor.objectpicker.text": "文字",
"editor.objectpicker.title": "添加元素",
"editor.preview": "预览",
"editor.print": "打印",
"editor.redo": "重做",
"editor.undo": "撤消",
"editor.warning.load": "画布将被替换为保存的数据,需要继续吗?",
"editor.warning.save": "保存的数据将会被覆盖,需要继续吗?",
"main.built": "编译于",
"main.code": "查看源码",
"params.barcode.content": "内容",
"params.barcode.enable_caption": "打印文字",
"params.barcode.encoding": "类型",
"params.barcode.font_size": "字体大小",
"params.barcode.scale": "缩放比例",
"params.csv.enabled": "启用",
"params.csv.placeholders": "变量:",
"params.csv.rowsfound": "数据行数:",
"params.csv.tip": "第一行是表头,用作变量名。英文逗号用作分隔符。",
"params.csv.title": "动态标签数据CSV",
"params.generic.center.horizontal": "横向居中",
"params.generic.center.vertical": "竖向居中",
"params.label.apply": "应用",
"params.label.current": "当前设置:",
"params.label.direction.left": "向左",
"params.label.direction.top": "向上",
"params.label.direction": "出纸方向",
"params.label.dpmm": "点/毫米",
"params.label.export": "导出 JSON 文件",
"params.label.head_density.help": "计算方法:DPI / 25.4",
"params.label.head_density": "像素密度",
"params.label.import": "导入 JSON 文件",
"params.label.label_title": "自定义名称",
"params.label.menu_title": "标签规格",
"params.label.mm": "毫米",
"params.label.px": "像素",
"params.label.save_template": "保存模版",
"params.label.shape": "标签形状",
"params.label.size": "尺寸",
"params.label.split": "标签分割",
"params.label.tail.length": "尾巴长度",
"params.label.tail.position": "尾部位置",
"params.label.warning.direction": "根据你的打印机推荐使用:",
"params.label.warning.import": "所有已保存的标签规格将会被覆盖,是否继续?",
"params.label.warning.width": "标签宽度对于你的打印机来说可能太宽了:",
"params.qrcode.ecl": "误差校正级别",
"params.saved_labels.kb_used": "KB 已使用",
"params.saved_labels.label_title.placeholder": "(可选)",
"params.saved_labels.label_title": "名称",
"params.saved_labels.load.browser": "载入到画布",
"params.saved_labels.load.json": "导入 JSON 文件",
"params.saved_labels.make_default": "设置默认",
"params.saved_labels.menu_title": "已保存的标签",
"params.saved_labels.save.browser.replace": "保存/替换",
"params.saved_labels.save.browser": "另存为",
"params.saved_labels.save.json": "导出 JSON 文件",
"params.text.align.center": "居中",
"params.text.align.left": "左对齐",
"params.text.align.right": "右对齐",
"params.text.bold": "加粗",
"params.text.edit.title": "编辑文字",
"params.text.edit": "编辑",
"params.text.fetch_fonts": "获取系统字体",
"params.text.font_family": "字体",
"params.text.font_size.down": "缩小",
"params.text.font_size.up": "加大",
"params.text.font_size": "字体大小",
"params.text.line_height": "行距",
"params.text.vorigin.bottom": "底部对齐",
"params.text.vorigin.center": "居中",
"params.text.vorigin.top": "顶部对齐",
"params.text.vorigin": "垂直对齐",
"params.variables.insert.date": "日期",
"params.variables.insert.datetime": "日期时间",
"params.variables.insert.time": "时间",
"params.variables.insert": "插入变量",
"preview.close": "关闭",
"preview.copies": "份数",
"preview.density": "浓度",
"preview.label_type.Black": "黑标纸",
"preview.label_type.BlackMarkGap": "黑标间隙纸",
"preview.label_type.Continuous": "连续纸",
"preview.label_type.HeatShrinkTube": "热缩管",
"preview.label_type.Invalid": "无效纸张",
"preview.label_type.Perforated": "定孔纸",
"preview.label_type.PvcTag": "PVC",
"preview.label_type.Transparent": "透明纸",
"preview.label_type.WithGaps": "间隙纸",
"preview.label_type": "标签类型",
"preview.not_connected": "打印机未连接",
"preview.not_implemented": "暂不支持",
"preview.offset.inner": "内偏移",
"preview.offset.outer": "外偏移",
"preview.offset": "偏移",
"preview.postprocess.atkinson": "抖动Atkinson",
"preview.postprocess.threshold": "阈值化",
"preview.postprocess": "前置处理器",
"preview.print_task": "打印接口",
"preview.print.cancel": "取消打印",
"preview.print": "打印",
"preview.threshold": "阈值",
"preview.title": "打印预览",
"params.label.mirror": "镜像元素",
"preview.print.system": "使用系统打印接口打印",
"params.generic.fit.mode.ratio_max": "裁切",
"params.generic.fit.mode.stretch": "拉伸",
"params.generic.fit.mode.ratio_min": "填充",
"params.generic.fit": "填充页面",
"editor.clear": "清除画布",
"editor.clear.confirm": "清除画布内容吗?",
"params.label.split.count": "分割份数",
"params.vector.round_radius": "圆角半径",
"params.vector.stroke_width": "笔刷宽度",
"params.vector.fill": "填充",
"params.color.transparent": "透明",
"params.color.white": "白色",
"params.color.black": "黑色",
"params.generic.arrange": "层级",
"params.generic.arrange.bottom": "置为底层",
"params.generic.arrange.top": "移到最前",
"browser_warning.fingerprinting": "检测到浏览器正在处理画布以防止指纹识别。需要禁用此保护功能,不然打印出来的标签会出现瑕疵。",
"params.color": "颜色",
"params.text.italic": "斜体",
"params.qrcode.version": "版本",
"params.label.warning.div8": "标签宽度以像素为单位必须能被8整除",
"params.params.text.split": "文字分割(换行)",
"params.params.text.split.spaces": "空格",
"params.params.text.split.grapheme": "按字",
"params.qrcode.mode": "模式",
"params.generic.position": "位置",
"debug.page_delay.help": "如果多页打印出现了问题,请尝试添加延迟",
"params.saved_labels.save.url": "复制链接",
"params.saved_labels.load.url.warn": "确定从URL加载标签数据吗这个操作是危险的",
"params.saved_labels.load.url.loaded": "已从URL加载标签数据",
"params.saved_labels.save.url.copied": "标签URL已复制到剪贴板",
"params.saved_labels.save.url.warn": "标签数据太大了部分浏览器可能无法正确处理这个URL。\n可以尝试减小标签大小例如删除图像。\n继续吗",
"preview.postprocess.bayer": "抖动Bayer",
"editor.warning.load.csv": "CSV数据也将会被替换。",
"params.saved_labels.save.withcsv": "动态数据 (CSV) 已启用,因此它将包含在已保存的标签中。"
}

View File

@@ -0,0 +1,151 @@
{
"lang.name": "繁體中文",
"browser_warning.fingerprinting": "哇!看起來您的瀏覽器正在扭曲畫布以防止指紋識別。請停用此保護,因為它可能會在標籤上造成瑕疵。",
"browser_warning.lines.first": "哎呀,您的瀏覽器似乎不支援藍牙和序列埠通訊",
"browser_warning.lines.second": "但是您仍然可以使用標籤編輯功能",
"connector.bluetooth": "藍牙",
"connector.disconnect.heartbeat": "已中斷連線(印表機無回應)",
"connector.serial": "序列埠USB",
"editor.clear": "清除畫布",
"editor.clear.confirm": "清除畫布內容嗎?",
"editor.clone": "複製",
"editor.default_text": "文字",
"editor.delete": "刪除",
"editor.iconpicker.mdi_link_title": "在這裡查看詳細清單",
"editor.iconpicker.search": "搜尋",
"editor.iconpicker.title": "新增圖示",
"editor.import.zpl": "匯入 ZPL",
"editor.objectpicker.barcode": "條碼",
"editor.objectpicker.circle": "圓形",
"editor.objectpicker.image": "圖片",
"editor.objectpicker.line": "線條",
"editor.objectpicker.qrcode": "QR Code",
"editor.objectpicker.rectangle": "矩形",
"editor.objectpicker.text": "文字",
"editor.objectpicker.title": "新增物件",
"editor.preview": "預覽",
"editor.print": "列印",
"editor.redo": "重做",
"editor.undo": "復原",
"editor.warning.load": "畫布將被替換為已儲存的資料",
"editor.warning.save": "已儲存的資料將被覆寫。儲存?",
"main.built": "建置於",
"main.code": "原始碼",
"params.barcode.content": "內容",
"params.barcode.enable_caption": "啟用標題",
"params.barcode.encoding": "編碼",
"params.barcode.font_size": "字體大小",
"params.barcode.scale": "縮放比例",
"params.csv.enabled": "啟用",
"params.csv.placeholders": "變數:",
"params.csv.rowsfound": "找到的資料列數:",
"params.csv.tip": "第一列是標題。它用作變數名稱。逗號用作分隔符號。",
"params.csv.title": "動態標籤資料CSV",
"params.generic.arrange": "排列",
"params.generic.arrange.top": "移至最上層",
"params.generic.arrange.bottom": "移至最下層",
"params.generic.center.horizontal": "水平置中",
"params.generic.center.vertical": "垂直置中",
"params.generic.fit": "符合頁面大小",
"params.generic.fit.mode.stretch": "延展",
"params.generic.fit.mode.ratio_min": "符合",
"params.generic.fit.mode.ratio_max": "填滿",
"params.generic.position": "位置",
"params.label.apply": "套用",
"params.label.current": "目前參數:",
"params.label.direction.left": "向左",
"params.label.direction.top": "向上",
"params.label.direction": "列印方向",
"params.label.dpmm": "點/公釐",
"params.label.export": "匯出",
"params.label.head_density.help": "計算方式DPI / 25.4",
"params.label.head_density": "像素密度",
"params.label.import": "匯入",
"params.label.label_title": "自訂標題",
"params.label.menu_title": "標籤屬性",
"params.label.mm": "公釐",
"params.label.px": "像素",
"params.label.save_template": "另存為範本",
"params.label.shape": "標籤形狀",
"params.label.size": "尺寸",
"params.label.split": "標籤分割",
"params.label.split.count": "分割份數",
"params.label.tail.length": "尾巴長度",
"params.label.tail.position": "尾巴位置",
"params.label.mirror": "物件鏡像",
"params.label.warning.direction": "您的印表機建議使用的方向:",
"params.label.warning.import": "所有已儲存的範本將被覆寫",
"params.label.warning.width": "標籤寬度對於您的印表機來說太大:",
"params.label.warning.div8": "標籤寬度(像素)必須能被 8 整除",
"params.qrcode.ecl": "錯誤校正等級",
"params.qrcode.mode": "模式",
"params.qrcode.version": "版本",
"params.saved_labels.kb_used": "KB 已使用",
"params.saved_labels.label_title.placeholder": "(選填)",
"params.saved_labels.label_title": "標題",
"params.saved_labels.load.browser": "載入",
"params.saved_labels.load.json": "匯入",
"params.saved_labels.make_default": "設為預設",
"params.saved_labels.menu_title": "儲存/載入(瀏覽器儲存空間)",
"params.saved_labels.save.browser.replace": "儲存(取代)",
"params.saved_labels.save.browser": "儲存",
"params.saved_labels.save.json": "匯出",
"params.text.align.center": "對齊文字:置中",
"params.text.align.left": "對齊文字:靠左",
"params.text.align.right": "對齊文字:靠右",
"params.text.bold": "粗體",
"params.text.edit.title": "編輯文字",
"params.text.edit": "在彈出視窗中編輯",
"params.text.fetch_fonts": "擷取字型",
"params.text.font_family": "字型系列",
"params.text.font_size.down": "減少字型大小",
"params.text.font_size.up": "增加字型大小",
"params.text.font_size": "字型大小",
"params.text.italic": "斜體",
"params.text.line_height": "行高",
"params.params.text.split": "文字分割(換行)",
"params.params.text.split.spaces": "空格",
"params.params.text.split.grapheme": "字素(字元)",
"params.text.vorigin.bottom": "底部",
"params.text.vorigin.center": "置中",
"params.text.vorigin.top": "頂部",
"params.text.vorigin": "垂直原點",
"params.variables.insert.date": "日期",
"params.variables.insert.datetime": "日期時間",
"params.variables.insert.time": "時間",
"params.variables.insert": "插入變數",
"params.vector.round_radius": "圓角半徑",
"params.vector.stroke_width": "筆畫寬度",
"params.vector.fill": "填滿",
"params.color": "顏色",
"params.color.transparent": "透明",
"params.color.white": "白色",
"params.color.black": "黑色",
"preview.close": "關閉",
"preview.copies": "份數",
"preview.density": "濃度",
"preview.label_type.Black": "黑標籤",
"preview.label_type.BlackMarkGap": "黑標間隙",
"preview.label_type.Continuous": "連續",
"preview.label_type.HeatShrinkTube": "熱縮管",
"preview.label_type.Invalid": "無效",
"preview.label_type.Perforated": "打孔",
"preview.label_type.PvcTag": "PVC 標籤",
"preview.label_type.Transparent": "透明",
"preview.label_type.WithGaps": "間隙",
"preview.label_type": "標籤類型",
"preview.not_connected": "印表機未連線",
"preview.not_implemented": "尚未實作",
"preview.offset.inner": "內部",
"preview.offset.outer": "外部",
"preview.offset": "偏移",
"preview.postprocess.atkinson": "網點化Atkinson",
"preview.postprocess.threshold": "閾值",
"preview.postprocess": "後處理",
"preview.print_task": "列印任務",
"preview.print.cancel": "取消列印",
"preview.print.system": "使用作業系統印表機列印",
"preview.print": "列印",
"preview.threshold": "閾值",
"preview.title": "列印預覽"
}

69
web/src/locale/index.ts Normal file
View File

@@ -0,0 +1,69 @@
import lang_cs from "$/locale/dicts/cs.json";
import lang_de from "$/locale/dicts/de.json";
import lang_en from "$/locale/dicts/en.json";
import lang_it from "$/locale/dicts/it.json";
import lang_ru from "$/locale/dicts/ru.json";
import lang_pl from "$/locale/dicts/pl.json";
import lang_zh_cn from "$/locale/dicts/zh_cn.json";
import lang_zh_tw from "$/locale/dicts/zh_tw.json";
import lang_fr from "$/locale/dicts/fr.json";
import lang_pt_br from "$/locale/dicts/pt_BR.json";
import lang_hr from "$/locale/dicts/hr.json";
import lang_ko_kr from "$/locale/dicts/ko_KR.json";
import lang_es from "$/locale/dicts/es.json";
import lang_ar from "$/locale/dicts/ar.json";
import lang_hu from "$/locale/dicts/hu.json";
import lang_tr from "$/locale/dicts/tr.json";
import lang_hi from "$/locale/dicts/hi.json";
import lang_mr from "$/locale/dicts/mr.json";
export type TranslationKey = keyof typeof lang_en;
export type TranslationDict = Record<TranslationKey, string>;
export const langPack = {
/** English (fallback) */
en: lang_en,
/** Czech */
cs: lang_cs as TranslationDict,
/** German */
de: lang_de as TranslationDict,
/** Italian */
it: lang_it as TranslationDict,
/** Russian */
ru: lang_ru as TranslationDict,
/** Polish */
pl: lang_pl as TranslationDict,
/** Simplified Chinese */
zh_cn: lang_zh_cn as TranslationDict,
/** Traditional Chinese */
zh_tw: lang_zh_tw as TranslationDict,
/** French */
fr: lang_fr as TranslationDict,
/** Portuguese (Brazil) */
pt_br: lang_pt_br as TranslationDict,
/** Croatian */
hr: lang_hr as TranslationDict,
/** Korean */
ko_kr: lang_ko_kr as TranslationDict,
/** Spanish */
es: lang_es as TranslationDict,
/** Arabic */
ar: lang_ar as TranslationDict,
/** Hungarian */
hu: lang_hu as TranslationDict,
/** Turkish */
tr: lang_tr as TranslationDict,
/** Hindi */
hi: lang_hi as TranslationDict,
/** Marathi */
mr: lang_mr as TranslationDict,
} as const;
export type SupportedLanguage = keyof typeof langPack;
export const languageNames = Object.assign(
{},
...Object.entries(langPack).map(([k, v]) => ({
[k]: v["lang.name"] ?? k,
})),
) as Record<SupportedLanguage, string>;

148
web/src/stores.ts Normal file
View File

@@ -0,0 +1,148 @@
import { get, readable, writable } from "svelte/store";
import {
AppConfigSchema,
CsvParamsSchema,
UserFontSchema,
UserIconSchema,
type CsvParams,
type UserFont,
type UserIcon,
type AppConfig,
type AutomationProps,
type ConnectionState,
} from "$/types";
import {
RequestCommandId,
ResponseCommandId,
Utils,
instantiateClient,
type HeartbeatData,
type FicheroClient,
type PrinterInfo,
type PrinterModelMeta,
type RfidInfo,
} from "$/lib/fichero";
import { Toasts } from "$/utils/toasts";
import { tr } from "$/utils/i18n";
import { LocalStoragePersistence, writablePersisted } from "$/utils/persistence";
import { APP_CONFIG_DEFAULTS, CSV_DEFAULT, OBJECT_DEFAULTS_TEXT } from "$/defaults";
import z from "zod";
import { FileUtils } from "$/utils/file_utils";
export const fontCache = writable<string[]>([OBJECT_DEFAULTS_TEXT.fontFamily]);
export const appConfig = writablePersisted<AppConfig>("config", AppConfigSchema, APP_CONFIG_DEFAULTS);
export const userIcons = writablePersisted<UserIcon[]>("user_icons", z.array(UserIconSchema), []);
export const userFonts = writablePersisted<UserFont[]>("user_fonts", z.array(UserFontSchema), []);
export const loadedFonts = writable<FontFace[]>([]);
export const connectionState = writable<ConnectionState>("disconnected");
export const connectedPrinterName = writable<string>("");
export const printerClient = writable<FicheroClient>();
export const heartbeatData = writable<HeartbeatData>();
export const printerInfo = writable<PrinterInfo>();
export const rfidInfo = writable<RfidInfo | undefined>();
export const ribbonRfidInfo = writable<RfidInfo | undefined>();
export const printerMeta = writable<PrinterModelMeta | undefined>();
export const heartbeatFails = writable<number>(0);
export const csvData = writablePersisted<CsvParams>("csv_params", CsvParamsSchema, { data: CSV_DEFAULT });
userFonts.subscribe(FileUtils.loadFonts);
export const automation = readable<AutomationProps | undefined>(
(() => {
try {
return LocalStoragePersistence.loadAutomation() ?? undefined;
} catch (e) {
console.error(e);
}
return undefined;
})(),
);
export const refreshRfidInfo = () => {
const client = get(printerClient);
if (!client) {
return;
}
client.abstraction.rfidInfo().then(rfidInfo.set).catch(console.error);
client.abstraction
.rfidInfo2()
.then(ribbonRfidInfo.set)
.catch(() => {});
};
export const initClient = () => {
printerClient.update((prevClient: FicheroClient) => {
let newClient: FicheroClient = prevClient;
if (prevClient !== undefined) {
prevClient.disconnect();
}
newClient = instantiateClient();
const conf = get(appConfig);
if (conf.packetIntervalMs !== undefined) {
newClient.setPacketInterval(conf.packetIntervalMs);
}
newClient.on("packetsent", (e) => {
console.log(`>> ${Utils.bufToHex(e.packet.toBytes())} (${RequestCommandId[e.packet.command]})`);
});
newClient.on("packetreceived", (e) => {
console.log(`<< ${Utils.bufToHex(e.packet.toBytes())} (${ResponseCommandId[e.packet.command]})`);
});
newClient.on("connect", (e) => {
console.log("onConnect");
heartbeatFails.set(0);
connectionState.set("connected");
connectedPrinterName.set(e.info.deviceName ?? "unknown");
});
newClient.on("printerinfofetched", (e) => {
console.log("printerInfoFetched");
printerInfo.set(e.info);
printerMeta.set(newClient.getModelMetadata());
});
newClient.on("disconnect", () => {
console.log("onDisconnect");
connectionState.set("disconnected");
connectedPrinterName.set("");
printerInfo.set({});
printerMeta.set(undefined);
});
newClient.on("heartbeat", (e) => {
heartbeatFails.set(0);
heartbeatData.update((prev) => {
if (
prev?.paperRfidSuccess !== e.data?.paperRfidSuccess ||
prev?.ribbonRfidSuccess !== e.data?.ribbonRfidSuccess
) {
refreshRfidInfo();
}
return e.data;
});
});
newClient.on("heartbeatfailed", (e) => {
const maxFails = 5;
heartbeatFails.set(e.failedAttempts);
console.warn(`Heartbeat failed ${e.failedAttempts}/${maxFails}`);
if (e.failedAttempts >= maxFails) {
Toasts.error(get(tr)("connector.disconnect.heartbeat"));
newClient.disconnect();
}
});
return newClient;
});
};

30
web/src/styles/font.scss Normal file
View File

@@ -0,0 +1,30 @@
/* noto-sans-latin-wght-normal */
@font-face {
font-family: "Noto Sans Variable";
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url(@fontsource-variable/noto-sans/files/noto-sans-latin-wght-normal.woff2) format("woff2-variations");
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074,
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* noto-sans-cyrillic-wght-normal */
@font-face {
font-family: "Noto Sans Variable";
font-style: normal;
font-display: swap;
font-weight: 100 900;
src: url(@fontsource-variable/noto-sans/files/noto-sans-cyrillic-wght-normal.woff2) format("woff2-variations");
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* material-icons */
@font-face {
font-family: "Material Icons";
font-style: normal;
font-display: swap;
font-weight: 400;
src: url(material-icons/iconfont/material-icons.woff2) format("woff2");
}

2130
web/src/styles/mdi_icons.ts Normal file

File diff suppressed because it is too large Load Diff

35
web/src/styles/style.scss Normal file
View File

@@ -0,0 +1,35 @@
@import "bootstrap/scss/bootstrap-utilities";
@import "bootstrap/scss/reboot";
@import "bootstrap/scss/type";
@import "bootstrap/scss/containers";
@import "bootstrap/scss/grid";
@import "bootstrap/scss/buttons";
@import "bootstrap/scss/button-group";
@import "bootstrap/scss/forms";
@import "bootstrap/scss/dropdown";
@import "bootstrap/scss/modal";
@import "bootstrap/scss/close";
@import "bootstrap/scss/progress";
@import "bootstrap/scss/alert";
@import "bootstrap/scss/transitions";
@import "font";
body {
font-family: "Noto Sans Variable", sans-serif;
}
.toastify.toast-danger {
background: var(--bs-danger-bg-subtle);
border: 1px solid var(--bs-danger-border-subtle);
color: var(--bs-danger-text-emphasis);
}
.toastify.toast-info {
background: var(--bs-success-bg-subtle);
border: 1px solid var(--bs-success-border-subtle);
color: var(--bs-success-text-emphasis);
}
.cursor-help {
cursor: help;
}

133
web/src/types.ts Normal file
View File

@@ -0,0 +1,133 @@
import { LabelType, printTaskNames } from "$/lib/fichero";
import * as fabric from "fabric";
import { z } from "zod";
export type ConnectionState = "connecting" | "connected" | "disconnected";
export type ConnectionType = "bluetooth";
export type LabelUnit = "mm" | "px";
export type OjectType = "text" | "rectangle" | "line" | "circle" | "image" | "qrcode" | "barcode";
export type PostProcessType = "threshold" | "dither" | "bayer";
export type MoveDirection = "up" | "down" | "left" | "right";
export type LabelShape = "rect" | "rounded_rect" | "circle";
export type LabelSplit = "none" | "vertical" | "horizontal";
export type TailPosition = "right" | "bottom" | "left" | "top";
export type MirrorType = "none" | "copy" | "flip";
type _Range<T extends number, R extends unknown[]> = R["length"] extends T ? R[number] : _Range<T, [R["length"], ...R]>;
export type Range<T extends number> = number extends T ? number : _Range<T, []>;
export const CsvParamsSchema = z.object({
data: z.string(),
});
/** Not validated */
export const FabricObjectSchema = z.custom<fabric.FabricObject>((val: any): boolean => {
return typeof val === "object";
});
export const LabelPropsSchema = z.object({
printDirection: z.enum(["left", "top"]),
size: z.object({
width: z.number().positive(),
height: z.number().positive(),
}),
shape: z.enum(["rect", "rounded_rect", "circle"]).default("rect").optional(),
split: z.enum(["none", "vertical", "horizontal"]).default("none").optional(),
splitParts: z.number().min(1).default(2).optional(),
tailPos: z.enum(["right", "bottom", "left", "top"]).default("right").optional(),
tailLength: z.number().default(0).optional(),
mirror: z.enum(["none", "copy", "flip"]).default("none").optional(),
});
export const LabelPresetSchema = z.object({
width: z.number().positive(),
height: z.number().positive(),
unit: z.enum(["mm", "px"]),
dpmm: z.number().positive(),
printDirection: z.enum(["left", "top"]),
title: z.string().optional(),
shape: z.enum(["rect", "rounded_rect", "circle"]).default("rect").optional(),
split: z.enum(["none", "vertical", "horizontal"]).default("none").optional(),
splitParts: z.number().min(1).default(2).optional(),
tailPos: z.enum(["right", "bottom", "left", "top"]).default("right").optional(),
tailLength: z.number().default(0).optional(),
mirror: z.enum(["none", "copy", "flip"]).default("none").optional(),
});
export const FabricJsonSchema = z.object({
version: z.string(),
objects: z.array(FabricObjectSchema),
});
export const ExportedLabelTemplateSchema = z.object({
canvas: FabricJsonSchema,
label: LabelPropsSchema,
thumbnailBase64: z.string().optional(),
title: z.string().optional(),
timestamp: z.number().positive().optional(),
id: z.string().optional(), // filled with localStorage key, not exported
csv: CsvParamsSchema.optional(),
});
const [firstTask, ...otherTasks] = printTaskNames;
export const PreviewPropsOffsetSchema = z.object({
x: z.number(),
y: z.number(),
offsetType: z.enum(["inner", "outer"]),
});
export const PreviewPropsSchema = z.object({
postProcess: z.enum(["threshold", "dither", "bayer"]).optional(),
postProcessInvert: z.boolean().optional(),
threshold: z.number().gte(1).lte(255).optional(),
quantity: z.number().gte(1).optional(),
density: z.number().gte(1).optional(),
speed: z.union([z.literal(0), z.literal(1)]).optional(),
labelType: z.enum(LabelType).optional(),
printTaskName: z.enum([firstTask, ...otherTasks]).optional(),
offset: PreviewPropsOffsetSchema.optional(),
});
export const AutomationPropsSchema = z.object({
/** Request device connect on page load. Works only for Capacitor BLE connection. */
autoConnect: z.boolean().optional(),
/** Connect to MAC or device id. Works only for Capacitor BLE connection. */
autoConnectDeviceId: z.string().optional(),
/** immediately - just open print preview dialog */
startPrint: z.enum(["after_connect", "immediately"]).optional(),
});
export const AppConfigSchema = z.object({
/** Keep image aspect ration when using "fit" button */
fitMode: z.enum(["stretch", "ratio_min", "ratio_max"]),
pageDelay: z.number().gte(0).optional(),
iconListMode: z.enum(["user", "pack", "both"]),
packetIntervalMs: z.number().gte(0).optional(),
});
export const UserIconSchema = z.object({
name: z.string(),
data: z.string(),
});
export const UserFontSchema = z
.object({
gzippedDataB64: z.string(),
family: z.string(),
mimeType: z.string(),
});
export type CsvParams = z.infer<typeof CsvParamsSchema>;
export type UserIcon = z.infer<typeof UserIconSchema>;
export type LabelProps = z.infer<typeof LabelPropsSchema>;
export type LabelPreset = z.infer<typeof LabelPresetSchema>;
export type FabricJson = z.infer<typeof FabricJsonSchema>;
export type ExportedLabelTemplate = z.infer<typeof ExportedLabelTemplateSchema>;
export type PreviewPropsOffset = z.infer<typeof PreviewPropsOffsetSchema>;
export type PreviewProps = z.infer<typeof PreviewPropsSchema>;
export type AutomationProps = z.infer<typeof AutomationPropsSchema>;
export type AppConfig = z.infer<typeof AppConfigSchema>;
export type UserFont = z.infer<typeof UserFontSchema>;

224
web/src/utils/barcode.ts Normal file
View File

@@ -0,0 +1,224 @@
type EAN13BitPattern = {
A: string;
B: string;
C: string;
};
const ean13_bp: Record<"0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9", EAN13BitPattern> = {
"0": { A: "0001101", B: "0100111", C: "1110010" },
"1": { A: "0011001", B: "0110011", C: "1100110" },
"2": { A: "0010011", B: "0011011", C: "1101100" },
"3": { A: "0111101", B: "0100001", C: "1000010" },
"4": { A: "0100011", B: "0011101", C: "1011100" },
"5": { A: "0110001", B: "0111001", C: "1001110" },
"6": { A: "0101111", B: "0000101", C: "1010000" },
"7": { A: "0111011", B: "0010001", C: "1000100" },
"8": { A: "0110111", B: "0001001", C: "1001000" },
"9": { A: "0001011", B: "0010111", C: "1110100" },
};
const ean13_table_switch_mask = {
"0": "AAAAAA",
"1": "AABABB",
"2": "AABBAB",
"3": "AABBBA",
"4": "ABAABB",
"5": "ABBAAB",
"6": "ABBBAA",
"7": "ABABAB",
"8": "ABABBA",
"9": "ABBABA",
};
/**
* Convert 12 or 13 digit numbers to EAN13 barcode
* @param data string of 12 or 13 digits
* @returns string of EAN13 barcode, it is an array of 95 characters, each character is either 0 or 1, representing a white or black stripe, respectively.
*/
export function ean13(data: string): { text: string; bandcode: string } {
if (data.length > 13) throw new Error("Data too long for EAN13");
if (data.length < 12) data = data.padEnd(12, "0");
if (/^\d+$/.test(data) === false) throw new Error("Invalid character in EAN13");
// checksum
let checksum = 0;
for (let i = 0; i < 12; i++) {
const digit = parseInt(data[i], 10);
checksum += (i % 2 === 0 ? 1 : 3) * digit;
}
checksum = (10 - (checksum % 10)) % 10;
if (data.length === 12) data += checksum.toString();
else if (data.length === 13 && data[12] !== checksum.toString()) throw new Error("Invalid checksum in EAN13");
const result: string[] = [];
result.push("101"); // Start
// Left Side
const table_switch = ean13_table_switch_mask[data[0] as keyof typeof ean13_table_switch_mask];
for (let i = 1; i < 7; i++) {
const digit = data[i];
const tab = table_switch[i - 1] as keyof EAN13BitPattern;
const coding = ean13_bp[digit as keyof typeof ean13_bp][tab];
result.push(coding);
}
result.push("01010"); // Center Guard
// Right Side
for (let i = 7; i < 13; i++) {
const digit = data[i];
const coding = ean13_bp[digit as keyof typeof ean13_bp].C;
result.push(coding);
}
result.push("101"); // Stop
return {
text: data,
bandcode: result.join(""),
};
}
// --------------------------------
type Code128BitPattern = {
ascii: number;
code: string;
};
const code128_bp: Code128BitPattern[] = [
{ ascii: 32, code: "11011001100" }, // A: SP, B: SP, C: 00, BandCode: 212222
{ ascii: 33, code: "11001101100" }, // A: !, B: !, C: 01, BandCode: 222122
{ ascii: 34, code: "11001100110" }, // A: “, B: “, C: 02, BandCode: 222221
{ ascii: 35, code: "10010011000" }, // A: #, B: #, C: 03, BandCode: 121223
{ ascii: 36, code: "100h0001100" }, // A: $, B: $, C: 04, BandCode: 121322
{ ascii: 37, code: "10001001100" }, // A: %, B: %, C: 05, BandCode: 131222
{ ascii: 38, code: "10011001000" }, // A: &, B: &, C: 06, BandCode: 122213
{ ascii: 39, code: "10011000100" }, // A: , B: , C: 07, BandCode: 122312
{ ascii: 40, code: "10001100100" }, // A: (, B: (, C: 08, BandCode: 132212
{ ascii: 41, code: "1100h00h000" }, // A: ), B: ), C: 09, BandCode: 221213
{ ascii: 42, code: "11001000100" }, // A: *, B: *, C: 10, BandCode: 221312
{ ascii: 43, code: "11000100100" }, // A: +, B: +, C: 11, BandCode: 231212
{ ascii: 44, code: "10110011100" }, // A: ,, B: ,, C: 12, BandCode: 112232
{ ascii: 45, code: "10011011100" }, // A: -, B: -, C: 13, BandCode: 122132
{ ascii: 46, code: "10011001110" }, // A: ., B: ., C: 14, BandCode: 122231
{ ascii: 47, code: "10111001100" }, // A: /, B: /, C: 15, BandCode: 113222
{ ascii: 48, code: "10011101100" }, // A: 0, B: 0, C: 16, BandCode: 123122
{ ascii: 49, code: "10011100110" }, // A: 1, B: 1, C: 17, BandCode: 123221
{ ascii: 50, code: "11001110010" }, // A: 2, B: 2, C: 18, BandCode: 223211
{ ascii: 51, code: "11001011100" }, // A: 3, B: 3, C: 19, BandCode: 221132
{ ascii: 52, code: "11001001110" }, // A: 4, B: 4, C: 20, BandCode: 221231
{ ascii: 53, code: "11011100100" }, // A: 5, B: 5, C: 21, BandCode: 213212
{ ascii: 54, code: "11001110100" }, // A: 6, B: 6, C: 22, BandCode: 223112
{ ascii: 55, code: "11101101110" }, // A: 7, B: 7, C: 23, BandCode: 312131
{ ascii: 56, code: "11101001100" }, // A: 8, B: 8, C: 24, BandCode: 311222
{ ascii: 57, code: "11100101100" }, // A: 9, B: 9, C: 25, BandCode: 321122
{ ascii: 58, code: "11100100110" }, // A: :, B: :, C: 26, BandCode: 321221
{ ascii: 59, code: "11101100100" }, // A: ;, B: ;, C: 27, BandCode: 312212
{ ascii: 60, code: "11100110100" }, // A: <, B: <, C: 28, BandCode: 322112
{ ascii: 61, code: "11100110010" }, // A: =, B: =, C: 29, BandCode: 322211
{ ascii: 62, code: "11011011000" }, // A: >, B: >, C: 30, BandCode: 212123
{ ascii: 63, code: "11011000110" }, // A: ?, B: ?, C: 31, BandCode: 212321
{ ascii: 64, code: "11000110110" }, // A: @, B: @, C: 32, BandCode: 232121
{ ascii: 65, code: "10100011000" }, // A: A, B: A, C: 33, BandCode: 111323
{ ascii: 66, code: "10001011000" }, // A: B, B: B, C: 34, BandCode: 131123
{ ascii: 67, code: "10001000110" }, // A: C, B: C, C: 35, BandCode: 131321
{ ascii: 68, code: "10110001000" }, // A: D, B: D, C: 36, BandCode: 112313
{ ascii: 69, code: "10001101000" }, // A: E, B: E, C: 37, BandCode: 132113
{ ascii: 70, code: "10001100010" }, // A: F, B: F, C: 38, BandCode: 132311
{ ascii: 71, code: "11010001000" }, // A: G, B: G, C: 39, BandCode: 211313
{ ascii: 72, code: "11000101000" }, // A: H, B: H, C: 40, BandCode: 231113
{ ascii: 73, code: "11000100010" }, // A: I, B: I, C: 41, BandCode: 231311
{ ascii: 74, code: "10110111000" }, // A: J, B: J, C: 42, BandCode: 112133
{ ascii: 75, code: "10110001110" }, // A: K, B: K, C: 43, BandCode: 112331
{ ascii: 76, code: "10001101110" }, // A: L, B: L, C: 44, BandCode: 132131
{ ascii: 77, code: "10111011000" }, // A: M, B: M, C: 45, BandCode: 113123
{ ascii: 78, code: "10111000110" }, // A: N, B: N, C: 46, BandCode: 113321
{ ascii: 79, code: "10001110110" }, // A: O, B: O, C: 47, BandCode: 133121
{ ascii: 80, code: "11101110110" }, // A: P, B: P, C: 48, BandCode: 313121
{ ascii: 81, code: "11010001110" }, // A: Q, B: Q, C: 49, BandCode: 211331
{ ascii: 82, code: "11000101110" }, // A: R, B: R, C: 50, BandCode: 231131
{ ascii: 83, code: "11011101000" }, // A: S, B: S, C: 51, BandCode: 213113
{ ascii: 84, code: "11011100010" }, // A: T, B: T, C: 52, BandCode: 213311
{ ascii: 85, code: "11011101110" }, // A: U, B: U, C: 53, BandCode: 213131
{ ascii: 86, code: "11101011000" }, // A: V, B: V, C: 54, BandCode: 311123
{ ascii: 87, code: "11101000110" }, // A: W, B: W, C: 55, BandCode: 311321
{ ascii: 88, code: "11100010110" }, // A: X, B: X, C: 56, BandCode: 331121
{ ascii: 89, code: "11101101000" }, // A: Y, B: Y, C: 57, BandCode: 312113
{ ascii: 90, code: "11101100010" }, // A: Z, B: Z, C: 58, BandCode: 312311
{ ascii: 91, code: "11100011010" }, // A: [, B: [, C: 59, BandCode: 332111
{ ascii: 92, code: "11101111010" }, // A: \, B: \, C: 60, BandCode: 314111
{ ascii: 93, code: "11001000010" }, // A: ], B: ], C: 61, BandCode: 221411
{ ascii: 94, code: "11110001010" }, // A: ^, B: ^, C: 62, BandCode: 431111
{ ascii: 95, code: "10100110000" }, // A: _, B: _, C: 63, BandCode: 111224
{ ascii: 96, code: "10100001100" }, // A: NUL, B: `, C: 64, BandCode: 111422
{ ascii: 97, code: "10010110000" }, // A: SOH, B: a, C: 65, BandCode: 121124
{ ascii: 98, code: "10010000110" }, // A: STX, B: b, C: 66, BandCode: 121421
{ ascii: 99, code: "10000101100" }, // A: ETX, B: c, C: 67, BandCode: 141122
{ ascii: 100, code: "10000100110" }, // A: EOT, B: d, C: 68, BandCode: 141221
{ ascii: 101, code: "10110010000" }, // A: ENQ, B: e, C: 69, BandCode: 112214
{ ascii: 102, code: "10110000100" }, // A: ACK, B: f, C: 70, BandCode: 112412
{ ascii: 103, code: "10011010000" }, // A: BEL, B: g, C: 71, BandCode: 122114
{ ascii: 104, code: "10011000010" }, // A: BS, B: h, C: 72, BandCode: 122411
{ ascii: 105, code: "10000110100" }, // A: HT, B: i, C: 73, BandCode: 142112
{ ascii: 106, code: "10000110010" }, // A: LF, B: j, C: 74, BandCode: 142211
{ ascii: 107, code: "11000010010" }, // A: VT, B: k, C: 75, BandCode: 241211
{ ascii: 108, code: "11001010000" }, // A: FF, B: l, C: 76, BandCode: 221114
{ ascii: 109, code: "11110111010" }, // A: CR, B: m, C: 77, BandCode: 413111
{ ascii: 110, code: "11000010100" }, // A: SO, B: n, C: 78, BandCode: 241112
{ ascii: 111, code: "10001111010" }, // A: SI, B: o, C: 79, BandCode: 134111
{ ascii: 112, code: "10100111100" }, // A: DLE, B: p, C: 80, BandCode: 111242
{ ascii: 113, code: "10010111100" }, // A: DC1, B: q, C: 81, BandCode: 121142
{ ascii: 114, code: "10010011110" }, // A: DC2, B: r, C: 82, BandCode: 121241
{ ascii: 115, code: "10111100100" }, // A: DC3, B: s, C: 83, BandCode: 114212
{ ascii: 116, code: "10011110100" }, // A: DC4, B: t, C: 84, BandCode: 124112
{ ascii: 117, code: "10011110010" }, // A: NAK, B: u, C: 85, BandCode: 124211
{ ascii: 118, code: "11110100100" }, // A: SYN, B: v, C: 86, BandCode: 411212
{ ascii: 119, code: "11110010100" }, // A: ETB, B: w, C: 87, BandCode: 421112
{ ascii: 120, code: "11110010010" }, // A: CAN, B: x, C: 88, BandCode: 421211
{ ascii: 121, code: "11011011110" }, // A: EM, B: y, C: 89, BandCode: 212141
{ ascii: 122, code: "11011110110" }, // A: SUB, B: z, C: 90, BandCode: 214121
{ ascii: 123, code: "11110110110" }, // A: ESC, B: {, C: 91, BandCode: 412121
{ ascii: 124, code: "10101111000" }, // A: FS, B: |, C: 92, BandCode: 111143
{ ascii: 125, code: "10100011110" }, // A: GS, B: }, C: 93, BandCode: 111341
{ ascii: 126, code: "10001011110" }, // A: RS, B: ~, C: 94, BandCode: 131141
{ ascii: 200, code: "10111101000" }, // A: US, B: DEL, C: 95, BandCode: 114113
{ ascii: 201, code: "10111100010" }, // A: FNC3, B: FNC3, C: 96, BandCode: 114311
{ ascii: 202, code: "11110101000" }, // A: FNC2, B: FNC2, C: 97, BandCode: 411113
{ ascii: 203, code: "11110100010" }, // A: SHIFT, B: SHIFT, C: 98, BandCode: 411311
{ ascii: 204, code: "10111011110" }, // A: CODEC, B: CODEC, C: 99, BandCode: 113141
{ ascii: 205, code: "10111101110" }, // A: CODEB, B: FNC4, C: CODEB, BandCode: 114131
{ ascii: 206, code: "11101011110" }, // A: FNC4, B: CODEA, C: CODEA, BandCode: 311141
{ ascii: 207, code: "11110101110" }, // A: FNCl, B: FNCl, C: FNCl, BandCode: 411131
{ ascii: 208, code: "11010000100" }, // A: StartA, B: StartA, C: StartA, BandCode: 211412
{ ascii: 209, code: "11010010000" }, // A: StartB, B: StartB, C: StartB, BandCode: 211214
{ ascii: 210, code: "11010011100" }, // A: StartC, B: StartC, C: StartC, BandCode: 211232
{ ascii: 211, code: "1100011101011" }, // A: Stop, B: Stop, C: Stop, BandCode: 2331112
];
const code128_ascii_to_id = code128_bp.reduce(
(acc, { ascii }, idx) => {
acc[ascii] = idx;
return acc;
},
{} as Record<number, number>,
);
/**
* Converts a string to Code128B barcode
* @param data string to convert
* @returns string of Code128B barcode, it is a sequence of 0 and 1, representing a white or black stripe, respectively.
*/
export function code128b(data: string): string {
// Code128 allows only 232 characters, but we need to add start, stop, and checksum, so there are 229 characters left.
if (data.length > 229) throw new Error("Data too long for Code128B");
const result: string[] = [];
result.push(code128_bp[104].code); // Start Code B
// Convert each character to Code128B
let checksum = 104;
for (let i = 0; i < data.length; i++) {
const id = code128_ascii_to_id[data.charCodeAt(i)];
if (id === undefined) throw new Error("Invalid character in Code128B");
result.push(code128_bp[id].code);
checksum += (i + 1) * id;
}
result.push(code128_bp[checksum % 103].code); // Checksum
result.push(code128_bp[106].code); // Stop
return result.join("");
}

29
web/src/utils/browsers.ts Normal file
View File

@@ -0,0 +1,29 @@
/** Check if browser makes some modifications to canvas when reading */
export const detectAntiFingerprinting = () => {
const size = 32;
const color = [0, 127, 255, 255];
const canvas = document.createElement("canvas");
canvas.height = size;
canvas.width = size;
const ctx = canvas.getContext("2d");
if (ctx === null) return false;
ctx.fillStyle = `rgba(${color[0]}, ${color[1]}, ${color[2]}, 1)`;
ctx.fillRect(0, 0, canvas.width, canvas.height);
const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
for (let i = 0; i < data.length; i += 1) {
if (data[i] !== color[i % 4]) {
canvas.remove();
return true;
}
}
canvas.remove();
return false;
};

View File

@@ -0,0 +1,45 @@
import * as fabric from "fabric";
import QRCode from "$/fabric-object/qrcode";
import Barcode from "$/fabric-object/barcode";
import dayjs from "dayjs";
import { TextboxExt } from "$/fabric-object/textbox-ext";
const VARIABLE_TEMPLATE_RX = /{\s*(\$?\w+)\s*(?:\|\s*(.*?)\s*)?}/g;
const preprocessDateTime = (format?: string) => {
const dt = dayjs();
if (format) {
return dt.format(format);
}
return dt.format("YYYY-MM-DD HH:mm:ss");
};
const preprocessString = (input: string, variables?: { [v: string]: string }): string => {
return input.replace(VARIABLE_TEMPLATE_RX, (src, key, filter) => {
if (variables !== undefined && key in variables) {
return variables[key];
} else if (key === "dt") {
return preprocessDateTime(filter);
}
return src;
});
};
/** Replace text templates in some canvas objects */
export const canvasPreprocess = (canvas: fabric.Canvas, variables?: { [key: string]: string }) => {
canvas.forEachObject((obj: fabric.FabricObject) => {
if (obj instanceof fabric.IText) {
const text = preprocessString(obj.text ?? "", variables);
if (obj instanceof TextboxExt && obj.fontAutoSize) {
obj.setAndShrinkText(text, obj.width);
} else {
obj.set({ text });
}
} else if (obj instanceof QRCode) {
obj.set({ text: preprocessString(obj.text ?? "", variables) });
} else if (obj instanceof Barcode && obj.encoding === "CODE128B") {
obj.set({ text: preprocessString(obj.text ?? "", variables) });
}
});
};

View File

@@ -0,0 +1,60 @@
import * as fabric from "fabric";
import Barcode from "$/fabric-object/barcode";
import QRCode from "$/fabric-object/qrcode";
export class CanvasUtils {
static equalSpacingFillText(ctx: CanvasRenderingContext2D, text: string, x: number, y: number, printWidth: number) {
// calculate every character width, and spacing
const widths = [];
for (let i = 0; i < text.length; i++) {
const char = text.charAt(i);
const metrics = ctx.measureText(char);
widths.push(metrics.width);
}
const totalWidth = widths.reduce((a, b) => a + b, 0);
const spacing = (printWidth - totalWidth) / (text.length - 1);
// print every character with calculated spacing
let offset = 0;
for (let i = 0; i < text.length; i++) {
const char = text.charAt(i);
ctx.fillText(char, x + offset, y);
offset += widths[i] + spacing;
}
}
static fixFabricObjectScale(obj: fabric.FabricObject) {
const isNotScalable = obj instanceof Barcode || obj instanceof fabric.Rect || obj instanceof QRCode;
if (isNotScalable) {
obj.set({
width: Math.round(obj.width * (obj.scaleX ?? 1)),
height: Math.round(obj.height * (obj.scaleY ?? 1)),
scaleX: 1,
scaleY: 1,
left: Math.round(obj.left),
top: Math.round(obj.top),
});
// todo: move to QRCode maybe
if (obj instanceof QRCode) {
const qrMin = 42;
const size = Math.max(obj.width + (obj.width % 2), qrMin);
obj.set({
width: size,
height: size,
});
}
}
}
static fitObjectIntoCanvas(canvas: fabric.Canvas, obj: fabric.FabricObject, xMargin: number, yMarin: number) {
const widthRatio = canvas.width / (obj.width + xMargin * 2);
const heightRatio = canvas.height / (obj.height + yMarin * 2);
const scaleFactor = Math.min(widthRatio, heightRatio);
obj.set({ left: xMargin, top: yMarin });
obj.scale(scaleFactor);
canvas.centerObjectV(obj);
canvas.centerObjectH(obj);
}
}

431
web/src/utils/file_utils.ts Normal file
View File

@@ -0,0 +1,431 @@
import * as fabric from "fabric";
import {
ExportedLabelTemplateSchema,
LabelPresetSchema,
UserFont,
type ExportedLabelTemplate,
type FabricJson,
type LabelPreset,
type LabelProps,
} from "$/types";
import { OBJECT_DEFAULTS, THUMBNAIL_HEIGHT, THUMBNAIL_QUALITY } from "$/defaults";
import { z } from "zod";
import { CustomCanvas } from "$/fabric-object/custom_canvas";
import { Capacitor } from "@capacitor/core";
import { CanvasUtils } from "$/utils/canvas_utils";
import { LocalStoragePersistence } from "./persistence";
import { csvData, loadedFonts } from "$/stores";
import { get } from "svelte/store";
export class FileUtils {
static timestamp(): number {
return Math.floor(Date.now() / 1000);
}
static timestampFloat(): number {
return Date.now() / 1000;
}
/** Convert string to base64 string */
static base64str(str: string): string {
const bytes = new TextEncoder().encode(str);
const binString = String.fromCodePoint(...bytes);
return btoa(binString);
}
/** Convert object to base64 string */
static base64obj(obj: unknown): string {
const json: string = JSON.stringify(obj);
return FileUtils.base64str(json);
}
/** Convert object to base64 string */
static base64buf(buf: ArrayBuffer): Promise<string> {
const blob = new Blob([buf]);
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
const base64 = result.split(",")[1];
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
static async decompressData(buf: BufferSource): Promise<ArrayBuffer> {
const ds = new DecompressionStream("gzip");
const writer = ds.writable.getWriter();
writer.write(buf);
writer.close();
return await new Response(ds.readable).arrayBuffer();
}
static async compressData(buf: BufferSource): Promise<ArrayBuffer> {
const cs = new CompressionStream("gzip");
const writer = cs.writable.getWriter();
writer.write(buf);
writer.close();
return await new Response(cs.readable).arrayBuffer();
}
/** Convert base64 string to bytes */
static base64toBytes(b64str: string): Uint8Array<ArrayBuffer> {
const binaryString = atob(b64str);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.codePointAt(i)!;
}
return bytes;
}
static async blobToDataUrl(file: Blob): Promise<string> {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (readerEvt: ProgressEvent<FileReader>) => {
if (readerEvt?.target?.result) {
resolve(readerEvt.target.result as string);
}
};
reader.onerror = (readerEvt: ProgressEvent<FileReader>) => {
console.error(readerEvt);
reject(new Error("File read error"));
};
});
}
static async downloadBase64Web(filename: string, mime: string, base64Data: string) {
const byteChars = atob(base64Data);
const byteNumbers = new Array(byteChars.length);
for (let i = 0; i < byteChars.length; i++) {
byteNumbers[i] = byteChars.charCodeAt(i);
}
const arr = new Uint8Array(byteNumbers);
const blob = new Blob([arr], { type: mime });
const a = document.createElement("a");
a.download = filename;
a.href = URL.createObjectURL(blob);
a.click();
setTimeout(() => {
URL.revokeObjectURL(a.href);
a.remove();
}, 10_000);
}
static async downloadBase64Capacitor(filename: string, base64Data: string) {
const { Directory, Filesystem } = await import("@capacitor/filesystem");
const { Share } = await import("@capacitor/share");
const result = await Filesystem.writeFile({
data: base64Data,
path: filename,
directory: Directory.Cache,
});
await Share.share({
title: filename,
text: filename,
url: result.uri,
});
}
static async downloadBase64(filename: string, mime: string, base64Data: string) {
if (Capacitor.getPlatform() !== "web") {
FileUtils.downloadBase64Capacitor(filename, base64Data);
return;
}
FileUtils.downloadBase64Web(filename, mime, base64Data);
}
static makeExportedLabel(canvas: fabric.Canvas, labelProps: LabelProps, includeCsv: boolean): ExportedLabelTemplate {
const thumbnailBase64: string = canvas.toDataURL({
width: canvas.width,
height: canvas.height,
left: 0,
top: 0,
multiplier: THUMBNAIL_HEIGHT / (canvas.height || 1),
quality: THUMBNAIL_QUALITY,
format: "jpeg",
});
const tpl: ExportedLabelTemplate = {
canvas: canvas.toJSON(),
label: labelProps,
thumbnailBase64,
timestamp: FileUtils.timestamp(),
};
if (includeCsv) {
tpl.csv = get(csvData);
}
tpl.id = LocalStoragePersistence.createUidForLabel(tpl);
return tpl;
}
/** Convert label template to JSON and download it */
static saveLabelAsJson(label: ExportedLabelTemplate) {
const parsed = ExportedLabelTemplateSchema.omit({ id: true }).parse(label);
const timestamp = label.timestamp ?? FileUtils.timestamp();
let filename = `label_${timestamp}.json`;
if (parsed.title && parsed.title.trim().length > 0) {
filename = `${parsed.title}.json`;
}
FileUtils.downloadBase64(filename, "application/json", FileUtils.base64obj(parsed));
}
/** Convert canvas to PNG and download it */
static saveCanvasAsPng(canvas: fabric.Canvas) {
const timestamp = FileUtils.timestamp();
const url = canvas.toDataURL({
width: canvas.width,
height: canvas.height,
left: 0,
top: 0,
format: "png",
multiplier: 1,
});
FileUtils.downloadBase64(`label_${timestamp}.png`, "image/png", url.split("base64,")[1]);
}
/** Convert label template to JSON and download it */
static saveLabelPresetsAsJson(presets: LabelPreset[]) {
const parsed = z.array(LabelPresetSchema).parse(presets);
FileUtils.downloadBase64(`presets_${FileUtils.timestamp()}.json`, "application/json", FileUtils.base64obj(parsed));
}
/**
* Open file picker and return file contents
*
* fixme: never ends if dialog closed
*
**/
static async pickFileAsync(acceptExtension: string, multiple: boolean): Promise<FileList> {
return new Promise((resolve) => {
const input: HTMLInputElement = document.createElement("input");
input.type = "file";
input.multiple = multiple;
if (acceptExtension !== "*") {
input.accept = `.${acceptExtension}`;
}
input.onchange = (e: Event) => {
const target = e.target as HTMLInputElement;
if (target.files !== null && target.files.length > 0) {
resolve(target.files);
}
};
input.click();
});
}
static async pickAndReadTextFile(acceptExtension: string, multiple: boolean): Promise<string[]> {
const fileList = await FileUtils.pickFileAsync(acceptExtension, multiple);
const result: string[] = [];
for (const file of fileList) {
const ext = file.name.split(".").pop();
if (ext === acceptExtension) {
const data = await file.text();
result.push(data);
} else {
throw new Error(`Only ${acceptExtension} allowed`);
}
}
return result;
}
static async pickAndReadSingleTextFile(acceptExtension: string): Promise<string> {
const result = await FileUtils.pickAndReadTextFile(acceptExtension, false);
if (result.length === 0) {
throw new Error("No files processed");
}
return result[0];
}
/**
* Open file picker and return file contents
* */
static async pickAndReadBinaryFile(acceptExtension: string): Promise<{ name: string; data: ArrayBuffer }> {
const fileList = await FileUtils.pickFileAsync(acceptExtension, false);
const file: File = fileList[0];
const ext = file.name.split(".").pop();
if (acceptExtension !== "*" && ext !== acceptExtension) {
throw new Error(`Only ${acceptExtension} allowed`);
}
const data: ArrayBuffer = await file.arrayBuffer();
return { name: file.name, data };
}
static async loadCanvasState(canvas: fabric.Canvas, state: FabricJson): Promise<void> {
await canvas.loadFromJSON(state, (_, obj) => {
if (obj instanceof fabric.FabricObject) {
obj.set({ snapAngle: OBJECT_DEFAULTS.snapAngle });
CanvasUtils.fixFabricObjectScale(obj);
}
});
if (canvas instanceof CustomCanvas) {
canvas.virtualZoom(canvas.getVirtualZoom());
}
canvas.requestRenderAll();
}
static printImageUrls(sources: string[]) {
const imgs = sources.map((src) => `<img src="${src}"/>`);
const html = `
<html>
<head>
<style>
html, body {
margin: 0;
padding: 0;
}
img {
display: block;
width: 100vw;
height: 100vh;
image-rendering: pixelated;
${imgs.length > 1 ? "page-break-after: always;" : ""}
}
</style>
</head>
<body>
${imgs.join("\n")}
</body>
</html>
`;
const iframe = document.createElement("iframe");
iframe.onload = () => {
const iframeWindow = iframe.contentWindow!;
iframeWindow.onafterprint = () => iframe.remove();
iframeWindow.print();
};
iframe.style.display = "none";
iframe.src = "about:blank";
iframe.srcdoc = html;
document.body.appendChild(iframe);
}
static async makeLabelUrl(label: ExportedLabelTemplate): Promise<string> {
const labelStr = JSON.stringify({ ...label, thumbnailBase64: undefined });
const encoder = new TextEncoder();
const data = encoder.encode(labelStr);
if (data.length > 2 * 1024 * 1024) {
throw new Error("Label data size > 2MB");
}
const compressed = await FileUtils.compressData(data);
const b64data = await FileUtils.base64buf(compressed);
return `${location.protocol}//${location.host}/#load=${b64data}`;
}
static urlHashParamsToDict(): Record<string, string> {
const anchorData = globalThis.location.hash.slice(1);
if (!anchorData) {
return {};
}
return anchorData.split("&").reduce((res: Record<string, string>, item: string) => {
const firstEqualsIndex = item.indexOf("=");
if (firstEqualsIndex === -1) {
// Handle case without value (e.g., "key" without "=value")
res[item] = "";
} else {
const key = item.slice(0, firstEqualsIndex);
const value = item.slice(firstEqualsIndex + 1);
res[key] = value;
}
return res;
}, {});
}
static async readLabelFromUrl(): Promise<ExportedLabelTemplate | null> {
const params = FileUtils.urlHashParamsToDict();
if ("uload" in params) {
const b64data: string = params["uload"];
const jsonBytes = FileUtils.base64toBytes(b64data);
const jsonStr = new TextDecoder().decode(jsonBytes);
const labelObj = JSON.parse(jsonStr);
return ExportedLabelTemplateSchema.parse(labelObj);
}
if (!("load" in params)) {
return null;
}
const b64data: string = params["load"];
const bytes = FileUtils.base64toBytes(b64data);
const decompressed = await FileUtils.decompressData(bytes);
const decoder = new TextDecoder();
const decoded = decoder.decode(decompressed);
const labelObj = JSON.parse(decoded);
return ExportedLabelTemplateSchema.parse(labelObj);
}
static async loadFonts(fontsToLoad: UserFont[]) {
const loadedList = get(loadedFonts);
for (const font of fontsToLoad) {
if (loadedList.some((e) => e.family === font.family)) {
continue;
}
const bytes = FileUtils.base64toBytes(font.gzippedDataB64);
const decompressed = await FileUtils.decompressData(bytes);
const b64 = await FileUtils.base64buf(decompressed);
const fontFace = new FontFace(font.family, `url(data:${font.mimeType};base64,${b64})`);
try {
const loaded = await fontFace.load();
loadedList.push(loaded);
document.fonts.add(loaded);
} catch (e) {
console.error(`Failed to load font ${font.family}:`, e);
}
}
// remove font that not exist anymore
for (let i = loadedList.length - 1; i >= 0; i--) {
const loadedFont = loadedList[i];
if (!fontsToLoad.some((e) => e.family === loadedFont.family)) {
document.fonts.delete(loadedFont);
loadedList.splice(i, 1);
}
}
loadedFonts.set(loadedList);
}
}

40
web/src/utils/i18n.ts Normal file
View File

@@ -0,0 +1,40 @@
import { derived, writable } from "svelte/store";
import type { TranslationKey, SupportedLanguage } from "$/locale";
import { languageNames, langPack } from "$/locale";
import { match as langMatch } from "@formatjs/intl-localematcher";
/** Check browser language and return supported language code.
* If language is not supported, "en" is returned. */
const guessBrowserLanguage = (): SupportedLanguage => {
const fallback: SupportedLanguage = "en";
const browserLang = navigator.language;
const supportedLangs = Object.keys(langPack).map((e) => e.replaceAll("_", "-"));
try {
const nearestLang = langMatch([browserLang], supportedLangs, fallback);
return nearestLang.replaceAll("-", "_") as SupportedLanguage;
} catch (e) {
console.warn("Unable to detect language:", e);
return fallback;
}
};
export const locale = writable<SupportedLanguage>(
(localStorage.getItem("locale") as SupportedLanguage) ?? guessBrowserLanguage(),
);
locale.subscribe((value: SupportedLanguage) => localStorage.setItem("locale", value));
export const tr = derived(locale, ($locale) => (key: TranslationKey) => {
const result = langPack[$locale] ? langPack[$locale][key] : undefined;
if (result === undefined || result === "") {
if ($locale !== "en") {
console.warn(`${key} of ${$locale} locale is not translated`);
}
return langPack.en[key];
}
return result;
});
export const locales = languageNames;
export type { TranslationKey, SupportedLanguage } from "$/locale";

View File

@@ -0,0 +1,180 @@
import * as fabric from "fabric";
import { OBJECT_DEFAULTS, OBJECT_DEFAULTS_TEXT, OBJECT_DEFAULTS_VECTOR, OBJECT_SIZE_DEFAULTS } from "$/defaults";
import Barcode from "$/fabric-object/barcode";
import { QRCode } from "$/fabric-object/qrcode";
import type { OjectType } from "$/types";
import { Toasts } from "$/utils/toasts";
import { FileUtils } from "$/utils/file_utils";
import { CanvasUtils } from "$/utils/canvas_utils";
import { TextboxExt, TextboxExtProps } from "$/fabric-object/textbox-ext";
export class LabelDesignerObjectHelper {
static async addSvg(canvas: fabric.Canvas, svgCode: string): Promise<fabric.FabricObject | fabric.Group> {
const { objects, options } = await fabric.loadSVGFromString(svgCode);
const obj = fabric.util.groupSVGElements(
objects.filter((o) => o !== null),
options,
);
obj.set({ ...OBJECT_DEFAULTS });
CanvasUtils.fitObjectIntoCanvas(canvas, obj, OBJECT_DEFAULTS.left, OBJECT_DEFAULTS.top);
canvas.add(obj);
canvas.renderAll();
return obj;
}
static async addImageFile(canvas: fabric.Canvas, file: File): Promise<fabric.FabricObject | fabric.Group> {
if (file.type.startsWith("image/svg")) {
const data = await file.text();
return await this.addSvg(canvas, data);
}
if (file.type === "image/png" || file.type === "image/jpeg" || file.type === "image/bmp" || file.type === "image/gif") {
const url = await FileUtils.blobToDataUrl(file);
const fabricImg = await fabric.FabricImage.fromURL(url);
fabricImg.set({ ...OBJECT_DEFAULTS });
CanvasUtils.fitObjectIntoCanvas(canvas, fabricImg, OBJECT_DEFAULTS.left, OBJECT_DEFAULTS.top);
canvas.add(fabricImg);
return fabricImg;
}
throw new Error("Unsupported image");
}
static async addImageWithFilePicker(fabricCanvas: fabric.Canvas): Promise<fabric.FabricObject | fabric.Group> {
const files = await FileUtils.pickFileAsync("*", false);
try {
return await this.addImageFile(fabricCanvas, files[0]);
} catch (e) {
// fixme: catch error in other place
Toasts.error(e);
throw e;
}
}
static async addImageBlob(fabricCanvas: fabric.Canvas, img: Blob): Promise<fabric.FabricImage> {
const url = await FileUtils.blobToDataUrl(img);
const fabricImg = await fabric.FabricImage.fromURL(url);
fabricImg.set({ left: 0, top: 0, snapAngle: OBJECT_DEFAULTS.snapAngle });
fabricCanvas.add(fabricImg);
return fabricImg;
}
static async addObjectFromClipboard(
fabricCanvas: fabric.Canvas,
data: DataTransfer,
): Promise<fabric.FabricObject | undefined> {
// paste image
for (const item of data.items) {
if (item.type.includes("image")) {
const file = item.getAsFile();
if (file) {
return await LabelDesignerObjectHelper.addImageFile(fabricCanvas, file);
}
}
}
// paste text
const text = data.getData("text");
if (text) {
const obj = LabelDesignerObjectHelper.addText(fabricCanvas, text);
fabricCanvas.setActiveObject(obj);
return obj;
}
}
static addText(canvas: fabric.Canvas, text?: string, options?: Partial<TextboxExtProps>): TextboxExt {
const obj = new TextboxExt(text ?? "Text", {
...OBJECT_DEFAULTS_TEXT,
...options,
});
canvas.add(obj);
canvas.centerObject(obj);
return obj;
}
static addStaticText(canvas: fabric.Canvas, text?: string, options?: Partial<fabric.TextProps>): fabric.FabricText {
const obj = new fabric.FabricText(text ?? "Text", {
...OBJECT_DEFAULTS_TEXT,
...options,
});
canvas.add(obj);
canvas.centerObject(obj);
return obj;
}
static addHLine(canvas: fabric.Canvas): fabric.Line {
const obj = new fabric.Line(
[
OBJECT_DEFAULTS.left,
OBJECT_DEFAULTS.top,
OBJECT_DEFAULTS.left + OBJECT_SIZE_DEFAULTS.width,
OBJECT_DEFAULTS.top,
],
{ ...OBJECT_DEFAULTS_VECTOR },
);
canvas.add(obj);
canvas.centerObjectV(obj);
return obj;
}
static addCircle(canvas: fabric.Canvas): fabric.Circle {
const obj = new fabric.Circle({
...OBJECT_DEFAULTS_VECTOR,
radius: OBJECT_SIZE_DEFAULTS.width / 2,
});
canvas.add(obj);
canvas.centerObjectV(obj);
return obj;
}
static addRect(canvas: fabric.Canvas): fabric.Rect {
const obj = new fabric.Rect({
...OBJECT_SIZE_DEFAULTS,
...OBJECT_DEFAULTS_VECTOR,
});
canvas.add(obj);
canvas.centerObjectV(obj);
return obj;
}
static addQrCode(canvas: fabric.Canvas): QRCode {
const qr = new QRCode({
text: "Fichero",
...OBJECT_SIZE_DEFAULTS,
...OBJECT_DEFAULTS,
});
canvas.add(qr);
return qr;
}
static addBarcode(canvas: fabric.Canvas): Barcode {
const barcode = new Barcode({
...OBJECT_DEFAULTS,
text: "123456789012",
height: OBJECT_SIZE_DEFAULTS.height,
encoding: "CODE128B",
});
canvas.add(barcode);
return barcode;
}
static addObject(canvas: fabric.Canvas, objType: OjectType): fabric.FabricObject | undefined {
switch (objType) {
case "text":
return this.addText(canvas);
case "line":
return this.addHLine(canvas);
case "circle":
return this.addCircle(canvas);
case "rectangle":
return this.addRect(canvas);
case "image":
this.addImageWithFilePicker(canvas);
return;
case "qrcode":
return this.addQrCode(canvas);
case "barcode":
return this.addBarcode(canvas);
}
}
}

View File

@@ -0,0 +1,89 @@
import * as fabric from "fabric";
import { GRID_SIZE, OBJECT_DEFAULTS } from "$/defaults";
import type { MoveDirection } from "$/types";
export class LabelDesignerUtils {
static async cloneSelection(canvas: fabric.Canvas): Promise<void> {
const clonedList: fabric.FabricObject[] = [];
const selection = canvas.getActiveObject();
if (selection === undefined) {
return;
}
const selected: fabric.FabricObject[] = canvas.getActiveObjects();
for (const obj of selected) {
const cloned = await obj.clone();
if (selection instanceof fabric.ActiveSelection) {
cloned.left += selection.left + selection.width / 2;
cloned.top += selection.top + selection.height / 2;
}
cloned.top += GRID_SIZE;
cloned.left += GRID_SIZE;
cloned.snapAngle = OBJECT_DEFAULTS.snapAngle;
clonedList.push(cloned);
}
canvas.add(...clonedList);
const newSelection = new fabric.ActiveSelection(clonedList);
canvas.setActiveObject(newSelection);
}
static moveSelection(
canvas: fabric.Canvas,
direction: MoveDirection,
ctrl?: boolean,
) {
const selected: fabric.FabricObject[] = canvas.getActiveObjects();
const amount = ctrl ? 1 : GRID_SIZE;
selected.forEach((obj) => {
if (direction === "left") {
// round to fix inter-pixel positions
obj.left = Math.round(obj.left) - amount;
} else if (direction === "right") {
obj.left = Math.round(obj.left) + amount;
} else if (direction === "up") {
obj.top = Math.round(obj.top) - amount;
} else if (direction === "down") {
obj.top = Math.round(obj.top) + amount;
}
obj.setCoords();
});
canvas.requestRenderAll();
}
static deleteSelection(canvas: fabric.Canvas) {
const selected: fabric.FabricObject[] = canvas.getActiveObjects();
selected.forEach((obj) => {
canvas.remove(obj);
});
}
static isAnyInputFocused(canvas: fabric.Canvas): boolean {
const focused: Element | null = document.activeElement;
if (
focused !== null &&
(focused.tagName === "INPUT" || focused.tagName === "TEXTAREA")
) {
return true;
}
const selected: fabric.FabricObject[] = canvas.getActiveObjects();
const editing = selected.some(
(obj) => obj instanceof fabric.IText && obj.isEditing,
);
if (editing) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,336 @@
import {
AutomationPropsSchema,
ExportedLabelTemplateSchema,
FabricJsonSchema,
LabelPresetSchema,
LabelPropsSchema,
PreviewPropsSchema,
type AutomationProps,
type ConnectionType,
type ExportedLabelTemplate,
type LabelPreset,
type LabelProps,
type PreviewProps,
} from "$/types";
import { z } from "zod";
import { FileUtils } from "$/utils/file_utils";
import { get, writable, type Updater, type Writable } from "svelte/store";
/** Writable store, value is persisted to localStorage */
export function writablePersisted<T>(
key: string,
schema: z.ZodType<T>,
initialValue: T,
): Writable<T> {
const wr = writable<T>(initialValue);
try {
const val = LocalStoragePersistence.loadAndValidateObject(key, schema);
if (val === null) {
wr.set(initialValue);
} else {
wr.set(val);
}
} catch {
wr.set(initialValue);
}
return {
subscribe: wr.subscribe,
set: (value: T) => {
LocalStoragePersistence.validateAndSaveObject(key, value, schema);
wr.set(value);
},
update: (updater: Updater<T>) => {
const newValue: T = updater(get(wr));
LocalStoragePersistence.validateAndSaveObject(key, newValue, schema);
wr.set(newValue);
},
};
}
export class LocalStoragePersistence {
/** Result in kilobytes */
static usedSpace(): number {
let total = 0;
Object.keys(localStorage).forEach((key) => {
total += (localStorage[key].length + key.length) * 2;
});
return Math.floor(total / 1024);
}
static saveObject(key: string, data: any) {
if (data === null || data === undefined) {
localStorage.removeItem(key);
return;
}
localStorage.setItem(key, JSON.stringify(data));
}
static loadObject(key: string): any {
const data = localStorage.getItem(key);
if (data !== null) {
try {
return JSON.parse(data);
} catch (e) {
console.log(e);
}
}
return null;
}
/**
* @throws {z.ZodError}
*/
static loadAndValidateObject<T>(key: string, schema: z.ZodType<T>) {
const data = this.loadObject(key);
if (data === null) {
return null;
}
return schema.parse(data);
}
static validateAndSaveObject<T>(
key: string,
data: any,
schema: z.ZodType<T>,
): void {
if (data === null || data === undefined) {
this.saveObject(key, data);
return;
}
const obj = schema.parse(data);
this.saveObject(key, obj);
}
/**
* @throws {z.ZodError}
*/
static loadLastLabelProps(): LabelProps | null {
return this.loadAndValidateObject("last_label_props", LabelPropsSchema);
}
/**
* @throws {z.ZodError}
*/
static saveLastLabelProps(labelData: LabelProps) {
this.validateAndSaveObject("last_label_props", labelData, LabelPropsSchema);
}
static createUidForLabel(label: ExportedLabelTemplate): string {
const basename = `saved_label_${label.timestamp}`;
let counter = 0;
while (`${basename}_${counter}` in localStorage) {
counter++;
}
return `${basename}_${counter}`;
}
static saveLabels(labels: ExportedLabelTemplate[]): {
zodErrors: z.ZodError[];
otherErrors: Error[];
} {
const zodErrors: z.ZodError[] = [];
const otherErrors: Error[] = [];
Object.keys(localStorage).forEach((key) => {
if (key.startsWith("saved_label")) {
localStorage.removeItem(key);
}
});
labels.forEach((label) => {
try {
if (label.timestamp === undefined) {
label.timestamp = FileUtils.timestamp();
}
const basename = `saved_label_${label.timestamp}`;
let counter = 0;
while (`${basename}_${counter}` in localStorage) {
counter++;
}
this.validateAndSaveObject(
this.createUidForLabel(label),
label,
ExportedLabelTemplateSchema.omit({ id: true }),
);
} catch (e) {
if (e instanceof z.ZodError) {
zodErrors.push(e);
}
if (e instanceof Error) {
otherErrors.push(e);
}
}
});
return { zodErrors, otherErrors };
}
/**
* @throws {z.ZodError}
*/
static loadLabels(): ExportedLabelTemplate[] {
const legacyLabel = this.loadAndValidateObject(
"saved_canvas_props",
LabelPropsSchema,
);
const legacyCanvas = this.loadAndValidateObject(
"saved_canvas_data",
FabricJsonSchema,
);
const items: ExportedLabelTemplate[] = [];
if (legacyLabel !== null && legacyCanvas !== null) {
localStorage.removeItem("saved_canvas_props");
localStorage.removeItem("saved_canvas_data");
const item: ExportedLabelTemplate = {
label: legacyLabel,
canvas: legacyCanvas,
timestamp: FileUtils.timestamp(),
};
this.validateAndSaveObject(
`saved_label_${item.timestamp}`,
item,
ExportedLabelTemplateSchema,
);
}
Object.keys(localStorage)
.sort()
.forEach((key) => {
if (key.startsWith("saved_label")) {
try {
const item = this.loadAndValidateObject(
key,
ExportedLabelTemplateSchema,
);
if (item != null) {
item.id = key;
items.push(item);
}
} catch (e) {
console.error(e);
}
}
});
return items;
}
/**
* @throws {z.ZodError}
*/
static savePreviewProps(props: PreviewProps) {
this.validateAndSaveObject(
"saved_preview_props",
props,
PreviewPropsSchema,
);
}
/**
* @throws {z.ZodError}
*/
static loadSavedPreviewProps(): PreviewProps | null {
return this.loadAndValidateObject(
"saved_preview_props",
PreviewPropsSchema,
);
}
/**
* @throws {z.ZodError}
*/
static saveLabelPresets(presets: LabelPreset[]) {
this.validateAndSaveObject(
"label_presets",
presets,
z.array(LabelPresetSchema),
);
}
/**
* @throws {z.ZodError}
*/
static loadLabelPresets(): LabelPreset[] | null {
const presets = this.loadAndValidateObject(
"label_presets",
z.array(LabelPresetSchema),
);
return presets === null || presets.length === 0 ? null : presets;
}
static loadLastConnectionType(): ConnectionType | null {
const value = localStorage.getItem("connection_type");
if (value === null || !["bluetooth", "serial"].includes(value)) {
return null;
}
return value as ConnectionType;
}
static saveLastConnectionType(value: ConnectionType) {
localStorage.setItem("connection_type", value);
}
/**
* @throws {z.ZodError}
*/
static saveAutomation(value?: AutomationProps) {
this.validateAndSaveObject("automation", value, AutomationPropsSchema);
}
/**
* @throws {z.ZodError}
*/
static loadAutomation(): AutomationProps | null {
return this.loadAndValidateObject("automation", AutomationPropsSchema);
}
/**
* @throws {z.ZodError}
*/
static saveDefaultTemplate(value?: ExportedLabelTemplate) {
this.validateAndSaveObject(
"default_template",
value,
ExportedLabelTemplateSchema.omit({ id: true }),
);
}
/**
* @throws {z.ZodError}
*/
static loadDefaultTemplate(): ExportedLabelTemplate | null {
return this.loadAndValidateObject(
"default_template",
ExportedLabelTemplateSchema,
);
}
static hasCustomDefaultTemplate(): boolean {
return "default_template" in localStorage;
}
/**
* @throws {z.ZodError}
*/
static saveCachedFonts(fonts: string[]) {
this.validateAndSaveObject("font_cache", fonts, z.array(z.string()));
}
/**
* @throws {z.ZodError}
*/
static loadCachedFonts(): string[] {
return this.loadAndValidateObject("font_cache", z.array(z.string())) ?? [];
}
}

View File

@@ -0,0 +1,111 @@
export const copyImageData = (iData: ImageData): ImageData => {
return new ImageData(new Uint8ClampedArray(iData.data), iData.width, iData.height);
};
// Original code is taken from https://github.com/NielsLeenheer/CanvasDither
// (but it is has typescript definitions and Atkinson threshold)
/**
* Change the image to blank and white using a simple threshold
*
*
* @param {object} image The imageData of a Canvas 2d context
* @param {number} threshold Threshold value (0-255)
* @return {object} The resulting imageData
*
*/
export const threshold = (image: ImageData, threshold: number): ImageData => {
for (let i = 0; i < image.data.length; i += 4) {
const luminance = image.data[i] * 0.299 + image.data[i + 1] * 0.587 + image.data[i + 2] * 0.114;
const value = luminance < threshold ? 0 : 255;
image.data.fill(value, i, i + 3);
}
return image;
};
/**
* Change the image to blank and white using the Atkinson algorithm
*
* @param {object} image The imageData of a Canvas 2d context
* @param {number} threshold Threshold value (0-255)
* @return {object} The resulting imageData
*
*/
export const atkinson = (image: ImageData, threshold: number): ImageData => {
const src = image.data;
const dst = new Uint8ClampedArray(image.width * image.height);
for (let l = 0, i = 0; i < src.length; l++, i += 4) {
dst[l] = src[i] * 0.299 + src[i + 1] * 0.587 + src[i + 2] * 0.114;
}
for (let l = 0, i = 0; i < src.length; l++, i += 4) {
const value = dst[l] < threshold ? 0 : 255;
const error = Math.floor((dst[l] - value) / 8);
src.fill(value, i, i + 3);
dst[l + 1] += error;
dst[l + 2] += error;
dst[l + image.width - 1] += error;
dst[l + image.width] += error;
dst[l + image.width + 1] += error;
dst[l + 2 * image.width] += error;
}
return image;
};
/**
* Change the image to blank and white using the Bayer ordered dithering
*
* @param {object} image The imageData of a Canvas 2d context
* @param {number} threshold Threshold value (0-255)
* @return {object} The resulting imageData
*
*/
export const bayer = (image: ImageData, threshold: number): ImageData => {
const src = image.data;
const width = image.width;
// Pre-calculated 8x8 Bayer matrix (normalized to 0-255)
const bayerMatrix = [
[0, 191, 48, 239, 12, 203, 60, 251],
[128, 64, 176, 112, 140, 76, 188, 124],
[32, 223, 16, 207, 44, 235, 28, 219],
[160, 96, 144, 80, 172, 108, 156, 92],
[8, 199, 56, 247, 4, 195, 52, 243],
[136, 72, 184, 120, 132, 68, 180, 116],
[40, 231, 24, 215, 36, 227, 20, 211],
[168, 104, 152, 88, 164, 100, 148, 84]
];
for (let i = 0; i < src.length; i += 4) {
const x = (i / 4) % width;
const y = Math.floor((i / 4) / width);
const gray = src[i] * 0.299 + src[i + 1] * 0.587 + src[i + 2] * 0.114;
const bayerValue = bayerMatrix[y % 8][x % 8];
const value = gray < threshold - bayerValue / 2 ? 0 : 255;
src[i] = src[i + 1] = src[i + 2] = value;
}
return image;
};
/**
* Invert image
*
* @param {object} image The imageData of a Canvas 2d context
* @return {object} The resulting imageData
*
*/
export const invert = (image: ImageData): ImageData => {
for (let i = 0; i < image.data.length; i += 4) {
const black = (image.data[i] + image.data[i + 1] + image.data[i + 2]) === 0;
image.data.fill(black ? 255 : 0, i, i + 3);
}
return image;
};

32
web/src/utils/toasts.ts Normal file
View File

@@ -0,0 +1,32 @@
import Toastify from "toastify-js";
import { z } from "zod";
export class Toasts {
static error(e: any) {
console.error(e);
Toastify({
text: `${e}`,
gravity: "bottom",
duration: 5000,
className: "toast-danger",
}).showToast();
}
static message(text: string) {
Toastify({
text,
gravity: "bottom",
duration: 5000,
className: "toast-info",
}).showToast();
}
static zodErrors(e: any, prefix: string) {
if (e instanceof z.ZodError) {
e.issues.forEach((i) => {
this.error(`${prefix} "${i.path.join("→")}" ${i.message}`);
});
}
}
}

View File

@@ -0,0 +1,60 @@
import * as fabric from "fabric";
import type { ExportedLabelTemplate, LabelProps } from "$/types";
export type UndoState = { undoDisabled: boolean; redoDisabled: boolean };
export class UndoRedo {
private readonly UNDO_MAX: number = 20;
private buf: ExportedLabelTemplate[] = [];
private index: number = 0;
public paused: boolean = false;
public onLabelUpdate?: (data: ExportedLabelTemplate) => Promise<void>;
public onStateUpdate?: (state: UndoState) => void;
private updateState() {
this.onStateUpdate?.({
undoDisabled: this.index === 0,
redoDisabled: this.index >= this.buf.length - 1,
});
}
async undo() {
if (this.index > 0 && this.index < this.buf.length) {
await this.onLabelUpdate?.(this.buf[this.index - 1]);
this.index--;
}
this.updateState();
}
async redo() {
if (this.index < this.buf.length - 1) {
await this.onLabelUpdate?.(this.buf[this.index + 1]);
this.index++;
}
this.updateState();
}
push(fabricCanvas: fabric.Canvas, labelProps: LabelProps) {
if (this.paused) {
return;
}
if (this.index !== this.buf.length - 1 && this.index > 0 && this.index <= this.buf.length) {
this.buf = this.buf.slice(0, this.index + 1);
}
this.buf.push({
label: labelProps,
canvas: fabricCanvas.toJSON(),
});
if (this.buf.length > this.UNDO_MAX) {
this.buf.shift();
}
this.index = this.buf.length - 1;
this.updateState();
}
}

16
web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,16 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />
declare const __APP_VERSION__: string;
declare const __APP_COMMIT__: string;
declare const __BUILD_DATE__: string;
// not declared in ts lib, experimental feature
declare type FontData = {
readonly family: string;
readonly fullName: string;
readonly postscriptName: string;
readonly style: string;
};
declare function queryLocalFonts(): Promise<ReadonlyArray<FontData>>;

5
web/svelte.config.js Normal file
View File

@@ -0,0 +1,5 @@
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
export default {
preprocess: vitePreprocess(),
};

30
web/tsconfig.json Normal file
View File

@@ -0,0 +1,30 @@
{
"compilerOptions": {
"noImplicitOverride": true,
"esModuleInterop": true,
"skipLibCheck": true,
"target": "es2022",
"module": "es2022",
"moduleResolution": "bundler",
"allowJs": false,
"resolveJsonModule": true,
"moduleDetection": "force",
"isolatedModules": true,
"strict": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": false,
"forceConsistentCasingInFileNames": true,
"useUnknownInCatchVariables": true,
"lib": ["dom", "dom.iterable", "es2022", "webworker"],
"baseUrl": "./",
"paths": {
"$/*": ["src/*"]
}
},
"include": [
"vite.config.ts",
"src/**/*.js",
"src/**/*.ts",
"src/**/*.svelte"
]
}

55
web/vite.config.ts Normal file
View File

@@ -0,0 +1,55 @@
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import { resolve } from "node:path";
const getDate = (): string => {
const date = new Date();
const fmt = (n: number) => (n > 9 ? n : `0${n}`);
return `${date.getFullYear()}-${fmt(date.getMonth() + 1)}-${fmt(date.getDate())}`;
};
// https://vitejs.dev/config/
export default defineConfig({
base: process.env.GITHUB_PAGES ? "/fichero-printer/" : "/",
plugins: [svelte()],
define: {
__APP_VERSION__: JSON.stringify(process.env.npm_package_version),
__APP_COMMIT__: JSON.stringify(process.env.COMMIT_HASH),
__BUILD_DATE__: JSON.stringify(getDate()),
},
resolve: {
alias: {
$: resolve(__dirname, "./src")
},
},
build: {
rollupOptions: {
output: {
manualChunks: (id: string) => {
if (id.endsWith(".css") || id.endsWith(".scss")) {
return "style";
}
if (id.includes("node_modules")) {
if (id.includes("fabric")) {
return "lib.2.fabric";
} else if (
id.includes("@capacitor/filesystem") ||
id.includes("@capacitor/share")
) {
return "lib.2.cap";
} else if (id.includes("zod")) {
return "lib.2.zod";
}
return "lib.1.other";
}
return null;
},
chunkFileNames: () => {
return "assets/[name].[hash].js";
},
},
},
},
});