Skip to content

Commit

Permalink
Add functionality to export the CLF structure back to XML.
Browse files Browse the repository at this point in the history
  • Loading branch information
MichaelMauderer committed Mar 6, 2025
1 parent 1d8bb31 commit fdcda69
Show file tree
Hide file tree
Showing 7 changed files with 785 additions and 15 deletions.
32 changes: 31 additions & 1 deletion colour_clf_io/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,11 @@ def read_clf(path: str | Path) -> ProcessList:
xml = lxml.etree.parse(str(path)) # noqa: S320
xml_process_list = xml.getroot()

return ProcessList.from_xml(xml_process_list)
process_list = ProcessList.from_xml(xml_process_list)
if process_list is None:
err = "Process list could not be parsed."
raise ValueError(err)
return process_list


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

return ProcessList.from_xml(xml)


def write_clf(process_list: ProcessList, path: str | Path | None = None) -> None | str:
"""
Write the given *ProcessList* as a CLF file to the target
location. If no *path* is given the CLF document will be returned as a string.
Parameters
----------
process_list
*ProcessList* that should be written.
path
Location of the file, or *None* to return a string representation of the
CLF document.
Returns
-------
:class:`colour_clf_io.ProcessList`
"""
xml = process_list.to_xml()
serialised = lxml.etree.tostring(xml)
if path is None:
return serialised.decode("utf-8")
with open(path, "wb") as f:
f.write(serialised)
return None
144 changes: 135 additions & 9 deletions colour_clf_io/elements.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,28 @@

from __future__ import annotations

import itertools
import typing
from dataclasses import dataclass

if typing.TYPE_CHECKING:
import numpy.typing as npt

if typing.TYPE_CHECKING:
import lxml.etree
import lxml.etree

from colour_clf_io.errors import ParsingError
from colour_clf_io.parsing import (
ParserConfig,
XMLParsable,
XMLWritable,
check_none,
child_element,
child_element_or_exception,
map_optional,
retrieve_attributes,
retrieve_attributes_as_float,
set_attr_if_not_none,
set_element_if_not_none,
three_floats,
)
from colour_clf_io.values import Channel
Expand All @@ -50,7 +53,7 @@


@dataclass
class Array(XMLParsable):
class Array(XMLParsable, XMLWritable):
"""
Represent an *Array* element.
Expand Down Expand Up @@ -124,6 +127,27 @@ def from_xml(

return Array(values=values, dim=dimensions)

def to_xml(self) -> lxml.etree._Element:
"""
Serialise this object as an XML object.
Returns
-------
:class:`lxml.etree._Element`
"""
xml = lxml.etree.Element("Array")
xml.set("dim", " ".join(map(str, self.dim)))
if len(self.dim) <= 1:
xml.text = "\n".join(map(str, self.values))
else:
row_length = self.dim[-1]
text = "\n".join(
" ".join(map(str, row))
for row in itertools.batched(self.values, row_length)
)
xml.text = text
return xml

def as_array(self) -> npt.NDArray:
"""
Convert the *CLF* element into a numpy array.
Expand All @@ -144,7 +168,7 @@ def as_array(self) -> npt.NDArray:


