đź’ˇ Full working example available on GitHub: scrub-office-document-pii-dotnet

The Old Way Was Painful

The routine goes like this. Open the document, File, Info, Check for Issues, Inspect Document, tick the boxes, Remove All, save under a new name, close, open the next one. Forty files later somebody notices that Inspect Document also stripped the Title that the records index keys on, and that the copy sent out an hour earlier still carried a SharePoint approver ID, because the file had been saved from a different application that wrote the field back.

Metadata PII removal is a GroupDocs.Metadata capability for .NET that deletes identity-bearing properties from Office documents programmatically and reports what remains afterwards. The manual routine fails on three counts: it does not scale past a handful of files, it is all-or-nothing about which fields go, and it produces no record of what was removed. This article shows the .NET version of the same work, one property group at a time.

It helps to know what is actually in there. A Word file that has been through a review round typically carries Author and LastSavedBy from the Windows account of whoever saved it, Manager and Company from the corporate template, a revision counter, TotalEditingTime, a LastPrinted timestamp, and counters for comment threads. Add SharePoint to the chain and you also get approver identifiers, workflow paths, content-type URIs, and the template the document was created from. None of it is visible on the page, and all of it travels in the same file.

There’s a Better Way

Everything in GroupDocs.Metadata for .NET runs through one property search engine. RemoveProperties takes a lambda over MetadataProperty, deletes every property the lambda accepts, and returns the count. FindProperties runs the same lambda without writing. Properties also carry tags, so Tags.Person.Creator identifies author-style fields across formats and packages instead of matching literal names that differ per producing application.

That gives three shapes of cleanup instead of one button: a tag pass for identity fields, name passes for families such as comments and revisions, and Sanitize() when nothing should survive. All three return numbers, and the numbers are what makes the pass auditable.

The New Way: A Predicate per Property Group

Step 1 - Clear the names

Four tag checks cover the identity group. Descriptive fields are untouched, which is the difference from the Document Inspector’s Remove All:

using (var metadata = new Metadata(inputPath))
{
    if (metadata.FileFormat == FileFormat.Unknown) return 0;
    var affected = metadata.RemoveProperties(p =>
        p.Tags.Contains(Tags.Person.Creator) ||
        p.Tags.Contains(Tags.Person.Editor) ||
        p.Tags.Contains(Tags.Person.Manager) ||
        p.Tags.Contains(Tags.Corporate.Company));
    metadata.Save(outputPath);
    return affected;
}

The FileFormat.Unknown check is the guard that keeps a zero honest: without it, an unreadable file and a clean file look the same to the caller.

Step 2 - Clear the families around them

Comments, revisions, and server fields have no tag, so the predicate matches names instead. The editing timeline is the group most often forgotten, and it is the one that says how a document was produced:

using (var metadata = new Metadata(inputPath))
{
    if (metadata.FileFormat == FileFormat.Unknown) return 0;
    var affected = metadata.RemoveProperties(p =>
        p.Name != null && (
            p.Name.Contains("Revision") ||
            p.Name.Contains("TrackedChange") ||
            p.Name.Contains("LastPrinted") ||
            p.Name.Contains("TotalEditingTime") ||
            p.Name.Contains("EditTime")));
    metadata.Save(outputPath);
    return affected;
}

Substring matching is deliberate: it catches CommentsCount alongside Comment, and TotalEditingTime alongside EditTime, without maintaining an exact-name list per format. The SharePoint pass is the same call with Server, Workflow, Approver, ContentType, and Template.

Step 3 - Wipe everything, then check the result

At the trust boundary, one call replaces the four passes:

using (var metadata = new Metadata(inputPath))
{
    if (metadata.FileFormat == FileFormat.Unknown) return 0;
    var affected = metadata.Sanitize();
    metadata.Save(outputPath);
    return affected;
}

Then the part the manual routine has no equivalent for. The verification scan reuses the removal predicates through FindProperties and sorts the survivors into two lists:

foreach (var p in props)
{
    var value = p.InterpretedValue?.ToString() ?? p.Value?.ToString() ?? string.Empty;
    if (string.IsNullOrWhiteSpace(value)) continue;
    if (value == "0" || value == "0.0") continue;

    var entry = $"{p.Name}={value}";
    var name = p.Name ?? string.Empty;
    if (name.StartsWith("Comment") || name.StartsWith("Revision"))
        report.ContentLevelLeaks.Add(entry);
    else
        report.MetadataLeaks.Add(entry);
}

MetadataLeaks must be empty before a file counts as sanitized. ContentLevelLeaks is informational: Word comments and tracked-change authors sit in word/document.xml, which is body content, and clearing those calls for a content-editing library such as Aspose.Words rather than a metadata API.

Why not just call Sanitize on everything?

Because most documents are still in use. Sanitize() clears every detected package, and that includes Title, Subject, and Keywords, the fields a records system and a search index depend on. Use the targeted passes while a file circulates internally, keep the descriptive metadata working, and reserve the full wipe for the copy that actually leaves the organization.

Side-by-Side: Before vs. After

Manual inspection GroupDocs.Metadata for .NET
Selectivity Remove All, descriptive fields included one predicate per property group
Coverage fields the dialog exposes every package the library detects, custom OOXML parts included
Record none affected count returned per operation
Verification reopen and look FindProperties scan with metadata and content-level lists
Batch of 200 files 200 click-throughs one loop, five operations, one log line per file

The row that changes behaviour is the record. Once every pass returns a count, sanitization stops being a step somebody remembers to do and becomes data the pipeline can assert on: a threshold in a test, a field in an audit table, a condition that fails a nightly job. That is also the row a manual routine cannot produce at any level of discipline.

Real-World Example: The Pre-Send Hook

A support portal lets staff attach documents to customer tickets. The attachment handler now runs the identity pass and the server pass before the file is stored, records both counts against the ticket, and runs the leak check on the saved copy. A non-empty metadata-leak list rejects the upload with a message naming the offending property, so the person attaching the file finds out immediately rather than after it reaches the customer.

Two details make that hook practical. The passes write to a new path, so the original stays in the staff member’s own storage and nothing is destroyed by an automated step. And the counts go into the ticket record next to the attachment, which means the answer to “what was removed from this document” is a stored number rather than an assumption about what the pipeline usually does.

The first time I pointed that check at a real template, it came back with a Manager value that the identity pass had removed seconds earlier and the corporate template had written straight back on save. The removal call was working exactly as documented; the pipeline around it was the problem, and only the read-back showed it.

What Else Can You Do with GroupDocs.Metadata?

The same predicate engine reads. Comparing properties between two versions of a document surfaces ownership changes and re-authoring outside the review process, and the metadata scrubbing overview covers where an interactive tool still fits next to an API-driven pass. Because the tag system spans formats, the identity predicate written here also runs against PDFs, images, and audio files without modification.

That portability is worth planning for. A cleanup rule written as a lambda over MetadataProperty is ordinary C#, so it can live in a shared library, be unit tested against fixture documents, and be applied by whichever service needs it: an export endpoint, a scheduled records job, or a build step that sanitizes documentation attachments before release. The rules stay in one place; only the call sites change.

Conclusion

Four targeted passes, one full sanitize, one verification scan. That set covers the practical range for Office documents: keep the descriptive metadata while a file is in circulation, clear everything when it leaves, and prove the result either way. Clone the sample, run it against a document that has been through a real review round, and read the affected counts. They are usually higher than expected, which is the whole point.

Additional Resources