diff --git a/cmd/postcards/cmd/compile.go b/cmd/postcards/cmd/compile.go index 8f0fe40..5f100c3 100644 --- a/cmd/postcards/cmd/compile.go +++ b/cmd/postcards/cmd/compile.go @@ -4,15 +4,17 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/dotpostcard/postcards-go/compile" + "github.com/dotpostcard/postcards-go/internal/cmdhelp" "github.com/spf13/cobra" ) // compileCmd represents the compile command var compileCmd = &cobra.Command{ Use: "compile", - Short: "Compiles images & metadata into a postcard file", + Short: "Compiles images & metadata into a postcard file, or web-compatible equivalent", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { path, err := filepath.Abs(args[0]) @@ -20,26 +22,43 @@ var compileCmd = &cobra.Command{ return fmt.Errorf("unknown file path: %w", err) } + outdir, err := cmdhelp.Outdir(cmd, path) + if err != nil { + return err + } + override, err := cmd.Flags().GetBool("override") if err != nil { override = false } - filename, data, err := compile.Files(path, !override) + webFormat, err := cmd.Flags().GetBool("web") + if err != nil { + webFormat = false + } + + filenames, datas, err := compile.Files(path, !override, webFormat) if err != nil { return err } - if data == nil { - fmt.Printf("Postcard already exists, skipping: %s\n", filename) + if datas == nil { + fmt.Printf("Postcard files already exist, skipping: %s\n", strings.Join(filenames, ", ")) } - fmt.Printf("Writing postcard file to %s\n", filename) - return os.WriteFile(filename, data, 0600) + fmt.Println("Writing postcard files…") + for i, filename := range filenames { + if err := os.WriteFile(filepath.Join(outdir, filename), datas[i], 0600); err != nil { + return fmt.Errorf("unable to write file %s: %w", filename, err) + } + fmt.Printf("↪ Wrote postcard file to %s\n", filename) + } + return nil }, } func init() { compileCmd.Flags().Bool("override", false, "overrides output files") + compileCmd.Flags().Bool("web", false, "make web-compatible postcard file") rootCmd.AddCommand(compileCmd) } diff --git a/cmd/postcards/cmd/root.go b/cmd/postcards/cmd/root.go index 0a4165d..3f9eab2 100644 --- a/cmd/postcards/cmd/root.go +++ b/cmd/postcards/cmd/root.go @@ -1,6 +1,7 @@ package cmd import ( + "fmt" "os" "github.com/dotpostcard/postcards-go" @@ -14,14 +15,18 @@ var rootCmd = &cobra.Command{ Version: postcards.Version.String(), } +func checkErr(err error) { + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + func Execute() { rootCmd.PersistentFlags().Bool("here", false, "Output files in the current working directory") rootCmd.PersistentFlags().Bool("there", true, "Output files in the same directory as the source data") rootCmd.PersistentFlags().String("outdir", "", "Output files to the given directory") rootCmd.MarkFlagsMutuallyExclusive("here", "there", "outdir") - err := rootCmd.Execute() - if err != nil { - os.Exit(1) - } + checkErr(rootCmd.Execute()) } diff --git a/compile/compile.go b/compile/compile.go index dd0e9e5..10a4b18 100644 --- a/compile/compile.go +++ b/compile/compile.go @@ -23,91 +23,110 @@ import ( var nameRegex = regexp.MustCompile(`(.+)-(?:front|back|meta)+\.[a-z]+`) // Files accepts a path to one of the three needed files, attempts to find the others, and provides the conventional name and bytes for the file. -func Files(part string, skipIfPresent bool) (string, []byte, error) { +func Files(part string, skipIfPresent bool, webFormat bool) ([]string, [][]byte, error) { dir := filepath.Dir(part) parts := nameRegex.FindStringSubmatch(filepath.Base(part)) if len(parts) != 2 { - return "", nil, fmt.Errorf("given filename not of the form *-{front,back,meta}.ext") + return nil, nil, fmt.Errorf("given filename not of the form *-{front,back,meta}.ext") } prefix := parts[1] - outputFilename := fmt.Sprintf("%s.postcard", prefix) + var outputFilenames []string + if webFormat { + outputFilenames = []string{ + fmt.Sprintf("%s.webp", prefix), + fmt.Sprintf("%s.md", prefix), + } + } else { + outputFilenames = []string{fmt.Sprintf("%s.postcard", prefix)} + } - exists, err := fileExists(outputFilename) + exists, err := anyFilesExist(outputFilenames...) if err != nil { - return outputFilename, nil, nil + return outputFilenames, nil, nil } if skipIfPresent && exists { - return outputFilename, nil, fmt.Errorf("output file already exists: %s", outputFilename) + return outputFilenames, nil, fmt.Errorf("output file already exists: %s", strings.Join(outputFilenames, ", ")) } metaRaw, metaExt, err := openVagueFilename(dir, prefix, "meta", "json", "yml", "yaml") if err != nil { - return "", nil, fmt.Errorf("couldn't load metadata: %w", err) + return nil, nil, fmt.Errorf("couldn't load metadata: %w", err) } meta, err := metaReader(metaRaw, metaExt) if err != nil { - return "", nil, fmt.Errorf("couldn't parse metadata: %w", err) + return nil, nil, fmt.Errorf("couldn't parse metadata: %w", err) } front, _, err := openVagueFilename(dir, prefix, "front", "png", "jpg", "tif", "tiff") if err != nil { - return "", nil, fmt.Errorf("couldn't load postcard front: %w", err) + return nil, nil, fmt.Errorf("couldn't load postcard front: %w", err) } back, _, err := openVagueFilename(dir, prefix, "back", "png", "jpg", "tif", "tiff") if err != nil { - return "", nil, fmt.Errorf("couldn't load postcard back: %w", err) + return nil, nil, fmt.Errorf("couldn't load postcard back: %w", err) } - pc, err := Readers(front, back, meta) - if err != nil { - return "", nil, err - } + if webFormat { + img, md, err := CompileWeb(front, back, meta) + if err != nil { + return nil, nil, err + } - buf := new(bytes.Buffer) - if err := postcards.Write(pc, buf); err != nil { - return "", nil, err - } + return outputFilenames, [][]byte{img, md}, nil + } else { + pc, err := Readers(front, back, meta) + if err != nil { + return nil, nil, err + } - return outputFilename, buf.Bytes(), nil -} + buf := new(bytes.Buffer) + if err := postcards.Write(pc, buf); err != nil { + return nil, nil, err + } -func fileExists(filename string) (bool, error) { - info, err := os.Stat(filename) - if os.IsNotExist(err) { - return false, nil - } else if err != nil { - return false, err + return outputFilenames, [][]byte{buf.Bytes()}, nil } - if info.IsDir() { - return true, fmt.Errorf("file %s is a directory", filename) +} + +func anyFilesExist(filenames ...string) (bool, error) { + for _, filename := range filenames { + info, err := os.Stat(filename) + if os.IsNotExist(err) { + continue + } else if err != nil { + return false, err + } + if info.IsDir() { + return true, fmt.Errorf("file %s is a directory", filename) + } + return true, nil } - return true, nil + return false, nil } -// Readers accepts reader objects for each of the components of a postcard file, and creates an in-memory Postcard object. -func Readers(frontReader, backReader io.Reader, mp MetadataProvider) (*types.Postcard, error) { +func processImages(frontReader, backReader io.Reader, mp MetadataProvider) (image.Image, image.Image, types.Size, types.Size, types.Metadata, error) { meta, err := mp.Metadata() if err != nil { - return nil, fmt.Errorf("unable to obtain the metadata: %w", err) + return nil, nil, types.Size{}, types.Size{}, types.Metadata{}, fmt.Errorf("unable to obtain the metadata: %w", err) } if err := validateMetadata(meta); err != nil { - return nil, fmt.Errorf("metadata invalid: %w", err) + return nil, nil, types.Size{}, types.Size{}, types.Metadata{}, fmt.Errorf("metadata invalid: %w", err) } frontRaw, frontDims, err := readerToImage(frontReader) if err != nil { - return nil, fmt.Errorf("unable to parse image for front image: %w", err) + return nil, nil, types.Size{}, types.Size{}, types.Metadata{}, fmt.Errorf("unable to parse image for front image: %w", err) } backRaw, backDims, err := readerToImage(backReader) if err != nil { - return nil, fmt.Errorf("unable to parse image for back image: %w", err) + return nil, nil, types.Size{}, types.Size{}, types.Metadata{}, fmt.Errorf("unable to parse image for back image: %w", err) } meta.FrontDimensions = bestFrontDimensions(meta.FrontDimensions, frontDims, backDims, meta.Flip.Heteroriented()) if err := validate.Dimensions(&meta, frontRaw.Bounds(), backRaw.Bounds(), frontDims, backDims); err != nil { - return nil, err + return nil, nil, types.Size{}, types.Size{}, types.Metadata{}, err } if isOversized(frontDims) { log.Printf("WARNING! This postcard is very large (%s), do the images have the correct ppi/ppcm?\n", frontDims) @@ -115,11 +134,21 @@ func Readers(frontReader, backReader io.Reader, mp MetadataProvider) (*types.Pos frontImg, err := hideSecrets(frontRaw, meta.Front.Secrets) if err != nil { - return nil, fmt.Errorf("unable to hide the secret areas specified on the postcard front: %w", err) + return nil, nil, types.Size{}, types.Size{}, types.Metadata{}, fmt.Errorf("unable to hide the secret areas specified on the postcard front: %w", err) } backImg, err := hideSecrets(backRaw, meta.Back.Secrets) if err != nil { - return nil, fmt.Errorf("unable to hide the secret areas specified on the postcard back: %w", err) + return nil, nil, types.Size{}, types.Size{}, types.Metadata{}, fmt.Errorf("unable to hide the secret areas specified on the postcard back: %w", err) + } + + return frontImg, backImg, frontDims, backDims, meta, nil +} + +// Readers accepts reader objects for each of the components of a postcard file, and creates an in-memory Postcard object. +func Readers(frontReader, backReader io.Reader, mp MetadataProvider) (*types.Postcard, error) { + frontImg, backImg, frontDims, backDims, meta, err := processImages(frontReader, backReader, mp) + if err != nil { + return nil, err } frontWebp, err := encodeWebp(frontImg, frontDims) diff --git a/compile/compile_test.go b/compile/compile_test.go index 6a75dd9..8428da9 100644 --- a/compile/compile_test.go +++ b/compile/compile_test.go @@ -25,14 +25,28 @@ func hashOfPostcardInnards(data []byte) [16]byte { return md5.Sum(data) } -func ExampleFiles() { - filename, data, err := compile.Files("../fixtures/hello-meta.yaml", false) +func ExampleFiles_postcard() { + filenames, datas, err := compile.Files("../fixtures/hello-meta.yaml", false, false) if err != nil { panic(err) } - fmt.Printf("%s has checksum %x", filename, hashOfPostcardInnards(data)) - // Output: hello.postcard has checksum ecb741d69f14bd70aaa3f02436e5ea49 + fmt.Printf("(%d file) %s has checksum %x", len(filenames), filenames[0], hashOfPostcardInnards(datas[0])) + // Output: (1 file) hello.postcard has checksum 659a65db02a600f70d3bf0b438d10297 +} + +func ExampleFiles_web() { + filenames, datas, err := compile.Files("../fixtures/hello-meta.yaml", false, true) + if err != nil { + panic(err) + } + + fmt.Printf("(%d files) %s has checksum %x, %s has checksum %x", + len(filenames), + filenames[0], hashOfPostcardInnards(datas[0]), + filenames[1], hashOfPostcardInnards(datas[1]), + ) + // Output: (2 files) hello.webp has checksum c085551191a9bf20e65392ed239d990e, hello.md has checksum 47ab2ccda9585222cc0625f5298e12c7 } func checkBadSetup(t *testing.T, err error) { diff --git a/compile/web.go b/compile/web.go new file mode 100644 index 0000000..99b83f3 --- /dev/null +++ b/compile/web.go @@ -0,0 +1,81 @@ +package compile + +import ( + "bytes" + _ "embed" + "image" + "image/draw" + "io" + "math/big" + "text/template" + + "github.com/dotpostcard/postcards-go/internal/types" +) + +//go:embed web.md.tmpl +var webTemplateStr string +var webTemplate = template.Must(template.New("web").Parse(webTemplateStr)) + +func CompileWeb(front, back io.Reader, mp MetadataProvider) (imgBytes []byte, mdBytes []byte, err error) { + frontImg, backImg, frontDims, _, meta, err := processImages(front, back, mp) + if err != nil { + return nil, nil, err + } + + combinedDims := types.Size{ + PxWidth: frontDims.PxWidth, + PxHeight: frontDims.PxHeight * 2, + CmWidth: frontDims.CmWidth, + CmHeight: frontDims.CmHeight.Mul(frontDims.CmHeight, big.NewRat(2, 1)), + } + + frontBounds := frontImg.Bounds() + backBounds := image.Rectangle{ + Min: image.Point{0, frontDims.PxHeight}, + Max: image.Point{frontDims.PxWidth, combinedDims.PxHeight}, + } + + combinedImg := image.NewRGBA(image.Rect(0, 0, combinedDims.PxWidth, combinedDims.PxHeight)) + draw.Draw(combinedImg, frontBounds, frontImg, image.Point{}, draw.Src) + + if meta.Flip == types.FlipLeftHand || meta.Flip == types.FlipRightHand { + backImg = rotateImage(backImg, meta.Flip) + } + draw.Draw(combinedImg, backBounds, backImg, image.Point{}, draw.Src) + + combined, err := encodeWebp(combinedImg, combinedDims) + if err != nil { + return nil, nil, err + } + + buf := new(bytes.Buffer) + if err := webTemplate.Execute(buf, meta); err != nil { + return nil, nil, err + } + + return combined, buf.Bytes(), nil +} + +func rotateImage(img image.Image, flip types.Flip) image.Image { + bounds := img.Bounds() + rotated := image.NewRGBA(image.Rect(0, 0, bounds.Dy(), bounds.Dx())) + + switch flip { + case types.FlipLeftHand: + // Top left of the source should be bottom left of the output + for x := bounds.Min.X; x < bounds.Max.X; x++ { + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + rotated.Set(y, bounds.Max.X-x, img.At(x, y)) + } + } + case types.FlipRightHand: + // Top left of the source should be top right of the output + for x := bounds.Min.X; x < bounds.Max.X; x++ { + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + rotated.Set(bounds.Max.Y-y, x, img.At(x, y)) + } + } + } + + return rotated +} diff --git a/compile/web.md.tmpl b/compile/web.md.tmpl new file mode 100644 index 0000000..902c673 --- /dev/null +++ b/compile/web.md.tmpl @@ -0,0 +1,9 @@ +--- +title: {{ .Location.Name }} +lat: {{ .Location.Latitude }} +long: {{ .Location.Longitude }} +date: {{ .SentOn }} +frontalt: {{ .Front.Description }} +--- + +{{ .Back.Transcription }} diff --git a/go.mod b/go.mod index 5bd4bb7..72d92e4 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/dsoprea/go-utility v0.0.0-20200711062821-fab8125e9bdf // indirect github.com/go-errors/errors v1.1.1 // indirect github.com/golang/geo v0.0.0-20200319012246-673a6f80352d // indirect + github.com/stretchr/testify v1.7.1 // indirect golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 64d05da..697ead8 100644 --- a/go.sum +++ b/go.sum @@ -41,8 +41,9 @@ github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= golang.org/x/image v0.0.0-20220902085622-e7cb96979f69 h1:Lj6HJGCSn5AjxRAH2+r35Mir4icalbqku+CLUtjnvXY= diff --git a/internal/cmdhelp/outdir.go b/internal/cmdhelp/outdir.go new file mode 100644 index 0000000..e5cdff9 --- /dev/null +++ b/internal/cmdhelp/outdir.go @@ -0,0 +1,31 @@ +package cmdhelp + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" +) + +func Outdir(cmd *cobra.Command, therePath string) (string, error) { + outdir, err := cmd.Flags().GetString("outdir") + if err != nil { + return "", err + } + if outdir != "" { + // Only error if outdir is a regular file (ie. allow existing and non-existing directories) + if fi, err := os.Stat(outdir); (err != os.ErrNotExist || err == nil) && !fi.IsDir() { + return "", fmt.Errorf("outdir %s is a regular file", outdir) + } + return outdir, os.MkdirAll(outdir, 0700) + } + heredir, err := cmd.Flags().GetBool("here") + if err != nil { + return "", err + } + if heredir { + return ".", nil + } + return filepath.Dir(therePath), nil +}