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