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.
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:
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));
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.StructureTreereads the tags into typed objects. EachPdfStructureNodehas itsTypeas written and theStandardTypeit maps to through the role map,Alt,ActualText,Language, the table and list attributes (Scope,ColSpan,RowSpan,ListNumbering), itsParent,Childrenand page, andGetText()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), andRemove, either keeping the children (Unwrap) or turning the content below into artifacts (ToArtifacts). RemoveTagstakes out the structure tree,/MarkInfo, every/StructParentsand each marked-content sequence with an/MCID. Only the marking operators change, so pages paint and extract as before. AfterwardsTagandApplyTaggingaccept 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, andtree.FindMissingContent()lists references to content that isn’t there. - A tree read before
RemoveTags, tagging orRefreshStructureTree()is stale and throws when you read its children. Readdoc.StructureTreeagain. RemoveTags(new PdfRemoveTagsOptions { DryRun = true })reports what it would remove without changing anything, and doesn’t needAllowEditing. 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
THhas noScope. 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.