psd 1.5.0 → 2.0.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +1 -1
  3. data/README.md +2 -1
  4. data/lib/psd.rb +2 -1
  5. data/lib/psd/blend_mode.rb +5 -1
  6. data/lib/psd/channel_image.rb +6 -4
  7. data/lib/psd/header.rb +8 -0
  8. data/lib/psd/helpers.rb +13 -1
  9. data/lib/psd/image.rb +1 -1
  10. data/lib/psd/image_exports/png.rb +2 -70
  11. data/lib/psd/image_formats/layer_raw.rb +1 -1
  12. data/lib/psd/image_formats/raw.rb +2 -4
  13. data/lib/psd/image_modes/cmyk.rb +16 -6
  14. data/lib/psd/layer/helpers.rb +1 -1
  15. data/lib/psd/layer_info/fill_opacity.rb +2 -2
  16. data/lib/psd/logger.rb +7 -1
  17. data/lib/psd/node.rb +6 -2
  18. data/lib/psd/node_group.rb +9 -1
  19. data/lib/psd/node_root.rb +16 -3
  20. data/lib/psd/nodes/build_preview.rb +7 -69
  21. data/lib/psd/nodes/search.rb +11 -10
  22. data/lib/psd/renderer.rb +91 -0
  23. data/lib/psd/renderer/blender.rb +53 -0
  24. data/lib/psd/renderer/canvas.rb +95 -0
  25. data/lib/psd/renderer/canvas_management.rb +26 -0
  26. data/lib/psd/renderer/clipping_mask.rb +41 -0
  27. data/lib/psd/{compose.rb → renderer/compose.rb} +23 -19
  28. data/lib/psd/renderer/layer_styles.rb +56 -0
  29. data/lib/psd/renderer/layer_styles/color_overlay.rb +65 -0
  30. data/lib/psd/renderer/layer_styles/drop_shadow.rb +75 -0
  31. data/lib/psd/renderer/mask.rb +68 -0
  32. data/lib/psd/resources/guides.rb +35 -0
  33. data/lib/psd/version.rb +1 -1
  34. data/psd.gemspec +3 -3
  35. data/spec/files/blendmodes.psd +0 -0
  36. data/spec/files/empty-layer-subgroups.psd +0 -0
  37. data/spec/files/guides.psd +0 -0
  38. data/spec/guides_spec.rb +34 -0
  39. data/spec/hierarchy_spec.rb +27 -3
  40. data/spec/image_spec.rb +36 -35
  41. data/spec/parsing_spec.rb +13 -0
  42. metadata +23 -7
  43. data/lib/psd/clipping_mask.rb +0 -49
  44. data/lib/psd/layer_styles.rb +0 -84
@@ -26,7 +26,7 @@ class PSD
26
26
  alias :children_with_path :children_at_path
27
27
 
28
28
  # Given a layer comp ID, name, or :last for last document state, create a new
29
- # tree based on the layers/groups that belong to the comp only.
29
+ # tree with layer/group visibility altered based on the layer comp.
30
30
  def filter_by_comp(id)
31
31
  if id.is_a?(String)
32
32
  comp = psd.layer_comps.select { |c| c[:name] == id }.first
@@ -46,18 +46,19 @@ class PSD
46
46
  private
47
47
 
48
48
  def filter_for_comp!(id, node)
49
- node.children.select! do |c|
49
+ # Force layers to be visible if they are enabled for the comp
50
+ node.children.each do |c|
51
+ enabled = c.visible?
52
+
50
53
  c
51
54
  .metadata
52
- .data[:layer_comp]['layerSettings'].map { |l| !l.has_key?('enab') || l['enab'] == true ? l['compList'] : nil }
53
- .flatten
54
- .compact
55
- .include?(id)
56
- end
55
+ .data[:layer_comp]['layerSettings'].each do |l|
56
+ enabled = l['enab'] if l.has_key?('enab')
57
+ break if l['compList'].include?(id)
58
+ end
57
59
 
58
- node.children.each do |c|
59
- c.force_visible = true
60
- filter_for_comp!(id, c) if c.is_a?(PSD::Node::Group)
60
+ c.force_visible = enabled
61
+ filter_for_comp!(id, c) if c.group?
61
62
  end
62
63
  end
63
64
  end
