Repository navigation
Redaction Engine
This page documents how TRUE glyph-level redaction works, the challenges encountered, and solutions implemented.
TRUE redaction means text is removed from the PDF structure, not just visually hidden.
| Approach | Visual | Text Extraction | Security |
|---|---|---|---|
| Black box overlay | Hidden | Text extractable | INSECURE |
| TRUE glyph removal | Hidden | Text NOT extractable | SECURE |
# Black box overlay (WRONG)
Original: "Secret: PASSWORD123"
After: "Secret: ████████████" ← Black rectangle drawn
Extract: "Secret: PASSWORD123" ← Text still there!
# TRUE redaction (CORRECT)
Original: "Secret: PASSWORD123"
After: "Secret: " ← Glyphs removed from content stream
Extract: "Secret: " ← Text gone from structure
┌─────────────────────────────────────────────────────────────┐
│ REDACTION PIPELINE │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. GET CONTENT │
│ ┌──────────────────────────────────────────────────┐ │
│ │ page.Contents → raw bytes (decompressed) │ │
│ └──────────────────────────────────────────────────┘ │
│ ↓ │
│ 2. PARSE │
│ ┌──────────────────────────────────────────────────┐ │
│ │ ContentStreamParser.Parse(bytes, pageHeight) │ │
│ │ → List<PdfOperation> with bounding boxes │ │
│ └──────────────────────────────────────────────────┘ │
│ ↓ │
│ 3. FILTER │
│ ┌──────────────────────────────────────────────────┐ │
│ │ operations.Where(op => !op.IntersectsWith(area)) │ │
│ │ Remove operations that intersect redaction area │ │
│ └──────────────────────────────────────────────────┘ │
│ ↓ │
│ 4. REBUILD │
│ ┌──────────────────────────────────────────────────┐ │
│ │ ContentStreamBuilder.Build(filteredOperations) │ │
│ │ → new content stream bytes │ │
│ └──────────────────────────────────────────────────┘ │
│ ↓ │
│ 5. REPLACE │
│ ┌──────────────────────────────────────────────────┐ │
│ │ page.Contents.Clear() │ │
│ │ page.Contents.CreateSingleContent(newBytes) │ │
│ └──────────────────────────────────────────────────┘ │
│ ↓ │
│ 6. VISUAL MARKER (Optional) │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Draw black rectangle at redaction location │ │
│ │ (Visual confirmation only, security already │ │
│ │ achieved by step 3) │ │
│ └──────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
TextBoundsCalculator produces inaccurate bounding boxes:
Expected: Text "Hello" → bbox (100, 700, 45, 12)
Actual: Text "Hello" → bbox (100, 700, 2000, 12) ← Width is wrong!
Root cause: Font metrics are approximated. Without actual font data, we estimate character widths.
- Use exact font metrics: Requires parsing font files - too complex
- Tighter bbox matching: Breaks for legitimate cases
Use generous spatial filtering + text content matching:
// CharacterMatcher.cs approach:
// 1. Filter letters by Y position (vertical alignment) with GENEROUS tolerance
// 2. Filter letters starting near operation's X position
// 3. DON'T use bbox width/right edge at all - it's unreliable
// 4. Within this spatial region, match by TEXT CONTENT sequence
var candidateLetters = letters.Where(letter => {
// Must be on roughly the same line
bool yMatch = letterCenterY >= opBottom - 20 && letterCenterY <= opTop + 20;
// Must start reasonably close (don't check right edge!)
bool xMatch = letterLeft >= opLeft - 50;
return yMatch && xMatch;
});PDFs can encode text in any order:
# Content stream order:
(W) Tj at x=200
(H) Tj at x=100
(o) Tj at x=150
# Visual order (reading): "HoW"
This is valid PDF! The transformation matrices position each letter correctly.
- PDF generators optimize for output efficiency
- Text grouped by font, color, or layer
- Background-to-foreground drawing order
The birth-certificate-request-scrambled.pdf demonstrates this:
CharacterMatcher candidates: "R PLEEQAUSEES TPR FIONRT..."
Expected: "PLEASE PRINT FIRST..."
Letters are present but in wrong order.
// CharacterMatcher.cs
var candidateLetters = letters
.Where(letter => /* spatial filter */)
// CRITICAL: Sort by position to get reading order!
.OrderBy(l => pageHeight - l.GlyphRectangle.Top) // Top to bottom
.ThenBy(l => l.GlyphRectangle.Left) // Left to right
.ToList();Now candidates are in reading order regardless of content stream order.
Different systems use different origins:
Text extraction finds text at (100, 400) ← PDF coordinates
Redaction area at (100, 392) ← Avalonia coordinates
No intersection! (12-point difference)
-
PdfTextExtractionServiceusesImageSelectionToPdfCoords() -
RedactionServiceusesImageSelectionToPdfPointsTopLeft() - These produced different Y values!
All coordinate conversion goes through CoordinateConverter.cs:
// Single source of truth for conversions
public static class CoordinateConverter
{
public static Rect ImageSelectionToPdfPointsTopLeft(Rect imageSelection, int dpi)
{
// Consistent conversion everywhere
}
public static (double, double, double, double) ImageSelectionToPdfCoords(
Rect imageSelection, double pageHeight, int dpi)
{
// Uses same math, returns different format
}
}// CoordinateConversionDebugTest.cs
[Fact]
public void CompareCoordinateConversions()
{
var method1 = CoordinateConverter.ImageSelectionToPdfPointsTopLeft(...);
var method2 = CoordinateConverter.ImageSelectionToPdfCoords(...);
// Convert to same format and compare
Assert.Equal(method1.Y, convertedMethod2.Y, precision: 0.01);
}TJ arrays contain mixed strings and numbers:
[(H) -10 (e) -5 (llo)] TJ
The numbers are kerning adjustments in thousandths of an em.
- Positive number: Move LEFT (counterintuitive!)
- Negative number: Move RIGHT
-10 means "add 10/1000 em of space"
If redacting "ll" from "Hello":
Original: [(H) -10 (e) -5 (llo)] TJ
After: [(H) -10 (e) -5 (o)] TJ # Must preserve kerning!
Parse TJ arrays into individual letter positions, then rebuild:
// Parsing
foreach (var element in tjArray)
{
if (element is string s)
ProcessString(s);
else if (element is double n)
AdjustPosition(n);
}
// Rebuilding (after filtering)
private void SerializeTJArray(TextOperation op, StringBuilder sb)
{
// Reconstruct with appropriate kerning
}Text position depends on accumulated state:
BT
/F1 12 Tf # Set font
1 0 0 1 100 700 Tm # Set position
(First) Tj # Show at (100, 700)
50 0 Td # Move right
(Second) Tj # Show at (150, 700)
ET
Where is "Second"? Must track all state changes.
Graphics State (q/Q/cm):
class PdfGraphicsState
{
public PdfMatrix TransformationMatrix { get; }
public double LineWidth { get; }
// ...
}Text State (BT/ET/Tf/Td/Tm):
class PdfTextState
{
public string FontName { get; }
public double FontSize { get; }
public PdfMatrix TextMatrix { get; }
public double CharacterSpacing { get; }
public double WordSpacing { get; }
// ...
}private PdfGraphicsState _currentState;
private Stack<PdfGraphicsState> _stateStack = new();
case "q": // Save state
_stateStack.Push(_currentState.Clone());
break;
case "Q": // Restore state
if (_stateStack.Count > 0)
_currentState = _stateStack.Pop();
break;
case "cm": // Modify CTM
_currentState.TransformationMatrix =
_currentState.TransformationMatrix.Multiply(newMatrix);
break;Scanned PDFs have invisible text for searchability:
BT
/F1 12 Tf
3 Tr # Render mode 3 = INVISIBLE
(Scanned text) Tj
ET
This text is:
- Not visible on screen
- Extractable by text tools
- A security risk if not redacted
Redact regardless of render mode:
// Check render mode but still redact
if (textState.RenderMode == 3)
{
_logger.LogWarning("Redacting invisible text (OCR layer)");
}
// Always apply redaction
if (operation.IntersectsWith(area))
{
// Remove operation
}When performing multiple sequential redactions on the same PDF, reconstructed text operations have massively inflated bounding boxes that cause over-redaction.
Example:
Original PDF: 2314 characters
After redacting "TORRINGTON" (2 occurrences): Only 105 characters remain (95% lost!)
Lost: "REQUEST FOR COPY OF BIRTH CERTIFICATE", all form fields, labels
Remaining: Only the address line at bottom
Location: PdfEditor.Redaction/GlyphLevel/TextSegmenter.cs lines 97-98, 128
When a character has no PdfPig letter match (e.g., trailing spaces), TextSegmenter estimates glyph width incorrectly:
// Line 97-98: Calculate AVERAGE character width for unmapped chars
glyphWidth = textOperation.BoundingBox.Width / textOperation.Text.Length;
// Line 128: ADD this to segment width (accumulates incorrectly!)
currentSegment.Width += glyphWidth;Why this fails:
- First redaction pass creates reconstructed text operations
- Reconstructed operations have placeholder bounding boxes (not real letter positions)
- When a segment has unmapped characters, it uses the operation's bbox width
- For reconstructed operations, this bbox is a placeholder/estimate
- Dividing this by text length gives huge per-character widths
- These widths accumulate (line 128), creating segments spanning 1000+ points
-
OperationReconstructoruses these inflated widths for new operation bboxes - Second redaction pass finds these inflated bboxes intersecting unrelated areas
- Entire operations get removed incorrectly
Evidence from logs:
First pass (redacting "TORRINGTON" at x=383-467):
TextOp #58 BEFORE: BBox=(154.68,365.87)-(467.22,375.89) ✓ Correct
TextOp #58 AFTER: BBox=(157.87,362.79)-(1109.93,375.96) ✗ WRONG!
↑
Extended to 1109.93 (page width is only 612!)
Second pass (redacting "TORRINGTON" at x=308-396):
TextOp #60: BBox=(211.30,351.33)-(1117.87,364.50) ← Intersects=True
This inflated bbox intersects with UNRELATED redaction area!
Entire operation removed incorrectly!
Option 1: Calculate segment width from matched letters only (RECOMMENDED)
- Only use letters that have actual PdfPig positions
- For unmapped characters (spaces, etc.), use a small fixed width (~5 points)
- Don't accumulate based on estimated widths from placeholder bboxes
// In TextSegmenter.BuildSegments()
if (match != null)
{
// Real letter - use actual width
glyphWidth = match.Letter.GlyphRectangle.Width;
}
else
{
// No match - use FIXED small width, NOT operation bbox average
glyphWidth = 5.0; // Approximate space width
glyphHeight = 12.0; // Approximate text height
}Option 2: Don't use segment widths for reconstruction
- OperationReconstructor should NOT set bounding boxes
- Let ContentStreamParser recalculate bboxes after reconstruction
- Or: Reparse PDF with PdfPig after reconstruction to get real positions
- Reconstructed operations lose spatial information - their bboxes are estimates
- Don't use operation bboxes for per-character calculations - they're whole-operation aggregates
- Sequential operations break if bboxes aren't accurate - each pass compounds errors
- Test with multiple sequential redactions - single redaction hides this bug
[Fact]
public void SequentialRedactions_OnSamePage_WorkCorrectly()
{
// First redaction
var result1 = redactor.RedactText(input, output1, "FIRST");
// Second redaction on output of first
var result2 = redactor.RedactText(output1, output2, "SECOND");
// Both should succeed
result1.Success.Should().BeTrue();
result2.Success.Should().BeTrue();
// Final PDF should have BOTH removed
var text = ExtractAllText(output2);
text.Should().NotContain("FIRST");
text.Should().NotContain("SECOND");
// Other text should remain (check content preservation)
text.Length.Should().BeGreaterThan(expectedMinimumLength);
}- Issue #124: Sequential redaction failures in RealWorldBirthCertificateTests
- Issue #125: Adaptive Y-tolerance for letter matching (related spatial issue)
When reconstructing text operations, we create NEW BT/Tf/Tm/Tj/ET sequences, but the ORIGINAL TextStateOperations remain in the operation list, causing:
- Nested BT operators (invalid PDF - BT cannot be nested)
- Orphaned operators without operands (Tf, Tm, etc. missing their arguments)
- Missing BT/ET delimiters for non-reconstructed text
Example Invalid Content Stream:
BT ← Original BT
BT ← Reconstructed BT (NESTED - INVALID!)
/TT1 1 Tf
1 0 0 1 100 700 Tm
(Text) Tj
ET ← Reconstructed ET
ET ← Original ET
Tj ← Orphaned operator (no BT/ET, no operands!)
In GlyphRemover.ProcessOperations(), we kept ALL non-text operations including TextStateOperations from BT...ET blocks. When OperationReconstructor creates new BT/Tf/Tm/Tj/ET sequences, the original TextStateOperations create conflicts.
Initial fix attempt: Filter out ALL BT/ET operators. Problem: This breaks text blocks that aren't being reconstructed - their TextOperations lose their BT...ET delimiters.
Key insight: When ANY text in a BT...ET block is redacted, reconstruct the ENTIRE block. Don't mix original and reconstructed operations within the same block.
Algorithm:
Phase 1: Identify Text Blocks
==============================
- Track each BT...ET block with its StreamPosition range
- Mark blocks that contain ANY TextOperation intersecting the redaction area
- Result: List of { BtPos, EtPos, HasReconstructedText }
Phase 2: Filter and Build
==========================
For each operation:
- If inside a reconstructed block:
* If it's the TextOperation being redacted: reconstruct it
* Otherwise: SKIP (will be part of reconstruction)
- If inside a non-reconstructed block: KEEP as-is
- If outside any block: KEEP as-isImplementation:
// Phase 1: Identify text blocks
var textBlocks = IdentifyTextBlocks(operations, redactionArea);
// Phase 2: Build output
foreach (var operation in operations)
{
var block = textBlocks.FirstOrDefault(b => b.Contains(operation.StreamPosition));
if (block?.HasReconstructedText == true)
{
// Inside reconstructed block - only process TextOperations
if (operation is TextOperation textOp && textOp.IntersectsWith(redactionArea))
{
var reconstructed = ReconstructWithPositioning(segments, textOp);
modifiedOperations.AddRange(reconstructed); // Adds complete BT/Tf/Tm/Tj/ET
}
// Skip all other operations in this block (BT, ET, Tf, Tm, etc.)
}
else
{
// Not in reconstructed block - keep as-is
modifiedOperations.Add(operation);
}
}- Multiple TextOperations in one block: If ANY intersects, reconstruct ALL
- Nested BT blocks (malformed PDF): Use stack during identification
- Partial redaction within block: Still reconstruct entire block for consistency
- TextOperations outside blocks: Keep as-is (though technically invalid PDF)
[Fact]
public void SequentialRedactions_PreservesNonRedactedTextBlocks()
{
// PDF with multiple text blocks
var pdf = CreatePdfWithMultipleTextBlocks();
// First redaction
var result1 = redactor.RedactText(pdf, output1, "REDACT");
// Second redaction
var result2 = redactor.RedactText(output1, output2, "REMOVE");
// Verify valid PDF structure (no nested BT)
var contentStream = ExtractContentStream(output2);
contentStream.Should().NotContain("BT\nBT");
// Verify no orphaned operators
ValidatePdfStructure(output2).Should().BeTrue();
// Verify content preservation
var text = ExtractAllText(output2);
text.Should().NotContain("REDACT");
text.Should().NotContain("REMOVE");
text.Should().Contain("Untouched text from other blocks");
}- Issue #124: Sequential redaction failures in RealWorldBirthCertificateTests
- Issue #125: Adaptive Y-tolerance for letter matching (related spatial issue)
- Form XObjects: Text in reusable content not parsed
- Type 3 fonts: Glyph-as-content-stream not modified
-
Inline images:
BI...ID...EInot fully parsed - Metadata: /Info dictionary not sanitized
- Annotations: Appearance streams not checked
See PdfEditor.Redaction/IMPLEMENTATION_PLAN.md for the new library approach.
Every redaction test must include:
var textAfter = PdfTestHelpers.ExtractAllText(redactedPdf);
textAfter.Should().NotContain("REDACTED_TEXT",
"Text must be REMOVED from PDF structure, not just hidden");# After redaction, verify with pdftotext
pdftotext redacted.pdf -
# Should NOT contain redacted text| File | Purpose |
|---|---|
Services/RedactionService.cs |
Orchestrates redaction |
Services/Redaction/ContentStreamParser.cs |
Parse content streams |
Services/Redaction/ContentStreamBuilder.cs |
Rebuild content streams |
Services/Redaction/CharacterMatcher.cs |
Match letters to operations |
Services/Redaction/TextBoundsCalculator.cs |
Calculate text positions |
Services/Redaction/PdfGraphicsState.cs |
Graphics state tracking |
Services/Redaction/PdfTextState.cs |
Text state tracking |
Last updated: 2024-12-22