Module: Idml::Render::Image

Defined in:
lib/idml/render/image.rb

Overview

Embeds linked images (JPEG) as PDF XObjects. Parses the JPEG binary header for width/height, creates an image stream with DCTDecode filter (raw JPEG bytes, no decompression needed).

Defined Under Namespace

Classes: Transform

Constant Summary collapse

PNG_MAGIC =
"\x89PNG\r\n\x1a\n".b
JPEG_MAGIC_BYTES =
[0xFF, 0xD8].freeze

Class Method Summary collapse

Class Method Details

.combine(outer, inner) ⇒ Object

Combine two transforms: result = outer ∘ inner (outer is applied after inner).



193
194
195
196
197
198
199
200
201
202
# File 'lib/idml/render/image.rb', line 193

def combine(outer, inner)
  Transform.new(
    (outer.a * inner.a) + (outer.c * inner.b),
    (outer.b * inner.a) + (outer.d * inner.b),
    (outer.a * inner.c) + (outer.c * inner.d),
    (outer.b * inner.c) + (outer.d * inner.d),
    (outer.a * inner.e) + (outer.c * inner.f) + outer.e,
    (outer.b * inner.e) + (outer.d * inner.f) + outer.f,
  )
end

.compute_placement(image_transform:, parent_transform:, pixel_height:, page_height:, page_transform: nil) ⇒ Object

Compute the PDF placement for an image given its IDML transforms. IDML uses Y-down; PDF uses Y-up. This flips Y and combines parent + image transforms.

Returns { x:, y:, scale_x:, scale_y: } where (x, y) is the bottom-left of the image in PDF page coordinates.



257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
# File 'lib/idml/render/image.rb', line 257

def compute_placement(image_transform:, parent_transform:,
                      pixel_height:, page_height:,
                      page_transform: nil)
  page_t = page_transform || identity
  combined = combine(page_t, combine(parent_transform, image_transform))

  scale_x = combined.a
  scale_y = combined.d
  idml_x = combined.e
  idml_y = combined.f
  scaled_height = pixel_height * scale_y.abs

  pdf_x = idml_x
  pdf_y = page_height - idml_y - scaled_height

  { x: pdf_x, y: pdf_y, scale_x: scale_x.abs, scale_y: scale_y.abs }
end

.detect_format(data) ⇒ Object

Detect image format from magic bytes. Returns :jpeg, :png, or nil.



18
19
20
21
22
23
# File 'lib/idml/render/image.rb', line 18

def detect_format(data)
  return :jpeg if data.getbyte(0) == 0xFF && data.getbyte(1) == 0xD8
  return :png if data.bytesize >= 8 && data[0, 8] == PNG_MAGIC

  nil
end

.draw_imageObject

DEPRECATED: draw_image is no longer used. pdfrb 0.15.0+ has Canvas#draw_image_matrix for drawing image XObjects with a full affine transform. SpreadRenderer uses it directly.



292
293
294
# File 'lib/idml/render/image.rb', line 292

def draw_image(*)
  "DEPRECATED"
end

.each_jpeg_marker(data) ⇒ Object

Iterate JPEG markers, yielding (marker_byte, data_offset) for each.



62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# File 'lib/idml/render/image.rb', line 62

def each_jpeg_marker(data)
  pos = 2
  while pos < data.bytesize - 1
    return unless data.getbyte(pos) == 0xFF

    marker = data.getbyte(pos + 1)
    return if marker.nil? || marker == 0xD9 # EOI

    pos += 4
    length = read_uint16(data, pos - 2)
    return unless length

    yield marker, pos
    pos += length - 2
  end
end

.each_png_chunk(data) ⇒ Object

Iterate PNG chunks, yielding (type, data) for each.



169
170
171
172
173
174
175
176
177
178
179
180
181
# File 'lib/idml/render/image.rb', line 169

def each_png_chunk(data)
  pos = 8
  while pos + 8 <= data.bytesize
    length = read_uint32(data, pos)
    return unless length

    chunk_type = data[pos + 4, 4]
    pos += 8
    chunk_data = data[pos, length]
    yield chunk_type, chunk_data
    pos += length + 4 # data + CRC
  end
end

.enclosing_transform(xml, pos) ⇒ Object

