Skip to content

Release 1.1.0: PDF platform parity - #4

Merged
MPCoreDeveloper merged 21 commits into
mainfrom
003-pdf-platform-parity
Oct 23, 2026
Merged

MPCoreDeveloper merged 21 commits into
mainfrom
003-pdf-platform-parity

Conversation

@MPCoreDeveloper

@MPCoreDeveloper MPCoreDeveloper commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

Ships 1.1.0: the PDF platform parity release, and the release gate itself. Twelve commits, fast-forward from main — authoring (f6754f6, a375069), editing (b2992c0), the cover-page and reading work (4deb55a, ee8fccb, a0a8210), generated documents and PDF/A (7fa7079), and 6f6f1a0, which is this PR's release record.

What 1.1 adds

Surface Entry point
Generate a document from nothing PdfDocumentBuilder, PdfPageBuilder, graphics state, shapes, text layouts
Merge, insert and cover pages PdfMerger, PdfPageEditor (insert / rotate / delete / crop), PdfPageComposer
Read what a page draws PdfContentReader — text runs, images, inline images, vector operations, form XObjects
Read layout, not bytes PdfLayoutReader, PdfTextCollector, block segmentation
Walk the object graph PdfObjectGraph, PdfInspection
Sign and validate PdfSigner (appended revision), PdfSignatureValidator (integrity and trust kept apart)
Convert and check PDF/A PdfAConverter (PdfA2b, PdfA3b), PdfAValidator

docs/parity.md audits all 21 parity rows: eleven name 1.1, five are excluded non-goals, five are 1.2 only. No row claims evidence it does not have — rows 3, 6 and 10 were relabelled Planned → Deferred in 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 -- src at 748 tests:

6,842 of 8,799 executable lines added by 1.1 are covered — 77.76%

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 from AddSharpCorePDF() 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.2 task.

This PR cannot be tagged until ADR-002 carries the release owner's signature. It is Proposed, and its signature table is empty. That is deliberate — an unsigned waiver is the thing the gate exists to prevent.

Verification

  • dotnet build SharpCorePDF.slnx -c Release — 0 warnings, 0 errors
  • Both legs, net10.0 and net11.0 — 748/748 each, no test removed since v1.0.0 (git diff v1.0.0 -- tests reviewed)
  • 15 of 15 examples run ok (…)
  • quickstart.md run end to end — transcript below; it changed the manual rather than the code: quickstart.md no longer names test classes, a scenario and tool flags that never existed
  • git diff --check and git diff --check v1.0.0 — exit 0
  • CI on ea95c78 — both legs, the coverage report, and SonarCloud's QUALITY GATE STATUS: PASSED. Getting there took the two fixes below.
  • <Version> 1.1.0, AssemblyVersion/FileVersion 1.1.0.0
  • veraPDF is not installed on the release machine (VERAPDF_HOME unset), so PDF/A conformance rests on the recorded veraPDF 1.30.3 PASS; nothing in this PR changes the font embedding or output path that run validated

What CI caught, that three Windows runs could not

