程式一:簡報 去背 及 高解析辦識分離文字檔
簡易步驟說明
1.複製以下程式碼
2.貼上後,使用CANVAS 直接執行
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI 圖片文字移除 - 專業工作站版</title>
<script src="https://cdn.tailwindcss.com"></script>
<!-- PDF.js 核心 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<!-- PptxGenJS (PPTX 生成) -->
<script src="https://cdn.jsdelivr.net/npm/pptxgenjs@3.12.0/dist/pptxgen.bundle.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;700;900&display=swap');
body { font-family: 'Noto Sans TC', sans-serif; background-color: #f1f5f9; }
.drop-zone-active { border-color: #2563eb !important; background-color: #eff6ff !important; transform: scale(1.01); }
.loader { border: 3px solid #f3f3f3; border-top: 3px solid #2563eb; border-radius: 50%; width: 24px; height: 24px; animation: spin 1s linear infinite; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.pdf-view-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--page-width, 200px), 1fr));
gap: 1.5rem;
}
.pdf-view-scroll {
display: flex;
gap: 1.5rem;
overflow-x: auto;
padding-bottom: 1rem;
white-space: nowrap;
}
.custom-scrollbar::-webkit-scrollbar { height: 8px; width: 6px; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; }
.page-card { transition: all 0.2s ease; border: 2px solid transparent; }
.page-card:hover { transform: translateY(-3px); border-color: #3b82f6; }
.slot-card { transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
.waiting-badge { background: #64748b; color: white; padding: 2px 8px; border-radius: 6px; font-size: 10px; font-weight: 900; }
</style>
</head>
<body class="min-h-screen text-slate-900 pb-20">
<div class="max-w-7xl mx-auto px-4 py-10">
<!-- 標題與狀態 -->
<header class="mb-10 flex flex-col md:flex-row justify-between items-start md:items-end gap-4">
<div>
<h1 class="text-4xl font-black text-slate-900 tracking-tighter italic">AI 圖片文字移除 <span class="text-blue-600">PRO</span> BY 觀點LAB</h1>
<p class="text-slate-500 font-bold mt-2 text-lg">高精度佈局還原 & 智慧內容填補系統</p>
</div>
<div class="text-right bg-white p-3 rounded-2xl shadow-sm border border-slate-100">
<span class="text-[10px] font-black text-slate-400 block uppercase tracking-widest">系統運行狀態</span>
<span class="text-xs font-black text-emerald-600 flex items-center justify-end gap-1">
<span class="w-1.5 h-1.5 bg-emerald-500 rounded-full animate-pulse"></span> 自動注入金鑰模式
</span>
</div>
</header>
<!-- PDF 解析區 -->
<section class="mb-12">
<div class="flex flex-col md:flex-row md:items-center justify-between mb-6 gap-4">
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-slate-900 text-white rounded-lg flex items-center justify-center font-black">1</div>
<h2 class="text-xl font-black text-slate-800">PDF 高精細解析</h2>
</div>
<div id="pdf-controls" class="hidden flex flex-wrap items-center gap-4 bg-white px-4 py-2 rounded-2xl shadow-sm border border-slate-200">
<!-- 比例選擇 -->
<div class="flex items-center gap-2 bg-slate-50 px-2 py-1 rounded-xl border border-slate-100">
<span class="text-[10px] font-black text-slate-400 uppercase">佈局比例</span>
<select id="pdf-ratio-select" class="text-xs font-black border-none bg-transparent outline-none cursor-pointer text-blue-600">
<option value="16:9">16:9 (橫)</option>
<option value="9:16">9:16 (直)</option>
<option value="4:3">4:3 (標)</option>
</select>
</div>
<div class="flex bg-slate-100 p-1 rounded-xl">
<button id="view-grid-btn" class="px-3 py-1 text-xs font-black rounded-lg bg-white shadow-sm text-blue-600">網格檢視</button>
<button id="view-scroll-btn" class="px-3 py-1 text-xs font-black rounded-lg text-slate-500 hover:text-slate-700">橫向捲動</button>
</div>
<div class="flex items-center gap-2 border-l pl-4 border-slate-200">
<span class="text-[10px] font-black text-slate-400 uppercase">預覽縮放</span>
<input type="range" id="pdf-zoom-range" min="100" max="350" value="200" class="h-1.5 w-24 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-blue-600">
</div>
<button id="add-all-pages-btn" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-xl text-xs font-black shadow-lg transition">一鍵導入排程</button>
</div>
</div>
<div id="pdf-drop-zone" class="bg-white rounded-3xl border-2 border-dashed border-slate-200 p-12 text-center cursor-pointer hover:border-blue-400 transition-all group shadow-sm">
<input type="file" id="pdf-file-input" class="hidden" accept="application/pdf">
<div id="pdf-empty-state">
<div class="w-16 h-16 bg-blue-50 text-blue-500 rounded-2xl flex items-center justify-center mx-auto mb-4 group-hover:scale-110 transition-transform">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg>
</div>
<p class="text-xl font-black text-slate-800">點擊或拖放 PDF 進行解析</p>
<p class="text-slate-400 text-sm font-semibold mt-1 italic">系統將自動提取文字內容與排版,並支援一鍵導出至 PPTX</p>
</div>
<div id="pdf-preview-container" class="hidden text-left">
<div class="flex items-center justify-between bg-slate-50 p-4 rounded-2xl mb-6 border border-slate-100">
<div class="flex items-center gap-4">
<span id="page-count-tag" class="bg-slate-900 text-white px-3 py-1 rounded-full text-[10px] font-black tracking-widest uppercase">0 PAGES</span>
<span class="text-xs font-black text-slate-400">READY FOR ANALYSIS</span>
</div>
<div class="flex gap-2">
<button id="pdf-to-pptx-btn" class="bg-emerald-600 hover:bg-emerald-700 text-white px-6 py-2 rounded-xl font-black text-xs shadow-lg transition flex items-center gap-2">
<span id="pptx-btn-text">視覺還原 PPTX</span>
<div id="pptx-loader" class="loader !w-3 !h-3 !border-white !border-t-transparent hidden"></div>
</button>
<button id="clear-pdf-btn" class="text-xs font-black text-red-500 px-4 hover:underline">清除</button>
</div>
</div>
<div id="pdf-pages-list" class="pdf-view-grid custom-scrollbar"></div>
</div>
</div>
</section>
<!-- 圖片去字區 -->
<section>
<div class="flex flex-col md:flex-row md:items-center justify-between mb-6 gap-4">
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-blue-600 text-white rounded-lg flex items-center justify-center font-black">2</div>
<h2 class="text-xl font-black text-slate-800">圖片去文字工作台</h2>
</div>
<div class="flex items-center gap-4 flex-wrap justify-end">
<div id="result-export-group" class="hidden flex items-center gap-3 bg-white px-4 py-2 rounded-2xl shadow-sm border border-slate-100">
<!-- 比例選擇 -->
<div class="flex items-center gap-2 border-r pr-3 mr-1 border-slate-100">
<span class="text-[10px] font-black text-slate-400 uppercase tracking-tighter">比例</span>
<select id="work-ratio-select" class="text-xs font-black border-none bg-transparent outline-none cursor-pointer text-blue-600">
<option value="16:9">16:9</option>
<option value="9:16">9:16</option>
<option value="4:3">4:3</option>
</select>
</div>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="pptx-custom-name-toggle" class="w-4 h-4 text-blue-600 rounded">
<span class="text-[10px] font-black text-slate-400 uppercase">自定義檔名</span>
</label>
<input type="text" id="pptx-filename-input" placeholder="簡報名稱..." class="hidden text-xs border border-slate-200 rounded-lg px-2 py-1 outline-none w-32 font-bold">
<button id="export-results-pptx-btn" class="bg-emerald-600 hover:bg-emerald-700 text-white px-4 py-2 rounded-xl text-xs font-black shadow-lg">導出結果 PPTX</button>
</div>
<div class="flex items-center gap-4 bg-white px-3 py-2 rounded-xl shadow-sm border border-slate-100">
<div class="flex items-center gap-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-tighter">槽位</label>
<select id="slot-count-select" class="text-xs font-black border-none bg-transparent outline-none cursor-pointer text-blue-600">
<option value="4">04</option>
<option value="8">08</option>
<option value="12">12</option>
<option value="20">20</option>
</select>
</div>
<button id="clear-all-slots-btn" class="hidden text-xs font-black text-red-500 px-2 hover:underline transition-all">全部清除</button>
</div>
<button id="process-all-btn" class="bg-slate-900 hover:bg-black text-white px-6 py-2 rounded-xl text-xs font-black shadow-lg hidden transition">開始批次去字</button>
</div>
</div>
<div id="master-drop-zone" class="bg-white border-2 border-dashed border-slate-200 rounded-3xl p-10 text-center cursor-pointer hover:border-blue-400 transition-all mb-8 shadow-sm group">
<input type="file" id="master-file-input" class="hidden" accept="image/*" multiple>
<div class="flex flex-col items-center gap-2">
<p class="text-slate-500 font-bold group-hover:text-blue-500 transition-colors">在此上傳、從上方拖曳,或點擊頁面「+」導入</p>
<span class="text-[10px] font-black text-slate-300 uppercase tracking-widest italic">Stable Queuing Logic Enabled</span>
</div>
</div>
<div id="slots-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"></div>
</section>
</div>
<!-- 槽位模板 -->
<template id="slot-template">
<div class="slot-card bg-white rounded-3xl border border-slate-100 p-5 flex flex-col shadow-sm">
<div class="empty-state flex-grow flex flex-col items-center justify-center text-slate-200 italic font-black text-sm min-h-[300px]">
<div class="w-10 h-10 border-2 border-dashed border-slate-100 rounded-lg flex items-center justify-center mb-2">+</div>
Empty
</div>
<div class="active-state hidden flex-grow flex flex-col">
<div class="flex justify-between mb-4">
<span class="text-[10px] font-black text-blue-600 italic slot-label uppercase tracking-widest">SLOT 01</span>
<button class="remove-btn text-slate-300 hover:text-red-500 transition font-black">×</button>
</div>
<div class="space-y-4">
<div class="aspect-video bg-slate-50 rounded-xl overflow-hidden border border-slate-100 relative shadow-inner">
<img class="original-img w-full h-full object-contain" src="">
<div class="slot-loader absolute inset-0 bg-white/80 hidden flex-col items-center justify-center">
<div class="loader mb-2"></div>
<span class="text-[10px] font-black text-blue-600 animate-pulse uppercase">AI Analyzing</span>
</div>
<div class="slot-waiting absolute inset-0 bg-slate-900/10 hidden flex-col items-center justify-center backdrop-blur-[2px]">
<span class="waiting-badge">排隊中</span>
</div>
</div>
<div class="aspect-video bg-slate-50 rounded-xl overflow-hidden border border-slate-100 relative shadow-inner flex items-center justify-center">
<img class="result-img w-full h-full object-contain hidden" src="">
<div class="placeholder text-slate-200 italic font-black text-[10px] uppercase">No Result</div>
</div>
</div>
<div class="flex gap-2 mt-5">
<button class="process-btn bg-slate-900 hover:bg-black text-white text-[10px] py-2.5 rounded-lg flex-1 font-black transition">去文字</button>
<button class="download-btn bg-emerald-600 hover:bg-emerald-700 text-white text-[10px] px-3 rounded-lg font-black disabled:opacity-20 transition" disabled>下載</button>
</div>
</div>
</div>
</template>
<div id="toast" class="fixed bottom-8 left-1/2 -translate-x-1/2 bg-slate-900 text-white px-8 py-4 rounded-2xl shadow-2xl opacity-0 transition-all pointer-events-none z-50 text-sm font-black border border-slate-700"></div>
<script>
const apiKey = ""; // 自動注入金鑰模式
const MODEL_OCR = "gemini-2.5-flash-preview-09-2025";
const MODEL_IMAGE = "gemini-2.5-flash-image-preview";
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
const pdfPagesList = document.getElementById('pdf-pages-list');
const pdfPreviewContainer = document.getElementById('pdf-preview-container');
const pdfControls = document.getElementById('pdf-controls');
const pdfZoomRange = document.getElementById('pdf-zoom-range');
const viewGridBtn = document.getElementById('view-grid-btn');
const viewScrollBtn = document.getElementById('view-scroll-btn');
const slotsContainer = document.getElementById('slots-container');
const processAllBtn = document.getElementById('process-all-btn');
const clearAllSlotsBtn = document.getElementById('clear-all-slots-btn');
const resultExportGroup = document.getElementById('result-export-group');
const exportResultsPptxBtn = document.getElementById('export-results-pptx-btn');
const pptxCustomNameToggle = document.getElementById('pptx-custom-name-toggle');
const pptxFilenameInput = document.getElementById('pptx-filename-input');
const pdfRatioSelect = document.getElementById('pdf-ratio-select');
const workRatioSelect = document.getElementById('work-ratio-select');
const toast = document.getElementById('toast');
let currentPdfPageUrls = [];
let slots = [];
function showToast(msg, isError = false) {
toast.textContent = msg;
toast.style.backgroundColor = isError ? '#ef4444' : '#0f172a';
toast.classList.replace('opacity-0', 'opacity-100');
setTimeout(() => toast.classList.replace('opacity-100', 'opacity-0'), 3000);
}
const wait = (ms) => new Promise(res => setTimeout(res, ms));
// 增強型重試機制
async function fetchWithRetry(url, options, maxRetries = 6) {
const delays = [1000, 2000, 4000, 8000, 16000, 32000];
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch(url, options);
if (response.ok) return await response.json();
if (response.status === 429) {
const waitTime = delays[i];
showToast(`頻率限制,等待 ${waitTime/1000} 秒後重試...`, true);
await wait(waitTime);
continue;
}
if (response.status >= 500) { await wait(2000); continue; }
const errorJson = await response.json();
throw new Error(errorJson.error?.message || `HTTP ${response.status}`);
} catch (e) {
if (i === maxRetries - 1) throw e;
await wait(delays[i]);
}
}
}
// --- 核心拖曳邏輯 ---
function setupDragAndDrop(element, onDropCallback) {
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
element.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
}, false);
});
element.addEventListener('dragover', () => element.classList.add('drop-zone-active'));
element.addEventListener('dragleave', () => element.classList.remove('drop-zone-active'));
element.addEventListener('drop', (e) => {
element.classList.remove('drop-zone-active');
const files = e.dataTransfer.files;
if (files && files.length > 0) {
onDropCallback(files);
}
});
}
setupDragAndDrop(document.getElementById('pdf-drop-zone'), (files) => {
const file = files[0];
if (file.type === 'application/pdf') processPdf(file);
else showToast('請拖放 PDF 檔案', true);
});
setupDragAndDrop(document.getElementById('master-drop-zone'), async (files) => {
for (const file of Array.from(files)) {
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (ev) => fillSlot(ev.target.result);
reader.readAsDataURL(file);
}
}
});
// --- PDF 解析 ---
document.getElementById('pdf-file-input').onchange = (e) => { if(e.target.files[0]) processPdf(e.target.files[0]); };
document.getElementById('pdf-drop-zone').onclick = (e) => { if(!e.target.closest('#pdf-preview-container')) document.getElementById('pdf-file-input').click(); };
async function processPdf(file) {
showToast('正在解析 PDF 視覺佈局...');
currentPdfPageUrls = [];
pdfPreviewContainer.classList.remove('hidden');
pdfControls.classList.remove('hidden');
pdfPagesList.innerHTML = '';
document.getElementById('pdf-empty-state').classList.add('hidden');
try {
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
document.getElementById('page-count-tag').textContent = `${pdf.numPages} PAGES`;
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const viewport = page.getViewport({ scale: 2.5 });
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.height = viewport.height;
canvas.width = viewport.width;
await page.render({ canvasContext: ctx, viewport: viewport }).promise;
const dataUrl = canvas.toDataURL('image/png');
currentPdfPageUrls.push(dataUrl);
const div = document.createElement('div');
div.className = 'page-card bg-white p-2 rounded-2xl border border-slate-100 shadow-sm relative group cursor-grab custom-scrollbar';
div.style.width = `${pdfZoomRange.value}px`;
div.innerHTML = `
<div class="relative overflow-hidden rounded-xl">
<img src="${dataUrl}" class="w-full">
<button class="add-btn absolute top-2 right-2 bg-blue-600 text-white w-8 h-8 rounded-lg opacity-0 group-hover:opacity-100 transition shadow-xl font-black">+</button>
</div>
<div class="text-[10px] font-black text-center mt-2 text-slate-400 uppercase tracking-widest">Page ${i}</div>
`;
div.querySelector('.add-btn').onclick = (e) => { e.stopPropagation(); fillSlot(dataUrl); };
pdfPagesList.appendChild(div);
}
} catch (err) { showToast('PDF 解析失敗', true); }
}
// --- PPTX 生成比例輔助 ---
function setPptxLayout(pptx, ratio) {
if (ratio === '9:16') {
pptx.defineLayout({ name: 'MOBILE', width: 5.625, height: 10 });
pptx.layout = 'MOBILE';
} else if (ratio === '4:3') {
pptx.layout = 'LAYOUT_4x3';
} else {
pptx.layout = 'LAYOUT_WIDE'; // 16:9
}
}
// --- PPTX 視覺還原 ---
document.getElementById('pdf-to-pptx-btn').onclick = async () => {
const btn = document.getElementById('pdf-to-pptx-btn');
const txt = document.getElementById('pptx-btn-text');
const ldr = document.getElementById('pptx-loader');
btn.disabled = true; ldr.classList.remove('hidden');
try {
const pptx = new PptxGenJS();
setPptxLayout(pptx, pdfRatioSelect.value);
for (let i = 0; i < currentPdfPageUrls.length; i++) {
txt.textContent = `辨識中 (${i+1}/${currentPdfPageUrls.length})`;
const base64 = currentPdfPageUrls[i].split(',')[1];
const blocks = await callAiOcr(base64);
const slide = pptx.addSlide(); slide.background = { fill: "FFFFFF" };
if (blocks && Array.isArray(blocks) && blocks.length > 0) {
blocks.forEach(block => {
if (!block.text || !block.box_2d) return;
let ymin = block.box_2d[0], xmin = block.box_2d[1], ymax = block.box_2d[2], xmax = block.box_2d[3];
if (ymax <= 1 && xmax <= 1) {
ymin *= 1000; xmin *= 1000; ymax *= 1000; xmax *= 1000;
}
const xP = xmin / 10;
const yP = ymin / 10;
const wP = (xmax - xmin) / 10;
const hP = (ymax - ymin) / 10;
slide.addText(block.text, {
x: `${xP.toFixed(2)}%`,
y: `${yP.toFixed(2)}%`,
w: `${wP.toFixed(2)}%`,
h: `${hP.toFixed(2)}%`,
fontSize: block.font_size || 12,
color: block.color?.replace('#','') || '000000',
bold: block.is_bold || false,
align: block.align || 'left',
valign: 'middle',
margin: 0
});
});
showToast(`第 ${i+1} 頁辨識到 ${blocks.length} 個文字塊`);
} else {
showToast(`第 ${i+1} 頁未偵測到明顯文字`, true);
}
await wait(1800);
}
const name = pptxCustomNameToggle.checked && pptxFilenameInput.value.trim() ? pptxFilenameInput.value.trim() : `Restore_${Date.now()}`;
pptx.writeFile({ fileName: `${name}.pptx` });
showToast('PPTX 重建成功!');
} catch (err) {
console.error(err);
showToast(`辨識出錯: ${err.message}`, true);
}
finally { btn.disabled = false; ldr.classList.add('hidden'); txt.textContent = '視覺還原 PPTX'; }
};
async function callAiOcr(base64) {
const prompt = `請辨識圖片中「所有」文字區塊。JSON 格式包含:text, box_2d [ymin, xmin, ymax, xmax], font_size, is_bold, align, color。座標比例 0-1000。`;
const payload = {
contents: [{ parts: [{ text: prompt }, { inlineData: { mimeType: "image/png", data: base64 } }] }],
generationConfig: {
responseMimeType: "application/json",
responseSchema: {
type: "ARRAY",
items: {
type: "OBJECT",
properties: {
text: { type: "STRING" },
box_2d: { type: "ARRAY", items: { type: "NUMBER" }, minItems: 4, maxItems: 4 },
font_size: { type: "NUMBER" },
is_bold: { type: "BOOLEAN" },
align: { type: "STRING" },
color: { type: "STRING" }
},
required: ["text", "box_2d"]
}
}
}
};
const data = await fetchWithRetry(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL_OCR}:generateContent?key=${apiKey}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const textResult = data.candidates?.[0]?.content?.parts?.[0]?.text;
return textResult ? JSON.parse(textResult) : [];
}
// --- 槽位管理與去字 ---
function initSlots(count) {
const currentData = slots.map(s => ({ b64: s.originalBase64, res: s.resultImg.src }));
slotsContainer.innerHTML = ''; slots = [];
for (let i = 0; i < count; i++) {
const clone = document.getElementById('slot-template').content.cloneNode(true);
const card = clone.querySelector('.slot-card');
const data = {
index: i, el: card,
originalImg: card.querySelector('.original-img'),
resultImg: card.querySelector('.result-img'),
loader: card.querySelector('.slot-loader'),
waitingOverlay: card.querySelector('.slot-waiting'),
originalBase64: null
};
card.querySelector('.slot-label').textContent = `SLOT ${String(i+1).padStart(2, '0')}`;
card.querySelector('.process-btn').onclick = () => processSlot(i);
card.querySelector('.remove-btn').onclick = () => resetSlot(i);
slots.push(data); slotsContainer.appendChild(card);
if (currentData[i]?.b64) fillSlotWithData(i, currentData[i]);
}
updateGlobalControls();
}
function fillSlotWithData(i, item) {
const s = slots[i];
s.originalBase64 = item.b64;
s.originalImg.src = `data:image/png;base64,${item.b64}`;
s.el.querySelector('.empty-state').classList.add('hidden');
s.el.querySelector('.active-state').classList.remove('hidden');
if (item.res.startsWith('data')) {
s.resultImg.src = item.res; s.resultImg.classList.remove('hidden');
s.el.querySelector('.placeholder').classList.add('hidden');
s.el.querySelector('.download-btn').disabled = false;
}
}
function fillSlot(url) {
const slot = slots.find(s => !s.originalBase64);
if (!slot) return showToast('所有欄位皆已佔用', true);
slot.originalBase64 = url.split(',')[1];
slot.originalImg.src = url;
slot.el.querySelector('.empty-state').classList.add('hidden');
slot.el.querySelector('.active-state').classList.remove('hidden');
updateGlobalControls();
}
async function processSlot(i) {
const s = slots[i]; if (!s.originalBase64) return;
s.loader.classList.remove('hidden');
try {
const prompt = "Please remove all text from this image. Fill the gaps using the surrounding background texture to make it look clean and natural.";
const data = await fetchWithRetry(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL_IMAGE}:generateContent?key=${apiKey}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }, { inlineData: { mimeType: "image/png", data: s.originalBase64 } }] }],
generationConfig: { responseModalities: ['IMAGE'] }
})
});
const b64 = data.candidates[0].content.parts.find(p => p.inlineData).inlineData.data;
s.resultImg.src = `data:image/png;base64,${b64}`;
s.resultImg.classList.remove('hidden');
s.el.querySelector('.placeholder').classList.add('hidden');
s.el.querySelector('.download-btn').disabled = false;
s.el.querySelector('.download-btn').onclick = () => {
const a = document.createElement('a'); a.href = s.resultImg.src; a.download = `cleaned_${i+1}.png`; a.click();
};
} catch (e) { showToast(`處理失敗: ${e.message}`, true); }
finally { s.loader.classList.add('hidden'); updateGlobalControls(); }
}
processAllBtn.onclick = async () => {
const pending = slots.filter(s => s.originalBase64 && !s.resultImg.src.startsWith('data'));
if (pending.length === 0) return;
showToast(`佇列啟動:開始依序處理 ${pending.length} 張圖片...`);
pending.forEach(s => s.waitingOverlay.classList.remove('hidden'));
for (const s of pending) {
s.waitingOverlay.classList.add('hidden');
await processSlot(s.index);
await wait(2000);
}
showToast('批次處理完成');
};
clearAllSlotsBtn.onclick = () => {
slots.forEach((_, i) => resetSlot(i));
showToast('工作台已全部清除');
};
// --- 控制項事件 ---
pdfZoomRange.oninput = (e) => {
const val = e.target.value;
document.querySelectorAll('.page-card').forEach(div => div.style.width = `${val}px`);
};
viewGridBtn.onclick = () => {
pdfPagesList.className = 'pdf-view-grid custom-scrollbar';
viewGridBtn.className = 'px-3 py-1 text-xs font-black rounded-lg bg-white shadow-sm text-blue-600';
viewScrollBtn.className = 'px-3 py-1 text-xs font-black rounded-lg text-slate-500 hover:text-slate-700';
};
viewScrollBtn.onclick = () => {
pdfPagesList.className = 'pdf-view-scroll custom-scrollbar';
viewScrollBtn.className = 'px-3 py-1 text-xs font-black rounded-lg bg-white shadow-sm text-blue-600';
viewGridBtn.className = 'px-3 py-1 text-xs font-black rounded-lg text-slate-500 hover:text-slate-700';
};
document.getElementById('add-all-pages-btn').onclick = () => currentPdfPageUrls.forEach(url => fillSlot(url));
document.getElementById('slot-count-select').onchange = (e) => initSlots(parseInt(e.target.value));
pptxCustomNameToggle.onchange = (e) => pptxFilenameInput.classList.toggle('hidden', !e.target.checked);
exportResultsPptxBtn.onclick = () => {
const results = slots.filter(s => s.resultImg.src.startsWith('data:image'));
const pptx = new PptxGenJS();
setPptxLayout(pptx, workRatioSelect.value);
results.forEach(s => pptx.addSlide().addImage({ data: s.resultImg.src, x: 0, y: 0, w: '100%', h: '100%' }));
const name = pptxCustomNameToggle.checked && pptxFilenameInput.value.trim() ? pptxFilenameInput.value.trim() : `Results_${Date.now()}`;
pptx.writeFile({ fileName: `${name}.pptx` });
showToast('結果已導出');
};
function resetSlot(i) {
const s = slots[i]; s.originalBase64 = null; s.resultImg.src = '';
s.resultImg.classList.add('hidden'); s.el.querySelector('.empty-state').classList.remove('hidden');
s.el.querySelector('.active-state').classList.add('hidden');
s.el.querySelector('.placeholder').classList.remove('hidden');
updateGlobalControls();
}
function updateGlobalControls() {
const hasProcessable = slots.some(s => s.originalBase64 && !s.resultImg.src.startsWith('data'));
const hasResults = slots.some(s => s.resultImg.src.startsWith('data:image'));
const hasAnyContent = slots.some(s => s.originalBase64);
processAllBtn.classList.toggle('hidden', !hasProcessable);
resultExportGroup.classList.toggle('hidden', !hasResults);
clearAllSlotsBtn.classList.toggle('hidden', !hasAnyContent);
}
document.getElementById('clear-pdf-btn').onclick = () => {
pdfPagesList.innerHTML = ''; currentPdfPageUrls = [];
pdfPreviewContainer.classList.add('hidden'); pdfControls.classList.add('hidden');
document.getElementById('pdf-empty-state').classList.remove('hidden');
};
document.getElementById('master-drop-zone').onclick = (e) => {
document.getElementById('master-file-input').click();
};
document.getElementById('master-file-input').onchange = (e) => {
const files = Array.from(e.target.files);
files.forEach(file => {
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (ev) => fillSlot(ev.target.result);
reader.readAsDataURL(file);
}
});
};
initSlots(4);
</script>
</body>
</html>
PPT合併
簡易步驟說明
1.複製以下程式碼
2.貼上後,使用CANVAS 直接執行
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PPTX 合併工具 - 文字與背景結合</title>
<!-- React & ReactDOM -->
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<!-- Babel for JSX -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Libraries for PPTX Processing -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js"></script>
<style>
body {
background-color: #f3f4f6;
font-family: 'Noto Sans TC', sans-serif;
}
.drop-zone {
transition: all 0.2s ease;
}
.drop-zone.active {
border-color: #3b82f6;
background-color: #eff6ff;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState, useRef } = React;
// Inline Icons components to avoid external dependency errors
const IconUpload = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" x2="12" y1="3" y2="15"/></svg>
);
const IconFileText = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-gray-400"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" x2="8" y1="13" y2="13"/><line x1="16" x2="8" y1="17" y2="17"/><line x1="10" x2="8" y1="9" y2="9"/></svg>
);
const IconImage = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-gray-400"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>
);
const IconArrowRight = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-gray-300"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
);
const IconCheck = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
);
const IconDownload = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>
);
const IconLoader = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="animate-spin"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
);
const IconAlert = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></svg>
);
const IconMagic = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-blue-600"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/><path d="M5 3v4"/><path d="M9 3v4"/><path d="M3 5h4"/><path d="M3 9h4"/></svg>
);
const App = () => {
const [textFile, setTextFile] = useState(null);
const [bgFile, setBgFile] = useState(null);
const [status, setStatus] = useState('idle'); // idle, processing, success, error
const [logs, setLogs] = useState([]);
const [downloadUrl, setDownloadUrl] = useState(null);
const addLog = (msg) => {
setLogs(prev => [...prev, msg]);
};
const handleFileDrop = (e, type) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file && file.name.endsWith('.pptx')) {
if (type === 'text') setTextFile(file);
else setBgFile(file);
} else {
alert('請上傳 .pptx 格式的檔案');
}
};
// Helper to handle XML namespace variations
const getSpTree = (doc) => {
if (!doc) return null;
// Try standard tag name
let tree = doc.getElementsByTagName("p:spTree")[0];
if (tree) return tree;
// Try to find by localName manually if namespace prefix is different or stripped
const all = doc.getElementsByTagName("*");
for (let i = 0; i < all.length; i++) {
if (all[i].localName === "spTree") return all[i];
}
return null;
};
const processPPTX = async () => {
if (!textFile || !bgFile) return;
setStatus('processing');
setLogs([]);
addLog('初始化處理程序...');
try {
const textZip = new JSZip();
const bgZip = new JSZip();
// 1. Load Files
addLog('讀取: 文字來源檔...');
const textContent = await textZip.loadAsync(textFile);
addLog('讀取: 背景基底檔...');
const bgContent = await bgZip.loadAsync(bgFile);
// 2. Identify Slides
const textSlides = Object.keys(textContent.files).filter(path => path.match(/ppt\/slides\/slide\d+\.xml/));
const bgSlides = Object.keys(bgContent.files).filter(path => path.match(/ppt\/slides\/slide\d+\.xml/));
// Sort slides naturally
const sorter = (a, b) => {
const getNum = (str) => {
const match = str.match(/slide(\d+)\.xml/);
return match ? parseInt(match[1]) : 0;
};
return getNum(a) - getNum(b);
};
textSlides.sort(sorter);
bgSlides.sort(sorter);
addLog(`偵測: 文字檔 ${textSlides.length} 頁, 背景檔 ${bgSlides.length} 頁`);
const processCount = Math.min(textSlides.length, bgSlides.length);
const parser = new DOMParser();
const serializer = new XMLSerializer();
for (let i = 0; i < processCount; i++) {
const textSlidePath = textSlides[i];
const bgSlidePath = bgSlides[i];
// Read XML content
const textXmlStr = await textContent.file(textSlidePath).async("string");
const bgXmlStr = await bgContent.file(bgSlidePath).async("string");
const textDoc = parser.parseFromString(textXmlStr, "application/xml");
const bgDoc = parser.parseFromString(bgXmlStr, "application/xml");
// Check for parse errors
if (textDoc.getElementsByTagName("parsererror").length > 0) {
throw new Error(`解析文字檔 XML 失敗: ${textSlidePath}`);
}
const textSpTree = getSpTree(textDoc);
const bgSpTree = getSpTree(bgDoc);
if (textSpTree && bgSpTree) {
const textChildren = Array.from(textSpTree.childNodes);
let addedCount = 0;
textChildren.forEach(node => {
// Robustly check node type using localName
const localName = node.localName || node.nodeName.split(':').pop();
// We want Shapes (sp), Groups (grpSp), GraphicFrames (graphicFrame), Pictures (pic)
const allowedTags = ['sp', 'grpSp', 'graphicFrame', 'pic'];
if (allowedTags.includes(localName)) {
try {
// Import node to new document
const clonedNode = bgDoc.importNode(node, true);
// Simple ID collision avoidance (optional but safer)
// Find cNvPr element and append a suffix to id
// This is a naive implementation; proper ID management is complex
// but appending a random suffix helps avoid direct clashes with BG elements
const cNvPr = clonedNode.getElementsByTagName ?
(clonedNode.getElementsByTagName("p:cNvPr")[0] || clonedNode.getElementsByTagName("cNvPr")[0]) : null;
if (cNvPr && cNvPr.getAttribute("id")) {
cNvPr.setAttribute("id", cNvPr.getAttribute("id") + "999");
}
bgSpTree.appendChild(clonedNode);
addedCount++;
} catch (e) {
console.warn("Node import failed", e);
}
}
});
addLog(`第 ${i + 1} 頁: 合併了 ${addedCount} 個物件`);
} else {
addLog(`第 ${i + 1} 頁: 找不到內容結構 (spTree),跳過`);
}
// Save modified XML
const newBgXmlStr = serializer.serializeToString(bgDoc);
bgContent.file(bgSlidePath, newBgXmlStr);
}
// 3. Generate File
addLog('正在打包新檔案...');
const content = await bgContent.generateAsync({ type: "blob" });
const url = URL.createObjectURL(content);
setDownloadUrl(url);
setStatus('success');
addLog('處理完成!');
} catch (err) {
console.error(err);
setStatus('error');
addLog(`錯誤: ${err.message}`);
}
};
const downloadFile = () => {
if (downloadUrl) {
const a = document.createElement('a');
a.href = downloadUrl;
a.download = "merged_presentation.pptx";
a.click();
}
};
const reset = () => {
setTextFile(null);
setBgFile(null);
setStatus('idle');
setDownloadUrl(null);
setLogs([]);
};
return (
<div className="min-h-screen py-12 px-4 sm:px-6 lg:px-8 flex flex-col items-center">
<div className="max-w-3xl w-full space-y-8 bg-white p-8 rounded-xl shadow-lg">
<div className="text-center">
<h1 className="text-3xl font-extrabold text-gray-900 flex items-center justify-center gap-3">
<IconMagic />
PPTX 魔法合併工具
</h1>
<p className="mt-2 text-gray-500">
專注於將文字內容移植到背景模板
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 items-center">
{/* Text Input */}
<FileUploader
file={textFile}
setFile={setTextFile}
title="1. 上傳文字檔 (無背景)"
icon={<IconFileText />}
accept=".pptx"
type="text"
onDrop={handleFileDrop}
/>
<div className="flex justify-center">
<div className="hidden md:block transform rotate-0">
<IconArrowRight />
</div>
<div className="md:hidden transform rotate-90 my-2">
<IconArrowRight />
</div>
</div>
{/* BG Input */}
<FileUploader
file={bgFile}
setFile={setBgFile}
title="2. 上傳背景檔 (無文字)"
icon={<IconImage />}
accept=".pptx"
type="bg"
onDrop={handleFileDrop}
/>
</div>
{/* Action Area */}
<div className="flex flex-col items-center justify-center space-y-4 pt-6 border-t border-gray-100">
{status === 'processing' && (
<div className="flex items-center space-x-2 text-blue-600 animate-pulse">
<IconLoader />
<span className="font-medium">正在解析與合併 XML...</span>
</div>
)}
{status === 'error' && (
<div className="flex items-center space-x-2 text-red-600 bg-red-50 px-4 py-2 rounded-lg">
<IconAlert />
<span>合併失敗,請查看日誌了解詳情。</span>
</div>
)}
{status === 'idle' && (
<button
onClick={processPPTX}
disabled={!textFile || !bgFile}
className={`
flex items-center px-8 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white
${(!textFile || !bgFile) ? 'bg-gray-300 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500'}
`}
>
開始合併
</button>
)}
{status === 'success' && (
<div className="flex flex-col items-center space-y-4">
<div className="flex items-center space-x-2 text-green-600 font-bold text-lg">
<IconCheck />
<span>合併成功!</span>
</div>
<div className="flex space-x-4">
<button
onClick={downloadFile}
className="flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white bg-green-600 hover:bg-green-700 shadow-lg transition-all transform hover:scale-105"
>
<IconDownload />
<span className="ml-2">下載新檔案</span>
</button>
<button
onClick={reset}
className="flex items-center px-6 py-3 border border-gray-300 text-base font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
重置
</button>
</div>
</div>
)}
</div>
{/* Logs Console */}
<div className="bg-gray-900 rounded-lg p-4 font-mono text-sm h-48 overflow-y-auto shadow-inner">
<div className="text-gray-400 mb-2 border-b border-gray-700 pb-1">處理日誌:</div>
{logs.length === 0 && <span className="text-gray-600 italic">等待操作...</span>}
{logs.map((log, idx) => (
<div key={idx} className="text-green-400">> {log}</div>
))}
</div>
<div className="text-xs text-gray-400 mt-4 bg-yellow-50 p-3 rounded border border-yellow-100">
<strong>⚠️ 使用提示:</strong>
<ul className="list-disc pl-5 mt-1 space-y-1">
<li><strong>文字移位:</strong> 合併後文字位置會保留原檔案設定,若背景設計不同,可能需要手動微調位置。</li>
<li><strong>字型:</strong> 若您的電腦沒有安裝原檔案使用的字型,顯示可能會有所不同。</li>
<li><strong>安全性:</strong> 所有操作皆在瀏覽器本地完成,檔案不會離開您的電腦。</li>
</ul>
</div>
</div>
</div>
);
};
const FileUploader = ({ file, setFile, title, icon, accept, type, onDrop }) => {
const fileInputRef = useRef(null);
const [isDragOver, setIsDragOver] = useState(false);
const handleDragOver = (e) => {
e.preventDefault();
setIsDragOver(true);
};
const handleDragLeave = () => {
setIsDragOver(false);
};
const handleDrop = (e) => {
setIsDragOver(false);
onDrop(e, type);
};
return (
<div
className={`
relative border-2 border-dashed rounded-xl p-6 flex flex-col items-center justify-center text-center h-48 cursor-pointer drop-zone
${isDragOver ? 'active border-blue-500 bg-blue-50' : 'border-gray-300 hover:border-blue-400 hover:bg-gray-50'}
${file ? 'bg-blue-50 border-blue-500 border-solid' : ''}
`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current.click()}
>
<input
type="file"
ref={fileInputRef}
className="hidden"
accept={accept}
onChange={(e) => setFile(e.target.files[0])}
/>
{file ? (
<div className="space-y-2">
<div className="w-12 h-12 bg-blue-100 text-blue-600 rounded-full flex items-center justify-center mx-auto">
<IconCheck />
</div>
<p className="font-medium text-gray-900 break-all line-clamp-2">{file.name}</p>
<p className="text-xs text-gray-500">{(file.size / 1024 / 1024).toFixed(2)} MB</p>
</div>
) : (
<div className="space-y-2 pointer-events-none flex flex-col items-center">
{icon}
<h3 className="text-sm font-medium text-gray-900">{title}</h3>
<p className="text-xs text-gray-500">點擊或拖放檔案</p>
</div>
)}
</div>
);
};
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
</script>
</body>
</html>
請直接執行程式,不要修改程式語法