Rendering SVG with KiCad scripting API

The adventure of rendering SVGs with KiCad pcbnew python api. What I wanted to do was pretty simple, at least I thought, I wanted to replicate the comportment of the "Export → SVG" button in KiCad.

Rendering SVG with KiCad scripting API

While developing my PCBTAGS project, I had to generate previews of pcb and I decided to use KiCad as a backbone. Let me tell you, it was quite an adventure to render them with KiCad pcbnew python api. While this API is really cool, it also has some issues that I had the pleasure to discover the hard way.

What I wanted to do was pretty simple, at least I thought, I wanted to replicate the comportment of the "Export → SVG" button in KiCad.
Pretty easy no? Load the board, create a plot controller change a few options, and we're ready. And this where it went haywire:

import pcbnew  
filename = "board.kicad_pcb"  
output_dir = "output/"  
  
board = pcbnew.LoadBoard(filename)  
  
plot_controller = pcbnew.PLOT_CONTROLLER(board)  
plot_options = plot_controller.GetPlotOptions()  
  
plot_options.SetOutputDirectory(output_dir)  
plot_options.SetPlotFrameRef(False)  
plot_options.SetDrillMarksType(pcbnew.PCB_PLOT_PARAMS.FULL_DRILL_SHAPE)  
plot_options.SetSkipPlotNPTH_Pads(False)  
plot_options.SetMirror(False)  
plot_options.SetFormat(pcbnew.PLOT_FORMAT_SVG)  
plot_options.SetSvgPrecision(4, False)  
plot_options.SetPlotViaOnMaskLayer(True)  
  
plot_controller.OpenPlotfile("mask", pcbnew.PLOT_FORMAT_SVG, "Top mask layer")  
plot_controller.SetColorMode(True)  
plot_controller.SetLayer(pcbnew.F_Mask)  
plot_controller.PlotLayer()  
plot_controller.ClosePlot()  

Multiple issues with this:

  • My board has traces in the negative as I like to center around 0 this means the half SVG simply isn't displayed
  • The render is very tiny for some reason
  • Hard to notice but despite having SetColorMode set to True, there isn't any colour here

First issue / Centering around 0

This, is in retrospect, pretty easy to fix but a nightmare to find.

The pcbnew lib exposes the ComputeBoundingBox function and (luckily) the EDA_RECT class.

To get the center point for the board, we can do:

pcb_bounding_box = board.ComputeBoundingBox()  
print("origin", pcb_bounding_box.GetOrigin())  
print("height", pcb_bounding_box.GetHeight())  
print("width", pcb_bounding_box.GetWidth())  

Then we can enable the use of the auxiliary origin and set it to the origin of the pcb bounding box:

plot_options.SetUseAuxOrigin(True)  
board.GetDesignSettings().SetAuxOrigin(pcb_bounding_box.GetOrigin())  

First issue solved

Second issue / Image size

The issue should be elementary to solve. We know that the large SVG is caused by the renderer using the page format (At least I discovered it after a few hours of reading the source code)

Thanks to the bounding box from the first issue, we know exactly what size the output should, so let's change it.

