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/document.py ADDED
@@ -0,0 +1,509 @@
1
+ """Document model: OFD.xml, Document.xml, pages, templates and text objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import unicodedata
7
+ import xml.etree.ElementTree as ET
8
+ from collections.abc import Mapping
9
+ from dataclasses import dataclass, field
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from ._xml import child, child_text, children, grandchildren, local_name
13
+ from .errors import OFDError, OFDFormatError
14
+ from .layout import page_text
15
+ from .package import Package, Source
16
+
17
+ if TYPE_CHECKING:
18
+ from .invoice import Invoice
19
+
20
+ #: Page box used when a file does not give one: A4 portrait, in millimetres.
21
+ DEFAULT_PAGE_BOX = (0.0, 0.0, 210.0, 297.0)
22
+ #: Deepest nesting of PageBlock and CompositeObject elements that is followed.
23
+ MAX_NESTING = 32
24
+ #: Text may stick out of the page box by this much (mm) and still count as on the page.
25
+ _PAGE_MARGIN = 2.0
26
+
27
+ # An affine transform (a, b, c, d, e, f): x' = a*x + c*y + e, y' = b*x + d*y + f.
28
+ Affine = tuple[float, float, float, float, float, float]
29
+ _IDENTITY: Affine = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class DocInfo:
34
+ """Document metadata from OFD.xml (DocBody/DocInfo)."""
35
+
36
+ doc_id: str | None = None
37
+ title: str | None = None
38
+ author: str | None = None
39
+ subject: str | None = None
40
+ abstract: str | None = None
41
+ creation_date: str | None = None
42
+ mod_date: str | None = None
43
+ creator: str | None = None
44
+ creator_version: str | None = None
45
+ keywords: tuple[str, ...] = ()
46
+ custom_data: dict[str, str] = field(default_factory=dict)
47
+
48
+ def to_dict(self) -> dict[str, Any]:
49
+ return {
50
+ "doc_id": self.doc_id,
51
+ "title": self.title,
52
+ "author": self.author,
53
+ "subject": self.subject,
54
+ "abstract": self.abstract,
55
+ "creation_date": self.creation_date,
56
+ "mod_date": self.mod_date,
57
+ "creator": self.creator,
58
+ "creator_version": self.creator_version,
59
+ "keywords": list(self.keywords),
60
+ "custom_data": dict(self.custom_data),
61
+ }
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class TextRun:
66
+ """A piece of text as placed on a page.
67
+
68
+ Coordinates are in millimetres with the origin at the top left of the page.
69
+ ``y`` is the baseline. ``width`` is an estimate of the advance width.
70
+ """
71
+
72
+ text: str
73
+ x: float
74
+ y: float
75
+ size: float
76
+ width: float
77
+ object_id: str | None = None
78
+ from_template: bool = False
79
+
80
+
81
+ class Page:
82
+ """One page of an OFD document. Content is read on first use."""
83
+
84
+ def __init__(self, document: OFDDocument, body: _Body, index: int, page_id: str | None, location: str | None) -> None:
85
+ self._document = document
86
+ self._body = body
87
+ self.index = index
88
+ self.id = page_id
89
+ self.location = location
90
+ self._box: tuple[float, float, float, float] | None = None
91
+ self._runs: list[TextRun] | None = None
92
+ self._objects: dict[str, str] | None = None
93
+
94
+ @property
95
+ def box(self) -> tuple[float, float, float, float]:
96
+ """The page's physical box ``(x, y, width, height)`` in millimetres."""
97
+ self._load()
98
+ assert self._box is not None
99
+ return self._box
100
+
101
+ @property
102
+ def width(self) -> float:
103
+ return self.box[2]
104
+
105
+ @property
106
+ def height(self) -> float:
107
+ return self.box[3]
108
+
109
+ @property
110
+ def runs(self) -> list[TextRun]:
111
+ """All text runs on the page, including those from templates."""
112
+ self._load()
113
+ assert self._runs is not None
114
+ return list(self._runs)
115
+
116
+ def text(self) -> str:
117
+ """The page's visible text in reading order."""
118
+ x0, y0, width, height = self.box
119
+ visible = [
120
+ run
121
+ for run in self.runs
122
+ if x0 - _PAGE_MARGIN <= run.x <= x0 + width + _PAGE_MARGIN
123
+ and y0 - _PAGE_MARGIN <= run.y <= y0 + height + _PAGE_MARGIN
124
+ ]
125
+ return page_text(visible)
126
+
127
+ def object_text(self, object_id: str) -> str | None:
128
+ """Text of the text object with this ID, wherever it sits on the page."""
129
+ self._load()
130
+ assert self._objects is not None
131
+ return self._objects.get(object_id.strip())
132
+
133
+ def _load(self) -> None:
134
+ if self._runs is not None:
135
+ return
136
+ document = self._document
137
+ runs: list[TextRun] = []
138
+ objects: dict[str, str] = {}
139
+ box = self._body.page_box
140
+ if self.location is not None:
141
+ root = document._package.read_xml(self.location)
142
+ box = _parse_box(child_text(child(root, "Area"), "PhysicalBox")) or box
143
+ units = document._composite_units(self._body)
144
+ page_resources = [
145
+ path
146
+ for path in (document._package.resolve(self.location, (r.text or "")) for r in children(root, "PageRes"))
147
+ if path is not None
148
+ ]
149
+ if page_resources:
150
+ units = {**units, **document._load_units(page_resources)}
151
+ for reference in children(root, "Template"):
152
+ template = self._body.templates.get((reference.get("TemplateID") or "").strip())
153
+ if template is not None:
154
+ template_runs, template_objects = document._template(template, units)
155
+ runs.extend(template_runs)
156
+ for object_id, text in template_objects.items():
157
+ objects.setdefault(object_id, text)
158
+ page_runs, page_objects = _collect_text(root, units, from_template=False)
159
+ runs.extend(page_runs)
160
+ objects.update(page_objects)
161
+ self._box, self._runs, self._objects = box, runs, objects
162
+
163
+ def __repr__(self) -> str:
164
+ return f"<Page index={self.index} id={self.id!r} location={self.location!r}>"
165
+
166
+
167
+ @dataclass
168
+ class _Body:
169
+ """One DocBody of OFD.xml, resolved to entries in the package."""
170
+
171
+ document_path: str
172
+ page_box: tuple[float, float, float, float]
173
+ templates: dict[str, str]
174
+ resources: list[str]
175
+ custom_tags: str | None
176
+ attachments: str | None
177
+ units: dict[str, ET.Element] | None = None
178
+
179
+
180
+ class OFDDocument:
181
+ """An opened OFD file. Use :func:`ofdkit.open` to create one.
182
+
183
+ Close it when done, or use it as a context manager.
184
+ """
185
+
186
+ def __init__(self, source: Source) -> None:
187
+ self._package = Package(source)
188
+ self._templates: dict[str, tuple[list[TextRun], dict[str, str]]] = {}
189
+ self.warnings: list[str] = []
190
+ try:
191
+ self._load()
192
+ except BaseException:
193
+ self._package.close()
194
+ raise
195
+
196
+ def _load(self) -> None:
197
+ entry = self._package.find("OFD.xml")
198
+ if entry is None:
199
+ raise OFDFormatError("not an OFD file: OFD.xml is missing")
200
+ root = self._package.read_xml(entry)
201
+ if local_name(root.tag) != "OFD":
202
+ raise OFDFormatError(f"not an OFD file: OFD.xml has root <{local_name(root.tag)}>")
203
+ bodies = list(children(root, "DocBody"))
204
+ if not bodies:
205
+ raise OFDFormatError("OFD.xml contains no DocBody")
206
+
207
+ self.info = _parse_doc_info(child(bodies[0], "DocInfo"))
208
+ self._bodies: list[_Body] = []
209
+ self.pages: list[Page] = []
210
+ for number, body in enumerate(bodies, start=1):
211
+ location = child_text(body, "DocRoot")
212
+ document_path = self._package.resolve(entry, location)
213
+ if document_path is None:
214
+ self.warnings.append(f"document {number}: DocRoot not found ({location})")
215
+ continue
216
+ self._load_body(document_path)
217
+ if not self._bodies:
218
+ raise OFDFormatError("no readable document in the file (Document.xml not found)")
219
+
220
+ def _load_body(self, document_path: str) -> None:
221
+ package = self._package
222
+ document = package.read_xml(document_path)
223
+ common = child(document, "CommonData")
224
+ page_box = _parse_box(child_text(child(common, "PageArea"), "PhysicalBox")) or DEFAULT_PAGE_BOX
225
+ templates: dict[str, str] = {}
226
+ resources: list[str] = []
227
+ if common is not None:
228
+ for template in children(common, "TemplatePage"):
229
+ template_id = (template.get("ID") or "").strip()
230
+ resolved = package.resolve(document_path, template.get("BaseLoc"))
231
+ if template_id and resolved is not None:
232
+ templates[template_id] = resolved
233
+ elif template_id:
234
+ self.warnings.append(f"template {template_id}: not found ({template.get('BaseLoc')})")
235
+ for element in common:
236
+ if local_name(element.tag) in ("PublicRes", "DocumentRes"):
237
+ resolved = package.resolve(document_path, element.text)
238
+ if resolved is not None:
239
+ resources.append(resolved)
240
+ body = _Body(
241
+ document_path=document_path,
242
+ page_box=page_box,
243
+ templates=templates,
244
+ resources=resources,
245
+ custom_tags=package.resolve(document_path, child_text(document, "CustomTags")),
246
+ attachments=package.resolve(document_path, child_text(document, "Attachments")),
247
+ )
248
+ self._bodies.append(body)
249
+ for page in grandchildren(document, "Pages", "Page"):
250
+ location = package.resolve(document_path, page.get("BaseLoc"))
251
+ if location is None:
252
+ self.warnings.append(f"page {len(self.pages) + 1}: content not found ({page.get('BaseLoc')})")
253
+ page_id = (page.get("ID") or "").strip() or None
254
+ self.pages.append(Page(self, body, len(self.pages), page_id, location))
255
+
256
+ def _composite_units(self, body: _Body) -> dict[str, ET.Element]:
257
+ if body.units is None:
258
+ body.units = self._load_units(body.resources)
259
+ return body.units
260
+
261
+ def _load_units(self, resource_paths: list[str]) -> dict[str, ET.Element]:
262
+ """Composite graphic units by ID, from resource files (PublicRes, DocumentRes, PageRes)."""
263
+ units: dict[str, ET.Element] = {}
264
+ for path in resource_paths:
265
+ try:
266
+ root = self._package.read_xml(path)
267
+ except OFDError as exc:
268
+ self.warnings.append(f"resources {path}: {exc}")
269
+ continue
270
+ for unit in grandchildren(root, "CompositeGraphicUnits", "CompositeGraphicUnit"):
271
+ unit_id = (unit.get("ID") or "").strip()
272
+ if unit_id:
273
+ units.setdefault(unit_id, unit)
274
+ return units
275
+
276
+ def _template(self, location: str, units: Mapping[str, ET.Element]) -> tuple[list[TextRun], dict[str, str]]:
277
+ if location not in self._templates:
278
+ try:
279
+ root = self._package.read_xml(location)
280
+ except OFDError as exc:
281
+ self.warnings.append(f"template {location}: {exc}")
282
+ self._templates[location] = ([], {})
283
+ else:
284
+ self._templates[location] = _collect_text(root, units, from_template=True)
285
+ return self._templates[location]
286
+
287
+ def page_by_id(self, page_id: str) -> Page | None:
288
+ """The page with this ID (as used by PageRef in custom tags), or None."""
289
+ page_id = page_id.strip()
290
+ for page in self.pages:
291
+ if page.id == page_id:
292
+ return page
293
+ return None
294
+
295
+ def text(self, page_separator: str = "\n\n") -> str:
296
+ """All pages' text in reading order. A page that cannot be read adds a warning."""
297
+ texts = []
298
+ for page in self.pages:
299
+ try:
300
+ texts.append(page.text())
301
+ except OFDError as exc:
302
+ self.warnings.append(f"page {page.index + 1}: {exc}")
303
+ texts.append("")
304
+ return page_separator.join(texts)
305
+
306
+ def invoice(self) -> Invoice | None:
307
+ """Invoice data if this file is an e-invoice, else None."""
308
+ from .invoice import read_invoice
309
+
310
+ return read_invoice(self)
311
+
312
+ def close(self) -> None:
313
+ self._package.close()
314
+
315
+ def __enter__(self) -> OFDDocument:
316
+ return self
317
+
318
+ def __exit__(self, *exc_info: object) -> None:
319
+ self.close()
320
+
321
+ def __repr__(self) -> str:
322
+ return f"<OFDDocument pages={len(self.pages)}>"
323
+
324
+
325
+ def _parse_doc_info(element: ET.Element | None) -> DocInfo:
326
+ if element is None:
327
+ return DocInfo()
328
+ keywords = tuple(
329
+ keyword.text.strip()
330
+ for keyword in grandchildren(element, "Keywords", "Keyword")
331
+ if keyword.text and keyword.text.strip()
332
+ )
333
+ custom: dict[str, str] = {}
334
+ for item in grandchildren(element, "CustomDatas", "CustomData"):
335
+ name = (item.get("Name") or "").strip()
336
+ if name:
337
+ custom[name] = (item.text or "").strip()
338
+ return DocInfo(
339
+ doc_id=child_text(element, "DocID"),
340
+ title=child_text(element, "Title"),
341
+ author=child_text(element, "Author"),
342
+ subject=child_text(element, "Subject"),
343
+ abstract=child_text(element, "Abstract"),
344
+ creation_date=child_text(element, "CreationDate"),
345
+ mod_date=child_text(element, "ModDate"),
346
+ creator=child_text(element, "Creator"),
347
+ creator_version=child_text(element, "CreatorVersion"),
348
+ keywords=keywords,
349
+ custom_data=custom,
350
+ )
351
+
352
+
353
+ def _collect_text(
354
+ page_root: ET.Element, units: Mapping[str, ET.Element], *, from_template: bool
355
+ ) -> tuple[list[TextRun], dict[str, str]]:
356
+ """Text runs and object texts from a page or template Content.xml."""
357
+ runs: list[TextRun] = []
358
+ objects: dict[str, str] = {}
359
+
360
+ def walk(elements: list[ET.Element], transform: Affine, depth: int) -> None:
361
+ for element in elements:
362
+ name = local_name(element.tag)
363
+ if name == "TextObject":
364
+ object_runs, text = _text_object(element, transform, from_template)
365
+ runs.extend(object_runs)
366
+ object_id = (element.get("ID") or "").strip()
367
+ if object_id:
368
+ objects.setdefault(object_id, text)
369
+ elif depth >= MAX_NESTING:
370
+ continue
371
+ elif name == "PageBlock":
372
+ walk(list(element), transform, depth + 1)
373
+ elif name == "CompositeObject":
374
+ unit = units.get((element.get("ResourceID") or "").strip())
375
+ content = child(unit, "Content")
376
+ if content is not None:
377
+ walk(list(content), _compose(transform, _object_transform(element)), depth + 1)
378
+
379
+ content = child(page_root, "Content")
380
+ if content is not None:
381
+ for layer in children(content, "Layer"):
382
+ walk(list(layer), _IDENTITY, 0)
383
+ return runs, objects
384
+
385
+
386
+ def _text_object(element: ET.Element, transform: Affine, from_template: bool) -> tuple[list[TextRun], str]:
387
+ """Runs for each TextCode of a TextObject, plus the object's full text."""
388
+ a, b, c, d, e, f = _compose(transform, _object_transform(element))
389
+ size = _parse_float(element.get("Size"), 0.0)
390
+ effective_size = size * (math.sqrt(abs(a * d - b * c)) or 1.0)
391
+ x_scale = math.hypot(a, b) or 1.0
392
+ object_id = (element.get("ID") or "").strip() or None
393
+
394
+ runs: list[TextRun] = []
395
+ texts: list[str] = []
396
+ x = y = 0.0
397
+ for code in children(element, "TextCode"):
398
+ text = _clean(code.text)
399
+ # A TextCode without X or Y reuses the previous TextCode's value.
400
+ x = _parse_float(code.get("X"), x)
401
+ y = _parse_float(code.get("Y"), y)
402
+ texts.append(text)
403
+ if not text:
404
+ continue
405
+ deltas = _parse_deltas(code.get("DeltaX"), len(text) - 1)
406
+ runs.append(
407
+ TextRun(
408
+ text=text,
409
+ x=a * x + c * y + e,
410
+ y=b * x + d * y + f,
411
+ size=effective_size,
412
+ width=_advance(text, deltas, size) * x_scale,
413
+ object_id=object_id,
414
+ from_template=from_template,
415
+ )
416
+ )
417
+ return runs, "".join(texts)
418
+
419
+
420
+ def _object_transform(element: ET.Element) -> Affine:
421
+ """Object space to parent space: the CTM, then a move to the Boundary's origin."""
422
+ box = _parse_box(element.get("Boundary")) or (0.0, 0.0, 0.0, 0.0)
423
+ a, b, c, d, e, f = _parse_numbers(element.get("CTM"), 6) or _IDENTITY
424
+ return (a, b, c, d, e + box[0], f + box[1])
425
+
426
+
427
+ def _compose(outer: Affine, inner: Affine) -> Affine:
428
+ """The transform that applies ``inner`` first, then ``outer``."""
429
+ A, B, C, D, E, F = outer
430
+ a, b, c, d, e, f = inner
431
+ return (A * a + C * b, B * a + D * b, A * c + C * d, B * c + D * d, A * e + C * f + E, B * e + D * f + F)
432
+
433
+
434
+ def _advance(text: str, deltas: list[float], size: float) -> float:
435
+ """Advance width of ``text`` in object space, from DeltaX where given."""
436
+ total = 0.0
437
+ last = len(text) - 1
438
+ for index, char in enumerate(text):
439
+ if index < last and index < len(deltas):
440
+ total += deltas[index]
441
+ else:
442
+ total += _char_width(char, size)
443
+ return total
444
+
445
+
446
+ def _char_width(char: str, size: float) -> float:
447
+ return size if unicodedata.east_asian_width(char) in ("W", "F") else size * 0.5
448
+
449
+
450
+ def _clean(text: str | None) -> str:
451
+ """Drop control characters; tabs become spaces."""
452
+ if not text:
453
+ return ""
454
+ return "".join(" " if char == "\t" else char for char in text if char == "\t" or unicodedata.category(char) != "Cc")
455
+
456
+
457
+ def _parse_float(value: str | None, default: float) -> float:
458
+ if value is None:
459
+ return default
460
+ try:
461
+ number = float(value)
462
+ except ValueError:
463
+ return default
464
+ return number if math.isfinite(number) else default
465
+
466
+
467
+ def _parse_numbers(value: str | None, count: int) -> tuple[float, ...] | None:
468
+ if not value:
469
+ return None
470
+ try:
471
+ numbers = tuple(float(part) for part in value.replace(",", " ").split())
472
+ except ValueError:
473
+ return None
474
+ if len(numbers) < count or not all(math.isfinite(n) for n in numbers[:count]):
475
+ return None
476
+ return numbers[:count]
477
+
478
+
479
+ def _parse_box(value: str | None) -> tuple[float, float, float, float] | None:
480
+ numbers = _parse_numbers(value, 4)
481
+ if numbers is None:
482
+ return None
483
+ return (numbers[0], numbers[1], numbers[2], numbers[3])
484
+
485
+
486
+ def _parse_deltas(value: str | None, limit: int) -> list[float]:
487
+ """Parse a DeltaX/DeltaY list, expanding the ``g <count> <value>`` shorthand.
488
+
489
+ Only the first ``limit`` values are kept, so a huge repeat count cannot use much memory.
490
+ """
491
+ if not value or limit <= 0:
492
+ return []
493
+ tokens = value.split()
494
+ deltas: list[float] = []
495
+ index = 0
496
+ while index < len(tokens) and len(deltas) < limit:
497
+ token = tokens[index]
498
+ try:
499
+ if token == "g" and index + 2 < len(tokens):
500
+ count = int(float(tokens[index + 1]))
501
+ delta = float(tokens[index + 2])
502
+ deltas.extend([delta] * max(0, min(count, limit - len(deltas))))
503
+ index += 3
504
+ continue
505
+ deltas.append(float(token))
506
+ except (ValueError, OverflowError):
507
+ pass
508
+ index += 1
509
+ return deltas
ofdkit/errors.py ADDED
@@ -0,0 +1,13 @@
1
+ """Exceptions raised by ofdkit."""
2
+
3
+
4
+ class OFDError(Exception):
5
+ """Base class for all errors raised while reading an OFD file."""
6
+
7
+
8
+ class OFDFormatError(OFDError):
9
+ """The file is not an OFD document, or a required part cannot be read."""
10
+
11
+
12
+ class OFDLimitError(OFDError):
13
+ """The file exceeds a safety limit, such as part size or entry count."""