DocWright.Formats.Text
1.1.0
See the version list below for details.
dotnet add package DocWright.Formats.Text --version 1.1.0
NuGet\Install-Package DocWright.Formats.Text -Version 1.1.0
<PackageReference Include="DocWright.Formats.Text" Version="1.1.0" />
<PackageVersion Include="DocWright.Formats.Text" Version="1.1.0" />
<PackageReference Include="DocWright.Formats.Text" />
paket add DocWright.Formats.Text --version 1.1.0
#r "nuget: DocWright.Formats.Text, 1.1.0"
#:package DocWright.Formats.Text@1.1.0
#addin nuget:?package=DocWright.Formats.Text&version=1.1.0
#tool nuget:?package=DocWright.Formats.Text&version=1.1.0
DocWright
A from-scratch .NET Word-document library. Near-term goal: DOCX → PDF visually indistinguishable from Word's own PDF export for the supported feature set, proven by automated visual diffs against real oracle output. Long-term goal: a DocIO-class library built on the same core.
The tree ships the whole pipeline today: DOCX and legacy DOC readers, a lossless DOCX writer,
the mutable DOM with an editing API, style resolution and layout, PDF and raster renderers,
mail merge, document compare, content controls and form fields, ECMA-376 decryption, and
HTML/RTF/Markdown/plain-text import and export — on top of the frozen plugin seams, the
reference-oracle pipeline and the three-metric visual diff harness. See ROADMAP.md for what
is done and what is next, and COMPAT.md for the supported feature set.
Cross-app authoring guidance
For teams authoring DOCX content that must remain readable across Microsoft Word and LibreOffice, use the one-page standard here:
Solution map
| Project | Purpose |
|---|---|
src/DocWright.Core |
Twips/EMU/Color primitives, fixed-point math, plugin seams (IDocumentReader/IDocumentWriter, ILayoutRenderer, ITextShaper), IConversionDiagnostics, exception taxonomy. Zero dependencies. |
src/DocWright.Dom |
Mutable, lossless document object model: blocks, inlines, styles, numbering, theme, shapes and settings, plus the editing API, the DOM field engine, revisions, content controls and form fields, and the preservation islands that make round-trip lossless. |
src/DocWright.Formats.Doc |
Legacy Word 97-2003 (.doc) reader over a reusable CFB parser. |
src/DocWright.Formats.Docx |
Streaming DOCX reader and the lossless DOCX writer, including DrawingML/VML shape parsing and verbatim island re-emission. The only package that may depend on DocumentFormat.OpenXml. |
src/DocWright.Formats.Html |
HTML import and export: a tolerant, dependency-free HTML5-shaped parser, a documented CSS subset, and a DOM-to-HTML writer with self-contained and linked output modes. |
src/DocWright.Formats.Markdown |
Markdown import and export: a dependency-free CommonMark 0.31.2 block and inline parser with the GitHub-flavored table, strikethrough and task-list extensions, checked against the specification's own 652 examples. |
src/DocWright.Formats.Rdl |
SSRS Report Definition Language export (.rdl), for RDL 2016/01 and 2010/01. Turns a document into a report template — page setup, Tablix tables, embedded images and merge fields bound as =Fields!X.Value dataset expressions — validated against both committed XSDs. Export only; there is no RDL reader. |
src/DocWright.Formats.Rtf |
RTF 1.9 import and export: a dependency-free tokenizer and destination reader, single-byte code pages compiled in so decoding never depends on host configuration, and a deterministic DOM-to-RTF writer. |
src/DocWright.Formats.Text |
Plain-text import and export: encoding detection, line-ending and paragraph-splitting policy on the way in, and a GetText-based writer with page-break and table-rendering options on the way out. |
src/DocWright.Fonts |
Font discovery, OpenType/TTC parsing and metrics, kerning and cluster advances, glyph-outline decoding (glyf and CFF Type 2), CFF subsetting, and GlyphRunPlanner — the single place both renderers decide which glyphs a run draws. |
src/DocWright.Layout |
StyleResolver + layout engine + shape geometry (guide evaluation, preset outlines, gradients, charts). The resolver is the one-way bridge DOM → immutable ResolvedDocument; layout never sees the DOM. |
src/DocWright.Renderers.Pdf |
Single-pass PDF renderer with TrueType/CFF subsetting. Consumes the laid-out seam only. |
src/DocWright.Renderers.Imaging |
Raster renderer: the same laid-out seam to PNG/JPEG pages. Dependency-free, integer-deterministic (byte-identical output across runs and TFMs). |
src/DocWright.MailMerge |
Merge-field discovery, a data-source abstraction with no System.Data dependency, simple and nested-region merges, and conditional record flow over the DOM field engine. |
src/DocWright.Compare |
Tree diff over two WordDocuments producing tracked changes (w:ins, w:del, w:rPrChange, w:pPrChange), with paragraph alignment, move detection, run-level LCS and bounded execution. Implements no seam — an ordinary DOM consumer. |
src/DocWright.Cryptography |
ECMA-376 / MS-OFFCRYPTO: agile and standard encrypted-package detection and decryption, legacy RC4 and XOR obfuscation (read), document-protection password hashes, and a host-owned signing seam. BCL cryptography only. |
src/DocWright.ComplexText |
Optional HarfBuzz-backed complex-text shaper behind ITextShaper. Opt-in: nothing in the core path requires it. |
src/DocWright.Composition |
Fluent document composition: ComposedDocument.Create(...) authors a document from code and lowers it onto the DOM, so it renders through the ordinary pipeline to PDF, images or .docx. No second layout engine — the composer never breaks pages, which is what COMPAT.md's maps/approximates/refused matrix is built around. |
src/DocWright.Formats.Pdf |
PDF import: object layer, cross-reference machinery, filters, content-stream interpreter, colour spaces, fonts, encryption, and a page rasterizer. The opposite direction from Renderers.Pdf, not its partner. |
src/DocWright.Formats.Odt |
OpenDocument Text export, gated by LibreOffice opening every output. Export only; there is no ODT reader. |
src/DocWright.Formats.Epub |
EPUB 3 export over the HTML core, gated by EPUBCheck. Export only. |
src/DocWright.Ole |
The MS-CFB compound-file reader and OLE payload extraction, below Formats.Doc and Cryptography — a .doc, an encrypted package and an OLE payload are all compound files. |
src/DocWright.Signatures |
OPC package signature verification and signing, and PDF signing. The one package that takes System.Security.Cryptography.Xml, because a hand-written C14N produces signatures that verify against nothing but themselves. |
src/DocWright |
Facade: DocWrightConverter.Convert(input, pdf, options), ConvertToImages(input, imageOptions, options), ConvertPdfToImages, Load and Save, with format auto-detection via DocumentFormatRegistry. |
tests/DocWright.Tests |
xunit suite, runs on net48, net8.0 and net10.0 — every runtime TFM the packages ship; public-API approval tests, zero-allocation gates, tr-TR culture leg, and the hostile-input fuzz layers. |
tests/DocWright.Rendering.Tests |
net10.0-only visual harness tests. The only test project allowed native deps (PDFium via Docnet, ImageSharp). |
bench/DocWright.Benchmarks |
BenchmarkDotNet hot-path benchmarks (net48 + net8.0 + net10.0); committed baselines in bench/results/. |
tools/DocWright.VisualHarness |
The harness library: PDF rasterization, pixel/SSIM/glyph metrics, artifacts, thresholds, scoreboard. Never shipped. |
tools/ConvertCompareApp |
Converts input/test.docx and diffs it against the Word reference, writing side-by-side, overlay, heatmap and report.json artifacts per page. The primary fidelity-triage loop. |
tools/CorpusGen |
Regenerates the seed corpus (byte-stable output). |
tools/ReferenceGen |
Oracle scripts + RefRaster (PDF → reference PNGs + manifest). |
tools/Ratchet |
Nightly threshold-tightening proposal script. |
All library projects multi-target net48;netstandard2.0;net8.0;net10.0; netstandard2.0 is
the functional floor, and the net8+/net10 legs are trim/AOT-clean (IsAotCompatible).
Quick start
dotnet build DocWright.slnx -c Release # all TFMs
dotnet test tests/DocWright.Tests -c Release # unit tests (net48 + net10)
dotnet test tests/DocWright.Rendering.Tests -c Release # visual harness
dotnet run --project bench/DocWright.Benchmarks -c Release -f net10.0 -- --filter '*'
Package smoke validation (Phase 10 release gate)
dotnet pack DocWright.slnx -c Release -o artifacts/nuget
powershell -NoProfile -ExecutionPolicy Bypass -File .\eng\Run-Phase10Smoke.ps1 `
-RepositoryRoot (Get-Location).Path `
-PackageDirectory (Resolve-Path .\artifacts\nuget).Path
Run-Phase10Smoke.ps1 validates:
- net48 console consumer (
PackageReference) conversion, - net10 ASP.NET Core minimal API conversion over HTTP,
- net10 NativeAOT publish + conversion smoke (requires NativeAOT C++ toolchain prerequisites).
Net48 quickstarts
PackageReference (SDK-style):
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DocWright" Version="0.10.0" />
</ItemGroup>
</Project>
packages.config (non-SDK):
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="DocWright" version="0.10.0" targetFramework="net48" />
</packages>
Net10 minimal API quickstart
using DocWright;
var app = WebApplication.CreateBuilder(args).Build();
app.MapPost("/convert", async (HttpRequest request, CancellationToken cancellationToken) =>
{
await using var output = new MemoryStream();
var converter = new DocWrightConverter();
await converter.ConvertAsync(request.Body, output, options: null, cancellationToken);
return Results.File(output.ToArray(), "application/pdf", "converted.pdf");
});
app.Run();
Compose a document from code
DocWright.Composition authors a document with a fluent, nested surface that reads like the page
it produces. It lowers onto the DOM, so the result is a real Word document — every line of
shaping, table, pagination and PDF/A work applies to it, and .SaveAsDocx() comes free.
using DocWright.Composition;
var invoice = ComposedDocument.Create(document =>
{
document.Page(page =>
{
page.Size(PageSizes.A4).Margin(2, Unit.Centimetre);
page.DefaultTextStyle(text => text.FontFamily("Calibri").FontSize(11));
page.Header().Text("INVOICE 2026-0042").FontSize(20).Bold();
page.Content().Column(column =>
{
column.Spacing(8);
column.Item().Text("Bill to: Contoso Ltd.");
column.Item().Table(table =>
{
table.Columns(c => { c.Relative(3); c.Relative(1); c.Relative(1); });
table.Header(h =>
{
h.Cell().Text("Item").Bold();
h.Cell().Text("Qty").Bold();
h.Cell().Text("Total").Bold();
});
foreach (var line in lines)
{
table.Cell().Text(line.Name);
table.Cell().AlignRight().Text(line.Quantity);
table.Cell().AlignRight().Text(line.Total);
}
});
});
page.Footer().AlignCenter().Text(t =>
{
t.Span("Page "); t.CurrentPageNumber(); t.Span(" of "); t.TotalPages();
});
});
});
invoice.GeneratePdf("invoice.pdf");
invoice.GenerateImages(new ImageRenderOptions { Width = 1200 });
invoice.SaveAsDocx("invoice.docx");
A table's header row repeats on every page it crosses, and PAGE/NUMPAGES are substituted per
page — both by the existing layout engine, not by anything the composer added.
The composer does not break pages; the layout engine does. Anything that would have to measure
against the space left on a page — ShrinkToFit, dynamic components, infinite-height containers —
is refused by design and absent at compile time rather than throwing. COMPAT.md carries the full
maps / approximates / refused matrix, and ToWordDocument() is the escape hatch for anything
the fluent surface cannot express.
Phase 10 end-to-end benchmark smoke matrix
Commands:
$env:DOCWRIGHT_BENCH_SMOKE='1'
dotnet run --project bench/DocWright.Benchmarks -c Release -f net48 -- --filter '*EndToEndConversionBenchmarks*'
dotnet run --project bench/DocWright.Benchmarks -c Release -f net8.0 -- --filter '*EndToEndConversionBenchmarks*'
dotnet run --project bench/DocWright.Benchmarks -c Release -f net10.0 -- --filter '*EndToEndConversionBenchmarks*'
Observed Dry means (single-iteration smoke profile):
| Scenario | net48 | net8.0 | net10.0 |
|---|---|---|---|
| Small document | 2.198 s | 1.405 s | 1.345 s |
| 500-page document | 3.041 s | 2.413 s | 2.491 s |
| Table-heavy document | 1.745 s | 1.005 s | 1.021 s |
| Image-heavy document | 2.001 s | 1.043 s | 1.101 s |
Reports:
bench/results/phase10/net48/DocWright.Benchmarks.EndToEndConversionBenchmarks-report-github.mdbench/results/phase10/net8.0/DocWright.Benchmarks.EndToEndConversionBenchmarks-report-github.mdbench/results/phase10/net10.0/DocWright.Benchmarks.EndToEndConversionBenchmarks-report-github.mdbench/results/phase10/net48/shortrun-artifacts/results/DocWright.Benchmarks.EndToEndConversionBenchmarks-report-github.mdbench/results/phase10/net8.0/shortrun-artifacts/results/DocWright.Benchmarks.EndToEndConversionBenchmarks-report-github.mdbench/results/phase10/net10.0/shortrun-artifacts/results/DocWright.Benchmarks.EndToEndConversionBenchmarks-report-github.md
The reference oracle (the definition of "perfect")
References live under references/<category>/<doc>/: the oracle's document.pdf, its
pages rasterized to page-NNN.png at 150 DPI via PDFium, and reference.json recording
the oracle identity (word-<build> or libreoffice-<version>), export settings, DPI and
content hashes. Reference PNGs/PDFs are stored in Git LFS.
Regeneration is deliberate and manual — never per-CI:
# Uses Word COM automation when Word is installed; LibreOffice otherwise.
powershell tools/ReferenceGen/Generate-References.ps1 [-Oracle word|libreoffice] [-Filter 'seed-01*']
Every comparison records which oracle produced its reference (oracleId in actuals and
scoreboard). The CI workflow refresh-references.yml (manual trigger) does the same on a
self-hosted Windows runner with Word.
The committed corpus references are Word 16.0 exports (word-16.0.16.0.20131), regenerated
2026-07-25. They were LibreOffice exports before that, and switching oracles moved every number —
it retired the single largest apparent defect (a ~1% advance-width drift that turned out to be
LibreOffice's) and exposed one the old oracle could not see (WQ-0005 row banding). Treat any
fidelity measurement as meaningless without the oracleId it was taken against, and don't
compare numbers across oracles.
Real-world documents enter the corpus through the intake script, which strips personal information via Word and assigns default thresholds:
powershell tools/ReferenceGen/Intake-Document.ps1 -Path C:\docs\contract.docx -Category 02-styles
The visual diff harness
Three metrics per page, candidate vs reference:
- Pixel diff % — after a 1-pixel tolerance dilation (a pixel only counts as different if it cannot be matched within the 3×3 neighborhood in both directions), over the union canvas. Catches missing/moved content while forgiving single-pixel rasterization jitter.
- SSIM — structural similarity over luma, uniform 8×8 sliding window; 1.0 = identical.
- Glyph-position diff — characters and boxes are extracted from both PDFs via PDFium and compared with per-axis twip tolerances. This catches sub-visual drift (a fraction of a pixel is many twips) and localizes it to specific glyphs.
On any threshold failure the harness writes, per page: a side-by-side PNG, a red diff
heatmap over the reference, and a JSON report with the worst diff rectangles and the glyph
mismatch list (under artifacts/visual/, or DOCWRIGHT_ARTIFACTS_DIR).
Two comparison gates
The harness is driven from tests/DocWright.Rendering.Tests in two distinct modes:
- Oracle self-check (
CorpusTests) — compares each committed reference PDF against itself. It exercises the full production path (discovery, rasterization, glyph extraction, thresholds, scoreboard) and guards against rasterizer/reference drift; it is not a fidelity signal (candidate == reference). Actuals:artifacts/visual/corpus/. - Candidate fidelity gate (
DocWrightFidelityTests) — the real DOCX → PDF signal. For every reference it converts the source DOCX withDocWrightConverterand compares DocWright's own PDF against the oracle through the same three metrics. Actuals:artifacts/visual/candidate/candidate-actuals.json.
The candidate gate has two enforcement tiers:
- Always enforced: a well-formed PDF is produced and its page count matches the oracle. Differing pagination can never be "visually identical", so it is a hard failure.
- Enforced under
DOCWRIGHT_ENFORCE_FIDELITY=1: the pixel/SSIM/glyph budgets below. Every document's real numbers are written tocandidate-actuals.jsonand the test output either way, so a non-enforcing run still reports the current fidelity.
Both tiers are conditional on the font environment. DocWright must resolve the same
families the oracle embedded (the seed corpus is Calibri / Calibri Light); a substituted
family has different advance widths, which moves line breaks and therefore page breaks. So a
run whose conversion reports DXP3004 — the converter's family-level substitution summary —
prints its numbers and gates on nothing, naming the substituted families. Style-level
simulations on a family that is installed (DXP3001 synthetic bold, for instance) are
deterministic DocWright behavior, not an environment difference, and do not disarm the gate.
The flag is set in CI, but the Linux harness job has no Calibri, so it reports; run the
harness where the reference fonts exist to make it gate. candidate-font-substitutions.json
next to the actuals records what a given run substituted.
Thresholds
Two files, on deliberately different scales:
tests/corpus/thresholds.json— the oracle self-check budget, and the floor the perturbed fixture must breach to prove the harness detects a 40tw shift. Actuals here are exactly 0, so it stays tight.tests/corpus/fidelity-thresholds.json— the candidate fidelity budget: DocWright's measured gap against the oracle plus ~20% headroom. This is recorded debt, not a target. It has no$default: every corpus document carries its own reviewed, measured entry, and a document without one fails with instructions rather than silently inheriting a budget.
Both map a corpus doc id (01-plain-text/seed-01-...) to
{maxPixelDiffPercent, minSsim, maxTextDxTwips, maxTextDyTwips}. Semantics: a page fails
when pixelDiff% exceeds its max, SSIM falls below its min, or any glyph drifts beyond a
per-axis twip tolerance.
Keeping them apart matters: DocWright's real gaps are orders of magnitude larger than the self-check's, so widening the shared file to fit them would silently retire both the rasterizer-drift guard and the perturbation detection test.
Ratchet policy
Thresholds only ever tighten. The nightly job (nightly-ratchet.yml) runs the harness and
proposes tightened values wherever actuals beat thresholds by more than 20% (proposal =
actual + 20% headroom), once per file — the self-check budget from
artifacts/visual/corpus/actuals.json, the fidelity budget from
artifacts/visual/candidate/candidate-actuals.json. Each proposal is an artifact; applying
one is a reviewed commit. Loosening a threshold requires a human and a justification.
Scoreboard
scoreboard.json at the repo root is the long-term fidelity trend line: one appended entry
per recorded commit with the corpus-wide fidelity score (mean over pages of
ssim × (1 − pixelDiff)), per-document scores, and the oracles used. The corpus test
appends an entry when DOCWRIGHT_UPDATE_SCOREBOARD=1 (with DOCWRIGHT_COMMIT=<sha>).
Corpus
Graded folders under tests/corpus/: 01-plain-text … 08-doc-binary. The three seed
documents in 01-plain-text are generated by tools/CorpusGen (OpenXml) and are
byte-stable: zip entries are sorted, timestamps pinned, relationship ids fixed — so the
corpus is reproducible from source. tests/fixtures/perturbed/ holds a deliberately
shifted variant of seed-01 (top margin +2pt) whose oracle export must fail the thresholds —
the harness's canary.
Determinism rules (enforced from Phase 0)
- All layout arithmetic is integer twips (1/20pt); font-unit conversion is
funits × emSize / unitsPerEmwith round-half-away-from-zero (FixedPointMath). - Floating point only in font parsing and at the renderer boundary.
- InvariantCulture everywhere; CI runs the entire suite under tr-TR.
- No timestamps in output unless caller-supplied (
PdfMetadata.CreationDate). - Public API is approval-tested (
tests/DocWright.Tests/PublicApi/*.approved.txt); evolution is additive-only within a major version. To re-approve after an intentional additive change:DOCWRIGHT_APPROVE_API=1 dotnet test -f net10.0. - Hot paths are covered by zero-allocation tests (net10) and BenchmarkDotNet baselines; later phases may not regress committed baselines by more than 5%.
CI
- Windows job — builds every TFM, runs the net48 test leg.
- Linux job — net10 tests with an 85% line-coverage gate on core projects, the same suite under tr-TR, the visual harness against committed references (LFS), and a benchmark smoke run.
- refresh-references — manual, self-hosted Windows + Word; opens a PR with regenerated references.
- nightly-ratchet — proposes threshold tightenings as an artifact.
Word quirks
When observed Word output contradicts ECMA-376/ISO-32000, Word wins — every such case is recorded in WORDQUIRKS.md with a repro document and the Word build.
For host-side compatibility extension guidance (registering external readers/fallbacks), see COMPAT.md.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
| .NET Core | netcoreapp2.0 was computed. netcoreapp2.1 was computed. netcoreapp2.2 was computed. netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.0 is compatible. netstandard2.1 was computed. |
| .NET Framework | net461 was computed. net462 was computed. net463 was computed. net47 was computed. net471 was computed. net472 was computed. net48 is compatible. net481 was computed. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen40 was computed. tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETFramework 4.8
- DocWright.Core (>= 1.1.0)
- DocWright.Dom (>= 1.1.0)
- Microsoft.Bcl.HashCode (>= 1.1.1)
- System.Buffers (>= 4.6.1)
- System.Memory (>= 4.6.3)
- System.Runtime.CompilerServices.Unsafe (>= 6.1.2)
-
.NETStandard 2.0
- DocWright.Core (>= 1.1.0)
- DocWright.Dom (>= 1.1.0)
- Microsoft.Bcl.HashCode (>= 1.1.1)
- System.Buffers (>= 4.6.1)
- System.Memory (>= 4.6.3)
- System.Runtime.CompilerServices.Unsafe (>= 6.1.2)
-
net10.0
- DocWright.Core (>= 1.1.0)
- DocWright.Dom (>= 1.1.0)
-
net8.0
- DocWright.Core (>= 1.1.0)
- DocWright.Dom (>= 1.1.0)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on DocWright.Formats.Text:
| Package | Downloads |
|---|---|
|
DocWright
Convert, render and edit Word documents in .NET — no Microsoft Word, no Office interop, no native dependencies. Reads DOCX and legacy DOC, renders to PDF and PNG/JPEG, saves to DOCX, HTML, Markdown, RTF, text, ODT, EPUB and RDL, rasterizes existing PDFs, and edits documents through a mutable model with find/replace, placeholder filling, content controls and tracked changes. This is the main package: install it for the whole pipeline. |
GitHub repositories
This package is not used by any popular GitHub repositories.
See CHANGELOG.md for release notes.