According to the documentation, the BOARD class has a function called GetPageSettings and I know from reading the source code, that GetPageSettings returns a pointer to a PAGE_INFO class that holds the page size. So one would think that we could do something like this (at least that's what is done in the GUI code when you click "Export → SVG"):

currpageInfo = board.GetPageSettings()  
currpageInfo.SetWidthMils(int(pcb_bounding_box.GetWidth() / pcbnew.IU_PER_MILS))  
currpageInfo.SetHeightMils(int(pcb_bounding_box.GetHeight() / pcbnew.IU_PER_MILS))  
board.SetPageSettings(currpageInfo)  

But no, no, no, the PAGE_INFO class doesn't have a swig proxy in the pcbnew file, so you actually can't modify it. You can use it, but cannot call or access anything inside (the only statements posing an issue are SetWidthMils and SetHeightMils)

Traceback (most recent call last):  
 File ".\main.py", line 60, in generate
    k = generator.generate(options.canvas, options.color, **options.options)
 File ".\generators\spotify\main.py", line 281, in generate
    svgs = pcb2svg.generate_svg_from_gerber_and_drill(kicad_pcb_file.name, theme=color)
 File ".\tools\kicad\pcb2svg.py", line 281, in generate_svg_from_gerber_and_drill
    currpageInfo.SetWidthMils(int(pcb_bounding_box.GetWidth() / pcbnew.IU_PER_MILS))
 AttributeError: 'SwigPyObject' object has no attribute 'SetWidthMils'    

So, how do you bypass that? By modifying the exported SVG, of course.

Looking at an SVG generated by the GUI, we can deduce the values that would actually need replacing from the bounding box

IU_PER_MM = pcbnew.IU_PER_MILS / 2.54 * 1000  
VIEW_BOX_DIVIDER = 100  # Why that value? Wish I knew  
new_svg_attributes = {  
    "width": f"{round(pcb_bounding_box.GetWidth() / IU_PER_MM, 5)}cm", 
    "height": f"{round(pcb_bounding_box.GetHeight() / IU_PER_MM, 5)}cm", 
    "viewBox": f"0 0 {int(pcb_bounding_box.GetWidth() / VIEW_BOX_DIVIDER)} {int(pcb_bounding_box.GetHeight() / VIEW_BOX_DIVIDER)}",
}    

Thereafter, we can use python XML library to properly edit the attributes of the SVG (i.e., not a brute force .replace)

import xml.etree.ElementTree as xml_et  
  
tree = xml_et.parse("output/board-mask.svg")  
root = tree.getroot()  
  
for attr, value in new_svg_attributes.items():  
    root.attrib[attr] = value  
svg = xml_et.tostring(root, encoding='utf8', method='xml')  
print(svg)    

And success we now have a black & white SVG

Third issue / Colours

You would think that using SetColorMode(True) would actually enable colours. Why would that be the case, though? Why would it be easy?
As with the first issue, it's in retrospect a pretty easy fix.

Turns out that you need to tell the thing to use the damn default colour scheme because why a default colour scheme would be used by default.

settings_manager = pcbnew.GetSettingsManager()  
color_settings = settings_manager.GetColorSettings()  
plot_options.SetColorSettings(color_settings)  

Success we now have ugly colours but at least they are there

Once again, one would think that we could use the color_settings var to change the colours, but that would be too easy. The COLOR_SETTINGS class doesn't have a swig proxy in the pcbnew, either. Great.
So to modify the colours, we need to pull out the big guns and use .replace on the SVG to get colours that are somewhat OK.

After a bit of poking around in the SVG, the colours are these (didn't bother with the silkscreen for now):

KICAD_THEME_SEARCH = {  
 "top_silkscreen": "-----unknown-----", 
 "top_mask": "D864FF", 
 "top_layer": "C83434", 
 "edge_cuts": "D0D2CD", 
 "bottom_layer": "C83434",
 "bottom_mask": "D864FF", 
 "bottom_silkscreen": "-----unknown-----", 
 "drill": "ECECEC",
}  

We can then use a bit of code to replace everything:

top_layer = top_layer\  
 .replace(KICAD_THEME_SEARCH["top_silkscreen"].encode("utf8"), theme["top_silkscreen"].encode("utf8"))\ 
 .replace(KICAD_THEME_SEARCH["top_mask"].encode("utf8"), theme["top_layer"].encode("utf8"))\
 .replace(KICAD_THEME_SEARCH["top_layer"].encode("utf8"), theme["top_mask"].encode("utf8"))\
 .replace(KICAD_THEME_SEARCH["edge_cuts"].encode("utf8"), theme["edge_cuts"].encode("utf8"))\
 .replace(KICAD_THEME_SEARCH["drill"].encode("utf8"), theme["drill"].encode("utf8"))

bottom_layer = bottom_layer\  
 .replace(KICAD_THEME_SEARCH["bottom_silkscreen"].encode("utf8"), theme["bottom_silkscreen"].encode("utf8"))\
 .replace(KICAD_THEME_SEARCH["bottom_mask"].encode("utf8"), theme["bottom_layer"].encode("utf8"))\
 .replace(KICAD_THEME_SEARCH["bottom_layer"].encode("utf8"), theme["bottom_mask"].encode("utf8"))\
 .replace(KICAD_THEME_SEARCH["edge_cuts"].encode("utf8"), theme["edge_cuts"].encode("utf8"))\
 .replace(KICAD_THEME_SEARCH["drill"].encode("utf8"), theme["drill"].encode("utf8"))  )

You can note that, as the mask layer isn't actually a mask in the SVG but only a path, so I need to invert top_mask and top_layer. It isn't great but works for now as I wanted to avoid dealing with it any more.

And finally, we have our SVG (rendered with the black ENIG theme)

Conclusion

This may appear elementary, but doing things without any proper docs and with APIs that lacks some features can take quite a while. Getting to this state took at somewhere between 8 and 10 hours of reading the documentation, reading the source code and trial & error.

I have great respect for the KiCad devs and while it might look a lot like a rant towards KiCad, it is not. I realize that generating SVG from the python API probably isn't the principal use of the lib.

In my opinion, an API layer should expose everything that is needed. A few parts don't have a swig proxy and this is a pain to get around.

But then again, KiCad is free and open-source you can't rant at the devs (or anything really), it's a fantastic tool. If I had more time, I could implement it and ask to merge it upstream and solve it for everyone. However, I simply don't have the time.