#!/usr/bin/env ruby
# encoding: utf-8

Version='2.04'
Myname = 'copy'

require 'optparse'
require 'ostruct'
require 'mkmf'
require 'colorize'

# todo:	remove extension before adding one
#	replace unavailable density with next higher one silently
#	warn for existing output file
# copy -1 (dubbelzijdig dus) scant voor en achterkant ineens, maar ik moet
#   2x return typen.

Help=
<<'DOC'
= copy - copy from scanner to pdf or image file(s)

= Synopsis
copy [options]	

= Description
copy, without any options or arguments, scans an image on the first
available device, at the default size, producing a one page PDF (two pages
on a duplex scanner), which is then displayed with your favorite pdf
viewer. The default size is the maximum size of the device, unless
|DEFAULTX| and/or |DEFAULTY| have been set in de configursation file.

With the |--number=X| option, multiple sheets can be scanned, resulting in
a multi-page pdf document. The default is to scan 1 sheet, which will be
scanned without delay; if the option is set to 2 or more, you need to
press |RETURN| for every page to be scanned, including the first, giving
you time to place a new sheet on the scanner. You don't need to count
sheets - you can set the |--number| option to a large number and stop
scanning with |control-D|.

With the |--imagetype=X| option, you can scan to one or more image files,
instead of pdf.X can be one of |pdf, png, jpg, ppm, gif, pgm, pbm, tif|
Again, these are saved in the file which you specified with the
|--outfile=X| option, where |X| is |copy| by default. If you scan multiple
pages, this file name will be extended with |1| for the first page, |2| for
the second, and so on. With the |--start| option, numbering can be started
with an other number than 1.
Many options are available to influence brightness, contrast, pixel
density, color mode, image quality, rotation, page position and size, and
more. See the Options section for more information.

= Configuration
copy needs one or more scan devices and it needs to know their properties.
It reads that information from a configuration file. Copy looks at three
places for that file, and uses the first available:

   1. ./copy.conf
   2. ~/.copy.conf
   3. $PREFIX/copy.conf where PREFIX is an environment variable, like |/usr/local|

You need to create this configuration file, using the |scanimage| program
with the |--help| option, which lists the available devices, as well as
their properties. This file must also define the constants |PDFVIEWER| and
|IMGVIEWER|, which point to executables to display pdf's and images
respectively. Optionally, you can define the constants |DEFAULTX| and
|DEFAULTY|, containing width and height of the scan area in mm. Without
these, the maximum area of the device will be scanned if the |-x| and |-y|
options are not used.

Here is an example, which has two entries,
both for the Epson V350 scanner, the first in flatbed mode, the second
in transparency mode:

  PDFVIEWER=ENV['PDFVIEWER'] || 'evince'
  IMGVIEWER=ENV['IMGVIEWER'] || 'xv'
  DEFAULTX=210
  DEFAULTY=297
  DEVICES = [
    OpenStruct.new(
      :device  => 'epkowa',
      :name    => 'Epson V350 Flatbed',
      :source  => 'Flatbed',
      :Lineart => 'Binary',
      :Gray    => 'Gray',
      :Color   => 'Color',
      :xmax    => 215.9,
      :ymax    => 297.18,
      :xdpi    => [100,200,300,400,600,800,1200,2400,4800],
      :ydpi    => [100,200,300,400,600,800,1200,1600,2400,3600,4800,6600,9600],
      :hasxy   => true,
      :Dup     => false
    ),
    OpenStruct.new(
      :device  => 'epkowa',
      :name    => 'Epson V350 Transparency',
      :source  => 'Transparency',
      :posneg  => ['Positive','Negative'],
      :Lineart => 'Binary',
      :Gray    => 'Gray',
      :Color   => 'Color',
      :xmax    => 36.83,
      :ymax    => 122.17,
      :xdpi    => [300,600,1200,2400,4800],
      :ydpi    => [100,300,400,600,800,1200,1600,2400,3600,4800,6600,9600],
      :hasxy   => true,
      :Dup     => false
    ),
  ]

= Options

== General options
-h			Print this help and exit
-H, --help		Show full documentation via less and exit
-V, --version		Print version and exit
-v, --verbose		Be verbose

