feat(cjk): support inline annotation(warichu, Gezhu) layout (#3934)
* feat(warichu): support warichu (割注) inline annotation layout
- Add warichu HTML transformer that converts <span class="warichu"> and
<warichu> elements into .warichu-pending placeholders during content load
- Implement runtime layout (layoutWarichu / relayoutWarichu) that measures
column position and splits text into small inline-block chunks (2 chars
each) so CSS column boundaries can break between them, preventing large
blank gaps in vertical-rl pagination
- Use column stride (column-width + column-gap) for accurate position
measurement across column boundaries
- Hook into stabilized event for initial layout and relayout on resize
- Add warichu CSS styles (inline-block chunks, half-size font, vertical align)
* fix(warichu): correct HTML slicing edge cases
- sliceHtml: re-emit tags that were already open before the slice start
so the result stays well-formed. Previously a slice past an opening
tag produced an orphan closing tag (e.g. "<b>Hello</b>"[3,5] →
"lo</b>" instead of "<b>lo</b>").
- sliceHtml / removeFirstVisibleChar / removeLastVisibleChar: treat HTML
entities (e.g. &) as one visible character so they aren't split or
truncated mid-entity (e.g. removeFirstVisibleChar("&rest") →
"amp;rest").
- buildNodes: drop a duplicate chunk.appendChild(l2).
- Add unit tests covering the above for the three pure helpers.
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
This commit is contained in:
@@ -546,6 +546,34 @@ const getTranslationStyles = (showSource: boolean) => `
|
||||
}
|
||||
`;
|
||||
|
||||
const getWarichuStyles = () => `
|
||||
/* Warichu (割注/夹注) — double-line inline annotation */
|
||||
.warichu-pending {
|
||||
display: inline;
|
||||
font-size: 0.5em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.warichu-chunk {
|
||||
display: inline-block;
|
||||
line-height: 1.1;
|
||||
font-size: 0.5em;
|
||||
text-indent: 0;
|
||||
vertical-align: middle !important;
|
||||
width: 1lh !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
.warichu-chunk .warichu-line {
|
||||
display: inline;
|
||||
}
|
||||
.warichu-open,
|
||||
.warichu-close {
|
||||
display: inline;
|
||||
font-size: 0.5em;
|
||||
vertical-align: middle;
|
||||
line-height: 1.1;
|
||||
}
|
||||
`;
|
||||
|
||||
export interface ThemeCode {
|
||||
bg: string;
|
||||
fg: string;
|
||||
@@ -641,8 +669,9 @@ export const getStyles = (viewSettings: ViewSettings, themeCode?: ThemeCode) =>
|
||||
viewSettings.isEink,
|
||||
);
|
||||
const translationStyles = getTranslationStyles(viewSettings.showTranslateSource!);
|
||||
const warichuStyles = getWarichuStyles();
|
||||
const userStylesheet = viewSettings.userStylesheet!;
|
||||
return `${pageLayoutStyles}\n${paragraphLayoutStyles}\n${fontStyles}\n${colorStyles}\n${translationStyles}\n${userStylesheet}`;
|
||||
return `${pageLayoutStyles}\n${paragraphLayoutStyles}\n${fontStyles}\n${colorStyles}\n${translationStyles}\n${warichuStyles}\n${userStylesheet}`;
|
||||
};
|
||||
|
||||
export const applyTranslationStyle = (viewSettings: ViewSettings) => {
|
||||
|
||||
@@ -0,0 +1,607 @@
|
||||
/**
|
||||
* Warichu (割注/夹注) — Runtime Layout
|
||||
*
|
||||
* Each chunk is placed directly in the parent container (not nested in
|
||||
* a wrapper). All nodes belonging to the same warichu share a unique
|
||||
* data-warichu-id. The first node (.warichu-head) also carries the
|
||||
* full text and paren data for relayout.
|
||||
*/
|
||||
|
||||
let warichuIdCounter = 0;
|
||||
|
||||
// Per-document cache: skip relayout when column-width hasn't changed.
|
||||
const envCache = new WeakMap<Document, { columnSize: number; columnStride: number }>();
|
||||
|
||||
export function layoutWarichu(doc: Document): void {
|
||||
const pendingElements = Array.from(doc.querySelectorAll('.warichu-pending'));
|
||||
if (!pendingElements.length) return;
|
||||
|
||||
const env = getEnv(doc);
|
||||
if (!env) return;
|
||||
|
||||
// Update cache so subsequent relayout calls can detect changes
|
||||
envCache.set(doc, { columnSize: env.columnSize, columnStride: env.columnStride });
|
||||
|
||||
for (const el of pendingElements) {
|
||||
const span = el as HTMLElement;
|
||||
const { text, html, openParen, closeParen } = getData(span);
|
||||
if (!text) continue;
|
||||
|
||||
const wid = 'w' + warichuIdCounter++;
|
||||
|
||||
// Measure at the pending span's current visible position
|
||||
const firstChunkChars = measureFirstChunk(span, env, openParen);
|
||||
|
||||
// Build nodes
|
||||
const nodes = buildNodes(
|
||||
doc,
|
||||
wid,
|
||||
text,
|
||||
html,
|
||||
openParen,
|
||||
closeParen,
|
||||
firstChunkChars,
|
||||
env.fullLineChars,
|
||||
);
|
||||
|
||||
// Replace the pending span with the array of nodes
|
||||
span.replaceWith(...nodes);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-layout warichu after resize — but only if column-width actually changed.
|
||||
*/
|
||||
export function relayoutWarichu(doc: Document): void {
|
||||
const heads = Array.from(doc.querySelectorAll('.warichu-head'));
|
||||
if (!heads.length) return;
|
||||
|
||||
const env = getEnv(doc);
|
||||
if (!env) return;
|
||||
|
||||
// Skip relayout if column-width hasn't changed since last layout
|
||||
const cached = envCache.get(doc);
|
||||
if (cached && cached.columnSize === env.columnSize && cached.columnStride === env.columnStride) {
|
||||
return;
|
||||
}
|
||||
envCache.set(doc, { columnSize: env.columnSize, columnStride: env.columnStride });
|
||||
|
||||
for (const h of heads) {
|
||||
const head = h as HTMLElement;
|
||||
const wid = head.dataset['warichuId'] || '';
|
||||
const text = head.dataset['text'] || '';
|
||||
const html = head.dataset['html'] || '';
|
||||
const openParen = head.dataset['open'] || '';
|
||||
const closeParen = head.dataset['close'] || '';
|
||||
if (!wid || !text) continue;
|
||||
|
||||
const parentEl = head.parentElement;
|
||||
if (!parentEl) continue;
|
||||
|
||||
// Find all nodes with this warichu id
|
||||
const allNodes = Array.from(parentEl.querySelectorAll(`[data-warichu-id="${wid}"]`));
|
||||
|
||||
// Keep the head node, remove the rest. We'll re-insert after the head.
|
||||
for (let i = 1; i < allNodes.length; i++) {
|
||||
allNodes[i]!.remove();
|
||||
}
|
||||
|
||||
// Clear head content to make it a tiny inline probe for measurement
|
||||
head.className = 'warichu-head';
|
||||
head.removeAttribute('style');
|
||||
head.textContent = '\u200B';
|
||||
head.style.display = 'inline';
|
||||
head.style.fontSize = '0.5em';
|
||||
head.style.lineHeight = '1.1';
|
||||
|
||||
const firstChunkChars = measureFirstChunk(head, env, openParen);
|
||||
|
||||
// Build new nodes (the first will replace the head)
|
||||
const nodes = buildNodes(
|
||||
doc,
|
||||
wid,
|
||||
text,
|
||||
html,
|
||||
openParen,
|
||||
closeParen,
|
||||
firstChunkChars,
|
||||
env.fullLineChars,
|
||||
);
|
||||
|
||||
// Insert all new nodes after the head, then remove the head
|
||||
const refNode = head.nextSibling;
|
||||
for (const node of nodes) {
|
||||
parentEl.insertBefore(node, refNode);
|
||||
}
|
||||
head.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Core measurement ──────────────────────────────────────────────────
|
||||
|
||||
function measureFirstChunk(el: HTMLElement, env: LayoutEnv, _openParen: string): number {
|
||||
const { isVertical } = env;
|
||||
const doc = el.ownerDocument;
|
||||
const parent = el.parentNode;
|
||||
if (!parent) return env.fullLineChars;
|
||||
|
||||
// Insert warichu-chunk probes one by one before the warichu element.
|
||||
// When a chunk lands in a different column (its cross-axis position shifts),
|
||||
// we know the previous chunk was the last that fit in the current column.
|
||||
const testChunks: HTMLElement[] = [];
|
||||
let fitChars = 0;
|
||||
let firstCrossPos: number | null = null;
|
||||
|
||||
for (let n = 1; n <= Math.ceil(env.fullLineChars / CHARS_PER_CHUNK) + 2; n++) {
|
||||
const chunk = doc.createElement('span');
|
||||
chunk.className = 'warichu-chunk';
|
||||
chunk.style.visibility = 'hidden';
|
||||
const l1 = doc.createElement('span');
|
||||
l1.className = 'warichu-line';
|
||||
l1.textContent = '国'.repeat(CHARS_PER_CHUNK);
|
||||
chunk.appendChild(l1);
|
||||
chunk.appendChild(doc.createElement('br'));
|
||||
const l2 = doc.createElement('span');
|
||||
l2.className = 'warichu-line';
|
||||
l2.textContent = '国'.repeat(CHARS_PER_CHUNK);
|
||||
chunk.appendChild(l2);
|
||||
|
||||
parent.insertBefore(chunk, el);
|
||||
testChunks.push(chunk);
|
||||
|
||||
const rect = chunk.getBoundingClientRect();
|
||||
// Cross-axis: in vertical-rl columns are side by side horizontally,
|
||||
// so cross-axis = left. In horizontal-tb, cross-axis = top.
|
||||
const crossPos = isVertical ? rect.left : rect.top;
|
||||
|
||||
if (firstCrossPos === null) {
|
||||
firstCrossPos = crossPos;
|
||||
} else if (Math.abs(crossPos - firstCrossPos) > 2) {
|
||||
// Jumped to a different column
|
||||
fitChars = (n - 1) * CHARS_PER_CHUNK;
|
||||
break;
|
||||
}
|
||||
fitChars = n * CHARS_PER_CHUNK;
|
||||
}
|
||||
|
||||
// Clean up
|
||||
for (const c of testChunks) c.remove();
|
||||
|
||||
return Math.max(2, fitChars);
|
||||
}
|
||||
|
||||
// ── Build nodes ───────────────────────────────────────────────────────
|
||||
|
||||
// Maximum characters per line in a single physical chunk. Keeping this
|
||||
// small ensures each inline-block chunk has a tiny inline-axis size,
|
||||
// allowing CSS column boundaries to break between adjacent chunks
|
||||
// instead of pushing an oversized block to the next column.
|
||||
const CHARS_PER_CHUNK = 2;
|
||||
|
||||
// Characters that must not appear at the start of a line (kinsoku).
|
||||
const KINSOKU_NO_START = new Set([
|
||||
',',
|
||||
'。',
|
||||
'、',
|
||||
')',
|
||||
')',
|
||||
'」',
|
||||
'』',
|
||||
'】',
|
||||
'〕',
|
||||
']',
|
||||
'!',
|
||||
'?',
|
||||
':',
|
||||
';',
|
||||
'.',
|
||||
'‥',
|
||||
'…',
|
||||
'・',
|
||||
]);
|
||||
|
||||
function buildNodes(
|
||||
doc: Document,
|
||||
wid: string,
|
||||
text: string,
|
||||
html: string,
|
||||
openParen: string,
|
||||
closeParen: string,
|
||||
firstChunkChars: number,
|
||||
fullLineChars: number,
|
||||
): Node[] {
|
||||
const nodes: Node[] = [];
|
||||
const totalChars = text.length;
|
||||
const useHtml = html.length > 0;
|
||||
|
||||
// Open paren
|
||||
if (openParen) {
|
||||
const op = doc.createElement('span');
|
||||
op.className = 'warichu-open';
|
||||
op.dataset['warichuId'] = wid;
|
||||
op.textContent = openParen;
|
||||
nodes.push(op);
|
||||
}
|
||||
|
||||
// ── Phase 1: generate (t1, t2) pairs grouped by segment (= column) ──
|
||||
// Track both plain text positions and corresponding HTML slices.
|
||||
const allPairs: { t1: string; t2: string; h1: string; h2: string }[] = [];
|
||||
const segBounds: { start: number; end: number }[] = [];
|
||||
|
||||
let off = 0;
|
||||
let isFirstSeg = true;
|
||||
while (off < totalChars) {
|
||||
const maxPerLine = isFirstSeg ? firstChunkChars : fullLineChars;
|
||||
isFirstSeg = false;
|
||||
const remaining = totalChars - off;
|
||||
const segLen = remaining <= maxPerLine * 2 ? remaining : maxPerLine * 2;
|
||||
const segText = text.slice(off, off + segLen);
|
||||
|
||||
const mid = Math.ceil(segText.length / 2);
|
||||
const topLine = segText.slice(0, mid);
|
||||
const botLine = segText.slice(mid);
|
||||
|
||||
// Get corresponding HTML slices
|
||||
const topHtml = useHtml ? sliceHtml(html, off, off + mid) : '';
|
||||
const botHtml = useHtml ? sliceHtml(html, off + mid, off + segLen) : '';
|
||||
|
||||
off += segLen;
|
||||
|
||||
const segStart = allPairs.length;
|
||||
const pairCount = Math.max(topLine.length, botLine.length);
|
||||
for (let ci = 0; ci < pairCount; ci += CHARS_PER_CHUNK) {
|
||||
const t1 = topLine.slice(ci, ci + CHARS_PER_CHUNK);
|
||||
const t2 = botLine.slice(ci, ci + CHARS_PER_CHUNK);
|
||||
const h1 = useHtml ? sliceHtmlRelative(topHtml, topLine, ci, ci + CHARS_PER_CHUNK) : '';
|
||||
const h2 = useHtml ? sliceHtmlRelative(botHtml, botLine, ci, ci + CHARS_PER_CHUNK) : '';
|
||||
if (t1 || t2) {
|
||||
allPairs.push({ t1, t2, h1, h2 });
|
||||
}
|
||||
}
|
||||
segBounds.push({ start: segStart, end: allPairs.length - 1 });
|
||||
}
|
||||
|
||||
// ── Phase 2: kinsoku at column (segment) boundaries ──
|
||||
for (let si = 0; si < segBounds.length; si++) {
|
||||
const { start, end } = segBounds[si]!;
|
||||
const first = allPairs[start];
|
||||
if (!first) continue;
|
||||
|
||||
if (first.t1.length > 0 && KINSOKU_NO_START.has(first.t1[0]!) && si > 0) {
|
||||
const prevEnd = segBounds[si - 1]!.end;
|
||||
const ch = first.t1[0]!;
|
||||
allPairs[prevEnd]!.t2 += ch;
|
||||
allPairs[prevEnd]!.h2 += ch;
|
||||
first.t1 = first.t1.slice(1);
|
||||
first.h1 = removeFirstVisibleChar(first.h1);
|
||||
}
|
||||
|
||||
if (first.t2.length > 0 && KINSOKU_NO_START.has(first.t2[0]!)) {
|
||||
if (start === end && first.t1.length > 1) {
|
||||
// Single-chunk segment: moving the kinsoku char to the end of t1
|
||||
// would leave t2 empty (e.g. "長東"/"。" → "長東。"/"").
|
||||
// Instead pull the last char of t1 down so the kinsoku char is
|
||||
// no longer at the start of t2. e.g. "長東"/"。" → "長"/"東。".
|
||||
const lastChar = first.t1[first.t1.length - 1]!;
|
||||
first.t1 = first.t1.slice(0, -1);
|
||||
first.t2 = lastChar + first.t2;
|
||||
if (first.h1) {
|
||||
first.h1 = removeLastVisibleChar(first.h1);
|
||||
}
|
||||
first.h2 = lastChar + first.h2;
|
||||
} else {
|
||||
// Multi-chunk segment or t1 has only 1 char: push the kinsoku
|
||||
// char to the end of the last chunk's top line.
|
||||
const ch = first.t2[0]!;
|
||||
allPairs[end]!.t1 += ch;
|
||||
allPairs[end]!.h1 += ch;
|
||||
first.t2 = first.t2.slice(1);
|
||||
first.h2 = removeFirstVisibleChar(first.h2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 3: create DOM nodes ──
|
||||
let isFirstHead = true;
|
||||
for (const { t1, t2, h1, h2 } of allPairs) {
|
||||
if (!t1 && !t2) continue;
|
||||
|
||||
const chunk = doc.createElement('span');
|
||||
chunk.className = 'warichu-chunk';
|
||||
chunk.dataset['warichuId'] = wid;
|
||||
|
||||
if (isFirstHead) {
|
||||
chunk.classList.add('warichu-head');
|
||||
chunk.dataset['text'] = text;
|
||||
chunk.dataset['html'] = html;
|
||||
chunk.dataset['open'] = openParen;
|
||||
chunk.dataset['close'] = closeParen;
|
||||
isFirstHead = false;
|
||||
}
|
||||
|
||||
const l1 = doc.createElement('span');
|
||||
l1.className = 'warichu-line';
|
||||
if (useHtml && h1) {
|
||||
l1.innerHTML = h1;
|
||||
} else {
|
||||
l1.textContent = t1 || '\u200B';
|
||||
}
|
||||
chunk.appendChild(l1);
|
||||
|
||||
chunk.appendChild(doc.createElement('br'));
|
||||
const l2 = doc.createElement('span');
|
||||
l2.className = 'warichu-line';
|
||||
if (useHtml && h2) {
|
||||
l2.innerHTML = h2;
|
||||
} else {
|
||||
l2.textContent = t2 || '\u200B';
|
||||
}
|
||||
chunk.appendChild(l2);
|
||||
|
||||
nodes.push(chunk);
|
||||
}
|
||||
|
||||
// Close paren
|
||||
if (closeParen) {
|
||||
const cp = doc.createElement('span');
|
||||
cp.className = 'warichu-close';
|
||||
cp.dataset['warichuId'] = wid;
|
||||
cp.textContent = closeParen;
|
||||
nodes.push(cp);
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
interface LayoutEnv {
|
||||
isVertical: boolean;
|
||||
oneCharInline: number;
|
||||
columnSize: number;
|
||||
columnStride: number; // column-width + column-gap
|
||||
fullLineChars: number;
|
||||
marginStart: number;
|
||||
}
|
||||
|
||||
function getData(el: HTMLElement) {
|
||||
return {
|
||||
text: el.dataset['text'] || '',
|
||||
html: el.dataset['html'] || '',
|
||||
openParen: el.dataset['open'] || '',
|
||||
closeParen: el.dataset['close'] || '',
|
||||
};
|
||||
}
|
||||
|
||||
function getEnv(doc: Document): LayoutEnv | null {
|
||||
const win = doc.defaultView;
|
||||
if (!win) return null;
|
||||
|
||||
const bodyStyle = win.getComputedStyle(doc.body);
|
||||
const htmlStyle = win.getComputedStyle(doc.documentElement);
|
||||
const writingMode = bodyStyle.writingMode || htmlStyle.writingMode || 'horizontal-tb';
|
||||
const isVertical = writingMode.includes('vertical');
|
||||
|
||||
const probe = doc.createElement('span');
|
||||
probe.style.fontSize = '0.5em';
|
||||
probe.style.lineHeight = '1.1';
|
||||
probe.style.visibility = 'hidden';
|
||||
probe.style.position = 'absolute';
|
||||
probe.style.whiteSpace = 'nowrap';
|
||||
probe.textContent = '国';
|
||||
doc.body.appendChild(probe);
|
||||
const probeRect = probe.getBoundingClientRect();
|
||||
const oneCharInline = isVertical ? probeRect.height : probeRect.width;
|
||||
doc.body.removeChild(probe);
|
||||
|
||||
const rootStyle = win.getComputedStyle(doc.documentElement);
|
||||
let columnSize = 0;
|
||||
|
||||
const cwProp = rootStyle.getPropertyValue('column-width');
|
||||
if (cwProp && cwProp !== 'auto') {
|
||||
columnSize = parseFloat(cwProp);
|
||||
}
|
||||
if (!columnSize || columnSize <= 0) {
|
||||
if (isVertical) {
|
||||
const v = rootStyle.getPropertyValue('--available-height');
|
||||
if (v) columnSize = parseFloat(v);
|
||||
} else {
|
||||
const v = rootStyle.getPropertyValue('--available-width');
|
||||
if (v) columnSize = parseFloat(v);
|
||||
}
|
||||
}
|
||||
if (!columnSize || columnSize <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Read column-gap for stride calculation
|
||||
const cgProp = rootStyle.getPropertyValue('column-gap');
|
||||
const columnGap = cgProp ? parseFloat(cgProp) : 0;
|
||||
const columnStride = columnSize + (columnGap > 0 ? columnGap : 0);
|
||||
|
||||
let marginStart = 0;
|
||||
if (isVertical) {
|
||||
const v = rootStyle.getPropertyValue('--page-margin-top');
|
||||
if (v) marginStart = parseFloat(v);
|
||||
} else {
|
||||
const v = rootStyle.getPropertyValue('--page-margin-left');
|
||||
if (v) marginStart = parseFloat(v);
|
||||
}
|
||||
|
||||
// Measure fullLineChars by placing actual warichu-chunk inline-blocks
|
||||
// and detecting when one jumps to a different column (cross-axis shift).
|
||||
const testContainer = doc.createElement('div');
|
||||
testContainer.style.visibility = 'hidden';
|
||||
doc.body.appendChild(testContainer);
|
||||
|
||||
let fullLineChars = 2;
|
||||
const maxChunks = Math.ceil(columnSize / oneCharInline / CHARS_PER_CHUNK) + 2;
|
||||
let firstCrossPos: number | null = null;
|
||||
|
||||
for (let n = 1; n <= maxChunks; n++) {
|
||||
const chunk = doc.createElement('span');
|
||||
chunk.className = 'warichu-chunk';
|
||||
const l1 = doc.createElement('span');
|
||||
l1.className = 'warichu-line';
|
||||
l1.textContent = '国'.repeat(CHARS_PER_CHUNK);
|
||||
chunk.appendChild(l1);
|
||||
chunk.appendChild(doc.createElement('br'));
|
||||
const l2 = doc.createElement('span');
|
||||
l2.className = 'warichu-line';
|
||||
l2.textContent = '国'.repeat(CHARS_PER_CHUNK);
|
||||
chunk.appendChild(l2);
|
||||
testContainer.appendChild(chunk);
|
||||
|
||||
const rect = chunk.getBoundingClientRect();
|
||||
const crossPos = isVertical ? rect.left : rect.top;
|
||||
|
||||
if (firstCrossPos === null) {
|
||||
firstCrossPos = crossPos;
|
||||
} else if (Math.abs(crossPos - firstCrossPos) > 2) {
|
||||
fullLineChars = Math.max(2, (n - 1) * CHARS_PER_CHUNK);
|
||||
break;
|
||||
}
|
||||
fullLineChars = n * CHARS_PER_CHUNK;
|
||||
}
|
||||
|
||||
testContainer.remove();
|
||||
|
||||
return { isVertical, oneCharInline, columnSize, columnStride, fullLineChars, marginStart };
|
||||
}
|
||||
|
||||
// ── HTML slicing helpers ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Slice an HTML string by visible character positions [start, end).
|
||||
* Tags are preserved and properly opened/closed. Tags that were already open
|
||||
* before `start` are re-emitted at the beginning of the slice so the result
|
||||
* stays well-formed. HTML entities (e.g. `&`) are treated as one visible
|
||||
* character.
|
||||
*/
|
||||
export function sliceHtml(html: string, start: number, end: number): string {
|
||||
let visibleIdx = 0;
|
||||
let i = 0;
|
||||
let result = '';
|
||||
const openTags: string[] = [];
|
||||
let entered = false;
|
||||
|
||||
const enterSlice = () => {
|
||||
if (entered) return;
|
||||
entered = true;
|
||||
for (const t of openTags) result += `<${t}>`;
|
||||
};
|
||||
|
||||
while (i < html.length && visibleIdx < end) {
|
||||
if (html[i] === '<') {
|
||||
const tagEnd = html.indexOf('>', i);
|
||||
if (tagEnd === -1) break;
|
||||
const tag = html.slice(i, tagEnd + 1);
|
||||
const isClosing = tag.startsWith('</');
|
||||
const isSelfClosing = tag.endsWith('/>');
|
||||
if (visibleIdx >= start) {
|
||||
enterSlice();
|
||||
result += tag;
|
||||
}
|
||||
if (!isClosing && !isSelfClosing) {
|
||||
const tagName = tag.match(/^<(\w+)/)?.[1] || '';
|
||||
if (tagName) openTags.push(tagName);
|
||||
} else if (isClosing) {
|
||||
const tagName = tag.match(/^<\/(\w+)/)?.[1] || '';
|
||||
const lastOpen = openTags.lastIndexOf(tagName);
|
||||
if (lastOpen !== -1) openTags.splice(lastOpen, 1);
|
||||
}
|
||||
i = tagEnd + 1;
|
||||
} else {
|
||||
const charLen = entityLengthAt(html, i);
|
||||
if (visibleIdx >= start && visibleIdx < end) {
|
||||
enterSlice();
|
||||
result += html.slice(i, i + charLen);
|
||||
}
|
||||
visibleIdx++;
|
||||
i += charLen;
|
||||
}
|
||||
}
|
||||
|
||||
// Close any unclosed tags
|
||||
for (let t = openTags.length - 1; t >= 0; t--) {
|
||||
result += `</${openTags[t]}>`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Length of the visible character starting at `i` — 1 for normal chars,
|
||||
* or the entity length (e.g. 5 for `&`) when an entity starts here.
|
||||
*/
|
||||
function entityLengthAt(html: string, i: number): number {
|
||||
if (html[i] !== '&') return 1;
|
||||
const semi = html.indexOf(';', i + 1);
|
||||
if (semi === -1 || semi - i >= 10) return 1;
|
||||
// Validate entity body to avoid swallowing arbitrary `&...;` text
|
||||
const body = html.slice(i + 1, semi);
|
||||
return /^#?[a-zA-Z0-9]+$/.test(body) ? semi - i + 1 : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice a sub-HTML string relative to a plain text line.
|
||||
* `lineHtml` is the HTML for the full line, `lineText` is the plain text.
|
||||
* Returns the HTML corresponding to lineText[start:end].
|
||||
*/
|
||||
function sliceHtmlRelative(lineHtml: string, lineText: string, start: number, end: number): string {
|
||||
if (!lineHtml) return lineText.slice(start, end);
|
||||
// Map from the line's text positions to the full HTML
|
||||
const actualEnd = Math.min(end, lineText.length);
|
||||
return sliceHtml(lineHtml, start, actualEnd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the first visible (non-tag) character from an HTML string.
|
||||
* HTML entities (e.g. `&`) are treated as one visible character and
|
||||
* removed in their entirety.
|
||||
*/
|
||||
export function removeFirstVisibleChar(html: string): string {
|
||||
let i = 0;
|
||||
while (i < html.length) {
|
||||
if (html[i] === '<') {
|
||||
const end = html.indexOf('>', i);
|
||||
if (end === -1) break;
|
||||
i = end + 1;
|
||||
} else {
|
||||
// Found first visible char — remove it (full entity if applicable)
|
||||
const charLen = entityLengthAt(html, i);
|
||||
return html.slice(0, i) + html.slice(i + charLen);
|
||||
}
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the last visible (non-tag) character from an HTML string.
|
||||
* HTML entities (e.g. `&`) are treated as one visible character and
|
||||
* removed in their entirety.
|
||||
*/
|
||||
export function removeLastVisibleChar(html: string): string {
|
||||
let i = html.length - 1;
|
||||
while (i >= 0) {
|
||||
if (html[i] === '>') {
|
||||
const start = html.lastIndexOf('<', i);
|
||||
if (start === -1) break;
|
||||
i = start - 1;
|
||||
} else if (html[i] === ';') {
|
||||
// Possible entity end — look back for `&` and validate the body.
|
||||
const amp = html.lastIndexOf('&', i);
|
||||
if (amp !== -1 && i - amp < 10) {
|
||||
const body = html.slice(amp + 1, i);
|
||||
if (/^#?[a-zA-Z0-9]+$/.test(body)) {
|
||||
return html.slice(0, amp) + html.slice(i + 1);
|
||||
}
|
||||
}
|
||||
return html.slice(0, i) + html.slice(i + 1);
|
||||
} else {
|
||||
// Found last visible char — remove it
|
||||
return html.slice(0, i) + html.slice(i + 1);
|
||||
}
|
||||
}
|
||||
return html;
|
||||
}
|
||||
Reference in New Issue
Block a user