feat: 工具链就绪(bleak/pillow/pytest/capstone/androguard) + Web协议提取报告

This commit is contained in:
agent committed 2026-07-24 09:23:28 +00:00
1 parent 30571dc0a5
commit cb7eee312f
11 files changed
+8006

No files matched your search

+4409
View File
File diff suppressed because it is too large. Load diff
+15
View File
@@ -0,0 +1,15 @@
function hexToBytes(hex) {
for (var bytes = [], c = 0; c < hex.length; c += 2)
bytes.push(parseInt(hex.substr(c, 2), 16));
return new Uint8Array(bytes);
}
function bytesToHex(data) {
return new Uint8Array(data).reduce(
function (memo, i) {
return memo + ("0" + i.toString(16)).slice(-2);
}, "");
}
function intToHex(intIn, bytes = 4) {
return intIn.toString(16).padStart(bytes * 2, '0');
}
File diff suppressed because it is too large. Load diff
+943
View File
@@ -0,0 +1,943 @@
(function(){
const canvas = document.getElementById('unifiedCanvas');
const ctx = canvas.getContext('2d');
// 获取当前选中的屏幕尺寸
const screenSelect = document.getElementById('epdwh');
let W = 800, H = 480;
function updateCanvasSize() {
const [newW, newH] = screenSelect.value.split('x').map(Number);
W = newW;
H = newH;
canvas.width = W;
canvas.height = H;
}
// 初始化尺寸
updateCanvasSize();
let currentTemplate = 'schedule';
let globalTitle = "◉ 输入内容·页眉";
let presetLinesData = [];
let hitAreas = [];
let courseData = {
days: ["周一", "周二", "周三", "周四", "周五"],
periods: ["第1节", "第2节", "第3节", "第4节"],
cells: {}
};
function initDefaultCourse() {
courseData.cells = {};
const defaults = {
"0,0": "语文", "0,1": "数学", "0,2": "英语", "0,3": "体育",
"1,0": "数学", "1,1": "语文", "1,2": "物理", "1,3": "历史",
"2,0": "英语", "2,1": "化学", "2,2": "语文", "2,3": "自习",
"3,0": "物理", "3,1": "英语", "3,2": "数学", "3,3": "政治",
"4,0": "历史", "4,1": "体育", "4,2": "化学", "4,3": "班会"
};
for(let k in defaults) courseData.cells[k] = defaults[k];
}
initDefaultCourse();
let courseHitAreas = [];
function applyCourseCustomSettings() {
const dayCount = parseInt(document.getElementById('courseDaysCount').value) || 5;
const periodCount = parseInt(document.getElementById('coursePeriodsCount').value) || 4;
const dayNames = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
courseData.days = dayNames.slice(0, dayCount);
courseData.periods = [];
for(let i=1; i<=periodCount; i++) {
courseData.periods.push(`第${i}节`);
}
courseData.cells = {};
render();
}
let qrText = "https://mspppclock.tech/";
let qrSize = 150;
let qrCodeImage = null;
let qrCaptionLines = [];
let qrCaptionStyle = { fontSize: 14, fontFamily: "微软雅黑", baseColor: "#000000", invertMode: "none", alignment: "center" };
function syncCaptionFromTextarea() {
const raw = document.getElementById('qrCaptionText').value;
const lines = raw.split(/\r?\n/).filter(l => l.trim() !== "" || l === "");
qrCaptionLines = lines.length ? lines : ["(点击添加文字)"];
}
function updateCaptionLinesToUI() {
document.getElementById('qrCaptionText').value = qrCaptionLines.join("\n");
}
async function generateQRCodeImage(text, size=90, colorDark="#000000", colorLight="#FFFFFF") {
return new Promise((resolve) => {
const tempDiv = document.createElement('div');
tempDiv.style.position = 'absolute';
tempDiv.style.left = '-9999px';
document.body.appendChild(tempDiv);
try {
const qrcode = new QRCode(tempDiv, {
text: text,
width: size,
height: size,
colorDark: colorDark,
colorLight: colorLight,
correctLevel: QRCode.CorrectLevel.M
});
setTimeout(() => {
const canvasQR = tempDiv.querySelector('canvas');
if (canvasQR) {
const imgData = canvasQR.toDataURL();
resolve(imgData);
} else resolve(null);
document.body.removeChild(tempDiv);
}, 60);
} catch(e) { resolve(null); document.body.removeChild(tempDiv); }
});
}
async function refreshQRImage() {
qrSize = parseInt(document.getElementById('qrSizeInput').value) || 84;
const img = await generateQRCodeImage(qrText, qrSize, "#000", "#fff");
qrCodeImage = img;
if(currentTemplate === 'qrcode') render();
}
let customRawText = "小雨在放学路上捡到一枚【闪闪发亮的硬币】。\n她买了两颗【橘子味的糖果】,一颗塞进嘴里,\n甜得她眯起了眼睛;另一颗放在路边的长椅上。\n一只【唱歌的小鸟】飞过来,歪着脑袋看了看\n,叼起糖果飞走了。小雨有点失落,\n但还是继续往家走。第二天早上,她推开窗户,\n发现窗台上放着一朵【金黄色的小野花】,花瓣上还挂着露珠。小雨捧着花笑了:\n“原来快乐会【越分越多】呀。”\n从那天起,她每天都往长椅上放一颗糖。";
let customStyle = {
fontSize: 17, fontFamily: "宋体", baseColor: "#000000", invertMode: "none", alignment: "center"
};
const chineseFonts = ["宋体","微软雅黑","黑体","楷体","思源黑体","苹方"];
const invertModes = [
{ value: "none", label: "无" }, { value: "red-black", label: "红字黑底" }, { value: "red-white", label: "红字白底" },
{ value: "black-red", label: "黑字红底" }, { value: "black-white", label: "黑字白底" },
{ value: "white-red", label: "白字红底" }, { value: "white-black", label: "白字黑底" }
];
function applyInvertStyle(baseColor, invertMode) {
if (invertMode === "none") return { bgColor: null, textColor: baseColor };
if (invertMode === "red-black") return { bgColor: "#000000", textColor: "#FF0000" };
if (invertMode === "red-white") return { bgColor: "#FFFFFF", textColor: "#FF0000" };
if (invertMode === "black-red") return { bgColor: "#FF0000", textColor: "#000000" };
if (invertMode === "black-white") return { bgColor: "#FFFFFF", textColor: "#000000" };
if (invertMode === "white-red") return { bgColor: "#FF0000", textColor: "#FFFFFF" };
if (invertMode === "white-black") return { bgColor: "#000000", textColor: "#FFFFFF" };
return { bgColor: null, textColor: baseColor };
}
const templatePresets = {
schedule: [ { text: "✅ 今日核心议程", fontSize: 24, fontFamily: "微软雅黑", baseColor: "#FF0000", invertMode: "none", alignment: "center" },
{ text: "【09:30】 敏捷站会 · 【需求对齐】", fontSize: 18,fontFamily: "微软雅黑", baseColor: "#000000", invertMode: "none", alignment: "left" },
{ text: "11:00 前端架构评审会议", fontSize: 18, fontFamily: "黑体", baseColor: "#000000", invertMode: "black-red", alignment: "left" },
{ text: "14:30 跨部门协作同步会", fontSize: 18, fontFamily: "微软雅黑", baseColor: "#FF0000", invertMode: "red-white", alignment: "left" },
{ text: "16:45 代码审查 + 项目复盘", fontSize: 18, fontFamily: "楷体", baseColor: "#000000", invertMode: "none", alignment: "left" }
],
businesscard: [ { text: "✦ 名 片 精 选 ✦", fontSize: 24, fontFamily: "微软雅黑", baseColor: "#FF0000", invertMode: "none", alignment: "center" },
{ text: "欧阳 静澜", fontSize: 26, fontFamily: "黑体", baseColor: "#000000", invertMode: "black-white", alignment: "center" },
{ text: "创意设计总监 / 合伙人", fontSize: 18, fontFamily: "微软雅黑", baseColor: "#000000", invertMode: "none", alignment: "center" },
{ text: "jinglan@art.cn", fontSize: 17, fontFamily: "宋体", baseColor: "#FF0000", invertMode: "red-black", alignment: "center" },
{ text: "三色哲学 · 红黑白", fontSize: 16, fontFamily: "微软雅黑", baseColor: "#000000", invertMode: "none", alignment: "center" }
],
memo: [ { text: "📋 紧 急 备 忘 录", fontSize: 28, fontFamily: "微软雅黑", baseColor: "#FF0000", invertMode: "none", alignment: "center" },
{ text: "• 季度总结报告 (周五截止)", fontSize: 24, fontFamily: "微软雅黑", baseColor: "#000000", invertMode: "black-red", alignment: "left" },
{ text: "• 设计评审会议 14:00", fontSize: 24, fontFamily: "宋体", baseColor: "#000000", invertMode: "none", alignment: "left" },
{ text: "• 前端组件库升级计划", fontSize: 24, fontFamily: "黑体", baseColor: "#FF0000", invertMode: "red-white", alignment: "left" },
{ text: "• 团队建设活动策划", fontSize: 23, fontFamily: "微软雅黑", baseColor: "#000000", invertMode: "none", alignment: "left" }
]
};
function loadPresetData(templateId) {
if(templatePresets[templateId]) presetLinesData = JSON.parse(JSON.stringify(templatePresets[templateId]));
else presetLinesData = [{ text: "示例行", fontSize: 16, fontFamily: "微软雅黑", baseColor: "#000000", invertMode: "none", alignment: "left" }];
}
function renderCourseTable() {
ctx.fillStyle = "#FFFFFF";
ctx.fillRect(0, 0, W, H);
const isSmallScreen = (W === 296 && H === 128);
const isLargeScreen = (W === 800 && H === 480);
if (!isSmallScreen) {
// 显示页眉
const headerFontSize = isLargeScreen ? 36 : 18;
ctx.strokeStyle = "#000000";
ctx.lineWidth = 2;
ctx.strokeRect(4, 4, W-8, H-8);
ctx.fillStyle = "#FF0000";
ctx.fillRect(8, 8, 26, 4);
ctx.fillStyle = "#000000";
ctx.fillRect(W-54, 12, 42, 4);
let title = globalTitle.trim() || "输入学校班级学期等信息";
// 页眉标题
ctx.font = `bold ${headerFontSize}px '微软雅黑'`;
ctx.fillStyle = "#FF0000";
ctx.fillText(title, 20, headerFontSize + 12);
ctx.beginPath();
ctx.strokeStyle = "#FF0000";
ctx.moveTo(12, headerFontSize + 22);
ctx.lineTo(W-12, headerFontSize + 22);
ctx.stroke();
var startY = headerFontSize + 30;
} else {
ctx.strokeStyle = "#FF0000";
ctx.lineWidth = 2;
ctx.strokeRect(4, 4, W-8, H-8);
var startY = 8;
}
const dayCount = courseData.days.length;
const periodCount = courseData.periods.length;
const startX = 12;
const cellW = (W - 24) / (dayCount + 1);
const cellH = (H - startY - 8) / (periodCount + 1);
const headerTextFontSize = isLargeScreen ? 30 : 15;
const cellTextFontSize = isLargeScreen ? 28 : (isSmallScreen ? 11 : 14);
// 绘制节次表头
ctx.font = `bold ${headerTextFontSize}px '微软雅黑'`;
ctx.fillStyle = "#000000";
ctx.fillRect(startX, startY, cellW, cellH);
ctx.fillStyle = "#FF0000";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("节次", startX + cellW/2, startY + cellH/2);
// 绘制星期表头
for(let d=0; d<dayCount; d++) {
ctx.fillStyle = d%2===0 ? "#000000" : "#FF0000";
ctx.fillRect(startX + (d+1)*cellW, startY, cellW, cellH);
ctx.fillStyle = "#FFFFFF";
ctx.font = `bold ${headerTextFontSize}px '微软雅黑'`;
ctx.fillText(courseData.days[d], startX + (d+1)*cellW + cellW/2, startY + cellH/2);
}
courseHitAreas = [];
// 绘制课程内容
for(let p=0; p<periodCount; p++) {
const yPos = startY + (p+1)*cellH;
// 节次列
ctx.fillStyle = "#000000";
ctx.fillRect(startX, yPos, cellW, cellH);
ctx.fillStyle = "#FF0000";
ctx.font = `bold ${cellTextFontSize}px '微软雅黑'`;
ctx.fillText(courseData.periods[p], startX + cellW/2, yPos + cellH/2);
for(let d=0; d<dayCount; d++) {
const x = startX + (d+1)*cellW;
const y = yPos;
const cellKey = `${d},${p}`;
let courseName = courseData.cells[cellKey] || "点击";
ctx.fillStyle = "#F0F0F0";
ctx.fillRect(x, y, cellW, cellH);
// 获取单元格样式(如果没有则使用默认值)
const cellStyle = courseData.cellStyles?.[cellKey] || {
fontSize: cellTextFontSize,
fontFamily: "微软雅黑",
color: "#000000"
};
// 限制字体大小不超过单元格高度的60%
const finalFontSize = Math.min(cellStyle.fontSize, cellH * 0.6);
ctx.font = `${finalFontSize}px "${cellStyle.fontFamily}"`;
ctx.fillStyle = cellStyle.color;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
// 文字截断处理
let maxChars = isLargeScreen ? 6 : (isSmallScreen ? 2 : 4);
let displayName = courseName.length > maxChars ? courseName.slice(0, maxChars-1) + "…" : courseName;
// 如果文字仍然太宽,进一步缩小字体
let tempFontSize = finalFontSize;
let textWidth = ctx.measureText(displayName).width;
while (textWidth > cellW - 8 && tempFontSize > 10) {
tempFontSize--;
ctx.font = `${tempFontSize}px "${cellStyle.fontFamily}"`;
textWidth = ctx.measureText(displayName).width;
}
ctx.fillText(displayName, x + cellW/2, y + cellH/2);
courseHitAreas.push({ row: p, col: d, x, y, w: cellW, h: cellH, cellKey, currentName: courseName });
}
}
if (!isSmallScreen) {
// 底部装饰线
ctx.fillStyle = "#000000";
ctx.fillRect(16, H-11, 42, 2);
ctx.fillStyle = "#FF0000";
ctx.fillRect(66, H-13, 26, 2);
}
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
ctx.beginPath();
ctx.strokeStyle = "#ff0000";
ctx.lineWidth = 1;
ctx.setLineDash([]);
for(let d = 1; d <= dayCount; d++){
ctx.beginPath();
ctx.moveTo(startX + d * cellW, startY);
ctx.lineTo(startX + d * cellW, startY + (periodCount + 1) * cellH);
ctx.stroke();
}
for(let p = 1; p <= periodCount; p++){
ctx.beginPath();
ctx.moveTo(startX, startY + p * cellH);
ctx.lineTo(startX + (dayCount + 1) * cellW, startY + p * cellH);
ctx.stroke();
}
}
function editCourseCell(row, col, currentName) {
const cellKey = `${col},${row}`;
const existingStyle = courseData.cellStyles?.[cellKey] || {
fontSize: 16,
fontFamily: "微软雅黑",
color: "#000000"
};
const modal = document.createElement('div');
modal.className = 'modal-mask';
modal.innerHTML = `
<div class="modal-card" style="width: 320px;">
<h3>编辑课程</h3>
<div class="modal-field"><label>课程名称</label><input id="courseNameInput" value="${escapeHtml(currentName)}" style="width:100%;"></div>
<div class="modal-field"><label>字体大小</label><input id="fontSizeInput" type="number" value="${existingStyle.fontSize}" min="10" max="48" step="1" style="width:100%;"></div>
<div class="modal-field"><label>字体</label><select id="fontFamilyInput" style="width:100%;">${chineseFonts.map(f => `<option value="${f}" ${existingStyle.fontFamily === f ? 'selected' : ''}>${f}</option>`).join('')}</select></div>
<div class="modal-field"><label>文字颜色</label><select id="colorInput" style="width:100%;"><option value="#000000" ${existingStyle.color === '#000000' ? 'selected' : ''}>黑色</option><option value="#FF0000" ${existingStyle.color === '#FF0000' ? 'selected' : ''}>红色</option></select></div>
<div class="modal-buttons"><button class="cancel" id="cancelBtn">取消</button><button id="confirmBtn">确认</button></div>
</div>
`;
document.body.appendChild(modal);
modal.querySelector('#confirmBtn').onclick = () => {
const newName = modal.querySelector('#courseNameInput').value.trim() || "自习";
const newFontSize = parseInt(modal.querySelector('#fontSizeInput').value) || 16;
const newFontFamily = modal.querySelector('#fontFamilyInput').value;
const newColor = modal.querySelector('#colorInput').value;
courseData.cells[cellKey] = newName;
if (!courseData.cellStyles) courseData.cellStyles = {};
courseData.cellStyles[cellKey] = {
fontSize: Math.min(48, Math.max(10, newFontSize)),
fontFamily: newFontFamily,
color: newColor
};
modal.remove();
render();
};
modal.querySelector('.cancel').onclick = () => modal.remove();
modal.onclick = e => e.target === modal && modal.remove();
}
function renderPresetTemplate() {
ctx.fillStyle = "#ffffffff";
ctx.fillRect(0, 0, W, H);
const isSmallScreen = (W === 296 && H === 128);
const isLargeScreen = (W === 800 && H === 480);
if (!isSmallScreen) {
ctx.strokeStyle = "#FF0000";
ctx.lineWidth = 2;
ctx.strokeRect(4, 4, W-8, H-8);
ctx.fillStyle = "#FF0000";
ctx.fillRect(8, 8, 26, 4);
ctx.fillStyle = "#000000";
ctx.fillRect(W-54, 12, 42, 4);
let titleText = globalTitle.trim() || "输入内容·页眉";
let headerFontSize = isLargeScreen ? 52 : 26;
ctx.font = `bold ${headerFontSize}px '微软雅黑'`;
let titleWidth = ctx.measureText(titleText.slice(0,24)).width;
let bgX = (W - (titleWidth+40))/2;
ctx.fillStyle = "#FF0000";
ctx.fillRect(bgX, 7, titleWidth+40, headerFontSize + 10);
ctx.fillStyle = "#FFF";
ctx.fillText(titleText.slice(0,24), (W-titleWidth)/2, headerFontSize + 5);
ctx.beginPath();
ctx.strokeStyle = "#FF0000";
ctx.moveTo(12, headerFontSize + 20);
ctx.lineTo(W-12, headerFontSize + 20);
ctx.stroke();
var startY = headerFontSize + 50;
} else {
ctx.strokeStyle = "#FF0000";
ctx.lineWidth = 2;
ctx.strokeRect(4, 4, W-8, H-8);
var startY = 10;
}
const lineHeightBase = 26;
let currentY = startY;
hitAreas = [];
for(let i=0; i<presetLinesData.length; i++) {
const line = presetLinesData[i];
const segments = parseColoredText(line.text);
let { bgColor, textColor } = applyInvertStyle(line.baseColor, line.invertMode);
ctx.font = `${line.fontSize}px "${line.fontFamily}"`;
let totalWidth = 0;
segments.forEach(seg => {
ctx.font = `${line.fontSize}px "${line.fontFamily}"`;
totalWidth += ctx.measureText(seg.text).width;
});
let startX = line.alignment === 'center' ? (W - totalWidth) / 2 :
(line.alignment === 'right' ? W - totalWidth - 18 : 22);
let currentX = startX;
segments.forEach(seg => {
ctx.font = `${line.fontSize}px "${line.fontFamily}"`;
let textWidth = ctx.measureText(seg.text).width;
if(bgColor) {
ctx.fillStyle = bgColor;
ctx.fillRect(currentX, currentY-line.fontSize, textWidth+2, line.fontSize+4);
}
if(seg.isRed) {
ctx.fillStyle = "#FF0000";
} else {
ctx.fillStyle = textColor;
}
ctx.fillText(seg.text, currentX, currentY);
currentX += textWidth;
});
hitAreas.push({ index:i, yTop: currentY-20, yBottom: currentY+10 });
currentY += line.fontSize + 8;
if(currentY > H-20) break;
}
ctx.fillStyle = "#000";
ctx.fillRect(16, H-11, 42, 2);
ctx.fillStyle = "#FF0000";
ctx.fillRect(66, H-13, 26, 2);
}
function parseColoredText(text) {
const segments = [];
let lastIndex = 0;
const regex = /【(.*?)】/g;
let match;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
segments.push({
text: text.substring(lastIndex, match.index),
isRed: false
});
}
segments.push({
text: match[1],
isRed: true
});
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
segments.push({
text: text.substring(lastIndex),
isRed: false
});
}
return segments.length ? segments : [{ text: text, isRed: false }];
}
async function renderQRCodeMode() {
ctx.fillStyle = "#ffffffff";
ctx.fillRect(0, 0, W, H);
const isSmallScreen = (W === 296 && H === 128);
const isLargeScreen = (W === 800 && H === 480);
if (!isSmallScreen) {
ctx.strokeStyle = "#FF0000";
ctx.lineWidth = 2;
ctx.strokeRect(4, 4, W-8, H-8);
ctx.fillStyle = "#FF0000";
ctx.fillRect(8, 8, 26, 4);
ctx.fillStyle = "#000";
ctx.fillRect(W-54, 12, 42, 4);
const headerFontSize = isLargeScreen ? 36 : 18;
ctx.font = `bold ${headerFontSize}px '微软雅黑'`;
ctx.fillStyle = "#FF0000";
ctx.fillText(globalTitle.slice(0, 20), 33, headerFontSize + 10);
ctx.beginPath();
ctx.moveTo(12, headerFontSize + 20);
ctx.lineTo(W-12, headerFontSize + 20);
ctx.stroke();
var qrY = headerFontSize + 24;
var captionStartY = headerFontSize + qrSize + 30;
} else {
ctx.strokeStyle = "#FF0000";
ctx.lineWidth = 2;
ctx.strokeRect(4, 4, W-8, H-8);
var qrY = 8;
var captionStartY = qrSize + 12;
}
if(qrCodeImage) {
const img = new Image();
img.src = qrCodeImage;
await new Promise(r => { img.onload = r; img.onerror = r; });
let drawSize = qrSize;
if (isSmallScreen && drawSize > 90) {
drawSize = 80;
}
ctx.drawImage(img, (W-drawSize)/2, qrY, drawSize, drawSize);
}
let y = captionStartY;
const captionFontSize = isLargeScreen ? 32 : (isSmallScreen ? 12 : 18);
for(let line of qrCaptionLines) {
if(!line.trim()) { y += captionFontSize + 4; continue; }
ctx.font = `${captionFontSize}px '微软雅黑'`;
let tw = ctx.measureText(line).width;
ctx.fillStyle = "#f00";
ctx.fillText(line, (W-tw)/2, y+(H/20));
y += captionFontSize + 6;
}
if (!isSmallScreen) {
ctx.fillStyle = "#000";
ctx.fillRect(16, H-11, 42, 2);
ctx.fillStyle = "#FF0000";
ctx.fillRect(66, H-13, 26, 2);
}
}
function renderCustomMode() {
ctx.fillStyle = "#ffffffff";
ctx.fillRect(0, 0, W, H);
const isSmallScreen = (W === 296 && H === 128);
const isLargeScreen = (W === 800 && H === 480);
if (!isSmallScreen) {
ctx.strokeStyle = "#FF0000";
ctx.lineWidth = 2;
ctx.strokeRect(4, 4, W-8, H-8);
const headerFontSize = isLargeScreen ? 40 : 20;
const headerText = globalTitle.slice(0, 24);
ctx.font = `bold ${headerFontSize}px '微软雅黑'`;
const textWidth = ctx.measureText(headerText).width;
const boxX = 14;
const boxY = 8;
const boxWidth = Math.max(textWidth + 30, 150);
const boxHeight = headerFontSize + 10;
ctx.fillStyle = "#000";
ctx.fillRect(boxX, boxY, boxWidth, boxHeight);
ctx.fillStyle = "#FF0000";
ctx.fillText(headerText, boxX + 15, boxY + headerFontSize + 3);
ctx.fillStyle = "#FF0000";
ctx.fillRect(8, 8, 26, 4);
ctx.fillStyle = "#000";
ctx.fillRect(W-54, 12, 42, 4);
ctx.beginPath();
ctx.moveTo(12, headerFontSize + 30);
ctx.lineTo(W-12, headerFontSize + 30);
ctx.stroke();
var startY = headerFontSize + 48;
} else {
ctx.strokeStyle = "#FF0000";
ctx.lineWidth = 2;
ctx.strokeRect(4, 4, W-8, H-8);
var startY = 8;
}
const { fontSize, fontFamily, baseColor, invertMode, alignment } = customStyle;
const { bgColor, textColor } = applyInvertStyle(baseColor, invertMode);
ctx.font = `${fontSize}px "${fontFamily}"`;
let rawLines = customRawText.split('\n');
let allLines = [];
for(let line of rawLines) {
let segments = parseColoredText(line);
let currentLineSegments = [];
let currentLineWidth = 0;
for(let seg of segments) {
ctx.font = `${fontSize}px "${fontFamily}"`;
let segWidth = ctx.measureText(seg.text).width;
if(currentLineWidth + segWidth > W - 44 && currentLineSegments.length > 0) {
allLines.push([...currentLineSegments]);
currentLineSegments = [];
currentLineWidth = 0;
}
currentLineSegments.push(seg);
currentLineWidth += segWidth;
}
if(currentLineSegments.length > 0) {
allLines.push(currentLineSegments);
}
}
let y = startY;
for(let lineSegments of allLines) {
if(!lineSegments.length) { y += fontSize + 6; continue; }
let totalWidth = 0;
for(let seg of lineSegments) {
ctx.font = `${fontSize}px "${fontFamily}"`;
totalWidth += ctx.measureText(seg.text).width;
}
let startX = alignment === 'center' ? (W - totalWidth) / 2 :
(alignment === 'right' ? W - totalWidth - 18 : 22);
let currentX = startX;
for(let seg of lineSegments) {
ctx.font = `${fontSize}px "${fontFamily}"`;
let textWidth = ctx.measureText(seg.text).width;
const textHeight = fontSize;
if(bgColor) {
ctx.fillStyle = bgColor;
ctx.fillRect(currentX-2, y + fontSize/2 - textHeight+1, textWidth+4, textHeight + 4);
}
if(seg.isRed) {
ctx.fillStyle = "#FF0000";
} else {
ctx.fillStyle = textColor;
}
ctx.fillText(seg.text, currentX, y + fontSize/2);
currentX += textWidth;
}
y += fontSize + 6;
if(y > H-30) break;
}
if (!isSmallScreen) {
ctx.fillStyle = "#000";
ctx.fillRect(16, H-11, 42, 2);
ctx.fillStyle = "#FF0000";
ctx.fillRect(66, H-13, 26, 2);
}
}
function render() {
if(currentTemplate === 'course') renderCourseTable();
else if(currentTemplate === 'qrcode') renderQRCodeMode();
else if(currentTemplate === 'customText') renderCustomMode();
else renderPresetTemplate();
const canvas = document.getElementById('unifiedCanvas');
applyImageAdjustmentsmb(canvas)
}
function applyImageAdjustmentsmb(canvas) {
const brightness = 0;
const contrast = 100;
const saturation = 100;
const diffusion = 0;
const ctx = canvas.getContext('2d');
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
if (brightness !== 0) {
const brightnessFactor = brightness / 100;
for (let i = 0; i < data.length; i += 4) {
data[i] = clamp(data[i] + 255 * brightnessFactor, 0, 255);
data[i + 1] = clamp(data[i + 1] + 255 * brightnessFactor, 0, 255);
data[i + 2] = clamp(data[i + 2] + 255 * brightnessFactor, 0, 255);
}
}
if (contrast !== 0) {
const contrastFactor = (contrast + 100) / 100;
const contrastAdjust = (1 - contrastFactor) * 128;
for (let i = 0; i < data.length; i += 4) {
data[i] = clamp(data[i] * contrastFactor + contrastAdjust, 0, 255);
data[i + 1] = clamp(data[i + 1] * contrastFactor + contrastAdjust, 0, 255);
data[i + 2] = clamp(data[i + 2] * contrastFactor + contrastAdjust, 0, 255);
}
}
if (saturation !== 100) {
const saturationFactor = saturation / 100;
for (let i = 0; i < data.length; i += 4) {
const gray = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
data[i] = clamp(gray + (data[i] - gray) * saturationFactor, 0, 255);
data[i + 1] = clamp(gray + (data[i + 1] - gray) * saturationFactor, 0, 255);
data[i + 2] = clamp(gray + (data[i + 2] - gray) * saturationFactor, 0, 255);
}
}
ctx.putImageData(imageData, 0, 0);
return diffusion;
}
function handleCanvasClick(e) {
if(currentTemplate === 'course') {
const r = canvas.getBoundingClientRect();
const x = (e.clientX-r.left)*(W/r.width);
const y = (e.clientY-r.top)*(H/r.height);
for(let a of courseHitAreas) {
if(x>=a.x&&x<=a.x+a.w&&y>=a.y&&y<=a.y+a.h) {
editCourseCell(a.row,a.col,a.currentName); return;
}
}
return;
}
if(currentTemplate === 'qrcode') {
const r = canvas.getBoundingClientRect();
const y = (e.clientY-r.top)*(H/r.height);
if(y>130) {
const m = document.createElement('div');
m.className='modal-mask';
m.innerHTML=`<div class="modal-card"><h3>编辑文字</h3><textarea rows=4 style="width:100%;background:#000;color:#fff;border:1px solid red;border-radius:16px;padding:10px;">${qrCaptionLines.join('\n')}</textarea><div class="modal-buttons"><button class=cancel>取消</button><button class=ok>保存</button></div></div>`;
document.body.appendChild(m);
m.querySelector('.ok').onclick=()=>{
qrCaptionLines = m.querySelector('textarea').value.split('\n');
updateCaptionLinesToUI();
render(); m.remove();
};
m.querySelector('.cancel').onclick=()=>m.remove();
}
return;
}
if(currentTemplate !== 'customText') {
const r = canvas.getBoundingClientRect();
const y = (e.clientY-r.top)*(H/r.height);
for(let a of hitAreas) {
if(y>=a.yTop&&y<=a.yBottom) {
editPresetModal(a.index); return;
}
}
addPresetNewRow();
}
}
function addPresetNewRow() {
const modal = document.createElement('div');
modal.className = 'modal-mask';
modal.innerHTML = `
<div class="modal-card">
<h3>➕ 添加新行</h3>
<div class="modal-field"><label>文字内容</label><input id="modalText" value="新行内容"></div>
<div class="modal-field"><label>大小</label><input id="modalFontSize" type="number" value="14" min="10" max="52"></div>
<div class="modal-field"><label>字体</label><select id="modalFontFamily">${chineseFonts.map(f => `<option value="${f}">${f}</option>`).join('')}</select></div>
<div class="modal-field"><label>基色</label><select id="modalBaseColor"><option value="#000000">黑色</option><option value="#FF0000">红色</option></select></div>
<div class="modal-field"><label>反色</label><select id="modalInvert">${invertModes.map(m => `<option value="${m.value}">${m.label}</option>`).join('')}</select></div>
<div class="modal-field"><label>对齐</label><select id="modalAlign"><option value="left">左</option><option value="center">中</option><option value="right">右</option></select></div>
<div class="modal-buttons"><button class="cancel" id="cancelBtn">取消</button><button id="confirmBtn">确认</button></div>
</div>
`;
document.body.appendChild(modal);
const confirm = () => {
const newRow = {
text: modal.querySelector('#modalText').value,
fontSize: Math.min(52, Math.max(10, parseInt(modal.querySelector('#modalFontSize').value) || 14)),
fontFamily: modal.querySelector('#modalFontFamily').value,
baseColor: modal.querySelector('#modalBaseColor').value,
invertMode: modal.querySelector('#modalInvert').value,
alignment: modal.querySelector('#modalAlign').value
};
presetLinesData.push(newRow);
renderPresetTemplate();
modal.remove();
};
modal.querySelector('#confirmBtn').onclick = confirm;
modal.querySelector('#cancelBtn').onclick = () => modal.remove();
modal.onclick = (e) => { if(e.target === modal) modal.remove(); };
}
function editPresetModal(idx) {
const line = presetLinesData[idx];
const modal = document.createElement('div');
modal.className = 'modal-mask';
modal.innerHTML = `
<div class="modal-card">
<h3>编辑第 ${idx+1} 行</h3>
<div class="modal-field"><label>文字内容</label><input id="modalText" value="${escapeHtml(line.text)}"></div>
<div class="modal-field"><label>大小</label><input id="modalFontSize" type="number" value="${line.fontSize}" min="10" max="52"></div>
<div class="modal-field"><label>字体</label><select id="modalFontFamily">${chineseFonts.map(f => `<option value="${f}" ${line.fontFamily===f?'selected':''}>${f}</option>`).join('')}</select></div>
<div class="modal-field"><label>基色</label><select id="modalBaseColor"><option value="#000000" ${line.baseColor==='#000000'?'selected':''}>黑色</option><option value="#FF0000" ${line.baseColor==='#FF0000'?'selected':''}>红色</option></select></div>
<div class="modal-field"><label>反色</label><select id="modalInvert">${invertModes.map(m => `<option value="${m.value}" ${line.invertMode===m.value?'selected':''}>${m.label}</option>`).join('')}</select></div>
<div class="modal-field"><label>对齐</label><select id="modalAlign"><option value="left" ${line.alignment==='left'?'selected':''}>左</option><option value="center" ${line.alignment==='center'?'selected':''}>中</option><option value="right" ${line.alignment==='right'?'selected':''}>右</option></select></div>
<div class="modal-buttons"><button class="cancel" id="cancelBtn">取消</button><button id="confirmBtn">确认</button></div>
</div>
`;
document.body.appendChild(modal);
const confirm = () => {
presetLinesData[idx] = {
text: modal.querySelector('#modalText').value,
fontSize: Math.min(52, Math.max(10, parseInt(modal.querySelector('#modalFontSize').value) || 14)),
fontFamily: modal.querySelector('#modalFontFamily').value,
baseColor: modal.querySelector('#modalBaseColor').value,
invertMode: modal.querySelector('#modalInvert').value,
alignment: modal.querySelector('#modalAlign').value
};
renderPresetTemplate();
modal.remove();
};
modal.querySelector('#confirmBtn').onclick = confirm;
modal.querySelector('#cancelBtn').onclick = () => modal.remove();
modal.onclick = (e) => { if(e.target === modal) modal.remove(); };
}
function switchTemplate(tpl) {
currentTemplate = tpl;
document.getElementById('customBlock').style.display = tpl==='customText'?'block':'none';
document.getElementById('qrcodeBlock').style.display = tpl==='qrcode'?'block':'none';
document.getElementById('courseBlock').style.display = tpl==='course'?'block':'none';
document.getElementById('presetHintBlock').style.display = (tpl!=='customText'&&tpl!=='qrcode')?'block':'none';
if(tpl === 'course') { loadPresetData('schedule'); render(); }
else if(tpl === 'qrcode') { syncCaptionFromTextarea(); refreshQRImage(); }
else if(tpl === 'customText') { render(); }
else { loadPresetData(tpl); render(); }
}
function resetCurrent() {
if(currentTemplate === 'customText') {
customRawText = "小雨在放学路上捡到一枚【闪闪发亮的硬币】。\n她买了两颗【橘子味的糖果】,一颗塞进嘴里,\n甜得她眯起了眼睛;另一颗放在路边的长椅上。\n一只【唱歌的小鸟】飞过来,歪着脑袋看了看\n,叼起糖果飞走了。小雨有点失落,\n但还是继续往家走。第二天早上,她推开窗户,\n发现窗台上放着一朵【金黄色的小野花】,花瓣上还挂着露珠。小雨捧着花笑了:\n“原来快乐会【越分越多】呀。”\n从那天起,她每天都往长椅上放一颗糖。";
document.getElementById('freeTextArea').value = customRawText;
} else if(currentTemplate === 'qrcode') {
qrText = "https://mspppclock.tech/";
document.getElementById('qrContentInput').value = qrText;
qrCaptionLines = ["扫码访问官网"];
updateCaptionLinesToUI();
refreshQRImage();
} else if(currentTemplate === 'course') {
initDefaultCourse();
} else {
loadPresetData(currentTemplate);
}
render();
}
function escapeHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function init() {
const fsel = document.getElementById('customFontFamily');
chineseFonts.forEach(f=>{ let o=document.createElement('option');o.value=f;o.textContent=f;fsel.appendChild(o); });
const isel = document.getElementById('customInvertMode');
invertModes.forEach(m=>{ let o=document.createElement('option');o.value=m.value;o.textContent=m.label;isel.appendChild(o); });
document.getElementById('freeTextArea').value = customRawText;
document.getElementById('freeTextArea').oninput = e=>{ customRawText=e.target.value; render(); };
document.querySelectorAll('#customFontSize,#customFontFamily,#customBaseColor,#customInvertMode,#customAlign').forEach(i=>{
i.onchange=()=>{
customStyle.fontSize = +document.getElementById('customFontSize').value;
customStyle.fontFamily = document.getElementById('customFontFamily').value;
customStyle.baseColor = document.getElementById('customBaseColor').value;
customStyle.invertMode = document.getElementById('customInvertMode').value;
customStyle.alignment = document.getElementById('customAlign').value;
render();
};
});
document.getElementById('globalTitleInput').oninput = e=>{ globalTitle=e.target.value; render(); };
document.getElementById('forceRefreshBtn').onclick = render;
document.getElementById('resetCurrentBtn').onclick = resetCurrent;
document.getElementById('generateQRBtn').onclick = ()=>{ qrText=document.getElementById('qrContentInput').value; refreshQRImage(); };
document.getElementById('qrCaptionText').oninput = ()=>{ syncCaptionFromTextarea(); render(); };
document.getElementById('applyCourseSettingBtn').onclick = applyCourseCustomSettings;
canvas.onclick = handleCanvasClick;
document.querySelectorAll('.temp-btn').forEach(b=>{
b.onclick=()=>{
document.querySelectorAll('.temp-btn').forEach(x=>x.classList.remove('active'));
b.classList.add('active');
switchTemplate(b.dataset.template);
};
});
document.getElementById('epdwh').addEventListener('change', function() {
const [newW, newH] = this.value.split('x').map(Number);
canvas.width = newW;
canvas.height = newH;
render();
});
// 监听屏幕尺寸变化
document.getElementById('epdwh').addEventListener('change', function() {
updateCanvasSize();
if (currentTemplate === 'qrcode') {
refreshQRImage(); // 二维码模式需要重新生成
} else {
render();
}
});
switchTemplate('schedule');
}
init();
})();
+181
View File
@@ -0,0 +1,181 @@
# 墨水屏 Web 上位机 BLE 协议提取报告(原始证据)
> 来源:analysis/web/(ble.js 4408 行 / index.html 1309 行 / moban.js 943 行 / timedjs.js 714 行 / timedjs2.js 323 行 / switch2.js 54 行 / time.js 21 行 / hex.js 15 行)
> 设备:4.2寸 400x300 黑白红三色墨水屏,DA14585,设备名前缀 `NRF-`
## 1. GATT 结构
| 项 | 值 | 证据 |
|---|---|---|
| 设备名过滤 | `namePrefix: 'NRF-'` | ble.js:4052-4054 |
| optionalServices | `0000221f-...34fb`、`00001f10-...34fb`、`13187b10-eba9-a3ba-044e-83d3217d9a38` | ble.js:4054 |
| EPD Service | `13187b10-eba9-a3ba-044e-83d3217d9a38` | ble.js:4163 |
| EPD Characteristic | `4b646063-6264-f3a7-8941-e65356ea82fe` | ble.js:4165 |
| RXTX Service | `00001f10-0000-1000-8000-00805f9b34fb` | ble.js:4101 |
| RXTX Characteristic | `00001f1f-0000-1000-8000-00805f9b34fb` | ble.js:4103 |
| `0000221f` | 仅声明未使用 | ble.js:4054 |
- epdCharacteristic:图像数据块 + 刷屏命令(`00xx`/`01`/`AA`),writeValueWithResponse(ble.js:38-44),订阅 notify(ble.js:4167-4205)。
- rxtxCharacteristic:全部控制命令(时间/模式/WiFi/激活/倒计时/模板槽位),writeValueWithResponse + 100ms 后 read(ble.js:50-71);另有 writeValueWithoutResponse(ble.js:84-90);notify 是设备ID 14 字节应答主通道(ble.js:4121-4137)。
- 连接顺序:preConnect()(ble.js:4045)→ connect()(ble.js:4158)先 EPD 后 RXTX(ble.js:4207);断线重连≤5次(ble.js:30-36)。
## 2. 命令协议
### 2.1 发送封装
| 函数 | 行为 | 证据 |
|---|---|---|
| sendCommand | EPD writeValueWithResponse | ble.js:38-44 |
| rxTxSendCommand | RXTX write+100ms read | ble.js:50-75 |
| rxTxSendCommand2 | RXTX writeWithoutResponse | ble.js:84-90 |
| triggerEpdCmd | hex→EPD | ble.js:3971-3974 |
| triggerRxTxCmd | hex→RXTX | ble.js:3963-3966 |
| triggerRxTxCmd2 | hex→RXTX(无响应) | ble.js:3967-3970 |
| setri | RXTX 发 cmd,300ms 后补 `e2` | ble.js:3956-3962 |
| clearScreen | EPD 发 `00{cmd}` 再 `01` | ble.js:45-49 |
| triggerEpdRed_lut | RXTX `E6{cmd}`(0 直接返回) | ble.js:3975-3980 |
| triggerEpdJH | RXTX `EF{cmd}`,500ms 后 `e2` | ble.js:3981-3987 |
| triggerEpdCHEPAI | 字符双写+`EF` 前缀,500ms 后 `e2` | ble.js:3988-3998 |
| triggerRxTxCmd_LED | `cmd+note+color+HH+MM` | ble.js:3999-4009 |
| triggerRxTxCmd_XM | `cmd+HH+MM` | ble.js:4010-4020 |
| triggerRxTxCmd_LB | `e901`+hex(n)+cmd | ble.js:4022-4038 |
### 2.2 Opcode 一览
**EPD 通道:**
| Opcode | 含义 | 证据 |
|---|---|---|
| `00{xx}` | 刷屏第一步 | ble.js:47 |
| `01` | 刷屏执行/整图结束刷新 | ble.js:48,160,230 |
| `AA` | 日历小图传输结束 | ble.js:159 |
| `03{ff\|00}{offset:2B BE}{data}` | 整图数据块(≤240B/块) | ble.js:98,104 |
| `04{ff\|00}{offset:2B BE}{data}` | 日历小图数据块(hex 总长 7000/18000 时) | ble.js:99,104 |
**RXTX 通道:**
| Opcode | 含义 | 证据 |
|---|---|---|
| `e2` | 提交/强制刷新(多数命令收尾) | ble.js:256,3961 等 |
| `dd{unix:4B}{year:2B}{month:1B}{day:1B}{week:1B}` | 设置时间 | ble.js:251-257 |
| `2300`/`2301` | 三色/黑白屏模式 | index.html:840-841 |
| `14` | 12/24 小时制切换 | index.html:845 |
| `15` | 时钟颜色切换 | index.html:881 |
| `e3` | 反色模式 | index.html:878 |
| `e4` | 时钟字体切换 | index.html:880 |
| `e5` | 旋转180° | index.html:886 |
| `e10000`..`e10003` | 图片模式 图一~图四 | index.html:868-873 |
| `e10103`/`e10104`/`e10105` | 日历模式一/二/三 | index.html:860-862 |
| `e10201`/`e10202`/`e10203` | 时钟模式一/二/三 | index.html:853-855 |
| `E6{lut}` | 红/黑 LUT 校准:01–0f 设红、10–f0 设黑 | ble.js:3978; index.html:885-887 |
| `FB01{HH}{MM}` / `FB00{HH}{MM}` | 开/关休眠时段 | index.html:897-898; ble.js:4017 |
| `e901{n:hex}{01\|00}` | 图片轮播间隔(01=分钟 00=小时) | ble.js:4035 |
| `EA0{slot}` | 上传选槽位 EA00–EA03 | ble.js:213,237 |
| `EA000{n-1}` | 轮播数量 1–4 | ble.js:241-249 |
| `EF{激活码hex}` | 激活设备 | ble.js:3891,3983 |
| `EF{逐位双写号码}` | 停车牌("12"→`EF1122`) | ble.js:3990-3995 |
| `EFEF` | 请求设备 ID(14B notify 应答) | ble.js:3924,4123 |
| `FA` | 关闭倒计时 | timedjs2.js:142,308 |
| `ff00{year}{MM}{dd}` | 时钟倒计时日期(year=4位十进制ASCII) | timedjs2.js:250-253 |
| `ffff{year}{MM}{dd}` | 日历倒计时日期 | timedjs2.js:245-248 |
| `04{128B hex}` + `0{charCount}{余hex}` | 时钟倒计时前缀文字位图(>4字符拆两包) | timedjs2.js:206-217 |
| `0{charCount}{位图hex}` | 同上 ≤4字符单包 | timedjs2.js:218-224 |
| `990{j}{72B hex}` | 日历倒计时文字位图(每字符一包,24x24/8) | timedjs2.js:229-233,300-304 |
| ASCII `WIFI_SSID:..;WIFI_PASS:..;CITY:..` | WiFi+天气城市配置(UTF-8 直发) | ble.js:3839-3863 |
| `{cmd}{note}{color}{HH}{MM}` | LED 变体(遗留) | ble.js:4000-4008 |
## 3. 图像传输协议(sendBufferData ble.js:92-107)
1. 分块:`my_step=480` hex 字符 = 240 数据字节/块(ble.js:9,100)。
2. 帧格式(ble.js:104):`cod`(1B: 03整图/04小图) + `code`(1B: ff=bw平面 00=红平面) + offset(2B 大端,=字节序号) + ≤240B 数据。块总长 244B。
3. cod 判定(ble.js:98-99):hex 长度 7000(=3500B=280x100/8) 或 18000(=9000B=400x180/8) 用 `04`,否则 `03`。
4. 无应用层 ACK,顺序 writeWithResponse(ble.js:102-106)。
5. 结束:delay(300) → 整图发 `01`;日历小图(宽280 或 高180)发 `AA`(ble.js:158-160)。
上传函数差异:
| 函数 | 前置 | 打包 | 结束 |
|---|---|---|---|
| upload_image | 无 | 296宽→canvas2bytes_bw;250/212宽→bw;其他→canvas2bytes 双平面 | 01/AA |
| upload_image_moban(bat) | RXTX `EA0{bat}` | 296宽→bw,否则双平面 | 01 |
| upload_image_lb(cmd) | RXTX `EA0{cmd}` | 同 upload_image | 同 |
| upload_image_lb_num(cmd) | RXTX `EA000{cmd-1}` | 不传图 | 无 |
三色 = 两次发送:bw 平面(code=ff) + 红平面(code=00)(ble.js:154-155,219-222)。
## 4. 像素打包(timedjs.js)
| 函数 | 扫描 | 位序 | 用途 | 证据 |
|---|---|---|---|---|
| canvas2bytes | 行主序 y0→H,x0→W | MSB first,8px/B | 400x300/800x480/280x100/400x180 | timedjs.js:564-584 |
| canvas2bytes_bw | 列主序 x 从 W-1→0,y0→H | MSB first | 2.9寸296x128、2.13寸 | timedjs.js:586-611 |
| canvas2bytes_to | 转置 | MSB first | 未调用 | timedjs.js:612-632 |
位值:bw 平面 R,G,B 全>0→1(白) 否则 0(黑);bwr 平面 R>0,G=0,B=0→1(红)(timedjs.js:573-576,599-601)。
400x300 → 每平面 15000B。红色判定:`r>g*1.5 && r>b*1.5 && r>threshold`(timedjs.js:255 等)。
抖动算法(dithering() 分发 timedjs.js:513-563):
- 黑白:none/floydsteinberg(7,3,5,1 /16)/atkinson(1/8×6)/bayer(4x4 [[15,135,45,165],[195,75,225,105],[60,180,30,150],[240,120,210,90]])/stucki(/42)/jarvis(/48)
- 黑白红:bwr_ 前缀同名 6 种,默认 bwr_atkinson
- threshold 默认 125;diffusion 0–100/100 误差缩放
- bwrPalette=黑白红(timedjs.js:504-508)
- 预处理 applyImageAdjustments:亮度/对比度/饱和度(timedjs.js:2-38),灰度 0.299/0.587/0.114
## 5. 时钟/日历/倒计时
setTime(ble.js:251-257)RXTX 连发 `dd…` + `e2`:
| 偏移 | 长度 | 含义 |
|---|---|---|
| 0 | 1B | `dd` |
| 1 | 4B | unix 秒(本地时区修正 Date.now()/1000 - tzOffset*60,ble.js:4222) |
| 5 | 2B | 年(大端) |
| 7 | 1B | 月 1–12 |
| 8 | 1B | 日 |
| 9 | 1B | 星期(getDay()||7:周一=1…周日=7) |
倒计时:
- 时钟界面 gridSize=16:>4 字符(中文=1 英文=0.5)拆两包 `04`+128B / `0{n}`+余;≤4 单包 `0{n}`(timedjs2.js:206-224)
- 日历界面 gridSize=24:每字符一包 `990{j}`+72B,间隔1s(timedjs2.js:228-235)
- 日期帧 `ff00{year}{MM}{dd}`(时钟)/`ffff...`(日历),year=4位十进制ASCII(timedjs2.js:244-254)
- 收尾 `E2`;关闭 `FA`+1s+`E2`(timedjs2.js:141-145)
- upload_rili:`990*`→`FA`→`e10103`→`E2`(timedjs2.js:308-312)
## 6. 模板系统(moban.js)
6 模板渲染到 #unifiedCanvas(#epdwh 选 400x300/800x480/296x128),无 BLE 代码;上传走 upload_image_moban(0)→`EA00`+图块+`01`(index.html:1243)。
| 模板 | data-template | 渲染 |
|---|---|---|
| 日程 schedule | renderPresetTemplate | moban.js:140-145,369-467 |
| 名片 businesscard | 同上 | moban.js:146-151 |
| 备忘录 memo | 同上 | moban.js:152-157 |
| 课程表 course | renderCourseTable(默认5天x4节,3–7天、2–9节) | moban.js:29-63,165-317 |
| 二维码 qrcode | renderQRCodeMode(qrcodejs CorrectLevel.M) | moban.js:81-113,501-570 |
| 自定义文本 customText | renderCustomMode(自动换行) | moban.js:572-693 |
红字语法 `【…】`(parseColoredText moban.js:470-498);反色模式 7 种(moban.js:122-137)。
## 7. 激活机制
- API:`http://4638e813e4.qicp.vip:8889/key_generator.php`(ble.js:3866)
- fullAutoActivate(ble.js:3932-3955):
1. 读 #fixedId(用户固定ID,UI 隐藏 index.html:930-935)
2. getDeviceIdFromScreen:RXTX 发 `EFEF`,8s 超时,notify 累计 14 字节(EXPECTED_ID_BYTES=14 ble.js:4123),可打印转 ASCII 否则 hex(ble.js:4121-4137)
3. requestActivationCode:GET `?id={fixedId}&key={deviceId}`(ble.js:3879-3886)
4. extractActivationCode:正则 `激活码1:([0-9A-F]{32,})` / `激活码:(…)` / 任意≥32hex(ble.js:3869-3876)
5. sendActivationToDevice:`EF`+大写激活码,400ms 后 `e2`(ble.js:3889-3902)
- 算法在服务端 key_generator.php,客户端无实现。自助站 qbsg.top,作者微信 2549294317。
- 固件下载同 host:`http://4638e813e4.qicp.vip:8889/GuJian/file_down.php`(index.html:836),型号列表在服务端。
## 8. 高级命令
index.html:919-920:#cmdcmd 任意 hex → triggerRxTxCmd 直发 RXTX。
hex.js:2-15:hexToBytes/bytesToHex/intToHex(大端 pad)。
## 9. 变体与其他
- LED=灯板变体(遗留);XM=休眠(FB00/FB01);LB=轮播(e901/EA000);CHEPAI=停车牌;JH=激活;lut=LUT 校准 E6
- 设备名前缀仅 `NRF-`(ble.js:4053)
- chinaCities 城市→天气代码表 ble.js:259 起约3580行,用于 WIFI 配置 CITY 字段
- 死代码:SendCommand(i 未初始化)、upload_image_lbs(未定义)
+55
View File
@@ -0,0 +1,55 @@
document.getElementById('brightness').addEventListener('input', function() {
document.getElementById('brightnessValue').textContent = this.value;
});
document.getElementById('contrast').addEventListener('input', function() {
document.getElementById('contrastValue').textContent = this.value;
});
document.getElementById('saturation').addEventListener('input', function() {
document.getElementById('saturationValue').textContent = this.value;
});
document.getElementById('diffusion').addEventListener('input', function() {
document.getElementById('diffusionValue').textContent = this.value;
});
// 获取 DOM 元素
const brightnessSlider = document.getElementById('brightness');
const contrastSlider = document.getElementById('contrast');
const saturationSlider = document.getElementById('saturation');
const diffusionSlider = document.getElementById('diffusion');
const brightnessValue = document.getElementById('brightnessValue');
const contrastValue = document.getElementById('contrastValue');
const saturationValue = document.getElementById('saturationValue');
const diffusionValue = document.getElementById('diffusionValue');
// 应用滤镜效果
function applyFilters() {
update_image();
}
// 监听滑块变化事件(实时刷新)
brightnessSlider.addEventListener('input', applyFilters);
contrastSlider.addEventListener('input', applyFilters);
saturationSlider.addEventListener('input', applyFilters);
diffusionSlider.addEventListener('input', applyFilters);
// 重置所有参数
function resetAll() {
brightnessSlider.value = '0';
contrastSlider.value = '0';
saturationSlider.value = '35';
diffusionSlider.value = '100';
// 更新显示数值
brightnessValue.textContent = 0;
contrastValue.textContent = 0;
saturationValue.textContent = 35;
diffusionValue.textContent = 100;
updateRotation(0) ;
updateScale(100) ;
update_image();
}
+21
View File
@@ -0,0 +1,21 @@
function toggleSwitch(e) {
const rect = e.currentTarget.getBoundingClientRect();
const clickX = e.clientX - rect.left;
if (clickX < rect.width / 2) {
// 选中时钟
switchBg.style.left = '0';
gridSizeSelect.value=16;
} else {
// 选中日历
switchBg.style.left = '50%';
gridSizeSelect.value=24;
}
}
+715
View File
@@ -0,0 +1,715 @@
function applyImageAdjustments(canvas) {
const brightness = parseInt(document.getElementById('brightness').value);
const contrast = parseInt(document.getElementById('contrast').value);
const saturation = parseInt(document.getElementById('saturation').value);
const diffusion = parseInt(document.getElementById('diffusion').value) / 100;
const ctx = canvas.getContext('2d');
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
if (brightness !== 0) {
const brightnessFactor = brightness / 100;
for (let i = 0; i < data.length; i += 4) {
data[i] = clamp(data[i] + 255 * brightnessFactor, 0, 255);
data[i + 1] = clamp(data[i + 1] + 255 * brightnessFactor, 0, 255);
data[i + 2] = clamp(data[i + 2] + 255 * brightnessFactor, 0, 255);
}
}
if (contrast !== 0) {
const contrastFactor = (contrast + 100) / 100;
const contrastAdjust = (1 - contrastFactor) * 128;
for (let i = 0; i < data.length; i += 4) {
data[i] = clamp(data[i] * contrastFactor + contrastAdjust, 0, 255);
data[i + 1] = clamp(data[i + 1] * contrastFactor + contrastAdjust, 0, 255);
data[i + 2] = clamp(data[i + 2] * contrastFactor + contrastAdjust, 0, 255);
}
}
if (saturation !== 100) {
const saturationFactor = saturation / 100;
for (let i = 0; i < data.length; i += 4) {
const gray = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
data[i] = clamp(gray + (data[i] - gray) * saturationFactor, 0, 255);
data[i + 1] = clamp(gray + (data[i + 1] - gray) * saturationFactor, 0, 255);
data[i + 2] = clamp(gray + (data[i + 2] - gray) * saturationFactor, 0, 255);
}
}
ctx.putImageData(imageData, 0, 0);
return diffusion;
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
function applyNoneDithering(ctx, width, height, threshold, isColor = false) {
const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const gray = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
const value = gray < threshold ? 0 : 255;
data[i] = data[i + 1] = data[i + 2] = value;
}
ctx.putImageData(imageData, 0, 0);
}
function applyFloydSteinbergDithering(ctx, width, height, threshold, diffusion = 1.0, isColor = false) {
const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 4;
const oldR = data[idx];
const oldG = data[idx + 1];
const oldB = data[idx + 2];
const gray = 0.299 * oldR + 0.587 * oldG + 0.114 * oldB;
const newPixel = gray < threshold ? 0 : 255;
const err = (gray - newPixel) * diffusion;
data[idx] = data[idx + 1] = data[idx + 2] = newPixel;
if (x + 1 < width) {
addError(data, idx + 4, err, 7/16);
}
if (y + 1 < height) {
if (x - 1 >= 0) {
addError(data, idx + width * 4 - 4, err, 3/16);
}
addError(data, idx + width * 4, err, 5/16);
if (x + 1 < width) {
addError(data, idx + width * 4 + 4, err, 1/16);
}
}
}
}
ctx.putImageData(imageData, 0, 0);
}
function applyAtkinsonDithering(ctx, width, height, threshold, diffusion = 1.0, isColor = false) {
const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 4;
const oldR = data[idx];
const oldG = data[idx + 1];
const oldB = data[idx + 2];
const gray = 0.299 * oldR + 0.587 * oldG + 0.114 * oldB;
const newPixel = gray < threshold ? 0 : 255;
const err = (gray - newPixel) * diffusion;
data[idx] = data[idx + 1] = data[idx + 2] = newPixel;
if (x + 1 < width) {
addError(data, idx + 4, err, 1/8);
}
if (x + 2 < width) {
addError(data, idx + 8, err, 1/8);
}
if (y + 1 < height) {
if (x - 1 >= 0) {
addError(data, idx + width * 4 - 4, err, 1/8);
}
addError(data, idx + width * 4, err, 1/8);
if (x + 1 < width) {
addError(data, idx + width * 4 + 4, err, 1/8);
}
}
if (y + 2 < height) {
addError(data, idx + width * 8, err, 1/8);
}
}
}
ctx.putImageData(imageData, 0, 0);
}
function applyBayerDithering(ctx, width, height, threshold, diffusion = 1.0, isColor = false) {
const bayerThresholdMap = [
[15, 135, 45, 165],
[195, 75, 225, 105],
[60, 180, 30, 150],
[240, 120, 210, 90]
];
const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const gray = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
const x = (i / 4) % width;
const y = Math.floor((i / 4) / width);
const mapValue = bayerThresholdMap[x % 4][y % 4];
const adjustedThreshold = threshold * (1 + (mapValue - 128) / 128 * diffusion);
const finalThreshold = clamp(adjustedThreshold, 0, 255);
const newPixel = gray < finalThreshold ? 0 : 255;
data[i] = data[i + 1] = data[i + 2] = newPixel;
}
ctx.putImageData(imageData, 0, 0);
}
function applyStuckiDithering(ctx, width, height, threshold, diffusion = 1.0, isColor = false) {
const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 4;
const oldR = data[idx];
const oldG = data[idx + 1];
const oldB = data[idx + 2];
const gray = 0.299 * oldR + 0.587 * oldG + 0.114 * oldB;
const newPixel = gray < threshold ? 0 : 255;
const err = (gray - newPixel) * diffusion;
data[idx] = data[idx + 1] = data[idx + 2] = newPixel;
if (x + 1 < width) {
addError(data, idx + 4, err, 8/42);
}
if (x + 2 < width) {
addError(data, idx + 8, err, 4/42);
}
if (y + 1 < height) {
if (x - 2 >= 0) {
addError(data, idx + width * 4 - 8, err, 2/42);
}
if (x - 1 >= 0) {
addError(data, idx + width * 4 - 4, err, 4/42);
}
addError(data, idx + width * 4, err, 8/42);
if (x + 1 < width) {
addError(data, idx + width * 4 + 4, err, 4/42);
}
if (x + 2 < width) {
addError(data, idx + width * 4 + 8, err, 2/42);
}
}
if (y + 2 < height) {
if (x - 2 >= 0) {
addError(data, idx + width * 8 - 8, err, 1/42);
}
if (x - 1 >= 0) {
addError(data, idx + width * 8 - 4, err, 2/42);
}
addError(data, idx + width * 8, err, 4/42);
if (x + 1 < width) {
addError(data, idx + width * 8 + 4, err, 2/42);
}
if (x + 2 < width) {
addError(data, idx + width * 8 + 8, err, 1/42);
}
}
}
}
ctx.putImageData(imageData, 0, 0);
}
function applyJarvisJudiceNinkeDithering(ctx, width, height, threshold, diffusion = 1.0, isColor = false) {
const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 4;
const oldR = data[idx];
const oldG = data[idx + 1];
const oldB = data[idx + 2];
const gray = 0.299 * oldR + 0.587 * oldG + 0.114 * oldB;
const newPixel = gray < threshold ? 0 : 255;
const err = (gray - newPixel) * diffusion;
data[idx] = data[idx + 1] = data[idx + 2] = newPixel;
if (x + 1 < width) {
addError(data, idx + 4, err, 7/48);
}
if (x + 2 < width) {
addError(data, idx + 8, err, 5/48);
}
if (y + 1 < height) {
if (x - 2 >= 0) {
addError(data, idx + width * 4 - 8, err, 3/48);
}
if (x - 1 >= 0) {
addError(data, idx + width * 4 - 4, err, 5/48);
}
addError(data, idx + width * 4, err, 7/48);
if (x + 1 < width) {
addError(data, idx + width * 4 + 4, err, 5/48);
}
if (x + 2 < width) {
addError(data, idx + width * 4 + 8, err, 3/48);
}
}
if (y + 2 < height) {
if (x - 2 >= 0) {
addError(data, idx + width * 8 - 8, err, 1/48);
}
if (x - 1 >= 0) {
addError(data, idx + width * 8 - 4, err, 3/48);
}
addError(data, idx + width * 8, err, 5/48);
if (x + 1 < width) {
addError(data, idx + width * 8 + 4, err, 3/48);
}
if (x + 2 < width) {
addError(data, idx + width * 8 + 8, err, 1/48);
}
}
}
}
ctx.putImageData(imageData, 0, 0);
}
function addError(data, idx, err, factor) {
data[idx] = clamp(data[idx] + err * factor, 0, 255);
data[idx + 1] = clamp(data[idx + 1] + err * factor, 0, 255);
data[idx + 2] = clamp(data[idx + 2] + err * factor, 0, 255);
}
function applyBWRNoneDithering(ctx, width, height, threshold, diffusion = 1.0) {
const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
if (r > g * 1.5 && r > b * 1.5 && r > threshold) {
data[i] = 255;
data[i + 1] = 0;
data[i + 2] = 0;
} else {
const gray = 0.299 * r + 0.587 * g + 0.114 * b;
const value = gray < threshold ? 0 : 255;
data[i] = data[i + 1] = data[i + 2] = value;
}
}
ctx.putImageData(imageData, 0, 0);
}
function applyBWRFloydSteinbergDithering(ctx, width, height, threshold, diffusion = 1.0) {
const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 4;
const oldR = data[idx];
const oldG = data[idx + 1];
const oldB = data[idx + 2];
if (oldR > oldG * 1.5 && oldR > oldB * 1.5 && oldR > threshold) {
data[idx] = 255;
data[idx + 1] = 0;
data[idx + 2] = 0;
continue;
}
const gray = 0.299 * oldR + 0.587 * oldG + 0.114 * oldB;
const newPixel = gray < threshold ? 0 : 255;
const errR = (oldR - newPixel) * diffusion;
const errG = (oldG - newPixel) * diffusion;
const errB = (oldB - newPixel) * diffusion;
data[idx] = data[idx + 1] = data[idx + 2] = newPixel;
if (x + 1 < width) {
addColorError(data, idx + 4, errR, errG, errB, 7/16);
}
if (y + 1 < height) {
if (x - 1 >= 0) {
addColorError(data, idx + width * 4 - 4, errR, errG, errB, 3/16);
}
addColorError(data, idx + width * 4, errR, errG, errB, 5/16);
if (x + 1 < width) {
addColorError(data, idx + width * 4 + 4, errR, errG, errB, 1/16);
}
}
}
}
ctx.putImageData(imageData, 0, 0);
}
function applyBWRAtkinsonDithering(ctx, width, height, threshold, diffusion = 1.0) {
const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 4;
const oldR = data[idx];
const oldG = data[idx + 1];
const oldB = data[idx + 2];
if (oldR > oldG * 1.5 && oldR > oldB * 1.5 && oldR > threshold) {
data[idx] = 255;
data[idx + 1] = 0;
data[idx + 2] = 0;
continue;
}
const gray = 0.299 * oldR + 0.587 * oldG + 0.114 * oldB;
const newPixel = gray < threshold ? 0 : 255;
const errR = (oldR - newPixel) * diffusion;
const errG = (oldG - newPixel) * diffusion;
const errB = (oldB - newPixel) * diffusion;
data[idx] = data[idx + 1] = data[idx + 2] = newPixel;
if (x + 1 < width) {
addColorError(data, idx + 4, errR, errG, errB, 1/8);
}
if (x + 2 < width) {
addColorError(data, idx + 8, errR, errG, errB, 1/8);
}
if (y + 1 < height) {
if (x - 1 >= 0) {
addColorError(data, idx + width * 4 - 4, errR, errG, errB, 1/8);
}
addColorError(data, idx + width * 4, errR, errG, errB, 1/8);
if (x + 1 < width) {
addColorError(data, idx + width * 4 + 4, errR, errG, errB, 1/8);
}
}
if (y + 2 < height) {
addColorError(data, idx + width * 8, errR, errG, errB, 1/8);
}
}
}
ctx.putImageData(imageData, 0, 0);
}
function applyBWRBayerDithering(ctx, width, height, threshold, diffusion = 1.0) {
const bayerThresholdMap = [
[15, 135, 45, 165],
[195, 75, 225, 105],
[60, 180, 30, 150],
[240, 120, 210, 90]
];
const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const x = (i / 4) % width;
const y = Math.floor((i / 4) / width);
const mapValue = bayerThresholdMap[x % 4][y % 4];
const adjustedThreshold = threshold * (1 + (mapValue - 128) / 128 * diffusion);
const finalThreshold = clamp(adjustedThreshold, 0, 255);
if (r > g * 1.5 && r > b * 1.5 && r > finalThreshold) {
data[i] = 255;
data[i + 1] = 0;
data[i + 2] = 0;
} else {
const gray = 0.299 * r + 0.587 * g + 0.114 * b;
const value = gray < finalThreshold ? 0 : 255;
data[i] = data[i + 1] = data[i + 2] = value;
}
}
ctx.putImageData(imageData, 0, 0);
}
function applyBWRStuckiDithering(ctx, width, height, threshold, diffusion = 1.0) {
const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 4;
const oldR = data[idx];
const oldG = data[idx + 1];
const oldB = data[idx + 2];
if (oldR > oldG * 1.5 && oldR > oldB * 1.5 && oldR > threshold) {
data[idx] = 255;
data[idx + 1] = 0;
data[idx + 2] = 0;
continue;
}
const gray = 0.299 * oldR + 0.587 * oldG + 0.114 * oldB;
const newPixel = gray < threshold ? 0 : 255;
const errR = (oldR - newPixel) * diffusion;
const errG = (oldG - newPixel) * diffusion;
const errB = (oldB - newPixel) * diffusion;
data[idx] = data[idx + 1] = data[idx + 2] = newPixel;
if (x + 1 < width) {
addColorError(data, idx + 4, errR, errG, errB, 8/42);
}
if (x + 2 < width) {
addColorError(data, idx + 8, errR, errG, errB, 4/42);
}
if (y + 1 < height) {
if (x - 2 >= 0) {
addColorError(data, idx + width * 4 - 8, errR, errG, errB, 2/42);
}
if (x - 1 >= 0) {
addColorError(data, idx + width * 4 - 4, errR, errG, errB, 4/42);
}
addColorError(data, idx + width * 4, errR, errG, errB, 8/42);
if (x + 1 < width) {
addColorError(data, idx + width * 4 + 4, errR, errG, errB, 4/42);
}
if (x + 2 < width) {
addColorError(data, idx + width * 4 + 8, errR, errG, errB, 2/42);
}
}
if (y + 2 < height) {
if (x - 2 >= 0) {
addColorError(data, idx + width * 8 - 8, errR, errG, errB, 1/42);
}
if (x - 1 >= 0) {
addColorError(data, idx + width * 8 - 4, errR, errG, errB, 2/42);
}
addColorError(data, idx + width * 8, errR, errG, errB, 4/42);
if (x + 1 < width) {
addColorError(data, idx + width * 8 + 4, errR, errG, errB, 2/42);
}
if (x + 2 < width) {
addColorError(data, idx + width * 8 + 8, errR, errG, errB, 1/42);
}
}
}
}
ctx.putImageData(imageData, 0, 0);
}
function applyBWRJarvisJudiceNinkeDithering(ctx, width, height, threshold, diffusion = 1.0) {
const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * 4;
const oldR = data[idx];
const oldG = data[idx + 1];
const oldB = data[idx + 2];
if (oldR > oldG * 1.5 && oldR > oldB * 1.5 && oldR > threshold) {
data[idx] = 255;
data[idx + 1] = 0;
data[idx + 2] = 0;
continue;
}
const gray = 0.299 * oldR + 0.587 * oldG + 0.114 * oldB;
const newPixel = gray < threshold ? 0 : 255;
const errR = (oldR - newPixel) * diffusion;
const errG = (oldG - newPixel) * diffusion;
const errB = (oldB - newPixel) * diffusion;
data[idx] = data[idx + 1] = data[idx + 2] = newPixel;
if (x + 1 < width) {
addColorError(data, idx + 4, errR, errG, errB, 7/48);
}
if (x + 2 < width) {
addColorError(data, idx + 8, errR, errG, errB, 5/48);
}
if (y + 1 < height) {
if (x - 2 >= 0) {
addColorError(data, idx + width * 4 - 8, errR, errG, errB, 3/48);
}
if (x - 1 >= 0) {
addColorError(data, idx + width * 4 - 4, errR, errG, errB, 5/48);
}
addColorError(data, idx + width * 4, errR, errG, errB, 7/48);
if (x + 1 < width) {
addColorError(data, idx + width * 4 + 4, errR, errG, errB, 5/48);
}
if (x + 2 < width) {
addColorError(data, idx + width * 4 + 8, errR, errG, errB, 3/48);
}
}
if (y + 2 < height) {
if (x - 2 >= 0) {
addColorError(data, idx + width * 8 - 8, errR, errG, errB, 1/48);
}
if (x - 1 >= 0) {
addColorError(data, idx + width * 8 - 4, errR, errG, errB, 3/48);
}
addColorError(data, idx + width * 8, errR, errG, errB, 5/48);
if (x + 1 < width) {
addColorError(data, idx + width * 8 + 4, errR, errG, errB, 3/48);
}
if (x + 2 < width) {
addColorError(data, idx + width * 8 + 8, errR, errG, errB, 1/48);
}
}
}
}
ctx.putImageData(imageData, 0, 0);
}
function addColorError(data, idx, errR, errG, errB, factor) {
data[idx] = clamp(data[idx] + errR * factor, 0, 255);
data[idx + 1] = clamp(data[idx + 1] + errG * factor, 0, 255);
data[idx + 2] = clamp(data[idx + 2] + errB * factor, 0, 255);
}
const bwrPalette = [
[0, 0, 0, 255],
[255, 255, 255, 255],
[255, 0, 0, 255]
]
const bwPalette = [
[0, 0, 0, 255],
[255, 255, 255, 255],
]
function dithering(ctx, width, height, threshold, type) {
const diffusion = parseInt(document.getElementById('diffusion').value) / 100;
if (type.startsWith('bwr_')) {
const bwrType = type.replace('bwr_', '');
switch(bwrType) {
case 'none':
applyBWRNoneDithering(ctx, width, height, threshold, diffusion);
break;
case 'floydsteinberg':
applyBWRFloydSteinbergDithering(ctx, width, height, threshold, diffusion);
break;
case 'atkinson':
applyBWRAtkinsonDithering(ctx, width, height, threshold, diffusion);
break;
case 'bayer':
applyBWRBayerDithering(ctx, width, height, threshold, diffusion);
break;
case 'stucki':
applyBWRStuckiDithering(ctx, width, height, threshold, diffusion);
break;
case 'jarvis':
applyBWRJarvisJudiceNinkeDithering(ctx, width, height, threshold, diffusion);
break;
default:
applyBWRNoneDithering(ctx, width, height, threshold, diffusion);
}
} else {
switch(type) {
case 'none':
applyNoneDithering(ctx, width, height, threshold, false);
break;
case 'floydsteinberg':
applyFloydSteinbergDithering(ctx, width, height, threshold, diffusion, false);
break;
case 'atkinson':
applyAtkinsonDithering(ctx, width, height, threshold, diffusion, false);
break;
case 'bayer':
applyBayerDithering(ctx, width, height, threshold, diffusion, false);
break;
case 'stucki':
applyStuckiDithering(ctx, width, height, threshold, diffusion, false);
break;
case 'jarvis':
applyJarvisJudiceNinkeDithering(ctx, width, height, threshold, diffusion, false);
break;
default:
applyNoneDithering(ctx, width, height, threshold, false);
}
}
}
function canvas2bytes(canvas, type = 'bw') {
const ctx = canvas.getContext("2d");
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const arr = [];
let buffer = [];
for (let y = 0; y < canvas.height; y++) {
for (let x = 0; x < canvas.width; x++) {
const index = (canvas.width * 4 * y) + x * 4;
if (type !== 'bwr') {
buffer.push(imageData.data[index] > 0 && imageData.data[index + 1] > 0 && imageData.data[index + 2] > 0 ? 1 : 0);
} else {
buffer.push(imageData.data[index] > 0 && imageData.data[index + 1] === 0 && imageData.data[index + 2] === 0 ? 1 : 0);
}
if (buffer.length === 8) {
arr.push(parseInt(buffer.join(''), 2));
buffer = [];
}
}
}
return arr;
}
function canvas2bytes_bw(canvas, type='bw') {
const ctx = canvas.getContext("2d");
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const arr = [];
let buffer = [];
for (let x =canvas.width-1; x >= 0; x--) {
for (let y = 0; y < canvas.height; y++) {
const index = (canvas.width * 4 * y) + x * 4;
if (type !== 'bwr') {
buffer.push(imageData.data[index] > 0 && imageData.data[index+1] > 0 && imageData.data[index+2] > 0 ? 1 : 0);
} else {
buffer.push(imageData.data[index] > 0 && imageData.data[index+1] === 0 && imageData.data[index+2] === 0 ? 1 : 0);
}
if (buffer.length === 8) {
arr.push(parseInt(buffer.join(''), 2));
buffer = [];
}
}
}
return arr;
}
function canvas2bytes_to(canvas, type = 'bw') {
const ctx = canvas.getContext("2d");
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const arr = [];
let buffer = [];
for (let y = 0; y < canvas.width; y++) {
for (let x = 0; x < canvas.height; x++) {
const index = (canvas.width * 4 * x) + y * 4;
if (type !== 'bwr') {
buffer.push(imageData.data[index] > 0 && imageData.data[index + 1] > 0 && imageData.data[index + 2] > 0 ? 1 : 0);
} else {
buffer.push(imageData.data[index] > 0 && imageData.data[index + 1] === 0 && imageData.data[index + 2] === 0 ? 1 : 0);
}
if (buffer.length === 8) {
arr.push(parseInt(buffer.join(''), 2));
buffer = [];
}
}
}
return arr;
}
function getColorDistance(rgba1, rgba2) {
const [r1, b1, g1] = rgba1;
const [r2, b2, g2] = rgba2;
const rm = (r1 + r2) / 2;
const r = r1 - r2;
const g = g1 - g2;
const b = b1 - b2;
return Math.sqrt((2 + rm / 256) * r * r + 4 * g * g + (2 + (255 - rm) / 256) * b * b);
}
function getNearColor(pixel, palette) {
let minDistance = 255 * 255 * 3 + 1;
let paletteIndex = 0;
for (let i = 0; i < palette.length; i++) {
const targetColor = palette[i];
const distance = getColorDistance(pixel, targetColor);
if (distance < minDistance) {
minDistance = distance;
paletteIndex = i;
}
}
return palette[paletteIndex];
}
function getNearColorV2(color, palette) {
let minDistanceSquared = 255 * 255 + 255 * 255 + 255 * 255 + 1;
let bestIndex = 0;
for (let i = 0; i < palette.length; i++) {
let rdiff = (color[0] & 0xff) - (palette[i][0] & 0xff);
let gdiff = (color[1] & 0xff) - (palette[i][1] & 0xff);
let bdiff = (color[2] & 0xff) - (palette[i][2] & 0xff);
let distanceSquared = rdiff * rdiff + gdiff * gdiff + bdiff * bdiff;
if (distanceSquared < minDistanceSquared) {
minDistanceSquared = distanceSquared;
bestIndex = i;
}
}
return palette[bestIndex];
}
function updatePixel(imageData, index, color) {
imageData[index] = color[0];
imageData[index + 1] = color[1];
imageData[index + 2] = color[2];
imageData[index + 3] = color[3];
}
function getColorErr(color1, color2, rate) {
const res = [];
for (let i = 0; i < 3; i++) {
res.push(Math.floor((color1[i] - color2[i]) / rate));
}
return res;
}
function updatePixelErr(imageData, index, err, rate) {
imageData[index] += err[0] * rate;
imageData[index + 1] += err[1] * rate;
imageData[index + 2] += err[2] * rate;
}
function ditheringCanvasByPalette(canvas, palette, type) {
palette = palette || bwrPalette;
const ctx = canvas.getContext('2d');
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const w = imageData.width;
for (let currentPixel = 0; currentPixel <= imageData.data.length; currentPixel += 4) {
const newColor = getNearColorV2(imageData.data.slice(currentPixel, currentPixel + 4), palette);
if (type === "bwr_floydsteinberg") {
const err = getColorErr(imageData.data.slice(currentPixel, currentPixel + 4), newColor, 16);
updatePixel(imageData.data, currentPixel, newColor);
updatePixelErr(imageData.data, currentPixel + 4, err, 7);
updatePixelErr(imageData.data, currentPixel + 4 * w - 4, err, 3);
updatePixelErr(imageData.data, currentPixel + 4 * w, err, 5);
updatePixelErr(imageData.data, currentPixel + 4 * w + 4, err, 1);
} else {
const err = getColorErr(imageData.data.slice(currentPixel, currentPixel + 4), newColor, 8);
updatePixel(imageData.data, currentPixel, newColor);
updatePixelErr(imageData.data, currentPixel + 4, err, 1);
updatePixelErr(imageData.data, currentPixel + 8, err, 1);
updatePixelErr(imageData.data, currentPixel + 4 * w - 4, err, 1);
updatePixelErr(imageData.data, currentPixel + 4 * w, err, 1);
updatePixelErr(imageData.data, currentPixel + 4 * w + 4, err, 1);
updatePixelErr(imageData.data, currentPixel + 8 * w, err, 1);
}
}
ctx.putImageData(imageData, 0, 0);
}
+323
View File
@@ -0,0 +1,323 @@
document.addEventListener('DOMContentLoaded', function () {
const textInput = document.getElementById('textInput');
const fontFamily = document.getElementById('fontFamily');
const invertColors = document.getElementById('invertColors');
const showGrid = document.getElementById('showGrid');
const generateBtn = document.getElementById('generateBtn');
const upload_rili_text = document.getElementById('upload_rili_text');
const previewCanvas = document.getElementById('previewCanvas');
const codeOutput = document.getElementById('codeOutput');
const copyBtn = document.getElementById('copyBtn');
const gridInfoText = document.getElementById('gridInfoText');
const fontSizeSlider = document.getElementById('fontSizeSlider');
const fontSizeValue = document.getElementById('fontSizeValue');
const gridSizeSelect = document.getElementById('gridSizeSelect');
const currentGridSize = document.getElementById('currentGridSize');
const sizeHint = document.getElementById('sizeHint');
const ctx = previewCanvas.getContext('2d');
document.getElementById('fullAutoActivateBtn').addEventListener('click', fullAutoActivate);
const previewScale = 8;
let fontSizeRatio = 0.85;
const grid_Size=16;
updateGridSize();
drawPreview();
generateBtn.addEventListener('click', function () {
drawPreview();
generateCode();
});
upload_rili_text.addEventListener('click', function () {
drawPreview();
upload_rili();
});
copyBtn.addEventListener('click', function () {
sendcode();
});
fontSizeSlider.addEventListener('input', function () {
const value = this.value;
fontSizeValue.textContent = `${value}%`;
fontSizeRatio = value / 100;
drawPreview();
});
gridSizeSelect.addEventListener('change', function () {
updateGridSize();
drawPreview();
});
function getCurrentGridSize() {
return parseInt(gridSizeSelect.value);
}
function updateGridSize() {
const gridSize = getCurrentGridSize();
currentGridSize.textContent = ``;
}
function formatDate(date) {
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
return `${year}年${month}月${day}日`;
}
function drawPreview() {
const text = textInput.value || 'ABCDEFGH';
const charCount = text.length;
const font = fontFamily.value;
const invert = invertColors.checked;
const grid = showGrid.checked;
const gridSize = getCurrentGridSize();
const gridSize_all=gridSize==16?128:96;
// 计算实际显示宽度
function getCharWidth(char) {
if (/[\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]/.test(char)) {
return gridSize; // 汉字/中文标点
}
return gridSize/2; // 英文/数字/英文标点
}
function getTotalWidth(str) {
let width = 0;
for (let char of str) {
width += getCharWidth(char);
}
return width;
}
const totalDisplayWidth = getTotalWidth(text);
const actualCharWidth = gridSize * getCharWidth(text[0] || 'A');
if (totalDisplayWidth > gridSize_all) {
alert(`超出总字符(当前:${totalDisplayWidth},时钟倒计时最大支持16个英文或8个汉字,日历倒计时最大支持8个英文或4个汉字)`);
return;
}
previewCanvas.width = totalDisplayWidth * previewScale;
previewCanvas.height = gridSize * previewScale;
ctx.clearRect(0, 0, previewCanvas.width, previewCanvas.height);
ctx.fillStyle = invert ? '#000000' : '#ffffff';
ctx.fillRect(0, 0, previewCanvas.width, previewCanvas.height);
let currentX = 0;
ctx.font = `bold ${gridSize * fontSizeRatio * previewScale}px ${font}`;
ctx.textBaseline = 'middle';
ctx.fillStyle = invert ? '#ffffff' : '#000000';
for (let i = 0; i < charCount; i++) {
const currentChar = text[i];
const charDisplayWidth = getCharWidth(currentChar);
const charPixelWidth = charDisplayWidth * previewScale;
const charX = currentX + charPixelWidth / 2;
ctx.textAlign = 'center';
ctx.fillText(currentChar, charX, previewCanvas.height / 2);
currentX += charPixelWidth;
}
if (grid) {
ctx.strokeStyle = invert ? '#333333' : '#dddddd';
for (let x = 0; x <= charCount * gridSize; x++) {
const lineX = x * previewScale;
ctx.beginPath();
ctx.moveTo(lineX, 0);
ctx.lineTo(lineX, previewCanvas.height);
ctx.lineWidth = x % gridSize === 0 ? 2 : 1;
ctx.stroke();
}
for (let y = 0; y <= gridSize; y++) {
const lineY = y * previewScale;
ctx.beginPath();
ctx.moveTo(0, lineY);
ctx.lineTo(previewCanvas.width, lineY);
ctx.lineWidth = 1;
ctx.stroke();
}
}
}
async function sendcode() {
triggerRxTxCmd('FA');
await delay(1000);
triggerRxTxCmd('E2');
}
function calculateTextWidth(text) {
let totalWidth = 0;
for (let i = 0; i < text.length; i++) {
const char = text[i];
if (/[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff\u2e80-\u2eff\u3000-\u303f\u31c0-\u31ef\u2f00-\u2fdf\u2e80-\u2eff\u1100-\u11ff\u3130-\u318f\uac00-\ud7af]/.test(char)) {
totalWidth += 2; // 中文字符宽度
} else {
totalWidth += 1; // 英文字符及数字、标点等宽度
}
}
return Math.ceil(totalWidth / 2);
}
async function generateCode() {
const text = textInput.value || 'ABCDEFGH';
const gridSize = getCurrentGridSize();
const charCount =calculateTextWidth(text) ;
const font = fontFamily.value;
const invert = invertColors.checked;
const totalGridWidth = charCount * gridSize;
const totalGridHeight = gridSize;
const tempCanvas = document.createElement('canvas');
tempCanvas.width = totalGridWidth;
tempCanvas.height = totalGridHeight;
const tempCtx = tempCanvas.getContext('2d');
tempCtx.fillStyle = invert ? '#000000' : '#ffffff';
tempCtx.fillRect(0, 0, totalGridWidth, totalGridHeight);
tempCtx.font = `bold ${totalGridHeight * fontSizeRatio}px ${font}`;
tempCtx.textAlign = 'center';
tempCtx.textBaseline = 'middle';
tempCtx.fillStyle = invert ? '#ffffff' : '#000000';
tempCtx.fillText(text, totalGridWidth / 2, totalGridHeight / 2);
const imageData = tempCtx.getImageData(0, 0, totalGridWidth, totalGridHeight);
const data = imageData.data;
const allBytes = [];
for (let y = 0; y < totalGridHeight; y++) {
const bytesPerLine = totalGridWidth / 8;
const lineBytes = new Array(bytesPerLine).fill(0);
for (let x = 0; x < totalGridWidth; x++) {
const pixelIndex = (y * totalGridWidth + x) * 4;
const brightness = (data[pixelIndex] + data[pixelIndex + 1] + data[pixelIndex + 2]) / 3;
const bitValue = invert ? (brightness > 128 ? 0 : 1) : (brightness > 128 ? 1 : 0);
const byteIndex = Math.floor(x / 8);
const bitPosition = 7 - (x % 8);
lineBytes[byteIndex] |= (bitValue << bitPosition);
}
allBytes.push(...lineBytes);
}
if (gridSize == 16) {
if (charCount > 8) {addLog(`设置字符超过了16个(纯中文字符最多4个,纯英文字符最多8个)`); return;}
if (charCount > 4) {
let code = '0' + 4;
for (let i = 0; i < allBytes.length - 1 - (charCount - 4) * 32; i++) {
code += `${allBytes[i].toString(16).padStart(2, '0')}`;
}
triggerRxTxCmd(code);
await delay(1000);
let code1 = '0' + charCount;
for (let i = 128; i < allBytes.length - 1; i++) {
code1 += `${allBytes[i].toString(16).padStart(2, '0')}`;
}
triggerRxTxCmd(code1);
} else {
let code = '0' + (charCount);
for (let i = 0; i < allBytes.length - 1; i++) {
code += `${allBytes[i].toString(16).padStart(2, '0')}`;
}
triggerRxTxCmd(code);
}
} else if (gridSize == 24) {
if (charCount > 4) {addLog(`设置字符超过了8个(纯中文字符最多4个,纯英文字符最多8个)`); return;}
for (let j = 0; j < charCount; j++) {
let code = '990' + j;
for (let i = 0; i < 72; i++) {
code += `${allBytes[i + j * 72].toString(16).padStart(2, '0')}`;
}
triggerRxTxCmd(code);
await delay(1000);
}
}
const selectedDate = new Date(datePicker.value);
let year = selectedDate.getFullYear();
let month = selectedDate.getMonth() + 1;
let day = selectedDate.getDate();
await delay(1000);
if (month < 10) month = '0' + month;
if (day < 10) day = '0' + day;
if (gridSize == 24) {
let code2 = 'ffff' + year + month + day;
if (year > 2000) {
triggerRxTxCmd(code2);
};
} else {
let code2 = 'ff00' + year + month + day;
if (year > 2000) {
triggerRxTxCmd(code2);
};
}
await delay(1000);
triggerRxTxCmd('E2');
}
async function upload_rili() {
const text = textInput.value || 'ABCDEFGH';
const gridSize = getCurrentGridSize();
const charCount = calculateTextWidth(text);
const font = fontFamily.value;
const invert = invertColors.checked;
const totalGridWidth = charCount * gridSize;
const totalGridHeight = gridSize;
const tempCanvas = document.createElement('canvas');
tempCanvas.width = totalGridWidth;
tempCanvas.height = totalGridHeight;
const tempCtx = tempCanvas.getContext('2d');
tempCtx.fillStyle = invert ? '#000000' : '#ffffff';
tempCtx.fillRect(0, 0, totalGridWidth, totalGridHeight);
tempCtx.font = `bold ${totalGridHeight * fontSizeRatio}px ${font}`;
tempCtx.textAlign = 'center';
tempCtx.textBaseline = 'middle';
tempCtx.fillStyle = invert ? '#ffffff' : '#000000';
tempCtx.fillText(text, totalGridWidth / 2, totalGridHeight / 2);
const imageData = tempCtx.getImageData(0, 0, totalGridWidth, totalGridHeight);
const data = imageData.data;
const allBytes = [];
for (let y = 0; y < totalGridHeight; y++) {
const bytesPerLine = totalGridWidth / 8;
const lineBytes = new Array(bytesPerLine).fill(0);
for (let x = 0; x < totalGridWidth; x++) {
const pixelIndex = (y * totalGridWidth + x) * 4;
const brightness = (data[pixelIndex] + data[pixelIndex + 1] + data[pixelIndex + 2]) / 3;
const bitValue = invert ? (brightness > 128 ? 0 : 1) : (brightness > 128 ? 1 : 0);
const byteIndex = Math.floor(x / 8);
const bitPosition = 7 - (x % 8);
lineBytes[byteIndex] |= (bitValue << bitPosition);
}
allBytes.push(...lineBytes);
}
if (gridSize == 24) {
if (charCount > 4) {addLog(`设置字符超过了8个(纯中文字符最多4个,纯英文字符最多8个)`); return;}
for (let j = 0; j < charCount; j++) {
let code = '990' + j;
for (let i = 0; i < 72; i++) {
code += `${allBytes[i + j * 72].toString(16).padStart(2, '0')}`;
}
triggerRxTxCmd(code);
await delay(1000);
}
}
triggerRxTxCmd('FA');
await delay(1000);
triggerRxTxCmd('e10103');
await delay(1000);
triggerRxTxCmd('E2');
}
textInput.addEventListener('input', drawPreview);
fontFamily.addEventListener('change', drawPreview);
invertColors.addEventListener('change', drawPreview);
showGrid.addEventListener('change', drawPreview);
window.drawPreview = drawPreview;
});