rexml in ruby 1.8.6 is more strict that attributes cannot be Fixnum objects
[booh] / lib / booh / booh-lib.rb
1 #                         *  BOOH  *
2 #
3 # A.k.a `Best web-album Of the world, Or your money back, Humerus'.
4 #
5 # The acronyn sucks, however this is a tribute to Dragon Ball by
6 # Akira Toriyama, where the last enemy beaten by heroes of Dragon
7 # Ball is named "Boo". But there was already a free software project
8 # called Boo, so this one will be it "Booh". Or whatever.
9 #
10 #
11 # Copyright (c) 2004 Guillaume Cottenceau <gc3 at bluewin.ch>
12 #
13 # This software may be freely redistributed under the terms of the GNU
14 # public license version 2.
15 #
16 # You should have received a copy of the GNU General Public License
17 # along with this program; if not, write to the Free Software
18 # Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
19
20 require 'iconv'
21 require 'timeout'
22
23 require 'rexml/document'
24
25 require 'gettext'
26 include GetText
27 bindtextdomain("booh")
28
29 require 'booh/config.rb'
30 require 'booh/version.rb'
31 begin
32     require 'gtk2'
33 rescue LoadError
34     $no_gtk2 = true
35 end
36 begin
37     require 'booh/libadds'
38 rescue LoadError
39     $no_libadds = true
40 end
41
42 module Booh
43     $verbose_level = 2
44     $CURRENT_CHARSET = `locale charmap`.chomp
45     $convert = 'convert -interlace line +profile "*"'
46     $convert_enhance = '-contrast -enhance -normalize'
47
48     def utf8(string)
49         return Iconv::iconv("UTF-8", $CURRENT_CHARSET, string).to_s
50     end
51
52     def utf8cut(string, maxlen)
53         begin
54             return Iconv::iconv("UTF-8", $CURRENT_CHARSET, string[0..maxlen-1]).to_s
55         rescue Iconv::InvalidCharacter
56             return utf8cut(string, maxlen-1)
57         end
58     end
59
60     def sizename(key)
61         #- fake for gettext to find these; if themes need more sizes, english name for them should be added here
62         sizenames = { 'small' => utf8(_("small")), 'medium' => utf8(_("medium")), 'large' => utf8(_("large")),
63                       'x-large' => utf8(_("x-large")), 'xx-large' => utf8(_("xx-large")),
64                       'original' => utf8(_("original")) }
65         return sizenames[key] || key
66     end
67     
68     def from_utf8(string)
69         return Iconv::iconv($CURRENT_CHARSET, "UTF-8", string).to_s
70     end
71
72     def from_utf8_safe(string)
73         begin
74             return Iconv::iconv($CURRENT_CHARSET, "UTF-8", string).to_s
75         rescue Iconv::IllegalSequence
76             return ''
77         end
78     end
79
80     def make_dest_filename_old(orig_filename)
81         #- we remove non alphanumeric characters but need to do that
82         #- cleverly to not end up with two similar dest filenames. we won't
83         #- urlencode because urldecode might happen in the browser.
84         return orig_filename.unpack("C*").collect { |v| v.chr =~ /[a-zA-Z\-_0-9\.\/]/ ? v.chr : sprintf("%2X", v) }.to_s
85     end
86
87     def make_dest_filename(orig_filename)
88         #- we remove non alphanumeric characters but need to do that
89         #- cleverly to not end up with two similar dest filenames. we won't
90         #- urlencode because urldecode might happen in the browser.
91         return orig_filename.unpack("C*").collect { |v| v.chr =~ /[a-zA-Z\-_0-9\.\/]/ ? v.chr : sprintf("~%02X", v) }.to_s
92     end
93
94     def msg(verbose_level, msg)
95         if verbose_level <= $verbose_level
96             if verbose_level == 0
97                 warn _("\t***ERROR***: %s\n") % msg
98             elsif verbose_level == 1
99                 warn _("\tWarning: %s\n") % msg
100             else
101                 puts msg
102             end
103         end
104     end
105
106     def msg_(verbose_level, msg)
107         if verbose_level <= $verbose_level
108             if verbose_level == 0
109                 warn _("\t***ERROR***: %s") % msg
110             elsif verbose_level == 1
111                 warn _("\tWarning: %s") % msg
112             else
113                 print msg
114             end
115         end
116     end
117
118     def die_(msg)
119         puts msg
120         exit 1
121     end
122
123     def select_theme(name, limit_sizes, optimizefor32, nperrow)
124         $theme = name
125         msg 3, _("Selecting theme '%s'") % $theme
126         themedir = "#{$FPATH}/themes/#{$theme}"
127         if !File.directory?(themedir)
128             die _("Theme was not found (tried %s directory).") % themedir
129         end
130         eval File.open("#{themedir}/metadata/parameters.rb").readlines.join
131
132         if limit_sizes
133             if limit_sizes != 'all'
134                 sizes = limit_sizes.split(/,/)
135                 $images_size = $images_size.find_all { |e| sizes.include?(e['name']) }
136                 if $images_size.length == 0
137                     die _("Can't carry on, no valid size selected.")
138                 end
139             end
140         else
141             $images_size = $images_size.find_all { |e| !e['optional'] }
142         end
143
144         if optimizefor32
145             $images_size.each { |e|
146                 e['fullscreen'].gsub!(/(\d+x)(\d+)/) { $1 + ($2.to_f*8/9).to_i.to_s }
147                 e['thumbnails'].gsub!(/(\d+x)(\d+)/) { $1 + ($2.to_f*8/9).to_i.to_s }
148             }
149             $albums_thumbnail_size.gsub!(/(\d+x)(\d+)/) { $1 + ($2.to_f*8/9).to_i.to_s }
150         end
151
152         if nperrow && nperrow != $default_N
153             ratio = nperrow.to_f / $default_N.to_f
154             $images_size.each { |e|
155                 e['thumbnails'].gsub!(/(\d+)x(\d+)/) { ($1.to_f/ratio).to_i.to_s + 'x' + ($2.to_f/ratio).to_i.to_s }
156             }
157         end
158
159         $default_size = $images_size.detect { |sizeobj| sizeobj['default'] }
160         if $default_size == nil
161             $default_size = $images_size[0]
162         end
163     end
164
165     def entry2type(entry)
166         if entry =~ /\.(jpg|jpeg|jpe|gif|bmp|png)$/i && entry !~ /['"\[\]]/
167             return 'image'
168         elsif !$ignore_videos && entry =~ /\.(mov|avi|mpg|mpeg|mpe|wmv|asx|3gp|mp4)$/i && entry !~ /['"\[\]]/
169             #- might consider using file magic later..
170             return 'video'
171         else
172             return nil
173         end
174     end
175
176     def sys(cmd)
177         msg 2, cmd
178         system(cmd)
179     end
180
181     def waitjob
182         finished = Process.wait2
183         $pids.delete(finished[0])
184         $pids = $pids.find_all { |pid| Process.waitpid(pid, Process::WNOHANG) == nil }
185     end
186
187     def waitjobs
188         while $pids && $pids.length > 0
189             waitjob
190         end
191     end
192
193     #- parallelizable sys
194     def psys(cmd)
195         if $mproc
196             if pid = fork
197                 $pids << pid
198             else
199                 msg 2, cmd + ' &'
200                 system(cmd)
201                 exit 0
202             end
203             if $pids.length == $mproc
204                 waitjob
205             end
206         else
207             sys(cmd)
208         end
209     end
210
211     #- grab the results of a command but don't sleep forever on a runaway process
212     def subproc_runaway_aware(command)
213         begin
214             timeout(10) {
215                 return `#{command}`
216             }
217         rescue Timeout::Error    
218             msg 1, _("forgetting runaway process (transcode sucks again?)")
219             #- todo should slay transcode but dunno how to do that
220             return nil
221         end
222     end
223
224     def get_image_size(fullpath)
225         if !$no_identify
226             if $sizes_cache.nil?
227                 $sizes_cache = {}
228             end
229             if $sizes_cache[fullpath].nil?
230                 #- identify is slow, try with gdk if available (4ms vs 35ms)
231                 if $no_gtk2
232                     if `identify '#{fullpath}'` =~ / JPEG (\d+)x(\d+) /
233                         $sizes_cache[fullpath] = { :x => $1.to_i, :y => $2.to_i }
234                     end
235                 else
236                     if pxb = Gdk::Pixbuf.new(fullpath)
237                         $sizes_cache[fullpath] = { :x => pxb.width, :y => pxb.height }
238                     end
239                 end
240             end
241             return $sizes_cache[fullpath]
242         else
243             return nil
244         end
245     end
246
247     #- commify from http://pleac.sourceforge.net/ (pleac rulz)
248     def commify(n)
249         n.to_s =~ /([^\.]*)(\..*)?/
250         int, dec = $1.reverse, $2 ? $2 : ""
251         while int.gsub!(/(,|\.|^)(\d{3})(\d)/, '\1\2' + _(",") + '\3')
252         end
253         int.reverse + dec
254     end
255
256     def guess_rotate(filename)
257         #- identify is slow, try with libexif if available (4ms vs 35ms)
258         if $no_libadds
259             if $no_identify
260                 return 0
261             end
262             orientation = `identify -format "%[EXIF:orientation]" '#{filename}'`.chomp.to_i
263         else
264             orientation = Exif.orientation(filename)
265         end
266
267         if orientation == 6
268             angle = 90
269         elsif orientation == 8
270             angle = -90
271         else
272             return 0
273         end
274         size = get_image_size(filename)
275         #- remove rotate if image is obviously already in portrait (situation can come from gthumb)
276         if size && size[:x] < size[:y]
277             return 0
278         else
279             return angle
280         end
281     end
282
283     def rotate_pixbuf(pixbuf, angle)
284         return pixbuf.rotate(angle ==  90 ? Gdk::Pixbuf::ROTATE_CLOCKWISE :
285                              angle == 180 ? Gdk::Pixbuf::ROTATE_UPSIDEDOWN :
286                              angle == 270 ? Gdk::Pixbuf::ROTATE_COUNTERCLOCKWISE :
287                                             Gdk::Pixbuf::ROTATE_NONE)
288     end
289
290     def gen_thumbnails_element(orig, xmldirorelem, allow_background, dests)
291         if xmldirorelem.name == 'dir'
292             xmldirorelem = xmldirorelem.elements["*[@filename='#{utf8(File.basename(orig))}']"]
293         end
294         gen_thumbnails(orig, allow_background, dests, xmldirorelem, '')
295     end
296
297     def gen_thumbnails_subdir(orig, xmldirorelem, allow_background, dests, type)
298         #- type can be `subdirs' or `thumbnails' 
299         gen_thumbnails(orig, allow_background, dests, xmldirorelem, type + '-')
300     end
301
302     def gen_thumbnails(orig, allow_background, dests, felem, attributes_prefix)
303         if !dests.detect { |dest| !File.exists?(dest['filename']) } 
304             return true
305         end
306
307         convert_options = ''
308         dest_dir = make_dest_filename(File.dirname(dests[0]['filename']))
309
310         if entry2type(orig) == 'image'
311             if felem
312                 if whitebalance = felem.attributes["#{attributes_prefix}white-balance"]
313                     neworig = "#{dest_dir}/#{File.basename(orig)}-whitebalance#{whitebalance}.jpg"
314                     cmd = "booh-fix-whitebalance '#{orig}' '#{neworig}' #{whitebalance}"
315                     sys(cmd)
316                     if File.exists?(neworig)
317                         orig = neworig
318                     end
319                 end
320                 if gammacorrect = felem.attributes["#{attributes_prefix}gamma-correction"]
321                     neworig = "#{dest_dir}/#{File.basename(orig)}-gammacorrect#{gammacorrect}.jpg"
322                     cmd = "booh-gamma-correction '#{orig}' '#{neworig}' #{gammacorrect}"
323                     sys(cmd)
324                     if File.exists?(neworig)
325                         orig = neworig
326                     end
327                 end
328                 rotate = felem.attributes["#{attributes_prefix}rotate"]
329                 if !rotate
330                     felem.add_attribute("#{attributes_prefix}rotate", rotate = guess_rotate(orig).to_s)
331                 end
332                 convert_options += "-rotate #{rotate} "
333                 if felem.attributes["#{attributes_prefix}enhance"]
334                     convert_options += ($config['convert-enhance'] || $convert_enhance) + " "
335                 end
336             end
337             for dest in dests
338                 if !File.exists?(dest['filename'])
339                     cmd = nil
340                     cmd ||= "#{$convert} #{convert_options}-size #{dest['size']} -resize '#{dest['size']}>' '#{orig}' '#{dest['filename']}'"
341                     if allow_background
342                         psys(cmd)
343                     else
344                         sys(cmd)
345                     end
346                 end
347             end
348             if neworig
349                 if allow_background
350                     waitjobs
351                 end
352                 system("rm -f '#{neworig}'")
353             end
354             return true
355
356         elsif entry2type(orig) == 'video'
357             if felem
358                 #- frame-offset is an attribute that allows to specify which frame to use for the thumbnail
359                 frame_offset = felem.attributes["#{attributes_prefix}frame-offset"]
360                 if !frame_offset
361                     felem.add_attribute("#{attributes_prefix}frame-offset", frame_offset = "0")
362                 end
363                 frame_offset = frame_offset.to_i
364                 if rotate = felem.attributes["#{attributes_prefix}rotate"]
365                     convert_options += "-rotate #{rotate} "
366                 end
367                 if felem.attributes["#{attributes_prefix}enhance"]
368                     convert_options += ($config['convert-enhance'] || $convert_enhance) + " "
369                 end
370             end
371             orig_base = File.basename(dests[0]['filename']) + File.basename(orig)
372             orig_image = "#{dest_dir}/#{orig_base}.jpg000000.jpg"
373             system("rm -f '#{orig_image}'")
374             for dest in dests
375                 if !File.exists?(orig_image)
376                     transcode_options = ''
377                     if felem
378                         if felem.attributes["#{attributes_prefix}color-swap"]
379                             transcode_options += '-k '
380                         end
381                     end
382                     cmd = "mencoder '#{orig}' -nosound -ovc lavc -lavcopts vcodec=mjpeg -o '#{dest_dir}/#{orig_base}.avi' -frames #{frame_offset+26} -fps 25 >/dev/null 2>/dev/null"
383                     msg 2, cmd
384                     system cmd
385                     if File.exists?("#{dest_dir}/#{orig_base}.avi")
386                         cmd = "transcode -a 0 -c #{frame_offset}-#{frame_offset+1} -i '#{dest_dir}/#{orig_base}.avi' -y jpg -o '#{dest_dir}/#{orig_base}.jpg' #{transcode_options} 2>&1"
387                         msg 2, cmd
388                         results = subproc_runaway_aware(cmd)
389                         system("rm -f '#{dest_dir}/#{orig_base}.avi'")
390                         if results =~ /skipping frames/ && results =~ /encoded 0 frames/
391                             msg 0, _("specified frame-offset too large? max frame was: %s. that may also be another probleme. try another value.") %
392                                 results.scan(/skipping frames \[000000-(\d+)\]/)[-1]
393                             return false
394                         elsif results =~ /V: import format.*unknown/ || !File.exists?(orig_image)
395                             msg 0, _("could not extract first image of video %s encoded by mencoder") % "#{dest_dir}/#{orig_base}.avi"
396                             return false
397                         end
398                     else
399                         msg 0, _("could not make mencoder to encode %s to mjpeg") % orig
400                         return false
401                     end
402                     if felem && whitebalance = felem.attributes["#{attributes_prefix}white-balance"]
403                         if whitebalance.to_f != 0
404                             neworig = "#{dest_dir}/#{orig_base}-whitebalance#{whitebalance}.jpg"
405                             cmd = "booh-fix-whitebalance '#{orig_image}' '#{neworig}' #{whitebalance}"
406                             sys(cmd)
407                             if File.exists?(neworig)
408                                 orig_image = neworig
409                             end
410                         end
411                     end
412                     if felem && gammacorrect = felem.attributes["#{attributes_prefix}gamma-correction"]
413                         if gammacorrect.to_f != 0
414                             neworig = "#{dest_dir}/#{orig_base}-gammacorrect#{gammacorrect}.jpg"
415                             cmd = "booh-gamma-correction '#{orig_image}' '#{neworig}' #{gammacorrect}"
416                             sys(cmd)
417                             if File.exists?(neworig)
418                                 orig_image = neworig
419                             end
420                         end
421                     end
422                 end
423                 if !File.exists?(dest['filename'])
424                     sys("#{$convert} #{convert_options}-size #{dest['size']} -resize #{dest['size']} '#{orig_image}' '#{dest['filename']}'")
425                 end
426             end
427             if neworig
428                 system("rm -f '#{neworig}'")
429             end
430             return true
431         end
432     end
433
434     def invornil(obj, methodname)
435         if obj == nil
436             return nil
437         else
438             return obj.method(methodname).call
439         end
440     end
441
442     def find_subalbum_info_type(xmldir)
443         #- first look for subdirs info; if not, means there is no subdir
444         if xmldir.attributes['subdirs-caption']
445             return 'subdirs'
446         else
447             return 'thumbnails'
448         end
449     end
450
451     def find_subalbum_caption_info(xmldir)
452         type = find_subalbum_info_type(xmldir)
453         return [ from_utf8(xmldir.attributes["#{type}-captionfile"]), xmldir.attributes["#{type}-caption"] ]
454     end
455
456     def file_size(path)
457         begin
458             return File.size(path)
459         rescue
460             return -1
461         end
462     end
463
464     def max(a, b)
465         a > b ? a : b
466     end
467
468     def clamp(n, a, b)
469         n < a ? a : n > b ? b : n
470     end
471
472     def pano_amount(elem)
473         if pano_amount = elem.attributes['pano-amount']
474             if $N_per_row
475                 return clamp(pano_amount.to_f, 1, $N_per_row.to_i)
476             else
477                 return clamp(pano_amount.to_f, 1, $default_N.to_i)
478             end
479         else
480             return nil
481         end
482     end
483
484     def substInFile(name)
485         newcontent = IO.readlines(name).collect { |l| yield l }
486         ios = File.open(name, "w")
487         ios.write(newcontent)
488         ios.close
489     end
490 end
491
492 class File
493     def File.reduce_path(path)
494         return path.gsub(/\w+\/\.\.\//, '')
495     end
496 end
497
498 module Enumerable
499     def collect_with_index
500         out = []
501         each_with_index { |e,i|
502             out << yield(e,i)
503         }
504         return out
505     end
506 end
507
508 class REXML::Element
509     def previous_element_byname(name)
510         n = self
511         while n = n.previous_element
512             if n.name == name
513                 return n
514             end
515         end
516         return nil
517     end
518
519     def previous_element_byname_notattr(name, attr)
520         n = self
521         while n = n.previous_element
522             if n.name == name && !n.attributes[attr]
523                 return n
524             end
525         end
526         return nil
527     end
528
529     def next_element_byname(name)
530         n = self
531         while n = n.next_element
532             if n.name == name
533                 return n
534             end
535         end
536         return nil
537     end
538
539     def next_element_byname_notattr(name, attr)
540         n = self
541         while n = n.next_element
542             if n.name == name && !n.attributes[attr]
543                 return n
544             end
545         end
546         return nil
547     end
548
549     def child_byname_notattr(name, attr)
550         elements.each(name) { |element|
551             if !element.attributes[attr]
552                 return element
553             end
554         }
555         return nil
556     end
557 end
558
559