qttk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +2 -0
  3. data/Gemfile +6 -0
  4. data/Gemfile.lock +36 -0
  5. data/README.markdown +131 -0
  6. data/Rakefile +17 -0
  7. data/TODO.markdown +236 -0
  8. data/bin/qt +7 -0
  9. data/etc/gutenprint/gp-tool.rb +56 -0
  10. data/etc/gutenprint/gutenprint-filter.c +400 -0
  11. data/etc/gutenprint/gutenprint.rb +86 -0
  12. data/etc/gutenprint/stp-test +326 -0
  13. data/etc/images/3551599565_db282cf840_o.jpg +0 -0
  14. data/etc/images/4843122063_d582c569e9_o.jpg +0 -0
  15. data/etc/images/4843128953_83c1770907_o.jpg +0 -0
  16. data/lib/quadtone.rb +56 -0
  17. data/lib/quadtone/cgats.rb +137 -0
  18. data/lib/quadtone/cluster_calculator.rb +81 -0
  19. data/lib/quadtone/color.rb +83 -0
  20. data/lib/quadtone/color/cmyk.rb +112 -0
  21. data/lib/quadtone/color/device_n.rb +23 -0
  22. data/lib/quadtone/color/gray.rb +46 -0
  23. data/lib/quadtone/color/lab.rb +150 -0
  24. data/lib/quadtone/color/qtr.rb +71 -0
  25. data/lib/quadtone/color/rgb.rb +71 -0
  26. data/lib/quadtone/color/xyz.rb +80 -0
  27. data/lib/quadtone/curve.rb +138 -0
  28. data/lib/quadtone/curve_set.rb +196 -0
  29. data/lib/quadtone/descendants.rb +9 -0
  30. data/lib/quadtone/environment.rb +5 -0
  31. data/lib/quadtone/extensions/math.rb +11 -0
  32. data/lib/quadtone/extensions/pathname3.rb +11 -0
  33. data/lib/quadtone/printer.rb +106 -0
  34. data/lib/quadtone/profile.rb +217 -0
  35. data/lib/quadtone/quad_file.rb +59 -0
  36. data/lib/quadtone/renderer.rb +139 -0
  37. data/lib/quadtone/run.rb +10 -0
  38. data/lib/quadtone/sample.rb +32 -0
  39. data/lib/quadtone/separator.rb +36 -0
  40. data/lib/quadtone/target.rb +277 -0
  41. data/lib/quadtone/tool.rb +61 -0
  42. data/lib/quadtone/tools/add_printer.rb +73 -0
  43. data/lib/quadtone/tools/characterize.rb +43 -0
  44. data/lib/quadtone/tools/chart.rb +31 -0
  45. data/lib/quadtone/tools/check.rb +16 -0
  46. data/lib/quadtone/tools/dir.rb +15 -0
  47. data/lib/quadtone/tools/edit.rb +23 -0
  48. data/lib/quadtone/tools/init.rb +82 -0
  49. data/lib/quadtone/tools/install.rb +15 -0
  50. data/lib/quadtone/tools/linearize.rb +28 -0
  51. data/lib/quadtone/tools/list.rb +19 -0
  52. data/lib/quadtone/tools/print.rb +38 -0
  53. data/lib/quadtone/tools/printer_options.rb +40 -0
  54. data/lib/quadtone/tools/rename.rb +17 -0
  55. data/lib/quadtone/tools/render.rb +43 -0
  56. data/lib/quadtone/tools/rewrite.rb +15 -0
  57. data/lib/quadtone/tools/separate.rb +71 -0
  58. data/lib/quadtone/tools/show.rb +15 -0
  59. data/lib/quadtone/tools/test.rb +26 -0
  60. data/qttk.gemspec +34 -0
  61. metadata +215 -0
