Code: Select all
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Domoticz icon generator</title>
<style>
*{box-sizing:border-box}body{margin:0;background:#18212e;color:#edf2fa;font:16px system-ui,sans-serif;padding:24px}main{max-width:700px;margin:24px auto;background:#253346;padding:28px;border-radius:18px}h1{font-size:25px;margin-top:0}label{display:block;margin:18px 0 8px}input{max-width:100%;font:inherit}input[type=text]{width:100%;padding:10px;border-radius:7px;border:1px solid #8090a0}button{margin-top:22px;width:100%;padding:14px;border:0;border-radius:8px;background:#64d7af;color:#10251c;font:bold 17px system-ui;cursor:pointer}button:disabled{opacity:.5;cursor:wait}.previews{display:flex;gap:22px;margin-top:20px;flex-wrap:wrap}canvas{width:96px;height:96px;background:repeating-conic-gradient(#eee 0% 25%,#bbb 0% 50%) 0/16px 16px;border-radius:6px}.hint{color:#c0ccda;font-size:14px;line-height:1.6}#status{white-space:pre-wrap;line-height:1.5}a{color:#8ee6c5}#download{display:none;margin-top:16px;padding:10px}code{color:#b5e5ff}
</style>
</head>
<body><main>
<h1>Domoticz icon generator</h1>
<p class="hint">Runs locally without libraries, CDNs, or uploading files to the internet. Original PNG transparency is preserved; white backgrounds are not removed automatically.</p>
<label for="base">Icon set name (Latin letters, digits, _ and -)</label>
<input id="base" type="text" value="floodlight" maxlength="64" pattern="[A-Za-z][A-Za-z0-9_-]{0,63}">
<label for="title">Display name in Domoticz</label><input id="title" type="text" value="Floodlight" maxlength="100">
<label for="on">On icon (PNG):</label><input id="on" type="file" accept="image/png,.png">
<label for="off">Off icon (PNG):</label><input id="off" type="file" accept="image/png,.png">
<div class="previews"><div><p>On · 48×48</p><canvas id="previewOn" width="48" height="48"></canvas></div><div><p>Off · 48×48</p><canvas id="previewOff" width="48" height="48"></canvas></div></div>
<p class="hint">Images fit within a square without stretching or cropping. Archive contents: <code>name48_On.png</code>, <code>name48_Off.png</code>, <code>name.png</code> (16×16, generated from On), and <code>icons.txt</code>.</p>
<button id="generate" type="button">Create ZIP</button>
<p id="status" role="status" aria-live="polite"></p><a id="download">Download ZIP</a>
<noscript>JavaScript must be enabled to use this tool.</noscript>
</main>
<script>
'use strict';
const $=id=>document.getElementById(id);
const encoder=new TextEncoder();
let downloadURL=null;
function report(message){$('status').textContent=message;}
async function loadPNG(file){
if(!file)throw new Error('Select both files: On and Off.');
if(file.size>20*1024*1024)throw new Error('The file exceeds 20 MB. Use a smaller image.');
const signature=new Uint8Array(await file.slice(0,8).arrayBuffer());
const png=[137,80,78,71,13,10,26,10];
if(signature.length!==8||!png.every((b,i)=>signature[i]===b))throw new Error('File "'+file.name+'" is not a PNG.');
const url=URL.createObjectURL(file);
try{
return await new Promise((resolve,reject)=>{
const img=new Image();
img.onload=()=>resolve(img);
img.onerror=()=>reject(new Error('Unable to load image "'+file.name+'".'));
img.src=url;
});
}finally{URL.revokeObjectURL(url);}
}
function draw(img,canvas){
const ctx=canvas.getContext('2d');
if(!ctx)throw new Error('Your browser does not support Canvas 2D.');
ctx.clearRect(0,0,canvas.width,canvas.height);
ctx.imageSmoothingEnabled=true;ctx.imageSmoothingQuality='high';
const scale=Math.min(canvas.width/img.naturalWidth,canvas.height/img.naturalHeight);
const w=img.naturalWidth*scale,h=img.naturalHeight*scale;
ctx.drawImage(img,(canvas.width-w)/2,(canvas.height-h)/2,w,h);
}
async function pngBytes(img,size){
const c=document.createElement('canvas');c.width=c.height=size;draw(img,c);
const blob=await new Promise((resolve,reject)=>c.toBlob(b=>b?resolve(b):reject(new Error('Failed to create PNG.')),'image/png'));
return new Uint8Array(await blob.arrayBuffer());
}
const crcTable=Uint32Array.from({length:256},(_,n)=>{
for(let k=0;k<8;k++)n=n&1?0xedb88320^(n>>>1):n>>>1;
return n>>>0;
});
function crc32(data){let crc=0xffffffff;for(const b of data)crc=crcTable[(crc^b)&255]^(crc>>>8);return (crc^0xffffffff)>>>0;}
// ZIP STORE: PNG is already compressed; no external library is needed.
function makeZip(entries){
const chunks=[],central=[];let offset=0,centralSize=0;
const now=new Date(),year=Math.max(1980,Math.min(2107,now.getFullYear()));
const time=(now.getHours()<<11)|(now.getMinutes()<<5)|(now.getSeconds()>>1);
const date=((year-1980)<<9)|((now.getMonth()+1)<<5)|now.getDate();
for(const [name,data] of entries){
const filename=encoder.encode(name),crc=crc32(data);
const local=new Uint8Array(30+filename.length),v=new DataView(local.buffer);
v.setUint32(0,0x04034b50,true);v.setUint16(4,20,true);v.setUint16(6,0x800,true);
v.setUint16(10,time,true);v.setUint16(12,date,true);v.setUint32(14,crc,true);
v.setUint32(18,data.length,true);v.setUint32(22,data.length,true);v.setUint16(26,filename.length,true);local.set(filename,30);
chunks.push(local,data);
const c=new Uint8Array(46+filename.length),w=new DataView(c.buffer);
w.setUint32(0,0x02014b50,true);w.setUint16(4,20,true);w.setUint16(6,20,true);w.setUint16(8,0x800,true);
w.setUint16(12,time,true);w.setUint16(14,date,true);w.setUint32(16,crc,true);
w.setUint32(20,data.length,true);w.setUint32(24,data.length,true);w.setUint16(28,filename.length,true);w.setUint32(42,offset,true);c.set(filename,46);
central.push(c);centralSize+=c.length;offset+=local.length+data.length;
}
const end=new Uint8Array(22),e=new DataView(end.buffer);
e.setUint32(0,0x06054b50,true);e.setUint16(8,entries.length,true);e.setUint16(10,entries.length,true);e.setUint32(12,centralSize,true);e.setUint32(16,offset,true);
return new Blob([...chunks,...central,end],{type:'application/zip'});
}
function clearDownload(){
if(downloadURL){URL.revokeObjectURL(downloadURL);downloadURL=null;}
$('download').style.display='none';$('download').removeAttribute('href');
}
let revision=0;
for(const [input,preview] of [['on','previewOn'],['off','previewOff']]){
$(input).addEventListener('change',async()=>{
const current=++revision;clearDownload();report('');
const file=$(input).files[0];
const canvas=$(preview);canvas.getContext('2d').clearRect(0,0,48,48);
if(!file)return;
try{const img=await loadPNG(file);if($(input).files[0]===file)draw(img,canvas);}
catch(error){if(current===revision)report(error.message);}
});
}
for(const id of ['base','title'])$(id).addEventListener('input',()=>{revision++;clearDownload();});
$('generate').addEventListener('click',async()=>{
const button=$('generate');button.disabled=true;clearDownload();report('Processing...');
const current=revision;
try{
const base=$('base').value.trim(),title=$('title').value.trim()||base;
if(!/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(base))throw new Error('The name must contain 1 to 64 characters, start with a Latin letter, and contain only Latin letters, digits, _ or -.');
if(/[;\r\n]/.test(title))throw new Error('The display name must not contain semicolons or line breaks.');
const [on,off]=await Promise.all([loadPNG($('on').files[0]),loadPNG($('off').files[0])]);
const [on48,off48,small]=await Promise.all([pngBytes(on,48),pngBytes(off,48),pngBytes(on,16)]);
if(current!==revision)throw new Error('The settings changed during processing. Click "Create ZIP" again.');
const blob=makeZip([[base+'48_On.png',on48],[base+'48_Off.png',off48],[base+'.png',small],['icons.txt',encoder.encode(base+';'+title+';'+title+'\n')]]);
downloadURL=URL.createObjectURL(blob);
const link=$('download');link.href=downloadURL;link.download=base+'_icons.zip';link.style.display='inline-block';
draw(on,$('previewOn'));draw(off,$('previewOff'));
report('The archive is ready. Click "Download ZIP".');
}catch(error){report('Error: '+(error.message||String(error)));console.error(error);}
finally{button.disabled=false;}
});
window.addEventListener('pagehide',()=>{if(downloadURL)URL.revokeObjectURL(downloadURL);});
</script></body></html>