Skip to main content

superbook_pdf/
finalize.rs

1//! Final Output Processing module
2//!
3//! Provides functionality for final page processing including
4//! gradient padding resize and edge feathering.
5//!
6//! # Features
7//!
8//! - Paper color preserving resize
9//! - Page offset shift application
10//! - Edge feathering for seamless blending
11//! - Final output to target height (3508)
12//!
13//! # Example
14//!
15//! ```rust,no_run
16//! use superbook_pdf::finalize::{FinalizeOptions, PageFinalizer};
17//! use std::path::Path;
18//!
19//! let options = FinalizeOptions::builder()
20//!     .target_height(3508)
21//!     .build();
22//!
23//! let result = PageFinalizer::finalize(
24//!     Path::new("input.png"),
25//!     Path::new("output.png"),
26//!     &options,
27//!     None, // No crop region
28//!     0, 0, // No shift
29//! ).unwrap();
30//! ```
31
32use image::{GenericImageView, Rgb, RgbImage};
33use std::path::{Path, PathBuf};
34use thiserror::Error;
35
36use crate::normalize::{CornerColors, ImageNormalizer};
37
38// ============================================================
39// Constants
40// ============================================================
41
42/// Standard final output height (A4-ish at 300 DPI)
43pub const FINAL_TARGET_HEIGHT: u32 = 3508;
44
45/// Default corner patch percentage for paper color sampling
46const DEFAULT_CORNER_PATCH_PERCENT: u32 = 3;
47
48/// Default feather pixels for edge blending
49const DEFAULT_FEATHER_PIXELS: u32 = 4;
50
51// ============================================================
52// Error Types
53// ============================================================
54
55/// Finalization error types
56#[derive(Debug, Error)]
57pub enum FinalizeError {
58    #[error("Image not found: {0}")]
59    ImageNotFound(PathBuf),
60
61    #[error("Invalid image: {0}")]
62    InvalidImage(String),
63
64    #[error("Failed to save image: {0}")]
65    SaveError(String),
66
67    #[error("Invalid crop region")]
68    InvalidCropRegion,
69
70    #[error("IO error: {0}")]
71    IoError(#[from] std::io::Error),
72}
73
74pub type Result<T> = std::result::Result<T, FinalizeError>;
75
76// ============================================================
77// Data Structures
78// ============================================================
79
80/// Crop region rectangle
81#[derive(Debug, Clone, Copy)]
82pub struct CropRegion {
83    pub x: i32,
84    pub y: i32,
85    pub width: u32,
86    pub height: u32,
87}
88
89impl CropRegion {
90    /// Create a new crop region
91    pub fn new(x: i32, y: i32, width: u32, height: u32) -> Self {
92        Self {
93            x,
94            y,
95            width,
96            height,
97        }
98    }
99
100    /// Right edge coordinate
101    pub fn right(&self) -> i32 {
102        self.x + self.width as i32
103    }
104
105    /// Bottom edge coordinate
106    pub fn bottom(&self) -> i32 {
107        self.y + self.height as i32
108    }
109
110    /// Create from bounding coordinates
111    pub fn from_bounds(left: i32, top: i32, right: i32, bottom: i32) -> Self {
112        let width = (right - left).max(0) as u32;
113        let height = (bottom - top).max(0) as u32;
114        Self {
115            x: left,
116            y: top,
117            width,
118            height,
119        }
120    }
121}
122
123/// Finalization options
124#[derive(Debug, Clone)]
125pub struct FinalizeOptions {
126    /// Target output width (calculated from height and aspect ratio if not set)
127    pub target_width: Option<u32>,
128    /// Target output height
129    pub target_height: u32,
130    /// Margin percentage to add around content
131    pub margin_percent: u32,
132    /// Feather pixels for edge blending
133    pub feather_pixels: u32,
134    /// Corner patch percentage for paper color sampling
135    pub corner_patch_percent: u32,
136}
137
138impl Default for FinalizeOptions {
139    fn default() -> Self {
140        Self {
141            target_width: None,
142            target_height: FINAL_TARGET_HEIGHT,
143            margin_percent: 0,
144            feather_pixels: DEFAULT_FEATHER_PIXELS,
145            corner_patch_percent: DEFAULT_CORNER_PATCH_PERCENT,
146        }
147    }
148}
149
150impl FinalizeOptions {
151    /// Create a new options builder
152    pub fn builder() -> FinalizeOptionsBuilder {
153        FinalizeOptionsBuilder::default()
154    }
155}
156
157/// Builder for FinalizeOptions
158#[derive(Debug, Default)]
159pub struct FinalizeOptionsBuilder {
160    options: FinalizeOptions,
161}
162
163impl FinalizeOptionsBuilder {
164    /// Set target width
165    #[must_use]
166    pub fn target_width(mut self, width: u32) -> Self {
167        self.options.target_width = Some(width);
168        self
169    }
170
171    /// Set target height
172    #[must_use]
173    pub fn target_height(mut self, height: u32) -> Self {
174        self.options.target_height = height;
175        self
176    }
177
178    /// Set margin percentage
179    #[must_use]
180    pub fn margin_percent(mut self, percent: u32) -> Self {
181        self.options.margin_percent = percent.clamp(0, 50);
182        self
183    }
184
185    /// Set feather pixels
186    #[must_use]
187    pub fn feather_pixels(mut self, pixels: u32) -> Self {
188        self.options.feather_pixels = pixels;
189        self
190    }
191
192    /// Set corner patch percentage
193    #[must_use]
194    pub fn corner_patch_percent(mut self, percent: u32) -> Self {
195        self.options.corner_patch_percent = percent.clamp(1, 20);
196        self
197    }
198
199    /// Build the options
200    #[must_use]
201    pub fn build(self) -> FinalizeOptions {
202        self.options
203    }
204}
205
206/// Finalization result
207#[derive(Debug, Clone)]
208pub struct FinalizeResult {
209    /// Input path
210    pub input_path: PathBuf,
211    /// Output path
212    pub output_path: PathBuf,
213    /// Original image size
214    pub original_size: (u32, u32),
215    /// Final output size
216    pub final_size: (u32, u32),
217    /// Scale factor used
218    pub scale: f64,
219    /// Shift applied (x, y)
220    pub shift_applied: (i32, i32),
221}
222
223// ============================================================
224// Page Finalizer
225// ============================================================
226
227/// Page finalizer for final output generation
228pub struct PageFinalizer;
229
230impl PageFinalizer {
231    /// Finalize a page with optional crop region and shift
232    pub fn finalize(
233        input_path: &Path,
234        output_path: &Path,
235        options: &FinalizeOptions,
236        crop_region: Option<CropRegion>,
237        shift_x: i32,
238        shift_y: i32,
239    ) -> Result<FinalizeResult> {
240        if !input_path.exists() {
241            return Err(FinalizeError::ImageNotFound(input_path.to_path_buf()));
242        }
243
244        let img =
245            image::open(input_path).map_err(|e| FinalizeError::InvalidImage(e.to_string()))?;
246
247        let (orig_w, orig_h) = img.dimensions();
248        let rgb_img = img.to_rgb8();
249
250        // Determine crop region (or use full image)
251        let crop = crop_region.unwrap_or_else(|| CropRegion::new(0, 0, orig_w, orig_h));
252
253        // Calculate output dimensions
254        let (final_w, final_h, scale) =
255            Self::calculate_output_dimensions(crop.width, crop.height, options);
256
257        // Sample corner colors for gradient background
258        let corners = ImageNormalizer::sample_corner_colors(&rgb_img, options.corner_patch_percent);
259
260        // Create final output with gradient background
261        let final_img = Self::create_final_image(
262            &rgb_img,
263            final_w,
264            final_h,
265            &crop,
266            scale,
267            shift_x,
268            shift_y,
269            &corners,
270            options.feather_pixels,
271        );
272
273        // Save result
274        final_img
275            .save(output_path)
276            .map_err(|e| FinalizeError::SaveError(e.to_string()))?;
277
278        Ok(FinalizeResult {
279            input_path: input_path.to_path_buf(),
280            output_path: output_path.to_path_buf(),
281            original_size: (orig_w, orig_h),
282            final_size: (final_w, final_h),
283            scale,
284            shift_applied: (shift_x, shift_y),
285        })
286    }
287
288    /// Finalize multiple pages with unified crop regions for odd/even groups
289    pub fn finalize_batch(
290        pages: &[(PathBuf, PathBuf, bool)], // (input, output, is_odd)
291        options: &FinalizeOptions,
292        odd_crop: Option<CropRegion>,
293        even_crop: Option<CropRegion>,
294        page_shifts: &[(i32, i32)],
295    ) -> Result<Vec<FinalizeResult>> {
296        let mut results = Vec::with_capacity(pages.len());
297
298        for (i, (input, output, is_odd)) in pages.iter().enumerate() {
299            let crop = if *is_odd { odd_crop } else { even_crop };
300            let (shift_x, shift_y) = page_shifts.get(i).copied().unwrap_or((0, 0));
301
302            let result = Self::finalize(input, output, options, crop, shift_x, shift_y)?;
303            results.push(result);
304        }
305
306        Ok(results)
307    }
308
309    /// Calculate output dimensions based on crop region and options
310    fn calculate_output_dimensions(
311        crop_w: u32,
312        crop_h: u32,
313        options: &FinalizeOptions,
314    ) -> (u32, u32, f64) {
315        let target_h = options.target_height;
316
317        // Calculate width to maintain aspect ratio
318        let scale = target_h as f64 / crop_h as f64;
319        let final_w = options
320            .target_width
321            .unwrap_or_else(|| (crop_w as f64 * scale).round() as u32);
322
323        (final_w, target_h, scale)
324    }
325
326    /// Create the final output image
327    #[allow(clippy::too_many_arguments)]
328    fn create_final_image(
329        src: &RgbImage,
330        final_w: u32,
331        final_h: u32,
332        crop: &CropRegion,
333        scale: f64,
334        shift_x: i32,
335        shift_y: i32,
336        corners: &CornerColors,
337        feather: u32,
338    ) -> RgbImage {
339        // Calculate scaled shift
340        let scaled_shift_x = (shift_x as f64 * scale).round() as i32;
341        let scaled_shift_y = (shift_y as f64 * scale).round() as i32;
342
343        // Calculate offset with crop position
344        let crop_offset_x = (-crop.x as f64 * scale).round() as i32 + scaled_shift_x;
345        let crop_offset_y = (-crop.y as f64 * scale).round() as i32 + scaled_shift_y;
346
347        // Create gradient background canvas
348        let mut canvas = Self::create_gradient_canvas(final_w, final_h, corners);
349
350        // Scale and place the source image
351        let scaled_w = (src.width() as f64 * scale).round() as u32;
352        let scaled_h = (src.height() as f64 * scale).round() as u32;
353
354        // Resize source image
355        let scaled_img = image::imageops::resize(
356            src,
357            scaled_w,
358            scaled_h,
359            image::imageops::FilterType::Lanczos3,
360        );
361
362        // Draw scaled image with offset
363        for y in 0..scaled_h {
364            for x in 0..scaled_w {
365                let px = crop_offset_x + x as i32;
366                let py = crop_offset_y + y as i32;
367
368                if px >= 0 && (px as u32) < final_w && py >= 0 && (py as u32) < final_h {
369                    canvas.put_pixel(px as u32, py as u32, *scaled_img.get_pixel(x, y));
370                }
371            }
372        }
373
374        // Apply feathering at edges
375        if feather > 0 {
376            Self::apply_feather(
377                &mut canvas,
378                crop_offset_x,
379                crop_offset_y,
380                scaled_w,
381                scaled_h,
382                feather,
383            );
384        }
385
386        canvas
387    }
388
389    /// Create a gradient background canvas using corner colors
390    fn create_gradient_canvas(width: u32, height: u32, corners: &CornerColors) -> RgbImage {
391        let mut canvas = RgbImage::new(width, height);
392
393        for y in 0..height {
394            let v = y as f32 / (height - 1).max(1) as f32;
395            for x in 0..width {
396                let u = x as f32 / (width - 1).max(1) as f32;
397                let color = corners.interpolate(u, v);
398                canvas.put_pixel(x, y, Rgb([color.r, color.g, color.b]));
399            }
400        }
401
402        canvas
403    }
404
405    /// Apply edge feathering
406    fn apply_feather(
407        canvas: &mut RgbImage,
408        off_x: i32,
409        off_y: i32,
410        img_w: u32,
411        img_h: u32,
412        range: u32,
413    ) {
414        let (canvas_w, canvas_h) = canvas.dimensions();
415        let range = range as i32;
416
417        // Create a copy for reading background values
418        let bg_copy = canvas.clone();
419
420        for y in (off_y - range).max(0)..(off_y + img_h as i32 + range).min(canvas_h as i32) {
421            for x in (off_x - range).max(0)..(off_x + img_w as i32 + range).min(canvas_w as i32) {
422                // Calculate distance from image edge
423                let dx = if x < off_x {
424                    off_x - x
425                } else if x >= off_x + img_w as i32 {
426                    x - (off_x + img_w as i32 - 1)
427                } else {
428                    0
429                };
430
431                let dy = if y < off_y {
432                    off_y - y
433                } else if y >= off_y + img_h as i32 {
434                    y - (off_y + img_h as i32 - 1)
435                } else {
436                    0
437                };
438
439                let d = dx.max(dy);
440                if d >= range || d == 0 {
441                    continue;
442                }
443
444                // Blend factor
445                let alpha = d as f32 / range as f32;
446
447                let bg = bg_copy.get_pixel(x as u32, y as u32);
448                let fg = canvas.get_pixel(x as u32, y as u32);
449                let blended = Self::lerp_rgb(bg, fg, 1.0 - alpha);
450                canvas.put_pixel(x as u32, y as u32, blended);
451            }
452        }
453    }
454
455    fn lerp_rgb(a: &Rgb<u8>, b: &Rgb<u8>, t: f32) -> Rgb<u8> {
456        fn lerp(a: u8, b: u8, t: f32) -> u8 {
457            (a as f32 + (b as f32 - a as f32) * t)
458                .round()
459                .clamp(0.0, 255.0) as u8
460        }
461
462        Rgb([
463            lerp(a.0[0], b.0[0], t),
464            lerp(a.0[1], b.0[1], t),
465            lerp(a.0[2], b.0[2], t),
466        ])
467    }
468}
469
470/// Add margin to crop region and clip to image bounds
471pub fn add_margin_and_clip(
472    region: &CropRegion,
473    margin: i32,
474    img_width: u32,
475    img_height: u32,
476) -> CropRegion {
477    if region.width == 0 || region.height == 0 {
478        return CropRegion::new(0, 0, img_width, img_height);
479    }
480
481    let left = (region.x - margin).max(0);
482    let top = (region.y - margin).max(0);
483    let right = (region.right() + margin).min(img_width as i32 - 1);
484    let bottom = (region.bottom() + margin).min(img_height as i32 - 1);
485
486    let width = (right - left + 1).max(1) as u32;
487    let height = (bottom - top + 1).max(1) as u32;
488
489    CropRegion {
490        x: left,
491        y: top,
492        width,
493        height,
494    }
495}
496
497// ============================================================
498// Tests
499// ============================================================
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504    use crate::normalize::PaperColor;
505    use tempfile::tempdir;
506
507    #[test]
508    fn test_default_options() {
509        let opts = FinalizeOptions::default();
510        assert_eq!(opts.target_height, FINAL_TARGET_HEIGHT);
511        assert_eq!(opts.margin_percent, 0);
512        assert!(opts.target_width.is_none());
513    }
514
515    #[test]
516    fn test_builder() {
517        let opts = FinalizeOptions::builder()
518            .target_width(2480)
519            .target_height(3508)
520            .margin_percent(5)
521            .feather_pixels(8)
522            .build();
523
524        assert_eq!(opts.target_width, Some(2480));
525        assert_eq!(opts.target_height, 3508);
526        assert_eq!(opts.margin_percent, 5);
527        assert_eq!(opts.feather_pixels, 8);
528    }
529
530    #[test]
531    fn test_crop_region() {
532        let region = CropRegion::new(100, 200, 500, 600);
533        assert_eq!(region.x, 100);
534        assert_eq!(region.y, 200);
535        assert_eq!(region.right(), 600);
536        assert_eq!(region.bottom(), 800);
537    }
538
539    #[test]
540    fn test_crop_region_from_bounds() {
541        let region = CropRegion::from_bounds(50, 100, 550, 700);
542        assert_eq!(region.x, 50);
543        assert_eq!(region.y, 100);
544        assert_eq!(region.width, 500);
545        assert_eq!(region.height, 600);
546    }
547
548    #[test]
549    fn test_add_margin_and_clip() {
550        let region = CropRegion::new(100, 100, 800, 600);
551        let clipped = add_margin_and_clip(&region, 50, 1000, 800);
552
553        assert_eq!(clipped.x, 50);
554        assert_eq!(clipped.y, 50);
555        // Width should expand but clip at image bounds
556    }
557
558    #[test]
559    fn test_add_margin_empty_region() {
560        let region = CropRegion::new(0, 0, 0, 0);
561        let clipped = add_margin_and_clip(&region, 10, 1000, 800);
562
563        // Should return full image for empty region
564        assert_eq!(clipped.width, 1000);
565        assert_eq!(clipped.height, 800);
566    }
567
568    #[test]
569    fn test_image_not_found() {
570        let result = PageFinalizer::finalize(
571            Path::new("/nonexistent/image.png"),
572            Path::new("/output.png"),
573            &FinalizeOptions::default(),
574            None,
575            0,
576            0,
577        );
578        assert!(matches!(result, Err(FinalizeError::ImageNotFound(_))));
579    }
580
581    #[test]
582    fn test_finalize_result_fields() {
583        let result = FinalizeResult {
584            input_path: PathBuf::from("/input.png"),
585            output_path: PathBuf::from("/output.png"),
586            original_size: (4960, 7016),
587            final_size: (2480, 3508),
588            scale: 0.5,
589            shift_applied: (10, -5),
590        };
591
592        assert_eq!(result.original_size, (4960, 7016));
593        assert_eq!(result.final_size, (2480, 3508));
594        assert_eq!(result.shift_applied, (10, -5));
595    }
596
597    #[test]
598    fn test_calculate_output_dimensions() {
599        let options = FinalizeOptions::default();
600        let (w, h, scale) = PageFinalizer::calculate_output_dimensions(2480, 3508, &options);
601
602        assert_eq!(h, FINAL_TARGET_HEIGHT);
603        assert!(scale > 0.0);
604        assert!(w > 0);
605    }
606
607    #[test]
608    fn test_margin_percent_clamping() {
609        let opts = FinalizeOptions::builder()
610            .margin_percent(100) // Should clamp to 50
611            .build();
612        assert_eq!(opts.margin_percent, 50);
613    }
614
615    #[test]
616    fn test_send_sync() {
617        fn assert_send_sync<T: Send + Sync>() {}
618        assert_send_sync::<FinalizeOptions>();
619        assert_send_sync::<FinalizeResult>();
620        assert_send_sync::<CropRegion>();
621        assert_send_sync::<FinalizeError>();
622    }
623
624    #[test]
625    fn test_error_types() {
626        let _err1 = FinalizeError::ImageNotFound(PathBuf::from("/test"));
627        let _err2 = FinalizeError::InvalidImage("bad".to_string());
628        let _err3 = FinalizeError::SaveError("failed".to_string());
629        let _err4 = FinalizeError::InvalidCropRegion;
630    }
631
632    #[test]
633    fn test_finalize_with_fixture() {
634        let temp_dir = tempdir().unwrap();
635        let output = temp_dir.path().join("finalized.png");
636
637        let options = FinalizeOptions::builder().target_height(200).build();
638
639        let result = PageFinalizer::finalize(
640            Path::new("tests/fixtures/with_margins.png"),
641            &output,
642            &options,
643            None,
644            0,
645            0,
646        );
647
648        match result {
649            Ok(r) => {
650                assert!(output.exists());
651                assert_eq!(r.final_size.1, 200);
652            }
653            Err(e) => {
654                eprintln!("Finalize error: {:?}", e);
655            }
656        }
657    }
658
659    #[test]
660    fn test_create_gradient_canvas() {
661        let corners = CornerColors {
662            top_left: PaperColor::new(255, 255, 255),
663            top_right: PaperColor::new(250, 250, 250),
664            bottom_left: PaperColor::new(245, 245, 245),
665            bottom_right: PaperColor::new(240, 240, 240),
666        };
667
668        let canvas = PageFinalizer::create_gradient_canvas(100, 100, &corners);
669        assert_eq!(canvas.dimensions(), (100, 100));
670
671        // Top-left should be close to white
672        let tl = canvas.get_pixel(0, 0);
673        assert!(tl.0[0] > 250);
674    }
675
676    // ============================================================
677    // TC-FINAL Spec Tests
678    // ============================================================
679
680    // TC-FINAL-001: 標準リサイズ - 3508高さ
681    #[test]
682    fn test_tc_final_001_standard_resize_to_3508() {
683        let options = FinalizeOptions::default();
684
685        // Default target height should be 3508
686        assert_eq!(options.target_height, FINAL_TARGET_HEIGHT);
687        assert_eq!(options.target_height, 3508);
688
689        // Test dimension calculation for large image
690        let (w, h, scale) = PageFinalizer::calculate_output_dimensions(4960, 7016, &options);
691        assert_eq!(h, 3508, "Output height should be 3508");
692        assert!(scale < 1.0, "Scale should be < 1 for downscaling");
693        assert!(w > 0, "Output width should be positive");
694    }
695
696    // TC-FINAL-002: クロップ適用 - 正確なクロップ
697    #[test]
698    fn test_tc_final_002_crop_region_application() {
699        let region = CropRegion::new(100, 150, 800, 600);
700
701        // Verify region bounds
702        assert_eq!(region.x, 100);
703        assert_eq!(region.y, 150);
704        assert_eq!(region.width, 800);
705        assert_eq!(region.height, 600);
706        assert_eq!(region.right(), 900);
707        assert_eq!(region.bottom(), 750);
708
709        // Test clipping to image bounds
710        let clipped = add_margin_and_clip(&region, 0, 1000, 800);
711        assert!(clipped.right() <= 1000, "Right edge should not exceed image width");
712        assert!(clipped.bottom() <= 800, "Bottom edge should not exceed image height");
713    }
714
715    // TC-FINAL-003: シフト適用 - 位置移動
716    #[test]
717    fn test_tc_final_003_shift_application() {
718        // Test FinalizeResult with shift values
719        let result = FinalizeResult {
720            input_path: PathBuf::from("input.png"),
721            output_path: PathBuf::from("output.png"),
722            original_size: (2480, 3508),
723            final_size: (2480, 3508),
724            scale: 1.0,
725            shift_applied: (25, -15),
726        };
727
728        // Verify shift is recorded
729        assert_eq!(result.shift_applied.0, 25, "X shift should be 25");
730        assert_eq!(result.shift_applied.1, -15, "Y shift should be -15");
731    }
732
733    // TC-FINAL-004: 紙色パディング - 自然な余白
734    #[test]
735    fn test_tc_final_004_paper_color_padding() {
736        let corners = CornerColors {
737            top_left: PaperColor::new(252, 250, 248),
738            top_right: PaperColor::new(253, 251, 249),
739            bottom_left: PaperColor::new(251, 249, 247),
740            bottom_right: PaperColor::new(250, 248, 246),
741        };
742
743        // Create gradient canvas with paper color
744        let canvas = PageFinalizer::create_gradient_canvas(200, 300, &corners);
745        assert_eq!(canvas.dimensions(), (200, 300));
746
747        // Center should be interpolated between corners
748        let center = canvas.get_pixel(100, 150);
749        // Should be close to average of corners (roughly 251-252 for R)
750        assert!(
751            center.0[0] >= 248,
752            "Center R {} should be near paper color (>= 248)",
753            center.0[0]
754        );
755    }
756
757    // TC-FINAL-005: バッチ処理 - 全ページ処理
758    #[test]
759    fn test_tc_final_005_batch_processing() {
760        let temp_dir = tempdir().unwrap();
761
762        // Create test images: (input, output, is_odd)
763        let mut pages = Vec::new();
764        for i in 0..3 {
765            let input = temp_dir.path().join(format!("page_{}.png", i));
766            let output = temp_dir.path().join(format!("final_{}.png", i));
767            let img = image::RgbImage::from_pixel(200, 300, image::Rgb([255, 255, 255]));
768            img.save(&input).unwrap();
769            pages.push((input, output, i % 2 == 1)); // odd pages: 1, 3, ...
770        }
771
772        let options = FinalizeOptions::builder().target_height(150).build();
773        let page_shifts = vec![(0, 0), (5, -3), (0, 0)];
774
775        let results = PageFinalizer::finalize_batch(&pages, &options, None, None, &page_shifts);
776
777        // Should succeed
778        assert!(results.is_ok(), "Batch processing should succeed");
779        let results = results.unwrap();
780
781        assert_eq!(
782            results.len(),
783            3,
784            "All {} pages should be processed",
785            pages.len()
786        );
787
788        // Verify all outputs exist
789        for (_, output, _) in &pages {
790            assert!(output.exists(), "Output {:?} should exist", output);
791        }
792    }
793}