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,7 @@
# Copyright (c) 2023, Manfred Moitzi
# License: MIT License
#
# 1 plot unit (plu) = 0.025mm
# 40 plu = 1mm
# 1016 plu = 1 inch
# 3.39 plu = 1 dot @300 dpi
@@ -0,0 +1,398 @@
# Copyright (c) 2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
import enum
from xml.etree import ElementTree as ET
import ezdxf
from ezdxf.document import Drawing
from ezdxf import zoom, colors
from ezdxf.addons import xplayer
from ezdxf.addons.drawing import svg, layout, pymupdf, dxf
from ezdxf.addons.drawing.dxf import ColorMode
from .tokenizer import hpgl2_commands
from .plotter import Plotter
from .interpreter import Interpreter
from .backend import Recorder, placement_matrix, Player
DEBUG = False
ENTER_HPGL2_MODE = b"%1B"
class Hpgl2Error(Exception):
"""Base exception for the :mod:`hpgl2` add-on."""
pass
class Hpgl2DataNotFound(Hpgl2Error):
"""No HPGL/2 data was found, maybe the "Enter HPGL/2 mode" escape sequence is missing."""
pass
class EmptyDrawing(Hpgl2Error):
"""The HPGL/2 commands do not produce any content."""
pass
class MergeControl(enum.IntEnum):
NONE = 0 # print order
LUMINANCE = 1 # sort filled polygons by luminance
AUTO = 2 # guess best method
def to_dxf(
b: bytes,
*,
rotation: int = 0,
mirror_x: bool = False,
mirror_y: bool = False,
color_mode=ColorMode.RGB,
merge_control: MergeControl = MergeControl.AUTO,
) -> Drawing:
"""
Exports the HPGL/2 commands of the byte stream `b` as a DXF document.
The page content is created at the origin of the modelspace and 1 drawing unit is 1
plot unit (1 plu = 0.025mm) unless scaling values are provided.
The content of HPGL files is intended to be plotted on white paper, therefore a white
filling will be added as background in color mode :attr:`RGB`.
All entities are assigned to a layer according to the pen number with the name scheme
``PEN_<###>``. In order to be able to process the file better, it is also possible to
assign the :term:`ACI` color by layer by setting the argument `color_mode` to
:attr:`ColorMode.ACI`, but then the RGB color is lost because the RGB color has
always the higher priority over the :term:`ACI`.
The first paperspace layout "Layout1" of the DXF document is set up to print the entire
modelspace on one sheet, the size of the page is the size of the original plot file in
millimeters.
HPGL/2's merge control works at the pixel level and cannot be replicated by DXF,
but to prevent fillings from obscuring text, the filled polygons are
sorted by luminance - this can be forced or disabled by the argument `merge_control`,
see also :class:`MergeControl` enum.
Args:
b: plot file content as bytes
rotation: rotation angle of 0, 90, 180 or 270 degrees
mirror_x: mirror in x-axis direction
mirror_y: mirror in y-axis direction
color_mode: the color mode controls how color values are assigned to DXF entities,
see :class:`ColorMode`
merge_control: how to order filled polygons, see :class:`MergeControl`
Returns: DXF document as instance of class :class:`~ezdxf.document.Drawing`
"""
if rotation not in (0, 90, 180, 270):
raise ValueError("rotation angle must be 0, 90, 180, or 270")
# 1st pass records output of the plotting commands and detects the bounding box
doc = ezdxf.new()
try:
player = record_plotter_output(b, merge_control)
except Hpgl2Error:
return doc
bbox = player.bbox()
m = placement_matrix(bbox, -1 if mirror_x else 1, -1 if mirror_y else 1, rotation)
player.transform(m)
bbox = player.bbox()
msp = doc.modelspace()
dxf_backend = dxf.DXFBackend(msp, color_mode=color_mode)
bg_color = colors.RGB(255, 255, 255)
if color_mode == ColorMode.RGB:
doc.layers.add("BACKGROUND")
bg = dxf.add_background(msp, bbox, color=bg_color)
bg.dxf.layer = "BACKGROUND"
# 2nd pass replays the plotting commands to plot the DXF
# exports the HPGL/2 content in plot units (plu) as modelspace:
# 1 plu = 0.025mm or 40 plu == 1mm
xplayer.hpgl2_to_drawing(player, dxf_backend, bg_color=bg_color.to_hex())
del player
if bbox.has_data: # non-empty page
zoom.window(msp, bbox.extmin, bbox.extmax)
dxf.update_extents(doc, bbox)
# paperspace is set up in mm:
dxf.setup_paperspace(doc, bbox)
return doc
def to_svg(
b: bytes,
*,
rotation: int = 0,
mirror_x: bool = False,
mirror_y: bool = False,
merge_control=MergeControl.AUTO,
) -> str:
"""
Exports the HPGL/2 commands of the byte stream `b` as SVG string.
The plot units are mapped 1:1 to ``viewBox`` units and the size of image is the size
of the original plot file in millimeters.
HPGL/2's merge control works at the pixel level and cannot be replicated by the
backend, but to prevent fillings from obscuring text, the filled polygons are
sorted by luminance - this can be forced or disabled by the argument `merge_control`,
see also :class:`MergeControl` enum.
Args:
b: plot file content as bytes
rotation: rotation angle of 0, 90, 180 or 270 degrees
mirror_x: mirror in x-axis direction
mirror_y: mirror in y-axis direction
merge_control: how to order filled polygons, see :class:`MergeControl`
Returns: SVG content as ``str``
"""
if rotation not in (0, 90, 180, 270):
raise ValueError("rotation angle must be 0, 90, 180, or 270")
# 1st pass records output of the plotting commands and detects the bounding box
try:
player = record_plotter_output(b, merge_control)
except Hpgl2Error:
return ""
# transform content for the SVGBackend of the drawing add-on
bbox = player.bbox()
size = bbox.size
# HPGL/2 uses (integer) plot units, 1 plu = 0.025mm or 1mm = 40 plu
width_in_mm = size.x / 40
height_in_mm = size.y / 40
if rotation in (0, 180):
page = layout.Page(width_in_mm, height_in_mm)
else:
page = layout.Page(height_in_mm, width_in_mm)
# adjust rotation for y-axis mirroring
rotation += 180
# The SVGBackend expects coordinates in the range [0, output_coordinate_space]
max_plot_units = round(max(size.x, size.y))
settings = layout.Settings(output_coordinate_space=max_plot_units)
m = layout.placement_matrix(
bbox,
sx=-1 if mirror_x else 1,
sy=1 if mirror_y else -1, # inverted y-axis
rotation=rotation,
page=page,
output_coordinate_space=settings.output_coordinate_space,
)
player.transform(m)
# 2nd pass replays the plotting commands on the SVGBackend of the drawing add-on
svg_backend = svg.SVGRenderBackend(page, settings)
xplayer.hpgl2_to_drawing(player, svg_backend, bg_color="#ffffff")
del player
xml = svg_backend.get_xml_root_element()
return ET.tostring(xml, encoding="unicode", xml_declaration=True)
def to_pdf(
b: bytes,
*,
rotation: int = 0,
mirror_x: bool = False,
mirror_y: bool = False,
merge_control=MergeControl.AUTO,
) -> bytes:
"""
Exports the HPGL/2 commands of the byte stream `b` as PDF data.
The plot units (1 plu = 0.025mm) are converted to PDF units (1/72 inch) so the image
has the size of the original plot file.
HPGL/2's merge control works at the pixel level and cannot be replicated by the
backend, but to prevent fillings from obscuring text, the filled polygons are
sorted by luminance - this can be forced or disabled by the argument `merge_control`,
see also :class:`MergeControl` enum.
Python module PyMuPDF is required: https://pypi.org/project/PyMuPDF/
Args:
b: plot file content as bytes
rotation: rotation angle of 0, 90, 180 or 270 degrees
mirror_x: mirror in x-axis direction
mirror_y: mirror in y-axis direction
merge_control: how to order filled polygons, see :class:`MergeControl`
Returns: PDF content as ``bytes``
"""
return _pymupdf(
b,
rotation=rotation,
mirror_x=mirror_x,
mirror_y=mirror_y,
merge_control=merge_control,
fmt="pdf",
)
def to_pixmap(
b: bytes,
*,
rotation: int = 0,
mirror_x: bool = False,
mirror_y: bool = False,
merge_control=MergeControl.AUTO,
fmt: str = "png",
dpi: int = 96,
) -> bytes:
"""
Exports the HPGL/2 commands of the byte stream `b` as pixel image.
Supported image formats:
=== =========================
png Portable Network Graphics
ppm Portable Pixmap
pbm Portable Bitmap
=== =========================
The plot units (1 plu = 0.025mm) are converted to dot per inch (dpi) so the image
has the size of the original plot file.
HPGL/2's merge control works at the pixel level and cannot be replicated by the
backend, but to prevent fillings from obscuring text, the filled polygons are
sorted by luminance - this can be forced or disabled by the argument `merge_control`,
see also :class:`MergeControl` enum.
Python module PyMuPDF is required: https://pypi.org/project/PyMuPDF/
Args:
b: plot file content as bytes
rotation: rotation angle of 0, 90, 180 or 270 degrees
mirror_x: mirror in x-axis direction
mirror_y: mirror in y-axis direction
merge_control: how to order filled polygons, see :class:`MergeControl`
fmt: image format
dpi: output resolution in dots per inch
Returns: image content as ``bytes``
"""
fmt = fmt.lower()
if fmt not in pymupdf.SUPPORTED_IMAGE_FORMATS:
raise ValueError(f"image format '{fmt}' not supported")
return _pymupdf(
b,
rotation=rotation,
mirror_x=mirror_x,
mirror_y=mirror_y,
merge_control=merge_control,
fmt=fmt,
dpi=dpi,
)
def _pymupdf(
b: bytes,
*,
rotation: int = 0,
mirror_x: bool = False,
mirror_y: bool = False,
merge_control=MergeControl.AUTO,
fmt: str = "pdf",
dpi=96,
) -> bytes:
if not pymupdf.is_pymupdf_installed:
print("Python module PyMuPDF is required: https://pypi.org/project/PyMuPDF/")
return b""
if rotation not in (0, 90, 180, 270):
raise ValueError("rotation angle must be 0, 90, 180, or 270")
# 1st pass records output of the plotting commands and detects the bounding box
try:
player = record_plotter_output(b, merge_control)
except Hpgl2Error:
return b""
# transform content for the SVGBackend of the drawing add-on
bbox = player.bbox()
size = bbox.size
# HPGL/2 uses (integer) plot units, 1 plu = 0.025mm or 1mm = 40 plu
width_in_mm = size.x / 40
height_in_mm = size.y / 40
if rotation in (0, 180):
page = layout.Page(width_in_mm, height_in_mm)
else:
page = layout.Page(height_in_mm, width_in_mm)
# adjust rotation for y-axis mirroring
rotation += 180
# The PDFBackend expects coordinates as pt = 1/72 inch; 1016 plu = 1 inch
max_plot_units = max(size.x, size.y) / 1016 * 72
settings = layout.Settings(output_coordinate_space=max_plot_units)
m = layout.placement_matrix(
bbox,
sx=-1 if mirror_x else 1,
sy=-1 if mirror_y else 1,
rotation=rotation,
page=page,
output_coordinate_space=settings.output_coordinate_space,
)
player.transform(m)
# 2nd pass replays the plotting commands on the PyMuPdfBackend of the drawing add-on
pymupdf_backend = pymupdf.PyMuPdfBackend()
xplayer.hpgl2_to_drawing(player, pymupdf_backend, bg_color="#ffffff")
del player
if fmt == "pdf":
return pymupdf_backend.get_pdf_bytes(page, settings=settings)
else:
return pymupdf_backend.get_pixmap_bytes(
page, fmt=fmt, settings=settings, dpi=dpi
)
def print_interpreter_log(interpreter: Interpreter) -> None:
print("HPGL/2 interpreter log:")
print(f"unsupported commands: {interpreter.not_implemented_commands}")
if interpreter.errors:
print("parsing errors:")
for err in interpreter.errors:
print(err)
def record_plotter_output(
b: bytes,
merge_control: MergeControl,
) -> Player:
commands = hpgl2_commands(b)
if len(commands) == 0:
print("HPGL2 data not found.")
raise Hpgl2DataNotFound
recorder = Recorder()
plotter = Plotter(recorder)
interpreter = Interpreter(plotter)
interpreter.run(commands)
if DEBUG:
print_interpreter_log(interpreter)
player = recorder.player()
bbox = player.bbox()
if not bbox.has_data:
raise EmptyDrawing
if merge_control == MergeControl.AUTO:
if plotter.has_merge_control:
merge_control = MergeControl.LUMINANCE
if merge_control == MergeControl.LUMINANCE:
if DEBUG:
print("merge control on: sorting filled polygons by luminance")
player.sort_filled_paths()
return player
@@ -0,0 +1,237 @@
# Copyright (c) 2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Sequence, NamedTuple, Any, Iterator
from typing_extensions import Self
import abc
import copy
import enum
import math
from .deps import (
Vec2,
Path,
colors,
Matrix44,
BoundingBox2d,
)
from .properties import Properties, Pen
from ezdxf.npshapes import NumpyPath2d, NumpyPoints2d
# Page coordinates are always plot units:
# 1 plot unit (plu) = 0.025mm
# 40 plu = 1mm
# 1016 plu = 1 inch
# 3.39 plu = 1 dot @300 dpi
# positive x-axis is horizontal from left to right
# positive y-axis is vertical from bottom to top
class Backend(abc.ABC):
"""Abstract base class for implementing a low level output backends."""
@abc.abstractmethod
def draw_polyline(self, properties: Properties, points: Sequence[Vec2]) -> None:
"""Draws a polyline from a sequence `points`. The input coordinates are page
coordinates in plot units. The `points` sequence can contain 0 or more
points!
Args:
properties: display :class:`Properties` for the polyline
points: sequence of :class:`ezdxf.math.Vec2` instances
"""
...
@abc.abstractmethod
def draw_paths(
self, properties: Properties, paths: Sequence[Path], filled: bool
) -> None:
"""Draws filled or outline paths from the sequence of `paths`. The input coordinates
are page coordinates in plot units. The `paths` sequence can contain 0 or more
single :class:`~ezdxf.path.Path` instances. Draws outline paths if
Properties.FillType is NONE and filled paths otherwise.
Args:
properties: display :class:`Properties` for the filled polygon
paths: sequence of single :class:`ezdxf.path.Path` instances
filled: draw filled paths if ``True`` otherwise outline paths
"""
...
class RecordType(enum.Enum):
POLYLINE = enum.auto()
FILLED_PATHS = enum.auto()
OUTLINE_PATHS = enum.auto()
class DataRecord(NamedTuple):
type: RecordType
property_hash: int
data: Any
class Recorder(Backend):
"""The :class:`Recorder` class records the output of the :class:`Plotter` class.
All input coordinates are page coordinates:
- 1 plot unit (plu) = 0.025mm
- 40 plu = 1 mm
- 1016 plu = 1 inch
"""
def __init__(self) -> None:
self._records: list[DataRecord] = []
self._properties: dict[int, Properties] = {}
self._pens: Sequence[Pen] = []
def player(self) -> Player:
"""Returns a :class:`Player` instance with the original recordings. Make a copy
of this player to protect the original recordings from being modified::
safe_player = recorder.player().copy()
"""
return Player(self._records, self._properties)
def draw_polyline(self, properties: Properties, points: Sequence[Vec2]) -> None:
self.store(RecordType.POLYLINE, properties, NumpyPoints2d(points))
def draw_paths(
self, properties: Properties, paths: Sequence[Path], filled: bool
) -> None:
data = tuple(map(NumpyPath2d, paths))
record_type = RecordType.FILLED_PATHS if filled else RecordType.OUTLINE_PATHS
self.store(record_type, properties, data)
def store(self, record_type: RecordType, properties: Properties, args) -> None:
prop_hash = properties.hash()
if prop_hash not in self._properties:
self._properties[prop_hash] = properties.copy()
self._records.append(DataRecord(record_type, prop_hash, args))
if len(self._pens) != len(properties.pen_table):
self._pens = list(properties.pen_table.values())
class Player:
"""This class replays the recordings of the :class:`Recorder` class on another
backend. The class can modify the recorded output.
"""
def __init__(self, records: list[DataRecord], properties: dict[int, Properties]):
self._records: list[DataRecord] = records
self._properties: dict[int, Properties] = properties
self._bbox = BoundingBox2d()
def __copy__(self) -> Self:
"""Returns a new :class:`Player` instance with a copy of recordings."""
records = copy.deepcopy(self._records)
player = self.__class__(records, self._properties)
player._bbox = self._bbox.copy()
return player
copy = __copy__
def recordings(self) -> Iterator[tuple[RecordType, Properties, Any]]:
"""Yields all recordings as `(RecordType, Properties, Data)` tuples.
The content of the `Data` field is determined by the enum :class:`RecordType`:
- :attr:`RecordType.POLYLINE` returns a :class:`NumpyPoints2d` instance
- :attr:`RecordType.FILLED_POLYGON` returns a tuple of :class:`NumpyPath2d` instances
"""
props = self._properties
for record in self._records:
yield record.type, props[record.property_hash], record.data
def bbox(self) -> BoundingBox2d:
"""Returns the bounding box of all recorded polylines and polygons as
:class:`~ezdxf.math.BoundingBox2d`.
"""
if not self._bbox.has_data:
self.update_bbox()
return self._bbox
def update_bbox(self) -> None:
points: list[Vec2] = []
for record in self._records:
if record.type == RecordType.POLYLINE:
points.extend(record.data.extents())
else:
for path in record.data:
points.extend(path.extents())
self._bbox = BoundingBox2d(points)
def replay(self, backend: Backend) -> None:
"""Replay the recording on another backend."""
current_props = Properties()
props = self._properties
for record in self._records:
current_props = props.get(record.property_hash, current_props)
if record.type == RecordType.POLYLINE:
backend.draw_polyline(current_props, record.data.vertices())
else:
paths = [p.to_path2d() for p in record.data]
backend.draw_paths(
current_props, paths, filled=record.type == RecordType.FILLED_PATHS
)
def transform(self, m: Matrix44) -> None:
"""Transforms the recordings by a transformation matrix `m` of type
:class:`~ezdxf.math.Matrix44`.
"""
for record in self._records:
if record.type == RecordType.POLYLINE:
record.data.transform_inplace(m)
else:
for path in record.data:
path.transform_inplace(m)
if self._bbox.has_data:
# fast, but maybe inaccurate update
self._bbox = BoundingBox2d(m.fast_2d_transform(self._bbox.rect_vertices()))
def sort_filled_paths(self) -> None:
"""Sort filled paths by descending luminance (from light to dark).
This also changes the plot order in the way that all filled paths are plotted
before polylines and outline paths.
"""
fillings = []
outlines = []
current = Properties()
props = self._properties
for record in self._records:
if record.type == RecordType.FILLED_PATHS:
current = props.get(record.property_hash, current)
key = colors.luminance(current.resolve_fill_color())
fillings.append((key, record))
else:
outlines.append(record)
fillings.sort(key=lambda r: r[0], reverse=True)
records = [sort_rec[1] for sort_rec in fillings]
records.extend(outlines)
self._records = records
def placement_matrix(
bbox: BoundingBox2d, sx: float = 1.0, sy: float = 1.0, rotation: float = 0.0
) -> Matrix44:
"""Returns a matrix to place the bbox in the first quadrant of the coordinate
system (+x, +y).
"""
if abs(sx) < 1e-9:
sx = 1.0
if abs(sy) < 1e-9:
sy = 1.0
m = Matrix44.scale(sx, sy, 1.0)
if rotation:
m @= Matrix44.z_rotate(math.radians(rotation))
corners = m.fast_2d_transform(bbox.rect_vertices())
tx, ty = BoundingBox2d(corners).extmin
return m @ Matrix44.translate(-tx, -ty, 0)
@@ -0,0 +1,13 @@
# Copyright (c) 2023, Manfred Moitzi
# License: MIT License
#
# Central import location of frontend dependencies.
# To extract te hpgl2 add-on from the ezdxf package, the following tools have to be
# implemented or extracted too. The dependencies of the implemented backends are not
# listed here.
from ezdxf.math import Vec2, ConstructionCircle, BoundingBox2d, Bezier4P, AnyVec, Matrix44
from ezdxf.path import Path, bbox as path_bbox, transform_paths
from ezdxf.tools.standards import PAGE_SIZES
from ezdxf import colors
NULLVEC2 = Vec2(0, 0)
@@ -0,0 +1,458 @@
# Copyright (c) 2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Iterator, Iterable
import string
from .deps import Vec2, NULLVEC2
from .properties import RGB
from .plotter import Plotter
from .tokenizer import Command, pe_decode
class Interpreter:
"""The :class:`Interpreter` is the frontend for the :class:`Plotter` class.
The :meth:`run` methods interprets the low level HPGL commands from the
:func:`hpgl2_commands` parser and sends the commands to the virtual plotter
device, which sends his output to a low level :class:`Backend` class.
Most CAD application send a very restricted subset of commands to plotters,
mostly just polylines and filled polygons. Implementing the whole HPGL/2 command set
is not worth the effort - unless reality proofs otherwise.
Not implemented commands:
- the whole character group - text is send as filled polygons or polylines
- configuration group: IN, DF, RO, IW - the plotter is initialized by creating a
new plotter and page rotation is handled by the add-on itself
- polygon group: EA, ER, EW, FA, RR, WG, the rectangle and wedge commands
- line and fill attributes group: LA, RF, SM, SV, TR, UL, WU, linetypes and
hatch patterns are decomposed into simple lines by CAD applications
Args:
plotter: virtual :class:`Plotter` device
"""
def __init__(self, plotter: Plotter) -> None:
self.errors: list[str] = []
self.not_implemented_commands: set[str] = set()
self._disabled_commands: set[str] = set()
self.plotter = plotter
def add_error(self, error: str) -> None:
self.errors.append(error)
def run(self, commands: list[Command]) -> None:
"""Interprets the low level HPGL commands from the :func:`hpgl2_commands` parser
and sends the commands to the virtual plotter device.
"""
for name, args in commands:
if name in self._disabled_commands:
continue
method = getattr(self, f"cmd_{name.lower()}", None)
if method:
method(args)
elif name[0] in string.ascii_letters:
self.not_implemented_commands.add(name)
def disable_commands(self, commands: Iterable[str]) -> None:
"""Disable commands manually, like the scaling command ["SC", "IP", "IR"].
This is a feature for experts, because disabling commands which changes the pen
location may distort or destroy the plotter output.
"""
self._disabled_commands.update(commands)
# Configure pens, line types, fill types
def cmd_ft(self, args: list[bytes]):
"""Set fill type."""
fill_type = 1
spacing = 0.0
angle = 0.0
values = tuple(to_floats(args))
arg_count = len(values)
if arg_count > 0:
fill_type = int(values[0])
if arg_count > 1:
spacing = values[1]
if arg_count > 2:
angle = values[2]
self.plotter.set_fill_type(fill_type, spacing, angle)
def cmd_pc(self, args: list[bytes]):
"""Set pen color as RGB tuple."""
values = list(to_ints(args))
if len(values) == 4:
index, r, g, b = values
self.plotter.set_pen_color(index, RGB(r, g, b))
else:
self.add_error("invalid arguments for PC command")
def cmd_pw(self, args: list[bytes]):
"""Set pen width."""
arg_count = len(args)
if arg_count:
width = to_float(args[0], 0.35)
else:
self.add_error("invalid arguments for PW command")
return
index = -1
if arg_count > 1:
index = to_int(args[1], index)
self.plotter.set_pen_width(index, width)
def cmd_sp(self, args: list[bytes]):
"""Select pen."""
if len(args):
self.plotter.set_current_pen(to_int(args[0], 1))
def cmd_np(self, args: list[bytes]):
"""Set number of pens."""
if len(args):
self.plotter.set_max_pen_count(to_int(args[0], 2))
def cmd_ip(self, args: list[bytes]):
"""Set input points p1 and p2 absolute."""
if len(args) == 0:
self.plotter.reset_scaling()
return
points = to_points(to_floats(args))
if len(points) > 1:
self.plotter.set_scaling_points(points[0], points[1])
else:
self.add_error("invalid arguments for IP command")
def cmd_ir(self, args: list[bytes]):
"""Set input points p1 and p2 in percentage of page size."""
if len(args) == 0:
self.plotter.reset_scaling()
return
values = list(to_floats(args))
if len(values) == 2:
xp1 = clamp(values[0], 0.0, 100.0)
yp1 = clamp(values[1], 0.0, 100.0)
self.plotter.set_scaling_points_relative_1(xp1 / 100.0, yp1 / 100.0)
elif len(values) == 4:
xp1 = clamp(values[0], 0.0, 100.0)
yp1 = clamp(values[1], 0.0, 100.0)
xp2 = clamp(values[2], 0.0, 100.0)
yp2 = clamp(values[3], 0.0, 100.0)
self.plotter.set_scaling_points_relative_2(
xp1 / 100.0, yp1 / 100.0, xp2 / 100.0, yp2 / 100.0
)
else:
self.add_error("invalid arguments for IP command")
def cmd_sc(self, args: list[bytes]):
if len(args) == 0:
self.plotter.reset_scaling()
return
values = list(to_floats(args))
if len(values) < 4:
self.add_error("invalid arguments for SC command")
return
scaling_type = 0
if len(values) > 4:
scaling_type = int(values[4])
if scaling_type == 1: # isotropic
left = 50.0
if len(values) > 5:
left = clamp(values[5], 0.0, 100.0)
bottom = 50.0
if len(values) > 6:
bottom = clamp(values[6], 0.0, 100.0)
self.plotter.set_isotropic_scaling(
values[0],
values[1],
values[2],
values[3],
left,
bottom,
)
elif scaling_type == 2: # point factor
self.plotter.set_point_factor(
Vec2(values[0], values[2]), values[1], values[3]
)
else: # anisotropic
self.plotter.set_anisotropic_scaling(
values[0], values[1], values[2], values[3]
)
def cmd_mc(self, args: list[bytes]):
status = 0
if len(args):
status = to_int(args[0], status)
self.plotter.set_merge_control(bool(status))
def cmd_ps(self, args: list[bytes]):
length = 1189 # A0
height = 841
count = len(args)
if count:
length = to_int(args[0], length)
height = int(length * 1.5)
if count > 1:
height = to_int(args[1], height)
self.plotter.setup_page(length, height)
# pen movement:
def cmd_pd(self, args: list[bytes]):
"""Lower pen down and plot lines."""
self.plotter.pen_down()
if len(args):
self.plotter.plot_polyline(to_points(to_floats(args)))
def cmd_pu(self, args: list[bytes]):
"""Lift pen up and move pen."""
self.plotter.pen_up()
if len(args):
self.plotter.plot_polyline(to_points(to_floats(args)))
def cmd_pa(self, args: list[bytes]):
"""Place pen absolute. Plots polylines if pen is down."""
self.plotter.set_absolute_mode()
if len(args):
self.plotter.plot_polyline(to_points(to_floats(args)))
def cmd_pr(self, args: list[bytes]):
"""Place pen relative.Plots polylines if pen is down."""
self.plotter.set_relative_mode()
if len(args):
self.plotter.plot_polyline(to_points(to_floats(args)))
# plot commands:
def cmd_ci(self, args: list[bytes]):
"""Plot full circle."""
arg_count = len(args)
if not arg_count:
self.add_error("invalid arguments for CI command")
return
self.plotter.push_pen_state()
# implicit pen down!
self.plotter.pen_down()
radius = to_float(args[0], 1.0)
chord_angle = 5.0
if arg_count > 1:
chord_angle = to_float(args[1], chord_angle)
self.plotter.plot_abs_circle(radius, chord_angle)
self.plotter.pop_pen_state()
def cmd_aa(self, args: list[bytes]):
"""Plot arc absolute."""
if len(args) < 3:
self.add_error("invalid arguments for AR command")
return
self._arc_out(args, self.plotter.plot_abs_arc)
def cmd_ar(self, args: list[bytes]):
"""Plot arc relative."""
if len(args) < 3:
self.add_error("invalid arguments for AR command")
return
self._arc_out(args, self.plotter.plot_rel_arc)
@staticmethod
def _arc_out(args: list[bytes], output_method):
"""Plot arc"""
arg_count = len(args)
if arg_count < 3:
return
x = to_float(args[0])
y = to_float(args[1])
sweep_angle = to_float(args[2])
chord_angle = 5.0
if arg_count > 3:
chord_angle = to_float(args[3], chord_angle)
output_method(Vec2(x, y), sweep_angle, chord_angle)
def cmd_at(self, args: list[bytes]):
"""Plot arc absolute from three points."""
if len(args) < 4:
self.add_error("invalid arguments for AT command")
return
self._arc_3p_out(args, self.plotter.plot_abs_arc_three_points)
def cmd_rt(self, args: list[bytes]):
"""Plot arc relative from three points."""
if len(args) < 4:
self.add_error("invalid arguments for RT command")
return
self._arc_3p_out(args, self.plotter.plot_rel_arc_three_points)
@staticmethod
def _arc_3p_out(args: list[bytes], output_method):
"""Plot arc from three points"""
arg_count = len(args)
if arg_count < 4:
return
points = to_points(to_floats(args))
if len(points) < 2:
return
chord_angle = 5.0
if arg_count > 4:
chord_angle = to_float(args[4], chord_angle)
try:
output_method(points[0], points[1], chord_angle)
except ZeroDivisionError:
pass
def cmd_bz(self, args: list[bytes]):
"""Plot cubic Bezier curves with absolute user coordinates."""
self._bezier_out(args, self.plotter.plot_abs_cubic_bezier)
def cmd_br(self, args: list[bytes]):
"""Plot cubic Bezier curves with relative user coordinates."""
self._bezier_out(args, self.plotter.plot_rel_cubic_bezier)
@staticmethod
def _bezier_out(args: list[bytes], output_method):
kind = 0
ctrl1 = NULLVEC2
ctrl2 = NULLVEC2
for point in to_points(to_floats(args)):
if kind == 0:
ctrl1 = point
elif kind == 1:
ctrl2 = point
elif kind == 2:
end = point
output_method(ctrl1, ctrl2, end)
kind = (kind + 1) % 3
def cmd_pe(self, args: list[bytes]):
"""Plot Polyline Encoded."""
if len(args):
data = args[0]
else:
self.add_error("invalid arguments for PE command")
return
plotter = self.plotter
# The last pen up/down state remains after leaving the PE command.
pen_down = True
# Ignores and preserves the current absolute/relative mode of the plotter.
absolute = False
frac_bin_bits = 0
base = 64
index = 0
length = len(data)
point_queue: list[Vec2] = []
while index < length:
char = data[index]
if char in b":<>=7":
index += 1
if char == 58: # ":" - select pen
values, index = pe_decode(data, base=base, start=index)
plotter.set_current_pen(int(values[0]))
if len(values) > 1:
point_queue.extend(to_points(values[1:]))
elif char == 60: # "<" - pen up and goto coordinates
pen_down = False
elif char == 62: # ">" - fractional data
values, index = pe_decode(data, base=base, start=index)
frac_bin_bits = int(values[0])
if len(values) > 1:
point_queue.extend(to_points(values[1:]))
elif char == 61: # "=" - next coordinates are absolute
absolute = True
elif char == 55: # "7" - 7-bit mode
base = 32
else:
values, index = pe_decode(
data, frac_bits=frac_bin_bits, base=base, start=index
)
point_queue.extend(to_points(values))
if point_queue:
plotter.pen_down()
if absolute:
# next point is absolute: make relative
point_queue[0] = point_queue[0] - plotter.user_location
if not pen_down:
target = point_queue.pop(0)
plotter.move_to_rel(target)
if not point_queue: # last point in queue
plotter.pen_up()
if point_queue:
plotter.plot_rel_polyline(point_queue)
point_queue.clear()
pen_down = True
absolute = False
# polygon mode:
def cmd_pm(self, args: list[bytes]) -> None:
"""Enter/Exit polygon mode."""
status = 0
if len(args):
status = to_int(args[0], status)
if status == 2:
self.plotter.exit_polygon_mode()
else:
self.plotter.enter_polygon_mode(status)
def cmd_fp(self, args: list[bytes]) -> None:
"""Plot filled polygon."""
fill_method = 0
if len(args):
fill_method = one_of(to_int(args[0], fill_method), (0, 1))
self.plotter.fill_polygon(fill_method)
def cmd_ep(self, _) -> None:
"""Plot edged polygon."""
self.plotter.edge_polygon()
def to_floats(args: Iterable[bytes]) -> Iterator[float]:
for arg in args:
try:
yield float(arg)
except ValueError:
pass
def to_ints(args: Iterable[bytes]) -> Iterator[int]:
for arg in args:
try:
yield int(arg)
except ValueError:
pass
def to_points(values: Iterable[float]) -> list[Vec2]:
points: list[Vec2] = []
append_point = False
buffer: float = 0.0
for value in values:
if append_point:
points.append(Vec2(buffer, value))
append_point = False
else:
buffer = value
append_point = True
return points
def to_float(s: bytes, default=0.0) -> float:
try:
return float(s)
except ValueError:
return default
def to_int(s: bytes, default=0) -> int:
try:
return int(s)
except ValueError:
return default
def clamp(v, v_min, v_max):
return max(min(v_max, v), v_min)
def one_of(value, choice):
if value in choice:
return value
return choice[0]
@@ -0,0 +1,134 @@
# Copyright (c) 2023, Manfred Moitzi
# License: MIT License
# 1 plot unit (plu) = 0.025mm
# 40 plu = 1 mm
# 1016 plu = 1 inch
# 3.39 plu = 1 dot @300 dpi
from __future__ import annotations
from typing import Sequence
import math
from .deps import Vec2, NULLVEC2
INCH_TO_PLU = 1016
MM_TO_PLU = 40
class Page:
def __init__(self, size_x: int, size_y: int):
self.size_x = int(size_x) # in plotter units (plu)
self.size_y = int(size_y) # plu
self.p1 = NULLVEC2 # plu
self.p2 = Vec2(size_x, size_y)
self.user_scaling = False
self.user_scale_x: float = 1.0
self.user_scale_y: float = 1.0
self.user_origin = NULLVEC2 # plu
def set_scaling_points(self, p1: Vec2, p2: Vec2) -> None:
self.reset_scaling()
self.p1 = Vec2(p1)
self.p2 = Vec2(p2)
def apply_scaling_factors(self, sx: float, sy: float) -> None:
self.set_ucs(self.user_origin, self.user_scale_x * sx, self.user_scale_y * sy)
def set_scaling_points_relative_1(self, xp1: float, yp1: float) -> None:
size = self.p2 - self.p1
p1 = Vec2(self.size_x * xp1, self.size_y * yp1)
self.set_scaling_points(p1, p1 + size)
def set_scaling_points_relative_2(
self, xp1: float, yp1: float, xp2: float, yp2: float
) -> None:
p1 = Vec2(self.size_x * xp1, self.size_y * yp1)
p2 = Vec2(self.size_x * xp2, self.size_y * yp2)
self.set_scaling_points(p1, p2)
def reset_scaling(self) -> None:
self.p1 = NULLVEC2
self.p2 = Vec2(self.size_x, self.size_y)
self.set_ucs(NULLVEC2)
def set_isotropic_scaling(
self,
x_min: float,
x_max: float,
y_min: float,
y_max: float,
left=0.5,
bottom=0.5,
) -> None:
size = self.p2 - self.p1
delta_x = x_max - x_min
delta_y = y_max - y_min
scale_x = 1.0
if abs(delta_x) > 1e-9:
scale_x = size.x / delta_x
scale_y = 1.0
if abs(delta_y) > 1e-9:
scale_y = size.y / delta_y
scale = min(abs(scale_x), abs(scale_y))
scale_x = math.copysign(scale, scale_x)
scale_y = math.copysign(scale, scale_y)
offset_x = (size.x - delta_x * scale_x) * left
offset_y = (size.y - delta_y * scale_y) * bottom
origin_x = self.p1.x + offset_x - x_min * scale_x
origin_y = self.p1.y + offset_y - y_min * scale_y
self.set_ucs(Vec2(origin_x, origin_y), scale_x, scale_y)
def set_anisotropic_scaling(
self, x_min: float, x_max: float, y_min: float, y_max: float
) -> None:
size = self.p2 - self.p1
delta_x = x_max - x_min
delta_y = y_max - y_min
scale_x = 1.0
if abs(delta_x) > 1e-9:
scale_x = size.x / delta_x
scale_y = 1.0
if abs(delta_y) > 1e-9:
scale_y = size.y / delta_y
origin_x = self.p1.x - x_min * scale_x
origin_y = self.p1.y - y_min * scale_y
self.set_ucs(Vec2(origin_x, origin_y), scale_x, scale_y)
def set_ucs(self, origin: Vec2, sx: float = 1.0, sy: float = 1.0):
self.user_origin = Vec2(origin)
self.user_scale_x = float(sx)
self.user_scale_y = float(sy)
if abs(self.user_scale_x) < 1e-6:
self.user_scale_x = 1.0
if abs(self.user_scale_y) < 1e-6:
self.user_scale_y = 1.0
if math.isclose(self.user_scale_x, 1.0) and math.isclose(
self.user_scale_y, 1.0
):
self.user_scaling = False
else:
self.user_scaling = True
def page_point(self, x: float, y: float) -> Vec2:
"""Returns the page location as page point in plotter units."""
return self.page_vector(x, y) + self.user_origin
def page_vector(self, x: float, y: float) -> Vec2:
"""Returns the user vector in page vector in plotter units."""
if self.user_scaling:
x = self.user_scale_x * x
y = self.user_scale_y * y
return Vec2(x, y)
def page_points(self, points: Sequence[Vec2]) -> list[Vec2]:
"""Returns all user points as page points in plotter units."""
return [self.page_point(p.x, p.y) for p in points]
def page_vectors(self, vectors: Sequence[Vec2]) -> list[Vec2]:
"""Returns all user vectors as page vectors in plotter units."""
return [self.page_vector(p.x, p.y) for p in vectors]
def scale_length(self, length: float) -> tuple[float, float]:
"""Scale a length in user units to plotter units, scaling can be non-uniform."""
return length * self.user_scale_x, length * self.user_scale_y
@@ -0,0 +1,315 @@
# Copyright (c) 2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Sequence, Iterator
import math
from .deps import (
Vec2,
Path,
NULLVEC2,
ConstructionCircle,
Bezier4P,
)
from .properties import RGB, Properties, FillType
from .backend import Backend
from .polygon_buffer import PolygonBuffer
from .page import Page
class Plotter:
"""
The :class:`Plotter` class represents a virtual plotter device.
The HPGL/2 commands send by the :class:`Interpreter` are processed into simple
polylines and filled polygons and send to low level :class:`Backend`.
HPGL/2 uses a units system called "Plot Units":
- 1 plot unit (plu) = 0.025mm
- 40 plu = 1 mm
- 1016 plu = 1 inch
The Plotter device does not support font rendering and page rotation (RO).
The scaling commands IP, RP, SC are supported.
"""
def __init__(self, backend: Backend) -> None:
self.backend = backend
self._output_backend = backend
self._polygon_buffer = PolygonBuffer()
self.page = Page(1189, 841)
self.properties = Properties()
self.is_pen_down = False
self.is_absolute_mode = True
self.is_polygon_mode = False
self.has_merge_control = False
self._user_location = NULLVEC2
self._pen_state_stack: list[bool] = []
@property
def user_location(self) -> Vec2:
"""Returns the current pen location as point in the user coordinate system."""
return self._user_location
@property
def page_location(self) -> Vec2:
"""Returns the current pen location as page point in plotter units."""
location = self.user_location
return self.page.page_point(location.x, location.y)
def setup_page(self, size_x: int, size_y: int):
self.page = Page(size_x, size_y)
def set_scaling_points(self, p1: Vec2, p2: Vec2) -> None:
self.page.set_scaling_points(p1, p2)
def set_scaling_points_relative_1(self, xp1: float, yp1: float) -> None:
self.page.set_scaling_points_relative_1(xp1, yp1)
def set_scaling_points_relative_2(
self, xp1: float, yp1: float, xp2: float, yp2: float
) -> None:
self.page.set_scaling_points_relative_2(xp1, yp1, xp2, yp2)
def reset_scaling(self) -> None:
self.page.reset_scaling()
def set_point_factor(self, origin: Vec2, scale_x: float, scale_y: float) -> None:
self.page.set_ucs(origin, scale_x, scale_y)
def set_isotropic_scaling(
self,
x_min: float,
x_max: float,
y_min: float,
y_max: float,
left=0.5,
bottom=0.5,
) -> None:
self.page.set_isotropic_scaling(x_min, x_max, y_min, y_max, left, bottom)
def set_anisotropic_scaling(
self, x_min: float, x_max: float, y_min: float, y_max: float
) -> None:
self.page.set_anisotropic_scaling(x_min, x_max, y_min, y_max)
def set_merge_control(self, status: bool) -> None:
self.has_merge_control = status
def pen_up(self) -> None:
self.is_pen_down = False
def pen_down(self) -> None:
self.is_pen_down = True
def push_pen_state(self) -> None:
self._pen_state_stack.append(self.is_pen_down)
def pop_pen_state(self) -> None:
if len(self._pen_state_stack):
self.is_pen_down = self._pen_state_stack.pop()
def move_to(self, location: Vec2) -> None:
if self.is_absolute_mode:
self.move_to_abs(location)
else:
self.move_to_rel(location)
def move_to_abs(self, user_location: Vec2) -> None:
self._user_location = user_location
def move_to_rel(self, user_location: Vec2) -> None:
self._user_location += user_location
def set_absolute_mode(self) -> None:
self.is_absolute_mode = True
def set_relative_mode(self) -> None:
self.is_absolute_mode = False
def set_current_pen(self, index: int) -> None:
self.properties.set_current_pen(index)
def set_max_pen_count(self, index: int) -> None:
self.properties.set_max_pen_count(index)
def set_pen_width(self, index: int, width: float) -> None:
self.properties.set_pen_width(index, width)
def set_pen_color(self, index: int, color: RGB) -> None:
self.properties.set_pen_color(index, color)
def set_fill_type(self, fill_type: int, spacing: float, angle: float) -> None:
if fill_type in (3, 4): # adjust spacing between hatching lines
spacing = max(self.page.scale_length(spacing))
self.properties.set_fill_type(fill_type, spacing, angle)
def enter_polygon_mode(self, status: int) -> None:
self.is_polygon_mode = True
self.backend = self._polygon_buffer
if status == 0:
self._polygon_buffer.reset(self.page_location)
elif status == 1:
self._polygon_buffer.close_path()
def exit_polygon_mode(self) -> None:
self.is_polygon_mode = False
self._polygon_buffer.close_path()
self.backend = self._output_backend
def fill_polygon(self, fill_method: int) -> None:
self.properties.set_fill_method(fill_method)
self.plot_filled_polygon_buffer(self._polygon_buffer.get_paths())
def edge_polygon(self) -> None:
self.plot_outline_polygon_buffer(self._polygon_buffer.get_paths())
def plot_polyline(self, points: Sequence[Vec2]):
if not points:
return
if self.is_absolute_mode:
self.plot_abs_polyline(points)
else:
self.plot_rel_polyline(points)
def plot_abs_polyline(self, points: Sequence[Vec2]):
# input coordinates are user coordinates
if not points:
return
current_page_location = self.page_location
self.move_to_abs(points[-1]) # user coordinates!
if self.is_pen_down:
# convert to page coordinates:
points = self.page.page_points(points)
# insert current page location as starting point:
points.insert(0, current_page_location)
# draw polyline in absolute page coordinates:
self.backend.draw_polyline(self.properties, points)
def plot_rel_polyline(self, points: Sequence[Vec2]):
# input coordinates are user coordinates
if not points:
return
# convert to absolute user coordinates:
self.plot_abs_polyline(
tuple(rel_to_abs_points_dynamic(self.user_location, points))
)
def plot_abs_circle(self, radius: float, chord_angle: float):
# radius in user units
if self.is_pen_down:
center = self.user_location
vertices = [
center + Vec2.from_deg_angle(a, radius)
for a in arc_angles(0, 360.0, chord_angle)
]
# draw circle in absolute page coordinates:
self.backend.draw_polyline(self.properties, vertices)
def plot_abs_arc(self, center: Vec2, sweep_angle: float, chord_angle: float):
start_point = self.user_location
radius_vec = start_point - center
radius = radius_vec.magnitude
start_angle = radius_vec.angle_deg
end_angle = start_angle + sweep_angle
end_point = center + Vec2.from_deg_angle(end_angle, radius)
self.move_to_abs(end_point)
if self.is_pen_down:
vertices = [
center + Vec2.from_deg_angle(a, radius)
for a in arc_angles(start_angle, sweep_angle, chord_angle)
]
self.backend.draw_polyline(self.properties, vertices)
def plot_rel_arc(self, center_rel: Vec2, sweep_angle: float, chord_angle: float):
self.plot_abs_arc(center_rel + self.user_location, sweep_angle, chord_angle)
def plot_abs_arc_three_points(self, inter: Vec2, end: Vec2, chord_angle: float):
# input coordinates are user coordinates
start = self.user_location
circle = ConstructionCircle.from_3p(start, inter, end)
center = circle.center
start_angle = (start - center).angle_deg
end_angle = (end - center).angle_deg
inter_angle = (inter - center).angle_deg
sweep_angle = sweeping_angle(start_angle, inter_angle, end_angle)
self.plot_abs_arc(center, sweep_angle, chord_angle)
def plot_rel_arc_three_points(self, inter: Vec2, end: Vec2, chord_angle: float):
# input coordinates are user coordinates
current = self.user_location
self.plot_abs_arc_three_points(current + inter, current + end, chord_angle)
def plot_abs_cubic_bezier(self, ctrl1: Vec2, ctrl2: Vec2, end: Vec2):
# input coordinates are user coordinates
current_page_location = self.page_location
self.move_to_abs(end) # user coordinates!
if self.is_pen_down:
# convert to page coordinates:
ctrl1, ctrl2, end = self.page.page_points((ctrl1, ctrl2, end))
# draw cubic bezier curve in absolute page coordinates:
p = Path(current_page_location)
p.curve4_to(end, ctrl1, ctrl2)
self.backend.draw_paths(self.properties, [p], filled=False)
def plot_rel_cubic_bezier(self, ctrl1: Vec2, ctrl2: Vec2, end: Vec2):
# input coordinates are user coordinates
ctrl1, ctrl2, end = rel_to_abs_points_static(
self.user_location, (ctrl1, ctrl2, end)
)
self.plot_abs_cubic_bezier(ctrl1, ctrl2, end)
def plot_filled_polygon_buffer(self, paths: Sequence[Path]):
# input coordinates are page coordinates!
self.backend.draw_paths(self.properties, paths, filled=True)
def plot_outline_polygon_buffer(self, paths: Sequence[Path]):
# input coordinates are page coordinates!
self.backend.draw_paths(self.properties, paths, filled=False)
def rel_to_abs_points_dynamic(current: Vec2, points: Sequence[Vec2]) -> Iterator[Vec2]:
"""Returns the absolute location of increment points, each point is an increment
of the previous point starting at the current pen location.
"""
for point in points:
current += point
yield current
def rel_to_abs_points_static(current: Vec2, points: Sequence[Vec2]) -> Iterator[Vec2]:
"""Returns the absolute location of increment points, all points are relative
to the current pen location.
"""
for point in points:
yield current + point
def arc_angles(start: float, sweep_angle: float, chord_angle: float) -> Iterator[float]:
# clamp to 0.5 .. 180
chord_angle = min(180.0, max(0.5, chord_angle))
count = abs(round(sweep_angle / chord_angle))
delta = sweep_angle / count
for index in range(count + 1):
yield start + delta * index
def sweeping_angle(start: float, intermediate: float, end: float) -> float:
"""Returns the sweeping angle from start angle to end angle passing the
intermediate angle.
"""
start = start % 360.0
intermediate = intermediate % 360.0
end = end % 360.0
angle = end - start
i_to_s = start - intermediate
i_to_e = end - intermediate
if math.isclose(abs(i_to_e) + abs(i_to_s), abs(angle)):
return angle
else: # return complementary angle with opposite orientation
if angle < 0:
return 360.0 + angle
else:
return angle - 360.0
@@ -0,0 +1,53 @@
# Copyright (c) 2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Sequence
from .backend import Backend
from .deps import Vec2, Path
from .properties import Properties
class PolygonBuffer(Backend):
def __init__(self):
self.path = Path()
self.start_new_sub_polygon = False
def draw_polyline(self, properties: Properties, points: Sequence[Vec2]) -> None:
if len(points) == 0:
return
index = 0
if self.start_new_sub_polygon:
self.start_new_sub_polygon = False
count = len(points)
while self.path.end.isclose(points[index]):
index += 1
if index == count:
return
self.path.move_to(points[index])
for p in points[index + 1 :]:
self.path.line_to(p)
def draw_paths(
self, properties: Properties, paths: Sequence[Path], filled: bool
) -> None:
if filled:
raise NotImplementedError()
for p in paths:
if len(p) == 0:
continue
if self.start_new_sub_polygon:
self.start_new_sub_polygon = False
self.path.move_to(p.start)
self.path.append_path(p)
def get_paths(self) -> Sequence[Path]:
return list(self.path.sub_paths())
def close_path(self):
if len(self.path):
self.path.close_sub_path()
self.start_new_sub_polygon = True
def reset(self, location: Vec2) -> None:
self.path = Path(location)
self.start_new_sub_polygon = False
@@ -0,0 +1,175 @@
# Copyright (c) 2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
import dataclasses
import enum
import copy
from ezdxf.colors import RGB
class FillType(enum.IntEnum):
"""Fill type enumeration."""
NONE = 0
SOLID = 1
HATCHING = 2
CROSS_HATCHING = 3
SHADING = 4
class FillMethod(enum.IntEnum):
"""Fill method enumeration."""
EVEN_ODD = 0
NON_ZERO_WINDING = 1
RGB_NONE = RGB(-1, -1, -1)
RGB_BLACK = RGB(0, 0, 0)
RGB_WHITE = RGB(255, 255, 255)
LIGHT_GREY = RGB(200, 200, 200)
@dataclasses.dataclass
class Pen:
"""Represents a pen table entry."""
index: int
width: float # in mm
color: RGB
class Properties:
"""Consolidated display properties."""
DEFAULT_PEN = Pen(1, 0.35, RGB_NONE)
def __init__(self) -> None:
# hashed content
self.pen_index: int = 1
self.pen_color = RGB_NONE
self.pen_width: float = 0.35 # in mm
self.fill_type = FillType.SOLID
self.fill_method = FillMethod.EVEN_ODD
self.fill_hatch_line_angle: float = 0.0 # in degrees
self.fill_hatch_line_spacing: float = 40.0 # in plotter units
self.fill_shading_density: float = 100.0
# not hashed content
self.max_pen_count: int = 2
self.pen_table: dict[int, Pen] = {}
self.reset()
def hash(self) -> int:
return hash(
(
self.pen_index,
self.pen_color,
self.pen_width,
self.fill_type,
self.fill_method,
self.fill_hatch_line_angle,
self.fill_hatch_line_spacing,
self.fill_shading_density,
)
)
def copy(self) -> Properties:
# the pen table is shared across all copies of Properties
return copy.copy(self)
def setup_default_pen_table(self):
if len(self.pen_table):
return
pens = self.pen_table
width = self.DEFAULT_PEN.width
pens[0] = Pen(0, width, RGB(255, 255, 255)) # white
pens[1] = Pen(1, width, RGB(0, 0, 0)) # black
pens[2] = Pen(2, width, RGB(255, 0, 0)) # red
pens[3] = Pen(3, width, RGB(0, 255, 0)) # green
pens[4] = Pen(4, width, RGB(255, 255, 0)) # yellow
pens[5] = Pen(5, width, RGB(0, 0, 255)) # blue
pens[6] = Pen(6, width, RGB(255, 0, 255)) # magenta
pens[7] = Pen(6, width, RGB(0, 255, 255)) # cyan
def reset(self) -> None:
self.max_pen_count = 2
self.pen_index = self.DEFAULT_PEN.index
self.pen_color = self.DEFAULT_PEN.color
self.pen_width = self.DEFAULT_PEN.width
self.pen_table = {}
self.fill_type = FillType.SOLID
self.fill_method = FillMethod.EVEN_ODD
self.fill_hatch_line_angle = 0.0
self.fill_hatch_line_spacing = 40.0
self.fill_shading_density = 1.0
self.setup_default_pen_table()
def get_pen(self, index: int) -> Pen:
return self.pen_table.get(index, self.DEFAULT_PEN)
def set_max_pen_count(self, count: int) -> None:
self.max_pen_count = count
def set_current_pen(self, index: int) -> None:
self.pen_index = index
pen = self.get_pen(index)
self.pen_width = pen.width
self.pen_color = pen.color
def set_pen_width(self, index: int, width: float) -> None:
if index == -1:
self.pen_width = width
else:
pen = self.pen_table.setdefault(
index, Pen(index, width, self.DEFAULT_PEN.color)
)
pen.width = width
def set_pen_color(self, index: int, rgb: RGB) -> None:
if index == -1:
self.pen_color = rgb
else:
pen = self.pen_table.setdefault(
index, Pen(index, self.DEFAULT_PEN.width, rgb)
)
pen.color = rgb
def set_fill_type(self, fill_type: int, spacing: float, angle: float):
if fill_type == 3:
self.fill_type = FillType.HATCHING
self.fill_hatch_line_spacing = spacing
self.fill_hatch_line_angle = angle
elif fill_type == 4:
self.fill_type = FillType.CROSS_HATCHING
self.fill_hatch_line_spacing = spacing
self.fill_hatch_line_angle = angle
elif fill_type == 10:
self.fill_type = FillType.SHADING
self.fill_shading_density = spacing
else:
self.fill_type = FillType.SOLID
def set_fill_method(self, fill_method: int) -> None:
self.fill_method = FillMethod(fill_method)
def resolve_pen_color(self) -> RGB:
"""Returns the final RGB pen color."""
rgb = self.pen_color
if rgb is RGB_NONE:
pen = self.pen_table.get(self.pen_index, self.DEFAULT_PEN)
rgb = pen.color
if rgb is RGB_NONE:
return RGB_BLACK
return rgb
def resolve_fill_color(self) -> RGB:
"""Returns the final RGB fill color."""
ft = self.fill_type
if ft == FillType.SOLID:
return self.resolve_pen_color()
elif ft == FillType.SHADING:
grey = min(int(2.55 * (100.0 - self.fill_shading_density)), 255)
return RGB(grey, grey, grey)
elif ft == FillType.HATCHING or ft == FillType.CROSS_HATCHING:
return LIGHT_GREY
return RGB_WHITE
@@ -0,0 +1,224 @@
# Copyright (c) 2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import NamedTuple, Sequence
class Command(NamedTuple):
name: str
args: Sequence[bytes]
ESCAPE = 27
SEMICOLON = ord(";")
PERCENT = ord("%")
MINUS = ord("-")
QUOTE_CHAR = ord('"')
CHAR_A = ord("A")
CHAR_B = ord("B")
CHAR_Z = ord("Z")
DEFAULT_TEXT_TERMINATOR = 3
# Enter HPGL/2 mode commands
# b"%-0B", # ??? not documented (assumption)
# b"%-1B", # ??? not documented (really exist)
# b"%-2B", # ??? not documented (assumption)
# b"%-3B", # ??? not documented (assumption)
# b"%0B", # documented in the HPGL2 reference by HP
# b"%1B", # documented
# b"%2B", # documented
# b"%3B", # documented
def get_enter_hpgl2_mode_command_length(s: bytes, i: int) -> int:
try:
if s[i] != ESCAPE:
return 0
if s[i + 1] != PERCENT:
return 0
length = 4
if s[i + 2] == MINUS:
i += 1
length = 5
# 0, 1, 2 or 3 + "B"
if 47 < s[i + 2] < 52 and s[i + 3] == CHAR_B:
return length
except IndexError:
pass
return 0
KNOWN_START_SEQUENCES = [b"BPIN", b"BP;IN", b"INPS", b"IN;PS", b"INDF", b"IN;DF"]
def has_known_start_sequence(b: bytes) -> bool:
for start_sequence in KNOWN_START_SEQUENCES:
if b.startswith(start_sequence):
return True
return False
def find_hpgl2_entry_point(s: bytes, start: int) -> int:
while True:
try:
index = s.index(b"%", start)
except ValueError:
return len(s)
length = get_enter_hpgl2_mode_command_length(s, index)
if length:
return index + length
start += 2
def hpgl2_commands(s: bytes) -> list[Command]:
"""Low level plot file parser, extracts the HPGL/2 from the byte stream `b`.
.. Important::
This parser expects the "Enter HPGL/2 mode" escape sequence to recognize
HPGL/2 commands. The sequence looks like this: ``[ESC]%1B``, multiple variants
of this sequence are supported.
"""
text_terminator = DEFAULT_TEXT_TERMINATOR
def find_terminator(i: int) -> int:
while i < length:
c = s[i]
if c == QUOTE_CHAR:
i = find_mark(i + 1, QUOTE_CHAR)
elif (CHAR_A <= c <= CHAR_Z) or c == SEMICOLON or c == ESCAPE:
break
i += 1
return i
def find_mark(i: int, mark: int) -> int:
while i < length and s[i] != mark:
i += 1
return i + 1
def append_command(b: bytes) -> None:
if b[:2] == b"DT":
nonlocal text_terminator
if len(b) > 2:
text_terminator = b[2]
else:
text_terminator = DEFAULT_TEXT_TERMINATOR
else:
commands.append(make_command(b))
commands: list[Command] = []
length = len(s)
if has_known_start_sequence(s):
index = 0
else:
index = find_hpgl2_entry_point(s, 0)
while index < length:
char = s[index]
start = index
if char == ESCAPE:
# HPGL/2 does not use escape sequences, whatever this sequence is,
# HPGL/2 mode was left. Find next entry point into HPGL/2 mode:
index = find_hpgl2_entry_point(s, index)
continue
if char <= 32: # skip all white space and control chars between commands
index += 1
continue
index_plus_2 = index + 2
if index_plus_2 >= length:
append_command(s[index:])
break
command = s[start:index_plus_2]
if command == b"PE":
index = find_mark(index_plus_2, SEMICOLON)
index -= 1 # exclude terminator ";" from command args
elif command == b"LB":
index = find_mark(index_plus_2, text_terminator)
# include special terminator in command args,
# otherwise the parser is confused
else:
index = find_terminator(index_plus_2)
append_command(s[start:index])
if index < length and s[index] == SEMICOLON:
index += 1
return commands
def make_command(cmd: bytes) -> Command:
if not cmd:
return Command("NOOP", tuple())
name = cmd[:2].decode()
if name == "PE":
args = (bytes([c for c in cmd[2:] if c > 32]),)
else:
args = tuple(s for s in cmd[2:].split(b",")) # type: ignore
return Command(name, args)
def fractional_bits(decimal_places: int) -> int:
return round(decimal_places * 3.33)
def pe_encode(value: float, frac_bits: int = 0, base: int = 64) -> bytes:
if frac_bits:
value *= 1 << frac_bits
x = round(value)
else:
x = round(value)
if x >= 0:
x *= 2
else:
x = abs(x) * 2 + 1
chars = bytearray()
while x >= base:
x, r = divmod(x, base)
chars.append(63 + r)
if base == 64:
chars.append(191 + x)
else:
chars.append(95 + x)
return bytes(chars)
def pe_decode(
s: bytes, frac_bits: int = 0, base=64, start: int = 0
) -> tuple[list[float], int]:
def _decode():
factors.reverse()
x = 0
for f in factors:
x = x * base + f
factors.clear()
if x & 1:
x = -(x - 1)
x = x >> 1
return x
n = 1 << frac_bits
if base == 64:
terminator = 191
else:
terminator = 95
values: list[float] = []
factors = []
for index in range(start, len(s)):
value = s[index]
if value < 63:
return values, index
if value >= terminator:
factors.append(value - terminator)
x = _decode()
if frac_bits:
values.append(x / n)
else:
values.append(float(x))
else:
factors.append(value - 63)
return values, len(s)
@@ -0,0 +1,522 @@
# Copyright (c) 2023, Manfred Moitzi
# License: MIT License
from __future__ import annotations
from typing import Any, TYPE_CHECKING
import math
import os
import pathlib
from ezdxf.addons import xplayer
from ezdxf.addons.xqt import QtWidgets, QtGui, QtCore, QMessageBox
from ezdxf.addons.drawing import svg, layout, pymupdf, dxf
from ezdxf.addons.drawing.qtviewer import CADGraphicsView
from ezdxf.addons.drawing.pyqt import PyQtPlaybackBackend
from . import api
from .deps import BoundingBox2d, Matrix44, colors
if TYPE_CHECKING:
from ezdxf.document import Drawing
VIEWER_NAME = "HPGL/2 Viewer"
class HPGL2Widget(QtWidgets.QWidget):
def __init__(self, view: CADGraphicsView) -> None:
super().__init__()
layout = QtWidgets.QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(view)
self.setLayout(layout)
self._view = view
self._view.closing.connect(self.close)
self._player: api.Player = api.Player([], {})
self._reset_backend()
def _reset_backend(self) -> None:
self._backend = PyQtPlaybackBackend()
@property
def view(self) -> CADGraphicsView:
return self._view
@property
def player(self) -> api.Player:
return self._player.copy()
def plot(self, data: bytes) -> None:
self._reset_backend()
self._player = api.record_plotter_output(data, api.MergeControl.AUTO)
def replay(
self, bg_color="#ffffff", override=None, reset_view: bool = True
) -> None:
self._reset_backend()
self._view.begin_loading()
new_scene = QtWidgets.QGraphicsScene()
self._backend.set_scene(new_scene)
new_scene.addItem(self._bg_paper(bg_color))
xplayer.hpgl2_to_drawing(
self._player, self._backend, bg_color="", override=override
)
self._view.end_loading(new_scene)
self._view.buffer_scene_rect()
if reset_view:
self._view.fit_to_scene()
def _bg_paper(self, color):
bbox = self._player.bbox()
insert = bbox.extmin
size = bbox.size
rect = QtWidgets.QGraphicsRectItem(insert.x, insert.y, size.x, size.y)
rect.setBrush(QtGui.QBrush(QtGui.QColor(color)))
return rect
SPACING = 20
DEFAULT_DPI = 96
COLOR_SCHEMA = [
"Default",
"Black on White",
"White on Black",
"Monochrome Light",
"Monochrome Dark",
"Blueprint High Contrast",
"Blueprint Low Contrast",
]
class HPGL2Viewer(QtWidgets.QMainWindow):
def __init__(self) -> None:
super().__init__()
self._cad = HPGL2Widget(CADGraphicsView())
self._view = self._cad.view
self._player: api.Player = api.Player([], {})
self._bbox: BoundingBox2d = BoundingBox2d()
self._page_rotation = 0
self._color_scheme = 0
self._current_file = pathlib.Path()
self.page_size_label = QtWidgets.QLabel()
self.png_size_label = QtWidgets.QLabel()
self.message_label = QtWidgets.QLabel()
self.scaling_factor_line_edit = QtWidgets.QLineEdit("1")
self.dpi_line_edit = QtWidgets.QLineEdit(str(DEFAULT_DPI))
self.flip_x_check_box = QtWidgets.QCheckBox("Horizontal")
self.flip_x_check_box.setCheckState(QtCore.Qt.CheckState.Unchecked)
self.flip_x_check_box.stateChanged.connect(self.update_view)
self.flip_y_check_box = QtWidgets.QCheckBox("Vertical")
self.flip_y_check_box.setCheckState(QtCore.Qt.CheckState.Unchecked)
self.flip_y_check_box.stateChanged.connect(self.update_view)
self.rotation_combo_box = QtWidgets.QComboBox()
self.rotation_combo_box.addItems(["0", "90", "180", "270"])
self.rotation_combo_box.currentIndexChanged.connect(self.update_rotation)
self.color_combo_box = QtWidgets.QComboBox()
self.color_combo_box.addItems(COLOR_SCHEMA)
self.color_combo_box.currentIndexChanged.connect(self.update_colors)
self.aci_export_mode = QtWidgets.QCheckBox("ACI Export Mode")
self.aci_export_mode.setCheckState(QtCore.Qt.CheckState.Unchecked)
self.export_svg_button = QtWidgets.QPushButton("Export SVG")
self.export_svg_button.clicked.connect(self.export_svg)
self.export_png_button = QtWidgets.QPushButton("Export PNG")
self.export_png_button.clicked.connect(self.export_png)
self.export_pdf_button = QtWidgets.QPushButton("Export PDF")
self.export_pdf_button.clicked.connect(self.export_pdf)
self.export_dxf_button = QtWidgets.QPushButton("Export DXF")
self.export_dxf_button.clicked.connect(self.export_dxf)
self.disable_export_buttons(True)
self.scaling_factor_line_edit.editingFinished.connect(self.update_sidebar)
self.dpi_line_edit.editingFinished.connect(self.update_sidebar)
layout = QtWidgets.QHBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
container = QtWidgets.QWidget()
container.setLayout(layout)
self.setCentralWidget(container)
layout.addWidget(self._cad)
sidebar = self.make_sidebar()
layout.addWidget(sidebar)
self.setWindowTitle(VIEWER_NAME)
self.resize(1600, 900)
self.show()
def reset_values(self):
self.scaling_factor_line_edit.setText("1")
self.dpi_line_edit.setText(str(DEFAULT_DPI))
self.flip_x_check_box.setCheckState(QtCore.Qt.CheckState.Unchecked)
self.flip_y_check_box.setCheckState(QtCore.Qt.CheckState.Unchecked)
self.rotation_combo_box.setCurrentIndex(0)
self._page_rotation = 0
self.update_view()
def make_sidebar(self) -> QtWidgets.QWidget:
sidebar = QtWidgets.QWidget()
v_layout = QtWidgets.QVBoxLayout()
v_layout.setContentsMargins(SPACING // 2, 0, SPACING // 2, 0)
sidebar.setLayout(v_layout)
policy = QtWidgets.QSizePolicy()
policy.setHorizontalPolicy(QtWidgets.QSizePolicy.Policy.Fixed)
sidebar.setSizePolicy(policy)
open_button = QtWidgets.QPushButton("Open HPGL/2 File")
open_button.clicked.connect(self.select_plot_file)
v_layout.addWidget(open_button)
v_layout.addWidget(self.page_size_label)
h_layout = QtWidgets.QHBoxLayout()
h_layout.addWidget(QtWidgets.QLabel("Scaling Factor:"))
h_layout.addWidget(self.scaling_factor_line_edit)
v_layout.addLayout(h_layout)
h_layout = QtWidgets.QHBoxLayout()
h_layout.addWidget(QtWidgets.QLabel("Page Rotation:"))
h_layout.addWidget(self.rotation_combo_box)
v_layout.addLayout(h_layout)
group = QtWidgets.QGroupBox("Mirror Page")
h_layout = QtWidgets.QHBoxLayout()
h_layout.addWidget(self.flip_x_check_box)
h_layout.addWidget(self.flip_y_check_box)
group.setLayout(h_layout)
v_layout.addWidget(group)
h_layout = QtWidgets.QHBoxLayout()
h_layout.addWidget(QtWidgets.QLabel("Colors:"))
h_layout.addWidget(self.color_combo_box)
v_layout.addLayout(h_layout)
v_layout.addSpacing(SPACING)
h_layout = QtWidgets.QHBoxLayout()
h_layout.addWidget(QtWidgets.QLabel("DPI (PNG only):"))
h_layout.addWidget(self.dpi_line_edit)
v_layout.addLayout(h_layout)
v_layout.addWidget(self.png_size_label)
v_layout.addWidget(self.export_png_button)
v_layout.addWidget(self.export_svg_button)
v_layout.addWidget(self.export_pdf_button)
v_layout.addSpacing(SPACING)
v_layout.addWidget(self.aci_export_mode)
v_layout.addWidget(self.export_dxf_button)
v_layout.addSpacing(SPACING)
reset_button = QtWidgets.QPushButton("Reset")
reset_button.clicked.connect(self.reset_values)
v_layout.addWidget(reset_button)
v_layout.addSpacing(SPACING)
v_layout.addWidget(self.message_label)
return sidebar
def disable_export_buttons(self, disabled: bool):
self.export_svg_button.setDisabled(disabled)
self.export_dxf_button.setDisabled(disabled)
if pymupdf.is_pymupdf_installed:
self.export_png_button.setDisabled(disabled)
self.export_pdf_button.setDisabled(disabled)
else:
print("PDF/PNG export requires the PyMuPdf package!")
self.export_png_button.setDisabled(True)
self.export_pdf_button.setDisabled(True)
def load_plot_file(self, path: str | os.PathLike, force=False) -> None:
try:
with open(path, "rb") as fp:
data = fp.read()
if force:
data = b"%1B" + data
self.set_plot_data(data, path)
except IOError as e:
QtWidgets.QMessageBox.critical(self, "Loading Error", str(e))
def select_plot_file(self) -> None:
path, _ = QtWidgets.QFileDialog.getOpenFileName(
self,
dir=str(self._current_file.parent),
caption="Select HPGL/2 Plot File",
filter="Plot Files (*.plt)",
)
if path:
self.load_plot_file(path)
def set_plot_data(self, data: bytes, filename: str | os.PathLike) -> None:
try:
self._cad.plot(data)
except api.Hpgl2Error as e:
msg = f"Cannot plot HPGL/2 file '{filename}', {str(e)}"
QtWidgets.QMessageBox.critical(self, "Plot Error", msg)
return
self._player = self._cad.player
self._bbox = self._player.bbox()
self._current_file = pathlib.Path(filename)
self.update_colors(self._color_scheme)
self.update_sidebar()
self.setWindowTitle(f"{VIEWER_NAME} - " + str(filename))
self.disable_export_buttons(False)
def resizeEvent(self, event: QtGui.QResizeEvent) -> None:
self._view.fit_to_scene()
def get_scale_factor(self) -> float:
try:
return float(self.scaling_factor_line_edit.text())
except ValueError:
return 1.0
def get_dpi(self) -> int:
try:
return int(self.dpi_line_edit.text())
except ValueError:
return DEFAULT_DPI
def get_page_size(self) -> tuple[int, int]:
factor = self.get_scale_factor()
x = 0
y = 0
if self._bbox.has_data:
size = self._bbox.size
# 40 plot units = 1mm
x = round(size.x / 40 * factor)
y = round(size.y / 40 * factor)
if self._page_rotation in (90, 270):
x, y = y, x
return x, y
def get_pixel_size(self) -> tuple[int, int]:
dpi = self.get_dpi()
x, y = self.get_page_size()
return round(x / 25.4 * dpi), round(y / 25.4 * dpi)
def get_flip_x(self) -> bool:
return self.flip_x_check_box.checkState() == QtCore.Qt.CheckState.Checked
def get_flip_y(self) -> bool:
return self.flip_y_check_box.checkState() == QtCore.Qt.CheckState.Checked
def get_aci_export_mode(self) -> bool:
return self.aci_export_mode.checkState() == QtCore.Qt.CheckState.Checked
def update_sidebar(self):
x, y = self.get_page_size()
self.page_size_label.setText(f"Page Size: {x}x{y}mm")
px, py = self.get_pixel_size()
self.png_size_label.setText(f"PNG Size: {px}x{py}px")
self.clear_message()
def update_view(self):
self._view.setTransform(self.view_transformation())
self._view.fit_to_scene()
self.update_sidebar()
def update_rotation(self, index: int):
rotation = index * 90
if rotation != self._page_rotation:
self._page_rotation = rotation
self.update_view()
def update_colors(self, index: int):
self._color_scheme = index
self._cad.replay(*replay_properties(index))
self.update_view()
def view_transformation(self):
if self._page_rotation == 0:
m = Matrix44()
else:
m = Matrix44.z_rotate(math.radians(self._page_rotation))
sx = -1 if self.get_flip_x() else 1
# inverted y-axis
sy = 1 if self.get_flip_y() else -1
m @= Matrix44.scale(sx, sy, 1)
return QtGui.QTransform(*m.get_2d_transformation())
def show_message(self, msg: str) -> None:
self.message_label.setText(msg)
def clear_message(self) -> None:
self.message_label.setText("")
def get_export_name(self, suffix: str) -> str:
return str(self._current_file.with_suffix(suffix))
def export_svg(self) -> None:
path, _ = QtWidgets.QFileDialog.getSaveFileName(
self,
dir=self.get_export_name(".svg"),
caption="Save SVG File",
filter="SVG Files (*.svg)",
)
if not path:
return
try:
with open(path, "wt") as fp:
fp.write(self.make_svg_string())
self.show_message("SVG successfully exported")
except IOError as e:
QMessageBox.critical(self, "Export Error", str(e))
def get_export_matrix(self) -> Matrix44:
scale = self.get_scale_factor()
rotation = self._page_rotation
sx = -scale if self.get_flip_x() else scale
sy = -scale if self.get_flip_y() else scale
if rotation in (90, 270):
sx, sy = sy, sx
m = Matrix44.scale(sx, sy, 1)
if rotation:
m @= Matrix44.z_rotate(math.radians(rotation))
return m
def make_svg_string(self) -> str:
"""Replays the HPGL/2 recordings on the SVGBackend of the drawing add-on."""
player = self._player.copy()
player.transform(self.get_export_matrix())
size = player.bbox().size
svg_backend = svg.SVGBackend()
bg_color, override = replay_properties(self._color_scheme)
xplayer.hpgl2_to_drawing(
player, svg_backend, bg_color=bg_color, override=override
)
del player # free memory as soon as possible
# 40 plot units == 1mm
page = layout.Page(width=size.x / 40, height=size.y / 40)
return svg_backend.get_string(page)
def export_pdf(self) -> None:
path, _ = QtWidgets.QFileDialog.getSaveFileName(
self,
dir=self.get_export_name(".pdf"),
caption="Save PDF File",
filter="PDF Files (*.pdf)",
)
if not path:
return
try:
with open(path, "wb") as fp:
fp.write(self._pymupdf_export(fmt="pdf"))
self.show_message("PDF successfully exported")
except IOError as e:
QMessageBox.critical(self, "Export Error", str(e))
def export_png(self) -> None:
path, _ = QtWidgets.QFileDialog.getSaveFileName(
self,
dir=self.get_export_name(".png"),
caption="Save PNG File",
filter="PNG Files (*.png)",
)
if not path:
return
try:
with open(path, "wb") as fp:
fp.write(self._pymupdf_export(fmt="png"))
self.show_message("PNG successfully exported")
except IOError as e:
QMessageBox.critical(self, "Export Error", str(e))
def _pymupdf_export(self, fmt: str) -> bytes:
"""Replays the HPGL/2 recordings on the PyMuPdfBackend of the drawing add-on."""
player = self._player.copy()
player.transform(self.get_export_matrix())
size = player.bbox().size
pdf_backend = pymupdf.PyMuPdfBackend()
bg_color, override = replay_properties(self._color_scheme)
xplayer.hpgl2_to_drawing(
player, pdf_backend, bg_color=bg_color, override=override
)
del player # free memory as soon as possible
# 40 plot units == 1mm
page = layout.Page(width=size.x / 40, height=size.y / 40)
if fmt == "pdf":
return pdf_backend.get_pdf_bytes(page)
else:
return pdf_backend.get_pixmap_bytes(page, fmt=fmt, dpi=self.get_dpi())
def export_dxf(self) -> None:
path, _ = QtWidgets.QFileDialog.getSaveFileName(
self,
dir=self.get_export_name(".dxf"),
caption="Save DXF File",
filter="DXF Files (*.dxf)",
)
if not path:
return
doc = self._get_dxf_document()
try:
doc.saveas(path)
self.show_message("DXF successfully exported")
except IOError as e:
QMessageBox.critical(self, "Export Error", str(e))
def _get_dxf_document(self) -> Drawing:
import ezdxf
from ezdxf import zoom
color_mode = (
dxf.ColorMode.ACI if self.get_aci_export_mode() else dxf.ColorMode.RGB
)
doc = ezdxf.new()
msp = doc.modelspace()
player = self._player.copy()
bbox = player.bbox()
m = self.get_export_matrix()
corners = m.fast_2d_transform(bbox.rect_vertices())
# move content to origin:
tx, ty = BoundingBox2d(corners).extmin
m @= Matrix44.translate(-tx, -ty, 0)
player.transform(m)
bbox = player.bbox()
dxf_backend = dxf.DXFBackend(msp, color_mode=color_mode)
bg_color, override = replay_properties(self._color_scheme)
if color_mode == dxf.ColorMode.RGB:
doc.layers.add("BACKGROUND")
bg = dxf.add_background(msp, bbox, colors.RGB.from_hex(bg_color))
bg.dxf.layer = "BACKGROUND"
# exports the HPGL/2 content in plot units (plu) as modelspace:
# 1 plu = 0.025mm or 40 plu == 1mm
xplayer.hpgl2_to_drawing(
player, dxf_backend, bg_color=bg_color, override=override
)
del player
if bbox.has_data: # non-empty page
zoom.window(msp, bbox.extmin, bbox.extmax)
dxf.update_extents(doc, bbox)
# paperspace is set up in mm:
dxf.setup_paperspace(doc, bbox)
return doc
def replay_properties(index: int) -> tuple[str, Any]:
bg_color, override = "#ffffff", None # default
if index == 1: # black on white
bg_color, override = "#ffffff", xplayer.map_color("#000000")
elif index == 2: # white on black
bg_color, override = "#000000", xplayer.map_color("#ffffff")
elif index == 3: # monochrome light
bg_color, override = "#ffffff", xplayer.map_monochrome(dark_mode=False)
elif index == 4: # monochrome dark
bg_color, override = "#000000", xplayer.map_monochrome(dark_mode=True)
elif index == 5: # blueprint high contrast
bg_color, override = "#192c64", xplayer.map_color("#e9ebf3")
elif index == 6: # blueprint low contrast
bg_color, override = "#243f8f", xplayer.map_color("#bdc5dd")
return bg_color, override