HarmonyOS 排版便笺【便笺系列 03】:段落边界与对齐规则【共创稿事节】
给便笺增加居中按钮时,我会先做一件看似多余的事:只把范围放在标题中间的一个字上,再观察整行标题会怎样变化。若沿用上一篇字符样式的直觉,容易期待只有那个字移动;段落格式恰好要求相反的理解。本篇把开发顺序拆成定位段落、应用格式、查询结果三个步骤,围绕同一份便笺解释字符范围与段落责任的关系。

第一笔先记下换行,而不是行数
沿用 NoteTypeset、API 12 和第一篇的 NoteSeed.ets,页面初始内容没有改变。标题末尾有一个明确换行,正文跟在其后。屏幕上看见多少行会受到字号与宽度影响,文档中哪里开始新段落则由内容结构决定。先固定换行位置,后面的居中与缩进才能讨论同一件事。
在开发记录里,软换行与段落换行应该分开描述。正文变宽时原来折成两行的句子可能合成一行,这不代表两个段落合并了;用户输入一个换行,则可能建立新的段落边界。把视觉行号存成段落标识,会让同一文档在不同窗口上得到不同业务结构,也让格式恢复变得不可靠。
本次先不做自由选择范围的工具栏,而是保留两个明确实验动作:恢复样例后让首段居中,恢复样例后给正文段设置缩进。每次格式实验都回到同一份输入,避免前一次编辑改变了后一次的定位。按钮名称把恢复行为写清楚,不让测试辅助操作伪装成无损的普通格式命令。
这并不意味着正式编辑器每次格式化都应该重置文本。真实工具栏应使用当前选择或光标定位到段落,并处理混合格式状态。当前页面的目标是隔离段落机制,固定输入能够排除手势、光标位置和旧选择带来的干扰。把机制验证通过后,再把它接到上一篇的范围入口,会比同时排查两个层次简单。
第二步让小范围命中完整段落
字符格式与段落格式虽然都接收范围,却不能解释为相同的作用粒度。updateSpanStyle 适合改变范围内的字重或颜色;updateParagraphStyle 用范围找到涉及的段落,再更新段落对齐或缩进。范围只落在标题中的一部分时,标题整体居中是符合段落语义的结果,并不是接口越界修改。
页面采用标题内部的一到二作为定位范围,避免把端点恰好放在换行边界上。这种选择有意减少歧义:当前只要证明标题所属段落会响应即可,不必让第一次实验同时承担空范围、跨段范围和边界归属的验证。随后再补边界用例,结论才容易归因。
段落更新之后立即调用 getParagraphs 查询同一个范围,将返回的段落范围显示到页面。这个读取不能替代视觉检查,却能解释“为什么只有一个字的范围影响了整段”。界面变化负责证明排版结果,返回范围负责证明命中的结构,二者一起记录,比只截一张居中后的图片更容易追踪原因。

