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.
This commit is contained in:
Salvador E. Tropea 2022-04-11 17:24:39 -03:00
parent db3a6c05cc
commit 4e659c3ddd
10 changed files with 1011 additions and 111 deletions

View File

@ -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

2
debian/control vendored
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -0,0 +1 @@
from . import transform, compose # noqa: F401

417
kibot/svgutils/compose.py Normal file
View File

@ -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)

View File

@ -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

435
kibot/svgutils/transform.py Normal file
View File

@ -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 ``<text>`` 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 ``<image>`` 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 ``<path>`` 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 ``<g>`` 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 ``<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
``<svg>`` tag.
Returns
-------
GroupElement
All elements of the figure without the ``<svg>`` 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()
<svgutils.transform.GroupElement object at ...>
"""
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

View File

@ -13,6 +13,7 @@ outputs:
# drill_marks: small
title: Chau
# plot_sheet_reference: false
format: 'SVG'
pages:
- # monochrome: true
scaling: 2.0