# -*- 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) # The Assembly image is a composition from Pixlok and oNline Web Fonts # The rest are KiCad icons """ Dependencies: - from: RSVG role: Create outputs preview id: rsvg1 - from: RSVG role: Create PNG icons id: rsvg2 - from: Ghostscript role: Create outputs preview - from: ImageMagick role: Create outputs preview """ import os import subprocess import pprint import shlex from shutil import copy2 from math import ceil from struct import unpack from tempfile import NamedTemporaryFile from .gs import GS from .optionable import BaseOptions from .kiplot import config_output, get_output_dir from .misc import W_NOTYET, W_MISSTOOL, W_NOOUTPUTS from .registrable import RegOutput from .macros import macros, document, output_class # noqa: F401 from . import log, __version__ logger = log.get_logger() CAT_IMAGE = {'PCB': 'pcbnew', 'Schematic': 'eeschema', 'Compress': 'zip', 'fabrication': 'fabrication', 'export': 'export', 'assembly': 'assembly_simple', 'repair': 'repair', 'docs': 'project', 'BoM': 'bom', '3D': '3d', 'gerber': 'gerber', 'drill': 'load_drill'} EXT_IMAGE = {'gbr': 'file_gbr', 'gtl': 'file_gbr', 'gtp': 'file_gbr', 'gbo': 'file_gbr', 'gto': 'file_gbr', 'gbs': 'file_gbr', 'gbl': 'file_gbr', 'gts': 'file_gbr', 'gml': 'file_gbr', 'gm1': 'file_gbr', 'gbrjob': 'file_gerber_job', 'brd': 'file_brd', 'bz2': 'file_bz2', 'dxf': 'file_dxf', 'cad': 'file_cad', 'drl': 'file_drl', 'pdf': 'file_pdf', 'txt': 'file_txt', 'pos': 'file_pos', 'csv': 'file_csv', 'svg': 'file_svg', 'eps': 'file_eps', 'png': 'file_png', 'jpg': 'file_jpg', 'plt': 'file_plt', 'ps': 'file_ps', 'rar': 'file_rar', 'scad': 'file_scad', 'stl': 'file_stl', 'step': 'file_stp', 'stp': 'file_stp', 'wrl': 'file_wrl', 'html': 'file_html', 'css': 'file_css', 'xml': 'file_xml', 'tsv': 'file_tsv', 'xlsx': 'file_xlsx', 'xyrs': 'file_xyrs', 'xz': 'file_xz', 'gz': 'file_gz', 'tar': 'file_tar', 'zip': 'file_zip', 'kicad_pcb': 'pcbnew', 'sch': 'eeschema', 'kicad_sch': 'eeschema'} for i in range(31): n = str(i) EXT_IMAGE['gl'+n] = 'file_gbr' EXT_IMAGE['g'+n] = 'file_gbr' EXT_IMAGE['gp'+n] = 'file_gbr' CAT_REP = {'PCB': ['pdf_pcb_print', 'svg_pcb_print', 'pcb_print'], 'Schematic': ['pdf_sch_print', 'svg_sch_print']} BIG_ICON = 256 MID_ICON = 64 OUT_COLS = 12 BIG_2_MID_REL = int(ceil(BIG_ICON/MID_ICON)) IMAGEABLES_SIMPLE = {'png', 'jpg'} IMAGEABLES_GS = {'pdf', 'eps', 'ps'} IMAGEABLES_SVG = {'svg'} STYLE = """ .cat-table { margin-left: auto; margin-right: auto; } .cat-table td { padding: 20px 24px; } .nav-table { margin-left: auto; margin-right: auto; } .nav-table td { padding: 20px 24px; } .output-table { width: 1280px; margin-left: auto; margin-right: auto; 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); } .output-table thead tr { background-color: #0e4e8e; color: #ffffff; text-align: left; } .output-table th { padding: 10px 12px; } .output-table td { padding: 5px 7px; } .out-cell { width: 128px; text-align: center } .out-img { text-align: center; margin-left: auto; margin-right: auto; } .cat-img { text-align: center; margin-left: auto; margin-right: auto; } .td-small { text-align: center; font-size: 0.6em; } .td-normal { text-align: center; } .generator { text-align: right; font-size: 0.6em; } a:link, a:visited { text-decoration: none;} a:hover, a:active { text-decoration: underline;} """ 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()) return False if cmd_output.strip(): logger.debug('- Output from command:\n'+cmd_output.decode()) return True def get_png_size(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')): return 0, 0 w, h = unpack('>LL', s[16:24]) return int(w), int(h) class Navigate_ResultsOptions(BaseOptions): def __init__(self): with document: self.output = GS.def_global_output """ *Filename for the output (%i=html, %x=navigate) """ self.link_from_root = '' """ *The name of a file to create at the main output directory linking to the home page """ super().__init__() self._expand_id = 'navigate' self._expand_ext = 'html' def add_to_tree(self, cat, out, o_tree): # Add `out` to `o_tree` in the `cat` category cat = cat.split('/') node = o_tree for c in cat: if c not in node: # New one node[c] = {} node = node[c] node[out.name] = out def svg_to_png(self, svg_file, png_file, width): cmd = [self.rsvg_command, '-w', str(width), '-f', 'png', '-o', png_file, svg_file] return _run_command(cmd) def copy(self, img, width): """ Copy an SVG icon to the images/ dir. Tries to convert it to PNG. """ img_w = "{}_{}".format(img, width) if img_w in self.copied_images: # Already copied, just return its name return self.copied_images[img_w] src = os.path.join(self.img_src_dir, img+'.svg') dst = os.path.join(self.out_dir, 'images', img_w) id = img_w if self.rsvg_command is not None and self.svg_to_png(src, dst+'.png', width): img_w += '.png' else: copy2(src, dst+'.svg') img_w += '.svg' name = os.path.join('images', img_w) self.copied_images[id] = name return name def can_be_converted(self, ext): if ext in IMAGEABLES_SVG and self.rsvg_command is None: logger.warning(W_MISSTOOL+"Missing SVG to PNG converter") return False if ext in IMAGEABLES_GS and not self.ps2img_avail: logger.warning(W_MISSTOOL+"Missing PS/PDF to PNG converter") return False if ext in IMAGEABLES_SIMPLE and self.convert_command is None: logger.warning(W_MISSTOOL+"Missing ImageMagick converter") return False return ext in IMAGEABLES_SVG or ext in IMAGEABLES_GS or ext in IMAGEABLES_SIMPLE def get_image_for_cat(self, cat): img = None # Check if we have an output that can represent this category if cat in CAT_REP and self.convert_command is not None: outs_rep = CAT_REP[cat] rep_file = None # Look in all outputs for o in RegOutput.get_outputs(): # Is this one that can be used to represent it? if o.type in outs_rep: out_dir = get_output_dir(o.dir, o, dry=True) targets = o.get_targets(out_dir) # Look the output targets for tg in targets: ext = os.path.splitext(tg)[1][1:].lower() # Can be converted to an image? if os.path.isfile(tg) and self.can_be_converted(ext): rep_file = tg break if rep_file: break if rep_file: cat, _ = self.get_image_for_file(rep_file, cat, no_icon=True) return cat if cat in CAT_IMAGE: img = self.copy(CAT_IMAGE[cat], BIG_ICON) cat_img = '{}'.format(img, cat, BIG_ICON, BIG_ICON) cat = ('
{}
{}
'. format(cat_img, cat)) return cat def compose_image(self, file, ext, img, out_name, no_icon=False): if not os.path.isfile(file): logger.warning(W_NOTYET+"{} not yet generated, using an icon".format(os.path.relpath(file))) return False, None, None if self.convert_command is None: return False, None, None # Create a unique name using the output name and the generated file name bfname = os.path.splitext(os.path.basename(file))[0] fname = os.path.join(self.out_dir, 'images', out_name+'_'+bfname+'.png') # Full path for the icon image icon = os.path.join(self.out_dir, img) if ext == 'pdf': # Only page 1 file += '[0]' if ext == 'svg': with NamedTemporaryFile(mode='w', suffix='.png', delete=False) as f: tmp_name = f.name logger.debug('Temporal convert: {} -> {}'.format(file, tmp_name)) if not self.svg_to_png(file, tmp_name, BIG_ICON): return False, None, None file = tmp_name cmd = [self.convert_command, file, # Size for the big icons (width) '-resize', str(BIG_ICON)+'x'] if not no_icon: cmd.extend([ # Add the file type icon icon, # At the bottom right '-gravity', 'south-east', # This is a composition, not 2 images '-composite']) cmd.append(fname) res = _run_command(cmd) if ext == 'svg': logger.debug('Removing temporal {}'.format(tmp_name)) os.remove(tmp_name) return res, fname, os.path.relpath(fname, start=self.out_dir) def get_image_for_file(self, file, out_name, no_icon=False): ext = os.path.splitext(file)[1][1:].lower() wide = False # Copy the icon for this file extension icon_name = 'folder' if os.path.isdir(file) else EXT_IMAGE.get(ext, 'unknown') img = self.copy(icon_name, MID_ICON) # Full name for the file file_full = file # Just the file, to display it file = os.path.basename(file) # The icon size height = width = MID_ICON # Check if this file can be represented by an image if self.can_be_converted(ext): # Try to compose the image of the file with the icon ok, fimg, new_img = self.compose_image(file_full, ext, img, 'cat_'+out_name, no_icon) if ok: # It was converted, replace the icon by the composited image img = new_img # Compute its size width, height = get_png_size(fimg) # We are using the big size wide = True # Now add the image with its file name as caption ext_img = '{}'.format(img, file, width, height) file = ('
{}
{}
'. format(ext_img, 'td-normal' if no_icon else 'td-small', out_name if no_icon else file)) return file, wide def add_back_home(self, f, prev): if prev is not None: prev += '.html' f.write('') f.write(' ') f.write(' '. format(prev, self.back_img, MID_ICON, MID_ICON)) f.write(' '. format(self.home, self.home_img, MID_ICON, MID_ICON)) f.write(' ') f.write('') f.write('

