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}`, + 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(''); + 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 += ``; + } + + 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; +}