# -*- coding: utf-8 -*-
# Copyright (c) 2020-2021 Salvador E. Tropea
# Copyright (c) 2020-2021 Instituto Nacional de TecnologĂa Industrial
# Copyright (c) 2016-2020 Oliver Henry Walters (@SchrodingersGat)
# License: MIT
# Project: KiBot (formerly KiPlot)
# Adapted from: https://github.com/SchrodingersGat/KiBoM
"""
HTML Writer: Generates a HTML BoM file.
"""
import os
from base64 import b64encode
from struct import unpack
from .columnlist import ColumnList, BoMError
from .kibot_logo import KIBOT_LOGO, KIBOT_LOGO_W, KIBOT_LOGO_H
BG_GEN = "#DCF5E4"
BG_KICAD = "#F5DCA9"
BG_USER = "#DCEFF5"
BG_EMPTY = "#F57676"
BG_GEN_L = "#E6FFEE"
BG_KICAD_L = "#FFE6B3"
BG_USER_L = "#E6F9FF"
BG_EMPTY_L = "#FF8080"
HEAD_COLOR_R = "#982020"
HEAD_COLOR_R_L = "#c85050"
HEAD_COLOR_G = "#009879"
HEAD_COLOR_G_L = "#30c8a9"
HEAD_COLOR_B = "#0e4e8e"
HEAD_COLOR_B_L = "#3e7ebe"
STYLE_COMMON = (" .cell-title { vertical-align: bottom; }\n"
" .cell-info { vertical-align: top; padding: 1em;}\n"
" .cell-extra-info { vertical-align: top; padding: 1em;}\n"
" .cell-stats { vertical-align: top; padding: 1em;}\n"
" .title { font-size:2.5em; font-weight: bold; }\n"
" .subtitle { font-size:1.5em; font-weight: bold; }\n"
" .h2 { font-size:1.5em; font-weight: bold; }\n"
" .td-empty0 { text-align: center; background-color: "+BG_EMPTY+";}\n"
" .td-gen0 { text-align: center; background-color: "+BG_GEN+";}\n"
" .td-kicad0 { text-align: center; background-color: "+BG_KICAD+";}\n"
" .td-user0 { text-align: center; background-color: "+BG_USER+";}\n"
" .td-empty1 { text-align: center; background-color: "+BG_EMPTY_L+";}\n"
" .td-gen1 { text-align: center; background-color: "+BG_GEN_L+";}\n"
" .td-kicad1 { text-align: center; background-color: "+BG_KICAD_L+";}\n"
" .td-user1 { text-align: center; background-color: "+BG_USER_L+";}\n"
" .color-ref { margin: 25px 0; }\n"
" .color-ref th { text-align: left }\n"
" .color-ref td { padding: 5px 20px; }\n"
" .head-table { margin-bottom: 2em; }\n"
# Table sorting cursor. 60% transparent when disabled. Solid white when enabled.
" .tg-sort-header::-moz-selection{background:0 0}\n"
" .tg-sort-header::selection{background:0 0}.tg-sort-header{cursor:pointer}\n"
" .tg-sort-header:after{content:'';float:right;border-width:0 5px 5px;border-style:solid;\n"
" border-color:#ffffff transparent;visibility:hidden;opacity:.6}\n"
" .tg-sort-header:hover:after{visibility:visible}\n"
" .tg-sort-asc:after,.tg-sort-asc:hover:after,.tg-sort-desc:after{visibility:visible;opacity:1}\n"
" .tg-sort-desc:after{border-bottom:none;border-width:5px 5px 0}\n")
TABLE_MODERN = """
.content-table {
border-collapse:
collapse;
margin-top: 5px;
margin-bottom: 4em;
font-size: 0.9em;
font-family: sans-serif;
min-width: 400px;
border-radius: 5px 5px 0 0;
overflow: hidden;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.15);
}
.content-table thead tr { background-color: @bg@; color: #ffffff; text-align: left; }
.content-table th, .content-table td { padding: 12px 15px; }
.content-table tbody tr { border-bottom: 1px solid #dddddd; }
.content-table tbody tr:nth-of-type(even) { background-color: #f3f3f3; }
.content-table tbody tr:last-of-type { border-bottom: 2px solid @bg@; }
.content-table * tr:hover > td { background-color: @bgl@ !important }
"""
TABLE_CLASSIC = (" .content-table, .content-table th, .content-table td { border: 1px solid black; }\n"
" .content-table * tr:hover > td { background-color: #B0B0B0 !important }\n")
# JavaScript table sorter. Is floating around internet, i.e.:
# - Stack Overflow: https://stackoverflow.com/questions/61122696/addeventlistener-after-change-event
# - pimpmykicadbom: https://gitlab.com/antto/pimpmykicadbom
# - Table Generator: https://www.tablesgenerator.com/
SORT_CODE = ('\n')
def cell_class(col):
""" Return a background color for a given column title """
col = col.lower()
# Auto-generated columns
if col in ColumnList.COLUMNS_GEN_L:
return 'gen' # BG_GEN
# KiCad protected columns
elif col in ColumnList.COLUMNS_PROTECTED_L:
return 'kicad' # BG_KICAD
# Additional user columns
return 'user' # BG_USER
def link(text):
for t in ["http", "https", "ftp", "www"]:
if text.startswith(t):
return '{t}'.format(t=text)
return text
def content_table(html, groups, headings, head_names, cfg, link_datasheet, link_digikey, link_mouser, link_lcsc, col_colors,
dnf=False):
cl = ''
# Table start
html.write('
\n')
# Row titles:
html.write(" \n")
html.write(" \n")
for i, h in enumerate(head_names):
if col_colors:
# Cell background color
cl = ' class="th-'+cell_class(headings[i])+'"'
html.write(' | {} | \n'.format(cl, h))
html.write("
\n")
html.write(" \n")
html.write(" \n")
rc = 0
hl_empty = cfg.html.highlight_empty
for i, group in enumerate(groups):
if (cfg.ignore_dnf and not group.is_fitted()) != dnf:
continue
row = group.get_row(headings)
if link_datasheet != -1:
datasheet = group.get_field(ColumnList.COL_DATASHEET_L)
html.write(' \n'.format(i))
for n, r in enumerate(row):
# A link to Digi-Key?
if link_digikey and headings[n] in link_digikey:
r = '' + r + ''
if link_mouser and headings[n] in link_mouser:
r = '' + r + ''
if link_lcsc and headings[n] in link_lcsc:
r = '' + r + ''
# Link this column to the datasheet?
if link_datasheet == n and datasheet.startswith('http'):
r = '' + r + ''
if col_colors:
# Empty cell?
if hl_empty and (len(r) == 0 or r.strip() == "~"):
cl = 'empty'
else:
cl = cell_class(headings[n])
cl = ' class="td-{}{}"'.format(cl, rc % 2)
if headings[n] == ColumnList.COL_REFERENCE_L:
for ref in r.split(cfg.ref_separator):
r = ''.format(ref)+r
html.write(' | {} | \n'.format(cl, link(r)))
html.write("
\n")
rc += 1
html.write(" \n")
html.write("
\n")
return
def embed_image(file):
with open(file, 'rb') as f:
s = f.read()
if not (s[:8] == b'\x89PNG\r\n\x1a\n' and (s[12:16] == b'IHDR')):
raise BoMError('Only PNG images are supported for the logo')
w, h = unpack('>LL', s[16:24])
return int(w), int(h), 'data:image/png;base64,'+b64encode(s).decode('ascii')
def write_stats(html, cfg):
if len(cfg.aggregate) == 1:
# Only one project
html.write('\n')
html.write(' \n')
if not cfg.html.hide_pcb_info:
prj = cfg.aggregate[0]
html.write(" Schematic: {} \n".format(prj.name))
html.write(" Variant: {} \n".format(cfg.variant.name))
html.write(" Revision: {} \n".format(prj.sch.revision))
html.write(" Date: {} \n".format(prj.sch.date))
html.write(" KiCad Version: {} \n".format(cfg.kicad_version))
html.write(' | \n')
html.write(' \n')
if not cfg.html.hide_stats_info:
html.write(" Component Groups: {} \n".format(cfg.n_groups))
html.write(" Component Count: {} (per PCB) \n\n".format(cfg.total_str))
html.write(" Fitted Components: {} (per PCB) \n".format(cfg.fitted_str))
html.write(" Number of PCBs: {} \n".format(cfg.number))
html.write(" Total Components: {t} (for {n} PCBs) \n".format(n=cfg.number, t=cfg.n_build))
html.write(' | \n')
html.write('
\n')
else:
# Multiple projects
# Global stats
html.write('\n')
html.write(' \n')
if not cfg.html.hide_pcb_info:
html.write(" Variant: {} \n".format(cfg.variant.name))
html.write(" KiCad Version: {} \n".format(cfg.kicad_version))
html.write(' | \n')
html.write(' \n')
if not cfg.html.hide_stats_info:
html.write(" Component Groups: {} \n".format(cfg.n_groups))
html.write(" Component Count: {} (per PCB) \n\n".format(cfg.total_str))
html.write(" Fitted Components: {} (per PCB) \n".format(cfg.fitted_str))
html.write(" Number of PCBs: {} \n".format(cfg.number))
html.write(" Total Components: {t} (for {n} PCBs) \n".format(n=cfg.number, t=cfg.n_build))
html.write(' | \n')
html.write('
\n')
# Individual stats
for prj in cfg.aggregate:
html.write('\n')
html.write(' | \n')
html.write(' '+prj.sch.title+' \n')
html.write(' | \n')
html.write('
\n')
html.write('\n')
html.write(' \n')
if not cfg.html.hide_pcb_info:
html.write(" Schematic: {} \n".format(prj.name))
html.write(" Revision: {} \n".format(prj.sch.revision))
html.write(" Date: {} \n".format(prj.sch.date))
if prj.sch.company:
html.write(" Company: {} \n".format(prj.sch.company))
if prj.ref_id:
html.write(" ID: {} \n".format(prj.ref_id))
html.write(' | \n')
html.write(' \n')
if not cfg.html.hide_stats_info:
html.write(" Component Groups: {} \n".format(prj.comp_groups))
html.write(" Component Count: {} (per PCB) \n\n".format(prj.total_str))
html.write(" Fitted Components: {} (per PCB) \n".format(prj.fitted_str))
html.write(" Number of PCBs: {} \n".format(prj.number))
html.write(" Total Components: {t} (for {n} PCBs) \n".format(n=prj.number, t=prj.comp_build))
html.write(' | \n')
html.write('
\n')
def write_extra_info(html, cfg):
if not cfg.html.extra_info:
return
html.write('\n')
html.write(' \n')
html.write('
\n')
def write_html(filename, groups, headings, head_names, cfg):
"""
Write BoM out to a HTML file
filename = path to output file (must be a .csv, .txt or .tsv file)
groups = [list of ComponentGroup groups]
headings = [list of headings to search for data in the BoM file]
head_names = [list of headings to display in the BoM file]
cfg = BoMOptions object with all the configuration
"""
link_datasheet = -1
if cfg.html.datasheet_as_link and cfg.html.datasheet_as_link in headings:
link_datasheet = headings.index(cfg.html.datasheet_as_link)
link_digikey = cfg.html.digikey_link
link_mouser = cfg.html.mouser_link
link_lcsc = cfg.html.lcsc_link
col_colors = cfg.html.col_colors
# Compute the CSS
style_name = cfg.html.style
if os.path.isfile(style_name):
with open(style_name, 'rt') as f:
style = f.read()
else:
# Common stuff
style = STYLE_COMMON
if style_name.startswith('modern-'):
# content-table details
if style_name.endswith('green'):
head_color = HEAD_COLOR_G
head_color_l = HEAD_COLOR_G_L
elif style_name.endswith('blue'):
head_color = HEAD_COLOR_B
head_color_l = HEAD_COLOR_B_L
else:
head_color = HEAD_COLOR_R
head_color_l = HEAD_COLOR_R_L
style += TABLE_MODERN.replace('@bg@', head_color)
style += TABLE_MODERN.replace('@bgl@', head_color_l)
else:
# Background is white, so we change the sorting cursor to black
style = style.replace('border-color:#ffffff', 'border-color:#000000')
style += TABLE_CLASSIC
with open(filename, "w") as html:
# HTML Head
html.write("\n")
html.write("\n")
html.write(' \n') # UTF-8 encoding for unicode support
if cfg.html.title:
html.write(' '+cfg.html.title+'\n')
# CSS
html.write("\n")
html.write("\n")
html.write("\n")
# Page Header
img = None
if cfg.html.logo is not None:
if cfg.html.logo:
img_w, img_h, img = embed_image(cfg.html.logo)
else:
img = 'data:image/png;base64,'+KIBOT_LOGO
img_w = KIBOT_LOGO_W
img_h = KIBOT_LOGO_H
if img or not cfg.html.hide_pcb_info or not cfg.html.hide_stats_info or cfg.html.title:
html.write('\n')
html.write('\n')
n = 2
if len(cfg.aggregate) > 1:
n += 2*len(cfg.aggregate)
if len(cfg.html.extra_info):
n += 1
html.write(' \n'.format(n))
if img:
html.write(' \n')
html.write(' | \n')
html.write(' \n')
if cfg.html.title:
html.write(' '+cfg.html.title+' \n')
html.write(' | \n')
html.write('
\n')
write_extra_info(html, cfg)
write_stats(html, cfg)
html.write('
\n')
# Fitted groups
html.write("Component Groups
\n")
content_table(html, groups, headings, head_names, cfg, link_datasheet, link_digikey, link_mouser, link_lcsc,
col_colors)
# DNF component groups
if cfg.html.generate_dnf and cfg.n_total != cfg.n_fitted:
html.write("Optional components (DNF=Do Not Fit)
\n")
content_table(html, groups, headings, head_names, cfg, link_datasheet, link_digikey, link_mouser, link_lcsc,
col_colors, True)
# Color reference
if col_colors:
html.write('\n')
html.write('| Color reference: |
\n')
html.write('| KiCad Fields (default) |
\n')
html.write('| Generated Fields |
\n')
html.write('| User Fields |
\n')
if cfg.html.highlight_empty:
html.write('| Empty Fields |
\n')
html.write('
\n')
html.write(SORT_CODE)
html.write("")
return True