diff --git a/.gitattributes b/.gitattributes index 55ee688..48fcc2f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,5 @@ # Retain the pinned upstream metrics and license bytes exactly. tools/data/base14/* -text -whitespace src/base14_metrics_data.rs text eol=lf +# Preserve PDF stream bytes and xref offsets; never normalize them as text. +*.pdf binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb157d4..e83c2da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,7 @@ name: CI env: - FULLBLEED_CI_VERSION: "2.5.5" + FULLBLEED_CI_VERSION: "2.5.6" RUST_MIN_STACK: "4194304" on: @@ -144,6 +144,19 @@ jobs: - name: Verify finalized PDF gradient previews run: python -I tools/smoke_gradient_preview.py --out target/gradient-preview + - name: Verify single-copy styled PDF text with an independent reader + run: | + python -m pip install pypdf==6.19.0 + python -I tools/smoke_text_extraction.py --out target/text-extraction + + - name: Retain styled PDF text and preview evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: text-extraction-${{ runner.os }} + path: target/text-extraction + if-no-files-found: warn + - name: Retain gradient PDFs and preview evidence if: always() uses: actions/upload-artifact@v7 diff --git a/.github/workflows/publish-crates.yml b/.github/workflows/publish-crates.yml index d7d56bd..0177932 100644 --- a/.github/workflows/publish-crates.yml +++ b/.github/workflows/publish-crates.yml @@ -10,7 +10,7 @@ on: description: "Annotated release tag whose exact source commit will be published" required: true type: string - default: "v2.5.5" + default: "v2.5.6" publish: description: "Publish after all validation gates pass" required: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f909e57..1c2796c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -109,6 +109,19 @@ jobs: - name: Verify finalized PDF gradient previews run: python -I tools/smoke_gradient_preview.py --out target/gradient-preview + - name: Verify single-copy styled PDF text with an independent reader + run: | + python -m pip install pypdf==6.19.0 + python -I tools/smoke_text_extraction.py --out target/text-extraction + + - name: Retain styled PDF text and preview evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: release-text-extraction + path: target/text-extraction + if-no-files-found: warn + - name: Retain gradient PDFs and preview evidence if: always() uses: actions/upload-artifact@v7 diff --git a/Cargo.lock b/Cargo.lock index a540292..e7b4d20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "fullbleed" -version = "2.5.5" +version = "2.5.6" dependencies = [ "fullbleed_audit_contract", "subsetter", diff --git a/Cargo.toml b/Cargo.toml index fc1eacf..bb6c372 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fullbleed" -version = "2.5.5" +version = "2.5.6" edition = "2024" rust-version = "1.85" license = "MIT" diff --git a/README.md b/README.md index f9f0f7a..9e7549a 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Path("invoice.pdf").write_bytes(pdf) Open `invoice.pdf`. The [quickstart](https://docs.fullbleed.dev/getting-started/quickstart/) adds fonts, previews, and a complete project. The runnable source is in [`examples/quickstart/hello.py`](examples/quickstart/hello.py). -**Using Rust?** Start with the [native Rust quickstart](https://docs.fullbleed.dev/getting-started/rust/). The [`fullbleed` crate](https://crates.io/crates/fullbleed) exposes `FullBleed::builder()` and `render_to_buffer(...)`, with no Python requirement. The guide includes runnable invoice and report examples with explicit fonts and PNG previews, plus the [Rust API reference](https://docs.rs/fullbleed/2.5.5/fullbleed/). +**Using Rust?** Start with the [native Rust quickstart](https://docs.fullbleed.dev/getting-started/rust/). The [`fullbleed` crate](https://crates.io/crates/fullbleed) exposes `FullBleed::builder()` and `render_to_buffer(...)`, with no Python requirement. The guide includes runnable invoice and report examples with explicit fonts and PNG previews, plus the [Rust API reference](https://docs.rs/fullbleed/2.5.6/fullbleed/). **Using Node.js?** The [Node.js quickstart](https://docs.fullbleed.dev/getting-started/node/) includes a downloadable designed invoice project. The separate [Node package](https://github.com/fullbleed-engine/fullbleed-node) bundles the engine as WebAssembly and includes fonts, ESM/CommonJS support, TypeScript declarations, and PNG previews. It requires Node 22+ and covers ordinary PDF generation. @@ -230,7 +230,7 @@ python -m pip install fullbleed From a local wheel: ```bash -python -m pip install C:\path\to\fullbleed-2.5.5-cp310-abi3-win_amd64.whl +python -m pip install C:\path\to\fullbleed-2.5.6-cp310-abi3-win_amd64.whl ``` From a source checkout with Rust installed, no Python build package is needed: diff --git a/ReleaseNotes.MD b/ReleaseNotes.MD index 19eb96e..c9fee47 100644 --- a/ReleaseNotes.MD +++ b/ReleaseNotes.MD @@ -1,3 +1,30 @@ +# Fullbleed Release Notes - 2.5.6 + +Release date: October 3, 2026 + +## Keep synthesized bold text searchable once + +PDFs using upright synthesized bold previously contained both visible Type 3 +glyph text and an invisible logical text copy. Independent readers could extract +headings twice or interleave their letters. Visible glyphs now use shared vector +paint forms, leaving one logical text copy for search and extraction. + +The finalized preview reader preserves the affine transform of those path forms +so outlines and strokes scale together. Selecting an unused fallback font after +a transformed element no longer causes an embedded-font profile to reject the +document. Fonts used by actual text still require embedding for those profiles. + +Installed-wheel checks cover 36 styled fixtures across ordinary, tagged, and +PDF/UA-1-profile output, with retained PDF/PNG evidence and an independent text +reader in CI. Local comparisons use four text readers. These fixture checks do +not establish general renderer parity, screen-reader acceptance, or standards +conformance. The core gains no runtime dependency. + +Affected PDF and finalized-preview hashes change. Review saved baselines before +updating them. See the [2.5.6 release runbook](docs/release/2.5.6-runbook.md). + +--- + # Fullbleed Release Notes - 2.5.5 Release date: October 2, 2026 diff --git a/cli_schema.md b/cli_schema.md index 2f17b9a..d0ce79f 100644 --- a/cli_schema.md +++ b/cli_schema.md @@ -2,7 +2,7 @@ # CLI and Agent JSON Contract -Generated from the installed Fullbleed **2.5.5** runtime. The canonical machine artifact is `fullbleed-agent-contract.json`. +Generated from the installed Fullbleed **2.5.6** runtime. The canonical machine artifact is `fullbleed-agent-contract.json`. ## Authority and invocation diff --git a/docs/release/2.5.6-runbook.md b/docs/release/2.5.6-runbook.md new file mode 100644 index 0000000..de39feb --- /dev/null +++ b/docs/release/2.5.6-runbook.md @@ -0,0 +1,56 @@ +# Fullbleed 2.5.6 release runbook + +This patch removes duplicate searchable text from upright synthesized bold and +keeps its glyph paint reusable. Publish from a clean annotated `v2.5.6` tag after +the checks below pass. + +## Build and verify + +Keep Cargo, Python, CI smoke metadata, release defaults, and generated agent +artifacts synchronized at 2.5.6. The optional MCP adapter remains 0.1.1 and the +audit crate remains 0.1.3. Historical example pins retain their verified versions. + +Build and install a wheel in an isolated environment. Generate agent artifacts +using `tools/generate_agent_contract.py --python PATH --json`, rebuild and install +the final wheel, then run the generator with `--check`. Build the sdist from that +same source. Do not hand-edit generated agent artifacts. + +- Run the synthetic-bold PDF writer and finalized-preview Rust tests, then the + full Rust suite with default and Python/SVG features. +- Run focused installed-native Python extraction, preview, compiled-table, and + profile tests; then run the full Python suite with + `FULLBLEED_TEST_INSTALLED_NATIVE=1`. + Include repeat rendering of fixed, reflow, and compact bindings so glyph + resources survive cached, multiple-record output. +- Install the test-only reader `pypdf==6.19.0` and run + `python -I tools/smoke_text_extraction.py --out target/text-extraction`. + Retain all 36 PDFs, source HTML/CSS, direct/finalized PNGs, text, coverage + measurements, and hashes. The public 2.5.5 quickstart must fail the single-copy + check. A candidate that painted oversized strokes was rejected by the visual + coverage control; do not weaken that check to accept boxes or blank glyphs. +- Extract the same PDFs with PyMuPDF, pdfminer.six, and PDFium. Compare normalized + text with the authored expected values; retain reader versions and results. +- Compare actual PDFs with public 2.5.5 fixtures using an independent renderer. + Compare only identical HTML/CSS inputs. Inspect native previews, including + Unicode, italic, opacity, shadows, rotated, scaled, and sheared bold. +- Verify that previews of retained earlier-version PDFs remain unchanged. +- Review golden changes. The Acme invoice loses its duplicate Type 3 text and + needs a new PDF hash; its direct PNG baseline stays unchanged. Compare the + before/after PDFs with an independent renderer before accepting the hash. +- Run installed-package smoke, agent-contract checks, metadata/license checks, + deterministic goldens, CSS status, and the bundled Skill publishing dry run. +- Require Linux and Windows CI, the full platform-wheel/source-distribution + release workflow, and the existing print-profile checks. + +## Publish and verify + +Use the configured PyPI trusted publisher and crates.io workflow. Retain the +source tag, successful CI, public registry metadata, and artifact hashes. Install +the public wheel in a fresh environment and repeat the styled extraction check. +Update the optional Node package to the published engine, verify the packed npm +tarball, and repeat the original Node invoice reproduction before promoting it. + +These checks establish behavior for the retained fixtures. They do not establish +general PDF renderer parity, screen-reader acceptance, or profile conformance. +The dedicated PDF/VT validator remains unconfigured; its skipped check does not +establish independent conformance. PDF/PNG hashes can change for affected output. diff --git a/docs/release/2.5.6-validation.json b/docs/release/2.5.6-validation.json new file mode 100644 index 0000000..4c1fac3 --- /dev/null +++ b/docs/release/2.5.6-validation.json @@ -0,0 +1,657 @@ +{ + "schema": "fullbleed.release_validation.v1", + "release": "2.5.6", + "status": "local candidate; release CI and public distribution checks are separate gates", + "checked_at": "2026-10-03T16:04:35.189892+00:00", + "source_sha256": { + "src/pdf.rs": "519e68b11f5d6f3e7531617ac98626ec31eacaf6b999a50519aed423cd99a089", + "src/pdf_raster.rs": "bea771a2f47c8df3578b011d4c21174230f35e6f4479032a73a3aa5e7cd8b995", + "src/canvas.rs": "e3eb446fd5f0f289612ef5665b9fdc41d2fe259d0f084bc6e8822562f8d6560e", + "src/flowable.rs": "1436393e32717f37f3da4bde0ac57465eb6c5c49aaa445b516f9219082dd751b", + "tools/smoke_text_extraction.py": "cd5ad976ea7c9f56f3e4cf562394283e0b5af4e9211e5df03d6c2483c3fbc285", + "tests/test_fullbleed_text_extraction.py": "ce70abf4fcb66ed1038fde200315261cbadbcd0a4e36e9b5def3caf16ac47389" + }, + "local_wheel_sha256": "935da66d35f6d229a296708ab497a07175c9d1798c8513029f49ad237fac18fa", + "tests": { + "rust_default": 1306, + "rust_python_svg": 1307, + "python": 497, + "styled_documents": 36, + "golden_documents": 3 + }, + "readers": { + "pypdf": "6.1.1", + "PyMuPDF": "1.26.5", + "pdfminer.six": "20251230", + "pypdfium2": "5.4.0", + "fullbleed": "2.5.6" + }, + "negative_control": { + "version": "2.5.5", + "case": "quickstart", + "expected": "Invoice INV-1042 Consulting: USD 1,200.00", + "extracted": "Invoice INV-1042Invoice INV-1042 Consulting: USD 1,200.00", + "pdf_sha256": "84c66b14e4b7a54bd524f87ba4fab6cfee52cc4d902a2a9abc703685a5f5a18c" + }, + "cases": [ + { + "profile": "none", + "name": "quickstart", + "pdf_sha256": "2ae9b416e62bd34871a89eb73ec90f50c69dcb4cdc53c81e4e5c20d21dd0dc03", + "extracted": { + "pymupdf": "Invoice INV-1042 Consulting: USD 1,200.00", + "pdfminer": "Invoice INV-1042 Consulting: USD 1,200.00", + "pdfium": "Invoice INV-1042 Consulting: USD 1,200.00" + }, + "expected": "Invoice INV-1042 Consulting: USD 1,200.00", + "text_ok": true, + "external_visual_delta": { + "changed_channels": 6, + "mean_absolute": 1.595497506037961e-05, + "max_channel": 36, + "relative_ink_delta": 2.371487604530727e-05 + }, + "legacy_native_preview_unchanged": true + }, + { + "profile": "none", + "name": "repeated", + "pdf_sha256": "c1113351fd49ca138af8c34da18493b449f921d1966bc541fcb756840e038b18", + "extracted": { + "pymupdf": "ABBA ABBA 2026", + "pdfminer": "ABBA ABBA 2026", + "pdfium": "ABBA ABBA 2026" + }, + "expected": "ABBA ABBA 2026", + "text_ok": true, + "external_visual_delta": { + "changed_channels": 6, + "mean_absolute": 4.487336735731765e-06, + "max_channel": 7, + "relative_ink_delta": 2.259291335617728e-05 + }, + "legacy_native_preview_unchanged": true + }, + { + "profile": "none", + "name": "small-heading", + "pdf_sha256": "085049979566b2adc1cf9925d3a94dfbdcf521d3855ffa7f6762d0bc78bb7f45", + "extracted": { + "pymupdf": "Small heading", + "pdfminer": "Small heading", + "pdfium": "Small heading" + }, + "expected": "Small heading", + "text_ok": true, + "external_visual_delta": { + "changed_channels": 0, + "mean_absolute": 0.0, + "max_channel": 0, + "relative_ink_delta": 0.0 + }, + "legacy_native_preview_unchanged": true + }, + { + "profile": "none", + "name": "large-bold", + "pdf_sha256": "8b3a2515b76d799ada358717d83de31e5280ccf28bff11fff7a41861e79346fe", + "extracted": { + "pymupdf": "Large bold 48", + "pdfminer": "Large bold 48", + "pdfium": "Large bold 48" + }, + "expected": "Large bold 48", + "text_ok": true, + "external_visual_delta": { + "changed_channels": 0, + "mean_absolute": 0.0, + "max_channel": 0, + "relative_ink_delta": 0.0 + }, + "legacy_native_preview_unchanged": true + }, + { + "profile": "none", + "name": "bold-italic", + "pdf_sha256": "8f56f6b79bad7309b84d7b143c1939a6577cfcf6112480c6ff502c7460f0fbf1", + "extracted": { + "pymupdf": "Bold italic office", + "pdfminer": "Bold italic office", + "pdfium": "Bold italic office" + }, + "expected": "Bold italic office", + "text_ok": true, + "external_visual_delta": { + "changed_channels": 0, + "mean_absolute": 0.0, + "max_channel": 0, + "relative_ink_delta": 0.0 + }, + "legacy_native_preview_unchanged": true + }, + { + "profile": "none", + "name": "unicode", + "pdf_sha256": "7cea574f1cb9ebc19144b5c205aa8e89dc7122e78d963b416fe783d57d406b98", + "extracted": { + "pymupdf": "R\u00e9sum\u00e9 caf\u00e9 office", + "pdfminer": "R\u00e9sum\u00e9 caf\u00e9 office", + "pdfium": "R\u00e9sum\u00e9 caf\u00e9 office" + }, + "expected": "R\u00e9sum\u00e9 caf\u00e9 office", + "text_ok": true, + "external_visual_delta": { + "changed_channels": 15, + "mean_absolute": 5.48452267700549e-06, + "max_channel": 5, + "relative_ink_delta": 1.2641234188976339e-05 + }, + "legacy_native_preview_unchanged": true + }, + { + "profile": "none", + "name": "mixed", + "pdf_sha256": "f51618b10af043f62acd889e5a5bb827bb93dfddf1186699f270439bc80f18b6", + "extracted": { + "pymupdf": "Plain bold plain italic.", + "pdfminer": "Plain bold plain italic.", + "pdfium": "Plain bold plain italic." + }, + "expected": "Plain bold plain italic.", + "text_ok": true, + "external_visual_delta": { + "changed_channels": 0, + "mean_absolute": 0.0, + "max_channel": 0, + "relative_ink_delta": 0.0 + }, + "legacy_native_preview_unchanged": true + }, + { + "profile": "none", + "name": "opacity", + "pdf_sha256": "211fd713d83f4af31a31533e5ad32537463e65a736036bd920fb3fbffd04cf3f", + "extracted": { + "pymupdf": "Translucent heading", + "pdfminer": "Translucent heading", + "pdfium": "Translucent heading" + }, + "expected": "Translucent heading", + "text_ok": true, + "external_visual_delta": { + "changed_channels": 0, + "mean_absolute": 0.0, + "max_channel": 0, + "relative_ink_delta": 0.0 + }, + "legacy_native_preview_unchanged": true + }, + { + "profile": "none", + "name": "transform", + "pdf_sha256": "bcf657e5ca08bc764dad0cc4fc588d554a6ecdbede21b8121edb7d369f242504", + "extracted": { + "pymupdf": "Rotated heading", + "pdfminer": "Rotated heading", + "pdfium": "Rotated heading" + }, + "expected": "Rotated heading", + "text_ok": true + }, + { + "profile": "none", + "name": "scaled", + "pdf_sha256": "2244841c52af34d013f33101524789d7b42e400aee712ab89c769b6c1fc421cc", + "extracted": { + "pymupdf": "Scaled heading", + "pdfminer": "Scaled heading", + "pdfium": "Scaled heading" + }, + "expected": "Scaled heading", + "text_ok": true + }, + { + "profile": "none", + "name": "sheared", + "pdf_sha256": "757e48c2621fa88f7d9588b796fb8b93d172f9152d4768110ef4f89310e571e9", + "extracted": { + "pymupdf": "Sheared heading", + "pdfminer": "Sheared heading", + "pdfium": "Sheared heading" + }, + "expected": "Sheared heading", + "text_ok": true + }, + { + "profile": "none", + "name": "shadow", + "pdf_sha256": "b43e7df0d79962ac0765a731cd8699e5aea01175b3489760018d94e11ef30031", + "extracted": { + "pymupdf": "Shadow heading", + "pdfminer": "Shadow heading", + "pdfium": "Shadow heading" + }, + "expected": "Shadow heading", + "text_ok": true, + "external_visual_delta": { + "changed_channels": 0, + "mean_absolute": 0.0, + "max_channel": 0, + "relative_ink_delta": 0.0 + }, + "legacy_native_preview_unchanged": true + }, + { + "profile": "tagged", + "name": "quickstart", + "pdf_sha256": "c9199af586d22d25c74496298dfaed4f82cef4bf0cebd0a70bddd7322799648a", + "extracted": { + "pymupdf": "Invoice INV-1042 Consulting: USD 1,200.00", + "pdfminer": "Invoice INV-1042 Consulting: USD 1,200.00", + "pdfium": "Invoice INV-1042 Consulting: USD 1,200.00" + }, + "expected": "Invoice INV-1042 Consulting: USD 1,200.00", + "text_ok": true, + "external_visual_delta": { + "changed_channels": 6, + "mean_absolute": 1.595497506037961e-05, + "max_channel": 36, + "relative_ink_delta": 2.371487604530727e-05 + }, + "legacy_native_preview_unchanged": true + }, + { + "profile": "tagged", + "name": "repeated", + "pdf_sha256": "f87766a097517ee4427d634afc435cccece82f4899d0c4b47a2aeaa4ef235062", + "extracted": { + "pymupdf": "ABBA ABBA 2026", + "pdfminer": "ABBA ABBA 2026", + "pdfium": "ABBA ABBA 2026" + }, + "expected": "ABBA ABBA 2026", + "text_ok": true, + "external_visual_delta": { + "changed_channels": 6, + "mean_absolute": 4.487336735731765e-06, + "max_channel": 7, + "relative_ink_delta": 2.259291335617728e-05 + }, + "legacy_native_preview_unchanged": true + }, + { + "profile": "tagged", + "name": "small-heading", + "pdf_sha256": "8fb56fe47ba5be86eeeef52c6644d9f5b44c5d59e0704bd8f529fd1f8a38a8e1", + "extracted": { + "pymupdf": "Small heading", + "pdfminer": "Small heading", + "pdfium": "Small heading" + }, + "expected": "Small heading", + "text_ok": true, + "external_visual_delta": { + "changed_channels": 0, + "mean_absolute": 0.0, + "max_channel": 0, + "relative_ink_delta": 0.0 + }, + "legacy_native_preview_unchanged": true + }, + { + "profile": "tagged", + "name": "large-bold", + "pdf_sha256": "f7b7ded1a27451a8eacfb6cc9b9f25e35c3508cc6f70439d08b5a5d7836a9be1", + "extracted": { + "pymupdf": "Large bold 48", + "pdfminer": "Large bold 48", + "pdfium": "Large bold 48" + }, + "expected": "Large bold 48", + "text_ok": true, + "external_visual_delta": { + "changed_channels": 0, + "mean_absolute": 0.0, + "max_channel": 0, + "relative_ink_delta": 0.0 + }, + "legacy_native_preview_unchanged": true + }, + { + "profile": "tagged", + "name": "bold-italic", + "pdf_sha256": "bdd9ce699fdbbe6f95d68190f6638c7ee5cac9fed14d3bf6cbcabe41e8e357ac", + "extracted": { + "pymupdf": "Bold italic office", + "pdfminer": "Bold italic office", + "pdfium": "Bold italic office" + }, + "expected": "Bold italic office", + "text_ok": true, + "external_visual_delta": { + "changed_channels": 0, + "mean_absolute": 0.0, + "max_channel": 0, + "relative_ink_delta": 0.0 + }, + "legacy_native_preview_unchanged": true + }, + { + "profile": "tagged", + "name": "unicode", + "pdf_sha256": "795b9a5fe44f450fc364eb455636483159f0f067aed68e6e86b231c4e46d3182", + "extracted": { + "pymupdf": "R\u00e9sum\u00e9 caf\u00e9 office", + "pdfminer": "R\u00e9sum\u00e9 caf\u00e9 office", + "pdfium": "R\u00e9sum\u00e9 caf\u00e9 office" + }, + "expected": "R\u00e9sum\u00e9 caf\u00e9 office", + "text_ok": true, + "external_visual_delta": { + "changed_channels": 15, + "mean_absolute": 5.48452267700549e-06, + "max_channel": 5, + "relative_ink_delta": 1.2641234188976339e-05 + }, + "legacy_native_preview_unchanged": true + }, + { + "profile": "tagged", + "name": "mixed", + "pdf_sha256": "afbdb914286e3e3311d1a8864c3f18cd6a30c268a3a50955f6a0bf4306656c70", + "extracted": { + "pymupdf": "Plain bold plain italic.", + "pdfminer": "Plain bold plain italic.", + "pdfium": "Plain bold plain italic." + }, + "expected": "Plain bold plain italic.", + "text_ok": true, + "external_visual_delta": { + "changed_channels": 0, + "mean_absolute": 0.0, + "max_channel": 0, + "relative_ink_delta": 0.0 + }, + "legacy_native_preview_unchanged": true + }, + { + "profile": "tagged", + "name": "opacity", + "pdf_sha256": "1ef7dd3b9990907237eb2e2f340a6b633950d543c05a120d01c444a42fc28fb8", + "extracted": { + "pymupdf": "Translucent heading", + "pdfminer": "Translucent heading", + "pdfium": "Translucent heading" + }, + "expected": "Translucent heading", + "text_ok": true, + "external_visual_delta": { + "changed_channels": 0, + "mean_absolute": 0.0, + "max_channel": 0, + "relative_ink_delta": 0.0 + }, + "legacy_native_preview_unchanged": true + }, + { + "profile": "tagged", + "name": "transform", + "pdf_sha256": "f1460e1deda6b1c6c8ac6e76d329ba6446f60fa40bfd5226a2e7bf3c285fd3c1", + "extracted": { + "pymupdf": "Rotated heading", + "pdfminer": "Rotated heading", + "pdfium": "Rotated heading" + }, + "expected": "Rotated heading", + "text_ok": true + }, + { + "profile": "tagged", + "name": "scaled", + "pdf_sha256": "8a6e3c1fbc92ff6d4c8ee11814495e72aae2d0d13f12795de6a8ffa424ec63ab", + "extracted": { + "pymupdf": "Scaled heading", + "pdfminer": "Scaled heading", + "pdfium": "Scaled heading" + }, + "expected": "Scaled heading", + "text_ok": true + }, + { + "profile": "tagged", + "name": "sheared", + "pdf_sha256": "eb5a82e9d4fb55de1fe6b45683d8c0e875abc8d5d2a68efd5c3d6876ba161606", + "extracted": { + "pymupdf": "Sheared heading", + "pdfminer": "Sheared heading", + "pdfium": "Sheared heading" + }, + "expected": "Sheared heading", + "text_ok": true + }, + { + "profile": "tagged", + "name": "shadow", + "pdf_sha256": "88f2699ab9a84f905644a01c6a9a8153587f6cde3c4f918ec5b45a8c0eae128b", + "extracted": { + "pymupdf": "Shadow heading", + "pdfminer": "Shadow heading", + "pdfium": "Shadow heading" + }, + "expected": "Shadow heading", + "text_ok": true, + "external_visual_delta": { + "changed_channels": 0, + "mean_absolute": 0.0, + "max_channel": 0, + "relative_ink_delta": 0.0 + }, + "legacy_native_preview_unchanged": true + }, + { + "profile": "pdfua-1", + "name": "quickstart", + "pdf_sha256": "cecac76edc06ec15f744af89d9e44433ce68f9a8da89405fba71600510aef56d", + "extracted": { + "pymupdf": "Invoice INV-1042 Consulting: USD 1,200.00", + "pdfminer": "Invoice INV-1042 Consulting: USD 1,200.00", + "pdfium": "Invoice INV-1042 Consulting: USD 1,200.00" + }, + "expected": "Invoice INV-1042 Consulting: USD 1,200.00", + "text_ok": true, + "external_visual_delta": { + "changed_channels": 6, + "mean_absolute": 1.595497506037961e-05, + "max_channel": 36, + "relative_ink_delta": 2.371487604530727e-05 + }, + "legacy_native_preview_unchanged": true + }, + { + "profile": "pdfua-1", + "name": "repeated", + "pdf_sha256": "1fb74dc74e4bace5b901ae20b0739306423edd8ae0234412649eed67fd733109", + "extracted": { + "pymupdf": "ABBA ABBA 2026", + "pdfminer": "ABBA ABBA 2026", + "pdfium": "ABBA ABBA 2026" + }, + "expected": "ABBA ABBA 2026", + "text_ok": true, + "external_visual_delta": { + "changed_channels": 6, + "mean_absolute": 4.487336735731765e-06, + "max_channel": 7, + "relative_ink_delta": 2.259291335617728e-05 + }, + "legacy_native_preview_unchanged": true + }, + { + "profile": "pdfua-1", + "name": "small-heading", + "pdf_sha256": "6c6bf83f2b90d3baeab1924ea8a0fd1b6bd5b9324d0fe3809adc4f4587af5530", + "extracted": { + "pymupdf": "Small heading", + "pdfminer": "Small heading", + "pdfium": "Small heading" + }, + "expected": "Small heading", + "text_ok": true, + "external_visual_delta": { + "changed_channels": 0, + "mean_absolute": 0.0, + "max_channel": 0, + "relative_ink_delta": 0.0 + }, + "legacy_native_preview_unchanged": true + }, + { + "profile": "pdfua-1", + "name": "large-bold", + "pdf_sha256": "4fd7ee81117269ed13b0c8e228d6c6ae8d49c66d4970a5031fbed367591493b0", + "extracted": { + "pymupdf": "Large bold 48", + "pdfminer": "Large bold 48", + "pdfium": "Large bold 48" + }, + "expected": "Large bold 48", + "text_ok": true, + "external_visual_delta": { + "changed_channels": 0, + "mean_absolute": 0.0, + "max_channel": 0, + "relative_ink_delta": 0.0 + }, + "legacy_native_preview_unchanged": true + }, + { + "profile": "pdfua-1", + "name": "bold-italic", + "pdf_sha256": "d21032cedd3501775e97ef7eef98a46cf88d33580ae96988144a14010327089c", + "extracted": { + "pymupdf": "Bold italic office", + "pdfminer": "Bold italic office", + "pdfium": "Bold italic office" + }, + "expected": "Bold italic office", + "text_ok": true, + "external_visual_delta": { + "changed_channels": 0, + "mean_absolute": 0.0, + "max_channel": 0, + "relative_ink_delta": 0.0 + }, + "legacy_native_preview_unchanged": true + }, + { + "profile": "pdfua-1", + "name": "unicode", + "pdf_sha256": "d9794a038991d63e3542015b3378fccff35cda57a6e7428f5880f983ed6fb91d", + "extracted": { + "pymupdf": "R\u00e9sum\u00e9 caf\u00e9 office", + "pdfminer": "R\u00e9sum\u00e9 caf\u00e9 office", + "pdfium": "R\u00e9sum\u00e9 caf\u00e9 office" + }, + "expected": "R\u00e9sum\u00e9 caf\u00e9 office", + "text_ok": true, + "external_visual_delta": { + "changed_channels": 15, + "mean_absolute": 5.48452267700549e-06, + "max_channel": 5, + "relative_ink_delta": 1.2641234188976339e-05 + }, + "legacy_native_preview_unchanged": true + }, + { + "profile": "pdfua-1", + "name": "mixed", + "pdf_sha256": "27b688815c967ca4b1c5656b23f13bfc83f5f2635279b906ccbbea424dba4170", + "extracted": { + "pymupdf": "Plain bold plain italic.", + "pdfminer": "Plain bold plain italic.", + "pdfium": "Plain bold plain italic." + }, + "expected": "Plain bold plain italic.", + "text_ok": true, + "external_visual_delta": { + "changed_channels": 0, + "mean_absolute": 0.0, + "max_channel": 0, + "relative_ink_delta": 0.0 + }, + "legacy_native_preview_unchanged": true + }, + { + "profile": "pdfua-1", + "name": "opacity", + "pdf_sha256": "50c9f670de97a2212a955dbdca2de83e1a4c84df8e6eb01aef97fac736af80fc", + "extracted": { + "pymupdf": "Translucent heading", + "pdfminer": "Translucent heading", + "pdfium": "Translucent heading" + }, + "expected": "Translucent heading", + "text_ok": true, + "external_visual_delta": { + "changed_channels": 0, + "mean_absolute": 0.0, + "max_channel": 0, + "relative_ink_delta": 0.0 + }, + "legacy_native_preview_unchanged": true + }, + { + "profile": "pdfua-1", + "name": "transform", + "pdf_sha256": "f9c9c32e3a53a5412d79a447be5b88396c7a6460551fbda0950ed1862aa20061", + "extracted": { + "pymupdf": "Rotated heading", + "pdfminer": "Rotated heading", + "pdfium": "Rotated heading" + }, + "expected": "Rotated heading", + "text_ok": true + }, + { + "profile": "pdfua-1", + "name": "scaled", + "pdf_sha256": "613a0b20d4b08897f100280587615fe0aeb274c1b4d900f9c1ad3264d0d59745", + "extracted": { + "pymupdf": "Scaled heading", + "pdfminer": "Scaled heading", + "pdfium": "Scaled heading" + }, + "expected": "Scaled heading", + "text_ok": true + }, + { + "profile": "pdfua-1", + "name": "sheared", + "pdf_sha256": "60f3c9228842686e50fa62d30eda7785f7b11ce39e6734f7d6ee142010921528", + "extracted": { + "pymupdf": "Sheared heading", + "pdfminer": "Sheared heading", + "pdfium": "Sheared heading" + }, + "expected": "Sheared heading", + "text_ok": true + }, + { + "profile": "pdfua-1", + "name": "shadow", + "pdf_sha256": "0572bb356a6d8c3578fdfcdbd4854dfa301398f780a0063b685f20291195d594", + "extracted": { + "pymupdf": "Shadow heading", + "pdfminer": "Shadow heading", + "pdfium": "Shadow heading" + }, + "expected": "Shadow heading", + "text_ok": true + } + ], + "limitations": [ + "Fixture evidence is not general renderer parity, screen-reader acceptance, or standards conformance.", + "The dedicated PDF/VT validator is not configured." + ], + "evidence": "CI retains the source HTML/CSS, PDFs, native previews, text, and hashes in text-extraction artifacts; the release evidence archive adds local independent-reader comparisons." +} diff --git a/docs/release/README.md b/docs/release/README.md index 83a00a4..b84ac9c 100644 --- a/docs/release/README.md +++ b/docs/release/README.md @@ -4,6 +4,11 @@ This directory is the release-operations layer for Fullbleed. It is separate from feature documentation so release decisions can be audited without reading engine internals. +## 2.5.6 Documents + +- `docs/release/2.5.6-runbook.md`: single-copy synthesized-bold text, affine glyph previews, and retained extraction checks. +- `tools/smoke_text_extraction.py`: installed-wheel styled PDFs, independent text extraction, and native preview coverage checks. + ## 2.5.5 Documents - `docs/release/2.5.5-runbook.md`: finalized gradient previews, source PDF preservation, and independent visual checks. diff --git a/examples/acme_invoice/output/acme_sample_invoice.pdf b/examples/acme_invoice/output/acme_sample_invoice.pdf index 440605a..f2eac16 100644 Binary files a/examples/acme_invoice/output/acme_sample_invoice.pdf and b/examples/acme_invoice/output/acme_sample_invoice.pdf differ diff --git a/examples/acme_invoice/output/acme_sample_invoice_component_mount_validation.json b/examples/acme_invoice/output/acme_sample_invoice_component_mount_validation.json index 7985c22..75c71d2 100644 --- a/examples/acme_invoice/output/acme_sample_invoice_component_mount_validation.json +++ b/examples/acme_invoice/output/acme_sample_invoice_component_mount_validation.json @@ -2,7 +2,7 @@ "schema": "fullbleed.component_mount_validation.v2", "schema_version": 2, "ok": true, - "bytes_written": 149431, + "bytes_written": 133691, "missing_glyph_count": 0, "overflow_count": 0, "pagination_trace_available": true, diff --git a/fullbleed-agent-contract.json b/fullbleed-agent-contract.json index 4d0830e..3add764 100644 --- a/fullbleed-agent-contract.json +++ b/fullbleed-agent-contract.json @@ -4207,7 +4207,7 @@ "license": "MIT", "name": "Fullbleed PDF Engine", "python_package": "fullbleed", - "version": "2.5.5" + "version": "2.5.6" }, "profiles": { "pdf": { diff --git a/goldens/expected/golden_suite.expected.json b/goldens/expected/golden_suite.expected.json index 1ce3266..559ef9d 100644 --- a/goldens/expected/golden_suite.expected.json +++ b/goldens/expected/golden_suite.expected.json @@ -1,14 +1,14 @@ { "schema": "fullbleed.golden_suite.v1", - "generated_at_utc": "2026-09-30T22:12:33.479150+00:00", + "generated_at_utc": "2026-10-03T15:49:04.157375+00:00", "cases": { "acme_invoice": { "case_id": "acme_invoice", "report": "examples/acme_invoice/report.py", "pdf": { "path": "examples/acme_invoice/output/acme_sample_invoice.pdf", - "bytes": 149431, - "sha256": "7a025d57da2503277e7792b35ab4cb4c68b6aa39843f71bf84c44e633fe74420" + "bytes": 133691, + "sha256": "f7f69432764aa811a53a3c83b9df08743fbf0ae5804d4686809f472b642690fb" }, "png": { "count": 1, @@ -24,7 +24,7 @@ "validation": { "path": "examples/acme_invoice/output/acme_sample_invoice_component_mount_validation.json", "kind": "component_mount", - "sha256": "52d7faaa433ab98053e0a8c99a2fcb827a47e2f490385095f50327573b7e6da2" + "sha256": "20b3b9daf31cb17e9b81f21312032de550cbb5d1e785a3581a56b6be644bbbfb" }, "css_layers": { "path": "examples/acme_invoice/output/acme_sample_invoice_css_layers.json", diff --git a/llms.txt b/llms.txt index 6af1c4d..349f1f6 100644 --- a/llms.txt +++ b/llms.txt @@ -1,6 +1,6 @@ # Fullbleed PDF Engine -> Fullbleed 2.5.5 is a deterministic, self-contained document-generation runtime for Python and Rust. It turns structured data plus static HTML/CSS into validated PDF documents without a browser. +> Fullbleed 2.5.6 is a deterministic, self-contained document-generation runtime for Python and Rust. It turns structured data plus static HTML/CSS into validated PDF documents without a browser. The installed runtime is authoritative. Do not infer its API from this file or from remembered release knowledge. Start with: diff --git a/pyproject.toml b/pyproject.toml index dcf1a93..7741f90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ backend-path = ["build_backend"] [project] name = "fullbleed" -version = "2.5.5" +version = "2.5.6" description = "Deterministic Python PDF engine for reports, invoices, accessible documents, print-ready output, and compiled VDP" readme = "README.md" requires-python = ">=3.10" diff --git a/src/canvas.rs b/src/canvas.rs index 3df4812..e10cdfa 100644 --- a/src/canvas.rs +++ b/src/canvas.rs @@ -422,8 +422,8 @@ pub enum Command { m11: f32, }, /// Reusable vector-shader path for browser-synthesized bold. The PDF - /// backend compiles each unique glyph into a shared Type 3 program instead - /// of serializing the same expanded outline at every text occurrence. + /// backend compiles each unique glyph into a shared paint-only Form; + /// the accompanying logical text command supplies searchable text once. DrawSyntheticBoldGlyphRun { x: Pt, y: Pt, diff --git a/src/flowable.rs b/src/flowable.rs index 98b54ab..37cc798 100644 --- a/src/flowable.rs +++ b/src/flowable.rs @@ -5658,7 +5658,7 @@ fn draw_registered_text_run( let italic_shear = synthetic_italic_shear(style.font_style); // Fixed VDP substitutes slot values after layout. Keep binding-bearing // text as a patchable text command instead of compiling the placeholder's - // glyph IDs into the reusable Type 3 synthetic-bold shader. + // glyph IDs into the reusable synthetic-bold paint program. if contains_binding_slot && synthetic_bold { if synthetic_italic { canvas.draw_string_synthetic_bold_italic(x, y, text, strength, italic_shear); @@ -5696,8 +5696,7 @@ fn draw_registered_text_run( canvas, registry, style, font_name, x, y, &text, strength, 0.0, ) { // Preserve search, copy, accessibility, and tagged-PDF text - // while the visible glyphs follow Chromium's unhinted Type 3 - // synthetic-bold geometry. + // while the visible glyphs use reusable unhinted paint forms. canvas.save_state(); canvas.set_text_rendering_mode(3); canvas.draw_string(x, y, text); @@ -5797,9 +5796,9 @@ fn draw_registered_synthetic_bold_outline_run( } // Chromium serializes synthetic browser bold as unhinted Type 3 outlines. - // Compile the shaped run into reusable glyph programs. The Type 3 font - // matrix supplies Chromium's paint phase itself; the correction used by - // direct page paths would apply that phase twice. This replaces + // Compile the shaped run into reusable paint-only glyph programs, keeping + // the same font-space transform. The correction used by direct page paths + // would apply that phase twice. This replaces // O(occurrences × outline commands) display lists with O(unique glyphs) // vector programs plus compact run references. canvas.save_state(); @@ -5840,7 +5839,7 @@ fn draw_registered_synthetic_bold_outline_run( return true; } - // Oblique synthesis retains the direct path until the Type 3 shader grows + // Oblique synthesis retains the direct path until the reusable shader grows // a full affine font matrix; this preserves existing combined bold/italic // geometry without weakening the common reusable upright path. let (phase_x, phase_y) = browser_synthetic_bold_outline_phase(style.font_size); diff --git a/src/pdf.rs b/src/pdf.rs index 4eae0d6..b6cc2fa 100644 --- a/src/pdf.rs +++ b/src/pdf.rs @@ -1129,6 +1129,7 @@ pub(crate) struct PdfStreamWriter<'a, W: Write> { image_bytes_total: usize, form_resources: Vec<(String, usize)>, + glyph_form_map: BTreeMap<(String, u16, u32), String>, form_name_map: HashMap, form_content_map: HashMap, form_size_map: HashMap, @@ -1565,6 +1566,7 @@ impl<'a, W: Write> PdfStreamWriter<'a, W> { next_image_index: 1, image_bytes_total: 0, form_resources: Vec::new(), + glyph_form_map: BTreeMap::new(), form_name_map: HashMap::new(), form_content_map: HashMap::new(), form_size_map: HashMap::new(), @@ -3299,9 +3301,9 @@ impl<'a, W: Write> PdfStreamWriter<'a, W> { pre_shaped: Option<&ShapedText>, ) -> io::Result { let font_key = self.font_key(font_name); - if !self.fonts.contains_key(&font_key) { - self.ensure_font(font_name)?; - } + // Resolve only fonts used by text, and record usage in each document + // even when the shared font resource was created by an earlier one. + self.ensure_font(font_name)?; let Some((resource, encoding)) = self .fonts .get(&font_key) @@ -3696,7 +3698,8 @@ impl<'a, W: Write> PdfStreamWriter<'a, W> { let mut current_font_size = Pt::from_f32(12.0); let mut current_font_name = "Helvetica".to_string(); let mut current_fill = Color::BLACK; - let mut graphics_state_stack: Vec<(Pt, String, Color)> = Vec::new(); + let mut current_text_rendering_mode = 0u8; + let mut graphics_state_stack: Vec<(Pt, String, Color, u8)> = Vec::new(); let mut tag_stack: Vec = Vec::new(); let mut suppressed_tag_depth = 0usize; let mut marked_content_stack: Vec = Vec::new(); @@ -3766,14 +3769,18 @@ impl<'a, W: Write> PdfStreamWriter<'a, W> { current_font_size, current_font_name.clone(), current_fill, + current_text_rendering_mode, )); out.push_str("q\n"); } Command::RestoreState => { - if let Some((font_size, font_name, fill)) = graphics_state_stack.pop() { + if let Some((font_size, font_name, fill, text_mode)) = + graphics_state_stack.pop() + { current_font_size = font_size; current_font_name = font_name; current_fill = fill; + current_text_rendering_mode = text_mode; } out.push_str("Q\n"); } @@ -3974,12 +3981,12 @@ impl<'a, W: Write> PdfStreamWriter<'a, W> { Command::ApplyBackdropFilter { .. } => {} Command::SetFontName(name) => { current_font_name = name.clone(); - self.ensure_font(¤t_font_name)?; } Command::SetFontSize(size) => { current_font_size = *size; } Command::SetTextRenderingMode(mode) => { + current_text_rendering_mode = (*mode).min(7); out.push_str(&format!("{} Tr\n", (*mode).min(7))); } Command::ClipRect { @@ -4181,29 +4188,33 @@ impl<'a, W: Write> PdfStreamWriter<'a, W> { offsets, stroke_width, } => { + // Like the Type 3 glyphs this replaces, only invisible + // text mode suppresses paint (ISO 32000-1, 9.3.6). + if current_text_rendering_mode == 3 { + continue; + } let synthetic_bold_millionths = synthetic_bold_ratio_millionths(*stroke_width, current_font_size); let mut pen_x = *x; let mut pen_y = *y; for (index, glyph_id) in glyph_ids.iter().copied().enumerate() { let offset = offsets.get(index).copied().unwrap_or((Pt::ZERO, Pt::ZERO)); - if let Some(resource) = self.ensure_type3_glyph( + if let Some(resource) = self.ensure_glyph_paint_form( ¤t_font_name, glyph_id, synthetic_bold_millionths, )? { - out.push_str("BT\n"); + // The authored text is emitted once by the following + // invisible DrawString. Painting these same glyphs as + // Type 3 text makes readers extract both copies. out.push_str(&format!( - "/{} {} Tf\n", - resource, - fmt_pt(current_font_size) - )); - out.push_str(&format!( - "1 0 0 1 {} {} Tm\n", + "q\n{} 0 0 {} {} {} cm\n/{} Do\nQ\n", + format_fixed(current_font_size.to_milli_i64(), 6), + format_fixed(-current_font_size.to_milli_i64(), 6), fmt_pt(pen_x + offset.0), fmt_pt(page_height - pen_y - offset.1), + resource, )); - out.push_str(&format!("<{:02X}> Tj\nET\n", glyph_id & 0x00ff)); } if let Some((advance_x, advance_y)) = advances.get(index) { pen_x = pen_x + *advance_x; @@ -5125,6 +5136,46 @@ impl<'a, W: Write> PdfStreamWriter<'a, W> { Ok(Some(font.resource.clone())) } + fn ensure_glyph_paint_form( + &mut self, + name: &str, + glyph_id: u16, + synthetic_bold_millionths: u32, + ) -> io::Result> { + if glyph_id == 0 { + return Ok(None); + } + let logical_name = self.canonical_font_name(name); + let key = ( + normalize_font_key(&logical_name), + glyph_id, + synthetic_bold_millionths, + ); + if let Some(resource) = self.glyph_form_map.get(&key) { + return Ok(Some(resource.clone())); + } + let Some(outline) = self + .registry + .and_then(|registry| registry.glyph_outline_for_id(&logical_name, glyph_id)) + else { + return Ok(None); + }; + let (program, bbox, _) = glyph_outline_program(&outline, synthetic_bold_millionths); + let resource = format!("GlyphPaint{}", self.glyph_form_map.len() + 1); + let object_id = self.alloc_ids(1); + let dictionary = format!( + "/Type /XObject /Subtype /Form /BBox [{} {} {} {}] /Resources << >>", + fmt(bbox[0]), + fmt(bbox[1]), + fmt(bbox[2]), + fmt(bbox[3]), + ); + self.write_stream_object_bytes(object_id, &dictionary, program.as_bytes())?; + self.form_resources.push((resource.clone(), object_id)); + self.glyph_form_map.insert(key, resource.clone()); + Ok(Some(resource)) + } + fn write_type3_font( &mut self, font_state: &Type3StreamFont, @@ -9408,6 +9459,23 @@ fn synthetic_bold_ratio_millionths(stroke_width: Pt, font_size: Pt) -> u32 { fn type3_glyph_program( outline: &RegisteredGlyphOutline, synthetic_bold_millionths: u32, +) -> (String, [f32; 4], f32) { + let (paint, bbox, width) = glyph_outline_program(outline, synthetic_bold_millionths); + let program = format!( + "{} 0 {} {} {} {} d1\n{}", + fmt(width), + fmt(bbox[0]), + fmt(bbox[1]), + fmt(bbox[2]), + fmt(bbox[3]), + paint, + ); + (program, bbox, width) +} + +fn glyph_outline_program( + outline: &RegisteredGlyphOutline, + synthetic_bold_millionths: u32, ) -> (String, [f32; 4], f32) { let scale = 1000.0 / f32::from(outline.units_per_em.max(1)); let transform = |x: f32, y: f32| (x * scale, -y * scale); @@ -9501,17 +9569,10 @@ fn type3_glyph_program( bbox[3] += expansion; } let width = f32::from(outline.advance) * scale; - let mut program = format!( - "{} 0 {} {} {} {} d1\n", - fmt(width), - fmt(bbox[0]), - fmt(bbox[1]), - fmt(bbox[2]), - fmt(bbox[3]), - ); + let mut program = String::new(); if stroke_width > 0.0 { // Skia's synthetic font weight is a fill plus centered outline with a - // four-unit miter limit. Keeping it inside the Type 3 glyph program + // four-unit miter limit. Keeping it inside the shared glyph program // makes the expansion reusable and gives every occurrence the same // unhinted vector-mask raster phase. program.push_str(&format!( @@ -10764,7 +10825,7 @@ mod tests { } #[test] - fn pdfua1_type3_synthetic_bold_font_maps_visible_glyphs_to_unicode() { + fn pdfua1_synthetic_bold_keeps_one_logical_text_copy() { let inter_path = repo_font_path("Inter-Variable.ttf"); let inter_bytes = std::fs::read(&inter_path).expect("read inter"); let mut registry = FontRegistry::new(); @@ -10816,9 +10877,17 @@ mod tests { document_to_pdf_with_metrics_and_registry(&doc, None, Some(®istry), &options) .expect("render PDF/UA synthetic bold text"); let pdf = String::from_utf8_lossy(&bytes); - assert!(pdf.contains("/Subtype /Type3")); - assert!(pdf.contains("/CMapName /FullBleed-Type3-UCS")); - assert!(pdf.contains(&format!("<{:02X}> <0041>", glyph_id & 0x00ff))); + let content = page_content_bytes(&bytes); + assert!(!pdf.contains("/Subtype /Type3")); + assert!(pdf.contains("/Subtype /Form")); + assert!(pdf.contains("/ToUnicode")); + assert!(pdf.contains("<0041>")); + assert_eq!( + count_token(&content, b" Tj") + count_token(&content, b" TJ"), + 1 + ); + assert_eq!(count_token(&content, b"/P <> BDC"), 1); + assert_eq!(count_token(&content, b"/GlyphPaint1 Do"), 1); } #[test] @@ -10904,7 +10973,7 @@ mod tests { } #[test] - fn synthetic_bold_glyph_run_reuses_one_stroked_type3_program() { + fn synthetic_bold_glyph_run_reuses_one_stroked_paint_form() { let inter_path = repo_font_path("Inter-Variable.ttf"); let inter_bytes = std::fs::read(&inter_path).expect("read inter"); let mut registry = FontRegistry::new(); @@ -10939,16 +11008,12 @@ mod tests { .expect("render reusable synthetic-bold glyph run"); let pdf = String::from_utf8_lossy(&bytes); let content = page_content_bytes(&bytes); - assert!(pdf.contains("/Subtype /Type3")); + assert!(!pdf.contains("/Subtype /Type3")); + assert_eq!(count_token(&bytes, b"/Subtype /Form"), 1); assert!(pdf.contains("31.238 w")); assert!(pdf.contains("B\n")); - assert_eq!(count_token(&content, b" Tj"), 3); - // CharProc and Encoding each name the glyph once; repeated page draws - // reference that shared program instead of expanding its outline. - assert_eq!( - count_token(&bytes, format!("/g{:04X}", glyph_id).as_bytes()), - 2 - ); + assert_eq!(count_token(&content, b" Tj"), 0); + assert_eq!(count_token(&content, b"/GlyphPaint1 Do"), 3); } #[test] @@ -11056,6 +11121,33 @@ mod tests { assert!(err.to_string().contains("pdfua1 requires a font registry")); } + #[test] + fn pdfua1_does_not_require_an_unused_restored_font() { + let inter_path = repo_font_path("Inter-Variable.ttf"); + let mut registry = FontRegistry::new(); + let name = registry + .register_bytes(std::fs::read(&inter_path).unwrap(), None) + .expect("register font"); + let mut doc = text_page(&name, "Embedded text"); + // A transformed element restores the canvas's default font after its + // last text run. Selection alone must not embed or validate that font. + doc.pages[0] + .commands + .push(Command::SetFontName("Helvetica".to_string())); + let options = PdfOptions { + pdf_profile: PdfProfile::PdfUa1, + document_lang: Some("en-US".to_string()), + document_title: Some("Unused font selection".to_string()), + ..PdfOptions::default() + }; + let bytes = + document_to_pdf_with_metrics_and_registry(&doc, None, Some(®istry), &options) + .expect("only painted text requires an embedded font"); + let pdf = String::from_utf8_lossy(&bytes); + assert!(pdf.contains("/FontFile2")); + assert!(!pdf.contains("/BaseFont /Helvetica")); + } + #[test] fn wtpdf_profiles_emit_pdf_declarations_and_pdf20_structure() { let doc = one_page_document(vec![]); diff --git a/src/pdf_raster.rs b/src/pdf_raster.rs index 6b6d431..43246cb 100644 --- a/src/pdf_raster.rs +++ b/src/pdf_raster.rs @@ -966,6 +966,44 @@ fn parse_xobject( // Give forms their own path and graphics state, as PDF requires. Their // coordinates are already mapped into page space by the parser. let mut form_commands = Vec::new(); + // Keep a path-only form's affine transform on the raster graphics + // state. Baking it into coordinates while leaving `w` in font units + // makes small reusable glyph forms render as solid stroked boxes. + // The native path renderer then transforms both outlines and strokes, + // including nonuniform scale and shear, without a scalar approximation. + if form_content.operations.iter().all(|op| { + matches!( + op.operator.as_str(), + "m" | "l" + | "c" + | "h" + | "re" + | "f" + | "F" + | "f*" + | "S" + | "s" + | "B" + | "B*" + | "b" + | "b*" + | "w" + | "J" + | "j" + | "M" + ) + }) { + let matrix = nested_state.ctm; + form_commands.push(Command::ConcatMatrix { + a: matrix.a, + b: -matrix.b, + c: -matrix.c, + d: matrix.d, + e: Pt::from_f32(matrix.e), + f: Pt::from_f32(-matrix.f), + }); + nested_state.ctm = Matrix::identity(); + } forms::clip_bbox( doc, &stream.dict, @@ -4576,7 +4614,7 @@ endbfrange } #[test] - fn finalized_preview_recovers_generated_synthetic_bold_type3_glyphs() { + fn finalized_preview_paints_generated_synthetic_bold_forms() { let mut bundle = crate::AssetBundle::default(); bundle.add(crate::Asset::new( "PreviewRegular".to_string(), @@ -4596,31 +4634,20 @@ endbfrange ) .expect("synthetic-bold PDF"); assert!( - pdf.windows(b"/Subtype /Type3".len()) - .any(|window| window == b"/Subtype /Type3"), - "fixture must exercise the generated Type 3 path" + pdf.windows(b"/Subtype /Form".len()) + .any(|window| window == b"/Subtype /Form"), + "fixture must exercise the reusable glyph paint path" ); let doc = LoDocument::load_mem(&pdf).expect("parse PDF"); let (pages, embedded_fonts) = parse_pdf_pages(&doc).expect("lower PDF for preview"); assert!(!embedded_fonts.is_empty()); - let synthetic_runs = pages[0] - .commands - .iter() - .filter_map(|command| match command { - Command::DrawSyntheticBoldGlyphRun { - glyph_ids, - stroke_width, - .. - } => Some((glyph_ids, stroke_width)), - _ => None, - }) - .collect::>(); - assert!(!synthetic_runs.is_empty()); assert!( - synthetic_runs - .iter() - .all(|(glyph_ids, stroke_width)| !glyph_ids.is_empty() && **stroke_width > Pt::ZERO) + pages[0].commands.iter().any( + |command| matches!(command, Command::DefineForm { commands, .. } + if commands.iter().any(|command| matches!(command, Command::FillStroke))) + ), + "preview must lower the visible glyph forms to painted paths" ); let previews = pdf_bytes_to_png_pages(&pdf, 120, None, true).expect("finalized preview"); diff --git a/tests/test_fullbleed_text_extraction.py b/tests/test_fullbleed_text_extraction.py new file mode 100644 index 0000000..0bfabe7 --- /dev/null +++ b/tests/test_fullbleed_text_extraction.py @@ -0,0 +1,46 @@ +"""Regression checks on PDFs emitted by the installed native extension.""" +from importlib import resources +import io +from pathlib import Path +import runpy + +import fullbleed +import pytest + + +def test_styled_pdf_text_is_extracted_once(tmp_path): + if not hasattr(fullbleed, "PdfEngine"): + pytest.skip("fullbleed native extension is not available") + pytest.importorskip("pypdf") + script = Path(__file__).resolve().parents[1] / "tools" / "smoke_text_extraction.py" + report = runpy.run_path(str(script))["check"](tmp_path) + assert report["ok"] and len(report["cases"]) == 36 + + +@pytest.mark.parametrize("mode", ["fixed", "reflow", "compact"]) +def test_compiled_bold_text_and_previews_survive_repeated_rendering(tmp_path, mode): + if not hasattr(fullbleed, "PdfEngine"): + pytest.skip("fullbleed native extension is not available") + PdfReader = pytest.importorskip("pypdf").PdfReader + engine = fullbleed.PdfEngine() + bundle = fullbleed.AssetBundle() + bundle.add_file(str(resources.files("fullbleed_assets").joinpath("fonts/Inter-Variable.ttf")), + fullbleed.AssetKind.Font, name="Inter") + engine.register_bundle(bundle) + compiled = engine.compile_pdf("

