ruviz 0.4.8

High-performance 2D plotting library for Rust
Documentation
//! Export functionality
//!
//! Provides export capabilities for various formats:
//! - PNG: Raster export via `Plot::save()`
//! - SVG: Vector export via `Plot::to_svg()` or `Plot::render_to_svg()`
//! - PDF: Vector export via `Plot::save_pdf()` (requires `pdf` feature)
//!
//! The PDF export uses an SVG -> PDF pipeline for high-quality vector output.

use crate::{
    core::plot::Image,
    core::{PlottingError, Result},
};
use image::{
    ColorType, ImageEncoder,
    codecs::png::{CompressionType, FilterType, PngEncoder},
};
use std::fs::{self, File, OpenOptions};
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};

#[cfg(windows)]
use std::{iter::once, os::windows::ffi::OsStrExt};

#[cfg(windows)]
use windows_sys::Win32::Storage::FileSystem::{MOVEFILE_REPLACE_EXISTING, MoveFileExW};

const TEMP_FILE_CREATE_RETRIES: usize = 8;

pub mod svg;

#[cfg(feature = "pdf")]
pub mod pdf;

#[cfg(feature = "pdf")]
pub mod svg_to_pdf;

pub use svg::SvgRenderer;

#[cfg(feature = "pdf")]
pub use pdf::PdfRenderer;

#[cfg(feature = "pdf")]
pub use svg_to_pdf::{page_sizes, svg_to_pdf, svg_to_pdf_file};

fn validate_rgba_image(image: &Image) -> Result<()> {
    let expected_len = (image.width as usize)
        .saturating_mul(image.height as usize)
        .saturating_mul(4);
    if image.pixels.len() != expected_len {
        return Err(PlottingError::InvalidInput(format!(
            "RGBA image buffer length mismatch: expected {expected_len} bytes for {}x{}, got {}",
            image.width,
            image.height,
            image.pixels.len()
        )));
    }

    Ok(())
}

/// Encode an in-memory RGBA image as PNG bytes.
pub fn encode_rgba_png(image: &Image) -> Result<Vec<u8>> {
    validate_rgba_image(image)?;

    let mut bytes = Vec::new();
    PngEncoder::new_with_quality(&mut bytes, CompressionType::Fast, FilterType::Adaptive)
        .write_image(
            &image.pixels,
            image.width,
            image.height,
            ColorType::Rgba8.into(),
        )
        .map_err(|err| PlottingError::RenderError(format!("failed to encode PNG: {err}")))?;

    Ok(bytes)
}

fn atomic_temp_path(path: &Path) -> PathBuf {
    static TEMP_PATH_NONCE: AtomicU64 = AtomicU64::new(0);
    let parent = path.parent().unwrap_or_else(|| Path::new("."));
    let file_name = path
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or("ruviz-output");
    let nonce = TEMP_PATH_NONCE.fetch_add(1, Ordering::Relaxed);
    parent.join(format!(
        ".{}.{}.{}.tmp",
        file_name,
        std::process::id(),
        nonce
    ))
}

fn cleanup_temp_file(path: &Path) {
    let _ = fs::remove_file(path);
}

fn ensure_parent_dir(path: &Path) -> std::io::Result<()> {
    if let Some(parent) = path.parent() {
        if !parent.as_os_str().is_empty() {
            fs::create_dir_all(parent)?;
        }
    }

    Ok(())
}

fn resolve_atomic_destination(path: &Path) -> std::io::Result<PathBuf> {
    #[cfg(unix)]
    {
        let mut current = path.to_path_buf();
        let mut hops = 0usize;

        loop {
            match fs::symlink_metadata(&current) {
                Ok(metadata) if metadata.file_type().is_symlink() => {
                    if hops >= 16 {
                        return Err(std::io::Error::other(
                            "too many symlink levels while resolving export destination",
                        ));
                    }

                    let target = fs::read_link(&current)?;
                    current = if target.is_absolute() {
                        target
                    } else {
                        current
                            .parent()
                            .unwrap_or_else(|| Path::new("."))
                            .join(target)
                    };
                    hops += 1;
                }
                Ok(_) => return Ok(current),
                Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(current),
                Err(err) => return Err(err),
            }
        }
    }

    #[cfg(not(unix))]
    {
        Ok(path.to_path_buf())
    }
}

