diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..23ec656 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/doc/ +/lib/ +/bin/ +/.shards/ + +# Libraries don't need dependency lock +# Dependencies will be locked in application that uses them +/shard.lock diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..4b95e7d --- /dev/null +++ b/.travis.yml @@ -0,0 +1,5 @@ +language: crystal +before_install: + - sudo apt-get update + - sudo apt-get install imagemagick libmagickcore-dev libmagickwand-dev +script: make test diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..385238d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2017 Roman Kalnytskyi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..234b1ba --- /dev/null +++ b/Makefile @@ -0,0 +1,7 @@ +CRYSTAL_BIN ?= `which crystal` +.PHONY: test + +test: + $(CRYSTAL_BIN) run spec/*_spec.cr spec/crymagick/*_spec.cr -- --parallel 4 +seq: + $(CRYSTAL_BIN) run spec/*_spec.cr spec/crymagick/*_spec.cr \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f182949 --- /dev/null +++ b/README.md @@ -0,0 +1,267 @@ +# CryMagick [![Build Status](https://travis-ci.org/imdrasil/crymagick.svg)](https://travis-ci.org/imdrasil/crymagick) [![Latest Release](https://img.shields.io/github/release/imdrasil/crymagick.svg)](https://github.com/imdrasil/crymagick/releases) + +**CryMagick** is a [ImageMagick](http://imagemagick.org/) command line interface for crystal. Inspired by [minimagick](https://github.com/minimagick/minimagick). + +## Installation + +Add this to your application's `shard.yml`: + +```yaml +dependencies: + crymagick: + github: imdrasil/crymagick +``` + +## Requirements + +ImageMagick command-line tool has to be installed. You can check if you have it installed by running + +```shell +$ convert -version +Version: ImageMagick 6.8.9-9 Q16 x86_64 2017-05-26 http://www.imagemagick.org +Copyright: Copyright (C) 1999-2014 ImageMagick Studio LLC +Features: DPC Modules OpenMP +``` + +## Usage + +Let's first see a basic example of resizing an image. + +```crystal +image = CryMagick::Image.open("input.jpg") +image.path #=> "/var/folders/k7/6zx6dx6x7ys3rv3srh0nyfj00000gn/T/magick20140921-75881-1yho3zc.jpg" +image.resize "100x100" +image.format "png" +image.write "output.png" +``` + +`CryMagick::Image.open` makes a copy of the image, and further methods modify that copy (the original stays untouched). We then resize the image, and write it to a file. The writing part is necessary because the copy is just temporary, it gets garbage collected when we lose reference to the image. + +On the other hand, if we want the original image to actually get modified, we can use `CryMagick::Image.new`. + +### Combine options + +While using methods like `#resize` directly is convenient, if we use more methods in this way, it quickly becomes inefficient, because it calls the command on each methods call. `CryMagick::Image#combine_options` takes multiple options and from them builds one single command. + +```crystal +image.combine_options do |b| + b.resize "250x200>" + b.rotate "-90" + b.flip +end # the command gets executed +``` + +As a handy shortcut, `CryMagick::Image.build` accepts an optional block which is used to combine_options. + +```crystal +image = CryMagick::Image.build("input.jpg") do |b| + b.resize "250x200>" + b.rotate "-90" + b.flip +end # the command gets executed +``` + +The yielded builder is an instance of `CryMagick::Tool::Mogrify`. + +### Attributes + +A `CryMagick::Image` has various handy attributes. + +``` +image.type #=> "JPEG" +image.mime_type #=> "image/jpeg" +image.width #=> 250 +image.height #=> 300 +image.dimensions #=> [250, 300] +image.size #=> 3451 (in bytes) +image.colorspace #=> "DirectClass sRGB" +image.exif #=> {"DateTimeOriginal" => "2013:09:04 08:03:39", ...} +image.resolution #=> [75, 75] +image.signature #=> "60a7848c4ca6e36b8e2c5dea632ecdc29e9637791d2c59ebf7a54c0c6a74ef7e" +``` + +If you need more control, you can also access raw image attributes: + +```crystal +image["%[gamma]"] # "0.9" +``` + +To get the all information about the image, CryMagick gives you a handy method which returns the output from `identify -verbose` in hash format: + +```crystal +image.data #=> +# { +# "format": "JPEG", +# "mimeType": "image/jpeg", +# "class": "DirectClass", +# "geometry": { +# "width": 200, +# "height": 276, +# "x": 0, +# "y": 0 +# }, +# "resolution": { +# "x": "300", +# "y": "300" +# }, +# "colorspace": "sRGB", +# "channelDepth": { +# "red": 8, +# "green": 8, +# "blue": 8 +# }, +# "quality": 92, +# "properties": { +# "date:create": "2016-07-11T19:17:53+08:00", +# "date:modify": "2016-07-11T19:17:53+08:00", +# "exif:ColorSpace": "1", +# "exif:ExifImageLength": "276", +# "exif:ExifImageWidth": "200", +# "exif:ExifOffset": "90", +# "exif:Orientation": "1", +# "exif:ResolutionUnit": "2", +# "exif:XResolution": "300/1", +# "exif:YResolution": "300/1", +# "icc:copyright": "Copyright (c) 1998 Hewlett-Packard Company", +# "icc:description": "sRGB IEC61966-2.1", +# "icc:manufacturer": "IEC http://www.iec.ch", +# "icc:model": "IEC 61966-2.1 Default RGB colour space - sRGB", +# "jpeg:colorspace": "2", +# "jpeg:sampling-factor": "1x1,1x1,1x1", +# "signature": "1b2336f023e5be4a9f357848df9803527afacd4987ecc18c4295a272403e52c1" +# }, +# ... +# } +``` + +### Configuration + +```crystal +CryMagick::Configuration.configure do |config| + config.cli_path = "some/path" + config.whiny = false +end +``` + +### Composite + +CryMagick allows to composite images: + +```crystal +first_image = CryMagick::Image.new("first.jpg") +second_image = CryMagick::Image.new("second.jpg") +result = first_image.composite(second_image) do |c| + c.compose "Over" # OverCompositeOp + c.geometry "+20+20" # copy second_image onto first_image from (20, 20) +end +result.write "output.jpg" +``` + +### Metal + +If you want to be close to the metal, you can use ImageMagick's command-line tools directly. + +```crystal +CryMagick::Tool::Mogrify.build do |mogrify| + mogrify.resize("100x100") + mogrify.negate + mogrify << "image.jpg" +end #=> `mogrify -resize 100x100 -negate image.jpg` + +# OR + +mogrify = CryMagick::Tool::Mogrify.new +mogrify.resize("100x100") +mogrify.negate +mogrify << "image.jpg" +mogrify.call #=> `mogrify -resize 100x100 -negate image.jpg` +``` + +This way of using CryMagick is highly recommended if you want to maximize performance of your image processing. Here are some of the features. + +#### Appending + +The most basic way of building a command is appending strings: + +```crystal +CryMagick::Tool::Convert.build do |convert| + convert << "input.jpg" + convert.merge! ["-resize", "500x500", "-negate"] + convert << "output.jpg" +end +``` + +#### Methods + +Instead of passing in options directly, you can use pure methods: + +```crystal +convert.resize("500x500") +convert.rotate(90) +convert.distort("Perspective", "0,0,0,0 0,45,0,45 69,0,60,10 69,45,60,35") +``` + +#### Chainging + +```crystal +CryMagick::Tool::Convert.build do |convert| + convert << "input.jpg" + convert.clone(0).background('gray').shadow('80x5+5+5') + convert.negate + convert << "output.jpg" +end +``` + +#### "Plus" + +```crystal +CryMagick::Tool::Convert.build do |convert| + convert << "input.jpg" + convert.repage.+ + convert.distort.+("Perspective", "more args") +end +# convert input.jpg +repage +distort Perspective 'more args' +``` + +#### Stack + +```crystal +CryMagick::Tool::Convert.build do |convert| + convert << "wand.gif" + convert.stack do |stack| + stack << "wand.gif" + stack.rotate(30) + end + convert << "images.gif" +end +``` + +## Troubleshooting + +`CryMagick::Tool` uses `method_missing` macro so all method calling with invalid arguments will go there. Fot now all dynamically generated methods are logged into stdout during compilation time. +``` + +## Development + +To run test suite + +```shell +$ make test +``` + +Next feature: +- [ ] add graphicsmagick +- [ ] add different image converting tools support + +## Contributing + +1. Fork it ( https://github.com/imdrasil/crymagick/fork ) +2. Create your feature branch (git checkout -b my-new-feature) +3. Commit your changes (git commit -am 'Add some feature') +4. Push to the branch (git push origin my-new-feature) +5. Create a new Pull Request + +## Contributors + +- [imdrasil](https://github.com/imdrasil) Roman Kalnytskyi - creator, maintainer + diff --git a/shard.yml b/shard.yml new file mode 100644 index 0000000..206bc3b --- /dev/null +++ b/shard.yml @@ -0,0 +1,12 @@ +name: crymagick +version: 0.1.0 + +authors: + - Roman Kalnytskyi + +crystal: 0.23.1 + +license: MIT +development_dependencies: + minitest: + github: ysbaddaden/minitest.cr diff --git a/spec/crymagick/image_spec.cr b/spec/crymagick/image_spec.cr new file mode 100644 index 0000000..c9f2022 --- /dev/null +++ b/spec/crymagick/image_spec.cr @@ -0,0 +1,507 @@ +require "../spec_helper" + +describe CryMagick::Image do + let(:described_class) { CryMagick::Image } + @subject : CryMagick::Image? + let(:subject) { CryMagick::Image.open(image_path) } + + describe ".read" do + it "reads image from String" do + string = File.read(image_path) + image = described_class.read(string) + expect(image.valid?).must_equal(true) + end + + it "reads image from tempfile" do + tempfile = get_tempfile + FileUtils.cp(image_path, tempfile.path) + image = described_class.read(tempfile) + expect(image.valid?).must_equal(true) + end + end + + describe ".open" do + it "makes a copy of the image" do + image = described_class.open(image_path) + expect(image.path).wont_equal image_path + expect(image.valid?).must_equal true + end + + it "accepts a string" do + image = described_class.open(image_path) + expect(image.valid?).must_equal true + end + + # it "loads a remote image" do + # begin + # image = described_class.open(image_url) + # expect(image).to be_valid + # rescue SocketError + # end + # end + + it "validates the image" do + assert_raises(CryMagick::Invalid) do + described_class.open(image_path(:not)) + end + end + + it "does not mistake a path with a colon for a URI schema" do + described_class.open(image_path(:colon)) + end + end + + describe ".create" do + def create(path = image_path) + described_class.create do |f| + f.print(File.read(path)) + end + end + + it "creates an image" do + image = create + expect(File.exists?(image.path)).must_equal true + end + + it "validates the image if validation is set" do + assert_raises(CryMagick::Invalid) do + create(image_path(:not)) + end + end + + it "doesn't validate image if validation is disabled" do + begin + CryMagick::Configuration.validate_on_create = false + create(image_path(:not)) + ensure + CryMagick::Configuration.validate_on_create = true + end + end + end + + describe "equivalence" do + @image : CryMagick::Image? + @same_image : CryMagick::Image? + @other_image : CryMagick::Image? + let(:image) { described_class.new(image_path) } + let(:same_image) { described_class.new(image_path) } + let(:other_image) { described_class.new(image_path(:exif)) } + + it "is #== to itself" do + expect(image).must_equal(image) + end + + it "is #== to an instance of the same image" do + expect(image).must_equal(same_image) + end + + it "is not #== to an instance of a different image" do + expect(image).wont_equal(other_image) + end + + it "generates the same hash code for an instance of the same image" do + expect(image.hash).must_equal(same_image.hash) + end + + it "generates different same hash codes for a different image" do + expect(image.hash).wont_equal(other_image.hash) + end + end + + describe "#tempfile" do + it "returns the underlying temporary file" do + image = described_class.open(image_path) + expect(image.tempfile).wont_be_nil + end + end + + describe "#valid?" do + it "returns true when image is valid" do + image = described_class.new(image_path) + expect(image.valid?).must_equal(true) + end + + it "returns false when image is not valid" do + image = described_class.new(image_path(:not)) + expect(image.valid?).must_equal(false) + end + end + + it "create temfile" do + file = described_class.open("spec/fixtures/cylinder_shaded.png") + expect(File.exists?(file.path)).must_equal(true) + end + + describe "#write" do + it "writes the image" do + output_path = random_path("test output") + subject.write(output_path) + expect(described_class.new(output_path).valid?).must_equal(true) + end + + it "writes an image with stream" do + output_stream = IO::Memory.new + subject.write(output_stream) + expect(described_class.read(output_stream.to_s).valid?).must_equal(true) + end + + it "writes layers" do + output_path = random_path(".#{subject.type.downcase}") + subject = described_class.new(image_path(:gif)) + subject.frames.first.write(output_path) + expect(described_class.new(output_path).valid?).must_equal(true) + end + + it "works when writing to the same path" do + subject.write(subject.path) + expect(File.read(subject.path)).wont_be_empty + end + end + + describe "#format" do + let(:subject) { described_class.open(image_path(:jpg)) } + + it "changes the format of the photo" do + expect_to_change(->{ subject.type }) do + subject.format("png") + end + end + + it "reformats an image with a given extension" do + expect_to_change(->{ File.extname(subject.path) }, to: ".png") do + subject.format(:png) + end + end + + it "creates the file with new extension" do + subject.format(:png) + expect(File.exists?(subject.path)).must_equal(true) + end + + it "accepts a block of additional commands" do + expect_to_change(->{ subject.dimensions }, to: {100, 100}) do + subject.format(:png) do |b| + b.resize("100x100!") + end + end + end + + it "works without an extension with .open" do + subject = described_class.open(image_path(:jpg_without_extension)) + subject.format("png") + + expect(File.extname(subject.path)).must_equal ".png" + expect(subject.type).must_equal "PNG" + end + + it "works without an extension with .new" do + subject = described_class.new(image_path(:jpg_without_extension)) + subject.format("png") + + expect(File.extname(subject.path)).must_equal ".png" + expect(subject.type).must_equal "PNG" + end + + it "deletes the previous tempfile" do + old_path = subject.path.dup + subject.format(:png) + expect(File.exists?(old_path)).must_equal false + end + + it "deletes *.cache files generated from .mpc" do + image = described_class.open(image_path) + image.format("mpc") + cache_path = image.path.sub(/mpc$/, "cache") + image.format("png") + + expect(File.exists?(cache_path)).must_equal false + end + + it "doesn't delete itself when formatted to the same format" do + subject.format(subject.type.downcase) + expect(File.exists?(subject.path)).must_equal true + end + + it "reformats multi-image formats to multiple images" do + subject = described_class.open(image_path(:animation)) + subject.format(:jpg, "-1") + + expect(Dir[subject.path.sub(/\..+$/, ".*")].size).must_equal 21 + end + + it "reformats multi-image formats to a single image" do + subject = described_class.open(image_path(:animation)) + subject.format("jpg") + expect(subject.valid?).must_equal true + end + + it "reformats a layer" do + subject = described_class.open(image_path(:animation)) + layer = subject.layers.first + layer.format("jpg") + expect(layer.valid?).must_equal true + expect(layer.path[/\..+$/]).must_equal ".jpg" + expect(File.exists?(layer.path)).must_equal true + end + + it "clears the info only at the end" do + subject.format("png") { subject.type } + expect(subject.type).must_equal "PNG" + end + + it "returns self" do + expect(subject.format("png")).must_equal subject + end + + it "reads read_opts from passed arguments" do + subject = described_class.open(image_path(:animation)) + layer = subject.layers.first + layer.format("jpg", "-1", {"density" => "300"}) + expect(layer.valid?).must_equal true + end + end + + describe "#braces" do + it "inspects image meta info" do + expect_be_a(subject[:width], Int32) + expect_be_a(subject[:height], Int32) + expect_be_a(subject[:colorspace], String) + expect(subject[:format]).must_match(/[A-Z]/) + expect(subject[:signature]).must_match(/[[:alnum:]]{64}/) + end + + it "supports string keys" do + expect_be_a(subject["width"], Int32) + expect_be_a(subject["height"], Int32) + expect_be_a(subject["colorspace"], String) + expect(subject["format"]).must_match(/[A-Z]/) + expect(subject["signature"]).must_match(/[[:alnum:]]{64}/) + end + + it "reads exif" do + subject = described_class.new(image_path(:exif)) + expect(subject["EXIF:Flash"]).wont_equal "0" + end + + it "passes unknown values directly to -format" do + expect(subject["%w %h"].as(String).split.map(&.to_i)).must_equal [subject[:width], subject[:height]] + end + end + + it "has attributes" do + expect(subject.type).must_match(/^[A-Z]+$/) + expect(subject.mime_type).must_match(/^image\/[a-z]+$/) + expect(subject.width).wont_equal(0) + expect(subject.height).wont_equal(0) + subject.dimensions + expect(subject.size).wont_equal(0) + expect(subject.human_size).wont_be_empty + expect_be_a(subject.colorspace, String) + expect_be_a(subject.resolution, Tuple(Float64, Float64)) + expect(subject.signature).must_match(/[[:alnum:]]{64}/) + end + + it "generates attributes of layers" do + expect(subject.layers[0].type).must_match(/^[A-Z]+$/) + expect(subject.layers[0].size > 0).must_equal true + end + + it "changes colorspace when called with an argument" do + # TODO: add correct expectation + subject.colorspace("Gray") + end + + it "changes size when called with an argument" do + # TODO: add correct expectation + subject.size("20x20") + end + + describe "#exif" do + let(:subject) { described_class.new(image_path(:exif)) } + + it "returns a hash of EXIF data" do + expect_be_a(subject.exif["DateTimeOriginal"], String) + end + end + + describe "#resolution" do + it "accepts units" do # skip_cli: :graphicsmagick + expect(subject.resolution("PixelsPerCentimeter")) + .wont_equal subject.resolution("PixelsPerInch") + end + end + + describe "#mime_type" do + it "returns the correct mime type" do + jpg = described_class.new(image_path(:jpg)) + expect(jpg.mime_type).must_equal "image/jpeg" + end + end + + describe "#details" do + # TODO: add after implementation + end + + describe "#data" do + # TODO: add after implementation + end + + describe "#layers" do + it "returns a list of images" do + expect_be_a(subject.layers, Array(CryMagick::Image)) + expect(subject.layers.first.valid?).must_equal true + end + + it "returns multiple images for GIFs, PDFs and PSDs" do + gif = described_class.new(image_path(:gif)) + + expect(gif.layers.size > 1).must_equal true + expect(gif.frames.size > 1).must_equal true + expect(gif.pages.size > 1).must_equal true + end + + it "returns one image for other formats" do + jpg = described_class.new(image_path(:jpg)) + + expect(jpg.layers.size).must_equal 1 + end + end + + describe "#combine_options" do + it "chains multiple options and executes them in one command" do + expect_to_change(->{ subject.dimensions }, to: {20, 30}) do + subject.combine_options { |c| c.resize "20x30!" } + end + end + + it "clears the info only at the end" do + subject.combine_options { |c| c.resize("20x30!"); subject.width } + expect(subject.dimensions).must_equal({20, 30}) + end + + it "returns self" do + expect(subject.combine_options { }).must_equal subject + end + end + + describe "#composite" do + @other_image : CryMagick::Image? + @mask : CryMagick::Image? + let(:other_image) { described_class.open(image_path) } + let(:mask) { described_class.open(image_path) } + + it "creates a composite of two images" do + image = subject.composite(other_image) + expect(image.valid?).must_equal true + end + + it "creates a composite of two images with mask" do + image = subject.composite(other_image, "jpg", mask) + expect(image.valid?).must_equal true + end + + it "makes the composited image with the provided extension" do + result = subject.composite(other_image, "png") + expect(result.path.ends_with?(".png")).must_equal true + end + + it "defaults the extension to the extension of the base image" do + subject = described_class.open(image_path(:jpg)) + result = subject.composite(other_image) + expect(result.path.ends_with? ".jpeg").must_equal true + + subject = described_class.open(image_path(:gif)) + result = subject.composite(other_image) + expect(result.path.ends_with? ".gif").must_equal true + end + end + + describe "#collapse!" do + let(:subject) { described_class.open(image_path(:animation)) } + + it "collapses the image to one frame" do + subject.collapse! + expect(subject.identify.lines.size).must_equal 1 + end + + it "keeps the extension" do + expect_not_to_change(->{ subject.type }) do + subject.collapse! + end + end + + it "clears the info" do + expect_to_change(->{ subject.size }) do + subject.collapse! + end + end + + it "returns self" do + expect(subject.collapse!).must_equal subject + end + end + + describe "#destroy!" do + it "deletes the underlying tempfile" do + image = described_class.open(image_path) + image.destroy! + + expect(File.exists?(image.path)).must_equal false + end + + it "doesn't delete when there is no tempfile" do + image = described_class.new(image_path) + image.destroy! + + expect(File.exists?(image.path)).must_equal true + end + + it "deletes .cache files generated by handling .mpc files" do + image = described_class.open(image_path) + image.format("mpc") + image.destroy! + + expect(File.exists?(image.path.sub(/mpc$/, "cache"))).must_equal false + end + end + + describe "#identify" do + it "returns the output of identify" do + expect(subject.identify).must_match(subject.type) + end + + it "yields an optional block" do + output = subject.identify do |b| + b.verbose + end + expect(output).must_match("Format:") + end + end + + describe "#run_command" do + it "runs the given command" do + output = subject.run_command("identify", "-format", "%w", subject.path) + expect(output).must_equal subject.width.to_s + end + end + + describe "#data" do + describe "when the data return is not an array" do + let(:subject) { described_class.new(image_path(:jpg)) } + + it "returns image JSON data" do + expect(subject.data["format"]).must_equal "JPEG" + expect(subject.data["colorspace"]).must_equal "sRGB" + end + end + + describe "when the data return is an array (ex png)" do + let(:subject) { described_class.new(image_path(:png)) } + + it "returns image JSON data" do + expect(subject.data["format"]).must_equal "PNG" + expect(subject.data["colorspace"]).must_equal "sRGB" + end + end + end +end diff --git a/spec/crymagick/shell_spec.cr b/spec/crymagick/shell_spec.cr new file mode 100644 index 0000000..bc29d1a --- /dev/null +++ b/spec/crymagick/shell_spec.cr @@ -0,0 +1,73 @@ +require "../spec_helper" + +describe Shell do + let(:subject) { CryMagick::Shell.new } + + describe "#run" do + it "returns stdout, stderr and status" do + output = subject.run(%w[echo "asd"]) + expect([output[0].to_s, output[1].to_s, output[2]]).must_equal ["asd\n", "", 0] + end + + it "raises an error when executable wasn't found" do + assert_raises(CryMagick::Error) do + subject.run(%w[foo]) + end + end + + it "raises errors only in whiny mode" do + subject.run(%w[foo], {:whiny => false}) + end + end + + describe "#execute" do + it "executes the command in the shell" do + stdout, stderr, status = subject.execute(["identify", "#{image_path(:gif)}"]) + + stdout = stdout.to_s + stderr = stderr.to_s + expect(stdout).must_match("GIF") + expect(stderr).must_equal "" + expect(status).must_equal 0 + + stdout, stderr, status = subject.execute(%w[identify foo]) + stdout = stdout.to_s + stderr = stderr.to_s + + expect(stdout).must_equal "" + expect(stderr).must_match("unable to open image `foo'") + expect(status).must_equal 256 + end + + it "handles larger output" do + # Timeout.timeout(1) do + stdout, a1, a2 = subject.execute(["convert", "#{image_path(:gif)}", "-"]) + expect(stdout.to_s).must_match("GIF") + # end + end + + it "returns an appropriate response when command wasn't found" do + stdout, stderr, code = subject.execute(%w[unexisting command]) + expect(code).must_equal 32512 + end + + # it "logs the command and execution time in debug mode" do + # MiniMagick.logger = Logger.new(stream = StringIO.new) + # MiniMagick.logger.level = Logger::DEBUG + # subject.execute(%W[identify #{image_path(:gif)}]) + # stream.rewind + # expect(stream.read).to match /\[\d+.\d+s\] identify #{image_path(:gif)}/ + # end + + # it "terminate long running commands if MiniMagick.timeout is set" do + # MiniMagick.timeout = 0.1 + # expect { subject.execute(%w[sleep 0.2]) }.to raise_error(Timeout::Error) + # MiniMagick.timeout = nil + # end + + it "doesn't break on spaces" do + stdout, a1, a2 = subject.execute(["identify", "-format", "%w %h", image_path]) + expect(stdout.to_s).must_match(/\d+ \d+/) + end + end +end diff --git a/spec/crymagick/tool_spec.cr b/spec/crymagick/tool_spec.cr new file mode 100644 index 0000000..0f24b0c --- /dev/null +++ b/spec/crymagick/tool_spec.cr @@ -0,0 +1,145 @@ +require "../spec_helper" + +describe CryMagick::Tool do + @subject : CryMagick::Tool::Identify? + let(:subject) { CryMagick::Tool::Identify.new } + let(:described_class) { CryMagick::Tool } + + describe "#call" do + it "calls the shell to run the command" do + subject << image_path(:gif) + output = subject.call + expect(output).must_match("GIF") + end + + it "strips the output" do + subject << image_path + output = subject.call + expect(output.ends_with?("\n")).must_equal(false) + end + + # it "accepts stdin" do + # subject << "-" + # output = subject.call({"stdin" => File.read(image_path)}) + # expect(output).must_match(/JPEG/) + # end + end + + describe ".new" do + it "accepts a block, and immediately executes the command" do + output = CryMagick::Tool.build("identify") do |builder| + builder << image_path(:gif) + end + expect(output).must_match("GIF") + end + end + + describe "#command" do + it "includes the executable and the arguments" do + subject.list("Command") + expect(subject.command).must_equal(%w(identify -list Command)) + end + end + + describe "#executable" do + # it "prepends 'gm' to the command list when using GraphicsMagick" do + # CryMagick::Configuration.cli = :graphicsmagick + # allow(CryMagick).to receive(:cli).and_return(:graphicsmagick) + # expect(subject.executable).must_equal %W(gm identify) + # end + + it "respects #cli_path" do + begin + CryMagick::Configuration.cli_path = "path/to/cli" + expect(subject.executable).must_equal %w(path/to/cli/identify) + ensure + CryMagick::Configuration.cli_path = "" + end + end + end + # #<< + describe "#append" do + it "adds argument to the args list" do + subject << "foo" << "bar" << 123 + expect(subject.args).must_equal %w(foo bar 123) + end + end + + describe "#merge!" do + it "adds arguments to the args list" do + subject << "pre-existing" + subject.merge! ["foo", 123] + expect(subject.args).must_equal %w(pre-existing foo 123) + end + end + + # #+ + describe "#plus" do + it "switches the last option to + form" do + subject.help + subject.help.+ + subject.debug.+ "foo" + subject.debug.+ 8, "bar" + expect(subject.args).must_equal %w(-help +help +debug foo +debug 8 bar) + end + end + + describe "#stdin" do + it "appends the '-' pseudo-filename" do + subject.stdin + expect(subject.args).must_equal %w(-) + end + end + + describe "#stdout" do + it "appends the '-' pseudo-filename" do + subject.stdout + expect(subject.args).must_equal %w(-) + end + end + + describe "#stack" do + it "it surrounds added arguments with parantheses" do + subject.stack do |stack| + stack << "foo" + stack << "bar" + end + expect(subject.args).must_equal ["\\(", "foo", "bar", "\\)"] + end + end + + describe "#clone" do + it "adds an option instead of the default behaviour" do + subject.clone + expect(subject.args).must_equal %w(-clone) + end + + it "accepts arguments" do + subject.clone(0) + expect(subject.args).must_equal %w(-clone 0) + end + + it "is convertable to plus version" do + subject.clone.+ + expect(subject.args).must_equal %w(+clone) + end + end + + describe "#method_missing" do + it "adds CLI options" do + subject.foo_bar("baz") + expect(subject.args).must_equal %w(-foo-bar baz) + end + end + + it "defines creation operator methods" do + subject.radial_gradient.canvas "khaki" + expect(subject.args).must_equal %w(radial-gradient: canvas:khaki) + end + + it "doesn't raise errors when false is passed to the constructor" do + subject.help + + CryMagick::Tool::Identify.build({:whiny => false}) { |b| b.help } + end +end diff --git a/spec/crymagick_spec.cr b/spec/crymagick_spec.cr new file mode 100644 index 0000000..e0afcaa --- /dev/null +++ b/spec/crymagick_spec.cr @@ -0,0 +1,4 @@ +require "./spec_helper" + +describe CryMagick do +end diff --git a/spec/fixtures/animation.gif b/spec/fixtures/animation.gif new file mode 100644 index 0000000..51c6dbe Binary files /dev/null and b/spec/fixtures/animation.gif differ diff --git a/spec/fixtures/badly_encoded_line.jpg b/spec/fixtures/badly_encoded_line.jpg new file mode 100644 index 0000000..30384b8 Binary files /dev/null and b/spec/fixtures/badly_encoded_line.jpg differ diff --git a/spec/fixtures/clipping_path.jpg b/spec/fixtures/clipping_path.jpg new file mode 100644 index 0000000..870170d Binary files /dev/null and b/spec/fixtures/clipping_path.jpg differ diff --git a/spec/fixtures/cylinder_shaded.png b/spec/fixtures/cylinder_shaded.png new file mode 100644 index 0000000..9f9407c Binary files /dev/null and b/spec/fixtures/cylinder_shaded.png differ diff --git a/spec/fixtures/default.jpg b/spec/fixtures/default.jpg new file mode 100644 index 0000000..5bef90c Binary files /dev/null and b/spec/fixtures/default.jpg differ diff --git a/spec/fixtures/empty_identify_line.png b/spec/fixtures/empty_identify_line.png new file mode 100644 index 0000000..8e09d5e Binary files /dev/null and b/spec/fixtures/empty_identify_line.png differ diff --git a/spec/fixtures/engine.png b/spec/fixtures/engine.png new file mode 100644 index 0000000..be2402e Binary files /dev/null and b/spec/fixtures/engine.png differ diff --git a/spec/fixtures/exif.jpg b/spec/fixtures/exif.jpg new file mode 100644 index 0000000..f685c49 Binary files /dev/null and b/spec/fixtures/exif.jpg differ diff --git a/spec/fixtures/not_an_image.cr b/spec/fixtures/not_an_image.cr new file mode 100644 index 0000000..e69de29 diff --git a/spec/fixtures/rgb.png b/spec/fixtures/rgb.png new file mode 100644 index 0000000..45bb929 Binary files /dev/null and b/spec/fixtures/rgb.png differ diff --git a/spec/fixtures/with:colon.jpg b/spec/fixtures/with:colon.jpg new file mode 100644 index 0000000..5bef90c Binary files /dev/null and b/spec/fixtures/with:colon.jpg differ diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr new file mode 100644 index 0000000..ff510b4 --- /dev/null +++ b/spec/spec_helper.cr @@ -0,0 +1,32 @@ +require "../src/crymagick" +require "minitest/autorun" +require "./support/helper" + +module Minitest + class Test + include Helper + extend Helper + + macro expect_to_change(block, **opts) + %old = {{block.id}}.call + {{yield}} + %new = {{block.id}}.call + {% if opts[:to] != nil %} + expect(%new).must_equal({{opts[:to]}}) + {% else %} + expect(%old).wont_equal(%new) + {% end %} + end + + macro expect_not_to_change(block) + %old = {{block.id}}.call + {{yield}} + %new = {{block.id}}.call + expect(%old).must_equal(%new) + end + + macro expect_be_a(obj, klass) + expect({{obj}}.class).must_equal({{klass}}) + end + end +end diff --git a/spec/support/helper.cr b/spec/support/helper.cr new file mode 100644 index 0000000..aabbb26 --- /dev/null +++ b/spec/support/helper.cr @@ -0,0 +1,46 @@ +module Helper + def image_path(type = :default) + if type != :jpg_without_extension + name = case type + when :default, :jpg + "default.jpg" + when :png + "engine.png" + when :animation, :gif + "animation.gif" + when :exif + "exif.jpg" + when :empty_identity_line + "empty_identity_line.png" + when :badly_encoded_line + "badly_encoded_line.jpg" + when :not + "not_an_image.cr" + when :colon + "with:colon.jpg" + when :clipping_path + "clipping_path.jpg" + when :rgb + "rgb.png" + when :rgb_tmp + "rgb_tmp.png" + else + raise "Image #{type} doesn't exist" + end + File.join("spec", "fixtures", name) + else + path = random_path + FileUtils.cp image_path, path + path + end + end + + def get_tempfile(path = "tempfile") + CryMagick::Tempfile.new(path) + end + + def random_path(basename = "") + tempfile = CryMagick::Tempfile.new(basename) + tempfile.path + end +end diff --git a/src/crymagick.cr b/src/crymagick.cr new file mode 100644 index 0000000..efbf462 --- /dev/null +++ b/src/crymagick.cr @@ -0,0 +1,6 @@ +require "./crymagick/configuration" +require "./crymagick/errors" +require "./crymagick/*" + +module CryMagick +end diff --git a/src/crymagick/configuration.cr b/src/crymagick/configuration.cr new file mode 100644 index 0000000..d8cbd7f --- /dev/null +++ b/src/crymagick/configuration.cr @@ -0,0 +1,37 @@ +require "logger" + +module CryMagick + module Configuration + # [logger, Logger?, nil] + macro class_property(name, type, default) + {% name = name.id %} + + @@{{name}} : {{type}} = {{default}} + + def self.{{name}} + @@{{name}} + end + + def self.{{name}}=(value : {{type}}) + @@{{name}} = value + end + end + + {% for option in [ + ["cli", Symbol, :imagemagick], + ["processor", Symbol, :mogrify], + ["cli_path", String, ""], + ["processor_path", String, "/usr/bin/mogrify"], + ["whiny", Bool, true], + ["validate_on_write", Bool, true], + ["validate_on_create", Bool, true], + ] %} + + class_property({{option[0]}}, {{option[1]}}, {{option[2]}}) + {% end %} + + def self.configure(&block) + yield self + end + end +end diff --git a/src/crymagick/errors.cr b/src/crymagick/errors.cr new file mode 100644 index 0000000..d86ee83 --- /dev/null +++ b/src/crymagick/errors.cr @@ -0,0 +1,7 @@ +module CryMagick + class Error < Exception + end + + class Invalid < Error + end +end diff --git a/src/crymagick/image.cr b/src/crymagick/image.cr new file mode 100644 index 0000000..dbcf766 --- /dev/null +++ b/src/crymagick/image.cr @@ -0,0 +1,257 @@ +require "file_utils" + +module CryMagick + class Image + # reads given file + def self.read(file : String, ext : String = "") + create(ext) { |temp| temp.print(file) } + end + + def self.read(file : IO, ext : String = "") + create(ext) { |temp| temp.print(file.gets_to_end) } + end + + # creates new Image from given path + def self.open(path : String, ext : String? = nil) + raise "File is not exists" unless File.exists?(path) + ext ||= File.extname(path) + # TODO: allow to pass url + File.open(path) do |f| + read(f, ext) + end + end + + # creates tempfile and yield it to write + def self.create(ext : String? = nil, validate : Bool = Configuration.validate_on_create) + tempfile = CryMagick::Utilities.tempfile(ext.to_s.downcase) { |t| yield t } + new(tempfile.path, tempfile).tap do |image| + image.validate! if validate + end + end + + getter path, tempfile : CryMagick::Tempfile? + + def tempfile! + @tempfile.not_nil! + end + + def initialize(@path : String, @tempfile = nil) + @info = Info.new(@path) + end + + def ==(other : Image) + signature == other.signature + end + + def hash + signature.hash + end + + def valid? + validate! + true + rescue CryMagick::Invalid + false + end + + def validate! + identify + rescue error : Error + raise Invalid.new(error.message) + end + + macro attribute(name, key = nil) + {% _name = (key == nil) ? name : key %} + def {{name.id}} + @info.{{_name.id}} + end + end + + attribute :type, "format" + attribute :mime_type + attribute :width + attribute :height + attribute :dimensions + attribute :size + attribute :human_size + attribute :colorspace + attribute :exif + attribute :signature + attribute :data + attribute :details + + def resolution(unit : String = "") + @info.resolution(unit) + end + + def [](value) + @info[value.to_s] + end + + def layers + layers_count = identify.lines.size + buf = [] of Image + layers_count.times do |i| + buf << Image.new("#{path}[#{i}]") + end + buf + end + + def pages + layers + end + + def frames + layers + end + + def get_pixels + output = Tool::Convert.build do |con| + convert << path + convert.depth(8) + convert << "RGB:-" + end + + pixel_array = output.unpack("C*") + pixels = pixel_array.each_slice(3).each_slice(width).to_a + output.clear + pixel_array.clear + pixels + end + + # page = -1 for all frames + # TODO: fix converting several frames - point current image to first one (now it points to empty img) + def format(_format, page : String = "0", read_options : Hash(String, String) = {} of String => String) + new_temp_file = nil + new_path = if @tempfile + new_temp_file = Utilities.tempfile(".#{_format}") + new_temp_file.path + else + path.split(".")[0...-1].join("") + ".#{_format}" + end + input_path = path.clone + input_path += "[#{page}]" if page != "-1" && !layer? + + Tool::Convert.build do |con| + read_options.each do |key, value| + con.send(key, value) + end + + con << input_path + yield con + con << new_path + end + + if @tempfile + destroy! + @tempfile = new_temp_file.not_nil! + else + File.delete(path) unless path == new_path || layer? + end + @path = new_path + @info.clear + @info = Info.new(@path) + + self + end + + def format(_format, page : String = "0", read_options : Hash(String, String) = {} of String => String) + format(_format, page, read_options) { } + end + + def combine_options + mogrify { |m| yield m } + end + + def write(output_to : String) + if layer? + Tool::Convert.build do |builder| + builder << path + builder << output_to + end + else + FileUtils.cp(path, output_to) unless path == output_to + end + end + + def write(output : IO) + output.print(File.read(path)) + end + + def composite(other_image, output_ext = type.downcase, mask : String? = nil) + output_tempfile = Utilities.tempfile(".#{output_ext}") + + Tool::Composite.build do |comp| + yield comp + comp << other_image.path + comp << path + comp << mask.path if mask + comp << output_tempfile.path + end + + Image.new(output_tempfile.path, output_tempfile) + end + + def composite(other_image, output_ext = type.downcase, mask : String? = nil) + composite(other_image, output_ext, mask) { } + end + + def collapse!(frame : Int32 = 0) + mogrify(frame) { |builder| builder.quality(100) } + end + + def destroy! + return unless @tempfile + FileUtils.rm_rf(tempfile!.path.sub(/mpc$/, "cache")) if tempfile!.path.ends_with?(".mpc") + tempfile!.unlink + end + + def identify + Tool::Identify.build do |builder| + builder << path + end + end + + def identify(&block) + Tool::Identify.build do |builder| + yield builder + builder << path + end + end + + def mogrify(page : Int32? = nil) + mogrify(page) { } + end + + def mogrify(page : Int32? = nil) + Tool::Mogrify.build do |builder| + yield builder + builder << (page ? "#{path}[#{page.to_s}]" : path) + end + @info.clear + self + end + + def layer? + path =~ /\[\d+\]$/ + end + + def run_command(tool_name : String, *args) + Tool.build(tool_name) do |builder| + args.each do |arg| + builder << arg + end + end + end + + macro method_missing(call) + def {{call.name.id}}(*args) + mogrify do |builder| + builder.{{call.name.id}}(*args) + end + end + end + end +end + +require "./image/info" diff --git a/src/crymagick/image/info.cr b/src/crymagick/image/info.cr new file mode 100644 index 0000000..c36f46e --- /dev/null +++ b/src/crymagick/image/info.cr @@ -0,0 +1,201 @@ +require "json" + +module CryMagick + class Image + class Info + ASCII_ENCODED_EXIF_KEYS = %w(ExifVersion FlashPixVersion) + ALL_ATTRS = %w(format mime_type width height dimensions size human_size colorspace exif resolution signature) + STRING_ATTRS = %w(format mime_type human_size colorspace signature data details) + INT_ATTR = %w(width height) + + @dimensions : Tuple(Int32, Int32)? + @size : UInt64? + @exif : Hash(String, String)? + @data : Hash(String, Hash(String, String))? + @details : Hash(String, Hash(String, String))? + + {% for var in STRING_ATTRS %} + @{{var.id}} : String? + {% end %} + + {% for var in INT_ATTR %} + @{{var.id}} : Int32? + {% end %} + + def initialize(@path : String) + @info = {} of String => String + @resolution = {} of String => Tuple(Float64, Float64) + end + + def [](value, *args) + _value = value.to_s + {% for attr in ALL_ATTRS %} + return {{attr.id}} if _value == "{{attr.id}}" + {% end %} + raw(value) + end + + def clear + @info.clear + @resolution.clear + {% for attr in ALL_ATTRS %} + {% if attr != "resolution" %} + @{{attr.id}} = nil + {% end %} + {% end %} + end + + def mime_type + "image/#{format.downcase}" + end + + def colorspace + @colorspace ||= raw("%r") + end + + def exif + @exif ||= begin + hash = {} of String => String + output = raw("%[EXIF:*]") + + output.each_line do |line| + line = line.chomp("\n") + + case Configuration.cli + when :imagemagick + if match = line.match(/^exif:/) + key, value = match.post_match.split("=", 2) + value = decode_comma_separated_ascii_characters(value) if ASCII_ENCODED_EXIF_KEYS.includes?(key) + hash[key] = value + else + hash[hash.keys.last] += "\n#{line}" + end + when :graphicsmagick + key, value = line.split("=", 2) + hash[key] = value.gsub("\\012", "\n") # convert "\012" characters to newlines + end + end + + hash + end + end + + def resolution(unit = "") + @resolution[unit] ||= begin + output = identify do |b| + b.units(unit) unless unit.empty? + b.format("%x %y") + end + values = output.split(" ") + {values[0].to_f, values[1].to_f} + end + end + + def signature + @signature ||= raw("%#") + end + + def data + raise "Not implemented" + end + + def details + raise "Not implemented yet" + raise "CryMagick::Image#details is deprecated, as it was causing too many parsing errors. You should use CryMagick::Image#data instead" if MiniMagick.imagemagick? + + @details ||= begin + details_string = identify(&.verbose) + key_stack = [] of String + details_string.lines.to_a[1..-1].each_with_object({} of String => String) do |line, details_hash| + next if !line.valid_encoding? || line.strip.length.zero? + + level = line[/^\s*/].length / 2 - 1 + if level >= 0 + until key_stack.size <= level + key_stack.pop + end + else + # Some metadata, such as SVG clipping paths, will be saved without + # indentation, resulting in a level of -1 + last_key = details_hash.keys.last + details_hash[last_key] = "" if details_hash[last_key].empty? + details_hash[last_key] += line + next + end + + key, _, value = line.partition(/:[\s\n]/).map(&:strip) + hash = key_stack.inject(details_hash) { |hash, key| hash.fetch(key) } + if value.empty? + hash[key] = {} of String => String + key_stack.push key + else + hash[key] = value + end + end + end + end + + def data + json = Tool::Convert.build do |convert| + convert << path + convert << "json:" + end + + data = JSON.parse(json) + data["image"] + end + + {% for attr in %w(format width height dimensions size human_size) %} + def {{attr.id}} + return @{{attr.id}}.not_nil! if @{{attr.id}} + cheap_info({{attr}}) + @{{attr.id}}.not_nil! + end + {% end %} + + def cheap_info(value) + format, width, height, size = self["%m %w %h %b"].as(String).split(" ") + + path = @path + path = path.match(/\[\d+\]$/).not_nil!.pre_match if path =~ /\[\d+\]$/ + @format = format + @width = width.to_i + @height = height.to_i + @dimensions = {@width.not_nil!, @height.not_nil!} + @size = File.size(path) + @human_size = size + end + + def raw(value) + @info["raw:#{value}"] ||= identify { |b| b.format(value) } + end + + def raw_exif(value) + raw("%[#{value}]") + end + + def identify : String + Tool::Identify.build do |builder| + yield builder + builder << path + end + end + + private def path + value = @path + value += "[0]" unless value =~ /\[\d+\]$/ + value + end + + private def decode_comma_separated_ascii_characters(encoded_value) + return encoded_value unless encoded_value.includes?(",") + arr = [] of Char + res = encoded_value.scan(/\d+/) + res.size.times do |i| + arr << res[i].to_s.to_i.chr + end + arr.join + end + end + end +end diff --git a/src/crymagick/shell.cr b/src/crymagick/shell.cr new file mode 100644 index 0000000..e34fce7 --- /dev/null +++ b/src/crymagick/shell.cr @@ -0,0 +1,23 @@ +module CryMagick + class Shell + def run(command, options : Hash(Symbol, Bool | String) = {} of Symbol => Bool | String) + whiny = options.has_key?(:whiny) ? options[:whiny] : Configuration.whiny + stdout, stderr, status = execute(command) + if status != 0 && whiny + raise Error.new("`#{command.join(" ")}` failed with error(#{status}):\n#{stderr.to_s}\noutput:\n#{stdout}") + end + [stdout, stderr, status] + end + + def execute(command : Array(String)) + output = IO::Memory.new + error = IO::Memory.new + command[1..-1].each_with_index do |e, i| + j = i + 1 + command[j] = "'#{e}'" if e.includes?(' ') + end + res = Process.run(command.join(" "), shell: true, output: output, error: error) + [output, error, res.exit_status] + end + end +end diff --git a/src/crymagick/tool.cr b/src/crymagick/tool.cr new file mode 100644 index 0000000..224bb8d --- /dev/null +++ b/src/crymagick/tool.cr @@ -0,0 +1,112 @@ +module CryMagick + class Tool + CREATION_OPERATORS = %w(xc canvas logo rose gradient radial-gradient plasma pattern label caption text) + + def self.build(name : String) : String + instance = new(name) + yield instance + instance.call + end + + getter name : String, args + @whiny : Bool + + def initialize(@name) + @args = [] of String + @whiny = Configuration.whiny + end + + def initialize(@name, options : Hash(Symbol, Bool) = {} of Symbol => Bool) + @args = [] of String + @whiny = options.has_key?(:whiny) ? options[:whiny] : Configuration.whiny + end + + def call : String + shell = Shell.new + stdout, status, stderr = shell.run(command, {:whiny => @whiny}) + stdout.to_s.strip + end + + def call(&block) + shell = Shell.new + stdout, stderr, status = shell.run(command, {:whiny => @whiny}) + yield stdout, stderr, status + stdout.to_s.strip + end + + def command + arr = [] of String + arr.concat(executable) + arr.concat(args) + arr + end + + def executable + exe = [name] + exe.unshift File.join(Configuration.cli_path, exe.shift) unless Configuration.cli_path.empty? + exe + end + + def <<(arg) + args << arg.to_s + self + end + + def send(name, *opts) + args << "-#{name}" + merge!(opts) + end + + def merge!(new_args) + new_args.each { |arg| self << arg } + self + end + + def +(*values) + args[-1] = args[-1].sub(/^-/, "+") + merge!(values) + self + end + + def stack + self << "\\(" + yield self + self << "\\)" + end + + def stdin + self << "-" + end + + def stdout + self << "-" + end + + {% for operator in CREATION_OPERATORS %} + def {{operator.tr("-", "_").id}}(value) + self << "{{operator.id}}:#{value.to_s}" + self + end + + def {{operator.tr("-", "_").id}} + self << "{{operator.id}}:" + self + end + {% end %} + + def clone(*args) + send("clone", *args) + end + + # Currently notification about dynamically generated methods will be printed out + # to stdout during compilation + macro method_missing(call) + {% p "Dynamically generates method #{@type}##{call.id}".id %} + def {{call.name.id}}(*args) + send({{call.name.tr("_", "-").id.stringify}}, *args) + end + end + end +end + +require "./tool/*" diff --git a/src/crymagick/tool/animate.cr b/src/crymagick/tool/animate.cr new file mode 100644 index 0000000..ba44c49 --- /dev/null +++ b/src/crymagick/tool/animate.cr @@ -0,0 +1,18 @@ +module CryMagick + class Tool + # + # @see http://www.imagemagick.org/script/animate.php + # + class Animate < CryMagick::Tool + def initialize(options : Hash(Symbol, Bool) = {} of Symbol => Bool) + super("animate", options) + end + + def self.build(options : Hash(Symbol, Bool) = {} of Symbol => Bool) + instance = new(options) + yield instance + instance.call + end + end + end +end diff --git a/src/crymagick/tool/compare.cr b/src/crymagick/tool/compare.cr new file mode 100644 index 0000000..45ba510 --- /dev/null +++ b/src/crymagick/tool/compare.cr @@ -0,0 +1,18 @@ +module CryMagick + class Tool + # For more details visit + # [page](see http://www.imagemagick.org/script/compare.php) + # + class Compare < CryMagick::Tool + def initialize(options : Hash(Symbol, Bool) = {} of Symbol => Bool) + super("compare", options) + end + + def self.build(options : Hash(Symbol, Bool) = {} of Symbol => Bool) + instance = new(options) + yield instance + instance.call + end + end + end +end diff --git a/src/crymagick/tool/composite.cr b/src/crymagick/tool/composite.cr new file mode 100644 index 0000000..90aaf43 --- /dev/null +++ b/src/crymagick/tool/composite.cr @@ -0,0 +1,15 @@ +module CryMagick + class Tool + class Composite < CryMagick::Tool + def initialize(options = {} of Symbol => Bool) + super("composite", options) + end + + def self.build(options : Hash(Symbol, Bool) = {} of Symbol => Bool) + instance = new(options) + yield instance + instance.call + end + end + end +end diff --git a/src/crymagick/tool/conjure.cr b/src/crymagick/tool/conjure.cr new file mode 100644 index 0000000..1512f28 --- /dev/null +++ b/src/crymagick/tool/conjure.cr @@ -0,0 +1,18 @@ +module CryMagick + class Tool + ## + # @see http://www.imagemagick.org/script/conjure.php + # + class Conjure < CryMagick::Tool + def initialize(options : Hash(Symbol, Bool) = {} of Symbol => Bool) + super("conjure", options) + end + + def self.build(options : Hash(Symbol, Bool) = {} of Symbol => Bool) + instance = new(options) + yield instance + instance.call + end + end + end +end diff --git a/src/crymagick/tool/convert.cr b/src/crymagick/tool/convert.cr new file mode 100644 index 0000000..19ca2a5 --- /dev/null +++ b/src/crymagick/tool/convert.cr @@ -0,0 +1,15 @@ +module CryMagick + class Tool + class Convert < CryMagick::Tool + def initialize(options = {} of Symbol => Bool) + super("convert", options) + end + + def self.build(options : Hash(Symbol, Bool) = {} of Symbol => Bool) + instance = new(options) + yield instance + instance.call + end + end + end +end diff --git a/src/crymagick/tool/display.cr b/src/crymagick/tool/display.cr new file mode 100644 index 0000000..ce8e214 --- /dev/null +++ b/src/crymagick/tool/display.cr @@ -0,0 +1,18 @@ +module CryMagick + class Tool + ## + # @see http://www.imagemagick.org/script/display.php + # + class Display < CryMagick::Tool + def initialize(options : Hash(Symbol, Bool) = {} of Symbol => Bool) + super("display", options) + end + + def self.build(options : Hash(Symbol, Bool) = {} of Symbol => Bool) + instance = new(options) + yield instance + instance.call + end + end + end +end diff --git a/src/crymagick/tool/identify.cr b/src/crymagick/tool/identify.cr new file mode 100644 index 0000000..729cac0 --- /dev/null +++ b/src/crymagick/tool/identify.cr @@ -0,0 +1,18 @@ +module CryMagick + class Tool + ## + # @see http://www.imagemagick.org/script/identify.php + # + class Identify < CryMagick::Tool + def initialize(options : Hash(Symbol, Bool) = {} of Symbol => Bool) + super("identify", options) + end + + def self.build(options : Hash(Symbol, Bool) = {} of Symbol => Bool) + instance = new(options) + yield instance + instance.call + end + end + end +end diff --git a/src/crymagick/tool/import.cr b/src/crymagick/tool/import.cr new file mode 100644 index 0000000..3ae41c0 --- /dev/null +++ b/src/crymagick/tool/import.cr @@ -0,0 +1,18 @@ +module CryMagick + class Tool + ## + # @see http://www.imagemagick.org/script/import.php + # + class Import < CryMagick::Tool + def initialize(options : Hash(Symbol, Bool) = {} of Symbol => Bool) + super("import", options) + end + + def self.build(options : Hash(Symbol, Bool) = {} of Symbol => Bool) + instance = new(options) + yield instance + instance.call + end + end + end +end diff --git a/src/crymagick/tool/mogrify.cr b/src/crymagick/tool/mogrify.cr new file mode 100644 index 0000000..bd4c2a6 --- /dev/null +++ b/src/crymagick/tool/mogrify.cr @@ -0,0 +1,15 @@ +module CryMagick + class Tool + class Mogrify < CryMagick::Tool + def initialize(options = {} of Symbol => Bool) + super("mogrify", options) + end + + def self.build(options : Hash(Symbol, Bool) = {} of Symbol => Bool) + instance = new(options) + yield instance + instance.call + end + end + end +end diff --git a/src/crymagick/tool/montage.cr b/src/crymagick/tool/montage.cr new file mode 100644 index 0000000..c024033 --- /dev/null +++ b/src/crymagick/tool/montage.cr @@ -0,0 +1,18 @@ +module CryMagick + class Tool + ## + # @see http://www.imagemagick.org/script/montage.php + # + class Montage < CryMagick::Tool + def initialize(options : Hash(Symbol, Bool) = {} of Symbol => Bool) + super("montage", options) + end + + def self.build(options : Hash(Symbol, Bool) = {} of Symbol => Bool) + instance = new(options) + yield instance + instance.call + end + end + end +end diff --git a/src/crymagick/tool/stream.cr b/src/crymagick/tool/stream.cr new file mode 100644 index 0000000..8284da6 --- /dev/null +++ b/src/crymagick/tool/stream.cr @@ -0,0 +1,18 @@ +module CryMagick + class Tool + ## + # @see http://www.imagemagick.org/script/stream.php + # + class Stream < CryMagick::Tool + def initialize(options : Hash(Symbol, Bool) = {} of Symbol => Bool) + super("stream", options) + end + + def self.build(options : Hash(Symbol, Bool) = {} of Symbol => Bool) + instance = new(options) + yield instance + instance.call + end + end + end +end diff --git a/src/crymagick/utilities.cr b/src/crymagick/utilities.cr new file mode 100644 index 0000000..1822133 --- /dev/null +++ b/src/crymagick/utilities.cr @@ -0,0 +1,74 @@ +module CryMagick + class Tempfile < IO::FileDescriptor + ALLOWED_SYMBOLS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + FILE_CREATING_ATTEMPS = 238328 + TMP_DIR_PATH = "/tmp" + + getter path : String + + def initialize(name : String, existing : Bool = false) + oflag = LibC::O_RDWR | LibC::O_CREAT | LibC::O_APPEND | LibC::O_CLOEXEC + @path = existing ? name : self.class.temp_file_path(name) + fd = LibC.open(@path.check_no_null_byte, oflag, 420) + raise Errno.new("Error opening tempfile '#{@path}'") if fd < 0 + super(fd, blocking: true) + end + + def self.open(filename : String, &block) + tempfile = Tempfile.new(filename) + begin + yield tempfile + ensure + tempfile.close + end + tempfile + end + + def self.tmp_name(pattern : String) : String + raise Errno.new("tmp_name (#{pattern})") if pattern.size < 6 || !pattern.includes?("XXXXXX") + FILE_CREATING_ATTEMPS.times do + part = "#{ALLOWED_SYMBOLS[rand(62)]}#{ALLOWED_SYMBOLS[rand(62)]}#{ALLOWED_SYMBOLS[rand(62)]}#{ALLOWED_SYMBOLS[rand(62)]}#{ALLOWED_SYMBOLS[rand(62)]}#{ALLOWED_SYMBOLS[rand(62)]}" + new_path = pattern.sub("XXXXXX", part) + next if File.exists?(new_path) + return new_path + end + + raise Error.new("No free tempfile name") + end + + def self.temp_file_path(name : String) : String + tmpdir = dirname + File::SEPARATOR + Tempfile.tmp_name("#{tmpdir}XXXXXX.#{name}") + end + + def self.dirname : String + tmpdir = ENV["TMPDIR"]? || TMP_DIR_PATH + tmpdir = tmpdir + File::SEPARATOR unless tmpdir.ends_with? File::SEPARATOR + File.dirname(tmpdir) + end + + def delete + File.delete(@path) + end + + def unlink + delete + end + end + + module Utilities + # Raise temp file with given dot-based extension + def self.tempfile(ext : String) : CryMagick::Tempfile + CryMagick::Tempfile.open("crymagick#{ext}") do |file| + yield file + end + end + + def self.tempfile(ext) : CryMagick::Tempfile + CryMagick::Tempfile.new("crymagick#{ext}") + end + + # def gc + # end + end +end diff --git a/src/crymagick/version.cr b/src/crymagick/version.cr new file mode 100644 index 0000000..6e531d6 --- /dev/null +++ b/src/crymagick/version.cr @@ -0,0 +1,3 @@ +module CryMagick + VERSION = "0.1.0" +end