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/invoice.py
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
"""Invoice fields from Chinese e-invoices in OFD format.
|
|
2
|
+
|
|
3
|
+
Three places in an OFD file can carry invoice data, read in this order of trust:
|
|
4
|
+
|
|
5
|
+
1. An XML attachment with the invoice itself (``Attachs/original_invoice.xml`` in older
|
|
6
|
+
e-invoices), which holds the values directly.
|
|
7
|
+
2. Custom tags (``Tags/CustomTags.xml``): each field points at the text objects on the
|
|
8
|
+
page that show its value. Both the 2016/2019 e-invoice schema (``fp:eInvoice``) and
|
|
9
|
+
the layout used by fully digitalised invoices (数电票, ``ofd:root``) are supported.
|
|
10
|
+
3. ``CustomData`` entries in OFD.xml, which 数电票 use for a few key fields.
|
|
11
|
+
|
|
12
|
+
ofdkit only reads these values. It never checks them against the tax authority, so a
|
|
13
|
+
result says what the file contains, not that the invoice is genuine.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
import xml.etree.ElementTree as ET
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from decimal import Decimal, InvalidOperation
|
|
23
|
+
from typing import TYPE_CHECKING, Any
|
|
24
|
+
|
|
25
|
+
from ._xml import child_text, children, grandchildren, local_name
|
|
26
|
+
from .errors import OFDError
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from .document import OFDDocument
|
|
30
|
+
|
|
31
|
+
# Leaf element names of the e-invoice schemas, mapped to Invoice attributes.
|
|
32
|
+
_FIELDS = {
|
|
33
|
+
"InvoiceNo": "invoice_number",
|
|
34
|
+
"InvoiceCode": "invoice_code",
|
|
35
|
+
"IssueDate": "issue_date",
|
|
36
|
+
"InvoiceCheckCode": "check_code",
|
|
37
|
+
"MachineNo": "machine_number",
|
|
38
|
+
"TaxExclusiveTotalAmount": "amount_without_tax",
|
|
39
|
+
"TaxTotalAmount": "tax_amount",
|
|
40
|
+
"TaxInclusiveTotalAmount": "total_amount",
|
|
41
|
+
"InvoiceClerk": "issuer",
|
|
42
|
+
"Payee": "payee",
|
|
43
|
+
"Checker": "reviewer",
|
|
44
|
+
"Note": "note",
|
|
45
|
+
}
|
|
46
|
+
_PARTY_FIELDS = {"Name": "name", "TaxID": "tax_id", "AddrTel": "address_phone", "FinancialAccount": "bank_account"}
|
|
47
|
+
_ITEM_FIELDS = {
|
|
48
|
+
"Item": "name",
|
|
49
|
+
"Specification": "specification",
|
|
50
|
+
"MeasurementDimension": "unit",
|
|
51
|
+
"Quantity": "quantity",
|
|
52
|
+
"Price": "unit_price",
|
|
53
|
+
"Amount": "amount",
|
|
54
|
+
"TaxScheme": "tax_rate",
|
|
55
|
+
"TaxAmount": "tax_amount",
|
|
56
|
+
}
|
|
57
|
+
# CustomData names that e-invoices put in OFD.xml, mapped to schema leaf names.
|
|
58
|
+
# Names are compared after removing spaces and punctuation (see _custom_data_key).
|
|
59
|
+
_CUSTOM_DATA = {
|
|
60
|
+
"发票号码": "InvoiceNo",
|
|
61
|
+
"发票代码": "InvoiceCode",
|
|
62
|
+
"开票日期": "IssueDate",
|
|
63
|
+
"校验码": "InvoiceCheckCode",
|
|
64
|
+
"机器编号": "MachineNo",
|
|
65
|
+
"合计金额": "TaxExclusiveTotalAmount",
|
|
66
|
+
"合计税额": "TaxTotalAmount",
|
|
67
|
+
"价税合计": "TaxInclusiveTotalAmount",
|
|
68
|
+
"购买方名称": "BuyerName",
|
|
69
|
+
"购买方纳税人识别号": "BuyerTaxID",
|
|
70
|
+
"购买方地址电话": "BuyerAddrTel",
|
|
71
|
+
"购买方开户行及账号": "BuyerFinancialAccount",
|
|
72
|
+
"销售方名称": "SellerName",
|
|
73
|
+
"销售方纳税人识别号": "SellerTaxID",
|
|
74
|
+
"销售方地址电话": "SellerAddrTel",
|
|
75
|
+
"销售方开户行及账号": "SellerFinancialAccount",
|
|
76
|
+
"开票人": "InvoiceClerk",
|
|
77
|
+
"收款人": "Payee",
|
|
78
|
+
"复核人": "Checker",
|
|
79
|
+
"复核": "Checker",
|
|
80
|
+
"备注": "Note",
|
|
81
|
+
}
|
|
82
|
+
# A source counts as an invoice only if it has at least one of these.
|
|
83
|
+
_KEY_FIELDS = ("InvoiceNo", "InvoiceCode", "TaxInclusiveTotalAmount", "TaxExclusiveTotalAmount")
|
|
84
|
+
|
|
85
|
+
_AMOUNT = re.compile(r"^-?\d+(?:\.\d+)?$")
|
|
86
|
+
_DATE = re.compile(r"^(\d{4})\s*[年/.-]?\s*(\d{1,2})\s*[月/.-]?\s*(\d{1,2})\s*日?(?:[\sT]+\d{1,2}:\d{2}(?::\d{2})?)?$")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class Party:
|
|
91
|
+
"""Buyer (购买方) or seller (销售方)."""
|
|
92
|
+
|
|
93
|
+
name: str | None = None
|
|
94
|
+
tax_id: str | None = None
|
|
95
|
+
address_phone: str | None = None
|
|
96
|
+
bank_account: str | None = None
|
|
97
|
+
|
|
98
|
+
def to_dict(self) -> dict[str, str | None]:
|
|
99
|
+
return {
|
|
100
|
+
"name": self.name,
|
|
101
|
+
"tax_id": self.tax_id,
|
|
102
|
+
"address_phone": self.address_phone,
|
|
103
|
+
"bank_account": self.bank_account,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class LineItem:
|
|
109
|
+
"""One line of goods or services. Numbers are kept as decimal strings."""
|
|
110
|
+
|
|
111
|
+
name: str | None = None
|
|
112
|
+
specification: str | None = None
|
|
113
|
+
unit: str | None = None
|
|
114
|
+
quantity: str | None = None
|
|
115
|
+
unit_price: str | None = None
|
|
116
|
+
amount: str | None = None
|
|
117
|
+
tax_rate: str | None = None
|
|
118
|
+
tax_amount: str | None = None
|
|
119
|
+
|
|
120
|
+
def to_dict(self) -> dict[str, str | None]:
|
|
121
|
+
return {
|
|
122
|
+
"name": self.name,
|
|
123
|
+
"specification": self.specification,
|
|
124
|
+
"unit": self.unit,
|
|
125
|
+
"quantity": self.quantity,
|
|
126
|
+
"unit_price": self.unit_price,
|
|
127
|
+
"amount": self.amount,
|
|
128
|
+
"tax_rate": self.tax_rate,
|
|
129
|
+
"tax_amount": self.tax_amount,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class Invoice:
|
|
135
|
+
"""Invoice data found in an OFD file.
|
|
136
|
+
|
|
137
|
+
Amounts are decimal strings such as ``"4449.00"`` so that no precision is lost.
|
|
138
|
+
``issue_date`` is ``YYYY-MM-DD`` when the file's date could be read.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
invoice_number: str | None = None
|
|
142
|
+
invoice_code: str | None = None
|
|
143
|
+
issue_date: str | None = None
|
|
144
|
+
check_code: str | None = None
|
|
145
|
+
machine_number: str | None = None
|
|
146
|
+
buyer: Party = field(default_factory=Party)
|
|
147
|
+
seller: Party = field(default_factory=Party)
|
|
148
|
+
amount_without_tax: str | None = None
|
|
149
|
+
tax_amount: str | None = None
|
|
150
|
+
total_amount: str | None = None
|
|
151
|
+
issuer: str | None = None
|
|
152
|
+
payee: str | None = None
|
|
153
|
+
reviewer: str | None = None
|
|
154
|
+
note: str | None = None
|
|
155
|
+
items: list[LineItem] = field(default_factory=list)
|
|
156
|
+
#: Where the values came from: "attachment", "custom_tags" and/or "custom_data".
|
|
157
|
+
sources: list[str] = field(default_factory=list)
|
|
158
|
+
#: Problems noticed in the data, such as totals that do not add up.
|
|
159
|
+
warnings: list[str] = field(default_factory=list)
|
|
160
|
+
#: Every value found, by element path, for fields this class has no attribute for.
|
|
161
|
+
raw: dict[str, list[str]] = field(default_factory=dict)
|
|
162
|
+
|
|
163
|
+
def to_dict(self, include_raw: bool = False) -> dict[str, Any]:
|
|
164
|
+
data: dict[str, Any] = {
|
|
165
|
+
"invoice_number": self.invoice_number,
|
|
166
|
+
"invoice_code": self.invoice_code,
|
|
167
|
+
"issue_date": self.issue_date,
|
|
168
|
+
"check_code": self.check_code,
|
|
169
|
+
"machine_number": self.machine_number,
|
|
170
|
+
"buyer": self.buyer.to_dict(),
|
|
171
|
+
"seller": self.seller.to_dict(),
|
|
172
|
+
"amount_without_tax": self.amount_without_tax,
|
|
173
|
+
"tax_amount": self.tax_amount,
|
|
174
|
+
"total_amount": self.total_amount,
|
|
175
|
+
"issuer": self.issuer,
|
|
176
|
+
"payee": self.payee,
|
|
177
|
+
"reviewer": self.reviewer,
|
|
178
|
+
"note": self.note,
|
|
179
|
+
"items": [item.to_dict() for item in self.items],
|
|
180
|
+
"sources": list(self.sources),
|
|
181
|
+
"warnings": list(self.warnings),
|
|
182
|
+
}
|
|
183
|
+
if include_raw:
|
|
184
|
+
data["raw"] = {path: list(values) for path, values in self.raw.items()}
|
|
185
|
+
return data
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass
|
|
189
|
+
class _Source:
|
|
190
|
+
"""Values from one place in the file: leaf path -> values, plus grouped line items."""
|
|
191
|
+
|
|
192
|
+
name: str
|
|
193
|
+
values: dict[str, list[str]] = field(default_factory=dict)
|
|
194
|
+
items: list[dict[str, str]] = field(default_factory=list)
|
|
195
|
+
|
|
196
|
+
def leaf(self, leaf_name: str) -> list[str]:
|
|
197
|
+
"""Values of every path ending in ``leaf_name``."""
|
|
198
|
+
found: list[str] = []
|
|
199
|
+
for path, values in self.values.items():
|
|
200
|
+
if path.rsplit("/", 1)[-1] == leaf_name:
|
|
201
|
+
found.extend(values)
|
|
202
|
+
return found
|
|
203
|
+
|
|
204
|
+
def is_invoice(self) -> bool:
|
|
205
|
+
return any(self.leaf(name) for name in _KEY_FIELDS)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def read_invoice(document: OFDDocument) -> Invoice | None:
|
|
209
|
+
"""Collect invoice data from an opened document, or None if it holds none."""
|
|
210
|
+
sources = [
|
|
211
|
+
source
|
|
212
|
+
for source in (*_attachment_sources(document), *_tag_sources(document), _custom_data_source(document))
|
|
213
|
+
if source.is_invoice()
|
|
214
|
+
]
|
|
215
|
+
if not sources:
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
def first(leaf_name: str) -> str | None:
|
|
219
|
+
for source in sources:
|
|
220
|
+
for value in source.leaf(leaf_name):
|
|
221
|
+
if value:
|
|
222
|
+
return value
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
invoice = Invoice()
|
|
226
|
+
for leaf_name, attribute in _FIELDS.items():
|
|
227
|
+
setattr(invoice, attribute, first(leaf_name))
|
|
228
|
+
for prefix, party in (("Buyer", invoice.buyer), ("Seller", invoice.seller)):
|
|
229
|
+
for suffix, attribute in _PARTY_FIELDS.items():
|
|
230
|
+
setattr(party, attribute, first(prefix + suffix))
|
|
231
|
+
|
|
232
|
+
for source in sources:
|
|
233
|
+
items = source.items or _flat_items(source)
|
|
234
|
+
if items:
|
|
235
|
+
invoice.items = [_line_item(item) for item in items]
|
|
236
|
+
break
|
|
237
|
+
|
|
238
|
+
for source in sources:
|
|
239
|
+
if source.name not in invoice.sources:
|
|
240
|
+
invoice.sources.append(source.name)
|
|
241
|
+
for path, values in source.values.items():
|
|
242
|
+
invoice.raw.setdefault(path, list(values))
|
|
243
|
+
|
|
244
|
+
_normalise(invoice)
|
|
245
|
+
return invoice
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _attachment_sources(document: OFDDocument) -> list[_Source]:
|
|
249
|
+
sources = []
|
|
250
|
+
package = document._package
|
|
251
|
+
for body in document._bodies:
|
|
252
|
+
if body.attachments is None:
|
|
253
|
+
continue
|
|
254
|
+
try:
|
|
255
|
+
listing = package.read_xml(body.attachments)
|
|
256
|
+
except OFDError as exc:
|
|
257
|
+
document.warnings.append(f"attachments: {exc}")
|
|
258
|
+
continue
|
|
259
|
+
for attachment in children(listing, "Attachment"):
|
|
260
|
+
location = child_text(attachment, "FileLoc")
|
|
261
|
+
path = package.resolve(body.attachments, location)
|
|
262
|
+
if path is None or not path.lower().endswith(".xml"):
|
|
263
|
+
continue
|
|
264
|
+
try:
|
|
265
|
+
root = package.read_xml(path)
|
|
266
|
+
except OFDError:
|
|
267
|
+
continue
|
|
268
|
+
sources.append(_read_tree("attachment", root, resolve=None))
|
|
269
|
+
return sources
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _tag_sources(document: OFDDocument) -> list[_Source]:
|
|
273
|
+
sources = []
|
|
274
|
+
package = document._package
|
|
275
|
+
|
|
276
|
+
def resolve(page_ref: str | None, object_id: str) -> str | None:
|
|
277
|
+
page = document.page_by_id(page_ref) if page_ref else None
|
|
278
|
+
for candidate in [page] if page is not None else document.pages:
|
|
279
|
+
try:
|
|
280
|
+
text = candidate.object_text(object_id)
|
|
281
|
+
except OFDError:
|
|
282
|
+
continue
|
|
283
|
+
if text is not None:
|
|
284
|
+
return text
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
for body in document._bodies:
|
|
288
|
+
if body.custom_tags is None:
|
|
289
|
+
continue
|
|
290
|
+
try:
|
|
291
|
+
listing = package.read_xml(body.custom_tags)
|
|
292
|
+
except OFDError as exc:
|
|
293
|
+
document.warnings.append(f"custom tags: {exc}")
|
|
294
|
+
continue
|
|
295
|
+
for tag in children(listing, "CustomTag"):
|
|
296
|
+
path = package.resolve(body.custom_tags, child_text(tag, "FileLoc"))
|
|
297
|
+
if path is None:
|
|
298
|
+
continue
|
|
299
|
+
try:
|
|
300
|
+
root = package.read_xml(path)
|
|
301
|
+
except OFDError as exc:
|
|
302
|
+
document.warnings.append(f"custom tags: {exc}")
|
|
303
|
+
continue
|
|
304
|
+
sources.append(_read_tree("custom_tags", root, resolve=resolve))
|
|
305
|
+
return sources
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _custom_data_source(document: OFDDocument) -> _Source:
|
|
309
|
+
source = _Source("custom_data")
|
|
310
|
+
for name, value in document.info.custom_data.items():
|
|
311
|
+
leaf_name = _CUSTOM_DATA.get(_custom_data_key(name))
|
|
312
|
+
if leaf_name and value.strip():
|
|
313
|
+
source.values.setdefault(leaf_name, []).append(value.strip())
|
|
314
|
+
return source
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _custom_data_key(name: str) -> str:
|
|
318
|
+
"""``销售方地址、 电话`` and ``销售方地址电话`` name the same field."""
|
|
319
|
+
return re.sub(r"[\s、,,::]", "", name)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _read_tree(name: str, root: ET.Element, resolve: Callable[[str | None, str], str | None] | None) -> _Source:
|
|
323
|
+
"""Collect leaf values of an invoice XML tree.
|
|
324
|
+
|
|
325
|
+
A leaf is an element whose only children are ObjectRef elements (custom tags) or
|
|
326
|
+
that has no children at all (attachments). GoodsInfo elements group line items.
|
|
327
|
+
"""
|
|
328
|
+
source = _Source(name)
|
|
329
|
+
|
|
330
|
+
def value_of(element: ET.Element) -> str | None:
|
|
331
|
+
references = list(children(element, "ObjectRef"))
|
|
332
|
+
if references and resolve is not None:
|
|
333
|
+
parts = []
|
|
334
|
+
for reference in references:
|
|
335
|
+
object_id = (reference.text or "").strip()
|
|
336
|
+
if object_id:
|
|
337
|
+
text = resolve(reference.get("PageRef"), object_id)
|
|
338
|
+
if text:
|
|
339
|
+
parts.append(text)
|
|
340
|
+
value = "".join(parts).strip()
|
|
341
|
+
else:
|
|
342
|
+
value = (element.text or "").strip()
|
|
343
|
+
return value or None
|
|
344
|
+
|
|
345
|
+
def visit(element: ET.Element, path: list[str], depth: int) -> None:
|
|
346
|
+
element_name = local_name(element.tag)
|
|
347
|
+
if not element_name or depth > 16:
|
|
348
|
+
return
|
|
349
|
+
if element_name == "GoodsInfo":
|
|
350
|
+
item: dict[str, str] = {}
|
|
351
|
+
for leaf in element:
|
|
352
|
+
value = value_of(leaf)
|
|
353
|
+
if value is not None:
|
|
354
|
+
item.setdefault(local_name(leaf.tag), value)
|
|
355
|
+
if item:
|
|
356
|
+
source.items.append(item)
|
|
357
|
+
return
|
|
358
|
+
element_children = [c for c in element if local_name(c.tag) and local_name(c.tag) != "ObjectRef"]
|
|
359
|
+
if not element_children:
|
|
360
|
+
value = value_of(element)
|
|
361
|
+
if value is not None:
|
|
362
|
+
source.values.setdefault("/".join([*path, element_name]), []).append(value)
|
|
363
|
+
return
|
|
364
|
+
for element_child in element_children:
|
|
365
|
+
visit(element_child, [*path, element_name], depth + 1)
|
|
366
|
+
|
|
367
|
+
for element_child in root:
|
|
368
|
+
visit(element_child, [], 0)
|
|
369
|
+
return source
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _flat_items(source: _Source) -> list[dict[str, str]]:
|
|
373
|
+
"""Line items written as repeated top-level elements (数电票 custom tags)."""
|
|
374
|
+
columns = {leaf: source.values.get(leaf, []) for leaf in _ITEM_FIELDS}
|
|
375
|
+
count = max((len(values) for values in columns.values()), default=0)
|
|
376
|
+
return [
|
|
377
|
+
{leaf: values[index] for leaf, values in columns.items() if index < len(values)}
|
|
378
|
+
for index in range(count)
|
|
379
|
+
]
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _line_item(values: dict[str, str]) -> LineItem:
|
|
383
|
+
return LineItem(**{attribute: values.get(leaf) for leaf, attribute in _ITEM_FIELDS.items()})
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _normalise(invoice: Invoice) -> None:
|
|
387
|
+
for attribute in ("amount_without_tax", "tax_amount", "total_amount"):
|
|
388
|
+
setattr(invoice, attribute, _amount(getattr(invoice, attribute)))
|
|
389
|
+
for item in invoice.items:
|
|
390
|
+
item.amount = _amount(item.amount)
|
|
391
|
+
item.tax_amount = _amount(item.tax_amount)
|
|
392
|
+
item.unit_price = _amount(item.unit_price)
|
|
393
|
+
item.quantity = _amount(item.quantity)
|
|
394
|
+
invoice.issue_date = _date(invoice.issue_date)
|
|
395
|
+
for party in (invoice.buyer, invoice.seller):
|
|
396
|
+
if party.tax_id:
|
|
397
|
+
party.tax_id = re.sub(r"\s+", "", party.tax_id).upper()
|
|
398
|
+
if invoice.invoice_number:
|
|
399
|
+
invoice.invoice_number = re.sub(r"\s+", "", invoice.invoice_number)
|
|
400
|
+
|
|
401
|
+
totals = [_decimal(invoice.amount_without_tax), _decimal(invoice.tax_amount), _decimal(invoice.total_amount)]
|
|
402
|
+
if all(value is not None for value in totals):
|
|
403
|
+
without_tax, tax, total = totals
|
|
404
|
+
assert without_tax is not None and tax is not None and total is not None
|
|
405
|
+
if without_tax + tax != total:
|
|
406
|
+
invoice.warnings.append(
|
|
407
|
+
f"amount_without_tax + tax_amount != total_amount ({without_tax} + {tax} != {total})"
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _amount(value: str | None) -> str | None:
|
|
412
|
+
"""Strip currency signs, thousands separators and spaces from a money value."""
|
|
413
|
+
if value is None:
|
|
414
|
+
return None
|
|
415
|
+
cleaned = re.sub(r"[¥¥,,\s元]", "", value)
|
|
416
|
+
return cleaned if _AMOUNT.match(cleaned) else value
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _decimal(value: str | None) -> Decimal | None:
|
|
420
|
+
if value is None or not _AMOUNT.match(value):
|
|
421
|
+
return None
|
|
422
|
+
try:
|
|
423
|
+
return Decimal(value)
|
|
424
|
+
except InvalidOperation:
|
|
425
|
+
return None
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _date(value: str | None) -> str | None:
|
|
429
|
+
"""``2024年05月28日``, ``20240528`` or ``2024/5/28`` become ``2024-05-28``."""
|
|
430
|
+
if value is None:
|
|
431
|
+
return None
|
|
432
|
+
match = _DATE.match(value.strip())
|
|
433
|
+
if not match:
|
|
434
|
+
return value
|
|
435
|
+
year, month, day = (int(part) for part in match.groups())
|
|
436
|
+
if not (1 <= month <= 12 and 1 <= day <= 31):
|
|
437
|
+
return value
|
|
438
|
+
return f"{year:04d}-{month:02d}-{day:02d}"
|
ofdkit/layout.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Reading order: turn positioned text runs into lines of plain text."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class _Run(Protocol):
|
|
10
|
+
text: str
|
|
11
|
+
x: float
|
|
12
|
+
y: float
|
|
13
|
+
size: float
|
|
14
|
+
width: float
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Runs whose baselines differ by less than this share of the font size are one line.
|
|
18
|
+
_SAME_LINE = 0.5
|
|
19
|
+
# A horizontal gap wider than this share of the font size gets a space ...
|
|
20
|
+
_WORD_GAP = 0.25
|
|
21
|
+
# ... and a gap wider than this one marks separate columns.
|
|
22
|
+
_COLUMN_GAP = 1.5
|
|
23
|
+
# A vertical step larger than this many font sizes starts a new paragraph.
|
|
24
|
+
_PARAGRAPH_GAP = 2.2
|
|
25
|
+
# Smallest size used for tolerances, so tiny or missing font sizes still work.
|
|
26
|
+
_MIN_SIZE = 1.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def page_text(runs: Sequence[_Run]) -> str:
|
|
30
|
+
"""Join runs into text, top to bottom and left to right."""
|
|
31
|
+
lines = _group_lines([run for run in _dedupe(runs) if run.text.strip()])
|
|
32
|
+
out: list[str] = []
|
|
33
|
+
previous_y: float | None = None
|
|
34
|
+
previous_size = _MIN_SIZE
|
|
35
|
+
for line in lines:
|
|
36
|
+
size = max(min(run.size for run in line), _MIN_SIZE)
|
|
37
|
+
y = line[0].y
|
|
38
|
+
if previous_y is not None and y - previous_y > _PARAGRAPH_GAP * max(size, previous_size):
|
|
39
|
+
out.append("")
|
|
40
|
+
out.append(_join_line(line))
|
|
41
|
+
previous_y, previous_size = y, size
|
|
42
|
+
return "\n".join(out).strip("\n")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _dedupe(runs: Sequence[_Run]) -> list[_Run]:
|
|
46
|
+
"""Drop runs drawn twice at the same spot, a common way to fake bold text."""
|
|
47
|
+
seen: set[tuple[str, int, int]] = set()
|
|
48
|
+
unique = []
|
|
49
|
+
for run in runs:
|
|
50
|
+
key = (run.text, round(run.x * 4), round(run.y * 4))
|
|
51
|
+
if key not in seen:
|
|
52
|
+
seen.add(key)
|
|
53
|
+
unique.append(run)
|
|
54
|
+
return unique
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _group_lines(runs: list[_Run]) -> list[list[_Run]]:
|
|
58
|
+
lines: list[list[_Run]] = []
|
|
59
|
+
for run in sorted(runs, key=lambda r: (r.y, r.x)):
|
|
60
|
+
if lines:
|
|
61
|
+
line = lines[-1]
|
|
62
|
+
anchor = line[0]
|
|
63
|
+
tolerance = _SAME_LINE * max(min(run.size, anchor.size), _MIN_SIZE)
|
|
64
|
+
if abs(run.y - anchor.y) <= tolerance:
|
|
65
|
+
line.append(run)
|
|
66
|
+
continue
|
|
67
|
+
lines.append([run])
|
|
68
|
+
for line in lines:
|
|
69
|
+
line.sort(key=lambda r: r.x)
|
|
70
|
+
return lines
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _join_line(line: list[_Run]) -> str:
|
|
74
|
+
parts = [line[0].text]
|
|
75
|
+
for previous, run in zip(line, line[1:]):
|
|
76
|
+
size = max(min(previous.size, run.size), _MIN_SIZE)
|
|
77
|
+
gap = run.x - (previous.x + previous.width)
|
|
78
|
+
if gap > _COLUMN_GAP * size:
|
|
79
|
+
parts.append(" ")
|
|
80
|
+
elif gap > _WORD_GAP * size:
|
|
81
|
+
parts.append(" ")
|
|
82
|
+
parts.append(run.text)
|
|
83
|
+
# Leading spaces are layout, not content, and would start a code block in Markdown.
|
|
84
|
+
return "".join(parts).strip()
|
ofdkit/markdown.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Markdown output: the document's text, plus an invoice summary table for e-invoices."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from .document import OFDDocument
|
|
8
|
+
from .errors import OFDError
|
|
9
|
+
from .invoice import Invoice
|
|
10
|
+
|
|
11
|
+
_INVOICE_ROWS = [
|
|
12
|
+
("发票号码", lambda i: i.invoice_number),
|
|
13
|
+
("发票代码", lambda i: i.invoice_code),
|
|
14
|
+
("开票日期", lambda i: i.issue_date),
|
|
15
|
+
("校验码", lambda i: i.check_code),
|
|
16
|
+
("购买方名称", lambda i: i.buyer.name),
|
|
17
|
+
("购买方纳税人识别号", lambda i: i.buyer.tax_id),
|
|
18
|
+
("购买方地址电话", lambda i: i.buyer.address_phone),
|
|
19
|
+
("购买方开户行及账号", lambda i: i.buyer.bank_account),
|
|
20
|
+
("销售方名称", lambda i: i.seller.name),
|
|
21
|
+
("销售方纳税人识别号", lambda i: i.seller.tax_id),
|
|
22
|
+
("销售方地址电话", lambda i: i.seller.address_phone),
|
|
23
|
+
("销售方开户行及账号", lambda i: i.seller.bank_account),
|
|
24
|
+
("合计金额", lambda i: i.amount_without_tax),
|
|
25
|
+
("合计税额", lambda i: i.tax_amount),
|
|
26
|
+
("价税合计", lambda i: i.total_amount),
|
|
27
|
+
("开票人", lambda i: i.issuer),
|
|
28
|
+
("收款人", lambda i: i.payee),
|
|
29
|
+
("复核人", lambda i: i.reviewer),
|
|
30
|
+
("备注", lambda i: i.note),
|
|
31
|
+
]
|
|
32
|
+
_ITEM_COLUMNS = [
|
|
33
|
+
("项目名称", "name"),
|
|
34
|
+
("规格型号", "specification"),
|
|
35
|
+
("单位", "unit"),
|
|
36
|
+
("数量", "quantity"),
|
|
37
|
+
("单价", "unit_price"),
|
|
38
|
+
("金额", "amount"),
|
|
39
|
+
("税率", "tax_rate"),
|
|
40
|
+
("税额", "tax_amount"),
|
|
41
|
+
]
|
|
42
|
+
_INLINE_SPECIAL = re.compile(r"([\\`*_])")
|
|
43
|
+
_LINE_START_SPECIAL = re.compile(r"^(\s*)([#>])", re.MULTILINE)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def document_markdown(document: OFDDocument) -> str:
|
|
47
|
+
"""Markdown for a whole document.
|
|
48
|
+
|
|
49
|
+
E-invoices start with a table of their fields. Multi-page documents get a heading per
|
|
50
|
+
page. Characters that Markdown would treat as formatting are escaped, so that invoice
|
|
51
|
+
item names such as ``*信息技术服务*`` keep their asterisks.
|
|
52
|
+
"""
|
|
53
|
+
sections: list[str] = []
|
|
54
|
+
invoice = document.invoice()
|
|
55
|
+
if invoice is not None:
|
|
56
|
+
sections.append(invoice_markdown(invoice))
|
|
57
|
+
|
|
58
|
+
pages = document.pages
|
|
59
|
+
use_headings = invoice is not None or len(pages) > 1
|
|
60
|
+
for page in pages:
|
|
61
|
+
try:
|
|
62
|
+
text = escape(page.text())
|
|
63
|
+
except OFDError as exc:
|
|
64
|
+
document.warnings.append(f"page {page.index + 1}: {exc}")
|
|
65
|
+
text = ""
|
|
66
|
+
if use_headings:
|
|
67
|
+
heading = f"## 第 {page.index + 1} 页" if len(pages) > 1 else "## 原文"
|
|
68
|
+
sections.append(f"{heading}\n\n{text}".rstrip())
|
|
69
|
+
elif text:
|
|
70
|
+
sections.append(text)
|
|
71
|
+
return "\n\n".join(sections).strip() + "\n"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def invoice_markdown(invoice: Invoice) -> str:
|
|
75
|
+
"""A Markdown table of the invoice's fields, followed by its line items."""
|
|
76
|
+
lines = ["## 发票信息", "", "| 项目 | 内容 |", "| --- | --- |"]
|
|
77
|
+
for label, get in _INVOICE_ROWS:
|
|
78
|
+
value = get(invoice)
|
|
79
|
+
if value:
|
|
80
|
+
lines.append(f"| {label} | {_cell(value)} |")
|
|
81
|
+
if invoice.items:
|
|
82
|
+
lines += ["", "### 明细", ""]
|
|
83
|
+
columns = [(label, attribute) for label, attribute in _ITEM_COLUMNS if any(getattr(item, attribute) for item in invoice.items)]
|
|
84
|
+
lines.append("| " + " | ".join(label for label, _ in columns) + " |")
|
|
85
|
+
lines.append("|" + " --- |" * len(columns))
|
|
86
|
+
for item in invoice.items:
|
|
87
|
+
lines.append("| " + " | ".join(_cell(getattr(item, attribute) or "") for _, attribute in columns) + " |")
|
|
88
|
+
if invoice.warnings:
|
|
89
|
+
lines += ["", *[f"> 注意:{escape(warning)}" for warning in invoice.warnings]]
|
|
90
|
+
return "\n".join(lines)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def escape(text: str) -> str:
|
|
94
|
+
"""Escape characters that would turn plain text into Markdown formatting."""
|
|
95
|
+
text = _INLINE_SPECIAL.sub(r"\\\1", text)
|
|
96
|
+
return _LINE_START_SPECIAL.sub(r"\1\\\2", text)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _cell(value: str) -> str:
|
|
100
|
+
return escape(value).replace("|", "\\|").replace("\r", " ").replace("\n", " ")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""MarkItDown plugin: convert OFD files to Markdown with ``markitdown --use-plugins``.
|
|
2
|
+
|
|
3
|
+
Install with ``pip install "ofdkit[markitdown]"``. MarkItDown finds the plugin through the
|
|
4
|
+
``markitdown.plugin`` entry point declared in ofdkit's package metadata.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import zipfile
|
|
10
|
+
from typing import Any, BinaryIO
|
|
11
|
+
|
|
12
|
+
from markitdown import DocumentConverter, DocumentConverterResult, StreamInfo
|
|
13
|
+
|
|
14
|
+
from .document import OFDDocument
|
|
15
|
+
from .markdown import document_markdown
|
|
16
|
+
|
|
17
|
+
__plugin_interface_version__ = 1
|
|
18
|
+
|
|
19
|
+
ACCEPTED_EXTENSIONS = (".ofd",)
|
|
20
|
+
ACCEPTED_MIME_TYPES = ("application/ofd", "application/x-ofd")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class OfdConverter(DocumentConverter):
|
|
24
|
+
"""Converts OFD documents (GB/T 33190) to Markdown."""
|
|
25
|
+
|
|
26
|
+
def accepts(self, file_stream: BinaryIO, stream_info: StreamInfo, **kwargs: Any) -> bool:
|
|
27
|
+
if (stream_info.extension or "").lower() in ACCEPTED_EXTENSIONS:
|
|
28
|
+
return True
|
|
29
|
+
if (stream_info.mimetype or "").lower().split(";")[0].strip() in ACCEPTED_MIME_TYPES:
|
|
30
|
+
return True
|
|
31
|
+
return _looks_like_ofd(file_stream)
|
|
32
|
+
|
|
33
|
+
def convert(self, file_stream: BinaryIO, stream_info: StreamInfo, **kwargs: Any) -> DocumentConverterResult:
|
|
34
|
+
with OFDDocument(file_stream.read()) as document:
|
|
35
|
+
return DocumentConverterResult(markdown=document_markdown(document), title=document.info.title)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def register_converters(markitdown: Any, **kwargs: Any) -> None:
|
|
39
|
+
"""Called by MarkItDown when plugins are enabled."""
|
|
40
|
+
markitdown.register_converter(OfdConverter())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _looks_like_ofd(file_stream: BinaryIO) -> bool:
|
|
44
|
+
"""A ZIP file whose root holds OFD.xml. The stream position is restored."""
|
|
45
|
+
position = file_stream.tell()
|
|
46
|
+
try:
|
|
47
|
+
if file_stream.read(4) != b"PK\x03\x04":
|
|
48
|
+
return False
|
|
49
|
+
file_stream.seek(position)
|
|
50
|
+
with zipfile.ZipFile(file_stream) as archive:
|
|
51
|
+
return any(name.lstrip("/").lower() == "ofd.xml" for name in archive.namelist())
|
|
52
|
+
except (zipfile.BadZipFile, OSError, ValueError, EOFError):
|
|
53
|
+
return False
|
|
54
|
+
finally:
|
|
55
|
+
file_stream.seek(position)
|