RDLL.excelcannon
0.5.2
dotnet tool install --global RDLL.excelcannon --version 0.5.2
dotnet new tool-manifest
dotnet tool install --local RDLL.excelcannon --version 0.5.2
#tool dotnet:?package=RDLL.excelcannon&version=0.5.2
nuke :add-package RDLL.excelcannon --version 0.5.2
ExcelCannon
Fire an Excel workbook in — get a canonical, deterministic, LLM-ready model out. Then diff, score, and lint filled templates against it.
ExcelCannon reads .xlsx/.xlsm and produces one canonical form: JSON for machines (diffable, byte-identical on every run) and a compact text render for LLM inference (~7× fewer tokens than the JSON). It captures everything that makes the workbook function — formulas, styles, merged headers, tables, dropdowns/validations, conditional formatting, named ranges, frozen panes, VBA macro source — plus derived semantics: detected headers, cell roles, a first-class input surface (every fillable field with its label and constraint), and dependency summaries. Three modes decide how much of the value layer comes with it: template (structure and literals), data (everything) and schema (structure and column schemas, with every data-region value suppressed).
Website: https://excelcannon.wee-app.com · Release notes: releases.html
Install
dotnet tool install -g RDLL.excelcannon # the CLI
dotnet add package RDLL.excelcannon.sdk # the same engine, in-process from .NET
Ships as a .NET tool wrapping a native Rust binary, and as a library package that P/Invokes the same engine. Both carry the engine for win-x64 today; linux-x64 and macOS natives are on the roadmap — see Supported platforms.
Commands
| Command | What it does |
|---|---|
extract <SRC> |
Workbook → canonical JSON model (--mode template\|data\|schema, --data = --mode data; --header "Sheet!A7", --distinct-values[=MAX], --out, --text) |
render <SRC> |
Extracted JSON → compact text (--mode/--data) — byte-identical to extract --text. A --mode that disagrees with the model's own kind is stated on stderr; what was stated still decides, so --mode template over a data model withholds the cached results it holds, and --mode data over a model that holds none cannot put them back |
diff <LEFT> <RIGHT> |
Score two workbooks/models: category percentages + input-surface completion (--json, --epsilon, --weights, --top-n, --sheet, --exclude-sheet, --region) |
lint <SRC> |
Check a workbook/model against the lint rules (--json, --strict, --top-n, --rule, --exclude-rule, --list-rules, --sheet, --exclude-sheet, --region) |
diff and lint accept either raw workbooks (.xlsx/.xlsm) or previously extracted .json models on any argument — extension-sniffed.
Scoping. --sheet NAME (repeatable) compares or checks only the named sheets; --exclude-sheet NAME skips them instead; --region "SHEET!RANGE" narrows a sheet to one block. A scoped percentage is always labelled (scoped) and the report states what it covered, so it can never be read as a whole-workbook number.
A sheet named in a lint scope that the workbook does not have is reported as a scope-missing-sheet error — the run fails and the rest of the report still arrives, because a sheet the generator was supposed to produce and did not is the defect worth hunting. In a diff a sheet present on one side is likewise a difference; a sheet present on neither is refused before anything is scored (error[16] InvalidScope, exit 2, no report and no --json file), because there is nothing to compare and a 0.00% would read as a measurement rather than a rejection.
Choosing the rules. --rule ID (repeatable) runs only those rules; --exclude-rule ID skips them; --list-rules prints the ids, what each one needs to fire, and what it looks for. An id that is not a rule is refused rather than ignored, so a typo in a gate cannot pass as a clean workbook. A rule-filtered report is labelled (rule-filtered) and states rules in its JSON, exactly as a scoped one states scope.
Every <SRC> is a path or an https:// URL — see scenario 8. Global flags for the URL case: --timeout <SECS> (60), --max-download <MB> (200), --allow-insecure-http.
Exit codes: 0 clean/identical · 1 differences or lint errors found · 2 tool failure. CI-friendly.
Scenarios
1. Feed a workbook to an LLM
excelcannon extract report.xlsx --data --text report.txt
report.txt is the token-efficient render: per-sheet grids, detected headers, input fields with labels and constraints, formula fills stated once, dependency summaries. Prompt with the text; keep the JSON (--out model.json) as the exact machine record.
A real render, in full — this is the whole of one of the test fixtures:
# excelcannon v5 | kind=data | sheets=2
= INPUT SURFACE 5 fields | 2 blank | 3 filled
-- name Regions = Lists!$A$1:$A$4
== SHEET Form | A1:D6 | frozen r1
-- grid A1:D6
r1: Region | Q1 | Q2 | Total
r2: North | 100 | 120 | 220
r3: South | 90 | 80 | 170
r4: East | 70 | 60 | 130
r5: West | 50 | 40 | 90
r6: ~ | =SUBTOTAL(109,[Q1]) | =SUBTOTAL(109,[Q2]) | =SUBTOTAL(109,[Total])
-- fills
D2:D5 = SUM(RC[-2]:RC[-1])
-- table tblSales A1:D6 hdr totals [Region,Q1:sum,Q2:sum,Total:sum] style=TableStyleMedium2
-- validation F2:F5 list("Yes,No") blank-ok dropdown prompt="Approved?: Pick one" error="Not a choice: Choose Yes or No"
-- validation G2:G5 whole between(1,100) blank-ok
-- condfmt D2:D5 cellIs > 100 -> color#9C0006,fill#FFC7CE
-- headers
A1:D1 [table,type,frozen] A=Region B=Q1 C=Q2 D=Total
-- inputs
Region: A2:A5 filled
Q1: B2:B5 filled
Q2: C2:C5 filled
Approved?: F2:F5 [list("Yes,No")] blank
G2:G5 [whole between(1,100)] blank
-- deps
D2:D5 <- B2:C5
terminals: D2:D5 B6 C6 D6
== SHEET Lists | A1:A4 | hidden
-- cells
A1: North
A2: South
A3: East
A4: West
2. Understand a blank template before filling it
excelcannon extract template.xlsx --text template.txt
Template mode (no --data) keeps labels and formulas but drops cached formula results. The = INPUT SURFACE section lists every fillable field — address, nearest label, validation constraint, number format — which is exactly what a filler (human or model) needs to complete the form.
3. Benchmark how well a template was filled
excelcannon diff expected.xlsx produced.xlsx --json report.json
Markdown summary to stdout, machine report to report.json:
**Overall match: 96.40%**
> weights: value 0.40, formula 0.30, format 0.10, structure 0.20
| category | match | matched | union |
| --- | --- | --- | --- |
| value | 90.91% | 30 | 33 |
**Input surface: 32 fields | A 100.00% | B 90.63% | both 90.63%**
| field | range | A | B |
| --- | --- | --- | --- |
| Revaluation basis | Sales!D45:D60 | filled | blank |
### Sales - value (1)
- `D47` [Revaluation basis D45:D60]: value differs (`S52a CPI` -> `Statutory S148`) - not a permitted value
Category percentages localize what kind of difference; the input-surface score tells you how much of the form was actually completed, and the table under it names the fields somebody left incomplete. Findings carry the field they fall in — field_label / field_range in the JSON — and a value the cell's list validation does not offer is flagged. Exit 1 on any difference makes it gate-able in CI or a benchmark loop.
Scope it when only part of the workbook was under test:
excelcannon diff expected.xlsx produced.xlsx \
--sheet "2b. REV AND ESC" --sheet "3a. DEFERRED BENEFITS" \
--region "4. NOTES!B2:Q120"
**Overall match (scoped): 97.50%**
> scope: 2 sheets ("2b. REV AND ESC", "3a. DEFERRED BENEFITS") + 1 region ("4. NOTES!B2:Q120"); 9 of 12 sheets not scored
> weights: value 0.40, formula 0.30, format 0.10, structure 0.20
Weighting the headline. overall_pct is a weighted mean of the four category percentages, renormalized over the categories the two workbooks actually exercised. The defaults are value=0.4, formula=0.3, structure=0.2, format=0.1, and --weights changes them:
excelcannon diff expected.xlsx produced.xlsx --weights "value=0.5,structure=0.4,formula=0.1,format=0"
A category the flag does not name keeps its default, so --weights format=0 is the whole of "stop letting cell styling drag the number down" — two consultants filling the same template produce different fills and borders and neither is wrong. The weights are ratios, so 1,1,1,1 and 0.25,0.25,0.25,0.25 are the same request; a name that is not one of the four, a negative weight, and four zeros are all refused before either file is read. Every report states the weighting it was scored with — summary.weights in the JSON, the > weights: line in the markdown — so two differently-weighted runs cannot be compared by accident. The SDK sets the same four numbers as DiffOptions.Weights.
4. Catch template-fill defects
excelcannon lint filled.xlsx --strict
Rules: broken references (#REF!), values violating list validations, formulas overwritten with literals, cached error values (#DIV/0!…), input fields left blank. --strict fails on warnings too.
A run of one defect down a column is one finding over the range — P29:P50 hold a cached error value: #N/A (22 cells) — not one per cell, and the report opens with a per-rule count table. --top-n N caps how many findings each rule lists in the human summary, so one flooding rule cannot push the others off the end; the JSON always carries them all.
Gating a generated workbook. Fail the build on the two rules that mean the fill is wrong, and tolerate the rest:
excelcannon lint filled.xlsx --rule broken-refs --rule validation-violations --strict
Half the rules need data the extraction mode may not carry — a schema-mode model has no cell values at all. Those rules are now named rather than silently silent: the report raises one rules-not-evaluated notice and lists the ids under skipped_rules, so a strict gate fails instead of passing a model nothing could be checked against. excelcannon lint --list-rules says which rules need what.
5. Compare against a stored baseline
excelcannon extract golden.xlsx --data --out golden.json # once, commit the JSON
excelcannon diff golden.json produced.xlsx # later runs, no re-extract
Extraction is deterministic — the same workbook always yields byte-identical JSON — so committed models are stable baselines.
6. Share the structure without sharing the data
excelcannon extract personnel.xlsx --mode schema --text schema.txt
Schema mode keeps the headers and describes the rows instead of reproducing them. It publishes text only for an affirmative reason — a column name of a region it actually described, or a caption that passes a set of conservative checks — so a cell it cannot account for is withheld rather than printed. Each column comes back as a type, a format, a constraint, the R1C1 it computes itself with, and how many rows were filled:
The whole of a six-row personnel workbook, schema mode, verbatim — twenty-two planted names, addresses, phone numbers and salaries, none of which appear:
# excelcannon v5 | kind=schema | sheets=2
= INPUT SURFACE 5 fields | 5 filled
-- name Approvers = Directory!$A$1:$A$3
== SHEET Roster | A1:F6 | frozen r1
-- cells
F2: =E2*0.1 {fmt:#,##0.00}
-- fills
F2:F6 = RC[-1]*0.1 {fmt:#,##0.00}
-- validation D2:D6 list("Alpha,Beta") blank-ok dropdown
-- headers
A1:F1 [style,frozen] A=Name B=Email C=Phone D=Team E=Salary F=Bonus
-- schema A1:F1 -> A2:F6
A=Name (string) 5/5
B=Email (string) 5/5
C=Phone (string) 5/5
D=Team (string) [list("Alpha,Beta")] 5/5
E=Salary (number) {#,##0.00} 5/5
F=Bonus (number) {#,##0.00} =RC[-1]*0.1 5/5
-- inputs
Name: A2:A6 filled
Email: B2:B6 filled
Phone: C2:C6 filled
Team: D2:D6 [list("Alpha,Beta")] filled
Salary: E2:E6 {#,##0.00} filled
-- deps
F2:F6 <- E2:E6
terminals: F2:F6
== SHEET Directory | A1:A3 | hidden
-- suppressed 3 cells
A1:A3 hidden 3 string
Use it to hand a workbook's shape to an LLM, a schema reviewer or a ticket when the contents are personal or confidential. A units/notes row under the header is kept as each column's description; a title alone at the top of a sheet is kept as a caption. Hidden sheets are withheld whole, and anything no schema accounts for is counted rather than dropped in silence — every populated cell is described, published, or stubbed here, with no fourth outcome:
-- suppressed 9854 cells
C5:C705 undescribed 701 string
A1:D12572 hidden 50288 string
On the corpus that is the difference between a schema render of eiopa-solvency2-dpm-annotated-templates at 6.35 MB and one at 562 KB.
A header row does not have to be the first row of the sheet. A frozen pane or an autofilter is the author saying where their table starts, so both are read as anchors — from the sheet itself, never from the stale copies a saved custom view keeps — and the rows just above the boundary are searched — which is what finds the header under a title block, under a band of summary figures, or across a blank row from its own data. An anchor alone is never enough: a style or a type break has to agree, or the rows stay withheld and the sheet says so (diagnostic 1017). Where the sheet gives nothing to go on, say it yourself:
excelcannon extract deferred.xlsx --mode schema --header "4a. DEFERRED DATA!A7"
A dropdown that draws its answers from a hidden lookup sheet states them here as well, so a reader of the schema knows what the column actually allows without being handed the lookup sheet. A source the engine will not read out — dynamic, two-dimensional, or full of formulas — publishes nothing and says so:
-- validation B2:B10 list(REV_BASES_Valid = None,S52a CPI,S52a RPI,…(+8 more)) blank-ok dropdown
-- validation F2:F10 list(OFFSET(DROPDOWNS!$E$3,0,0,4,1)) blank-ok dropdown
Schema mode also takes back a source it could read where reading it would restate what the same model just suppressed: a range landing inside a described region's body is that region's rows rather than a vocabulary, and a hidden sheet does not publish a dropdown of its own contents. Data and template modes suppress nothing and keep both.
Schema mode publishes counts, not values. The one exception is opt-in, and off unless you ask for it by name — the vocabulary of a low-cardinality column:
excelcannon extract members.xlsx --mode schema --distinct-values --text schema.txt
B=Plan section (string) values(Bayer|Sterling|Ex-DuPont|Non-Bayer) 24/24
C=Gender (string) values(M|F) 24/24
E=Email (string) values(withheld:identifier_shaped) 24/24
With no number the threshold is 12 distinct values a column; =30 raises it.
Clearing the threshold is not enough: a set is withheld anyway when the column
holds numbers, dates or formulas, when its values repeat too little to read as a
vocabulary rather than as records, when any single one of them was written only
once — a category label is written many times, a name is written once — or when
any one of them looks like an email, a postcode, a national-insurance number or
another identifier. It is withheld whole rather than in part. Without the flag
the model is byte-identical to one from before the option existed.
It reduces exposure; it is not a certified anonymizer. A header somebody wrote a person's name into is still a header. What changed in BL-010 is the direction of failure: a region the detector cannot read is now withheld rather than published, so a miss costs information — stated in the -- suppressed block — instead of costing privacy. See the limitation section in docs/semantics.md.
7. Call it from .NET, in-process
dotnet add package RDLL.excelcannon.sdk
Same four verbs, no child process — as a static class or as an injected
IExcelCannon. The package carries the native engine for win-x64 and
P/Invokes it; on any other platform the first call throws
PlatformNotSupportedException naming the gap. Linux and macOS natives are
planned — the packaging already accepts a staged multi-RID tree, so the missing
piece is building and smoke-testing the other binaries, not shipping them. See
Supported platforms. The canonical JSON stays the
contract, so nothing here duplicates the model as C# types.
using RDLL.ExcelCannon.Sdk;
// Workbook -> canonical model. ModelResult.Json is the same document the CLI writes.
var schema = ExcelCannon.Extract("personnel.xlsx", ExtractMode.Schema);
Console.WriteLine($"{schema.Kind} v{schema.SchemaVersion}: {string.Join(", ", schema.SheetNames)}");
// The token-efficient rendering, for a prompt.
Console.WriteLine(ExcelCannon.Render(schema.Json, ExtractMode.Schema));
// Score a filled template against the gold copy.
var expected = ExcelCannon.Extract("golden.xlsx", ExtractMode.Data);
var produced = ExcelCannon.Extract("filled.xlsx", ExtractMode.Data);
var diff = ExcelCannon.Diff(expected.Json, produced.Json, new DiffOptions { Epsilon = 1e-9 });
Console.WriteLine($"{diff.OverallPct:F1}% match, {diff.FindingCount} differences (exit {diff.ExitCode})");
// Lint what was produced.
var lint = ExcelCannon.Lint(produced.Json);
Console.WriteLine($"{lint.FindingCount} findings, {lint.ErrorCount} errors");
ExtractOptions carries everything extract has on the command line — a stated
header row, schema mode's distinct-values threshold, and the three limits that
apply to an https:// source. Header rows are spelled exactly as --header
spells them, quoted sheet names included, because both surfaces parse them with
the same engine code:
var model = ExcelCannon.Extract("insurer-return.xlsx", new ExtractOptions
{
Mode = ExtractMode.Schema,
Headers = new[] { "'4a. DEFERRED DATA'!A7" }, // same spelling as --header
DistinctValues = 12, // Schema mode only; null or 0 is off
FetchTimeout = TimeSpan.FromSeconds(120), // URL sources only
MaxDownloadMegabytes = 500,
});
An option the engine cannot honour — a header that is not SHEET!CELL, or
DistinctValues outside schema mode — is refused as
ExcelCannonErrorCategory.InvalidExtractOptions (code 17) rather than ignored;
nothing is read. An override that is well-formed but names a sheet the workbook
does not have is not a failure: the extraction succeeds and says so as
diagnostic 1018.
In a host with a container, register it once and inject IExcelCannon — the
same four verbs behind a seam a test can replace:
using Microsoft.Extensions.DependencyInjection; // AddExcelCannon lives here
using RDLL.ExcelCannon.Sdk;
builder.Services.AddExcelCannon(options =>
{
options.ExtractMode = ExtractMode.Schema; // what Extract/Render use when no mode is passed
options.DiffEpsilon = 1e-9;
options.LintStrict = true; // what LintExitCode fails on
});
public sealed class Auditor(IExcelCannon excel)
{
public string Summarize(string path) => excel.Render(excel.Extract(path).Json);
}
AddExcelCannon is idempotent and TryAdd-based — your own registration of
IExcelCannon wins — and registers a singleton, because the engine is stateless
and its error channel is per-thread. Configuration is validated at registration,
not at the first call. new ExcelCannonClient() gives the same instance surface
without a container, and an unconfigured one behaves exactly like the static
class.
netstandard2.0 and net8.0; failures come back as ExcelCannonException,
which carries a typed Category, a stable ErrorCode, the Source/Part/
Sheet/Cell the engine determined, and the IsRetryable/IsUserFixable
flags — so an app can retry a locked file, tell the user to remove a workbook's
password, or report a bug, without parsing English:
catch (ExcelCannonException error) when (error.Category == ExcelCannonErrorCategory.EncryptedWorkbook)
{
Console.Error.WriteLine($"{error.Source} is password-protected; remove the password and re-save.");
}
The code table, the append-only stability covenant and the reaction guidance are
in docs/errors.md, which also documents the C ABI's
excelcannon_last_error_json() and the CLI's --error-format json. Panics are
caught at the boundary rather than unwinding into the CLR.
Extraction that succeeds can still have guessed at things — a workbook with no
styles part, rows placed by document order because a producer omitted r, a
chart recorded as present without being read. Those arrive as diagnostics:
workbook.diagnostics in the model, model.Diagnostics and model.HasWarnings
on ModelResult, and one summary line on stderr from excelcannon extract
(exit code unchanged, stdout still pure JSON). They are not errors, they have
their own append-only code range starting at 1000, and diff ignores them —
same document, second half:
ModelResult model = ExcelCannon.Extract(path, ExtractMode.Data);
if (model.HasWarnings)
{
// fidelity was reduced somewhere; model.Diagnostics says where
}
``` The only package
dependency on `net8.0` is
`Microsoft.Extensions.DependencyInjection.Abstractions` (the `AddExcelCannon`
seam); `netstandard2.0` additionally brings `System.Text.Json`. Packaging
details: [`docs/packaging.md`](docs/packaging.md).
#### Supported platforms
| Package | Native engine shipped for |
|---|---|
| `RDLL.excelcannon` (CLI tool) | `win-x64` |
| `RDLL.excelcannon.sdk` (library) | `win-x64` |
**Restore is not the same as run.** The SDK assembly targets `netstandard2.0`
and `net8.0`, so it restores and compiles on any platform your .NET SDK
supports. What is platform-specific is the native engine underneath it: on a
platform this package carries no binary for, the *first call* throws
PlatformNotSupportedException: RDLL.excelcannon.sdk ships its native engine for win-x64 only in this version; this process is <OS>/<arch>. See the package README, 'Supported platforms'.
— named and immediate, rather than a bare "Unable to load shared library". It is
deliberately not an `ExcelCannonException`: no engine ran, so no engine error
category would be true of it.
`linux-x64` is the next one planned; the packaging path for it already exists
(`package-sdk.ps1 -NativeBinaryRoot <staged tree> -RuntimeIdentifiers …`) and
needs no script changes. What is missing is a build host that can produce the
`.so` *and* run a consumer against it, because this repo's standard is that
packaging proves the packed bits by executing them — shipping a native nobody
has run is worse than shipping none. macOS needs an Apple build host and is
roadmap only. See [`docs/backlog/BL-015-linux-x64-native.md`](docs/backlog/BL-015-linux-x64-native.md).
Every release states the RID list it shipped, in its notes.
### 8. Read straight from a presigned blob or S3 URL
The workbooks are in Azure Blob or S3 and what you hold is a presigned link.
Hand it over as-is — the query string is what authenticates it, so a plain GET
is all that is needed and **no cloud SDK is involved**:
excelcannon extract "https://acct.blob.core.windows.net/reports/q3.xlsx?sv=2024-11-04&sig=..." --mode data --out q3.json excelcannon diff "https://acct.blob.core.windows.net/gold/template.xlsx?sig=..." q3.json --json report.json excelcannon lint "https://bucket.s3.amazonaws.com/filled/q3.xlsx?X-Amz-Signature=..." --strict
```csharp
var model = ExcelCannon.Extract(presignedUrl, ExtractMode.Data);
The signature is treated as a credential. It is never printed, logged, or
written into anything: not the model, not an error message, not a diff or lint
report. A URL is named as scheme://host/path everywhere and nothing else, and
source.filename records the last path segment — q3.xlsx — exactly as it
records a file name for a path. A user:password@ authority is dropped the
same way. The model is byte-identical to one extracted from the same workbook on
disk apart from source.origin, which reads "url"; the SHA-256 covers the
bytes as delivered, so baseline diffs keep working across the two.
Limits and behaviour, all of them deliberate:
| Default | Change it with | |
|---|---|---|
| Transport | https:// only — an unencrypted request would hand the signature to the network |
--allow-insecure-http |
| Timeout, whole fetch | 60 s | --timeout <SECS> |
| Size cap | 200 MB, enforced while reading so a lying Content-Length cannot balloon memory |
--max-download <MB> |
| Redirects | followed, bounded at 10 | — |
| Retries | one, on 5xx / timeout / failed connection only |
— |
Failures map onto the existing error taxonomy with no new codes: 404/410 →
NotFound, 401/403 → AccessDenied (retry by re-issuing the URL), size cap
→ LimitExceeded, everything the network can do → Io, refused http:// →
UnsupportedFeature. The full table is in
docs/errors.md.
Not in scope, by design: Azure/AWS/GCS SDK auth flows (account keys, IAM, OAuth), writing back to a URL, and caching downloads on disk.
Notes
- Determinism: fixed ordering everywhere, no timestamps;
renderreproducesextract --textbyte-for-byte from the JSON. - Schema: canonical JSON carries
schema_version(currently 5); breaking model changes bump it and the minor version.diagnosticsarrived without a bump — absent unless extraction had something to report, so no existing field changed meaning.source.originarrived the same way: absent for a file,"url"for a download. - URL sources: any source argument may be an
https://URL, presigned or public. The HTTP client isureq+ rustls with bundled roots — no tokio, no cloud SDK — and it costs ~3.4 MB of binary;--no-default-featuresbuilds it out. - Fidelity:
diagnosticssays what extraction guessed at or did not model. 12 of the 89 corpus workbooks report anything at all; one reports a warning. - Battle-tested against an 89-workbook corpus (1631 sheets, 7.0M cells) including EIOPA Solvency II QRT sets (574 sheets), EBA COREP/Pillar 3 layouts, and real filled bank/insurer disclosures. 767 tests in the workspace, plus 4
--ignoredcorpus tests run per release.
Development
Rust workspace: excelcannon-core (library), excelcannon-cli, excelcannon-ffi (C ABI cdylib behind the .NET SDK), excelcannon-stress (corpus harness). Managed side: pack/ (the tool's shim) and sdk/RDLL.ExcelCannon.Sdk/ (the library package). See docs/architecture.md, docs/errors.md (the error-code contract), docs/packaging.md, docs/releases.md (release process), docs/backlog/ (work tracking). Build: cargo test --workspace (on the dev box: $env:RUSTUP_TOOLCHAIN='stable-x86_64-pc-windows-gnu').
MIT licensed.
| 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. |
This package has no dependencies.