Jump To …

psdlayer.coffee

class PSDLayer
  CHANNEL_SUFFIXES =
    '-2': 'layer mask'
    '-1': 'A'
    0: 'R'
    1: 'G'
    2: 'B'
    3: 'RGB'
    4: 'CMYK'
    5: 'HSL'
    6: 'HSB'
    9: 'Lab'
    11: 'RGB'
    12: 'Lab'
    13: 'CMYK'

  SECTION_DIVIDER_TYPES =
    0: "other"
    1: "open folder"
    2: "closed folder"
    3: "bounding section divider" # hidden in the UI

  BLEND_MODES =
    "norm": "normal"
    "dark": "darken"
    "lite": "lighten"
    "hue":  "hue"
    "sat":  "saturation"
    "colr": "color"
    "lum":  "luminosity"
    "mul":  "multiply"
    "scrn": "screen"
    "diss": "dissolve"
    "over": "overlay"
    "hLit": "hard light"
    "sLit": "soft light"
    "diff": "difference"
    "smud": "exclusion"
    "div":  "color dodge"
    "idiv": "color burn"
    "lbrn": "linear burn"
    "lddg": "linear dodge"
    "vLit": "vivid light"
    "lLit": "linear light"
    "pLit": "pin light"
    "hMix": "hard mix"

  BLEND_FLAGS =
    0: "transparency protected"
    1: "visible"
    2: "obsolete"
    3: "bit 4 useful"
    4: "pixel data irrelevant"

  MASK_FLAGS =
    0: "position relative"
    1: "layer mask disabled"
    2: "invert layer mask"

  SAFE_FONTS = [
    "Arial"
    "Courier New"
    "Georgia"
    "Times New Roman"
    "Verdana"
    "Trebuchet MS"
    "Lucida Sans"
    "Tahoma"
  ]

  constructor: (@file, @header = null) ->
    @image = null
    @mask = {}
    @blendingRanges = {}
    @adjustments = {}

Defaults

    @layerType = "normal"
    @blendingMode = "normal"
    @opacity = 255
    @fillOpacity = 255

    @isFolder = false
    @isHidden = false

  parse: (layerIndex = null) ->
    @parseInfo(layerIndex)
    @parseBlendModes()

Length of the rest of the layer data

    extralen = @file.readInt()
    @layerEnd = @file.tell() + extralen

    assert extralen > 0

Marking our start point in case we need to bail and recover

    extrastart = @file.tell()

    result = @parseMaskData()
    if not result

Make this more graceful in the future?

      Log.debug "Error parsing mask data for layer ##{layerIndex}. Skipping."
      return @file.seek @layerEnd, false

    @parseBlendingRanges()
    @parseLegacyLayerName()
    @parseExtraData()

    @name = @legacyName unless @name?

    Log.debug "Layer #{layerIndex}:", @

In case there are filler zeros

    @file.seek extrastart + extralen, false

Parse important information about this layer such as position, size, and channel info. Layer Records section.

  parseInfo: (layerIndex) ->
    @idx = layerIndex

#

    Layer Info

#

    [@top, @left, @bottom, @right, @channels] = @file.readf ">iiiih"
    [@rows, @cols] = [@bottom - @top, @right - @left]

    assert @channels > 0

Alias

    @height = @rows
    @width = @cols

Sanity check

    if @bottom < @top or @right < @left or @channels > 64
      Log.debug "Somethings not right, attempting to skip layer."
      @file.seek 6 * @channels + 12
      @file.skipBlock "layer info: extra data"
      return # next layer

Read channel info

    @channelsInfo = []
    for i in [0...@channels]
      [channelID, channelLength] = @file.readf ">hi"
      Log.debug "Channel #{i}: id=#{channelID}, #{channelLength} bytes, type=#{CHANNEL_SUFFIXES[channelID]}"

      @channelsInfo.push id: channelID, length: channelLength
    

