云朵字生成器-html

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>一字满A4·文本轮廓控制</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<style>
*{box-sizing:border-box;margin:0;padding:0;font-family:Microsoft YaHei,sans-serif}
body{background:#f2f4f6;padding:20px}
.container{display:flex;gap:24px;max-width:1600px;margin:0 auto}
.panel{width:360px;background:#fff;border-radius:12px;padding:24px;box-shadow:0 4px 12px rgba(0,0,0,.08)}
.panel h3{margin-bottom:16px;color:#222}
textarea{width:100%;height:140px;padding:10px;border:1px solid #ddd;border-radius:6px;resize:vertical;margin-bottom:16px}
.setting-item{margin-bottom:16px}
.setting-item label{display:block;margin-bottom:6px;font-size:14px;color:#555}
select,input[type=color]{width:100%;height:36px;border:1px solid #ddd;border-radius:6px}
.input-group { display: flex; gap: 10px; align-items: center; }
.input-group input[type=range] { flex: 1; cursor: pointer; }
.input-group input[type=number] { width: 80px; height: 36px; border: 1px solid #ddd; border-radius: 6px; padding: 0 8px; font-family: inherit; }
.file-upload { margin-top: 8px; font-size: 13px; color: #666; }
.file-upload input[type=file] { margin-top: 4px; border: none; padding: 0; height: auto; width: 100%; }
.btn{width:100%;height:42px;border:none;border-radius:6px;color:#fff;font-size:15px;cursor:pointer;margin-bottom:10px;transition: all 0.2s;}
.btn:hover{opacity: 0.9;}
.btn:disabled{opacity: 0.5; cursor: not-allowed;}
.btn.gen{background:#2ecc71}
.btn.dl{background:#3498db}
.btn.dl-all{background:#e67e22; margin-top: 10px;}
.preview-area{flex:1;display:flex;flex-direction:column;gap:20px}
.preview-card{background:#fff;border-radius:12px;padding:20px;box-shadow:0 4px 12px rgba(0,0,0,.08);text-align:center}
.preview-card canvas{max-width:100%;border:1px solid #eee;background:#fff;margin-bottom:16px}
</style>
</head>
<body>
<div class="container">
  <div class="panel">
    <h3>一字满A4 · 文本轮廓控制</h3>
    <label>输入文字(每行1个):</label>
    <textarea id="text">守
护
</textarea>

    <div class="setting-item">
      <label>字体(系统字体或自定义)</label>
      <select id="fontFamily"></select>
      <div class="file-upload">
        <label>找不到想要的?上传本地字体文件:</label>
        <input type="file" id="customFontFile" accept=".ttf,.otf,.woff,.woff2">
      </div>
    </div>

    <div class="setting-item">
      <label>字号 (px)</label>
      <div class="input-group">
        <input type="range" id="fontSizeRange" min="100" max="3000" value="1990">
        <input type="number" id="fontSizeNum" min="100" max="3000" value="1990">
      </div>
    </div>

    <div class="setting-item">
      <label>蓝色层厚度 (px)</label>
      <div class="input-group">
        <input type="range" id="outerWidthRange" min="0" max="2000" value="540">
        <input type="number" id="outerWidthNum" min="0" max="2000" value="540">
      </div>
    </div>

    <div class="setting-item">
      <label>白色轮廓宽度 (px)</label>
      <div class="input-group">
        <input type="range" id="middleWidthRange" min="0" max="1500" value="290">
        <input type="number" id="middleWidthNum" min="0" max="1500" value="290">
      </div>
    </div>

    <div class="setting-item">
      <label>底层颜色</label>
      <input type="color" id="outerColor" value="#ff001e">
    </div>

    <button class="btn gen" id="gen">生成图片</button>
    <button class="btn dl-all" id="dlAll" disabled>批量打包下载 (ZIP)</button>
  </div>

  <div class="preview-area" id="preview"></div>
</div>

<script>
// 全局变量,用于存储当前生成的画布数据,方便打包
let currentGeneratedData = [];

// --- 1. UI 双向绑定(滑动条与数字输入框) ---
const binds = [
  { range: 'fontSizeRange', num: 'fontSizeNum' },
  { range: 'outerWidthRange', num: 'outerWidthNum' },
  { range: 'middleWidthRange', num: 'middleWidthNum' }
];

binds.forEach(({ range, num }) => {
  const r = document.getElementById(range);
  const n = document.getElementById(num);
  r.addEventListener('input', () => n.value = r.value);
  n.addEventListener('change', () => {
    let val = Number(n.value);
    const min = Number(r.min), max = Number(r.max);
    if (val < min) val = min;
    if (val > max) val = max;
    n.value = val;
    r.value = val;
  });
});

// --- 2. 字体加载逻辑 ---
async function loadFonts(){
  const sel=document.getElementById('fontFamily');
  sel.innerHTML = '';
  const commonFonts = [
    "SimHei","Microsoft YaHei","KaiTi","SimSun","FangSong",
    "Arial","Times New Roman","微软雅黑","黑体","宋体","楷体","仿宋"
  ];
  let fontSet = new Set(commonFonts);
  try {
    if ('queryLocalFonts' in window) {
      const localFonts = await window.queryLocalFonts();
      localFonts.forEach(x => fontSet.add(x.family));
    }
  } catch(e) { console.warn("无法获取全部系统字体权限"); }

  const fontList = Array.from(fontSet).sort();
  fontList.forEach(f=>{
    const o=document.createElement('option');
    o.value=f; o.textContent=f; sel.appendChild(o);
  });
}

document.getElementById('customFontFile').addEventListener('change', async (e) => {
  const file = e.target.files[0];
  if (!file) return;
  try {
    const customFontName = 'CustomFont_' + Date.now();
    const arrayBuffer = await file.arrayBuffer();
    const customFont = new FontFace(customFontName, arrayBuffer);
    await customFont.load();
    document.fonts.add(customFont);

    const sel = document.getElementById('fontFamily');
    const opt = document.createElement('option');
    opt.value = customFontName;
    opt.textContent = `[自定义] ${file.name.split('.')[0]}`;
    sel.insertBefore(opt, sel.firstChild);
    sel.value = customFontName;
    alert(`自定义字体 "${file.name}" 加载成功!`);
  } catch (err) {
    alert('字体加载失败,请检查文件。');
  }
});

const A4_W = 2480, A4_H = 3508;

// --- 3. 核心渲染引擎 ---
function getSolidOutlineCanvas(text, opt, outlineWidth, colorHex) {
  const c = document.createElement('canvas');
  c.width = A4_W; c.height = A4_H;
  const g = c.getContext('2d');
  
  const fontSize = Math.max(100, Number(opt.fontSize));
  g.font = `bold ${fontSize}px "${opt.fontFamily}"`;
  const x = A4_W / 2, y = A4_H / 2;

  g.textAlign = 'center'; g.textBaseline = 'middle';
  g.lineJoin = 'round'; g.lineCap = 'round';
  
  g.fillStyle = '#000000';
  g.fillText(text, x, y);
  
  if (outlineWidth > 0) {
    g.strokeStyle = '#000000';
    g.lineWidth = outlineWidth;
    for(let offset = -0.5; offset <= 0.5; offset += 0.5) {
      g.strokeText(text, x + offset, y + offset);
    }
  }

  const imgData = g.getImageData(0, 0, A4_W, A4_H);
  const data = imgData.data;
  const width = A4_W, height = A4_H;
  
  const visited = new Uint8Array(width * height);
  const q = new Uint32Array(width * height);
  let head = 0, tail = 0;

  for (let x = 0; x < width; x++) {
    if (data[x * 4 + 3] === 0) { q[tail++] = x; visited[x] = 1; }
    const bottomIdx = (height - 1) * width + x;
    if (data[bottomIdx * 4 + 3] === 0) { q[tail++] = bottomIdx; visited[bottomIdx] = 1; }
  }
  for (let y = 0; y < height; y++) {
    const leftIdx = y * width;
    if (data[leftIdx * 4 + 3] === 0 && !visited[leftIdx]) { q[tail++] = leftIdx; visited[leftIdx] = 1; }
    const rightIdx = y * width + width - 1;
    if (data[rightIdx * 4 + 3] === 0 && !visited[rightIdx]) { q[tail++] = rightIdx; visited[rightIdx] = 1; }
  }

  while (head < tail) {
    const idx = q[head++];
    const cx = idx % width, cy = (idx / width) | 0;
    if (cy > 0) { const nIdx = idx - width; if (!visited[nIdx] && data[nIdx * 4 + 3] === 0) { visited[nIdx] = 1; q[tail++] = nIdx; } }
    if (cy < height - 1) { const nIdx = idx + width; if (!visited[nIdx] && data[nIdx * 4 + 3] === 0) { visited[nIdx] = 1; q[tail++] = nIdx; } }
    if (cx > 0) { const nIdx = idx - 1; if (!visited[nIdx] && data[nIdx * 4 + 3] === 0) { visited[nIdx] = 1; q[tail++] = nIdx; } }
    if (cx < width - 1) { const nIdx = idx + 1; if (!visited[nIdx] && data[nIdx * 4 + 3] === 0) { visited[nIdx] = 1; q[tail++] = nIdx; } }
  }

  let r_col = 255, g_col = 255, b_col = 255;
  if (colorHex && colorHex.startsWith('#') && colorHex.length === 7) {
    r_col = parseInt(colorHex.slice(1, 3), 16);
    g_col = parseInt(colorHex.slice(3, 5), 16);
    b_col = parseInt(colorHex.slice(5, 7), 16);
  }

  const radius = 3; 
  for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
      const i = y * width + x;
      if (visited[i] === 0) {
        let alpha = data[i * 4 + 3];
        if (alpha === 0) {
          alpha = 255;
        } else if (alpha > 0 && alpha < 255) {
          let isOuterEdge = false;
          const minY = Math.max(0, y - radius), maxY = Math.min(height - 1, y + radius);
          const minX = Math.max(0, x - radius), maxX = Math.min(width - 1, x + radius);
          search: for (let cy = minY; cy <= maxY; cy++) {
            for (let cx = minX; cx <= maxX; cx++) {
              if (visited[cy * width + cx] === 1) { isOuterEdge = true; break search; }
            }
          }
          if (!isOuterEdge) alpha = 255;
        }
        data[i * 4] = r_col; data[i * 4 + 1] = g_col; data[i * 4 + 2] = b_col; data[i * 4 + 3] = alpha;
      } else {
        data[i * 4 + 3] = 0;
      }
    }
  }

  g.putImageData(imgData, 0, 0);
  return c;
}

function drawSolidText(text, opt) {
  const c = document.createElement('canvas');
  c.width = A4_W; c.height = A4_H;
  const g = c.getContext('2d');
  
  g.fillStyle = '#ffffff';
  g.fillRect(0, 0, A4_W, A4_H);

  const fontSize = Math.max(100, Number(opt.fontSize));
  const blueThickness = Math.max(0, Number(opt.outerWidth));
  const whiteOutlineWidth = Math.max(0, Number(opt.middleWidth));
  const blueColor = opt.outerColor;

  if (blueThickness > 0) {
    const blueLayer = getSolidOutlineCanvas(text, opt, blueThickness, blueColor);
    g.drawImage(blueLayer, 0, 0);
  }

  if (whiteOutlineWidth > 0) {
    const whiteLayer = getSolidOutlineCanvas(text, opt, whiteOutlineWidth, '#ffffff');
    g.drawImage(whiteLayer, 0, 0);
  }

  g.font = `bold ${fontSize}px "${opt.fontFamily}"`;
  g.textAlign = 'center'; g.textBaseline = 'middle';
  g.fillStyle = '#000000';
  g.fillText(text, A4_W / 2, A4_H / 2);

  return c;
}

function render(){
  const box = document.getElementById('preview');
  box.innerHTML = '';
  const lines = document.getElementById('text').value.split('\n').map(s=>s.trim()).filter(Boolean);
  
  const opt = {
    fontFamily: document.getElementById('fontFamily').value,
    fontSize: document.getElementById('fontSizeNum').value,
    outerWidth: document.getElementById('outerWidthNum').value,
    middleWidth: document.getElementById('middleWidthNum').value,
    outerColor: document.getElementById('outerColor').value
  };

  if (lines.length === 0) {
    alert('请输入至少一个文字!');
    return;
  }

  const genBtn = document.getElementById('gen');
  const dlAllBtn = document.getElementById('dlAll');
  
  genBtn.textContent = '生成中...';
  genBtn.disabled = true;
  dlAllBtn.disabled = true;
  
  // 清空之前的记录
  currentGeneratedData = [];

  setTimeout(() => {
    lines.forEach(txt => {
      const card = document.createElement('div');
      card.className = 'preview-card';
      const cv = drawSolidText(txt, opt);
      
      // 保存到全局数组,供打包下载使用
      currentGeneratedData.push({ text: txt, canvas: cv });

      const d = document.createElement('button');
      d.className = 'btn dl';
      d.textContent = `下载 "${txt}"`;
      d.onclick = () => {
        const a = document.createElement('a');
        a.download = `${txt}_A4.png`;
        a.href = cv.toDataURL('image/png', 1.0);
        a.click();
      };
      
      card.appendChild(cv);
      card.appendChild(d);
      box.appendChild(card);
    });
    
    genBtn.textContent = '生成图片';
    genBtn.disabled = false;
    
    // 如果有生成内容,启用批量下载按钮
    if(currentGeneratedData.length > 0) {
      dlAllBtn.disabled = false;
    }
  }, 50); 
}

// --- 4. 批量打包 ZIP 下载功能 ---
document.getElementById('dlAll').addEventListener('click', async () => {
  if(currentGeneratedData.length === 0) return;
  
  const btn = document.getElementById('dlAll');
  btn.textContent = '正在打包,请稍候...';
  btn.disabled = true;

  try {
    const zip = new JSZip();
    
    // 将所有 canvas 转换为 Blob 并塞入压缩包
    const promises = currentGeneratedData.map((item, index) => {
      return new Promise((resolve) => {
        item.canvas.toBlob((blob) => {
          // 给文件名加上序号,防止用户输入了重复的文字导致文件被覆盖
          const fileName = `${String(index + 1).padStart(3, '0')}_${item.text}_A4.png`;
          zip.file(fileName, blob);
          resolve();
        }, 'image/png', 1.0);
      });
    });

    await Promise.all(promises);

    // 生成 ZIP 文件并触发下载
    const content = await zip.generateAsync({ type: 'blob' });
    const url = URL.createObjectURL(content);
    
    const a = document.createElement('a');
    a.href = url;
    a.download = `批量文字A4打印图_${new Date().getTime()}.zip`;
    a.click();
    
    URL.revokeObjectURL(url); // 清理内存
  } catch (error) {
    console.error("打包失败:", error);
    alert("打包下载过程中出现错误,请检查控制台。");
  } finally {
    btn.textContent = '批量打包下载 (ZIP)';
    btn.disabled = false;
  }
});

document.getElementById('gen').onclick = render;
window.addEventListener('load',async ()=>{
  await loadFonts();
  render();
});
</script>
</body>
</html>
相关推荐
FlyWIHTSKY2 小时前
Vue 3 单文件组件加载顺序详解
前端·javascript·vue.js
霪霖笙箫2 小时前
真授之以渔:我是怎么从"想给文章配几张图",一步步做出一个可发布 skill 的
前端·人工智能·开源
yzin2 小时前
【源码】【react】useCallback、useMemo、memo 原理
前端·react.js
CHU7290352 小时前
扭蛋机盲盒小程序前端功能设计及核心玩法介绍
前端·小程序
毛骗导演2 小时前
OpenClaw Gateway RPC 运行时:一个 WebSocket 协议引擎的深度解剖
前端·架构
码路飞2 小时前
不会 Rust 也能玩 WebAssembly:3 个 npm install 就能用的 WASM 神器
前端·javascript·webassembly
sudo_jin2 小时前
从“输入网址”到“帧级控制”:我对事件循环与主线程管理的终极认知
前端·javascript
flyfox2 小时前
Kiro AI IDE 深度使用指南:从入门到高效开发
前端·人工智能·ai编程
lovingsoft2 小时前
Cursor Skills 实战教程:解锁AI编码效率,附多场景案例
前端·人工智能