小程序在iOS下,html2canvas生成海报文字自动换行异常缩进的问题

背景

最近在做小程序,有一个生成海报的需求,在小程序下是没有DOM的,所以用 Webview H 5 + html2canvas 来做,做出来以后在 Andriod 上还好,但是在 iOS 里换行有问题,换行异常缩进。

我使用的是 html2canvas ,直接传入需要渲染的海报文本,让 html2canvas 自动处理换行,估计这个 html2canvas 在模拟浏览器文本布局时,在 iOS 下自动换行计算结果有 bug。

解决思路是:在调用 html2canvas 之前,不再让目标文本依赖 html2canvas 自动换行,而是在 DOM 阶段先把文本拆成多个显式行节点。

方案思路

核心方案如下:

  1. 创建一个隐藏的测量 span
  2. 将待判断的候选字符串塞入 span
  3. 通过 span.getBoundingClientRect().width 获取真实 DOM 渲染宽度
  4. 将该宽度与目标容器内容宽度比较
  5. 使用二分查找,找到当前行能容纳的最大字符串长度
  6. 不断处理剩余文本,直到全文拆分完成
  7. 将拆分后的每一行渲染成独立 DOM 节点
  8. 最后再调用 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: fixedleft/top: -99999px:避免影响页面布局
  • display: inline-block:便于读取文本实际宽度
  • white-space: pre:保留空格,不让测量文本自动折行
  • word-break: keep-alloverflow-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;
}

这个函数会循环处理剩余文本:

  1. 对当前剩余文本执行二分
  2. 找到一行能放下的最大长度
  3. 截取这一行
  4. 将剩余内容继续进入下一轮

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-family
  • font-size
  • font-weight
  • font-style
  • letter-spacing
  • line-height
  • text-transform
  • white-space
  • word-break
  • overflow-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 测量宽度,配合二分查找计算每行最大长度,既能保证性能,也能提高截图结果的一致性。


已发布

分类

来自

标签:

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注