图 2:页面从原始两段内容开始;两个恢复按钮建立不同的独立格式实验,查询按钮只读取当前内容。
第三步把格式实验写成完整页面
替换 pages/Index.ets,继续导入第一篇提供的 fillSeed。基础 RichEditor 与当前使用的段落接口都包含在 API 12 基线内。代码没有加载外部文件,没有异步任务,也没有特殊权限。页面就绪后建立样例,销毁时只关闭普通 ready 标志,避免对已经离开界面的状态继续赋值。
import { fillSeed } from '../common/NoteSeed';
@Entry
@Component
struct Index {
private controller: RichEditorController = new RichEditorController();
private ready: boolean = false;
@State private status: string = '等待编辑器';
@State private detail: string = '段落查询尚未执行';
aboutToDisappear(): void { this.ready = false; }
private centerTitle(): void {
if (!this.ready) { return; }
fillSeed(this.controller);
this.controller.updateParagraphStyle({
start: 1, end: 2,
style: { textAlign: TextAlign.Center }
});
const paragraphs = this.controller.getParagraphs({ start: 1, end: 2 });
const ranges: string[] = [];
for (const item of paragraphs) {
ranges.push(`[${item.range[0]}, ${item.range[1]})`);
}
this.detail = ranges.join(' · ');
this.status = '首段居中,正文保留原格式';
}
private indentBody(): void {
if (!this.ready) { return; }
fillSeed(this.controller);
this.controller.updateParagraphStyle({
start: 5, end: 6,
style: { textAlign: TextAlign.Start, leadingMargin: 20 }
});
const paragraphs = this.controller.getParagraphs({ start: 5, end: 6 });
const ranges: string[] = [];
for (const item of paragraphs) {
ranges.push(`[${item.range[0]}, ${item.range[1]})`);
}
this.detail = ranges.join(' · ');
this.status = '正文段缩进 20 vp';
}
private inspectAll(): void {
if (!this.ready) { return; }
const paragraphs = this.controller.getParagraphs();
this.detail = paragraphs.map((item: RichEditorParagraphResult) =>
`[${item.range[0]}, ${item.range[1]})`).join(' · ');
this.status = `当前返回 ${paragraphs.length} 个段落`;
}
build() {
Column({ space: 14 }) {
Text('段落对齐').fontSize(26).fontWeight(FontWeight.Bold)
.fontColor('#263246')
Text('字符范围定位,段落承担格式').fontSize(14).fontColor('#68758A')
RichEditor({ controller: this.controller })
.width('100%').height(210).padding(16)
.backgroundColor(Color.White).borderRadius(16)
.onReady(() => {
this.ready = true;
fillSeed(this.controller);
this.status = '保留原始段落格式';
})
Row({ space: 12 }) {
Button('恢复后居中').onClick(() => { this.centerTitle(); })
Button('恢复后缩进').onClick(() => { this.indentBody(); })
}
Button('查询当前段落').onClick(() => { this.inspectAll(); })
Text(this.status).fontSize(16).fontColor('#4F5E8C')
Text(this.detail).fontSize(14).fontColor('#68758A')
.width('100%').padding(12).backgroundColor('#E8ECF4').borderRadius(12)
}.width('100%').height('100%').padding({ left: 24, right: 24, top: 40 })
.backgroundColor('#F6F4EF')
}
}
居中函数没有改动文字内容,也没有设置字号和字重。正文缩进函数同样只提交段落格式,标题保持种子定义。两个函数前面的恢复步骤让各自得到独立结果,而不是叠加示例。如果希望观察标题居中与正文缩进同时存在,可以去掉第二次恢复,但那应作为一个明确的新用例,而不是悄悄改变当前按钮含义。
leadingMargin 接受尺寸或占位描述,本篇使用固定数值二十,对应页面逻辑尺寸中的缩进。它不是在正文前插入二十个空格。空格会改变内容、选择和导出结果,还受字体影响;段落格式则表达排版意图,文本本身保持不变。图示中只标注格式产生的起始偏移,不把它算进文档字符坐标。
按照当前 API 说明,段首是图片或 BuilderSpan 时,leadingMargin 有不同的适用边界,不能把文本段的结果直接推广到所有混排对象。本系列此时仍限定文本跨度,所以这组实验可保持明确。下一篇把内嵌对象放进独立实验控制器,正是为了不让它改变这里已经建立的正文假设。
查询当前段落按钮没有先恢复样例,读者可以自行输入换行、删除换行或调整文本后再次查询。它与两个格式按钮形成一读两写的关系:查询只报告现状,格式实验明确重置输入。操作语义分开以后,页面上的范围变化才不会被额外写入掩盖。
第四步为边界留下可复查的记录
先点击恢复后居中,观察标题整体居中,正文仍按原格式排列。再点击恢复后缩进,标题应回到初始位置,正文段出现缩进。第二次操作没有保留第一种格式,原因是入口明确恢复了样例。记录结果时要把这一前置动作写在同一条用例里,不能误判为段落样式相互覆盖。
接着在正文中间插入换行,点击查询当前段落。返回内容应反映新的文档结构。缩小编辑区宽度可以制造视觉折行,再查询段落作比较:改变宽度不会凭空插入换行字符。这个对照帮助定位“行数变多”究竟来自布局,还是来自内容编辑,不需要用渲染耗时或性能数字解释。
第三组用例删除标题与正文之间的换行。两部分文字仍可能因为宽度而占据多行,但它们的段落归属已经发生变化。此时不要继续用固定的首段与第二段按钮推断当前文档,因为按钮会先恢复样例。应该先使用只读查询记录当前结构,再用新的可控输入研究合并后的格式继承。
空段落也应单独观察。连续两个换行之间看起来没有正文,却仍然影响光标位置与后续输入。不能为了让查询结果“更整齐”过滤所有空文本,再把剩余范围当成原始文档坐标;那样会丢掉结构信息。文本导出可以保留换行,段落统计则应根据产品需要说明是否纳入空段落。

