Skip to content

Commit 0800742

Browse files
Add functionality to export the CLF structure back to XML.
1 parent 1d8bb31 commit 0800742

File tree

7 files changed

+642
-15
lines changed

7 files changed

+642
-15
lines changed

colour_clf_io/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,3 +153,12 @@ def parse_clf(text: str | bytes) -> ProcessList | None:
153153
xml = lxml.etree.fromstring(text) # noqa: S320
154154

155155
return ProcessList.from_xml(xml)
156+
157+
158+
def write_clf(process_list: ProcessList, path: str | Path | None = None) -> None | str:
159+
xml = process_list.to_xml()
160+
serialised = lxml.etree.tostring(xml)
161+
if path is None:
162+
return serialised.decode("utf-8")
163+
with open(path, "wb") as f:
164+
f.write(serialised)

colour_clf_io/elements.py

Lines changed: 87 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,28 @@
88

99
from __future__ import annotations
1010

11+
import itertools
1112
import typing
1213
from dataclasses import dataclass
1314

1415
if typing.TYPE_CHECKING:
1516
import numpy.typing as npt
1617

17-
if typing.TYPE_CHECKING:
18-
import lxml.etree
18+
import lxml.etree
1919

2020
from colour_clf_io.errors import ParsingError
2121
from colour_clf_io.parsing import (
2222
ParserConfig,
2323
XMLParsable,
24+
XMLWritable,
2425
check_none,
2526
child_element,
2627
child_element_or_exception,
2728
map_optional,
2829
retrieve_attributes,
2930
retrieve_attributes_as_float,
31+
set_attr_if_not_none,
32+
set_element_if_not_none,
3033
three_floats,
3134
)
3235
from colour_clf_io.values import Channel
@@ -50,7 +53,7 @@
5053

5154

5255
@dataclass
53-
class Array(XMLParsable):
56+
class Array(XMLParsable, XMLWritable):
5457
"""
5558
Represent an *Array* element.
5659
@@ -124,6 +127,20 @@ def from_xml(
124127

125128
return Array(values=values, dim=dimensions)
126129

130+
def to_xml(self) -> lxml.etree._Element:
131+
xml = lxml.etree.Element("Array")
132+
xml.set("dim", " ".join(map(str, self.dim)))
133+
if len(self.dim) <= 1:
134+
xml.text = "\n".join(self.values)
135+
else:
136+
row_length = self.dim[-1]
137+
text = "\n".join(
138+
" ".join(map(str, row))
139+
for row in itertools.batched(self.values, row_length)
140+
)
141+
xml.text = text
142+
return xml
143+
127144
def as_array(self) -> npt.NDArray:
128145
"""
129146
Convert the *CLF* element into a numpy array.
@@ -144,7 +161,7 @@ def as_array(self) -> npt.NDArray:
144161

145162