@dataclass
class CalibrationInfo(XMLParsable):
class CalibrationInfo(XMLParsable, XMLWritable):
"""
Represent a *CalibrationInfo* container element for a
:class:`colour_clf_io.ProcessList` class instance.
Expand Down Expand Up @@ -227,9 +251,32 @@ def from_xml(

return CalibrationInfo(**attributes)

def to_xml(self) -> lxml.etree._Element:
"""
Serialise this object as an XML object.
Returns
-------
:class:`lxml.etree._Element`
"""
xml = lxml.etree.Element("CalibrationInfo")
set_attr_if_not_none(
xml, "DisplayDeviceSerialNum", self.display_device_serial_num
)
set_attr_if_not_none(
xml, "DisplayDeviceHostName", self.display_device_host_name
)
set_attr_if_not_none(xml, "OperatorName", self.operator_name)
set_attr_if_not_none(xml, "CalibrationDateTime", self.calibration_date_time)
set_attr_if_not_none(xml, "MeasurementProbe", self.measurement_probe)
set_attr_if_not_none(
xml, "CalibrationSoftwareName", self.calibration_software_name
)
return xml


@dataclass
class SOPNode(XMLParsable):
class SOPNode(XMLParsable, XMLWritable):
"""
Represent a *SOPNode* element for a :class:`colour_clf_io.ASC_CDL`
*Process Node*.
Expand Down Expand Up @@ -312,6 +359,20 @@ def from_xml(

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

def to_xml(self) -> lxml.etree._Element:
"""
Serialise this object as an XML object.
Returns
-------
:class:`lxml.etree._Element`
"""
xml = lxml.etree.Element("SOPNode")
set_element_if_not_none(xml, "Slope", " ".join(map(str, self.slope)))
set_element_if_not_none(xml, "Offset", " ".join(map(str, self.offset)))
set_element_if_not_none(xml, "Power", " ".join(map(str, self.power)))
return xml

@classmethod
def default(cls) -> SOPNode:
"""
Expand All @@ -331,7 +392,7 @@ def default(cls) -> SOPNode:


@dataclass
class SatNode(XMLParsable):
class SatNode(XMLParsable, XMLWritable):
"""
Represent a *SatNode* element for a :class:`colour_clf_io.ASC_CDL`
*Process Node*.
Expand Down Expand Up @@ -399,6 +460,18 @@ def from_xml(

return SatNode(saturation=saturation)

def to_xml(self) -> lxml.etree._Element:
"""
Serialise this object as an XML object.
Returns
-------
:class:`lxml.etree._Element`
"""
xml = lxml.etree.Element("SatNode")
set_element_if_not_none(xml, "Saturation", self.saturation)
return xml

@classmethod
def default(cls) -> SatNode:
"""
Expand All @@ -414,7 +487,7 @@ def default(cls) -> SatNode:


@dataclass
class Info(XMLParsable):
class Info(XMLParsable, XMLWritable):
"""
Represent an *Info* element.
Expand Down Expand Up @@ -520,9 +593,27 @@ def from_xml(xml: lxml.etree._Element | None, config: ParserConfig) -> Info | No

return Info(calibration_info=calibration_info, **attributes)

def to_xml(self) -> lxml.etree._Element:
"""
Serialise this object as an XML object.
Returns
-------
:class:`lxml.etree._Element`
"""
xml = lxml.etree.Element("Info")
set_attr_if_not_none(xml, "AppRelease", self.app_release)
set_attr_if_not_none(xml, "Copyright", self.copyright)
set_attr_if_not_none(xml, "Revision", self.revision)
set_attr_if_not_none(xml, "AcesTransformID", self.aces_transform_id)
set_attr_if_not_none(xml, "AcesUserName", self.aces_user_name)
if self.calibration_info is not None:
xml.append(self.calibration_info.to_xml())
return xml


@dataclass
class LogParams(XMLParsable):
class LogParams(XMLParsable, XMLWritable):
"""
Represent a *LogParams* element for a :class:`colour_clf_io.Log`
*Process Node*.
Expand Down Expand Up @@ -649,6 +740,26 @@ def from_xml(

return LogParams(channel=channel, **attributes)

def to_xml(self) -> lxml.etree._Element:
"""
Serialise this object as an XML object.
Returns
-------
:class:`lxml.etree._Element`
"""
xml = lxml.etree.Element("LogParams")
set_attr_if_not_none(xml, "base", self.base)
set_attr_if_not_none(xml, "logSideSlope", self.log_side_slope)
set_attr_if_not_none(xml, "logSideOffset", self.log_side_offset)
set_attr_if_not_none(xml, "linSideSlope", self.lin_side_slope)
set_attr_if_not_none(xml, "linSideOffset", self.lin_side_offset)
set_attr_if_not_none(xml, "linSideBreak", self.lin_side_break)
set_attr_if_not_none(xml, "linearSlope", self.linear_slope)
if self.channel is not None:
xml.set("channel", self.channel.value)
return xml

@classmethod
def default(cls) -> LogParams:
"""
Expand All @@ -673,7 +784,7 @@ def default(cls) -> LogParams:


@dataclass
class ExponentParams(XMLParsable):
class ExponentParams(XMLParsable, XMLWritable):
"""
Represent a *ExponentParams* element for a :class:`colour_clf_io.Exponent`
*Process Node*.
Expand Down Expand Up @@ -772,6 +883,21 @@ def from_xml(

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

def to_xml(self) -> lxml.etree._Element:
"""
Serialise this object as an XML object.
Returns
-------
:class:`lxml.etree._Element`
"""
xml = lxml.etree.Element("ExponentParams")
set_attr_if_not_none(xml, "exponent", self.exponent)
set_attr_if_not_none(xml, "offset", self.offset)
if self.channel is not None:
xml.set("channel", self.channel.value)
return xml

@classmethod
def default(cls) -> ExponentParams:
"""
Expand Down
38 changes: 36 additions & 2 deletions colour_clf_io/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@
from collections.abc import Callable, Iterable
from typing import Any

if typing.TYPE_CHECKING:
import lxml.etree
import lxml.etree

from colour_clf_io.errors import ParsingError

Expand All @@ -34,6 +33,7 @@
"NAMESPACE_NAME",
"ParserConfig",
"XMLParsable",
"XMLWritable",
"map_optional",
"retrieve_attributes",
"retrieve_attributes_as_float",
Expand Down Expand Up @@ -120,6 +120,29 @@ def from_xml(
"""


class XMLWritable(ABC):
"""
Define the base class for objects that can be serialised to XML.
This is an :class:`ABCMeta` abstract class that must be inherited by
sub-classes.
Methods
-------
- :meth:`~colour_lf_io.parsing.XMLParsable.to_xml`
"""

@abstractmethod
def to_xml(self) -> lxml.etree._Element:
"""
Serialise this object as an XML object.
Returns
-------
:class:`lxml.etree._Element`
"""


def map_optional(function: Callable, value: Any | None) -> Any:
"""
Apply the given function to given ``value`` if ``value`` is not ``None``.
Expand Down Expand Up @@ -490,3 +513,14 @@ def three_floats(text: str | None) -> tuple[float, float, float]:
values = tuple(map(float, parts))
# Note: Repacking here to satisfy type check.
return values[0], values[1], values[2]


def set_attr_if_not_none(node: lxml.etree._Element, attr: str, value: Any) -> None:
if value is not None:
node.set(attr, str(value))


def set_element_if_not_none(node: lxml.etree._Element, name: str, value: Any) -> None:
if value is not None and value != "":
child = lxml.etree.SubElement(node, name)
child.text = str(value)
Loading

0 comments on commit fdcda69

Please sign in to comment.