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/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."""
|