KiBot/kibot/out_pcb_print.py

992 lines
42 KiB
Python

# -*- coding: utf-8 -*-
# Copyright (c) 2022 Salvador E. Tropea
# Copyright (c) 2022 Instituto Nacional de Tecnología Industrial
# Copyright (c) 2022 Albin Dennevi (create_pdf_from_pages)
# License: GPL-3.0
# Project: KiBot (formerly KiPlot)
# Base idea: https://gitlab.com/dennevi/Board2Pdf/ (Released as Public Domain)
import re
import os
import subprocess
from pcbnew import B_Cu, F_Cu, FromMM, IsCopperLayer, PLOT_CONTROLLER, PLOT_FORMAT_SVG, wxSize, F_Mask, B_Mask
from shutil import rmtree, which
from tempfile import NamedTemporaryFile, 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 .kicad.worksheet import Worksheet, WksError
from .kicad.config import KiConf
from .kicad.v5_sch import SchError
from .kicad.pcb import PCB
from .misc import CMD_PCBNEW_PRINT_LAYERS, URL_PCBNEW_PRINT_LAYERS, PDF_PCB_PRINT, MISSING_TOOL, W_PDMASKFAIL
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
from .__main__ import __version__
from . import PyPDF2
from . import log
logger = log.get_logger()
SVG2PDF = 'rsvg-convert'
PDF2PS = 'pdf2ps'
VIATYPE_THROUGH = 3
VIATYPE_BLIND_BURIED = 2
VIATYPE_MICROVIA = 1
POLY_FILL_STYLE = ("fill:{0}; fill-opacity:1.0; stroke:{0}; stroke-width:1; stroke-opacity:1; stroke-linecap:round; "
"stroke-linejoin:round;fill-rule:evenodd;")
def _run_command(cmd):
logger.debug('- Executing: '+str(cmd))
try:
cmd_output = subprocess.check_output(cmd, stderr=subprocess.STDOUT)
except subprocess.CalledProcessError as e:
logger.error('Failed to run %s, error %d', cmd[0], e.returncode)
if e.output:
logger.debug('Output from command: '+e.output.decode())
exit(PDF_PCB_PRINT)
if cmd_output.strip():
logger.debug('- Output from command:\n'+cmd_output.decode())
def hex_to_rgb(value):
""" Return (red, green, blue) in float between 0-1 for the color given as #rrggbb. """
value = value.lstrip('#')
rgb = tuple(int(value[i:i+2], 16) for i in range(0, 6, 2))
rgb = (rgb[0]/255, rgb[1]/255, rgb[2]/255)
alpha = int(value[6:], 16)/255 if len(value) == 8 else 1.0
return rgb, alpha
def to_gray(color):
avg = (color[0]+color[1]+color[2])/3
return (avg, avg, avg)
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 load_svg(file, color, colored_holes, holes_color, monochrome):
with open(file, 'rt') as f:
content = f.read()
color = color[:7]
if monochrome:
color = to_gray_hex(color)
holes_color = to_gray_hex(holes_color)
if colored_holes:
content = content.replace('#FFFFFF', '**black_hole**')
if color != '#000000':
# Files plotted
content = content.replace('#000000', color)
# Files generated by "Print"
content = content.replace('stroke:rgb(0%,0%,0%)', 'stroke:'+color)
if colored_holes:
content = content.replace('**black_hole**', holes_color)
return content
def get_width(svg):
""" Finds the width in viewBox units """
return float(svg.root.get('viewBox').split(' ')[2])
def to_inches(w):
val = float(w[:-2])
units = w[-2:]
if units == 'cm':
return val/2.54
if units == 'pt':
return val/72.0
# Currently impossible for KiCad
return val
def create_pdf_from_pages(input_files, output_fn):
output = PyPDF2.PdfFileWriter()
# Collect all pages
open_files = []
er = None
for filename in input_files:
try:
file = open(filename, 'rb')
open_files.append(file)
pdf_reader = PyPDF2.PdfFileReader(file)
page_obj = pdf_reader.getPage(0)
page_obj.compressContentStreams()
output.addPage(page_obj)
except (IOError, ValueError, EOFError) as e:
er = str(e)
if er:
raise KiPlotConfigurationError('Error reading `{}` ({})'.format(filename, er))
# Write all pages to a file
pdf_output = None
try:
pdf_output = open(output_fn, '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_fn, er))
# Close the files
for f in open_files:
f.close()
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 svg_to_png(input_folder, svg_file, png_file, width):
cmd = [SVG2PDF, '-w', str(width), '-f', 'png', '-o', os.path.join(input_folder, png_file),
os.path.join(input_folder, svg_file)]
_run_command(cmd)
def svg_to_eps(input_folder, svg_file, eps_file):
cmd = [SVG2PDF, '-d', '72', '-p', '72', '-f', 'eps', '-o', os.path.join(input_folder, eps_file),
os.path.join(input_folder, svg_file)]
_run_command(cmd)
def pdf_to_ps(ps_file, output):
cmd = [PDF2PS, ps_file, output]
_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(os.path.join(input_folder, pdf_file))
create_pdf_from_pages(svg_files, output_fn)
class LayerOptions(Layer):
""" Data for a layer """
def __init__(self):
super().__init__()
self._unkown_is_error = True
with document:
self.color = ""
""" Color used for this layer """
self.plot_footprint_refs = True
""" Include the footprint references """
self.plot_footprint_values = True
""" Include the footprint values """
self.force_plot_invisible_refs_vals = False
""" Include references and values even when they are marked as invisible """
def config(self, parent):
super().config(parent)
if self.color:
self.validate_color('color')
class PagesOptions(Optionable):
""" One page of the output document """
def __init__(self):
super().__init__()
self._unkown_is_error = True
with document:
self.mirror = False
""" Print mirrored (X axis inverted) """
self.monochrome = False
""" Print in gray scale """
self.scaling = None
""" [number=1.0] Scale factor (0 means autoscaling)"""
self.title = ''
""" Text used to replace the sheet title. %VALUE expansions are allowed.
If it starts with `+` the text is concatenated """
self.sheet = 'Assembly'
""" Text to use for the `sheet` in the title block """
self.sheet_reference_color = ''
""" Color to use for the frame and title block """
self.line_width = 0.1
""" [0.02,2] For objects without width [mm] (KiCad 5) """
self.negative_plot = False
""" Invert black and white. Only useful for a single layer """
self.exclude_pads_from_silkscreen = False
""" Do not plot the component pads in the silk screen (KiCad 5.x only) """
self.tent_vias = True
""" Cover the vias """
self.colored_holes = True
""" Change the drill holes to be colored instead of white """
self.holes_color = '#000000'
""" Color used for the holes when `colored_holes` is enabled """
self.sort_layers = False
""" Try to sort the layers in the same order that uses KiCad for printing """
self.layers = LayerOptions
""" [list(dict)|list(string)|string] List of layers printed in this page.
Order is important, the last goes on top """
self._scaling_example = 1.0
def config(self, parent):
super().config(parent)
if isinstance(self.layers, type):
raise KiPlotConfigurationError("Missing `layers` list")
# Fill the ID member for all the layers
self.layers = LayerOptions.solve(self.layers)
if self.sort_layers:
self.layers.sort(key=lambda x: get_priority(x._id), reverse=True)
if self.sheet_reference_color:
self.validate_color('sheet_reference_color')
if self.holes_color:
self.validate_color('holes_color')
if self.scaling is None:
self.scaling = parent.scaling
class PCB_PrintOptions(VariantOptions):
# Mappings to KiCad config values. They should be the same used in drill_marks.py
_drill_marks_map = {'none': 0, 'small': 1, 'full': 2}
_pad_colors = {'pad_color': 'pad_through_hole',
'via_color': 'via_through',
'micro_via_color': 'via_micro',
'blind_via_color': 'via_blind_buried'}
def __init__(self):
with document:
self.output_name = None
""" {output} """
self.output = GS.def_global_output
""" Filename for the output (%i=assembly, %x=pdf)/(%i=assembly_page_NN, %x=svg)"""
self.hide_excluded = False
""" Hide components in the Fab layer that are marked as excluded by a variant """
self._drill_marks = 'full'
""" What to use to indicate the drill places, can be none, small or full (for real scale) """
self.color_theme = '_builtin_classic'
""" Selects the color theme. Only applies to KiCad 6.
To use the KiCad 6 default colors select `_builtin_default`.
Usually user colors are stored as `user`, but you can give it another name """
self.plot_sheet_reference = True
""" Include the title-block (worksheet, frame, etc.) """
self.sheet_reference_layout = ''
""" Worksheet file (.kicad_wks) to use. Leave empty to use the one specified in the project """
self.frame_plot_mechanism = 'internal'
""" [gui,internal,plot] Plotting the frame from Python is problematic.
This option selects a workaround strategy.
gui: uses KiCad GUI to do it. Is slow but you get the correct frame.
But it can't keep track of page numbers.
internal: KiBot loads the `.kicad_wks` and does the drawing work.
Best option, but some details are different from what the GUI generates.
plot: uses KiCad Python API. Only available for KiCad 6.
You get the default frame and some substitutions doesn't work """
self.pages = PagesOptions
""" [list(dict)] List of pages to include in the output document.
Each page contains one or more layers of the PCB """
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,PNG,EPS,PS] Format for the output file/s.
Note that for PS you need `ghostscript` which isn't part of the default docker images """
self.png_width = 1280
""" Width of the PNG in pixels """
self.colored_pads = True
""" Plot through-hole in a different color. Like KiCad GUI does """
self.pad_color = ''
""" Color used for `colored_pads` """
self.colored_vias = True
""" Plot vias in a different color. Like KiCad GUI does """
self.via_color = ''
""" Color used for through-hole `colored_vias` """
self.micro_via_color = ''
""" Color used for micro `colored_vias` """
self.blind_via_color = ''
""" Color used for blind/buried `colored_vias` """
self.keep_temporal_files = False
""" Store the temporal page and layer files in the output dir and don't delete them """
self.force_edge_cuts = False
""" Add the `Edge.Cuts` to all the pages """
self.scaling = 1.0
""" Default scale factor (0 means autoscaling)"""
self.realistic_solder_mask = True
""" Try to draw the solder mask as a real solder mask, not the negative used for fabrication.
In order to get a good looking select a color with transparency, i.e. '#14332440'.
PcbDraw must be installed in order to use this option """
super().__init__()
self._expand_id = 'assembly'
@property
def drill_marks(self):
return self._drill_marks
@drill_marks.setter
def drill_marks(self, val):
if val not in self._drill_marks_map:
raise KiPlotConfigurationError("Unknown drill mark type: {}".format(val))
self._drill_marks = val
def config(self, parent):
super().config(parent)
if isinstance(self.pages, type):
raise KiPlotConfigurationError("Missing `pages` list")
self._color_theme = load_color_theme(self.color_theme)
if self._color_theme is None:
raise KiPlotConfigurationError("Unable to load `{}` color theme".format(self.color_theme))
# Assign a color if none was defined
layer_id2color = self._color_theme.layer_id2color
for p in self.pages:
for la in p.layers:
if not la.color:
if la._id in layer_id2color:
la.color = layer_id2color[la._id]
else:
la.color = "#000000"
self._drill_marks = PCB_PrintOptions._drill_marks_map[self._drill_marks]
self._expand_ext = self.format.lower()
for member, color in self._pad_colors.items():
if getattr(self, member):
self.validate_color(member)
else:
setattr(self, member, getattr(self._color_theme, color))
if self.frame_plot_mechanism == 'plot' and GS.ki5():
raise KiPlotConfigurationError("You can't use `plot` for `frame_plot_mechanism` with KiCad 5. It will crash.")
KiConf.init(GS.pcb_file)
if self.sheet_reference_layout:
self.sheet_reference_layout = KiConf.expand_env(self.sheet_reference_layout)
if not os.path.isfile(self.sheet_reference_layout):
raise KiPlotConfigurationError("Missing page layout file: "+self.sheet_reference_layout)
def filter_components(self):
if not self._comps:
return
comps_hash = self.get_refs_hash()
self.cross_modules(GS.board, comps_hash)
self.remove_paste_and_glue(GS.board, comps_hash)
if self.hide_excluded:
self.remove_fab(GS.board, comps_hash)
def unfilter_components(self):
if not self._comps:
return
comps_hash = self.get_refs_hash()
self.uncross_modules(GS.board, comps_hash)
self.restore_paste_and_glue(GS.board, comps_hash)
if self.hide_excluded:
self.restore_fab(GS.board, comps_hash)
def get_targets(self, out_dir):
if self.format in ['SVG', 'PNG', 'EPS']:
files = []
for n in range(len(self.pages)):
id = self._expand_id+('_page_%02d' % (n+1))
files.append(self.expand_filename(out_dir, self.output, id, self._expand_ext))
return files
return [self._parent.expand_filename(out_dir, self.output)]
def clear_layer(self, layer):
tmp_layer = GS.board.GetLayerID(GS.work_layer)
cleared_layer = GS.board.GetLayerID(layer)
moved = []
for g in GS.board.GetDrawings():
if g.GetLayer() == cleared_layer:
g.SetLayer(tmp_layer)
moved.append(g)
for m in GS.get_modules():
for gi in m.GraphicalItems():
if gi.GetLayer() == cleared_layer:
gi.SetLayer(tmp_layer)
moved.append(gi)
self.moved_items = moved
self.cleared_layer = cleared_layer
def restore_layer(self):
for g in self.moved_items:
g.SetLayer(self.cleared_layer)
def plot_frame_api(self, pc, po, p):
""" KiCad 6 can plot the frame because it loads the worksheet format.
But not the one from the project, just a default """
self.clear_layer('Edge.Cuts')
po.SetPlotFrameRef(True)
po.SetScale(1.0)
po.SetNegative(False)
pc.SetLayer(self.cleared_layer)
pc.OpenPlotfile('frame', PLOT_FORMAT_SVG, p.sheet)
pc.PlotLayer()
pc.ClosePlot()
self.restore_layer()
def fill_kicad_vars(self, page, pages, p):
vars = {}
vars['KICAD_VERSION'] = 'KiCad E.D.A. '+GS.kicad_version+' + KiBot v'+__version__
vars['#'] = str(page)
vars['##'] = str(pages)
GS.load_pcb_title_block()
for num in range(9):
vars['COMMENT'+str(num+1)] = GS.pcb_com[num]
vars['COMPANY'] = GS.pcb_comp
vars['ISSUE_DATE'] = GS.pcb_date
vars['REVISION'] = GS.pcb_rev
# The set_title member already took care of modifying the board value
tb = GS.board.GetTitleBlock()
vars['TITLE'] = tb.GetTitle()
vars['FILENAME'] = GS.pcb_basename+'.kicad_pcb'
vars['SHEETNAME'] = p.sheet
layer = ''
for la in p.layers:
if len(layer):
layer += '+'
layer = layer+la.layer
vars['LAYER'] = layer
vars['PAPER'] = self.paper
return vars
def plot_frame_internal(self, pc, po, p, page, pages):
""" Here we plot the frame manually """
self.clear_layer('Edge.Cuts')
po.SetPlotFrameRef(False)
po.SetScale(1.0)
po.SetNegative(False)
pc.SetLayer(self.cleared_layer)
# Load the WKS
error = None
try:
ws = Worksheet.load(self.layout)
except (WksError, SchError) as e:
error = str(e)
if error:
raise KiPlotConfigurationError('Error reading `{}` ({})'.format(self.layout, error))
tb_vars = self.fill_kicad_vars(page, pages, p)
ws.draw(GS.board, self.cleared_layer, page, self.paper_w, self.paper_h, tb_vars)
pc.OpenPlotfile('frame', PLOT_FORMAT_SVG, p.sheet)
pc.PlotLayer()
pc.ClosePlot()
ws.undraw(GS.board)
self.restore_layer()
# We need to plot the images in a separated pass
self.last_worksheet = ws
def plot_frame_gui(self, dir_name, layer='Edge.Cuts'):
""" KiCad 5 crashes if we try to print the frame.
So we print a frame using pcbnew_do export.
We use SVG output to then generate a vectorized PDF. """
output = os.path.join(dir_name, GS.pcb_basename+"-frame.svg")
check_script(CMD_PCBNEW_PRINT_LAYERS, URL_PCBNEW_PRINT_LAYERS, '1.6.7')
# Move all the drawings away
# KiCad 5 always prints Edge.Cuts, so we make it empty
self.clear_layer(layer)
# Save the PCB
pcb_name, pcb_dir = self.save_tmp_dir_board('pcb_print')
# Restore the layer
self.restore_layer()
# Output file name
cmd = [CMD_PCBNEW_PRINT_LAYERS, 'export', '--output_name', output, '--monochrome', '--svg', '--pads', '0',
pcb_name, dir_name, layer]
cmd, video_remove = add_extra_options(cmd)
# Execute it
ret = exec_with_retry(cmd)
# Remove the temporal PCB
logger.debug('Removing temporal PCB used for frame `{}`'.format(pcb_dir))
rmtree(pcb_dir)
if ret:
logger.error(CMD_PCBNEW_PRINT_LAYERS+' returned %d', ret)
exit(PDF_PCB_PRINT)
if video_remove:
video_name = os.path.join(self.expand_filename_pcb(GS.out_dir), 'pcbnew_export_screencast.ogv')
if os.path.isfile(video_name):
os.remove(video_name)
patch_svg_file(output, remove_bkg=True)
def plot_pads(self, la, pc, p, filelist):
id = la._id
logger.debug('- Plotting pads for layer {} ({})'.format(la.layer, id))
# Make invisible anything but through-hole pads
tmp_layer = GS.board.GetLayerID(GS.work_layer)
moved = []
removed = []
vias = []
wxSize(0, 0)
for m in GS.get_modules():
for gi in m.GraphicalItems():
if gi.GetLayer() == id:
gi.SetLayer(tmp_layer)
moved.append(gi)
for pad in m.Pads():
dr = pad.GetDrillSize()
if dr.x:
continue
layers = pad.GetLayerSet()
layers.removeLayer(id)
pad.SetLayerSet(layers)
removed.append(pad)
for e in GS.board.GetDrawings():
if e.GetLayer() == id:
e.SetLayer(tmp_layer)
moved.append(e)
for e in list(GS.board.Zones()):
if e.GetLayer() == id:
e.SetLayer(tmp_layer)
moved.append(e)
via_type = 'VIA' if GS.ki5() else 'PCB_VIA'
for e in GS.board.GetTracks():
if e.GetClass() == via_type:
vias.append((e, e.GetDrill(), e.GetWidth()))
e.SetDrill(0)
e.SetWidth(0)
elif e.GetLayer() == id:
e.SetLayer(tmp_layer)
moved.append(e)
# Plot the layer
# pc.SetLayer(id) already selected
suffix = la.suffix+'_pads'
pc.OpenPlotfile(suffix, PLOT_FORMAT_SVG, p.sheet)
pc.PlotLayer()
pc.ClosePlot()
# Restore everything
for e in moved:
e.SetLayer(id)
for pad in removed:
layers = pad.GetLayerSet()
layers.addLayer(id)
pad.SetLayerSet(layers)
for (via, drill, width) in vias:
via.SetDrill(drill)
via.SetWidth(width)
# Add it to the list
filelist.append((GS.pcb_basename+"-"+suffix+".svg", self.pad_color))
def plot_vias(self, la, pc, p, filelist, via_t, via_c):
id = la._id
logger.debug('- Plotting vias for layer {} ({})'.format(la.layer, id))
# Make invisible anything but vias
tmp_layer = GS.board.GetLayerID(GS.work_layer)
moved = []
removed = []
vias = []
wxSize(0, 0)
for m in GS.get_modules():
for gi in m.GraphicalItems():
if gi.GetLayer() == id:
gi.SetLayer(tmp_layer)
moved.append(gi)
for pad in m.Pads():
layers = pad.GetLayerSet()
layers.removeLayer(id)
pad.SetLayerSet(layers)
removed.append(pad)
for e in GS.board.GetDrawings():
if e.GetLayer() == id:
e.SetLayer(tmp_layer)
moved.append(e)
for e in list(GS.board.Zones()):
if e.GetLayer() == id:
e.SetLayer(tmp_layer)
moved.append(e)
via_type = 'VIA' if GS.ki5() else 'PCB_VIA'
for e in GS.board.GetTracks():
if e.GetClass() == via_type:
if e.GetViaType() == via_t:
# Include it, but ...
if not e.IsOnLayer(id):
# This is a via that doesn't drill this layer
# Lamentably KiCad will draw a drill here
# So we create a "patch" for the hole
top = e.TopLayer()
bottom = e.BottomLayer()
w = e.GetWidth()
d = e.GetDrill()
vias.append((e, d, w, top, bottom))
e.SetWidth(d)
e.SetDrill(1)
e.SetTopLayer(F_Cu)
e.SetBottomLayer(B_Cu)
else:
top = e.TopLayer()
bottom = e.BottomLayer()
w = e.GetWidth()
d = e.GetDrill()
vias.append((e, d, w, top, bottom))
e.SetWidth(0)
elif e.GetLayer() == id:
e.SetLayer(tmp_layer)
moved.append(e)
# Plot the layer
suffix = la.suffix+'_vias_'+str(via_t)
pc.OpenPlotfile(suffix, PLOT_FORMAT_SVG, p.sheet)
pc.PlotLayer()
pc.ClosePlot()
# Restore everything
for e in moved:
e.SetLayer(id)
for pad in removed:
layers = pad.GetLayerSet()
layers.addLayer(id)
pad.SetLayerSet(layers)
for (via, drill, width, top, bottom) in vias:
via.SetDrill(drill)
via.SetWidth(width)
via.SetTopLayer(top)
via.SetBottomLayer(bottom)
# Add it to the list
filelist.append((GS.pcb_basename+"-"+suffix+".svg", via_c))
def add_frame_images(self, svg, monochrome):
if (not self.plot_sheet_reference or not self.frame_plot_mechanism == 'internal' or
not self.last_worksheet.has_images):
return
if monochrome:
if which('convert') is None:
logger.error('`convert` not installed. install `imagemagick` or equivalent')
exit(MISSING_TOOL)
for img in self.last_worksheet.images:
with NamedTemporaryFile(mode='wb', suffix='.png', delete=False) as f:
f.write(img.data)
fname = f.name
dest = fname.replace('.png', '_gray.png')
_run_command(['convert', fname, '-set', 'colorspace', 'Gray', '-separate', '-average', dest])
with open(dest, 'rb') as f:
img.data = f.read()
os.remove(fname)
os.remove(dest)
self.last_worksheet.add_images_to_svg(svg)
def fill_polygons(self, svg, color):
""" I don't know how to generate filled polygons on KiCad 5.
So here we look for KiCad 5 unfilled polygons and transform them into filled polygons.
Note that all polygons in the frame are filled. """
logger.debug('- Filling KiCad 5 polygons')
cnt = 0
ml_coord = re.compile(r'M(\d+) (\d+) L(\d+) (\d+)')
# Scan the SVG
for e in svg.root:
if e.tag.endswith('}g'):
# This is a graphic
if len(e) < 2:
# Polygons have at least 2 paths
continue
# Check that all elements are paths and that they have the coordinates in 'd'
all_path = True
for c in e:
if not c.tag.endswith('}path') or c.get('d') is None:
all_path = False
break
if all_path:
# Ok, this is a KiCad 5 polygon
# Create a list with all the points
coords = 'M '
all_coords = True
first = True
for c in e:
coord = c.get('d')
res = ml_coord.match(coord)
if not res:
# Discard it if we can't understand the coordinates
all_coords = False
break
coords += res.group(1)+','+res.group(2)+'\n'
if first:
start = res.group(1)+','+res.group(2)
first = False
if all_coords:
# Ok, we have all the points
end = res.group(3)+','+res.group(4)
if start == end:
# Must be a closed polygon
coords += end+'\nZ'
# Make the first a single filled polygon
e[0].set('style', POLY_FILL_STYLE.format(color))
e[0].set('d', coords)
# Remove the rest
for c in e[1:]:
e.remove(c)
cnt = cnt+1
logger.debug('- Filled {} polygons'.format(cnt))
def merge_svg(self, input_folder, input_files, output_folder, output_file, p):
""" Merge all pages into one """
first = True
for (file, color) in input_files:
logger.debug(' - Loading layer file '+file)
file = os.path.join(input_folder, file)
new_layer = fromstring(load_svg(file, color, p.colored_holes, p.holes_color, p.monochrome))
width = get_width(new_layer)
if GS.ki5() and file.endswith('frame.svg'):
if p.monochrome:
color = to_gray_hex(color)
self.fill_polygons(new_layer, color)
if first:
svg_out = new_layer
# This is the width declared at the beginning of the file
base_width = width
phys_width = to_inches(new_layer.width)
first = False
self.add_frame_images(svg_out, p.monochrome)
else:
root = new_layer.getroot()
# Adjust the coordinates of this section to the main width
scale = base_width/width
if scale != 1.0:
logger.debug(' - Scaling {} by {}'.format(file, scale))
for e in root:
e.scale(scale)
svg_out.append([root])
svg_out.save(os.path.join(output_folder, output_file))
return phys_width
def find_paper_size(self):
pcb = PCB.load(GS.pcb_file)
self.paper_w = pcb.paper_w
self.paper_h = pcb.paper_h
self.paper = pcb.paper
def plot_extra_cu(self, id, la, pc, p, filelist):
""" Plot pads and vias to make them different """
if id >= F_Cu and id <= B_Cu:
if self.colored_pads:
self.plot_pads(la, pc, p, filelist)
if self.colored_vias:
self.plot_vias(la, pc, p, filelist, VIATYPE_THROUGH, self.via_color)
self.plot_vias(la, pc, p, filelist, VIATYPE_BLIND_BURIED, self.blind_via_color)
self.plot_vias(la, pc, p, filelist, VIATYPE_MICROVIA, self.micro_via_color)
def plot_realistic_solder_mask(self, id, temp_dir, out_file, color, mirror, scale):
""" Plot the solder mask closer to reality, not the apertures """
if not self.realistic_solder_mask or (id != F_Mask and id != B_Mask):
return
logger.debug('- Plotting realistic solder mask using PcbDraw')
# Check PcbDraw is available
if which('pcbdraw') is None:
logger.error('`pcbdraw` not installed, needed for `realistic_solder_mask`')
exit(MISSING_TOOL)
# Run PcbDraw to make the heavy work (find the Edge.Cuts path and create masks)
pcbdraw_file = os.path.join(temp_dir, out_file.replace('.svg', '-pcbdraw.svg'))
cmd = ['pcbdraw', '--no-warn-back', '-f', '']
if id == B_Mask:
cmd.append('-b')
cmd.extend([GS.pcb_file, pcbdraw_file])
_run_command(cmd)
# Load the SVG created by PcbDraw
with open(pcbdraw_file, 'rt') as f:
svg = fromstring(f.read())
# Load the plot file from KiCad to get the real coordinates system
out_file = os.path.join(temp_dir, out_file)
with open(out_file, 'rt') as f:
svg_kicad = fromstring(f.read())
view_box = svg_kicad.root.get('viewBox')
view_box_elements = view_box.split(' ')
paper_size_iu_x = int(view_box_elements[2])
paper_size_iu_y = int(view_box_elements[3])
# Compute the coordinates translation for mirror
transform = ''
if scale != 1.0 and scale:
# This the autocenter computation used by KiCad
scale_x = scale_y = scale
board_center = GS.board.GetBoundingBox().GetCenter()
offset_x = round((board_center.x*scale-(paper_size_iu_x/2.0))/scale)
offset_y = round((board_center.y*scale-(paper_size_iu_y/2.0))/scale)
if mirror:
scale_x = -scale_x
offset_x += round(paper_size_iu_x/scale)
transform = 'scale({},{}) translate({},{})'.format(scale_x, scale_y, -offset_x, -offset_y)
else:
if mirror:
transform = 'scale(-1,1) translate({},0)'.format(-paper_size_iu_x)
# Filter the PcbDraw SVG to get what we want
defs = None
g = None
for child in svg.root:
if child.tag.endswith('}defs'):
# Keep the cut-off and pads-mask-silkscreen defs
defs = child
logger.debug(' - Found <defs>')
for df in child:
if df.get('id') not in ['cut-off', 'pads-mask-silkscreen']:
child.remove(df)
elif child.tag.endswith('}g') and child.get('id') == "boardContainer":
# Keep the solder mask
g = child
g.set('transform', transform)
g_mask = g[0]
if g_mask.get('clip-path') == "url(#cut-off)" and g_mask.get('mask') == "url(#hole-mask)":
logger.debug(' - Found clip-path')
g_mask.set('mask', "url(#pads-mask-silkscreen)")
for gf in g_mask:
if gf.get('id') != 'substrate-board':
g_mask.remove(gf)
else:
# Apply our color to the solder mask
alpha = 1.0
if len(color) == 9:
alpha = int(color[7:], 16)/255
color = color[:7]
gf.set('style', "fill:{0}; fill-opacity:{1}; stroke:{0};".format(color, alpha))
if g is None or defs is None:
logger.warning(W_PDMASKFAIL+'Failed to extract elements from the PcbDraw SVG')
return
# Adjust the paper to what KiCad used
svg.root.set('width', svg_kicad.root.get('width'))
svg.root.set('height', svg_kicad.root.get('height'))
svg.root.set('viewBox', view_box)
# Save the filtered file
svg.save(out_file)
def set_scaling(self, po, scaling):
if scaling:
po.SetScale(scaling)
return scaling
sz = GS.board.GetBoundingBox().GetSize()
scale_x = FromMM(self.paper_w)/sz.x
scale_y = FromMM(self.paper_h)/sz.y
scale = min(scale_x, scale_y)
po.SetScale(scale)
logger.debug('- Autoscale: {}'.format(scale))
return scale
def generate_output(self, output):
if self.format != 'SVG' and which(SVG2PDF) is None:
logger.error('`{}` not installed. Install `librsvg2-bin` or equivalent'.format(SVG2PDF))
exit(MISSING_TOOL)
if self.format == 'PS' and which(PDF2PS) is None:
logger.error('`{}` not installed. '.format(PDF2PS))
logger.error('Install `librsvg2-bin` or equivalent')
exit(MISSING_TOOL)
output_dir = os.path.dirname(output)
if self.keep_temporal_files:
temp_dir_base = output_dir
else:
temp_dir_base = mkdtemp(prefix='tmp-kibot-pcb_print-')
logger.debug('Starting to generate `{}`'.format(output))
logger.debug('- Temporal dir: {}'.format(temp_dir_base))
self.find_paper_size()
if self.sheet_reference_layout:
layout = self.sheet_reference_layout
else:
# Find the layout file
layout = KiConf.fix_page_layout(GS.pro_file, dry=True)[1]
if not layout or not os.path.isfile(layout):
layout = os.path.abspath(os.path.join(os.path.dirname(__file__), 'kicad_layouts', 'default.kicad_wks'))
logger.debug('- Using layout: '+layout)
self.layout = layout
# Plot options
pc = PLOT_CONTROLLER(GS.board)
po = pc.GetPlotOptions()
# Set General Options:
po.SetExcludeEdgeLayer(True) # We plot it separately
po.SetUseAuxOrigin(False)
po.SetAutoScale(False)
# Helpers for force_edge_cuts
if self.force_edge_cuts:
edge_layer = LayerOptions.create_layer('Edge.Cuts')
edge_id = edge_layer._id
layer_id2color = self._color_theme.layer_id2color
if edge_id in layer_id2color:
edge_layer.color = layer_id2color[edge_id]
else:
edge_layer.color = "#000000"
# Generate the output, page by page
pages = []
for n, p in enumerate(self.pages):
# Use a dir for each page, avoid overwriting files, just for debug purposes
page_str = "%02d" % (n+1)
temp_dir = os.path.join(temp_dir_base, page_str)
os.makedirs(temp_dir, exist_ok=True)
po.SetOutputDirectory(temp_dir)
# Adapt the title
self.set_title(p.title if p.title else self.title)
# 1) Plot all layers to individual PDF files (B&W)
po.SetPlotFrameRef(False) # We plot it separately
po.SetMirror(p.mirror)
p.scaling = self.set_scaling(po, p.scaling)
po.SetNegative(p.negative_plot)
po.SetPlotViaOnMaskLayer(not p.tent_vias)
if GS.ki5():
po.SetLineWidth(FromMM(p.line_width))
po.SetPlotPadsOnSilkLayer(not p.exclude_pads_from_silkscreen)
filelist = []
if self.force_edge_cuts and next(filter(lambda x: x._id == edge_id, p.layers), None) is None:
p.layers.append(edge_layer)
for la in p.layers:
id = la._id
logger.debug('- Plotting layer {} ({})'.format(la.layer, id))
po.SetPlotReference(la.plot_footprint_refs)
po.SetPlotValue(la.plot_footprint_values)
po.SetPlotInvisibleText(la.force_plot_invisible_refs_vals)
# Avoid holes on non-copper layers
po.SetDrillMarksType(self._drill_marks if IsCopperLayer(id) else 0)
pc.SetLayer(id)
pc.OpenPlotfile(la.suffix, PLOT_FORMAT_SVG, p.sheet)
pc.PlotLayer()
pc.ClosePlot()
filelist.append((GS.pcb_basename+"-"+la.suffix+".svg", la.color))
self.plot_extra_cu(id, la, pc, p, filelist)
self.plot_realistic_solder_mask(id, temp_dir, filelist[-1][0], filelist[-1][1], p.mirror, p.scaling)
# 2) Plot the frame using an empty layer and 1.0 scale
po.SetMirror(False)
if self.plot_sheet_reference:
logger.debug('- Plotting the frame')
if self.frame_plot_mechanism == 'gui':
self.plot_frame_gui(temp_dir)
elif self.frame_plot_mechanism == 'plot':
self.plot_frame_api(pc, po, p)
else: # internal
self.plot_frame_internal(pc, po, p, len(pages)+1, len(self.pages))
color = p.sheet_reference_color if p.sheet_reference_color else self._color_theme.pcb_frame
filelist.append((GS.pcb_basename+"-frame.svg", color))
# 3) Stack all layers in one file
if self.format == 'SVG':
id = self._expand_id+('_page_'+page_str)
assembly_file = self.expand_filename(output_dir, self.output, id, self._expand_ext)
else:
assembly_file = GS.pcb_basename+".svg"
logger.debug('- Merging layers to {}'.format(assembly_file))
self.merge_svg(temp_dir, filelist, temp_dir, assembly_file, p)
if self.format in ['PNG', 'EPS']:
id = self._expand_id+('_page_'+page_str)
out_file = self.expand_filename(output_dir, self.output, id, self._expand_ext)
if self.format == 'PNG':
svg_to_png(temp_dir, assembly_file, out_file, self.png_width)
else:
svg_to_eps(temp_dir, assembly_file, out_file)
pages.append(os.path.join(page_str, assembly_file))
self.restore_title()
# Join all pages in one file
if self.format in ['PDF', 'PS']:
logger.debug('- Creating output file {}'.format(output))
if self.format == 'PDF':
create_pdf_from_svg_pages(temp_dir_base, pages, output)
else:
ps_file = os.path.join(temp_dir, GS.pcb_basename+'.ps')
create_pdf_from_svg_pages(temp_dir_base, pages, ps_file)
pdf_to_ps(ps_file, output)
# Remove the temporal files
if not self.keep_temporal_files:
rmtree(temp_dir_base)
logger.debug('Finished generating `{}`'.format(output))
def run(self, output):
super().run(output)
self.filter_components()
self.generate_output(output)
self.unfilter_components()
@output_class
class PCB_Print(BaseOutput): # noqa: F821
""" PCB Print
Prints the PCB using a mechanism that is more flexible than `pdf_pcb_print` and `svg_pcb_print`.
Supports PDF, SVG, PNG, EPS and PS formats.
KiCad 5: including the frame is slow.
KiCad 6: for custom frames use the `enable_ki6_frame_fix`, is slow. """
def __init__(self):
super().__init__()
with document:
self.options = PCB_PrintOptions
""" [dict] Options for the `pcb_print` output """