· engineering· 约 20 分钟精读

Offline Report Layout Budget A4 Pagination — Technical Analysis | KeyRotate Technology

核心要点速览 · TL;DR 概要

报告一旦离开手机就只剩纸面。本文以 TagWorks 与 SlingGuard 两套 PDF 导出代码为样本,逐行核对 595×842 点阵下的列宽合计、三处互不相同的页底余量、只看行首的分页判断,以及两套报告都没有页码这一事实。

Offline Report Layout Budget A4 Pagination — Technical Analysis | KeyRotate Technology

一、报告离开设备之后,只剩纸面

现场检验的最后一件事,是把结果交出去。检验员在手机上按下导出,设备把本地登记册合成为一份 PDF,通过系统分享面板发给甲方、安全经理或者归档邮箱。从这一刻起,这份文件的阅读环境不再受控:它可能被打印成 A4 纸装进文件夹,可能被扫描仪重新拍成图片,也可能在半年后由一位没有用过这款应用的人翻看。

界面上的排版问题可以在滑动中掩盖,纸面上的排版问题不会。一页内容如果越出纸边,打印机裁掉的不是像素,而是记录;一行证据如果被文本框截断,读者看到的仍是一行整齐的灰字,没有任何提示告诉他少了一半。端侧离线工具没有排版服务,也没有渲染农场,只有一台手机和一组写死在代码里的坐标。

本文讨论的就是这组坐标。样本是两套真实的导出实现:TagWorks 的电器检测报告与 SlingGuard 的索具检验报告。两者都用系统提供的 PDF 渲染器在本地生成 A4 页面,尺寸常量、列宽表、分页判断分别写在各自的 ReportExporter 中。以下所有数字都可以在源码里逐行对上。

二、A4 在代码里是一组点阵常量

两套实现的页面尺寸写法一致:

let pageSize = CGSize(width: 595, height: 842) // A4 portrait, points

595×842 是 A4 在 72 dpi 下的点阵值,对应 210×297 毫米。这个单位不是像素:渲染器按点写入,打印驱动再按设备分辨率展开,所以同一份 PDF 在 300 dpi 打印机上仍然是同样的物理尺寸。代码把纸的物理属性固化成了一个二元组,之后所有的版心、列宽、行高都以这个二元组为基准。

版心从页边距推出。TagWorks 的正文统一从 x=40 起排,右侧在同一条 40 点边距上收口,于是版心宽度是 595−80=515 点。封面上的资质声明框、签名框、封面字段行都用这个 515。问题出现在表格:表格的列宽并没有从 515 反推,而是另写了一张数组。

三、横向预算:40 加 590 等于 630

TagWorks 的结果表有六列,列宽数组是 170、90、70、110、70、80,合计 590 点。表格起点是 x=40,所以最后一列的右缘落在 40+590=630 点,比 595 点的纸宽多出 35 点。

这 35 点不是留白差异,是实打实的裁切。表格最右一列是到期日期,内容形如 12 Mar 2027,以 9 点字体排版约需 55 到 60 点宽度;这一列的可用宽度是 80 点,减掉被裁掉的 35 点后只剩 45 点,末尾几位字符会落在纸外。渲染器不会报错,导出也不会失败,文件打开后看起来一切正常,只是那一列在纸上少了一截。

SlingGuard 的明细表是另一种结果。七列宽度为 62、138、88、48、76、75、72,合计 559 点,起点是 x=34,右缘落在 593 点,距离 595 点的纸边只剩 2 点。它没有越界,但也没有余量:任何一次列宽微调,或者任何一次把字号从 8.2 点调大,都会立刻把最后一列推出纸外。

对照项TagWorks 结果表SlingGuard 明细表
列数67
列宽数组170 / 90 / 70 / 110 / 70 / 8062 / 138 / 88 / 48 / 76 / 75 / 72
列宽合计590 pt559 pt
起点 x40 pt34 pt
右缘位置630 pt593 pt
与 595 pt 纸宽的差越界 35 pt余量 2 pt

