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) %}
+
+{% 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: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 {