:format -> :paper
[minicomic.git] / lib / minicomic.rb
blob7ed7169a012b41cc4f045a6299033262a1f1a09d
2 # minicomic.rb: Rake tasks for minicomics using Inkscape and other tools
4 # Copyright 2007  MenTaLguY <mental@rydia.net>
5 # Copyright 2007  John Bintz <jcoswell@coswellproductions.org>
7 # This library is made available under the same terms as Ruby.
10 require 'set'
11 require 'rake'
12 require 'rexml/document'
13 require 'rexml/streamlistener'
14 require 'yaml'
16 class Minicomic
18 DEFAULT_OPTIONS = {
19   :rasterize => true,
21   :margin => 13.5,
22   :dpi => 200,
24   :color => true,
26   :paper => :letter,
27   :orientation => :landscape,
29   # in pixels
30   :web_height => 680,
31   :thumbnail_height => 96,
32   
33   :minimini => false,
34   :forcedpi => false
37 private
39 # constants
40 LEFT = 0
41 RIGHT = 1
42 PHI = ( 1 + Math.sqrt( 5 ) ) / 2
44 module Common
45   def get_ps_bbox( filename )
46     result = nil
47     File.open( filename, "r" ) do |stream|
48       magic = stream.gets.chomp
49       break unless magic =~ /^%!PS-Adobe/
50       stream.each_line do |line|
51         case line
52         when /^%%BoundingBox: (\d+) (\d+) (\d+) (\d+)/
53           result = [ ($1.to_f)..($3.to_f), ($2.to_f)..($4.to_f) ]
54           break
55         when /^%%EndComments/
56           break
57         end
58       end
59     end
60     raise RuntimeError, "unable to get bbox for #{ filename }" unless result
61     result
62   end
64   def get_generic_bbox( filename )
65     IO.popen("-", "r") do |stream|
66       if stream
67         line = stream.gets
68         if line
69           line.chomp.split.map { |n| 0.0..(n.to_f) }
70         else
71           raise RuntimeError, "unable to get bbox for #{ filename }"
72         end
73       else
74         begin
75           sh "identify", "-format", "%w %h", filename
76         rescue Exception => e
77           e.display
78         end
79         exit! 127
80       end
81     end
82   end
84   class SVGBBoxListener
85     include REXML::StreamListener
86     
87     def initialize
88       @bbox = nil
89     end
90     
91     def tag_start(name, attrs)
92       case name
93       when "svg"
94         @bbox = [ 0..(attrs['width'].to_f), 0..(attrs['height'].to_f) ]
95         throw :got_bbox, true
96       end  
97     end
98     
99     def bbox
100       raise "Unable to obtain bbox" unless @bbox
101       @bbox
102     end
103   end
105   def get_svg_bbox( filename )
106     bbox_listener = SVGBBoxListener.new
107     catch(:got_bbox) do
108       File.open( filename, "r" ) do |stream|
109         REXML::Document.parse_stream( stream, bbox_listener )
110       end
111     end
112     bbox_listener.bbox
113   end
115   def get_bbox( filename )
116     case filename
117     when /\.svg$/i
118       get_svg_bbox( filename )
119     when /\.e?ps$/i
120       get_ps_bbox( filename )
121     else
122       get_generic_bbox( filename )
123     end
124   end
126   def scale_print_page( width, height )
127     width_scale = @options[:page_width].to_f / width
128     height_scale = @options[:page_height].to_f / height
129     if width * height_scale > @options[:page_width]
130       width_scale
131     else
132       height_scale
133     end * @options[:scale]
134   end
136 include Common
138 def eps_from_svg( eps_file, svg_file )
139   file eps_file => [ svg_file ] do
140     sh 'inkscape', '-T', '-B', '-E', eps_file, svg_file
141   end
144 def back_ps( out_file, in_file )
145   file out_file => [ in_file ] do
146     sh 'psselect', '-e', in_file, out_file
147   end
150 def front_ps( out_file, in_file )
151   file out_file => [ in_file ] do
152     sh 'psselect', '-o', in_file, out_file
153   end
156 def make2up( in_file, out_file )
157   page_width = @options[:page_width].round
158   page_height = @options[:page_height].round
159   case @options[:orientation]
160   when :landscape
161     if @options[:minimini]
162       layout = "2:0L(#{page_height},0)+1L(#{page_height},#{page_width})+0L(#{page_height*2},0)+1L(#{page_height*2},#{page_width})"
163     else
164       layout = "2:0L(#{page_height},0)+1L(#{page_height},#{page_width})"
165     end
166   else
167     if @options[:minimini]
168       layout = "2:0(0,0)+1(#{page_width},0)+0(0,#{page_height})+1(#{page_width},#{page_height})"
169     else
170       layout = "2:0(0,0)+1(#{page_width},0)"
171     end
172   end
173   sh 'pstops', "-w#{ @options[:paper_width].round }", "-h#{ @options[:paper_height].round }", layout, in_file, out_file
176 def duplex_ps( out_file, in_file )
177   temp_file = "#{ out_file }.temp"
178   file out_file => [ in_file ] do
179     sh 'psbook', in_file, temp_file
180     begin
181       make2up temp_file, out_file
182     ensure
183       rm temp_file
184     end
185   end
188 def proof_ps( out_file, in_file )
189   temp_file = "#{ out_file }.temp"
190   file out_file => [ in_file ] do
191     sh 'psselect', '-p_1,1-_2', in_file, temp_file
192     begin
193       make2up temp_file, out_file
194     ensure
195       rm temp_file
196     end
197   end
200 def pdf_from_ps( pdf_file, ps_file )
201   file pdf_file => [ ps_file ] do
202     case @options[:orientation]
203     when :landscape
204       orient = 3
205     else
206       orient = 0
207     end
208       
209     sh 'gs', '-q', '-dSAFER', '-dNOPAUSE', '-dBATCH', '-sDEVICE=pdfwrite', "-sOutputFile=#{ pdf_file }", '-dAutoRotatePages=/None', '-c', "<< /PageSize [#{ @options[:paper_width].round } #{ @options[:paper_height].round }] /Orientation #{orient} >> setpagedevice", '-c', '.setpdfwrite', '-f', ps_file
210   end
213 def png_from_svg( png_file, svg_file, high_res, &height_calc )
214   png_from_file( png_file, svg_file, high_res, true, &height_calc )
217 def png_from_image_file( png_file, image_file, high_res, &height_calc )
218   png_from_file( png_file, image_file, high_res, false, &height_calc )
221 def png_from_file( png_file, image_file, high_res, is_svg, &height_calc )
222   temp_png_file = "#{ png_file }.temp"
223   file png_file => [ image_file ] do
224     height = height_calc.call image_file
225     
226     if is_svg
227       sh 'inkscape', '-h', height.to_s, '-C', '-y', '1.0', '-e', temp_png_file, image_file
228     else
229       if @options[:forcedpi]
230         sh 'convert', image_file, "-filter", "Lanczos", "-resize", "x#{height}>", "png:#{temp_png_file}"
231       else
232         sh 'convert', image_file, "png:#{temp_png_file}"
233       end
234     end
235     
236     begin
237       args = '-q', '-rem', 'allb'
238       case @options[:color]
239       when :color, true
240         args.push '-c', '2'
241       when :greyscale, :grayscale, false
242         args.push '-c', '0', '-bit_depth', '8'
243       when :monochrome
244         args.push '-c', '0', '-bit_depth', ( high_res ? '1' : '4' )
245       end
246       args.push temp_png_file, png_file
247       sh 'pngcrush', *args
248     rescue Exception
249       rm png_file if File.exist? png_file
250       raise
251     ensure
252       rm temp_png_file
253     end
254   end
257 def web_png_from_svg( png_file, svg_file )
258   png_from_svg( png_file, svg_file, false ) { @options[:web_height] }
261 def web_png_from_image_file( png_file, image_file )
262   png_from_image_file( png_file, image_file, false ) { @options[:web_height] }
265 def print_png_from_svg( png_file, splits, svg_file )
266   png_from_svg( png_file, svg_file, @options[:dpi] > 150 ) do |filename|
267     dims = get_bbox( filename ).map { |r| r.end - r.begin }
268     scale = scale_print_page( dims[0] / splits, dims[1] )
269     ( dims[1].to_f * scale * @options[:dpi] / 72 ).round
270   end
273 def print_png_from_image_file( png_file, splits, image_file )
274   png_from_image_file( png_file, image_file, @options[:dpi] > 150 ) do |filename|
275     dims = get_bbox( filename ).map { |r| r.end - r.begin }
276     scale = scale_print_page( dims[0] / splits, dims[1] )
277     ( dims[1].to_f * scale * @options[:dpi] / 72 ).round
278   end
281 def print_eps_from_image_file( eps_file, splits, image_file )
282   eps_from_image_file( eps_file, image_file ) do |filename|
283     dims = get_bbox( filename ).map { |r| r.end - r.begin }
284     scale = scale_print_page( dims[0] / splits, dims[1] )
285     ( dims[1].to_f * scale * @options[:dpi] / 72 ).round
286   end
289 def thumbnail_jpeg_from_image( jpeg_file, image_file )
290   file jpeg_file => [ image_file ] do
291     sh 'convert', '-filter', 'sinc', '-resize', "x#{ @options[:thumbnail_height] }", image_file, jpeg_file
292   end
295 ImageSlice = Struct.new :n, :total, :filename
296 PageSpec = Struct.new :bbox, :scale, :dims, :filename
298 class BookletLayout
299   include Common
301   def initialize( options, stream, *pages )
302     @options = options
303     @bboxes = {}
304     @stream = stream
306     pages.push nil if pages.size % 2 != 0 # pad to even number of pages
308     # move the back cover to the beginning, to be paired with the front cover
309     pages.unshift pages.pop
311     # pair up adjacent pages into spreads
312     spreads = (0...(pages.size/2)).map { |i| [ pages[i*2], pages[i*2+1] ] }
314     # format each spread, breaking spreads back into invdividual pages after
315     pages = spreads.inject( [] ) do |acc, spread|
316       is_cover = acc.empty?
317       acc.push *format_pages( is_cover, *spread )
318     end
320     # return the back cover to the end
321     pages.push pages.shift
323     emit_document pages
324   end
326   def format_pages( is_cover, *pages )
327     spread = pages.map do |page|
328       if page
329         bounds = bbox( page )
330         dims = (0..1).map { |d| bounds[d].end - bounds[d].begin }
331         scale = scale_print_page( *dims )
332         PageSpec[ bounds, scale, dims.map { |n| n * scale }, page.filename ]
333       else
334         nil
335       end
336     end
338     if spread.all? # i.e. both?
339       spread_dims = spread.map { |page| page.dims }
341       if spread[LEFT].filename == spread[RIGHT].filename
342         tx = translate_spread( *spread_dims )
343       elsif is_cover
344         tx = spread_dims.map { |dims| translate_single( dims ) }
345       else
346         tx = translate_facing( *spread_dims )
347       end
349       [ LEFT, RIGHT ].map do |side|
350         [ spread[side].bbox, spread[side].scale, tx[side],
351           spread[side].filename ]
352       end
353     else
354       spread.map do |page|
355         if page
356           [ page.bbox, page.scale, translate_single( page.dims ),
357             page.filename ]
358         else
359           nil
360         end
361       end
362     end
363   end
365   def bbox( page )
366     rx, ry = @bboxes[page.filename] ||= get_bbox( page.filename )
367     width = rx.end - rx.begin
368     [ ( rx.begin + width * page.n / page.total )..( rx.begin + width * ( page.n + 1 ) / page.total ), ry ]
369   end
371   def bottom_margin( height )
372     ( @options[:page_height].to_f - height ) / PHI
373   end
375   def horizontal_margin( width )
376     ( @options[:page_width].to_f - width ) / 2
377   end
379   def translate_single( dims )
380     [ horizontal_margin( dims[0] ), bottom_margin( dims[1] ) ]
381   end
383   def translate_facing( left, right )
384     left_margin = horizontal_margin( left[0] )
385     right_margin = horizontal_margin( right[0] )
386     gutter = [ [ left_margin, right_margin ].min, 0 ].max
387     [ [ left_margin + ( gutter / 3 ), bottom_margin( left[1] ) ],
388       [ right_margin - ( gutter / 3 ), bottom_margin( right[1] ) ] ]
389   end
391   def translate_spread( left, right )
392     [ [ @options[:page_width].to_f - left[0], bottom_margin( left[1] ) ],
393       [ 0, bottom_margin( right[1] ) ] ]
394   end
396   def emit_dsc( name, value=nil )
397     if value
398       @stream.puts "%%#{ name }: #{ value }"
399     else
400       @stream.puts "%%#{ name }"
401     end
402   end
404   def emit_document( pages )
405     @stream.puts "%!PS-Adobe-3.0"
406     emit_dsc 'Creator', 'minicomic'
407     emit_dsc 'Pages', pages.size
408     #emit_dsc 'Orientation', 'Portrait'
409     #bbox = "0 0 #{ @options[:paper_width].round } #{ @options[:paper_height].round }"
410     #emit_dsc 'BoundingBox', bbox
411     #emit_dsc 'HiResBoundingBox', bbox
412     emit_dsc 'EndComments'
413     pages.each_with_index do |page, n|
414       if page
415         emit_page( n, *page )
416       else
417         emit_empty_page( n )
418       end
419     end
420     emit_dsc 'EOF'
421   end
423   def emit_clip_rect( x0, y0, x1, y1 )
424     @stream.puts "newpath"
425     @stream.puts "#{ x0 } #{ y0 } moveto"
426     @stream.puts "#{ x0 } #{ y1 } lineto"
427     @stream.puts "#{ x1 } #{ y1 } lineto"
428     @stream.puts "#{ x1 } #{ y0 } lineto"
429     @stream.puts "closepath eoclip newpath"
430   end
432   def emit_page( n, bbox, scale, translate, document )
433     emit_dsc 'Page', "#{ n } #{ n }"
435     @stream.puts "save"
436     @stream.puts "/showpage {} def"
437     emit_clip_rect( 0, 0, @options[:page_width].round, @options[:page_height].round )
439     @stream.puts <<EOS
440 #{ translate.join( ' ' ) } translate
441 #{ scale } #{ scale } scale
444     emit_embedded_page( bbox, document )
446     @stream.puts "restore"
447     @stream.puts "showpage"
448     emit_dsc 'PageTrailer'
449   end
451   def emit_embedded_page( bbox, document )
452     case document
453     when /\.e?ps$/i
454       emit_embedded_page_eps( bbox, document )
455     else
456       emit_embedded_page_generic( bbox, document )
457     end
458   end
460   def emit_embedded_page_eps( bbox, document )
461     @stream.puts "#{ bbox.map { |r| -r.begin }.join( ' ' ) } translate"
462     emit_clip_rect( bbox[0].begin, bbox[1].begin, bbox[0].end, bbox[1].end )
463     emit_dsc 'BeginDocument', File.basename( document )
464     File.open( document, 'r' ) do |input|
465       input.each_line do |line|
466         @stream.puts line
467       end
468     end
469     emit_dsc 'EndDocument'
470   end
472   def emit_embedded_page_generic( bbox, document )
473     emit_dsc 'BeginDocument', File.basename( document )
474     IO.popen( "-", "r" ) do |input|
475       if input
476         input.each_line do |line|
477           @stream.puts line
478         end
479       else
480         begin
481           geometry = "#{ ( bbox[0].end - bbox[0].begin ).round }x#{ ( bbox[1].end - bbox[1].begin ).round }"
482           # straightforward image -> eps conversion doesn't work for spreads, write to temp png to work around this
483           temp_png_image = "#{document}.temp"
484           sh "convert", "-crop", "#{geometry}+#{ bbox[0].begin.round }+#{ bbox[1].begin.round }", "+repage", document, "png:#{temp_png_image}"
485           sh "convert", "png:#{temp_png_image}", "+repage", "eps:-"
486           rm temp_png_image
487         rescue Exception => e
488           e.display
489         end
490         exit! 127
491       end
492     end
493     emit_dsc 'EndDocument'
494   end
496   def emit_empty_page( n )
497     emit_dsc 'Page', "#{ n } #{ n }"
498     @stream.puts "showpage"
499   end
502 def layout_booklet( layout_ps, *pages )
503   file layout_ps => pages.compact.map { |page| page.filename } do
504     begin
505       File.open( layout_ps, 'w' ) do |stream|
506         BookletLayout.new( @options, stream, *pages )
507       end
508     rescue
509       rm layout_ps
510       raise
511     end
512   end
515 SPECIAL_NAMES = %w(front-cover back-cover cover)
516 SPECIAL_NAME_RES = SPECIAL_NAMES.map do |name|
517   Regexp.new( Regexp.quote( name ).gsub( /-/, "\W*" ), "i" )
520 def normalize_special_name( name )
521   name = name.gsub( /[a-z](?=[A-Z])/, '\&-' )
522   name.gsub!( /\W+/, '-' )
523   name.downcase!
524   name
527 def initialize( dir, options={} )
528   @options = DEFAULT_OPTIONS.merge options
530   case @options[:paper]
531   when :letter
532     @options[:paper_width] = 8.5 * 72
533     @options[:paper_height] = 11 * 72
534   when :legal
535     @options[:paper_width] = 8.5 * 72
536     @options[:paper_height] = 14 * 72
537   when :tabloid
538     @options[:paper_width] = 11 * 72
539     @options[:paper_height] = 17 * 72
540   when :a4
541     @options[:paper_width] = 210 * 72 / 25.4
542     @options[:paper_height] = 297 * 72 / 25.4
543   when :a5
544     @options[:paper_width] = 148 * 72 / 25.4
545     @options[:paper_height] = 210 * 72 / 25.4
546   when :b5
547     @options[:paper_width] = 176 * 72 / 25.4
548     @options[:paper_height] = 250 * 72 / 25.4
549   when Array
550     @options[:paper_width] = @options[:paper][0].to_f
551     @options[:paper_height] = @options[:paper][1].to_f
552   else
553     raise ArgumentError, "Unknown paper size #{ @options[:paper] }"
554   end
555   
556   case @options[:orientation]
557   when :portrait
558     @options[:page_width] = @options[:paper_width]
559     @options[:page_height] = @options[:paper_height]
560   when :landscape
561     @options[:page_height] = @options[:paper_width]
562     @options[:page_width] = @options[:paper_height]
563   else
564     raise ArgumentError, "Bad orientation #{@options[:orientation]}"
565   end
567   @options[:page_width] /= 2
568   @options[:page_height] /= 2 if @options[:minimini]
570   @options[:scale] = ( @options[:page_width] - ( @options[:margin] * 2.0 ) ) / @options[:page_width]
572   pages_dir = File.join( dir, 'pages' )
574   scratch_dir = File.join( dir, 'scratch' )
575   directory scratch_dir
577   web_dir = File.join( dir, 'web' )
578   directory web_dir
580   print_dir = File.join( dir, 'print' )
581   directory print_dir
583   layout_ps = File.join( scratch_dir, 'layout.ps' )
585   specials = Set.new
586   pages = []
588   if @options[:rasterize]
589     print_ext = 'png'
590   else
591     print_ext = 'eps'
592   end
594   filelist = []
596   if @options[:filelist]
597     current_page_number = 1
598     YAML::load( File.open( @options[:filelist] ) ).each do |info|
599       if info['blank']
600         current_page_number += 1
601       else
602         name = File.basename( info['file'] )
603         if matches = /^(.*)\.([^\.]*)$/.match(name)
604           all, name, ext = matches.to_a
605         end
607         if info['spread'] == true
608           page_file = "pages-%02d-%02d.%s" % [ current_page_number, current_page_number + 1, ext ]
609           current_page_number += 2
610         elsif info['page_name']
611           page_file = "%s.%s" % [ info['page_name'].gsub("_","-"), ext ]
612         else
613           page_file = "page-%02d.%s" % [ current_page_number, ext ]
614           current_page_number += 1
615         end
616         filelist << [ info['file'], page_file ]
617       end
618     end
619   else
620     FileList[File.join( pages_dir, '*.*' )].each do |page_file|
621       filelist << [ page_file, page_file ]
622     end
623   end
625   filelist.each do |original_image_file, page_file|
626     name = File.basename( page_file )
627     if matches = /^(.*)\.([^\.]*)$/.match(name)
628       all, name, ext = matches.to_a
629     end
631     min = max = nil
632     print_name = name
633     case name
634     when *SPECIAL_NAME_RES
635       name = normalize_special_name( name )
636       next unless SPECIAL_NAMES.include? name # warning?
637       next if specials.include? name # warning?
638       specials.add name
639       if name == 'front-cover'
640         web_name = "page-00"
641       else
642         web_name = nil
643       end
644     when /^page\W*(\d+)$/i
645       min = max = $1.to_i
646       suffix = "%02d" % [ min ]
647       print_name = "page-#{ suffix }"
648       web_name = print_name
649     when /^pages?\W*(\d+)\D+(\d+)$/i
650       min, max = $1.to_i, $2.to_i
651       min, max = max, min if max < min
652       suffix = "%02d-%02d" % [ min, max ]
653       print_name = "pages-#{ suffix }"
654       web_name = print_name
655     else
656       # warning?
657       next
658     end
660     print_file = File.join( scratch_dir, "#{ print_name }.#{ print_ext }" )
662     if min
663       size = max - min + 1
664       if min > 0
665         (0...size).each do |n|
666           pages[n+min-1] = ImageSlice[ n, size, print_file ]
667         end
668       end
669     else
670       size = 1
671     end
673     case ext
674     when 'svg'
675       case print_ext
676       when 'png'
677         print_png_from_svg( print_file, size, original_image_file )
678       when 'eps'
679         eps_from_svg( print_file, original_image_file )
680       end
681     else
682       case print_ext
683       when 'png'
684         print_png_from_image_file( print_file, size, original_image_file )
685       when 'eps'
686         print_eps_from_image_file( print_file, size, original_image_file )
687       end
688     end
689     task print_file => [ scratch_dir ]
691     if web_name
692       page_png = File.join( web_dir, "#{ web_name }.png" )
693       case ext
694       when 'svg'
695         web_png_from_svg( page_png, original_image_file )
696       else
697         web_png_from_image_file( page_png, original_image_file )
698       end
699       task page_png => [ web_dir ]
701       thumbnail_name = web_name.sub( /^page/, 'thumbnail' )
702       thumbnail_jpeg = File.join( web_dir, "#{ thumbnail_name }.jpeg" )
703       thumbnail_jpeg_from_image( thumbnail_jpeg, page_png )
704       task thumbnail_jpeg => [ web_dir ]
706       task :web => [ page_png, thumbnail_jpeg ]
707     end
708   end
710   inside_front = nil
711   inside_back = nil
712   if @options[:use_inside]
713     inside_front = pages.shift
714     inside_back = pages.pop if pages.size % 4 == 1
715   end
716   pages = [ nil, inside_front ] +
717           pages + [ nil ] * ( ( 4 - pages.size ) % 4 ) +
718           [ inside_back, nil ]
720   specials.each do |name|
721     filename = File.join( scratch_dir, "#{ name }.#{ print_ext }" )
722     case name
723     when 'front-cover'
724       pages[0] ||= ImageSlice[ 0, 1, filename ] # 'cover' takes precedence
725     when 'back-cover'
726       pages[-1] ||= ImageSlice[ 0, 1, filename ] # 'cover' takes precedence
727     when 'cover'
728       pages[-1] = ImageSlice[ 0, 2, filename ]
729       pages[0] = ImageSlice[ 1, 2, filename ]
730     end
731   end
733   layout_booklet( layout_ps, *pages )
735   duplex_ps = File.join( scratch_dir, "duplex.ps" )
737   %w(proof back front duplex).each do |kind|
738     case kind
739     when 'duplex', 'proof'
740       source = layout_ps
741     else
742       source = duplex_ps
743     end 
744     kind_ps = File.join( scratch_dir, "#{ kind }.ps" )
745     send( "#{ kind }_ps", kind_ps, source )
746     task kind_ps => [ scratch_dir ]
747     kind_pdf = File.join( print_dir, "#{ kind }.pdf" )
748     pdf_from_ps( kind_pdf, kind_ps )
749     task kind_pdf => [ print_dir ]
750     task kind => [ kind_pdf ]
751   end
753   desc "Generate PDF file for duplex printing"
754   task :duplex
756   desc "Generate PDF files for single-sided printing"
757   task :single => [ :front, :back ]
759   desc "Generate PDF file for review of reader spreads"
760   task :proof
762   desc "Remove all output and intermediate files"
763   task :clean do
764     rm_rf print_dir
765     rm_rf web_dir
766     rm_rf scratch_dir
767   end
769   desc "Generate graphics for the web"
770   task :web
772   desc "All print and web output"
773   task :all => [ :proof, :duplex, :single, :web ]
778 def minicomic( dir, options={} )
779   Minicomic.new( dir, options )