From 854dd4496aa50247cc1d7ab431f9c8301ad1106e Mon Sep 17 00:00:00 2001 From: Tater Date: Sat, 3 Oct 2026 11:04:36 -0500 Subject: [PATCH] Fix duplicate synthetic-bold PDF text and prepare 2.5.6 --- .gitattributes | 2 + .github/workflows/ci.yml | 15 +- .github/workflows/publish-crates.yml | 2 +- .github/workflows/release.yml | 13 + Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 4 +- ReleaseNotes.MD | 27 + cli_schema.md | 2 +- docs/release/2.5.6-runbook.md | 56 ++ docs/release/2.5.6-validation.json | 657 ++++++++++++++++++ docs/release/README.md | 5 + .../output/acme_sample_invoice.pdf | Bin 149431 -> 133691 bytes ...le_invoice_component_mount_validation.json | 2 +- fullbleed-agent-contract.json | 2 +- goldens/expected/golden_suite.expected.json | 8 +- llms.txt | 2 +- pyproject.toml | 2 +- src/canvas.rs | 4 +- src/flowable.rs | 13 +- src/pdf.rs | 166 ++++- src/pdf_raster.rs | 67 +- tests/test_fullbleed_text_extraction.py | 46 ++ tools/smoke_text_extraction.py | 115 +++ 24 files changed, 1133 insertions(+), 81 deletions(-) create mode 100644 docs/release/2.5.6-runbook.md create mode 100644 docs/release/2.5.6-validation.json create mode 100644 tests/test_fullbleed_text_extraction.py create mode 100644 tools/smoke_text_extraction.py 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 440605adb7f7af93caac6d030b46cab525d355b1..f2eac162a5dd6420927a43bfce5eb8913b39ceee 100644 GIT binary patch delta 11204 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 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()