- Default density to thick (2) for bolder output - Default font size to 30pt for better readability - Add draw.fontmode="1" to disable antialiasing for crisp 1-bit text - Add ImageOps.autocontrast before thresholding images - Update web GUI defaults to match CLI
875 lines
23 KiB
HTML
875 lines
23 KiB
HTML
<!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>
|
|
<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>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
|
|
|
|
// ---- 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 textToCanvas(text, fontSize) {
|
|
// Render text in landscape orientation, then rotate 90 CW for label
|
|
const landscapeW = 240;
|
|
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
|
|
ctx.fillStyle = '#000';
|
|
ctx.font = `${fontSize}px sans-serif`;
|
|
ctx.textBaseline = 'middle';
|
|
|
|
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++) {
|
|
const metrics = ctx.measureText(lines[i]);
|
|
const x = (landscapeW - metrics.width) / 2;
|
|
ctx.fillText(lines[i], Math.max(4, x), startY + i * lineHeight);
|
|
}
|
|
|
|
// Rotate 90 CW: (x,y) -> (landscapeH - 1 - y, x)
|
|
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
|
|
const scale = PRINTHEAD_PX / img.width;
|
|
const newH = Math.round(img.height * scale);
|
|
|
|
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);
|
|
|
|
return 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 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 = () => {
|
|
uploadedCanvas = prepareImageCanvas(img);
|
|
showPreview(uploadedCanvas, 'imgPreview');
|
|
$('imgPreviewWrap').style.display = '';
|
|
$('imgPrintBtn').disabled = !connected;
|
|
log(`Image loaded: ${img.width}x${img.height} -> ${uploadedCanvas.width}x${uploadedCanvas.height}`);
|
|
};
|
|
img.src = reader.result;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
|
|
// ---- Event listeners ----
|
|
|
|
$('textInput').addEventListener('input', updateTextPreview);
|
|
$('fontSize').addEventListener('input', updateTextPreview);
|
|
|
|
// File input
|
|
$('fileInput').addEventListener('change', e => {
|
|
if (e.target.files[0]) handleFile(e.target.files[0]);
|
|
});
|
|
|
|
// 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>
|