blog-image 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- blog_image-0.1.0/.github/ISSUE_TEMPLATE/config.yml +8 -0
- blog_image-0.1.0/.github/workflows/ci.yml +52 -0
- blog_image-0.1.0/.github/workflows/release.yml +42 -0
- blog_image-0.1.0/.gitignore +14 -0
- blog_image-0.1.0/CHANGELOG.md +42 -0
- blog_image-0.1.0/LICENSE +21 -0
- blog_image-0.1.0/PKG-INFO +190 -0
- blog_image-0.1.0/README.md +165 -0
- blog_image-0.1.0/blog_image/__init__.py +8 -0
- blog_image-0.1.0/blog_image/__main__.py +4 -0
- blog_image-0.1.0/blog_image/cli.py +61 -0
- blog_image-0.1.0/blog_image/config.py +236 -0
- blog_image-0.1.0/blog_image/fonts.py +133 -0
- blog_image-0.1.0/blog_image/motifs.py +89 -0
- blog_image-0.1.0/blog_image/render.py +167 -0
- blog_image-0.1.0/docs/exemple-16x9.png +0 -0
- blog_image-0.1.0/examples/complet.yml +17 -0
- blog_image-0.1.0/examples/image.yml +4 -0
- blog_image-0.1.0/pyproject.toml +50 -0
- blog_image-0.1.0/tests/test_config.py +69 -0
- blog_image-0.1.0/tests/test_render.py +91 -0
- blog_image-0.1.0/uv.lock +467 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
blank_issues_enabled: true
|
|
2
|
+
contact_links:
|
|
3
|
+
- name: Discuter sur Discord
|
|
4
|
+
url: https://discord.gg/FS2xqJ4QT6
|
|
5
|
+
about: Le salon #blog-image du serveur F-Overseas Solutions — la réponse y arrive plus vite que dans une issue.
|
|
6
|
+
- name: Question d'usage (Q&A)
|
|
7
|
+
url: https://github.com/foverseas/blog-image/discussions/categories/q-a
|
|
8
|
+
about: Comment choisir ses formats, une police qui ne se charge pas, un titre qui se coupe mal — une réponse marquée ici sert au suivant.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
lint:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v7
|
|
14
|
+
|
|
15
|
+
- uses: astral-sh/setup-uv@v10.3.2
|
|
16
|
+
with:
|
|
17
|
+
python-version: "3.12"
|
|
18
|
+
enable-cache: true
|
|
19
|
+
|
|
20
|
+
- name: Install the project
|
|
21
|
+
run: uv sync --locked --extra dev
|
|
22
|
+
|
|
23
|
+
- name: Lint
|
|
24
|
+
run: uv run ruff check .
|
|
25
|
+
|
|
26
|
+
tests:
|
|
27
|
+
runs-on: ubuntu-latest
|
|
28
|
+
strategy:
|
|
29
|
+
fail-fast: false
|
|
30
|
+
matrix:
|
|
31
|
+
python-version: ["3.10", "3.12", "3.14"]
|
|
32
|
+
|
|
33
|
+
steps:
|
|
34
|
+
- uses: actions/checkout@v7
|
|
35
|
+
|
|
36
|
+
# resolve_fonts() falls back to DejaVu when the spec names no family;
|
|
37
|
+
# the runner image is not guaranteed to carry it.
|
|
38
|
+
- name: Install the fallback font family
|
|
39
|
+
run: |
|
|
40
|
+
sudo apt-get update
|
|
41
|
+
sudo apt-get install -y --no-install-recommends fonts-dejavu-core
|
|
42
|
+
|
|
43
|
+
- uses: astral-sh/setup-uv@v10.3.2
|
|
44
|
+
with:
|
|
45
|
+
python-version: ${{ matrix.python-version }}
|
|
46
|
+
enable-cache: true
|
|
47
|
+
|
|
48
|
+
- name: Install the project
|
|
49
|
+
run: uv sync --locked --extra dev
|
|
50
|
+
|
|
51
|
+
- name: Run the tests
|
|
52
|
+
run: uv run pytest -q
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
name: Publier sur PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
environment: pypi
|
|
11
|
+
permissions:
|
|
12
|
+
# L'authentification auprès de PyPI se fait par ce jeton OIDC,
|
|
13
|
+
# pas par un secret : il n'y a aucun token à stocker ici.
|
|
14
|
+
id-token: write
|
|
15
|
+
contents: read
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v7
|
|
19
|
+
|
|
20
|
+
- uses: astral-sh/setup-uv@v10.3.2
|
|
21
|
+
with:
|
|
22
|
+
python-version: "3.12"
|
|
23
|
+
enable-cache: true
|
|
24
|
+
|
|
25
|
+
# Un tag qui ne correspond pas à la version du paquet publierait un
|
|
26
|
+
# numéro inattendu, et PyPI n'accepte jamais deux fois la même version.
|
|
27
|
+
- name: Vérifier que le tag correspond à la version du paquet
|
|
28
|
+
run: |
|
|
29
|
+
tag="${GITHUB_REF_NAME#v}"
|
|
30
|
+
version="$(uv version --short)"
|
|
31
|
+
if [ "$tag" != "$version" ]; then
|
|
32
|
+
echo "::error::le tag $GITHUB_REF_NAME annonce $tag, pyproject.toml dit $version"
|
|
33
|
+
exit 1
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
- name: Construire la distribution
|
|
37
|
+
run: uv build
|
|
38
|
+
|
|
39
|
+
- name: Vérifier les métadonnées
|
|
40
|
+
run: uvx twine check dist/*
|
|
41
|
+
|
|
42
|
+
- uses: pypa/gh-action-pypi-publish@v1.14.3
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Journal des changements
|
|
2
|
+
|
|
3
|
+
Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le
|
|
4
|
+
versionnage [SemVer](https://semver.org/lang/fr/).
|
|
5
|
+
|
|
6
|
+
## [0.1.0] - 2026-10-24
|
|
7
|
+
|
|
8
|
+
Première version.
|
|
9
|
+
|
|
10
|
+
### Ajouté
|
|
11
|
+
|
|
12
|
+
- Génération des images de couverture d'un article dans plusieurs rapports
|
|
13
|
+
d'image à partir d'un fichier YAML, pour ne plus entretenir à la main trois
|
|
14
|
+
fichiers qui divergent au premier changement de titre.
|
|
15
|
+
- Trois formats par défaut — 16:9 (1600×900) pour Open Graph et l'en-tête du
|
|
16
|
+
billet, 4:3 (1600×1200) pour l'emplacement Discover, 1:1 (1200×1200) pour les
|
|
17
|
+
recadrages sociaux. Les ratios `3x2`, `2x1`, `4x5` et `9x16` sont connus, et
|
|
18
|
+
toute taille s'écrit en pixels (`1240x700`).
|
|
19
|
+
- Clés de description en français ou en anglais (`titre` ou `title`, `fond` ou
|
|
20
|
+
`background`). Une clé inconnue est une erreur plutôt qu'un silence.
|
|
21
|
+
- Coupe automatique du titre sur la largeur mesurée de la police, avec
|
|
22
|
+
réduction du corps tant que le compte de lignes ou la largeur dépasse. Une
|
|
23
|
+
seule description sert les trois formats.
|
|
24
|
+
- Respect des espaces insécables : un `?` ou un `:` français ne part pas seul à
|
|
25
|
+
la ligne.
|
|
26
|
+
- Motifs `particules` et `grille`, ou `aucun`, dimensionnés sur l'unité
|
|
27
|
+
typographique et non sur la bande qu'ils occupent.
|
|
28
|
+
- Résolution de police multi-plateforme : une famille, un gabarit de chemin
|
|
29
|
+
contenant `%s`, ou une paire de fichiers explicite. À défaut, repli sur IBM
|
|
30
|
+
Plex Mono, DejaVu puis Arial, avec un message qui dit où l'outil a cherché.
|
|
31
|
+
- Interface en ligne de commande (`-o`, `-f`, `-n`, `--dry-run`, `-q`) et API
|
|
32
|
+
Python (`load_spec`, `resolve_fonts`, `render`).
|
|
33
|
+
|
|
34
|
+
### Notes
|
|
35
|
+
|
|
36
|
+
- Le rendu est reproductible : les mêmes descriptions produisent les mêmes
|
|
37
|
+
fichiers, octet pour octet, de Pillow 11.3 à Pillow 13.0.
|
|
38
|
+
- 29 tests, exécutés en intégration continue sur Python 3.10, 3.12 et 3.14.
|
|
39
|
+
- Le paquet n'est pas publié sur PyPI ; il s'installe depuis ce dépôt avec
|
|
40
|
+
`uv tool install git+https://github.com/foverseas/blog-image`.
|
|
41
|
+
|
|
42
|
+
[0.1.0]: https://github.com/foverseas/blog-image/releases/tag/v0.1.0
|
blog_image-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 F-Overseas Solutions
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: blog-image
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Generate article cover images in several aspect ratios from a short YAML file.
|
|
5
|
+
Project-URL: Homepage, https://github.com/foverseas/blog-image
|
|
6
|
+
Project-URL: Repository, https://github.com/foverseas/blog-image
|
|
7
|
+
Project-URL: Issues, https://github.com/foverseas/blog-image/issues
|
|
8
|
+
Project-URL: Discord, https://discord.gg/FS2xqJ4QT6
|
|
9
|
+
Author: F-Overseas Solutions
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: blog,cover-image,og-image,pillow,social-card
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: pillow>=10.0
|
|
20
|
+
Requires-Dist: pyyaml>=6.0
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: ruff>=0.17; extra == 'dev'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# blog-image
|
|
27
|
+
|
|
28
|
+
[](https://github.com/foverseas/blog-image/actions/workflows/ci.yml)
|
|
29
|
+
|
|
30
|
+
Génère les images de couverture d'un article dans plusieurs formats, à partir
|
|
31
|
+
d'un fichier YAML de trois lignes.
|
|
32
|
+
|
|
33
|
+
```yaml
|
|
34
|
+
# image.yml
|
|
35
|
+
titre: "Combien coûte une application mobile"
|
|
36
|
+
accent: "#00ff99"
|
|
37
|
+
fond: "#1c1c22"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```console
|
|
41
|
+
$ blog-image image.yml
|
|
42
|
+
-> article-16x9.png 1600x900
|
|
43
|
+
-> article-4x3.png 1600x1200
|
|
44
|
+
-> article-1x1.png 1200x1200
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+

|
|
48
|
+
|
|
49
|
+
Trois formats parce qu'un article en a besoin de trois : le 16:9 pour Open
|
|
50
|
+
Graph et l'en-tête du billet, le 4:3 pour l'emplacement Discover, le 1:1 pour
|
|
51
|
+
les recadrages sociaux. Les générer séparément, c'est trois fichiers qui
|
|
52
|
+
divergent au premier changement de titre.
|
|
53
|
+
|
|
54
|
+
## Installation
|
|
55
|
+
|
|
56
|
+
Avec [uv](https://docs.astral.sh/uv/), sans rien installer de permanent :
|
|
57
|
+
|
|
58
|
+
```console
|
|
59
|
+
$ uvx --from blog-image blog-image image.yml
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Pour l'avoir sous la main partout :
|
|
63
|
+
|
|
64
|
+
```console
|
|
65
|
+
$ uv tool install blog-image # ou : pipx install blog-image
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Et en développement, depuis les sources :
|
|
69
|
+
|
|
70
|
+
```console
|
|
71
|
+
$ git clone https://github.com/foverseas/blog-image
|
|
72
|
+
$ cd blog-image
|
|
73
|
+
$ uv sync --extra dev # crée .venv d'après uv.lock
|
|
74
|
+
$ uv run blog-image examples/image.yml
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`pip install blog-image` fonctionne aussi : c'est un paquet ordinaire, uv n'est
|
|
78
|
+
pas une dépendance.
|
|
79
|
+
|
|
80
|
+
Il faut Python 3.10, Pillow et PyYAML. La police par défaut est JetBrains
|
|
81
|
+
Mono ; si elle n'est pas installée, le programme retombe sur IBM Plex Mono,
|
|
82
|
+
DejaVu puis Arial, et dit où il a cherché s'il ne trouve rien.
|
|
83
|
+
|
|
84
|
+
## Le fichier de description
|
|
85
|
+
|
|
86
|
+
Seul `titre` est obligatoire. Les clés s'écrivent en français ou en anglais.
|
|
87
|
+
|
|
88
|
+
| Clé | Alias | Défaut | Rôle |
|
|
89
|
+
| --- | --- | --- | --- |
|
|
90
|
+
| `titre` | `title` | — | le titre, coupé automatiquement sur la largeur réelle de la police |
|
|
91
|
+
| `sous-titre` | `subtitle` | vide | une ligne sous le titre ; une liste devient autant de lignes |
|
|
92
|
+
| `chapeau` | `eyebrow` | vide | surtitre en capitales espacées, dans la couleur d'accent |
|
|
93
|
+
| `pied` | `footer` | vide | le pied de carte, précédé d'une pastille |
|
|
94
|
+
| `fond` | `background` | `#1c1c22` | fond de la carte |
|
|
95
|
+
| `texte` | `foreground` | `#ffffff` | couleur du texte |
|
|
96
|
+
| `accent` | — | `#00ff99` | pastille, surtitre, point final, motif |
|
|
97
|
+
| `motif` | — | `particules` | `particules`, `grille` ou `aucun` |
|
|
98
|
+
| `graine` | `seed` | `0` | change la disposition du motif sans rien toucher d'autre |
|
|
99
|
+
| `police` | `font` | JetBrains Mono | nom de famille, gabarit de chemin contenant `%s`, ou `{regular:, bold:}` |
|
|
100
|
+
| `formats` | — | `[16x9, 4x3, 1x1]` | ratios connus ou tailles explicites en pixels |
|
|
101
|
+
| `nom` | `name` | `article` | base du nom de fichier |
|
|
102
|
+
| `sortie` | `out` | `.` | dossier de sortie, relatif au fichier YAML |
|
|
103
|
+
| `point` | `dot` | `true` | le point d'accent après le titre |
|
|
104
|
+
| `lignes-max` | `max_lines` | `4` | au-delà, le titre rapetisse au lieu de déborder |
|
|
105
|
+
|
|
106
|
+
Les couleurs s'écrivent `#rgb`, `#rrggbb` ou `[28, 28, 34]`. Une clé inconnue
|
|
107
|
+
est une erreur : une faute de frappe dans un nom de clé ne se voit sinon qu'une
|
|
108
|
+
fois la carte terminée.
|
|
109
|
+
|
|
110
|
+
Ratios connus : `16x9`, `4x3`, `3x2`, `2x1`, `1x1`, `4x5`, `9x16`. Pour tout
|
|
111
|
+
autre besoin, écrivez la taille en pixels — `1240x700`.
|
|
112
|
+
|
|
113
|
+
Voir [`examples/complet.yml`](examples/complet.yml) pour un fichier avec toutes
|
|
114
|
+
les clés.
|
|
115
|
+
|
|
116
|
+
## En ligne de commande
|
|
117
|
+
|
|
118
|
+
```console
|
|
119
|
+
$ blog-image image.yml # formats du fichier
|
|
120
|
+
$ blog-image image.yml -o public/blog # ailleurs
|
|
121
|
+
$ blog-image image.yml -f 16x9 -f 1200x628 # autres formats
|
|
122
|
+
$ blog-image image.yml --dry-run # ce qui serait écrit, sans écrire
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`-n/--name` remplace la base du nom de fichier, `-q/--quiet` coupe la sortie.
|
|
126
|
+
Une erreur de description sort en code 2 avec un message en clair, pas une
|
|
127
|
+
trace.
|
|
128
|
+
|
|
129
|
+
## Comme bibliothèque
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
from pathlib import Path
|
|
133
|
+
from blog_image import load_spec, render, resolve_fonts
|
|
134
|
+
|
|
135
|
+
spec = load_spec(Path("image.yml"))
|
|
136
|
+
fonts = resolve_fonts(spec.font)
|
|
137
|
+
for fmt in spec.formats:
|
|
138
|
+
render(spec, fonts, fmt, spec.path_for(fmt))
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Trois décisions qui expliquent le reste du code
|
|
142
|
+
|
|
143
|
+
**Tout est dessiné à 3× puis réduit en Lanczos.** C'est ce qui tient les
|
|
144
|
+
filets d'un pixel et le petit corps, pas l'anticrénelage du rastériseur, qui
|
|
145
|
+
est inégal à ces tailles. La réduction est suivie d'une quantification sur 256
|
|
146
|
+
couleurs : sur un aplat, elle ne se voit pas et divise le poids du fichier par
|
|
147
|
+
deux.
|
|
148
|
+
|
|
149
|
+
**Le corps du texte est calculé sur une unité, pas sur la largeur.** Une carte
|
|
150
|
+
1:1 n'est pas plus large qu'une 4:3 mais elle a bien plus de place ; dimensionner
|
|
151
|
+
sur la largeur seule la laisse creuse. L'unité corrige la largeur par l'écart
|
|
152
|
+
entre le ratio demandé et le 16:9 :
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
unit = width * (1 + max(0.0, height / width - 0.5625) * 0.34)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
**Le titre est coupé sur la largeur mesurée de la police**, pas sur un nombre
|
|
159
|
+
de caractères, et le corps diminue tant que le compte de lignes dépasse
|
|
160
|
+
`lignes-max`. C'est ce qui évite d'écrire une coupe différente par format.
|
|
161
|
+
|
|
162
|
+
Le motif, lui, est dimensionné sur la même unité et non sur la bande qu'il
|
|
163
|
+
occupe : en 16:9 cette bande fait quelques centaines de pixels, en 1:1 cinq
|
|
164
|
+
fois plus, et un décor calé sur la bande disparaît dans le premier cas et
|
|
165
|
+
écrase le second. Seul le nombre de marques dépend de la place disponible.
|
|
166
|
+
|
|
167
|
+
## Tests
|
|
168
|
+
|
|
169
|
+
```console
|
|
170
|
+
$ uv run pytest
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Le rendu est reproductible : les mêmes descriptions donnent les mêmes fichiers,
|
|
174
|
+
octet pour octet, d'une version de Pillow à l'autre. `uv.lock` est versionné
|
|
175
|
+
pour que ce soit vrai d'une machine à l'autre aussi.
|
|
176
|
+
|
|
177
|
+
## Journal des changements
|
|
178
|
+
|
|
179
|
+
Voir [CHANGELOG.md](CHANGELOG.md).
|
|
180
|
+
|
|
181
|
+
## Contact
|
|
182
|
+
|
|
183
|
+
Une question d'usage, un titre qui se coupe mal, une idée de format : le
|
|
184
|
+
[Discord](https://discord.gg/FS2xqJ4QT6) est le chemin le plus court. Les questions posées dans les
|
|
185
|
+
[Discussions](https://github.com/foverseas/blog-image/discussions/categories/q-a) y gagnent une réponse qui
|
|
186
|
+
reste trouvable, et les bugs vont dans les [issues](https://github.com/foverseas/blog-image/issues).
|
|
187
|
+
|
|
188
|
+
## Licence
|
|
189
|
+
|
|
190
|
+
MIT.
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# blog-image
|
|
2
|
+
|
|
3
|
+
[](https://github.com/foverseas/blog-image/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
Génère les images de couverture d'un article dans plusieurs formats, à partir
|
|
6
|
+
d'un fichier YAML de trois lignes.
|
|
7
|
+
|
|
8
|
+
```yaml
|
|
9
|
+
# image.yml
|
|
10
|
+
titre: "Combien coûte une application mobile"
|
|
11
|
+
accent: "#00ff99"
|
|
12
|
+
fond: "#1c1c22"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```console
|
|
16
|
+
$ blog-image image.yml
|
|
17
|
+
-> article-16x9.png 1600x900
|
|
18
|
+
-> article-4x3.png 1600x1200
|
|
19
|
+
-> article-1x1.png 1200x1200
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+

|
|
23
|
+
|
|
24
|
+
Trois formats parce qu'un article en a besoin de trois : le 16:9 pour Open
|
|
25
|
+
Graph et l'en-tête du billet, le 4:3 pour l'emplacement Discover, le 1:1 pour
|
|
26
|
+
les recadrages sociaux. Les générer séparément, c'est trois fichiers qui
|
|
27
|
+
divergent au premier changement de titre.
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
Avec [uv](https://docs.astral.sh/uv/), sans rien installer de permanent :
|
|
32
|
+
|
|
33
|
+
```console
|
|
34
|
+
$ uvx --from blog-image blog-image image.yml
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Pour l'avoir sous la main partout :
|
|
38
|
+
|
|
39
|
+
```console
|
|
40
|
+
$ uv tool install blog-image # ou : pipx install blog-image
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Et en développement, depuis les sources :
|
|
44
|
+
|
|
45
|
+
```console
|
|
46
|
+
$ git clone https://github.com/foverseas/blog-image
|
|
47
|
+
$ cd blog-image
|
|
48
|
+
$ uv sync --extra dev # crée .venv d'après uv.lock
|
|
49
|
+
$ uv run blog-image examples/image.yml
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`pip install blog-image` fonctionne aussi : c'est un paquet ordinaire, uv n'est
|
|
53
|
+
pas une dépendance.
|
|
54
|
+
|
|
55
|
+
Il faut Python 3.10, Pillow et PyYAML. La police par défaut est JetBrains
|
|
56
|
+
Mono ; si elle n'est pas installée, le programme retombe sur IBM Plex Mono,
|
|
57
|
+
DejaVu puis Arial, et dit où il a cherché s'il ne trouve rien.
|
|
58
|
+
|
|
59
|
+
## Le fichier de description
|
|
60
|
+
|
|
61
|
+
Seul `titre` est obligatoire. Les clés s'écrivent en français ou en anglais.
|
|
62
|
+
|
|
63
|
+
| Clé | Alias | Défaut | Rôle |
|
|
64
|
+
| --- | --- | --- | --- |
|
|
65
|
+
| `titre` | `title` | — | le titre, coupé automatiquement sur la largeur réelle de la police |
|
|
66
|
+
| `sous-titre` | `subtitle` | vide | une ligne sous le titre ; une liste devient autant de lignes |
|
|
67
|
+
| `chapeau` | `eyebrow` | vide | surtitre en capitales espacées, dans la couleur d'accent |
|
|
68
|
+
| `pied` | `footer` | vide | le pied de carte, précédé d'une pastille |
|
|
69
|
+
| `fond` | `background` | `#1c1c22` | fond de la carte |
|
|
70
|
+
| `texte` | `foreground` | `#ffffff` | couleur du texte |
|
|
71
|
+
| `accent` | — | `#00ff99` | pastille, surtitre, point final, motif |
|
|
72
|
+
| `motif` | — | `particules` | `particules`, `grille` ou `aucun` |
|
|
73
|
+
| `graine` | `seed` | `0` | change la disposition du motif sans rien toucher d'autre |
|
|
74
|
+
| `police` | `font` | JetBrains Mono | nom de famille, gabarit de chemin contenant `%s`, ou `{regular:, bold:}` |
|
|
75
|
+
| `formats` | — | `[16x9, 4x3, 1x1]` | ratios connus ou tailles explicites en pixels |
|
|
76
|
+
| `nom` | `name` | `article` | base du nom de fichier |
|
|
77
|
+
| `sortie` | `out` | `.` | dossier de sortie, relatif au fichier YAML |
|
|
78
|
+
| `point` | `dot` | `true` | le point d'accent après le titre |
|
|
79
|
+
| `lignes-max` | `max_lines` | `4` | au-delà, le titre rapetisse au lieu de déborder |
|
|
80
|
+
|
|
81
|
+
Les couleurs s'écrivent `#rgb`, `#rrggbb` ou `[28, 28, 34]`. Une clé inconnue
|
|
82
|
+
est une erreur : une faute de frappe dans un nom de clé ne se voit sinon qu'une
|
|
83
|
+
fois la carte terminée.
|
|
84
|
+
|
|
85
|
+
Ratios connus : `16x9`, `4x3`, `3x2`, `2x1`, `1x1`, `4x5`, `9x16`. Pour tout
|
|
86
|
+
autre besoin, écrivez la taille en pixels — `1240x700`.
|
|
87
|
+
|
|
88
|
+
Voir [`examples/complet.yml`](examples/complet.yml) pour un fichier avec toutes
|
|
89
|
+
les clés.
|
|
90
|
+
|
|
91
|
+
## En ligne de commande
|
|
92
|
+
|
|
93
|
+
```console
|
|
94
|
+
$ blog-image image.yml # formats du fichier
|
|
95
|
+
$ blog-image image.yml -o public/blog # ailleurs
|
|
96
|
+
$ blog-image image.yml -f 16x9 -f 1200x628 # autres formats
|
|
97
|
+
$ blog-image image.yml --dry-run # ce qui serait écrit, sans écrire
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`-n/--name` remplace la base du nom de fichier, `-q/--quiet` coupe la sortie.
|
|
101
|
+
Une erreur de description sort en code 2 avec un message en clair, pas une
|
|
102
|
+
trace.
|
|
103
|
+
|
|
104
|
+
## Comme bibliothèque
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from pathlib import Path
|
|
108
|
+
from blog_image import load_spec, render, resolve_fonts
|
|
109
|
+
|
|
110
|
+
spec = load_spec(Path("image.yml"))
|
|
111
|
+
fonts = resolve_fonts(spec.font)
|
|
112
|
+
for fmt in spec.formats:
|
|
113
|
+
render(spec, fonts, fmt, spec.path_for(fmt))
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Trois décisions qui expliquent le reste du code
|
|
117
|
+
|
|
118
|
+
**Tout est dessiné à 3× puis réduit en Lanczos.** C'est ce qui tient les
|
|
119
|
+
filets d'un pixel et le petit corps, pas l'anticrénelage du rastériseur, qui
|
|
120
|
+
est inégal à ces tailles. La réduction est suivie d'une quantification sur 256
|
|
121
|
+
couleurs : sur un aplat, elle ne se voit pas et divise le poids du fichier par
|
|
122
|
+
deux.
|
|
123
|
+
|
|
124
|
+
**Le corps du texte est calculé sur une unité, pas sur la largeur.** Une carte
|
|
125
|
+
1:1 n'est pas plus large qu'une 4:3 mais elle a bien plus de place ; dimensionner
|
|
126
|
+
sur la largeur seule la laisse creuse. L'unité corrige la largeur par l'écart
|
|
127
|
+
entre le ratio demandé et le 16:9 :
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
unit = width * (1 + max(0.0, height / width - 0.5625) * 0.34)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**Le titre est coupé sur la largeur mesurée de la police**, pas sur un nombre
|
|
134
|
+
de caractères, et le corps diminue tant que le compte de lignes dépasse
|
|
135
|
+
`lignes-max`. C'est ce qui évite d'écrire une coupe différente par format.
|
|
136
|
+
|
|
137
|
+
Le motif, lui, est dimensionné sur la même unité et non sur la bande qu'il
|
|
138
|
+
occupe : en 16:9 cette bande fait quelques centaines de pixels, en 1:1 cinq
|
|
139
|
+
fois plus, et un décor calé sur la bande disparaît dans le premier cas et
|
|
140
|
+
écrase le second. Seul le nombre de marques dépend de la place disponible.
|
|
141
|
+
|
|
142
|
+
## Tests
|
|
143
|
+
|
|
144
|
+
```console
|
|
145
|
+
$ uv run pytest
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Le rendu est reproductible : les mêmes descriptions donnent les mêmes fichiers,
|
|
149
|
+
octet pour octet, d'une version de Pillow à l'autre. `uv.lock` est versionné
|
|
150
|
+
pour que ce soit vrai d'une machine à l'autre aussi.
|
|
151
|
+
|
|
152
|
+
## Journal des changements
|
|
153
|
+
|
|
154
|
+
Voir [CHANGELOG.md](CHANGELOG.md).
|
|
155
|
+
|
|
156
|
+
## Contact
|
|
157
|
+
|
|
158
|
+
Une question d'usage, un titre qui se coupe mal, une idée de format : le
|
|
159
|
+
[Discord](https://discord.gg/FS2xqJ4QT6) est le chemin le plus court. Les questions posées dans les
|
|
160
|
+
[Discussions](https://github.com/foverseas/blog-image/discussions/categories/q-a) y gagnent une réponse qui
|
|
161
|
+
reste trouvable, et les bugs vont dans les [issues](https://github.com/foverseas/blog-image/issues).
|
|
162
|
+
|
|
163
|
+
## Licence
|
|
164
|
+
|
|
165
|
+
MIT.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Generate article cover images in several aspect ratios from a short YAML file."""
|
|
2
|
+
|
|
3
|
+
from .config import Format, Spec, load_spec
|
|
4
|
+
from .fonts import FontSet, resolve_fonts
|
|
5
|
+
from .render import render
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
__all__ = ["FontSet", "Format", "Spec", "load_spec", "render", "resolve_fonts"]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Command line entry point: `blog-image image.yml`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .config import SpecError, load_spec, parse_format, slugify
|
|
11
|
+
from .fonts import FontError, resolve_fonts
|
|
12
|
+
from .render import render
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
prog="blog-image",
|
|
18
|
+
description="Generate article cover images in several aspect ratios from a short YAML file.",
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument("spec", type=Path, help="YAML file describing the card")
|
|
21
|
+
parser.add_argument("-o", "--out", type=Path, help="output directory (overrides `sortie`)")
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"-f",
|
|
24
|
+
"--format",
|
|
25
|
+
action="append",
|
|
26
|
+
dest="formats",
|
|
27
|
+
metavar="RATIO",
|
|
28
|
+
help="format to render, repeatable; overrides `formats` (e.g. 16x9, 1x1, 1600x900)",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument("-n", "--name", help="base name for the files (overrides `nom`)")
|
|
31
|
+
parser.add_argument("--dry-run", action="store_true", help="list what would be written and stop")
|
|
32
|
+
parser.add_argument("-q", "--quiet", action="store_true", help="print nothing on success")
|
|
33
|
+
parser.add_argument("--version", action="version", version=f"blog-image {__version__}")
|
|
34
|
+
return parser
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def main(argv: list[str] | None = None) -> int:
|
|
38
|
+
args = build_parser().parse_args(argv)
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
spec = load_spec(args.spec)
|
|
42
|
+
if args.formats:
|
|
43
|
+
spec.formats = tuple(parse_format(token) for token in args.formats)
|
|
44
|
+
if args.out is not None:
|
|
45
|
+
spec.out = args.out
|
|
46
|
+
if args.name:
|
|
47
|
+
spec.name = slugify(args.name)
|
|
48
|
+
fonts = None if args.dry_run else resolve_fonts(spec.font)
|
|
49
|
+
except (SpecError, FontError) as error:
|
|
50
|
+
print(f"blog-image: {error}", file=sys.stderr)
|
|
51
|
+
return 2
|
|
52
|
+
|
|
53
|
+
for fmt in spec.formats:
|
|
54
|
+
path = spec.path_for(fmt)
|
|
55
|
+
if not args.dry_run:
|
|
56
|
+
render(spec, fonts, fmt, path)
|
|
57
|
+
if not args.quiet:
|
|
58
|
+
prefix = "would write" if args.dry_run else "->"
|
|
59
|
+
print(f" {prefix} {path} {fmt.width}x{fmt.height}")
|
|
60
|
+
|
|
61
|
+
return 0
|