From b63c00a5563f82f6a75c3a2b94e5c51da9ff5c57 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 01:14:12 +0000 Subject: [PATCH] feat: implement generalized sandhi engine and ruby display in Romanizer - Introduced `js/sandhi.js` as a centralized tone sandhi engine for Hakka dialects. - Updated the Romanizer to display tone sandhi results using `` tags. - Refactored `main.js` to use the new sandhi engine for dictionary search results. - Enhanced Romanizer logic to correctly handle manual capitalization and joining rules for syllables wrapped in `` tags. - Added styling for sandhi-applied ruby elements in the Romanizer tool. - Normalized dialect name handling within the sandhi engine. Co-authored-by: Aiuanyu <93833368+Aiuanyu@users.noreply.github.com> --- index.html | 1 + js/romanizer.js | 125 +++++++++++++++++++++++++++----- js/sandhi.js | 186 ++++++++++++++++++++++++++++++++++++++++++++++++ main.js | 152 ++++----------------------------------- style.css | 1 + 5 files changed, 307 insertions(+), 158 deletions(-) create mode 100644 js/sandhi.js diff --git a/index.html b/index.html index bf1628c..b848fc8 100644 --- a/index.html +++ b/index.html @@ -25,6 +25,7 @@ + diff --git a/js/romanizer.js b/js/romanizer.js index 5a58fe8..f5091b1 100644 --- a/js/romanizer.js +++ b/js/romanizer.js @@ -95,17 +95,33 @@ document.addEventListener('DOMContentLoaded', () => { const wrapper = uppercaseBtn.parentNode; // 【新】為這粒音節加上手動設定个標籤 wrapper.dataset.manualCase = 'true'; + + // --- Refactored to handle ruby elements --- + const handleTextNode = (node) => { + const currentText = node.textContent; + if (currentText && currentText.length > 0) { + if (currentText[0] === currentText[0].toUpperCase()) { + node.textContent = currentText.toLowerCase(); + } else { + node.textContent = currentText.charAt(0).toUpperCase() + currentText.slice(1); + } + } + }; + const textNode = Array.from(wrapper.childNodes).find(n => n.nodeType === Node.TEXT_NODE); + const rubyElement = wrapper.querySelector('ruby'); + if (textNode) { - const currentText = textNode.textContent; - if (currentText[0] === currentText[0].toUpperCase()) { - textNode.textContent = currentText.toLowerCase(); - } else { - textNode.textContent = currentText.charAt(0).toUpperCase() + currentText.slice(1); + handleTextNode(textNode); + } else if (rubyElement) { + // If it's a ruby, the first child node should be the text node of the base text + const rubyTextNode = Array.from(rubyElement.childNodes).find(n => n.nodeType === Node.TEXT_NODE); + if (rubyTextNode) { + handleTextNode(rubyTextNode); } - // 手動調整後,毋使呼叫 applyCapitalizationRules,直接儲存狀態 - saveState(); } + // 手動調整後,毋使呼叫 applyCapitalizationRules,直接儲存狀態 + saveState(); } else if (deleteBtn) { const wrapper = deleteBtn.parentNode; @@ -498,8 +514,18 @@ document.addEventListener('DOMContentLoaded', () => { function populateSegmentGroup(container, romanizedText, sourceSpan) { container.innerHTML = ''; const tokens = tokenizeRomanization(romanizedText); + + // --- Apply Sandhi if engine is available --- + let sandhiResults = []; + if (typeof HakkaSandhi !== 'undefined') { + sandhiResults = HakkaSandhi.applyToTokens(tokens, romanizerSelectedDialect); + } else { + sandhiResults = tokens.map(t => ({ text: t, sandhi: null })); + } - tokens.forEach(token => { + sandhiResults.forEach(res => { + const token = res.text || (res.variants && res.variants.map(v => v.text).join('/')); + if (!/[【】()()\/]/.test(token)) { // 【新邏輯】淨產生音節个 wrapper,毋產生連接符號 const wrapper = document.createElement('span'); @@ -510,7 +536,36 @@ document.addEventListener('DOMContentLoaded', () => { if (sourceSpan.dataset.noCapitalize) { wrapper.dataset.noCapitalize = 'true'; } - wrapper.appendChild(document.createTextNode(token)); + + // --- Render Sandhi with Ruby --- + if (res.variants) { + res.variants.forEach((v, idx) => { + if (v.newTone) { + const ruby = document.createElement('ruby'); + ruby.className = v.rubyClass; + ruby.textContent = v.text; + const rt = document.createElement('rt'); + rt.textContent = v.newTone; + ruby.appendChild(rt); + wrapper.appendChild(ruby); + } else { + wrapper.appendChild(document.createTextNode(v.text)); + } + if (idx < res.variants.length - 1) { + wrapper.appendChild(document.createTextNode('/')); + } + }); + } else if (res.newTone) { + const ruby = document.createElement('ruby'); + ruby.className = res.rubyClass; + ruby.textContent = res.text; + const rt = document.createElement('rt'); + rt.textContent = res.newTone; + ruby.appendChild(rt); + wrapper.appendChild(ruby); + } else { + wrapper.appendChild(document.createTextNode(token)); + } const uppercaseBtn = document.createElement('button'); uppercaseBtn.className = 'uppercase-btn'; @@ -545,11 +600,23 @@ document.addEventListener('DOMContentLoaded', () => { if (wrapper.dataset.manualCase) { return; } + + const handleLowering = (node) => { + const currentText = node.textContent; + if (currentText && currentText.length > 0) { + node.textContent = currentText.charAt(0).toLowerCase() + currentText.slice(1); + } + }; + const textNode = Array.from(wrapper.childNodes).find(n => n.nodeType === Node.TEXT_NODE); + const rubyElement = wrapper.querySelector('ruby'); + if (textNode) { - const currentText = textNode.textContent; - if (currentText && currentText.length > 0) { - textNode.textContent = currentText.charAt(0).toLowerCase() + currentText.slice(1); + handleLowering(textNode); + } else if (rubyElement) { + const rubyTextNode = Array.from(rubyElement.childNodes).find(n => n.nodeType === Node.TEXT_NODE); + if (rubyTextNode) { + handleLowering(rubyTextNode); } } }); @@ -575,10 +642,21 @@ document.addEventListener('DOMContentLoaded', () => { if (firstSyllableInGroup) { // 【新】愛確定無手動標籤,正做自動大寫 if (capitalizeNext && !firstSyllableInGroup.dataset.manualCase && !firstSyllableInGroup.dataset.noCapitalize) { - const textNode = Array.from(firstSyllableInGroup.childNodes).find(n => n.nodeType === Node.TEXT_NODE); - if (textNode) { + const handleUpper = (textNode) => { const text = textNode.textContent; textNode.textContent = text.charAt(0).toUpperCase() + text.slice(1); + }; + + const textNode = Array.from(firstSyllableInGroup.childNodes).find(n => n.nodeType === Node.TEXT_NODE); + const rubyElement = firstSyllableInGroup.querySelector('ruby'); + + if (textNode) { + handleUpper(textNode); + } else if (rubyElement) { + const rubyTextNode = Array.from(rubyElement.childNodes).find(n => n.nodeType === Node.TEXT_NODE); + if (rubyTextNode) { + handleUpper(rubyTextNode); + } } } capitalizeNext = false; @@ -660,12 +738,21 @@ function updateJoiningSeparators() { // 若係無障礙物,正插入連接符號 if (!hasObstacle) { - const s1TextNode = Array.from(s1.childNodes).find(n => n.nodeType === Node.TEXT_NODE); - const s2TextNode = Array.from(s2.childNodes).find(n => n.nodeType === Node.TEXT_NODE); + const getSyllableText = (syllableEl) => { + const textNode = Array.from(syllableEl.childNodes).find(n => n.nodeType === Node.TEXT_NODE); + if (textNode) return textNode.textContent.toLowerCase(); + const ruby = syllableEl.querySelector('ruby'); + if (ruby) { + const rubyTextNode = Array.from(ruby.childNodes).find(n => n.nodeType === Node.TEXT_NODE); + if (rubyTextNode) return rubyTextNode.textContent.toLowerCase(); + } + return ''; + }; + + const s1Text = getSyllableText(s1); + const s2Text = getSyllableText(s2); - if (s1TextNode && s2TextNode) { - const s1Text = s1TextNode.textContent.toLowerCase(); - const s2Text = s2TextNode.textContent.toLowerCase(); + if (s1Text && s2Text) { let separator = ''; switch (romanizerJoiningMode) { diff --git a/js/sandhi.js b/js/sandhi.js new file mode 100644 index 0000000..c41e4e0 --- /dev/null +++ b/js/sandhi.js @@ -0,0 +1,186 @@ +/** + * HakSpring Sandhi Engine + * Generalized framework for Hakka tone sandhi rules. + */ + +const HakkaSandhi = (() => { + const BLOCKING_PUNCTUATION = '()()【】'; + const SKIPPABLE_PUNCTUATION = '\\s、'; + const ALL_PUNCTUATION_CHARS = SKIPPABLE_PUNCTUATION + BLOCKING_PUNCTUATION; + + const TOKENIZER_REGEX = new RegExp( + `[^<>` + + ALL_PUNCTUATION_CHARS + + `]+|[${SKIPPABLE_PUNCTUATION}]+|[${BLOCKING_PUNCTUATION}]+`, + 'g' + ); + const SKIPPABLE_REGEX = new RegExp(`^[${SKIPPABLE_PUNCTUATION}]+$`); + const BLOCKING_REGEX = new RegExp(`^[${BLOCKING_PUNCTUATION}]+$`); + + /** + * Defines sandhi rules for different dialects. + * Each dialect has an array of rules. + * A rule is a function: (currentSyllable, nextSyllable) => { newTone, rubyClass } | null + */ + const rules = { + '大埔': [ + // Rule 1: 31/53 + (31, 11, 33, 53, 2) -> 55 (高降變) + // Note: Dapu 31 is circumflex [âêîôû], while some other dialects/notations might use grave [àèìòù]. + (current, next) => { + if (current.match(/[àèìòùâêîôû](?![bdg])/) && next.match(/[àèìòùâêîôûǎěǐǒǔāēīōū]/)) { + return { newTone: '55', rubyClass: 'sandhi-高降變' }; + } + return null; + }, + // Rule 2: 33 + (113, 21, 53, 31, 35) -> 35 (中平變) + (current, next) => { + if (current.match(/[āēīōū]/) && next.match(/[ǎěǐǒǔâêîôûàèìòùáéíóú]/)) { + return { newTone: '35', rubyClass: 'sandhi-中平變' }; + } + return null; + }, + // Rule 3: 113 + 113 -> 33 (低升變) + (current, next) => { + if (current.match(/[ǎěǐǒǔ]/) && next.match(/[ǎěǐǒǔ]/)) { + return { newTone: '33', rubyClass: 'sandhi-低升變' }; + } + return null; + } + ] + // Other dialects can be added here + }; + + /** + * Applies sandhi rules to a single syllable given the next one. + */ + function applyRule(syllable, nextSyllable, dialect) { + const dialectRules = rules[dialect] || []; + for (const rule of dialectRules) { + const result = rule(syllable, nextSyllable); + if (result) return result; + } + return null; + } + + /** + * Processes a list of tokens and applies sandhi. + * Tokens can be syllables, punctuation, or spaces. + */ + function applyToTokens(tokens, dialectName) { + const dialect = dialectName ? dialectName.replace('教典', '') : ''; + const results = []; + for (let i = 0; i < tokens.length; i++) { + let currentToken = tokens[i]; + + // Skip non-syllables or already processed ruby tags + if ( + currentToken.startsWith(' { + const ruleResult = applyRule(v, nextWordToken, dialect); + return ruleResult ? { text: v, ...ruleResult } : { text: v, sandhi: null }; + }); + results.push({ variants: processedVariants }); + } else { + const ruleResult = applyRule(currentToken, nextWordToken, dialect); + results.push(ruleResult ? { text: currentToken, ...ruleResult } : { text: currentToken, sandhi: null }); + } + } + return results; + } + + /** + * Processes an HTML string (like from the dictionary) and injects tags for sandhi. + */ + function applyToHtml(htmlContent, dialectName) { + const dialect = dialectName ? dialectName.replace('教典', '') : ''; + if (!dialect || !rules[dialect]) return htmlContent; + + const sandhiRubyRegex = /]*>.*?<\/ruby>/g; + let preliminaryTokens = []; + let lastIndex = 0; + + // Preserve existing sandhi ruby tags + htmlContent.replace(sandhiRubyRegex, (match, offset) => { + if (offset > lastIndex) { + preliminaryTokens.push(htmlContent.substring(lastIndex, offset)); + } + preliminaryTokens.push(match); + lastIndex = offset + match.length; + return match; + }); + if (lastIndex < htmlContent.length) { + preliminaryTokens.push(htmlContent.substring(lastIndex)); + } + + const tokens = preliminaryTokens + .flatMap((token) => { + if (token.startsWith(' t && t.length > 0); + + const sandhiResults = applyToTokens(tokens, dialect); + let hasActualModification = false; + + const finalTokens = sandhiResults.map(res => { + if (res.variants) { + const variantHtml = res.variants.map(v => { + if (v.newTone) { + hasActualModification = true; + return `${v.text}${v.newTone}`; + } + return v.text; + }).join('/'); + return variantHtml; + } else if (res.newTone) { + hasActualModification = true; + return `${res.text}${res.newTone}`; + } + return res.text; + }); + + return hasActualModification ? finalTokens.join('') : htmlContent; + } + + return { + applyToTokens, + applyToHtml, + hasRulesFor: (dialect) => !!(dialect && rules[dialect.replace('教典', '')]) + }; +})(); + +// Export for browser environment +if (typeof window !== 'undefined') { + window.HakkaSandhi = HakkaSandhi; +} diff --git a/main.js b/main.js index 01b9738..d9b873e 100644 --- a/main.js +++ b/main.js @@ -821,143 +821,15 @@ function updatePopupPosition(popupEl, selectionRect) { popupEl.style.visibility = 'visible'; } -function getDapuSandhiHtml(htmlContent) { - const BLOCKING_PUNCTUATION = '()()【】'; - const SKIPPABLE_PUNCTUATION = '\\s、'; - const ALL_PUNCTUATION_CHARS = SKIPPABLE_PUNCTUATION + BLOCKING_PUNCTUATION; - - const TOKENIZER_REGEX = new RegExp( - `[^<>` + - ALL_PUNCTUATION_CHARS + - `]+|[${SKIPPABLE_PUNCTUATION}]+|[${BLOCKING_PUNCTUATION}]+`, - 'g', - ); - const SKIPPABLE_REGEX = new RegExp(`^[${SKIPPABLE_PUNCTUATION}]+$`); - const BLOCKING_REGEX = new RegExp(`^[${BLOCKING_PUNCTUATION}]+$`); - - const sandhiRubyRegex = - /]*>.*?<\/ruby>/g; - let preliminaryTokens = []; - let lastIndex = 0; - - htmlContent.replace(sandhiRubyRegex, (match, offset) => { - if (offset > lastIndex) { - preliminaryTokens.push(htmlContent.substring(lastIndex, offset)); - } - preliminaryTokens.push(match); - lastIndex = offset + match.length; - return match; - }); - if (lastIndex < htmlContent.length) { - preliminaryTokens.push(htmlContent.substring(lastIndex)); - } - - const tokens = preliminaryTokens - .flatMap((token) => { - if (token.startsWith('