Documentation
This commit is contained in:
parent
0dece85b14
commit
77cce1d3f9
15
.gitignore
vendored
15
.gitignore
vendored
@ -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/
|
||||
|
||||
75
README.md
75
README.md
@ -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
|
||||
|
||||

|
||||
|
||||
| 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.
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
32
deploy.ps1
32
deploy.ps1
@ -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
BIN
docs/parametres.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 485 KiB |
422
docs/schema_parametres.py
Normal file
422
docs/schema_parametres.py
Normal 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
304
i18n.py
Normal 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
BIN
images/parameters_en.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 310 KiB |
BIN
locale/en/LC_MESSAGES/circlepacking.mo
Normal file
BIN
locale/en/LC_MESSAGES/circlepacking.mo
Normal file
Binary file not shown.
BIN
locale/fr/LC_MESSAGES/circlepacking.mo
Normal file
BIN
locale/fr/LC_MESSAGES/circlepacking.mo
Normal file
Binary file not shown.
115
po/circlepacking.pot
Normal file
115
po/circlepacking.pot
Normal 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
124
po/en.po
Normal 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
124
po/fr.po
Normal 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"
|
||||
@ -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")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user