Skip to content

Commit fdcda69

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

File tree

7 files changed

+785
-15
lines changed

7 files changed

+785
-15
lines changed

colour_clf_io/__init__.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,11 @@ def read_clf(path: str | Path) -> ProcessList:
127127
xml = lxml.etree.parse(str(path)) # noqa: S320
128128
xml_process_list = xml.getroot()
129129

130-
return ProcessList.from_xml(xml_process_list)
130+
process_list = ProcessList.from_xml(xml_process_list)
131+
if process_list is None:
132+
err = "Process list could not be parsed."
133+
raise ValueError(err)
134+
return process_list
131135

132136

133137
def parse_clf(text: str | bytes) -> ProcessList | None:
@@ -153,3 +157,29 @@ def parse_clf(text: str | bytes) -> ProcessList | None:
153157
xml = lxml.etree.fromstring(text) # noqa: S320
154158

155159
return ProcessList.from_xml(xml)
160+
161+
162+
def write_clf(process_list: ProcessList, path: str | Path | None = None) -> None | str:
163+
"""
164+
Write the given *ProcessList* as a CLF file to the target
165+
location. If no *path* is given the CLF document will be returned as a string.
166+
167+
Parameters
168+
----------
169+
process_list
170+
*ProcessList* that should be written.
171+
path
172+
Location of the file, or *None* to return a string representation of the
173+
CLF document.
174+
175+
Returns
176+
-------
177+
:class:`colour_clf_io.ProcessList`
178+
"""
179+
xml = process_list.to_xml()
180+
serialised = lxml.etree.tostring(xml)
181+
if path is None:
182+
return serialised.decode("utf-8")
183+
with open(path, "wb") as f:
184+
f.write(serialised)
185+
return None

colour_clf_io/elements.py