== Device options
-0			Set the scan device nr to 0 (or 1..9).
			The default is 0.
    --list		List all devices

== Brightness,Contrast,density options
-b, --brightness=X	Set brightness correction to X percent (default: 0)
-c, --contrast=X	Set contrast correction to X percent (default: 0)
-d, --density=X		Set contrast correction to X dpi (default: 300)

== Color mode options
-C, --color		Color mode (the default)
-G, --gray		Gray instead of Color mode
-L, --lineart		Lineart instead of Color mode

== Positioning options
-l, --left=X		set left offset to X mm (default: 0)
-t, --top=X		set top offset to X mm (default: 0)
-x, --x=X		set width to X mm (default: scanner maximum)
-y, --y=X		set height to X mm (default: scanner maximum)
-A, --A=X		set page size to X, where X can be A0..A8

== Output options
-o, --outfile=X		Set output filename to X (default: copy).
			If not explicitly set, copy.pdf is not saved, but
			displayed with the application defined in the 
			environment variable |PDFVIEWER|.
			Other image types are always saved.
-i, --imagetype=X	set image type to X; default: pdf

== Conversion & Interaction options
-s, --start=X		Starting number for multiple output files (default: 1)
-n, --number=X		Number of sheets to scan (1 or more, default: 1);
			on a duplex scanner, this generates twice as many pages.
-q, --quality=X		jpg conversion quality (%, default 75)
-r, --rotate=X		rotate X degrees clockwise
-e, --examples		show some examples of use

= Author
[Wybo Dekker](wybo@dekkerdocumenten.nl)

= Copyright
Released under the [GNU General Public License](www.gnu.org/copyleft/gpl.html)
DOC

def warn(*m) Warn *m if $o.verbose; end
def Warn(*m) mess :magenta,*m; end
def  die(*m) mess :red,*m; exit 1; end

# print colored string(s), 1 line per argument
# First argument can be foreground color
# Second argument can be backgroud color
def mess(*args)
  # Set default colors
  foreground_color = args.first.is_a?(Symbol) ? args.shift : :black
  background_color = args.first.is_a?(Symbol) ? args.shift : :default
  return if args.empty?
  prefix=Myname + ": "
  # Print the remaining arguments with indentation, the first prefixed with
  # Myname:
  args.each { |s|
    puts (prefix + " #{s}").colorize(color: foreground_color, background: background_color)
    prefix.gsub!(/./,' ')
  }
end

def help
  system("echo \"#{Help}\" | less -P#{Myname}-#{Version.tr('.','·')}")
end

def findex(*f)
   missing=[]
   f.each do |v|
        find_executable0(v.sub(/ .*/,'')) or missing.push(v)
   end
   die("Missing executables: #{missing}") if missing.size > 0
end

def setdev(n)
   $o.device == DEVICES[0] or die "you can set the scan device only once"
   n.between?(0,DEVICES.size-1) or
      die "the device number must be between 0 and #{DEVICES.size-1}, not #{n}"
   d = DEVICES[n]
   $o.device     = d
   $o.name       = d.name
   $o.mode       = d.Color
   $o.xmax       = d.xmax
   $o.ymax       = d.ymax
   $o.source     = d.source or nil
   $o.brightness = d.brightness
   $o.contrast   = d.contrast
   $o.density    = d.density
end

