/** * Token位置计算器 * 负责计算token在DOM中的像素位置,处理Unicode/UTF-16转换 */ import {FrontendAnalyzeResult} from "../api/GLTR_API"; import {TokenFragmentRect} from "./types"; interface TextNodeIndexEntry { node: Text; startOffset: number; // Unicode字符偏移起始位置 endOffset: number; // Unicode字符偏移结束位置 utf16Start: number; // UTF-16代码单元起始位置 utf16End: number; // UTF-16代码单元结束位置 charToUtf16Map: number[]; // 字符偏移到UTF-16偏移的映射表 } export class TokenPositionCalculator { private textNodeIndex?: TextNodeIndexEntry[]; private baseNode: HTMLElement; constructor(baseNode: HTMLElement) { this.baseNode = baseNode; } /** 与 calculateTokenPositions 一致的累积 zoom(Range → 覆盖层坐标) */ getZoom(): number { return this.getAccumulatedZoom(this.baseNode); } /** * 获取元素及其祖先的累积 zoom 值 * CSS zoom 会影响 getBoundingClientRect() 但不影响 clientWidth/clientHeight * * 注意事项: * - CSS zoom 不是标准属性(Chrome/Safari 支持,Firefox 126+ 支持) * * @param element 目标元素 * @returns 累积的 zoom 值 */ private getAccumulatedZoom(element: HTMLElement): number { let zoom = 1; let current: HTMLElement | null = element; let depth = 0; const MAX_DEPTH = 50; // 防止无限循环 while (current && depth < MAX_DEPTH) { const style = window.getComputedStyle(current); // 检查浏览器是否支持 zoom 属性(兼容性处理) if (typeof style.zoom === 'string' && style.zoom !== '' && style.zoom !== 'normal') { const elementZoom = parseFloat(style.zoom); if (!isNaN(elementZoom) && elementZoom > 0) { zoom *= elementZoom; } else if (!isNaN(elementZoom) && elementZoom <= 0) { console.warn(`[TokenPositionCalculator] Invalid zoom value: ${style.zoom}`, current); // 忽略无效 zoom 值,继续使用当前累积值 } } current = current.parentElement; depth++; } if (depth >= MAX_DEPTH) { console.warn(`[TokenPositionCalculator] DOM depth exceeded ${MAX_DEPTH}, stopping zoom calculation`); } return zoom; } /** * 计算token的像素位置(一次性计算,避免重复遍历) * @param rd 分析结果数据 * @param fromTokenIndex 只计算 index >= fromTokenIndex 的 token,默认 0(全量) * @returns token位置数组 */ calculateTokenPositions(rd: FrontendAnalyzeResult, fromTokenIndex = 0): TokenFragmentRect[] { if (!this.baseNode) return []; const positions: TokenFragmentRect[] = []; const containerRect = this.baseNode.getBoundingClientRect(); // 获取累积的 zoom 值,用于将 getBoundingClientRect 坐标转换回未缩放坐标 // 这样输出的坐标与 clientWidth/clientHeight 一致 const zoom = this.getAccumulatedZoom(this.baseNode); // 过滤有效token(跳过已处理的旧 token) const validTokens = rd.bpe_strings.map((tokenObj, index) => ({ tokenObj, index, offset: tokenObj.offset })).filter(({ index, offset }) => { if (index < fromTokenIndex) return false; const [start, end] = offset; return !(start === end || start < 0 || end < 0 || end <= start); }); // 一次性计算所有token位置 validTokens.forEach(({ tokenObj, index, offset }) => { const [start, end] = offset; // 使用findNodeAndOffset找到文本节点和偏移 const startResult = this.findNodeAndOffset(start); const endResult = this.findNodeAndOffset(end); if (!startResult || !endResult) { console.warn(`⚠️ 无法找到token ${index} 的位置 (${start}, ${end})`); return; } // 创建Range对象 const range = document.createRange(); range.setStart(startResult.node, startResult.offset); range.setEnd(endResult.node, endResult.offset); // 获取各段像素坐标(token可能被拆到多行) const rectList = Array.from(range.getClientRects()); const fragments = rectList.length > 0 ? rectList : [range.getBoundingClientRect()]; fragments.forEach((rect, fragmentIndex) => { if (!rect || rect.height === 0) { return; } const hScaled = rect.height / zoom; if (hScaled <= 0) { return; } // 保留 Range 的真实宽度。iOS/WebKit 在换行后的 token 前可能返回上一行行尾的 // 零宽 rect;如果这里提前改成占位宽,DAG 等下游就无法识别并过滤这个幽灵片。 const wRaw = rect.width / zoom; // 将 getBoundingClientRect 的缩放坐标转换回未缩放坐标(与 clientWidth/clientHeight 一致) const tokenPos = { tokenIndex: index, fragmentIndex, fragmentCount: fragments.length, rectKey: `${index}-${fragmentIndex}`, x: (rect.left - containerRect.left) / zoom, y: (rect.top - containerRect.top) / zoom, width: wRaw, height: hScaled }; positions.push(tokenPos); }); }); return positions; } /** * 构建文本节点索引,用于优化findNodeAndOffset的查找性能 * 从O(n)的线性遍历优化为O(log n)的二分查找 */ buildTextNodeIndex(): void { if (!this.baseNode) { this.textNodeIndex = undefined; return; } const index: TextNodeIndexEntry[] = []; let currentCharOffset = 0; // Unicode字符偏移(code points) let currentUtf16Offset = 0; // UTF-16代码单元偏移 // 遍历所有文本节点 const walker = document.createTreeWalker( this.baseNode, NodeFilter.SHOW_TEXT, null ); let node: Text; while (node = walker.nextNode() as Text) { const nodeText = node.textContent || ''; // 使用Array.from()将字符串转换为字符数组(正确处理Unicode字符) // 注意:这里只在构建索引时执行一次,后续查找时不再执行 const nodeChars = Array.from(nodeText); const nodeCharLength = nodeChars.length; // Unicode字符数 const nodeUtf16Length = nodeText.length; // UTF-16代码单元长度(相对于当前节点) const startOffset = currentCharOffset; const endOffset = currentCharOffset + nodeCharLength; const utf16Start = currentUtf16Offset; const utf16End = currentUtf16Offset + nodeUtf16Length; // 预计算字符偏移到UTF-16偏移的映射表 // charToUtf16Map[i] 表示第i个字符(Unicode字符)在「当前文本节点内部」对应的UTF-16偏移 const charToUtf16Map: number[] = new Array(nodeCharLength + 1); // +1 用于包含末尾位置 let utf16Pos = 0; // 对于每个字符位置,计算其对应的UTF-16偏移 for (let i = 0; i <= nodeCharLength; i++) { charToUtf16Map[i] = utf16Pos; if (i < nodeCharLength) { // 当前字符的UTF-16长度 const char = nodeChars[i]; utf16Pos += char.length; // 字符的UTF-16长度(对于emoji可能是2) } } index.push({ node, startOffset, endOffset, utf16Start, utf16End, charToUtf16Map }); currentCharOffset += nodeCharLength; currentUtf16Offset += nodeUtf16Length; } this.textNodeIndex = index; } /** * 根据全局字符偏移找到对应的文本节点和局部偏移 * 使用二分查找优化性能:从O(n)优化为O(log n) * 正确处理Unicode字符(包括emoji):将Unicode字符偏移转换为UTF-16代码单元偏移 * * @param globalOffset Unicode字符偏移(code points),来自Python的offset_mapping * @returns 文本节点和UTF-16代码单元偏移(Range API需要) */ findNodeAndOffset(globalOffset: number): { node: Text, offset: number } | null { // 如果索引不存在或为空,回退到构建索引 if (!this.textNodeIndex || this.textNodeIndex.length === 0) { this.buildTextNodeIndex(); if (!this.textNodeIndex || this.textNodeIndex.length === 0) { return null; } } const index = this.textNodeIndex; // 二分查找:找到包含globalOffset的节点 let left = 0; let right = index.length - 1; let foundIndex = -1; while (left <= right) { const mid = Math.floor((left + right) / 2); const entry = index[mid]; if (globalOffset >= entry.startOffset && globalOffset < entry.endOffset) { foundIndex = mid; break; } else if (globalOffset < entry.startOffset) { right = mid - 1; } else { left = mid + 1; } } // 处理边界情况:globalOffset正好等于最后一个节点的结束位置 if (foundIndex === -1 && index.length > 0) { const lastEntry = index[index.length - 1]; if (globalOffset === lastEntry.endOffset) { foundIndex = index.length - 1; // 使用预计算的映射表获取末尾位置的UTF-16偏移 const lastLocalCharOffset = lastEntry.endOffset - lastEntry.startOffset; const utf16Offset = lastEntry.charToUtf16Map[lastLocalCharOffset]; return { node: lastEntry.node, offset: utf16Offset }; } return null; } if (foundIndex === -1) { return null; } const entry = index[foundIndex]; const localCharOffset = globalOffset - entry.startOffset; // 使用预计算的映射表直接查表,避免重复的Array.from、slice、join操作 // 这是性能优化的关键:从O(n)的字符串操作优化为O(1)的数组查表 const utf16Offset = entry.charToUtf16Map[localCharOffset]; // 注意:Range.setStart/End 需要的是「相对于当前文本节点」的UTF-16偏移 // charToUtf16Map 已经存的是局部偏移,无需再加上 utf16Start return { node: entry.node, offset: utf16Offset }; } /** * 重置索引(当文本内容变化时调用) */ resetIndex(): void { this.textNodeIndex = undefined; } }