Skip to content

Commit 6e55435

Browse files
authored
Merge pull request yfedoseev#238 from yfedoseev/release/v0.3.17
feat: v0.3.17 - resolve RefCell panic and refine table detection
2 parents cfb573b + edca5db commit 6e55435

11 files changed

Lines changed: 153 additions & 48 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,22 @@
22

33
All notable changes to PDFOxide are documented here.
44

5+
## [0.3.17] - 2026-03-08
6+
> Stable Recursion and Refined Table Heuristics
7+
8+
### Features
9+
10+
- **Refined Table Detection** — The spatial table detector now requires at least **2 columns** to identify a region as a table. This significantly reduces false positives where single-column lists or bullet points were incorrectly wrapped in ASCII boxes.
11+
- **Optimized Text Extraction** — Refactored the internal extraction pipeline to eliminate redundant work when processing Tagged PDFs. The structure tree and page spans are now extracted once and shared across the detection and rendering phases.
12+
13+
### Bug Fixes
14+
15+
- **Resolved `RefCell` already borrowed panic** (#237) — Fixed a critical reentrancy issue where recursive Form XObject processing (e.g., extracting images from nested forms) could trigger a runtime panic. Replaced long-lived borrows with scoped, tiered cache access using Rust best practices. (Reported by **@marph91**)
16+
17+
### 🏆 Community Contributors
18+
19+
🥇 **@marph91** — Thank you for identifying the complex `RefCell` borrow conflict in nested image extraction (#237). This report led to a comprehensive safety audit of our interior mutability patterns and a more robust, recursion-safe caching architecture! 🚀
20+
521
## [0.3.16] - 2026-03-08
622
> Advanced Visual Table Detection and Automated Python Stubs
723

Cargo.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ members = [".", "pdf_oxide_mcp", "pdf_oxide_cli"]
33

44
[package]
55
name = "pdf_oxide"
6-
version = "0.3.16"
6+
version = "0.3.17"
77
edition = "2021"
88
authors = ["Yury Fedoseev <yfedoseev@gmail.com>"]
99
license = "MIT OR Apache-2.0"

pdf_oxide_cli/Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "pdf_oxide_cli"
3-
version = "0.3.16"
3+
version = "0.3.17"
44
edition = "2021"
55
description = "CLI for pdf-oxide — the fastest PDF toolkit. 22 commands: text extraction, PDF to markdown, search, merge, split, images, compress, encrypt, watermark, forms, and more."
66
license = "MIT OR Apache-2.0"
@@ -16,7 +16,7 @@ name = "pdf-oxide"
1616
path = "src/main.rs"
1717

1818
[dependencies]
19-
pdf_oxide = { version = "0.3.16", path = "..", features = ["rendering", "logging"] }
19+
pdf_oxide = { version = "0.3.17", path = "..", features = ["rendering", "logging"] }
2020
clap = { version = "4", features = ["derive"] }
2121
is-terminal = "0.4"
2222
serde_json = "1.0"

pdf_oxide_mcp/Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "pdf_oxide_mcp"
3-
version = "0.3.16"
3+
version = "0.3.17"
44
edition = "2021"
55
description = "MCP server for PDF extraction — gives Claude, Cursor, and AI assistants the ability to read PDFs locally. Text, markdown, and HTML output. Powered by pdf_oxide."
66
license = "MIT OR Apache-2.0"
@@ -16,7 +16,7 @@ name = "pdf-oxide-mcp"
1616
path = "src/main.rs"
1717

1818
[dependencies]
19-
pdf_oxide = { version = "0.3.16", path = ".." }
19+
pdf_oxide = { version = "0.3.17", path = ".." }
2020
serde = { version = "1.0", features = ["derive"] }
2121
serde_json = "1.0"
2222

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "maturin"
44

55
[project]
66
name = "pdf_oxide"
7-
version = "0.3.16"
7+
version = "0.3.17"
88
description = "The fastest Python PDF library: 0.8ms mean, 5× faster than PyMuPDF. Text extraction, markdown conversion, PDF creation. 100% pass rate on 3,830 PDFs."
99
readme = "README.md"
1010
requires-python = ">=3.8"

src/document.rs

Lines changed: 43 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,8 @@ pub struct PdfDocument {
188188
RefCell<HashMap<ObjectRef, Option<Vec<crate::layout::TextSpan>>>>,
189189
/// Cache of extracted images from Form XObjects (keyed by ObjectRef).
190190
/// Images are stored without CTM applied — caller applies its own CTM.
191-
pub(crate) form_xobject_images_cache: HashMap<ObjectRef, Vec<crate::extractors::PdfImage>>,
191+
pub(crate) form_xobject_images_cache:
192+
RefCell<HashMap<ObjectRef, Vec<crate::extractors::PdfImage>>>,
192193
/// Regions marked for erasure per page
193194
pub(crate) erase_regions: HashMap<usize, Vec<crate::geometry::Rect>>,
194195
}
@@ -436,7 +437,7 @@ impl PdfDocument {
436437
xobject_stream_cache: RefCell::new(HashMap::new()),
437438
xobject_stream_cache_bytes: Cell::new(0),
438439
xobject_spans_cache: RefCell::new(HashMap::new()),
439-
form_xobject_images_cache: HashMap::new(),
440+
form_xobject_images_cache: RefCell::new(HashMap::new()),
440441
erase_regions: HashMap::new(),
441442
};
442443

@@ -867,7 +868,8 @@ impl PdfDocument {
867868
}
868869

869870
// Check cache first
870-
if let Some(cached) = self.object_cache.borrow().get(&obj_ref).cloned() {
871+
let cached_opt = self.object_cache.borrow().get(&obj_ref).cloned();
872+
if let Some(cached) = cached_opt {
871873
return Ok(cached);
872874
}
873875

@@ -1098,12 +1100,15 @@ impl PdfDocument {
10981100
/// For compressed objects or on any error, returns true (conservative — will load fully).
10991101
pub fn is_form_xobject(&self, obj_ref: ObjectRef) -> bool {
11001102
// Check negative cache first (known non-Form XObjects)
1101-
if self.image_xobject_cache.borrow().contains(&obj_ref) {
1102-
return false;
1103+
{
1104+
if self.image_xobject_cache.borrow().contains(&obj_ref) {
1105+
return false;
1106+
}
11031107
}
11041108

11051109
// If already in object cache, check directly
1106-
if let Some(cached) = self.object_cache.borrow().get(&obj_ref).cloned() {
1110+
let cached_opt = self.object_cache.borrow().get(&obj_ref).cloned();
1111+
if let Some(cached) = cached_opt {
11071112
let is_form = cached
11081113
.as_dict()
11091114
.and_then(|d| d.get("Subtype"))
@@ -6711,8 +6716,8 @@ impl PdfDocument {
67116716
// Layer 2: Check font set cache for the /Font dictionary.
67126717
// Pages sharing the same /Font dict skip the entire per-font loop.
67136718
if let Some(font_dict_ref) = font_dict_ref {
6714-
if let Some(cached_set) = self.font_set_cache.borrow().get(&font_dict_ref).cloned()
6715-
{
6719+
let cached_set_opt = self.font_set_cache.borrow().get(&font_dict_ref).cloned();
6720+
if let Some(cached_set) = cached_set_opt {
67166721
for (name, font_arc) in &cached_set {
67176722
extractor.add_font_shared(name.clone(), Arc::clone(font_arc));
67186723
}
@@ -6745,12 +6750,12 @@ impl PdfDocument {
67456750
hasher.finish()
67466751
};
67476752

6748-
if let Some(cached_set) = self
6753+
let cached_fingerprint_opt = self
67496754
.font_fingerprint_cache
67506755
.borrow()
67516756
.get(&fingerprint)
6752-
.cloned()
6753-
{
6757+
.cloned();
6758+
if let Some(cached_set) = cached_fingerprint_opt {
67546759
for (name, font_arc) in &cached_set {
67556760
extractor.add_font_shared(name.clone(), Arc::clone(font_arc));
67566761
}
@@ -6772,9 +6777,8 @@ impl PdfDocument {
67726777
hasher.finish()
67736778
};
67746779

6775-
if let Some((cached_set, _check_name, _check_hash)) =
6776-
self.font_name_set_cache.borrow().get(&name_hash).cloned()
6777-
{
6780+
let cached_name_set = self.font_name_set_cache.borrow().get(&name_hash).cloned();
6781+
if let Some((cached_set, _check_name, _check_hash)) = cached_name_set {
67786782
// Layer 4: Same font names within a document virtually always map
67796783
// to the same underlying fonts. Trust the name-based cache to avoid
67806784
// expensive load_object calls for spot-check verification.
@@ -6798,7 +6802,8 @@ impl PdfDocument {
67986802
for (name, font_obj) in sorted_font_entries {
67996803
// If font is a reference, check per-font cache first
68006804
if let Some(font_ref) = font_obj.as_reference() {
6801-
if let Some(cached) = self.font_cache.borrow().get(&font_ref).cloned() {
6805+
let cached_font_opt = self.font_cache.borrow().get(&font_ref).cloned();
6806+
if let Some(cached) = cached_font_opt {
68026807
extractor.add_font_shared(name.clone(), cached);
68036808
continue;
68046809
}
@@ -6815,9 +6820,9 @@ impl PdfDocument {
68156820

68166821
// Layer 5: Per-font identity cache — skip from_dict when a
68176822
// structurally identical font was already parsed elsewhere.
6818-
if let Some(cached) =
6819-
self.font_identity_cache.borrow().get(&id_hash).cloned()
6820-
{
6823+
let cached_identity_opt =
6824+
self.font_identity_cache.borrow().get(&id_hash).cloned();
6825+
if let Some(cached) = cached_identity_opt {
68216826
self.font_cache
68226827
.borrow_mut()
68236828
.insert(font_ref, Arc::clone(&cached));
@@ -8162,19 +8167,22 @@ impl PdfDocument {
81628167
return Ok(Vec::new());
81638168
}
81648169

8165-
// Check image result cache — images stored with Form's own Matrix only
8166-
if let Some(cached_images) = self.form_xobject_images_cache.get(&xobject_ref) {
8167-
let images = cached_images
8168-
.iter()
8169-
.map(|img| {
8170-
let mut cloned = img.clone();
8171-
if let Some(rect) = cloned.bbox() {
8172-
cloned.set_bbox(self.transform_bbox_with_ctm(rect, parent_ctm));
8173-
}
8174-
cloned
8175-
})
8176-
.collect();
8177-
return Ok(images);
8170+
// Check image result cache — images stored with Form's own Matrix only.
8171+
// Scope the borrow to ensure it's dropped before potential recursion.
8172+
{
8173+
if let Some(cached_images) = self.form_xobject_images_cache.borrow().get(&xobject_ref) {
8174+
let images = cached_images
8175+
.iter()
8176+
.map(|img| {
8177+
let mut cloned = img.clone();
8178+
if let Some(rect) = cloned.bbox() {
8179+
cloned.set_bbox(self.transform_bbox_with_ctm(rect, parent_ctm));
8180+
}
8181+
cloned
8182+
})
8183+
.collect();
8184+
return Ok(images);
8185+
}
81788186
}
81798187

81808188
xobject_stack.push(xobject_ref);
@@ -8220,12 +8228,12 @@ impl PdfDocument {
82208228
};
82218229

82228230
// Decode form stream — check cache first to avoid repeated decompression
8223-
let stream_data = if let Some(cached) = self
8231+
let cached_stream = self
82248232
.xobject_stream_cache
82258233
.borrow()
82268234
.get(&xobject_ref)
8227-
.cloned()
8228-
{
8235+
.cloned();
8236+
let stream_data = if let Some(cached) = cached_stream {
82298237
cached.as_ref().clone()
82308238
} else {
82318239
match self.decode_stream_with_encryption(xobject, xobject_ref) {
@@ -8322,6 +8330,7 @@ impl PdfDocument {
83228330

83238331
// Cache the raw images (with Form's own Matrix applied, but no parent CTM)
83248332
self.form_xobject_images_cache
8333+
.borrow_mut()
83258334
.insert(xobject_ref, raw_images.clone());
83268335

83278336
// Apply parent_ctm to produce final images for this call

src/extractors/text.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4419,7 +4419,7 @@ impl TextExtractor {
44194419
// Span result cache: reuse extracted spans from self-contained Form XObjects.
44204420
// Only works for XObjects with own /Resources (font context is self-contained).
44214421
if self.extract_spans {
4422-
let cached_spans = doc.xobject_spans_cache.borrow().get(&xobject_ref).cloned();
4422+
let cached_spans = { doc.xobject_spans_cache.borrow().get(&xobject_ref).cloned() };
44234423
if let Some(cached_spans) = cached_spans {
44244424
if let Some(spans) = cached_spans {
44254425
self.spans.extend(spans.iter().cloned());
@@ -4482,7 +4482,8 @@ impl TextExtractor {
44824482

44834483
// Decode the stream — check cache first to avoid repeated FlateDecode.
44844484
self.xobject_decode_count += 1;
4485-
let cached_stream = doc.xobject_stream_cache.borrow().get(&xobject_ref).cloned();
4485+
let cached_stream =
4486+
{ doc.xobject_stream_cache.borrow().get(&xobject_ref).cloned() };
44864487
let stream_data = if let Some(cached) = cached_stream {
44874488
cached.as_ref().clone()
44884489
} else {
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
use pdf_oxide::converters::ConversionOptions;
2+
use pdf_oxide::document::PdfDocument;
3+
4+
#[test]
5+
fn test_recursive_xobject_image_extraction_safety() {
6+
// Regression test for ensuring recursive image extraction from Form XObjects
7+
// does not trigger RefCell borrow conflicts.
8+
9+
let mut pdf = Vec::new();
10+
let mut offsets: Vec<usize> = Vec::new();
11+
12+
pdf.extend_from_slice(b"%PDF-1.4\n");
13+
14+
// 1 0 obj: Catalog
15+
offsets.push(pdf.len());
16+
pdf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
17+
18+
// 2 0 obj: Pages
19+
offsets.push(pdf.len());
20+
pdf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n");
21+
22+
// 3 0 obj: Page
23+
offsets.push(pdf.len());
24+
pdf.extend_from_slice(b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /XObject << /Form1 5 0 R >> >> >>\nendobj\n");
25+
26+
// 4 0 obj: Page content (invokes Form1)
27+
let content = b"/Form1 Do";
28+
offsets.push(pdf.len());
29+
pdf.extend_from_slice(format!("4 0 obj\n<< /Length {} >>\nstream\n", content.len()).as_bytes());
30+
pdf.extend_from_slice(content);
31+
pdf.extend_from_slice(b"\nendstream\nendobj\n");
32+
33+
// 5 0 obj: Form XObject (invokes Im1)
34+
let form_content = b"q 100 0 0 100 100 100 cm /Im1 Do Q";
35+
let form_res = b"<< /XObject << /Im1 6 0 R >> >>";
36+
offsets.push(pdf.len());
37+
pdf.extend_from_slice(format!("5 0 obj\n<< /Type /XObject /Subtype /Form /BBox [0 0 612 792] /Resources {} /Length {} >>\nstream\n", String::from_utf8_lossy(form_res), form_content.len()).as_bytes());
38+
pdf.extend_from_slice(form_content);
39+
pdf.extend_from_slice(b"\nendstream\nendobj\n");
40+
41+
// 6 0 obj: Image XObject
42+
let img_data = vec![0u8; 100 * 100 * 3];
43+
offsets.push(pdf.len());
44+
pdf.extend_from_slice(format!("6 0 obj\n<< /Type /XObject /Subtype /Image /Width 100 /Height 100 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length {} >>\nstream\n", img_data.len()).as_bytes());
45+
pdf.extend_from_slice(&img_data);
46+
pdf.extend_from_slice(b"\nendstream\nendobj\n");
47+
48+
let xref_offset = pdf.len();
49+
pdf.extend_from_slice(b"xref\n");
50+
pdf.extend_from_slice(format!("0 {}\n", offsets.len() + 1).as_bytes());
51+
pdf.extend_from_slice(b"0000000000 65535 f \n");
52+
for offset in &offsets {
53+
pdf.extend_from_slice(format!("{:010} 00000 n \n", offset).as_bytes());
54+
}
55+
56+
pdf.extend_from_slice(
57+
format!(
58+
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{}\n%%EOF\n",
59+
offsets.len() + 1,
60+
xref_offset
61+
)
62+
.as_bytes(),
63+
);
64+
65+
let mut doc = PdfDocument::from_bytes(pdf).expect("Failed to parse PDF");
66+
67+
let options = ConversionOptions {
68+
include_images: true,
69+
..Default::default()
70+
};
71+
72+
// Multiple iterations to ensure caching logic is also exercised safely
73+
for _ in 0..10 {
74+
let result = doc.to_markdown(0, &options);
75+
assert!(result.is_ok(), "Image extraction failed in recursive XObject");
76+
let markdown = result.unwrap();
77+
assert!(markdown.contains("![Image"), "Markdown should contain image reference");
78+
}
79+
}

uv.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)