WordCanvas.ClearScript
0.12.0
dotnet add package WordCanvas.ClearScript --version 0.12.0
NuGet\Install-Package WordCanvas.ClearScript -Version 0.12.0
<PackageReference Include="WordCanvas.ClearScript" Version="0.12.0" />
<PackageVersion Include="WordCanvas.ClearScript" Version="0.12.0" />
<PackageReference Include="WordCanvas.ClearScript" />
paket add WordCanvas.ClearScript --version 0.12.0
#r "nuget: WordCanvas.ClearScript, 0.12.0"
#:package WordCanvas.ClearScript@0.12.0
#addin nuget:?package=WordCanvas.ClearScript&version=0.12.0
#tool nuget:?package=WordCanvas.ClearScript&version=0.12.0
WordCanvas.ClearScript — .NET bindings for the headless WordCanvas pipeline
Run the canvas-word layout / import / export pipeline from C# — DOCX import, page-accurate PDF export, DOCX export, and the fluent DocumentBuilder — without a browser, a Node process, or any port of the engine. The exact same JavaScript that the editor and the Node backend run is hosted inside a V8 isolate via ClearScript; C# marshals only bytes across the boundary.
┌──────────────── .NET process ─────────────────┐
│ WordCanvasEngine (ClearScript V8 isolate) │
│ • loads wordcanvas.clearscript.js (esbuild) │
│ • host-injects the bundled .ttf fonts │
│ • DocumentBuilder / Import / Export (typed) │
│ │
│ byte[] docx ─▶ [ V8: runImport ] ─▶ doc ─┐ │
│ │ │
│ doc ─▶ [ V8: renderPdf / writeDocx ] ─▶ byte[]│
└────────────────────────────────────────────────┘
The document model never crosses the boundary as data: an imported or built document
stays inside V8 as an opaque WordDocument handle, and only binary blobs (the docx
in, the pdf/docx out) are marshalled (as Uint8Array, zero base64).
How it runs the browser engine under bare V8
The pipeline is the isomorphic one described in EXPORT.md — it already runs in a
Web Worker and on Node with no DOM. Hosting it in ClearScript's bare V8 (no Node, no
DOM, no event loop) needed three things, all confined to the JS bundle + the host:
- Fonts by injection, not I/O.
installMeasureHost'sfs/fetchfont read is skipped because the host pre-registers the bundled metric-clone + math fonts viaregisterFont(file, bytes)— so measurement uses the same fontkit-over-clones path as the Node backend, byte-for-byte, with zero I/O. - A host-owned scheduler. Bare V8 has no
process, timers,TextEncoder,navigator,console, etc. The esbuild banner supplies them:process.nextTickandqueueMicrotaskare true microtasks;setTimeout/setImmediateenqueue to a host-drainable macrotask queue. The host then drives a Node-like loop — a V8 microtask checkpoint, then a macrotask drain, repeated — to settle the export's async chain (seePumpUntilDone). Blockingawaitwould deadlock the single V8 thread; this does not. - Synchronous PDF collection. pdfkit's
PDFDocumentis a NodeReadablewhose flowing-mode emission deadlocks mid-stream without a real event loop. Since pdfkit generates everything synchronously (deflateSync), the bundle entry patches it to capture pushed chunks and replay them as synchronousdata+end— lossless, and it sidesteps the stream machinery entirely.
Layout
dotnet/
src/WordCanvas.ClearScript/ the binding library (net10.0, x64)
WordCanvasEngine.cs V8 host: load bundle, inject fonts, pump, marshal
WordDocument.cs opaque in-V8 doc handle + ExportPdf/ExportDocx
Builder/ typed DocumentBuilder/StoryBuilder/… over the JS API
assets/wordcanvas.clearscript.js the esbuild bundle (generated, gitignored)
fonts/ bundled .ttf clones (linked from frontend at build)
bench/WordCanvas.Benchmarks/ BenchmarkDotNet suite over the real reports
frontend/
src/clearscript/entry.ts the bundle entry (exposes the pipeline on globalThis)
scripts/build-clearscript.mjs esbuild → assets/wordcanvas.clearscript.js
Build
The JS bundle is produced by esbuild from the frontend workspace, then the .NET projects copy it (and the fonts) to their output:
# 1. one-time: install the polyfill plugin (already in frontend devDependencies)
npm install
# 2. build the ClearScript bundle (→ dotnet/src/WordCanvas.ClearScript/assets/…)
node frontend/scripts/build-clearscript.mjs
# 3. build the .NET solution
dotnet build dotnet/WordCanvas.slnx -c Release
Requires the Windows x64 ClearScript V8 native package (referenced by the csproj) and .NET 10. The bundled fonts are linked from
frontend/src/export/shared/fonts.
Usage
Import a .docx (incl. from a MemoryStream) and export
using WordCanvas.ClearScript;
using var engine = new WordCanvasEngine(); // one V8 isolate; not thread-safe
// from bytes, a span, or any Stream (e.g. a MemoryStream / HTTP body)
WordDocument doc = engine.ImportDocx(File.ReadAllBytes("report.docx"));
// using var ms = new MemoryStream(uploadBytes); var doc = engine.ImportDocx(ms);
byte[] pdf = doc.ExportPdf(); // page-accurate PDF
byte[] docx = doc.ExportDocx(); // round-tripped OOXML
Console.WriteLine($"{doc.BlockCount} blocks, {doc.MediaCount} images, {doc.Warnings.Count} warnings");
Compose a document with the typed builder
using WordCanvas.ClearScript.Builder;
WordDocument doc = engine.NewBuilder(new CreateOptions { PageSize = PageSizeName.A4 })
.Style(new NamedStyle { Id = "Heading1", Name = "Heading 1",
Char = new CharStyle { Bold = true, FontSizePx = 28 } })
.Paragraph("Quarterly Report", p => p.WithStyle("Heading1"))
.Paragraph("Generated from C#.", p => p.Italic().Color("#555"))
.TableOfContents(new TocOptions { MaxLevel = 2 })
.BulletList("First", "Second", "Third")
.Table(new[]
{
new CellContent[] { "Item", "Qty" },
new CellContent[] { "Widget", "10" },
}, new TableOptions { HeaderRow = true })
.Footer(f => f.Paragraph(p => p.Text("Page ").PageField().Text(" of ").NumPagesField()))
.Build();
byte[] pdf = doc.ExportPdf();
Or start from a template (its styles, lists, page setup, and bands carry over):
var b = engine.NewBuilderFromTemplate(File.ReadAllBytes("template.docx"));
var doc = b.Paragraph("Body generated against the template's styles").Build();
The Paragraph family is fully overloaded — Paragraph(), Paragraph(text),
Paragraph(configure), Paragraph(text, configure) — so you never pass a placeholder
null.
Equations, right-to-left & real table styles
The builder authors every model feature — not just the common ones. Equations accept
LaTeX or MathML (typeset on the same canvas as the editor, round-tripped to OMML on
export); paragraphs carry a base writing direction (OOXML w:bidi); and real table
styles register a conditional-band entity that is baked onto the cells and emitted as
w:tblStyle:
WordDocument doc = engine.NewBuilder()
// A real table style with conditional bands (header + zebra striping + borders).
.TableStyle(new TableStyleDef
{
Id = "Grid", Name = "Grid",
Conds = new Dictionary<TableCond, TableCondProps>
{
[TableCond.WholeTable] = new() { Borders = CellBorders.All(new CellBorder { Color = "#c8ccd0", WidthPx = 1 }) },
[TableCond.FirstRow] = new() { Shading = "#1a1a2e", Char = new CharStyle { Bold = true, Color = "#fff" } },
[TableCond.Band2Horz] = new() { Shading = "#f1f3f4" },
},
})
// Inline + display equations (LaTeX and MathML).
.Paragraph(p => p.Text("The identity ").InlineEquation("e^{i\\pi}+1=0").Text(" flows inline."))
.Equation("\\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}")
.EquationMathml("<math><msup><mi>a</mi><mn>2</mn></msup><mo>+</mo><msup><mi>b</mi><mn>2</mn></msup></math>",
new EquationOptions { Align = EquationAlign.Right })
// Right-to-left base direction (mirrors alignment + start/end indents).
.Paragraph("هذا نص عربي", p => p.Direction(Direction.Rtl))
// Outline level (a TOC entry without a heading style), keep-together, tab stops.
.Paragraph("Appendix", p => p.OutlineLevel(0).KeepTogether())
// Reference the registered table style by id (cells are baked + reference kept).
.Table(new[]
{
new CellContent[] { "Layer", "Role" },
new CellContent[] { "Model", "invertible ops" },
new CellContent[] { "Paint", "fillText per fragment" },
}, new TableOptions { StyleId = "Grid" })
.Build();
Worked example — rebuild the editor's default document
examples/WordCanvas.Example.Showcase rebuilds the editor's flagship "no docId"
sample document entirely with the typed builder and exports it to PDF + DOCX — a
7-page tour exercising headings + TOC, inline fields (DATE/PAGE/NUMPAGES/IF), every
content-control kind, merged / tall (paginating) / field-in-cell tables, embedded
images, multilevel + bullet lists, footnotes, bookmarks, hidden text, hyperlinks,
sub/superscript, CJK text, display + inline equations (LaTeX & MathML), a right-to-left
paragraph, a registered real table style, and header/footer page fields:
dotnet run -c Release --project dotnet/examples/WordCanvas.Example.Showcase
# → showcase.pdf (7 pages) + showcase.docx, re-imports to 140 blocks, 0 warnings
The headless PDF export bundles Latin + STIX-math + a CJK fallback font only, so the RTL demo uses Latin text (the base-direction effect — right alignment + mirrored indents — is script-independent); Arabic/Hebrew flow through the same bidi engine and round-trip in the
.docx.
Headless TOC / field calculation (the Syncfusion replacement)
TOC, PAGEREF, and page-number fields can't be evaluated by OpenXML /
WordprocessingDocument — computing them needs a layout engine to know what
lands on which page. WordCanvas has one, so it can do headlessly what teams otherwise
reach for Syncfusion to do. Both operations are drift-free: only the field result
/ cached numbers are rewritten in the original document.xml; every other byte (and
every field your model doesn't represent) is preserved.
Generate a TOC field's content — for a .docx that carries a TOC field with an
empty/placeholder result (e.g. emitted by your C# pipeline). Produces the entries Word
would render on F9, with live PAGEREF page numbers + hyperlinks:
var result = engine.GenerateToc(File.ReadAllBytes("report.docx"),
new TocOptions { MaxLevel = 3 });
File.WriteAllBytes("report.docx", result.Docx);
// result.Generated / result.Headings / result.BookmarksSynthesized
Recalculate cached page numbers — Word's F9 for TOC/PAGEREF numbers that
already exist but are stale:
var result = engine.RecalcTocPageNumbers(stream); // byte[] or Stream
// result.Changed = how many cached numbers were rewritten; result.Skipped = non-arabic
Both lay the document out with the same engine the PDF export uses, so the page numbers match the rendered pages. Measured on the real reports: generate a 30-entry TOC in ~1.2 s; recalc page numbers in ~0.5–1.3 s.
Building a document? You don't reopen or call anything extra. The builder's
TableOfContents() + Build() regenerates the TOC from the document's current
headings (including ones added after the TOC), and TOC page numbers are
layout-resolved — the export bakes them in the same layout pass, so the exported
DOCX/PDF is always correct. The Syncfusion "save → reopen → UpdateFields → save again"
round-trip simply isn't needed:
var doc = engine.NewBuilder()
.Paragraph("Contents").TableOfContents(new TocOptions { MaxLevel = 1 })
.PageBreak().Paragraph("Section 1", p => p.WithStyle("Heading1"))
.PageBreak().Paragraph("Section 2", p => p.WithStyle("Heading1"))
.Build();
byte[] docx = doc.ExportDocx(); // TOC field already carries live PAGEREF entries + pages
For an explicit, in-memory "Update Fields" on a loaded document (e.g. to materialize an
empty TOC field's entries from the current headings without a docx round-trip), call
UpdateFields — it returns a new handle:
WordDocument updated = doc.UpdateFields(); // or engine.UpdateFields(doc, opts)
Custom fonts
By default the engine embeds only the bundled Latin metric clones (+ STIX math + a
CJK fallback); any other family a document references is substituted to the
closest clone. Register your own fonts so that family is measured and PDF-subset-
embedded as itself — parity with the editor's / Node backend's fonts config.
Bare V8 has no fetch, so the host supplies the raw TTF/OTF bytes directly (the
same model as the bundled fonts — not URLs). A CustomFont carries the family, its
required vertical metrics (FontSizing — ascent & descent as fractions of em, so
measurement, layout, and both exports paginate identically), and the per-style bytes
(Regular required; Bold / Italic / BoldItalic optional, each falling back to
Regular):
using WordCanvas.ClearScript;
var options = new WordCanvasEngineOptions
{
BundlePath = WordCanvasEngineOptions.Default().BundlePath,
FontsDirectory = WordCanvasEngineOptions.Default().FontsDirectory,
CustomFonts = // applied to every engine built from these options
{
new CustomFont
{
Family = "Brand Sans",
Sizing = new FontSizing(ascent: 0.9, descent: 0.25), // required
Regular = File.ReadAllBytes("BrandSans-Regular.ttf"), // required
Bold = File.ReadAllBytes("BrandSans-Bold.ttf"), // optional
Italic = File.ReadAllBytes("BrandSans-Italic.ttf"), // optional
},
},
};
using var engine = new WordCanvasEngine(options);
WordDocument doc = engine.NewBuilder()
.Paragraph("Rendered in Brand Sans", p => p.Font("Brand Sans"))
.Build();
byte[] pdf = doc.ExportPdf(); // Brand Sans measured + subset-embedded, not substituted
- Family matching is case- and quote-insensitive: the run's font family (builder
.Font("Brand Sans"), or a family an imported.docxalready references) just has to name the registeredFamily. - Both PDF and DOCX export honour registered fonts. PDF measures + embeds them; DOCX uses them for TOC page-number pagination (the text itself references families by name, as OOXML always does).
- TTF/OTF only. WOFF/WOFF2 is rejected — both fontkit (measuring) and pdfkit (embedding) need uncompressed SFNT bytes (same limit as the browser).
- Pooling. Prefer
WordCanvasEngineOptions.CustomFontsso every engine aWordCanvasEnginePoolbuilds gets the same set.engine.RegisterCustomFont(...)/engine.ClearCustomFonts()mutate one engine at runtime, but a font registered that way persists on that engine across pool leases, so an unrelated later caller would inherit it — use options for the pooled case.
Hosting in a multi-threaded app (ASP.NET Core)
A WordCanvasEngine owns one V8 isolate and is not thread-safe — two threads
must never touch the same engine at once. ASP.NET Core (and any worker host) runs
requests concurrently on thread-pool threads, so do not register one engine as a
singleton and call it from multiple requests. Three hazards apply: concurrent
access corrupts the isolate; each engine keeps its own V8 heap (unbounded engines →
OOM); and every call is a synchronous, CPU-bound pump that holds its thread.
Use the built-in WordCanvasEnginePool — a concurrency-bounded, thread-safe pool
that leases an engine to one caller at a time, reuses engines across requests (so the
bundle-load + font-install cost is paid once), and caps how many run in parallel
(bounding both V8 heap and thread pressure). Register it as a singleton:
// Program.cs — the pool is thread-safe and shared; engineOptions is fully configurable.
builder.Services.AddSingleton(_ => new WordCanvasEnginePool(
maxConcurrency: 4, // ≈ memory ceiling: 4 × MaxHeapSizeMb
engineOptions: new WordCanvasEngineOptions
{
BundlePath = Path.Combine(AppContext.BaseDirectory, "assets", "wordcanvas.clearscript.js"),
FontsDirectory = Path.Combine(AppContext.BaseDirectory, "fonts"),
MaxHeapSizeMb = 512,
}));
// maxConcurrency defaults to Environment.ProcessorCount; engineOptions to
// WordCanvasEngineOptions.Default() (resolves bundle+fonts next to the assembly or
// from WORDCANVAS_BUNDLE / WORDCANVAS_FONTS). Optionally pre-build engines at startup
// with `await pool.PrewarmAsync(4)` (e.g. from an IHostedService).
// Endpoint — lease an engine for exactly one operation. UseAsync offloads the
// synchronous pump to a thread-pool thread and returns the result; the engine is
// used single-threaded for the whole lambda and returned to the pool afterwards.
app.MapPost("/export.pdf", async (HttpRequest req, WordCanvasEnginePool pool, CancellationToken ct) =>
{
using var body = new MemoryStream();
await req.Body.CopyToAsync(body, ct);
body.Position = 0;
byte[] pdf = await pool.UseAsync(engine =>
{
WordDocument doc = engine.ImportDocx(body);
return doc.ExportPdf();
}, ct);
return Results.File(pdf, "application/pdf");
});
UseAsync<T>/UseAsync— lease + run off the request thread (preferred in request pipelines);Use<T>/Use— synchronous lease for workers already off the request path. Both block (async or sync) until a lease frees up.ctcancels only while waiting for a lease; once the lambda starts, the synchronous V8 call can't be interrupted.- Sizing.
maxConcurrency × MaxHeapSizeMbis roughly the memory ceiling for document work — size it to the box, not to request volume. Excess concurrent requests queue on the pool rather than spawning unbounded isolates. - Combine with the stream overloads (
doc.ExportPdf(Stream)) inside the lambda for allocation-free output under load (see Notes & limitations).
If you'd rather not pool, the fallbacks are a single engine guarded by your own
SemaphoreSlim(1) (serializes all document work), or a scoped/per-request engine
(simple but pays full startup on every request).
Benchmark
dotnet run -c Release --project dotnet/bench/WordCanvas.Benchmarks -- --filter *
BenchmarkDotNet times Import, Export PDF, and
Export DOCX over the real reports in frontend/ (MemoryDiagnoser on). The
corpus directory is auto-discovered; override with the WORDCANVAS_DOCS env var.
Results
BenchmarkDotNet v0.15.8 · Windows 11 · 11th Gen Intel Core i7-11700K · .NET 10.0.9 X64
RyuJIT. LaunchCount=1 WarmupCount=2 IterationCount=6. Mean times; everything runs
through the V8-hosted JS engine.
| Document | Size | Blocks | Import | Export PDF | Export DOCX |
|---|---|---|---|---|---|
| PARTY INVITATION (1 large image) | 13 MB | 11 | 12.5 ms | 42.4 ms | 299.9 ms |
| RenderedReport Version-5 (32408) | 2.7 MB | 1249 | 75.6 ms | 234.8 ms | 170.7 ms |
| RenderedReport Version-5 (no TOC) | 2.7 MB | 1220 | 103.8 ms | 234.3 ms | 106.8 ms |
| AppraiseRequest Version-101 | 9.0 MB | 3353 | 269.1 ms | 406.2 ms | 533.5 ms |
| SignedReport Version-3 | 9.6 MB | 2062 | 194.2 ms | 397.2 ms | 462.3 ms |
Export benchmarks reuse a document imported once in [GlobalSetup], so they measure
export alone. (The 13 MB invitation is one giant image with almost no text, hence its
fast import/PDF but heavier DOCX re-zip.)
These are end-to-end host→V8→host times including all marshalling — a 9 MB, 3353-block real estate report imports in ~270 ms and renders a page-accurate PDF in ~0.4 s.
Comparison vs Syncfusion
VsSyncfusionBenchmarks runs the WordCanvas pipeline head-to-head against
Syncfusion (DocIO +
DocIORenderer) over the same reports and the same three operations — open a .docx,
export to PDF, export to DOCX — grouped by operation with WordCanvas as the baseline,
so the report shows a direct Ratio per document.
1. Provide a Syncfusion license key (Essential Studio v33 — matches the pinned
Syncfusion.* v33.2.15 packages):
$env:SYNCFUSION_LICENSE_KEY = "<your v33 community/trial/commercial key>"
Without a key Syncfusion runs in trial mode (watermark + license overhead) and the numbers are not representative — the benchmark prints a warning in that case.
2. Run the comparison:
dotnet run -c Release --project dotnet/bench/WordCanvas.Benchmarks -- --filter *VsSyncfusion*
# quick non-statistical Syncfusion-only timings:
dotnet run -c Release --project dotnet/bench/WordCanvas.Benchmarks -- sfsmoke
Results
BenchmarkDotNet v0.15.8 · i7-11700K · .NET 10.0.9 · Syncfusion v33.2.15 (licensed). Mean times; "Speedup" = Syncfusion ÷ WordCanvas (>1 → WordCanvas faster).
Export PDF — WordCanvas wins decisively (and allocates 11–335× less):
| Document | Blocks | WordCanvas | Syncfusion | Speedup |
|---|---|---|---|---|
| SignedReport (9.4 MB) | 2062 | 0.42 s | 6.89 s | 16.6× |
| PARTY INVITATION (13 MB) | 11 | 47 ms | 0.43 s | 9.1× |
| AppraiseRequest (9 MB) | 3353 | 0.53 s | 1.07 s | 2.0× |
| RenderedReport (2.7 MB) | 1249 | 0.28 s | 0.90 s | 3.2× |
| RenderedReport (2.7 MB) | 1220 | 0.23 s | 0.76 s | 3.2× |
Import / open — WordCanvas 1.3–1.8× faster, ~700–1500× less allocation (AppraiseRequest: 148 KB vs 182 MB):
| Document | WordCanvas | Syncfusion | Speedup |
|---|---|---|---|
| AppraiseRequest (9 MB) | 281 ms | 500 ms | 1.8× |
| SignedReport (9.4 MB) | 187 ms | 289 ms | 1.5× |
| RenderedReport (2.6 MB) | 78 ms | 121 ms | 1.6× |
Export DOCX — Syncfusion is faster here (2–4×), though WordCanvas still allocates 10–14× less:
| Document | WordCanvas | Syncfusion | Speedup |
|---|---|---|---|
| AppraiseRequest (9 MB) | 565 ms | 136 ms | 0.24× |
| SignedReport (9.4 MB) | 458 ms | 142 ms | 0.31× |
| RenderedReport (2.7 MB) | 103 ms | 49 ms | 0.48× |
Takeaway: WordCanvas dominates the layout-bound operation (PDF, where it reuses its own engine instead of a full re-render) and import, with order-of-magnitude lower memory throughout. Syncfusion's hand-tuned OOXML writer is faster at plain DOCX save.
Fairness notes. Both sides do the same logical work on the same machine and corpus. Differences to keep in mind when reading the numbers:
- Fonts/layout. WordCanvas measures + embeds bundled metric-clone fonts (no system fonts; deterministic, identical to its Node backend). Syncfusion lays out with the machine's installed fonts. So the PDFs differ visually — this compares throughput, not pixel parity.
- Import. WordCanvas import = parse → in-V8 document model (crosses the C#↔V8 boundary); Syncfusion open = parse → in-memory DOM. Both produce a reusable document.
- Export. Both export benchmarks reuse a document loaded once in
[GlobalSetup]. WordCanvas PDF reuses its own layout engine + pdfkit; Syncfusion PDF uses DocIORenderer. WordCanvas timings include host↔V8 marshalling of the output bytes.
Notes & limitations
One engine per thread. A
WordCanvasEngineowns one V8 isolate and is not thread-safe; create one per thread or serialize access. Engine construction loads a ~6 MB bundle + 25 fonts, so reuse it across documents. In a web/worker host use the built-inWordCanvasEnginePool(see Hosting in a multi-threaded app).Memory. Converting all five reports (13 + 9.6 + 9 + 2×2.7 MB) back-to-back in one engine peaks at ~700 MB working set: V8 heap ~100 MB used / ~160 MB committed, .NET managed ~67 MB (the transient output
byte[]s), and ~480 MB of fixed V8 + ClearScript native + CLR + bundle/font baseline paid once per engine.WordCanvasEngine.V8HeapBytes()reports live heap usage. The per-document marginal cost is the model + the output blob; drop eachWordDocument/result between docs to keep a large batch flat.Allocation-free export (high throughput). The bulk of the footprint is the V8 heap (tune
MaxHeapSizeMb, dispose handles) — on the .NET side only the output blob is large. For sustained server load, use the stream overloadsExportPdf(Stream)/ExportDocx(Stream)instead of thebyte[]ones: they copy the V8 result in 256 KB chunks through a sharedArrayPoolbuffer, so the large outputbyte[]is never allocated (no LOH churn). They take a caller-ownedStream, so they compose withMicrosoft.IO.RecyclableMemoryStream(the library itself takes no dependency on it):using var ms = recyclableManager.GetStream(); // RecyclableMemoryStream doc.ExportPdf(ms); // pooled copy; returns bytes written await ms.CopyToAsync(httpResponse.Body);Fonts. The bundle embeds the Latin metric clones, STIX math, and Noto fallbacks for CJK (Simplified Chinese), Arabic, and Hebrew — matching the Node export; other families are substituted to the nearest of these. Register your own with Custom fonts to embed them as themselves. Scripts with no bundled fallback (or glyphs outside a fallback's coverage) still render as tofu until a covering font is registered. The model keeps original family names.
PDF bytes are byte-identical to the Node host (same bundle) in deterministic mode.
WordCanvasEngine.SetExportDate(fixedDate)pins theCreationDate/ModDateand the trailer/ID(the latter is otherwise host-divergent — pdfkit derives it fromBuffer.from(md5WordArray), which differs between Node's nativeBufferand the V8 polyfill — so deterministic mode pins it to a content hash instead). Verified on all five real reports viafrontend/scripts/dump-pdfs.mjs(Node host) +pdfdump(ClearScript host) +compare-pdfs.mjs→ ALL IDENTICAL. Without a fixed date, output is live-dated and not reproducible (same as any pdfkit export).
Keeping the bindings in sync
The C# wrapper is a stringly-typed bridge (InvokeMethod("name", …)), so the compiler
can't catch a JS builder method that gained no C# mirror, or a C# call to a method the
builder renamed. A vitest parity test closes that gap and runs in the normal frontend
CI:
npx vitest run src/builder/csharpParity.test.ts # (in frontend/)
It reflects the JS builder's method surface off the class prototypes and scrapes the JS
method names the bindings invoke out of dotnet/src/WordCanvas.ClearScript/Builder/*.cs,
then asserts both directions: every C# call targets a real JS method, and every JS method
is bridged (or in a small, documented allowlist of internal helpers / typed-variant escape
hatches). A new public builder method fails the test until it's bridged or consciously
allowlisted.
Verifying Node ≡ ClearScript output
The same JS bundle runs under Node and under V8, so their output can be byte-compared
directly (this is also how a UTF-16BE font-name decode bug in the V8 TextDecoder
polyfill was found and fixed):
node frontend/scripts/dump-pdfs.mjs out-node # Node host
dotnet run -c Release --project dotnet/bench/WordCanvas.Benchmarks -- pdfdump out-cs # V8 host
node frontend/scripts/compare-pdfs.mjs out-node out-cs # → ALL IDENTICAL
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | 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. |
-
net10.0
- Microsoft.ClearScript.V8 (>= 7.5.1)
- Microsoft.ClearScript.V8.Native.win-x64 (>= 7.5.1)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.12.0 | 358 | 7/22/2026 |