HTML+CSS+JavaScript实现的好用的汉字拼音标注工具

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">&times;</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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&apos;'); }
        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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

学习&认知实践爱好者

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值