$cffile = ''
def find_devices
   # Read DEVICES and PDFVIEWER from the config file
   ['./copy.conf',
    ENV['HOME']+'/.copy.conf',
    ENV['PREFIX']+'/copy.conf'
   ].each do |c|
       if File.exist?(c)
          load c
	  $cffile = c # remember for after option handling
          break
       end
   end
   defined? DEVICES or die "Found no configuration file"

   # The scanners now have a name (DEVICE[n].name), but no device
   # (DEVICE[n].device).
   # We cannot store the device names, because these change when as
   # scanner is removed from the system and the put back.
   # Let scanimage find the current device name from the scanners name.
   # Note that this may take a long time depending on the entries in
   # /etc/sane.d/dll.conf.
   # If you have /etc/sane.d/dll.d/iscan, you can also put the active
   # entries there and uncomment all entries in dll.conf.
   # In the .conf file corresponding to the active entries, remove all
   # irrelevant devices; for example, remove scsi entries if you have no
   # scsi.
   # For example, in my system, with all entries
   # uncommented, it takes 37 seconds; after commenting out all entries not
   # reported by scanimage -L, it takes 0.34 seconds.

   t = Time.now
   p=`scanimage -f "'%m'=>'%d', "`

   # this creates, in p, something like:
   # 'CANON DR-2080C' => 'canon_dr:libusb:001:003',
   # 'Epson PID 08C3' => 'epson2:libusb:002:003',
   # 'Epson WF-2650/2660 Series' => 'epkowa:usb:002:003',
   # 'Epson Perfection V350' => 'epkowa:interpreter:001:008',
   dev = {}
   eval "dev = {#{p}}"
   DEVICES.each do |d|
      d.device = dev[d.name] or Warn "'#{d.name}' is not an existing device.",
					"Have a look at the output of",
					%q{scanimage -f "'%m'=>'%d', %n"}
   end
   tt = Time.now - t 
   if tt > 10
      Warn "Device scan took #{tt} seconds.",
           "You should probably comment out all entries in",
 	   "/etc/sane.d/dll.conf except:"
      puts dev.values.map {|v| "\t"+v.sub(/:.*/,'') }.uniq
   end
end

def sys(command)
   warn(command.strip.squeeze(' '))
   system(command) or system(command) or die "system call #{command} failed"
end

def onescan(file,start,last)
   warn "onescan(#{file},#{start},#{last})"
   sys <<-EOF
        scanimage \
        --device="#{$o.device.device}" \
        #{$o.source.nil? ? nil : '--source='+$o.source.gsub(/ /,'\\ ')} \
        #{$o.film.nil? ? nil : '--film-type='+$o.film.gsub(/ /,'\\ ')} \
        --#{$o.device.hasxy ? 'x-' : ''}resolution #{$o.density} \
        --#{$o.device.hasxy ? 'y-' : ''}resolution #{$o.density} \
        --mode #{$o.mode} \
        -l #{$o.left} \
        -t #{$o.top} \
        -x #{$o.x} \
        -y #{$o.y} \
	--batch-start #{start} \
	--batch-count #{$o.device.Dup ? 2 : 1} \
	--format pnm \
	--batch="#{file}.ppm" 2>/dev/null
   EOF
   unless start == last
      puts "Press enter to scan sheet \e[1;5;37;43m #{start.next} \e[0m"
      gets
      puts "\e[F\e[F"
   end
end

def scan(dir,file)
   warn "Running: scan(#{dir},#{file})"
   dup = $o.device.Dup	# true if scanning two sides
   inc = dup ? 2 : 1	# increment filename with 2 if scanning two sides
   last = $o.number*inc	# total no of pages/files generated
   # for a single page scan, filename is set with -o|--output option,
   # default: copy
   # For a multipage scan, the filename is suffixed with 1, 2, 3, ... or, 
   # there are more than 9 pages, 01, 02, 03,,, or even 001, 002, 003...
   f = file
   f += "%0#{last.to_s.size}d" if last > 1
   f =~ /^\// or f = "#{dir}/#{f}"
   (1..last).step(inc).each do |n|
       onescan(f,n,last-(dup ? 1 : 0))
   end
   sys <<-EOF
	for i in #{dir}/*.ppm; do
	   out="${i%.ppm}".#{$o.imagetype}
	   exiftool -comment='' -overwrite_original_in_place "$i" >/dev/null
	   convert -quality #{$o.quality} \
		-density #{$o.density} \
		-brightness-contrast #{$o.brightness}x#{$o.contrast} \
		-comment '' \
		"$i" -rotate #{$o.rotate} "$out"
	    test #{$o.imagetype} = ppm || rm "$i"
	done
   EOF
   nscan = Dir["#{dir}/*.#{$o.imagetype}"].size
   warn "#{nscan} pages scanned"
   nscan.positive? or exit
end

find_devices