Generated by KiBot v{}

'. format(__version__)) def write_head(self, f, title): f.write('\n') f.write('\n') f.write('\n') f.write(' {}\n'.format(title if title else 'Main page')) f.write(' \n') # UTF-8 encoding for unicode support f.write(' \n') f.write(' \n') f.write('\n') f.write('\n') def generate_cat_page_for(self, name, node, prev, category): logger.debug('- Categories: '+str(node.keys())) with open(os.path.join(self.out_dir, name), 'wt') as f: self.write_head(f, category) name, ext = os.path.splitext(name) # Limit to 5 categories by row c_cats = len(node) rows = ceil(c_cats/5.0) by_row = c_cats/rows acc = 0 f.write('\n\n') for cat, content in node.items(): if not isinstance(content, dict): continue if acc >= by_row: # Flush the table and create another acc = 0 f.write('\n
\n\n\n') pname = name+'_'+cat+ext self.generate_page_for(content, pname, name, category+'/'+cat) f.write(' \n'.format(pname, self.get_image_for_cat(cat))) acc += 1 f.write('\n
{}
\n') self.generate_outputs(f, node) self.add_back_home(f, prev) f.write('\n\n') def generate_outputs(self, f, node): for oname, out in node.items(): if isinstance(out, dict): continue f.write('\n') out_name = oname.replace(' ', '_') oname = oname.replace('_', ' ') oname = oname[0].upper()+oname[1:] if out.comment: oname += ': '+out.comment f.write('\n'.format(OUT_COLS, oname)) out_dir = get_output_dir(out.dir, out, dry=True) f.write('\n') targets = out.get_targets(out_dir) if len(targets) == 1: tg_rel = os.path.relpath(os.path.abspath(targets[0]), start=self.out_dir) img, _ = self.get_image_for_file(targets[0], out_name) f.write('\n'. format(OUT_COLS, tg_rel, img)) else: c = 0 for tg in targets: if c == OUT_COLS: f.write('\n\n') c = 0 tg_rel = os.path.relpath(os.path.abspath(tg), start=self.out_dir) img, wide = self.get_image_for_file(tg, out_name) # Check if we need to break this row span = 1 if wide: span = BIG_2_MID_REL remain = OUT_COLS-c if span > remain: f.write('\n\n'.format(remain)) # Add a new cell f.write('\n'.format(span, tg_rel, img)) c = c+span if c < OUT_COLS: f.write('\n'.format(OUT_COLS-c)) f.write('\n') # This row is just to ensure we have at least 1 cell in each column f.write('\n') for _ in range(OUT_COLS): f.write('\n') f.write('\n') f.write('\n') f.write('
{}
{}
{}
\n') def generate_end_page_for(self, name, node, prev, category): logger.debug('- Outputs: '+str(node.keys())) with open(os.path.join(self.out_dir, name), 'wt') as f: self.write_head(f, category) name, ext = os.path.splitext(name) self.generate_outputs(f, node) self.add_back_home(f, prev) f.write('\n\n') def generate_page_for(self, node, name, prev=None, category=''): logger.debug('Generating page for '+name) if isinstance(list(node.values())[0], dict): self.generate_cat_page_for(name, node, prev, category) else: self.generate_end_page_for(name, node, prev, category) def get_targets(self, out_dir): # Listing all targets is too complex, we list the most relevant # This is good enough to compress the result name = self._parent.expand_filename(out_dir, self.output) files = [os.path.join(out_dir, 'images'), os.path.join(out_dir, 'styles.css'), os.path.join(out_dir, 'favicon.ico')] if self.link_from_root: files.append(os.path.join(GS.out_dir, self.link_from_root)) self.out_dir = out_dir self.get_html_names(self.create_tree(), name, files) return files def get_html_names_cat(self, name, node, prev, category, files): files.append(os.path.join(self.out_dir, name)) name, ext = os.path.splitext(name) for cat, content in node.items(): if not isinstance(content, dict): continue pname = name+'_'+cat+ext self.get_html_names(content, pname, files, name, category+'/'+cat) def get_html_names(self, node, name, files, prev=None, category=''): if isinstance(list(node.values())[0], dict): self.get_html_names_cat(name, node, prev, category, files) else: files.append(os.path.join(self.out_dir, name)) def create_tree(self): o_tree = {} for o in RegOutput.get_outputs(): config_output(o) cat = o.category if cat is None: continue if isinstance(cat, str): cat = [cat] for c in cat: self.add_to_tree(c, o, o_tree) return o_tree def run(self, name): self.out_dir = os.path.dirname(name) self.img_src_dir = GS.get_resource_path('images') self.img_dst_dir = os.path.join(self.out_dir, 'images') os.makedirs(self.img_dst_dir, exist_ok=True) self.copied_images = {} name = os.path.basename(name) # Create a tree with all the outputs o_tree = self.create_tree() logger.debug('Collected outputs:\n'+pprint.pformat(o_tree)) if not o_tree: logger.warning(W_NOOUTPUTS+'No outputs for navigate results') return with open(os.path.join(self.out_dir, 'styles.css'), 'wt') as f: f.write(STYLE) self.rsvg_command = self.check_tool('rsvg1') self.convert_command = self.check_tool('ImageMagick') self.ps2img_avail = self.check_tool('Ghostscript') # Create the pages self.home = name self.back_img = self.copy('back', MID_ICON) self.home_img = self.copy('home', MID_ICON) copy2(os.path.join(self.img_src_dir, 'favicon.ico'), os.path.join(self.out_dir, 'favicon.ico')) self.generate_page_for(o_tree, name) # Link it? if self.link_from_root: redir_file = os.path.join(GS.out_dir, self.link_from_root) rel_start = os.path.relpath(os.path.join(self.out_dir, name), start=GS.out_dir) logger.debug('Creating redirector: {} -> {}'.format(redir_file, rel_start)) with open(redir_file, 'wt') as f: f.write('\n\n'.format(rel_start)) f.write('\n') @output_class class Navigate_Results(BaseOutput): # noqa: F821 """ Navigate Results Generates a web page to navigate the generated outputs """ def __init__(self): super().__init__() # Make it low priority so it gets created after all the other outputs self.priority = 10 with document: self.options = Navigate_ResultsOptions """ *[dict] Options for the `navigate_results` output """ # The help is inherited and already mentions the default priority self.fix_priority_help() @staticmethod def get_conf_examples(name, layers, templates): outs = BaseOutput.simple_conf_examples(name, 'Web page to browse the results', 'Browse') # noqa: F821 outs[0]['options'] = {'link_from_root': 'index.html'} return outs