Find the ItemTransform of the nearest enclosing page-item element (Rectangle, Polygon, etc.) before pos.



234
235
236
237
238
239
240
241
242
243
244
245
# File 'lib/idml/render/image.rb', line 234

def enclosing_transform(xml, pos)
  prefix = xml[0...pos]
  tags = %w[Rectangle Polygon Polyline Ellipse GraphicLine Group]
  last_open = tags.filter_map { |tag| prefix.rindex(/<#{tag}\s/) }.max
  return identity unless last_open

  elem = xml[last_open..prefix.length]
  t = elem.match(/ItemTransform="([^"]+)"/)
  return identity unless t

  parse_transform(t[1])
end

.extract_from_spread(xml) ⇒ Object

Extract image references from spread XML. Returns Array of { uri:, transform:, parent_transform: } hashes.



206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
# File 'lib/idml/render/image.rb', line 206

def extract_from_spread(xml)
  images = []
  pos = 0
  while (img_start = xml.index(/<Image\s/, pos))
    img_end = xml.index(%r{</Image>}, img_start)
    img_end = xml.index("/>", img_start) if img_end.nil?
    break unless img_end

    block = xml[img_start..img_end]
    parent_transform = enclosing_transform(xml, img_start)

    t_match = block.match(/ItemTransform="([^"]+)"/)
    link_match = block.match(/LinkResourceURI="([^"]+)"/)
    if t_match && link_match
      images << {
        uri: link_match[1],
        transform: parse_transform(t_match[1]),
        parent_transform: parent_transform,
      }
    end

    pos = img_end + 1
  end
  images
end

.find_sof(data) ⇒ Object

Walk JPEG markers until SOF is found. Yields marker fields: marker_byte, precision, height, width, channels.



49
50
51
52
53
54
55
56
57
58
59
# File 'lib/idml/render/image.rb', line 49

def find_sof(data)
  return nil unless jpeg_header?(data)

  each_jpeg_marker(data) do |marker, pos|
    next unless sof_marker?(marker)

    yield_sof_fields(data, pos, marker) { |*a| yield(*a) }
    return
  end
  nil
end

.identityObject



247
248
249
# File 'lib/idml/render/image.rb', line 247

def identity
  Transform.new(1, 0, 0, 1, 0, 0)
end

.jpeg_colorspace(data) ⇒ Object

Detect JPEG color space from binary header. Returns :DeviceRGB (3 channels), :DeviceGray (1), or :DeviceCMYK (4).



36
37
38
39
40
41
42
43
44
45
# File 'lib/idml/render/image.rb', line 36

def jpeg_colorspace(data)
  find_sof(data) do |_, _, _, _, channels|
    return case channels
           when 1 then :DeviceGray
           when 3 then :DeviceRGB
           when 4 then :DeviceCMYK
           end
  end
  nil
end

.jpeg_dimensions(data) ⇒ Object

Read JPEG dimensions from the binary header. Returns [width, height] or nil if not a valid JPEG.



27
28
29
30
31
32
# File 'lib/idml/render/image.rb', line 27

def jpeg_dimensions(data)
  find_sof(data) do |_, _, height, width|
    return [width, height]
  end
  nil
end

.jpeg_header?(data) ⇒ Boolean



79
80
81
# File 'lib/idml/render/image.rb', line 79

def jpeg_header?(data)
  data.getbyte(0) == 0xFF && data.getbyte(1) == 0xD8
end

.parse_transform(str) ⇒ Object

Parse "a b c d e f" into a Transform.



184
185
186
187
188
189
# File 'lib/idml/render/image.rb', line 184

def parse_transform(str)
  parts = str.split(/\s+/).map(&:to_f)
  return nil unless parts.length == 6

  Transform.new(*parts)
end

.png_colorspace(data) ⇒ Object

Detect PNG color space from the IHDR chunk's color type field. Returns :DeviceRGB, :DeviceGray, or nil.



115
116
117
118
119
120
121
122
123
124
125
# File 'lib/idml/render/image.rb', line 115

def png_colorspace(data)
  return nil unless detect_format(data) == :png

  read_png_ihdr(data) do |_w, _h, _bit_depth, color_type|
    return case color_type
           when 0, 4 then :DeviceGray
           when 2, 6 then :DeviceRGB
           end
  end
  nil
