Skip to content

feat(render): expose positioned image pixels - #367

Draft
qin-ctx wants to merge 6 commits into
firecrawl:mainfrom
qin-ctx:feat/rendered-image-regions
Draft

feat(render): expose positioned image pixels#367
qin-ctx wants to merge 6 commits into
firecrawl:mainfrom
qin-ctx:feat/rendered-image-regions

Conversation

@qin-ctx

@qin-ctx qin-ctx commented Aug 12, 2026

Copy link
Copy Markdown

Background

pdf-inspector can place image markers in Markdown, but every marker currently uses the same target:

![Image: Im0](image)

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):

Text before the first image.

![Image: Im0](image)

Text between images.

![Image: Im1](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:

Text before the first image.

![Image: Im0](pdf-image:p1_i1)

Text between images.

![Image: Im1](pdf-image:p1_i2)

The new extract_images_mem API returns one rendered image for each occurrence:

let images = extract_images_mem(&pdf, RenderOptions::new())?;

assert_eq!(images[0].reference, "pdf-image:p1_i1");
assert_eq!(images[0].page, 0);
assert_eq!(images[0].occurrence, 1);

The caller matches Markdown and pixels by comparing reference. No filename or coordinate guessing is needed.

Each result also contains:

  • the PDF resource name, for diagnostics only;
  • the zero-based page and one-based occurrence;
  • the source bounding box;
  • the rendered width and height;
  • opaque RGBA8 pixels;
  • renderer warning codes.

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_images is enabled, and rendering remains behind the existing render feature.

Implementation notes

  • Reuses image positions and content-stream order already extracted by pdf-inspector.
  • Applies page CropBox and rotation before cropping image pixels.
  • Loads the renderer once and processes image-bearing pages one at a time.
  • Checks page dimensions and total output size before allocating rendered buffers.

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 render
  • cargo clippy --features render -- -D warnings
  • cargo clippy -- -D warnings
  • Direct call to process_pdf_mem_with_options and extract_images_mem using a generated text-image-text PDF

The generated PDF produced pdf-image:p1_i1 in 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.

massimodeluisa and others added 6 commits August 12, 2026 09:41
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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Comment thread src/render.rs
.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;

@cubic-dev-ai cubic-dev-ai Bot Aug 12, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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>
Fix with cubic

/// 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> {

@cubic-dev-ai cubic-dev-ai Bot Aug 12, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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>
Fix with cubic

.unwrap_or_else(|_| "200".to_string())
.parse::<f32>()
.expect("PDF_INSPECTOR_RENDER_DPI must be a number");
let case = &CASES[2];

@cubic-dev-ai cubic-dev-ai Bot Aug 12, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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>
Fix with cubic

Comment thread wasm/src/lib.rs
@@ -5,6 +5,10 @@ use pdf_inspector::{
use serde::{Deserialize, Serialize};

@cubic-dev-ai cubic-dev-ai Bot Aug 12, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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>
Fix with cubic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants