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

Expand XMP decoder

+615 -273
-24
formats/xmp/codec.go
··· 2 2 3 3 import ( 4 4 "bytes" 5 - "fmt" 6 5 "io" 7 6 "io/fs" 8 7 "path" 9 8 10 9 "github.com/jphastings/dotpostcard/formats" 11 - "github.com/jphastings/dotpostcard/types" 12 10 ) 13 11 14 12 const codecName = "XMP Metadata" ··· 46 44 } 47 45 48 46 return bundles, remaining, nil 49 - } 50 - 51 - func (c codec) Encode(pc types.Postcard, _ *formats.EncodeOptions) ([]formats.FileWriter, error) { 52 - filename := fmt.Sprintf("%s-meta.xmp", pc.Name) 53 - writer := func(w io.Writer) error { 54 - // Don't write pixel & physical size information to an XMP which isn't embedded 55 - if xmp, err := MetadataToXMP(pc.Meta, nil); err == nil { 56 - _, writeErr := w.Write(xmp) 57 - return writeErr 58 - } else { 59 - return err 60 - } 61 - } 62 - fw := formats.NewFileWriter(filename, writer) 63 - 64 - return []formats.FileWriter{fw}, nil 65 - } 66 - 67 - func (b bundle) Decode(_ *formats.DecodeOptions) (types.Postcard, error) { 68 - meta, _, err := MetadataFromXMP(b.r) 69 - // TODO: How do I get the name here? 70 - return types.Postcard{Meta: meta}, err 71 47 } 72 48 73 49 func (b bundle) RefPath() string {
+3 -4
formats/xmp/codec_test.go
··· 42 42 } 43 43 44 44 func TestDecode(t *testing.T) { 45 + ex := testhelpers.SamplePostcard 45 46 bnd := bundle{r: bytes.NewReader(testhelpers.SampleXMP)} 46 47 47 48 pc, err := bnd.Decode(nil) 48 - _ = pc 49 - assert.Error(t, err, "decoding is not yet implemented") 49 + assert.NoError(t, err) 50 50 51 - // assert.NoError(t, err) 52 - // assert.Equal(t, testhelpers.SamplePostcard.Meta, pc.Meta) 51 + assert.Equal(t, ex.Meta, pc.Meta) 53 52 }
+217
formats/xmp/decode.go
··· 1 + package xmp 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "io" 7 + "math/big" 8 + "strconv" 9 + "time" 10 + 11 + "github.com/jphastings/dotpostcard/formats" 12 + "github.com/jphastings/dotpostcard/types" 13 + "github.com/trimmer-io/go-xmp/xmp" 14 + ) 15 + 16 + func (b bundle) Decode(_ *formats.DecodeOptions) (types.Postcard, error) { 17 + meta, _, err := MetadataFromXMP(b.r) 18 + // TODO: How do I get the name here? 19 + return types.Postcard{Meta: meta}, err 20 + } 21 + 22 + type xmpAlt struct { 23 + Lang string `json:"lang"` 24 + Value string `json:"value"` 25 + } 26 + 27 + type xmpRegion struct { 28 + Names []xmpAlt `json:"Iptc4xmpExt:Name"` 29 + Boundary struct { 30 + Shape string `json:"Iptc4xmpExt:rbShape"` 31 + Unit string `json:"Iptc4xmpExt:rbUnit"` 32 + Vertices []struct { 33 + X string `json:"Iptc4xmpExt:rbX"` 34 + Y string `json:"Iptc4xmpExt:rbY"` 35 + } `json:"Iptc4xmpExt:rbVertices"` 36 + } `json:"Iptc4xmpExt:RegionBoundary"` 37 + } 38 + 39 + type xmpJSON struct { 40 + Models struct { 41 + Iptc4xmpExt struct { 42 + Regions []xmpRegion `json:"Iptc4xmpExt:ImageRegion"` 43 + } `json:"Iptc4xmpExt"` 44 + Postcard struct { 45 + Context []xmpAlt `json:"Postcard:Context"` 46 + ContextAuthor string `json:"Postcard:ContextAuthor"` 47 + DescriptionFront string `json:"Postcard:DescriptionFront"` 48 + DescriptionBack string `json:"Postcard:DescriptionBack"` 49 + Flip types.Flip `json:"Postcard:Flip"` 50 + Sender string `json:"Postcard:Sender"` 51 + Recipient string `json:"Postcard:Recipient"` 52 + TranscriptionFront string `json:"Postcard:TranscriptionFront"` 53 + TranscriptionBack string `json:"Postcard:TranscriptionBack"` 54 + PhysicalThicknessMM string `json:"Postcard:PhysicalThicknessMM"` 55 + } `json:"Postcard"` 56 + EXIF struct { 57 + Date string `json:"exif:DateTimeOriginal"` 58 + LocationName string `json:"exif:GPSAreaInformation"` 59 + Latitude string `json:"exif:GPSLatitude"` 60 + Longitude string `json:"exif:GPSLongitude"` 61 + } `json:"exif"` 62 + } `json:"models"` 63 + } 64 + 65 + func MetadataFromXMP(r io.Reader) (types.Metadata, types.Size, error) { 66 + d := xmp.NewDecoder(r) 67 + doc := &xmp.Document{} 68 + if err := d.Decode(doc); err != nil { 69 + return types.Metadata{}, types.Size{}, err 70 + } 71 + 72 + jb, err := doc.MarshalJSON() 73 + if err != nil { 74 + return types.Metadata{}, types.Size{}, fmt.Errorf("unable to parse contents of XMP: %w", err) 75 + } 76 + 77 + var js xmpJSON 78 + if err := json.Unmarshal(jb, &js); err != nil { 79 + return types.Metadata{}, types.Size{}, fmt.Errorf("unable to parse contents of JSONified XMP: %w", err) 80 + } 81 + 82 + var meta types.Metadata 83 + 84 + if len(js.Models.Postcard.Context) > 0 { 85 + meta.Locale = js.Models.Postcard.Context[0].Lang 86 + meta.Context.Description = js.Models.Postcard.Context[0].Value 87 + meta.Context.Author = scanPerson(js.Models.Postcard.ContextAuthor) 88 + } 89 + 90 + meta.Flip = js.Models.Postcard.Flip 91 + meta.Sender = scanPerson(js.Models.Postcard.Sender) 92 + meta.Recipient = scanPerson(js.Models.Postcard.Recipient) 93 + meta.Front.Description = js.Models.Postcard.DescriptionFront 94 + meta.Back.Description = js.Models.Postcard.DescriptionBack 95 + if thick, err := strconv.ParseFloat(js.Models.Postcard.PhysicalThicknessMM, 64); err == nil { 96 + meta.Physical.ThicknessMM = thick 97 + } 98 + 99 + json.Unmarshal([]byte(js.Models.Postcard.TranscriptionFront), &meta.Front.Transcription) 100 + json.Unmarshal([]byte(js.Models.Postcard.TranscriptionBack), &meta.Back.Transcription) 101 + 102 + if date, err := time.Parse(`2006-01-02`, js.Models.EXIF.Date); err == nil { 103 + meta.SentOn = types.Date{Time: date} 104 + } 105 + meta.Location.Name = js.Models.EXIF.LocationName 106 + meta.Location.Latitude = scanDegrees(js.Models.EXIF.Latitude) 107 + meta.Location.Longitude = scanDegrees(js.Models.EXIF.Longitude) 108 + 109 + front, back := extractSecrets(meta.Flip, js.Models.Iptc4xmpExt.Regions) 110 + meta.Front.Secrets = front 111 + meta.Back.Secrets = back 112 + 113 + xPaths, err := doc.ListPaths() 114 + if err != nil { 115 + return types.Metadata{}, types.Size{}, err 116 + } 117 + 118 + var size types.Size 119 + // X, Y, scaling factor 120 + var resolution [3]*big.Rat 121 + 122 + for _, xPath := range xPaths { 123 + switch xPath.Path { 124 + case "tiff:ImageWidth": 125 + size.PxWidth, _ = strconv.Atoi(xPath.Value) 126 + case "tiff:ImageLength": 127 + size.PxHeight, _ = strconv.Atoi(xPath.Value) 128 + case "tiff:XResolution": 129 + res, err := scanBigRat(xPath.Value) 130 + if err == nil { 131 + resolution[0] = res 132 + } 133 + case "tiff:YResolution": 134 + res, err := scanBigRat(xPath.Value) 135 + if err == nil { 136 + resolution[1] = res 137 + } 138 + case "tiff:ResolutionUnit": 139 + if xPath.Value != "3" { 140 + // Assume inches 141 + resolution[2] = big.NewRat(100, 254) 142 + } 143 + } 144 + } 145 + 146 + if meta.Flip != types.FlipNone { 147 + size.PxHeight /= 2 148 + } 149 + 150 + if resolution[0] != nil { 151 + // Convert units, if necessary 152 + if resolution[2] != nil { 153 + resolution[0].Mul(resolution[0], resolution[2]) 154 + resolution[1].Mul(resolution[1], resolution[2]) 155 + } 156 + 157 + size.SetResolution(resolution[0], resolution[1]) 158 + // Postcard height is half reported dimensions if Flip isn't none (ie. this is a stacked web format postcard image) 159 + if meta.Flip != types.FlipNone { 160 + size.CmHeight.Quo(size.CmHeight, big.NewRat(2, 1)) 161 + } 162 + meta.Physical.FrontDimensions = size 163 + } 164 + 165 + return meta, size, nil 166 + } 167 + 168 + func scanBigRat(str string) (*big.Rat, error) { 169 + var a, b int64 170 + if _, err := fmt.Sscanf(str, "%d/%d", &a, &b); err != nil { 171 + return nil, err 172 + } 173 + return big.NewRat(a, b), nil 174 + } 175 + 176 + func scanDegrees(str string) *float64 { 177 + var deg int 178 + var min float64 179 + dir := str[len(str)-1:] 180 + num := str[:len(str)-1] 181 + 182 + if _, err := fmt.Sscanf(num, "%d,%f", &deg, &min); err != nil { 183 + return nil 184 + } 185 + 186 + fl := float64(deg) + min/60 187 + switch dir { 188 + case "N": 189 + if fl > 90 || fl < 0 { 190 + return nil 191 + } 192 + case "E": 193 + if fl > 180 || fl < 0 { 194 + return nil 195 + } 196 + case "S": 197 + if fl > 90 || fl < 0 { 198 + return nil 199 + } 200 + fl *= -1 201 + case "W": 202 + if fl > 180 || fl < 0 { 203 + return nil 204 + } 205 + fl *= -1 206 + default: 207 + return nil 208 + } 209 + 210 + return &fl 211 + } 212 + 213 + func scanPerson(str string) types.Person { 214 + var p types.Person 215 + p.Scan(str) 216 + return p 217 + }
+39
formats/xmp/decode_test.go
··· 1 + package xmp 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/assert" 7 + ) 8 + 9 + func Test_scanDegrees(t *testing.T) { 10 + cases := []struct { 11 + name string 12 + str string 13 + wantNil bool 14 + wantFloat float64 15 + }{ 16 + {"North", "45,16.83364010N", false, 45.28056066829611}, 17 + {"East", "7,39.81691860E", false, 7.663615309995059}, 18 + {"South", "2,3.45S", false, -2.0575}, 19 + {"West", "6,7.89W", false, -6.1315}, 20 + 21 + {"Invalid North over", "91,0.0N", true, 0}, 22 + {"Invalid North over", "90,0.001N", true, 0}, 23 + {"Invalid North under", "-1,0.0N", true, 0}, 24 + {"Invalid North under", "0,-0.01N", true, 0}, 25 + } 26 + 27 + for _, c := range cases { 28 + t.Run(c.name, func(t *testing.T) { 29 + fl := scanDegrees(c.str) 30 + if c.wantNil { 31 + assert.Nil(t, fl) 32 + return 33 + } 34 + 35 + assert.NotNil(t, fl) 36 + assert.InDelta(t, c.wantFloat, *fl, 0.0000000001) 37 + }) 38 + } 39 + }
+68
formats/xmp/encode.go
··· 1 + package xmp 2 + 3 + import ( 4 + "bytes" 5 + "encoding/xml" 6 + "fmt" 7 + "io" 8 + 9 + "github.com/jphastings/dotpostcard/formats" 10 + "github.com/jphastings/dotpostcard/internal/general" 11 + "github.com/jphastings/dotpostcard/types" 12 + ) 13 + 14 + func (c codec) Encode(pc types.Postcard, _ *formats.EncodeOptions) ([]formats.FileWriter, error) { 15 + filename := fmt.Sprintf("%s-meta.xmp", pc.Name) 16 + writer := func(w io.Writer) error { 17 + // Don't write pixel & physical size information to an XMP which isn't embedded 18 + if xmp, err := MetadataToXMP(pc.Meta, nil); err == nil { 19 + _, writeErr := w.Write(xmp) 20 + return writeErr 21 + } else { 22 + return err 23 + } 24 + } 25 + fw := formats.NewFileWriter(filename, writer) 26 + 27 + return []formats.FileWriter{fw}, nil 28 + } 29 + 30 + func MetadataToXMP(meta types.Metadata, dims *types.Size) ([]byte, error) { 31 + var sections []interface{} 32 + if dims != nil { 33 + sections = addTIFFSection(sections, *dims) 34 + } 35 + sections = addIPTCCoreSection(sections, meta) 36 + sections = addIPTCExtSection(sections, meta) 37 + sections = addExifSection(sections, meta) 38 + sections = addDCSection(sections, meta) 39 + sections = addPostcardSection(sections, meta) 40 + 41 + x := xmpXML{ 42 + NamespaceX: "adobe:ns:meta/", 43 + NamespaceXMPTK: fmt.Sprintf("postcards/v%s", general.Version), 44 + RDF: rdfXML{ 45 + Namespace: "http://www.w3.org/1999/02/22-rdf-syntax-ns#", 46 + Sections: sections, 47 + }, 48 + } 49 + 50 + d := &bytes.Buffer{} 51 + 52 + // Intro 53 + if _, err := d.Write([]byte("<?xpacket begin='' id='W5M0MpCehiHzreSzNTczkc9d'?>")); err != nil { 54 + return nil, fmt.Errorf("unable to write start of XMP XML data: %w", err) 55 + } 56 + 57 + // XML 58 + if err := xml.NewEncoder(d).Encode(x); err != nil { 59 + return nil, fmt.Errorf("unable to write XMP XML data: %w", err) 60 + } 61 + 62 + // Outro 63 + if _, err := d.Write([]byte("<?xpacket end='w'?>")); err != nil { 64 + return nil, fmt.Errorf("unable to write end of XMP XML data: %w", err) 65 + } 66 + 67 + return d.Bytes(), nil 68 + }
+1 -3
formats/xmp/iptccore.go
··· 1 1 package xmp 2 2 3 3 import ( 4 - "strings" 5 - 6 4 "github.com/jphastings/dotpostcard/formats" 7 5 "github.com/jphastings/dotpostcard/types" 8 6 ) ··· 17 15 return sections 18 16 } 19 17 20 - text, lang := formats.AltText(meta, strings.Split(meta.Locale, "-")[0]) 18 + text, lang := formats.AltText(meta, meta.Locale) 21 19 22 20 return append(sections, xmpIptc4xmpCoreXML{ 23 21 Namespace: "http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/",
+19 -14
formats/xmp/iptcext.go
··· 1 1 package xmp 2 2 3 3 import ( 4 - "strings" 5 - 6 4 "github.com/jphastings/dotpostcard/types" 7 5 ) 8 6 ··· 44 42 return sections 45 43 } 46 44 47 - var regions []iptcRegion 48 - 49 - prvExp := langText{Lang: strings.Split(meta.Locale, "-")[0]} 45 + prvExp := langText{Lang: meta.Locale} 50 46 if text, ok := privateExplainer[prvExp.Lang]; ok { 51 47 prvExp.Text = text 52 48 } else { ··· 54 50 prvExp.Text = privateExplainer["en"] 55 51 } 56 52 57 - for _, secret := range append(meta.Front.Secrets, meta.Back.Secrets...) { 53 + var regions []iptcRegion 54 + regions = append(regions, regionsForSide(prvExp, true, meta.Flip, meta.Front.Secrets)...) 55 + regions = append(regions, regionsForSide(prvExp, false, meta.Flip, meta.Back.Secrets)...) 56 + 57 + return append(sections, xmpIptc4xmpExt{ 58 + Namespace: "http://iptc.org/std/Iptc4xmpExt/2008-02-29/", 59 + Regions: regions, 60 + }) 61 + } 62 + 63 + func regionsForSide(prvExp langText, onFront bool, flip types.Flip, secrets []types.Polygon) []iptcRegion { 64 + var regions []iptcRegion 65 + for _, secret := range secrets { 58 66 var vertices []iptcRegionVertex 59 67 60 68 for _, point := range secret.Points { 61 - vertices = append(vertices, iptcRegionVertex{ParseType: "Resource", X: point.X, Y: point.Y}) 69 + p := point.TransformToDoubleSided(onFront, flip) 70 + vertices = append(vertices, iptcRegionVertex{ParseType: "Resource", X: p.X, Y: p.Y}) 62 71 } 63 72 64 - r := iptcRegion{ 73 + regions = append(regions, iptcRegion{ 65 74 ParseType: "Resource", 66 75 Name: prvExp, 67 76 Boundary: iptcRegionBoundary{ ··· 70 79 Shape: "polygon", 71 80 Vertices: vertices, 72 81 }, 73 - } 74 - regions = append(regions, r) 82 + }) 75 83 } 76 84 77 - return append(sections, xmpIptc4xmpExt{ 78 - Namespace: "http://iptc.org/std/Iptc4xmpExt/2008-02-29/", 79 - Regions: regions, 80 - }) 85 + return regions 81 86 }
+15 -14
formats/xmp/postcard.go
··· 1 1 package xmp 2 2 3 3 import ( 4 - "strings" 5 - 6 4 "github.com/jphastings/dotpostcard/types" 7 5 ) 8 6 9 7 type xmpPostcard struct { 10 - Namespace string `xml:"xmlns:Postcard,attr"` 11 - Flip types.Flip `xml:"Postcard:Flip"` 12 - Sender types.Person `xml:"Postcard:Sender,omitempty"` 13 - Recipient types.Person `xml:"Postcard:Recipient,omitempty"` 14 - Context langText `xml:"Postcard:Context,omitempty"` 15 - ContextAuthor types.Person `xml:"Postcard:ContextAuthor,omitempty"` 16 - DescriptionFront string `xml:"Postcard:DescriptionFront,omitempty"` 17 - DescriptionBack string `xml:"Postcard:DescriptionBack,omitempty"` 18 - TranscriptionFront types.AnnotatedText `xml:"Postcard:TranscriptionFront,omitempty"` 19 - TranscriptionBack types.AnnotatedText `xml:"Postcard:TranscriptionBack,omitempty"` 8 + Namespace string `xml:"xmlns:Postcard,attr"` 9 + Flip types.Flip `xml:"Postcard:Flip"` 10 + Sender types.Person `xml:"Postcard:Sender,omitempty"` 11 + Recipient types.Person `xml:"Postcard:Recipient,omitempty"` 12 + Context langText `xml:"Postcard:Context>rdf:Alt>rdf:li,omitempty"` 13 + ContextAuthor types.Person `xml:"Postcard:ContextAuthor,omitempty"` 14 + DescriptionFront string `xml:"Postcard:DescriptionFront,omitempty"` 15 + DescriptionBack string `xml:"Postcard:DescriptionBack,omitempty"` 16 + TranscriptionFront types.AnnotatedText `xml:"Postcard:TranscriptionFront,omitempty"` 17 + TranscriptionBack types.AnnotatedText `xml:"Postcard:TranscriptionBack,omitempty"` 18 + PhysicalThicknessMM float64 `xml:"Postcard:PhysicalThicknessMM,omitempty"` 20 19 } 21 20 22 21 func addPostcardSection(sections []interface{}, meta types.Metadata) []interface{} { ··· 30 29 DescriptionBack: meta.Back.Description, 31 30 TranscriptionFront: meta.Front.Transcription, 32 31 TranscriptionBack: meta.Back.Transcription, 32 + 33 + PhysicalThicknessMM: meta.Physical.ThicknessMM, 33 34 } 34 35 35 - if meta.Context.Description != "" { 36 + if meta.Context.Description != "" || meta.Locale != "" { 36 37 xmp.Context = langText{ 37 38 Text: meta.Context.Description, 38 - Lang: strings.Split(meta.Locale, "-")[0], 39 + Lang: meta.Locale, 39 40 } 40 41 xmp.ContextAuthor = meta.Context.Author 41 42 }
+65
formats/xmp/regions.go
··· 1 + package xmp 2 + 3 + import ( 4 + "strconv" 5 + 6 + "github.com/jphastings/dotpostcard/types" 7 + ) 8 + 9 + func extractSecrets(flip types.Flip, regions []xmpRegion) (front, back []types.Polygon) { 10 + for _, region := range regions { 11 + // Secrets are alwats polygonal & relative shapes, so skip any others 12 + if region.Boundary.Shape != "polygon" || region.Boundary.Unit != "relative" { 13 + continue 14 + } 15 + 16 + // Secret regions have specific names (in various languages), skip any that don't have the right name 17 + isSecretRegion := false 18 + for _, n := range region.Names { 19 + // Check for any of the indicators that this is a secret region 20 + if val, ok := privateExplainer[n.Lang]; ok && val == n.Value { 21 + isSecretRegion = true 22 + break 23 + } 24 + } 25 + if !isSecretRegion { 26 + continue 27 + } 28 + 29 + regionOnFront := true 30 + poly := types.Polygon{Prehidden: true} 31 + for i, vert := range region.Boundary.Vertices { 32 + x, xErr := strconv.ParseFloat(vert.X, 64) 33 + y, yErr := strconv.ParseFloat(vert.Y, 64) 34 + if xErr != nil || yErr != nil { 35 + poly.Points = nil 36 + break 37 + } 38 + 39 + // Make sure the region is only one one side of the card (if it's a double sided card) 40 + if flip != types.FlipNone { 41 + vertOnFront := y < 0.5 42 + if i == 0 { 43 + regionOnFront = vertOnFront 44 + } else if vertOnFront != regionOnFront { 45 + poly.Points = nil 46 + break 47 + } 48 + } 49 + 50 + point := types.Point{X: x, Y: y}.TransformToSingleSided(regionOnFront, flip) 51 + poly.Points = append(poly.Points, point) 52 + } 53 + if poly.Points == nil { 54 + continue 55 + } 56 + 57 + if regionOnFront { 58 + front = append(front, poly) 59 + } else { 60 + back = append(back, poly) 61 + } 62 + } 63 + 64 + return front, back 65 + }
-206
formats/xmp/xmp.go
··· 1 - package xmp 2 - 3 - import ( 4 - "bytes" 5 - "encoding/json" 6 - "encoding/xml" 7 - "fmt" 8 - "io" 9 - "math/big" 10 - "strconv" 11 - "time" 12 - 13 - "github.com/jphastings/dotpostcard/internal/general" 14 - "github.com/jphastings/dotpostcard/types" 15 - 16 - "github.com/trimmer-io/go-xmp/xmp" 17 - ) 18 - 19 - func MetadataToXMP(meta types.Metadata, dims *types.Size) ([]byte, error) { 20 - var sections []interface{} 21 - if dims != nil { 22 - sections = addTIFFSection(sections, *dims) 23 - } 24 - sections = addIPTCCoreSection(sections, meta) 25 - sections = addIPTCExtSection(sections, meta) 26 - sections = addExifSection(sections, meta) 27 - sections = addDCSection(sections, meta) 28 - sections = addPostcardSection(sections, meta) 29 - 30 - x := xmpXML{ 31 - NamespaceX: "adobe:ns:meta/", 32 - NamespaceXMPTK: fmt.Sprintf("postcards/v%s", general.Version), 33 - RDF: rdfXML{ 34 - Namespace: "http://www.w3.org/1999/02/22-rdf-syntax-ns#", 35 - Sections: sections, 36 - }, 37 - } 38 - 39 - d := &bytes.Buffer{} 40 - 41 - // Intro 42 - if _, err := d.Write([]byte("<?xpacket begin='' id='W5M0MpCehiHzreSzNTczkc9d'?>")); err != nil { 43 - return nil, fmt.Errorf("unable to write start of XMP XML data: %w", err) 44 - } 45 - 46 - // XML 47 - if err := xml.NewEncoder(d).Encode(x); err != nil { 48 - return nil, fmt.Errorf("unable to write XMP XML data: %w", err) 49 - } 50 - 51 - // Outro 52 - if _, err := d.Write([]byte("<?xpacket end='w'?>")); err != nil { 53 - return nil, fmt.Errorf("unable to write end of XMP XML data: %w", err) 54 - } 55 - 56 - return d.Bytes(), nil 57 - } 58 - 59 - func MetadataFromXMP(r io.Reader) (types.Metadata, types.Size, error) { 60 - d := xmp.NewDecoder(r) 61 - doc := &xmp.Document{} 62 - if err := d.Decode(doc); err != nil { 63 - return types.Metadata{}, types.Size{}, err 64 - } 65 - 66 - xPaths, err := doc.ListPaths() 67 - if err != nil { 68 - return types.Metadata{}, types.Size{}, err 69 - } 70 - 71 - var meta types.Metadata 72 - var size types.Size 73 - var resolution [3]*big.Rat 74 - 75 - for _, xPath := range xPaths { 76 - switch xPath.Path { 77 - case "Postcard:Flip": 78 - meta.Flip = types.Flip(xPath.Value) 79 - 80 - case "tiff:ImageWidth": 81 - size.PxWidth, _ = strconv.Atoi(xPath.Value) 82 - case "tiff:ImageLength": 83 - size.PxHeight, _ = strconv.Atoi(xPath.Value) 84 - case "tiff:XResolution": 85 - res, err := scanBigRat(xPath.Value) 86 - if err == nil { 87 - resolution[0] = res 88 - } 89 - case "tiff:YResolution": 90 - res, err := scanBigRat(xPath.Value) 91 - if err == nil { 92 - resolution[1] = res 93 - } 94 - case "tiff:ResolutionUnit": 95 - if xPath.Value != "3" { 96 - // Assume inches 97 - resolution[2] = big.NewRat(100, 254) 98 - } 99 - 100 - case "exif:GPSAreaInformation": 101 - meta.Location.Name = xPath.Value 102 - case "exif:GPSLatitude": 103 - meta.Location.Latitude = scanDegrees(xPath.Value) 104 - case "exif:GPSLongitude": 105 - meta.Location.Longitude = scanDegrees(xPath.Value) 106 - 107 - case "Postcard:Recipient": 108 - meta.Recipient = scanPerson(xPath.Value) 109 - case "Postcard:Sender": 110 - meta.Sender = scanPerson(xPath.Value) 111 - case "Postcard:Context": 112 - meta.Context.Description = xPath.Value 113 - case "Postcard:ContextAuthor": 114 - meta.Context.Author = scanPerson(xPath.Value) 115 - 116 - case "exif:DateTimeOriginal": 117 - if date, err := time.Parse(`2006-01-02`, xPath.Value); err == nil { 118 - meta.SentOn = types.Date{Time: date} 119 - } 120 - 121 - case "Postcard:DescriptionFront": 122 - meta.Front.Description = xPath.Value 123 - case "Postcard:DescriptionBack": 124 - meta.Back.Description = xPath.Value 125 - case "Postcard:TranscriptionFront": 126 - _ = json.Unmarshal([]byte(xPath.Value), &meta.Front.Transcription) 127 - case "Postcard:TranscriptionBack": 128 - _ = json.Unmarshal([]byte(xPath.Value), &meta.Back.Transcription) 129 - 130 - default: 131 - // TODO: secret sections 132 - // fmt.Printf("%s // %T: %v\n", xPath.Path, xPath.Value, xPath.Value) 133 - } 134 - } 135 - 136 - if meta.Flip != types.FlipNone { 137 - size.PxHeight /= 2 138 - } 139 - 140 - if resolution[0] != nil { 141 - // Convert units, if necessary 142 - if resolution[2] != nil { 143 - resolution[0].Mul(resolution[0], resolution[2]) 144 - resolution[1].Mul(resolution[1], resolution[2]) 145 - } 146 - 147 - size.SetResolution(resolution[0], resolution[1]) 148 - // Postcard height is half reported dimensions if Flip isn't none (ie. this is a stacked web format postcard image) 149 - if meta.Flip != types.FlipNone { 150 - size.CmHeight.Quo(size.CmHeight, big.NewRat(2, 1)) 151 - } 152 - meta.Physical.FrontDimensions = size 153 - } 154 - 155 - return meta, size, nil 156 - } 157 - 158 - func scanBigRat(str string) (*big.Rat, error) { 159 - var a, b int64 160 - if _, err := fmt.Sscanf(str, "%d/%d", &a, &b); err != nil { 161 - return nil, err 162 - } 163 - return big.NewRat(a, b), nil 164 - } 165 - 166 - func scanDegrees(str string) *float64 { 167 - var deg int 168 - var min float64 169 - var dir string 170 - 171 - if _, err := fmt.Sscanf(str, "%d,%f%s", &deg, &min, &dir); err != nil { 172 - return nil 173 - } 174 - 175 - fl := float64(deg) + min/60 176 - switch dir { 177 - case "N": 178 - if fl > 90 || fl < 0 { 179 - return nil 180 - } 181 - case "E": 182 - if fl > 180 || fl < 0 { 183 - return nil 184 - } 185 - case "S": 186 - if fl > 90 || fl < 0 { 187 - return nil 188 - } 189 - fl *= -1 190 - case "W": 191 - if fl > 180 || fl < 0 { 192 - return nil 193 - } 194 - fl *= -1 195 - default: 196 - return nil 197 - } 198 - 199 - return &fl 200 - } 201 - 202 - func scanPerson(str string) types.Person { 203 - var p types.Person 204 - p.Scan(str) 205 - return p 206 - }
+63 -6
internal/testhelpers/fixtures.go
··· 6 6 "image" 7 7 _ "image/png" 8 8 "math/big" 9 + "time" 9 10 10 11 "github.com/jphastings/dotpostcard/types" 11 12 ) ··· 41 42 var SamplePostcard = types.Postcard{ 42 43 Name: "some-postcard", 43 44 Meta: types.Metadata{ 45 + Locale: "en-GB", 46 + Location: types.Location{ 47 + Name: "Front, Italy", 48 + Latitude: &([]float64{45.28}[0]), 49 + Longitude: &([]float64{7.66}[0]), 50 + }, 44 51 Flip: "book", 52 + SentOn: types.Date{ 53 + Time: time.Date(2006, time.January, 2, 0, 0, 0, 0, time.UTC), 54 + }, 55 + Sender: types.Person{ 56 + Name: "Alice", 57 + Uri: "https://alice.example.com", 58 + }, 59 + Recipient: types.Person{ 60 + Name: "Bob", 61 + Uri: "https://bob.example.org", 62 + }, 63 + Front: types.Side{ 64 + Description: "The word 'Front' in large blue letters", 65 + Transcription: types.AnnotatedText{ 66 + Text: "Front", 67 + }, 68 + Secrets: []types.Polygon{{ 69 + Prehidden: true, 70 + Points: []types.Point{ 71 + {0.3, 0.6}, 72 + {0.3, 0.8}, 73 + {0.4, 0.8}, 74 + {0.4, 0.6}, 75 + }, 76 + }}, 77 + }, 78 + Back: types.Side{ 79 + Description: "The word 'Back' in large red letters", 80 + Transcription: types.AnnotatedText{ 81 + Text: "Back", 82 + Annotations: []types.Annotation{{ 83 + Type: types.ATLocale, 84 + Value: "en-GB", 85 + Start: 0, 86 + End: 4, 87 + }}, 88 + }, 89 + Secrets: []types.Polygon{{ 90 + Prehidden: true, 91 + Points: []types.Point{ 92 + {0, 0}, 93 + {0, 0.3}, 94 + {0.1, 0.3}, 95 + {0.1, 0}, 96 + }, 97 + }}, 98 + }, 99 + Context: types.Context{ 100 + Author: types.Person{ 101 + Name: "Carol", 102 + Uri: "https://carol.example.net", 103 + }, 104 + Description: "This is a sample postcard, with all fields expressed.", 105 + }, 106 + 45 107 Physical: types.Physical{ 46 108 FrontDimensions: types.Size{ 47 109 PxWidth: 1480, ··· 49 111 CmWidth: big.NewRat(148, 10), 50 112 CmHeight: big.NewRat(105, 10), 51 113 }, 52 - }, 53 - Front: types.Side{ 54 - Description: "The word 'Front' in large blue letters", 55 - }, 56 - Back: types.Side{ 57 - Description: "The word 'Back' in large red letters", 114 + ThicknessMM: 0.4, 58 115 }, 59 116 }, 60 117 Front: testImages["front-landscape.png"],
+1 -1
internal/testhelpers/samplexmp.xml
··· 1 - <?xpacket begin='' id='W5M0MpCehiHzreSzNTczkc9d'?><x:xmpmeta xmlns:x="adobe:ns:meta/" xmlns:xmptk="postcards/v0.1.0"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description xmlns:Iptc4xmpCore="http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/"><Iptc4xmpCore:AltTextAccessibility><rdf:Alt><rdf:li xml:lang="en">On the front of a postcard: The word &#39;Front&#39; in large red letters</rdf:li></rdf:Alt></Iptc4xmpCore:AltTextAccessibility></rdf:Description><rdf:Description xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:description><rdf:Alt><rdf:li xml:lang="en">A postcard stored in the dotpostcard format (https://dotpostcard.org)</rdf:li></rdf:Alt></dc:description></rdf:Description><rdf:Description xmlns:Postcard="https://dotpostcard.org/xmp/1.0/"><Postcard:Flip>book</Postcard:Flip><Postcard:Sender></Postcard:Sender><Postcard:Recipient></Postcard:Recipient><Postcard:Context xml:lang=""></Postcard:Context><Postcard:ContextAuthor></Postcard:ContextAuthor></rdf:Description></rdf:RDF></x:xmpmeta><?xpacket end='w'?> 1 + <?xpacket begin='' id='W5M0MpCehiHzreSzNTczkc9d'?><x:xmpmeta xmlns:x="adobe:ns:meta/" xmlns:xmptk="postcards/v0.1.0"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description xmlns:Iptc4xmpCore="http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/"><Iptc4xmpCore:AltTextAccessibility><rdf:Alt><rdf:li xml:lang="en">Both sides of a postcard. On the front: The word &#39;Front&#39; in large blue letters On the back: Back</rdf:li></rdf:Alt></Iptc4xmpCore:AltTextAccessibility></rdf:Description><rdf:Description xmlns:Iptc4xmpExt="http://iptc.org/std/Iptc4xmpExt/2008-02-29/"><Iptc4xmpExt:ImageRegion><rdf:Bag><rdf:li rdf:parseType="Resource"><Iptc4xmpExt:Name><rdf:Alt><rdf:li xml:lang="en">Private information</rdf:li></rdf:Alt></Iptc4xmpExt:Name><Iptc4xmpExt:RegionBoundary rdf:parseType="Resource"><Iptc4xmpExt:rbUnit>relative</Iptc4xmpExt:rbUnit><Iptc4xmpExt:rbShape>polygon</Iptc4xmpExt:rbShape><Iptc4xmpExt:rbVertices><rdf:Seq><rdf:li rdf:parseType="Resource"><Iptc4xmpExt:rbX>0.3</Iptc4xmpExt:rbX><Iptc4xmpExt:rbY>0.3</Iptc4xmpExt:rbY></rdf:li><rdf:li rdf:parseType="Resource"><Iptc4xmpExt:rbX>0.3</Iptc4xmpExt:rbX><Iptc4xmpExt:rbY>0.4</Iptc4xmpExt:rbY></rdf:li><rdf:li rdf:parseType="Resource"><Iptc4xmpExt:rbX>0.4</Iptc4xmpExt:rbX><Iptc4xmpExt:rbY>0.4</Iptc4xmpExt:rbY></rdf:li><rdf:li rdf:parseType="Resource"><Iptc4xmpExt:rbX>0.4</Iptc4xmpExt:rbX><Iptc4xmpExt:rbY>0.3</Iptc4xmpExt:rbY></rdf:li></rdf:Seq></Iptc4xmpExt:rbVertices></Iptc4xmpExt:RegionBoundary></rdf:li><rdf:li rdf:parseType="Resource"><Iptc4xmpExt:Name><rdf:Alt><rdf:li xml:lang="en">Private information</rdf:li></rdf:Alt></Iptc4xmpExt:Name><Iptc4xmpExt:RegionBoundary rdf:parseType="Resource"><Iptc4xmpExt:rbUnit>relative</Iptc4xmpExt:rbUnit><Iptc4xmpExt:rbShape>polygon</Iptc4xmpExt:rbShape><Iptc4xmpExt:rbVertices><rdf:Seq><rdf:li rdf:parseType="Resource"><Iptc4xmpExt:rbX>0</Iptc4xmpExt:rbX><Iptc4xmpExt:rbY>0.5</Iptc4xmpExt:rbY></rdf:li><rdf:li rdf:parseType="Resource"><Iptc4xmpExt:rbX>0</Iptc4xmpExt:rbX><Iptc4xmpExt:rbY>0.65</Iptc4xmpExt:rbY></rdf:li><rdf:li rdf:parseType="Resource"><Iptc4xmpExt:rbX>0.1</Iptc4xmpExt:rbX><Iptc4xmpExt:rbY>0.65</Iptc4xmpExt:rbY></rdf:li><rdf:li rdf:parseType="Resource"><Iptc4xmpExt:rbX>0.1</Iptc4xmpExt:rbX><Iptc4xmpExt:rbY>0.5</Iptc4xmpExt:rbY></rdf:li></rdf:Seq></Iptc4xmpExt:rbVertices></Iptc4xmpExt:RegionBoundary></rdf:li></rdf:Bag></Iptc4xmpExt:ImageRegion></rdf:Description><rdf:Description xmlns:exif="http://ns.adobe.com/exif/1.0/"><exif:DateTimeOriginal>2006-01-02</exif:DateTimeOriginal><exif:GPSAreaInformation>Front, Italy</exif:GPSAreaInformation><exif:GPSLatitude>45,16.80000000N</exif:GPSLatitude><exif:GPSLongitude>7,39.60000000E</exif:GPSLongitude></rdf:Description><rdf:Description xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:description><rdf:Alt><rdf:li xml:lang="en">A postcard stored in the dotpostcard format (https://dotpostcard.org)</rdf:li></rdf:Alt></dc:description></rdf:Description><rdf:Description xmlns:Postcard="https://dotpostcard.org/xmp/1.0/"><Postcard:Flip>book</Postcard:Flip><Postcard:Sender>Alice (https://alice.example.com)</Postcard:Sender><Postcard:Recipient>Bob (https://bob.example.org)</Postcard:Recipient><Postcard:Context><rdf:Alt><rdf:li xml:lang="en-GB">This is a sample postcard, with all fields expressed.</rdf:li></rdf:Alt></Postcard:Context><Postcard:ContextAuthor>Carol (https://carol.example.net)</Postcard:ContextAuthor><Postcard:DescriptionFront>The word &#39;Front&#39; in large blue letters</Postcard:DescriptionFront><Postcard:DescriptionBack>The word &#39;Back&#39; in large red letters</Postcard:DescriptionBack><Postcard:TranscriptionFront>{&#34;text&#34;:&#34;Front&#34;}</Postcard:TranscriptionFront><Postcard:TranscriptionBack>{&#34;text&#34;:&#34;Back&#34;,&#34;annotations&#34;:[{&#34;type&#34;:&#34;locale&#34;,&#34;value&#34;:&#34;en-GB&#34;,&#34;start&#34;:0,&#34;end&#34;:4}]}</Postcard:TranscriptionBack><Postcard:PhysicalThicknessMM>0.4</Postcard:PhysicalThicknessMM></rdf:Description></rdf:RDF></x:xmpmeta><?xpacket end='w'?>
+1 -1
types/annotations.go
··· 16 16 17 17 type Annotation struct { 18 18 Type AnnotationType `json:"type"` 19 - Value string `json:"value"` 19 + Value string `json:"value,omitempty"` 20 20 // The *byte* count just before this annotation starts 21 21 Start uint `json:"start"` 22 22 // The *byte* count just after this annotation ends
+67
types/point.go
··· 34 34 35 35 return nil 36 36 } 37 + 38 + // Transforms a point (relative to a single image) so that it will point to the same spot 39 + // (when relative to the double-sided equivalent). 40 + func (p Point) TransformToDoubleSided(onFront bool, flip Flip) Point { 41 + if flip == FlipNone { 42 + return p 43 + } 44 + 45 + // For double sided images the front side is always correctly oriented, and half the height of the full image 46 + if onFront { 47 + return Point{ 48 + X: p.X, 49 + Y: p.Y / 2, 50 + } 51 + } 52 + 53 + switch flip { 54 + case FlipLeftHand: 55 + return Point{ 56 + X: p.Y, 57 + Y: 1 - p.X/2, 58 + } 59 + case FlipRightHand: 60 + return Point{ 61 + X: (1 - p.Y), 62 + Y: p.X/2 + 0.5, 63 + } 64 + default: // FlipBook & FlipCalendar are the same 65 + return Point{ 66 + X: p.X, 67 + Y: (p.Y / 2) + 0.5, 68 + } 69 + } 70 + } 71 + 72 + // Transforms a point (relative to a double image) so that it will point to the same spot 73 + // (when relative to the single-sided equivalent). 74 + func (p Point) TransformToSingleSided(onFront bool, flip Flip) Point { 75 + if flip == FlipNone { 76 + return p 77 + } 78 + 79 + if onFront { 80 + return Point{ 81 + X: p.X, 82 + Y: p.Y * 2, 83 + } 84 + } 85 + 86 + switch flip { 87 + case FlipLeftHand: 88 + return Point{ 89 + X: 2 - 2*p.Y, 90 + Y: p.X, 91 + } 92 + case FlipRightHand: 93 + return Point{ 94 + X: 2*p.Y - 1, 95 + Y: (1 - p.X), 96 + } 97 + default: // FlipBook & FlipCalendar are the same 98 + return Point{ 99 + X: p.X, 100 + Y: (p.Y * 2) - 1, 101 + } 102 + } 103 + }
+56
types/point_test.go
··· 1 + package types_test 2 + 3 + import ( 4 + "fmt" 5 + "testing" 6 + 7 + . "github.com/jphastings/dotpostcard/types" 8 + "github.com/stretchr/testify/assert" 9 + ) 10 + 11 + var transformCases = []struct { 12 + onFront bool 13 + flip Flip 14 + transformed Point 15 + }{ 16 + {true, FlipNone, Point{X: 0.13, Y: 0.17}}, 17 + 18 + {true, FlipBook, Point{X: 0.13, Y: 0.085}}, 19 + {false, FlipBook, Point{X: 0.13, Y: 0.585}}, 20 + 21 + {true, FlipCalendar, Point{X: 0.13, Y: 0.085}}, 22 + {false, FlipCalendar, Point{X: 0.13, Y: 0.585}}, 23 + 24 + {true, FlipLeftHand, Point{X: 0.13, Y: 0.085}}, 25 + {false, FlipLeftHand, Point{X: 0.17, Y: 0.935}}, 26 + 27 + {true, FlipRightHand, Point{X: 0.13, Y: 0.085}}, 28 + {false, FlipRightHand, Point{X: 0.83, Y: 0.565}}, 29 + } 30 + 31 + // Accuracy of floating point results 32 + const acc = 0.0000000001 33 + 34 + func TestTransformToDoubleSided(t *testing.T) { 35 + original := Point{X: 0.13, Y: 0.17} 36 + 37 + for _, c := range transformCases { 38 + t.Run(fmt.Sprintf("Front:%v-Flip:%s", c.onFront, c.flip), func(t *testing.T) { 39 + got := original.TransformToDoubleSided(c.onFront, c.flip) 40 + assert.InDelta(t, c.transformed.X, got.X, acc, "Incorrect X value: wanted %v got %v", c.transformed.X, got.X) 41 + assert.InDelta(t, c.transformed.Y, got.Y, acc, "Incorrect Y value: wanted %v got %v", c.transformed.X, got.Y) 42 + }) 43 + } 44 + } 45 + 46 + func TestTransformToSingleSided(t *testing.T) { 47 + original := Point{X: 0.13, Y: 0.17} 48 + 49 + for _, c := range transformCases { 50 + t.Run(fmt.Sprintf("Front:%v-Flip:%s", c.onFront, c.flip), func(t *testing.T) { 51 + got := c.transformed.TransformToSingleSided(c.onFront, c.flip) 52 + assert.InDelta(t, original.X, got.X, acc, "Incorrect X value: wanted %v got %v", original.X, got.X) 53 + assert.InDelta(t, original.Y, got.Y, acc, "Incorrect Y value: wanted %v got %v", original.X, got.Y) 54 + }) 55 + } 56 + }