关于 truecopy
truecopy 是用于从文档提取表格行并提示读取风险的 TypeScript 库和命令行工具,面向需要检查和验证提取数据的开发者,避免将看似合理的错误结果直接当真。
主要功能
- 01读取 PDF、Word、ODT、CSV、TSV 和粘贴文本中的表格
- 02报告读取警告和结构化发现
- 03说明检测到的列边界与文档布局
- 04追溯单元格在页面中的位置
- 05使用格式解析数字和日期
- 06按 schema 或 Standard Schema 验证行
适用场景
- 01从账单及其他文档提取行
- 02检查表格提取结果可能不可靠的原因
- 03将可疑单元格追溯到来源位置
- 04在后续使用前验证提取的记录
配置
本地服务的配置在各客户端中通用,复制后粘贴到客户端的 MCP 配置文件即可。
工具
4收录的工具列表 · 更新于 1 天前
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| from | integer | 否 | 要返回的首行,1 表示从开头开始。行号保持不变。 |
| path | string | 是 | 此计算机上文件的绝对路径。文件在此处读取,不会上传。 |
| limit | integer | 否 | 要返回的行数,最多 2000 行。默认值为 200。剩余内容会统计并标明,不会悄悄丢弃。 |
| maximumPages | integer | 否 | 要打开的页数。默认值为 40,这用于限制失控的读取量,并不代表文档的页数:年报请调高此值。 |
详细介绍
truecopy
使用 TypeScript 从 PDF 中提取表格,并判断提取结果是否有误。
将文档转换为行,并拒绝无法信赖的读取结果。PDF、Word 或 OpenDocument 文件、粘贴内容、CSV、OCR 页面:一个引擎即可读取所有这些内容。
如果解析器从误读的文档中返回看似可信的表格,这比什么都不返回更糟,因为没人会检查看起来正确的表格。truecopy 是围绕你编写的任意读取器构建的流水线:输入字节的入口、能发现错误读取的检查、让你看见它做出何种判断的方式,以及一个一致性测试套件——一旦读取结果不再可靠,它就会亮红灯。
现在就用最糟糕的文档试试
npx -y -p truecopy -p pdfjs-dist truecopy a-statement.pdf
无需安装、无需项目、无需编写代码。命令中会指定引擎,因为它是一个可选的同级依赖,npm 不会自行获取它:只读取粘贴内容或 CSV 的项目永远不必下载 PDF 读取器,而对于这些文件,npx truecopy the-file.csv 就是完整命令。若对 PDF 漏装了引擎,它会提示 no-engine,而不是胡乱猜测。它会输出切分结果、每列的内容、填充频率,最后还会自行指出哪些内容无法确认。
page 1 - cut at 68, 229, 356, 411, 490
what this reading cannot vouch for:
- column 5 of page 1 is filled on only 2% of its rows - the cut may have invented it
- the pages disagree on how many columns there are (6 on 2, 7 on 1)
npm install truecopy
两行代码,搞定行数据
import { readTable } from 'truecopy';
const { rows, warnings, findings } = await readTable(file);
// rows [['02/05/2026', 'CARTE AMAZON', '12,40'], …]
// warnings ['column 3 of page 2 is filled on only 8% of its rows - the cut may have invented it']
// findings [{ code: 'thin-column', page: 2, column: 3, shareFilled: 0.08, message: '…' }]
没有 kindOf,没有阈值,也没有角色。如果你只想要单元格,解构出 rows 就行。
file 就是被拖入的文件。切分方式不在乎页面是用哪把尺子量的,所以同样两行代码既能读取 PDF,也能读取从 PDF 中粘贴出来的表格、CSV 和 TSV:
| 拖入的内容 | 列的起始位置 |
|---|---|
| 项目的 x 坐标,单位为点 | |
| 用空格粘贴的表格 | 字段起始的字符位置 |
| 使用分隔符写入的文件 | 字段的索引:CSV 不会对齐各行 |
| 文本,或字段带引号的文件 | 没有:整行原样返回,并在 warnings 中说明 |
最后一行是规则,不是例外。带引号的 CSV 字段可能包含分隔符,因此即使如此仍按分隔符切分,也会让后面的每一列都错位。truecopy 会原样返回未切分的行,因为避免半吊子解析正是这个库存在的意义。
explainDocument 会说明使用了哪把尺子,因此切分结果绝不会只是一串数字:页面上是 cut at 100, 290,粘贴内容上是 cut at characters 7, 22,CSV 上是 cut on the delimiter,因为列本身就是字段,字段索引毫无意义。
第二个字段正是它不只是另一个提取器的原因。warnings 为空,不代表读取结果正确:这只表示页面的形态看起来没有问题,这是一个范围小得多的结论。每条警告都是在不了解你的文档内容的情况下计算出来的:完全没有列的页面、几乎总是空白的列、切分方式各不相同的页面,以及夹在中间的空白页。
findings 是同一份列表,只是增加了 code,以及对应的页面和列。人读句子;程序根据代码分支,绝不依赖可能在明天被改写的英文文本。
当行数据必须可信(有下游操作会依据它们执行)时,这一页剩余部分就是告诉你该怎么做。
一个完整可运行的读取器,单文件,只需一条命令:
node example/read-a-statement.mjs
example/read-a-statement.mjs 会生成一个真实的 PDF,打开它,打印读取器的判断结果,并通过契约验证。里面没有任何桩代码,你也不需要提供文档:它会自行生成。
查看读取器的判断结果
读取结果有误时,你首先需要的不是更好的解析器,而是弄清楚三件事中究竟是哪一件出了问题:文档本身很特别、列切分错误,还是你的规则有误。每一种情况都需要不同的修正方法。
import { openDocument, explainDocument } from 'truecopy';
const document = await openDocument(file);
console.log(
explainDocument(document, {
signature: {
kindOf: (cell) => (isDate(cell) ? 'date' : isAmount(cell) ? 'amount' : 'text'),
thresholds: {
date: { share: 0.6, emptyIsAnomalyAbove: 0.7 },
amount: { share: 0.6 },
text: { share: 0.6 }
}
},
roles: [
{ role: 'when', kind: 'date', minimum: 0.6, take: 'best' },
{ role: 'money', kind: 'amount', minimum: 0.6, take: 'each' }
]
})
);
truecopy - statement-2026-05.pdf
pdf, 1 page(s), 6 row(s)
page 1 - cut at 100, 290
3 column(s), 6 row(s)
col kind filled role
0 date 83% when
1 text 100% -
2 amount 100% money
1 02/05/2026 | CARTE 28/04 AMAZON EU> | 12,40
2 03/05/2026 | VIR SEPA LOYER MAI | 750,00
3 05/05/2026 | CARTE 03/05 SNCF CONN> | 68,00
4 09/05/2026 | PRLV EDF | 91,32
5 12/05/2026 | CARTE 11/05 LE BOULAN> | 7,90
6 ! | TOTAL DES DEBITS | 929,62 <- column 0 (date) is empty
这是文本,不是图像:可以放进终端、CI 日志、错误报告和测试的预期值中。不需要画布,也不需要无头浏览器。
无需按发行方配置
读取表格的常见做法是不断调整设置,直到某个发行方的版式读取正确。这样做确实有效,但遇到第二个发行方就不行了:每多一家银行、每换一种格式、每次重新设计,都要再加一组只有作者自己看得懂的设置。
truecopy 会从表格本身了解表格。满是日期的列就是日期列,无论表头写的是什么;与其他所有行都不一样的行,则是总计、余额或页脚。
import { findRowAnomalies } from 'truecopy/signature';
// Which rows fall outside the signature the table itself sets.
const anomalies = findRowAnomalies(rows, signature);
它不会读取标签,因此能适应你从未见过的发行方。禁止词列表只能识别昨天的文档。
经过测量,能力止步于此
没有标注的是判断:哪些行属于表格,哪一列对应什么内容。划分边界是另一个问题,也是仍需帮助的地方。
在一份真实的银行对账单上进行测量时,我们特意隐藏了表头标签:按整页的 x 坐标分布来划分,提出了十二列,而表格实际只有五列,因为它把信头、地址块和页脚也当作判断列起点的依据。改为根据形状相同的行来划分(包含日期和金额的行),则提出了五列,并且仅凭内容就命名了五种角色中的三种。
boundariesFromRecurrence 是通用的解决方案,readTable 也使用它:只保留在每一行中反复出现的 x 坐标,因为真实列的左边缘会反复出现,而描述中的某个词不会。对于上面的对账单,它将列数从十二列缩减到七列,并将其余列标记为窄列。如果页面上有更可靠的依据(例如表头行或线条),则使用 boundariesFromAnchors。
无论如何,请保留自检功能。在同一份对账单上,读取结果仍然是错误的,而之所以能发现这一点,唯一的原因是各行金额之和与文档本身声明的余额不符。
**这就是这个库的真实定位。**它不保证能正确读取未知版式,而是保证能告诉你它何时没有读对。
每个值都知道自己的来源
无法指出出处的值,就无人能够核验。给某人一串数字,他就得重新在文档中搜寻;给他一个位置,他就能直接跳转到那里。这种差别决定了修正界面会被使用还是被弃用。
import { cellsOf, placesOf } from 'truecopy/layout';
const cells = cellsOf(page); // cells[3][1] is what the fourth row holds
const places = placesOf(page); // places[3][1] is where to find it
// { page: 1, x: 430, y: 700, width: 30 }
两者的布局完全相同,因此可以一起读取。没有内容落入的单元格没有位置:值为 null,而不是一个空矩形。ReviewableRow.where 为要求用户确认的行携带同样的信息。
没有高度,因为无法得知:这里的文本项不包含高度,凭空编造行距只会画出文档中本不存在的矩形。
表记方式不等于领域知识
1 234,50 和 1,234.50 表示相同数量,(123,45) 和 123,45- 都表示负数,25/01 在这里是日/月,在那里则是月/日:这些都与银行或发票无关。它们描述的是页面的排版方式,也是每个人都会重写、也都会搞错一次的部分。
import { readNumber, findNumbers, readLeadingDate } from 'truecopy/notation';
readNumber('27800.50'); // 27800.5 - not 2 780 050
readNumber('(123,45)'); // -123.45 - the accounting negative
readNumber("1'234.56"); // 1234.56
// Never starts a match inside another number: a date glued to a figure
// reads as one figure, not as "026 300,00".
findNumbers('30/05/2026 300,00', 2); // [{ value: 300, ... }]
readLeadingDate('25 janv. 2026 GROCERIES', { dateOrder: 'DMY', months });
// { date, length: 13 } - so the caller strips it and keeps the wording
它返回 null,而不是猜测。对于会被人据以采取行动的数字,拒绝作答只需更正,而错误读取则会导致决策失误。
这些功能都不需要 PDF。truecopy/notation 处理字符串,因此 API 字段、HTML 抓取内容或粘贴的邮件都能像页面一样读取,而且不会加载 pdfjs-dist。在编写自己的模式之前,值得先使用这一层——因为每个人都会改为编写的模式,都有一个无人察觉的缺陷:
import { accentFree } from 'truecopy/notation';
// `\w` is [A-Za-z0-9_] in JavaScript, whatever the document is written in.
/d[ée]tach\w+/.test('détaché'); // false - the suffix is an accented letter
/d[ée]tach\p{L}*/u.test('détaché'); // true
accentFree('Détaché'); // 'detache' - or fold first and compare plainly
这里不会发生任何失败:模式能够编译、运行,并返回一部分看似合理的匹配结果,随后人们会把它当作关于文档的事实,而不是规则本身的缺陷。
用自己的语言说明拒绝原因
try {
await openDocument(file);
} catch (error) {
if (error instanceof UnreadableDocument) {
// 'empty' | 'too-big' | 'no-text' | 'too-slow' | 'no-engine' | 'not-opened'
showInYourOwnWords(error.reason);
}
}
原因有名称,因此句子不必固定。只提供英文说明的库,会迫使使用其他语言的应用程序自行维护一份拒绝提示。
no-engine 和 not-opened 被有意区分,原因并非表面上的差异:前者表示_安装 pdfjs-dist,后者表示_无法打开此文件,请检查是否有密码。如果告知了错误的原因,用户会去寻找并不存在的锁,而 Agent 则会声称文档受到了保护。pdfjs-dist 是一个对等依赖项,因此只读取粘贴内容和 CSV 的项目不会下载永远用不到的 PDF 引擎。
一次声明,两种输出
只需编写一次 schema,就能同时获得校验逻辑和记录类型。一个结构声明两次就会发生偏差,而悄然偏离的总是校验逻辑这一半。
import { schemaOf, validate, type RecordOf } from 'truecopy/schema';
export const CAREER = schemaOf({
name: 'career-record',
fields: {
year: { format: 'year', minimum: 1930, maximum: 2030, required: true },
quarters: { format: 'integer', minimum: 0, maximum: 4, required: false },
pay: { format: 'number', minimum: 0, required: false }
},
minimumRecords: 5,
key: 'year'
});
export type CareerRow = RecordOf<typeof CAREER>;
// { year: number; quarters: number | null; pay: number | null }
const violation = validate(CAREER, rows);
// null, or { cause: 'too-few-records', conforming: 1, expected: 5 }
schema 是纯数据:没有访问器、闭包,也没有任何无法通过 JSON 保存的内容。它可以提供给他人、进行版本管理,由阅读文档的人加以完善,也可以在无需部署的情况下替换为适用于另一个市场的 schema。缺少什么会明确标出;应用会用自己的语气写出那句话。
已经在 Zod、Valibot 或 ArkType 中声明了该行类型? 不要重复声明。任何 Standard Schema 都适用,而 truecopy 还补上了逐条记录校验器无法获知的部分:一份文档中有多少条格式良好的记录。
import { validateWith } from 'truecopy/schema';
validateWith(myZodSchema, rows, { minimumRecords: 5, key: 'year' });
为什么拒绝很重要
研究领域从另一个方向得出了相同结论,并为之命名:经过校准的弃答。Zero Hallucination, by Construction(Raduta 等,2026)将其列为企业文档 AI 架构的六个层之一(“当依据不足时,系统会拒绝回答,而不是猜测”),并配以基于证据的置信度检查,对照源文档核验生成内容。这两者分别对应 refuse 和 selfCheck,层层匹配。
它鲜少出现,原因并非技术问题:基准测试会奖励自信的猜测,却将弃答计为错误,因此整个系统都被调校成倾向猜测。确定性解析器没有理由也这样做。
读者遵循以下五条原则:
- 让文档自行校验。 银行对账单会列出
opening + Σ = closing;职业记录会注明季度总数。根据文档自身声明来核对读取结果,是唯一不依赖布局的校验方式。 - 拒绝也胜过返回看似合理的表格。 最严重的问题不是漏掉异常,而是误读文档后凭空捏造出二十二条异常。
- 计数不等于测量。 运行两次读取并保留“找到更多行”的结果,选出的总是更差的那个:切分不佳的读取器会把脚注当作数据。
- 读取器提出建议,由人来确认。 修正是产品的一部分,而不是故障后的补救路径。
- 每个量词都有边界。 这些模式会逐行处理整份文档。
由模型来读取时
将单元格转为记录的模型会以一种显而易见的方式失败——漏掉一条记录;也会以一种不易察觉的方式失败:返回一个文档中从未印出的合理值。根据邮政编码推断出的城镇、四舍五入后的数字、重新拼合的日期,每个看起来都像是成功提取的结果。这种失败有确定性的解决办法,而且不需要更好的模型:
模型永远不会看到 PDF。 它会收到这个库切分出的带编号行。它返回的记录会注明所依据的行号。随后,每个值都会且只会从这些行中查找——因此模型无法生成事实,只能指向文档中已有的事实。
import { numberedRows, citedText, carriesText, carriesNumber } from 'truecopy/cite';
const prompt = rules + '\n' + numberedRows(page); // what the model sees
const source = citedText(page, record.rows); // what it claims to have read
carriesText(source, record.town); // all its words, in order - never a substring
carriesNumber(source, record.surface, mark); // the page's numbers - never a digit soup
carriesNumber(source, record.postcode, mark, 'punctuation'); // brackets as asides, not as a sign
在采用这种方式之前,我们已在真实语料库中,将这两种查找方法与其朴素形式进行了测量。要求连续子串匹配的做法,拒绝了一份文档 111 条记录中的 104 条,因为布局会把姓名的尾部排到数字列之后——所有字词都按顺序出现,却从未连成一段。将源内容压平成一串数字,则会在一条根本不包含该数字的行中,找到一个出现了三次的表面匹配:这个防护在毫无保护作用的地方恰好显得牢不可破。
由你决定的是策略,而非机制:哪些字段是数字字段,哪个单一字段用于锚定记录,以及侧字段校验失败时应将其丢弃并计数,而不是视为致命错误。checkExtraction(truecopy/contract)负责算术校验这一半——将返回结果的总和与文档对自身声明的总计进行核对。
当值的生命周期超过其读取结果
离线读取一次文档,将其转化为值,再由网站提供数月,就无法针对每位读者重新运行提取。能证明这种值真实可靠的不是提取结果,而是句子:它没有被存储,而是在显示时通过保存在值旁边的短小 锚点,在文档中重新找到。文本不会被复制,因此值不会与之偏离;而文档修订的那一天,锚点就无法再定位,这种缺失便是警报。
import { anchoredPassage, missingFigures, proofSpan } from 'truecopy/anchor';
// stored once: { value: '9 m', anchor: 'hauteur maximale de 9' }
const passage = anchoredPassage(article, rule.anchor, { units: 'lines', depth: 'outline' });
// A null passage is the alarm: the document was revised since it was read.
if (passage === null) return null; // never a value without its sentence
const quoted = [...passage.heading, passage.line, ...passage.opened].join(' ');
missingFigures(quoted, rule.value, { decimal: mark }); // [] or the figures it does not carry
proofSpan(quoted, rule.value, { decimal: mark }); // how far a reader travels to the proof
单独引用一段文字很精确,却没有说明任何事情。 - under two years of service: one month 丢失了 - for technicians,这是规则的一半;也丢失了它上面的句子,这是另一半:单独引用时,它读起来像是适用于所有人的通知期限。因此,一段摘录应包含段落、它的所有上级内容,以及它引出的列表;三者之所以会分别呈现,是因为窄屏要省略其中哪些内容,属于显示决策。
而锚点仍能定位,只能证明段落存在,绝不能证明它说了什么。在一份规划规范中测得:一条显示为 35 m from the axis 的规则,旁边有一句关于同一条道路的话,写着 a minimum setback of 10 metres。missingFigures 会发现这一点;proofSpan 则决定读者能否在屏幕截断引用之前看到该数值。
让规则成为强制要求的工具包
可以用 return null 绕过接口,却绕不过断言。把工具包与自己的语料库一起接入门禁:
import { checkContract, contractReport, failures, pdfWithText } from 'truecopy/kit';
it('holds the reading contract', async () => {
const results = await checkContract(
myReader,
[
{ name: 'balances', document: aSoundDocument, expected: 'read' },
{ name: 'does not balance', document: aWrongDocument, expected: 'needs-review' }
],
{
referencePdf: pdfWithText([{ word: '2018', x: 50, y: 700 }]),
// YOUR door, not the kit's: what is measured is your reader,
// extraction included.
open: openLikeTheApp,
foreign: [{ name: 'a payslip', document: aPayslip }]
}
);
expect(failures(results)).toEqual([]);
writeFileSync('contract.txt', contractReport(results));
});
六条规则,逐条列出:上面的法则规定了读者会被告知什么;以下规则则由语料库从外部检查读者收到的内容。
- 语料库公布的判定,就是返回的判定。
- 与文档矛盾的读取结果绝不会被视为可靠。
- 所有读取内容都可由相关人员审查。
- 从字节到记录的处理链确实会运行,使用的是真实 PDF(
pdfWithText会生成一个包含 xref 表的 PDF)。 - 没有实质内容的文档会被拒绝,而不会悄然返回空结果。
- 属于另一种类型的文档会被拒绝:你要提供用于区分的语料库,因为只有你知道必须将它与什么区分开。
contractReport 会将运行结果写入一个可提交的文件。读取结果如果悄然从二十六条记录降到二十四条,所有规则都能通过,也不会触发任何断言;但 diff 会发现变化。
第五条法则(每个量词都有界限)没有纳入工具包,因为简单的源码扫描会产生误报。它由你自己的门禁中的 eslint-plugin-regexp 及其 no-super-linear-backtracking 和 no-super-linear-move 规则来保证。
三个方法,而非五个
读取器实现 read、selfCheck 和 rowsToReview。这三者承载了整套理念:工作本身、第一条法则,以及人在其中的位置。
repair 和 refuse 是可选的,而且它们的默认行为遵循默认值唯一允许的偏向:倾向于拒绝。repair 不会尝试修复,因为诚实地留有缺口胜过草草修补。refuse 会拒绝未生成任何记录的读取结果。
在任何操作开始前就要求实现五个方法,这会筑起一道墙;而接口前一旦有墙,就会有人在五处都写上 return null。kit.ts 正是为捕捉这种规避而存在的;如今最有可能写出这种代码的,是根据类型编写代码的模型。
各机制
每个机制也都是独立的入口,因此想要使用某个机制的项目就要为它付出代价。
| 模块 | truecopy/… | 功能 |
|---|---|---|
open.ts | open | 字节 → 页面 → 行:大小上限、页面上限、截止时间、释放引擎、可替换引擎 |
layout.ts | layout | 纯几何:行、列、单元格、位置;无引擎、无字节、无时钟 |
table.ts | table | readTable(file):两行路径,以及它不会保证的内容 |
notation.ts | notation | 页面如何写出数字和日期,而非它们的含义 |
classify.ts | classify | 这是否是预期类型的文档,包括优先级 |
columns.ts | columns | 每列包含什么,只计数一次 |
roles.ts | roles | 每列的作用是什么,根据其内容推断 |
records.ts | records | 哪些行属于同一条记录,以及何时一条打印行并不代表一条记录 |
labels.ts | labels | 哪些单元格可能是标签所指示的值,按距离从近到远排列 |
signature.ts | signature | 哪些行违反了表格自身的类型签名 |
schema.ts | schema | 阅读结果必须满足的字段、格式和数量 |
cite.ts | cite | 模型引用了哪些行,以及它们是否确实包含各个值 |
anchor.ts | anchor | 存储值来自哪段文本,以及如何在文档中重新找到该段文本 |
explain.ts | explain | 上述所有机制以文字形式作出的判定 |
pattern.ts | pattern | 作为数据的领域知识,在 ReDoS 防护下编译 |
contract.ts | contract | 诚实的阅读结果应是什么样 |
kit.ts | kit | 如何让它成为强制要求 |
优先级,以及为何要说明而非排序
银行对账单会在交易标签中引用“发票”一词。如果按错误的顺序阅读,它就会被归档为发票,而它包含的每笔操作都会随之离开预算范围。IBAN,或借方/贷方/余额这些词汇,会在查看“发票”一词之前就决定结果。
只有三个概念:模式集(包含其所有模式)、要求(任意一个模式集)、类型(包含其所有要求,首个匹配项优先)。absent 会反转一项要求,因此“仅当不存在 IBAN 时才是发票”会被明确说明,而不会暗中塞进一个另有含义的排序中。
它对你的文档一无所知
它从未听说过日期、金额、借方或季度。你指定单元格的类型和希望返回的角色;这个库负责计数、划分、比较,并指出哪条规则被违反。
这是有意为之。一个内置了这些含义的库,会迫使下一个项目用为别人设计的词汇来描述自己的文档。
所有因市场、发行方或文档系列而异的内容都是值,而不是分支;请参阅 pattern.ts。值可以由后端提供,由文档读取者完善,进行版本管理,并在无需部署的情况下替换为另一个市场的值。分支只能由能够修改代码的人更改。配置文件中包含正则表达式,而 JSON 不支持,因此它们会以 { source, flags } 的形式传递,并在此处重新构建;这恰好是风险所在,也是防护措施所在。无法通过校验的模式会被编译为一个永不匹配的模式,因此功能会降级,但页面仍能正常运行。
一个读取器,或两个
项目可以接入一个或两个读取器。对账单读取器可以在有坐标时运行一个基于位置的读取器,在没有坐标时运行一个基于行语法的读取器;而 OCR 页面恰好需要两者都运行,并采用信息更丰富的读取结果。如何让它们竞争由你决定;除此之外的一切都不是你的事。
readTable 是读取器,也是默认读取器,因此上手不费吹灰之力。它只是入门途径,并非产品本身。产品是处理管线,以及围绕最终执行工作的读取器所提供的保障。每种机制都单独开放,正是为了让你能逐步替换默认实现中的各个部分。
它永远不会做什么
没有明确边界的库,每次发布都会新增边界。以下是明确拒绝的事项,而非路线图。
- 它永远不会内置你文档的含义。 没有日期,没有金额,没有借方,没有季度,也没有发行方。领域知识以数据的形式传递(
pattern.ts、schemaOf),因为值可以进行版本管理并替换为另一个市场的值,而分支只能由能够修改代码的人更改。 - 它永远不会添加针对各发行方的配置。 通过不断调整设置让某家银行的文档能被正确读取,正是这个库旨在取代的做法。读取效果不佳的版式要么是缺陷,要么是有文档说明的限制,绝不会变成预设配置。
- 它永远不会变成 CSV 解析器。 它会按四种分隔符拆分,而且只在计数连续多行重复时才这样做。带引号的文件会连同警告一起原样返回,因为对带引号的 CSV 进行半吊子解析,恰恰会得到看似合理却错误的结果;这正是此处其他内容一直在反对的做法。请使用真正的 CSV 库;这个入口是为粘贴的内容、导出文件和 OCR 行准备的。
- 它永远不会变成校验库。
schemaOf用格式和文档记录数限制读取结果。更复杂的校验应交给 Zod 或 Valibot,而validateWith接受它们的校验器,不与之竞争。 - 它永远不会自带引擎。 PDF 通过
pdfjs-dist处理,它仍是一个可选的同级依赖;.docx和.odt是 XML 压缩包,因此会使用平台已有的功能读取,无需增加任何依赖。不支持 OCR 引擎、电子表格读取器或旧版.doc。其他格式则由你以字节或文本形式传入;无法读取的字节会按名称拒绝,而不会被解码成乱码。 - 它永远不会触及你未传入的文件系统、网络或时钟。 它运行在浏览器中。
bin/truecopy.mjs是唯一例外;它是一个命令,而不是库的一部分。 - 它永远不会增加第二种切分算法。 只使用一种方法判断哪些左边缘会重复,一套阈值,三把尺子。需要专属算法的格式应使用自己的库。
要求
Node 20+,ESM。pdfjs-dist 是可选的对等依赖,仅在需要时加载,且只用于 PDF:读取文本、粘贴内容或 CSV 都不需要它。
pdf.js 有意通过其 legacy 构建版本使用:现代构建版本拒绝在浏览器之外运行,这会导致整条链路无法测试。在 Node 中,不传入任何 engine,系统会为你导入该构建版本。两个使用方中的九个摄取脚本手动导入它并将其作为 pdfjs 传入,这样虽然可行,但多此一举;提供此选项是为了支持受字节预算限制的浏览器读取器,这是唯一值得单独导入现代构建版本的情况。
如果需要 worker,请自行解析其 URL 并传入,因为每个打包工具的处理方式都不同,在这里选定一种会让你受其限制:
import workerSrc from 'pdfjs-dist/legacy/build/pdf.worker.mjs?url'; // Vite
await openDocument(file, { workerSrc, maximumPages: 200 });
批处理脚本会提高限制,而且本就应该如此:DEFAULT_LIMITS(20 MB、40 页、30 秒)是为了保护浏览器标签页,而不是为了每年只运行一次的摄取任务。两个使用方读取年报时,选择了 80 到 200 MB、600 到 2000 页。优先使用 keepPage,而不是 maximumPages:它能从长文档的末尾截取内容。
无需安装任何东西,即可在助手中使用
能使用终端的 Agent 已经可以运行上述命令,能导入模块的 Agent 则可以使用这个库。但大多数助手都不是这两种情况:能与 MCP 通信的主机只能调用工具,没有其他能力。因此,同一条处理管道也提供了一个通过 stdio 运行的 MCP 服务,运行在文档所在的机器上。
{
"mcpServers": {
"truecopy": {
"command": "npx",
"args": ["-y", "-p", "truecopy", "-p", "pdfjs-dist", "truecopy-mcp"]
}
}
}
共四个工具,每个回答都以判定结果开头:reliable、doubtful 或 unreadable。unreadable 表示文件未能打开;doubtful 表示读取结果中至少有一处看起来不对,调查结果会说明具体问题和位置;reliable 表示从页面的形态来看没有发现异常,这不表示“读取结果正确”,回答会明确说明这一点。模型会先读取判定结果,再据此采用数值。
read_table将 PDF、CSV、TSV 或保存的粘贴内容转换为行:包括分隔方式、每列的填充频率、带编号的行,以及读取结果中无法保证的内容。每次调用最多处理 200 行;未处理的内容会计数,而不会被丢弃。find_amounts列出小数位数符合指定值的数值(默认两位,这正是页面区分金额与参考编号的方式),每个数值都附有打印出来的字符,以及可供引用的行和列。find_dates列出以日期开头的单元格,按你声明的日月顺序读取,绝不会自行猜测;没有年份的行只有在给出年份时才会被视为日期。check_citations接收模型声称已读取的记录,每条记录都注明来源行,并指出引用行中未打印出哪些值。JSON 数字会按文档自身的小数分隔符作为数值核对;JSON 字符串则按其原有词语顺序核对。它回答的是“这个内容是读取到的还是编造的”,绝不回答“内容是否完整”,并会明确说明这一点。read_table和check_citations会返回已读取字节的digest;将其传回后,如果文件在两次调用之间发生变化,后续调用就会拒绝处理该文件。
不会上传任何内容,因为根本没有可供上传的地方。 服务运行在文件所在的位置,不会发起网络调用,也不会打开未交给它的文件。对账单、工资单和职业记录是最不该发送到任何地方的文档,而服务在本地能对这些文档执行的计算,这个工具也都能完成。设置 TRUECOPY_MCP_ROOT 后,它不会打开该目录以外的任何内容;在检查之前会先解析符号链接。
它支持 2026-07-28 修订版及更早版本,因此,无论主机通过 initialize 启动,还是直接调用 server/discover,都能正常工作。npx -y -p truecopy -p pdfjs-dist truecopy mcp 会启动同一个服务,MCP 注册表中的 io.github.truecopydev/truecopy 条目正是这样命名它的。
如果你是被要求阅读文档的 AI
有人给了你一份声明、一张发票、一份导出文件,并要求你找出其中的某个数值。真正重要的失败,不是漏掉一个数值,而是返回一个文档根本没有打印出来的数值,而且看起来完全像是成功了。因此:
- 先调用
read_table,在做任何其他事情之前阅读 verdict。unreadable表示无从依据:请如实说明。doubtful表示有内容看起来不对;调查结果会指出页码和列,而其中的数值需要标记出来。reliable表示没有发现异常,这就是全部结论。 - 使用
find_amounts查找数值,使用find_dates查找日期,而不是从文字中读取:每项结果都会附带打印该内容的行。 - 在任何人依据你的值采取行动之前,先将对应行交给
check_citations核验。引用的行没有打印出来的值,就不是从这些行中读到的:请重新读取,或将其删除。 - 绝不填补缺失内容。缺少总计就是缺少总计,不是把各行相加的机会;文档是唯一的信息来源,核验也只会指向文档本身。
要通过更大的系统(例如你自己的服务端或其他传输方式)提供这些工具,请参阅 truecopy/mcp:TOOLS 是工具描述符,respond 则会在提供将路径转换为 File 的方式后,响应一条 JSON-RPC 消息。
给编码 Agent
这个库发布时,所有正在使用的模型都已经完成训练,因此模型若要基于它编写代码,除非拿到 API 信息,否则就会凭空编造。以下两种方式都与已安装版本匹配:
# already on disk once you install the package
cp -r node_modules/truecopy/skills/truecopy ~/.claude/skills/
# or, to follow the repository
claude plugin marketplace add truecopydev/truecopy
claude plugin install truecopy@truecopy
skills/truecopy/SKILL.md 是纯 Markdown 文件,除了 frontmatter 之外不包含 Claude 专属指令,因此也可作为任何会读取 AGENTS.md 的其他 Agent 的 AGENTS.md。如果 npm run skill 指向此包已不再提供的导出项,就会失败。
文档网站会在每个页面的相同路径下提供 Markdown 版本,扩展名为 .md;llms.txt 中链接的就是这些文件。
门禁
npm run gate
格式检查,不使用排版用短横线;Lint(零警告,sonarjs + regexp)、类型检查、构建、knip、jscpd、测试、覆盖率 100 %,以及只升不降的 ratchet。衡量他人的工具包,会先衡量自己。
每次推送和每个拉取请求都会在 CI 中运行相同的命令。如果它在你的机器上通过,在这里也会通过。
贡献
CONTRIBUTING.md:变更需要满足的条件,以及变更如何合并。发现某份文档内容有误?请提交 issue,并附上 npx truecopy 的输出,绝不要附上文档本身。
RELEASING.md:这里的版本号会对你产生哪些影响,又不会产生哪些影响。简而言之:发布只会更新补丁版本,第二位数字保持不变,而且绝不会撤回任何内容。
安全:SECURITY.md。这个库不会发起网络请求,不会访问文件系统,也不包含运行时依赖,因此攻击面很小且明确:具体说明见该文件。
许可
MIT © Florian Mousseau
评论
一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。