diff --git a/TODO.md b/TODO.md index f004f66..28ccb62 100644 --- a/TODO.md +++ b/TODO.md @@ -1,6 +1,7 @@ # TODO -- [ ] JPEGli in SVG with mask smaller than WebP? +- [ ] Coregistration of front and back (slight rotation & translation differences) +- [x] JPEGli in SVG with mask smaller than PNG? - [ ] Improve HasTransparency detection (for all format decoding) - [ ] Move component/decode.go#decodeImage to internal/images/decode.go? - [ ] Fix XMP inject/extract tests @@ -11,7 +12,6 @@ - [ ] Store number of sides in the Metadata - [ ] Validate the number of sides in the metadata with the number of images provided - [ ] Allow conversion of postcard files on PostOffice -- [ ] Coregistration of front and back (slight rotation & translation differences) - [ ] Allow uploading/choice of SVGs for the back side, to make a "blank" postcard - [ ] Responsive layout for the back — in physical units - [ ] Rendering SVG inside go/wasm diff --git a/formats.go b/formats.go index 2dd48a9..d682bb7 100644 --- a/formats.go +++ b/formats.go @@ -21,6 +21,7 @@ var formatDocs embed.FS var codecs = map[string]formats.Codec{ "component": component.Codec(), "web": web.DefaultCodec, + "svg": web.SVGCodec, "usd": usd.Codec(), "usdz": usdz.Codec(), "json": metadata.Codec(metadata.AsJSON), @@ -28,7 +29,8 @@ var codecs = map[string]formats.Codec{ "xmp": xmp.Codec(), } -var Codecs = []string{"component", "web", "usdz", "usd", "json", "yaml", "xmp"} +// Used for ordering +var Codecs = []string{"component", "web", "svg", "usdz", "usd", "json", "yaml", "xmp"} // These 'formats' will trigger the IncludeSupportFiles encoder option instead of a different codec var supportFiles = []string{"css", "html"} diff --git a/formats/usd/codec.go b/formats/usd/codec.go index c983a97..8d66ad2 100644 --- a/formats/usd/codec.go +++ b/formats/usd/codec.go @@ -159,7 +159,6 @@ func (c codec) Encode(pc types.Postcard, opts *formats.EncodeOptions) ([]formats maxX, maxY := pc.Meta.Physical.FrontDimensions.MustPhysical() // TODO: Coregister front & back? - // TODO: Handle no back frontPoints, err := images.Outline(pc.Front, false, true) if err != nil { @@ -167,6 +166,7 @@ func (c codec) Encode(pc types.Postcard, opts *formats.EncodeOptions) ([]formats } fTris := geom3d.Triangulate(frontPoints) + // TODO: Handle no back backPoints, err := images.Outline(pc.Back, true, true) if err != nil { return fmt.Errorf("back image can't be outlined: %w", err) diff --git a/formats/web/codec.go b/formats/web/codec.go index b70758b..26807ff 100644 --- a/formats/web/codec.go +++ b/formats/web/codec.go @@ -57,6 +57,7 @@ var formatCapabilities = map[string]capabilities{ "jpeg": {}, "webp": {lossless: true, transparency: true}, "png": {lossless: true, transparency: true}, + "svg": {transparency: true}, } // Only returns true if the capabilities on struct owning this method meet the needs of the provided capabilities object. @@ -78,5 +79,6 @@ func Codec(format string, altFormats ...string) (formats.Codec, error) { } var DefaultCodec, _ = Codec("jpeg", "webp") +var SVGCodec, _ = Codec("svg") func (c codec) Name() string { return codecName } diff --git a/formats/web/encode.go b/formats/web/encode.go index 6ee12d8..3a4194b 100644 --- a/formats/web/encode.go +++ b/formats/web/encode.go @@ -90,6 +90,8 @@ func (c codec) Encode(pc types.Postcard, opts *formats.EncodeOptions) ([]formats err = images.WritePNG(w, combinedImg, xmpData, opts.Archival) case "jpeg": err = images.WriteJPEG(w, combinedImg, xmpData) + case "svg": + err = writeSVG(w, pc, combinedImg, xmpData) default: err = fmt.Errorf("unsupported output image format: %s", format) } diff --git a/formats/web/postcard.svg.qtpl b/formats/web/postcard.svg.qtpl new file mode 100644 index 0000000..ea4a1d1 --- /dev/null +++ b/formats/web/postcard.svg.qtpl @@ -0,0 +1,31 @@ +{% func SVG(v svgVars) %} + + {%s= string(v.metadata) %} + + + + + + + + +{% endfunc %} \ No newline at end of file diff --git a/formats/web/postcard.svg.qtpl.go b/formats/web/postcard.svg.qtpl.go new file mode 100644 index 0000000..fa37948 --- /dev/null +++ b/formats/web/postcard.svg.qtpl.go @@ -0,0 +1,124 @@ +// Code generated by qtc from "postcard.svg.qtpl". DO NOT EDIT. +// See https://github.com/valyala/quicktemplate for details. + +//line postcard.svg.qtpl:1 +package web + +//line postcard.svg.qtpl:1 +import ( + qtio422016 "io" + + qt422016 "github.com/valyala/quicktemplate" +) + +//line postcard.svg.qtpl:1 +var ( + _ = qtio422016.Copy + _ = qt422016.AcquireByteBuffer +) + +//line postcard.svg.qtpl:1 +func StreamSVG(qw422016 *qt422016.Writer, v svgVars) { +//line postcard.svg.qtpl:1 + qw422016.N().S(` + + `) +//line postcard.svg.qtpl:14 + qw422016.N().S(string(v.metadata)) +//line postcard.svg.qtpl:14 + qw422016.N().S(` + + + + + + + + +`) +//line postcard.svg.qtpl:31 +} + +//line postcard.svg.qtpl:31 +func WriteSVG(qq422016 qtio422016.Writer, v svgVars) { +//line postcard.svg.qtpl:31 + qw422016 := qt422016.AcquireWriter(qq422016) +//line postcard.svg.qtpl:31 + StreamSVG(qw422016, v) +//line postcard.svg.qtpl:31 + qt422016.ReleaseWriter(qw422016) +//line postcard.svg.qtpl:31 +} + +//line postcard.svg.qtpl:31 +func SVG(v svgVars) string { +//line postcard.svg.qtpl:31 + qb422016 := qt422016.AcquireByteBuffer() +//line postcard.svg.qtpl:31 + WriteSVG(qb422016, v) +//line postcard.svg.qtpl:31 + qs422016 := string(qb422016.B) +//line postcard.svg.qtpl:31 + qt422016.ReleaseByteBuffer(qb422016) +//line postcard.svg.qtpl:31 + return qs422016 +//line postcard.svg.qtpl:31 +} diff --git a/formats/web/svg.go b/formats/web/svg.go new file mode 100644 index 0000000..fab6a6a --- /dev/null +++ b/formats/web/svg.go @@ -0,0 +1,82 @@ +package web + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "image" + "io" + "strings" + + "github.com/jphastings/dotpostcard/internal/geom3d" + "github.com/jphastings/dotpostcard/internal/images" + "github.com/jphastings/dotpostcard/types" +) + +//go:generate qtc -file postcard.svg.qtpl + +type svgVars struct { + size types.Size + b64Img string + frontPoints []geom3d.Point + backPoints []geom3d.Point + metadata []byte +} + +// This keeps the whole JPEG in memory. TODO: Figure out how to do this while streaming +func writeSVG(w io.Writer, pc types.Postcard, combinedImg image.Image, xmpData []byte) error { + frontPoints, err := images.Outline(pc.Front, false, false) + if err != nil { + return fmt.Errorf("front image can't be outlined: %w", err) + } + // TODO: Handle no back + backPoints, err := images.Outline(pc.Back, false, false) + if err != nil { + return fmt.Errorf("back image can't be outlined: %w", err) + } + + var buf bytes.Buffer + b64W := base64.NewEncoder(base64.StdEncoding, &buf) + if err := images.WriteJPEG(b64W, combinedImg, nil); err != nil { + return err + } + + meta, err := json.Marshal(pc.Meta) + if err != nil { + return err + } + + v := svgVars{ + size: pc.Meta.Physical.FrontDimensions, + b64Img: buf.String(), + frontPoints: frontPoints, + backPoints: backPoints, + metadata: meta, + } + + WriteSVG(w, v) + return nil +} + +func pointsToPath(points []geom3d.Point, w, h int, back bool) string { + offsetH := 0 + if back { + offsetH = h + } + + var path strings.Builder + for i, p := range points { + if i == 0 { + path.WriteString("M") + } else { + path.WriteString("L") + } + x := p.X * float64(w) + y := p.Y*float64(h) + float64(offsetH) + path.WriteString(fmt.Sprintf("%.1f %.1f", x, y)) + + } + path.WriteString("Z") + return path.String() +} diff --git a/go.sum b/go.sum index 0513178..b914a3d 100644 --- a/go.sum +++ b/go.sum @@ -8,12 +8,14 @@ github.com/alecthomas/chroma/v2 v2.19.0 h1:Im+SLRgT8maArxv81mULDWN8oKxkzboH07CHe github.com/alecthomas/chroma/v2 v2.19.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/calvinfeng/rdp-path-simplification v0.0.0-20180903222510-ae464721f91c h1:rY8nXHWBaXCqDuMfge4bNhbQ/KYgGK0b9ZduB3BosY0= github.com/calvinfeng/rdp-path-simplification v0.0.0-20180903222510-ae464721f91c/go.mod h1:5c8wO8qWvpTEFr5p+oeHaCig8KEZAQoLLm24DzsEMwQ= github.com/campoy/embedmd v1.0.0 h1:V4kI2qTJJLf4J29RzI/MAt2c3Bl4dQSYPuflzwFH2hY= @@ -99,8 +101,10 @@ github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgR github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI= github.com/golang/geo v0.0.0-20250707181242-c5087ca84cf4 h1:vCeHcs8N7MOccOOsOVIy1xcYu+kBkA4J5urTgigww7c= github.com/golang/geo v0.0.0-20250707181242-c5087ca84cf4/go.mod h1:AN0OjM34c3PbjAsX+QNma1nYtJtRxl+s9MZNV7S+efw= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-units v0.0.0-20250612230646-eddd77f68220/go.mod h1:wBcRMlRM/bVzYk9xtR2hOp3+iWOhEh1FiK8sAzeR9eA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= @@ -115,6 +119,7 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -131,6 +136,7 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/montanaflynn/stats v0.6.6/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= @@ -157,16 +163,19 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/sunshineplan/tiff v0.0.0-20220128141034-29b9d69bd906 h1:+yYRCj+PGQNnnen4+/Q7eKD2J87RJs+O39bjtHhPauk= github.com/sunshineplan/tiff v0.0.0-20220128141034-29b9d69bd906/go.mod h1:O+Ar7ouRbdfxLgoZLFz447/dvdM1NVKk1VpOQaijvAU= github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= +github.com/tmaxmax/go-sse v0.8.0/go.mod h1:HLoxqxdH+7oSUItjtnpxjzJedfr/+Rrm/dNWBcTxJFM= github.com/trimmer-io/go-xmp v1.0.0 h1:zY8bolSga5kOjBAaHS6hrdxLgEoYuT875xTy0QDwZWs= github.com/trimmer-io/go-xmp v1.0.0/go.mod h1:Aaptr9sp1lLv7UnCAdQ+gSHZyY2miYaKmcNVj7HRBwA= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM= github.com/valyala/quicktemplate v1.8.0 h1:zU0tjbIqTRgKQzFY1L42zq0qR3eh4WoQQdIdqCysW5k= github.com/valyala/quicktemplate v1.8.0/go.mod h1:qIqW8/igXt8fdrUln5kOSb+KWMaJ4Y8QUsfd1k6L2jM= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= @@ -176,6 +185,7 @@ github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Br github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/image v0.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas= diff --git a/internal/images/outline.go b/internal/images/outline.go index 104b593..0f73d30 100644 --- a/internal/images/outline.go +++ b/internal/images/outline.go @@ -11,6 +11,9 @@ import ( "github.com/jphastings/dotpostcard/internal/geom3d" ) +// const linePrecision = 0.0022 +const linePrecision = 0.0012 + // Returns the outline of the image's transparency as an _anticlockwise_ series of X/Y points func Outline(im image.Image, invertX, invertY bool) ([]geom3d.Point, error) { b := im.Bounds() @@ -49,8 +52,7 @@ func Outline(im image.Image, invertX, invertY bool) ([]geom3d.Point, error) { pos, dir = nextPos, nextDir } - ep := 0.0022 - path := rdp.SimplifyPath(outline, ep) + path := rdp.SimplifyPath(outline, linePrecision) geomPath := make([]geom3d.Point, len(path)) for i, p := range path {