struct AtomicWriteFailure {
    error: PlottingError,
    cleanup_temp: bool,
}

impl AtomicWriteFailure {
    fn cleanup(error: PlottingError) -> Self {
        Self {
            error,
            cleanup_temp: true,
        }
    }

    fn preserve_temp(error: PlottingError) -> Self {
        Self {
            error,
            cleanup_temp: false,
        }
    }
}

fn create_atomic_temp_file(path: &Path) -> std::io::Result<(PathBuf, File)> {
    let mut last_err = None;

    for _ in 0..TEMP_FILE_CREATE_RETRIES {
        let temp_path = atomic_temp_path(path);
        match OpenOptions::new()
            .create_new(true)
            .write(true)
            .open(&temp_path)
        {
            Ok(file) => return Ok((temp_path, file)),
            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
                // Stale temp file from a previous crash with the same PID.
                // No live process can share our PID, so removing it is safe.
                cleanup_temp_file(&temp_path);
                last_err = Some(err);
            }
            Err(err) => return Err(err),
        }
    }

    Err(last_err.unwrap_or_else(|| {
        std::io::Error::new(
            std::io::ErrorKind::AlreadyExists,
            "failed to allocate a unique temporary export file",
        )
    }))
}

fn rename_temp_into_place(
    temp_path: &Path,
    path: &Path,
) -> std::result::Result<(), AtomicWriteFailure> {
    #[cfg(windows)]
    {
        let temp_wide: Vec<u16> = temp_path.as_os_str().encode_wide().chain(once(0)).collect();
        let path_wide: Vec<u16> = path.as_os_str().encode_wide().chain(once(0)).collect();

        // MOVEFILE_REPLACE_EXISTING preserves the destination if the replacement fails,
        // avoiding the delete-then-rename data-loss window from the previous fallback.
        let replace_result = unsafe {
            MoveFileExW(
                temp_wide.as_ptr(),
                path_wide.as_ptr(),
                MOVEFILE_REPLACE_EXISTING,
            )
        };

        if replace_result != 0 {
            Ok(())
        } else {
            let err = std::io::Error::last_os_error();
            Err(AtomicWriteFailure::preserve_temp(PlottingError::IoError(
                std::io::Error::new(
                    err.kind(),
                    format!(
                        "failed to replace {} with {}; the temporary file has been preserved for recovery",
                        path.display(),
                        temp_path.display()
                    ),
                ),
            )))
        }
    }

    #[cfg(not(windows))]
    {
        fs::rename(temp_path, path)
            .map_err(|err| AtomicWriteFailure::cleanup(PlottingError::IoError(err)))
    }
}

pub(crate) fn write_bytes_atomic<P: AsRef<Path>>(path: P, bytes: &[u8]) -> Result<()> {
    let path = path.as_ref();
    let destination_path = resolve_atomic_destination(path).map_err(PlottingError::IoError)?;
    ensure_parent_dir(&destination_path).map_err(PlottingError::IoError)?;
    let (temp_path, mut file) =
        create_atomic_temp_file(&destination_path).map_err(PlottingError::IoError)?;

    let write_result = (|| -> std::result::Result<(), AtomicWriteFailure> {
        file.write_all(bytes)
            .map_err(|err| AtomicWriteFailure::cleanup(PlottingError::IoError(err)))?;
        file.sync_all()
            .map_err(|err| AtomicWriteFailure::cleanup(PlottingError::IoError(err)))?;
        drop(file);
        rename_temp_into_place(&temp_path, &destination_path)?;
        Ok(())
    })();

    if let Err(failure) = write_result {
        if failure.cleanup_temp {
            cleanup_temp_file(&temp_path);
        }
        return Err(failure.error);
    }

    Ok(())
}

