mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-24 03:58:12 +08:00
Add WEBP, TIFF, ICO, TGA, HDR, and EXR image import and more raster export formats (#4554)
* Add WEBP, TIFF, ICO, TGA, HDR, and EXR image import and more raster export file types * Try a file as TGA only when no image format is recognized
This commit is contained in:
@@ -94,19 +94,38 @@ impl DialogLayoutHolder for ExportDialogMessageHandler {
|
||||
|
||||
impl LayoutHolder for ExportDialogMessageHandler {
|
||||
fn layout(&self) -> Layout {
|
||||
let entries = [(FileType::Png, "PNG"), (FileType::Jpg, "JPG"), (FileType::Svg, "SVG")]
|
||||
// The vector type, then the raster ones
|
||||
let file_types = [
|
||||
vec![(FileType::Svg, "SVG")],
|
||||
vec![
|
||||
(FileType::Png, "PNG"),
|
||||
(FileType::Jpg, "JPG"),
|
||||
(FileType::Webp, "WEBP"),
|
||||
(FileType::Tiff, "TIFF"),
|
||||
(FileType::Bmp, "BMP"),
|
||||
(FileType::Tga, "TGA"),
|
||||
(FileType::Ico, "ICO"),
|
||||
],
|
||||
];
|
||||
let selected_index = file_types.iter().flatten().position(|(file_type, _)| *file_type == self.file_type);
|
||||
let entries = file_types
|
||||
.into_iter()
|
||||
.map(|(file_type, name)| {
|
||||
RadioEntryData::new(format!("{file_type:?}"))
|
||||
.label(name)
|
||||
.on_update(move |_| ExportDialogMessage::FileType { file_type }.into())
|
||||
.map(|section| {
|
||||
section
|
||||
.into_iter()
|
||||
.map(|(file_type, name)| {
|
||||
MenuListEntry::new(format!("{file_type:?}"))
|
||||
.label(name)
|
||||
.on_commit(move |_| ExportDialogMessage::FileType { file_type }.into())
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let export_type = vec![
|
||||
TextLabel::new("File Type").table_align(true).min_width(100).widget_instance(),
|
||||
Separator::new(SeparatorStyle::Unrelated).widget_instance(),
|
||||
RadioInput::new(entries).selected_index(Some(self.file_type as u32)).widget_instance(),
|
||||
DropdownInput::new(entries).selected_index(selected_index.map(|index| index as u32)).min_width(200).widget_instance(),
|
||||
];
|
||||
|
||||
let resolution = vec![
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::IconName;
|
||||
use super::utility_types::{MouseCursorIcon, PersistedState};
|
||||
use crate::messages::app_window::app_window_message_handler::AppWindowPlatform;
|
||||
use crate::messages::frontend::utility_types::{DocumentInfo, EyedropperPreviewImage, FileDialogOptions, FileFilter, RasterizedImage};
|
||||
use crate::messages::frontend::utility_types::{DocumentInfo, EyedropperPreviewImage, FileDialogOptions, FileFilter, FileType, RasterizedImage};
|
||||
use crate::messages::input_mapper::utility_types::misc::ActionShortcut;
|
||||
use crate::messages::layout::utility_types::widget_prelude::*;
|
||||
use crate::messages::portfolio::document::node_graph::utility_types::{
|
||||
@@ -109,7 +109,8 @@ pub enum FrontendMessage {
|
||||
TriggerExportImage {
|
||||
svg: String,
|
||||
name: String,
|
||||
mime: String,
|
||||
#[serde(rename = "fileType")]
|
||||
file_type: FileType,
|
||||
size: (f64, f64),
|
||||
},
|
||||
TriggerFetchAndOpenDocument {
|
||||
|
||||
@@ -47,28 +47,30 @@ pub enum MouseCursorIcon {
|
||||
Rotate,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))]
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum FileType {
|
||||
#[default]
|
||||
Png,
|
||||
Jpg,
|
||||
Webp,
|
||||
Tiff,
|
||||
Bmp,
|
||||
Tga,
|
||||
Ico,
|
||||
Svg,
|
||||
}
|
||||
|
||||
impl FileType {
|
||||
pub fn to_mime(self) -> &'static str {
|
||||
match self {
|
||||
FileType::Png => "image/png",
|
||||
FileType::Jpg => "image/jpeg",
|
||||
FileType::Svg => "image/svg+xml",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extension(self) -> &'static str {
|
||||
match self {
|
||||
FileType::Png => "png",
|
||||
FileType::Jpg => "jpg",
|
||||
FileType::Webp => "webp",
|
||||
FileType::Tiff => "tiff",
|
||||
FileType::Bmp => "bmp",
|
||||
FileType::Tga => "tga",
|
||||
FileType::Ico => "ico",
|
||||
FileType::Svg => "svg",
|
||||
}
|
||||
}
|
||||
@@ -77,6 +79,11 @@ impl FileType {
|
||||
let name = match self {
|
||||
FileType::Png => "PNG Image",
|
||||
FileType::Jpg => "JPEG Image",
|
||||
FileType::Webp => "WEBP Image",
|
||||
FileType::Tiff => "TIFF Image",
|
||||
FileType::Bmp => "BMP Image",
|
||||
FileType::Tga => "TGA Image",
|
||||
FileType::Ico => "ICO Image",
|
||||
FileType::Svg => "SVG Image",
|
||||
};
|
||||
FileFilter {
|
||||
@@ -85,6 +92,51 @@ impl FileType {
|
||||
mime_types: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encodes 8-bit RGBA pixels with straight alpha as a file of this raster type.
|
||||
pub fn encode(self, width: u32, height: u32, rgba: Vec<u8>) -> Result<Vec<u8>, String> {
|
||||
use image::buffer::ConvertBuffer;
|
||||
use image::{ImageFormat, RgbImage, RgbaImage};
|
||||
|
||||
let Some(mut image) = RgbaImage::from_raw(width, height, rgba) else {
|
||||
return Err("Failed to create image buffer for export".to_string());
|
||||
};
|
||||
|
||||
let format = match self {
|
||||
FileType::Png => ImageFormat::Png,
|
||||
FileType::Jpg => ImageFormat::Jpeg,
|
||||
FileType::Webp => ImageFormat::WebP,
|
||||
FileType::Tiff => ImageFormat::Tiff,
|
||||
FileType::Bmp => ImageFormat::Bmp,
|
||||
FileType::Tga => ImageFormat::Tga,
|
||||
FileType::Ico => ImageFormat::Ico,
|
||||
FileType::Svg => return Err("SVG cannot be exported from an image buffer".to_string()),
|
||||
};
|
||||
if self == FileType::Ico && (width > 256 || height > 256) {
|
||||
return Err("An ICO image can be at most 256 pixels wide and tall. Lower the scale factor or choose a smaller export area.".to_string());
|
||||
}
|
||||
|
||||
let mut encoded = Vec::new();
|
||||
let mut cursor = std::io::Cursor::new(&mut encoded);
|
||||
|
||||
let result = if self == FileType::Jpg {
|
||||
// Composite onto a white background since JPG doesn't support transparency
|
||||
for pixel in image.pixels_mut() {
|
||||
let [r, g, b, a] = pixel.0;
|
||||
let alpha = a as f32 / 255.;
|
||||
let blend = |channel: u8| (channel as f32 * alpha + 255. * (1. - alpha)).round() as u8;
|
||||
*pixel = image::Rgba([blend(r), blend(g), blend(b), 255]);
|
||||
}
|
||||
|
||||
let image: RgbImage = image.convert();
|
||||
image.write_to(&mut cursor, format)
|
||||
} else {
|
||||
image.write_to(&mut cursor, format)
|
||||
};
|
||||
result.map_err(|error| format!("Failed to encode {self:?}: {error}"))?;
|
||||
|
||||
Ok(encoded)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||||
@@ -128,3 +180,39 @@ pub struct FileDialogOptions {
|
||||
pub filters: Vec<FileFilter>,
|
||||
pub multiple: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use graphene_std::raster::Image;
|
||||
|
||||
#[test]
|
||||
fn every_raster_file_type_encodes_an_image_that_reads_back() {
|
||||
// Opaque red, then fully transparent
|
||||
let pixels = vec![255, 0, 0, 255, 0, 0, 0, 0];
|
||||
|
||||
for file_type in [FileType::Png, FileType::Jpg, FileType::Webp, FileType::Tiff, FileType::Bmp, FileType::Tga, FileType::Ico] {
|
||||
let encoded = file_type.encode(2, 1, pixels.clone()).unwrap_or_else(|error| panic!("{file_type:?}: {error}"));
|
||||
let decoded = Image::from_encoded(&encoded).unwrap_or_else(|| panic!("{file_type:?} should read back"));
|
||||
assert_eq!((decoded.width, decoded.height), (2, 1), "{file_type:?}");
|
||||
|
||||
// Only JPG lacks transparency, so it lands on white
|
||||
let transparent = decoded.to_flat_u8().0[4..8].to_vec();
|
||||
if file_type == FileType::Jpg {
|
||||
assert!(transparent.iter().all(|&channel| channel > 250), "{transparent:?}");
|
||||
} else {
|
||||
assert_eq!(transparent[3], 0, "{file_type:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_types_that_cannot_hold_the_image_say_so() {
|
||||
assert!(FileType::Svg.encode(1, 1, vec![0; 4]).is_err());
|
||||
assert!(FileType::Png.encode(2, 2, vec![0; 4]).is_err());
|
||||
|
||||
let too_large = FileType::Ico.encode(257, 1, vec![0; 257 * 4]).unwrap_err();
|
||||
assert!(too_large.contains("256 pixels"), "{too_large}");
|
||||
assert!(FileType::Ico.encode(256, 1, vec![0; 256 * 4]).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use super::utility_types::{DataType, IngestAction, TypeFilter, decoded_image_size};
|
||||
use super::utility_types::{DataType, IngestAction, TypeFilter};
|
||||
use crate::messages::frontend::utility_types::{FileDialogOptions, FileFilter};
|
||||
use crate::messages::prelude::*;
|
||||
use glam::IVec2;
|
||||
use graph_craft::application_io::resource::ResourceId;
|
||||
use graph_craft::document::value::TaggedValue;
|
||||
use graphene_std::raster::Image;
|
||||
use graphene_std::raster_nodes::color_lookup_table::{Lut, LutParseError};
|
||||
|
||||
#[derive(ExtractField)]
|
||||
@@ -124,7 +125,7 @@ impl MessageHandler<IngestMessage, IngestMessageContext> for IngestMessageHandle
|
||||
(insert, artboard_canvas)
|
||||
}
|
||||
DataType::Raster(_) => {
|
||||
let Some(size) = decoded_image_size(&data) else { return unsupported(responses) };
|
||||
let Some(size) = Image::encoded_size(&data) else { return unsupported(responses) };
|
||||
let insert = DocumentMessage::InsertImage {
|
||||
name: name.clone(),
|
||||
data: data.into(),
|
||||
@@ -185,7 +186,7 @@ fn rejection(data: &[u8], data_type: DataType, accepted_types: &[DataType]) -> O
|
||||
}
|
||||
|
||||
match data_type {
|
||||
DataType::Raster(_) => decoded_image_size(data).is_none().then_some("This file could not be read as an image."),
|
||||
DataType::Raster(_) => Image::encoded_size(data).is_none().then_some("This file could not be read as an image."),
|
||||
DataType::Lut => Lut::parse(data).err().map(|error| match error {
|
||||
LutParseError::IccProfileClass => {
|
||||
"This ICC profile describes the colors of a device (like a monitor or printer) instead\n\
|
||||
@@ -226,7 +227,6 @@ mod tests {
|
||||
use super::*;
|
||||
use graph_craft::document::NodeId;
|
||||
use graphene_std::Color;
|
||||
use graphene_std::raster::Image;
|
||||
|
||||
const REQUESTING_DOCUMENT: DocumentId = DocumentId(3);
|
||||
const IDENTITY_CUBE: &[u8] = b"LUT_3D_SIZE 2\n0 0 0\n1 0 0\n0 1 0\n1 1 0\n0 0 1\n1 0 1\n0 1 1\n1 1 1\n";
|
||||
@@ -337,6 +337,19 @@ mod tests {
|
||||
assert!(refusal(IDENTITY_CUBE, "grade.png").contains("does not accept"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_input_takes_an_image_format_that_has_no_signature() {
|
||||
// A TGA file is only told apart by its name
|
||||
let tga = crate::messages::frontend::utility_types::FileType::Tga.encode(2, 1, vec![255; 8]).unwrap();
|
||||
let stores = |file_name: &str| {
|
||||
let responses = ingest_named(&tga, Some(file_name), resource_input(TypeFilter::raster().types), true);
|
||||
responses.iter().any(|message| stored_resource(message).is_some())
|
||||
};
|
||||
|
||||
assert!(stores("photo.tga"));
|
||||
assert!(!stores("photo.unknown"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_input_tells_a_corrupt_image_apart_from_a_wrong_type() {
|
||||
let png = Image::new(8, 8, Color::WHITE).to_png();
|
||||
|
||||
@@ -12,11 +12,6 @@ use std::path::Path;
|
||||
/// How many leading bytes are inspected to recognize a text format.
|
||||
const SNIFFED_TEXT_LENGTH: usize = 4096;
|
||||
|
||||
/// The pixel size of a file that fully decodes as a raster image.
|
||||
pub fn decoded_image_size(data: &[u8]) -> Option<(u32, u32)> {
|
||||
image::load_from_memory(data).ok().map(|image| (image.width(), image.height()))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))]
|
||||
pub enum IngestAction {
|
||||
@@ -241,6 +236,9 @@ mod tests {
|
||||
fn type_filter_to_file_filter() {
|
||||
let filter = FileFilter::from(TypeFilter::image());
|
||||
assert!(filter.extensions.iter().any(|extension| extension == "jpeg") && filter.extensions.iter().any(|extension| extension == "png"));
|
||||
for enabled in ["webp", "tiff", "ico", "tga", "hdr", "exr"] {
|
||||
assert!(filter.extensions.iter().any(|extension| extension == enabled), "{enabled} should be offered");
|
||||
}
|
||||
assert!(filter.extensions.last().is_some_and(|extension| extension == "svg"));
|
||||
assert!(filter.mime_types.contains(&"image/jpeg".to_string()) && filter.mime_types.contains(&"image/svg+xml".to_string()));
|
||||
|
||||
|
||||
@@ -167,6 +167,13 @@ pub enum PortfolioMessage {
|
||||
artboard_name: Option<String>,
|
||||
artboard_count: usize,
|
||||
},
|
||||
SaveRasterizedExport {
|
||||
name: String,
|
||||
file_type: FileType,
|
||||
width: u32,
|
||||
height: u32,
|
||||
data: Vec<u8>,
|
||||
},
|
||||
SubmitActiveGraphRender,
|
||||
SubmitGraphRender {
|
||||
document_id: DocumentId,
|
||||
|
||||
@@ -1266,6 +1266,18 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||
});
|
||||
}
|
||||
}
|
||||
PortfolioMessage::SaveRasterizedExport { name, file_type, width, height, data } => match file_type.encode(width, height, data) {
|
||||
Ok(content) => responses.add(FrontendMessage::TriggerSaveFile {
|
||||
name,
|
||||
folder: None,
|
||||
filters: vec![file_type.file_filter()],
|
||||
content: content.into(),
|
||||
}),
|
||||
Err(description) => responses.add(DialogMessage::DisplayDialogError {
|
||||
title: "Unable to export document".to_string(),
|
||||
description,
|
||||
}),
|
||||
},
|
||||
PortfolioMessage::SubmitActiveGraphRender => {
|
||||
if let Some(document_id) = self.active_document_id {
|
||||
responses.add(PortfolioMessage::SubmitGraphRender { document_id, ignore_hash: false });
|
||||
|
||||
@@ -751,9 +751,8 @@ impl NodeGraphExecutor {
|
||||
content: svg.into_bytes().into(),
|
||||
});
|
||||
} else {
|
||||
let mime = file_type.to_mime().to_string();
|
||||
let size = size.as_dvec2().into();
|
||||
responses.add(FrontendMessage::TriggerExportImage { svg, name, mime, size });
|
||||
responses.add(FrontendMessage::TriggerExportImage { svg, name, file_type, size });
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "gpu")]
|
||||
@@ -761,49 +760,8 @@ impl NodeGraphExecutor {
|
||||
data: RenderOutputType::Buffer { data, width, height },
|
||||
..
|
||||
}) if file_type != FileType::Svg => {
|
||||
use image::buffer::ConvertBuffer;
|
||||
use image::{ImageFormat, RgbImage, RgbaImage};
|
||||
|
||||
let Some(mut image) = RgbaImage::from_raw(width, height, data) else {
|
||||
return Err("Failed to create image buffer for export".to_string());
|
||||
};
|
||||
|
||||
let mut encoded = Vec::new();
|
||||
let mut cursor = std::io::Cursor::new(&mut encoded);
|
||||
|
||||
match file_type {
|
||||
FileType::Png => {
|
||||
let result = image.write_to(&mut cursor, ImageFormat::Png);
|
||||
if let Err(err) = result {
|
||||
return Err(format!("Failed to encode PNG: {err}"));
|
||||
}
|
||||
}
|
||||
FileType::Jpg => {
|
||||
// Composite onto a white background since JPG doesn't support transparency
|
||||
for pixel in image.pixels_mut() {
|
||||
let [r, g, b, a] = pixel.0;
|
||||
let alpha = a as f32 / 255.;
|
||||
let blend = |channel: u8| (channel as f32 * alpha + 255. * (1. - alpha)).round() as u8;
|
||||
*pixel = image::Rgba([blend(r), blend(g), blend(b), 255]);
|
||||
}
|
||||
|
||||
let image: RgbImage = image.convert();
|
||||
let result = image.write_to(&mut cursor, ImageFormat::Jpeg);
|
||||
if let Err(err) = result {
|
||||
return Err(format!("Failed to encode JPG: {err}"));
|
||||
}
|
||||
}
|
||||
FileType::Svg => {
|
||||
return Err("SVG cannot be exported from an image buffer".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
responses.add(FrontendMessage::TriggerSaveFile {
|
||||
name,
|
||||
folder,
|
||||
filters,
|
||||
content: encoded.into(),
|
||||
});
|
||||
let content = file_type.encode(width, height, data)?.into();
|
||||
responses.add(FrontendMessage::TriggerSaveFile { name, folder, filters, content });
|
||||
}
|
||||
_ => {
|
||||
return Err(format!("Incorrect render type for exporting to an SVG ({file_type:?}, {node_graph_output})"));
|
||||
|
||||
Reference in New Issue
Block a user