# Purpose: read and write AutoCAD CTB files # Copyright (c) 2010-2023, Manfred Moitzi # License: MIT License # IMPORTANT: use only standard 7-Bit ascii code from __future__ import annotations from typing import ( Union, Optional, BinaryIO, TextIO, Iterable, Iterator, Any, ) import os from abc import abstractmethod from io import StringIO from array import array from struct import pack import zlib END_STYLE_BUTT = 0 END_STYLE_SQUARE = 1 END_STYLE_ROUND = 2 END_STYLE_DIAMOND = 3 END_STYLE_OBJECT = 4 JOIN_STYLE_MITER = 0 JOIN_STYLE_BEVEL = 1 JOIN_STYLE_ROUND = 2 JOIN_STYLE_DIAMOND = 3 JOIN_STYLE_OBJECT = 5 FILL_STYLE_SOLID = 64 FILL_STYLE_CHECKERBOARD = 65 FILL_STYLE_CROSSHATCH = 66 FILL_STYLE_DIAMONDS = 67 FILL_STYLE_HORIZONTAL_BARS = 68 FILL_STYLE_SLANT_LEFT = 69 FILL_STYLE_SLANT_RIGHT = 70 FILL_STYLE_SQUARE_DOTS = 71 FILL_STYLE_VERICAL_BARS = 72 FILL_STYLE_OBJECT = 73 DITHERING_ON = 1 # bit coded color_policy GRAYSCALE_ON = 2 # bit coded color_policy NAMED_COLOR = 4 # bit coded color_policy AUTOMATIC = 0 OBJECT_LINEWEIGHT = 0 OBJECT_LINETYPE = 31 OBJECT_COLOR = -1 OBJECT_COLOR2 = -1006632961 STYLE_COUNT = 255 DEFAULT_LINE_WEIGHTS = [ 0.00, # 0 0.05, # 1 0.09, # 2 0.10, # 3 0.13, # 4 0.15, # 5 0.18, # 6 0.20, # 7 0.25, # 8 0.30, # 9 0.35, # 10 0.40, # 11 0.45, # 12 0.50, # 13 0.53, # 14 0.60, # 15 0.65, # 16 0.70, # 17 0.80, # 18 0.90, # 19 1.00, # 20 1.06, # 21 1.20, # 22 1.40, # 23 1.58, # 24 2.00, # 25 2.11, # 26 ] # color_type: (thx to Rammi) # Take color from layer, ignore other bytes. COLOR_BY_LAYER = 0xC0 # Take color from insertion, ignore other bytes COLOR_BY_BLOCK = 0xC1 # RGB value, other bytes are R,G,B. COLOR_RGB = 0xC2 # ACI, AutoCAD color index, other bytes are 0,0,index ??? COLOR_ACI = 0xC3 def color_name(index: int) -> str: return "Color_%d" % (index + 1) def get_bool(value: Union[str, bool]) -> bool: if isinstance(value, str): upperstr = value.upper() if upperstr == "TRUE": value = True elif upperstr == "FALSE": value = False else: raise ValueError("Unknown bool value '%s'." % str(value)) return value class PlotStyle: def __init__( self, index: int, data: Optional[dict] = None, parent: Optional[PlotStyleTable] = None, ): data = data or {} self.parent = parent self.index = int(index) self.name = str(data.get("name", color_name(index))) self.localized_name = str(data.get("localized_name", color_name(index))) self.description = str(data.get("description", "")) # do not set _color, _mode_color or _color_policy directly # use set_color() method, and the properties dithering and grayscale self._color = int(data.get("color", OBJECT_COLOR)) self._color_type = COLOR_RGB if self._color != OBJECT_COLOR: self._mode_color = int(data.get("mode_color", self._color)) self._color_policy = int(data.get("color_policy", DITHERING_ON)) self.physical_pen_number = int(data.get("physical_pen_number", AUTOMATIC)) self.virtual_pen_number = int(data.get("virtual_pen_number", AUTOMATIC)) self.screen = int(data.get("screen", 100)) self.linepattern_size = float(data.get("linepattern_size", 0.5)) self.linetype = int(data.get("linetype", OBJECT_LINETYPE)) # 0 .. 30 self.adaptive_linetype = get_bool(data.get("adaptive_linetype", True)) # lineweight index self.lineweight = int(data.get("lineweight", OBJECT_LINEWEIGHT)) self.end_style = int(data.get("end_style", END_STYLE_OBJECT)) self.join_style = int(data.get("join_style", JOIN_STYLE_OBJECT)) self.fill_style = int(data.get("fill_style", FILL_STYLE_OBJECT)) @property def color(self) -> Optional[tuple[int, int, int]]: """Get style color as ``(r, g, b)`` tuple or ``None``, if style has object color. """ if self.has_object_color(): return None # object color else: return int2color(self._mode_color)[:3] @color.setter def color(self, rgb: tuple[int, int, int]) -> None: """Set color as RGB values.""" r, g, b = rgb # when defining a user-color, `mode_color` represents the real # true_color as (r, g, b) tuple and color_type = COLOR_RGB (0xC2) as # highest byte, the `color` value calculated for a user-color is not a # (r, g, b) tuple and has color_type = COLOR_ACI (0xC3) (sometimes), set # for `color` the same value as for `mode_color`, because AutoCAD # corrects the `color` value by itself. self._mode_color = mode_color2int(r, g, b, color_type=self._color_type) self._color = self._mode_color @property def color_type(self): if self.has_object_color(): return None # object color else: return self._color_type @color_type.setter def color_type(self, value: int): self._color_type = value def set_object_color(self) -> None: """Set color to object color.""" self._color = OBJECT_COLOR self._mode_color = OBJECT_COLOR def set_lineweight(self, lineweight: float) -> None: """Set `lineweight` in millimeters. Use ``0.0`` to set lineweight by object. """ assert self.parent is not None self.lineweight = self.parent.get_lineweight_index(lineweight) def get_lineweight(self) -> float: """Returns the lineweight in millimeters or `0.0` for use entity lineweight. """ assert self.parent is not None return self.parent.lineweights[self.lineweight] def has_object_color(self) -> bool: """``True`` if style has object color.""" return self._color in (OBJECT_COLOR, OBJECT_COLOR2) @property def aci(self) -> int: """:ref:`ACI` in range from ``1`` to ``255``. Has no meaning for named plot styles. (int) """ return self.index + 1 @property def dithering(self) -> bool: """Depending on the capabilities of your plotter, dithering approximates the colors with dot patterns. When this option is ``False``, the colors are mapped to the nearest color, resulting in a smaller range of colors when plotting. Dithering is available only whether you select the object’s color or assign a plot style color. """ return bool(self._color_policy & DITHERING_ON) @dithering.setter def dithering(self, status: bool) -> None: if status: self._color_policy |= DITHERING_ON else: self._color_policy &= ~DITHERING_ON @property def grayscale(self) -> bool: """Plot colors in grayscale. (bool)""" return bool(self._color_policy & GRAYSCALE_ON) @grayscale.setter def grayscale(self, status: bool) -> None: if status: self._color_policy |= GRAYSCALE_ON else: self._color_policy &= ~GRAYSCALE_ON @property def named_color(self) -> bool: return bool(self._color_policy & NAMED_COLOR) @named_color.setter def named_color(self, status: bool) -> None: if status: self._color_policy |= NAMED_COLOR else: self._color_policy &= ~NAMED_COLOR def write(self, stream: TextIO) -> None: """Write style data to file-like object `stream`.""" index = self.index stream.write(" %d{\n" % index) stream.write(' name="%s\n' % self.name) stream.write(' localized_name="%s\n' % self.localized_name) stream.write(' description="%s\n' % self.description) stream.write(" color=%d\n" % self._color) if self._color != OBJECT_COLOR: stream.write(" mode_color=%d\n" % self._mode_color) stream.write(" color_policy=%d\n" % self._color_policy) stream.write(" physical_pen_number=%d\n" % self.physical_pen_number) stream.write(" virtual_pen_number=%d\n" % self.virtual_pen_number) stream.write(" screen=%d\n" % self.screen) stream.write(" linepattern_size=%s\n" % str(self.linepattern_size)) stream.write(" linetype=%d\n" % self.linetype) stream.write( " adaptive_linetype=%s\n" % str(bool(self.adaptive_linetype)).upper() ) stream.write(" lineweight=%s\n" % str(self.lineweight)) stream.write(" fill_style=%d\n" % self.fill_style) stream.write(" end_style=%d\n" % self.end_style) stream.write(" join_style=%d\n" % self.join_style) stream.write(" }\n") class PlotStyleTable: """PlotStyle container""" def __init__( self, description: str = "", scale_factor: float = 1.0, apply_factor: bool = False, ): self.description = description self.scale_factor = scale_factor self.apply_factor = apply_factor # set custom_lineweight_display_units to 1 for showing lineweight in inch in # AutoCAD CTB editor window, but lineweight is always defined in mm self.custom_lineweight_display_units = 0 self.lineweights = array("f", DEFAULT_LINE_WEIGHTS) def get_lineweight_index(self, lineweight: float) -> int: """Get index of `lineweight` in the lineweight table or append `lineweight` to lineweight table. """ try: return self.lineweights.index(lineweight) except ValueError: self.lineweights.append(lineweight) return len(self.lineweights) - 1 def set_table_lineweight(self, index: int, lineweight: float) -> int: """Argument `index` is the lineweight table index, not the :ref:`ACI`. Args: index: lineweight table index = :attr:`PlotStyle.lineweight` lineweight: in millimeters """ try: self.lineweights[index] = lineweight return index except IndexError: self.lineweights.append(lineweight) return len(self.lineweights) - 1 def get_table_lineweight(self, index: int) -> float: """Returns lineweight in millimeters of lineweight table entry `index`. Args: index: lineweight table index = :attr:`PlotStyle.lineweight` Returns: lineweight in mm or ``0.0`` for use entity lineweight """ return self.lineweights[index] def save(self, filename: str | os.PathLike) -> None: """Save CTB or STB file as `filename` to the file system.""" with open(filename, "wb") as stream: self.write(stream) def write(self, stream: BinaryIO) -> None: """Compress and write the CTB or STB file to binary `stream`.""" memfile = StringIO() self.write_content(memfile) memfile.write(chr(0)) # end of file body = memfile.getvalue() memfile.close() _compress(stream, body) @abstractmethod def write_content(self, stream: TextIO) -> None: pass def _write_lineweights(self, stream: TextIO) -> None: """Write custom lineweight table to text `stream`.""" stream.write("custom_lineweight_table{\n") for index, weight in enumerate(self.lineweights): stream.write(" %d=%.2f\n" % (index, weight)) stream.write("}\n") def parse(self, text: str) -> None: """Parse plot styles from CTB string `text`.""" def set_lineweights(lineweights): if lineweights is None: return self.lineweights = array("f", [0.0] * len(lineweights)) for key, value in lineweights.items(): self.lineweights[int(key)] = float(value) parser = PlotStyleFileParser(text) self.description = parser.get("description", "") self.scale_factor = float(parser.get("scale_factor", 1.0)) self.apply_factor = get_bool(parser.get("apply_factor", True)) self.custom_lineweight_display_units = int( parser.get("custom_lineweight_display_units", 0) ) set_lineweights(parser.get("custom_lineweight_table", None)) self.load_styles(parser.get("plot_style", {})) @abstractmethod def load_styles(self, styles): pass class ColorDependentPlotStyles(PlotStyleTable): def __init__( self, description: str = "", scale_factor: float = 1.0, apply_factor: bool = False, ): super().__init__(description, scale_factor, apply_factor) self._styles: list[PlotStyle] = [ PlotStyle(index, parent=self) for index in range(STYLE_COUNT) ] self._styles.insert( 0, PlotStyle(256) ) # 1-based array: insert dummy value for index 0 def __getitem__(self, aci: int) -> PlotStyle: """Returns :class:`PlotStyle` for :ref:`ACI` `aci`.""" if 0 < aci < 256: return self._styles[aci] else: raise IndexError(aci) def __setitem__(self, aci: int, style: PlotStyle): """Set plot `style` for `aci`.""" if 0 < aci < 256: style.parent = self self._styles[aci] = style else: raise IndexError(aci) def __iter__(self): """Iterable of all plot styles.""" return iter(self._styles[1:]) def new_style(self, aci: int, data: Optional[dict] = None) -> PlotStyle: """Set `aci` to new attributes defined by `data` dict. Args: aci: :ref:`ACI` data: ``dict`` of :class:`PlotStyle` attributes: description, color, physical_pen_number, virtual_pen_number, screen, linepattern_size, linetype, adaptive_linetype, lineweight, end_style, join_style, fill_style """ # ctb table index = aci - 1 # ctb table starts with index 0, where aci == 0 means BYBLOCK style = PlotStyle(index=aci - 1, data=data) style.color_type = COLOR_RGB self[aci] = style return style def get_lineweight(self, aci: int): """Returns the assigned lineweight for :class:`PlotStyle` `aci` in millimeter. """ style = self[aci] lineweight = style.get_lineweight() if lineweight == 0.0: return None else: return lineweight def write_content(self, stream: TextIO) -> None: """Write the CTB-file to text `stream`.""" self._write_header(stream) self._write_aci_table(stream) self._write_plot_styles(stream) self._write_lineweights(stream) def _write_header(self, stream: TextIO) -> None: """Write header values of CTB-file to text `stream`.""" stream.write('description="%s\n' % self.description) stream.write("aci_table_available=TRUE\n") stream.write("scale_factor=%.1f\n" % self.scale_factor) stream.write("apply_factor=%s\n" % str(self.apply_factor).upper()) stream.write( "custom_lineweight_display_units=%s\n" % str(self.custom_lineweight_display_units) ) def _write_aci_table(self, stream: TextIO) -> None: """Write AutoCAD Color Index table to text `stream`.""" stream.write("aci_table{\n") for style in self: index = style.index stream.write(' %d="%s\n' % (index, color_name(index))) stream.write("}\n") def _write_plot_styles(self, stream: TextIO) -> None: """Write user styles to text `stream`.""" stream.write("plot_style{\n") for style in self: style.write(stream) stream.write("}\n") def load_styles(self, styles): for index, style in styles.items(): index = int(index) style = PlotStyle(index, style) style.color_type = COLOR_RGB aci = index + 1 self[aci] = style class NamedPlotStyles(PlotStyleTable): def __init__( self, description: str = "", scale_factor: float = 1.0, apply_factor: bool = False, ): super().__init__(description, scale_factor, apply_factor) normal = PlotStyle( 0, data={ "name": "Normal", "localized_name": "Normal", }, ) self._styles: dict[str, PlotStyle] = {"Normal": normal} def __iter__(self) -> Iterable[str]: """Iterable of all plot style names.""" return self.keys() def __getitem__(self, name: str) -> PlotStyle: """Returns :class:`PlotStyle` by `name`.""" return self._styles[name] def __delitem__(self, name: str) -> None: """Delete plot style `name`. Plot style ``'Normal'`` is not deletable.""" if name != "Normal": del self._styles[name] else: raise ValueError("Can't delete plot style 'Normal'. ") def keys(self) -> Iterable[str]: """Iterable of all plot style names.""" keys = set(self._styles.keys()) keys.discard("Normal") result = ["Normal"] result.extend(sorted(keys)) return iter(result) def items(self) -> Iterator[tuple[str, PlotStyle]]: """Iterable of all plot styles as (``name``, class:`PlotStyle`) tuples.""" for key in self.keys(): yield key, self._styles[key] def values(self) -> Iterable[PlotStyle]: """Iterable of all class:`PlotStyle` objects.""" for key, value in self.items(): yield value def new_style( self, name: str, data: Optional[dict] = None, localized_name: Optional[str] = None, ) -> PlotStyle: """Create new class:`PlotStyle` `name` by attribute dict `data`, replaces existing class:`PlotStyle` objects. Args: name: plot style name localized_name: name shown in plot style editor, uses `name` if ``None`` data: ``dict`` of :class:`PlotStyle` attributes: description, color, physical_pen_number, virtual_pen_number, screen, linepattern_size, linetype, adaptive_linetype, lineweight, end_style, join_style, fill_style """ if name.lower() == "Normal": raise ValueError("Can't replace or modify plot style 'Normal'. ") data = data or {} data["name"] = name data["localized_name"] = localized_name or name index = len(self._styles) style = PlotStyle(index=index, data=data, parent=self) style.color_type = COLOR_ACI style.named_color = True self._styles[name] = style return style def get_lineweight(self, name: str): """Returns the assigned lineweight for :class:`PlotStyle` `name` in millimeter. """ style = self[name] lineweight = style.get_lineweight() if lineweight == 0.0: return None else: return lineweight def write_content(self, stream: TextIO) -> None: """Write the STB-file to text `stream`.""" self._write_header(stream) self._write_plot_styles(stream) self._write_lineweights(stream) def _write_header(self, stream: TextIO) -> None: """Write header values of CTB-file to text `stream`.""" stream.write('description="%s\n' % self.description) stream.write("aci_table_available=FALSE\n") stream.write("scale_factor=%.1f\n" % self.scale_factor) stream.write("apply_factor=%s\n" % str(self.apply_factor).upper()) stream.write( "custom_lineweight_display_units=%s\n" % str(self.custom_lineweight_display_units) ) def _write_plot_styles(self, stream: TextIO) -> None: """Write user styles to text `stream`.""" stream.write("plot_style{\n") for index, style in enumerate(self.values()): style.index = index style.write(stream) stream.write("}\n") def load_styles(self, styles): for index, style in styles.items(): index = int(index) style = PlotStyle(index, style) style.color_type = COLOR_ACI self._styles[style.name] = style def _read_ctb(stream: BinaryIO) -> ColorDependentPlotStyles: """Read a CTB-file from binary `stream`.""" content: bytes = _decompress(stream) styles = ColorDependentPlotStyles() styles.parse(content.decode()) return styles def _read_stb(stream: BinaryIO) -> NamedPlotStyles: """Read a STB-file from binary `stream`.""" content: bytes = _decompress(stream) styles = NamedPlotStyles() styles.parse(content.decode()) return styles def load( filename: str | os.PathLike, ) -> Union[ColorDependentPlotStyles, NamedPlotStyles]: """Load the CTB or STB file `filename` from file system.""" filename = str(filename) with open(filename, "rb") as stream: if filename.lower().endswith(".ctb"): return _read_ctb(stream) elif filename.lower().endswith(".stb"): return _read_stb(stream) else: raise ValueError('Invalid file type: "{}"'.format(filename)) def new_ctb() -> ColorDependentPlotStyles: """Create a new CTB file.""" return ColorDependentPlotStyles() def new_stb() -> NamedPlotStyles: """Create a new STB file.""" return NamedPlotStyles() def _decompress(stream: BinaryIO) -> bytes: """Read and decompress the file content from binray `stream`.""" content = stream.read() data = zlib.decompress(content[60:]) # type: bytes return data[:-1] # truncate trailing \nul def _compress(stream: BinaryIO, content: str): """Compress `content` and write to binary `stream`.""" comp_body = zlib.compress(content.encode()) adler_chksum = zlib.adler32(comp_body) stream.write(b"PIAFILEVERSION_2.0,CTBVER1,compress\r\npmzlibcodec") stream.write(pack("LLL", adler_chksum, len(content), len(comp_body))) stream.write(comp_body) class PlotStyleFileParser: """A very simple CTB/STB file parser. CTB/STB files are created by applications, so the file structure should be correct in the most cases. """ def __init__(self, text: str): self.data = {} for element, value in PlotStyleFileParser.iteritems(text): self.data[element] = value @staticmethod def iteritems(text: str): """Iterate over all first level (start at col 0) elements.""" line_index = 0 def get_name() -> str: """Get element name of line .""" line = lines[line_index] if line.endswith("{"): # start of a list like 'plot_style{' name = line[:-1] else: # simple name=value line name = line.split("=", 1)[0] return name.strip() def get_mapping() -> dict: """Get mapping of elements enclosed by { }. e. g. lineweights, plot_styles, aci_table """ def end_of_list(): return lines[line_index].endswith("}") nonlocal line_index data = dict() while not end_of_list(): name = get_name() value = get_value() # get value or sub-list data[name] = value line_index += 1 return data # skip '}' - end of list def get_value() -> Union[str, dict]: """Get value of line or the list that starts in line . """ nonlocal line_index line = lines[line_index] if line.endswith("{"): # start of a list line_index += 1 return get_mapping() else: # it's a simple name=value line value: str = line.split("=", 1)[1] value = sanitized_value(value) line_index += 1 return value def skip_empty_lines(): nonlocal line_index while line_index < len(lines) and len(lines[line_index]) == 0: line_index += 1 lines = text.split("\n") while line_index < len(lines): name = get_name() value = get_value() yield name, value skip_empty_lines() def get(self, name: str, default: Any) -> Any: return self.data.get(name, default) def sanitized_value(value: str) -> str: value = value.strip() if value.startswith('"'): # strings: ="string return value[1:] # remove unknown appendix like this: "0.0076200000000 (+7.Z+"8V?S_LC )" # the pattern is " ()", see issue #1069 if value.endswith(")"): return value.split(" ")[0] return value def int2color(color: int) -> tuple[int, int, int, int]: """Convert color integer value from CTB-file to ``(r, g, b, color_type) tuple. """ # Take color from layer, ignore other bytes. color_type = (color & 0xFF000000) >> 24 red = (color & 0xFF0000) >> 16 green = (color & 0xFF00) >> 8 blue = color & 0xFF return red, green, blue, color_type def mode_color2int(red: int, green: int, blue: int, color_type=COLOR_RGB) -> int: """Convert mode_color (r, g, b, color_type) tuple to integer.""" return -color2int(red, green, blue, color_type) def color2int(red: int, green: int, blue: int, color_type: int) -> int: """Convert color (r, g, b, color_type) to integer.""" return -((color_type << 24) + (red << 16) + (green << 8) + blue) & 0xFFFFFFFF