# Option defaults:
$o = OpenStruct.new(
  :brightness  => DEVICES[0].brightness,
  :contrast    => DEVICES[0].contrast,
  :density     => DEVICES[0].density,
  :device      => DEVICES[0],
  :imagetype   => 'pdf',
  :left        => 0,
  :mode        => DEVICES[0].Color,
  :name        => DEVICES[0].name,
  :number      => 1,
  :outpath     => ENV['PWD'],
  :outfile     => nil,
  :quality     => 75,
  :rotate      => 0,
  :start       => 1,
  :top         => 0,
  :verbose     => false,
  :x           => DEFAULTX || DEVICES[0].xmax,
  :xmax        => DEVICES[0].xmax,
  :y           => DEFAULTY || DEVICES[0].ymax,
  :ymax        => DEVICES[0].ymax,
)

OptionParser.new(
   banner = <<~EOD,
	This is copy version #{Version}:
	copy from scanner to pdf or image(s)\n
	Usage: #{Myname} -[0..9]  [other options]\n
	Options:
	EOD
   width = 23,
   indent = ''
) do |opts|

   # check that any device option is first argument:
   opts.default_argv.size > 0 and
   opts.default_argv[1..-1].map do |x|
      x.match(/^-[1-9]/) and
         die "Device options -1..-9 must be first argument"
   end

   opts.separator "== General options"
   opts.on('-h','print this help and exit') do
      puts opts.to_a.delete_if { |x| x =~ /—$/ }
      exit
   end

   opts.on('-H','--help','show full documentation via less and exit') do
      help
      exit
   end

   opts.on('-V','--version','print version and exit') do
      puts Version
      exit
   end

   opts.on('-v','--verbose','be verbose') do
      $o.verbose = true
   end

   opts.separator "\n== Device options"
   opts.on('-0',	'set the scan device nr to 0 (or 1..9)',
     		"the default is 0 (#{$o.name})"
   ) do
      setdev(0)
   end
   opts.on('-1','—') do setdev(1) end
   opts.on('-2','—') do setdev(2) end
   opts.on('-3','—') do setdev(3) end
   opts.on('-4','—') do setdev(4) end
   opts.on('-5','—') do setdev(5) end
   opts.on('-6','—') do setdev(6) end
   opts.on('-7','—') do setdev(7) end
   opts.on('-8','—') do setdev(8) end
   opts.on('-9','—') do setdev(9) end

   opts.on('--list','List all devices') do
      (0...DEVICES.size).each do |i|
         printf "%i %s %s\n",i,DEVICES[i].name,DEVICES[i].source
      end
      exit
   end

   opts.separator "\n== brightness,contrast,density options"

   opts.on('-b','--brightness=X',Integer,
           'set brightness correction to X percent',
           " (default: #{$o.brightness})"
   ) do |v|
      v.between?(-100,100) or
         die "brightness must be between -100 and 100"
      $o.brightness = v
   end

   opts.on('-c','--contrast=X',Integer,
      "set contrast correction to X percent (default: #{$o.contrast})"
   ) do |v|
      v.between?(-100,100) or
         die "contrast must be between -100 and 100"
      $o.contrast = v
   end

   opts.on('-d','--density=X',Integer,
      "set contrast correction to X dpi (default: #{$o.density})"
   ) do |v|
      $o.device.xdpi.include?(v) or
         die("density must be in #{$o.device.xdpi.inspect} dpi")
      $o.density = v
   end

   opts.separator "\n== Color mode options"

   opts.on('-C','--color','Color mode (the default)') do
      $o.mode = $o.device.Color
   end

   opts.on('-G','--gray','Gray instead of Color mode') do
      $o.mode = $o.device.Gray
   end

   opts.on('-L','--lineart','Lineart instead of Color mode') do
      $o.mode = $o.device.Lineart
   end

   opts.separator "\n== Positioning options"

   opts.on('-l','--left=X',Integer,
           "set left offset to X mm (default: #{$o.left})"
   ) do |v|
      $o.left = v
   end

   opts.on('-t','--top=X',Integer,
           "set top offset to X mm (default: #{$o.top})"
   ) do |v|
      $o.top = v
   end

   opts.on('-x','--x=X',Integer,
           "set width to X mm (default: #{$o.x})"
   ) do |v|
      $o.x = [v,$o.xmax].min
   end

   opts.on('-y','--y=X',Integer,
           "set height to X mm (default: #{$o.y})"
   ) do |v|
      $o.y = [v,$o.ymax].min
   end

   opts.on('-A','--A=X',Integer,
           "set page size to X, where X can be A0..A8"
   ) do |v|
      v.between?(0,8) or die "page size must be between 0 and 8"
      w=(0..8).map {|x| (841/1.41412**x).to_i }
      h=(0..8).map {|y| (1189/1.41412**y).to_i }
      $o.x = [w[v],$o.xmax].min
      $o.y = [h[v],$o.ymax].min
   end

   opts.separator "\n== Output options"

   opts.on('-o','--outfile=X',String,
           'set output filename to X (default: copy)'
   )  do |v|
      b=v.split('/')[-1]
      if b != v # slash found?
         $o.outpath = v.sub(/(.*)\/.*/,'\1')
      end
      $o.outfile = b
   end

   opts.on('-i','--imagetype=X',String,
      'set image type to X; default: pdf')  do |v|
      $o.imagetype = v
      v =~/^(pdf|png|jpg|ppm|gif|pgm|pbm|tif)$/ or
         die("Unknown image type #{v}")
   end

   opts.separator "\n== Conversion & Interaction options"

   opts.on('-s','--start=X',Integer,
           'Starting number for multiple output files (default: 1)'
   ) do |v|
      $o.start = v
   end

   opts.on('-n','--number=X',Integer,
           'Number of sheets to scan (1 or more, default: 1)'
   ) do |v|
      $o.number = v
   end

   opts.on('-q','--quality=X',Integer,
           "jpg conversion quality (%,default #{$o.quality})"
   ) do |v|
      $o.quality = v
   end

   opts.on('-r','--rotate=X',Integer,'rotate X degrees clockwise') do |v|
      v = v.to_i
      unless [0,90,180,270].include?(v)
        die("--rotate option argument must be 0,90,180,or 270")
      end
      $o.rotate = v
   end

   opts.on('-e','--examples','show some examples of use') do
      puts <<-EOD
      copy a page and preview it:
         copy

      on device 3, which is a duplex scanner, create 4 colored JPEG images
      in testn.jpg:
         copy -3 -n2 -ijpg -otest

      copy a 30x30 mm square in the center of an A4 page and preview the pdf:
         copy -t133 -l90 -x30 -y30

      Same, but save the output  to test.pdf without previewing:
         copy -t133 -l90 -x30 -y30 -otest

      EOD
      exit
   end

   opts.on('-I','—') do
      system("instscript #{Myname}") or
         die 'the -I option is for developers only'
      exit
   end

   opts.parse!
