Skip to content

Commit c706ddd

Browse files
committed
fix: use turbojpeg native scaled DCT decoding for thumbnails — JPEG images decode at 1/8 resolution during DCT phase instead of full-res decode + downsample
1 parent c7a5216 commit c706ddd

2 files changed

Lines changed: 166 additions & 98 deletions

File tree

open-pdf-render/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@ description = "Pure Rust PDF page renderer — renders PDF pages to RGBA bitmaps
99
lopdf = "0.34"
1010
tiny-skia = "0.11"
1111
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
12+
turbojpeg = { version = "1", default-features = false, features = ["image", "cmake"] }
1213
ttf-parser = "0.25"
1314
rayon = "1"

open-pdf-render/src/interpreter.rs

Lines changed: 165 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -293,9 +293,11 @@ impl Interpreter {
293293
state.restore();
294294
}
295295

296-
/// Decode and draw an image XObject. When `max_decode_pixels` is set,
297-
/// images larger than that limit are downsampled after decode to cap
298-
/// memory usage and speed up rendering (used for thumbnails).
296+
/// Decode and draw an image XObject. When `max_decode_pixels > 0`,
297+
/// JPEGs are decoded at reduced resolution via turbojpeg's native
298+
/// scaled DCT decoding (1/2, 1/4, or 1/8) — this is done during the
299+
/// decode itself, avoiding full-resolution decode entirely. Non-JPEG
300+
/// images are decoded at full resolution then box-filtered down.
299301
fn handle_image_execute(
300302
stream: &lopdf::Stream,
301303
renderer: &mut SkiaRenderer,
@@ -304,23 +306,8 @@ impl Interpreter {
304306
max_decode_pixels: u32,
305307
) {
306308
let dict = &stream.dict;
307-
let width = dict.get(b"Width").ok()
308-
.and_then(|o| match o {
309-
Object::Integer(i) => Some(*i as u32),
310-
Object::Reference(id) => doc.get_object(*id).ok().and_then(|o| {
311-
if let Object::Integer(i) = o { Some(*i as u32) } else { None }
312-
}),
313-
_ => None,
314-
}).unwrap_or(0);
315-
let height = dict.get(b"Height").ok()
316-
.and_then(|o| match o {
317-
Object::Integer(i) => Some(*i as u32),
318-
Object::Reference(id) => doc.get_object(*id).ok().and_then(|o| {
319-
if let Object::Integer(i) = o { Some(*i as u32) } else { None }
320-
}),
321-
_ => None,
322-
}).unwrap_or(0);
323-
309+
let width = Self::read_int(dict, b"Width", doc).unwrap_or(0);
310+
let height = Self::read_int(dict, b"Height", doc).unwrap_or(0);
324311
if width == 0 || height == 0 { return; }
325312

326313
let filter = dict.get(b"Filter").ok().and_then(|o| match o {
@@ -335,111 +322,191 @@ impl Interpreter {
335322
_ => None,
336323
});
337324
let filter_name = filter.as_deref().unwrap_or(b"");
325+
let is_jpeg = filter_name == b"DCTDecode";
338326

339-
// Decode image to RGBA
340-
let (mut img_w, mut img_h, mut rgba) = if filter_name == b"DCTDecode" {
327+
// ─── JPEG: use turbojpeg with native scaled DCT decoding ─────────
328+
let (img_w, img_h, rgba) = if is_jpeg {
341329
let raw = &stream.content;
342-
match image::load_from_memory_with_format(raw, image::ImageFormat::Jpeg) {
343-
Ok(img) => {
344-
let img = img.to_rgba8();
345-
let w = img.width();
346-
let h = img.height();
347-
(w, h, img.into_raw())
348-
}
349-
Err(_) => return,
330+
match Self::decode_jpeg_scaled(raw, max_decode_pixels) {
331+
Some(result) => result,
332+
None => return,
333+
}
334+
} else {
335+
// ─── Non-JPEG: raw pixel decode + optional box downsample ────
336+
match Self::decode_raw_image(dict, stream, doc, width, height, max_decode_pixels) {
337+
Some(result) => result,
338+
None => return,
339+
}
340+
};
341+
342+
state.save();
343+
state.concat_matrix(1.0, 0.0, 0.0, -1.0, 0.0, 1.0);
344+
renderer.draw_image(img_w, img_h, &rgba, &state.current);
345+
state.restore();
346+
}
347+
348+
/// Read an integer from a PDF dict, resolving indirect references.
349+
fn read_int(dict: &Dictionary, key: &[u8], doc: &Document) -> Option<u32> {
350+
dict.get(key).ok().and_then(|o| match o {
351+
Object::Integer(i) => Some(*i as u32),
352+
Object::Reference(id) => doc.get_object(*id).ok().and_then(|o| {
353+
if let Object::Integer(i) = o { Some(*i as u32) } else { None }
354+
}),
355+
_ => None,
356+
})
357+
}
358+
359+
/// Decode JPEG using turbojpeg with native scaled DCT decoding.
360+
/// When `max_pixels > 0`, picks the smallest scaling factor (1/1, 1/2,
361+
/// 1/4, 1/8) that keeps the decoded pixel count at or below the budget.
362+
/// The scaling happens DURING the DCT decode — no full-resolution
363+
/// decode is ever performed. This is what makes it fast.
364+
fn decode_jpeg_scaled(jpeg_data: &[u8], max_pixels: u32) -> Option<(u32, u32, Vec<u8>)> {
365+
let mut decompressor = turbojpeg::Decompressor::new().ok()?;
366+
let header = decompressor.read_header(jpeg_data).ok()?;
367+
let full_w = header.width;
368+
let full_h = header.height;
369+
370+
// Pick best scaling factor if we have a pixel budget.
371+
// turbojpeg supports native 1/2, 1/4, 1/8 etc. decode — the DCT
372+
// coefficients are only partially reconstructed, making it much
373+
// faster than full decode + downsample.
374+
if max_pixels > 0 && (full_w * full_h) as u32 > max_pixels {
375+
let factors = turbojpeg::Decompressor::supported_scaling_factors();
376+
let mut best: Option<turbojpeg::ScalingFactor> = None;
377+
for &factor in &factors {
378+
let sw = factor.scale(full_w as usize);
379+
let sh = factor.scale(full_h as usize);
380+
if (sw * sh) as u32 <= max_pixels {
381+
best = Some(factor);
382+
break;
383+
}
384+
}
385+
if best.is_none() {
386+
best = factors.last().copied();
350387
}
388+
if let Some(factor) = best {
389+
let _ = decompressor.set_scaling_factor(factor);
390+
}
391+
}
392+
393+
// Read header — then apply the scaling factor to get output dimensions
394+
let header2 = decompressor.read_header(jpeg_data).ok()?;
395+
// If we set a scaling factor, compute the scaled output size
396+
let (out_w, out_h) = if max_pixels > 0 && (full_w * full_h) as u32 > max_pixels {
397+
let factors = turbojpeg::Decompressor::supported_scaling_factors();
398+
let mut sw = header2.width;
399+
let mut sh = header2.height;
400+
for &factor in &factors {
401+
let fw = factor.scale(full_w as usize);
402+
let fh = factor.scale(full_h as usize);
403+
if (fw * fh) as u32 <= max_pixels {
404+
sw = fw;
405+
sh = fh;
406+
break;
407+
}
408+
}
409+
if sw == header2.width && sh == header2.height {
410+
if let Some(&last) = factors.last() {
411+
sw = last.scale(full_w as usize);
412+
sh = last.scale(full_h as usize);
413+
}
414+
}
415+
(sw, sh)
351416
} else {
352-
let bits = dict.get(b"BitsPerComponent").ok()
353-
.and_then(|o| if let Object::Integer(i) = o { Some(*i as u8) } else { None })
354-
.unwrap_or(8);
355-
if bits != 8 { return; }
417+
(header2.width, header2.height)
418+
};
419+
420+
let mut image = turbojpeg::Image {
421+
pixels: vec![0u8; out_w * out_h * 4],
422+
width: out_w,
423+
pitch: out_w * 4,
424+
height: out_h,
425+
format: turbojpeg::PixelFormat::RGBA,
426+
};
427+
428+
decompressor.decompress(jpeg_data, image.as_deref_mut()).ok()?;
356429

357-
let cs_name = dict.get(b"ColorSpace").ok().and_then(|o| match o {
430+
Some((out_w as u32, out_h as u32, image.pixels))
431+
}
432+
433+
/// Decode a non-JPEG image (raw/deflated pixel data) with optional
434+
/// box-filter downsampling when exceeding the pixel budget.
435+
fn decode_raw_image(
436+
dict: &Dictionary,
437+
stream: &lopdf::Stream,
438+
doc: &Document,
439+
width: u32,
440+
height: u32,
441+
max_pixels: u32,
442+
) -> Option<(u32, u32, Vec<u8>)> {
443+
let bits = dict.get(b"BitsPerComponent").ok()
444+
.and_then(|o| if let Object::Integer(i) = o { Some(*i as u8) } else { None })
445+
.unwrap_or(8);
446+
if bits != 8 { return None; }
447+
448+
let cs_name = dict.get(b"ColorSpace").ok().and_then(|o| match o {
449+
Object::Name(n) => Some(n.clone()),
450+
Object::Reference(id) => doc.get_object(*id).ok().and_then(|o| {
451+
if let Object::Name(n) = o { Some(n.clone()) } else { None }
452+
}),
453+
Object::Array(arr) => arr.first().and_then(|o| match o {
358454
Object::Name(n) => Some(n.clone()),
359-
Object::Reference(id) => doc.get_object(*id).ok().and_then(|o| {
360-
if let Object::Name(n) = o { Some(n.clone()) } else { None }
361-
}),
362-
Object::Array(arr) => arr.first().and_then(|o| match o {
363-
Object::Name(n) => Some(n.clone()),
364-
_ => None,
365-
}),
366455
_ => None,
367-
});
368-
let components: usize = match cs_name.as_deref() {
369-
Some(b"DeviceCMYK") => 4,
370-
Some(b"DeviceGray") | Some(b"CalGray") => 1,
371-
_ => 3,
372-
};
373-
374-
let raw_pixels = match stream.decompressed_content() {
375-
Ok(p) => p,
376-
Err(_) => return,
377-
};
378-
let expected = width as usize * height as usize * components;
379-
if raw_pixels.len() < expected { return; }
380-
381-
let mut out = Vec::with_capacity(width as usize * height as usize * 4);
382-
let mut idx = 0;
383-
for _ in 0..(width as usize * height as usize) {
456+
}),
457+
_ => None,
458+
});
459+
let components: usize = match cs_name.as_deref() {
460+
Some(b"DeviceCMYK") => 4,
461+
Some(b"DeviceGray") | Some(b"CalGray") => 1,
462+
_ => 3,
463+
};
464+
465+
let raw_pixels = stream.decompressed_content().ok()?;
466+
let expected = width as usize * height as usize * components;
467+
if raw_pixels.len() < expected { return None; }
468+
469+
// Determine output size — downsample if over budget
470+
let (out_w, out_h, step_x, step_y) = if max_pixels > 0 && width * height > max_pixels {
471+
let ratio = (max_pixels as f64 / (width as f64 * height as f64)).sqrt();
472+
let nw = ((width as f64 * ratio).ceil() as u32).max(1);
473+
let nh = ((height as f64 * ratio).ceil() as u32).max(1);
474+
(nw, nh, width as f64 / nw as f64, height as f64 / nh as f64)
475+
} else {
476+
(width, height, 1.0, 1.0)
477+
};
478+
479+
let mut rgba = Vec::with_capacity((out_w * out_h * 4) as usize);
480+
for dy in 0..out_h {
481+
for dx in 0..out_w {
482+
let src_x = (dx as f64 * step_x) as usize;
483+
let src_y = (dy as f64 * step_y) as usize;
484+
let idx = (src_y * width as usize + src_x) * components;
384485
match components {
385486
1 => {
386487
let g = raw_pixels[idx];
387-
out.extend_from_slice(&[g, g, g, 255]);
388-
idx += 1;
488+
rgba.extend_from_slice(&[g, g, g, 255]);
389489
}
390490
3 => {
391-
out.extend_from_slice(&[raw_pixels[idx], raw_pixels[idx+1], raw_pixels[idx+2], 255]);
392-
idx += 3;
491+
rgba.extend_from_slice(&[raw_pixels[idx], raw_pixels[idx+1], raw_pixels[idx+2], 255]);
393492
}
394493
4 => {
395494
let c = raw_pixels[idx] as f32 / 255.0;
396495
let m = raw_pixels[idx+1] as f32 / 255.0;
397496
let y = raw_pixels[idx+2] as f32 / 255.0;
398497
let k = raw_pixels[idx+3] as f32 / 255.0;
399-
out.extend_from_slice(&[
498+
rgba.extend_from_slice(&[
400499
(255.0 * (1.0 - c) * (1.0 - k)) as u8,
401500
(255.0 * (1.0 - m) * (1.0 - k)) as u8,
402501
(255.0 * (1.0 - y) * (1.0 - k)) as u8,
403502
255,
404503
]);
405-
idx += 4;
406-
}
407-
_ => { out.extend_from_slice(&[0, 0, 0, 255]); idx += components; }
408-
}
409-
}
410-
(width, height, out)
411-
};
412-
413-
// Downsample if image exceeds the pixel budget (fast box filter).
414-
// For thumbnails this turns a 5000×5000 decode into a 200×200 draw.
415-
if max_decode_pixels > 0 && img_w * img_h > max_decode_pixels {
416-
let ratio = (max_decode_pixels as f64 / (img_w as f64 * img_h as f64)).sqrt();
417-
let new_w = ((img_w as f64 * ratio).ceil() as u32).max(1);
418-
let new_h = ((img_h as f64 * ratio).ceil() as u32).max(1);
419-
let sx = img_w as f64 / new_w as f64;
420-
let sy = img_h as f64 / new_h as f64;
421-
let mut small = Vec::with_capacity((new_w * new_h * 4) as usize);
422-
for dy in 0..new_h {
423-
for dx in 0..new_w {
424-
let src_x = (dx as f64 * sx) as usize;
425-
let src_y = (dy as f64 * sy) as usize;
426-
let src_idx = (src_y * img_w as usize + src_x) * 4;
427-
if src_idx + 3 < rgba.len() {
428-
small.extend_from_slice(&rgba[src_idx..src_idx + 4]);
429-
} else {
430-
small.extend_from_slice(&[0, 0, 0, 255]);
431504
}
505+
_ => { rgba.extend_from_slice(&[0, 0, 0, 255]); }
432506
}
433507
}
434-
img_w = new_w;
435-
img_h = new_h;
436-
rgba = small;
437508
}
438-
439-
state.save();
440-
state.concat_matrix(1.0, 0.0, 0.0, -1.0, 0.0, 1.0);
441-
renderer.draw_image(img_w, img_h, &rgba, &state.current);
442-
state.restore();
509+
Some((out_w, out_h, rgba))
443510
}
444511

445512
pub fn extract_commands(

0 commit comments

Comments
 (0)