2026-10-01 22:49:07 -04:00
//! File › Export › Save for Web (Legacy), File › Export › Export Preferences, the Quick Export
//! pipeline that honours them, and File › Generate › Image Assets (Photoshop's Generator).
//!
//! Save for Web optimises the flattened (optionally resized, sRGB-converted) image as GIF,
//! PNG-8, PNG-24, JPEG or WBMP, per slice, into a folder with an optional HTML table; without an
//! output it only reports the optimised size (the dialog's 2-Up/4-Up annotations). Colour
//! reduction and dithering reuse `algo::quantize`; GIF/WBMP/progressive JPEG writers live in
//! `codecs::web`.
//!
//! Image Assets implements the naming grammar of Adobe's public Generator documentation: a
//! layer or group named `foo.png`, `200% foo@2x.png, 48x48 icons/foo.png8`, `photo.jpg80%`,
//! `?x100 thumb.gif` is exported into `<document>-assets/` whenever the document is saved while
//! the feature is on; a `default` layer (`default 50% low/ + 200% @2x`) adds variants.
use photocraft_algo ::quantize ::{ self , Dither , Forced , PaletteKind };
use photocraft_doc ::slices ::{ self , ResolvedSlice , SliceKind };
use photocraft_doc ::{ DocId , Document , LayerContent , LayerId };
use photocraft_geom ::Rect ;
use serde_json ::{ Value , json };
use crate ::commands ::CommandSpec ;
2026-10-21 01:29:31 -05:00
use crate ::file_cmds ::{ is_windows_device_name , join , native_doc , portable_device_name , stem , write_file };
2026-10-01 22:49:07 -04:00
use crate ::{ EngineError , Result , Session };
fn bad ( cmd : & str , msg : impl Into < String > ) -> EngineError {
EngineError ::BadParams { cmd : cmd . into (), msg : msg . into () }
}
fn other ( e : impl std ::fmt ::Display ) -> EngineError {
EngineError ::Other ( e . to_string ())
}
// ---------- settings ----------
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WebFormat {
Gif ,
Png8 ,
Png24 ,
Jpeg ,
Wbmp ,
}
impl WebFormat {
pub fn from_id ( s : & str ) -> Option < Self > {
Some ( match s . to_ascii_lowercase (). as_str () {
"gif" => WebFormat ::Gif ,
"png8" | "png-8" => WebFormat ::Png8 ,
"png24" | "png-24" | "png" | "png32" => WebFormat ::Png24 ,
"jpeg" | "jpg" => WebFormat ::Jpeg ,
"wbmp" => WebFormat ::Wbmp ,
_ => return None ,
})
}
pub fn ext ( self ) -> & 'static str {
match self {
WebFormat ::Gif => "gif" ,
WebFormat ::Png8 | WebFormat ::Png24 => "png" ,
WebFormat ::Jpeg => "jpg" ,
WebFormat ::Wbmp => "wbmp" ,
}
}
pub fn indexed ( self ) -> bool {
matches! ( self , WebFormat ::Gif | WebFormat ::Png8 )
}
}
/// Which metadata Save for Web embeds (JPEG only, like Photoshop's legacy exporter).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WebMetadata {
None ,
Copyright ,
CopyrightAndContact ,
2026-10-18 23:23:10 +02:00
/// All the XMP, none of the photo's EXIF (camera, date taken, GPS).
AllExceptCameraInfo ,
/// All the XMP and the photo's EXIF.
2026-10-01 22:49:07 -04:00
All ,
}
/// One optimisation preset (the right-hand side of the Save for Web dialog).
#[derive(Clone, Debug, PartialEq)]
pub struct WebSettings {
pub format : WebFormat ,
pub palette : PaletteKind ,
pub colors : usize ,
2026-10-13 02:50:00 +02:00
/// Fixed opaque source palette in index order.
pub color_table : Option < Vec < [ u8 ; 3 ] >> ,
/// Replacements applied after quantization, preserving pixel indices.
pub color_table_edits : std ::collections ::BTreeMap < usize , [ u8 ; 3 ] > ,
2026-10-01 22:49:07 -04:00
pub dither : Dither ,
/// 0..=1.
pub dither_amount : f32 ,
pub transparency : bool ,
/// Edge / background colour (0..=1 RGB); None = no matte (hard alpha threshold).
pub matte : Option < [ f32 ; 3 ] > ,
pub interlaced : bool ,
/// Web Snap: palette entries within this distance (0..=1 of the channel range) snap to the
/// web-safe cube.
pub web_snap : f32 ,
/// JPEG quality 0..=100.
pub quality : u8 ,
pub progressive : bool ,
pub optimized : bool ,
pub embed_icc : bool ,
pub metadata : WebMetadata ,
pub convert_to_srgb : bool ,
}
impl Default for WebSettings {
fn default () -> Self {
WebSettings {
format : WebFormat ::Png24 ,
palette : PaletteKind ::Selective ,
colors : 256 ,
2026-10-13 02:50:00 +02:00
color_table : None ,
color_table_edits : Default ::default (),
2026-10-01 22:49:07 -04:00
dither : Dither ::Diffusion ,
dither_amount : 0.88 ,
transparency : true ,
matte : Some ([ 1.0 , 1.0 , 1.0 ]),
interlaced : false ,
web_snap : 0.0 ,
quality : 60 ,
progressive : false ,
optimized : true ,
embed_icc : false ,
metadata : WebMetadata ::Copyright ,
convert_to_srgb : true ,
}
}
}
fn hex3 ( s : & str ) -> Option < [ f32 ; 3 ] > {
let s = s . trim_start_matches ( '#' );
let b = | i : usize | u8 ::from_str_radix ( s . get ( i .. i + 2 ) ? , 16 ). ok (). map ( | v | f32 ::from ( v ) / 255.0 );
( s . len () == 6 ). then_some (()) ? ;
Some ([ b ( 0 ) ? , b ( 2 ) ? , b ( 4 ) ? ])
}
impl WebSettings {
/// Settings from command parameters (missing keys keep the defaults; Photoshop's preset
/// names such as "GIF 128 Dithered" or "JPEG High" are accepted as `"preset"`).
2026-10-13 05:52:16 +02:00
///
/// Without a preset there is no matte, as in Photoshop's scripted Save for Web
/// (`ExportOptionsSaveForWeb` left at its defaults): a GIF or PNG-8 keeps the colour of its
/// partly transparent pixels instead of lightening them towards white (#4115). Formats
/// without transparency still go over white.
2026-10-01 22:49:07 -04:00
pub fn from_params ( p : & Value , cmd : & str ) -> Result < Self > {
2026-10-13 05:52:16 +02:00
let mut st = WebSettings { matte : None , .. WebSettings ::default () };
2026-10-01 22:49:07 -04:00
if let Some ( preset ) = p . get ( "preset" ). and_then ( Value ::as_str ) {
st = preset_settings ( preset ). ok_or_else ( || bad ( cmd , format! ( "unknown preset ` {preset} `" ))) ? ;
}
let s = | k : & str | p . get ( k ). and_then ( Value ::as_str );
if let Some ( f ) = s ( "format" ) {
st . format = WebFormat ::from_id ( f ). ok_or_else ( || bad ( cmd , format! ( "unknown format ` {f} ` (gif|png8|png24|jpeg|wbmp)" ))) ? ;
}
if let Some ( k ) = s ( "palette" ) {
st . palette = match k {
"restrictive" | "web" => PaletteKind ::Web ,
k => PaletteKind ::from_id ( k ). ok_or_else ( || {
bad ( cmd , format! ( "unknown palette ` {k} ` (perceptual|selective|adaptive|restrictive|exact|systemMac|systemWindows|uniform)" ))
}) ? ,
};
}
2026-10-01 23:23:49 -04:00
if let Some ( n ) = crate ::commands ::int ( p , "colors" ). filter ( | v | * v > 0 ). map ( | v | v as u64 ) {
2026-10-01 22:49:07 -04:00
st . colors = ( n as usize ). clamp ( 2 , 256 );
}
if let Some ( d ) = s ( "dither" ) {
st . dither = match d {
"none" | "noDither" => Dither ::None ,
"diffusion" => Dither ::Diffusion ,
"pattern" => Dither ::Pattern ,
"noise" => Dither ::Noise ,
d => return Err ( bad ( cmd , format! ( "unknown dither ` {d} ` (none|diffusion|pattern|noise)" ))),
};
}
if let Some ( a ) = p . get ( "ditherAmount" ). and_then ( Value ::as_f64 ) {
st . dither_amount = ( a as f32 / 100.0 ). clamp ( 0.0 , 1.0 );
}
if let Some ( b ) = p . get ( "transparency" ). and_then ( Value ::as_bool ) {
st . transparency = b ;
}
match s ( "matte" ) {
Some ( "none" ) => st . matte = None ,
Some ( c ) => st . matte = Some ( hex3 ( c ). ok_or_else ( || bad ( cmd , "matte is \" none \" or \" #rrggbb \" " )) ? ),
None => {}
}
for ( k , slot ) in [
( "interlaced" , & mut st . interlaced ),
( "progressive" , & mut st . progressive ),
( "optimized" , & mut st . optimized ),
( "embedIcc" , & mut st . embed_icc ),
( "convertToSrgb" , & mut st . convert_to_srgb ),
] {
if let Some ( b ) = p . get ( k ). and_then ( Value ::as_bool ) {
* slot = b ;
}
}
if let Some ( w ) = p . get ( "webSnap" ). and_then ( Value ::as_f64 ) {
st . web_snap = ( w as f32 / 100.0 ). clamp ( 0.0 , 1.0 );
}
if let Some ( q ) = p . get ( "quality" ). and_then ( Value ::as_f64 ) {
st . quality = q . clamp ( 0.0 , 100.0 ). round () as u8 ;
}
if let Some ( m ) = s ( "metadata" ) {
st . metadata = match m {
"none" => WebMetadata ::None ,
"copyright" => WebMetadata ::Copyright ,
"copyrightAndContact" | "contact" => WebMetadata ::CopyrightAndContact ,
2026-10-18 23:23:10 +02:00
"allExceptCameraInfo" => WebMetadata ::AllExceptCameraInfo ,
"all" => WebMetadata ::All ,
m => return Err ( bad ( cmd , format! ( "unknown metadata ` {m} ` (none|copyright|copyrightAndContact|allExceptCameraInfo|all)" ))),
2026-10-01 22:49:07 -04:00
};
}
2026-10-13 02:50:00 +02:00
if let Some ( table ) = p . get ( "colorTable" ) {
let table = table . as_array (). filter ( | a | ( 1 ..= 256 ). contains ( & a . len ())). ok_or_else ( || bad ( cmd , "colorTable must contain 1– 256 #rrggbb colours" )) ? ;
st . color_table = Some (
table
. iter ()
. map ( | v | v . as_str (). and_then ( hex3 ). map ( | c | c . map ( to8 )). ok_or_else ( || bad ( cmd , "colorTable entries must be #rrggbb colours" )))
. collect ::< Result < Vec < _ >>> () ? ,
);
}
if let Some ( edits ) = p . get ( "colorTableEdits" ) {
let edits = edits
. as_object ()
. filter ( | m | m . len () <= 256 )
. ok_or_else ( || bad ( cmd , "colorTableEdits must be an object of palette indices to #rrggbb colours" )) ? ;
let table = st . color_table . as_ref (). ok_or_else ( || bad ( cmd , "colorTableEdits requires colorTable" )) ? ;
for ( index , value ) in edits {
let index = index . parse ::< usize > (). ok (). filter ( | i | * i < table . len ()). ok_or_else ( || bad ( cmd , "colorTableEdits index is outside colorTable" )) ? ;
let color = value . as_str (). and_then ( hex3 ). map ( | c | c . map ( to8 )). ok_or_else ( || bad ( cmd , "colorTableEdits values must be #rrggbb colours" )) ? ;
st . color_table_edits . insert ( index , color );
}
}
2026-10-01 22:49:07 -04:00
Ok ( st )
}
}
impl WebSettings {
/// The settings as command parameters (the inverse of [`WebSettings::from_params`]).
pub fn to_params ( & self ) -> Value {
let format = match self . format {
WebFormat ::Gif => "gif" ,
WebFormat ::Png8 => "png8" ,
WebFormat ::Png24 => "png24" ,
WebFormat ::Jpeg => "jpeg" ,
WebFormat ::Wbmp => "wbmp" ,
};
let palette = match self . palette {
PaletteKind ::Exact => "exact" ,
PaletteKind ::SystemMac => "systemMac" ,
PaletteKind ::SystemWindows => "systemWindows" ,
PaletteKind ::Web => "restrictive" ,
PaletteKind ::Uniform => "uniform" ,
PaletteKind ::Perceptual => "perceptual" ,
PaletteKind ::Selective => "selective" ,
PaletteKind ::Adaptive => "adaptive" ,
};
let dither = match self . dither {
Dither ::None => "none" ,
Dither ::Diffusion => "diffusion" ,
Dither ::Pattern => "pattern" ,
Dither ::Noise => "noise" ,
};
let metadata = match self . metadata {
WebMetadata ::None => "none" ,
WebMetadata ::Copyright => "copyright" ,
WebMetadata ::CopyrightAndContact => "copyrightAndContact" ,
2026-10-18 23:23:10 +02:00
WebMetadata ::AllExceptCameraInfo => "allExceptCameraInfo" ,
2026-10-01 22:49:07 -04:00
WebMetadata ::All => "all" ,
};
let matte = self . matte . map_or ( "none" . to_string (), | c | format! ( "# {:02x}{:02x}{:02x} " , to8 ( c [ 0 ]), to8 ( c [ 1 ]), to8 ( c [ 2 ])));
2026-10-13 02:50:00 +02:00
let mut p = json! ({ "format" : format , "palette" : palette , "colors" : self . colors , "dither" : dither , "ditherAmount" : ( self . dither_amount * 100.0 ). round (), "transparency" : self . transparency , "matte" : matte , "interlaced" : self . interlaced , "webSnap" : ( self . web_snap * 100.0 ). round (), "quality" : self . quality , "progressive" : self . progressive , "optimized" : self . optimized , "embedIcc" : self . embed_icc , "metadata" : metadata , "convertToSrgb" : self . convert_to_srgb });
if let Some ( table ) = & self . color_table {
p [ "colorTable" ] = json! ( table . iter (). map ( | c | rgb_hex ( * c )). collect ::< Vec < _ >> ());
}
if ! self . color_table_edits . is_empty () {
p [ "colorTableEdits" ] =
json! ( self . color_table_edits . iter (). map ( | ( i , c ) | ( i . to_string (), rgb_hex ( * c ))). collect ::< std ::collections ::BTreeMap < _ , _ >> ());
}
p
2026-10-01 22:49:07 -04:00
}
}
2026-10-13 02:50:00 +02:00
/// A palette colour in command/control-channel notation.
pub fn rgb_hex ( c : [ u8 ; 3 ]) -> String {
format! ( "# {:02x}{:02x}{:02x} " , c [ 0 ], c [ 1 ], c [ 2 ])
}
2026-10-01 22:49:07 -04:00
/// Save for Web's named presets.
pub const PRESETS : [ & str ; 12 ] = [
"GIF 128 Dithered" ,
"GIF 128 No Dither" ,
"GIF 32 Dithered" ,
"GIF 32 No Dither" ,
"GIF 64 Dithered" ,
"GIF 64 No Dither" ,
"GIF Restrictive" ,
"JPEG High" ,
"JPEG Low" ,
"JPEG Medium" ,
"PNG-24" ,
"PNG-8 128 Dithered" ,
];
pub fn preset_settings ( name : & str ) -> Option < WebSettings > {
let d = WebSettings ::default ();
let gif = | colors : usize , dither : bool | WebSettings {
format : WebFormat ::Gif ,
colors ,
dither : if dither { Dither ::Diffusion } else { Dither ::None },
.. WebSettings ::default ()
};
Some ( match name {
"GIF 128 Dithered" => gif ( 128 , true ),
"GIF 128 No Dither" => gif ( 128 , false ),
"GIF 32 Dithered" => gif ( 32 , true ),
"GIF 32 No Dither" => gif ( 32 , false ),
"GIF 64 Dithered" => gif ( 64 , true ),
"GIF 64 No Dither" => gif ( 64 , false ),
"GIF Restrictive" => WebSettings { palette : PaletteKind ::Web , .. gif ( 256 , true ) },
"JPEG High" => WebSettings { format : WebFormat ::Jpeg , quality : 60 , .. d },
"JPEG Low" => WebSettings { format : WebFormat ::Jpeg , quality : 10 , .. d },
"JPEG Medium" => WebSettings { format : WebFormat ::Jpeg , quality : 30 , .. d },
"JPEG Maximum" => WebSettings { format : WebFormat ::Jpeg , quality : 100 , .. d },
"JPEG Very High" => WebSettings { format : WebFormat ::Jpeg , quality : 80 , .. d },
"PNG-24" => WebSettings { format : WebFormat ::Png24 , .. d },
"PNG-8 128 Dithered" => WebSettings { format : WebFormat ::Png8 , colors : 128 , .. d },
_ => return None ,
})
}
// ---------- the image ----------
/// The document as Save for Web sees it: RGB (converted to sRGB unless told otherwise), resized
/// by `"width"`/`"height"`/`"percent"`, flattened. Returns the document and the scale factors.
pub fn web_document ( doc : & Document , p : & Value , st : & WebSettings ) -> Result < ( Document , f64 , f64 ) > {
let mut tmp = Session ::new ();
tmp . add_document ( doc . clone (), None );
let mode = doc . mode ;
if mode != photocraft_color ::ColorMode ::Rgb {
tmp . execute ( "image.mode.rgb" , json! ({})) ? ;
}
if st . convert_to_srgb && doc . icc_profile . is_some () {
// Documents without a profile are treated as sRGB already.
tmp . execute ( "edit.convertToProfile" , json! ({ "profile" : "srgb" , "intent" : "perceptual" })) ? ;
}
let ( w0 , h0 ) = ( f64 ::from ( doc . size . width ), f64 ::from ( doc . size . height ));
let pct = p . get ( "percent" ). and_then ( Value ::as_f64 ). filter ( | v | * v > 0.0 );
let w = p . get ( "width" ). and_then ( Value ::as_f64 ). filter ( | v | * v > 0.0 );
let h = p . get ( "height" ). and_then ( Value ::as_f64 ). filter ( | v | * v > 0.0 );
let ( nw , nh ) = match ( pct , w , h ) {
( Some ( pc ), _ , _ ) => ( w0 * pc / 100.0 , h0 * pc / 100.0 ),
( _ , Some ( w ), Some ( h )) => ( w , h ),
( _ , Some ( w ), None ) => ( w , h0 * w / w0 ),
( _ , None , Some ( h )) => ( w0 * h / h0 , h ),
_ => ( w0 , h0 ),
};
let ( nw , nh ) = ( nw . round (). max ( 1.0 ), nh . round (). max ( 1.0 ));
if ( nw - w0 ). abs () >= 1.0 || ( nh - h0 ). abs () >= 1.0 {
let resample = p . get ( "resample" ). and_then ( Value ::as_str ). unwrap_or ( "bicubic" );
tmp . execute ( "image.imageSize" , json! ({ "width" : nw , "height" : nh , "resample" : resample })) ? ;
}
let out = ( * tmp . active (). ok_or ( EngineError ::NoDocument ) ? . doc ). clone ();
Ok (( out , nw / w0 , nh / h0 ))
}
/// An optimised image.
#[derive(Clone, Debug)]
pub struct Optimized {
pub bytes : Vec < u8 > ,
pub ext : & 'static str ,
pub width : u32 ,
pub height : u32 ,
/// Colours in the palette (indexed formats).
pub colors : Option < usize > ,
2026-10-13 02:50:00 +02:00
/// Opaque source palette before replacements (empty for non-indexed formats).
pub color_table : Vec < [ u8 ; 3 ] > ,
pub transparent_index : Option < usize > ,
/// Indexed pixels for previews; empty for non-indexed formats or when preview is disabled.
pub indices : Vec < u8 > ,
2026-10-01 22:49:07 -04:00
/// What the optimised file looks like, straight RGBA8 (for the dialog's previews).
pub preview : Vec < u8 > ,
}
fn to8 ( v : f32 ) -> u8 {
( v . clamp ( 0.0 , 1.0 ) * 255.0 ). round () as u8
}
/// XMP to embed for the metadata choice.
pub fn web_xmp ( doc : & Document , m : WebMetadata ) -> Option < String > {
let xmp = doc . metadata . xmp . as_deref () ? ;
match m {
WebMetadata ::None => None ,
2026-10-18 23:23:10 +02:00
WebMetadata ::All | WebMetadata ::AllExceptCameraInfo => Some ( xmp . to_string ()),
2026-10-01 22:49:07 -04:00
WebMetadata ::Copyright | WebMetadata ::CopyrightAndContact => {
let info = crate ::file_cmds ::read_file_info ( Some ( xmp ));
let mut keep = serde_json ::Map ::new ();
let mut keys = vec! [ "copyright" , "copyrightStatus" , "copyrightUrl" ];
if m == WebMetadata ::CopyrightAndContact {
keys . extend ([ "author" , "authorTitle" ]);
}
for k in keys {
if let Some ( v ) = info . get ( k ). filter ( | v | v . as_str (). is_some_and ( | s | ! s . is_empty ()) || v . is_array ()) {
keep . insert ( k . into (), v . clone ());
}
}
( ! keep . is_empty ()). then ( || crate ::file_cmds ::write_file_info ( None , & Value ::Object ( keep )))
}
}
}
2026-10-18 23:23:10 +02:00
/// The photo's EXIF to embed for the metadata choice: only "All" carries it (#7436).
pub fn web_exif ( doc : & Document , m : WebMetadata ) -> Option <& [ u8 ] > {
doc . metadata . exif . as_deref (). map ( Vec ::as_slice ). filter ( | _ | m == WebMetadata ::All )
}
/// The response's warnings: EXIF that a JPEG couldn't hold (one segment, about 64 KB).
fn web_warnings ( st : & WebSettings , exif : Option <& [ u8 ] > , dpi : f32 ) -> Vec < String > {
exif . filter ( | x | st . format == WebFormat ::Jpeg && ! photocraft_codecs ::web ::jpeg_exif_fits ( x , Some ( dpi )))
. map ( | x | photocraft_codecs ::FidelityWarning ::MetadataTooLarge { what : "EXIF" , bytes : x . len () }. to_string ())
. into_iter ()
. collect ()
}
2026-10-01 22:49:07 -04:00
/// Optimises `rect` of `px` (straight RGBA, `bw` wide) with `st`. `preview` also returns what
/// the file looks like (the dialog's panes); exports skip it.
#[allow(clippy::too_many_arguments)]
2026-10-18 23:23:10 +02:00
pub fn optimize (
px : & [[ f32 ; 4 ]],
bw : usize ,
rect : Rect ,
st : & WebSettings ,
icc : Option <& [ u8 ] > ,
exif : Option <& [ u8 ] > ,
xmp : Option <& str > ,
dpi : f32 ,
preview : bool ,
) -> Result < Optimized > {
2026-10-13 02:50:00 +02:00
if bw == 0 || rect . x0 < 0 || rect . y0 < 0 || rect . is_empty () || rect . x1 as usize > bw || ( rect . y1 as usize ). checked_mul ( bw ). is_none_or ( | end | end > px . len ())
{
return Err ( other ( "slice is outside the pixel buffer" ));
2026-10-01 22:49:07 -04:00
}
2026-10-13 02:50:00 +02:00
let ( w , h ) = ( rect . width () as usize , rect . height () as usize );
2026-10-01 22:49:07 -04:00
let row = | y : i32 | {
let o = y as usize * bw ;
2026-10-13 02:50:00 +02:00
px . get ( o + rect . x0 as usize .. o + rect . x1 as usize ). unwrap_or ( & [])
2026-10-01 22:49:07 -04:00
};
let region = || ( rect . y0 .. rect . y1 ). flat_map ( row );
let matte = st . matte . unwrap_or ([ 1.0 , 1.0 , 1.0 ]);
let over = | p : & [ f32 ; 4 ] | -> [ f32 ; 4 ] {
[ p [ 0 ] * p [ 3 ] + matte [ 0 ] * ( 1.0 - p [ 3 ]), p [ 1 ] * p [ 3 ] + matte [ 1 ] * ( 1.0 - p [ 3 ]), p [ 2 ] * p [ 3 ] + matte [ 2 ] * ( 1.0 - p [ 3 ]), 1.0 ]
};
let ( ww , hh ) = ( w as u32 , h as u32 );
let e = | e : photocraft_codecs ::CodecError | other ( e );
let mut look : Vec < u8 > = Vec ::new ();
2026-10-13 02:50:00 +02:00
let mut color_table = Vec ::new ();
let mut transparent_index = None ;
let mut indices = Vec ::new ();
2026-10-01 22:49:07 -04:00
let ( bytes , colors ) = match st . format {
WebFormat ::Png24 => {
let alpha = st . transparency && region (). any ( | p | p [ 3 ] < 1.0 );
let k = if alpha { 4 } else { 3 };
let mut data = Vec ::with_capacity ( w * h * k );
for p in region () {
let q = if alpha { * p } else { over ( p ) };
data . extend_from_slice ( & [ to8 ( q [ 0 ]), to8 ( q [ 1 ]), to8 ( q [ 2 ]), to8 ( q [ 3 ])][ .. k ]);
}
if preview {
2026-10-04 10:39:06 -04:00
look = if alpha { data . clone () } else { data . as_chunks ::< 3 > (). 0. iter (). flat_map ( | c | [ c [ 0 ], c [ 1 ], c [ 2 ], 255 ]). collect () };
2026-10-01 22:49:07 -04:00
}
let layout = if alpha { photocraft_codecs ::ChannelLayout ::Rgba } else { photocraft_codecs ::ChannelLayout ::Rgb };
let meta = photocraft_codecs ::Metadata { dpi : Some (( dpi , dpi )), xmp : xmp . map ( str ::to_string ), .. Default ::default () };
let img = photocraft_codecs ::Image ::from_u8 ( ww , hh , layout , data ). map_err ( e ) ? . with_icc ( icc . map ( < [ u8 ] > ::to_vec )). with_meta ( meta );
let opts = photocraft_codecs ::EncodeOptions {
png_interlaced : st . interlaced ,
embed_icc : icc . is_some (),
embed_metadata : xmp . is_some (),
.. Default ::default ()
};
( photocraft_codecs ::encode ( & img , photocraft_codecs ::Format ::Png , & opts ). map_err ( e ) ? , None )
}
WebFormat ::Jpeg => {
let mut rgb = Vec ::with_capacity ( w * h * 3 );
for p in region () {
let q = over ( p );
rgb . extend_from_slice ( & [ to8 ( q [ 0 ]), to8 ( q [ 1 ]), to8 ( q [ 2 ])]);
}
// Save for Web's 0– 100 quality → encoder 1– 100.
let q = st . quality . max ( 1 );
2026-10-18 23:23:10 +02:00
let bytes =
photocraft_codecs ::web ::encode_jpeg_rgb8 ( ww , hh , & rgb , q , st . progressive , st . optimized , icc . filter ( | _ | st . embed_icc ), Some ( dpi ), exif , xmp )
. map_err ( e ) ? ;
2026-10-01 22:49:07 -04:00
if preview {
// The preview shows the compression artefacts.
look = match photocraft_codecs ::decode ( & bytes ) {
Ok ( img ) => img . convert ( photocraft_codecs ::ChannelLayout ::Rgba , photocraft_codecs ::SampleType ::U8 ). data (). to_vec (),
2026-10-04 10:39:06 -04:00
Err ( _ ) => rgb . as_chunks ::< 3 > (). 0. iter (). flat_map ( | c | [ c [ 0 ], c [ 1 ], c [ 2 ], 255 ]). collect (),
2026-10-01 22:49:07 -04:00
};
}
( bytes , None )
}
WebFormat ::Wbmp => {
let mut gray : Vec < f32 > = region ()
. map ( | p | {
let q = over ( p );
0.299 * q [ 0 ] + 0.587 * q [ 1 ] + 0.114 * q [ 2 ]
})
. collect ();
let method = match st . dither {
Dither ::None => quantize ::BitmapMethod ::Threshold ,
Dither ::Pattern => quantize ::BitmapMethod ::Pattern ,
_ => quantize ::BitmapMethod ::Diffusion ,
};
quantize ::to_bitmap ( & mut gray , w , method );
let white : Vec < bool > = gray . iter (). map ( | v | * v > 0.5 ). collect ();
if preview {
look = white . iter (). flat_map ( | b | if * b { [ 255 , 255 , 255 , 255 ] } else { [ 0 , 0 , 0 , 255 ] }). collect ();
}
( photocraft_codecs ::web ::encode_wbmp ( ww , hh , & white ). map_err ( e ) ? , Some ( 2 ))
}
WebFormat ::Gif | WebFormat ::Png8 => {
let transparent = st . transparency && region (). any ( | p | p [ 3 ] < 0.5 );
// Partially transparent edges blend with the matte; with transparency off everything does.
let mut work : Vec < [ f32 ; 4 ] > = region ()
. map ( | p | {
if st . transparency && p [ 3 ] < 0.5 {
[ 0.0 , 0.0 , 0.0 , 0.0 ]
} else if st . transparency && st . matte . is_none () {
[ p [ 0 ], p [ 1 ], p [ 2 ], 1.0 ]
} else {
over ( p )
}
})
. collect ();
let room = if transparent { st . colors . saturating_sub ( 1 ). max ( 2 ) } else { st . colors };
2026-10-13 02:50:00 +02:00
let mut pal = if let Some ( table ) = & st . color_table {
if table . is_empty () || table . len () > if transparent { 255 } else { 256 } {
return Err ( other ( "colorTable has too many entries for this image's transparency" ));
}
table . clone ()
} else {
// Large images: build adaptive palettes from an even ~1 MP sample (Exact needs all).
let n_opaque = work . iter (). filter ( | p | p [ 3 ] >= 0.5 ). count ();
let step = if n_opaque > 1 << 20 && st . palette != PaletteKind ::Exact { n_opaque / ( 1 << 20 ) + 1 } else { 1 };
let mut src : Vec < [ f32 ; 4 ] > = work . iter (). filter ( | p | p [ 3 ] >= 0.5 ). step_by ( step ). copied (). collect ();
if src . is_empty () {
src = work . clone ();
}
quantize ::build_palette ( & src , st . palette , room , Forced ::None ). map_err ( other ) ?
};
if st . web_snap > 0.0 && st . color_table . is_none () {
2026-10-01 22:49:07 -04:00
let tol = ( st . web_snap * 255.0 ) as u8 ;
for c in & mut pal {
let snap = c . map ( | x | (( f32 ::from ( x ) / 51.0 ). round () * 51.0 ) as u8 );
if c . iter (). zip ( snap ). all ( | ( a , b ) | a . abs_diff ( b ) <= tol ) {
* c = snap ;
}
}
}
if transparent {
pal . truncate ( 255 );
}
2026-10-13 02:50:00 +02:00
let first = pal . first (). copied (). ok_or_else ( || other ( "empty colour table" )) ? ;
color_table = pal . clone ();
2026-10-01 22:49:07 -04:00
let t = transparent . then ( || {
2026-10-13 02:50:00 +02:00
pal . push ( first );
2026-10-01 22:49:07 -04:00
pal . len () - 1
});
2026-10-13 02:50:00 +02:00
transparent_index = t ;
2026-10-01 22:49:07 -04:00
let idx = quantize_rows ( & mut work , w , & pal , st . dither , st . dither_amount , t );
2026-10-13 02:50:00 +02:00
for ( index , color ) in & st . color_table_edits {
let slot = pal . get_mut ( * index ). filter ( | _ | Some ( * index ) != t ). ok_or_else ( || other ( "colour edit is outside the opaque palette" )) ? ;
* slot = * color ;
}
2026-10-01 22:49:07 -04:00
if preview {
look = idx
. iter ()
. flat_map ( | k | {
2026-10-13 02:50:00 +02:00
let c = pal . get ( * k as usize ). copied (). unwrap_or ( first );
2026-10-01 22:49:07 -04:00
[ c [ 0 ], c [ 1 ], c [ 2 ], if Some ( * k as usize ) == t { 0 } else { 255 }]
})
. collect ();
}
let tt = t . map ( | v | v as u8 );
let bytes = if st . format == WebFormat ::Gif {
photocraft_codecs ::web ::encode_gif_indexed ( ww , hh , & idx , & pal , tt , st . interlaced ). map_err ( e ) ?
} else {
2026-10-19 19:02:18 +02:00
photocraft_codecs ::encode_png_indexed ( ww , hh , & idx , & pal , tt , None , None ). map_err ( e ) ?
2026-10-01 22:49:07 -04:00
};
2026-10-13 02:50:00 +02:00
if preview {
indices = idx ;
}
2026-10-01 22:49:07 -04:00
( bytes , Some ( pal . len ()))
}
};
2026-10-13 02:50:00 +02:00
Ok ( Optimized { bytes , ext : st . format . ext (), width : ww , height : hh , colors , color_table , transparent_index , indices , preview : look })
2026-10-01 22:49:07 -04:00
}
/// Nearest palette entry through a lazily filled 6-bit-per-channel inverse colour map: each
/// 4× 4× 4 cell is resolved once (by its centre), so large images cost a table lookup per pixel
/// instead of a palette scan. `t` (the transparent index) is never returned.
struct InverseMap < 'a > {
pal : & 'a [[ u8 ; 3 ]],
t : Option < usize > ,
lut : Vec < u16 > ,
}
impl < 'a > InverseMap < 'a > {
fn new ( pal : & 'a [[ u8 ; 3 ]], t : Option < usize > ) -> Self {
InverseMap { pal , t , lut : vec ! [ u16 ::MAX ; 1 << 18 ] }
}
#[inline]
fn get ( & mut self , c : [ f32 ; 3 ]) -> usize {
let q = c . map ( | v | (( v . clamp ( 0.0 , 1.0 ) * 255.0 ). round () as usize ) >> 2 );
let key = q [ 0 ] << 12 | q [ 1 ] << 6 | q [ 2 ];
let v = self . lut [ key ];
if v != u16 ::MAX {
return usize ::from ( v );
}
let center = q . map ( | v | ( v * 4 + 2 ) as f32 );
let mut best = ( f32 ::MAX , 0 usize );
for ( i , e ) in self . pal . iter (). enumerate () {
if Some ( i ) == self . t {
continue ;
}
let d : f32 = ( 0 .. 3 ). map ( | k | ( center [ k ] - f32 ::from ( e [ k ])). powi ( 2 )). sum ();
if d < best . 0 {
best = ( d , i );
}
}
self . lut [ key ] = best . 1 as u16 ;
best . 1
}
}
fn hash01 ( x : usize , y : usize ) -> f32 {
let mut h = ( x as u32 ). wrapping_mul ( 0x8da6_b343 ) ^ ( y as u32 ). wrapping_mul ( 0xd816_3841 ) ^ 0x2545_f491 ;
h ^= h >> 13 ;
h = h . wrapping_mul ( 0x5bd1_e995 );
h ^= h >> 15 ;
( h & 0xffff ) as f32 / 65535.0
}
/// Palette mapping with dithering (Floyd– Steinberg diffusion, 8× 8 Bayer pattern, noise), like
/// `algo::quantize::quantize` but through [`InverseMap`]. `y0` keeps patterns continuous across
/// bands. Pixels with alpha < ½ take index `t`.
fn quantize_band ( px : & mut [[ f32 ; 4 ]], w : usize , y0 : usize , pal : & [[ u8 ; 3 ]], dither : Dither , amount : f32 , t : Option < usize > ) -> Vec < u8 > {
let h = px . len () / w . max ( 1 );
let mut map = InverseMap ::new ( pal , t );
let mut idx = vec! [ 0 u8 ; px . len ()];
let opaque_n = pal . len () - usize ::from ( t . is_some ());
let spread = ( 1.0 / ( opaque_n as f32 ). cbrt (). max ( 1.0 )). min ( 0.5 ) * amount . clamp ( 0.0 , 1.0 );
let a = amount . clamp ( 0.0 , 1.0 );
let mut cur = vec! [[ 0.0 f32 ; 3 ]; if dither == Dither ::Diffusion { w + 2 } else { 0 }];
let mut next = cur . clone ();
for y in 0 .. h {
if dither == Dither ::Diffusion {
std ::mem ::swap ( & mut cur , & mut next );
next . iter_mut (). for_each ( | e | * e = [ 0.0 ; 3 ]);
}
for x in 0 .. w {
let i = y * w + x ;
let p = px [ i ];
if p [ 3 ] < 0.5
&& let Some ( t ) = t
{
idx [ i ] = t as u8 ;
continue ;
}
let mut c = [ p [ 0 ], p [ 1 ], p [ 2 ]];
match dither {
Dither ::None => {}
Dither ::Diffusion => ( 0 .. 3 ). for_each ( | k | c [ k ] += cur [ x + 1 ][ k ]),
Dither ::Pattern => {
let d = quantize ::bayer8 ( x , y0 + y ) * spread ;
c = c . map ( | v | v + d );
}
Dither ::Noise => {
let d = ( hash01 ( x , y0 + y ) - 0.5 ) * spread ;
c = c . map ( | v | v + d );
}
}
let c = c . map ( | v | v . clamp ( 0.0 , 1.0 ));
let k = map . get ( c );
idx [ i ] = k as u8 ;
if dither == Dither ::Diffusion {
let e = pal [ k ]. map ( | v | f32 ::from ( v ) / 255.0 );
let d : [ f32 ; 3 ] = std ::array ::from_fn ( | q | ( c [ q ] - e [ q ]) * a );
for q in 0 .. 3 {
cur [ x + 2 ][ q ] += d [ q ] * 7.0 / 16.0 ;
next [ x ][ q ] += d [ q ] * 3.0 / 16.0 ;
next [ x + 1 ][ q ] += d [ q ] * 5.0 / 16.0 ;
next [ x + 2 ][ q ] += d [ q ] / 16.0 ;
}
}
}
}
idx
}
/// [`quantize_band`] over the whole image, in parallel bands of 64 rows when the dither is
/// position-independent. Error diffusion carries error down the image, so it stays sequential.
fn quantize_rows ( px : & mut [[ f32 ; 4 ]], w : usize , pal : & [[ u8 ; 3 ]], dither : Dither , amount : f32 , t : Option < usize > ) -> Vec < u8 > {
let h = px . len () / w . max ( 1 );
if dither == Dither ::Diffusion || h < 128 || cfg! ( target_arch = "wasm32" ) {
return quantize_band ( px , w , 0 , pal , dither , amount , t );
}
#[cfg(not(target_arch = "wasm32" ))]
{
use rayon ::prelude ::* ;
let band = w * 64 ;
let mut idx = vec! [ 0 u8 ; px . len ()];
px . par_chunks_mut ( band )
. zip ( idx . par_chunks_mut ( band ))
. enumerate ()
. for_each ( | ( i , ( p , out )) | out . copy_from_slice ( & quantize_band ( p , w , i * 64 , pal , dither , amount , t )));
idx
}
2026-10-04 13:26:26 -04:00
// Single-threaded on wasm (the early return above always takes this path there).
2026-10-01 22:49:07 -04:00
#[cfg(target_arch = "wasm32" )]
2026-10-04 13:26:26 -04:00
quantize_band ( px , w , 0 , pal , dither , amount , t )
2026-10-01 22:49:07 -04:00
}
/// A file-name-safe slice name.
fn slice_file ( name : & str ) -> String {
let s : String = name . chars (). map ( | c | if c . is_alphanumeric () || matches! ( c , '-' | '_' ) { c } else { '_' }). collect ();
2026-10-11 23:18:52 -06:00
let s = if s . is_empty () { "slice" . into () } else { s };
portable_generated_stem ( s )
}
/// Generated filenames should remain usable on Windows even when a document or slice is named
/// after a reserved device. Caller-supplied path components are still rejected below.
fn portable_generated_stem ( stem : String ) -> String {
2026-10-21 01:29:31 -05:00
portable_device_name ( stem )
2026-10-01 22:49:07 -04:00
}
fn html_escape ( s : & str ) -> String {
s . replace ( '&' , "&" ). replace ( '<' , "<" ). replace ( '>' , ">" ). replace ( '"' , """ )
}
2026-10-11 23:18:52 -06:00
/// Imported slice URLs are document data. Only ordinary web/contact links and relative links
/// become anchors; an unsupported scheme leaves the image unlinked.
fn safe_slice_url ( url : & str ) -> bool {
if url . is_empty () || url . trim () != url || url . chars (). any ( | c | c . is_control () || c == '\\' ) {
return false ;
}
let delimiter = url . find ([ '/' , '?' , '#' ]). unwrap_or ( url . len ());
let Some ( colon ) = url . find ( ':' ). filter ( | pos | * pos < delimiter ) else {
return true ;
};
let scheme = url . get ( .. colon ). unwrap_or_default (). to_ascii_lowercase ();
let rest = url . get ( colon + 1 .. ). unwrap_or_default ();
match scheme . as_str () {
"http" | "https" | "ftp" | "ftps" => rest . strip_prefix ( "//" ). is_some_and ( | host | ! host . is_empty () && ! host . starts_with ( '/' )),
"mailto" | "tel" => ! rest . is_empty (),
_ => false ,
}
}
/// Keep the image folder relative to the selected export directory on every platform.
fn web_images_folder ( images : & str , cmd : & str ) -> Result < String > {
if images . is_empty () {
return Ok ( String ::new ());
}
let trimmed = images . trim_end_matches ([ '/' , '\\' ]);
if trimmed . is_empty () {
return Err ( bad ( cmd , "imagesFolder must be a relative folder name" ));
}
let parts = asset_path_components ( trimmed ). map_err ( | e | bad ( cmd , format! ( "imagesFolder: {e} " ))) ? ;
Ok ( parts . join ( "/" ))
}
fn web_output_path ( dir : & str , relative : & str ) -> Result < String > {
let root = if dir . is_empty () { "." } else { dir };
asset_output_path ( root , relative ). map ( | ( actual , _ ) | actual )
}
fn web_derived_path ( dir : & str , relative : & str , native_paths : bool ) -> Result < String > {
if native_paths {
web_output_path ( dir , relative )
} else {
// Browser downloads use logical names, without a host filesystem to inspect.
asset_path_components ( relative ) ? ;
Ok ( join ( dir , relative ))
}
}
2026-10-01 22:49:07 -04:00
fn scale_rect ( r : Rect , sx : f64 , sy : f64 ) -> Rect {
Rect ::new (
( f64 ::from ( r . x0 ) * sx ). round () as i32 ,
( f64 ::from ( r . y0 ) * sy ). round () as i32 ,
( f64 ::from ( r . x1 ) * sx ). round () as i32 ,
( f64 ::from ( r . y1 ) * sy ). round () as i32 ,
)
}
/// The HTML table for `cells` (rect, td body) over a `w`× `h` image: one column per distinct x
/// edge, one row per y edge, colspan/rowspan per slice, and a spacer row fixing column widths.
fn html_table ( title : & str , w : u32 , h : u32 , cells : & [( Rect , String , String )], spacer : Option <& str > ) -> String {
let mut xs : Vec < i32 > = cells . iter (). flat_map ( | ( r , _ , _ ) | [ r . x0 , r . x1 ]). collect ();
let mut ys : Vec < i32 > = cells . iter (). flat_map ( | ( r , _ , _ ) | [ r . y0 , r . y1 ]). collect ();
xs . sort_unstable ();
xs . dedup ();
ys . sort_unstable ();
ys . dedup ();
let col = | x : i32 | xs . iter (). position ( | v | * v == x ). unwrap_or ( 0 );
let row = | y : i32 | ys . iter (). position ( | v | * v == y ). unwrap_or ( 0 );
let ( nc , nr ) = ( xs . len (). saturating_sub ( 1 ), ys . len (). saturating_sub ( 1 ));
let mut taken = vec! [ false ; nc * nr ];
let mut by_row : Vec < Vec < ( usize , String ) >> = vec! [ Vec ::new (); nr ];
for ( r , attrs , body ) in cells {
let ( c0 , c1 , r0 , r1 ) = ( col ( r . x0 ), col ( r . x1 ), row ( r . y0 ), row ( r . y1 ));
if ( r0 .. r1 ). any ( | j | ( c0 .. c1 ). any ( | i | taken [ j * nc + i ])) {
2026-10-07 23:12:27 -04:00
continue ; // overlapped (the caller cuts slices into visible pieces, so this can't happen)
2026-10-01 22:49:07 -04:00
}
for j in r0 .. r1 {
for i in c0 .. c1 {
taken [ j * nc + i ] = true ;
}
}
let mut td = String ::from ( " \t\t <td" );
if c1 - c0 > 1 {
td . push_str ( & format! ( " colspan= \" {} \" " , c1 - c0 ));
}
if r1 - r0 > 1 {
td . push_str ( & format! ( " rowspan= \" {} \" " , r1 - r0 ));
}
td . push_str ( attrs );
td . push ( '>' );
td . push_str ( body );
td . push_str ( "</td> \n " );
by_row [ r0 ]. push (( c0 , td ));
}
let mut s = format! (
"<html> \n <head> \n <title> {t} </title> \n <meta http-equiv= \" Content-Type \" content= \" text/html; charset=utf-8 \" > \n </head> \n <body bgcolor= \" #FFFFFF \" leftmargin= \" 0 \" topmargin= \" 0 \" marginwidth= \" 0 \" marginheight= \" 0 \" > \n <!-- Save for Web Slices ( {t} ) --> \n <table id= \" Table_01 \" width= \" {w} \" height= \" {h} \" border= \" 0 \" cellpadding= \" 0 \" cellspacing= \" 0 \" > \n " ,
t = html_escape ( title )
);
for ( j , mut tds ) in by_row . into_iter (). enumerate () {
tds . sort_by_key ( | ( c , _ ) | * c );
s . push_str ( " \t <tr> \n " );
for ( _ , td ) in tds {
s . push_str ( & td );
}
if let Some ( sp ) = spacer {
2026-10-11 23:18:52 -06:00
s . push_str ( & format! ( " \t\t <td> \n\t\t\t <img src= \" {} \" width= \" 1 \" height= \" {} \" alt= \"\" ></td> \n " , html_escape ( sp ), ys [ j + 1 ] - ys [ j ]));
2026-10-01 22:49:07 -04:00
}
s . push_str ( " \t </tr> \n " );
}
if let Some ( sp ) = spacer {
s . push_str ( " \t <tr> \n " );
for i in 0 .. nc {
2026-10-11 23:18:52 -06:00
s . push_str ( & format! ( " \t\t <td> \n\t\t\t <img src= \" {} \" width= \" {} \" height= \" 1 \" alt= \"\" ></td> \n " , html_escape ( sp ), xs [ i + 1 ] - xs [ i ]));
2026-10-01 22:49:07 -04:00
}
s . push_str ( " \t\t <td></td> \n\t </tr> \n " );
}
s . push_str ( "</table> \n <!-- End Save for Web Slices --> \n </body> \n </html> \n " );
s
}
/// File › Export › Save for Web (Legacy).
fn save_for_web ( s : & mut Session , p : & Value ) -> Result < Value > {
2026-10-11 23:18:52 -06:00
save_for_web_impl ( s , p , & mut write_file , true )
2026-10-09 14:03:58 -04:00
}
/// Save for Web through a platform writer (native files or browser downloads).
/// The writer receives every image, spacer and HTML file. A write error stops the export;
2026-10-11 23:18:52 -06:00
/// only a complete export remembers its settings and fires the export event. A custom writer
/// that targets the local filesystem must enforce its own path confinement.
2026-10-09 14:03:58 -04:00
pub fn save_for_web_with_writer ( s : & mut Session , p : & Value , write : & mut dyn FnMut ( & str , & [ u8 ]) -> Result < () > ) -> Result < Value > {
2026-10-11 23:18:52 -06:00
save_for_web_impl ( s , p , write , false )
}
2026-10-13 09:26:47 +08:00
/// Settings to seed the Save for Web dialog from on the next launch: `file_menu` is session
/// state, so a complete export also writes the preferences' remembered dialog settings (like
/// Fill, Liquify and the filter dialogs). The destination is per export, never remembered.
fn remember_web_settings ( s : & mut Session , cmd : & str , settings : & Value ) {
let mut m = settings . clone ();
if let Some ( o ) = m . as_object_mut () {
for k in [ "path" , "dir" , "trustedSliceHtml" ] {
o . remove ( k );
}
}
s . prefs . edit ( | p | {
p . dialogs . insert ( cmd . to_string (), m );
});
}
2026-10-11 23:18:52 -06:00
fn save_for_web_impl ( s : & mut Session , p : & Value , write : & mut dyn FnMut ( & str , & [ u8 ]) -> Result < () > , native_paths : bool ) -> Result < Value > {
2026-10-01 22:49:07 -04:00
let cmd = "file.export.saveForWebLegacy" ;
2026-10-09 14:03:58 -04:00
if let Some ( authorize ) = s . authorize {
authorize ( cmd , p ) ? ;
}
// Direct platform calls must export the moved selection as it appears on the canvas,
// just as Session::execute does before dispatching the native command.
crate ::float_cmds ::before_command ( s , cmd ) ? ;
2026-10-01 22:49:07 -04:00
let st = WebSettings ::from_params ( p , cmd ) ? ;
let d = s . active (). ok_or ( EngineError ::NoDocument ) ? ;
let doc = d . doc . clone ();
let ( wdoc , sx , sy ) = web_document ( & doc , p , & st ) ? ;
let buf = photocraft_compose ::flatten ( & wdoc );
let bw = wdoc . size . width as usize ;
let icc = wdoc . icc_profile . as_deref (). map ( | v | v . as_slice ());
let xmp = web_xmp ( & doc , st . metadata );
2026-10-18 23:23:10 +02:00
let exif = web_exif ( & doc , st . metadata );
2026-10-01 22:49:07 -04:00
let dpi = wdoc . resolution_dpi ;
2026-10-18 23:23:10 +02:00
let warnings = web_warnings ( & st , exif , dpi );
2026-10-01 22:49:07 -04:00
let all = slices ::resolve ( & doc );
let pick = p . get ( "slices" ). and_then ( Value ::as_str ). unwrap_or ( "all" );
let numbers : Option < Vec < u64 >> = p . get ( "numbers" ). and_then ( Value ::as_array ). map ( | a | a . iter (). filter_map ( Value ::as_u64 ). collect ());
let chosen : Vec <& ResolvedSlice > = all
. iter ()
. filter ( | r | match ( & numbers , pick ) {
( Some ( n ), _ ) => n . contains ( & ( r . number as u64 )),
( None , "user" ) => r . origin != photocraft_doc ::SliceOrigin ::Auto ,
_ => true ,
})
. collect ();
let single = p . get ( "path" ). and_then ( Value ::as_str ). filter ( | v | ! v . is_empty ());
let dir = p . get ( "dir" ). and_then ( Value ::as_str ). filter ( | v | ! v . is_empty ());
2026-10-11 23:18:52 -06:00
let mut last_web = p . clone ();
// A trusted-markup decision applies to this export only, never to another document opened
// with the dialog's remembered settings.
if let Some ( last ) = last_web . as_object_mut () {
last . remove ( "trustedSliceHtml" );
}
2026-10-01 22:49:07 -04:00
// Estimate only (the dialog's annotations): the whole image, or each chosen slice.
if single . is_none () && dir . is_none () {
2026-10-18 23:23:10 +02:00
let o = optimize ( & buf . px , bw , wdoc . bounds (), & st , icc , exif , xmp . as_deref (), dpi , false ) ? ;
2026-10-13 02:50:00 +02:00
return Ok (
json! ({ "format" : o . ext , "bytes" : o . bytes . len (), "width" : o . width , "height" : o . height , "colors" : o . colors , "colorTable" : o . color_table . iter (). map ( | c | rgb_hex ( * c )). collect ::< Vec < _ >> (), "transparentIndex" : o . transparent_index }),
);
2026-10-01 22:49:07 -04:00
}
if let Some ( path ) = single . filter ( | _ | dir . is_none () && ( all . len () == 1 || p . get ( "slices" ). is_none ())) {
2026-10-18 23:23:10 +02:00
let o = optimize ( & buf . px , bw , wdoc . bounds (), & st , icc , exif , xmp . as_deref (), dpi , false ) ? ;
2026-10-09 14:03:58 -04:00
write ( path , & o . bytes ) ? ;
2026-10-13 09:26:47 +08:00
remember_web_settings ( s , cmd , & last_web );
2026-10-11 23:18:52 -06:00
s . file_menu . last_web = Some ( last_web );
2026-10-01 22:49:07 -04:00
crate ::automate_cmds ::fire_event ( s , "export" );
2026-10-18 23:23:10 +02:00
return Ok ( json! ({ "files" : [ path ], "bytes" : o . bytes . len (), "width" : o . width , "height" : o . height , "colors" : o . colors , "warnings" : warnings }));
2026-10-01 22:49:07 -04:00
}
let dir =
dir . map ( str ::to_string ). or_else ( || single . and_then ( | f | std ::path ::Path ::new ( f ). parent (). map ( | d | d . to_string_lossy (). into_owned ()))). unwrap_or_default ();
let html = p . get ( "html" ). and_then ( Value ::as_bool ). unwrap_or ( false );
2026-10-11 23:18:52 -06:00
let images = web_images_folder ( p . get ( "imagesFolder" ). and_then ( Value ::as_str ). unwrap_or ( "images" ), cmd ) ? ;
let trusted_slice_html = p . get ( "trustedSliceHtml" ). and_then ( Value ::as_bool ). unwrap_or ( false );
let base = portable_generated_stem ( single . map ( stem ). unwrap_or_else ( || slices ::base_name ( & doc )));
2026-10-01 22:49:07 -04:00
let mut files = Vec ::new ();
let mut cells : Vec < ( Rect , String , String ) > = Vec ::new ();
let mut total = 0 usize ;
2026-10-07 23:12:27 -04:00
// Output names are unique (case-insensitively, for macOS and Windows file systems): two
// slices named alike, or names that sanitize alike, must not overwrite each other. The HTML
// page's spacer image is reserved.
let mut used : std ::collections ::HashSet < String > = std ::collections ::HashSet ::new ();
if html {
used . insert ( "spacer.gif" . into ());
}
let mut unique = | stem : & str , ext : & str | {
let mut name = format! ( " {stem} . {ext} " );
let mut k = 2 u32 ;
while ! used . insert ( name . to_lowercase ()) {
name = format! ( " {stem} _ {k} . {ext} " );
k = k . saturating_add ( 1 );
}
name
};
2026-10-19 16:02:32 +02:00
// Slices never overlap here: one covered by later slices comes as its visible pieces (see
// `slices::resolve`), each its own image and cell, as Photoshop's subslices. A no-image cell's
// text goes into its first piece only.
let mut texted = std ::collections ::HashSet ::new ();
2026-10-01 22:49:07 -04:00
for r in & chosen {
2026-10-19 16:02:32 +02:00
let piece = scale_rect ( r . rect , sx , sy ). intersect ( & wdoc . bounds ());
if piece . is_empty () {
2026-10-01 22:49:07 -04:00
continue ;
}
let stored = r . id . and_then ( | id | doc . slices . get ( id ));
2026-10-19 16:02:32 +02:00
if r . kind == SliceKind ::NoImage {
let text = stored
. filter ( | s | texted . insert ( s . id ))
. map ( | s | if trusted_slice_html && s . cell_text_is_html { s . cell_text . clone () } else { html_escape ( & s . cell_text ) })
. unwrap_or_default ();
let bg = stored . and_then ( | s | s . background ). map ( | c | format! ( " bgcolor= \" # {:02X}{:02X}{:02X} \" " , c [ 1 ], c [ 2 ], c [ 3 ])). unwrap_or_default ();
cells . push (( piece , bg , text ));
continue ;
2026-10-07 23:12:27 -04:00
}
2026-10-19 16:02:32 +02:00
let o = optimize ( & buf . px , bw , piece , & st , icc , exif , xmp . as_deref (), dpi , false ) ? ;
let name = unique ( & slice_file ( & r . name ), o . ext );
let rel = if images . is_empty () { name . clone () } else { format! ( " {images} / {name} " ) };
let out = join ( & dir , & rel );
write ( & web_derived_path ( & dir , & rel , native_paths ) ? , & o . bytes ) ? ;
total += o . bytes . len ();
files . push ( out );
let alt = stored . map_or ( String ::new (), | s | html_escape ( & s . alt ));
let img = format! ( " \n\t\t\t <img src= \" {} \" width= \" {} \" height= \" {} \" alt= \" {alt} \" >" , html_escape ( & rel ), piece . width (), piece . height ());
let body = match stored . filter ( | s | safe_slice_url ( & s . url )) {
Some ( s ) => {
let target = if s . target . is_empty () { String ::new () } else { format! ( " target= \" {} \" " , html_escape ( & s . target )) };
format! ( " \n\t\t\t <a href= \" {} \" {target} > {img} </a>" , html_escape ( & s . url ))
}
None => img ,
};
cells . push (( piece , String ::new (), body ));
2026-10-01 22:49:07 -04:00
}
let mut html_path = None ;
if html {
let grid = cells . len () > 1 ;
let spacer = grid . then ( || if images . is_empty () { "spacer.gif" . to_string () } else { format! ( " {images} /spacer.gif" ) });
if let Some ( sp ) = & spacer {
let gif = photocraft_codecs ::web ::encode_gif_indexed ( 1 , 1 , & [ 0 ], & [[ 0 , 0 , 0 ]], Some ( 0 ), false ). map_err ( other ) ? ;
2026-10-11 23:18:52 -06:00
write ( & web_derived_path ( & dir , sp , native_paths ) ? , & gif ) ? ;
2026-10-01 22:49:07 -04:00
}
let page = html_table ( & base , wdoc . size . width , wdoc . size . height , & cells , spacer . as_deref ());
let hp = join ( & dir , & format! ( " {base} .html" ));
2026-10-11 23:18:52 -06:00
write ( & web_derived_path ( & dir , & format! ( " {base} .html" ), native_paths ) ? , page . as_bytes ()) ? ;
2026-10-01 22:49:07 -04:00
html_path = Some ( hp );
}
2026-10-13 09:26:47 +08:00
remember_web_settings ( s , cmd , & last_web );
2026-10-11 23:18:52 -06:00
s . file_menu . last_web = Some ( last_web );
2026-10-01 22:49:07 -04:00
crate ::automate_cmds ::fire_event ( s , "export" );
2026-10-18 23:23:10 +02:00
Ok (
json! ({ "files" : files , "html" : html_path , "bytes" : total , "slices" : chosen . len (), "width" : wdoc . size . width , "height" : wdoc . size . height , "warnings" : warnings }),
)
2026-10-01 22:49:07 -04:00
}
// ---------- Export Preferences / Quick Export ----------
fn export_preferences ( s : & mut Session , p : & Value ) -> Result < Value > {
let cmd = "file.export.exportPreferences" ;
let mut values = serde_json ::Map ::new ();
for ( k , key ) in [
( "quickExportFormat" , "export.quickExportFormat" ),
( "quickExportLocation" , "export.quickExportLocation" ),
( "jpegQuality" , "export.jpegQuality" ),
2026-10-09 01:57:01 +07:00
( "webpLossless" , "export.webpLossless" ),
( "webpQuality" , "export.webpQuality" ),
2026-10-01 22:49:07 -04:00
( "metadata" , "export.metadata" ),
( "convertToSrgb" , "export.convertToSrgb" ),
] {
if let Some ( v ) = p . get ( k ) {
values . insert ( key . into (), v . clone ());
}
}
if ! values . is_empty () {
s . execute ( "prefs.set" , json! ({ "values" : values })). map_err ( | e | bad ( cmd , e . to_string ())) ? ;
}
Ok ( json! ({ "section" : "export" , "values" : s . prefs (). get ( "export" ). unwrap_or ( Value ::Null )}))
}
2026-10-17 12:53:10 -05:00
/// The Quick Export format's extension (Export Preferences › Format).
fn quick_export_ext ( s : & Session ) -> String {
serde_json ::to_value ( s . prefs (). export . quick_export_format ). ok (). and_then ( | v | v . as_str (). map ( str ::to_string )). unwrap_or_else ( || "png" . into ())
}
/// Where Quick Export writes the active document with no `path` given: `<name>.<format>` beside
/// its file, when Export Preferences › Location is Same Folder and the document has a file.
pub fn quick_export_target ( s : & Session ) -> Option < String > {
let same = s . prefs (). export . quick_export_location == crate ::prefs ::ExportLocation ::SameFolder ;
let dp = s . active () ? . path . as_deref (). filter ( | _ | same ) ? ;
let dir = std ::path ::Path ::new ( dp ). parent (). map ( | d | d . to_string_lossy (). into_owned ()). unwrap_or_default ();
Some ( join ( & dir , & format! ( " {} . {} " , stem ( dp ), quick_export_ext ( s ))))
}
/// Whether [`quick_export_target`] is the active document's own file (a document opened from
/// `photo.png` and exported as PNG), which Quick Export must not replace without asking (#6599).
pub fn quick_export_target_is_own_file ( s : & Session ) -> bool {
let own = s . active (). and_then ( | d | d . path . as_deref ());
quick_export_target ( s ). zip ( own ). is_some_and ( | ( target , own ) | crate ::file_cmds ::same_file ( & target , own ))
}
2026-10-01 22:49:07 -04:00
/// File › Export › Quick Export as <format>: the format, quality, metadata, colour space and
/// location from Export Preferences. `path` overrides the location ("ask" needs one).
fn quick_export ( s : & mut Session , p : & Value ) -> Result < Value > {
let cmd = "file.export.quickExport" ;
let prefs = s . prefs (). export . clone ();
2026-10-17 12:53:10 -05:00
let fmt = quick_export_ext ( s );
2026-10-01 22:49:07 -04:00
let path = match p . get ( "path" ). and_then ( Value ::as_str ). filter ( | v | ! v . is_empty ()) {
Some ( v ) => v . to_string (),
None => {
2026-10-17 12:53:10 -05:00
let Some ( target ) = quick_export_target ( s ) else {
return Err ( bad ( cmd , "missing `path` (Export Preferences › Location is \" ask \" , or the document was never saved)" ));
};
// The document's own file is never replaced by an export the user didn't name (#6599).
if quick_export_target_is_own_file ( s ) {
return Err ( bad ( cmd , format! ( "the export would replace the document's own file {target} ; give a `path`" )));
2026-10-01 22:49:07 -04:00
}
2026-10-17 12:53:10 -05:00
target
2026-10-01 22:49:07 -04:00
}
};
2026-10-22 14:28:42 +01:00
// The name typed in the save dialog wins over Export Preferences' format: Quick Export as PNG
// renamed to `photo.jpeg` must write a JPEG, not PNG bytes upload checks refuse (#8519).
let fmt = match crate ::file_cmds ::extension ( & path ). as_deref () {
None => fmt ,
Some ( "jpg" | "jpeg" ) => "jpg" . into (),
2026-10-22 14:04:21 +00:00
Some ( e @ ( "png" | "gif" | "webp" | "exr" )) => e . into (),
Some ( e ) => return Err ( bad ( cmd , format! ( "Quick Export writes PNG, JPEG, GIF, WebP or EXR, not . {e} ; use Export As" ))),
2026-10-22 14:28:42 +01:00
};
2026-10-17 12:53:10 -05:00
let d = s . active (). ok_or ( EngineError ::NoDocument ) ? ;
let doc = d . doc . clone ();
2026-10-21 09:50:50 +08:00
if fmt == "exr" {
// EXR keeps floating-point scene values. The ordinary exporter converts to linear
// light; the web/sRGB pipeline would instead clamp HDR and negative values.
let opts = photocraft_io ::ExportOptions { xmp : photocraft_io ::XmpEmbed ::None , .. Default ::default () };
let out = photocraft_io ::export ( & doc , "exr" , & opts ). map_err ( other ) ? ;
write_file ( & path , & out . bytes ) ? ;
crate ::automate_cmds ::fire_event ( s , "export" );
2026-10-21 15:05:33 +08:00
return Ok ( json! ({ "path" : path , "format" : fmt , "bytes" : out . bytes . len (), "warnings" : out . warnings }));
2026-10-21 09:50:50 +08:00
}
2026-10-01 22:49:07 -04:00
let metadata = serde_json ::to_value ( prefs . metadata ). ok (). and_then ( | v | v . as_str (). map ( str ::to_string )). unwrap_or_default ();
2026-10-04 13:26:26 -04:00
let mut o = serde_json ::Map ::new ();
o . insert (
"metadata" . into (),
json! ( if metadata == "all" {
"all"
} else if metadata == "none" {
"none"
} else {
"copyright"
}),
);
o . insert ( "convertToSrgb" . into (), json! ( prefs . convert_to_srgb ));
o . insert ( "transparency" . into (), json! ( true ));
2026-10-13 05:52:16 +02:00
o . insert ( "matte" . into (), json! ( "#ffffff" ));
2026-10-01 22:49:07 -04:00
match fmt . as_str () {
"jpg" => {
o . insert ( "format" . into (), json! ( "jpeg" ));
o . insert ( "quality" . into (), json! ( prefs . jpeg_quality . min ( 100 )));
}
"gif" => {
o . insert ( "format" . into (), json! ( "gif" ));
}
"webp" => {
2026-10-09 01:57:01 +07:00
// WebP uses the regular exporter. Its optional quality is in the Photoshop-style
// 0– 12 save scale: invert that mapping so the codec receives the chosen 1– 100.
// Keeping quality absent preserves the historical lossless Quick Export behavior.
2026-10-01 22:49:07 -04:00
let ( wdoc , _ , _ ) = web_document ( & doc , & json! ({}), & WebSettings { convert_to_srgb : prefs . convert_to_srgb , .. Default ::default () }) ? ;
2026-10-09 01:57:01 +07:00
let quality = if prefs . webp_lossless { None } else { Some (( f64 ::from ( prefs . webp_quality . clamp ( 1 , 100 )) - 1.0 ) * 12.0 / 99.0 ) };
let warnings = crate ::file_cmds ::save_doc ( & wdoc , & path , quality ) ? ;
2026-10-01 22:49:07 -04:00
crate ::automate_cmds ::fire_event ( s , "export" );
return Ok ( json! ({ "path" : path , "format" : fmt , "warnings" : warnings }));
}
_ => {
o . insert ( "format" . into (), json! ( "png24" ));
}
}
2026-10-04 13:26:26 -04:00
let q = Value ::Object ( o );
2026-10-01 22:49:07 -04:00
let st = WebSettings ::from_params ( & q , cmd ) ? ;
let ( wdoc , _ , _ ) = web_document ( & doc , & json! ({}), & st ) ? ;
let buf = photocraft_compose ::flatten ( & wdoc );
let xmp = web_xmp ( & doc , st . metadata );
2026-10-18 23:23:10 +02:00
let exif = web_exif ( & doc , st . metadata );
2026-10-01 22:49:07 -04:00
let opt = optimize (
& buf . px ,
wdoc . size . width as usize ,
wdoc . bounds (),
& st ,
wdoc . icc_profile . as_deref (). map ( | v | v . as_slice ()),
2026-10-18 23:23:10 +02:00
exif ,
2026-10-01 22:49:07 -04:00
xmp . as_deref (),
wdoc . resolution_dpi ,
false ,
) ? ;
write_file ( & path , & opt . bytes ) ? ;
crate ::automate_cmds ::fire_event ( s , "export" );
2026-10-18 23:23:10 +02:00
Ok ( json! ({ "path" : path , "format" : fmt , "bytes" : opt . bytes . len (), "warnings" : web_warnings ( & st , exif , wdoc . resolution_dpi )}))
2026-10-01 22:49:07 -04:00
}
// ---------- Generate › Image Assets ----------
/// One asset requested by a layer name.
#[derive(Clone, Debug, PartialEq)]
pub struct AssetSpec {
/// Relative output path (folders allowed), extension included.
pub file : String ,
/// png8 | png24 | png32 | jpg | gif | webp
pub format : String ,
/// JPEG quality 1..=100.
pub quality : Option < u8 > ,
/// Uniform scale (1.0 = 100 %).
pub scale : Option < f64 > ,
/// Target width / height in px (`?` = keep aspect).
pub width : Option < f64 > ,
pub height : Option < f64 > ,
}
/// A `default` layer variant: size plus a folder (`low/`) or name suffix (`@2x`).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct AssetDefault {
pub scale : Option < f64 > ,
pub width : Option < f64 > ,
pub height : Option < f64 > ,
pub folder : String ,
pub suffix : String ,
}
fn unit_px ( tok : & str , dpi : f64 ) -> Option < Option < f64 >> {
if tok == "?" {
return Some ( None );
}
let ( num , k ) = if let Some ( n ) = tok . strip_suffix ( "px" ) {
( n , 1.0 )
} else if let Some ( n ) = tok . strip_suffix ( "in" ) {
( n , dpi )
} else if let Some ( n ) = tok . strip_suffix ( "cm" ) {
( n , dpi / 2.54 )
} else if let Some ( n ) = tok . strip_suffix ( "mm" ) {
( n , dpi / 25.4 )
} else {
( tok , 1.0 )
};
num . trim (). parse ::< f64 > (). ok (). filter ( | v | * v > 0.0 ). map ( | v | Some ( v * k ))
}
/// `200%` or `WxH` (units px/in/cm/mm, `?` wildcard) at the start of `item`; returns the size and
/// the rest.
fn parse_size ( item : & str , dpi : f64 ) -> ( Option < f64 > , Option < f64 > , Option < f64 > , & str ) {
let item = item . trim ();
let ( first , rest ) = item . split_once ( char ::is_whitespace ). map_or (( item , "" ), | ( a , b ) | ( a , b . trim ()));
if let Some ( pc ) = first . strip_suffix ( '%' ). and_then ( | n | n . parse ::< f64 > (). ok ()). filter ( | v | * v > 0.0 ) {
return ( Some ( pc / 100.0 ), None , None , rest );
}
// "100x50", "2in x 1in", "?x100"
if let Some (( w , h )) = first . split_once ([ 'x' , 'X' ]). filter ( | ( w , h ) | ! w . is_empty () && ! h . is_empty ())
&& let ( Some ( w ), Some ( h )) = ( unit_px ( w , dpi ), unit_px ( h , dpi ))
{
return ( None , w , h , rest );
}
// Spaced form "2in x 1in name.png": three tokens.
let toks : Vec <& str > = item . split_whitespace (). collect ();
if toks . len () >= 4
&& toks [ 1 ]. eq_ignore_ascii_case ( "x" )
&& let ( Some ( w ), Some ( h )) = ( unit_px ( toks [ 0 ], dpi ), unit_px ( toks [ 2 ], dpi ))
{
2026-10-18 09:07:45 +02:00
// Skip exactly the three size tokens; searching for the name's first word could match inside the size.
let mut rest = item ;
for _ in 0 .. 3 {
rest = rest . trim_start (). trim_start_matches ( | c : char | ! c . is_whitespace ());
}
return ( None , w , h , rest . trim_start ());
2026-10-01 22:49:07 -04:00
}
( None , None , None , item )
}
/// Parses a layer name into its asset specs (empty when it names no asset).
pub fn parse_asset_name ( name : & str , dpi : f64 ) -> Vec < AssetSpec > {
let mut out = Vec ::new ();
for item in name . split ([ ',' , '+' ]) {
let ( scale , width , height , rest ) = parse_size ( item , dpi );
let file = rest . trim ();
let Some (( stem_part , ext )) = file . rsplit_once ( '.' ) else { continue };
if stem_part . is_empty () {
continue ;
}
let ext_l = ext . to_ascii_lowercase ();
let ( format , quality , out_ext ) = if let Some ( q ) = ext_l . strip_prefix ( "jpeg" ). or_else ( || ext_l . strip_prefix ( "jpg" )) {
let q = q . trim ();
let quality = if q . is_empty () {
None
} else if let Some ( pc ) = q . strip_suffix ( '%' ) {
pc . parse ::< u32 > (). ok (). map ( | v | v . clamp ( 1 , 100 ) as u8 )
} else {
// 1– 10 scale.
q . parse ::< u32 > (). ok (). map ( | v | ( v . clamp ( 1 , 10 ) * 10 ) as u8 )
};
if ! q . is_empty () && quality . is_none () {
continue ;
}
( "jpg" . to_string (), quality , if ext_l . starts_with ( "jpeg" ) { "jpeg" } else { "jpg" })
} else {
match ext_l . as_str () {
"png" | "png32" => ( "png32" . to_string (), None , "png" ),
"png24" => ( "png24" . to_string (), None , "png" ),
"png8" => ( "png8" . to_string (), None , "png" ),
"gif" => ( "gif" . to_string (), None , "gif" ),
"webp" => ( "webp" . to_string (), None , "webp" ),
_ => continue ,
}
};
out . push ( AssetSpec { file : format ! ( "{stem_part}.{out_ext}" ), format , quality , scale , width , height });
}
out
}
/// Parses a `default …` layer name into its variants.
pub fn parse_defaults ( name : & str , dpi : f64 ) -> Option < Vec < AssetDefault >> {
let rest = name . trim (). strip_prefix ( "default" ) ? ;
if ! rest . is_empty () && ! rest . starts_with ( char ::is_whitespace ) {
return None ;
}
let mut v = Vec ::new ();
for item in rest . split ([ ',' , '+' ]) {
let ( scale , width , height , tail ) = parse_size ( item , dpi );
let tail = tail . trim ();
let ( folder , suffix ) = match tail . rfind ( '/' ) {
Some ( i ) => ( tail [ ..= i ]. to_string (), tail [ i + 1 .. ]. to_string ()),
None => ( String ::new (), tail . to_string ()),
};
if scale . is_none () && width . is_none () && height . is_none () && folder . is_empty () && suffix . is_empty () {
continue ;
}
v . push ( AssetDefault { scale , width , height , folder , suffix });
}
Some ( v )
}
fn asset_settings ( spec : & AssetSpec ) -> WebSettings {
let base = WebSettings { transparency : true , matte : None , metadata : WebMetadata ::None , .. WebSettings ::default () };
match spec . format . as_str () {
"jpg" => WebSettings { format : WebFormat ::Jpeg , quality : spec . quality . unwrap_or ( 90 ), matte : Some ([ 1.0 , 1.0 , 1.0 ]), .. base },
"png8" => WebSettings { format : WebFormat ::Png8 , dither : Dither ::None , .. base },
"png24" => WebSettings { format : WebFormat ::Png24 , transparency : false , matte : Some ([ 1.0 , 1.0 , 1.0 ]), .. base },
"gif" => WebSettings { format : WebFormat ::Gif , dither : Dither ::None , .. base },
_ => WebSettings { format : WebFormat ::Png24 , .. base },
}
}
2026-10-11 23:18:52 -06:00
/// Layer names and `default` folders are document data, so reject unsafe output components.
fn asset_path_components ( relative : & str ) -> Result < Vec <& str >> {
// Split both separator styles even on Unix, where a backslash would otherwise be a legal
// filename. This also catches Windows drive, rooted, and UNC paths on every platform.
let mut parts = Vec ::new ();
for part in relative . split ([ '/' , '\\' ]) {
if part . is_empty ()
|| part == "."
|| part == ".."
|| part . ends_with ( '.' )
|| part . ends_with ( ' ' )
|| part . contains ( ':' )
|| part . contains ( '\0' )
2026-10-21 01:02:05 -05:00
|| is_windows_device_name ( part )
2026-10-11 23:18:52 -06:00
{
return Err ( other ( format! ( "asset path {relative:?} has an unsafe component {part:?} " )));
}
parts . push ( part );
}
Ok ( parts )
}
/// Resolve a generated asset below the selected output directory. The atomic writer follows an
/// existing file symlink, so reject it and directory symlinks before writing.
fn asset_output_path ( dir : & str , relative : & str ) -> Result < ( String , String ) > {
if dir . is_empty () {
return Err ( other ( "image assets need an output directory" ));
}
let mut parts = asset_path_components ( relative ) ? ;
// Keep the caller's spelling in the result (for example /var on macOS), while writes use
// the canonical directory so a symlink in the selected root cannot move the destination.
let mut reported = std ::path ::PathBuf ::from ( dir );
for part in & parts {
reported . push ( part );
}
let file = parts . pop (). ok_or_else ( || other ( format! ( "asset path {relative:?} is empty" ))) ? ;
let chosen_root : std ::path ::PathBuf = std ::path ::Path ::new ( dir ). components (). collect ();
std ::fs ::create_dir_all ( & chosen_root ). map_err ( | e | other ( format! ( "asset output directory {dir:?} : {e} " ))) ? ;
// The caller chose this root, so a symlink to an existing directory is intentional. All
// document-derived components below it are checked against its canonical target.
let root = std ::fs ::canonicalize ( & chosen_root ). map_err ( | e | other ( format! ( "asset output directory {dir:?} : {e} " ))) ? ;
let mut parent = root . clone ();
for part in parts {
parent . push ( part );
match std ::fs ::symlink_metadata ( & parent ) {
Ok ( meta ) if meta . file_type (). is_symlink () => {
return Err ( other ( format! ( "asset path {relative:?} crosses a symlink at {} " , parent . display ())));
}
Ok ( meta ) if ! meta . is_dir () => {
return Err ( other ( format! ( "asset path {relative:?} has a non-directory at {} " , parent . display ())));
}
Ok ( _ ) => {}
Err ( e ) if e . kind () == std ::io ::ErrorKind ::NotFound => {
std ::fs ::create_dir ( & parent ). map_err ( | e | other ( format! ( "asset folder {} : {e} " , parent . display ()))) ? ;
}
Err ( e ) => return Err ( other ( format! ( "asset folder {} : {e} " , parent . display ()))),
}
let actual = std ::fs ::canonicalize ( & parent ). map_err ( | e | other ( format! ( "asset folder {} : {e} " , parent . display ()))) ? ;
if ! actual . starts_with ( & root ) {
return Err ( other ( format! ( "asset path {relative:?} escapes output directory {dir:?} " )));
}
parent = actual ;
}
let parent = std ::fs ::canonicalize ( & parent ). map_err ( | e | other ( format! ( "asset folder {} : {e} " , parent . display ()))) ? ;
if ! parent . starts_with ( & root ) {
return Err ( other ( format! ( "asset path {relative:?} escapes output directory {dir:?} " )));
}
let output = parent . join ( file );
match std ::fs ::symlink_metadata ( & output ) {
Ok ( meta ) if meta . file_type (). is_symlink () => {
return Err ( other ( format! ( "asset path {relative:?} points to a symlink" )));
}
Ok ( meta ) if ! meta . is_file () => {
return Err ( other ( format! ( "asset path {relative:?} is not a file" )));
}
Ok ( _ ) => {}
Err ( e ) if e . kind () == std ::io ::ErrorKind ::NotFound => {}
Err ( e ) => return Err ( other ( format! ( "asset path {relative:?} : {e} " ))),
}
Ok (( output . to_string_lossy (). into_owned (), reported . to_string_lossy (). into_owned ()))
}
2026-10-01 22:49:07 -04:00
/// Exports every asset named by `doc`'s layers into `dir`. Returns (files, errors).
2026-10-17 12:55:54 -05:00
///
/// Each file is written once per run (#6614): when two layers (or two default variants) name the
/// same file, the one higher in the Layers panel keeps it and the other is reported in `errors`.
/// Names are compared ignoring case, as `Logo.png` and `logo.png` are one file on Windows and
/// macOS.
2026-10-01 22:49:07 -04:00
pub fn generate_assets ( doc : & Document , dir : & str ) -> ( Vec < String > , Vec < Value > ) {
let dpi = f64 ::from ( doc . resolution_dpi . max ( 1.0 ));
let mut defaults : Option < Vec < AssetDefault >> = None ;
2026-10-17 12:55:54 -05:00
let mut by_layer : Vec < Vec < ( LayerId , AssetSpec ) >> = Vec ::new ();
2026-10-01 22:49:07 -04:00
for ( _ , _ , l ) in doc . walk () {
if let Some ( d ) = parse_defaults ( & l . name , dpi ) {
defaults = Some ( d );
continue ;
}
if matches! ( l . content , LayerContent ::Adjustment ( _ )) {
continue ;
}
2026-10-17 12:55:54 -05:00
by_layer . push ( parse_asset_name ( & l . name , dpi ). into_iter (). map ( | spec | ( l . id , spec )). collect ());
2026-10-01 22:49:07 -04:00
}
2026-10-17 12:55:54 -05:00
// Top layer first, as the Layers panel lists them (`walk` goes bottom to top).
let jobs : Vec < ( LayerId , AssetSpec ) > = by_layer . into_iter (). rev (). flatten (). collect ();
2026-10-01 22:49:07 -04:00
let variants : Vec < AssetDefault > = match defaults {
Some ( d ) if ! d . is_empty () => d ,
_ => vec! [ AssetDefault ::default ()],
};
let mut files = Vec ::new ();
let mut errors = Vec ::new ();
2026-10-17 12:55:54 -05:00
let mut written = std ::collections ::HashSet ::new ();
2026-10-01 22:49:07 -04:00
for ( id , spec ) in & jobs {
for v in & variants {
let r = ( || -> Result < String > {
2026-10-11 23:18:52 -06:00
let ( name_stem , ext ) = spec . file . rsplit_once ( '.' ). unwrap_or (( spec . file . as_str (), "png" ));
let rel = format! ( " {}{}{} . {ext} " , v . folder , name_stem , v . suffix );
2026-10-17 12:55:54 -05:00
let key = asset_path_components ( & rel ) ? . join ( "/" ). to_lowercase ();
if written . contains ( & key ) {
return Err ( other ( format! ( " {rel} : another layer already writes this file; rename one of the layers" )));
}
2026-10-01 22:49:07 -04:00
let mut one = crate ::layer_menu_cmds ::layer_document ( doc , * id ) ? ;
one . metadata = Default ::default ();
let ( w0 , h0 ) = ( f64 ::from ( one . size . width ), f64 ::from ( one . size . height ));
// The layer's own size spec wins; default variants scale on top of it.
let mut k = spec . scale . unwrap_or ( 1.0 );
let ( mut tw , mut th ) = ( w0 * k , h0 * k );
match ( spec . width , spec . height ) {
( Some ( w ), Some ( h )) => ( tw , th ) = ( w , h ),
( Some ( w ), None ) => ( tw , th ) = ( w , h0 * w / w0 ),
( None , Some ( h )) => ( tw , th ) = ( w0 * h / h0 , h ),
_ => {}
}
k = v . scale . unwrap_or ( 1.0 );
tw *= k ;
th *= k ;
match ( v . width , v . height ) {
( Some ( w ), Some ( h )) => ( tw , th ) = ( w , h ),
( Some ( w ), None ) => ( tw , th ) = ( w , th * w / tw ),
( None , Some ( h )) => ( tw , th ) = ( tw * h / th , h ),
_ => {}
}
let st = asset_settings ( spec );
let p = json! ({ "width" : tw . round (). max ( 1.0 ), "height" : th . round (). max ( 1.0 )});
let ( wdoc , _ , _ ) = web_document ( & one , & p , & WebSettings { convert_to_srgb : true , .. st . clone () }) ? ;
if spec . format == "webp" {
2026-10-11 23:18:52 -06:00
let ( out , reported ) = asset_output_path ( dir , & rel ) ? ;
2026-10-01 22:49:07 -04:00
crate ::file_cmds ::save_doc ( & wdoc , & out , None ) ? ;
2026-10-17 12:55:54 -05:00
written . insert ( key );
2026-10-11 23:18:52 -06:00
return Ok ( reported );
2026-10-01 22:49:07 -04:00
}
let buf = photocraft_compose ::flatten ( & wdoc );
2026-10-18 23:23:10 +02:00
let o = optimize ( & buf . px , wdoc . size . width as usize , wdoc . bounds (), & st , None , None , None , wdoc . resolution_dpi , false ) ? ;
2026-10-11 23:18:52 -06:00
let ( out , reported ) = asset_output_path ( dir , & rel ) ? ;
2026-10-01 22:49:07 -04:00
write_file ( & out , & o . bytes ) ? ;
2026-10-17 12:55:54 -05:00
written . insert ( key );
2026-10-11 23:18:52 -06:00
Ok ( reported )
2026-10-01 22:49:07 -04:00
})();
match r {
Ok ( f ) => files . push ( f ),
Err ( e ) => errors . push ( json! ({ "asset" : spec . file , "error" : e . to_string ()})),
}
}
}
( files , errors )
}
/// `<document folder>/<document name>-assets`.
pub fn assets_dir ( doc_path : & str ) -> String {
let p = std ::path ::Path ::new ( doc_path );
let dir = p . parent (). map ( | d | d . to_string_lossy (). into_owned ()). unwrap_or_default ();
join ( & dir , & format! ( " {} -assets" , stem ( doc_path )))
}
fn image_assets ( s : & mut Session , p : & Value ) -> Result < Value > {
let d = s . active (). ok_or ( EngineError ::NoDocument ) ? ;
let id : DocId = d . doc . id ;
let was = s . file_menu . image_assets . contains ( & id );
let on = p . get ( "on" ). and_then ( Value ::as_bool ). unwrap_or ( ! was );
if on {
if ! was {
s . file_menu . image_assets . push ( id );
}
} else {
s . file_menu . image_assets . retain ( | x | * x != id );
}
let d = s . active (). ok_or ( EngineError ::NoDocument ) ? ;
let dir = p . get ( "dir" ). and_then ( Value ::as_str ). map ( str ::to_string ). or_else ( || d . path . as_deref (). map ( assets_dir ));
if ! on {
return Ok ( json! ({ "enabled" : false }));
}
match dir {
Some ( dir ) if ! cfg! ( target_arch = "wasm32" ) => {
let ( files , errors ) = generate_assets ( & d . doc , & dir );
Ok ( json! ({ "enabled" : true , "dir" : dir , "files" : files , "errors" : errors }))
}
_ => Ok ( json! ({ "enabled" : true , "dir" : null , "files" : [], "note" : "assets are written when the document is saved" })),
}
}
/// After a save: regenerate the document's image assets when Generate › Image Assets is on.
pub fn on_saved ( s : & Session , index : usize ) -> Option < Value > {
let d = s . documents (). get ( index ) ? ;
if ! s . file_menu . image_assets . contains ( & d . doc . id ) || cfg! ( target_arch = "wasm32" ) {
return None ;
}
let dir = assets_dir ( d . path . as_deref () ? );
let ( files , errors ) = generate_assets ( & d . doc , & dir );
Some ( json! ({ "dir" : dir , "files" : files , "errors" : errors }))
}
pub fn specs () -> Vec < CommandSpec > {
macro_rules ! spec {
( $id :expr , $label :expr , $menu :expr , $sc :expr , $params :expr , $enabled :expr , $run :expr ) => {
CommandSpec { id : $id , label : $label , menu : $menu , shortcut : $sc , params : $params , enabled : $enabled , journal : true , run : $run }
};
}
fn has_doc ( s : & Session ) -> std ::result ::Result < (), String > {
s . active (). map ( | _ | ()). ok_or_else ( || "no document open" . into ())
}
vec! [
spec! (
"file.export.saveForWebLegacy" ,
"Save for Web (Legacy)…" ,
& [ "File" , "Export" ],
Some ( "Cmd+Alt+Shift+S" ),
2026-10-18 23:23:10 +02:00
r ##"{"preset":"GIF 128 Dithered|JPEG High|PNG-24|…"?,"format":"gif|png8|png24|jpeg|wbmp"="png24","palette":"perceptual|selective|adaptive|restrictive|exact|systemMac|systemWindows|uniform"="selective","colors":2..256=256,"colorTable":["#rrggbb",…]? (fixed opaque source palette, 1– 256 entries; 255 with transparency),"colorTableEdits":{"0":"#rrggbb",…}? (replace entries after indexing; requires colorTable),"dither":"none|diffusion|pattern|noise"="diffusion","ditherAmount":0..100=88,"transparency":bool=true,"matte":"#rrggbb|none"="none" (a preset's own: white; formats without transparency go over white),"interlaced":bool=false,"webSnap":0..100=0,"quality":0..100=60,"progressive":bool=false,"optimized":bool=true,"embedIcc":bool=false,"metadata":"none|copyright|copyrightAndContact|allExceptCameraInfo|all"="copyright" (EXIF only with all),"convertToSrgb":bool=true,"width"|"height"|"percent"? (image size),"resample":"bicubic|bilinear|nearest"?,"path":file? (whole image),"dir":folder? (one file per slice in images/),"imagesFolder":relative-folder?="images","html":bool=false,"trustedSliceHtml":bool=false (only for trusted documents; emit raw no-image cell HTML),"slices":"all|user"="all","numbers":[n]?} → no path/dir: {bytes,width,height,colors,colorTable,transparentIndex} estimate; else {files, html, bytes, warnings}"## ,
2026-10-01 22:49:07 -04:00
has_doc ,
save_for_web
),
spec! (
"file.export.exportPreferences" ,
"Export Preferences…" ,
& [ "File" , "Export" ],
None ,
2026-10-21 12:03:09 +08:00
r ##"{"quickExportFormat":"png|jpg|gif|webp|exr"?,"quickExportLocation":"ask|sameFolder"?,"jpegQuality":1..100?,"webpLossless":bool?,"webpQuality":1..100?,"metadata":"none|copyright|all"?,"convertToSrgb":bool?} → {values}"## ,
2026-10-01 22:49:07 -04:00
| _ | Ok (()),
export_preferences
),
spec! (
"file.export.quickExport" ,
"Quick Export" ,
& [],
None ,
2026-10-18 23:23:10 +02:00
r ##"{"path":str? (required unless Export Preferences › Location is "sameFolder" and the document is saved)} → {path, format, bytes, warnings}"## ,
2026-10-01 22:49:07 -04:00
native_doc ,
quick_export
),
spec! (
"file.generate.imageAssets" ,
"Image Assets" ,
& [ "File" , "Generate" ],
None ,
r ##"{"on":bool? (default: toggle),"dir":folder? (default <document>-assets next to the file)} → {enabled, files, errors}; layers named like "foo.png", "200% foo@2x.png", "48x48 icons/a.png8", "photo.jpg80%" are exported, now and after each save"## ,
has_doc ,
image_assets
),
]
}
#[cfg(test)]
#[path = "web_cmds/tests.rs" ]
mod tests ;