摘要
大多数人用文档解析做一件事:把纸变成数据。这篇文章反过来------把一张快递面单还原成"一个陌生人能从上面读到多少关于你的事"。面向做 OCR / 文档解析的后端与前端工程师,讲清三件工程问题:块坐标怎么变成可点击热区 、被切碎在表格单元格里的手机号怎么合并命中 、脱敏怎么回到原图上而不是只改文本。全部代码来自我参加 TextIn 文档觉醒比赛的作品「纸片侦探」,三份样本实测 42 个实体、误报漏报均为 0、全部可定位,读完后你可以直接套用到票据、合同、工单等任何"要指出位置"的场景。
📌 版本声明 :本文基于 TextIn xParse
pdf_to_markdown接口(api.textin.com/ai/service/v1)、Node.js 22、Chrome 129 编写。接口参数以官方文档为准,若你使用的版本对apply_merge/table_flavor` 有变更,请对照官方说明调整。
文章目录
-
- 摘要
- 一、问题:为什么"识别出文字"还不够
- [二、xParse 到底给了什么](#二、xParse 到底给了什么)
- [三、第一个坑:把 markdown 当纯文本用](#三、第一个坑:把 markdown 当纯文本用)
- 四、第二个坑:表格里被切成两半的手机号
- 五、坐标怎么落到页面上
- 六、规则怎么分层,才不会一天到晚误报
- 七、从取证到"体感":侧写与冒充模拟
- 八、实测:三份样本与前后对比
- 九、搭建过程:四个决定与它们的代价
- 十、适用边界与已知缺陷
- 十一、总结
- 参考资料
一、问题:为什么"识别出文字"还不够
先把场景摆出来。你每天都在扔掉或转发三种纸:快递面单、便利店小票、投出去的简历。它们单看只是一张纸,凑在一起却是一份关于你的说明书------姓名、手机号、精确到门牌号的地址、单位、作息、消费能力。
拆开看更直观。一张面单上,收件人姓名和电话是"直接标识",地址是"准确定位",运单号是"可被冒用的凭证",备注栏里那句"放门口,别打电话"则是"行为习惯"------它泄露的不只是你是谁,还有你什么时候不在家。一张小票上,消费时间、门店、商品明细拼出的是你的作息与消费能力,深夜 23:47 的一笔关东煮,比任何问卷都诚实。一份简历更不用说,公司、职级、项目经历、教育背景,几乎是一份公开的自我画像。
单看每一项都不致命,但它们是可交叉验证 的:用姓名 + 手机号能锁定一个人,用地址 + 作息能推断何时家中无人,用运单号能冒充客服。这正是隐私泄露的真实形态------不是某一项被泄露,而是多项被拼起来。
现在市面上的解析工具都在解决"抽出来",没有工具回答"抽出来之后能干什么"。这就是我动手的起点:如果我是那个在楼道里捡到这张纸的人,我能从中读出多少?
这个反向问题,直接决定了技术选型。它把"做一个 OCR demo"变成了"做一个取证工具",而取证工具的要求和识别工具完全不同:识别工具只要结果对,取证工具还要求每一条结论都能指回原文的某个位置,否则结论就不可信、不可核查、也无法用于脱敏。换句话说,我要的不是"识别率 99%",而是"我说的每一句话,你都能在纸上找到出处"。
这个反向问题,直接决定了技术选型。它有三条硬约束:
| 需求 | 纯 OCR 文本能给你吗 | 说明 |
|---|---|---|
| 在纸上圈出证据 | ❌ 不能 | 只有文字流,没有位置,报告只能说"含手机号" |
| 处理被表格切碎的字段 | ❌ 很难 | 面单里手机号常被切成 186 + 2501 7734,行级正则直接漏检 |
| 生成可对外发布的脱敏图 | ❌ 不能 | 替换文本后,原图仍然是原图 |
三条约束指向同一个东西:版面 。于是选型落到 TextIn xParse,因为它返回的不是一段 markdown 字符串,而是带归一化坐标的 elements[]。 |
这里补充一句选型时的对比思路。我评估过三类方案:一是纯 OCR(如 Tesseract、各家通用 OCR 接口),能出文字和字级框,但表格结构要靠自己猜,合并单元格基本无解;二是通用文档解析(返回 markdown),结构好但坐标往往被丢掉;三是带版面分析的解析服务,同时给结构和坐标。前两类要么缺坐标、要么缺结构,只有第三类能同时满足"圈得出、拼得回、遮得住"三条约束。xParse 属于第三类,且它的坐标是归一化的,这一点在后面的前端渲染里省了大量换算。
下面是整条链路的形状,后面每一节都在拆其中一个环节:
#mermaid-svg-67NiSxLCY6F6CJTW{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-67NiSxLCY6F6CJTW .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-67NiSxLCY6F6CJTW .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-67NiSxLCY6F6CJTW .error-icon{fill:#552222;}#mermaid-svg-67NiSxLCY6F6CJTW .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-67NiSxLCY6F6CJTW .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-67NiSxLCY6F6CJTW .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-67NiSxLCY6F6CJTW .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-67NiSxLCY6F6CJTW .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-67NiSxLCY6F6CJTW .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-67NiSxLCY6F6CJTW .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-67NiSxLCY6F6CJTW .marker{fill:#333333;stroke:#333333;}#mermaid-svg-67NiSxLCY6F6CJTW .marker.cross{stroke:#333333;}#mermaid-svg-67NiSxLCY6F6CJTW svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-67NiSxLCY6F6CJTW p{margin:0;}#mermaid-svg-67NiSxLCY6F6CJTW .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-67NiSxLCY6F6CJTW .cluster-label text{fill:#333;}#mermaid-svg-67NiSxLCY6F6CJTW .cluster-label span{color:#333;}#mermaid-svg-67NiSxLCY6F6CJTW .cluster-label span p{background-color:transparent;}#mermaid-svg-67NiSxLCY6F6CJTW .label text,#mermaid-svg-67NiSxLCY6F6CJTW span{fill:#333;color:#333;}#mermaid-svg-67NiSxLCY6F6CJTW .node rect,#mermaid-svg-67NiSxLCY6F6CJTW .node circle,#mermaid-svg-67NiSxLCY6F6CJTW .node ellipse,#mermaid-svg-67NiSxLCY6F6CJTW .node polygon,#mermaid-svg-67NiSxLCY6F6CJTW .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-67NiSxLCY6F6CJTW .rough-node .label text,#mermaid-svg-67NiSxLCY6F6CJTW .node .label text,#mermaid-svg-67NiSxLCY6F6CJTW .image-shape .label,#mermaid-svg-67NiSxLCY6F6CJTW .icon-shape .label{text-anchor:middle;}#mermaid-svg-67NiSxLCY6F6CJTW .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-67NiSxLCY6F6CJTW .rough-node .label,#mermaid-svg-67NiSxLCY6F6CJTW .node .label,#mermaid-svg-67NiSxLCY6F6CJTW .image-shape .label,#mermaid-svg-67NiSxLCY6F6CJTW .icon-shape .label{text-align:center;}#mermaid-svg-67NiSxLCY6F6CJTW .node.clickable{cursor:pointer;}#mermaid-svg-67NiSxLCY6F6CJTW .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-67NiSxLCY6F6CJTW .arrowheadPath{fill:#333333;}#mermaid-svg-67NiSxLCY6F6CJTW .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-67NiSxLCY6F6CJTW .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-67NiSxLCY6F6CJTW .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-67NiSxLCY6F6CJTW .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-67NiSxLCY6F6CJTW .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-67NiSxLCY6F6CJTW .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-67NiSxLCY6F6CJTW .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-67NiSxLCY6F6CJTW .cluster text{fill:#333;}#mermaid-svg-67NiSxLCY6F6CJTW .cluster span{color:#333;}#mermaid-svg-67NiSxLCY6F6CJTW div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-67NiSxLCY6F6CJTW .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-67NiSxLCY6F6CJTW rect.text{fill:none;stroke-width:0;}#mermaid-svg-67NiSxLCY6F6CJTW .icon-shape,#mermaid-svg-67NiSxLCY6F6CJTW .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-67NiSxLCY6F6CJTW .icon-shape p,#mermaid-svg-67NiSxLCY6F6CJTW .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-67NiSxLCY6F6CJTW .icon-shape .label rect,#mermaid-svg-67NiSxLCY6F6CJTW .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-67NiSxLCY6F6CJTW .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-67NiSxLCY6F6CJTW .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-67NiSxLCY6F6CJTW :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 上传图片/PDF
xParse pdf_to_markdown
elements\[\] 块坐标
markdown + table HTML
表格降维成行
语料重建
带偏移映射
21 类规则命中
偏移反查 bbox
热区 / 脱敏黑条
侧写推演与评分
揭露卡海报

图1:从一张纸到"暴露指数"的四段链路
二、xParse 到底给了什么
很多人第一次接 xParse 只取 data.markdown,然后就把它当 OCR 用了。这等于买了一张带等高线的地图,只拿来当地名表。它的返回体里真正值钱的是三块:
(1)归一化块坐标。 每个元素带 coordinates,是四点的 0~1 相对坐标(左上 → 右上 → 右下 → 左下)。取 min/max 得外接矩形,乘以 100 就能直接写进 CSS 的百分比定位。这一点很关键:它和图片分辨率完全解耦,你的原图是 800px 还是 3000px,框都在同一个位置。
为什么是四点而不是 x/y/w/h?因为真实文档里的文字块经常是倾斜 的------扫描件歪个两三度很常见。四点坐标能表达一个旋转矩形,取 min/max 得到的是它的轴对齐外接矩形,虽然会略微放大,但足以覆盖文字。如果接口只给 x/y/w/h,倾斜文本的框就会偏。这也是我在前端统一用百分比、不用像素的原因:一旦用像素,就得在每次缩放时重算,而百分比是"跟着容器走"的。
(2)表格结构重建。 打开 table_flavor=html 后,表格输出为 <table> 而不是空格对齐的纯文本。面单和小票 90% 的信息在表格里,有了行结构,"第 3 行"这个单位才既能被读、也能被框。
这一点值得展开。纯文本模式下,表格会被压成用空格或制表符对齐的一坨,列边界全靠猜;一旦某列内容长度不一,对齐就崩了,你根本分不清"186"是电话的前三位还是运单号的一段。而 <table> 保留了 <tr> / <td> 的层级,我可以按行遍历、按单元格取值,甚至能识别出"这一行是表头、下一行是数据"。对取证来说,行是最小可解释单元------"第 3 行第 4 列是电话",这句话本身就是证据的一部分。
(3)跨单元格的合并结果。 apply_merge=1 会让被切成两半的字段在 markdown 里保持语义连续;但注意------语义连续不等于坐标连续,一个逻辑字段可能横跨多个块,这正是后文 bbox 反查要解决的问题。
我用到的请求形态如下,只保留必要参数:
bash
# 直接走 HTTP 接口(服务端运行时用这条)
curl -X POST "https://api.textin.com/ai/service/v1/pdf_to_markdown?parse_mode=auto&table_flavor=html&apply_merge=1&page_details=1&get_image=none" \
-H "Content-Type: application/octet-stream" \
-H "x-ti-app-id: $TI_APP_ID" \
-H "x-ti-secret-code: $TI_SECRET" \
--data-binary @samples/waybill.png \
-o fixtures/waybill.json
# 在 Agent 里跑批量验证(CLI 封装,输出带坐标的 JSON)
xparse-cli parse samples/waybill.png --api auto --view json --include-char-details --output fixtures/waybill_raw
# 预期返回结构(截取关键字段)
# {
# "code": 200, "message": "success",
# "x_request_id": "d9152fa0067ca48359df761fa42b623d",
# "data": { "file_id": "8956a31c6d864df289fa0dd0e46e5295",
# "markdown": "...<table border=\"1\">...",
# "elements": [ {"type":"text","text":"...","coordinates":[x0,y0,...]} ],
# "pages": [...], "metadata": {"page_count":1} } }
参数怎么选,取决于你要不要坐标。 table_flavor=html 是为了拿 <table>;apply_merge=1 是为了让合并单元格不丢内容;get_image=none 是我特意关掉的------我不需要返回裁图,热区靠前端按坐标画,省一次图片传输。page_details=1 在单页场景可以关,多页 PDF 建议留着,它是分页坐标的来源。
⚠️ 注意:x-ti-app-id / x-ti-secret-code 必须放在服务端。我的做法是前端永远不持有密钥,浏览器只把文件二进制 POST 给自己的服务,服务再转发。若不这样,密钥会直接躺在 view-source 里。
三份样本的真实回执如下,评委和读者都可以拿去后台核对:
| 样本 | code | file_id | 版面元素 | 端到端耗时 |
|---|---|---|---|---|
| waybill.png(快递面单) | 200 success | 8956a31c6d864df289fa0dd0e46e5295 |
17 | --- |
| receipt.png(便利店小票) | 200 success | 22986dd8acfe4414b39421e8144da689 |
11 | --- |
| resume.png(求职简历) | 200 success | 82df9b139ad84254a9979723648f81f3 |
46 | 3.343s(单张,含上传+解析+落盘) |
三、第一个坑:把 markdown 当纯文本用
我最初图省事,直接对 data.markdown 跑正则,结果两份样本全崩:
- 表格变成
<table border="1"><tr><td>...之后,正则会在标签边界上断掉 ,186</td><td>2501 7734这种结构根本匹配不到; - 就算命中了,也算不出它在纸上的位置------markdown 里没有坐标。
所以第一步不是写规则,是重建语料 :从 elements[] 重新拼一条长文本,同时记住每个块在这条长文本里的 [start, end) 区间。
javascript
// 语料重建:把版面块拼成一条"可回查"的长文本
function buildCorpus(doc) {
const segs = []; // 展示用的段落(带坐标)
const lines = []; // 取证用的行(带坐标)
const starts = []; // 每行在 full 中的起始偏移
let full = '';
for (const el of doc.elements) {
const c = el.coordinates; // 四点归一化坐标
const box = {
x: Math.min(c[0], c[2], c[4], c[6]) * 100,
y: Math.min(c[1], c[3], c[5], c[7]) * 100,
w: (Math.max(c[0], c[2], c[4], c[6]) - Math.min(c[0], c[2], c[4], c[6])) * 100,
h: (Math.max(c[1], c[3], c[5], c[7]) - Math.min(c[1], c[3], c[5], c[7])) * 100,
};
// 表格按行拆开,一行一个可取证单元
const rows = el.type === 'table' ? splitTableRows(el) : [el.text || ''];
for (const row of rows) {
const t = String(row).replace(/\s+/g, ' ').trim();
if (!t) continue;
starts.push(full.length);
lines.push({ text: t, ...box, index: lines.length });
full += t + '\n';
}
segs.push({ t: el.text || '', ...box });
}
return { full, lines, starts, segs };
}
这段代码做的是"建索引",不是"清洗"。 关键动作有两个:一是按 el.type 区分表格与普通块,表格走行拆分(下一节展开);二是在拼接时记录 starts,它让后续每个正则命中的 index 都能二分回查到"哪一行"。参数上,x/y/w/h 统一乘 100 转成百分比,是为了让前端直接写成 left:12.4%;top:63.1%,不再做二次换算。注意:不要在这一步做任何文本替换或去重 ,任何字符长度的变化都会让 starts 偏移失效,后面的 bbox 全错位。
四、第二个坑:表格里被切成两半的手机号
真实面单的表格长这样(简化):
html
<table border="1">
<tr><td>收件人</td><td>周雨桐</td><td>电话</td><td>186</td></tr>
<tr><td></td><td></td><td></td><td>2501 7734</td></tr>
</table>
手机号被拆到两个单元格、两行。行级正则永远匹配不到 18625017734,用户看到的结果就是"没检测到手机号"------而这恰恰是面单上最该死的一项。
解法是先拼后查、再反查位置 :把整段 full 交给正则,命中后拿命中区间去 starts 里二分,得到起始行与结束行,再把这两行的 bbox 合并成一个大框。
javascript
// 命中 → 偏移反查 → 合并 bbox
function locate(hit, corpus) {
const { lines, starts } = corpus;
const a = upperBound(starts, hit.start); // 起止落在哪一行
const b = upperBound(starts, hit.end - 1);
const la = lines[a], lb = lines[b];
// 跨行/跨块时取外接矩形,保证黑条能盖住整段
const left = Math.min(la.x, lb.x);
const top = la.y;
const right = Math.max(la.x + la.w, lb.x + lb.w);
const bottom = lb.y + lb.h;
return {
bbox: { x: left, y: top, w: right - left, h: bottom - top },
lineFrom: a, lineTo: b, // 用于"点击跳回原位"
span: b - a + 1, // 跨了几行,>1 说明是合并命中
};
}
function upperBound(arr, v) { // 二分:最后一个 <= v 的下标
let lo = 0, hi = arr.length - 1, ans = 0;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (arr[mid] <= v) { ans = mid; lo = mid + 1; } else hi = mid - 1;
}
return ans;
}
为什么用二分而不是遍历? 简历类文档有 46 个元素、数百行文本,每个实体都要反查一次,线性扫描是 O(n·m);二分把每次反查压到 O(log n),实测三种文档的引擎耗时都在 0.18~0.53ms,完全够用。代价是什么? 合并出来的框是"外接矩形",如果两行在纸上并不相邻(比如跨栏),框会偏大。我的取舍是:宁可框大一点,也不能框不住------因为用户点"定位"时,看到的是"这两行都要遮"。

图2:行级正则漏检与合并命中的差别
五、坐标怎么落到页面上
后端给出百分比 bbox 之后,前端只剩一件事:让框和纸对齐。这里最容易翻车的地方是容器尺寸------图片有原始宽高、有 object-fit、还可能被响应式缩放。
javascript
// 渲染热区:百分比定位,容器保证与图片同比例
function renderBoxes(root, boxes, entities) {
const frag = document.createDocumentFragment();
entities.forEach((e, i) => {
const b = e.bbox;
if (!b) return;
const d = document.createElement('div');
d.className = `box lv-${e.risk}`; // critical / high / mid / low
d.style.cssText = `left:${b.x}%;top:${b.y}%;width:${b.w}%;height:${b.h}%`;
d.dataset.i = i;
d.title = `${e.label}:${e.value}`;
frag.appendChild(d);
});
root.appendChild(frag);
}
// 点击右侧条目 → 滚到对应热区并高亮
function focusEntity(i) {
const el = document.querySelector(`.box[data-i="${i}"]`);
if (!el) return;
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
el.classList.add('pulse');
setTimeout(() => el.classList.remove('pulse'), 1200);
}
css
/* 容器必须是"图片的等比盒子",否则百分比会错位 */
.doc-inner { position: relative; line-height: 0; }
.doc-inner img { display: block; width: 100%; height: auto; }
.box { position: absolute; border: 1.5px solid var(--red);
background: rgba(255,69,58,.10); cursor: pointer; }
.doc.masked .box { background: transparent; border-color: transparent; }
.doc.masked .box.lv-critical,
.doc.masked .box.lv-high {
background: #05060a; border-color: #05060a; box-shadow: 0 0 0 4px #05060a;
}
这两段合起来才是"可点击定位"的完整实现。 要点有三个:① 容器 .doc-inner 必须 position:relative 且高度跟随图片,这样百分比才有参照;② 热区写成百分比而不是像素,换屏幕、缩放、甚至导出海报都不用重算;③ 脱敏模式不是"删掉框",而是把高危框填成黑块------因为脱敏要遮的是原图,不是遮掉我的标注。
最后那条 box-shadow: 0 0 0 4px 是踩坑换来的:黑条之间会露出 1px 的图片缝,肉眼几乎看不见,但截图放大后能读出半个字。加了外扩阴影之后,缝隙才真正消失。

图3:从块坐标到脱敏黑条的四层映射
六、规则怎么分层,才不会一天到晚误报
21 类规则我分成两层,理由不是"好写",而是证据强度不同:
- 第一层:19 条正则 。手机号、身份证、运单号、卡尾号、金额、时间戳等。特征是自证------号码本身就是证据,不需要上下文。
- 第二层:2 条行级规则 。地址与城市。特征是依赖上下文 ------
杭州仓是不是地址,取决于它后面有没有门牌、前面有没有省市区链。
分层的收益在实测里很直接。第一版我把所有东西都用正则硬怼,两份样本立刻翻车:
| 误判 | 现象 | 根因 | 修正 |
|---|---|---|---|
杭州仓 被判为地址 |
小票上出现一条"详细地址" | 正则只匹配了城市名 | 地址改为行级规则,要求同时出现省市级联关键词或门牌号 |
| 8 位会员号被判为银行卡 | 简历/小票各多一条 critical | 银行卡规则缺长度门槛 | 加 length >= 16 前置校验 |
| 地址与小区重复上报 | 同一位置两条结论 | 两条规则命中同一段 | 上报前做包含关系去重 |
javascript
// 分层判定:正则层自证,行级层依赖上下文
const RULES = [
{ type: 'phone', re: /1[3-9]\d{9}/g, risk: 'critical' },
{ type: 'trackno', re: /(?:SF|JD|YT|ZTO|STO)[\s-]?\d{3,8}[\s-]?\d{3,8}/gi, risk: 'high' },
{ type: 'bankcard', re: /\b\d{16,19}\b/g, risk: 'critical',
guard: (s) => s.replace(/\D/g, '').length >= 16 }, // 长度门槛,压掉会员号
{ type: 'idcard', re: /\b\d{17}[\dXx]\b/g, risk: 'critical' },
];
function extractEntities(corpus) {
const out = [];
for (const rule of RULES) {
rule.re.lastIndex = 0;
let m;
while ((m = rule.re.exec(corpus.full))) {
const value = m[0];
if (rule.guard && !rule.guard(value)) continue; // 误报闸门
const pos = locate({ start: m.index, end: m.index + value.length }, corpus);
out.push({ type: rule.type, value, risk: rule.risk, ...pos });
}
}
// 地址:只在行维度判定,要求"省市级联 + 门牌"同时成立
corpus.lines.forEach((L, i) => {
if (!/(省|市|区|县|路|街|道)/.test(L.text)) return;
if (!/(\d+\s*(号|室|栋|幢|单元|楼))/.test(L.text)) return;
out.push({ type: 'address', value: L.text, risk: 'critical', bbox: L, lineFrom: i, lineTo: i });
});
return dedupe(out);
}
这段的结构值得看一眼:guard 是误报闸门。 每条规则可以带一个校验函数,先匹配再校验,两步都过才上报。这样做的好处是规则可以放宽(比如运单号前缀枚举不全时也敢匹配),把精度压力交给 guard。
dedupe 处理的是另一类问题:地址行里往往同时包含小区名和门牌号,两条规则会命中同一段文本。我的策略是长值优先 + 包含关系去重------如果 A 的值包含 B 的值,只保留 A,避免同一位置冒出两条结论稀释可信度。
七、从取证到"体感":侧写与冒充模拟
如果只输出一张"检测到 6 项敏感信息"的清单,用户看完就关掉了。真正让人后背发凉的是下一步:用这些实体拼一段骗子会说的话。
javascript
// 用抽出的实体生成"第一句话",模板按文档类型切换
function buildAttack(ents, doc, corpus) {
const g = (t) => (ents.find((e) => e.type === t) || {}).value || '';
const name = g('name'), phone = g('phone');
const track = g('trackno'), addr = g('address');
const beh = ents.filter((e) => e.type === 'behavior').map((e) => e.value);
const day = (g('datetime') || '').slice(0, 10);
if (!phone && !addr && !track) return null;
const org = (g('company') || '').replace(/股份有限公司|有限公司/g, '') || '顺捷速运';
const lines = [];
lines.push(`「请问是${name || '您'}吗?我是${org}的客服,占用您一分钟------」`);
if (day || track) lines.push(`「${day} 那笔,单号 ${track} 的件,我们已经按您留言办了。」`);
if (beh.length) lines.push(`「您留言说「${beh.join(',')}」,对吧?」`);
if (addr) lines.push(`「地址是${addr},我核对一下。」`);
lines.push('「现在系统显示这单异常,需要您做一次人脸核验,很快的------」');
return { lines, seals: ['手机号', '姓名', '单号', '备注习惯', '地址'].filter(Boolean) };
}
它的价值不在"生成",而在"归因"。 每一句话都必须能指回纸上的一处证据,所以返回值里带 seals,前端据此把对应实体高亮成红色------用户看到的是"这句话里的每个字都来自我这张纸"。这也是我不敢用大模型自由发挥的原因:模型会编,而编出来的话术会让整个作品失去说服力。
同样的机制换一套模板:小票走"门店退款",简历走"HR 加微信"。切换依据不是文档名,而是抽到的实体类型组合------有 trackno 就是物流,有 company 且无 trackno 就是简历。
八、实测:三份样本与前后对比
| 样本 | 版面元素 | 实体 | 高危项 | 侧写 | 可定位框 | 暴露指数 |
|---|---|---|---|---|---|---|
| 顺捷速运面单 | 17 | 16 | 10 | 7 条 | 16/16 | 96 · 极度危险 |
| 邻家便利小票(23:47) | 11 | 8 | 5 | 6 条 | 8/8 | 68 · 高度暴露 |
| 求职简历 | 46 | 18 | 4 | 6 条 | 18/18 | 76 · 高度暴露 |
三份样本合计 42 个实体 ,逐个与原文人工核对:误报 0、漏报 0,100% 带可点击定位框。这不是"分类准确率",而是"每一条结论都能在纸上指出位置"的可核查性------这两件事在工程上差别很大。
耗时方面,单张图片端到端(上传 + 解析 + 落盘)3.343s ,取证引擎 0.18~0.53ms。也就是说瓶颈完全在网络解析,不在规则,这决定了架构上没必要上缓存或异步队列------对个人自查这种低频场景,同步返回反而体验最好。
取证引擎 TextIn xParse Node 服务 浏览器 取证引擎 TextIn xParse Node 服务 浏览器 #mermaid-svg-E8QNnwOg0FKCveiD{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-E8QNnwOg0FKCveiD .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-E8QNnwOg0FKCveiD .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-E8QNnwOg0FKCveiD .error-icon{fill:#552222;}#mermaid-svg-E8QNnwOg0FKCveiD .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-E8QNnwOg0FKCveiD .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-E8QNnwOg0FKCveiD .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-E8QNnwOg0FKCveiD .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-E8QNnwOg0FKCveiD .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-E8QNnwOg0FKCveiD .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-E8QNnwOg0FKCveiD .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-E8QNnwOg0FKCveiD .marker{fill:#333333;stroke:#333333;}#mermaid-svg-E8QNnwOg0FKCveiD .marker.cross{stroke:#333333;}#mermaid-svg-E8QNnwOg0FKCveiD svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-E8QNnwOg0FKCveiD p{margin:0;}#mermaid-svg-E8QNnwOg0FKCveiD .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-E8QNnwOg0FKCveiD text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-E8QNnwOg0FKCveiD .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-E8QNnwOg0FKCveiD .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-E8QNnwOg0FKCveiD .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-E8QNnwOg0FKCveiD .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-E8QNnwOg0FKCveiD #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-E8QNnwOg0FKCveiD .sequenceNumber{fill:white;}#mermaid-svg-E8QNnwOg0FKCveiD #sequencenumber{fill:#333;}#mermaid-svg-E8QNnwOg0FKCveiD #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-E8QNnwOg0FKCveiD .messageText{fill:#333;stroke:none;}#mermaid-svg-E8QNnwOg0FKCveiD .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-E8QNnwOg0FKCveiD .labelText,#mermaid-svg-E8QNnwOg0FKCveiD .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-E8QNnwOg0FKCveiD .loopText,#mermaid-svg-E8QNnwOg0FKCveiD .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-E8QNnwOg0FKCveiD .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-E8QNnwOg0FKCveiD .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-E8QNnwOg0FKCveiD .noteText,#mermaid-svg-E8QNnwOg0FKCveiD .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-E8QNnwOg0FKCveiD .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-E8QNnwOg0FKCveiD .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-E8QNnwOg0FKCveiD .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-E8QNnwOg0FKCveiD .actorPopupMenu{position:absolute;}#mermaid-svg-E8QNnwOg0FKCveiD .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-E8QNnwOg0FKCveiD .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-E8QNnwOg0FKCveiD .actor-man circle,#mermaid-svg-E8QNnwOg0FKCveiD line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-E8QNnwOg0FKCveiD :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} POST /api/parse(二进制流) pdf_to_markdown(app-id 在服务端) file_id + markdown + elements\[\] buildCorpus() → extractEntities() 实体 + bbox + 侧写 报告 JSON(3.34s) 渲染热区 / 脱敏预览 / 揭露卡
前后对比更有说服力:
| 维度 | 纯 OCR 文本 / 人眼看 | 用 xParse 版面重建 |
|---|---|---|
| 得到的东西 | 按顺序吐出的文字,表格结构丢失 | 带坐标的块 + 重建后的行 |
| 能否指出位置 | 不能,只能说"含手机号" | 42/42 可回到原图定位 |
| 跨单元格字段 | 手机号被切开时漏检 | 偏移映射合并后命中 |
| 脱敏 | 只替换文本,原图照旧泄露 | 按块坐标打黑条,可安全分享 |
| 单份耗时 | 人工抄录判风险 8~12 分钟(估算) | 端到端约 4 秒 |
| 发现项数 | 人眼通常只注意 3~4 项 | 固定输出 16/8/18 项 |
#mermaid-svg-xe6tAyfcmdDqT9Rh{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-xe6tAyfcmdDqT9Rh .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-xe6tAyfcmdDqT9Rh .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-xe6tAyfcmdDqT9Rh .error-icon{fill:#552222;}#mermaid-svg-xe6tAyfcmdDqT9Rh .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-xe6tAyfcmdDqT9Rh .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-xe6tAyfcmdDqT9Rh .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-xe6tAyfcmdDqT9Rh .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-xe6tAyfcmdDqT9Rh .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-xe6tAyfcmdDqT9Rh .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-xe6tAyfcmdDqT9Rh .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-xe6tAyfcmdDqT9Rh .marker{fill:#333333;stroke:#333333;}#mermaid-svg-xe6tAyfcmdDqT9Rh .marker.cross{stroke:#333333;}#mermaid-svg-xe6tAyfcmdDqT9Rh svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-xe6tAyfcmdDqT9Rh p{margin:0;}#mermaid-svg-xe6tAyfcmdDqT9Rh :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 三份文档的实体数与可定位率 面单 小票 简历 20 18 16 14 12 10 8 6 4 2 0 实体数
九、搭建过程:四个决定与它们的代价
技术选型讲完,说说真正耗时间的地方。这个作品从想法到上线不到一天,但有四个决定值得写下来,因为它们都不是"最优解",而是"权衡后的解"。
决定一:样本从哪来------不碰真实单据。
我没法拿真实面单做演示,那是把别人的隐私再公开一次。所以三份样本是按真实版式自建的 HTML,再用 Playwright 渲染成 PNG:面单做成三列表格 + 条码位 + 备注栏,小票刻意压低对比度、缩窄行距模拟热敏纸,简历做多级标题和分栏。
代价是"可控但不够脏"------真实照片会有折痕、反光、倾斜。我补了两个脏点:把地址写成跨行、把手机号拆到两个单元格,专门用来验证合并逻辑。如果你的目标是验证鲁棒性,自建样本永远替代不了真实分布,这点必须承认。
决定二:前端不引框架。
单页、无路由、无状态,用一个框架等于给部署加一条构建链。所以前端是原生 JS + Canvas,服务端是零依赖 Node HTTP。
代价是热区联动要自己写事件委托,脱敏切换要自己管 class。收益是部署只传文件、node server.js 就能跑,评委点开链接不会有白屏和 chunk 加载失败。
决定三:脱敏在前端做,不在后端重绘图片。
一条路是后端按坐标重绘一张打了黑块的图再返回;另一条是前端在原图上叠不透明块。我选后者:用户能实时看到"遮哪儿、还剩什么",而且不用把原图二次上传一趟。
代价是"导出的图"本质是屏幕截图,坐标一旦被 CSS 破坏就全错------这也是我前面反复强调容器必须等比例的原因。为了让分享图能脱离浏览器,揭露卡我用 Canvas 重新画了一张 900×1200 的海报,而不是截 DOM。
决定四:不接大模型。
侧写和话术都能交给模型生成,效果会更"像人"。但我算了一下:一段编造的诈骗话术,会让整个作品的证据链失效------用户一旦发现"这句话不是我纸上写的",前面 21 类规则的可信度就一起没了。
所以规则引擎扛下全部推理,输出永远是"结论 + 证据 + 置信度"。代价很明确:召回上限由规则决定,遇到没见过的字段会直接标"未命中"。
#mermaid-svg-YZlglL42bSXZr3E9{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-YZlglL42bSXZr3E9 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-YZlglL42bSXZr3E9 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-YZlglL42bSXZr3E9 .error-icon{fill:#552222;}#mermaid-svg-YZlglL42bSXZr3E9 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-YZlglL42bSXZr3E9 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-YZlglL42bSXZr3E9 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-YZlglL42bSXZr3E9 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-YZlglL42bSXZr3E9 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-YZlglL42bSXZr3E9 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-YZlglL42bSXZr3E9 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-YZlglL42bSXZr3E9 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-YZlglL42bSXZr3E9 .marker.cross{stroke:#333333;}#mermaid-svg-YZlglL42bSXZr3E9 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-YZlglL42bSXZr3E9 p{margin:0;}#mermaid-svg-YZlglL42bSXZr3E9 defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-YZlglL42bSXZr3E9 g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-YZlglL42bSXZr3E9 g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-YZlglL42bSXZr3E9 g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-YZlglL42bSXZr3E9 g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-YZlglL42bSXZr3E9 g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-YZlglL42bSXZr3E9 .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-YZlglL42bSXZr3E9 .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-YZlglL42bSXZr3E9 .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-YZlglL42bSXZr3E9 .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-YZlglL42bSXZr3E9 .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-YZlglL42bSXZr3E9 .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-YZlglL42bSXZr3E9 .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-YZlglL42bSXZr3E9 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-YZlglL42bSXZr3E9 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-YZlglL42bSXZr3E9 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-YZlglL42bSXZr3E9 .edgeLabel .label text{fill:#333;}#mermaid-svg-YZlglL42bSXZr3E9 .label div .edgeLabel{color:#333;}#mermaid-svg-YZlglL42bSXZr3E9 .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-YZlglL42bSXZr3E9 .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-YZlglL42bSXZr3E9 .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-YZlglL42bSXZr3E9 .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-YZlglL42bSXZr3E9 .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-YZlglL42bSXZr3E9 .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-YZlglL42bSXZr3E9 .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-YZlglL42bSXZr3E9 #statediagram-barbEnd{fill:#333333;}#mermaid-svg-YZlglL42bSXZr3E9 .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-YZlglL42bSXZr3E9 .cluster-label,#mermaid-svg-YZlglL42bSXZr3E9 .nodeLabel{color:#131300;}#mermaid-svg-YZlglL42bSXZr3E9 .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-YZlglL42bSXZr3E9 .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-YZlglL42bSXZr3E9 .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-YZlglL42bSXZr3E9 .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-YZlglL42bSXZr3E9 .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-YZlglL42bSXZr3E9 .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-YZlglL42bSXZr3E9 .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-YZlglL42bSXZr3E9 .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-YZlglL42bSXZr3E9 .note-edge{stroke-dasharray:5;}#mermaid-svg-YZlglL42bSXZr3E9 .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-YZlglL42bSXZr3E9 .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-YZlglL42bSXZr3E9 .statediagram-note text{fill:black;}#mermaid-svg-YZlglL42bSXZr3E9 .statediagram-note .nodeLabel{color:black;}#mermaid-svg-YZlglL42bSXZr3E9 .statediagram .edgeLabel{color:red;}#mermaid-svg-YZlglL42bSXZr3E9 #dependencyStart,#mermaid-svg-YZlglL42bSXZr3E9 #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-YZlglL42bSXZr3E9 .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-YZlglL42bSXZr3E9 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 自建虚构版式
xParse 真实解析
三份样本回放
误判暴露
42 实体全可定位
揭露卡海报
选题
样本
接入
引擎
修规则
上线
传播
踩坑的顺序基本就是这张图:样本做好→接 xParse→回放发现误判→改规则→再回放。真正卡住我的是"地址"那条规则,前后改了四版才把 杭州仓 这类噪音压掉。
用起来的真实感受
上线后我自己先用了一周,有三个体感值得记下来,因为它们都推翻了我原本的设计。
第一,三秒是很长的等待。 引擎跑完只要 0.18ms,可 xParse 端到端是 3.34s。我自己测的时候觉得"挺快",但让没用过的人试,第一句话是"卡住了吗"。所以我加了逐条推进的日志------"上传中 → 解析中 → 重建语料 → 规则取证",让等待有进度。性能数字是给工程师看的,进度条才是给用户看的。
第二,没人看分数,都去点红框。 我原本以为暴露指数 96 是主角,结果所有人第一动作都是点红框,看它跳回原图上哪一行。只有一个朋友问"为什么是 96 不是 90"。这让我把热区从"装饰"改成"主交互"------点击定位、hover 显示风险原因,都是后加的。
第三,也是最扎心的一点:反应是"好玩",不是"害怕"。 有几个人的第一反应是截图发群里说"这也太准了"。这说明威胁感还不够------他们没意识到这张纸此刻可能正躺在楼道垃圾桶里。所以最后一版把"骗子开场白"从附加栏目提到了主流程末尾:数字让人点头,那句话让人后背发凉。
决定四的代价:不接大模型,话术是模板拼的,所以措辞偏硬,遇到格式很怪的简历会显得生硬。换来的好处是同一份文档每次生成的话术完全一致------证据链不能有随机性。
十、适用边界与已知缺陷
这一节我写得比较克制,因为边界就是作品的诚实度:
- 版面框是块级的,不是字级的。 表格内多行共用同一块坐标时,行级切片是近似值;它不影响实体判定,只影响框的精确高度。若要字级精度,得用
--include-char-details拿字符级坐标,代价是返回体大几倍。 - 跨栏合并会放大外接矩形。 两行在纸上并不相邻时,框会覆盖中间区域,截图里会显得"遮多了"。
- 侧写是规则引擎,不是大模型。 好处是可解释、可复现、离线;坏处是对非常规文档召回有限,遇到没见过的字段会明确标"未命中",不会编。
- 只覆盖三类高频纸。 合同、发票、工单的版式差异大,规则需要另行扩展,不能直接套用。
- ⚠️ 不要用它处理真实敏感文件做公开演示。 我的三份样本全部为虚构数据,按真实版式自建;真实面单请只在本机使用。

图4:脱敏前后:同一套坐标,两种用途
十一、总结
回到开头那个问题:为什么"识别出文字"不够?
因为当用户拿着手机准备把一张纸发出去时,他要的不是数据,是判断 和动作------这张纸暴露了我什么、在哪一行、我该遮哪里。三件事分别对应块坐标、偏移反查、坐标复用,缺一条链路就断。
工程上真正有复用价值的,是三个模式:
- 语料重建 + 偏移映射:把"有坐标的结构化块"变成"可回查的长文本",这是所有需要定位的解析任务的地基。任何票据、合同、工单场景都能直接抄这段。
guard误报闸门:规则可以放宽,精度交给二次校验,比死抠正则好维护得多。- 一份坐标多处复用:热区、脱敏、导出海报共用同一套百分比矩形,改动只在一个地方发生。
再补一句边界:这套方案不适合字级精修、不适合版式完全未知的文档、也不适合当作安全产品承诺"100% 不泄露"------它做的是"让你在发出去之前看一眼",不是"替你保证安全"。
如果你想自己试:线上版在 https://papertrace.app.workbuddy.host/?sharecode=RHEHH2RDZE ,打开就有三份样本,不用注册;报告底部可以展开 xParse 的原始 markdown 和 file_id 自行核对。代码结构是零依赖 Node + 原生 JS,一个 server.js 加一个 733 行的取证引擎,照着本文的顺序读一遍就能改造成自己的场景。
参考资料
- TextIn xParse 产品与接口文档:https://docs.textin.com/api/pdf-to-markdown
- TextIn 开放平台控制台(获取 app-id / secret-code):https://www.textin.com/
- MDN:CSS 定位与百分比尺寸
position: absolute:https://developer.mozilla.org/zh-CN/docs/Web/CSS/position - MDN:
Array.prototype.sort稳定性与比较函数:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Array/sort - Mermaid 官方文档(flowchart / sequenceDiagram / xychart):https://mermaid.js.org/intro/
#TextIn #AI数据层基础设施