673 lines
23 KiB
JavaScript
673 lines
23 KiB
JavaScript
/**
|
||
* TextFitting.js
|
||
* 文本自适应渲染模块
|
||
* 负责文本的自适应布局、换行、公式渲染等功能
|
||
*/
|
||
|
||
class TextFittingAdapter {
|
||
constructor(options = {}) {
|
||
this.textFittingEngine = null;
|
||
this.globalFontSizeCache = new Map(); // idx -> { estimatedFontSize, bbox }
|
||
this.hasPreprocessed = false;
|
||
|
||
// 公式缓存
|
||
this._formulaCache = new Map();
|
||
this._katexWarned = false;
|
||
this._katexUnavailableWarned = false;
|
||
|
||
// 可配置选项
|
||
this.options = Object.assign({
|
||
initialScale: 1.0,
|
||
minScale: 0.3,
|
||
scaleStepHigh: 0.05,
|
||
scaleStepLow: 0.1,
|
||
lineSkipCJK: 1.5,
|
||
lineSkipWestern: 1.3,
|
||
minLineHeight: 1.05,
|
||
globalFontScale: 0.85,
|
||
bboxNormalizedRange: 1000
|
||
}, options);
|
||
}
|
||
|
||
/**
|
||
* 初始化文本自适应引擎
|
||
*/
|
||
initialize() {
|
||
// 检查 TextFittingEngine 是否已加载
|
||
if (typeof TextFittingEngine === 'undefined') {
|
||
console.error('[TextFittingAdapter] TextFittingEngine 未加载!请确保 js/utils/text-fitting.js 已正确引入');
|
||
console.error('[TextFittingAdapter] 当前可用类:', typeof TextFittingEngine, typeof PDFTextRenderer);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
this.textFittingEngine = new TextFittingEngine({
|
||
initialScale: this.options.initialScale,
|
||
minScale: this.options.minScale,
|
||
scaleStepHigh: this.options.scaleStepHigh,
|
||
scaleStepLow: this.options.scaleStepLow,
|
||
lineSkipCJK: this.options.lineSkipCJK,
|
||
lineSkipWestern: this.options.lineSkipWestern,
|
||
minLineHeight: this.options.minLineHeight
|
||
});
|
||
|
||
console.log('[TextFittingAdapter] 文本自适应引擎已启用');
|
||
} catch (error) {
|
||
console.error('[TextFittingAdapter] 文本自适应引擎初始化失败:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 预处理:使用众数统计计算全局统一的字号
|
||
* @param {Array} contentListJson - 原文内容列表
|
||
* @param {Array} translatedContentList - 译文内容列表
|
||
*/
|
||
preprocessGlobalFontSizes(contentListJson, translatedContentList) {
|
||
if (this.hasPreprocessed) return;
|
||
|
||
console.log('[TextFittingAdapter] 开始预处理全局字号(使用众数统计)...');
|
||
const startTime = performance.now();
|
||
|
||
const BBOX_NORMALIZED_RANGE = this.options.bboxNormalizedRange || 1000;
|
||
|
||
// 第一步:收集所有段落的最优缩放因子
|
||
const allScales = [];
|
||
const tempCache = new Map(); // 临时存储每个段落的最优缩放
|
||
|
||
contentListJson.forEach((item, idx) => {
|
||
if (item.type !== 'text' || !item.bbox) return;
|
||
|
||
const translatedItem = translatedContentList[idx];
|
||
if (!translatedItem || !translatedItem.text) return;
|
||
|
||
const bbox = item.bbox;
|
||
const bboxHeight = (bbox[3] - bbox[1]) / BBOX_NORMALIZED_RANGE;
|
||
const bboxWidth = (bbox[2] - bbox[0]) / BBOX_NORMALIZED_RANGE;
|
||
const text = translatedItem.text;
|
||
|
||
// 检测是否包含公式
|
||
const hasFormula = /\$\$?[\s\S]*?\$\$?/.test(text);
|
||
|
||
// 判断是否为短文本(标题、图注等)
|
||
// 短文本定义:字符数 < 50(更宽松),或者包含换行符且总字符数 < 80
|
||
const isShortText = text.length < 50 || (/\n/.test(text) && text.length < 80);
|
||
|
||
// 计算该段落的最优缩放因子(模拟实际渲染)
|
||
const optimalScale = this._calculateOptimalScale(text, bboxWidth, bboxHeight);
|
||
|
||
// 根据文本单元数量加权(字符数越多,权重越大)
|
||
const unitCount = Math.max(1, Math.floor(text.length / 10));
|
||
for (let i = 0; i < unitCount; i++) {
|
||
allScales.push(optimalScale);
|
||
}
|
||
|
||
tempCache.set(idx, {
|
||
optimalScale: optimalScale,
|
||
bbox: bbox,
|
||
bboxHeight: bboxHeight,
|
||
hasFormula: hasFormula, // 保存公式标记
|
||
isShortText: isShortText // 保存短文本标记
|
||
});
|
||
});
|
||
|
||
// 第二步:计算众数和关键百分位数
|
||
const modeScale = this._calculateMode(allScales);
|
||
const percentile50 = this._calculatePercentile(allScales, 0.50); // 中位数
|
||
const percentile60 = this._calculatePercentile(allScales, 0.60);
|
||
const percentile70 = this._calculatePercentile(allScales, 0.70);
|
||
const percentile80 = this._calculatePercentile(allScales, 0.80);
|
||
|
||
// ✅ 使用分层限制策略:短文本用80%分位,长文本用60%分位
|
||
const shortTextLimitScale = percentile80; // 短文本(标题、图注等)使用更宽松的限制
|
||
const longTextLimitScale = percentile60; // 长文本(正文)使用更严格的限制
|
||
|
||
// 统计公式段落数量
|
||
let formulaCount = 0;
|
||
let shortTextCount = 0;
|
||
tempCache.forEach((data) => {
|
||
if (data.hasFormula) formulaCount++;
|
||
if (data.isShortText) shortTextCount++;
|
||
});
|
||
|
||
console.log(`[TextFittingAdapter] 收集了 ${allScales.length} 个缩放样本,其中 ${formulaCount} 个包含公式,${shortTextCount} 个短文本`);
|
||
console.log(`[TextFittingAdapter] 50%分位=${percentile50.toFixed(3)}, 60%分位=${percentile60.toFixed(3)}, 70%分位=${percentile70.toFixed(3)}, 80%分位=${percentile80.toFixed(3)}, 众数=${modeScale.toFixed(3)}`);
|
||
console.log(`[TextFittingAdapter] 短文本上限=${shortTextLimitScale.toFixed(3)} (80%分位), 长文本上限=${longTextLimitScale.toFixed(3)} (60%分位)`);
|
||
|
||
// 第三步:应用分层分位数限制,避免字号过大
|
||
tempCache.forEach((data, idx) => {
|
||
// 根据文本长度选择不同的上限
|
||
const limitScale = data.isShortText ? shortTextLimitScale : longTextLimitScale;
|
||
const finalScale = Math.min(data.optimalScale, limitScale);
|
||
const estimatedFontSize = data.bboxHeight * finalScale;
|
||
|
||
this.globalFontSizeCache.set(idx, {
|
||
estimatedFontSize: estimatedFontSize,
|
||
bbox: data.bbox,
|
||
scale: finalScale
|
||
});
|
||
});
|
||
|
||
console.log(`[TextFittingAdapter] 预处理完成:众数=${modeScale.toFixed(3)}, 短文本限制=${shortTextLimitScale.toFixed(3)}, 长文本限制=${longTextLimitScale.toFixed(3)}, 耗时=${(performance.now() - startTime).toFixed(0)}ms`);
|
||
this.hasPreprocessed = true;
|
||
}
|
||
|
||
/**
|
||
* 计算单个段落的最优缩放因子
|
||
* ⚠️ 必须与 drawPlainTextWithFitting 使用相同的参数,避免估算偏差
|
||
* @private
|
||
*/
|
||
_calculateOptimalScale(text, bboxWidth, bboxHeight) {
|
||
const textLength = text.length;
|
||
const isCJK = /[\u4e00-\u9fa5]/.test(text);
|
||
const hasNewlines = /\n/.test(text);
|
||
|
||
// ✅ 检测公式:如果有公式,使用更保守的估算
|
||
const hasFormula = /\$\$?[\s\S]*?\$\$?/.test(text);
|
||
if (hasFormula) {
|
||
// 公式通常占用更多垂直空间,使用保守的缩放
|
||
return 0.5; // 固定返回较小的缩放因子
|
||
}
|
||
|
||
// ✅ 使用与实际渲染相同的初始行距(关键修复!)
|
||
const initialLineSkip = isCJK ? 1.5 : 1.3;
|
||
|
||
// 估算:假设使用 bboxHeight * 某个缩放因子作为字号
|
||
// 从一个合理的缩放因子开始迭代(0.7 是常见的中等值)
|
||
let testScale = 0.7;
|
||
let bestScale = 0.3; // 最小值
|
||
|
||
// 简化迭代:尝试几个典型缩放值
|
||
for (const scale of [0.9, 0.8, 0.7, 0.6, 0.5, 0.4, 0.3]) {
|
||
const fontSize = bboxHeight * scale;
|
||
|
||
// 估算字符宽度(基于字号)
|
||
const estimatedCharWidth = fontSize * (isCJK ? 1.0 : 0.6);
|
||
|
||
// 估算每行字符数(考虑90%的可用宽度)
|
||
const effectiveWidth = bboxWidth * 0.9;
|
||
const charsPerLine = Math.max(1, Math.floor(effectiveWidth / estimatedCharWidth));
|
||
|
||
// 估算行数
|
||
const estimatedLines = hasNewlines
|
||
? text.split('\n').length
|
||
: Math.ceil(textLength / charsPerLine);
|
||
|
||
// 计算总高度(使用初始行距,与实际渲染一致)
|
||
const lineHeight = fontSize * initialLineSkip;
|
||
const totalHeight = estimatedLines === 1
|
||
? fontSize * 1.2
|
||
: (estimatedLines - 1) * lineHeight + fontSize * 1.2;
|
||
|
||
// 如果能放下,这就是一个可行的缩放
|
||
if (totalHeight <= bboxHeight) {
|
||
bestScale = scale;
|
||
break; // 找到第一个可行的(最大的)缩放就停止
|
||
}
|
||
}
|
||
|
||
return bestScale;
|
||
}
|
||
|
||
/**
|
||
* 计算数组的众数(mode)
|
||
* @private
|
||
*/
|
||
_calculateMode(arr) {
|
||
if (arr.length === 0) return this.options.globalFontScale; // 回退到默认值
|
||
|
||
// 将连续值离散化(四舍五入到0.05精度)
|
||
const rounded = arr.map(v => Math.round(v * 20) / 20);
|
||
|
||
// 统计频率
|
||
const frequency = new Map();
|
||
rounded.forEach(val => {
|
||
frequency.set(val, (frequency.get(val) || 0) + 1);
|
||
});
|
||
|
||
// 找到出现次数最多的值
|
||
let maxCount = 0;
|
||
let modeValue = this.options.globalFontScale;
|
||
|
||
frequency.forEach((count, value) => {
|
||
if (count > maxCount) {
|
||
maxCount = count;
|
||
modeValue = value;
|
||
}
|
||
});
|
||
|
||
return modeValue;
|
||
}
|
||
|
||
/**
|
||
* 计算百分位数
|
||
* @param {number[]} arr - 数值数组
|
||
* @param {number} percentile - 百分位 (0-1),如 0.70 表示 70% 分位数
|
||
* @returns {number} 百分位数值
|
||
* @private
|
||
*/
|
||
_calculatePercentile(arr, percentile) {
|
||
if (arr.length === 0) return this.options.globalFontScale;
|
||
if (percentile < 0 || percentile > 1) {
|
||
console.warn('[TextFittingAdapter] 百分位参数超出范围,使用默认值');
|
||
return this.options.globalFontScale;
|
||
}
|
||
|
||
// 排序数组(升序)
|
||
const sorted = [...arr].sort((a, b) => a - b);
|
||
|
||
// 计算百分位位置
|
||
const index = percentile * (sorted.length - 1);
|
||
const lower = Math.floor(index);
|
||
const upper = Math.ceil(index);
|
||
const weight = index - lower;
|
||
|
||
// 线性插值
|
||
if (lower === upper) {
|
||
return sorted[lower];
|
||
}
|
||
return sorted[lower] * (1 - weight) + sorted[upper] * weight;
|
||
}
|
||
|
||
/**
|
||
* 绘制纯文本到指定区域(带回退方案)
|
||
* @param {CanvasRenderingContext2D} ctx - Canvas 上下文
|
||
* @param {string} text - 文本内容
|
||
* @param {number} x - X 坐标
|
||
* @param {number} y - Y 坐标
|
||
* @param {number} width - 区域宽度
|
||
* @param {number} height - 区域高度
|
||
* @param {boolean} isShortText - 是否为短文本(如标题)
|
||
* @param {Object} cachedInfo - 预处理的字号信息
|
||
*/
|
||
drawPlainTextInBox(ctx, text, x, y, width, height, isShortText = false, cachedInfo = null) {
|
||
// 优先使用新的文本自适应引擎
|
||
if (this.textFittingEngine) {
|
||
const suggestedFontSize = cachedInfo ? cachedInfo.estimatedFontSize : null;
|
||
return this.drawPlainTextWithFitting(ctx, text, x, y, width, height, isShortText, suggestedFontSize);
|
||
}
|
||
|
||
// 回退方案:如果引擎未初始化
|
||
let bestFontSize = 8;
|
||
let bestLines = [];
|
||
|
||
let maxFontSize = Math.min(width / 10, height / 3);
|
||
if (isShortText) {
|
||
maxFontSize = Math.max(maxFontSize, 18);
|
||
}
|
||
|
||
// 从大到小寻找最佳字号
|
||
let foundPerfectFit = false;
|
||
for (let fontSize = maxFontSize; fontSize >= 3; fontSize -= 0.5) {
|
||
ctx.font = `${fontSize}px "Noto Sans SC", "PingFang SC", "Microsoft YaHei", Arial, sans-serif`;
|
||
const lines = this.wrapText(ctx, text, width - 4);
|
||
const lineHeight = fontSize * 1.5;
|
||
|
||
const totalHeight = lines.length > 1
|
||
? (lines.length - 1) * lineHeight + fontSize * 1.3 + 8
|
||
: fontSize * 1.3 + 8;
|
||
|
||
if (totalHeight <= height) {
|
||
bestFontSize = fontSize;
|
||
bestLines = lines;
|
||
foundPerfectFit = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 极端情况处理
|
||
if (!foundPerfectFit) {
|
||
bestFontSize = 3;
|
||
ctx.font = `${bestFontSize}px "Noto Sans SC", "PingFang SC", "Microsoft YaHei", Arial, sans-serif`;
|
||
const allLines = this.wrapText(ctx, text, width - 4);
|
||
const lineHeight = bestFontSize * 1.5;
|
||
|
||
const totalHeight = allLines.length > 1
|
||
? (allLines.length - 1) * lineHeight + bestFontSize * 1.3 + 8
|
||
: bestFontSize * 1.3 + 8;
|
||
|
||
if (totalHeight > height) {
|
||
const availableHeight = height - 8;
|
||
const maxLines = Math.max(1, Math.floor(availableHeight / lineHeight));
|
||
bestLines = allLines.slice(0, maxLines);
|
||
} else {
|
||
bestLines = allLines;
|
||
}
|
||
}
|
||
|
||
// 短文本最小字号限制
|
||
if (isShortText && bestLines.length === 1 && bestFontSize < 14) {
|
||
bestFontSize = 14;
|
||
ctx.font = `${bestFontSize}px "Noto Sans SC", "PingFang SC", "Microsoft YaHei", Arial, sans-serif`;
|
||
bestLines = this.wrapText(ctx, text, width - 4);
|
||
}
|
||
|
||
// 绘制文字
|
||
ctx.fillStyle = '#000';
|
||
ctx.textBaseline = 'top';
|
||
ctx.font = `${bestFontSize}px "Noto Sans SC", "PingFang SC", "Microsoft YaHei", Arial, sans-serif`;
|
||
|
||
const lineHeight = bestFontSize * 1.5;
|
||
|
||
bestLines.forEach((line, i) => {
|
||
const lineY = y + 4 + i * lineHeight;
|
||
ctx.fillText(line, x + 2, lineY);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 使用文本自适应算法绘制文本(优化版,支持动态行距调整)
|
||
* @param {CanvasRenderingContext2D} ctx - Canvas 上下文
|
||
* @param {string} text - 文本内容
|
||
* @param {number} x - X 坐标
|
||
* @param {number} y - Y 坐标
|
||
* @param {number} width - 区域宽度
|
||
* @param {number} height - 区域高度
|
||
* @param {boolean} isShortText - 是否为短文本
|
||
* @param {number} suggestedFontSize - 建议字号(来自预处理)
|
||
*/
|
||
drawPlainTextWithFitting(ctx, text, x, y, width, height, isShortText = false, suggestedFontSize = null) {
|
||
try {
|
||
// 判断是否为 CJK 语言
|
||
const isCJK = /[\u4e00-\u9fa5]/.test(text);
|
||
|
||
// 内边距:对小bbox减少padding避免裁剪
|
||
const paddingTop = height < 20 ? 0.5 : 2;
|
||
const paddingX = 2;
|
||
const availableHeight = height - paddingTop * 2;
|
||
const availableWidth = width - paddingX * 2;
|
||
|
||
// 字号范围估算
|
||
const estimatedSingleLineFontSize = height * 0.8;
|
||
|
||
// 最小字号:动态调整(基于bbox高度)
|
||
let minFontSize;
|
||
if (height < 20) {
|
||
minFontSize = Math.max(6, height * 0.35); // 小bbox:最小6px
|
||
} else {
|
||
minFontSize = isShortText ? 10 : 8; // 正常bbox:10px/8px
|
||
}
|
||
|
||
const maxFontSize = Math.min(estimatedSingleLineFontSize * 1.5, height * 1.2);
|
||
|
||
const hasNewlines = text.includes('\n');
|
||
const textLength = text.length;
|
||
|
||
console.log(`[TextFitting] 开始: "${text.substring(0, 30)}..." bbox=${width.toFixed(0)}x${height.toFixed(0)}, 字号范围=${minFontSize.toFixed(1)}-${maxFontSize.toFixed(1)}px, 文本长度=${textLength}, 有换行=${hasNewlines}`);
|
||
|
||
// 宽度因子(优先使用全宽)
|
||
const widthFactors = (textLength < 20 || hasNewlines)
|
||
? [1.0]
|
||
: [1.0, 0.95, 0.90, 0.85, 0.80, 0.75, 0.70];
|
||
|
||
let bestSolution = null;
|
||
|
||
// 动态行距策略:初始值 → 逐步缩小
|
||
const initialLineSkip = isCJK ? 1.5 : 1.3;
|
||
const lineSkipStep = 0.1;
|
||
const minLineSkip = 1.1; // 最小行距
|
||
|
||
// 对每个宽度因子和行距组合,使用二分查找找到最大可用字号
|
||
for (const widthFactor of widthFactors) {
|
||
const effectiveWidth = availableWidth * widthFactor;
|
||
|
||
// 尝试不同的行距
|
||
for (let currentLineSkip = initialLineSkip; currentLineSkip >= minLineSkip; currentLineSkip -= lineSkipStep) {
|
||
let low = minFontSize;
|
||
let high = maxFontSize;
|
||
let foundFontSize = null;
|
||
let foundLines = null;
|
||
|
||
while (high - low > 0.5) {
|
||
const mid = (low + high) / 2;
|
||
|
||
ctx.font = `${mid}px Arial, "Microsoft YaHei", "SimHei", sans-serif`;
|
||
const lines = this.wrapText(ctx, text, effectiveWidth);
|
||
const lineHeight = mid * currentLineSkip;
|
||
|
||
const totalHeight = lines.length === 1
|
||
? mid * 1.2
|
||
: (lines.length - 1) * lineHeight + mid * 1.2;
|
||
|
||
if (totalHeight <= availableHeight) {
|
||
foundFontSize = mid;
|
||
foundLines = lines;
|
||
low = mid;
|
||
} else {
|
||
high = mid;
|
||
}
|
||
}
|
||
|
||
if (foundFontSize) {
|
||
// 找到可行方案,优先选择字号大、行距大的方案
|
||
const quality = foundFontSize * currentLineSkip; // 综合质量评分
|
||
if (!bestSolution || quality > (bestSolution.fontSize * bestSolution.lineSkip)) {
|
||
bestSolution = {
|
||
fontSize: foundFontSize,
|
||
widthFactor,
|
||
lines: foundLines,
|
||
lineSkip: currentLineSkip
|
||
};
|
||
}
|
||
break; // 找到可行方案后,不需要继续缩小行距
|
||
}
|
||
}
|
||
}
|
||
|
||
// 没找到合适方案,使用最小字号和最小行距
|
||
if (!bestSolution) {
|
||
ctx.font = `${minFontSize}px Arial, "Microsoft YaHei", "SimHei", sans-serif`;
|
||
const lines = this.wrapText(ctx, text, availableWidth);
|
||
const lineHeight = minFontSize * minLineSkip;
|
||
const maxLines = Math.max(1, Math.floor(availableHeight / lineHeight));
|
||
bestSolution = {
|
||
fontSize: minFontSize,
|
||
widthFactor: 1.0,
|
||
lines: lines.slice(0, maxLines),
|
||
lineSkip: minLineSkip
|
||
};
|
||
}
|
||
|
||
// 绘制文字
|
||
const { fontSize, lines, lineSkip } = bestSolution;
|
||
const lineHeight = fontSize * lineSkip;
|
||
|
||
ctx.fillStyle = '#000';
|
||
ctx.textBaseline = 'top';
|
||
ctx.font = `${fontSize}px Arial, "Microsoft YaHei", "SimHei", sans-serif`;
|
||
|
||
const totalTextHeight = lines.length === 1
|
||
? fontSize
|
||
: (lines.length - 1) * lineHeight + fontSize;
|
||
|
||
const startY = y + paddingTop + (availableHeight - totalTextHeight) / 2;
|
||
|
||
lines.forEach((line, i) => {
|
||
const lineY = startY + i * lineHeight;
|
||
const lineWidth = ctx.measureText(line).width;
|
||
const lineX = x + paddingX + (availableWidth - lineWidth) / 2;
|
||
ctx.fillText(line, lineX, lineY);
|
||
});
|
||
|
||
console.log(`[TextFitting] 完成: 字号=${fontSize.toFixed(1)}px, 行数=${lines.length}, 行距=${lineSkip.toFixed(2)}, 宽度因子=${bestSolution.widthFactor}`);
|
||
|
||
} catch (error) {
|
||
console.error('[TextFitting] 渲染失败:', error);
|
||
// 回退到简单绘制
|
||
ctx.fillStyle = '#000';
|
||
ctx.font = '12px "Noto Sans SC", "PingFang SC", "Microsoft YaHei", Arial, sans-serif';
|
||
ctx.fillText(text.substring(0, 50), x + 2, y + 2);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 文本换行算法(支持中英文混排间距)
|
||
* @param {CanvasRenderingContext2D} ctx - Canvas 上下文
|
||
* @param {string} text - 文本内容
|
||
* @param {number} maxWidth - 最大宽度
|
||
* @returns {Array} 换行后的文本数组
|
||
*/
|
||
wrapText(ctx, text, maxWidth) {
|
||
if (!text) return [];
|
||
|
||
const lines = [];
|
||
let currentLine = '';
|
||
|
||
// 按自然断句分段
|
||
const segments = text.split(/([。?!,、;:\n])/);
|
||
|
||
for (let segment of segments) {
|
||
if (!segment) continue;
|
||
|
||
// 标点符号直接加到当前行
|
||
if (/^[。?!,、;:]$/.test(segment)) {
|
||
currentLine += segment;
|
||
continue;
|
||
}
|
||
|
||
// 换行符强制换行
|
||
if (segment === '\n') {
|
||
if (currentLine) {
|
||
lines.push(currentLine);
|
||
currentLine = '';
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// 按字符逐个添加
|
||
for (let i = 0; i < segment.length; i++) {
|
||
const char = segment[i];
|
||
const testLine = currentLine + char;
|
||
|
||
// 计算宽度时考虑中英文混排间距
|
||
const lineWidth = this._measureTextWithCJKSpacing(ctx, testLine);
|
||
|
||
if (lineWidth > maxWidth && currentLine.length > 0) {
|
||
lines.push(currentLine);
|
||
currentLine = char;
|
||
} else {
|
||
currentLine = testLine;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (currentLine) {
|
||
lines.push(currentLine);
|
||
}
|
||
|
||
return lines.length > 0 ? lines : [''];
|
||
}
|
||
|
||
/**
|
||
* 测量文本宽度(考虑中英文混排间距)
|
||
* @private
|
||
*/
|
||
_measureTextWithCJKSpacing(ctx, text) {
|
||
if (!text) return 0;
|
||
|
||
let totalWidth = ctx.measureText(text).width;
|
||
let spacingCount = 0;
|
||
|
||
// 计算需要添加间距的位置数量
|
||
for (let i = 0; i < text.length - 1; i++) {
|
||
if (this._needsCJKWesternSpacing(text[i], text[i + 1])) {
|
||
spacingCount++;
|
||
}
|
||
}
|
||
|
||
// 每个间距添加0.5个字符宽度
|
||
const avgCharWidth = ctx.measureText('中').width; // 使用CJK字符宽度作为基准
|
||
totalWidth += spacingCount * avgCharWidth * 0.5;
|
||
|
||
return totalWidth;
|
||
}
|
||
|
||
/**
|
||
* 判断两个字符之间是否需要添加间距
|
||
* @private
|
||
*/
|
||
_needsCJKWesternSpacing(char1, char2) {
|
||
// 黑名单:这些字符不需要添加间距
|
||
// 包括:中文标点、数学公式标记符号($)、括号等
|
||
const punctuationBlacklist = /[,。、;:!?""''()《》【】…—$]/;
|
||
|
||
if (punctuationBlacklist.test(char1) || punctuationBlacklist.test(char2)) {
|
||
return false;
|
||
}
|
||
|
||
const isCJK1 = /[\u4e00-\u9fa5]/.test(char1);
|
||
const isCJK2 = /[\u4e00-\u9fa5]/.test(char2);
|
||
const isWestern1 = /[a-zA-Z0-9]/.test(char1);
|
||
const isWestern2 = /[a-zA-Z0-9]/.test(char2);
|
||
|
||
// CJK → Western 或 Western → CJK 需要间距
|
||
return (isCJK1 && isWestern2) || (isWestern1 && isCJK2);
|
||
}
|
||
|
||
/**
|
||
* 渲染文本中的数学公式(KaTeX)
|
||
* @param {string} text - 包含公式的文本
|
||
* @returns {string} 渲染后的 HTML
|
||
*/
|
||
renderFormulasInText(text) {
|
||
// 使用缓存避免重复渲染
|
||
if (this._formulaCache.has(text)) {
|
||
return this._formulaCache.get(text);
|
||
}
|
||
|
||
if (typeof window.renderMathInElement === 'function') {
|
||
// 预处理公式:修复常见的非标准LaTeX命令
|
||
let processedText = text;
|
||
// 将 \plus 替换为 +(\plus 不是标准LaTeX命令)
|
||
processedText = processedText.replace(/\\plus(?![a-zA-Z])/g, '+');
|
||
|
||
const tempContainer = document.createElement('div');
|
||
tempContainer.textContent = processedText;
|
||
|
||
try {
|
||
window.renderMathInElement(tempContainer, {
|
||
delimiters: [
|
||
{ left: '$$', right: '$$', display: true },
|
||
{ left: '$', right: '$', display: false }
|
||
],
|
||
throwOnError: false,
|
||
strict: false
|
||
});
|
||
const result = tempContainer.innerHTML;
|
||
|
||
// 缓存结果(最多 500 条)
|
||
if (this._formulaCache.size < 500) {
|
||
this._formulaCache.set(text, result);
|
||
}
|
||
|
||
return result;
|
||
} catch (e) {
|
||
if (!this._katexWarned) {
|
||
console.warn('[TextFittingAdapter] KaTeX 渲染失败:', e);
|
||
this._katexWarned = true;
|
||
}
|
||
return text;
|
||
}
|
||
} else {
|
||
if (!this._katexUnavailableWarned) {
|
||
console.warn('[TextFittingAdapter] renderMathInElement 不可用');
|
||
this._katexUnavailableWarned = true;
|
||
}
|
||
return text;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 清除缓存
|
||
*/
|
||
clearCache() {
|
||
this.globalFontSizeCache.clear();
|
||
this._formulaCache.clear();
|
||
this.hasPreprocessed = false;
|
||
}
|
||
}
|
||
|
||
// 导出模块
|
||
if (typeof module !== 'undefined' && module.exports) {
|
||
module.exports = TextFittingAdapter;
|
||
}
|