1. 竖排需求的场景,比你想的要多得多
1.1 古风界面、书籍面板和弹窗标题都在要这个功能
最近在一套古风题材项目里,UI组连续提了三次同一个需求:中文字幕要竖排。第一次是副本选择弹窗的标题,第二次是开场剧情里的书信界面,第三次直接要求整个剧情面板能切竖排模式。这类需求并不是特例。古风MMO的公告牌、卷轴任务、诗词答题;二次元游戏的日记系统、神社签文;甚至一些现代题材里的书本翻页效果,都会遇到竖排展示。只要做的是中文项目,早晚会在某个版本排期里看到“竖排文字”四个字。
竖排和横排不只是“转个方向”的视觉差异。中文竖排有自己的一套排版规则:列从上到下、下一列在左边或右边;句读、引号、书名号的朝向和位置都要调整;段落之间要有明显的列间距。美术拿到竖排需求后,通常会先给出几版参考图——大多是印刷书刊的竖排样式。如果程序这边只给一个旋转过的UILabel,肯定过不了评审。
我在这套项目里做竖排支持之前,也在网上翻过不少方案。论坛和博客上的回答大致分两类:一类说“给每个字加\n不就行了”,一类说“把整个Label旋转90度”。这两条路我都实际试过,效果都撑不过美术的眼睛。这篇文章就把我在NGUI源码层做竖排文字支持的完整过程写下来,包括排版算法、标点位置、动态字体处理和几个容易忽略的坑,给以后还要接竖排需求的团队做个参考。
1.2 我最初踩过的三种“简单方案”
先说给每个字加\n。看起来确实是竖排了,实际跑起来问题一堆。NGUI的UILabel会按行高做换行,这里的一行并不是一个字的视觉高度,而是字体行高乘上行距系数,通常是字号的1.2到1.5倍。把每个字单独成行后,字与字的垂直距离会被行距拉开,间距根本不可控。想调小间距,只能动Line Spacing,那样又会影响横排界面。更麻烦的是标点:逗号、句号默认放在行的底部,竖排里它们被甩到每一列的顶部,看着非常奇怪。中文规范里竖排的句读应该在字的右下角位置,而不是行首。逐个加\n的做法除了实现简单,几乎没有任何可取之处。
再说旋转90度。把Label挂一个Transform旋转,感觉上竖过来了,实际上阅读顺序完全不对。横排文字是从左到右,旋转之后变成从上到下,但下一列的方向跑到哪里去了?从左到右变成了从右到左,可下一列应该沿另一侧继续。说白了,旋转只改了朝向,没有改排版顺序。而且旋转后的宽高判断、点击区域、多行截断全部混乱。比如UILabel的Width本身是横排宽度,旋转后它变成了视觉上的高度,在编辑器里调整尺寸时非常反直觉。这个方法也被我放弃了。
第三种是我一度觉得最省事的:让美术把竖排内容直接切图。适合标题、两三行短文案,一旦涉及动态内容就完蛋。多语言、服务器下发活动公告、玩家改昵称、数字计时器这些场景,根本不可能让美术随时出图。切图方案只能做静态展示,不适合做文本组件。
1.3 结论:必须从UILabel的排版层动手
试过一轮之后,我的判断很明确:竖排必须做成UILabel的一种排版模式,而不是在业务层拼凑。NGUI本身是开源的(至少你可以在工程里拿到全部C#源码),UILabel的排版逻辑集中在ProcessText和PrintCharacter两个方法里。横排改成竖排,本质上就是把“按行布局、行内字符横向递进”改成“按列布局、列内字符纵向递进”。这个改动放在源码层,一次搞定,所有使用UILabel的地方都能通过一个开关切换。后面不管是剧情面板、活动公告还是聊天框的特殊样式,都可以复用同一套竖排逻辑。
2. NGUI文字排版管线里,竖排到底卡在哪个环节
2.1 从mText到屏幕上的字形,标准链路是什么样的
要改竖排,先得知道NGUI的UILabel是怎么把字符串变成屏幕上的网格的。整个过程可以分成三个阶段。第一阶段是预处理:UILabel拿到你赋给text的字符串,把它存进mText,然后处理富文本颜色标记、转义字符、URL链接等,生成一个内部的mProcessedText。第二阶段是折行和度量:根据你设置的Width和Alignment,调用NGUIText.WrapText这类逻辑,在mProcessedText里插入换行符,并计算出最终打印区域。第三阶段是字形写入:PrintCharacter遍历mProcessedText里的每个字符,取出字体图集里的字形信息,计算出该字符在局部坐标系下的位置、宽高和UV,填入顶点列表,最后交给UIWidget的OnFill提交到渲染层。
竖排改造真正要动的,是第二和第三阶段。第二阶段里的自动折行是按“水平宽度”来算的,一行装不下了就换下一行,但竖排要求的是“垂直高度不够了就换下一列”,所以不能沿用横排的折行计算。第三阶段的坐标递进也一样,横排每个字符的x在增加,竖排每个字符的y在减小。
2.2 横排与竖排的核心坐标差异
用一张表把横排和竖排的排版参数说清楚。
| 排版方向 | 主轴推进方向 | 换行/换列触发条件 | 主轴上新增量 | 副轴上新增量 |
|---|---|---|---|---|
| 横排 | 每行内从左到右,x递增 | 当前行x超出Label宽度 | 字符的advanceX | 换行后y减少一行的行高 |
| 竖排 | 每列内从上到下,y递减 | 当前列y超出Label高度 | 字符的advanceY | 换列后x增加一列的列宽 |
横排里,一行的高度通常取字号乘以固定行距,一行的宽度是每个字符advanceX的累加。竖排里,一个字符的“行高”在BMFont里可以直接取字形信息的advanceY,在动态字体里则一般是字号加一个你自定义的间距。列宽也不应该简单取字号,而是取这一列里所有字符字形的最大实际宽度。不过大部分中文字库的字形都是正方形,直接取fontSize再乘间距也够用,我在后文会给出两种算法的取舍。
2.3 DynamicFont与BMFont在竖排改造中的差异
NGUI的字体来源分两种,BitmapFont(位图字体)和DynamicFont(动态字体)。位图字体是通过BMFont工具或NGUI的Font Maker生成的,每个字符在fnt文件里都记录了xOffset、yOffset、width、height、advanceX、advanceY。改造竖排时,每个字的高度数据是现成的,直接在源码里读取就好。动态字体则麻烦一点,NGUI走的是Unity内置Font接口,动态字形并不保证提供可靠的advanceY,很多动态字体的advanceY甚至为0。竖排模式下,遇到DynamicFont就不能依赖字形数据,得退回到“fontSize + verticalSpacing”来估算行高,否则所有字会叠在一起。
我在项目里同时还遇到过另一个差异:位图字体如果包含的是等宽中文,竖排效果最好;动态字体因为要考虑抗锯齿和动态图集,字符的实际绘制尺寸可能比基准字号大一圈,竖排的间距会产生轻微不均匀。这种情况通常加一个固定的spacing系数就能解决,后面代码里我会体现。
3. 重写UILabel排版逻辑,实现真正的竖排
3.1 改源码还是继承重写,我为什么选了改源码
网上普遍流传一句话叫“能不改第三方源码就不改”,但实际做下来,UILabel这层不改源码非常难受。ProcessText、PrintCharacter、mProcessedText这些关键的排版方法和字段,在NGUI里混了private和protected,外部继承时能拿到的只是一部分。想不改源码又实现完整竖排,得把大半个排版链路重新复制一份,维护成本比直接改源码还高。
多数商业项目根本不会随便升NGUI版本,版本长期锁死。所以我选择了直接把UILabel.cs拷进工程,加代码,锁死版本。这是当时性价比最高的做法。如果你是那种特别在意插件纯净度的工程,也可以尝试继承方案,但是要做好心理准备:你需要在子类里重写一整套字符度量逻辑,还要绕过基类的私有字段,代码会非常绕。
3.2 在UILabel里新增竖排开关和间距参数
我在UILabel.cs里增加了下面几个字段。
[HideInInspector] public bool vertical = false; [Range(0f, 20f)] public float verticalSpacing = 0f; [Range(0f, 40f)] public float columnSpacing = 0f; // 竖排方向,false表示列从左往右排列;true表示古籍风格的列从右往左 [HideInInspector] public bool verticalRTL = false;verticalSpacing是字与字之间的额外垂直间距,columnSpacing是列与列之间的水平间距。之所以不用NGUI自带的lineSpacing,是因为那个值同时影响横排渲染,而竖排需求一般是独立开启的,两个轴共用一个间距参数迟早会打架。字段故意标了HideInInspector,这样老界面上不会多出一堆参数,竖排文字一般由预制体在初始化时设置,不进Inspector手动可见也没有问题。当然,如果你做编辑器扩展,也可以把这几个字段暴露出来。
3.3 核心算法:从“逐字横推进”到“逐字纵推进”
竖排算法的关键说穿了就一句话:不再按行推进,而是按列推进。
以Pivot为中心的情况为例。假设Label宽为mWidth,高为mHeight,fontSize为mFontSize。横排时,第一个字符的基线X从左侧开始,Y在行中间。竖排时,第一个字符应该出现在容器的右上方或左上方,然后y坐标向下递减,每个字走一个“行高”。当y超出底部边界时,x坐标向右(或向左)增加一个“列宽”,y回到顶部继续写入下一列。
列宽最简单取法是mFontSize加columnSpacing,因为中文是方块字,这个办法在绝大多数场景下够用。如果遇到英文、数字、特殊符号混排,英文字形宽度明显小于fontSize,按fontSize算列宽会产生比较大的留白。更精确的做法是先扫描一遍当前文本,统计这一列里所有字符字形宽度的最大值,用最大值当列宽。代价是每次排版多遍历一次文本,不过竖排文本通常不会特别长,这个性能开销可以接受。我在代码里给列宽计算加了一个开关,让用户在“快”和“准”之间自己选。
竖排主轴的起始位置也要考虑Pivot。NGUI有Bottom、Center、Top三类垂直Pivot和Left、Center、Right三类水平Pivot。横排时Pivot决定了文本块相对Label锚点的位置,竖排时其实质含义没有变:Top类型的Pivot,第一列从容器顶部开始往下排;Bottom类型则从底部开始往上排。但为了符合阅读习惯,竖排大多数时候希望列内是“顶对齐”,列组在水平方向按Left、Center、Right对齐。我在代码里把垂直Pivot临时解释为“列内的起始方向”,水平Pivot解释为“列组的对齐方向”,这也是踩了几次坑之后确定下来的规则。
3.4 关键代码:竖排打印分支
下面这段代码是在原版PrintCharacter的基础上,把竖排分支单独抽出来。为了不贴一整篇源码,我把和具体版本强相关的字段引用做了简化,核心逻辑是这样:
protected virtual void PrintCharacter(BetterList<CharacterInfo> verts, bool isUnderlined) { if (vertical) { PrintVerticalCharacter(verts, isUnderlined); return; } // 原版的横排PrintCharacter逻辑,保持不变 // ... } private void PrintVerticalCharacter(BetterList<CharacterInfo> verts, bool isUnderlined) { float originX = 0f; float originY = 0f; // 根据pivot计算起始位置 if (pivot == UIWidget.Pivot.TopLeft || pivot == UIWidget.Pivot.Top || pivot == UIWidget.Pivot.TopRight) { originY = mHeight * 0.5f - mFontSize * 0.5f; } else if (pivot == UIWidget.Pivot.BottomLeft || pivot == UIWidget.Pivot.Bottom || pivot == UIWidget.Pivot.BottomRight) { originY = -mHeight * 0.5f + mFontSize * 0.5f; } else { originY = 0f; } if (pivot == UIWidget.Pivot.Left || pivot == UIWidget.Pivot.TopLeft || pivot == UIWidget.Pivot.BottomLeft) { originX = -mWidth * 0.5f + mFontSize * 0.5f; } else if (pivot == UIWidget.Pivot.Right || pivot == UIWidget.Pivot.TopRight || pivot == UIWidget.Pivot.BottomRight) { originX = mWidth * 0.5f - mFontSize * 0.5f; } else { originX = 0f; } float colWidth = mFontSize + columnSpacing; float lineHeight = mFontSize + verticalSpacing; if (verticalRTL) originX = -originX; float x = originX; float y = originY; int lineCount = 0; for (int i = 0; i < mProcessedText.Length; i++) { char ch = mProcessedText[i]; // 跳过富文本标记,NGUI在ProcessText阶段已经把部分符号转成特殊字符 if (ch == '\n') { lineCount++; if (!verticalRTL) { x += colWidth; } else { x -= colWidth; } y = originY; continue; } // 获取字形 BMSymbol symbol = null; int chId = mFont.GetGlyph(ch, out symbol); if (chId == 0) continue; float glyphWidth = symbol != null ? symbol.width : mFontSize; float glyphHeight = symbol != null ? symbol.height : mFontSize; // 竖排:把字符矩形放到当前列位置 float offsetX = x - glyphWidth * 0.5f; float offsetY = y - glyphHeight * 0.5f; // 这里插入一个小钩子,用于标点位置偏移 ApplyVerticalPunctuationOffset(ch, ref offsetX, ref offsetY); // 写入CharacterInfo CharacterInfo info = new CharacterInfo(); info.index = chId; info.font = 0; info.x = offsetX; info.y = offsetY; info.width = glyphWidth; info.height = glyphHeight; // uv相关的赋值省略 verts.Add(info); // 列内纵向推进 y -= lineHeight; // 超出Label高度,换列 if (y < -mHeight * 0.5f + mFontSize * 0.5f) { lineCount++; if (!verticalRTL) { x += colWidth; } else { x -= colWidth; } y = originY; } } }这段代码已经是完整逻辑的骨架。实际接入的时候有几个地方要和你手里的NGUI版本对齐:GetGlyph的返回值类型、CharacterInfo字段名、BMSymbol是否为空。不同小版本之间会有差异,但总体思路不用变。
3.5 让竖排模式躲开横排的自动折行
还有个不得不处理的地方:ProcessText里的横排自动折行。竖排模式下,我们不希望NGUI按照mWidth去折行,否则每一列还没排满,就被水平宽度切断了。
我的做法是在ProcessText的开头加一个判断。如果vertical为true,就先把NGUI的折行宽度临时设置成一个很大的值,让它的自动换行逻辑基本失效,同时保留富文本符号的预处理。这样mProcessedText里不会被插入那么多横排换行符,竖排打印逻辑才有完整的文本可用。这个办法看起来很hack,但比绕过整个ProcessText重新实现颜色标签解析要省事很多。
protected override void ProcessText() { if (vertical) { int oldMaxLineWidth = mMaxLineWidth; mMaxLineWidth = 999999; base.ProcessText(); mMaxLineWidth = oldMaxLineWidth; return; } base.ProcessText(); }注意mMaxLineWidth在有些NGUI版本里不是这个字段名,你需要到源码里找到Width相关的折行变量替换一下。竖排真正的高度限制交给PrintVerticalCharacter里的纵向判断,而不是交给ProcessText的宽度判断。
3.6 编辑器里的宽高含义变化
做完竖排之后,UILabel在Inspector里的Width和Height的含义会发生变化。横排时Width限制一行能放多少字,Height限制总共显示多少行。竖排时,Height变成一列能排多少字,Width变成总共能放多少列。这个变化很容易让人困惑,尤其是美术在编辑器里调尺寸的时候。
我在项目里的做法是给竖排模式做了一个自定义Inspector,当vertical为true时,把原本的Width、Height标签显示为“横向列容量”“纵向列容量”。这纯属编辑器体验优化,不改UILabel核心逻辑,但非常值得做。哪怕你不做自定义Inspector,也建议至少加个注释,不然过了几个月回头改UI的人一定会踩坑。
4. 竖排之后的标点、英文、富文本和国际化大坑
4.1 中文标点在竖排里的位置偏移
这是竖排里最需要细心处理的一环。中文标点的规范在竖排里和横排完全不一样。句号、逗号、顿号在竖排里应该放在字身右下角附近;问号和感叹号经常需要垂直居中或偏下;冒号和分号偏向底部;引号、括号、书名号要整体旋转90度方向放置。
我最初直接把横排的标点字形原样塞进竖排,结果所有标点都老老实实待在“行列中心”。静态短句还能忍,一旦做书信整页展示,满屏标点位置不对,观感非常不好。
我的解决方案是在PrintVerticalCharacter里加了一个ApplyVerticalPunctuationOffset方法,按Unicode区间判断标点类型,给offsetX和offsetY加一个像素级偏移。下面是我维护的一组初始值,具体像素数值要根据你项目用的字体族微调。
private void ApplyVerticalPunctuationOffset(char ch, ref float offsetX, ref float offsetY) { if (ch >= 0x3002 && ch <= 0x3003) // 句号、问号 { offsetX += mFontSize * 0.2f; offsetY -= mFontSize * 0.2f; } else if (ch == 0xFF0C || ch == 0x3001) // 全角逗号、顿号 { offsetX += mFontSize * 0.15f; offsetY -= mFontSize * 0.25f; } else if (ch == 0xFF1A || ch == 0xFF1B) // 全角冒号、分号 { offsetX += mFontSize * 0.1f; offsetY -= mFontSize * 0.2f; } // 引号、书名号等成对符号,建议进一步做朝向翻转 }这块代码是纯经验值,别指望一次到位。我当时是根据美术给的参考图调了一个下午,把每个标点的位置写成可配置数组,而不是硬编码在判断里。最理想的做法是把“标点类型->偏移比例”做成一个字段,策划或美术可以在Inspector里按字体微调。
4.2 英文、数字和空格怎么处理
竖排场景里经常混进英文和数字,比如“第3章”“VIP12”“共9999人围观”。这些字符如果按中文方块字处理,视觉效果会很怪,因为英文和数字都是窄字形,竖排时每一列宽度被拉大,留白太多。
业界比较常见的处理有两种。一种是把英文字母整体旋转90度,让单词顺着竖排方向横躺着读。这种效果在日文排版里很常见,但实现复杂度高:你需要对每段连续英文字符做单独分组,计算单词宽度,再对整组字形旋转,同时还要调整标点。另一种是干脆把竖排限制为纯中文场景,遇到英文和数字仍然横排显示,每个西文字符单独占一列,不旋转,也不做单词合并。这个方案胜在简单,实际项目里只要文案约定好书写的场景,基本够用。
我先采用的是第二种方案。规则是:内容里如果出现连续英文单词,文案统一在资源管理阶段就处理成“按字符拆开的竖排样式”,或者直接避免长段英文出现在竖排容器里。等后续确实有日式竖排那种复杂需求,再考虑实现整词旋转。
空格也需要单独处理。全角空格在竖排里应该表现为一个字的空白位置,半角空格则应该视为零宽或半个字宽。我在PrintVerticalCharacter里对空格做了特殊判断,跳过绘制但仍推进lineHeight,否则竖排文本遇到空格会直接挤成一团。
4.3 富文本标签会参与竖排推进,必须跳过
NGUI支持在字符串里内嵌[00ff00]这类颜色标签和[url=xxx]超链接标签。横排时,这些符号在ProcessText阶段被NGUI内部转换成不可见的标记,不会占据排版位置。但我在竖排改造过程中发现,如果沿用“巨大宽度绕过折行”的思路,某些版本的NGUI会在mProcessedText里保留部分符号占位,竖排打印时如果没有额外过滤,屏幕上会多出空白列或异常字符。
解决方式是在竖排打印循环里加一个状态机,用小括号计数方式跳过成对出现的符号标记。NGUI的标签格式变化不大,你可以参考它自带的ParseSymbol逻辑做过滤。记住一个原则:富文本标签必须在ProcessText阶段被处理干净,你不能期望PrintCharacter再去识别它们。
4.4 Localization多语言切换后的重建问题
项目里做过多语言的人都知道,NGUI的UILabel在切换语言时会重新赋值text。竖排模式下,German、French这些语言单词长度差异巨大,俄罗斯语、阿拉伯语更不用说。中文一句话可能竖排三列,英文翻译过去可能需要八列,宽高瞬间溢出。
不要试图在UILabel里做自动缩放来适配多语言竖排。我的建议是把竖排Label的宽高固定为一个足够容纳最长文本的数值,配合列数统计脚本在运行时计算实际列数,动态调整外层容器的尺寸。否则不同语言之间,竖排容器忽大忽小,界面布局会抖动。
另外,繁体中文和简体中文的字符宽高基本兼容,但文本长度差异也要注意。繁体文本会比简体长不少,切换语言以后要慎重判断是否需要重新手动设置Label.height。
4.5 动态字体下可能出现的字形叠字问题
我现在这套项目里,竖排主要用BMFont。但我另外用动态字体做过一次性验证,发现动态字体的坑主要在两个地方。一是前面说过的advanceY不可靠,二是同一字体在动态图集里刚加载第一个字符时,字形的实际尺寸可能和fontSize不一致,导致第一列的第一批字位置略微错乱。
解决动态字体叠字问题,可以在创建UILabel时调用一次font.RequestCharactersInTexture,把文本先都请求一遍。或者在竖排先例里不读取动态字体的字形height,而是统一用fontSize作为字宽和列宽。等字体图集完全生成后再刷新,位置就稳定了。
4.6 内存分配和GC问题
竖排改造如果做得粗糙,会引入一个隐形性能问题:每帧重建文本时,如果你在PrintVerticalCharacter里大量new对象,比如new CharacterInfo、new float数组、new List,GC压力会比横排大得多。因为竖排通常用在整页书信、公告这种长文本上,一页几百个字符很常见。
我做了三处优化:一是把CharacterInfo尝试复用为类对象池,二是不在循环里做字符串拼接,三是把所有标点判断改成switch语句而不是字符串Contains。NGUI本身的BetterList已经避免了一部分分配,但竖排逻辑里你新增的临时对象不在它的管理范围内,需要自己注意。
5. 从竖排到古籍右页阅读模式的扩展
5.1 从右往左排列的竖排
传统古籍的竖排是从右向左,和现代横排从左向右完全是反的。做竖排的时候,如果一开始就把方向逻辑写死为“逐列向右”,后面要支持古籍模式就得返工。所以我在设计字段时直接加了verticalRTL,在换列时判断方向。
从右向左的竖排,起始列X应该从容器右边开始,每次换列x向左移动一个列宽。顶部到底部仍是一样。这个字段的代码改动很小,前面代码已经体现。真正麻烦的是和Pivot的配合。古籍模式里,列组右侧对齐往往更常见,所以我在初始化时会把originX的计算逻辑反向处理一下。
5.2 标点在古籍模式里的朝向翻转
从右往左排之后,标点的视觉“重心”也要反过来。比如句号在从右往左的竖排里,应该向左边偏一点还是右边偏一点,不同字体族习惯不太一样。我在ApplyVerticalPunctuationOffset里增加了对verticalRTL的判断,把所有X方向的偏移取反。这个细节不处理,古籍模式会看起来“标点飘了”。
5.3 配合Tween做逐字出现的剧情演出
竖排书籍界面通常不只是静态显示,往往配合逐字出现的演出。NGUI的TweenAlpha能对UILabel整体做淡入,但逐字出现需要自定义组件。我自己写过一个VerticalTextReveal,挂在竖排Label上,按每帧固定字数增加显示长度。这里有个小技巧:不要把文本截断后重新赋给UILabel,那样会每次触发ProcessText重建,长文本下帧率会掉。更好的做法是控制UILabel的fillAmount或者用shader裁剪掉未显示部分,或者直接把需要隐藏的字数换算成纵向偏移,只显示指定区间的字符。
5.4 性能优化方向和数据挑选
竖排改造跑通之后,我建议再做一次性能摸底。重点看三块:ProcessText的耗时、PrintCharacter的耗时、网格顶点数量。NGUI原本对静态文本有缓存机制,如果竖排逻辑破坏了缓存判断,静态UI每一帧都在重新排版,那性能会很糟。我在项目里把UILabel的cacheText相关判断也兼容了一下,只有当text、vertical相关参数变化时才重建网格。没做这个优化之前,一个页面十几个竖排Label,帧时间直接增加两三毫秒,优化之后基本回到横排水平。
6. 最终效果与个人心得
竖排功能交付之后,美术那边的反馈比我预想的好得多。原本需要切图的诗词标题、书信正文、活动公告,现在全部改成动态文本。多语言切换、服务器下发的临时活动文案,也都能直接复用同一套竖排Label。
回头总结,最大的心得是:不要试图在业务层用旋转、加换行符之类的方式模拟竖排,排版层面的需求一定要在排版层解决。虽然改NGUI源码听起来有点“野蛮”,但换来的是整个项目所有Label组件都具备了竖排能力,这个收益非常值。
如果你现在正打算做竖排,我的建议是按这个顺序来:先把横排拆成“折行”和“字符打印”两个环节,然后在PrintCharacter里加竖排分支,先跑通最基本的列式布局;再处理标点、间距、富文本这些细节;最后再考虑古籍模式、逐字演出等进阶功能。每一步做完都要立刻用一屏真实文案验收,别只在字符串“竖排测试”四个字上验证。竖排的细节藏在标点、英文、空格和排版规则里,只有真刀真枪跑实际内容,问题才会暴露出来。