From 7590bde6192018cfac29ddb60b46968380dc40bc Mon Sep 17 00:00:00 2001 From: jaseg Date: Tue, 9 Dec 2025 14:17:18 +0100 Subject: [PATCH] Add simple GUI --- metadata.json | 28 ++ package.py | 106 +++++++ pyproject.toml | 7 +- src/kicoil/cli.py | 17 +- src/kicoil/geometry.py | 68 ++-- src/kicoil/gui.py | 687 +++++++++++++++++++++++++++++++++++++++++ src/kicoil/svg.py | 25 +- uv.lock | 195 ++++++++++++ 8 files changed, 1095 insertions(+), 38 deletions(-) create mode 100644 metadata.json create mode 100644 package.py create mode 100644 src/kicoil/gui.py diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..d15c29a --- /dev/null +++ b/metadata.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://go.kicad.org/pcm/schemas/v1", + "name": "KiCoil", + "description": "A planar inductor for KiCad", + "description_full": "KiCoil generates planar inductors as footprints. Currently, circular spiral and toroid inductors are supported. KiCoil supports arbitrary intermediates between spiral and toroid inductors. By playing around with this, you can create inductors that have lower parasitics and higher self-resonant frequency than standard multilayer spiral inductors.", + "identifier": "de.jaseg.kicoil", + "type": "plugin", + "author": { + "name": "jaseg", + "contact": { + "web": "https://jaseg.de/" + } + }, + "license": "GPL-3.0", + "resources": { + "homepage": "https://jaseg.de/projects/kicoil", + "git": "https://git.jaseg.de/kicoil", + "issues": "https://codeberg.org/jaseg/kicoil/issues" + }, + "versions": [ + { + "version": "0.9.0", + "status": "stable", + "kicad_version": "8.00" + } + ], + "runtime": "ipc" +} diff --git a/package.py b/package.py new file mode 100644 index 0000000..a07ed60 --- /dev/null +++ b/package.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +import re +import hashlib +import shutil +import subprocess +import json +from pathlib import Path + +import click + +def tree_size(path): + return sum(entry.stat().st_size for entry in path.glob('**/*') if entry.is_file()) + +@click.command() +@click.option('--major', 'increment', flag_value='major') +@click.option('--minor', 'increment', flag_value='minor', default=True) +@click.option('--patch', 'increment', flag_value='patch', default=True) +@click.argument('version', required=False) +def do_release(version, increment): + if not version: + tag = subprocess.run('git describe --tags --abbrev=0 --match v*'.split(), + check=True, capture_output=True, text=True) + major, minor, patch = map(int, re.fullmatch(r'v([0-9]+)\.([0-9]+)\.([0-9]+)', tag.stdout.strip()).groups()) + match increment: + case 'major': + major, minor, patch = (major+1, 0, 0) + case 'minor': + major, minor, patch = (major, minor+1, 0) + case 'patch': + major, minor, patch = (major, minor, patch+1) + version = f'{major}.{minor}.{patch}' + + res = subprocess.run('git status --porcelain --untracked-files=no'.split(), + check=True, capture_output=True, text=True) + if res.stdout.strip(): + raise click.ClickException('There are uncommitted changes in this repository.') + + print('Cleaning old footprints') + footprint_dir = Path('de.jaseg.kimesh.footprints') / 'footprints' + shutil.rmtree(footprint_dir, ignore_errors=True) + footprint_dir.mkdir() + + print('Re-generating footprints') + for n in range(1, 9): + subprocess.run(['python', '-m', 'footprint_generator', + '-w', '0.100,0.120,0.150,0.200,0.250,0.300,0.350,0.400,0.500,0.600,0.700,0.800,1.000,1.200,1.500,1.800', + '-c', '0.100,0.120,0.150,0.200,0.300,0.400,0.500', + '-n', str(n), + str(footprint_dir / f'kimesh_anchors_{n}wire.pretty') + ], check=True) + + res = subprocess.run('git ls-files'.split(), check=True, capture_output=True, text=True) + for path in res.stdout.splitlines(): + if re.fullmatch(r'de\.jaseg\.kimesh\.[^/]*-v[.0-9]*\.zip', path.strip()): + print(f'Removing old release zip {path} from git index.') + subprocess.run(['git', 'rm', path], check=True, capture_output=True) + + for pkg_dir in Path('de.jaseg.kimesh.plugin'), Path('de.jaseg.kimesh.footprints'): + # NOTE: metadata.json appears twice. In what I believe is a sub-optimal design choice, the variant in the + # archive is only allowed to contain the current version in its version list without its zip file metadata, + # while the variant in the repository index is supposed to contain all past versions including their zip file + # metadata. AFAICT they are the same otherwise. + meta_path = Path(f'{pkg_dir}-repo-metadata.json') + + print(f'Updating metadata file {meta_path}') + ver_dict = { + 'version': version, + 'status': 'stable', + 'kicad_version': '7.99', + } + + # Include just the version metadata in the metadata for the archive + meta_file = json.loads(meta_path.read_text()) + meta_file['versions'] = [ver_dict] + (pkg_dir / 'metadata.json').write_text(json.dumps(meta_file, indent=4)) + + zip_fn = Path(shutil.make_archive(f'{pkg_dir.name}-v{version}', 'zip', pkg_dir, '.')) + print(f'Adding new release zip {zip_fn} to git index.') + subprocess.run(['git', 'add', str(zip_fn)], check=True, capture_output=True) + + # Add the zip's metadata to the metadata for the repository + ver_dict['download_sha256'] = hashlib.sha256(zip_fn.read_bytes()).hexdigest() + ver_dict['download_size'] = zip_fn.stat().st_size + ver_dict['download_url'] = f'https://git.jaseg.de/kimesh.git/plain/{zip_fn.name}?h=v{version}' + ver_dict['install_size'] = tree_size(pkg_dir) + + meta_file = json.loads(meta_path.read_text()) + meta_file['versions'].append(ver_dict) + meta_path.write_text(json.dumps(meta_file, indent=4)) + + print(f'Adding updated metadata file {meta_path} to git index') + subprocess.run(['git', 'add', str(meta_path)], check=True, capture_output=True) + + print('Create git commit') + subprocess.run(['git', 'commit', '-m', f'Version {version}', '--no-edit'], check=True, capture_output=True) + res = subprocess.run('git rev-parse --short HEAD'.split(), check=True, capture_output=True, text=True) + print(f'Created commit {res.stdout.strip()}') + print(f'Creating and signing version tag v{version}') + subprocess.run(['git', + '-c', 'user.signingkey=E36F75307F0A0EC2D145FF5CED7A208EEEC76F2D', + '-c', 'user.email=python-mpv@jaseg.de', + 'tag', '-s', f'v{version}', '-m', f'Version v{version}'], + check=True) + +if __name__ == '__main__': + do_release() diff --git a/pyproject.toml b/pyproject.toml index 499d209..c212c0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,6 @@ maintainers = [ { name = "Kicoil maintainers", email = "kicoil@jaseg.de" }, ] keywords = ["kicad", "gerber", "pcb", "electronics", "EDA"] - classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Console", @@ -29,12 +28,16 @@ classifiers = [ [project.urls] Homepage = "https://jaseg.de/projects/kicoil/" Documentation = "https://kicoil.jaseg.de/" -Source = "https://codeberg.org/jaseg/kicoil" +Source = "https://git.jaseg.de/kicoil" "Source Mirror" = "https://git.jaseg.de/kicoil.git" Tracker = "https://codeberg.org/jaseg/kicoil/issues" [project.scripts] kicoil = "kicoil.cli:cli" +kicoil-gui = "kicoil.gui:main" + +[dependency-groups] +gui = ["cairosvg", "pillow"] [build-system] requires = ["uv-build"] diff --git a/src/kicoil/cli.py b/src/kicoil/cli.py index 6027a03..351a2cc 100644 --- a/src/kicoil/cli.py +++ b/src/kicoil/cli.py @@ -1,3 +1,17 @@ +# Copyright 2025 Jan Sebastian Götte +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# import logging import subprocess @@ -50,14 +64,13 @@ def print_valid_twists(ctx, param, value): @click.option('--arc-tolerance', type=float, default=0.02) @click.option('--format', type=click.Choice(['svg', 'gerber', 'kicad-footprint', 'kicad-pcb', 'show']), default='kicad-footprint') @click.option('--clipboard/--no-clipboard', help='Use clipboard integration (requires wl-clipboard)') -@click.option('--clockwise/--counter-clockwise', help='Direction of generated spiral. Default: counter-clockwise when wound from the inside.') +@click.option('--clockwise/--counter-clockwise', help='Direction of generated top layer spiral. Default: counter-clockwise when wound from the inside.') @click.option('--single-layer/--two-layer', help='Single-layer mode. This just forces twists to 0.') @click.version_option() def cli(outfile, footprint_name, clipboard, single_layer, arc_tolerance, circle_segments, format, **kwargs): logger = logging.getLogger('kicoil') if single_layer: - kwargs['twists'] = 0 kwargs['layers'] = 1 else: kwargs['layers'] = 2 diff --git a/src/kicoil/geometry.py b/src/kicoil/geometry.py index 874d30e..da81d06 100644 --- a/src/kicoil/geometry.py +++ b/src/kicoil/geometry.py @@ -1,3 +1,19 @@ + +# Copyright 2025 Jan Sebastian Götte +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + import warnings import logging from dataclasses import dataclass, field @@ -18,26 +34,7 @@ def point_line_distance(p, l1, l2): x1, y1 = l1 x2, y2 = l2 # https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line - return abs((x2-x1)*(y1-y0) - (x1-x0)*(y2-y1)) / sqrt((x2-x1)**2 + (y2-y1)**2) - -def line_line_intersection(l1, l2): - p1, p2 = l1 - p3, p4 = l2 - x1, y1 = p1 - x2, y2 = p2 - x3, y3 = p3 - x4, y4 = p4 - - # https://en.wikipedia.org/wiki/Line%E2%80%93line_intersection - px = ((x1*y2-y1*x2)*(x3-x4)-(x1-x2)*(x3*y4-y3*x4))/((x1-x2)*(y3-y4)-(y1-y2)*(x3-x4)) - py = ((x1*y2-y1*x2)*(y3-y4)-(y1-y2)*(x3*y4-y3*x4))/((x1-x2)*(y3-y4)-(y1-y2)*(x3-x4)) - return px, py - -def angle_between_vectors(va, vb): - angle = atan2(vb[1], vb[0]) - atan2(va[1], va[0]) - if angle < 0: - angle += 2*pi - return angle + return ((x2-x1)*(y1-y0) - (x1-x0)*(y2-y1)) / sqrt((x2-x1)**2 + (y2-y1)**2) # https://en.wikipedia.org/wiki/Farey_sequence#Next_term @@ -91,11 +88,14 @@ def arc_approximate(points, trace_width, layer, tolerance=0.02, level=0): yield from arc_approximate(points[i_mid:], trace_width, layer, tolerance, level+1) else: - yield kicad.make_arc(x0, y0, x2, y2, x1, y1, trace_width, layer) + if point_line_distance((x1, y1), (x0, y0), (x2, y2)) > 0: + yield kicad.make_arc(x0, y0, x2, y2, x1, y1, trace_width, layer) + else: + yield kicad.make_arc(x2, y2, x0, y0, x1, y1, trace_width, layer) def compute_spiral(r1, r2, a1, a2, fn=64): - fn = ceil(fn * (a2-a1)/(2*pi)) + fn = ceil(fn * abs(a2-a1)/(2*pi)) x0, y0 = cos(a1)*r1, sin(a1)*r1 dr = 3 if r2 < r1 else -3 @@ -119,7 +119,7 @@ class PlanarInductor(): inner_diameter: float turns: int twists: int - trace_width: float + trace_width: float = None clearance: float = None layers: int = 2 via_diameter: float = 0.6 @@ -139,9 +139,12 @@ class PlanarInductor(): self.inner_radius = self.inner_diameter/2 self.turns_per_layer = self.turns/self.layers self.sector_angle = 2*pi / self.twists + + if self.clockwise: + self.sector_angle *= -1 + self.sweeping_angle = self.sector_angle * self.turns_per_layer self.spiral_pitch = (self.outer_radius-self.inner_radius) / self.turns_per_layer - self.layers = 2 if self.twists > 0 else 1 self.R = None # will be calculated during render c1 = self.inner_radius @@ -151,6 +154,9 @@ class PlanarInductor(): alpha = (alpha1+alpha2)/2 self.projected_spiral_pitch = self.spiral_pitch*cos(alpha) + if self.layers == 1 and self.twists > 1: + warnings.warn('Warning: Twists set to a value other than 1, but single-layer mode is enabled. The twists value will be ignored.') + self.twists = 1 if self.turns < 1: raise ValueError(f'Error: PlanarInductor.turns must be 1 or more') @@ -248,9 +254,13 @@ class PlanarInductor(): self.logger.info(f'Approximate resistance: {self.R:g} Ω') + @property + def default_footprint_name(self): + return f'planar-coil-{self.outer_diameter:.2f}x{self.inner_diameter:.2f}-n{self.turns}-k{self.twists}' + def render_footprint(self, name=None, arc_tolerance=0.02, circle_segments=64): if name is None: - name = f'generated-coil-{self.outer_diameter:.2f}x{self.inner_diameter:.2f}-n{self.turns}-k{self.twists}' + name = self.default_footprint_name from . import __version__ footprint = Footprint( @@ -281,12 +291,14 @@ class PlanarInductor(): fn=circle_segments) x0, y0 = points_layer0[0] xn, yn = points_layer0[-1] + footprint.arcs.extend(arc_approximate(points_layer0, self.trace_width, self.layer_pair[0], arc_tolerance)) - if self.twists > 0: + if self.layers > 1: # Handle the returning arm on the bottom layer points_layer1, _ = compute_spiral(r1=self.inner_radius, r2=self.outer_radius, a1=fold_angle, a2=end_angle, fn=circle_segments) + footprint.arcs.extend(arc_approximate(points_layer1, self.trace_width, self.layer_pair[1], arc_tolerance)) else: # Add a straight connecting segment connecting the inner point to the outside of the spiral. @@ -294,9 +306,7 @@ class PlanarInductor(): xq = xn + cos(fold_angle) * dr yq = yn - sin(fold_angle) * dr points_layer1 = [(xn, yn), (xq, yq)] - - footprint.arcs.extend(arc_approximate(points_layer0, self.trace_width, self.layer_pair[0], arc_tolerance)) - footprint.arcs.extend(arc_approximate(points_layer1, self.trace_width, self.layer_pair[1], arc_tolerance)) + footprint.lines.append(kicad.make_line(xn, yn, xq, yq, self.trace_width, self.layer_pair[1])) # Handle inner via ring and process staggering if enabled r = self.inner_via_ring_radius diff --git a/src/kicoil/gui.py b/src/kicoil/gui.py new file mode 100644 index 0000000..3f0aaa8 --- /dev/null +++ b/src/kicoil/gui.py @@ -0,0 +1,687 @@ +# Copyright 2025 Jan Sebastian Götte +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# This file was created using claude.ai because hand-writing GUI code sucks. The rest of kicoil is written by hand. +# + +""" GUI for generating KiCad footprints using kicoil. """ + +import tkinter as tk +from tkinter import ttk, filedialog, messagebox, scrolledtext +import sys +import logging +import warnings +import traceback +from pathlib import Path +from contextlib import contextmanager +from io import BytesIO + +from .geometry import PlanarInductor, divisors +from .svg import make_transparent_svg + +try: + # for rendering gerbonara's svg output to PNG + import cairosvg + # for scaling the rendered PNGs to the UI's resolution since tkinter is very limited there + from PIL import Image, ImageTk + HAS_PREVIEW = True +except ImportError: + HAS_PREVIEW = False + + +class TextWidgetHandler(logging.Handler): + """Custom logging handler that writes to a tkinter Text widget""" + def __init__(self, text_widget): + super().__init__() + self.text_widget = text_widget + self.setFormatter(logging.Formatter('%(levelname)s: %(message)s')) + + def emit(self, record): + msg = self.format(record) + + # Determine tag based on log level + if record.levelno >= logging.ERROR: + tag = 'error' + elif record.levelno >= logging.WARNING: + tag = 'warning' + else: + tag = 'info' + + # Temporarily enable widget to insert text + self.text_widget['state'] = 'normal' + self.text_widget.insert(tk.END, msg + '\n', tag) + self.text_widget.see(tk.END) + self.text_widget['state'] = 'disabled' + self.text_widget.update_idletasks() + + +# https://stackoverflow.com/questions/27820178/how-to-add-placeholder-to-an-entry-in-tkinter +class EntryWithPlaceholder(tk.Entry): + def __init__(self, master=None, placeholder="empty", color='grey', *args, **kwargs): + # Initialize parent Entry with all provided arguments + super().__init__(master, *args, **kwargs) + + self.placeholder = placeholder + self.placeholder_color = color + self.showing_placeholder = False + + # Get the default foreground color from the created widget + self.default_fg_color = self['fg'] + + self.bind("", self.foc_in) + self.bind("", self.foc_out) + + # Check initial state and show placeholder if empty + # Use a small delay to ensure the widget is fully initialized + self.after(1, self._check_initial_state) + + def _check_initial_state(self): + if not super().get(): + self.put_placeholder() + + def put_placeholder(self): + self.showing_placeholder = True + self['fg'] = self.placeholder_color + super().delete(0, 'end') + super().insert(0, self.placeholder) + + def update_placeholder(self, new_placeholder): + self.placeholder = new_placeholder + # If currently showing placeholder, update the display + if self.showing_placeholder: + super().delete(0, 'end') + super().insert(0, self.placeholder) + + def foc_in(self, *args): + if self.showing_placeholder: + self.showing_placeholder = False + self['fg'] = self.default_fg_color + super().delete(0, 'end') + + def foc_out(self, *args): + if not super().get(): + self.put_placeholder() + else: + self.showing_placeholder = False + + def get(self): + if self.showing_placeholder: + return '' + return super().get() + + +class KiCoilGUI: + def __init__(self, root): + self.root = root + self.root.title("KiCoil - Planar Inductor Generator") + self.root.geometry("1000x650") + + style = ttk.Style() + style.theme_use('clam') + + main_container = ttk.Frame(root) + main_container.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + root.columnconfigure(0, weight=1) + root.rowconfigure(0, weight=1) + main_container.columnconfigure(0, weight=0) # Left panel doesn't grow horizontally + main_container.columnconfigure(1, weight=1) # Preview panel grows to fill space + main_container.rowconfigure(0, weight=1) + + main_frame = ttk.Frame(main_container, padding="10") + main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + + title_frame = ttk.Frame(main_frame) + title_frame.grid(row=0, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=(0, 10)) + ttk.Label(title_frame, text="Planar Inductor Generator", + font=('Helvetica', 16, 'bold')).pack(side=tk.LEFT) + + self.preview_visible = tk.BooleanVar(value=True) + self.preview_button = ttk.Button(title_frame, text="Hide Preview", + command=self.toggle_preview, width=15) + self.preview_button.pack(side=tk.RIGHT) + + self.notebook = ttk.Notebook(main_frame) + self.notebook.grid(row=1, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5) + + geometry_frame = ttk.Frame(self.notebook, padding="10") + self.notebook.add(geometry_frame, text="Geometry") + self.create_geometry_params(geometry_frame) + + traces_frame = ttk.Frame(self.notebook, padding="10") + self.notebook.add(traces_frame, text="Traces") + self.create_trace_params(traces_frame) + + via_frame = ttk.Frame(self.notebook, padding="10") + self.notebook.add(via_frame, text="Vias") + self.create_via_params(via_frame) + + output_frame = ttk.Frame(self.notebook, padding="10") + self.notebook.add(output_frame, text="Output") + self.create_output_params(output_frame) + + button_frame = ttk.Frame(main_frame) + button_frame.grid(row=2, column=0, columnspan=3, pady=10) + + ttk.Button(button_frame, text="Show Valid Twists", + command=self.show_valid_twists, width=20).pack(side=tk.LEFT, padx=5) + ttk.Button(button_frame, text="Generate and Save", + command=self.generate_footprint, width=20).pack(side=tk.LEFT, padx=5) + + status_label = ttk.Label(main_frame, text="Output:", font=('Helvetica', 10, 'bold')) + status_label.grid(row=3, column=0, sticky=tk.W, pady=(10, 0)) + + self.output_text = scrolledtext.ScrolledText(main_frame, height=8, width=80, state='disabled') + self.output_text.grid(row=4, column=0, columnspan=3, sticky=(tk.W, tk.E, tk.N, tk.S), pady=5) + self.output_text.tag_config('error', foreground='red') + self.output_text.tag_config('warning', foreground='orange') + self.output_text.tag_config('info', foreground='black') + + main_frame.columnconfigure(0, weight=1) + main_frame.rowconfigure(1, weight=0) # Notebook doesn't grow vertically + main_frame.rowconfigure(4, weight=1) # Output area takes all vertical space + + self.preview_frame = ttk.LabelFrame(main_container, text="Preview", padding=10) + + if HAS_PREVIEW: + # Create canvas for image display + self.preview_canvas = tk.Canvas(self.preview_frame, bg='white') + self.preview_canvas.pack(fill=tk.BOTH, expand=True) + self.preview_image = None # Store reference to prevent garbage collection + self.preview_raw_image = None # Store unscaled image for rescaling + + # Bind canvas resize event to update preview + self.preview_canvas.bind('', self._on_preview_resize) + + else: + info_text = "Preview not available\n\nInstall dependencies:\npip install cairosvg pillow" + self.preview_label = ttk.Label(self.preview_frame, text=info_text, + justify=tk.CENTER, anchor=tk.CENTER) + self.preview_label.pack(fill=tk.BOTH, expand=True) + + self.preview_frame.grid(row=0, column=1, sticky=(tk.N, tk.S, tk.E, tk.W), padx=(10, 10), pady=10) + + self.current_model = None + self._validation_after_id = None + self.setup_logging() + self.setup_traces() + self.root.after(100, self.validate_parameters) + + def _on_preview_resize(self, event): + # Debounce resize events - only update after resize is complete + if hasattr(self, '_resize_after_id') and self._resize_after_id is not None: + self.root.after_cancel(self._resize_after_id) + self._resize_after_id = self.root.after(100, self._rescale_preview) + + def _rescale_preview(self): + canvas_width = self.preview_canvas.winfo_width() + canvas_height = self.preview_canvas.winfo_height() + if canvas_width <= 1 or canvas_height <= 1 or self.preview_raw_image is None: + self.preview_canvas.delete("all") + return + + # Calculate scaling to fit within canvas + image = self.preview_raw_image.copy() + img_ratio = image.width / image.height + canvas_ratio = canvas_width / canvas_height + + if img_ratio > canvas_ratio: + # Image is wider than canvas + new_width = int(canvas_width * 0.95) + new_height = int(new_width / img_ratio) + else: + # Image is taller than canvas + new_height = int(canvas_height * 0.95) + new_width = int(new_height * img_ratio) + + image = image.resize((new_width, new_height), Image.Resampling.LANCZOS) + + self.preview_image = ImageTk.PhotoImage(image) + self.preview_canvas.create_image( + canvas_width // 2, canvas_height // 2, + image=self.preview_image, anchor=tk.CENTER + ) + + def toggle_preview(self): + if self.preview_visible.get(): + self.preview_frame.grid_forget() + self.preview_visible.set(False) + self.preview_button.config(text="Show Preview") + + current_width = self.root.winfo_width() + current_height = self.root.winfo_height() + new_width = max(680, current_width - 400) + self.root.geometry(f"{new_width}x{current_height}") + + else: + self.preview_frame.grid(row=0, column=1, sticky=(tk.N, tk.S, tk.E, tk.W), padx=(10, 0), pady=10) + self.preview_visible.set(True) + self.preview_button.config(text="Hide Preview") + + current_width = self.root.winfo_width() + current_height = self.root.winfo_height() + new_width = current_width + 400 # Add ~400px for preview + self.root.geometry(f"{new_width}x{current_height}") + + def create_geometry_params(self, parent): + row = 0 + + # Turns + ttk.Label(parent, text="Number of Turns:").grid(row=row, column=0, sticky=tk.W, pady=5) + self.turns_var = tk.IntVar(value=5) + ttk.Spinbox(parent, from_=1, to=100, textvariable=self.turns_var, + width=15).grid(row=row, column=1, sticky=tk.W, pady=5) + ttk.Label(parent, text="Number of spiral turns", + foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0)) + row += 1 + + # Twists + ttk.Label(parent, text="Twists per Revolution:").grid(row=row, column=0, sticky=tk.W, pady=5) + self.twists_var = tk.IntVar(value=1) + ttk.Spinbox(parent, from_=0, to=50, textvariable=self.twists_var, + width=15).grid(row=row, column=1, sticky=tk.W, pady=5) + ttk.Label(parent, text="Must be co-prime to turns", + foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0)) + row += 1 + + # Outer Diameter + ttk.Label(parent, text="Outer Diameter (mm):").grid(row=row, column=0, sticky=tk.W, pady=5) + self.outer_dia_var = tk.DoubleVar(value=50.0) + ttk.Spinbox(parent, from_=1, to=500, increment=0.5, + textvariable=self.outer_dia_var, width=15).grid(row=row, column=1, sticky=tk.W, pady=5) + ttk.Label(parent, text="Outside diameter of coil", + foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0)) + row += 1 + + # Inner Diameter + ttk.Label(parent, text="Inner Diameter (mm):").grid(row=row, column=0, sticky=tk.W, pady=5) + self.inner_dia_var = tk.DoubleVar(value=25.0) + ttk.Spinbox(parent, from_=0, to=500, increment=0.5, + textvariable=self.inner_dia_var, width=15).grid(row=row, column=1, sticky=tk.W, pady=5) + ttk.Label(parent, text="Inside diameter of coil", + foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0)) + row += 1 + + # Layer Mode + ttk.Label(parent, text="Layer Mode:").grid(row=row, column=0, sticky=tk.W, pady=5) + self.layer_mode_var = tk.IntVar(value=2) + layer_frame = ttk.Frame(parent) + layer_frame.grid(row=row, column=1, columnspan=2, sticky=tk.W, pady=5) + ttk.Radiobutton(layer_frame, text="Two Layer", variable=self.layer_mode_var, + value=2).pack(side=tk.LEFT, padx=(0, 10)) + ttk.Radiobutton(layer_frame, text="Single Layer", variable=self.layer_mode_var, + value=1).pack(side=tk.LEFT) + row += 1 + + # Direction + ttk.Label(parent, text="Winding Direction:").grid(row=row, column=0, sticky=tk.W, pady=5) + self.direction_var = tk.StringVar(value="counter-clockwise") + dir_frame = ttk.Frame(parent) + dir_frame.grid(row=row, column=1, columnspan=2, sticky=tk.W, pady=5) + ttk.Radiobutton(dir_frame, text="Counter-Clockwise", variable=self.direction_var, + value="counter-clockwise").pack(side=tk.LEFT, padx=(0, 10)) + ttk.Radiobutton(dir_frame, text="Clockwise", variable=self.direction_var, + value="clockwise").pack(side=tk.LEFT) + row += 1 + + def create_trace_params(self, parent): + row = 0 + + # Trace Width + ttk.Label(parent, text="Trace Width (mm):").grid(row=row, column=0, sticky=tk.W, pady=5) + self.trace_width_entry = EntryWithPlaceholder(parent, placeholder="automatic", width=15) + self.trace_width_entry.grid(row=row, column=1, sticky=tk.W, pady=5) + row += 1 + + # Clearance + ttk.Label(parent, text="Clearance (mm):").grid(row=row, column=0, sticky=tk.W, pady=5) + self.clearance_entry = EntryWithPlaceholder(parent, placeholder="automatic", width=15) + self.clearance_entry.grid(row=row, column=1, sticky=tk.W, pady=5) + row += 1 + + # Copper Thickness (in µm) + ttk.Label(parent, text="Copper Thickness (µm):").grid(row=row, column=0, sticky=tk.W, pady=5) + self.copper_thickness_var = tk.DoubleVar(value=35.0) # 35µm = 0.035mm = 1 Oz + ttk.Spinbox(parent, from_=1, to=1000, increment=1, format="%.1f", + textvariable=self.copper_thickness_var, width=15).grid(row=row, column=1, sticky=tk.W, pady=5) + ttk.Label(parent, text="35µm = 1 Oz copper", + foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0)) + row += 1 + + def create_via_params(self, parent): + """Create via parameter controls""" + row = 0 + + # Via Diameter + ttk.Label(parent, text="Via Diameter (mm):").grid(row=row, column=0, sticky=tk.W, pady=5) + self.via_diameter_var = tk.DoubleVar(value=0.6) + ttk.Spinbox(parent, from_=0.1, to=5.0, increment=0.1, format="%.2f", + textvariable=self.via_diameter_var, width=15).grid(row=row, column=1, sticky=tk.W, pady=5) + row += 1 + + # Via Drill + ttk.Label(parent, text="Via Drill (mm):").grid(row=row, column=0, sticky=tk.W, pady=5) + self.via_drill_entry = EntryWithPlaceholder(parent, placeholder="automatic", width=15) + self.via_drill_entry.grid(row=row, column=1, sticky=tk.W, pady=5) + row += 1 + + # Via Offset + ttk.Label(parent, text="Via Offset (mm):").grid(row=row, column=0, sticky=tk.W, pady=5) + self.via_offset_entry = EntryWithPlaceholder(parent, placeholder="automatic", width=15) + self.via_offset_entry.grid(row=row, column=1, sticky=tk.W, pady=5) + row += 1 + + # Stagger Inner Vias + self.stagger_inner_var = tk.BooleanVar(value=False) + ttk.Checkbutton(parent, text="Stagger inner via ring", + variable=self.stagger_inner_var).grid(row=row, column=0, columnspan=2, sticky=tk.W, pady=5) + row += 1 + + # Stagger Outer Vias + self.stagger_outer_var = tk.BooleanVar(value=False) + ttk.Checkbutton(parent, text="Stagger outer via ring", + variable=self.stagger_outer_var).grid(row=row, column=0, columnspan=2, sticky=tk.W, pady=5) + row += 1 + + def create_output_params(self, parent): + row = 0 + + # Footprint Name + ttk.Label(parent, text="Footprint Name:").grid(row=row, column=0, sticky=tk.W, pady=5) + self.footprint_name_entry = EntryWithPlaceholder(parent, placeholder="automatic") + self.footprint_name_entry.grid(row=row, column=1, columnspan=2, sticky=(tk.W, tk.E), pady=5) + row += 1 + + # KiCad layer names + copper_layers = ['F.Cu', 'B.Cu'] + [f'In{i}.Cu' for i in range(1, 31)] + + # Top Layer + ttk.Label(parent, text="Top Layer:").grid(row=row, column=0, sticky=tk.W, pady=5) + self.top_layer_var = tk.StringVar(value="F.Cu") + top_layer_combo = ttk.Combobox(parent, textvariable=self.top_layer_var, + values=copper_layers, state='readonly', width=23) + top_layer_combo.grid(row=row, column=1, sticky=tk.W, pady=5) + row += 1 + + # Bottom Layer + ttk.Label(parent, text="Bottom Layer:").grid(row=row, column=0, sticky=tk.W, pady=5) + self.bottom_layer_var = tk.StringVar(value="B.Cu") + bottom_layer_combo = ttk.Combobox(parent, textvariable=self.bottom_layer_var, + values=copper_layers, state='readonly', width=23) + bottom_layer_combo.grid(row=row, column=1, sticky=tk.W, pady=5) + row += 1 + + # Circle Segments + ttk.Label(parent, text="Circle Segments:").grid(row=row, column=0, sticky=tk.W, pady=5) + self.circle_segments_var = tk.IntVar(value=64) + ttk.Spinbox(parent, from_=8, to=360, textvariable=self.circle_segments_var, + width=15).grid(row=row, column=1, sticky=tk.W, pady=5) + ttk.Label(parent, text="Points per 360° for arc interpolation", + foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0)) + row += 1 + + # Arc Tolerance + ttk.Label(parent, text="Arc Tolerance (mm):").grid(row=row, column=0, sticky=tk.W, pady=5) + self.arc_tolerance_var = tk.DoubleVar(value=0.02) + ttk.Spinbox(parent, from_=0.001, to=1.0, increment=0.001, format="%.3f", + textvariable=self.arc_tolerance_var, width=15).grid(row=row, column=1, sticky=tk.W, pady=5) + row += 1 + + # Keepout Zone + ttk.Label(parent, text="Keepout Zone:").grid(row=row, column=0, sticky=tk.W, pady=5) + self.keepout_var = tk.BooleanVar(value=True) + ttk.Checkbutton(parent, text="Add keepout area", + variable=self.keepout_var).grid(row=row, column=1, sticky=tk.W, pady=5) + row += 1 + + # Keepout Margin + ttk.Label(parent, text="Keepout Margin (mm):").grid(row=row, column=0, sticky=tk.W, pady=5) + self.keepout_margin_var = tk.DoubleVar(value=5.0) + ttk.Spinbox(parent, from_=0, to=50, increment=0.5, + textvariable=self.keepout_margin_var, width=15).grid(row=row, column=1, sticky=tk.W, pady=5) + ttk.Label(parent, text="Margin around coil", + foreground='gray').grid(row=row, column=2, sticky=tk.W, padx=(10, 0)) + row += 1 + + def get_parameters(self): + params = { + 'turns' : self.turns_var.get(), + 'outer_diameter' : self.outer_dia_var.get(), + 'inner_diameter' : self.inner_dia_var.get(), + 'layers' : self.layer_mode_var.get(), + 'twists' : self.twists_var.get(), + 'clockwise' : (self.direction_var.get() == "clockwise"), + 'copper_thickness' : self.copper_thickness_var.get() / 1000.0, # µm -> mm + 'keepout_zone' : self.keepout_var.get(), + 'keepout_margin' : self.keepout_margin_var.get(), + 'via_diameter' : self.via_diameter_var.get(), + 'stagger_inner_vias' : self.stagger_inner_var.get(), + 'stagger_outer_vias' : self.stagger_outer_var.get(), + 'layer_pair' : f"{self.top_layer_var.get()},{self.bottom_layer_var.get()}", + } + + if (trace_width_value := self.trace_width_entry.get()): + params['trace_width'] = float(trace_width_value) + + if (clearance_value := self.clearance_entry.get()): + params['clearance'] = float(clearance_value) + + if (via_drill_value := self.via_drill_entry.get()): + params['via_drill'] = float(via_drill_value) + + if (via_offset_value := self.via_offset_entry.get()): + params['via_offset'] = float(via_offset_value) + + return params + + def setup_logging(self): + """Set up logging handler to capture kicoil logger output for display in the output text widget""" + self.kicoil_logger = logging.getLogger('kicoil') + self.kicoil_logger.setLevel(logging.INFO) + self.kicoil_logger.handlers.clear() + self.log_handler = TextWidgetHandler(self.output_text) + self.kicoil_logger.addHandler(self.log_handler) + + @contextmanager + def capture_warnings(self): + """Context manager to capture kicoil's warnings to the output text widget""" + def show_warning(message, category, filename, lineno, file=None, line=None): + self.output_text.config(state='normal') + self.output_text.insert(tk.END, f'{message}\n', 'warning') + self.output_text.see(tk.END) + self.output_text.config(state='disabled') + self.output_text.update_idletasks() + + old_showwarning, warnings.showwarning = warnings.showwarning, show_warning + try: + yield + finally: + warnings.showwarning = old_showwarning + + def setup_traces(self): + for var in [ + self.turns_var, + self.outer_dia_var, + self.inner_dia_var, + self.layer_mode_var, + self.direction_var, + self.twists_var, + self.copper_thickness_var, + self.keepout_var, + self.keepout_margin_var, + self.via_diameter_var, + self.stagger_inner_var, + self.stagger_outer_var, + self.top_layer_var, + self.bottom_layer_var]: + var.trace_add('write', self._on_parameter_change) + + for entry in [self.trace_width_entry, self.clearance_entry, + self.via_drill_entry, self.via_offset_entry, + self.footprint_name_entry]: + entry.bind('', lambda e: self._on_parameter_change()) + + def _on_parameter_change(self, *args): + # Schedule validation to avoid too many rapid calls + if self._validation_after_id is not None: + self.root.after_cancel(self._validation_after_id) + self._validation_after_id = self.root.after(200, self.validate_parameters) + + def validate_parameters(self): + """Validate parameters by creating PlanarInductor instance""" + try: + self.output_text.config(state='normal') + self.output_text.delete('1.0', tk.END) + + with self.capture_warnings(): + self.current_model = PlanarInductor(**self.get_parameters()) + + # If we got here, parameters are valid + self.output_text.insert(tk.END, "Parameters valid\n", 'info') + self.update_placeholders() + self.update_preview() + return True + + except ValueError as e: + self.output_text.insert(tk.END, f"ERROR: {e}\n", 'error') + self.output_text.see(tk.END) + + self.current_model = None + self.update_placeholders() + return False + + except Exception as e: + tb = traceback.format_exc() + self.output_text.insert(tk.END, f"Unexpected error:\n{tb}\n", 'error') + self.output_text.see(tk.END) + + print(tb, file=sys.stderr) + + self.current_model = None + self.update_placeholders() + return True + + finally: + self.output_text.config(state='disabled') + + def update_placeholders(self): + if self.current_model is None: + self.trace_width_entry.update_placeholder("automatic") + self.clearance_entry.update_placeholder("automatic") + self.via_drill_entry.update_placeholder("automatic") + self.via_offset_entry.update_placeholder("automatic") + self.footprint_name_entry.update_placeholder("automatic") + + else: + if not self.trace_width_entry.get(): + self.trace_width_entry.update_placeholder(f"auto: {self.current_model.trace_width:.2f}") + + if not self.clearance_entry.get(): + self.clearance_entry.update_placeholder(f"auto: {self.current_model.clearance:.2f}") + + if not self.via_drill_entry.get(): + self.via_drill_entry.update_placeholder(f"auto: {self.current_model.via_drill:.2f}") + + if not self.via_offset_entry.get(): + self.via_offset_entry.update_placeholder(f"auto: {self.current_model.via_offset:.2f}") + + if not self.footprint_name_entry.get(): + self.footprint_name_entry.update_placeholder(self.current_model.default_footprint_name) + + def update_preview(self): + if not HAS_PREVIEW: + return + + arc_tolerance = self.arc_tolerance_var.get() + circle_segments = self.circle_segments_var.get() + + footprint = self.current_model.render_footprint(None, arc_tolerance, circle_segments) + svg_tag = make_transparent_svg(footprint) + viewbox = svg_tag.attrs.get('viewBox', '0 0 800 800') + _, _, svg_width, svg_height = map(float, viewbox.split()) + min_dimension = 800 + scale = max(min_dimension / svg_width, min_dimension / svg_height) + output_width = int(svg_width * scale) + output_height = int(svg_height * scale) + svg_tag.attrs['width'] = f'{output_width}px' + svg_tag.attrs['height'] = f'{output_height}px' + + png_data = cairosvg.svg2png(bytestring=str(svg_tag).encode('utf-8')) + self.preview_raw_image = Image.open(BytesIO(png_data)) + self._rescale_preview() + + + def show_valid_twists(self): + """Show valid twist counts for current number of turns""" + turns = self.turns_var.get() + valid_twists = list(divisors(turns, turns)) + + self.output_text.config(state='normal') + self.output_text.delete('1.0', tk.END) + self.output_text.insert('1.0', f'Valid twist counts for {turns} turns:\n') + for d in valid_twists: + self.output_text.insert(tk.END, f' {d}\n') + self.output_text.config(state='disabled') + + def generate_footprint(self): + """Generate the KiCad footprint using the validated model""" + + if not self.validate_parameters(): + messagebox.showerror("Error", "Cannot generate model. Please check the output for warnings or errors.") + return + + try: + footprint_name = self.footprint_name_entry.get() or None + arc_tolerance = self.arc_tolerance_var.get() + circle_segments = self.circle_segments_var.get() + + self.output_text.config(state='normal') + self.output_text.insert(tk.END, "Rendering footprint...\n\n", 'info') + + footprint = self.current_model.render_footprint(footprint_name, arc_tolerance, circle_segments) + default_name = footprint_name or model.default_footprint_name + output_file = filedialog.asksaveasfilename( + title="Save KiCad Footprint", + defaultextension=".kicad_mod", + initialfile=f"{default_name}.kicad_mod", + filetypes=[("KiCad Footprint", "*.kicad_mod"), ("All files", "*.*")] + ) + + if not output_file: + self.output_text.insert(tk.END, "\nSave cancelled.\n", 'info') + return + + Path(output_file).write_text(footprint.serialize()) + self.output_text.insert(tk.END, f"\nSuccess! Footprint saved to:\n {output_file}\n", 'info') + + except Exception as e: + tb = traceback.format_exc() + self.output_text.config(state='normal') + self.output_text.insert(tk.END, f"\nError generating footprint:\n{tb}\n", 'error') + self.output_text.see(tk.END) + + print(tb, file=sys.stderr) + + messagebox.showerror("Error", f"Error generating footprint: {e}") + + finally: + self.output_text.config(state='disabled') + + +def main(): + root = tk.Tk() + app = KiCoilGUI(root) + root.mainloop() + +if __name__ == "__main__": + main() diff --git a/src/kicoil/svg.py b/src/kicoil/svg.py index 0d54a63..aa8a885 100644 --- a/src/kicoil/svg.py +++ b/src/kicoil/svg.py @@ -1,4 +1,19 @@ +# Copyright 2025 Jan Sebastian Götte +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + import textwrap from gerbonara.utils import Tag @@ -8,13 +23,13 @@ def make_transparent_svg(footprint): stack = LayerStack() footprint.render(stack) root_tag = stack.to_svg(margin=5, colors={ - 'top copper': 'hsl(240 90% 65%)', - 'bottom copper': 'hsl( 0 90% 65%)', - 'drill pth': 'hsl(120 60% 50%)', + 'top copper': '#eb5c5c', + 'bottom copper': '#5c5ceb', + 'drill pth': '#52cc52', }) - root_tag.children[0].attrs['opacity'] = '98%' - root_tag.children[1].attrs['opacity'] = '98%' + root_tag.children[0].attrs['opacity'] = '0.7' + root_tag.children[1].attrs['opacity'] = '0.7' root_tag.children[1].attrs['style'] = 'mix-blend-mode: multiply' return root_tag diff --git a/uv.lock b/uv.lock index a60d493..a5837da 100644 --- a/uv.lock +++ b/uv.lock @@ -20,6 +20,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, ] +[[package]] +name = "cairocffi" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/c5/1a4dc131459e68a173cbdab5fad6b524f53f9c1ef7861b7698e998b837cc/cairocffi-1.7.1.tar.gz", hash = "sha256:2e48ee864884ec4a3a34bfa8c9ab9999f688286eb714a15a43ec9d068c36557b", size = 88096, upload-time = "2024-06-18T10:56:06.741Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d8/ba13451aa6b745c49536e87b6bf8f629b950e84bd0e8308f7dc6883b67e2/cairocffi-1.7.1-py3-none-any.whl", hash = "sha256:9803a0e11f6c962f3b0ae2ec8ba6ae45e957a146a004697a1ac1bbf16b073b3f", size = 75611, upload-time = "2024-06-18T10:55:59.489Z" }, +] + +[[package]] +name = "cairosvg" +version = "2.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cairocffi" }, + { name = "cssselect2" }, + { name = "defusedxml" }, + { name = "pillow" }, + { name = "tinycss2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/b9/5106168bd43d7cd8b7cc2a2ee465b385f14b63f4c092bb89eee2d48c8e67/cairosvg-2.8.2.tar.gz", hash = "sha256:07cbf4e86317b27a92318a4cac2a4bb37a5e9c1b8a27355d06874b22f85bef9f", size = 8398590, upload-time = "2025-05-15T06:56:32.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/48/816bd4aaae93dbf9e408c58598bc32f4a8c65f4b86ab560864cb3ee60adb/cairosvg-2.8.2-py3-none-any.whl", hash = "sha256:eab46dad4674f33267a671dce39b64be245911c901c70d65d2b7b0821e852bf5", size = 45773, upload-time = "2025-05-15T06:56:28.552Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -41,6 +114,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cssselect2" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tinycss2" }, + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/86/fd7f58fc498b3166f3a7e8e0cddb6e620fe1da35b02248b1bd59e95dbaaa/cssselect2-0.8.0.tar.gz", hash = "sha256:7674ffb954a3b46162392aee2a3a0aedb2e14ecf99fcc28644900f4e6e3e9d3a", size = 35716, upload-time = "2025-03-05T14:46:07.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/e7/aa315e6a749d9b96c2504a1ba0ba031ba2d0517e972ce22682e3fccecb09/cssselect2-0.8.0-py3-none-any.whl", hash = "sha256:46fc70ebc41ced7a32cd42d58b1884d72ade23d21e5a4eaaf022401c13f0e76e", size = 15454, upload-time = "2025-03-05T14:46:06.463Z" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + [[package]] name = "flask" version = "3.1.2" @@ -157,12 +252,24 @@ dependencies = [ { name = "gerbonara" }, ] +[package.dev-dependencies] +gui = [ + { name = "cairosvg" }, + { name = "pillow" }, +] + [package.metadata] requires-dist = [ { name = "click" }, { name = "gerbonara", specifier = ">=1.6.0" }, ] +[package.metadata.requires-dev] +gui = [ + { name = "cairosvg" }, + { name = "pillow" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -215,6 +322,64 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "pillow" +version = "12.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" }, + { url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" }, + { url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" }, + { url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" }, + { url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" }, + { url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" }, + { url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" }, + { url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" }, + { url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" }, + { url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" }, + { url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" }, + { url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" }, + { url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" }, + { url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" }, + { url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" }, + { url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" }, + { url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" }, + { url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" }, + { url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" }, + { url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" }, + { url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" }, + { url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" }, + { url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" }, + { url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" }, + { url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" }, + { url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" }, + { url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" }, + { url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" }, + { url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" }, + { url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" }, + { url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" }, + { url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" }, + { url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" }, +] + [[package]] name = "priority" version = "2.0.0" @@ -224,6 +389,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5e/5f/82c8074f7e84978129347c2c6ec8b6c59f3584ff1a20bc3c940a3e061790/priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa", size = 8946, upload-time = "2021-06-27T10:15:03.856Z" }, ] +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + [[package]] name = "quart" version = "0.20.0" @@ -260,6 +434,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/50/0a9e7e7afe7339bd5e36911f0ceb15fed51945836ed803ae5afd661057fd/rtree-1.4.1-py3-none-win_arm64.whl", hash = "sha256:3d46f55729b28138e897ffef32f7ce93ac335cb67f9120125ad3742a220800f0", size = 355253, upload-time = "2025-08-13T19:32:00.296Z" }, ] +[[package]] +name = "tinycss2" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" }, +] + +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, +] + [[package]] name = "werkzeug" version = "3.1.4"