ofdkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ofdkit/__init__.py +54 -0
- ofdkit/__main__.py +5 -0
- ofdkit/_xml.py +120 -0
- ofdkit/batch.py +111 -0
- ofdkit/cli.py +189 -0
- ofdkit/document.py +509 -0
- ofdkit/errors.py +13 -0
- ofdkit/invoice.py +438 -0
- ofdkit/layout.py +84 -0
- ofdkit/markdown.py +100 -0
- ofdkit/markitdown_plugin.py +55 -0
- ofdkit/mcp_server.py +125 -0
- ofdkit/package.py +145 -0
- ofdkit-0.1.0.dist-info/METADATA +186 -0
- ofdkit-0.1.0.dist-info/RECORD +18 -0
- ofdkit-0.1.0.dist-info/WHEEL +4 -0
- ofdkit-0.1.0.dist-info/entry_points.txt +6 -0
- ofdkit-0.1.0.dist-info/licenses/LICENSE +202 -0
ofdkit/mcp_server.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""MCP server: lets AI tools (Claude, Codex, Cursor and others) read OFD files.
|
|
2
|
+
|
|
3
|
+
Run it with ``ofdkit mcp`` after ``pip install "ofdkit[mcp]"``, or without installing
|
|
4
|
+
anything: ``uvx --from "ofdkit[mcp]" ofdkit mcp``. Every tool only reads files.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from mcp.server.mcpserver import MCPServer
|
|
13
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
14
|
+
from mcp.types import ToolAnnotations
|
|
15
|
+
|
|
16
|
+
from . import __version__
|
|
17
|
+
from .batch import find_ofd_files, invoice_rows, summary
|
|
18
|
+
from .cli import file_info
|
|
19
|
+
from .document import OFDDocument
|
|
20
|
+
from .errors import OFDError
|
|
21
|
+
from .markdown import document_markdown
|
|
22
|
+
|
|
23
|
+
#: Most files list_ofd_invoices reads in one call.
|
|
24
|
+
MAX_BATCH_FILES = 500
|
|
25
|
+
|
|
26
|
+
INSTRUCTIONS = """\
|
|
27
|
+
Reads OFD files (GB/T 33190), the Chinese fixed-layout format used for e-invoices
|
|
28
|
+
(电子发票, 数电票) and official documents. Pass absolute file paths.
|
|
29
|
+
Use read_ofd_invoice for invoice fields, read_ofd_text for any document's text, and
|
|
30
|
+
list_ofd_invoices to summarise a folder of invoices. Values are what the file
|
|
31
|
+
contains; they are not checked against the tax authority."""
|
|
32
|
+
|
|
33
|
+
_READ_ONLY = ToolAnnotations(readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def create_server(root: str | Path | None = None) -> MCPServer:
|
|
37
|
+
"""Build the server. With ``root`` set, only files inside that folder can be read."""
|
|
38
|
+
allowed = Path(root).expanduser().resolve() if root else None
|
|
39
|
+
server = MCPServer(name="ofdkit", version=__version__, instructions=INSTRUCTIONS)
|
|
40
|
+
|
|
41
|
+
def check(path: str) -> Path:
|
|
42
|
+
resolved = Path(path).expanduser().resolve()
|
|
43
|
+
if allowed is not None and not resolved.is_relative_to(allowed):
|
|
44
|
+
raise ToolError(f"{path} is outside the allowed folder {allowed}")
|
|
45
|
+
if not resolved.exists():
|
|
46
|
+
raise ToolError(f"{path} does not exist")
|
|
47
|
+
return resolved
|
|
48
|
+
|
|
49
|
+
def open_document(path: str) -> OFDDocument:
|
|
50
|
+
try:
|
|
51
|
+
return OFDDocument(check(path))
|
|
52
|
+
except OFDError as exc:
|
|
53
|
+
raise ToolError(f"cannot read {path}: {exc}") from exc
|
|
54
|
+
|
|
55
|
+
@server.tool(title="Read OFD text", annotations=_READ_ONLY)
|
|
56
|
+
def read_ofd_text(path: str, markdown: bool = False, first_page: int = 1, last_page: int | None = None) -> str:
|
|
57
|
+
"""Extract the text of an OFD file in reading order.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
path: Absolute path to the .ofd file.
|
|
61
|
+
markdown: Return Markdown; e-invoices then start with a table of their fields.
|
|
62
|
+
first_page: First page to include, counting from 1.
|
|
63
|
+
last_page: Last page to include; all remaining pages if omitted.
|
|
64
|
+
"""
|
|
65
|
+
with open_document(path) as document:
|
|
66
|
+
if markdown and first_page == 1 and last_page is None:
|
|
67
|
+
return document_markdown(document)
|
|
68
|
+
pages = document.pages[max(first_page, 1) - 1 : last_page]
|
|
69
|
+
if not pages:
|
|
70
|
+
raise ToolError(f"no pages in range {first_page}-{last_page}; the file has {len(document.pages)} pages")
|
|
71
|
+
texts = []
|
|
72
|
+
for page in pages:
|
|
73
|
+
try:
|
|
74
|
+
text = page.text()
|
|
75
|
+
except OFDError as exc:
|
|
76
|
+
text = f"[page {page.index + 1} could not be read: {exc}]"
|
|
77
|
+
texts.append(f"## 第 {page.index + 1} 页\n\n{text}" if markdown else text)
|
|
78
|
+
return "\n\n".join(texts)
|
|
79
|
+
|
|
80
|
+
@server.tool(title="Read OFD invoice", annotations=_READ_ONLY)
|
|
81
|
+
def read_ofd_invoice(path: str) -> dict[str, Any]:
|
|
82
|
+
"""Read the fields of a Chinese e-invoice (数电票 or 增值税电子发票) in OFD format.
|
|
83
|
+
|
|
84
|
+
Returns invoice number and code, issue date (YYYY-MM-DD), buyer and seller, amounts
|
|
85
|
+
as decimal strings, line items, and warnings such as totals that do not add up.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
path: Absolute path to the .ofd file.
|
|
89
|
+
"""
|
|
90
|
+
with open_document(path) as document:
|
|
91
|
+
invoice = document.invoice()
|
|
92
|
+
if invoice is None:
|
|
93
|
+
raise ToolError(f"no invoice data in {path}; use read_ofd_text to read its text")
|
|
94
|
+
return invoice.to_dict()
|
|
95
|
+
|
|
96
|
+
@server.tool(title="OFD file info", annotations=_READ_ONLY)
|
|
97
|
+
def ofd_info(path: str) -> dict[str, Any]:
|
|
98
|
+
"""Pages (with sizes in millimetres), metadata, and whether an OFD file is an invoice.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
path: Absolute path to the .ofd file.
|
|
102
|
+
"""
|
|
103
|
+
with open_document(path) as document:
|
|
104
|
+
return file_info(document, path)
|
|
105
|
+
|
|
106
|
+
@server.tool(title="List OFD invoices in a folder", annotations=_READ_ONLY)
|
|
107
|
+
def list_ofd_invoices(folder: str, recursive: bool = False) -> dict[str, Any]:
|
|
108
|
+
"""Summarise every invoice in a folder of .ofd files.
|
|
109
|
+
|
|
110
|
+
Returns one row per file (number, date, buyer, seller, amounts, or the reason it
|
|
111
|
+
could not be read), duplicate invoice numbers flagged, and totals.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
folder: Absolute path to a folder.
|
|
115
|
+
recursive: Also look in subfolders.
|
|
116
|
+
"""
|
|
117
|
+
directory = check(folder)
|
|
118
|
+
if not directory.is_dir():
|
|
119
|
+
raise ToolError(f"{folder} is not a folder")
|
|
120
|
+
files, _ = find_ofd_files([directory], recursive)
|
|
121
|
+
truncated = len(files) > MAX_BATCH_FILES
|
|
122
|
+
rows = invoice_rows(files[:MAX_BATCH_FILES])
|
|
123
|
+
return {"summary": summary(rows), "truncated": truncated, "rows": rows}
|
|
124
|
+
|
|
125
|
+
return server
|
ofdkit/package.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""The ZIP container of an OFD file: entry lookup, path resolution and safety limits."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import os
|
|
7
|
+
import posixpath
|
|
8
|
+
import xml.etree.ElementTree as ET
|
|
9
|
+
import zipfile
|
|
10
|
+
import zlib
|
|
11
|
+
from typing import BinaryIO, Union
|
|
12
|
+
|
|
13
|
+
from ._xml import parse_xml
|
|
14
|
+
from .errors import OFDFormatError, OFDLimitError
|
|
15
|
+
|
|
16
|
+
Source = Union[str, "os.PathLike[str]", bytes, bytearray, BinaryIO]
|
|
17
|
+
|
|
18
|
+
#: Most entries a package may contain.
|
|
19
|
+
MAX_ENTRIES = 50_000
|
|
20
|
+
#: Largest single part, uncompressed, that ofdkit will read into memory.
|
|
21
|
+
MAX_PART_SIZE = 64 * 1024 * 1024
|
|
22
|
+
|
|
23
|
+
_UTF8_FLAG = 0x800
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Package:
|
|
27
|
+
"""Read-only view of the parts inside an OFD file."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, source: Source) -> None:
|
|
30
|
+
try:
|
|
31
|
+
if isinstance(source, (bytes, bytearray)):
|
|
32
|
+
self._zip = zipfile.ZipFile(io.BytesIO(bytes(source)))
|
|
33
|
+
else:
|
|
34
|
+
self._zip = zipfile.ZipFile(source)
|
|
35
|
+
except (zipfile.BadZipFile, ValueError, EOFError) as exc:
|
|
36
|
+
raise OFDFormatError(f"not an OFD file (not a ZIP container): {exc}") from exc
|
|
37
|
+
|
|
38
|
+
infos = self._zip.infolist()
|
|
39
|
+
if len(infos) > MAX_ENTRIES:
|
|
40
|
+
self.close()
|
|
41
|
+
raise OFDLimitError(f"too many entries in the package ({len(infos)} > {MAX_ENTRIES})")
|
|
42
|
+
|
|
43
|
+
self._entries: dict[str, zipfile.ZipInfo] = {}
|
|
44
|
+
for info in infos:
|
|
45
|
+
if info.is_dir():
|
|
46
|
+
continue
|
|
47
|
+
self._entries.setdefault(_normalize(_entry_name(info)), info)
|
|
48
|
+
self._casefolded: dict[str, str] = {}
|
|
49
|
+
for name in self._entries:
|
|
50
|
+
self._casefolded.setdefault(name.casefold(), name)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def names(self) -> list[str]:
|
|
54
|
+
"""Names of all files in the package."""
|
|
55
|
+
return list(self._entries)
|
|
56
|
+
|
|
57
|
+
def find(self, path: str) -> str | None:
|
|
58
|
+
"""Return the entry name for ``path``, matching case-insensitively as a fallback."""
|
|
59
|
+
path = _normalize(path)
|
|
60
|
+
if path in self._entries:
|
|
61
|
+
return path
|
|
62
|
+
return self._casefolded.get(path.casefold())
|
|
63
|
+
|
|
64
|
+
def resolve(self, base: str | None, location: str | None) -> str | None:
|
|
65
|
+
"""Resolve a location written inside ``base`` to an entry name, or None.
|
|
66
|
+
|
|
67
|
+
The standard says relative locations are relative to the referencing file and
|
|
68
|
+
absolute ones start at the package root. Producers get this wrong often enough
|
|
69
|
+
that a location is also tried relative to the root, and matched without case.
|
|
70
|
+
"""
|
|
71
|
+
location = (location or "").strip().replace("\\", "/")
|
|
72
|
+
if not location:
|
|
73
|
+
return None
|
|
74
|
+
if location.startswith("/"):
|
|
75
|
+
candidates = [location]
|
|
76
|
+
else:
|
|
77
|
+
base_dir = posixpath.dirname(_normalize(base)) if base else ""
|
|
78
|
+
candidates = [posixpath.join(base_dir, location), location]
|
|
79
|
+
for candidate in candidates:
|
|
80
|
+
candidate = posixpath.normpath(_normalize(candidate))
|
|
81
|
+
if candidate == ".." or candidate.startswith("../"):
|
|
82
|
+
continue
|
|
83
|
+
found = self.find(candidate)
|
|
84
|
+
if found is not None:
|
|
85
|
+
return found
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
def read(self, name: str) -> bytes:
|
|
89
|
+
"""Read one entry, refusing entries larger than MAX_PART_SIZE."""
|
|
90
|
+
info = self._entries.get(name)
|
|
91
|
+
if info is None:
|
|
92
|
+
raise OFDFormatError(f"missing part: {name}")
|
|
93
|
+
if info.file_size > MAX_PART_SIZE:
|
|
94
|
+
raise OFDLimitError(f"{name} is too large ({info.file_size} bytes)")
|
|
95
|
+
try:
|
|
96
|
+
with self._zip.open(info) as stream:
|
|
97
|
+
data = stream.read(MAX_PART_SIZE + 1)
|
|
98
|
+
except (zipfile.BadZipFile, zlib.error, RuntimeError, NotImplementedError, EOFError, OSError) as exc:
|
|
99
|
+
# RuntimeError: encrypted entry; NotImplementedError: unsupported compression.
|
|
100
|
+
raise OFDFormatError(f"cannot read {name}: {exc}") from exc
|
|
101
|
+
if len(data) > MAX_PART_SIZE:
|
|
102
|
+
raise OFDLimitError(f"{name} is too large when decompressed")
|
|
103
|
+
return data
|
|
104
|
+
|
|
105
|
+
def read_xml(self, name: str) -> ET.Element:
|
|
106
|
+
"""Read and parse one XML entry."""
|
|
107
|
+
return parse_xml(self.read(name), name)
|
|
108
|
+
|
|
109
|
+
def close(self) -> None:
|
|
110
|
+
self._zip.close()
|
|
111
|
+
|
|
112
|
+
def __enter__(self) -> Package:
|
|
113
|
+
return self
|
|
114
|
+
|
|
115
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
116
|
+
self.close()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _normalize(name: str) -> str:
|
|
120
|
+
name = name.replace("\\", "/")
|
|
121
|
+
while name.startswith(("/", "./")):
|
|
122
|
+
name = name[1:] if name.startswith("/") else name[2:]
|
|
123
|
+
return name
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _entry_name(info: zipfile.ZipInfo) -> str:
|
|
127
|
+
"""Decode an entry name the way its producer most likely wrote it.
|
|
128
|
+
|
|
129
|
+
Without the UTF-8 flag, zipfile decodes names as CP437, but Chinese tools usually
|
|
130
|
+
stored UTF-8 or GBK bytes there.
|
|
131
|
+
"""
|
|
132
|
+
if info.flag_bits & _UTF8_FLAG:
|
|
133
|
+
return info.filename
|
|
134
|
+
try:
|
|
135
|
+
raw = info.filename.encode("cp437")
|
|
136
|
+
except UnicodeEncodeError:
|
|
137
|
+
return info.filename
|
|
138
|
+
if raw.isascii():
|
|
139
|
+
return info.filename
|
|
140
|
+
for encoding in ("utf-8", "gb18030"):
|
|
141
|
+
try:
|
|
142
|
+
return raw.decode(encoding)
|
|
143
|
+
except UnicodeDecodeError:
|
|
144
|
+
continue
|
|
145
|
+
return info.filename
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ofdkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Read OFD documents (GB/T 33190) and Chinese e-invoices: text, invoice fields, and AI tool integrations.
|
|
5
|
+
Keywords: ofd,gb/t 33190,e-invoice,fapiao,电子发票,数电票,mcp
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Natural Language :: Chinese (Simplified)
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Topic :: Office/Business :: Financial :: Accounting
|
|
15
|
+
Classifier: Topic :: Text Processing
|
|
16
|
+
Requires-Dist: markitdown>=0.1,<0.2 ; extra == 'markitdown'
|
|
17
|
+
Requires-Dist: mcp>=2.3,<3 ; extra == 'mcp'
|
|
18
|
+
Maintainer: Edgar-311
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Project-URL: Homepage, https://github.com/Edgar-311/ofdkit
|
|
21
|
+
Project-URL: Issues, https://github.com/Edgar-311/ofdkit/issues
|
|
22
|
+
Project-URL: Changelog, https://github.com/Edgar-311/ofdkit/blob/main/CHANGELOG.md
|
|
23
|
+
Provides-Extra: markitdown
|
|
24
|
+
Provides-Extra: mcp
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# ofdkit
|
|
28
|
+
|
|
29
|
+
轻量的 Python 工具,用来读取 OFD 文件,提取里面的文字和发票字段,并能接入 AI 工具。OFD 是中国的国家标准版式文档格式(GB/T 33190),常见于电子发票和公文。
|
|
30
|
+
|
|
31
|
+
A lightweight Python toolkit for reading OFD documents (GB/T 33190, the Chinese fixed-layout format used for e-invoices and official documents): extract text and invoice fields, and use them from AI tools.
|
|
32
|
+
|
|
33
|
+
> 状态:0.1 版,接口以后可能还会调整。更新记录见 [CHANGELOG](https://github.com/Edgar-311/ofdkit/blob/main/CHANGELOG.md)。
|
|
34
|
+
> Status: 0.1, the API may still change. See the [CHANGELOG](https://github.com/Edgar-311/ofdkit/blob/main/CHANGELOG.md).
|
|
35
|
+
|
|
36
|
+
## 能做什么 / What it does
|
|
37
|
+
|
|
38
|
+
- 按阅读顺序提取文字,包括模板和复合对象里的文字。
|
|
39
|
+
Text in reading order, including text in templates and composite objects.
|
|
40
|
+
- 读取电子发票字段(数电票和增值税电子发票),输出 JSON:发票号码、开票日期、购买方、销售方、金额、税额、价税合计、明细。
|
|
41
|
+
Invoice fields from e-invoices as JSON: number, date, buyer, seller, amounts, tax, total, line items.
|
|
42
|
+
- 把一个文件夹里的发票汇总成 CSV,Excel 可以直接打开,并提示重复的发票号码。
|
|
43
|
+
A folder of invoices summarised as CSV (opens in Excel), with duplicate invoice numbers flagged.
|
|
44
|
+
- 只用 Python 标准库,没有任何第三方依赖;能读很多不规范的文件(路径写法不对、编码不对、XML 前缀错误等)。
|
|
45
|
+
Standard library only, no dependencies; reads many non-conforming files (bad paths, encodings, XML prefixes).
|
|
46
|
+
|
|
47
|
+
## 安装 / Install
|
|
48
|
+
|
|
49
|
+
需要 Python 3.10 或更新版本。Requires Python 3.10+.
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install ofdkit
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## 命令行 / Command line
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
ofdkit text 文件.ofd # 提取文字 / print text
|
|
59
|
+
ofdkit invoice 发票.ofd # 发票字段(JSON)/ invoice fields as JSON
|
|
60
|
+
ofdkit batch 发票文件夹/ -o 发票汇总.csv # 批量汇总 / summarise a folder as CSV
|
|
61
|
+
ofdkit info 文件.ofd # 页数、作者等信息 / pages and metadata
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`ofdkit invoice` 的输出示例(数据是虚构的)/ Example output (fictional data):
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"invoice_number": "00000000000000000001",
|
|
69
|
+
"invoice_code": null,
|
|
70
|
+
"issue_date": "2026-01-02",
|
|
71
|
+
"check_code": null,
|
|
72
|
+
"machine_number": null,
|
|
73
|
+
"buyer": {"name": "测试单位甲", "tax_id": "TESTBUYER0001", "address_phone": null, "bank_account": null},
|
|
74
|
+
"seller": {"name": "测试单位乙", "tax_id": "TESTSELLER0002", "address_phone": null, "bank_account": null},
|
|
75
|
+
"amount_without_tax": "100.00",
|
|
76
|
+
"tax_amount": "6.00",
|
|
77
|
+
"total_amount": "106.00",
|
|
78
|
+
"issuer": "测试人员",
|
|
79
|
+
"payee": null,
|
|
80
|
+
"reviewer": null,
|
|
81
|
+
"note": null,
|
|
82
|
+
"items": [
|
|
83
|
+
{"name": "*测试*服务项目", "specification": null, "unit": null, "quantity": null,
|
|
84
|
+
"unit_price": null, "amount": "100.00", "tax_rate": "6%", "tax_amount": "6.00"}
|
|
85
|
+
],
|
|
86
|
+
"sources": ["custom_tags"],
|
|
87
|
+
"warnings": []
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
金额是字符串,不会丢失精度。如果"合计金额 + 合计税额"不等于"价税合计",`warnings` 里会提示。
|
|
92
|
+
Amounts are decimal strings, so no precision is lost. `warnings` notes totals that do not add up.
|
|
93
|
+
|
|
94
|
+
## 在 AI 工具里使用 / Use from AI tools
|
|
95
|
+
|
|
96
|
+
### MCP 服务器 / MCP server
|
|
97
|
+
|
|
98
|
+
让 Claude、Codex、Cursor 等 AI 工具直接读取 OFD 文件。需要先安装 [uv](https://docs.astral.sh/uv/)。所有工具都是只读的。
|
|
99
|
+
Lets Claude, Codex, Cursor and other AI tools read OFD files. Requires [uv](https://docs.astral.sh/uv/). All tools are read-only.
|
|
100
|
+
|
|
101
|
+
| 工具 / Tool | 作用 / What it does |
|
|
102
|
+
| --- | --- |
|
|
103
|
+
| `read_ofd_text` | 按阅读顺序提取文字,可选 Markdown 和页码范围 / text in reading order, optional Markdown and page range |
|
|
104
|
+
| `read_ofd_invoice` | 发票字段 / invoice fields |
|
|
105
|
+
| `ofd_info` | 页数、尺寸、元数据 / pages, sizes, metadata |
|
|
106
|
+
| `list_ofd_invoices` | 汇总一个文件夹里的发票 / summarise a folder of invoices |
|
|
107
|
+
|
|
108
|
+
Claude Code:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
claude mcp add ofdkit -- uvx --from "ofdkit[mcp]" ofdkit mcp
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Claude Desktop、Cursor 等使用 JSON 配置的工具 / tools with a JSON config:
|
|
115
|
+
|
|
116
|
+
```json
|
|
117
|
+
{
|
|
118
|
+
"mcpServers": {
|
|
119
|
+
"ofdkit": {
|
|
120
|
+
"command": "uvx",
|
|
121
|
+
"args": ["--from", "ofdkit[mcp]", "ofdkit", "mcp"]
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Codex(`~/.codex/config.toml`):
|
|
128
|
+
|
|
129
|
+
```toml
|
|
130
|
+
[mcp_servers.ofdkit]
|
|
131
|
+
command = "uvx"
|
|
132
|
+
args = ["--from", "ofdkit[mcp]", "ofdkit", "mcp"]
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
加上 `--root 文件夹` 可以只允许读取这个文件夹里的文件。
|
|
136
|
+
Add `--root FOLDER` to allow only files inside that folder.
|
|
137
|
+
|
|
138
|
+
### Agent Skill
|
|
139
|
+
|
|
140
|
+
[`skills/ofd-reader`](https://github.com/Edgar-311/ofdkit/tree/main/skills/ofd-reader) 告诉 AI 什么时候、怎么用 ofdkit 命令行,适用于支持 [Agent Skills](https://agentskills.io) 的工具。把这个文件夹复制到:
|
|
141
|
+
[`skills/ofd-reader`](https://github.com/Edgar-311/ofdkit/tree/main/skills/ofd-reader) tells an AI agent when and how to use the ofdkit command line. Copy the folder to:
|
|
142
|
+
|
|
143
|
+
- Claude Code: `~/.claude/skills/ofd-reader/`
|
|
144
|
+
- Codex: `~/.codex/skills/ofd-reader/`
|
|
145
|
+
|
|
146
|
+
### MarkItDown 插件 / MarkItDown plugin
|
|
147
|
+
|
|
148
|
+
[MarkItDown](https://github.com/microsoft/markitdown) 是把各种文件转成 Markdown 给 AI 读的工具。安装插件后它就能读 OFD:
|
|
149
|
+
[MarkItDown](https://github.com/microsoft/markitdown) converts files to Markdown for AI. With this plugin it reads OFD too:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
pip install "ofdkit[markitdown]"
|
|
153
|
+
markitdown --use-plugins 发票.ofd
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Python
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
import ofdkit
|
|
160
|
+
|
|
161
|
+
with ofdkit.open("发票.ofd") as doc:
|
|
162
|
+
print(doc.text())
|
|
163
|
+
invoice = doc.invoice() # 不是发票时返回 None / None if not an invoice
|
|
164
|
+
if invoice:
|
|
165
|
+
print(invoice.invoice_number, invoice.total_amount)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## 注意 / Notes
|
|
169
|
+
|
|
170
|
+
- ofdkit 只读取文件里写了什么,不验证发票真伪。查验真伪请使用国家税务总局的发票查验平台。
|
|
171
|
+
ofdkit reports what a file contains. It does not verify that an invoice is genuine.
|
|
172
|
+
- 文字如果是画成图形或图片的,就读不出来(需要 OCR,不在本项目范围内)。
|
|
173
|
+
Text drawn as shapes or images cannot be extracted (that needs OCR, which is out of scope).
|
|
174
|
+
- 多栏排版的页面,目前两栏的文字会出现在同一行,用三个空格隔开。
|
|
175
|
+
On multi-column pages, both columns currently share each line, separated by three spaces.
|
|
176
|
+
|
|
177
|
+
## 原则 / Principles
|
|
178
|
+
|
|
179
|
+
- 只读取 OFD,不生成、不修改发票。
|
|
180
|
+
Reads OFD only. It never creates or modifies invoices.
|
|
181
|
+
- 本项目大部分代码由 AI(Claude)编写,维护者负责决定方向和批准发布。
|
|
182
|
+
Most code is written with AI assistance (Claude). The maintainer sets the direction and approves releases.
|
|
183
|
+
|
|
184
|
+
## 许可证 / License
|
|
185
|
+
|
|
186
|
+
[Apache-2.0](https://github.com/Edgar-311/ofdkit/blob/main/LICENSE)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
ofdkit/__init__.py,sha256=kiU9yXbFbWRJmRUSY8dNuxBWmPBVQibZzkKghxx_X0c,1553
|
|
2
|
+
ofdkit/__main__.py,sha256=E6Gls0DNz8GQK2K-kOUIx8cYhgANW_CH54VKrfCfs14,52
|
|
3
|
+
ofdkit/_xml.py,sha256=zCSoRTbQ2trxakf5Ey_P_4xcqyxh1Hzlz1ZaEdzh4lY,4722
|
|
4
|
+
ofdkit/batch.py,sha256=_tSoe2x6upEiWi8HXewWj6WyQtSJF566Rgm9LdVSrWo,3675
|
|
5
|
+
ofdkit/cli.py,sha256=n4myDBXi9eK-KcpNWYbqbBdH5kj623BaYA9jLnVdR9A,7156
|
|
6
|
+
ofdkit/document.py,sha256=kd1h3dbFtSkPYGhc5-gRUp3VFde1OEX7viNNBaVegt4,19217
|
|
7
|
+
ofdkit/errors.py,sha256=JKVhg36Lu6DVLXkfCBDJPAy4xrgXvVaNGgup38ehNL0,356
|
|
8
|
+
ofdkit/invoice.py,sha256=vWvdAYFH_d1UTZhwIzAf6n5ruqohC9NeP7tp7iI9DUc,16176
|
|
9
|
+
ofdkit/layout.py,sha256=3bSYGM9ytZ_FaiRj1a1c6oY0ukQR8OGUUXHn01CK_bU,2775
|
|
10
|
+
ofdkit/markdown.py,sha256=A4smJ-Upxc3EyDKnVfgVsoDXvFP4gJlDzGp35f3FxuA,3943
|
|
11
|
+
ofdkit/markitdown_plugin.py,sha256=vL78_98f0l4KMYy3tfeq8dU1qGpLXf0NhnC8bYN-zIM,2099
|
|
12
|
+
ofdkit/mcp_server.py,sha256=essaiq2xpBd8XZSk2zo3XRu5MNFj9ZVRD9NW20YwRfk,5437
|
|
13
|
+
ofdkit/package.py,sha256=6-HJyYvERTjQs70Z1KzEKTmMO1AvfyAkJ3WAGXCGjBg,5205
|
|
14
|
+
ofdkit-0.1.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
15
|
+
ofdkit-0.1.0.dist-info/WHEEL,sha256=a0ta4TK7UKMB0EbDesmi2mn0Djkly7aj_7Tu2R7SseE,81
|
|
16
|
+
ofdkit-0.1.0.dist-info/entry_points.txt,sha256=rRjw7GvihBgFe8na-kXxK-AT3taKYvFdYWNlJ891ANc,99
|
|
17
|
+
ofdkit-0.1.0.dist-info/METADATA,sha256=0HA2gsXWQJAP9si4eh0KmiDCRRPK5rydK63FVdsXsso,7997
|
|
18
|
+
ofdkit-0.1.0.dist-info/RECORD,,
|