Source code for vibesketch.document.cdx

"""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))