· engineering· 约 20 分钟精读
端侧离线合规报告的版面预算工程:A4 点阵几何、分页闸门与跨页证据的可见性
报告一旦离开手机就只剩纸面。本文以 TagWorks 与 SlingGuard 两套 PDF 导出代码为样本,逐行核对 595×842 点阵下的列宽合计、三处互不相同的页底余量、只看行首的分页判断,以及两套报告都没有页码这一事实。
一、报告离开设备之后,只剩纸面
现场检验的最后一件事,是把结果交出去。检验员在手机上按下导出,设备把本地登记册合成为一份 PDF,通过系统分享面板发给甲方、安全经理或者归档邮箱。从这一刻起,这份文件的阅读环境不再受控:它可能被打印成 A4 纸装进文件夹,可能被扫描仪重新拍成图片,也可能在半年后由一位没有用过这款应用的人翻看。
界面上的排版问题可以在滑动中掩盖,纸面上的排版问题不会。一页内容如果越出纸边,打印机裁掉的不是像素,而是记录;一行证据如果被文本框截断,读者看到的仍是一行整齐的灰字,没有任何提示告诉他少了一半。端侧离线工具没有排版服务,也没有渲染农场,只有一台手机和一组写死在代码里的坐标。
本文讨论的就是这组坐标。样本是两套真实的导出实现:TagWorks 的电器检测报告与 SlingGuard 的索具检验报告。两者都用系统提供的 PDF 渲染器在本地生成 A4 页面,尺寸常量、列宽表、分页判断分别写在各自的 ReportExporter 中。以下所有数字都可以在源码里逐行对上。
二、A4 在代码里是一组点阵常量
两套实现的页面尺寸写法一致:
let pageSize = CGSize(width: 595, height: 842) // A4 portrait, points595×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 明细表 |
|---|---|---|
| 列数 | 6 | 7 |
| 列宽数组 | 170 / 90 / 70 / 110 / 70 / 80 | 62 / 138 / 88 / 48 / 76 / 75 / 72 |
| 列宽合计 | 590 pt | 559 pt |
| 起点 x | 40 pt | 34 pt |
| 右缘位置 | 630 pt | 593 pt |
| 与 595 pt 纸宽的差 | 越界 35 pt | 余量 2 pt |
两套代码都在注释里写明了 A4 与点阵,也都在同一行里写了列宽数组,但没有任何一处把”列宽合计”与”纸宽”放在一起比较。列宽是排版意图,纸宽是物理约束,两者之间缺一个断言。下图把这两笔账叠在同一张纸的坐标上。
四、纵向预算:三处页底余量互不相同
纵向的判断更分散。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 点。
三个余量分别对应三种排版对象: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 的字形。两套报告的字号因此是绝对数值:
| 用途 | TagWorks | SlingGuard |
|---|---|---|
| 封面主标题 | 20 pt | 19 pt |
| 分节标题 | 16 pt | 16 pt |
| 键值行 | 11 pt | 10 pt |
| 表格正文 | 9 pt | 8.2 pt |
| 段落正文 | 9 pt | 9.5 pt |
| 签名说明 | 8.5 pt | 10 pt |
| 水印 | 120 pt | 20 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.