From 73eb8ead8881424a43dd1469d1c75547203346a4 Mon Sep 17 00:00:00 2001 From: Lionel Lalande Date: Mon, 6 Jul 2026 17:09:56 +0200 Subject: [PATCH 1/8] refactor(cli): improve console logging --- src/Cli/CustomConsoleFormatter.cs | 40 +++++++++++++++++++++++++++++++ src/Cli/Program.cs | 14 +++++------ 2 files changed, 47 insertions(+), 7 deletions(-) create mode 100644 src/Cli/CustomConsoleFormatter.cs diff --git a/src/Cli/CustomConsoleFormatter.cs b/src/Cli/CustomConsoleFormatter.cs new file mode 100644 index 0000000..258b87e --- /dev/null +++ b/src/Cli/CustomConsoleFormatter.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Console; + +namespace Anonymizer.Cli; + +internal sealed class CustomConsoleFormatter() : ConsoleFormatter(FormatterName) +{ + public const string FormatterName = "Custom"; + + public override void Write(in LogEntry logEntry, IExternalScopeProvider? scopeProvider, TextWriter textWriter) + { + var message = logEntry.Formatter(logEntry.State, logEntry.Exception); + if (message is null) return; + + var timestamp = DateTimeOffset.Now.ToString("HH:mm:ss"); + textWriter.Write($"[{timestamp}] "); + + var color = GetColor(logEntry.LogLevel); + textWriter.Write($"{color}{logEntry.LogLevel}{ResetColor}: "); + + textWriter.WriteLine(message); + + if (logEntry.Exception is not null) + textWriter.WriteLine(logEntry.Exception.Message); + } + + private static string GetColor(LogLevel level) => level switch + { + LogLevel.Trace => "\u001b[90m", // Gris fonce + LogLevel.Debug => "\u001b[37m", // Gris clair + LogLevel.Information => "\u001b[32m", // Vert + LogLevel.Warning => "\u001b[33m", // Jaune + LogLevel.Error => "\u001b[31m", // Rouge + LogLevel.Critical => "\u001b[97;41m", // Blanc sur fond rouge + _ => "\u001b[0m" + }; + + private const string ResetColor = "\u001b[0m"; +} diff --git a/src/Cli/Program.cs b/src/Cli/Program.cs index 4285ee4..7ac1e0d 100644 --- a/src/Cli/Program.cs +++ b/src/Cli/Program.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Console; using static Anonymizer.Cli.Internals.ConsoleA; @@ -32,13 +33,12 @@ builder.ConfigureLogging((builder) => { builder.ClearProviders(); - builder.AddFilter((category, level) => category?.StartsWith("Microsoft.") is false || level >= LogLevel.Error); - builder.AddSimpleConsole((options) => - { - options.SingleLine = true; - options.TimestampFormat = "[yyyy-MM-dd HH:mm:ss] "; - options.IncludeScopes = false; - }); + builder.SetMinimumLevel(LogLevel.Information); + builder.AddFilter("Microsoft", (level) => level >= LogLevel.Warning); + builder.AddFilter("System", (level) => level >= LogLevel.Warning); + builder.AddConsole((options) => + options.FormatterName = CustomConsoleFormatter.FormatterName); + builder.Services.AddSingleton(); }); RootCommand root = new(builder); From cbbadaf188d0a8ab2f67b14daac19e61a82b49cf Mon Sep 17 00:00:00 2001 From: Lionel Lalande Date: Mon, 6 Jul 2026 17:10:09 +0200 Subject: [PATCH 2/8] refactor(cli): extract setup lifecycle service --- src/Cli/Commands/InstallCommand.cs | 144 +---------- src/Cli/Commands/RootCommand.cs | 2 +- src/Cli/Commands/UninstallCommand.cs | 32 +-- src/Cli/Commands/UpdateCommand.cs | 164 +----------- src/Cli/Lifetime/ProcessManager.cs | 22 +- src/Cli/Lifetime/SetupLifecycleService.cs | 288 ++++++++++++++++++++++ src/Cli/Program.cs | 4 + 7 files changed, 324 insertions(+), 332 deletions(-) create mode 100644 src/Cli/Lifetime/SetupLifecycleService.cs diff --git a/src/Cli/Commands/InstallCommand.cs b/src/Cli/Commands/InstallCommand.cs index cecc319..1a9b6c2 100644 --- a/src/Cli/Commands/InstallCommand.cs +++ b/src/Cli/Commands/InstallCommand.cs @@ -1,6 +1,4 @@ using System.CommandLine; -using System.Diagnostics; -using System.IO.Compression; using System.Runtime.Versioning; using Anonymizer.Cli.Lifetime; @@ -30,147 +28,9 @@ public InstallCommand(IHostBuilder builder) : base("install", HelpDesc) SetAction((parseResult) => { var app = builder.Build(); + var setup = app.Services.GetRequiredService(); var noAutostart = parseResult.GetRequiredValue(_noAutostartOption); - var autostart = noAutostart ? null : app.Services.GetRequiredService(); - return Install(autostart); + return setup.InstallAsync(enableAutostart: !noAutostart); }); } - - private static async Task Install(IAutostartManager? autostart) - { - using var alreadyRunning = SingleInstance.TryAcquire("Setup"); - ProcessManager.KillRunningInstances(); - - var installDir = Application.Install.Dir; - var currentExeFile = Application.File; - Console.WriteLine($"✅ Installing {Application.Name} into: {installDir}"); - - installDir.Create(); - - var (payloadSize, payloadStart) = GetPayloadInfo(currentExeFile); - var payloadZipFile = ExtractPayloadToTemp(currentExeFile, payloadStart, payloadSize); - - ExtractZipSafely(payloadZipFile, installDir); - - var anonymizerPath = CreateAnonymizerWithoutPayload(currentExeFile, installDir, payloadStart); - - Metadata metadata = new(DateTime.UtcNow, typeof(InstallCommand).Assembly.GetName().Version, anonymizerPath.FullName); - await metadata.Save(); - - RunDownload(anonymizerPath); - - autostart?.SetAutostart(true); - - Console.WriteLine("📁 Installation completed."); - } - - private static (long payloadSize, long payloadStart) GetPayloadInfo(FileInfo exeFile) - { - using var fs = exeFile.Open(FileMode.Open, FileAccess.Read, FileShare.Read); - - fs.Seek(-8, SeekOrigin.End); - Span sizeBytes = stackalloc byte[8]; - fs.ReadExactly(sizeBytes); - long payloadSize = BitConverter.ToInt64(sizeBytes); - - var magicBytes = System.Text.Encoding.UTF8.GetBytes(Magic); - fs.Seek(-(8 + magicBytes.Length), SeekOrigin.End); - Span magicRead = stackalloc byte[magicBytes.Length]; - fs.ReadExactly(magicRead); - - if (!magicRead.SequenceEqual(magicBytes)) - throw new InvalidOperationException("Invalid setup file: payload footer not found."); - - long payloadStart = fs.Length - (8 + magicBytes.Length + payloadSize); - return (payloadSize, payloadStart); - } - - private static FileInfo ExtractPayloadToTemp(FileInfo exeFile, long payloadStart, long payloadSize) - { - FileInfo tempFile = new(Path.Combine(Path.GetTempPath(), "anonymizer-payload.zip")); - - using var fs = exeFile.Open(FileMode.Open, FileAccess.Read, FileShare.Read); - using var outFs = tempFile.Open(FileMode.Create, FileAccess.Write); - - fs.Seek(payloadStart, SeekOrigin.Begin); - - var buffer = new byte[81920]; - long remaining = payloadSize; - while (remaining > 0) - { - int toRead = (int)Math.Min(buffer.Length, remaining); - int read = fs.Read(buffer, 0, toRead); - if (read is 0) break; - outFs.Write(buffer, 0, read); - remaining -= read; - } - - return tempFile; - } - - private static void ExtractZipSafely(FileInfo zipFile, DirectoryInfo installDir) - { - using var archive = ZipFile.OpenRead(zipFile.FullName); - - foreach (var entry in archive.Entries) - { - if (entry.FullName.Equals("appsettings.json", StringComparison.OrdinalIgnoreCase)) - continue; - - if (entry.FullName.Equals("metadata.json", StringComparison.OrdinalIgnoreCase)) - continue; - - string destinationPath = Path.Combine(installDir.FullName, entry.FullName); - - Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); - - if (string.IsNullOrEmpty(entry.Name)) - continue; - - entry.ExtractToFile(destinationPath, overwrite: true); - } - } - - private static FileInfo CreateAnonymizerWithoutPayload(FileInfo exeFile, DirectoryInfo installDir, long payloadStart) - { - FileInfo targetFile = new(Path.Combine( - installDir.FullName, - Path.ChangeExtension(Application.Name, OperatingSystem.IsWindows() ? ".exe" : string.Empty) - )); - - using var input = exeFile.Open(FileMode.Open, FileAccess.Read, FileShare.Read); - using var output = targetFile.Open(FileMode.Create, FileAccess.Write); - - var buffer = new byte[81920]; - long remaining = payloadStart; - while (remaining > 0) - { - int toRead = (int)Math.Min(buffer.Length, remaining); - int read = input.Read(buffer, 0, toRead); - if (read == 0) break; - output.Write(buffer, 0, read); - remaining -= read; - } - - if (!OperatingSystem.IsWindows()) - Process.Start("chmod", $"+x \"{targetFile}\"")?.WaitForExit(); - - return targetFile; - } - - private static void RunDownload(FileInfo anonymizerFile) - { - var psi = new ProcessStartInfo - { - FileName = anonymizerFile.FullName, - ArgumentList = { "download" }, - WorkingDirectory = anonymizerFile.Directory!.FullName, - UseShellExecute = false, - }; - - var p = Process.Start(psi); - p?.WaitForExit(); - } - - private const string Magic = "SETUP-PAYLOAD"; } diff --git a/src/Cli/Commands/RootCommand.cs b/src/Cli/Commands/RootCommand.cs index 83c3180..e024bdd 100644 --- a/src/Cli/Commands/RootCommand.cs +++ b/src/Cli/Commands/RootCommand.cs @@ -26,7 +26,7 @@ public RootCommand(IHostBuilder builder) : base(HelpDesc) { Add(new InstallCommand(builder)); Add(new UninstallCommand(builder)); - Add(new UpdateCommand()); + Add(new UpdateCommand(builder)); } } diff --git a/src/Cli/Commands/UninstallCommand.cs b/src/Cli/Commands/UninstallCommand.cs index 7a3312a..0ee3167 100644 --- a/src/Cli/Commands/UninstallCommand.cs +++ b/src/Cli/Commands/UninstallCommand.cs @@ -20,35 +20,7 @@ public UninstallCommand(IHostBuilder builder) : base("uninstall", HelpDesc) => SetAction((parseResult) => { var app = builder.Build(); - var autostart = app.Services.GetRequiredService(); - Uninstall(autostart); + var setup = app.Services.GetRequiredService(); + setup.Uninstall(); }); - - private static void Uninstall(IAutostartManager autostart) - { - using var alreadyRunning = SingleInstance.TryAcquire("Setup"); - - DirectoryInfo installDir = Application.Install.Dir; - if (!installDir.Exists) - { - Console.WriteLine($"No installation found at: {installDir}"); - return; - } - - Console.WriteLine($"Uninstalling {Application.Name} from: {installDir}"); - - ProcessManager.KillRunningInstances(); - - try - { - autostart.SetAutostart(false); - - installDir.Delete(recursive: true); - Console.WriteLine("Uninstallation complete."); - } - catch (Exception ex) - { - Console.WriteLine($"Failed to uninstall: {ex.Message}"); - } - } } diff --git a/src/Cli/Commands/UpdateCommand.cs b/src/Cli/Commands/UpdateCommand.cs index ee4332e..398c9e3 100644 --- a/src/Cli/Commands/UpdateCommand.cs +++ b/src/Cli/Commands/UpdateCommand.cs @@ -1,10 +1,11 @@ using System.CommandLine; -using System.Diagnostics; -using System.IO.Compression; using System.Runtime.Versioning; using Anonymizer.Cli.Lifetime; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + namespace Anonymizer.Cli.Commands; [SupportedOSPlatform("Linux")] @@ -15,158 +16,11 @@ internal sealed class UpdateCommand : Command Updates the application in the user directory if a newer version is available. """; - private const string Magic = "SETUP-PAYLOAD"; - - public UpdateCommand() : base("update", HelpDesc) => - SetAction(_ => Update()); - - private static async Task Update() - { - using var alreadyRunning = SingleInstance.TryAcquire("Setup"); - - var installDir = Application.Install.Dir; - if (!installDir.Exists) - { - Console.WriteLine($"No installation found at: {installDir}"); - Console.WriteLine("Run 'anonymizer install' first."); - return; - } - - var metadataFile = Application.Metadata.File; - if (!metadataFile.Exists) - { - Console.WriteLine("Installation metadata not found. Cannot determine installed version."); - return; - } - - var installedMetadata = await Metadata.Load(); - Version? installedVersion = installedMetadata.Version; - - Version currentVersion = typeof(UpdateCommand).Assembly.GetName().Version!; - if (installedVersion is not null && installedVersion >= currentVersion) - { - Console.WriteLine("Already up to date."); - return; - } - - Console.WriteLine("A new version is available. Updating..."); - - ProcessManager.KillRunningInstances(); - - string tempPath = Path.Combine(Path.GetTempPath(), "anonymizer-update"); - DirectoryInfo tempDir = new(tempPath); - if (tempDir.Exists) - tempDir.Delete(recursive: true); - tempDir.Create(); - - string setupName = OperatingSystem.IsWindows() ? "setup.exe" : "setup"; - Uri releaseSetupUri = new(Models.BaseUri, setupName); - FileInfo downloadedSetup = new(Path.Combine(tempPath, setupName)); - Console.WriteLine($"Downloading: {releaseSetupUri}"); - - using (var http = new HttpClient()) - using (var stream = await http.GetStreamAsync(releaseSetupUri)) - using (var file = downloadedSetup.Open(FileMode.Create, FileAccess.Write)) - await stream.CopyToAsync(file); - - var (payloadSize, payloadStart) = GetPayloadInfo(downloadedSetup); - - var payloadZip = ExtractPayloadToTemp(downloadedSetup, payloadStart, payloadSize); - ZipFile.ExtractToDirectory(payloadZip.OpenRead(), installDir.FullName, overwriteFiles: true); - - var anonymizerPath = CreateAnonymizerWithoutPayload(downloadedSetup, installDir, payloadStart); - - Metadata metadata = new(DateTime.UtcNow, currentVersion, anonymizerPath.FullName); - await metadata.Save(); - RunDownload(anonymizerPath); - - tempDir.Delete(recursive: true); - - Console.WriteLine("Update complete."); - } - - private static (long payloadSize, long payloadStart) GetPayloadInfo(FileInfo exeFile) - { - using var fs = exeFile.Open(FileMode.Open, FileAccess.Read); - - fs.Seek(-8, SeekOrigin.End); - Span sizeBytes = stackalloc byte[8]; - fs.ReadExactly(sizeBytes); - long payloadSize = BitConverter.ToInt64(sizeBytes); - - var magicBytes = System.Text.Encoding.UTF8.GetBytes(Magic); - fs.Seek(-(8 + magicBytes.Length), SeekOrigin.End); - Span magicRead = stackalloc byte[magicBytes.Length]; - fs.ReadExactly(magicRead); - - if (!magicRead.SequenceEqual(magicBytes)) - throw new InvalidOperationException("Invalid setup file: payload footer not found."); - - long payloadStart = fs.Length - (8 + magicBytes.Length + payloadSize); - return (payloadSize, payloadStart); - } - - private static FileInfo ExtractPayloadToTemp(FileInfo exeFile, long payloadStart, long payloadSize) - { - FileInfo tempFile = new(Path.Combine(Path.GetTempPath(), "anonymizer-update-payload.zip")); - - using var fs = exeFile.Open(FileMode.Open, FileAccess.Read); - using var outFs = tempFile.Open(FileMode.Create, FileAccess.Write); - - fs.Seek(payloadStart, SeekOrigin.Begin); - - var buffer = new byte[81920]; - long remaining = payloadSize; - while (remaining > 0) + public UpdateCommand(IHostBuilder builder) : base("update", HelpDesc) => + SetAction((_) => { - int toRead = (int)Math.Min(buffer.Length, remaining); - int read = fs.Read(buffer, 0, toRead); - if (read is 0) break; - outFs.Write(buffer, 0, read); - remaining -= read; - } - - return tempFile; - } - - private static FileInfo CreateAnonymizerWithoutPayload(FileInfo exeFile, DirectoryInfo installDir, long payloadStart) - { - FileInfo targetFile = new(Path.Combine( - installDir.FullName, - Path.ChangeExtension(Application.Name, OperatingSystem.IsWindows() ? ".exe" : string.Empty) - )); - - using var input = exeFile.Open(FileMode.Open, FileAccess.Read); - using var output = targetFile.Open(FileMode.Create, FileAccess.Write); - - var buffer = new byte[81920]; - long remaining = payloadStart; - while (remaining > 0) - { - int toRead = (int)Math.Min(buffer.Length, remaining); - int read = input.Read(buffer, 0, toRead); - if (read == 0) break; - output.Write(buffer, 0, read); - remaining -= read; - } - - if (!OperatingSystem.IsWindows()) - Process.Start("chmod", $"+x \"{targetFile}\"")?.WaitForExit(); - - return targetFile; - } - - private static void RunDownload(FileInfo anonymizerFile) - { - var psi = new ProcessStartInfo - { - FileName = anonymizerFile.FullName, - ArgumentList = { "download" }, - WorkingDirectory = anonymizerFile.Directory!.FullName, - UseShellExecute = false, - }; - - var p = Process.Start(psi); - p?.WaitForExit(); - } + var app = builder.Build(); + var setup = app.Services.GetRequiredService(); + return setup.UpdateAsync(); + }); } diff --git a/src/Cli/Lifetime/ProcessManager.cs b/src/Cli/Lifetime/ProcessManager.cs index 7e4cc6e..701ec7f 100644 --- a/src/Cli/Lifetime/ProcessManager.cs +++ b/src/Cli/Lifetime/ProcessManager.cs @@ -1,23 +1,37 @@ using System.Diagnostics; +using Microsoft.Extensions.Logging; + namespace Anonymizer.Cli.Lifetime; -internal static class ProcessManager +internal sealed partial class ProcessManager(ILogger logger) { - public static void KillRunningInstances() + public void KillRunningInstances() { foreach (var process in Process.GetProcessesByName(Application.Name)) { try { - Console.WriteLine($"Stopping running instance: PID {process.Id}"); + LogStoppingProcess(process.Id); process.Kill(entireProcessTree: true); process.WaitForExit(5000); } catch (Exception ex) { - Console.WriteLine($"Failed to stop process {process.Id}: {ex.Message}"); + LogFailedToStopProcess(ex, process.Id); } } } + +#pragma warning disable CA1822 + + [LoggerMessage(LogLevel.Information, "Stopping running instance: PID {ProcessId}")] + private partial void LogStoppingProcess(int processId); + + [LoggerMessage(LogLevel.Warning, "Failed to stop process {ProcessId}.")] + private partial void LogFailedToStopProcess(Exception exception, int processId); + +#pragma warning restore CA1822 + + private readonly ILogger _logger = logger; } diff --git a/src/Cli/Lifetime/SetupLifecycleService.cs b/src/Cli/Lifetime/SetupLifecycleService.cs new file mode 100644 index 0000000..2344a0a --- /dev/null +++ b/src/Cli/Lifetime/SetupLifecycleService.cs @@ -0,0 +1,288 @@ +using System.Diagnostics; +using System.IO.Compression; +using System.Runtime.Versioning; + +using Microsoft.Extensions.Logging; + +namespace Anonymizer.Cli.Lifetime; + +[SupportedOSPlatform("Linux")] +[SupportedOSPlatform("Windows")] +internal sealed partial class SetupLifecycleService( + ProcessManager processManager, + ILogger logger, + IAutostartManager? autostartManager = null) +{ + public async Task InstallAsync(bool enableAutostart) + { + using var alreadyRunning = SingleInstance.TryAcquire("Setup"); + processManager.KillRunningInstances(); + + var installDir = Application.Install.Dir; + var currentExeFile = Application.File; + LogInstalling(Application.Name, installDir.FullName); + + installDir.Create(); + + var (payloadSize, payloadStart) = GetPayloadInfo(currentExeFile); + var payloadZipFile = ExtractPayloadToTemp(currentExeFile, payloadStart, payloadSize); + + ExtractZipSafely(payloadZipFile, installDir); + + var anonymizerPath = CreateAnonymizerWithoutPayload(currentExeFile, installDir, payloadStart); + + Metadata metadata = new(DateTime.UtcNow, typeof(SetupLifecycleService).Assembly.GetName().Version, anonymizerPath.FullName); + await metadata.Save(); + + RunDownload(anonymizerPath); + + if (enableAutostart) + autostartManager?.SetAutostart(true); + + LogInstallationCompleted(); + } + + public void Uninstall() + { + using var alreadyRunning = SingleInstance.TryAcquire("Setup"); + + DirectoryInfo installDir = Application.Install.Dir; + if (!installDir.Exists) + { + LogNoInstallationFound(installDir.FullName); + return; + } + + LogUninstalling(Application.Name, installDir.FullName); + + processManager.KillRunningInstances(); + + try + { + autostartManager?.SetAutostart(false); + + installDir.Delete(recursive: true); + LogUninstallationComplete(); + } + catch (Exception ex) + { + LogUninstallFailure(ex); + } + } + + public async Task UpdateAsync() + { + using var alreadyRunning = SingleInstance.TryAcquire("Setup"); + + var installDir = Application.Install.Dir; + if (!installDir.Exists) + { + LogNoInstallationFound(installDir.FullName); + LogRunInstallFirst(); + return; + } + + var metadataFile = Application.Metadata.File; + if (!metadataFile.Exists) + { + LogMetadataNotFound(); + return; + } + + var installedMetadata = await Metadata.Load(); + Version? installedVersion = installedMetadata.Version; + + Version currentVersion = typeof(SetupLifecycleService).Assembly.GetName().Version!; + if (installedVersion is not null && installedVersion >= currentVersion) + { + LogAlreadyUpToDate(); + return; + } + + LogUpdateStarting(); + + processManager.KillRunningInstances(); + + string tempPath = Path.Combine(Path.GetTempPath(), "anonymizer-update"); + DirectoryInfo tempDir = new(tempPath); + if (tempDir.Exists) + tempDir.Delete(recursive: true); + tempDir.Create(); + + string setupName = OperatingSystem.IsWindows() ? "setup.exe" : "setup"; + Uri releaseSetupUri = new(Models.BaseUri, setupName); + FileInfo downloadedSetup = new(Path.Combine(tempPath, setupName)); + LogDownloading(releaseSetupUri.ToString()); + + using (var http = new HttpClient()) + using (var stream = await http.GetStreamAsync(releaseSetupUri)) + using (var file = downloadedSetup.Open(FileMode.Create, FileAccess.Write)) + await stream.CopyToAsync(file); + + var (payloadSize, payloadStart) = GetPayloadInfo(downloadedSetup); + + var payloadZip = ExtractPayloadToTemp(downloadedSetup, payloadStart, payloadSize); + ZipFile.ExtractToDirectory(payloadZip.OpenRead(), installDir.FullName, overwriteFiles: true); + + var anonymizerPath = CreateAnonymizerWithoutPayload(downloadedSetup, installDir, payloadStart); + + Metadata metadata = new(DateTime.UtcNow, currentVersion, anonymizerPath.FullName); + await metadata.Save(); + RunDownload(anonymizerPath); + + tempDir.Delete(recursive: true); + + LogUpdateComplete(); + } + + private static (long payloadSize, long payloadStart) GetPayloadInfo(FileInfo exeFile) + { + using var fs = exeFile.Open(FileMode.Open, FileAccess.Read, FileShare.Read); + + fs.Seek(-8, SeekOrigin.End); + Span sizeBytes = stackalloc byte[8]; + fs.ReadExactly(sizeBytes); + long payloadSize = BitConverter.ToInt64(sizeBytes); + + var magicBytes = System.Text.Encoding.UTF8.GetBytes(Magic); + fs.Seek(-(8 + magicBytes.Length), SeekOrigin.End); + Span magicRead = stackalloc byte[magicBytes.Length]; + fs.ReadExactly(magicRead); + + if (!magicRead.SequenceEqual(magicBytes)) + throw new InvalidOperationException("Invalid setup file: payload footer not found."); + + long payloadStart = fs.Length - (8 + magicBytes.Length + payloadSize); + return (payloadSize, payloadStart); + } + + private static FileInfo ExtractPayloadToTemp(FileInfo exeFile, long payloadStart, long payloadSize) + { + FileInfo tempFile = new(Path.Combine(Path.GetTempPath(), "anonymizer-payload.zip")); + + using var fs = exeFile.Open(FileMode.Open, FileAccess.Read, FileShare.Read); + using var outFs = tempFile.Open(FileMode.Create, FileAccess.Write); + + fs.Seek(payloadStart, SeekOrigin.Begin); + + var buffer = new byte[81920]; + long remaining = payloadSize; + while (remaining > 0) + { + int toRead = (int)Math.Min(buffer.Length, remaining); + int read = fs.Read(buffer, 0, toRead); + if (read is 0) break; + outFs.Write(buffer, 0, read); + remaining -= read; + } + + return tempFile; + } + + private static void ExtractZipSafely(FileInfo zipFile, DirectoryInfo installDir) + { + using var archive = ZipFile.OpenRead(zipFile.FullName); + + foreach (var entry in archive.Entries) + { + if (entry.FullName.Equals("appsettings.json", StringComparison.OrdinalIgnoreCase)) + continue; + + if (entry.FullName.Equals("metadata.json", StringComparison.OrdinalIgnoreCase)) + continue; + + string destinationPath = Path.Combine(installDir.FullName, entry.FullName); + + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + + if (string.IsNullOrEmpty(entry.Name)) + continue; + + entry.ExtractToFile(destinationPath, overwrite: true); + } + } + + private static FileInfo CreateAnonymizerWithoutPayload(FileInfo exeFile, DirectoryInfo installDir, long payloadStart) + { + FileInfo targetFile = new(Path.Combine( + installDir.FullName, + Path.ChangeExtension(Application.Name, OperatingSystem.IsWindows() ? ".exe" : string.Empty) + )); + + using var input = exeFile.Open(FileMode.Open, FileAccess.Read, FileShare.Read); + using var output = targetFile.Open(FileMode.Create, FileAccess.Write); + + var buffer = new byte[81920]; + long remaining = payloadStart; + while (remaining > 0) + { + int toRead = (int)Math.Min(buffer.Length, remaining); + int read = input.Read(buffer, 0, toRead); + if (read == 0) break; + output.Write(buffer, 0, read); + remaining -= read; + } + + if (!OperatingSystem.IsWindows()) + Process.Start("chmod", $"+x \"{targetFile}\"")?.WaitForExit(); + + return targetFile; + } + + private static void RunDownload(FileInfo anonymizerFile) + { + var psi = new ProcessStartInfo + { + FileName = anonymizerFile.FullName, + ArgumentList = { "download" }, + WorkingDirectory = anonymizerFile.Directory!.FullName, + UseShellExecute = false, + }; + + var p = Process.Start(psi); + p?.WaitForExit(); + } + +#pragma warning disable CA1822 + + [LoggerMessage(LogLevel.Information, "Installing {AppName} into: {InstallDir}")] + private partial void LogInstalling(string appName, string installDir); + + [LoggerMessage(LogLevel.Information, "Installation completed.")] + private partial void LogInstallationCompleted(); + + [LoggerMessage(LogLevel.Warning, "No installation found at: {InstallDir}")] + private partial void LogNoInstallationFound(string installDir); + + [LoggerMessage(LogLevel.Information, "Run 'anonymizer install' first.")] + private partial void LogRunInstallFirst(); + + [LoggerMessage(LogLevel.Warning, "Installation metadata not found. Cannot determine installed version.")] + private partial void LogMetadataNotFound(); + + [LoggerMessage(LogLevel.Information, "Already up to date.")] + private partial void LogAlreadyUpToDate(); + + [LoggerMessage(LogLevel.Information, "A new version is available. Updating...")] + private partial void LogUpdateStarting(); + + [LoggerMessage(LogLevel.Information, "Downloading: {ReleaseSetupUri}")] + private partial void LogDownloading(string releaseSetupUri); + + [LoggerMessage(LogLevel.Information, "Update complete.")] + private partial void LogUpdateComplete(); + + [LoggerMessage(LogLevel.Information, "Uninstalling {AppName} from: {InstallDir}")] + private partial void LogUninstalling(string appName, string installDir); + + [LoggerMessage(LogLevel.Information, "Uninstallation complete.")] + private partial void LogUninstallationComplete(); + + [LoggerMessage(LogLevel.Error, "Failed to uninstall.")] + private partial void LogUninstallFailure(Exception exception); + +#pragma warning restore CA1822 + + private readonly ILogger _logger = logger; + private const string Magic = "SETUP-PAYLOAD"; +} diff --git a/src/Cli/Program.cs b/src/Cli/Program.cs index 7ac1e0d..53701ed 100644 --- a/src/Cli/Program.cs +++ b/src/Cli/Program.cs @@ -19,6 +19,10 @@ { services.AddHttpClient(); services.AddSingleton(); + services.AddSingleton(); + + if (OperatingSystem.IsWindows() || OperatingSystem.IsLinux()) + services.AddSingleton(); if (Path.GetFileNameWithoutExtension(Environment.ProcessPath) is "setup") { From 7771d8c6392e315028794e300aa5afda775826fe Mon Sep 17 00:00:00 2001 From: Lionel Lalande Date: Mon, 6 Jul 2026 17:10:17 +0200 Subject: [PATCH 3/8] fix(cli): refresh model downloads reliably --- src/Cli/Commands/DownloadCommand.cs | 14 +++++--------- src/Cli/Downloaders/ModelDownloader.cs | 13 +------------ src/Cli/Internals/ProgressBar.cs | 4 ++-- 3 files changed, 8 insertions(+), 23 deletions(-) diff --git a/src/Cli/Commands/DownloadCommand.cs b/src/Cli/Commands/DownloadCommand.cs index 18012bf..dbc3ace 100644 --- a/src/Cli/Commands/DownloadCommand.cs +++ b/src/Cli/Commands/DownloadCommand.cs @@ -8,7 +8,7 @@ namespace Anonymizer.Cli.Commands; -internal sealed class DownloadCommand : Command +internal sealed partial class DownloadCommand : Command { private const string HelpDesc = """ Download PII NER and face-recognition models. @@ -20,16 +20,13 @@ public DownloadCommand(IHostBuilder builder) : base("download", HelpDesc) => var app = builder.Build(); var modelDownloader = app.Services.GetRequiredService(); - var piiModelTask = DownloadAndUncompress(modelDownloader, Models.PII.Model.RemoteUri, Application.Models.PII.Dir, cancellationToken); - var faceModelTask = DownloadAndUncompress(modelDownloader, Models.Face.Model.RemoteUri, Application.Models.Face.Dir, cancellationToken); - await Task.WhenAll(piiModelTask, faceModelTask); + await DownloadAndUncompress(modelDownloader, Models.Face.Model.RemoteUri, Application.Models.Face.Dir, cancellationToken); + await DownloadAndUncompress(modelDownloader, Models.PII.Model.RemoteUri, Application.Models.PII.Dir, cancellationToken); }); private static async Task DownloadAndUncompress(ModelDownloader modelDownloader, Uri remoteUri, DirectoryInfo outputDir, CancellationToken cancellationToken) { - DirectoryInfo? modelsRoot = outputDir.Parent; - if (modelsRoot is null) - throw new InvalidOperationException("Unable to resolve models root directory."); + outputDir.Create(); string tempDirPath = Path.Combine(Path.GetTempPath(), "anonymizer-models"); Directory.CreateDirectory(tempDirPath); @@ -42,7 +39,7 @@ private static async Task DownloadAndUncompress(ModelDownloader modelDownloader, try { await modelDownloader.DownloadAsync(remoteUri, archiveFile, cancellationToken); - ZipFile.ExtractToDirectory(archiveFile.FullName, modelsRoot.FullName, overwriteFiles: true); + ZipFile.ExtractToDirectory(archiveFile.FullName, outputDir.FullName, overwriteFiles: true); } finally { @@ -74,4 +71,3 @@ private static void EnsureExpectedFiles(Uri remoteUri, DirectoryInfo outputDir) } } } - diff --git a/src/Cli/Downloaders/ModelDownloader.cs b/src/Cli/Downloaders/ModelDownloader.cs index b931864..7156b87 100644 --- a/src/Cli/Downloaders/ModelDownloader.cs +++ b/src/Cli/Downloaders/ModelDownloader.cs @@ -9,13 +9,6 @@ internal sealed partial class ModelDownloader(HttpClient http, ILogger Date: Mon, 6 Jul 2026 19:38:04 +0200 Subject: [PATCH 4/8] refactor(cli): unify namespace from Anonymizer.Cli to Anonymizer --- src/Cli/Anonymizer.Cli.csproj | 2 +- src/Cli/AnonymizerService.cs | 4 ++-- src/Cli/Application.cs | 2 +- src/Cli/Commands/ConfigCommand.cs | 2 +- src/Cli/Commands/ConfigGetCommand.cs | 2 +- src/Cli/Commands/ConfigSetCommand.cs | 2 +- src/Cli/Commands/DownloadCommand.cs | 4 ++-- src/Cli/Commands/InputArgument.cs | 2 +- src/Cli/Commands/InstallCommand.cs | 4 ++-- src/Cli/Commands/RootCommand.cs | 2 +- src/Cli/Commands/RunCommand.cs | 2 +- src/Cli/Commands/StartCommand.cs | 4 ++-- src/Cli/Commands/UninstallCommand.cs | 4 ++-- src/Cli/Commands/UpdateCommand.cs | 4 ++-- src/Cli/Core/NativeMethods.cs | 2 +- src/Cli/CustomConsoleFormatter.cs | 2 +- src/Cli/Downloaders/ModelDownloader.cs | 4 ++-- src/Cli/FilesWatcher.cs | 2 +- src/Cli/FilesWatcherOptions.cs | 2 +- src/Cli/Internals/ConsoleA.cs | 2 +- src/Cli/Internals/ProgressBar.cs | 2 +- src/Cli/Lifetime/IAutostartManager.cs | 2 +- src/Cli/Lifetime/LinuxAutostart.cs | 2 +- src/Cli/Lifetime/Metadata.cs | 2 +- src/Cli/Lifetime/ProcessManager.cs | 2 +- src/Cli/Lifetime/SetupLifecycleService.cs | 4 +++- src/Cli/Lifetime/SingleInstance.cs | 2 +- src/Cli/Lifetime/WindowsAutostart.cs | 2 +- src/Cli/Models.cs | 2 +- src/Cli/Program.cs | 10 +++++----- 30 files changed, 43 insertions(+), 41 deletions(-) diff --git a/src/Cli/Anonymizer.Cli.csproj b/src/Cli/Anonymizer.Cli.csproj index 1a92a61..8246192 100644 --- a/src/Cli/Anonymizer.Cli.csproj +++ b/src/Cli/Anonymizer.Cli.csproj @@ -7,7 +7,7 @@ enable enable true - Anonymizer.Cli + Anonymizer diff --git a/src/Cli/AnonymizerService.cs b/src/Cli/AnonymizerService.cs index f177312..ce746b1 100644 --- a/src/Cli/AnonymizerService.cs +++ b/src/Cli/AnonymizerService.cs @@ -1,8 +1,8 @@ -using Anonymizer.Cli.Core; +using Anonymizer.Core; using Microsoft.Extensions.Logging; -namespace Anonymizer.Cli; +namespace Anonymizer; internal sealed partial class AnonymizerService(ILogger logger) { diff --git a/src/Cli/Application.cs b/src/Cli/Application.cs index 217e898..b2e7983 100644 --- a/src/Cli/Application.cs +++ b/src/Cli/Application.cs @@ -3,7 +3,7 @@ using static System.IO.Path; -namespace Anonymizer.Cli; +namespace Anonymizer; internal static class Application { diff --git a/src/Cli/Commands/ConfigCommand.cs b/src/Cli/Commands/ConfigCommand.cs index 9e2a8c9..c12c3e2 100644 --- a/src/Cli/Commands/ConfigCommand.cs +++ b/src/Cli/Commands/ConfigCommand.cs @@ -2,7 +2,7 @@ using Microsoft.Extensions.Hosting; -namespace Anonymizer.Cli.Commands; +namespace Anonymizer.Commands; internal sealed class ConfigCommand : Command { diff --git a/src/Cli/Commands/ConfigGetCommand.cs b/src/Cli/Commands/ConfigGetCommand.cs index d8af88d..a51607b 100644 --- a/src/Cli/Commands/ConfigGetCommand.cs +++ b/src/Cli/Commands/ConfigGetCommand.cs @@ -4,7 +4,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -namespace Anonymizer.Cli.Commands; +namespace Anonymizer.Commands; internal sealed class ConfigGetCommand : Command { diff --git a/src/Cli/Commands/ConfigSetCommand.cs b/src/Cli/Commands/ConfigSetCommand.cs index ee0f3b1..b7533aa 100644 --- a/src/Cli/Commands/ConfigSetCommand.cs +++ b/src/Cli/Commands/ConfigSetCommand.cs @@ -6,7 +6,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -namespace Anonymizer.Cli.Commands; +namespace Anonymizer.Commands; internal sealed class ConfigSetCommand : Command { diff --git a/src/Cli/Commands/DownloadCommand.cs b/src/Cli/Commands/DownloadCommand.cs index dbc3ace..c3f5fc2 100644 --- a/src/Cli/Commands/DownloadCommand.cs +++ b/src/Cli/Commands/DownloadCommand.cs @@ -1,12 +1,12 @@ using System.CommandLine; using System.IO.Compression; -using Anonymizer.Cli.Downloaders; +using Anonymizer.Downloaders; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -namespace Anonymizer.Cli.Commands; +namespace Anonymizer.Commands; internal sealed partial class DownloadCommand : Command { diff --git a/src/Cli/Commands/InputArgument.cs b/src/Cli/Commands/InputArgument.cs index 5a748a5..28d7eba 100644 --- a/src/Cli/Commands/InputArgument.cs +++ b/src/Cli/Commands/InputArgument.cs @@ -1,6 +1,6 @@ using System.CommandLine; -namespace Anonymizer.Cli.Commands; +namespace Anonymizer.Commands; internal sealed class InputArgument : Argument { diff --git a/src/Cli/Commands/InstallCommand.cs b/src/Cli/Commands/InstallCommand.cs index 1a9b6c2..5700134 100644 --- a/src/Cli/Commands/InstallCommand.cs +++ b/src/Cli/Commands/InstallCommand.cs @@ -1,12 +1,12 @@ using System.CommandLine; using System.Runtime.Versioning; -using Anonymizer.Cli.Lifetime; +using Anonymizer.Lifetime; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -namespace Anonymizer.Cli.Commands; +namespace Anonymizer.Commands; [SupportedOSPlatform("Linux")] [SupportedOSPlatform("Windows")] diff --git a/src/Cli/Commands/RootCommand.cs b/src/Cli/Commands/RootCommand.cs index e024bdd..e69d89b 100644 --- a/src/Cli/Commands/RootCommand.cs +++ b/src/Cli/Commands/RootCommand.cs @@ -2,7 +2,7 @@ using Microsoft.Extensions.Hosting; -namespace Anonymizer.Cli.Commands; +namespace Anonymizer.Commands; internal sealed class RootCommand : System.CommandLine.RootCommand { diff --git a/src/Cli/Commands/RunCommand.cs b/src/Cli/Commands/RunCommand.cs index 9970e9f..696708f 100644 --- a/src/Cli/Commands/RunCommand.cs +++ b/src/Cli/Commands/RunCommand.cs @@ -3,7 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -namespace Anonymizer.Cli.Commands; +namespace Anonymizer.Commands; internal sealed class RunCommand : Command { diff --git a/src/Cli/Commands/StartCommand.cs b/src/Cli/Commands/StartCommand.cs index cb05677..2196e29 100644 --- a/src/Cli/Commands/StartCommand.cs +++ b/src/Cli/Commands/StartCommand.cs @@ -1,11 +1,11 @@ using System.CommandLine; -using Anonymizer.Cli.Lifetime; +using Anonymizer.Lifetime; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -namespace Anonymizer.Cli.Commands; +namespace Anonymizer.Commands; internal sealed class StartCommand : Command { diff --git a/src/Cli/Commands/UninstallCommand.cs b/src/Cli/Commands/UninstallCommand.cs index 0ee3167..b669533 100644 --- a/src/Cli/Commands/UninstallCommand.cs +++ b/src/Cli/Commands/UninstallCommand.cs @@ -1,12 +1,12 @@ using System.CommandLine; using System.Runtime.Versioning; -using Anonymizer.Cli.Lifetime; +using Anonymizer.Lifetime; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -namespace Anonymizer.Cli.Commands; +namespace Anonymizer.Commands; [SupportedOSPlatform("Linux")] [SupportedOSPlatform("Windows")] diff --git a/src/Cli/Commands/UpdateCommand.cs b/src/Cli/Commands/UpdateCommand.cs index 398c9e3..66d7b2a 100644 --- a/src/Cli/Commands/UpdateCommand.cs +++ b/src/Cli/Commands/UpdateCommand.cs @@ -1,12 +1,12 @@ using System.CommandLine; using System.Runtime.Versioning; -using Anonymizer.Cli.Lifetime; +using Anonymizer.Lifetime; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -namespace Anonymizer.Cli.Commands; +namespace Anonymizer.Commands; [SupportedOSPlatform("Linux")] [SupportedOSPlatform("Windows")] diff --git a/src/Cli/Core/NativeMethods.cs b/src/Cli/Core/NativeMethods.cs index 7530d08..1884b94 100644 --- a/src/Cli/Core/NativeMethods.cs +++ b/src/Cli/Core/NativeMethods.cs @@ -1,6 +1,6 @@ using System.Runtime.InteropServices; -namespace Anonymizer.Cli.Core; +namespace Anonymizer.Core; internal static unsafe partial class NativeMethods { diff --git a/src/Cli/CustomConsoleFormatter.cs b/src/Cli/CustomConsoleFormatter.cs index 258b87e..3dc8897 100644 --- a/src/Cli/CustomConsoleFormatter.cs +++ b/src/Cli/CustomConsoleFormatter.cs @@ -2,7 +2,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Console; -namespace Anonymizer.Cli; +namespace Anonymizer; internal sealed class CustomConsoleFormatter() : ConsoleFormatter(FormatterName) { diff --git a/src/Cli/Downloaders/ModelDownloader.cs b/src/Cli/Downloaders/ModelDownloader.cs index 7156b87..0664fd2 100644 --- a/src/Cli/Downloaders/ModelDownloader.cs +++ b/src/Cli/Downloaders/ModelDownloader.cs @@ -1,8 +1,8 @@ -using Anonymizer.Cli.Internals; +using Anonymizer.Internals; using Microsoft.Extensions.Logging; -namespace Anonymizer.Cli.Downloaders; +namespace Anonymizer.Downloaders; internal sealed partial class ModelDownloader(HttpClient http, ILogger logger) { diff --git a/src/Cli/FilesWatcher.cs b/src/Cli/FilesWatcher.cs index 6dfcae7..be41526 100644 --- a/src/Cli/FilesWatcher.cs +++ b/src/Cli/FilesWatcher.cs @@ -2,7 +2,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -namespace Anonymizer.Cli; +namespace Anonymizer; internal sealed partial class FilesWatcher : BackgroundService { diff --git a/src/Cli/FilesWatcherOptions.cs b/src/Cli/FilesWatcherOptions.cs index 54edde3..3e829a2 100644 --- a/src/Cli/FilesWatcherOptions.cs +++ b/src/Cli/FilesWatcherOptions.cs @@ -1,4 +1,4 @@ -namespace Anonymizer.Cli; +namespace Anonymizer; internal sealed class FilesWatcherOptions { diff --git a/src/Cli/Internals/ConsoleA.cs b/src/Cli/Internals/ConsoleA.cs index d8d19ce..463f6e6 100644 --- a/src/Cli/Internals/ConsoleA.cs +++ b/src/Cli/Internals/ConsoleA.cs @@ -1,6 +1,6 @@ using System.Runtime.InteropServices; -namespace Anonymizer.Cli.Internals; +namespace Anonymizer.Internals; internal static partial class ConsoleA { diff --git a/src/Cli/Internals/ProgressBar.cs b/src/Cli/Internals/ProgressBar.cs index 628057c..3accab1 100644 --- a/src/Cli/Internals/ProgressBar.cs +++ b/src/Cli/Internals/ProgressBar.cs @@ -1,6 +1,6 @@ using System.Diagnostics; -namespace Anonymizer.Cli.Internals; +namespace Anonymizer.Internals; internal sealed class ProgressBar(int width = 30) { diff --git a/src/Cli/Lifetime/IAutostartManager.cs b/src/Cli/Lifetime/IAutostartManager.cs index a01d496..35ce5c2 100644 --- a/src/Cli/Lifetime/IAutostartManager.cs +++ b/src/Cli/Lifetime/IAutostartManager.cs @@ -1,4 +1,4 @@ -namespace Anonymizer.Cli.Lifetime; +namespace Anonymizer.Lifetime; internal interface IAutostartManager { diff --git a/src/Cli/Lifetime/LinuxAutostart.cs b/src/Cli/Lifetime/LinuxAutostart.cs index 018f007..ba42e62 100644 --- a/src/Cli/Lifetime/LinuxAutostart.cs +++ b/src/Cli/Lifetime/LinuxAutostart.cs @@ -1,6 +1,6 @@ using System.Runtime.Versioning; -namespace Anonymizer.Cli.Lifetime; +namespace Anonymizer.Lifetime; [SupportedOSPlatform("Linux")] internal sealed class LinuxAutostart : IAutostartManager diff --git a/src/Cli/Lifetime/Metadata.cs b/src/Cli/Lifetime/Metadata.cs index b681876..911f744 100644 --- a/src/Cli/Lifetime/Metadata.cs +++ b/src/Cli/Lifetime/Metadata.cs @@ -2,7 +2,7 @@ using System.Text.Json; using System.Text.Json.Serialization; -namespace Anonymizer.Cli.Lifetime; +namespace Anonymizer.Lifetime; [SupportedOSPlatform("Linux")] [SupportedOSPlatform("Windows")] diff --git a/src/Cli/Lifetime/ProcessManager.cs b/src/Cli/Lifetime/ProcessManager.cs index 701ec7f..6066303 100644 --- a/src/Cli/Lifetime/ProcessManager.cs +++ b/src/Cli/Lifetime/ProcessManager.cs @@ -2,7 +2,7 @@ using Microsoft.Extensions.Logging; -namespace Anonymizer.Cli.Lifetime; +namespace Anonymizer.Lifetime; internal sealed partial class ProcessManager(ILogger logger) { diff --git a/src/Cli/Lifetime/SetupLifecycleService.cs b/src/Cli/Lifetime/SetupLifecycleService.cs index 2344a0a..30753b0 100644 --- a/src/Cli/Lifetime/SetupLifecycleService.cs +++ b/src/Cli/Lifetime/SetupLifecycleService.cs @@ -2,9 +2,11 @@ using System.IO.Compression; using System.Runtime.Versioning; +using Anonymizer.Lifetime; + using Microsoft.Extensions.Logging; -namespace Anonymizer.Cli.Lifetime; +namespace Anonymizer.Lifetime; [SupportedOSPlatform("Linux")] [SupportedOSPlatform("Windows")] diff --git a/src/Cli/Lifetime/SingleInstance.cs b/src/Cli/Lifetime/SingleInstance.cs index 2e5a964..8c2821d 100644 --- a/src/Cli/Lifetime/SingleInstance.cs +++ b/src/Cli/Lifetime/SingleInstance.cs @@ -1,6 +1,6 @@ using System.Runtime.Versioning; -namespace Anonymizer.Cli.Lifetime; +namespace Anonymizer.Lifetime; internal static class SingleInstance { diff --git a/src/Cli/Lifetime/WindowsAutostart.cs b/src/Cli/Lifetime/WindowsAutostart.cs index 6c47b50..4bafb7b 100644 --- a/src/Cli/Lifetime/WindowsAutostart.cs +++ b/src/Cli/Lifetime/WindowsAutostart.cs @@ -2,7 +2,7 @@ using Microsoft.Win32; -namespace Anonymizer.Cli.Lifetime; +namespace Anonymizer.Lifetime; [SupportedOSPlatform("Windows")] internal sealed class WindowsAutostart : IAutostartManager diff --git a/src/Cli/Models.cs b/src/Cli/Models.cs index fb23edd..8eab0a8 100644 --- a/src/Cli/Models.cs +++ b/src/Cli/Models.cs @@ -1,4 +1,4 @@ -namespace Anonymizer.Cli; +namespace Anonymizer; internal static class Models { diff --git a/src/Cli/Program.cs b/src/Cli/Program.cs index 53701ed..4793fed 100644 --- a/src/Cli/Program.cs +++ b/src/Cli/Program.cs @@ -1,7 +1,7 @@ -using Anonymizer.Cli; -using Anonymizer.Cli.Commands; -using Anonymizer.Cli.Downloaders; -using Anonymizer.Cli.Lifetime; +using Anonymizer; +using Anonymizer.Commands; +using Anonymizer.Downloaders; +using Anonymizer.Lifetime; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -9,7 +9,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Console; -using static Anonymizer.Cli.Internals.ConsoleA; +using static Anonymizer.Internals.ConsoleA; AttachToConsole(); Console.OutputEncoding = Console.InputEncoding = System.Text.Encoding.UTF8; From 69799f33ffb0713714d2c515ecb41075ec10d269 Mon Sep 17 00:00:00 2001 From: Lionel Lalande Date: Mon, 6 Jul 2026 20:38:50 +0200 Subject: [PATCH 5/8] feat(cli): update to better Windows support --- .github/workflows/build-csharp.yml | 15 ++++++- Anonymizer.slnx | 1 + README.md | 54 ++++++++---------------- scripts/install.ps1 | 17 ++++---- scripts/install.sh | 13 +++--- src/Cli/Anonymizer.Cli.csproj | 14 +++++- src/Cli/Commands/RootCommand.cs | 3 ++ src/Cli/Commands/StartCommand.Execute.cs | 29 +++++++++++++ src/Cli/Commands/StartCommand.Linux.cs | 21 +++++++++ src/Cli/Commands/StartCommand.cs | 37 ---------------- src/Cli/Lifetime/ProcessManager.cs | 27 ++++++++---- src/Cli/Lifetime/WindowsAutostart.cs | 3 +- src/Win/Anonymizer.Win.csproj | 42 ++++++++++++++++++ src/Win/Program.cs | 28 ++++++++++++ src/Win/Properties/launchSettings.json | 10 +++++ src/Win/appsettings.json | 3 ++ 16 files changed, 217 insertions(+), 100 deletions(-) create mode 100644 src/Cli/Commands/StartCommand.Execute.cs create mode 100644 src/Cli/Commands/StartCommand.Linux.cs delete mode 100644 src/Cli/Commands/StartCommand.cs create mode 100644 src/Win/Anonymizer.Win.csproj create mode 100644 src/Win/Program.cs create mode 100644 src/Win/Properties/launchSettings.json create mode 100644 src/Win/appsettings.json diff --git a/.github/workflows/build-csharp.yml b/.github/workflows/build-csharp.yml index 9828a94..97f86e6 100644 --- a/.github/workflows/build-csharp.yml +++ b/.github/workflows/build-csharp.yml @@ -67,6 +67,18 @@ jobs: -p:Version=$VERSION \ -o artifacts/${{ matrix.prefix }} + - name: Build .Net (${{ matrix.target }}) + if: matrix.target == 'windows' + shell: bash + run: | + VERSION="${{ steps.version.outputs.version }}" + dotnet publish \ + src/Win \ + -c Release \ + -r ${{ matrix.rid }} \ + -p:Version=$VERSION \ + -o artifacts/${{ matrix.prefix }} + - name: Create setup-linux.zip if: github.event_name != 'pull_request' && matrix.target == 'linux' shell: bash @@ -80,7 +92,8 @@ jobs: run: | Set-Location "artifacts/${{ matrix.prefix }}" $items = @( - "appsettings.json" + "appsettings.json", + "anonymizerw.exe", "Anonymizer.Core.dll", "onnxruntime.dll", "onnxruntime_providers_shared.dll") diff --git a/Anonymizer.slnx b/Anonymizer.slnx index a694832..5128fc6 100644 --- a/Anonymizer.slnx +++ b/Anonymizer.slnx @@ -1,5 +1,6 @@ + diff --git a/README.md b/README.md index 09257cb..c5ba59c 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ It runs as a lightweight daemon on Windows and Linux, with a unified installer a - Cross‑platform (Windows, Linux) - Unified installer (Pwsh, Bash) - Automatic OS detection -- Automatic service installation +- Automatic background start - Self‑contained setup bundles (setup.zip) - Semantic Versioning powered by git-cliff - GitHub Actions automated release pipeline @@ -35,13 +35,12 @@ curl -sL https://github.com/LsquaredTechnologies/Anonymizer/releases/latest/down The installer will automatically: - Detect Windows or Linux (WSL) -- Download win/setup.zip -- Extract it into %LOCALAPPDATA%/anonymizer +- Download setup.exe +- Copy it into %LOCALAPPDATA%/anonymizer - Run: ```pwsh - setup.exe install anonymizer - setup.exe service install anonymizer + setup.exe install ``` ### Linux @@ -52,36 +51,23 @@ Bash curl -sL https://github.com/LsquaredTechnologies/Anonymizer/releases/latest/download/install.sh | bash ``` -Zsh - -```zsh -curl -sL https://github.com/LsquaredTechnologies/Anonymizer/releases/latest/download/install.zsh | zsh -``` - The installer will automatically: - Detect Linux (including WSL) -- Download linux/setup.zip -- Extract it into ~/.local/share/anonymizer +- Download setup +- Copy it into ~/.local/share/anonymizer - Run: ```shell - ./anonymizer install anonymizer - ./anonymizer service install anonymizer + ./setup install ``` ## 🛠️ Debugging -### Check service status - -```shell -anonymizer status -``` - ### Run in foreground (debug mode) ```shell -anonymizer run --verbose +anonymizer run ``` ### View logs @@ -108,25 +94,23 @@ curl -sL https://github.com/LsquaredTechnologies/Anonymizer/releases/latest/down The installer will automatically: -- Download the latest setup.zip +- Download the latest setup binary - Replace binaries -- Restart the service +- Restart the background process ## ❌ Uninstalling ### Linux ```shell -anonymizer service uninstall -anonymizer uninstall +setup uninstall rm -rf ~/.local/share/anonymizer ``` ### Windows ```pwsh -setup.exe service uninstall anonymizer -setup.exe uninstall anonymizer +setup.exe uninstall Remove-Item "$env:LOCALAPPDATA\anonymizer" -Recurse -Force ``` @@ -139,12 +123,8 @@ Remove-Item "$env:LOCALAPPDATA\anonymizer" -Recurse -Force │ ├── install.ps1 │ └── install.sh └── src/ - └── Anonymizer.Cli/ - └── scripts - ├── anonymizer.py - ├── download_model.py - ├── downloader.py - └── face_detector.py + ├── Anonymizer.Cli/ + └── Anonymizer.Win/ ``` ## 🧪 Development @@ -158,13 +138,13 @@ dotnet build ### Run ```shell -dotnet run --project src/Anonymizer +dotnet run --project src/Cli -- run ``` -### Run service locally +### Run background watcher locally ```shell -dotnet run --project src/Anonymizer.Service +dotnet run --project src/Win ``` ## 🚀 Release Workflow diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 814b19a..efc5f7b 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -10,15 +10,15 @@ $Repo = "Anonymizer" Write-Host "Detecting OS..." if ($PSVersionTable.OS -match "Linux") { $OS = "linux" - $Suffix = "" $InstallDir = "$HOME/.local/share/anonymizer" $BinaryName = "setup" + $AnonName = Join-Path $InstallDir "anonymizer" } else { $OS = "windows" - $Suffix = ".exe" $InstallDir = Join-Path $env:LOCALAPPDATA "anonymizer" $BinaryName = "setup.exe" + $AnonName = Join-Path $InstallDir "anonymizerw.exe" } if ($Version -eq "latest") { @@ -53,8 +53,6 @@ if ($OS -eq "linux") { chmod +x $FinalSetup } -$AnonCmd = Join-Path $InstallDir ("anonymizer" + $Suffix) - $running = Get-Process | Where-Object { $_.ProcessName -like "anonymizer*" } 2>$null if ($running) { Write-Host "Stopping running instance..." @@ -64,8 +62,11 @@ if ($running) { Write-Host "Running installer..." & $FinalSetup install -Write-Host "Downloading models..." -& $FinalSetup download - Write-Host "Launching Anonymizer..." -Start-Process $AnonCmd -ArgumentList "start" -WindowStyle Hidden +if ($OS -eq "windows") { + Start-Process "cmd.exe" -ArgumentList "/c start `"`" /b anonymizerw" +} +else { + chmod +x $AnonName + Start-Process $AnonName -ArgumentList "start" -WindowStyle Hidden +} diff --git a/scripts/install.sh b/scripts/install.sh index aab5f60..1855c20 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -6,7 +6,7 @@ VERSION="${1:-latest}" OWNER="LsquaredTechnologies" REPO="Anonymizer" -echo "🔍 Detecting OS: $OS" +echo "🔍 Detecting OS..." UNAME="$(uname -s | tr '[:upper:]' '[:lower:]')" case "$UNAME" in linux*) @@ -19,7 +19,7 @@ case "$UNAME" in OS="windows" INSTALL_DIR="${LOCALAPPDATA}/anonymizer" BINARY_NAME="setup.exe" - ANON_NAME="anonymizer.exe" + ANON_NAME="anonymizerw.exe" ;; *) echo "❌ Unsupported OS: $UNAME" @@ -58,8 +58,9 @@ fi echo "⚙️ Running installer..." "$SETUP" install -echo "📥 Downloading models..." -"$SETUP" download - echo "🚀 Launching Anonymizer..." -nohup "$ANON" start >/dev/null 2>&1 & +if [ "$OS" = "windows" ]; then + nohup "$ANON" >/dev/null 2>&1 & +else + nohup "$ANON" start >/dev/null 2>&1 & +fi diff --git a/src/Cli/Anonymizer.Cli.csproj b/src/Cli/Anonymizer.Cli.csproj index 8246192..b0e7a9f 100644 --- a/src/Cli/Anonymizer.Cli.csproj +++ b/src/Cli/Anonymizer.Cli.csproj @@ -1,7 +1,7 @@  - WinExe + Exe anonymizer net10.0 enable @@ -22,6 +22,18 @@ full + + $(DefineConstants);WINDOWS + + + + $(DefineConstants);LINUX + + + + + + diff --git a/src/Cli/Commands/RootCommand.cs b/src/Cli/Commands/RootCommand.cs index e69d89b..55a3a6d 100644 --- a/src/Cli/Commands/RootCommand.cs +++ b/src/Cli/Commands/RootCommand.cs @@ -18,7 +18,10 @@ public RootCommand(IHostBuilder builder) : base(HelpDesc) Add(new DownloadCommand(builder)); Add(new RunCommand(builder)); Add(new ConfigCommand(builder)); + +#if LINUX Add(new StartCommand(builder)); +#endif if (OperatingSystem.IsWindows() || OperatingSystem.IsLinux()) { diff --git a/src/Cli/Commands/StartCommand.Execute.cs b/src/Cli/Commands/StartCommand.Execute.cs new file mode 100644 index 0000000..88302bc --- /dev/null +++ b/src/Cli/Commands/StartCommand.Execute.cs @@ -0,0 +1,29 @@ +using Anonymizer.Lifetime; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +using static Anonymizer.Internals.ConsoleA; + +namespace Anonymizer.Commands; + +internal sealed partial class StartCommand +{ + public static async Task ExecuteAsync(IHostBuilder builder) + { + AttachToConsole(); + using var alreadyRunning = SingleInstance.TryAcquire("AppStart"); + if (!alreadyRunning.IsAcquired) + { + Console.Error.WriteLine("Anonymizer is already running."); + return; + } + + builder.ConfigureServices((context, services) => + { + services.AddHostedService(); + services.Configure(context.Configuration); + }); + await builder.Build().RunAsync(); + } +} diff --git a/src/Cli/Commands/StartCommand.Linux.cs b/src/Cli/Commands/StartCommand.Linux.cs new file mode 100644 index 0000000..fff57c1 --- /dev/null +++ b/src/Cli/Commands/StartCommand.Linux.cs @@ -0,0 +1,21 @@ +using System.CommandLine; + +using Anonymizer.Lifetime; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Anonymizer.Commands; + +internal sealed partial class StartCommand : Command +{ + private const string HelpDesc = """ + Starts the application in the background. + """; + + public StartCommand(IHostBuilder builder) : base("start", HelpDesc) + { + Hidden = true; + SetAction(async (_) => await ExecuteAsync(builder)); + } +} diff --git a/src/Cli/Commands/StartCommand.cs b/src/Cli/Commands/StartCommand.cs deleted file mode 100644 index 2196e29..0000000 --- a/src/Cli/Commands/StartCommand.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.CommandLine; - -using Anonymizer.Lifetime; - -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -namespace Anonymizer.Commands; - -internal sealed class StartCommand : Command -{ - private const string HelpDesc = """ - Starts the application in the background. - """; - - public StartCommand(IHostBuilder builder) : base("start", HelpDesc) - { - Hidden = true; - SetAction(async (_) => - { - using var alreadyRunning = SingleInstance.TryAcquire("AppStart"); - if (!alreadyRunning.IsAcquired) - { - Console.Error.WriteLine("Anonymizer is already running."); - return; - } - - builder.ConfigureServices((context, services) => - { - services.AddHostedService(); - services.Configure(context.Configuration); - }); - await builder.Build().RunAsync(); - }); - - } -} diff --git a/src/Cli/Lifetime/ProcessManager.cs b/src/Cli/Lifetime/ProcessManager.cs index 6066303..c0ca378 100644 --- a/src/Cli/Lifetime/ProcessManager.cs +++ b/src/Cli/Lifetime/ProcessManager.cs @@ -8,17 +8,26 @@ internal sealed partial class ProcessManager(ILogger logger) { public void KillRunningInstances() { - foreach (var process in Process.GetProcessesByName(Application.Name)) + string[] processNames = + [ + Application.Name, + "anonymizerw", + ]; + + foreach (string processName in processNames.Distinct(StringComparer.OrdinalIgnoreCase)) { - try - { - LogStoppingProcess(process.Id); - process.Kill(entireProcessTree: true); - process.WaitForExit(5000); - } - catch (Exception ex) + foreach (var process in Process.GetProcessesByName(processName)) { - LogFailedToStopProcess(ex, process.Id); + try + { + LogStoppingProcess(process.Id); + process.Kill(entireProcessTree: true); + process.WaitForExit(5000); + } + catch (Exception ex) + { + LogFailedToStopProcess(ex, process.Id); + } } } } diff --git a/src/Cli/Lifetime/WindowsAutostart.cs b/src/Cli/Lifetime/WindowsAutostart.cs index 4bafb7b..b20551a 100644 --- a/src/Cli/Lifetime/WindowsAutostart.cs +++ b/src/Cli/Lifetime/WindowsAutostart.cs @@ -9,9 +9,10 @@ internal sealed class WindowsAutostart : IAutostartManager { public void SetAutostart(bool enable) { + string executablePath = System.IO.Path.Combine(Application.Install.Path, "anonymizerw.exe"); using var key = Registry.CurrentUser.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Run", true)!; if (enable) - key.SetValue(Application.Name, $"\"{Application.Path}\""); + key.SetValue(Application.Name, $"\"{executablePath}\""); else key.DeleteValue(Application.Name, false); } diff --git a/src/Win/Anonymizer.Win.csproj b/src/Win/Anonymizer.Win.csproj new file mode 100644 index 0000000..e5e85e1 --- /dev/null +++ b/src/Win/Anonymizer.Win.csproj @@ -0,0 +1,42 @@ + + + + WinExe + anonymizerw + net10.0 + enable + enable + true + Anonymizer + + + + true + true + true + Speed + + + + true + full + + + + + + + + + + + + + + + + + + + + diff --git a/src/Win/Program.cs b/src/Win/Program.cs new file mode 100644 index 0000000..e1d3e74 --- /dev/null +++ b/src/Win/Program.cs @@ -0,0 +1,28 @@ +using Anonymizer; +using Anonymizer.Commands; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Console; + +var builder = Host.CreateDefaultBuilder(); +builder.ConfigureServices((context, services) => +{ + services.AddSingleton(); +}); +builder.ConfigureAppConfiguration((config) => + config.AddJsonFile(Application.AppSettings.Path, optional: true, reloadOnChange: true)); +builder.ConfigureLogging((builder) => +{ + builder.ClearProviders(); + builder.SetMinimumLevel(LogLevel.Information); + builder.AddFilter("Microsoft", (level) => level >= LogLevel.Warning); + builder.AddFilter("System", (level) => level >= LogLevel.Warning); + builder.AddConsole((options) => + options.FormatterName = CustomConsoleFormatter.FormatterName); + builder.Services.AddSingleton(); +}); + +await StartCommand.ExecuteAsync(builder); diff --git a/src/Win/Properties/launchSettings.json b/src/Win/Properties/launchSettings.json new file mode 100644 index 0000000..1d97e52 --- /dev/null +++ b/src/Win/Properties/launchSettings.json @@ -0,0 +1,10 @@ +{ + "profiles": { + "cli": { + "commandName": "Project", + "commandLineArgs": "start", + "dotnetRunMessages": false, + "workingDirectory": "." + } + } +} diff --git a/src/Win/appsettings.json b/src/Win/appsettings.json new file mode 100644 index 0000000..b995c1d --- /dev/null +++ b/src/Win/appsettings.json @@ -0,0 +1,3 @@ +{ + "filesdir": "../../samples" +} From 0639a6b7534a7bc496a04fa19b2e6e5cb696afc8 Mon Sep 17 00:00:00 2001 From: Lionel Lalande Date: Mon, 6 Jul 2026 20:39:14 +0200 Subject: [PATCH 6/8] fix(cli): correct executable path in Linux autostart configuration --- src/Cli/Lifetime/LinuxAutostart.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Cli/Lifetime/LinuxAutostart.cs b/src/Cli/Lifetime/LinuxAutostart.cs index ba42e62..6678600 100644 --- a/src/Cli/Lifetime/LinuxAutostart.cs +++ b/src/Cli/Lifetime/LinuxAutostart.cs @@ -17,11 +17,12 @@ public void SetAutostart(bool enable) { if (enable) { + string executablePath = Path.Combine(Application.Install.Path, "anonymizer"); var content = $""" [Desktop Entry] Type=Application Name={Application.Name} - Exec={Application.Path} + Exec={executablePath} start Hidden=false """; File.WriteAllText(_desktopFilePath, content); From 3e447a8e8b6bbccb3a66e29e38cf8f42f799c35b Mon Sep 17 00:00:00 2001 From: Lionel Lalande Date: Tue, 21 Jul 2026 07:43:49 +0200 Subject: [PATCH 7/8] fix(core): add more improvements --- src/Core/Cargo.toml | 2 +- src/Core/src/DocstrumBoundingBoxes.rs | 93 +++++ src/Core/src/NearestNeighbourWordExtractor.rs | 95 +++++ .../src/UnsupervisedReadingOrderDetector.rs | 23 ++ src/Core/src/layout_analysis.rs | 335 +++++++++++++----- src/Core/src/lib.rs | 41 ++- src/Core/src/markdown_generator.rs | 100 ++++++ src/Core/src/models.rs | 8 + src/Core/src/pdf_parser.rs | 177 +++++---- src/Core/src/pii_ner_detector.rs | 18 +- src/Core/src/pii_regex_detector.rs | 2 +- 11 files changed, 684 insertions(+), 210 deletions(-) create mode 100644 src/Core/src/DocstrumBoundingBoxes.rs create mode 100644 src/Core/src/NearestNeighbourWordExtractor.rs create mode 100644 src/Core/src/UnsupervisedReadingOrderDetector.rs create mode 100644 src/Core/src/markdown_generator.rs diff --git a/src/Core/Cargo.toml b/src/Core/Cargo.toml index f78a3fe..16a1df7 100644 --- a/src/Core/Cargo.toml +++ b/src/Core/Cargo.toml @@ -14,4 +14,4 @@ tokenizers = "0.19.1" ttf-parser = "0.20" [lib] -crate-type = ["cdylib"] +crate-type = ["cdylib", "rlib"] diff --git a/src/Core/src/DocstrumBoundingBoxes.rs b/src/Core/src/DocstrumBoundingBoxes.rs new file mode 100644 index 0000000..bb5bd83 --- /dev/null +++ b/src/Core/src/DocstrumBoundingBoxes.rs @@ -0,0 +1,93 @@ +use crate::models::{Block, Line}; + +pub struct DocstrumBoundingBoxes; + +impl DocstrumBoundingBoxes { + pub fn get_blocks(&self, words: Vec) -> Vec { + if words.is_empty() { return Vec::new(); } + + // Étape 1 : Regroupement des mots en lignes physiques + let lines = self.words_to_lines(words); + + // Étape 2 : Regroupement des lignes en blocs géométriques (Paragraphes structurels) + let mut blocks: Vec = Vec::new(); + + for line in lines { + let mut added = false; + for block in blocks.iter_mut() { + // Seuil Docstrum pour l'interligne (généralement 1.5 à 2x la hauteur de la ligne) + let max_line_spacing = line.bbox.height * 1.8; + let close_y = (line.baseline_y - block.baseline_y).abs() < max_line_spacing; + + // Vérification stricte du chevauchement horizontal (évite de fusionner 2 colonnes distinctes) + let x_overlap = line.bbox.x < (block.bbox.x + block.bbox.width) + && (line.bbox.x + line.bbox.width) > block.bbox.x; + + if close_y && x_overlap { + block.bbox.x = block.bbox.x.min(line.bbox.x); + block.bbox.y = block.bbox.y.min(line.bbox.y); + block.bbox.width = (block.bbox.x + block.bbox.width).max(line.bbox.x + line.bbox.width) - block.bbox.x; + block.bbox.height = (block.bbox.y + block.bbox.height).max(line.bbox.y + line.bbox.height) - block.bbox.y; + block.baseline_y = line.baseline_y; + block.lines.push(line.clone()); + added = true; + break; + } + } + + if !added { + blocks.push(Block { + bbox: line.bbox.clone(), + page_id: line.page_id, + baseline_y: line.baseline_y, + lines: vec![line], + }); + } + } + + blocks + } + + fn words_to_lines(&self, mut words: Vec) -> Vec { + // Tri de haut en bas strict pour l'analyse par balayage + words.sort_by(|a, b| b.baseline_y.partial_cmp(&a.baseline_y).unwrap()); + + let mut lines: Vec = Vec::new(); + + for word in words { + let mut added = false; + for line in lines.iter_mut() { + let v_tolerance = word.font_size * 0.4; + let inline = (word.baseline_y - line.baseline_y).abs() < v_tolerance; + + // Docstrum : On ne fusionne dans la ligne que si la distance X n'est pas un gouffre (seuil colonne) + let max_h_gap = word.font_size * 3.0; + let no_column_gap = (word.bbox.x - (line.bbox.x + line.bbox.width)).abs() < max_h_gap; + + if inline && no_column_gap { + line.bbox.x = line.bbox.x.min(word.bbox.x); + line.bbox.width = (line.bbox.x + line.bbox.width).max(word.bbox.x + word.bbox.width) - line.bbox.x; + line.words.push(word.clone()); + added = true; + break; + } + } + + if !added { + lines.push(Line { + bbox: word.bbox.clone(), + page_id: word.page_id, + baseline_y: word.baseline_y, + words: vec![word], + }); + } + } + + // Tri des mots à l'intérieur de chaque ligne de gauche à droite + for line in &mut lines { + line.words.sort_by(|a, b| a.bbox.x.partial_cmp(&b.bbox.x).unwrap()); + } + + lines + } +} diff --git a/src/Core/src/NearestNeighbourWordExtractor.rs b/src/Core/src/NearestNeighbourWordExtractor.rs new file mode 100644 index 0000000..b3fb0d4 --- /dev/null +++ b/src/Core/src/NearestNeighbourWordExtractor.rs @@ -0,0 +1,95 @@ +// Modèle de lettre d'entrée supposé (proche de PdfPig.Letter) +#[derive(Clone, Debug)] +pub struct PdfLetter { + pub text: String, + pub x: f64, + pub y: f64, // baseline y + pub width: f64, + pub height: f64, + pub font_size: f64, +} + +use crate::models::{Word, BoundingBox}; // Adaptez selon vos modèles existants + +pub struct NearestNeighbourWordExtractor; + +impl NearestNeighbourWordExtractor { + pub fn get_words(&self, mut letters: Vec) -> Vec { + if letters.is_empty() { return Vec::new(); } + + // Tri initial de gauche à droite, puis de haut en bas + letters.sort_by(|a, b| { + b.y.partial_cmp(&a.y).unwrap() + .then(a.x.partial_cmp(&b.x).unwrap()) + }); + + let mut words = Vec::new(); + let mut current_words_letters: Vec = Vec::new(); + + for letter in letters { + if current_words_letters.is_empty() { + current_words_letters.push(letter); + continue; + } + + let last = current_words_letters.last().unwrap(); + + // Calcul de l'écart horizontal entre la fin de la dernière lettre et le début de la nouvelle + let last_right = last.x + last.width; + let horizontal_gap = letter.x - last_right; + + // Tolérance verticale pour rester sur la même ligne (PdfPig utilise environ 10-15% de la hauteur) + let vertical_diff = (letter.y - last.y).abs(); + let v_tolerance = last.font_size * 0.2; + + // Seuil d'espace : si l'écart dépasse 30% de la taille de la police, on crée un mot + let word_space_threshold = last.font_size * 0.3; + + if vertical_diff < v_tolerance && horizontal_gap >= 0.0 && horizontal_gap < word_space_threshold { + current_words_letters.push(letter); + } else { + // Finalisation du mot courant + words.push(Self::build_word_from_letters(¤t_words_letters)); + current_words_letters.clear(); + current_words_letters.push(letter); + } + } + + if !current_words_letters.is_empty() { + words.push(Self::build_word_from_letters(¤t_words_letters)); + } + + words + } + + fn build_word_from_letters(letters: &[PdfLetter]) -> Word { + let first = &letters[0]; + let mut text = String::new(); + let mut min_x = first.x; + let mut max_x = first.x + first.width; + let mut min_y = first.y; + let mut max_y = first.y + first.height; + + for l in letters { + text.push_str(&l.text); + min_x = min_x.min(l.x); + max_x = max_x.max(l.x + l.width); + min_y = min_y.min(l.y); + max_y = max_y.max(l.y + l.height); + } + + Word { + text, + bbox: BoundingBox { + x: min_x, + y: min_y, + width: max_x - min_x, + height: max_y - min_y, + }, + baseline_y: first.y, + font_size: first.font_size, + page_id: 0, // À dynamiser selon votre contexte + para_char_range: None, + } + } +} diff --git a/src/Core/src/UnsupervisedReadingOrderDetector.rs b/src/Core/src/UnsupervisedReadingOrderDetector.rs new file mode 100644 index 0000000..b2167a8 --- /dev/null +++ b/src/Core/src/UnsupervisedReadingOrderDetector.rs @@ -0,0 +1,23 @@ +pub struct UnsupervisedReadingOrderDetector; + +impl UnsupervisedReadingOrderDetector { + pub fn get(&self, mut blocks: Vec) -> Vec { + // Algorithme non supervisé basé sur l'axe X (Colonnes) en priorité, puis Y (Lignes) + // Permet de lire complètement la colonne de gauche avant de passer à la colonne de droite + blocks.sort_by(|a, b| { + // Définition d'un seuil de tolérance de colonne (ex: 50 points d'écart sur l'axe X) + let col_tolerance = 40.0; + let x_diff = a.bbox.x - b.bbox.x; + + if x_diff.abs() > col_tolerance { + // Colonnes distinctes -> On trie de gauche à droite + a.bbox.x.partial_cmp(&b.bbox.x).unwrap() + } else { + // Même colonne -> On trie de haut en bas + b.baseline_y.partial_cmp(&a.baseline_y).unwrap() + } + }); + + blocks + } +} diff --git a/src/Core/src/layout_analysis.rs b/src/Core/src/layout_analysis.rs index cc0e9c2..18f7cd4 100644 --- a/src/Core/src/layout_analysis.rs +++ b/src/Core/src/layout_analysis.rs @@ -1,23 +1,38 @@ // src/layout_analysis.rs -use crate::models::{Block, Line, Paragraph, Word}; +use crate::models::{Block, Line, Paragraph, Word, Letter}; -pub fn build_segmented_layout(pages_words: Vec>) -> Vec { - let mut all_blocks = Vec::new(); +// ========================================================================= +// 1. PIPELINE PRINCIPAL : INTEGRATION PDFPIG +// ========================================================================= + +pub fn build_segmented_layout_pdfpig(pages_words_or_letters: Vec>) -> Vec { + let mut paragraphs = Vec::new(); let mut global_font_sum = 0.0; let mut global_word_count = 0.0; + let mut all_ordered_blocks = Vec::new(); - for words in pages_words { - if words.is_empty() { + for words_on_page in pages_words_or_letters { + if words_on_page.is_empty() { continue; } - for w in &words { - global_font_sum += w.font_size; + + // On extrait le vrai page_id stocké dans le premier mot de la page + let page_id = words_on_page[0].page_id; + + let mut page_letters = Vec::new(); + for word in words_on_page { + global_font_sum += word.font_size; global_word_count += 1.0; + page_letters.extend(word.letters); } - let lines = words_to_lines(words); - let blocks = lines_to_blocks(lines); - all_blocks.extend(blocks); + // On transmet le page_id à l'extracteur de mots + let exact_words = nearest_neighbour_word_extractor(page_letters, page_id); + + let text_blocks = docstrum_page_segmenter(exact_words); + let ordered_blocks = unsupervised_reading_order_detector(text_blocks); + + all_ordered_blocks.extend(ordered_blocks); } let avg_font_size = if global_word_count > 0.0 { @@ -25,123 +40,137 @@ pub fn build_segmented_layout(pages_words: Vec>) -> Vec { } else { 10.0 }; - let mut paragraphs = Vec::new(); - if all_blocks.is_empty() { + + if all_ordered_blocks.is_empty() { return paragraphs; } + // ========================================================================= + // 2. RECONSTRUCTION DES PARAGRAPHES & OFFSETS (Maintien de votre logique NER) + // ========================================================================= let mut current_para_text = String::new(); - let mut current_para_blocks = Vec::new(); - - for block in all_blocks { - let mut block_text = String::new(); - let mut block_font_sum = 0.0; - let mut block_word_count = 0.0; - - for line in &block.lines { - for word in &line.words { - block_text.push_str(&word.text); - block_text.push(' '); - block_font_sum += word.font_size; - block_word_count += 1.0; - } - } - let block_avg_font = if block_word_count > 0.0 { - block_font_sum / block_word_count - } else { - 10.0 - }; + let mut current_para_blocks: Vec = Vec::new(); + + for mut block in all_ordered_blocks { + // Calcule les offsets et la taille de police moyenne du bloc + let (block_text, block_avg_font) = annotate_block_offsets(&mut block); let is_heading = block_avg_font > (avg_font_size * 1.25); if is_heading { if !current_para_blocks.is_empty() { - paragraphs.push(Paragraph { - text: current_para_text.trim().to_string(), - blocks: current_para_blocks.clone(), - is_heading: false, - }); - current_para_text.clear(); - current_para_blocks.clear(); + paragraphs.push(finalize_paragraph( + std::mem::take(&mut current_para_text), + std::mem::take(&mut current_para_blocks), + false, + )); } - paragraphs.push(Paragraph { - text: block_text.trim().to_string(), - blocks: vec![block], - is_heading: true, - }); + paragraphs.push(finalize_paragraph(block_text, vec![block], true)); } else { let ends_with_punctuation = block_text.trim().ends_with('.') || block_text.trim().ends_with(':') || block_text.trim().ends_with('!'); + + let base = current_para_text.len(); + shift_block_offsets(&mut block, base); current_para_text.push_str(&block_text); current_para_blocks.push(block); if ends_with_punctuation { - paragraphs.push(Paragraph { - text: current_para_text.trim().to_string(), - blocks: current_para_blocks.clone(), - is_heading: false, - }); - current_para_text.clear(); - current_para_blocks.clear(); + paragraphs.push(finalize_paragraph( + std::mem::take(&mut current_para_text), + std::mem::take(&mut current_para_blocks), + false, + )); } } } if !current_para_blocks.is_empty() { - paragraphs.push(Paragraph { - text: current_para_text.trim().to_string(), - blocks: current_para_blocks, - is_heading: false, - }); + paragraphs.push(finalize_paragraph( + current_para_text, + current_para_blocks, + false, + )); } paragraphs } -fn words_to_lines(mut words: Vec) -> Vec { - words.sort_by(|a, b| { - b.baseline_y - .partial_cmp(&a.baseline_y) - .unwrap() +// ========================================================================= +// 3. SOUS-ALGORITHMES PDFPIG (PORTAGE RUST) +// ========================================================================= + +/// Équivalent de : NearestNeighbourWordExtractor.cs +fn nearest_neighbour_word_extractor(mut letters: Vec, page_id: lopdf::ObjectId) -> Vec { + if letters.is_empty() { return Vec::new(); } + + letters.sort_by(|a, b| { + b.baseline_y.partial_cmp(&a.baseline_y).unwrap() .then(a.bbox.x.partial_cmp(&b.bbox.x).unwrap()) }); - let mut lines: Vec = Vec::new(); - let is_special_str = |s: &str| { - if s.len() != 1 { - return false; + let mut words = Vec::new(); + let mut current_word_letters: Vec = Vec::new(); + + for letter in letters { + if letter.value.is_whitespace() || letter.value == '\u{00a0}' { + continue; } - let c = s.chars().next().unwrap(); - c.is_ascii_punctuation() - }; + + if current_word_letters.is_empty() { + current_word_letters.push(letter); + continue; + } + + let last = current_word_letters.last().unwrap(); + let last_right = last.bbox.x + last.bbox.width; + let horizontal_gap = letter.bbox.x - last_right; + let vertical_diff = (letter.baseline_y - last.baseline_y).abs(); + + let v_tolerance = last.font_size * 0.2; + let word_space_threshold = last.font_size * 0.3; + + if vertical_diff < v_tolerance && horizontal_gap >= -2.0 && horizontal_gap < word_space_threshold { + current_word_letters.push(letter); + } else { + // Passez le page_id ici + words.push(build_word_from_letters(¤t_word_letters, page_id)); + current_word_letters.clear(); + current_word_letters.push(letter); + } + } + + if !current_word_letters.is_empty() { + // Et ici + words.push(build_word_from_letters(¤t_word_letters, page_id)); + } + + words +} + +/// Équivalent de : DocstrumBoundingBoxes.cs +fn docstrum_page_segmenter(mut words: Vec) -> Vec { + if words.is_empty() { return Vec::new(); } + + // 1. Regroupement des mots en lignes (Words -> Lines) + words.sort_by(|a, b| b.baseline_y.partial_cmp(&a.baseline_y).unwrap()); + let mut lines: Vec = Vec::new(); for word in words { let mut added = false; for line in lines.iter_mut() { - // Lenient alignment to integrate isolated punctuation into the current line - let lenient = is_special_str(&word.text); - let max_v = if lenient { word.font_size * 0.8 } else { 6.0 }; + let v_tolerance = word.font_size * 0.4; + let inline = (word.baseline_y - line.baseline_y).abs() < v_tolerance; - // Comparison on the baseline (stable), not on the visual bbox which - // varies according to the stems/descenders of the word (a "g" and a "T" do not have the - // same bbox.y even though they are on the same line). - let inline = (word.baseline_y - line.baseline_y).abs() < max_v; - let no_column_gap = - (word.bbox.x - (line.bbox.x + line.bbox.width)).abs() < (word.font_size * 2.5); + // Evite l'effondrement des colonnes : maximum 2.5 à 3.0 espaces de police d'écart horizontal + let max_h_gap = word.font_size * 2.8; + let no_column_gap = (word.bbox.x - (line.bbox.x + line.bbox.width)).abs() < max_h_gap; if inline && no_column_gap { - // Physical fusion of BBoxes in the line (purely visual, the reference baseline - // of the line itself does not change) let min_x = line.bbox.x.min(word.bbox.x); let max_x = (line.bbox.x + line.bbox.width).max(word.bbox.x + word.bbox.width); - let min_y = line.bbox.y.min(word.bbox.y); - let max_y = (line.bbox.y + line.bbox.height).max(word.bbox.y + word.bbox.height); - line.bbox.x = min_x; line.bbox.width = max_x - min_x; - line.bbox.y = min_y; - line.bbox.height = max_y - min_y; - line.words.push(word.clone()); added = true; break; @@ -156,22 +185,26 @@ fn words_to_lines(mut words: Vec) -> Vec { }); } } - lines -} -fn lines_to_blocks(mut lines: Vec) -> Vec { + // Assure que chaque ligne lise ses mots strictement de gauche à droite + for line in &mut lines { + line.words.sort_by(|a, b| a.bbox.x.partial_cmp(&b.bbox.x).unwrap()); + } + + // 2. Regroupement des lignes en Blocs (Lines -> Blocks) lines.sort_by(|a, b| b.baseline_y.partial_cmp(&a.baseline_y).unwrap()); let mut blocks: Vec = Vec::new(); for line in lines { let mut added = false; for block in blocks.iter_mut() { - // Distance to the baseline of the LAST added line (actual line spacing), - // rather than to block.bbox.y which drifts with cumulative stems/descenders. - let close_y = (line.baseline_y - block.baseline_y).abs() < 18.0; - // Slight horizontal margin to capture punctuation shifted at the end of the block - let x_overlap = line.bbox.x < (block.bbox.x + block.bbox.width + 10.0) - && (line.bbox.x + line.bbox.width) > (block.bbox.x - 10.0); + // L'interligne max basé sur la hauteur de la ligne (Docstrum standard) + let max_line_spacing = line.bbox.height * 1.8; + let close_y = (line.baseline_y - block.baseline_y).abs() < max_line_spacing; + + // On ne fusionne verticalement que s'il y a un chevauchement horizontal (même colonne) + let x_overlap = line.bbox.x < (block.bbox.x + block.bbox.width) + && (line.bbox.x + line.bbox.width) > block.bbox.x; if close_y && x_overlap { let min_x = block.bbox.x.min(line.bbox.x); @@ -184,7 +217,6 @@ fn lines_to_blocks(mut lines: Vec) -> Vec { block.bbox.width = max_x - min_x; block.bbox.height = max_y - min_y; block.baseline_y = line.baseline_y; - block.lines.push(line.clone()); added = true; break; @@ -199,5 +231,118 @@ fn lines_to_blocks(mut lines: Vec) -> Vec { }); } } + blocks } + +/// Équivalent de : UnsupervisedReadingOrderDetector.cs +fn unsupervised_reading_order_detector(mut blocks: Vec) -> Vec { + // Analyse des blocs en mode multi-colonnes + blocks.sort_by(|a, b| { + // Si l'écart X entre deux blocs dépasse le seuil, ils appartiennent à deux colonnes distinctes + let col_tolerance = 50.0; + let x_diff = a.bbox.x - b.bbox.x; + + if x_diff.abs() > col_tolerance { + // Colonne de gauche d'abord, puis colonne de droite + a.bbox.x.partial_cmp(&b.bbox.x).unwrap() + } else { + // Même colonne : du haut vers le bas + b.baseline_y.partial_cmp(&a.baseline_y).unwrap() + } + }); + blocks +} + +// Helper pour générer une structure Word propre depuis la collection de ses structures Letter +fn build_word_from_letters(letters: &[Letter], page_id: lopdf::ObjectId) -> Word { + let first = &letters[0]; + let mut text = String::new(); + let mut min_x = first.bbox.x; + let mut max_x = first.bbox.x + first.bbox.width; + let mut min_y = first.bbox.y; + let mut max_y = first.bbox.y + first.bbox.height; + + for l in letters { + text.push(l.value); + min_x = min_x.min(l.bbox.x); + max_x = max_x.max(l.bbox.x + l.bbox.width); + min_y = min_y.min(l.bbox.y); + max_y = max_y.max(l.bbox.y + l.bbox.height); + } + + Word { + text, + bbox: crate::models::BBox { + x: min_x, + y: min_y, + width: max_x - min_x, + height: max_y - min_y, + }, + font_size: first.font_size, + page_id, // <--- On affecte directement le vrai ObjectId passé en paramètre + baseline_y: first.baseline_y, + letters: letters.to_vec(), + para_char_range: None, + } +} + +// ========================================================================= +// 4. FONCTIONS DE RECALAGE DES OFFSETS (Vos fonctions d'origine préservées) +// ========================================================================= + +fn annotate_block_offsets(block: &mut Block) -> (String, f64) { + let mut text = String::new(); + let mut font_sum = 0.0; + let mut word_count = 0.0; + + for line in &mut block.lines { + for word in &mut line.words { + let start = text.len(); + text.push_str(&word.text); + let end = text.len(); + word.para_char_range = Some(start..end); + text.push(' '); + + font_sum += word.font_size; + word_count += 1.0; + } + } + + let avg_font = if word_count > 0.0 { font_sum / word_count } else { 10.0 }; + (text, avg_font) +} + +fn shift_block_offsets(block: &mut Block, base: usize) { + if base == 0 { return; } + for line in &mut block.lines { + for word in &mut line.words { + if let Some(r) = word.para_char_range.take() { + word.para_char_range = Some((r.start + base)..(r.end + base)); + } + } + } +} + +fn finalize_paragraph(raw_text: String, mut blocks: Vec, is_heading: bool) -> Paragraph { + let trimmed_start = raw_text.len() - raw_text.trim_start().len(); + let final_text = raw_text.trim().to_string(); + let final_len = final_text.len(); + + for block in &mut blocks { + for line in &mut block.lines { + for word in &mut line.words { + if let Some(r) = &word.para_char_range { + let mut start = r.start.saturating_sub(trimmed_start); + let mut end = r.end.saturating_sub(trimmed_start); + if start > final_len { start = final_len; } + if end > final_len { end = final_len; } + if end < start { end = start; } + word.para_char_range = Some(start..end); + } + } + } + } + + Paragraph { text: final_text, blocks, is_heading } +} diff --git a/src/Core/src/lib.rs b/src/Core/src/lib.rs index b824518..9deeb2e 100644 --- a/src/Core/src/lib.rs +++ b/src/Core/src/lib.rs @@ -2,6 +2,7 @@ mod face_detector; mod font_map; mod layout_analysis; +mod markdown_generator; mod models; mod pdf_generator; mod pdf_parser; @@ -92,14 +93,20 @@ fn run_redaction_pipeline( let mut doc = Document::load(input_path)?; // Low-level extraction (Letters -> Physical Words) + // On mémorise au passage le numéro de page (1-indexé) associé à chaque + // ObjectId de page, pour pouvoir plus tard produire un export Markdown + // lisible (séparateurs "Page N") sans avoir à reparcourir le document. let mut all_pages_words = Vec::new(); - for page_id in doc.page_iter() { + let mut page_numbers: std::collections::HashMap = + std::collections::HashMap::new(); + for (page_index, page_id) in doc.page_iter().enumerate() { + page_numbers.insert(page_id, page_index + 1); let words = pdf_parser::extract_words(&doc, page_id)?; all_pages_words.push(words); } println!("Semantic analysis..."); - let paragraphs = layout_analysis::build_segmented_layout(all_pages_words); + let paragraphs = layout_analysis::build_segmented_layout_pdfpig(all_pages_words); println!("Initializing detection engines (Multi-agents)..."); @@ -125,25 +132,25 @@ fn run_redaction_pipeline( continue; } - let mut search_offset = 0; + // Comparaison d'intervalles directe (offset connu à l'avance, voir + // Word::para_char_range) : plus de curseur de recherche séquentiel, + // donc plus de désynchronisation possible sur des tokens courts et + // répétés (dates, numéros de téléphone...). for word in paragraph .blocks .iter() .flat_map(|b| &b.lines) .flat_map(|l| &l.words) { - let Some(local_idx) = paragraph.text[search_offset..].find(&word.text) else { + let Some(range) = &word.para_char_range else { continue; }; - let word_start = search_offset + local_idx; - let word_end = word_start + word.text.len(); let is_regex_pii = regex_spans .iter() - .any(|span| word_start.max(span.start) < word_end.min(span.end)); + .any(|span| range.start.max(span.start) < range.end.min(span.end)); if is_regex_pii { - // CORRECTION 1 : Remplacement de std::ptr::eq par une comparaison structurelle de valeur let already_exists = redaction_targets.iter().any(|w| { w.page_id == word.page_id && w.bbox == word.bbox && w.text == word.text }); @@ -153,7 +160,6 @@ fn run_redaction_pipeline( regex_count += 1; } } - search_offset = word_end; } } println!( @@ -206,6 +212,23 @@ fn run_redaction_pipeline( doc.save(output_str)?; + // Génère en complément un export Markdown anonymisé (même liste de + // cibles de caviardage que le PDF), à côté du fichier de sortie, ex: + // "document.redacted.pdf" -> "document.redacted.md". + println!("Generating redacted markdown export..."); + let markdown_content = markdown_generator::generate_markdown( + file_prefix, + ¶graphs, + &redaction_targets, + &page_numbers, + ); + let markdown_path = output_path_path.with_extension("md"); + std::fs::write(&markdown_path, markdown_content)?; + println!( + " -> Markdown export written to {}", + markdown_path.display() + ); + println!("\n[Success] Anonymization pipeline executed successfully."); Ok(()) } diff --git a/src/Core/src/markdown_generator.rs b/src/Core/src/markdown_generator.rs new file mode 100644 index 0000000..1596c10 --- /dev/null +++ b/src/Core/src/markdown_generator.rs @@ -0,0 +1,100 @@ +// src/markdown_generator.rs +// Construit un export Markdown du document reflétant le caviardage appliqué +// au PDF : tout mot signalé comme donnée personnelle (par le NER, le Regex ou +// tout autre détecteur en amont) est masqué par des blocs pleins, tout en +// conservant la structure de lecture (titres, paragraphes, pages) pour +// permettre une relecture sans jamais exposer la donnée d'origine. +use crate::models::{Paragraph, Word}; +use lopdf::ObjectId; +use std::collections::{HashMap, HashSet}; + +/// Construit une clé d'identité stable pour un `Word`, en reprenant la même +/// comparaison structurelle (page + bbox + texte) déjà utilisée ailleurs dans +/// le pipeline (voir `run_redaction_pipeline`), car `Word` ne dérive pas +/// `PartialEq`/`Hash`. +fn word_key(w: &Word) -> String { + format!( + "{}-{}-{:.4}-{:.4}-{:.4}-{:.4}-{}", + w.page_id.0, w.page_id.1, w.bbox.x, w.bbox.y, w.bbox.width, w.bbox.height, w.text + ) +} + +/// Remplace les caractères visibles d'un mot caviardé par des blocs pleins, +/// en conservant approximativement la longueur d'origine (nombre de +/// caractères Unicode) afin que la forme de la phrase reste lisible sans +/// révéler la donnée. +fn mask_word(word: &str) -> String { + "█".repeat(word.chars().count().max(1)) +} + +/// Génère le contenu Markdown complet du document. +/// +/// - `paragraphs` : la mise en page déjà segmentée (titres/paragraphes). +/// - `redactions` : les mots identifiés comme PII (NER + Regex), exactement +/// la même liste que celle utilisée par `pdf_generator::apply_text_redaction`. +/// - `page_numbers` : correspondance ObjectId de page -> numéro de page +/// (1-indexé), pour insérer des séparateurs de page lisibles. +pub fn generate_markdown( + document_name: &str, + paragraphs: &[Paragraph], + redactions: &[Word], + page_numbers: &HashMap, +) -> String { + let redacted_keys: HashSet = redactions.iter().map(word_key).collect(); + + let mut out = String::new(); + out.push_str(&format!("# {} (anonymisé)\n\n", document_name)); + out.push_str( + "_Export généré automatiquement à partir du pipeline d'anonymisation. \ + Les zones identifiées comme données personnelles ont été masquées (█)._\n\n---\n\n", + ); + + let mut current_page: Option = None; + + for paragraph in paragraphs { + // Détermine la page du paragraphe à partir de son premier bloc, et + // insère un séparateur de page à chaque changement. + if let Some(first_block) = paragraph.blocks.first() { + if let Some(&page_no) = page_numbers.get(&first_block.page_id) { + if current_page != Some(page_no) { + if current_page.is_some() { + out.push_str("\n---\n\n"); + } + out.push_str(&format!("*Page {}*\n\n", page_no)); + current_page = Some(page_no); + } + } + } + + let mut rendered = String::new(); + for block in ¶graph.blocks { + for line in &block.lines { + for word in &line.words { + if !rendered.is_empty() { + rendered.push(' '); + } + if redacted_keys.contains(&word_key(word)) { + rendered.push_str(&mask_word(&word.text)); + } else { + rendered.push_str(&word.text); + } + } + } + } + let rendered = rendered.trim(); + if rendered.is_empty() { + continue; + } + + if paragraph.is_heading { + out.push_str("## "); + out.push_str(rendered); + out.push_str("\n\n"); + } else { + out.push_str(rendered); + out.push_str("\n\n"); + } + } + + out +} diff --git a/src/Core/src/models.rs b/src/Core/src/models.rs index 35be4df..607d9f9 100644 --- a/src/Core/src/models.rs +++ b/src/Core/src/models.rs @@ -1,5 +1,6 @@ // src/models.rs use lopdf::ObjectId; +use std::ops::Range; #[derive(Debug, Clone, PartialEq)] pub struct BBox { @@ -36,6 +37,13 @@ pub struct Word { pub page_id: ObjectId, pub letters: Vec, // Memorization of letters for light green tracing pub baseline_y: f64, + // Position exacte (en octets) de ce mot dans `Paragraph::text`, calculée + // une seule fois pendant la construction du paragraphe (voir + // `layout_analysis::build_segmented_layout`). Remplace toute recherche de + // sous-chaîne a posteriori (fragile en cas de tokens répétés comme les + // dates ou les numéros de téléphone). `None` tant que le mot n'a pas + // encore été rattaché à un paragraphe. + pub para_char_range: Option>, } #[derive(Debug, Clone)] diff --git a/src/Core/src/pdf_parser.rs b/src/Core/src/pdf_parser.rs index 9d15ceb..ca4c660 100644 --- a/src/Core/src/pdf_parser.rs +++ b/src/Core/src/pdf_parser.rs @@ -155,9 +155,6 @@ fn parse_letters( op_index: usize, array_item: Option, ) { - // Decode via the actual font table (CID/GID -> Unicode + actual width). - // (glyph, byte_offset, byte_length) to be able to precisely erase - // each glyph later without affecting neighboring glyphs. let (glyphs, height_ratio, y_offset_ratio): ( Vec<(font_map::GlyphInfo, usize, usize)>, f64, @@ -206,8 +203,6 @@ fn parse_letters( let width = font_size * g.width; let height = font_size * height_ratio; - // Constant offset per font: the bottom of the box extends below the baseline (descenders), - // identical for all letters of this font/size. let baseline_y = m_abs[5]; let y = baseline_y + font_size * y_offset_ratio; @@ -227,119 +222,113 @@ fn parse_letters( } } +// ========================================================================= +// ALGORITHME LOGIQUE : NEAREST NEIGHBOUR WORD EXTRACTOR (PdfPig adaptation) +// ========================================================================= fn reconstruct_words(mut letters: Vec, page_id: ObjectId) -> Vec { if letters.is_empty() { return vec![]; } - letters.sort_by(|a, b| { - b.baseline_y - .partial_cmp(&a.baseline_y) - .unwrap() - .then(a.bbox.x.partial_cmp(&b.bbox.x).unwrap()) - }); - let mut words = Vec::new(); - let mut current_word = String::new(); - let mut current_word_letters = Vec::new(); - let mut word_bbox: Option = None; - let mut last_letter: Option = None; + // 1. Tri initial "Flou" : On trie d'abord par Y de haut en bas + letters.sort_by(|a, b| b.baseline_y.partial_cmp(&a.baseline_y).unwrap()); - // Only true isolated punctuation, never letters (accented or not) - let is_special = |c: char| c.is_ascii_punctuation(); - let mut saw_space = false; + let mut words = Vec::new(); + let mut current_word_letters: Vec = Vec::new(); for letter in letters { + // Ignorer les espaces physiques du flux PDF (on calcule les espaces géométriquement) if letter.value.is_whitespace() || letter.value == '\u{00a0}' { - saw_space = true; continue; } - // A true space in the text flow is a reliable word separator: - // it takes precedence over any geometric heuristic (kerning, attached punctuation...). - let is_new_word = if saw_space { - true - } else { - match &last_letter { - Some(last) => { - // Comparison on the baseline (stable), not on the visual bbox - // which varies with the stems/descenders of each glyph. - let vertical_dist = (letter.baseline_y - last.baseline_y).abs(); - let horizontal_dist = letter.bbox.x - (last.bbox.x + last.bbox.width); + if current_word_letters.is_empty() { + current_word_letters.push(letter); + continue; + } - // Leniency only for attaching isolated punctuation to the current word, - // never for attaching a normal word to the previous one. - let lenient = is_special(letter.value); - let max_v = if lenient { letter.font_size * 0.8 } else { 6.0 }; - // Expanded threshold: no need to be strict for detecting spaces, - // which prevents cutting a word in the middle due to kerning TJ. - let max_h = if lenient { - letter.font_size * 0.4 - } else { - letter.font_size * 0.5 - }; + let last = current_word_letters.last().unwrap(); - let is_overlapping_h = - horizontal_dist < max_h && horizontal_dist > -last.bbox.width; + // Distance géométrique locale entre la fin de la dernière lettre et le début de celle-ci + let last_right = last.bbox.x + last.bbox.width; + let horizontal_gap = letter.bbox.x - last_right; + let vertical_diff = (letter.baseline_y - last.baseline_y).abs(); - vertical_dist > max_v || !is_overlapping_h - } - None => true, - } - }; - saw_space = false; + // Seuils de tolérance proportionnels à la taille de la police + let v_tolerance = last.font_size * 0.2; // Alignement vertical sur la même ligne + let word_space_threshold = last.font_size * 0.3; // Seuil d'un espace mot (30% de la hauteur de police) - if is_new_word && !current_word.is_empty() { - if let Some(bbox) = word_bbox.take() { - let trimmed = current_word.trim(); - if !trimmed.is_empty() { - words.push(Word { - text: trimmed.to_string(), - bbox, - font_size: last_letter.as_ref().unwrap().font_size, - page_id, - letters: current_word_letters.clone(), // Injection - baseline_y: current_word_letters[0].baseline_y, - }); - } + // Si la lettre est alignée verticalement et que le gap horizontal est inférieur au seuil, + // alors elle appartient au même mot. Le seuil négatif (-2.0) gère les légers chevauchements/italiques. + if vertical_diff < v_tolerance && horizontal_gap >= -2.0 && horizontal_gap < word_space_threshold { + current_word_letters.push(letter); + } else { + // Clôture du mot courant avant d'entamer le suivant + if let Some(word) = build_word_from_extracted_letters(¤t_word_letters, page_id) { + words.push(word); } - current_word.clear(); current_word_letters.clear(); + current_word_letters.push(letter); } + } - if current_word.is_empty() { - word_bbox = Some(letter.bbox.clone()); - } else if let Some(ref mut bbox) = word_bbox { - let min_x = bbox.x.min(letter.bbox.x); - let max_x = (bbox.x + bbox.width).max(letter.bbox.x + letter.bbox.width); - let min_y = bbox.y.min(letter.bbox.y); - let max_y = (bbox.y + bbox.height).max(letter.bbox.y + letter.bbox.height); - - bbox.x = min_x; - bbox.width = max_x - min_x; - bbox.y = min_y; - bbox.height = max_y - min_y; + // Traitement du dernier mot restant + if !current_word_letters.is_empty() { + if let Some(word) = build_word_from_extracted_letters(¤t_word_letters, page_id) { + words.push(word); } + } + + // Étape CRUCIALE : Une fois tous les mots capturés sur la page entière, + // on effectue un tri stable par ligne (Y) et colonne (X) pour renvoyer un flux cohérent à l'analyseur + words.sort_by(|a, b| { + b.baseline_y.partial_cmp(&a.baseline_y).unwrap() + .then(a.bbox.x.partial_cmp(&b.bbox.x).unwrap()) + }); + + words +} - current_word.push(letter.value); - current_word_letters.push(letter.clone()); - last_letter = Some(letter); +// Générateur de structure de mot unifié +fn build_word_from_extracted_letters(letters: &[Letter], page_id: ObjectId) -> Option { + if letters.is_empty() { return None; } + + // S'assurer que les lettres à l'intérieur du mot sont triées de gauche à droite + let mut sorted_letters = letters.to_vec(); + sorted_letters.sort_by(|a, b| a.bbox.x.partial_cmp(&b.bbox.x).unwrap()); + + let first = &sorted_letters[0]; + let mut text = String::new(); + let mut min_x = first.bbox.x; + let mut max_x = first.bbox.x + first.bbox.width; + let mut min_y = first.bbox.y; + let mut max_y = first.bbox.y + first.bbox.height; + + for l in &sorted_letters { + text.push(l.value); + min_x = min_x.min(l.bbox.x); + max_x = max_x.max(l.bbox.x + l.bbox.width); + min_y = min_y.min(l.bbox.y); + max_y = max_y.max(l.bbox.y + l.bbox.height); } - if !current_word.is_empty() { - if let Some(bbox) = word_bbox { - let trimmed = current_word.trim(); - if !trimmed.is_empty() { - words.push(Word { - text: trimmed.to_string(), - bbox, - font_size: last_letter.unwrap().font_size, - page_id, - baseline_y: current_word_letters[0].baseline_y, - letters: current_word_letters, - }); - } - } + let trimmed = text.trim(); + if trimmed.is_empty() { + return None; } - words + Some(Word { + text: trimmed.to_string(), + bbox: BBox { + x: min_x, + y: min_y, + width: max_x - min_x, + height: max_y - min_y, + }, + font_size: first.font_size, + page_id, + baseline_y: first.baseline_y, + letters: sorted_letters, + para_char_range: None, + }) } diff --git a/src/Core/src/pii_ner_detector.rs b/src/Core/src/pii_ner_detector.rs index a734264..b7e312c 100644 --- a/src/Core/src/pii_ner_detector.rs +++ b/src/Core/src/pii_ner_detector.rs @@ -110,32 +110,30 @@ impl PiiNerDetector { Vec::new() }); - let mut search_offset = 0; + if pii_spans.is_empty() { + continue; + } + // Chaque mot connaît déjà sa position exacte dans `paragraph.text` + // (calculée une seule fois par layout_analysis) : simple test de + // chevauchement d'intervalles, aucune recherche de sous-chaîne. for word in paragraph .blocks .iter() .flat_map(|b| &b.lines) .flat_map(|l| &l.words) { - // Si le mot n'est pas trouvé à partir du curseur courant - // (OCR imparfait, doublon, etc.), on passe au suivant sans - // faire avancer le curseur plutôt que de tout bloquer. - let Some(local_idx) = paragraph.text[search_offset..].find(&word.text) else { + let Some(range) = &word.para_char_range else { continue; }; - let word_start = search_offset + local_idx; - let word_end = word_start + word.text.len(); let is_pii = pii_spans .iter() - .any(|span| word_start.max(span.start) < word_end.min(span.end)); + .any(|span| range.start.max(span.start) < range.end.min(span.end)); if is_pii { words_to_redact.push(word.clone()); } - - search_offset = word_end; } } diff --git a/src/Core/src/pii_regex_detector.rs b/src/Core/src/pii_regex_detector.rs index d76aa63..325aef3 100644 --- a/src/Core/src/pii_regex_detector.rs +++ b/src/Core/src/pii_regex_detector.rs @@ -24,7 +24,7 @@ impl PiiRegexDetector { email_regex: Regex::new(r"(?i)\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b").unwrap(), phone_regex: Regex::new(r"(?:(?:\+|00)33|0)\s*[1-9](?:[\s.-]*\d+)+\b").unwrap(), date_regex: Regex::new(r"\b\d{2}[/-]\d{2}[/-]\d{4}\b").unwrap(), - url_regex: Regex::new(r"(?i)https?://(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)").unwrap(), + url_regex: Regex::new(r"(?i)((https?:)?//)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)").unwrap(), person_full_name_regex: Regex::new(r"(?m)^(?:#+\s*)?[A-ZÀ-ÖØ-Ý][a-zà-öø-ÿ]+(?:[-'][A-ZÀ-ÖØ-Ý][a-zà-öø-ÿ]+)?[ \t\u00A0\u202F]+(?:[A-ZÀ-ÖØ-Ý]{3,}(?:[-'’][A-ZÀ-ÖØ-Ý]{2,})*|(?:[A-ZÀ-ÖØ-Ý][ \t\u00A0\u202F]+){2,}[A-ZÀ-ÖØ-Ý])\b").unwrap(), } } From a5d5fdb1cde5750cceb8eee64df190b3aab6d759 Mon Sep 17 00:00:00 2001 From: Lionel Lalande Date: Tue, 21 Jul 2026 07:44:53 +0200 Subject: [PATCH 8/8] fix(cli): remove obsolete logs --- src/Cli/AnonymizerService.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Cli/AnonymizerService.cs b/src/Cli/AnonymizerService.cs index ce746b1..7a96175 100644 --- a/src/Cli/AnonymizerService.cs +++ b/src/Cli/AnonymizerService.cs @@ -49,7 +49,6 @@ private async Task RunPipeline(FileInfo file, CancellationToken cancellationToke OutputPaths output = OutputPaths.From(file); await Task.Run(() => { - Console.WriteLine("[DEBUG] Avant RunPipeline()"); var result = NativeMethods.RedactPdf(file.FullName, ResolveModelsDirectory().FullName, output.AnonymizedPdfPath); var error = result switch { @@ -64,7 +63,6 @@ await Task.Run(() => LogProcessError(file.FullName, error); else LogProcessTerminated(file.FullName); - Console.WriteLine("[DEBUG] Après RunPipeline()"); }, cancellationToken).ConfigureAwait(false); }