Parse the blend mode used for this layer including type and opacity

  parseBlendModes: ->
    @blendMode = {}

    [
      @blendMode.sig, # 8BIM
      @blendMode.key, # blending mode key
      @blendMode.opacity, # 0 - 255
      @blendMode.clipping, # 0 = base, 1 = non-base
      flags, 
      filler # unused data
    ] = @file.readf ">4s4sBBBB"

    assert @blendMode.sig is "8BIM"

    @blendMode.key = @blendMode.key.trim()
    @blendMode.opacityPercentage = (@blendMode.opacity * 100) / 255
    @blendMode.blender = BLEND_MODES[@blendMode.key]

    @blendMode.transparencyProtected = flags & 0x01
    @blendMode.visible = (flags & (0x01 << 1)) > 0
    @blendMode.visible = 1 - @blendMode.visible
    @blendMode.obsolete = (flags & (0x01 << 2)) > 0
    

PS >= 5.0; tells if bit 4 has useful info

    if (flags & (0x01 << 3)) > 0
      @blendMode.pixelDataIrrelevant = (flags & (0x01 << 4)) > 0

    @blendingMode = @blendMode.blender
    @opacity = @blendMode.opacity
    @visible = @blendMode.visible

    Log.debug "Blending mode:", @blendMode

  parseMaskData: ->
    @mask.size = @file.readInt()

Something wrong, bail.

    assert @mask.size in [36, 20, 0]

Valid, but this section doesn't exist.

    return true if @mask.size is 0

Parse mask position

    [
      @mask.top, 
      @mask.left, 
      @mask.bottom, 
      @mask.right,

Either 0 or 255

      @mask.defaultColor, 
      flags
    ] = @file.readf ">llllBB"

    assert @mask.defaultColor in [0, 255]

    @mask.width = @mask.right - @mask.left
    @mask.height = @mask.bottom - @mask.top

    @mask.relative = flags & 0x01
    @mask.disabled = (flags & (0x01 << 1)) > 0
    @mask.invert = (flags & (0x01 << 2)) > 0

If the size is 20, then there are 2 bytes of padding

    if @mask.size is 20
      @file.seek(2)
    else

This is weird.

      [
        flags,
        @mask.defaultColor
      ] = @file.readf ">BB"

Real flags. Same as above. Seriously, who designed this crap?

      @mask.relative = (flags & 0x01)
      @mask.disabled = (flags & (0x01 << 1)) > 0
      @mask.invert = (flags & (0x01 << 2)) > 0

For some reason the mask position info is duplicated here? Skip. Ugh.

      @file.seek 16

    true

  parseBlendingRanges: ->
    length = @file.readInt()

First, the grey blend. This is irrelevant for Lab & Greyscale.

    @blendingRanges.grey =
      source:
        black: @file.readShortInt()
        white: @file.readShortInt()
      dest:
        black: @file.readShortInt()
        white: @file.readShortInt()

    pos = @file.tell()

    @blendingRanges.numChannels = (length - 8) / 8
    assert @blendingRanges.numChannels > 0

    @blendingRanges.channels = []
    for i in [0...@blendingRanges.numChannels]
      @blendingRanges.channels.push
        source: 
          black: @file.readShortInt()
          white: @file.readShortInt()
        dest: 
          black: @file.readShortInt()
          white: @file.readShortInt()

Parse the name of this layer. This is considered the "legacy" name because it is encoded with MacRoman encoding. PS >= 5.0 includes a unicode version of the name, which is in the additional layer information section.

  parseLegacyLayerName: ->

Name length is padded in multiples of 4

    namelen = Util.pad4 @file.read(1)[0]
    @legacyName = Util.decodeMacroman(@file.read(namelen)).replace /\u0000/g, ''

  parseExtraData: ->
    while @file.tell() < @layerEnd
      [
        signature,
        key
      ] = @file.readf ">4s4s"

      assert.equal signature, "8BIM"

      length = Util.pad2 @file.readInt()
      pos = @file.tell()

