Files
stepanalyser/.venv/lib/python3.12/site-packages/ezdxf/addons/hpgl2/api.py
Christian Anetzberger a197de9456 initial
2026-01-22 20:23:51 +01:00

399 lines
13 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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