· engineering· 约 27 分钟精读

没有人签过的那一笔:端侧现场签名从触控采样到报告出口的坐标、字节与来源核查

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

TagWorks 与 SlingGuard 都提供手写签名采集。本文按源码逐环节核查一条笔画经过的四次改写:采样只留下坐标序列、捕获画布与写死的导出画板之间没有线性映射、两套画笔参数不一致、落图时纵横系数各 0.50 与 0.33;并普查签名位图的全部读取点——在一款产品里是报告上的一次绘制,在另一款里是设置页上的一行绿色文字,导出层引用为零。

没有人签过的那一笔:端侧现场签名从触控采样到报告出口的坐标、字节与来源核查

起重现场的台账交接时,最常听到的一句是”这单子上有签字”。两款工具都把签字实现为一块能划动的画布,划完按”保存”。本文不讨论这句话对不对,只把那一笔从落点到页面之间发生的事逐环节查清楚:它被记成什么,画进哪个框,存成什么字节,最后被谁读走。

结论先摆出来:这条链上有四次无人声明的改写——坐标换标尺、画笔换参数、位图换容器、纸面换纵横比;而”是否已签过名”这个状态,两款由同一类判据决定:字节存在与否,与有没有笔画无关。

同一批点值,被三个互不相同的画框接住按 SlingGuard 的常量绘制,1 点取 1 像素:采集 361 × 220,导出 350 × 180三个画框,同一个左上角这一段不进导出图180 x 60紫虚线 采集画布 361 × 220,基线在 y = 171,宽随机型变化青实线 导出画板 350 × 180;超出 180 的下拖笔迹整段丢失琥珀框 报告签名位 180 × 60,纵横系数各 0.50 与 0.33白点是三者共享的原点;两套代码都没做过坐标归一化落盘之前的五处口径差线形实时平头 3 pt,导出圆头 3.5 pt颜色实时跟随深浅模式,导出固定黑像素一处按屏幕倍率,一处按默认 1 倍容器外置 PNG 文件,或内嵌台账 JSON判据只看字节在不在,不看有没有笔画笔迹来源没有标记位:手写、一键套用、渲染兜底,共用同一个字段依据两款 SignatureCaptureView 与 CompanyProfile、InspectorSignatureManager 的实际实现

左右滑动查看完整图表

一、四个坐标框,一把写死的尺

采集界面在两款里都是独立文件:TagWorks 的 SignatureCaptureView.swift 共 184 行,SlingGuard 同名文件 187 行。两者都不是 PKCanvasView,而是 SwiftUI 的 Canvas 叠在圆角矩形上,加一条灰矩形当基线。

关键数字都写死在源码里:

坐标框TagWorksSlingGuard
捕获画布高度180 pt220 pt
捕获画布宽度由分组列表行宽决定,随机型变化同左
基线距画布底部36 pt(基线在 y 约 143)48 pt(基线在 y 约 171)
导出画板360 × 180 pt350 × 180 pt
纸面签名位180 × 60 pt不存在(导出层不画)

这里有两个结构性事实。第一,捕获画布的宽度是运行时布局值而非常量:iPhone 上约等于屏幕宽减去列表内边距,机型不同从 330 多点到 390 点都有,iPad 上可到 700 点以上;而导出画板的宽度是常量。两者之间没有任何一处代码做过线性映射或归一化,保存时只是把同一批点值原样重放进固定画布。

后果是可以直接算出来的。保存这一步的横纵映射是恒等的,两个画板的宽度则是两个写死的常量:

xsave=xcapture,Wboard∈{360, 350}x_{\mathrm{save}} = x_{\mathrm{capture}}, \qquad W_{\mathrm{board}} \in \{360,\ 350\}

捕获宽度小于导出画板宽度时,笔迹整体偏左,右侧留出一条空白;大于时——iPad 与横屏必然如此——超出的那段点值落在图像之外被裁掉。纵向同理:SlingGuard 捕获高 220 pt、导出高 180 pt,基线落在 y 约 171,其下只剩 8 pt 可保存空间,越过 y = 180 的下拖笔画与收尾的圈都被切断。TagWorks 两个高度同为 180 pt,纵向恰好同标度,问题只在横向。