/// Atomically writes an RGBA image as a PNG file.
pub fn write_rgba_png_atomic<P: AsRef<Path>>(path: P, image: &Image) -> Result<()> {
    validate_rgba_image(image)?;

    write_with_atomic_writer(path, |writer| {
        // Explicit settings keep encoder output stable across `image` crate
        // updates. `CompressionType::Fast` trades a bit of file size for lower
        // encode latency, while `FilterType::Adaptive` retains the normal
        // per-scanline filtering step.
        PngEncoder::new_with_quality(writer, CompressionType::Fast, FilterType::Adaptive)
            .write_image(
                &image.pixels,
                image.width,
                image.height,
                ColorType::Rgba8.into(),
            )
            .map_err(|err| PlottingError::RenderError(format!("failed to encode PNG: {err}")))
    })
}

pub(crate) fn write_with_atomic_writer<P, F>(path: P, writer: F) -> Result<()>
where
    P: AsRef<Path>,
    F: FnOnce(&mut BufWriter<File>) -> Result<()>,
{
    let path = path.as_ref();
    let destination_path = resolve_atomic_destination(path).map_err(PlottingError::IoError)?;
    ensure_parent_dir(&destination_path).map_err(PlottingError::IoError)?;
    let (temp_path, file) =
        create_atomic_temp_file(&destination_path).map_err(PlottingError::IoError)?;

    let write_result = (|| -> std::result::Result<(), AtomicWriteFailure> {
        let mut writer_handle = BufWriter::new(file);

        writer(&mut writer_handle).map_err(AtomicWriteFailure::cleanup)?;
        writer_handle
            .flush()
            .map_err(|err| AtomicWriteFailure::cleanup(PlottingError::IoError(err)))?;
        let file = writer_handle
            .into_inner()
            .map_err(|err| AtomicWriteFailure::cleanup(PlottingError::IoError(err.into_error())))?;
        file.sync_all()
            .map_err(|err| AtomicWriteFailure::cleanup(PlottingError::IoError(err)))?;
        drop(file);
        rename_temp_into_place(&temp_path, &destination_path)?;
        Ok(())
    })();

    if let Err(failure) = write_result {
        if failure.cleanup_temp {
            cleanup_temp_file(&temp_path);
        }
        return Err(failure.error);
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[cfg(unix)]
    #[test]
    fn atomic_write_preserves_symlink_and_updates_target() {
        use std::os::unix::fs::symlink;

        let tempdir = tempfile::tempdir().expect("tempdir");
        let runs_dir = tempdir.path().join("runs");
        fs::create_dir(&runs_dir).expect("runs dir");

        let target_path = runs_dir.join("42.png");
        fs::write(&target_path, b"old-bytes").expect("seed target");

        let link_path = tempdir.path().join("latest.png");
        symlink(Path::new("runs/42.png"), &link_path).expect("create symlink");

        write_bytes_atomic(&link_path, b"new-bytes").expect("atomic write through symlink");

        assert!(
            fs::symlink_metadata(&link_path)
                .expect("symlink metadata")
                .file_type()
                .is_symlink()
        );
        assert_eq!(
            fs::read_link(&link_path).expect("read symlink"),
            PathBuf::from("runs/42.png")
        );
        assert_eq!(fs::read(&target_path).expect("read target"), b"new-bytes");
    }

    #[test]
    fn atomic_write_creates_missing_parent_directories() {
        let tempdir = tempfile::tempdir().expect("tempdir");
        let nested_path = tempdir.path().join("nested/output/example.bin");

        write_bytes_atomic(&nested_path, b"new-bytes").expect("atomic write to nested path");

        assert_eq!(
            fs::read(&nested_path).expect("read nested output"),
            b"new-bytes"
        );
    }
}