Repository navigation
Project Architecture
This page documents the overall architecture of PdfEditor, including MVVM structure, service layer design, and key design decisions.
| Component | Technology | Purpose |
|---|---|---|
| UI Framework | Avalonia 11.1.3 | Cross-platform XAML UI |
| MVVM Framework | ReactiveUI 20.1.1 | Reactive bindings, commands |
| PDF Reading | PdfPig 0.1.11 | Text extraction, letter positions |
| PDF Manipulation | PDFsharp 6.2.2 | Document modification |
| PDF Rendering | PDFtoImage 4.0.2 | Page rendering via PDFium |
| Graphics | SkiaSharp 2.88.8 | 2D rendering |
| Scripting | Microsoft.CodeAnalysis.CSharp.Scripting 5.0.0 | Roslyn C# scripting |
| Testing | xUnit, FluentAssertions | Test framework |
All dependencies use permissive licenses (MIT, Apache 2.0, BSD-3).
┌─────────────────────────────────────────────────────────────┐
│ VIEW LAYER │
│ MainWindow.axaml MainWindow.axaml.cs │
│ ┌─────────────────┐ ┌─────────────────────────────┐ │
│ │ XAML UI │ │ Code-behind (minimal) │ │
│ │ - Bindings │ │ - Event handlers only │ │
│ │ - Triggers │ │ - Forward to ViewModel │ │
│ │ - Styles │ │ - No business logic │ │
│ └────────┬────────┘ └───────────────┬─────────────┘ │
│ │ Data Binding │ │
└───────────┼────────────────────────────────┼────────────────┘
│ │
┌───────────▼────────────────────────────────▼────────────────┐
│ VIEWMODEL LAYER │
│ MainWindowViewModel.cs │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ - Observable properties (current page, zoom, etc.) │ │
│ │ - Commands (load, save, redact, etc.) │ │
│ │ - Application state management │ │
│ │ - Orchestrates service calls │ │
│ └────────┬─────────────────────────────────────────────┘ │
│ │ Service calls │
└───────────┼─────────────────────────────────────────────────┘
│
┌───────────▼─────────────────────────────────────────────────┐
│ SERVICE LAYER │
│ ┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ │
│ │ PdfDocument │ │ PdfRender │ │ Redaction │ │
│ │ Service │ │ Service │ │ Service │ │
│ └──────────────┘ └──────────────┘ └─────────────────┘ │
│ ┌──────────────┐ ┌──────────────┐ ┌─────────────────┐ │
│ │ PdfTextEx │ │ PdfSearch │ │ Coordinate │ │
│ │ traction │ │ Service │ │ Converter │ │
│ └──────────────┘ └──────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────────┘
PdfEditor/ # GUI Application
├── Program.cs # Entry point
├── App.axaml # Application resources
├── Views/
│ ├── MainWindow.axaml # Main window UI
│ └── MainWindow.axaml.cs # Code-behind
├── ViewModels/
│ ├── MainWindowViewModel.cs # Main ViewModel
│ └── MainWindowViewModel.Scripting.cs # Scripting API
├── Models/
│ └── PageThumbnail.cs # Page thumbnail data
└── Services/
├── PdfDocumentService.cs # Load, save, page manipulation
├── PdfRenderService.cs # Render pages to images (with cache)
├── PdfTextExtractionService.cs # Text extraction
├── PdfSearchService.cs # Search functionality
├── CoordinateConverter.cs # Coordinate system conversions
├── RedactionService.cs # Redaction orchestration
└── ScriptingService.cs # Roslyn C# scripting
PdfEditor.Redaction/ # Redaction Library
├── ITextRedactor.cs # Main interface
├── TextRedactor.cs # Implementation
├── ContentStream/ # Content stream handling
├── GlyphLevel/ # Glyph-level removal
├── Operators/ # PDF operator handlers
├── PdfPig/ # PdfPig integration
├── PdfADetector.cs # PDF/A detection
└── PdfAMetadataPreserver.cs # PDF/A metadata
PdfEditor.Redaction.Cli/ # CLI Tool
├── Program.cs # Entry point
└── Commands/ # redact, search, verify, info
automation-scripts/ # Roslyn C# scripts
├── test-birth-certificate.csx
└── test-verapdf-corpus-sample.csx
Central application state and command handlers.
Observable Properties:
[Reactive] public int CurrentPageIndex { get; set; }
[Reactive] public double ZoomLevel { get; set; }
[Reactive] public bool IsDocumentLoaded { get; set; }
[Reactive] public string SearchText { get; set; }Commands:
public ReactiveCommand<Unit, Unit> OpenCommand { get; }
public ReactiveCommand<Unit, Unit> SaveCommand { get; }
public ReactiveCommand<Unit, Unit> RedactSelectionCommand { get; }
public ReactiveCommand<Unit, Unit> SearchCommand { get; }Services are stateless and focused:
// Each service has a single responsibility
public class PdfDocumentService
{
public PdfDocument Open(string path) { }
public void Save(PdfDocument doc, string path) { }
public void AddPage(PdfDocument doc, int afterIndex) { }
public void RemovePage(PdfDocument doc, int index) { }
public void RotatePage(PdfDocument doc, int index, int degrees) { }
}
public class RedactionService
{
public void RedactArea(PdfPage page, Rect area) { }
public void RedactText(PdfDocument doc, string text) { }
}Services are injected into ViewModel:
public class MainWindowViewModel
{
private readonly PdfDocumentService _documentService;
private readonly PdfRenderService _renderService;
private readonly RedactionService _redactionService;
public MainWindowViewModel(
PdfDocumentService documentService,
PdfRenderService renderService,
RedactionService redactionService)
{
_documentService = documentService;
_renderService = renderService;
_redactionService = redactionService;
}
}User clicks "Open"
↓
MainWindow raises OpenCommand
↓
MainWindowViewModel.OpenCommand.Execute()
↓
Shows file dialog, gets path
↓
_documentService.Open(path)
↓
PdfPig opens document
↓
ViewModel sets IsDocumentLoaded = true
↓
ReactiveUI notifies View
↓
View updates (enables controls, shows first page)
User drags selection rectangle
↓
MainWindow captures mouse events
↓
MainWindow calls ViewModel.SetSelection(rect)
↓
User clicks "Redact"
↓
ViewModel.RedactSelectionCommand.Execute()
↓
Convert selection to PDF coordinates
↓
_redactionService.RedactArea(page, area)
↓
ContentStreamParser.Parse()
↓
Filter operations in area
↓
ContentStreamBuilder.Build()
↓
Replace page content
↓
_renderService.RenderPage() for refresh
↓
View updates with new image
The redaction engine is the most complex component:
┌─────────────────────────────────────────────────────────────┐
│ RedactionService │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ RedactArea(page, area) │ │
│ │ 1. Get page content bytes │ │
│ │ 2. Parse with ContentStreamParser │ │
│ │ 3. Filter operations (remove those in area) │ │
│ │ 4. Rebuild with ContentStreamBuilder │ │
│ │ 5. Replace page content │ │
│ │ 6. Draw black rectangle (visual confirmation) │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│ │
▼ ▼
┌───────────────────────┐ ┌───────────────────────┐
│ ContentStreamParser │ │ ContentStreamBuilder │
│ - Tokenize stream │ │ - Serialize operations│
│ - Track graphics │ │ - Escape strings │
│ state (q/Q/cm) │ │ - Handle TJ arrays │
│ - Track text state │ │ │
│ (Tf/Td/Tm) │ │ │
│ - Calculate bboxes │ │ │
└───────────────────────┘ └───────────────────────┘
│
▼
┌───────────────────────┐
│ TextBoundsCalculator │
│ - Apply text matrix │
│ - Apply CTM │
│ - Convert to Avalonia│
│ coordinates │
└───────────────────────┘
Problem: No single library does everything we need.
Solution: Use two complementary libraries:
- PdfPig: Excellent for reading/parsing (accurate text extraction)
- PdfSharpCore: Good for writing/modifying (easy content manipulation)
Trade-off: More complexity but better capabilities.
Problem: How to remove content from PDF?
Options Considered:
- String replacement in content stream (fragile)
- Parse to AST, modify, serialize (robust)
- Use PDF library's editing API (limited)
Decision: Parse to operations, filter, rebuild. More work but reliable.
Problem: Need cross-platform support.
Decision: Avalonia provides WPF-like XAML with cross-platform support.
Benefits: Single codebase for Windows, Linux, macOS.
Problem: Calculating accurate text positions is complex.
Decision: Use PdfPig's Letter positions as source of truth (text selection works reliably).
Approach: Match PdfPig letters back to content stream operations.
Services throw exceptions for unrecoverable errors:
public PdfDocument Open(string path)
{
if (!File.Exists(path))
throw new FileNotFoundException($"PDF not found: {path}");
try
{
return PdfDocument.Open(path);
}
catch (PdfDocumentFormatException ex)
{
throw new InvalidOperationException($"Invalid PDF: {ex.Message}", ex);
}
}ViewModel catches and presents to user:
public async Task LoadDocumentAsync(string path)
{
try
{
_document = _documentService.Open(path);
IsDocumentLoaded = true;
}
catch (Exception ex)
{
await ShowErrorDialog($"Failed to open PDF: {ex.Message}");
IsDocumentLoaded = false;
}
}Use Microsoft.Extensions.Logging:
public class ContentStreamParser
{
private readonly ILogger<ContentStreamParser> _logger;
public ContentStreamParser(ILogger<ContentStreamParser> logger)
{
_logger = logger;
}
public List<PdfOperation> Parse(byte[] content, double pageHeight)
{
_logger.LogDebug("Parsing content stream: {Length} bytes", content.Length);
// ... parsing ...
_logger.LogInformation("Parsed {Count} operations", operations.Count);
return operations;
}
}All UI operations on main thread (Avalonia requirement).
Long operations use Task:
public async Task LoadDocumentAsync(string path)
{
IsLoading = true;
// Run on thread pool
var document = await Task.Run(() => _documentService.Open(path));
// Back on UI thread
_document = document;
IsLoading = false;
}Page rendering is CPU-intensive; do off UI thread:
public async Task<Bitmap> RenderPageAsync(int pageIndex, int dpi)
{
return await Task.Run(() => {
return _renderService.RenderPage(_document, pageIndex, dpi);
});
}-
Independent Redaction Library: Extract
PdfEditor.Redactionas standalone library - Plugin Architecture: Allow extensibility for new features
- OCR Integration: Built-in OCR for scanned documents
Current architecture handles:
- PDFs up to ~1000 pages
- Pages with ~500 operations each
- Real-time rendering at 150 DPI
For larger documents, consider:
- Virtual scrolling for thumbnails
- On-demand page loading
- Background pre-rendering
Last updated: 2024-12-29