大佬tsl0922上位机界面如下 :
![图片[1]-墨水屏蓝牙控制器上位机学习(1)-ELink墨水屏电子纸社区-FPGA CPLD-ChipDebug](http://chipdebug.com/wp-content/uploads/2025/11/20251111215629233-91762869389.png?v=1762869389)
main.css文件如下 :
:root { --primary-color: #0d6efd; --primary-hover: #0b5ed7; --secondary-color: #6c757d; --secondary-hover: #5c636a; --dark-bg: #121212; --dark-text: #e0e0e0; --dark-fieldset-bg: #1e1e1e; --dark-border: #333; --dark-code-bg: #2d2d2d; --dark-log-bg: #2a2a2a; --dark-input-bg: #2d2d2d; --dark-input-text: #e0e0e0; } body { margin: 0; padding: 0; font-family: system-ui, -apple-system, sans-serif; } .debug { display: none !important; } body.debug-mode .debug { display: flex !important; } body.debug-mode { background-color: var(--dark-bg); color: var(--dark-text); } body.debug-mode .main { background-color: var(--dark-bg); color: var(--dark-text); } body.debug-mode fieldset { background-color: var(--dark-fieldset-bg); box-shadow: 0 .5rem 0.5rem rgba(0, 0, 0, 0.5); } body.debug-mode h3 { border-bottom: 1px solid var(--dark-border); color: var(--dark-text); } body.debug-mode code { background: var(--dark-code-bg); color: #ff9800; } body.debug-mode #log { background: var(--dark-log-bg); border: 1px solid var(--dark-border); } body.debug-mode #log .time { color: #8bc34a; } body.debug-mode #log .action { color: #03a9f4; } body.debug-mode input[type=text], body.debug-mode input[type=number], body.debug-mode select { background-color: var(--dark-input-bg); color: var(--dark-input-text); border-color: var(--dark-border); } body.debug-mode input[type=file] { color: var(--dark-input-text); background-color: transparent; border-color: var(--dark-border); } body.debug-mode input[type=file]::file-selector-button { background-color: var(--dark-fieldset-bg); color: var(--dark-input-text); border-color: var(--dark-border); } body.debug-mode input[type=file]::file-selector-button:hover { background-color: #333; border-color: #444; } body.debug-mode fieldset legend { color: #64b5f6; } .main { width: 100%; max-width: 950px; margin: 0 auto; padding: 0 1rem; background: #fff; font-size: 1rem; font-weight: 400; line-height: 1.5; box-sizing: border-box; } .footer { display: flex; gap: 10px; font-size: 0.8rem; color: #666; flex-wrap: wrap; margin: 1rem 0; } .footer .links { display: flex; align-items: center; } .footer .links a { color: #666; text-decoration: none; position: relative; padding: 0 8px; } .footer .links a:first-child { padding-left: 0; } .footer .links a:not(:last-child)::after { content: "•"; position: absolute; right: -4px; color: #999; } .footer a:hover { color: #0d6efd; text-decoration: underline; } body.debug-mode .footer .links a:not(:last-child)::after { color: #666; } body.debug-mode .footer { color: #999; } body.debug-mode .footer a { color: #999; } body.debug-mode .footer a:hover { color: #64b5f6; } h3 { padding-bottom: .3em; border-bottom: 1px solid #CCC; text-align: center; } fieldset { border: none; box-shadow: 0 .5rem 0.5rem rgba(0, 0, 0, 0.2); background-color: #f8f9fa; padding: 10px; margin-bottom: 16px; border-radius: 4px; } fieldset legend { font-weight: bold; color: rgba(0, 0, 255, 0.6); } code { padding: .2em .4em; margin: 0; font-size: 85%; background: #CCC; border-radius: 3px; } .flex-container { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 8px; } .flex-group { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } #status { margin: 10px 0; } #log { width: 100%; min-height: 100px; max-height: 300px; margin: 0; padding: 5px; background: #DDD; overflow-y: auto; overflow-x: hidden; font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; box-sizing: border-box; word-break: break-word; } #log div { padding: 2px 0; } #log .time, #log .action { display: inline-block; white-space: nowrap; } #log .time { color: #333; margin-right: 0.5em; } #log .action { color: #666; margin-right: 0.5em; } #canvas-box { margin-top: 10px; width: 100%; } #canvas { border: black solid 1px; max-width: 100%; height: auto; display: block; margin: 0 auto; } button { padding: 0.375rem 0.75rem; border: 1px solid var(--primary-color); border-radius: 0.375rem; margin-bottom: 5px; white-space: nowrap; cursor: pointer; font-size: 0.9rem; } button:disabled { opacity: 0.65; } button.primary { color: #fff; background-color: var(--primary-color); } button.primary:hover { color: #fff; border-color: var(--primary-hover); background-color: var(--primary-hover); } button.secondary { color: #fff; background-color: var(--secondary-color); border-color: var(--secondary-color); } button.secondary:hover { color: #fff; border-color: var(--secondary-hover); background-color: var(--secondary-hover); } input[type=text], input[type=number], select { font-size: 1rem; font-weight: 400; line-height: 1.5; color: #212529; border: 1px solid #dee2e6; border-radius: 0.375rem; padding: .2rem .75rem; max-width: 100%; box-sizing: border-box; } input[type=file] { font-size: 1rem; font-weight: 400; line-height: 1.5; color: #212529; max-width: 100%; } input::file-selector-button { font-size: 0.9rem; font-weight: 400; line-height: 1.5; border: 1px solid var(--primary-color); border-radius: 0.375rem; cursor: pointer; } select { padding: .3rem 2.25rem .3rem .75rem; } input:focus, select:focus { border: 1px solid #86b7fe; box-shadow: 0 0 4px rgba(0, 120, 215, 0.8); outline: 0; } label { margin-right: 4px; white-space: nowrap; } .status-bar { display: none; font-size: 85%; color: #666; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px dotted #AAA; } canvas.text-placement-mode { border: 2px dashed var(--primary-color) !important; cursor: text !important; /* Force text cursor */ } .canvas-title { display: none; text-align: center; margin-bottom: 5px; color: var(--primary-color); } .canvas-tools { margin-top: 10px; justify-content: center; } .text-tools { display: none; } .tool-button { width: 36px; height: 36px; font-size: 1.2rem; display: inline-flex; align-items: center; justify-content: center; margin-right: 5px; background-color: #f8f9fa; border: 1px solid #dee2e6; border-radius: 4px; padding: 0; cursor: pointer; transition: all 0.2s ease; } .tool-button:hover { background-color: #e9ecef; border-color: #ced4da; } .tool-button.active { background-color: var(--primary-color); color: white; border-color: var(--primary-color); } body.debug-mode .tool-button { background-color: var(--dark-input-bg); border-color: var(--dark-border); color: var(--dark-text); } body.debug-mode .tool-button:hover { background-color: #3a3a3a; border-color: #444; } body.debug-mode .tool-button.active { background-color: var(--primary-color); color: white; border-color: var(--primary-hover); } input[type=text]:disabled, input[type=number]:disabled, select:disabled { opacity: 0.65; cursor: not-allowed; background-color: #e9ecef; color: #6c757d; } body.debug-mode input[type=text]:disabled, body.debug-mode input[type=number]:disabled, body.debug-mode select:disabled { background-color: #1a1a1a; color: #666; border-color: #2a2a2a; } @media (max-width: 768px) { .flex-container { flex-direction: column; } .flex-container.options .flex-group label { min-width: 80px; } .canvas-tools.flex-container { flex-direction: row; flex-wrap: wrap; justify-content: center; } .canvas-tools .flex-group { justify-content: center; width: 100%; } #log { height: 150px; margin-top: 10px; } fieldset { padding: 8px; } button { width: auto; } input[type=text], input[type=number], select { max-width: 100%; margin-bottom: 5px; } }
三个js文件分别如下 :
dithering.js
// Ported from: https://e-paper-display.cn/usb2epd.html // 固定的六色调色板 const rgbPalette = [ { name: "黄色", r: 255, g: 255, b: 0, value: 0xe2 }, { name: "绿色", r: 41, g: 204, b: 20, value: 0x96 }, { name: "蓝色", r: 0, g: 0, b: 255, value: 0x1d }, { name: "红色", r: 255, g: 0, b: 0, value: 0x4c }, { name: "黑色", r: 0, g: 0, b: 0, value: 0x00 }, { name: "白色", r: 255, g: 255, b: 255, value: 0xff } ]; // 四色调色板 const fourColorPalette = [ { name: "黑色", r: 0, g: 0, b: 0, value: 0x00 }, { name: "白色", r: 255, g: 255, b: 255, value: 0x01 }, { name: "红色", r: 255, g: 0, b: 0, value: 0x03 }, { name: "黄色", r: 255, g: 255, b: 0, value: 0x02 } ]; // 三色调色板 const threeColorPalette = [ { name: "黑色", r: 0, g: 0, b: 0, value: 0x00 }, { name: "白色", r: 255, g: 255, b: 255, value: 0x01 }, { name: "红色", r: 255, g: 0, b: 0, value: 0x02 } ]; function adjustContrast(imageData, factor) { const data = imageData.data; for (let i = 0; i < data.length; i += 4) { data[i] = Math.min(255, Math.max(0, (data[i] - 128) * factor + 128)); data[i + 1] = Math.min(255, Math.max(0, (data[i + 1] - 128) * factor + 128)); data[i + 2] = Math.min(255, Math.max(0, (data[i + 2] - 128) * factor + 128)); } return imageData; } function rgbToLab(r, g, b) { r = r / 255; g = g / 255; b = b / 255; r = r > 0.04045 ? Math.pow((r + 0.055) / 1.055, 2.4) : r / 12.92; g = g > 0.04045 ? Math.pow((g + 0.055) / 1.055, 2.4) : g / 12.92; b = b > 0.04045 ? Math.pow((b + 0.055) / 1.055, 2.4) : b / 12.92; r *= 100; g *= 100; b *= 100; let x = r * 0.4124 + g * 0.3576 + b * 0.1805; let y = r * 0.2126 + g * 0.7152 + b * 0.0722; let z = r * 0.0193 + g * 0.1192 + b * 0.9505; x /= 95.047; y /= 100.0; z /= 108.883; x = x > 0.008856 ? Math.pow(x, 1 / 3) : (7.787 * x) + (16 / 116); y = y > 0.008856 ? Math.pow(y, 1 / 3) : (7.787 * y) + (16 / 116); z = z > 0.008856 ? Math.pow(z, 1 / 3) : (7.787 * z) + (16 / 116); const l = (116 * y) - 16; const a = 500 * (x - y); const bLab = 200 * (y - z); return { l, a, b: bLab }; } function labDistance(lab1, lab2) { const dl = lab1.l - lab2.l; const da = lab1.a - lab2.a; const db = lab1.b - lab2.b; return Math.sqrt(0.2 * dl * dl + 3 * da * da + 3 * db * db); } function findClosestColor(r, g, b) { const mode = document.getElementById('ditherMode').value; let palette; if (mode === 'fourColor') { palette = fourColorPalette; } else if (mode === 'threeColor') { palette = threeColorPalette; } else { palette = rgbPalette; } // 蓝色特殊情况(仅限非三色、四色模式) if (mode !== 'fourColor' && mode !== 'threeColor' && r < 50 && g < 150 && b > 100) { return rgbPalette[2]; // 蓝色 } // 三色模式下优先检测红色 if (mode === 'threeColor') { // 如果红色通道显著高于绿色和蓝色,且强度足够 if (r > 120 && r > g * 1.5 && r > b * 1.5) { return threeColorPalette[2]; // 红色 } // 否则根据亮度选择黑或白 const luminance = 0.299 * r + 0.587 * g + 0.114 * b; return luminance < 128 ? threeColorPalette[0] : threeColorPalette[1]; // 黑色或白色 } const inputLab = rgbToLab(r, g, b); let minDistance = Infinity; let closestColor = palette[0]; for (const color of palette) { const colorLab = rgbToLab(color.r, color.g, color.b); const distance = labDistance(inputLab, colorLab); if (distance < minDistance) { minDistance = distance; closestColor = color; } } return closestColor; } function floydSteinbergDither(imageData, strength) { const width = imageData.width; const height = imageData.height; const data = imageData.data; const tempData = new Uint8ClampedArray(data); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const idx = (y * width + x) * 4; const r = tempData[idx]; const g = tempData[idx + 1]; const b = tempData[idx + 2]; const closest = findClosestColor(r, g, b); const errR = (r - closest.r) * strength; const errG = (g - closest.g) * strength; const errB = (b - closest.b) * strength; if (x + 1 < width) { const idxRight = idx + 4; tempData[idxRight] = Math.min(255, Math.max(0, tempData[idxRight] + errR * 7 / 16)); tempData[idxRight + 1] = Math.min(255, Math.max(0, tempData[idxRight + 1] + errG * 7 / 16)); tempData[idxRight + 2] = Math.min(255, Math.max(0, tempData[idxRight + 2] + errB * 7 / 16)); } if (y + 1 < height) { if (x > 0) { const idxDownLeft = idx + width * 4 - 4; tempData[idxDownLeft] = Math.min(255, Math.max(0, tempData[idxDownLeft] + errR * 3 / 16)); tempData[idxDownLeft + 1] = Math.min(255, Math.max(0, tempData[idxDownLeft + 1] + errG * 3 / 16)); tempData[idxDownLeft + 2] = Math.min(255, Math.max(0, tempData[idxDownLeft + 2] + errB * 3 / 16)); } const idxDown = idx + width * 4; tempData[idxDown] = Math.min(255, Math.max(0, tempData[idxDown] + errR * 5 / 16)); tempData[idxDown + 1] = Math.min(255, Math.max(0, tempData[idxDown + 1] + errG * 5 / 16)); tempData[idxDown + 2] = Math.min(255, Math.max(0, tempData[idxDown + 2] + errB * 5 / 16)); if (x + 1 < width) { const idxDownRight = idx + width * 4 + 4; tempData[idxDownRight] = Math.min(255, Math.max(0, tempData[idxDownRight] + errR * 1 / 16)); tempData[idxDownRight + 1] = Math.min(255, Math.max(0, tempData[idxDownRight + 1] + errG * 1 / 16)); tempData[idxDownRight + 2] = Math.min(255, Math.max(0, tempData[idxDownRight + 2] + errB * 1 / 16)); } } } } for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const idx = (y * width + x) * 4; const r = tempData[idx]; const g = tempData[idx + 1]; const b = tempData[idx + 2]; const closest = findClosestColor(r, g, b); data[idx] = closest.r; data[idx + 1] = closest.g; data[idx + 2] = closest.b; } } return imageData; } function atkinsonDither(imageData, strength) { const width = imageData.width; const height = imageData.height; const data = imageData.data; const tempData = new Uint8ClampedArray(data); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const idx = (y * width + x) * 4; const r = tempData[idx]; const g = tempData[idx + 1]; const b = tempData[idx + 2]; const closest = findClosestColor(r, g, b); data[idx] = closest.r; data[idx + 1] = closest.g; data[idx + 2] = closest.b; const errR = (r - closest.r) * strength; const errG = (g - closest.g) * strength; const errB = (b - closest.b) * strength; const fraction = 1 / 8; if (x + 1 < width) { const idxRight = idx + 4; tempData[idxRight] = Math.min(255, Math.max(0, tempData[idxRight] + errR * fraction)); tempData[idxRight + 1] = Math.min(255, Math.max(0, tempData[idxRight + 1] + errG * fraction)); tempData[idxRight + 2] = Math.min(255, Math.max(0, tempData[idxRight + 2] + errB * fraction)); } if (x + 2 < width) { const idxRight2 = idx + 8; tempData[idxRight2] = Math.min(255, Math.max(0, tempData[idxRight2] + errR * fraction)); tempData[idxRight2 + 1] = Math.min(255, Math.max(0, tempData[idxRight2 + 1] + errG * fraction)); tempData[idxRight2 + 2] = Math.min(255, Math.max(0, tempData[idxRight2 + 2] + errB * fraction)); } if (y + 1 < height) { if (x > 0) { const idxDownLeft = idx + width * 4 - 4; tempData[idxDownLeft] = Math.min(255, Math.max(0, tempData[idxDownLeft] + errR * fraction)); tempData[idxDownLeft + 1] = Math.min(255, Math.max(0, tempData[idxDownLeft + 1] + errG * fraction)); tempData[idxDownLeft + 2] = Math.min(255, Math.max(0, tempData[idxDownLeft + 2] + errB * fraction)); } const idxDown = idx + width * 4; tempData[idxDown] = Math.min(255, Math.max(0, tempData[idxDown] + errR * fraction)); tempData[idxDown + 1] = Math.min(255, Math.max(0, tempData[idxDown + 1] + errG * fraction)); tempData[idxDown + 2] = Math.min(255, Math.max(0, tempData[idxDown + 2] + errB * fraction)); if (x + 1 < width) { const idxDownRight = idx + width * 4 + 4; tempData[idxDownRight] = Math.min(255, Math.max(0, tempData[idxDownRight] + errR * fraction)); tempData[idxDownRight + 1] = Math.min(255, Math.max(0, tempData[idxDownRight + 1] + errG * fraction)); tempData[idxDownRight + 2] = Math.min(255, Math.max(0, tempData[idxDownRight + 2] + errB * fraction)); } } if (y + 2 < height) { const idxDown2 = idx + width * 8; tempData[idxDown2] = Math.min(255, Math.max(0, tempData[idxDown2] + errR * fraction)); tempData[idxDown2 + 1] = Math.min(255, Math.max(0, tempData[idxDown2 + 1] + errG * fraction)); tempData[idxDown2 + 2] = Math.min(255, Math.max(0, tempData[idxDown2 + 2] + errB * fraction)); } } } return imageData; } function stuckiDither(imageData, strength) { // 执行Stucki错误扩散算法以处理图像 const width = imageData.width; const height = imageData.height; const data = imageData.data; const tempData = new Uint8ClampedArray(data); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const idx = (y * width + x) * 4; const r = tempData[idx]; const g = tempData[idx + 1]; const b = tempData[idx + 2]; const closest = findClosestColor(r, g, b); const errR = (r - closest.r) * strength; const errG = (g - closest.g) * strength; const errB = (b - closest.b) * strength; const divisor = 42; if (x + 1 < width) { const idxRight = idx + 4; tempData[idxRight] = Math.min(255, Math.max(0, tempData[idxRight] + errR * 8 / divisor)); tempData[idxRight + 1] = Math.min(255, Math.max(0, tempData[idxRight + 1] + errG * 8 / divisor)); tempData[idxRight + 2] = Math.min(255, Math.max(0, tempData[idxRight + 2] + errB * 8 / divisor)); } if (x + 2 < width) { const idxRight2 = idx + 8; tempData[idxRight2] = Math.min(255, Math.max(0, tempData[idxRight2] + errR * 4 / divisor)); tempData[idxRight2 + 1] = Math.min(255, Math.max(0, tempData[idxRight2 + 1] + errG * 4 / divisor)); tempData[idxRight2 + 2] = Math.min(255, Math.max(0, tempData[idxRight2 + 2] + errB * 4 / divisor)); } if (y + 1 < height) { if (x > 1) { const idxDownLeft2 = idx + width * 4 - 8; tempData[idxDownLeft2] = Math.min(255, Math.max(0, tempData[idxDownLeft2] + errR * 2 / divisor)); tempData[idxDownLeft2 + 1] = Math.min(255, Math.max(0, tempData[idxDownLeft2 + 1] + errG * 2 / divisor)); tempData[idxDownLeft2 + 2] = Math.min(255, Math.max(0, tempData[idxDownLeft2 + 2] + errB * 2 / divisor)); } if (x > 0) { const idxDownLeft = idx + width * 4 - 4; tempData[idxDownLeft] = Math.min(255, Math.max(0, tempData[idxDownLeft] + errR * 4 / divisor)); tempData[idxDownLeft + 1] = Math.min(255, Math.max(0, tempData[idxDownLeft + 1] + errG * 4 / divisor)); tempData[idxDownLeft + 2] = Math.min(255, Math.max(0, tempData[idxDownLeft + 2] + errB * 4 / divisor)); } const idxDown = idx + width * 4; tempData[idxDown] = Math.min(255, Math.max(0, tempData[idxDown] + errR * 8 / divisor)); tempData[idxDown + 1] = Math.min(255, Math.max(0, tempData[idxDown + 1] + errG * 8 / divisor)); tempData[idxDown + 2] = Math.min(255, Math.max(0, tempData[idxDown + 2] + errB * 8 / divisor)); if (x + 1 < width) { const idxDownRight1 = idx + width * 4 + 4; tempData[idxDownRight1] = Math.min(255, Math.max(0, tempData[idxDownRight1] + errR * 4 / divisor)); tempData[idxDownRight1 + 1] = Math.min(255, Math.max(0, tempData[idxDownRight1 + 1] + errG * 4 / divisor)); tempData[idxDownRight1 + 2] = Math.min(255, Math.max(0, tempData[idxDownRight1 + 2] + errB * 4 / divisor)); } if (x + 2 < width) { const idxDownRight2 = idx + width * 4 + 8; tempData[idxDownRight2] = Math.min(255, Math.max(0, tempData[idxDownRight2] + errR * 2 / divisor)); tempData[idxDownRight2 + 1] = Math.min(255, Math.max(0, tempData[idxDownRight2 + 1] + errG * 2 / divisor)); tempData[idxDownRight2 + 2] = Math.min(255, Math.max(0, tempData[idxDownRight2 + 2] + errB * 2 / divisor)); } } if (y + 2 < height) { if (x > 1) { const idxDown2Left2 = idx + width * 8 - 8; tempData[idxDown2Left2] = Math.min(255, Math.max(0, tempData[idxDown2Left2] + errR * 1 / divisor)); tempData[idxDown2Left2 + 1] = Math.min(255, Math.max(0, tempData[idxDown2Left2 + 1] + errG * 1 / divisor)); tempData[idxDown2Left2 + 2] = Math.min(255, Math.max(0, tempData[idxDown2Left2 + 2] + errB * 1 / divisor)); } if (x > 0) { const idxDown2Left = idx + width * 8 - 4; tempData[idxDown2Left] = Math.min(255, Math.max(0, tempData[idxDown2Left] + errR * 2 / divisor)); tempData[idxDown2Left + 1] = Math.min(255, Math.max(0, tempData[idxDown2Left + 1] + errG * 2 / divisor)); tempData[idxDown2Left + 2] = Math.min(255, Math.max(0, tempData[idxDown2Left + 2] + errB * 2 / divisor)); } const idxDown2 = idx + width * 8; tempData[idxDown2] = Math.min(255, Math.max(0, tempData[idxDown2] + errR * 4 / divisor)); tempData[idxDown2 + 1] = Math.min(255, Math.max(0, tempData[idxDown2 + 1] + errG * 4 / divisor)); tempData[idxDown2 + 2] = Math.min(255, Math.max(0, tempData[idxDown2 + 2] + errB * 4 / divisor)); if (x + 1 < width) { const idxDown2Right = idx + width * 8 + 4; tempData[idxDown2Right] = Math.min(255, Math.max(0, tempData[idxDown2Right] + errR * 2 / divisor)); tempData[idxDown2Right + 1] = Math.min(255, Math.max(0, tempData[idxDown2Right + 1] + errG * 2 / divisor)); tempData[idxDown2Right + 2] = Math.min(255, Math.max(0, tempData[idxDown2Right + 2] + errB * 2 / divisor)); } if (x + 2 < width) { const idxDown2Right2 = idx + width * 8 + 8; tempData[idxDown2Right2] = Math.min(255, Math.max(0, tempData[idxDown2Right2] + errR * 1 / divisor)); tempData[idxDown2Right2 + 1] = Math.min(255, Math.max(0, tempData[idxDown2Right2 + 1] + errG * 1 / divisor)); tempData[idxDown2Right2 + 2] = Math.min(255, Math.max(0, tempData[idxDown2Right2 + 2] + errB * 1 / divisor)); } } } } for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const idx = (y * width + x) * 4; const r = tempData[idx]; const g = tempData[idx + 1]; const b = tempData[idx + 2]; const closest = findClosestColor(r, g, b); data[idx] = closest.r; data[idx + 1] = closest.g; data[idx + 2] = closest.b; } } return imageData; } function jarvisDither(imageData, strength) { const width = imageData.width; const height = imageData.height; const data = imageData.data; const tempData = new Uint8ClampedArray(data); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const idx = (y * width + x) * 4; const r = tempData[idx]; const g = tempData[idx + 1]; const b = tempData[idx + 2]; const closest = findClosestColor(r, g, b); data[idx] = closest.r; data[idx + 1] = closest.g; data[idx + 2] = closest.b; const errR = (r - closest.r) * strength; const errG = (g - closest.g) * strength; const errB = (b - closest.b) * strength; const divisor = 48; if (x + 1 < width) { const idxRight = idx + 4; tempData[idxRight] = Math.min(255, Math.max(0, tempData[idxRight] + errR * 7 / divisor)); tempData[idxRight + 1] = Math.min(255, Math.max(0, tempData[idxRight + 1] + errG * 7 / divisor)); tempData[idxRight + 2] = Math.min(255, Math.max(0, tempData[idxRight + 2] + errB * 7 / divisor)); } if (x + 2 < width) { const idxRight2 = idx + 8; tempData[idxRight2] = Math.min(255, Math.max(0, tempData[idxRight2] + errR * 5 / divisor)); tempData[idxRight2 + 1] = Math.min(255, Math.max(0, tempData[idxRight2 + 1] + errG * 5 / divisor)); tempData[idxRight2 + 2] = Math.min(255, Math.max(0, tempData[idxRight2 + 2] + errB * 5 / divisor)); } if (y + 1 < height) { if (x > 1) { const idxDownLeft2 = idx + width * 4 - 8; tempData[idxDownLeft2] = Math.min(255, Math.max(0, tempData[idxDownLeft2] + errR * 3 / divisor)); tempData[idxDownLeft2 + 1] = Math.min(255, Math.max(0, tempData[idxDownLeft2 + 1] + errG * 3 / divisor)); tempData[idxDownLeft2 + 2] = Math.min(255, Math.max(0, tempData[idxDownLeft2 + 2] + errB * 3 / divisor)); } if (x > 0) { const idxDownLeft = idx + width * 4 - 4; tempData[idxDownLeft] = Math.min(255, Math.max(0, tempData[idxDownLeft] + errR * 5 / divisor)); tempData[idxDownLeft + 1] = Math.min(255, Math.max(0, tempData[idxDownLeft + 1] + errG * 5 / divisor)); tempData[idxDownLeft + 2] = Math.min(255, Math.max(0, tempData[idxDownLeft + 2] + errB * 5 / divisor)); } const idxDown = idx + width * 4; tempData[idxDown] = Math.min(255, Math.max(0, tempData[idxDown] + errR * 7 / divisor)); tempData[idxDown + 1] = Math.min(255, Math.max(0, tempData[idxDown + 1] + errG * 7 / divisor)); tempData[idxDown + 2] = Math.min(255, Math.max(0, tempData[idxDown + 2] + errB * 7 / divisor)); if (x + 1 < width) { const idxDownRight = idx + width * 4 + 4; tempData[idxDownRight] = Math.min(255, Math.max(0, tempData[idxDownRight] + errR * 5 / divisor)); tempData[idxDownRight + 1] = Math.min(255, Math.max(0, tempData[idxDownRight + 1] + errG * 5 / divisor)); tempData[idxDownRight + 2] = Math.min(255, Math.max(0, tempData[idxDownRight + 2] + errB * 5 / divisor)); } if (x + 2 < width) { const idxDownRight2 = idx + width * 4 + 8; tempData[idxDownRight2] = Math.min(255, Math.max(0, tempData[idxDownRight2] + errR * 3 / divisor)); tempData[idxDownRight2 + 1] = Math.min(255, Math.max(0, tempData[idxDownRight2 + 1] + errG * 3 / divisor)); tempData[idxDownRight2 + 2] = Math.min(255, Math.max(0, tempData[idxDownRight2 + 2] + errB * 3 / divisor)); } } if (y + 2 < height) { if (x > 1) { const idxDown2Left2 = idx + width * 8 - 8; tempData[idxDown2Left2] = Math.min(255, Math.max(0, tempData[idxDown2Left2] + errR * 1 / divisor)); tempData[idxDown2Left2 + 1] = Math.min(255, Math.max(0, tempData[idxDown2Left2 + 1] + errG * 1 / divisor)); tempData[idxDown2Left2 + 2] = Math.min(255, Math.max(0, tempData[idxDown2Left2 + 2] + errB * 1 / divisor)); } if (x > 0) { const idxDown2Left = idx + width * 8 - 4; tempData[idxDown2Left] = Math.min(255, Math.max(0, tempData[idxDown2Left] + errR * 3 / divisor)); tempData[idxDown2Left + 1] = Math.min(255, Math.max(0, tempData[idxDown2Left + 1] + errG * 3 / divisor)); tempData[idxDown2Left + 2] = Math.min(255, Math.max(0, tempData[idxDown2Left + 2] + errB * 3 / divisor)); } const idxDown2 = idx + width * 8; tempData[idxDown2] = Math.min(255, Math.max(0, tempData[idxDown2] + errR * 5 / divisor)); tempData[idxDown2 + 1] = Math.min(255, Math.max(0, tempData[idxDown2 + 1] + errG * 5 / divisor)); tempData[idxDown2 + 2] = Math.min(255, Math.max(0, tempData[idxDown2 + 2] + errB * 5 / divisor)); if (x + 1 < width) { const idxDown2Right = idx + width * 8 + 4; tempData[idxDown2Right] = Math.min(255, Math.max(0, tempData[idxDown2Right] + errR * 3 / divisor)); tempData[idxDown2Right + 1] = Math.min(255, Math.max(0, tempData[idxDown2Right + 1] + errG * 3 / divisor)); tempData[idxDown2Right + 2] = Math.min(255, Math.max(0, tempData[idxDown2Right + 2] + errB * 3 / divisor)); } if (x + 2 < width) { const idxDown2Right2 = idx + width * 8 + 8; tempData[idxDown2Right2] = Math.min(255, Math.max(0, tempData[idxDown2Right2] + errR * 1 / divisor)); tempData[idxDown2Right2 + 1] = Math.min(255, Math.max(0, tempData[idxDown2Right2 + 1] + errG * 1 / divisor)); tempData[idxDown2Right2 + 2] = Math.min(255, Math.max(0, tempData[idxDown2Right2 + 2] + errB * 1 / divisor)); } } } } return imageData; } function bayerDither(imageData, strength) { const width = imageData.width; const height = imageData.height; const data = imageData.data; // 8x8 Bayer matrix (normalized to 0-1 range) const bayerMatrix = [ [0, 32, 8, 40, 2, 34, 10, 42], [48, 16, 56, 24, 50, 18, 58, 26], [12, 44, 4, 36, 14, 46, 6, 38], [60, 28, 52, 20, 62, 30, 54, 22], [3, 35, 11, 43, 1, 33, 9, 41], [51, 19, 59, 27, 49, 17, 57, 25], [15, 47, 7, 39, 13, 45, 5, 37], [63, 31, 55, 23, 61, 29, 53, 21] ]; const matrixSize = 8; const maxThreshold = 64; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const idx = (y * width + x) * 4; const r = data[idx]; const g = data[idx + 1]; const b = data[idx + 2]; // Get threshold from Bayer matrix const matrixX = x % matrixSize; const matrixY = y % matrixSize; const threshold = (bayerMatrix[matrixY][matrixX] / maxThreshold) * 255; // Apply dithering with strength factor const adjustedR = r + (threshold - 127.5) * strength; const adjustedG = g + (threshold - 127.5) * strength; const adjustedB = b + (threshold - 127.5) * strength; // Clamp values const clampedR = Math.min(255, Math.max(0, adjustedR)); const clampedG = Math.min(255, Math.max(0, adjustedG)); const clampedB = Math.min(255, Math.max(0, adjustedB)); // Find closest color in palette const closest = findClosestColor(clampedR, clampedG, clampedB); data[idx] = closest.r; data[idx + 1] = closest.g; data[idx + 2] = closest.b; } } return imageData; } function ditherImage(imageData) { const ditherType = document.getElementById('ditherType').value; const ditherStrength = parseFloat(document.getElementById('ditherStrength').value); switch (ditherType) { case 'floydSteinberg': return floydSteinbergDither(imageData, ditherStrength); case 'atkinson': return atkinsonDither(imageData, ditherStrength); case 'stucki': return stuckiDither(imageData, ditherStrength); case 'jarvis': return jarvisDither(imageData, ditherStrength); case 'bayer': return bayerDither(imageData, ditherStrength); default: return imageData; } } function decodeProcessedData(processedData, width, height, mode) { const imageData = new ImageData(width, height); const data = imageData.data; if (mode === 'sixColor') { for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const newIndex = (x * height) + (height - 1 - y); const value = processedData[newIndex]; const color = rgbPalette.find(c => c.value === value) || rgbPalette[5]; // 默认白色 const index = (y * width + x) * 4; data[index] = color.r; data[index + 1] = color.g; data[index + 2] = color.b; data[index + 3] = 255; // Alpha 透明度 } } } else if (mode === 'fourColor') { const fourColorValues = [ { value: 0x00, r: 0, g: 0, b: 0 }, // 黑色 { value: 0x01, r: 255, g: 255, b: 255 }, // 白色 { value: 0x03, r: 255, g: 0, b: 0 }, // 红色 { value: 0x02, r: 255, g: 255, b: 0 } // 黄色 ]; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const newIndex = (y * width + x) / 4 | 0; const shift = 6 - ((x % 4) * 2); const value = (processedData[newIndex] >> shift) & 0x03; const color = fourColorValues.find(c => c.value === value) || fourColorValues[1]; // 默认白色 const index = (y * width + x) * 4; data[index] = color.r; data[index + 1] = color.g; data[index + 2] = color.b; data[index + 3] = 255; } } } else if (mode === 'blackWhiteColor') { const byteWidth = Math.ceil(width / 8); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const byteIndex = y * byteWidth + Math.floor(x / 8); const bitIndex = 7 - (x % 8); const bit = (processedData[byteIndex] >> bitIndex) & 1; const index = (y * width + x) * 4; data[index] = bit ? 255 : 0; // 白或黑 data[index + 1] = bit ? 255 : 0; data[index + 2] = bit ? 255 : 0; data[index + 3] = 255; } } } else if (mode === 'threeColor') { const byteWidth = Math.ceil(width / 8); const blackWhiteData = processedData.slice(0, byteWidth * height); const redWhiteData = processedData.slice(byteWidth * height); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const byteIndex = y * byteWidth + Math.floor(x / 8); const bitIndex = 7 - (x % 8); const blackWhiteBit = (blackWhiteData[byteIndex] >> bitIndex) & 1; const redWhiteBit = (redWhiteData[byteIndex] >> bitIndex) & 1; const index = (y * width + x) * 4; if (!redWhiteBit) { // 红色 data[index] = 255; data[index + 1] = 0; data[index + 2] = 0; } else { // 黑或白 data[index] = blackWhiteBit ? 255 : 0; data[index + 1] = blackWhiteBit ? 255 : 0; data[index + 2] = blackWhiteBit ? 255 : 0; } data[index + 3] = 255; } } } return imageData; } function processImageData(imageData) { const width = imageData.width; const height = imageData.height; const data = imageData.data; const mode = document.getElementById('ditherMode').value; let processedData; if (mode === 'sixColor') { processedData = new Uint8Array(width * height); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const index = (y * width + x) * 4; const r = data[index]; const g = data[index + 1]; const b = data[index + 2]; const closest = findClosestColor(r, g, b); const newIndex = (x * height) + (height - 1 - y); processedData[newIndex] = closest.value; } } } else if (mode === 'fourColor') { processedData = new Uint8Array(Math.ceil((width * height) / 4)); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const index = (y * width + x) * 4; const r = data[index]; const g = data[index + 1]; const b = data[index + 2]; const closest = findClosestColor(r, g, b); // 使用 fourColorPalette const colorValue = closest.value; // 0x00 (黑), 0x01 (白), 0x02 (红), 0x03 (黄) const newIndex = (y * width + x) / 4 | 0; const shift = 6 - ((x % 4) * 2); processedData[newIndex] |= (colorValue << shift); } } } else if (mode === 'blackWhiteColor') { const byteWidth = Math.ceil(width / 8); processedData = new Uint8Array(byteWidth * height); const threshold = 140; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const index = (y * width + x) * 4; const r = data[index]; const g = data[index + 1]; const b = data[index + 2]; const grayscale = Math.round(0.299 * r + 0.587 * g + 0.114 * b); const bit = grayscale >= threshold ? 1 : 0; const byteIndex = y * byteWidth + Math.floor(x / 8); const bitIndex = 7 - (x % 8); processedData[byteIndex] |= (bit << bitIndex); } } } else if (mode === 'threeColor') { const byteWidth = Math.ceil(width / 8); const blackWhiteThreshold = 140; const redThreshold = 160; const blackWhiteData = new Uint8Array(height * byteWidth); const redWhiteData = new Uint8Array(height * byteWidth); for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const index = (y * width + x) * 4; const r = data[index]; const g = data[index + 1]; const b = data[index + 2]; const grayscale = Math.round(0.299 * r + 0.587 * g + 0.114 * b); const blackWhiteBit = grayscale >= blackWhiteThreshold ? 1 : 0; const blackWhiteByteIndex = y * byteWidth + Math.floor(x / 8); const blackWhiteBitIndex = 7 - (x % 8); if (blackWhiteBit) { blackWhiteData[blackWhiteByteIndex] |= (0x01 << blackWhiteBitIndex); } else { blackWhiteData[blackWhiteByteIndex] &= ~(0x01 << blackWhiteBitIndex); } const redWhiteBit = (r > redThreshold && r > g && r > b) ? 0 : 1; const redWhiteByteIndex = y * byteWidth + Math.floor(x / 8); const redWhiteBitIndex = 7 - (x % 8); if (redWhiteBit) { redWhiteData[redWhiteByteIndex] |= (0x01 << redWhiteBitIndex); } else { redWhiteData[redWhiteByteIndex] &= ~(0x01 << redWhiteBitIndex); } } } processedData = new Uint8Array(blackWhiteData.length + redWhiteData.length); processedData.set(blackWhiteData, 0); processedData.set(redWhiteData, blackWhiteData.length); } return processedData; }
main.js
let bleDevice, gattServer; let epdService, epdCharacteristic; let startTime, msgIndex, appVersion; let canvas, ctx, textDecoder; const EpdCmd = { SET_PINS: 0x00, INIT: 0x01, CLEAR: 0x02, SEND_CMD: 0x03, SEND_DATA: 0x04, REFRESH: 0x05, SLEEP: 0x06, SET_TIME: 0x20, WRITE_IMG: 0x30, // v1.6 SET_CONFIG: 0x90, SYS_RESET: 0x91, SYS_SLEEP: 0x92, CFG_ERASE: 0x99, }; const canvasSizes = [ { name: '1.54_152_152', width: 152, height: 152 }, { name: '1.54_200_200', width: 200, height: 200 }, { name: '2.13_212_104', width: 212, height: 104 }, { name: '2.13_250_122', width: 250, height: 122 }, { name: '2.66_296_152', width: 296, height: 152 }, { name: '2.9_296_128', width: 296, height: 128 }, { name: '2.9_384_168', width: 384, height: 168 }, { name: '3.5_384_184', width: 384, height: 184 }, { name: '3.7_416_240', width: 416, height: 240 }, { name: '3.97_800_480', width: 800, height: 480 }, { name: '4.2_400_300', width: 400, height: 300 }, { name: '5.79_792_272', width: 792, height: 272 }, { name: '7.5_800_480', width: 800, height: 480 }, { name: '10.2_960_640', width: 960, height: 640 }, { name: '10.85_1360_480', width: 1360, height: 480 }, { name: '11.6_960_640', width: 960, height: 640 }, { name: '4E_600_400', width: 600, height: 400 }, { name: '7.3E6', width: 480, height: 800 } ]; function hex2bytes(hex) { for (var bytes = [], c = 0; c < hex.length; c += 2) bytes.push(parseInt(hex.substr(c, 2), 16)); return new Uint8Array(bytes); } function bytes2hex(data) { return new Uint8Array(data).reduce( function (memo, i) { return memo + ("0" + i.toString(16)).slice(-2); }, ""); } function intToHex(intIn) { let stringOut = ("0000" + intIn.toString(16)).substr(-4) return stringOut.substring(2, 4) + stringOut.substring(0, 2); } function resetVariables() { gattServer = null; epdService = null; epdCharacteristic = null; msgIndex = 0; document.getElementById("log").value = ''; } async function write(cmd, data, withResponse = true) { if (!epdCharacteristic) { addLog("服务不可用,请检查蓝牙连接"); return false; } let payload = [cmd]; if (data) { if (typeof data == 'string') data = hex2bytes(data); if (data instanceof Uint8Array) data = Array.from(data); payload.push(...data) } addLog(bytes2hex(payload), '⇑'); try { if (withResponse) await epdCharacteristic.writeValueWithResponse(Uint8Array.from(payload)); else await epdCharacteristic.writeValueWithoutResponse(Uint8Array.from(payload)); } catch (e) { console.error(e); if (e.message) addLog("write: " + e.message); return false; } return true; } async function writeImage(data, step = 'bw') { const chunkSize = document.getElementById('mtusize').value - 2; const interleavedCount = document.getElementById('interleavedcount').value; const count = Math.round(data.length / chunkSize); let chunkIdx = 0; let noReplyCount = interleavedCount; for (let i = 0; i < data.length; i += chunkSize) { let currentTime = (new Date().getTime() - startTime) / 1000.0; setStatus(`${step == 'bw' ? '黑白' : '颜色'}块: ${chunkIdx + 1}/${count + 1}, 总用时: ${currentTime}s`); const payload = [ (step == 'bw' ? 0x0F : 0x00) | (i == 0 ? 0x00 : 0xF0), ...data.slice(i, i + chunkSize), ]; if (noReplyCount > 0) { await write(EpdCmd.WRITE_IMG, payload, false); noReplyCount--; } else { await write(EpdCmd.WRITE_IMG, payload, true); noReplyCount = interleavedCount; } chunkIdx++; } } async function setDriver() { await write(EpdCmd.SET_PINS, document.getElementById("epdpins").value); await write(EpdCmd.INIT, document.getElementById("epddriver").value); } async function syncTime(mode) { const timestamp = new Date().getTime() / 1000; const data = new Uint8Array([ (timestamp >> 24) & 0xFF, (timestamp >> 16) & 0xFF, (timestamp >> 8) & 0xFF, timestamp & 0xFF, -(new Date().getTimezoneOffset() / 60), mode ]); if (await write(EpdCmd.SET_TIME, data)) { addLog("时间已同步!"); addLog("屏幕刷新完成前请不要操作。"); } } async function clearScreen() { if (confirm('确认清除屏幕内容?')) { await write(EpdCmd.CLEAR); addLog("清屏指令已发送!"); addLog("屏幕刷新完成前请不要操作。"); } } async function sendcmd() { const cmdTXT = document.getElementById('cmdTXT').value; if (cmdTXT == '') return; const bytes = hex2bytes(cmdTXT); await write(bytes[0], bytes.length > 1 ? bytes.slice(1) : null); } async function sendimg() { const canvasSize = document.getElementById('canvasSize').value; const ditherMode = document.getElementById('ditherMode').value; const epdDriverSelect = document.getElementById('epddriver'); const selectedOption = epdDriverSelect.options[epdDriverSelect.selectedIndex]; if (selectedOption.getAttribute('data-size') !== canvasSize) { addLog(`画布尺寸和驱动不匹配,请重新选择。`); return; } if (selectedOption.getAttribute('data-color') !== ditherMode) { addLog(`颜色模式和驱动不匹配,请重新选择。`); return; } startTime = new Date().getTime(); const status = document.getElementById("status"); status.parentElement.style.display = "block"; const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const processedData = processImageData(imageData); updateButtonStatus(true); if (ditherMode === 'fourColor') { await writeImage(processedData, 'color'); } else if (ditherMode === 'threeColor') { const halfLength = Math.floor(processedData.length / 2); await writeImage(processedData.slice(0, halfLength), 'bw'); await writeImage(processedData.slice(halfLength), 'red'); } else if (ditherMode === 'blackWhiteColor') { await writeImage(processedData, 'bw'); } else { addLog("当前固件不支持此颜色模式。"); updateButtonStatus(); return; } await write(EpdCmd.REFRESH); updateButtonStatus(); const sendTime = (new Date().getTime() - startTime) / 1000.0; addLog(`发送完成!耗时: ${sendTime}s`); setStatus(`发送完成!耗时: ${sendTime}s`); addLog("屏幕刷新完成前请不要操作。"); setTimeout(() => { status.parentElement.style.display = "none"; }, 5000); } function downloadDataArray() { const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const processedData = processImageData(imageData); const mode = document.getElementById('ditherMode').value; if (mode === 'sixColor' && processedData.length !== canvas.width * canvas.height) { console.log(`错误:预期${canvas.width * canvas.height}字节,但得到${processedData.length}字节`); addLog('数组大小不匹配。请检查图像尺寸和模式。'); return; } const dataLines = []; for (let i = 0; i < processedData.length; i++) { const hexValue = (processedData[i] & 0xff).toString(16).padStart(2, '0'); dataLines.push(`0x${hexValue}`); } const formattedData = []; for (let i = 0; i < dataLines.length; i += 16) { formattedData.push(dataLines.slice(i, i + 16).join(', ')); } const colorModeValue = mode === 'sixColor' ? 0 : mode === 'fourColor' ? 1 : mode === 'blackWhiteColor' ? 2 : 3; const arrayContent = [ 'const uint8_t imageData[] PROGMEM = {', formattedData.join(',n'), '};', `const uint16_t imageWidth = ${canvas.width};`, `const uint16_t imageHeight = ${canvas.height};`, `const uint8_t colorMode = ${colorModeValue};` ].join('n'); const blob = new Blob([arrayContent], { type: 'text/plain' }); const link = document.createElement('a'); link.download = 'imagedata.h'; link.href = URL.createObjectURL(blob); link.click(); URL.revokeObjectURL(link.href); } function updateButtonStatus(forceDisabled = false) { const connected = gattServer != null && gattServer.connected; const status = forceDisabled ? 'disabled' : (connected ? null : 'disabled'); document.getElementById("reconnectbutton").disabled = (gattServer == null || gattServer.connected) ? 'disabled' : null; document.getElementById("sendcmdbutton").disabled = status; document.getElementById("calendarmodebutton").disabled = status; document.getElementById("clockmodebutton").disabled = status; document.getElementById("clearscreenbutton").disabled = status; document.getElementById("sendimgbutton").disabled = status; document.getElementById("setDriverbutton").disabled = status; } function disconnect() { updateButtonStatus(); resetVariables(); addLog('已断开连接.'); document.getElementById("connectbutton").innerHTML = '连接'; } async function preConnect() { if (gattServer != null && gattServer.connected) { if (bleDevice != null && bleDevice.gatt.connected) { bleDevice.gatt.disconnect(); } } else { resetVariables(); try { bleDevice = await navigator.bluetooth.requestDevice({ optionalServices: ['62750001-d828-918d-fb46-b6c11c675aec'], acceptAllDevices: true }); } catch (e) { console.error(e); if (e.message) addLog("requestDevice: " + e.message); addLog("请检查蓝牙是否已开启,且使用的浏览器支持蓝牙!建议使用以下浏览器:"); addLog("• 电脑: Chrome/Edge"); addLog("• Android: Chrome/Edge"); addLog("• iOS: Bluefy 浏览器"); return; } await bleDevice.addEventListener('gattserverdisconnected', disconnect); setTimeout(async function () { await connect(); }, 300); } } async function reConnect() { if (bleDevice != null && bleDevice.gatt.connected) bleDevice.gatt.disconnect(); resetVariables(); addLog("正在重连"); setTimeout(async function () { await connect(); }, 300); } function handleNotify(value, idx) { const data = new Uint8Array(value.buffer, value.byteOffset, value.byteLength); if (idx == 0) { addLog(`收到配置:${bytes2hex(data)}`); const epdpins = document.getElementById("epdpins"); const epddriver = document.getElementById("epddriver"); epdpins.value = bytes2hex(data.slice(0, 7)); if (data.length > 10) epdpins.value += bytes2hex(data.slice(10, 11)); epddriver.value = bytes2hex(data.slice(7, 8)); updateDitcherOptions(); } else { if (textDecoder == null) textDecoder = new TextDecoder(); addLog(textDecoder.decode(data), '⇓'); } } async function connect() { if (bleDevice == null || epdCharacteristic != null) return; try { addLog("正在连接: " + bleDevice.name); gattServer = await bleDevice.gatt.connect(); addLog(' 找到 GATT Server'); epdService = await gattServer.getPrimaryService('62750001-d828-918d-fb46-b6c11c675aec'); addLog(' 找到 EPD Service'); epdCharacteristic = await epdService.getCharacteristic('62750002-d828-918d-fb46-b6c11c675aec'); addLog(' 找到 Characteristic'); } catch (e) { console.error(e); if (e.message) addLog("connect: " + e.message); disconnect(); return; } try { const versionCharacteristic = await epdService.getCharacteristic('62750003-d828-918d-fb46-b6c11c675aec'); const versionData = await versionCharacteristic.readValue(); appVersion = versionData.getUint8(0); addLog(`固件版本: 0x${appVersion.toString(16)}`); } catch (e) { console.error(e); appVersion = 0x15; } if (appVersion < 0x16) { const oldURL = "https://tsl0922.github.io/EPD-nRF5/v1.5"; alert("!!!注意!!!n当前固件版本过低,可能无法正常使用部分功能,建议升级到最新版本。"); if (confirm('是否访问旧版本上位机?')) location.href = oldURL; setTimeout(() => { addLog(`如遇到问题,可访问旧版本上位机: ${oldURL}`); }, 500); } try { await epdCharacteristic.startNotifications(); epdCharacteristic.addEventListener('characteristicvaluechanged', (event) => { handleNotify(event.target.value, msgIndex++); }); } catch (e) { console.error(e); if (e.message) addLog("startNotifications: " + e.message); } await write(EpdCmd.INIT); document.getElementById("connectbutton").innerHTML = '断开'; updateButtonStatus(); } function setStatus(statusText) { document.getElementById("status").innerHTML = statusText; } function addLog(logTXT, action = '') { const log = document.getElementById("log"); const now = new Date(); const time = String(now.getHours()).padStart(2, '0') + ":" + String(now.getMinutes()).padStart(2, '0') + ":" + String(now.getSeconds()).padStart(2, '0') + " "; const logEntry = document.createElement('div'); const timeSpan = document.createElement('span'); timeSpan.className = 'time'; timeSpan.textContent = time; logEntry.appendChild(timeSpan); if (action !== '') { const actionSpan = document.createElement('span'); actionSpan.className = 'action'; actionSpan.innerHTML = action; logEntry.appendChild(actionSpan); } logEntry.appendChild(document.createTextNode(logTXT)); log.appendChild(logEntry); log.scrollTop = log.scrollHeight; while (log.childNodes.length > 20) { log.removeChild(log.firstChild); } } function clearLog() { document.getElementById("log").innerHTML = ''; } function updateCanvasSize() { const selectedSizeName = document.getElementById('canvasSize').value; const selectedSize = canvasSizes.find(size => size.name === selectedSizeName); canvas.width = selectedSize.width; canvas.height = selectedSize.height; updateImage(false); } function updateDitcherOptions() { const epdDriverSelect = document.getElementById('epddriver'); const selectedOption = epdDriverSelect.options[epdDriverSelect.selectedIndex]; const colorMode = selectedOption.getAttribute('data-color'); const canvasSize = selectedOption.getAttribute('data-size'); if (colorMode) document.getElementById('ditherMode').value = colorMode; if (canvasSize) document.getElementById('canvasSize').value = canvasSize; updateCanvasSize(); // always update image } function updateImage(clear = false) { const image_file = document.getElementById('image_file'); if (image_file.files.length == 0) return; if (clear) clearCanvas(); const file = image_file.files[0]; let image = new Image();; image.src = URL.createObjectURL(file); image.onload = function (event) { URL.revokeObjectURL(this.src); ctx.drawImage(image, 0, 0, image.width, image.height, 0, 0, canvas.width, canvas.height); // Redraw text and lines redrawTextElements(); redrawLineSegments(); convertDithering() } } function clearCanvas() { if (confirm('清除画布已有内容?')) { ctx.fillStyle = 'white'; ctx.fillRect(0, 0, canvas.width, canvas.height); textElements = []; // Clear stored text positions lineSegments = []; // Clear stored line segments return true; } return false; } function convertDithering() { const contrast = parseFloat(document.getElementById('contrast').value); const currentImageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const imageData = new ImageData( new Uint8ClampedArray(currentImageData.data), currentImageData.width, currentImageData.height ); adjustContrast(imageData, contrast); const mode = document.getElementById('ditherMode').value; const processedData = processImageData(ditherImage(imageData)); const finalImageData = decodeProcessedData(processedData, canvas.width, canvas.height, mode); ctx.putImageData(finalImageData, 0, 0); } function checkDebugMode() { const link = document.getElementById('debug-toggle'); const urlParams = new URLSearchParams(window.location.search); const debugMode = urlParams.get('debug'); if (debugMode === 'true') { document.body.classList.add('debug-mode'); link.innerHTML = '正常模式'; link.setAttribute('href', window.location.pathname); addLog("注意:开发模式功能已开启!不懂请不要随意修改,否则后果自负!"); } else { document.body.classList.remove('debug-mode'); link.innerHTML = '开发模式'; link.setAttribute('href', window.location.pathname + '?debug=true'); } } document.body.onload = () => { textDecoder = null; canvas = document.getElementById('canvas'); ctx = canvas.getContext("2d"); ctx.fillStyle = 'white'; ctx.fillRect(0, 0, canvas.width, canvas.height); initPaintTools(); updateButtonStatus(); checkDebugMode(); }
paint.js
let painting = false; let lastX = 0; let lastY = 0; let brushColor = "#000000"; let brushSize = 2; let currentTool = null; // Start with no tool selected let textElements = []; // Store text elements for re-rendering after dithering let lineSegments = []; // Store line segments for re-rendering after dithering let isTextPlacementMode = false; let draggingCanvasContext = null; // Backup of the canvas for dragging let selectedTextElement = null; // Track the currently selected text for dragging let isDraggingText = false; // Track if we're currently dragging text let dragOffsetX = 0; // Offset from mouse to text position when dragging let dragOffsetY = 0; let textBold = false; // Track if text should be bold let textItalic = false; // Track if text should be italic function setCanvasTitle(title) { const canvasTitle = document.querySelector('.canvas-title'); if (canvasTitle) { canvasTitle.innerText = title; canvasTitle.style.display = title && title !== '' ? 'block' : 'none'; } } function initPaintTools() { document.getElementById('brush-mode').addEventListener('click', () => { if (currentTool === 'brush') { setActiveTool(null, ''); } else { setActiveTool('brush', '画笔模式'); brushColor = document.getElementById('brush-color').value; } }); document.getElementById('eraser-mode').addEventListener('click', () => { if (currentTool === 'eraser') { setActiveTool(null, ''); } else { setActiveTool('eraser', '橡皮擦'); brushColor = "#FFFFFF"; } }); document.getElementById('text-mode').addEventListener('click', () => { if (currentTool === 'text') { setActiveTool(null, ''); } else { setActiveTool('text', '插入文字'); brushColor = document.getElementById('brush-color').value; } }); document.getElementById('brush-color').addEventListener('change', (e) => { brushColor = e.target.value; }); document.getElementById('brush-size').addEventListener('change', (e) => { brushSize = parseInt(e.target.value); }); document.getElementById('add-text-btn').addEventListener('click', startTextPlacement); // Add event listeners for bold and italic buttons document.getElementById('text-bold').addEventListener('click', () => { textBold = !textBold; document.getElementById('text-bold').classList.toggle('primary', textBold); }); document.getElementById('text-italic').addEventListener('click', () => { textItalic = !textItalic; document.getElementById('text-italic').classList.toggle('primary', textItalic); }); setupCanvasForPainting(); // Ensure no tool is selected by default updateToolUI(); } function setActiveTool(tool, title) { currentTool = tool; updateToolUI(); setCanvasTitle(title); // Cancel any pending text placement cancelTextPlacement(); } function updateToolUI() { // Update UI to reflect active tool or no tool document.getElementById('brush-mode').classList.toggle('active', currentTool === 'brush'); document.getElementById('eraser-mode').classList.toggle('active', currentTool === 'eraser'); document.getElementById('text-mode').classList.toggle('active', currentTool === 'text'); // Show/hide brush tools document.querySelectorAll('.brush-tools').forEach(el => { el.style.display = ['brush', 'text'].includes(currentTool) ? 'flex' : 'none'; }); // Show/hide text tools document.querySelectorAll('.text-tools').forEach(el => { el.style.display = currentTool === 'text' ? 'flex' : 'none'; }); } function setupCanvasForPainting() { canvas.addEventListener('mousedown', startPaint); canvas.addEventListener('mousemove', paint); canvas.addEventListener('mouseup', endPaint); canvas.addEventListener('mouseleave', endPaint); canvas.addEventListener('click', handleCanvasClick); // Touch support canvas.addEventListener('touchstart', handleTouchStart); canvas.addEventListener('touchmove', handleTouchMove); canvas.addEventListener('touchend', endPaint); } function startPaint(e) { if (!currentTool) return; if (currentTool === 'text') { // Check if we're clicking on a text element to drag const textElement = findTextElementAt(e); if (textElement && textElement === selectedTextElement) { isDraggingText = true; const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; const x = (e.clientX - rect.left) * scaleX; const y = (e.clientY - rect.top) * scaleY; // Calculate offset for smooth dragging dragOffsetX = textElement.x - x; dragOffsetY = textElement.y - y; return; // Don't start drawing } } else { painting = true; draw(e); } } function endPaint() { painting = false; isDraggingText = false; lastX = 0; lastY = 0; } function paint(e) { if (!currentTool) return; if (currentTool === 'text') { if (isDraggingText && selectedTextElement) { dragText(e); } } else { if (painting) { draw(e); } } } function draw(e) { const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; const x = (e.clientX - rect.left) * scaleX; const y = (e.clientY - rect.top) * scaleY; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.strokeStyle = brushColor; ctx.lineWidth = brushSize; ctx.beginPath(); if (lastX === 0 && lastY === 0) { // For the first point, just do a dot ctx.moveTo(x, y); ctx.lineTo(x+0.1, y+0.1); // Store the dot for redrawing lineSegments.push({ type: 'dot', x: x, y: y, color: brushColor, size: brushSize }); } else { // Connect to the previous point ctx.moveTo(lastX, lastY); ctx.lineTo(x, y); // Store the line segment for redrawing lineSegments.push({ type: 'line', x1: lastX, y1: lastY, x2: x, y2: y, color: brushColor, size: brushSize }); } ctx.stroke(); lastX = x; lastY = y; } function handleCanvasClick(e) { if (currentTool === 'text' && isTextPlacementMode) { placeText(e); } } // Improve touch handling for text placement function handleTouchStart(e) { e.preventDefault(); const touch = e.touches[0]; // If in text placement mode, handle as a click if (currentTool === 'text' && isTextPlacementMode) { const mouseEvent = new MouseEvent('click', { clientX: touch.clientX, clientY: touch.clientY }); canvas.dispatchEvent(mouseEvent); return; } // Otherwise handle as normal drawing const mouseEvent = new MouseEvent('mousedown', { clientX: touch.clientX, clientY: touch.clientY }); canvas.dispatchEvent(mouseEvent); } function handleTouchMove(e) { e.preventDefault(); const touch = e.touches[0]; const mouseEvent = new MouseEvent('mousemove', { clientX: touch.clientX, clientY: touch.clientY }); canvas.dispatchEvent(mouseEvent); } function dragText(e) { const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; const x = (e.clientX - rect.left) * scaleX; const y = (e.clientY - rect.top) * scaleY; // Update text position with offset selectedTextElement.x = x + dragOffsetX; selectedTextElement.y = y + dragOffsetY; // Redraw selected text element if (draggingCanvasContext) { ctx.putImageData(draggingCanvasContext, 0, 0); } else { ctx.clearRect(0, 0, canvas.width, canvas.height); } ctx.font = selectedTextElement.font; ctx.fillStyle = selectedTextElement.color; ctx.fillText(selectedTextElement.text, selectedTextElement.x, selectedTextElement.y); } function findTextElementAt(e) { const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; const x = (e.clientX - rect.left) * scaleX; const y = (e.clientY - rect.top) * scaleY; // Search through text elements in reverse order (top-most first) for (let i = textElements.length - 1; i >= 0; i--) { const text = textElements[i]; // Calculate text dimensions ctx.font = text.font; const textWidth = ctx.measureText(text.text).width; // Extract font size correctly from the font string // This handles "bold 14px Arial", "italic 14px Arial", "bold italic 14px Arial", etc. const fontSizeMatch = text.font.match(/(d+)px/); const fontSize = fontSizeMatch ? parseInt(fontSizeMatch[1]) : 14; // Default to 14 if not found const textHeight = fontSize * 1.2; // Approximate height // Check if click is within text bounds (allowing for some margin) const margin = 5; if (x >= text.x - margin && x <= text.x + textWidth + margin && y >= text.y - textHeight + margin && y <= text.y + margin) { return text; } } return null; } function startTextPlacement() { const text = document.getElementById('text-input').value.trim(); if (!text) { alert('请输入文字内容'); return; } isTextPlacementMode = true; // Add visual feedback setCanvasTitle('点击画布放置文字'); canvas.classList.add('text-placement-mode'); } function cancelTextPlacement() { isTextPlacementMode = false; canvas.classList.remove('text-placement-mode'); // reset dragging state isDraggingText = false; dragOffsetX = 0; dragOffsetY = 0; selectedTextElement = null; draggingCanvasContext = null; } function placeText(e) { const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; const x = (e.clientX - rect.left) * scaleX; const y = (e.clientY - rect.top) * scaleY; const text = document.getElementById('text-input').value; const fontFamily = document.getElementById('font-family').value; const fontSize = document.getElementById('font-size').value; // Build font style string let fontStyle = ''; if (textItalic) fontStyle += 'italic '; if (textBold) fontStyle += 'bold '; // Create a new text element const newText = { text: text, x: x, y: y, font: `${fontStyle}${fontSize}px ${fontFamily}`, color: brushColor }; // Add to our list of text elements textElements.push(newText); // Select this text element for immediate dragging selectedTextElement = newText; draggingCanvasContext = ctx.getImageData(0, 0, canvas.width, canvas.height); // Draw text on canvas ctx.font = newText.font; ctx.fillStyle = newText.color; ctx.fillText(newText.text, newText.x, newText.y); // Reset document.getElementById('text-input').value = ''; isTextPlacementMode = false; canvas.classList.remove('text-placement-mode'); setCanvasTitle('拖动新添加文字可调整位置'); } function redrawTextElements() { // Redraw all text elements after dithering textElements.forEach(item => { ctx.font = item.font; ctx.fillStyle = item.color; ctx.fillText(item.text, item.x, item.y); }); } function redrawLineSegments() { // Redraw all line segments after dithering lineSegments.forEach(segment => { ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.strokeStyle = segment.color; ctx.lineWidth = segment.size; ctx.beginPath(); if (segment.type === 'dot') { ctx.moveTo(segment.x, segment.y); ctx.lineTo(segment.x+0.1, segment.y+0.1); } else { ctx.moveTo(segment.x1, segment.y1); ctx.lineTo(segment.x2, segment.y2); } ctx.stroke(); }); }





没有回复内容