Skip to content

Add IOpenXmlNamespacePrefixFeature to control written namespace prefixes - #2139

Open
jafin wants to merge 2 commits into
dotnet:mainfrom
jafin:feature/namespace-prefix-override
Open

jafin wants to merge 2 commits into
dotnet:mainfrom
jafin:feature/namespace-prefix-override

Conversation

@jafin

@jafin jafin commented Sep 2, 2026

Copy link
Copy Markdown

Summary

Elements are serialized with a built-in prefix for their namespace, so a worksheet is written as <x:worksheet xmlns:x="..."> where Office writes <worksheet xmlns="...">. Both are valid, but consumers that parse without a namespace manager are sensitive to the difference, and there was no way to choose.

This adds an opt-in feature registered on the package:

document.Features.SetNamespacePrefixOverride(
    OpenXmlNamespacePrefix.DefaultFor("http://schemas.openxmlformats.org/spreadsheetml/2006/main"));

OpenXmlNamespacePrefix.Create(Func<string, string?>) supports arbitrary prefixes per namespace. Output and OpenXmlElement.Prefix are unchanged when no feature is registered.

Resolves #90

Problem

Elements are serialized with a built-in prefix for their namespace, so the SDK writes

<x:worksheet xmlns:x="http://schemas.openxmlformats.org/spreadsheetml/2006/main">

where Office writes

<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">

Both are valid and Excel reads either. The difference matters to consumers that parse the XML without a namespace manager — XPath, regex, strict diffing — and there is currently no way to choose. Opening an Office-authored file and saving it also converts it to the prefixed form, because default-namespace declarations are discarded at parse time (OpenXmlElement.cs:1583).

Design

  • The override is applied by an XmlWriter decorator wrapped around the part root rather than while resolving each element's prefix. A namespace-qualified attribute such as w:rsidR forces the underlying writer to declare the built-in prefix on its element, after which XmlWriter.LookupPrefix reports that prefix, so any decision taken from the writer's scope would silently revert the rest of the subtree. Forcing the prefix as each element is written keeps the whole tree consistent and covers unknown and extension elements.
  • Every namespace the part uses is validated before the part stream is opened, so a rejected prefix leaves the previous part content intact. A prefix already bound to another namespace, by the built-in table or by a declaration in the document, is rejected with an InvalidOperationException naming both namespaces. Taking one would otherwise either be refused by the writer as a redefinition or silently rebind the other namespace to a generated prefix (r:id shipping as p3:id).
  • Existing namespace declarations are preserved, so round-tripped documents keep what they declared and mc:Ignorable values are not stranded.
  • Descendants written under the override reuse the namespace resolver the root resolved, instead of walking to the part root through the feature chain per element. The override path performs one tree walk per save, the same as the un-overridden path.
  • The two internal XmlWriter decorators share a forwarding base class.

Scope and limits

Documented on IOpenXmlNamespacePrefixFeature: OpenXmlElement.Prefix is untouched so validation XPaths stay well formed; an unparsed part root is still copied verbatim; OpenXmlPartWriter and the LINQ-to-XML save path are unaffected.

Tests

33 tests in NamespacePrefixOverrideTests cover SpreadsheetML and WordprocessingML output, qualified attributes, unknown elements, declared and reserved prefixes, validation ordering, round-tripping, and the unchanged default behavior.

Performance

Saving a 50,000-row, 250,000-cell worksheet to memory, best of five runs, net10.0 Release:

Build No override With override Overhead
Before patch 184 ms 242 ms +58 ms
After patch 189 ms 211 ms +22 ms

The remaining overhead is the decorator's per-element dispatch and the feature's prefix lookup.

Elements are serialized with a built-in prefix for their namespace, so a
worksheet is written as <x:worksheet xmlns:x="..."> where Office writes
<worksheet xmlns="...">. Both are valid, but consumers that parse without a
namespace manager are sensitive to the difference, and there was no way to
choose.

This adds an opt-in feature registered on the package:

    document.Features.SetNamespacePrefixOverride(
        OpenXmlNamespacePrefix.DefaultFor(spreadsheetNamespace));

The override is applied by an XmlWriter decorator wrapped around the part
root, rather than while resolving each element's prefix. That placement is
load-bearing: a namespace-qualified attribute such as w:rsidR forces the
underlying writer to declare the built-in prefix on its element, and from
that point XmlWriter.LookupPrefix reports that prefix, so any decision taken
from the writer's namespace scope silently reverts the rest of the subtree.
Forcing the prefix as each element is written keeps the whole tree
consistent, covers unknown and extension elements for free, and costs a
single feature lookup per save when no override is registered.

A prefix already owned by another Open XML namespace is rejected with an
InvalidOperationException naming both namespaces. Taking one has no good
outcome: declared on the same element the writer refuses it, declared
further out the writer silently rebinds the other namespace to a generated
prefix, so r:id would ship as p3:id with no error.

Existing namespace declarations are left in place. Binding both a prefix and
the default prefix to one namespace is legal, it does not affect element
names once the prefix is forced, and it lets qualified attributes resolve
against the root instead of re-declaring the prefix on every element that
carries one. Dropping them would also strand mc:Ignorable values.

Scope and limits are documented on the interface: OpenXmlElement.Prefix is
untouched so validation XPaths stay well formed, an unparsed root is still
copied verbatim, and the LINQ-to-XML save path is unaffected.

Output and OpenXmlElement.Prefix are unchanged when no feature is
registered.
The override feature checked a supplied prefix only against the built-in
prefix table, and only the first time it saw each namespace. A prefix the
loaded document itself declared for another namespace slipped through to
the writer, which failed with a redefinition error on the start tag, and a
feature that changed its answer for a namespace bypassed the check
entirely. Both checks also ran lazily on the first element write, after
the part stream had been opened with FileMode.Create.

NamespacePrefixOverride now walks the tree once before the part is opened,
collecting every namespace declaration and every namespace in use, and
validates each supplied prefix against the built-in table and the
document's own declarations. Answers are re-validated whenever they change.
The root's namespace-hoisting pass reuses the collected declarations rather
than walking the tree again, so the override path performs one walk per
save, the same as the un-overridden path.

Descendants written under the override no longer resolve the namespace
resolver through their features, which walked to the part root on every
element; they read the resolver the root already resolved from the
decorator. Extended attributes with an empty prefix fall back to the
built-in prefix like parsed attributes do, instead of receiving a
generated one when the namespace is bound as the default.

The registration extension no longer duplicates the read-only guard every
feature collection already enforces, and the two XmlWriter decorators
share a forwarding base class.
@jafin

jafin commented Sep 2, 2026

Copy link
Copy Markdown
Author

@dotnet-policy-service agree

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SpreadSheet custom map add x: to defaultNamespace

1 participant