RTL/BiDi 全链路治理清单:从编辑器到 PPTX/PDF 导出,把多语言排版从“靠运气”变成“可控可回归”
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:统一清理不可见干扰字符:
ZWSP、ZWNJ、ZWJ、BOM、替换字符(�)等;否则断行、测量、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 下“编号点号位置”(例如
.1vs1.)。 - 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),避免拆坏显示单元。
伪代码:行级 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 视觉顺序下需要镜像。 如果你已经行级重排,但没做镜像,括号就会看起来“反了”。