#!/usr/bin/env python # coding=utf-8 # # Copyright (C) 2022 Jonathan Neuhauser (jonathan.neuhauser@outlook.com) # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # """HPGL Input state machine""" import math from typing import List, Optional, Tuple, Dict, Union from pyparsing import ParseResults import inkex class ListWithCallback(list): """A list that modifies elements with a callback before appending them""" def __init__(self, callback): super().__init__() self.callback = callback def append(self, item): super().append(self.callback(item)) PathData = ListWithCallback class HPGLStateMachine: """This a HPGL plotter""" def __init__(self, root: inkex.Layer, options): self.options = options self.x: float self.y: float self.pendown: bool self.current_path: PathData self.current_parent: inkex.Layer self.absolute_mode: bool self.root = root self.polygon_mode = False self.polygon_buffer: List[PathData] = [] # Start positions of polygon modes self.startx = 0 self.starty = 0 self.transform_manager = TransformManager(options) self.style_manager = StyleManager(options) self.initialize() def create_group(self): """Manage group""" # We'll set transforms; if the current layer is empty, don't create a new one self.finalize_path() if hasattr(self, "current_parent") and len(self.current_parent) == 0: group = self.current_parent else: group = inkex.Group() self.root.add(group) self.current_parent = group group.transform, clip = self.transform_manager.get_transform_and_clip( self.root.transform ) if clip is not None: self.root.root.defs.append(clip) group.clip = clip self.initialize_current_path() self.current_path.append(inkex.paths.Move(self.x, self.y)) def df_command(self): """default values""" self.style_manager.default_values() self.transform_manager.default_values() self.polygon_buffer = [] self.initialize_current_path() self.create_group() # in case scaling was turned off def initialize(self): """Reset state to default""" self.transform_manager.initialize() self.style_manager.initialize() self.x = 0 self.y = 0 self.pendown = False self.absolute_mode = True self.df_command() self.create_group() def initialize_current_path(self): """Initialize the current path with a custom append method""" def callback(new_elem): # we store the start position additionally to the path element itself result = [new_elem, self.pendown, self.x, self.y] if new_elem.is_absolute: self.x, self.y = new_elem.args[-2:] else: self.x, self.y = self.x + new_elem.args[-2], self.y + new_elem.args[-1] return result self.current_path = ListWithCallback(callback) def append_path_data(self, cmd, x, y): """Given lists of coordinates x, y, adds those to the path with command cmd, and sets the pen's position as the last of those""" def _line_command(self, key, xvals=None, yvals=None): if key in ["PA", "PR"]: self.absolute_mode = key == "PA" if key in ["PD", "PU"]: self.pendown = key == "PD" cmd = inkex.paths.Line if self.absolute_mode else inkex.paths.line if xvals is not None and yvals is not None: for xi, yi in zip(xvals, yvals): self.current_path.append(cmd(xi, yi)) def line_command(self, vals: ParseResults): """Handle pu, pd, pa, pr""" vals_dict = vals.as_dict() if "X" in vals_dict and "Y" in vals_dict: self._line_command(vals_dict["key"], vals_dict["X"], vals_dict["Y"]) else: self._line_command(vals_dict["key"]) @staticmethod def create_path(data: List[PathData], fill=False, pmode=False) -> inkex.Path: """Create a path out of the stored points""" result = inkex.Path() for subpath in data: for j, (cmd, pendown, xstart, ystart) in enumerate(subpath): # For simplicity, commands are converted to absolute in polygon mode drawn_before = any(subpath[k][1] for k in range(j)) if pmode: cmd = cmd.to_absolute(inkex.Vector2d(xstart, ystart)) if not fill and pendown and not subpath[j - 1][1] and drawn_before: # When edging a polygon, a line is drawn to "skip" (possibly # subsequent) pen-up commands (but # only if there already was a drawn command in this subpolygon) result.append(inkex.paths.Line(xstart, ystart)) # Don't draw commands when pen was up if not (not pendown and pmode and not fill and drawn_before): # Also the first command of each subpath in polygon mode is # converted: "When you use (PM1), the point after (PM1) becomes the # first point of the next subpolygon. This move is not used as a # boundary when filling a polygon with FP." if (j == 0 and pmode) or (not pmode and not pendown): repl = inkex.paths.move if cmd.is_relative else inkex.paths.Move result.append(repl(*cmd.args[-2:])) else: result.append(cmd) if pmode and j == len(subpath) - 1: result.append(inkex.paths.ZoneClose()) # Ensure that the first path command is a moveto if result and result[0].letter.lower() != "m": result.insert(0, inkex.paths.move(0, 0)) # Remove unnecessary moveto in the beginning of a path for i, cmd in enumerate(result.proxy_iterator()): if cmd.command.letter.lower() != "m": result = inkex.Path([inkex.paths.Move(*cmd.first_point)] + result[i:]) break return result def finalize_path(self) -> None: """Append the current path to the parent if it's effectively non-empty""" result = self.create_path([self.current_path], False, False) if self.options.break_apart: result_list = result.break_apart() else: result_list = [result] for res in result_list: if any(i.letter not in "mM" for i in res): if self.current_path not in self.polygon_buffer: pel = inkex.PathElement() pel.path = res self.current_parent.add(pel) self.style_manager.set_stroke( pel, p1=self.transform_manager.p1, p2=self.transform_manager.p2 ) self.initialize_current_path() self.current_path.append(inkex.paths.Move(self.x, self.y)) def ci_command(self, vals: ParseResults): """Draws a circle using radius. Chord_angle is ignored. Includes an automatic PD; restores the old pen state and returns to the center position afterwards.""" vals_dict = vals.as_dict() r = vals_dict["Radius"] pendown = self.pendown # Circles are implicit subpolygons in polygon mode self._pm_command(1) self.pendown = False self.current_path.append(inkex.paths.move(-r, 0)) self.pendown = True # sweep flag = 1 for counterclockwise rotation when y axis points upwards, # but this is honestly a best guess from an ambiguous specification self.current_path.append(inkex.paths.arc(r, r, 0, 1, 1, 2 * r, 0)) self.current_path.append(inkex.paths.arc(r, r, 0, 1, 1, -2 * r, 0)) self.pendown = False self.current_path.append(inkex.paths.move(r, 0)) # Restore pendown self.pendown = pendown # and terminate subpolygon self._pm_command(1) def arc_command(self, vals: ParseResults): """Draws an absolute/ relative arc""" vals_dict = vals.as_dict() absolute = vals_dict["key"] == "AA" c_x, c_y = vals_dict["X"][0], vals_dict["Y"][0] sweep = vals_dict["sweep"] * math.pi / 180 # Transform start point about center by sweep radius if not absolute: c_x, c_y = c_x + self.x, c_y + self.y dx, dy = (self.x - c_x), (self.y - c_y) endpoint = ( dx * math.cos(sweep) - dy * math.sin(sweep) + c_x, dx * math.sin(sweep) + dy * math.cos(sweep) + c_y, ) radius = math.sqrt(dx**2 + dy**2) # Determine SVG arc flags sweep_flag = 1 if sweep > 0 else 0 large_arc = 1 if abs(sweep) > math.pi else 0 res: inkex.paths.PathCommand = inkex.paths.Arc( radius, radius, 0, large_arc, sweep_flag, *endpoint ) if not absolute: res = res.to_relative(inkex.Vector2d(self.x, self.y)) self.current_path.append(res) def at_command(self, vals: ParseResults): """Draws an Absolute Three Point arc""" valsd = vals.as_dict() # Convert the three points into the complex plane # Idea: http://www.math.okstate.edu/~wrightd/INDRA/MobiusonCircles/node4.html x, y, z = [ i + 1j * j for i, j in zip([self.x] + valsd["X"], [self.y] + valsd["Y"]) ] res: Optional[inkex.paths.PathCommand] = None w = (z - x) / (y - x) if abs(w.imag) > 1e-12: c = -((x - y) * (w - abs(w) ** 2) / (2j * w.imag) - x) r = abs(c - x) # Now determine the arc flags by checking the angles deltas = [x - c, y - c, z - c] ang = [math.atan2(i.imag, i.real) for i in deltas] # Sweep flag is set if the three values are "in order" sweep = int(any(ang[0 + i] < ang[-2 + i] < ang[-1 + i] for i in range(3))) large_arc = 1 - int( ang[2] - ang[0] > math.pi or -math.pi < ang[2] - ang[0] < 0 ) large_arc = 1 - large_arc if sweep else large_arc res = inkex.paths.Arc(r, r, 0, large_arc, sweep, z.real, z.imag) else: # Points lie on a line # y between x and z -> draw a line, otherwise skip if x.real <= y.real <= z.real or x.real >= y.real >= z.real: res = inkex.paths.Line(z.real, z.imag) else: res = inkex.paths.Move(z.real, z.imag) self.current_path.append(res) def bezier_command(self, vals: ParseResults): """Draws an absolute/relative bezier, possibly multiple""" absolute = vals["key"] == "BZ" vals = vals.as_dict()["B"] cmd = inkex.paths.Curve if absolute else inkex.paths.curve for group in vals: self.current_path.append(cmd(*group)) def polyline_encoded(self, vals: ParseResults): """Parse Polyline Encoded""" data = vals["data"].encode("latin-1") absolute_mode = self.absolute_mode self.absolute_mode = False PolylineEncodedParser(self).polyline_encoded(data) self.absolute_mode = absolute_mode def pm_command(self, vals: ParseResults): """Entering / exiting polygon mode""" val = vals["value"] self._pm_command(int(val)) def _pm_command(self, val: int): """Entering / exiting polygon mode""" if val == 0: self.polygon_mode = True self.finalize_path() # Link polygon buffer and current path. self.polygon_buffer = [self.current_path] if val > 0 and self.polygon_mode: # Move to the end point of the current polygon buffer if val == 2: self.polygon_mode = False if len(self.current_path) < 2: return self.initialize_current_path() if val == 1: self.polygon_buffer += [self.current_path] def ft_command(self, vals: ParseResults): """Fill type""" self.style_manager.ft_command(vals.as_dict()) def _get_function(self, vals: ParseResults, obj): return getattr(obj, f"{vals[0][0].lower()}_command") def style_command(self, vals: ParseResults): """Handle all commands in the style groups, they are deferred to the StyleManager""" func = self._get_function(vals, self.style_manager) # For commands that take effect immediately, finalize the path. This is # managed by a decorator to keep all information in one place if hasattr(func, "clear_path"): self.finalize_path() func(vals[0].as_dict()) def transform_command(self, vals: ParseResults): """Handle all commands that potentially create a new parent group""" self.finalize_path() func = self._get_function(vals, self.transform_manager) func(vals[0].as_dict()) self.create_group() def edge_fill_polygon(self, vals: ParseResults): """Fill or edge polygon""" if self.polygon_mode: return # command illegal in polygon mode pel = inkex.PathElement() pel.path = self.create_path(self.polygon_buffer, vals["key"] == "FP", True) self.current_parent.add(pel) if vals["key"] == "FP": self.style_manager.set_fill(pel) if vals["fillmode"] == "0": pel.style["fill-rule"] = "evenodd" else: pel.style["fill-rule"] = "nonzero" else: # Draw polygon edges pel.style["fill"] = "none" self.style_manager.set_stroke( pel, p1=self.transform_manager.p1, p2=self.transform_manager.p2 ) def rectangle_wedge(self, element, stroke, fill): """Common functionality of rectangles and wedges""" # Finish the current path self.finalize_path() self.current_parent.append(element) if stroke: element.style["fill"] = "none" self.style_manager.set_stroke( element, p1=self.transform_manager.p1, p2=self.transform_manager.p2 ) if fill: self.style_manager.set_fill(element) # This command does not change the current pen position and up/down state, # but it uses the polygon buffer internally. In case the buffer will be reused, # push the path to the polygon buffer pdata = inkex.Path(element.get_path()).to_absolute() # Omit closing Z since polygon buffer does this automatically self.polygon_buffer = [ [ # type: ignore [i.command, i.command.letter != "M"] + list(i.first_point) for i in pdata.proxy_iterator() if i.command.letter.lower() != "z" ] ] def rectangle_command(self, vals: ParseResults): """Handle ER, EA, RA, RR""" command = vals["key"] x = vals["X"][0] y = vals["Y"][0] if command[1] == "R": x += self.x y += self.y rect = inkex.Rectangle.new( min(self.x, x), min(self.y, y), abs(self.x - x), abs(self.y - y) ).to_path_element() self.rectangle_wedge(rect, command[0] == "E", command[0] == "R") def wedge_command(self, vals: ParseResults): """Handle EW and WG commands""" command = vals["key"] radius = vals["radius"] start_angle = vals["start_angle"] + (0 if radius > 0 else 180) arc = inkex.PathElement.arc( [self.x, self.y], abs(vals["radius"]), arctype="slice", start=(start_angle % 360) * math.pi / 180, end=((start_angle + vals["sweep"]) % 360) * math.pi / 180, ) self.rectangle_wedge(arc, command[0] == "E", command[0] == "W") def clear_path(fun): """A decorator that tells the parent state machine that the path should be cleared before executing this method""" def wrapped(*args, **kwargs): return fun(*args, **kwargs) wrapped.clear_path = True # type: ignore return wrapped class StyleManager: """Abstraction layer to store the state from all commands affecting style (stroke and fill) Currently not implemented: - Fill types (patterns) except solid fill - fill anchor (AC) - raster fill (RF) - screened vectors (SV) - Symbol mode (SM) - carrying over residue of dasharray - adaptive line patterns (negative line indices) - triangle line cap - no-join (overlap) line join """ def __init__(self, options) -> None: self.current_style = inkex.Style() self.fill_type = "solid" self.options = options self.width_units_relative: bool self.pen_width: float self.linetype_manager: LineTypeManager self.transparency: bool def initialize(self): """IN commands that affect the StyleManager""" self.width_units_relative = False self._reset_pen_width() self.sp_command({"pen": 0}) def default_values(self): """DF commands that affect the StyleManager""" self.current_style["stroke-linecap"] = "butt" self.current_style["stroke-linejoin"] = "miter" self.current_style["stroke-miterlimit"] = 5 self.fill_type = "solid" self.linetype_manager = LineTypeManager(None, True, 0.04) self.transparency = True @clear_path def sp_command(self, vals: Dict): """Select pen""" penmap = { 0: "White", 1: "Black", 2: "Red", 3: "Green", 4: "Yellow", 5: "Blue", 6: "Magenta", 7: "Cyan", } self.current_style["stroke"] = inkex.Color(penmap[vals.get("pen", 0)].lower()) def ft_command(self, vals: Dict): """Fill type""" typ = int(vals.get("type", 1)) if typ < 3: self.fill_type = "solid" @clear_path def la_command(self, vals: Dict): """Set line attributes (line ends, line joins)""" for k, value in zip(vals["kind"], vals["value"]): kind = int(k) # Triangle line ends / line joins are not supported if kind == 1: dct = {1: "butt", 2: "square", 3: "round", 4: "round"} self.current_style["stroke-linecap"] = dct[value] if kind == 2: # 6: no join is not supported; would require splitting the paths # could be done by inserting "m 0,0" after every command dct = {1: "miter", 2: "miter", 3: "round", 4: "round", 5: "bevel"} self.current_style["stroke-linejoin"] = dct.get(value, "miter") if kind == 3: self.current_style["stroke-miterlimit"] = value @clear_path def lt_command(self, vals: Dict): """Set line type""" self.linetype_manager.lt_command(vals) def _reset_pen_width(self): self.pen_width = 0.35 if not self.width_units_relative else 0.001 @clear_path def pw_command(self, vals: Dict): """Set pen width""" if "width" not in vals: self._reset_pen_width() else: self.pen_width = vals["width"] def tr_command(self, vals: Dict): """Set transparency mode""" self.transparency = vals.get("transparency", "1") == "1" @clear_path def ul_command(self, vals: Dict): """Set user defined line type""" self.linetype_manager.ul_command(vals) def wu_command(self, vals: Dict): """Set pen width units""" self.width_units_relative = vals.get("units", "0") == "1" self._reset_pen_width() def p1_p2_mm( self, pt1: Union[Tuple[float, float], inkex.Vector2d], pt2: Union[Tuple[float, float], inkex.Vector2d], ): """Compute distance between p1 and p2 (provided in kwargs), and convert to mm""" return ( math.sqrt((pt1[1] - pt2[1]) ** 2 + (pt1[0] - pt2[0]) ** 2) / 40 * 1016 / self.options.resolution ) def set_stroke( self, element: inkex.PathElement, p1: Union[Tuple[float, float], inkex.Vector2d], p2: Union[Tuple[float, float], inkex.Vector2d], ): """Set stroke on an element based on the current stroke settings""" element.style["fill"] = None for i in ["dasharray", "linejoin", "miterlimit", "linecap"]: element.style[f"stroke-{i}"] = self.current_style(f"stroke-{i}") element.style["stroke"] = self.current_style("stroke") if self.transparency and self.current_style("stroke") == inkex.Color("white"): element.style["stroke-opacity"] = 0 # Stroke width width = self.pen_width * ( self.p1_p2_mm(p1, p2) if self.width_units_relative else 1 ) element.style["stroke-width"] = width # Apply stroke pattern self.linetype_manager.apply_to_path(element, self.p1_p2_mm(p1, p2)) def set_fill(self, element): """Set stroke on an element based on the current fill settings. Currently, only solid fill is supported""" # Fill polygon if self.fill_type == "solid": element.style["fill"] = self.current_style("stroke") if self.transparency and self.current_style("stroke") == inkex.Color( "white" ): element.style["fill-opacity"] = 0 class LineTypeManager: """Wrapper for the information contained in the LT and UL command""" default_linetypes: List[List[Union[float, int]]] = [ [0, 100], [50, 50], [70, 30], [80, 10, 0, 10], [70, 10, 10, 10], [50, 10, 10, 10, 10, 10], [70, 10, 0, 10, 0, 10], [50, 10, 0, 10, 10, 10, 0, 10], ] def __init__( self, index: Optional[int], unit_relative: bool, pattern_length: float ) -> None: # type, relative length, pattern length self.index = index self.unit_relative = unit_relative self.pattern_length = pattern_length self.linetypes = self.default_linetypes.copy() def lt_command(self, vals: Dict): """Set line type""" if len(vals) == 0: # no parameters, reset to solid lines self.index = None if "linetype" in vals: ltp = max(min(vals["linetype"], 8), 0) self.index = ltp if "mode" in vals: self.unit_relative = vals["mode"] == 0 if "pattern_length" in vals: self.pattern_length = max(0, vals["pattern_length"]) def ul_command(self, vals: Dict): """Set user defined line type""" if "index" not in vals: # Reset all line types: self.linetypes = self.default_linetypes.copy() return i = int(vals["index"]) if i < 1 or i > 8: return if "gap" not in vals: # Reset current line type self.linetypes[i - 1] = self.default_linetypes[i - 1] return gaps: List[float] = vals["gap"] if any(gap < 0 for gap in gaps) or sum(gaps) <= 0: return # invalid self.linetypes[i - 1] = [gap / sum(gaps) * 100 for gap in gaps] def apply_to_path(self, element: inkex.PathElement, p1p2_dist): """Apply the line style to a path element.""" if self.index is not None: # None -> solid line pattern_length = self.pattern_length * ( p1p2_dist / 100 if self.unit_relative else 1 ) if self.index > 0: element.style["stroke-dasharray"] = " ".join( [ str(dashdot / 100.0 * pattern_length) for dashdot in self.linetypes[self.index - 1] ] ) elif self.index == 0: # Only keep the end points of the path. new_path = inkex.Path() path_data = list(element.path.proxy_iterator()) for i, item in enumerate(path_data): if item.command.letter.lower() == "m": if path_data[i + 1].command.letter.lower() != "m": new_path.append(inkex.paths.Move(*(item.end_point))) new_path.append(inkex.paths.line(0.0001, 0)) else: # Workaround https://gitlab.com/inkscape/inkscape/-/issues/894 new_path.append(inkex.paths.Move(*(item.end_point))) new_path.append(inkex.paths.line(0.0001, 0)) element.path = new_path # Negative line types would have to be implemented in a similar way class TransformManager: """Abstraction layer to handle all commands that affect transform of the current layer, in particular IP, IR, SC, IW, RO""" def __init__(self, options) -> None: self.scale_manager = ScaleManager() self.clip_manager = ClipManager() self.p1: inkex.Vector2d self.p2: inkex.Vector2d self.ro_angle: int = 0 self.options = options self.ir_command = self.ip_command # Alias self.initialize() def initialize(self): """IN commands that affect the TransformManager""" self.default_values() self.p1 = inkex.Vector2d((0, 0)) self.p2 = inkex.Vector2d( ( self.options.width * self.options.resolution, self.options.height * self.options.resolution, ) ) self.ro_angle = 0 def default_values(self): """DF commands that affect the TransformManager""" self.scale_manager = ScaleManager(None) def get_transform_and_clip( self, root_transform: inkex.Transform ) -> Tuple[inkex.Transform, Optional[inkex.ClipPath]]: """Return the current effective transform and clip""" transform = self.get_rotation_transform() @ self.scale_manager.get_transform( self.p1, self.p2 ) clip = self.clip_manager.get_current_clip( root_transform, transform, scaled=self.scale_manager.enabled, bake=self.options.bake_transforms, ) return transform, clip def ip_command(self, vals: Dict): """Set the scaling points p1 and p2""" key = vals["key"] x = [float(i) for i in vals["X"]] y = [float(i) for i in vals["Y"]] if key == "IR": x = [i * self.options.width * self.options.resolution / 100 for i in x] y = [i * self.options.height * self.options.resolution / 100 for i in y] if len(x) == 1: # P2 tracks P1 x = [x[0], self.p2[0] - self.p1[0] + x[0]] y = [y[0], self.p2[1] - self.p1[1] + y[0]] for i in x, y: if i[0] == i[1]: # avoid scaling into infinity i[1] += 1 self.p1 = inkex.Vector2d(x[0], y[0]) self.p2 = inkex.Vector2d(x[1], y[1]) def sc_command(self, vals: Dict): """Scale command""" self.clip_manager.pin_to_plu() self.scale_manager = ScaleManager(vals) def get_rotation_transform(self) -> inkex.Transform: """Compute the effective transform caused by the RO command. Does not currently handle a following IP/IR without arguments.""" rotation = inkex.Transform().add_rotate(self.ro_angle, self.p1) translation = inkex.Transform() if self.ro_angle == 90: # Shift P1 into the bottom right corner translation.add_translate(self.p2[0] - self.p1[0], 0) elif self.ro_angle == 180: # Shift P1 into the top right corner translation.add_translate(self.p2[0] - self.p1[0], self.p2[1] - self.p1[1]) elif self.ro_angle == 270: # Shift P1 into the top left corner translation.add_translate(0, self.p2[1] - self.p1[1]) return translation @ rotation def ro_command(self, vals: Dict): """Handle canvas rotation""" self.ro_angle = int(vals.get("angle", 0)) def iw_command(self, vals: Dict): """Handle Soft Clipping""" self.clip_manager = ClipManager(vals.get("X", None), vals.get("Y", None)) class ScaleManager: """Helper class for the SC command""" def __init__(self, vals: Optional[Dict] = None): if vals is None: vals = {} self.vals = vals @property def enabled(self): """Determine if scaling is switched on""" return "Xmin" in self.vals def get_transform(self, p1, p2) -> inkex.Transform: """Return the transform from plotter units to user units, using the scaling points p1 and p2""" if not self.enabled: return inkex.Transform() xmin = self.vals["Xmin"] xmax = self.vals["Xmax"] ymin = self.vals["Ymin"] ymax = self.vals["Ymax"] # default not clear from spec? prob anisotropic type_id = self.vals.get("type", "0") if type_id != "2": if type_id == "0": pmin = [xmin, ymin] pmax = [xmax, ymax] elif type_id == "1": scale_y = abs(p2[1] - p1[1]) / abs(ymax - ymin) scale_x = abs(p2[0] - p1[0]) / abs(xmax - xmin) scale = min(scale_x, scale_y) if scale == scale_y: # Space left / right space = abs(p2[0] - p1[0]) - scale * abs(xmax - xmin) left = (self.vals.get("left", 50) / 100 * space) / scale right = ((100 - self.vals.get("left", 50)) / 100 * space) / scale pmin = [xmin - left, ymin] pmax = [xmax + right, ymax] if scale == scale_x: # Space left / right space = abs(p2[1] - p1[1]) - scale * abs(ymax - ymin) bottom = (self.vals.get("bottom", 50) / 100 * space) / scale top = ((100 - self.vals.get("bottom", 50)) / 100 * space) / scale pmin = [xmin, ymin - bottom] pmax = [xmax, ymax + top] # We need to map the points (xmin, ymin), (xmax, ymax)) # in the layer coordinate system to (P1), (P2) in plotter units, scale = [(p1[i] - p2[i]) / (pmin[i] - pmax[i]) for i in range(2)] offset = [p1[i] - scale[i] * pmin[i] for i in range(2)] elif type_id == "2": pmin = [xmin, ymin] scale = [xmax, ymax] offset = [p1[0] - scale[0] * xmin, p1[1] - scale[1] * ymin] return inkex.Transform(scale=tuple(scale), translate=tuple(offset)) class ClipManager: """Helper class for the IW command""" def __init__( self, x: Optional[Tuple[float, float]] = None, y: Optional[Tuple[float, float]] = None, ) -> None: self.x = x self.y = y self.storedx: Optional[Tuple[float, float]] = None self.storedy: Optional[Tuple[float, float]] = None self.pinned = False def get_current_clip( self, svg_to_plu: inkex.Transform, plu_to_uu: inkex.Transform, scaled=False, bake=False, ) -> Optional[inkex.ClipPath]: """Get the current clip path based on current transforms and scaling info""" if self.x is None or self.y is None: return None if not (self.x[1] >= self.x[0] and self.y[1] >= self.y[0]): inkex.errormsg("Bad clipping specification, will be ignored") return None if self.pinned: transform = svg_to_plu else: transform = svg_to_plu @ plu_to_uu if not bake: transform = -(svg_to_plu @ plu_to_uu) @ transform rect = inkex.Rectangle.new( self.x[0], self.y[0], self.x[1] - self.x[0], self.y[1] - self.y[0] ) aspath = inkex.Path(rect.get_path()) if scaled: # Store positions of the rectangle in plotter units bbox = aspath.transform(plu_to_uu).bounding_box() self.storedx = (float(bbox.left), float(bbox.right)) self.storedy = (float(bbox.top), float(bbox.bottom)) result = inkex.ClipPath() result.add(inkex.PathElement.new(path=aspath.transform(transform))) return result def pin_to_plu(self): """Pin position of the clip in plotter units""" self.x = self.storedx self.y = self.storedy self.pinned = True class PolylineEncodedParser: """Helper class to parse the PE command""" def __init__(self, parent: HPGLStateMachine): self.parent = parent @staticmethod def _decode_value(value, fractional_bits: Optional[int] = None, base32_mode=False): """Helper function to decode a HPGL-encoded number""" # First remove all irrelevant values value = "".join( chr(i) for i in value if not (i < 63 or 128 <= i <= 190 or i == 255) ).encode("latin-1") result = 0 for i, val in enumerate(value): if base32_mode and val > 127: val -= 128 offset = 63 if i == len(value) - 1: if base32_mode: offset = 95 else: offset = 191 result += (val - offset) << (5 if base32_mode else 6) * i # Only perform these steps if fractional data (i.e. coordinates) # are to be encoded if fractional_bits is not None: # Set sign result = result // 2 * (-1 if result % 2 == 1 else 1) # Fractional bits result = result / (2**fractional_bits) return result @staticmethod def _get_next_value( data, start_with, fractional_bits: Optional[int] = None, base32_mode=False ): """Helper function to find and then decode a HPGL encoded number""" i = start_with while i < len(data): if chr(data[i]) in ":><=7": # can happen if there is no data # (specification is contradictory if this is allowed or not) i -= 1 break if base32_mode: if data[i] % 128 >= 95: break else: if data[i] >= 191: break i += 1 return i + 1, PolylineEncodedParser._decode_value( data[start_with : i + 1], fractional_bits, base32_mode ) def polyline_encoded(self, data): """Parse a binary-encoded polyline""" index = 0 base32_mode = False fractional_bits = 0 while index < len(data): flag = chr(data[index]) if base32_mode: flag = chr(ord(flag) % 128) # remove highest bit if flag == "7": base32_mode = True index += 1 elif flag in ">:": index, result = PolylineEncodedParser._get_next_value( data, index + 1, None, base32_mode ) if flag == ":": # Select pen self.parent.style_manager.sp_command({"pen": result}) else: # Select the number of fractional bits fractional_bits = result else: start_index = index + (1 if flag in "<=" else 0) index, first = PolylineEncodedParser._get_next_value( data, start_index, fractional_bits, base32_mode ) index, second = PolylineEncodedParser._get_next_value( data, index, fractional_bits, base32_mode ) cmd = "PU" if flag == "<" else ("PA" if flag == "=" else "PR") # pylint: disable=protected-access if start_index != index: self.parent._line_command(cmd, [first], [second]) # If coordinates were found, send a pen-down command if necessary if not self.parent.pendown: self.parent._line_command("PD") else: self.parent._line_command(cmd)