end

.png_dimensions(data) ⇒ Object

Read PNG dimensions from the IHDR chunk. Returns [width, height] or nil if not a valid PNG.



104
105
106
107
108
109
110
111
# File 'lib/idml/render/image.rb', line 104

def png_dimensions(data)
  return nil unless detect_format(data) == :png

  read_png_ihdr(data) do |width, height, _bit_depth, _color_type|
    return [width, height]
  end
  nil
end

.png_idat_data(data) ⇒ Object

Extract concatenated IDAT chunk data from a PNG file. PDF can embed this with FlateDecode + Predictor 15 to let the viewer handle PNG unfiltering during decompression.



156
157
158
159
160
161
162
163
164
165
166
# File 'lib/idml/render/image.rb', line 156

def png_idat_data(data)
  return nil unless detect_format(data) == :png

  result = String.new(encoding: "ASCII-8BIT")
  each_png_chunk(data) do |type, chunk_data|
    break if type == "IEND"

    result << chunk_data if type == "IDAT"
  end
  result.empty? ? nil : result
end

.read_png_ihdr(data) {|width, height, bit_depth, color_type| ... } ⇒ Object

Parse PNG IHDR chunk (starts at byte offset 8, after the signature). Yields width, height, bit_depth, color_type.

Yields:

  • (width, height, bit_depth, color_type)


129
130
131
132
133
134
135
136
137
138
139
140
141
142
# File 'lib/idml/render/image.rb', line 129

def read_png_ihdr(data)
  ihdr_start = 8
  return nil if data.bytesize < ihdr_start + 25

  read_uint32(data, ihdr_start)
  type = data[ihdr_start + 4, 4]
  return nil unless type == "IHDR"

  width = read_uint32(data, ihdr_start + 8)
  height = read_uint32(data, ihdr_start + 12)
  bit_depth = data.getbyte(ihdr_start + 16)
  color_type = data.getbyte(ihdr_start + 17)
  yield width, height, bit_depth, color_type
end

.read_uint16(data, offset) ⇒ Object



91
92
93
94
95
# File 'lib/idml/render/image.rb', line 91

def read_uint16(data, offset)
  return nil if offset.nil? || offset + 1 >= data.bytesize

  (data.getbyte(offset) << 8) | data.getbyte(offset + 1)
end

.read_uint32(data, offset) ⇒ Object



144
145
146
147
148
149
150
151
# File 'lib/idml/render/image.rb', line 144

def read_uint32(data, offset)
  return nil if offset.nil? || offset + 3 >= data.bytesize

  (data.getbyte(offset) << 24) |
    (data.getbyte(offset + 1) << 16) |
    (data.getbyte(offset + 2) << 8) |
    data.getbyte(offset + 3)
end

.resolve_path(uri, base_dir: nil) ⇒ Object

Convert a file: URI to a local file path.



276
277
278
279
280
281
282
283
284
285
286
287
# File 'lib/idml/render/image.rb', line 276

def resolve_path(uri, base_dir: nil)
  path = uri.delete_prefix("file:")
  path = begin
    URI.decode_www_form_component(path)
  rescue StandardError
    path
  end
  path = File.expand_path(path)
  return path if File.exist?(path)

  base_dir ? File.join(base_dir, File.basename(path)) : path
end

.sof_marker?(byte) ⇒ Boolean

SOF markers: 0xC0–0xCF except 0xC4 (DHT) and 0xC8 (JPG).



98
99
100
# File 'lib/idml/render/image.rb', line 98

def sof_marker?(byte)
  byte.between?(0xC0, 0xCF) && byte != 0xC4 && byte != 0xC8
end

.yield_sof_fields(data, pos, marker) {|marker, precision, height, width, channels| ... } ⇒ Object

Yields:

  • (marker, precision, height, width, channels)


83
84
85
86
87
88
89
# File 'lib/idml/render/image.rb', line 83

def yield_sof_fields(data, pos, marker)
  precision = data.getbyte(pos)
  height = read_uint16(data, pos + 1)
  width = read_uint16(data, pos + 3)
  channels = data.getbyte(pos + 5)
  yield marker, precision, height, width, channels
end