TODO: many more adjustment layers to implement

      Log.debug("Extra layer info: key = #{key}, length = #{length}")
      switch key
        when "SoCo"
          @adjustments.solidColor = (new PSDSolidColor(@, length)).parse()
        when "GdFl"
          @adjustments.gradient = (new PSDGradient(@, length)).parse()
        when "PtFl"
          @adjustments.pattern = (new PSDPattern(@, length)).parse()
        when "brit"
          @adjustments.brightnessContrast = (new PSDBrightnessContrast(@, length)).parse()
        when "levl"
          @adjustments.levels = (new PSDLevels(@, length)).parse()
        when "curv"
          @adjustments.curves = (new PSDCurves(@, length)).parse()
        when "expA"
          @adjustments.exposure = (new PSDExposure(@, length)).parse()
        when "vibA"
          @adjustments.vibrance = (new PSDVibrance(@, length)).parse()
        when "hue2" # PS >= 5.0
          @adjustments.hueSaturation = (new PSDHueSaturation(@, length)).parse()
        when "blnc"
          @adjustments.colorBalance = (new PSDColorBalance(@, length)).parse()
        when "blwh"
          @adjustments.blackWhite = (new PSDBlackWhite(@, length)).parse()
        when "phfl"
          @adjustments.photoFilter = (new PSDPhotoFilter(@, length)).parse()
        when "thrs"
          @adjustments.threshold = (new PSDThreshold(@, length)).parse()
        when "nvrt"
          @adjustments.invert = (new PSDInvert(@, length)).parse()
        when "post"
          @adjustments.posterize = (new PSDPosterize(@, length)).parse()
        when "tySh" # PS <= 5
          @adjustments.typeTool = (new PSDTypeTool(@, length)).parse(true)
        when "TySh" # PS >= 6
          @adjustments.typeTool = (new PSDTypeTool(@, length)).parse()
        when "luni" # PS >= 5.0
          @name = @file.readUnicodeString()

This seems to be padded with null bytes (by 4?), but the easiest thing to do is to simply jump to the end of this section.

          @file.seek pos + length, false
        when "lyid"
          @layerId = @file.readInt()
        when "lsct"
          @readLayerSectionDivider()
        when "lrFX"
          @parseEffectsLayer(); @file.read(2) # why these 2 bytes?
        when "selc"
          @adjustments.selectiveColor = (new PSDSelectiveColor(@, length)).parse()
        else  
          @file.seek length
          Log.debug("Skipping additional layer info with key #{key}")

      if @file.tell() != (pos + length)
        Log.debug "Warning: additional layer info with key #{key} - unexpected end"
        @file.seek pos + length, false # Attempt to recover

  parseEffectsLayer: ->
    effects = []

    [
        v, # always 0
        count
    ] = @file.readf ">HH"

    while count-- > 0
      [
        signature,
        type
      ] = @file.readf ">4s4s"

      [size] = @file.readf ">i"

      pos = @file.tell()

      Log.debug("Parsing effect layer with type #{type} and size #{size}")

      effect =    
        switch type
          when "cmnS" then new PSDLayerEffectCommonStateInfo @file
          when "dsdw" then new PSDDropDownLayerEffect @file     
          when "isdw" then new PSDDropDownLayerEffect @file, true # inner drop shadow

      data = effect?.parse()

      left = (pos + size) - @file.tell()
      if left != 0
       Log.debug("Failed to parse effect layer with type #{type}")
       @file.seek left 
      else
        effects.push(data) unless type == "cmnS" # ignore commons state info

    @adjustments.effects = effects
        
  readLayerSectionDivider: ->
    code = @file.readInt()
    @layerType = SECTION_DIVIDER_TYPES[code]

    Log.debug "Layer type:", @layerType

    switch code
      when 1, 2 then @isFolder = true
      when 3 then @isHidden = true

  toJSON: ->
    sections = [
      'name'
      'legacyName'
      'top'
      'left'
      'bottom'
      'right'
      'channels'
      'rows'
      'cols'
      'channelsInfo'
      'mask'
      'layerType'
      'blendMode'
      'adjustments'
      'visible'
    ]

    data = {}
    for section in sections
      data[section] = @[section]

    data