@@ -0,0 +1,196 @@
1
+ module Quadtone
2
+
3
+ class CurveSet
4
+
5
+ attr_accessor :profile
6
+ attr_accessor :channels
7
+ attr_accessor :type
8
+ attr_accessor :curves
9
+
10
+ def initialize(params={})
11
+ @curves = []
12
+ params.each { |key, value| send("#{key}=", value) }
13
+ raise "Profile must be specified" unless @profile
14
+ raise "Channels must be specified" unless @channels
15
+ raise "Type must be specified" unless @type
16
+ @target = Target.new(name: @type.to_s, channels: @channels, base_dir: @profile.dir_path, type: @type, ink_limits: @profile.ink_limits)
17
+ generate_scale
18
+ end
19
+
20
+ def build_target
21
+ @target.build
22
+ end
23
+
24
+ def print_target
25
+ @profile.print_file(@target.image_file, calibrate: (@type == :characterization), print: true)
26
+ end
27
+
28
+ def measure_target(options={})
29
+ @target.measure(options)
30
+ process_target
31
+ chart_target
32
+ end
33
+
34
+ def process_target
35
+ case @type
36
+ when :characterization
37
+ import_from_target
38
+ verify_increasing_values
39
+ set_common_white
40
+ # trim_to_limits
41
+ # @profile.ink_limits = Hash[
42
+ # @curves.map do |curve|
43
+ # [
44
+ # curve.channel,
45
+ # curve.samples.last.input.value
46
+ # ]
47
+ # end
48
+ # ]
49
+ # normalize_curves
50
+ @profile.ink_partitions = partitions
51
+ when :linearization, :test
52
+ import_from_target
53
+ if @type == :linearization
54
+ @profile.linearization = grayscale(21)
55
+ elsif @type == :test
56
+ @profile.grayscale = grayscale(21)
57
+ end
58
+ end
59
+ @profile.save
60
+ @profile.install
61
+ end
62
+
63
+ def chart_target
64
+ import_from_target
65
+ out_file = (@profile.dir_path + @type.to_s).with_extname('.html')
66
+ out_file.open('w') { |io| io.write(to_html) }
67
+ ;;warn "Saved chart to #{out_file.to_s.inspect}"
68
+ system('qlmanage', '-p', out_file.to_s)
69
+ end
70
+
71
+ private
72
+
73
+ def import_from_target
74
+ @target.read
75
+ @curves.each do |curve|
76
+ curve.samples = @target.samples[curve.channel]
77
+ end
78
+ end
79
+
80
+ def generate_scale
81
+ @curves = @channels.map do |channel|
82
+ Curve.new(channel: channel, samples: [
83
+ Sample.new(input: Color::Gray.new(k: 0), output: Color::Lab.new(l: 100)),
84
+ Sample.new(input: Color::Gray.new(k: 1), output: Color::Lab.new(l: 0))
85
+ ])
86
+ end
87
+ end
88
+
89
+ def channels
90
+ @curves.map(&:channel)
91
+ end
92
+
93
+ def verify_increasing_values
94
+ @curves.each { |c| c.verify_increasing_values }
95
+ end
96
+
97
+ def trim_to_limits
98
+ @curves.each { |c| c.trim_to_limit }
99
+ end
100
+
101
+ def normalize_curves
102
+ @curves.each { |c| c.normalize_inputs }
103
+ end
104
+
105
+ # find average shade of paper, and update each curve to have that average as its first value
106
+
107
+ def set_common_white
108
+ samples = samples_with_value(0)
109
+ outputs = samples.map(&:output)
110
+ average, error = Color::Lab.average(outputs)
111
+ raise "too much variance in white samples: average = #{average}, error = #{error}" if error >= 1
112
+ samples.each { |s| s.output = average }
113
+ end
114
+
115
+ def samples_with_value(value)
116
+ @curves.map { |c| c.samples.find { |s| s.input_value == value } }.compact.flatten
117
+ end
118
+
119
+ def partitions
120
+ partitions = {}
121
+ previous_curve = nil
122
+ @curves.sort_by(&:dmax).reverse.each do |curve|
123
+ ;;warn "processing #{curve.channel}"
124
+ last_sample = curve.samples.last
125
+ if previous_curve
126
+ partitions[curve.channel] = previous_curve.input_for_output(last_sample.output.value) * partitions[previous_curve.channel]
127
+ ;;warn "\t" + "value on previous curve for dmax #{last_sample.output.value} = #{partitions[curve.channel]}"
128
+ else
129
+ partitions[curve.channel] = last_sample.input.value
130
+ ;;warn "\t" + "using absolute value of curve for dmax #{last_sample.output.value} = #{partitions[curve.channel]}"
131
+ end
132
+ previous_curve = curve
133
+ end
134
+ partitions
135
+ end
136
+
137
+ def grayscale(steps)
138
+ raise "Can't get gray scale of non-grayscale curveset" if @channels.length > 1
139
+ @curves.first.grayscale(steps)
140
+ end
141
+
142
+ def to_html
143
+ html = Builder::XmlMarkup.new(indent: 2)
144
+ html.div do
145
+ html.ul do
146
+ html.li("Channels: #{@channels.join(', ')}")
147
+ end
148
+ html.h3('Curve set:')
149
+ html.table(border: 1) do
150
+ html.tr do
151
+ [
152
+ 'channel',
153
+ 'ink limit',
154
+ 'density: min',
155
+ 'density: max',
156
+ 'density: range',
157
+ ].each { |s| html.th(s) }
158
+ end
159
+ @curves.each do |curve|
160
+ html.tr do
161
+ dmin, dmax = curve.dynamic_range
162
+ [
163
+ curve.channel.to_s,
164
+ curve.ink_limit.input,
165
+ '%.2f' % dmin,
166
+ '%.2f' % dmax,
167
+ '%.2f' % (dmax - dmin),
168
+ ].each { |s| html.td(s) }
169
+ end
170
+ end
171
+ end
172
+ html << to_svg
173
+ end
174
+ html.target!
175
+ end
176
+
177
+ def to_svg(options={})
178
+ size = options[:size] || 500
179
+ svg = Builder::XmlMarkup.new(indent: 2)
180
+ svg.svg(xmlns: 'http://www.w3.org/2000/svg', version: '1.1') do
181
+ svg.g(width: size, height: size, transform: "translate(0,#{size}) scale(1,-1)") do
182
+ svg.g(stroke: 'blue') do
183
+ svg.rect(x: 0, y: 0, width: size, height: size, fill: 'none', :'stroke-width' => 1)
184
+ svg.line(x1: 0, y1: 0, x2: size, y2: size, :'stroke-width' => 0.5)
185
+ end
186
+ @curves.each do |curve|
187
+ curve.draw_svg(svg, options)
188
+ end
189
+ end
190
+ end
191
+ svg.target!
192
+ end
193
+
194
+ end
195
+
196
+ end
@@ -0,0 +1,9 @@
1
+ # from http://stackoverflow.com/a/2393878
2
+
3
+ class Class
4
+
5
+ def descendants
6
+ ObjectSpace.each_object(::Class).select { |c| c < self }
7
+ end
8
+
9
+ end
@@ -0,0 +1,5 @@
1
+ module Quadtone
2
+
3
+ BaseDir = Pathname.new(ENV['HOME']) + '.qttk'
4
+
5
+ end
@@ -0,0 +1,11 @@
1
+ module Math
2
+
3
+ def deg2rad(deg)
4
+ deg * (PI / 180)
5
+ end
6
+
7
+ def rad2deg(rad)
8
+ rad * (180 / PI)
9
+ end
10
+
11
+ end
@@ -0,0 +1,11 @@
1
+ class Pathname
2
+
3
+ def with_extname(extname)
4
+ Pathname.new(without_extname.to_s + extname)
5
+ end
6
+
7
+ def without_extname
8
+ Pathname.new(to_s.sub(/#{Regexp.quote(self.extname)}$/, ''))
9
+ end
10
+
11
+ end
@@ -0,0 +1,106 @@
1
+ module Quadtone
2
+
3
+ class Printer
4
+
5
+ ImportantOptions = %i{
6
+ MediaType
7
+ Resolution
8
+ ripBlack
9
+ ripSpeed
10
+ stpDither
11
+ }
12
+
13
+ attr_accessor :name
14
+ attr_accessor :options
15
+ attr_accessor :attributes
16
+ attr_accessor :inks
17
+
18
+ def initialize(name)
19
+ @name = name
20
+ @cups_ppd = CupsPPD.new(@name, nil)
21
+ @options = Hash[
22
+ @cups_ppd.options.map { |o| [o.delete(:keyword).to_sym, HashStruct.new(o)] }
23
+ ]
24
+ @attributes = Hash[
25
+ @cups_ppd.attributes.map { |a| [a.delete(:name).to_sym, HashStruct.new(a)] }
26
+ ]
27
+ @cups_printer = CupsPrinter.new(@name)
28
+ get_inks
29
+ end
30
+
31
+ def quadtone?
32
+ @attributes[:Manufacturer].value == 'QuadToneRIP'
33
+ end
34
+
35
+ def get_inks
36
+ # FIXME: It would be nice to get this path programmatically.
37
+ ppd_file = Pathname.new("/etc/cups/ppd/#{@name}.ppd")
38
+ ppd_file.readlines.each do |line|
39
+ if line =~ /^\*%Inks\s*(.*?)\s*$/
40
+ @inks = $1.split(/,/).map(&:downcase).map(&:to_sym)
41
+ break
42
+ end
43
+ end
44
+ end
45
+
46
+ def page_size(name=nil)
47
+ name ||= @cups_ppd.attribute('DefaultPageSize').first[:value]
48
+ page_size = @cups_ppd.page_size(name) or raise "Can't determine page size #{name.inspect}"
49
+ size = HashStruct.new(page_size)
50
+ # change 'length' to 'height', or else there are problems with Hash#length
51
+ size[:height] = size.delete(:length)
52
+ size = HashStruct.new(size)
53
+ size.imageable_width = size.margin.right - size.margin.left
54
+ size.imageable_height = size.margin.top - size.margin.bottom
55
+ size
56
+ end
57
+
58
+ def default_options
59
+ Hash[
60
+ @options.map do |name, option|
61
+ [name, option.default_choice]
62
+ end
63
+ ]
64
+ end
65
+
66
+ def show_attributes
67
+ puts "Attributes:"
68
+ max_field_length = @attributes.keys.map(&:length).max
69
+ @attributes.sort_by { |name, info| name }.each do |name, attribute|
70
+ puts "\t" + "%#{max_field_length}s: %s%s" % [
71
+ name,
72
+ attribute.value.inspect,
73
+ attribute.spec.empty? ? '' : " [#{attribute.spec.inspect}]"
74
+ ]
75
+ end
76
+ end
77
+
78
+ def show_options
79
+ puts "Options:"
80
+ max_field_length = @options.keys.map(&:length).max
81
+ @options.sort_by { |name, option| name }.each do |name, option|
82
+ puts "\t" + "%#{max_field_length}s: %s [%s]" % [
83
+ name,
84
+ option.default_choice.inspect,
85
+ (option.choices.map { |o| o.choice } - [option.default_choice]).map { |o| o.inspect }.join(', ')
86
+ ]
87
+ end
88
+ end
89
+
90
+ def show_inks
91
+ puts "Inks:"
92
+ puts "\t" + @inks.map { |ink| ink.to_s.upcase }.join(', ')
93
+ end
94
+
95
+ def print_file(path, options)
96
+ warn "Printing #{path}"
97
+ warn "Options:"
98
+ options.each do |key, value|
99
+ warn "\t" + "%10s: %s" % [key, value]
100
+ end
101
+ @cups_printer.print_file(path, options)
102
+ end
103
+
104
+ end
105
+
106
+ end
@@ -0,0 +1,217 @@
1
+ module Quadtone
2
+
3
+ class Profile
4
+
5
+ attr_accessor :printer
6
+ attr_accessor :printer_options
7
+ attr_accessor :medium
8
+ attr_accessor :inks
9
+ attr_accessor :ink_partitions
10
+ attr_accessor :ink_limits
11
+ attr_accessor :linearization
12
+ attr_accessor :default_ink_limit
13
+ attr_accessor :gray_highlight
14
+ attr_accessor :gray_shadow
15
+ attr_accessor :gray_overlap
16
+ attr_accessor :gray_gamma
17
+ attr_accessor :characterization_curveset
18
+ attr_accessor :linearization_curveset
19
+ attr_accessor :test_curveset
20
+
21
+ ProfilesDir = BaseDir + 'profiles'
22
+ ProfileName = 'profile.txt'
23
+
24
+ def self.profile_names
25
+ Pathname.glob(ProfilesDir + '*').select { |p| p.directory? && p[0] != '.' }.map(&:basename)
26
+ end
27
+
28
+ def self.load(name)
29
+ profile = Profile.new
30
+ profile.load(name)
31
+ profile
32
+ end
33
+
34
+ def initialize(params={})
35
+ @printer_options = nil
36
+ @default_ink_limit = 1.0
37
+ @ink_limits = {}
38
+ @ink_partitions = {}
39
+ @gray_highlight = 0.06
40
+ @gray_shadow = 0.06
41
+ @gray_overlap = 0.10
42
+ @gray_gamma = 1.0
43
+ params.each { |key, value| send("#{key}=", value) }
44
+ end
45
+
46
+ def name
47
+ [
48
+ @printer.name.gsub(/[^-A-Z0-9]/i, ''),
49
+ @medium.gsub(/[^-A-Z0-9]/i, ''),
50
+ ].flatten.join('-')
51
+ end
52
+
53
+ def load(name)
54
+ inks_by_num = []
55
+ (ProfilesDir + name + ProfileName).readlines.each do |line|
56
+ line.chomp!
57
+ line.sub!(/#.*/, '')
58
+ line.strip!
59
+ next if line.empty?
60
+ key, value = line.split('=', 2)
61
+ case key
62
+ when 'PRINTER'
63
+ @printer = Printer.new(value)
64
+ when 'PRINTER_OPTIONS'
65
+ @printer_options = Hash[ value.split(',').map { |o| o.split('=') } ]
66
+ when 'MEDIUM'
67
+ @medium = value
68
+ when 'GRAPH_CURVE'
69
+ # ignore
70
+ when 'N_OF_INKS'
71
+ # ignore
72
+ when 'INKS'
73
+ @inks = value.split(',').map(&:downcase).map(&:to_sym)
74
+ when 'DEFAULT_INK_LIMIT'
75
+ @default_ink_limit = value.to_f / 100
76
+ when /^LIMIT_(.+)$/
77
+ @ink_limits[$1.downcase.to_sym] = value.to_f / 100
78
+ when 'N_OF_GRAY_PARTS'
79
+ # ignore
80
+ when /^GRAY_INK_(\d+)$/
81
+ i = $1.to_i - 1
82
+ inks_by_num[i] = value.downcase.to_sym
83
+ when /^GRAY_VAL_(\d+)$/
84
+ i = $1.to_i - 1
85
+ ink = inks_by_num[i]
86
+ @ink_partitions[ink] = value.to_f / 100
87
+ when 'GRAY_HIGHLIGHT'
88
+ @gray_highlight = value.to_f / 100
89
+ when 'GRAY_SHADOW'
90
+ @gray_shadow = value.to_f / 100
91
+ when 'GRAY_OVERLAP'
92
+ @gray_overlap = value.to_f / 100
93
+ when 'GRAY_GAMMA'
94
+ @gray_gamma = value.to_f
95
+ when 'LINEARIZE'
96
+ @linearization = value.gsub('"', '').split(/\s+/).map { |v| Color::Lab.new([v.to_f]) }
97
+ else
98
+ warn "Unknown key in QTR profile: #{key.inspect}"
99
+ end
100
+ end
101
+ @characterization_curveset = CurveSet.new(channels: @inks, profile: self, type: :characterization)
102
+ @linearization_curveset = CurveSet.new(channels: [:k], profile: self, type: :linearization)
103
+ end
104
+
105
+ def save
106
+ qtr_profile_path.dirname.mkpath
107
+ qtr_profile_path.open('w') do |io|
108
+ io.puts "PRINTER=#{@printer.name}"
109
+ io.puts "PRINTER_OPTIONS=#{@printer_options.map { |k, v| [k, v].join('=') }.join(',')}" if @printer_options
110
+ io.puts "MEDIUM=#{@medium}"
111
+ io.puts "GRAPH_CURVE=YES"
112
+ io.puts "INKS=#{@inks.join(',')}"
113
+ io.puts "N_OF_INKS=#{@inks.length}"
114
+ io.puts "DEFAULT_INK_LIMIT=#{@default_ink_limit * 100}"
115
+ @ink_limits.each do |ink, limit|
116
+ io.puts "LIMIT_#{ink.upcase}=#{limit * 100}"
117
+ end
118
+ io.puts "N_OF_GRAY_PARTS=#{@ink_partitions.length}"
119
+ @ink_partitions.each_with_index do |partition, i|
120
+ ink, value = *partition
121
+ io.puts "GRAY_INK_#{i+1}=#{ink.upcase}"
122
+ io.puts "GRAY_VAL_#{i+1}=#{value * 100}"
123
+ end
124
+ io.puts "GRAY_HIGHLIGHT=#{@gray_highlight * 100}"
125
+ io.puts "GRAY_SHADOW=#{@gray_shadow * 100}"
126
+ io.puts "GRAY_OVERLAP=#{@gray_overlap * 100}"
127
+ io.puts "GRAY_GAMMA=#{@gray_gamma}"
128
+ io.puts "LINEARIZE=\"#{@linearization.map(&:l).join(' ')}\"" if @linearization
129
+ end
130
+ end
131
+
132
+ def dir_path
133
+ ProfilesDir + name
134
+ end
135
+
136
+ def qtr_profile_path
137
+ dir_path + ProfileName
138
+ end
139
+
140
+ def quad_file_path
141
+ Pathname.new('/Library/Printers/QTR/quadtone') + @printer.name + "#{name}.quad"
142
+ end
143
+
144
+ def ink_limit(ink)
145
+ @ink_limits[ink] || @default_ink_limit
146
+ end
147
+
148
+ def install
149
+ # filename needs to match name of profile for quadprofile to install it properly,
150
+ # so temporarily make a symlink
151
+ tmp_file = Pathname.new('/tmp') + "#{name}.txt"
152
+ qtr_profile_path.symlink(tmp_file)
153
+ system('/Library/Printers/QTR/bin/quadprofile', tmp_file)
154
+ tmp_file.unlink
155
+ end
156
+
157
+ def print_file(input_path, options={})
158
+ options = HashStruct.new(options)
159
+ printer_options = @printer_options.dup
160
+ if options.printer_options
161
+ options.printer_options.each do |key, value|
162
+ printer_options[key.to_s] = value
163
+ end
164
+ end
165
+ if options.calibrate
166
+ printer_options['ColorModel'] = 'QTCAL'
167
+ else
168
+ printer_options['ripCurve1'] = name
169
+ end
170
+ @printer.print_file(input_path, printer_options)
171
+ end
172
+
173
+ def show
174
+ puts "Profile: #{name}"
175
+ puts "Printer: #{@printer.name}"
176
+ puts "Printer options:"
177
+ @printer_options.each do |key, value|
178
+ puts "\t" + "#{key}: #{value}"
179
+ end
180
+ puts "Medium: #{@medium}"
181
+ puts "Inks: #{@inks.join(', ')}"
182
+ puts "Default ink limit: #{@default_ink_limit}"
183
+ puts "Ink limits:"
184
+ @ink_limits.each do |ink, limit|
185
+ puts "\t" + "#{ink.upcase}: #{limit}"
186
+ end
187
+ puts "Gray settings:"
188
+ puts "\t" + "Highlight: #{@gray_highlight}"
189
+ puts "\t" + "Shadow: #{@gray_shadow}"
190
+ puts "\t" + "Overlap: #{@gray_overlap}"
191
+ puts "Gray gamma: #{@gray_gamma}"
192
+ puts "Ink partitions:"
193
+ @ink_partitions.each do |ink, value|
194
+ puts "\t" + "#{ink.upcase}: #{value}"
195
+ end
196
+ puts "Linearization: #{@linearization.join(', ')}" if @linearization
197
+ end
198
+
199
+ def to_html
200
+ html = Builder::XmlMarkup.new(indent: 2)
201
+ html.html do
202
+ html.head do
203
+ end
204
+ html.body do
205
+ html.h1("Profile: #{name}")
206
+ end
207
+ end
208
+ html.target!
209
+ end
210
+
211
+ def check
212
+ #FIXME: check values
213
+ end
214
+
215
+ end
216
+
217
+ end