lib/prawn/images/png.rb
# encoding: ASCII-8BIT
# frozen_string_literal: true
require 'stringio'
module Prawn
module Images # rubocop: disable Style/Documentation
# A convenience class that wraps the logic for extracting the parts of a PNG
# image that we need to embed them in a PDF.
class PNG < Image
# @group Extension API
# Palette data.
# @return [String]
attr_reader :palette
# Image data.
# @return [String]
attr_reader :img_data
# Transparency data.
# @return [Hash{Symbol => String}]
attr_reader :transparency
# Image width in pixels.
# @return [Integer]
attr_reader :width
# Image height in pixels.
# @return [Integer]
attr_reader :height
# Bits per sample or per palette index.
# @return [Integer]
attr_reader :bits
# Color type.
# @return [Integer]
attr_reader :color_type
# Compression method.
# @return [Integer]
attr_reader :compression_method
# Filter method.
# @return [Integer]
attr_reader :filter_method
# Interlace method.
# @return [Integer]
attr_reader :interlace_method
# Extracted alpha-channel.
# @return [String, nil]
attr_reader :alpha_channel
# Scaled width of the image in PDF points.
# @return [Number]
attr_accessor :scaled_width
# Scaled height of the image in PDF points.
# @return [Number]
attr_accessor :scaled_height
# Can this image handler process this image?
#
# @param image_blob [String]
# @return [Boolean]
def self.can_render?(image_blob)
image_blob[0, 8].unpack('C*') == [137, 80, 78, 71, 13, 10, 26, 10]
end
# Process a new PNG image
#
# @param data [String] A binary string of PNG data.
def initialize(data)
super()
data = StringIO.new(data.dup)
data.read(8) # Skip the default header
@palette = +''
@img_data = +''
@transparency = {}
loop do
chunk_size = data.read(4).unpack1('N')
section = data.read(4)
case section
when 'IHDR'
# we can grab other interesting values from here (like width,
# height, etc)
values = data.read(chunk_size).unpack('NNCCCCC')
@width = values[0]
@height = values[1]
@bits = values[2]
@color_type = values[3]
@compression_method = values[4]
@filter_method = values[5]
@interlace_method = values[6]
when 'PLTE'
@palette << data.read(chunk_size)
when 'IDAT'
@img_data << data.read(chunk_size)
when 'tRNS'
# This chunk can only occur once and it must occur after the
# PLTE chunk and before the IDAT chunk
@transparency = {}
case @color_type
when 3
@transparency[:palette] = data.read(chunk_size).unpack('C*')
when 0
# Greyscale. Corresponding to entries in the PLTE chunk.
# Grey is two bytes, range 0 .. (2 ^ bit-depth) - 1
grayval = data.read(chunk_size).unpack1('n')
@transparency[:grayscale] = grayval
when 2
# True colour with proper alpha channel.
@transparency[:rgb] = data.read(chunk_size).unpack('nnn')
end
when 'IEND'
# we've got everything we need, exit the loop
break
else
# unknown (or un-important) section, skip over it
data.seek(data.pos + chunk_size)
end
data.read(4) # Skip the CRC
end
@img_data = Zlib::Inflate.inflate(@img_data)
end
# Number of color components to each pixel.
#
# @return [Integer]
def colors
case color_type
when 0, 3, 4
1
when 2, 6
3
end
end
# Split the alpha channel data from the raw image data in images where
# it's required.
#
# @private
# @return [void]
def split_alpha_channel!
if alpha_channel?
if color_type == 3
generate_alpha_channel
else
split_image_data
end
end
end
# Is there an alpha-channel in this image?
#
# @return [Boolean]
def alpha_channel?
return true if color_type == 4 || color_type == 6
return @transparency.any? if color_type == 3
false
end
# Build a PDF object representing this image in `document`, and return
# a Reference to it.
#
# @param document [Prawn::Document]
# @return [PDF::Core::Reference]
def build_pdf_object(document)
if compression_method != 0
raise Errors::UnsupportedImageType,
'PNG uses an unsupported compression method'
end
if filter_method != 0
raise Errors::UnsupportedImageType,
'PNG uses an unsupported filter method'
end
if interlace_method != 0
raise Errors::UnsupportedImageType,
'PNG uses unsupported interlace method'
end
# some PNG types store the colour and alpha channel data together,
# which the PDF spec doesn't like, so split it out.
split_alpha_channel!
case colors
when 1
color = :DeviceGray
when 3
color = :DeviceRGB
else
raise Errors::UnsupportedImageType,
"PNG uses an unsupported number of colors (#{png.colors})"
end
# build the image dict
obj = document.ref!(
Type: :XObject,
Subtype: :Image,
Height: height,
Width: width,
BitsPerComponent: bits,
)
# append the actual image data to the object as a stream
obj << img_data
obj.stream.filters << {
FlateDecode: {
Predictor: 15,
Colors: colors,
BitsPerComponent: bits,
Columns: width,
},
}
# sort out the colours of the image
if palette.empty?
obj.data[:ColorSpace] = color
else
# embed the colour palette in the PDF as a object stream
palette_obj = document.ref!({})
palette_obj << palette
# build the color space array for the image
obj.data[:ColorSpace] = [
:Indexed,
:DeviceRGB,
(palette.size / 3) - 1,
palette_obj,
]
end
# *************************************
# add transparency data if necessary
# *************************************
# For PNG color types 0, 2 and 3, the transparency data is stored in
# a dedicated PNG chunk, and is exposed via the transparency attribute
# of the PNG class.
if transparency[:grayscale]
# Use Color Key Masking (spec section 4.8.5)
# - An array with N elements, where N is two times the number of color
# components.
val = transparency[:grayscale]
obj.data[:Mask] = [val, val]
elsif transparency[:rgb]
# Use Color Key Masking (spec section 4.8.5)
# - An array with N elements, where N is two times the number of color
# components.
rgb = transparency[:rgb]
obj.data[:Mask] = rgb.map { |x| [x, x] }.flatten
end
# For PNG color types 4 and 6, the transparency data is stored as
# a alpha channel mixed in with the main image data. The PNG class
# separates it out for us and makes it available via the alpha_channel
# attribute
if alpha_channel?
smask_obj = document.ref!(
Type: :XObject,
Subtype: :Image,
Height: height,
Width: width,
BitsPerComponent: bits,
ColorSpace: :DeviceGray,
Decode: [0, 1],
)
smask_obj.stream << alpha_channel
smask_obj.stream.filters << {
FlateDecode: {
Predictor: 15,
Colors: 1,
BitsPerComponent: bits,
Columns: width,
},
}
obj.data[:SMask] = smask_obj
end
obj
end
# Returns the minimum PDF version required to support this image.
#
# @return [Float]
def min_pdf_version
if bits > 8
# 16-bit color only supported in 1.5+ (ISO 32000-1:2008 8.9.5.1)
1.5
elsif alpha_channel?
# Need transparency for SMask
1.4
else
1.0
end
end
private
def split_image_data
alpha_bytes = bits / 8
color_bytes = colors * bits / 8
scanline_length = ((color_bytes + alpha_bytes) * width) + 1
scanlines = @img_data.bytesize / scanline_length
pixels = width * height
data = StringIO.new(@img_data)
data.binmode
color_data = [0x00].pack('C') * ((pixels * color_bytes) + scanlines)
color = StringIO.new(color_data)
color.binmode
@alpha_channel = [0x00].pack('C') * ((pixels * alpha_bytes) + scanlines)
alpha = StringIO.new(@alpha_channel)
alpha.binmode
scanlines.times do |line|
data.seek(line * scanline_length)
filter = data.getbyte
color.putc(filter)
alpha.putc(filter)
width.times do
color.write(data.read(color_bytes))
alpha.write(data.read(alpha_bytes))
end
end
@img_data = color_data
end
def generate_alpha_channel
alpha_palette = Hash.new(0xff)
0.upto(palette.bytesize / 3) do |n|
alpha_palette[n] = @transparency[:palette][n] || 0xff
end
scanline_length = width + 1
scanlines = @img_data.bytesize / scanline_length
pixels = width * height
data = StringIO.new(@img_data)
data.binmode
@alpha_channel = [0x00].pack('C') * (pixels + scanlines)
alpha = StringIO.new(@alpha_channel)
alpha.binmode
scanlines.times do |line|
data.seek(line * scanline_length)
filter = data.getbyte
alpha.putc(filter)
width.times do
color = data.read(1).unpack1('C')
alpha.putc(alpha_palette[color])
end
end
end
end
Prawn.image_handler.register(Prawn::Images::PNG)
end
end