end

warn "Configuration file: #{$cffile || 'none'}"
ARGV.size >0 and die("Unexpected arguments: #{ARGV}\n")
findex('scanimage','convert','pdflatex',PDFVIEWER)
if $o.imagetype == 'pdf'
   $o.imagetype = 'jpg'
   pdf = true
end

# create files in temporary directory:
file = $o.outfile || 'copy'
dir = "/tmp/#{Myname}-#{$$}"
Dir.mkdir(dir)
[0,1,2,15].each do |s|
   Signal.trap(s) do
      unless s == 1
         Dir["#{dir}/*"].each { |d| File.unlink(d) }
         Dir.rmdir(dir)
      end
   end
end


scan(dir,file)

if pdf
   w,h = $o.x,$o.y
   w,h = h,w if [90,270].include?($o.rotate)

   tex = open("#{dir}/main.tex",'w')
   tex.print <<~'EOD' % [w,h]
	\documentclass{article}
	\usepackage[margin=0pt,paperwidth=%smm,paperheight=%smm]{geometry}
	\parindent0pt\parskip0pt
	\usepackage{graphicx}
	\pagestyle{empty}
	\begin{document}
	EOD

   Dir["#{dir}/*."+$o.imagetype].sort.each do |f|
      tex.puts <<~'EOD' % f
	\includegraphics[height=.999\paperheight,width=.999\paperwidth]{%s}
	\eject
	EOD
   end
   tex.puts '\end{document}'
   tex.close
   sys("pdflatex --output-directory=#{dir} #{dir}/main >/dev/null")
   if $o.outfile
      sys("mv #{dir}/main.pdf \"#{$o.outpath}/#{$o.outfile}.pdf\"")
   else
      sys("#{PDFVIEWER} #{dir}/main.pdf")
   end
else
   sys("mv #{dir}/*.#{$o.imagetype} .")
end
