背景
最近在做小程序,有一个生成海报的需求,在小程序下是没有DOM的,所以用 Webview H
5 + html2canvas 来做,做出来以后在 Andriod 上还好,但是在 iOS 里换行有问题,换行异常缩进。
我使用的是 html2canvas ,直接传入需要渲染的海报文本,让 html2canvas 自动处理换行,估计这个 html2canvas 在模拟浏览器文本布局时,在 iOS 下自动换行计算结果有 bug。
解决思路是:在调用 html2canvas 之前,不再让目标文本依赖 html2canvas 自动换行,而是在 DOM 阶段先把文本拆成多个显式行节点。
方案思路
核心方案如下:
- 创建一个隐藏的测量
span - 将待判断的候选字符串塞入
span - 通过
span.getBoundingClientRect().width获取真实 DOM 渲染宽度 - 将该宽度与目标容器内容宽度比较
- 使用二分查找,找到当前行能容纳的最大字符串长度
- 不断处理剩余文本,直到全文拆分完成
- 将拆分后的每一行渲染成独立 DOM 节点
- 最后再调用
html2canvas截图
处理前的 DOM 可能是这样:
<div class="text">
这是一段很长的文本,由浏览器自动换行。
</div>
处理后变成:
<div class="line">这是一段很长的</div>
<div class="line">文本,由浏览器</div>
<div class="line">自动换行。</div>
这样 html2canvas 不再需要自己理解长文本如何自动换行,只需要截图已经排好版的 DOM。
为什么用二分
如果从第 1 个字符开始一个个追加,再判断宽度是否超出容器,复杂度会偏高。 例如一段文本有 1000 个字符,如果逐字测量,每一行都可能触发很多次 DOM 布局计算。
不过其实不用二分也可以,毕竟小程序里的海报,能塞下几个字,一个个加然后再计算比较就行,不过希望自己能考虑多一些、适应性更广的场景,所以选择了二分。
二分策略的优势是,每一行只需要大约 log2(n) 次测量。
例如当前剩余文本长度是 1000:
- 第一次测 500 个字符
- 第二次测 250 或 750 个字符
- 第三次继续缩小范围
- 大约 10 次以内就能找到最合适的长度
这比逐字追加更适合长文本场景。
核心实现
1. 准备样式
为了让测量结果和最终渲染结果一致,隐藏 span 必须复制真实文本节点的关键字体样式。
function copyLineStyle(source, target) {
const style = getComputedStyle(source);
const props = [
"fontFamily",
"fontSize",
"fontWeight",
"fontStyle",
"fontVariant",
"letterSpacing",
"lineHeight",
"textTransform"
];
props.forEach((prop) => {
target.style[prop] = style[prop];
});
}
如果测量节点和真实节点的字体、字号、字重、字间距不一致,计算出来的行宽就会有偏差。
2. 创建隐藏测量节点
let measureSpan = null;
function getMeasureSpan(styleSource) {
if (!measureSpan) {
measureSpan = document.createElement("span");
measureSpan.className = "measure-span";
document.body.appendChild(measureSpan);
}
copyLineStyle(styleSource, measureSpan);
return measureSpan;
}
对应 CSS:
.measure-span {
position: fixed;
left: -99999px;
top: -99999px;
display: inline-block;
width: auto;
max-width: none;
padding: 0;
margin: 0;
white-space: pre;
word-break: keep-all;
overflow-wrap: normal;
visibility: hidden;
pointer-events: none;
}
这里有几个关键点:
position: fixed加left/top: -99999px:避免影响页面布局display: inline-block:便于读取文本实际宽度white-space: pre:保留空格,不让测量文本自动折行word-break: keep-all和overflow-wrap: normal:避免测量时被浏览器提前断开
3. 测量文本宽度
const widthCache = new Map();
function measureTextWidth(text, styleSource) {
if (!text) return 0;
const style = getComputedStyle(styleSource);
const cacheKey = [
text,
style.fontFamily,
style.fontSize,
style.fontWeight,
style.letterSpacing
].join("::");
if (widthCache.has(cacheKey)) {
return widthCache.get(cacheKey);
}
const span = getMeasureSpan(styleSource);
span.textContent = text;
const width = span.getBoundingClientRect().width;
widthCache.set(cacheKey, width);
return width;
}
这一步是整个方案的基础。
每次将候选字符串写入隐藏 span,然后读取它的实际 DOM 宽度。这里没有调用 html2canvas,因此性能比“每次候选都截图再测量”要好很多。
widthCache 用来缓存已经测量过的字符串,避免重复触发布局读取。
4. 二分查找每行最大长度
function findMaxFitLength(text, maxWidth, styleSource) {
let left = 1;
let right = text.length;
let answer = 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const candidate = text.slice(0, mid);
const width = measureTextWidth(candidate, styleSource);
if (width <= maxWidth) {
answer = mid;
left = mid + 1;
} else {
right = mid - 1;
}
}
return answer;
}
判断逻辑很简单:
- 候选字符串宽度
<= maxWidth:说明还能继续放更多字符 - 候选字符串宽度
> maxWidth:说明当前字符串太长,需要缩短
最终返回的 answer 就是当前行能放下的最大字符数。
5. 将整段文本拆成多行
function splitTextIntoLines(text, maxWidth, styleSource) {
const normalized = text.replace(/\r\n/g, "\n");
const lines = [];
normalized.split("\n").forEach((paragraph) => {
let rest = paragraph;
if (!rest) {
lines.push("");
return;
}
while (rest.length > 0) {
let length = findMaxFitLength(rest, maxWidth, styleSource);
length = Math.max(1, length);
let line = rest.slice(0, length);
line = adjustBreakPoint(line, rest.length);
lines.push(line.trimEnd());
rest = rest.slice(line.length).trimStart();
}
});
return lines;
}
这个函数会循环处理剩余文本:
- 对当前剩余文本执行二分
- 找到一行能放下的最大长度
- 截取这一行
- 将剩余内容继续进入下一轮
Math.max(1, length) 是为了兜底。如果某个单字符本身就超过容器宽度,也必须至少推进 1 个字符,避免死循环。
6. 优化断行位置
为了让中英文混排的断行更自然,可以优先在标点或空格处断开。
function adjustBreakPoint(line, restLength) {
if (line.length >= restLength) {
return line;
}
const breakChars = ",。;:、,.!?;: ";
for (let i = line.length - 1; i > 0; i -= 1) {
if (breakChars.includes(line[i])) {
return line.slice(0, i + 1);
}
}
return line;
}
这不是必需逻辑,但对可读性有帮助。
例如原本可能拆成:
这是一个 html2can
vas 截图问题
经过断点优化后,可能变成:
这是一个
html2canvas 截图问题
具体是否保留,要看业务是否更重视排版自然度,还是更重视每行尽量填满。
7. 渲染为显式行节点
function renderLines(lines) {
lineRoot.replaceChildren();
lines.forEach((line) => {
const div = document.createElement("div");
div.className = "line";
div.textContent = line || " ";
lineRoot.appendChild(div);
});
}
对应 CSS:
.line {
min-height: 25.6px;
font-size: 16px;
line-height: 1.6;
color: #101828;
white-space: pre;
word-break: keep-all;
overflow-wrap: normal;
}
这里的关键是:
- 每一行都是独立节点
- 不再依赖
html2canvas自动换行 white-space: pre保证单行内容按原样展示
8. 最后执行 html2canvas
async function captureImage() {
const canvas = await html2canvas(captureTarget, {
backgroundColor: "#ffffff",
scale: window.devicePixelRatio || 1,
logging: false
});
result.replaceChildren(canvas);
}
到这一步时,目标 DOM 已经完成预换行处理。
html2canvas 只需要负责截图,不再参与换行计算。
完整调用流程
async function wrapByDomBinarySearch() {
widthCache.clear();
const maxTextWidth = getContentWidth(captureTarget);
const lines = splitTextIntoLines(inputText.value, maxTextWidth, styleSource);
renderLines(lines);
}
方案优点
相比直接让 html2canvas 处理自动换行,这个方案有几个明显优势:
- 截图前已经完成排版,结果更稳定
- 不依赖
html2canvas对自动换行的还原能力 - DOM 测量性能好于反复调用
html2canvas - 二分查找比逐字追加更适合长文本
- 对 iOS WebView、Safari 等兼容性问题更可控
注意事项
1. 测量样式必须一致
隐藏 span 的样式要和最终 .line 的样式保持一致,至少包括:
font-familyfont-sizefont-weightfont-styleletter-spacingline-heighttext-transformwhite-spaceword-breakoverflow-wrap
否则测量宽度和最终渲染宽度会不一致。
2. 容器宽度要用内容宽度
如果目标容器有 padding,不能直接使用 clientWidth 作为文本可用宽度。
应该减去左右 padding:
function getContentWidth(element) {
const style = getComputedStyle(element);
return element.clientWidth
- parseFloat(style.paddingLeft)
- parseFloat(style.paddingRight);
}
总结
这个方案的本质是:
把不可控的自动换行,提前转换成可控的显式 DOM 行。
在 iOS 下遇到 html2canvas 自动换行截图异常时,不要让 html2canvas 继续猜测文本应该如何换行,而是在截图之前完成换行计算。
DOM 阶段使用隐藏 span 测量宽度,配合二分查找计算每行最大长度,既能保证性能,也能提高截图结果的一致性。
发表回复