diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 09c27eb..56c3b7f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -13,7 +13,7 @@ updates: timezone: "Europe/Paris" open-pull-requests-limit: 5 commit-message: - prefix: "cargo" + prefix: "[BOT] Cargo :" labels: - "dependencies" - "rust" @@ -24,26 +24,6 @@ updates: - "minor" - "patch" - # --- Python dependencies (requirements.txt at the repository root) --- - - package-ecosystem: "pip" - directory: "/" - schedule: - interval: "weekly" - day: "monday" - time: "06:00" - timezone: "Europe/Paris" - open-pull-requests-limit: 5 - commit-message: - prefix: "pip" - labels: - - "dependencies" - - "python" - groups: - pip-minor-patch: - update-types: - - "minor" - - "patch" - # --- Actions used in .github/workflows/ --- - package-ecosystem: "github-actions" directory: "/" @@ -54,7 +34,7 @@ updates: timezone: "Europe/Paris" open-pull-requests-limit: 5 commit-message: - prefix: "ci" + prefix: "[BOT] CI :" labels: - "dependencies" - "github-actions" @@ -73,7 +53,7 @@ updates: timezone: "Europe/Paris" open-pull-requests-limit: 3 commit-message: - prefix: "docker" + prefix: "[BOT] Docker :" labels: - "dependencies" - "docker" diff --git a/.github/workflows/Docker CI.yml b/.github/workflows/Docker CI.yml index 2b654fc..82c9343 100644 --- a/.github/workflows/Docker CI.yml +++ b/.github/workflows/Docker CI.yml @@ -6,15 +6,13 @@ on: branches: [ main ] paths: - 'Dockerfile' - - 'requirements.txt' - - 'Filtro_Python/**' + - 'Filtro_Rust/**' - '.github/workflows/Docker CI.yml' pull_request: branches: [ main ] paths: - 'Dockerfile' - - 'requirements.txt' - - 'Filtro_Python/**' + - 'Filtro_Rust/**' - '.github/workflows/Docker CI.yml' workflow_dispatch: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9e16a31..0c4495d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,14 +1,12 @@ -# CodeQL security analysis for both implementations of the project: -# - Python: Filtro_Python/ -# - Rust: Filtro_Rust/ +# CodeQL security analysis for the Rust crate (Filtro_Rust/). # Results show up under Security > Code scanning. name: CodeQL on: push: - branches: [ "main" ] + branches: [ "main", "development" ] pull_request: - branches: [ "main" ] + branches: [ "main", "development" ] schedule: - cron: '0 5 * * 0' workflow_dispatch: @@ -35,9 +33,7 @@ jobs: fail-fast: false matrix: include: - # Neither language requires a build step for CodeQL extraction - - language: python - build-mode: none + # Rust does not require a build step for CodeQL extraction - language: rust build-mode: none diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml deleted file mode 100644 index 1c0ffbf..0000000 --- a/.github/workflows/python-ci.yml +++ /dev/null @@ -1,62 +0,0 @@ -# This workflow installs the Python dependencies, checks code quality with Pylint -# and verifies that the Flask application imports correctly. -name: Python CI - -on: - push: - branches: [ main ] - paths: - - 'Filtro_Python/**' - - 'requirements.txt' - - '.github/workflows/python-ci.yml' - pull_request: - branches: [ main ] - paths: - - 'Filtro_Python/**' - - 'requirements.txt' - - '.github/workflows/python-ci.yml' - workflow_dispatch: - -env: - # Run JavaScript actions on Node 24 instead of the default Node 20 - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - -concurrency: - group: python-${{ github.ref }} - cancel-in-progress: true - -jobs: - lint: - name: Pylint code analysis - runs-on: ubuntu-latest - permissions: - contents: read - - strategy: - fail-fast: false - matrix: - python-version: [ '3.12', '3.13' ] - - steps: - - uses: actions/checkout@v7 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v7 - with: - python-version: ${{ matrix.python-version }} - cache: 'pip' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - pip install pylint - - - name: Run Pylint - # The Python code lives in Filtro_Python/, which is not a package: - # --recursive=y is required for pylint to walk the directory - run: pylint --recursive=y Filtro_Python --ignore=venv,tests,migrations,static --exit-zero - - - name: Check that the application imports - working-directory: Filtro_Python - run: python -c "import app; print('Flask app OK:', app.app.name)" diff --git a/.gitignore b/.gitignore index 9ad87a3..081e7d8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,40 +1,3 @@ -# --------------------------------------------------------------- -# Python (Filtro_Python) -# --------------------------------------------------------------- -__pycache__/ -*.py[cod] -*$py.class -*.so - -# Virtual environments -.venv/ -venv/ -ENV/ -env/ - -# Packaging / build -build/ -dist/ -*.egg-info/ -.eggs/ - -# Tests & tooling caches -.pytest_cache/ -.mypy_cache/ -.ruff_cache/ -.coverage -.coverage.* -htmlcov/ -.tox/ - -# Flask -instance/ -*.log - -# Files generated at runtime by the app -Filtro_Python/static/uploads/ -Filtro_Python/static/results/ - # --------------------------------------------------------------- # Rust (Filtro_Rust) # --------------------------------------------------------------- diff --git a/Dockerfile b/Dockerfile index 930a5f5..27d31dc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,26 @@ -# Utilise une image python officielle comme parent -FROM python:3.14-slim +# syntax=docker/dockerfile:1 -# Définit le répertoire de travail dans le conteneur -WORKDIR /app +# ---- Build stage : compile the `filtro` binary ------------------------------ +FROM rust:1-slim AS build -# Copie les fichiers nécessaires -COPY . /app +WORKDIR /src +# Only the Rust crate is needed to build +COPY Filtro_Rust/ ./Filtro_Rust/ -# Installation des dépendances Python -RUN pip install --no-cache-dir -r requirements.txt +WORKDIR /src/Filtro_Rust +RUN cargo build --release --locked -# Expose le port de Flask (par défaut 5000) -EXPOSE 5000 +# ---- Runtime stage : minimal image with just the binary -------------------- +FROM debian:stable-slim -# L'application Flask vit dans Filtro_Python/ et utilise des chemins relatifs -# (static/uploads, static/results) : on se place donc dans ce dossier -WORKDIR /app/Filtro_Python +# Run as a non-root user +RUN useradd --create-home --uid 10001 filtro +USER filtro +WORKDIR /work -# Commande pour lancer l’application Flask -CMD ["python", "app.py"] +COPY --from=build /src/Filtro_Rust/target/release/filtro /usr/local/bin/filtro + +# The container *is* the CLI tool: +# docker run --rm -v "$PWD:/work" filtro photo.jpg -f jaune -o out +ENTRYPOINT ["filtro"] +CMD ["--help"] diff --git a/Filtro_Python/app.py b/Filtro_Python/app.py deleted file mode 100644 index a142b05..0000000 --- a/Filtro_Python/app.py +++ /dev/null @@ -1,80 +0,0 @@ -from flask import Flask, render_template, request -from PIL import Image -import os - -app = Flask(__name__) - -UPLOAD_FOLDER = 'static/uploads/' -RESULT_FOLDER = 'static/results/' -os.makedirs(UPLOAD_FOLDER, exist_ok=True) -os.makedirs(RESULT_FOLDER, exist_ok=True) - -def apply_filter(image_path, color_filter, niveau_blanc=180): # Niveau de blanc par défaut - img = Image.open(image_path) - largeur_img, hauteur_img = img.size - - # --- Parcours de chaque pixel de l'image --- - for y in range(hauteur_img): - for x in range(largeur_img): - r, v, b = img.getpixel((x, y)) - # --- Si le pixel est "clair" --- - if r > niveau_blanc and v > niveau_blanc and b > niveau_blanc: - # ---- FILTRE JAUNE ---- - if color_filter == "jaune": - img.putpixel((x, y), (254, 254, 1)) - # ---- FILTRE VERT ---- - elif color_filter == "vert": - img.putpixel((x, y), (145, 254, 1)) - # ---- FILTRE ROUGE ---- - elif color_filter == "rouge": - img.putpixel((x, y), (254, 1, 1)) - # ---- FILTRE NOIR ET BLANC (BLANC) ---- - elif color_filter == "nb": - img.putpixel((x, y), (254, 254, 254)) - else: - # ---- FILTRE JAUNE (Foncé) ---- - if color_filter == "jaune": - img.putpixel((x, y), (b, v, r)) - # ---- FILTRE VERT (Foncé) ---- - elif color_filter == "vert": - img.putpixel((x, y), (r, b, v)) - # ---- FILTRE ROUGE (Foncé) ---- - elif color_filter == "rouge": - img.putpixel((x, y), (b, r, v)) - # ---- FILTRE NOIR ET BLANC (NOIR) ---- - elif color_filter == "nb": - img.putpixel((x, y), (0, 0, 0)) - - result_filename = f"{color_filter}_{os.path.basename(image_path)}" - result_path = os.path.join(RESULT_FOLDER, result_filename) - img.save(result_path) - return result_filename - -@app.route('/', methods=['GET', 'POST']) -def index(): - uploaded_file = None - filtered_image = None - if request.method == 'POST': - # Upload (si nouvelle image) - if 'file' in request.files: - file = request.files['file'] - if file.filename != '': - file_path = os.path.join(UPLOAD_FOLDER, file.filename) - file.save(file_path) - uploaded_file = file.filename - elif 'action' in request.form: - # Application d'un filtre - filter_color = request.form['action'] - image_name = request.form['image_name'] - file_path = os.path.join(UPLOAD_FOLDER, image_name) - result_filename = apply_filter(file_path, filter_color) - filtered_image = result_filename - uploaded_file = image_name - - # Affiche la page avec l'image uploadée (si présente) et filtre choisi (si présent) - return render_template('index.html', uploaded_file=uploaded_file, filtered_image=filtered_image) - -if __name__ == '__main__': - app.run(host="0.0.0.0", port=5000, debug=False) - # app.run(debug=False) # Pour le développement, pas nécessaire en production - diff --git a/Filtro_Python/static/results/vert_ImageIA2.webp b/Filtro_Python/static/results/vert_ImageIA2.webp deleted file mode 100644 index 69ee8e4..0000000 Binary files a/Filtro_Python/static/results/vert_ImageIA2.webp and /dev/null differ diff --git a/Filtro_Python/static/style.css b/Filtro_Python/static/style.css deleted file mode 100644 index f6a8b3a..0000000 --- a/Filtro_Python/static/style.css +++ /dev/null @@ -1,87 +0,0 @@ -body { - background: #3b71ca; /* bleu doux */ - color: #fff; - font-family: 'Segoe UI', Arial, sans-serif; - margin: 0; -} - -.navbar { - background: #254776; - padding: 15px 0; - position: sticky; - top: 0; - width: 100%; - z-index: 100; -} - -.nav-container { - width: 90%; - margin: auto; - display: flex; - justify-content: space-between; - align-items: center; -} - -.nav-title { - font-size: 1.8em; - font-weight: bold; - color: #ffda47; - letter-spacing: 1px; -} - -.nav-link { - color: #fff; - font-size: 1.1em; - text-decoration: none; - border-bottom: 2px solid transparent; - transition: border-color 0.2s; -} -.nav-link:hover { border-bottom: 2px solid #ffda47; } - -.container { - width: 90%; - max-width: 400px; - margin: 40px auto 20px auto; - padding: 24px 20px; - background: rgba(255,255,255,0.08); - border-radius: 15px; - box-shadow: 0 2px 12px rgba(10,45,90,0.11); - text-align: center; -} - -input[type="file"] { - margin: 12px 0 18px 0; -} - -button { - background: #ffda47; - color: #254776; - border: none; - padding: 9px 18px; - border-radius: 8px; - margin: 6px 2px; - font-weight: bold; - cursor: pointer; - transition: background 0.16s, color 0.16s; -} -button:hover { - background: #fcd024; - color: #173860; -} - -.footer { - background: #14243e; - color: #fff; - padding: 32px 10px 22px 10px; - text-align: center; - margin-top: 70px; - border-top: 2px solid #ffda47; -} -.footer h3 { - margin-top: 0; - color: #ffda47; -} -.footer p { - max-width: 540px; - margin: 10px auto; -} diff --git a/Filtro_Python/static/uploads/ImageIA2.webp b/Filtro_Python/static/uploads/ImageIA2.webp deleted file mode 100644 index fe567c7..0000000 Binary files a/Filtro_Python/static/uploads/ImageIA2.webp and /dev/null differ diff --git a/Filtro_Python/templates/index.html b/Filtro_Python/templates/index.html deleted file mode 100644 index 18cfbf9..0000000 --- a/Filtro_Python/templates/index.html +++ /dev/null @@ -1,60 +0,0 @@ - - - - - Filtro - Appliquez votre filtre image ! - - - - - - - -
-

Upload d'une image

-
- - -
- - {% if uploaded_file %} -

Image chargée :

-

-
- - - - - -
- {% endif %} - - {% if filtered_image %} -

Image filtrée :