146163
@dataclass
147-
class CalibrationInfo(XMLParsable):
164+
class CalibrationInfo(XMLParsable, XMLWritable):
148165
"""
149166
Represent a *CalibrationInfo* container element for a
150167
:class:`colour_clf_io.ProcessList` class instance.
@@ -227,9 +244,25 @@ def from_xml(
227244

228245
return CalibrationInfo(**attributes)
229246

247+
def to_xml(self) -> lxml.etree._Element:
248+
xml = lxml.etree.Element("CalibrationInfo")
249+
set_attr_if_not_none(
250+
xml, "DisplayDeviceSerialNum", self.display_device_serial_num
251+
)
252+
set_attr_if_not_none(
253+
xml, "DisplayDeviceHostName", self.display_device_host_name
254+
)
255+
set_attr_if_not_none(xml, "OperatorName", self.operator_name)
256+
set_attr_if_not_none(xml, "CalibrationDateTime", self.calibration_date_time)
257+
set_attr_if_not_none(xml, "MeasurementProbe", self.measurement_probe)
258+
set_attr_if_not_none(
259+
xml, "CalibrationSoftwareName", self.calibration_software_name
260+
)
261+
return xml
262+
230263

231264
@dataclass
232-
class SOPNode(XMLParsable):
265+
class SOPNode(XMLParsable, XMLWritable):
233266
"""
234267
Represent a *SOPNode* element for a :class:`colour_clf_io.ASC_CDL`
235268
*Process Node*.
@@ -312,6 +345,13 @@ def from_xml(
312345

313346
return SOPNode(slope=slope, offset=offset, power=power)
314347

348+
def to_xml(self) -> lxml.etree._Element:
349+
xml = lxml.etree.Element("SOPNode")
350+
set_element_if_not_none(xml, "Slope", " ".join(map(str, self.slope)))
351+
set_element_if_not_none(xml, "Offset", " ".join(map(str, self.offset)))
352+
set_element_if_not_none(xml, "Power", " ".join(map(str, self.power)))
353+
return xml
354+
315355
@classmethod
316356
def default(cls) -> SOPNode:
317357
"""
@@ -331,7 +371,7 @@ def default(cls) -> SOPNode:
331371

332372

333373
@dataclass
334-
class SatNode(XMLParsable):
374+
class SatNode(XMLParsable, XMLWritable):
335375
"""
336376
Represent a *SatNode* element for a :class:`colour_clf_io.ASC_CDL`
337377
*Process Node*.
@@ -399,6 +439,11 @@ def from_xml(
399439

400440
return SatNode(saturation=saturation)
401441

442+
def to_xml(self) -> lxml.etree._Element:
443+
xml = lxml.etree.Element("SatNode")
444+
set_element_if_not_none(xml, "Saturation", self.saturation)
445+
return xml
446+
402447
@classmethod
403448
def default(cls) -> SatNode:
404449
"""
@@ -414,7 +459,7 @@ def default(cls) -> SatNode:
414459

415460

416461
@dataclass
417-
class Info(XMLParsable):
462+
class Info(XMLParsable, XMLWritable):
418463
"""
419464
Represent an *Info* element.
420465
@@ -520,9 +565,20 @@ def from_xml(xml: lxml.etree._Element | None, config: ParserConfig) -> Info | No
520565

521566
return Info(calibration_info=calibration_info, **attributes)
522567

568+
def to_xml(self) -> lxml.etree._Element:
569+
xml = lxml.etree.Element("Info")
570+
set_attr_if_not_none(xml, "AppRelease", self.app_release)
571+
set_attr_if_not_none(xml, "Copyright", self.copyright)
572+
set_attr_if_not_none(xml, "Revision", self.revision)
573+
set_attr_if_not_none(xml, "AcesTransformID", self.aces_transform_id)
574+
set_attr_if_not_none(xml, "AcesUserName", self.aces_user_name)
575+
if self.calibration_info is not None:
576+
xml.append(self.calibration_info.to_xml())
577+
return xml
578+
523579

524580
@dataclass
525-
class LogParams(XMLParsable):
581+
class LogParams(XMLParsable, XMLWritable):
526582
"""
527583
Represent a *LogParams* element for a :class:`colour_clf_io.Log`
528584
*Process Node*.
@@ -641,14 +697,27 @@ def from_xml(
641697
"lin_side_slope": "linSideSlope",
642698
"lin_side_offset": "linSideOffset",
643699
"lin_side_break": "linSideBreak",
644-
"linear_slope": "linearSlope",
700+
"linear_slope": "",
645701
},
646702
)
647703

648704
channel = map_optional(Channel, xml.get("channel"))
649705

650706
return LogParams(channel=channel, **attributes)
651707

708+
def to_xml(self) -> lxml.etree._Element:
709+
xml = lxml.etree.Element("LogParams")
710+
set_attr_if_not_none(xml, "base", self.base)
711+
set_attr_if_not_none(xml, "logSideSlope", self.log_side_slope)
712+
set_attr_if_not_none(xml, "logSideOffset", self.log_side_offset)
713+
set_attr_if_not_none(xml, "linSideSlope", self.lin_side_slope)
714+
set_attr_if_not_none(xml, "linSideOffset", self.lin_side_offset)
715+
set_attr_if_not_none(xml, "linSideBreak", self.lin_side_break)
716+
set_attr_if_not_none(xml, "linearSlope", self.linear_slope)
717+
if self.channel is not None:
718+
xml.set("channel", self.channel.value)
719+
return xml
720+
652721
@classmethod
653722
def default(cls) -> LogParams:
654723
"""
@@ -673,7 +742,7 @@ def default(cls) -> LogParams:
673742

674743

675744
@dataclass
676-
class ExponentParams(XMLParsable):
745+
class ExponentParams(XMLParsable, XMLWritable):
677746
"""
678747
Represent a *ExponentParams* element for a :class:`colour_clf_io.Exponent`
679748
*Process Node*.
@@ -772,6 +841,14 @@ def from_xml(
772841

773842
return ExponentParams(channel=channel, exponent=exponent, **attributes)
774843

844+
def to_xml(self) -> lxml.etree._Element:
845+
xml = lxml.etree.Element("ExponentParams")
846+
set_attr_if_not_none(xml, "exponent", self.exponent)
847+
set_attr_if_not_none(xml, "offset", self.offset)
848+
if self.channel is not None:
849+
xml.set("channel", self.channel.value)
850+
return xml
851+
775852
@classmethod
776853
def default(cls) -> ExponentParams:
777854
"""

colour_clf_io/parsing.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,7 @@
1818
from collections.abc import Callable, Iterable
1919
from typing import Any
2020

21-
if typing.TYPE_CHECKING:
22-
import lxml.etree
21+
import lxml.etree
2322

2423
from colour_clf_io.errors import ParsingError
2524

@@ -120,6 +119,25 @@ def from_xml(
120119
"""
121120

122121

122+
class XMLWritable(ABC):
123+
"""
124+
Define the base class for objects that can be serialised to XML.
125+
126+
This is an :class:`ABCMeta` abstract class that must be inherited by
127+
sub-classes.
128+
129+
Methods
130+
-------
131+
- :meth:`~colour_lf_io.parsing.XMLParsable.to_xml`
132+
"""
133+
134+
@abstractmethod
135+
def to_xml(self) -> lxml.etree._Element:
136+
"""
137+
Serialise this object as an XML object.
138+
"""
139+
140+
123141
def map_optional(function: Callable, value: Any | None) -> Any:
124142
"""
125143
Apply the given function to given ``value`` if ``value`` is not ``None``.
@@ -490,3 +508,14 @@ def three_floats(text: str | None) -> tuple[float, float, float]:
490508
values = tuple(map(float, parts))
491509
# Note: Repacking here to satisfy type check.
492510
return values[0], values[1], values[2]
511+
512+
513+
def set_attr_if_not_none(node, attr, value):
514+
if value is not None:
515+
node.set(attr, str(value))
516+
517+
518+
def set_element_if_not_none(node, name, value):
519+
if value is not None and value != "":
520+
child = lxml.etree.SubElement(node, name)
521+
child.text = str(value)

colour_clf_io/process_list.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
check_none,
2020
element_as_text,
2121
elements_as_text_list,
22+
set_attr_if_not_none,
23+
set_element_if_not_none,
2224
)
2325
from colour_clf_io.process_nodes import (
2426
ProcessNode,
@@ -200,3 +202,22 @@ def from_xml(xml: lxml.etree._Element | None) -> ProcessList | None:
200202
info=info,
201203
description=description,
202204
)
205+
206+
def to_xml(self) -> lxml.etree._Element:
207+
xml = lxml.etree.Element("ProcessList")
208+
set_attr_if_not_none(xml, "id", self.id)
209+
set_attr_if_not_none(xml, "compCLFversion", self.compatible_CLF_version)
210+
set_attr_if_not_none(xml, "name", self.name)
211+
set_attr_if_not_none(xml, "inverseOf", self.inverse_of)
212+
set_element_if_not_none(xml, "InputDescriptor", self.input_descriptor)
213+
set_element_if_not_none(xml, "OutputDescriptor", self.output_descriptor)
214+
if self.info:
215+
xml.append(self.info.to_xml())
216+
for description_text in self.description:
217+
description_element = lxml.etree.SubElement(xml, "Description")
218+
description_element.text = description_text
219+
# TODO: we might have to store a single list of children in order to preserve
220+
# ordering of description and process nodes
221+
for process_node in self.process_nodes:
222+
xml.append(process_node.to_xml())
223+
return xml

0 commit comments

Comments
 (0)