HTML+CSS+JavaScript实现的好用的汉字拼音标注工具
这是一个由前端三件套实现实现的汉字拼音标注工具的单网页工具,无需安装靠浏览器运行。它为中文内容的注音、教学、排版与传播提供了一站式、零门槛的解决方案。
这个工具把原本需要多个软件、多步操作才能完成的“给汉字准确注音并输出可出版物级材料”的工作,简化成了粘贴→确认→复制/导出,极大降低了中文注音内容的生产成本。教师和学习者只需粘贴课文或生词,瞬间获得拼音。
用户输入中文文本后,系统会自动为每个汉字生成拼音,并支持多种标注格式的输出和导出,同时也允许手动修改拼音和声调。显示截图:

本工具基于pinyin-pro词库的上下文分词能力,自动标注准确率已较高。但任何规则算法都可能出错,此时用户可直接点击修改拼音,并通过声调面板高效输入带调字母(避免记忆声调输入法快捷键),修改后的拼音会用于所有导出格式,确保最终输出准确。
点击“编辑拼音”按钮,自动切换到 Ruby 模式(因为其他模式不支持直接编辑),用鼠标选中 要修改的拼音字母,会立刻弹出浮动声调面板,包含 a/e/i/o/u/ü 的全部声调字母,点击 浮动声调面板的声调字母(如 ǎ),即可替换选中文字;如果想去掉声调,点击 “移除声调” 按钮,选中的带调字母会自动变回纯字母(如 ǎ → a整体架构)。例如:

整个工具完全运行在浏览器端,由前端三件套实现:HTML 负责结构,CSS 负责样式,JavaScript 处理全部业务逻辑,具有高度的可移植性和即时响应性。
外部依赖:
· pinyin-pro:汉字转拼音的核心库,支持分词、多音字识别和多种声调格式。
· JSZip:用于在浏览器中生成 .docx(本质是 ZIP 压缩包)。
pinyin-pro 负责“发音正确”,JSZip 负责“格式正确”。这两个外部库是通过 CDN(内容分发网络) 的 <script> 标签直接引入的,见脚本源码部分:
<script src="https://unpkg.com/pinyin-pro/dist/index.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
网络异常或网速不太稳定或不想联网使用,也可以下载到本地:从对应 CDN 下载 .js 文件,放到同级目录,改为 <script src="./pinyin-pro.js"></script>。
脚本源码中的POLYPHONE_CHARS 集合可以修改,该集合中的多音字仅控制视觉高亮(添加红色样式),不影响拼音的自动生成。拼音正误仍由 pinyin-pro 库决定。
源码如(由DeepSeek辅助生成):
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>汉字拼音标注工具 · 完整版</title>
<script src="https://unpkg.com/pinyin-pro/dist/index.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<style>
:root {
--bg: #f5f0eb;
--card-bg: #ffffff;
--primary: #6b5b4f;
--primary-light: #8b7355;
--accent: #c4976b;
--text: #3d3229;
--border: #e8ddd2;
--shadow-lg: 0 12px 40px rgba(61, 50, 41, 0.12);
--radius-lg: 20px;
--transition: 0.25s;
--font-serif: 'Georgia', 'Noto Serif SC', 'STSong', 'Songti SC', 'SimSun', 'KaiTi', '楷体', serif;
--font-sans: 'Inter', 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif;
}
* { margin:0; padding:0; box-sizing:border-box; }
body {
font-family: var(--font-sans);
background: linear-gradient(160deg, #f9f5f0 0%, #f0e9df 30%, #f5f0e8 60%, #faf6f2 100%);
min-height: 100vh; display: flex; align-items: center; justify-content: center;
padding: 20px; color: var(--text);
}
body::before, body::after { content:''; position:fixed; border-radius:50%; pointer-events:none; z-index:0; }
body::before { top:-180px; right:-120px; width:500px; height:500px; background:radial-gradient(circle, rgba(196,151,107,0.08) 0%, transparent 70%); }
body::after { bottom:-150px; left:-100px; width:450px; height:450px; background:radial-gradient(circle, rgba(139,115,85,0.06) 0%, transparent 70%); }
.container { width:100%; max-width:1100px; position:relative; z-index:1; display:flex; flex-direction:column; gap:20px; }
.header { text-align:center; padding:8px 0; }
.header .logo { display:inline-flex; align-items:center; gap:10px; }
.header .logo-icon { width:44px; height:44px; background:linear-gradient(135deg,#c4976b,#a07850); border-radius:13px; display:flex; align-items:center; justify-content:center; font-size:22px; color:#fff; box-shadow:0 6px 18px rgba(160,120,80,0.25); }
.header h1 { font-size:1.75rem; font-weight:700; color:#3d3229; }
.subtitle { font-size:0.9rem; color:#8b7b6b; }
.main-card {
background:var(--card-bg); border-radius:var(--radius-lg); box-shadow:var(--shadow-lg);
padding:28px 30px 24px; display:flex; flex-direction:column; gap:18px; border:1px solid var(--border);
}
.toolbar { display:flex; flex-wrap:wrap; align-items:center; gap:10px; padding-bottom:4px; }
.toolbar-group { display:flex; align-items:center; gap:6px; background:#faf7f3; border-radius:25px; padding:4px 5px; border:1px solid #ece4d8; }
.toolbar-group-label { font-size:0.75rem; color:#a89480; padding:0 8px 0 10px; font-weight:500; }
.tool-btn { padding:7px 15px; border-radius:22px; border:none; background:transparent; cursor:pointer; font-size:0.84rem; font-weight:500; color:#6b5b4f; transition:var(--transition); font-family:inherit; }
.tool-btn:hover { background:rgba(139,115,85,0.08); }
.tool-btn.active { background:#6b5b4f; color:#fff; font-weight:600; box-shadow:0 3px 10px rgba(107,91,79,0.25); }
.divider-dot { width:4px; height:4px; border-radius:50%; background:#d5c8b8; margin:0 2px; }
.content-area { display:grid; grid-template-columns:1fr 1fr; gap:20px; min-height:360px; }
@media (max-width:768px) {
.content-area { grid-template-columns:1fr; }
.tool-btn { padding:6px 11px; font-size:0.78rem; }
.main-card { padding:18px 14px 16px; }
}
.panel { display:flex; flex-direction:column; gap:10px; min-width:0; }
.panel-label { font-size:0.8rem; font-weight:600; color:#8b7b6b; display:flex; align-items:center; gap:8px; }
.dot { width:7px; height:7px; border-radius:50%; }
.dot-input { background:#c4976b; } .dot-output { background:#8b7355; }
.char-count { font-size:0.72rem; color:#b5a392; margin-left:auto; }
.input-textarea { flex:1; width:100%; min-height:280px; border:2px solid #ece4d8; border-radius:16px; padding:16px 18px; font-size:1.15rem; line-height:1.8; font-family:var(--font-serif); resize:vertical; outline:none; background:#fdfbf8; color:#3d3229; }
.input-textarea:focus { border-color:#c4976b; box-shadow:0 0 0 4px rgba(196,151,107,0.08); }
.output-area { flex:1; min-height:280px; border:2px solid #ece4d8; border-radius:16px; padding:18px; font-size:1.15rem; line-height:2.2; font-family:var(--font-serif); background:#fdfbf8; overflow-y:auto; word-break:break-all; color:#3d3229; position:relative; }
.output-area:empty::after { content:'拼音结果将在此显示...'; color:#c4b8a8; font-style:italic; font-family:var(--font-sans); font-size:0.9rem; }
.output-area ruby { ruby-align:center; }
.output-area rt { font-size:0.5em; color:#c4976b; font-weight:500; font-family:var(--font-sans); transition:all 0.2s; }
.output-area.editable rt { cursor:text; border-bottom:1px dashed #c4976b; padding:0 2px; min-width:10px; display:inline-block; }
.output-area.editable rt:focus { outline:none; border-bottom:2px solid #6b5b4f; background:rgba(196,151,107,0.05); }
.output-area .polyphone { color:#b8452e; }
.output-area .polyphone rt { color:#d4684a; font-weight:600; }
/* ★新增:浮动声调选择面板 */
.tone-palette {
display: none;
position: absolute;
z-index: 1500;
background: #fff;
border: 1px solid #c4976b;
border-radius: 12px;
padding: 8px 12px;
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
white-space: nowrap;
font-size: 0.9rem;
user-select: none;
}
.tone-palette.visible { display: block; }
.tone-palette .tone-row {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 4px;
}
.tone-palette .tone-row:last-child { margin-bottom: 0; }
.tone-palette .tone-label {
width: 20px;
font-weight: bold;
color: #6b5b4f;
text-align: center;
}
.tone-palette .tone-btn {
background: #faf6f1;
border: 1px solid #e8ddd2;
border-radius: 6px;
padding: 2px 6px;
cursor: pointer;
font-family: monospace;
font-size: 0.95rem;
color: #3d3229;
transition: all 0.15s;
}
.tone-palette .tone-btn:hover {
background: #c4976b;
color: #fff;
border-color: #c4976b;
}
.tone-palette .tone-clear {
margin-top: 6px;
background: transparent;
border: 1px dashed #ccc;
color: #888;
cursor: pointer;
font-size: 0.8rem;
padding: 2px 8px;
border-radius: 6px;
}
.tone-palette .tone-clear:hover {
background: #f5f5f5;
color: #333;
}
.action-bar { display:flex; flex-wrap:wrap; gap:8px; align-items:center; padding-top:2px; }
.btn { padding:9px 18px; border-radius:25px; border:1.5px solid #d5c8b8; background:#fff; cursor:pointer; font-size:0.85rem; font-weight:500; color:#6b5b4f; transition:var(--transition); display:inline-flex; align-items:center; gap:6px; font-family:inherit; }
.btn:hover { background:#faf6f1; border-color:#c4976b; }
.btn-primary { background:#6b5b4f; border-color:#6b5b4f; color:#fff; font-weight:600; }
.btn-primary:hover { background:#5a4a3f; }
.btn-outline-accent { border-color:#c4976b; color:#8b5e3c; }
.btn-export { background:#4a6b4f; border-color:#4a6b4f; color:#fff; font-weight:600; }
.btn-export:hover { background:#3d5a42; }
.btn-edit { background:#f0e6d8; border-color:#c4976b; color:#5a3e2b; }
.btn-edit.active { background:#c4976b; color:#fff; }
.btn-help { background:transparent; border:1.5px solid #d5c8b8; color:#6b5b4f; margin-left:auto; padding:9px 15px; border-radius:25px; cursor:pointer; font-size:0.85rem; transition:var(--transition); }
.btn-help:hover { background:#faf6f1; }
.toast { position:fixed; top:24px; left:50%; transform:translateX(-50%) translateY(-120px); background:#3d3229; color:#fff; padding:11px 22px; border-radius:25px; font-size:0.85rem; z-index:1000; pointer-events:none; transition:transform 0.4s cubic-bezier(0.175,0.885,0.32,1.275); box-shadow:0 8px 30px rgba(61,50,41,0.3); font-family:var(--font-sans); }
.toast.show { transform:translateX(-50%) translateY(0); }
.toast.success { background:#4a6b4f; }
.modal-overlay { display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.3); z-index:2000; justify-content:center; align-items:center; }
.modal-overlay.active { display:flex; }
.modal-card { background:white; border-radius:20px; padding:28px; max-width:600px; width:90%; max-height:80vh; overflow-y:auto; box-shadow:0 20px 50px rgba(0,0,0,0.2); font-family:var(--font-sans); color:#3d3229; }
.modal-card h2 { font-size:1.5rem; margin-bottom:18px; }
.modal-card h3 { font-size:1.1rem; margin:18px 0 8px; color:#6b5b4f; }
.modal-card p, .modal-card li { font-size:0.9rem; line-height:1.7; margin-bottom:8px; }
.modal-card ul { padding-left:20px; }
.modal-close { float:right; background:none; border:none; font-size:1.5rem; cursor:pointer; color:#8b7b6b; line-height:1; }
.modal-close:hover { color:#3d3229; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo"><div class="logo-icon">拼</div><div><h1>汉字拼音标注工具</h1><p class="subtitle">输入汉字 · 自动注音 · 拼音可编辑 · 声调助手</p></div></div>
</div>
<div class="main-card">
<div class="toolbar">
<div class="toolbar-group">
<span class="toolbar-group-label">显示模式</span>
<button class="tool-btn active" data-mode="ruby">📐 Ruby标注</button>
<button class="tool-btn" data-mode="bracket">📝 括号标注</button>
<button class="tool-btn" data-mode="pinyin-only">🔤 纯拼音</button>
</div>
<div class="divider-dot"></div>
<div class="toolbar-group">
<span class="toolbar-group-label">声调</span>
<button class="tool-btn active" data-tone="symbol">ā á ǎ à</button>
<button class="tool-btn" data-tone="num">a1 a2 a3 a4</button>
<button class="tool-btn" data-tone="none">无调</button>
</div>
<div class="divider-dot"></div>
<button class="tool-btn btn-edit" id="btnEditToggle">✏️ 编辑拼音</button>
<button class="tool-btn btn-sm" id="btnSample">💡 示例</button>
<button class="tool-btn btn-sm" id="btnClear">🗑️ 清空</button>
<button class="btn-help" id="btnHelp">❓ 帮助</button>
</div>
<div class="content-area">
<div class="panel">
<div class="panel-label"><span class="dot dot-input"></span> 输入文本 <span class="char-count" id="charCount">0 字</span></div>
<textarea class="input-textarea" id="inputText" placeholder="在此输入或粘贴汉字文本..."></textarea>
</div>
<div class="panel">
<div class="panel-label"><span class="dot dot-output"></span> 拼音结果 <span class="char-count" id="hanziCount">0 个汉字</span></div>
<div class="output-area" id="outputArea"></div>
</div>
</div>
<div class="action-bar">
<button class="btn btn-primary" id="btnCopyRuby">复制Ruby HTML</button>
<button class="btn btn-outline-accent" id="btnCopyBracket">复制括号标注</button>
<button class="btn" id="btnCopyPinyin">复制纯拼音</button>
<button class="btn btn-export" id="btnExportWord">导出Word (.docx)</button>
<span style="flex:1;"></span>
<span style="font-size:0.72rem;color:#b5a392;" id="editHint">编辑拼音时,选中文字即可出现声调面板</span>
</div>
</div>
</div>
<!-- 帮助模态框 -->
<div class="modal-overlay" id="helpModal">
<div class="modal-card">
<button class="modal-close" id="closeHelp">×</button>
<h2>📘 使用帮助</h2>
<h3>1. 基本操作</h3>
<p>在左侧文本框输入或粘贴中文,右侧会自动显示拼音。支持三种显示模式:<b>Ruby标注</b>、<b>括号标注</b>、<b>纯拼音</b>。声调可在符号、数字、无调之间切换。</p>
<p>拼音结果框中,多音字高亮提示——受脚本代码中的POLYPHONE_CHARS多音字集合控制。</p>
<h3>2. 修改拼音</h3>
<p>点击 <b>✏️ 编辑拼音</b> 后,自动进入"Ruby标注"模式,拼音字母变为可编辑状态。<b>选中拼音中的任意字母</b>,会出现声调选择面板,点击带声调的字母即可替换。编辑拼音后若修改输入文本,拼音会重新生成,手动修改将丢失。</p>
<h3>3. 导出与复制</h3>
<ul>
<li><b>复制Ruby HTML</b>:复制带ruby标签的HTML源码。</li>
<li><b>复制括号标注</b>:复制“汉字(拼音)”格式的纯文本。</li>
<li><b>复制纯拼音</b>:仅复制拼音字母。</li>
<li><b>导出Word</b>:生成.docx,拼音以Word拼音指南形式显示。</li>
</ul>
</div>
</div>
<div class="toast" id="toast"></div>
<!-- ★新增:浮动声调选择面板 -->
<div class="tone-palette" id="tonePalette">
<div class="tone-row">
<span class="tone-label">a</span>
<span class="tone-btn" data-char="ā">ā</span>
<span class="tone-btn" data-char="á">á</span>
<span class="tone-btn" data-char="ǎ">ǎ</span>
<span class="tone-btn" data-char="à">à</span>
</div>
<div class="tone-row">
<span class="tone-label">e</span>
<span class="tone-btn" data-char="ē">ē</span>
<span class="tone-btn" data-char="é">é</span>
<span class="tone-btn" data-char="ě">ě</span>
<span class="tone-btn" data-char="è">è</span>
</div>
<div class="tone-row">
<span class="tone-label">i</span>
<span class="tone-btn" data-char="ī">ī</span>
<span class="tone-btn" data-char="í">í</span>
<span class="tone-btn" data-char="ǐ">ǐ</span>
<span class="tone-btn" data-char="ì">ì</span>
</div>
<div class="tone-row">
<span class="tone-label">o</span>
<span class="tone-btn" data-char="ō">ō</span>
<span class="tone-btn" data-char="ó">ó</span>
<span class="tone-btn" data-char="ǒ">ǒ</span>
<span class="tone-btn" data-char="ò">ò</span>
</div>
<div class="tone-row">
<span class="tone-label">u</span>
<span class="tone-btn" data-char="ū">ū</span>
<span class="tone-btn" data-char="ú">ú</span>
<span class="tone-btn" data-char="ǔ">ǔ</span>
<span class="tone-btn" data-char="ù">ù</span>
</div>
<div class="tone-row">
<span class="tone-label">ü</span>
<span class="tone-btn" data-char="ǖ">ǖ</span>
<span class="tone-btn" data-char="ǘ">ǘ</span>
<span class="tone-btn" data-char="ǚ">ǚ</span>
<span class="tone-btn" data-char="ǜ">ǜ</span>
</div>
<button class="tone-clear" id="btnClearTone">移除声调 (还原为纯字母)</button>
</div>
<script>
(function() {
const inputText = document.getElementById('inputText');
const outputArea = document.getElementById('outputArea');
const charCount = document.getElementById('charCount');
const hanziCount = document.getElementById('hanziCount');
const toast = document.getElementById('toast');
const btnSample = document.getElementById('btnSample');
const btnClear = document.getElementById('btnClear');
const btnCopyRuby = document.getElementById('btnCopyRuby');
const btnCopyBracket = document.getElementById('btnCopyBracket');
const btnCopyPinyin = document.getElementById('btnCopyPinyin');
const btnExportWord = document.getElementById('btnExportWord');
const btnEditToggle = document.getElementById('btnEditToggle');
const editHint = document.getElementById('editHint');
const btnHelp = document.getElementById('btnHelp');
const helpModal = document.getElementById('helpModal');
const closeHelp = document.getElementById('closeHelp');
const tonePalette = document.getElementById('tonePalette');
const btnClearTone = document.getElementById('btnClearTone');
let currentMode = 'ruby';
let currentToneType = 'symbol';
let isEditable = false;
let currentPinyinArray = [];
let debounceTimer = null;
// 用户可调整:防抖延迟时间(毫秒),数值越小反应越快,但计算更频繁
const DEBOUNCE_DELAY = 250;
// POLYPHONE_CHARS 集合可以修改,该集合仅控制视觉高亮(添加红色样式),不影响拼音的自动生成。拼音正误仍由 pinyin-pro 库决定。
const POLYPHONE_CHARS = new Set(['了','好','长','行','会','都','还','得','着','重','朝','便','传','弹','调','干','给','更','和','教','看','落','没','强','为','相','应','乐','中','种','只','几','处','少','正','发','省','空','倒','假','将','降','卷','量','难','曲','似','系','转','度','否','漂','刨','屏','奇','数','舍','盛','提','兴','饮','铺','模','泥','渐','嚼','卡','埋','露','俩','纶','率','么','秘','泌','磨','胖','炮','抢','呛','塞','散','厦','折','蛇','什','石','说','拓','尾','吓','鲜','纤','轧','攒','择','曾','粘','涨','爪','钻','薄','剥','参','差','臭','畜','撮','待','逮','单','担','弹','当','叨','倒','得','的','地','恶','发','佛','杆','葛','哈','汗','喝','核','横','哗','划','混','豁','几','济','纪','夹','戛','监','见','槛','将','角','结','解','禁','劲','据','卷','咯','拉','啦','乐','累','擂','俩','燎','淋','令','溜','笼','搂','陆','抡','论','捋','吗','埋','蔓','没','闷','蒙','抹','哪','那','呐','囊','拧','弄','疟','娜','排','迫','朴','栖','蹊','卡','铅','浅','呛','切','且','亲','曲','嚷','若','撒','塞','散','丧','扫','色','刹','煞','厦','扇','稍','舍','什','识','属','刷','遂','拓','苔','倘','趟','提','挑','帖','通','同','吐','驮','拓','哇','瓦','莞','唯','尉','遗','蔚','乌','吓','鲜','相','巷','削','校','芯','吁','旋','压','咽','沿','要','掖','饮','哟','予','与','晕','载','攒','择','扎','轧','粘','涨','爪','这','着','折','挣','症','只','轴','著','拽','转','幢','琢','仔','兹','作']);
function isHanzi(ch) { return /^[\u4e00-\u9fff\u3400-\u4dbf]$/.test(ch); }
function isChinesePunctuation(ch) { return /^[\u3000-\u303f\uff00-\uffef\u2000-\u206f]$/.test(ch); }
function escapeHTML(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; }
function escapeXML(str) { return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); }
function showToast(msg, type = '') {
toast.textContent = msg; toast.className = 'toast '+type+' show';
clearTimeout(toast._timeout);
toast._timeout = setTimeout(() => toast.classList.remove('show'), 1800);
}
function getPinyinArray(text) {
if (!text || typeof pinyinPro === 'undefined') return [];
try { return pinyinPro.pinyin(text, { type:'array', toneType:currentToneType, segment:true }); }
catch(e) { return pinyinPro.pinyin(text, { type:'array', toneType:currentToneType }); }
}
// ★核心修复:基于data-pinyin-index同步拼音到数组
function syncPinyinFromDOM() {
if (!isEditable || currentMode !== 'ruby') return;
const rtElements = outputArea.querySelectorAll('rt[data-pinyin-index]');
rtElements.forEach(rt => {
const idx = parseInt(rt.getAttribute('data-pinyin-index'), 10);
if (!isNaN(idx) && idx >= 0 && idx < currentPinyinArray.length) {
currentPinyinArray[idx] = rt.textContent.trim();
}
});
}
function renderOutput() {
const text = inputText.value;
charCount.textContent = (text.replace(/\s/g,'').length) + ' 字';
if (!text.trim()) {
outputArea.innerHTML = ''; hanziCount.textContent = '0 个汉字'; currentPinyinArray = []; return;
}
currentPinyinArray = getPinyinArray(text);
const pinyinArr = currentPinyinArray;
if (pinyinArr.length === 0) {
outputArea.innerHTML = '<span style="color:#b5a392;">拼音库加载中…</span>';
hanziCount.textContent = '0 个汉字'; return;
}
const chars = [...text];
let hanziTotal = 0;
if (currentMode === 'ruby') {
let html = ''; let i = 0;
for (const ch of chars) {
if (ch === '\n') { html += '<br>'; i++; continue; }
if (isHanzi(ch)) {
hanziTotal++;
const py = (i < pinyinArr.length) ? escapeHTML(pinyinArr[i]) : '';
const isPoly = POLYPHONE_CHARS.has(ch);
const editableAttr = isEditable ? ' contenteditable="true"' : '';
if (py && py !== ch) {
html += `<ruby${isPoly?' class="polyphone"':''}><rb>${escapeHTML(ch)}</rb><rt data-pinyin-index="${i}"${editableAttr}>${py}</rt></ruby>`;
} else {
html += escapeHTML(ch);
}
i++;
} else { html += escapeHTML(ch); i++; }
}
outputArea.innerHTML = html;
outputArea.classList.toggle('editable', isEditable);
attachEditEvents();
} else if (currentMode === 'bracket') {
let result = ''; let i = 0;
for (const ch of chars) {
if (ch === '\n') { result += '\n'; i++; continue; }
if (isHanzi(ch)) { hanziTotal++; const py = (i<pinyinArr.length)?pinyinArr[i]:''; result += (py&&py!==ch)?`${ch}(${py})`:ch; i++; }
else { result += ch; i++; }
}
outputArea.innerHTML = escapeHTML(result).replace(/\n/g,'<br>');
outputArea.classList.remove('editable');
} else if (currentMode === 'pinyin-only') {
let result = ''; let i = 0;
for (const ch of chars) {
if (ch === '\n') { result += '\n'; i++; continue; }
if (isHanzi(ch)) { hanziTotal++; const py=(i<pinyinArr.length)?pinyinArr[i]:ch; result += (py&&py!==ch)?py:ch; if(i+1<chars.length&&isHanzi(chars[i+1])) result+=' '; i++; }
else if (isChinesePunctuation(ch)) { result+=ch; i++; }
else { result+=ch; i++; }
}
outputArea.innerHTML = escapeHTML(result).replace(/\n/g,'<br>');
outputArea.classList.remove('editable');
}
hanziCount.textContent = hanziTotal + ' 个汉字';
}
function attachEditEvents() {
if (!isEditable || currentMode !== 'ruby') return;
const rtElements = outputArea.querySelectorAll('rt[contenteditable="true"]');
rtElements.forEach(rt => {
const newRt = rt.cloneNode(true);
rt.parentNode.replaceChild(newRt, rt);
newRt.addEventListener('input', function() {
const idx = parseInt(this.getAttribute('data-pinyin-index'), 10);
if (!isNaN(idx) && idx >= 0 && idx < currentPinyinArray.length) {
currentPinyinArray[idx] = this.textContent.trim();
}
});
newRt.addEventListener('blur', syncPinyinFromDOM);
});
}
function debouncedRender() { clearTimeout(debounceTimer); debounceTimer = setTimeout(renderOutput, DEBOUNCE_DELAY); }
function immediateRender() { clearTimeout(debounceTimer); renderOutput(); }
function toggleEditMode() {
isEditable = !isEditable;
btnEditToggle.classList.toggle('active', isEditable);
btnEditToggle.innerHTML = isEditable ? '✏️ 编辑中...' : '✏️ 编辑拼音';
editHint.textContent = isEditable ? '选中拼音文字,下方出现声调面板' : '点击 ❓ 查看使用帮助';
if (isEditable && currentMode !== 'ruby') {
currentMode = 'ruby';
document.querySelectorAll('.tool-btn[data-mode]').forEach(b => b.classList.remove('active'));
document.querySelector('.tool-btn[data-mode="ruby"]').classList.add('active');
}
if (!isEditable) { syncPinyinFromDOM(); hideTonePalette(); }
immediateRender();
}
// ★新增:声调面板控制逻辑
function hideTonePalette() {
tonePalette.classList.remove('visible');
tonePalette.style.left = '-9999px';
tonePalette.style.top = '-9999px';
}
function showTonePalette(selection) {
if (!selection.rangeCount) return;
const range = selection.getRangeAt(0);
if (!range || range.collapsed) { hideTonePalette(); return; }
// 确保选区在可编辑的rt内
let node = range.commonAncestorContainer;
let insideRt = false;
while (node && node !== outputArea) {
if (node.nodeName === 'RT' && node.isContentEditable) {
insideRt = true;
break;
}
node = node.parentNode;
}
if (!insideRt) { hideTonePalette(); return; }
const rect = range.getBoundingClientRect();
const outputRect = outputArea.getBoundingClientRect();
// 面板定位:选区底部向下偏移5px,水平居中
const paletteWidth = tonePalette.offsetWidth || 280;
let left = rect.left + rect.width/2 - paletteWidth/2 - outputRect.left;
let top = rect.bottom - outputRect.top + 8;
// 边界调整
if (left < 4) left = 4;
if (left + paletteWidth > outputArea.offsetWidth - 4) left = outputArea.offsetWidth - paletteWidth - 4;
if (top > outputArea.offsetHeight - 120) top = rect.top - outputRect.top - tonePalette.offsetHeight - 8;
tonePalette.style.left = left + 'px';
tonePalette.style.top = top + 'px';
tonePalette.classList.add('visible');
}
// 监听outputArea上的选区变化
outputArea.addEventListener('mouseup', function(e) {
if (!isEditable) return;
setTimeout(() => {
const sel = window.getSelection();
if (sel.rangeCount && !sel.isCollapsed) {
const range = sel.getRangeAt(0);
if (range.commonAncestorContainer && outputArea.contains(range.commonAncestorContainer)) {
showTonePalette(sel);
} else {
hideTonePalette();
}
} else {
hideTonePalette();
}
}, 10);
});
// 点击声调按钮替换选区文字
tonePalette.addEventListener('click', function(e) {
const btn = e.target.closest('.tone-btn');
if (!btn) return;
const char = btn.getAttribute('data-char');
if (!char) return;
const sel = window.getSelection();
if (sel.rangeCount && !sel.isCollapsed) {
const range = sel.getRangeAt(0);
// 确保在rt内
let node = range.commonAncestorContainer;
while (node && node !== outputArea) {
if (node.nodeName === 'RT' && node.isContentEditable) break;
node = node.parentNode;
}
if (node && node.nodeName === 'RT') {
range.deleteContents();
range.insertNode(document.createTextNode(char));
range.collapse(false);
// 触发input事件以同步数组
node.dispatchEvent(new Event('input', { bubbles: true }));
hideTonePalette();
}
}
});
// 点击“移除声调”按钮
btnClearTone.addEventListener('click', function() {
const sel = window.getSelection();
if (!sel.rangeCount || sel.isCollapsed) return;
const range = sel.getRangeAt(0);
let node = range.commonAncestorContainer;
while (node && node !== outputArea) {
if (node.nodeName === 'RT' && node.isContentEditable) break;
node = node.parentNode;
}
if (!node || node.nodeName !== 'RT') return;
// 获取选中文本,去除声调(转为纯拉丁字母)
const selectedText = range.toString();
// 简单映射:将带调字母转为无调字母
const toneMap = { 'ā':'a','á':'a','ǎ':'a','à':'a', 'ē':'e','é':'e','ě':'e','è':'e', 'ī':'i','í':'i','ǐ':'i','ì':'i', 'ō':'o','ó':'o','ǒ':'o','ò':'o', 'ū':'u','ú':'u','ǔ':'u','ù':'u', 'ǖ':'ü','ǘ':'ü','ǚ':'ü','ǜ':'ü' };
let plain = '';
for (const c of selectedText) {
plain += toneMap[c] || c;
}
range.deleteContents();
range.insertNode(document.createTextNode(plain));
range.collapse(false);
node.dispatchEvent(new Event('input', { bubbles: true }));
hideTonePalette();
});
// 点击其他区域隐藏面板
document.addEventListener('mousedown', function(e) {
if (!tonePalette.classList.contains('visible')) return;
if (!tonePalette.contains(e.target) && !outputArea.contains(e.target)) {
hideTonePalette();
}
});
// 初始隐藏面板
hideTonePalette();
// 其余复制、导出等函数(保持原逻辑,仅调整复制时同步)
async function copyToClipboard(text, msg) {
if (!text) { showToast('没有可复制的内容'); return; }
try { await navigator.clipboard.writeText(text); showToast(msg||'已复制 ✓','success'); }
catch {
const ta = document.createElement('textarea'); ta.value=text; ta.style.position='fixed'; ta.style.opacity='0';
document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta);
showToast(msg||'已复制 ✓','success');
}
}
function buildWordDocumentXML(text, pinyinArr) {
const chars = [...text];
let bodyXML = '', runs = '', i = 0;
for (const ch of chars) {
if (ch === '\n') { if(runs.trim()) { bodyXML+=`<w:p><w:pPr><w:jc w:val="left"/></w:pPr>${runs}</w:p>`; runs=''; } else { bodyXML+='<w:p><w:pPr><w:jc w:val="left"/></w:pPr><w:r><w:t xml:space="preserve"> </w:t></w:r></w:p>'; } i++; continue; }
if (isHanzi(ch)) {
const py = (i < pinyinArr.length) ? pinyinArr[i] : '';
if (py && py !== ch) {
/* ★用户可调整 Word 拼音参数(单位均为半磅):
hps: 拼音字号 (16 = 8磅)
hpsRaise: 拼音垂直提升距离 (22 = 11磅)
hpsBaseText: 汉字字号 (22 = 11磅) */
runs += `<w:r>
<w:ruby>
<w:rubyPr>
<w:rubyAlign w:val="center"/>
<w:hps w:val="16"/>
<w:hpsRaise w:val="22"/>
<w:hpsBaseText w:val="22"/>
</w:rubyPr>
<w:rt><w:r><w:rPr><w:sz w:val="16"/></w:rPr><w:t xml:space="preserve">${escapeXML(py)}</w:t></w:r></w:rt>
<w:rubyBase><w:r><w:rPr><w:sz w:val="22"/></w:rPr><w:t xml:space="preserve">${escapeXML(ch)}</w:t></w:r></w:rubyBase>
</w:ruby>
</w:r>`;
} else { runs += `<w:r><w:rPr><w:sz w:val="22"/></w:rPr><w:t xml:space="preserve">${escapeXML(ch)}</w:t></w:r>`; }
i++;
} else { runs += `<w:r><w:rPr><w:sz w:val="22"/></w:rPr><w:t xml:space="preserve">${escapeXML(ch)}</w:t></w:r>`; i++; }
}
if (runs.trim()) bodyXML += `<w:p><w:pPr><w:jc w:val="left"/></w:pPr>${runs}</w:p>`;
return bodyXML;
}
async function exportToWord() {
const text = inputText.value;
if (!text.trim()) { showToast('请先输入汉字文本'); return; }
if (isEditable && currentMode==='ruby') syncPinyinFromDOM();
const arr = currentPinyinArray.length ? currentPinyinArray : getPinyinArray(text);
if (!arr.length) { showToast('拼音转换失败'); return; }
showToast('正在生成 Word 文档…','success');
const body = buildWordDocumentXML(text, arr);
const docXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${body}</w:body></w:document>`;
const zip = new JSZip();
zip.file('[Content_Types].xml', `<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>`);
zip.file('_rels/.rels', `<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>`);
zip.file('word/_rels/document.xml.rels', `<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`);
zip.file('word/document.xml', docXml);
const blob = await zip.generateAsync({type:'blob'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href=url; a.download='拼音标注.docx'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url);
showToast('Word 文档已导出 ✓','success');
}
// 事件绑定
inputText.addEventListener('input', debouncedRender);
inputText.addEventListener('paste', ()=>{ clearTimeout(debounceTimer); debounceTimer=setTimeout(renderOutput,100); });
document.querySelectorAll('.tool-btn[data-mode]').forEach(b => b.addEventListener('click', function(){
document.querySelectorAll('.tool-btn[data-mode]').forEach(x=>x.classList.remove('active'));
this.classList.add('active'); currentMode=this.dataset.mode;
if (currentMode!=='ruby' && isEditable) toggleEditMode();
immediateRender();
}));
document.querySelectorAll('.tool-btn[data-tone]').forEach(b => b.addEventListener('click', function(){
document.querySelectorAll('.tool-btn[data-tone]').forEach(x=>x.classList.remove('active'));
this.classList.add('active'); currentToneType=this.dataset.tone; immediateRender();
}));
btnEditToggle.addEventListener('click', toggleEditMode);
btnSample.addEventListener('click', ()=>{
const samples = ['你好,世界!欢迎使用汉字拼音标注工具。','春天来了,花儿开了,鸟儿在枝头欢唱。','学而时习之,不亦说乎?有朋自远方来,不亦乐乎?','长城是中华民族的骄傲,也是世界文化遗产。'];
inputText.value = samples[Math.floor(Math.random()*samples.length)]; immediateRender(); showToast('已填入示例 ✓','success');
});
btnClear.addEventListener('click', ()=>{
inputText.value=''; outputArea.innerHTML=''; charCount.textContent='0 字'; hanziCount.textContent='0 个汉字'; currentPinyinArray=[]; showToast('已清空 ✓','success');
});
btnCopyRuby.addEventListener('click', ()=>{
if (isEditable) syncPinyinFromDOM();
const arr = currentPinyinArray.length ? currentPinyinArray : getPinyinArray(inputText.value);
let html=''; let i=0;
for(const ch of [...inputText.value]) {
if(ch==='\n'){html+='<br>\n';i++;continue;}
if(isHanzi(ch)){const py=(i<arr.length)?arr[i]:''; html+=(py&&py!==ch)?`<ruby>${escapeHTML(ch)}<rt>${py}</rt></ruby>`:escapeHTML(ch); i++;}
else{html+=escapeHTML(ch);i++;}
}
copyToClipboard(html,'Ruby HTML 已复制 ✓');
});
btnCopyBracket.addEventListener('click', ()=>{
if(isEditable) syncPinyinFromDOM();
const arr = currentPinyinArray.length ? currentPinyinArray : getPinyinArray(inputText.value);
let res=''; let i=0;
for(const ch of [...inputText.value]) {
if(ch==='\n'){res+='\n';i++;continue;}
if(isHanzi(ch)){const py=(i<arr.length)?arr[i]:''; res+=(py&&py!==ch)?`${ch}(${py})`:ch; i++;}
else{res+=ch;i++;}
}
copyToClipboard(res,'括号标注已复制 ✓');
});
btnCopyPinyin.addEventListener('click', ()=>{
if(isEditable) syncPinyinFromDOM();
const chars=[...inputText.value]; const arr=currentPinyinArray.length?currentPinyinArray:getPinyinArray(inputText.value);
let res=''; let i=0;
for(const ch of chars){
if(ch==='\n'){res+='\n';i++;continue;}
if(isHanzi(ch)){const py=(i<arr.length)?arr[i]:ch; res+=(py&&py!==ch)?py:ch; if(i+1<chars.length&&isHanzi(chars[i+1])) res+=' '; i++;}
else if(isChinesePunctuation(ch)){res+=ch;i++;}
else{res+=ch;i++;}
}
copyToClipboard(res,'纯拼音已复制 ✓');
});
btnExportWord.addEventListener('click', exportToWord);
btnHelp.addEventListener('click', ()=>{ helpModal.classList.add('active'); });
closeHelp.addEventListener('click', ()=>{ helpModal.classList.remove('active'); });
helpModal.addEventListener('click', (e)=>{ if(e.target===helpModal) helpModal.classList.remove('active'); });
function init() {
if(typeof pinyinPro==='undefined') {
outputArea.innerHTML='<span style="color:#c4976b;">⏳ 正在加载拼音库…</span>';
let attempts=0;
const check=setInterval(()=>{
attempts++;
if(typeof pinyinPro!=='undefined'){clearInterval(check); if(inputText.value.trim()) renderOutput();}
else if(attempts>30){clearInterval(check); outputArea.innerHTML='<span style="color:#b8452e;">⚠️ 拼音库加载失败,请刷新</span>';}
},300);
} else if(inputText.value.trim()) renderOutput();
}
init();
})();
</script>
</body>
</html>
附录:
早期一个写过一个不太成熟的版本“用HTML5+JavaScript实现汉字转拼音工具”见 https://blog.csdn.net/cnds123/article/details/148092017
另外:
HTML版英语学习系统 https://blog.csdn.net/cnds123/article/details/148353183
在线汉字笔画练习工具(HTML文件版)https://blog.csdn.net/cnds123/article/details/148385687

1359

被折叠的 条评论
为什么被折叠?



