# # This program source code file is part of KiCad, a free EDA CAD application. # # Copyright (C) 2023 Mark Roszko # Copyright (C) 2023 KiCad Developers # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. # import difflib import os import platform import cairosvg import logging import subprocess from typing import Tuple from pathlib import Path from PIL import Image, ImageChops, ImageFilter, ImageEnhance import numpy as np logger = logging.getLogger("cli_util") Image.MAX_IMAGE_PIXELS = 800 * 1024 * 1024 // 4 # Increase limit to ~800MB uncompressed RGBA, 4bpp (~600MB RGB, 3bpp) def kicad_cli() -> str: if 'KICAD_CLI' in os.environ: return os.environ.get('KICAD_CLI') return "kicad-cli" def run_and_capture( command: list ) -> Tuple[ str, str, int ]: logger.info("Executing command \"%s\"", " ".join( command )) env = {} env.update(os.environ) if 'KICAD_CONFIG_HOME' not in env: if 'QA_DATA_ROOT' in env: base_path = env.get('QA_DATA_ROOT') else: # cwd ./qa/tests/cli/ base_path = os.path.join(os.getcwd(), '../../data') env['KICAD_CONFIG_HOME'] = os.path.join(base_path, 'config') env['KICAD9_SYMBOL_DIR'] = os.path.join(base_path, 'libraries') env['KICAD9_FOOTPRINT_DIR'] = os.path.join(base_path, 'libraries') proc = subprocess.Popen( command, stdout = subprocess.PIPE, stderr = subprocess.PIPE, encoding = 'utf-8', env = env ) out,err = proc.communicate() if proc.returncode != 0 or len(err) != 0: logger.info(f"command returned {proc.returncode}") logger.info(f"stdout: {out}") logger.info(f"stderr: {err}") return out, err, proc.returncode def textdiff_files( golden_filepath: str, new_filepath: str, skip: int = 0 ) -> bool: status: bool = True with open( golden_filepath, 'r' ) as f: golden_lines = f.readlines()[skip:] with open( new_filepath, 'r' ) as f: new_lines = f.readlines()[skip:] diff = difflib.unified_diff( golden_lines, new_lines, fromfile = golden_filepath, tofile = new_filepath ) diff_text = ''.join(list(diff)) if diff_text != "": logger.info( "Text diff found:" ) logger.info( diff_text ) return diff_text == "" def image_is_blank( image_path: str ) -> bool: img = Image.open( image_path ) sum = np.sum( np.asarray( img ) ) img.close() return sum == 0 def images_are_equal( image1_path: str, image2_path: str ) -> bool: # Note: if modifying this function - please add new tests for it in test_utils.py image1 = Image.open( image1_path ) image2 = Image.open( image2_path ) if image1.size != image2.size: return False if image1.mode != image2.mode: return False diff = ImageChops.difference( image1, image2 ) sum = np.sum( np.asarray( diff ) ) retval = True if sum != 0.0: # Images are not identical - lets allow 1 pixel error difference (for curved edges) diff_multi_bands = diff.split() binary_multi_bands = [] for band_diff in diff_multi_bands: thresholded_band = band_diff.point( lambda x: x > 0 and 255 ) binary_multi_bands.append( thresholded_band.convert( "1" ) ) binary_result = binary_multi_bands[0] for i in range( 1, len( binary_multi_bands ) ): binary_result = ImageChops.logical_or( binary_result, binary_multi_bands[i] ) eroded_result = binary_result.copy().filter( ImageFilter.MinFilter( 3 ) ) # erode once (trim 1 pixel) eroded_result_sum = np.sum( np.asarray( eroded_result ) ) retval = eroded_result_sum == 0 diff.close() binary_result.close() # Save diff if not retval: diff_name = image1.filename + ".DIFF_eroded_" + str( eroded_result_sum )+ ".png" red = Image.new( "RGB", image1.size, (255,0,0)) imageEnhanced = ImageEnhance.Contrast(image1).enhance(0.3) imageEnhanced.paste( red,mask=eroded_result) imageEnhanced.save(diff_name) logger.error( "Images not equal. Diff stored at '%s'", diff_name ) imageEnhanced.close() # Cleanup eroded_result.close() # Cleanup image1.close() image2.close() return retval def get_png_paths( generated_path: str, source_path : str, suffix : str = "" ) -> Tuple[str, str]: generated_stem = Path( generated_path ).stem generated_png_path = Path( generated_path ).with_stem( generated_stem + suffix).with_suffix( ".png" ) if generated_png_path.exists(): generated_png_path.unlink() # Delete file # store source png in same folder as generated, easier to compare source_stem = Path( source_path ).stem source_png_path = Path( generated_path ).with_stem( source_stem + "-source" + suffix).with_suffix( ".png" ) if source_png_path.exists(): source_png_path.unlink() # Delete file return str( generated_png_path ), str( source_png_path ) def svgs_are_equivalent( svg_generated_path: str, svg_source_path: str, comparison_dpi: int ) -> bool: png_generated, png_source = get_png_paths( svg_generated_path, svg_source_path ) cairosvg.svg2png( url=svg_generated_path, write_to=png_generated, dpi=comparison_dpi ) cairosvg.svg2png( url=svg_source_path, write_to=png_source, dpi=comparison_dpi ) return images_are_equal( png_generated , png_source ) def gerbers_are_equivalent( gerber_generated_path : str, gerber_source_path : str, comparison_dpi : int, originInches : Tuple[float, float], windowsizeInches : Tuple[float, float] ) -> bool: # Calculate tiles required noTilesRowsCols = np.array( [1,1] ) increaseRow = True tileSizeInches=np.array( windowsizeInches ) / noTilesRowsCols while( np.prod( tileSizeInches * comparison_dpi ) > Image.MAX_IMAGE_PIXELS // 2 ): if increaseRow: noTilesRowsCols[0]+=1 else: noTilesRowsCols[1]+=1 increaseRow=not increaseRow tileSizeInches=np.array( windowsizeInches ) / noTilesRowsCols gerberGeneratedIsBlank=True gerberSourceIsBlank=True gerbersAreEqual=True for row in range( noTilesRowsCols[0] ): for col in range( noTilesRowsCols[1] ): tileOrigin=np.array( originInches ) + ( np.array( [row,col] ) * tileSizeInches ) tile_name=f"R{row}C{col}" png_generated, png_source = get_png_paths( gerber_generated_path, gerber_source_path, tile_name ) convert_gerber_to_png( gerber_generated_path, png_generated, comparison_dpi, tileOrigin, tileSizeInches ) convert_gerber_to_png( gerber_source_path, png_source, comparison_dpi, tileOrigin, tileSizeInches ) gerberGeneratedIsBlank = gerberGeneratedIsBlank and image_is_blank( png_generated ) gerberSourceIsBlank = gerberSourceIsBlank and image_is_blank( png_source ) if( not images_are_equal( png_generated, png_source ) ): gerbersAreEqual = False assert( not gerberGeneratedIsBlank ) assert( not gerberSourceIsBlank ) # make sure test case is generated correctly return gerbersAreEqual def convert_gerber_to_png( gerber_path : str, png_path : str, dpi : int, originInches : Tuple[float, float], windowsizeInches : Tuple[float, float] ): originStr="{:.2f}".format(originInches[0]) + "x" + "{:.2f}".format(originInches[1]) windowsizeInchesStr="{:.2f}".format(windowsizeInches[0]) + "x" + "{:.2f}".format(windowsizeInches[1]) stdout, stderr, exitcode = run_and_capture(["gerbv", "--export=png", f"--dpi={dpi}", f"--origin={originStr}", f"--window_inch={windowsizeInchesStr}", f"--output={png_path}", "--foreground=#FFFFFF", "--background=#000000", gerber_path ]) def is_gerbv_installed() -> bool: try: stdout, stderr, exitcode = run_and_capture(["gerbv", "--version"]) except: return False return exitcode == 0 and stdout is not None and stdout.startswith("gerbv version")