feat(render): expose positioned image pixels - #367
Conversation
New off-by-default `render` feature with a single entry point, `render_pages_mem`: rasterizes selected zero-based pages through Hayro into opaque RGBA8 buffers, caller order and duplicates preserved, 200 DPI by default, 300 max. DPI, page dimensions, per-page pixels, combined output bytes and page-entry count are all validated before the first page renders. Interpreter font and image failures surface as typed per-page `RenderWarning`s; `RenderError` is a separate non-exhaustive enum. Includes renderer unit tests, a shared image fixture reused by the wasm runtime tests, and an ignored checksum-verified py-pdf corpus suite.
Adds render-feature CI: native tests, lints, docs and a release build with the feature on, an exact Rust 1.92.0 check for native and wasm32, release-mode wasm runtime tests on Node and headless Chrome, and the pinned py-pdf corpus job with per-file SHA-256 verification.
There was a problem hiding this comment.
4 issues found across 19 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="tests/support/render_fixture.rs">
<violation number="1" location="tests/support/render_fixture.rs:5">
P3: This new builder duplicates the object-framing + xref/trailer generation already in tests/integration_tests.rs (make_text_pdf/add_object). Since both live in separate integration-test binaries they can't trivially share a `#[cfg(test)]` mod, but a shared `tests/support/` helper could hold this once; consider extracting to avoid two divergent copies of the same fragile xref logic.</violation>
</file>
<file name="tests/render_corpus_tests.rs">
<violation number="1" location="tests/render_corpus_tests.rs:116">
P3: The measurement test reaches into the corpus by positional index `&CASES[2]` and separately asserts `pages_needing_ocr == [0]`. Reordering or editing CASES silently points this test at a different PDF, and the two facts are duplicated rather than derived from one source. Give the OCR-positive case a named lookup (e.g. a `fn ocr_positive_case() -> &CorpusCase` selected by a distinct marker) so the index and the OCR assertion can't drift apart.</violation>
</file>
<file name="wasm/src/lib.rs">
<violation number="1" location="wasm/src/lib.rs:413">
P3: The new unit test in `wasm/src/lib.rs` and the new integration test in `wasm/tests/render_browser.rs` are near-identical: same 64x64 render at dpi 72, same warnings/pixels-length/alpha assertions, and the same red/blue channel thresholds. Duplicating the full assertion block across two test crates makes future behavior changes (e.g. to the render contract) need two parallel edits that can drift. Consider extracting the shared assertions into a small helper in `tests/support/render_fixture.rs` and calling it from both tests, or trimming one of them to the environment-specific concern it exists to cover.</violation>
</file>
<file name="src/render.rs">
<violation number="1" location="src/render.rs:327">
P2: The join contract between Markdown references and `extract_images_mem` results depends on image occurrences being assigned in the *same order* on both sides, but the two sides order images differently. In `extract_images_mem`, `occurrence` is assigned as `index + 1` over items returned by `extract_page_text_items`, which yields images in **content-stream order** for each page. In the Markdown path (`src/markdown/mod.rs`), occurrences are assigned via `image_occurrences.entry(item.page)` while iterating the *final* `items` collection, which has been reordered by the layout/reading-order stage. On any page with multiple placed Image XObjects whose reading order differs from their content-stream order, the `_i{occurrence}` suffix will differ between the Markdown link and the `RenderedImage.reference`, so `markdown.contains(&image.reference)` and the documented join will silently fail. Since the entire feature is built on this shared reference, the two sides should derive occurrences from the same canonical ordering (e.g. both from content-stream order).</violation>
</file>
Shadow auto-approve: would not auto-approve because issues were found.
Fix all with cubic | Re-trigger cubic
| .expect("each prepared image page came from extracted items"); | ||
| let mut page_images = Vec::with_capacity(items.len()); | ||
| for (index, item) in items.into_iter().enumerate() { | ||
| let occurrence = index as u32 + 1; |
There was a problem hiding this comment.
P2: The join contract between Markdown references and extract_images_mem results depends on image occurrences being assigned in the same order on both sides, but the two sides order images differently. In extract_images_mem, occurrence is assigned as index + 1 over items returned by extract_page_text_items, which yields images in content-stream order for each page. In the Markdown path (src/markdown/mod.rs), occurrences are assigned via image_occurrences.entry(item.page) while iterating the final items collection, which has been reordered by the layout/reading-order stage. On any page with multiple placed Image XObjects whose reading order differs from their content-stream order, the _i{occurrence} suffix will differ between the Markdown link and the RenderedImage.reference, so markdown.contains(&image.reference) and the documented join will silently fail. Since the entire feature is built on this shared reference, the two sides should derive occurrences from the same canonical ordering (e.g. both from content-stream order).
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/render.rs, line 327:
<comment>The join contract between Markdown references and `extract_images_mem` results depends on image occurrences being assigned in the *same order* on both sides, but the two sides order images differently. In `extract_images_mem`, `occurrence` is assigned as `index + 1` over items returned by `extract_page_text_items`, which yields images in **content-stream order** for each page. In the Markdown path (`src/markdown/mod.rs`), occurrences are assigned via `image_occurrences.entry(item.page)` while iterating the *final* `items` collection, which has been reordered by the layout/reading-order stage. On any page with multiple placed Image XObjects whose reading order differs from their content-stream order, the `_i{occurrence}` suffix will differ between the Markdown link and the `RenderedImage.reference`, so `markdown.contains(&image.reference)` and the documented join will silently fail. Since the entire feature is built on this shared reference, the two sides should derive occurrences from the same canonical ordering (e.g. both from content-stream order).</comment>
<file context>
@@ -0,0 +1,672 @@
+ .expect("each prepared image page came from extracted items");
+ let mut page_images = Vec::with_capacity(items.len());
+ for (index, item) in items.into_iter().enumerate() {
+ let occurrence = index as u32 + 1;
+ let reference = crate::types::image_reference(item.page, occurrence);
+ let bbox = [item.x, item.y, item.width, item.height];
</file context>
| /// becoming object `index + 1`. Shares the header, `N 0 obj`/`endobj` | ||
| /// framing, and xref/trailer generation used by every fixture builder in | ||
| /// this crate's tests. | ||
| pub fn build_pdf_from_objects(object_bodies: &[Vec<u8>]) -> Vec<u8> { |
There was a problem hiding this comment.
P3: This new builder duplicates the object-framing + xref/trailer generation already in tests/integration_tests.rs (make_text_pdf/add_object). Since both live in separate integration-test binaries they can't trivially share a #[cfg(test)] mod, but a shared tests/support/ helper could hold this once; consider extracting to avoid two divergent copies of the same fragile xref logic.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/support/render_fixture.rs, line 5:
<comment>This new builder duplicates the object-framing + xref/trailer generation already in tests/integration_tests.rs (make_text_pdf/add_object). Since both live in separate integration-test binaries they can't trivially share a `#[cfg(test)]` mod, but a shared `tests/support/` helper could hold this once; consider extracting to avoid two divergent copies of the same fragile xref logic.</comment>
<file context>
@@ -0,0 +1,88 @@
+/// becoming object `index + 1`. Shares the header, `N 0 obj`/`endobj`
+/// framing, and xref/trailer generation used by every fixture builder in
+/// this crate's tests.
+pub fn build_pdf_from_objects(object_bodies: &[Vec<u8>]) -> Vec<u8> {
+ let mut pdf = b"%PDF-1.4\n".to_vec();
+ let mut offsets = vec![0_usize];
</file context>
| .unwrap_or_else(|_| "200".to_string()) | ||
| .parse::<f32>() | ||
| .expect("PDF_INSPECTOR_RENDER_DPI must be a number"); | ||
| let case = &CASES[2]; |
There was a problem hiding this comment.
P3: The measurement test reaches into the corpus by positional index &CASES[2] and separately asserts pages_needing_ocr == [0]. Reordering or editing CASES silently points this test at a different PDF, and the two facts are duplicated rather than derived from one source. Give the OCR-positive case a named lookup (e.g. a fn ocr_positive_case() -> &CorpusCase selected by a distinct marker) so the index and the OCR assertion can't drift apart.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/render_corpus_tests.rs, line 116:
<comment>The measurement test reaches into the corpus by positional index `&CASES[2]` and separately asserts `pages_needing_ocr == [0]`. Reordering or editing CASES silently points this test at a different PDF, and the two facts are duplicated rather than derived from one source. Give the OCR-positive case a named lookup (e.g. a `fn ocr_positive_case() -> &CorpusCase` selected by a distinct marker) so the index and the OCR assertion can't drift apart.</comment>
<file context>
@@ -0,0 +1,179 @@
+ .unwrap_or_else(|_| "200".to_string())
+ .parse::<f32>()
+ .expect("PDF_INSPECTOR_RENDER_DPI must be a number");
+ let case = &CASES[2];
+ let path = root.join(case.relative_path);
+ let bytes = std::fs::read(&path).expect("read OCR-positive corpus PDF");
</file context>
| @@ -5,6 +5,10 @@ use pdf_inspector::{ | |||
| use serde::{Deserialize, Serialize}; | |||
There was a problem hiding this comment.
P3: The new unit test in wasm/src/lib.rs and the new integration test in wasm/tests/render_browser.rs are near-identical: same 64x64 render at dpi 72, same warnings/pixels-length/alpha assertions, and the same red/blue channel thresholds. Duplicating the full assertion block across two test crates makes future behavior changes (e.g. to the render contract) need two parallel edits that can drift. Consider extracting the shared assertions into a small helper in tests/support/render_fixture.rs and calling it from both tests, or trimming one of them to the environment-specific concern it exists to cover.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At wasm/src/lib.rs, line 413:
<comment>The new unit test in `wasm/src/lib.rs` and the new integration test in `wasm/tests/render_browser.rs` are near-identical: same 64x64 render at dpi 72, same warnings/pixels-length/alpha assertions, and the same red/blue channel thresholds. Duplicating the full assertion block across two test crates makes future behavior changes (e.g. to the render contract) need two parallel edits that can drift. Consider extracting the shared assertions into a small helper in `tests/support/render_fixture.rs` and calling it from both tests, or trimming one of them to the environment-specific concern it exists to cover.</comment>
<file context>
@@ -393,6 +397,39 @@ mod tests {
+
+ assert_eq!(rendered.len(), 1);
+ assert_eq!(rendered[0].page, 0);
+ assert_eq!((rendered[0].width, rendered[0].height), (64, 64));
+ assert!(rendered[0].warnings.is_empty());
+ assert_eq!(
</file context>
Background
pdf-inspectorcan place image markers in Markdown, but every marker currently uses the same target:That marker shows where an image appeared, but a caller cannot tell which image data belongs there. This blocks AnyDoc from returning usable PDF images and blocks OpenViking from keeping those images in the correct document position.
This PR adds the missing link between a Markdown image marker and its rendered pixels.
Before
With image markers enabled, all images use
(image):There is no public API that returns rendered image pixels with an identifier matching those markers. A caller would have to parse the PDF again and guess the mapping.
After
Each image occurrence gets a stable reference based on its page and occurrence number:
The new
extract_images_memAPI returns one rendered image for each occurrence:The caller matches Markdown and pixels by comparing
reference. No filename or coordinate guessing is needed.Each result also contains:
Repeated placements remain separate results because the same PDF image resource can be drawn at different positions, sizes, or with different page transforms.
Compatibility
Default Markdown output does not change. Stable image references are only emitted when
MarkdownOptions::include_imagesis enabled, and rendering remains behind the existingrenderfeature.Implementation notes
pdf-inspector.This is a stacked Draft PR based on #280, which provides the optional Hayro renderer. Thanks to @massimodeluisa for that rendering foundation.
Validation
cargo test --features rendercargo clippy --features render -- -D warningscargo clippy -- -D warningsprocess_pdf_mem_with_optionsandextract_images_memusing a generated text-image-text PDFThe generated PDF produced
pdf-image:p1_i1in both Markdown and the image result. The extracted image was 557×334 pixels, and its RGBA buffer contained 744,152 bytes. One existing renderer test was updated; no test file or test function was added.OpenViking needs this capability through AnyDoc so it can ingest PDF images without parsing the PDF a second time. Thank you for considering this integration requirement.