@@ -0,0 +1,91 @@
1
+ require_relative 'renderer/canvas_management'
2
+
3
+ class PSD
4
+ class Renderer
5
+ include CanvasManagement
6
+
7
+ def initialize(node)
8
+ @root_node = node
9
+
10
+ # Our canvas always starts as the full document size because
11
+ # all measurements are relative to this size. We can later crop
12
+ # the image if needed.
13
+ @width = @root_node.document_dimensions[0].to_i
14
+ @height = @root_node.document_dimensions[1].to_i
15
+
16
+ @canvas_stack = []
17
+ @node_stack = [@root_node]
18
+
19
+ @rendered = false
20
+ end
21
+
22
+ def render!
23
+ PSD.logger.debug "Beginning render process"
24
+
25
+ # Create our base canvas
26
+ create_group_canvas(active_node, active_node.width, active_node.height)
27
+
28
+ # Begin the rendering process
29
+ execute_pipeline
30
+
31
+ @rendered = true
32
+ end
33
+
34
+ def execute_pipeline
35
+ PSD.logger.debug "Executing pipeline on #{active_node.debug_name}"
36
+ children.reverse.each do |child|
37
+ # We skip over hidden nodes. Maybe something configurable in the future?
38
+ next unless child.visible?
39
+
40
+ if child.group?
41
+ push_node(child)
42
+
43
+ if child.passthru_blending?
44
+ PSD.logger.debug "#{child.name} is a group with passthru blending"
45
+ execute_pipeline
46
+ else
47
+ PSD.logger.debug "#{child.name} is a group with #{child.blending_mode} blending"
48
+
49
+ create_group_canvas(child)
50
+ execute_pipeline
51
+
52
+ child_canvas = pop_canvas
53
+ child_canvas.paint_to active_canvas
54
+ end
55
+
56
+ pop_node and next
57
+ end
58
+
59
+ canvas = Canvas.new(child)
60
+ canvas.paint_to active_canvas
61
+ end
62
+ end
63
+
64
+ def to_png
65
+ render! unless @rendered
66
+ active_canvas.canvas
67
+ end
68
+
69
+ private
70
+
71
+ def children
72
+ if active_node.layer?
73
+ [active_node]
74
+ else
75
+ active_node.children
76
+ end
77
+ end
78
+
79
+ def push_node(node)
80
+ @node_stack << node
81
+ end
82
+
83
+ def pop_node
84
+ @node_stack.pop
85
+ end
86
+
87
+ def active_node
88
+ @node_stack.last
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,53 @@
1
+ class PSD
2
+ class Renderer
3
+ class Blender
4
+ attr_reader :fg, :bg
5
+
6
+ # Takes a foreground Canvas and a background Canvas
7
+ def initialize(fg, bg)
8
+ @fg = fg
9
+ @bg = bg
10
+
11
+ @opacity = @fg.opacity.to_i
12
+ @fill_opacity = @fg.fill_opacity.to_i
13
+ PSD.logger.debug "Blender: name = #{fg.node.name}, opacity = #{@opacity}, fill opacity = #{@fill_opacity}"
14
+ end
15
+
16
+ # Composes the foreground Canvas onto the background Canvas using the
17
+ # blending mode specified by the foreground.
18
+ def compose!
19
+ PSD.logger.debug "Composing #{fg.node.debug_name} onto #{bg.node.debug_name} with #{fg.node.blending_mode} blending"
20
+
21
+ offset_x = fg.left - bg.left
22
+ offset_y = fg.top - bg.top
23
+
24
+ fg.height.times do |y|
25
+ fg.width.times do |x|
26
+ base_x = x + offset_x
27
+ base_y = y + offset_y
28
+
29
+ next if base_x < 0 || base_y < 0 || base_x >= bg.width || base_y >= bg.height
30
+
31
+ color = Compose.send(
32
+ fg.node.blending_mode,
33
+ fg.canvas[x, y],
34
+ bg.canvas[base_x, base_y],
35
+ compose_options
36
+ )
37
+
38
+ bg.canvas[base_x, base_y] = color
39
+ end
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def compose_options
46
+ {
47
+ opacity: @opacity,
48
+ fill_opacity: @fill_opacity
49
+ }
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,95 @@
1
+ class PSD
2
+ class Renderer
3
+ class Canvas
4
+ attr_reader :canvas, :node, :width, :height, :left, :right, :top, :bottom, :opacity, :fill_opacity
5
+
6
+ def initialize(node, width = nil, height = nil, color = ChunkyPNG::Color::TRANSPARENT)
7
+ @node = node
8
+ @pixel_data = @node.group? ? [] : @node.image.pixel_data
9
+
10
+ @width = (width || @node.width).to_i
11
+ @height = (height || @node.height).to_i
12
+ @left = @node.left.to_i
13
+ @right = @node.right.to_i
14
+ @top = @node.top.to_i
15
+ @bottom = @node.bottom.to_i
16
+
17
+ @opacity = @node.opacity.to_f
18
+ @fill_opacity = @node.fill_opacity.to_f
19
+
20
+ @canvas = ChunkyPNG::Canvas.new(@width, @height, color)
21
+
22
+ initialize_canvas unless @node.group?
23
+ end
24
+
25
+ def paint_to(base)
26
+ PSD.logger.debug "Painting #{node.name} to #{base.node.debug_name}"
27
+
28
+ apply_mask
29
+ apply_clipping_mask
30
+ apply_layer_styles
31
+ apply_layer_opacity
32
+ compose_pixels(base)
33
+ end
34
+
35
+ def canvas=(canvas)
36
+ @canvas = canvas
37
+ @width = @canvas.width
38
+ @height = @canvas.height
39
+ end
40
+
41
+ def [](x, y); @canvas[x, y]; end
42
+ def []=(x, y, value); @canvas[x, y] = value; end
43
+
44
+ def method_missing(method, *args, &block)
45
+ @canvas.send(method, *args, &block)
46
+ end
47
+
48
+ private
49
+
50
+ def initialize_canvas
51
+ PSD.logger.debug "Initializing canvas for #{node.debug_name}"
52
+
53
+ # Sorry, ChunkyPNG.
54
+ @canvas.send(:replace_canvas!, width, height, @pixel_data)
55
+
56
+ # This can now be referenced by @canvas.pixels
57
+ @pixel_data = nil
58
+ end
59
+
60
+ def apply_mask
61
+ return unless @node.image.has_mask?
62
+
63
+ PSD.logger.debug "Applying layer mask to #{node.name}"
64
+ Mask.new(self).apply!
65
+ end
66
+
67
+ def apply_clipping_mask
68
+ return unless @node.clipped?
69
+ ClippingMask.new(self).apply!
70
+ end
71
+
72
+ def apply_layer_styles
73
+ PSD.logger.debug "Applying layer styles to #{node.name}"
74
+ LayerStyles.new(self).apply!
75
+ end
76
+
77
+ def apply_layer_opacity
78
+ return if @node.root?
79
+ PSD.logger.debug "Adjusting opacity for #{node.name}"
80
+
81
+ @node.ancestors.each do |parent|
82
+ break unless parent.passthru_blending?
83
+ @opacity = (@opacity * parent.opacity.to_f) / 255.0
84
+ end
85
+
86
+ PSD.logger.debug "Inherited opacity for #{@node.debug_name} is #{@opacity}"
87
+ @opacity = @opacity.to_i
88
+ end
89
+
90
+ def compose_pixels(base)
91
+ Blender.new(self, base).compose!
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,26 @@
1
+ class PSD
2
+ class Renderer
3
+ module CanvasManagement
4
+ def active_canvas
5
+ @canvas_stack.last
6
+ end
7
+
8
+ def create_group_canvas(node, width=@width, height=@height)
9
+ PSD.logger.debug "Group canvas created. Node = #{node.name || ":root:"}, width = #{width}, height = #{height}"
10
+ push_canvas Canvas.new(node, width, height)
11
+ end
12
+
13
+ def push_canvas(canvas)
14
+ @canvas_stack << canvas
15
+ end
16
+
17
+ def pop_canvas
18
+ @canvas_stack.pop
19
+ end
20
+
21
+ def stack_inspect
22
+ @canvas_stack.map { |c| c.node.name || ":root:" }.join("\n")
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,41 @@
1
+ class PSD
2
+ class Renderer
3
+ class ClippingMask
4
+ attr_reader :canvas, :mask
5
+
6
+ def initialize(canvas)
7
+ @canvas = canvas
8
+ @node = @canvas.node
9
+
10
+ mask_node = @canvas.node.next_sibling
11
+ @mask = Canvas.new(mask_node)
12
+ end
13
+
14
+ def apply!
15
+ return unless @node.clipped?
16
+
17
+ PSD.logger.debug "Applying clipping mask #{mask.node.name} to #{@node.name}"
18
+
19
+ @canvas.height.times do |y|
20
+ @canvas.width.times do |x|
21
+ doc_x = @canvas.left + x
22
+ doc_y = @canvas.top + y
23
+
24
+ mask_x = doc_x - @mask.left
25
+ mask_y = doc_y - @mask.top
26
+
27
+ if mask_x < 0 || mask_x > mask.width || mask_y < 0 || mask_y > mask.height
28
+ alpha = 0
29
+ else
30
+ pixel = mask.canvas.pixels[mask_y * mask.width + mask_x]
31
+ alpha = pixel.nil? ? 0 : ChunkyPNG::Color.a(pixel)
32
+ end
33
+
34
+ color = @canvas[x, y]
35
+ @canvas[x, y] = (color & 0xffffff00) | (ChunkyPNG::Color.a(color) * alpha / 255)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -15,9 +15,8 @@ class PSD
15
15
  # Normal blend modes
16
16
  #
17
17
 
18
- # Normal composition, delegate to ChunkyPNG
19
18
  def normal(fg, bg, opts={})
20
- return fg if fully_transparent?(bg)
19
+ return apply_opacity(fg, opts) if fully_transparent?(bg)
21
20
  return bg if fully_transparent?(fg)
22
21
 
23
22
  mix_alpha, dst_alpha = calculate_alphas(fg, bg, DEFAULT_OPTS.merge(opts))
@@ -27,13 +26,14 @@ class PSD
27
26
 
28
27
  rgba(new_r, new_g, new_b, dst_alpha)
29
28
  end
29
+ alias_method :passthru, :normal
30
30
 
31
31
  #
32
32
  # Subtractive blend modes
33
33
  #
34
34
 
35
35
  def darken(fg, bg, opts={})
36
- return fg if fully_transparent?(bg)
36
+ return apply_opacity(fg, opts) if fully_transparent?(bg)
37
37
  return bg if fully_transparent?(fg)
38
38
 
39
39
  mix_alpha, dst_alpha = calculate_alphas(fg, bg, DEFAULT_OPTS.merge(opts))
@@ -45,7 +45,7 @@ class PSD
45
45
  end
46
46
 
47
47
  def multiply(fg, bg, opts={})
48
- return fg if fully_transparent?(bg)
48
+ return apply_opacity(fg, opts) if fully_transparent?(bg)
49
49
  return bg if fully_transparent?(fg)
50
50
 
51
51
  mix_alpha, dst_alpha = calculate_alphas(fg, bg, DEFAULT_OPTS.merge(opts))
@@ -57,7 +57,7 @@ class PSD
57
57
  end
58
58
 
59
59
  def color_burn(fg, bg, opts={})
60
- return fg if fully_transparent?(bg)
60
+ return apply_opacity(fg, opts) if fully_transparent?(bg)
61
61
  return bg if fully_transparent?(fg)
62
62
 
63
63
  mix_alpha, dst_alpha = calculate_alphas(fg, bg, DEFAULT_OPTS.merge(opts))
@@ -79,7 +79,7 @@ class PSD
79
79
  end
80
80
 
81
81
  def linear_burn(fg, bg, opts={})
82
- return fg if fully_transparent?(bg)
82
+ return apply_opacity(fg, opts) if fully_transparent?(bg)
83
83
  return bg if fully_transparent?(fg)
84
84
 
85
85
  mix_alpha, dst_alpha = calculate_alphas(fg, bg, DEFAULT_OPTS.merge(opts))
@@ -96,7 +96,7 @@ class PSD
96
96
  #
97
97
 
98
98
  def lighten(fg, bg, opts={})
99
- return fg if fully_transparent?(bg)
99
+ return apply_opacity(fg, opts) if fully_transparent?(bg)
100
100
  return bg if fully_transparent?(fg)
101
101
 
102
102
  mix_alpha, dst_alpha = calculate_alphas(fg, bg, DEFAULT_OPTS.merge(opts))
@@ -109,7 +109,7 @@ class PSD
109
109
  end
110
110
 
111
111
  def screen(fg, bg, opts={})
112
- return fg if fully_transparent?(bg)
112
+ return apply_opacity(fg, opts) if fully_transparent?(bg)
113
113
  return bg if fully_transparent?(fg)
114
114
 
115
115
  mix_alpha, dst_alpha = calculate_alphas(fg, bg, DEFAULT_OPTS.merge(opts))
@@ -122,7 +122,7 @@ class PSD
122
122
  end
123
123
 
124
124
  def color_dodge(fg, bg, opts={})
125
- return fg if fully_transparent?(bg)
125
+ return apply_opacity(fg, opts) if fully_transparent?(bg)
126
126
  return bg if fully_transparent?(fg)
127
127
 
128
128
  mix_alpha, dst_alpha = calculate_alphas(fg, bg, DEFAULT_OPTS.merge(opts))
@@ -139,7 +139,7 @@ class PSD
139
139
  end
140
140
 
141
141
  def linear_dodge(fg, bg, opts={})
142
- return fg if fully_transparent?(bg)
142
+ return apply_opacity(fg, opts) if fully_transparent?(bg)
143
143
  return bg if fully_transparent?(fg)
144
144
 
145
145
  mix_alpha, dst_alpha = calculate_alphas(fg, bg, DEFAULT_OPTS.merge(opts))
@@ -157,7 +157,7 @@ class PSD
157
157
  #
