refactor: excel parse

This commit is contained in:
Blizzard
2026-04-16 10:01:11 +08:00
parent 680ecc320f
commit f62f95ec02
7941 changed files with 2899112 additions and 0 deletions
@@ -0,0 +1,779 @@
# 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 objects 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_index>."""
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 <line_index> or the list that starts in line
<line_index>.
"""
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: <name>="string
return value[1:]
# remove unknown appendix like this: "0.0076200000000 (+7.Z+"8V?S_LC )"
# the pattern is "<float|int> (<some data>)", 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