A tool for archiving & converting scans of postcards, and information about them.

feat: :construction: Add web-based postcard maker, including WASM

Postcard web & USDZ generator; Golang server and (golang-built) WASM service worker.

+554 -101
+29 -5
.goreleaser.yaml
··· 13 13 - go mod tidy 14 14 15 15 builds: 16 - - main: ./cmd/postcards 16 + - 17 + main: ./cmd/postcards 17 18 id: postcards 18 19 binary: postcards 19 20 env: ··· 29 30 - wasm 30 31 ldflags: 31 32 - -s -w -X "main.Date={{.CommitDate}}" 32 - universal_binaries: 33 - - name_template: postcards 34 - id: mac-universal 35 - replace: true 33 + - 34 + main: ./cmd/postoffice-serviceworker 35 + id: postoffice-serviceworker 36 + binary: postoffice-serviceworker 37 + env: 38 + - CGO_ENABLED=0 39 + goos: 40 + - js 41 + goarch: 42 + - wasm 43 + # It's important the postoffice native binary is built after the WASM blob 44 + # As the WASM blob is baked into this server as part of an embed directive 45 + - 46 + main: ./cmd/postoffice 47 + id: postoffice 48 + binary: postoffice 49 + env: 50 + - CGO_ENABLED=0 51 + goos: 52 + - linux 53 + - windows 54 + - darwin 55 + - wasip1 56 + goarch: 57 + - amd64 58 + - arm64 59 + - wasm 36 60 37 61 archives: 38 62 - format: tar.gz
+1
TODO.md
··· 17 17 - [ ] Don't re-encode same-same format. (eg. USDZ to Web(no alpha, lossy); Web to Web) 18 18 - [ ] Show warning when using fallback size to generate USDZ 19 19 - [ ] Show warning when losing information on conversion (are there any of these cases now?) 20 + - [ ] Get HTML format to output the _right_ image extension (perhaps provide argument for what to add into templates?) 20 21 21 22 ## Done 22 23
-1
cmd/postbox/main.go
··· 1 - package main
+30
cmd/postoffice-serviceworker/main.go
··· 1 + //go:build js 2 + // +build js 3 + 4 + package main 5 + 6 + import ( 7 + "fmt" 8 + "net/http" 9 + "os" 10 + 11 + "github.com/jphastings/dotpostcard/pkg/postoffice" 12 + wasmhttp "github.com/nlepage/go-wasm-http-server/v2" 13 + ) 14 + 15 + func main() { 16 + codecChoices, err := postoffice.DefaultCodecChoices() 17 + check(err, "Unable to load codecs") 18 + 19 + http.HandleFunc("/", postoffice.HTTPFormHander(codecChoices)) 20 + wasmhttp.Serve(nil) 21 + 22 + select {} 23 + } 24 + 25 + func check(err error, message string) { 26 + if err != nil { 27 + fmt.Fprintf(os.Stderr, "%s: %v\n", message, err) 28 + os.Exit(1) 29 + } 30 + }
+35
cmd/postoffice/main.go
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "os" 7 + 8 + "github.com/jphastings/dotpostcard/internal/www" 9 + "github.com/jphastings/dotpostcard/pkg/postoffice" 10 + ) 11 + 12 + func main() { 13 + codecChoices, err := postoffice.DefaultCodecChoices() 14 + check(err, "Unable to load codecs") 15 + 16 + fs := http.FileServer(http.FS(www.PostOffice)) 17 + http.Handle("/", fs) 18 + 19 + http.HandleFunc("/compile", postoffice.HTTPFormHander(codecChoices)) 20 + 21 + port, gavePort := os.LookupEnv("PORT") 22 + if !gavePort { 23 + port = "7678" 24 + } 25 + 26 + fmt.Printf("Starting server on :%s...\n", port) 27 + check(http.ListenAndServe(":"+port, nil), "Error starting server") 28 + } 29 + 30 + func check(err error, message string) { 31 + if err != nil { 32 + fmt.Fprintf(os.Stderr, "%s: %v\n", message, err) 33 + os.Exit(1) 34 + } 35 + }
+24
formats/component/bundle.go
··· 2 2 3 3 import ( 4 4 "errors" 5 + "io" 5 6 "io/fs" 6 7 "path" 7 8 "regexp" ··· 9 10 10 11 "github.com/jphastings/dotpostcard/formats" 11 12 "github.com/jphastings/dotpostcard/formats/metadata" 13 + "github.com/jphastings/dotpostcard/types" 12 14 ) 13 15 14 16 var usableExtensions = []string{".webp", ".png", ".jpg", ".jpeg", ".tif", ".tiff"} ··· 92 94 func (b bundle) Name() string { 93 95 return codecName 94 96 } 97 + 98 + type decodedMeta types.Metadata 99 + 100 + func (m decodedMeta) Decode(formats.DecodeOptions) (types.Postcard, error) { 101 + return types.Postcard{Meta: types.Metadata(m)}, nil 102 + } 103 + 104 + func BundleFromReaders(meta types.Metadata, front io.ReadCloser, back ...io.ReadCloser) formats.Bundle { 105 + b := bundle{ 106 + frontFile: front, 107 + name: meta.Name, 108 + // TODO: is this right? 109 + refPath: ".", 110 + metaBundle: decodedMeta(meta), 111 + } 112 + 113 + if len(back) >= 1 { 114 + b.backFile = back[0] 115 + } 116 + 117 + return b 118 + }
+9 -3
formats/component/codec.go
··· 3 3 import ( 4 4 "errors" 5 5 "fmt" 6 + "io" 6 7 "io/fs" 7 8 8 9 "github.com/jphastings/dotpostcard/formats" 9 10 "github.com/jphastings/dotpostcard/formats/metadata" 11 + "github.com/jphastings/dotpostcard/types" 10 12 ) 11 13 12 14 const codecName = "Component files" ··· 18 20 ) 19 21 20 22 var _ formats.Bundle = bundle{} 23 + 24 + type decoder interface { 25 + Decode(formats.DecodeOptions) (types.Postcard, error) 26 + } 21 27 22 28 type bundle struct { 23 29 name string 24 30 refPath string 25 - frontFile fs.File 26 - backFile fs.File 27 - metaBundle formats.Bundle 31 + frontFile io.ReadCloser 32 + backFile io.ReadCloser 33 + metaBundle decoder 28 34 } 29 35 30 36 var _ formats.Codec = codec{}
+6 -1
formats/component/decode.go
··· 22 22 ) 23 23 24 24 func (b bundle) Decode(opts formats.DecodeOptions) (types.Postcard, error) { 25 + defer b.frontFile.Close() 26 + if b.backFile != nil { 27 + defer b.backFile.Close() 28 + } 29 + 25 30 pc, err := b.metaBundle.Decode(opts) 26 31 if err != nil { 27 32 return types.Postcard{}, err ··· 71 76 pc.Meta.Physical.FrontDimensions.CmHeight = forcedSize.CmHeight 72 77 } 73 78 74 - if err := validateMetadata(pc); err != nil { 79 + if err := pc.Validate(); err != nil { 75 80 return types.Postcard{}, err 76 81 } 77 82
+5 -5
formats/component/encode.go
··· 5 5 "image" 6 6 "io" 7 7 8 - "git.sr.ht/~jackmordaunt/go-libwebp/webp" 9 8 "github.com/jphastings/dotpostcard/formats" 9 + "github.com/jphastings/dotpostcard/internal/images" 10 10 "github.com/jphastings/dotpostcard/types" 11 11 "golang.org/x/image/draw" 12 12 ) ··· 18 18 encImg := func(side image.Image) func(io.Writer) error { 19 19 return func(w io.Writer) error { 20 20 if opts != nil && opts.Archival { 21 - return webp.Encode(w, side, webp.Lossless()) 21 + return images.WriteWebP(w, side, nil, true, pc.Meta.HasTransparency) 22 22 } 23 23 24 24 startSize := side.Bounds() ··· 41 41 side = resizedImg 42 42 } 43 43 44 - return webp.Encode(w, side, webp.Quality(75)) 44 + return images.WriteWebP(w, side, nil, opts.Archival, pc.Meta.HasTransparency) 45 45 } 46 46 } 47 47 48 48 frontName := fmt.Sprintf("%s-front.webp", pc.Name) 49 - frontW := formats.NewFileWriter(frontName, encImg(pc.Front)) 49 + frontW := formats.NewFileWriter(frontName, "image/webp", encImg(pc.Front)) 50 50 51 51 backName := fmt.Sprintf("%s-back.webp", pc.Name) 52 - backW := formats.NewFileWriter(backName, encImg(pc.Back)) 52 + backW := formats.NewFileWriter(backName, "image/webp", encImg(pc.Back)) 53 53 54 54 return []formats.FileWriter{frontW, backW}, nil 55 55 }
-28
formats/component/validate.go
··· 1 - package component 2 - 3 - import ( 4 - "fmt" 5 - 6 - "github.com/jphastings/dotpostcard/types" 7 - ) 8 - 9 - func validateMetadata(pc types.Postcard) error { 10 - if pc.Sides() == 2 { 11 - if pc.Meta.Flip == types.FlipNone || !pc.Meta.Flip.IsValid() { 12 - var validFlips string 13 - for _, f := range types.ValidFlips { 14 - if f != types.FlipNone { 15 - validFlips += ", " + string(f) 16 - } 17 - } 18 - 19 - return fmt.Errorf( 20 - "invalid flip type for two-sided postcard '%s' must be one of: %s", 21 - pc.Meta.Flip, 22 - validFlips[2:], 23 - ) 24 - } 25 - } 26 - 27 - return nil 28 - }
+1 -1
formats/css/codec.go
··· 28 28 return err 29 29 } 30 30 31 - return []formats.FileWriter{formats.NewFileWriter("postcards.css", writer)}, nil 31 + return []formats.FileWriter{formats.NewFileWriter("postcards.css", "text/css", writer)}, nil 32 32 }
+3 -1
formats/filewriter.go
··· 48 48 type FileWriter struct { 49 49 fn func(io.Writer) error 50 50 Filename string 51 + Mimetype string 51 52 } 52 53 53 54 // NewFileWriter is a helper function for creating a read stream for the return values of Encoders 54 - func NewFileWriter(filename string, fn func(w io.Writer) error) FileWriter { 55 + func NewFileWriter(filename, mimetype string, fn func(w io.Writer) error) FileWriter { 55 56 return FileWriter{ 56 57 Filename: filename, 58 + Mimetype: mimetype, 57 59 fn: fn, 58 60 } 59 61 }
+1 -1
formats/html/codec.go
··· 43 43 } 44 44 45 45 return []formats.FileWriter{ 46 - formats.NewFileWriter(name, writer), 46 + formats.NewFileWriter(name, "text/html", writer), 47 47 }, nil 48 48 }
+35 -34
formats/metadata/codec.go
··· 7 7 "io" 8 8 "io/fs" 9 9 "path" 10 - "slices" 11 10 "strings" 12 11 13 12 "github.com/jphastings/dotpostcard/formats" ··· 24 23 var _ formats.Bundle = bundle{} 25 24 26 25 type bundle struct { 27 - ext MetadataType 26 + mt MetadataType 28 27 file fs.File 29 28 refPath string 30 29 } 31 30 32 31 var _ formats.Codec = codec{} 33 32 34 - type codec struct{ ext MetadataType } 33 + type codec MetadataType 35 34 36 - type MetadataType string 35 + type MetadataType struct { 36 + Extension string 37 + Mimetype string 38 + HumanName string 39 + } 37 40 38 - var AsJSON MetadataType = ".json" 39 - var AsYAML MetadataType = ".yaml" 40 - var AsXMP MetadataType = ".xmp" 41 + var AsJSON = MetadataType{".json", "application/json", "JSON " + codecName} 42 + var AsYAML = MetadataType{".yaml", "application/yaml", "YAML " + codecName} 43 + var AsXMP = MetadataType{".xmp", xmp.Mimetype, "XMP " + codecName} 41 44 42 - var Extensions = []string{".json", ".yaml", ".yml", ".xmp"} 45 + var Extensions = []string{AsJSON.Extension, AsYAML.Extension, AsXMP.Extension} 43 46 44 - func Codec(ext MetadataType) formats.Codec { return codec{ext: ext} } 45 - 46 - func (c codec) Name() string { 47 - switch c.ext { 48 - case AsJSON: 49 - return "JSON " + codecName 50 - case AsYAML: 51 - return "YAML " + codecName 52 - case AsXMP: 53 - return "XMP " + codecName 47 + func ExtToMediaType(ext string) (MetadataType, bool) { 48 + switch ext { 49 + case AsJSON.Extension: 50 + return AsJSON, true 51 + case AsYAML.Extension: 52 + return AsYAML, true 53 + case AsXMP.Extension: 54 + return AsXMP, true 54 55 default: 55 - return codecName 56 + return MetadataType{}, false 56 57 } 57 58 } 58 59 60 + func Codec(mt MetadataType) formats.Codec { return codec(mt) } 61 + func (c codec) Name() string { return c.HumanName } 62 + 59 63 func BundleFromFile(file fs.File, dirPath string) (formats.Bundle, error) { 60 64 info, err := file.Stat() 61 65 if err != nil { ··· 63 67 } 64 68 filename := info.Name() 65 69 ext := path.Ext(filename) 66 - if !slices.Contains(Extensions, ext) { 70 + mt, ok := ExtToMediaType(ext) 71 + if !ok { 67 72 return nil, fmt.Errorf("unknown metadata extension '%s'", ext) 68 73 } 69 74 70 - return bundle{file: file, ext: MetadataType(ext), refPath: path.Join(dirPath, filename)}, nil 75 + return bundle{file: file, mt: mt, refPath: path.Join(dirPath, filename)}, nil 71 76 } 72 77 73 78 func (c codec) Bundle(group formats.FileGroup) ([]formats.Bundle, []fs.File, error) { ··· 75 80 var remaining []fs.File 76 81 77 82 for _, file := range group.Files { 78 - if filename, ok := formats.HasFileSuffix(file, string(c.ext)); ok { 79 - bundles = append(bundles, bundle{file: file, ext: c.ext, refPath: path.Join(group.DirPath, filename)}) 83 + if filename, ok := formats.HasFileSuffix(file, string(c.Extension)); ok { 84 + bundles = append(bundles, bundle{file: file, mt: MetadataType(c), refPath: path.Join(group.DirPath, filename)}) 80 85 } else { 81 86 remaining = append(remaining, file) 82 87 } ··· 87 92 88 93 // The structure information is stored in the internal/types/postcard.go file, because Go. 89 94 func (c codec) Encode(pc types.Postcard, _ *formats.EncodeOptions) ([]formats.FileWriter, error) { 90 - if c.ext != AsJSON && c.ext != AsYAML && c.ext != AsXMP { 91 - return nil, fmt.Errorf("unknown metadata format '%s'", c.ext) 92 - } 93 - 94 - name := fmt.Sprintf("%s-meta%s", pc.Name, c.ext) 95 + name := fmt.Sprintf("%s-meta%s", pc.Name, c.Extension) 95 96 writer := func(w io.Writer) error { 96 - switch c.ext { 97 + switch MetadataType(c) { 97 98 case AsJSON: 98 99 return json.NewEncoder(w).Encode(pc.Meta) 99 100 case AsYAML: ··· 106 107 _, err = w.Write(xmp) 107 108 return err 108 109 default: 109 - return fmt.Errorf("unknown metadata format '%s'", c.ext) 110 + return fmt.Errorf("cannot encode '%s'", c.HumanName) 110 111 } 111 112 } 112 113 113 - return []formats.FileWriter{formats.NewFileWriter(name, writer)}, nil 114 + return []formats.FileWriter{formats.NewFileWriter(name, c.Mimetype, writer)}, nil 114 115 } 115 116 116 117 func (b bundle) Decode(_ formats.DecodeOptions) (types.Postcard, error) { 117 118 var pc types.Postcard 118 119 var err error 119 - switch b.ext { 120 + switch b.mt { 120 121 case AsJSON: 121 122 err = json.NewDecoder(b.file).Decode(&pc.Meta) 122 123 case AsYAML: ··· 124 125 case AsXMP: 125 126 pc.Meta, err = xmp.MetadataFromXMP(b.file) 126 127 default: 127 - return types.Postcard{}, fmt.Errorf("unknown metadata format '%s'", b.ext) 128 + return types.Postcard{}, fmt.Errorf("cannot decode '%s'", b.mt.HumanName) 128 129 } 129 130 130 - pc.Name = strings.TrimSuffix(path.Base(b.refPath), "-meta"+string(b.ext)) 131 + pc.Name = strings.TrimSuffix(path.Base(b.refPath), "-meta"+string(b.mt.Extension)) 131 132 132 133 if err != nil { 133 134 err = fmt.Errorf("error decoding %s: %w", b.refPath, err)
+3 -2
formats/usd/codec.go
··· 165 165 ext := path.Ext(fw.Filename) 166 166 sideFilename := strings.TrimSuffix(fw.Filename, ext) + "-texture" + ext 167 167 writeImage := func(w io.Writer) error { return fw.WriteTo(w) } 168 + imageMimetype := fw.Mimetype 168 169 169 170 writeUSD := func(w io.Writer) error { 170 171 maxX, maxY := pc.Meta.Physical.FrontDimensions.MustPhysical() ··· 226 227 } 227 228 228 229 return []formats.FileWriter{ 229 - formats.NewFileWriter(usdFilename, writeUSD), 230 - formats.NewFileWriter(sideFilename, writeImage), 230 + formats.NewFileWriter(usdFilename, "model/vnd.usda", writeUSD), 231 + formats.NewFileWriter(sideFilename, imageMimetype, writeImage), 231 232 }, nil 232 233 }
+1 -1
formats/usdz/codec.go
··· 114 114 115 115 usdzFilename := pc.Name + ".postcard.usdz" 116 116 return []formats.FileWriter{ 117 - formats.NewFileWriter(usdzFilename, writeUSDZ), 117 + formats.NewFileWriter(usdzFilename, "model/vnd.usdz+zip", writeUSDZ), 118 118 }, nil 119 119 }
+5 -5
formats/web/encode.go
··· 15 15 "github.com/jphastings/dotpostcard/types" 16 16 ) 17 17 18 - func (c codec) pickFormat(meta types.Metadata, opts *formats.EncodeOptions) (string, error) { 18 + func (c codec) pickFormat(meta types.Metadata, opts *formats.EncodeOptions) (string, string, error) { 19 19 needs := capabilities{ 20 20 transparency: meta.HasTransparency, 21 21 lossless: (opts != nil) && opts.Archival, ··· 29 29 } 30 30 } 31 31 if format == "" { 32 - return "", fmt.Errorf( 32 + return "", "", fmt.Errorf( 33 33 "none of the configured formats (%s) meet the needs of this postcard & options (%s)", 34 34 strings.Join(c.formats, ", "), 35 35 needs.String(), 36 36 ) 37 37 } 38 38 39 - return format, nil 39 + return format, "image/" + format, nil 40 40 } 41 41 42 42 func (c codec) Encode(pc types.Postcard, opts *formats.EncodeOptions) ([]formats.FileWriter, error) { 43 - format, err := c.pickFormat(pc.Meta, opts) 43 + format, mimetype, err := c.pickFormat(pc.Meta, opts) 44 44 if err != nil { 45 45 return nil, err 46 46 } ··· 91 91 return err 92 92 } 93 93 94 - return []formats.FileWriter{formats.NewFileWriter(name, writer)}, nil 94 + return []formats.FileWriter{formats.NewFileWriter(name, mimetype, writer)}, nil 95 95 } 96 96 97 97 func rotateForWeb(img image.Image, flip types.Flip) (image.Image, image.Rectangle) {
+3 -1
formats/xmp/encode.go
··· 11 11 "github.com/jphastings/dotpostcard/types" 12 12 ) 13 13 14 + const Mimetype = "application/x-xmp" 15 + 14 16 func (c codec) Encode(pc types.Postcard, _ *formats.EncodeOptions) ([]formats.FileWriter, error) { 15 17 filename := fmt.Sprintf("%s-meta.xmp", pc.Name) 16 18 writer := func(w io.Writer) error { ··· 22 24 return err 23 25 } 24 26 } 25 - fw := formats.NewFileWriter(filename, writer) 27 + fw := formats.NewFileWriter(filename, Mimetype, writer) 26 28 27 29 return []formats.FileWriter{fw}, nil 28 30 }
+3
go.mod
··· 15 15 github.com/dsoprea/go-tiff-image-structure v0.0.0-20221003165014-8ecc4f52edca 16 16 github.com/ernyoke/imger v1.0.0 17 17 github.com/gen2brain/jpegli v0.3.3 18 + github.com/nlepage/go-wasm-http-server/v2 v2.0.5 18 19 github.com/spf13/cobra v1.8.1 19 20 github.com/stretchr/testify v1.9.0 20 21 github.com/sunshineplan/tiff v0.0.0-20220128141034-29b9d69bd906 ··· 46 47 github.com/golang/geo v0.0.0-20230421003525-6adc56603217 // indirect 47 48 github.com/google/uuid v1.6.0 // indirect 48 49 github.com/gorilla/css v1.0.1 // indirect 50 + github.com/hack-pad/safejs v0.1.1 // indirect 49 51 github.com/hhrutter/lzw v1.0.0 // indirect 50 52 github.com/inconshreveable/mousetrap v1.1.0 // indirect 51 53 github.com/kr/pretty v0.3.1 // indirect ··· 56 58 github.com/muesli/reflow v0.3.0 // indirect 57 59 github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect 58 60 github.com/ncruces/go-strftime v0.1.9 // indirect 61 + github.com/nlepage/go-js-promise v1.0.0 // indirect 59 62 github.com/pmezard/go-difflib v1.0.0 // indirect 60 63 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 61 64 github.com/rivo/uniseg v0.4.7 // indirect
+6 -12
go.sum
··· 4 4 git.sr.ht/~sbinet/cmpimg v0.1.0/go.mod h1:FU12psLbF4TfNXkKH2ZZQ29crIqoiqTZmeQ7dkp/pxE= 5 5 git.sr.ht/~sbinet/gg v0.6.0 h1:RIzgkizAk+9r7uPzf/VfbJHBMKUr0F5hRFxTUGMnt38= 6 6 git.sr.ht/~sbinet/gg v0.6.0/go.mod h1:uucygbfC9wVPQIfrmwM2et0imr8L7KQWywX0xpFMm94= 7 - github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= 8 7 github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= 9 8 github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 10 9 github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= ··· 76 75 github.com/ebitengine/purego v0.7.1/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ= 77 76 github.com/ernyoke/imger v1.0.0 h1:v+vnOpZwCfet5AUruwhN54g9v8CnhKOB3ZCsMIa5AZ0= 78 77 github.com/ernyoke/imger v1.0.0/go.mod h1:Ft4UQ3taMpn8EVVLWRlT0ijOvMTKZZqhXXR4VzCsAT4= 79 - github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= 80 78 github.com/gen2brain/jpegli v0.3.3 h1:ryCOQpmGuVk6FA+QBe9st6cW48jsRdVOPiNrAJ50m+k= 81 79 github.com/gen2brain/jpegli v0.3.3/go.mod h1:6Dbgr+ni1IUBqGVOKHn8lY+6DvwSGfAfC7pPQiSK6uA= 82 80 github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= ··· 95 93 github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI= 96 94 github.com/golang/geo v0.0.0-20230421003525-6adc56603217 h1:HKlyj6in2JV6wVkmQ4XmG/EIm+SCYlPZ+V4GWit7Z+I= 97 95 github.com/golang/geo v0.0.0-20230421003525-6adc56603217/go.mod h1:8wI0hitZ3a1IxZfeH3/5I97CI8i5cLGsYe7xNhQGs9U= 98 - github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 99 96 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 100 97 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 101 98 github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 102 99 github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 100 + github.com/hack-pad/safejs v0.1.1 h1:d5qPO0iQ7h2oVtpzGnLExE+Wn9AtytxIfltcS2b9KD8= 101 + github.com/hack-pad/safejs v0.1.1/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio= 103 102 github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 104 103 github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 105 104 github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0= ··· 108 107 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 109 108 github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= 110 109 github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= 111 - github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= 112 - github.com/kolesa-team/go-webp v1.0.1/go.mod h1:oMvdivD6K+Q5qIIkVC2w4k2ZUnI1H+MyP7inwgWq9aA= 113 - github.com/kolesa-team/goexiv v1.1.0/go.mod h1:njKLWYFnmazfoR/82lj4RBGbKSN6ie0fV180/GlxSFU= 114 110 github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 115 111 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 116 112 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= ··· 127 123 github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 128 124 github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= 129 125 github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 130 - github.com/montanaflynn/stats v0.6.6/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= 131 126 github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 132 127 github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 133 128 github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg= 134 129 github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ= 135 130 github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= 136 131 github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 132 + github.com/nlepage/go-js-promise v1.0.0 h1:K7OmJ3+0BgWJ2LfXchg2sI6RDr7AW/KWR8182epFwGQ= 133 + github.com/nlepage/go-js-promise v1.0.0/go.mod h1:bdOP0wObXu34euibyK39K1hoBCtlgTKXGc56AGflaRo= 134 + github.com/nlepage/go-wasm-http-server/v2 v2.0.5 h1:24MooRbGomXxV9dFtP4Z6XCUKlRB39tD8RpL8X1t8hQ= 135 + github.com/nlepage/go-wasm-http-server/v2 v2.0.5/go.mod h1:r8j7cEOeUqNp+c+C52sNuWaFTvvT/cNqIwBuEtA36HA= 137 136 github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 138 137 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 139 138 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= ··· 150 149 github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= 151 150 github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 152 151 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 153 - github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 154 152 github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 155 153 github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 156 154 github.com/sunshineplan/tiff v0.0.0-20220128141034-29b9d69bd906 h1:+yYRCj+PGQNnnen4+/Q7eKD2J87RJs+O39bjtHhPauk= ··· 165 163 github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhbIQY4= 166 164 github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= 167 165 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 168 - golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= 169 166 golang.org/x/image v0.22.0 h1:UtK5yLUzilVrkjMAZAZ34DXGpASN8i8pj8g+O+yd10g= 170 167 golang.org/x/image v0.22.0/go.mod h1:9hPFhljd4zZ1GNSIZJ49sqbp45GKK9t6w+iXvGqZUz4= 171 168 golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= ··· 208 205 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 209 206 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 210 207 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 211 - lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= 212 - modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y= 213 208 modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= 214 209 modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= 215 - modernc.org/ccgo/v3 v3.17.0/go.mod h1:Sg3fwVpmLvCUTaqEUjiBDAvshIaKDB0RXaf+zgqFu8I= 216 210 modernc.org/ccgo/v4 v4.19.0 h1:f9K5VdC0nVhHKTFMvhjtZ8TbRgFQbASvE5yO1zs8eC0= 217 211 modernc.org/ccgo/v4 v4.19.0/go.mod h1:CfpAl+673iXNwMG/aqcQn+vDcu4Es/YLya7+9RHjTa4= 218 212 modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
+6
internal/www/embeds.go
··· 1 + package www 2 + 3 + import "embed" 4 + 5 + //go:embed postoffice 6 + var PostOffice embed.FS
+42
internal/www/postoffice/index.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <title>Postbox</title> 5 + <script src="postoffice.js"></script> 6 + </head> 7 + <body> 8 + <h1>Postbox</h1> 9 + <form id="postcard-input" action="/compile/" method="POST" enctype="multipart/form-data"> 10 + <input type="file" name="front" accept=".jpg, .jpeg, .png, .webp" required> 11 + <input type="file" name="back" accept=".jpg, .jpeg, .png, .webp"> 12 + 13 + <label><input type="checkbox" name="remove-border">Try to remove mono-coloured background</label> 14 + <label><input type="checkbox" name="ignore-transparency">Force non-transparent</label> 15 + <label><input type="checkbox" name="archival">Make archival (lossless) version</label> 16 + 17 + <input type="text" name="name"> 18 + <fieldset> 19 + <legend>How does the postcard flip?</legend> 20 + <label><input type="radio" name="flip" value="">One sided</label> 21 + <label><input type="radio" name="flip" value="book">Like a book</label> 22 + <label><input type="radio" name="flip" value="calendar">Like a calendar</label> 23 + <label><input type="radio" name="flip" value="left-hand">Holding in the left hand</label> 24 + <label><input type="radio" name="flip" value="right-hand">Holding in the right hand</label> 25 + </fieldset> 26 + 27 + <fieldset> 28 + <legend>What format would you like?</legend> 29 + <label><input type="radio" name="codec-choice" value="web">Web-based image</label> 30 + <label><input type="radio" name="codec-choice" value="usdz">USDZ</label> 31 + </fieldset> 32 + 33 + <button type="submit">Create Postcard</button> 34 + </form> 35 + 36 + <div id="output" style="max-width:40vw;margin: auto; display: none;"> 37 + <style></style> 38 + <div class="postcard-html"></div> 39 + <a class="postcard-download">Download</a> 40 + </div> 41 + </body> 42 + </html>
+14
internal/www/postoffice/postoffice-serviceworker.js
··· 1 + importScripts('https://cdn.jsdelivr.net/gh/golang/go@go1.23.1/misc/wasm/wasm_exec.js') 2 + importScripts('https://cdn.jsdelivr.net/gh/nlepage/go-wasm-http-server@v2.0.5/sw.js') 3 + 4 + const wasm = 'postoffice-serviceworker.wasm' 5 + 6 + // addEventListener('install', (event) => { 7 + // event.waitUntil(caches.open('postoffice').then((cache) => cache.add(wasm))) 8 + // }) 9 + 10 + addEventListener('activate', (event) => { 11 + event.waitUntil(clients.claim()) 12 + }) 13 + 14 + registerWasmHTTPListener(wasm, { base: '/compile' })
+1
internal/www/postoffice/postoffice-serviceworker.wasm
··· 1 + ../../../dist/postoffice-serviceworker_js_wasm/postoffice-serviceworker.wasm
+105
internal/www/postoffice/postoffice.js
··· 1 + document.addEventListener("DOMContentLoaded", () => { 2 + document.getElementById('postcard-input').addEventListener('submit', async (event) => { 3 + event.preventDefault() 4 + const {action, method} = event.target 5 + const body = new FormData(event.target) 6 + 7 + const res = await fetch(action, { method, body,}) 8 + .then(onlyOk) 9 + .then(processResult) 10 + }) 11 + }) 12 + 13 + async function processResult(res) { 14 + if (res.headers.get('Content-Type') == "multipart/form") { 15 + const fd = await res.formData() 16 + displayPostcard(fd.values()) 17 + } else { 18 + const blob = await res.blob() 19 + const file = new File([blob], extractFilename(res), { type: blob.type }); 20 + downloadFile(file) 21 + } 22 + } 23 + 24 + function extractFilename(res) { 25 + const cdh = res.headers.get('Content-Disposition') 26 + const filenameRegex = /filename=(?:(["'])([^;\n]+?)\1)/i; 27 + const matches = filenameRegex.exec(cdh); 28 + if (!matches) { 29 + return 30 + } 31 + 32 + return matches[2] 33 + } 34 + 35 + function onlyOk(res) { 36 + if (!res.ok) { 37 + throw new Error(`Unusable HTTP response: ${res.res.statusText}`) 38 + } 39 + return res 40 + } 41 + 42 + async function displayPostcard(files) { 43 + let image, html 44 + 45 + for (file of files) { 46 + switch(file.type) { 47 + case "model/vnd.usdz+zip": 48 + // Shortcut and just download the USDZ here, for now 49 + downloadFile(file) 50 + return 51 + case "text/css": 52 + await file.text().then(insertPostcardCSS) 53 + break; 54 + case "text/html": 55 + html = await file.text() 56 + break; 57 + case "image/jpeg": 58 + case "image/png": 59 + image = { 60 + filename: file.name, 61 + blobURL: URL.createObjectURL(file), 62 + } 63 + break; 64 + } 65 + } 66 + 67 + insertPostcardHTML(html, image) 68 + document.querySelector('#output').style.display = 'block' 69 + } 70 + 71 + 72 + function insertPostcardCSS(css) { 73 + const outCSS = document.querySelector('#output style') 74 + outCSS.appendChild(document.createTextNode(css)); 75 + } 76 + 77 + function insertPostcardHTML(html, image) { 78 + const outHTML = document.querySelector('#output div.postcard-html') 79 + const outDownload = document.querySelector('#output a.postcard-download') 80 + 81 + const [_, uniqueHTML] = html.split("\n\n") 82 + if (!uniqueHTML) { 83 + throw new Error("Unexpected HTML returned from postcard generator") 84 + } 85 + // TODO: fix this hack to replace the filename 86 + const toReplace = image.filename.replace(/\.[^/.]+$/, '.jpeg') 87 + outHTML.innerHTML = uniqueHTML.replaceAll(toReplace, image.blobURL) 88 + outDownload.href = image.blobURL 89 + outDownload.download = image.filename 90 + } 91 + 92 + 93 + function downloadFile(file) { 94 + const url = URL.createObjectURL(file); 95 + 96 + const a = document.createElement('a'); 97 + a.href = url; 98 + a.download = file.name; 99 + document.body.appendChild(a); 100 + a.click(); 101 + document.body.removeChild(a); 102 + URL.revokeObjectURL(url); 103 + } 104 + 105 + navigator.serviceWorker.register('postoffice-serviceworker.js').catch(console.error)
+21
pkg/postoffice/choices.go
··· 1 + package postoffice 2 + 3 + import ( 4 + "github.com/jphastings/dotpostcard/formats" 5 + "github.com/jphastings/dotpostcard/formats/css" 6 + "github.com/jphastings/dotpostcard/formats/html" 7 + "github.com/jphastings/dotpostcard/formats/usdz" 8 + "github.com/jphastings/dotpostcard/formats/web" 9 + ) 10 + 11 + func DefaultCodecChoices() (CodecChoices, error) { 12 + webCodec, err := web.Codec("jpeg", "png") 13 + if err != nil { 14 + return nil, err 15 + } 16 + 17 + return map[string][]formats.Codec{ 18 + "web": {webCodec, html.Codec(), css.Codec()}, 19 + "usdz": {usdz.Codec()}, 20 + }, nil 21 + }
+143
pkg/postoffice/http.go
··· 1 + package postoffice 2 + 3 + import ( 4 + "fmt" 5 + "io" 6 + "mime/multipart" 7 + "net/http" 8 + "net/textproto" 9 + "path" 10 + "strings" 11 + 12 + "github.com/jphastings/dotpostcard/formats" 13 + "github.com/jphastings/dotpostcard/formats/component" 14 + "github.com/jphastings/dotpostcard/types" 15 + ) 16 + 17 + type CodecChoices map[string][]formats.Codec 18 + 19 + func HTTPFormHander(codecChoices CodecChoices) func(http.ResponseWriter, *http.Request) { 20 + return func(w http.ResponseWriter, r *http.Request) { 21 + pc, codecs, encOpts, err := requestToPostcard(codecChoices, r) 22 + if err != nil { 23 + http.Error(w, fmt.Sprintf("Unable to create postcard: %v", err), http.StatusBadRequest) 24 + } 25 + 26 + var files []formats.FileWriter 27 + 28 + for _, c := range codecs { 29 + fws, err := c.Encode(pc, &encOpts) 30 + if err != nil { 31 + http.Error(w, err.Error(), http.StatusBadRequest) 32 + return 33 + } 34 + files = append(files, fws...) 35 + } 36 + 37 + if len(files) < 1 { 38 + http.Error(w, "No postcard image created", http.StatusInternalServerError) 39 + return 40 + } 41 + 42 + if len(files) == 1 { 43 + w.Header().Add("Content-Type", files[0].Mimetype) 44 + w.Header().Add("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, files[0].Filename)) 45 + files[0].WriteTo(w) 46 + return 47 + } 48 + 49 + mw := multipart.NewWriter(w) 50 + w.Header().Add("Content-Type", mw.FormDataContentType()) 51 + 52 + for _, f := range files { 53 + h := make(textproto.MIMEHeader) 54 + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, f.Filename, f.Filename)) 55 + h.Set("Content-Type", f.Mimetype) 56 + 57 + ww, err := mw.CreatePart(h) 58 + if err != nil { 59 + http.Error(w, "Unable to combine files", http.StatusInternalServerError) 60 + return 61 + } 62 + 63 + if err := f.WriteTo(ww); err != nil { 64 + http.Error(w, "Unable to write files", http.StatusInternalServerError) 65 + return 66 + } 67 + } 68 + 69 + mw.Close() 70 + } 71 + } 72 + 73 + func checkboxBool(formVal string) bool { return formVal == "on" } 74 + 75 + func requestToPostcard(codecChoices CodecChoices, r *http.Request) (types.Postcard, []formats.Codec, formats.EncodeOptions, error) { 76 + codecs, ok := codecChoices[r.FormValue("codec-choice")] 77 + if !ok { 78 + return types.Postcard{}, nil, formats.EncodeOptions{}, fmt.Errorf("no acceptable codec choice provided") 79 + } 80 + 81 + decOpts := formats.DecodeOptions{ 82 + IgnoreTransparency: checkboxBool(r.FormValue("ignore-transparency")), 83 + RemoveBorder: checkboxBool(r.FormValue("remove-border")), 84 + } 85 + encOpts := formats.EncodeOptions{ 86 + Archival: checkboxBool(r.FormValue("archival")), 87 + } 88 + 89 + var meta types.Metadata 90 + 91 + err := r.ParseMultipartForm(50 << 20) // 50 MB limit 92 + if err != nil { 93 + return types.Postcard{}, nil, encOpts, err 94 + } 95 + 96 + meta.Name = r.FormValue("name") 97 + meta.Flip = types.Flip(r.FormValue("flip")) 98 + 99 + frontR, nameGuess, err := formToFile(r.MultipartForm.File["front"]) 100 + if err != nil { 101 + return types.Postcard{}, nil, encOpts, err 102 + } 103 + backR, _, err := formToFile(r.MultipartForm.File["back"]) 104 + if err != nil { 105 + return types.Postcard{}, nil, encOpts, err 106 + } 107 + 108 + if meta.Name == "" { 109 + meta.Name = nameGuess 110 + } 111 + 112 + pc, err := component.BundleFromReaders(meta, frontR, backR).Decode(decOpts) 113 + if err != nil { 114 + return types.Postcard{}, nil, encOpts, err 115 + } 116 + 117 + return pc, codecs, encOpts, nil 118 + } 119 + 120 + func formToFile(fhs []*multipart.FileHeader) (io.ReadCloser, string, error) { 121 + if len(fhs) == 0 { 122 + return nil, "", nil 123 + } 124 + 125 + file, err := fhs[0].Open() 126 + if err != nil { 127 + return nil, "", err 128 + } 129 + 130 + name := fhs[0].Filename 131 + suffixesToRemove := []string{ 132 + path.Ext(name), 133 + "-front", 134 + "-only", 135 + "-back", 136 + } 137 + 138 + for _, suffix := range suffixesToRemove { 139 + name = strings.TrimSuffix(name, suffix) 140 + } 141 + 142 + return file, name, nil 143 + }
+22
types/validate.go
··· 1 + package types 2 + 3 + import ( 4 + "fmt" 5 + ) 6 + 7 + func (pc Postcard) Validate() error { 8 + switch pc.Sides() { 9 + case 0: 10 + return fmt.Errorf("a postcard must have at least a front side") 11 + case 1: 12 + if pc.Meta.Flip != FlipNone { 13 + return fmt.Errorf("flip of '%s' given, but only 1 side provided", pc.Meta.Flip) 14 + } 15 + case 2: 16 + if pc.Meta.Flip == FlipNone || !pc.Meta.Flip.IsValid() { 17 + return fmt.Errorf("invalid flip type '%s' for two-sided postcard", pc.Meta.Flip) 18 + } 19 + } 20 + 21 + return nil 22 + }