TagWorks 的回显还多一道标度差:已存签名以 scaledToFit().padding(20) 显示,180 pt 高的位图被塞进 140 pt 的可用高度,等比缩到约 78%;而新笔画按 1:1 记录。两者不在同一标度上——屏幕上像”描着旧签名补一笔”,保存时旧图不参与任何合成。

二、采样实现留下的只有坐标

笔画的数据结构在两款里不同:TagWorks 用 currentLines: [[CGPoint]] 加一条正在画的 currentLine: [CGPoint];SlingGuard 用 [SignatureLine],每线带一个 UUID 标识。差别只在渲染循环的写法;两边最终都只是一串二维坐标。

采集口径TagWorksSlingGuard
手势起点阈值minimumDistance: 0minimumDistance: 0.1
取点方式只取 value.location同左
合并触摸点无无
压力与倾角无无
每点时间戳无无
相邻点去重无无
落盘的笔画结构无(只留位图)无(只留位图)

两处值得多说一句。其一是 minimumDistance:TagWorks 取 0,手指一落点就产生第一个坐标;SlingGuard 取 0.1,位移不足 0.1 pt 的轻点不开线。其二是采样点只来自 value.location,没有用 coalescedTouches 取回系统合并掉的高频点。快速划动时的点密度因此只跟手势识别器的更新频率有关——一条横扫可能只有几十个采样点,Canvas 侧与保存侧都只做逐点 addLine、没有贝塞尔平滑,画出来是折线而不是曲线。界面上”用手指或 Apple Pencil 签名”的提示写的是笔,实现里没有压感与粗细变化,Pencil 与手指得到同样的几何结果。

更关键的是信息在保存那一刻被丢弃:两款落盘的都是位图,点序列只活在 @State 里,退出界面即消失,没有一条记录保存点数、起止坐标或笔与笔之间的间隔。而书写节奏、抬笔位置、速度变化这些动力学特征恰是纸面签名比对最常用的判据;丢掉之后,剩下的只是一张形状。

三、实时预览与落盘图,用的是两支不同的笔

绘制参数屏幕实时落盘
绘制器SwiftUI CanvasTagWorks:UIGraphicsImageRenderer;SlingGuard:ImageRenderer
线宽3 pt3 pt / 3.5 pt
线端与拐角默认平头TagWorks 显式圆头圆角;SlingGuard 默认
笔色UIColor.label / .primary固定 UIColor.black
位图倍率屏幕倍率TagWorks 取主屏倍率;SlingGuard 未设置,按 1.0
背景深色模式下是深灰卡片透明

三处差异都有可观察的后果。线宽与线端让同一笔在纸上略粗、拐角变圆;单点笔画(落笔即抬笔)在默认平头下画不出任何东西,在设置了圆头的路径下却落成一个实心圆点——屏幕上看不见,纸上有。位图倍率差得更实际:TagWorks 的 360 × 180 pt 在 3 倍屏上是 1080 × 540 像素,SlingGuard 的 350 × 180 pt 因为从未设置 scale(框架默认值即 1.0)就只是 350 × 180 像素,两者像素数差约 9.3 倍。同一支手指,两款留下的空间分辨率差了一个量级。

