From 4e659c3dddeef8b07ddc7410b2dcc29fd95251a8 Mon Sep 17 00:00:00 2001 From: "Salvador E. Tropea" Date: Mon, 11 Apr 2022 17:24:39 -0300 Subject: [PATCH] Added support for SVG to `pcb_print` - And now is much faster because all the processing is done using SVGs and we generate PDFs only during the last step. --- README.md | 2 + debian/control | 2 +- docs/README.in | 1 + docs/samples/generic_plot.kibot.yaml | 2 + kibot/out_pcb_print.py | 186 +++++----- kibot/svgutils/__init__.py | 1 + kibot/svgutils/compose.py | 417 +++++++++++++++++++++++ kibot/svgutils/templates.py | 75 ++++ kibot/svgutils/transform.py | 435 ++++++++++++++++++++++++ tests/yaml_samples/pcb_print.kibot.yaml | 1 + 10 files changed, 1011 insertions(+), 111 deletions(-) create mode 100644 kibot/svgutils/__init__.py create mode 100644 kibot/svgutils/compose.py create mode 100644 kibot/svgutils/templates.py create mode 100644 kibot/svgutils/transform.py diff --git a/README.md b/README.md index db4996c0..6588a377 100644 --- a/README.md +++ b/README.md @@ -1536,6 +1536,7 @@ Next time you need this list just use an alias, like this: - `dnf_filter`: [string|list(string)='_none'] Name of the filter to mark components as not fitted. A short-cut to use for simple cases where a variant is an overkill. - `drill_marks`: [string='full'] What to use to indicate the drill places, can be none, small or full (for real scale). + - `format`: [string='PDF'] [PDF,SVG] Format for the output file/s. - `hide_excluded`: [boolean=false] Hide components in the Fab layer that are marked as excluded by a variant. - `output`: [string='%f-%i%I%v.%x'] Filename for the output PDF (%i=assembly, %x=pdf). Affected by global options. - *output_name*: Alias for output. @@ -3066,6 +3067,7 @@ Additionally we support: - **Python macros**: Juha Jeronen (@Technologicat) - **Board2Pdf**: Albin Dennevi - **PyPDF2**: Mathieu Fenniak +- **svgutils**: Bartosz Telenczuk (@btel) - **Contributors**: - **Error filters ideas**: Leandro Heck (@leoheck) - **GitHub Actions Integration/SVG output**: @nerdyscout diff --git a/debian/control b/debian/control index a040d20f..e4d831c5 100644 --- a/debian/control +++ b/debian/control @@ -11,7 +11,7 @@ Package: kibot Architecture: all Multi-Arch: foreign Depends: ${misc:Depends}, ${python3:Depends}, python3-distutils, python3-yaml, kicad (>= 5.1.6), python3-wxgtk4.0 -Recommends: kibom.inti-cmnb (>= 1.8.0), interactivehtmlbom.inti-cmnb, pcbdraw, imagemagick, librsvg2-bin, python3-xlsxwriter, rar, poppler-utils +Recommends: kibom.inti-cmnb (>= 1.8.0), interactivehtmlbom.inti-cmnb, pcbdraw, imagemagick, librsvg2-bin, python3-xlsxwriter, rar, poppler-utils, python3-lxml Suggests: pandoc, texlive-latex-base, texlive-latex-recommended, git Description: KiCad Bot KiBot is a program which helps you to automate the generation of KiCad diff --git a/docs/README.in b/docs/README.in index 24a844f8..4e096924 100644 --- a/docs/README.in +++ b/docs/README.in @@ -1444,6 +1444,7 @@ Additionally we support: - **Python macros**: Juha Jeronen (@Technologicat) - **Board2Pdf**: Albin Dennevi - **PyPDF2**: Mathieu Fenniak +- **svgutils**: Bartosz Telenczuk (@btel) - **Contributors**: - **Error filters ideas**: Leandro Heck (@leoheck) - **GitHub Actions Integration/SVG output**: @nerdyscout diff --git a/docs/samples/generic_plot.kibot.yaml b/docs/samples/generic_plot.kibot.yaml index 07c9e090..e7e85318 100644 --- a/docs/samples/generic_plot.kibot.yaml +++ b/docs/samples/generic_plot.kibot.yaml @@ -966,6 +966,8 @@ outputs: dnf_filter: '_none' # [string='full'] What to use to indicate the drill places, can be none, small or full (for real scale) drill_marks: 'full' + # [string='PDF'] [PDF,SVG] Format for the output file/s + format: 'PDF' # [boolean=false] Hide components in the Fab layer that are marked as excluded by a variant hide_excluded: false # [string='%f-%i%I%v.%x'] Filename for the output PDF (%i=assembly, %x=pdf). Affected by global options diff --git a/kibot/out_pcb_print.py b/kibot/out_pcb_print.py index da84d06c..68a0be93 100644 --- a/kibot/out_pcb_print.py +++ b/kibot/out_pcb_print.py @@ -8,16 +8,17 @@ # Note: Original code released as Public Domain import os import subprocess -from pcbnew import PLOT_CONTROLLER, PLOT_FORMAT_PDF, FromMM -from shutil import rmtree +from pcbnew import PLOT_CONTROLLER, FromMM, PLOT_FORMAT_SVG +from shutil import rmtree, which from tempfile import mkdtemp +from .svgutils.transform import fromstring from .error import KiPlotConfigurationError from .gs import GS from .optionable import Optionable from .out_base import VariantOptions from .kicad.color_theme import load_color_theme from .kicad.patch_svg import patch_svg_file -from .misc import CMD_PCBNEW_PRINT_LAYERS, URL_PCBNEW_PRINT_LAYERS, PDF_PCB_PRINT +from .misc import CMD_PCBNEW_PRINT_LAYERS, URL_PCBNEW_PRINT_LAYERS, PDF_PCB_PRINT, MISSING_TOOL from .kiplot import check_script, exec_with_retry, add_extra_options from .macros import macros, document, output_class # noqa: F401 from .layer import Layer, get_priority @@ -25,9 +26,7 @@ from . import PyPDF2 from . import log logger = log.get_logger() - -# TODO: -# - SVG? +SVG2PDF = 'rsvg-convert' def _run_command(cmd): @@ -56,83 +55,42 @@ def to_gray(color): return (avg, avg, avg) -def colorize_pdf(folder, in_file, out_file, color, black_holes): - er = None - pdf_color = [PyPDF2.generic.FloatObject(color[0]), PyPDF2.generic.FloatObject(color[1]), - PyPDF2.generic.FloatObject(color[2])] - black_color = [PyPDF2.generic.FloatObject(0), PyPDF2.generic.FloatObject(0), PyPDF2.generic.FloatObject(0)] - try: - with open(os.path.join(folder, in_file), "rb") as f: - source = PyPDF2.PdfFileReader(f, "rb") - output = PyPDF2.PdfFileWriter() - for page in range(source.getNumPages()): - page = source.getPage(page) - content_object = page["/Contents"].getObject() - content = PyPDF2.pdf.ContentStream(content_object, source) - for i, (operands, operator) in enumerate(content.operations): - if operator == b"rg" or operator == b"RG": - if operands == [0, 0, 0]: - # Replace black by the selected color - content.operations[i] = (pdf_color, operator) - elif black_holes and operands == [1, 1, 1]: - # Replace white by black - content.operations[i] = (black_color, operator) - page.__setitem__(PyPDF2.generic.NameObject('/Contents'), content) - output.addPage(page) - try: - with open(os.path.join(folder, out_file), "wb") as outputStream: - output.write(outputStream) - except (IOError, ValueError, EOFError) as e: - er = str(e) - if er: - raise KiPlotConfigurationError('Error creating `{}` ({})'.format(out_file, er)) - except (IOError, ValueError, EOFError) as e: - er = str(e) - if er: - raise KiPlotConfigurationError('Error reading `{}` ({})'.format(in_file, er)) +def to_gray_hex(color): + rgb, alpha = hex_to_rgb(color) + avg = (rgb[0]+rgb[1]+rgb[2])/3 + avg_str = '%02X' % int(avg*255) + return '#'+avg_str+avg_str+avg_str -def merge_pdf(input_folder, input_files, output_folder, output_file): +def load_svg(file, color, black_holes, monochrome): + with open(file, 'rt') as f: + content = f.read() + color = color[:7] + if monochrome: + color = to_gray_hex(color) + if black_holes: + content = content.replace('#FFFFFF', '**black_hole**') + if color != '#000000': + content = content.replace('#000000', color) + if black_holes: + content = content.replace('**black_hole**', '#000000') + return content + + +def merge_svg(input_folder, input_files, output_folder, output_file, black_holes, monochrome): """ Merge all pages into one """ - output = PyPDF2.PdfFileWriter() - # Collect all pages, as a merged one - i = 0 - er = None - open_files = [] - extra_debug = GS.debug_level >= 3 - for filename in input_files: - if extra_debug: - logger.debug(" - {}".format(filename)) - try: - file = open(os.path.join(input_folder, filename), 'rb') - open_files.append(file) - pdf_reader = PyPDF2.PdfFileReader(file) - page_obj = pdf_reader.getPage(0) - if(i == 0): - merged_page = page_obj - else: - merged_page.mergePage(page_obj) - i = i+1 - except (IOError, ValueError, EOFError) as e: - er = str(e) - if er: - raise KiPlotConfigurationError('Error reading `{}` ({})'.format(filename, er)) - output.addPage(merged_page) - # Write the result to a file - pdf_output = None - try: - pdf_output = open(os.path.join(output_folder, output_file), 'wb') - output.write(pdf_output) - except (IOError, ValueError, EOFError) as e: - er = str(e) - finally: - if pdf_output: - pdf_output.close() - if er: - raise KiPlotConfigurationError('Error creating `{}` ({})'.format(output_file, er)) - # Close the input files - for f in open_files: - f.close() + first = True + for (file, color) in input_files: + file = os.path.join(input_folder, file) + new_layer = fromstring(load_svg(file, color, black_holes, monochrome)) + if first: + svg_out = new_layer + first = False + else: + root = new_layer.getroot() + root.moveto(1, 1) + svg_out.append([root]) + svg_out.save(os.path.join(output_folder, output_file)) def create_pdf_from_pages(input_folder, input_files, output_fn): @@ -169,17 +127,20 @@ def create_pdf_from_pages(input_folder, input_files, output_fn): f.close() -def colorize_layer(suffix, color, monochrome, filelist, temp_dir, black_holes=False): - in_file = GS.pcb_basename+"-"+suffix+".pdf" - if color != "#000000": - out_file = GS.pcb_basename+"-"+suffix+"-colored.pdf" - logger.debug('- Giving color to {} -> {} ({})'.format(in_file, out_file, color)) - rgb, alpha = hex_to_rgb(color) - color = rgb if not monochrome else to_gray(rgb) - colorize_pdf(temp_dir, in_file, out_file, color, black_holes) - filelist.append(out_file) - else: - filelist.append(in_file) +def svg_to_pdf(input_folder, svg_file, pdf_file): + # Note: rsvg-convert uses 90 dpi but KiCad (and the docs I found) says SVG pt is 72 dpi + cmd = [SVG2PDF, '-d', '72', '-p', '72', '-f', 'pdf', '-o', os.path.join(input_folder, pdf_file), + os.path.join(input_folder, svg_file)] + _run_command(cmd) + + +def create_pdf_from_svg_pages(input_folder, input_files, output_fn): + svg_files = [] + for svg_file in input_files: + pdf_file = svg_file.replace('.svg', '.pdf') + svg_to_pdf(input_folder, svg_file, pdf_file) + svg_files.append(pdf_file) + create_pdf_from_pages(input_folder, svg_files, output_fn) class LayerOptions(Layer): @@ -275,8 +236,9 @@ class PCB_PrintOptions(VariantOptions): self.title = '' """ Text used to replace the sheet title. %VALUE expansions are allowed. If it starts with `+` the text is concatenated """ + self.format = 'PDF' + """ [PDF,SVG] Format for the output file/s """ super().__init__() - self._expand_ext = 'pdf' self._expand_id = 'assembly' @property @@ -306,6 +268,7 @@ class PCB_PrintOptions(VariantOptions): else: la.color = "#000000" self._drill_marks = PCB_PrintOptions._drill_marks_map[self._drill_marks] + self._expand_ext = self.format.lower() def filter_components(self): if not self._comps: @@ -355,7 +318,7 @@ class PCB_PrintOptions(VariantOptions): po.SetScale(1.0) po.SetNegative(False) pc.SetLayer(self.edge_layer) - pc.OpenPlotfile('frame', PLOT_FORMAT_PDF, p.sheet) + pc.OpenPlotfile('frame', PLOT_FORMAT_SVG, p.sheet) pc.PlotLayer() self.restore_edge_cuts() @@ -389,11 +352,13 @@ class PCB_PrintOptions(VariantOptions): if os.path.isfile(video_name): os.remove(video_name) patch_svg_file(output, remove_bkg=True) - # Note: rsvg-convert uses 90 dpi but KiCad (and the docs I found) says SVG pt is 72 dpi - cmd = ['rsvg-convert', '-d', '72', '-p', '72', '-f', 'pdf', '-o', output.replace('.svg', '.pdf'), output] - _run_command(cmd) def generate_output(self, output): + if which(SVG2PDF) is None: + logger.error('`{}` not installed and needed for PDF output'.format(SVG2PDF)) + logger.error('Install `librsvg2-bin` or equivalent') + exit(MISSING_TOOL) + output_dir = os.path.dirname(output) temp_dir = mkdtemp(prefix='tmp-kibot-pcb_print-') logger.debug('- Temporal dir: {}'.format(temp_dir)) # Plot options @@ -418,6 +383,7 @@ class PCB_PrintOptions(VariantOptions): if GS.ki5(): po.SetLineWidth(FromMM(p.line_width)) po.SetPlotPadsOnSilkLayer(not p.exclude_pads_from_silkscreen) + filelist = [] for la in p.layers: id = la._id logger.debug('- Plotting layer {} ({})'.format(la.layer, id)) @@ -425,8 +391,9 @@ class PCB_PrintOptions(VariantOptions): po.SetPlotValue(la.plot_footprint_values) po.SetPlotInvisibleText(la.force_plot_invisible_refs_vals) pc.SetLayer(id) - pc.OpenPlotfile(la.suffix, PLOT_FORMAT_PDF, p.sheet) + pc.OpenPlotfile(la.suffix, PLOT_FORMAT_SVG, p.sheet) pc.PlotLayer() + filelist.append((GS.pcb_basename+"-"+la.suffix+".svg", la.color)) # 2) Plot the frame using an empty layer and 1.0 scale if self.plot_sheet_reference: logger.debug('- Plotting the frame') @@ -434,24 +401,23 @@ class PCB_PrintOptions(VariantOptions): self.plot_frame_ki6(pc, po, p) else: self.plot_frame_ki5(temp_dir) - pc.ClosePlot() - # 3) Apply the colors to the layer PDFs - filelist = [] - for la in p.layers: - colorize_layer(la.suffix, la.color, p.monochrome, filelist, temp_dir, p.black_holes) - # 4) Apply color to the frame - if self.plot_sheet_reference: color = p.sheet_reference_color if p.sheet_reference_color else self._color_theme.pcb_frame - colorize_layer('frame', color, p.monochrome, filelist, temp_dir) - # 5) Stack all layers in one file - assembly_file = GS.pcb_basename+"-"+str(n)+".pdf" + filelist.append((GS.pcb_basename+"-frame.svg", color)) + pc.ClosePlot() + # 3) Stack all layers in one file + if self.format == 'PDF': + assembly_file = GS.pcb_basename+"-"+str(n+1)+".svg" + else: + id = self._expand_id+('_page_%02d' % (n+1)) + assembly_file = self.expand_filename(output_dir, self.output, id, self._expand_ext) logger.debug('- Merging layers to {}'.format(assembly_file)) - merge_pdf(temp_dir, filelist, temp_dir, assembly_file) + merge_svg(temp_dir, filelist, temp_dir, assembly_file, p.black_holes, p.monochrome) pages.append(assembly_file) self.restore_title() # Join all pages in one file - logger.debug('- Creating output file {}'.format(output)) - create_pdf_from_pages(temp_dir, pages, output) + if self.format == 'PDF': + logger.debug('- Creating output file {}'.format(output)) + create_pdf_from_svg_pages(temp_dir, pages, output) # Remove the temporal files rmtree(temp_dir) diff --git a/kibot/svgutils/__init__.py b/kibot/svgutils/__init__.py new file mode 100644 index 00000000..deb765ff --- /dev/null +++ b/kibot/svgutils/__init__.py @@ -0,0 +1 @@ +from . import transform, compose # noqa: F401 diff --git a/kibot/svgutils/compose.py b/kibot/svgutils/compose.py new file mode 100644 index 00000000..b43688cc --- /dev/null +++ b/kibot/svgutils/compose.py @@ -0,0 +1,417 @@ +# coding=utf-8 +"""SVG definitions designed for easy SVG composing + +Features: + * allow for wildcard import + * defines a mini language for SVG composing + * short but readable names + * easy nesting + * method chaining + * no boilerplate code (reading files, extracting objects from svg, + transversing XML tree) + * universal methods applicable to all element types + * don't have to learn python +""" + +import os +import re + +from . import transform + +CONFIG = { + "svg.file_path": ".", + "figure.save_path": ".", + "image.file_path": ".", + "text.position": (0, 0), + "text.size": 8, + "text.weight": "normal", + "text.font": "Verdana", +} + + +class Element(transform.FigureElement): + """Base class for new SVG elements.""" + + def rotate(self, angle, x=0, y=0): + """Rotate element by given angle around given pivot. + + Parameters + ---------- + angle : float + rotation angle in degrees + x, y : float + pivot coordinates in user coordinate system (defaults to top-left + corner of the figure) + """ + super(Element, self).rotate(angle, x, y) + return self + + def move(self, x, y): + """Move the element by x, y. + + Parameters + ---------- + x,y : int, str + amount of horizontal and vertical shift + + Notes + ----- + The x, y can be given with a unit (for example, "3px", "5cm"). If no + unit is given the user unit is assumed ("px"). In SVG all units are + defined in relation to the user unit [1]_. + + .. [1] W3C SVG specification: + https://www.w3.org/TR/SVG/coords.html#Units + """ + self.moveto(x, y) + return self + + def find_id(self, element_id): + """Find a single element with the given ID. + + Parameters + ---------- + element_id : str + ID of the element to find + + Returns + ------- + found element + """ + element = transform.FigureElement.find_id(self, element_id) + return Element(element.root) + + def find_ids(self, element_ids): + """Find elements with given IDs. + + Parameters + ---------- + element_ids : list of strings + list of IDs to find + + Returns + ------- + a new `Panel` object which contains all the found elements. + """ + elements = [transform.FigureElement.find_id(self, eid) for eid in element_ids] + return Panel(*elements) + + +class SVG(Element): + """SVG from file. + + Parameters + ---------- + fname : str + full path to the file + fix_mpl : bool + replace pt units with px units to fix files created with matplotlib + """ + + def __init__(self, fname=None, fix_mpl=False): + if fname: + fname = os.path.join(CONFIG["svg.file_path"], fname) + svg = transform.fromfile(fname) + if fix_mpl: + w, h = svg.get_size() + svg.set_size((w.replace("pt", ""), h.replace("pt", ""))) + super(SVG, self).__init__(svg.getroot().root) + + # if height/width is in % units, we can't store the absolute values + if svg.width.endswith("%"): + self._width = None + else: + self._width = Unit(svg.width).to("px") + if svg.height.endswith("%"): + self._height = None + else: + self._height = Unit(svg.height).to("px") + + @property + def width(self): + """Get width of the svg file in px""" + if self._width: + return self._width.value + + @property + def height(self): + """Get height of the svg file in px""" + if self._height: + return self._height.value + + +class MplFigure(SVG): + """Matplotlib figure + + Parameters + ---------- + fig : matplotlib Figure instance + instance of Figure to be converted + kws : + keyword arguments passed to matplotlib's savefig method + """ + + def __init__(self, fig, **kws): + svg = transform.from_mpl(fig, savefig_kw=kws) + self.root = svg.getroot().root + + +class Image(Element): + """Raster or vector image + + Parameters + ---------- + width : float + height : float + image dimensions + fname : str + full path to the file + """ + + def __init__(self, width, height, fname): + fname = os.path.join(CONFIG["image.file_path"], fname) + _, fmt = os.path.splitext(fname) + fmt = fmt.lower()[1:] + with open(fname, "rb") as fid: + img = transform.ImageElement(fid, width, height, fmt) + self.root = img.root + + +class Text(Element): + """Text element. + + Parameters + ---------- + text : str + content + x, y : float or str + Text position. If unit is not given it will assume user units (px). + size : float, optional + Font size. + weight : str, optional + Font weight. It can be one of: normal, bold, bolder or lighter. + font : str, optional + Font family. + """ + + def __init__(self, text, x=None, y=None, **kwargs): + params = { + "size": CONFIG["text.size"], + "weight": CONFIG["text.weight"], + "font": CONFIG["text.font"], + } + if x is None or y is None: + x, y = CONFIG["text.position"] + params.update(kwargs) + element = transform.TextElement(x, y, text, **params) + Element.__init__(self, element.root) + + +class Panel(Element): + """Figure panel. + + Panel is a group of elements that can be transformed together. Usually + it relates to a labeled figure panel. + + Parameters + ---------- + svgelements : objects deriving from Element class + one or more elements that compose the panel + + Notes + ----- + The grouped elements need to be properly arranged in scale and position. + """ + + def __init__(self, *svgelements): + element = transform.GroupElement(svgelements) + Element.__init__(self, element.root) + + def __iter__(self): + elements = self.root.getchildren() + return (Element(el) for el in elements) + + +class Line(Element): + """Line element connecting given points. + + Parameters + ---------- + points : sequence of tuples + List of point x,y coordinates. + width : float, optional + Line width. + color : str, optional + Line color. Any of the HTML/CSS color definitions are allowed. + """ + + def __init__(self, points, width=1, color="black"): + element = transform.LineElement(points, width=width, color=color) + Element.__init__(self, element.root) + + +class Grid(Element): + """Line grid with coordinate labels to facilitate placement of new + elements. + + Parameters + ---------- + dx : float + Spacing between the vertical lines. + dy : float + Spacing between horizontal lines. + size : float or str + Font size of the labels. + + Notes + ----- + This element is mainly useful for manual placement of the elements. + """ + + def __init__(self, dx, dy, size=8): + self.size = size + lines = self._gen_grid(dx, dy) + element = transform.GroupElement(lines) + Element.__init__(self, element.root) + + def _gen_grid(self, dx, dy, width=0.5): + xmax, ymax = 1000, 1000 + x, y = 0, 0 + lines = [] + txt = [] + while x < xmax: + lines.append(transform.LineElement([(x, 0), (x, ymax)], width=width)) + txt.append(transform.TextElement(x, dy / 2, str(x), size=self.size)) + x += dx + while y < ymax: + lines.append(transform.LineElement([(0, y), (xmax, y)], width=width)) + txt.append(transform.TextElement(0, y, str(y), size=self.size)) + y += dy + return lines + txt + + +class Figure(Panel): + """Main figure class. + + This should be always the top class of all the generated SVG figures. + + Parameters + ---------- + width, height : float or str + Figure size. If unit is not given, user units (px) are assumed. + """ + + def __init__(self, width, height, *svgelements): + Panel.__init__(self, *svgelements) + self.width = Unit(width) + self.height = Unit(height) + + def save(self, fname): + """Save figure to SVG file. + + Parameters + ---------- + fname : str + Full path to file. + """ + element = transform.SVGFigure(self.width, self.height) + element.append(self) + element.save(os.path.join(CONFIG["figure.save_path"], fname)) + + def tostr(self): + """Export SVG as a string""" + element = transform.SVGFigure(self.width, self.height) + element.append(self) + svgstr = element.to_str() + return svgstr + + def _repr_svg_(self): + return self.tostr().decode("ascii") + + def tile(self, ncols, nrows): + """Automatically tile the panels of the figure. + + This will re-arranged all elements of the figure (first in the + hierarchy) so that they will uniformly cover the figure area. + + Parameters + ---------- + ncols, nrows : type + The number of columns and rows to arrange the elements into. + + + Notes + ----- + ncols * nrows must be larger or equal to number of + elements, otherwise some elements will go outside the figure borders. + """ + dx = (self.width / ncols).to("px").value + dy = (self.height / nrows).to("px").value + ix, iy = 0, 0 + for el in self: + el.move(dx * ix, dy * iy) + ix += 1 + if ix >= ncols: + ix = 0 + iy += 1 + if iy > nrows: + break + return self + + +class Unit: + """Implementation of SVG units and conversions between them. + + Parameters + ---------- + measure : str + value with unit (for example, '2cm') + """ + + per_inch = {"px": 90, "cm": 2.54, "mm": 25.4, "pt": 72.0} + + def __init__(self, measure): + try: + self.value = float(measure) + self.unit = "px" + except ValueError: + m = re.match(r"([0-9]+\.?[0-9]*)([a-z]+)", measure) + value, unit = m.groups() + self.value = float(value) + self.unit = unit + + def to(self, unit): + """Convert to a given unit. + + Parameters + ---------- + unit : str + Name of the unit to convert to. + + Returns + ------- + u : Unit + new Unit object with the requested unit and computed value. + """ + u = Unit("0cm") + u.value = self.value / self.per_inch[self.unit] * self.per_inch[unit] + u.unit = unit + return u + + def __str__(self): + return "{}{}".format(self.value, self.unit) + + def __repr__(self): + return "Unit({})".format(str(self)) + + def __mul__(self, number): + u = Unit("0cm") + u.value = self.value * number + u.unit = self.unit + return u + + def __truediv__(self, number): + return self * (1.0 / number) + + def __div__(self, number): + return self * (1.0 / number) diff --git a/kibot/svgutils/templates.py b/kibot/svgutils/templates.py new file mode 100644 index 00000000..bf49dd82 --- /dev/null +++ b/kibot/svgutils/templates.py @@ -0,0 +1,75 @@ +# coding=utf-8 + +from svgutils.transform import SVGFigure, GroupElement + + +class BaseTemplate(SVGFigure): + def __init__(self): + SVGFigure.__init__(self) + self.figures = [] + + def add_figure(self, fig): + w, h = fig.get_size() + root = fig.getroot() + self.figures.append({"root": root, "width": w, "height": h}) + + def _transform(self): + pass + + def save(self, fname): + self._generate_layout() + SVGFigure.save(self, fname) + + def _generate_layout(self): + + for i, f in enumerate(self.figures): + new_element = self._transform(f["root"], self.figures[:i]) + self.append(new_element) + + +class VerticalLayout(BaseTemplate): + def _transform(self, element, transform_list): + for t in transform_list: + element = GroupElement([element]) + element.moveto(0, t["height"]) + return element + + +class ColumnLayout(BaseTemplate): + def __init__(self, nrows, row_height=None, col_width=None): + """Multiple column layout with nrows and required number of + columns. col_width + determines the width of the column (defaults to width of the + first added element)""" + + self.nrows = nrows + self.col_width = col_width + self.row_height = row_height + + BaseTemplate.__init__(self) + + def _transform(self, element, transform_list): + + rows = 0 + + if not transform_list: + return element + + n_elements = len(transform_list) + rows = n_elements % self.nrows + cols = int(n_elements / self.nrows) + + if self.col_width is None: + self.col_width = transform_list[0]["width"] + if self.row_height is None: + self.row_height = transform_list[0]["height"] + + for _ in range(rows): + element = GroupElement([element]) + element.moveto(0, self.row_height) + + for _ in range(cols): + element = GroupElement([element]) + element.moveto(self.col_width, 0) + + return element diff --git a/kibot/svgutils/transform.py b/kibot/svgutils/transform.py new file mode 100644 index 00000000..ef15f9e3 --- /dev/null +++ b/kibot/svgutils/transform.py @@ -0,0 +1,435 @@ +from lxml import etree +from copy import deepcopy +import codecs + +try: + from StringIO import StringIO +except ImportError: + from io import StringIO + +SVG_NAMESPACE = "http://www.w3.org/2000/svg" +XLINK_NAMESPACE = "http://www.w3.org/1999/xlink" +SVG = "{%s}" % SVG_NAMESPACE +XLINK = "{%s}" % XLINK_NAMESPACE +NSMAP = {None: SVG_NAMESPACE, "xlink": XLINK_NAMESPACE} + + +class FigureElement(object): + """Base class representing single figure element""" + + def __init__(self, xml_element, defs=None): + + self.root = xml_element + + def moveto(self, x, y, scale_x=1, scale_y=None): + """Move and scale element. + + Parameters + ---------- + x, y : float + displacement in x and y coordinates in user units ('px'). + scale_x : float + x-direction scaling factor. To scale down scale_x < 1, scale up scale_x > 1. + scale_y : (optional) float + y-direction scaling factor. To scale down scale_y < 1, scale up scale_y > 1. + If set to default (None), then scale_y=scale_x. + """ + if scale_y is None: + scale_y = scale_x + self.root.set( + "transform", + "translate(%s, %s) scale(%s %s) %s" + % (x, y, scale_x, scale_y, self.root.get("transform") or ""), + ) + + def rotate(self, angle, x=0, y=0): + """Rotate element by given angle around given pivot. + + Parameters + ---------- + angle : float + rotation angle in degrees + x, y : float + pivot coordinates in user coordinate system (defaults to top-left + corner of the figure) + """ + self.root.set( + "transform", + "%s rotate(%f %f %f)" % (self.root.get("transform") or "", angle, x, y), + ) + + def skew(self, x=0, y=0): + """Skew the element by x and y degrees + Convenience function which calls skew_x and skew_y + + Parameters + ---------- + x,y : float, float + skew angle in degrees (default 0) + + If an x/y angle is given as zero degrees, that transformation is omitted. + """ + if x != 0: + self.skew_x(x) + if y != 0: + self.skew_y(y) + + return self + + def skew_x(self, x): + """Skew element along the x-axis by the given angle. + + Parameters + ---------- + x : float + x-axis skew angle in degrees + """ + self.root.set( + "transform", "%s skewX(%f)" % (self.root.get("transform") or "", x) + ) + return self + + def skew_y(self, y): + """Skew element along the y-axis by the given angle. + + Parameters + ---------- + y : float + y-axis skew angle in degrees + """ + self.root.set( + "transform", "%s skewY(%f)" % (self.root.get("transform") or "", y) + ) + return self + + def scale(self, x=0, y=None): + """Scale element separately across the two axes x and y. + If y is not provided, it is assumed equal to x (according to the + W3 specification). + + Parameters + ---------- + x : float + x-axis scaling factor. To scale down x < 1, scale up x > 1. + y : (optional) float + y-axis scaling factor. To scale down y < 1, scale up y > 1. + + """ + self.moveto(0, 0, x, y) + return self + + def __getitem__(self, i): + return FigureElement(self.root.getchildren()[i]) + + def copy(self): + """Make a copy of the element""" + return deepcopy(self.root) + + def tostr(self): + """String representation of the element""" + return etree.tostring(self.root, pretty_print=True) + + def find_id(self, element_id): + """Find element by its id. + + Parameters + ---------- + element_id : str + ID of the element to find + + Returns + ------- + FigureElement + one of the children element with the given ID.""" + find = etree.XPath("//*[@id=$id]") + return FigureElement(find(self.root, id=element_id)[0]) + + +class TextElement(FigureElement): + """Text element. + + Corresponds to SVG ```` tag.""" + + def __init__( + self, + x, + y, + text, + size=8, + font="Verdana", + weight="normal", + letterspacing=0, + anchor="start", + color="black", + ): + txt = etree.Element( + SVG + "text", + { + "x": str(x), + "y": str(y), + "font-size": str(size), + "font-family": font, + "font-weight": weight, + "letter-spacing": str(letterspacing), + "text-anchor": str(anchor), + "fill": str(color), + }, + ) + txt.text = text + FigureElement.__init__(self, txt) + + +class ImageElement(FigureElement): + """Inline image element. + + Correspoonds to SVG ```` tag. Image data encoded as base64 string. + """ + + def __init__(self, stream, width, height, format="png"): + base64str = codecs.encode(stream.read(), "base64").rstrip() + uri = "data:image/{};base64,{}".format(format, base64str.decode("ascii")) + attrs = {"width": str(width), "height": str(height), XLINK + "href": uri} + img = etree.Element(SVG + "image", attrs) + FigureElement.__init__(self, img) + + +class LineElement(FigureElement): + """Line element. + + Corresponds to SVG ```` tag. It handles only piecewise + straight segments + """ + + def __init__(self, points, width=1, color="black"): + linedata = "M{} {} ".format(*points[0]) + linedata += " ".join(map(lambda x: "L{} {}".format(*x), points[1:])) + line = etree.Element( + SVG + "path", {"d": linedata, "stroke-width": str(width), "stroke": color} + ) + FigureElement.__init__(self, line) + + +class GroupElement(FigureElement): + """Group element. + + Container for other elements. Corresponds to SVG ```` tag. + """ + + def __init__(self, element_list, attrib=None): + new_group = etree.Element(SVG + "g", attrib=attrib) + for e in element_list: + if isinstance(e, FigureElement): + new_group.append(e.root) + else: + new_group.append(e) + self.root = new_group + + +class SVGFigure(object): + """SVG Figure. + + It setups standalone SVG tree. It corresponds to SVG ```` tag. + """ + + def __init__(self, width=None, height=None): + self.root = etree.Element(SVG + "svg", nsmap=NSMAP) + self.root.set("version", "1.1") + + self._width = 0 + self._height = 0 + + if width: + try: + self.width = width # this goes to @width.setter a few lines down + except AttributeError: + # int or str + self._width = width + + if height: + try: + self.height = height # this goes to @height.setter a few lines down + except AttributeError: + self._height = height + + @property + def width(self): + """Figure width""" + return self.root.get("width") + + @width.setter + def width(self, value): + self._width = value.value + self.root.set("width", str(value)) + self.root.set("viewBox", "0 0 %s %s" % (self._width, self._height)) + + @property + def height(self): + """Figure height""" + return self.root.get("height") + + @height.setter + def height(self, value): + self._height = value.value + self.root.set("height", str(value)) + self.root.set("viewBox", "0 0 %s %s" % (self._width, self._height)) + + def append(self, element): + """Append new element to the SVG figure""" + try: + self.root.append(element.root) + except AttributeError: + self.root.append(GroupElement(element).root) + + def getroot(self): + """Return the root element of the figure. + + The root element is a group of elements after stripping the toplevel + ```` tag. + + Returns + ------- + GroupElement + All elements of the figure without the ```` tag. + """ + if "class" in self.root.attrib: + attrib = {"class": self.root.attrib["class"]} + else: + attrib = None + return GroupElement(self.root.getchildren(), attrib=attrib) + + def to_str(self): + """ + Returns a string of the SVG figure. + """ + return etree.tostring( + self.root, xml_declaration=True, standalone=True, pretty_print=True + ) + + def save(self, fname, encoding=None): + """ + Save figure to a file + Default encoding is "ASCII" when None is specified, as dictated by lxml . + """ + out = etree.tostring( + self.root, + xml_declaration=True, + standalone=True, + pretty_print=True, + encoding=encoding, + ) + with open(fname, "wb") as fid: + fid.write(out) + + def find_id(self, element_id): + """Find elements with the given ID""" + find = etree.XPath("//*[@id=$id]") + return FigureElement(find(self.root, id=element_id)[0]) + + def get_size(self): + """Get figure size""" + return self.root.get("width"), self.root.get("height") + + def set_size(self, size): + """Set figure size""" + w, h = size + self.root.set("width", w) + self.root.set("height", h) + + +def fromfile(fname): + """Open SVG figure from file. + + Parameters + ---------- + fname : str + name of the SVG file + + Returns + ------- + SVGFigure + newly created :py:class:`SVGFigure` initialised with the file content + """ + fig = SVGFigure() + with open(fname) as fid: + svg_file = etree.parse(fid, parser=etree.XMLParser(huge_tree=True)) + + fig.root = svg_file.getroot() + return fig + + +def fromstring(text): + """Create a SVG figure from a string. + + Parameters + ---------- + text : str + string representing the SVG content. Must be valid SVG. + + Returns + ------- + SVGFigure + newly created :py:class:`SVGFigure` initialised with the string + content. + """ + fig = SVGFigure() + svg = etree.fromstring(text.encode(), parser=etree.XMLParser(huge_tree=True)) + + fig.root = svg + + return fig + + +def from_mpl(fig, savefig_kw=None): + """Create a SVG figure from a ``matplotlib`` figure. + + Parameters + ---------- + fig : matplotlib.Figure instance + + savefig_kw : dict + keyword arguments to be passed to matplotlib's + `savefig` + + + + Returns + ------- + SVGFigure + newly created :py:class:`SVGFigure` initialised with the string + content. + + + Examples + -------- + + If you want to overlay the figure on another SVG, you may want to pass + the `transparent` option: + + >>> from svgutils import transform + >>> import matplotlib.pyplot as plt + >>> fig = plt.figure() + >>> line, = plt.plot([1,2]) + >>> svgfig = transform.from_mpl(fig, + ... savefig_kw=dict(transparent=True)) + >>> svgfig.getroot() + + + + """ + + fid = StringIO() + if savefig_kw is None: + savefig_kw = {} + + try: + fig.savefig(fid, format="svg", **savefig_kw) + except ValueError: + raise (ValueError, "No matplotlib SVG backend") + fid.seek(0) + fig = fromstring(fid.read()) + + # workaround mpl units bug + w, h = fig.get_size() + fig.set_size((w.replace("pt", ""), h.replace("pt", ""))) + + return fig diff --git a/tests/yaml_samples/pcb_print.kibot.yaml b/tests/yaml_samples/pcb_print.kibot.yaml index e3f7acc7..9526c7e9 100644 --- a/tests/yaml_samples/pcb_print.kibot.yaml +++ b/tests/yaml_samples/pcb_print.kibot.yaml @@ -13,6 +13,7 @@ outputs: # drill_marks: small title: Chau # plot_sheet_reference: false + format: 'SVG' pages: - # monochrome: true scaling: 2.0