-
- - - - {% endif %} -
- - - - - diff --git a/Filtro_Rust/Cargo.lock b/Filtro_Rust/Cargo.lock index 7de80e2..e28cc0f 100644 --- a/Filtro_Rust/Cargo.lock +++ b/Filtro_Rust/Cargo.lock @@ -2,13 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "Filtro_Rust" -version = "0.1.0" -dependencies = [ - "image", -] - [[package]] name = "adler2" version = "2.0.1" @@ -16,148 +9,72 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] -name = "aligned" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" -dependencies = [ - "as-slice", -] - -[[package]] -name = "aligned-vec" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" -dependencies = [ - "equator", -] - -[[package]] -name = "anyhow" -version = "1.0.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" - -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" - -[[package]] -name = "arg_enum_proc_macro" -version = "0.3.4" +name = "anstream" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ - "proc-macro2", - "quote", - "syn", + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", ] [[package]] -name = "arrayvec" -version = "0.7.6" +name = "anstyle" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] -name = "as-slice" -version = "0.2.1" +name = "anstyle-parse" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ - "stable_deref_trait", + "utf8parse", ] [[package]] -name = "autocfg" -version = "1.5.0" +name = "anstyle-query" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "av-scenechange" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "aligned", - "anyhow", - "arg_enum_proc_macro", - "arrayvec", - "log", - "num-rational", - "num-traits", - "pastey", - "rayon", - "thiserror", - "v_frame", - "y4m", + "windows-sys", ] [[package]] -name = "av1-grain" -version = "0.2.5" +name = "anstyle-wincon" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ - "anyhow", - "arrayvec", - "log", - "nom", - "num-rational", - "v_frame", + "anstyle", + "once_cell_polyfill", + "windows-sys", ] [[package]] -name = "avif-serialize" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f" -dependencies = [ - "arrayvec", -] - -[[package]] -name = "bit_field" -version = "0.10.3" +name = "autocfg" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "bitflags" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" - -[[package]] -name = "bitstream-io" -version = "4.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" -dependencies = [ - "core2", -] - -[[package]] -name = "built" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" - -[[package]] -name = "bumpalo" -version = "3.19.1" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bytemuck" -version = "1.24.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "byteorder-lite" @@ -165,18 +82,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" -[[package]] -name = "cc" -version = "1.2.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", -] - [[package]] name = "cfg-if" version = "1.0.4" @@ -184,120 +89,77 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "color_quant" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" - -[[package]] -name = "core2" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" -dependencies = [ - "memchr", -] - -[[package]] -name = "crc32fast" -version = "1.5.0" +name = "clap" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ - "cfg-if", + "clap_builder", + "clap_derive", ] [[package]] -name = "crossbeam-deque" -version = "0.8.6" +name = "clap_builder" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", + "anstream", + "anstyle", + "clap_lex", + "strsim", ] [[package]] -name = "crossbeam-epoch" -version = "0.9.18" +name = "clap_derive" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ - "crossbeam-utils", + "heck", + "proc-macro2", + "quote", + "syn 3.0.4", ] [[package]] -name = "crossbeam-utils" -version = "0.8.21" +name = "clap_lex" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - -[[package]] -name = "either" -version = "1.15.0" +name = "color_quant" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] -name = "equator" -version = "0.4.2" +name = "colorchoice" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" -dependencies = [ - "equator-macro", -] +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] -name = "equator-macro" -version = "0.4.2" +name = "crc32fast" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" dependencies = [ - "proc-macro2", - "quote", - "syn", + "cfg-if", ] [[package]] -name = "exr" -version = "1.74.0" +name = "crunchy" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" -dependencies = [ - "bit_field", - "half", - "lebe", - "miniz_oxide", - "rayon-core", - "smallvec", - "zune-inflate", -] +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "fax" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" -dependencies = [ - "fax_derive", -] - -[[package]] -name = "fax_derive" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" [[package]] name = "fdeflate" @@ -309,38 +171,30 @@ dependencies = [ ] [[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "flate2" -version = "1.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" +name = "filtro" +version = "0.1.0" dependencies = [ - "crc32fast", - "miniz_oxide", + "clap", + "image", + "thiserror", ] [[package]] -name = "getrandom" -version = "0.3.4" +name = "flate2" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", ] [[package]] name = "gif" -version = "0.14.1" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" dependencies = [ "color_quant", "weezl", @@ -357,6 +211,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "image" version = "0.25.10" @@ -366,16 +226,11 @@ dependencies = [ "bytemuck", "byteorder-lite", "color_quant", - "exr", "gif", "image-webp", "moxcms", "num-traits", "png", - "qoi", - "ravif", - "rayon", - "rgb", "tiff", "zune-core", "zune-jpeg", @@ -392,99 +247,26 @@ dependencies = [ ] [[package]] -name = "imgref" -version = "1.12.0" +name = "is_terminal_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] -name = "interpolate_name" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom", - "libc", -] - -[[package]] -name = "lebe" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" - -[[package]] -name = "libc" -version = "0.2.180" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" - -[[package]] -name = "libfuzzer-sys" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" -dependencies = [ - "arbitrary", - "cc", -] - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "loop9" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" -dependencies = [ - "imgref", -] - -[[package]] -name = "maybe-rayon" -version = "0.1.1" +name = "miniz_oxide" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ - "cfg-if", - "rayon", + "adler2", + "simd-adler32", ] -[[package]] -name = "memchr" -version = "2.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" - [[package]] name = "miniz_oxide" -version = "0.8.9" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" dependencies = [ "adler2", "simd-adler32", @@ -500,68 +282,6 @@ dependencies = [ "pxfm", ] -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - -[[package]] -name = "nom" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] - -[[package]] -name = "noop_proc_macro" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" - -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -572,90 +292,38 @@ dependencies = [ ] [[package]] -name = "once_cell" -version = "1.21.3" +name = "once_cell_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "pastey" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "png" -version = "0.18.0" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ "bitflags", "crc32fast", "fdeflate", "flate2", - "miniz_oxide", -] - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", + "miniz_oxide 0.8.9", ] [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] -[[package]] -name = "profiling" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" -dependencies = [ - "profiling-procmacros", -] - -[[package]] -name = "profiling-procmacros" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" -dependencies = [ - "quote", - "syn", -] - [[package]] name = "pxfm" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" -dependencies = [ - "num-traits", -] - -[[package]] -name = "qoi" -version = "0.4.1" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" -dependencies = [ - "bytemuck", -] +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" [[package]] name = "quick-error" @@ -665,168 +333,41 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quote" -version = "1.0.44" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] [[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "rand" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" -dependencies = [ - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom", -] - -[[package]] -name = "rav1e" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" -dependencies = [ - "aligned-vec", - "arbitrary", - "arg_enum_proc_macro", - "arrayvec", - "av-scenechange", - "av1-grain", - "bitstream-io", - "built", - "cfg-if", - "interpolate_name", - "itertools", - "libc", - "libfuzzer-sys", - "log", - "maybe-rayon", - "new_debug_unreachable", - "noop_proc_macro", - "num-derive", - "num-traits", - "paste", - "profiling", - "rand", - "rand_chacha", - "simd_helpers", - "thiserror", - "v_frame", - "wasm-bindgen", -] - -[[package]] -name = "ravif" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" -dependencies = [ - "avif-serialize", - "imgref", - "loop9", - "quick-error", - "rav1e", - "rayon", - "rgb", -] - -[[package]] -name = "rayon" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[package]] -name = "rgb" -version = "0.8.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "shlex" -version = "1.3.0" +name = "simd-adler32" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] -name = "simd-adler32" -version = "0.3.8" +name = "strsim" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] -name = "simd_helpers" -version = "0.1.0" +name = "syn" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ + "proc-macro2", "quote", + "unicode-ident", ] -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - [[package]] name = "syn" -version = "2.0.114" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" dependencies = [ "proc-macro2", "quote", @@ -835,22 +376,22 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.4", ] [[package]] @@ -869,74 +410,15 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] -name = "v_frame" -version = "0.3.9" +name = "utf8parse" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" -dependencies = [ - "aligned-vec", - "num-traits", - "wasm-bindgen", -] - -[[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" -dependencies = [ - "unicode-ident", -] +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "weezl" @@ -945,57 +427,57 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" [[package]] -name = "wit-bindgen" -version = "0.51.0" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "y4m" -version = "0.8.0" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] [[package]] name = "zerocopy" -version = "0.8.36" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dafd85c832c1b68bbb4ec0c72c7f6f4fc5179627d2bc7c26b30e4c0cc11e76cc" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.36" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cb7e4e8436d9db52fbd6625dbf2f45243ab84994a72882ec8227b99e72b439a" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] -name = "zune-core" -version = "0.5.1" +name = "zlib-rs" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] -name = "zune-inflate" -version = "0.2.54" +name = "zune-core" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" -dependencies = [ - "simd-adler32", -] +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" [[package]] name = "zune-jpeg" -version = "0.5.12" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] diff --git a/Filtro_Rust/Cargo.toml b/Filtro_Rust/Cargo.toml index 90a92a6..14443c8 100644 --- a/Filtro_Rust/Cargo.toml +++ b/Filtro_Rust/Cargo.toml @@ -1,7 +1,34 @@ [package] -name = "Filtro_Rust" +name = "filtro" version = "0.1.0" edition = "2024" +rust-version = "1.88" + +[[bin]] +name = "filtro" +path = "src/main.rs" + +[lib] +name = "filtro" +path = "src/lib.rs" [dependencies] -image = "0.25.9" +image = { version = "0.25", default-features = false, features = [ + "png", + "jpeg", + "bmp", + "tiff", + "webp", + "gif", +] } +thiserror = "2" +clap = { version = "4.6", features = ["derive"] } + +[dev-dependencies] +image = { version = "0.25", default-features = false, features = ["png"] } + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 1 +strip = true diff --git a/Filtro_Rust/README.md b/Filtro_Rust/README.md new file mode 100644 index 0000000..2d9773b --- /dev/null +++ b/Filtro_Rust/README.md @@ -0,0 +1,140 @@ +# Filtro (Rust) + +Outil de filtrage d'images en ligne de commande, écrit en Rust (édition 2024, +`rust-version` 1.88) : un cœur stable, et des filtres qui s'ajoutent sans jamais +toucher au cœur. + +## Architecture — dossiers et MVC + +Le crate expose **une bibliothèque** (`filtro`, le *Model*) et **un binaire** +(`filtro`, qui porte le *Controller* et la *View*). + +``` +Filtro_Rust/ +├── Cargo.toml +├── README.md +├── src/ +│ ├── lib.rs racine de la bibliothèque : modules + façade `pub use` +│ ├── main.rs entrée du binaire : assemble controller + view +│ │ +│ ├── model/ ── M — le cœur métier (aucune dépendance CLI) ── +│ │ ├── error.rs type d'erreur unique (FiltroError) +│ │ ├── pixel.rs types d'image partagés (Rgba8, Dimensions, PixelBuffer) +│ │ ├── resolution.rs (privé) lecture des ppp dans les métadonnées +│ │ ├── codec.rs (privé) pont vers le crate `image` +│ │ ├── pipeline/ +│ │ │ ├── analyzer.rs étape 1 — décode et décrit l'image +│ │ │ ├── constructor.rs étape 2 — prépare l'image de travail +│ │ │ ├── formatter.rs étape 3 — décline formats et tailles +│ │ │ ├── renderer.rs étape 4 — applique les filtres et encode +│ │ │ └── orchestrator.rs enchaîne les 4 étapes (Pipeline, PipelineBuilder) +│ │ └── filter/ +│ │ ├── contract.rs CONTRAT des filtres (Filter, FilterChain, …) +│ │ └── registry.rs configuration et instanciation des filtres +│ │ +│ ├── filters/ ── LES FILTRES, hors du cœur ── +│ │ ├── mod.rs register_all() +│ │ ├── color.rs jaune, vert, rouge +│ │ └── threshold.rs noir-blanc +│ │ +│ ├── controller/ ── C — traduit la CLI en configuration du cœur ── +│ │ ├── mod.rs run() : parse → pipeline → view ; register_filters() +│ │ └── cli.rs arguments clap + analyse des formats / tailles +│ │ +│ ├── view/ ── V — tout ce qui s'écrit sur le terminal ── +│ │ ├── report.rs résumé d'un traitement terminé +│ │ ├── catalog.rs affichage de `--list-filters` +│ │ └── mod.rs rapport d'erreur + tailles lisibles +│ │ +│ └── test_support.rs (cfg(test)) fabriques d'objets pour les tests +└── tests/ + └── pipeline.rs test d'intégration bout en bout +``` + +`controller/` et `view/` appartiennent **au binaire uniquement** : la +bibliothèque reste un domaine pur, réutilisable depuis un autre programme. + +## Le pipeline + +``` +Utilisateur ── image ──▶ Analyzer ──┐ + ├──▶ Constructor ──▶ Formatter ──▶ Renderer ──▶ sorties +Utilisateur ── filtres ─▶ Registre ─┘ (applique la chaîne) +``` + +| Étape | Fichier | Entrée | Sortie | +|-------|---------|--------|--------| +| Analyzer | `src/model/pipeline/analyzer.rs` | octets | `ImageAnalysis` (description + pixels RGBA8) | +| Constructor | `src/model/pipeline/constructor.rs` | analyse + `FilterRequirements` | `PreparedImage` | +| Formatter | `src/model/pipeline/formatter.rs` | image préparée | `FormatSet` (format × taille) | +| Renderer | `src/model/pipeline/renderer.rs` | variantes + `FilterChain` | `RenderOutput` (octets encodés) | + +Autour : `src/model/pixel.rs` (types d'image), `src/model/error.rs`, +`src/model/pipeline/orchestrator.rs` (orchestration), `src/model/codec.rs` (seul +fichier qui connaît le crate `image`), `src/model/resolution.rs` (lecture des ppp). + +## Cœur et filtres + +Le cœur ne contient aucun filtre. Il expose un contrat : + +* `src/model/filter/contract.rs` — `Filter` (transforme des pixels), + `FilterRequirements` (ce que le filtre attend de l'image), `FilterChain` + (composition) ; +* `src/model/filter/registry.rs` — `FilterParams`, `FilterFactory`, + `FilterRegistry`, `FilterRequest` (analyse de `id:clé=valeur`). + +Les filtres vivent dans `src/filters/`, module que le cœur ne référence jamais. +Ajouter un filtre : + +1. implémenter `Filter` et `FilterFactory` dans un fichier de `src/filters/` ; +2. ajouter une ligne à `filters::register_all`. + +Rien d'autre : ni le pipeline, ni le contrôleur ne changent. + +## Les filtres livrés + +| Identifiant | Effet | +|-------------|-------| +| `jaune` | blanc → jaune, canaux permutés en `bvr` | +| `vert` | blanc → vert, canaux permutés en `rbv` | +| `rouge` | blanc → rouge, canaux permutés en `brv` | +| `noir-blanc` | seuillage binaire | + +Paramètres communs aux filtres de couleur : `niveau` (seuil de blanc, défaut +180), `couleur` (remplacement du blanc, hexadécimal), `permutation` (mot de +trois lettres parmi `r`, `v`, `b`). `noir-blanc` accepte `niveau`, `mode` +(`canaux` ou `luminance`), `clair` et `sombre`. + +La virgule sépare les paramètres : les couleurs s'écrivent donc en hexadécimal +(`fefe01`), jamais en `254,254,1`. + +## Utilisation + +```bash +cargo run --release -- photo.jpg -f jaune +cargo run --release -- photo.jpg -f noir-blanc:niveau=128,mode=luminance +cargo run --release -- photo.jpg -f rouge -f noir-blanc # chaînage +cargo run --release -- photo.jpg --formats png,jpeg,webp \ + --size vignette:256 --size web:1200 --quality 90 -o sortie + +cargo run -- --list-filters +``` + +Sans filtre, `filtro` se comporte en convertisseur de formats. + +## Tests + +```bash +cargo test # unitaires par module + intégration bout en bout +``` + +`tests/pipeline.rs` définit un filtre `luminosite` **en dehors du cœur** : il +vérifie que l'API publique (`filtro::…`) suffit à écrire un filtre et à traiter +une image de bout en bout. + +## Notes + +* `#![forbid(unsafe_code)]`, aucun `unwrap()` hors des tests, `cargo clippy` propre. +* Garde-fou contre les bombes de décompression (`Analyzer::with_max_pixels`). +* Résolution lue dans le chunk `pHYs` (PNG) et le segment `JFIF` (JPEG). +* Dépendances : `image` 0.25, `thiserror` 2, `clap` 4.6. diff --git a/Filtro_Rust/src/controller/cli.rs b/Filtro_Rust/src/controller/cli.rs new file mode 100644 index 0000000..3c09f2d --- /dev/null +++ b/Filtro_Rust/src/controller/cli.rs @@ -0,0 +1,77 @@ +//! Définition des arguments de la ligne de commande et analyse des valeurs +//! composées (formats, déclinaisons dimensionnelles). + +use std::path::PathBuf; + +use clap::Parser; +use filtro::{FiltroError, OutputFormat, Result, SizeVariant}; + +#[derive(Parser, Debug)] +#[command( + name = "filtro", + version, + about = "Outil de filtrage d'images en ligne de commande", + long_about = None +)] +pub struct Cli { + /// Image à traiter. + #[arg(value_name = "IMAGE", required_unless_present = "list_filters")] + pub input: Option, + + /// Filtre à appliquer, répétable : `-f id` ou `-f id:clé=valeur,clé=valeur`. + #[arg(short = 'f', long = "filter", value_name = "EXPRESSION")] + pub filters: Vec, + + /// Formats de sortie, séparés par des virgules (png, jpeg, webp, bmp, tiff). + #[arg(long, value_name = "FORMATS", default_value = "png")] + pub formats: String, + + /// Déclinaison dimensionnelle, répétable : `--size vignette:256`. + #[arg(long = "size", value_name = "ÉTIQUETTE:PIXELS")] + pub sizes: Vec, + + /// Répertoire de sortie. + #[arg( + short = 'o', + long = "out", + value_name = "RÉPERTOIRE", + default_value = "sortie" + )] + pub out: PathBuf, + + /// Qualité des formats avec perte (1–100). + #[arg(long, value_name = "1-100", default_value_t = 85)] + pub quality: u8, + + /// Borne le plus grand côté de l'image de travail. + #[arg(long, value_name = "PIXELS")] + pub max_dimension: Option, + + /// Liste les filtres disponibles puis quitte. + #[arg(long)] + pub list_filters: bool, +} + +/// Analyse la liste de formats séparés par des virgules. +pub fn parse_formats(raw: &str) -> Result> { + let formats: Vec = raw + .split(',') + .filter(|token| !token.trim().is_empty()) + .map(OutputFormat::parse) + .collect::>()?; + if formats.is_empty() { + return Err(FiltroError::Config( + "la liste de formats est vide".to_owned(), + )); + } + Ok(formats) +} + +/// Analyse les déclinaisons dimensionnelles ; `Original` est toujours en tête. +pub fn parse_sizes(raw: &[String]) -> Result> { + let mut sizes = vec![SizeVariant::Original]; + for expression in raw { + sizes.push(SizeVariant::parse(expression)?); + } + Ok(sizes) +} diff --git a/Filtro_Rust/src/controller/mod.rs b/Filtro_Rust/src/controller/mod.rs new file mode 100644 index 0000000..e865a20 --- /dev/null +++ b/Filtro_Rust/src/controller/mod.rs @@ -0,0 +1,74 @@ +//! Couche **Controller** — traduit la ligne de commande en configuration du +//! cœur, lance le pipeline, puis confie l'affichage à la couche [`view`](crate::view). + +mod cli; + +use std::process::ExitCode; + +use clap::Parser; +use filtro::{FilterChain, FilterRegistry, FilterRequest, FiltroError, Pipeline, Result}; + +use crate::view; +use cli::{Cli, parse_formats, parse_sizes}; + +/// **Unique point de branchement des filtres.** +/// +/// Les filtres vivent hors du cœur, dans le module `filtro::filters`. +fn register_filters(registry: &mut FilterRegistry) -> Result<()> { + filtro::filters::register_all(registry) +} + +/// Point d'entrée de l'outil : renvoie le code de sortie du processus. +pub fn run() -> ExitCode { + match execute() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + view::report_error(&error); + ExitCode::FAILURE + } + } +} + +fn execute() -> Result<()> { + let cli = Cli::parse(); + + let mut registry = FilterRegistry::new(); + register_filters(&mut registry)?; + + if cli.list_filters { + view::print_filters(®istry); + return Ok(()); + } + + let input = cli + .input + .ok_or_else(|| FiltroError::Config("aucune image fournie".to_owned()))?; + + let requests: Vec = cli + .filters + .iter() + .map(|expression| FilterRequest::parse(expression)) + .collect::>()?; + + let chain: FilterChain = if requests.is_empty() { + FilterChain::empty() + } else { + registry.build_chain(&requests)? + }; + + let formats = parse_formats(&cli.formats)?; + let sizes = parse_sizes(&cli.sizes)?; + + let pipeline = Pipeline::builder() + .formats(formats) + .sizes(sizes) + .quality(cli.quality) + .max_dimension(cli.max_dimension) + .build()?; + + let output = pipeline.run_path(&input, &chain)?; + let written = output.write_to_dir(&cli.out)?; + + view::print_summary(&output, &written); + Ok(()) +} diff --git a/Filtro_Rust/src/filters/color.rs b/Filtro_Rust/src/filters/color.rs new file mode 100644 index 0000000..d267b9b --- /dev/null +++ b/Filtro_Rust/src/filters/color.rs @@ -0,0 +1,289 @@ +//! Filtres de couleur : `jaune`, `vert`, `rouge`. +//! +//! Les trois filtres font la même chose à deux constantes près : +//! +//! ```text +//! si r > niveau_blanc et v > niveau_blanc et b > niveau_blanc : +//! pixel <- (254, 254, 1) // le blanc devient une couleur +//! sinon : +//! pixel <- (b, v, r) // les autres pixels sont permutés +//! ``` +//! +//! Un seul type Rust couvre donc les trois filtres : seules la couleur de +//! remplacement et la permutation par défaut changent. + +use crate::Result; +use crate::{Filter, FilterContext}; +use crate::{FilterFactory, FilterParams, ParamSpec}; +use crate::{PixelBuffer, Rgba8}; + +// --------------------------------------------------------------------------- +// Permutation des canaux +// --------------------------------------------------------------------------- + +/// Un canal source. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Channel { + R, + V, + B, +} + +/// Réaffectation des trois canaux, décrite par un mot de trois lettres. +/// +/// `bvr` signifie : « le rouge de sortie prend le bleu d'entrée, le vert garde +/// le vert, le bleu prend le rouge » — soit le pixel `(b, v, r)`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ChannelOrder([Channel; 3]); + +impl ChannelOrder { + /// Aucune permutation (`rvb`). + pub const IDENTITY: Self = Self([Channel::R, Channel::V, Channel::B]); + + /// Analyse un mot de trois lettres parmi `r`, `v`/`g`, `b`. + pub fn parse(raw: &str) -> Option { + let letters: Vec = raw + .trim() + .to_ascii_lowercase() + .chars() + .map(|c| match c { + 'r' => Some(Channel::R), + 'v' | 'g' => Some(Channel::V), + 'b' => Some(Channel::B), + _ => None, + }) + .collect::>()?; + let channels: [Channel; 3] = letters.try_into().ok()?; + Some(Self(channels)) + } + + /// Applique la permutation ; l'alpha est toujours préservé. + pub fn apply(self, pixel: Rgba8) -> Rgba8 { + let take = |channel: Channel| match channel { + Channel::R => pixel.r, + Channel::V => pixel.g, + Channel::B => pixel.b, + }; + Rgba8::new(take(self.0[0]), take(self.0[1]), take(self.0[2]), pixel.a) + } +} + +// --------------------------------------------------------------------------- +// Le filtre +// --------------------------------------------------------------------------- + +/// Remplace les pixels clairs par une couleur, permute les autres. +#[derive(Debug, Clone)] +pub struct ColorFilter { + id: &'static str, + /// Seuil au-delà duquel un pixel est considéré comme blanc (`niveau_blanc`). + level: u8, + /// Couleur qui remplace le blanc. + color: Rgba8, + /// Permutation appliquée aux pixels non blancs. + order: ChannelOrder, +} + +impl ColorFilter { + /// Vrai si les trois composantes dépassent le seuil. + fn is_white(&self, pixel: Rgba8) -> bool { + pixel.r > self.level && pixel.g > self.level && pixel.b > self.level + } +} + +impl Filter for ColorFilter { + fn id(&self) -> &str { + self.id + } + + fn apply(&self, canvas: &mut PixelBuffer, _ctx: &FilterContext<'_>) -> Result<()> { + canvas.map_pixels(|_, _, pixel| { + if self.is_white(pixel) { + Rgba8 { + a: pixel.a, + ..self.color + } + } else { + self.order.apply(pixel) + } + }); + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Fabrique +// --------------------------------------------------------------------------- + +/// Fabrique commune aux trois filtres de couleur. +/// +/// Chaque filtre n'est qu'un jeu de valeurs par défaut. +#[derive(Debug, Clone, Copy)] +pub struct ColorFilterFactory { + id: &'static str, + description: &'static str, + color: Rgba8, + order: &'static str, + params: &'static [ParamSpec], +} + +impl FilterFactory for ColorFilterFactory { + fn id(&self) -> &'static str { + self.id + } + + fn description(&self) -> &'static str { + self.description + } + + fn parameters(&self) -> &'static [ParamSpec] { + self.params + } + + fn build(&self, params: &FilterParams) -> Result> { + let level = params.u8("niveau", 180)?; + let color = params.color("couleur", self.color)?; + let raw_order = params.text("permutation", self.order); + let order = + ChannelOrder::parse(raw_order).ok_or_else(|| crate::FiltroError::InvalidParameter { + filter: self.id.to_owned(), + name: "permutation".to_owned(), + reason: format!("« {raw_order} » n'est pas un mot de trois lettres parmi r, v, b"), + })?; + + Ok(Box::new(ColorFilter { + id: self.id, + level, + color, + order, + })) + } +} + +/// Construit la liste de paramètres d'un filtre de couleur. +macro_rules! color_params { + ($couleur:expr, $permutation:expr) => { + &[ + ParamSpec { + name: "niveau", + default: "180", + help: "seuil (0-255) au-delà duquel un pixel est vu comme blanc", + }, + ParamSpec { + name: "couleur", + default: $couleur, + help: "couleur hexadécimale remplaçant le blanc", + }, + ParamSpec { + name: "permutation", + default: $permutation, + help: "réaffectation des canaux des autres pixels (ex. bvr)", + }, + ] + }; +} + +/// Filtre `jaune` — blanc → jaune, canaux permutés en `bvr`. +pub fn yellow() -> ColorFilterFactory { + ColorFilterFactory { + id: "jaune", + description: "Remplace le blanc par du jaune et échange rouge et bleu", + color: Rgba8::new(254, 254, 1, 255), + order: "bvr", + params: color_params!("fefe01", "bvr"), + } +} + +/// Filtre `vert` — blanc → vert, canaux permutés en `rbv`. +pub fn green() -> ColorFilterFactory { + ColorFilterFactory { + id: "vert", + description: "Remplace le blanc par du vert et échange vert et bleu", + color: Rgba8::new(145, 254, 1, 255), + order: "rbv", + params: color_params!("91fe01", "rbv"), + } +} + +/// Filtre `rouge` — blanc → rouge, canaux permutés en `brv`. +pub fn red() -> ColorFilterFactory { + ColorFilterFactory { + id: "rouge", + description: "Remplace le blanc par du rouge et fait tourner les canaux", + color: Rgba8::new(254, 1, 1, 255), + order: "brv", + params: color_params!("fe0101", "brv"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::apply_to_pixel; + use crate::{FilterRegistry, FilterRequest}; + + #[test] + fn permutation_bvr_echange_rouge_et_bleu() { + let order = ChannelOrder::parse("bvr").unwrap(); + let out = order.apply(Rgba8::new(10, 20, 30, 200)); + assert_eq!(out, Rgba8::new(30, 20, 10, 200)); + } + + #[test] + fn permutation_invalide() { + assert!(ChannelOrder::parse("bv").is_none()); + assert!(ChannelOrder::parse("xyz").is_none()); + assert_eq!(ChannelOrder::parse("rgb"), Some(ChannelOrder::IDENTITY)); + } + + fn filtre(expression: &str) -> Box { + let mut registry = FilterRegistry::new(); + registry.register(yellow()).unwrap(); + registry.register(green()).unwrap(); + registry.register(red()).unwrap(); + registry + .build(&FilterRequest::parse(expression).unwrap()) + .unwrap() + } + + #[test] + fn le_blanc_devient_jaune() { + let sortie = apply_to_pixel(&*filtre("jaune"), Rgba8::new(200, 210, 220, 255)); + assert_eq!(sortie, Rgba8::new(254, 254, 1, 255)); + } + + #[test] + fn les_autres_pixels_sont_permutes() { + // 100 < 180 : le pixel n'est pas « blanc », il est donc permuté. + let sortie = apply_to_pixel(&*filtre("jaune"), Rgba8::new(10, 100, 30, 255)); + assert_eq!(sortie, Rgba8::new(30, 100, 10, 255)); + } + + #[test] + fn le_seuil_est_reglable() { + let clair = Rgba8::new(200, 200, 200, 255); + // Avec un seuil à 220, ce pixel n'est plus considéré comme blanc. + let sortie = apply_to_pixel(&*filtre("jaune:niveau=220"), clair); + assert_eq!(sortie, clair); // permutation bvr sur un gris = identique + } + + #[test] + fn la_couleur_est_reglable() { + let sortie = apply_to_pixel(&*filtre("rouge:couleur=00ccff"), Rgba8::WHITE); + assert_eq!(sortie, Rgba8::new(0, 204, 255, 255)); + } + + #[test] + fn l_alpha_est_preserve() { + let sortie = apply_to_pixel(&*filtre("vert"), Rgba8::new(250, 250, 250, 64)); + assert_eq!(sortie.a, 64); + } + + #[test] + fn permutation_invalide_rejetee_a_la_construction() { + let mut registry = FilterRegistry::new(); + registry.register(yellow()).unwrap(); + let request = FilterRequest::parse("jaune:permutation=xx").unwrap(); + assert!(registry.build(&request).is_err()); + } +} diff --git a/Filtro_Rust/src/filters/mod.rs b/Filtro_Rust/src/filters/mod.rs new file mode 100644 index 0000000..bc10c3c --- /dev/null +++ b/Filtro_Rust/src/filters/mod.rs @@ -0,0 +1,63 @@ +//! Les filtres — **hors du cœur**. +//! +//! Ce module n'utilise que l'API publique de la bibliothèque +//! ([`Filter`](crate::Filter), [`FilterFactory`](crate::FilterFactory)). +//! Aucune étape du pipeline ne le connaît : il pourrait être déplacé dans un +//! crate séparé sans modifier une seule ligne d'Analyzer, Constructor, +//! Formatter ou Renderer. +//! +//! Filtres livrés avec le projet : +//! +//! | Identifiant | Effet | +//! |-------------|-------| +//! | `jaune` | blanc → jaune, canaux permutés en `bvr` | +//! | `vert` | blanc → vert, canaux permutés en `rbv` | +//! | `rouge` | blanc → rouge, canaux permutés en `brv` | +//! | `noir-blanc` | seuillage binaire | + +pub mod color; +pub mod threshold; + +use crate::FilterRegistry; +use crate::Result; + +/// Enregistre tous les filtres fournis avec le projet. +/// +/// C'est l'unique point de couplage entre l'application et les filtres : +/// ajouter un filtre se résume à une ligne ici. +pub fn register_all(registry: &mut FilterRegistry) -> Result<()> { + registry.register(color::yellow())?; + registry.register(color::green())?; + registry.register(color::red())?; + registry.register(threshold::BlackAndWhiteFactory)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tous_les_filtres_s_enregistrent() { + let mut registry = FilterRegistry::new(); + register_all(&mut registry).unwrap(); + assert_eq!(registry.len(), 4); + for id in ["jaune", "vert", "rouge", "noir-blanc"] { + assert!(registry.get(id).is_some(), "filtre « {id} » absent"); + } + } + + #[test] + fn chaque_filtre_se_construit_sans_parametre() { + let mut registry = FilterRegistry::new(); + register_all(&mut registry).unwrap(); + for factory in registry.factories() { + let params = crate::FilterParams::new(factory.id()); + assert!( + factory.build(¶ms).is_ok(), + "le filtre « {} » exige un paramètre", + factory.id() + ); + } + } +} diff --git a/Filtro_Rust/src/filters/threshold.rs b/Filtro_Rust/src/filters/threshold.rs new file mode 100644 index 0000000..95dfbe2 --- /dev/null +++ b/Filtro_Rust/src/filters/threshold.rs @@ -0,0 +1,191 @@ +//! Filtre `noir-blanc` — seuillage binaire. +//! +//! ```text +//! si r > niveau_blanc et v > niveau_blanc et b > niveau_blanc : +//! pixel <- (255, 255, 255) +//! sinon : +//! pixel <- (0, 0, 0) +//! ``` +//! +//! Le mode par défaut compare les trois composantes séparément. Le paramètre +//! `mode=luminance` propose la variante habituelle en traitement d'image : +//! comparer la luminance perçue du pixel. + +use crate::{Filter, FilterContext}; +use crate::{FilterFactory, FilterParams, ParamSpec}; +use crate::{FiltroError, Result}; +use crate::{PixelBuffer, Rgba8}; + +/// Manière de décider si un pixel est clair. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ThresholdMode { + /// Les trois composantes doivent dépasser le seuil (mode par défaut). + Channels, + /// La luminance perçue doit dépasser le seuil. + Luminance, +} + +impl ThresholdMode { + fn parse(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().as_str() { + "canaux" | "channels" => Some(Self::Channels), + "luminance" => Some(Self::Luminance), + _ => None, + } + } + + fn is_light(self, pixel: Rgba8, level: u8) -> bool { + match self { + Self::Channels => pixel.r > level && pixel.g > level && pixel.b > level, + Self::Luminance => pixel.luminance() > level, + } + } +} + +/// Seuillage binaire : chaque pixel devient clair ou sombre. +#[derive(Debug, Clone)] +pub struct BlackAndWhiteFilter { + level: u8, + mode: ThresholdMode, + light: Rgba8, + dark: Rgba8, +} + +impl Filter for BlackAndWhiteFilter { + fn id(&self) -> &str { + "noir-blanc" + } + + fn apply(&self, canvas: &mut PixelBuffer, _ctx: &FilterContext<'_>) -> Result<()> { + canvas.map_pixels(|_, _, pixel| { + let chosen = if self.mode.is_light(pixel, self.level) { + self.light + } else { + self.dark + }; + Rgba8 { + a: pixel.a, + ..chosen + } + }); + Ok(()) + } +} + +/// Fabrique du filtre `noir-blanc`. +#[derive(Debug, Clone, Copy)] +pub struct BlackAndWhiteFactory; + +const PARAMS: &[ParamSpec] = &[ + ParamSpec { + name: "niveau", + default: "180", + help: "seuil (0-255) séparant le clair du sombre", + }, + ParamSpec { + name: "mode", + default: "canaux", + help: "canaux (comparaison composante par composante) ou luminance", + }, + ParamSpec { + name: "clair", + default: "ffffff", + help: "couleur hexadécimale des pixels au-dessus du seuil", + }, + ParamSpec { + name: "sombre", + default: "000000", + help: "couleur hexadécimale des pixels en dessous du seuil", + }, +]; + +impl FilterFactory for BlackAndWhiteFactory { + fn id(&self) -> &'static str { + "noir-blanc" + } + + fn description(&self) -> &'static str { + "Seuillage binaire : chaque pixel devient clair ou sombre" + } + + fn parameters(&self) -> &'static [ParamSpec] { + PARAMS + } + + fn build(&self, params: &FilterParams) -> Result> { + let raw_mode = params.text("mode", "canaux"); + let mode = ThresholdMode::parse(raw_mode).ok_or_else(|| FiltroError::InvalidParameter { + filter: self.id().to_owned(), + name: "mode".to_owned(), + reason: format!("« {raw_mode} » inconnu (attendu : canaux ou luminance)"), + })?; + + Ok(Box::new(BlackAndWhiteFilter { + level: params.u8("niveau", 180)?, + mode, + light: params.color("clair", Rgba8::WHITE)?, + dark: params.color("sombre", Rgba8::BLACK)?, + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::apply_to_pixel; + use crate::{FilterRegistry, FilterRequest}; + + fn filtre(expression: &str) -> Box { + let mut registry = FilterRegistry::new(); + registry.register(BlackAndWhiteFactory).unwrap(); + registry + .build(&FilterRequest::parse(expression).unwrap()) + .unwrap() + } + + #[test] + fn seuillage_par_canaux() { + let f = filtre("noir-blanc"); + assert_eq!( + apply_to_pixel(&*f, Rgba8::new(200, 200, 200, 255)), + Rgba8::WHITE + ); + assert_eq!( + apply_to_pixel(&*f, Rgba8::new(200, 100, 200, 255)), + Rgba8::BLACK + ); + } + + #[test] + fn seuillage_par_luminance() { + // Vert vif : luminance ≈ 191, au-dessus du seuil de 180… + let vert = Rgba8::new(100, 255, 100, 255); + assert_eq!( + apply_to_pixel(&*filtre("noir-blanc:mode=luminance"), vert), + Rgba8::WHITE + ); + // …alors qu'en mode « canaux » le rouge à 100 le fait basculer au noir. + assert_eq!(apply_to_pixel(&*filtre("noir-blanc"), vert), Rgba8::BLACK); + } + + #[test] + fn couleurs_personnalisees() { + let f = filtre("noir-blanc:clair=ffcc00,sombre=001133"); + assert_eq!( + apply_to_pixel(&*f, Rgba8::WHITE), + Rgba8::new(255, 204, 0, 255) + ); + assert_eq!( + apply_to_pixel(&*f, Rgba8::BLACK), + Rgba8::new(0, 17, 51, 255) + ); + } + + #[test] + fn mode_inconnu_rejete() { + let mut registry = FilterRegistry::new(); + registry.register(BlackAndWhiteFactory).unwrap(); + let request = FilterRequest::parse("noir-blanc:mode=sepia").unwrap(); + assert!(registry.build(&request).is_err()); + } +} diff --git a/Filtro_Rust/src/lib.rs b/Filtro_Rust/src/lib.rs new file mode 100644 index 0000000..c69e133 --- /dev/null +++ b/Filtro_Rust/src/lib.rs @@ -0,0 +1,76 @@ +//! # Filtro +//! +//! Traitement d'images organisé en quatre étapes stables, plus un jeu de +//! filtres qui vit à l'extérieur de ces étapes. +//! +//! ## Architecture (MVC) +//! +//! | Couche | Emplacement | Rôle | +//! |--------|-------------|------| +//! | **Model** | [`model`] (cette bibliothèque) | le cœur métier : pipeline, contrat des filtres, types d'image | +//! | **View** | `src/view/` (binaire) | rendu terminal des résultats et du catalogue de filtres | +//! | **Controller** | `src/controller/` (binaire) | traduction des arguments CLI en configuration du cœur | +//! +//! Les filtres livrés avec le projet sont regroupés dans [`filters`] : ce module +//! n'utilise que l'API publique ci-dessous et aucune étape du pipeline ne le +//! référence. +//! +//! ## Le pipeline +//! +//! | Étape | Rôle | Entrée | Sortie | +//! |-------|------|--------|--------| +//! | [`Analyzer`] | décode et décrit l'image | octets | [`ImageAnalysis`] | +//! | [`Constructor`] | prépare l'image de travail | analyse + exigences des filtres | [`PreparedImage`] | +//! | [`Formatter`] | décline formats et tailles | image préparée | [`FormatSet`] | +//! | [`Renderer`] | applique les filtres et encode | variantes + chaîne | [`RenderOutput`] | +//! +//! ```no_run +//! use filtro::{FilterRegistry, FilterRequest, OutputFormat, Pipeline}; +//! +//! # fn main() -> filtro::Result<()> { +//! let mut registry = FilterRegistry::new(); +//! filtro::filters::register_all(&mut registry)?; +//! +//! let requests = [FilterRequest::parse("jaune:niveau=200")?]; +//! let chain = registry.build_chain(&requests)?; +//! +//! let pipeline = Pipeline::builder() +//! .formats(vec![OutputFormat::Png, OutputFormat::Jpeg]) +//! .quality(90) +//! .build()?; +//! +//! pipeline.run_path("photo.jpg", &chain)?.write_to_dir("sortie")?; +//! # Ok(()) +//! # } +//! ``` + +#![forbid(unsafe_code)] +#![warn(missing_debug_implementations)] + +// --- Le cœur (Model) ------------------------------------------------------ +pub mod model; + +// --- Les filtres, hors du cœur ------------------------------------------ +pub mod filters; + +#[cfg(test)] +mod test_support; + +// --- Façade publique ---------------------------------------------------- +// L'API exposée est stable : seuls les chemins internes ont bougé. +pub use crate::model::error::{FiltroError, Result}; +pub use crate::model::filter::contract::{Filter, FilterChain, FilterContext, FilterRequirements}; +pub use crate::model::filter::registry::{ + FilterFactory, FilterParams, FilterRegistry, FilterRequest, ParamSpec, +}; +pub use crate::model::pipeline::analyzer::{ + Analyzer, ColorStats, ImageAnalysis, ImageMetadata, Origin, SourceFormat, +}; +pub use crate::model::pipeline::constructor::{ConstructionPolicy, Constructor, PreparedImage}; +pub use crate::model::pipeline::formatter::{ + FormatOptions, FormatSet, FormattedImage, Formatter, OutputFormat, OutputTarget, SizeVariant, +}; +pub use crate::model::pipeline::orchestrator::{Pipeline, PipelineBuilder}; +pub use crate::model::pipeline::renderer::{RenderOutput, RenderedArtifact, Renderer}; +pub use crate::model::pixel::{Dimensions, PixelBuffer, Rgba8}; +pub use crate::model::resolution::Resolution; diff --git a/Filtro_Rust/src/main.rs b/Filtro_Rust/src/main.rs index fa9bca8..16d516b 100644 --- a/Filtro_Rust/src/main.rs +++ b/Filtro_Rust/src/main.rs @@ -1,24 +1,9 @@ -use std::io::Cursor; -use image::ImageReader; +//! Binaire `filtro` — assemble les couches **Controller** et **View** autour de +//! la bibliothèque [`filtro`] (la couche **Model**). -fn main() -> Result<(), Box> { - // Charger une image depuis un fichier - let img = ImageReader::open("/workspaces/Filtro/Filtro_Rust/src/Image.webp")?.decode()?; +mod controller; +mod view; - // Sauvegarde directe - img.save("empty.jpg")?; - - // Écrire l'image dans un buffer mémoire - let mut bytes: Vec = Vec::new(); - img.write_to(&mut Cursor::new(&mut bytes), image::ImageFormat::Png)?; - - // Relire l'image depuis le buffer - let img2 = ImageReader::new(Cursor::new(bytes)) - .with_guessed_format()? - .decode()?; - - // Sauvegarde pour vérifier - img2.save("from_bytes.png")?; - - Ok(()) -} \ No newline at end of file +fn main() -> std::process::ExitCode { + controller::run() +} diff --git a/Filtro_Rust/src/model/codec.rs b/Filtro_Rust/src/model/codec.rs new file mode 100644 index 0000000..98f69df --- /dev/null +++ b/Filtro_Rust/src/model/codec.rs @@ -0,0 +1,117 @@ +//! Pont interne entre le cœur et la bibliothèque de codecs. +//! +//! Ce module est le **seul** endroit du crate qui connaît le type +//! `image::RgbaImage`. Les filtres et les étapes du pipeline ne manipulent que +//! [`PixelBuffer`] : changer de bibliothèque de décodage n'impacterait que ce +//! fichier. + +use std::io::Cursor; + +use image::codecs::bmp::BmpEncoder; +use image::codecs::jpeg::JpegEncoder; +use image::codecs::png::{CompressionType, FilterType as PngFilterType, PngEncoder}; +use image::codecs::tiff::TiffEncoder; +use image::codecs::webp::WebPEncoder; +use image::imageops::FilterType as ResizeFilter; +use image::{ExtendedColorType, ImageEncoder, ImageFormat, RgbaImage}; + +use crate::model::error::{FiltroError, Result}; +use crate::model::pipeline::formatter::{FormatOptions, OutputFormat}; +use crate::model::pixel::{Dimensions, PixelBuffer}; + +/// Décode des octets bruts vers un tampon RGBA8 et renvoie le format détecté. +pub(crate) fn decode(bytes: &[u8], origin: &str) -> Result<(PixelBuffer, Option)> { + let format = image::guess_format(bytes).ok(); + let decoded = image::load_from_memory(bytes).map_err(|source| FiltroError::Decode { + origin: origin.to_owned(), + source, + })?; + let rgba = decoded.into_rgba8(); + let dimensions = Dimensions::new(rgba.width(), rgba.height())?; + let buffer = PixelBuffer::from_raw(dimensions, rgba.into_raw())?; + Ok((buffer, format)) +} + +/// Ré-échantillonne un tampon (Lanczos3). +/// +/// Attention : il s'agit du ré-échantillonnage interne du pipeline, sans aucun +/// rapport avec les filtres applicatifs du projet. +pub(crate) fn resize(buffer: &PixelBuffer, target: Dimensions) -> Result { + if buffer.dimensions() == target { + return Ok(buffer.clone()); + } + let source = to_rgba_image(buffer)?; + let resized = + image::imageops::resize(&source, target.width, target.height, ResizeFilter::Lanczos3); + PixelBuffer::from_raw(target, resized.into_raw()) +} + +/// Encode un tampon vers les octets d'un fichier image. +pub(crate) fn encode( + buffer: &PixelBuffer, + format: OutputFormat, + options: &FormatOptions, +) -> Result> { + let width = buffer.width(); + let height = buffer.height(); + let mut out: Vec = Vec::new(); + let wrap = |source: image::ImageError| FiltroError::Encode { + format: format.to_string(), + source, + }; + + match format { + OutputFormat::Png => { + let encoder = PngEncoder::new_with_quality( + &mut out, + CompressionType::Default, + PngFilterType::Adaptive, + ); + encoder + .write_image(buffer.as_bytes(), width, height, ExtendedColorType::Rgba8) + .map_err(wrap)?; + } + OutputFormat::Jpeg => { + // JPEG ne porte pas d'alpha : le Formatter a déjà aplati l'image. + let rgb = to_rgb_bytes(buffer); + JpegEncoder::new_with_quality(&mut out, options.quality) + .encode(&rgb, width, height, ExtendedColorType::Rgb8) + .map_err(wrap)?; + } + OutputFormat::Bmp => { + let rgb = to_rgb_bytes(buffer); + let mut cursor = Cursor::new(&mut out); + BmpEncoder::new(&mut cursor) + .encode(&rgb, width, height, ExtendedColorType::Rgb8) + .map_err(wrap)?; + } + OutputFormat::Tiff => { + let cursor = Cursor::new(&mut out); + TiffEncoder::new(cursor) + .write_image(buffer.as_bytes(), width, height, ExtendedColorType::Rgba8) + .map_err(wrap)?; + } + OutputFormat::WebP => { + // Encodeur sans perte : le paramètre `quality` est ignoré ici. + WebPEncoder::new_lossless(&mut out) + .write_image(buffer.as_bytes(), width, height, ExtendedColorType::Rgba8) + .map_err(wrap)?; + } + } + + Ok(out) +} + +fn to_rgba_image(buffer: &PixelBuffer) -> Result { + RgbaImage::from_raw(buffer.width(), buffer.height(), buffer.as_bytes().to_vec()).ok_or_else( + || FiltroError::InvalidImage("tampon incompatible avec les dimensions".to_owned()), + ) +} + +fn to_rgb_bytes(buffer: &PixelBuffer) -> Vec { + let mut rgb = Vec::with_capacity(buffer.as_bytes().len() / 4 * 3); + for chunk in buffer.as_bytes().as_chunks::<4>().0 { + rgb.extend_from_slice(&chunk[..3]); + } + rgb +} diff --git a/Filtro_Rust/src/model/error.rs b/Filtro_Rust/src/model/error.rs new file mode 100644 index 0000000..c032d2f --- /dev/null +++ b/Filtro_Rust/src/model/error.rs @@ -0,0 +1,89 @@ +//! Type d'erreur unique du cœur applicatif. +//! +//! Aucune fonction publique de ce crate ne panique sur un chemin critique : +//! toute défaillance remonte sous forme de [`FiltroError`]. + +use std::path::PathBuf; + +use thiserror::Error; + +/// Alias de `Result` utilisé partout dans le crate. +pub type Result = std::result::Result; + +/// Erreurs pouvant survenir dans la chaîne Analyzer → Constructor → Formatter → Renderer. +#[derive(Debug, Error)] +pub enum FiltroError { + /// Lecture ou écriture sur le système de fichiers impossible. + #[error("erreur d'entrée/sortie sur « {path} »")] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + /// Le décodage de l'image source a échoué. + #[error("décodage impossible pour « {origin} »")] + Decode { + origin: String, + #[source] + source: image::ImageError, + }, + + /// L'encodage vers un format de sortie a échoué. + #[error("encodage impossible vers le format {format}")] + Encode { + format: String, + #[source] + source: image::ImageError, + }, + + /// Données de pixels incohérentes (dimensions nulles, tampon mal dimensionné…). + #[error("image invalide : {0}")] + InvalidImage(String), + + /// Aucun filtre enregistré ne porte cet identifiant. + #[error("filtre inconnu : « {name} » (disponibles : {available})")] + UnknownFilter { name: String, available: String }, + + /// Paramètre présent mais inutilisable. + #[error("paramètre « {name} » invalide pour le filtre « {filter} » : {reason}")] + InvalidParameter { + filter: String, + name: String, + reason: String, + }, + + /// Paramètre obligatoire absent. + #[error("paramètre obligatoire « {name} » manquant pour le filtre « {filter} »")] + MissingParameter { filter: String, name: String }, + + /// Un filtre a refusé de traiter l'image. + #[error("échec du filtre « {filter} » : {reason}")] + FilterFailed { filter: String, reason: String }, + + /// Format de sortie non reconnu. + #[error("format de sortie inconnu : « {0} »")] + UnknownFormat(String), + + /// Configuration incohérente fournie par l'appelant (CLI, API…). + #[error("configuration invalide : {0}")] + Config(String), +} + +impl FiltroError { + /// Raccourci pour construire une erreur d'entrée/sortie. + pub fn io(path: impl Into, source: std::io::Error) -> Self { + Self::Io { + path: path.into(), + source, + } + } + + /// Raccourci pour signaler l'échec d'un filtre depuis une implémentation externe. + pub fn filter_failed(filter: impl Into, reason: impl Into) -> Self { + Self::FilterFailed { + filter: filter.into(), + reason: reason.into(), + } + } +} diff --git a/Filtro_Rust/src/model/filter/contract.rs b/Filtro_Rust/src/model/filter/contract.rs new file mode 100644 index 0000000..c09d30b --- /dev/null +++ b/Filtro_Rust/src/model/filter/contract.rs @@ -0,0 +1,173 @@ +//! **Le contrat des filtres** — et rien d'autre. +//! +//! Ce fichier ne contient aucun filtre. Il décrit ce qu'un filtre *est*, du +//! point de vue du cœur : +//! +//! * il porte un identifiant ([`Filter::id`]) ; +//! * il peut déclarer ce qu'il attend de l'image ([`FilterRequirements`]) ; +//! * il transforme des pixels ([`Filter::apply`]). +//! +//! L'instanciation depuis la ligne de commande est décrite dans +//! [`crate::model::filter::registry`]. + +use std::fmt; + +use crate::model::error::Result; +use crate::model::pipeline::analyzer::ImageMetadata; +use crate::model::pipeline::formatter::OutputTarget; +use crate::model::pixel::PixelBuffer; + +/// Ce qu'un filtre exige de l'image qu'on lui remet. +/// +/// C'est le **seul** canal par lequel un filtre influence la préparation : +/// le Constructor lit ces données, jamais le filtre lui-même. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct FilterRequirements { + /// Le filtre a besoin d'un canal alpha significatif. + pub needs_alpha: bool, + /// Plus grand côté toléré ; le Constructor réduit l'image si nécessaire. + pub max_dimension: Option, +} + +impl FilterRequirements { + /// Combine deux jeux d'exigences en retenant la contrainte la plus stricte. + pub fn merge(self, other: Self) -> Self { + Self { + needs_alpha: self.needs_alpha || other.needs_alpha, + max_dimension: match (self.max_dimension, other.max_dimension) { + (Some(a), Some(b)) => Some(a.min(b)), + (Some(v), None) | (None, Some(v)) => Some(v), + (None, None) => None, + }, + } + } +} + +/// Ce que le filtre sait du traitement en cours. +/// +/// Le Renderer appelant les filtres une fois par variante, un filtre peut +/// s'adapter au format ou aux dimensions de sortie. +#[derive(Debug, Clone, Copy)] +pub struct FilterContext<'a> { + /// Description de l'image source. + pub metadata: &'a ImageMetadata, + /// Variante en cours de rendu. + pub target: &'a OutputTarget, + /// Rang du filtre dans la chaîne (0 = premier). + pub position: usize, +} + +/// Un filtre configuré, prêt à traiter des pixels. +/// +/// `Send + Sync` pour autoriser un rendu parallèle des variantes. +pub trait Filter: Send + Sync { + /// Identifiant du filtre, tel qu'écrit sur la ligne de commande. + fn id(&self) -> &str; + + /// Exigences sur l'image d'entrée. Par défaut : aucune. + fn requirements(&self) -> FilterRequirements { + FilterRequirements::default() + } + + /// Transforme les pixels sur place. + /// + /// Un filtre qui change les dimensions (recadrage, rotation) remplace le + /// contenu de `canvas` par un nouveau [`PixelBuffer`]. + fn apply(&self, canvas: &mut PixelBuffer, ctx: &FilterContext<'_>) -> Result<()>; +} + +/// Suite ordonnée de filtres, appliquée telle quelle par le Renderer. +#[derive(Default)] +pub struct FilterChain { + filters: Vec>, +} + +impl FilterChain { + /// Chaîne vide : le pipeline se comporte alors en convertisseur de formats. + pub fn empty() -> Self { + Self::default() + } + + pub fn new(filters: Vec>) -> Self { + Self { filters } + } + + pub fn is_empty(&self) -> bool { + self.filters.is_empty() + } + + pub fn len(&self) -> usize { + self.filters.len() + } + + /// Identifiants des filtres, dans l'ordre d'application. + pub fn ids(&self) -> Vec<&str> { + self.filters.iter().map(|f| f.id()).collect() + } + + /// Exigences cumulées, lues par le Constructor. + pub fn requirements(&self) -> FilterRequirements { + self.filters + .iter() + .fold(FilterRequirements::default(), |acc, f| { + acc.merge(f.requirements()) + }) + } + + /// Applique tous les filtres, dans l'ordre. + pub fn apply( + &self, + canvas: &mut PixelBuffer, + metadata: &ImageMetadata, + target: &OutputTarget, + ) -> Result<()> { + for (position, filter) in self.filters.iter().enumerate() { + let ctx = FilterContext { + metadata, + target, + position, + }; + filter.apply(canvas, &ctx)?; + } + Ok(()) + } +} + +impl fmt::Debug for FilterChain { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("FilterChain") + .field("ids", &self.ids()) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fusion_des_exigences() { + let a = FilterRequirements { + needs_alpha: true, + max_dimension: Some(4096), + }; + let b = FilterRequirements { + needs_alpha: false, + max_dimension: Some(2048), + }; + assert_eq!( + a.merge(b), + FilterRequirements { + needs_alpha: true, + max_dimension: Some(2048), + } + ); + } + + #[test] + fn chaine_vide() { + let chain = FilterChain::empty(); + assert!(chain.is_empty()); + assert_eq!(chain.requirements(), FilterRequirements::default()); + } +} diff --git a/Filtro_Rust/src/model/filter/mod.rs b/Filtro_Rust/src/model/filter/mod.rs new file mode 100644 index 0000000..952b562 --- /dev/null +++ b/Filtro_Rust/src/model/filter/mod.rs @@ -0,0 +1,13 @@ +//! Le **contrat** des filtres et leur annuaire — aucun filtre concret ici. +//! +//! * [`contract`] décrit ce qu'un filtre *est* du point de vue du cœur +//! ([`Filter`](contract::Filter), [`FilterChain`](contract::FilterChain), +//! [`FilterRequirements`](contract::FilterRequirements)) ; +//! * [`registry`] configure et instancie les filtres à partir d'une expression +//! textuelle ([`FilterRegistry`](registry::FilterRegistry), +//! [`FilterRequest`](registry::FilterRequest)). +//! +//! Les filtres livrés avec le projet vivent dans [`crate::filters`], hors du cœur. + +pub mod contract; +pub mod registry; diff --git a/Filtro_Rust/src/model/filter/registry.rs b/Filtro_Rust/src/model/filter/registry.rs new file mode 100644 index 0000000..ac953dc --- /dev/null +++ b/Filtro_Rust/src/model/filter/registry.rs @@ -0,0 +1,386 @@ +//! Déclaration, configuration et instanciation des filtres. +//! +//! Un filtre se demande en une chaîne : `jaune`, `jaune:niveau=200`, +//! `noir-blanc:niveau=128,clair=ffcc00`. +//! +//! * [`FilterRequest`] découpe cette expression ; +//! * [`FilterParams`] convertit les valeurs (toujours du texte au départ) ; +//! * [`FilterFactory`] valide et construit le filtre ; +//! * [`FilterRegistry`] fait l'annuaire des fabriques connues. +//! +//! Les paramètres sont volontairement textuels : une fabrique décide seule du +//! type attendu, ce qui évite au cœur de connaître la nature des filtres. + +use std::collections::BTreeMap; +use std::fmt; +use std::str::FromStr; + +use crate::model::error::{FiltroError, Result}; +use crate::model::filter::contract::{Filter, FilterChain}; +use crate::model::pixel::Rgba8; + +// --------------------------------------------------------------------------- +// Paramètres +// --------------------------------------------------------------------------- + +/// Paramètres nommés destinés à une fabrique. +/// +/// Les accesseurs prennent la valeur par défaut en argument : un filtre +/// s'écrit donc sans code de configuration répétitif. +/// +/// ``` +/// # use filtro::{FilterRequest, Rgba8}; +/// let request = FilterRequest::parse("jaune:niveau=200")?; +/// assert_eq!(request.params.u8("niveau", 180)?, 200); +/// assert_eq!(request.params.color("couleur", Rgba8::WHITE)?, Rgba8::WHITE); +/// # Ok::<(), filtro::FiltroError>(()) +/// ``` +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct FilterParams { + filter: String, + values: BTreeMap, +} + +impl FilterParams { + /// Crée un jeu de paramètres vide destiné au filtre `filter`. + pub fn new(filter: impl Into) -> Self { + Self { + filter: filter.into(), + values: BTreeMap::new(), + } + } + + pub fn set(&mut self, name: impl Into, value: impl Into) -> &mut Self { + self.values.insert(name.into(), value.into()); + self + } + + pub fn is_empty(&self) -> bool { + self.values.is_empty() + } + + /// Valeur brute, sans conversion. + pub fn raw(&self, name: &str) -> Option<&str> { + self.values.get(name).map(String::as_str) + } + + /// Texte, ou la valeur par défaut. + pub fn text<'a>(&'a self, name: &str, default: &'a str) -> &'a str { + self.raw(name).unwrap_or(default) + } + + /// Entier 0–255 (seuils, composantes). + pub fn u8(&self, name: &str, default: u8) -> Result { + self.parse(name, default, "un entier entre 0 et 255") + } + + /// Entier positif. + pub fn u32(&self, name: &str, default: u32) -> Result { + self.parse(name, default, "un entier positif") + } + + /// Nombre réel. + pub fn f64(&self, name: &str, default: f64) -> Result { + self.parse(name, default, "un nombre") + } + + /// Nombre réel contraint à un intervalle. + pub fn f64_in( + &self, + name: &str, + default: f64, + range: std::ops::RangeInclusive, + ) -> Result { + let value = self.f64(name, default)?; + if !range.contains(&value) { + return Err(self.invalid( + name, + format!( + "{value} est hors de l'intervalle [{}, {}]", + range.start(), + range.end() + ), + )); + } + Ok(value) + } + + /// Booléen : `true`/`vrai`/`oui`/`1` ou `false`/`faux`/`non`/`0`. + pub fn bool(&self, name: &str, default: bool) -> Result { + match self.raw(name) { + None => Ok(default), + Some(raw) => match raw.trim().to_ascii_lowercase().as_str() { + "true" | "vrai" | "oui" | "1" => Ok(true), + "false" | "faux" | "non" | "0" => Ok(false), + other => Err(self.invalid(name, format!("« {other} » n'est pas un booléen"))), + }, + } + } + + /// Couleur hexadécimale : `fefe01`, `#fefe01` ou `fefe01ff` (avec alpha). + /// + /// La virgule séparant les paramètres, la notation `r,v,b` n'est pas + /// utilisable ici. + pub fn color(&self, name: &str, default: Rgba8) -> Result { + match self.raw(name) { + None => Ok(default), + Some(raw) => Rgba8::from_hex(raw).ok_or_else(|| { + self.invalid( + name, + format!("« {raw} » n'est pas une couleur hexadécimale (ex. fefe01)"), + ) + }), + } + } + + /// Rejette les paramètres absents de la déclaration de la fabrique. + /// Rend les fautes de frappe visibles immédiatement. + pub fn check(&self, specs: &[ParamSpec]) -> Result<()> { + let unknown = self + .values + .keys() + .find(|name| !specs.iter().any(|spec| spec.name == name.as_str())); + match unknown { + None => Ok(()), + Some(name) => { + let known: Vec<&str> = specs.iter().map(|spec| spec.name).collect(); + Err(self.invalid( + name, + if known.is_empty() { + "ce filtre n'accepte aucun paramètre".to_owned() + } else { + format!("paramètres acceptés : {}", known.join(", ")) + }, + )) + } + } + } + + fn parse(&self, name: &str, default: T, expected: &str) -> Result { + match self.raw(name) { + None => Ok(default), + Some(raw) => raw + .trim() + .parse() + .map_err(|_| self.invalid(name, format!("« {raw} » n'est pas {expected}"))), + } + } + + fn invalid(&self, name: &str, reason: impl Into) -> FiltroError { + FiltroError::InvalidParameter { + filter: self.filter.clone(), + name: name.to_owned(), + reason: reason.into(), + } + } +} + +/// Description d'un paramètre, pour l'aide en ligne et la détection des fautes. +/// +/// `default` est purement documentaire : la valeur réellement appliquée est +/// celle passée aux accesseurs de [`FilterParams`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ParamSpec { + pub name: &'static str, + pub default: &'static str, + pub help: &'static str, +} + +// --------------------------------------------------------------------------- +// Fabriques et registre +// --------------------------------------------------------------------------- + +/// Sait décrire un filtre et l'instancier à partir de paramètres. +pub trait FilterFactory: Send + Sync { + /// Identifiant unique, en minuscules, sans espace ni deux-points. + fn id(&self) -> &'static str; + + /// Résumé d'une ligne affiché par `filtro --list-filters`. + fn description(&self) -> &'static str; + + /// Paramètres acceptés. + fn parameters(&self) -> &'static [ParamSpec] { + &[] + } + + /// Valide les paramètres et construit le filtre. + fn build(&self, params: &FilterParams) -> Result>; +} + +/// Filtre demandé par l'utilisateur, avant résolution. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FilterRequest { + pub id: String, + pub params: FilterParams, +} + +impl FilterRequest { + /// Analyse une expression `id` ou `id:clé=valeur,clé=valeur`. + pub fn parse(expression: &str) -> Result { + let expression = expression.trim(); + let (id, rest) = match expression.split_once(':') { + Some((id, rest)) => (id.trim(), rest), + None => (expression, ""), + }; + if id.is_empty() { + return Err(FiltroError::Config(format!( + "expression de filtre invalide : « {expression} »" + ))); + } + + let mut params = FilterParams::new(id); + for pair in rest.split(',').filter(|p| !p.trim().is_empty()) { + let (key, value) = pair.split_once('=').ok_or_else(|| { + FiltroError::Config(format!( + "paramètre « {} » mal formé (attendu clé=valeur)", + pair.trim() + )) + })?; + params.set(key.trim(), value.trim()); + } + + Ok(Self { + id: id.to_owned(), + params, + }) + } +} + +/// Annuaire des filtres disponibles à l'exécution. +/// +/// Le cœur crée un registre vide ; les filtres s'y ajoutent au démarrage. +#[derive(Default)] +pub struct FilterRegistry { + factories: BTreeMap<&'static str, Box>, +} + +impl FilterRegistry { + pub fn new() -> Self { + Self::default() + } + + /// Ajoute une fabrique. + /// + /// # Erreurs + /// Identifiant mal formé ou déjà pris. + pub fn register(&mut self, factory: impl FilterFactory + 'static) -> Result<()> { + let id = factory.id(); + if id.is_empty() || id.contains(char::is_whitespace) || id.contains(':') { + return Err(FiltroError::Config(format!( + "identifiant de filtre invalide : « {id} »" + ))); + } + if self.factories.contains_key(id) { + return Err(FiltroError::Config(format!( + "deux filtres portent l'identifiant « {id} »" + ))); + } + self.factories.insert(id, Box::new(factory)); + Ok(()) + } + + pub fn is_empty(&self) -> bool { + self.factories.is_empty() + } + + pub fn len(&self) -> usize { + self.factories.len() + } + + /// Fabriques disponibles, triées par identifiant. + pub fn factories(&self) -> impl Iterator + '_ { + self.factories.values().map(AsRef::as_ref) + } + + pub fn get(&self, id: &str) -> Option<&dyn FilterFactory> { + self.factories.get(id).map(AsRef::as_ref) + } + + /// Construit un filtre unique. + pub fn build(&self, request: &FilterRequest) -> Result> { + let factory = self + .get(&request.id) + .ok_or_else(|| FiltroError::UnknownFilter { + name: request.id.clone(), + available: self + .factories + .keys() + .copied() + .collect::>() + .join(", "), + })?; + request.params.check(factory.parameters())?; + factory.build(&request.params) + } + + /// Construit une chaîne complète, dans l'ordre fourni. + pub fn build_chain(&self, requests: &[FilterRequest]) -> Result { + let mut filters = Vec::with_capacity(requests.len()); + for request in requests { + filters.push(self.build(request)?); + } + Ok(FilterChain::new(filters)) + } +} + +impl fmt::Debug for FilterRegistry { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("FilterRegistry") + .field("filtres", &self.factories.keys().collect::>()) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_identifiant_seul() { + let request = FilterRequest::parse("jaune").unwrap(); + assert_eq!(request.id, "jaune"); + assert!(request.params.is_empty()); + } + + #[test] + fn parse_parametres() { + let request = FilterRequest::parse("jaune:niveau=200,couleur=fefe01").unwrap(); + assert_eq!(request.params.u8("niveau", 180).unwrap(), 200); + assert_eq!( + request.params.color("couleur", Rgba8::WHITE).unwrap(), + Rgba8::new(254, 254, 1, 255) + ); + } + + #[test] + fn valeurs_par_defaut() { + let params = FilterParams::new("test"); + assert_eq!(params.u8("niveau", 180).unwrap(), 180); + assert!(!params.bool("inverse", false).unwrap()); + assert_eq!(params.text("mode", "canaux"), "canaux"); + } + + #[test] + fn conversions_invalides() { + let request = FilterRequest::parse("test:niveau=300,actif=peut-etre").unwrap(); + assert!(request.params.u8("niveau", 0).is_err()); + assert!(request.params.bool("actif", false).is_err()); + } + + #[test] + fn parse_refuse_les_expressions_malformees() { + assert!(FilterRequest::parse("").is_err()); + assert!(FilterRequest::parse("jaune:niveau").is_err()); + } + + #[test] + fn parametre_inconnu_signale() { + let request = FilterRequest::parse("jaune:nivo=200").unwrap(); + let specs = [ParamSpec { + name: "niveau", + default: "180", + help: "seuil", + }]; + assert!(request.params.check(&specs).is_err()); + } +} diff --git a/Filtro_Rust/src/model/mod.rs b/Filtro_Rust/src/model/mod.rs new file mode 100644 index 0000000..fe39772 --- /dev/null +++ b/Filtro_Rust/src/model/mod.rs @@ -0,0 +1,21 @@ +//! Le cœur métier de Filtro — la couche **Model** du MVC. +//! +//! Ce module ne connaît ni la ligne de commande ni le terminal : les couches +//! `controller` et `view` du binaire le consomment uniquement à travers la +//! façade publique réexportée par [`crate`]. +//! +//! * [`error`] — le type d'erreur unique ; +//! * [`pixel`] — les types d'image partagés, indépendants de toute bibliothèque ; +//! * [`pipeline`] — les quatre étapes stables et leur orchestration ; +//! * [`filter`] — le *contrat* des filtres et leur annuaire d'instanciation. +//! +//! `codec` (pont vers le crate `image`) et `resolution` (lecture des ppp) restent +//! internes au crate. + +pub mod error; +pub mod filter; +pub mod pipeline; +pub mod pixel; + +pub(crate) mod codec; +pub(crate) mod resolution; diff --git a/Filtro_Rust/src/model/pipeline/analyzer.rs b/Filtro_Rust/src/model/pipeline/analyzer.rs new file mode 100644 index 0000000..6f5f7e3 --- /dev/null +++ b/Filtro_Rust/src/model/pipeline/analyzer.rs @@ -0,0 +1,298 @@ +//! Étape 1 — **Analyzer**. +//! +//! Lit l'image d'entrée, la décode en RGBA8 et produit une description +//! exploitable par le reste de la chaîne : dimensions, résolution, format +//! source, caractéristiques colorimétriques. +//! +//! L'Analyzer ne connaît aucun filtre. + +use std::path::{Path, PathBuf}; + +use crate::model::codec; +use crate::model::error::{FiltroError, Result}; +use crate::model::pixel::{Dimensions, PixelBuffer}; +use crate::model::resolution::{self, Resolution}; + +/// Format du fichier source tel que détecté à la lecture. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SourceFormat { + Png, + Jpeg, + Gif, + WebP, + Bmp, + Tiff, + /// Format reconnu par le décodeur mais hors de la liste ci-dessus. + Other(String), + /// Format indéterminé (flux brut, en-tête tronqué…). + Unknown, +} + +impl SourceFormat { + fn from_image_format(format: Option) -> Self { + match format { + Some(image::ImageFormat::Png) => Self::Png, + Some(image::ImageFormat::Jpeg) => Self::Jpeg, + Some(image::ImageFormat::Gif) => Self::Gif, + Some(image::ImageFormat::WebP) => Self::WebP, + Some(image::ImageFormat::Bmp) => Self::Bmp, + Some(image::ImageFormat::Tiff) => Self::Tiff, + Some(other) => Self::Other(format!("{other:?}").to_lowercase()), + None => Self::Unknown, + } + } +} + +impl std::fmt::Display for SourceFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Png => f.write_str("png"), + Self::Jpeg => f.write_str("jpeg"), + Self::Gif => f.write_str("gif"), + Self::WebP => f.write_str("webp"), + Self::Bmp => f.write_str("bmp"), + Self::Tiff => f.write_str("tiff"), + Self::Other(name) => f.write_str(name), + Self::Unknown => f.write_str("inconnu"), + } + } +} + +/// Provenance de l'image analysée. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Origin { + /// Fichier sur disque. + Path(PathBuf), + /// Flux mémoire (entrée standard, test, appel bibliothèque). + Memory { label: String }, +} + +impl Origin { + /// Radical utilisable pour nommer les fichiers de sortie. + pub fn stem(&self) -> String { + match self { + Self::Path(path) => path + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "image".to_owned()), + Self::Memory { label } => label.clone(), + } + } +} + +impl std::fmt::Display for Origin { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Path(path) => write!(f, "{}", path.display()), + Self::Memory { label } => write!(f, "<{label}>"), + } + } +} + +/// Statistiques calculées en un seul passage sur les pixels. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ColorStats { + /// Vrai si au moins un pixel n'est pas opaque. + pub has_transparency: bool, + /// Vrai si tous les pixels vérifient r == g == b. + pub is_grayscale: bool, + /// Luminance moyenne (0.0 – 255.0). + pub mean_luminance: f32, + /// Luminance minimale et maximale observées. + pub luminance_range: (u8, u8), +} + +/// Description de l'image, sans les pixels. +/// +/// C'est cette structure — légère et clonable — qui circule dans les contextes +/// de filtre et dans les journaux. +#[derive(Debug, Clone, PartialEq)] +pub struct ImageMetadata { + pub origin: Origin, + pub source_format: SourceFormat, + pub dimensions: Dimensions, + pub resolution: Option, + pub byte_size: usize, + pub stats: ColorStats, +} + +/// Résultat complet de l'analyse : description + pixels décodés. +#[derive(Debug, Clone, PartialEq)] +pub struct ImageAnalysis { + pub metadata: ImageMetadata, + pub buffer: PixelBuffer, +} + +impl ImageAnalysis { + /// Sépare la description des pixels ; consommé par le Constructor. + pub fn into_parts(self) -> (ImageMetadata, PixelBuffer) { + (self.metadata, self.buffer) + } +} + +/// Étape d'analyse. +#[derive(Debug, Clone)] +pub struct Analyzer { + /// Garde-fou contre les images décompressées démesurées (bombes de décompression). + max_pixels: Option, +} + +impl Default for Analyzer { + fn default() -> Self { + Self { + // 100 Mpx ≈ 400 Mio en RGBA8. + max_pixels: Some(100_000_000), + } + } +} + +impl Analyzer { + pub fn new() -> Self { + Self::default() + } + + /// Modifie la limite de pixels acceptés (`None` = aucune limite). + pub fn with_max_pixels(mut self, max_pixels: Option) -> Self { + self.max_pixels = max_pixels; + self + } + + /// Analyse un fichier. + pub fn analyze_path(&self, path: impl AsRef) -> Result { + let path = path.as_ref(); + let bytes = std::fs::read(path).map_err(|source| FiltroError::io(path, source))?; + self.analyze_bytes(&bytes, Origin::Path(path.to_path_buf())) + } + + /// Analyse un flux déjà en mémoire. + pub fn analyze_bytes(&self, bytes: &[u8], origin: Origin) -> Result { + if bytes.is_empty() { + return Err(FiltroError::InvalidImage(format!("flux vide : {origin}"))); + } + + let (buffer, detected) = codec::decode(bytes, &origin.to_string())?; + let dimensions = buffer.dimensions(); + + if let Some(limit) = self.max_pixels + && dimensions.pixel_count() > limit + { + return Err(FiltroError::InvalidImage(format!( + "image de {dimensions} ({} pixels) au-delà de la limite de {limit} pixels", + dimensions.pixel_count() + ))); + } + + let source_format = SourceFormat::from_image_format(detected); + let resolution = resolution::read(bytes, &source_format); + let stats = compute_stats(&buffer); + + Ok(ImageAnalysis { + metadata: ImageMetadata { + origin, + source_format, + dimensions, + resolution, + byte_size: bytes.len(), + stats, + }, + buffer, + }) + } +} + +fn compute_stats(buffer: &PixelBuffer) -> ColorStats { + let mut has_transparency = false; + let mut is_grayscale = true; + let mut sum = 0.0f64; + let mut min = u8::MAX; + let mut max = u8::MIN; + + for pixel in buffer.pixels() { + if pixel.a != 255 { + has_transparency = true; + } + if pixel.r != pixel.g || pixel.g != pixel.b { + is_grayscale = false; + } + let luma = pixel.luminance(); + sum += f64::from(luma); + min = min.min(luma); + max = max.max(luma); + } + + let count = buffer.dimensions().pixel_count().max(1) as f64; + ColorStats { + has_transparency, + is_grayscale, + mean_luminance: (sum / count) as f32, + luminance_range: (min, max), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::pixel::Rgba8; + + fn png_bytes(width: u32, height: u32, pixel: Rgba8) -> Vec { + let dims = Dimensions::new(width, height).unwrap(); + let mut buffer = PixelBuffer::new(dims).unwrap(); + buffer.map_pixels(|_, _, _| pixel); + crate::model::codec::encode( + &buffer, + crate::model::pipeline::formatter::OutputFormat::Png, + &crate::model::pipeline::formatter::FormatOptions::default(), + ) + .expect("encodage png") + } + + #[test] + fn analyse_une_image_png() { + let bytes = png_bytes(4, 2, Rgba8::new(200, 200, 200, 255)); + let analyzer = Analyzer::new(); + let analysis = analyzer + .analyze_bytes( + &bytes, + Origin::Memory { + label: "test".into(), + }, + ) + .expect("analyse"); + + assert_eq!(analysis.metadata.source_format, SourceFormat::Png); + assert_eq!(analysis.metadata.dimensions.width, 4); + assert_eq!(analysis.metadata.dimensions.height, 2); + assert!(analysis.metadata.stats.is_grayscale); + assert!(!analysis.metadata.stats.has_transparency); + } + + #[test] + fn refuse_un_flux_vide() { + let analyzer = Analyzer::new(); + let err = analyzer + .analyze_bytes( + &[], + Origin::Memory { + label: "vide".into(), + }, + ) + .unwrap_err(); + assert!(matches!(err, FiltroError::InvalidImage(_))); + } + + #[test] + fn respecte_la_limite_de_pixels() { + let bytes = png_bytes(8, 8, Rgba8::BLACK); + let analyzer = Analyzer::new().with_max_pixels(Some(16)); + assert!( + analyzer + .analyze_bytes( + &bytes, + Origin::Memory { + label: "gros".into() + } + ) + .is_err() + ); + } +} diff --git a/Filtro_Rust/src/model/pipeline/constructor.rs b/Filtro_Rust/src/model/pipeline/constructor.rs new file mode 100644 index 0000000..39b558b --- /dev/null +++ b/Filtro_Rust/src/model/pipeline/constructor.rs @@ -0,0 +1,154 @@ +//! Étape 2 — **Constructor**. +//! +//! Reçoit l'analyse (Analyzer) et les exigences de la chaîne de filtres, et en +//! tire une image de travail cohérente : c'est le seul endroit où l'image +//! d'origine est normalisée avant traitement. +//! +//! Le Constructor ne connaît **aucun filtre concret** : il ne lit que +//! [`FilterRequirements`], donnée déclarative fournie par le contrat. + +use crate::model::codec; +use crate::model::error::Result; +use crate::model::filter::contract::{FilterChain, FilterRequirements}; +use crate::model::pipeline::analyzer::{ImageAnalysis, ImageMetadata}; +use crate::model::pixel::{Dimensions, PixelBuffer, Rgba8}; + +/// Politique de préparation, indépendante des filtres. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ConstructionPolicy { + /// Plus grand côté de l'image de travail (`None` = taille d'origine). + pub max_dimension: Option, + /// Couleur de fond utilisée lorsqu'il faut supprimer la transparence. + pub background: Rgba8, +} + +impl Default for ConstructionPolicy { + fn default() -> Self { + Self { + max_dimension: None, + background: Rgba8::WHITE, + } + } +} + +/// Image prête à être déclinée par le Formatter puis rendue par le Renderer. +#[derive(Debug, Clone, PartialEq)] +pub struct PreparedImage { + /// Description de la source (dimensions d'origine incluses). + pub metadata: ImageMetadata, + /// Image de travail en RGBA8. + pub canvas: PixelBuffer, + /// Exigences cumulées de la chaîne, transmises aux étapes suivantes. + pub requirements: FilterRequirements, + /// Couleur de fond retenue pour les formats sans alpha. + pub background: Rgba8, + /// Dimensions d'origine si l'image a été réduite, `None` sinon. + pub resized_from: Option, +} + +impl PreparedImage { + pub fn dimensions(&self) -> Dimensions { + self.canvas.dimensions() + } +} + +/// Étape de préparation. +#[derive(Debug, Clone, Default)] +pub struct Constructor { + policy: ConstructionPolicy, +} + +impl Constructor { + pub fn new() -> Self { + Self::default() + } + + pub fn with_policy(policy: ConstructionPolicy) -> Self { + Self { policy } + } + + pub fn policy(&self) -> ConstructionPolicy { + self.policy + } + + /// Prépare l'image de travail. + /// + /// La borne dimensionnelle retenue est la plus stricte entre la politique + /// de l'application et les exigences de la chaîne de filtres. + pub fn prepare(&self, analysis: ImageAnalysis, chain: &FilterChain) -> Result { + let requirements = chain.requirements(); + let (metadata, mut canvas) = analysis.into_parts(); + + // Borne la plus stricte entre la politique de l'application et les + // exigences déclarées par les filtres. + let bound = match (self.policy.max_dimension, requirements.max_dimension) { + (Some(a), Some(b)) => Some(a.min(b)), + (Some(v), None) | (None, Some(v)) => Some(v), + (None, None) => None, + }; + + let mut resized_from = None; + if let Some(max_side) = bound + && let Some(target) = canvas.dimensions().scaled_to_fit(max_side) + { + resized_from = Some(canvas.dimensions()); + canvas = codec::resize(&canvas, target)?; + } + + // Le tampon reste en RGBA8 à alpha non prémultiplié : c'est la forme + // canonique du cœur. Une future exigence de conversion (espace + // linéaire, etc.) se traiterait ici, à partir de `requirements` seul. + + Ok(PreparedImage { + metadata, + canvas, + requirements, + background: self.policy.background, + resized_from, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{canvas, metadata}; + + fn analyse(width: u32, height: u32) -> ImageAnalysis { + let buffer = canvas(width, height, Rgba8::BLACK); + ImageAnalysis { + metadata: metadata(buffer.dimensions()), + buffer, + } + } + + #[test] + fn conserve_les_dimensions_sans_contrainte() { + let prepared = Constructor::new() + .prepare(analyse(120, 80), &FilterChain::empty()) + .unwrap(); + assert_eq!(prepared.dimensions().width, 120); + assert!(prepared.resized_from.is_none()); + } + + #[test] + fn applique_la_borne_de_la_politique() { + let constructor = Constructor::with_policy(ConstructionPolicy { + max_dimension: Some(60), + ..ConstructionPolicy::default() + }); + + let prepared = constructor + .prepare(analyse(120, 80), &FilterChain::empty()) + .unwrap(); + + assert_eq!(prepared.dimensions().width, 60); + assert_eq!(prepared.dimensions().height, 40); + assert_eq!( + prepared.resized_from, + Some(Dimensions::new(120, 80).unwrap()) + ); + // Les dimensions d'origine restent lisibles dans la description. + assert_eq!(prepared.metadata.dimensions.width, 120); + } +} diff --git a/Filtro_Rust/src/model/pipeline/formatter.rs b/Filtro_Rust/src/model/pipeline/formatter.rs new file mode 100644 index 0000000..17cfe49 --- /dev/null +++ b/Filtro_Rust/src/model/pipeline/formatter.rs @@ -0,0 +1,362 @@ +//! Étape 3 — **Formatter**. +//! +//! Décline l'image préparée en variantes : combinaisons (format de sortie × +//! déclinaison dimensionnelle). Chaque variante possède son propre tampon, +//! déjà adapté aux contraintes du format (aplatissement de l'alpha pour JPEG +//! et BMP, par exemple). +//! +//! Le Formatter ne connaît **aucun filtre** : il prépare des supports, le +//! Renderer y appliquera la chaîne. + +use crate::model::codec; +use crate::model::error::{FiltroError, Result}; +use crate::model::pipeline::constructor::PreparedImage; +use crate::model::pixel::{Dimensions, PixelBuffer, Rgba8}; + +/// Format de fichier proposé en sortie. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum OutputFormat { + Png, + Jpeg, + WebP, + Bmp, + Tiff, +} + +impl OutputFormat { + /// Tous les formats gérés, dans l'ordre d'affichage. + pub const ALL: [OutputFormat; 5] = [ + OutputFormat::Png, + OutputFormat::Jpeg, + OutputFormat::WebP, + OutputFormat::Bmp, + OutputFormat::Tiff, + ]; + + /// Extension de fichier, sans point. + pub fn extension(self) -> &'static str { + match self { + Self::Png => "png", + Self::Jpeg => "jpg", + Self::WebP => "webp", + Self::Bmp => "bmp", + Self::Tiff => "tiff", + } + } + + /// Vrai si le format transporte un canal alpha. + pub fn supports_alpha(self) -> bool { + matches!(self, Self::Png | Self::WebP | Self::Tiff) + } + + /// Vrai si le format est compressé avec perte (le réglage qualité s'applique). + pub fn is_lossy(self) -> bool { + matches!(self, Self::Jpeg) + } + + /// Analyse un nom de format (`png`, `jpg`, `jpeg`, `webp`, `bmp`, `tiff`…). + pub fn parse(raw: &str) -> Result { + match raw.trim().to_ascii_lowercase().as_str() { + "png" => Ok(Self::Png), + "jpg" | "jpeg" => Ok(Self::Jpeg), + "webp" => Ok(Self::WebP), + "bmp" => Ok(Self::Bmp), + "tif" | "tiff" => Ok(Self::Tiff), + other => Err(FiltroError::UnknownFormat(other.to_owned())), + } + } +} + +impl std::fmt::Display for OutputFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Png => "png", + Self::Jpeg => "jpeg", + Self::WebP => "webp", + Self::Bmp => "bmp", + Self::Tiff => "tiff", + }) + } +} + +/// Réglages d'encodage communs à une variante. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FormatOptions { + /// Qualité 1–100 pour les formats avec perte. + pub quality: u8, + /// Fond appliqué lorsque le format ne gère pas la transparence. + pub background: Rgba8, +} + +impl Default for FormatOptions { + fn default() -> Self { + Self { + quality: 85, + background: Rgba8::WHITE, + } + } +} + +/// Déclinaison dimensionnelle demandée. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SizeVariant { + /// Dimensions de l'image de travail. + Original, + /// Réduction pour tenir dans une boîte carrée de `max_side` pixels. + Bounded { label: String, max_side: u32 }, +} + +impl SizeVariant { + pub fn label(&self) -> &str { + match self { + Self::Original => "original", + Self::Bounded { label, .. } => label, + } + } + + /// Analyse une expression `étiquette:côté`, par exemple `vignette:256`. + pub fn parse(expression: &str) -> Result { + let (label, side) = expression.split_once(':').ok_or_else(|| { + FiltroError::Config(format!( + "déclinaison « {expression} » mal formée (attendu étiquette:pixels)" + )) + })?; + let label = label.trim(); + if label.is_empty() { + return Err(FiltroError::Config( + "étiquette de déclinaison vide".to_owned(), + )); + } + let max_side: u32 = side.trim().parse().map_err(|_| { + FiltroError::Config(format!("« {} » n'est pas un nombre de pixels", side.trim())) + })?; + if max_side == 0 { + return Err(FiltroError::Config( + "la taille d'une déclinaison doit être supérieure à zéro".to_owned(), + )); + } + Ok(Self::Bounded { + label: label.to_owned(), + max_side, + }) + } +} + +/// Identité d'une variante de sortie, transmise aux filtres via le contexte. +#[derive(Debug, Clone, PartialEq)] +pub struct OutputTarget { + pub format: OutputFormat, + pub size: SizeVariant, + pub dimensions: Dimensions, + pub options: FormatOptions, +} + +impl OutputTarget { + /// Nom de fichier construit à partir d'un radical. + pub fn file_name(&self, stem: &str) -> String { + match &self.size { + SizeVariant::Original => format!("{stem}.{}", self.format.extension()), + SizeVariant::Bounded { label, .. } => { + format!("{stem}-{label}.{}", self.format.extension()) + } + } + } +} + +/// Une variante prête à être filtrée puis encodée. +#[derive(Debug, Clone, PartialEq)] +pub struct FormattedImage { + pub target: OutputTarget, + pub canvas: PixelBuffer, +} + +/// Ensemble des variantes issues d'une même image préparée. +#[derive(Debug, Clone, PartialEq)] +pub struct FormatSet { + pub variants: Vec, +} + +impl FormatSet { + pub fn len(&self) -> usize { + self.variants.len() + } + + pub fn is_empty(&self) -> bool { + self.variants.is_empty() + } +} + +/// Étape de déclinaison. +#[derive(Debug, Clone)] +pub struct Formatter { + formats: Vec, + sizes: Vec, + options: FormatOptions, +} + +impl Default for Formatter { + fn default() -> Self { + Self { + formats: vec![OutputFormat::Png], + sizes: vec![SizeVariant::Original], + options: FormatOptions::default(), + } + } +} + +impl Formatter { + pub fn new() -> Self { + Self::default() + } + + /// Définit les formats produits (au moins un). + pub fn with_formats(mut self, formats: Vec) -> Result { + if formats.is_empty() { + return Err(FiltroError::Config( + "au moins un format de sortie est requis".to_owned(), + )); + } + self.formats = formats; + self.formats.dedup(); + Ok(self) + } + + /// Définit les déclinaisons dimensionnelles (`Original` est ajouté si la liste est vide). + pub fn with_sizes(mut self, sizes: Vec) -> Self { + self.sizes = if sizes.is_empty() { + vec![SizeVariant::Original] + } else { + sizes + }; + self + } + + /// Définit la qualité d'encodage des formats avec perte. + pub fn with_quality(mut self, quality: u8) -> Result { + if !(1..=100).contains(&quality) { + return Err(FiltroError::Config(format!( + "qualité {quality} hors de l'intervalle 1–100" + ))); + } + self.options.quality = quality; + Ok(self) + } + + pub fn formats(&self) -> &[OutputFormat] { + &self.formats + } + + pub fn sizes(&self) -> &[SizeVariant] { + &self.sizes + } + + /// Produit toutes les variantes (déclinaison × format). + /// + /// Le ré-échantillonnage n'est calculé qu'une fois par déclinaison, puis + /// partagé entre les formats. + pub fn format(&self, prepared: &PreparedImage) -> Result { + let mut variants = Vec::with_capacity(self.sizes.len() * self.formats.len()); + let mut options = self.options; + options.background = prepared.background; + + for size in &self.sizes { + let canvas = match size { + SizeVariant::Original => prepared.canvas.clone(), + SizeVariant::Bounded { max_side, .. } => { + match prepared.canvas.dimensions().scaled_to_fit(*max_side) { + Some(target) => codec::resize(&prepared.canvas, target)?, + None => prepared.canvas.clone(), + } + } + }; + let dimensions = canvas.dimensions(); + + for format in &self.formats { + let mut variant_canvas = canvas.clone(); + if !format.supports_alpha() { + variant_canvas.flatten_onto(options.background); + } + variants.push(FormattedImage { + target: OutputTarget { + format: *format, + size: size.clone(), + dimensions, + options, + }, + canvas: variant_canvas, + }); + } + } + + Ok(FormatSet { variants }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::prepared; + + #[test] + fn produit_le_produit_cartesien() { + let formatter = Formatter::new() + .with_formats(vec![OutputFormat::Png, OutputFormat::Jpeg]) + .unwrap() + .with_sizes(vec![ + SizeVariant::Original, + SizeVariant::Bounded { + label: "vignette".into(), + max_side: 50, + }, + ]); + + let set = formatter + .format(&prepared(200, 100, Rgba8::new(10, 20, 30, 255))) + .unwrap(); + + assert_eq!(set.len(), 4); + let vignette = set + .variants + .iter() + .find(|v| v.target.size.label() == "vignette") + .unwrap(); + assert_eq!(vignette.target.dimensions.width, 50); + assert_eq!(vignette.canvas.width(), 50); + } + + #[test] + fn aplatit_l_alpha_pour_les_formats_sans_transparence() { + let formatter = Formatter::new() + .with_formats(vec![OutputFormat::Png, OutputFormat::Jpeg]) + .unwrap(); + + let set = formatter + .format(&prepared(4, 4, Rgba8::new(10, 20, 30, 0))) + .unwrap(); + + for variant in &set.variants { + assert_eq!( + variant.canvas.has_transparency(), + variant.target.format.supports_alpha() + ); + } + } + + #[test] + fn nomme_les_fichiers() { + let formatter = Formatter::new().with_sizes(vec![SizeVariant::Bounded { + label: "web".into(), + max_side: 800, + }]); + let set = formatter + .format(&prepared(1600, 900, Rgba8::BLACK)) + .unwrap(); + assert_eq!(set.variants[0].target.file_name("photo"), "photo-web.png"); + } + + #[test] + fn refuse_une_qualite_invalide() { + assert!(Formatter::new().with_quality(0).is_err()); + assert!(Formatter::new().with_quality(101).is_err()); + } +} diff --git a/Filtro_Rust/src/model/pipeline/mod.rs b/Filtro_Rust/src/model/pipeline/mod.rs new file mode 100644 index 0000000..6f875c8 --- /dev/null +++ b/Filtro_Rust/src/model/pipeline/mod.rs @@ -0,0 +1,17 @@ +//! Le pipeline : quatre étapes stables, plus leur orchestration. +//! +//! | Étape | Module | Entrée | Sortie | +//! |-------|--------|--------|--------| +//! | [`Analyzer`](analyzer::Analyzer) | [`analyzer`] | octets | [`ImageAnalysis`](analyzer::ImageAnalysis) | +//! | [`Constructor`](constructor::Constructor) | [`constructor`] | analyse + exigences | [`PreparedImage`](constructor::PreparedImage) | +//! | [`Formatter`](formatter::Formatter) | [`formatter`] | image préparée | [`FormatSet`](formatter::FormatSet) | +//! | [`Renderer`](renderer::Renderer) | [`renderer`] | variantes + chaîne | [`RenderOutput`](renderer::RenderOutput) | +//! +//! [`orchestrator`] assemble ces étapes en un [`Pipeline`](orchestrator::Pipeline) +//! immuable et réutilisable. + +pub mod analyzer; +pub mod constructor; +pub mod formatter; +pub mod orchestrator; +pub mod renderer; diff --git a/Filtro_Rust/src/model/pipeline/orchestrator.rs b/Filtro_Rust/src/model/pipeline/orchestrator.rs new file mode 100644 index 0000000..4270836 --- /dev/null +++ b/Filtro_Rust/src/model/pipeline/orchestrator.rs @@ -0,0 +1,145 @@ +//! Orchestration des quatre étapes du cœur. +//! +//! ```text +//! Utilisateur ─ image ─▶ Analyzer ─┐ +//! ├─▶ Constructor ─▶ Formatter ─▶ Renderer ─▶ sorties +//! Utilisateur ─ filtres ─▶ Registre ┘ (applique les filtres) +//! ``` +//! +//! Le [`Pipeline`] est immuable et réutilisable pour plusieurs images. + +use std::path::Path; + +use crate::model::error::Result; +use crate::model::filter::contract::FilterChain; +use crate::model::pipeline::analyzer::{Analyzer, ImageAnalysis, Origin}; +use crate::model::pipeline::constructor::{ConstructionPolicy, Constructor}; +use crate::model::pipeline::formatter::{Formatter, OutputFormat, SizeVariant}; +use crate::model::pipeline::renderer::{RenderOutput, Renderer}; + +/// Chaîne de traitement complète. +#[derive(Debug, Clone, Default)] +pub struct Pipeline { + pub analyzer: Analyzer, + pub constructor: Constructor, + pub formatter: Formatter, + pub renderer: Renderer, +} + +impl Pipeline { + /// Pipeline par défaut : PNG, dimensions d'origine, aucune limite. + pub fn new() -> Self { + Self::default() + } + + pub fn builder() -> PipelineBuilder { + PipelineBuilder::default() + } + + /// Traite un fichier de bout en bout. + pub fn run_path(&self, input: impl AsRef, chain: &FilterChain) -> Result { + let analysis = self.analyzer.analyze_path(input)?; + self.run_analysis(analysis, chain) + } + + /// Traite un flux déjà en mémoire. + pub fn run_bytes( + &self, + bytes: &[u8], + origin: Origin, + chain: &FilterChain, + ) -> Result { + let analysis = self.analyzer.analyze_bytes(bytes, origin)?; + self.run_analysis(analysis, chain) + } + + /// Reprend le pipeline après l'analyse (utile pour les tests et les caches). + pub fn run_analysis( + &self, + analysis: ImageAnalysis, + chain: &FilterChain, + ) -> Result { + let prepared = self.constructor.prepare(analysis, chain)?; + let set = self.formatter.format(&prepared)?; + self.renderer.render(set, chain, &prepared.metadata) + } +} + +/// Construction pas à pas d'un [`Pipeline`], avec validation des réglages. +#[derive(Debug, Clone, Default)] +pub struct PipelineBuilder { + formats: Vec, + sizes: Vec, + quality: Option, + max_dimension: Option, + max_pixels: Option>, + stem: Option, +} + +impl PipelineBuilder { + pub fn formats(mut self, formats: Vec) -> Self { + self.formats = formats; + self + } + + pub fn sizes(mut self, sizes: Vec) -> Self { + self.sizes = sizes; + self + } + + pub fn quality(mut self, quality: u8) -> Self { + self.quality = Some(quality); + self + } + + /// Borne le plus grand côté de l'image de travail. + pub fn max_dimension(mut self, max_dimension: Option) -> Self { + self.max_dimension = max_dimension; + self + } + + /// Borne la taille décompressée acceptée par l'Analyzer. + pub fn max_pixels(mut self, max_pixels: Option) -> Self { + self.max_pixels = Some(max_pixels); + self + } + + pub fn stem(mut self, stem: impl Into) -> Self { + self.stem = Some(stem.into()); + self + } + + /// Valide les réglages et assemble le pipeline. + pub fn build(self) -> Result { + let mut analyzer = Analyzer::new(); + if let Some(max_pixels) = self.max_pixels { + analyzer = analyzer.with_max_pixels(max_pixels); + } + + let constructor = Constructor::with_policy(ConstructionPolicy { + max_dimension: self.max_dimension, + ..ConstructionPolicy::default() + }); + + let mut formatter = Formatter::new(); + if !self.formats.is_empty() { + formatter = formatter.with_formats(self.formats)?; + } + formatter = formatter.with_sizes(self.sizes); + if let Some(quality) = self.quality { + formatter = formatter.with_quality(quality)?; + } + + let renderer = match self.stem { + Some(stem) => Renderer::new().with_stem(stem), + None => Renderer::new(), + }; + + Ok(Pipeline { + analyzer, + constructor, + formatter, + renderer, + }) + } +} diff --git a/Filtro_Rust/src/model/pipeline/renderer.rs b/Filtro_Rust/src/model/pipeline/renderer.rs new file mode 100644 index 0000000..3d628a6 --- /dev/null +++ b/Filtro_Rust/src/model/pipeline/renderer.rs @@ -0,0 +1,193 @@ +//! Étape 4 — **Drawer / Renderer**. +//! +//! Applique la chaîne de filtres à chaque variante produite par le Formatter, +//! puis encode le résultat. Le Renderer est le seul composant du cœur qui +//! *exécute* du code de filtre — et il ne le fait qu'à travers le trait +//! [`Filter`](crate::model::filter::contract::Filter) : il ignore totalement ce que ces filtres font. + +use std::path::{Path, PathBuf}; + +use crate::model::codec; +use crate::model::error::{FiltroError, Result}; +use crate::model::filter::contract::FilterChain; +use crate::model::pipeline::analyzer::ImageMetadata; +use crate::model::pipeline::formatter::{FormatSet, OutputTarget}; + +/// Une image finale encodée, prête à être écrite ou servie. +#[derive(Debug, Clone, PartialEq)] +pub struct RenderedArtifact { + /// Variante à l'origine de ce rendu. + pub target: OutputTarget, + /// Nom de fichier suggéré. + pub file_name: String, + /// Octets encodés. + pub bytes: Vec, +} + +impl RenderedArtifact { + pub fn byte_size(&self) -> usize { + self.bytes.len() + } +} + +/// Résultat complet du pipeline, proposé à l'utilisateur. +#[derive(Debug, Clone, PartialEq)] +pub struct RenderOutput { + /// Description de l'image source. + pub metadata: ImageMetadata, + /// Identifiants des filtres appliqués, dans l'ordre. + pub applied_filters: Vec, + /// Une entrée par variante. + pub artifacts: Vec, +} + +impl RenderOutput { + pub fn is_empty(&self) -> bool { + self.artifacts.is_empty() + } + + pub fn len(&self) -> usize { + self.artifacts.len() + } + + /// Écrit toutes les variantes dans un répertoire, créé si nécessaire. + pub fn write_to_dir(&self, dir: impl AsRef) -> Result> { + let dir = dir.as_ref(); + std::fs::create_dir_all(dir).map_err(|source| FiltroError::io(dir, source))?; + let mut written = Vec::with_capacity(self.artifacts.len()); + for artifact in &self.artifacts { + let path = dir.join(&artifact.file_name); + std::fs::write(&path, &artifact.bytes) + .map_err(|source| FiltroError::io(&path, source))?; + written.push(path); + } + Ok(written) + } +} + +/// Étape de rendu. +#[derive(Debug, Clone, Default)] +pub struct Renderer { + stem: Option, +} + +impl Renderer { + pub fn new() -> Self { + Self::default() + } + + /// Force le radical des noms de fichiers (par défaut : celui de la source). + pub fn with_stem(mut self, stem: impl Into) -> Self { + self.stem = Some(stem.into()); + self + } + + /// Applique la chaîne à chaque variante et encode le résultat. + /// + /// Les filtres sont appliqués **par variante** : un filtre peut donc + /// s'adapter au format ou aux dimensions cibles via + /// [`FilterContext`](crate::model::filter::contract::FilterContext). + pub fn render( + &self, + set: FormatSet, + chain: &FilterChain, + metadata: &ImageMetadata, + ) -> Result { + let stem = self.stem.clone().unwrap_or_else(|| metadata.origin.stem()); + + let mut artifacts = Vec::with_capacity(set.variants.len()); + for mut variant in set.variants { + chain.apply(&mut variant.canvas, metadata, &variant.target)?; + + // Un filtre a pu modifier les dimensions : la cible est alignée + // sur le résultat réel avant encodage. + variant.target.dimensions = variant.canvas.dimensions(); + + // Second passage de sécurité : si un filtre a réintroduit de la + // transparence sur un format qui ne la gère pas. + if !variant.target.format.supports_alpha() { + variant + .canvas + .flatten_onto(variant.target.options.background); + } + + let bytes = codec::encode( + &variant.canvas, + variant.target.format, + &variant.target.options, + )?; + + artifacts.push(RenderedArtifact { + file_name: variant.target.file_name(&stem), + target: variant.target, + bytes, + }); + } + + Ok(RenderOutput { + metadata: metadata.clone(), + applied_filters: chain.ids().into_iter().map(str::to_owned).collect(), + artifacts, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::filter::contract::{Filter, FilterContext}; + use crate::model::pipeline::formatter::{Formatter, OutputFormat}; + use crate::model::pixel::{PixelBuffer, Rgba8}; + use crate::test_support::prepared; + + /// Filtre factice, défini ici pour vérifier que le trait suffit. + struct Inverseur; + + impl Filter for Inverseur { + fn id(&self) -> &str { + "inverseur-de-test" + } + + fn apply(&self, canvas: &mut PixelBuffer, _ctx: &FilterContext<'_>) -> Result<()> { + canvas.map_pixels(|_, _, p| Rgba8::new(255 - p.r, 255 - p.g, 255 - p.b, p.a)); + Ok(()) + } + } + + #[test] + fn rend_toutes_les_variantes() { + let image = prepared(8, 8, Rgba8::BLACK); + let set = Formatter::new() + .with_formats(vec![OutputFormat::Png, OutputFormat::Jpeg]) + .unwrap() + .format(&image) + .unwrap(); + + let output = Renderer::new() + .with_stem("photo") + .render(set, &FilterChain::empty(), &image.metadata) + .unwrap(); + + assert_eq!(output.len(), 2); + assert!(output.applied_filters.is_empty()); + assert_eq!(output.artifacts[0].file_name, "photo.png"); + assert!(output.artifacts.iter().all(|a| a.byte_size() > 0)); + } + + #[test] + fn applique_la_chaine_a_chaque_variante() { + let image = prepared(8, 8, Rgba8::BLACK); + let set = Formatter::new().format(&image).unwrap(); + let chain = FilterChain::new(vec![Box::new(Inverseur)]); + + let output = Renderer::new() + .render(set, &chain, &image.metadata) + .unwrap(); + + assert_eq!(output.applied_filters, vec!["inverseur-de-test"]); + + // L'image noire est devenue blanche : on relit le PNG produit. + let (buffer, _) = codec::decode(&output.artifacts[0].bytes, "test").unwrap(); + assert_eq!(buffer.pixel(0, 0), Some(Rgba8::WHITE)); + } +} diff --git a/Filtro_Rust/src/model/pixel.rs b/Filtro_Rust/src/model/pixel.rs new file mode 100644 index 0000000..9c03907 --- /dev/null +++ b/Filtro_Rust/src/model/pixel.rs @@ -0,0 +1,342 @@ +//! Représentation mémoire des pixels, indépendante de toute bibliothèque tierce. +//! +//! C'est le seul type de données que les filtres manipulent. Il ne dépend +//! volontairement pas du crate `image` : la bibliothèque de décodage peut être +//! remplacée sans casser le contrat public offert aux filtres. + +use crate::model::error::{FiltroError, Result}; + +/// Nombre d'octets par pixel (RGBA 8 bits, alpha non prémultiplié). +pub const CHANNELS: usize = 4; + +/// Un pixel RGBA 8 bits, alpha non prémultiplié. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Rgba8 { + pub r: u8, + pub g: u8, + pub b: u8, + pub a: u8, +} + +impl Rgba8 { + /// Pixel entièrement transparent. + pub const TRANSPARENT: Self = Self::new(0, 0, 0, 0); + /// Blanc opaque (fond par défaut lors de l'aplatissement de l'alpha). + pub const WHITE: Self = Self::new(255, 255, 255, 255); + /// Noir opaque. + pub const BLACK: Self = Self::new(0, 0, 0, 255); + + pub const fn new(r: u8, g: u8, b: u8, a: u8) -> Self { + Self { r, g, b, a } + } + + /// Analyse une couleur hexadécimale : `fefe01`, `#fefe01`, `fefe01ff`. + /// + /// Renvoie `None` si la chaîne n'est pas une couleur valide. + pub fn from_hex(raw: &str) -> Option { + let hex = raw.trim().trim_start_matches('#'); + if !hex.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok(); + match hex.len() { + 6 => Some(Self::new(byte(0)?, byte(2)?, byte(4)?, 255)), + 8 => Some(Self::new(byte(0)?, byte(2)?, byte(4)?, byte(6)?)), + _ => None, + } + } + + /// Luminance perçue (Rec. 601), utile aux filtres de seuillage. + pub fn luminance(self) -> u8 { + let y = 0.299 * f32::from(self.r) + 0.587 * f32::from(self.g) + 0.114 * f32::from(self.b); + y.round().clamp(0.0, 255.0) as u8 + } + + /// Composite ce pixel sur un fond opaque et renvoie le résultat opaque. + pub fn over(self, background: Rgba8) -> Rgba8 { + if self.a == 255 { + return self; + } + let alpha = f32::from(self.a) / 255.0; + let mix = |fg: u8, bg: u8| -> u8 { + let value = f32::from(fg) * alpha + f32::from(bg) * (1.0 - alpha); + value.round().clamp(0.0, 255.0) as u8 + }; + Rgba8::new( + mix(self.r, background.r), + mix(self.g, background.g), + mix(self.b, background.b), + 255, + ) + } +} + +/// Dimensions d'une image, toujours strictement positives. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Dimensions { + pub width: u32, + pub height: u32, +} + +impl Dimensions { + pub fn new(width: u32, height: u32) -> Result { + if width == 0 || height == 0 { + return Err(FiltroError::InvalidImage(format!( + "dimensions nulles ({width}x{height})" + ))); + } + Ok(Self { width, height }) + } + + /// Nombre total de pixels, calculé en `u64` pour éviter tout débordement. + pub fn pixel_count(self) -> u64 { + u64::from(self.width) * u64::from(self.height) + } + + /// Plus grand côté, base des variantes dimensionnelles du Formatter. + pub fn longest_side(self) -> u32 { + self.width.max(self.height) + } + + /// Dimensions réduites pour tenir dans une boîte de `max_side` pixels. + /// Renvoie `None` si l'image tient déjà dans la boîte. + pub fn scaled_to_fit(self, max_side: u32) -> Option { + if max_side == 0 || self.longest_side() <= max_side { + return None; + } + let ratio = f64::from(max_side) / f64::from(self.longest_side()); + let width = ((f64::from(self.width) * ratio).round() as u32).max(1); + let height = ((f64::from(self.height) * ratio).round() as u32).max(1); + Some(Dimensions { width, height }) + } +} + +impl std::fmt::Display for Dimensions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}x{}", self.width, self.height) + } +} + +/// Tampon de pixels RGBA8 contigu, ligne par ligne. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PixelBuffer { + dimensions: Dimensions, + data: Vec, +} + +impl PixelBuffer { + /// Crée un tampon entièrement transparent. + pub fn new(dimensions: Dimensions) -> Result { + let len = usize::try_from(dimensions.pixel_count()) + .ok() + .and_then(|count| count.checked_mul(CHANNELS)) + .ok_or_else(|| { + FiltroError::InvalidImage(format!("image trop grande : {dimensions}")) + })?; + Ok(Self { + dimensions, + data: vec![0; len], + }) + } + + /// Construit un tampon à partir d'octets bruts RGBA8. + /// + /// # Erreurs + /// Renvoie [`FiltroError::InvalidImage`] si la taille du tampon ne + /// correspond pas aux dimensions annoncées. + pub fn from_raw(dimensions: Dimensions, data: Vec) -> Result { + let expected = usize::try_from(dimensions.pixel_count()) + .ok() + .and_then(|count| count.checked_mul(CHANNELS)) + .ok_or_else(|| { + FiltroError::InvalidImage(format!("image trop grande : {dimensions}")) + })?; + if data.len() != expected { + return Err(FiltroError::InvalidImage(format!( + "tampon de {} octets pour {dimensions} (attendu : {expected})", + data.len() + ))); + } + Ok(Self { dimensions, data }) + } + + pub fn dimensions(&self) -> Dimensions { + self.dimensions + } + + pub fn width(&self) -> u32 { + self.dimensions.width + } + + pub fn height(&self) -> u32 { + self.dimensions.height + } + + /// Accès en lecture aux octets bruts (RGBA entrelacés). + pub fn as_bytes(&self) -> &[u8] { + &self.data + } + + /// Accès en écriture aux octets bruts, pour les filtres vectorisés. + pub fn as_bytes_mut(&mut self) -> &mut [u8] { + &mut self.data + } + + /// Consomme le tampon et rend les octets bruts. + pub fn into_raw(self) -> Vec { + self.data + } + + fn offset(&self, x: u32, y: u32) -> Option { + if x >= self.dimensions.width || y >= self.dimensions.height { + return None; + } + let index = u64::from(y) * u64::from(self.dimensions.width) + u64::from(x); + usize::try_from(index).ok()?.checked_mul(CHANNELS) + } + + /// Lit un pixel, ou `None` hors limites. + pub fn pixel(&self, x: u32, y: u32) -> Option { + let offset = self.offset(x, y)?; + Some(Rgba8::new( + self.data[offset], + self.data[offset + 1], + self.data[offset + 2], + self.data[offset + 3], + )) + } + + /// Lit un pixel en repliant les coordonnées sur le bord le plus proche. + /// Pratique pour les noyaux de convolution. + pub fn pixel_clamped(&self, x: i64, y: i64) -> Rgba8 { + let cx = x.clamp(0, i64::from(self.dimensions.width - 1)) as u32; + let cy = y.clamp(0, i64::from(self.dimensions.height - 1)) as u32; + self.pixel(cx, cy).unwrap_or(Rgba8::TRANSPARENT) + } + + /// Écrit un pixel ; sans effet si les coordonnées sortent de l'image. + pub fn set_pixel(&mut self, x: u32, y: u32, pixel: Rgba8) { + if let Some(offset) = self.offset(x, y) { + self.data[offset] = pixel.r; + self.data[offset + 1] = pixel.g; + self.data[offset + 2] = pixel.b; + self.data[offset + 3] = pixel.a; + } + } + + /// Itère sur les pixels dans l'ordre de lecture. + pub fn pixels(&self) -> impl Iterator + '_ { + self.data + .as_chunks::() + .0 + .iter() + .map(|c| Rgba8::new(c[0], c[1], c[2], c[3])) + } + + /// Applique une transformation pixel à pixel, en place. + pub fn map_pixels(&mut self, mut f: F) + where + F: FnMut(u32, u32, Rgba8) -> Rgba8, + { + let width = self.dimensions.width; + for (index, chunk) in self + .data + .as_chunks_mut::() + .0 + .iter_mut() + .enumerate() + { + let index = index as u32; + let x = index % width; + let y = index / width; + let out = f(x, y, Rgba8::new(chunk[0], chunk[1], chunk[2], chunk[3])); + chunk[0] = out.r; + chunk[1] = out.g; + chunk[2] = out.b; + chunk[3] = out.a; + } + } + + /// Vrai si au moins un pixel n'est pas totalement opaque. + pub fn has_transparency(&self) -> bool { + self.data + .as_chunks::() + .0 + .iter() + .any(|c| c[3] != 255) + } + + /// Aplatit la transparence sur un fond opaque (formats sans canal alpha). + pub fn flatten_onto(&mut self, background: Rgba8) { + if !self.has_transparency() { + return; + } + self.map_pixels(|_, _, pixel| pixel.over(background)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn buffer(width: u32, height: u32) -> PixelBuffer { + PixelBuffer::new(Dimensions::new(width, height).expect("dimensions valides")) + .expect("allocation") + } + + #[test] + fn dimensions_refuse_zero() { + assert!(Dimensions::new(0, 10).is_err()); + assert!(Dimensions::new(10, 0).is_err()); + } + + #[test] + fn from_raw_verifie_la_taille() { + let dims = Dimensions::new(2, 2).unwrap(); + assert!(PixelBuffer::from_raw(dims, vec![0; 16]).is_ok()); + assert!(PixelBuffer::from_raw(dims, vec![0; 15]).is_err()); + } + + #[test] + fn lecture_ecriture_pixel() { + let mut buf = buffer(3, 2); + buf.set_pixel(2, 1, Rgba8::new(10, 20, 30, 40)); + assert_eq!(buf.pixel(2, 1), Some(Rgba8::new(10, 20, 30, 40))); + assert_eq!(buf.pixel(3, 1), None); + } + + #[test] + fn map_pixels_fournit_les_coordonnees() { + let mut buf = buffer(2, 2); + buf.map_pixels(|x, y, _| Rgba8::new(x as u8, y as u8, 0, 255)); + assert_eq!(buf.pixel(1, 0), Some(Rgba8::new(1, 0, 0, 255))); + assert_eq!(buf.pixel(0, 1), Some(Rgba8::new(0, 1, 0, 255))); + } + + #[test] + fn aplatissement_alpha() { + let mut buf = buffer(1, 1); + buf.set_pixel(0, 0, Rgba8::new(0, 0, 0, 0)); + buf.flatten_onto(Rgba8::WHITE); + assert_eq!(buf.pixel(0, 0), Some(Rgba8::WHITE)); + } + + #[test] + fn lecture_couleur_hexadecimale() { + assert_eq!( + Rgba8::from_hex("#fefe01"), + Some(Rgba8::new(254, 254, 1, 255)) + ); + assert_eq!(Rgba8::from_hex("000000ff"), Some(Rgba8::BLACK)); + assert_eq!(Rgba8::from_hex("fff"), None); + assert_eq!(Rgba8::from_hex("zzzzzz"), None); + } + + #[test] + fn reduction_proportionnelle() { + let dims = Dimensions::new(1000, 500).unwrap(); + assert_eq!(dims.scaled_to_fit(2000), None); + let reduced = dims.scaled_to_fit(100).unwrap(); + assert_eq!((reduced.width, reduced.height), (100, 50)); + } +} diff --git a/Filtro_Rust/src/model/resolution.rs b/Filtro_Rust/src/model/resolution.rs new file mode 100644 index 0000000..39a0ebc --- /dev/null +++ b/Filtro_Rust/src/model/resolution.rs @@ -0,0 +1,120 @@ +//! Lecture de la résolution (points par pouce) dans les métadonnées. +//! +//! Le crate `image` n'expose pas la densité : on lit donc directement le chunk +//! `pHYs` d'un PNG ou le segment `JFIF` d'un JPEG. La lecture est défensive : +//! toute anomalie renvoie `None` plutôt qu'une erreur, car l'absence de +//! résolution n'empêche pas le traitement. + +use crate::model::pipeline::analyzer::SourceFormat; + +/// Résolution déclarée par le fichier source. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Resolution { + pub dpi_x: f32, + pub dpi_y: f32, +} + +const METERS_PER_INCH: f32 = 0.0254; + +/// Extrait la résolution si le format la porte et qu'elle est exploitable. +pub(crate) fn read(bytes: &[u8], format: &SourceFormat) -> Option { + match format { + SourceFormat::Png => png_phys(bytes), + SourceFormat::Jpeg => jpeg_jfif(bytes), + _ => None, + } +} + +fn be_u32(slice: &[u8]) -> Option { + Some(u32::from_be_bytes(slice.get(..4)?.try_into().ok()?)) +} + +fn be_u16(slice: &[u8]) -> Option { + Some(u16::from_be_bytes(slice.get(..2)?.try_into().ok()?)) +} + +/// Parcourt les chunks PNG jusqu'à `pHYs` (toujours placé avant `IDAT`). +fn png_phys(bytes: &[u8]) -> Option { + const SIGNATURE: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]; + if bytes.get(..8)? != SIGNATURE { + return None; + } + + let mut cursor = 8usize; + while cursor + 8 <= bytes.len() { + let length = be_u32(&bytes[cursor..])? as usize; + let kind = bytes.get(cursor + 4..cursor + 8)?; + let data_start = cursor + 8; + let data_end = data_start.checked_add(length)?; + if data_end > bytes.len() { + return None; + } + + if kind == b"pHYs" && length >= 9 { + let data = &bytes[data_start..data_end]; + // Unité 1 = pixels par mètre ; 0 = simple rapport d'aspect. + if data[8] != 1 { + return None; + } + return Some(Resolution { + dpi_x: be_u32(data)? as f32 * METERS_PER_INCH, + dpi_y: be_u32(&data[4..])? as f32 * METERS_PER_INCH, + }); + } + if kind == b"IDAT" || kind == b"IEND" { + return None; + } + + cursor = data_end.checked_add(4)?; // + CRC + } + None +} + +/// Parcourt les segments JPEG jusqu'à l'en-tête APP0/JFIF. +fn jpeg_jfif(bytes: &[u8]) -> Option { + if bytes.get(..2)? != [0xFF, 0xD8] { + return None; + } + + let mut cursor = 2usize; + while cursor + 4 <= bytes.len() { + if bytes[cursor] != 0xFF { + return None; + } + let marker = bytes[cursor + 1]; + // Marqueurs sans charge utile. + if marker == 0xD8 || marker == 0x01 || (0xD0..=0xD7).contains(&marker) { + cursor += 2; + continue; + } + // Début des données compressées : plus rien à lire. + if marker == 0xDA { + return None; + } + + let length = be_u16(&bytes[cursor + 2..])? as usize; + if length < 2 { + return None; + } + let segment = bytes.get(cursor + 4..cursor + 2 + length)?; + + if marker == 0xE0 && segment.len() >= 12 && &segment[..5] == b"JFIF\0" { + let x = be_u16(&segment[8..])? as f32; + let y = be_u16(&segment[10..])? as f32; + if x == 0.0 || y == 0.0 { + return None; + } + return match segment[7] { + 1 => Some(Resolution { dpi_x: x, dpi_y: y }), // points par pouce + 2 => Some(Resolution { + dpi_x: x * 2.54, + dpi_y: y * 2.54, + }), // points par centimètre + _ => None, + }; + } + + cursor += 2 + length; + } + None +} diff --git a/Filtro_Rust/src/red.rs b/Filtro_Rust/src/red.rs deleted file mode 100644 index 1d4b3b7..0000000 --- a/Filtro_Rust/src/red.rs +++ /dev/null @@ -1,71 +0,0 @@ -use image::{Rgb, RgbImage}; -use rfd::FileDialog; - -fn main() { - // Niveau blanc - let niveau_blanc: u8 = 180; - - println!("Veuillez choisir votre image"); - - // Ouverture de la boîte de dialogue - let fichier_image = FileDialog::new() - .set_title("Sélectionner un fichier") - .pick_file(); - - let fichier_image = match fichier_image { - Some(path) => path, - None => { - println!("Aucun fichier sélectionné."); - return; - } - }; - - println!("Démarrage du traitement de : {:?}", fichier_image); - println!("niveau_blanc {}", niveau_blanc); - - // Chargement de l'image - let mut img: RgbImage = image::open(&fichier_image) - .expect("Impossible d'ouvrir l'image") - .to_rgb8(); - - let (largeur_img, hauteur_img) = img.dimensions(); - - filtre_rouge(&mut img, largeur_img, hauteur_img, niveau_blanc); - - img.save("Image Rouge.jpg") - .expect("Impossible de sauvegarder l'image"); - - println!("Traitement image rouge terminé"); -} - -fn filtre_rouge( - img: &mut RgbImage, - largeur_img: u32, - hauteur_img: u32, - niveau_blanc: u8, -) { - println!("Vous avez choisi le filtre Rouge"); - - for y in 0..hauteur_img { - for x in 0..largeur_img { - let pixel = img.get_pixel(x, y); - - let r = pixel[0]; - let v = pixel[1]; - let b = pixel[2]; - - let nouveau_pixel = if r > niveau_blanc - && v > niveau_blanc - && b > niveau_blanc - { - // Blanc -> Rouge - Rgb([254, 1, 1]) - } else { - // Permutation des couleurs (b, r, v) - Rgb([b, r, v]) - }; - - img.put_pixel(x, y, nouveau_pixel); - } - } -} \ No newline at end of file diff --git a/Filtro_Rust/src/test_support.rs b/Filtro_Rust/src/test_support.rs new file mode 100644 index 0000000..a01dee0 --- /dev/null +++ b/Filtro_Rust/src/test_support.rs @@ -0,0 +1,74 @@ +//! Fabriques d'objets pour les tests — compilé uniquement avec `cargo test`. +//! +//! Évite de répéter la construction d'une `ImageMetadata` ou d'une +//! `OutputTarget` complète dans chaque test. + +use crate::model::filter::contract::{Filter, FilterContext, FilterRequirements}; +use crate::model::pipeline::analyzer::{ColorStats, ImageMetadata, Origin, SourceFormat}; +use crate::model::pipeline::constructor::PreparedImage; +use crate::model::pipeline::formatter::{FormatOptions, OutputFormat, OutputTarget, SizeVariant}; +use crate::model::pixel::{Dimensions, PixelBuffer, Rgba8}; + +/// Description d'image factice, cohérente avec les dimensions données. +pub(crate) fn metadata(dimensions: Dimensions) -> ImageMetadata { + ImageMetadata { + origin: Origin::Memory { + label: "test".into(), + }, + source_format: SourceFormat::Png, + dimensions, + resolution: None, + byte_size: 0, + stats: ColorStats { + has_transparency: false, + is_grayscale: false, + mean_luminance: 0.0, + luminance_range: (0, 255), + }, + } +} + +/// Image uniforme de la taille demandée. +pub(crate) fn canvas(width: u32, height: u32, pixel: Rgba8) -> PixelBuffer { + let dimensions = Dimensions::new(width, height).expect("dimensions valides"); + let mut canvas = PixelBuffer::new(dimensions).expect("allocation"); + canvas.map_pixels(|_, _, _| pixel); + canvas +} + +/// Image préparée uniforme, prête pour le Formatter ou le Renderer. +pub(crate) fn prepared(width: u32, height: u32, pixel: Rgba8) -> PreparedImage { + let canvas = canvas(width, height, pixel); + PreparedImage { + metadata: metadata(canvas.dimensions()), + canvas, + requirements: FilterRequirements::default(), + background: Rgba8::WHITE, + resized_from: None, + } +} + +/// Variante de sortie factice (PNG, taille d'origine). +pub(crate) fn target(dimensions: Dimensions) -> OutputTarget { + OutputTarget { + format: OutputFormat::Png, + size: SizeVariant::Original, + dimensions, + options: FormatOptions::default(), + } +} + +/// Applique un filtre à une image de 1×1 pixel et renvoie le résultat. +pub(crate) fn apply_to_pixel(filter: &dyn Filter, pixel: Rgba8) -> Rgba8 { + let mut buffer = canvas(1, 1, pixel); + let dimensions = buffer.dimensions(); + let metadata = metadata(dimensions); + let target = target(dimensions); + let ctx = FilterContext { + metadata: &metadata, + target: &target, + position: 0, + }; + filter.apply(&mut buffer, &ctx).expect("filtre appliqué"); + buffer.pixel(0, 0).expect("pixel présent") +} diff --git a/Filtro_Rust/src/view/catalog.rs b/Filtro_Rust/src/view/catalog.rs new file mode 100644 index 0000000..7499e77 --- /dev/null +++ b/Filtro_Rust/src/view/catalog.rs @@ -0,0 +1,29 @@ +//! Affichage du catalogue de filtres (`filtro --list-filters`). + +use filtro::FilterRegistry; + +/// Liste les filtres enregistrés, leurs paramètres et quelques exemples. +pub fn print_filters(registry: &FilterRegistry) { + if registry.is_empty() { + println!("Aucun filtre enregistré."); + println!( + "Les filtres s'ajoutent en implémentant `Filter` + `FilterFactory`, \ + puis en les enregistrant dans `register_filters` — sans modifier le cœur." + ); + return; + } + println!("Filtres disponibles ({}) :", registry.len()); + for factory in registry.factories() { + println!("\n {:<12} {}", factory.id(), factory.description()); + for param in factory.parameters() { + println!( + " {:<12} défaut {:<8} {}", + param.name, param.default, param.help + ); + } + } + println!("\nExemples :"); + println!(" filtro photo.jpg -f jaune"); + println!(" filtro photo.jpg -f noir-blanc:niveau=128,mode=luminance"); + println!(" filtro photo.jpg -f rouge -f noir-blanc (chaînage)"); +} diff --git a/Filtro_Rust/src/view/mod.rs b/Filtro_Rust/src/view/mod.rs new file mode 100644 index 0000000..1652fb3 --- /dev/null +++ b/Filtro_Rust/src/view/mod.rs @@ -0,0 +1,33 @@ +//! Couche **View** — tout ce qui s'écrit sur le terminal. +//! +//! Aucune logique métier ici : les fonctions reçoivent des types du cœur +//! (`filtro::…`) déjà calculés et se contentent de les mettre en forme. + +mod catalog; +mod report; + +pub use catalog::print_filters; +pub use report::print_summary; + +use filtro::FiltroError; + +/// Écrit une erreur sur la sortie d'erreur, en déroulant la chaîne de causes. +pub fn report_error(error: &FiltroError) { + eprintln!("erreur : {error}"); + let mut source = std::error::Error::source(error); + while let Some(cause) = source { + eprintln!(" ↳ {cause}"); + source = cause.source(); + } +} + +/// Taille lisible par un humain (o, Kio, Mio). +pub fn human_size(bytes: usize) -> String { + const KIO: usize = 1024; + const MIO: usize = KIO * 1024; + match bytes { + 0..=1023 => format!("{bytes} o"), + 1024..=1_048_575 => format!("{:.1} Kio", bytes as f64 / KIO as f64), + _ => format!("{:.1} Mio", bytes as f64 / MIO as f64), + } +} diff --git a/Filtro_Rust/src/view/report.rs b/Filtro_Rust/src/view/report.rs new file mode 100644 index 0000000..9d60004 --- /dev/null +++ b/Filtro_Rust/src/view/report.rs @@ -0,0 +1,40 @@ +//! Résumé d'un traitement terminé : source, filtres, variantes écrites. + +use std::path::PathBuf; + +use filtro::RenderOutput; + +use super::human_size; + +/// Écrit sur la sortie standard le compte rendu d'un [`RenderOutput`]. +/// +/// `written` est la liste des chemins réellement créés, dans le même ordre que +/// `output.artifacts`. +pub fn print_summary(output: &RenderOutput, written: &[PathBuf]) { + let source = &output.metadata; + println!( + "source : {} — {} {} ({} octets)", + source.origin, source.source_format, source.dimensions, source.byte_size + ); + if let Some(resolution) = source.resolution { + println!( + "résolution : {:.0}×{:.0} ppp", + resolution.dpi_x, resolution.dpi_y + ); + } + if output.applied_filters.is_empty() { + println!("filtres : aucun"); + } else { + println!("filtres : {}", output.applied_filters.join(" → ")); + } + println!("{} variante(s) écrite(s) :", written.len()); + for (path, artifact) in written.iter().zip(&output.artifacts) { + println!( + " {} — {} {} ({})", + path.display(), + artifact.target.format, + artifact.target.dimensions, + human_size(artifact.byte_size()) + ); + } +} diff --git a/Filtro_Rust/tests/pipeline.rs b/Filtro_Rust/tests/pipeline.rs new file mode 100644 index 0000000..e197f19 --- /dev/null +++ b/Filtro_Rust/tests/pipeline.rs @@ -0,0 +1,204 @@ +//! Test d'intégration bout-en-bout. +//! +//! Il n'utilise **que l'API publique** `filtro::…` — y compris pour définir un +//! filtre `luminosite` hors du cœur : la preuve que le contrat exposé suffit à +//! écrire un filtre sans toucher au pipeline. + +use std::io::Cursor; + +use filtro::{ + Analyzer, Filter, FilterChain, FilterContext, Origin, OutputFormat, Pipeline, PixelBuffer, + Result, Rgba8, SizeVariant, +}; + +// --- Un filtre défini entièrement hors du crate --------------------------- + +struct Luminosite { + delta: i16, +} + +impl Filter for Luminosite { + fn id(&self) -> &str { + "luminosite" + } + + fn apply(&self, canvas: &mut PixelBuffer, _ctx: &FilterContext<'_>) -> Result<()> { + canvas.map_pixels(|_, _, pixel| { + let adjust = |channel: u8| (i16::from(channel) + self.delta).clamp(0, 255) as u8; + Rgba8::new(adjust(pixel.r), adjust(pixel.g), adjust(pixel.b), pixel.a) + }); + Ok(()) + } +} + +// --- Fabrique d'une image source en mémoire ------------------------------ + +/// Encode un PNG uniforme de `width`×`height` de la couleur donnée. +fn source_png(width: u32, height: u32, color: [u8; 4]) -> Vec { + let image = image::RgbaImage::from_pixel(width, height, image::Rgba(color)); + let mut bytes = Vec::new(); + image::DynamicImage::ImageRgba8(image) + .write_to(&mut Cursor::new(&mut bytes), image::ImageFormat::Png) + .expect("encodage du PNG de test"); + bytes +} + +fn analyze(bytes: &[u8]) -> filtro::ImageAnalysis { + Analyzer::new() + .analyze_bytes( + bytes, + Origin::Memory { + label: "relecture".into(), + }, + ) + .expect("relecture d'une variante produite") +} + +// --- Les tests ---------------------------------------------------------- + +#[test] +fn bout_en_bout_avec_un_filtre_externe() { + let png = source_png(64, 48, [100, 100, 100, 255]); + + let pipeline = Pipeline::builder() + .formats(vec![OutputFormat::Png, OutputFormat::Jpeg]) + .sizes(vec![ + SizeVariant::Original, + SizeVariant::parse("vignette:32").unwrap(), + ]) + .quality(90) + .build() + .expect("pipeline valide"); + + let chain = FilterChain::new(vec![Box::new(Luminosite { delta: 40 })]); + let output = pipeline + .run_bytes( + &png, + Origin::Memory { + label: "source".into(), + }, + &chain, + ) + .expect("traitement"); + + // 2 formats × 2 tailles. + assert_eq!(output.artifacts.len(), 4); + assert_eq!(output.applied_filters, vec!["luminosite"]); + + for artifact in &output.artifacts { + assert!(!artifact.bytes.is_empty(), "variante vide"); + assert!( + artifact.file_name.starts_with("relecture") || artifact.file_name.starts_with("source"), + "nom inattendu : {}", + artifact.file_name + ); + } + + // La vignette PNG : plus grand côté ramené à 32, effet du filtre visible. + let vignette = output + .artifacts + .iter() + .find(|a| a.target.format == OutputFormat::Png && a.target.size.label() == "vignette") + .expect("vignette png présente"); + assert_eq!(vignette.target.dimensions.longest_side(), 32); + + // Le PNG pleine taille : 100 + 40 = 140 sur chaque canal. + let plein = output + .artifacts + .iter() + .find(|a| a.target.format == OutputFormat::Png && a.target.size.label() == "original") + .expect("png original présent"); + let relu = analyze(&plein.bytes); + assert_eq!( + relu.buffer.pixel(0, 0), + Some(Rgba8::new(140, 140, 140, 255)) + ); +} + +#[test] +fn sans_filtre_le_pipeline_convertit_les_formats() { + let png = source_png(20, 10, [10, 120, 200, 255]); + + let pipeline = Pipeline::builder() + .formats(vec![OutputFormat::Png, OutputFormat::WebP]) + .build() + .unwrap(); + + let output = pipeline + .run_bytes( + &png, + Origin::Memory { + label: "src".into(), + }, + &FilterChain::empty(), + ) + .unwrap(); + + assert!(output.applied_filters.is_empty()); + assert_eq!(output.artifacts.len(), 2); + for artifact in &output.artifacts { + assert_eq!(artifact.target.dimensions.width, 20); + assert_eq!(artifact.target.dimensions.height, 10); + } +} + +#[test] +fn jpeg_ne_conserve_pas_la_transparence() { + let png = source_png(16, 16, [200, 50, 50, 0]); // entièrement transparent + + let pipeline = Pipeline::builder() + .formats(vec![OutputFormat::Jpeg]) + .build() + .unwrap(); + + let output = pipeline + .run_bytes( + &png, + Origin::Memory { + label: "src".into(), + }, + &FilterChain::empty(), + ) + .unwrap(); + + let jpeg = &output.artifacts[0]; + assert!(!analyze(&jpeg.bytes).metadata.stats.has_transparency); +} + +#[test] +fn un_flux_vide_est_rejete() { + let pipeline = Pipeline::new(); + let result = pipeline.run_bytes( + &[], + Origin::Memory { + label: "vide".into(), + }, + &FilterChain::empty(), + ); + assert!(result.is_err()); +} + +#[test] +fn la_borne_dimensionnelle_reduit_l_image_de_travail() { + let png = source_png(200, 120, [255, 255, 255, 255]); + + let pipeline = Pipeline::builder() + .formats(vec![OutputFormat::Png]) + .max_dimension(Some(50)) + .build() + .unwrap(); + + let output = pipeline + .run_bytes( + &png, + Origin::Memory { + label: "src".into(), + }, + &FilterChain::empty(), + ) + .unwrap(); + + assert_eq!(output.artifacts[0].target.dimensions.longest_side(), 50); + // La description conserve les dimensions d'origine. + assert_eq!(output.metadata.dimensions.width, 200); +} diff --git a/README.fr.md b/README.fr.md index 84da1f7..c70d485 100644 --- a/README.fr.md +++ b/README.fr.md @@ -1,89 +1,110 @@ # Filtro -Filtro est une application de traitement d’image qui transforme des photos classiques en œuvres visuelles inspirées du style **Pop Art d’Andy Warhol**. +Filtro est un outil de traitement d'image qui transforme des photos ordinaires en +œuvres visuelles inspirées du style **Pop Art d'Andy Warhol** : aplats de couleur +saturés et contraste franc. -L’objectif du projet est de proposer un outil simple et rapide permettant d’appliquer des effets graphiques colorés, contrastés et artistiques afin de créer des portraits uniques. +Il se présente comme un **outil en ligne de commande écrit en Rust** (dans +[`Filtro_Rust/`](Filtro_Rust/)) : un pipeline stable en quatre étapes et un jeu +de filtres qui s'ajoutent sans jamais toucher au cœur. ## ✨ Fonctionnalités -* 🖼️ Importation d’images -* 🎨 Application d’effets Pop Art inspirés d’Andy Warhol -* 🌈 Génération de palettes de couleurs vives -* ⚡ Traitement rapide des images -* 🖥️ Interface simple et intuitive -* 🌐 Application accessible depuis un navigateur - -## 🚀 Démonstration - -Essayez Filtro en ligne : - -https://alexandre-git-sdv.github.io/Filtro/ +* 🎨 Filtres de couleur Pop Art — `jaune`, `vert`, `rouge`, `noir-blanc` +* 🔗 Chaînage de filtres (`-f rouge -f noir-blanc`) +* 🖼️ Sortie multi-format en une passe — PNG, JPEG, WebP, BMP, TIFF +* 📐 Déclinaisons dimensionnelles (vignettes, tailles web) générées ensemble +* 🔄 Simple convertisseur de formats quand aucun filtre n'est donné +* ⚡ Traitement rapide en un seul passage ; `#![forbid(unsafe_code)]` ## 🛠️ Technologies utilisées -* **Python** — traitement d’image et logique applicative -* **Rust** — composants optimisés (selon les modules du projet) -* **HTML / CSS / JavaScript** — interface utilisateur -* **Docker** — environnement de déploiement +* **Rust** (édition 2024, `rust-version` 1.88) — pipeline et CLI +* [`image`](https://crates.io/crates/image) — décodage et encodage +* [`clap`](https://crates.io/crates/clap) — analyse des arguments +* [`thiserror`](https://crates.io/crates/thiserror) — types d'erreur ## 📦 Installation locale ### Prérequis -* Python 3.x +* Une chaîne d'outils Rust ≥ 1.88 ([rustup.rs](https://rustup.rs)) * Git -### Cloner le projet +### Cloner et compiler ```bash git clone https://github.com/Alexandre-git-SDV/Filtro.git -cd Filtro +cd Filtro/Filtro_Rust +cargo build --release ``` -### Installer les dépendances +Le binaire se trouve alors dans `Filtro_Rust/target/release/filtro`. -```bash -pip install -r requirements.txt -``` +## 🚀 Utilisation -### Lancer l’application +À lancer depuis `Filtro_Rust/` (ou en appelant directement le binaire compilé) : ```bash -python app.py +cargo run --release -- photo.jpg -f jaune +cargo run --release -- photo.jpg -f noir-blanc:niveau=128,mode=luminance +cargo run --release -- photo.jpg -f rouge -f noir-blanc # chaînage +cargo run --release -- photo.jpg --formats png,jpeg,webp \ + --size vignette:256 --size web:1200 --quality 90 -o sortie + +cargo run -- --list-filters ``` -L’application sera ensuite disponible localement selon la configuration du serveur. +| Filtre | Effet | +|--------|-------| +| `jaune` | blanc → jaune, canaux rouge et bleu échangés | +| `vert` | blanc → vert | +| `rouge` | blanc → rouge | +| `noir-blanc` | seuillage binaire (par canaux ou par luminance) | + +Les filtres de couleur acceptent `niveau` (seuil de blanc, défaut 180), `couleur` +(couleur de remplacement en hexadécimal) et `permutation`. `noir-blanc` accepte +`niveau`, `mode` (`canaux` / `luminance`), `clair` et `sombre`. Les paramètres +sont séparés par des virgules : les couleurs s'écrivent donc en hexadécimal +(`fefe01`), jamais en `254,254,1`. ## 📂 Structure du projet ```text Filtro/ -├── app.py # Application principale -├── requirements.txt # Dépendances Python -├── dockerfile # Configuration Docker -├── static/ # Ressources statiques -├── templates/ # Templates de l’interface -└── README.md # Documentation +└── Filtro_Rust/ # l'outil CLI — voir Filtro_Rust/README.md + ├── Cargo.toml + ├── src/ + │ ├── lib.rs # bibliothèque « filtro » (le Model) + façade publique + │ ├── main.rs # point d'entrée du binaire + │ ├── model/ # M — cœur : types d'image, pipeline en 4 étapes, contrat des filtres + │ ├── filters/ # les filtres, hors du cœur + │ ├── controller/ # C — analyse de la CLI et orchestration + │ └── view/ # V — affichage terminal + └── tests/pipeline.rs # test d'intégration bout en bout ``` +Le pipeline : `Analyzer → Constructor → Formatter → Renderer`. On ajoute un filtre +en implémentant `Filter` + `FilterFactory` dans `src/filters/` puis en +l'enregistrant en une ligne — le cœur ne change pas. Détails complets dans +[`Filtro_Rust/README.md`](Filtro_Rust/README.md). + ## 🎯 Objectif du projet -Filtro explore la rencontre entre programmation et création artistique en utilisant le traitement d’image pour transformer des photos ordinaires en compositions graphiques originales. +Filtro explore la rencontre entre programmation et création artistique en +utilisant le traitement d'image pour transformer des photos ordinaires en +compositions graphiques originales. ## 🤝 Contribution Les contributions sont les bienvenues : 1. Forkez le projet -2. Créez une branche : - -```bash -git checkout -b feature/nouvelle-fonctionnalite -``` - -3. Faites vos modifications +2. Créez une branche : `git checkout -b feature/nouvelle-fonctionnalite` +3. Faites vos modifications (`cargo test` et `cargo clippy` doivent rester au vert) 4. Créez une Pull Request ## 📄 Licence -Ce projet est open source. Consultez le dépôt pour plus d’informations sur la licence utilisée. +Ce projet est open source. Consultez le dépôt pour plus d'informations sur la +licence utilisée. diff --git a/README.md b/README.md index 19f2584..558144e 100644 --- a/README.md +++ b/README.md @@ -1,90 +1,109 @@ # Filtro -Filtro is an image processing application that transforms regular photos into visual artworks inspired by the **Pop Art style of Andy Warhol**. +Filtro is an image-processing tool that turns ordinary photos into visual +artworks inspired by the **Pop Art style of Andy Warhol**: flat, saturated +colours and hard contrast. -The goal of the project is to provide a simple and fast tool for applying colorful, high-contrast artistic effects to create unique portraits. +It ships as a **command-line tool written in Rust** (under +[`Filtro_Rust/`](Filtro_Rust/)): a stable four-stage pipeline and a set of +filters that can be added without ever touching the core. ## ✨ Features -* 🖼️ Image import -* 🎨 Pop Art effects inspired by Andy Warhol -* 🌈 Generation of vibrant color palettes -* ⚡ Fast image processing -* 🖥️ Simple and intuitive interface -* 🌐 Browser-based application - -## 🚀 Demo - -Try Filtro online: - -https://alexandre-git-sdv.github.io/Filtro/ +* 🎨 Pop Art colour filters — `jaune`, `vert`, `rouge`, `noir-blanc` +* 🔗 Filter chaining (`-f rouge -f noir-blanc`) +* 🖼️ Multi-format output in one run — PNG, JPEG, WebP, BMP, TIFF +* 📐 Size variants (thumbnails, web sizes) generated together +* 🔄 Runs as a plain format converter when no filter is given +* ⚡ Fast, single-pass processing; `#![forbid(unsafe_code)]` ## 🛠️ Technologies Used -* **Python** — image processing and application logic -* **Rust** — optimized components (depending on project modules) -* **HTML / CSS / JavaScript** — user interface -* **Docker** — deployment environment +* **Rust** (edition 2024, `rust-version` 1.88) — pipeline and CLI +* [`image`](https://crates.io/crates/image) — decoding and encoding +* [`clap`](https://crates.io/crates/clap) — argument parsing +* [`thiserror`](https://crates.io/crates/thiserror) — error types ## 📦 Local Installation ### Prerequisites -* Python 3.x +* A Rust toolchain ≥ 1.88 ([rustup.rs](https://rustup.rs)) * Git -### Clone the repository +### Clone and build ```bash git clone https://github.com/Alexandre-git-SDV/Filtro.git -cd Filtro +cd Filtro/Filtro_Rust +cargo build --release ``` -### Install dependencies +The binary is then at `Filtro_Rust/target/release/filtro`. -```bash -pip install -r requirements.txt -``` +## 🚀 Usage -### Run the application +Run from `Filtro_Rust/` (or call the built binary directly): ```bash -python app.py +cargo run --release -- photo.jpg -f jaune +cargo run --release -- photo.jpg -f noir-blanc:niveau=128,mode=luminance +cargo run --release -- photo.jpg -f rouge -f noir-blanc # chaining +cargo run --release -- photo.jpg --formats png,jpeg,webp \ + --size vignette:256 --size web:1200 --quality 90 -o out + +cargo run -- --list-filters ``` -The application will then be available locally depending on your server configuration. +| Filter | Effect | +|--------|--------| +| `jaune` | white → yellow, red/blue channels swapped | +| `vert` | white → green | +| `rouge` | white → red | +| `noir-blanc` | binary threshold (per-channel or luminance) | + +Colour filters accept `niveau` (white threshold, default 180), `couleur` (hex +replacement colour) and `permutation`. `noir-blanc` accepts `niveau`, `mode` +(`canaux` / `luminance`), `clair` and `sombre`. Parameters are comma-separated, +so colours are written in hex (`fefe01`), never as `254,254,1`. ## 📂 Project Structure ```text Filtro/ -├── app.py # Main application -├── requirements.txt # Python dependencies -├── dockerfile # Docker configuration -├── static/ # Static assets -├── templates/ # Interface templates -└── README.md # Documentation +└── Filtro_Rust/ # the CLI tool — see Filtro_Rust/README.md + ├── Cargo.toml + ├── src/ + │ ├── lib.rs # library "filtro" (the Model) + public façade + │ ├── main.rs # binary entry point + │ ├── model/ # M — core: pixel types, 4-stage pipeline, filter contract + │ ├── filters/ # the filters, outside the core + │ ├── controller/ # C — CLI parsing and orchestration + │ └── view/ # V — terminal output + └── tests/pipeline.rs # end-to-end integration test ``` +The pipeline: `Analyzer → Constructor → Formatter → Renderer`. Filters are added +by implementing `Filter` + `FilterFactory` in `src/filters/` and registering them +in one line — the core never changes. Full details in +[`Filtro_Rust/README.md`](Filtro_Rust/README.md). + ## 🎯 Project Purpose -Filtro explores the connection between programming and digital art by using image processing techniques to transform ordinary photos into original graphic compositions. +Filtro explores the connection between programming and digital art by using +image-processing techniques to transform ordinary photos into original graphic +compositions. ## 🤝 Contributing Contributions are welcome: 1. Fork the project -2. Create a new branch: - -```bash -git checkout -b feature/new-feature -``` - -3. Make your changes +2. Create a new branch: `git checkout -b feature/new-feature` +3. Make your changes (`cargo test` and `cargo clippy` should stay green) 4. Submit a Pull Request ## 📄 License -This project is open source. Check the repository for more information about the license. - +This project is open source. Check the repository for more information about the +license. diff --git a/index.html b/index.html index c11eaaa..dfe4fdf 100644 --- a/index.html +++ b/index.html @@ -75,7 +75,7 @@

À propos

const sourceCanvas = document.getElementById('source-canvas'); const workCanvas = document.getElementById('work-canvas'); - // --- Reproduit exactement la logique de apply_filter() côté Python --- + // --- Même logique de filtrage que l'outil CLI (seuil de blanc + permutation des canaux) --- function applyFilter(imageData, colorFilter) { const data = imageData.data; // [r,g,b,a, r,g,b,a, ...] for (let i = 0; i < data.length; i += 4) { diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index fff3f71..0000000 --- a/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -requests -# This file is used to install the required packages for the project. -Flask -Pillow