RTL/BiDi 全链路治理清单:从编辑器到 PPTX/PDF 导出,把多语言排版从“靠运气”变成“可控可回归”

8300
2026-02-28 17:01
17 小时前

RTL/BiDi 全链路治理清单:从编辑器到 PPTX/PDF 导出,把多语言排版从“靠运气”变成“可控可回归”

绝大多数团队第一次遇到 RTL(Right-to-Left,从右到左)问题,都不是在“渲染阶段”,而是在“上线阶段”: 某个阿拉伯语用户反馈“括号反了/数字跑位/列表编号不对”,你打开同一份文档,发现每一行都不太一样,但又说不清为什么。

真正难的地方在于:RTL/BiDi 不是一个点上的 bug,而是一条链路上的系统性缺口。你修了 Web 预览的对齐,PPTX 导出又跑了; 你修了 PDF 的 BiDi 重排,列表编号的标点位置又不一致。没有治理清单,你会永远在“补丁地狱”里循环。

这篇文章给你一份可落地的全链路治理清单:把 RTL/BiDi 当成工程能力建设——有入口规则、有实现护栏、有回归样本、有指标闭环, 最终把“靠运气”变成“可控可回归”。

TL;DR(30 秒讲清楚)

  • 治理目标:同一份内容在 Web 预览、PPTX 导出、PDF 导出三种表面下都“读得对、排得稳、回归不退步”。
  • 最小闭环:locale → rtlMode(入口)→ start/end 对齐映射(布局)→ 行级 BiDi(文本)→ 缺字回退(字体)→ 样本回归(验证)。
  • 关键原则:先在逻辑顺序上断行,再对每行做 BiDi 重排与标点镜像;不要整段重排后再断行。
  • 落地手段:中间层模型(IR)统一输入;文本字符级度量+回退;导出侧把 bullet/编号/标点策略显式化。
  • 验收方式:像素 diff + 缺字率(notdef)+ 导出耗时 p95 + 失败率(按 locale 分桶)。

治理对象:你到底要覆盖哪些“表面”(Surfaces)

建议先把“表面”列清楚,否则你永远会漏一个:

  • 输入层:locale、内容字符串、用户粘贴输入(可能带控制字符/零宽字符)。
  • 编辑层:富文本 runs、列表、对齐、缩进、标点与数字混排。
  • Web 预览层:浏览器渲染(CSS direction、logical properties、字体回退)。
  • 导出层:PPTX(可编辑)与 PDF(分发/打印)两条通道。
  • 验证层:样本库、自动回归(视觉 diff)、异常定位与灰度回滚。
  • 观测层:导出失败率、缺字率、耗时 p95、按 locale 分桶的质量面板。

治理清单(可直接照抄进你的项目)

0) 入口规则(MUST):locale → rtlMode,且贯穿整条链路

  • MUST:定义 isRTLLocale(locale),把 rtlMode 作为一级参数贯穿:编辑器、预览、导出 PPTX、导出 PDF、回归脚本。
  • MUST:对齐策略尽量用逻辑对齐(start/end),输出到具体渲染器时再映射成 left/right
  • SHOULD:混排文本不要“全局按 rtlMode 处理”,而是以行级 BiDi 为准(rtlMode 决定 base direction)。

伪代码:RTL locale 判定(示例)

function isRTLLocale(locale) {
  const normalized = (locale || '').toLowerCase().trim();
  const rtl = ['ar', 'he', 'fa', 'ur'];
  return rtl.some((lang) => normalized === lang || normalized.startsWith(lang + '-'));
}

1) 文本输入清洗(MUST):零宽字符与控制字符要可控

  • MUST:统一清理不可见干扰字符:ZWSPZWNJZWJBOM、替换字符(�)等;否则断行、测量、BiDi 都会出现“看不见的偏移”。
  • SHOULD:保留必要的换行语义(\n),并让导出通道按同一规则处理。

伪代码:最小清洗(示例)

function sanitizeText(raw) {
  return String(raw || '')
    .replace(/\\u200B/g, '')   // ZWSP
    .replace(/\\uFEFF/g, '')   // BOM
    .replace(/�/g, '')         // replacement char
    .replace(/[\\b\\f\\r\\v]/g, '');
}

2) 对齐与布局(MUST):start/end 映射 + 列表在“外侧”

  • MUST:text-align: start/end 映射为 left/right(依赖 rtlMode),不要在内容里硬编码 left/right。
  • MUST:列表(bullet/number)必须显式建模:indent、符号或 numberStartAt;并规定 RTL 下“编号点号位置”(例如 .1 vs 1.)。
  • SHOULD:把 bullet 理解为“在文本区域外侧的独立元素”,不要当成文本的一部分(更利于跨渲染器一致)。

