Add simple GUI

This commit is contained in:
jaseg 2025-12-09 14:17:18 +01:00
parent b70ff262a6
commit 7590bde619
8 changed files with 1095 additions and 38 deletions

28
metadata.json Normal file
View file

@ -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"
}

106
package.py Normal file
View file

@ -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()

View file

@ -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"]

View file

@ -1,3 +1,17 @@
# Copyright 2025 Jan Sebastian Götte <code@jaseg.de>
#
# 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

View file

@ -1,3 +1,19 @@
# Copyright 2025 Jan Sebastian Götte <code@jaseg.de>
#
# 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

687
src/kicoil/gui.py Normal file
View file

@ -0,0 +1,687 @@
# Copyright 2025 Jan Sebastian Götte <code@jaseg.de>
#
# 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("<FocusIn>", self.foc_in)
self.bind("<FocusOut>", 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('<Configure>', 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('<KeyRelease>', 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()

View file

@ -1,4 +1,19 @@
# Copyright 2025 Jan Sebastian Götte <code@jaseg.de>
#
# 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

195
uv.lock generated
View file

@ -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"