1#[cfg(not(feature = "std"))]
39use alloc::{vec, vec::Vec};
40
41use crate::djvu_document::DjVuPage;
42use crate::iw44_new::Iw44Image;
43use crate::jb2_new;
44use crate::pixmap::{GrayPixmap, Pixmap};
45
46#[derive(Debug, thiserror::Error)]
50pub enum RenderError {
51 #[error("IW44 decode error: {0}")]
53 Iw44(#[from] crate::error::Iw44Error),
54
55 #[error("JB2 decode error: {0}")]
57 Jb2(#[from] crate::error::Jb2Error),
58
59 #[error("buffer too small: need {need} bytes, got {got}")]
61 BufTooSmall { need: usize, got: usize },
62
63 #[error("invalid render dimensions: {width}x{height}")]
65 InvalidDimensions { width: u32, height: u32 },
66
67 #[error("chunk index {chunk_n} out of range (max {max})")]
69 ChunkOutOfRange { chunk_n: usize, max: usize },
70
71 #[error("BZZ error: {0}")]
73 Bzz(#[from] crate::error::BzzError),
74
75 #[cfg(feature = "std")]
77 #[error("JPEG decode error: {0}")]
78 Jpeg(String),
79
80 #[error("document error: {0}")]
82 Doc(#[from] crate::djvu_document::DocError),
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
93pub enum UserRotation {
94 #[default]
96 None,
97 Cw90,
99 Rot180,
101 Ccw90,
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
109pub enum Resampling {
110 #[default]
112 Bilinear,
113 Lanczos3,
120}
121
122#[derive(Debug, Clone, PartialEq)]
141pub struct RenderOptions {
142 pub width: u32,
144 pub height: u32,
146 pub scale: f32,
148 pub bold: u8,
150 pub aa: bool,
152 pub rotation: UserRotation,
154 pub permissive: bool,
166 pub resampling: Resampling,
170}
171
172impl Default for RenderOptions {
173 fn default() -> Self {
174 RenderOptions {
175 width: 0,
176 height: 0,
177 scale: 1.0,
178 bold: 0,
179 aa: false,
180 rotation: UserRotation::None,
181 permissive: false,
182 resampling: Resampling::Bilinear,
183 }
184 }
185}
186
187impl RenderOptions {
188 pub fn fit_to_width(page: &crate::djvu_document::DjVuPage, width: u32) -> Self {
191 let (dw, dh) = display_dimensions(page);
192 let height = if dw == 0 {
193 width
194 } else {
195 ((dh as f64 * width as f64) / dw as f64).round() as u32
196 }
197 .max(1);
198 let scale = width as f32 / dw.max(1) as f32;
199 RenderOptions {
200 width,
201 height,
202 scale,
203 ..Default::default()
204 }
205 }
206
207 pub fn fit_to_height(page: &crate::djvu_document::DjVuPage, height: u32) -> Self {
210 let (dw, dh) = display_dimensions(page);
211 let width = if dh == 0 {
212 height
213 } else {
214 ((dw as f64 * height as f64) / dh as f64).round() as u32
215 }
216 .max(1);
217 let scale = height as f32 / dh.max(1) as f32;
218 RenderOptions {
219 width,
220 height,
221 scale,
222 ..Default::default()
223 }
224 }
225
226 pub fn fit_to_box(
229 page: &crate::djvu_document::DjVuPage,
230 max_width: u32,
231 max_height: u32,
232 ) -> Self {
233 let (dw, dh) = display_dimensions(page);
234 if dw == 0 || dh == 0 {
235 return RenderOptions {
236 width: max_width.max(1),
237 height: max_height.max(1),
238 scale: 1.0,
239 ..Default::default()
240 };
241 }
242 let scale_w = max_width as f64 / dw as f64;
243 let scale_h = max_height as f64 / dh as f64;
244 let scale = if scale_w < scale_h { scale_w } else { scale_h };
245 let width = (dw as f64 * scale).round() as u32;
246 let height = (dh as f64 * scale).round() as u32;
247 RenderOptions {
248 width: width.max(1),
249 height: height.max(1),
250 scale: scale as f32,
251 ..Default::default()
252 }
253 }
254}
255
256fn display_dimensions(page: &crate::djvu_document::DjVuPage) -> (u32, u32) {
258 let w = page.width() as u32;
259 let h = page.height() as u32;
260 match page.rotation() {
261 crate::info::Rotation::Cw90 | crate::info::Rotation::Ccw90 => (h, w),
262 _ => (w, h),
263 }
264}
265
266fn build_gamma_lut(gamma: f32) -> [u8; 256] {
277 let mut lut = [0u8; 256];
278 if gamma <= 0.0 || !gamma.is_finite() || (gamma - 1.0).abs() < 1e-4 {
279 for (i, v) in lut.iter_mut().enumerate() {
281 *v = i as u8;
282 }
283 return lut;
284 }
285 let inv_gamma = 1.0 / gamma;
286 for (i, v) in lut.iter_mut().enumerate() {
287 let linear = i as f32 / 255.0;
288 let corrected = linear.powf(inv_gamma);
289 *v = (corrected * 255.0 + 0.5) as u8;
290 }
291 lut
292}
293
294const FRACBITS: u32 = 4;
298const FRAC: u32 = 1 << FRACBITS;
299const FRAC_MASK: u32 = FRAC - 1;
300
301#[inline]
306fn sample_bilinear(pm: &Pixmap, fx: u32, fy: u32) -> (u8, u8, u8) {
307 let x0 = (fx >> FRACBITS).min(pm.width.saturating_sub(1));
308 let y0 = (fy >> FRACBITS).min(pm.height.saturating_sub(1));
309 let x1 = (x0 + 1).min(pm.width.saturating_sub(1));
310 let y1 = (y0 + 1).min(pm.height.saturating_sub(1));
311
312 let tx = fx & FRAC_MASK; let ty = fy & FRAC_MASK;
314
315 let (r00, g00, b00) = pm.get_rgb(x0, y0);
316 let (r10, g10, b10) = pm.get_rgb(x1, y0);
317 let (r01, g01, b01) = pm.get_rgb(x0, y1);
318 let (r11, g11, b11) = pm.get_rgb(x1, y1);
319
320 let lerp = |a: u8, b: u8, c: u8, d: u8| -> u8 {
321 let top = a as u32 * (FRAC - tx) + b as u32 * tx;
322 let bot = c as u32 * (FRAC - tx) + d as u32 * tx;
323 let v = (top * (FRAC - ty) + bot * ty) >> (2 * FRACBITS);
324 v.min(255) as u8
325 };
326
327 (
328 lerp(r00, r10, r01, r11),
329 lerp(g00, g10, g01, g11),
330 lerp(b00, b10, b01, b11),
331 )
332}
333
334#[inline]
341fn sample_area_avg(pm: &Pixmap, fx: u32, fy: u32, fx_step: u32, fy_step: u32) -> (u8, u8, u8) {
342 let x0 = (fx >> FRACBITS).min(pm.width.saturating_sub(1));
343 let y0 = (fy >> FRACBITS).min(pm.height.saturating_sub(1));
344 let x1 = ((fx + fx_step) >> FRACBITS).min(pm.width.saturating_sub(1));
345 let y1 = ((fy + fy_step) >> FRACBITS).min(pm.height.saturating_sub(1));
346
347 if x0 == x1 && y0 == y1 {
349 return pm.get_rgb(x0, y0);
350 }
351
352 let mut r_sum = 0u32;
353 let mut g_sum = 0u32;
354 let mut b_sum = 0u32;
355
356 let pw = pm.width as usize;
357 let cols = (x1 - x0 + 1) as usize;
358 let rows = (y1 - y0 + 1) as usize;
359
360 for sy in y0..=y1 {
362 let row_off = (sy as usize * pw + x0 as usize) * 4;
363 for c in 0..cols {
364 let off = row_off + c * 4;
365 if let Some(px) = pm.data.get(off..off + 3) {
366 r_sum += px[0] as u32;
367 g_sum += px[1] as u32;
368 b_sum += px[2] as u32;
369 }
370 }
371 }
372
373 let count = (rows * cols) as u32;
374 if count == 0 {
375 return (255, 255, 255);
376 }
377
378 (
379 ((r_sum + count / 2) / count) as u8,
380 ((g_sum + count / 2) / count) as u8,
381 ((b_sum + count / 2) / count) as u8,
382 )
383}
384
385#[inline]
391fn lanczos3_kernel(x: f32) -> f32 {
392 let ax = x.abs();
393 if ax >= 3.0 {
394 return 0.0;
395 }
396 if ax < 1e-6 {
397 return 1.0;
398 }
399 let pi_x = core::f32::consts::PI * ax;
400 let sinc_x = pi_x.sin() / pi_x;
401 let pi_x3 = pi_x / 3.0;
402 let sinc_x3 = pi_x3.sin() / pi_x3;
403 sinc_x * sinc_x3
404}
405
406pub fn scale_lanczos3(src: &Pixmap, dst_w: u32, dst_h: u32) -> Pixmap {
414 let src_w = src.width;
415 let src_h = src.height;
416
417 if src_w == dst_w && src_h == dst_h {
419 return src.clone();
420 }
421 if dst_w == 0 || dst_h == 0 {
422 return Pixmap::white(dst_w.max(1), dst_h.max(1));
423 }
424
425 let h_scale = src_w as f32 / dst_w as f32;
429 let h_support = (3.0_f32 * h_scale.max(1.0)).ceil() as i32; let mut mid = Pixmap::new(dst_w, src_h, 255, 255, 255, 255);
432 for oy in 0..src_h {
433 for ox in 0..dst_w {
434 let cx = (ox as f32 + 0.5) * h_scale - 0.5;
436 let x0 = (cx.floor() as i32 - h_support + 1).max(0);
437 let x1 = (cx.floor() as i32 + h_support).min(src_w as i32 - 1);
438
439 let mut r = 0.0_f32;
440 let mut g = 0.0_f32;
441 let mut b = 0.0_f32;
442 let mut w_sum = 0.0_f32;
443
444 for sx in x0..=x1 {
445 let w = lanczos3_kernel((sx as f32 - cx) / h_scale.max(1.0));
446 let (pr, pg, pb) = src.get_rgb(sx as u32, oy);
447 r += pr as f32 * w;
448 g += pg as f32 * w;
449 b += pb as f32 * w;
450 w_sum += w;
451 }
452
453 let norm = if w_sum.abs() > 1e-6 { 1.0 / w_sum } else { 1.0 };
454 mid.set_rgb(
455 ox,
456 oy,
457 (r * norm).round().clamp(0.0, 255.0) as u8,
458 (g * norm).round().clamp(0.0, 255.0) as u8,
459 (b * norm).round().clamp(0.0, 255.0) as u8,
460 );
461 }
462 }
463
464 let v_scale = src_h as f32 / dst_h as f32;
466 let v_support = (3.0_f32 * v_scale.max(1.0)).ceil() as i32;
467
468 let mut out = Pixmap::new(dst_w, dst_h, 255, 255, 255, 255);
469 for oy in 0..dst_h {
470 let cy = (oy as f32 + 0.5) * v_scale - 0.5;
471 let y0 = (cy.floor() as i32 - v_support + 1).max(0);
472 let y1 = (cy.floor() as i32 + v_support).min(src_h as i32 - 1);
473
474 for ox in 0..dst_w {
475 let mut r = 0.0_f32;
476 let mut g = 0.0_f32;
477 let mut b = 0.0_f32;
478 let mut w_sum = 0.0_f32;
479
480 for sy in y0..=y1 {
481 let w = lanczos3_kernel((sy as f32 - cy) / v_scale.max(1.0));
482 let (pr, pg, pb) = mid.get_rgb(ox, sy as u32);
483 r += pr as f32 * w;
484 g += pg as f32 * w;
485 b += pb as f32 * w;
486 w_sum += w;
487 }
488
489 let norm = if w_sum.abs() > 1e-6 { 1.0 / w_sum } else { 1.0 };
490 out.set_rgb(
491 ox,
492 oy,
493 (r * norm).round().clamp(0.0, 255.0) as u8,
494 (g * norm).round().clamp(0.0, 255.0) as u8,
495 (b * norm).round().clamp(0.0, 255.0) as u8,
496 );
497 }
498 }
499
500 out
501}
502
503#[inline]
506fn mask_box_any(
507 mask: &crate::bitmap::Bitmap,
508 fx: u32,
509 fy: u32,
510 fx_step: u32,
511 fy_step: u32,
512) -> bool {
513 let x0 = (fx >> FRACBITS).min(mask.width.saturating_sub(1));
514 let y0 = (fy >> FRACBITS).min(mask.height.saturating_sub(1));
515 let x1 = ((fx + fx_step) >> FRACBITS).min(mask.width.saturating_sub(1));
516 let y1 = ((fy + fy_step) >> FRACBITS).min(mask.height.saturating_sub(1));
517
518 for sy in y0..=y1 {
519 for sx in x0..=x1 {
520 if mask.get(sx, sy) {
521 return true;
522 }
523 }
524 }
525 false
526}
527
528#[inline]
530fn mask_box_center_fg(
531 mask: &crate::bitmap::Bitmap,
532 fx: u32,
533 fy: u32,
534 fx_step: u32,
535 fy_step: u32,
536) -> (u32, u32) {
537 let cx = (fx + fx_step / 2) >> FRACBITS;
539 let cy = (fy + fy_step / 2) >> FRACBITS;
540 (
541 cx.min(mask.width.saturating_sub(1)),
542 cy.min(mask.height.saturating_sub(1)),
543 )
544}
545
546fn aa_downscale(pm: &Pixmap) -> Pixmap {
552 let out_w = (pm.width / 2).max(1);
553 let out_h = (pm.height / 2).max(1);
554 let mut out = Pixmap::white(out_w, out_h);
555 for y in 0..out_h {
556 for x in 0..out_w {
557 let sx = (x * 2).min(pm.width.saturating_sub(1));
558 let sy = (y * 2).min(pm.height.saturating_sub(1));
559 let sx1 = (sx + 1).min(pm.width.saturating_sub(1));
560 let sy1 = (sy + 1).min(pm.height.saturating_sub(1));
561
562 let (r00, g00, b00) = pm.get_rgb(sx, sy);
563 let (r10, g10, b10) = pm.get_rgb(sx1, sy);
564 let (r01, g01, b01) = pm.get_rgb(sx, sy1);
565 let (r11, g11, b11) = pm.get_rgb(sx1, sy1);
566
567 let avg = |a: u8, b: u8, c: u8, d: u8| -> u8 {
568 ((a as u32 + b as u32 + c as u32 + d as u32 + 2) / 4) as u8
569 };
570 out.set_rgb(
571 x,
572 y,
573 avg(r00, r10, r01, r11),
574 avg(g00, g10, g01, g11),
575 avg(b00, b10, b01, b11),
576 );
577 }
578 }
579 out
580}
581
582fn rotation_to_steps(r: crate::info::Rotation) -> u8 {
586 use crate::info::Rotation;
587 match r {
588 Rotation::None => 0,
589 Rotation::Cw90 => 1,
590 Rotation::Rot180 => 2,
591 Rotation::Ccw90 => 3,
592 }
593}
594
595fn user_rotation_to_steps(r: UserRotation) -> u8 {
597 match r {
598 UserRotation::None => 0,
599 UserRotation::Cw90 => 1,
600 UserRotation::Rot180 => 2,
601 UserRotation::Ccw90 => 3,
602 }
603}
604
605fn combine_rotations(info: crate::info::Rotation, user: UserRotation) -> crate::info::Rotation {
608 use crate::info::Rotation;
609 let steps = (rotation_to_steps(info) + user_rotation_to_steps(user)) % 4;
610 match steps {
611 0 => Rotation::None,
612 1 => Rotation::Cw90,
613 2 => Rotation::Rot180,
614 3 => Rotation::Ccw90,
615 _ => unreachable!(),
616 }
617}
618
619fn rotate_pixmap(src: Pixmap, rotation: crate::info::Rotation) -> Pixmap {
623 use crate::info::Rotation;
624 match rotation {
625 Rotation::None => src,
626 Rotation::Cw90 => {
627 let w = src.height;
628 let h = src.width;
629 let mut out = Pixmap::white(w, h);
630 for y in 0..src.height {
631 for x in 0..src.width {
632 let (r, g, b) = src.get_rgb(x, y);
633 out.set_rgb(src.height - 1 - y, x, r, g, b);
634 }
635 }
636 out
637 }
638 Rotation::Rot180 => {
639 let mut out = Pixmap::white(src.width, src.height);
640 for y in 0..src.height {
641 for x in 0..src.width {
642 let (r, g, b) = src.get_rgb(x, y);
643 out.set_rgb(src.width - 1 - x, src.height - 1 - y, r, g, b);
644 }
645 }
646 out
647 }
648 Rotation::Ccw90 => {
649 let w = src.height;
650 let h = src.width;
651 let mut out = Pixmap::white(w, h);
652 for y in 0..src.height {
653 for x in 0..src.width {
654 let (r, g, b) = src.get_rgb(x, y);
655 out.set_rgb(y, src.width - 1 - x, r, g, b);
656 }
657 }
658 out
659 }
660 }
661}
662
663#[derive(Debug, Clone, Copy, Default)]
667struct PaletteColor {
668 r: u8,
669 g: u8,
670 b: u8,
671}
672
673struct FgbzPalette {
675 colors: Vec<PaletteColor>,
676 indices: Vec<i16>,
679}
680
681fn parse_fgbz(data: &[u8]) -> Result<FgbzPalette, RenderError> {
689 if data.len() < 3 {
690 return Ok(FgbzPalette {
691 colors: vec![],
692 indices: vec![],
693 });
694 }
695
696 let version = data[0];
697 let has_indices = (version & 0x80) != 0;
698
699 let n_colors =
700 u16::from_be_bytes([*data.get(1).unwrap_or(&0), *data.get(2).unwrap_or(&0)]) as usize;
701
702 if n_colors == 0 {
703 return Ok(FgbzPalette {
704 colors: vec![],
705 indices: vec![],
706 });
707 }
708
709 let color_bytes = n_colors * 3;
711 let color_data = data.get(3..).unwrap_or(&[]);
712
713 let mut colors = Vec::with_capacity(n_colors);
714 for i in 0..n_colors {
715 let base = i * 3;
716 if base + 2 < color_data.len().min(color_bytes) {
717 colors.push(PaletteColor {
718 r: color_data[base + 2],
719 g: color_data[base + 1],
720 b: color_data[base],
721 });
722 } else {
723 colors.push(PaletteColor { r: 0, g: 0, b: 0 });
724 }
725 }
726
727 let mut indices = Vec::new();
729 if has_indices {
730 let idx_start = 3 + color_bytes;
731 if idx_start + 3 <= data.len() {
732 let num_indices = ((data[idx_start] as u32) << 16)
733 | ((data[idx_start + 1] as u32) << 8)
734 | (data[idx_start + 2] as u32);
735
736 let bzz_data = data.get(idx_start + 3..).unwrap_or(&[]);
737 let decoded = crate::bzz_new::bzz_decode(bzz_data)?;
738
739 let n = num_indices as usize;
740 indices.reserve(n);
741 for i in 0..n {
742 if i * 2 + 1 < decoded.len() {
743 indices.push(i16::from_be_bytes([decoded[i * 2], decoded[i * 2 + 1]]));
744 }
745 }
746 }
747 }
748
749 Ok(FgbzPalette { colors, indices })
750}
751
752fn best_iw44_subsample(scale: f32) -> u32 {
765 if scale <= 0.0 || !scale.is_finite() || scale >= 1.0 {
766 return 1;
767 }
768 let max_sub = (1.0_f32 / scale) as u32; let mut s = 1u32;
771 while s * 2 <= max_sub {
772 s *= 2;
773 }
774 s.min(8)
775}
776
777fn decode_background_chunks(
788 page: &DjVuPage,
789 max_chunks: usize,
790 subsample: u32,
791) -> Result<Option<Pixmap>, RenderError> {
792 if max_chunks == usize::MAX {
794 let bg44_chunks = page.bg44_chunks();
795 if !bg44_chunks.is_empty() {
796 let img = page
799 .decoded_bg44()
800 .ok_or(RenderError::Iw44(crate::Iw44Error::Invalid))?;
801 return Ok(Some(img.to_rgb_subsample(subsample)?));
802 }
803 } else {
805 let bg44_chunks = page.bg44_chunks();
806 if !bg44_chunks.is_empty() {
807 let mut img = Iw44Image::new();
808 for chunk_data in bg44_chunks.iter().take(max_chunks) {
809 img.decode_chunk(chunk_data)?;
810 }
811 return Ok(Some(img.to_rgb_subsample(subsample)?));
812 }
813 }
814
815 #[cfg(feature = "std")]
817 if let Some(pm) = decode_bgjp(page)? {
818 return Ok(Some(pm));
819 }
820
821 Ok(None)
822}
823
824fn decode_background_chunks_permissive(
830 page: &DjVuPage,
831 max_chunks: usize,
832 subsample: u32,
833) -> Option<Pixmap> {
834 let bg44_chunks = page.bg44_chunks();
835 if !bg44_chunks.is_empty() {
836 let mut img = Iw44Image::new();
837 for chunk_data in bg44_chunks.iter().take(max_chunks) {
838 if img.decode_chunk(chunk_data).is_err() {
839 break; }
841 }
842 return img.to_rgb_subsample(subsample).ok();
843 }
844
845 #[cfg(feature = "std")]
847 {
848 decode_bgjp(page).ok().flatten()
849 }
850 #[cfg(not(feature = "std"))]
851 None
852}
853
854fn decode_mask(page: &DjVuPage) -> Result<Option<crate::bitmap::Bitmap>, RenderError> {
856 let sjbz = match page.find_chunk(b"Sjbz") {
857 Some(data) => data,
858 None => return Ok(None),
859 };
860
861 let dict = match page.find_chunk(b"Djbz") {
862 Some(djbz) => Some(jb2_new::decode_dict(djbz, None)?),
863 None => None,
864 };
865
866 let bm = jb2_new::decode(sjbz, dict.as_ref())?;
867 Ok(Some(bm))
868}
869
870fn decode_mask_indexed(
872 page: &DjVuPage,
873) -> Result<Option<(crate::bitmap::Bitmap, Vec<i32>)>, RenderError> {
874 let sjbz = match page.find_chunk(b"Sjbz") {
875 Some(data) => data,
876 None => return Ok(None),
877 };
878
879 let dict = match page.find_chunk(b"Djbz") {
880 Some(djbz) => Some(jb2_new::decode_dict(djbz, None)?),
881 None => None,
882 };
883
884 let (bm, blit_map) = jb2_new::decode_indexed(sjbz, dict.as_ref())?;
885 Ok(Some((bm, blit_map)))
886}
887
888fn decode_fg_palette_full(page: &DjVuPage) -> Result<Option<FgbzPalette>, RenderError> {
890 let fgbz = match page.find_chunk(b"FGbz") {
891 Some(data) => data,
892 None => return Ok(None),
893 };
894
895 let pal = parse_fgbz(fgbz)?;
896 if pal.colors.is_empty() {
897 return Ok(None);
898 }
899 Ok(Some(pal))
900}
901
902fn decode_fg44(page: &DjVuPage) -> Result<Option<Pixmap>, RenderError> {
906 let fg44_chunks = page.fg44_chunks();
907 if !fg44_chunks.is_empty() {
908 let mut img = Iw44Image::new();
909 for chunk_data in &fg44_chunks {
910 img.decode_chunk(chunk_data)?;
911 }
912 return Ok(Some(img.to_rgb()?));
913 }
914
915 #[cfg(feature = "std")]
917 if let Some(pm) = decode_fgjp(page)? {
918 return Ok(Some(pm));
919 }
920
921 Ok(None)
922}
923
924#[cfg(feature = "std")]
929fn decode_bgjp(page: &DjVuPage) -> Result<Option<Pixmap>, RenderError> {
930 let data = match page.find_chunk(b"BGjp") {
931 Some(d) => d,
932 None => return Ok(None),
933 };
934 Ok(Some(decode_jpeg_to_pixmap(data)?))
935}
936
937#[cfg(feature = "std")]
942fn decode_fgjp(page: &DjVuPage) -> Result<Option<Pixmap>, RenderError> {
943 let data = match page.find_chunk(b"FGjp") {
944 Some(d) => d,
945 None => return Ok(None),
946 };
947 Ok(Some(decode_jpeg_to_pixmap(data)?))
948}
949
950#[cfg(feature = "std")]
955fn decode_jpeg_to_pixmap(data: &[u8]) -> Result<Pixmap, RenderError> {
956 use zune_jpeg::JpegDecoder;
957 use zune_jpeg::zune_core::bytestream::ZCursor;
958
959 let cursor = ZCursor::new(data);
960 let mut decoder = JpegDecoder::new(cursor);
961 decoder
962 .decode_headers()
963 .map_err(|e| RenderError::Jpeg(format!("{e:?}")))?;
964 let info = decoder
965 .info()
966 .ok_or_else(|| RenderError::Jpeg("missing image info after decode_headers".to_owned()))?;
967 let w = info.width as usize;
968 let h = info.height as usize;
969 let rgb = decoder
970 .decode()
971 .map_err(|e| RenderError::Jpeg(format!("{e:?}")))?;
972
973 let mut rgba = vec![0u8; w * h * 4];
975 for (i, pixel) in rgba.chunks_exact_mut(4).enumerate() {
976 let src = i * 3;
977 pixel[0] = *rgb.get(src).unwrap_or(&0);
978 pixel[1] = *rgb.get(src + 1).unwrap_or(&0);
979 pixel[2] = *rgb.get(src + 2).unwrap_or(&0);
980 pixel[3] = 255;
981 }
982 Ok(Pixmap {
983 width: w as u32,
984 height: h as u32,
985 data: rgba,
986 })
987}
988
989#[derive(Debug, Clone, Copy, PartialEq, Eq)]
995pub struct RenderRect {
996 pub x: u32,
998 pub y: u32,
1000 pub width: u32,
1002 pub height: u32,
1004}
1005
1006struct CompositeContext<'a> {
1008 opts: &'a RenderOptions,
1009 page_w: u32,
1010 page_h: u32,
1011 bg: Option<&'a Pixmap>,
1012 bg_subsample: u32,
1015 mask: Option<&'a crate::bitmap::Bitmap>,
1016 fg_palette: Option<&'a FgbzPalette>,
1017 blit_map: Option<&'a [i32]>,
1019 fg44: Option<&'a Pixmap>,
1020 gamma_lut: &'a [u8; 256],
1021 offset_x: u32,
1023 offset_y: u32,
1025 out_w: u32,
1027 out_h: u32,
1029}
1030
1031#[inline]
1037fn lookup_palette_color(
1038 pal: &FgbzPalette,
1039 blit_map: Option<&[i32]>,
1040 mask: Option<&crate::bitmap::Bitmap>,
1041 px: u32,
1042 py: u32,
1043) -> PaletteColor {
1044 if let Some(bm) = blit_map
1045 && let Some(m) = mask
1046 {
1047 let mi = py as usize * m.width as usize + px as usize;
1048 if mi < bm.len() {
1049 let blit_idx = bm[mi];
1050 if blit_idx >= 0 {
1051 if !pal.indices.is_empty() {
1052 let bi = blit_idx as usize;
1054 if bi < pal.indices.len() {
1055 let ci = pal.indices[bi] as usize;
1056 if ci < pal.colors.len() {
1057 return pal.colors[ci];
1058 }
1059 }
1060 } else {
1061 let ci = blit_idx as usize;
1063 if ci < pal.colors.len() {
1064 return pal.colors[ci];
1065 }
1066 }
1067 }
1068 }
1069 }
1070 pal.colors.first().copied().unwrap_or_default()
1072}
1073
1074#[allow(clippy::too_many_arguments)]
1077fn composite_loop_bilinear(
1078 ctx: &CompositeContext<'_>,
1079 buf: &mut [u8],
1080 w: u32,
1081 h: u32,
1082 page_w: u32,
1083 page_h: u32,
1084 fx_step: u32,
1085 fy_step: u32,
1086) {
1087 for oy in 0..h {
1088 let fy = (oy + ctx.offset_y) * fy_step;
1089 let py = (fy >> FRACBITS).min(page_h.saturating_sub(1));
1090 let row_base = oy as usize * w as usize;
1091
1092 for ox in 0..w {
1093 let fx = (ox + ctx.offset_x) * fx_step;
1094 let px = (fx >> FRACBITS).min(page_w.saturating_sub(1));
1095
1096 let is_fg = ctx
1097 .mask
1098 .is_some_and(|m| px < m.width && py < m.height && m.get(px, py));
1099
1100 let (r, g, b) = if is_fg {
1101 if let Some(pal) = ctx.fg_palette {
1102 let color = lookup_palette_color(pal, ctx.blit_map, ctx.mask, px, py);
1103 (color.r, color.g, color.b)
1104 } else if let Some(fg) = ctx.fg44 {
1105 sample_bilinear(fg, fx, fy)
1106 } else {
1107 (0, 0, 0)
1108 }
1109 } else if let Some(bg) = ctx.bg {
1110 let s = ctx.bg_subsample;
1111 sample_bilinear(bg, fx / s, fy / s)
1112 } else {
1113 (255, 255, 255)
1114 };
1115
1116 let r = ctx.gamma_lut[r as usize];
1117 let g = ctx.gamma_lut[g as usize];
1118 let b = ctx.gamma_lut[b as usize];
1119
1120 let base = (row_base + ox as usize) * 4;
1121 if let Some(pixel) = buf.get_mut(base..base + 4) {
1122 pixel[0] = r;
1123 pixel[1] = g;
1124 pixel[2] = b;
1125 pixel[3] = 255;
1126 }
1127 }
1128 }
1129}
1130
1131#[allow(clippy::too_many_arguments)]
1134fn composite_loop_area_avg(
1135 ctx: &CompositeContext<'_>,
1136 buf: &mut [u8],
1137 w: u32,
1138 h: u32,
1139 _page_w: u32,
1140 _page_h: u32,
1141 fx_step: u32,
1142 fy_step: u32,
1143) {
1144 for oy in 0..h {
1145 let fy = (oy + ctx.offset_y) * fy_step;
1146 let row_base = oy as usize * w as usize;
1147
1148 for ox in 0..w {
1149 let fx = (ox + ctx.offset_x) * fx_step;
1150
1151 let is_fg = ctx
1152 .mask
1153 .is_some_and(|m| mask_box_any(m, fx, fy, fx_step, fy_step));
1154
1155 let (r, g, b) = if is_fg {
1156 if let Some(pal) = ctx.fg_palette {
1157 let (cx, cy) = mask_box_center_fg(ctx.mask.unwrap(), fx, fy, fx_step, fy_step);
1158 let color = lookup_palette_color(pal, ctx.blit_map, ctx.mask, cx, cy);
1159 (color.r, color.g, color.b)
1160 } else if let Some(fg) = ctx.fg44 {
1161 sample_area_avg(fg, fx, fy, fx_step, fy_step)
1162 } else {
1163 (0, 0, 0)
1164 }
1165 } else if let Some(bg) = ctx.bg {
1166 let s = ctx.bg_subsample;
1167 sample_area_avg(bg, fx / s, fy / s, fx_step / s, fy_step / s)
1168 } else {
1169 (255, 255, 255)
1170 };
1171
1172 let r = ctx.gamma_lut[r as usize];
1173 let g = ctx.gamma_lut[g as usize];
1174 let b = ctx.gamma_lut[b as usize];
1175
1176 let base = (row_base + ox as usize) * 4;
1177 if let Some(pixel) = buf.get_mut(base..base + 4) {
1178 pixel[0] = r;
1179 pixel[1] = g;
1180 pixel[2] = b;
1181 pixel[3] = 255;
1182 }
1183 }
1184 }
1185}
1186
1187fn composite_into(ctx: &CompositeContext<'_>, buf: &mut [u8]) -> Result<(), RenderError> {
1193 let w = ctx.out_w;
1194 let h = ctx.out_h;
1195 let full_w = ctx.opts.width;
1196 let full_h = ctx.opts.height;
1197 let page_w = ctx.page_w;
1198 let page_h = ctx.page_h;
1199
1200 let fx_step = ((page_w as u64 * FRAC as u64) / full_w.max(1) as u64) as u32;
1202 let fy_step = ((page_h as u64 * FRAC as u64) / full_h.max(1) as u64) as u32;
1203
1204 if fx_step > FRAC || fy_step > FRAC {
1206 composite_loop_area_avg(ctx, buf, w, h, page_w, page_h, fx_step, fy_step);
1207 } else {
1208 composite_loop_bilinear(ctx, buf, w, h, page_w, page_h, fx_step, fy_step);
1209 }
1210
1211 Ok(())
1212}
1213
1214pub fn render_into(
1228 page: &DjVuPage,
1229 opts: &RenderOptions,
1230 buf: &mut [u8],
1231) -> Result<(), RenderError> {
1232 let w = opts.width;
1233 let h = opts.height;
1234
1235 if w == 0 || h == 0 {
1236 return Err(RenderError::InvalidDimensions {
1237 width: w,
1238 height: h,
1239 });
1240 }
1241
1242 let need = (w as usize)
1243 .checked_mul(h as usize)
1244 .and_then(|n| n.checked_mul(4))
1245 .unwrap_or(usize::MAX);
1246
1247 if buf.len() < need {
1248 return Err(RenderError::BufTooSmall {
1249 need,
1250 got: buf.len(),
1251 });
1252 }
1253
1254 let gamma_lut = build_gamma_lut(page.gamma());
1255
1256 let bg_subsample = best_iw44_subsample(opts.scale);
1258 let bg = decode_background_chunks(page, usize::MAX, bg_subsample)?;
1259 let fg_palette = decode_fg_palette_full(page)?;
1260
1261 let (mask, blit_map) = if fg_palette.is_some() {
1263 match decode_mask_indexed(page)? {
1264 Some((bm, bm_map)) => (Some(bm), Some(bm_map)),
1265 None => (None, None),
1266 }
1267 } else {
1268 (decode_mask(page)?, None)
1269 };
1270
1271 let mask = if opts.bold > 0 {
1272 mask.map(|m| {
1273 let mut dilated = m;
1274 for _ in 0..opts.bold {
1275 dilated = dilated.dilate();
1276 }
1277 dilated
1278 })
1279 } else {
1280 mask
1281 };
1282 let fg44 = decode_fg44(page)?;
1283
1284 let ctx = CompositeContext {
1285 opts,
1286 page_w: page.width() as u32,
1287 page_h: page.height() as u32,
1288 bg: bg.as_ref(),
1289 bg_subsample,
1290 mask: mask.as_ref(),
1291 fg_palette: fg_palette.as_ref(),
1292 blit_map: blit_map.as_deref(),
1293 fg44: fg44.as_ref(),
1294 gamma_lut: &gamma_lut,
1295 offset_x: 0,
1296 offset_y: 0,
1297 out_w: w,
1298 out_h: h,
1299 };
1300 composite_into(&ctx, buf)?;
1301
1302 Ok(())
1303}
1304
1305pub fn render_pixmap(page: &DjVuPage, opts: &RenderOptions) -> Result<Pixmap, RenderError> {
1307 let w = opts.width;
1308 let h = opts.height;
1309
1310 if w == 0 || h == 0 {
1311 return Err(RenderError::InvalidDimensions {
1312 width: w,
1313 height: h,
1314 });
1315 }
1316
1317 let gamma_lut = build_gamma_lut(page.gamma());
1318
1319 let bg;
1321 let fg_palette;
1322 let mask;
1323 let blit_map;
1324 let fg44;
1325
1326 let bg_subsample = best_iw44_subsample(opts.scale);
1327
1328 if opts.permissive {
1329 bg = decode_background_chunks_permissive(page, usize::MAX, bg_subsample);
1330 fg_palette = decode_fg_palette_full(page).ok().flatten();
1331 let indexed = if fg_palette.is_some() {
1332 decode_mask_indexed(page).ok().flatten()
1333 } else {
1334 None
1335 };
1336 if let Some((bm, bm_map)) = indexed {
1337 mask = Some(bm);
1338 blit_map = Some(bm_map);
1339 } else {
1340 mask = decode_mask(page).ok().flatten();
1341 blit_map = None;
1342 }
1343 fg44 = decode_fg44(page).ok().flatten();
1344 } else {
1345 bg = decode_background_chunks(page, usize::MAX, bg_subsample)?;
1346 fg_palette = decode_fg_palette_full(page)?;
1347 let indexed_result = if fg_palette.is_some() {
1348 decode_mask_indexed(page)?
1349 } else {
1350 None
1351 };
1352 if let Some((bm, bm_map)) = indexed_result {
1353 mask = Some(bm);
1354 blit_map = Some(bm_map);
1355 } else {
1356 mask = if fg_palette.is_none() {
1357 decode_mask(page)?
1358 } else {
1359 None
1360 };
1361 blit_map = None;
1362 }
1363 fg44 = decode_fg44(page)?;
1364 }
1365
1366 let mask = if opts.bold > 0 {
1367 mask.map(|m| {
1368 let mut dilated = m;
1369 for _ in 0..opts.bold {
1370 dilated = dilated.dilate();
1371 }
1372 dilated
1373 })
1374 } else {
1375 mask
1376 };
1377
1378 let mut pm = Pixmap::white(w, h);
1379
1380 {
1381 let ctx = CompositeContext {
1382 opts,
1383 page_w: page.width() as u32,
1384 page_h: page.height() as u32,
1385 bg: bg.as_ref(),
1386 bg_subsample,
1387 mask: mask.as_ref(),
1388 fg_palette: fg_palette.as_ref(),
1389 blit_map: blit_map.as_deref(),
1390 fg44: fg44.as_ref(),
1391 gamma_lut: &gamma_lut,
1392 offset_x: 0,
1393 offset_y: 0,
1394 out_w: w,
1395 out_h: h,
1396 };
1397 composite_into(&ctx, &mut pm.data)?;
1398 }
1399
1400 if opts.aa {
1401 pm = aa_downscale(&pm);
1402 }
1403
1404 if opts.resampling == Resampling::Lanczos3 {
1409 let need_scale = page.width() as u32 != w || page.height() as u32 != h;
1410 if need_scale {
1411 let native_opts = RenderOptions {
1413 width: page.width() as u32,
1414 height: page.height() as u32,
1415 scale: 1.0,
1416 bold: opts.bold,
1417 aa: false,
1418 rotation: UserRotation::None, permissive: opts.permissive,
1420 resampling: Resampling::Bilinear, };
1422 if let Ok(native_pm) = render_pixmap(page, &native_opts) {
1424 pm = scale_lanczos3(&native_pm, w, h);
1425 }
1426 }
1428 }
1429
1430 Ok(rotate_pixmap(
1431 pm,
1432 combine_rotations(page.rotation(), opts.rotation),
1433 ))
1434}
1435
1436pub fn render_region(
1452 page: &DjVuPage,
1453 region: RenderRect,
1454 opts: &RenderOptions,
1455) -> Result<Pixmap, RenderError> {
1456 if region.width == 0 || region.height == 0 {
1457 return Err(RenderError::InvalidDimensions {
1458 width: region.width,
1459 height: region.height,
1460 });
1461 }
1462
1463 let full_w = opts.width.max(1);
1464 let full_h = opts.height.max(1);
1465 let gamma_lut = build_gamma_lut(page.gamma());
1466
1467 let bg;
1468 let fg_palette;
1469 let mask;
1470 let blit_map;
1471 let fg44;
1472
1473 let bg_subsample = best_iw44_subsample(opts.scale);
1474
1475 if opts.permissive {
1476 bg = decode_background_chunks_permissive(page, usize::MAX, bg_subsample);
1477 fg_palette = decode_fg_palette_full(page).ok().flatten();
1478 let indexed = if fg_palette.is_some() {
1479 decode_mask_indexed(page).ok().flatten()
1480 } else {
1481 None
1482 };
1483 if let Some((bm, bm_map)) = indexed {
1484 mask = Some(bm);
1485 blit_map = Some(bm_map);
1486 } else {
1487 mask = decode_mask(page).ok().flatten();
1488 blit_map = None;
1489 }
1490 fg44 = decode_fg44(page).ok().flatten();
1491 } else {
1492 bg = decode_background_chunks(page, usize::MAX, bg_subsample)?;
1493 fg_palette = decode_fg_palette_full(page)?;
1494 let indexed_result = if fg_palette.is_some() {
1495 decode_mask_indexed(page)?
1496 } else {
1497 None
1498 };
1499 if let Some((bm, bm_map)) = indexed_result {
1500 mask = Some(bm);
1501 blit_map = Some(bm_map);
1502 } else {
1503 mask = if fg_palette.is_none() {
1504 decode_mask(page)?
1505 } else {
1506 None
1507 };
1508 blit_map = None;
1509 }
1510 fg44 = decode_fg44(page)?;
1511 }
1512
1513 let mask = if opts.bold > 0 {
1514 mask.map(|m| {
1515 let mut dilated = m;
1516 for _ in 0..opts.bold {
1517 dilated = dilated.dilate();
1518 }
1519 dilated
1520 })
1521 } else {
1522 mask
1523 };
1524
1525 let out_w = region.width;
1526 let out_h = region.height;
1527 let mut pm = Pixmap::white(out_w, out_h);
1528
1529 let region_opts = RenderOptions {
1530 width: full_w,
1531 height: full_h,
1532 ..*opts
1533 };
1534 let ctx = CompositeContext {
1535 opts: ®ion_opts,
1536 page_w: page.width() as u32,
1537 page_h: page.height() as u32,
1538 bg: bg.as_ref(),
1539 bg_subsample,
1540 mask: mask.as_ref(),
1541 fg_palette: fg_palette.as_ref(),
1542 blit_map: blit_map.as_deref(),
1543 fg44: fg44.as_ref(),
1544 gamma_lut: &gamma_lut,
1545 offset_x: region.x,
1546 offset_y: region.y,
1547 out_w,
1548 out_h,
1549 };
1550 composite_into(&ctx, &mut pm.data)?;
1551
1552 if opts.resampling == Resampling::Lanczos3 {
1554 let need_scale = page.width() as u32 != full_w || page.height() as u32 != full_h;
1555 if need_scale {
1556 let native_opts = RenderOptions {
1557 width: page.width() as u32,
1558 height: page.height() as u32,
1559 scale: 1.0,
1560 bold: opts.bold,
1561 aa: false,
1562 rotation: UserRotation::None,
1563 permissive: opts.permissive,
1564 resampling: Resampling::Bilinear,
1565 };
1566 if let Ok(native_pm) = render_region(page, region, &native_opts) {
1567 pm = scale_lanczos3(&native_pm, out_w, out_h);
1568 }
1569 }
1570 }
1571
1572 Ok(rotate_pixmap(
1573 pm,
1574 combine_rotations(page.rotation(), opts.rotation),
1575 ))
1576}
1577
1578pub fn render_gray8(page: &DjVuPage, opts: &RenderOptions) -> Result<GrayPixmap, RenderError> {
1587 Ok(render_pixmap(page, opts)?.to_gray8())
1588}
1589
1590#[cfg(feature = "parallel")]
1597pub fn render_pages_parallel(
1598 doc: &crate::djvu_document::DjVuDocument,
1599 dpi: u32,
1600) -> Vec<Result<Pixmap, RenderError>> {
1601 use rayon::prelude::*;
1602
1603 let count = doc.page_count();
1604 (0..count)
1605 .into_par_iter()
1606 .map(|i| {
1607 let page = doc.page(i)?;
1608 let native_dpi = page.dpi() as f32;
1609 let scale = dpi as f32 / native_dpi;
1610 let w = ((page.width() as f32 * scale).round() as u32).max(1);
1611 let h = ((page.height() as f32 * scale).round() as u32).max(1);
1612 let opts = RenderOptions {
1613 width: w,
1614 height: h,
1615 scale,
1616 bold: 0,
1617 aa: false,
1618 rotation: UserRotation::None,
1619 permissive: false,
1620 resampling: Resampling::Bilinear,
1621 };
1622 render_pixmap(page, &opts)
1623 })
1624 .collect()
1625}
1626
1627pub fn render_coarse(page: &DjVuPage, opts: &RenderOptions) -> Result<Option<Pixmap>, RenderError> {
1631 let w = opts.width;
1632 let h = opts.height;
1633
1634 if w == 0 || h == 0 {
1635 return Err(RenderError::InvalidDimensions {
1636 width: w,
1637 height: h,
1638 });
1639 }
1640
1641 let bg_subsample = best_iw44_subsample(opts.scale);
1642 let bg = decode_background_chunks(page, 1, bg_subsample)?;
1643 let bg = match bg {
1644 Some(b) => b,
1645 None => return Ok(None),
1646 };
1647
1648 let gamma_lut = build_gamma_lut(page.gamma());
1649 let mut pm = Pixmap::white(w, h);
1650
1651 {
1652 let ctx = CompositeContext {
1653 opts,
1654 page_w: page.width() as u32,
1655 page_h: page.height() as u32,
1656 bg: Some(&bg),
1657 bg_subsample,
1658 mask: None,
1659 fg_palette: None,
1660 blit_map: None,
1661 fg44: None,
1662 gamma_lut: &gamma_lut,
1663 offset_x: 0,
1664 offset_y: 0,
1665 out_w: w,
1666 out_h: h,
1667 };
1668 composite_into(&ctx, &mut pm.data)?;
1669 }
1670
1671 Ok(Some(rotate_pixmap(
1672 pm,
1673 combine_rotations(page.rotation(), opts.rotation),
1674 )))
1675}
1676
1677pub fn render_progressive(
1688 page: &DjVuPage,
1689 opts: &RenderOptions,
1690 chunk_n: usize,
1691) -> Result<Pixmap, RenderError> {
1692 let w = opts.width;
1693 let h = opts.height;
1694
1695 if w == 0 || h == 0 {
1696 return Err(RenderError::InvalidDimensions {
1697 width: w,
1698 height: h,
1699 });
1700 }
1701
1702 let n_bg44 = page.bg44_chunks().len();
1703 let max_chunk = n_bg44.saturating_sub(1);
1704
1705 if n_bg44 > 0 && chunk_n > max_chunk {
1706 return Err(RenderError::ChunkOutOfRange {
1707 chunk_n,
1708 max: max_chunk,
1709 });
1710 }
1711
1712 let gamma_lut = build_gamma_lut(page.gamma());
1713
1714 let bg_subsample = best_iw44_subsample(opts.scale);
1716 let bg = decode_background_chunks(page, chunk_n + 1, bg_subsample)?;
1717 let fg_palette = decode_fg_palette_full(page)?;
1718
1719 let (mask, blit_map) = if fg_palette.is_some() {
1720 match decode_mask_indexed(page)? {
1721 Some((bm, bm_map)) => (Some(bm), Some(bm_map)),
1722 None => (None, None),
1723 }
1724 } else {
1725 (decode_mask(page)?, None)
1726 };
1727
1728 let mask = if opts.bold > 0 {
1729 mask.map(|m| {
1730 let mut dilated = m;
1731 for _ in 0..opts.bold {
1732 dilated = dilated.dilate();
1733 }
1734 dilated
1735 })
1736 } else {
1737 mask
1738 };
1739 let fg44 = decode_fg44(page)?;
1740
1741 let mut pm = Pixmap::white(w, h);
1742 {
1743 let ctx = CompositeContext {
1744 opts,
1745 page_w: page.width() as u32,
1746 page_h: page.height() as u32,
1747 bg: bg.as_ref(),
1748 bg_subsample,
1749 mask: mask.as_ref(),
1750 fg_palette: fg_palette.as_ref(),
1751 blit_map: blit_map.as_deref(),
1752 fg44: fg44.as_ref(),
1753 gamma_lut: &gamma_lut,
1754 offset_x: 0,
1755 offset_y: 0,
1756 out_w: w,
1757 out_h: h,
1758 };
1759 composite_into(&ctx, &mut pm.data)?;
1760 }
1761
1762 if opts.resampling == Resampling::Lanczos3 {
1764 let need_scale = page.width() as u32 != w || page.height() as u32 != h;
1765 if need_scale {
1766 let native_opts = RenderOptions {
1767 width: page.width() as u32,
1768 height: page.height() as u32,
1769 scale: 1.0,
1770 bold: opts.bold,
1771 aa: false,
1772 rotation: UserRotation::None,
1773 permissive: opts.permissive,
1774 resampling: Resampling::Bilinear,
1775 };
1776 if let Ok(native_pm) = render_progressive(page, &native_opts, chunk_n) {
1777 pm = scale_lanczos3(&native_pm, w, h);
1778 }
1779 }
1780 }
1781
1782 Ok(rotate_pixmap(
1783 pm,
1784 combine_rotations(page.rotation(), opts.rotation),
1785 ))
1786}
1787
1788#[cfg(test)]
1791mod tests {
1792 use super::*;
1793 use crate::djvu_document::DjVuDocument;
1794
1795 fn assets_path() -> std::path::PathBuf {
1796 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1797 .join("references/djvujs/library/assets")
1798 }
1799
1800 fn load_page(filename: &str) -> DjVuPage {
1801 let data = std::fs::read(assets_path().join(filename))
1802 .unwrap_or_else(|_| panic!("{filename} must exist"));
1803 let doc = DjVuDocument::parse(&data).unwrap_or_else(|e| panic!("parse failed: {e}"));
1804 let _ = doc.page(0).expect("page 0 must exist");
1807 let data2 = std::fs::read(assets_path().join(filename)).unwrap();
1809 let doc2 = DjVuDocument::parse(&data2).unwrap();
1810 drop(doc2);
1815 panic!("use load_doc_page instead")
1816 }
1817
1818 fn load_doc(filename: &str) -> DjVuDocument {
1820 let data = std::fs::read(assets_path().join(filename))
1821 .unwrap_or_else(|_| panic!("{filename} must exist"));
1822 DjVuDocument::parse(&data).unwrap_or_else(|e| panic!("parse failed: {e}"))
1823 }
1824
1825 #[test]
1829 fn render_options_default() {
1830 let opts = RenderOptions::default();
1831 assert_eq!(opts.width, 0);
1832 assert_eq!(opts.height, 0);
1833 assert_eq!(opts.bold, 0);
1834 assert!(!opts.aa);
1835 assert!((opts.scale - 1.0).abs() < 1e-6);
1836 assert_eq!(opts.resampling, Resampling::Bilinear);
1837 }
1838
1839 #[test]
1841 fn render_options_construction() {
1842 let opts = RenderOptions {
1843 width: 400,
1844 height: 300,
1845 scale: 0.5,
1846 bold: 1,
1847 aa: true,
1848 rotation: UserRotation::Cw90,
1849 permissive: false,
1850 resampling: Resampling::Bilinear,
1851 };
1852 assert_eq!(opts.width, 400);
1853 assert_eq!(opts.height, 300);
1854 assert_eq!(opts.bold, 1);
1855 assert!(opts.aa);
1856 assert!((opts.scale - 0.5).abs() < 1e-6);
1857 assert_eq!(opts.rotation, UserRotation::Cw90);
1858 }
1859
1860 #[test]
1862 fn fit_to_width_preserves_aspect() {
1863 let doc = load_doc("chicken.djvu");
1864 let page = doc.page(0).unwrap();
1865 let pw = page.width() as u32;
1866 let ph = page.height() as u32;
1867
1868 let opts = RenderOptions::fit_to_width(page, 800);
1869 assert_eq!(opts.width, 800);
1870 let expected_h = ((ph as f64 * 800.0) / pw as f64).round() as u32;
1871 assert_eq!(opts.height, expected_h);
1872 assert!((opts.scale - 800.0 / pw as f32).abs() < 0.01);
1873 }
1874
1875 #[test]
1877 fn fit_to_height_preserves_aspect() {
1878 let doc = load_doc("chicken.djvu");
1879 let page = doc.page(0).unwrap();
1880 let pw = page.width() as u32;
1881 let ph = page.height() as u32;
1882
1883 let opts = RenderOptions::fit_to_height(page, 600);
1884 assert_eq!(opts.height, 600);
1885 let expected_w = ((pw as f64 * 600.0) / ph as f64).round() as u32;
1886 assert_eq!(opts.width, expected_w);
1887 assert!((opts.scale - 600.0 / ph as f32).abs() < 0.01);
1888 }
1889
1890 #[test]
1892 fn fit_to_box_constrains_both() {
1893 let doc = load_doc("chicken.djvu");
1894 let page = doc.page(0).unwrap();
1895
1896 let opts = RenderOptions::fit_to_box(page, 10000, 100);
1898 assert!(opts.width <= 10000);
1899 assert!(opts.height <= 100);
1900 assert!(opts.width > 0 && opts.height > 0);
1901
1902 let opts = RenderOptions::fit_to_box(page, 100, 10000);
1904 assert!(opts.width <= 100);
1905 assert!(opts.height <= 10000);
1906 assert!(opts.width > 0 && opts.height > 0);
1907 }
1908
1909 #[test]
1911 fn fit_to_box_square() {
1912 let doc = load_doc("chicken.djvu");
1913 let page = doc.page(0).unwrap();
1914
1915 let opts = RenderOptions::fit_to_box(page, 500, 500);
1916 assert!(opts.width <= 500);
1917 assert!(opts.height <= 500);
1918 assert!(opts.width >= 490 || opts.height >= 490);
1920 }
1921
1922 #[test]
1924 fn fit_to_width_rotation_aware() {
1925 let doc = load_doc("boy_jb2_rotate90.djvu");
1927 let page = doc.page(0).unwrap();
1928 let pw = page.width() as u32;
1929 let ph = page.height() as u32;
1930 let (dw, dh) = (ph, pw);
1932
1933 let opts = RenderOptions::fit_to_width(page, 400);
1934 assert_eq!(opts.width, 400);
1935 let expected_h = ((dh as f64 * 400.0) / dw as f64).round() as u32;
1936 assert_eq!(opts.height, expected_h);
1937 }
1938
1939 #[test]
1941 fn render_into_invalid_dimensions() {
1942 let doc = load_doc("chicken.djvu");
1943 let page = doc.page(0).unwrap();
1944
1945 let opts = RenderOptions {
1946 width: 0,
1947 height: 100,
1948 ..Default::default()
1949 };
1950 let mut buf = vec![0u8; 400];
1951 let err = render_into(page, &opts, &mut buf).unwrap_err();
1952 assert!(
1953 matches!(err, RenderError::InvalidDimensions { .. }),
1954 "expected InvalidDimensions, got {err:?}"
1955 );
1956 }
1957
1958 #[test]
1960 fn render_into_buf_too_small() {
1961 let doc = load_doc("chicken.djvu");
1962 let page = doc.page(0).unwrap();
1963
1964 let opts = RenderOptions {
1965 width: 10,
1966 height: 10,
1967 ..Default::default()
1968 };
1969 let mut buf = vec![0u8; 10]; let err = render_into(page, &opts, &mut buf).unwrap_err();
1971 assert!(
1972 matches!(err, RenderError::BufTooSmall { need: 400, got: 10 }),
1973 "expected BufTooSmall, got {err:?}"
1974 );
1975 }
1976
1977 #[test]
1982 fn render_into_fills_buffer_no_alloc() {
1983 let doc = load_doc("chicken.djvu");
1984 let page = doc.page(0).unwrap();
1985
1986 let w = 50u32;
1987 let h = 40u32;
1988 let opts = RenderOptions {
1989 width: w,
1990 height: h,
1991 ..Default::default()
1992 };
1993 let mut buf = vec![0u8; (w * h * 4) as usize];
1994 render_into(page, &opts, &mut buf).expect("render_into should succeed");
1995
1996 assert!(
1998 buf.iter().any(|&b| b != 0),
1999 "rendered buffer should contain non-zero pixels"
2000 );
2001 }
2002
2003 #[test]
2005 fn render_into_reuse_buffer() {
2006 let doc = load_doc("chicken.djvu");
2007 let page = doc.page(0).unwrap();
2008
2009 let w = 30u32;
2010 let h = 20u32;
2011 let opts = RenderOptions {
2012 width: w,
2013 height: h,
2014 ..Default::default()
2015 };
2016 let mut buf = vec![0u8; (w * h * 4) as usize];
2017
2018 render_into(page, &opts, &mut buf).expect("first render_into should succeed");
2020 let first = buf.clone();
2021
2022 render_into(page, &opts, &mut buf).expect("second render_into should succeed");
2024 assert_eq!(
2025 first, buf,
2026 "repeated render_into should produce identical output"
2027 );
2028 }
2029
2030 #[test]
2032 fn gamma_correction_changes_pixels() {
2033 let lut_gamma = build_gamma_lut(2.2);
2035 let lut_identity = build_gamma_lut(1.0);
2036
2037 let mid = 128u8;
2039 let corrected = lut_gamma[mid as usize];
2040 let identity = lut_identity[mid as usize];
2041
2042 assert_eq!(identity, mid, "identity LUT must be identity");
2044
2045 assert!(
2047 corrected > mid,
2048 "gamma-corrected midtone ({corrected}) should be > identity ({mid})"
2049 );
2050 }
2051
2052 #[test]
2054 fn gamma_lut_identity() {
2055 let lut = build_gamma_lut(1.0);
2056 for (i, &val) in lut.iter().enumerate() {
2057 assert_eq!(val, i as u8, "identity LUT at {i}: expected {i}, got {val}");
2058 }
2059 }
2060
2061 #[test]
2063 fn gamma_lut_zero_is_identity() {
2064 let lut = build_gamma_lut(0.0);
2065 for (i, &val) in lut.iter().enumerate() {
2066 assert_eq!(val, i as u8, "zero gamma should produce identity LUT");
2067 }
2068 }
2069
2070 #[test]
2073 fn render_coarse_returns_pixmap() {
2074 let doc = load_doc("chicken.djvu");
2075 let page = doc.page(0).unwrap();
2076
2077 let opts = RenderOptions {
2078 width: 60,
2079 height: 80,
2080 ..Default::default()
2081 };
2082
2083 let result = render_coarse(page, &opts).expect("render_coarse should succeed");
2084 if let Some(pm) = result {
2086 assert_eq!(pm.width, 60);
2087 assert_eq!(pm.height, 80);
2088 assert_eq!(pm.data.len(), 60 * 80 * 4);
2089 }
2090 }
2092
2093 #[test]
2095 fn render_progressive_each_chunk() {
2096 let doc = load_doc("boy.djvu");
2098 let page = doc.page(0).unwrap();
2099
2100 let opts = RenderOptions {
2101 width: 80,
2102 height: 100,
2103 ..Default::default()
2104 };
2105
2106 let n_bg44 = page.bg44_chunks().len();
2107
2108 for chunk_n in 0..n_bg44 {
2109 let pm = render_progressive(page, &opts, chunk_n)
2110 .unwrap_or_else(|e| panic!("render_progressive chunk {chunk_n} failed: {e}"));
2111 assert_eq!(pm.width, 80);
2112 assert_eq!(pm.height, 100);
2113 assert_eq!(pm.data.len(), 80 * 100 * 4);
2114 assert!(
2116 pm.data.iter().any(|&b| b != 0),
2117 "chunk {chunk_n}: rendered frame should not be all-zero"
2118 );
2119 }
2120 }
2121
2122 #[test]
2124 fn render_progressive_chunk_out_of_range() {
2125 let doc = load_doc("boy.djvu");
2126 let page = doc.page(0).unwrap();
2127
2128 let opts = RenderOptions {
2129 width: 40,
2130 height: 50,
2131 ..Default::default()
2132 };
2133
2134 let n_bg44 = page.bg44_chunks().len();
2135 if n_bg44 == 0 {
2136 return;
2138 }
2139
2140 let err = render_progressive(page, &opts, n_bg44 + 10).unwrap_err();
2141 assert!(
2142 matches!(err, RenderError::ChunkOutOfRange { .. }),
2143 "expected ChunkOutOfRange, got {err:?}"
2144 );
2145 }
2146
2147 #[test]
2152 fn render_pixmap_gamma_differs_from_identity() {
2153 let doc = load_doc("chicken.djvu");
2154 let page = doc.page(0).unwrap();
2155
2156 let w = 40u32;
2157 let h = 53u32; let opts = RenderOptions {
2160 width: w,
2161 height: h,
2162 ..Default::default()
2163 };
2164
2165 let pm_gamma = render_pixmap(page, &opts).expect("render with gamma should succeed");
2167
2168 let lut_identity = build_gamma_lut(1.0);
2170 let pm_identity = render_pixmap(page, &opts).expect("render for identity should succeed");
2171 for i in 0..pm_identity.data.len().saturating_sub(3) {
2173 if i % 4 != 3 {
2174 let _ = lut_identity[pm_identity.data[i] as usize];
2176 }
2177 }
2178
2179 assert_eq!(pm_gamma.width, w);
2188 assert_eq!(pm_gamma.height, h);
2189 assert!(
2190 pm_gamma.data.iter().any(|&b| b != 255),
2191 "should have non-white pixels"
2192 );
2193 }
2194
2195 #[test]
2197 fn render_bilevel_page_has_black_pixels() {
2198 let doc = load_doc("boy_jb2.djvu");
2199 let page = doc.page(0).unwrap();
2200
2201 let opts = RenderOptions {
2202 width: 60,
2203 height: 80,
2204 ..Default::default()
2205 };
2206
2207 let pm = render_pixmap(page, &opts).expect("render bilevel should succeed");
2208 assert_eq!(pm.width, 60);
2209 assert_eq!(pm.height, 80);
2210 assert!(
2212 pm.data
2213 .chunks_exact(4)
2214 .any(|px| px[0] == 0 && px[1] == 0 && px[2] == 0),
2215 "bilevel page should contain black pixels"
2216 );
2217 }
2218
2219 #[test]
2221 fn render_with_aa() {
2222 let doc = load_doc("chicken.djvu");
2223 let page = doc.page(0).unwrap();
2224
2225 let opts = RenderOptions {
2226 width: 40,
2227 height: 54,
2228 aa: true,
2229 ..Default::default()
2230 };
2231 let pm = render_pixmap(page, &opts).expect("render with AA should succeed");
2233 assert_eq!(pm.width, 20);
2235 assert_eq!(pm.height, 27);
2236 }
2237
2238 #[allow(dead_code)]
2240 fn _unused_load_page(_: &str) -> ! {
2241 let _ = load_page; panic!("use load_doc instead")
2243 }
2244
2245 #[test]
2248 fn rotate_pixmap_none_is_identity() {
2249 let mut pm = Pixmap::white(3, 2);
2250 pm.set_rgb(0, 0, 255, 0, 0);
2251 let rotated = rotate_pixmap(pm.clone(), crate::info::Rotation::None);
2252 assert_eq!(rotated.width, 3);
2253 assert_eq!(rotated.height, 2);
2254 assert_eq!(rotated.get_rgb(0, 0), (255, 0, 0));
2255 }
2256
2257 #[test]
2258 fn rotate_pixmap_cw90_swaps_dims() {
2259 let mut pm = Pixmap::white(4, 2);
2260 pm.set_rgb(0, 0, 255, 0, 0); let rotated = rotate_pixmap(pm, crate::info::Rotation::Cw90);
2262 assert_eq!(rotated.width, 2);
2263 assert_eq!(rotated.height, 4);
2264 assert_eq!(rotated.get_rgb(1, 0), (255, 0, 0));
2266 }
2267
2268 #[test]
2269 fn rotate_pixmap_180_preserves_dims() {
2270 let mut pm = Pixmap::white(3, 2);
2271 pm.set_rgb(0, 0, 255, 0, 0); let rotated = rotate_pixmap(pm, crate::info::Rotation::Rot180);
2273 assert_eq!(rotated.width, 3);
2274 assert_eq!(rotated.height, 2);
2275 assert_eq!(rotated.get_rgb(2, 1), (255, 0, 0));
2276 }
2277
2278 #[test]
2279 fn rotate_pixmap_ccw90_swaps_dims() {
2280 let mut pm = Pixmap::white(4, 2);
2281 pm.set_rgb(0, 0, 255, 0, 0); let rotated = rotate_pixmap(pm, crate::info::Rotation::Ccw90);
2283 assert_eq!(rotated.width, 2);
2284 assert_eq!(rotated.height, 4);
2285 assert_eq!(rotated.get_rgb(0, 3), (255, 0, 0));
2287 }
2288
2289 #[test]
2290 fn render_pixmap_rotation_90_swaps_dimensions() {
2291 let doc = load_doc("boy_jb2_rotate90.djvu");
2292 let page = doc.page(0).expect("page 0");
2293 let orig_w = page.width();
2294 let orig_h = page.height();
2295 let opts = RenderOptions {
2296 width: orig_w as u32,
2297 height: orig_h as u32,
2298 ..Default::default()
2299 };
2300 let pm = render_pixmap(page, &opts).expect("render should succeed");
2301 assert_eq!(
2303 pm.width, orig_h as u32,
2304 "rotated width should be original height"
2305 );
2306 assert_eq!(
2307 pm.height, orig_w as u32,
2308 "rotated height should be original width"
2309 );
2310 }
2311
2312 #[test]
2313 fn render_pixmap_rotation_180_preserves_dimensions() {
2314 let doc = load_doc("boy_jb2_rotate180.djvu");
2315 let page = doc.page(0).expect("page 0");
2316 let orig_w = page.width();
2317 let orig_h = page.height();
2318 let opts = RenderOptions {
2319 width: orig_w as u32,
2320 height: orig_h as u32,
2321 ..Default::default()
2322 };
2323 let pm = render_pixmap(page, &opts).expect("render should succeed");
2324 assert_eq!(pm.width, orig_w as u32);
2325 assert_eq!(pm.height, orig_h as u32);
2326 }
2327
2328 #[test]
2329 fn render_pixmap_rotation_270_swaps_dimensions() {
2330 let doc = load_doc("boy_jb2_rotate270.djvu");
2331 let page = doc.page(0).expect("page 0");
2332 let orig_w = page.width();
2333 let orig_h = page.height();
2334 let opts = RenderOptions {
2335 width: orig_w as u32,
2336 height: orig_h as u32,
2337 ..Default::default()
2338 };
2339 let pm = render_pixmap(page, &opts).expect("render should succeed");
2340 assert_eq!(
2341 pm.width, orig_h as u32,
2342 "rotated width should be original height"
2343 );
2344 assert_eq!(
2345 pm.height, orig_w as u32,
2346 "rotated height should be original width"
2347 );
2348 }
2349
2350 #[test]
2354 fn combine_rotations_identity() {
2355 use crate::info::Rotation;
2356 assert_eq!(
2357 combine_rotations(Rotation::None, UserRotation::None),
2358 Rotation::None
2359 );
2360 }
2361
2362 #[test]
2363 fn combine_rotations_info_only() {
2364 use crate::info::Rotation;
2365 assert_eq!(
2366 combine_rotations(Rotation::Cw90, UserRotation::None),
2367 Rotation::Cw90
2368 );
2369 }
2370
2371 #[test]
2372 fn combine_rotations_user_only() {
2373 use crate::info::Rotation;
2374 assert_eq!(
2375 combine_rotations(Rotation::None, UserRotation::Ccw90),
2376 Rotation::Ccw90
2377 );
2378 }
2379
2380 #[test]
2381 fn combine_rotations_sum() {
2382 use crate::info::Rotation;
2383 assert_eq!(
2385 combine_rotations(Rotation::Cw90, UserRotation::Cw90),
2386 Rotation::Rot180
2387 );
2388 assert_eq!(
2390 combine_rotations(Rotation::Cw90, UserRotation::Ccw90),
2391 Rotation::None
2392 );
2393 assert_eq!(
2395 combine_rotations(Rotation::Rot180, UserRotation::Rot180),
2396 Rotation::None
2397 );
2398 }
2399
2400 #[test]
2402 fn user_rotation_cw90_swaps_dimensions() {
2403 let doc = load_doc("chicken.djvu");
2404 let page = doc.page(0).unwrap();
2405 let pw = page.width() as u32;
2406 let ph = page.height() as u32;
2407
2408 let opts = RenderOptions {
2409 width: pw,
2410 height: ph,
2411 rotation: UserRotation::Cw90,
2412 ..Default::default()
2413 };
2414 let pm = render_pixmap(page, &opts).expect("render");
2415 assert_eq!(pm.width, ph, "user Cw90 should swap: width becomes height");
2416 assert_eq!(pm.height, pw, "user Cw90 should swap: height becomes width");
2417 }
2418
2419 #[test]
2421 fn user_rotation_180_preserves_dimensions() {
2422 let doc = load_doc("chicken.djvu");
2423 let page = doc.page(0).unwrap();
2424 let pw = page.width() as u32;
2425 let ph = page.height() as u32;
2426
2427 let opts = RenderOptions {
2428 width: pw,
2429 height: ph,
2430 rotation: UserRotation::Rot180,
2431 ..Default::default()
2432 };
2433 let pm = render_pixmap(page, &opts).expect("render");
2434 assert_eq!(pm.width, pw);
2435 assert_eq!(pm.height, ph);
2436 }
2437
2438 #[test]
2440 fn user_rotation_default_is_none() {
2441 assert_eq!(UserRotation::default(), UserRotation::None);
2442 let opts = RenderOptions::default();
2443 assert_eq!(opts.rotation, UserRotation::None);
2444 }
2445
2446 #[test]
2449 fn fgbz_palette_page_renders_multiple_colors() {
2450 let doc = load_doc("irish.djvu");
2452 let page = doc.page(0).expect("page 0");
2453 let w = page.width() as u32;
2454 let h = page.height() as u32;
2455 let opts = RenderOptions {
2456 width: w,
2457 height: h,
2458 ..Default::default()
2459 };
2460 let pm = render_pixmap(page, &opts).expect("render should succeed");
2461
2462 let mut fg_colors = std::collections::HashSet::new();
2464 for y in 0..h {
2465 for x in 0..w {
2466 let (r, g, b) = pm.get_rgb(x, y);
2467 if r > 240 && g > 240 && b > 240 {
2469 continue;
2470 }
2471 fg_colors.insert((r, g, b));
2472 }
2473 }
2474
2475 assert!(
2478 fg_colors.len() > 1,
2479 "multi-color palette page should have >1 distinct foreground colors, got {}",
2480 fg_colors.len()
2481 );
2482 }
2483
2484 #[test]
2485 fn lookup_palette_color_uses_blit_map() {
2486 let pal = FgbzPalette {
2487 colors: vec![
2488 PaletteColor { r: 255, g: 0, b: 0 }, PaletteColor { r: 0, g: 0, b: 255 }, ],
2491 indices: vec![1, 0], };
2493 let bm = crate::bitmap::Bitmap::new(2, 1);
2494 let blit_map = vec![0i32, 1i32]; let c0 = lookup_palette_color(&pal, Some(&blit_map), Some(&bm), 0, 0);
2497 assert_eq!(
2498 (c0.r, c0.g, c0.b),
2499 (0, 0, 255),
2500 "blit 0 → indices[0]=1 → blue"
2501 );
2502
2503 let c1 = lookup_palette_color(&pal, Some(&blit_map), Some(&bm), 1, 0);
2504 assert_eq!(
2505 (c1.r, c1.g, c1.b),
2506 (255, 0, 0),
2507 "blit 1 → indices[1]=0 → red"
2508 );
2509 }
2510
2511 #[test]
2512 fn lookup_palette_color_fallback_without_blit_map() {
2513 let pal = FgbzPalette {
2514 colors: vec![PaletteColor { r: 0, g: 128, b: 0 }],
2515 indices: vec![],
2516 };
2517 let c = lookup_palette_color(&pal, None, None, 0, 0);
2518 assert_eq!(
2519 (c.r, c.g, c.b),
2520 (0, 128, 0),
2521 "should fall back to first color"
2522 );
2523 }
2524
2525 fn load_bgjp_doc() -> DjVuDocument {
2529 load_doc("bgjp_test.djvu")
2530 }
2531
2532 #[test]
2534 fn bgjp_fixture_loads() {
2535 let doc = load_bgjp_doc();
2536 let page = doc.page(0).unwrap();
2537 assert_eq!(page.width(), 4);
2538 assert_eq!(page.height(), 4);
2539 }
2540
2541 #[test]
2543 fn bgjp_chunk_present() {
2544 let doc = load_bgjp_doc();
2545 let page = doc.page(0).unwrap();
2546 assert!(
2547 page.find_chunk(b"BGjp").is_some(),
2548 "fixture must have a BGjp chunk"
2549 );
2550 assert!(
2551 page.bg44_chunks().is_empty(),
2552 "fixture must NOT have BG44 chunks"
2553 );
2554 }
2555
2556 #[test]
2558 fn decode_bgjp_returns_pixmap() {
2559 let doc = load_bgjp_doc();
2560 let page = doc.page(0).unwrap();
2561 let pm = decode_bgjp(page).expect("decode_bgjp must not error");
2562 assert!(pm.is_some(), "decode_bgjp must return Some(Pixmap)");
2563 let pm = pm.unwrap();
2564 assert_eq!(pm.width, 4);
2565 assert_eq!(pm.height, 4);
2566 assert_eq!(pm.data.len(), 4 * 4 * 4); }
2568
2569 #[test]
2571 fn decode_bgjp_returns_none_without_chunk() {
2572 let doc = load_doc("chicken.djvu");
2573 let page = doc.page(0).unwrap();
2574 let pm = decode_bgjp(page).expect("should not error");
2575 assert!(pm.is_none());
2576 }
2577
2578 #[test]
2580 fn decode_jpeg_to_pixmap_alpha_is_255() {
2581 let doc = load_bgjp_doc();
2582 let page = doc.page(0).unwrap();
2583 let data = page.find_chunk(b"BGjp").unwrap();
2584 let pm = decode_jpeg_to_pixmap(data).expect("decode must succeed");
2585 for chunk in pm.data.chunks_exact(4) {
2586 assert_eq!(chunk[3], 255, "alpha must be 255 for every pixel");
2587 }
2588 }
2589
2590 #[test]
2592 fn render_pixmap_uses_bgjp_background() {
2593 let doc = load_bgjp_doc();
2594 let page = doc.page(0).unwrap();
2595 let opts = RenderOptions {
2596 width: 4,
2597 height: 4,
2598 scale: 1.0,
2599 bold: 0,
2600 aa: false,
2601 rotation: UserRotation::None,
2602 permissive: false,
2603 resampling: Resampling::Bilinear,
2604 };
2605 let pm = render_pixmap(page, &opts).expect("render must succeed");
2606 assert_eq!(pm.width, 4);
2607 assert_eq!(pm.height, 4);
2608 }
2609
2610 #[test]
2612 fn render_coarse_uses_bgjp_background() {
2613 let doc = load_bgjp_doc();
2614 let page = doc.page(0).unwrap();
2615 let opts = RenderOptions {
2616 width: 4,
2617 height: 4,
2618 scale: 1.0,
2619 bold: 0,
2620 aa: false,
2621 rotation: UserRotation::None,
2622 permissive: false,
2623 resampling: Resampling::Bilinear,
2624 };
2625 let pm = render_coarse(page, &opts).expect("render_coarse must succeed");
2626 assert!(pm.is_some(), "must return Some when BGjp present");
2627 let pm = pm.unwrap();
2628 assert_eq!(pm.width, 4);
2629 assert_eq!(pm.height, 4);
2630 }
2631
2632 #[test]
2636 fn lanczos3_kernel_unity_at_zero() {
2637 assert!((lanczos3_kernel(0.0) - 1.0).abs() < 1e-5);
2638 }
2639
2640 #[test]
2642 fn lanczos3_kernel_zero_outside_support() {
2643 assert_eq!(lanczos3_kernel(3.0), 0.0);
2644 assert_eq!(lanczos3_kernel(-3.5), 0.0);
2645 assert_eq!(lanczos3_kernel(10.0), 0.0);
2646 }
2647
2648 #[test]
2650 fn scale_lanczos3_correct_dimensions() {
2651 let src = Pixmap::white(100, 80);
2652 let dst = scale_lanczos3(&src, 50, 40);
2653 assert_eq!(dst.width, 50);
2654 assert_eq!(dst.height, 40);
2655 }
2656
2657 #[test]
2659 fn scale_lanczos3_noop_when_same_size() {
2660 let src = Pixmap::new(4, 4, 200, 100, 50, 255);
2661 let dst = scale_lanczos3(&src, 4, 4);
2662 assert_eq!(dst.width, 4);
2663 assert_eq!(dst.height, 4);
2664 assert_eq!(dst.data, src.data);
2665 }
2666
2667 #[test]
2669 fn scale_lanczos3_preserves_solid_color() {
2670 let src = Pixmap::new(20, 20, 200, 0, 0, 255);
2672 let dst = scale_lanczos3(&src, 10, 10);
2673 assert_eq!(dst.width, 10);
2674 assert_eq!(dst.height, 10);
2675 for chunk in dst.data.chunks_exact(4) {
2677 let (r, g, b) = (chunk[0], chunk[1], chunk[2]);
2678 assert!(
2679 (r as i32 - 200).abs() <= 5 && g <= 5 && b <= 5,
2680 "expected near-red (200,0,0), got ({r},{g},{b})"
2681 );
2682 }
2683 }
2684
2685 #[test]
2687 fn render_pixmap_lanczos3_correct_dimensions() {
2688 let doc = load_doc("chicken.djvu");
2689 let page = doc.page(0).unwrap();
2690 let pw = page.width() as u32;
2691 let ph = page.height() as u32;
2692 let tw = pw / 2;
2693 let th = ph / 2;
2694
2695 let opts = RenderOptions {
2696 width: tw,
2697 height: th,
2698 scale: 0.5,
2699 resampling: Resampling::Lanczos3,
2700 ..Default::default()
2701 };
2702 let pm = render_pixmap(page, &opts).expect("Lanczos3 render must succeed");
2703 assert_eq!(pm.width, tw);
2704 assert_eq!(pm.height, th);
2705 }
2706
2707 #[test]
2709 fn lanczos3_differs_from_bilinear_at_half_scale() {
2710 let doc = load_doc("chicken.djvu");
2711 let page = doc.page(0).unwrap();
2712 let pw = page.width() as u32;
2713 let ph = page.height() as u32;
2714 let tw = pw / 2;
2715 let th = ph / 2;
2716
2717 let bilinear = render_pixmap(
2718 page,
2719 &RenderOptions {
2720 width: tw,
2721 height: th,
2722 scale: 0.5,
2723 resampling: Resampling::Bilinear,
2724 ..Default::default()
2725 },
2726 )
2727 .unwrap();
2728
2729 let lanczos = render_pixmap(
2730 page,
2731 &RenderOptions {
2732 width: tw,
2733 height: th,
2734 scale: 0.5,
2735 resampling: Resampling::Lanczos3,
2736 ..Default::default()
2737 },
2738 )
2739 .unwrap();
2740
2741 assert_eq!(bilinear.width, lanczos.width);
2743 assert_eq!(bilinear.height, lanczos.height);
2744
2745 let differ = bilinear
2747 .data
2748 .iter()
2749 .zip(lanczos.data.iter())
2750 .any(|(a, b)| a != b);
2751 assert!(
2752 differ,
2753 "Lanczos3 and bilinear must produce different pixel values"
2754 );
2755 }
2756
2757 #[test]
2759 fn resampling_default_is_bilinear() {
2760 let opts = RenderOptions::default();
2761 assert_eq!(opts.resampling, Resampling::Bilinear);
2762 }
2763
2764 #[test]
2768 fn render_region_allocates_proportionally() {
2769 let doc = load_doc("chicken.djvu");
2770 let page = doc.page(0).unwrap();
2771 let opts = RenderOptions::fit_to_width(page, 1000);
2772 let region = RenderRect {
2773 x: 0,
2774 y: 0,
2775 width: 256,
2776 height: 256,
2777 };
2778 let pm = render_region(page, region, &opts).expect("render_region should succeed");
2779 assert_eq!(pm.width, 256);
2780 assert_eq!(pm.height, 256);
2781 assert_eq!(pm.data.len(), 256 * 256 * 4);
2782 assert!(
2783 pm.data.len() <= 512 * 1024,
2784 "region allocation {} exceeds 512 KB",
2785 pm.data.len()
2786 );
2787 }
2788
2789 #[test]
2791 fn render_region_matches_full_render() {
2792 let doc = load_doc("chicken.djvu");
2793 let page = doc.page(0).unwrap();
2794 let opts = RenderOptions {
2795 width: 100,
2796 height: 80,
2797 ..Default::default()
2798 };
2799 let full = render_pixmap(page, &opts).expect("full render should succeed");
2800 let region = RenderRect {
2801 x: 10,
2802 y: 10,
2803 width: 30,
2804 height: 20,
2805 };
2806 let part = render_region(page, region, &opts).expect("region render should succeed");
2807
2808 assert_eq!(part.width, 30);
2809 assert_eq!(part.height, 20);
2810
2811 for ry in 0..20u32 {
2812 for rx in 0..30u32 {
2813 let full_base = ((10 + ry) as usize * 100 + (10 + rx) as usize) * 4;
2814 let part_base = (ry as usize * 30 + rx as usize) * 4;
2815 assert_eq!(
2816 &full.data[full_base..full_base + 4],
2817 &part.data[part_base..part_base + 4],
2818 "pixel mismatch at region ({rx},{ry}) / full ({},{} )",
2819 10 + rx,
2820 10 + ry
2821 );
2822 }
2823 }
2824 }
2825
2826 #[test]
2828 fn render_region_invalid_dimensions() {
2829 let doc = load_doc("chicken.djvu");
2830 let page = doc.page(0).unwrap();
2831 let opts = RenderOptions {
2832 width: 100,
2833 height: 100,
2834 ..Default::default()
2835 };
2836 let region = RenderRect {
2837 x: 0,
2838 y: 0,
2839 width: 0,
2840 height: 50,
2841 };
2842 let err = render_region(page, region, &opts).unwrap_err();
2843 assert!(
2844 matches!(err, RenderError::InvalidDimensions { .. }),
2845 "expected InvalidDimensions, got {err:?}"
2846 );
2847 }
2848
2849 #[test]
2851 fn render_pixmap_still_works_after_refactor() {
2852 let doc = load_doc("chicken.djvu");
2853 let page = doc.page(0).unwrap();
2854 let opts = RenderOptions {
2855 width: 80,
2856 height: 60,
2857 ..Default::default()
2858 };
2859 let pm = render_pixmap(page, &opts).expect("render_pixmap should succeed");
2860 assert_eq!(pm.width, 80);
2861 assert_eq!(pm.height, 60);
2862 assert_eq!(pm.data.len(), 80 * 60 * 4);
2863 }
2864
2865 #[test]
2867 fn best_iw44_subsample_values() {
2868 assert_eq!(best_iw44_subsample(1.0), 1, "scale=1.0 → subsample=1");
2869 assert_eq!(best_iw44_subsample(0.5), 2, "scale=0.5 → subsample=2");
2870 assert_eq!(
2871 best_iw44_subsample(0.375),
2872 2,
2873 "scale=0.375 → subsample=2 (1/0.375=2.67)"
2874 );
2875 assert_eq!(best_iw44_subsample(0.25), 4, "scale=0.25 → subsample=4");
2876 assert_eq!(
2877 best_iw44_subsample(0.1),
2878 8,
2879 "scale=0.1 → subsample=8 (capped)"
2880 );
2881 assert_eq!(
2882 best_iw44_subsample(0.0),
2883 1,
2884 "scale=0.0 → subsample=1 (edge case)"
2885 );
2886 assert_eq!(
2887 best_iw44_subsample(-1.0),
2888 1,
2889 "scale<0 → subsample=1 (edge case)"
2890 );
2891 assert_eq!(
2892 best_iw44_subsample(2.0),
2893 1,
2894 "scale>1.0 → subsample=1 (no upscaling needed)"
2895 );
2896 }
2897
2898 #[test]
2900 fn render_pixmap_subsampled_bg_correct_dimensions() {
2901 let doc = load_doc("boy.djvu");
2902 let page = doc.page(0).unwrap();
2903 let opts = RenderOptions {
2905 width: (page.width() as f32 * 0.5) as u32,
2906 height: (page.height() as f32 * 0.5) as u32,
2907 scale: 0.5,
2908 ..Default::default()
2909 };
2910 let pm = render_pixmap(page, &opts).expect("subsampled render should succeed");
2911 assert_eq!(pm.width, opts.width);
2912 assert_eq!(pm.height, opts.height);
2913 assert_eq!(
2914 pm.data.len() as u64,
2915 opts.width as u64 * opts.height as u64 * 4
2916 );
2917 }
2918
2919 #[test]
2922 fn decoded_bg44_cache_produces_identical_pixels_on_second_render() {
2923 let doc = load_doc("boy.djvu");
2924 let page = doc.page(0).unwrap();
2925 let opts = RenderOptions {
2926 width: page.width() as u32,
2927 height: page.height() as u32,
2928 ..Default::default()
2929 };
2930 let pm1 = render_pixmap(page, &opts).expect("first render should succeed");
2931 let pm2 = render_pixmap(page, &opts).expect("second render should succeed");
2932 assert_eq!(
2933 pm1.data, pm2.data,
2934 "cached render must produce identical pixels"
2935 );
2936 }
2937
2938 #[test]
2941 fn decoded_bg44_is_populated_after_render() {
2942 let doc = load_doc("boy.djvu");
2943 let page = doc.page(0).unwrap();
2944 let opts = RenderOptions {
2945 width: page.width() as u32,
2946 height: page.height() as u32,
2947 ..Default::default()
2948 };
2949 render_pixmap(page, &opts).expect("render should succeed");
2951 let cached = page
2953 .decoded_bg44()
2954 .expect("cache should be populated after render");
2955 assert_eq!(
2956 cached.width,
2957 page.width() as u32,
2958 "cached bg44 width must equal page width"
2959 );
2960 assert_eq!(
2961 cached.height,
2962 page.height() as u32,
2963 "cached bg44 height must equal page height"
2964 );
2965 }
2966
2967 #[test]
2972 fn render_region_applies_rotation() {
2973 let doc = load_doc("chicken.djvu");
2974 let page = doc.page(0).unwrap();
2975 let opts = RenderOptions {
2977 width: 80,
2978 height: 60,
2979 rotation: UserRotation::Cw90,
2980 ..Default::default()
2981 };
2982 let region = RenderRect {
2984 x: 0,
2985 y: 0,
2986 width: 40,
2987 height: 20,
2988 };
2989 let part = render_region(page, region, &opts).expect("region render should succeed");
2990 assert_eq!(
2992 part.width, 20,
2993 "expected width=20 (was region.height) after CW90 rotation"
2994 );
2995 assert_eq!(
2996 part.height, 40,
2997 "expected height=40 (was region.width) after CW90 rotation"
2998 );
2999 }
3000}