From dcaa5643d224ad134c7df57ee8579846f97258e7 Mon Sep 17 00:00:00 2001 From: "Salvador E. Tropea" Date: Thu, 31 Oct 2024 14:12:44 -0300 Subject: [PATCH] [PCB Print][Added][Experimental] A mechanism to paste images - In boxes from groups named "_kibot_image_OUTPUT" - Only PNGs - Very raw, WIP --- kibot/bom/html_writer.py | 7 +- kibot/kicad/worksheet.py | 34 +-- kibot/misc.py | 43 +++- kibot/out_navigate_results.py | 22 +- kibot/out_pcb_print.py | 105 +++++++- .../kicad_8/kibot_images.kicad_pcb | 234 ++++++++++++++++++ .../pcb_print_kibot_images.kibot.yaml | 32 +++ 7 files changed, 421 insertions(+), 56 deletions(-) create mode 100644 tests/board_samples/kicad_8/kibot_images.kicad_pcb create mode 100644 tests/yaml_samples/pcb_print_kibot_images.kibot.yaml diff --git a/kibot/bom/html_writer.py b/kibot/bom/html_writer.py index 81ce7189..f7b4074a 100644 --- a/kibot/bom/html_writer.py +++ b/kibot/bom/html_writer.py @@ -225,9 +225,10 @@ def content_table(html, groups, headings, head_names, cfg, link_datasheet, link_ def embed_image(file): - s, w, h = read_png(file) - if s is None: - raise BoMError('Only PNG images are supported for the logo') + try: + s, w, h, _ = read_png(file, logger) + except TypeError as e: + raise BoMError(f'Only PNG images are supported for the logo ({e})') return int(w), int(h), 'data:image/png;base64,'+b64encode(s).decode('ascii') diff --git a/kibot/kicad/worksheet.py b/kibot/kicad/worksheet.py index 34478745..64171f7c 100644 --- a/kibot/kicad/worksheet.py +++ b/kibot/kicad/worksheet.py @@ -13,7 +13,6 @@ """ from base64 import b64decode import io -from struct import unpack from pcbnew import wxPoint, wxSize, FromMM, wxPointMM from ..gs import GS if not GS.kicad_version_n: @@ -34,7 +33,7 @@ from .sexp_helpers import (_check_is_symbol_list, _check_float, _check_integer, _check_symbol_value, _check_str, _check_symbol, _check_relaxed, _get_points, _check_symbol_str, Color) from ..svgutils.transform import ImageElement, GroupElement -from ..misc import W_WKSVERSION +from ..misc import W_WKSVERSION, read_png from .. import log logger = log.get_logger() @@ -425,33 +424,10 @@ def draw(e, p): p.images.append(e) def parse_png(e): - s = e.data - offset = 8 - ppi = 300 - w = h = -1 - if s[0:8] != b'\x89PNG\r\n\x1a\n': - raise WksError('Image is not a PNG') - logger.debugl(2, 'Parsing PNG chunks') - while offset < len(s): - size, type = unpack('>L4s', s[offset:offset+8]) - logger.debugl(2, f'- Chunk {type} ({size})') - if type == b'IHDR': - w, h = unpack('>LL', s[offset+8:offset+16]) - logger.debugl(2, f' - Size {w}x{h}') - elif type == b'pHYs': - dpi_w, dpi_h, units = unpack('>LLB', s[offset+8:offset+17]) - if dpi_w != dpi_h: - raise WksError(f'PNG with different resolution for X and Y ({dpi_w} {dpi_h})') - if units != 1: - raise WksError(f'PNG with unknown units ({units})') - ppi = dpi_w/(100/2.54) - logger.debugl(2, f' - PPI {ppi} ({dpi_w} {dpi_h} {units})') - break - elif type == b'IEND': - break - offset += size+12 - if w == -1: - raise WksError('Broken PNG, no IHDR chunk') + try: + _, w, h, ppi = read_png(e.data, logger, only_size=False) + except TypeError as e: + raise WksError(str(e)) return w, h, ppi def add_to_svg(e, svg, p, svg_precision): diff --git a/kibot/misc.py b/kibot/misc.py index 3f578a3c..9aa74e50 100644 --- a/kibot/misc.py +++ b/kibot/misc.py @@ -553,13 +553,42 @@ def version_str2tuple(ver): return tuple(map(int, ver.split('.'))) -def read_png(file): - with open(file, 'rb') as f: - s = f.read() - if not (s[:8] == b'\x89PNG\r\n\x1a\n' and (s[12:16] == b'IHDR')): - return None, None, None - w, h = unpack('>LL', s[16:24]) - return s, w, h +def read_png(file, logger, only_size=True): + if isinstance(file, str): + with open(file, 'rb') as f: + s = f.read() + else: + # The data itself as bytes + s = file + offset = 8 + ppi = 300 + w = h = -1 + if s[0:8] != b'\x89PNG\r\n\x1a\n': + raise TypeError('Image is not a PNG') + logger.debugl(2, 'Parsing PNG chunks') + while offset < len(s): + size, type = unpack('>L4s', s[offset:offset+8]) + logger.debugl(2, f'- Chunk {type} ({size})') + if type == b'IHDR': + w, h = unpack('>LL', s[offset+8:offset+16]) + logger.debugl(2, f' - Size {w}x{h}') + if only_size: + return s, w, h, ppi + elif type == b'pHYs': + dpi_w, dpi_h, units = unpack('>LLB', s[offset+8:offset+17]) + if dpi_w != dpi_h: + raise TypeError(f'PNG with different resolution for X and Y ({dpi_w} {dpi_h})') + if units != 1: + raise TypeError(f'PNG with unknown units ({units})') + ppi = dpi_w/(100/2.54) + logger.debugl(2, f' - PPI {ppi} ({dpi_w} {dpi_h} {units})') + break + elif type == b'IEND': + break + offset += size+12 + if w == -1: + raise TypeError('Broken PNG, no IHDR chunk') + return s, w, h, ppi def force_list(v): diff --git a/kibot/out_navigate_results.py b/kibot/out_navigate_results.py index 52826898..80543368 100644 --- a/kibot/out_navigate_results.py +++ b/kibot/out_navigate_results.py @@ -26,7 +26,6 @@ import pprint from shutil import copy2 from math import ceil -from struct import unpack from .bom.kibot_logo import KIBOT_LOGO, KIBOT_LOGO_W, KIBOT_LOGO_H from .error import KiPlotConfigurationError from .gs import GS @@ -254,15 +253,6 @@ def _run_command(cmd): return True -def get_png_size(file): - with open(file, 'rb') as f: - s = f.read() - if not (s[:8] == b'\x89PNG\r\n\x1a\n' and (s[12:16] == b'IHDR')): - return 0, 0 - w, h = unpack('>LL', s[16:24]) - return int(w), int(h) - - class Navigate_ResultsOptions(BaseOptions): def __init__(self): with document: @@ -301,9 +291,10 @@ def config(self, parent): self.logo = os.path.abspath(self.logo) if not os.path.isfile(self.logo): raise KiPlotConfigurationError('Missing logo file `{}`'.format(self.logo)) - self._logo_data, self._logo_w, self._logo_h = read_png(self.logo) - if self._logo_data is None: - raise KiPlotConfigurationError('Only PNG images are supported for the logo') + try: + self._logo_data, self._logo_w, self._logo_h, _ = read_png(self.logo, logger) + except TypeError as e: + raise KiPlotConfigurationError(f'Only PNG images are supported for the logo ({e})') if self.logo == '': # Internal logo self._logo_w = int(KIBOT_LOGO_W/2) @@ -453,7 +444,10 @@ def get_image_for_file(self, file, out_name, no_icon=False, image=None): # It was converted, replace the icon by the composited image img = new_img # Compute its size - width, height = get_png_size(fimg) + try: + _, width, height, _ = read_png(fimg, logger) + except TypeError: + width = height = 0 # We are using the big size wide = True # Now add the image with its file name as caption diff --git a/kibot/out_pcb_print.py b/kibot/out_pcb_print.py index 564ec775..0feef0bf 100644 --- a/kibot/out_pcb_print.py +++ b/kibot/out_pcb_print.py @@ -29,7 +29,9 @@ # version: '2.40' # id: rsvg2 from copy import deepcopy +from dataclasses import dataclass import datetime +import io import re import os import importlib @@ -48,12 +50,14 @@ from .kicad.v5_sch import SchError from .kicad.pcb import PCB from .misc import (PDF_PCB_PRINT, W_PDMASKFAIL, W_MISSTOOL, PCBDRAW_ERR, W_PCBDRAW, VIATYPE_THROUGH, VIATYPE_BLIND_BURIED, - VIATYPE_MICROVIA, FONT_HELP_TEXT, W_BUG16418, pretty_list, try_int, W_NOPAGES, W_NOLAYERS, W_NOTHREPE) + VIATYPE_MICROVIA, FONT_HELP_TEXT, W_BUG16418, pretty_list, try_int, W_NOPAGES, W_NOLAYERS, W_NOTHREPE, + RENDERERS, read_png) from .create_pdf import create_pdf_from_pages from .macros import macros, document, output_class # noqa: F401 from .drill_marks import DRILL_MARKS_MAP, add_drill_marks from .layer import Layer, get_priority -from .kiplot import run_command, load_board, get_all_components +from .kiplot import run_command, load_board, get_all_components, look_for_output, get_output_targets, run_output +from .svgutils.transform import ImageElement, GroupElement from . import __version__ from . import log @@ -68,6 +72,14 @@ kicad_worksheet = None # Also needs svgutils +@dataclass +class ImageGroup: + name: str + layer: int + bbox: tuple + items: list + + def pcbdraw_warnings(tag, msg): logger.warning('{}({}) {}'.format(W_PCBDRAW, tag, msg)) @@ -662,7 +674,7 @@ def plot_frame_ki8_external(self, dir_name, p, page, pages, color): # https://gitlab.com/kicad/code/kicad/-/issues/18959 logger.debugl(1, ' - Fixing images') # Do a manual draw, just to collect any image - ws.draw(GS.board, GS.board.GetLayerID('Rescue'), page, self.pcb.paper_w, self.pcb.paper_h, tb_vars) + ws.draw(GS.board, GS.board.GetLayerID(GS.work_layer), page, self.pcb.paper_w, self.pcb.paper_h, tb_vars) ws.undraw(GS.board) # We need to plot the images in a separated pass self.last_worksheet = ws @@ -926,6 +938,88 @@ def search_text(self, svg, texts): # Process all text inside self.search_text_for_g(e, texts) + def move_kibot_image_groups(self): + """ Look for KiBot image groups (_kibot_image_*) + Move them to the Rescue layer + Memorize them to restore and analysis """ + self._image_groups = [] + tmp_layer = GS.board.GetLayerID(GS.work_layer) + for g in GS.board.Groups(): + name = g.GetName() + if not name.startswith('_kibot_image_'): + continue + x1, y1, x2, y2 = GS.compute_group_boundary(g) + moved = [] + layer = None + for item in g.GetItems(): + if layer is None: + layer = item.GetLayer() + moved.append((item, item.GetLayer())) + item.SetLayer(tmp_layer) + self._image_groups.append(ImageGroup(name, layer, (x1, y1, x2, y2), moved)) + + def restore_kibot_image_groups(self): + """ Move the KiBot image groups (_kibot_image_*) to their original layers """ + for g in self._image_groups: + for item in g.items: + item[0].SetLayer(item[1]) + self._image_groups = [] + + def add_output_images(self, svg, page): + """ Look for groups named _kibot_image_OUTPUT and paste images from the referred OUTPUTs """ + # Check which layers we printed + layers = {la._id for la in page._layers} + # Look for groups + logger.debug('Looking for image groups in the PCB') + for g in self._image_groups: + name = g.name + if not name.startswith('_kibot_image_'): + continue + logger.debugl(2, f'- Found {name}') + # Check if this group is for a layer we printed + if g.layer not in layers: + logger.debug('- {name} not in printed layers') + continue + # Look for the image from the output + output_name = name[13:] + output_obj = look_for_output(output_name, '`include image`', self._parent, RENDERERS) + targets, _, _ = get_output_targets(output_name, self._parent) + targets = [fn for fn in targets if fn.endswith('.png')] + if not targets: + raise KiPlotConfigurationError("PCB group `{name}` uses `{output_name}` which doesn't generate any PNG") + fname = targets[0] + logger.debugl(2, f'- Related image: {fname}') + if not os.path.exists(fname): + # The target doesn't exist + if not output_obj._done: + # The output wasn't created in this run, try running it + logger.debug('- Not yet generated, tying to generate it') + run_output(output_obj) + if not os.path.exists(fname): + raise KiPlotConfigurationError("Failed to generate `{fname}` for PCB group `{name}`") + # Add the image to the SVG + try: + s, w, h, dpi = read_png(fname, logger, only_size=False) + except TypeError as e: + raise KiPlotConfigurationError(f'Error reading {fname} size: {e} for PCB group `{name}`') + logger.debugl(2, f'- PNG: {w}x{h} {dpi} PPIs') + x1, y1, x2, y2 = g.bbox + logger.debugl(2, f'- Box: {x1},{y1} {x2},{y2} IUs') + # Convert pixels to mm and then to KiCad units + # w = GS.from_mm(w/dpi*25.4) + # h = GS.from_mm(h/dpi*25.4) + # logger.error(f'{w}x{h} IUs') + scale = GS.iu_to_svg(1.0, self.svg_precision) # This is the scale to convert IUs to SVG units + logger.debugl(2, f'- Scale {scale}') + # Put the image at the box coordinates with its size + img = ImageElement(io.BytesIO(s), x2-x1, y2-y1) + img.moveto(x1, y1) + img.scale(scale) + # Put the image in a group + g = GroupElement([img]) + # Add the group to the SVG + svg.append(g) + def merge_svg(self, input_folder, input_files, output_folder, output_file, p): """ Merge all layers into one page """ first = True @@ -950,6 +1044,7 @@ def merge_svg(self, input_folder, input_files, output_folder, output_file, p): first = False self.process_background(svg_out, width, height) self.add_frame_images(svg_out, p.monochrome) + self.add_output_images(svg_out, p) else: root = new_layer.getroot() # Adjust the coordinates of this section to the main width @@ -1315,6 +1410,8 @@ def generate_output(self, output): # Make visible only the layers we need # This is very important when scaling, otherwise the results are controlled by the .kicad_prl (See #407) self.set_visible(edge_id) + # Move KiBot image groups away + self.move_kibot_image_groups() # Generate the output, page by page pages = [] for n, p in enumerate(self._pages): @@ -1450,6 +1547,8 @@ def generate_output(self, output): # Use GS to create one PNG per page and then scale to the wanted width self.pdf_to_png(pdf_file, out_file) self.rename_pages(output_dir) + # Restore KiBot image groups away + self.restore_kibot_image_groups() # Remove the temporal files if not self.keep_temporal_files: rmtree(temp_dir_base) diff --git a/tests/board_samples/kicad_8/kibot_images.kicad_pcb b/tests/board_samples/kicad_8/kibot_images.kicad_pcb new file mode 100644 index 00000000..d5acba0f --- /dev/null +++ b/tests/board_samples/kicad_8/kibot_images.kicad_pcb @@ -0,0 +1,234 @@ +(kicad_pcb + (version 20240108) + (generator "pcbnew") + (generator_version "8.0") + (general + (thickness 1.6) + (legacy_teardrops no) + ) + (paper "A4") + (layers + (0 "F.Cu" signal) + (31 "B.Cu" signal) + (32 "B.Adhes" user "B.Adhesive") + (33 "F.Adhes" user "F.Adhesive") + (34 "B.Paste" user) + (35 "F.Paste" user) + (36 "B.SilkS" user "B.Silkscreen") + (37 "F.SilkS" user "F.Silkscreen") + (38 "B.Mask" user) + (39 "F.Mask" user) + (40 "Dwgs.User" user "User.Drawings") + (41 "Cmts.User" user "User.Comments") + (42 "Eco1.User" user "User.Eco1") + (43 "Eco2.User" user "User.Eco2") + (44 "Edge.Cuts" user) + (45 "Margin" user) + (46 "B.CrtYd" user "B.Courtyard") + (47 "F.CrtYd" user "F.Courtyard") + (48 "B.Fab" user) + (49 "F.Fab" user) + (50 "User.1" user) + (51 "User.2" user) + (52 "User.3" user) + (53 "User.4" user) + (54 "User.5" user) + (55 "User.6" user) + (56 "User.7" user) + (57 "User.8" user) + (58 "User.9" user) + ) + (setup + (pad_to_mask_clearance 0) + (allow_soldermask_bridges_in_footprints no) + (pcbplotparams + (layerselection 0x00010fc_ffffffff) + (plot_on_all_layers_selection 0x0000000_00000000) + (disableapertmacros no) + (usegerberextensions no) + (usegerberattributes yes) + (usegerberadvancedattributes yes) + (creategerberjobfile yes) + (dashed_line_dash_ratio 12.000000) + (dashed_line_gap_ratio 3.000000) + (svgprecision 4) + (plotframeref yes) + (viasonmask no) + (mode 1) + (useauxorigin no) + (hpglpennumber 1) + (hpglpenspeed 20) + (hpglpendiameter 15.000000) + (pdf_front_fp_property_popups yes) + (pdf_back_fp_property_popups yes) + (dxfpolygonmode yes) + (dxfimperialunits yes) + (dxfusepcbnewfont yes) + (psnegative no) + (psa4output no) + (plotreference yes) + (plotvalue yes) + (plotfptext yes) + (plotinvisibletext no) + (sketchpadsonfab no) + (subtractmaskfromsilk no) + (outputformat 4) + (mirror no) + (drillshape 0) + (scaleselection 1) + (outputdirectory "") + ) + ) + (net 0 "") + (gr_rect + (start 100 100) + (end 179 148) + (stroke + (width 0.2) + (type default) + ) + (fill none) + (layer "F.Cu") + (uuid "1db1e6bd-8e22-4f07-ad68-ab7edaac0dd4") + ) + (gr_rect + (start 107 44) + (end 180 80) + (stroke + (width 0.1) + (type default) + ) + (fill none) + (layer "Edge.Cuts") + (uuid "4a0bec0f-bc9a-49ce-aec4-be493c210d40") + ) + (gr_text "pp.png" + (at 106 105 0) + (layer "F.Cu") + (uuid "ecc984e5-5de9-493f-ad3a-5f4fa8c4594c") + (effects + (font + (size 1.5 1.5) + (thickness 0.3) + (bold yes) + ) + (justify left bottom) + ) + ) + (segment + (start 165 62) + (end 170 57) + (width 0.1) + (layer "F.Cu") + (net 0) + (uuid "2168b789-aea6-44ac-a3de-b05e0717b576") + ) + (segment + (start 120 58) + (end 126 64) + (width 0.1) + (layer "F.Cu") + (net 0) + (uuid "32870afb-1764-4ad0-bf75-970f099b31a9") + ) + (segment + (start 164 62) + (end 165 62) + (width 0.1) + (layer "F.Cu") + (net 0) + (uuid "441e34b8-b52b-46b5-a696-e8419c0dbec1") + ) + (segment + (start 149 68) + (end 155 68) + (width 0.1) + (layer "F.Cu") + (net 0) + (uuid "4949b618-2da0-41a0-a74f-c93e97e6af13") + ) + (segment + (start 141 64) + (end 146 59) + (width 0.1) + (layer "F.Cu") + (net 0) + (uuid "5bc9d339-f19b-42fb-9c01-3675b3a627d0") + ) + (segment + (start 158 68) + (end 164 62) + (width 0.1) + (layer "F.Cu") + (net 0) + (uuid "6a2b2e90-c5e7-4070-85ac-4a57347d333c") + ) + (segment + (start 126 64) + (end 141 64) + (width 0.1) + (layer "F.Cu") + (net 0) + (uuid "6db04be2-71a4-482b-bea9-39ca611368e9") + ) + (segment + (start 155 68) + (end 158 68) + (width 0.1) + (layer "F.Cu") + (net 0) + (uuid "c849788b-05fd-49ba-95ff-fef1b4c69462") + ) + (segment + (start 146 59) + (end 146 65) + (width 0.1) + (layer "F.Cu") + (net 0) + (uuid "d19c33c7-15ae-48e2-bc4b-7661317337a3") + ) + (segment + (start 146 65) + (end 149 68) + (width 0.1) + (layer "F.Cu") + (net 0) + (uuid "ea975319-371a-486f-8b6e-c19e156ecde2") + ) + (via + (at 146 59) + (size 0.8) + (drill 0.4) + (layers "F.Cu" "B.Cu") + (net 0) + (uuid "90cac0fc-67f8-4a66-bcae-b455afcddcb4") + ) + (via + (at 155 68) + (size 0.8) + (drill 0.4) + (layers "F.Cu" "B.Cu") + (net 0) + (uuid "9ddaaede-3433-41cc-ac30-e282f84c2d8a") + ) + (via + (at 120 58) + (size 0.8) + (drill 0.4) + (layers "F.Cu" "B.Cu") + (net 0) + (uuid "d6d1c810-d55e-48c1-8e4a-5ce9d70c69c5") + ) + (via + (at 170 57) + (size 0.8) + (drill 0.4) + (layers "F.Cu" "B.Cu") + (net 0) + (uuid "eb5f5ebb-1c56-465c-bc76-5ac890c49140") + ) + (group "_kibot_image_pcbdraw" + (uuid "cde91e27-d818-4fbc-9cc2-64aed07e5612") + (members "1db1e6bd-8e22-4f07-ad68-ab7edaac0dd4" "ecc984e5-5de9-493f-ad3a-5f4fa8c4594c") + ) +) diff --git a/tests/yaml_samples/pcb_print_kibot_images.kibot.yaml b/tests/yaml_samples/pcb_print_kibot_images.kibot.yaml new file mode 100644 index 00000000..6fd5c9f5 --- /dev/null +++ b/tests/yaml_samples/pcb_print_kibot_images.kibot.yaml @@ -0,0 +1,32 @@ +# Example KiBot config file +kibot: + version: 1 + +outputs: + - name: 'print_front' + comment: "Experiment" + type: pcb_print + options: + plot_sheet_reference: true + format: 'PDF' + keep_temporals: true + # frame_plot_mechanism: plot + pages: + - layers: + - layer: F.Cu + - layer: Edge.Cuts + color: "#FF4040" + + - name: pcbdraw + comment: "PcbDraw test top" + type: pcbdraw + dir: PcbDraw + options: + format: png + no_drillholes: True + placeholder: True + mirror: True + vcuts: True + warnings: all + show_components: all +