"""Binary CDX, ChemDraw's native format, read by translating it to CDXML (#95).
A CDX file is a tree of tagged objects with tagged properties; CDXML is the
same tree written as XML, object for object and property for property. So
this module rewrites a CDX file as CDXML and hands it to
:func:`vibesketch.document.cdxml.read_cdxml`, which reads the drawing as it
reads any CDXML file: the structures through RDKit, arrows, plus signs,
text, shapes and the reaction scheme from the elements.
Only the objects and properties that reader uses are translated; any other
property is left out, and an unknown object becomes an element the reader
reports as left out. Integers are read at the size they are stored with,
since ChemDraw 8.0 wrote some 2-byte properties as 1 byte.
Sources: CambridgeSoft, "CDX Format Specification": the header, objects and
properties (Introduction), the data types CDXCoordinate, CDXPoint2D,
CDXPoint3D, CDXRectangle, CDXObjectID and CDXString with its style runs, and
the tables of objects and properties with their CDXML names, mirrored at
https://chemapps.stolaf.edu/iupac/cdx/sdk/; and the enumerations in
``CDXConstants.h`` of Revvity's ChemDraw file library (BSD-3-Clause), which
RDKit bundles: https://github.com/Glysade/chemdraw.
"""
from __future__ import annotations
import struct
import xml.etree.ElementTree as ET
from collections.abc import Callable
__all__ = ["CdxUnreadable", "cdx_to_cdxml"]
[docs]
class CdxUnreadable(ValueError):
"""The data is not a CDX file this reader can use."""
#: "VjCD0100", the reserved 04 03 02 01, then reserved zero bytes before the document.
MAGIC = b"VjCD0100\x04\x03\x02\x01"
#: A CDXCoordinate is 1/65536 of a point.
_POINT = 65536.0
#: Object tags and their CDXML element names.
OBJECTS = {
0x8000: "CDXML", 0x8001: "page", 0x8002: "group", 0x8003: "fragment", 0x8004: "n",
0x8005: "b", 0x8006: "t", 0x8007: "graphic", 0x8008: "curve", 0x8009: "embeddedobject",
0x800A: "altgroup", 0x800B: "templategrid", 0x800C: "regnum", 0x800D: "scheme",
0x800E: "step", 0x8010: "spectrum", 0x8011: "objecttag", 0x8013: "sequence",
0x8014: "crossreference", 0x8015: "splitter", 0x8016: "table", 0x8017: "bracketedgroup",
0x8018: "bracketattachment", 0x8019: "crossingbond", 0x8020: "border", 0x8021: "geometry",
0x8022: "constraint", 0x8023: "tlcplate", 0x8024: "tlclane", 0x8025: "tlcspot",
0x8026: "chemicalproperty", 0x8027: "arrow",
} # fmt: skip
def _names(*pairs: tuple[int, str]) -> dict[int, str]:
return dict(pairs)
#: Enumerated properties: value to CDXML name.
_NODE_TYPE = _names(
(0, "Unspecified"), (1, "Element"), (2, "ElementList"), (3, "ElementListNickname"),
(4, "Nickname"), (5, "Fragment"), (6, "Formula"), (7, "GenericNickname"),
(8, "AnonymousAlternativeGroup"), (9, "NamedAlternativeGroup"), (10, "MultiAttachment"),
(11, "VariableAttachment"), (12, "ExternalConnectionPoint"), (13, "LinkNode"),
) # fmt: skip
_RADICAL = _names((0, "None"), (1, "Singlet"), (2, "Doublet"), (3, "Triplet"))
_DISPLAY = _names(
(0, "Solid"), (1, "Dash"), (2, "Hash"), (3, "WedgedHashBegin"), (4, "WedgedHashEnd"),
(5, "Bold"), (6, "WedgeBegin"), (7, "WedgeEnd"), (8, "Wavy"), (9, "HollowWedgeBegin"),
(10, "HollowWedgeEnd"), (11, "WavyWedgeBegin"), (12, "WavyWedgeEnd"), (13, "Dot"),
(14, "DashDot"),
) # fmt: skip
_GRAPHIC = _names(
(0, "Undefined"), (1, "Line"), (2, "Arc"), (3, "Rectangle"), (4, "Oval"), (5, "Orbital"),
(6, "Bracket"), (7, "Symbol"),
) # fmt: skip
_SYMBOL = _names(
(0, "LonePair"), (1, "Electron"), (2, "RadicalCation"), (3, "RadicalAnion"),
(4, "CirclePlus"), (5, "CircleMinus"), (6, "Dagger"), (7, "DoubleDagger"), (8, "Plus"),
(9, "Minus"), (10, "Racemic"), (11, "Absolute"), (12, "Relative"), (13, "LonePairBar"),
) # fmt: skip
_JUSTIFICATION = _names(
(-1, "Right"), (0, "Left"), (1, "Center"), (2, "Full"), (3, "Above"), (4, "Below"),
(5, "Auto"), (6, "Best"),
) # fmt: skip
_HEAD_TYPE = _names((0, "Unspecified"), (1, "Solid"), (2, "Hollow"), (3, "Angle"))
_HEAD_POSITION = _names(
(0, "Unspecified"), (1, "None"), (2, "Full"), (3, "HalfLeft"), (4, "HalfRight")
)
_NO_GO = _names((0, "Unspecified"), (1, "None"), (2, "Cross"), (3, "Hash"))
#: Bit-encoded properties: each bit's CDXML name, and the name of the value zero.
_ORDER_BITS = _names(
(0x0001, "1"), (0x0002, "2"), (0x0004, "3"), (0x0008, "4"), (0x0010, "5"), (0x0020, "6"),
(0x0040, "0.5"), (0x0080, "1.5"), (0x0100, "2.5"), (0x0200, "3.5"), (0x0400, "4.5"),
(0x0800, "5.5"), (0x1000, "dative"), (0x2000, "ionic"), (0x4000, "hydrogen"),
(0x8000, "threecenter"),
) # fmt: skip
_ARROW_BITS = _names(
(1, "HalfHead"), (2, "FullHead"), (4, "Resonance"), (8, "Equilibrium"), (16, "Hollow"),
(32, "RetroSynthetic"), (64, "NoGo"), (128, "Dipole"),
) # fmt: skip
_LINE_BITS = _names((1, "Dashed"), (2, "Bold"), (4, "Wavy"))
_RECTANGLE_BITS = _names(
(1, "RoundEdge"), (2, "Shadow"), (4, "Shaded"), (8, "Filled"), (16, "Dashed"), (32, "Bold")
)
_OVAL_BITS = _names(
(1, "Circle"), (2, "Shaded"), (4, "Filled"), (8, "Dashed"), (16, "Bold"), (32, "Shadowed")
)
def _signed(data: bytes) -> int:
return int.from_bytes(data, "little", signed=True) if data else 0
def _unsigned(data: bytes) -> int:
return int.from_bytes(data, "little", signed=False) if data else 0
def _coordinate(value: int) -> str:
return f"{value / _POINT:.4f}".rstrip("0").rstrip(".")
def _coordinates(data: bytes, count: int) -> list[int]:
if len(data) != 4 * count:
raise CdxUnreadable(f"a CDX coordinate property holds {len(data)} bytes, not {4 * count}")
return list(struct.unpack(f"<{count}i", data))
def _point2d(data: bytes) -> str:
y, x = _coordinates(data, 2) # CDX stores y first
return f"{_coordinate(x)} {_coordinate(y)}"
def _point3d(data: bytes) -> str:
z, y, x = _coordinates(data, 3)
return f"{_coordinate(x)} {_coordinate(y)} {_coordinate(z)}"
def _rectangle(data: bytes) -> str:
top, left, bottom, right = _coordinates(data, 4)
return " ".join(_coordinate(v) for v in (left, top, right, bottom))
def _enum(names: dict[int, str]) -> Callable[[bytes], str]:
def convert(data: bytes) -> str:
value = _signed(data)
return names.get(value, str(value))
return convert
def _bits(names: dict[int, str], zero: str | None = None) -> Callable[[bytes], str | None]:
def convert(data: bytes) -> str | None:
value = _unsigned(data)
if value == 0:
return zero
if value == 0xFFFF and names is _ORDER_BITS:
return None # unspecified order: left out, as in CDXML
found = [name for bit, name in names.items() if value & bit]
return " ".join(found) if found else str(value)
return convert
def _integer(data: bytes) -> str:
return str(_signed(data))
def _ids(data: bytes) -> str:
if len(data) % 4:
raise CdxUnreadable("a CDX object-id list is not a whole number of ids")
return " ".join(str(v) for v in struct.unpack(f"<{len(data) // 4}I", data))
def _tenths(data: bytes) -> str:
return f"{_signed(data) / 10:g}"
def _yes(_: bytes) -> str:
return "yes"
#: Translated properties: tag to CDXML attribute name and converter. The names are those
#: ChemDraw writes in CDXML, which :mod:`vibesketch.document.cdxml` and RDKit read.
_PROPERTIES: dict[int, tuple[str, Callable[[bytes], str | None]]] = {
0x000A: ("Z", _integer),
0x0012: ("SupersededBy", lambda d: str(_unsigned(d))),
0x0200: ("p", _point2d),
0x0201: ("xyz", _point3d),
0x0204: ("BoundingBox", _rectangle),
0x0207: ("Head3D", _point3d),
0x0208: ("Tail3D", _point3d),
0x020D: ("Center3D", _point3d),
0x020E: ("MajorAxisEnd3D", _point3d),
0x020F: ("MinorAxisEnd3D", _point3d),
0x0400: ("NodeType", _enum(_NODE_TYPE)),
0x0401: ("LabelDisplay", _integer),
0x0402: ("Element", _integer),
0x0420: ("Isotope", _integer),
0x0421: ("Charge", _integer),
0x0422: ("Radical", _enum(_RADICAL)),
0x042B: ("NumHydrogens", _integer),
0x0600: ("Order", _bits(_ORDER_BITS)),
0x0601: ("Display", _enum(_DISPLAY)),
0x0602: ("Display2", _enum(_DISPLAY)),
0x0604: ("B", lambda d: str(_unsigned(d))),
0x0605: ("E", lambda d: str(_unsigned(d))),
0x0608: ("BeginAttach", _integer),
0x0609: ("EndAttach", _integer),
0x0701: ("Justification", _enum(_JUSTIFICATION)),
0x0804: ("BondSpacing", _integer),
0x0805: ("BondLength", lambda d: _coordinate(_signed(d))),
0x0806: ("BoldWidth", lambda d: _coordinate(_signed(d))),
0x0807: ("LineWidth", lambda d: _coordinate(_signed(d))),
0x0808: ("MarginWidth", lambda d: _coordinate(_signed(d))),
0x0809: ("HashSpacing", lambda d: _coordinate(_signed(d))),
0x080C: ("CaptionJustification", _enum(_JUSTIFICATION)),
0x081A: ("LabelFont", _integer),
0x081B: ("CaptionFont", _integer),
0x081C: ("LabelSize", lambda d: f"{_signed(d) / 20:g}"),
0x081D: ("CaptionSize", lambda d: f"{_signed(d) / 20:g}"),
0x081E: ("LabelFace", _integer),
0x081F: ("CaptionFace", _integer),
0x0823: ("LabelJustification", _enum(_JUSTIFICATION)),
0x0A00: ("GraphicType", _enum(_GRAPHIC)),
0x0A01: ("LineType", _bits(_LINE_BITS, "Solid")),
0x0A02: ("ArrowType", _bits(_ARROW_BITS, "NoHead")),
0x0A03: ("RectangleType", _bits(_RECTANGLE_BITS, "Plain")),
0x0A04: ("OvalType", _bits(_OVAL_BITS)),
0x0A07: ("SymbolType", _enum(_SYMBOL)),
0x0A20: ("HeadSize", _integer),
0x0A21: ("AngularSize", _tenths),
0x0A2F: ("ArrowheadType", _enum(_HEAD_TYPE)),
0x0A30: ("ArrowheadCenterSize", lambda d: str(_unsigned(d))),
0x0A31: ("ArrowheadWidth", lambda d: str(_unsigned(d))),
0x0A35: ("ArrowheadHead", _enum(_HEAD_POSITION)),
0x0A36: ("ArrowheadTail", _enum(_HEAD_POSITION)),
0x0A3B: ("NoGo", _enum(_NO_GO)),
0x0C01: ("ReactionStepReactants", _ids),
0x0C02: ("ReactionStepProducts", _ids),
0x0C03: ("ReactionStepPlusses", _ids),
0x0C04: ("ReactionStepArrows", _ids),
0x0C05: ("ReactionStepObjectsAboveArrow", _ids),
0x0C06: ("ReactionStepObjectsBelowArrow", _ids),
}
_TEXT = 0x0700
_REPRESENTS = 0x000E
#: Text in a CDX string is Latin-1 unless a font says otherwise; ChemDraw writes Windows-1252.
_ENCODING = "cp1252"
def _styled(element: ET.Element, data: bytes) -> None:
"""A CDXString as the ``s`` runs of a text: style runs, then the characters."""
if len(data) < 2:
raise CdxUnreadable("a CDX text is shorter than its style-run count")
count = _unsigned(data[:2])
head = 2 + 10 * count
if len(data) < head:
raise CdxUnreadable("a CDX text is shorter than its style runs")
runs = [struct.unpack_from("<5H", data, 2 + 10 * i) for i in range(count)]
text = data[head:].decode(_ENCODING, errors="replace")
if not runs:
run = ET.SubElement(element, "s")
run.text = text
return
for index, (start, font, face, size, color) in enumerate(runs):
end = runs[index + 1][0] if index + 1 < len(runs) else len(text)
run = ET.SubElement(
element, "s", font=str(font), face=str(face), size=f"{size / 20:g}", color=str(color)
)
run.text = text[start:end]
def _read_object(data: bytes, at: int, tag: int, depth: int) -> tuple[ET.Element, int]:
"""The object whose tag was just read, with its id, properties and children."""
if depth > 64:
raise CdxUnreadable("CDX objects are nested more than 64 deep")
if at + 4 > len(data):
raise CdxUnreadable("a CDX object is cut short before its id")
element = ET.Element(OBJECTS.get(tag, f"cdx-object-{tag:#06x}"))
identifier = _unsigned(data[at : at + 4])
if identifier:
element.set("id", str(identifier))
at += 4
while True:
if at + 2 > len(data):
raise CdxUnreadable("a CDX object has no end")
child = _unsigned(data[at : at + 2])
at += 2
if child == 0:
return element, at
if child & 0x8000:
nested, at = _read_object(data, at, child, depth + 1)
element.append(nested)
continue
if at + 2 > len(data):
raise CdxUnreadable("a CDX property is cut short before its length")
length = _unsigned(data[at : at + 2])
at += 2
if length == 0xFFFF:
length = _unsigned(data[at : at + 4])
at += 4
value = data[at : at + length]
if len(value) != length:
raise CdxUnreadable("a CDX property runs past the end of the file")
at += length
if child == _TEXT and element.tag == "t":
_styled(element, value)
elif child == _REPRESENTS and len(value) >= 6:
# A represented property: the object it belongs to and the property's tag.
target, represented = struct.unpack_from("<IH", value)
name = _PROPERTIES.get(represented, (str(represented), _integer))[0]
ET.SubElement(element, "represent", attribute=name, object=str(target))
elif child in _PROPERTIES:
name, convert = _PROPERTIES[child]
converted = convert(value)
if converted is not None:
element.set(name, converted)
[docs]
def cdx_to_cdxml(data: bytes) -> bytes:
"""A binary CDX file as the equivalent CDXML; raises :class:`CdxUnreadable`."""
if not data.startswith(MAGIC):
raise CdxUnreadable("not a CDX file: it does not start with ChemDraw's VjCD0100 header")
# The reserved bytes after the signature end where the document object starts.
at = len(MAGIC)
while at + 2 <= len(data) and _unsigned(data[at : at + 2]) == 0 and at < len(MAGIC) + 32:
at += 2
if _unsigned(data[at : at + 2]) != 0x8000:
raise CdxUnreadable("the CDX file does not begin with a document object")
root, _ = _read_object(data, at + 2, 0x8000, 0)
return bytes(ET.tostring(root, encoding="utf-8", xml_declaration=True))