Skip to content

Redaction Engine

Marc Jones edited this page Dec 24, 2025 · 3 revisions

Redaction Engine

This page documents how TRUE glyph-level redaction works, the challenges encountered, and solutions implemented.

What is TRUE Redaction?

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

The Redaction Pipeline

┌─────────────────────────────────────────────────────────────┐
│                    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)                             │    │
│     └──────────────────────────────────────────────────┘    │
│                                                              │
└─────────────────────────────────────────────────────────────┘

Challenge 1: The Bounding Box Problem

The Problem

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.

Failed Solutions

  1. Use exact font metrics: Requires parsing font files - too complex
  2. Tighter bbox matching: Breaks for legitimate cases

Working Solution

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;
});

Challenge 2: Scrambled Content Stream Order

The Problem

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.

Why It Happens

  • PDF generators optimize for output efficiency
  • Text grouped by font, color, or layer
  • Background-to-foreground drawing order

Real Example

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.

Solution: Sort by Position

// 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.

Challenge 3: Coordinate System Mismatch

The Problem

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)

Root Cause

  • PdfTextExtractionService uses ImageSelectionToPdfCoords()
  • RedactionService uses ImageSelectionToPdfPointsTopLeft()
  • These produced different Y values!

Solution: Centralized Conversion

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
    }
}

Verification Test

// 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);
}

Challenge 4: TJ Array Complexity

The Problem

TJ arrays contain mixed strings and numbers:

[(H) -10 (e) -5 (llo)] TJ

The numbers are kerning adjustments in thousandths of an em.

Complication 1: Sign Convention

  • Positive number: Move LEFT (counterintuitive!)
  • Negative number: Move RIGHT
-10 means "add 10/1000 em of space"

Complication 2: Partial Redaction

If redacting "ll" from "Hello":

Original: [(H) -10 (e) -5 (llo)] TJ
After:    [(H) -10 (e) -5 (o)] TJ   # Must preserve kerning!

Solution

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
}

Challenge 5: State Tracking

The Problem

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.

State Components

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; }
    // ...
}

Solution: State Stack

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;

Challenge 6: Invisible Text (OCR Layers)

The Problem

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

Solution

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
}

Challenge 7: Sequential Redaction and Bounding Box Accumulation

The Problem

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

Root Cause

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:

  1. First redaction pass creates reconstructed text operations
  2. Reconstructed operations have placeholder bounding boxes (not real letter positions)
  3. When a segment has unmapped characters, it uses the operation's bbox width
  4. For reconstructed operations, this bbox is a placeholder/estimate
  5. Dividing this by text length gives huge per-character widths
  6. These widths accumulate (line 128), creating segments spanning 1000+ points
  7. OperationReconstructor uses these inflated widths for new operation bboxes
  8. Second redaction pass finds these inflated bboxes intersecting unrelated areas
  9. 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!

The Fix

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

Lessons Learned

  1. Reconstructed operations lose spatial information - their bboxes are estimates
  2. Don't use operation bboxes for per-character calculations - they're whole-operation aggregates
  3. Sequential operations break if bboxes aren't accurate - each pass compounds errors
  4. Test with multiple sequential redactions - single redaction hides this bug

Verification Test

[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);
}

Related Issues

  • Issue #124: Sequential redaction failures in RealWorldBirthCertificateTests
  • Issue #125: Adaptive Y-tolerance for letter matching (related spatial issue)

Challenge 8: Text Block Reconstruction and BT/ET Filtering

The Problem

When reconstructing text operations, we create NEW BT/Tf/Tm/Tj/ET sequences, but the ORIGINAL TextStateOperations remain in the operation list, causing:

  1. Nested BT operators (invalid PDF - BT cannot be nested)
  2. Orphaned operators without operands (Tf, Tm, etc. missing their arguments)
  3. 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!)

Root Cause

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.

The Solution: Two-Pass Block-Aware Filtering

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-is

Implementation:

// 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);
    }
}

Edge Cases Handled

  1. Multiple TextOperations in one block: If ANY intersects, reconstruct ALL
  2. Nested BT blocks (malformed PDF): Use stack during identification
  3. Partial redaction within block: Still reconstruct entire block for consistency
  4. TextOperations outside blocks: Keep as-is (though technically invalid PDF)

Verification Test

[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");
}

Related Issues

  • Issue #124: Sequential redaction failures in RealWorldBirthCertificateTests
  • Issue #125: Adaptive Y-tolerance for letter matching (related spatial issue)

Current Limitations

Not Yet Handled

  1. Form XObjects: Text in reusable content not parsed
  2. Type 3 fonts: Glyph-as-content-stream not modified
  3. Inline images: BI...ID...EI not fully parsed
  4. Metadata: /Info dictionary not sanitized
  5. Annotations: Appearance streams not checked

Planned Improvements

See PdfEditor.Redaction/IMPLEMENTATION_PLAN.md for the new library approach.

Verification

Required Test Assertion

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");

External Tool Verification

# After redaction, verify with pdftotext
pdftotext redacted.pdf -
# Should NOT contain redacted text

Codebase Locations

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

Clone this wiki locally