图 3:字符范围负责定位;同一定位用于字符样式时只改变文字,用于段落样式时改变命中段落的排版。
把范围扩展到标题与正文两边,可以继续检查跨段更新。此时应预期多个段落受到同一段落属性影响,而不是只有范围中间一块文字居中。边界范围最好分成恰好落在换行之前、包含换行、越过换行三组,并查询实际返回结构;不要只凭手指选框覆盖了几行判断命中了几段。
首行缩进、整体对齐和文本容器的内边距也需要分开看。内边距改变编辑区域与内容之间的布局空间,段落格式改变文档内部的排版;它们可以同时存在。若缩进看起来比预期大,应先确认是否把编辑区的十六点内边距也算进了截图测量,而不是立即改写文档范围。
判断对齐结果时还要固定阅读方向。示例使用当前中文便笺的起始对齐,没有把 Start 直接替换成一个永远代表屏幕左侧的业务常量。面向多语言文档时,对齐策略应结合语言方向单独验证;界面按钮可以表达起始、结束或居中,文档模型则保留对应的排版含义,避免在语言切换时留下硬编码位置。
标题与正文具有不同字号,因此基线和行高也可能不同。段落居中只解决水平方向的排列,不能据此推断两行之间的距离。需要统一行高时,应使用与目标版本匹配的文字样式能力,并另外检查过小行高是否压住字形;不要用增删空行来掩盖行高问题,否则纯文本内容与页面外观将被不必要地绑定。
最后一页记录的是格式边界
这一篇的开发记录可以归成三列:输入内容、格式命令、结构与视觉结果。输入中保留换行,命令中保留定位范围,结果中同时记录段落查询和页面排列。这样读者能够重新执行同一条路径,而不是依赖“看起来差不多”的截图。没有实际执行的设备结果不进入记录结论,保留可复现步骤即可。
段落格式不需要修改业务文本,所以它也不应改变便笺的语义内容。若将来导出纯文本,居中与缩进将失去视觉表达,但换行仍然有意义。产品如果要求导出后保留精确版式,就需要选择能描述段落样式的格式;不能把纯文本后缀改成另一种扩展名,便宣称保留了排版信息。
同样,段落的当前位置并不是长期稳定身份。用户在开头插入一段内容,后面的范围会整体变化;在中间删除换行,则会改变段落数量。需要批注、协作或历史关联时,应额外设计业务锚点,而不是把某次返回的范围永久保存。当前查询为当次格式操作服务,不替长期数据模型作决定。
接下来加入内嵌内容时,最容易发生的误解是“既然看起来在同一行,就一定能按普通文字读回”。第四篇会用 BuilderSpan 做一个尺寸受控的标签,把可视混排和可导出内容分开。便笺正文继续保留文本跨度契约,让新的展示能力能够被评估,而不破坏已经验证过的文本和段落操作。
官方资料
更多推荐

所有评论(0)