import os import shutil import sys import pcbnew import wx import re import traceback import tempfile import logging import json from pathlib import Path try: from .pypdf import PdfReader, PdfWriter, PageObject, Transformation, generic except ImportError: from pypdf import PdfReader, PdfWriter, PageObject, Transformation, generic # Try to import PyMuPDF. has_pymupdf = True try: import pymupdf # This imports PyMuPDF except: try: import fitz as pymupdf # This imports PyMuPDF using old name except: try: import fitz_old as pymupdf # This imports PyMuPDF using temporary old name except: has_pymupdf = False # after pip uninstall PyMuPDF the import still works, but not `open()` # check if it's possible to call pymupdf.open() if has_pymupdf: try: pymupdf.open() except: has_pymupdf = False # Try to import pdfCropMargins. has_pdfcropmargins = True try: from pdfCropMargins import crop # This imports pdfCropMargins except: has_pdfcropmargins = False _logger = logging.getLogger(__name__) def exception_msg(info: str, tb=True): msg = f"{info}\n\n" + ( traceback.format_exc() if tb else '') try: wx.MessageBox(msg, 'Error', wx.OK | wx.ICON_ERROR) except wx._core.PyNoAppError: print(f'Error: {msg}', file=sys.stderr) def io_file_error_msg(function: str, input_file: str, folder: str = '', more: str = '', tb=True): if(folder != ''): input_file = input_file + " in " + folder msg = f"{function} failed\nOn input file {input_file}\n\n{more}" + ( traceback.format_exc() if tb else '') try: wx.MessageBox(msg, 'Error', wx.OK | wx.ICON_ERROR) except wx._core.PyNoAppError: print(f'Error: {msg}', file=sys.stderr) def colorize_pdf_pymupdf(folder, input_file, output_file, color, transparency): # If transparency is non zero, run colorize_pdf_pymupdf_with_transparency instead. if not transparency == 0: return colorize_pdf_pymupdf_with_transparency(folder, input_file, output_file, color, transparency) try: with pymupdf.open(os.path.join(folder, input_file)) as doc: xref_number = doc[0].get_contents() stream_bytes = doc.xref_stream(xref_number[0]) new_color = ''.join([f'{c:.3g} ' for c in color]) _logger.debug(f'{new_color=}') stream_bytes = re.sub(br'(\s)0 0 0 (RG|rg)', bytes(fr'\g<1>{new_color}\g<2>', 'ascii'), stream_bytes) doc.update_stream(xref_number[0], stream_bytes) doc.save(os.path.join(folder, output_file), clean=True) except RuntimeError as e: if "invalid key in dict" in str(e): io_file_error_msg(colorize_pdf_pymupdf.__name__, input_file, folder, "This error can be due to PyMuPdf not being able to handle pdfs created by KiCad 7.0.1 due to a bug in KiCad 7.0.1. Upgrade KiCad or switch to pypdf instead.\n\n") return False except: io_file_error_msg(colorize_pdf_pymupdf.__name__, input_file, folder) return False return True def colorize_pdf_pymupdf_with_transparency(folder, input_file, output_file, color, transparency): opacity = 1-float(transparency / 100) doc = pymupdf.open(os.path.join(folder, input_file)) page = doc[0] paths = page.get_drawings() # extract existing drawings # this is a list of "paths", which can directly be drawn again using Shape # ------------------------------------------------------------------------- # # define some output page with the same dimensions outpdf = pymupdf.open() outpage = outpdf.new_page(width=page.rect.width, height=page.rect.height) shape = outpage.new_shape() # make a drawing canvas for the output page # -------------------------------------- # loop through the paths and draw them # -------------------------------------- for path in paths: #print("Object:") #print("fill_opacity:", type(path["fill_opacity"])) #print("stroke_opacity:", type(path["stroke_opacity"])) if(path["color"] == None): new_color = None else: new_color = color #tuple((float(0.0), float(1.0), float(1.0))) if(path["fill"] == None): new_fill = None else: new_fill = color #tuple((float(1.0), float(0.0), float(1.0))) if(path["fill_opacity"] == None): new_fill_opacity = None else: new_fill_opacity = opacity #float(0.5) if(path["stroke_opacity"] == None): new_stroke_opacity = None else: new_stroke_opacity = opacity #float(0.5) #pprint.pp(path) # ------------------------------------ # draw each entry of the 'items' list # ------------------------------------ for item in path["items"]: # these are the draw commands if item[0] == "l": # line shape.draw_line(item[1], item[2]) elif item[0] == "re": # rectangle shape.draw_rect(item[1]) elif item[0] == "qu": # quad shape.draw_quad(item[1]) elif item[0] == "c": # curve shape.draw_bezier(item[1], item[2], item[3], item[4]) else: raise ValueError("unhandled drawing", item) # ------------------------------------------------------ # all items are drawn, now apply the common properties # to finish the path # ------------------------------------------------------ if new_fill_opacity: shape.finish( fill=new_fill, # fill color color=new_color, # line color dashes=path["dashes"], # line dashing even_odd=path.get("even_odd", True), # control color of overlaps closePath=path["closePath"], # whether to connect last and first point #lineJoin=path["lineJoin"], # how line joins should look like #lineCap=max(path["lineCap"]), # how line ends should look like width=path["width"], # line width #stroke_opacity=new_stroke_opacity, # same value for both fill_opacity=new_fill_opacity, # opacity parameters ) if new_stroke_opacity: shape.finish( fill=new_fill, # fill color color=new_color, # line color dashes=path["dashes"], # line dashing even_odd=path.get("even_odd", True), # control color of overlaps closePath=path["closePath"], # whether to connect last and first point #lineJoin=path["lineJoin"], # how line joins should look like #lineCap=max(path["lineCap"]), # how line ends should look like lineJoin=2, lineCap=1, width=path["width"], # line width stroke_opacity=new_stroke_opacity, # same value for both #fill_opacity=new_fill_opacity, # opacity parameters ) # all paths processed - commit the shape to its page shape.commit() page = outpdf[0] paths = page.get_drawings() # extract existing drawings #for path in paths: # print("Object:") # pprint.pp(path) outpdf.save(os.path.join(folder, output_file), clean=True) return True def colorize_pdf_pypdf(folder, input_file, output_file, color, transparency): try: with open(os.path.join(folder, input_file), "rb") as f: class ErrorHandler(object): def write(self, data): if "UserWarning" not in data: io_file_error_msg(colorize_pdf_pypdf.__name__, input_file, folder, data + "\n\n", tb=False) return False if sys.stderr is None: handler = ErrorHandler() sys.stderr = handler source = PdfReader(f) output = PdfWriter() page = source.pages[0] content_object = page["/Contents"].get_object() content = generic.ContentStream(content_object, source) for i, (operands, operator) in enumerate(content.operations): if operator in (b"rg", b"RG"): if operands == [0, 0, 0]: content.operations[i] = ( [generic.FloatObject(intensity) for intensity in color], operator) # else: # print(operator, operands[0], operands[1], operands[2], "The type is : ", type(operands[0]), # type(operands[1]), type(operands[2])) page[generic.NameObject("/Contents")] = content output.add_page(page) with open(os.path.join(folder, output_file), "wb") as output_stream: output.write(output_stream) except Exception: io_file_error_msg(colorize_pdf_pypdf.__name__, input_file, folder) return False return True def create_blank_page(input_file_path: str, output_file_path: str): try: # Open input file and create a page with the same size pdf_reader = PdfReader(input_file_path) src_page: PageObject = pdf_reader.pages[0] page = PageObject.create_blank_page(width=src_page.mediabox.width, height=src_page.mediabox.height) # Create the output file with the page in it output = PdfWriter() output.add_page(page) with open(output_file_path, "wb") as output_stream: output.write(output_stream) except: io_file_error_msg(create_blank_page.__name__, input_file_path, output_file_path) return False return True def get_page_size(file_path: str): try: # Open the file and check what size it is pdf_reader = PdfReader(file_path) src_page: PageObject = pdf_reader.pages[0] print("File:", str(file_path)) page_width = src_page.mediabox.width print("Width:", str(page_width)) page_height = src_page.mediabox.height print("Height:", str(page_height)) except: io_file_error_msg(get_page_size.__name__, file_path) return False, float(0), float(0) return True, page_width, page_height def merge_and_scale_pdf(input_folder: str, input_files: list, output_folder: str, output_file: str, frame_file: str, scale_or_crop: dict, template_name: str, pymupdf_merge: bool): # I haven't found a way to scale the pdf and preserve the popup-menus. # For now, I'm taking the easy way out and handle the merging differently depending # on if scaling is used or not. At least the popup-menus are preserved when not using scaling. # https://github.com/pymupdf/PyMuPDF/discussions/2499 # If popups aren't used, I'm using the with_scaling method to get rid of annotations if(scale_or_crop['scaling_method'] == '3'): # If scaling_method = 3, use a different method when using pymupdf output_file_path = os.path.join(output_folder, output_file) scaling_factor = float(scale_or_crop['scaling_factor']) if(pymupdf_merge): return merge_pdf_pymupdf_with_scaling(input_folder, input_files, output_file_path, frame_file, template_name, scaling_factor) else: return merge_pdf_pypdf(input_folder, input_files, output_file_path, frame_file, template_name, scaling_factor) # If scaling_method is 1 or 2 the merged file is not the final file. if(scale_or_crop['scaling_method'] == '1' or scale_or_crop['scaling_method'] == '2'): merged_file_path = os.path.join(input_folder, "merged_" + output_file) else: merged_file_path = os.path.join(output_folder, output_file) if(scale_or_crop['scaling_method'] == '2' and frame_file != 'None'): # The frame layer should not be scaled, so don't merge this with the others. input_files.remove(frame_file) # Merge input_files to output_file if(pymupdf_merge): return_value = merge_pdf_pymupdf(input_folder, input_files, merged_file_path, frame_file, template_name) else: return_value = merge_pdf_pypdf(input_folder, input_files, merged_file_path, frame_file, template_name, 1.0) if not return_value: return False # If scaling_method is 1 or 2, the merged file shall be cropped if(scale_or_crop['scaling_method'] == '1'): whitespace = scale_or_crop['crop_whitespace'] cropped_file_path = os.path.join(output_folder, output_file) elif(scale_or_crop['scaling_method'] == '2'): whitespace = scale_or_crop['scale_whitespace'] cropped_file = "cropped_" + output_file cropped_file_path = os.path.join(input_folder, cropped_file) if(scale_or_crop['scaling_method'] == '1' or scale_or_crop['scaling_method'] == '2'): # Crop the file output_doc_pathname, exit_code, stdout_str, stderr_str = crop( ["-p", "0", "-a", "-" + whitespace, "-t", "250", "-A", "-o", cropped_file_path, merged_file_path], string_io=True, quiet=False) if(exit_code): exception_msg("Failed to crop file.\npdfCropMargins exitcode was: " + str(exit_code) + "\n\nstdout_str: " + str(stdout_str) + "\n\nstderr_str: " + str(stderr_str)) return False if(scale_or_crop['scaling_method'] == '2'): # If no frame layer is selected, create a blank file with same size as the first original file if(frame_file == 'None'): first_file = input_files[0] frame_file = "blank_file.pdf" if not create_blank_page(os.path.join(input_folder, first_file), os.path.join(input_folder, frame_file)): return False # Scale the cropped file. scaled_file_path = os.path.join(output_folder, output_file) if(pymupdf_merge): new_file_list = [cropped_file, frame_file] return merge_pdf_pymupdf_with_scaling(input_folder, new_file_list, scaled_file_path, frame_file, template_name, 1.0) else: return_value, frame_file_width, frame_file_height = get_page_size(os.path.join(input_folder, frame_file)) if not return_value: return False resized_cropped_file = "resized_" + cropped_file resized_cropped_file_path = os.path.join(input_folder, resized_cropped_file) resize_page_pypdf(cropped_file_path, resized_cropped_file_path, frame_file_width, frame_file_height) new_file_list = [frame_file, resized_cropped_file] return merge_pdf_pypdf(input_folder, new_file_list, scaled_file_path, frame_file, template_name, 1.0) return True def merge_pdf_pymupdf(input_folder: str, input_files: list, output_file_path: str, frame_file: str, template_name: str): try: output = None for filename in reversed(input_files): try: if output is None: output = pymupdf.open(os.path.join(input_folder, filename)) else: # using "with" to force RAII and avoid another "for" closing files with pymupdf.open(os.path.join(input_folder, filename)) as src: output[0].show_pdf_page(src[0].rect, # select output rect src, # input document overlay=False) except Exception: io_file_error_msg(merge_pdf_pymupdf.__name__, filename, input_folder) return False # Set correct page name in the table of contents (pdf outline) # toc = output.get_toc(simple=False) # print("Toc: ", toc) # toc[0][1] = template_name # output.set_toc(toc) # The above code doesn't work for the toc (outlines) of a pdf created by Kicad. # It correctly sets the name of the first page, but when clicking on a footprint it no longer zooms to that footprint # Lets do it the low-level way instead: try: xref = output.pdf_catalog() # get xref of the /Catalog # print(output.xref_object(xref)) # print object definition # for key in output.xref_get_keys(xref): # iterate over all keys and print the keys and values # print("%s = %s" % (key, output.xref_get_key(xref, key))) # The loop will output something like this: # Type = ('name', '/Catalog') # Pages = ('xref', '1 0 R') # Version = ('name', '/1.5') # PageMode = ('name', '/UseOutlines') # Outlines = ('xref', '20 0 R') # Names = ('xref', '4 0 R') # PageLayout = ('name', '/SinglePage') key_value = output.xref_get_key(xref, "Outlines") # Get the value of the 'Outlines' key xref = int(key_value[1].split(' ')[0]) # Set xref to the xref found in the value of the 'Outlines' key # The object now referenced by xref looks something like this: # Type = ('name', '/Outlines') # Count = ('int', '3') # First = ('xref', '11 0 R') # Last = ('xref', '11 0 R') key_value = output.xref_get_key(xref, "First") # Get the value of the 'First' key xref = int(key_value[1].split(' ')[0]) # Set xref to the xref found in the value of the 'First' key # The object now referenced by xref looks something like this: # Title = ('string', 'Page 1') # Parent = ('xref', '20 0 R') # Count = ('int', '-1') # First = ('xref', '12 0 R') # Last = ('xref', '12 0 R') # A = ('xref', '10 0 R') if output.xref_get_key(xref, "Title")[0] == 'string': # Check if the 'Title' key exists page_name = "(" + template_name + ")" output.xref_set_key(xref, "Title", page_name) except Exception: # If the first page was colored using colorize_pdf_pymupdf_with_transparency, then the table of # contents (pdf outline) has been lost. # Lets create a new toc with the correct page name. toc = [[1, template_name, 1]] output.set_toc(toc) output.save(output_file_path) # , garbage=2 output.close() except Exception: io_file_error_msg(merge_pdf_pymupdf.__name__, output_file_path) return False return True def merge_pdf_pymupdf_with_scaling(input_folder: str, input_files: list, output_file_path: str, frame_file: str, template_name: str, layer_scale: float): try: output = pymupdf.open() page = None for filename in reversed(input_files): try: # using "with" to force RAII and avoid another "for" closing files with pymupdf.open(os.path.join(input_folder, filename)) as src: if page is None: page = output.new_page(width=src[0].rect.width * layer_scale, height=src[0].rect.height * layer_scale) cropbox = pymupdf.Rect((page.rect.width - src[0].rect.width) / 2, (page.rect.height - src[0].rect.height) / 2, (page.rect.width + src[0].rect.width) / 2, (page.rect.height + src[0].rect.height) / 2) pos = cropbox if frame_file == filename else page.rect try: page.show_pdf_page(pos, # select output rect src, # input document overlay=False) except ValueError: # This happens if the page is blank. Which it is if we've created a blank frame file. pass except Exception: io_file_error_msg(merge_pdf_pymupdf_with_scaling.__name__, filename, input_folder) return False page.set_cropbox(cropbox) # Set correct page name in the table of contents (pdf outline) # When scaling is used, component references will not be retained toc = [[1, template_name, 1]] output.set_toc(toc) output.save(output_file_path) except Exception: io_file_error_msg(merge_pdf_pymupdf_with_scaling.__name__, output_file_path) return False return True def merge_pdf_pypdf(input_folder: str, input_files: list, output_file_path: str, frame_file: str, template_name: str, layer_scale: float): try: page = None for filename in input_files: try: filepath = os.path.join(input_folder, filename) pdf_reader = PdfReader(filepath) src_page: PageObject = pdf_reader.pages[0] op = Transformation() if layer_scale > 1.0: if filename == frame_file: x_offset = src_page.mediabox.width * (layer_scale - 1.0) / 2 y_offset = src_page.mediabox.height * (layer_scale - 1.0) / 2 op = op.translate(x_offset, y_offset) else: op = op.scale(layer_scale) if page is None: page = PageObject.create_blank_page(width=src_page.mediabox.width * layer_scale, height=src_page.mediabox.height * layer_scale) page.cropbox.lower_left = ((page.mediabox.width - src_page.mediabox.width) / 2, (page.mediabox.height - src_page.mediabox.height) / 2) page.cropbox.upper_right = ((page.mediabox.width + src_page.mediabox.width) / 2, (page.mediabox.height + src_page.mediabox.height) / 2) page.merge_transformed_page(src_page, op) except Exception: error_bitmap = "" error_msg = traceback.format_exc() if 'KeyError: 0' in error_msg: error_bitmap = "This error can be caused by the presence of a bitmap image on this layer. Bitmaps are only allowed on the layer furthest down in the layer list. See Issue #11 for more information.\n\n" io_file_error_msg(merge_pdf_pypdf.__name__, filename, input_folder, error_bitmap) return False output = PdfWriter() output.add_page(page) output.add_outline_item(title=template_name, page_number=0) with open(output_file_path, "wb") as output_stream: output.write(output_stream) except: io_file_error_msg(merge_pdf_pypdf.__name__, output_file_path) return False return True def resize_page_pypdf(input_file_path: str, output_file_path: str, page_width: float, page_height: float): reader = PdfReader(input_file_path) page = reader.pages[0] writer = PdfWriter() w = float(page.mediabox.width) h = float(page.mediabox.height) scale_factor = min(page_height/h, page_width/w) # Calculate the final amount of blank space in width and height space_w = page_width - w*scale_factor space_h = page_height - h*scale_factor # Calculate offsets to center the scaled result x_offset = -page.cropbox.left*scale_factor + space_w/2 y_offset = -page.cropbox.bottom*scale_factor + space_h/2 transform = Transformation().scale(scale_factor).translate(x_offset, y_offset) page.add_transformation(transform) page.cropbox = generic.RectangleObject((0, 0, page_width, page_height)) page.mediabox = generic.RectangleObject((0, 0, page_width, page_height)) writer.add_page(page) writer.write(output_file_path) def create_pdf_from_pages(input_folder, input_files, output_folder, output_file, use_popups): try: output = PdfWriter() for filename in input_files: try: output.append(os.path.join(input_folder, filename)) except: io_file_error_msg(create_pdf_from_pages.__name__, filename, input_folder) return False # If popup menus are used, add the needed javascript. Pypdf and PyMuPdf removes this in most operations. if use_popups: javascript_string = "function ShM(aEntries) { var aParams = []; for (var i in aEntries) { aParams.push({ cName: aEntries[i][0], cReturn: aEntries[i][1] }) } var cChoice = app.popUpMenuEx.apply(app, aParams); if (cChoice != null && cChoice.substring(0, 1) == '#') this.pageNum = parseInt(cChoice.slice(1)); else if (cChoice != null && cChoice.substring(0, 4) == 'http') app.launchURL(cChoice); }" output.add_js(javascript_string) for page in output.pages: # This has to be done on the writer, not the reader! page.compress_content_streams() # This is CPU intensive! output.write(os.path.join(output_folder, output_file)) except: io_file_error_msg(create_pdf_from_pages.__name__, output_file, output_folder) return False return True class LayerInfo: std_color = "#000000" std_transparency = 0 def __init__(self, layer_names: dict, layer_name: str, template: dict, frame_layer: str, popups: str): self.name: str = layer_name self.id: int = layer_names[layer_name] self.color_hex: str = template["layers"].get(layer_name, self.std_color) # color as '#rrggbb' hex string self.with_frame: bool = layer_name == frame_layer try: self.transparency_value = int(template["layers_transparency"][layer_name]) # transparency as '0'-'100' string except KeyError: self.transparency_value = 0 try: # Bool specifying if layer is negative self.negative = template["layers_negative"][layer_name] == "true" except KeyError: self.negative = False try: # Bool specifying if footprint values shall be plotted self.footprint_value = template["layers_footprint_values"][layer_name] == "true" except KeyError: self.footprint_value = True try: # Bool specifying if footprint references shall be plotted self.reference_designator = template["layers_reference_designators"][layer_name] == "true" except KeyError: self.reference_designator = True # Check the popup settings. self.front_popups = True self.back_popups = True if popups == "Front Layer": self.back_popups = False elif popups == "Back Layer": self.front_popups = False elif popups == "None": self.front_popups = False self.back_popups = False @property def has_color(self) -> bool: """Checks if the layer color is not the standard color (=black).""" return self.color_hex != self.std_color @property def has_transparency(self) -> bool: """Checks if the layer transparency is not the standard value (=0%).""" return self.transparency_value != self.std_transparency @property def color_rgb(self) -> tuple[float, float, float]: """Return (red, green, blue) in float between 0-1.""" value = self.color_hex.lstrip('#') lv = len(value) rgb = tuple(int(value[i:i + lv // 3], 16) for i in range(0, lv, lv // 3)) rgb = (rgb[0] / 255, rgb[1] / 255, rgb[2] / 255) return rgb @property def color_rgb_int(self) -> tuple[int, int, int]: """Return (red, green, blue) in float between 0-1.""" value = self.color_hex.lstrip('#') lv = len(value) rgb = tuple(int(value[i:i + lv // 3], 16) for i in range(0, lv, lv // 3)) rgb = (rgb[0], rgb[1], rgb[2]) return rgb @property def transparency(self) -> int: """Return 0-100 in str.""" value = self.transparency_value return value def __repr__(self): var_str = ', '.join(f"{key}: {value}" for key, value in vars(self).items()) return f'{self.__class__.__name__}:{{ {var_str} }}' class Template: def __init__(self, name: str, template: dict, layer_names: dict): self.name: str = name # the template name self.mirrored: bool = template.get("mirrored", False) # template is mirrored or not self.tented: bool = template.get("tented", False) # template is tented or not self.scale_or_crop: dict = { "scaling_method": template.get("scaling_method", "0"), "crop_whitespace": template.get("crop_whitespace", "10"), "scale_whitespace": template.get("scale_whitespace", "30"), "scaling_factor": template.get("scaling_factor", "3.0") } frame_layer: str = template.get("frame", "") # layer name of the frame layer popups: str = template.get("popups", "") # setting saying if popups shall be taken from front, back or both # collection the settings of the enabled layers self.settings: list[LayerInfo] = [] if "enabled_layers" in template: enabled_layers = template["enabled_layers"].split(',') for el in enabled_layers: if el: # If this is the first layer, use the popup settings. if el == enabled_layers[0]: layer_popups: str = popups else: layer_popups: str = "None" # Prepend to settings layer_info = LayerInfo(layer_names, el, template, frame_layer, layer_popups) self.settings.insert(0, layer_info) @property def steps(self) -> int: """number of process steps for this template""" return len(self.settings) + sum([layer.has_color for layer in self.settings]) @property def steps_without_coloring(self) -> int: """number of process steps for this template""" return len(self.settings) @property def has_transparency(self) -> bool: for layer_info in self.settings: if layer_info.has_transparency: return True return False def __repr__(self): var_str = ', '.join(f"{key}: {value}" for key, value in vars(self).items()) return f'{self.__class__.__name__}:{{ {var_str} }}' def create_kicad_color_template(template_settings, template_file_path: str) -> bool: layer_color_name = { "F.Cu" : "f", "In1.Cu" : "in1", "In2.Cu" : "in2", "In3.Cu" : "in3", "In4.Cu" : "in4", "In5.Cu" : "in5", "In6.Cu" : "in6", "In7.Cu" : "in7", "In8.Cu" : "in8", "In9.Cu" : "in9", "In10.Cu" : "in10", "In11.Cu" : "in11", "In12.Cu" : "in12", "In13.Cu" : "in13", "In14.Cu" : "in14", "In15.Cu" : "in15", "In16.Cu" : "in16", "In17.Cu" : "in17", "In18.Cu" : "in18", "In19.Cu" : "in19", "In20.Cu" : "in20", "In21.Cu" : "in21", "In22.Cu" : "in22", "In23.Cu" : "in23", "In24.Cu" : "in24", "In25.Cu" : "in25", "In26.Cu" : "in26", "In27.Cu" : "in27", "In28.Cu" : "in28", "In29.Cu" : "in29", "In30.Cu" : "in30", "In31.Cu" : "in31", "In32.Cu" : "in32", "In33.Cu" : "in33", "In34.Cu" : "in34", "In35.Cu" : "in35", "In36.Cu" : "in36", "In37.Cu" : "in37", "In38.Cu" : "in38", "In39.Cu" : "in39", "In40.Cu" : "in40", "In41.Cu" : "in41", "In42.Cu" : "in42", "In43.Cu" : "in43", "In44.Cu" : "in44", "In45.Cu" : "in45", "In46.Cu" : "in46", "In47.Cu" : "in47", "In48.Cu" : "in48", "In49.Cu" : "in49", "In50.Cu" : "in50", "In51.Cu" : "in51", "In52.Cu" : "in52", "In53.Cu" : "in53", "In54.Cu" : "in54", "In55.Cu" : "in55", "In56.Cu" : "in56", "In57.Cu" : "in57", "In58.Cu" : "in58", "In59.Cu" : "in59", "In60.Cu" : "in60", "In61.Cu" : "in61", "In62.Cu" : "in62", "B.Cu" : "b", "B.Adhesive" : "b_adhes", "F.Adhesive" : "f_adhes", "B.Paste" : "b_paste", "F.Paste" : "f_paste", "B.Silkscreen" : "b_silks", "F.Silkscreen" : "f_silks", "B.Mask" : "b_mask", "F.Mask" : "f_mask", "User.Drawings" : "dwgs_user", "User.Comments" : "cmts_user", "User.Eco1" : "eco1_user", "User.Eco2" : "eco2_user", "Edge.Cuts" : "edge_cuts", "Margin" : "margin", "B.Courtyard" : "b_crtyd", "F.Courtyard" : "f_crtyd", "B.Fab" : "b_fab", "F.Fab" : "f_fab", "User.1" : "user_1", "User.2" : "user_2", "User.3" : "user_3", "User.4" : "user_4", "User.5" : "user_5", "User.6" : "user_6", "User.7" : "user_7", "User.8" : "user_8", "User.9" : "user_9", "User.10" : "user_10", "User.11" : "user_11", "User.12" : "user_12", "User.13" : "user_13", "User.14" : "user_14", "User.15" : "user_15", "User.16" : "user_16", "User.17" : "user_17", "User.18" : "user_18", "User.19" : "user_19", "User.20" : "user_20", "User.21" : "user_21", "User.22" : "user_22", "User.23" : "user_23", "User.24" : "user_24", "User.25" : "user_25", "User.26" : "user_26", "User.27" : "user_27", "User.28" : "user_28", "User.29" : "user_29", "User.30" : "user_30", "User.31" : "user_31", "User.32" : "user_32", "User.33" : "user_33", "User.34" : "user_34", "User.35" : "user_35", "User.36" : "user_36", "User.37" : "user_37", "User.38" : "user_38", "User.39" : "user_39", "User.40" : "user_40", "User.41" : "user_41", "User.42" : "user_42", "User.43" : "user_43", "User.44" : "user_44", "User.45" : "user_45", "Rescue" : "" } copper_dict = {} layers_dict = {} for layer_info in template_settings: color_string = "rgb(" + str(layer_info.color_rgb_int[0]) + ", " + str(layer_info.color_rgb_int[1]) + ", " + str(layer_info.color_rgb_int[2]) + ")" if pcbnew.IsCopperLayer(layer_info.id): copper_dict[layer_color_name[layer_info.name]] = color_string else: layers_dict[layer_color_name[layer_info.name]] = color_string # If this is the frame layer then set "worksheet" to this color as well. # This doesn't work in KiCad 7.0. Was fixed in KiCad 8.0. See https://gitlab.com/kicad/code/kicad/-/commit/077159ac130d276af043695afbf186f0565035e9 if layer_info.with_frame: layers_dict["worksheet"] = color_string layers_dict["copper"] = copper_dict color_template = { "board" : layers_dict, "meta" : { "name" : "Board2Pdf Template", "version" : 5 } } with open(template_file_path, 'w') as f: json.dump(color_template, f, ensure_ascii=True, indent=4, sort_keys=True) return True def plot_pdfs(board, dlg=None, **kwargs) -> bool: output_path: str = kwargs.pop('output_path', 'plot') templates: list = kwargs.pop('templates', []) enabled_templates: list = kwargs.pop('enabled_templates', []) create_svg: bool = kwargs.pop('create_svg', False) del_temp_files: bool = kwargs.pop('del_temp_files', True) del_single_page_files: bool = kwargs.pop('del_single_page_files', True) asy_file_extension = kwargs.pop('assembly_file_extension', '__Assembly') colorize_lib: str = kwargs.pop('colorize_lib', '') merge_lib: str = kwargs.pop('merge_lib', '') page_info: str = kwargs.pop('page_info', '') info_variable: str = kwargs.pop('info_variable', '0') if dlg is None: if(colorize_lib == 'kicad'): pymupdf_color = False kicad_color = True elif(colorize_lib == 'pymupdf' or colorize_lib == 'fitz'): pymupdf_color = True kicad_color = False else: pymupdf_color = False kicad_color = False pymupdf_merge = has_pymupdf and merge_lib != 'pypdf' def set_progress_status(progress: int, status: str): print(f'{int(progress):3d}%: {status}') def msg_box(text, caption, flags): print(f"{caption}: {text}") elif isinstance(dlg, wx.Panel): pymupdf_color = dlg.m_radio_pymupdf.GetValue() kicad_color = dlg.m_radio_kicad.GetValue() pymupdf_merge = dlg.m_radio_merge_pymupdf.GetValue() def set_progress_status(progress: int, status: str): dlg.m_staticText_status.SetLabel(f'Status: {status}') dlg.m_progress.SetValue(int(progress)) dlg.Refresh() dlg.Update() def msg_box(text, caption, flags): wx.MessageBox(text, caption, flags) else: print(f"Error: Unknown dialog type {type(dlg)}", file=sys.stderr) return False if (pymupdf_color or pymupdf_merge or create_svg) and not has_pymupdf: msg_box( "PyMuPdf wasn't loaded.\n\nIt must be installed for it to be used for coloring, for merging and for creating SVGs.\n\nMore information under Install dependencies in the Wiki at board2pdf.dennevi.com", 'Error', wx.OK | wx.ICON_ERROR) set_progress_status(100, "Failed to load PyMuPDF.") return False if not has_pdfcropmargins: # Check if any of the enabled templates uses pdfCropMargins use_pdfcropmargins = False for t in enabled_templates: if t in templates: if "scaling_method" in templates[t]: if templates[t]["scaling_method"] == "1" or templates[t]["scaling_method"] == "2": use_pdfcropmargins = True if use_pdfcropmargins: msg_box( "pdfCropMargins wasn't loaded.\n\nSome of the Scale and Crop settings requires pdfCropMargins to be installed.\n\nMore information under Install dependencies in the Wiki at board2pdf.dennevi.com", 'Error', wx.OK | wx.ICON_ERROR) set_progress_status(100, "Failed to load pdfCropMargins.") return False # colorize_pdf function points to colorize_pdf_pymupdf if pymupdf_color is true, else it points to colorize_pdf_pypdf # This function is only used if kicad_color is False colorize_pdf = colorize_pdf_pymupdf if pymupdf_color else colorize_pdf_pypdf os.chdir(os.path.dirname(board.GetFileName())) output_dir = os.path.abspath(os.path.expanduser(os.path.expandvars(output_path))) if del_temp_files: # in case the files are deleted: use the OS temp directory temp_dir = tempfile.mkdtemp() else: temp_dir = os.path.abspath(os.path.join(output_dir, "temp")) progress = 5 set_progress_status(progress, "Started plotting...") plot_controller = pcbnew.PLOT_CONTROLLER(board) plot_options = plot_controller.GetPlotOptions() base_filename = os.path.basename(os.path.splitext(board.GetFileName())[0]) final_assembly_file = base_filename + asy_file_extension + ".pdf" final_assembly_file_with_path = os.path.abspath(os.path.join(output_dir, final_assembly_file)) if "assembly_file_output" in kwargs: final_assembly_file = kwargs.pop('assembly_file_output') final_assembly_file_with_path = str(Path(final_assembly_file_with_path).absolute()) # Create the directory if it doesn't exist already os.makedirs(output_dir, exist_ok=True) # Check if we're able to write to the output file. try: # os.access(os.path.join(output_dir, final_assembly_file), os.W_OK) open(os.path.join(output_dir, final_assembly_file), "w") except: msg_box("The output file is not writeable. Perhaps it's open in another application?\n\n" + final_assembly_file_with_path, 'Error', wx.OK | wx.ICON_ERROR) set_progress_status(100, "Failed to write to output file.") return False plot_options.SetOutputDirectory(temp_dir) # Build a dict to translate layer names to layerID layer_names = {} for i in range(pcbnew.PCBNEW_LAYER_ID_START, pcbnew.PCBNEW_LAYER_ID_START + pcbnew.PCB_LAYER_ID_COUNT): layer_names[board.GetStandardLayerName(i)] = i steps: int = 2 # number of process steps templates_list: list[Template] = [] warn_about_transparancy = False for t in enabled_templates: # { "Test-template": {"mirrored": true, "enabled_layers": "B.Fab,B.Mask,Edge.Cuts,F.Adhesive", "frame": "In4.Cu", # "layers": {"B.Fab": "#000012", "B.Mask": "#000045"}} } if t in templates: temp = Template(t, templates[t], layer_names) _logger.debug(temp) # If not using PyMuPdf, check if any layers are transparant if not pymupdf_color: if temp.has_transparency: warn_about_transparancy = True # Count how many steps it will take to complete this operation if kicad_color: steps += 1 + temp.steps_without_coloring else: steps += 1 + temp.steps # Build list of templates that shall be used templates_list.append(temp) progress_step: float = 95 / steps if warn_about_transparancy: msg_box("One or more layers have transparency set. Transparancy only works when using PyMuPDF for coloring.", 'Warning', wx.OK | wx.ICON_WARNING) try: # Set General Options: if int(pcbnew.Version()[0:1]) < 9: plot_options.SetPlotInvisibleText(False) else: plot_options.SetHideDNPFPsOnFabLayers(False) plot_options.SetSketchDNPFPsOnFabLayers(False) plot_options.SetCrossoutDNPFPsOnFabLayers(False) # plot_options.SetPlotPadsOnSilkLayer(False); plot_options.SetUseAuxOrigin(False) plot_options.SetScale(1.0) plot_options.SetAutoScale(False) # plot_options.SetPlotMode(PLOT_MODE) # plot_options.SetLineWidth(2000) if pcbnew.Version()[0:3] == "6.0": # This method is only available on V6, not V6.99/V7 plot_options.SetExcludeEdgeLayer(True) except: msg_box(traceback.format_exc(), 'Error', wx.OK | wx.ICON_ERROR) set_progress_status(100, "Failed to set plot_options") return False use_popups = False template_filelist = [] title_block = board.GetTitleBlock() info_variable_int = int(info_variable) if(info_variable_int>=1 and info_variable_int<=9): previous_comment = title_block.GetComment(info_variable_int-1) # Iterate over the templates for page_count, template in enumerate(templates_list): if kicad_color: sm = pcbnew.GetSettingsManager() template_file_path = os.path.join(sm.GetColorSettingsPath(), "board2pdf.json") if not create_kicad_color_template(template.settings, template_file_path): set_progress_status(100, f"Failed to create color template for template {template.name}") return False plot_controller.SetColorMode(True) sm.ReloadColorSettings() cs = sm.GetColorSettings("board2pdf") plot_options.SetColorSettings(cs) plot_options.SetBlackAndWhite(False) page_info_tmp = page_info.replace("${template_name}", template.name) page_info_tmp = page_info_tmp.replace("${page_nr}", str(page_count + 1)) page_info_tmp = page_info_tmp.replace("${total_pages}", str(len(templates_list))) if(info_variable_int>=1 and info_variable_int<=9): title_block.SetComment(info_variable_int-1, page_info_tmp) board.SetTitleBlock(title_block) # msg_box("Now starting with template: " + template_name) # Plot layers to pdf files for layer_info in template.settings: progress += progress_step set_progress_status(progress, f"Plotting {layer_info.name} for template {template.name}") if pcbnew.Version()[0:3] == "6.0": if pcbnew.IsCopperLayer(layer_info.id): # Should probably do this on mask layers as well plot_options.SetDrillMarksType( 2) # NO_DRILL_SHAPE = 0, SMALL_DRILL_SHAPE = 1, FULL_DRILL_SHAPE = 2 else: plot_options.SetDrillMarksType( 0) # NO_DRILL_SHAPE = 0, SMALL_DRILL_SHAPE = 1, FULL_DRILL_SHAPE = 2 else: # API changed in V6.99/V7 try: if pcbnew.IsCopperLayer(layer_info.id): # Should probably do this on mask layers as well plot_options.SetDrillMarksType(pcbnew.DRILL_MARKS_FULL_DRILL_SHAPE) else: plot_options.SetDrillMarksType(pcbnew.DRILL_MARKS_NO_DRILL_SHAPE) except: msg_box( "Unable to set Drill Marks type.\n\nIf you're using a V6.99 build from before Dec 07 2022 then update to a newer build.\n\n" + traceback.format_exc(), 'Error', wx.OK | wx.ICON_ERROR) set_progress_status(100, "Failed to set Drill Marks type") return False try: plot_options.SetPlotFrameRef(layer_info.with_frame) plot_options.SetNegative(layer_info.negative) plot_options.SetPlotValue(layer_info.footprint_value) plot_options.SetPlotReference(layer_info.reference_designator) plot_options.SetMirror(template.mirrored) if int(pcbnew.Version()[0:1]) < 9: plot_options.SetPlotViaOnMaskLayer(template.tented) if int(pcbnew.Version()[0:1]) >= 8: plot_options.m_PDFFrontFPPropertyPopups = layer_info.front_popups plot_options.m_PDFBackFPPropertyPopups = layer_info.back_popups plot_controller.SetLayer(layer_info.id) if pcbnew.Version()[0:3] == "6.0": plot_controller.OpenPlotfile(layer_info.name, pcbnew.PLOT_FORMAT_PDF, template.name) else: plot_controller.OpenPlotfile(layer_info.name, pcbnew.PLOT_FORMAT_PDF, template.name, template.name) plot_controller.PlotLayer() except: msg_box(traceback.format_exc(), 'Error', wx.OK | wx.ICON_ERROR) set_progress_status(100, "Failed to set plot_options or plot_controller") return False plot_controller.ClosePlot() template_use_popups = False frame_file = 'None' filelist = [] # Change color of pdf files for layer_info in template.settings: ln = layer_info.name.replace('.', '_') input_file = f"{base_filename}-{ln}.pdf" output_file = f"{base_filename}-{ln}-colored.pdf" if not kicad_color and ( layer_info.has_color or layer_info.has_transparency ): progress += progress_step set_progress_status(progress, f"Coloring {layer_info.name} for template {template.name}") if not colorize_pdf(temp_dir, input_file, output_file, layer_info.color_rgb, layer_info.transparency): set_progress_status(100, f"Failed when coloring {layer_info.name} for template {template.name}") return False filelist.append(output_file) else: filelist.append(input_file) if layer_info.with_frame: # the frame layer is scaled by 1.0, all others by `layer_scale` #### Seems wrong to set frame_file to this!! We should be able to check which layer is chosen. frame_file = filelist[-1] # Set template_use_popups to True if any layer has popups template_use_popups = template_use_popups or layer_info.front_popups or layer_info.back_popups # Merge pdf files progress += progress_step set_progress_status(progress, f"Merging all layers of template {template.name}") assembly_file = f"{base_filename}_{template.name}.pdf" assembly_file = assembly_file.replace(' ', '_') if not merge_and_scale_pdf(temp_dir, filelist, output_dir, assembly_file, frame_file, template.scale_or_crop, template.name, pymupdf_merge): set_progress_status(100, "Failed when merging all layers of template " + template.name) return False template_filelist.append(assembly_file) # Set use_popups to True if any template has popups use_popups = use_popups or template_use_popups if kicad_color: # Delete Board2Pdf color template try: os.remove(template_file_path) except: msg_box(f"Delete color template file failed\n\n" + traceback.format_exc(), 'Error', wx.OK | wx.ICON_ERROR) set_progress_status(100, "Failed to delete color template file") return False sm = pcbnew.GetSettingsManager() sm.ReloadColorSettings() if(info_variable_int>=1 and info_variable_int<=9): title_block.SetComment(info_variable_int-1, previous_comment) # Add all generated pdfs to one file progress += progress_step set_progress_status(progress, "Adding all templates to a single file") if not create_pdf_from_pages(output_dir, template_filelist, output_dir, final_assembly_file, use_popups): set_progress_status(100, "Failed when adding all templates to a single file") return False # Create SVG(s) if settings says so if create_svg: for template_file in template_filelist: template_pdf = pymupdf.open(os.path.join(output_dir, template_file)) try: svg_image = template_pdf[0].get_svg_image() svg_filename = os.path.splitext(template_file)[0] + ".svg" with open(os.path.join(output_dir, svg_filename), "w") as file: file.write(svg_image) except: msg_box("Failed to create SVG in {output_dir}\n\n" + traceback.format_exc(), 'Error', wx.OK | wx.ICON_ERROR) set_progress_status(100, "Failed to create SVG(s)") return False template_pdf.close() # Delete temp files if setting says so if del_temp_files: try: shutil.rmtree(temp_dir) except: msg_box(f"del_temp_files failed\n\nOn dir {temp_dir}\n\n" + traceback.format_exc(), 'Error', wx.OK | wx.ICON_ERROR) set_progress_status(100, "Failed to delete temp files") return False # Delete single page files if setting says so if del_single_page_files: for template_file in template_filelist: delete_file = os.path.join(output_dir, os.path.splitext(template_file)[0] + ".pdf") try: os.remove(delete_file) except: msg_box(f"del_single_page_files failed\n\nOn file {delete_file}\n\n" + traceback.format_exc(), 'Error', wx.OK | wx.ICON_ERROR) set_progress_status(100, "Failed to delete single files") return False set_progress_status(100, "All done!") endmsg = "Assembly pdf created: " + os.path.abspath(os.path.join(output_dir, final_assembly_file)) if not del_single_page_files: endmsg += "\n\nSingle page pdf files created:" for template_file in template_filelist: endmsg += "\n" + os.path.abspath(os.path.join(output_dir, os.path.splitext(template_file)[0] + ".pdf")) if create_svg: endmsg += "\n\nSVG files created:" for template_file in template_filelist: endmsg += "\n" + os.path.abspath(os.path.join(output_dir, os.path.splitext(template_file)[0] + ".svg")) msg_box(endmsg, 'All done!', wx.OK) return True