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

feat: polish for first pass of svg creator

Includes: handling postcards with no back, commented out (!) code for not-quite-working bezier curves, ensure ignored transparency images have no transparency enocded

+170 -88
+1
cmd/postcards/main.go
··· 56 56 encOpts := formats.EncodeOptions{ 57 57 Archival: archival, 58 58 IncludeSupportFiles: incSupportFiles, 59 + NoTransparency: ignoreTransparency && !removeBorder, 59 60 } 60 61 61 62 bundles, err := postcards.MakeBundles(inputPaths)
+3 -2
formats/web/codec.go
··· 32 32 type capabilities struct { 33 33 lossless bool 34 34 transparency bool 35 + masking bool 35 36 } 36 37 37 38 func (c capabilities) String() string { ··· 57 58 "jpeg": {}, 58 59 "webp": {lossless: true, transparency: true}, 59 60 "png": {lossless: true, transparency: true}, 60 - "svg": {transparency: true}, 61 + "svg": {transparency: true, masking: true}, 61 62 } 62 63 63 64 // Only returns true if the capabilities on struct owning this method meet the needs of the provided capabilities object. ··· 78 79 return codec{formats: fmts}, nil 79 80 } 80 81 81 - var DefaultCodec, _ = Codec("jpeg", "webp") 82 + var DefaultCodec, _ = Codec("jpeg", "webp", "svg") 82 83 var SVGCodec, _ = Codec("svg") 83 84 84 85 func (c codec) Name() string { return codecName }
+3 -2
formats/web/encode.go
··· 65 65 66 66 combinedImg := image.NewRGBA(combinedSize) 67 67 // Fill the backdrop with the card colour if we're ignoring transparency 68 - if opts.IgnoreTransparency() { 68 + // or where the transparency is completed as a mask 69 + if opts.IgnoreTransparency() || formatCapabilities[format].masking { 69 70 bg := &image.Uniform{pc.Meta.Physical.GetCardColor()} 70 71 draw.Draw(combinedImg, combinedImg.Bounds(), bg, image.Point{}, draw.Src) 71 72 } ··· 91 92 case "jpeg": 92 93 err = images.WriteJPEG(w, combinedImg, xmpData) 93 94 case "svg": 94 - err = writeSVG(w, pc, combinedImg, xmpData) 95 + err = writeSVG(w, pc, combinedImg, xmpData, pc.Meta.HasTransparency) 95 96 default: 96 97 err = fmt.Errorf("unsupported output image format: %s", format) 97 98 }
+17 -6
formats/web/postcard.svg.qtpl
··· 1 1 {% func SVG(v svgVars) %} 2 2 <svg 3 3 xmlns="http://www.w3.org/2000/svg" 4 - {% if v.size.HasPhysical() %} 5 - {% code 4 + {%- code 5 + h := v.size.PxHeight 6 + if v.hasBack { 7 + h *= 2 8 + } 9 + -%} 10 + {%- if v.size.HasPhysical() -%} 11 + {%- code 6 12 cmW, cmH := v.size.MustPhysical() 7 - %} 13 + if v.hasBack { 14 + cmH *= 2 15 + } 16 + -%} 8 17 width="{%f.2 cmW %}cm" 9 - height="{%f.2 cmH * 2 %}cm" 10 - {% endif %} 11 - viewBox="0 0 {%d v.size.PxWidth %} {%d v.size.PxHeight * 2 %}" 18 + height="{%f.2 cmH %}cm" 19 + {%- endif -%} 20 + viewBox="0 0 {%d v.size.PxWidth %} {%d h %}" 12 21 version="1.1" 13 22 > 14 23 <defs> 15 24 <clipPath id="edge"> 16 25 <path d="{%s pointsToPath(v.frontPoints, v.size.PxWidth, v.size.PxHeight, false) %}"/> 26 + {%- if v.hasBack -%} 17 27 <path d="{%s pointsToPath(v.backPoints, v.size.PxWidth, v.size.PxHeight, true) %}"/> 28 + {%- endif -%} 18 29 </clipPath> 19 30 </defs> 20 31 <image
+59 -47
formats/web/postcard.svg.qtpl.go
··· 23 23 qw422016.N().S(` 24 24 <svg 25 25 xmlns="http://www.w3.org/2000/svg" 26 - `) 27 - //line postcard.svg.qtpl:4 26 + `) 27 + //line postcard.svg.qtpl:5 28 + h := v.size.PxHeight 29 + if v.hasBack { 30 + h *= 2 31 + } 32 + 33 + //line postcard.svg.qtpl:10 28 34 if v.size.HasPhysical() { 29 - //line postcard.svg.qtpl:4 30 - qw422016.N().S(` 31 - `) 32 - //line postcard.svg.qtpl:6 35 + //line postcard.svg.qtpl:12 33 36 cmW, cmH := v.size.MustPhysical() 37 + if v.hasBack { 38 + cmH *= 2 39 + } 34 40 35 - //line postcard.svg.qtpl:7 36 - qw422016.N().S(` 37 - width="`) 38 - //line postcard.svg.qtpl:8 41 + //line postcard.svg.qtpl:16 42 + qw422016.N().S(` width="`) 43 + //line postcard.svg.qtpl:17 39 44 qw422016.N().FPrec(cmW, 2) 40 - //line postcard.svg.qtpl:8 45 + //line postcard.svg.qtpl:17 41 46 qw422016.N().S(`cm" 42 47 height="`) 43 - //line postcard.svg.qtpl:9 44 - qw422016.N().FPrec(cmH*2, 2) 45 - //line postcard.svg.qtpl:9 48 + //line postcard.svg.qtpl:18 49 + qw422016.N().FPrec(cmH, 2) 50 + //line postcard.svg.qtpl:18 46 51 qw422016.N().S(`cm" 47 - `) 48 - //line postcard.svg.qtpl:10 52 + `) 53 + //line postcard.svg.qtpl:19 49 54 } 50 - //line postcard.svg.qtpl:10 51 - qw422016.N().S(` 52 - viewBox="0 0 `) 53 - //line postcard.svg.qtpl:11 55 + //line postcard.svg.qtpl:19 56 + qw422016.N().S(` viewBox="0 0 `) 57 + //line postcard.svg.qtpl:20 54 58 qw422016.N().D(v.size.PxWidth) 55 - //line postcard.svg.qtpl:11 59 + //line postcard.svg.qtpl:20 56 60 qw422016.N().S(` `) 57 - //line postcard.svg.qtpl:11 58 - qw422016.N().D(v.size.PxHeight * 2) 59 - //line postcard.svg.qtpl:11 61 + //line postcard.svg.qtpl:20 62 + qw422016.N().D(h) 63 + //line postcard.svg.qtpl:20 60 64 qw422016.N().S(`" 61 65 version="1.1" 62 66 > 63 67 <defs> 64 68 <clipPath id="edge"> 65 69 <path d="`) 66 - //line postcard.svg.qtpl:16 70 + //line postcard.svg.qtpl:25 67 71 qw422016.E().S(pointsToPath(v.frontPoints, v.size.PxWidth, v.size.PxHeight, false)) 68 - //line postcard.svg.qtpl:16 72 + //line postcard.svg.qtpl:25 69 73 qw422016.N().S(`"/> 70 - <path d="`) 71 - //line postcard.svg.qtpl:17 72 - qw422016.E().S(pointsToPath(v.backPoints, v.size.PxWidth, v.size.PxHeight, true)) 73 - //line postcard.svg.qtpl:17 74 - qw422016.N().S(`"/> 75 - </clipPath> 74 + `) 75 + //line postcard.svg.qtpl:26 76 + if v.hasBack { 77 + //line postcard.svg.qtpl:26 78 + qw422016.N().S(` <path d="`) 79 + //line postcard.svg.qtpl:27 80 + qw422016.E().S(pointsToPath(v.backPoints, v.size.PxWidth, v.size.PxHeight, true)) 81 + //line postcard.svg.qtpl:27 82 + qw422016.N().S(`"/> 83 + `) 84 + //line postcard.svg.qtpl:28 85 + } 86 + //line postcard.svg.qtpl:28 87 + qw422016.N().S(` </clipPath> 76 88 </defs> 77 89 <image 78 90 x="0" ··· 82 94 preserveAspectRatio="none" 83 95 clip-path="url(#edge)" 84 96 href="data:image/jpeg;base64,`) 85 - //line postcard.svg.qtpl:27 97 + //line postcard.svg.qtpl:38 86 98 qw422016.E().S(v.b64Img) 87 - //line postcard.svg.qtpl:27 99 + //line postcard.svg.qtpl:38 88 100 qw422016.N().S(`" 89 101 /> 90 102 </svg> 91 103 `) 92 - //line postcard.svg.qtpl:30 104 + //line postcard.svg.qtpl:41 93 105 } 94 106 95 - //line postcard.svg.qtpl:30 107 + //line postcard.svg.qtpl:41 96 108 func WriteSVG(qq422016 qtio422016.Writer, v svgVars) { 97 - //line postcard.svg.qtpl:30 109 + //line postcard.svg.qtpl:41 98 110 qw422016 := qt422016.AcquireWriter(qq422016) 99 - //line postcard.svg.qtpl:30 111 + //line postcard.svg.qtpl:41 100 112 StreamSVG(qw422016, v) 101 - //line postcard.svg.qtpl:30 113 + //line postcard.svg.qtpl:41 102 114 qt422016.ReleaseWriter(qw422016) 103 - //line postcard.svg.qtpl:30 115 + //line postcard.svg.qtpl:41 104 116 } 105 117 106 - //line postcard.svg.qtpl:30 118 + //line postcard.svg.qtpl:41 107 119 func SVG(v svgVars) string { 108 - //line postcard.svg.qtpl:30 120 + //line postcard.svg.qtpl:41 109 121 qb422016 := qt422016.AcquireByteBuffer() 110 - //line postcard.svg.qtpl:30 122 + //line postcard.svg.qtpl:41 111 123 WriteSVG(qb422016, v) 112 - //line postcard.svg.qtpl:30 124 + //line postcard.svg.qtpl:41 113 125 qs422016 := string(qb422016.B) 114 - //line postcard.svg.qtpl:30 126 + //line postcard.svg.qtpl:41 115 127 qt422016.ReleaseByteBuffer(qb422016) 116 - //line postcard.svg.qtpl:30 128 + //line postcard.svg.qtpl:41 117 129 return qs422016 118 - //line postcard.svg.qtpl:30 130 + //line postcard.svg.qtpl:41 119 131 }
+87 -31
formats/web/svg.go
··· 3 3 import ( 4 4 "bytes" 5 5 "encoding/base64" 6 - "encoding/json" 7 6 "fmt" 8 7 "image" 9 8 "io" ··· 21 20 b64Img string 22 21 frontPoints []geom3d.Point 23 22 backPoints []geom3d.Point 24 - metadata []byte 23 + hasBack bool 25 24 } 26 25 27 26 // This keeps the whole JPEG in memory. TODO: Figure out how to do this while streaming 28 - func writeSVG(w io.Writer, pc types.Postcard, combinedImg image.Image, xmpData []byte) error { 29 - frontPoints, err := images.Outline(pc.Front, false, false) 30 - if err != nil { 31 - return fmt.Errorf("front image can't be outlined: %w", err) 27 + func writeSVG(w io.Writer, pc types.Postcard, combinedImg image.Image, xmpData []byte, hasTransparency bool) error { 28 + v := svgVars{ 29 + size: pc.Meta.Physical.FrontDimensions, 30 + hasBack: pc.Back != nil, 32 31 } 33 - // TODO: Handle no back 34 - backPoints, err := images.Outline(pc.Back, false, false) 35 - if err != nil { 36 - return fmt.Errorf("back image can't be outlined: %w", err) 32 + 33 + if hasTransparency { 34 + frontPoints, err := images.Outline(pc.Front, false, false) 35 + if err != nil { 36 + return fmt.Errorf("front image can't be outlined: %w", err) 37 + } 38 + v.frontPoints = frontPoints 39 + 40 + if v.hasBack { 41 + backPoints, err := images.Outline(pc.Back, false, false) 42 + if err != nil { 43 + return fmt.Errorf("back image can't be outlined: %w", err) 44 + } 45 + v.backPoints = backPoints 46 + } 37 47 } 38 48 39 49 var buf bytes.Buffer ··· 41 51 if err := images.WriteJPEG(b64W, combinedImg, xmpData); err != nil { 42 52 return err 43 53 } 54 + v.b64Img = buf.String() 44 55 45 - meta, err := json.Marshal(pc.Meta) 46 - if err != nil { 47 - return err 48 - } 56 + WriteSVG(w, v) 57 + return nil 58 + } 49 59 50 - v := svgVars{ 51 - size: pc.Meta.Physical.FrontDimensions, 52 - b64Img: buf.String(), 53 - frontPoints: frontPoints, 54 - backPoints: backPoints, 55 - metadata: meta, 56 - } 60 + // TODO: Figure out if bezier curves are worth it 61 + // const ( 62 + // bezierTension = 1.0 / 6.0 63 + // smoothUnderAngle = 45 64 + // ) 57 65 58 - WriteSVG(w, v) 59 - return nil 66 + type bezierPoint struct { 67 + px, py float64 68 + // TODO: Figure out if bezier curves are worth it 69 + // c1x, c1y float64 70 + // c2x, c2y float64 60 71 } 61 72 62 73 func pointsToPath(points []geom3d.Point, w, h int, back bool) string { ··· 65 76 offsetH = h 66 77 } 67 78 68 - var path strings.Builder 79 + bp := make([]bezierPoint, len(points)) 69 80 for i, p := range points { 81 + bp[i].px = p.X * float64(w) 82 + bp[i].py = p.Y*float64(h) + float64(offsetH) 83 + } 84 + // TODO: Figure out if bezier curves are worth it 85 + // for i, p := range bp { 86 + // p0, p1, p2, p3 := bp[(i-1+len(bp))%len(bp)], p, bp[(i+1)%len(bp)], bp[(i+2)%len(bp)] 87 + 88 + // angle := calcAngle(p0, p1, p2) 89 + // if angle < smoothUnderAngle { 90 + // bp[i].c1x = p1.px + (p2.px-p0.px)*bezierTension 91 + // bp[i].c1y = p1.py + (p2.py-p0.py)*bezierTension 92 + // bp[i].c2x = p2.px + (p3.px-p1.px)*bezierTension 93 + // bp[i].c2y = p2.py + (p3.py-p1.py)*bezierTension 94 + // } 95 + // } 96 + 97 + var path strings.Builder 98 + for i, p := range bp { 70 99 if i == 0 { 71 - path.WriteString("M") 100 + path.WriteString(fmt.Sprintf( 101 + "M%.1f %.1f", 102 + p.px, p.py, 103 + )) 104 + // TODO: Figure out if bezier curves are worth it 105 + // } else if p.c1x != 0 && p.c1y != 0 && p.c2x != 0 && p.c2y != 0 { 106 + // path.WriteString(fmt.Sprintf( 107 + // "C%.1f %.1f,%.1f %.1f,%.1f %.1f", 108 + // p.c1x, p.c1y, 109 + // p.c2x, p.c2y, 110 + // p.px, p.py, 111 + // )) 72 112 } else { 73 - path.WriteString("L") 113 + path.WriteString(fmt.Sprintf( 114 + "L%.1f %.1f", 115 + p.px, p.py, 116 + )) 74 117 } 75 - x := p.X * float64(w) 76 - y := p.Y*float64(h) + float64(offsetH) 77 - path.WriteString(fmt.Sprintf("%.1f %.1f", x, y)) 78 - 79 118 } 80 - path.WriteString("Z") 81 119 return path.String() 82 120 } 121 + 122 + // TODO: Figure out if bezier curves are worth it 123 + // func calcAngle(p0, p1, p2 bezierPoint) float64 { 124 + // v1x, v1y := p1.px-p0.px, p1.py-p0.py 125 + // v2x, v2y := p2.px-p1.px, p2.py-p1.py 126 + 127 + // dot := v1x*v2x + v1y*v2y 128 + // mag1 := math.Hypot(v1x, v1y) 129 + // mag2 := math.Hypot(v2x, v2y) 130 + 131 + // // Points are on top of each other, treat as straight 132 + // if mag1 == 0 || mag2 == 0 { 133 + // return 180 134 + // } 135 + 136 + // cosTheta := dot / (mag1 * mag2) 137 + // return 180 * math.Acos(math.Max(-1, math.Min(1, cosTheta))) / math.Pi 138 + // }