12. 证件正反面合成A4:composeIdToA4 排版与打印精度控制
身份证正反面分别扫描后,用户最常见的需求是"合成到一张A4纸上打印"。看似简单,实则暗藏诸多工程细节:打印精度控制、真实物理尺寸1:1还原、横竖布局自动居中、保持宽高比缩放、以及防滥用水印集成。本文完整拆解 composeIdToA4 函数的实现,以及 IdCopyService 如何将扫描管线与A4合成、水印集成串联起来。
一、业务场景与核心需求
1.1 典型使用流程
用户拍摄身份证正面
→ 自动检测边缘 + 透视校正
→ 增强滤镜(白底加深、文字锐化)
→ 用户拍摄身份证反面
→ 同样校正+滤镜
→ 选择布局(上下竖排 / 左右横排)
→ 可选:添加防滥用水印
→ 生成A4尺寸JPEG
→ 可直接打印(1:1物理尺寸)
1.2 核心约束
| 打印尺寸 | 1:1物理比例 | 打印出来的证件大小与真实身份证一致 |
| A4尺寸 | 210×297mm | 国际标准纸张 |
| 预览精度 | ~130dpi | 流畅预览 |
| 导出精度 | 300dpi | 打印级精度,2480×3508px |
| 证件物理尺寸 | 85.6×54mm | 第二代身份证标准(CR80) |
| 证件间距 | 8mm | 正反面之间的空隙 |
| 排版方式 | 整组居中 | 横竖布局均居中放置,不靠固定边距 |
| 缩放方式 | 保持宽高比 | 不拉伸、不裁剪,等比缩放到证件框内 |
| 水印集成 | 可选叠加 | WatermarkService 防滥用保护 |
二、DPI与物理尺寸的数学关系
2.1 核心公式
打印世界的黄金公式:
像素数(px) = 物理尺寸(mm) / 25.4 × DPI
其中 25.4 是英寸到毫米的换算因子。
2.2 关键尺寸计算
在 composeIdToA4 中,所有像素尺寸都从物理尺寸 + DPI 动态计算:
final W = (8.2677 * dpi).round(); // A4 宽 210mm
final H = (11.6929 * dpi).round(); // A4 高 297mm
final cardW = (85.6 * dpi / 25.4).round();
final cardH = (54.0 * dpi / 25.4).round();
final gap = (8 * dpi / 25.4).round();
注意:A4 宽度用 8.2677 * dpi 而不是 210 * dpi / 25.4,两者数学上等价(210/25.4 ≈ 8.2677),都是计算 A4 纸的英寸数乘以 DPI。
| A4纸 | 210×297mm | 1075×1520px | 2480×3508px |
| 身份证 | 85.6×54mm | 438×276px | 1011×638px |
| 证件间距 | 8mm | 41px | 94px |
关键理解: 为什么预览用 130dpi 而导出用 300dpi?因为 300dpi 的 A4 图有 2480×3508 ≈ 870 万像素,合成+水印可能需要 1~2 秒,拖动布局参数时完全无法实时预览。130dpi 只有约 160 万像素,重绘仅需几百毫秒,足以支撑实时交互。
三、composeIdToA4 核心实现
3.1 函数签名
/// 证件复印(专门功能):
/// 把证件正反面合成到同一张 A4 白纸上(300dpi = 2480×3508),
/// 支持「上下竖排」与「左右横排」两种版式,
/// 电子版可发送、纸质版可按 A4 100% 直接打印。
/// 两种版式下证件都按真实物理尺寸(85.6×54mm)居中放置,大小完全一致。
img.Image composeIdToA4({
required img.Image front,
required img.Image back,
required bool stacked,
int dpi = 300,
})
参数说明:
| front | img.Image | 必填 | 证件正面图像(已解码的 image 包对象) |
| back | img.Image | 必填 | 证件反面图像(已解码的 image 包对象) |
| stacked | bool | 必填 | true=上下竖排,false=左右横排 |
| dpi | int | 300 | 输出精度,决定 A4 画布的像素尺寸 |
输入是 img.Image 对象而不是文件路径——因为调用方(IdCopyService)已经做了扫描管线处理(裁边+透视矫正+滤镜),直接传入内存中的图像对象,避免重复编解码。
3.2 完整代码
import 'dart:math' as math;
import 'package:image/image.dart' as img;
import '../../core/image_math.dart';
img.Image composeIdToA4({
required img.Image front,
required img.Image back,
required bool stacked,
int dpi = 300,
}) {
final W = (8.2677 * dpi).round(); // A4 宽 210mm
final H = (11.6929 * dpi).round(); // A4 高 297mm
final canvas = img.Image(width: W, height: H, numChannels: 4);
img.fill(canvas, color: img.ColorRgba8(255, 255, 255, 255));
// 身份证真实物理尺寸(85.6×54mm),两种版式共用
final cardW = (85.6 * dpi / 25.4).round();
final cardH = (54.0 * dpi / 25.4).round();
final gap = (8 * dpi / 25.4).round();
void place(img.Image im, int slotX, int slotY) {
final f = ensureRgba(im);
if (f.width <= 0 || f.height <= 0) return;
// 保持比例缩放到身份证真实尺寸框内(不拉伸、不裁剪)
final scale = math.min(cardW / f.width, cardH / f.height);
final w = (f.width * scale).round();
final h = (f.height * scale).round();
final x = slotX + ((cardW – w) ~/ 2);
final y = slotY + ((cardH – h) ~/ 2);
final resized = img.copyResize(f,
width: w, height: h, interpolation: img.Interpolation.linear);
img.compositeImage(canvas, resized, dstX: x, dstY: y);
}
if (stacked) {
// 上下竖排:整组居中
final startX = (W – cardW) ~/ 2;
final startY = (H – cardH * 2 – gap) ~/ 2;
place(front, startX, startY);
place(back, startX, startY + cardH + gap);
} else {
// 左右横排:整组居中
final startX = (W – cardW * 2 – gap) ~/ 2;
final startY = (H – cardH) ~/ 2;
place(front, startX, startY);
place(back, startX + cardW + gap, startY);
}
return canvas;
}
四、核心设计细节
4.1 保持宽高比缩放(place 函数)
void place(img.Image im, int slotX, int slotY) {
final f = ensureRgba(im);
if (f.width <= 0 || f.height <= 0) return;
// 保持比例缩放到身份证真实尺寸框内(不拉伸、不裁剪)
final scale = math.min(cardW / f.width, cardH / f.height);
final w = (f.width * scale).round();
final h = (f.height * scale).round();
final x = slotX + ((cardW – w) ~/ 2);
final y = slotY + ((cardH – h) ~/ 2);
final resized = img.copyResize(f,
width: w, height: h, interpolation: img.Interpolation.linear);
img.compositeImage(canvas, resized, dstX: x, dstY: y);
}
关键逻辑:
为什么不直接缩放到 cardW × cardH?因为用户拍摄的证件图比例不一定是标准的 85.6:54。可能裁边有误差、可能证件略有倾斜导致比例变化。等比缩放+居中保证了证件不会变形,留白均匀。
4.2 整组垂直居中(不是固定上边距)
上下竖排布局:
// 上下竖排:整组居中
final startX = (W – cardW) ~/ 2;
final startY = (H – cardH * 2 – gap) ~/ 2;
计算方式是 (A4 高度 – 两张卡高度 – 间距) / 2——让"两张卡+间距"这个整体在 A4 纸上垂直居中,而不是从固定的 25mm 上边距开始。
左右横排布局同理:
// 左右横排:整组居中
final startX = (W – cardW * 2 – gap) ~/ 2;
final startY = (H – cardH) ~/ 2;
水平方向让"两张卡+间距"整体居中,垂直方向单张卡居中。
为什么整组居中而不是固定上边距?因为固定边距在不同 DPI 下需要重新计算,而且竖排时两张卡的总高度是固定的(2×cardH + gap),居中放置视觉上更平衡,打印出来也更美观——上面空白和下面空白一样多,看起来就是"专业复印"的效果。
4.3 证件间距 8mm
final gap = (8 * dpi / 25.4).round();
正反面之间留 8mm 的空隙。这个宽度刚好:
- 不会太窄(<5mm):两张证件挤在一起,观感局促
- 不会太宽(>12mm):浪费空间,竖排时可能接近 A4 边缘
- 8mm 大约是身份证高度的 15%,视觉比例舒适
五、两种版式对比
5.1 上下竖排(stacked = true)
┌─────────── A4 ───────────┐
│ │
│ ┌─────────────────────┐ │
│ │ 身份证正面 │ │
│ │ 85.6×54mm │ │
│ └─────────────────────┘ │
│ 8mm 间隙 │
│ ┌─────────────────────┐ │
│ │ 身份证反面 │ │
│ │ 85.6×54mm │ │
│ └─────────────────────┘ │
│ │
│ (空白) │
│ │
└────────────────────────────┘
- 正面在上,反面在下
- 左右居中,整组垂直居中
- 适合传统"身份证复印"的习惯,大多数人默认竖排
5.2 左右横排(stacked = false)
┌─────────── A4 ───────────┐
│ │
│ │
│ ┌────────┐ 8mm ┌──────┐│
│ │ 正面 │ 间隙 │ 反面 ││
│ │ │ │ ││
│ └────────┘ └──────┘│
│ │
│ │
│ (空白) │
│ │
│ │
└────────────────────────────┘
- 正面在左,反面在右
- 上下居中,整组水平居中
- 适合节省纸张高度,或者需要在下方添加备注文字的场景
两种版式下证件的物理尺寸完全一致——都是 85.6×54mm 真实大小。区别只是排列方向,不影响打印出来的证件尺寸。
六、IdCopyService:从扫描到A4的完整管线
composeIdToA4 是纯排版函数,不负责文件读写、扫描处理、水印集成。这些上层逻辑由 IdCopyService 封装:
6.1 类结构
/// 证件复印渲染服务:
/// 正反面各自「裁边 + 透视矫正」后合成到同一张 A4 画布。
///
/// 预览、持久化、PDF 导出、图片导出共用这一条管线,
/// 仅通过 dpi / 质量参数区分用途,保证各处输出效果一致。
/// 整个合成在单个后台 isolate 内完成(双面渲染 → A4 合成 → 可选水印 → 编码),
/// 避免多次 isolate 往返与中间 JPEG 反复编解码。
class IdCopyService {
static Future<ProcessResult> renderSide({...}) => processPageImage(...);
static Future<Uint8List?> compose({
required String frontPath,
required String backPath,
required List<P2> frontCorners,
required List<P2> backCorners,
required bool stacked,
required int dpi,
int jpegQuality = 92,
int sideMaxDim = 2600,
img.Image? watermarkTile,
double watermarkOpacity = 0.16,
double watermarkStep = 2.4,
PageSettings? frontSettings,
PageSettings? backSettings,
}) async { ... }
}
6.2 compose 方法完整流程
static Future<Uint8List?> compose({
required String frontPath,
required String backPath,
required List<P2> frontCorners,
required List<P2> backCorners,
required bool stacked,
required int dpi,
int jpegQuality = 92,
int sideMaxDim = 2600,
img.Image? watermarkTile,
double watermarkOpacity = 0.16,
double watermarkStep = 2.4,
PageSettings? frontSettings,
PageSettings? backSettings,
}) async {
final fs = frontSettings ?? sideSettings(frontCorners);
final bs = backSettings ?? sideSettings(backCorners);
// 水印图块转成原始 RGBA 字节再进 isolate:img.Image 不作为闭包捕获,
// 避免跨 isolate 序列化问题。
final hasTile = watermarkTile != null;
final tileRgba = watermarkTile?.toUint8List();
final tileW = watermarkTile?.width ?? 0;
final tileH = watermarkTile?.height ?? 0;
Uint8List? task() {
// 1) 渲染正面(裁边 + 透视矫正 + 增强滤镜)
final rF = processPageImageSync(
originalPath: frontPath, s: fs,
maxDim: sideMaxDim, jpegQuality: 92,
);
// 2) 渲染反面
final rB = processPageImageSync(
originalPath: backPath, s: bs,
maxDim: sideMaxDim, jpegQuality: 92,
);
final fImg = decodeImageNormalized(rF.jpeg);
final bImg = decodeImageNormalized(rB.jpeg);
if (fImg == null || bImg == null) return null;
// 3) 合成到 A4 画布
final canvas =
composeIdToA4(front: fImg, back: bImg, stacked: stacked, dpi: dpi);
// 4) 可选:叠加防滥用水印
if (hasTile && tileRgba != null && tileW > 0 && tileH > 0) {
final tile = img.Image.fromBytes(
width: tileW, height: tileH,
bytes: tileRgba.buffer, numChannels: 4,
);
WatermarkService.stampWatermarkTile(
canvas, tile,
opacity: watermarkOpacity,
step: watermarkStep,
);
}
// 5) 编码为 JPEG
return img.encodeJpg(canvas, quality: jpegQuality);
}
try {
if (kIsWeb) return task();
return await Isolate.run(task);
} catch (_) {
try { return task(); } catch (_) { return null; }
}
}
6.3 单 Isolate 全流程设计
整个合成流程在单个后台 isolate 内完成:
Isolate 内执行:
正面渲染(裁边+透视+滤镜)
反面渲染(裁边+透视+滤镜)
A4 合成(composeIdToA4)
可选:水印平铺(WatermarkService.stampWatermarkTile)
JPEG 编码
→ 返回 Uint8List
为什么放在同一个 isolate?
- 避免多次 isolate 往返:每启动一次 isolate 都有开销,把所有步骤打包只启动一次
- 避免中间 JPEG 反复编解码:如果正面渲染在一个 isolate、A4 合成在另一个,中间需要 JPEG 编码→解码,既耗时间又损失画质
- 水印图块特殊处理:watermarkTile 是主 isolate 渲染的 img.Image,不能直接跨 isolate 传递,所以先转成 Uint8List 再传入
6.4 水印集成
水印由 WatermarkService 提供,在 A4 合成完成后叠加:
WatermarkService.stampWatermarkTile(
canvas, tile,
opacity: watermarkOpacity, // 默认 0.16
step: watermarkStep, // 默认 2.4
);
证件复印场景的水印参数:
| opacity | 0.16 | 较低透明度,不遮挡证件信息但有防滥用效果 |
| step | 2.4 | 稍疏的密度,A4 大面积下视觉更舒适 |
水印图块由调用方在主 isolate 预先通过 WatermarkService.renderWatermarkTile 渲染,字号按目标 dpi 的画布宽度换算。
七、sideSettings:证件裁剪默认配置
/// 证件裁剪默认使用「增强」滤镜:白底加深、文字锐化,适合复印观感。
static PageSettings sideSettings(List<P2> corners) => PageSettings(
applyCrop: true,
corners: corners,
filter: FilterMode.enhanced,
);
证件复印默认使用 enhanced(增强)滤镜——白底加深、文字锐化,模拟复印机的"加深"效果,让证件黑白分明,打印出来更清晰。
八、性能数据
300dpi 导出(A4 = 2480×3508px),iPhone 13 实测:
| 正面渲染(裁边+透视+滤镜) | ~80ms |
| 反面渲染(裁边+透视+滤镜) | ~80ms |
| A4 合成(composeIdToA4) | ~20ms |
| 水印平铺(可选) | ~150ms |
| JPEG 编码 | ~60ms |
| 总耗时(无水印) | ~240ms |
| 总耗时(含水印) | ~390ms |
130dpi 预览则快得多,整体在 100ms 以内,可支撑实时交互。
九、打印适配与注意事项
9.1 尺寸验证
生成的 A4 图像在 300dpi 下的像素尺寸:
| A4 画布 | 2480×3508px | 210×297mm |
| 证件 | ~1011×638px | 85.6×54mm |
| 间距 | ~94px | 8mm |
9.2 打印机设置建议
| 纸张尺寸 | A4 | 标准纸张 |
| 打印质量 | 高(300dpi+) | 确保证件文字清晰 |
| 缩放 | 100%(实际尺寸) | 否则证件尺寸会变形 |
| 双面打印 | 否 | 单面即可 |
| 纸张类型 | 普通纸 | 证件复印不需要照片纸 |
重要提醒: 很多打印机默认会"适合页面"缩放,导致输出尺寸不等于设计尺寸。务必提醒用户关闭自动缩放,选择"实际尺寸"或"100%"打印,才能保证证件 1:1 物理尺寸。
十、踩坑总结
- 输入是 img.Image 对象不是文件路径——调用方已经做了扫描处理,直接传内存图像避免重复编解码;
- 参数名是 stacked 不是 vertical——"堆叠"更准确描述上下排列的关系,横排时是并列不是水平;
- 证件间距是 8mm 不是 10mm——8mm 在 A4 纸上视觉比例更舒适,也符合常见复印习惯;
- 整组垂直居中而不是固定上边距——居中视觉更平衡,也省去"上边距设多少合适"的纠结;
- 缩放用 math.min 取较小比例——保持宽高比,证件不会被拉伸变形,留白均匀;
- 水印图块要先转成 Uint8List 再进 isolate——img.Image 对象不能直接跨 isolate 序列化;
- 整个合成流程在单个 isolate 内完成——避免多次 isolate 启动开销和中间 JPEG 反复编解码;
- 滤镜默认用 enhanced 增强模式——证件复印需要黑白分明,增强滤镜的白底加深+文字锐化效果最合适;
- DPI 是参数而不是常量——预览用低 DPI(~130)保证流畅,导出用高 DPI(300)保证打印清晰。
证件合成 A4 看似只是"把两张图贴到一张白纸上",但要做到"1:1 物理尺寸 + 等比缩放不变形 + 整组居中美观 + 水印集成 + 单isolate高性能",每一个细节都需要仔细考量。composeIdToA4 虽然只有 50 多行代码,但背后是对打印物理尺寸、用户体验和性能的全面把控。
🔔 完整源码即将上架
本系列到此完结。从证件照背景替换、红印章保留、污点修复、离线 OCR、极限压缩、防滥用水印到证件 A4 合成,“安心扫描” App 的核心技术链路已全部拆解完毕。完整源码即将上架,敬请关注。
网硕互联帮助中心



评论前必须登录!
注册