158
158
 
159
159
  def overlay(fg, bg, opts={})
160
- return fg if fully_transparent?(bg)
160
+ return apply_opacity(fg, opts) if fully_transparent?(bg)
161
161
  return bg if fully_transparent?(fg)
162
162
 
163
163
  mix_alpha, dst_alpha = calculate_alphas(fg, bg, DEFAULT_OPTS.merge(opts))
@@ -178,7 +178,7 @@ class PSD
178
178
  end
179
179
 
180
180
  def soft_light(fg, bg, opts={})
181
- return fg if fully_transparent?(bg)
181
+ return apply_opacity(fg, opts) if fully_transparent?(bg)
182
182
  return bg if fully_transparent?(fg)
183
183
 
184
184
  mix_alpha, dst_alpha = calculate_alphas(fg, bg, DEFAULT_OPTS.merge(opts))
@@ -197,7 +197,7 @@ class PSD
197
197
  end
198
198
 
199
199
  def hard_light(fg, bg, opts={})
200
- return fg if fully_transparent?(bg)
200
+ return apply_opacity(fg, opts) if fully_transparent?(bg)
201
201
  return bg if fully_transparent?(fg)
202
202
 
203
203
  mix_alpha, dst_alpha = calculate_alphas(fg, bg, DEFAULT_OPTS.merge(opts))
@@ -218,7 +218,7 @@ class PSD
218
218
  end
219
219
 
220
220
  def vivid_light(fg, bg, opts={})
221
- return fg if fully_transparent?(bg)
221
+ return apply_opacity(fg, opts) if fully_transparent?(bg)
222
222
  return bg if fully_transparent?(fg)
223
223
 
224
224
  mix_alpha, dst_alpha = calculate_alphas(fg, bg, DEFAULT_OPTS.merge(opts))
@@ -239,7 +239,7 @@ class PSD
239
239
  end
240
240
 
241
241
  def linear_light(fg, bg, opts={})
242
- return fg if fully_transparent?(bg)
242
+ return apply_opacity(fg, opts) if fully_transparent?(bg)
243
243
  return bg if fully_transparent?(fg)
244
244
 
245
245
  mix_alpha, dst_alpha = calculate_alphas(fg, bg, DEFAULT_OPTS.merge(opts))
@@ -260,7 +260,7 @@ class PSD
260
260
  end
261
261
 
262
262
  def pin_light(fg, bg, opts={})
263
- return fg if fully_transparent?(bg)
263
+ return apply_opacity(fg, opts) if fully_transparent?(bg)
264
264
  return bg if fully_transparent?(fg)
265
265
 
266
266
  mix_alpha, dst_alpha = calculate_alphas(fg, bg, DEFAULT_OPTS.merge(opts))
@@ -281,7 +281,7 @@ class PSD
281
281
  end
282
282
 
283
283
  def hard_mix(fg, bg, opts={})
284
- return fg if fully_transparent?(bg)
284
+ return apply_opacity(fg, opts) if fully_transparent?(bg)
285
285
  return bg if fully_transparent?(fg)
286
286
 
287
287
  mix_alpha, dst_alpha = calculate_alphas(fg, bg, DEFAULT_OPTS.merge(opts))
@@ -298,7 +298,7 @@ class PSD
298
298
  #
299
299
 
300
300
  def difference(fg, bg, opts={})
301
- return fg if fully_transparent?(bg)
301
+ return apply_opacity(fg, opts) if fully_transparent?(bg)
302
302
  return bg if fully_transparent?(fg)
303
303
 
304
304
  mix_alpha, dst_alpha = calculate_alphas(fg, bg, DEFAULT_OPTS.merge(opts))
@@ -311,7 +311,7 @@ class PSD
311
311
  end
312
312
 
313
313
  def exclusion(fg, bg, opts={})
314
- return fg if fully_transparent?(bg)
314
+ return apply_opacity(fg, opts) if fully_transparent?(bg)
315
315
  return bg if fully_transparent?(fg)
316
316
 
317
317
  mix_alpha, dst_alpha = calculate_alphas(fg, bg, DEFAULT_OPTS.merge(opts))
@@ -346,6 +346,10 @@ class PSD
346
346
  opts[:opacity] * opts[:fill_opacity] / 255
347
347
  end
348
348
 
349
+ def apply_opacity(color, opts)
350
+ (color & 0xffffff00) | ((color & 0x000000ff) * calculate_opacity(opts) / 255)
351
+ end
352
+
349
353
  def blend_channel(bg, fg, alpha)
350
354
  ((bg << 8) + (fg - bg) * alpha) >> 8
351
355
  end