Files
SubMiner/vendor/hachidori/extension/render/glossary.js
T
sudacode d9fdc7ef6d feat(dictionary): add Hachidori backend support
- Add backend selection, setup gating, Anki integration, and external host support
- Add launcher flags, documentation, packaging, and focused tests
- Open on-demand overlay modals on the first attempt
2026-09-22 00:21:19 -07:00

1478 lines
52 KiB
JavaScript

/*
* Structured-content, furigana, and dictionary-style rendering for the
* Hachidori popup.
*
* Ported from GameSentenceMiner PR #549
* (GSM_Overlay/features/hoshidicts/reader.js). Structured content follows
* Yomitan's structured-content schema; the furigana segmentation and
* pitch-accent ruby are adapted from Hoshi Reader:
* https://github.com/Manhhao/Hoshi-Reader/tree/c31c9d0ce376ff83bf6a91d908bf9f8e0fb4947b/Features/Popup
*
* Copyright (C) 2026 Manhhao
* Copyright (C) 2023-2026 Yomitan Authors
* Copyright (C) 2021-2022 Yomichan Authors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
(function (root, factory) {
const api = factory();
if (typeof module !== "undefined" && module.exports) {
module.exports = api;
}
if (root) {
root.HDGlossary = api;
}
}(typeof window !== "undefined" ? window : globalThis, function () {
"use strict";
const MAX_TEXT_LENGTH = 128 * 1024;
const MAX_LOOKUP_TEXT_BYTES = 4 * 1024;
const MAX_MEDIA_DISPLAY_SIZE = 1024;
const MAX_STRUCTURED_NODES = 1_048_576;
const MAX_STRUCTURED_LOCATION_SEGMENTS = 64;
const MAX_STRUCTURED_DATA_ATTRIBUTES = 64;
const MAX_STRUCTURED_DATA_KEY_LENGTH = 64;
const MAX_STRUCTURED_DATA_VALUE_LENGTH = 4096;
const MAX_DICTIONARY_STYLE_BYTES = 256 * 1024;
const MAX_DICTIONARY_STYLES_BYTES = 2 * 1024 * 1024;
// These compatibility aliases are typed at each use site. Even a page's
// @property registration must not turn their values into resource URLs.
const DICTIONARY_STYLE_VARIABLES = new Set([
"--text-color", "--background-color", "--fg", "--canvas", "--font-size-no-units",
]);
const DICTIONARY_STYLE_GROUPS = new Set([
"CSSMediaRule", "CSSSupportsRule", "CSSContainerRule",
]);
const DICTIONARY_FONT_FAMILIES = new Set([
"serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui",
"ui-serif", "ui-sans-serif", "ui-monospace", "ui-rounded", "math", "fangsong",
"inherit", "initial", "unset", "revert", "revert-layer",
]);
const HAN_CHARACTER_PATTERN =
/[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\u{20000}-\u{2fa1f}]/u;
const KANJI_SEGMENT_PATTERN =
/[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\u{20000}-\u{2fa1f}\u3005]+|[^\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\u{20000}-\u{2fa1f}\u3005]+/gu;
const KANA_PATTERN = /[\u3040-\u30ff\uff66-\uff9f]/u;
const PITCH_SMALL_KANA = new Set(Array.from(
"ゃゅょぁぃぅぇぉゎャュョァィゥェォヮ"
));
const COMBINING_MARK_PATTERN = /\p{Mark}/u;
const ALLOWED_STRUCTURED_TAGS = new Set([
"a",
"br",
"code",
"details",
"div",
"em",
"img",
"li",
"ol",
"p",
"rp",
"rt",
"ruby",
"small",
"span",
"strong",
"sub",
"summary",
"sup",
"table",
"tbody",
"td",
"tfoot",
"th",
"thead",
"tr",
"ul",
]);
const IGNORED_STRUCTURED_TAGS = new Set([
"audio",
"button",
"canvas",
"iframe",
"input",
"script",
"source",
"style",
"svg",
"video",
]);
const STRUCTURED_TAGS_WITHOUT_CONTENT = new Set(["br", "img"]);
const STRUCTURED_STYLE_PROPERTIES = new Map([
["background", ["background", "color"]],
["backgroundColor", ["background-color", "color"]],
["borderColor", ["border-color", "color"]],
["borderRadius", ["border-radius", "length-sequence"]],
["borderStyle", ["border-style", "border-style"]],
["borderWidth", ["border-width", "length-sequence"]],
["clipPath", ["clip-path", "clip-path"]],
["color", ["color", "color"]],
["cursor", ["cursor", "cursor"]],
["fontSize", ["font-size", "length"]],
["fontStyle", ["font-style", "font-style"]],
["fontWeight", ["font-weight", "font-weight"]],
["listStyleType", ["list-style-type", "list-style-type"]],
["margin", ["margin", "signed-length-sequence"]],
["marginBottom", ["margin-bottom", "signed-length"]],
["marginLeft", ["margin-left", "signed-length"]],
["marginRight", ["margin-right", "signed-length"]],
["marginTop", ["margin-top", "signed-length"]],
["padding", ["padding", "length-sequence"]],
["paddingBottom", ["padding-bottom", "length"]],
["paddingLeft", ["padding-left", "length"]],
["paddingRight", ["padding-right", "length"]],
["paddingTop", ["padding-top", "length"]],
["textAlign", ["text-align", "text-align"]],
["textDecorationColor", ["text-decoration-color", "color"]],
["textDecorationLine", ["text-decoration-line", "text-decoration-line"]],
["textDecorationStyle", ["text-decoration-style", "text-decoration-style"]],
["textEmphasis", ["text-emphasis", "safe-css-token"]],
["textShadow", ["text-shadow", "safe-css-token"]],
["verticalAlign", ["vertical-align", "vertical-align"]],
["whiteSpace", ["white-space", "white-space"]],
["wordBreak", ["word-break", "word-break"]],
]);
function isRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function boundedString(value, maxLength = MAX_TEXT_LENGTH) {
return typeof value === "string" ? value.slice(0, maxLength) : "";
}
function structuredContentLocation(path) {
const omitted = Math.max(0, path.length - MAX_STRUCTURED_LOCATION_SEGMENTS);
const segments = omitted > 0
? [...path.slice(0, MAX_STRUCTURED_LOCATION_SEGMENTS / 2),
`[${omitted} path segments omitted]`,
...path.slice(-MAX_STRUCTURED_LOCATION_SEGMENTS / 2)]
: path;
let location = "";
for (const segment of segments) {
if (typeof segment === "string" && segment.startsWith("[")) {
location += segment;
continue;
}
location += typeof segment === "number"
? `[${segment}]`
: `${location ? "." : ""}${segment}`;
}
return location || "structuredContent";
}
function structuredContentLimitError(kind, actual, limit, path) {
const error = new RangeError(
`Structured content ${kind} ${actual} exceeds limit ${limit} at ${structuredContentLocation(path)}`
);
error.code = "structured-content-limit";
error.structuredContentActual = actual;
error.structuredContentLimit = limit;
error.structuredContentLimitKind = kind;
error.structuredContentLocation = structuredContentLocation(path);
return error;
}
function toHiragana(text) {
return String(text || "").replace(
/[\u30a1-\u30f6]/gu,
(character) => String.fromCharCode(character.charCodeAt(0) - 0x60)
);
}
function splitPitchAccentMorae(reading) {
const morae = [];
for (const character of Array.from(String(reading || "").normalize("NFC"))) {
const previousIndex = morae.length - 1;
if (
previousIndex >= 0 &&
(PITCH_SMALL_KANA.has(character) || COMBINING_MARK_PATTERN.test(character))
) {
morae[previousIndex] += character;
} else {
morae.push(character);
}
}
return morae;
}
function buildPitchAccentMorae(reading, position) {
const morae = splitPitchAccentMorae(reading);
if (
morae.length === 0 ||
!Number.isInteger(position) ||
position < 0 ||
position > morae.length
) {
return null;
}
const levels = morae.map((_, index) => {
if (position === 0) {
return index === 0 ? "low" : "high";
}
if (position === 1) {
return index === 0 ? "high" : "low";
}
return index === 0 || index >= position ? "low" : "high";
});
const levelAfterWord = position === 0 ? "high" : "low";
return morae.map((text, index) => {
const level = levels[index];
const nextLevel = levels[index + 1] || levelAfterWord;
return {
text,
level,
transition: level === nextLevel
? null
: level === "low" ? "rise" : "drop",
};
});
}
function selectPitchAccent(
pitchGroups,
preferredDictionary = null,
moraCount = null
) {
const groups = Array.isArray(pitchGroups) ? pitchGroups : [];
const maximumPosition = Number.isInteger(moraCount) && moraCount >= 0
? moraCount
: null;
const preferred = typeof preferredDictionary === "string"
? preferredDictionary.trim()
: "";
const orderedGroups = preferred
? [
...groups.filter((group) => group?.dictionary === preferred),
...groups.filter((group) => group?.dictionary !== preferred),
]
: groups;
for (const group of orderedGroups) {
if (!isRecord(group) || !Array.isArray(group.pitches)) {
continue;
}
for (const pitch of group.pitches) {
if (
isRecord(pitch) &&
Number.isInteger(pitch.position) &&
pitch.position >= 0 &&
(maximumPosition === null || pitch.position <= maximumPosition)
) {
return {
dictionary: boundedString(group.dictionary, 4096),
pitch,
};
}
}
}
return null;
}
function createFuriganaSegment(text, reading) {
return { text, reading };
}
function getFuriganaKanaSegments(text, reading) {
const newSegments = [];
let start = 0;
let state = reading[0] === text[0];
for (let index = 1; index < text.length; index += 1) {
const nextState = reading[index] === text[index];
if (state === nextState) {
continue;
}
newSegments.push(
createFuriganaSegment(
text.substring(start, index),
state ? "" : reading.substring(start, index)
)
);
state = nextState;
start = index;
}
newSegments.push(
createFuriganaSegment(
text.substring(start),
state ? "" : reading.substring(start)
)
);
return newSegments;
}
function segmentizeFurigana(reading, normalizedReading, groups, groupStart) {
const groupCount = groups.length - groupStart;
if (groupCount <= 0) {
return reading.length === 0 ? [] : null;
}
const group = groups[groupStart];
if (group.isKana) {
if (
group.normalizedText !== null &&
normalizedReading.startsWith(group.normalizedText)
) {
const segments = segmentizeFurigana(
reading.substring(group.text.length),
normalizedReading.substring(group.text.length),
groups,
groupStart + 1
);
if (segments !== null) {
if (reading.startsWith(group.text)) {
segments.unshift(createFuriganaSegment(group.text, ""));
} else {
segments.unshift(...getFuriganaKanaSegments(group.text, reading));
}
return segments;
}
}
return null;
}
let result = null;
for (let index = reading.length; index >= group.text.length; index -= 1) {
const segments = segmentizeFurigana(
reading.substring(index),
normalizedReading.substring(index),
groups,
groupStart + 1
);
if (segments !== null) {
if (result !== null) {
return null;
}
segments.unshift(
createFuriganaSegment(group.text, reading.substring(0, index))
);
result = segments;
}
if (groupCount === 1) {
break;
}
}
return result;
}
function segmentFurigana(expression, reading) {
if (!reading || reading === expression) {
return [{ text: expression, reading: "" }];
}
const groups = [];
const matches = String(expression).match(KANJI_SEGMENT_PATTERN) || [];
for (const text of matches) {
const isKana = KANA_PATTERN.test(text[0]);
groups.push({
isKana,
text,
normalizedText: isKana ? toHiragana(text) : null,
});
}
const segments = segmentizeFurigana(
reading,
toHiragana(reading),
groups,
0
);
return segments === null
? [{ text: expression, reading }]
: segments;
}
function appendExpressionRuby(
documentRef,
parent,
expression,
reading,
onKanjiClick,
pitchOptions = {}
) {
const appendText = (target, text) => {
for (const character of Array.from(text)) {
if (!HAN_CHARACTER_PATTERN.test(character) || typeof onKanjiClick !== "function") {
target.appendChild(documentRef.createTextNode(character));
continue;
}
const button = documentRef.createElement("button");
button.type = "button";
button.className = "gsm-hoshidicts-kanji-link";
button.textContent = character;
button.setAttribute("aria-label", `Look up kanji ${character}`);
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
onKanjiClick(character, button);
});
target.appendChild(button);
}
};
const pitchReading = reading || expression;
const selectedPitch = pitchOptions.enabled === false
? null
: selectPitchAccent(
pitchOptions.groups,
pitchOptions.dictionary,
splitPitchAccentMorae(pitchReading).length
);
const pitchedMorae = selectedPitch
? buildPitchAccentMorae(pitchReading, selectedPitch.pitch.position)
: null;
if (pitchedMorae) {
// One column per furigana segment, so each reading sits over the text it
// reads. Kana segments get a column too, keeping the contour unbroken.
let segments = segmentFurigana(expression, reading).map((segment) => ({
text: segment.text,
moraCount: splitPitchAccentMorae(segment.reading || segment.text).length,
}));
if (
segments.reduce((total, segment) => total + segment.moraCount, 0) !==
pitchedMorae.length
) {
segments = [{ text: expression, moraCount: pitchedMorae.length }];
}
const title = [
selectedPitch.dictionary,
`Pitch accent ${selectedPitch.pitch.position}`,
].filter(Boolean).join(" · ");
let moraIndex = 0;
for (const segment of segments) {
const ruby = documentRef.createElement("ruby");
ruby.className = "gsm-hoshidicts-pitch-ruby";
const base = documentRef.createElement("span");
base.className = "gsm-hoshidicts-pitch-base";
appendText(base, segment.text);
ruby.appendChild(base);
const rt = documentRef.createElement("rt");
rt.className = "gsm-hoshidicts-pitch-reading";
rt.dataset.pitchPosition = String(selectedPitch.pitch.position);
if (selectedPitch.dictionary) {
rt.dataset.pitchDictionary = selectedPitch.dictionary;
}
rt.title = title;
const contour = documentRef.createElement("span");
contour.className = "gsm-hoshidicts-pitch-contour";
for (const mora of pitchedMorae.slice(moraIndex, moraIndex + segment.moraCount)) {
const span = documentRef.createElement("span");
span.className = "gsm-hoshidicts-pitch-mora";
span.dataset.pitchLevel = mora.level;
if (mora.transition) {
span.dataset.pitchTransition = mora.transition;
}
span.textContent = mora.text;
contour.appendChild(span);
}
moraIndex += segment.moraCount;
rt.appendChild(contour);
ruby.appendChild(rt);
parent.appendChild(ruby);
}
return;
}
for (const segment of segmentFurigana(expression, reading)) {
if (!segment.reading) {
appendText(parent, segment.text);
continue;
}
const ruby = documentRef.createElement("ruby");
appendText(ruby, segment.text);
const rt = documentRef.createElement("rt");
rt.textContent = segment.reading;
ruby.appendChild(rt);
parent.appendChild(ruby);
}
}
function parseTagList(value) {
return String(value || "").split(/\s+/u).filter(Boolean);
}
function isSafeCssToken(value) {
return (
typeof value === "string" &&
value.length > 0 &&
value.length <= 128 &&
!/[\u0000-\u001f\u007f;{}]/u.test(value) &&
!/(?:url|expression|var)\s*\(/iu.test(value)
);
}
function normalizeColor(value) {
if (!isSafeCssToken(value)) {
return null;
}
const trimmed = value.trim();
if (
/^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/iu.test(trimmed) ||
/^(?:rgb|rgba|hsl|hsla)\([0-9.,%+\-\s/]+\)$/iu.test(trimmed) ||
/^(?:[a-z]+|currentColor|transparent)$/iu.test(trimmed)
) {
return trimmed;
}
return null;
}
function normalizeLengthToken(value, allowNegative = false) {
if (typeof value === "number") {
if (!Number.isFinite(value) || Math.abs(value) > 256 || (!allowNegative && value < 0)) {
return null;
}
return `${value}px`;
}
if (!isSafeCssToken(value)) {
return null;
}
const trimmed = value.trim();
const match = /^(-?(?:0|[0-9]+(?:\.[0-9]+)?))(px|em|rem|%)?$/u.exec(trimmed);
if (!match) {
return null;
}
const amount = Number(match[1]);
const unit = match[2] || (amount === 0 ? "" : "px");
const limit = unit === "em" || unit === "rem"
? 16
: unit === "%"
? 100
: 256;
if (!Number.isFinite(amount) || Math.abs(amount) > limit || (!allowNegative && amount < 0)) {
return null;
}
return `${match[1]}${unit}`;
}
function normalizeLengthSequence(value, allowNegative = false) {
if (typeof value === "number") {
return normalizeLengthToken(value, allowNegative);
}
if (!isSafeCssToken(value)) {
return null;
}
const tokens = value.trim().split(/\s+/u);
if (tokens.length < 1 || tokens.length > 4) {
return null;
}
const normalized = tokens.map((token) => normalizeLengthToken(token, allowNegative));
return normalized.every((token) => token !== null) ? normalized.join(" ") : null;
}
// Value kinds that are one pattern match against a string. The rest need
// their own handling and stay spelled out below.
const STRUCTURED_STYLE_PATTERNS = new Map([
["border-style", /^(?:none|hidden|dotted|dashed|solid|double)$/u],
["clip-path", /^(?:circle|ellipse|inset)\([0-9.,%+\-\s]+\)$/u],
[
"cursor",
/^(?:auto|default|pointer|help|text|wait|progress|not-allowed|zoom-in|zoom-out)$/u,
],
["font-style", /^(?:normal|italic)$/u],
["text-align", /^(?:start|end|left|right|center|justify|match-parent)$/u],
["text-decoration-style", /^(?:solid|double|dotted|dashed|wavy)$/u],
["white-space", /^(?:normal|nowrap|pre|pre-wrap|pre-line|break-spaces)$/u],
["word-break", /^(?:normal|break-all|keep-all|break-word)$/u],
]);
function normalizeStructuredStyleValue(kind, value) {
const pattern = STRUCTURED_STYLE_PATTERNS.get(kind);
if (pattern) {
return typeof value === "string" && pattern.test(value) ? value : null;
}
if (kind === "color") {
return normalizeColor(value);
}
if (kind === "length") {
return normalizeLengthToken(value);
}
if (kind === "signed-length") {
// A bare number here means em, not the px normalizeLengthToken assumes.
if (typeof value === "number") {
return Number.isFinite(value) && Math.abs(value) <= 16
? `${value}em`
: null;
}
return normalizeLengthToken(value, true);
}
if (kind === "length-sequence") {
return normalizeLengthSequence(value);
}
if (kind === "signed-length-sequence") {
return normalizeLengthSequence(value, true);
}
if (kind === "font-weight") {
if (
typeof value === "string" &&
/^(?:normal|bold|bolder|lighter|[1-9]00)$/u.test(value)
) {
return value;
}
if (Number.isInteger(value) && value >= 100 && value <= 900 && value % 100 === 0) {
return String(value);
}
}
if (kind === "list-style-type") {
return isSafeCssToken(value) && value.trim().length <= 64
? value.trim()
: null;
}
if (kind === "text-decoration-line") {
const values = Array.isArray(value) ? value : [value];
return values.length >= 1 && values.length <= 4 && values.every(
(item) => typeof item === "string" &&
/^(?:none|underline|overline|line-through|blink)$/u.test(item)
) ? values.join(" ") : null;
}
if (kind === "vertical-align") {
if (
typeof value === "string" &&
/^(?:baseline|sub|super|text-top|text-bottom|middle|top|bottom)$/u.test(value)
) {
return value;
}
return normalizeLengthToken(value, true);
}
if (kind === "safe-css-token") {
return isSafeCssToken(value) ? value.trim() : null;
}
return null;
}
function applyStructuredStyle(element, rawStyle) {
if (!isRecord(rawStyle)) {
return;
}
for (const [property, value] of Object.entries(rawStyle)) {
const definition = STRUCTURED_STYLE_PROPERTIES.get(property);
if (!definition) {
continue;
}
const [cssProperty, kind] = definition;
const normalized = normalizeStructuredStyleValue(kind, value);
if (normalized !== null) {
element.style.setProperty(cssProperty, normalized);
}
}
}
function normalizeMediaPath(value) {
if (
typeof value !== "string" ||
value.length < 1 ||
value.length > 4096 ||
/[\\\u0000-\u001f\u007f]/u.test(value) ||
value.startsWith("/") ||
/^[A-Za-z][A-Za-z0-9+.-]*:/u.test(value)
) {
return null;
}
const components = value.split("/");
return components.some((component) => !component || component === "." || component === "..")
? null
: value;
}
// hd_media_result carries a data: URL; PR #549 resolved media to blob: URLs
// from its own object-URL cache. Either way the bytes came from this
// extension. The character class stops the URL escaping the CSS url("…")
// context appendStructuredImage interpolates it into.
function isRenderableMediaUrl(url) {
return typeof url === "string" && /^(?:blob|data):[^"'()\s\\]+$/u.test(url);
}
function appendStructuredImage(documentRef, parent, value, state) {
const path = normalizeMediaPath(value.path);
if (!path) return;
// The Anki exporter shares structured parsing, but writes inert portable
// image markup instead of installing a popup's asynchronous preview owner.
if (typeof state.appendImage === "function") {
state.appendImage(documentRef, parent, value, { dictionary: state.dictionary, path });
return;
}
if (typeof state.resolveMedia !== "function") {
return;
}
const useNaturalDimensions = !["width", "height", "preferredWidth", "preferredHeight"]
.some((key) => Object.prototype.hasOwnProperty.call(value, key))
&& value.sizeUnits !== "px"
&& value.sizeUnits !== "em";
const width = Number.isFinite(Number(value.width)) && Number(value.width) > 0
? Number(value.width)
: 100;
const height = Number.isFinite(Number(value.height)) && Number(value.height) > 0
? Number(value.height)
: 100;
const preferredWidth = Number.isFinite(Number(value.preferredWidth)) &&
Number(value.preferredWidth) > 0
? Number(value.preferredWidth)
: null;
const preferredHeight = Number.isFinite(Number(value.preferredHeight)) &&
Number(value.preferredHeight) > 0
? Number(value.preferredHeight)
: null;
const aspectWidth = preferredWidth || width;
const aspectHeight = preferredHeight || height;
let usedWidth = preferredWidth || (
preferredHeight ? preferredHeight * width / height : width
);
if (preferredWidth === null && preferredHeight !== null && (!Number.isFinite(usedWidth) || usedWidth === 0)) {
// The product can overflow/underflow even when the final width fits.
// Keep valid original results; try the other groupings only on failure.
usedWidth = preferredHeight * (width / height);
if (!Number.isFinite(usedWidth) || usedWidth === 0) {
usedWidth = (preferredHeight / height) * width;
}
}
const units = value.sizeUnits === "em" ? "em" : "px";
const maximumSize = units === "em" ? 64 : MAX_MEDIA_DISPLAY_SIZE;
const displayWidth = Math.max(0.1, Math.min(maximumSize, usedWidth));
const link = documentRef.createElement("a");
link.className = "gloss-image-link gloss-sc-a";
applyStructuredData(link, value.data);
link.target = "_blank";
link.rel = "noopener noreferrer";
link.dataset.path = path;
link.dataset.imageLoadState = "not-loaded";
link.dataset.hasAspectRatio = "true";
link.dataset.imageRendering = typeof value.imageRendering === "string"
? value.imageRendering
: value.pixelated === true
? "pixelated"
: "auto";
link.dataset.appearance = typeof value.appearance === "string"
? value.appearance
: "auto";
link.dataset.background = String(
typeof value.background === "boolean" ? value.background : true
);
link.dataset.collapsed = String(value.collapsed === true);
link.dataset.collapsible = String(value.collapsible !== false);
if (typeof value.verticalAlign === "string") {
link.dataset.verticalAlign = value.verticalAlign;
}
if (preferredWidth !== null || preferredHeight !== null || units === "em") {
link.dataset.sizeUnits = units;
}
const container = documentRef.createElement("span");
container.className = "gloss-image-container";
container.style.width = `${displayWidth}${units}`;
if (typeof value.title === "string" && value.title.length <= 4096) {
container.title = value.title;
}
if (isSafeCssToken(value.border)) {
container.style.border = value.border;
}
const borderRadius = normalizeLengthSequence(value.borderRadius);
if (borderRadius !== null) {
container.style.borderRadius = borderRadius;
}
const sizer = documentRef.createElement("span");
sizer.className = "gloss-image-sizer";
// One sizing rule owns the ratio; raw CSS aspect-ratio bypasses this cap.
sizer.style.paddingTop = `${Math.min(10_000, aspectHeight / aspectWidth * 100)}%`;
const background = documentRef.createElement("span");
background.className = "gloss-image-background";
const overlay = documentRef.createElement("span");
overlay.className = "gloss-image-container-overlay";
const image = documentRef.createElement("img");
image.className = "gloss-image gloss-sc-img gsm-hoshidicts-structured-image";
image.alt = isRecord(value.data) && typeof value.data.alt === "string"
? value.data.alt.slice(0, 1024)
: typeof value.alt === "string"
? value.alt.slice(0, 1024)
: "";
image.decoding = "async";
image.draggable = false;
image.style.width = "100%";
image.style.height = "100%";
container.append(sizer, background, overlay, image);
link.appendChild(container);
const linkText = documentRef.createElement("span");
linkText.className = "gloss-image-link-text";
linkText.textContent = "Image";
link.appendChild(linkText);
const onLayoutChange = typeof state.onLayoutChange === "function"
? state.onLayoutChange
: () => {};
const ownsView = typeof state.isCurrent === "function" ? state.isCurrent : () => true;
const isCurrent = () => image.isConnected && ownsView();
const ownsDisplayedImage = state.isImageCurrent
|| (() => image.isConnected && (state.isCurrentLink || ownsView)());
let imageContext = state.imageContext || {};
let appliedSources = imageContext.popupImageSources ?? null;
let attempt = 0;
let supplier = null;
let sourceLabel = null;
function updateSourceLabel() {
if (!supplier || supplier === state.dictionary) {
if (!sourceLabel) return false;
sourceLabel.remove();
sourceLabel = null;
return true;
}
const name = imageContext.dictionaryPresentation?.find(entry => entry.title === supplier)?.displayName || supplier;
const text = `Image: ${name}`;
if (sourceLabel?.textContent === text && sourceLabel.dataset.dictionary === supplier) return false;
if (!sourceLabel) {
sourceLabel = documentRef.createElement("span");
sourceLabel.className = "gloss-image-source";
if (state.imageSourceLabelHost) state.imageSourceLabelHost.appendChild(sourceLabel);
else link.after(sourceLabel);
}
sourceLabel.textContent = text;
sourceLabel.dataset.dictionary = supplier;
sourceLabel.title = supplier;
return true;
}
let previewHovered = false;
let previewFocused = false;
const showPreview = () => {
if (!isCurrent()) return;
state.requestImagePreview?.(link, image);
};
const hidePreview = () => {
state.hideImagePreview?.(link);
};
const hideUnownedPreview = () => {
if (!previewHovered && !previewFocused) hidePreview();
};
link.addEventListener("mouseenter", () => {
previewHovered = true;
showPreview();
});
link.addEventListener("mouseleave", () => {
previewHovered = false;
hideUnownedPreview();
});
link.addEventListener("focus", () => {
previewFocused = true;
showPreview();
});
link.addEventListener("blur", () => {
previewFocused = false;
link.removeAttribute("tabindex");
hideUnownedPreview();
});
const failImage = () => {
if (!isCurrent()) return;
hidePreview();
image.hidden = true;
link.removeAttribute("href");
background.style.removeProperty("--image");
link.dataset.imageLoadState = "load-error";
link.setAttribute("role", "img");
linkText.textContent = image.alt ? `${image.alt}: Image failed to load` : "Image failed to load";
link.setAttribute("aria-label", linkText.textContent);
supplier = null;
updateSourceLabel();
state.onImageError?.();
onLayoutChange();
};
parent.appendChild(link);
let onLoad = null;
let onError = null;
function loadImage(refresh = false) {
const currentAttempt = ++attempt;
const ownsAttempt = () => attempt === currentAttempt && ownsView();
const canPublish = () => image.isConnected && ownsAttempt();
if (onLoad) image.removeEventListener("load", onLoad);
if (onError) image.removeEventListener("error", onError);
onLoad = () => {
if (!canPublish() || image.hidden) return;
if (useNaturalDimensions && image.naturalWidth > 0 && image.naturalHeight > 0) {
const naturalWidth = Math.max(0.1, Math.min(MAX_MEDIA_DISPLAY_SIZE, image.naturalWidth));
container.style.width = `${naturalWidth}px`;
sizer.style.paddingTop = `${Math.min(10_000, image.naturalHeight / image.naturalWidth * 100)}%`;
}
link.dataset.imageLoadState = "loaded";
onLayoutChange();
state.refreshImagePreview?.(link, image);
};
const failAttempt = () => { if (canPublish()) failImage(); };
onError = () => { if (!image.hidden) failAttempt(); };
image.addEventListener("load", onLoad);
image.addEventListener("error", onError);
if (refresh) {
const retainedFocus = link.getRootNode().activeElement === link;
state.onImageStart?.();
image.hidden = true;
image.removeAttribute("src");
// Keep a deliberately focused control keyboard-focusable while its
// old URL is unavailable. The successful href restores native focus.
if (retainedFocus) link.tabIndex = 0;
link.removeAttribute("href");
link.removeAttribute("role");
link.removeAttribute("aria-label");
linkText.textContent = "Image";
background.style.removeProperty("--image");
link.dataset.imageLoadState = "not-loaded";
supplier = null;
updateSourceLabel();
state.refreshImagePreview?.(link, image);
// Chrome 128 can drop focus when href is removed even though tabindex
// was installed first. Restore it synchronously so the popup's
// focusout microtask sees the refreshed control, not a false departure.
if (retainedFocus && link.getRootNode().activeElement !== link) {
link.tabIndex = 0;
link.focus({ preventScroll: true });
}
}
let resolvedSupplier = state.dictionary;
let mediaPromise;
try {
mediaPromise = Promise.resolve(state.resolveMedia({ path, width, height, isCurrent: ownsAttempt,
onResolvedSource(title) { if (ownsAttempt()) resolvedSupplier = title; },
}));
} catch (error) {
mediaPromise = Promise.reject(error);
}
mediaPromise.then((url) => {
if (!canPublish()) return;
if (!isRenderableMediaUrl(url)) throw new Error("dictionary image is unavailable");
image.hidden = false;
image.src = url;
link.href = url;
link.removeAttribute("tabindex");
link.dataset.imageLoadState = "loaded";
background.style.setProperty("--image", `url("${url}")`);
supplier = resolvedSupplier;
// The image's load/error callback positions its new label too.
updateSourceLabel();
}).catch(failAttempt);
}
state.onImageCreated?.({
isCurrent: ownsDisplayedImage,
updatePresentation(context) {
imageContext = context;
if (appliedSources === (context.popupImageSources ?? null) || !ownsView()) return updateSourceLabel();
appliedSources = context.popupImageSources ?? null;
loadImage(true);
return true;
},
});
loadImage();
}
// Yomitan writes `element.dataset["sc" + Key]`, so dictionary CSS is written
// against the attribute names the dataset setter derives: ASCII capitals
// become hyphen-lowercase and every other character, including Japanese
// keys such as 付録 or 外字, is kept as is (`data-sc付録`). A key the setter
// would reject (a hyphen before an ASCII lowercase letter) is dropped, as
// Yomitan drops it; setAttribute rejects the remaining invalid names.
function structuredDataAttributeName(rawKey) {
if (
typeof rawKey !== "string" ||
rawKey.length === 0 ||
rawKey.length > MAX_STRUCTURED_DATA_KEY_LENGTH
) {
return null;
}
const property = `sc${rawKey[0].toUpperCase()}${rawKey.slice(1)}`;
if (/-[a-z]/u.test(property)) return null;
return `data-${property.replace(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`)}`;
}
function applyStructuredData(element, data) {
if (!isRecord(data)) {
return;
}
let count = 0;
for (const [key, rawValue] of Object.entries(data)) {
if (count >= MAX_STRUCTURED_DATA_ATTRIBUTES) {
break;
}
if (
typeof rawValue !== "string" &&
typeof rawValue !== "number" &&
typeof rawValue !== "boolean"
) {
continue;
}
const attribute = structuredDataAttributeName(key);
const value = String(rawValue);
if (
!attribute ||
value.length > MAX_STRUCTURED_DATA_VALUE_LENGTH ||
/[\u0000-\u001f\u007f]/u.test(value)
) {
continue;
}
try {
element.setAttribute(attribute, value);
} catch {
continue;
}
count += 1;
}
}
function parseStructuredLink(href) {
if (typeof href !== "string" || href.length === 0 || href.length > 4096) {
return null;
}
if (href.startsWith("?")) {
const params = new Map();
try {
for (const part of href.slice(1).split("&")) {
const separator = part.indexOf("=");
const rawKey = separator < 0 ? part : part.slice(0, separator);
const rawValue = separator < 0 ? "" : part.slice(separator + 1);
const key = decodeURIComponent(rawKey.replace(/\+/gu, " "));
if (!params.has(key)) {
params.set(
key,
decodeURIComponent(rawValue.replace(/\+/gu, " "))
);
}
}
} catch {
return null;
}
const query = boundedString(params.get("query"), MAX_LOOKUP_TEXT_BYTES).trim();
if (!query) {
return null;
}
return {
internal: true,
primaryReading: boundedString(
params.get("primary_reading"),
MAX_LOOKUP_TEXT_BYTES
).trim(),
query,
};
}
const url = globalThis.HDExternalLinks.normaliseExternalUrl(href);
return url ? { href: url, internal: false } : null;
}
function ownsStructuredLink(element, state) {
const isCurrent = state.isCurrentLink || state.isCurrent;
return element.isConnected && (typeof isCurrent !== "function" || isCurrent());
}
function appendStructuredValue(
documentRef,
parent,
value,
state,
_depth,
path = ["structuredContent"]
) {
const currentPath = [...path];
const stack = [{ kind: "value", parent, value }];
const pushChild = (childParent, childValue, segment) => {
stack.push({ kind: "leave" });
stack.push({ kind: "value", parent: childParent, value: childValue });
stack.push({ kind: "enter", segment });
};
while (stack.length > 0) {
const frame = stack.pop();
if (frame.kind === "enter") {
currentPath.push(frame.segment);
continue;
}
if (frame.kind === "leave") {
currentPath.pop();
continue;
}
if (frame.kind === "array") {
if (frame.index < frame.value.length) {
stack.push({ ...frame, index: frame.index + 1 });
pushChild(frame.parent, frame.value[frame.index], frame.index);
}
continue;
}
if (frame.kind === "append-external-icon") {
const icon = documentRef.createElement("span");
icon.className = "gloss-link-external-icon";
icon.setAttribute("aria-hidden", "true");
frame.element.appendChild(icon);
continue;
}
if (state.nodes >= MAX_STRUCTURED_NODES) {
throw structuredContentLimitError(
"node count",
state.nodes + 1,
MAX_STRUCTURED_NODES,
currentPath
);
}
// Bound traversal work, including containers and values that render no DOM.
state.nodes += 1;
value = frame.value;
parent = frame.parent;
if (typeof value === "string") {
parent.appendChild(documentRef.createTextNode(value));
continue;
}
if (typeof value === "number" || typeof value === "boolean") {
parent.appendChild(documentRef.createTextNode(String(value)));
continue;
}
if (Array.isArray(value)) {
stack.push({ kind: "array", parent, value, index: 0 });
continue;
}
if (!isRecord(value)) {
continue;
}
if (value.type === "structured-content") {
pushChild(parent, value.content, "content");
continue;
}
if (value.type === "text") {
const property = Object.prototype.hasOwnProperty.call(value, "text") ? "text" : "content";
pushChild(parent, value[property], property);
continue;
}
if (value.type === "image") {
value = { ...value, tag: "img" };
}
const tag = typeof value.tag === "string" ? value.tag.toLowerCase() : "";
if (IGNORED_STRUCTURED_TAGS.has(tag)) {
continue;
}
if (!ALLOWED_STRUCTURED_TAGS.has(tag)) {
if (Object.prototype.hasOwnProperty.call(value, "content")) {
pushChild(parent, value.content, "content");
}
continue;
}
if (tag === "img") {
appendStructuredImage(documentRef, parent, value, state);
continue;
}
const element = documentRef.createElement(tag);
element.classList.add(`gloss-sc-${tag}`);
applyStructuredStyle(element, value.style);
applyStructuredData(element, value.data);
if (
typeof value.lang === "string" &&
/^[A-Za-z0-9-]{1,35}$/u.test(value.lang)
) {
element.setAttribute("lang", value.lang);
}
if (tag === "td" || tag === "th") {
for (const [property, attribute] of [
["colSpan", "colspan"],
["rowSpan", "rowspan"],
]) {
const span = Number(value[property]);
if (Number.isInteger(span) && span >= 1 && span <= 32) {
element.setAttribute(attribute, String(span));
}
}
}
if (tag === "details" && typeof state.onLayoutChange === "function") {
element.addEventListener("toggle", state.onLayoutChange);
}
if (typeof value.title === "string" && value.title.length <= 4096) {
element.title = value.title;
}
if (tag === "details" && value.open === true) {
element.open = true;
}
if (tag === "a") {
element.classList.add("gloss-link", "gsm-hoshidicts-structured-link");
const link = parseStructuredLink(value.href);
if (link?.internal) {
element.setAttribute("href", "#");
element.dataset.hoshidictsQuery = link.query;
if (link.primaryReading) {
element.dataset.hoshidictsReading = link.primaryReading;
}
element.addEventListener("click", (event) => {
if (event.defaultPrevented) return;
event.preventDefault();
event.stopPropagation();
if (ownsStructuredLink(element, state) && typeof state.onInternalLink === "function") {
state.onInternalLink({
anchor: element,
focusChild: event.detail === 0,
primaryReading: link.primaryReading,
query: link.query,
});
}
});
} else if (link) {
element.href = link.href;
element.target = "_blank";
element.rel = "noopener noreferrer";
element.dataset.external = "true";
const activate = (event) => {
if (event.defaultPrevented || event.button !== (event.type === "auxclick" ? 1 : 0)) return;
event.preventDefault();
event.stopPropagation();
if (!ownsStructuredLink(element, state)) return;
if (typeof state.onExternalLink === "function") {
state.onExternalLink({
url: link.href,
active: event.shiftKey || !(event.button === 1 || event.ctrlKey || event.metaKey),
});
}
};
element.addEventListener("click", activate);
element.addEventListener("auxclick", activate);
}
}
let contentParent = element;
if (tag === "a") {
contentParent = documentRef.createElement("span");
contentParent.className = "gloss-link-text";
element.appendChild(contentParent);
}
if (tag === "table") {
const container = documentRef.createElement("div");
container.className = "gloss-sc-table-container";
container.appendChild(element);
parent.appendChild(container);
} else {
parent.appendChild(element);
}
if (tag === "a" && element.dataset.external === "true") {
stack.push({ kind: "append-external-icon", element });
}
if (
!STRUCTURED_TAGS_WITHOUT_CONTENT.has(tag) &&
Object.prototype.hasOwnProperty.call(value, "content")
) {
pushChild(contentParent, value.content, "content");
}
}
}
function appendTextOnlyGlossary(documentRef, parent, rawGlossary, options = {}) {
const value = typeof rawGlossary === "string" ? rawGlossary : "";
if (!value) {
return;
}
let parsed = value;
try {
parsed = JSON.parse(value);
} catch {
// Plain glossary strings are rendered literally, including any HTML-like text.
}
// `glossary` is the whole glossary array of one term-bank row, and each of
// its elements is a separate sense. appendStructuredValue concatenates an
// array into its parent, which is right for a structured-content `content`
// run but would run two senses together here ("to eatto live on ..."), so
// the top level is split into one item each, as Yomitan does.
const items = Array.isArray(parsed) ? parsed : [parsed];
if (items.length === 0) {
return;
}
if (items.some((item) => isRecord(item) && item.type === "structured-content")) {
parent.classList.add("structured-content");
}
const state = {
nodes: 0,
dictionary: options.dictionary,
appendImage: options.appendImage,
imageContext: options.imageContext,
onImageCreated: options.onImageCreated,
isCurrent: options.isCurrent,
isCurrentLink: options.isCurrentLink,
onExternalLink: options.onExternalLink,
onInternalLink: options.onInternalLink,
onLayoutChange: options.onLayoutChange,
requestImagePreview: options.requestImagePreview,
refreshImagePreview: options.refreshImagePreview,
hideImagePreview: options.hideImagePreview,
resolveMedia: typeof options.resolveMedia === "function"
? ({ path, width, height, isCurrent, onResolvedSource }) => options.resolveMedia({
dictionary: options.dictionary,
generation: options.generation,
height,
isCurrent,
onResolvedSource,
path,
width,
})
: null,
};
if (items.length === 1) {
appendStructuredValue(documentRef, parent, items[0], state, 0, ["glossary", 0]);
return;
}
const list = documentRef.createElement("ul");
list.className = "gloss-list";
for (let index = 0; index < items.length; index += 1) {
const listItem = documentRef.createElement("li");
listItem.className = "gloss-item";
appendStructuredValue(documentRef, listItem, items[index], state, 0, ["glossary", index]);
list.appendChild(listItem);
}
parent.appendChild(list);
}
function utf8Length(value) {
return typeof TextEncoder === "function"
? new TextEncoder().encode(value).length
: value.length;
}
function isSafeDictionaryStyle(style) {
const declarations = style.cssText;
// Check the whole browser-serialized block: var() shorthands enumerate as
// empty longhands until substitution. Residual escapes can disguise both
// function names and variable delimiters; drop that cosmetic rule rather
// than reinterpret CSS tokens. Do not strip comment-like text in strings.
if (declarations.includes("\\")
|| /\b(?:url|src|image-set|paint|attr)\s*\(/iu.test(declarations)
|| /(?<![\w\P{ASCII}-])--[\w\P{ASCII}-]+\(/u.test(declarations)) return false;
for (const property of style) {
// Named fonts can activate an outer page's @font-face without a URL here.
// CSSOM expands non-variable font shorthands into font-family as well.
if (property === "font-family" && !style.getPropertyValue(property).split(",")
.every((family) => DICTIONARY_FONT_FAMILIES.has(family.trim().toLowerCase()))) return false;
}
return true;
}
function typeDictionaryStyleVariables(style, prefix) {
const declarations = style.cssText;
if (!declarations.includes("--")) return;
const suffixes = [];
// CSSOM has already balanced the declaration block, and residual escapes
// were rejected. Keep strings/comments opaque while pairing parentheses.
// Aliases are typed at each var(); the dictionary's own names are renamed
// wherever they appear, in declarations and references alike.
style.cssText = declarations.replace(
/"[^"]*"|'[^']*'|\/\*[\s\S]*?\*\/|\bvar\(\s*(--[\w\P{ASCII}-]+)|(?<![\w\P{ASCII}-])--[\w\P{ASCII}-]+|[()]/giu,
(token, variable) => {
const name = variable ?? (token.startsWith("--") ? token : null);
if (name && !DICTIONARY_STYLE_VARIABLES.has(name)) {
if (variable) suffixes.push("");
return token.replace(name, prefix + name.slice(2));
}
if (variable) {
const numeric = variable === "--font-size-no-units";
suffixes.push(numeric ? " * 1)" : " 100%, transparent)");
return (numeric ? "calc(" : "color-mix(in srgb, ") + token;
}
if (token === "(") suffixes.push("");
return token === ")" ? token + suffixes.pop() : token;
},
);
}
function filterDictionaryStyleRules(parent, prefix) {
for (let index = parent.cssRules.length - 1; index >= 0; index -= 1) {
const rule = parent.cssRules[index];
const kind = rule.constructor.name;
if (kind === "CSSStyleRule" || kind === "CSSNestedDeclarations") {
if (!isSafeDictionaryStyle(rule.style)) {
parent.deleteRule(index);
continue;
}
typeDictionaryStyleVariables(rule.style, prefix);
} else if (!DICTIONARY_STYLE_GROUPS.has(kind)) {
// Global definitions (@font-face, @property, keyframes, imports, etc.)
// are not glossary-local even when written inside an @scope block.
parent.deleteRule(index);
continue;
}
if (rule.cssRules) filterDictionaryStyleRules(rule, prefix);
}
}
// Replaces whatever styles a previous generation installed in `host` rather
// than tracking the elements outside, so a caller can re-apply at any time.
// `host` is the shadow root (or document head) the popup lives in.
function applyDictionaryStyles(documentRef, host, generation, entries) {
for (const element of host.querySelectorAll(
"style[data-hoshidicts-dictionary-style]"
)) {
element.remove();
}
const applied = [];
const dictionaries = new Set();
let totalBytes = 0;
// Other custom properties belong to the dictionary. They are renamed under
// a prefix the page cannot read through the closed shadow root, so neither
// an inherited page value nor an @property registration can reach them.
const prefix = `--hd${Array.from(
documentRef.defaultView.crypto.getRandomValues(new Uint8Array(16)),
(byte) => byte.toString(16).padStart(2, "0"),
).join("")}-`;
for (const entry of Array.isArray(entries) ? entries : []) {
if (!isRecord(entry)) {
continue;
}
const dictionary = boundedString(entry.dictionary, 4096);
const entryStyles = boundedString(entry.styles, MAX_DICTIONARY_STYLE_BYTES + 1);
const styleBytes = utf8Length(entryStyles);
// Keep the existing stylesheet transport bounds. Containment is enforced
// by parsed scoping and the trusted card's paint boundary, not its size.
if (
!dictionary ||
!entryStyles ||
dictionaries.has(dictionary) ||
styleBytes > MAX_DICTIONARY_STYLE_BYTES ||
totalBytes + styleBytes > MAX_DICTIONARY_STYLES_BYTES
) {
continue;
}
dictionaries.add(dictionary);
totalBytes += styleBytes;
// Detached parsing cannot fetch resources. Only browser-serialized rules
// enter the controlled scope; raw closing braces must never reach it.
const sheet = new documentRef.defaultView.CSSStyleSheet();
sheet.replaceSync(entryStyles);
filterDictionaryStyleRules(sheet, prefix);
const style = documentRef.createElement("style");
style.dataset.hoshidictsDictionaryStyle = dictionary;
style.dataset.hoshidictsGeneration = String(generation);
style.textContent = [
`@scope (.gsm-hoshidicts-glossary-content[data-hoshidicts-dictionary=${documentRef.defaultView.CSS.escape(dictionary)}]) {`,
...[...sheet.cssRules].map((rule) => rule.cssText),
"}",
].join("\n");
host.appendChild(style);
applied.push(style);
}
return applied;
}
return {
appendExpressionRuby,
appendStructuredImage,
appendStructuredValue,
appendTextOnlyGlossary,
applyDictionaryStyles,
applyStructuredData,
applyStructuredStyle,
boundedString,
buildPitchAccentMorae,
createFuriganaSegment,
getFuriganaKanaSegments,
isRecord,
isSafeCssToken,
normalizeColor,
normalizeLengthSequence,
normalizeLengthToken,
normalizeMediaPath,
normalizeStructuredStyleValue,
parseStructuredLink,
parseTagList,
segmentFurigana,
segmentizeFurigana,
selectPitchAccent,
splitPitchAccentMorae,
structuredDataAttributeName,
toHiragana,
};
}));