# -*- 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) """ Dependencies: - name: markdown2 python_module: true debian: python3-markdown2 arch: python-markdown2 role: mandatory - from: Git role: Find commit hash and/or date """ import glob import os import shutil import stat import subprocess import sys from tempfile import NamedTemporaryFile, TemporaryDirectory, mkdtemp from .error import KiPlotConfigurationError from .misc import PCB_GENERATORS, RENDERERS, W_MORERES from .gs import GS from .kiplot import config_output, run_output, get_output_dir, load_board, run_command, configure_and_run from .optionable import BaseOptions, Optionable from .out_base import BaseOutput from .registrable import RegOutput from .macros import macros, document, output_class # noqa: F401 from . import log logger = log.get_logger() def _get_tmp_name(ext): with NamedTemporaryFile(mode='w', suffix='.'+ext, delete=False) as f: f.close() os.chmod(f.name, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH) return f.name class PresentBoards(Optionable): def __init__(self): super().__init__() with document: self.mode = 'local' """ [local,file,external] How images and gerbers are obtained. *local*: Only applies to the currently selected PCB. You must provide the names of the outputs used to render the images and compress the gerbers. When empty KiBot will use the first render/gerber output it finds. To apply variants use `pcb_from_output` and a `pcb_variant` output. *file*: You must specify the file names used for the images and the gerbers. *external*: You must specify an external KiBot configuration. It will be applied to the selected PCB to create the images and the gerbers. The front image must be generated in a dir called *front*, the back image in a dir called *back* and the gerbers in a dir called *gerbers* """ self.name = '' """ *Name for this board. If empty we use the name of the PCB. Applies to all modes """ self.comment = '' """ A comment or description for this board. Applies to all modes """ self.pcb_file = '' """ Name of the KiCad PCB file. When empty we use the current PCB. Is ignored for the *local* mode """ self.pcb_from_output = '' """ Use the PCB generated by another output. Is ignored for the *file* mode """ self.front_image = '' """ How to obtain the front view of the PCB. *local*: the name of an output to render it. If empty we use the first renderer. *file*: the name of the rendered image. *external*: ignored, we use `extrenal_config` """ self.back_image = '' """ How to obtain the back view of the PCB. *local*: the name of an output to render it. If empty we use the first renderer. *file*: the name of the rendered image. *external*: ignored, we use `extrenal_config` """ self.gerbers = '' """ How to obtain an archive with the gerbers. *local*: the name of a `gerber` output. If empty we use the first `gerber` output. *file*: the name of a compressed archive. *external*: ignored, we use `extrenal_config` """ self.external_config = '' """ Name of an external KiBot configuration. Only used in the *external* mode """ def config(self, parent): super().config(parent) if not self.name: self.name = GS.pcb_basename if not self.pcb_file: self.pcb_file = GS.pcb_file if self.mode == 'file': files = [self.front_image, self.back_image, self.gerbers] for f in files: if not os.path.isfile(f): raise KiPlotConfigurationError('Missing file: `{}`'.format(f)) elif self.mode == 'external': if not self.external_config: raise KiPlotConfigurationError('`external_config` must be specified for the external mode') if not os.path.isfile(self.external_config): raise KiPlotConfigurationError('Missing external config: `{}`'.format(self.external_config)) if self.pcb_from_output and self.mode != 'file': out = RegOutput.get_output(self.pcb_from_output) self._pcb_from_output = out if out is None: raise KiPlotConfigurationError('Unknown output `{}` selected in board {}'. format(self.pcb_from_output, self.name)) def fill_empty_values(self, parent): # The defaults are good enough, but we need to attach to a parent self._parent = parent def solve_file(self): return self.name, self.comment, self.pcb_file, self.front_image, self.back_image, self.gerbers def generate_image(self, back, tmp_name): options = self._renderer.get_renderer_options() if options is None: raise KiPlotConfigurationError('No suitable renderer ({})'.format(self._renderer)) # Memorize the current options options.save_renderer_options() logger.debug('Starting renderer with back: {}, name: {}'.format(back, tmp_name)) # Configure it according to our needs options.setup_renderer([], [], back, tmp_name) self._renderer.dir = self._parent._parent.dir self._renderer._done = False run_output(self._renderer) # Restore the options options.restore_renderer_options() def do_compress(self, tmp_name, out): tree = {'name': '_temporal_compress_gerbers', 'type': 'compress', 'comment': 'Internally created to compress gerbers', 'options': {'output': tmp_name, 'files': [{'from_output': out.name, 'dest': '/'}]}} configure_and_run(tree, os.path.dirname(tmp_name), 'Creating gerbers archive ...') def generate_archive(self, out, tmp_name): out.options logger.debug('Starting gerber name: {}'.format(out.name)) # Save options old_dir = out.dir old_done = out._done old_variant = out.options.variant # Configure it according to our needs with TemporaryDirectory() as tmp_dir: logger.debug('Generating the gerbers at '+tmp_dir) out.done = False out.dir = tmp_dir out.options.variant = None run_output(out) self.do_compress(tmp_name, out) # Restore options out.dir = old_dir out._done = old_done out.options.variant = old_variant def solve_pcb(self, load_it=True): if not self.pcb_from_output: return False out_name = self.pcb_from_output out = RegOutput.get_output(out_name) if out is None: raise KiPlotConfigurationError('Unknown output `{}` selected in board {}'. format(out_name, self.name)) if out.type not in PCB_GENERATORS: raise KiPlotConfigurationError("Output `{}` can't be used to render the PCB, must be {}". format(out, PCB_GENERATORS)) config_output(out) run_output(out) new_pcb = out.get_targets(get_output_dir(out.dir, out))[0] if load_it: GS.board = None self.old_pcb = GS.pcb_file GS.set_pcb(new_pcb) load_board() self.new_pcb = new_pcb return True def solve_local_image(self, out_name, back=False): if not out_name: out = next(filter(lambda x: x.type in RENDERERS, RegOutput.get_outputs()), None) if not out: raise KiPlotConfigurationError('No renderer output found, must be {}'.format(RENDERERS)) else: out = RegOutput.get_output(out_name) if out is None: raise KiPlotConfigurationError('Unknown output `{}` selected in board {}'. format(out_name, self.name)) if out.type not in RENDERERS: raise KiPlotConfigurationError("Output `{}` can't be used to render the PCB, must be {}". format(out, RENDERERS)) config_output(out) self._renderer = out tmp_name = _get_tmp_name(out.get_extension()) self.temporals.append(tmp_name) self.generate_image(back, tmp_name) return tmp_name def solve_local_gerbers(self, out_name): if not out_name: out = next(filter(lambda x: x.type == 'gerber', RegOutput.get_outputs()), None) if not out: raise KiPlotConfigurationError('No gerber output found') else: out = RegOutput.get_output(out_name) if out is None: raise KiPlotConfigurationError('Unknown output `{}` selected in board {}'. format(out_name, self.name)) if out.type != 'gerber': raise KiPlotConfigurationError("Output `{}` must be `gerber` type, not {}". format(out, out.type)) config_output(out) tmp_name = _get_tmp_name('zip') self.temporals.append(tmp_name) # Generate the archive self.generate_archive(out, tmp_name) return tmp_name def solve_local(self): fname = GS.pcb_file pcb_changed = self.solve_pcb() front_image = self.solve_local_image(self.front_image) back_image = self.solve_local_image(self.back_image, back=True) gerbers = self.solve_local_gerbers(self.gerbers) if pcb_changed: fname = self.new_pcb GS.set_pcb(self.old_pcb) GS.reload_project(GS.pro_file) return self.name, self.comment, fname, front_image, back_image, gerbers def get_ext_file(self, main_dir, sub_dir): d_name = os.path.join(main_dir, sub_dir) logger.debugl(1, 'Looking for results at '+d_name) if not os.path.isdir(d_name): raise KiPlotConfigurationError('`{}` should create a directory called `{}`'. format(self.external_config, sub_dir)) res = glob.glob(os.path.join(d_name, '*')) if not res: raise KiPlotConfigurationError('`{}` created an empty `{}`'. format(self.external_config, sub_dir)) if len(res) > 1: logger.warning(W_MORERES+'`{}` generated more than one file at `{}`'. format(self.external_config, sub_dir)) return res[0] def solve_external(self): tmp_dir = mkdtemp() self.temporals.append(tmp_dir) fname = self.new_pcb if self.solve_pcb(load_it=False) else GS.pcb_file cmd = [sys.argv[0], '-c', self.external_config, '-b', fname, '-d', tmp_dir] run_command(cmd) front_image = self.get_ext_file(tmp_dir, 'front') back_image = self.get_ext_file(tmp_dir, 'back') gerbers = self.get_ext_file(tmp_dir, 'gerbers') return self.name, self.comment, fname, front_image, back_image, gerbers def solve(self, temporals): self.temporals = temporals if self.mode == 'file': return self.solve_file() elif self.mode == 'local': return self.solve_local() # external return self.solve_external() class KiKit_PresentOptions(BaseOptions): def __init__(self): with document: self.description = '' """ *Name for a markdown file containing the main part of the page to be generated. This is mandatory and is the description of your project. You can embed the markdown code. If the text doesn't map to a file and contains more than one line KiBot will assume this is the markdown """ self.boards = PresentBoards """ [dict|list(dict)] One or more boards that compose your project. When empty we will use only the main PCB for the current project """ self.resources = Optionable """ [string|list(string)=''] A list of file name patterns for additional resources to be included. I.e. images referenced in description. They will be copied relative to the output dir """ self.template = 'default' """ Path to a template directory or a name of built-in one. See KiKit's doc/present.md for template specification """ self.repository = '' """ URL of the repository. Will be passed to the template. If empty we will try to find it using `git remote get-url origin`. The default template uses it to create an URL for the current commit """ self.name = '' """ Name of the project. Will be passed to the template. If empty we use the name of the KiCad project. The default template uses it for things like the page title """ super().__init__() self._git_solved = False def get_git_command(self): if not self._git_solved: self._git_command = self.check_tool('Git') self._git_solved = True return self._git_command def config(self, parent): super().config(parent) # Validate the input file name if not self.description: raise KiPlotConfigurationError('You must specify an input markdown file using `description`') if not os.path.isfile(self.description): self.description = self.description.split('\n') if len(self.description) == 1: raise KiPlotConfigurationError('Missing description file `{}`'.format(self.description)) # List of boards if isinstance(self.boards, type): a_board = PresentBoards() a_board.fill_empty_values(self) self.boards = [a_board] elif isinstance(self.boards, PresentBoards): self.boards = [self.boards] # else ... we have a list of boards self.resources = self.force_list(self.resources, comma_sep=False) if not self.name: self.name = GS.pcb_basename # Make sure the template exists if not os.path.exists(os.path.join(self.template, "template.json")): try: self.template = GS.get_resource_path(os.path.join('pcbdraw', 'present', 'templates', self.template)) except SystemExit: raise KiPlotConfigurationError('Missing template `{}`'.format(self.template)) # Repo URL if not self.repository and self.get_git_command(): try: self.repository = run_command([self.get_git_command(), 'remote', 'get-url', 'origin'], just_raise=True) except subprocess.CalledProcessError: pass def get_targets(self, out_dir): self.ensure_tool('markdown2') from .PcbDraw.present import readTemplate # The web page out_dir = self._parent.expand_dirname(out_dir) res = [os.path.join(out_dir, 'index.html')] # The resources template = readTemplate(self.template) for r in self.resources: template.addResource(r) res.extend(template.listResources(out_dir)) # The boards res.append(os.path.join(out_dir, 'boards')) return res def generate_images(self, dir_name, content): # Memorize the current options self.save_options() dir = os.path.dirname(os.path.join(dir_name, self.imgname)) if not os.path.exists(dir): os.makedirs(dir) counter = 0 for item in content: if item["type"] != "steps": continue for x in item["steps"]: counter += 1 filename = self.imgname.replace('%d', str(counter)) x["img"] = self.generate_image(x["side"], x["components"], x["active_components"], filename) # Restore the options self.restore_options() return content def run(self, dir_name): self.ensure_tool('markdown2') from .PcbDraw.present import boardpage # Generate missing images board = [] temporals = [] try: for brd in self.boards: board.append(brd.solve(temporals)) # Support embedded markdown if isinstance(self.description, list): tmp_md = _get_tmp_name('md') with open(tmp_md, 'w') as f: f.writelines([ln+'\n' for ln in self.description]) self._description = tmp_md temporals.append(tmp_md) else: self._description = self.description try: boardpage(dir_name, self._description, board, self.resources, self.template, self.repository, self.name, self.get_git_command()) except RuntimeError as e: raise KiPlotConfigurationError('KiKit present error: '+str(e)) finally: for f in temporals: if os.path.isfile(f): os.remove(f) elif os.path.isdir(f): shutil.rmtree(f) @output_class class KiKit_Present(BaseOutput): """ KiKit's Present - Project Presentation Creates an HTML file showing your project. It can contain one or more PCBs, showing their top and bottom sides. Also includes a download link and the gerbers. """ def __init__(self): super().__init__() with document: self.options = KiKit_PresentOptions """ *[dict] Options for the `kikit_present` output """ self._category = 'PCB/docs' @staticmethod def get_conf_examples(name, layers, templates): if not GS.check_tool(name, 'markdown2'): return None outs = BaseOutput.simple_conf_examples(name, 'Simple project presentation', 'Presentation') outs[0]['options'] = {'description': '# Presentation for '+GS.pcb_basename+'\n' 'This is an automatically generated presentation page', 'boards': {'mode': 'local', 'comment': 'Resources included', 'front_image': 'renderer_for_present', 'back_image': 'renderer_for_present', 'gerbers': 'gerbers_for_present'}} # Renderer renderer = BaseOutput.simple_conf_examples('pcbdraw', 'Renderer for the presentation', 'Render_for_presentation') renderer[0]['name'] = 'renderer_for_present' renderer[0]['run_by_default'] = False outs.append(renderer[0]) # Gerbers gerber = BaseOutput.simple_conf_examples('gerber', 'Gerbers for the presentation', 'Gerber_for_presentation') gerber[0]['name'] = 'gerbers_for_present' gerber[0]['layers'] = 'copper' gerber[0]['run_by_default'] = False outs.append(gerber[0]) return outs