Documentation

This commit is contained in:
Alex 2026-10-01 11:57:58 +02:00
parent 0dece85b14
commit 77cce1d3f9
15 changed files with 1324 additions and 80 deletions

15
.gitignore vendored
View File

@ -1 +1,14 @@
__pycache__/*
# Cache Python et outils
__pycache__/
*.pyc
.pytest_cache/
.venv/
# Archives de distribution
*.zip
# SVG intermediaires du schema (regeneres par docs/schema_parametres.py)
docs/*.svg
# Page de presentation : hors depot
site/

View File

@ -37,13 +37,15 @@ possible qui ne déborde pas du contour et ne touche aucun cercle déjà posé.
- Rayons minimal et maximal réglables, écart entre cercles (*gap*) et marge
intérieure au contour, exprimés dans l'unité de votre choix
- Les cercles héritent du style (fond, contour) de la forme source
- Résultat placé dans un groupe nommé « Circle packing » ; forme d'origine
- Résultat placé dans un groupe nommé « Remplissage par cercles » ; forme d'origine
conservée par défaut
- Transformations respectées : le groupe généré annule celle de son parent
- Graine aléatoire pour un résultat reproductible
- Cercles ordonnés de proche en proche (chaîne du plus proche voisin) : les logiciels
de découpe laser qui suivent l'ordre du document minimisent leurs déplacements à vide
- Grille de hachage spatial : des dizaines de milliers de tentatives restent rapides
- Interface traduite en français et en anglais, selon la langue d'Inkscape
(anglais pour toute autre langue)
- Script de déploiement PowerShell (installation, mise à jour, désinstallation)
## Installation
@ -67,21 +69,24 @@ Le script crée le sous-dossier, remplace les fichiers existants, nettoie le
### Installation manuelle
Copier `circle_packing.inx`, `circle_packing.py` et `packing_core.py` dans un
sous-dossier (par exemple `circlePacking`) du dossier des extensions utilisateur,
puis redémarrer Inkscape :
Copier `circle_packing.inx`, `circle_packing.py`, `packing_core.py`,
`images/parameters_en.png` et le dossier `locale/` dans un sous-dossier (par
exemple `circlePacking`) du dossier des extensions utilisateur, puis redémarrer
Inkscape :
- Windows : `%APPDATA%\inkscape\extensions`
- Linux : `~/.config/inkscape/extensions`
- macOS : `~/Library/Application Support/org.inkscape.Inkscape/config/inkscape/extensions`
Inkscape explore récursivement ce dossier ; les trois fichiers doivent rester
ensemble, `circle_packing.py` important `packing_core.py`.
Inkscape explore récursivement ce dossier ; les fichiers doivent rester
ensemble : `circle_packing.py` importe `packing_core.py`, l'onglet « Aide »
affiche `images/parameters_en.png` et `locale/` porte les traductions.
## Utilisation
1. Sélectionner une ou plusieurs formes fermées.
2. **Extensions → AlexDesign → Remplissage par cercles (circle packing)…**
(*Circle Packing Fill…* en anglais)
3. Régler les paramètres, puis **Appliquer**.
### Remplir avec une forme personnalisée
@ -118,6 +123,8 @@ Conseils de réglage :
## Paramètres
![Schéma des paramètres : rayons minimal et maximal, écart et marge, forme déposée, nombre de tentatives](docs/parametres.png)
| Paramètre | Défaut | Rôle |
|---|---|---|
| Forme déposée | Cercle | Cercle, ou forme personnalisée (dernier objet sélectionné) |
@ -136,15 +143,21 @@ Le remplissage greedy atteint typiquement 60 à 75 % de couverture.
## Structure du projet
```
circle_packing.inx Description de la boîte de dialogue Inkscape
circle_packing.py Extension : lecture de la sélection, aplatissement des
chemins, écriture des <circle> dans le SVG
packing_core.py Noyau géométrique sans dépendance à inkex
deploy.ps1 Déploiement / désinstallation sous Windows
test_packing.py Tests pytest
tests/data/shapes.svg Formes d'exemple
docs/rendu.png Image de présentation du README (non déployée)
```text
circle_packing.inx Boîte de dialogue Inkscape (textes source en anglais)
circle_packing.py Extension : lecture de la sélection, aplatissement des
chemins, écriture des <circle> dans le SVG
packing_core.py Noyau géométrique sans dépendance à inkex
i18n.py Chaîne de traduction (extract / update / compile)
po/ Modèle circlepacking.pot et catalogues en.po, fr.po
locale/ Catalogues compilés (.mo), déployés
images/parameters_en.png Schéma de l'onglet « Aide » (déployé)
docs/schema_parametres.py Générateur du schéma (fr pour le README, en pour le dialogue)
docs/parametres.png Schéma des paramètres du README (non déployé)
docs/rendu.png Image de présentation du README (non déployée)
deploy.ps1 Déploiement / désinstallation sous Windows
test_packing.py Tests pytest
tests/data/shapes.svg Formes d'exemple
```
API de `packing_core.py` (utilisable seule, hors Inkscape) :
@ -175,7 +188,7 @@ pip install pytest
python -m pytest test_packing.py -q
```
Résultat attendu : **24 passés, 3 ignorés**. Les tests ignorés sont ceux de bout en
Résultat attendu : **28 passés, 4 ignorés**. Les tests ignorés sont ceux de bout en
bout, sautés quand `inkex` n'est pas importable. Pour les exécuter, ajouter le dossier
des extensions d'Inkscape au `PYTHONPATH` et installer les dépendances d'`inkex`
absentes d'un Python standard :
@ -183,13 +196,39 @@ absentes d'un Python standard :
```powershell
pip install lxml tinycss2 cssselect2 cssselect
$env:PYTHONPATH = 'C:\Program Files\Inkscape\share\inkscape\extensions'
python -m pytest test_packing.py -q # 27 passés
python -m pytest test_packing.py -q # 32 passés
```
Les tests couvrent l'appartenance à une forme trouée, les distances au contour, la
grille spatiale, et les invariants du packing : aucun chevauchement, rayons dans
les bornes, cercles tous intérieurs, reproductibilité à graine fixée. L'ordre de sortie est
comparé à une recherche du plus proche voisin en force brute et à la longueur du parcours
d'un ordre mélangé.
d'un ordre mélangé. S'y ajoutent les traductions (catalogues complets et recompilés) et la
cohérence entre le `.inx`, les arguments du script et les images de la boîte de dialogue.
### Traductions
Les textes source (`.inx` et appels `_()` de `circle_packing.py`) sont en anglais ;
`i18n.py` les extrait et compile les catalogues, sans dépendance à gettext :
```powershell
python i18n.py # extract + update + compile ; liste les msgstr manquants
```
Après modification d'un texte : lancer `python i18n.py`, compléter les `msgstr`
vides de `po/fr.po`, relancer. `po/en.po` se remplit tout seul ; ne jamais éditer
les `.mo`. Pour ajouter une langue, l'ajouter à `LANGUAGES` dans `i18n.py`.
### Schéma des paramètres
```powershell
python docs/schema_parametres.py # fr : docs/parametres.png (1400 px)
# en : images/parameters_en.png (900 px)
```
Les cercles du schéma sont calculés par `packing_core`, le dessin reste donc fidèle
au résultat réel. La version anglaise sert à l'onglet « Aide » de la boîte de
dialogue (Inkscape ne traduit pas le chemin d'une image). L'export PNG passe par
`inkscape.com`.
Aucun outil de lint n'est configuré dans le projet ; le code suit PEP 8.

View File

@ -1,65 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<inkscape-extension xmlns="http://www.inkscape.org/namespace/inkscape/extension">
<name>Remplissage par cercles (circle packing)</name>
<!--
Textes source en anglais, traduits par le catalogue « circlepacking »
(locale/<langue>/LC_MESSAGES/circlepacking.mo). Après toute modification
d'un texte : python i18n.py, puis compléter po/fr.po et relancer.
-->
<inkscape-extension translationdomain="circlepacking"
xmlns="http://www.inkscape.org/namespace/inkscape/extension">
<name>Circle Packing Fill</name>
<id>fr.alexp.circlepacking</id>
<param name="tab" type="notebook">
<page name="options" gui-text="Cercles">
<page name="options" gui-text="Circles">
<param name="shape_mode" type="optiongroup" appearance="combo"
gui-text="Forme déposée :">
<option value="circle">Cercle</option>
<option value="custom">Forme personnalisée (dernier objet sélectionné)</option>
gui-text="Placed shape:">
<option value="circle">Circle</option>
<option value="custom">Custom shape (last selected object)</option>
</param>
<param name="min_radius" type="float" precision="3" min="0.001" max="10000"
gui-text="Rayon minimal :">1.0</param>
gui-text="Minimum radius:">1.0</param>
<param name="max_radius" type="float" precision="3" min="0.001" max="10000"
gui-text="Rayon maximal :">20.0</param>
gui-text="Maximum radius:">20.0</param>
<param name="gap" type="float" precision="3" min="0" max="10000"
gui-text="Écart entre cercles :">0.5</param>
gui-text="Gap between circles:">0.5</param>
<param name="margin" type="float" precision="3" min="0" max="10000"
gui-text="Marge intérieure au contour :">0.5</param>
<param name="unit" type="optiongroup" appearance="combo" gui-text="Unité :">
<option value="mm">mm</option>
<option value="cm">cm</option>
<option value="px">px</option>
<option value="pt">pt</option>
<option value="in">in</option>
gui-text="Inner margin to the outline:">0.5</param>
<param name="unit" type="optiongroup" appearance="combo" gui-text="Unit:">
<!-- Les unités ne se traduisent pas -->
<option translatable="no" value="mm">mm</option>
<option translatable="no" value="cm">cm</option>
<option translatable="no" value="px">px</option>
<option translatable="no" value="pt">pt</option>
<option translatable="no" value="in">in</option>
</param>
</page>
<page name="algo" gui-text="Algorithme">
<page name="algo" gui-text="Algorithm">
<param name="attempts" type="int" min="1" max="10000000"
gui-text="Nombre de tentatives :">20000</param>
gui-text="Number of attempts:">20000</param>
<param name="max_circles" type="int" min="0" max="1000000"
gui-text="Nombre maximal de cercles (0 = illimité) :">5000</param>
gui-text="Maximum number of circles (0 = unlimited):">5000</param>
<param name="seed" type="int" min="0" max="2147483647"
gui-text="Graine aléatoire (0 = aléatoire) :">0</param>
gui-text="Random seed (0 = random):">0</param>
<param name="flatness" type="float" precision="3" min="0.001" max="10"
gui-text="Finesse d'échantillonnage des courbes :">0.2</param>
gui-text="Curve sampling fineness:">0.2</param>
<param name="keep_original" type="bool"
gui-text="Conserver la forme d'origine">true</param>
gui-text="Keep the original shape">true</param>
</page>
<page name="help" gui-text="Aide">
<label xml:space="preserve">Sélectionnez une ou plusieurs formes fermées
(chemin, rectangle, cercle, ellipse, polygone),
puis lancez l'extension.
<page name="help" gui-text="Help">
<!-- Schéma généré par docs/schema_parametres.py, en anglais :
Inkscape ne traduit pas le chemin d'une image. Affiché aux
3/4 du PNG (900 px) pour limiter la hauteur de l'onglet. -->
<image width="675" height="617">images/parameters_en.png</image>
<separator/>
<!-- Un paragraphe par ligne : Inkscape le replie à la largeur de
l'onglet ; des retours à la ligne forcés le laisseraient étroit. -->
<label xml:space="preserve">Select one or more closed shapes (path, rectangle, circle, ellipse, polygon), then run the extension. Inner subpaths are treated as holes (even-odd rule).
Les cercles générés héritent du style (fond, contour)
de la forme source et sont placés dans un groupe.
The generated circles inherit the style (fill, stroke) of the source shape and are placed in a group, ordered from neighbour to neighbour to shorten the travel of a laser cutter.
Forme personnalisée : collez la forme voulue dans le
document, sélectionnez les formes à remplir, puis
ajoutez le motif à la sélection en dernier (Maj+clic).
Le dernier objet sélectionné sert de motif, il n'est
pas rempli. Chaque copie est mise à l'échelle pour tenir
dans le disque calculé, orientée au hasard, et conserve
le style du motif.
Custom shape: paste the shape you want into the document, select the shapes to fill, then add the motif to the selection last (Shift+click). The last selected object is used as the motif and is not filled. Each copy is scaled to fit the computed disc, rotated at random, and keeps the style of the motif.
Un plus grand nombre de tentatives augmente la densité
de remplissage mais allonge le temps de calcul.
Les sous-chemins internes sont traités comme des trous
(règle pair-impair).</label>
More attempts give a denser fill but a longer computation.</label>
</page>
</param>

View File

@ -22,6 +22,7 @@ import sys
import inkex
from inkex import bezier
from inkex.localization import localize
# L'extension vit dans son propre sous-dossier des extensions Inkscape, qui
# n'est pas forcement dans sys.path : on l'y ajoute pour trouver packing_core.
@ -29,6 +30,11 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from packing_core import bounding_circle, pack_circles # noqa: E402
# Installe _() dans les builtins : catalogue « circlepacking » de la langue
# de l'interface (Inkscape transmet domaine et dossier par l'environnement),
# textes anglais d'origine sinon.
localize()
def flatten_path(path, flatness):
"""Convertit un inkex.Path en liste d'anneaux (listes de points)."""
@ -76,10 +82,9 @@ class CirclePacking(inkex.EffectExtension):
motif = None
if opt.shape_mode == "custom":
if len(selected) < 2:
inkex.errormsg(
"Mode « forme personnalisée » : sélectionnez la ou les "
"formes à remplir, puis ajoutez le motif à la sélection "
"en dernier (Maj+clic).")
inkex.errormsg(_(
"Custom shape mode: select the shape(s) to fill, then "
"add the motif to the selection last (Shift+click)."))
return
motif = selected[-1]
selected = selected[:-1]
@ -94,16 +99,17 @@ class CirclePacking(inkex.EffectExtension):
shapes.append(elem)
if not shapes:
inkex.errormsg("Sélectionnez au moins une forme fermée "
"(chemin, rectangle, cercle, ellipse, polygone).")
inkex.errormsg(_("Select at least one closed shape "
"(path, rectangle, circle, ellipse, polygon)."))
return
motif_geometry = None
if motif is not None:
motif_geometry = self.build_motif(motif)
if motif_geometry is None:
inkex.errormsg("Le motif sélectionné ne fournit aucun contour "
"exploitable.")
inkex.errormsg(_("The selected motif has no usable outline. "
"Convert it to a path, or pick another "
"object as the motif."))
return
def to_uu(value):
@ -115,10 +121,11 @@ class CirclePacking(inkex.EffectExtension):
margin = to_uu(opt.margin)
if min_radius <= 0 or max_radius <= 0:
inkex.errormsg("Les rayons doivent être strictement positifs.")
inkex.errormsg(_("The radii must be strictly positive."))
return
if min_radius > max_radius:
inkex.errormsg("Le rayon minimal doit être inférieur au rayon maximal.")
inkex.errormsg(_("The minimum radius must be smaller than the "
"maximum radius."))
return
rng = random.Random(opt.seed) if opt.seed else random.Random()
@ -129,9 +136,9 @@ class CirclePacking(inkex.EffectExtension):
margin, rng, motif_geometry)
if total == 0:
inkex.errormsg("Aucun cercle n'a pu être placé. Réduisez le rayon "
"minimal, l'écart ou la marge, ou augmentez le "
"nombre de tentatives.")
inkex.errormsg(_("No circle could be placed. Reduce the minimum "
"radius, the gap or the margin, or increase the "
"number of attempts."))
@staticmethod
def is_fillable(elem):
@ -180,7 +187,7 @@ class CirclePacking(inkex.EffectExtension):
parent = elem.getparent()
group = inkex.Group()
group.label = "Circle packing"
group.label = _("Circle packing")
if parent is not None:
# Les cercles sont calcules en coordonnees absolues du document :
# on neutralise la transformation heritee du parent d'accueil.

View File

@ -1,10 +1,12 @@
<#
<#
.SYNOPSIS
Déploie l'extension « Remplissage par cercles » dans Inkscape.
.DESCRIPTION
Copie circle_packing.inx, circle_packing.py et packing_core.py dans un
sous-dossier dédié du répertoire des extensions utilisateur d'Inkscape
Copie circle_packing.inx, circle_packing.py, packing_core.py, le schéma de
l'onglet « Help » (images\parameters_en.png) et les traductions compilées
(locale\<langue>\LC_MESSAGES\circlepacking.mo) dans un sous-dossier dédié
du répertoire des extensions utilisateur d'Inkscape
(Inkscape explore récursivement ce répertoire). Refuse de s'exécuter si
Inkscape est ouvert : les extensions ne sont chargées qu'au démarrage.
@ -36,7 +38,10 @@ param(
$ErrorActionPreference = 'Stop'
$files = @('circle_packing.inx', 'circle_packing.py', 'packing_core.py')
$files = @('circle_packing.inx', 'circle_packing.py', 'packing_core.py', 'images\parameters_en.png')
$localeDir = 'locale'
# Fichiers qu'une ancienne installation posait à plat à la racine des extensions
$legacyFiles = @('circle_packing.inx', 'circle_packing.py', 'packing_core.py')
$source = $PSScriptRoot
$destination = Join-Path $ExtensionsDir $FolderName
@ -57,7 +62,7 @@ if ($Uninstall) {
Write-Host "Rien à supprimer : $destination n'existe pas." -ForegroundColor Yellow
}
# Fichiers d'une installation « à plat » antérieure, à la racine des extensions
foreach ($file in $files) {
foreach ($file in $legacyFiles) {
$legacy = Join-Path $ExtensionsDir $file
if (Test-Path -LiteralPath $legacy) {
Remove-Item -LiteralPath $legacy -Force
@ -69,13 +74,16 @@ if ($Uninstall) {
# --- Vérification des sources ------------------------------------------------
$missing = $files | Where-Object { -not (Test-Path -LiteralPath (Join-Path $source $_)) }
if (-not (Test-Path -LiteralPath (Join-Path $source $localeDir))) {
$missing = @($missing) + "$localeDir\ (lancer : python i18n.py)"
}
if ($missing) {
Write-Error "Fichier(s) introuvable(s) dans $source : $($missing -join ', ')"
exit 1
}
# --- Nettoyage d'une installation « à plat » antérieure ----------------------
foreach ($file in $files) {
foreach ($file in $legacyFiles) {
$legacy = Join-Path $ExtensionsDir $file
if (Test-Path -LiteralPath $legacy) {
Remove-Item -LiteralPath $legacy -Force
@ -93,10 +101,20 @@ foreach ($file in $files) {
$from = Join-Path $source $file
$to = Join-Path $destination $file
$action = if (Test-Path -LiteralPath $to) { 'remplacé' } else { 'copié ' }
New-Item -ItemType Directory -Path (Split-Path $to) -Force | Out-Null
Copy-Item -LiteralPath $from -Destination $to -Force
Write-Host " $action $file"
}
# Traductions : le dossier est remplacé en entier (langue retirée => supprimée)
$localeTo = Join-Path $destination $localeDir
if (Test-Path -LiteralPath $localeTo) {
Remove-Item -LiteralPath $localeTo -Recurse -Force
}
Copy-Item -LiteralPath (Join-Path $source $localeDir) -Destination $localeTo -Recurse
$languages = Get-ChildItem -LiteralPath $localeTo -Directory | ForEach-Object Name
Write-Host " copié $localeDir\ ($($languages -join ', '))"
# Le cache Python d'une version précédente peut masquer les fichiers copiés
$pycache = Join-Path $destination '__pycache__'
if (Test-Path -LiteralPath $pycache) {
@ -105,4 +123,4 @@ if (Test-Path -LiteralPath $pycache) {
Write-Host ""
Write-Host "Extension déployée dans $destination" -ForegroundColor Green
Write-Host "Redémarrez Inkscape, puis : Extensions > Rendu > Remplissage par cercles (circle packing)..."
Write-Host "Redémarrez Inkscape, puis : Extensions > AlexDesign > Remplissage par cercles (circle packing)..."

BIN
docs/parametres.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 KiB

422
docs/schema_parametres.py Normal file
View File

@ -0,0 +1,422 @@
#!/usr/bin/env python3
# coding=utf-8
"""
Genere le schema explicatif des parametres de l'extension.
python docs/schema_parametres.py # les deux versions
python docs/schema_parametres.py fr # docs/parametres.svg + .png (README)
python docs/schema_parametres.py en # docs/parameters_en.svg
# + images/parameters_en.png (onglet
# « Help » de la boite de dialogue)
Les cercles sont calcules par packing_core (memes fonctions que l'extension),
le schema reste donc fidele au resultat reel. L'export PNG passe par
inkscape.com, qui attend la fin de l'export (contrairement au raccourci
Chocolatey « inkscape »).
"""
import math
import os
import random
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, ROOT)
import packing_core as core # noqa: E402
W, H = 1400, 1280 # 4 panneaux de 700 x 640
INK = "#343a40" # element principal
GHOST = "#dde1e5" # elements secondaires / voisins
DIM = "#d9480f" # cotes
DIM2 = "#1971c2" # cotes secondaires
EDGE = "#212529" # contour des formes
FONT = "font-family:Arial,Helvetica,sans-serif"
INKSCAPE = [r"C:\Program Files\Inkscape\bin\inkscape.com", "/usr/bin/inkscape",
"/Applications/Inkscape.app/Contents/MacOS/inkscape"]
# Version par langue : textes, fichiers produits, largeur du PNG et agrandissement
# des textes (la version anglaise est affichee reduite dans la boite de dialogue :
# 900 px de large, textes x 1.25 pour rester lisibles).
VERSIONS = {
"fr": {
"svg": os.path.join(HERE, "parametres.svg"),
"png": os.path.join(HERE, "parametres.png"),
"png_width": 1400,
"font_scale": 1.0,
"text": {
"title1": "Rayon minimal et rayon maximal",
"rmax": "rayon maximal",
"rmin": "rayon minimal",
"note1": ["Chaque cercle est le plus grand possible à cet endroit, sans dépasser",
"le rayon maximal. Sous le rayon minimal, il n'est pas posé."],
"title2": "Écart et marge intérieure",
"gap": "écart",
"margin": "marge",
"note2": ["L'écart sépare deux cercles voisins ; la marge tient les cercles",
"à distance du contour et des trous."],
"title3": "Forme déposée : cercle ou motif",
"motif": "motif",
"disc": "disque calculé",
"note3": ["Le motif est le dernier objet sélectionné. Chaque copie est mise à",
"l'échelle pour tenir dans le disque calculé, puis orientée au hasard."],
"title4": "Nombre de tentatives",
"few": "300 tentatives",
"many": "20 000 tentatives",
"note4": ["Plus de tentatives : les vides se remplissent de petits cercles et",
"le calcul est plus long. Les trous de la forme restent vides."],
},
},
"en": {
"svg": os.path.join(HERE, "parameters_en.svg"),
"png": os.path.join(ROOT, "images", "parameters_en.png"),
"png_width": 900,
"font_scale": 1.25,
"text": {
"title1": "Minimum and maximum radius",
"rmax": "maximum radius",
"rmin": "minimum radius",
"note1": ["Each circle is as large as it can be there, up to the",
"maximum radius. Below the minimum, it is not placed."],
"title2": "Gap and inner margin",
"gap": "gap",
"margin": "margin",
"note2": ["The gap separates neighbouring circles; the margin",
"keeps them away from the outline and the holes."],
"title3": "Placed shape: circle or motif",
"motif": "motif",
"disc": "computed disc",
"note3": ["The motif is the last selected object. Each copy is scaled",
"to fit the computed disc, then rotated at random."],
"title4": "Number of attempts",
"few": "300 attempts",
"many": "20,000 attempts",
"note4": ["More attempts: the gaps fill up with small circles and it",
"takes longer. Holes in the shape stay empty."],
},
},
}
def _unit(angle):
"""Vecteur unitaire, angle en degres, sens anti-horaire a l'ecran (y vers le bas)."""
a = math.radians(angle)
return math.cos(a), -math.sin(a)
out = []
T = {}
FS = 1.0
def fmt(v):
return "{:.2f}".format(v).rstrip("0").rstrip(".")
def pts(polyline):
return " ".join("{},{}".format(fmt(x), fmt(y)) for x, y in polyline)
def polyline(points, color, width, extra=""):
out.append('<polyline points="{}" fill="none" stroke="{}" stroke-width="{}" '
'stroke-linejoin="miter" {}/>'.format(pts(points), color, fmt(width), extra))
def region(rings, fill="#ffffff", stroke=EDGE, width=2.2):
"""Forme fermee (anneaux, pair-impair) : le contour a remplir."""
d = " ".join("M " + " L ".join("{},{}".format(fmt(x), fmt(y)) for x, y in ring) + " Z"
for ring in rings)
out.append('<path d="{}" fill="{}" fill-rule="evenodd" stroke="{}" stroke-width="{}" '
'stroke-linejoin="round"/>'.format(d, fill, stroke, fmt(width)))
def disc(x, y, r, fill=GHOST, stroke=INK, width=1.3, extra=""):
out.append('<circle cx="{}" cy="{}" r="{}" fill="{}" stroke="{}" stroke-width="{}" '
'{}/>'.format(fmt(x), fmt(y), fmt(r), fill, stroke, fmt(width), extra))
def discs(circles, **style):
for x, y, r in circles:
disc(x, y, r, **style)
def text(x, y, s, size=17, color=INK, anchor="start", weight="normal", halo=True):
style = "{};font-size:{}px;font-weight:{};fill:{}".format(
FONT, fmt(size * FS), weight, color)
if halo:
style += ";paint-order:stroke;stroke:#ffffff;stroke-width:5px;stroke-linejoin:round"
out.append('<text x="{}" y="{}" text-anchor="{}" style="{}">{}</text>'.format(
fmt(x), fmt(y), anchor, style, s))
def note(x, y, lines):
"""Legende de bas de panneau, derniere ligne sur la ligne de base `y`."""
step = 22 * FS
for k, line in enumerate(lines):
text(x, y - (len(lines) - 1 - k) * step, line, size=16, halo=False)
def arrow_head(tip, direction, color, size=9):
ux, uy = direction
nx, ny = -uy, ux
base = (tip[0] - size * ux, tip[1] - size * uy)
out.append('<polygon points="{}" fill="{}"/>'.format(pts([
tip, (base[0] + 0.45 * size * nx, base[1] + 0.45 * size * ny),
(base[0] - 0.45 * size * nx, base[1] - 0.45 * size * ny)]), color))
def span(p, q, color, label, label_at, anchor="middle", size=17):
"""Cote posee directement entre p et q (fleche a chaque bout), texte en `label_at`."""
dx, dy = q[0] - p[0], q[1] - p[1]
length = math.hypot(dx, dy)
ux, uy = dx / length, dy / length
polyline([p, q], color, 2)
arrow_head(p, (-ux, -uy), color, 10)
arrow_head(q, (ux, uy), color, 10)
text(label_at[0], label_at[1], label, size=size, color=color, anchor=anchor,
weight="bold")
def radius_mark(circle, angle, color, label, label_at, anchor="middle"):
"""Rayon cote du centre au bord, dans la direction `angle` (degres)."""
x, y, r = circle
ux, uy = _unit(angle)
tip = (x + r * ux, y + r * uy)
polyline([(x, y), tip], color, 1.8)
arrow_head(tip, (ux, uy), color, 8)
out.append('<circle cx="{}" cy="{}" r="3" fill="{}"/>'.format(fmt(x), fmt(y), color))
text(label_at[0], label_at[1], label, color=color, anchor=anchor, weight="bold")
def leader(start, end, color):
polyline([start, end], color, 1.3)
def circle_ring(cx, cy, r, n=180):
return [(cx + r * math.cos(2 * math.pi * k / n), cy + r * math.sin(2 * math.pi * k / n))
for k in range(n)]
def rounded_rect(x, y, w, h, r, n=10):
ring = []
corners = ((x + w - r, y + r, -90), (x + w - r, y + h - r, 0),
(x + r, y + h - r, 90), (x + r, y + r, 180))
for cx, cy, a0 in corners:
for k in range(n + 1):
a = math.radians(a0 + 90 * k / n)
ring.append((cx + r * math.cos(a), cy + r * math.sin(a)))
return ring
def star(cx, cy, outer, inner, branches=5, angle=-90.0):
ring = []
for k in range(2 * branches):
a = math.radians(angle + 180.0 * k / branches)
radius = outer if k % 2 == 0 else inner
ring.append((cx + radius * math.cos(a), cy + radius * math.sin(a)))
return ring
def place_motif(ring, x, y, r, angle):
"""Copie du motif inscrite dans le disque (x, y, r) : meme calcul que
CirclePacking.place_motif, a partir du cercle circonscrit du noyau."""
cx, cy, radius = core.bounding_circle([ring])
scale = r / radius
cos_a, sin_a = math.cos(math.radians(angle)), math.sin(math.radians(angle))
return [(x + scale * ((px - cx) * cos_a - (py - cy) * sin_a),
y + scale * ((px - cx) * sin_a + (py - cy) * cos_a)) for px, py in ring]
def nearest_on_rings(x, y, rings):
"""Point du contour le plus proche de (x, y)."""
best, best_point = float("inf"), None
for ring in rings:
for i, (x1, y1) in enumerate(ring):
x2, y2 = ring[(i + 1) % len(ring)]
dx, dy = x2 - x1, y2 - y1
seg = dx * dx + dy * dy
t = 0.0 if seg == 0 else max(0.0, min(1.0, ((x - x1) * dx + (y - y1) * dy) / seg))
px, py = x1 + t * dx, y1 + t * dy
d = math.hypot(x - px, y - py)
if d < best:
best, best_point = d, (px, py)
return best_point
def panel_title(x, y, number, title):
text(x, y, "{} {}".format(number, title), size=22, weight="bold", halo=False)
# --------------------------------------------------------------------------
# Panneaux : chaque dessin est un vrai resultat de packing_core.pack_circles.
# --------------------------------------------------------------------------
def panel_1():
"""Rayons : le plus grand cercle atteint le rayon maximal, aucun n'est
plus petit que le rayon minimal."""
panel_title(30, 50, 1, T["title1"])
shape = [rounded_rect(60, 85, 580, 450, 40)]
circles = core.pack_circles(shape, 10, 85, gap=7, margin=9, attempts=6000,
rng=random.Random(3))
region(shape)
discs(circles)
big = max(circles, key=lambda c: c[2])
disc(*big, fill="#ffe8cc", stroke=DIM, width=1.8)
radius_mark(big, 35, DIM, T["rmax"], (big[0], big[1] + 32 * FS))
# le plus petit cercle, assez loin du grand pour que les textes ne se melent pas
far = [c for c in circles if math.hypot(c[0] - big[0], c[1] - big[1]) > big[2] + 110
and 150 < c[0] < 520 and 150 < c[1] < 470]
small = min(far, key=lambda c: c[2])
disc(*small, fill="#d0ebff", stroke=DIM2, width=1.8)
ux, uy = _unit(40)
start = (small[0] + small[2] * ux, small[1] + small[2] * uy)
end = (small[0] + (small[2] + 34) * ux, small[1] + (small[2] + 34) * uy)
leader(start, end, DIM2)
text(end[0] + 4, end[1] - 4, T["rmin"], color=DIM2, weight="bold")
note(30, 610, T["note1"])
def panel_2():
"""Ecart entre deux cercles voisins, marge entre un cercle et le contour."""
ox = 700
panel_title(ox + 30, 50, 2, T["title2"])
shape = [rounded_rect(ox + 60, 85, 580, 450, 40)]
gap, margin = 56.0, 52.0
circles = core.pack_circles(shape, 34, 90, gap=gap, margin=margin, attempts=4000,
rng=random.Random(8))
region(shape)
discs(circles)
# paire de cercles au plus pres l'un de l'autre : leur distance vaut l'ecart
pairs = [(math.hypot(a[0] - b[0], a[1] - b[1]) - a[2] - b[2], a, b)
for i, a in enumerate(circles) for b in circles[i + 1:]]
_slack, a, b = min(pairs, key=lambda item: item[0])
dx, dy = b[0] - a[0], b[1] - a[1]
dist = math.hypot(dx, dy)
ux, uy = dx / dist, dy / dist
p = (a[0] + a[2] * ux, a[1] + a[2] * uy)
q = (b[0] - b[2] * ux, b[1] - b[2] * uy)
nx, ny = -uy, ux
if ny > 0:
nx, ny = -nx, -ny # texte du cote haut de la cote
middle = (0.5 * (p[0] + q[0]), 0.5 * (p[1] + q[1]))
span(p, q, DIM, T["gap"], (middle[0] + 24 * FS * nx, middle[1] + 24 * FS * ny + 6 * FS))
# cercle au plus pres du contour : sa distance au bord vaut la marge
edge = min(circles, key=lambda c: core.distance_to_rings(c[0], c[1], shape) - c[2])
foot = nearest_on_rings(edge[0], edge[1], shape)
dx, dy = foot[0] - edge[0], foot[1] - edge[1]
dist = math.hypot(dx, dy)
ux, uy = dx / dist, dy / dist
rim = (edge[0] + edge[2] * ux, edge[1] + edge[2] * uy)
nx, ny = -uy, ux
if ny > 0:
nx, ny = -nx, -ny
middle = (0.5 * (rim[0] + foot[0]), 0.5 * (rim[1] + foot[1]))
span(rim, foot, DIM2, T["margin"],
(middle[0] + 30 * FS * nx, middle[1] + 30 * FS * ny + 6 * FS))
note(ox + 30, 610, T["note2"])
def panel_3():
"""Forme personnalisee : le motif est inscrit dans chaque disque calcule."""
oy = 640
panel_title(30, oy + 50, 3, T["title3"])
motif = star(0, 0, 50, 21)
# le motif source, a gauche
source = place_motif(motif, 110, oy + 215, 58, 0)
region([source], fill="#ffe8cc", stroke=DIM, width=1.8)
text(110, oy + 215 + 58 + 26 * FS, T["motif"], color=DIM, anchor="middle",
weight="bold")
polyline([(180, oy + 215), (208, oy + 215)], INK, 1.8)
arrow_head((216, oy + 215), (1, 0), INK, 9)
# legende du disque calcule, sous le motif
disc(110, oy + 400, 30, fill="none", stroke=DIM2, width=1.8,
extra='stroke-dasharray="6,4"')
text(110, oy + 430 + 26 * FS, T["disc"], color=DIM2, anchor="middle",
weight="bold")
shape = [rounded_rect(235, oy + 85, 405, 450, 40)]
rng = random.Random(5)
circles = core.pack_circles(shape, 14, 70, gap=6, margin=9, attempts=5000, rng=rng)
region(shape)
for x, y, r in circles:
disc(x, y, r, fill="none", stroke="#adb5bd", width=1, extra='stroke-dasharray="4,4"')
region([place_motif(motif, x, y, r, rng.uniform(0.0, 360.0))], fill=GHOST,
stroke=INK, width=1.3)
big = max(circles, key=lambda c: c[2])
disc(*big, fill="none", stroke=DIM2, width=1.8, extra='stroke-dasharray="6,4"')
note(30, oy + 610, T["note3"])
def panel_4():
"""Tentatives : meme forme trouee, remplissage lache puis dense."""
ox, oy = 700, 640
panel_title(ox + 30, oy + 50, 4, T["title4"])
for k, (attempts, key) in enumerate(((300, "few"), (20000, "many"))):
x0 = ox + 45 + k * 315
shape = [rounded_rect(x0, oy + 85, 295, 400, 36),
circle_ring(x0 + 147.5, oy + 285, 62)]
circles = core.pack_circles(shape, 3.5, 44, gap=3.5, margin=5, attempts=attempts,
rng=random.Random(2))
region(shape)
discs(circles, width=1.1)
text(x0 + 147.5, oy + 485 + 34 * FS, T[key], anchor="middle", weight="bold",
halo=False)
note(ox + 30, oy + 610, T["note4"])
def export_png(svg_path, png_path, width):
exe = next((path for path in INKSCAPE if os.path.exists(path)), None)
if exe is None:
print("Inkscape introuvable : exporter {} a la main".format(svg_path))
return
os.makedirs(os.path.dirname(png_path), exist_ok=True)
subprocess.run([exe, svg_path, "--export-type=png",
"--export-width={}".format(width),
"--export-filename={}".format(png_path)],
check=True, stderr=subprocess.DEVNULL)
print(png_path)
def build(language):
global FS
version = VERSIONS[language]
out.clear()
T.clear()
T.update(version["text"])
FS = version["font_scale"]
out.append('<rect width="{}" height="{}" fill="#ffffff"/>'.format(W, H))
for x1, y1, x2, y2 in ((700, 20, 700, H - 20), (20, 640, W - 20, 640)):
out.append('<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="#dee2e6" '
'stroke-width="1.5"/>'.format(x1, y1, x2, y2))
panel_1()
panel_2()
panel_3()
panel_4()
svg = ('<?xml version="1.0" encoding="UTF-8"?>\n'
'<svg xmlns="http://www.w3.org/2000/svg" width="{0}" height="{1}" '
'viewBox="0 0 {0} {1}">\n{2}\n</svg>\n').format(W, H, "\n".join(out))
with open(version["svg"], "w", encoding="utf-8", newline="\n") as handle:
handle.write(svg)
print(version["svg"])
export_png(version["svg"], version["png"], version["png_width"])
def main(argv):
languages = argv or sorted(VERSIONS)
for language in languages:
build(language)
if __name__ == "__main__":
main(sys.argv[1:])

304
i18n.py Normal file
View File

@ -0,0 +1,304 @@
#!/usr/bin/env python3
# coding=utf-8
"""
Chaine de traduction de l'extension, sans dependance a gettext.
python i18n.py # extract + update + compile
python i18n.py extract # po/<domaine>.pot depuis le .inx et les .py
python i18n.py update # reporte le modele dans po/<langue>.po
python i18n.py compile # po/<langue>.po -> locale/<langue>/LC_MESSAGES/*.mo
Le domaine est lu dans l'attribut translationdomain du .inx ; les sources sont
le .inx et tous les .py du dossier (hors i18n.py et tests).
Les textes source (.inx et appels _() des .py) sont en anglais : c'est ce
qu'Inkscape affiche pour une langue sans catalogue. Le catalogue anglais est
rempli automatiquement avec les textes source ; seul fr.po se traduit a la main.
Les chaines retenues dans le .inx suivent les regles d'Inkscape (inx.its) :
<name>, attributs gui-text et gui-description, contenu de <option>, <item> et
<label>, sauf translatable="no". Les espaces sont normalises, sauf sous
xml:space="preserve".
"""
import ast
import os
import re
import struct
import sys
import xml.etree.ElementTree as ET
HERE = os.path.dirname(os.path.abspath(__file__))
LANGUAGES = ["en", "fr"]
SOURCE_LANGUAGE = "en"
PO_DIR = os.path.join(HERE, "po")
LOCALE_DIR = os.path.join(HERE, "locale")
XML_SPACE = "{http://www.w3.org/XML/1998/namespace}space"
INX_NS = "{http://www.inkscape.org/namespace/inkscape/extension}"
def _find_inx():
found = sorted(name for name in os.listdir(HERE) if name.endswith(".inx"))
if len(found) != 1:
sys.exit("i18n.py attend un seul fichier .inx dans {} (trouve : {})".format(
HERE, found))
return found[0]
INX = _find_inx()
_INX_ROOT = ET.parse(os.path.join(HERE, INX)).getroot()
DOMAIN = _INX_ROOT.get("translationdomain")
if not DOMAIN:
sys.exit("Attribut translationdomain absent de " + INX)
EXTENSION_NAME = (_INX_ROOT.findtext(INX_NS + "name") or DOMAIN).strip()
SOURCES = [INX] + sorted(
name for name in os.listdir(HERE)
if name.endswith(".py") and name != "i18n.py" and not name.startswith("test"))
# --------------------------------------------------------------------------
# Extraction
# --------------------------------------------------------------------------
def _local(tag):
return tag.rsplit("}", 1)[-1]
def _normalize(text, preserve):
return text if preserve else " ".join(text.split())
def extract_inx(path):
"""Chaines traduisibles d'un fichier .inx, dans l'ordre du document."""
messages = []
def walk(elem, preserve):
preserve = elem.get(XML_SPACE, "preserve" if preserve else "default") == "preserve"
if elem.get("translatable") != "no":
for attr in ("gui-text", "gui-description"):
if elem.get(attr):
messages.append(_normalize(elem.get(attr), preserve))
if _local(elem.tag) in ("name", "option", "item", "label") and elem.text:
messages.append(_normalize(elem.text, preserve))
for child in elem:
walk(child, preserve)
walk(ET.parse(path).getroot(), False)
return [m for m in messages if m]
def extract_py(path):
"""Arguments litteraux des appels _("...") d'un script Python."""
with open(path, encoding="utf-8") as handle:
tree = ast.parse(handle.read(), path)
calls = [node for node in ast.walk(tree)
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
and node.func.id == "_" and len(node.args) == 1
and isinstance(node.args[0], ast.Constant)
and isinstance(node.args[0].value, str)]
calls.sort(key=lambda node: (node.lineno, node.col_offset))
return [node.args[0].value for node in calls]
def extract():
"""Liste ordonnee et sans doublon de (msgid, [fichiers sources])."""
found = {}
for name in SOURCES:
path = os.path.join(HERE, name)
strings = extract_inx(path) if name.endswith(".inx") else extract_py(path)
for msgid in strings:
found.setdefault(msgid, [])
if name not in found[msgid]:
found[msgid].append(name)
return list(found.items())
# --------------------------------------------------------------------------
# Lecture / ecriture des fichiers .po
# --------------------------------------------------------------------------
_ESCAPES = {"n": "\n", "t": "\t", '"': '"', "\\": "\\"}
def _unquote(line):
body = line.strip()[1:-1]
return re.sub(r'\\(.)', lambda m: _ESCAPES.get(m.group(1), m.group(1)), body)
def _quote(text):
text = text.replace("\\", "\\\\").replace('"', '\\"').replace("\t", "\\t")
lines = text.split("\n")
parts = [line + "\\n" for line in lines[:-1]]
if lines[-1]:
parts.append(lines[-1])
if len(parts) <= 1:
return '"{}"'.format(parts[0] if parts else "")
return '""\n' + "\n".join('"{}"'.format(part) for part in parts)
def read_po(path):
"""Dictionnaire msgid -> (msgstr, fuzzy). L'en-tete a pour cle ""."""
entries = {}
if not os.path.exists(path):
return entries
msgid = msgstr = None
field = None
fuzzy = False
def flush():
if msgid is not None:
entries[msgid] = (msgstr or "", fuzzy)
with open(path, encoding="utf-8") as handle:
for raw in handle:
line = raw.strip()
if line.startswith("#,") and "fuzzy" in line:
flush()
msgid = msgstr = field = None
fuzzy = True
elif line.startswith("msgid "):
if field == "msgstr":
flush()
fuzzy = False
msgid, msgstr, field = _unquote(line[6:]), None, "msgid"
elif line.startswith("msgstr "):
msgstr, field = _unquote(line[7:]), "msgstr"
elif line.startswith('"') and field == "msgid":
msgid += _unquote(line)
elif line.startswith('"') and field == "msgstr":
msgstr += _unquote(line)
elif not line and field == "msgstr":
flush()
msgid = msgstr = field = None
fuzzy = False
flush()
return entries
def _header(language):
fields = [
("Project-Id-Version", DOMAIN),
("Language", language or ""),
("MIME-Version", "1.0"),
("Content-Type", "text/plain; charset=UTF-8"),
("Content-Transfer-Encoding", "8bit"),
("Plural-Forms", {"fr": "nplurals=2; plural=(n > 1);",
"en": "nplurals=2; plural=(n != 1);"}.get(language, "")),
]
return "".join("{}: {}\n".format(key, value) for key, value in fields if value)
def write_po(path, language, messages, translations):
title = ("Modele de traduction" if language is None
else "Traduction ({})".format(language))
out = ["# {} de l'extension Inkscape « {} ».".format(title, EXTENSION_NAME),
"# Genere par i18n.py ; ne modifier que les msgstr.",
"msgid \"\"",
"msgstr " + _quote(_header(language)),
""]
for msgid, refs in messages:
msgstr, fuzzy = translations.get(msgid, ("", False))
out.append("#: " + " ".join(refs))
if fuzzy:
out.append("#, fuzzy")
out.append("msgid " + _quote(msgid))
out.append("msgstr " + _quote(msgstr))
out.append("")
with open(path, "w", encoding="utf-8", newline="\n") as handle:
handle.write("\n".join(out))
# --------------------------------------------------------------------------
# Compilation .mo (format GNU gettext)
# --------------------------------------------------------------------------
def write_mo(path, catalog):
"""Ecrit un catalogue {msgid: msgstr} au format .mo, cles triees."""
keys = sorted(catalog)
ids = [key.encode("utf-8") for key in keys]
strs = [catalog[key].encode("utf-8") for key in keys]
count = len(keys)
ids_start = 7 * 4 + 16 * count
strs_start = ids_start + sum(len(b) + 1 for b in ids)
table_ids, table_strs = [], []
offset = ids_start
for data in ids:
table_ids += [len(data), offset]
offset += len(data) + 1
offset = strs_start
for data in strs:
table_strs += [len(data), offset]
offset += len(data) + 1
output = struct.pack("<7I", 0x950412DE, 0, count, 7 * 4, 7 * 4 + 8 * count, 0, 0)
output += struct.pack("<{}I".format(2 * count), *table_ids)
output += struct.pack("<{}I".format(2 * count), *table_strs)
output += b"".join(data + b"\0" for data in ids)
output += b"".join(data + b"\0" for data in strs)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as handle:
handle.write(output)
# --------------------------------------------------------------------------
# Commandes
# --------------------------------------------------------------------------
def po_path(language):
return os.path.join(PO_DIR, "{}.po".format(language))
def mo_path(language):
return os.path.join(LOCALE_DIR, language, "LC_MESSAGES", DOMAIN + ".mo")
def cmd_extract():
os.makedirs(PO_DIR, exist_ok=True)
messages = extract()
write_po(os.path.join(PO_DIR, DOMAIN + ".pot"), None, messages, {})
print("{} chaines -> po/{}.pot".format(len(messages), DOMAIN))
def cmd_update():
messages = extract()
for language in LANGUAGES:
translations = read_po(po_path(language))
if language == SOURCE_LANGUAGE:
translations = {msgid: (msgid, False) for msgid, _refs in messages}
write_po(po_path(language), language, messages, translations)
missing = [m for m, _r in messages if not translations.get(m, ("", False))[0]]
print("po/{}.po : {} a traduire".format(language, len(missing)))
for msgid in missing:
print(" " + msgid.splitlines()[0])
def cmd_compile():
for language in LANGUAGES:
entries = read_po(po_path(language))
catalog = {msgid: msgstr for msgid, (msgstr, fuzzy) in entries.items()
if msgid and msgstr and not fuzzy}
catalog[""] = _header(language)
write_mo(mo_path(language), catalog)
print("{} -> {} ({} chaines)".format(
os.path.relpath(po_path(language), HERE),
os.path.relpath(mo_path(language), HERE), len(catalog) - 1))
def main(argv):
commands = {"extract": [cmd_extract], "update": [cmd_update],
"compile": [cmd_compile],
"all": [cmd_extract, cmd_update, cmd_compile]}
name = argv[0] if argv else "all"
if name not in commands:
print(__doc__)
return 1
for command in commands[name]:
command()
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

BIN
images/parameters_en.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

Binary file not shown.

115
po/circlepacking.pot Normal file
View File

@ -0,0 +1,115 @@
# Modele de traduction de l'extension Inkscape « Circle Packing Fill ».
# Genere par i18n.py ; ne modifier que les msgstr.
msgid ""
msgstr ""
"Project-Id-Version: circlepacking\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: circle_packing.inx
msgid "Circle Packing Fill"
msgstr ""
#: circle_packing.inx
msgid "Circles"
msgstr ""
#: circle_packing.inx
msgid "Placed shape:"
msgstr ""
#: circle_packing.inx
msgid "Circle"
msgstr ""
#: circle_packing.inx
msgid "Custom shape (last selected object)"
msgstr ""
#: circle_packing.inx
msgid "Minimum radius:"
msgstr ""
#: circle_packing.inx
msgid "Maximum radius:"
msgstr ""
#: circle_packing.inx
msgid "Gap between circles:"
msgstr ""
#: circle_packing.inx
msgid "Inner margin to the outline:"
msgstr ""
#: circle_packing.inx
msgid "Unit:"
msgstr ""
#: circle_packing.inx
msgid "Algorithm"
msgstr ""
#: circle_packing.inx
msgid "Number of attempts:"
msgstr ""
#: circle_packing.inx
msgid "Maximum number of circles (0 = unlimited):"
msgstr ""
#: circle_packing.inx
msgid "Random seed (0 = random):"
msgstr ""
#: circle_packing.inx
msgid "Curve sampling fineness:"
msgstr ""
#: circle_packing.inx
msgid "Keep the original shape"
msgstr ""
#: circle_packing.inx
msgid "Help"
msgstr ""
#: circle_packing.inx
msgid ""
"Select one or more closed shapes (path, rectangle, circle, ellipse, polygon), then run the extension. Inner subpaths are treated as holes (even-odd rule).\n"
"\n"
"The generated circles inherit the style (fill, stroke) of the source shape and are placed in a group, ordered from neighbour to neighbour to shorten the travel of a laser cutter.\n"
"\n"
"Custom shape: paste the shape you want into the document, select the shapes to fill, then add the motif to the selection last (Shift+click). The last selected object is used as the motif and is not filled. Each copy is scaled to fit the computed disc, rotated at random, and keeps the style of the motif.\n"
"\n"
"More attempts give a denser fill but a longer computation."
msgstr ""
#: circle_packing.py
msgid "Custom shape mode: select the shape(s) to fill, then add the motif to the selection last (Shift+click)."
msgstr ""
#: circle_packing.py
msgid "Select at least one closed shape (path, rectangle, circle, ellipse, polygon)."
msgstr ""
#: circle_packing.py
msgid "The selected motif has no usable outline. Convert it to a path, or pick another object as the motif."
msgstr ""
#: circle_packing.py
msgid "The radii must be strictly positive."
msgstr ""
#: circle_packing.py
msgid "The minimum radius must be smaller than the maximum radius."
msgstr ""
#: circle_packing.py
msgid "No circle could be placed. Reduce the minimum radius, the gap or the margin, or increase the number of attempts."
msgstr ""
#: circle_packing.py
msgid "Circle packing"
msgstr ""

124
po/en.po Normal file
View File

@ -0,0 +1,124 @@
# Traduction (en) de l'extension Inkscape « Circle Packing Fill ».
# Genere par i18n.py ; ne modifier que les msgstr.
msgid ""
msgstr ""
"Project-Id-Version: circlepacking\n"
"Language: en\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: circle_packing.inx
msgid "Circle Packing Fill"
msgstr "Circle Packing Fill"
#: circle_packing.inx
msgid "Circles"
msgstr "Circles"
#: circle_packing.inx
msgid "Placed shape:"
msgstr "Placed shape:"
#: circle_packing.inx
msgid "Circle"
msgstr "Circle"
#: circle_packing.inx
msgid "Custom shape (last selected object)"
msgstr "Custom shape (last selected object)"
#: circle_packing.inx
msgid "Minimum radius:"
msgstr "Minimum radius:"
#: circle_packing.inx
msgid "Maximum radius:"
msgstr "Maximum radius:"
#: circle_packing.inx
msgid "Gap between circles:"
msgstr "Gap between circles:"
#: circle_packing.inx
msgid "Inner margin to the outline:"
msgstr "Inner margin to the outline:"
#: circle_packing.inx
msgid "Unit:"
msgstr "Unit:"
#: circle_packing.inx
msgid "Algorithm"
msgstr "Algorithm"
#: circle_packing.inx
msgid "Number of attempts:"
msgstr "Number of attempts:"
#: circle_packing.inx
msgid "Maximum number of circles (0 = unlimited):"
msgstr "Maximum number of circles (0 = unlimited):"
#: circle_packing.inx
msgid "Random seed (0 = random):"
msgstr "Random seed (0 = random):"
#: circle_packing.inx
msgid "Curve sampling fineness:"
msgstr "Curve sampling fineness:"
#: circle_packing.inx
msgid "Keep the original shape"
msgstr "Keep the original shape"
#: circle_packing.inx
msgid "Help"
msgstr "Help"
#: circle_packing.inx
msgid ""
"Select one or more closed shapes (path, rectangle, circle, ellipse, polygon), then run the extension. Inner subpaths are treated as holes (even-odd rule).\n"
"\n"
"The generated circles inherit the style (fill, stroke) of the source shape and are placed in a group, ordered from neighbour to neighbour to shorten the travel of a laser cutter.\n"
"\n"
"Custom shape: paste the shape you want into the document, select the shapes to fill, then add the motif to the selection last (Shift+click). The last selected object is used as the motif and is not filled. Each copy is scaled to fit the computed disc, rotated at random, and keeps the style of the motif.\n"
"\n"
"More attempts give a denser fill but a longer computation."
msgstr ""
"Select one or more closed shapes (path, rectangle, circle, ellipse, polygon), then run the extension. Inner subpaths are treated as holes (even-odd rule).\n"
"\n"
"The generated circles inherit the style (fill, stroke) of the source shape and are placed in a group, ordered from neighbour to neighbour to shorten the travel of a laser cutter.\n"
"\n"
"Custom shape: paste the shape you want into the document, select the shapes to fill, then add the motif to the selection last (Shift+click). The last selected object is used as the motif and is not filled. Each copy is scaled to fit the computed disc, rotated at random, and keeps the style of the motif.\n"
"\n"
"More attempts give a denser fill but a longer computation."
#: circle_packing.py
msgid "Custom shape mode: select the shape(s) to fill, then add the motif to the selection last (Shift+click)."
msgstr "Custom shape mode: select the shape(s) to fill, then add the motif to the selection last (Shift+click)."
#: circle_packing.py
msgid "Select at least one closed shape (path, rectangle, circle, ellipse, polygon)."
msgstr "Select at least one closed shape (path, rectangle, circle, ellipse, polygon)."
#: circle_packing.py
msgid "The selected motif has no usable outline. Convert it to a path, or pick another object as the motif."
msgstr "The selected motif has no usable outline. Convert it to a path, or pick another object as the motif."
#: circle_packing.py
msgid "The radii must be strictly positive."
msgstr "The radii must be strictly positive."
#: circle_packing.py
msgid "The minimum radius must be smaller than the maximum radius."
msgstr "The minimum radius must be smaller than the maximum radius."
#: circle_packing.py
msgid "No circle could be placed. Reduce the minimum radius, the gap or the margin, or increase the number of attempts."
msgstr "No circle could be placed. Reduce the minimum radius, the gap or the margin, or increase the number of attempts."
#: circle_packing.py
msgid "Circle packing"
msgstr "Circle packing"

124
po/fr.po Normal file
View File

@ -0,0 +1,124 @@
# Traduction (fr) de l'extension Inkscape « Circle Packing Fill ».
# Genere par i18n.py ; ne modifier que les msgstr.
msgid ""
msgstr ""
"Project-Id-Version: circlepacking\n"
"Language: fr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: circle_packing.inx
msgid "Circle Packing Fill"
msgstr "Remplissage par cercles (circle packing)"
#: circle_packing.inx
msgid "Circles"
msgstr "Cercles"
#: circle_packing.inx
msgid "Placed shape:"
msgstr "Forme déposée :"
#: circle_packing.inx
msgid "Circle"
msgstr "Cercle"
#: circle_packing.inx
msgid "Custom shape (last selected object)"
msgstr "Forme personnalisée (dernier objet sélectionné)"
#: circle_packing.inx
msgid "Minimum radius:"
msgstr "Rayon minimal :"
#: circle_packing.inx
msgid "Maximum radius:"
msgstr "Rayon maximal :"
#: circle_packing.inx
msgid "Gap between circles:"
msgstr "Écart entre cercles :"
#: circle_packing.inx
msgid "Inner margin to the outline:"
msgstr "Marge intérieure au contour :"
#: circle_packing.inx
msgid "Unit:"
msgstr "Unité :"
#: circle_packing.inx
msgid "Algorithm"
msgstr "Algorithme"
#: circle_packing.inx
msgid "Number of attempts:"
msgstr "Nombre de tentatives :"
#: circle_packing.inx
msgid "Maximum number of circles (0 = unlimited):"
msgstr "Nombre maximal de cercles (0 = illimité) :"
#: circle_packing.inx
msgid "Random seed (0 = random):"
msgstr "Graine aléatoire (0 = aléatoire) :"
#: circle_packing.inx
msgid "Curve sampling fineness:"
msgstr "Finesse d'échantillonnage des courbes :"
#: circle_packing.inx
msgid "Keep the original shape"
msgstr "Conserver la forme d'origine"
#: circle_packing.inx
msgid "Help"
msgstr "Aide"
#: circle_packing.inx
msgid ""
"Select one or more closed shapes (path, rectangle, circle, ellipse, polygon), then run the extension. Inner subpaths are treated as holes (even-odd rule).\n"
"\n"
"The generated circles inherit the style (fill, stroke) of the source shape and are placed in a group, ordered from neighbour to neighbour to shorten the travel of a laser cutter.\n"
"\n"
"Custom shape: paste the shape you want into the document, select the shapes to fill, then add the motif to the selection last (Shift+click). The last selected object is used as the motif and is not filled. Each copy is scaled to fit the computed disc, rotated at random, and keeps the style of the motif.\n"
"\n"
"More attempts give a denser fill but a longer computation."
msgstr ""
"Sélectionnez une ou plusieurs formes fermées (chemin, rectangle, cercle, ellipse, polygone), puis lancez l'extension. Les sous-chemins internes sont traités comme des trous (règle pair-impair).\n"
"\n"
"Les cercles générés héritent du style (fond, contour) de la forme source et sont placés dans un groupe, ordonnés de proche en proche pour raccourcir les déplacements d'une découpeuse laser.\n"
"\n"
"Forme personnalisée : collez la forme voulue dans le document, sélectionnez les formes à remplir, puis ajoutez le motif à la sélection en dernier (Maj+clic). Le dernier objet sélectionné sert de motif, il n'est pas rempli. Chaque copie est mise à l'échelle pour tenir dans le disque calculé, orientée au hasard, et conserve le style du motif.\n"
"\n"
"Un plus grand nombre de tentatives augmente la densité de remplissage mais allonge le temps de calcul."
#: circle_packing.py
msgid "Custom shape mode: select the shape(s) to fill, then add the motif to the selection last (Shift+click)."
msgstr "Mode « forme personnalisée » : sélectionnez la ou les formes à remplir, puis ajoutez le motif à la sélection en dernier (Maj+clic)."
#: circle_packing.py
msgid "Select at least one closed shape (path, rectangle, circle, ellipse, polygon)."
msgstr "Sélectionnez au moins une forme fermée (chemin, rectangle, cercle, ellipse, polygone)."
#: circle_packing.py
msgid "The selected motif has no usable outline. Convert it to a path, or pick another object as the motif."
msgstr "Le motif sélectionné ne fournit aucun contour exploitable. Convertissez-le en chemin, ou choisissez un autre objet comme motif."
#: circle_packing.py
msgid "The radii must be strictly positive."
msgstr "Les rayons doivent être strictement positifs."
#: circle_packing.py
msgid "The minimum radius must be smaller than the maximum radius."
msgstr "Le rayon minimal doit être inférieur au rayon maximal."
#: circle_packing.py
msgid "No circle could be placed. Reduce the minimum radius, the gap or the margin, or increase the number of attempts."
msgstr "Aucun cercle n'a pu être placé. Réduisez le rayon minimal, l'écart ou la marge, ou augmentez le nombre de tentatives."
#: circle_packing.py
msgid "Circle packing"
msgstr "Remplissage par cercles"

View File

@ -1,9 +1,16 @@
#!/usr/bin/env python3
# coding=utf-8
"""Tests du noyau geometrique, executables sans Inkscape : pytest test_packing.py"""
"""Tests de l'extension « Circle Packing Fill » : pytest test_packing.py
Le noyau geometrique, les traductions et la coherence du .inx se testent sans
Inkscape ; les tests de bout en bout sont sautes si inkex est absent.
"""
import math
import os
import random
import re
import xml.etree.ElementTree as ET
import pytest
@ -18,6 +25,8 @@ from packing_core import (
rings_bbox,
)
HERE = os.path.dirname(os.path.abspath(__file__))
SQUARE = [[(0, 0), (100, 0), (100, 100), (0, 100)]]
SQUARE_WITH_HOLE = SQUARE + [[(40, 40), (60, 40), (60, 60), (40, 60)]]
@ -317,3 +326,70 @@ def test_custom_motif_is_the_last_selected(tmp_path):
assert all(copy.style.get("fill") == "#3366cc" for copy in copies)
# le groupe est insere apres le triangle rempli, pas apres le rectangle
assert group.getprevious().get("id") == "m"
# --- traductions et boite de dialogue ---------------------------------------
def test_translations_up_to_date_and_complete():
"""Chaque texte du .inx et des .py a sa traduction dans chaque catalogue."""
import gettext
import i18n
msgids = [msgid for msgid, _refs in i18n.extract()]
assert "Circle Packing Fill" in msgids
assert "mm" not in msgids # unites marquees translatable="no"
for language in i18n.LANGUAGES:
entries = i18n.read_po(i18n.po_path(language))
missing = [m for m in msgids if not entries.get(m, ("", False))[0]]
assert not missing, "{}.po incomplet : {}".format(language, missing)
catalog = gettext.translation(i18n.DOMAIN, i18n.LOCALE_DIR, [language])
for msgid in msgids:
assert catalog.gettext(msgid) == entries[msgid][0], \
"{} : .mo a recompiler (python i18n.py)".format(language)
def test_po_roundtrip(tmp_path):
import i18n
messages = [("Simple", ["a"]), ('Quote "x" and \\ back', ["a"]),
("Two\nlines", ["a"]), ("Tab\tend\n", ["a"])]
path = str(tmp_path / "xx.po")
i18n.write_po(path, "fr", messages,
{m: ("<" + m + ">", False) for m, _r in messages})
entries = i18n.read_po(path)
for msgid, _refs in messages:
assert entries[msgid] == ("<" + msgid + ">", False)
def test_inx_matches_arguments():
"""Chaque <param> du .inx a son add_argument, et inversement."""
root = ET.parse(os.path.join(HERE, "circle_packing.inx")).getroot()
params = {elem.get("name") for elem in root.iter()
if elem.tag.rsplit("}", 1)[-1] == "param"}
with open(os.path.join(HERE, "circle_packing.py"), encoding="utf-8") as handle:
arguments = set(re.findall(r'add_argument\("--(\w+)"', handle.read()))
assert params == arguments
def test_inx_images_exist():
"""Les images de la boite de dialogue sont presentes (chemin relatif au .inx)."""
root = ET.parse(os.path.join(HERE, "circle_packing.inx")).getroot()
images = [elem for elem in root.iter() if elem.tag.rsplit("}", 1)[-1] == "image"]
assert images
for elem in images:
assert os.path.isfile(os.path.join(HERE, elem.text.strip())), elem.text
def test_group_label_defaults_to_source_text(tmp_path):
"""Hors d'Inkscape (aucun catalogue transmis), le groupe genere porte le
libelle anglais d'origine."""
pytest.importorskip("inkex")
from circle_packing import CirclePacking
out = tmp_path / "out.svg"
CirclePacking().run(["--id=rect", "--attempts=500", "--seed=1",
"--output={}".format(out),
os.path.join(HERE, "tests", "data", "shapes.svg")])
assert "Circle packing" in out.read_text(encoding="utf-8")