edof 3.0.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.
- edof/__init__.py +91 -0
- edof/api/__init__.py +0 -0
- edof/api/commands.py +326 -0
- edof/engine/__init__.py +1 -0
- edof/engine/color.py +90 -0
- edof/engine/renderer.py +259 -0
- edof/engine/text_engine.py +251 -0
- edof/engine/transform.py +241 -0
- edof/exceptions.py +78 -0
- edof/export/__init__.py +1 -0
- edof/export/bitmap.py +92 -0
- edof/export/pdf.py +62 -0
- edof/export/printer.py +119 -0
- edof/format/__init__.py +1 -0
- edof/format/document.py +509 -0
- edof/format/objects.py +358 -0
- edof/format/serializer.py +149 -0
- edof/format/styles.py +176 -0
- edof/format/variables.py +155 -0
- edof/gui/__init__.py +0 -0
- edof/gui/pyqt6_widget.py +254 -0
- edof/gui/tkinter_canvas.py +541 -0
- edof/py.typed +0 -0
- edof/utils/__init__.py +1 -0
- edof/utils/compat.py +82 -0
- edof/utils/qr.py +69 -0
- edof/version.py +38 -0
- edof-3.0.0.dist-info/METADATA +473 -0
- edof-3.0.0.dist-info/RECORD +32 -0
- edof-3.0.0.dist-info/WHEEL +5 -0
- edof-3.0.0.dist-info/licenses/LICENSE +21 -0
- edof-3.0.0.dist-info/top_level.txt +1 -0
edof/__init__.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# edof/__init__.py
|
|
2
|
+
"""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
edof – Easy Document Format 3.0
|
|
5
|
+
================================
|
|
6
|
+
Programmatic document creation, template filling, and export.
|
|
7
|
+
|
|
8
|
+
Quick start::
|
|
9
|
+
|
|
10
|
+
import edof
|
|
11
|
+
|
|
12
|
+
doc = edof.Document(title="Hello")
|
|
13
|
+
page = doc.add_page()
|
|
14
|
+
tb = page.add_textbox(10, 10, 120, 20, "Hello, World!")
|
|
15
|
+
tb.style.font_size = 36
|
|
16
|
+
tb.style.auto_shrink = True
|
|
17
|
+
doc.save("hello.edof")
|
|
18
|
+
doc.export_bitmap("hello.png", dpi=300)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from edof.version import __version__, FORMAT_VERSION_STR # noqa: F401
|
|
22
|
+
from edof.exceptions import ( # noqa: F401
|
|
23
|
+
EdofError, EdofVersionError, EdofResourceError,
|
|
24
|
+
EdofRenderError, EdofVariableError, EdofAPIError,
|
|
25
|
+
EdofValidationError, EdofPrintError,
|
|
26
|
+
EdofNewerVersionWarning, EdofMissingOptionalWarning,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from edof.format.document import Document, Page, ResourceStore # noqa: F401
|
|
30
|
+
from edof.format.objects import ( # noqa: F401
|
|
31
|
+
EdofObject, TextBox, ImageBox, Shape, QRCode, Group,
|
|
32
|
+
SHAPE_RECT, SHAPE_ELLIPSE, SHAPE_LINE, SHAPE_POLYGON, SHAPE_ARROW,
|
|
33
|
+
)
|
|
34
|
+
from edof.format.styles import TextStyle, StrokeStyle, FillStyle, ShadowStyle # noqa: F401
|
|
35
|
+
from edof.format.variables import VariableStore, VariableDef # noqa: F401
|
|
36
|
+
from edof.format.variables import ( # noqa: F401
|
|
37
|
+
VAR_TEXT, VAR_IMAGE, VAR_NUMBER, VAR_DATE, VAR_BOOL, VAR_QR, VAR_URL,
|
|
38
|
+
)
|
|
39
|
+
from edof.format.serializer import EdofSerializer # noqa: F401
|
|
40
|
+
from edof.format.document import ( # noqa: F401
|
|
41
|
+
CS_RGB, CS_RGBA, CS_GRAY, CS_BW, CS_CMYK, BD_8, BD_16,
|
|
42
|
+
)
|
|
43
|
+
from edof.engine.transform import Transform, to_mm, from_mm, mm_to_px # noqa: F401
|
|
44
|
+
from edof.engine.renderer import render_page, render_document # noqa: F401
|
|
45
|
+
from edof.export.bitmap import ( # noqa: F401
|
|
46
|
+
export_page_bitmap, export_all_pages, export_to_bytes,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# ── Convenience aliases ───────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
def load(path: str) -> Document:
|
|
52
|
+
"""Load an .edof file and return a Document."""
|
|
53
|
+
return Document.load(path)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def new(width: float = 210.0, height: float = 297.0, **kwargs) -> Document:
|
|
57
|
+
"""Create a new blank Document."""
|
|
58
|
+
return Document(width=width, height=height, **kwargs)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
# Core
|
|
63
|
+
"Document", "Page", "ResourceStore",
|
|
64
|
+
# Objects
|
|
65
|
+
"EdofObject", "TextBox", "ImageBox", "Shape", "QRCode", "Group",
|
|
66
|
+
"SHAPE_RECT", "SHAPE_ELLIPSE", "SHAPE_LINE", "SHAPE_POLYGON", "SHAPE_ARROW",
|
|
67
|
+
# Styles
|
|
68
|
+
"TextStyle", "StrokeStyle", "FillStyle", "ShadowStyle",
|
|
69
|
+
# Variables
|
|
70
|
+
"VariableStore", "VariableDef",
|
|
71
|
+
"VAR_TEXT", "VAR_IMAGE", "VAR_NUMBER", "VAR_DATE", "VAR_BOOL", "VAR_QR", "VAR_URL",
|
|
72
|
+
# Serializer
|
|
73
|
+
"EdofSerializer",
|
|
74
|
+
# Color-space / bit-depth constants
|
|
75
|
+
"CS_RGB", "CS_RGBA", "CS_GRAY", "CS_BW", "CS_CMYK", "BD_8", "BD_16",
|
|
76
|
+
# Transform
|
|
77
|
+
"Transform", "to_mm", "from_mm", "mm_to_px",
|
|
78
|
+
# Render
|
|
79
|
+
"render_page", "render_document",
|
|
80
|
+
# Export helpers
|
|
81
|
+
"export_page_bitmap", "export_all_pages", "export_to_bytes",
|
|
82
|
+
# Convenience
|
|
83
|
+
"load", "new",
|
|
84
|
+
# Version
|
|
85
|
+
"__version__", "FORMAT_VERSION_STR",
|
|
86
|
+
# Exceptions
|
|
87
|
+
"EdofError", "EdofVersionError", "EdofResourceError",
|
|
88
|
+
"EdofRenderError", "EdofVariableError", "EdofAPIError",
|
|
89
|
+
"EdofValidationError", "EdofPrintError",
|
|
90
|
+
"EdofNewerVersionWarning", "EdofMissingOptionalWarning",
|
|
91
|
+
]
|
edof/api/__init__.py
ADDED
|
File without changes
|
edof/api/commands.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
# edof/api/commands.py
|
|
2
|
+
"""
|
|
3
|
+
Command API – a string-based command system usable from:
|
|
4
|
+
• editor undo/redo stacks
|
|
5
|
+
• external scripts / automation
|
|
6
|
+
• network / IPC APIs
|
|
7
|
+
|
|
8
|
+
Every command is a plain dict:
|
|
9
|
+
{"cmd": "set_text", "object_id": "...", "text": "Hello"}
|
|
10
|
+
|
|
11
|
+
The CommandRegistry maps command names → handler functions.
|
|
12
|
+
The CommandHistory manages undo/redo.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from edof.format.document import Document
|
|
21
|
+
|
|
22
|
+
Handler = Callable[["Document", dict], Any]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ── Registry ──────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
class CommandRegistry:
|
|
28
|
+
def __init__(self) -> None:
|
|
29
|
+
self._handlers: Dict[str, Handler] = {}
|
|
30
|
+
self._register_builtins()
|
|
31
|
+
|
|
32
|
+
def register(self, name: str, handler: Handler) -> None:
|
|
33
|
+
self._handlers[name] = handler
|
|
34
|
+
|
|
35
|
+
def execute(self, doc: "Document", command: dict) -> Any:
|
|
36
|
+
from edof.exceptions import EdofAPIError
|
|
37
|
+
name = command.get("cmd")
|
|
38
|
+
if not name:
|
|
39
|
+
raise EdofAPIError("Command dict must have a 'cmd' key.")
|
|
40
|
+
handler = self._handlers.get(name)
|
|
41
|
+
if handler is None:
|
|
42
|
+
raise EdofAPIError(f"Unknown command: {name!r}")
|
|
43
|
+
return handler(doc, command)
|
|
44
|
+
|
|
45
|
+
# ── Built-in commands ──────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
def _register_builtins(self) -> None:
|
|
48
|
+
self.register("set_text", _cmd_set_text)
|
|
49
|
+
self.register("set_variable", _cmd_set_variable)
|
|
50
|
+
self.register("fill_variables", _cmd_fill_variables)
|
|
51
|
+
self.register("add_page", _cmd_add_page)
|
|
52
|
+
self.register("remove_page", _cmd_remove_page)
|
|
53
|
+
self.register("add_textbox", _cmd_add_textbox)
|
|
54
|
+
self.register("add_image", _cmd_add_image)
|
|
55
|
+
self.register("add_shape", _cmd_add_shape)
|
|
56
|
+
self.register("add_qrcode", _cmd_add_qrcode)
|
|
57
|
+
self.register("remove_object", _cmd_remove_object)
|
|
58
|
+
self.register("move_object", _cmd_move_object)
|
|
59
|
+
self.register("resize_object", _cmd_resize_object)
|
|
60
|
+
self.register("rotate_object", _cmd_rotate_object)
|
|
61
|
+
self.register("set_style", _cmd_set_style)
|
|
62
|
+
self.register("set_visibility", _cmd_set_visibility)
|
|
63
|
+
self.register("set_layer", _cmd_set_layer)
|
|
64
|
+
self.register("export_bitmap", _cmd_export_bitmap)
|
|
65
|
+
self.register("export_pdf", _cmd_export_pdf)
|
|
66
|
+
self.register("save", _cmd_save)
|
|
67
|
+
self.register("validate", _cmd_validate)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ── Command handlers ──────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
def _get_obj(doc: "Document", command: dict):
|
|
73
|
+
from edof.exceptions import EdofAPIError
|
|
74
|
+
oid = command.get("object_id")
|
|
75
|
+
if not oid:
|
|
76
|
+
raise EdofAPIError("'object_id' required.")
|
|
77
|
+
page_idx = int(command.get("page", 0))
|
|
78
|
+
page = doc.pages[page_idx]
|
|
79
|
+
obj = page.get_object(oid)
|
|
80
|
+
if obj is None:
|
|
81
|
+
raise EdofAPIError(f"Object {oid!r} not found on page {page_idx}.")
|
|
82
|
+
return obj
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _cmd_set_text(doc, cmd):
|
|
86
|
+
obj = _get_obj(doc, cmd)
|
|
87
|
+
from edof.format.objects import TextBox
|
|
88
|
+
if not isinstance(obj, TextBox):
|
|
89
|
+
from edof.exceptions import EdofAPIError
|
|
90
|
+
raise EdofAPIError("Object is not a TextBox.")
|
|
91
|
+
obj.text = str(cmd.get("text", ""))
|
|
92
|
+
return obj.id
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _cmd_set_variable(doc, cmd):
|
|
96
|
+
name = cmd.get("name")
|
|
97
|
+
value = cmd.get("value", "")
|
|
98
|
+
doc.set_variable(name, value)
|
|
99
|
+
return name
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _cmd_fill_variables(doc, cmd):
|
|
103
|
+
mapping = cmd.get("mapping", {})
|
|
104
|
+
doc.fill_variables(mapping)
|
|
105
|
+
return list(mapping.keys())
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _cmd_add_page(doc, cmd):
|
|
109
|
+
page = doc.add_page(
|
|
110
|
+
width=cmd.get("width"), height=cmd.get("height"),
|
|
111
|
+
dpi=cmd.get("dpi"), color_space=cmd.get("color_space"),
|
|
112
|
+
)
|
|
113
|
+
return page.id
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _cmd_remove_page(doc, cmd):
|
|
117
|
+
doc.remove_page(int(cmd.get("index", 0)))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _cmd_add_textbox(doc, cmd):
|
|
121
|
+
page = doc.pages[int(cmd.get("page", 0))]
|
|
122
|
+
tb = page.add_textbox(
|
|
123
|
+
x=float(cmd.get("x", 0)),
|
|
124
|
+
y=float(cmd.get("y", 0)),
|
|
125
|
+
width=float(cmd.get("width", 80)),
|
|
126
|
+
height=float(cmd.get("height", 20)),
|
|
127
|
+
text=str(cmd.get("text", "")),
|
|
128
|
+
unit=cmd.get("unit", "mm"),
|
|
129
|
+
)
|
|
130
|
+
if "variable" in cmd:
|
|
131
|
+
tb.variable = cmd["variable"]
|
|
132
|
+
return tb.id
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _cmd_add_image(doc, cmd):
|
|
136
|
+
page = doc.pages[int(cmd.get("page", 0))]
|
|
137
|
+
rid = cmd.get("resource_id") or doc.add_resource_from_file(cmd["path"])
|
|
138
|
+
ib = page.add_image(resource_id=rid,
|
|
139
|
+
x=float(cmd.get("x", 0)),
|
|
140
|
+
y=float(cmd.get("y", 0)),
|
|
141
|
+
width=float(cmd.get("width", 50)),
|
|
142
|
+
height=float(cmd.get("height", 50)),
|
|
143
|
+
unit=cmd.get("unit", "mm"))
|
|
144
|
+
return ib.id
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _cmd_add_shape(doc, cmd):
|
|
148
|
+
page = doc.pages[int(cmd.get("page", 0))]
|
|
149
|
+
sh = page.add_shape(
|
|
150
|
+
shape_type=cmd.get("shape_type", "rect"),
|
|
151
|
+
x=float(cmd.get("x", 0)), y=float(cmd.get("y", 0)),
|
|
152
|
+
width=float(cmd.get("width", 50)), height=float(cmd.get("height", 30)),
|
|
153
|
+
unit=cmd.get("unit", "mm"),
|
|
154
|
+
)
|
|
155
|
+
return sh.id
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _cmd_add_qrcode(doc, cmd):
|
|
159
|
+
page = doc.pages[int(cmd.get("page", 0))]
|
|
160
|
+
qr = page.add_qrcode(
|
|
161
|
+
data=cmd.get("data", ""),
|
|
162
|
+
x=float(cmd.get("x", 0)), y=float(cmd.get("y", 0)),
|
|
163
|
+
size=float(cmd.get("size", 30)),
|
|
164
|
+
unit=cmd.get("unit", "mm"),
|
|
165
|
+
error_correction=cmd.get("error_correction", "M"),
|
|
166
|
+
)
|
|
167
|
+
return qr.id
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _cmd_remove_object(doc, cmd):
|
|
171
|
+
page = doc.pages[int(cmd.get("page", 0))]
|
|
172
|
+
return page.remove_object(cmd.get("object_id", ""))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _cmd_move_object(doc, cmd):
|
|
176
|
+
obj = _get_obj(doc, cmd)
|
|
177
|
+
obj.transform.translate(
|
|
178
|
+
float(cmd.get("dx", 0)),
|
|
179
|
+
float(cmd.get("dy", 0)),
|
|
180
|
+
cmd.get("unit", "mm"),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _cmd_resize_object(doc, cmd):
|
|
185
|
+
obj = _get_obj(doc, cmd)
|
|
186
|
+
if "factor" in cmd:
|
|
187
|
+
obj.transform.resize_uniform(float(cmd["factor"]))
|
|
188
|
+
else:
|
|
189
|
+
obj.transform.resize_free(
|
|
190
|
+
float(cmd.get("width", obj.transform.width)),
|
|
191
|
+
float(cmd.get("height", obj.transform.height)),
|
|
192
|
+
cmd.get("unit", "mm"),
|
|
193
|
+
cmd.get("anchor", "top-left"),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _cmd_rotate_object(doc, cmd):
|
|
198
|
+
obj = _get_obj(doc, cmd)
|
|
199
|
+
if "angle_absolute" in cmd:
|
|
200
|
+
obj.transform.rotate_to(float(cmd["angle_absolute"]))
|
|
201
|
+
else:
|
|
202
|
+
obj.transform.rotate(float(cmd.get("angle", 0)))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _cmd_set_style(doc, cmd):
|
|
206
|
+
obj = _get_obj(doc, cmd)
|
|
207
|
+
from edof.format.objects import TextBox
|
|
208
|
+
if isinstance(obj, TextBox):
|
|
209
|
+
for k, v in cmd.get("style", {}).items():
|
|
210
|
+
if hasattr(obj.style, k):
|
|
211
|
+
setattr(obj.style, k, v)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _cmd_set_visibility(doc, cmd):
|
|
215
|
+
obj = _get_obj(doc, cmd)
|
|
216
|
+
obj.visible = bool(cmd.get("visible", True))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _cmd_set_layer(doc, cmd):
|
|
220
|
+
obj = _get_obj(doc, cmd)
|
|
221
|
+
obj.layer = int(cmd.get("layer", 0))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _cmd_export_bitmap(doc, cmd):
|
|
225
|
+
from edof.export.bitmap import export_page_bitmap
|
|
226
|
+
export_page_bitmap(
|
|
227
|
+
doc,
|
|
228
|
+
page_index=int(cmd.get("page", 0)),
|
|
229
|
+
path=cmd["path"],
|
|
230
|
+
dpi=cmd.get("dpi"),
|
|
231
|
+
color_space=cmd.get("color_space"),
|
|
232
|
+
format=cmd.get("format", "PNG"),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _cmd_export_pdf(doc, cmd):
|
|
237
|
+
from edof.export.pdf import export_pdf
|
|
238
|
+
export_pdf(doc, cmd["path"])
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _cmd_save(doc, cmd):
|
|
242
|
+
doc.save(cmd["path"])
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _cmd_validate(doc, cmd):
|
|
246
|
+
return doc.validate()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ── History (undo/redo) ────────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
@dataclass
|
|
252
|
+
class HistoryEntry:
|
|
253
|
+
description: str
|
|
254
|
+
snapshot: bytes # serialised document state
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class CommandHistory:
|
|
258
|
+
"""
|
|
259
|
+
Undo/redo stack based on document snapshots.
|
|
260
|
+
Each snapshot is a full .edof file in memory (~fast for small documents).
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
def __init__(self, max_undo: int = 50) -> None:
|
|
264
|
+
self._stack: List[HistoryEntry] = []
|
|
265
|
+
self._pointer: int = -1
|
|
266
|
+
self._max: int = max_undo
|
|
267
|
+
|
|
268
|
+
def _snapshot(self, doc: "Document") -> bytes:
|
|
269
|
+
from edof.format.serializer import EdofSerializer
|
|
270
|
+
return EdofSerializer.to_bytes(doc)
|
|
271
|
+
|
|
272
|
+
def _restore(self, data: bytes) -> "Document":
|
|
273
|
+
from edof.format.serializer import EdofSerializer
|
|
274
|
+
return EdofSerializer.from_bytes(data)
|
|
275
|
+
|
|
276
|
+
def push(self, doc: "Document", description: str = "") -> None:
|
|
277
|
+
# Drop any redo states ahead of current pointer
|
|
278
|
+
self._stack = self._stack[:self._pointer + 1]
|
|
279
|
+
entry = HistoryEntry(description=description,
|
|
280
|
+
snapshot=self._snapshot(doc))
|
|
281
|
+
self._stack.append(entry)
|
|
282
|
+
if len(self._stack) > self._max:
|
|
283
|
+
self._stack.pop(0)
|
|
284
|
+
self._pointer = len(self._stack) - 1
|
|
285
|
+
|
|
286
|
+
def undo(self, doc: "Document") -> Optional["Document"]:
|
|
287
|
+
if self._pointer <= 0:
|
|
288
|
+
return None
|
|
289
|
+
self._pointer -= 1
|
|
290
|
+
return self._restore(self._stack[self._pointer].snapshot)
|
|
291
|
+
|
|
292
|
+
def redo(self, doc: "Document") -> Optional["Document"]:
|
|
293
|
+
if self._pointer >= len(self._stack) - 1:
|
|
294
|
+
return None
|
|
295
|
+
self._pointer += 1
|
|
296
|
+
return self._restore(self._stack[self._pointer].snapshot)
|
|
297
|
+
|
|
298
|
+
def can_undo(self) -> bool:
|
|
299
|
+
return self._pointer > 0
|
|
300
|
+
|
|
301
|
+
def can_redo(self) -> bool:
|
|
302
|
+
return self._pointer < len(self._stack) - 1
|
|
303
|
+
|
|
304
|
+
def description_undo(self) -> str:
|
|
305
|
+
if self._pointer > 0:
|
|
306
|
+
return self._stack[self._pointer].description
|
|
307
|
+
return ""
|
|
308
|
+
|
|
309
|
+
def description_redo(self) -> str:
|
|
310
|
+
if self._pointer < len(self._stack) - 1:
|
|
311
|
+
return self._stack[self._pointer + 1].description
|
|
312
|
+
return ""
|
|
313
|
+
|
|
314
|
+
def clear(self) -> None:
|
|
315
|
+
self._stack.clear()
|
|
316
|
+
self._pointer = -1
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# ── Module-level singleton registry ───────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
registry = CommandRegistry()
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def execute(doc: "Document", command: dict) -> Any:
|
|
325
|
+
"""Execute a command dict against a document using the global registry."""
|
|
326
|
+
return registry.execute(doc, command)
|
edof/engine/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# edof sub-package
|
edof/engine/color.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# edof/engine/color.py
|
|
2
|
+
"""
|
|
3
|
+
Colour-space helpers.
|
|
4
|
+
Conversion is delegated to Pillow; this module provides a clean interface
|
|
5
|
+
and validates the settings chosen in the document.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
from typing import Optional
|
|
10
|
+
from PIL import Image
|
|
11
|
+
|
|
12
|
+
# Mapping from edof CS constants → Pillow mode
|
|
13
|
+
PILLOW_MODE: dict[str, str] = {
|
|
14
|
+
"RGB": "RGB",
|
|
15
|
+
"RGBA": "RGBA",
|
|
16
|
+
"L": "L", # 8-bit grayscale
|
|
17
|
+
"1": "1", # 1-bit B&W
|
|
18
|
+
"CMYK": "CMYK",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
VALID_COLOR_SPACES = set(PILLOW_MODE.keys())
|
|
22
|
+
VALID_BIT_DEPTHS = {8, 16}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def validate(color_space: str, bit_depth: int) -> None:
|
|
26
|
+
from edof.exceptions import EdofValidationError
|
|
27
|
+
if color_space not in VALID_COLOR_SPACES:
|
|
28
|
+
raise EdofValidationError(
|
|
29
|
+
f"Unknown colour space {color_space!r}. "
|
|
30
|
+
f"Valid: {sorted(VALID_COLOR_SPACES)}"
|
|
31
|
+
)
|
|
32
|
+
if bit_depth not in VALID_BIT_DEPTHS:
|
|
33
|
+
raise EdofValidationError(
|
|
34
|
+
f"Unsupported bit depth {bit_depth}. Valid: {VALID_BIT_DEPTHS}"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def pillow_mode(color_space: str) -> str:
|
|
39
|
+
return PILLOW_MODE.get(color_space, "RGB")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def convert_image(img: Image.Image,
|
|
43
|
+
color_space: str,
|
|
44
|
+
bit_depth: int = 8) -> Image.Image:
|
|
45
|
+
"""
|
|
46
|
+
Convert a Pillow image to the target colour space and bit depth.
|
|
47
|
+
Always works non-destructively (returns new image).
|
|
48
|
+
"""
|
|
49
|
+
validate(color_space, bit_depth)
|
|
50
|
+
mode = pillow_mode(color_space)
|
|
51
|
+
|
|
52
|
+
# ── Colour-space conversion ────────────────────────────────────────────────
|
|
53
|
+
if img.mode != mode:
|
|
54
|
+
if mode == "1":
|
|
55
|
+
# Convert to grayscale first for clean B&W threshold
|
|
56
|
+
img = img.convert("L").convert("1")
|
|
57
|
+
elif mode == "CMYK" and img.mode in ("RGBA", "LA"):
|
|
58
|
+
img = img.convert("RGB").convert("CMYK")
|
|
59
|
+
else:
|
|
60
|
+
try:
|
|
61
|
+
img = img.convert(mode)
|
|
62
|
+
except Exception:
|
|
63
|
+
img = img.convert("RGB").convert(mode)
|
|
64
|
+
|
|
65
|
+
# ── Bit depth ─────────────────────────────────────────────────────────────
|
|
66
|
+
if bit_depth == 16 and mode in ("RGB", "L", "RGBA"):
|
|
67
|
+
import numpy as np
|
|
68
|
+
arr = np.array(img, dtype=np.uint16) * 257 # 8-bit → 16-bit range
|
|
69
|
+
# Pillow doesn't natively save 16-bit RGB; return as I;16 for TIFF
|
|
70
|
+
if mode == "L":
|
|
71
|
+
img = Image.fromarray(arr.astype(np.uint16), mode="I;16")
|
|
72
|
+
# For RGB 16-bit export Pillow uses mode "I;16" only for grayscale;
|
|
73
|
+
# callers should save as TIFF with bit_depth hint in metadata.
|
|
74
|
+
|
|
75
|
+
return img
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def background_image(width_px: int, height_px: int,
|
|
79
|
+
color_space: str,
|
|
80
|
+
background: tuple = (255, 255, 255)) -> Image.Image:
|
|
81
|
+
"""Create a blank canvas in the correct mode."""
|
|
82
|
+
mode = pillow_mode(color_space)
|
|
83
|
+
if mode == "1":
|
|
84
|
+
return Image.new("1", (width_px, height_px), 1)
|
|
85
|
+
if mode == "CMYK":
|
|
86
|
+
return Image.new("CMYK", (width_px, height_px), (0, 0, 0, 0))
|
|
87
|
+
# Clamp alpha for RGBA
|
|
88
|
+
if mode == "RGBA" and len(background) == 3:
|
|
89
|
+
background = (*background, 255)
|
|
90
|
+
return Image.new(mode, (width_px, height_px), background[:len(background)])
|