#!/usr/bin/env python3 # coding=utf-8 # # Copyright (c) 2024 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., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA # """ Convert CGM files to SVG """ from dataclasses import dataclass import inspect from inkex.utils import pairwise import math from typing import Callable, Dict, List, Optional, Tuple, get_type_hints, Union import inkex import cgm_parse import cgm_enums class CGMConverterMeta(type): """Metaclass so that we can automatically call the methods based on their type hint""" def __new__(cls, name, bases, class_dict): new_class = super().__new__(cls, name, bases, class_dict) call_mapping = {} for name, method in inspect.getmembers(new_class, predicate=inspect.isfunction): hints = get_type_hints(method) if name.startswith("on_") and len(hints) == 1: type_ = list(hints.values())[0] call_mapping[type_] = method new_class.call_mapping = call_mapping return new_class @dataclass class FillAttributes: fill: cgm_enums.Colour interior_style: cgm_enums.InteriorStyleEnum @dataclass class LineAttributes: specification_mode: cgm_enums.WidthSpecificationModeEnum width: float colour: cgm_enums.Colour type: cgm_enums.LineTypeEnum visibility: cgm_enums.EdgeVisibilityEnum @dataclass class TextAttributes: colour: cgm_enums.Colour character_height: Optional[float] fallback_character_height: Optional[float] horizontal_alignment: cgm_enums.TextHorizontalAlignmentEnum vertical_alignment: cgm_enums.TextVerticalAlignmentEnum x_character_up: Optional[float] y_character_up: Optional[float] x_character_base: Optional[float] y_character_base: Optional[float] precision: cgm_enums.TextPrecisionEnum expansion_ratio: float character_spacing: float path: cgm_enums.TextPathEnum font_index: int class CGMConverter(metaclass=CGMConverterMeta): call_mapping: Dict[type, Callable] def __init__(self) -> None: self.current_page = None self.document = inkex.InputExtension.get_template(width="0", height="0") self.svg = self.document.getroot() self.color_table: Dict[int, cgm_enums.DirectColour] = {} self.vdc_height = 100 self.font_list: List[str] = ["sans-serif"] self.default_replacement: List[cgm_parse.CGMCommand] = [] self.first_page_origin: Optional[Tuple[float, float]] = None pass def set_defaults(self) -> None: self.edge_attributes = LineAttributes( cgm_enums.WidthSpecificationModeEnum.SCALED, 1.0, cgm_enums.DirectColour(0, 0, 0), # device-dependent fg color cgm_enums.LineTypeEnum.SOLID, cgm_enums.EdgeVisibilityEnum.OFF, ) self.line_attributes = LineAttributes( cgm_enums.WidthSpecificationModeEnum.SCALED, 1.0, cgm_enums.DirectColour(0, 0, 0), # device-dependent fg color cgm_enums.LineTypeEnum.SOLID, cgm_enums.EdgeVisibilityEnum.ON, ) self.fill_attributes = FillAttributes( cgm_enums.DirectColour(0, 0, 0), # device-dependent fg color interior_style=cgm_enums.InteriorStyleEnum.HOLLOW, ) self.text_attributes = TextAttributes( cgm_enums.DirectColour(0, 0, 0), # device-dependent fg color None, None, cgm_enums.TextHorizontalAlignmentEnum.NORMAL_HORIZONTAL, cgm_enums.TextVerticalAlignmentEnum.NORMAL_VERTICAL, 0, None, None, 0, cgm_enums.TextPrecisionEnum.STRING, 1.0, 0, cgm_enums.TextPathEnum.RIGHT, 1, ) self.current_text: Optional[inkex.TextElement] = None first = self.first_page_origin == None for cmd in self.default_replacement: self.stream(cmd) if first: # Restore this information if it was overwritten by the default replacement self.first_page_origin = None def apply_edge(self, elem): if self.edge_attributes.visibility == cgm_enums.EdgeVisibilityEnum.ON: elem.style.update( { "stroke": self.get_colour(self.edge_attributes.colour), "stroke-width": self.edge_attributes.width, } ) self.apply_stroke_dasharray(elem, self.edge_attributes.type) def apply_line(self, elem): elem.style.update( { "fill": None, "stroke": self.get_colour(self.line_attributes.colour), "stroke-width": self.line_attributes.width, } ) self.apply_stroke_dasharray(elem, self.line_attributes.type) def apply_stroke_dasharray(self, elem, stroke_type: cgm_enums.LineTypeEnum): if stroke_type == cgm_enums.LineTypeEnum.SOLID: return elif stroke_type == cgm_enums.LineTypeEnum.DASH: array = [3, 3] elif stroke_type == cgm_enums.LineTypeEnum.DOT: array = [1, 1] elif stroke_type == cgm_enums.LineTypeEnum.DASH_DOT: array = [4, 2, 1, 2] elif stroke_type == cgm_enums.LineTypeEnum.DASH_DOT_DOT: array = [4, 2, 1, 2, 1, 2] else: return # private value elem.style["stroke-dasharray"] = [ i * float(elem.style["stroke-width"]) for i in array ] def get_colour(self, colour: cgm_enums.Colour): if isinstance(colour, cgm_enums.IndexColour): colour = self.color_table.get( colour.index, cgm_enums.DirectColour(255, 255, 255) if colour.index == 0 else cgm_enums.DirectColour(0, 0, 0), ) if isinstance(colour, cgm_enums.DirectColour): if colour.colour_model == cgm_enums.ColourModelEnum.RGB: return inkex.Color([colour.v1, colour.v2, colour.v3]) else: inkex.errormsg(f"Color model {colour.colour_model} not implemented") def apply_fill(self, elem): fill = None if self.fill_attributes.interior_style == cgm_enums.InteriorStyleEnum.HOLLOW: fill = None elif self.fill_attributes.interior_style == cgm_enums.InteriorStyleEnum.SOLID: fill = self.get_colour(self.fill_attributes.fill) elem.style.update({"fill-rule": "evenodd", "fill": fill}) def on_begin_picture(self, arg: cgm_parse.BeginPicture): self.current_page = self.svg.namedview.add(inkex.Page()) assert self.current_page is not None self.current_page.set("inkscape:label", str(arg.name)) self.current_layer: inkex.Layer = self.svg.add(inkex.Layer()) self.set_defaults() def on_vdc_extent(self, arg: cgm_parse.VDCExtent): first_page = False if self.first_page_origin is None: self.first_page_origin = arg.first_corner.x, arg.first_corner.y first_page = True x, y, width, height = ( arg.first_corner.x - self.first_page_origin[0], arg.first_corner.y - self.first_page_origin[1], abs(arg.second_corner.x - arg.first_corner.x), abs(arg.second_corner.y - arg.first_corner.y), ) assert self.current_page is not None self.current_page.set("x", x) self.current_page.set("y", y) self.current_page.set("width", width) self.current_page.set("height", height) self.svg.set("width", width) self.svg.set("height", height) # x1 = x * matrix.a + y * matrix.b + matrix.e, # y1 = x * matrix.c + y * matrix.d + matrix.f; # We need a transform so that VDC Extent p1 is the bottom left of the page # and VDC Extent p2 is the bottom right of the page a = (width) / (arg.second_corner.x - arg.first_corner.x) d = (height) / (arg.first_corner.y - arg.second_corner.y) e = x - arg.first_corner.x * a f = y - arg.second_corner.y * d trans = inkex.Transform((a, 0, 0, d, e, f)) assert ( trans.apply_to_point(arg.first_corner.x + arg.first_corner.y * 1j) == x + (y + height) * 1j ) assert ( trans.apply_to_point(arg.second_corner.x + arg.second_corner.y * 1j) == (x + width) + (y) * 1j ) self.current_layer.transform = trans if first_page: # only for first page self.svg.set("viewBox", f"0 0 {width} {height}") self.vdc_height = height pass def on_rectangle(self, arg: cgm_parse.Rectangle): rect = self.current_layer.add( inkex.Rectangle.new( arg.p1.x, arg.p1.y, arg.p2.x - arg.p1.x, arg.p2.y - arg.p1.y ) ) self.apply_fill(rect) self.apply_edge(rect) def on_circle(self, arg: cgm_parse.Circle): circle = self.current_layer.add( inkex.Circle.new((arg.center.x, arg.center.y), arg.radius) ) self.apply_fill(circle) self.apply_edge(circle) def on_ellipse(self, arg: cgm_parse.Ellipse): ellipse = inkex.Ellipse.new((0, 0), (1, 1)) ellipse.transform = inkex.Transform( ( arg.point_1.x - arg.center.x, arg.point_1.y - arg.center.y, arg.point_2.x - arg.center.x, arg.point_2.y - arg.center.y, arg.center.x, arg.center.y, ) ) assert ( ellipse.transform.apply_to_point(1) == arg.point_1.x + (arg.point_1.y) * 1j ) assert ( ellipse.transform.apply_to_point(1j) == arg.point_2.x + (arg.point_2.y) * 1j ) pel: inkex.PathElement = self.current_layer.add(ellipse.to_path_element()) pel.apply_transform() self.apply_fill(pel) self.apply_edge(pel) def on_polygon(self, arg: cgm_parse.Polygon): poly = inkex.Polygon.new(" ".join([f"{i.x},{i.y}" for i in arg.points])) path = self.current_layer.add(poly.to_path_element()) self.apply_fill(path) self.apply_edge(path) def on_polyline(self, arg: cgm_parse.Polyline): poly = inkex.Polyline.new(" ".join([f"{i.x},{i.y}" for i in arg.points])) path = self.current_layer.add(poly.to_path_element()) self.apply_line(path) def on_disjoint_polyline(self, arg: cgm_parse.DisjointPolyline): path = inkex.PathElement.new( inkex.Path( [ inkex.paths.Move(pt.x, pt.y) if i % 2 == 0 else inkex.paths.Line(pt.x, pt.y) for i, pt in enumerate(arg.points) ] ) ) self.current_layer.add(path) self.apply_line(path) def on_polygon_set(self, arg: cgm_parse.PolygonSet): current_close_point = cgm_enums.Point(0, 0) if len(arg.points) == 0: return polygons: List[List[Tuple[bool, cgm_enums.Point]]] = [] # First construct a list of polygons in the set. def iterator(pts): yield from pairwise(pts) yield [pts[-1], None] for e1, e2 in iterator(arg.points): if e1 is None: polygons.append([]) current_close_point = e2.pt else: if e2 is not None and e1.linetype in ( cgm_enums.PolygonSetLineTypeEnum.INVISIBLE, cgm_enums.PolygonSetLineTypeEnum.VISIBLE, ): polygons[-1].append( (e1.linetype == cgm_enums.PolygonSetLineTypeEnum.VISIBLE, e2.pt) ) else: draw = e1.linetype in ( cgm_enums.PolygonSetLineTypeEnum.CLOSE_VISIBLE, cgm_enums.PolygonSetLineTypeEnum.VISIBLE, ) polygons[-1].append((draw, current_close_point)) if e2 is not None: current_close_point = e2.pt polygons.append([]) # Now re-shuffle each polygon so we start with an invisible # line if it exists. Coordinates are absolute so this should be fine shuffled = [] single_element = True for poly in polygons: has_invisible = any([not j[0] for j in poly]) if has_invisible: inv_index = ([p[0] for p in poly]).index(False) shuffled.append(poly[inv_index:] + poly[:inv_index]) else: shuffled.append(poly) if has_invisible: single_element = False # Draw the fill, then the stroke. fill_path = inkex.Path() for poly in shuffled: fill_path.append(inkex.paths.Move(poly[-1][1].x, poly[-1][1].y)) for point in poly[:-1]: # The last line is drawn via ZoneClose fill_path.append(inkex.paths.Line(point[1].x, point[1].y)) fill_path.append(inkex.paths.ZoneClose()) fill_elem = self.current_layer.add(inkex.PathElement.new(fill_path)) self.apply_fill(fill_elem) if single_element: self.apply_edge(fill_elem) else: # Draw the stroke separately stroke_path = inkex.Path() for poly in shuffled: stroke_path.append(inkex.paths.Move(poly[-1][1].x, poly[-1][1].y)) for point in poly: if point[0]: stroke_path.append(inkex.paths.Line(point[1].x, point[1].y)) else: stroke_path.append(inkex.paths.Move(point[1].x, point[1].y)) if all(i[0] for i in poly): stroke_path.append(inkex.paths.ZoneClose()) stroke_elem = self.current_layer.add(inkex.PathElement.new(stroke_path)) self.apply_edge(stroke_elem) stroke_elem.style["fill"] = None def on_polybezier(self, arg: cgm_parse.Polybezier): if len(arg.points) == 0: return path = inkex.Path() if arg.continuous == cgm_enums.PolybezierContinuityEnum.CONTINUOUS: assert len(arg.points) % 3 == 1, "Bad Polybezier specification" path.append(inkex.paths.Move(complex(arg.points[0]))) for i in range((len(arg.points) - 1) // 3): path.append( inkex.paths.Curve( complex(arg.points[3 * i + 1]), complex(arg.points[3 * i + 2]), complex(arg.points[3 * i + 3]), ) ) else: assert len(arg.points) % 4 == 0, "Bad Polybezier specification" for i in range(len(arg.points) // 4): if i == 0 or path[-1].cend_point(0, 0) != complex(arg.points[4 * i]): path.append(inkex.paths.Move(complex(arg.points[4 * i]))) path.append( inkex.paths.Curve( complex(arg.points[4 * i + 1]), complex(arg.points[4 * i + 2]), complex(arg.points[4 * i + 3]), ) ) pel = self.current_layer.add(inkex.PathElement.new(path)) self.apply_line(pel) pel.style["stroke"] = "red" def on_circular_arc_3pt(self, arg: cgm_parse.CircularArc3Point): path = inkex.PathElement.arc_from_3_points( complex(arg.p1), complex(arg.p2), complex(arg.p3), "arc" ) self.current_layer.append(path) self.apply_line(path) def on_circular_arc_3pt_close(self, arg: cgm_parse.CircularArc3PointClose): path = inkex.PathElement.arc_from_3_points( complex(arg.p1), complex(arg.p2), complex(arg.p3), "chord" if arg.closure == cgm_enums.ArcClosureEnum.CHORD else "slice", ) self.current_layer.append(path) self.apply_fill(path) self.apply_edge(path) def circular_arc_helper( self, arg: Union[cgm_parse.CircularArcCentre, cgm_parse.CircularArcCentreClose] ): c = complex(arg.centre) start_ray = arg.delta_x_start + arg.delta_y_start * 1j end_ray = arg.delta_x_end + arg.delta_y_end * 1j start_angle = inkex.Vector2d(start_ray).angle end_angle = inkex.Vector2d(end_ray).angle arctype = "arc" if isinstance(arg, cgm_parse.CircularArcCentreClose): arctype = ( "chord" if arg.closure == cgm_enums.ArcClosureEnum.CHORD else "slice" ) path = inkex.PathElement.arc( inkex.Vector2d(c), arg.radius, arg.radius, arctype, start=start_angle, end=end_angle, ) return path def on_circular_arc_centre(self, arg: cgm_parse.CircularArcCentre): path = self.circular_arc_helper(arg) self.current_layer.append(path) self.apply_line(path) def on_circular_arc_centre_close(self, arg: cgm_parse.CircularArcCentreClose): path = self.circular_arc_helper(arg) self.current_layer.append(path) self.apply_fill(path) self.apply_edge(path) def on_interior_style(self, arg: cgm_parse.InteriorStyle): self.fill_attributes.interior_style = arg.interior_style def on_fill_colour(self, arg: cgm_parse.FillColour): self.fill_attributes.fill = arg.fill def on_color_table(self, arg: cgm_parse.ColourTable): for i, col in enumerate(arg.colours): self.color_table[i + arg.colour_index.index] = col def on_line_width(self, arg: cgm_parse.LineWidth): if arg.width > 0: self.line_attributes.width = arg.width def on_line_width_specification_mode( self, arg: cgm_parse.LineWidthSpecificationMode ): self.line_attributes.specification_mode = arg.size_specification def on_line_colour(self, arg: cgm_parse.LineColour): self.line_attributes.colour = arg.colour def on_line_type(self, arg: cgm_parse.LineType): self.line_attributes.type = arg.type def on_edge_width(self, arg: cgm_parse.EdgeWidth): if arg.width > 0: self.edge_attributes.width = arg.width def on_edge_colour(self, arg: cgm_parse.EdgeColour): self.edge_attributes.colour = arg.colour def on_edge_type(self, arg: cgm_parse.EdgeType): self.edge_attributes.type = arg.type def on_edge_visibility(self, arg: cgm_parse.EdgeVisibility): self.edge_attributes.visibility = arg.visibility def on_background(self, arg: cgm_parse.BackgroundColour): self.svg.namedview.set("pagecolor", str(self.get_colour(arg.colour))) # Text-related def on_font_list(self, arg: cgm_parse.FontList): self.font_list = arg.fonts def on_text_font_index(self, arg: cgm_parse.TextFontIndex): self.text_attributes.font_index = arg.index def on_text_alignment(self, arg: cgm_parse.TextAlignment): self.text_attributes.horizontal_alignment = arg.horizontal_alignment self.text_attributes.vertical_alignment = arg.vertical_alignment def on_character_height(self, arg: cgm_parse.CharacterHeight): self.text_attributes.character_height = arg.height def on_character_orientation(self, arg: cgm_parse.CharacterOrientation): self.text_attributes.x_character_base = arg.x_character_base self.text_attributes.x_character_up = arg.x_character_up self.text_attributes.y_character_base = arg.y_character_base self.text_attributes.y_character_up = arg.y_character_up self.text_attributes.fallback_character_height = math.sqrt( arg.x_character_up**2 + arg.y_character_up**2 ) def on_text_precision(self, arg: cgm_parse.TextPrecision): self.text_attributes.precision = arg.text_precision def on_character_spacing(self, arg: cgm_parse.CharacterSpacing): self.text_attributes.character_spacing = arg.spacing def on_text_path(self, arg: cgm_parse.TextPath): self.text_attributes.path = arg.path def on_character_expansion(self, arg: cgm_parse.CharacterExpansionFactor): self.text_attributes.expansion_ratio = arg.factor def on_text_colour(self, arg: cgm_parse.TextColour): self.text_attributes.colour = arg.colour def add_tspan(self, text: str): assert self.current_text is not None tspan = self.current_text.add(inkex.Tspan()) tspan.text = text ch = ( self.text_attributes.character_height or self.text_attributes.fallback_character_height ) tspan.style.update( { "font-size": 1 / 100 * self.vdc_height if ch is None else ch, "font-stretch": f"{self.text_attributes.expansion_ratio * 100}%", "letter-spacing": self.text_attributes.character_spacing, "fill": self.get_colour(self.text_attributes.colour), # 1-based indices "font-family": self.font_list[self.text_attributes.font_index - 1], } ) def on_text(self, arg: cgm_parse.Text): self.current_text = inkex.TextElement() # Apply transform # get baseline unit vector cuv = inkex.Vector2d( 0 if self.text_attributes.x_character_up is None else self.text_attributes.x_character_up, self.vdc_height if self.text_attributes.y_character_up is None else self.text_attributes.y_character_up, ) cbv = inkex.Vector2d( self.vdc_height if self.text_attributes.x_character_base is None else self.text_attributes.x_character_base, 0 if self.text_attributes.y_character_base is None else self.text_attributes.y_character_base, ) cuv = 1 / cuv.length * cuv cbv = 1 / cbv.length * cbv self.current_text.transform = inkex.Transform( (cbv.x, cbv.y, cuv.x, cuv.y, 0, 0) ) @ inkex.Transform((1, 0, 0, -1, 0, 0)) origin = arg.point.x + arg.point.y * 1j # Apply reverse transform to origin origin = (-self.current_text.transform).apply_to_point(origin) self.current_text.set("x", origin.x) self.current_text.set("y", origin.y) if ( self.text_attributes.horizontal_alignment == cgm_enums.TextHorizontalAlignmentEnum.CENTER ): self.current_text.style["text-anchor"] = "middle" elif ( self.text_attributes.horizontal_alignment == cgm_enums.TextHorizontalAlignmentEnum.RIGHT ): self.current_text.style["text-anchor"] = "end" if ( self.text_attributes.vertical_alignment == cgm_enums.TextVerticalAlignmentEnum.HALF ): self.current_text.set("dy", "0.4em") if ( self.text_attributes.vertical_alignment == cgm_enums.TextVerticalAlignmentEnum.TOP ): self.current_text.set("dy", "1em") # TODO: character direction, vertical alignment self.add_tspan(arg.string) if arg.flag == cgm_enums.TextFinalFlag.FINAL: self.finalize_text() def finalize_text(self): max_font_size = max([i.style["font-size"] for i in self.current_text]) # This is for vertical alignment, which is specified in em of the largest # text of the box self.current_text.style["font-size"] = max_font_size self.current_layer.append(self.current_text) def on_default_replacement(self, arg: cgm_parse.MetafileDefaultsReplacement): self.default_replacement = arg.data def stream(self, arg: cgm_parse.CGMCommand): handler = self.call_mapping.get(type(arg)) if handler: # otherwise we ignore the command handler(self, arg) class CgmInput(inkex.InputExtension): def __init__(self) -> None: super().__init__() self.converter = CGMConverter() def load(self, stream): self.parser = cgm_parse.BinaryCGMCommandParser(stream) for command in self.parser.parse(): self.converter.stream(command) return self.converter.document if __name__ == "__main__": CgmInput().run()