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 Feb 28, 2025
1 parent 1d8bb31 commit 0800742
Show file tree
Hide file tree
Showing 7 changed files with 642 additions and 15 deletions.
9 changes: 9 additions & 0 deletions colour_clf_io/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,12 @@ 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:
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)
97 changes: 87 additions & 10 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,20 @@ def from_xml(

return Array(values=values, dim=dimensions)

def to_xml(self) -> 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(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 +161,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 +244,25 @@ def from_xml(

return CalibrationInfo(**attributes)

def to_xml(self) -> 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 +345,13 @@ def from_xml(

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

def to_xml(self) -> 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 +371,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 +439,11 @@ def from_xml(

return SatNode(saturation=saturation)

def to_xml(self) -> 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 +459,7 @@ def default(cls) -> SatNode:


@dataclass
class Info(XMLParsable):
class Info(XMLParsable, XMLWritable):
"""
Represent an *Info* element.
Expand Down Expand Up @@ -520,9 +565,20 @@ 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:
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 @@ -641,14 +697,27 @@ def from_xml(
"lin_side_slope": "linSideSlope",
"lin_side_offset": "linSideOffset",
"lin_side_break": "linSideBreak",
"linear_slope": "linearSlope",
"linear_slope": "",
},
)

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

return LogParams(channel=channel, **attributes)

def to_xml(self) -> 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 +742,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 +841,14 @@ def from_xml(

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

def to_xml(self) -> 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
33 changes: 31 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 Down Expand Up @@ -120,6 +119,25 @@ 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.
"""


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 +508,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, attr, value):
if value is not None:
node.set(attr, str(value))


def set_element_if_not_none(node, name, value):
if value is not None and value != "":
child = lxml.etree.SubElement(node, name)
child.text = str(value)
21 changes: 21 additions & 0 deletions colour_clf_io/process_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
check_none,
element_as_text,
elements_as_text_list,
set_attr_if_not_none,
set_element_if_not_none,
)
from colour_clf_io.process_nodes import (
ProcessNode,
Expand Down Expand Up @@ -200,3 +202,22 @@ def from_xml(xml: lxml.etree._Element | None) -> ProcessList | None:
info=info,
description=description,
)

def to_xml(self) -> lxml.etree._Element:
xml = lxml.etree.Element("ProcessList")
set_attr_if_not_none(xml, "id", self.id)
set_attr_if_not_none(xml, "compCLFversion", self.compatible_CLF_version)
set_attr_if_not_none(xml, "name", self.name)
set_attr_if_not_none(xml, "inverseOf", self.inverse_of)
set_element_if_not_none(xml, "InputDescriptor", self.input_descriptor)
set_element_if_not_none(xml, "OutputDescriptor", self.output_descriptor)
if self.info:
xml.append(self.info.to_xml())
for description_text in self.description:
description_element = lxml.etree.SubElement(xml, "Description")
description_element.text = description_text
# TODO: we might have to store a single list of children in order to preserve
# ordering of description and process nodes
for process_node in self.process_nodes:
xml.append(process_node.to_xml())
return xml
Loading

0 comments on commit 0800742

Please sign in to comment.