use std::collections::BTreeMap;
use std::path::Path;
use std::sync::OnceLock;
use ironlab_ir::{Axes, Cell, Dimension, NodeId, Parameter, Projection, Text, ValidationReport};
use ironlab_text::TextEngine;
use crate::axes::AxesMut;
use crate::error::Error;
use crate::{ExportReport, RasterOptions};
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Figure {
ir: ironlab_ir::Figure,
}
impl Figure {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn size_mm(mut self, width: f64, height: f64) -> Self {
self.ir.size.width_mm = width;
self.ir.size.height_mm = height;
self
}
#[must_use]
pub fn title(mut self, title: impl Into<Text>) -> Self {
self.ir.title = Some(title.into());
self
}
#[must_use]
pub fn font_size_pt(mut self, size: f64) -> Self {
self.ir.font_size_pt = size;
self
}
#[must_use]
pub fn tiles(mut self, rows: u32, cols: u32) -> Self {
self.ir.layout.rows = rows;
self.ir.layout.cols = cols;
self
}
#[must_use]
pub fn parameter(mut self, name: impl Into<String>, value: impl Into<Parameter>) -> Self {
self.ir.parameters.insert(name.into(), value.into());
self
}
#[must_use]
pub fn parameters(&self) -> &BTreeMap<String, Parameter> {
&self.ir.parameters
}
pub fn axes(&mut self, row: u32, col: u32) -> AxesMut<'_> {
let covers = |cell: &Cell| {
row.checked_sub(cell.row)
.is_some_and(|offset| offset < cell.row_span)
&& col
.checked_sub(cell.col)
.is_some_and(|offset| offset < cell.col_span)
};
let id = match self.ir.axes.iter().find(|axes| covers(&axes.cell)) {
Some(axes) => axes.id,
None => self.add_axes(Cell {
row,
col,
..Cell::default()
}),
};
AxesMut::new(&mut self.ir, id)
}
pub fn axes3(&mut self, row: u32, col: u32) -> AxesMut<'_> {
let mut axes = self.axes(row, col);
axes.make_3d();
axes
}
pub fn axes_span(&mut self, row: u32, col: u32, row_span: u32, col_span: u32) -> AxesMut<'_> {
let cell = Cell {
row,
col,
row_span,
col_span,
};
let existing = self
.ir
.axes
.iter_mut()
.find(|axes| (axes.cell.row, axes.cell.col) == (row, col));
let id = match existing {
Some(axes) => {
axes.cell = cell;
axes.id
}
None => self.add_axes(cell),
};
AxesMut::new(&mut self.ir, id)
}
pub fn link(&mut self, dim: Dimension, axes: &[NodeId]) -> Result<&mut Self, Error> {
self.ir.link(dim, axes)?;
Ok(self)
}
pub fn link_all(&mut self, dim: Dimension) -> Result<&mut Self, Error> {
self.ir.link_all(dim)?;
Ok(self)
}
pub fn link_all_x(&mut self) -> Result<&mut Self, Error> {
self.link_all(Dimension::X)
}
pub fn link_all_y(&mut self) -> Result<&mut Self, Error> {
self.link_all(Dimension::Y)
}
#[must_use]
pub fn ir(&self) -> &ironlab_ir::Figure {
&self.ir
}
pub fn ir_mut(&mut self) -> &mut ironlab_ir::Figure {
&mut self.ir
}
#[must_use]
pub fn into_ir(self) -> ironlab_ir::Figure {
self.ir
}
#[must_use]
pub fn from_ir(ir: ironlab_ir::Figure) -> Self {
Self { ir }
}
#[must_use]
pub fn validate(&self) -> ValidationReport {
self.ir.validate()
}
pub fn save(&self, path: impl AsRef<Path>) -> Result<(), Error> {
let path = path.as_ref();
match Format::of(path)? {
Format::Protobuf => std::fs::write(path, self.to_protobuf())?,
Format::Json => self.save_json(path)?,
}
Ok(())
}
pub fn load(path: impl AsRef<Path>) -> Result<Self, Error> {
let path = path.as_ref();
match Format::of(path)? {
Format::Protobuf => Ok(Self::from_protobuf(&std::fs::read(path)?)?),
Format::Json => Self::load_json(path),
}
}
pub fn save_json(&self, path: impl AsRef<Path>) -> Result<(), Error> {
std::fs::write(path, self.ir.to_json())?;
Ok(())
}
pub fn load_json(path: impl AsRef<Path>) -> Result<Self, Error> {
let json = std::fs::read_to_string(path)?;
Ok(Self::from_ir(ironlab_ir::Figure::from_json(&json)?))
}
#[must_use]
pub fn to_protobuf(&self) -> Vec<u8> {
self.ir.to_protobuf()
}
pub fn from_protobuf(bytes: &[u8]) -> Result<Self, Error> {
Ok(Self::from_ir(ironlab_ir::Figure::from_protobuf(bytes)?))
}
pub fn export_pdf(&self, path: impl AsRef<Path>) -> Result<ExportReport, Error> {
self.export_pdf_with(path, RasterOptions::default())
}
pub fn export_pdf_with(
&self,
path: impl AsRef<Path>,
raster: RasterOptions,
) -> Result<ExportReport, Error> {
let validation = self.check_valid()?;
let text = text_engine();
let options = ironlab_pdf::PdfOptions {
raster,
..ironlab_pdf::PdfOptions::for_figure(&self.ir)
};
let exported = ironlab_viewer::export_pdf(&self.ir, text, &options)?;
std::fs::write(path, exported.bytes)?;
Ok(ExportReport {
validation: validation.warnings,
scene: exported.warnings,
})
}
pub fn show(self) -> Result<(), Error> {
self.check_valid()?;
let name = self
.ir
.title
.as_ref()
.map_or_else(|| "Figure".to_owned(), |title| title.content.clone());
ironlab_viewer::run(vec![(name, self.ir)])?;
Ok(())
}
fn check_valid(&self) -> Result<ValidationReport, Error> {
let report = self.validate();
if report.is_valid() {
Ok(report)
} else {
Err(Error::Invalid(report))
}
}
fn add_axes(&mut self, cell: Cell) -> NodeId {
let id = self.ir.alloc_node_id();
self.ir.axes.push(Axes {
id,
cell,
projection: Projection::TwoD,
..Axes::default()
});
id
}
}
impl From<ironlab_ir::Figure> for Figure {
fn from(ir: ironlab_ir::Figure) -> Self {
Self::from_ir(ir)
}
}
impl From<Figure> for ironlab_ir::Figure {
fn from(figure: Figure) -> Self {
figure.into_ir()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Format {
Protobuf,
Json,
}
impl Format {
fn of(path: &Path) -> Result<Self, Error> {
let extension = path.extension().and_then(|extension| extension.to_str());
match extension {
Some(ext) if ext.eq_ignore_ascii_case("fig") => Ok(Self::Protobuf),
Some(ext) if ext.eq_ignore_ascii_case("json") => Ok(Self::Json),
_ => Err(Error::UnsupportedFormat(path.to_path_buf())),
}
}
}
fn text_engine() -> &'static TextEngine {
static ENGINE: OnceLock<TextEngine> = OnceLock::new();
ENGINE.get_or_init(TextEngine::new)
}