Inject Jyutping

Add Cantonese pronunciation (Jyutping) on Chinese characters.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name              Inject Jyutping
// @name:zh-HK        注入粵拼
// @name:zh-TW        注入粵拼
// @name:zh-CN        注入粤拼
// @name:ja           粤拼を注入
// @name:ko           월병(粵拼)을 주입
// @description       Add Cantonese pronunciation (Jyutping) on Chinese characters.
// @description:zh-HK 為漢字標註粵語發音(粵拼)。
// @description:zh-TW 為漢字標註粵語發音(粵拼)。
// @description:zh-CN 为汉字标注粤语发音(粤拼)。
// @description:ja    漢字に広東語の発音(粤拼)を付けます。
// @description:ko    한자에 광동어의 발음(월병/粵拼)을 붙인다.
// @namespace         https://jyutping.org
// @version           0.5.0
// @license           BSD-2-Clause
// @icon              https://raw.githubusercontent.com/CanCLID/inject-jyutping/refs/heads/main/icons/128.png
// @match             *://*/*
// @grant             GM_addStyle
// @run-at            context-menu
// ==/UserScript==

'use strict';

GM_addStyle(`
    ruby.inject-jyutping > rt {
        font-size: 0.74em;
        font-variant: initial;
        margin-left: 0.1em;
        margin-right: 0.1em;
        text-transform: initial;
        letter-spacing: initial;
    }

    ruby.inject-jyutping > rt::after {
        content: attr(data-content);
    }
`);

// src/MessageManager.ts
function isMessage(obj) {
  return obj && typeof obj === "object" && typeof obj.id === "number" && "msg" in obj;
}
var getUniqueId = ((id) => () => id++)(0);

class MessageManager {
  worker;
  constructor(worker) {
    this.worker = worker;
  }
  sendMessage(handlerName, msg) {
    const { worker } = this;
    const id = getUniqueId();
    return new Promise((resolve) => {
      worker.addEventListener("message", function f({ data: response }) {
        if (isMessage(response) && response.id === id) {
          worker.removeEventListener("message", f);
          resolve(response.msg);
        }
      });
      worker.postMessage({ msg, id, name: handlerName });
    });
  }
  registerHandler(handlerName, f) {
    const { worker } = this;
    worker.addEventListener("message", ({ data: msg }) => {
      if (isMessage(msg) && "name" in msg && msg.name === handlerName) {
        const res = f(msg.msg);
        worker.postMessage({ msg: res, id: msg.id });
      }
    });
  }
}

// src/index.ts
function hasHanChar(s) {
  return /[\p{Unified_Ideograph}\u3006\u3007]/u.test(s);
}
function isTargetLang(locale) {
  const [lang] = locale.split("-", 1);
  return lang !== "ja" && lang !== "ko" && lang !== "vi";
}
function makeRuby(ch, pronunciation) {
  const ruby = document.createElement("ruby");
  ruby.classList.add("inject-jyutping");
  ruby.textContent = ch;
  const rt = document.createElement("rt");
  rt.lang = "yue-Latn";
  rt.dataset["content"] = pronunciation;
  ruby.appendChild(rt);
  return ruby;
}
function forEachText(node, callback, lang = "") {
  if (!isTargetLang(lang)) {
    return;
  }
  if (node.nodeType === Node.TEXT_NODE) {
    if (hasHanChar(node.nodeValue || "")) {
      callback(node);
    }
  } else if (node.nodeType === Node.ELEMENT_NODE) {
    if (["RUBY", "OPTION", "TEXTAREA", "SCRIPT", "STYLE"].includes(node.nodeName)) {
      return;
    }
    for (const child of node.childNodes) {
      forEachText(child, callback, node.lang);
    }
  }
}
async function convertText(node) {
  const conversionResults = await mm.sendMessage("convert", node.nodeValue || "");
  const newNodes = document.createDocumentFragment();
  for (const [k, v] of conversionResults) {
    newNodes.appendChild(v === null ? document.createTextNode(k) : makeRuby(k, v));
  }
  if (node.isConnected) {
    node.parentNode?.replaceChild(newNodes, node);
  }
}
var worker = new Worker(URL.createObjectURL(new Blob(["function o(e){return e&&typeof e===\"object\"&&typeof e.id===\"number\"&&\"msg\"in e}var d=((e)=>()=>e++)(0);class n{e;constructor(e){this.worker=e}sendMessage(e,r){const{worker:s}=this,t=d();return new Promise((i)=>{s.addEventListener(\"message\",function g({data:a}){if(o(a)&&a.id===t)s.removeEventListener(\"message\",g),i(a.msg)}),s.postMessage({msg:r,id:t,name:e})})}registerHandler(e,r){const{worker:s}=this;s.addEventListener(\"message\",({data:t})=>{if(o(t)&&\"name\"in t&&t.name===e){const i=r(t.msg);s.postMessage({msg:i,id:t.id})}})}}importScripts(\"https://cdn.jsdelivr.net/npm/[email protected]\");var c=new n(globalThis);c.registerHandler(\"convert\",ToJyutping.getJyutpingList);\n"], { type: "text/javascript" })));
var mm = new MessageManager(worker);
var mo = new MutationObserver((changes) => {
  for (const change of changes) {
    for (const node of change.addedNodes) {
      const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentNode;
      forEachText(node, convertText, element?.closest?.("[lang]")?.lang);
    }
  }
});
forEachText(document.body, convertText, document.body.lang || document.documentElement.lang);
mo.observe(document.body, {
  characterData: true,
  childList: true,
  subtree: true
});