伪代码:对齐映射(示例)

function mapLogicalAlign(align, rtlMode) {
  if (rtlMode) {
    if (align === 'start') return 'right';
    if (align === 'end') return 'left';
  } else {
    if (align === 'start') return 'left';
    if (align === 'end') return 'right';
  }
  return align; // left/center/right/justify
}

3) 文本排版(MUST):先断行,再行级 BiDi 重排 + 标点镜像

这是一条最重要的铁律,值得写进你的工程约束: 换行发生在逻辑顺序,BiDi 重排发生在每一行。

  • MUST:断行要基于可测量宽度(字符级 width),并尊重 token 边界(word/CJK/复杂脚本/空格/标点)。
  • MUST:对每一行做 BiDi 重排,并对 RTL 字符做标点镜像(括号等)。
  • SHOULD:超长 token(尤其 emoji/组合字符/复杂脚本)优先按 grapheme 分割(Intl.Segmenter),避免拆坏显示单元。
RTL/BiDi 典型故障定位树:括号方向反/数字跑位/换行错位/列表编号异常/缺字空框分别对应标点镜像、BiDi 行级重排、断行 token 规则、编号点号策略、字体回退与子集化等根因
图:把症状映射到“哪一层的规则没做”,排障会快很多。

伪代码:行级 BiDi(概念版)

function layoutBidiLines({ chars, maxWidthPt, rtlMode }) {
  // chars: [{ char, widthPt, fontFamily, style... }](逻辑顺序)
  const baseDir = rtlMode ? 'rtl' : 'ltr';

  const tokens = tokenize(chars);                 // word/cjk/complex/space/punct/newline...
  const lineRanges = breakLines(tokens, maxWidthPt); // 逻辑顺序断行(必要时按 grapheme 硬拆)

  const levels = bidiGetEmbeddingLevels(charsToText(chars), baseDir);
  return lineRanges.map(({ start, end }) => {
    const order = bidiReorderIndices(start, end, levels);
    return order.map((i) => ({
      ...chars[i],
      char: isRtl(levels[i]) ? mirrorPunct(chars[i].char) : chars[i].char,
    }));
  });
}

4) 阿拉伯语与复杂脚本(SHOULD):shaping 与组合字符别忽略

  • SHOULD:阿拉伯语需要 shaping(连写整形),否则同一个字母在不同位置的形态不会正确。
  • SHOULD:复杂脚本(如 Devanagari/Thai 等)不要简单按“单字符”断行与测量,至少要把组合与零宽字符纳入考虑。

伪代码:shaping(示例)

function shapeIfNeeded(text, baseDir) {
  if (baseDir !== 'rtl') return text;
  // 伪代码:对阿拉伯语做 shaping(具体可用现成 reshaper)
  return reshapeArabic(text);
}

5) 字体策略(MUST):缺字回退 + 字体子集化 + 统计缺字率

  • MUST:导出侧要做缺字回退(notdef 兜底):按 Unicode 范围为 Emoji/CJK/Arabic/Hebrew/Thai/Devanagari 等准备 fallback 字体。
  • MUST:PDF 要做字体子集化(subsetting),否则多语言文档会把文件体积推到不可控。
  • SHOULD:把缺字做成指标:notdef glyph 数 / glyph 总数;并按 locale、字体名、页面类型分桶。

6) 导出通道差异治理(MUST):PPTX 与 PDF 的“差异边界”要写清楚

  • MUST:PPTX:明确是否启用渲染器级 rtlMode;对齐映射必须一致;富文本必须拆 runs;列表规则要一致。
  • MUST:PDF:明确阅读方向(可选);文本渲染必须走“断行 + 行级 BiDi + 镜像”;bullet 尽量用几何绘制避免字体依赖。
  • SHOULD:建立跨通道对照样本:同一份内容导出 PPTX 与 PDF,对比“关键不变量”(不多一行、不跑版、编号一致)。

延伸阅读(实现细节案例): PptxGenJS 导出 PPTX:RTL 对齐 + runs + 列表 / pdf-lib 导出 PDF:字体度量 + BiDi

7) 回归与发布门禁(MUST):用“样本库 + 自动化”守住不退步

  • MUST:建立 RTL 样本库(建议 10–50 份):纯 RTL、RTL+数字、RTL+英文缩写、RTL+列表、RTL+括号/引号、RTL+emoji。
  • MUST:导出自动回归:把导出产物渲染成图片,与基准做像素 diff(允许轻微容差),并在 CI/发布前跑。
  • MUST:指标门禁:导出失败率、缺字率、耗时 p95、体积 p95;任一超阈值就阻断发布或必须灰度。
  • SHOULD:把问题定位信息写进日志:哪一行触发 BiDi、哪一个字符触发 fallback、哪一页像素 diff 超阈值。

