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 1a92a61..b0e7a9f 100644
--- a/src/Cli/Anonymizer.Cli.csproj
+++ b/src/Cli/Anonymizer.Cli.csproj
@@ -1,13 +1,13 @@
- WinExe
+ Exe
anonymizer
net10.0
enable
enable
true
- Anonymizer.Cli
+ Anonymizer
@@ -22,6 +22,18 @@
full
+
+ $(DefineConstants);WINDOWS
+
+
+
+ $(DefineConstants);LINUX
+
+
+
+
+
+
diff --git a/src/Cli/AnonymizerService.cs b/src/Cli/AnonymizerService.cs
index f177312..7a96175 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)
{
@@ -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);
}
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 18012bf..c3f5fc2 100644
--- a/src/Cli/Commands/DownloadCommand.cs
+++ b/src/Cli/Commands/DownloadCommand.cs
@@ -1,14 +1,14 @@
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 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/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 cecc319..5700134 100644
--- a/src/Cli/Commands/InstallCommand.cs
+++ b/src/Cli/Commands/InstallCommand.cs
@@ -1,14 +1,12 @@
using System.CommandLine;
-using System.Diagnostics;
-using System.IO.Compression;
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")]
@@ -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..55a3a6d 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
{
@@ -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())
{
@@ -26,7 +29,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/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.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 cb05677..0000000
--- a/src/Cli/Commands/StartCommand.cs
+++ /dev/null
@@ -1,37 +0,0 @@
-using System.CommandLine;
-
-using Anonymizer.Cli.Lifetime;
-
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Hosting;
-
-namespace Anonymizer.Cli.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/Commands/UninstallCommand.cs b/src/Cli/Commands/UninstallCommand.cs
index 7a3312a..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")]
@@ -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..66d7b2a 100644
--- a/src/Cli/Commands/UpdateCommand.cs
+++ b/src/Cli/Commands/UpdateCommand.cs
@@ -1,11 +1,12 @@
using System.CommandLine;
-using System.Diagnostics;
-using System.IO.Compression;
using System.Runtime.Versioning;
-using Anonymizer.Cli.Lifetime;
+using Anonymizer.Lifetime;
-namespace Anonymizer.Cli.Commands;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+namespace Anonymizer.Commands;
[SupportedOSPlatform("Linux")]
[SupportedOSPlatform("Windows")]
@@ -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/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
new file mode 100644
index 0000000..3dc8897
--- /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;
+
+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/Downloaders/ModelDownloader.cs b/src/Cli/Downloaders/ModelDownloader.cs
index b931864..0664fd2 100644
--- a/src/Cli/Downloaders/ModelDownloader.cs
+++ b/src/Cli/Downloaders/ModelDownloader.cs
@@ -1,21 +1,14 @@
-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)
{
public async Task DownloadAsync(Uri remoteUri, FileInfo outputFile, CancellationToken cancellationToken)
{
outputFile.Directory?.Create();
-
- if (outputFile.Exists)
- {
- LogAlreadyExists(outputFile.FullName);
- return;
- }
-
try
{
LogStartDownload();
@@ -35,7 +28,7 @@ public async Task DownloadAsync(Uri remoteUri, FileInfo outputFile, Cancellation
ProgressBar progress = new();
await using var input = await response.Content.ReadAsStreamAsync(cancellationToken);
- await using var output = outputFile.Open(FileMode.Create, FileAccess.Write);
+ await using var output = outputFile.Open(FileMode.Create, FileAccess.Write, FileShare.Read);
var buffer = new byte[81920];
long totalRead = 0;
@@ -48,7 +41,6 @@ public async Task DownloadAsync(Uri remoteUri, FileInfo outputFile, Cancellation
}
progress.Finish();
- LogSuccess(outputFile.FullName);
}
catch (Exception ex)
{
@@ -68,9 +60,6 @@ public async Task DownloadAsync(Uri remoteUri, FileInfo outputFile, Cancellation
[LoggerMessage(LogLevel.Warning, "Unable to download ONNX model.")]
private partial void LogUnableToDownloadModel();
- [LoggerMessage(LogLevel.Information, "Model successfully downloaded to {Path}.")]
- private partial void LogSuccess(string path);
-
[LoggerMessage(LogLevel.Error, "Failed to download ONNX model.")]
private partial void LogFailure(Exception exception);
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 56b2835..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)
{
@@ -27,6 +27,7 @@ public void Report(long downloaded, long total)
string speedStr = $"{FormatBytes(speed)}/s".PadLeft(10);
string etaStr = TimeSpan.FromSeconds(eta).ToString(@"hh\:mm\:ss");
+ Console.CursorVisible = false;
Console.CursorLeft = 0;
Console.ForegroundColor = ConsoleColor.Cyan;
Console.Write("▕");
@@ -43,8 +44,7 @@ public void Finish()
_ = this;
Console.CursorLeft = 0;
Console.WriteLine();
- Console.ForegroundColor = ConsoleColor.Green;
- Console.WriteLine("✔ Download complete.");
+ Console.CursorVisible = true;
Console.ResetColor();
}
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..6678600 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
@@ -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);
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 7e4cc6e..c0ca378 100644
--- a/src/Cli/Lifetime/ProcessManager.cs
+++ b/src/Cli/Lifetime/ProcessManager.cs
@@ -1,23 +1,46 @@
using System.Diagnostics;
-namespace Anonymizer.Cli.Lifetime;
+using Microsoft.Extensions.Logging;
-internal static class ProcessManager
+namespace Anonymizer.Lifetime;
+
+internal sealed partial class ProcessManager(ILogger logger)
{
- public static void KillRunningInstances()
+ 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
+ foreach (var process in Process.GetProcessesByName(processName))
{
- Console.WriteLine($"Stopping running instance: PID {process.Id}");
- process.Kill(entireProcessTree: true);
- process.WaitForExit(5000);
- }
- catch (Exception ex)
- {
- Console.WriteLine($"Failed to stop process {process.Id}: {ex.Message}");
+ try
+ {
+ LogStoppingProcess(process.Id);
+ process.Kill(entireProcessTree: true);
+ process.WaitForExit(5000);
+ }
+ catch (Exception ex)
+ {
+ 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..30753b0
--- /dev/null
+++ b/src/Cli/Lifetime/SetupLifecycleService.cs
@@ -0,0 +1,290 @@
+using System.Diagnostics;
+using System.IO.Compression;
+using System.Runtime.Versioning;
+
+using Anonymizer.Lifetime;
+
+using Microsoft.Extensions.Logging;
+
+namespace Anonymizer.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/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..b20551a 100644
--- a/src/Cli/Lifetime/WindowsAutostart.cs
+++ b/src/Cli/Lifetime/WindowsAutostart.cs
@@ -2,16 +2,17 @@
using Microsoft.Win32;
-namespace Anonymizer.Cli.Lifetime;
+namespace Anonymizer.Lifetime;
[SupportedOSPlatform("Windows")]
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/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 4285ee4..4793fed 100644
--- a/src/Cli/Program.cs
+++ b/src/Cli/Program.cs
@@ -1,14 +1,15 @@
-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;
using Microsoft.Extensions.Hosting;
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;
@@ -18,6 +19,10 @@
{
services.AddHttpClient();
services.AddSingleton();
+ services.AddSingleton();
+
+ if (OperatingSystem.IsWindows() || OperatingSystem.IsLinux())
+ services.AddSingleton();
if (Path.GetFileNameWithoutExtension(Environment.ProcessPath) is "setup")
{
@@ -32,13 +37,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);
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(),
}
}
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"
+}