Recursive AI

Read, edit and remove PDF tags in C#

All onepdf recipes

doc.StructureTree reads a tagged PDF’s structure tree into objects you can change in place, and doc.RemoveTags() takes the tags out. The program below fixes a skipped heading level in the IRS Form W-9, a public tagged PDF, then shows the tags removed.

Terminal
dotnet new console -o PdfTags && cd PdfTags
dotnet add package RecursiveAI.OnePdf --version 1.14.0
curl -LO https://www.irs.gov/pub/irs-pdf/fw9.pdf

Put this in Program.cs and run dotnet run:

C#
using OnePdf;
using OnePdf.Text;
 
// Read and edit the tags: the W-9 jumps from an H2 to an H4.
using (PdfDocument doc = PdfDocument.Open("fw9.pdf",
    new PdfOpenOptions { AllowEditing = true }))
{
    PdfStructureTree tree = doc.StructureTree!;   // null when the file is untagged
    int count = tree.Elements().Count();
    Console.WriteLine($"{count} elements, {tree.Diagnostics.Count} problems");
    Console.WriteLine($"Heading findings: {HeadingFindings(doc)}");
 
    int previous = 0;
    // depth first, document order
    foreach (PdfStructureNode node in tree.Elements())
    {
        if (node.StandardType is not ['H', >= '1' and <= '6']) continue;
        int level = node.StandardType[1] - '0';
        if (level > previous + 1)
        {
            string text = node.GetText();
            int page = node.PageIndex + 1;
            string fix = $"{node.StandardType} -> H{previous + 1}";
            Console.WriteLine($"  page {page}: \"{text}\" {fix}");
            node.SetType($"H{previous + 1}");
            level = previous + 1;
        }
        previous = level;
    }
    Console.WriteLine($"Heading findings after: {HeadingFindings(doc)}");
    doc.Save("fw9-headings.pdf");
}
 
// Or take the tags out entirely, to tag the file again from scratch.
using (PdfDocument doc = PdfDocument.Open("fw9.pdf",
    new PdfOpenOptions { AllowEditing = true }))
{
    string before = Text(doc);
    PdfRemoveTagsResult removed = doc.RemoveTags();
    Console.WriteLine($"Removed {removed.SequencesRemoved} marked sequences");
    doc.Save("fw9-untagged.pdf");
    using PdfDocument check = PdfDocument.Open("fw9-untagged.pdf");
    Console.WriteLine($"Tagged: {check.IsTagged}, same text: {Text(check) == before}");
}
 
static int HeadingFindings(PdfDocument doc) =>
    doc.CheckPdfUA().Count(v => v.Checkpoint == "14");
static string Text(PdfDocument doc) =>
    string.Concat(doc.ExtractText(new PdfTextOptions { UseStructureTree = false })
        .Select(p => p.Text));
Text
708 elements, 0 problems
Heading findings: 1
  page 3: "Line 1" H4 -> H3
Heading findings after: 0
Removed 478 marked sequences
Tagged: False, same text: True

How it works

  • doc.StructureTree reads the tags into typed objects. Each PdfStructureNode has its Type as written and the StandardType it maps to through the role map, Alt, ActualText, Language, the table and list attributes (Scope, ColSpan, RowSpan, ListNumbering), its Parent, Children and page, and GetText() for the text it covers.
  • Edits change the document at once and keep the tree’s bookkeeping consistent: SetType, the properties above, tree.MapRole, MoveTo, WrapChildren, InsertChild (a missing table cell, for example), and Remove, either keeping the children (Unwrap) or turning the content below into artifacts (ToArtifacts).
  • RemoveTags takes out the structure tree, /MarkInfo, every /StructParents and each marked-content sequence with an /MCID. Only the marking operators change, so pages paint and extract as before. Afterwards Tag and ApplyTagging accept the file: see Tagging existing PDFs.

Limits and good to know

  • Reading never throws on a damaged tree. Cycles, shared elements and the like are listed in tree.Diagnostics, and tree.FindMissingContent() lists references to content that isn’t there.
  • A tree read before RemoveTags, tagging or RefreshStructureTree() is stale and throws when you read its children. Read doc.StructureTree again.
  • RemoveTags(new PdfRemoveTagsOptions { DryRun = true }) reports what it would remove without changing anything, and doesn’t need AllowEditing. It also finds leftover marked content in files that have no tree.
  • ArtifactUntaggedGraphics() marks paths, images and forms drawn outside any tag as artifacts in a tagged file, which clears that PDF/UA finding when only graphics were behind it. Untagged text is counted, never wrapped: what it is stays your decision.
  • An inserted TH has no Scope. Set it at once, or the PDF/UA check reports the cell.

Every call and its limits are in Structure tree and removing tags. For fonts the file doesn’t embed, see embed missing fonts.