Invoice

Customer {{name}}

", + "@page {size:A4;margin:20mm} body {font-family:Inter}") + previews = [] + for attempt in range(2): + bindings = {"name": ["Alpha", "Bravo"]} + if mode == "fixed": + pdf = compiled.render_pdf_bindings(bindings) + else: + pdf = compiled.render_pdf_reflow_bindings( + bindings, compression="compact" if mode == "compact" else "throughput") + texts = [" ".join(page.extract_text().split()) for page in PdfReader(io.BytesIO(pdf)).pages] + assert texts == ["Invoice Customer Alpha", "Invoice Customer Bravo"] + path = tmp_path / f"{attempt}.pdf" + path.write_bytes(pdf) + previews.append([bytes(png) for png in engine.render_finalized_pdf_image_pages(str(path), 96)]) + assert len(previews[0]) == 2 + assert previews[0] == previews[1], "Cached rendering lost or changed a glyph paint resource" diff --git a/tools/smoke_text_extraction.py b/tools/smoke_text_extraction.py new file mode 100644 index 0000000..8114c53 --- /dev/null +++ b/tools/smoke_text_extraction.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +"""Verify one searchable text copy in installed-engine styled PDFs. + +pypdf is a test-only reader, never a Fullbleed runtime dependency. +""" +from __future__ import annotations + +import argparse +from datetime import datetime, timezone +import hashlib +from importlib import metadata, resources +import json +from pathlib import Path +import runpy +import sys + +import fullbleed +from pypdf import PdfReader + + +decode_png = runpy.run_path(str(Path(__file__).with_name("run_css_fixture_suite.py")))["_decode_png_rgba_rows"] + + +def ink(png: bytes) -> int: + width, _, rows = decode_png(png) + return sum(width * 3 * 255 - sum(row[0::4]) - sum(row[1::4]) - sum(row[2::4]) + for row in rows) + + +CASES = [ + ("quickstart", "

Invoice INV-1042

Consulting: USD 1,200.00

", + "h1 { color: #175c52 }", "Invoice INV-1042 Consulting: USD 1,200.00"), + ("repeated", "

ABBA ABBA 2026

", "p {font-weight:700}", "ABBA ABBA 2026"), + ("small-heading", "

Small heading

", "h1 {font-size:9pt}", "Small heading"), + ("large-bold", "

Large bold 48

", "p {font-size:48pt;font-weight:700}", "Large bold 48"), + ("bold-italic", "

Bold italic office

", "p {font-weight:700;font-style:italic}", "Bold italic office"), + ("unicode", "

Résumé café office

", "p {font-weight:700}", "Résumé café office"), + ("mixed", "

Plain bold plain italic.

", "", "Plain bold plain italic."), + ("opacity", "

Translucent heading

", "p {font-weight:700;opacity:.65;color:#175c52}", "Translucent heading"), + ("transform", "

Rotated heading

", "p {font-weight:700;transform:rotate(4deg);transform-origin:left top}", "Rotated heading"), + ("scaled", "

Scaled heading

", "p {font-weight:700;transform:scale(1.2,.8);transform-origin:left top}", "Scaled heading"), + ("sheared", "

Sheared heading

", "p {font-weight:700;transform:skewX(12deg);transform-origin:left top}", "Sheared heading"), + ("shadow", "

Shadow heading

", "p {font-weight:700;text-shadow:1pt 1pt 2pt #888}", "Shadow heading"), +] + + +def check(output: Path) -> dict: + output.mkdir(parents=True, exist_ok=True) + report = dict(schema="fullbleed.text_extraction_smoke.v1", ok=False, + checked_at=datetime.now(timezone.utc).isoformat(), + fullbleed_version=metadata.version("fullbleed"), + reader_version=metadata.version("pypdf"), python=sys.version, + scope="Single-copy text extraction and native preview smoke; not PDF profile conformance.", + cases=[]) + try: + for profile in ["none", "tagged", "pdfua-1"]: + engine = fullbleed.PdfEngine(pdf_profile=profile, document_lang="en-US", + document_title="Text extraction regression") + bundle = fullbleed.AssetBundle() + bundle.add_file(str(resources.files("fullbleed_assets").joinpath("fonts/Inter-Variable.ttf")), + fullbleed.AssetKind.Font, name="Inter") + engine.register_bundle(bundle) + for name, html, style, expected in CASES: + folder = output / profile / name + folder.mkdir(parents=True, exist_ok=True) + css = "@page {size:A4;margin:20mm} body {font-family:Inter} " + style + (folder / "source.html").write_text(html, encoding="utf-8") + (folder / "source.css").write_text(css, encoding="utf-8") + source = folder / "output.pdf" + engine.render_pdf_to_file(html, css, str(source)) + pdf = source.read_bytes() + reader = PdfReader(source) + text = " ".join(" ".join(page.extract_text() for page in reader.pages).split()) + previews = list(engine.render_finalized_pdf_image_pages(str(source), 96)) + for index, png in enumerate(previews, 1): + (folder / f"page-{index}.png").write_bytes(bytes(png)) + direct = list(engine.render_image_pages(html, css, 96)) + assert len(direct) == len(previews) == 1, f"{profile}/{name}" + reference_png = bytes(direct[0]) + (folder / "direct-preview.png").write_bytes(reference_png) + reference_ink = ink(reference_png) + preview_ink = ink(bytes(previews[0])) + # A broad coverage bound catches blank glyphs or solid boxes + # while allowing font/path rasterization differences. This is + # a visual regression control, not a pixel-parity assertion. + coverage_ratio = preview_ink / reference_ink if reference_ink else 0 + result = dict(profile=profile, name=name, expected=expected, extracted=text, + pages=len(reader.pages), previews=len(previews), + pdf_sha256=hashlib.sha256(pdf).hexdigest(), + preview_sha256=[hashlib.sha256(bytes(png)).hexdigest() for png in previews], + source_unchanged=source.read_bytes() == pdf, + preview_ink_ratio=coverage_ratio) + result["ok"] = (text == expected and result["pages"] == result["previews"] == 1 + and result["source_unchanged"] and .8 <= coverage_ratio <= 1.2) + report["cases"].append(result) + report["ok"] = all(case["ok"] for case in report["cases"]) + failed = [f"{case['profile']}/{case['name']}: {case['extracted']!r}, ink ratio={case['preview_ink_ratio']:.3f}" + for case in report["cases"] if not case["ok"]] + assert report["ok"], "Single-copy text regression: " + "; ".join(failed) + finally: + (output / "verification.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + return report + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out", type=Path, required=True) + args = parser.parse_args() + result = check(args.out.resolve()) + print(json.dumps(dict(ok=result["ok"], cases=len(result["cases"]), output=str(args.out.resolve())))) + + +if __name__ == "__main__": + main()