This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
All build, test, and tool invocations run from src/.
# Build
dotnet build src/OpenXmlStreaming.slnx
# Full test suite (there is no test filtering infrastructure beyond --filter)
dotnet test src/OpenXmlStreaming.Tests/OpenXmlStreaming.Tests.csproj
# A single test / class / keyword
dotnet test src/OpenXmlStreaming.Tests/OpenXmlStreaming.Tests.csproj --filter "FullyQualifiedName~MigrationGuide.WordStreaming"
# Benchmarks (default BDN job — publishable, slow)
dotnet run -c Release --project src/OpenXmlStreaming.Benchmarks -- --filter "*"
# Benchmarks (ShortRun — ~1-2 min, acceptable noise)
dotnet run -c Release --project src/OpenXmlStreaming.Benchmarks -- --job short --filter "*"
# Benchmarks (dry run — smoke-test the runner only, numbers are meaningless)
dotnet run -c Release --project src/OpenXmlStreaming.Benchmarks -- --job dry --filter "*Word_Simple*"dotnet-tools.json installs MarkdownSnippets.Tool as a local tool; mdsnippets also runs automatically via the MarkdownSnippets.MsBuild package reference on the Tests project, so every test build rewrites /readme.md with the latest snippet content. If you change a snippet-marked test body, rebuild the Tests project to propagate the change to the readme.
Three layers, stacked:
-
OpenXmlPackageWriter— forward-only OPC package writer built onZipArchivein Create mode (which emits ZIP data descriptors, so the target stream does not need to be seekable). Writes parts viaCreatePart/WritePart, tracks part-level relationships via the returnedOpenXmlPartEntry, and writes[Content_Types].xml+_rels/.relsduring disposal.Finish()isinternal— callers should useDispose/DisposeAsync, not call finalization explicitly. Tests seeFinishvia[InternalsVisibleTo]in the csproj (the assembly is strong-named, so the public-key token is required). -
BufferedWriteStream— write-only buffered adapter that sits betweenZipArchiveand the caller's target stream. TheOpenXmlPackageWriterconstructor takes abufferSize(default 80 KB,OpenXmlPackageWriter.DefaultBufferSize); passing0opts out entirely.BufferedWriteStreamoverrides bothWriteandWriteAsync(the latter is important — the baseStream.WriteAsyncdefault falls back to syncWrite, which defeats the async contract the writer needs). Sync writes fromZipArchiveaccumulate; spill flushes usetarget.Write; the final flush duringDisposeAsyncgoes throughtarget.WriteAsync, which is where the async win lives. Intermediate sync spills during largeWritePartcalls are unavoidable —ZipArchive's write surface is sync-only, and the only mitigation is a bigger buffer. -
Higher-level builders —
StreamingWorkbookBuilder,StreamingWordDocumentBuilder,StreamingPresentationBuilder. Each wrapsOpenXmlPackageWriterwith format-aware part URI / rId allocation and composes the main part (xl/workbook.xml,word/document.xml,ppt/presentation.xml) inFinish/DisposeAsyncusing the tracked parts.StreamingWordDocumentBuilderis asymmetric — sub-part methods (AddHeader,AddFooter, …) return the relationship id because the caller needs it to construct content references (FooterReference.Id, etc.), andWriteDocumentis explicit rather than dispose-triggered.StreamingPresentationBuilderembeds a minimal default theme + slide master + slide layout which is written lazily on the firstAddSlidecall (idempotent, so an empty presentation still produces a valid.pptx).
StreamingDocument.CreateWord/CreateSpreadsheet/CreatePresentation are thin factories that pre-register the package-level officeDocument relationship pointing at the main part URI. They exist for callers using the low-level writer directly; the builders use them internally.
NUnit + Verify.OpenXml. All round-trip tests call Verify(stream, extension: "docx"/"xlsx"/"pptx"); the Verify.OpenXml plugin opens the stream via the matching SDK XxxDocument.Open, normalises it via DeterministicPackage (from the sibling DeterministicIoPackaging repo), and snapshots both the binary package and extracted text/csv/info.
Namespace collisions force partial test classes. DocumentFormat.OpenXml.Wordprocessing, DocumentFormat.OpenXml.Spreadsheet, and DocumentFormat.OpenXml.Presentation (plus DocumentFormat.OpenXml.Drawing) share many type names (Row, Cell, Text, Shape, TextBody, ColorMap, Bold, FontSize, …). As a result:
Wordprocessingis deliberately NOT inGlobalUsings.cs. Adding it back breaks any test file that also importsSpreadsheetorDrawing.- Test fixtures that cover multiple document types (
MigrationGuide,BuilderTests) are split into partial classes — one file per document type — with a file-levelusingfor its namespace. SeeMigrationGuide.Word.cs/.Spreadsheet.cs/.Presentation.cs. MigrationGuide.Presentation.csandBuilderTests.Presentation.csadditionally aliasDrawing = DocumentFormat.OpenXml.Drawing;becausePresentationandDrawingcollide on ~15 type names and there is no way to import both as top-level usings. Presentation types are unqualified; Drawing types useDrawing.TypeName.- Single-document-type files (e.g.
BufferedWriteStreamTests.cs) use a file-levelusingfor the relevant namespace. OpenXmlPackageWriterTests.csandSamples.csuse traditional namespace aliases (P =,S =) because they intentionally mix multiple document types in a single file.
Test-time sinks. NonSeekableStream.cs wraps another stream with CanSeek=false to exercise the non-seekable write path. SyncAsyncTrackingStream.cs counts sync vs async Write calls and is the harness for validating async dispose / flush behaviour.
The readme's code examples are all snippet-backed. Each snippet: <name> directive in readme.md is replaced by mdsnippets (at build time) with the body of a // begin-snippet: <name> … // end-snippet region in a test file. Subtle consequences:
- Snippets live inside tests that actually run — readme examples can't rot silently.
Samples.csholds the reusable "how-to" samples;MigrationGuide.*.csholds the side-by-side before/after migration examples. mdsnippets.jsonatsrc/mdsnippets.jsonconfigures the runner (InPlaceOverwrite, 100-column width, TOC exclusions).- When adding a new snippet-backed example, also add a corresponding
snippet: <name>directive inreadme.mdand rebuild the Tests project to inject it.
Two benchmark classes:
ForwardOnlyBenchmarks— per-format × per-size (Simple/Medium/Complex) pairs comparingXxxDocument.Create(Standard) againstStreamingDocument.CreateXxx(ForwardOnly). UsesNonwritingStream(a discarding seekable stream) to isolate writer CPU + allocation cost from sink I/O. Ported from the upstream Open-XML-SDK PR #2058 that originally proposed this API.IoScenarioBenchmarks— four classes (NonSeekableWordBenchmarks,NonSeekableSpreadsheetBenchmarks,FileWordBenchmarks,FileSpreadsheetBenchmarks) measuring the real-world use case: large documents written to either a non-seekable sink (modeled withNonSeekableDiscardStream) or a temp file. The Standard side of the non-seekable scenario uses the idiomatic workaround — buffer toMemoryStreamfirst, thenCopyTothe sink — because the SDK can't target non-seekable streams directly.
Each IoScenario pair lives in its own class so BenchmarkDotNet can compute a meaningful per-pair Ratio column (one [Baseline = true] per class).
This library has hard behavioural dependencies on two SimonCropp repos that travel alongside it:
C:\Code\Verify.OpenXml— provides the pptx/xlsx/docx stream converters thatVerify.OpenXml 1.7.0uses. The pptx converter was added specifically to support this library'sCreatePresentation_RoundTripstest. If that test fails after a Verify.OpenXml update, check whether the pptx converter was changed.C:\Code\DeterministicIoPackaging— providesDeterministicPackage.Convert, used by Verify.OpenXml to normalise the ZIP output before snapshotting. PowerPoint determinism support (thePptxRelationshipPatcher/PptxContentPatcher) was added specifically so this library's pptx snapshots don't drift run-to-run. If pptx snapshots start flapping, verify that DeterministicIoPackaging's ppt/ patchers still run.
The main assembly is strong-named (key.snk at src/key.snk, signing configured via ProjectDefaults package). OpenXmlStreaming.csproj grants InternalsVisibleTo to OpenXmlStreaming.Tests with the full public-key token — a bare assembly name is rejected by the compiler when the granting assembly is signed. If you add a new InternalsVisibleTo, extract the public key via sn -tp key.snk and include it.
- Builder disposal inside test bodies. Tests that use
await using var builder = …followed byawait Verify(stream, …)fail at runtime because the builder disposes at method exit, afterVerifyhas already read the stream. Wrap the builder in an explicitawait using (var builder = …) { … }scope so disposal happens beforeVerifyruns. SeeMigrationGuide.*Buildertests for the pattern. WritePartis sync by design. There is noWritePartAsyncbecause the underlyingOpenXmlElement.WriteTo(XmlWriter)andZipArchivewrite paths are sync, and wrapping them in aTaskwould be actively misleading — the calling thread would still block inside the serialisation call. The async surface exists only atDisposeAsync/FlushAsyncboundaries, where genuine async I/O happens.bufferSize: 0disables async flushing. Without the internal buffer,DisposeAsynchas nothing to flush asynchronously, so everyZipArchivesync write lands directly on the target. Use this only when the target is already an in-memory stream where the extra copy isn't worth it.