Lines changed: 135 additions & 9 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,27 @@ def from_xml(
124127

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

130+
def to_xml(self) -> lxml.etree._Element:
131+
"""
132+
Serialise this object as an XML object.
133+
134+
Returns
135+
-------
136+
:class:`lxml.etree._Element`
137+
"""
138+
xml = lxml.etree.Element("Array")
139+
xml.set("dim", " ".join(map(str, self.dim)))
140+
if len(self.dim) <= 1:
141+
xml.text = "\n".join(map(str, self.values))
142+
else:
143+
row_length = self.dim[-1]
144+
text = "\n".join(
145+
" ".join(map(str, row))
146+
for row in itertools.batched(self.values, row_length)
147+
)
148+
xml.text = text
149+
return xml
150+
127151
def as_array(self) -> npt.NDArray:
128152
"""
129153
Convert the *CLF* element into a numpy array.
@@ -144,7 +168,7 @@ def as_array(self) -> npt.NDArray:
144168

145169

146170
@dataclass
147-
class CalibrationInfo(XMLParsable):
171+
class CalibrationInfo(XMLParsable, XMLWritable):
148172
"""
149173
Represent a *CalibrationInfo* container element for a
150174
:class:`colour_clf_io.ProcessList` class instance.
@@ -227,9 +251,32 @@ def from_xml(
227251

228252
return CalibrationInfo(**attributes)
229253

254+
def to_xml(self) -> lxml.etree._Element:
255+
"""
256+
Serialise this object as an XML object.
257+
258+
Returns
259+
-------
260+
:class:`lxml.etree._Element`
261+
"""
262+
xml = lxml.etree.Element("CalibrationInfo")
263+
set_attr_if_not_none(
264+
xml, "DisplayDeviceSerialNum", self.display_device_serial_num
265+
)
266+
set_attr_if_not_none(
267+
xml, "DisplayDeviceHostName", self.display_device_host_name
268+
)
269+
set_attr_if_not_none(xml, "OperatorName", self.operator_name)
270+
set_attr_if_not_none(xml, "CalibrationDateTime", self.calibration_date_time)
271+
set_attr_if_not_none(xml, "MeasurementProbe", self.measurement_probe)
272+
set_attr_if_not_none(
273+
xml, "CalibrationSoftwareName", self.calibration_software_name
274+
)
275+
return xml
276+
230277

231278
@dataclass
232-
class SOPNode(XMLParsable):
279+
class SOPNode(XMLParsable, XMLWritable):
233280
"""
234281
Represent a *SOPNode* element for a :class:`colour_clf_io.ASC_CDL`
235282
*Process Node*.
@@ -312,6 +359,20 @@ def from_xml(
312359

313360
return SOPNode(slope=slope, offset=offset, power=power)
314361

362+
def to_xml(self) -> lxml.etree._Element:
363+
"""
364+
Serialise this object as an XML object.
365+
366+
Returns
367+
-------
368+
:class:`lxml.etree._Element`
369+
"""
370+
xml = lxml.etree.Element("SOPNode")
371+
set_element_if_not_none(xml, "Slope", " ".join(map(str, self.slope)))
372+
set_element_if_not_none(xml, "Offset", " ".join(map(str, self.offset)))
373+
set_element_if_not_none(xml, "Power", " ".join(map(str, self.power)))
374+
return xml
375+
315376
@classmethod
316377
def default(cls) -> SOPNode:
317378
"""
@@ -331,7 +392,7 @@ def default(cls) -> SOPNode:
331392

332393

333394
@dataclass
334-
class SatNode(XMLParsable):
395+
class SatNode(XMLParsable, XMLWritable):
335396
"""
336397
Represent a *SatNode* element for a :class:`colour_clf_io.ASC_CDL`
337398
*Process Node*.
@@ -399,6 +460,18 @@ def from_xml(
399460

400461
return SatNode(saturation=saturation)
401462

463+
def to_xml(self) -> lxml.etree._Element:
464+
"""
465+
Serialise this object as an XML object.
466+
467+
Returns
468+
-------
469+
:class:`lxml.etree._Element`
470+
"""
471+
xml = lxml.etree.Element("SatNode")
472+
set_element_if_not_none(xml, "Saturation", self.saturation)
473+
return xml
474+
402475
@classmethod
403476
def default(cls) -> SatNode:
404477
"""
@@ -414,7 +487,7 @@ def default(cls) -> SatNode:
414487

415488

416489
@dataclass
417-
class Info(XMLParsable):
490+
class Info(XMLParsable, XMLWritable):
418491
"""
419492
Represent an *Info* element.
420493
@@ -520,9 +593,27 @@ def from_xml(xml: lxml.etree._Element | None, config: ParserConfig) -> Info | No
520593

521594
return Info(calibration_info=calibration_info, **attributes)
522595

596+
def to_xml(self) -> lxml.etree._Element:
597+
"""
598+
Serialise this object as an XML object.
599+
600+
Returns
601+
-------
602+
:class:`lxml.etree._Element`
603+
"""
604+
xml = lxml.etree.Element("Info")
605+
set_attr_if_not_none(xml, "AppRelease", self.app_release)
606+
set_attr_if_not_none(xml, "Copyright", self.copyright)
607+
set_attr_if_not_none(xml, "Revision", self.revision)
608+
set_attr_if_not_none(xml, "AcesTransformID", self.aces_transform_id)
609+
set_attr_if_not_none(xml, "AcesUserName", self.aces_user_name)
610+
if self.calibration_info is not None:
611+
xml.append(self.calibration_info.to_xml())
612+
return xml
613+
523614

524615
@dataclass
525-
class LogParams(XMLParsable):
616+
class LogParams(XMLParsable, XMLWritable):
526617
"""
527618
Represent a *LogParams* element for a :class:`colour_clf_io.Log`
528619
*Process Node*.
@@ -649,6 +740,26 @@ def from_xml(
649740

650741
return LogParams(channel=channel, **attributes)
651742

743+
def to_xml(self) -> lxml.etree._Element:
744+
"""
745+
Serialise this object as an XML object.
746+
747+
Returns
748+
-------
749+
:class:`lxml.etree._Element`
750+
"""
751+
xml = lxml.etree.Element("LogParams")
752+
set_attr_if_not_none(xml, "base", self.base)
753+
set_attr_if_not_none(xml, "logSideSlope", self.log_side_slope)
754+
set_attr_if_not_none(xml, "logSideOffset", self.log_side_offset)
755+
set_attr_if_not_none(xml, "linSideSlope", self.lin_side_slope)
756+
set_attr_if_not_none(xml, "linSideOffset", self.lin_side_offset)
757+
set_attr_if_not_none(xml, "linSideBreak", self.lin_side_break)
758+
set_attr_if_not_none(xml, "linearSlope", self.linear_slope)
759+
if self.channel is not None:
760+
xml.set("channel", self.channel.value)
761+
return xml
762+
652763
@classmethod
653764
def default(cls) -> LogParams:
654765
"""
@@ -673,7 +784,7 @@ def default(cls) -> LogParams:
673784

674785

675786
@dataclass
676-
class ExponentParams(XMLParsable):
787+
class ExponentParams(XMLParsable, XMLWritable):
677788
"""
678789
Represent a *ExponentParams* element for a :class:`colour_clf_io.Exponent`
679790
*Process Node*.
@@ -772,6 +883,21 @@ def from_xml(
772883

773884
return ExponentParams(channel=channel, exponent=exponent, **attributes)
774885

886+
def to_xml(self) -> lxml.etree._Element:
887+
"""
888+
Serialise this object as an XML object.
889+
890+
Returns
891+
-------
892+
:class:`lxml.etree._Element`
893+
"""
894+
xml = lxml.etree.Element("ExponentParams")
895+
set_attr_if_not_none(xml, "exponent", self.exponent)
896+
set_attr_if_not_none(xml, "offset", self.offset)
897+
if self.channel is not None:
898+
xml.set("channel", self.channel.value)
899+
return xml
900+
775901
@classmethod
776902
def default(cls) -> ExponentParams:
777903
"""

colour_clf_io/parsing.py

Lines changed: 36 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

@@ -34,6 +33,7 @@
3433
"NAMESPACE_NAME",
3534
"ParserConfig",
3635
"XMLParsable",
36+
"XMLWritable",
3737
"map_optional",
3838
"retrieve_attributes",
3939
"retrieve_attributes_as_float",
@@ -120,6 +120,29 @@ def from_xml(
120120
"""
121121

122122

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

0 commit comments

Comments
 (0)