mcpField
T
truecopy

truecopy

本地服务
github.com生产力31 天前更新

关于 truecopy

truecopy 是用于从文档提取表格行并提示读取风险的 TypeScript 库和命令行工具,面向需要检查和验证提取数据的开发者,避免将看似合理的错误结果直接当真。

主要功能

  1. 01读取 PDF、Word、ODT、CSV、TSV 和粘贴文本中的表格
  2. 02报告读取警告和结构化发现
  3. 03说明检测到的列边界与文档布局
  4. 04追溯单元格在页面中的位置
  5. 05使用格式解析数字和日期
  6. 06按 schema 或 Standard Schema 验证行

适用场景

  1. 01从账单及其他文档提取行
  2. 02检查表格提取结果可能不可靠的原因
  3. 03将可疑单元格追溯到来源位置
  4. 04在后续使用前验证提取的记录

配置

本地服务的配置在各客户端中通用,复制后粘贴到客户端的 MCP 配置文件即可。

mcp.json · 通用配置
{
"mcpServers": {
"truecopydev-truecopy": {
"command": "npx",
"args": [
"-y",
"truecopy"
],
"env": {
"TRUECOPY_MCP_ROOT": "<TRUECOPY_MCP_ROOT>"
}
}
}
}
启动命令npx -y truecopy
需要配置的环境变量
TRUECOPY_MCP_ROOTA directory the server may not read outside of, symbolic links resolved. Unset, it reads what the account running it can read.

本地服务需要在你的电脑上安装运行环境(如 Node.js 18+)。各客户端的配置文件位置不同,请参考客户端文档。

安装包
npmtruecopy2.0.20

工具

4

收录的工具列表 · 更新于 1 天前

参数类型必填说明
frominteger否要返回的首行,1 表示从开头开始。行号保持不变。
pathstring是此计算机上文件的绝对路径。文件在此处读取,不会上传。
limitinteger否要返回的行数,最多 2000 行。默认值为 200。剩余内容会统计并标明,不会悄悄丢弃。
maximumPagesinteger否要打开的页数。默认值为 40,这用于限制失控的读取量,并不代表文档的页数:年报请调高此值。

详细介绍

truecopy

使用 TypeScript 从 PDF 中提取表格,并判断提取结果是否有误。

将文档转换为行,并拒绝无法信赖的读取结果。PDF、Word 或 OpenDocument 文件、粘贴内容、CSV、OCR 页面:一个引擎即可读取所有这些内容。

npm gate license

如果解析器从误读的文档中返回看似可信的表格,这比什么都不返回更糟,因为没人会检查看起来正确的表格。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:

拖入的内容列的起始位置
PDF项目的 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,层层匹配。

它鲜少出现,原因并非技术问题:基准测试会奖励自信的猜测,却将弃答计为错误,因此整个系统都被调校成倾向猜测。确定性解析器没有理由也这样做。

读者遵循以下五条原则:

  1. 让文档自行校验。 银行对账单会列出 opening + Σ = closing;职业记录会注明季度总数。根据文档自身声明来核对读取结果,是唯一不依赖布局的校验方式。
  2. 拒绝也胜过返回看似合理的表格。 最严重的问题不是漏掉异常,而是误读文档后凭空捏造出二十二条异常。
  3. 计数不等于测量。 运行两次读取并保留“找到更多行”的结果,选出的总是更差的那个:切分不佳的读取器会把脚注当作数据。
  4. 读取器提出建议,由人来确认。 修正是产品的一部分,而不是故障后的补救路径。
  5. 每个量词都有边界。 这些模式会逐行处理整份文档。

由模型来读取时

将单元格转为记录的模型会以一种显而易见的方式失败——漏掉一条记录;也会以一种不易察觉的方式失败:返回一个文档中从未印出的合理值。根据邮政编码推断出的城镇、四舍五入后的数字、重新拼合的日期,每个看起来都像是成功提取的结果。这种失败有确定性的解决办法,而且不需要更好的模型:

模型永远不会看到 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));
});

六条规则,逐条列出:上面的法则规定了读者会被告知什么;以下规则则由语料库从外部检查读者收到的内容。

  1. 语料库公布的判定,就是返回的判定。
  2. 与文档矛盾的读取结果绝不会被视为可靠。
  3. 所有读取内容都可由相关人员审查。
  4. 从字节到记录的处理链确实会运行,使用的是真实 PDF(pdfWithText 会生成一个包含 xref 表的 PDF)。
  5. 没有实质内容的文档会被拒绝,而不会悄然返回空结果。
  6. 属于另一种类型的文档会被拒绝:你要提供用于区分的语料库,因为只有你知道必须将它与什么区分开。

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.tsopen字节 → 页面 → 行:大小上限、页面上限、截止时间、释放引擎、可替换引擎
layout.tslayout纯几何:行、列、单元格、位置;无引擎、无字节、无时钟
table.tstablereadTable(file):两行路径,以及它不会保证的内容
notation.tsnotation页面如何写出数字和日期,而非它们的含义
classify.tsclassify这是否是预期类型的文档,包括优先级
columns.tscolumns每列包含什么,只计数一次
roles.tsroles每列的作用是什么,根据其内容推断
records.tsrecords哪些行属于同一条记录,以及何时一条打印行并不代表一条记录
labels.tslabels哪些单元格可能是标签所指示的值,按距离从近到远排列
signature.tssignature哪些行违反了表格自身的类型签名
schema.tsschema阅读结果必须满足的字段、格式和数量
cite.tscite模型引用了哪些行,以及它们是否确实包含各个值
anchor.tsanchor存储值来自哪段文本,以及如何在文档中重新找到该段文本
explain.tsexplain上述所有机制以文字形式作出的判定
pattern.tspattern作为数据的领域知识,在 ReDoS 防护下编译
contract.tscontract诚实的阅读结果应是什么样
kit.tskit如何让它成为强制要求

优先级,以及为何要说明而非排序

银行对账单会在交易标签中引用“发票”一词。如果按错误的顺序阅读,它就会被归档为发票,而它包含的每笔操作都会随之离开预算范围。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

有人给了你一份声明、一张发票、一份导出文件,并要求你找出其中的某个数值。真正重要的失败,不是漏掉一个数值,而是返回一个文档根本没有打印出来的数值,而且看起来完全像是成功了。因此:

  1. 先调用 read_table,在做任何其他事情之前阅读 verdict。unreadable 表示无从依据:请如实说明。doubtful 表示有内容看起来不对;调查结果会指出页码和列,而其中的数值需要标记出来。reliable 表示没有发现异常,这就是全部结论。
  2. 使用 find_amounts 查找数值,使用 find_dates 查找日期,而不是从文字中读取:每项结果都会附带打印该内容的行。
  3. 在任何人依据你的值采取行动之前,先将对应行交给 check_citations 核验。引用的行没有打印出来的值,就不是从这些行中读到的:请重新读取,或将其删除。
  4. 绝不填补缺失内容。缺少总计就是缺少总计,不是把各行相加的机会;文档是唯一的信息来源,核验也只会指向文档本身。

要通过更大的系统(例如你自己的服务端或其他传输方式)提供这些工具,请参阅 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

评论

支持 Markdown

第一次接触 MCP?

从协议是什么,到在客户端里装上第一个服务,这几条回答覆盖了大多数入门问题。

一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。