Skip to content

Commit 7c59802

Browse files
committed
Expand image conversion outputs to png jpg and gif
1 parent 02232b2 commit 7c59802

6 files changed

Lines changed: 192 additions & 17 deletions

File tree

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ If you already know what you want, use the direct command:
241241
```bash
242242
jot convert logo.png ico
243243
jot convert logo.png svg
244+
jot convert screenshot.png jpg
244245
```
245246

246247
That writes the converted file next to the source image by default.
@@ -251,15 +252,18 @@ If you want the guided flow instead:
251252
jot task
252253
```
253254

254-
Pick `convert image`, choose the source file, then choose `.ico` or `.svg`.
255+
Pick `convert image`, choose the source file, then choose the target format.
255256

256257
Current image conversion support:
257258

258259
- inputs: `.png`, `.jpg`, `.jpeg`, `.gif`
259-
- outputs: `.ico`, `.svg`
260+
- outputs: `.png`, `.jpg`, `.gif`, `.ico`, `.svg`
260261

261262
Notes:
262263

264+
- `.png` output preserves raster detail and alpha
265+
- `.jpg` output is optimized for photos and screenshots and flattens transparency onto white
266+
- `.gif` output is single-frame and palette-limited
263267
- `.ico` output builds a multi-size favicon-style icon automatically
264268
- `.svg` output wraps the source raster inside a standalone SVG file; it is not traced vector output
265269

main.go

Lines changed: 98 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -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 {
528528
func 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

54575460
func isSupportedConvertTargetFormat(format string) bool {
54585461
for _, candidate := range convertOutputFormats {
@@ -5465,7 +5468,12 @@ func isSupportedConvertTargetFormat(format string) bool {
54655468

54665469
func 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

54715479
func 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.
59936051
func svgEscapeTitle(s string) string {
59946052
s = strings.ReplaceAll(s, "&", "&amp;")
@@ -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+
60206107
func resizeImageForIcon(src image.Image, size int) *image.RGBA {
60216108
bounds := src.Bounds()
60226109
srcWidth := bounds.Dx()

main_test.go

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,11 +355,12 @@ func TestJotConvertHelpWritesCommandGuide(t *testing.T) {
355355
help := out.String()
356356
for _, snippet := range []string{
357357
"jot convert",
358-
"<ico|svg>",
358+
"<png|jpg|jpeg|gif|ico|svg>",
359359
"--out PATH",
360360
"multi-size favicon-style icon",
361361
"Raster-to-`.svg` output wraps the source image",
362362
"Supported raster inputs today",
363+
"jot convert screenshot.png jpg",
363364
} {
364365
if !strings.Contains(help, snippet) {
365366
t.Fatalf("expected help to contain %q, got %q", snippet, help)
@@ -470,6 +471,77 @@ func TestConvertImageFileCreatesEmbeddedSVG(t *testing.T) {
470471
}
471472
}
472473

474+
func TestConvertImageFileCreatesPNG(t *testing.T) {
475+
workdir := t.TempDir()
476+
sourcePath := filepath.Join(workdir, "logo.jpg")
477+
writeTestPNG(t, sourcePath, 40, 24)
478+
479+
result, err := convertImageFile(convertOptions{
480+
SourcePath: sourcePath,
481+
TargetFormat: "png",
482+
})
483+
if err != nil {
484+
t.Fatalf("convertImageFile returned error: %v", err)
485+
}
486+
if filepath.Ext(result.OutputPath) != ".png" {
487+
t.Fatalf("expected .png output, got %q", result.OutputPath)
488+
}
489+
data, err := os.ReadFile(result.OutputPath)
490+
if err != nil {
491+
t.Fatalf("read png failed: %v", err)
492+
}
493+
if len(data) < 8 || !bytes.Equal(data[:8], []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}) {
494+
t.Fatalf("expected png signature, got invalid output")
495+
}
496+
}
497+
498+
func TestConvertImageFileCreatesJPGAndWarnsWhenFlatteningAlpha(t *testing.T) {
499+
workdir := t.TempDir()
500+
sourcePath := filepath.Join(workdir, "logo.png")
501+
img := image.NewRGBA(image.Rect(0, 0, 20, 20))
502+
for y := 0; y < 20; y++ {
503+
for x := 0; x < 20; x++ {
504+
alpha := uint8(255)
505+
if x > 10 {
506+
alpha = 128
507+
}
508+
img.Set(x, y, color.RGBA{R: 220, G: 80, B: 80, A: alpha})
509+
}
510+
}
511+
file, err := os.Create(sourcePath)
512+
if err != nil {
513+
t.Fatalf("create png failed: %v", err)
514+
}
515+
if err := png.Encode(file, img); err != nil {
516+
_ = file.Close()
517+
t.Fatalf("encode png failed: %v", err)
518+
}
519+
if err := file.Close(); err != nil {
520+
t.Fatalf("close png failed: %v", err)
521+
}
522+
523+
result, err := convertImageFile(convertOptions{
524+
SourcePath: sourcePath,
525+
TargetFormat: "jpg",
526+
})
527+
if err != nil {
528+
t.Fatalf("convertImageFile returned error: %v", err)
529+
}
530+
if filepath.Ext(result.OutputPath) != ".jpg" {
531+
t.Fatalf("expected .jpg output, got %q", result.OutputPath)
532+
}
533+
if result.Warning == "" {
534+
t.Fatalf("expected jpg conversion warning when flattening alpha")
535+
}
536+
data, err := os.ReadFile(result.OutputPath)
537+
if err != nil {
538+
t.Fatalf("read jpg failed: %v", err)
539+
}
540+
if len(data) < 4 || data[0] != 0xff || data[1] != 0xd8 || data[len(data)-2] != 0xff || data[len(data)-1] != 0xd9 {
541+
t.Fatalf("expected jpeg markers, got invalid output")
542+
}
543+
}
544+
473545
func TestJotTaskConvertFlowCreatesOutputAndPrintsTip(t *testing.T) {
474546
workdir := t.TempDir()
475547
sourcePath := filepath.Join(workdir, "logo.png")

packaging/chocolatey/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ Use the direct command when you already know the job:
185185
```bash
186186
jot convert logo.png ico
187187
jot convert logo.png svg
188+
jot convert screenshot.png jpg
188189
```
189190

190191
Or use the guided task flow:
@@ -198,10 +199,13 @@ Pick `convert image`, then choose the source image and target format.
198199
Current image conversion support:
199200

200201
- inputs: `.png`, `.jpg`, `.jpeg`, `.gif`
201-
- outputs: `.ico`, `.svg`
202+
- outputs: `.png`, `.jpg`, `.gif`, `.ico`, `.svg`
202203

203204
Notes:
204205

206+
- `.png` output preserves raster detail and alpha
207+
- `.jpg` output is optimized for photos and screenshots and flattens transparency onto white
208+
- `.gif` output is single-frame and palette-limited
205209
- `.ico` output builds a multi-size favicon-style icon automatically
206210
- `.svg` output wraps the source raster inside a standalone SVG file; it is not traced vector output
207211

packaging/homebrew/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ Use the direct command when you already know the job:
185185
```bash
186186
jot convert logo.png ico
187187
jot convert logo.png svg
188+
jot convert screenshot.png jpg
188189
```
189190

190191
Or use the guided task flow:
@@ -198,10 +199,13 @@ Pick `convert image`, then choose the source image and target format.
198199
Current image conversion support:
199200

200201
- inputs: `.png`, `.jpg`, `.jpeg`, `.gif`
201-
- outputs: `.ico`, `.svg`
202+
- outputs: `.png`, `.jpg`, `.gif`, `.ico`, `.svg`
202203

203204
Notes:
204205

206+
- `.png` output preserves raster detail and alpha
207+
- `.jpg` output is optimized for photos and screenshots and flattens transparency onto white
208+
- `.gif` output is single-frame and palette-limited
205209
- `.ico` output builds a multi-size favicon-style icon automatically
206210
- `.svg` output wraps the source raster inside a standalone SVG file; it is not traced vector output
207211

packaging/npm/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ Use the direct command when you already know the job:
114114
```bash
115115
jot convert logo.png ico
116116
jot convert logo.png svg
117+
jot convert screenshot.png jpg
117118
```
118119

119120
Or use the guided task flow:
@@ -127,10 +128,13 @@ Pick `convert image`, then choose the source image and target format.
127128
Current image conversion support:
128129

129130
- inputs: `.png`, `.jpg`, `.jpeg`, `.gif`
130-
- outputs: `.ico`, `.svg`
131+
- outputs: `.png`, `.jpg`, `.gif`, `.ico`, `.svg`
131132

132133
Notes:
133134

135+
- `.png` output preserves raster detail and alpha
136+
- `.jpg` output is optimized for photos and screenshots and flattens transparency onto white
137+
- `.gif` output is single-frame and palette-limited
134138
- `.ico` output builds a multi-size favicon-style icon automatically
135139
- `.svg` output wraps the source raster inside a standalone SVG file; it is not traced vector output
136140

0 commit comments

Comments
 (0)