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. &amp;) as one visible character so they aren't split or
  truncated mid-entity (e.g. removeFirstVisibleChar("&amp;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:
loveheaven
2026-04-27 19:12:03 +08:00
committed by Huang Xin
parent fa61f40262
commit ebbbf104b2
6 changed files with 985 additions and 1 deletions
@@ -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&amp;B" has 3 visible chars: A, &, B
expect(sliceHtml('A&amp;B', 0, 1)).toBe('A');
expect(sliceHtml('A&amp;B', 1, 2)).toBe('&amp;');
expect(sliceHtml('A&amp;B', 0, 2)).toBe('A&amp;');
expect(sliceHtml('A&amp;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('&amp;rest')).toBe('rest');
expect(removeFirstVisibleChar('&lt;X')).toBe('X');
});
it('handles a tag followed by an entity', () => {
expect(removeFirstVisibleChar('<b>&amp;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&amp" — corrupt.
expect(removeLastVisibleChar('front&amp;')).toBe('front');
expect(removeLastVisibleChar('X&gt;')).toBe('X');
});
it('handles an entity wrapped in a closing tag', () => {
expect(removeLastVisibleChar('<b>front&amp;</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, '&amp;').replace(/"/g, '&quot;');
// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'),
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('');
}
+30 -1
View File
@@ -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) => {
+607
View File
@@ -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. `&amp;`) 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 `&amp;`) 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. `&amp;`) 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. `&amp;`) 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;
}