两套代码都在注释里写明了 A4 与点阵,也都在同一行里写了列宽数组,但没有任何一处把”列宽合计”与”纸宽”放在一起比较。列宽是排版意图,纸宽是物理约束,两者之间缺一个断言。下图把这两笔账叠在同一张纸的坐标上。

A4 纸面上的版面预算与两处越界封面页站点页证据行页脚越界封面:字段、声明与签名框站点页:表头与数据行证据行:起点 58,宽 497 pt分页闸门:792 pt 起判断页底:842 pt页脚文字固定在 720 与 806 pt,后者落在闸门线之下 14 pt表行横轴:起点 40 pt,六列合计 590 pt40595630末列 35 pt 落在纸外页宽 595 pt 是纸张常量,列宽 590 pt 是代码常量,两者之间没有断言

四、纵向预算:三处页底余量互不相同

纵向的判断更分散。TagWorks 的站点分页判断写在表格循环里:

if ry >= pageSize.height - 50 {

842 减 50 等于 792,行首一旦越过 792 点就换页。SlingGuard 的登记册表格用的是另一个常数:

if rowY > pageSize.height - 48 {

842 减 48 等于 794,比较符也从大于等于变成了大于。同一份工程里,段落排版又用了第三个值:

if currentY + height + 28 > pageSize.height - 28 {

842 减 28 等于 814,而且判断的对象不是行首,是行首加上实测文本高度再加 28 点。

三份文档的页底余量各自写死页底 842 pt842 − 50792 pt报告表格行首842 − 48794 pt索具表格行首842 − 28814 pt索具段落实测前两处只在行首比较一次,行高与续行不在预算内第三处先量出段落实际高度,再决定是否翻页三个余量分散在两个工程的两个文件里,没有共享常量

三个余量分别对应三种排版对象:6 列结果表的行、7 列明细表的行、可以换行的长段落。它们各自成立,合起来却说明一件事:页底余量不是一个共享常量,而是每个排版函数自己决定的一个减法。50、48、28 这三个数字没有任何注释解释来源,也没有任何测试覆盖它们的边界。

五、行组预算:闸门只看行首

TagWorks 的结果表每台设备占两行:一行是六列数据,行高 16 点;紧接着一行是证据摘要,起点缩进到 x=58,宽度 497 点,行高 14 点。也就是说,一个设备在纸面上占 30 点。

分页判断发生在数据行之前,比较的是当前行首 ry。假设 ry 停在 791 点,判断不成立,数据行画出,ry 变成 807;证据行跟进,落墨区间延伸到 823 点;下一次循环时 ry 已经越过 792,才触发换页。于是这一组行的实际落墨比闸门低了 31 点。50 点的余量刚好够容纳一组,但不够容纳两组,而代码只检查了组的第一行。

这类问题在排版里叫孤行控制。成熟的排版引擎会把”标题加首行""表格行加注释行”当成不可拆分的整体来判断,而不是逐行判断。这里的实现没有行组概念,只有行首坐标。

证据行本身还有第二层风险。它是把最多十二类字段用分隔符拼成一条字符串,再画进一个宽度 497、高度 14 的矩形。文本绘制方法在矩形内换行并按矩形高度裁剪,超出部分不会出现省略号,也不会留下任何标记。字段少时这条线读起来是一句摘要;字段多时它读到一半就停住,而读者无法分辨这是记录的终点,还是被裁掉的中点。

六、测量式排版与固定坐标并存在同一份工程

SlingGuard 的段落函数提供了另一种做法。它先用文本测量求出实际高度:

let height = max(18, ceil((text as NSString).boundingRect(
    with: CGSize(width: 500, height: 500),
    options: [.usesLineFragmentOrigin, .usesFontLeading],
    attributes: bodyAttributes, context: nil).height))

拿到高度之后,再判断 currentY 加上高度加 28 是否越过 814 点,越过才换页,并在续页顶部重画标题。这是标准的”先测量、后分页”路径,长段落不会压到页底,也不会被裁掉半行。

但同一个文件里的键值行没有采用这套做法。键值对由一个固定步进的函数绘制:值画在 x=190、宽度 360、高度 30 的框里,函数返回 y+17。高度 30 的框配 17 点的步进,意味着值一旦折成两行,第二行就会和下一行的键撞在一起。函数内部没有任何测量,也没有任何边界判断。

于是同一份报告里出现了两种排版范式:段落是测量式的,键值与表格是坐标式的。前者知道自己占多高,后者只知道下一个起点在哪。这不是对错问题,而是两套写法在同一张纸上拼接时必须付出的对齐成本。

七、字号的绝对下限

纸面没有动态字体。系统设置里的字号缩放作用于界面,不作用于已经写进 PDF 的字形。两套报告的字号因此是绝对数值:

用途TagWorksSlingGuard
封面主标题20 pt19 pt
分节标题16 pt16 pt
键值行11 pt10 pt
表格正文9 pt8.2 pt
段落正文9 pt9.5 pt
签名说明8.5 pt10 pt
水印120 pt20 pt

8.2 点在 A4 上是 2.9 毫米的字高,9 点是 3.2 毫米。这个量级接近银行对账单的脚注,在室内阅读没有问题,在工地现场、复印件、传真件或者老花镜下的阅读条件里就开始吃力。A4 纸的物理尺寸固定,意味着缩小字号是唯一能在同一页塞进更多行的办法,而这个决定一旦做出,就没有回头路:报告已经发出去了。

签名说明那一行更典型。TagWorks 用 8.5 点写”Operator Signature (recorded)“,位置在签名横线下方 4 点处。这个字号在屏幕上几乎看不清,在纸上也只是勉强可读,但它承载的是”这一栏是操作员签名”这一层语义。字号在这里不是审美选择,而是责任归属的可见性。

八、页的身份:两套报告都没有页码

两份实现都会生成多页文档。TagWorks 的页数是 1 加站点数,每个站点一页;SlingGuard 的页数是 1 加地点数加检验记录数,每一条检验记录单独起一页。登记册越大,页数越多,但两份代码里都没有页码,也没有总页数。

我在两套 ReportExporter 中检索过 Page、pageNumber、currentPage 三个词,结果为空。页眉只写标题,页脚只写一行来源说明,中间没有任何”第 3 页 / 共 12 页”的标记。这在屏幕上无关紧要,因为 PDF 阅读器会显示页码;一旦打印、复印、扫描或者拆页归档,页序就失去了自证能力。

续页标记也不完整。TagWorks 在表格换页后重画表头,但不重画站点名,所以一个站点的第二页上没有站名,只有一张没有归属的表。SlingGuard 做得细一些,表格续页会写 Location 加 continued,段落续页会写 Inspection Record Evidence 加 continued,但键值行没有续页概念,因为键值行根本不判断边界。

页的身份还包括页脚的位置。TagWorks 的免责说明画在 y=720,免费版水印页脚画在 y=806。842 减 806 等于 36,这行 9 点的红字距离纸边 36 点,落在打印机的不可打印区域之外,但落在大多数文件夹的打孔区之内。装在左侧两孔夹里的报告,这一行会被孔打掉一部分。

九、水印的几何同样需要预算

免费版报告带水印,这是产品分层的实现方式。TagWorks 的水印是 120 点粗体,透明度 0.16,绕页面中心 (297, 421) 旋转负 30 度;SlingGuard 的水印是 20 点粗体,透明度 0.18,旋转负 45 度。

两者的几何意图不同。TagWorks 的水印很大,覆盖封面中部的空白区,单字高度接近页宽的六分之一,任何裁切都去不掉;SlingGuard 的水印很小,只占页面中心一小块,用截图工具裁掉边缘就能绕开。水印的强度不取决于透明度,取决于它覆盖的版面面积与文本的交叠程度。

水印与正文的关系也需要预算。TagWorks 的水印画在 (297, 421),签名框在 (340, 550) 到 (520, 610) 之间,两者不重叠;但水印的旋转范围会扫过 y=421 上下约 100 点,仍然落在声明框上方的空白里,不会压住声明文字。这是一次恰好成立的布局,不是一次被验证过的布局。

十、四条可以落地的收敛线

第一条是把版面常量集中。页宽、版心、列宽数组、行高、页底余量应该是同一组具名常量,并且在同一处做一次断言:列宽合计加上左右边距不超过页宽,行组高度不超过页底余量。断言失败应该在开发期崩溃,而不是在客户的打印机上静默裁切。

第二条是引入行组概念。分页判断的对象不该是单行行首,而应该是这一组内容的整体高度。数据行加证据行是 30 点,标题加首行是一个整体,段落加签名块是一个整体。判断函数接收组高,返回是否需要换页,这样 50 点余量才真正够用。

第三条是把测量式排版扩展到所有可变高内容。证据摘要、备注、缺陷描述、处置措施都是用户输入的文本,长度不可预知,都应该先测量再决定换行、截断还是续页。截断必须留下可见标记,因为不可见的截断会让读者把残缺当成完整。

第四条是给每一页一个身份。页码、总页数、站点名或地点名、生成时间,这四项应该出现在每一页的页脚,而不是只出现在封面。有了这四项,散页可以重新排序,复印件可以追溯来源,扫描件可以被人工核对。

十一、能力边界

这四条收敛线能解决版面问题,但解决不了记录本身的问题。PDF 是一张画好的纸,不是结构化数据:字段被画成字形之后就不可检索、不可校验、不可重新计算。报告里的一行”证据:通过”是绘制结果,不是判定过程,审计方无法从 PDF 反推出当时的阈值、量程和原始读数。

版面正确也不等于记录完整。一张没有被裁切的表格,只能证明这一行被画出来了,不能证明这一行背后的测量有效。反过来,一张被裁切的表格也不等于记录缺失,原始数据仍然在设备本地。纸面与台账是两份不同的东西,前者用于交付,后者用于追溯,两者不能互相替代。

还有一层边界是设备侧的。报告由手机生成,字体来自系统,渲染器来自系统,打印驱动来自系统。任何一次系统升级都可能改变字体的度量,从而改变换行位置。对于固定坐标的版面,这种变化是静默的;对于测量式排版,这种变化至少会被测量函数感知到。这也是为什么第三条收敛线值得优先做。

最后是法律边界。两套报告都在封面写了免责声明,明确说明应用不执行检测、不验证仪器、不判定合规。这段文字的位置在 y=494 到 y=544 之间,宽度 483 点,9 点字,五行左右。它是整份文件里最重要的一段话,也是字号最小的一段话之一。版面预算应该为它留出足够的位置和足够的字号,而不是把它塞进签名框上方的空隙里。

参考文献

[1] Apple Developer Documentation. UIGraphicsPDFRenderer. https://developer.apple.com/documentation/uikit/uigraphicspdfrenderer [2] Apple Developer Documentation. NSString.boundingRect(with:options:attributes:context:). https://developer.apple.com/documentation/foundation/nsstring/1524729-boundingrect [3] ISO 216:2007. Writing paper and certain classes of printed matter — Trimmed sizes — A and B series. https://www.iso.org/standard/36631.html [4] ISO 32000-1:2008. Document management — Portable document format — Part 1: PDF 1.7. https://www.iso.org/standard/51502.html [5] Standards Australia. AS/NZS 3760:2022. In-service safety inspection and testing of electrical equipment. https://www.standards.org.au/ [6] UK Health and Safety Executive. Lifting Operations and Lifting Equipment Regulations 1998 (LOLER), Regulation 9. https://www.hse.gov.uk/work-equipment-machinery/loler.htm [7] U.S. Department of Labor, OSHA. 29 CFR 1910.184 — Slings.

Share:
Back to Blog