1288 lines
58 KiB
Python
1288 lines
58 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright (c) 2022-2023 Salvador E. Tropea
|
|
# Copyright (c) 2022-2023 Instituto Nacional de Tecnología Industrial
|
|
# License: GPL-3.0
|
|
# Project: KiBot (formerly KiPlot)
|
|
# Base idea: https://gitlab.com/dennevi/Board2Pdf/ (Released as Public Domain)
|
|
"""
|
|
Dependencies:
|
|
- from: RSVG
|
|
role: Create PDF, PNG, PS and EPS formats
|
|
id: rsvg1
|
|
- from: Ghostscript
|
|
role: Create PNG, PS and EPS formats
|
|
- from: ImageMagick
|
|
role: Create monochrome prints and scaled PNG files
|
|
# The plot_frame_gui() needs KiAuto to print the frame
|
|
- from: KiAuto
|
|
command: pcbnew_do
|
|
role: Print the page frame in GUI mode
|
|
version: 1.6.7
|
|
- from: LXML
|
|
role: mandatory
|
|
"""
|
|
# Direct SVG to EPS conversion is problematic.
|
|
# If we use 72 dpi the page size is ok, but some objects (currently the solder mask) have low resolution.
|
|
# So we create a PDF and then use GS to create the EPS files.
|
|
# - from: RSVG
|
|
# role: Create EPS format
|
|
# version: '2.40'
|
|
# id: rsvg2
|
|
from copy import deepcopy
|
|
import re
|
|
import os
|
|
import subprocess
|
|
import importlib
|
|
from pcbnew import B_Cu, B_Mask, F_Cu, F_Mask, FromMM, IsCopperLayer, LSET, PLOT_CONTROLLER, PLOT_FORMAT_SVG
|
|
import shlex
|
|
from shutil import rmtree
|
|
from tempfile import NamedTemporaryFile, mkdtemp
|
|
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.config import KiConf
|
|
from .kicad.v5_sch import SchError
|
|
from .kicad.pcb import PCB
|
|
from .misc import (PDF_PCB_PRINT, W_PDMASKFAIL, W_MISSTOOL, PCBDRAW_ERR, W_PCBDRAW, VIATYPE_THROUGH, VIATYPE_BLIND_BURIED,
|
|
VIATYPE_MICROVIA)
|
|
from .create_pdf import create_pdf_from_pages
|
|
from .macros import macros, document, output_class # noqa: F401
|
|
from .drill_marks import DRILL_MARKS_MAP, add_drill_marks
|
|
from .layer import Layer, get_priority
|
|
from . import __version__
|
|
from . import log
|
|
|
|
logger = log.get_logger()
|
|
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;")
|
|
DRAWING_LAYERS = ['Dwgs.User', 'Cmts.User', 'Eco1.User', 'Eco2.User']
|
|
EXTRA_LAYERS = ['F.Fab', 'B.Fab', 'F.CrtYd', 'B.CrtYd']
|
|
# Opacity to make something invisible, but not removable
|
|
ALMOST_TRANSPARENT = '0.01'
|
|
# The following modules will be downloaded after we solve the dependencies
|
|
# They are just helpers and we solve their dependencies
|
|
svgutils = None # Will be loaded during dependency check
|
|
kicad_worksheet = None # Also needs svgutils
|
|
|
|
|
|
def pcbdraw_warnings(tag, msg):
|
|
logger.warning('{}({}) {}'.format(W_PCBDRAW, tag, msg))
|
|
|
|
|
|
def _run_command(cmd):
|
|
logger.debug('- Executing: '+shlex.join(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_size(svg):
|
|
""" Finds the width and height in viewBox units """
|
|
view_box = svg.root.get('viewBox').split(' ')
|
|
return float(view_box[2]), float(view_box[3])
|
|
|
|
|
|
class LayerOptions(Layer):
|
|
""" Data for a layer """
|
|
def __init__(self):
|
|
super().__init__()
|
|
self._unknown_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')
|
|
|
|
def copy_extra_from(self, ref):
|
|
""" Copy members specific to LayerOptions """
|
|
self.color = ref.color
|
|
self.plot_footprint_refs = ref.plot_footprint_refs
|
|
self.plot_footprint_values = ref.plot_footprint_values
|
|
self.force_plot_invisible_refs_vals = ref.force_plot_invisible_refs_vals
|
|
|
|
|
|
class PagesOptions(Optionable):
|
|
""" One page of the output document """
|
|
def __init__(self):
|
|
super().__init__()
|
|
self._unknown_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.autoscale_margin_x = None
|
|
""" [number=0] Horizontal margin used for the autoscaling mode [mm] """
|
|
self.autoscale_margin_y = None
|
|
""" [number=0] Vertical margin used for the autoscaling mode [mm] """
|
|
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.
|
|
Pattern (%*) and text variables are expanded.
|
|
In addition when you use `repeat_for_layer` the following patterns are available:
|
|
%ln layer name, %ls layer suffix and %ld layer description """
|
|
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.
|
|
You can reuse other layers lists, some options aren't used here, but they are valid """
|
|
self.page_id = '%02d'
|
|
""" Text to differentiate the pages. Use %d (like in C) to get the page number """
|
|
self.sketch_pads_on_fab_layers = False
|
|
""" Draw only the outline of the pads on the *.Fab layers (KiCad 6+) """
|
|
self.sketch_pad_line_width = 0.1
|
|
""" Line width for the sketched pads [mm], see `sketch_pads_on_fab_layers` (KiCad 6+)
|
|
Note that this value is currently ignored by KiCad (6.0.9) """
|
|
self.repeat_for_layer = ''
|
|
""" Use this page as a pattern to create more pages.
|
|
The other pages will change the layer mentioned here.
|
|
This can be used to generate a page for each copper layer, here you put `F.Cu`.
|
|
See `repeat_layers` """
|
|
self.repeat_layers = LayerOptions
|
|
""" [list(dict)|list(string)|string] List of layers to replace `repeat_for_layer`.
|
|
This can be used to generate a page for each copper layer, here you put `copper` """
|
|
self.repeat_inherit = True
|
|
""" If we will inherit the options of the layer we are replacing.
|
|
Disable it if you specify the options in `repeat_layers`, which is unlikely """
|
|
self._scaling_example = 1.0
|
|
self._autoscale_margin_x_example = 0
|
|
self._autoscale_margin_y_example = 0
|
|
|
|
def expand_sheet_patterns(self, parent, layer=None):
|
|
if layer:
|
|
self.sheet = self.sheet.replace('%ln', layer.layer)
|
|
self.sheet = self.sheet.replace('%ls', layer.suffix)
|
|
self.sheet = self.sheet.replace('%ld', layer.description)
|
|
self.sheet = self.expand_filename_pcb(self.sheet)
|
|
|
|
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
|
|
if self.autoscale_margin_x is None:
|
|
self.autoscale_margin_x = parent.autoscale_margin_x
|
|
if self.autoscale_margin_y is None:
|
|
self.autoscale_margin_y = parent.autoscale_margin_y
|
|
self.sketch_pad_line_width = GS.from_mm(self.sketch_pad_line_width)
|
|
# Validate the repeat_* stuff
|
|
if self.repeat_for_layer:
|
|
layer = Layer.solve(self.repeat_for_layer)
|
|
if len(layer) > 1:
|
|
raise KiPlotConfigurationError('Please specify a single layer for `repeat_for_layer`')
|
|
layer = layer[0]
|
|
self._repeat_for_layer = next(filter(lambda x: x._id == layer._id, self.layers), None)
|
|
if self._repeat_for_layer is None:
|
|
raise KiPlotConfigurationError("Layer `{}` specified in `repeat_for_layer` isn't valid".format(layer))
|
|
self._repeat_for_layer_index = self.layers.index(self._repeat_for_layer)
|
|
if isinstance(self.repeat_layers, type):
|
|
raise KiPlotConfigurationError('`repeat_for_layer` specified, but nothing to repeat')
|
|
self._repeat_layers = LayerOptions.solve(self.repeat_layers)
|
|
|
|
|
|
class PCB_PrintOptions(VariantOptions):
|
|
# Mappings to KiCad config values. They should be the same used in drill_marks.py
|
|
_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/ps)/(%i=assembly_page_NN, %x=svg/png/eps).
|
|
Consult the `page_number_as_extension` and `page_id` options """
|
|
self.page_number_as_extension = False
|
|
""" When enabled the %i is always `assembly`, the %x will be NN.FORMAT (i.e. 01.png).
|
|
Note: page numbers can be customized using the `page_id` option for each page """
|
|
self.hide_excluded = False
|
|
""" Hide components in the Fab layer that are marked as excluded by a variant.
|
|
Affected by global options """
|
|
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
|
|
""" [0,7680] Width of the PNG in pixels. Use 0 to use as many pixels as the DPI needs for the page size """
|
|
self.dpi = 360
|
|
""" [36,1200] Resolution (Dots Per Inch) for the output file. Most objects are vectors, but thing
|
|
like the the solder mask are handled as images by the conversion tools """
|
|
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.forced_edge_cuts_color = ''
|
|
""" Color used for the `force_edge_cuts` option """
|
|
self.scaling = 1.0
|
|
""" *Default scale factor (0 means autoscaling)"""
|
|
self.individual_page_scaling = True
|
|
""" Tell KiCad to apply the scaling for each page as a separated entity.
|
|
Disabling it the pages are coherent and can be superposed """
|
|
self.autoscale_margin_x = 0
|
|
""" Default horizontal margin used for the autoscaling mode [mm] """
|
|
self.autoscale_margin_y = 0
|
|
""" Default vertical margin used for the autoscaling mode [mm] """
|
|
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 """
|
|
self.add_background = False
|
|
""" Add a background to the pages, see `background_color` """
|
|
self.background_color = '#FFFFFF'
|
|
""" Color for the background when `add_background` is enabled """
|
|
self.background_image = ''
|
|
""" Background image, must be an SVG, only when `add_background` is enabled """
|
|
self.svg_precision = 4
|
|
""" [0,6] Scale factor used to represent 1 mm in the SVG (KiCad 6).
|
|
The value is how much zeros has the multiplier (1 mm = 10 power `svg_precision` units).
|
|
Note that for an A4 paper Firefox 91 and Chrome 105 can't handle more than 5 """
|
|
add_drill_marks(self)
|
|
super().__init__()
|
|
self._expand_id = 'assembly'
|
|
|
|
def config(self, parent):
|
|
super().config(parent)
|
|
if isinstance(self.pages, type):
|
|
raise KiPlotConfigurationError("Missing `pages` list")
|
|
# Expand any repeat_for_layer
|
|
pages = []
|
|
for page in self.pages:
|
|
if page.repeat_for_layer:
|
|
for la in page._repeat_layers:
|
|
new_page = deepcopy(page)
|
|
if page.repeat_inherit:
|
|
la.copy_extra_from(page._repeat_for_layer)
|
|
new_page.layers[page._repeat_for_layer_index] = la
|
|
new_page.expand_sheet_patterns(parent, la)
|
|
pages.append(new_page)
|
|
else:
|
|
page.expand_sheet_patterns(parent)
|
|
pages.append(page)
|
|
self.pages = pages
|
|
# Color theme
|
|
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 = 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.forced_edge_cuts_color:
|
|
self.validate_color('forced_edge_cuts_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)
|
|
if self.add_background:
|
|
self.validate_color('background_color')
|
|
if self.background_image:
|
|
if not os.path.isfile(self.background_image):
|
|
raise KiPlotConfigurationError("Missing background image file: "+self.background_image)
|
|
with open(self.background_image, 'rt') as f:
|
|
ln = f.readline()
|
|
if not ln.startswith('<?xml') and not ln.startswith('<!DOCTYPE svg'):
|
|
raise KiPlotConfigurationError("Background image must be an SVG ({})".format(self.background_image))
|
|
|
|
def get_id_and_ext(self, n=None, id='%02d'):
|
|
try:
|
|
pn_str = id % (n+1) if n is not None else id
|
|
except TypeError:
|
|
# If id doesn't contain %d we get this exception
|
|
pn_str = id
|
|
if self.page_number_as_extension:
|
|
return self._expand_id, pn_str+'.'+self._expand_ext
|
|
return self._expand_id+'_page_'+pn_str, self._expand_ext
|
|
|
|
def get_targets(self, out_dir):
|
|
if self.format in ['SVG', 'PNG', 'EPS']:
|
|
files = []
|
|
for n, p in enumerate(self.pages):
|
|
id, ext = self.get_id_and_ext(n, p.page_id)
|
|
files.append(self.expand_filename(out_dir, self.output, id, 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 = kicad_worksheet.Worksheet.load(self.layout)
|
|
except (kicad_worksheet.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")
|
|
command = self.ensure_tool('KiAuto')
|
|
# Move all the drawings away
|
|
# KiCad 5 always prints Edge.Cuts, so we make it empty
|
|
self.clear_layer(layer)
|
|
# Start with a fresh list of files to remove
|
|
cur_files_to_remove = self._files_to_remove
|
|
self._files_to_remove = []
|
|
# Save the PCB
|
|
pcb_name, pcb_dir = self.save_tmp_dir_board('pcb_print')
|
|
self._files_to_remove.append(pcb_dir)
|
|
# Restore the layer
|
|
self.restore_layer()
|
|
# Output file name
|
|
cmd = [command, 'export', '--output_name', output, '--monochrome', '--svg', '--pads', '0',
|
|
pcb_name, dir_name, layer]
|
|
# Execute it
|
|
self.exec_with_retry(self.add_extra_options(cmd, dir_name), PDF_PCB_PRINT)
|
|
# Rotate the paper size if needed and remove the background (or it will be over the drawings)
|
|
patch_svg_file(output, remove_bkg=True, is_portrait=self.paper_portrait)
|
|
self._files_to_remove = cur_files_to_remove
|
|
|
|
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 = []
|
|
zones = GS.zones()
|
|
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()
|
|
if GS.layers_contains(layers, id):
|
|
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()):
|
|
layers = e.GetLayerSet()
|
|
if GS.layers_contains(layers, id):
|
|
zones.append(e)
|
|
e.UnFill()
|
|
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(self.min_w)
|
|
elif e.GetLayer() == id:
|
|
if e.GetWidth():
|
|
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)
|
|
if len(zones):
|
|
GS.fill_zones(GS.board, zones)
|
|
# Add it to the list
|
|
filelist.append((pc.GetPlotFileName(), 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 = []
|
|
zones = GS.zones()
|
|
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()
|
|
if GS.layers_contains(layers, id):
|
|
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()):
|
|
layers = e.GetLayerSet()
|
|
if GS.layers_contains(layers, id):
|
|
zones.append(e)
|
|
e.UnFill()
|
|
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(self.min_w)
|
|
elif e.GetLayer() == id:
|
|
if e.GetWidth():
|
|
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)
|
|
if len(zones):
|
|
GS.fill_zones(GS.board, zones)
|
|
# Add it to the list
|
|
filelist.append((pc.GetPlotFileName(), 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:
|
|
convert_command = self.ensure_tool('ImageMagick')
|
|
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_command, 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, self.svg_precision)
|
|
|
|
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 process_background(self, svg_out, width, height):
|
|
""" Applies the background options """
|
|
if not self.add_background:
|
|
return
|
|
if self.background_image:
|
|
img = svgutils.fromfile(self.background_image)
|
|
w, h = get_size(img)
|
|
root = img.getroot()
|
|
root.moveto(0, 0)
|
|
root.scale(width/w, height/h)
|
|
svg_out.insert([root])
|
|
svg_out.insert(svgutils.RectElement(0, 0, width, height, color=self.background_color))
|
|
|
|
def fix_opacity_for_g(self, e):
|
|
contains_text = False
|
|
for c in e:
|
|
# Adjust the text opacity
|
|
if c.tag.endswith('}text'):
|
|
opacity = c.get('opacity')
|
|
if opacity is not None and opacity == '0' and c.text is not None:
|
|
c.set('opacity', ALMOST_TRANSPARENT)
|
|
c.set('style', 'font-family:monospace')
|
|
contains_text = True
|
|
elif c.tag.endswith('}g'):
|
|
# Process all text inside
|
|
self.fix_opacity_for_g(c)
|
|
# Adjust the graphic opacity
|
|
if contains_text:
|
|
style = e.get('style')
|
|
if style is not None:
|
|
e.set('style', style.replace('fill-opacity:0.0', 'fill-opacity:'+ALMOST_TRANSPARENT))
|
|
|
|
def fix_opacity(self, svg):
|
|
""" Transparent text is discarded by rsvg-convert.
|
|
So we make it almost invisible. """
|
|
logger.debug(' - Making text searchable')
|
|
# Scan the SVG
|
|
for e in svg.root:
|
|
# Look for graphics
|
|
if e.tag.endswith('}g'):
|
|
# Process all text inside
|
|
self.fix_opacity_for_g(e)
|
|
|
|
def merge_svg(self, input_folder, input_files, output_folder, output_file, p):
|
|
""" Merge all layers into one page """
|
|
first = True
|
|
for (file, color) in input_files:
|
|
logger.debug(' - Loading layer file '+file)
|
|
file = os.path.join(input_folder, file)
|
|
new_layer = svgutils.fromstring(load_svg(file, color, p.colored_holes, p.holes_color, p.monochrome))
|
|
width, height = get_size(new_layer)
|
|
# Workaround for polygon fill on KiCad 5
|
|
if GS.ki5 and file.endswith('frame.svg'):
|
|
if p.monochrome:
|
|
color = to_gray_hex(color)
|
|
self.fill_polygons(new_layer, color)
|
|
# Make text searchable
|
|
self.fix_opacity(new_layer)
|
|
if first:
|
|
svg_out = new_layer
|
|
# This is the width declared at the beginning of the file
|
|
base_width = width
|
|
first = False
|
|
self.process_background(svg_out, width, height)
|
|
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))
|
|
|
|
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_portrait = pcb.paper_portrait
|
|
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:
|
|
# Here we force the same bounding box
|
|
# Problem: we will remove items, so the bbox can be affected
|
|
# Solution: we add a couple of points at the edges of the bbox
|
|
bbox = GS.board.GetBoundingBox()
|
|
track1 = GS.create_puntual_track(GS.board, bbox.GetOrigin(), id)
|
|
track2 = GS.create_puntual_track(GS.board, bbox.GetEnd(), id)
|
|
|
|
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)
|
|
|
|
GS.board.Remove(track1)
|
|
GS.board.Remove(track2)
|
|
|
|
def pcbdraw_by_module(self, pcbdraw_file, back):
|
|
self.ensure_tool('LXML')
|
|
from .PcbDraw.plot import PcbPlotter, PlotSubstrate
|
|
# Run PcbDraw to make the heavy work (find the Edge.Cuts path and create masks)
|
|
try:
|
|
plotter = PcbPlotter(GS.board)
|
|
plotter.yield_warning = pcbdraw_warnings
|
|
plotter.render_back = back
|
|
plotter.plot_plan = [PlotSubstrate(only_mask=True)]
|
|
plotter.svg_precision = self.svg_precision
|
|
image = plotter.plot()
|
|
except (RuntimeError, SyntaxError, IOError) as e:
|
|
logger.error('PcbDraw error: '+str(e))
|
|
exit(PCBDRAW_ERR)
|
|
|
|
if GS.debug_level > 1:
|
|
# Save the SVG only for debug purposes
|
|
image.write(pcbdraw_file)
|
|
# Return the SVG as a string
|
|
from lxml.etree import tostring
|
|
return tostring(image).decode()
|
|
|
|
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')
|
|
pcbdraw_file = os.path.join(temp_dir, out_file.replace('.svg', '-pcbdraw.svg'))
|
|
# Create an SVG using PcbDraw engine to generate the solder mask
|
|
svg = svgutils.fromstring(self.pcbdraw_by_module(pcbdraw_file, id == B_Mask))
|
|
# 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 = svgutils.fromstring(f.read())
|
|
view_box = svg_kicad.root.get('viewBox')
|
|
view_box_elements = view_box.split(' ')
|
|
# This is the paper size using the SVG precision
|
|
paper_size_x = int(round(float(view_box_elements[2])))
|
|
paper_size_y = int(round(float(view_box_elements[3])))
|
|
# Compute the coordinates translation for mirror
|
|
transform = ''
|
|
if scale != 1.0 and scale:
|
|
# This is the autocenter computation used by KiCad
|
|
scale_x = scale_y = scale
|
|
board_center = GS.board.GetBoundingBox().GetCenter()
|
|
bcx, bcy = GS.iu_to_svg((board_center.x, board_center.y), self.svg_precision)
|
|
offset_x = GS.svg_round((bcx*scale-(paper_size_x/2.0))/scale)
|
|
offset_y = GS.svg_round((bcy*scale-(paper_size_y/2.0))/scale)
|
|
if mirror:
|
|
scale_x = -scale_x
|
|
offset_x += round(paper_size_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_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}; stroke-width: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
|
|
bbox = GS.board.GetBoundingBox()
|
|
# KiCad 7 workaround, doing GS.board.GetBoundingBox().GetSize() fails
|
|
sz = bbox.GetSize()
|
|
scale_x = FromMM(self.paper_w-self.autoscale_margin_x*2)/sz.x
|
|
scale_y = FromMM(self.paper_h-self.autoscale_margin_y*2)/sz.y
|
|
scale = min(scale_x, scale_y)
|
|
po.SetScale(scale)
|
|
logger.debug('- Autoscale: {}'.format(scale))
|
|
return scale
|
|
|
|
def svg_to_pdf(self, 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
|
|
# We use a 5x scale and then reduce it to maintain the page size
|
|
# Note: rsvg 2.50.3 has this problem 2.54.5 doesn't, so we ensure the size is correct, not a fixed scale
|
|
dpi = str(self.dpi)
|
|
cmd = [self.rsvg_command, '-d', dpi, '-p', dpi, '-f', 'pdf', '-o', os.path.join(input_folder, pdf_file),
|
|
os.path.join(input_folder, svg_file)]
|
|
_run_command(cmd)
|
|
|
|
# We can't control the resolution in this way
|
|
# def svg_to_png(self, input_folder, svg_file, png_file, width):
|
|
# cmd = [self.rsvg_command, '-w', str(width), '-f', 'png', '-o', os.path.join(input_folder, png_file),
|
|
# os.path.join(input_folder, svg_file)]
|
|
# _run_command(cmd)
|
|
|
|
# Poor resolution or wrong page size, using a PDF + GS solves it
|
|
# def svg_to_eps(self, input_folder, svg_file, eps_file):
|
|
# cmd = [self.rsvg_command_eps, '-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 run_gs(self, pdf_file, output, device, use_dpi=False):
|
|
cmd = [self.gs_command, '-q', '-dNOPAUSE', '-dBATCH', '-P-', '-dSAFER', '-sDEVICE='+device,
|
|
'-sOutputFile='+output, '-c', 'save', 'pop', '-f', pdf_file]
|
|
if use_dpi:
|
|
cmd.insert(1, '-r'+str(self.dpi))
|
|
_run_command(cmd)
|
|
|
|
def pdf_to_ps(self, pdf_file, output):
|
|
self.run_gs(pdf_file, output, 'ps2write')
|
|
|
|
def pdf_to_eps(self, pdf_file, output):
|
|
self.run_gs(pdf_file, output, 'eps2write')
|
|
|
|
def pdf_to_png(self, pdf_file, output):
|
|
self.run_gs(pdf_file, output, 'png16m', use_dpi=True)
|
|
if self.png_width:
|
|
# Adjust the width
|
|
convert_command = self.ensure_tool('ImageMagick')
|
|
size = str(self.png_width)+'x'
|
|
for n in range(len(self.pages)):
|
|
file = output % (n+1)
|
|
cmd = [convert_command, file, '-resize', size, file]
|
|
_run_command(cmd)
|
|
|
|
def create_pdf_from_svg_pages(self, input_folder, input_files, output_fn):
|
|
""" Convert individual SVG files into individual PDF files using 360 dpi.
|
|
Then join the individual PDF files into one PDF file scaled to the right page size. """
|
|
svg_files = []
|
|
for svg_file in input_files:
|
|
pdf_file = svg_file.replace('.svg', '.pdf')
|
|
logger.debug('- Creating {} from {}'.format(pdf_file, svg_file))
|
|
self.svg_to_pdf(input_folder, svg_file, pdf_file)
|
|
svg_files.append(os.path.join(input_folder, pdf_file))
|
|
logger.debug('- Joining {} into {} ({}x{})'.format(svg_files, output_fn, self.paper_w, self.paper_h))
|
|
create_pdf_from_pages(svg_files, output_fn, forced_width=self.paper_w)
|
|
|
|
def check_tools(self):
|
|
if self.format != 'SVG':
|
|
self.rsvg_command = self.ensure_tool('rsvg1')
|
|
self.gs_command = self.ensure_tool('Ghostscript')
|
|
# if self.format == 'EPS':
|
|
# self.rsvg_command_eps = self.ensure_tool('rsvg2')
|
|
|
|
def rename_pages(self, output_dir):
|
|
for n, p in enumerate(self.pages):
|
|
id, ext = self.get_id_and_ext(n)
|
|
cur_name = self.expand_filename(output_dir, self.output, id, ext)
|
|
id, ext = self.get_id_and_ext(n, p.page_id)
|
|
user_name = self.expand_filename(output_dir, self.output, id, ext)
|
|
if cur_name != user_name and os.path.isfile(cur_name):
|
|
os.replace(cur_name, user_name)
|
|
|
|
def generate_output(self, output):
|
|
self.check_tools()
|
|
# Avoid KiCad 5 complaining about fake vias diameter == drill == 0
|
|
self.min_w = 2 if GS.ki5 else 0
|
|
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(GS.get_resource_path('kicad_layouts'), 'default.kicad_wks'))
|
|
logger.debug('- Using layout: '+layout)
|
|
self.layout = layout
|
|
# Memorize the list of visible layers
|
|
old_visible = GS.board.GetVisibleLayers()
|
|
# Plot options
|
|
pc = PLOT_CONTROLLER(GS.board)
|
|
po = pc.GetPlotOptions()
|
|
# Set General Options:
|
|
GS.SetExcludeEdgeLayer(po, True) # We plot it separately
|
|
po.SetUseAuxOrigin(False)
|
|
po.SetAutoScale(False)
|
|
GS.SetSvgPrecision(po, self.svg_precision)
|
|
# Helpers for force_edge_cuts
|
|
if self.force_edge_cuts:
|
|
edge_layer = LayerOptions.create_layer('Edge.Cuts')
|
|
edge_id = edge_layer._id
|
|
if self.forced_edge_cuts_color:
|
|
edge_layer.color = self.forced_edge_cuts_color
|
|
else:
|
|
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"
|
|
# Make visible only the layers we need
|
|
# This is very important when scaling, otherwise the results are controlled by the .kicad_prl (See #407)
|
|
if not self.individual_page_scaling:
|
|
vis_layers = LSET()
|
|
for p in self.pages:
|
|
for la in p.layers:
|
|
vis_layers.addLayer(la._id)
|
|
GS.board.SetVisibleLayers(vis_layers)
|
|
# Generate the output, page by page
|
|
pages = []
|
|
for n, p in enumerate(self.pages):
|
|
# Make visible only the layers we need
|
|
# This is very important when scaling, otherwise the results are controlled by the .kicad_prl (See #407)
|
|
if self.individual_page_scaling:
|
|
vis_layers = LSET()
|
|
for la in p.layers:
|
|
vis_layers.addLayer(la._id)
|
|
GS.board.SetVisibleLayers(vis_layers)
|
|
# 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)
|
|
else:
|
|
po.SetSketchPadsOnFabLayers(p.sketch_pads_on_fab_layers)
|
|
po.SetSketchPadLineWidth(p.sketch_pad_line_width)
|
|
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((pc.GetPlotFileName(), 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, ext = self.get_id_and_ext(n, p.page_id)
|
|
assembly_file = self.expand_filename(output_dir, self.output, id, 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)
|
|
pages.append(os.path.join(page_str, assembly_file))
|
|
self.restore_title()
|
|
# Join all pages in one file
|
|
if self.format != 'SVG':
|
|
if self.format == 'PDF':
|
|
logger.debug('- Creating output file {}'.format(output))
|
|
self.create_pdf_from_svg_pages(temp_dir_base, pages, output)
|
|
else:
|
|
logger.debug('- Creating output files')
|
|
# PS and EPS using Ghostscript
|
|
# Create a PDF (but in a temporal place)
|
|
pdf_file = os.path.join(temp_dir, GS.pcb_basename+'_joined.pdf')
|
|
self.create_pdf_from_svg_pages(temp_dir_base, pages, pdf_file)
|
|
if self.format == 'PS':
|
|
# Use GS to create one PS
|
|
self.pdf_to_ps(pdf_file, output)
|
|
else: # EPS and PNG
|
|
id, ext = self.get_id_and_ext()
|
|
out_file = self.expand_filename(output_dir, self.output, id, ext, make_safe=False)
|
|
if self.format == 'EPS':
|
|
# Use GS to create one EPS per page
|
|
self.pdf_to_eps(pdf_file, out_file)
|
|
else:
|
|
# Use GS to create one PNG per page and then scale to the wanted width
|
|
self.pdf_to_png(pdf_file, out_file)
|
|
self.rename_pages(output_dir)
|
|
# Remove the temporal files
|
|
if not self.keep_temporal_files:
|
|
rmtree(temp_dir_base)
|
|
# Restore the list of visible layers
|
|
GS.board.SetVisibleLayers(old_visible)
|
|
logger.debug('Finished generating `{}`'.format(output))
|
|
|
|
def run(self, output):
|
|
super().run(output)
|
|
self.ensure_tool('LXML')
|
|
global svgutils
|
|
svgutils = importlib.import_module('.svgutils.transform', package=__package__)
|
|
global kicad_worksheet
|
|
kicad_worksheet = importlib.import_module('.kicad.worksheet', package=__package__)
|
|
self.filter_pcb_components()
|
|
self.generate_output(output)
|
|
self.unfilter_pcb_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 """
|
|
self._category = 'PCB/docs'
|
|
|
|
@staticmethod
|
|
def get_conf_examples(name, layers, templates):
|
|
outs = []
|
|
if len(DRAWING_LAYERS) < 10 and GS.ki6:
|
|
DRAWING_LAYERS.extend(['User.'+str(c+1) for c in range(9)])
|
|
extra = {la._id for la in Layer.solve(EXTRA_LAYERS)}
|
|
disabled = set()
|
|
# Check we can convert SVGs
|
|
if GS.check_tool(name, 'rsvg1') is None:
|
|
logger.warning(W_MISSTOOL+'Disabling most printed formats')
|
|
disabled |= {'PDF', 'PNG', 'EPS', 'PS'}
|
|
# Check we can convert to PS
|
|
if GS.check_tool(name, 'Ghostscript') is None:
|
|
logger.warning(W_MISSTOOL+'Disabling postscript/PDF printed format')
|
|
disabled |= {'PDF', 'PNG', 'EPS', 'PS'}
|
|
if GS.check_tool(name, 'ImageMagick') is None:
|
|
disabled |= {'PNG'}
|
|
# Generate one output for each format
|
|
for fmt in ['PDF', 'SVG', 'PNG', 'EPS', 'PS']:
|
|
if fmt in disabled:
|
|
continue
|
|
gb = {}
|
|
gb['name'] = 'basic_{}_{}'.format(name, fmt.lower())
|
|
gb['comment'] = 'PCB'
|
|
gb['type'] = name
|
|
gb['dir'] = os.path.join('PCB', fmt)
|
|
pages = []
|
|
# One page for each Cu layer
|
|
for la in layers:
|
|
page = None
|
|
mirror = False
|
|
if la.is_copper():
|
|
if la.is_top():
|
|
use_layers = ['F.Cu', 'F.Mask', 'F.Paste', 'F.SilkS', 'Edge.Cuts']
|
|
elif la.is_bottom():
|
|
use_layers = ['B.Cu', 'B.Mask', 'B.Paste', 'B.SilkS', 'Edge.Cuts']
|
|
mirror = True
|
|
else:
|
|
use_layers = [la.layer, 'Edge.Cuts']
|
|
useful = GS.get_useful_layers(use_layers+DRAWING_LAYERS, layers)
|
|
page = {}
|
|
page['layers'] = [{'layer': la.layer} for la in useful]
|
|
elif la._id in extra:
|
|
useful = GS.get_useful_layers([la, 'Edge.Cuts']+DRAWING_LAYERS, layers)
|
|
page = {}
|
|
page['layers'] = [{'layer': la.layer} for la in useful]
|
|
mirror = la.layer.startswith('B.')
|
|
if page:
|
|
if mirror:
|
|
page['mirror'] = True
|
|
if la.description:
|
|
page['sheet'] = la.description
|
|
# Change the color of the masks
|
|
for ly in page['layers']:
|
|
if ly['layer'].endswith('.Mask'):
|
|
ly['color'] = '#14332440'
|
|
pages.append(page)
|
|
ops = {'format': fmt, 'pages': pages, 'keep_temporal_files': True}
|
|
if fmt in ['PNG', 'SVG']:
|
|
ops['add_background'] = True
|
|
gb['options'] = ops
|
|
outs.append(gb)
|
|
return outs
|