diff --git a/apps/readest-app/src/__tests__/utils/warichu.test.ts b/apps/readest-app/src/__tests__/utils/warichu.test.ts
new file mode 100644
index 00000000..23eeb131
--- /dev/null
+++ b/apps/readest-app/src/__tests__/utils/warichu.test.ts
@@ -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('Hello', 0, 5)).toBe('Hello');
+ });
+
+ it('keeps a fully-enclosing tag intact when slicing the prefix', () => {
+ expect(sliceHtml('Hello', 0, 2)).toBe('He');
+ });
+
+ it('re-emits tags that were open before the slice start', () => {
+ // Bug: previously returned "lo" — orphan close tag.
+ expect(sliceHtml('Hello', 3, 5)).toBe('lo');
+ expect(sliceHtml('Hello', 2, 4)).toBe('ll');
+ });
+
+ it('handles nested tags open before the slice', () => {
+ expect(sliceHtml('Hello', 2, 4)).toBe('ll');
+ });
+
+ 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('Hello')).toBe('ello');
+ });
+
+ 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('&rest')).toBe('rest');
+ });
+});
+
+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('Hello')).toBe('Hell');
+ });
+
+ 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('front&')).toBe('front');
+ });
+
+ it('treats a stray semicolon (not part of an entity) as a single character', () => {
+ expect(removeLastVisibleChar('Hello;')).toBe('Hello');
+ });
+});
diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx
index f66a2b84..e3e37e99 100644
--- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx
+++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx
@@ -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) => {
diff --git a/apps/readest-app/src/services/transformers/index.ts b/apps/readest-app/src/services/transformers/index.ts
index 0967f708..d1b94be8 100644
--- a/apps/readest-app/src/services/transformers/index.ts
+++ b/apps/readest-app/src/services/transformers/index.ts
@@ -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
];
diff --git a/apps/readest-app/src/services/transformers/warichu.ts b/apps/readest-app/src/services/transformers/warichu.ts
new file mode 100644
index 00000000..3edc6eab
--- /dev/null
+++ b/apps/readest-app/src/services/transformers/warichu.ts
@@ -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, '>'),
+ 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 =
+ /]*class\s*=\s*["'][^"']*\bwarichuu?\b[^"']*["'][^>]*>([\s\S]*?)<\/span>/gi;
+const WARICHU_ELEMENT_REGEX = /]*)?>([\s\S]*?)<\/warichu>/gi;
+
+export const warichuTransformer: Transformer = {
+ name: 'warichu',
+ transform: async (ctx) => {
+ let result = ctx.content;
+ if (!/warichuu?| {
+ 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 (
+ `${open}${content}${close}`
+ );
+ };
+
+ 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('');
+}
diff --git a/apps/readest-app/src/utils/style.ts b/apps/readest-app/src/utils/style.ts
index 2183e01f..d86b0577 100644
--- a/apps/readest-app/src/utils/style.ts
+++ b/apps/readest-app/src/utils/style.ts
@@ -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) => {
diff --git a/apps/readest-app/src/utils/warichu.ts b/apps/readest-app/src/utils/warichu.ts
new file mode 100644
index 00000000..63d73f17
--- /dev/null
+++ b/apps/readest-app/src/utils/warichu.ts
@@ -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();
+
+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;
+}