笔色的差异是单向的:屏幕上跟深浅色模式走,落盘钉死为黑。这对纸面是正确取舍,但带来一个回显副作用——已存位图只有黑色像素,TagWorks 又把它直接叠在分组列表的次级背景(深色模式下接近 #1C1C1E)上,没做 template 渲染也不反色。于是深色模式里用户已保存的那张签名几乎看不见:能确认它存在,确认不了它长什么样。

四、落图:从 2:1 到 3:1 的那一次压扁

TagWorks 是两款里唯一把签名画上纸面的。代码在报告封面的操作者记录框里:签名位是一个宽 180、高 60 点的矩形,左上角落在页面坐标 (340, 550),绘制入口是 sig.draw(in:)。源图纵横比 360 : 180 = 2 : 1,目标框 180 : 60 = 3 : 1,而 draw(in:) 是填满式绘制、不做等比适配,两个方向的系数各是:

kx=180360=0.50,ky=60180=0.33‾,kxky=1.50k_x = \frac{180}{360} = 0.50, \qquad k_y = \frac{60}{180} = 0.3\overline{3}, \qquad \frac{k_x}{k_y} = 1.50

也就是说纸上的签名相对手写时横向被拉宽 50%:一条 3.5 pt 宽的竖笔落纸横向只剩 1.75 pt,横笔纵向只剩约 1.17 pt,笔画粗细随之各向异性。

另两处细节:界面上那条引导基线只是视图层矩形,从未参与栅格化;PDF 上的下划线是绘制时另画的(x 从 330 到 525,y = 614),两者没有几何关系,纸面因此呈现”笔迹浮在横线上方”的观感。图下方 8.5 pt 小字固定印着 Operator Signature (recorded)。

SlingGuard 一侧没有这一段:ReportExporter.makeReport 从 CompanyProfile 里只读四个字符串,印成封面四行键值:

报告封面字段取值来源类型
Report preparerprofile.companyName文本
Site / locationprofile.siteLocation文本
Profile inspectorprofile.inspectorName文本
Profile referenceprofile.certificationNumber文本

五、“已签名”的判据是字节,不是笔画

两款都把签名状态做成一个绿色小词:TagWorks 在文件存在时印 Captured,SlingGuard 在字段非空时印 Configured,判据分别是 FileManager.fileExists 与 signatureImageData != nil。存在性判据不知道位图里有没有笔画。

SlingGuard 里至少有四条路径能给出一个”看起来已经签过”的状态:

分支触发条件文件里的内容界面显示
手写画布上有笔画并保存用户点序列栅格化出的位图Configured
出厂默认档案结构体的默认初始化两条贝塞尔曲线,300 × 80 点Configured
一键套用按下 Use Default Signature11 个硬编码坐标点连成的折线Configured
渲染兜底ImageRenderer 未产出图像与出厂默认同源的曲线Configured

这一节先记一笔容器之差,它决定了两款完全不同的失败方式。SlingGuard 的签名不是文件,而是档案结构体里的一个 Data 字段:CompanyProfile 是 Codable,JSON 编码器对二进制数据的默认写法是 base64,字节量按 4 : 3 膨胀;而档案一旦改动就触发防抖保存,写下去的是整本快照——改一个公司名的一个字母,这张笔迹图也被原样重写一遍。TagWorks 走另一条:签名是文档目录里一张独立的 PNG,不进台账文件,因此要靠备份档案单独携带;它的还原路径反而比写入路径严谨,会校验拿到的字节能否解码成图像,解不出就抛错而不是静默覆盖。一处是快照里的一团字节,一处是台账之外没人回收的文件——两种”存下来”并不等价。

第二条需要展开。签名以 signatureImageData: Data? 内嵌在登记册快照里,而它的默认初始化把这张图直接赋成 defaultSampleSignatureData()——一张由两条 addCurve 画出的合成笔迹;RiggingStore.profile 的初值就是 CompanyProfile()。这意味着:一台新装、从未打开过签名界面的设备,设置页那一行已经是绿色的 Configured。谁把它读成”检验员已录入签名”,谁读到的就是两段贝塞尔曲线。

与签名相关的只有 signatureImageData 与 companySealEnabled 两个字段,既没有来源枚举也没有 isSample 标记,第三、第四条路径同样不落来源。

companySealEnabled 是同一形状的另一种存在:设置页一枚开关,脚注写明该偏好随本地档案保留但不作为报告里的法律认证;逐字查下来它在全工程只出现四处——声明、默认初始化、赋值、开关绑定——导出层一次都没读过。一个存了但没人读的布尔量,界面上却做成了可交互控件。

TagWorks 的按钮语义有另一处不对称。画布上方的 Clear 只清空内存里的两组点数组,不删除已落盘的位图;真正把签名从设备上抹掉的是下方一个独立的红色按钮 Remove Saved Signature,而这个区块只在文件存在时才渲染。于是出现这样一条操作序列:画几笔 → 按 Clear(屏幕空了)→ 按 Save → save() 因为 currentLines 为空直接跳过写入 → 界面上那行 Active signature stored 依旧在,旧的签名位图依旧进报告。擦干净了、也保存了,但作废没有发生。

同一屏上还有第三种提交语义:Inspector Name 与执照号两个输入框绑的是 @AppStorage,每次击键直接写进用户默认值,而 Cancel 只调用 dismiss()、不回滚文本——取消签名页时笔画没了,刚改过的姓名与执照号已经生效。位图(有笔画才覆盖)、签名文件(点 Save 才写)、文本(击键即写)各有各的提交时机,界面却用同一对”Cancel / Save”按钮把它们包装成一次原子决定。

六、出口普查:六个读取点,两个真读

把签名相关字段的所有引用点摊开,是这样一张账:

引用点TagWorksSlingGuard
声明与默认值单例管理器属性结构体字段 + 默认初始化
写入save() 一处(有笔画才写)saveSignature() 两处(正常与兜底)
移除Remove Saved Signature 一处无独立入口(清空后保存即覆盖)
读取(显示)签名页回显设置页状态词
读取(报告)封面签名位 1 次0 次
读取(标签 / 物理标识)0 次0 次
读取(CSV)0 次0 次
读取(备份)打包与还原各 1 次随快照整体进档

TagWorks 的签名确实进了报告:makeReport 的 signatureImage 形参默认空,唯一生产调用点不传,于是走 loadSignature() 兜底——读设备当前那张位图。物理标签上只有文本 TESTED BY: 姓名 · 执照号,没有图像。旧版归档缺档案段时保留当前档案不覆盖。

SlingGuard 走的是另一条路:写进去、随快照进备份、还原时回来,但在报告与标签两处出口一次都没被读。那块能划动的画布,唯一读者是设置页上那行绿字;而签名页承诺的是”用于本地报告呈现”——这句承诺由存储兑现,不由呈现兑现。同一条链上的自动化走查在注释里写着”导出法定 A4 报告(内嵌签名与印章)“,脚本断言查的却是免费档水印是否存在;而它在签名那一步按下的,正是 Use Default Signature。TagWorks 的走查也只是在画布上水平拖了一下,画出一条直线当作签名。注释描述意图,断言覆盖别的字段,这类缝隙在离网设备上不会被任何日志暴露。

七、法定坐标:签名要证明的东西,这张图证明不了

行业里”签名”这个词最近仍被当作基础设施在采购:公开采购渠道上,11 月 10 日有一则医院”医、技、护、患电子签名认证升级”项目的中标公告,11 月 13 日又有一则”印章管理平台开发”项目的询比公告,标的都落在证书、密钥与用印管控这一层,与一块能划动的画布不是同一件事。把三方口径并列,能看出这张位图处在哪一层。

《电子签名法》第十三条规定,电子签名同时符合下列条件的,视为可靠的电子签名:签名制作数据用于电子签名时属于签名人专有;签署时签名制作数据仅由签名人控制;签署后对签名的任何改动能够被发现;签署后对数据电文内容和形式的任何改动能够被发现。逐条对照:

法定条件端侧位图的实现事实判定
制作数据专有一张 PNG 或一段 Data,可复制、可随备份迁移不满足
签署时仅由本人控制同一台设备上的任何持机者都能触发保存不满足
签名改动可发现全工程未引用 CryptoKit / SecKey,无摘要、无 MAC不满足
内容改动可发现位图不覆盖任何报告字节,与记录无绑定不满足

欧盟 eIDAS 第二十六条对”高级电子签名”的四条要求(唯一关联签署人、可识别签署人、签名制作数据在高置信下由本人独占控制、与所签数据关联到改动能被发现)落在同一处断点:第三、第四项需要密钥与摘要绑定,一张位图给不出。

起重这一侧的法定要素清单里也没有”笔迹图样”这一项。LOLER 1998 第一附表列出检验报告必须载明的内容(HSE 指引概括为 11 条),与”人”有关的是第九、十两项:出具报告人的姓名、地址与资历,以及代表作者签署或认证该报告者的姓名与地址。它要的是可核查的身份文字要素,不是一张图像;AS/NZS 3760 对记录的要求也是同一方向。签名图像在法定要素表里没有格子,把它印上报告是行业习惯,不是法条要求。

两款产品的界面文案其实已经把边界写清楚了。TagWorks 签名页脚写着:应用记录所提交的信息,不核实资质、不认证合规;报告限制段落写着:不测试设备、不验证测试仪、不判定法律合规、不替代 competent person 的判断。SlingGuard 签名页顶部写得更直白:这张签名图本身不认证报告、不验证能力、不签发证书。三份文案没有一处把这张位图称作可靠电子签名——实现与文案在这里是一致的,不一致的是”用于报告呈现”那句与导出层零引用之间的距离。

八、工程收敛的四条线

要把这一环做实,需要动的不是加功能,而是让已经存在的字段承担它们已经被声称的语义。

第一条,画布尺寸入数据。捕获那一刻把画布实际宽高随位图存成元数据(画板宽、画板高、点序列包围盒),保存时做一次线性映射,而不是靠”运行时宽度刚好接近常量”这一巧合;scale 也要显式指定成同一个值,因为两个渲染器的默认倍率一处 3 倍一处 1 倍。SlingGuard 那条 220 pt 采集对 180 pt 导出的纵向差、基线以下 8 pt 的可保存空间,都该由常量断言钉住:捕获高度、导出高度、基线偏移三个数一旦改动,测试就该红。

第二条,存在性判据升级为内容判据。!= nil 与 fileExists 之后应当再算一步:非透明像素数、笔画点数下限、包围盒面积。更重要的是把来源写进字段——sample / handwritten / fallback 三态之一,并在设置页与报告上如实显示”出厂示例签名”。现在的状态是:三条无笔画路径与真笔迹共用一个绿色标签,事后无法区分。

第三条,按钮语义补齐。Clear 之后的 Save 应当是个显式决定:要么把”作废签名”写下去并留痕,要么在点阵为空且设备已有签名时弹一次确认,而不是静默跳过写入;文本字段的提交时机也该对齐——击键即写加上 Cancel 不回滚,等于让”取消”只覆盖了一半数据。

第四条,让采集面与呈现面对账。SlingGuard 要么在报告里真画出这张图(并处理非等比与基线对齐),要么把那句说明改成与实现相符的话;走查注释里”内嵌签名”四个字应当换成一条真断言。这类”承诺在文案里、实现停在存储里”的缝,离网设备上没有任何服务端日志能暴露,只能靠静态普查:凡采集面写入的字段,都要能在导出调用链里被数出读取点,数不出来就是没落地。

九、能力边界

  • 能证明的:某个署名的样貌在某个时刻被写进了这台设备的某个文件。仅此而已。
  • 不能证明的:写字的人是谁、写的时候报告内容是什么、写完之后台账有没有被改过。
  • 不覆盖任何字节:无摘要、无时间戳、无 nonce,位图与报告内容之间没有密码学绑定,把正文改掉这张图照样成立。
  • 点序列不落盘,笔迹的动力学特征在保存时永久丢失。
  • 换一张 PNG 不需要任何凭证:位图的可替换性不是缺陷清单上的一条,而是它的物理属性。

把这一节和第七节放在一起,可以得到一个不漂亮但能对外说的定位:端侧工具里的”签名”是一块录入控件加一张图像证据,它抵的是”在纸质表格上签个名”的记录成本,不抵身份认证与内容完整性保护。前者在离线工况里的价值确实存在;后者不会由一段贝塞尔曲线或一层 PNG 交付。这条线画清楚,比界面上多一个绿色单词有用。

参考资料

  • [1] 全国人民代表大会常务委员会. 中华人民共和国电子签名法(2019 年修正)第十三、十四条. 电子签名法
  • [2] European Union. Regulation (EU) No 910/2014 (eIDAS) Article 26, Requirements for advanced electronic signatures. eidas-art-26
  • [3] Health and Safety Executive. Thorough examination of lifting equipment: Lifting operations and lifting equipment regulations 1998, Schedule 1 report particulars. loler-thorough-examinations
  • [4] Health and Safety Executive. INDG422: Thorough examination of lifting equipment. indg422
  • [5] Apple. ImageRenderer.scale(默认值与纵横比换算). imagerenderer-scale
  • [6] Apple. UIGraphicsImageRendererFormat.scale(默认取主屏幕倍率). rendererformat-scale
  • [7] Apple. DragGesture 与 UIGestureRecognizer 的触摸合并与预测. coalesced-touches
  • [8] Standards Australia. AS/NZS 3760:2022 In-service inspection and testing of electrical equipment. as-nzs-3760
  • [9] 中国政府采购网. 银川市妇幼保健院医、技、护、患电子签名认证升级项目中标公告(2026-11-10). signature-cert-upgrade
  • [10] 中国招标投标信息网. 某量子信息科技集团 2026 年印章管理平台开发项目询比公告(2026-11-13). seal-platform
Share:
Back to Blog