Both were real, and neither is reachable from a Windows machine:

  1. The parity corpus did not match its own manifest off Windows. ParityCorpusTests hashes every fixture
    against manifest.json. The generator wrote the platform's newline and Git committed the LF form of files
    that had been generated with CRLF, so the corpus matched its manifest only on the machine that produced it.
    The generator now writes LF explicitly, .gitattributes pins tests/SharpCorePDF.Tests/Assets/parity/** as
    -text, and the five labelled entries in the manifest are recomputed from the LF bytes — checked
    against the index blobs, which is what every platform checks out: 95 entries, 0 mismatched.
  2. The quality gate failed, and coverage was not the reason. SonarCloud reported new_coverage 83.5%
    against its floor of 80; what failed was new_reliability_rating, from one S2583 in PdfImageFilters
    — a condition on a local assigned inside a callback the analyser does not follow.
    PredictorRowDecoder gained a Push(ReadOnlySpan<byte>, Memory<byte>) overload that returns the bytes it
    wrote, so the caller branches on a returned value. Four new lines, all covered: the release figure stays
    77.76%.

After merge

  1. Tag v1.1.0 on the commit whose SonarCloud analysis is the newest on main (G8/G9 — publish.yml checks the tag against <Version>).
  2. 1.2 picks 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.md is Git-ignored, so it is inline here rather than attached:

Transcript — 2026-10-23, Windows 11 x64, .NET SDK 11.0.100-rc.2
# T104 — the release run of `quickstart.md`

Run on 2026-10-23 from `d:\repos\MPCoreDeveloper\SharpCorePDF`, Windows 11 x64 (12 logical cores), .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,
uv 0.12.18. veraPDF and the `qpdf` CLI are not installed on this machine; `validate-pdf.py` uses the qpdf
inside the pikepdf wheel and `validate-pdfa.py` reports “cannot run” where veraPDF is absent.

Blocks are quoted from the run: long output is trimmed where the omission is marked `…`, and each test
invocation is condensed to the summary line its runner prints.

`<output>` is `C:\Users\Posse\AppData\Local\Temp\SharpCorePDF.Examples\output`.

## Step 1 — the 1.0 baseline still passes

```powershell
dotnet build .\SharpCorePDF.slnx -c Release -t:Rebuild
```

```
Build succeeded.
    0 Warning(s)
    0 Error(s)
Time Elapsed 00:00:11.16
```

```powershell
dotnet test .\tests\SharpCorePDF.Tests\SharpCorePDF.Tests.csproj -c Release -p:TargetFramework=net11.0
dotnet test .\tests\SharpCorePDF.Tests\SharpCorePDF.Tests.csproj -c Release -p:TargetFramework=net10.0 -r win-x64
```

```
net11.0 : total: 743, failed: 0, succeeded: 743, skipped: 0  (3s 507ms)
net10.0 : total: 743, failed: 0, succeeded: 743, skipped: 0  (3s 459ms)
```

The 1.0 baseline was 561 tests; `git diff v1.0.0 -- tests` removes no `[Fact]`/`[Theory]` (0 removed).

```powershell
dotnet run --project .\examples\SharpCorePDF.Examples -c Release
```

```
=== merge-files: merge whole documents from disk                              --- ok (130 ms)
=== select-pages: take selected pages, in a selected order                    --- ok (66 ms)
=== merge-streams: merge documents that are already in memory                 --- ok (14 ms)
=== author-page: write a cover page with a logo and text, …                   --- ok (133 ms)
=== edit-pages: edit a document that exists: insert, rotate, delete, crop …   --- ok (39 ms)
=== no-container: use the packages without a dependency injection container    --- ok (13 ms)
=== inspect-first: describe documents before merging them                     --- ok (25 ms)
=== read-content: read the images, text, comments, form, bookmarks, …         --- ok (70 ms)
=== extract-layout: read the letters, blocks and reading order of a page, …    --- ok (60 ms)
=== inspect-objects: walk the object graph …                                  --- ok (20 ms)
=== options: control metadata, bookmarks, the version and reproducibility     --- ok (15 ms)
=== archive-pdfa: write a document for PDF/A-2b, and refuse one that cannot be --- ok (21 ms)
=== protect-result: write a result that needs a password, …                   --- ok (49 ms)
=== warnings: read what the merge reports about what it had to change         --- ok (4 ms)
=== errors: recognise the ways a merge can be refused                         --- ok (12 ms)
All 15 example(s) ran.   (exit 0)
```

## Phase 1 — creator parity

```powershell
dotnet test .\tests\SharpCorePDF.Tests\SharpCorePDF.Tests.csproj -c Release -p:TargetFramework=net10.0 --filter "FullyQualifiedName~PdfDocumentBuilderTests"
dotnet run --project .\examples\SharpCorePDF.Examples -c Release -- author-page
uv run --with pikepdf --with pypdf --with pymupdf python docs\tools\verify-cover.py --output artifacts\c5-transcript.md
uv run --with pikepdf python docs\tools\validate-pdf.py %TEMP%\SharpCorePDF.Examples\output\cover.pdf %TEMP%\SharpCorePDF.Examples\output\cover-and-summary.pdf
```

```
total: 34, failed: 0, succeeded: 34
--- ok (228 ms)

== cover.pdf
  qpdf check: no problems
  version   : 1.7, pages: 1, objects: 14
  root      : ['/Pages', '/Type']
  page 1    : [0.0, 0.0, 595.28, 841.89] rotate=0 content=1208 bytes, annots=0
  streams   : 6 decoded, 0 failed
== cover-and-summary.pdf
  qpdf check: no problems
  version   : 1.7, pages: 3, objects: 19
  root      : ['/Pages', '/Type']
  page 1    : [0.0, 0.0, 595.28, 841.89] rotate=0 content=1208 bytes, annots=0
  page 2    : [0.0, 0.0, 595.0, 842.0] rotate=0 content=62 bytes, annots=0
  page 3    : [0.0, 0.0, 585.0, 842.0] rotate=0 content=62 bytes, annots=0
  streams   : 8 decoded, 0 failed
```

`verify-cover.py` → **Verification: pass — 0 expectation(s) broken** (8,835-byte `cover.pdf`, 3-page
`cover-and-summary.pdf`, MediaBox 595.276 × 841.89 pt, `/Im1` 152 × 87 with `/SMask`, `(100, 45)` =
`(31, 58, 95)`, `(50, 45)` white). Transcript: `artifacts/c5-transcript.md`.

## Phase 2 — editing parity

```powershell
dotnet test .\tests\SharpCorePDF.Tests\SharpCorePDF.Tests.csproj -c Release -p:TargetFramework=net10.0 --filter "FullyQualifiedName~PdfDocumentEditorTests"
dotnet run --project .\examples\SharpCorePDF.Examples -c Release -- edit-pages
uv run --with pikepdf python docs\tools\validate-pdf.py %TEMP%\SharpCorePDF.Examples\output\edited.pdf
uv run --with pypdf python -c "from pypdf import PdfReader; r=PdfReader('…\edited.pdf'); [print(i+1, p.mediabox, p.cropbox) for i,p in enumerate(r.pages)]"
```

```
total: 37, failed: 0, succeeded: 37

    operations applied           insert a 595.28x841.89 point page at position 1, rotate page 2 by 90 degrees, delete page 4, crop page 3 to [36 36 806 559]
    pages                        3
    bytes written                2247
    output version               1.7
    warnings                     1
      OutputVerified: the edited document was read back and contains 3 page(s), as expected.
    pages read back              3
    page 2 rotation              Clockwise90
--- ok (151 ms)

== edited.pdf
  qpdf check: no problems
  version   : 1.7, pages: 3, objects: 11
  page 1    : [0.0, 0.0, 595.28, 841.89] rotate=0 content=626 bytes, annots=0
  page 2    : [0.0, 0.0, 842.0, 595.0] rotate=90 content=63 bytes, annots=0
  page 3    : [0.0, 0.0, 832.0, 595.0] rotate=0 content=63 bytes, annots=0
  streams   : 3 decoded, 0 failed

1 RectangleObject([0, 0, 595.27559, 841.88976]) RectangleObject([0, 0, 595.27559, 841.88976])
2 RectangleObject([0, 0, 842, 595]) RectangleObject([0, 0, 842, 595])
3 RectangleObject([0, 0, 832, 595]) RectangleObject([36, 36, 806, 559])
```

The only warning is the example's own success signal; the third page's `cropbox` is the rectangle the
plan asked for, read by an implementation that did not write the file.

## Phase 3 — annotations and forms (not runnable on 1.1.0)

```powershell
dotnet test … --filter "FullyQualifiedName~PdfAnnotationTests|FullyQualifiedName~PdfFormTests"
```

```
Test run summary: Zero tests ran
```

Annotations and form fields are 1.2 work: the manual's chapters 15–19 are reserved for them, the tree
carries no annotation or form API, and `annotate-and-fill` is not a scenario. The block is kept in
`quickstart.md` as that phase's acceptance run and marked as 1.2.

## Phase 4 — signatures and PDF/A

```powershell
dotnet test .\tests\SharpCorePDF.Tests\SharpCorePDF.Tests.csproj -c Release -p:TargetFramework=net10.0 --filter "FullyQualifiedName~PdfDocumentSignerTests|FullyQualifiedName~PdfDocumentSignatureValidatorTests|FullyQualifiedName~PdfATests"
dotnet run --project .\examples\SharpCorePDF.Examples -c Release -- archive-pdfa
uv run --with pikepdf python docs\tools\validate-pdf.py %TEMP%\SharpCorePDF.Examples\output\archive-1b.pdf %TEMP%\SharpCorePDF.Examples\output\archive-2b.pdf %TEMP%\SharpCorePDF.Examples\output\archive-3b.pdf
python docs\tools\validate-pdfa.py %TEMP%\SharpCorePDF.Examples\output\archive-2b.pdf --profile 2b
```

```
total: 12, failed: 0, succeeded: 12

--- PDF/A-1b
    pages                        1
    bytes                        6872
    …
    unsatisfied                  0
    remarks                      2
--- PDF/A-2b
    pages                        1
    bytes                        6872
    …
    unsatisfied                  0
    remarks                      2
--- PDF/A-3b
    pages                        1
    bytes                        6872
    …
    unsatisfied                  0
    remarks                      2
    next: python docs/tools/validate-pdfa.py <file> --profile 2b
    refused                      True
    bytes written when refused   0
    the font 'Helvetica' is not embedded; a PDF/A document must carry the program of every font it uses.
--- ok (188 ms)

== archive-1b.pdf   qpdf check: no problems   1.4, pages: 1, objects: 14   root ['/Metadata', '/OutputIntents', '/Pages', '/Type']   6 streams decoded, 0 failed
== archive-2b.pdf   qpdf check: no problems   1.7, pages: 1, objects: 14   root ['/Metadata', '/OutputIntents', '/Pages', '/Type']   6 streams decoded, 0 failed
== archive-3b.pdf   qpdf check: no problems   1.7, pages: 1, objects: 14   root ['/Metadata', '/OutputIntents', '/Pages', '/Type']   6 streams decoded, 0 failed

veraPDF was not found: put it on PATH, set VERAPDF_HOME, or pass --verapdf <path>.
exit=2
```

The conformance claim is veraPDF's and this machine cannot make it: the tool exits 2 (“cannot run”)
rather than inventing a report. veraPDF 1.30.3's `PASS` for 1b/2b/3b is recorded under “Phase 4 progress —
archival output”; `sign-and-archive` (T094) and `validate-signatures.py` against its `signed.pdf` are 1.2
work, and pyHanko's evidence for the signature surface 1.1.0 ships is under “Phase 4 progress —
signatures”.

## Phase 5 — extraction, layout and the object graph

```powershell
dotnet test .\tests\SharpCorePDF.Tests\SharpCorePDF.Tests.csproj -c Release -p:TargetFramework=net10.0 --filter "FullyQualifiedName~PdfLayoutReaderTests|FullyQualifiedName~PdfLayoutExportTests|FullyQualifiedName~PdfLayoutCorpusTests|FullyQualifiedName~PdfObjectInspectorTests"
dotnet run --project .\examples\SharpCorePDF.Examples -c Release -- extract-layout inspect-objects
python docs\tools\score-layout.py <exports> --labels tests\SharpCorePDF.Tests\Assets\parity\labelled --known-order-mismatch table
```

```
total: 78, failed: 0, succeeded: 78

--- Summary.pdf: every object the file lists
    object 1                     Dictionary(2)
    …
    object 8                     Dictionary(2)
    objects                      8
    dangling references          0
    warnings                     0

--- the operators page 1 paints, in the order it paints them
    offset 0                     BT
    offset 10                    Tf Name F1 Integer 24
    offset 20                    Td Integer 72 Integer 742
    offset 57                    Tj String Quarterly summary - page 1 of 2
    offset 60                    ET
    operations                   5
--- ok (24 ms)
```

`score-layout.py` needs a folder of ALTO exports of the five labelled documents; wiring that run is T079
and is open, so this phase's external scoring is the `PdfLayoutCorpusTests` leg above (SC-004's thresholds
are asserted there) and the scored corpus run is recorded when T079 closes.

## Step 8 — determinism and independence

```powershell
dotnet run --project .\examples\SharpCorePDF.Examples -c Release -- author-page
Get-FileHash %TEMP%\SharpCorePDF.Examples\output\cover.pdf -Algorithm SHA256
dotnet run --project .\examples\SharpCorePDF.Examples -c Release -- author-page
Get-FileHash %TEMP%\SharpCorePDF.Examples\output\cover.pdf -Algorithm SHA256
```

```
--- ok (228 ms)
499520B9B79F5476A9C45160134DDBF5783391AD3CF72FCD8A74937D340D7A3D
--- ok (217 ms)
499520B9B79F5476A9C45160134DDBF5783391AD3CF72FCD8A74937D340D7A3D
```

Two processes, one hash. The **merged** file is a different story, and it is the documented one: `/ModDate`
is the current time and the file identifier is derived from the file, so
`cover-and-summary.pdf` hashed `7AF7A3B7…` on the first run and `BB82DBB9…` on the second. Pinning the date
pins the bytes — `options` does exactly that:

```powershell
dotnet run --project .\examples\SharpCorePDF.Examples -c Release -- options
```

```
    fingerprint of run 1         D32E1E6E7A99EA57C74E89F0B47BBC932A22D4C7AC083DB99EB444A6D8B65EB3
    fingerprint of run 2         D32E1E6E7A99EA57C74E89F0B47BBC932A22D4C7AC083DB99EB444A6D8B65EB3
    identical                    True
    modification date            01/01/2026 12:00:00 +00:00
```

`report.pdf` hashed `D32E1E6E7A99EA57C74E89F0B47BBC932A22D4C7AC083DB99EB444A6D8B65EB3` in two separate
processes as well.

```powershell
dotnet list .\SharpCorePDF.slnx package --include-transitive | Select-String 'PdfSharp|PdfPig|SkiaSharp|System.Drawing'
git diff v1.0.0 --unified=0 -- 'src/**/*.csproj' | Select-String '^\+.*PackageReference'
Select-String -Path (Get-ChildItem src -Recurse -Filter '*.cs').FullName -Pattern '\b(HttpClient|WebRequest|WebClient|Socket|TcpClient|UdpClient|Dns)\b' | Where-Object { $_.Line -notmatch '^\s*(//|///|\*)' }
```

```
package lines: 108
no PDFsharp, PdfPig, SkiaSharp or System.Drawing package
no PackageReference added to src/ since v1.0.0
no network API in src/ (comments excluded)
```

Without the `Where-Object` the same search returns two hits, both doc comments stating that the library
never opens a socket: `IPdfTimestampProvider.cs:9` and `PdfSignatureOptions.cs:17`. The filter is what
separates a comment from a call, and the quickstart now says so.

## T103 — coverage on new code

```powershell
dotnet tool install --tool-path .\.tools dotnet-coverage
mkdir artifacts\coverage
.\.tools\dotnet-coverage collect -f coverage -o .\artifacts\coverage\net11.0.coverage "dotnet test .\tests\SharpCorePDF.Tests\SharpCorePDF.Tests.csproj -c Release -p:TargetFramework=net11.0"
.\.tools\dotnet-coverage merge -f xml -o .\artifacts\coverage\net11.0.xml .\artifacts\coverage\net11.0.coverage
python docs\tools\coverage-on-new-code.py artifacts\coverage\net11.0.xml
```

```
report: artifacts\coverage\net11.0.xml (one instrumented run)
new code: the lines added to src/ since v1.0.0

