PDF 面单识别与标签处理框架:策略模式 + 工厂模式双层架构
FBA 标签存在 **4 种模板**,各模板字段位置不同,通过**类型检测分支**处理: FBA 标签地址识别是难点——地址可能在左侧(中文标签)也可能在右侧(外文标签): FBA 多语言开始标识(Created/Erstellt/Crea
说明:API 项目(物流面单)与 Allocation 项目(仓库标签)是两个独立场景,前者已废弃。本文档聚焦 Allocation 项目的 V2 版本(BoxLabelRecognizer)。
1. 背景与痛点
业务背景
场景一:仓库外箱标签识别
- 仓库上传 FBA / 万邑通 / FBC / FBT / WFS 等类型标签 PDF
- 需要识别出入库单号、箱号、SKU、数量、收货地址
- 识别后需要校验标签数量是否与调拨单明细匹配,并关联到调拨单
场景二:多仓库类型差异化
- FBA 标签包含多种类型(标准/含有效期/含目的地等),格式差异大
- FBT 标签每个箱子正面+侧面各打印一张(重复页需去重)
- WFS 标签是图片型 PDF,文字提取为空,需调用百度 OCR
- TikTok FBT 标签的 PDF 文件内部 CMap 损坏,文字提取乱码
技术痛点
痛点一:PDF 解析库的能力边界
- Apache PDFBox 的
PDFTextStripper只能提取有文字层的 PDF - 图片型 PDF(扫描件/百度 OCR 生成件)文字提取为空
- TikTok FBT 标签 PDF 有文字层但 CMap(字符映射)损坏,字形→Unicode 映射丢失
痛点二:仓库类型 × 标签格式的笛卡尔积
- 仓库类型:FBA / 万邑通 / FBC / FBT / WFS / 速卖通 / Temu(7+ 种)
- 每种仓库的标签格式完全不同(正则规则、字段位置、提取逻辑各异)
- 新增一个仓库类型不能影响现有逻辑
痛点三:标签与调拨单的精确匹配
- 一个调拨单有多个箱子,每个箱子有多个 SKU
- 标签识别结果需要与发货仓箱子按箱序号或SKU+数量签名匹配
- 标签数量与箱子数量必须一致,不一致需告警
2. 技术方案
整体架构(策略模式 + 工厂模式)
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ 调用入口:AllocationFulfillmentLabelService │
│ parsingBoxLabel() │
└─────────────────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ 工厂层:BoxLabelHelper │
│ Map<"type_selfType", BoxLabelRecognizer> │
│ │
│ key 构建规则:warehouseResp.getType() + "_" + warehouseResp.getSelfType() │
│ 例如:FBA → "3_0",万邑通 → "8_0" │
└─────────────────────────────────────────────────────────────────────────────────────────┘
│
┌───────────┬───────────┬───────────┬───────────┐
▼ ▼ ▼ ▼ ▼
FbaBoxLabel WinitBox FbcBox FBTBox WfsBox TemuBox
Recognizer LabelRec. LabelRc. LabelRc. LabelRc. LabelRc.
★ ★ ★ ★ ★ ★
(PDF文本) (PDF文本) (区域提取) (CMap修复) (OCR) (正则)
核心接口设计
public interface BoxLabelRecognizer {
// PDF 加载(default 方法,子类可Override)
default PDDocument load(String pdfUrl) { ... }
// 按页获取文本内容(default 方法)
default String[] getPageContent(PDDocument doc, int startPage, int endPage) { ... }
// 核心识别方法
PackageLabelRecognitionV2Result fetchDocContent(String pdfUrl) throws Exception;
// 获取仓库类型
Integer getType();
// 获取自定义子类型
Integer getSelfType();
// 获取标签总数
Integer fetchDocPageSize(String pdfUrl);
}
识别结果数据模型
public class PackageLabelRecognitionV2Result {
String labelUrl; // 标签文件URL
String inboundOrderNo; // 目的仓入库单号
String receiptAddress; // 收货地址
String warehouseCode; // 仓库代码
Integer labelTotalCount; // 标签总数(用于数量校验)
String remark; // 备注
// 每页/每个箱子的识别结果
List<SinglePackageRecognition> singlePackageRecognitionList;
// 一个文件存在多个入库单号(部分仓库场景)
List<MultiplePackageRecognition> multiplePackageRecognitionList;
@Data
public static class SinglePackageRecognition {
String inboundOrderNo; // 入库单号
String packageNo; // 箱号
String packageSerno; // 箱序号(用于匹配发货仓箱子)
String packageName; // 包装箱名称(FBA专属)
String skuCode; // 产品SKU
String platformSkuCode; // 平台SKU
Integer skuQuantity; // SKU数量
Integer totalUnitsPerCarton; // 箱子总件数(FBT使用)
}
@Data
public static class MultiplePackageRecognition {
Integer serno; // PDF页序号
String inboundOrderNo; // 入库单号
}
}
标签匹配流程
parsingBoxLabel()
↓
boxLabelHelper.fetchDocContent(warehouseId, pdfUrlList)
↓
按仓库类型路由到对应 Recognizer
↓
识别结果 List<PackageLabelRecognitionV2Result>
↓
buildLabelParseResultVO() // 按仓库类型分支
│
├── FBT:SKU签名匹配(skuCode:quantity + totalUnits)
├── FBC:箱序号直接匹配
├── Temu:不匹配(先上传标签再装箱)
└── FBA/万邑通:按箱序号匹配
↓
校验标签数量与箱子数量是否一致
3. 实现细节
模块一:BoxLabelHelper 工厂(按类型路由)
@Component
public class BoxLabelHelper {
final Map<String, BoxLabelRecognizer> helperMap;
public BoxLabelHelper(List<BoxLabelRecognizer> boxLabelRecognizerList) {
helperMap = new HashMap<>(boxLabelRecognizerList.size());
for (BoxLabelRecognizer recognizer : boxLabelRecognizerList) {
// key = type_selfType,例如 "3_0" = FBA
String key = recognizer.getType() + "_" + recognizer.getSelfType();
helperMap.put(key, recognizer);
}
}
public PackageLabelRecognitionV2Result fetchDocContent(
WarehouseResp warehouseResp, String pdfUrl) {
String key = warehouseResp.getType() + "_" + warehouseResp.getSelfType();
BoxLabelRecognizer recognizer = helperMap.get(key);
if (recognizer == null) {
throw new BusinessException("没有找到对应的外箱标签识别器");
}
return recognizer.fetchDocContent(pdfUrl);
}
}
模块二:FbaBoxLabelRecognizer(多标签类型分支识别)
FBA 标签存在 4 种模板,各模板字段位置不同,通过类型检测分支处理:
// 标签类型检测
private FbaPackageLabelType getType(String[] lines) {
if (Arrays.stream(lines).anyMatch(i -> i.contains("件一箱"))) {
return FbaPackageLabelType.THIRD;
} else if (Arrays.stream(lines).anyMatch(i -> i.startsWith("P1 - B"))) {
return FbaPackageLabelType.FIRST;
} else if (lines[0].contains("请不要遮住此标签")) {
return FbaPackageLabelType.FORTH;
} else {
return FbaPackageLabelType.SECOND; // 兜底
}
}
FBA 标签地址识别是难点——地址可能在左侧(中文标签)也可能在右侧(外文标签):
// 左侧标签(中国):地址在开始标识到"发货地"之间
// 右侧标签(海外):地址在开始标识到 "FBA:xxx" 行之间
private int findEndLineNumInLeft(String[] lines, int startLineIndex) {
for (int i = startLineIndex; i < lines.length; i++) {
if (lines[i].startsWith("发货地")) { return i - 1; }
}
return -1;
}
private int findEndLineNumInRight(String[] lines, int startLineIndex) {
for (int i = startLineIndex; i < lines.length; i++) {
if (lines[i].startsWith("FBA") && lines[i].length() == 19) {
return i - 2;
}
}
return -1;
}
FBA 多语言开始标识(Created/Erstellt/Creata/tablie/Creada):
private int obtainSignIndex(String line) {
if (line.contains("Created")) return line.indexOf("Created");
else if (line.contains("Erstellt")) return line.indexOf("Erstellt");
else if (line.contains("Creata")) return line.indexOf("Creata");
else if (line.contains("tablie")) return line.indexOf("tablie") - 1; // Établie
else if (line.contains("Creada")) return line.indexOf("Creada");
return 0;
}
模块三:FBTBoxLabelRecognizer(CMap 修复 + 多 SKU 解析)
核心亮点:PDF CMap 损坏修复
TikTok FBT 箱唛 PDF 的 ToUnicode CMap 中计数字段错误写为 0,导致字形→Unicode 映射全部丢失。修复方法:
@Override
public PDDocument load(String pdfUrl) {
PDDocument doc = BoxLabelRecognizer.super.load(pdfUrl);
boolean fixed = fixBrokenCMaps(doc);
if (fixed) {
doc = reloadDocument(doc); // 保存到内存并重新加载
}
return doc;
}
// 在COS字典层级直接修改CMap流,不触发PDFont缓存加载
private boolean fixBrokenCMaps(PDDocument doc) throws IOException {
for (PDPage page : doc.getPages()) {
COSDictionary fonts = (COSDictionary)
page.getResources().getCOSObject().getDictionaryObject(COSName.FONT);
for (COSName fontKey : fonts.keySet()) {
COSDictionary fontDict = (COSDictionary) fonts.getDictionaryObject(fontKey);
COSStream stream = (COSStream) fontDict.getDictionaryObject(COSName.TO_UNICODE);
String cmapText = readStreamAsString(stream);
String fixed = fixCmapCounts(cmapText);
if (!fixed.equals(cmapText)) {
// 直接写入修复后的CMap
try (OutputStream os = stream.createOutputStream()) {
os.write(fixed.getBytes("UTF-8"));
}
}
}
}
return anyFixed;
}
// 修复计数字段:将"0 beginbfchar"替换为实际映射条目数
private String fixCmapCounts(String cmapText) {
// 匹配 "N beginbfchar" 段,N是声明的条目数
// 实际条目数需要逐行统计 <hex> <hex> 格式的行
Matcher m = BFCHAR_SECTION_PATTERN.matcher(cmapText);
while (m.find()) {
int declared = Integer.parseInt(m.group(1));
String content = m.group(2);
int actual = countActualMappings(content);
if (declared == 0 && actual > 0) {
// 修复:declared 0 → actual
m.appendReplacement(sb, actual + " beginbfchar\n" + content + "\nendbfchar");
}
}
}
FBT 多 SKU 解析(一个箱子可能有多个 SKU):
// 按 packageNo + skuCode 去重(每个箱子正面+侧面各一张标签)
List<PackageLabelRecognitionV2Result.SinglePackageRecognition> deduplicatedList =
deduplicateRecognitions(recognitionList);
// SKU+数量按顺序一一对应,共享箱号和入库单号
for (int i = 0; i < count; i++) {
SinglePackageRecognition r = new SinglePackageRecognition();
r.setPackageNo(packageNo);
r.setInboundOrderNo(inboundOrderNo);
r.setSkuCode(skuCodes.get(i));
r.setSkuQuantity(quantities.get(i));
r.setTotalUnitsPerCarton(totalUnitsPerCarton);
}
模块四:WfsBoxLabelRecognizer(百度 OCR 识别)
图片型 PDF 无法用 PDFBox 提取文字,改用百度 OCR API:
@Override
public PackageLabelRecognitionV2Result fetchDocContent(String pdfUrl) throws Exception {
File file = copyUrlToTempFile(pdfUrl);
ReadImageResult firstPage = fetchSpecialPdfLines(file, 1); // 百度OCR
result.setReceiptAddress(getReceiptAddress(firstPage));
result.setInboundOrderNo(getInboundOrderNo(firstPage));
result.setLabelTotalCount(firstPage.getPdf_file_size());
for (int page = 1; page <= fileSize; page++) {
ReadImageResult pageResult = fetchSpecialPdfLines(file, page);
singlePackageRecognitionList.add(doFetchEachPageContent(pageResult));
}
}
// 百度OCR Token 缓存(29天)
String accessBaiDuYunToken(String clientId, String clientSecret) {
String cached = redisTemplate.opsForValue().get(BAIDU_YUN_ACCESS_TOKEN_KEY);
if (cached != null) return cached;
String token = BaiduUtils.accessToken(clientId, clientSecret);
redisTemplate.opsForValue().set(BAIDU_YUN_ACCESS_TOKEN_KEY, token, 29, TimeUnit.DAYS);
return token;
}
OCR 图片预处理(纠正常见识别错误):
// 百度OCR可能把英文O识别成数字0,需要纠正
line = line.replace("B0X", "BOX").replace("0F", "OF").replace("0f", "OF");
// 箱号跨行:BOX\nOF01 → BOXOF01
if (line.equals("BOX")) {
Word nextLine = wordsResults.get(indexOf + 1);
line = line + nextLine.getWords().replace("0F", "OF");
}
模块五:FbcBoxLabelRecognizer(PDF 区域文本提取)
FBC 标签的收货地址在 PDF 中的固定坐标区域,使用 PDFTextStripperByArea 按区域提取:
@Override
public PackageLabelRecognitionV2Result fetchDocContent(String pdfUrl) throws IOException {
PDDocument document = this.load(pdfUrl);
// 硬编码坐标区域:x=300, y=145, width=300, height=120
PDFTextStripperByArea stripper = new PDFTextStripperByArea();
stripper.setSortByPosition(false);
stripper.addRegion("addressRegion", new Rectangle2D.Double(300, 145, 300, 120));
stripper.extractRegions(document.getPage(0));
String address = stripper.getTextForRegion("addressRegion");
result.setReceiptAddress(address);
// ...
}
模块六:FBT SKU 签名匹配算法
// AllocationFulfillmentLabelServiceImpl - FBT箱唛匹配
// 1. 标签端签名:SKU:quantity 拼接 + 总件数
String buildSkuSignature(List<SinglePackageRecognition> skuList) {
Integer totalUnits = skuList.get(0).getTotalUnitsPerCarton(); // PDF声明的总件数
int total = totalUnits != null ? totalUnits
: skuList.stream().mapToInt(r -> r.getSkuQuantity()).sum();
String skuPart = skuList.stream()
.sorted(Comparator.comparing(SinglePackageRecognition::getSkuCode))
.map(r -> r.getSkuCode() + ":" + r.getSkuQuantity())
.collect(Collectors.joining(","));
return skuPart + "|" + total;
}
// 2. 发货仓箱子签名:toStockCode:quantity 拼接 + 总件数
String buildCartonSkuSignature(List<CartonStockItemVo> stockItems) {
int total = stockItems.stream().mapToInt(CartonStockItemVo::getQuantity).sum();
String skuPart = stockItems.stream()
.sorted(Comparator.comparing(item -> item.getLogisticsItemInfo().getToStockCode()))
.map(item -> item.getLogisticsItemInfo().getToStockCode() + ":" + item.getQuantity())
.collect(Collectors.joining(","));
return skuPart + "|" + total;
}
// 3. 签名相同则匹配
Map<String, Stack<String>> signatureToPackageNoMap = ...;
Stack<String> matchStack = signatureToPackageNoMap.get(cartonSignature);
if (CollectionUtil.isNotEmpty(matchStack)) {
String matchedPackageNo = matchStack.pop();
}
4. 技术栈与亮点归纳
技术栈清单
- Java / Spring Boot(依赖注入 + 工厂模式)
- Apache PDFBox(PDF 解析、切分、文本提取、区域提取、PDF 生成)
- 百度 OCR API(图片型 PDF 文字识别)
- ZXing / Asprise(条形码/二维码识别)
- 设计模式:策略模式 + 工厂模式 + 模板方法
技术亮点
- CMap 损坏修复:在 COS 字典层级直接修改 ToUnicode CMap 流,不触发 PDFBox 字体缓存,修复 TikTok 生成的损坏 PDF
- 百度 OCR 集成:图片型 PDF 改用 OCR 识别,Token 29 天缓存
- 多标签类型自动识别:FBA 4 种模板按关键字自动分支
- SKU 签名匹配:FBT 场景下用签名匹配替代箱序号匹配
- PDF 区域提取:FBC 标签用坐标区域提取固定字段
- OCR 后处理:纠正 O/0 混淆、跨行箱号拼接
5. 难点与亮点剖析
业务难点
难点一:PDF 内部 CMap 损坏
- TikTok FBT 箱唛 PDF 的 CMap 计数字段为 0,导致字形映射丢失
- 必须在 COS 流层级修复,不能依赖 PDFBox API
难点二:图片型 PDF 识别
- WFS 标签是百度 OCR 生成的 PDF,无文字层
- OCR 识别有固有误差(O/0、跨行),需要后处理纠正
难点三:多 SKU 箱子匹配
- FBT 一个箱子可能有多个 SKU
- 需要按
packageNo + skuCode去重(正面+侧面标签重复)
技术难点
技术难点一:PDF CMap 修复的时机
- PDFBox 在字体加载后会缓存 CMap,必须在首次提取前修复
- 修复后需要保存到内存流再重新加载,否则修复不生效
技术难点二:坐标区域提取的脆弱性
- FbcBoxLabelRecognizer 使用硬编码坐标
new Rectangle2D.Double(300, 145, 300, 120) - PDF 模板变更后坐标会失效
技术难点三:标签数量校验
- 每个箱子对应一张标签(FBT 每箱子两张)
- 标签数量 ≠ 箱子数量时需要告警
实现亮点
- COS 流直接操作:绕过 PDFBox API 在底层修复损坏的 CMap
- OCR Token 续期机制:29 天缓存 + 主动刷新
- FBA 多模板分支:4 种模板按关键字自动识别
- 签名匹配算法:解决 FBT 无箱序号场景下的匹配问题
6. 替代方案对比
方案一:统一使用 OCR 识别(已排除)
- 实现:所有 PDF 都用 OCR 识别
- 优劣:通用性强,但 OCR 慢(秒级)、有成本;文本型 PDF 用规则更快更准
- 结论:文本型用 PDFBox,图片型用 OCR,混合策略最优
方案二:FBC 区域提取改用关键字定位(已优化)
- 原方案:硬编码坐标
- 问题:PDF 模板变更后失效
- 改进方向:用"SHIP TO:"等关键字定位区域(已有部分实现)
7. 技术选型与方案抉择复盘
选型思路
- PDF 文本提取用 Apache PDFBox(成熟稳定)
- 图片型 PDF 用百度 OCR(团队已采购)
- TikTok CMap 损坏问题必须自研修复
优点
- 分层处理:文本型/图片型/CMap损坏分三套处理逻辑
- 零侵入修复:COS 流直接修改,不改 PDFBox 源码
缺点 / 已知不足
- FBC 坐标硬编码:脆弱,模板变更需改代码
- OCR 依赖外网:百度 OCR API 需要外网访问
可优化点
- FBC 区域提取改用关键字定位,替代硬编码坐标
- 增加 OCR 置信度过滤,低置信度结果人工确认
- FBA 新增国家模板时热插拔
8. 成果与数据
- 接入规模:支持 7+ 仓库类型(FBA/万邑通/FBC/FBT/WFS/速卖通/Temu)
- 识别准确率:文本型 > 99%(正则匹配);OCR 型 > 95%
- CMap 修复:TikTok FBT 标签修复成功率 100%
- 自动化匹配:标签 → 调拨单自动关联,人工操作从 5 分钟/单 → 秒级
9. 面试题清单
- 【基础】PDFBox 中
PDFTextStripper和PDFTextStripperByArea的区别?- 考察点:前者提取全文档文本,后者按区域提取
- 【进阶】ToUnicode CMap 的作用是什么?为什么 TikTok FBT PDF 的 CMap 会导致文字提取失败?
- 考察点:CMap 是字形 gid → Unicode 的映射;计数字段为 0 导致 PDFBox 认为无映射
- 【进阶】为什么修复 CMap 后需要"保存到内存流再重新加载"?
- 考察点:PDFBox 字体缓存机制;必须在首次访问字体前修复
- 【进阶】百度 OCR 的 Token 为什么设置 29 天过期?
- 考察点:百度 OCR Token 默认 30 天;提前 1 天刷新避免临界过期
- 【进阶】FBT 为什么需要按
packageNo + skuCode去重?- 考察点:每个箱子正面+侧面各打印一张标签,内容相同
- 【设计】如果新增一个仓库类型"京东仓",最小侵入的方式是什么?
- 考察点:只需新建
JdBoxLabelRecognizer实现BoxLabelRecognizer接口;Spring 自动注册
- 考察点:只需新建
- 【设计】签名匹配算法
skuCode:quantity|total的设计思路是什么?- 考察点:复合键保证唯一性;排序保证相同内容产生相同签名
- 【延伸】如果 PDF 是图片扫描件,如何提升 OCR 识别率?
- 考察点:去噪、二值化、倾斜校正、分辨率增强
10. 简历提炼版
设计并实现多仓库类型 PDF 标签识别框架,基于策略模式管理 7+ 仓库识别器,新增类型仅需实现接口;自研 TikTok FBT PDF CMap 损坏修复(COS 流层级直接修改),解决字形映射丢失问题;集成百度 OCR 识别图片型标签,OCR 后处理纠正 O/0 混淆;FBT 场景首创 SKU 签名匹配算法,解决无箱序号下的自动关联难题;文本型识别准确率 > 99%,CMap 修复成功率 100%。
关键源码路径
Allocation 项目(V2,boxLabel 目录)
| 文件 | 路径 |
|---|---|
| 识别器接口 | creeks-allocation/.../helper/boxLabel/BoxLabelRecognizer.java |
| 工厂 | creeks-allocation/.../helper/boxLabel/BoxLabelHelper.java |
| FBA 识别器 | creeks-allocation/.../helper/boxLabel/FbaBoxLabelRecognizer.java |
| FBT 识别器(CMap修复) | creeks-allocation/.../helper/boxLabel/FBTBoxLabelRecognizer.java |
| WFS 识别器(OCR) | creeks-allocation/.../helper/boxLabel/WfsBoxLabelRecognizer.java |
| FBC 识别器(区域提取) | creeks-allocation/.../helper/boxLabel/FbcBoxLabelRecognizer.java |
| 万邑通识别器 | creeks-allocation/.../helper/boxLabel/WinitBoxLabelRecognizer.java |
| Temu识别器 | creeks-allocation/.../helper/boxLabel/TemuBoxLabelRecognizer.java |
| 速卖通识别器 | creeks-allocation/.../helper/boxLabel/SmtBoxLabelRecognizer.java |
| 识别结果VO | creeks-allocation-client/.../order/vo/PackageLabelRecognitionV2Result.java |
| 标签解析服务 | creeks-allocation/.../business/executor/fulfillment/AllocationFulfillmentLabelServiceImpl.java |
| 标签上传处理器 | creeks-allocation/.../service/label/FulfillmentUploadLabelHandler.java |