diff --git a/Cargo.lock b/Cargo.lock index 9dd1bc2b4c..abb2e3551d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1062,7 +1062,7 @@ dependencies = [ "dyn-any", "glam", "image", - "kurbo", + "kurbo 0.13.0", "log", "lyon_geom", "no-std-types", @@ -1705,6 +1705,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" dependencies = [ "crc32fast", + "libz-rs-sys", "miniz_oxide", ] @@ -1714,6 +1715,15 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + [[package]] name = "fnv" version = "1.0.7" @@ -2199,6 +2209,7 @@ dependencies = [ "interpreted-executor", "log", "preprocessor", + "rendering", "tokio", "wgpu", "wgpu-executor", @@ -2402,11 +2413,12 @@ dependencies = [ "image", "interpreted-executor", "js-sys", - "kurbo", + "kurbo 0.13.0", "log", "num_enum", "once_cell", "preprocessor", + "rendering", "serde", "serde_bytes", "serde_json", @@ -2414,7 +2426,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tsify", - "usvg", + "usvg 0.47.0", "vello", "wasm-bindgen", "web-sys", @@ -2845,10 +2857,26 @@ dependencies = [ "gif", "num-traits", "png", - "zune-core", - "zune-jpeg", + "zune-core 0.4.12", + "zune-jpeg 0.4.20", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", ] +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + [[package]] name = "imagesize" version = "0.14.0" @@ -3126,6 +3154,72 @@ dependencies = [ "libc", ] +[[package]] +name = "krilla" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0ddfec86fec13d068075e14f22a7e217c281f3ed69ddcb427bf3f5d504fd674" +dependencies = [ + "base64", + "bumpalo", + "flate2", + "float-cmp 0.10.0", + "gif", + "image-webp", + "imagesize 0.14.0", + "indexmap", + "once_cell", + "pdf-writer", + "png", + "rustc-hash 2.1.1", + "rustybuzz", + "siphasher", + "skrifa 0.37.0", + "smallvec", + "subsetter", + "tiny-skia-path 0.11.4", + "xmp-writer", + "yoke", + "zune-jpeg 0.5.13", +] + +[[package]] +name = "krilla-svg" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f485e1a850201a01dcd8d73e7cf09f2cd4c4cc85c2cd296359094d49336d8ef7" +dependencies = [ + "flate2", + "fontdb", + "krilla", + "png", + "resvg", + "tiny-skia", + "usvg 0.45.1", +] + +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + +[[package]] +name = "kurbo" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce9729cc38c18d86123ab736fd2e7151763ba226ac2490ec092d1dd148825e32" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + [[package]] name = "kurbo" version = "0.13.0" @@ -3193,6 +3287,15 @@ dependencies = [ "redox_syscall", ] +[[package]] +name = "libz-rs-sys" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c10501e7805cee23da17c7790e59df2870c0d4043ec6d03f67d31e2b53e77415" +dependencies = [ + "zlib-rs", +] + [[package]] name = "linebender_resource_handle" version = "0.1.1" @@ -3206,7 +3309,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8421b276e96af0ace5f3d8d2d165d0dea07fe764d2fe94ec06bb1acaf8a1e759" dependencies = [ "arrayvec", - "kurbo", + "kurbo 0.13.0", "polycool", "rustc-hash 2.1.1", "smallvec", @@ -4042,6 +4145,18 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pdf-writer" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92a79477295a713c2ed425aa82a8b5d20cec3fdee203706cbe6f3854880c1c81" +dependencies = [ + "bitflags 2.11.0", + "itoa", + "memchr", + "ryu", +] + [[package]] name = "peniko" version = "0.6.0" @@ -4049,7 +4164,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a2b6aadb221872732e87d465213e9be5af2849b0e8cc5300a8ba98fffa2e00a" dependencies = [ "color", - "kurbo", + "kurbo 0.13.0", "linebender_resource_handle", "smallvec", ] @@ -4439,6 +4554,12 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.37.5" @@ -4574,7 +4695,7 @@ dependencies = [ "futures", "glam", "image", - "kurbo", + "kurbo 0.13.0", "ndarray", "no-std-types", "node-macro", @@ -4759,11 +4880,14 @@ dependencies = [ "dyn-any", "glam", "graphic-types", - "kurbo", + "krilla", + "krilla-svg", + "kurbo 0.13.0", "log", "num-traits", "serde", - "usvg", + "usvg 0.45.1", + "usvg 0.47.0", "vector-types", "vello", ] @@ -4777,7 +4901,7 @@ dependencies = [ "glam", "graphene-core", "graphic-types", - "kurbo", + "kurbo 0.13.0", "log", "node-macro", "raster-types", @@ -4829,6 +4953,23 @@ dependencies = [ "web-sys", ] +[[package]] +name = "resvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" +dependencies = [ + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes 0.15.3", + "tiny-skia", + "usvg 0.45.1", + "zune-jpeg 0.4.20", +] + [[package]] name = "rfd" version = "0.15.4" @@ -4853,6 +4994,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + [[package]] name = "ring" version = "0.17.14" @@ -5589,7 +5739,7 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" dependencies = [ - "float-cmp", + "float-cmp 0.9.0", ] [[package]] @@ -5643,6 +5793,18 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "subsetter" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb6895a12ac5599bb6057362f00e8a3cf1daab4df33f553a55690a44e4fed8d0" +dependencies = [ + "kurbo 0.12.0", + "rustc-hash 2.1.1", + "skrifa 0.37.0", + "write-fonts", +] + [[package]] name = "subtle" version = "2.6.1" @@ -5655,13 +5817,23 @@ version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" +[[package]] +name = "svgtypes" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo 0.11.3", + "siphasher", +] + [[package]] name = "svgtypes" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" dependencies = [ - "kurbo", + "kurbo 0.13.0", "siphasher", ] @@ -5903,6 +6075,7 @@ dependencies = [ "bytemuck", "cfg-if", "log", + "png", "tiny-skia-path 0.11.4", ] @@ -6377,6 +6550,33 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "usvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb", + "imagesize 0.13.0", + "kurbo 0.11.3", + "log", + "pico-args", + "roxmltree 0.20.0", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes 0.15.3", + "tiny-skia-path 0.11.4", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + [[package]] name = "usvg" version = "0.47.0" @@ -6387,8 +6587,8 @@ dependencies = [ "data-url", "flate2", "fontdb", - "imagesize", - "kurbo", + "imagesize 0.14.0", + "kurbo 0.13.0", "log", "pico-args", "roxmltree 0.21.1", @@ -6396,7 +6596,7 @@ dependencies = [ "simplecss", "siphasher", "strict-num", - "svgtypes", + "svgtypes 0.16.1", "tiny-skia-path 0.12.0", "ttf-parser", "unicode-bidi", @@ -6439,7 +6639,7 @@ dependencies = [ "glam", "graphene-core", "graphic-types", - "kurbo", + "kurbo 0.13.0", "log", "node-macro", "qrcodegen", @@ -6463,7 +6663,7 @@ dependencies = [ "dyn-any", "fixedbitset", "glam", - "kurbo", + "kurbo 0.13.0", "log", "lyon_geom", "node-macro", @@ -7652,6 +7852,19 @@ version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "052283831dbae3d879dc7f51f3d92703a316ca49f91540417d38591826127814" +[[package]] +name = "write-fonts" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "886614b5ce857341226aa091f3c285e450683894acaaa7887f366c361efef79d" +dependencies = [ + "font-types 0.10.0", + "indexmap", + "kurbo 0.12.0", + "log", + "read-fonts 0.35.0", +] + [[package]] name = "writeable" version = "0.6.1" @@ -7738,6 +7951,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" +[[package]] +name = "xmp-writer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce9e2f4a404d9ebffc0a9832cf4f50907220ba3d7fffa9099261a5cab52f2dd7" + [[package]] name = "yansi" version = "1.0.1" @@ -7931,19 +8150,40 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "zlib-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3" + [[package]] name = "zune-core" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + [[package]] name = "zune-jpeg" version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1f7e205ce79eb2da3cd71c5f55f3589785cb7c79f6a03d1c8d1491bda5d089" dependencies = [ - "zune-core", + "zune-core 0.4.12", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec5f41c76397b7da451efd19915684f727d7e1d516384ca6bd0ec43ec94de23c" +dependencies = [ + "zune-core 0.5.1", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 118f8d0208..a81a3b94fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -155,6 +155,9 @@ parley = "0.6" skrifa = "0.40" polycool = "0.4" # Linebender ecosystem (END) +krilla = "0.6" +krilla-svg = "0.3" +usvg-045 = { package = "usvg", version = "0.45" } rand = { version = "0.9", default-features = false, features = ["std_rng"] } rand_chacha = "0.9" glam = { version = "0.29", default-features = false, features = [ diff --git a/editor/Cargo.toml b/editor/Cargo.toml index 4719d5430a..a6171f0cad 100644 --- a/editor/Cargo.toml +++ b/editor/Cargo.toml @@ -21,6 +21,7 @@ graphite-proc-macros = { workspace = true } graph-craft = { workspace = true } interpreted-executor = { workspace = true } graphene-std = { workspace = true } # NOTE: `core-types` should not be added here because `graphene-std` re-exports its contents +rendering = { workspace = true } preprocessor = { workspace = true } # Workspace dependencies diff --git a/editor/src/messages/dialog/dialog_message_handler.rs b/editor/src/messages/dialog/dialog_message_handler.rs index 1621de1c3e..3dcd589132 100644 --- a/editor/src/messages/dialog/dialog_message_handler.rs +++ b/editor/src/messages/dialog/dialog_message_handler.rs @@ -109,6 +109,10 @@ impl MessageHandler> for DialogMessageHa self.export_dialog.bounds = ExportBounds::AllArtwork; } + if self.export_dialog.bounds == ExportBounds::ArtboardsAsPages && self.export_dialog.artboards.len() < 2 { + self.export_dialog.bounds = ExportBounds::AllArtwork; + } + self.export_dialog.has_selection = document.network_interface.selected_nodes().selected_layers(document.metadata()).next().is_some(); self.export_dialog.send_dialog_to_frontend(responses); } diff --git a/editor/src/messages/dialog/export_dialog/export_dialog_message_handler.rs b/editor/src/messages/dialog/export_dialog/export_dialog_message_handler.rs index 71866cb1fd..4765ee7c99 100644 --- a/editor/src/messages/dialog/export_dialog/export_dialog_message_handler.rs +++ b/editor/src/messages/dialog/export_dialog/export_dialog_message_handler.rs @@ -44,18 +44,21 @@ impl MessageHandler> for Exp ExportDialogMessage::ExportBounds { bounds } => self.bounds = bounds, ExportDialogMessage::Submit => { + let artboards_as_pages = self.bounds == ExportBounds::ArtboardsAsPages; let artboard_name = match self.bounds { ExportBounds::Artboard(layer) => self.artboards.get(&layer).cloned(), _ => None, }; + let bounds = if artboards_as_pages { ExportBounds::AllArtwork } else { self.bounds }; responses.add_front(PortfolioMessage::SubmitDocumentExport { name: portfolio.active_document().map(|document| document.name.clone()).unwrap_or_default(), file_type: self.file_type, scale_factor: self.scale_factor, - bounds: self.bounds, + bounds, transparent_background: self.file_type != FileType::Jpg && self.transparent_background, artboard_name, artboard_count: self.artboards.len(), + artboards_as_pages, }) } } @@ -91,7 +94,7 @@ impl DialogLayoutHolder for ExportDialogMessageHandler { impl LayoutHolder for ExportDialogMessageHandler { fn layout(&self) -> Layout { - let entries = [(FileType::Png, "PNG"), (FileType::Jpg, "JPG"), (FileType::Svg, "SVG")] + let entries = [(FileType::Png, "PNG"), (FileType::Jpg, "JPG"), (FileType::Svg, "SVG"), (FileType::Pdf, "PDF")] .into_iter() .map(|(file_type, name)| { RadioEntryData::new(format!("{file_type:?}")) @@ -101,40 +104,49 @@ impl LayoutHolder for ExportDialogMessageHandler { .collect(); let export_type = vec![ - TextLabel::new("File Type").table_align(true).min_width(100).widget_instance(), + TextLabel::new("File Type").table_align(true).min_width(120).widget_instance(), Separator::new(SeparatorStyle::Unrelated).widget_instance(), RadioInput::new(entries).selected_index(Some(self.file_type as u32)).widget_instance(), ]; let resolution = vec![ - TextLabel::new("Scale Factor").table_align(true).min_width(100).widget_instance(), + TextLabel::new("Scale Factor").table_align(true).min_width(120).widget_instance(), Separator::new(SeparatorStyle::Unrelated).widget_instance(), NumberInput::new(Some(self.scale_factor)) .unit("") .min(0.) .max((1_u64 << f64::MANTISSA_DIGITS) as f64) .increment_step(0.5) - .disabled(self.file_type == FileType::Svg) + .disabled(self.file_type == FileType::Svg || self.file_type == FileType::Pdf) .on_update(|number_input: &NumberInput| ExportDialogMessage::ScaleFactor { factor: number_input.value.unwrap() }.into()) .min_width(200) .widget_instance(), ]; - let standard_bounds = vec![ + let standard_bounds: Vec<(ExportBounds, String, bool)> = vec![ (ExportBounds::AllArtwork, "All Artwork".to_string(), false), (ExportBounds::Selection, "Selection".to_string(), !self.has_selection), ]; - let artboards = self.artboards.iter().map(|(&layer, name)| (ExportBounds::Artboard(layer), name.to_string(), false)).collect(); - let choices = [standard_bounds, artboards]; + + let artboards_as_pages: Vec<(ExportBounds, String, bool)> = if self.file_type == FileType::Pdf && self.artboards.len() >= 2 { + vec![(ExportBounds::ArtboardsAsPages, "Artboards as Pages".to_string(), false)] + } else { + vec![] + }; + + let artboards: Vec<(ExportBounds, String, bool)> = self.artboards.iter().map(|(&layer, name)| (ExportBounds::Artboard(layer), name.to_string(), false)).collect(); + let choices = [standard_bounds, artboards_as_pages, artboards]; let current_bounds = if !self.has_selection && self.bounds == ExportBounds::Selection { ExportBounds::AllArtwork + } else if self.bounds == ExportBounds::ArtboardsAsPages && (self.file_type != FileType::Pdf || self.artboards.len() < 2) { + ExportBounds::AllArtwork } else { self.bounds }; let index = choices.iter().flatten().position(|(bounds, _, _)| *bounds == current_bounds).unwrap_or(0); - let mut entries = choices + let mut entries: Vec> = choices .into_iter() .map(|choice| { choice @@ -149,19 +161,17 @@ impl LayoutHolder for ExportDialogMessageHandler { }) .collect::>(); - if entries[1].is_empty() { - entries.remove(1); - } + entries.retain(|section| !section.is_empty()); let export_area = vec![ - TextLabel::new("Bounds").table_align(true).min_width(100).widget_instance(), + TextLabel::new("Bounds").table_align(true).min_width(120).widget_instance(), Separator::new(SeparatorStyle::Unrelated).widget_instance(), DropdownInput::new(entries).selected_index(Some(index as u32)).widget_instance(), ]; let checkbox_id = CheckboxId::new(); let transparent_background = vec![ - TextLabel::new("Transparency").table_align(true).min_width(100).for_checkbox(checkbox_id).widget_instance(), + TextLabel::new("Transparency").table_align(true).min_width(120).for_checkbox(checkbox_id).widget_instance(), Separator::new(SeparatorStyle::Unrelated).widget_instance(), CheckboxInput::new(self.transparent_background) .disabled(self.file_type == FileType::Jpg) @@ -170,11 +180,13 @@ impl LayoutHolder for ExportDialogMessageHandler { .widget_instance(), ]; - Layout(vec![ + let rows = vec![ LayoutGroup::row(export_type), LayoutGroup::row(resolution), LayoutGroup::row(export_area), LayoutGroup::row(transparent_background), - ]) + ]; + + Layout(rows) } } diff --git a/editor/src/messages/frontend/utility_types.rs b/editor/src/messages/frontend/utility_types.rs index 979a3d0221..cdc3f33f4b 100644 --- a/editor/src/messages/frontend/utility_types.rs +++ b/editor/src/messages/frontend/utility_types.rs @@ -47,6 +47,7 @@ pub enum FileType { Png, Jpg, Svg, + Pdf, } impl FileType { @@ -55,6 +56,7 @@ impl FileType { FileType::Png => "image/png", FileType::Jpg => "image/jpeg", FileType::Svg => "image/svg+xml", + FileType::Pdf => "application/pdf", } } } @@ -66,6 +68,7 @@ pub enum ExportBounds { AllArtwork, Selection, Artboard(LayerNodeIdentifier), + ArtboardsAsPages, } #[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(large_number_types_as_bigints))] diff --git a/editor/src/messages/portfolio/portfolio_message.rs b/editor/src/messages/portfolio/portfolio_message.rs index 97c39c624b..4d10fc0913 100644 --- a/editor/src/messages/portfolio/portfolio_message.rs +++ b/editor/src/messages/portfolio/portfolio_message.rs @@ -142,6 +142,7 @@ pub enum PortfolioMessage { transparent_background: bool, artboard_name: Option, artboard_count: usize, + artboards_as_pages: bool, }, SubmitActiveGraphRender, SubmitGraphRender { diff --git a/editor/src/messages/portfolio/portfolio_message_handler.rs b/editor/src/messages/portfolio/portfolio_message_handler.rs index d45f832f82..8eb9aeec1d 100644 --- a/editor/src/messages/portfolio/portfolio_message_handler.rs +++ b/editor/src/messages/portfolio/portfolio_message_handler.rs @@ -6,7 +6,7 @@ use crate::consts::{DEFAULT_DOCUMENT_NAME, DEFAULT_STROKE_WIDTH, FILE_EXTENSION} use crate::messages::animation::TimingInformation; use crate::messages::clipboard::utility_types::ClipboardContent; use crate::messages::dialog::simple_dialogs; -use crate::messages::frontend::utility_types::{DocumentDetails, OpenDocument}; +use crate::messages::frontend::utility_types::{DocumentDetails, ExportBounds, FileType, OpenDocument}; use crate::messages::input_mapper::utility_types::input_keyboard::Key; use crate::messages::input_mapper::utility_types::macros::{action_shortcut, action_shortcut_manual}; use crate::messages::layout::utility_types::widget_prelude::*; @@ -1127,25 +1127,78 @@ impl MessageHandler> for Portfolio transparent_background, artboard_name, artboard_count, + artboards_as_pages, } => { let document = self.active_document_id.and_then(|id| self.documents.get_mut(&id)).expect("Tried to render non-existent document"); - let export_config = ExportConfig { - name, - file_type, - scale_factor, - bounds, - transparent_background, - artboard_name, - artboard_count, - ..Default::default() + let document_id = self.active_document_id.unwrap(); + + // When exporting All Artwork as a multi-page PDF with 2+ artboards, + // submit one export per artboard (in layer-stack order, top = page 1). + let artboard_layers: Vec<(LayerNodeIdentifier, String)> = if artboards_as_pages && bounds == ExportBounds::AllArtwork && file_type == FileType::Pdf { + document + .metadata() + .all_layers() + .filter(|&layer| document.network_interface.is_artboard(&layer.to_node(), &[])) + .map(|layer| { + let ab_name = document + .network_interface + .node_metadata(&layer.to_node(), &[]) + .map(|m| m.persistent_metadata.display_name.clone()) + .and_then(|n| if n.is_empty() { None } else { Some(n) }) + .unwrap_or_else(|| "Artboard".to_string()); + (layer, ab_name) + }) + .collect() + } else { + Vec::new() }; - let result = self.executor.submit_document_export(document, self.active_document_id.unwrap(), export_config); - if let Err(description) = result { - responses.add(DialogMessage::DisplayDialogError { - title: "Unable to export document".to_string(), - description, - }); + if artboard_layers.len() >= 2 { + // Multi-page PDF: one graph execution per artboard + let total_pages = artboard_layers.len(); + let mut first_error: Option = None; + for (layer, ab_name) in artboard_layers { + let per_artboard_config = ExportConfig { + name: name.clone(), + file_type, + scale_factor, + bounds: ExportBounds::Artboard(layer), + transparent_background, + artboard_name: Some(ab_name), + artboard_count: total_pages, + is_multipage_pdf: true, + pdf_pages_total: total_pages, + ..Default::default() + }; + if let Err(e) = self.executor.submit_document_export(document, document_id, per_artboard_config) { + first_error = Some(e); + break; + } + } + if let Some(description) = first_error { + responses.add(DialogMessage::DisplayDialogError { + title: "Unable to export document".to_string(), + description, + }); + } + } else { + // Single-page / non-PDF path (unchanged) + let export_config = ExportConfig { + name, + file_type, + scale_factor, + bounds, + transparent_background, + artboard_name, + artboard_count, + ..Default::default() + }; + if let Err(description) = self.executor.submit_document_export(document, document_id, export_config) { + responses.add(DialogMessage::DisplayDialogError { + title: "Unable to export document".to_string(), + description, + }); + } } } PortfolioMessage::SubmitActiveGraphRender => { diff --git a/editor/src/node_graph_executor.rs b/editor/src/node_graph_executor.rs index 8fe138be44..ff1a6ecaf8 100644 --- a/editor/src/node_graph_executor.rs +++ b/editor/src/node_graph_executor.rs @@ -55,6 +55,9 @@ pub struct NodeGraphExecutor { futures: VecDeque<(u64, ExecutionContext)>, node_graph_hash: u64, previous_node_to_inspect: Option, + /// Accumulates `(svg_string, width, height)` pages for multi-page PDF exports. + /// Key: `(document_name, total_page_count)`. Cleared once the PDF is emitted. + pdf_page_accumulator: HashMap<(String, usize), Vec<(String, f32, f32)>>, } #[derive(Debug, Clone)] @@ -77,6 +80,7 @@ impl NodeGraphExecutor { node_graph_hash: 0, current_execution_id: 0, previous_node_to_inspect: None, + pdf_page_accumulator: Default::default(), }; (node_runtime, node_executor) } @@ -225,15 +229,15 @@ impl NodeGraphExecutor { pub fn submit_document_export(&mut self, document: &mut DocumentMessageHandler, document_id: DocumentId, mut export_config: ExportConfig) -> Result<(), String> { let network = document.network_interface.document_network().clone(); - let export_format = if export_config.file_type == FileType::Svg { - graphene_std::application_io::ExportFormat::Svg - } else { - graphene_std::application_io::ExportFormat::Raster + let export_format = match export_config.file_type { + FileType::Svg => graphene_std::application_io::ExportFormat::Svg, + FileType::Pdf => graphene_std::application_io::ExportFormat::Pdf, + _ => graphene_std::application_io::ExportFormat::Raster, }; // Calculate the bounding box of the region to be exported let bounds = match export_config.bounds { - ExportBounds::AllArtwork => document.network_interface.document_bounds_document_space(!export_config.transparent_background), + ExportBounds::AllArtwork | ExportBounds::ArtboardsAsPages => document.network_interface.document_bounds_document_space(!export_config.transparent_background), ExportBounds::Selection => document.network_interface.selected_bounds_document_space(!export_config.transparent_background, &[]), ExportBounds::Artboard(id) => document.metadata().bounding_box_document(id), } @@ -424,7 +428,7 @@ impl NodeGraphExecutor { Ok(()) } - fn process_export(&self, node_graph_output: TaggedValue, export_config: ExportConfig, responses: &mut VecDeque) -> Result<(), String> { + fn process_export(&mut self, node_graph_output: TaggedValue, export_config: ExportConfig, responses: &mut VecDeque) -> Result<(), String> { let ExportConfig { file_type, name, @@ -433,6 +437,8 @@ impl NodeGraphExecutor { transparent_background, artboard_name, artboard_count, + is_multipage_pdf, + pdf_pages_total, .. } = export_config; @@ -440,12 +446,14 @@ impl NodeGraphExecutor { FileType::Svg => "svg", FileType::Png => "png", FileType::Jpg => "jpg", + FileType::Pdf => "pdf", }; + // For multi-page PDF the final filename is just "DocName.pdf" (no artboard suffix). let base_name = match (artboard_name, artboard_count) { - (Some(artboard_name), count) if count > 1 => format!("{name} - {artboard_name}"), - _ => name, + (Some(artboard_name), count) if count > 1 && !is_multipage_pdf => format!("{name} - {artboard_name}"), + _ => name.clone(), }; - let name = format!("{base_name}.{file_extension}"); + let file_name = format!("{base_name}.{file_extension}"); match node_graph_output { TaggedValue::RenderOutput(RenderOutput { @@ -454,13 +462,44 @@ impl NodeGraphExecutor { }) => { if file_type == FileType::Svg { responses.add(FrontendMessage::TriggerSaveFile { - name, + name: file_name, content: svg.into_bytes().into(), }); + } else if file_type == FileType::Pdf { + if is_multipage_pdf { + // Accumulate this artboard's SVG; emit the PDF once all pages arrive. + let page_size = size.as_dvec2(); + let key = (name.clone(), pdf_pages_total); + let pages = self.pdf_page_accumulator.entry(key.clone()).or_default(); + pages.push((svg, page_size.x.max(1.) as f32, page_size.y.max(1.) as f32)); + + if pages.len() == pdf_pages_total { + // All pages collected — stitch into a single multi-page PDF. + let pages = self.pdf_page_accumulator.remove(&key).unwrap(); + let output_name = format!("{name}.pdf"); + match rendering::svg_to_pdf::svg_pages_to_pdf(&pages) { + Ok(pdf_data) => responses.add(FrontendMessage::TriggerSaveFile { + name: output_name, + content: pdf_data.into(), + }), + Err(err) => return Err(format!("Failed to build multi-page PDF: {err}")), + } + } + } else { + // Single-page PDF. + let size = size.as_dvec2(); + match rendering::svg_to_pdf::svg_to_pdf(&svg, Some(size.x.max(1.) as f32), Some(size.y.max(1.) as f32)) { + Ok(pdf_data) => responses.add(FrontendMessage::TriggerSaveFile { + name: file_name, + content: pdf_data.into(), + }), + Err(err) => return Err(format!("Failed to convert SVG to PDF: {err}")), + } + } } 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_name, mime, size }); } } #[cfg(feature = "gpu")] @@ -500,9 +539,15 @@ impl NodeGraphExecutor { FileType::Svg => { return Err("SVG cannot be exported from an image buffer".to_string()); } + FileType::Pdf => { + return Err("PDF cannot be exported from an image buffer".to_string()); + } } - responses.add(FrontendMessage::TriggerSaveFile { name, content: encoded.into() }); + responses.add(FrontendMessage::TriggerSaveFile { + name: file_name, + content: encoded.into(), + }); } _ => { return Err(format!("Incorrect render type for exporting to an SVG ({file_type:?}, {node_graph_output})")); diff --git a/editor/src/node_graph_executor/runtime.rs b/editor/src/node_graph_executor/runtime.rs index 9b7a8de4d6..eb9abd98b5 100644 --- a/editor/src/node_graph_executor/runtime.rs +++ b/editor/src/node_graph_executor/runtime.rs @@ -87,6 +87,13 @@ pub struct ExportConfig { pub size: UVec2, pub artboard_name: Option, pub artboard_count: usize, + /// When true, this execution is one page of a multi-page PDF. + /// The executor accumulates pages and emits the PDF when all arrive. + #[serde(skip)] + pub is_multipage_pdf: bool, + /// Total number of pages expected for the multi-page PDF. + #[serde(skip)] + pub pdf_pages_total: usize, } #[derive(Clone)] diff --git a/node-graph/graphene-cli/Cargo.toml b/node-graph/graphene-cli/Cargo.toml index b5dcfd71fd..64207f9940 100644 --- a/node-graph/graphene-cli/Cargo.toml +++ b/node-graph/graphene-cli/Cargo.toml @@ -16,6 +16,7 @@ gpu = ["interpreted-executor/gpu", "graphene-std/gpu", "wgpu-executor"] graphene-std = { workspace = true } interpreted-executor = { workspace = true } graph-craft = { workspace = true, features = ["loading"] } +rendering = { workspace = true } preprocessor = { workspace = true } # Workspace dependencies diff --git a/node-graph/graphene-cli/src/export.rs b/node-graph/graphene-cli/src/export.rs index dee1847c7c..f4f7a660c8 100644 --- a/node-graph/graphene-cli/src/export.rs +++ b/node-graph/graphene-cli/src/export.rs @@ -16,6 +16,7 @@ pub enum FileType { Png, Jpg, Gif, + Pdf, } pub fn detect_file_type(path: &Path) -> Result { @@ -24,7 +25,8 @@ pub fn detect_file_type(path: &Path) -> Result { Some("png") => Ok(FileType::Png), Some("jpg" | "jpeg") => Ok(FileType::Jpg), Some("gif") => Ok(FileType::Gif), - _ => Err("Unsupported file extension. Supported formats: .svg, .png, .jpg, .gif".to_string()), + Some("pdf") => Ok(FileType::Pdf), + _ => Err("Unsupported file extension. Supported formats: .svg, .png, .jpg, .gif, .pdf".to_string()), } } @@ -39,7 +41,7 @@ pub async fn export_document( ) -> Result<(), Box> { // Determine export format based on file type let export_format = match file_type { - FileType::Svg => ExportFormat::Svg, + FileType::Svg | FileType::Pdf => ExportFormat::Svg, _ => ExportFormat::Raster, }; @@ -63,9 +65,15 @@ pub async fn export_document( match result { TaggedValue::RenderOutput(output) => match output.data { RenderOutputType::Svg { svg, .. } => { - // Write SVG directly to file - std::fs::write(&output_path, svg)?; - log::info!("Exported SVG to: {}", output_path.display()); + if file_type == FileType::Pdf { + let pdf_bytes = rendering::svg_to_pdf::svg_to_pdf(&svg, width.map(|w| w as f32), height.map(|h| h as f32))?; + std::fs::write(&output_path, pdf_bytes)?; + log::info!("Exported PDF to: {}", output_path.display()); + } else { + // Write SVG directly to file + std::fs::write(&output_path, svg)?; + log::info!("Exported SVG to: {}", output_path.display()); + } } RenderOutputType::Texture(image_texture) => { // Convert GPU texture to CPU buffer @@ -112,7 +120,7 @@ fn write_raster_image(output_path: PathBuf, file_type: FileType, data: Vec, image.write_to(&mut cursor, ImageFormat::Jpeg)?; log::info!("Exported JPG to: {}", output_path.display()); } - FileType::Svg | FileType::Gif => unreachable!("SVG and GIF should have been handled in export_document"), + FileType::Svg | FileType::Gif | FileType::Pdf => unreachable!("SVG, GIF, and PDF should have been handled in export_document"), } std::fs::write(&output_path, cursor.into_inner())?; diff --git a/node-graph/libraries/application-io/src/lib.rs b/node-graph/libraries/application-io/src/lib.rs index c93ffaa445..49f2ffd7d2 100644 --- a/node-graph/libraries/application-io/src/lib.rs +++ b/node-graph/libraries/application-io/src/lib.rs @@ -225,6 +225,7 @@ pub enum ExportFormat { #[default] Svg, Raster, + Pdf, } #[derive(Debug, Default, Clone, Copy, PartialEq, DynAny, serde::Serialize, serde::Deserialize)] diff --git a/node-graph/libraries/rendering/Cargo.toml b/node-graph/libraries/rendering/Cargo.toml index e33ce052fd..eed480b990 100644 --- a/node-graph/libraries/rendering/Cargo.toml +++ b/node-graph/libraries/rendering/Cargo.toml @@ -25,3 +25,6 @@ graphic-types = { workspace = true } # Workspace dependencies vello = { workspace = true } +krilla = { workspace = true } +krilla-svg = { workspace = true } +usvg-045 = { workspace = true } diff --git a/node-graph/libraries/rendering/src/lib.rs b/node-graph/libraries/rendering/src/lib.rs index 418f4e9c0c..1dab6528e5 100644 --- a/node-graph/libraries/rendering/src/lib.rs +++ b/node-graph/libraries/rendering/src/lib.rs @@ -1,6 +1,7 @@ pub mod convert_usvg_path; pub mod render_ext; mod renderer; +pub mod svg_to_pdf; pub mod to_peniko; pub use renderer::*; diff --git a/node-graph/libraries/rendering/src/svg_to_pdf.rs b/node-graph/libraries/rendering/src/svg_to_pdf.rs new file mode 100644 index 0000000000..68e465c779 --- /dev/null +++ b/node-graph/libraries/rendering/src/svg_to_pdf.rs @@ -0,0 +1,47 @@ +use krilla::page::PageSettings; +use krilla_svg::{SurfaceExt, SvgSettings}; + +fn add_svg_page(document: &mut krilla::Document, tree: &usvg_045::Tree, page_width: f32, page_height: f32) -> Result<(), String> { + let page_width = page_width.max(1.); + let page_height = page_height.max(1.); + + let page_settings = PageSettings::from_wh(page_width, page_height).ok_or_else(|| "Invalid page dimensions for PDF export".to_string())?; + + let mut page = document.start_page_with(page_settings); + let mut surface = page.surface(); + + let krilla_size = krilla::geom::Size::from_wh(page_width, page_height).ok_or_else(|| "Invalid size for PDF export".to_string())?; + surface.draw_svg(tree, krilla_size, SvgSettings::default()); + + surface.finish(); + page.finish(); + Ok(()) +} + +/// Convert a single SVG string to a one-page PDF. +pub fn svg_to_pdf(svg: &str, page_width: Option, page_height: Option) -> Result, String> { + let options = usvg_045::Options::default(); + let tree = usvg_045::Tree::from_str(svg, &options).map_err(|e| format!("Failed to parse SVG for PDF conversion: {e}"))?; + + let width = page_width.unwrap_or(tree.size().width() as f32); + let height = page_height.unwrap_or(tree.size().height() as f32); + + let mut document = krilla::Document::new(); + add_svg_page(&mut document, &tree, width, height)?; + document.finish().map_err(|e| format!("Failed to generate PDF: {e:?}")) +} + +/// Convert multiple SVGs into a single multi-page PDF, one page per SVG. +pub fn svg_pages_to_pdf(pages: &[(String, f32, f32)]) -> Result, String> { + if pages.is_empty() { + return Err("No pages provided for multi-page PDF export".to_string()); + } + + let options = usvg_045::Options::default(); + let mut document = krilla::Document::new(); + for (svg, w, h) in pages { + let tree = usvg_045::Tree::from_str(svg, &options).map_err(|e| format!("Failed to parse SVG for PDF: {e}"))?; + add_svg_page(&mut document, &tree, *w, *h)?; + } + document.finish().map_err(|e| format!("Failed to generate multi-page PDF: {e:?}")) +} diff --git a/node-graph/nodes/gstd/src/render_node.rs b/node-graph/nodes/gstd/src/render_node.rs index 1a217d0068..b11d8120b8 100644 --- a/node-graph/nodes/gstd/src/render_node.rs +++ b/node-graph/nodes/gstd/src/render_node.rs @@ -97,7 +97,7 @@ async fn create_context<'a: 'n>( let footprint = render_config.viewport; let render_output_type = match render_config.export_format { - ExportFormat::Svg => RenderOutputTypeRequest::Svg, + ExportFormat::Svg | ExportFormat::Pdf => RenderOutputTypeRequest::Svg, ExportFormat::Raster => RenderOutputTypeRequest::Vello, };