GPUI's RenderImage contract is BGRA (assets.rs) and every platform atlas uploads the bytes raw into BGRA8 textures. All other decode paths swap R/B before building a RenderImage; the PDF path fed pdfium's RGBA buffer straight through, so PDF pages rendered with swapped channels on every platform (white/gray unaffected, colors wrong). Swap R/B at both RenderImage construction sites (page view + thumbnails) and pin pdf_viewer's RGBA raster contract with a native channel-order regression test (skips when no libpdfium is present).
189 lines
6.6 KiB
Rust
189 lines
6.6 KiB
Rust
use pdf_viewer::rasterizer::RasterizerOptions;
|
|
use pdf_viewer::{PageLayoutMode, PdfToolbarState, PdfView, PdfViewerSettings, PdfiumEngine};
|
|
use std::time::Duration;
|
|
|
|
const MINIMAL_PDF: &[u8] = b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>\nendobj\nxref\n0 4\n0000000000 65535 f \n0000000010 00000 n \n0000000060 00000 n \n0000000117 00000 n \ntrailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n185\n%%EOF\n";
|
|
|
|
#[test]
|
|
fn test_end_to_end_pdf_view_lifecycle() {
|
|
let settings = PdfViewerSettings {
|
|
cache_budget_mb: 16,
|
|
..Default::default()
|
|
};
|
|
|
|
let mut view = PdfView::new(settings);
|
|
let sample_pdf = MINIMAL_PDF;
|
|
|
|
view.load_from_bytes(sample_pdf.to_vec(), None)
|
|
.expect("Load sample PDF bytes");
|
|
|
|
assert_eq!(view.total_pages(), 1);
|
|
assert_eq!(view.display_page_number(), 1);
|
|
|
|
// Initial container layout
|
|
view.set_container_size(1024.0, 768.0);
|
|
assert!(view.zoom_percentage() > 0);
|
|
|
|
// Zoom manipulations
|
|
view.set_zoom(1.5, Some((512.0, 384.0)));
|
|
assert_eq!(view.zoom_percentage(), 150);
|
|
|
|
view.zoom_in(None);
|
|
assert!(view.zoom_percentage() > 150);
|
|
|
|
view.reset_zoom();
|
|
assert_eq!(view.zoom_percentage(), 100);
|
|
|
|
// Rendering page
|
|
let rendered = view.get_or_render_page(0).expect("Render page 0");
|
|
assert!(rendered.width > 0);
|
|
assert!(rendered.height > 0);
|
|
assert!(rendered.dark_mode); // smart dark mode default
|
|
|
|
// Dark mode toggle
|
|
view.toggle_dark_mode();
|
|
let rendered_light = view.get_or_render_page(0).expect("Render light mode");
|
|
assert!(!rendered_light.dark_mode);
|
|
}
|
|
|
|
#[test]
|
|
fn test_reload_preserving_exact_viewport_and_zoom() {
|
|
let temp_dir = tempfile::tempdir().expect("tempdir");
|
|
let pdf_path = temp_dir.path().join("document.pdf");
|
|
|
|
// Write initial PDF
|
|
{
|
|
std::fs::write(&pdf_path, MINIMAL_PDF).expect("write");
|
|
}
|
|
|
|
let mut view = PdfView::new(PdfViewerSettings::default());
|
|
view.open_file(&pdf_path).expect("open file");
|
|
|
|
// Simulate user zooming and panning
|
|
view.set_zoom(2.5, None);
|
|
view.handle_mouse_down((100.0, 100.0));
|
|
view.handle_mouse_move((150.0, 200.0));
|
|
view.handle_mouse_up();
|
|
|
|
let initial_zoom = view.zoom_percentage();
|
|
let snapshot = view.save_state_snapshot();
|
|
assert_eq!(initial_zoom, 250);
|
|
assert_eq!(snapshot.pan_x, 50.0);
|
|
assert_eq!(snapshot.pan_y, 100.0);
|
|
|
|
// Sleep to ensure timestamp difference
|
|
std::thread::sleep(Duration::from_millis(25));
|
|
|
|
// Compiler rebuilds the PDF
|
|
{
|
|
std::fs::write(&pdf_path, MINIMAL_PDF).expect("rebuild");
|
|
}
|
|
|
|
// Debounced hot reload trigger
|
|
let reloaded = view.reload_preserving_state().expect("hot reload");
|
|
assert!(reloaded, "Reload should succeed");
|
|
|
|
// Verify viewport state was strictly preserved
|
|
assert_eq!(view.zoom_percentage(), initial_zoom);
|
|
let new_snapshot = view.save_state_snapshot();
|
|
assert_eq!(new_snapshot.pan_x, 50.0);
|
|
assert_eq!(new_snapshot.pan_y, 100.0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_toolbar_synchronization_with_view_state() {
|
|
let view = PdfView::new(PdfViewerSettings::default());
|
|
let toolbar_state = PdfToolbarState::new(
|
|
view.display_page_number(),
|
|
view.total_pages(),
|
|
view.zoom_percentage(),
|
|
view.dark_mode(),
|
|
view.layout_mode(),
|
|
);
|
|
|
|
assert_eq!(toolbar_state.current_page, 1);
|
|
assert_eq!(toolbar_state.zoom_percentage, 100);
|
|
assert!(toolbar_state.dark_mode);
|
|
assert_eq!(toolbar_state.layout_mode, PageLayoutMode::Continuous);
|
|
}
|
|
|
|
/// Builds a single-page PDF whose entire canvas is filled pure red, with a fully
|
|
/// computed xref table so no repair parsing is involved.
|
|
fn red_page_pdf() -> Vec<u8> {
|
|
let content = b"1 0 0 rg 0 0 612 792 re f";
|
|
let objects: Vec<Vec<u8>> = vec![
|
|
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
|
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
|
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << >> /Contents 4 0 R >>"
|
|
.to_vec(),
|
|
format!(
|
|
"<< /Length {} >>\nstream\n{}\nendstream",
|
|
content.len(),
|
|
String::from_utf8_lossy(content)
|
|
)
|
|
.into_bytes(),
|
|
];
|
|
|
|
let mut out = b"%PDF-1.7\n".to_vec();
|
|
let mut offsets = Vec::with_capacity(objects.len());
|
|
for (index, object) in objects.iter().enumerate() {
|
|
offsets.push(out.len() as u32);
|
|
out.extend_from_slice(format!("{} 0 obj\n", index + 1).as_bytes());
|
|
out.extend_from_slice(object);
|
|
out.extend_from_slice(b"\nendobj\n");
|
|
}
|
|
|
|
let xref_offset = out.len() as u32;
|
|
out.extend_from_slice(format!("xref\n0 {}\n", objects.len() + 1).as_bytes());
|
|
out.extend_from_slice(b"0000000000 65535 f \n");
|
|
for offset in &offsets {
|
|
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
|
}
|
|
out.extend_from_slice(
|
|
format!(
|
|
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{}\n%%EOF",
|
|
objects.len() + 1,
|
|
xref_offset
|
|
)
|
|
.as_bytes(),
|
|
);
|
|
out
|
|
}
|
|
|
|
/// Guards against PDFium's native BGRA bitmap order leaking through: the rasterizer
|
|
/// must hand out RGBA buffers, so a red page must read as high-R/low-B. The mock
|
|
/// renderer also draws red, so this only proves something when a native engine
|
|
/// bound; without libpdfium the test cannot exercise the real byte order.
|
|
#[test]
|
|
fn test_native_render_outputs_rgba_channel_order() {
|
|
let engine = PdfiumEngine::new();
|
|
if !engine.is_native_available() {
|
|
eprintln!("skipping: native libpdfium not available on this machine");
|
|
return;
|
|
}
|
|
|
|
let rendered = engine
|
|
.render_page_from_bytes(&red_page_pdf(), 0, RasterizerOptions::default())
|
|
.expect("native render should succeed");
|
|
|
|
let stride = rendered.width as usize * 4;
|
|
assert_eq!(rendered.rgba_buffer.len(), stride * rendered.height as usize);
|
|
|
|
for (x, y) in [
|
|
(rendered.width as usize / 2, rendered.height as usize / 2),
|
|
(rendered.width as usize / 4, rendered.height as usize / 4),
|
|
(
|
|
rendered.width as usize * 3 / 4,
|
|
rendered.height as usize * 3 / 4,
|
|
),
|
|
] {
|
|
let pixel = &rendered.rgba_buffer[y * stride + x * 4..y * stride + x * 4 + 4];
|
|
let (r, g, b, a) = (pixel[0], pixel[1], pixel[2], pixel[3]);
|
|
assert!(
|
|
r > 200 && b < 60,
|
|
"buffer is not RGBA-ordered at ({x},{y}): R={r} G={g} B={b} A={a}"
|
|
);
|
|
assert_eq!(a, 255, "opaque page should carry alpha 255");
|
|
}
|
|
}
|