ballonizer 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (5) hide show
  1. checksums.yaml +7 -0
  2. data/Rakefile +63 -0
  3. data/lib/ballonizer.rb +401 -0
  4. data/spec/ballonizer_spec.rb +539 -0
  5. metadata +245 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 9b02cccd0f9fc456b32c0172a91aa790b3d06132
4
+ data.tar.gz: 0ca2e4a60db0d971dc0b1aa443e208efef0d7a5b
5
+ SHA512:
6
+ metadata.gz: f6fdc80bedf5f03cca40990d7608acd67e402f6c4e21923610389c20a285a2639a50eedd8456e983584303bf405e5ce1cf7764cce9f35a9e87e6887dbb965027
7
+ data.tar.gz: d599207ecde8c8dd194a05c5b4582ec0ccda398def397d26618a9526cd1242a91e3197c18bbea8d0b279d3b640dd3f8050dc23c6c1e11cddea82a1d681396b5e
data/Rakefile ADDED
@@ -0,0 +1,63 @@
1
+ require 'rspec/core/rake_task'
2
+ require 'jshintrb/jshinttask'
3
+ require 'jasmine-headless-webkit'
4
+ require 'sequel'
5
+ require 'ballonizer'
6
+
7
+ Jasmine::Headless::Task.new('jasmine') do |t|
8
+ t.colors = true
9
+ t.keep_on_error = true
10
+ t.jasmine_config = 'spec/javascripts/support/jasmine.yml'
11
+ end
12
+
13
+ RSpec::Core::RakeTask.new :spec
14
+
15
+ Jshintrb::JshintTask.new :jshint do |t|
16
+ t.pattern = '{lib/ballonizer/*.js,spec/javascripts/*.js}'
17
+ t.options = {
18
+ bitwise: true,
19
+ camelcase: true,
20
+ es3: true,
21
+ immed: true,
22
+ indent: 4,
23
+ latedef: true,
24
+ strict: true,
25
+ curly: true,
26
+ eqeqeq: true,
27
+ eqnull: true,
28
+ immed: true,
29
+ noarg: true,
30
+ trailing: true,
31
+ unused: true,
32
+ jquery: true,
33
+ white: true
34
+ }
35
+ end
36
+
37
+ namespace :db do
38
+ desc 'Create/Reset the tables used by ballonizer in a database, the default' +
39
+ 'database is the example (examples/ballonizer_app/test.db), but you' +
40
+ 'can specify one with "rake db:reset[\'sqlite:///absolute/path/to/' +
41
+ 'database_name.db\']" (check sequel support to another databases).'
42
+ task :reset, :database_path do | t, args |
43
+ database_path = args[:database_path] || 'sqlite://examples/ballonizer_app/test.db'
44
+ Sequel.connect(database_path) do | db |
45
+ # The order is important, can throw Foreign Key Constraint Violation otherwise
46
+ tables = [:ballonized_image_ballons,
47
+ :ballonized_image_versions,
48
+ :images,
49
+ :ballons]
50
+ db.drop_table?(*tables)
51
+ Ballonizer.create_tables(db)
52
+ end
53
+ end
54
+ end
55
+
56
+ desc 'Run an example of the lib use in http://localhost:9292'
57
+ task :example do
58
+ sh 'rackup examples/ballonizer_app/config.ru'
59
+ end
60
+
61
+ desc 'Run the ruby and the javascript module specs and the jshint'
62
+ task :default => [:spec, :jasmine, :jshint]
63
+
data/lib/ballonizer.rb ADDED
@@ -0,0 +1,401 @@
1
+ require 'nokogiri'
2
+ require 'addressable/uri'
3
+ require 'sequel'
4
+ require 'json'
5
+ require 'htmlentities'
6
+ require 'rack'
7
+
8
+ # This gem provides mechanisms to allow ballons (or speech bubbles) to be
9
+ # added/removed/edited over images of a HTML or XHTML document and to be
10
+ # persisted. The edition of the ballons is possible by the javascript module
11
+ # provided by this gem. The persistence is allowed by the Ballonizer class.
12
+ # The Ballonizer class is basically a wrapper around the database used to
13
+ # persist the ballons, and offer methods to process the requests made by
14
+ # the client side (by a form created by the javascript module), and to modify
15
+ # a (X)HTML document adding the ballons of the image over it.
16
+ #
17
+ # This class lacks a lot of features like: access to an abstraction of the
18
+ # ballons, images and their relationship; control over users who edit the
19
+ # ballons; access to the old versions of the ballon set of a image (who
20
+ # are stored in the database, but only can be accessed directly by the
21
+ # Sequel::Database object). It's a work in progress, be warned to use
22
+ # carefully and motivated to contribute.
23
+ #
24
+ # The JavaScript library used to allow edition in the client side works
25
+ # as follows: double click over the image add a ballon, double click over
26
+ # a ballon allow edit the text, when the ballon lose the focus it returns
27
+ # to the non-edition state, a ballon without text (or only with spaces) it's
28
+ # automatically removed when lose focus, drag the ballon change its position
29
+ # (restricted to image space), drag ballon by the right-bottom handle
30
+ # resize the ballon (also restricted to image space). Any change in the ballons
31
+ # make visible a button fixed in the right-top corner of the browser viewport.
32
+ # Every time a ballons is changed (or added/removed) the json of a hidden
33
+ # form is updated. The button submits this json by POST request to the url
34
+ # configured by :form_handler_url setting.
35
+ #
36
+ # To the image be 'ballonized' it have to match the :img_to_ballonize_css_selector.
37
+ # The 'ballonized' term here means: have the ballons added over the image in
38
+ # ballonize_page.
39
+ #
40
+ # To use this class with your (rack isn't?) app you need to: create the
41
+ # necessary tables in a Sequel::Database object with Ballonizer.create_tables;
42
+ # create a ballonizer instance with the url where you gonna handle the ballon
43
+ # change requests. Handle the ballon changes request in that url with
44
+ # process_submit. Call instance.ballonize_page over the html documents who can
45
+ # have the images to be ballonized. Include the jquery, jquery-json, jquery-ui,
46
+ # and ballonizer javascript libraries in the page (provided by the gem); Include
47
+ # the ui-lightness and ballonizer css in the page (provided by the gem).
48
+ # Check if the image match the css selector :img_to_ballonize_css_selector.
49
+ #
50
+ # What's explained above is basically the example you can access with
51
+ # 'rake example' and is in the examples/ballonizer_app/config.ru file.
52
+ # You can reset the database with 'rake db:reset' (and if you pass an argument
53
+ # as 'rake db:reset[postgres://user:password@host:port/database_name]'
54
+ # you can create the tables in the database already used by your app).
55
+ # The tables names are: images, ballons, ballonized_image_versions,
56
+ # ballonized_image_ballons.
57
+ #
58
+ class Ballonizer
59
+
60
+ # The superclass of any error explicitly raised by the Ballonizer class.
61
+ class Error < ArgumentError; end
62
+ # The class used in exceptions related to a invalid value for a submit.
63
+ class SubmitError < Error; end
64
+
65
+ attr_accessor :db, :settings
66
+
67
+ # The default #settings
68
+ DEFAULT_SETTINGS = {
69
+ # The css selector used to define the elements to ballonize.
70
+ img_to_ballonize_css_selector: 'img.to_ballonize',
71
+ # A url to be used in the client-side action attribute of the form for
72
+ # ballon submition. The value will be used in the javascript snippet who
73
+ # initialize the ballonizer client javascript allowing ballon edition
74
+ # (and consequently creating the form).
75
+ form_handler_url: '#',
76
+ # Define if the javascript code who allow edition will be added to the page.
77
+ # (this don't refer to the jquery-* libs and the ballonizer.js only the
78
+ # snippet to execute when the page is ready)
79
+ add_js_for_edition: true
80
+ }.freeze.each { | _, v| v.freeze }
81
+
82
+ # Create a new Ballonizer object from a Sequel Database (with the expected
83
+ # tables, who can be created with Ballonizer.create_tables) and a optional
84
+ # hash of settings.
85
+ # @param db [Sequel::Database] A Sequel::Database with tables as described
86
+ # @param settings [Hash{Symbol => String}] A optional hash of settings. The
87
+ # default value and explanation of each option are documented in the
88
+ # DEFAULT_SETTINGS constant.
89
+ # @return [Ballonizer] A new ballonizer instance.
90
+ # @see Ballonizer.create_tables
91
+ def initialize(db, settings = {})
92
+ @db = db
93
+ @settings = DEFAULT_SETTINGS.merge(settings)
94
+ end
95
+
96
+ # Convenience method for process_submit_json, extract the json from the
97
+ # request, validate and pass to the method.
98
+ # @param env A env Rack hash.
99
+ # @return [Ballonizer] The self, to allow chaining.
100
+ # @raise [JSON::ParserError, Ballonizer::SubmitError]
101
+ # @see process_submit_json
102
+ def process_submit(env, time = nil)
103
+ request = Rack::Request.new(env)
104
+ submit_json = request['ballonizer_data']
105
+ valid_submit_json?(submit_json, true)
106
+ process_submit_json(submit_json, time)
107
+ end
108
+
109
+ # Verify if the json is a valid output from the client counterpart.
110
+ # If the argument is valid untaint, otherwise taint (unless it's frozen).
111
+ # If the second parameter argument is true the method will throw
112
+ # exceptions when the input is invalid.
113
+ # @param submit_json [String] A JSON String.
114
+ # @param throw_exceptions [FalseClass,TrueClass] Define behaviour when the
115
+ # input is invalid. If true throw exceptions, otherwise only return false.
116
+ # Default value: false (don't throw exceptions).
117
+ # @return [true, false]
118
+ # @raise [JSON::ParserError, Ballonizer::SubmitError]
119
+ # @see valid_submit_hash?
120
+ # @note This is a instance method because, in the future, the validation
121
+ # can depend of instance settings.
122
+ def valid_submit_json?(submit_json, throw_exceptions=false)
123
+ parsed_submit = JSON.parse(submit_json)
124
+ valid_submit_hash?(parsed_submit, true)
125
+ submit_json.untaint unless submit_json.frozen?
126
+ true
127
+ rescue JSON::ParserError, SubmitError => e
128
+ submit_json.taint unless submit_json.frozen?
129
+ raise e if throw_exceptions
130
+ false
131
+ end
132
+
133
+ # Act as #valid_submit_json, but over a already parsed json and don't
134
+ # (un)taint the hash.
135
+ # @param submit_hash [Hash] A parsed JSON.
136
+ # @return [true, false]
137
+ # @raise [Ballonizer::SubmitError]
138
+ # @see valid_submit_json?
139
+ # @note This is a instance method because, in the future, the validation
140
+ # can depend of instance settings.
141
+ def valid_submit_hash?(submit_hash, throw_exceptions=false)
142
+ if submit_hash.empty?
143
+ fail SubmitError, "the submit request is empty"
144
+ end
145
+
146
+ submit_hash.each do | img_src, ballons |
147
+ unless img_src.is_a?(String)
148
+ # TODO: validate if valid URI?
149
+ # TODO: define img_src max lenght?
150
+ fail SubmitError, "the image src is a '#{img_src.class}' and not a String"
151
+ end
152
+ unless Addressable::URI.parse(img_src).absolute?
153
+ fail SubmitError, "the image src ('#{img_src.class}') is not an absolute URI"
154
+ end
155
+ unless ballons.is_a?(Array)
156
+ fail SubmitError, "the image with src '#{img_src}' is key of a " +
157
+ "'#{ballons.class}' and not a Array"
158
+ end
159
+
160
+ ballons.each do | ballon |
161
+ unless ballon["text"].is_a?(String)
162
+ fail SubmitError, "the ballon text is a '#{ballon.class}' and not" +
163
+ " a String"
164
+ end
165
+ if ballon["text"].empty?
166
+ fail SubmitError, "the ballon text is empty"
167
+ end
168
+ [:top, :left, :width, :height].each do | bound_name |
169
+ bound = ballon[bound_name.to_s]
170
+ unless bound.is_a?(Fixnum) || bound.is_a?(Float)
171
+ fail SubmitError, "the #{bound_name.to_s} (#{bound.to_s}) isn't" +
172
+ " a Fixnum or Float (is a '#{bound.class.to_s}')"
173
+ end
174
+ unless bound >= 0 && bound <= 1
175
+ fail SubmitError, "the #{bound_name.to_s} (#{bound.to_s}) isn't"
176
+ " between 0 and 1 (both inclusive)"
177
+ end
178
+ end
179
+
180
+ ballon_end = {}
181
+ ballon_end[:x] = ballon["top"] + ballon["height"]
182
+ ballon_end[:y] = ballon["left"] + ballon["width"]
183
+
184
+ [:x, :y].each do | axis |
185
+ if ballon_end[axis] > 1
186
+ fail SubmitError, "the ballon with text #{ballon["text"]} is trespassing" +
187
+ " the #{ {x: "right side", y: "bottom"} [axis] }" +
188
+ " of the image"
189
+ end
190
+ end
191
+ end
192
+ end
193
+
194
+ # if pass everything above return true
195
+ true
196
+ rescue SubmitError => exception
197
+ # HACK: "don't use exceptions for flow control", but this is the most DRY
198
+ # way...
199
+ if throw_exceptions then raise exception else false end
200
+ end
201
+
202
+ # Receive a untainted json (assume as validated by #valid_submit_json?)
203
+ # and add it to the database.
204
+ # @param submit_json [String] A untainted JSON string. Validated with #valid_submit_json?.
205
+ # @param time [Time] A Time instance to be used in place of Time.now. Optional.
206
+ # @return [Ballonizer] The self, to allow chaining.
207
+ # @raise [SecurityError] If the input is tainted.
208
+ def process_submit_json(submit_json, time = nil)
209
+ fail SecurityError, 'the input is tainted' if submit_json.tainted?
210
+ process_submit_hash(JSON.parse(submit_json), time)
211
+ end
212
+
213
+ # Behave as process_submit_json except that takes a already parsed json (hash)
214
+ # and don't check if it's tainted.
215
+ # @param submit_hash [Hash] A JSON hash. Validate with #valid_submit_json?.
216
+ # @param time [Time] A Time instance to be used in place of Time.now. Optional.
217
+ # @return [Ballonizer] The self, to allow chaining.
218
+ def process_submit_hash(submit_hash, time = nil)
219
+ time = Time.now unless time
220
+ self.db.transaction do
221
+ images = self.db[:images]
222
+ db_ballons = self.db[:ballons]
223
+ ballonized_image_versions = self.db[:ballonized_image_versions]
224
+ ballonized_image_ballons = self.db[:ballonized_image_ballons]
225
+
226
+ submit_hash.each do | img_src, ballons |
227
+ img_src = Addressable::URI.parse(img_src).normalize.to_s
228
+ db_image = images.first({img_src: img_src})
229
+ image_id, version = nil, nil
230
+
231
+ if db_image
232
+ image_id = db_image[:id]
233
+ version = ballonized_image_versions.where({image_id: image_id})
234
+ .max(:version) + 1
235
+ else
236
+ image_id = images.insert({img_src: img_src})
237
+ version = 1
238
+ end
239
+
240
+ ballonized_image_versions.insert({
241
+ image_id: image_id,
242
+ version: version,
243
+ time: time
244
+ })
245
+ ballons.each do | ballon |
246
+ db_ballon = db_ballons.first(ballon)
247
+ ballon_id = db_ballon ? db_ballon[:id] : db_ballons.insert(ballon)
248
+ ballonized_image_ballons.insert({
249
+ image_id: image_id,
250
+ version: version,
251
+ ballon_id: ballon_id,
252
+ })
253
+ end
254
+ end
255
+ end
256
+ end
257
+
258
+ # Wrap each image to ballonize with a container, add its ballons to the
259
+ # container and add the js snippet for the edition initialization.
260
+ # @param page [String] The (X)HTML page.
261
+ # @param page_url [String] The url of the page to be ballonized, necessary
262
+ # to make absolute the src attribute of img (if it's relative).
263
+ # @param settings [Hash{Symbol => String}] Optional. If not provided the
264
+ # #settings will be used.
265
+ # @return [String] The page ballonized (new string).
266
+ def ballonize_page(page, page_url, settings = {})
267
+ settings = self.settings.merge(settings)
268
+
269
+ parsed_page = Workaround.parse_html_or_xhtml(page)
270
+ selector = settings[:img_to_ballonize_css_selector]
271
+ imgs = parsed_page.css(selector)
272
+
273
+ unless imgs.empty?
274
+ imgs.wrap('<div class="ballonizer_image_container" ></div>')
275
+
276
+ imgs.each do | img |
277
+ img_src = img['src']
278
+ absolute_normal_src = Addressable::URI.parse(page_url)
279
+ .join(img_src)
280
+ .normalize.to_s
281
+ ballons = last_ballon_set_of_image(absolute_normal_src)
282
+ ballons.each do | ballon |
283
+ img.add_previous_sibling(self.class.create_ballon_node(ballon))
284
+ end
285
+ end
286
+
287
+ if settings[:add_js_for_edition]
288
+ parsed_page.at_css('head').children.last
289
+ .add_next_sibling(self.js_load_snippet)
290
+ end
291
+ end
292
+
293
+ parsed_page.to_s
294
+ end
295
+
296
+ # @api private Don't use this method. It is for internal use only.
297
+ def self.create_ballon_node(ballon_data)
298
+ text = HTMLEntities.new.encode(ballon_data[:text])
299
+
300
+ style = ''
301
+ [:top, :left, :width, :height].each do | sym |
302
+ # transform ratio [0,1] to percent [0, 100]
303
+ style = style + "#{sym}: #{(ballon_data[sym] * 100)}%;"
304
+ end
305
+
306
+ "<p class='ballonizer_ballon' style='#{style}'>#{text}</p>"
307
+ end
308
+
309
+ # @api private
310
+ # Don't use this method. It is for internal use only.
311
+ # @note This method don't make distinction between a image in the database
312
+ # without any ballons (removed in the last version, by example) or a image
313
+ # who isn't in the database (both return a empty array).
314
+ def last_ballon_set_of_image(img_src)
315
+ db_image = self.db[:images].first({img_src: img_src})
316
+ if db_image
317
+ image_id = db_image[:id]
318
+ version = self.db[:ballonized_image_versions].where({image_id: image_id})
319
+ .max(:version)
320
+ self.db[:ballonized_image_ballons]
321
+ .join(:ballons, { ballonized_image_ballons__version: version,
322
+ ballonized_image_ballons__image_id: image_id,
323
+ ballonized_image_ballons__ballon_id: :ballons__id
324
+ }).select(:text, :top, :left, :width, :height).all
325
+ else
326
+ []
327
+ end
328
+ end
329
+
330
+ # Return a String with the snippet added to the pages to allow edition in them.
331
+ # @return [String] The added snippet. Already with the <script/> tag around it.
332
+ def js_load_snippet
333
+ <<-end
334
+ <script type="text/javascript">
335
+ $(document).ready(function() {
336
+ Ballonizer('#{self.settings[:form_handler_url]}',
337
+ '.ballonizer_image_container',
338
+ $('body'));
339
+ })
340
+ </script>
341
+ end
342
+ end
343
+
344
+ # Executes the create_table operations over the Sequel::Database argument.
345
+ # @param db [Sequel::Database] The database where create the tables.
346
+ # @return [void]
347
+ def self.create_tables(db)
348
+ db.create_table(:images) do
349
+ primary_key :id
350
+ String :img_src, :size => 255, :unique => true, :allow_null => false
351
+ end
352
+ db.create_table(:ballons) do
353
+ primary_key :id
354
+
355
+ String :text, :size => 255, :allow_null => false
356
+ Float :top, :allow_null => false
357
+ Float :left, :allow_null => false
358
+ Float :width, :allow_null => false
359
+ Float :height, :allow_null => false
360
+ end
361
+ db.create_table(:ballonized_image_versions) do
362
+ Integer :version
363
+ foreign_key :image_id, :images
364
+ DateTime :time, :allow_null => false
365
+ primary_key [:version, :image_id]
366
+ end
367
+ db.create_table(:ballonized_image_ballons) do
368
+ Integer :version
369
+ foreign_key :image_id, :images
370
+ foreign_key :ballon_id, :ballons
371
+ foreign_key [:version, :image_id], :ballonized_image_versions
372
+ end
373
+ end
374
+
375
+ # @api private Don't use the methods of this module. They are for internal use only.
376
+ module Workaround
377
+ def self.parse_html_or_xhtml(doc)
378
+ # If you parse XHTML as HTML with Nokogiri and use to_s after the markup can be messed up
379
+ #
380
+ # Example: <meta name="description" content="not important" />
381
+ # becomes <meta name="description" content="not important" >
382
+ # To avoid this we parse a document who is XML valid as XML, and, otherwise as HTML
383
+ parsed_doc = nil
384
+ begin
385
+ # this also isn't a great way to do this
386
+ # the Nokogiri don't have exception classes, this way any StandardError will be silenced
387
+ options = Nokogiri::XML::ParseOptions::DEFAULT_XML &
388
+ Nokogiri::XML::ParseOptions::STRICT &
389
+ Nokogiri::XML::ParseOptions::NONET
390
+ parsed_doc = Nokogiri::XML::Document.parse(doc, nil, nil, options)
391
+ rescue
392
+ parsed_doc = Nokogiri::HTML(doc)
393
+ end
394
+
395
+ parsed_doc
396
+ end
397
+ end
398
+
399
+ private_constant :Workaround
400
+ end
401
+
@@ -0,0 +1,539 @@
1
+ require 'ballonizer'
2
+ require 'rspec-html-matchers'
3
+ # Avoid to use equivalent-xml, the specs break with cosmetic changes this way
4
+ require 'equivalent-xml'
5
+ require 'stringio'
6
+
7
+ # make the changes in the BD restricted to the example
8
+ RSpec.configure do |c|
9
+ c.around(:each) do |example|
10
+ DB.transaction(:rollback=>:always){example.run}
11
+ end
12
+ end
13
+
14
+ describe Ballonizer do
15
+
16
+ DB = Sequel.sqlite
17
+
18
+ def self.populate_ballonizer_tables_with_test_data(db, time)
19
+ db[:images].insert({img_src: 'http://comic-translation.com/tr/pt-BR/a_comic/imgs/1.jpg'})
20
+ db[:images].insert({img_src: 'http://comic-translation.com/tr/pt-BR/a_comic/imgs/2.jpg'})
21
+ db[:images].insert({img_src: 'http://comic-translation.com/tr/pt-BR/a_comic/imgs/3.jpg'})
22
+
23
+ db[:ballons].insert({ text: 'first ballon of first image',
24
+ top: 0, left: 0, width: 0.5, height: 0.5 })
25
+ db[:ballons].insert({ text: 'second ballon of first image',
26
+ top: 0.5, left: 0.5, width: 0.5, height: 0.5 })
27
+ db[:ballons].insert({ text: 'first ballon of second image',
28
+ top: 0, left: 0, width: 0.5, height: 0.5 })
29
+ db[:ballons].insert({ text: 'second ballon of second image',
30
+ top: 0.5, left: 0.5, width: 0.5, height: 0.5 })
31
+ db[:ballons].insert({ text: 'first ballon of third image',
32
+ top: 0, left: 0, width: 0.5, height: 0.5 })
33
+ db[:ballons].insert({ text: 'second ballon of third image',
34
+ top: 0.5, left: 0.5, width: 0.5, height: 0.5 })
35
+
36
+ # both ballons added in the first version
37
+ db[:ballonized_image_versions].insert({image_id: 1, version: 1, time: time})
38
+ db[:ballonized_image_ballons].insert({image_id: 1, version: 1, ballon_id: 1})
39
+ db[:ballonized_image_ballons].insert({image_id: 1, version: 1, ballon_id: 2})
40
+
41
+ # second ballon added in the second version
42
+ db[:ballonized_image_versions].insert({image_id: 2, version: 1, time: time})
43
+ db[:ballonized_image_ballons].insert({image_id: 2, version: 1, ballon_id: 3})
44
+ db[:ballonized_image_versions].insert({image_id: 2, version: 2, time: time})
45
+ db[:ballonized_image_ballons].insert({image_id: 2, version: 2, ballon_id: 3})
46
+ db[:ballonized_image_ballons].insert({image_id: 2, version: 2, ballon_id: 4})
47
+
48
+ # both added in first version, but second removed in the second version
49
+ db[:ballonized_image_versions].insert({image_id: 3, version: 1, time: time})
50
+ db[:ballonized_image_ballons].insert({image_id: 3, version: 1, ballon_id: 5})
51
+ db[:ballonized_image_ballons].insert({image_id: 3, version: 1, ballon_id: 6})
52
+ db[:ballonized_image_versions].insert({image_id: 3, version: 2, time: time})
53
+ db[:ballonized_image_ballons].insert({image_id: 3, version: 2, ballon_id: 5})
54
+ end
55
+
56
+ Ballonizer.create_tables(DB)
57
+ self.populate_ballonizer_tables_with_test_data(DB, Time.at(0))
58
+
59
+ def deep_copy(v)
60
+ JSON.parse(JSON.generate(v))
61
+ end
62
+
63
+ # Definitions ending with '_example' are to be cloned and defined in a
64
+ # context without the sufix. Definitions without the sufix are used in the
65
+ # specs and may require the definition of some without '_example' counterparts.
66
+ let (:ballonizer_new_args_example) do
67
+ [DB, {}]
68
+ end
69
+ # TODO: This isn't a valid Rack env, to turn it in a valid env will be
70
+ # necessary to add all obrigatory 'rack.' variables described in:
71
+ # http://rack.rubyforge.org/doc/SPEC.html
72
+ # (the missing are the hijack related and the .errors)
73
+ let (:env_example) do
74
+ form_data = StringIO.new(Addressable::URI.form_encode({
75
+ ballonizer_data: JSON.generate(submit_hash)
76
+ }))
77
+ { 'HTTP_HOST' => 'proxysite.net',
78
+ 'SCRIPT_NAME' => '',
79
+ 'PATH_INFO' => '/pt_BR-ballon_translate/comic/',
80
+ 'QUERY_STRING' => '',
81
+ 'SERVER_NAME' => 'proxysite.net',
82
+ 'SERVER_PORT' => '80',
83
+ 'REQUEST_METHOD' => 'POST',
84
+ 'HTTP_VERSION' => 'HTTP/1.1',
85
+ 'CONTENT_LENGTH' => form_data.size.to_s,
86
+ 'CONTENT_TYPE' => 'application/x-www-form-urlencoded',
87
+ 'rack.url_scheme' => 'http',
88
+ 'rack.input' => form_data,
89
+ 'rack.version' => [1, 0],
90
+ 'rack.multithread' => false,
91
+ 'rack.multiprocess' => false,
92
+ 'rack.run_once' => false
93
+ }
94
+ end
95
+ let (:original_page_example) do
96
+ doc = <<-END
97
+ <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
98
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
99
+ <html xmlns="http://www.w3.org/1999/xhtml">
100
+ <head>
101
+ <title>A title</title>
102
+ <meta http-equiv="content-type" content="application/xhtml+xml; charset=UTF-8" />
103
+ </head>
104
+ <body>
105
+ </body>
106
+ </html>
107
+ END
108
+
109
+ Nokogiri::XML::Document.parse(doc)
110
+ end
111
+ let (:page_url_example) do
112
+ 'http://comic-translation.com/tr/pt-BR/a_comic/'
113
+ end
114
+ let (:settings_example) do
115
+ {}
116
+ end
117
+ let (:submit_json_example) do
118
+ '{"http://imgs.xkcd.com/comics/cells.png":[{"left":0,"top":0,"width":1,"height":0.23837209302325582,"text":"When you see a claim that a common drug or vitamin \"kills cancer cells in a petri dish\", keep in mind:"},{"left":0.0963302752293578,"top":0.9273255813953488,"width":0.7798165137614679,"height":0.055232558139534885,"text":"So does a handgun."}]}'
119
+ end
120
+ let (:submit_hash_example) do
121
+ JSON.parse(submit_json_example)
122
+ end
123
+
124
+ # definitions to be overriden in specific contexts (by doing a clone of the
125
+ # '_example' counterpart and changing what is needed)
126
+ let (:ballonizer_new_args) { ballonizer_new_args_example }
127
+ let (:env) { env_example }
128
+ let (:original_page) { original_page_example.to_s }
129
+ let (:page_url) { page_url_example }
130
+ let (:settings) { settings_example }
131
+ let (:submit_json) { submit_json_example }
132
+ let (:submit_hash) { submit_hash_example }
133
+
134
+ # Definition who need others (no *_example)
135
+ let (:instance) { described_class.new(*ballonizer_new_args) }
136
+ let (:ballonized_page) do
137
+ instance.ballonize_page(original_page, page_url, settings)
138
+ end
139
+
140
+ # TODO: verify if the style property has the correct values
141
+ describe '#ballonize_page' do
142
+ subject { ballonized_page }
143
+
144
+ context 'when the page has no img elements to ballonize' do
145
+ it "don't make changes in the page" do
146
+ should be_equivalent_to(original_page)
147
+ end
148
+ end
149
+ context 'when the page has img elements to ballonize' do
150
+ context 'and one of them have .to_ballonize class' do
151
+ let (:original_page) do
152
+ page_with_images = original_page_example.clone
153
+ img1 = "<img alt='A test image' class='other_class to_ballonize' src='imgs/1.jpg' />"
154
+ img2 = "<img alt='A second test image' class='yet_another_class' src='imgs/2.jpg' />"
155
+ page_with_images.at_css('body') << img1 << img2
156
+ page_with_images.to_s
157
+ end
158
+ it 'add a container around the img' do
159
+ should have_tag('div', :with => { class: 'ballonizer_image_container' }) do
160
+ with_tag('img', :with => { alt: 'A test image' })
161
+ end
162
+ end
163
+ context 'and it have ballons in the database' do
164
+ it 'add the ballons inside the container' do
165
+ # the parentheses of the 'should' are necessary, otherwise the
166
+ # conditions inside the block are silently not tested
167
+ should(have_tag('div', :with => { class: 'ballonizer_image_container' }) do
168
+ with_tag("img[alt='A test image']")
169
+ with_tag('p', { text: 'first ballon of first image',
170
+ with: { class: 'ballonizer_ballon' }})
171
+ with_tag('p', { text: 'second ballon of first image',
172
+ with: { class: 'ballonizer_ballon'}})
173
+ end)
174
+ end
175
+ end
176
+ end
177
+ context 'and more than one of them is to be ballonized' do
178
+ let (:original_page) do
179
+ page_with_images = original_page_example.clone
180
+ # the first image in the db is used in the other test, don't need to
181
+ # be reused
182
+ img2 = "<img alt='the second test image' class='to_ballonize' src='imgs/2.jpg' />"
183
+ img3 = "<img alt='the third test image' class='yet_another_class to_ballonize' src='imgs/3.jpg' />"
184
+ img4 = "<img alt='the fourth test image' class='an unimportant class' src='imgs/4.jpg' />"
185
+ page_with_images.at_css('body') << img2 << img3 << img4
186
+ page_with_images.to_s
187
+ end
188
+ it 'add a container around the imgs' do
189
+ # TODO: DRY this test
190
+ should(have_tag('div', :with => { class: 'ballonizer_image_container' }) do
191
+ with_tag('img', :with => { alt: 'the second test image' })
192
+ end)
193
+ should(have_tag('div', :with => { class: 'ballonizer_image_container' }) do
194
+ with_tag('img', :with => { alt: 'the third test image' })
195
+ end)
196
+ end
197
+ context 'and they have ballons in the database' do
198
+ it 'add the ballons inside the containers' do
199
+ # TODO: break this spec in smaller parts, this specificate more
200
+ # than one thing
201
+ should(have_tag('div', :with => { class: 'ballonizer_image_container' }) do
202
+ # the second image have two versions, the second ballon is added
203
+ # in the second version, so here we verify if the ballonize_page
204
+ # recover the ballons of the last version
205
+ with_tag('img', :with => { alt: 'the second test image' })
206
+ with_tag('p', { text: 'first ballon of second image',
207
+ with: { class: 'ballonizer_ballon' }})
208
+ with_tag('p', { text: 'second ballon of second image',
209
+ with: { class: 'ballonizer_ballon' }})
210
+ end)
211
+ should(have_tag('div', :with => { class: 'ballonizer_image_container' }) do
212
+ # the third image have two versions, the second ballon is removed
213
+ # in the second version, so here we verify if the ballonize_page
214
+ # do not use a ballon of an old version in the image
215
+ with_tag('img', :with => { alt: 'the third test image' })
216
+ with_tag('p', { text: 'first ballon of third image',
217
+ with: { class: 'ballonizer_ballon' }})
218
+ without_tag('p', { text: 'second ballon of third image',
219
+ with: { class: 'ballonizer_ballon' }})
220
+ end)
221
+ end
222
+ end
223
+ end
224
+ context 'and no one of them have the .to_ballonize class' do
225
+ let (:original_page) do
226
+ page_with_images = original_page_example.clone
227
+ page_with_images.at_css('body')
228
+ .add_child "<img alt='A test image' src='/a/path/to/the/image.jpg' >"
229
+ page_with_images.to_s
230
+ end
231
+ it "don't add a container around the imgs" do
232
+ should_not have_tag('div', :with => {
233
+ class: 'ballonizer_image_container'
234
+ })
235
+ end
236
+ it "don't add the links of the required javascript and css" do
237
+ # the callback will be called only if a image was ballonized?
238
+ should_not have_tag('link')
239
+ end
240
+ end
241
+ end
242
+ end
243
+
244
+ describe '#valid_submit_json?' do
245
+ subject { instance.valid_submit_json? submit_json }
246
+
247
+ shared_examples 'common behavior for invalid input' do
248
+
249
+ it { should be_false }
250
+
251
+ it 'taint the input' do
252
+ instance.valid_submit_json? submit_json
253
+ expect(submit_json.tainted?).to be_true
254
+ end
255
+
256
+ context 'but frozen' do
257
+ let (:frozen_submit_json) { deep_copy(submit_json).freeze }
258
+ let (:method_call) do
259
+ lambda { instance.valid_submit_json? submit_json }
260
+ end
261
+
262
+ it "shouldn't taint the input" do
263
+ # an attempt to taint a frozen object will raise a RuntimeError
264
+ expect(method_call).to_not raise_error
265
+ end
266
+ end
267
+ context 'when the second argument is true' do
268
+ let (:method_call) do
269
+ lambda { instance.valid_submit_json?(submit_json, true) }
270
+ end
271
+
272
+ # necessary define the 'exception_type' let variable in the context
273
+ # who will use this shared_examples
274
+ it { expect(method_call).to raise_error(exception_type) }
275
+ end
276
+ end
277
+
278
+ # NOTE: All cases who throw a Ballonizer::SubmitError are verified
279
+ # in the #valid_submit_hash? (who is used by the #valid_submit_json?)
280
+ context 'with invalid input' do
281
+ context '(a malformed json)' do
282
+ let (:submit_json) { 'not a valid json' }
283
+ let (:exception_type) { JSON::ParserError }
284
+
285
+ include_examples 'common behavior for invalid input'
286
+ end
287
+ context '(invalid submit data)' do
288
+ let (:submit_json) { '{}' }
289
+ let (:exception_type) { described_class::SubmitError }
290
+
291
+ include_examples 'common behavior for invalid input'
292
+ end
293
+ end
294
+
295
+ context 'with valid input' do
296
+ it { should be_true }
297
+
298
+ it 'untaint the input' do
299
+ instance.valid_submit_json? submit_json.taint
300
+ expect(submit_json.tainted?).to be_false
301
+ end
302
+
303
+ context 'but frozen' do
304
+ let (:frozen_tainted_submit_json) do
305
+ deep_copy(submit_json).taint.freeze
306
+ end
307
+ let (:method_call) do
308
+ lambda { instance.valid_submit_json? submit_json }
309
+ end
310
+
311
+ it "shouldn't untaint the input" do
312
+ # an attempt to taint a frozen object will raise a RuntimeError
313
+ expect(method_call).to_not raise_error
314
+ end
315
+ end
316
+ context 'when the second argument is true' do
317
+ let (:method_call) do
318
+ lambda { instance.valid_submit_json?(submit_json, true) }
319
+ end
320
+
321
+ it { expect(method_call).to_not raise_error }
322
+ end
323
+ end
324
+ end
325
+
326
+ # TODO: validate ballon text and url size < 255; if the url is a url;
327
+ # extra fields in the hash?
328
+ describe '#valid_submit_hash?' do
329
+ subject { instance.valid_submit_hash? submit_hash }
330
+
331
+ # To be used in all contexts where the input is invalid
332
+ shared_examples 'and the second argument is true' do
333
+ # If this context isn't added the message don't appear in the output
334
+ # of the 'rspec -fd' and the lets and subjects are merged (what can
335
+ # create some nasty bugs)
336
+ context 'and the second argument is true' do
337
+ let (:method_call) do
338
+ lambda { instance.valid_submit_hash?(submit_hash, true) }
339
+ end
340
+
341
+ it { expect(method_call).to raise_error(described_class::SubmitError) }
342
+ end
343
+ end
344
+
345
+ context "when the bounds aren't numbers between 0 and 1" do
346
+ let (:submit_hash) do
347
+ deep_copy(submit_hash_example)[submit_hash_example.keys.first]
348
+ .first.update({ top: 10 })
349
+ end
350
+
351
+ it { should be_false }
352
+
353
+ include_examples 'and the second argument is true'
354
+ end
355
+
356
+ context "when the text isn't a non-empty String" do
357
+ let (:submit_hash) do
358
+ deep_copy(submit_hash_example)[submit_hash_example.keys.first]
359
+ .first.update({ text: "" })
360
+ end
361
+
362
+ it { should be_false }
363
+
364
+ include_examples 'and the second argument is true'
365
+ end
366
+
367
+ [:x, :y].each do | axis |
368
+
369
+ position, size = { x: ["left", "top"], y: ["top", "height"] }[axis]
370
+
371
+ context "when the #{position} plus #{size} is greater than one" do
372
+ let (:submit_hash) do
373
+ h = deep_copy(submit_hash_example)
374
+ first_ballon = h[h.keys.first].first
375
+ first_ballon[position] = 0.75
376
+ first_ballon[size] = 0.5
377
+ h
378
+ end
379
+
380
+ it { should be_false }
381
+
382
+ include_examples 'and the second argument is true'
383
+ end
384
+ end
385
+
386
+ context 'when the submit contain a image without ballons' do
387
+ let (:submit_hash) do
388
+ { submit_hash_example.keys.first => [] }
389
+ end
390
+
391
+ it { should be_true }
392
+ end
393
+ context 'when the hash is valid' do
394
+ it { should be_true }
395
+
396
+ context 'and the second argument is true' do
397
+ let (:method_call) do
398
+ lambda { instance.valid_submit_hash?(submit_hash, true) }
399
+ end
400
+
401
+ it { expect(method_call).to_not raise_error(described_class::SubmitError) }
402
+ it { expect(method_call.call).to be_true }
403
+ end
404
+ end
405
+ end
406
+
407
+ describe '#process_submit_hash' do
408
+ # TODO: Add context "when the submit refer to two or more images?"
409
+ context 'when its the first submit of a image' do
410
+ let(:submit_hash) do
411
+ # the keys are String because hashs parsed from JSON are this way
412
+ { 'http://comic-translation.com/tr/pt-BR/a_comic/imgs/4.jpg' => [
413
+ { 'text' => 'the first ballon of the fourth image',
414
+ 'left' => 0, 'top' => 0, 'width' => 0.5, 'height' => 0.5 },
415
+ { 'text' => 'the second ballon of the fourth image',
416
+ 'left' => 0.5, 'top' => 0.5, 'width' => 0.5, 'height' => 0.5 },
417
+ ]}
418
+ end
419
+
420
+ let (:original_page) do
421
+ page_with_images = original_page_example.clone
422
+ img = "<img alt='A fourth test image' class='to_ballonize' src='imgs/4.jpg' />"
423
+ page_with_images.at_css('body') << img
424
+ page_with_images.to_s
425
+ end
426
+
427
+ it 'the ballonize_page add the ballons to the image' do
428
+ instance.process_submit_hash(submit_hash, Time.at(0))
429
+ expect(ballonized_page).to have_tag('p',
430
+ { text: 'the first ballon of the fourth image',
431
+ with: { class: 'ballonizer_ballon' } }
432
+ )
433
+ expect(ballonized_page).to have_tag('p',
434
+ { text: 'the second ballon of the fourth image',
435
+ with: { class: 'ballonizer_ballon' } }
436
+ )
437
+ end
438
+ end
439
+ context 'when the submit refer to a image already with ballons' do
440
+ let(:submit_hash) do
441
+ # the keys are String because hashs parsed from JSON are this way
442
+ { 'http://comic-translation.com/tr/pt-BR/a_comic/imgs/1.jpg' => [
443
+ # the "(... version)" added to avoid reutilize a ballon (what is
444
+ # not the objective of the test here)
445
+ { 'text' => 'the first ballon (version 2) of the first image',
446
+ 'left' => 0, 'top' => 0, 'width' => 0.5, 'height' => 0.5 },
447
+ { 'text' => 'the second ballon (version 2) of the first image',
448
+ 'left' => 0.5, 'top' => 0.5, 'width' => 0.5, 'height' => 0.5 },
449
+ ]}
450
+ end
451
+
452
+ let (:original_page) do
453
+ page_with_images = original_page_example.clone
454
+ img = "<img alt='A test image' class='to_ballonize' src='imgs/1.jpg' />"
455
+ page_with_images.at_css('body') << img
456
+ page_with_images.to_s
457
+ end
458
+
459
+ it 'the ballonize_page use the new ballons' do
460
+ instance.process_submit_hash(submit_hash, Time.at(0))
461
+ expect(ballonized_page).to have_tag('p',
462
+ { text: 'the first ballon (version 2) of the first image',
463
+ with: { class: 'ballonizer_ballon' } }
464
+ )
465
+ expect(ballonized_page).to have_tag('p',
466
+ { text: 'the second ballon (version 2) of the first image',
467
+ with: { class: 'ballonizer_ballon' } }
468
+ )
469
+ end
470
+ end
471
+ end
472
+
473
+ describe '#process_submit_json' do
474
+ # As process_submit_json use process_submit_hash (it's only a convenience
475
+ # method) we don't test the rest of its behaviour (who is already covered
476
+ # in #process_submit_hash)
477
+ context 'when the input is tainted' do
478
+ let (:submit_json) do
479
+ submit_hash_example.clone.taint
480
+ end
481
+
482
+ it do
483
+ expect {
484
+ instance.process_submit_json(submit_json)
485
+ }.to raise_error(SecurityError)
486
+ end
487
+ end
488
+ end
489
+
490
+ describe '#process_submit' do
491
+
492
+ # As process_submit use the #valid_submit_json? and #process_submit_hash
493
+ # we only make the tests here for example and its not the ideia cover the
494
+ # cases already covered in the specs of the two methods
495
+ context 'when the input is invalid' do
496
+ # The submit hash is used to define the env_example
497
+ # (and in consequence the env)
498
+ let (:submit_hash) do
499
+ # this input is invalid because the text field can't be empty
500
+ { 'http://comic-translation.com/tr/pt-BR/a_comic/imgs/4.jpg' => [
501
+ { 'text' => '', 'left' => 0, 'top' => 0, 'width' => 0.5, 'height' => 0.5 }
502
+ ]}
503
+ end
504
+
505
+ it do
506
+ expect { instance.process_submit(env) }.to raise_error(
507
+ described_class::SubmitError
508
+ )
509
+ end
510
+ end
511
+
512
+ context 'when the input is valid' do
513
+ # this code is almost the same as the used in #process_submit_hash,
514
+ # think in some way to DRY up this
515
+ let(:submit_hash) do
516
+ { 'http://comic-translation.com/tr/pt-BR/a_comic/imgs/4.jpg' => [
517
+ { 'text' => 'the first ballon of the fourth image',
518
+ 'left' => 0, 'top' => 0, 'width' => 0.5, 'height' => 0.5 }
519
+ ]}
520
+ end
521
+
522
+ let (:original_page) do
523
+ page_with_images = original_page_example.clone
524
+ img = "<img alt='A fourth test image' class='to_ballonize' src='imgs/4.jpg' />"
525
+ page_with_images.at_css('body') << img
526
+ page_with_images.to_s
527
+ end
528
+
529
+ it 'the ballonize_page add the ballons to the image' do
530
+ instance.process_submit(env)
531
+ expect(ballonized_page).to have_tag('p',
532
+ { text: 'the first ballon of the fourth image',
533
+ with: { class: 'ballonizer_ballon' } }
534
+ )
535
+ end
536
+ end
537
+ end
538
+ end
539
+
metadata ADDED
@@ -0,0 +1,245 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ballonizer
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Henrique Becker
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2013-06-28 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: nokogiri
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ~>
18
+ - !ruby/object:Gem::Version
19
+ version: '1.5'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ~>
25
+ - !ruby/object:Gem::Version
26
+ version: '1.5'
27
+ - !ruby/object:Gem::Dependency
28
+ name: addressable
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ~>
32
+ - !ruby/object:Gem::Version
33
+ version: '2.3'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ~>
39
+ - !ruby/object:Gem::Version
40
+ version: '2.3'
41
+ - !ruby/object:Gem::Dependency
42
+ name: sequel
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ~>
46
+ - !ruby/object:Gem::Version
47
+ version: '3.48'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ~>
53
+ - !ruby/object:Gem::Version
54
+ version: '3.48'
55
+ - !ruby/object:Gem::Dependency
56
+ name: json
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ~>
60
+ - !ruby/object:Gem::Version
61
+ version: '1.7'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ~>
67
+ - !ruby/object:Gem::Version
68
+ version: '1.7'
69
+ - !ruby/object:Gem::Dependency
70
+ name: htmlentities
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ~>
74
+ - !ruby/object:Gem::Version
75
+ version: '4.3'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ~>
81
+ - !ruby/object:Gem::Version
82
+ version: '4.3'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rack
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ~>
88
+ - !ruby/object:Gem::Version
89
+ version: '1.5'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ~>
95
+ - !ruby/object:Gem::Version
96
+ version: '1.5'
97
+ - !ruby/object:Gem::Dependency
98
+ name: equivalent-xml
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ~>
102
+ - !ruby/object:Gem::Version
103
+ version: '0.3'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ~>
109
+ - !ruby/object:Gem::Version
110
+ version: '0.3'
111
+ - !ruby/object:Gem::Dependency
112
+ name: jshintrb
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ~>
116
+ - !ruby/object:Gem::Version
117
+ version: 0.2.4
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ~>
123
+ - !ruby/object:Gem::Version
124
+ version: 0.2.4
125
+ - !ruby/object:Gem::Dependency
126
+ name: jasmine-headless-webkit
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ~>
130
+ - !ruby/object:Gem::Version
131
+ version: 0.8.4
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ~>
137
+ - !ruby/object:Gem::Version
138
+ version: 0.8.4
139
+ - !ruby/object:Gem::Dependency
140
+ name: rspec-core
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - ~>
144
+ - !ruby/object:Gem::Version
145
+ version: '2.13'
146
+ type: :development
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - ~>
151
+ - !ruby/object:Gem::Version
152
+ version: '2.13'
153
+ - !ruby/object:Gem::Dependency
154
+ name: rspec-expectations
155
+ requirement: !ruby/object:Gem::Requirement
156
+ requirements:
157
+ - - ~>
158
+ - !ruby/object:Gem::Version
159
+ version: '2.13'
160
+ type: :development
161
+ prerelease: false
162
+ version_requirements: !ruby/object:Gem::Requirement
163
+ requirements:
164
+ - - ~>
165
+ - !ruby/object:Gem::Version
166
+ version: '2.13'
167
+ - !ruby/object:Gem::Dependency
168
+ name: rspec-html-matchers
169
+ requirement: !ruby/object:Gem::Requirement
170
+ requirements:
171
+ - - ~>
172
+ - !ruby/object:Gem::Version
173
+ version: 0.4.1
174
+ type: :development
175
+ prerelease: false
176
+ version_requirements: !ruby/object:Gem::Requirement
177
+ requirements:
178
+ - - ~>
179
+ - !ruby/object:Gem::Version
180
+ version: 0.4.1
181
+ - !ruby/object:Gem::Dependency
182
+ name: rake
183
+ requirement: !ruby/object:Gem::Requirement
184
+ requirements:
185
+ - - ~>
186
+ - !ruby/object:Gem::Version
187
+ version: '10.0'
188
+ type: :development
189
+ prerelease: false
190
+ version_requirements: !ruby/object:Gem::Requirement
191
+ requirements:
192
+ - - ~>
193
+ - !ruby/object:Gem::Version
194
+ version: '10.0'
195
+ - !ruby/object:Gem::Dependency
196
+ name: sqlite3
197
+ requirement: !ruby/object:Gem::Requirement
198
+ requirements:
199
+ - - ~>
200
+ - !ruby/object:Gem::Version
201
+ version: '1.3'
202
+ type: :development
203
+ prerelease: false
204
+ version_requirements: !ruby/object:Gem::Requirement
205
+ requirements:
206
+ - - ~>
207
+ - !ruby/object:Gem::Version
208
+ version: '1.3'
209
+ description: A ruby+javascript library to allow add and edit speech bubbles over images
210
+ in a (X)HTML page and persist the ballons.
211
+ email: henriquebecker91@gmail.com
212
+ executables: []
213
+ extensions: []
214
+ extra_rdoc_files: []
215
+ files:
216
+ - lib/ballonizer.rb
217
+ - spec/ballonizer_spec.rb
218
+ - Rakefile
219
+ homepage: http://rubygems.org/gems/ballonize
220
+ licenses:
221
+ - Public domain
222
+ metadata: {}
223
+ post_install_message:
224
+ rdoc_options: []
225
+ require_paths:
226
+ - lib
227
+ required_ruby_version: !ruby/object:Gem::Requirement
228
+ requirements:
229
+ - - '>='
230
+ - !ruby/object:Gem::Version
231
+ version: '0'
232
+ required_rubygems_version: !ruby/object:Gem::Requirement
233
+ requirements:
234
+ - - '>='
235
+ - !ruby/object:Gem::Version
236
+ version: '0'
237
+ requirements: []
238
+ rubyforge_project:
239
+ rubygems_version: 2.0.2
240
+ signing_key:
241
+ specification_version: 4
242
+ summary: 'ballonize: to allow colaborative edition of ballons in images'
243
+ test_files:
244
+ - spec/ballonizer_spec.rb
245
+ has_rdoc: true