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.
12 require 'rexml/document'
13 require 'rexml/streamlistener'
27 :orientation => :landscape,
31 :thumbnail_height => 96,
42 PHI = ( 1 + Math.sqrt( 5 ) ) / 2
45 def get_ps_bbox( filename )
47 File.open( filename, "r" ) do |stream|
48 magic = stream.gets.chomp
49 break unless magic =~ /^%!PS-Adobe/
50 stream.each_line do |line|
52 when /^%%BoundingBox: (\d+) (\d+) (\d+) (\d+)/
53 result = [ ($1.to_f)..($3.to_f), ($2.to_f)..($4.to_f) ]
60 raise RuntimeError, "unable to get bbox for #{ filename }" unless result
64 def get_generic_bbox( filename )
65 IO.popen("-", "r") do |stream|
69 line.chomp.split.map { |n| 0.0..(n.to_f) }
71 raise RuntimeError, "unable to get bbox for #{ filename }"
75 sh "identify", "-format", "%w %h", filename
85 include REXML::StreamListener
91 def tag_start(name, attrs)
94 @bbox = [ 0..(attrs['width'].to_f), 0..(attrs['height'].to_f) ]
100 raise "Unable to obtain bbox" unless @bbox
105 def get_svg_bbox( filename )
106 bbox_listener = SVGBBoxListener.new
108 File.open( filename, "r" ) do |stream|
109 REXML::Document.parse_stream( stream, bbox_listener )
115 def get_bbox( filename )
118 get_svg_bbox( filename )
120 get_ps_bbox( filename )
122 get_generic_bbox( filename )
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]
133 end * @options[:scale]
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
144 def back_ps( out_file, in_file )
145 file out_file => [ in_file ] do
146 sh 'psselect', '-e', in_file, out_file
150 def front_ps( out_file, in_file )
151 file out_file => [ in_file ] do
152 sh 'psselect', '-o', in_file, out_file
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]
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})"
164 layout = "2:0L(#{page_height},0)+1L(#{page_height},#{page_width})"
167 if @options[:minimini]
168 layout = "2:0(0,0)+1(#{page_width},0)+0(0,#{page_height})+1(#{page_width},#{page_height})"
170 layout = "2:0(0,0)+1(#{page_width},0)"
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
181 make2up temp_file, out_file
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
193 make2up temp_file, out_file
200 def pdf_from_ps( pdf_file, ps_file )
201 file pdf_file => [ ps_file ] do
202 case @options[:orientation]
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
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
227 sh 'inkscape', '-h', height.to_s, '-C', '-y', '1.0', '-e', temp_png_file, image_file
229 if @options[:forcedpi]
230 sh 'convert', image_file, "-filter", "Lanczos", "-resize", "x#{height}>", "png:#{temp_png_file}"
232 sh 'convert', image_file, "png:#{temp_png_file}"
237 args = '-q', '-rem', 'allb'
238 case @options[:color]
241 when :greyscale, :grayscale, false
242 args.push '-c', '0', '-bit_depth', '8'
244 args.push '-c', '0', '-bit_depth', ( high_res ? '1' : '4' )
246 args.push temp_png_file, png_file
249 rm png_file if File.exist? png_file
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
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
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
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
295 ImageSlice = Struct.new :n, :total, :filename
296 PageSpec = Struct.new :bbox, :scale, :dims, :filename
301 def initialize( options, stream, *pages )
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 )
320 # return the back cover to the end
321 pages.push pages.shift
326 def format_pages( is_cover, *pages )
327 spread = pages.map do |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 ]
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 )
344 tx = spread_dims.map { |dims| translate_single( dims ) }
346 tx = translate_facing( *spread_dims )
349 [ LEFT, RIGHT ].map do |side|
350 [ spread[side].bbox, spread[side].scale, tx[side],
351 spread[side].filename ]
356 [ page.bbox, page.scale, translate_single( page.dims ),
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 ]
371 def bottom_margin( height )
372 ( @options[:page_height].to_f - height ) / PHI
375 def horizontal_margin( width )
376 ( @options[:page_width].to_f - width ) / 2
379 def translate_single( dims )
380 [ horizontal_margin( dims[0] ), bottom_margin( dims[1] ) ]
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] ) ] ]
391 def translate_spread( left, right )
392 [ [ @options[:page_width].to_f - left[0], bottom_margin( left[1] ) ],
393 [ 0, bottom_margin( right[1] ) ] ]
396 def emit_dsc( name, value=nil )
398 @stream.puts "%%#{ name }: #{ value }"
400 @stream.puts "%%#{ name }"
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|
415 emit_page( n, *page )
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"
432 def emit_page( n, bbox, scale, translate, document )
433 emit_dsc 'Page', "#{ n } #{ n }"
436 @stream.puts "/showpage {} def"
437 emit_clip_rect( 0, 0, @options[:page_width].round, @options[:page_height].round )
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'
451 def emit_embedded_page( bbox, document )
454 emit_embedded_page_eps( bbox, document )
456 emit_embedded_page_generic( bbox, document )
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|
469 emit_dsc 'EndDocument'
472 def emit_embedded_page_generic( bbox, document )
473 emit_dsc 'BeginDocument', File.basename( document )
474 IO.popen( "-", "r" ) do |input|
476 input.each_line do |line|
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:-"
487 rescue Exception => e
493 emit_dsc 'EndDocument'
496 def emit_empty_page( n )
497 emit_dsc 'Page', "#{ n } #{ n }"
498 @stream.puts "showpage"
502 def layout_booklet( layout_ps, *pages )
503 file layout_ps => pages.compact.map { |page| page.filename } do
505 File.open( layout_ps, 'w' ) do |stream|
506 BookletLayout.new( @options, stream, *pages )
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+/, '-' )
527 def initialize( dir, options={} )
528 @options = DEFAULT_OPTIONS.merge options
530 case @options[:paper]
532 @options[:paper_width] = 8.5 * 72
533 @options[:paper_height] = 11 * 72
535 @options[:paper_width] = 8.5 * 72
536 @options[:paper_height] = 14 * 72
538 @options[:paper_width] = 11 * 72
539 @options[:paper_height] = 17 * 72
541 @options[:paper_width] = 210 * 72 / 25.4
542 @options[:paper_height] = 297 * 72 / 25.4
544 @options[:paper_width] = 148 * 72 / 25.4
545 @options[:paper_height] = 210 * 72 / 25.4
547 @options[:paper_width] = 176 * 72 / 25.4
548 @options[:paper_height] = 250 * 72 / 25.4
550 @options[:paper_width] = @options[:paper][0].to_f
551 @options[:paper_height] = @options[:paper][1].to_f
553 raise ArgumentError, "Unknown paper size #{ @options[:paper] }"
556 case @options[:orientation]
558 @options[:page_width] = @options[:paper_width]
559 @options[:page_height] = @options[:paper_height]
561 @options[:page_height] = @options[:paper_width]
562 @options[:page_width] = @options[:paper_height]
564 raise ArgumentError, "Bad orientation #{@options[:orientation]}"
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' )
580 print_dir = File.join( dir, 'print' )
583 layout_ps = File.join( scratch_dir, 'layout.ps' )
588 if @options[:rasterize]
596 if @options[:filelist]
597 current_page_number = 1
598 YAML::load( File.open( @options[:filelist] ) ).each do |info|
600 current_page_number += 1
602 name = File.basename( info['file'] )
603 if matches = /^(.*)\.([^\.]*)$/.match(name)
604 all, name, ext = matches.to_a
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 ]
613 page_file = "page-%02d.%s" % [ current_page_number, ext ]
614 current_page_number += 1
616 filelist << [ info['file'], page_file ]
620 FileList[File.join( pages_dir, '*.*' )].each do |page_file|
621 filelist << [ page_file, page_file ]
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
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?
639 if name == 'front-cover'
644 when /^page\W*(\d+)$/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
660 print_file = File.join( scratch_dir, "#{ print_name }.#{ print_ext }" )
665 (0...size).each do |n|
666 pages[n+min-1] = ImageSlice[ n, size, print_file ]
677 print_png_from_svg( print_file, size, original_image_file )
679 eps_from_svg( print_file, original_image_file )
684 print_png_from_image_file( print_file, size, original_image_file )
686 print_eps_from_image_file( print_file, size, original_image_file )
689 task print_file => [ scratch_dir ]
692 page_png = File.join( web_dir, "#{ web_name }.png" )
695 web_png_from_svg( page_png, original_image_file )
697 web_png_from_image_file( page_png, original_image_file )
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 ]
712 if @options[:use_inside]
713 inside_front = pages.shift
714 inside_back = pages.pop if pages.size % 4 == 1
716 pages = [ nil, inside_front ] +
717 pages + [ nil ] * ( ( 4 - pages.size ) % 4 ) +
720 specials.each do |name|
721 filename = File.join( scratch_dir, "#{ name }.#{ print_ext }" )
724 pages[0] ||= ImageSlice[ 0, 1, filename ] # 'cover' takes precedence
726 pages[-1] ||= ImageSlice[ 0, 1, filename ] # 'cover' takes precedence
728 pages[-1] = ImageSlice[ 0, 2, filename ]
729 pages[0] = ImageSlice[ 1, 2, filename ]
733 layout_booklet( layout_ps, *pages )
735 duplex_ps = File.join( scratch_dir, "duplex.ps" )
737 %w(proof back front duplex).each do |kind|
739 when 'duplex', 'proof'
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 ]
753 desc "Generate PDF file for duplex printing"
756 desc "Generate PDF files for single-sided printing"
757 task :single => [ :front, :back ]
759 desc "Generate PDF file for review of reader spreads"
762 desc "Remove all output and intermediate files"
769 desc "Generate graphics for the 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 )