From f7ad9582f68045b5b9e1af7526d7bb633e7bf5d9 Mon Sep 17 00:00:00 2001 From: mathieu Date: Mon, 17 Aug 2026 23:32:05 +0200 Subject: [PATCH] Ajouter le scan camera du code-barres ISBN Le decodage EAN-13/EAN-8 est fait en C# par ZXing.Net (IsbnScanner), conformement a la decision actee dans CLAUDE.md : le JavaScript ne fournit que les pixels de la camera, aucune logique de decodage ne lui est confiee. Le scan se greffe en amont du flux d'ajout par ISBN existant : des qu'un code est lu et reconnu comme ISBN-13 valide, la recherche de notices est declenchee sans que l'utilisateur ait a retaper quoi que ce soit. La saisie manuelle reste accessible en permanence, y compris quand la camera echoue. Points traites explicitement : - camera arriere par defaut (facingMode "environment", en "ideal" pour ne pas echouer sur un PC sans camera arriere) ; - liberation de la camera a l'annulation, au succes et au demontage du composant ; - cadence limitee a ~12 images/s et decodage restreint a la bande centrale reduite a 640 px de large : inutile de decoder chaque frame en pleine taille ; - messages distincts pour permission refusee, absence de camera, camera occupee et contexte non securise (getUserMedia exige HTTPS ou localhost : une IP de reseau local en http ne marchera jamais, c'est une source de confusion garantie). Co-Authored-By: Claude Opus 5 --- .../Composants/ScannerCodeBarres.razor | 263 ++++++++++++++++++ MaBibli.Client/MaBibli.Client.csproj | 2 + MaBibli.Client/Pages/AjoutIsbn.razor | 30 +- MaBibli.Client/Scan/BancEssaiScan.cs | 40 +++ MaBibli.Client/Scan/IsbnScanner.cs | 53 ++++ MaBibli.Client/wwwroot/css/app.css | 33 +++ MaBibli.Client/wwwroot/js/scanner-camera.js | 61 ++++ MaBibli.Tests/IsbnScannerTests.cs | 102 +++++++ MaBibli.Tests/MaBibli.Tests.csproj | 3 + 9 files changed, 585 insertions(+), 2 deletions(-) create mode 100644 MaBibli.Client/Composants/ScannerCodeBarres.razor create mode 100644 MaBibli.Client/Scan/BancEssaiScan.cs create mode 100644 MaBibli.Client/Scan/IsbnScanner.cs create mode 100644 MaBibli.Client/wwwroot/js/scanner-camera.js create mode 100644 MaBibli.Tests/IsbnScannerTests.cs diff --git a/MaBibli.Client/Composants/ScannerCodeBarres.razor b/MaBibli.Client/Composants/ScannerCodeBarres.razor new file mode 100644 index 0000000..108adc3 --- /dev/null +++ b/MaBibli.Client/Composants/ScannerCodeBarres.razor @@ -0,0 +1,263 @@ +@* + Scan du code-barres ISBN par la caméra. + + Le JS (wwwroot/js/scanner-camera.js) ne fait QUE fournir des pixels : tout le décodage + est en C# via ZXing.Net (IsbnScanner), conformément à la décision actée dans CLAUDE.md. + + On ne décode pas à chaque frame de la caméra : la cadence est volontairement limitée + (voir IntervalleMs). 10-15 images/s suffisent largement pour scanner un livre tenu à la + main, et cela ménage la batterie du téléphone. +*@ +@using System.Diagnostics +@using MaBibli.Client.Scan +@using MaBibli.Shared.Isbn +@implements IAsyncDisposable +@inject IJSRuntime JS + +
+ @if (_etat == Etat.Erreur) + { + + } + else + { +
+ + +
+ +

+ @if (_etat == Etat.Demarrage) + { + Ouverture de la caméra… + } + else + { + Visez le code-barres au dos du livre, bien à plat et bien éclairé. + } +

+ + @if (_avertissementFormat is not null) + { +

@_avertissementFormat

+ } + + @if (_framesDecodees > 0) + { + @* Mesure réelle en WASM interprété : CLAUDE.md ne dispose que d'une estimation + (~5-10 ms/frame extrapolés du JIT x64). Cette ligne donne le chiffre constaté + sur l'appareil de l'utilisateur, seul moyen de décider d'activer l'AOT. *@ +

+ Décodage : @_msMoyen.ToString("0.0") ms/frame en moyenne (@_framesDecodees frames) +

+ } + } + +
+ +
+
+ +@code { + /// Appelé avec l'ISBN-13 dès qu'un code-barres exploitable est décodé. + [Parameter] public EventCallback OnCodeDetecte { get; set; } + + /// Appelé quand l'utilisateur renonce au scan. + [Parameter] public EventCallback OnAnnuler { get; set; } + + private enum Etat { Demarrage, Actif, Erreur } + + // ~12 images/s : bien assez pour scanner, et deux fois moins de travail que 25 fps. + private const int IntervalleMs = 80; + + // La frame est réduite avant transfert : un 1280×720 en RGBA pèse 3,6 Mo à traverser + // le pont JS→C# à chaque itération. 640 px de large suffisent à lire un EAN-13. + private const int LargeurMax = 640; + + // Seule la bande centrale est décodée : c'est là que l'utilisateur vise. + private const double PartHauteur = 0.45; + + private ElementReference _video; + private IJSObjectReference? _module; + private CancellationTokenSource? _boucle; + private Etat _etat = Etat.Demarrage; + private string? _erreur; + private string? _avertissementFormat; + private long _framesDecodees; + private double _totalMs; + private double _msMoyen => _framesDecodees == 0 ? 0 : _totalMs / _framesDecodees; + + protected override async Task OnAfterRenderAsync(bool premierRendu) + { + if (!premierRendu) + { + return; + } + + try + { + _module = await JS.InvokeAsync("import", "./js/scanner-camera.js"); + + var diagnostic = await _module.InvokeAsync("diagnostic"); + if (diagnostic != "ok") + { + Echouer(diagnostic switch + { + // Piège garanti en développement : depuis un téléphone, pointer l'IP locale + // du PC (http://192.168.x.x) n'est PAS un contexte sécurisé pour le navigateur. + "contexte-non-securise" => + "La caméra n'est accessible qu'en HTTPS (ou sur localhost). " + + "En développement, ouvrir l'application par l'adresse IP du PC ne suffit pas. " + + "Saisissez l'ISBN à la main.", + _ => "Ce navigateur ne donne pas accès à la caméra. Saisissez l'ISBN à la main.", + }); + return; + } + + await _module.InvokeVoidAsync("demarrer", _video); + _etat = Etat.Actif; + StateHasChanged(); + + _boucle = new CancellationTokenSource(); + _ = BoucleAsync(_boucle.Token); + } + catch (JSException ex) + { + // On ne laisse jamais un écran noir sans explication : le nom de l'erreur DOM + // dit précisément ce qui manque à l'utilisateur pour que ça marche. + Echouer(NomErreurDom(ex.Message) switch + { + "NotAllowedError" or "SecurityError" => + "L'accès à la caméra a été refusé. Autorisez-le dans les réglages du navigateur, " + + "ou saisissez l'ISBN à la main.", + "NotFoundError" or "DevicesNotFoundError" or "OverconstrainedError" => + "Aucune caméra utilisable n'a été trouvée sur cet appareil. Saisissez l'ISBN à la main.", + "NotReadableError" or "TrackStartError" => + "La caméra est déjà utilisée par une autre application. Fermez-la, ou saisissez l'ISBN à la main.", + _ => $"La caméra n'a pas pu démarrer ({ex.Message}). Saisissez l'ISBN à la main.", + }); + } + catch (Exception ex) + { + Echouer($"La caméra n'a pas pu démarrer ({ex.Message}). Saisissez l'ISBN à la main."); + } + } + + private async Task BoucleAsync(CancellationToken jeton) + { + try + { + while (!jeton.IsCancellationRequested) + { + await Task.Delay(IntervalleMs, jeton); + + if (_module is null || jeton.IsCancellationRequested) + { + return; + } + + var dimensions = await _module.InvokeAsync("capturer", LargeurMax, PartHauteur); + if (dimensions is null) + { + continue; // vidéo pas encore prête + } + + var pixels = await _module.InvokeAsync("pixels"); + + var depart = Stopwatch.GetTimestamp(); + var code = IsbnScanner.TryDecode(pixels, dimensions[0], dimensions[1]); + _totalMs += Stopwatch.GetElapsedTime(depart).TotalMilliseconds; + _framesDecodees++; + + if (code is null) + { + // Une frame sur dix suffit à rafraîchir la mesure sans repeindre en boucle. + if (_framesDecodees % 10 == 0) + { + StateHasChanged(); + } + continue; + } + + var isbn = IsbnUtils.Normaliser(code); + if (isbn is null || !IsbnUtils.EstIsbn13Valide(isbn)) + { + // Un EAN-8, ou l'EAN d'un produit non-livre : on le dit et on continue. + _avertissementFormat = $"Code lu (« {code} »), mais ce n'est pas un ISBN. Continuez à viser le code-barres du livre."; + StateHasChanged(); + continue; + } + + await ArreterCameraAsync(); + await OnCodeDetecte.InvokeAsync(isbn); + return; + } + } + catch (OperationCanceledException) + { + // Sortie normale : le composant a été quitté. + } + catch (JSDisconnectedException) + { + // La page se ferme, plus rien à libérer côté JS. + } + } + + private async Task AnnulerAsync() + { + await ArreterCameraAsync(); + await OnAnnuler.InvokeAsync(); + } + + private void Echouer(string message) + { + _erreur = message; + _etat = Etat.Erreur; + StateHasChanged(); + } + + private static string NomErreurDom(string message) + { + // Blazor sérialise l'erreur DOM sous la forme "NotAllowedError: Permission denied". + var separateur = message.IndexOf(':'); + return separateur > 0 ? message[..separateur].Trim() : message.Trim(); + } + + private async Task ArreterCameraAsync() + { + _boucle?.Cancel(); + _boucle = null; + + if (_module is null) + { + return; + } + + try + { + await _module.InvokeVoidAsync("arreter"); + } + catch (JSDisconnectedException) + { + } + } + + // Quitter l'écran doit libérer la caméra, pas seulement arrêter la boucle. + public async ValueTask DisposeAsync() + { + await ArreterCameraAsync(); + + if (_module is not null) + { + try + { + await _module.DisposeAsync(); + } + catch (JSDisconnectedException) + { + } + + _module = null; + } + } +} diff --git a/MaBibli.Client/MaBibli.Client.csproj b/MaBibli.Client/MaBibli.Client.csproj index 0bd012c..990f985 100644 --- a/MaBibli.Client/MaBibli.Client.csproj +++ b/MaBibli.Client/MaBibli.Client.csproj @@ -11,6 +11,8 @@ + + diff --git a/MaBibli.Client/Pages/AjoutIsbn.razor b/MaBibli.Client/Pages/AjoutIsbn.razor index c37479c..114ae9f 100644 --- a/MaBibli.Client/Pages/AjoutIsbn.razor +++ b/MaBibli.Client/Pages/AjoutIsbn.razor @@ -9,7 +9,7 @@ @if (_etape == Etape.Saisie) {

- Saisissez l'ISBN imprimé sur le livre. Le scan par la caméra arrivera plus tard. + Scannez le code-barres, ou saisissez l'ISBN imprimé sur le livre.

@@ -24,10 +24,20 @@ @onclick="ChercherAsync"> @(_chargement ? "Recherche…" : "Chercher") + @* La saisie manuelle reste le recours quand le code-barres est abîmé, absent, + ou que la caméra est indisponible : elle ne disparaît jamais derrière le scan. *@ + Saisir à la main
} +@if (_etape == Etape.Scan) +{ + +} + @if (_erreur is not null) { @@ -103,7 +113,7 @@ } @code { - private enum Etape { Saisie, Choix, Formulaire } + private enum Etape { Saisie, Scan, Choix, Formulaire } private Etape _etape = Etape.Saisie; private string _isbn = string.Empty; @@ -115,6 +125,22 @@ private IReadOnlyList _avertissements = []; private EnregistrementLivre _saisie = new(); + private void OuvrirScanner() + { + _erreur = null; + _avertissements = []; + _etape = Etape.Scan; + } + + // Le scan enchaîne directement sur le flux de lookup existant : l'utilisateur + // ne retape jamais ce qui vient d'être scanné. + private async Task SurCodeDetecteAsync(string isbn) + { + _isbn = isbn; + _etape = Etape.Saisie; + await ChercherAsync(); + } + private async Task SurTouche(KeyboardEventArgs e) { if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_isbn)) diff --git a/MaBibli.Client/Scan/BancEssaiScan.cs b/MaBibli.Client/Scan/BancEssaiScan.cs new file mode 100644 index 0000000..1ee0f0c --- /dev/null +++ b/MaBibli.Client/Scan/BancEssaiScan.cs @@ -0,0 +1,40 @@ +using System.Diagnostics; +using Microsoft.JSInterop; + +namespace MaBibli.Client.Scan; + +/// +/// Mesure du coût réel du décodage dans le navigateur. +/// +/// CLAUDE.md ne dispose que de chiffres JIT x64 (0,53 ms/frame dans le pire cas) et d'une +/// extrapolation non vérifiée pour le WASM interprété (~5-10 ms/frame). Ce banc d'essai +/// donne le chiffre constaté, sur la machine de l'utilisateur, sans caméra ni code-barres. +/// +/// On mesure volontairement le pire cas — une frame de bruit sans code-barres — +/// car c'est l'échec de décodage qui coûte le plus cher, et la quasi-totalité des frames +/// d'une caméra sont dans ce cas. C'est donc lui qui gouverne le framerate atteignable. +/// +/// Depuis la console du navigateur : +/// await DotNet.invokeMethodAsync('MaBibli.Client', 'MesurerScan', 640, 288, 20) +/// +public static class BancEssaiScan +{ + [JSInvokable("MesurerScan")] + public static double MesurerPireCasMs(int largeur, int hauteur, int iterations) + { + var rgba = new byte[largeur * hauteur * 4]; + new Random(1234).NextBytes(rgba); + + // Un tour à blanc : la première exécution paie l'initialisation de ZXing, + // qui n'a rien à voir avec le coût par frame en régime établi. + IsbnScanner.TryDecode(rgba, largeur, hauteur); + + var depart = Stopwatch.GetTimestamp(); + for (var i = 0; i < iterations; i++) + { + IsbnScanner.TryDecode(rgba, largeur, hauteur); + } + + return Stopwatch.GetElapsedTime(depart).TotalMilliseconds / iterations; + } +} diff --git a/MaBibli.Client/Scan/IsbnScanner.cs b/MaBibli.Client/Scan/IsbnScanner.cs new file mode 100644 index 0000000..bbae909 --- /dev/null +++ b/MaBibli.Client/Scan/IsbnScanner.cs @@ -0,0 +1,53 @@ +using ZXing; +using ZXing.Common; + +namespace MaBibli.Client.Scan; + +/// +/// Décodage d'un code-barres EAN-13 / EAN-8 à partir d'un buffer de pixels RGBA. +/// +/// Décision actée dans CLAUDE.md : le décodage reste **entièrement en C#** (ZXing.Net), +/// le JavaScript ne sert qu'à fournir les pixels de la caméra. Cette classe ne dépend +/// donc ni du navigateur, ni de Blazor : elle est réutilisable telle quelle hors WASM +/// (app native, décodage d'une photo côté serveur, scan en masse). +/// +public static class IsbnScanner +{ + // ZXing.Net n'est pas thread-safe sur ce lecteur, mais le WASM est mono-thread + // et la boucle de scan est séquentielle : une instance partagée suffit et évite + // de reconstruire les hints à chaque frame. + private static readonly MultiFormatReader Reader = new() + { + Hints = new Dictionary + { + [DecodeHintType.POSSIBLE_FORMATS] = new List + { + BarcodeFormat.EAN_13, BarcodeFormat.EAN_8, + }, + [DecodeHintType.TRY_HARDER] = true, + }, + }; + + /// + /// Tente de décoder un code-barres. Renvoie le texte brut du code, ou null + /// si la frame n'en contient aucun de lisible — cas très majoritaire en usage réel. + /// + /// Buffer brut issu de ctx.getImageData(...).data (4 octets par pixel). + public static string? TryDecode(byte[] rgba, int largeur, int hauteur) + { + ArgumentNullException.ThrowIfNull(rgba); + + if (largeur <= 0 || hauteur <= 0 || rgba.Length < largeur * hauteur * 4) + { + return null; + } + + // RGBLuminanceSource consomme directement le buffer du canvas : + // aucune bibliothèque d'image nécessaire (ni SkiaSharp, ni ImageSharp). + var source = new RGBLuminanceSource( + rgba, largeur, hauteur, RGBLuminanceSource.BitmapFormat.RGBA32); + + // La version .NET de ZXing renvoie null sur échec (pas d'exception). + return Reader.decode(new BinaryBitmap(new HybridBinarizer(source)))?.Text; + } +} diff --git a/MaBibli.Client/wwwroot/css/app.css b/MaBibli.Client/wwwroot/css/app.css index a86d6ee..2393ebd 100644 --- a/MaBibli.Client/wwwroot/css/app.css +++ b/MaBibli.Client/wwwroot/css/app.css @@ -449,3 +449,36 @@ body { flex: 0 0 auto; } } + +/* --- Scan du code-barres --- */ + +.scanner-cadre { + position: relative; + background: #000; + border-radius: 8px; + overflow: hidden; + aspect-ratio: 4 / 3; +} + +.scanner-video { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +/* Le viseur matérialise la bande réellement décodée : viser ailleurs ne sert à rien. */ +.scanner-viseur { + position: absolute; + left: 5%; + right: 5%; + top: 27.5%; + height: 45%; + border: 2px solid rgba(255, 255, 255, 0.85); + border-radius: 6px; + box-shadow: 0 0 0 100vmax rgba(0, 0, 0, 0.35); +} + +.scanner-mesure { + font-variant-numeric: tabular-nums; +} diff --git a/MaBibli.Client/wwwroot/js/scanner-camera.js b/MaBibli.Client/wwwroot/js/scanner-camera.js new file mode 100644 index 0000000..7c232a4 --- /dev/null +++ b/MaBibli.Client/wwwroot/js/scanner-camera.js @@ -0,0 +1,61 @@ +// Rôle unique de ce fichier : ouvrir la caméra et fournir des pixels à C#. +// AUCUN décodage ici — il est fait par ZXing.Net côté C# (décision actée dans CLAUDE.md). +// getUserMedia et canvas/getImageData n'ont pas d'équivalent C#, d'où ces quelques lignes. + +let flux = null; +let video = null; +let canvas = null; +let ctx = null; +let derniersPixels = null; + +// getUserMedia exige un contexte sécurisé : HTTPS, ou localhost. +// Une IP de réseau local (192.168.x.x) en http NE l'est PAS — piège classique en dev. +export function diagnostic() { + if (!window.isSecureContext) return 'contexte-non-securise'; + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return 'api-absente'; + return 'ok'; +} + +export async function demarrer(element) { + video = element; + // facingMode "environment" = caméra arrière : on scanne un livre tenu devant soi. + // "ideal" et non "exact" pour ne pas échouer sur un PC qui n'a qu'une webcam frontale. + flux = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } }, + audio: false, + }); + video.srcObject = flux; + video.setAttribute('playsinline', ''); + await video.play(); + canvas = document.createElement('canvas'); + ctx = canvas.getContext('2d', { willReadFrequently: true }); +} + +// Capture une frame et la garde en mémoire. Renvoie [largeur, hauteur], ou null si +// la vidéo n'est pas encore prête. Les pixels se récupèrent ensuite via pixels(). +export function capturer(largeurMax, partHauteur) { + if (!video || !ctx || video.readyState < 2 || !video.videoWidth) return null; + const echelle = Math.min(1, largeurMax / video.videoWidth); + const l = Math.round(video.videoWidth * echelle); + const h = Math.round(video.videoHeight * echelle); + if (canvas.width !== l || canvas.height !== h) { canvas.width = l; canvas.height = h; } + ctx.drawImage(video, 0, 0, l, h); + // On ne renvoie que la bande centrale, celle que l'utilisateur vise : moins de pixels + // à transférer et à décoder, donc moins de travail par frame côté C#. + const hb = Math.max(1, Math.round(h * partHauteur)); + const y = Math.round((h - hb) / 2); + derniersPixels = new Uint8Array(ctx.getImageData(0, y, l, hb).data.buffer); + return [l, hb]; +} + +export function pixels() { + return derniersPixels; +} + +// Libérer la caméra : sinon le voyant reste allumé et la batterie se vide, +// ce que l'utilisateur perçoit — à raison — comme un dysfonctionnement. +export function arreter() { + if (flux) { flux.getTracks().forEach(t => t.stop()); flux = null; } + if (video) { video.srcObject = null; video = null; } + canvas = null; ctx = null; derniersPixels = null; +} diff --git a/MaBibli.Tests/IsbnScannerTests.cs b/MaBibli.Tests/IsbnScannerTests.cs new file mode 100644 index 0000000..1b824f1 --- /dev/null +++ b/MaBibli.Tests/IsbnScannerTests.cs @@ -0,0 +1,102 @@ +using MaBibli.Client.Scan; +using ZXing.Common; +using ZXing.OneD; + +namespace MaBibli.Tests; + +/// +/// Tests du décodage pur (aucune caméra, aucun navigateur) : on encode un EAN-13 avec +/// ZXing, on le convertit en buffer RGBA comme le ferait getImageData, et on +/// vérifie le trajet aller-retour. Aucune image externe n'est nécessaire. +/// +public class IsbnScannerTests +{ + // Les ISBN de vérification du projet (voir CLAUDE.md). + [Theory] + [InlineData("9782070612758")] // Le Petit Prince + [InlineData("9782253004226")] // Germinal + [InlineData("9782080704092")] // Le Horla + [InlineData("9780262033848")] // Introduction to Algorithms + public void Decode_un_EAN13_genere(string isbn) + { + var (rgba, largeur, hauteur) = ImageEan13(isbn); + + Assert.Equal(isbn, IsbnScanner.TryDecode(rgba, largeur, hauteur)); + } + + [Fact] + public void Decode_aussi_un_EAN8() + { + // Pas un ISBN, mais le format est dans les hints : le décodeur doit le lire, + // c'est la couche appelante qui écarte les codes non-ISBN. + var matrice = new EAN8Writer().encode("96385074", ZXing.BarcodeFormat.EAN_8, 260, 160); + var (rgba, largeur, hauteur) = VersRgba(matrice); + + Assert.Equal("96385074", IsbnScanner.TryDecode(rgba, largeur, hauteur)); + } + + [Fact] + public void Renvoie_null_sur_une_frame_sans_code_barres() + { + // Cas très majoritaire en usage réel : la caméra filme autre chose. + // ZXing doit rendre null, jamais lever d'exception. + const int largeur = 640, hauteur = 480; + var rgba = new byte[largeur * hauteur * 4]; + var alea = new Random(1234); + alea.NextBytes(rgba); + + Assert.Null(IsbnScanner.TryDecode(rgba, largeur, hauteur)); + } + + [Fact] + public void Renvoie_null_sur_une_image_uniforme() + { + var rgba = new byte[320 * 200 * 4]; + Array.Fill(rgba, (byte)255); + + Assert.Null(IsbnScanner.TryDecode(rgba, 320, 200)); + } + + [Theory] + [InlineData(0, 100)] + [InlineData(100, 0)] + [InlineData(-5, 100)] + public void Renvoie_null_sur_des_dimensions_absurdes(int largeur, int hauteur) + => Assert.Null(IsbnScanner.TryDecode(new byte[4000], largeur, hauteur)); + + [Fact] + public void Renvoie_null_si_le_buffer_est_trop_court() + { + // Une frame tronquée ne doit pas faire tomber la boucle de scan. + Assert.Null(IsbnScanner.TryDecode(new byte[100], 640, 480)); + } + + private static (byte[] Rgba, int Largeur, int Hauteur) ImageEan13(string isbn) + => VersRgba(new EAN13Writer().encode(isbn, ZXing.BarcodeFormat.EAN_13, 380, 160)); + + /// + /// Convertit une BitMatrix en buffer RGBA, exactement la forme que le canvas + /// du navigateur fournit à C# (4 octets par pixel, opaque). + /// + private static (byte[] Rgba, int Largeur, int Hauteur) VersRgba(BitMatrix matrice) + { + var largeur = matrice.Width; + var hauteur = matrice.Height; + var rgba = new byte[largeur * hauteur * 4]; + + for (var y = 0; y < hauteur; y++) + { + for (var x = 0; x < largeur; x++) + { + var valeur = matrice[x, y] ? (byte)0 : (byte)255; + var i = ((y * largeur) + x) * 4; + rgba[i] = valeur; + rgba[i + 1] = valeur; + rgba[i + 2] = valeur; + rgba[i + 3] = 255; + } + } + + return (rgba, largeur, hauteur); + } +} diff --git a/MaBibli.Tests/MaBibli.Tests.csproj b/MaBibli.Tests/MaBibli.Tests.csproj index 758bbc5..212a6dc 100644 --- a/MaBibli.Tests/MaBibli.Tests.csproj +++ b/MaBibli.Tests/MaBibli.Tests.csproj @@ -26,6 +26,9 @@ + +