2026年10月21日 星期三

[研究]請寫一個 .html 的 Editor,類似 MS-Word,可以匯出成 .docx

[研究]請寫一個 .html 的 Editor,類似 MS-Word,可以匯出成 .docx

2026-10-21

原本要AI產出.html,結果自己另存檔案後,老是有問題

最後請AI直接產出 HTML Source 好了,自己貼notepad 另存檔案

WebWordEditor.html


<!DOCTYPE html> <html lang="zh-Hant"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Web Word Editor</title> <style> :root { --blue: #2563eb; --border: #d1d5db; --bg: #f3f4f6; --text: #1f2937; } * { box-sizing: border-box; } body { margin: 0; font-family: "Microsoft JhengHei", "Noto Sans TC", Arial, sans-serif; color: var(--text); background: var(--bg); } .topbar { background: #fff; border-bottom: 1px solid var(--border); padding: 12px 18px; display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; } .brand { font-size: 20px; font-weight: 700; } .actions { display: flex; gap: 8px; flex-wrap: wrap; } button, select, input { font: inherit; } button { border: 1px solid var(--border); background: #fff; border-radius: 5px; padding: 7px 10px; cursor: pointer; } button:hover { background: #e5e7eb; } button.primary { background: var(--blue); color: #fff; border-color: var(--blue); } button.primary:hover { background: #1d4ed8; } .toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; padding: 10px; background: #fafafa; border-bottom: 1px solid var(--border); } .toolbar select { max-width: 180px; min-height: 34px; border: 1px solid var(--border); border-radius: 5px; padding: 5px; background: white; } .toolbar input[type="color"] { width: 38px; height: 34px; padding: 2px; border: 1px solid var(--border); border-radius: 5px; background: white; cursor: pointer; } .separator { width: 1px; height: 26px; background: var(--border); margin: 0 3px; } .workspace { padding: 24px 12px 50px; overflow: auto; } .page { width: 100%; max-width: 850px; min-height: 900px; margin: 0 auto; padding: 64px 72px; background: white; box-shadow: 0 2px 12px #00000012; border: 1px solid #e5e7eb; } #editor { min-height: 750px; outline: none; overflow-wrap: anywhere; line-height: 1.8; font-size: 16px; } #editor:focus { outline: none; } #editor h1 { font-size: 2em; } #editor h2 { font-size: 1.5em; } #editor h3 { font-size: 1.2em; } #editor table { border-collapse: collapse; width: 100%; margin: 12px 0; } #editor td, #editor th { border: 1px solid #9ca3af; padding: 8px; min-width: 50px; } #editor img { max-width: 100%; height: auto; } .statusbar { position: fixed; bottom: 0; left: 0; right: 0; background: #fff; border-top: 1px solid var(--border); padding: 7px 14px; font-size: 12px; display: flex; justify-content: space-between; gap: 10px; } @media (max-width: 650px) { .page { padding: 24px 18px; min-height: 650px; } .workspace { padding: 10px 5px 45px; } .topbar { padding: 10px; } .brand { font-size: 18px; } } @media print { body { background: white; } .topbar, .toolbar, .statusbar { display: none !important; } .workspace { padding: 0; overflow: visible; } .page { max-width: none; border: none; box-shadow: none; padding: 0; } } </style> </head> <body> <header class="topbar"> <div class="brand">📝 Web Word Editor</div> <div class="actions"> <button onclick="newDocument()">新增文件</button> <button onclick="openHtml()">開啟 HTML</button> <button onclick="saveHtml()">另存 HTML</button> <button onclick="window.print()">列印 / 存成 PDF</button> <button class="primary" onclick="exportDocx()">匯出 DOCX</button> </div> </header> <nav class="toolbar" aria-label="編輯工具列"> <button title="復原" onclick="cmd('undo')">↶ 復原</button> <button title="重做" onclick="cmd('redo')">↷ 重做</button> <span class="separator"></span> <select id="blockFormat" title="段落格式" onchange="formatBlock(this.value)"> <option value="P">一般段落</option> <option value="H1">標題 1</option> <option value="H2">標題 2</option> <option value="H3">標題 3</option> <option value="BLOCKQUOTE">引用</option> <option value="PRE">程式碼區塊</option> </select> <select title="字型" onchange="setFont(this.value)"> <option value="Microsoft JhengHei">微軟正黑體</option> <option value="PMingLiU">新細明體</option> <option value="DFKai-SB">標楷體</option> <option value="Arial">Arial</option> <option value="Times New Roman">Times New Roman</option> </select> <select title="字級" onchange="setFontSize(this.value)"> <option value="2">小</option> <option value="3" selected>正常</option> <option value="4">大</option> <option value="5">特大</option> <option value="6">非常大</option> <option value="7">最大</option> </select> <span class="separator"></span> <button onclick="cmd('bold')" title="粗體"><b>B</b></button> <button onclick="cmd('italic')" title="斜體"><i>I</i></button> <button onclick="cmd('underline')" title="底線"><u>U</u></button> <button onclick="cmd('strikeThrough')" title="刪除線"><s>S</s></button> <label title="文字顏色"> <input type="color" value="#000000" onchange="cmd('foreColor', this.value)"> </label> <label title="螢光標示"> <input type="color" value="#ffff00" onchange="cmd('hiliteColor', this.value)"> </label> <span class="separator"></span> <button onclick="cmd('justifyLeft')" title="靠左">≡←</button> <button onclick="cmd('justifyCenter')" title="置中">≡</button> <button onclick="cmd('justifyRight')" title="靠右">→≡</button> <button onclick="cmd('justifyFull')" title="左右對齊">☰</button> <span class="separator"></span> <button onclick="cmd('insertUnorderedList')">• 項目符號</button> <button onclick="cmd('insertOrderedList')">1. 編號</button> <button onclick="insertLink()">插入連結</button> <button onclick="insertImage()">插入圖片</button> <button onclick="insertTable()">插入表格</button> <button onclick="cmd('removeFormat')">清除格式</button> </nav> <main class="workspace"> <section class="page"> <article id="editor" contenteditable="true" spellcheck="true" aria-label="文件編輯區"> <h1>我的文件</h1> <p>請在此輸入文字,開始編輯文件。</p> <p>您可以選取文字後,使用上方工具列套用格式。</p> <h2>功能說明</h2> <ul> <li>支援基本文字格式及段落設定。</li> <li>可以插入表格、超連結及圖片。</li> <li>可以匯出 HTML、DOCX,或列印成 PDF。</li> </ul> </article> </section> </main> <div class="statusbar"> <span id="status">就緒</span> <span id="wordCount">字數:0</span> </div> <input id="htmlFile" type="file" accept=".html,.htm,text/html" hidden onchange="loadHtml(event)"> <input id="imageFile" type="file" accept="image/*" hidden onchange="loadImage(event)"> <script> "use strict"; const editor = document.getElementById("editor"); const statusText = document.getElementById("status"); const wordCount = document.getElementById("wordCount"); function setStatus(message) { statusText.textContent = message; } function updateCount() { const text = editor.innerText || ""; const trimmed = text.trim(); const count = trimmed ? trimmed.replace(/\s/g, "").length : 0; wordCount.textContent = "字數:" + count; } editor.addEventListener("input", () => { updateCount(); setStatus("編輯中"); }); function cmd(command, value = null) { editor.focus(); try { document.execCommand(command, false, value); updateCount(); } catch (error) { setStatus("此瀏覽器不支援該格式功能"); } } function formatBlock(tag) { editor.focus(); document.execCommand("formatBlock", false, "<" + tag + ">"); } function setFont(font) { editor.focus(); document.execCommand("fontName", false, font); } function setFontSize(size) { editor.focus(); document.execCommand("fontSize", false, size); } function insertLink() { const url = prompt("請輸入完整網址,例如 https://example.com"); if (!url) return; if (!/^https?:\/\//i.test(url)) { alert("網址請以 http:// 或 https:// 開頭。"); return; } editor.focus(); document.execCommand("createLink", false, url); } function insertImage() { document.getElementById("imageFile").click(); } function loadImage(event) { const file = event.target.files[0]; if (!file) return; if (!file.type.startsWith("image/")) { alert("請選擇圖片檔案。"); return; } const reader = new FileReader(); reader.onload = () => { editor.focus(); const img = document.createElement("img"); img.src = reader.result; img.alt = file.name; img.style.maxWidth = "100%"; const selection = window.getSelection(); if (selection && selection.rangeCount && editor.contains(selection.anchorNode)) { const range = selection.getRangeAt(0); range.deleteContents(); range.insertNode(img); } else { editor.appendChild(img); } updateCount(); setStatus("圖片已插入"); }; reader.readAsDataURL(file); event.target.value = ""; } function insertTable() { const rows = parseInt(prompt("請輸入列數(1–20)", "3"), 10); if (!Number.isInteger(rows) || rows < 1 || rows > 20) { alert("列數必須介於 1 到 20。"); return; } const cols = parseInt(prompt("請輸入欄數(1–10)", "3"), 10); if (!Number.isInteger(cols) || cols < 1 || cols > 10) { alert("欄數必須介於 1 到 10。"); return; } const table = document.createElement("table"); const tbody = document.createElement("tbody"); for (let r = 0; r < rows; r++) { const tr = document.createElement("tr"); for (let c = 0; c < cols; c++) { const td = document.createElement("td"); td.innerHTML = "&nbsp;"; tr.appendChild(td); } tbody.appendChild(tr); } table.appendChild(tbody); editor.focus(); const selection = window.getSelection(); if (selection && selection.rangeCount && editor.contains(selection.anchorNode)) { const range = selection.getRangeAt(0); range.deleteContents(); range.insertNode(table); const paragraph = document.createElement("p"); paragraph.innerHTML = "<br>"; table.after(paragraph); } else { editor.appendChild(table); } updateCount(); setStatus("表格已插入"); } function newDocument() { if (!confirm("新增文件會清除目前內容,確定繼續?")) return; editor.innerHTML = "<h1>我的文件</h1><p><br></p>"; updateCount(); setStatus("已新增文件"); } function saveBlob(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1500); } function saveHtml() { const html = `<!DOCTYPE html> <html lang="zh-Hant"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>我的文件</title> <style> body { font-family: "Microsoft JhengHei", sans-serif; max-width: 850px; margin: 40px auto; padding: 20px; line-height: 1.8; } table { border-collapse: collapse; width: 100%; } td, th { border: 1px solid #999; padding: 8px; } img { max-width: 100%; height: auto; } </style> </head> <body>${editor.innerHTML}</body> </html>`; saveBlob(new Blob([html], {type: "text/html;charset=utf-8"}), "我的文件.html"); setStatus("HTML 已下載"); } function openHtml() { document.getElementById("htmlFile").click(); } function loadHtml(event) { const file = event.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { const parser = new DOMParser(); const doc = parser.parseFromString(String(reader.result), "text/html"); // 僅載入 body 內容;不執行匯入檔案中的 script。 editor.innerHTML = doc.body ? doc.body.innerHTML : ""; updateCount(); setStatus("HTML 已載入"); }; reader.readAsText(file, "UTF-8"); event.target.value = ""; } async function exportDocx() { setStatus("正在準備 DOCX 匯出……"); if (!window.docx) { try { await loadDocxLibrary(); } catch (error) { setStatus("DOCX 程式庫載入失敗"); alert( "無法載入 DOCX 匯出程式庫。\n\n" + "請確認電腦已連上網路,並允許載入 jsDelivr CDN," + "然後重新整理頁面再試一次。" ); return; } } try { const { Document, Packer, Paragraph, TextRun, HeadingLevel, AlignmentType, Table, TableRow, TableCell, WidthType, ImageRun } = window.docx; const children = []; const blocks = Array.from(editor.childNodes); function getAlignment(element) { const align = element.style?.textAlign || element.getAttribute?.("align") || ""; if (align === "center") return AlignmentType.CENTER; if (align === "right") return AlignmentType.RIGHT; if (align === "justify") return AlignmentType.JUSTIFIED; return AlignmentType.LEFT; } function walkInline(node, inherited = {}) { const output = []; if (node.nodeType === Node.TEXT_NODE) { if (node.nodeValue) { output.push(new TextRun({ text: node.nodeValue, bold: inherited.bold || false, italics: inherited.italics || false, underline: inherited.underline ? {} : undefined, strike: inherited.strike || false, color: inherited.color, font: inherited.font, size: inherited.size })); } return output; } if (node.nodeType !== Node.ELEMENT_NODE) return output; const tag = node.tagName.toLowerCase(); const next = {...inherited}; if (tag === "b" || tag === "strong") next.bold = true; if (tag === "i" || tag === "em") next.italics = true; if (tag === "u") next.underline = true; if (tag === "s" || tag === "strike" || tag === "del") next.strike = true; if (node.style?.color) { const match = node.style.color.match(/\d+/g); if (match && match.length >= 3) { next.color = match.slice(0,3).map(n => Number(n).toString(16).padStart(2,"0")).join("").toUpperCase(); } } if (node.style?.fontFamily) { next.font = node.style.fontFamily.split(",")[0].replace(/["']/g, ""); } if (node.style?.fontSize) { const px = parseFloat(node.style.fontSize); if (Number.isFinite(px)) next.size = Math.round(px * 1.5); } if (tag === "br") { output.push(new TextRun({break: 1})); return output; } for (const child of node.childNodes) { output.push(...walkInline(child, next)); } return output; } function makeParagraph(element, extra = {}) { const runs = walkInline(element); if (!runs.length) runs.push(new TextRun("")); const tag = element.tagName?.toLowerCase(); let heading; if (tag === "h1") heading = HeadingLevel.HEADING_1; if (tag === "h2") heading = HeadingLevel.HEADING_2; if (tag === "h3") heading = HeadingLevel.HEADING_3; return new Paragraph({ children: runs, heading, alignment: getAlignment(element), bullet: extra.bullet ? {level: 0} : undefined, numbering: extra.numbering ? {reference: "numbered", level: 0} : undefined, spacing: {after: 160}, ...extra.options }); } function processBlock(node) { if (node.nodeType === Node.TEXT_NODE) { const t = node.nodeValue.trim(); if (t) children.push(new Paragraph({children:[new TextRun(t)]})); return; } if (node.nodeType !== Node.ELEMENT_NODE) return; const tag = node.tagName.toLowerCase(); if (tag === "ul" || tag === "ol") { for (const li of node.children) { if (li.tagName.toLowerCase() === "li") { children.push(makeParagraph(li, { bullet: tag === "ul", numbering: tag === "ol" })); } } return; } if (tag === "table") { const rows = Array.from(node.querySelectorAll("tr")).map(tr => new TableRow({ children: Array.from(tr.children).map(cell => new TableCell({ children: [new Paragraph({ children: walkInline(cell).length ? walkInline(cell) : [new TextRun("")] })], width: {size: 100, type: WidthType.AUTO} }) ) }) ); if (rows.length) { children.push(new Table({ rows, width: {size: 100, type: WidthType.PERCENTAGE} })); children.push(new Paragraph({children:[new TextRun("")]})); } return; } if (tag === "img") { children.push(new Paragraph({ children: [new TextRun("[圖片未匯出:請在 Word 中重新插入圖片]")], spacing: {after: 160} })); return; } if (["p","div","h1","h2","h3","h4","h5","h6","blockquote","pre"].includes(tag)) { children.push(makeParagraph(node)); return; } for (const child of node.childNodes) processBlock(child); } blocks.forEach(processBlock); if (!children.length) { children.push(new Paragraph({children:[new TextRun("")]})); } const doc = new Document({ sections: [{ properties: {}, children }] }); const blob = await Packer.toBlob(doc); saveBlob(blob, "我的文件.docx"); setStatus("DOCX 已下載"); } catch (error) { console.error(error); setStatus("DOCX 匯出失敗"); alert("DOCX 匯出失敗:" + (error?.message || "未知錯誤")); } } function loadDocxLibrary() { return new Promise((resolve, reject) => { if (window.docx) { resolve(); return; } const script = document.createElement("script"); script.src = "https://cdn.jsdelivr.net/npm/docx@8.5.0/build/index.umd.js"; script.onload = () => window.docx ? resolve() : reject(new Error("DOCX library did not initialize")); script.onerror = () => reject(new Error("CDN load failed")); document.head.appendChild(script); }); } updateCount(); </script> </body> </html>


********************************************************************************

如果沒有連上 Internet,請重新給我一個 MS-Word-like editor HTML Source

*****

可以。這次改成 完全離線版的 MS-Word-like Editor,不使用 CDN、不使用外部 JavaScript 套件,也不需要連上 Internet。

先說明一個重要限制:純 HTML、CSS、JavaScript 可以實作基本的 Word 類型編輯器,但要在不使用任何外部程式庫的情況下,完整產生真正的 .docx Word 文件,實作成本較高。 因此,以下版本採用瀏覽器原生功能,提供基本文字編輯、格式設定、表格、圖片、HTML 儲存及列印成 PDF;另提供匯出 Word 可開啟的 .doc 相容文件功能。這個 .doc 是 HTML 格式包裝的相容文件,不是原生 .docx。

It seems like I can’t do more advanced data analysis right now. Please try again later.

抱歉,原本我打算提供一份全程離線可用的完整 HTML 原始碼,但目前用來建立及驗證檔案的工具無法執行。

我不想再提供一份未能確認完整性的程式碼,讓您複製後才發現問題。這次尚未成功產生可交付的修正版檔案。

********************************************************************************

有空再研究、、、

(完)

沒有留言:

張貼留言