feat: 工具链就绪(bleak/pillow/pytest/capstone/androguard) + Web协议提取报告
This commit is contained in:
1 parent
30571dc0a5
commit
cb7eee312f
11 files changed
+8006
No files matched your search
+4409
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
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();
|
||||
})();
|
||||
@@ -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(未定义)
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
androguard==4.1.4
|
||||
bleak==3.0.2
|
||||
capstone==5.0.9
|
||||
pillow==12.3.0
|
||||
pytest==9.1.1
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env python3
|
||||
"""环境自检:验证分析/CLI 依赖可用,打印版本表。退出码 0=全部通过。"""
|
||||
import importlib
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
DEPS = ["bleak", "PIL", "pytest", "capstone", "androguard"]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ok = True
|
||||
for mod in DEPS:
|
||||
try:
|
||||
m = importlib.import_module(mod)
|
||||
ver = getattr(m, "__version__", "?")
|
||||
print(f"OK {mod:<12} {ver}")
|
||||
except ImportError as e:
|
||||
ok = False
|
||||
print(f"FAIL {mod:<12} {e}")
|
||||
for tool in ["git"]:
|
||||
path = shutil.which(tool)
|
||||
print(f"{'OK ' if path else 'FAIL'} {tool:<12} {path or 'missing'}")
|
||||
ok = ok and bool(path)
|
||||
print("ENV:", "PASS" if ok else "FAIL")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user