Feature: 003-pdf-platform-parity | Matrix source: spec.md | Releases: 1.1 (what ships) and 1.2 (the rest)
This is the public scorecard for creation, editing and reading parity. It records what PDFsharp 6.5 and PdfPig 0.1.x do, what SharpCorePDF promises and in which release, who owns the row, the test that fails if the behaviour is reverted, and the external command that validates it.
Status vocabulary
- Evidenced — the named test exists, passes, and the external command has been run on the output.
- Partial — part of the row is evidenced in 1.0 or by an earlier phase; the rest is still planned for the release named in the Release cell.
- Planned — the named test and command become runnable in the task that closes the row. A planned row is a commitment, not evidence; T058, T079 and T096 close the remaining ones in 1.2.
- Deferred — a row that
1.1.0does not complete, after the scope decision of 2026-10-22 below. It is still Must; only its release moved, and the task that completes it is named. - Out of scope — a declared non-goal, owned by the product boundary in
ADR-001, not by a workstream.
No “Must” row is unowned. The owner is the story that closes it: US1 creator, US2 editing,
US3 reading, US4 trust. The external commands use the corpus and the harness described in
docs/parity/README.md and docs/measurements.md.
| # | Capability | PDFsharp / PdfPig reference | SharpCorePDF outcome | Release | Owner | Named test | External validation | Status |
|---|---|---|---|---|---|---|---|---|
| 1 | Page creation, fixed-position text, standard and embedded fonts | PDFsharp: pages, fixed-position text and standard/embedded fonts. PdfPig: basic creation only. | Must: create A4 and custom pages, draw positioned text with standard and caller-supplied TrueType fonts. | 1.1 | US1 creator | PdfDocumentBuilderTests.Writes_an_a4_page_with_its_text, Embeds_a_truetype_font_and_reads_its_text_back |
uv run --with pikepdf --with pypdf --with pymupdf python docs/tools/verify-cover.py (C1–C3 in one command), or validate-pdf.py <output.pdf> alone |
Evidenced (1.0 baseline; re-run 2026-10-22, automated 2026-10-23) |
| 2 | Vector drawing: lines, paths, curves, rectangles, ellipses, fill, stroke, clipping, transforms | PDFsharp: GDI+-class XGraphics. PdfPig: path operations in the builder. |
Must: a path writer over m l c v y h re f S B W n q Q cm gs d J j M G g RG rg K k CS cs SC sc SCN scn ca CA. |
1.1 paths, the rest 1.2 | US1 creator | PdfDocumentBuilderTests.Writes_a_line_as_a_stroked_subpath, Writes_an_ellipse_as_four_bezier_curves, Writes_a_clipping_path, Writes_a_balanced_graphics_state, Writes_a_translucent_fill_with_an_extgstate, Writes_the_line_cap_join_and_miter_limit, Writes_the_dash_pattern_and_its_phase, Writes_a_solid_line_as_an_empty_dash_array, Writes_fill_and_stroke_colours_in_their_space, Writes_a_cmyk_paint_as_four_components, Writes_a_translation_as_a_page_space_transform, Writes_a_scale_that_keeps_the_top_left_corner_still, Writes_a_rotation_that_turns_canvas_coordinates, Writes_the_transform_before_the_drawing_it_applies_to (T022) |
python docs/tools/validate-pdf.py <shapes.pdf> from the draw-shapes example (pikepdf 10.17.0 / qpdf: qpdf check: no problems, PDF 1.7, 1 page, 6 objects, 1937-byte content stream, 0 failed) and a PyMuPDF 1.28.5 render probed at the dash, the even-odd hole, the clip edge and the rotated band (measurements.md §12) |
Partial (2026-10-23) — every operator a canvas call can reach is evidenced, dash/cap/join/miter/SetTransform included; the remainder the Release cell names is the colour-space selection set CS/cs/SC/sc/SCN/scn, which the content builder can write and no public canvas call selects |
| 3 | Text measurement, wrapping, alignment, leading, basic layout | PDFsharp: basic text layout. PdfPig: not a layout engine. | Must: typeface metrics, single-line and multi-line drawing, greedy wrapping, leading and horizontal/vertical alignment. | 1.2 | US1 creator | PdfDocumentBuilderTests.Reports_the_metrics_of_a_standard_font, .Measures_a_standard_font_line_with_the_afm_widths, .Measures_an_embedded_face_with_its_own_metrics, .Wraps_a_paragraph_greedily_to_the_width, .Places_a_line_at_the_alignment_it_was_given, .Stretches_the_lines_of_a_justified_paragraph, .Steps_the_baselines_by_the_line_height, .Breaks_a_word_into_pieces_that_fit, .Refuses_a_word_that_cannot_fit, .Draws_the_layout_from_the_top_left_of_its_box (twenty-two tests in tests/SharpCorePDF.Tests/PdfDocumentBuilderTests.TextLayout.cs) |
uv run --with fontTools python -c "from fontTools.ttLib import TTFont; ..." reads the embedded face's unitsPerEm, ascent, descent and the 8001-unit advance of Veuillez, which is what the layout tests assert; a PyMuPDF read of a probe page shows every span one ascent below the origin it was drawn at, stepping by the 15-point leading and centred on the box; validate-pdf.py reports qpdf check: no problems |
Evidenced (2026-10-23) — standard and embedded metrics, PdfTypeface.Measure, greedy wrapping, left/center/right/justify, leading, the four overflow policies and the DrawTextLayout round-trip; alignment is horizontal only, because no API in the row asks for a vertical one; documented in manual chapter 16 (docs/manual/src/16-text-layout.md) |
| 4 | PNG, JPEG, BMP and GIF images | PDFsharp: PNG, JPEG, BMP, GIF. PdfPig: image extraction, basic creation. | Must: PNG alpha/palette/16-bit policy, JPEG pass-through with EXIF/CMYK transform, BMP and first-frame GIF, explicit refusal by name. | 1.1 PNG, the rest 1.2 | US1 creator | PdfDocumentBuilderTests.Writes_an_rgba_png_with_a_soft_mask, Writes_an_rgb_png_with_a_colour_key, Writes_a_palette_png_with_transparency, Writes_a_greyscale_png_with_alpha, Uses_the_resolution_of_a_png_for_its_natural_size, Writes_one_image_object_for_a_logo_used_on_two_pages, Refuses_an_interlaced_png, Refuses_a_sixteen_bit_png, Writes_a_jpeg_without_re_encoding_it, Writes_a_cmyk_jpeg_with_the_flip_pdf_needs, Writes_a_greyscale_jpeg_as_one_component_image, Uses_the_jfif_density_of_a_jpeg_for_its_natural_size, Writes_a_bmp_with_its_rows_in_order, Turns_a_top_down_bmp_the_same_way_up, Reads_a_thirty_two_bit_bmp_as_opaque_colour, Expands_the_palette_of_a_bmp_to_rgb, Writes_a_gif_and_expands_its_palette, Puts_an_interlaced_gif_back_into_row_order, Turns_the_transparency_of_a_gif_into_a_soft_mask, Draws_only_the_first_frame_of_an_animation, Embeds_one_image_object_for_a_file_drawn_twice, Declares_the_icc_profile_a_png_states, Reads_the_profile_out_of_the_icc_chunk, Reads_a_grey_profile_as_one_component, Refuses_a_profile_that_does_not_describe_the_samples, Refuses_an_icc_chunk_that_is_not_a_profile (T024, T032) |
uv run --with pikepdf python docs/tools/validate-pdf.py <output.pdf> and PyMuPDF image placement; python docs/tools/verify-creator.py runs the creator-parity example and checks the profile it wrote against the PNG it drew, byte for byte, along with the tree, the packet, both images and a render (measurements.md §13) |
Evidenced (2026-10-23) — the PNG matrix, /SMask, pHYs, JPEG pass-through with the CMYK /Decode flip, BMP row order and palette expansion, the GIF first frame with its transparency and interlacing, and the by-name refusal of every variant a reader does not read are covered by the tests above; the ICC policy is decided and implemented: an iCCP profile is written as an /ICCBased colour space with /N and /Alternate, the 3144-byte sRGB profile of creator-parity.pdf reads back byte for byte under pikepdf 10.17.0, PyMuPDF reports the image as ICCBased, and a profile that does not describe the samples is refused by name |
| 5 | Page import, insert, delete, reorder, rotate, crop, resize, split, extract, append | PDFsharp: all operations. PdfPig: merge only. | Must: immutable plan, one-based page references, materialised inherited geometry, no source mutation. | 1.1 operations, external run in 1.2 | US2 editing | PdfDocumentEditorTests.Reorders_deletes_and_duplicates_pages, .Adds_rotations_to_the_rotation_a_page_already_has, .Geometry.Crop_is_clamped_to_the_media_box_and_reported, .Composition.Imports_a_page_of_another_document, .Composition.Appends_every_page_of_another_document, .Composition.Splits_the_document_into_parts_of_the_requested_size |
uv run --with pikepdf python docs/tools/validate-pdf.py <output.pdf>; pypdf reads order and geometry (T058, 1.2) |
Partial: every listed operation is implemented and covered by PdfDocumentEditorTests, .Composition and .Geometry (2026-10-22); the external run and the T058 closure are 1.2 |
| 6 | Overlay, underlay, watermark, stamp | PDFsharp: yes. PdfPig: no. | Must: wrap original content in q/Q, record overlay/underlay in the requested layer with clipping and constant alpha. |
1.2 | US2 editing | PdfDocumentEditorTests.Draws_an_overlay_page_over_the_page_it_is_applied_to, Draws_an_underlay_page_before_the_page_it_is_applied_to, Writes_the_opacity_and_blend_mode_of_a_layer_through_a_graphics_state, Keeps_the_layer_on_the_page_it_was_applied_to (T040) |
uv run --with pikepdf python docs/tools/validate-pdf.py <output.pdf> and a PyMuPDF visual comparison |
Met in 1.2 for the structures: a layer is a form XObject drawn from a stream of the page's own — an overlay appends that stream, an underlay prepends it, the original streams stay untouched between them inside a q/Q frame, which is also what keeps a transform a page states outside a q from reaching the layer, the visible area clips the layer and ca/CA/BM in an ExtGState carry the opacity and the blend mode (2026-10-24). The external validate-pdf.py run on the watermark example's output (pikepdf / qpdf, PDF 1.7, 2 pages, 6 streams decoded, qpdf check: no problems) confirms an independent implementation accepts the layer, and the text reader returns the stamp as text of the pages it was placed on; the PyMuPDF visual comparison has not been made |
| 7 | Metadata, XMP, outlines, destinations, page labels, viewer preferences | PDFsharp: yes. PdfPig: reads metadata and bookmarks. | Must: edit information dictionary and XMP, write outlines/destinations/page labels/viewer preferences; removed destinations warn. | 1.1 metadata, the rest 1.2 | US2 editing | PdfDocumentEditorTests.Overrides_the_metadata_it_sets_and_preserves_the_rest, Drops_xmp_when_metadata_overrides_the_information_dictionary, Keeps_xmp_when_metadata_is_not_overridden, Replaces_the_xmp_packet_it_is_given, Writes_the_bookmarks_it_is_given, Removes_the_outline_of_the_source_when_it_is_empty, Writes_the_page_labels_it_is_given, Writes_the_viewer_preferences_it_is_given (T041); XMP authoring and outlines are PdfDocumentBuilderTests.Carries_the_xmp_packet_it_was_given, .Writes_the_outline_it_was_given (T034) |
uv run --with pikepdf python docs/tools/validate-pdf.py <output.pdf>; pypdf reads metadata and outline |
Met in 1.2 for the writes: SetMetadata, SetXmp, SetOutline, SetPageLabels and SetViewerPreferences each replace what the source carried, a stated outline or label range that names a page the result does not have is refused, and PdfOutline.Empty / PdfPageLabeling.None remove rather than drop (2026-10-24). A bookmark of a page that a later operation deletes is still redirected to the first surviving page with a warning |
| 8 | AcroForm creation, filling, flattening and appearance generation | PDFsharp: yes. PdfPig: read-only forms. | Must: text, checkbox, radio, choice and push-button fields; unique qualified names; fill, reset, read-only refusal and flatten with generated appearances. | 1.2 | US2 editing | PdfFormEditorTests.Fills_and_reads_back_form_values, Fills_a_field_the_document_already_carries, Refuses_a_read_only_field, Refuses_an_unsupported_field_type, Flattens_a_form_without_a_form_viewer, Resets_the_form_to_its_default_values, Removes_the_xfa_form_and_says_so (T044, T055) |
python docs/tools/validate-pdf.py on the three files the fill-form example writes (form-created.pdf, form-filled.pdf, form-flattened.pdf): qpdf check: no problems for all three, page 1 carrying five annotations and a 62-byte content stream before the flatten and none with 226 bytes after it; a pypdf read-back of the same three (person.name = Grace Hopper, agree = /Off, country = BE, and no field at all on the flattened file); and a PyMuPDF render of the flattened page that reads Grace Hopper as page text (T056) |
Met in 1.2 (2026-10-24): the five kinds are written with unique qualified names, a field the document already carries is filled in place — its rectangle, ancestors and widgets survive — and a name it does not carry becomes a new field of the tree the name spells; ResetForm restores /DV, FlattenForm draws each widget into the page content and drops the field, /NeedAppearances is written false because every widget carries the appearance the engine drew, and a read-only field, a signature field and a name that would split an existing one are refused with the name they name; an XFA packet is removed with XfaFormRemoved and an action on a field is copied inert with FormFieldActionPreserved |
| 9 | Annotation creation, editing, removal and appearance streams | PDFsharp: broad annotation model. PdfPig: read-only annotations. | Must: link, text, free text, stamp, highlight, underline, strikeout, square, circle, line and ink; update/remove; appearance for every creatable kind. | 1.2 | US2 editing | PdfAnnotationTests.Adds_a_highlight_and_reads_it_back_with_an_appearance, Adds_links_to_a_url_and_to_a_page, Updates_one_annotation_and_removes_another, Keeps_the_annotation_a_page_already_carried, Carries_an_annotation_into_a_duplicate_of_its_page, Writes_an_appearance_for_every_kind_that_draws_something, Draws_the_text_of_a_free_text_annotation_into_its_appearance, Drops_a_link_whose_destination_the_edit_removes, Clips_an_annotation_to_the_page_and_reports_it, Refuses_an_update_of_an_annotation_the_page_does_not_carry, Refuses_a_link_with_both_a_page_and_a_url, Refuses_a_translucent_annotation_for_pdfa_1b (T043) |
python docs/tools/validate-pdf.py annotated.pdf from the annotate example (pikepdf 10.17.0 / qpdf: qpdf check: no problems, PDF 1.7, 2 pages, 20 objects, page 1 annots=6, 6 streams decoded, 0 failed) and a PyMuPDF 2.0 render of the same file, which reports six annotations and paints the highlight quad (255, 223, 129) for #FFE082, the note icon (255, 211, 0), the free text in #1F3A5F and the stamp frame (191, 43, 31) |
Met in 1.2 (2026-10-24): the ten kinds the row lists are written by PdfAnnotations, each with an appearance stream the engine draws unless the caller supplies one; AddAnnotation, UpdateAnnotation and RemoveAnnotation rebuild the page's /Annots without touching its content, a page the plan does not name keeps its objects, an annotation that states no identifier is named scpdf-N, and every refusal the contract states — unknown kind, link with both a page and a URL, missing identifier, empty geometry, opacity outside 0-1, translucency under PDF/A-1b — is made before a byte is written |
| 10 | Incremental updates that preserve existing signatures | PDFsharp: yes. PdfPig: no. | Must: append a revision, keep every prior byte, refuse a changed source, and keep the prior signature's byte range valid. | 1.2 | US2 editing | PdfIncrementalWriterTests.Appends_a_revision_after_the_bytes_of_the_source, Chains_the_revision_to_the_cross_reference_section_of_the_source, Chains_a_second_revision_to_the_first, Keeps_a_signature_that_covers_the_earlier_revision, Refuses_an_incremental_update_of_an_encrypted_document (T042, T050) |
uv run --with pikepdf python docs/tools/validate-pdf.py <output.pdf> and a byte-for-byte prefix comparison |
Met in 1.2 (2026-10-24): a 1220-byte source edited into a 2465-byte result whose first 1220 bytes are the source unchanged, with one /Prev, two %%EOF and two startxref in the file; validate-pdf.py on that result reports qpdf check: no problems, PDF 1.7, 2 pages, page 1 rotate=90 and 4 streams decoded, so an independent reader resolves the appended revision; the signature of a signed source still validates with CoveredRevision 2 of the 3 revisions the edit leaves, and the same plan applied as a rewrite reports PriorSignatureInvalidated |
| 11 | Digital signatures: sign, validate, timestamp, report integrity and trust separately | PDFsharp: yes. PdfPig: no. | Must: detached CMS over a reserved byte range, X.509, optional caller-supplied RFC 3161 token, integrity and trust as separate results. | 1.1 | US4 trust | PdfDocumentSignerTests.Writes_a_signature_that_verifies_against_its_own_bytes, Signing_a_second_time_keeps_the_first_signature_valid, Embeds_the_timestamp_token_the_caller_supplies (T080, T087), PdfDocumentSignatureValidatorTests.Reports_a_changed_byte_as_a_digest_mismatch, Keeps_integrity_independent_of_trust, Reports_a_request_for_an_online_check_without_making_one (T081) |
python docs/tools/validate-signatures.py --trust-signer <anchor.pem> <signed.pdf> (pyHanko, T093) and uv run --with pikepdf python docs/tools/validate-pdf.py <signed.pdf> |
Met (2026-10-23) on pyHanko and qpdf: a signed revision reports intact with coverage ENTIRE_FILE, a twice-signed one reports intact/ENTIRE_REVISION then intact/ENTIRE_FILE, both trusted against the signer's own certificate as anchor, and qpdf --check reports no problems on either file. A timestamp token is embedded, never checked: the authority is the caller's. The sign-and-archive example (T094) writes a PDF/A-3b file and signs it afterwards — the order that keeps the archival bytes intact — and pyHanko reads what it wrote as field ArchivalSignature, integrity intact (coverage ENTIRE_FILE), signed at the moment the options state (2026-10-24) |
| 12 | PDF/A-1b, PDF/A-2b and PDF/A-3b output | PDFsharp: PDF/A manager. PdfPig: no. | Must: embed every font, write XMP and an output intent, remove encryption and forbidden actions, refuse what cannot conform. | 1.1 | US4 trust | PdfATests.Writes_the_structures_the_profile_requires, Refuses_a_font_it_cannot_embed, Refuses_transparency_for_pdf_a_1b_but_writes_it_for_2b (T082) |
python docs/tools/validate-pdfa.py --verapdf <verapdf.bat> --profile 2b archive-2b.pdf, and the same for 1b/3b: veraPDF 1.30.3 reports PASS for all three profiles of an authored page and of SafeWebCore.pdf (T093, T096) |
Met — the output intent is sRGB and built in; a caller-supplied ICC profile, a merge-time profile and PDF/A-3 /AF associations are out of scope, and no conformance is claimed without a validator |
| 13 | Positioned letters, words and blocks; configurable segmentation and reading order | PDFsharp: not a reading API. PdfPig: yes. | Must: PdfLetter with Unicode, size, font name, box and baseline; word and block strategies; raw and column-aware reading order with the strategy in the result. |
1.1 | US3 reading | PdfLayoutReaderTests.Reads_one_letter_per_glyph_in_content_order, Splits_a_two_column_page_into_one_block_per_column, Reads_a_page_of_two_columns_left_to_right_whatever_order_the_content_used (T059–T060), PdfLayoutCorpusTests.Meets_SC_004_on_the_labelled_corpus, PdfLayoutCorpusTests.Table_page_is_still_read_column_by_column (T076) |
python docs/tools/score-layout.py <exports> --labels tests/SharpCorePDF.Tests/Assets/parity/labelled --known-order-mismatch table (T075) |
Met on the labelled corpus (T075–T076): recall and boxes 100% on all five documents, reading order 100% on four. table is recorded as a known order mismatch — a table is symmetric under the XY cut, so a cell becomes a block and the order comes out column by column, which is the order a two-column page needs; table semantics are out of contract. PdfLayoutCorpusTests.Table_page_is_still_read_column_by_column fails if the limitation ever goes away |
| 14 | Images, hyperlinks, form values, annotations, embedded files, metadata, bookmarks | PDFsharp: partial. PdfPig: yes. | Must: expose each resource through the read side, decode image pixels when asked, and warn on an unresolved destination or unsupported filter. | 1.1 partial, pixels in 1.2 | US3 reading | PdfLayoutReaderTests.Reads_the_links_of_a_page_and_their_uris, Resolves_a_direct_and_a_named_destination, Keeps_an_unresolved_destination_and_says_so, PdfContentReaderTests.Returns_every_annotation_with_its_geometry_and_text, Returns_the_field_tree_with_qualified_names, PdfContentReaderTests.Exposes_the_samples_of_a_decoded_image_as_pixels, Does_not_expose_a_jpeg_as_pixels, Does_not_expose_the_pixels_of_a_filter_it_cannot_decode, Exposes_a_palette_image_as_the_colours_of_its_table, Exposes_a_palette_whose_table_is_a_string, Exposes_bilevel_samples_as_one_byte_per_sample, Exposes_four_bit_samples_at_the_scale_of_their_depth, Exposes_two_bit_samples_at_the_scale_of_their_depth, Exposes_a_cmyk_image_as_four_device_samples_per_pixel, Exposes_the_samples_of_an_iccbased_image, Does_not_expose_the_samples_of_a_separation_space, Does_not_expose_a_palette_image_whose_index_is_outside_its_table, Leaves_an_expansion_above_the_budget_out_of_the_record, Exposes_the_samples_of_an_image_that_declares_no_filter, Keeps_an_expansion_that_fits_the_budget_exactly, Exposes_four_bit_samples_of_a_three_channel_image, Does_not_expose_an_image_that_states_no_extent, Exposes_the_samples_of_a_profile_by_the_components_it_states, Does_not_read_a_palette_whose_table_is_empty, Refuses_to_resolve_a_colour_space_without_a_document, Resolves_the_calibrated_forms_of_the_device_spaces, Does_not_build_a_palette_over_a_palette_or_over_a_function, Reads_a_palette_whose_declared_index_is_zero, Clamps_a_declared_index_to_the_entries_the_table_holds, Does_not_read_an_icc_space_that_states_no_profile, Exposes_the_pixels_of_an_inline_image_stated_by_an_abbreviation (T072) |
uv run --with pypdf python -c "from pypdf import PdfReader; ..." and validate-pdf.py on the corpus, plus the score-layout.py run of T079 (5 documents, 0 short of a threshold) and an external parse of all four export formats (T079) |
Met in 1.2 (2026-10-24): every resource the row names is exposed — images with their payload, geometry, mask and pixels, hyperlinks with resolved and unresolved destinations, form values through PdfFormFieldInfo, annotations, embedded files, metadata and bookmarks; pixels are Gray8, Rgb24 or Cmyk32 for every filter the library decodes and for an image whose stream declares no filter at all — a palette applied so an Indexed image yields colours, samples narrower than a byte widened, four device samples left as CMYK rather than converted — and null for a filter it does not decode, which is reported with ImageFilterUnsupported rather than dropped |
| 15 | Public read-only object graph and content-stream inspection | PDFsharp: partial. PdfPig: yes. | Must: immutable PdfObjectView/PdfContentOperationView, dangling references visible, operation offsets reported. |
1.1 tests, the rest 1.2 | US3 reading | PdfObjectInspectorTests.Returns_one_node_per_listed_object_in_number_order, Projects_a_reference_as_a_node_and_never_follows_it, Reports_a_dangling_reference_once_for_the_target_it_names, Reads_the_operations_of_a_page_in_the_order_it_states_them |
uv run --with pikepdf python docs/tools/validate-pdf.py <input.pdf> plus a mutation-attempt test |
Partial: twelve tests in PdfObjectInspectorTests are in the suite; the external run and the mutation attempt are T079's closure run (its T062 leg), in 1.2 |
| 16 | Export to ALTO, PAGE XML, hOCR and SVG text | PDFsharp: no. PdfPig: yes. | Must: four exporters in page space, escaped text, a valid empty-page element, and a result that names the strategy used. | 1.1 exporters, the rest 1.2 | US3 reading | PdfLayoutExportTests.Exports_alto_pagexml_hocr_and_svg, Writes_a_page_element_for_a_page_with_no_text, Escapes_text_that_looks_like_markup, Returns_the_blocks_it_wrote_with_the_strategy_that_produced_them (T063) |
schema validation for ALTO/PAGE XML (T079) | Partial: four exporters and their tests are in the suite; the schema run is T079 |
| 17 | Rendering pages to images or raster previews | PDFsharp: no. PdfPig: no. | Non-goal: no rasteriser, thumbnail generator, viewer or print pipeline. | 1.1 (excluded) | Product boundary (ADR-001) | None by design | Out-of-scope statement in README.md, docs/manual/src/02-what-it-is-not.md and ADR-001 |
Out of scope |
| 18 | OCR, HTML-to-PDF, PDF-to-Word/HTML, reflow, tagged-PDF/PDF-UA authoring | PDFsharp: no. PdfPig: no. | Non-goal: no OCR, document understanding, table semantics, Office conversion, reflow or accessibility conformance claim. | 1.1 (excluded) | Product boundary (ADR-001) | None by design | Out-of-scope statement in README.md and ADR-001 |
Out of scope |
| 19 | MigraDoc-style flow engine with automatic page breaks | PDFsharp: separate MigraDoc product. PdfPig: no. | Non-goal: no flow layout engine; the caller places content on pages. | 1.1 (excluded) | Product boundary (ADR-001) | None by design | Out-of-scope statement in docs/manual/src/02-what-it-is-not.md and ADR-001 |
Out of scope |
| 20 | JavaScript execution, 3D, multimedia, geospatial, public-key encryption | PDFsharp: no or partial. PdfPig: no. | Non-goal: actions may be copied or refused with a warning; nothing is executed, and no new encryption surface is added. | 1.1 (excluded) | Product boundary (ADR-001) | None by design | Out-of-scope statement in README.md and ADR-001 |
Out of scope |
| 21 | Drop-in source or binary compatibility with PDFsharp or PdfPig | N/A | Non-goal: capability parity only; no reference package is referenced by the suite. | 1.1 (excluded) | Product boundary (ADR-001) | None by design | ADR-001 and the dependency check in quickstart step 8 | Out of scope |
1.1.0 ships what the tree implements and evidences, and this table names 1.2 in the Release cell of
every row it does not finish. The decision, its reasoning and the task list behind it are in
tasks.md Gate 0; the specification carries the matching
amendment. It changes four things here and nothing else:
- Release cells name the release that ships the row.
1.1means the row is complete in1.1.0;1.1 paths, the rest 1.2means the part in the name is evidenced and the remainder is a1.2commitment. A task named in the Status cell of such a row is a1.2task. - A
Deferredrow is stillMust. The column that moves is the release, not the requirement, and the row keeps its owner, its named test and its command. Nothing is deleted and nothing is re-labelled “out of scope”. - No row is left
Plannedin a tagged release.1.1.0tags only rows whose Release cell names1.1; the rest carry1.2in the same commit, which is what T105 checks. - SC-001 reads “100% of the rows that name 1.1”. The full matrix is still the 1.2 exit condition, and
that sentence lives in
spec.md, not here, so the promise is amended where it was made.
T105's audit of the table (2026-10-23) — the check D3 item 3 asks for, run over all 21 rows: eleven name
1.1 (rows 1, 2, 4, 5, 7, 11, 12, 13, 14, 15, 16), five are non-goals excluded from the release (17–21), and
five name 1.2 only (3, 6, 8, 9, 10). No row claims Evidenced or Met without both a named test and an
external command — the only rows that claim it (1, 11, 12, 13) name tests that are in the suite and a command
that was run, and their Status cell says which run. Rows 3, 6 and 10 still read Planned after the scope
decision, which is the one label D3 item 3 rules out, so they now say Deferred and keep their 1.2 task; rows
8 and 9 stay Partial because the reading half is evidenced and the writing half is the 1.2 task. Nothing in
the table was moved from 1.2 to 1.1, and no row was relabelled out of scope.
Row 3 since the audit (2026-10-23). The text-layout work of T028, T029 and T023 landed under the 1.2
release cell the row already carried, so its Status cell now reads Evidenced with the twenty-two tests of
PdfDocumentBuilderTests.TextLayout.cs, the fontTools and PyMuPDF readings recorded in
measurements.md §11, and the validate-pdf.py run on the same page. The release cell is
unchanged: the row ships in 1.2, and the work being evidenced early is what lets T038 close it without
re-opening the design.
Row 2 since the audit (2026-10-23). T022 added the nine operator tests the row was missing — dash and
phase, cap, join, miter limit, the three device colour spaces, and SetTransform for a translation, a scale
and a rotation — and the draw-shapes example produced a page to run them against, so the Status cell now
names fourteen tests and a render rather than five tests and a promise. It stays Partial on purpose: the
Release cell calls a remainder 1.2, and that remainder is the colour-space selection operators
(CS/cs/SC/sc/SCN/scn). They are written by the content builder — the annotation and form writers of
1.2 select a space before they paint — but no canvas call reaches them today, so there is nothing for a
caller to test. Marking the row Evidenced would have claimed an operator set nothing in the public surface
exercises.
What 1.1.0 delivers, row by row: 1 (page, text, fonts), the path writer of 2, the PNG half of
4, every page operation of 5 except its external run, the information dictionary of 7, 11
(signatures), 12 (PDF/A-1b/2b/3b), 13 (layout) and the parts of 14–16 the suite already
covers. Deferred to 1.2.0: 3 entirely — now built and evidenced, see above — and the remainder of 2,
4, 5, 6, 7, 8, 9, 10, 14, 15 and 16 — 36 tasks when the decision was
taken, recorded with their numbers in tasks.md.
A row moves from Planned to Evidenced only when all of the following exist on the release commit:
- the named test passes on both
net10.0andnet11.0; - the external command has been run against an output produced by the example or fixture in the row;
- the result, the validator version and the performance row are recorded in
docs/measurements.md; - the test that would fail on revert is named in the pull request.
T038 closed the creator rows (2–4) on 2026-10-23 — 4 is Evidenced, 2 and 7 keep a stated remainder —
T058 the editing rows, T096 the trust rows
(11–12) and T079 the reading rows (13–16). T101 re-reads every recorded budget; a regression above 10%
blocks the release
and requires a decision recorded in docs/measurements.md.
dotnet test ... --filter "FullyQualifiedName~ParityCorpusTests"— 8 passed on both target frameworks, including every manifest entry's SHA-256, 76 PDFs opening with the reader, 60 born-digital documents, five labelled documents and the image refusal variants.- The full suite is 569 passed on
net10.0andnet11.0, and the Release solution builds with 0 warnings and 0 errors — the 1.0 baseline of 561 plus the eight corpus tests, no regression. python docs/tools/make-fixtures.py --parity <temp-dir>— 95 fixtures plus the manifest, all 96 files byte-for-byte identical to the committed tree (zero differences), so regeneration produces no unexpected change.python docs/tools/validate-pdf.pyon all 76 PDFs —qpdf check: no problems, every content stream and image decoded, zero failures.- Creator: 10 pages / 200 lines / RGBA logo — 3,484 KB, 14.69 ms, 74,094 B (workstation GC, median
of five). Editor: 100 pages / 200 operations — 1,733 KB, 3.64 ms, 80,004 B. Both are baselines,
not release claims; see
docs/measurements.md§9.
No “Must” row is unowned. The rows still marked Planned are the work of Phases 1–5; none is claimed as delivered by this document.
- The question's own scenario is an example, not a fixture:
dotnet run -c Release --project .\examples\SharpCorePDF.Examples -- author-pagewritescover.pdf(1 page, 8,835 bytes) andcover-and-summary.pdf(3 pages) under%TEMP%\SharpCorePDF.Examples\output, and prints page 1's text back through the library. The run reportsok (222 ms)for the whole scenario, merge included, against SC-001's 400 ms budget. python docs/tools/validate-pdf.pyon both files —qpdf check: no problems: 1 page / 14 objects and 3 pages / 19 objects, 6 and 8 streams decoded, 0 failed. pikepdf 10.17.0 carries qpdf, so this is FR-012'sqpdf --checkleg and the “no syntax problems” clause of SC-004.pypdf6.19.0 withstrict=True: 3 pages, page 1's MediaBox595.276 × 841.89, and the extracted text is exactlyDécomptes SPRL…Référence : é è à ç ô ï ' / |./Resources/XObjectholds/Im1,/Subtype /Image, 152 × 87, with an/SMask. That is AC2, AC3 and FR-009.- PyMuPDF (MuPDF 1.28.5) at 72 dpi:
(100, 45)=(31, 58, 95)inside the logo and(50, 45)= white in its transparent margin, which is SC-004's render clause. dotnet test -c Release --filter "FullyQualifiedName~PdfDocumentBuilderTests"— 33 passed onnet10.0and onnet11.0; the full suite is 738 passed on both legs, with 0 warnings and 0 errors. Determinism is one of the 33 (Writes_the_same_bytes_for_the_same_input), which is AC4.- Not claimed: rows 2 and 4 stay Partial because dash/cap/join/transform and JPEG/BMP/GIF are T022,
T024, T030 and T031; row 3 stays Planned because metrics,
MeasureStringand wrapping are T028 and T029. None of those is a condition of002-page-authoring; Gate 0 inspecs/003-pdf-platform-parity/tasks.mdrecords the decision they hang from (D1/D2/D3). - Limits recorded with the code: text is drawn one line per call — a line break is refused by name — and
the layout types (
PdfTextLayout,PdfTextLine,PdfTextRun) carry lines and runs, but nothing in the release computes a line break from a paragraph. - Both statements describe the
1.1.0tree, and this section stays as it was concluded on. The1.2work noted above changed them:PdfTextLayouter.Layoutcomputes the line breaks,PdfTypeface.Measuremeasures before a page exists, and row 3 is nowEvidenced.
- The five open US1 tasks are done. T032 decided the ICC policy and implemented it (the only part of the
Phase 1 matrix that was still open), T034 added
WithXmp,WithOutline,ImportPageandImportPagestoPdfDocumentBuilder(WithMetadatawas already there), T036 added thecreator-parityscenario, T037 wrote manual chapter 17 and pointed chapters 12 and 16 at it, and T038 ran the external validation and closed the rows it owns. - The decisions, in one place. An
iCCPchunk is declared, not dropped: the profile becomes its own Flate stream and the image names it as[/ICCBased n 0 R], with/Nfrom the profile's own colour-space signature and/Alternatethe device space a reader falls back to; a profile that does not describe the samples is refused by name. Importing a page reuses the merge engine'sPdfObjectCopier, so an imported page keeps what a merged page keeps, and its inherited attributes are materialised on the copy because an authored document has one flat page tree. An XMP packet is written byte for byte and uncompressed, as PDF/A-1 requires and as a validator expects. Outline page numbers are checked when the document is written, because pages can still be added after the tree is set; a bookmark that dangles is refused, not written. - Tests. Eighteen authoring tests are new — five for the profile, three for the packet, four for the outline
and six for import — and the suite reports 1664 passed, 0 failed (832 on
net10.0and 832 onnet11.0), withdotnet build -c Releaseat 0 warnings and 0 errors. The new tests read every result back through the content reader, so they assert what a consumer sees and not only the bytes the writer produced. - External evidence (
measurements.md§13).validate-pdf.py(pikepdf 10.17.0 / qpdf) reportsqpdf check: no problemsforcreator-parity.pdf: PDF 1.7, 3 pages, 27 objects, a catalog carrying/Metadataand/Outlines, five bookmarks, page boxes of A4, A5 and the imported 595 × 842, 10 streams decoded and 0 failed. PyMuPDF reads the table of contents with its nesting and the URL entry, the 605-byte packet and both images —DeviceRGB/DCTDecodefor the JPEG,ICCBasedfor the PNG. pikepdf resolves the profile stream and compares it with the Windows sRGB file it was made from: 3144 bytes, identical. pypdf extractsDécomptes SPRLwith its accent from page 1 and the imported page's own text from page 3. - Row 4 is Evidenced and row 2 stays Partial for the one thing the Release cell names and no public
canvas call reaches: the colour-space selection operators
CS/cs/SC/sc/SCN/scn, which the content builder can write and the canvas does not select. Row 7 stays Partial: authoring a packet and an outline is evidenced, editing an existing document's is T041. - The layout allocation row is in the measure tool (
--creator): 60 sentences wrapped at 400 pt cost 49 KB and 1.13 ms for 60 lines, against 3,448 KB and 14.15 ms for the ten-page creator document with its RGBA logo. - Not claimed: no EXIF rotation, no colour conversion, no sixteen bit or Adam7 PNG, no SVG, TIFF, WebP or JPEG 2000, and no animated GIF — each refusal has a test and a row in chapter 17. The manual is rebuilt at 23 chapters and 103 pages, with chapters 18 and 19 still reserved for annotations and form fields.
- The reading surface is in the tree behind
IPdfLayoutReader—ReadLettersAsync,ReadWordsAsync,ReadHyperlinksAsync,ReadBlocksAsyncandExportAsync— reached throughPdfLayoutReaderServiceandAddSharpCorePDF(), and constructible by hand. dotnet test -c Debug— 1396 passed, 0 failed (698 onnet10.0and 698 onnet11.0), with 0 warnings and 0 errors.- SC-004 is scored, not asserted.
docs/tools/score-layout.pyreads a folder of ALTO exports and the label files beside the labelled PDFs, and prints one line per threshold; on 2026-10-22 it measured recall 100% and box agreement 100% on all five documents, and reading order 100% on four of them. tableis the fifth, and it is a recorded limitation rather than a pass: a table is symmetric under the XY cut — the gap between its columns is as wide as the gap between its rows — so a cell becomes a block and the order comes out column by column (66.7% order agreement), which is the order a two-column page needs. Telling a table from two columns whose lines align is a semantic judgement, and table semantics are out of contract.PdfLayoutCorpusTests.Table_page_is_still_read_column_by_columnfails if the limitation ever goes away, so the exception cannot outlive the behaviour it records.- Row 13 is Met on that evidence; rows 14 and 16 stay Partial and row 15 stays Planned: the ALTO/PAGE XML schema run is T079, the hyperlink and pixel-decode gaps are row 14's own, and the read-only object graph (T062, T070–T071) is not in the tree yet.
- Limits recorded with the code: an export describes text only —
PdfLayoutOptions.IncludeImagesis reserved and has no effect in this release — and every exporter writes the page's own coordinates with the origin moved to the top left, because none of the four formats has a bottom-left one.
-
The signing surface is in the tree behind
IPdfDocumentSignerandIPdfDocumentSignatureValidator, reached throughPdfDocumentSigner,PdfDocumentSignatureValidatorandAddSharpCorePDF(), and constructible by hand. Signing appends one revision; validation reads every signature field the document holds and costs one pass over the bytes each one covers. -
dotnet test SharpCorePDF.slnx— 1452 passed, 0 failed (726 onnet10.0and 726 onnet11.0), anddotnet build -c Release— 0 warnings, 0 errors. -
The evidence is external, because a signature that only its own writer believes is not a signature. A document signed by the engine and the same document signed twice were checked by two independent implementations:
uv run --with pyhanko python docs/tools/validate-signatures.py ` --trust-signer <the signer's certificate, PEM> --require-trusted signed.pdf signed-twice.pdf uv run --with pikepdf python docs/tools/validate-pdf.py signed.pdf signed-twice.pdf
pyHanko reports
integrity: intactandtrust: trustedfor both signatures, with coverageENTIRE_FILEfor the single signature, andENTIRE_REVISIONthenENTIRE_FILEfor the pair — the first signature covers the revision it signed and stops there, which is what an appended revision must look like. qpdf reportsno problemson both files.--require-trustedis what makes the run fail on an untrusted chain, so the exit code is evidence and not decoration. -
The external run found two defects the unit tests had not, and both are fixed in this tree: the incremental cross-reference table wrote entries 19 bytes long instead of the 20 the specification fixes, and the
signatureTimeStampTokenattribute was written without the[1] IMPLICITwrapper itsunsignedAttrsfield carries.PdfDocumentSignerTests.Writes_cross_reference_entries_as_twenty_byte_recordsandEmbeds_the_timestamp_token_the_caller_supplieshold them shut. -
Row 11 is Met on that evidence with one limit stated in the row: a timestamp token is embedded for the caller and never validated here — checking a TSA's own signature over the token needs trust anchors the caller has not supplied in this release.
- The archival surface is in the tree behind
IPdfAWriter, reached throughPdfAWriterService,PdfAWriterandAddSharpCorePDF(), and constructible by hand.PdfEditOutputOptions.PdfAProfileroutes an ordinary edit through the same writer, so an archive does not need a second document pipeline. dotnet test SharpCorePDF.slnx— 1476 passed, 0 failed (738 onnet10.0and 738 onnet11.0), anddotnet build -c Release— 0 warnings, 0 errors.- The writer produces the structures each profile names — the header version, the XMP packet with
pdfaid:partandpdfaid:conformance, an/N 3sRGB output intent built by the library, the document information mirrored into the packet,/NeedAppearances false, no encryption — and refuses what it cannot write correctly instead of labelling it. Every font must be embedded; a standard font, a transparency for 1b, aJPXDecode/LZWDecodestream for 1b, an attachment for 1b/2b, optional content for 1b, an annotation without an appearance, a forbidden action and an external file stream are refusals that leave the destination empty. - The evidence is external, because a profile is a claim a validator makes. veraPDF 1.30.3 was run over
the documents the
archive-pdfaexample writes from one authored page, and over the same three profiles of the six-pageSafeWebCore.pdffixture written by "Microsoft: Print To PDF":All six files reportpython docs/tools/validate-pdfa.py --verapdf <verapdf.bat> --profile 2b archive-2b.pdf python docs/tools/validate-pdfa.py --verapdf <verapdf.bat> archive-1b.pdf archive-3b.pdf
PASS. The run found one defect the unit tests had not: a CID font subset without a/CIDSetstream fails ISO 19005-1 clause 6.3.5-3, and the authoring writer did not emit one. It does now, listing every CID the preserved numbering makes present, which also satisfies the PDF/A-2 rule that a set, when present, must name every CID of the program. A source whose subset states no set cannot be given an accurate one, so that document is refused for 1b with the font named. - A
Textannotation without an appearance stream is refused for all three profiles rather than written: the appearance is content, and generating one would invent it. TheOutlined.pdffixture is that case. - Row 12 is Met on that evidence with the limits stated in the row: a caller-supplied ICC profile, a
PDF/A-3
/AFassociation and PDF/UA are out of scope, and the library never claims conformance itself.