Files
2025-08-28 03:07:33 +07:00

1261 lines
53 KiB
Python

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