KiBot/kibot/kicad/worksheet.py

541 lines
19 KiB
Python

# -*- coding: utf-8 -*-
# Copyright (c) 2022 Salvador E. Tropea
# Copyright (c) 2022 Instituto Nacional de Tecnología Industrial
# License: GPL-3.0
# Project: KiBot (formerly KiPlot)
# KiCad bugs:
# - Text bold doesn't work
# - Shape Line and Rect swapped
"""
KiCad v5/6 Worksheet format.
A basic implementation of the .kicad_wks file format.
Documentation: https://dev-docs.kicad.org/en/file-formats/sexpr-worksheet/
"""
import io
from struct import unpack
from pcbnew import (wxPoint, wxSize, FromMM, GR_TEXT_HJUSTIFY_LEFT, GR_TEXT_HJUSTIFY_RIGHT, GR_TEXT_HJUSTIFY_CENTER,
GR_TEXT_VJUSTIFY_TOP, GR_TEXT_VJUSTIFY_CENTER, GR_TEXT_VJUSTIFY_BOTTOM, wxPointMM)
from ..gs import GS
if not GS.kicad_version_n:
# When running the regression tests we need it
from kibot.__main__ import detect_kicad
detect_kicad()
if GS.ki6:
from pcbnew import PCB_SHAPE, PCB_TEXT, FILL_T_FILLED_SHAPE, SHAPE_T_POLY
else:
from pcbnew import DRAWSEGMENT, TEXTE_PCB
PCB_SHAPE = DRAWSEGMENT
PCB_TEXT = TEXTE_PCB
FILL_T_FILLED_SHAPE = 0
SHAPE_T_POLY = 4
from .sexpdata import load, SExpData
from .v6_sch import (_check_is_symbol_list, _check_float, _check_integer, _check_symbol_value, _check_str, _check_symbol,
_check_relaxed, _get_points, _check_symbol_str)
from ..svgutils.transform import ImageElement, GroupElement
from ..misc import W_WKSVERSION, KICAD5_SVG_SCALE
from .. import log
logger = log.get_logger()
setup = None
# The version of "kicad_wks" used for all tests
SUP_VERSION = 20210606
# Hash to convert KiCad 5 "%X" markers to KiCad 6 "${XXX}" text variables
KI5_2_KI6 = {'K': 'KICAD_VERSION', 'S': '#', 'N': '##', 'C0': 'COMMENT1', 'C1': 'COMMENT2', 'C2': 'COMMENT3',
'C3': 'COMMENT4', 'C4': 'COMMENT5', 'C5': 'COMMENT6', 'C6': 'COMMENT7', 'C7': 'COMMENT8',
'C8': 'COMMENT9', 'Y': 'COMPANY', 'F': 'FILENAME', 'D': 'ISSUE_DATE', 'Z': 'PAPER', 'R': 'REVISION',
'P': 'SHEETNAME', 'T': 'TITLE'}
class WksError(Exception):
pass
def text_from_ki5(text):
for k, v in KI5_2_KI6.items():
text = text.replace('%'+k, '${'+v+'}')
text = text.replace('%%', '%')
return text
def _check_mm(items, pos, name):
return FromMM(_check_float(items, pos, name))
def _get_point(items, pos, sname, name):
value = _check_symbol_value(items, pos, name, sname)
ref = 'rbcorner'
if len(value) > 3:
ref = _check_symbol(value, 3, sname+' reference')
return wxPoint(_check_mm(value, 1, sname+' x'), _check_mm(value, 2, sname+' y')), ref
def _get_size(items, pos, sname, name):
value = _check_symbol_value(items, pos, name, sname)
return wxSize(_check_mm(value, 1, sname+' x'), _check_mm(value, 2, sname+' y'))
class WksSetup(object):
def __init__(self):
super().__init__()
self.text_w = self.text_h = FromMM(1.5)
self.line_width = self.text_line_width = FromMM(0.15)
self.left_margin = self.right_margin = self.top_margin = self.bottom_margin = FromMM(10)
@staticmethod
def parse(items):
s = WksSetup()
for i in items[1:]:
i_type = _check_is_symbol_list(i)
if i_type == 'textsize':
s.text_w = _check_mm(i, 1, 'textsize width')
s.text_h = _check_mm(i, 2, 'textsize height')
elif i_type == 'linewidth':
s.line_width = _check_mm(i, 1, i_type)
elif i_type == 'textlinewidth':
s.text_line_width = _check_mm(i, 1, i_type)
elif i_type in ['left_margin', 'right_margin', 'top_margin', 'bottom_margin']:
setattr(s, i_type, _check_mm(i, 1, i_type))
else:
raise WksError('Unknown setup attribute `{}`'.format(i))
return s
class WksDrawing(object):
c_name = 'base'
def __init__(self):
super().__init__()
self.repeat = 1
self.incrx = self.incry = 0
self.comment = ''
self.name = ''
self.option = ''
def parse_fixed_args(self, items):
""" Default parser for fixed arguments.
Used when no fixed args are used. """
return 1
def parse_specific_args(self, i_type, i, items, offset):
""" Default parser for arguments specific for the class. """
raise WksError('Unknown {} attribute `{}`'.format(self.c_name, i))
@classmethod
def parse(cls, items):
s = cls()
offset = s.parse_fixed_args(items)
for c, i in enumerate(items[offset:]):
i_type = _check_is_symbol_list(i)
if i_type == 'repeat':
s.repeat = _check_integer(i, 1, i_type)
elif i_type in ['incrx', 'incry']:
setattr(s, i_type, _check_mm(i, 1, i_type))
elif i_type == 'comment':
s.comment = _check_str(i, 1, i_type)
elif i_type == 'name':
s.nm = _check_relaxed(i, 1, i_type)
elif i_type == 'option':
# Not documented 2022/04/15
s.option = _check_symbol(i, 1, i_type)
else:
s.parse_specific_args(i_type, i, items, c+offset)
return s
class WksLine(WksDrawing):
c_name = 'line'
def __init__(self):
super().__init__()
self.start = wxPoint(0, 0)
self.start_ref = 'rbcorner'
self.end = wxPoint(0, 0)
self.end_ref = 'rbcorner'
self.line_width = setup.line_width
self.shape = 0 # SHAPE_T_SEGMENT but is 1!
def draw_line(e, p, st, en):
s = PCB_SHAPE()
s.SetShape(e.shape)
s.SetStart(st)
s.SetEnd(en)
s.SetWidth(e.line_width)
s.SetLayer(p.layer)
p.board.Add(s)
p.pcb_items.append(s)
def draw(e, p):
st, sti = p.solve_ref(e.start, e.incrx, e.incry, e.start_ref)
en, eni = p.solve_ref(e.end, e.incrx, e.incry, e.end_ref)
for _ in range(e.repeat):
if GS.ki5 and e.shape:
# Using KiCad 5 I always get a line. Why? What's missing?
e.draw_line(p, st, wxPoint(en.x, st.y))
e.draw_line(p, wxPoint(en.x, st.y), wxPoint(en.x, en.y))
e.draw_line(p, wxPoint(en.x, en.y), wxPoint(st.x, en.y))
e.draw_line(p, wxPoint(st.x, en.y), st)
else:
e.draw_line(p, st, en)
st += sti
en += eni
if p.out_of_margin(st):
break
def parse_specific_args(self, i_type, i, items, offset):
if i_type == 'linewidth':
self.line_width = _check_mm(i, 1, i_type)
elif i_type == 'start':
self.start, self.start_ref = _get_point(items, offset, i_type, self.c_name)
elif i_type == 'end':
self.end, self.end_ref = _get_point(items, offset, i_type, self.c_name)
else:
super().parse_specific_args(i_type, i, items, offset)
class WksRect(WksLine):
c_name = 'rect'
def __init__(self):
super().__init__()
self.shape = 1 # SHAPE_T_RECT but is 0!!!
class WksFont(object):
name = 'font'
def __init__(self):
super().__init__()
self.bold = False
self.italic = False
self.size = wxSize(setup.text_w, setup.text_h)
self.line_width = setup.text_line_width
@staticmethod
def parse(items):
s = WksFont()
for c, i in enumerate(items[1:]):
i_type = _check_is_symbol_list(i, allow_orphan_symbol=('bold', 'italic'))
if i_type == 'bold':
s.bold = True
elif i_type == 'italic':
s.italic = True
elif i_type == 'size':
s.size = _get_size(items, c+1, i_type, WksFont.name)
elif i_type == 'linewidth':
s.line_width = _check_mm(i, 1, i_type)
else:
raise WksError('Unknown font attribute `{}`'.format(i))
return s
class WksText(WksDrawing):
c_name = 'tbtext'
V_JUSTIFY = {'top': GR_TEXT_VJUSTIFY_TOP, 'bottom': GR_TEXT_VJUSTIFY_BOTTOM}
H_JUSTIFY = {'center': GR_TEXT_HJUSTIFY_CENTER, 'right': GR_TEXT_HJUSTIFY_RIGHT, 'left': GR_TEXT_HJUSTIFY_LEFT}
def __init__(self):
super().__init__()
self.pos = wxPoint(0, 0)
self.pos_ref = 'rbcorner'
self.font = WksFont()
self.h_justify = GR_TEXT_HJUSTIFY_LEFT
self.v_justify = GR_TEXT_VJUSTIFY_CENTER
self.text = ''
self.rotate = 0
self.max_len = 0
self.max_height = 0
self.incr_label = 1
def parse_fixed_args(self, items):
self.text = _check_relaxed(items, 1, self.c_name+' text')
return 2
def parse_specific_args(self, i_type, i, items, offset):
if i_type == 'rotate':
self.rotate = _check_float(i, 1, i_type)
elif i_type == 'pos':
self.pos, self.pos_ref = _get_point(items, offset, i_type, self.c_name)
elif i_type == 'justify':
# Not documented 2022/04/15
for index in range(len(i)-1):
val = _check_symbol(i, index+1, i_type)
if val in WksText.V_JUSTIFY:
self.v_justify = WksText.V_JUSTIFY[val]
elif val in WksText.H_JUSTIFY:
self.h_justify = WksText.H_JUSTIFY[val]
else:
raise WksError('Unknown justify value `{}`'.format(val))
elif i_type == 'font':
self.font = WksFont.parse(i)
elif i_type == 'maxlen':
# Not documented 2022/04/15
self.max_len = _check_mm(i, 1, i_type)
elif i_type == 'maxheight':
# Not documented 2022/04/15
self.max_height = _check_mm(i, 1, i_type)
elif i_type == 'incrlabel':
# Not documented 2022/04/15
self.incr_label = _check_integer(i, 1, i_type)
else:
super().parse_specific_args(i_type, i, items, offset)
def draw(e, p):
pos, posi = p.solve_ref(e.pos, e.incrx, e.incry, e.pos_ref)
text = GS.expand_text_variables(e.text, p.tb_vars)
for _ in range(e.repeat):
s = PCB_TEXT(None)
s.SetText(text)
s.SetPosition(pos)
s.SetTextSize(e.font.size)
if e.font.bold:
s.SetBold(True)
thickness = round(e.font.line_width*2)
else:
thickness = e.font.line_width
if hasattr(s, 'SetTextThickness'):
s.SetTextThickness(thickness)
else:
s.SetThickness(thickness)
s.SetHorizJustify(e.h_justify)
s.SetVertJustify(e.v_justify)
s.SetLayer(p.layer)
if e.font.italic:
s.SetItalic(True)
if e.rotate:
s.SetTextAngle(e.rotate*10)
# Adjust the text size to the maximum allowed
if e.max_len > 0:
w = s.GetBoundingBox().GetWidth()
if w > e.max_len:
s.SetTextWidth(round(e.font.size.x*e.max_len/w))
if e.max_height > 0:
h = s.GetBoundingBox().GetHeight()
if h > e.max_height:
s.SetTextHeight(round(e.font.size.y*e.max_height/h))
# Add it to the board and to the list of things to remove
p.board.Add(s)
p.pcb_items.append(s)
# Increment the position
pos += posi
if p.out_of_margin(pos):
break
# Increment the text
# This is what KiCad does ... not very cleaver
if text:
text_end = text[-1]
if text_end.isdigit():
# Only increment the last digit "9" -> "10", "10" -> "11", "19" -> "110"?!!
text_end = str(int(text_end)+e.incr_label)
else:
text_end = chr((ord(text_end)+e.incr_label) % 256)
text = text[:-1]+text_end
else:
text = '?'
class WksPolygon(WksDrawing):
c_name = 'polygon'
def __init__(self):
super().__init__()
self.pos = wxPoint(0, 0)
self.pos_ref = 'rbcorner'
self.rotate = 0
self.line_width = setup.line_width
self.pts = []
def parse_specific_args(self, i_type, i, items, offset):
if i_type == 'rotate':
self.rotate = _check_float(i, 1, i_type)
elif i_type == 'pos':
self.pos, self.pos_ref = _get_point(items, offset, i_type, self.c_name)
elif i_type == 'linewidth':
self.line_width = _check_mm(i, 1, i_type)
elif i_type == 'pts':
self.pts.append([wxPointMM(p.x, p.y) for p in _get_points(i)])
else:
super().parse_specific_args(i_type, i, items, offset)
def draw(e, p):
pos, posi = p.solve_ref(e.pos, e.incrx, e.incry, e.pos_ref)
for _ in range(e.repeat):
for pts in e.pts:
s = PCB_SHAPE()
s.SetShape(SHAPE_T_POLY)
if hasattr(s, 'SetFillMode'):
s.SetFillMode(FILL_T_FILLED_SHAPE)
s.SetPolyPoints([pos+p for p in pts])
s.SetWidth(e.line_width)
s.SetLayer(p.layer)
if e.rotate:
s.Rotate(pos, e.rotate*10)
p.board.Add(s)
p.pcb_items.append(s)
pos += posi
if p.out_of_margin(pos):
break
class WksBitmap(WksDrawing):
c_name = 'bitmap'
def __init__(self):
super().__init__()
self.pos = wxPoint(0, 0)
self.pos_ref = 'rbcorner'
self.scale = 1.0
self.data = b''
def parse_specific_args(self, i_type, i, items, offset):
if i_type == 'pos':
self.pos, self.pos_ref = _get_point(items, offset, i_type, self.c_name)
elif i_type == 'scale':
self.scale = _check_float(i, 1, i_type)
elif i_type == 'pngdata':
for c in range(len(i)-1):
v = _check_symbol_str(i, c+1, self.c_name+' pngdata', 'data')
self.data += bytes([int(c, 16) for c in v.split(' ') if c])
else:
super().parse_specific_args(i_type, i, items, offset)
def draw(e, p):
# Can we draw it using KiCad? I don't see how
# Make a list to be added to the SVG output
p.images.append(e)
def add_to_svg(e, svg, p):
s = e.data
w, h = unpack('>LL', s[16:24])
# For KiCad 300 dpi is 1:1 scale
dpi = 300/e.scale
# Convert pixels to mm and then to KiCad units
w = FromMM(w/dpi*25.4)
h = FromMM(h/dpi*25.4)
# KiCad informs the position for the center of the image
pos, posi = p.solve_ref(e.pos, e.incrx, e.incry, e.pos_ref)
for _ in range(e.repeat):
img = ImageElement(io.BytesIO(s), w, h)
x = pos.x-round(w/2)
y = pos.y-round(h/2)
img.moveto(x, y)
if GS.ki5:
# KiCad 5 uses Inches and with less resolution
img.scale(KICAD5_SVG_SCALE)
# Put the image in a group
g = GroupElement([img])
# Add the group to the SVG
svg.append(g)
# Increment the position
pos += posi
if p.out_of_margin(pos):
break
class Worksheet(object):
def __init__(self, setup, elements, version, generator, has_images):
super().__init__()
self.setup = setup
self.elements = elements
self.version = version
self.generator = generator
self.has_images = has_images
@staticmethod
def load(file):
with open(file, 'rt') as fh:
error = None
try:
wks = load(fh)[0]
except SExpData as e:
error = str(e)
if error:
raise WksError(error)
if not isinstance(wks, list) or (wks[0].value() != 'page_layout' and wks[0].value() != 'kicad_wks'):
raise WksError('No kicad_wks signature')
elements = []
global setup
setup = WksSetup()
version = 0
generator = ''
has_images = False
for e in wks[1:]:
e_type = _check_is_symbol_list(e)
if e_type == 'setup':
setup = WksSetup.parse(e)
elif e_type == 'rect':
elements.append(WksRect.parse(e))
elif e_type == 'line':
elements.append(WksLine.parse(e))
elif e_type == 'tbtext':
obj = WksText.parse(e)
if not version:
obj.text = text_from_ki5(obj.text)
elements.append(obj)
elif e_type == 'polygon':
elements.append(WksPolygon.parse(e))
elif e_type == 'bitmap':
elements.append(WksBitmap.parse(e))
has_images = True
elif e_type == 'version':
version = _check_integer(e, 1, e_type)
if version > SUP_VERSION:
logger.warning(W_WKSVERSION+"Unsupported worksheet version, loading could fail")
elif e_type == 'generator':
generator = _check_symbol(e, 1, e_type)
else:
raise WksError('Unknown worksheet attribute `{}`'.format(e_type))
return Worksheet(setup, elements, version, generator, has_images)
def set_page(self, pw, ph):
pw = FromMM(pw)
ph = FromMM(ph)
self.pw = pw
self.ph = ph
self.lm = self.setup.left_margin
self.tm = self.setup.top_margin
self.rm = pw-self.setup.right_margin
self.bm = ph-self.setup.bottom_margin
def solve_ref(self, pt, inc_x, inc_y, ref):
pt = wxPoint(pt.x, pt.y) # Make a copy
if ref[0] == 'l':
pt.x += self.lm
elif ref[0] == 'r':
pt.x = self.rm-pt.x
inc_x = -inc_x
if ref[1] == 't':
pt.y += self.tm
elif ref[1] == 'b':
pt.y = self.bm-pt.y
inc_y = -inc_y
return pt, wxPoint(inc_x, inc_y)
def check_page(self, e):
return e.option and ((e.option == 'page1only' and self.page != 1) or (e.option == 'notonpage1' and self.page == 1))
def draw(self, board, layer, page, page_w, page_h, tb_vars):
self.pcb_items = []
self.set_page(page_w, page_h)
self.layer = layer
self.board = board
self.page = page
self.tb_vars = tb_vars
self.images = []
for e in self.elements:
# Some objects are for the first page, other for all but the first page, and most for all
if self.check_page(e):
continue
e.draw(self)
def add_images_to_svg(self, svg):
for e in self.images:
e.add_to_svg(svg, self)
def out_of_margin(self, p):
""" Used to check if the repeat went outside the page usable area """
return p.x > self.rm or p.y > self.bm
def undraw(self, board):
for e in self.pcb_items:
board.Remove(e)