Exis.PdfEditor — The .NET PDF Toolkit Exis.PdfEditor — The Python PDF Toolkit
Find & replace text in PDFs (with forensic-grade de-identification — the original text is permanently removed from the file, not just hidden), merge, split, build, fill forms, redact, sign, optimize, classify scanned vs. digital pages, and convert to PDF/A — all from your C# code. Zero dependencies. Lossless when you want it; destructive when you need it. Find & replace text in PDFs (with forensic-grade de-identification — the original text is permanently removed from the file, not just hidden), merge, split, build, fill forms, redact, sign, optimize, classify scanned vs. digital pages, and convert to PDF/A — all from your Python code. Native AOT-compiled binary, no .NET runtime required.
dotnet add package Exis.PdfEditor
Install-Package Exis.PdfEditor
pip install exis-pdfeditor
poetry add exis-pdfeditor
See it in Action
Watch a 5-minute walkthrough of the library — from dotnet add package to batch find & replace, regex patterns, and styled replacements. Console, WPF, and WinForms demo apps included.
Watch a walkthrough of the Python library — from pip install exis-pdfeditor to batch find & replace, regex patterns, and styled replacements. Browse the Python sample app on GitHub for working examples.
Why Exis.PdfEditor?
Most PDF libraries destroy your document
Most libraries convert your PDF to an intermediate format (HTML, image, or DOM), apply changes, then rebuild the entire file from scratch. This destructive render-rebuild cycle inevitably breaks the original document.
What gets broken:
- Interactive form fields are flattened or lost
- Digital signatures are invalidated
- Character spacing and kerning are altered
- Font substitution changes the appearance
- Bookmarks, links, and annotations are dropped
Exis.PdfEditor edits content streams directly
Exis.PdfEditor parses the actual PDF content streams and performs surgical replacements at the operator level. No intermediate format. No rebuild. The output is the same file with only the targeted changes applied.
What gets preserved:
- All form fields remain fully interactive
- Digital signatures stay valid on unmodified pages
- Original character spacing is maintained exactly
- Embedded fonts are reused, not substituted
- Bookmarks, annotations, and metadata are untouched
Other Libraries
Exis.PdfEditor
Find & replace that actually removes the text — not just hides it
Most "PDF redaction" tools draw a black box, paint a white rectangle, or hide text behind an annotation. The original characters stay in the file's byte stream and any forensic tool can recover them. This is the root cause of nearly every public PDF redaction failure you've read about — from the 2016 court filing to ongoing FOIA disclosures.
Exis.PdfEditor edits the text-showing operators inside the content stream directly — the replaced characters are genuinely gone from the page content, with no hidden overlay to peel back. By default PdfFindReplace.Execute uses an incremental update (fast, smaller output), which keeps the original bytes as a prior revision in the file. That's normal PDF revision history — and for de-identification it is not enough.
To produce a single clean revision with the original permanently dropped, finalize with PdfOptimizer.Optimize. After this step the original text cannot be recovered from the file bytes by any forensic tool, content-stream walker, hex editor, or PDF revision-history utility — the bytes do not exist in the file. See the Destructive Find & Replace code sample below.
Code Samples
Find & Replace Text (3 Lines of Code)
using Exis.PdfEditor;
using Exis.PdfEditor.Licensing;
ExisLicense.Initialize(); // Complimentary 14-day trial - no key needed
var result = PdfFindReplace.Execute(
"contract.pdf",
"contract-updated.pdf",
"Acme Corporation",
"Globex Industries");
Console.WriteLine($"Replaced {result.TotalReplacements} occurrences " +
$"across {result.PagesModified} pages.");
Find & Replace Text (3 Lines of Code)
import exis_pdfeditor
exis_pdfeditor.initialize() # Complimentary 14-day trial - no key needed
result = exis_pdfeditor.find_replace(
"contract.pdf",
"contract-updated.pdf",
"Acme Corporation",
"Globex Industries",
)
print(f"Replaced {result.totalReplacements} occurrences "
f"across {result.pagesModified} pages.")
Destructive Find & Replace (De-identification — No Recoverable Original)
By default, PdfFindReplace.Execute writes an incremental update — fast, smaller output, but the original bytes survive as a prior revision in the file. For de-identification (PHI, PII, account numbers), follow up with PdfOptimizer.Optimize to collapse the file to a single clean revision. After this step, the original value cannot be recovered by any forensic tool. Verify by counting %%EOF markers in the output — 1 means a single clean revision; 2+ means prior content is still present.
// 1) Replace the sensitive text.
PdfFindReplace.Execute("input.pdf", "tmp.pdf", "John Smith", "PT-0047");
// 2) Collapse to a single clean revision — the pre-replacement text is dropped.
PdfOptimizer.Optimize("tmp.pdf", "safe-to-send.pdf",
new PdfOptimizeOptions { RemoveDuplicateObjects = true });
// 3) Optional: verify the output is a single revision (no prior content retained).
int revisions = System.Text.RegularExpressions.Regex.Matches(
System.Text.Encoding.ASCII.GetString(File.ReadAllBytes("safe-to-send.pdf")),
"%%EOF").Count;
// 1 = clean | 2+ = prior revisions still present
Why PdfOptimizer.Optimize rather than disabling incremental update? Optimize rebuilds through the document writer, which reproduces embedded subset fonts faithfully. A raw non-incremental rewrite can corrupt subset font encodings. Optimize is the supported way to flatten to one clean revision for sharing.
Destructive Find & Replace (De-identification — No Recoverable Original)
By default, find_replace writes an incremental update — fast, smaller output, but the original bytes survive as a prior revision in the file. For de-identification, follow up with optimize to collapse the file to a single clean revision. After this step the original value cannot be recovered by any forensic tool.
# 1) Replace the sensitive text.
exis_pdfeditor.find_replace("input.pdf", "tmp.pdf", "John Smith", "PT-0047")
# 2) Collapse to a single clean revision - the pre-replacement text is dropped.
exis_pdfeditor.optimize("tmp.pdf", "safe-to-send.pdf",
remove_duplicate_objects=True)
# 3) Optional: count %%EOF markers in the output (1 = clean, 2+ = revisions remain).
with open("safe-to-send.pdf", "rb") as f:
revisions = f.read().count(b"%%EOF")
Classify Page Content (No License Required)
Detect whether each page is real text, a scan, an already-OCR'd scan, or empty. Pure content-stream analysis — no OCR engine or native dependencies. Useful for routing work (only scanned pages need image redaction/OCR) and for deciding whether text find/replace can possibly succeed.
IReadOnlyList<PageContentInfo> pages = PdfInspector.AnalyzePages("input.pdf");
foreach (var p in pages)
Console.WriteLine($"Page {p.PageNumber}: {p.Kind} " +
$"(text chars={p.TextCharCount}, image={p.ImageCoverageRatio:P0}, ocrLayer={p.HasInvisibleTextLayer})");
// Roll up to a single document kind: Digital | Scanned | AlreadyOcrd | Mixed | Empty
DocumentContentKind kind = PdfInspector.ClassifyDocument("input.pdf");
if (kind is DocumentContentKind.Scanned or DocumentContentKind.Mixed)
Console.WriteLine("Has scanned pages - text find/replace won't reach image text.");
Classify Page Content (No License Required)
Detect whether each page is real text, a scan, an already-OCR'd scan, or empty — pure content-stream analysis, no OCR engine required.
pages = exis_pdfeditor.analyze_pages("input.pdf")
for p in pages:
print(f"Page {p.pageNumber}: {p.kind} "
f"(text chars={p.textCharCount}, image={p.imageCoverageRatio:.0%}, "
f"ocrLayer={p.hasInvisibleTextLayer})")
# Roll up to a single document kind
kind = exis_pdfeditor.classify_document("input.pdf")
# "Digital" | "Scanned" | "AlreadyOcrd" | "Mixed" | "Empty"
Add an Invisible Searchable Text Layer
Overlay selectable, searchable text on a page using an embedded TrueType font (drawn in text render mode 3 so it is invisible). Useful for accessibility or making image-only content findable — the page's existing visible content is preserved.
byte[] ttf = File.ReadAllBytes("DejaVuSans.ttf"); // any TrueType font with the glyphs you need
byte[] output = PdfTextLayer.AddInvisibleTextLayer(
File.ReadAllBytes("scan.pdf"), pageNumber: 1, items: new[]
{
new PositionedText { Text = "Patient Name: Nguyễn Phạm", X = 72, Y = 700, FontSize = 12 }
}, ttf);
File.WriteAllBytes("searchable.pdf", output);
Multiple Find & Replace Pairs
var pairs = new[]
{
new FindReplacePair("2025", "2026"),
new FindReplacePair("Draft", "Final"),
new FindReplacePair("CONFIDENTIAL", "PUBLIC"),
};
var result = PdfFindReplace.Execute(
"report.pdf",
"report-final.pdf",
pairs);
// For de-identification across many pairs (PHI, account numbers, etc.),
// chain a single PdfOptimizer.Optimize at the end to drop every superseded
// revision in one pass — producing a clean, forensically-safe output.
PdfOptimizer.Optimize("report-final.pdf", "report-clean.pdf",
new PdfOptimizeOptions { RemoveDuplicateObjects = true });
Multiple Find & Replace Pairs
result = exis_pdfeditor.find_replace(
"report.pdf",
"report-final.pdf",
pairs=[
{"search": "2025", "replace": "2026"},
{"search": "Draft", "replace": "Final"},
{"search": "CONFIDENTIAL", "replace": "PUBLIC"},
],
)
Regex Pattern Matching
var options = new PdfFindReplaceOptions { UseRegex = true };
// Replace all US phone numbers with a placeholder
var result = PdfFindReplace.Execute(
"document.pdf",
"redacted.pdf",
@"\(\d{3}\)\s?\d{3}-\d{4}",
"[PHONE REDACTED]",
options);
// If this is a real redaction (not just a placeholder swap), follow with
// PdfOptimizer.Optimize so the original phone numbers cannot be recovered
// from prior file revisions. See the "Destructive Find & Replace" sample above.
Regex Pattern Matching
# Replace all US phone numbers with a placeholder
result = exis_pdfeditor.find_replace(
"document.pdf",
"redacted.pdf",
pairs=[
{
"search": r"\(\d{3}\)\s?\d{3}-\d{4}",
"replace": "[PHONE REDACTED]",
"isRegex": True,
},
],
)
Licensed Usage
// Complimentary 14-day trial — no key needed, full features unlocked
ExisLicense.Initialize();
// Or, after purchase (pdfbatcheditor.com/developers — $499/developer/year)
ExisLicense.Initialize("XXXX-XXXX-XXXX-XXXX");
// Unlimited pages, no restrictions, no console messages
var result = PdfFindReplace.Execute("large-doc.pdf", "output.pdf", "old", "new");
Licensed Usage
# Complimentary 14-day trial — no key needed, full features unlocked
exis_pdfeditor.initialize()
# Or, after purchase (pdfbatcheditor.com/developers — $499/developer/year)
exis_pdfeditor.initialize("XXXX-XXXX-XXXX-XXXX")
# Or set EXIS_PDF_LICENSE_KEY environment variable
# Unlimited pages, no restrictions, no console messages
result = exis_pdfeditor.find_replace("large-doc.pdf", "output.pdf", "old", "new")
Text Fitting Options
var options = new PdfFindReplaceOptions
{
CaseSensitive = true,
WholeWordOnly = false,
UseRegex = false,
UseIncrementalUpdate = true,
TextFitting = TextFittingMode.Adaptive, // Best quality text fitting
MinHorizontalScale = 70, // Minimum Tz percentage (50-100)
MaxFontSizeReduction = 1.5 // Max font size reduction in points
};
var result = PdfFindReplace.Execute(
"contract.pdf", "updated.pdf",
"Short Name", "A Much Longer Replacement Name That Needs Fitting",
options);
Text Fitting Options
result = exis_pdfeditor.find_replace(
"contract.pdf",
"updated.pdf",
"Short Name",
"A Much Longer Replacement Name That Needs Fitting",
case_sensitive=True,
whole_word=False,
use_regex=False,
use_incremental_update=True,
text_fitting="adaptive", # none | preserve_width | fit_to_page | adaptive
min_horizontal_scale=70, # Minimum Tz percentage (50-100)
max_font_size_reduction=1.5, # Max font size reduction in points
)
Text Formatting & Decorations
// Apply formatting to replacement text
var result = PdfFindReplace.Execute(
"input.pdf", "output.pdf",
"old text", "new text",
new PdfFindReplaceOptions
{
ReplacementTextColor = PdfColor.Red, // Font color of replaced text
ReplacementHighlightColor = PdfColor.Yellow, // Background highlight behind text
ReplacementBold = true, // Faux bold via fill+stroke rendering
ReplacementUnderline = true, // Draw underline below text
ReplacementStrikethrough = true // Draw line through text
});
Text Formatting & Decorations
# Apply formatting to replacement text
result = exis_pdfeditor.find_replace(
"input.pdf",
"output.pdf",
"old text",
"new text",
replacement_text_color={"r": 1, "g": 0, "b": 0}, # Red font
replacement_highlight_color={"r": 1, "g": 1, "b": 0}, # Yellow highlight
replacement_bold=True, # Faux bold
replacement_underline=True,
replacement_strikethrough=True,
)
Merge PDFs
// Merge multiple PDFs into one, preserving page dimensions and resources
byte[] merged = PdfMerger.Merge(new[] { "cover.pdf", "report.pdf", "appendix.pdf" });
File.WriteAllBytes("combined.pdf", merged);
// Or write directly to a file
PdfMerger.MergeToFile(new[] { "file1.pdf", "file2.pdf" }, "merged.pdf");
// Merge with page range selection
byte[] selected = PdfMerger.Merge(new[]
{
new PdfMergeInput(File.ReadAllBytes("doc1.pdf"), new[] { 1, 3, 5 }),
new PdfMergeInput(File.ReadAllBytes("doc2.pdf")) // all pages
});
Merge PDFs
# Merge multiple PDFs into one, preserving page dimensions and resources
exis_pdfeditor.merge(
["cover.pdf", "report.pdf", "appendix.pdf"],
"combined.pdf",
)
Split PDFs
// Split into individual pages
List<byte[]> pages = PdfSplitter.Split("input.pdf");
// Extract specific pages (1-based)
byte[] subset = PdfSplitter.ExtractPages("input.pdf", new[] { 1, 3, 5 });
// Split to individual files with naming pattern
PdfSplitter.SplitToFiles("input.pdf", "page_{0}.pdf");
Split PDFs
# Split into individual page files
result = exis_pdfeditor.split("input.pdf", "output_folder/")
print(f"Split into {result.pageCount} files")
# Extract specific pages (1-based) into a new PDF
exis_pdfeditor.extract_pages("input.pdf", "subset.pdf", pages=[1, 3, 5])
Build PDFs from Scratch
byte[] pdf = PdfBuilder.Create()
.WithMetadata(m => m.Title("Report").Author("Exis"))
.AddPage(page => page
.Size(PdfPageSize.A4)
.AddText("Hello, World!", x: 72, y: 750, fontSize: 24,
options: o => o.Font("Helvetica").Bold().Color(0, 0, 0.8))
.AddText("Generated with Exis.PdfEditor", x: 72, y: 720, fontSize: 12)
.AddLine(72, 710, 523, 710, strokeWidth: 1)
.AddRectangle(72, 600, 200, 80, fill: true,
fillRed: 0.95, fillGreen: 0.95, fillBlue: 1.0)
.AddImage(jpegBytes, x: 300, y: 400, width: 200, height: 150))
.AddPage(page => page
.Size(PdfPageSize.Letter)
.AddText("Page 2", x: 72, y: 700, fontSize: 14))
.Build();
File.WriteAllBytes("output.pdf", pdf);
Stamp / Overlay another PDF
# Overlay a letterhead on top of every page
result = exis_pdfeditor.stamp(
"document.pdf",
"stamped.pdf",
"letterhead.pdf",
mode="overlay",
)
print(f"Stamped {result.pagesStamped} pages")
# Or use as a background underlay
exis_pdfeditor.stamp(
"document.pdf",
"stamped.pdf",
"background.pdf",
mode="underlay",
opacity=0.5,
page_range=[1],
)
Extract Text
// Extract all text from a PDF
PdfTextResult text = PdfTextExtractor.ExtractText("input.pdf");
Console.WriteLine(text.FullText);
// Extract from specific pages only
PdfTextResult partial = PdfTextExtractor.ExtractText("input.pdf", new[] { 1, 3 });
// Structured extraction with position and font data
PdfStructuredTextResult structured = PdfTextExtractor.ExtractStructured("input.pdf");
foreach (var block in structured.Pages[0].TextBlocks)
Console.WriteLine($"[{block.X:F0},{block.Y:F0}] {block.Text} " +
$"(font={block.FontName}, size={block.FontSize})");
Extract Text
# Extract all text from a PDF
result = exis_pdfeditor.extract_text("input.pdf")
print(result.fullText)
# Extract from specific pages only
partial = exis_pdfeditor.extract_text("input.pdf", pages=[1, 3])
# Structured extraction with position and font data
structured = exis_pdfeditor.extract_text_structured("input.pdf")
for block in structured.pages[0].textBlocks:
print(f"[{block.x:.0f},{block.y:.0f}] {block.text} "
f"(font={block.fontName}, size={block.fontSize})")
Inspect Document (No License Required)
PdfDocumentInfo info = PdfInspector.Inspect("input.pdf");
Console.WriteLine($"Pages: {info.PageCount}");
Console.WriteLine($"Title: {info.Title}");
Console.WriteLine($"Fonts: {string.Join(", ", info.FontsUsed)}");
Console.WriteLine($"Encrypted: {info.IsEncrypted}");
Console.WriteLine($"Form fields: {info.FormFieldCount}");
Inspect Document (No License Required)
info = exis_pdfeditor.inspect("input.pdf")
print(f"Pages: {info.pageCount}")
print(f"Title: {info.title}")
print(f"Fonts: {', '.join(info.fontsUsed)}")
print(f"Encrypted: {info.isEncrypted}")
print(f"Form fields: {info.formFieldCount}")
Image Replacement
// Find all images in a PDF
var found = PdfImageEditor.FindImages("input.pdf");
foreach (var img in found.Images)
Console.WriteLine($"Image #{img.Index}: {img.PixelWidth}x{img.PixelHeight} " +
$"{img.ColorSpace} {img.Format} on page(s) {string.Join(", ", img.PageNumbers)}");
// Replace all images with a new one
byte[] newLogo = File.ReadAllBytes("new-logo.jpg");
var result = PdfImageEditor.ReplaceAll("input.pdf", "output.pdf", newLogo);
Console.WriteLine($"Replaced {result.ImagesReplaced} of {result.ImagesFound} images");
// Replace specific images by index or page range
var selective = PdfImageEditor.Replace("input.pdf", "output.pdf", newLogo,
new PdfImageReplaceOptions { ImageIndices = new[] { 0, 2 } });
Image Replacement
# Find all images in a PDF
result = exis_pdfeditor.find_images("input.pdf")
for img in result.images:
print(f"Image {img.index}: {img.pixelWidth}x{img.pixelHeight} "
f"{img.colorSpace} {img.format} on page(s) {img.pageNumbers}")
# Replace all images with a new one
result = exis_pdfeditor.replace_image("input.pdf", "output.pdf", "new-logo.jpg")
print(f"Replaced {result.imagesReplaced} of {result.imagesFound} images")
# Replace specific images by index, scoped to a page
exis_pdfeditor.replace_image(
"input.pdf", "output.pdf", "new-logo.jpg",
image_indices=[0, 2],
page_range=[1],
scale_mode="scale_to_fit",
)
Auto-Layout Document Builder
byte[] pdf = PdfDocumentBuilder.Create()
.PageSize(PdfPageSize.A4)
.Margins(72)
.WithMetadata(m => m.Title("Report").Author("Exis"))
.Header(h => h
.AddText("Quarterly Report", PdfHorizontalAlignment.Center, 12, o => o.Bold())
.AddLine())
.Footer(f => f
.AddLine()
.AddPageNumber()) // "Page 1 of 3"
.AddParagraph("Introduction", 18, o => o.Bold())
.AddSpacing(8)
.AddParagraph("This report covers Q1 results.")
.AddSpacing(12)
.AddTable(t => t
.Columns(2, 1, 1)
.AlternatingRowBackground(0.95, 0.95, 1.0)
.HeaderRow(r => r.AddCell("Product").AddCell("Units").AddCell("Revenue"))
.AddRow(r => r.AddCell("Widget A").AddCell("1,200").AddCell("$24,000"))
.AddRow(r => r.AddCell("Widget B").AddCell("850").AddCell("$17,000")))
.AddPageBreak()
.AddParagraph("Appendix", 14, o => o.Bold())
.Build();
Page Editing (rotate, crop, reorder, delete)
# Rotate all pages 90 degrees clockwise
exis_pdfeditor.rotate("in.pdf", "rotated.pdf", angle=90)
# Rotate only specific pages
exis_pdfeditor.rotate("in.pdf", "rotated.pdf", angle=180, pages=[2, 4])
# Crop pages to a rectangle
exis_pdfeditor.crop(
"in.pdf", "cropped.pdf",
rect={"x": 50, "y": 50, "width": 500, "height": 700},
)
# Reorder pages
exis_pdfeditor.reorder("in.pdf", "reordered.pdf", order=[3, 1, 2])
# Delete pages
exis_pdfeditor.delete_pages("in.pdf", "trimmed.pdf", pages=[2, 4])
Form Filling
// Read form fields
List<PdfFormField> fields = PdfFormFiller.GetFields("form.pdf");
foreach (var field in fields)
Console.WriteLine($"{field.Name} ({field.FieldType}) = {field.CurrentValue}");
// Fill fields
var result = PdfFormFiller.Fill("form.pdf", "filled.pdf", new Dictionary<string, string>
{
{ "FirstName", "John" },
{ "LastName", "Doe" },
{ "State", "CA" },
{ "AgreeToTerms", "Yes" } // checkbox
});
Console.WriteLine($"Filled {result.FieldsFilled} fields");
// Flatten form (merge field appearances, remove interactive fields)
PdfFormFiller.Flatten("filled.pdf", "flattened.pdf");
Form Filling
# Read form fields
fields = exis_pdfeditor.list_fields("form.pdf")
for field in fields:
print(f"{field.name} ({field.type}) = {field.value}")
# Fill fields
result = exis_pdfeditor.fill_form(
"form.pdf", "filled.pdf",
fields={
"FirstName": "John",
"LastName": "Doe",
"State": "CA",
"AgreeToTerms": "Yes", # checkbox
},
)
print(f"Filled {result.fieldsFilled} fields")
# Flatten in one call - merge values into page content
exis_pdfeditor.fill_form(
"form.pdf", "flattened.pdf",
fields={"FirstName": "John"},
flatten=True,
)
Redaction
var result = PdfRedactor.Redact("input.pdf", "redacted.pdf", new[]
{
// Text-based redaction
new PdfRedaction { Text = "CONFIDENTIAL" },
// Regex pattern (e.g., SSN)
new PdfRedaction { Text = @"\d{3}-\d{2}-\d{4}", IsRegex = true },
// Replace with alternative text
new PdfRedaction { Text = "SECRET", ReplaceWith = "[REDACTED]" },
// Area-based redaction on specific page
new PdfRedaction { PageNumber = 3, Area = new PdfRect(100, 200, 300, 50) }
});
Console.WriteLine($"Applied {result.RedactionsApplied} redactions");
Redaction
result = exis_pdfeditor.redact(
"input.pdf", "redacted.pdf",
redactions=[
# Text-based redaction
{"text": "CONFIDENTIAL"},
# Regex pattern (e.g., SSN)
{"text": r"\d{3}-\d{2}-\d{4}", "isRegex": True},
# Replace with alternative text
{"text": "SECRET", "replaceWith": "[REDACTED]"},
# Area-based redaction on a specific page
{
"area": {"x": 100, "y": 200, "width": 300, "height": 50},
"pageNumber": 3,
},
],
)
print(f"Applied {result.redactionsApplied} redactions")
Optimization
var result = PdfOptimizer.Optimize("input.pdf", "optimized.pdf", new PdfOptimizeOptions
{
CompressStreams = true,
RemoveDuplicateObjects = true,
RemoveMetadata = false,
DownsampleImages = true,
MaxImageDpi = 150
});
Console.WriteLine($"Saved {result.BytesSaved} bytes ({result.ReductionPercent:F1}%)");
Console.WriteLine($"Images downsampled: {result.ImagesDownsampled}");
Optimization
result = exis_pdfeditor.optimize(
"input.pdf", "optimized.pdf",
downsample_images=True,
max_image_dpi=150,
remove_metadata=False,
)
print(f"Reduced {result.originalSize:,} -> {result.optimizedSize:,} bytes "
f"({result.reductionPercent:.1f}% smaller)")
print(f"Images downsampled: {result.imagesDownsampled}")
Digital Signatures (.NET 8, 9, 10+)
using System.Security.Cryptography.X509Certificates;
// Sign a PDF
var cert = new X509Certificate2("certificate.pfx", "password");
PdfSigner.Sign("input.pdf", "signed.pdf", new PdfSignOptions
{
Certificate = cert,
Reason = "Approved",
Location = "New York",
ContactInfo = "admin@example.com"
});
// Verify a signed PDF
PdfSignatureInfo info = PdfSigner.Verify("signed.pdf");
Console.WriteLine($"Signed: {info.IsSigned}");
Console.WriteLine($"Valid: {info.IsValid}");
Console.WriteLine($"Signer: {info.SignerName}");
Console.WriteLine($"Certificate: {info.CertificateSubject}");
Console.WriteLine($"Issuer: {info.CertificateIssuer}");
Console.WriteLine($"Timestamp: {info.HasTimestamp}");
// Verify all signatures in a multi-signed document
List<PdfSignatureInfo> all = PdfSigner.VerifyAll("multi-signed.pdf");
foreach (var sig in all)
Console.WriteLine($"{sig.SignerName}: valid={sig.IsValid}");
Digital Signatures
# Sign with an invisible signature
exis_pdfeditor.sign(
"input.pdf", "signed.pdf",
cert_path="certificate.pfx",
cert_password="password",
reason="Approved",
location="New York",
signer_name="John Doe",
)
# Sign with a visible signature box on page 1
exis_pdfeditor.sign(
"input.pdf", "signed.pdf",
cert_path="certificate.pfx",
cert_password="password",
visible=True,
page=1,
rect={"x": 50, "y": 50, "width": 200, "height": 60},
)
# Verify a signed PDF
info = exis_pdfeditor.verify("signed.pdf")
print(f"Signed: {info.isSigned}")
print(f"Valid: {info.isValid}")
print(f"Signer: {info.signerName}")
print(f"Reason: {info.reason}")
# Verify all signatures in a multi-signed document
all_sigs = exis_pdfeditor.verify("multi-signed.pdf", all_signatures=True)
for sig in all_sigs:
print(f"{sig.signerName}: valid={sig.isValid}")
PDF/A Compliance
// Validate (no license required)
// Levels: PdfA1b, PdfA2b, PdfA2u, PdfA3b, PdfA3u
PdfAValidationResult result = PdfAConverter.Validate("input.pdf", PdfALevel.PdfA2b);
Console.WriteLine($"Compliant: {result.IsCompliant}");
foreach (var v in result.Violations)
Console.WriteLine($" [{v.Code}] {v.Message} (auto-fix: {v.CanAutoFix})");
// Convert to PDF/A
byte[] pdfa = PdfAConverter.Convert("input.pdf", PdfALevel.PdfA2b);
File.WriteAllBytes("output-pdfa.pdf", pdfa);
PDF/A Compliance
# Validate (no license required)
# Levels: 1b, 2b, 2u, 3b, 3u
result = exis_pdfeditor.pdfa_validate("input.pdf", level="2b")
print(f"Compliant: {result.isCompliant}")
for v in result.violations:
print(f" [{v.code}] {v.message} (auto-fix: {v.canAutoFix})")
# Convert to PDF/A
exis_pdfeditor.pdfa_convert("input.pdf", "output-pdfa.pdf", level="2b")
Watermark
// Diagonal watermark across all pages (default style)
PdfWatermark.AddText("input.pdf", "output.pdf", "CONFIDENTIAL");
// Custom: large red "DRAFT" across the page, 50% opacity, pages 1-3 only
PdfWatermark.AddText("input.pdf", "output.pdf", "DRAFT", new PdfWatermarkOptions
{
Position = WatermarkPosition.Across, // Top | Bottom | Center | Across
FontSize = 72,
TextColor = PdfColor.Red,
Opacity = 0.5,
PageRange = new[] { 1, 2, 3 }
});
// Footer-style watermark on every page
PdfWatermark.AddText("input.pdf", "output.pdf", "Internal Use Only",
new PdfWatermarkOptions
{
Position = WatermarkPosition.Bottom,
FontSize = 14,
Opacity = 0.5
});
Watermark
# Basic diagonal watermark (default style)
result = exis_pdfeditor.watermark("input.pdf", "output.pdf", "DRAFT")
print(f"Watermarked {result.pagesWatermarked} of {result.totalPages} pages")
# Large red CONFIDENTIAL across the page, 15% opacity
exis_pdfeditor.watermark(
"input.pdf", "output.pdf",
"CONFIDENTIAL",
position="across", # top | bottom | center | across
font_size=72,
text_color={"r": 1, "g": 0, "b": 0},
opacity=0.15,
)
# Footer-style watermark on page 1 only
exis_pdfeditor.watermark(
"input.pdf", "footer.pdf",
"Internal Use Only - Page 1",
position="bottom",
font_size=14,
opacity=0.5,
page_range=[1],
)
Bates Numbering
// Defaults: starts at 1, 6 digits, bottom-right corner
BatesStampResult result = PdfBatesStamp.ApplyBatesStamp("input.pdf", "stamped.pdf");
Console.WriteLine($"Stamped {result.PagesStamped} pages: " +
$"{result.FirstNumber} -> {result.LastNumber}");
// Full legal production: prefix, confidentiality label, continuous counter
PdfBatesStamp.ApplyBatesStamp("input.pdf", "stamped.pdf", new BatesStampOptions
{
Prefix = "SMITH",
StartNumber = 1,
Digits = 6, // Produces SMITH000001, SMITH000002, ...
Position = BatesPosition.BottomRight, // Any of the 6 corners
FontSize = 10,
TextColor = PdfColor.Black,
MarginInches = 0.5f,
ConfidentialityLabel = "CONFIDENTIAL", // Stamped on opposite edge
SkipFirstPage = true, // Skip cover pages
CounterAdvancesOnSkippedPages = false // Counter holds during skips
});
// Continuous numbering across multiple files in list order
int next = 1;
foreach (var path in files)
{
var r = PdfBatesStamp.ApplyBatesStamp(path, $"out/{Path.GetFileName(path)}",
new BatesStampOptions { Prefix = "ABC", StartNumber = next, Digits = 6 });
next = r.LastNumber + 1;
}
Bates Numbering
# Note: Bates numbering is a .NET-only capability in the current Exis.PdfEditor
# release. The PDF Batch Editor desktop app exposes it for macOS/Windows users,
# and the .NET library ships the PdfBatesStamp class above. A Python binding is
# on the roadmap — contact support@exisone.com if you need early access.
# Common workaround today: use the existing watermark() function with a
# per-page loop to emulate simple Bates stamps.
import exis_pdfeditor
info = exis_pdfeditor.inspect("input.pdf")
current = "input.pdf"
counter = 1
for page in range(1, info.pageCount + 1):
stamp = f"SMITH{counter:06d}"
next_out = f"_tmp_{page}.pdf"
exis_pdfeditor.watermark(
current, next_out, stamp,
position="bottom",
font_size=10,
opacity=1.0,
page_range=[page],
)
current = next_out
counter += 1
# Final result is the last _tmp_ file; rename/move as desired.
XMP & Info Metadata
// Read both XMP packet and /Info dictionary. License-free, works on encrypted PDFs.
PdfMetadataInfo meta = PdfXmpMetadata.Extract("input.pdf");
if (meta.HasXmp)
{
Console.WriteLine($"XMP packet: {meta.XmpByteSize} bytes");
Console.WriteLine(meta.XmpXml); // Full <?xpacket ... ?> payload as UTF-8
}
if (meta.HasInfo)
{
PdfInfoDict info = meta.Info;
Console.WriteLine($"Title: {info.Title}");
Console.WriteLine($"Author: {info.Author}");
Console.WriteLine($"Subject: {info.Subject}");
Console.WriteLine($"Keywords: {info.Keywords}");
Console.WriteLine($"Creator: {info.Creator}");
Console.WriteLine($"Producer: {info.Producer}");
Console.WriteLine($"Created: {info.CreationDate:o}");
Console.WriteLine($"Modified: {info.ModificationDate:o}");
foreach (var kv in info.Custom) // Non-standard /Info keys
Console.WriteLine($"[custom] {kv.Key} = {kv.Value}");
}
// Replace the /Info dictionary (trailer ref must already exist)
var newInfo = new PdfInfoDict
{
Title = "Quarterly Report",
Author = "Finance Team",
Subject = "Q1 2026 earnings summary",
Keywords = "earnings, Q1, 2026",
Creator = "Exis.PdfEditor",
Producer = "Exis.PdfEditor 3.6",
CreationDate = new DateTime(2026, 4, 1, 9, 0, 0, DateTimeKind.Utc),
ModificationDate = DateTime.UtcNow,
};
newInfo.Custom["Company"] = "Exis LLC";
newInfo.Custom["Revision"] = "v3.6.3";
PdfXmpMetadata.SetInfo("input.pdf", "output.pdf", newInfo);
// Replace the XMP packet (RDF/XML wrapped in <?xpacket ...?>)
string xmp = @"<?xpacket begin=""""?>
<x:xmpmeta xmlns:x=""adobe:ns:meta/"">
<rdf:RDF xmlns:rdf=""http://www.w3.org/1999/02/22-rdf-syntax-ns#"">
<rdf:Description xmlns:dc=""http://purl.org/dc/elements/1.1/"">
<dc:title><rdf:Alt><rdf:li xml:lang=""x-default"">Quarterly Report</rdf:li></rdf:Alt></dc:title>
<dc:creator><rdf:Seq><rdf:li>Finance Team</rdf:li></rdf:Seq></dc:creator>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
<?xpacket end=""w""?>";
PdfXmpMetadata.SetXmp("input.pdf", "output.pdf", xmp);
// Wipe metadata for privacy (both XMP + /Info in one incremental update)
PdfXmpMetadata.RemoveAll("input.pdf", "stripped.pdf");
// Or drop only one form:
PdfXmpMetadata.RemoveXmp("input.pdf", "stripped-xmp.pdf");
PdfXmpMetadata.RemoveInfo("input.pdf", "stripped-info.pdf");
XMP & Info Metadata
# Read both XMP packet and /Info dictionary. License-free, works on encrypted PDFs.
meta = exis_pdfeditor.get_metadata("document.pdf")
if meta.hasXmp:
print(f"XMP packet: {meta.xmpByteSize} bytes")
print(meta.xmpXml) # Full <?xpacket ... ?> payload as UTF-8 text
if meta.hasInfo:
info = meta.info
print(f"Title: {info.title}")
print(f"Author: {info.author}")
print(f"Subject: {info.subject}")
print(f"Keywords: {info.keywords}")
print(f"Creator: {info.creator}")
print(f"Producer: {info.producer}")
print(f"Created: {info.creationDate}") # ISO 8601 string or None
print(f"Modified: {info.modificationDate}")
for key, value in vars(info.custom).items(): # Non-standard /Info keys
print(f" [custom] {key} = {value}")
# Replace the /Info dictionary (trailer ref must already exist)
from datetime import datetime, timezone
exis_pdfeditor.set_info(
"input.pdf", "output.pdf",
info={
"title": "Quarterly Report",
"author": "Finance Team",
"subject": "Q1 2026 earnings summary",
"keywords": "earnings, Q1, 2026",
"creator": "exis-pdfeditor",
"producer": "Exis.PdfEditor 3.6",
"creationDate": datetime(2026, 4, 1, 9, 0, tzinfo=timezone.utc),
"modificationDate": datetime.now(timezone.utc),
"custom": {
"Company": "Exis LLC",
"Revision": "v3.6.4",
},
},
)
# Replace the XMP packet (RDF/XML wrapped in <?xpacket ...?>)
xmp = """<?xpacket begin=""?>
<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title><rdf:Alt><rdf:li xml:lang="x-default">Quarterly Report</rdf:li></rdf:Alt></dc:title>
<dc:creator><rdf:Seq><rdf:li>Finance Team</rdf:li></rdf:Seq></dc:creator>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>"""
exis_pdfeditor.set_xmp("input.pdf", "output.pdf", xmp)
# Wipe metadata for privacy (both XMP + /Info in one incremental update)
exis_pdfeditor.remove_metadata("input.pdf", "stripped.pdf")
# Or drop only one form:
exis_pdfeditor.remove_xmp("input.pdf", "stripped-xmp.pdf")
exis_pdfeditor.remove_info("input.pdf", "stripped-info.pdf")
Async API
// All I/O operations have async overloads with CancellationToken support
byte[] merged = await PdfMerger.MergeAsync(inputPaths, cancellationToken);
PdfTextResult text = await PdfTextExtractor.ExtractTextAsync(stream, cancellationToken);
var info = await PdfInspector.InspectAsync(path, cancellationToken);
var result = await PdfOptimizer.OptimizeAsync(data, options, cancellationToken);
var sigs = await PdfSigner.VerifyAllAsync(path, cancellationToken);
// Pattern: ClassName.MethodNameAsync(...) on all classes
Encrypt & Decrypt
# Encrypt with user (open) and owner (permissions) passwords
exis_pdfeditor.encrypt(
"document.pdf", "protected.pdf",
user_password="openme",
owner_password="secret",
permissions=["Print", "CopyText"],
# Available: Print, ModifyContents, CopyText, AddAnnotations,
# FillForms, PrintHighQuality, All
)
# Decrypt to remove protection
exis_pdfeditor.decrypt("protected.pdf", "unlocked.pdf", password="openme")
How Exis.PdfEditor Compares
| Feature | Exis.PdfEditor | IronPDF | Spire.PDF | Aspose.PDF | Syncfusion |
|---|---|---|---|---|---|
| Direct content stream editing | ✓ | ✗ Renders via HTML/Chromium | ✗ Redaction-style replacement | ✗ Fragment-based replacement | ✗ Redaction-style replacement |
| Preserve form fields | ✓ | ✗ | Partial | Partial | ✗ |
| Preserve digital signatures | ✓ On unmodified pages | ✗ | ✗ | ✗ | ✗ |
| Preserve character spacing | ✓ | ✗ | ✗ | Partial | ✗ |
| Zero native dependencies | ✓ Pure .NET | ✗ Requires Chromium | ✓ | ✓ | ✓ |
| DLL size | < 500 KB | ~250 MB | ~20 MB | ~40 MB | ~15 MB |
| Batch processing | ✓ Single-pass multi-pair | Manual loop | Manual loop | Manual loop | Manual loop |
| .NET Framework 4.8 | ✓ | ✓ | ✓ | ✓ | ✗ .NET 6+ only |
| Cross-platform | ✓ | ✓ | ✓ | ✓ | ✓ |
| Regex support | ✓ | ✓ | ✓ | ✓ | ✓ |
| Price (per dev/year) | $499 | $749 | $999 | $1,175 | $995* |
| Company HQ | 🇺🇸 USA | 🇺🇸 USA | 🇨🇳 China | 🇦🇺 Australia | 🇺🇸 USA |
* Syncfusion pricing requires a platform license. Prices are approximate and based on publicly available information as of 2026. Contact vendors for current pricing.
"Direct content stream editing" means the library modifies PDF content stream operators in-place rather than converting to an intermediate format and rebuilding.
Everything You Need for PDF Processing
Direct Content Stream Editing
Edit PDF text at the operator level. No intermediate format, no render-rebuild. Your document comes out identical except for the targeted changes.
Zero Native Dependencies
Pure .NET assembly under 500 KB. No Chromium, no native DLLs, no system-level installs. Works everywhere .NET runs.
Lossless Editing
Forms, signatures, spacing, fonts, bookmarks, and annotations are preserved. Incremental updates keep signatures valid on unmodified pages.
Multi-Target Framework
Targets .NET Standard 2.0 (Framework 4.6.1+, Core 2.0+) and .NET 8/9/10+ with optimized builds and digital signature support.
Batch Processing
Process multiple find/replace pairs in a single pass. Built for high-throughput scenarios with thousands of documents.
Regex Support
Full .NET regex engine for pattern-based find and replace. Redact SSNs, phone numbers, emails, or any custom pattern.
Cross-Platform
Runs on Windows, Linux, and macOS. Deploy to Azure, AWS, Docker, or on-premises servers without platform-specific dependencies.
Small Footprint
Under 500 KB total. No bloated runtime, no embedded browser engine. Minimal memory usage even on large documents.
PDF Merge
Combine multiple PDFs into one document, preserving page dimensions and resources.
PDF Split
Extract individual pages or page ranges into separate PDFs. Split to files with naming patterns.
PDF Builder
Create PDFs from scratch with a fluent API. Add text, images, lines, and rectangles with full formatting control.
Text Extraction
Pull text content from PDF pages. Extract from all pages or specific page ranges.
Document Inspector
Read metadata, fonts, page dimensions, and form field counts. Works without any license.
Image Replacement
Find, analyze, and replace images in PDFs. Swap logos or graphics by index or page range with JPEG/PNG.
Auto-Layout Builder
Create reports with auto-pagination, text wrapping, tables, headers/footers, and page numbers.
Form Filling
Read and fill AcroForm fields including text, checkbox, and dropdown. Lossless form preservation.
Redaction
Text-based, regex pattern, or area-based redaction. Permanently remove sensitive content from PDFs.
Optimization
Compress streams, remove duplicate objects, and reduce file size while preserving document quality.
Digital Signatures
Sign PDFs with X.509 certificates and verify existing signatures. Available on .NET 8, 9, 10+.
Encryption & Security
AES-256 encryption with user/owner passwords. Granular permissions (print, copy, edit, annotate, forms). Decrypt to remove protection.
PDF/A Compliance
Validate and convert to PDF/A (1b, 2b, 2u, 3b, 3u) for long-term archival. Validation works without a license.
Watermark
Text watermarks with positioning (top/bottom/center/across), font size, color, opacity, and per-page-range application.
Bates Numbering
Stamp sequential Bates numbers across legal production sets. Prefixes, digit padding, six-position placement, and confidentiality labels. (.NET)
Async API
All I/O operations have async overloads with CancellationToken support for scalable applications.
Forensic-Grade De-identification
Find/replace + PdfOptimizer.Optimize produces a single clean revision — the original text is permanently removed from the file bytes. Not hidden, not overlaid: gone. Survives hex-editor and content-stream extraction checks.
Content Classification
Per-page detection of Digital, Scanned, AlreadyOcrd, or Empty content via PdfInspector.AnalyzePages / ClassifyDocument. Pure content-stream analysis, no OCR engine, no license required.
Invisible Searchable Text Layer
Overlay selectable text on any page (Unicode-safe, embedded TrueType) so scans become findable while the visible image stays intact. Built for accessibility and post-OCR layering workflows.
XMP + /Info Metadata
Read, replace, or wipe both modern XMP packets and the legacy /Info dictionary. Extraction is license-free and works on encrypted PDFs.
Diagnostic Structure Dump
When a file fails to process and you can't share it, PdfInspector.DumpStructure emits a self-contained text report (objects, streams, filter chains, encryption details, undecodable streams) that you can paste into a bug report.
Simple, Transparent Pricing
- Unlimited pages per document
- Unlimited files per project
- All features included
- Email support from the developer
- 14-day complimentary trial included
- Unlimited pages per document
- Unlimited files per project
- All features included
- Email support from the developer
- Automatic annual renewal
Start with a complimentary 14-day trial. No credit card required. Just install the NuGet package and call ExisLicense.Initialize() with no arguments.
Start with a complimentary 14-day trial. No credit card required. Just pip install exis-pdfeditor and call exis_pdfeditor.initialize() with no arguments.
All prices in USD. License keys are delivered via email within minutes of purchase. Volume discounts available for 5+ developers — contact support@exisone.com.
How the Complimentary Trial Works
Install & Initialize
- Install via NuGet:
dotnet add package Exis.PdfEditor - Call
ExisLicense.Initialize()with no arguments - 14-day trial starts automatically
- No registration, no credit card, no email required
Install & Initialize
- Install via pip:
pip install exis-pdfeditor - Call
exis_pdfeditor.initialize()with no arguments - 14-day trial starts automatically
- No registration, no credit card, no email required
Trial Limitations
- Maximum 3 pages processed per document after expiry
- Console message printed on each operation
- All API features are fully available
- No watermarks added to output
- Trial resets if you clear local app data
Upgrade to Licensed
- Purchase a license key above ($499/year)
- Pass your key:
ExisLicense.Initialize("YOUR-KEY") - Unlimited pages, no console messages
- Same API, same code — just add your key
Upgrade to Licensed
- Purchase a license key above ($499/year)
- Pass your key:
exis_pdfeditor.initialize("YOUR-KEY") - Or set
EXIS_PDF_LICENSE_KEYenvironment variable - Unlimited pages, same API — just add your key
No code changes needed when upgrading — just add your license key to the Initialize() call.
Built by a Team You Can Trust
Frequently Asked Questions
Python: Python 3.9 or later. Native AOT-compiled binary bundled in the wheel — no .NET runtime required. Platform wheels for Windows x64, Linux x64, and macOS ARM64 (Apple Silicon).
ExisLicense.Initialize() with no arguments. The trial starts automatically — no registration, no credit card, no email.
Python: Run
pip install exis-pdfeditor and call exis_pdfeditor.initialize() with no arguments. Same trial, same automatic activation.
During the trial, all features are fully available with no restrictions. After expiry, you can still use the library on documents up to 3 pages.
Tj, TJ, '). Exis.PdfEditor parses these streams, locates the target text across operator boundaries, and performs surgical replacements at the byte level. The rest of the document — forms, signatures, metadata, fonts — remains completely untouched. Most other libraries convert the PDF to an intermediate format (HTML, DOM, or image), modify that, then rebuild the entire PDF, which destroys forms, signatures, and spacing.MinHorizontalScale and MaxFontSizeReduction options. When replacement text is shorter, the original spacing is preserved naturally. The TextFittingMode.Adaptive setting provides the best balance of quality and fit.By default (
UseIncrementalUpdate = true), PdfFindReplace.Execute writes an incremental update: the new content is appended to the file and the original bytes are kept as a prior revision. This is fast, produces a smaller diff, and is how PDF revision history works. The original text is no longer rendered by any viewer — but it is still present in the file bytes and can be extracted by forensic tools or by walking earlier xref tables. For most editing scenarios (fixing typos, updating dates, rewording paragraphs) this is exactly what you want.
For de-identification (PHI, account numbers, names, anything sensitive that must not survive in the file bytes), follow the find/replace with
PdfOptimizer.Optimize. It rebuilds the document keeping only the objects the final revision references, discarding every superseded original. After this step the file contains exactly one revision (one %%EOF marker) and the original text cannot be recovered by any forensic tool, hex editor, content-stream walker, or PDF revision-history utility — the bytes do not exist in the file. This is the pattern used internally by the PDF Batch Editor desktop app's HIPAA-compliant find/replace workflow.
See the Destructive Find & Replace code sample for the canonical two-line pattern. To verify a file is clean, count
%%EOF markers — one means a single clean revision; two or more means prior revisions are retained.
Why
PdfOptimizer.Optimize rather than just disabling incremental update? Optimize rebuilds through the document writer, which reproduces embedded subset fonts faithfully. A raw non-incremental rewrite can corrupt subset font encodings, producing garbled glyphs in the output. Optimize is the supported way to flatten a document to one clean revision.
PdfInspector.AnalyzePages classifies every page as Digital (extractable text), Scanned (image with little or no text layer), AlreadyOcrd (image with an invisible OCR text layer), or Empty. PdfInspector.ClassifyDocument rolls those up to a single document kind (Digital, Scanned, AlreadyOcrd, Mixed, or Empty).
This is pure content-stream analysis — no OCR engine and no native dependencies — and it is license-free. Use it to route work efficiently (only scanned pages need image redaction or OCR) and to decide whether a text find/replace operation can possibly match anything before you spend cycles on it. See the Classify Page Content sample below.
PdfInspector.Inspect (page counts, metadata, fonts, form field counts), PdfInspector.AnalyzePages / ClassifyDocument (Digital / Scanned / AlreadyOcrd / Empty per page), PdfInspector.DumpStructure (full diagnostic dump for bug reports), PdfAConverter.Validate (PDF/A compliance check), PdfSecurity.GetEncryptionInfo, and PdfXmpMetadata.Extract (XMP + /Info read, safe on encrypted files). Only operations that modify PDFs (find/replace, merge, split, etc.) require a license or active trial.Start Building with Exis.PdfEditor Today
Questions? Email support@exisone.com — you will hear back from the developer who wrote the code.