assembly                                    covered    lines  line coverage
SharpCorePDF.Domain.dll                         997    1,255         79.44%
SharpCorePDF.Application.dll                    306      375         81.60%
SharpCorePDF.Infrastructure.dll               8,255    9,579         86.18%
SharpCorePDF.DependencyInjection.dll             55       63         87.30%

new code: 6,718 of 8,798 executable added lines covered (76.36%), gate 90%
FAIL: 90% or more of the new code has to be covered
(exit 1)
```

The gate is not met, and the number is not an artifact of the definition. Splitting the same report at the
same `git diff`: the 162 files the diff touches hold 7,363/9,550 lines (77.10%), the files it does not
touch hold 2,235/2,542 (87.92%). So the miss is the 1.1 surface. Of the 2,080 added lines the suite never
executed, 1,243 sit in twenty files, led by `PdfContentScanner` (166 of its 330 added lines, file at
49.70%), `TrueTypeFont` (94, 59.66%), `PdfTextScanner` (93, 65.04%), `SignatureValidator` (85, 62.39%) and
`PdfARequirements` (84, 57.36%). Closing the gap is a test-writing pass or a recorded exception; T103
records the measurement and leaves that decision open.

## Verdict

| Item | Result |
| --- | --- |
| Release build | **pass** — 0 warnings, 0 errors |
| Both test legs | **pass** — 743/743 on net10.0 and net11.0, 0 skipped, 1.0 tests intact |
| All 15 examples | **pass** — 15 `ok`, exit 0 |
| Phase 1 creator | **pass** — 34 tests, `author-page`, qpdf/pypdf/PyMuPDF, 0 expectations broken |
| Phase 2 editing | **pass** — 37 tests, insert/rotate/delete/crop read back by pypdf |
| Phase 3 annotations and forms | **not runnable** — 1.2 work, recorded as such |
| Phase 4 signatures and PDF/A | **partial** — 12 tests + `archive-pdfa` + qpdf clean; veraPDF absent, `sign-and-archive` is 1.2 |
| Phase 5 extraction, layout, object graph | **pass** — 78 tests + `inspect-objects`; `score-layout.py` corpus run is T079 |
| Step 8 determinism | **pass** — `cover.pdf` identical across processes; merged file identical once `ModificationDate` is pinned |
| Step 8 no new package | **pass** — none added, no PDFsharp/PdfPig/SkiaSharp/System.Drawing |
| Step 8 no network | **pass** — code-only search empty |
| T103 coverage on new code | **fail** — 76.36% against 90% |

One row of that summary reads 76.36%: the run predates the five tests added in 6f6f1a0. The figure this release ships with is 77.76%, and ADR-002 is why.

MPCoreDeveloper and others added 12 commits October 21, 2026 16:42
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.
Copilot AI balanced review requested due to automatic review settings October 23, 2026 12:50
#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.
@MPCoreDeveloper

Copy link
Copy Markdown
Owner Author

C5's own transcript, on this commit

docs/tools/verify-cover.py is the gate that asserts C1–C3 rather than trusting them, and T104 asks for both transcripts on the request. artifacts/c5-transcript.md is Git-ignored like the release run, so it is here in full — 1,560 bytes, pass — 0 expectation(s) broken.

artifacts/c5-transcript.md

C1–C3 transcript — cover page, 2026-10-23

uv 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 ok (246 ms) for the build, the merge and the read-back, and wrote an 8,835-byte cover.pdf and a 3-page cover-and-summary.pdf.

  • cover.pdf
    • qpdf check: no problems
    • pages: 1, objects: 14, streams: 6 decoded, 0 failed
    • page 1 MediaBox: 595.276 × 841.89 pt
    • image XObject: /Im1 152 × 87 with /SMask
    • page 1 text: 7 lines, accented line intact: True
    • pixmap at 72 dpi: 596 × 842; (100, 45) = (31, 58, 95), (150, 60) = (31, 58, 95), (50, 45) = (255, 255, 255), (30, 200) = (255, 255, 255)
  • cover-and-summary.pdf
    • qpdf check: no problems
    • pages: 3, objects: 19, streams: 8 decoded, 0 failed
    • page 1 MediaBox: 595.276 × 841.89 pt
    • image XObject: /Im1 152 × 87 with /SMask
    • page 1 text: 7 lines, accented line intact: True
    • pixmap at 72 dpi: 596 × 842; (100, 45) = (31, 58, 95), (150, 60) = (31, 58, 95), (50, 45) = (255, 255, 255), (30, 200) = (255, 255, 255)

Verdict: pass — 0 expectation(s) broken.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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.
@MPCoreDeveloper
MPCoreDeveloper merged commit effe5de into main Oct 23, 2026
2 checks passed
MPCoreDeveloper added a commit that referenced this pull request Oct 23, 2026
#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.
MPCoreDeveloper added a commit that referenced this pull request Oct 23, 2026
…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.
@MPCoreDeveloper
MPCoreDeveloper deleted the 003-pdf-platform-parity branch October 23, 2026 14:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants