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
-
.combine(outer, inner) ⇒ Object
Combine two transforms: result = outer ∘ inner (outer is applied after inner).
-
.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.
-
.detect_format(data) ⇒ Object
Detect image format from magic bytes.
-
.draw_image ⇒ Object
DEPRECATED: draw_image is no longer used.
-
.each_jpeg_marker(data) ⇒ Object
Iterate JPEG markers, yielding (marker_byte, data_offset) for each.
-
.each_png_chunk(data) ⇒ Object
Iterate PNG chunks, yielding (type, data) for each.
-
.enclosing_transform(xml, pos) ⇒ Object
Find the ItemTransform of the nearest enclosing page-item element (Rectangle, Polygon, etc.) before
pos. -
.extract_from_spread(xml) ⇒ Object
Extract image references from spread XML.
-
.find_sof(data) ⇒ Object
Walk JPEG markers until SOF is found.
- .identity ⇒ Object
-
.jpeg_colorspace(data) ⇒ Object
Detect JPEG color space from binary header.
-
.jpeg_dimensions(data) ⇒ Object
Read JPEG dimensions from the binary header.
- .jpeg_header?(data) ⇒ Boolean
-
.parse_transform(str) ⇒ Object
Parse "a b c d e f" into a Transform.
-
.png_colorspace(data) ⇒ Object
Detect PNG color space from the IHDR chunk's color type field.
-
.png_dimensions(data) ⇒ Object
Read PNG dimensions from the IHDR chunk.
-
.png_idat_data(data) ⇒ Object
Extract concatenated IDAT chunk data from a PNG file.
-
.read_png_ihdr(data) {|width, height, bit_depth, color_type| ... } ⇒ Object
Parse PNG IHDR chunk (starts at byte offset 8, after the signature).
- .read_uint16(data, offset) ⇒ Object
- .read_uint32(data, offset) ⇒ Object
-
.resolve_path(uri, base_dir: nil) ⇒ Object
Convert a file: URI to a local file path.
-
.sof_marker?(byte) ⇒ Boolean
SOF markers: 0xC0–0xCF except 0xC4 (DHT) and 0xC8 (JPG).
- .yield_sof_fields(data, pos, marker) {|marker, precision, height, width, channels| ... } ⇒ Object
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_image ⇒ Object
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] = %w[Rectangle Polygon Polyline Ellipse GraphicLine Group] last_open = .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 |
.identity ⇒ Object
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.
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.(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
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 |