Repository navigation
Release 1.1.0: PDF platform parity - #4
Conversation
Add IPdfContentReader: one read per document that says what a page carries - images with their masks, text, annotations, form fields, bookmarks, attachments, page detail and XMP metadata - each guarded by the same payload budget, and reporting a warning kind per cause instead of throwing on a document it can still partly read. Text arrives as glyphs, grouped into words and lines by geometry measured in ems and decoded through each font's encoding, /Differences and /ToUnicode. A code no font maps is skipped and named, once per font and page, in TextUnreadable, rather than guessed at. q and Q save and restore the whole text state, and a form starts from the state it inherits, which is what keeps text inside a scaled or rotated form where a viewer draws it. Cover it the way the rest of the repository is covered: fixtures and tests for every member, a read-content example, Chapter 8 of the manual with its section on reading the text (rebuilt to 103 headings and 56 pages), the spec record beside it, and a measurement for the text fixture - 16,400 words over 200 pages in 98.59 ms.
Add PdfDocumentBuilder, PdfPageCanvas, PdfTypeface and PdfAuthoringOptions: pages of a chosen size, lines of text placed on a baseline and PNG images placed by their top-left corner, all in points, written in memory to a PdfByteArraySource that a MergePlan accepts like any other source. Text is drawn in one of the twelve Latin standard fonts through /WinAnsiEncoding, or in a TrueType program the caller supplies - never a system font. An embedded face is subset with glyph ids preserved, its glyf and loca tables rebuilt, every checksum recomputed and a /ToUnicode CMap written, so the characters that were drawn are the characters that come back: 5,332 bytes from the 17,572-byte DejaVu fixture. PNG support reads the 8-bit greyscale, RGB, palette, greyscale-alpha and RGBA forms without an imaging library - IDAT pass-through behind /Predictor 15, the five PNG filters, alpha split into an /SMask, tRNS palettes expanded to RGB, a colour key written as /Mask and pHYs driving the natural size. A fixed creation date and document identifier make the bytes reproducible. Cover it the way the rest of the repository is covered: 23 authoring tests, 549 per target on net10.0 and net11.0, an author-page example checked with pikepdf, pypdf and PyMuPDF, Chapter 12 of the manual with the cover recipe moved to Chapter 13 (rebuilt to 112 headings and 62 pages), the spec record beside it, and authoring measurements - about 9 KB and 0.036 ms per page.
Run the authoring and full suites in a fontless Linux container, check the generated PDFs with qpdf and pdftotext, correct the README test count, and make the missing-file test create its parent directory so it behaves the same on Unix.
Phase 1 (T014-T021) adds the immutable geometry, paint, image and text values, the new warnings, and the internal content-stream, path and graphics-state writers with byte-exact tests.
A plan could rotate, delete, move and duplicate pages but could not change the geometry a viewer uses, so a caller who needed a smaller visible area or a different paper size had to re-render the page outside the library. PdfEditPlan.CropPage sets an explicit /CropBox and ResizePage changes /MediaBox, with PdfPageResizeMode.Scale scaling the content or Preserve keeping it at its original size and centring it. Neither operation rewrites content bytes. A resized page stores its untouched original /Contents array between a q plus cm stream and a Q stream, which a viewer concatenates; a crop only writes a new box. Inherited and existing page boxes are materialised, so a crop set before a resize travels through the same transform, and a source box written in the opposite corner order is normalised and reported as PdfWarningKind.PageGeometryNormalized. A crop box that reaches outside the media box is clamped and reported; one that misses it entirely is refused before any output is written. Cover it the way the rest of the repository is covered: eight geometry tests through the public read side, 603 tests per target on net10.0 and net11.0, a public API snapshot entry for every new type and member, Chapter 14 of the manual, and the changelog.
Two engine ports, both resolved by AddSharpCorePDF() and both constructible by hand like the ones that came before: IPdfDocumentSigner with IPdfDocumentSignatureValidator, and IPdfLayoutReader with IPdfObjectInspector. Neither brings a policy of its own: signing opens no socket, validating trusts nothing the caller did not name, and reading writes no timestamp. Signing appends one revision to the source and leaves every byte before it where it was. The writer reserves a /ByteRange and a /Contents placeholder, hashes the revision with the gap removed, patches the range and only then writes the detached CMS into it; the CMS is built here rather than by SignedCms, because a detached signature over a reserved range needs the digest of everything except its own placeholder, the signed attributes CAdES names and a size known before the value exists. A second signature therefore covers the revision the first one made, and the first still covers the revision it signed. Validation reports integrity and trust as separate results, because a document changed after signing and a certificate that leads nowhere are different defects; a structure it cannot read is Unknown, never Valid. Reading exposes the letters, the words they make, the blocks those words make and the order the page is read in, with the geometry a caller measures with, plus the links the page carries and the object graph behind it. On the labelled corpus the recall and box agreement are 100% on all five documents and the reading order is 100% on four; `table` is recorded as a known order mismatch, because telling a table from two columns is a semantic judgement and table semantics are out of contract. The external run found two defects the unit tests did not, and both are fixed here: incremental cross-reference entries were written 19 bytes long where the specification fixes 20, and the signatureTimeStampToken attribute lacked the [1] IMPLICIT wrapper its unsignedAttrs field carries. pyHanko now reports intact and trusted with the expected coverage on a signed file and on a twice-signed one, and qpdf reports no problems on either. 1452 tests pass on net10.0 and net11.0; the Release build has no warnings.
The PDF/A writer, the archive example and its manual chapter are the last capability work of Phase 4. Gate 0 C6 is decided as D3: 1.1.0 delivers what this tree implements and the rest of the Must rows move to 1.2, which spec.md, tasks.md, parity.md and the release checklist now say in one voice. C5 stops being a shell session: docs/tools/verify-cover.py runs the example, repeats the qpdf, pypdf and PyMuPDF checks, asserts the recorded values and prints the transcript to attach.
The release lanes are done and recorded: T101 re-ran every measurement (no regression above 10%), T102 ran the determinism, empty-font, no-network and no-new-package checks, T104 ran quickstart.md end to end, and T105 audited the parity table, wrote the 1.1.0 entry, bumped the version to 1.1.0 and restated the gates G1-G9 for this release. T103's build is 0 warnings and 0 errors with 748 tests on both legs and no 1.0 test removed, but its coverage gate is not met: 6,841 of the 8,798 executable lines 1.1 adds are covered, 77.76% against the 90% floor. Two runs before it measured 76.36% and 77.48%, so the miss is a test count and not a definition. ADR-002 records the decision to ship with that number stated: the floor stays at 90%, no test is written only to execute a line, and the parsers' refusal branches become a 1.2 task. It carries the release owner's signature line, because an unsigned waiver is what the gate exists to prevent. Five tests were added where the 1.1 surface had none: the inline-image abbreviations a producer actually writes, an inline image with no EI, and the signing pair resolved from AddSharpCorePDF(), including the arguments it refuses before it opens a source.
#4 is open, 003-pdf-platform-parity into main, twelve commits. T104 closes on it and T106's "clean tree" step now means something concrete, so both task records name the pull request instead of describing it as pending. The release-checklist's list of what stands between the gate table and a tag loses its middle item: the transcript is Git-ignored and therefore lives in the pull request body under a collapsed block, not as an attachment. What is left is the ADR-002 signature and the tag naming the commit whose SonarCloud analysis is newest on main.
C5's own transcript, on this commit
artifacts/c5-transcript.mdC1–C3 transcript — cover page, 2026-10-23uv run --with pikepdf --with pypdf --with pymupdf python docs/tools/verify-cover.py
dotnet run -c Release --project .\examples\SharpCorePDF.Examples -- author-page
uv run --with pikepdf python docs/tools/validate-pdf.py <cover.pdf> <merged.pdf>
uv run --with pypdf python -c "from pypdf import PdfReader; ..."
uv run --with pymupdf python -c "import pymupdf; ..."Versions: .NET SDK 11.0.100-rc.2.26504.105; Python 3.12.10; pikepdf 10.17.0 (qpdf inside); pypdf 6.19.0; PyMuPDF 2.0. The example reported
Verdict: pass — 0 expectation(s) broken. |
There was a problem hiding this comment.
🔵 Needs a closer look
It introduces a large new public API surface including cryptographic signing and PDF/A, and knowingly ships below the coverage gate under an ADR, so a human owner must make the final release call.
2 open issues
What changed in this PR
This PR ships the 1.1.0 "PDF platform parity" release, expanding SharpCorePDF from a merge/inspect library into one that also authors, edits, reads content/layout, signs/validates, and converts to PDF/A — all dependency-free. It also bumps the version, updates the manual/docs, swaps the old _bmad/.cline tooling references for Spec Kit (.specify/.clinerules), and records the release decisions in ADRs (including ADR-002, which documents shipping below the 90% new-code coverage gate at 77.76%).
Changes:
- Adds large public API surfaces across Domain (value objects, read models, ports, exceptions), Infrastructure (reading/layout analysis, authoring, signatures, filters), and Application (services + ports), wired through
AddSharpCorePDF(). - Updates version to
1.1.0, release notes, manual content/metadata, examples, fixtures tooling, CI Sonar exclusions, and.gitignore. - Introduces ADRs and Spec Kit scaffolding; migrates away from the old BMad tooling references.
| File / Group | Description |
|---|---|
src/SharpCorePDF.Domain/ValueObjects/* (fonts, images, paint/path/graphics, text layout, signatures, PDF/A, strategies) |
New immutable value objects and enums backing authoring, reading and signing. PdfFillRule.cs has an inaccurate NonZero doc. |
src/SharpCorePDF.Domain/ReadModels/* (hyperlink, destination, text block, signature validation, embedded file, etc.) |
New read models for the reading/signing surfaces. PdfHyperlink.IsInternal is logically inconsistent with IsExternal for file-target links. |
src/SharpCorePDF.Domain/Ports/*, Exceptions/* |
New domain ports (signer, validator, inspector, A-writer, timestamp) and edit/authoring exceptions. |
src/SharpCorePDF.Infrastructure/Reading/*, Authoring/*, Pdf/PdfFilters*, signature helpers |
Engine implementations: letter/line segmentation, hyperlink projection, PDF/A rules, shared Flate/ASCII filter exposure, CMS signature model, certificate naming. |
src/SharpCorePDF.Infrastructure/PdfEngineOptions.cs, PdfDocumentSignatureValidator.cs, PdfAuthoringOptions.cs, PdfGlyphRead.cs |
New engine options (MaxInlineContentBytes), validator entry point, authoring options, glyph/page-box records. |
src/SharpCorePDF.Application/{Ports,Services}/* |
Use cases and engine-side ports for signing, validating, object inspection and PDF/A conversion. |
src/SharpCorePDF.DependencyInjection/...Extensions.cs |
Registers the new editor/reader/signer/validator/A-writer services behind their ports. |
src/Directory.Build.props, src/.editorconfig, *.csproj |
Version bump to 1.1.0, release notes, analyzer suppressions, font/asset packaging. |
tests/SharpCorePDF.Tests/* |
New tests for image sources, layout/hyperlink reading, content reader harness; cross-platform fix for the missing-file test; .ttf/parity asset copying. |
docs/**, .github/**, .specify/**, .clinerules/.gitignore |
Manual/version/docs updates, ADRs, CI Sonar exclusion changes, Spec Kit scaffolding, tooling-reference migration. |
Review effort: Balanced
| /// Gets a value indicating whether the link goes somewhere this document resolves, which is what | ||
| /// distinguishes a jump from a link that names something the reader cannot follow. | ||
| /// </summary> | ||
| public bool IsInternal => Uri is null; |
| /// <summary>The rule that decides which parts of a path are inside when it is filled or clipped.</summary> | ||
| public enum PdfFillRule | ||
| { | ||
| /// <summary>A point is inside when a ray crosses the path an odd number of times, counting direction.</summary> |
The Linux leg of the pull request's CI failed ParityCorpusTests on labelled/header-body.json: f44b7aa9… recorded against b556a358… in the run. The manifest records each fixture's SHA-256 and byte count, the generator wrote the platform's newline, and Git committed the LF form of a file generated with CRLF. The corpus therefore matched its own manifest only on the machine that produced it, and no Windows run could see it - all three of them passed. Three fixes, one at each level. make_parity_fixtures.py writes LF explicitly (newline="\n" for the manifest and the five labelled exports), so the bytes it produces are the bytes it records. .gitattributes marks the parity folder -text, so no checkout converts a fixture back to CRLF. And the five labelled entries in manifest.json are recomputed from the LF bytes. Checked the way a Linux runner sees it - the blob in the index rather than the working tree: 95 entries, 0 mismatched, 748/748 locally.
…say about it The gate on #4 failed on new_reliability_rating, not on coverage: new_coverage reads 83.5% against Sonar's floor of 80, and a single S2583 at PdfImageFilters.cs:287 took the rating to 3. The condition is not wrong to exist - "written" is assigned inside the row callback Push invokes synchronously - but the analyser does not follow a delegate, and a red gate is a red gate. PredictorRowDecoder.Push now has an overload that undoes rows into a Memory<byte> and returns the bytes written, so the caller branches on a returned value instead of a captured local. Memory rather than Span because a span cannot be captured by the callback inside the overload; the compiler said so first (CS9108). Coverage does not move at two decimals: 6,842 of 8,799, still 77.76%. What does move is the prose. The checklist claimed the quality gate's coverage floor is this repository's 90%; it is Sonar way's 80, and 90 is measured locally. G9 and ADR-002 now quote the service's 83.5% on this pull request instead of its project-wide 92% at 1.0.0, and say why the two numbers differ rather than leaving a reader to reconcile them.
#4 is open, 003-pdf-platform-parity into main, twelve commits. T104 closes on it and T106's "clean tree" step now means something concrete, so both task records name the pull request instead of describing it as pending. The release-checklist's list of what stands between the gate table and a tag loses its middle item: the transcript is Git-ignored and therefore lives in the pull request body under a collapsed block, not as an attachment. What is left is the ADR-002 signature and the tag naming the commit whose SonarCloud analysis is newest on main.
…say about it The gate on #4 failed on new_reliability_rating, not on coverage: new_coverage reads 83.5% against Sonar's floor of 80, and a single S2583 at PdfImageFilters.cs:287 took the rating to 3. The condition is not wrong to exist - "written" is assigned inside the row callback Push invokes synchronously - but the analyser does not follow a delegate, and a red gate is a red gate. PredictorRowDecoder.Push now has an overload that undoes rows into a Memory<byte> and returns the bytes written, so the caller branches on a returned value instead of a captured local. Memory rather than Span because a span cannot be captured by the callback inside the overload; the compiler said so first (CS9108). Coverage does not move at two decimals: 6,842 of 8,799, still 77.76%. What does move is the prose. The checklist claimed the quality gate's coverage floor is this repository's 90%; it is Sonar way's 80, and 90 is measured locally. G9 and ADR-002 now quote the service's 83.5% on this pull request instead of its project-wide 92% at 1.0.0, and say why the two numbers differ rather than leaving a reader to reconcile them.


Ships
1.1.0: the PDF platform parity release, and the release gate itself. Twelve commits, fast-forward frommain— authoring (f6754f6,a375069), editing (b2992c0), the cover-page and reading work (4deb55a,ee8fccb,a0a8210), generated documents and PDF/A (7fa7079), and6f6f1a0, which is this PR's release record.What 1.1 adds
PdfDocumentBuilder,PdfPageBuilder, graphics state, shapes, text layoutsPdfMerger,PdfPageEditor(insert / rotate / delete / crop),PdfPageComposerPdfContentReader— text runs, images, inline images, vector operations, form XObjectsPdfLayoutReader,PdfTextCollector, block segmentationPdfObjectGraph,PdfInspectionPdfSigner(appended revision),PdfSignatureValidator(integrity and trust kept apart)PdfAConverter(PdfA2b,PdfA3b),PdfAValidatordocs/parity.mdaudits all 21 parity rows: eleven name1.1, five are excluded non-goals, five are1.2only. No row claims evidence it does not have — rows 3, 6 and 10 were relabelledPlanned→Deferredin this PR for exactly that reason.The gate that is not met, and the decision about it
The new-code coverage gate asks for 90%. Measured against
git diff v1.0.0 -- srcat 748 tests:Earlier runs on the same tree measured 76.36% and 77.48%, so the miss is a test count, not a definition. Five tests were added where the 1.1 surface had none (inline-image abbreviations, an unterminated
EI, and the signing pair resolved fromAddSharpCorePDF()including the arguments it refuses); that moved the number by 1.4 points, and a further 1.2 points would take two hundred more tests.ADR-002 records the decision: ship with the number stated, keep the floor at 90%, write no test only to execute a line, and take the parsers' refusal branches as a
1.2task.Verification
dotnet build SharpCorePDF.slnx -c Release— 0 warnings, 0 errorsnet10.0andnet11.0— 748/748 each, no test removed sincev1.0.0(git diff v1.0.0 -- testsreviewed)ok (…)quickstart.mdrun end to end — transcript below; it changed the manual rather than the code:quickstart.mdno longer names test classes, a scenario and tool flags that never existedgit diff --checkandgit diff --check v1.0.0— exit 0ea95c78— both legs, the coverage report, and SonarCloud'sQUALITY GATE STATUS: PASSED. Getting there took the two fixes below.<Version>1.1.0,AssemblyVersion/FileVersion1.1.0.0VERAPDF_HOMEunset), so PDF/A conformance rests on the recorded veraPDF 1.30.3PASS; nothing in this PR changes the font embedding or output path that run validatedWhat CI caught, that three Windows runs could not
Both were real, and neither is reachable from a Windows machine:
ParityCorpusTestshashes every fixtureagainst
manifest.json. The generator wrote the platform's newline and Git committed the LF form of filesthat had been generated with CRLF, so the corpus matched its manifest only on the machine that produced it.
The generator now writes LF explicitly,
.gitattributespinstests/SharpCorePDF.Tests/Assets/parity/**as-text, and the five labelled entries in the manifest are recomputed from the LF bytes — checkedagainst the index blobs, which is what every platform checks out: 95 entries, 0 mismatched.
new_coverage83.5%against its floor of 80; what failed was
new_reliability_rating, from oneS2583inPdfImageFilters— a condition on a local assigned inside a callback the analyser does not follow.
PredictorRowDecodergained aPush(ReadOnlySpan<byte>, Memory<byte>)overload that returns the bytes itwrote, so the caller branches on a returned value. Four new lines, all covered: the release figure stays
77.76%.
After merge
v1.1.0on the commit whose SonarCloud analysis is the newest onmain(G8/G9 —publish.ymlchecks the tag against<Version>).1.2picks up the five deferred parity rows (T028, T040, T042) and the coverage follow-up named in ADR-002.The release run (T104), as recorded
artifacts/t104-release-transcript.mdis Git-ignored, so it is inline here rather than attached:Transcript — 2026-10-23, Windows 11 x64, .NET SDK 11.0.100-rc.2
One row of that summary reads
76.36%: the run predates the five tests added in6f6f1a0. The figure this release ships with is 77.76%, and ADR-002 is why.