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/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)