@@ -15,8 +15,8 @@ import (
1515 "html/template"
1616 "image"
1717 "image/color"
18- _ "image/gif"
19- _ "image/jpeg"
18+ "image/gif"
19+ "image/jpeg"
2020 "image/png"
2121 "io"
2222 "math"
@@ -528,12 +528,13 @@ func renderCaptureHelp(color bool) string {
528528func renderConvertHelp (color bool ) string {
529529 style := helpStyler {color : color }
530530 var b strings.Builder
531- writeHelpHeader (& b , style , "jot convert" , "Convert a local image to `.ico` or `.svg` right from the terminal." )
531+ writeHelpHeader (& b , style , "jot convert" , "Convert a local image into web-ready image formats right from the terminal." )
532532 writeUsageSection (& b , style , []string {
533- "jot convert <image-path> <ico|svg>" ,
534- "jot convert <image-path> <ico|svg > --out <output-path>" ,
533+ "jot convert <image-path> <png|jpg|jpeg|gif| ico|svg>" ,
534+ "jot convert <image-path> <format > --out <output-path>" ,
535535 }, []string {
536- "`.ico` output builds a multi-size favicon-style icon and saves it next to the source image by default." ,
536+ "`.png`, `.jpg`, and `.gif` output re-encode the source image into that target format and save it next to the source image by default." ,
537+ "`.ico` output builds a multi-size favicon-style icon automatically." ,
537538 "Raster-to-`.svg` output wraps the source image inside a standalone SVG file; jot does not trace vectors yet." ,
538539 "Supported raster inputs today: `.png`, `.jpg`, `.jpeg`, and `.gif`." ,
539540 })
@@ -544,6 +545,7 @@ func renderConvertHelp(color bool) string {
544545 writeExamplesSection (& b , style , []string {
545546 "jot convert logo.png ico" ,
546547 "jot convert logo.png svg" ,
548+ "jot convert screenshot.png jpg" ,
547549 `jot convert ".\assets\brand.jpg" ico --out ".\public\favicon.ico"` ,
548550 })
549551 return b .String ()
@@ -690,6 +692,7 @@ func renderTaskHelp(color bool) string {
690692 "jot task" ,
691693 "jot task convert" ,
692694 "jot convert logo.png ico" ,
695+ "jot convert screenshot.png jpg" ,
693696 })
694697 return b .String ()
695698}
@@ -5452,7 +5455,7 @@ type convertResult struct {
54525455 Warning string
54535456}
54545457
5455- var convertOutputFormats = []string {"ico" , "svg" }
5458+ var convertOutputFormats = []string {"ico" , "svg" , "png" , "jpg" , "gif" }
54565459
54575460func isSupportedConvertTargetFormat (format string ) bool {
54585461 for _ , candidate := range convertOutputFormats {
@@ -5465,7 +5468,12 @@ func isSupportedConvertTargetFormat(format string) bool {
54655468
54665469func canonicalConvertFormat (format string ) string {
54675470 format = strings .ToLower (strings .TrimSpace (format ))
5468- return format
5471+ switch format {
5472+ case "jpeg" :
5473+ return "jpg"
5474+ default :
5475+ return format
5476+ }
54695477}
54705478
54715479func defaultExtensionForConvertFormat (format string ) string {
@@ -5540,7 +5548,7 @@ func parseConvertArgs(args []string) (convertOptions, error) {
55405548 }
55415549
55425550 if len (positional ) != 2 {
5543- return options , fmt .Errorf ("usage: jot convert <image-path> <ico|svg>" )
5551+ return options , fmt .Errorf ("usage: jot convert <image-path> <png|jpg|jpeg|gif| ico|svg>" )
55445552 }
55455553
55465554 options .SourcePath = strings .TrimSpace (positional [0 ])
@@ -5549,7 +5557,7 @@ func parseConvertArgs(args []string) (convertOptions, error) {
55495557 return options , errors .New ("image path must be provided" )
55505558 }
55515559 if ! isSupportedConvertTargetFormat (options .TargetFormat ) {
5552- return options , fmt .Errorf ("unsupported output format %q; use `ico` or `svg`" , positional [1 ])
5560+ return options , fmt .Errorf ("unsupported output format %q; use `png`, `jpg`, `gif`, ` ico`, or `svg`" , positional [1 ])
55535561 }
55545562 return options , nil
55555563}
@@ -5578,7 +5586,7 @@ func jotTask(stdin io.Reader, w io.Writer, args []string, getwd func() string) e
55785586 if _ , err := fmt .Fprint (w , ui .sectionLabel ("tasks" )); err != nil {
55795587 return err
55805588 }
5581- if _ , err := fmt .Fprintln (w , ui .listItem (1 , "convert image" , "Turn . png . jpg . gif into . ico or . svg" , "" )); err != nil {
5589+ if _ , err := fmt .Fprintln (w , ui .listItem (1 , "convert image" , "Turn raster images into png, jpg, gif, ico, or svg" , "" )); err != nil {
55825590 return err
55835591 }
55845592 if _ , err := fmt .Fprintln (w , "" ); err != nil {
@@ -5719,6 +5727,9 @@ func promptTaskFormat(reader *bufio.Reader, w io.Writer, ui termUI) (string, err
57195727 }{
57205728 {key : "ico" , name : ".ico" , desc : "Multi-size favicon (16x16 to 256x256)" },
57215729 {key : "svg" , name : ".svg" , desc : "Embedded SVG wrapper (scalable container)" },
5730+ {key : "png" , name : ".png" , desc : "Lossless raster output with alpha support" },
5731+ {key : "jpg" , name : ".jpg" , desc : "Compressed raster output for photos and screenshots" },
5732+ {key : "gif" , name : ".gif" , desc : "Palette-based raster output for simple graphics" },
57225733 }
57235734 for i , row := range rows {
57245735 if _ , err := fmt .Fprintln (w , ui .listItem (i + 1 , row .name , row .desc , "" )); err != nil {
@@ -5738,6 +5749,12 @@ func promptTaskFormat(reader *bufio.Reader, w io.Writer, ui termUI) (string, err
57385749 return "ico" , nil
57395750 case "2" , "svg" :
57405751 return "svg" , nil
5752+ case "3" , "png" :
5753+ return "png" , nil
5754+ case "4" , "jpg" :
5755+ return "jpg" , nil
5756+ case "5" , "gif" :
5757+ return "gif" , nil
57415758 default :
57425759 return "" , fmt .Errorf ("unknown format %q" , selection )
57435760 }
@@ -5804,6 +5821,8 @@ func convertImageFile(options convertOptions) (convertResult, error) {
58045821 data , err = buildICOFile (sourcePath )
58055822 case "svg" :
58065823 data , warning , err = buildEmbeddedSVG (sourcePath )
5824+ case "png" , "jpg" , "gif" :
5825+ data , warning , err = buildRasterOutputFile (sourcePath , options .TargetFormat )
58075826 default :
58085827 err = fmt .Errorf ("unsupported output format %q" , options .TargetFormat )
58095828 }
@@ -5989,6 +6008,45 @@ func buildEmbeddedSVG(sourcePath string) ([]byte, string, error) {
59896008 return []byte (svg ), warning , nil
59906009}
59916010
6011+ func buildRasterOutputFile (sourcePath string , targetFormat string ) ([]byte , string , error ) {
6012+ if ! isSupportedRasterPath (sourcePath ) {
6013+ return nil , "" , fmt .Errorf ("`%s` is not a supported raster source; use `.png`, `.jpg`, `.jpeg`, or `.gif`" , sourcePath )
6014+ }
6015+
6016+ file , err := os .Open (sourcePath )
6017+ if err != nil {
6018+ return nil , "" , err
6019+ }
6020+ defer file .Close ()
6021+
6022+ src , _ , err := image .Decode (file )
6023+ if err != nil {
6024+ return nil , "" , fmt .Errorf ("could not decode %s as a raster image: %w" , sourcePath , err )
6025+ }
6026+
6027+ var buf bytes.Buffer
6028+ var warning string
6029+ switch targetFormat {
6030+ case "png" :
6031+ err = png .Encode (& buf , src )
6032+ case "jpg" :
6033+ if hasAlpha (src ) {
6034+ src = flattenImageOnBackground (src , color.RGBA {R : 255 , G : 255 , B : 255 , A : 255 })
6035+ warning = "transparent pixels were flattened onto a white background for JPG output."
6036+ }
6037+ err = jpeg .Encode (& buf , src , & jpeg.Options {Quality : 92 })
6038+ case "gif" :
6039+ err = gif .Encode (& buf , src , & gif.Options {NumColors : 256 })
6040+ warning = "GIF output is single-frame and palette-limited; gradients and photos may lose detail."
6041+ default :
6042+ return nil , "" , fmt .Errorf ("unsupported raster output format %q" , targetFormat )
6043+ }
6044+ if err != nil {
6045+ return nil , "" , err
6046+ }
6047+ return buf .Bytes (), warning , nil
6048+ }
6049+
59926050// svgEscapeTitle escapes characters that are not valid in an SVG <title> text node.
59936051func svgEscapeTitle (s string ) string {
59946052 s = strings .ReplaceAll (s , "&" , "&" )
@@ -6017,6 +6075,35 @@ func rasterMIMEType(path string) string {
60176075 }
60186076}
60196077
6078+ func hasAlpha (img image.Image ) bool {
6079+ bounds := img .Bounds ()
6080+ for y := bounds .Min .Y ; y < bounds .Max .Y ; y ++ {
6081+ for x := bounds .Min .X ; x < bounds .Max .X ; x ++ {
6082+ _ , _ , _ , a := img .At (x , y ).RGBA ()
6083+ if a != 0xffff {
6084+ return true
6085+ }
6086+ }
6087+ }
6088+ return false
6089+ }
6090+
6091+ func flattenImageOnBackground (src image.Image , bg color.RGBA ) * image.RGBA {
6092+ bounds := src .Bounds ()
6093+ dst := image .NewRGBA (bounds )
6094+ for y := bounds .Min .Y ; y < bounds .Max .Y ; y ++ {
6095+ for x := bounds .Min .X ; x < bounds .Max .X ; x ++ {
6096+ sr , sg , sb , sa := src .At (x , y ).RGBA ()
6097+ alpha := float64 (sa ) / 65535.0
6098+ red := uint8 (alpha * float64 (sr / 257 ) + (1 - alpha )* float64 (bg .R ))
6099+ green := uint8 (alpha * float64 (sg / 257 ) + (1 - alpha )* float64 (bg .G ))
6100+ blue := uint8 (alpha * float64 (sb / 257 ) + (1 - alpha )* float64 (bg .B ))
6101+ dst .SetRGBA (x , y , color.RGBA {R : red , G : green , B : blue , A : 255 })
6102+ }
6103+ }
6104+ return dst
6105+ }
6106+
60206107func resizeImageForIcon (src image.Image , size int ) * image.RGBA {
60216108 bounds := src .Bounds ()
60226109 srcWidth := bounds .Dx ()
0 commit comments