feat(cjk): support inline annotation(warichu, Gezhu) layout (#3934)
* feat(warichu): support warichu (割注) inline annotation layout
- Add warichu HTML transformer that converts <span class="warichu"> and
<warichu> elements into .warichu-pending placeholders during content load
- Implement runtime layout (layoutWarichu / relayoutWarichu) that measures
column position and splits text into small inline-block chunks (2 chars
each) so CSS column boundaries can break between them, preventing large
blank gaps in vertical-rl pagination
- Use column stride (column-width + column-gap) for accurate position
measurement across column boundaries
- Hook into stabilized event for initial layout and relayout on resize
- Add warichu CSS styles (inline-block chunks, half-size font, vertical align)
* fix(warichu): correct HTML slicing edge cases
- sliceHtml: re-emit tags that were already open before the slice start
so the result stays well-formed. Previously a slice past an opening
tag produced an orphan closing tag (e.g. "<b>Hello</b>"[3,5] →
"lo</b>" instead of "<b>lo</b>").
- sliceHtml / removeFirstVisibleChar / removeLastVisibleChar: treat HTML
entities (e.g. &) as one visible character so they aren't split or
truncated mid-entity (e.g. removeFirstVisibleChar("&rest") →
"amp;rest").
- buildNodes: drop a duplicate chunk.appendChild(l2).
- Add unit tests covering the above for the three pure helpers.
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { sliceHtml, removeFirstVisibleChar, removeLastVisibleChar } from '@/utils/warichu';
|
||||
|
||||
describe('sliceHtml', () => {
|
||||
it('returns the slice of plain text by visible-char positions', () => {
|
||||
expect(sliceHtml('Hello', 0, 3)).toBe('Hel');
|
||||
expect(sliceHtml('Hello', 2, 5)).toBe('llo');
|
||||
});
|
||||
|
||||
it('keeps a fully-enclosing tag intact when the slice covers all visible chars', () => {
|
||||
expect(sliceHtml('<b>Hello</b>', 0, 5)).toBe('<b>Hello</b>');
|
||||
});
|
||||
|
||||
it('keeps a fully-enclosing tag intact when slicing the prefix', () => {
|
||||
expect(sliceHtml('<b>Hello</b>', 0, 2)).toBe('<b>He</b>');
|
||||
});
|
||||
|
||||
it('re-emits tags that were open before the slice start', () => {
|
||||
// Bug: previously returned "lo</b>" — orphan close tag.
|
||||
expect(sliceHtml('<b>Hello</b>', 3, 5)).toBe('<b>lo</b>');
|
||||
expect(sliceHtml('<b>Hello</b>', 2, 4)).toBe('<b>ll</b>');
|
||||
});
|
||||
|
||||
it('handles nested tags open before the slice', () => {
|
||||
expect(sliceHtml('<b><i>Hello</i></b>', 2, 4)).toBe('<b><i>ll</i></b>');
|
||||
});
|
||||
|
||||
it('treats HTML entities as one visible character', () => {
|
||||
// "A&B" has 3 visible chars: A, &, B
|
||||
expect(sliceHtml('A&B', 0, 1)).toBe('A');
|
||||
expect(sliceHtml('A&B', 1, 2)).toBe('&');
|
||||
expect(sliceHtml('A&B', 0, 2)).toBe('A&');
|
||||
expect(sliceHtml('A&B', 2, 3)).toBe('B');
|
||||
});
|
||||
|
||||
it('returns empty string when slice range is empty', () => {
|
||||
expect(sliceHtml('Hello', 2, 2)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeFirstVisibleChar', () => {
|
||||
it('removes the first plain character', () => {
|
||||
expect(removeFirstVisibleChar('Hello')).toBe('ello');
|
||||
});
|
||||
|
||||
it('skips opening tags and removes the first text character after them', () => {
|
||||
expect(removeFirstVisibleChar('<b>Hello</b>')).toBe('<b>ello</b>');
|
||||
});
|
||||
|
||||
it('treats an HTML entity as a single visible character and removes it whole', () => {
|
||||
// Bug: previously returned "amp;rest" — corrupt.
|
||||
expect(removeFirstVisibleChar('&rest')).toBe('rest');
|
||||
expect(removeFirstVisibleChar('<X')).toBe('X');
|
||||
});
|
||||
|
||||
it('handles a tag followed by an entity', () => {
|
||||
expect(removeFirstVisibleChar('<b>&rest</b>')).toBe('<b>rest</b>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeLastVisibleChar', () => {
|
||||
it('removes the last plain character', () => {
|
||||
expect(removeLastVisibleChar('Hello')).toBe('Hell');
|
||||
});
|
||||
|
||||
it('skips closing tags and removes the last text character before them', () => {
|
||||
expect(removeLastVisibleChar('<b>Hello</b>')).toBe('<b>Hell</b>');
|
||||
});
|
||||
|
||||
it('treats a trailing HTML entity as a single visible character and removes it whole', () => {
|
||||
// Bug: previously returned "front&" — corrupt.
|
||||
expect(removeLastVisibleChar('front&')).toBe('front');
|
||||
expect(removeLastVisibleChar('X>')).toBe('X');
|
||||
});
|
||||
|
||||
it('handles an entity wrapped in a closing tag', () => {
|
||||
expect(removeLastVisibleChar('<b>front&</b>')).toBe('<b>front</b>');
|
||||
});
|
||||
|
||||
it('treats a stray semicolon (not part of an entity) as a single character', () => {
|
||||
expect(removeLastVisibleChar('Hello;')).toBe('Hello');
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
transformStylesheet,
|
||||
} from '@/utils/style';
|
||||
import { mountAdditionalFonts, mountCustomFont } from '@/styles/fonts';
|
||||
import { layoutWarichu, relayoutWarichu } from '@/utils/warichu';
|
||||
import { getBookDirFromLanguage, getBookDirFromWritingMode } from '@/utils/book';
|
||||
import { getIndexFromCfi } from '@/utils/cfi';
|
||||
import { useUICSS } from '@/hooks/useUICSS';
|
||||
@@ -175,6 +176,7 @@ const FoliateViewer: React.FC<{
|
||||
'sanitizer',
|
||||
'simplecc',
|
||||
'proofread',
|
||||
'warichu',
|
||||
],
|
||||
};
|
||||
return Promise.resolve(transformContent(ctx));
|
||||
@@ -329,6 +331,19 @@ const FoliateViewer: React.FC<{
|
||||
|
||||
const stabilizedHandler = useCallback(() => {
|
||||
setLoading(false);
|
||||
// Layout/relayout warichu after paginator has set column-width via columnize()
|
||||
const contents = viewRef.current?.renderer?.getContents?.() || [];
|
||||
for (const { doc } of contents) {
|
||||
if (doc) {
|
||||
const hasPending = doc.querySelectorAll('.warichu-pending').length > 0;
|
||||
const hasExisting = doc.querySelectorAll('.warichu-head').length > 0;
|
||||
if (hasPending) {
|
||||
layoutWarichu(doc);
|
||||
} else if (hasExisting) {
|
||||
relayoutWarichu(doc);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const docRelocateHandler = (event: Event) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { sanitizerTransformer } from './sanitizer';
|
||||
import { simpleccTransformer } from './simplecc';
|
||||
import { styleTransformer } from './style';
|
||||
import { proofreadTransformer } from './proofread';
|
||||
import { warichuTransformer } from './warichu';
|
||||
|
||||
export const availableTransformers: Transformer[] = [
|
||||
punctuationTransformer,
|
||||
@@ -17,5 +18,6 @@ export const availableTransformers: Transformer[] = [
|
||||
sanitizerTransformer,
|
||||
simpleccTransformer,
|
||||
proofreadTransformer,
|
||||
warichuTransformer,
|
||||
// Add more transformers here
|
||||
];
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import type { Transformer } from './types';
|
||||
|
||||
const extractParens = (text: string): { open: string; content: string; close: string } => {
|
||||
let open = '';
|
||||
let close = '';
|
||||
let content = text;
|
||||
const openParens = ['(', '(', '〔', '[', '【'];
|
||||
const closeParens = [')', ')', '〕', ']', '】'];
|
||||
for (let i = 0; i < openParens.length; i++) {
|
||||
if (content.startsWith(openParens[i]!)) {
|
||||
open = openParens[i]!;
|
||||
content = content.slice(open.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < closeParens.length; i++) {
|
||||
if (content.endsWith(closeParens[i]!)) {
|
||||
close = closeParens[i]!;
|
||||
content = content.slice(0, -close.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { open, content, close };
|
||||
};
|
||||
|
||||
const escapeAttr = (s: string) => s.replace(/&/g, '&').replace(/"/g, '"');
|
||||
|
||||
// Tags that are safe to keep — they don't change font size or layout dimensions.
|
||||
const SAFE_TAGS = new Set([
|
||||
'b',
|
||||
'i',
|
||||
'em',
|
||||
'strong',
|
||||
'u',
|
||||
's',
|
||||
'strike',
|
||||
'del',
|
||||
'ins',
|
||||
'mark',
|
||||
'span',
|
||||
'a',
|
||||
]);
|
||||
|
||||
// Style properties that affect font size — strip these from inline styles.
|
||||
const SIZE_STYLE_PROPS =
|
||||
/font-size|font-family|line-height|display|position|margin|padding|width|height/i;
|
||||
|
||||
/**
|
||||
* Strip tags that affect font size/layout, keep safe styling tags.
|
||||
* Returns { html: sanitized HTML string, text: plain text for measurement }.
|
||||
*/
|
||||
const sanitizeInnerHTML = (innerHtml: string): { html: string; text: string } => {
|
||||
// Use a temporary element to parse the HTML properly
|
||||
const tmp = document.createElement('div');
|
||||
tmp.innerHTML = innerHtml;
|
||||
|
||||
const processNode = (node: Node): { html: string; text: string } => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const t = node.textContent || '';
|
||||
return {
|
||||
html: t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'),
|
||||
text: t,
|
||||
};
|
||||
}
|
||||
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||
return { html: '', text: '' };
|
||||
}
|
||||
|
||||
const el = node as HTMLElement;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
|
||||
// Recursively process children
|
||||
let childHtml = '';
|
||||
let childText = '';
|
||||
for (const child of Array.from(el.childNodes)) {
|
||||
const r = processNode(child);
|
||||
childHtml += r.html;
|
||||
childText += r.text;
|
||||
}
|
||||
|
||||
// Tags that change font size or disrupt layout — unwrap (keep children only)
|
||||
if (!SAFE_TAGS.has(tag)) {
|
||||
return { html: childHtml, text: childText };
|
||||
}
|
||||
|
||||
// For safe tags, keep the element but strip size-affecting style properties
|
||||
let attrs = '';
|
||||
for (const attr of Array.from(el.attributes)) {
|
||||
if (attr.name === 'style') {
|
||||
// Filter out size-affecting properties
|
||||
const filtered = attr.value
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s && !SIZE_STYLE_PROPS.test(s.split(':')[0] || ''))
|
||||
.join('; ');
|
||||
if (filtered) {
|
||||
attrs += ` style="${escapeAttr(filtered)}"`;
|
||||
}
|
||||
} else if (attr.name === 'class' || attr.name === 'id' || attr.name === 'lang') {
|
||||
attrs += ` ${attr.name}="${escapeAttr(attr.value)}"`;
|
||||
}
|
||||
// Skip other attributes (href, tabindex, etc.)
|
||||
}
|
||||
|
||||
return {
|
||||
html: `<${tag}${attrs}>${childHtml}</${tag}>`,
|
||||
text: childText,
|
||||
};
|
||||
};
|
||||
|
||||
let html = '';
|
||||
let text = '';
|
||||
for (const child of Array.from(tmp.childNodes)) {
|
||||
const r = processNode(child);
|
||||
html += r.html;
|
||||
text += r.text;
|
||||
}
|
||||
|
||||
return { html: html.trim(), text: text.trim() };
|
||||
};
|
||||
|
||||
const WARICHU_SPAN_REGEX =
|
||||
/<span\s+[^>]*class\s*=\s*["'][^"']*\bwarichuu?\b[^"']*["'][^>]*>([\s\S]*?)<\/span>/gi;
|
||||
const WARICHU_ELEMENT_REGEX = /<warichu(?:\s[^>]*)?>([\s\S]*?)<\/warichu>/gi;
|
||||
|
||||
export const warichuTransformer: Transformer = {
|
||||
name: 'warichu',
|
||||
transform: async (ctx) => {
|
||||
let result = ctx.content;
|
||||
if (!/warichuu?|<warichu/i.test(result)) return result;
|
||||
|
||||
const replace = (_: string, innerText: string) => {
|
||||
const { html, text } = sanitizeInnerHTML(innerText);
|
||||
if (!text) return '';
|
||||
const { open, content, close } = extractParens(text);
|
||||
// Also extract parens from the HTML version, keeping tags intact
|
||||
const htmlParens = extractHtmlParens(html, open, close);
|
||||
return (
|
||||
`<span class="warichu-pending"` +
|
||||
` data-open="${escapeAttr(open)}"` +
|
||||
` data-close="${escapeAttr(close)}"` +
|
||||
` data-text="${escapeAttr(content)}"` +
|
||||
` data-html="${escapeAttr(htmlParens)}"` +
|
||||
`>${open}${content}${close}</span>`
|
||||
);
|
||||
};
|
||||
|
||||
result = result.replace(WARICHU_SPAN_REGEX, replace);
|
||||
result = result.replace(WARICHU_ELEMENT_REGEX, replace);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove the open/close parens from the HTML string, preserving internal tags.
|
||||
* We strip the first `open.length` visible characters from the start and
|
||||
* the last `close.length` visible characters from the end.
|
||||
*/
|
||||
function extractHtmlParens(html: string, open: string, close: string): string {
|
||||
let result = html;
|
||||
if (open) {
|
||||
result = stripVisibleChars(result, open.length, 'start');
|
||||
}
|
||||
if (close) {
|
||||
result = stripVisibleChars(result, close.length, 'end');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip `count` visible (non-tag) characters from the start or end of an HTML string.
|
||||
*/
|
||||
function stripVisibleChars(html: string, count: number, from: 'start' | 'end'): string {
|
||||
if (count <= 0) return html;
|
||||
|
||||
if (from === 'end') {
|
||||
// Reverse approach: strip from the end
|
||||
const reversed = reverseHtml(html);
|
||||
const stripped = stripVisibleChars(reversed, count, 'start');
|
||||
return reverseHtml(stripped);
|
||||
}
|
||||
|
||||
// Strip `count` visible characters from the start
|
||||
let stripped = 0;
|
||||
let i = 0;
|
||||
while (i < html.length && stripped < count) {
|
||||
if (html[i] === '<') {
|
||||
// Skip over tag
|
||||
const end = html.indexOf('>', i);
|
||||
if (end === -1) break;
|
||||
i = end + 1;
|
||||
} else if (html[i] === '&') {
|
||||
// HTML entity — counts as 1 visible char
|
||||
const semi = html.indexOf(';', i);
|
||||
if (semi !== -1 && semi - i < 10) {
|
||||
i = semi + 1;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
stripped++;
|
||||
} else {
|
||||
i++;
|
||||
stripped++;
|
||||
}
|
||||
}
|
||||
return html.slice(i);
|
||||
}
|
||||
|
||||
/** Reverse an HTML string by reversing visible characters while keeping tags in place. */
|
||||
function reverseHtml(html: string): string {
|
||||
// Simple approach: extract segments of [tag | char], reverse the chars
|
||||
const segments: { type: 'tag' | 'char'; value: string }[] = [];
|
||||
let i = 0;
|
||||
while (i < html.length) {
|
||||
if (html[i] === '<') {
|
||||
const end = html.indexOf('>', i);
|
||||
if (end === -1) {
|
||||
segments.push({ type: 'char', value: html[i]! });
|
||||
i++;
|
||||
} else {
|
||||
segments.push({ type: 'tag', value: html.slice(i, end + 1) });
|
||||
i = end + 1;
|
||||
}
|
||||
} else if (html[i] === '&') {
|
||||
const semi = html.indexOf(';', i);
|
||||
if (semi !== -1 && semi - i < 10) {
|
||||
segments.push({ type: 'char', value: html.slice(i, semi + 1) });
|
||||
i = semi + 1;
|
||||
} else {
|
||||
segments.push({ type: 'char', value: html[i]! });
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
segments.push({ type: 'char', value: html[i]! });
|
||||
i++;
|
||||
}
|
||||
}
|
||||
// Reverse: keep tags at their relative positions but reverse char order
|
||||
const chars = segments.filter((s) => s.type === 'char').reverse();
|
||||
let ci = 0;
|
||||
return segments
|
||||
.map((s) => {
|
||||
if (s.type === 'tag') return s.value;
|
||||
return chars[ci++]?.value || '';
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
@@ -546,6 +546,34 @@ const getTranslationStyles = (showSource: boolean) => `
|
||||
}
|
||||
`;
|
||||
|
||||
const getWarichuStyles = () => `
|
||||
/* Warichu (割注/夹注) — double-line inline annotation */
|
||||
.warichu-pending {
|
||||
display: inline;
|
||||
font-size: 0.5em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.warichu-chunk {
|
||||
display: inline-block;
|
||||
line-height: 1.1;
|
||||
font-size: 0.5em;
|
||||
text-indent: 0;
|
||||
vertical-align: middle !important;
|
||||
width: 1lh !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
.warichu-chunk .warichu-line {
|
||||
display: inline;
|
||||
}
|
||||
.warichu-open,
|
||||
.warichu-close {
|
||||
display: inline;
|
||||
font-size: 0.5em;
|
||||
vertical-align: middle;
|
||||
line-height: 1.1;
|
||||
}
|
||||
`;
|
||||
|
||||
export interface ThemeCode {
|
||||
bg: string;
|
||||
fg: string;
|
||||
@@ -641,8 +669,9 @@ export const getStyles = (viewSettings: ViewSettings, themeCode?: ThemeCode) =>
|
||||
viewSettings.isEink,
|
||||
);
|
||||
const translationStyles = getTranslationStyles(viewSettings.showTranslateSource!);
|
||||
const warichuStyles = getWarichuStyles();
|
||||
const userStylesheet = viewSettings.userStylesheet!;
|
||||
return `${pageLayoutStyles}\n${paragraphLayoutStyles}\n${fontStyles}\n${colorStyles}\n${translationStyles}\n${userStylesheet}`;
|
||||
return `${pageLayoutStyles}\n${paragraphLayoutStyles}\n${fontStyles}\n${colorStyles}\n${translationStyles}\n${warichuStyles}\n${userStylesheet}`;
|
||||
};
|
||||
|
||||
export const applyTranslationStyle = (viewSettings: ViewSettings) => {
|
||||
|
||||
@@ -0,0 +1,607 @@
|
||||
/**
|
||||
* Warichu (割注/夹注) — Runtime Layout
|
||||
*
|
||||
* Each chunk is placed directly in the parent container (not nested in
|
||||
* a wrapper). All nodes belonging to the same warichu share a unique
|
||||
* data-warichu-id. The first node (.warichu-head) also carries the
|
||||
* full text and paren data for relayout.
|
||||
*/
|
||||
|
||||
let warichuIdCounter = 0;
|
||||
|
||||
// Per-document cache: skip relayout when column-width hasn't changed.
|
||||
const envCache = new WeakMap<Document, { columnSize: number; columnStride: number }>();
|
||||
|
||||
export function layoutWarichu(doc: Document): void {
|
||||
const pendingElements = Array.from(doc.querySelectorAll('.warichu-pending'));
|
||||
if (!pendingElements.length) return;
|
||||
|
||||
const env = getEnv(doc);
|
||||
if (!env) return;
|
||||
|
||||
// Update cache so subsequent relayout calls can detect changes
|
||||
envCache.set(doc, { columnSize: env.columnSize, columnStride: env.columnStride });
|
||||
|
||||
for (const el of pendingElements) {
|
||||
const span = el as HTMLElement;
|
||||
const { text, html, openParen, closeParen } = getData(span);
|
||||
if (!text) continue;
|
||||
|
||||
const wid = 'w' + warichuIdCounter++;
|
||||
|
||||
// Measure at the pending span's current visible position
|
||||
const firstChunkChars = measureFirstChunk(span, env, openParen);
|
||||
|
||||
// Build nodes
|
||||
const nodes = buildNodes(
|
||||
doc,
|
||||
wid,
|
||||
text,
|
||||
html,
|
||||
openParen,
|
||||
closeParen,
|
||||
firstChunkChars,
|
||||
env.fullLineChars,
|
||||
);
|
||||
|
||||
// Replace the pending span with the array of nodes
|
||||
span.replaceWith(...nodes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-layout warichu after resize — but only if column-width actually changed.
|
||||
*/
|
||||
export function relayoutWarichu(doc: Document): void {
|
||||
const heads = Array.from(doc.querySelectorAll('.warichu-head'));
|
||||
if (!heads.length) return;
|
||||
|
||||
const env = getEnv(doc);
|
||||
if (!env) return;
|
||||
|
||||
// Skip relayout if column-width hasn't changed since last layout
|
||||
const cached = envCache.get(doc);
|
||||
if (cached && cached.columnSize === env.columnSize && cached.columnStride === env.columnStride) {
|
||||
return;
|
||||
}
|
||||
envCache.set(doc, { columnSize: env.columnSize, columnStride: env.columnStride });
|
||||
|
||||
for (const h of heads) {
|
||||
const head = h as HTMLElement;
|
||||
const wid = head.dataset['warichuId'] || '';
|
||||
const text = head.dataset['text'] || '';
|
||||
const html = head.dataset['html'] || '';
|
||||
const openParen = head.dataset['open'] || '';
|
||||
const closeParen = head.dataset['close'] || '';
|
||||
if (!wid || !text) continue;
|
||||
|
||||
const parentEl = head.parentElement;
|
||||
if (!parentEl) continue;
|
||||
|
||||
// Find all nodes with this warichu id
|
||||
const allNodes = Array.from(parentEl.querySelectorAll(`[data-warichu-id="${wid}"]`));
|
||||
|
||||
// Keep the head node, remove the rest. We'll re-insert after the head.
|
||||
for (let i = 1; i < allNodes.length; i++) {
|
||||
allNodes[i]!.remove();
|
||||
}
|
||||
|
||||
// Clear head content to make it a tiny inline probe for measurement
|
||||
head.className = 'warichu-head';
|
||||
head.removeAttribute('style');
|
||||
head.textContent = '\u200B';
|
||||
head.style.display = 'inline';
|
||||
head.style.fontSize = '0.5em';
|
||||
head.style.lineHeight = '1.1';
|
||||
|
||||
const firstChunkChars = measureFirstChunk(head, env, openParen);
|
||||
|
||||
// Build new nodes (the first will replace the head)
|
||||
const nodes = buildNodes(
|
||||
doc,
|
||||
wid,
|
||||
text,
|
||||
html,
|
||||
openParen,
|
||||
closeParen,
|
||||
firstChunkChars,
|
||||
env.fullLineChars,
|
||||
);
|
||||
|
||||
// Insert all new nodes after the head, then remove the head
|
||||
const refNode = head.nextSibling;
|
||||
for (const node of nodes) {
|
||||
parentEl.insertBefore(node, refNode);
|
||||
}
|
||||
head.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Core measurement ──────────────────────────────────────────────────
|
||||
|
||||
function measureFirstChunk(el: HTMLElement, env: LayoutEnv, _openParen: string): number {
|
||||
const { isVertical } = env;
|
||||
const doc = el.ownerDocument;
|
||||
const parent = el.parentNode;
|
||||
if (!parent) return env.fullLineChars;
|
||||
|
||||
// Insert warichu-chunk probes one by one before the warichu element.
|
||||
// When a chunk lands in a different column (its cross-axis position shifts),
|
||||
// we know the previous chunk was the last that fit in the current column.
|
||||
const testChunks: HTMLElement[] = [];
|
||||
let fitChars = 0;
|
||||
let firstCrossPos: number | null = null;
|
||||
|
||||
for (let n = 1; n <= Math.ceil(env.fullLineChars / CHARS_PER_CHUNK) + 2; n++) {
|
||||
const chunk = doc.createElement('span');
|
||||
chunk.className = 'warichu-chunk';
|
||||
chunk.style.visibility = 'hidden';
|
||||
const l1 = doc.createElement('span');
|
||||
l1.className = 'warichu-line';
|
||||
l1.textContent = '国'.repeat(CHARS_PER_CHUNK);
|
||||
chunk.appendChild(l1);
|
||||
chunk.appendChild(doc.createElement('br'));
|
||||
const l2 = doc.createElement('span');
|
||||
l2.className = 'warichu-line';
|
||||
l2.textContent = '国'.repeat(CHARS_PER_CHUNK);
|
||||
chunk.appendChild(l2);
|
||||
|
||||
parent.insertBefore(chunk, el);
|
||||
testChunks.push(chunk);
|
||||
|
||||
const rect = chunk.getBoundingClientRect();
|
||||
// Cross-axis: in vertical-rl columns are side by side horizontally,
|
||||
// so cross-axis = left. In horizontal-tb, cross-axis = top.
|
||||
const crossPos = isVertical ? rect.left : rect.top;
|
||||
|
||||
if (firstCrossPos === null) {
|
||||
firstCrossPos = crossPos;
|
||||
} else if (Math.abs(crossPos - firstCrossPos) > 2) {
|
||||
// Jumped to a different column
|
||||
fitChars = (n - 1) * CHARS_PER_CHUNK;
|
||||
break;
|
||||
}
|
||||
fitChars = n * CHARS_PER_CHUNK;
|
||||
}
|
||||
|
||||
// Clean up
|
||||
for (const c of testChunks) c.remove();
|
||||
|
||||
return Math.max(2, fitChars);
|
||||
}
|
||||
|
||||
// ── Build nodes ───────────────────────────────────────────────────────
|
||||
|
||||
// Maximum characters per line in a single physical chunk. Keeping this
|
||||
// small ensures each inline-block chunk has a tiny inline-axis size,
|
||||
// allowing CSS column boundaries to break between adjacent chunks
|
||||
// instead of pushing an oversized block to the next column.
|
||||
const CHARS_PER_CHUNK = 2;
|
||||
|
||||
// Characters that must not appear at the start of a line (kinsoku).
|
||||
const KINSOKU_NO_START = new Set([
|
||||
',',
|
||||
'。',
|
||||
'、',
|
||||
')',
|
||||
')',
|
||||
'」',
|
||||
'』',
|
||||
'】',
|
||||
'〕',
|
||||
']',
|
||||
'!',
|
||||
'?',
|
||||
':',
|
||||
';',
|
||||
'.',
|
||||
'‥',
|
||||
'…',
|
||||
'・',
|
||||
]);
|
||||
|
||||
function buildNodes(
|
||||
doc: Document,
|
||||
wid: string,
|
||||
text: string,
|
||||
html: string,
|
||||
openParen: string,
|
||||
closeParen: string,
|
||||
firstChunkChars: number,
|
||||
fullLineChars: number,
|
||||
): Node[] {
|
||||
const nodes: Node[] = [];
|
||||
const totalChars = text.length;
|
||||
const useHtml = html.length > 0;
|
||||
|
||||
// Open paren
|
||||
if (openParen) {
|
||||
const op = doc.createElement('span');
|
||||
op.className = 'warichu-open';
|
||||
op.dataset['warichuId'] = wid;
|
||||
op.textContent = openParen;
|
||||
nodes.push(op);
|
||||
}
|
||||
|
||||
// ── Phase 1: generate (t1, t2) pairs grouped by segment (= column) ──
|
||||
// Track both plain text positions and corresponding HTML slices.
|
||||
const allPairs: { t1: string; t2: string; h1: string; h2: string }[] = [];
|
||||
const segBounds: { start: number; end: number }[] = [];
|
||||
|
||||
let off = 0;
|
||||
let isFirstSeg = true;
|
||||
while (off < totalChars) {
|
||||
const maxPerLine = isFirstSeg ? firstChunkChars : fullLineChars;
|
||||
isFirstSeg = false;
|
||||
const remaining = totalChars - off;
|
||||
const segLen = remaining <= maxPerLine * 2 ? remaining : maxPerLine * 2;
|
||||
const segText = text.slice(off, off + segLen);
|
||||
|
||||
const mid = Math.ceil(segText.length / 2);
|
||||
const topLine = segText.slice(0, mid);
|
||||
const botLine = segText.slice(mid);
|
||||
|
||||
// Get corresponding HTML slices
|
||||
const topHtml = useHtml ? sliceHtml(html, off, off + mid) : '';
|
||||
const botHtml = useHtml ? sliceHtml(html, off + mid, off + segLen) : '';
|
||||
|
||||
off += segLen;
|
||||
|
||||
const segStart = allPairs.length;
|
||||
const pairCount = Math.max(topLine.length, botLine.length);
|
||||
for (let ci = 0; ci < pairCount; ci += CHARS_PER_CHUNK) {
|
||||
const t1 = topLine.slice(ci, ci + CHARS_PER_CHUNK);
|
||||
const t2 = botLine.slice(ci, ci + CHARS_PER_CHUNK);
|
||||
const h1 = useHtml ? sliceHtmlRelative(topHtml, topLine, ci, ci + CHARS_PER_CHUNK) : '';
|
||||
const h2 = useHtml ? sliceHtmlRelative(botHtml, botLine, ci, ci + CHARS_PER_CHUNK) : '';
|
||||
if (t1 || t2) {
|
||||
allPairs.push({ t1, t2, h1, h2 });
|
||||
}
|
||||
}
|
||||
segBounds.push({ start: segStart, end: allPairs.length - 1 });
|
||||
}
|
||||
|
||||
// ── Phase 2: kinsoku at column (segment) boundaries ──
|
||||
for (let si = 0; si < segBounds.length; si++) {
|
||||
const { start, end } = segBounds[si]!;
|
||||
const first = allPairs[start];
|
||||
if (!first) continue;
|
||||
|
||||
if (first.t1.length > 0 && KINSOKU_NO_START.has(first.t1[0]!) && si > 0) {
|
||||
const prevEnd = segBounds[si - 1]!.end;
|
||||
const ch = first.t1[0]!;
|
||||
allPairs[prevEnd]!.t2 += ch;
|
||||
allPairs[prevEnd]!.h2 += ch;
|
||||
first.t1 = first.t1.slice(1);
|
||||
first.h1 = removeFirstVisibleChar(first.h1);
|
||||
}
|
||||
|
||||
if (first.t2.length > 0 && KINSOKU_NO_START.has(first.t2[0]!)) {
|
||||
if (start === end && first.t1.length > 1) {
|
||||
// Single-chunk segment: moving the kinsoku char to the end of t1
|
||||
// would leave t2 empty (e.g. "長東"/"。" → "長東。"/"").
|
||||
// Instead pull the last char of t1 down so the kinsoku char is
|
||||
// no longer at the start of t2. e.g. "長東"/"。" → "長"/"東。".
|
||||
const lastChar = first.t1[first.t1.length - 1]!;
|
||||
first.t1 = first.t1.slice(0, -1);
|
||||
first.t2 = lastChar + first.t2;
|
||||
if (first.h1) {
|
||||
first.h1 = removeLastVisibleChar(first.h1);
|
||||
}
|
||||
first.h2 = lastChar + first.h2;
|
||||
} else {
|
||||
// Multi-chunk segment or t1 has only 1 char: push the kinsoku
|
||||
// char to the end of the last chunk's top line.
|
||||
const ch = first.t2[0]!;
|
||||
allPairs[end]!.t1 += ch;
|
||||
allPairs[end]!.h1 += ch;
|
||||
first.t2 = first.t2.slice(1);
|
||||
first.h2 = removeFirstVisibleChar(first.h2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 3: create DOM nodes ──
|
||||
let isFirstHead = true;
|
||||
for (const { t1, t2, h1, h2 } of allPairs) {
|
||||
if (!t1 && !t2) continue;
|
||||
|
||||
const chunk = doc.createElement('span');
|
||||
chunk.className = 'warichu-chunk';
|
||||
chunk.dataset['warichuId'] = wid;
|
||||
|
||||
if (isFirstHead) {
|
||||
chunk.classList.add('warichu-head');
|
||||
chunk.dataset['text'] = text;
|
||||
chunk.dataset['html'] = html;
|
||||
chunk.dataset['open'] = openParen;
|
||||
chunk.dataset['close'] = closeParen;
|
||||
isFirstHead = false;
|
||||
}
|
||||
|
||||
const l1 = doc.createElement('span');
|
||||
l1.className = 'warichu-line';
|
||||
if (useHtml && h1) {
|
||||
l1.innerHTML = h1;
|
||||
} else {
|
||||
l1.textContent = t1 || '\u200B';
|
||||
}
|
||||
chunk.appendChild(l1);
|
||||
|
||||
chunk.appendChild(doc.createElement('br'));
|
||||
const l2 = doc.createElement('span');
|
||||
l2.className = 'warichu-line';
|
||||
if (useHtml && h2) {
|
||||
l2.innerHTML = h2;
|
||||
} else {
|
||||
l2.textContent = t2 || '\u200B';
|
||||
}
|
||||
chunk.appendChild(l2);
|
||||
|
||||
nodes.push(chunk);
|
||||
}
|
||||
|
||||
// Close paren
|
||||
if (closeParen) {
|
||||
const cp = doc.createElement('span');
|
||||
cp.className = 'warichu-close';
|
||||
cp.dataset['warichuId'] = wid;
|
||||
cp.textContent = closeParen;
|
||||
nodes.push(cp);
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
interface LayoutEnv {
|
||||
isVertical: boolean;
|
||||
oneCharInline: number;
|
||||
columnSize: number;
|
||||
columnStride: number; // column-width + column-gap
|
||||
fullLineChars: number;
|
||||
marginStart: number;
|
||||
}
|
||||
|
||||
function getData(el: HTMLElement) {
|
||||
return {
|
||||
text: el.dataset['text'] || '',
|
||||
html: el.dataset['html'] || '',
|
||||
openParen: el.dataset['open'] || '',
|
||||
closeParen: el.dataset['close'] || '',
|
||||
};
|
||||
}
|
||||
|
||||
function getEnv(doc: Document): LayoutEnv | null {
|
||||
const win = doc.defaultView;
|
||||
if (!win) return null;
|
||||
|
||||
const bodyStyle = win.getComputedStyle(doc.body);
|
||||
const htmlStyle = win.getComputedStyle(doc.documentElement);
|
||||
const writingMode = bodyStyle.writingMode || htmlStyle.writingMode || 'horizontal-tb';
|
||||
const isVertical = writingMode.includes('vertical');
|
||||
|
||||
const probe = doc.createElement('span');
|
||||
probe.style.fontSize = '0.5em';
|
||||
probe.style.lineHeight = '1.1';
|
||||
probe.style.visibility = 'hidden';
|
||||
probe.style.position = 'absolute';
|
||||
probe.style.whiteSpace = 'nowrap';
|
||||
probe.textContent = '国';
|
||||
doc.body.appendChild(probe);
|
||||
const probeRect = probe.getBoundingClientRect();
|
||||
const oneCharInline = isVertical ? probeRect.height : probeRect.width;
|
||||
doc.body.removeChild(probe);
|
||||
|
||||
const rootStyle = win.getComputedStyle(doc.documentElement);
|
||||
let columnSize = 0;
|
||||
|
||||
const cwProp = rootStyle.getPropertyValue('column-width');
|
||||
if (cwProp && cwProp !== 'auto') {
|
||||
columnSize = parseFloat(cwProp);
|
||||
}
|
||||
if (!columnSize || columnSize <= 0) {
|
||||
if (isVertical) {
|
||||
const v = rootStyle.getPropertyValue('--available-height');
|
||||
if (v) columnSize = parseFloat(v);
|
||||
} else {
|
||||
const v = rootStyle.getPropertyValue('--available-width');
|
||||
if (v) columnSize = parseFloat(v);
|
||||
}
|
||||
}
|
||||
if (!columnSize || columnSize <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Read column-gap for stride calculation
|
||||
const cgProp = rootStyle.getPropertyValue('column-gap');
|
||||
const columnGap = cgProp ? parseFloat(cgProp) : 0;
|
||||
const columnStride = columnSize + (columnGap > 0 ? columnGap : 0);
|
||||
|
||||
let marginStart = 0;
|
||||
if (isVertical) {
|
||||
const v = rootStyle.getPropertyValue('--page-margin-top');
|
||||
if (v) marginStart = parseFloat(v);
|
||||
} else {
|
||||
const v = rootStyle.getPropertyValue('--page-margin-left');
|
||||
if (v) marginStart = parseFloat(v);
|
||||
}
|
||||
|
||||
// Measure fullLineChars by placing actual warichu-chunk inline-blocks
|
||||
// and detecting when one jumps to a different column (cross-axis shift).
|
||||
const testContainer = doc.createElement('div');
|
||||
testContainer.style.visibility = 'hidden';
|
||||
doc.body.appendChild(testContainer);
|
||||
|
||||
let fullLineChars = 2;
|
||||
const maxChunks = Math.ceil(columnSize / oneCharInline / CHARS_PER_CHUNK) + 2;
|
||||
let firstCrossPos: number | null = null;
|
||||
|
||||
for (let n = 1; n <= maxChunks; n++) {
|
||||
const chunk = doc.createElement('span');
|
||||
chunk.className = 'warichu-chunk';
|
||||
const l1 = doc.createElement('span');
|
||||
l1.className = 'warichu-line';
|
||||
l1.textContent = '国'.repeat(CHARS_PER_CHUNK);
|
||||
chunk.appendChild(l1);
|
||||
chunk.appendChild(doc.createElement('br'));
|
||||
const l2 = doc.createElement('span');
|
||||
l2.className = 'warichu-line';
|
||||
l2.textContent = '国'.repeat(CHARS_PER_CHUNK);
|
||||
chunk.appendChild(l2);
|
||||
testContainer.appendChild(chunk);
|
||||
|
||||
const rect = chunk.getBoundingClientRect();
|
||||
const crossPos = isVertical ? rect.left : rect.top;
|
||||
|
||||
if (firstCrossPos === null) {
|
||||
firstCrossPos = crossPos;
|
||||
} else if (Math.abs(crossPos - firstCrossPos) > 2) {
|
||||
fullLineChars = Math.max(2, (n - 1) * CHARS_PER_CHUNK);
|
||||
break;
|
||||
}
|
||||
fullLineChars = n * CHARS_PER_CHUNK;
|
||||
}
|
||||
|
||||
testContainer.remove();
|
||||
|
||||
return { isVertical, oneCharInline, columnSize, columnStride, fullLineChars, marginStart };
|
||||
}
|
||||
|
||||
// ── HTML slicing helpers ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Slice an HTML string by visible character positions [start, end).
|
||||
* Tags are preserved and properly opened/closed. Tags that were already open
|
||||
* before `start` are re-emitted at the beginning of the slice so the result
|
||||
* stays well-formed. HTML entities (e.g. `&`) are treated as one visible
|
||||
* character.
|
||||
*/
|
||||
export function sliceHtml(html: string, start: number, end: number): string {
|
||||
let visibleIdx = 0;
|
||||
let i = 0;
|
||||
let result = '';
|
||||
const openTags: string[] = [];
|
||||
let entered = false;
|
||||
|
||||
const enterSlice = () => {
|
||||
if (entered) return;
|
||||
entered = true;
|
||||
for (const t of openTags) result += `<${t}>`;
|
||||
};
|
||||
|
||||
while (i < html.length && visibleIdx < end) {
|
||||
if (html[i] === '<') {
|
||||
const tagEnd = html.indexOf('>', i);
|
||||
if (tagEnd === -1) break;
|
||||
const tag = html.slice(i, tagEnd + 1);
|
||||
const isClosing = tag.startsWith('</');
|
||||
const isSelfClosing = tag.endsWith('/>');
|
||||
if (visibleIdx >= start) {
|
||||
enterSlice();
|
||||
result += tag;
|
||||
}
|
||||
if (!isClosing && !isSelfClosing) {
|
||||
const tagName = tag.match(/^<(\w+)/)?.[1] || '';
|
||||
if (tagName) openTags.push(tagName);
|
||||
} else if (isClosing) {
|
||||
const tagName = tag.match(/^<\/(\w+)/)?.[1] || '';
|
||||
const lastOpen = openTags.lastIndexOf(tagName);
|
||||
if (lastOpen !== -1) openTags.splice(lastOpen, 1);
|
||||
}
|
||||
i = tagEnd + 1;
|
||||
} else {
|
||||
const charLen = entityLengthAt(html, i);
|
||||
if (visibleIdx >= start && visibleIdx < end) {
|
||||
enterSlice();
|
||||
result += html.slice(i, i + charLen);
|
||||
}
|
||||
visibleIdx++;
|
||||
i += charLen;
|
||||
}
|
||||
}
|
||||
|
||||
// Close any unclosed tags
|
||||
for (let t = openTags.length - 1; t >= 0; t--) {
|
||||
result += `</${openTags[t]}>`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Length of the visible character starting at `i` — 1 for normal chars,
|
||||
* or the entity length (e.g. 5 for `&`) when an entity starts here.
|
||||
*/
|
||||
function entityLengthAt(html: string, i: number): number {
|
||||
if (html[i] !== '&') return 1;
|
||||
const semi = html.indexOf(';', i + 1);
|
||||
if (semi === -1 || semi - i >= 10) return 1;
|
||||
// Validate entity body to avoid swallowing arbitrary `&...;` text
|
||||
const body = html.slice(i + 1, semi);
|
||||
return /^#?[a-zA-Z0-9]+$/.test(body) ? semi - i + 1 : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice a sub-HTML string relative to a plain text line.
|
||||
* `lineHtml` is the HTML for the full line, `lineText` is the plain text.
|
||||
* Returns the HTML corresponding to lineText[start:end].
|
||||
*/
|
||||
function sliceHtmlRelative(lineHtml: string, lineText: string, start: number, end: number): string {
|
||||
if (!lineHtml) return lineText.slice(start, end);
|
||||
// Map from the line's text positions to the full HTML
|
||||
const actualEnd = Math.min(end, lineText.length);
|
||||
return sliceHtml(lineHtml, start, actualEnd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the first visible (non-tag) character from an HTML string.
|
||||
* HTML entities (e.g. `&`) are treated as one visible character and
|
||||
* removed in their entirety.
|
||||
*/
|
||||
export function removeFirstVisibleChar(html: string): string {
|
||||
let i = 0;
|
||||
while (i < html.length) {
|
||||
if (html[i] === '<') {
|
||||
const end = html.indexOf('>', i);
|
||||
if (end === -1) break;
|
||||
i = end + 1;
|
||||
} else {
|
||||
// Found first visible char — remove it (full entity if applicable)
|
||||
const charLen = entityLengthAt(html, i);
|
||||
return html.slice(0, i) + html.slice(i + charLen);
|
||||
}
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the last visible (non-tag) character from an HTML string.
|
||||
* HTML entities (e.g. `&`) are treated as one visible character and
|
||||
* removed in their entirety.
|
||||
*/
|
||||
export function removeLastVisibleChar(html: string): string {
|
||||
let i = html.length - 1;
|
||||
while (i >= 0) {
|
||||
if (html[i] === '>') {
|
||||
const start = html.lastIndexOf('<', i);
|
||||
if (start === -1) break;
|
||||
i = start - 1;
|
||||
} else if (html[i] === ';') {
|
||||
// Possible entity end — look back for `&` and validate the body.
|
||||
const amp = html.lastIndexOf('&', i);
|
||||
if (amp !== -1 && i - amp < 10) {
|
||||
const body = html.slice(amp + 1, i);
|
||||
if (/^#?[a-zA-Z0-9]+$/.test(body)) {
|
||||
return html.slice(0, amp) + html.slice(i + 1);
|
||||
}
|
||||
}
|
||||
return html.slice(0, i) + html.slice(i + 1);
|
||||
} else {
|
||||
// Found last visible char — remove it
|
||||
return html.slice(0, i) + html.slice(i + 1);
|
||||
}
|
||||
}
|
||||
return html;
|
||||
}
|
||||
Reference in New Issue
Block a user