taigi-sandhi-visualization

Highlights tone sandhi changes in Taiwanese romanization on the MOE dictionary site. Changed tones are marked in red with a tooltip showing possible base tone → sandhi tone.

// ==UserScript==
// @name        taigi-sandhi-visualization
// @namespace   hey0wing
// @version     1.2
// @description Highlights tone sandhi changes in Taiwanese romanization on the MOE dictionary site. Changed tones are marked in red with a tooltip showing possible base tone → sandhi tone.
// @author      hey0wing
// @match       https://sutian.moe.edu.tw/*
// @run-at      document-idle
// @grant       none
// @license     MIT
// ==/UserScript==

(() => {
    'use strict';
    
    const sandhi_diagram_red = `
        <svg id="sandhi_red" width="250" height="150" xmlns="http://www.w3.org/2000/svg">
            <!-- Grid of numbers -->
            <text id="1" x="25" y="25" font-size="12" text-anchor="middle" alignment-baseline="central">1</text>
            <text id="2" x="125" y="25" font-size="12" text-anchor="middle" alignment-baseline="central">2</text>
            <text id="4" x="225" y="25" font-size="12" text-anchor="middle" alignment-baseline="central">4</text>
            <text id="5" x="75" y="75" font-size="12" text-anchor="middle" alignment-baseline="central">5</text>
            <text id="7" x="25" y="125" font-size="12" text-anchor="middle" alignment-baseline="central">7</text>
            <text id="3" x="125" y="125" font-size="12" text-anchor="middle" alignment-baseline="central">3</text>
            <text id="8" x="225" y="125" font-size="12" text-anchor="middle" alignment-baseline="central">8</text>

            <text id="4h" x="175" y="15" font-size="12" text-anchor="middle" alignment-baseline="central">-h</text>
            <text id="8h" x="175" y="115" font-size="12" text-anchor="middle" alignment-baseline="central">-h</text>
            <text id="ptk" x="205" y="75" font-size="12" text-anchor="middle" alignment-baseline="central">-p,t,k</text>

            <!-- Horizontal arrows -->
            <path id="2_1" d="M115 25 H35" stroke="black" stroke-width="2" marker-end="url(#arrow)"/>
            <path id="4_2" d="M215 25 H135" stroke="black" stroke-width="2" marker-end="url(#arrow)"/>
            <path id="7_3" d="M35 125 H115" stroke="black" stroke-width="2" marker-end="url(#arrow)"/>
            <path id="8_3" d="M215 125 H135" stroke="black" stroke-width="2" marker-end="url(#arrow)"/>

            <!-- Vertical arrows -->
            <path id="1_7" d="M25 35 V115" stroke="black" stroke-width="2" marker-end="url(#arrow)"/>
            <path id="3_2" d="M125 115 V35" stroke="black" stroke-width="2" marker-end="url(#arrow)"/>
            <path id="4_8" d="M225 35 V115" stroke="black" stroke-width="2" marker-end="url(#arrow)"/>
            <path id="8_4" d="M225 115 V35" stroke="black" stroke-width="2" marker-end="url(#arrow)"/>

            <!-- Diagonal arrows -->
            <path id="5_7" d="M70 85 L30 120" stroke="black" stroke-width="2" marker-end="url(#arrow)"/>
            <path id="5_3" d="M80 85 L120 120" stroke="black" stroke-width="2" marker-end="url(#arrow)"/>

            <!-- arrow definition -->
            <defs>
                <marker id="arrow" markerWidth="6" markerHeight="4" refX="5" refY="2" orient="auto">
                    <polygon points="0 0, 6 2, 0 4" fill="black"/>
                </marker>
                <marker id="arrow_red" markerWidth="6" markerHeight="4" refX="5" refY="2" orient="auto">
                    <polygon points="0 0, 6 2, 0 4" fill="red"/>
                </marker>
            </defs>
        </svg>
    `
    
    const sandhi_diagram_blue = `
        <svg id="sandhi_blue" width="250" height="150" xmlns="http://www.w3.org/2000/svg">
            <!-- Grid of numbers -->
            <text id="2,3" x="25" y="25" font-size="12" text-anchor="middle" alignment-baseline="central">2,3</text>
            <text id="1" x="125" y="25" font-size="12" text-anchor="middle" alignment-baseline="central">1</text>
            <text id="4" x="225" y="25" font-size="12" text-anchor="middle" alignment-baseline="central">4</text>
            <text id="5" x="25" y="125" font-size="12" text-anchor="middle" alignment-baseline="central">5</text>
            <text id="7" x="125" y="125" font-size="12" text-anchor="middle" alignment-baseline="central">7</text>
            <text id="8" x="225" y="125" font-size="12" text-anchor="middle" alignment-baseline="central">8</text>

            <text id="4h" x="175" y="15" font-size="12" text-anchor="middle" alignment-baseline="central">-h</text>
            <text id="8h" x="175" y="115" font-size="12" text-anchor="middle" alignment-baseline="central">-h</text>
            <text id="ptk" x="205" y="75" font-size="12" text-anchor="middle" alignment-baseline="central">-p,t,k</text>

            <!-- Horizontal arrows -->
            <path id="2,3_1" d="M40 25 H115" stroke="black" stroke-width="2" marker-end="url(#arrow)"/>
            <path id="4_1" d="M215 25 H135" stroke="black" stroke-width="2" marker-end="url(#arrow)"/>
            <path id="5_7" d="M35 125 H115" stroke="black" stroke-width="2" marker-end="url(#arrow)"/>
            <path id="8_7" d="M215 125 H135" stroke="black" stroke-width="2" marker-end="url(#arrow)"/>

            <!-- Vertical arrows -->
            <path id="1_7" d="M125 35 V115" stroke="black" stroke-width="2" marker-end="url(#arrow)"/>
            <path id="4_8" d="M225 35 V115" stroke="black" stroke-width="2" marker-end="url(#arrow)"/>
            <path id="8_4" d="M225 115 V35" stroke="black" stroke-width="2" marker-end="url(#arrow)"/>

            <!-- arrow definition -->
            <defs>
                <marker id="arrow" markerWidth="6" markerHeight="4" refX="5" refY="2" orient="auto">
                    <polygon points="0 0, 6 2, 0 4" fill="black"/>
                </marker>
                <marker id="arrow_blue" markerWidth="6" markerHeight="4" refX="5" refY="2" orient="auto">
                    <polygon points="0 0, 6 2, 0 4" fill="blue"/>
                </marker>
            </defs>
        </svg>
    `

    let sandhi_isH = {
        // suffix === á
        true:   { 8: 5, 4: 2 },
        // suffix !== á
        false:  { 8: 3, 4: 2 },
    }
    let sandhi_map = {
        // suffix === á
        true:   { 1: 7, 2: 1, 3: 1, 5: 7, 7: 7, 4: 8, 8: 4 },
        // suffix !== á
        false:  { 1: 7, 2: 1, 3: 2, 5: 7, 7: 3, 4: 8, 8: 4 },
    }

    // Function to get the tone number from a syllable
    function getTone({syllable='', sandhi='', suffix='', neutral=null}) {
        const isChecked = /[pthk]\.?$/.test(syllable);
        const isH = /[h]$/.test(syllable);
        const normalized = syllable.normalize('NFD');
        
        let tone = null;
        for (let i = 0; i < normalized.length; i++) {
            const code = normalized.charCodeAt(i);
            if (code >= 0x0300 && code <= 0x036F) { // Combining diacritics
                tone =  code === 0x0301 ? 2 :
                        code === 0x0300 ? 3 :
                        code === 0x0302 ? 5 :
                        code === 0x0304 ? 7 :
                        code === 0x030D ? 8 :
                        code === 0x030C ? 6 :
                        code === 0x030B && 9
            }
        }
        tone ??= isChecked ? 4 : 1

        if (neutral=='before') return { tone: tone, sandhi: null, color: 'green', display: tone }
        if (neutral=='after') return { tone: 0, sandhi: null, color: 'green', display: 0 }

        let sandhi_t = (tone != 6 && tone != 9 && sandhi) ? (isH ? sandhi_isH[suffix == 'á'][tone] : sandhi_map[suffix == 'á'][tone]) : null
        return {
            tone: tone,
            sandhi: sandhi_t,
            color: sandhi_t ? (suffix == 'á' ? 'blue': 'red') : null,
            display: sandhi_t ? sandhi_t : tone,
        }
    }

    // Modified from https://github.com/andreihar/taibun.js
    function isCjk(input) {
        return [...input].some(char => {
            const code = char.codePointAt(0);
            return (
                (0x4E00 <= code && code <= 0x9FFF) ||  // BASIC
                (0x3400 <= code && code <= 0x4DBF) ||  // Ext A
                (0x20000 <= code && code <= 0x2A6DF) ||  // Ext B
                (0x2A700 <= code && code <= 0x2EBEF) ||  // Ext C,D,E,F
                (0x30000 <= code && code <= 0x323AF) ||  // Ext G,H
                (0x2EBF0 <= code && code <= 0x2EE5F)  // Ext I
            );
        });
    }

    function highlightSandhi(text) {
        const words = text.replace('/',' / ').split(/\s+/);
        return `
        <div class="d-flex flex-row flex-wrap align-items-end">
            ${words.map((v1, i) => {
                let w1 = v1.split('--');
                return w1.map((v2, j) => {
                    let word = v2.split('-');
                    return word.map((v3, k) => {
                        if (v3 === '/') return '<div>/</div>';
                        let tone;
                        if (k === word.length - 1 && j !== w1.length - 1) {
                            // Word before 輕聲 neutral tone
                            tone = getTone({ syllable: v3, neutral: 'before' });
                        } else if (k === 0 && j !== 0) {
                            // Word after 輕聲 neutral tone
                            tone = getTone({ syllable: v3, neutral: 'after' });
                        } else if (word.length === 1 && i !== words.length - 1 && ![',','.','!'].some(x => v3.includes(x))) {
                            // Monosyllabic and not the final word
                            tone = getTone({ syllable: v3, sandhi: true, suffix: words[i + 1] });
                        } else {
                            tone = getTone({ syllable: v3, sandhi: k !== word.length - 1, suffix: word[k + 1] });
                        }
                        return `
                            <div>
                                <div class="syllable-cell ${tone.color}" 
                                    data-color="${tone.color}" 
                                    data-tone="${tone.tone}" 
                                    data-sandhi="${tone.sandhi}">
                                    ${tone.display}
                                </div>
                                <div>${v3}</div>
                            </div>
                        `;
                    }).join('<div>-</div>');
                }).join('<div>--</div>');
            }).join('&nbsp;')}
        </div>`
    }

    function processPage(node) {
        if (node.nodeType === Node.TEXT_NODE) {
            let parent = node.parentNode;
            while (parent) {
                parent = parent.parentNode;
            }
            let text = node.nodeValue.trim();
            if (text && /[\-āáàâǎa̍ēéèêěe̍īíìîǐi̍ōóòôǒo̍ūúùûǔu̍͘]/.test(text) && !isCjk(text)) {
                const div = document.createElement('div');
                div.innerHTML = highlightSandhi(text);
                node.parentNode.replaceChild(div, node);
            }
        } else if (node.nodeType === Node.ELEMENT_NODE) {
            if (node.tagName === 'UL' && ['fs-4', 'fw-bold', 'list-inline'].every(c => node.classList.contains(c))) {
                replaceUL(node);
            } else {
                for (let i = 0; i < node.childNodes.length; i++) {
                    processPage(node.childNodes[i]);
                }
            }
        }
        return true
    }

    function replaceUL(node) {
        // Get all span texts from li children and join with "/"
        let spanTexts = Array.from(node.querySelectorAll('li'))
            .map(li => li.querySelector('span')?.textContent || '')
            .filter(text => text)
            .join('/');
        node.querySelectorAll('span').forEach(span => span.remove());

        node.parentNode.classList.remove('align-items-baseline');
        node.parentNode.classList.add('align-items-end');
        node.lastChild.classList.remove('slash-divider');
        const div = document.createElement('li');
        div.innerHTML = highlightSandhi(spanTexts);
        div.classList.add('list-inline-item');
        node.insertBefore(div, node.firstChild);
    }
    
    const style = document.createElement('style');
    style.textContent = `
        .custom-tooltip {
            display: none;
            position: absolute;
            background-color: white;
            padding: 5px 10px;
            border: 4px solid black;
            border-radius: 4px;
            font-size: 12px;
            z-index: 100;
        }
        .syllable-cell {
            font-size: .8rem;
            text-align: center;
        }
        .syllable-cell.red {
            color: red;
            font-weight: 700;
        }
        .syllable-cell.blue {
            color: blue;
            font-weight: 700;
        }
        .syllable-cell.green {
            color: green;
            font-weight: 700;
        }
        .syllable-cell.red:hover, .syllable-cell.blue:hover {
            cursor: pointer;
            background-color: #f0f0f0;
        }
    `;
    document.head.appendChild(style);

    const tooltip = document.createElement('div');
    tooltip.id = 'custom-tooltip';
    tooltip.className = 'custom-tooltip';
    document.body.appendChild(tooltip);

    document.addEventListener('click', (e) => {
        const tooltip = document.getElementById('custom-tooltip');
        if (e.target.classList.contains('syllable-cell') && e.target.dataset.sandhi != 'null') {
            tooltip.style.display = 'block';
            tooltip.innerHTML = e.target.dataset.color == 'red' ? sandhi_diagram_red : sandhi_diagram_blue
            
            var id = `${e.target.dataset.tone}_${e.target.dataset.sandhi}`;
            if (id == '4_8') document.getElementById('8_4').remove();
            if (id == '8_4') document.getElementById('4_8').remove();
            if (['2_1', '3_1'].includes(id) && e.target.dataset.color == 'blue') id = '2,3_1'

            if (id == '7_7') {
                const text = document.getElementById('7');
                text.setAttribute('fill', e.target.dataset.color);
                text.setAttribute('font-size', 16);
            } else {
                const path = document.getElementById(id);
                path.setAttribute('stroke', e.target.dataset.color);
                path.setAttribute('marker-end', `url(#arrow_${e.target.dataset.color})`);
            }

            const rect = e.target.getBoundingClientRect();
            let left = rect.left + window.scrollX + rect.width / 2 - tooltip.offsetWidth / 2;
            let top = rect.top + window.scrollY - tooltip.offsetHeight - 10;

            left = Math.max(0, Math.min(left, window.innerWidth - tooltip.offsetWidth));
            top = top < 0 ? rect.top + window.scrollY + rect.height + 10 : top;

            tooltip.style.left = `${left}px`;
            tooltip.style.top = `${top}px`;
        } else {
            tooltip.style.display = 'none';
            tooltip.innerHTML = ''
        }
    });

    // Run initially and observe for changes
    console.log("taigi-sandhi-visualization")
    if (processPage(document.getElementsByTagName('main')[0])) {
        console.log('done')
    }
    const observer = new MutationObserver((mutations) => {
        mutations.forEach((mutation) => {
            mutation.addedNodes.forEach((node) => {
                if (node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.TEXT_NODE) {
                    if (processPage(node)) {
                        console.log('done')
                    }
                }
            });
        });
    });
    observer.observe(document.getElementsByTagName('main')[0], { childList: true, subtree: true });
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址