ballonizer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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