指标与验证(Metrics & Validation)

清单能帮你把“该做什么”写清楚,但能否长期稳定,取决于你有没有把验收固化成可观测指标自动回归。 建议最少把下面 5 个门禁做成仪表盘与发布前检查(按 locale / format / release 分桶):

  • 导出失败率:export_failed / (export_completed + export_failed)(失败一定要有事件与异常信号)。
  • 缺字率:notdef_glyph_count / glyph_count(按字体名/页面类型分桶,便于定位 fallback 缺失)。
  • 导出耗时 p95:分别看 pptx/pdf 与不同语言(RTL 往往更吃布局与测量)。
  • 文件体积 p95:PDF 尤其关注(字体全量嵌入/图片未压缩会瞬间爆炸)。
  • 像素回归通过率:样本库导出 → 渲染成位图 → 像素 diff;通过率下降必须阻断或灰度。

通过标准(建议):

  • 正确性:样本库像素 diff 不回退;RTL/混排样本不跑版、不乱序。
  • 缺字:notdef 缺字率接近 0;fallback 命中可追踪可定位。
  • 性能/体积:导出耗时 p95 与文件体积 p95 不回退(或在可接受阈值内)。

实操上,建议每次发布都做“对照验证”:同一份样本库在旧版本/新版本各跑一遍,把 p95/缺字率/像素 diff 结果放到同一张对照表里, 再决定是否放量。

最小可复现验证(示例):

# 1) 准备一组 RTL 样本(至少 5 份):纯 RTL / RTL+数字 / RTL+英文缩写 / RTL+列表 / RTL+括号
# 2) 对同一份样本分别导出:Web 预览截图 + PPTX + PDF
# 3) 把导出产物渲染成图片,与预览截图做像素 diff(允许轻微容差)
# 4) 抽样人工检查:列表编号点号位置、括号镜像、混排顺序、是否出现缺字方框
# 5) 记录并分桶:失败率/缺字率/耗时 p95(按 locale/format/release)

预期与判定(建议):

  • 正确性:样本库在 Web/PPTX/PDF 三种表面下“读得对、排得稳”,不多一行、不乱序。
  • 缺字:notdef 缺字率接近 0;任何缺字都能定位到“哪个字符 + 哪个字体 + fallback 是否命中”。
  • 回归:像素 diff 通过率不回退;出现回退能定位到具体层(清洗/断行/BiDi/对齐/字体)。

不通过先查:是否在整段级别做了 BiDi(应改为行级);对齐是否写死 left/right(应改为 start/end 映射); 列表是否把 bullet 当成文本(应显式建模);以及字体 fallback 是否覆盖 RTL 脚本范围。

FAQ(常见问题)

Q1:为什么 RTL 问题总是“偶现”?

因为 BiDi 混排经常由“很小的条件”触发:一行里出现了数字、英文缩写、括号或引号,embedding levels 就会变化。 没有行级 BiDi 与样本回归,你修一次只能碰巧覆盖一类场景。

Q2:只要给容器加 dir="rtl" 不就好了?

这只能解决 Web 预览的一部分问题:对齐与基本方向。导出(PPTX/PDF)不一定遵循浏览器的排版结果, 你仍然需要在导出侧显式实现“断行 + 行级 BiDi + 镜像 + 列表策略”。

Q3:为什么“先重排后断行”会出错?

因为断行属于布局决策,它依赖逻辑顺序与 token 边界。你先把整段重排成视觉顺序,再断行,相当于在错误的序列上切行, 最终会出现字符被拆散、标点跑位、数字乱跳。

Q4:如何选择 fallback 字体?

工程上建议按 Unicode 范围准备一组“覆盖面广”的字体族:CJK、Arabic、Hebrew、Emoji、Thai、Devanagari 等。 导出时以字符级 notdef 判定为准:主字体缺字就切换 fallback,并把这个事件记录为“缺字率”指标。

Q5:为什么要做字体子集化?

多语言字体文件通常很大。PDF 如果全量嵌入字体,文档体积会不可控,甚至影响下载与打开速度。 子集化的本质是:只嵌入这份文档真正用到的字符集合。

Q6:如何快速定位“括号方向反了”?

优先检查是否做了“RTL 行内镜像字符”:括号、尖括号等符号在 RTL 视觉顺序下需要镜像。 如果你已经行级重排,但没做镜像,括号就会看起来“反了”。