Module: Echoes::Iterm2Images

Defined in:
lib/echoes/iterm2_images.rb

Overview

iTerm2 inline-image protocol (OSC 1337 with the ‘File=` verb). Wire format:

\e]1337;File=<key=value>[;<key=value>...]:<base64-payload>\a

Most of the heavy lifting (NSBitmapImageRep PNG/JPEG/TIFF/GIF decode, RGBA pixmap storage on the screen, blit through the multicell renderer) is already shipped for the Kitty graphics protocol — this module just parses the OSC 1337 envelope and delegates to Echoes::KittyGraphics::AppKitPng / Screen#put_kitty_image.

Out of scope (follow-ups, if anyone hits them):

- `inline=0` (save to disk / clipboard) — we ignore these.
- `preserveAspectRatio=0` (non-uniform stretch) — we always
  preserve aspect; user can request explicit `width=` + `height=`.
- The `name=`-base64 metadata — useful for "Save As" UIs we
  don't have.

Class Method Summary collapse

Class Method Details

.compute_cell_dimensions(params, image, screen) ⇒ Object

Translate the wire’s ‘width=` / `height=` into cell counts for the renderer. Supported forms (per iTerm2 docs):

N      — N character cells
Npx    — N pixels; rounded up to a whole cell
N%     — N% of the screen dimension
auto   — natural pixel size / cell pixel size (default)

nil or unrecognized → ‘auto`.



151
152
153
154
155
156
157
158
159
160
161
162
163
# File 'lib/echoes/iterm2_images.rb', line 151

def compute_cell_dimensions(params, image, screen)
  cell_w_px = screen.cell_pixel_width.to_f
  cell_h_px = screen.cell_pixel_height.to_f
  cells_w = parse_dim(params['width'],
                      natural_px: image[:width],
                      cell_px:    cell_w_px,
                      screen_size: screen.cols)
  cells_h = parse_dim(params['height'],
                      natural_px: image[:height],
                      cell_px:    cell_h_px,
                      screen_size: screen.rows)
  [cells_w, cells_h]
end

.decode_image(bytes) ⇒ Object

Bytes → width:, height:. Despite the name, AppKitPng handles every format NSBitmapImageRep recognizes (PNG / JPEG / GIF / TIFF / BMP) because it draws the decoded CGImage into a known RGBA8 CGBitmapContext.



88
89
90
91
# File 'lib/echoes/iterm2_images.rb', line 88

def decode_image(bytes)
  require_relative 'kitty_graphics_appkit'
  KittyGraphics::AppKitPng.decode(bytes)
end

.decode_payload(b64) ⇒ Object

Tolerant base64 decode (allows wrapped / whitespace payloads). Mirrors KittyGraphics.decode_payload to keep the two paths symmetric.



76
77
78
79
80
81
82
# File 'lib/echoes/iterm2_images.rb', line 76

def decode_payload(b64)
  cleaned = b64.to_s.delete("\r\n\t ")
  return nil if cleaned.empty?
  cleaned.unpack1('m0')
rescue ArgumentError
  nil
end

.decode_svg(bytes, params, screen) ⇒ Object

SVG → width:, height:. Computes the target pixel size from the wire-format width / height (cells, px, or %) before rasterizing, because the renderer needs an explicit target —vector input has no intrinsic raster dimensions.



97
98
99
100
101
# File 'lib/echoes/iterm2_images.rb', line 97

def decode_svg(bytes, params, screen)
  require_relative 'svg_renderer'
  w, h = svg_target_pixels(params, screen, bytes)
  SvgRenderer.rasterize(bytes, width: w, height: h)
end

.handle(rest, screen:, writer: nil) ⇒ Object

Top-level entry point. ‘rest` is everything between `e]1337;` and the terminating BEL/ST. Returns truthy on successful image display; nil otherwise (silently).



28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# File 'lib/echoes/iterm2_images.rb', line 28

def handle(rest, screen:, writer: nil)
  verb_args = rest.to_s
  return nil unless verb_args.start_with?('File=')

  params_str, payload_b64 = verb_args.byteslice(5..).split(':', 2)
  return nil if payload_b64.nil? || payload_b64.empty?

  params = parse_params(params_str || '')

  # iTerm2 only inlines images when `inline=1`. Absent means
  # "save to disk", which we don't support — bail.
  return nil unless params['inline'] == '1'

  bytes = decode_payload(payload_b64)
  return nil unless bytes

  image = if svg?(bytes)
            decode_svg(bytes, params, screen)
          else
            decode_image(bytes)
          end
  return nil unless image

  cells_w, cells_h = compute_cell_dimensions(params, image, screen)
  screen.put_kitty_image(
    rgba:    image[:rgba],
    width:   image[:width],
    height:  image[:height],
    cells_w: cells_w,
    cells_h: cells_h,
  )
  true
end

.parse_dim(value, natural_px:, cell_px:, screen_size:) ⇒ Object



165
166
167
168
169
170
171
172
173
174
175
# File 'lib/echoes/iterm2_images.rb', line 165

def parse_dim(value, natural_px:, cell_px:, screen_size:)
  return nil if value.nil? || value.empty? || value == 'auto'
  if value.end_with?('px')
    return nil if cell_px <= 0
    (value.to_f / cell_px).ceil
  elsif value.end_with?('%')
    (screen_size * value.to_f / 100.0).ceil
  else
    value.to_i
  end
end

.parse_params(str) ⇒ Object

Parse “k1=v1;k2=v2” into “k2”=>“v2”.



63
64
65
66
67
68
69
70
71
# File 'lib/echoes/iterm2_images.rb', line 63

def parse_params(str)
  out = {}
  str.split(';').each do |pair|
    k, v = pair.split('=', 2)
    next if k.nil? || k.empty?
    out[k] = (v || '').to_s
  end
  out
end

.svg?(bytes) ⇒ Boolean

Returns:

  • (Boolean)


103
104
105
106
# File 'lib/echoes/iterm2_images.rb', line 103

def svg?(bytes)
  require_relative 'svg_sniffer'
  SvgSniffer.svg?(bytes)
end

.svg_dim_to_pixels(value, cell_px, screen_size) ⇒ Object



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

def svg_dim_to_pixels(value, cell_px, screen_size)
  return nil if value.nil? || value.empty? || value == 'auto'
  if value.end_with?('px')
    n = value.to_f
    n.positive? ? n.round : nil
  elsif value.end_with?('%')
    return nil if cell_px <= 0
    (screen_size * value.to_f / 100.0 * cell_px).round
  else
    return nil if cell_px <= 0
    n = value.to_f
    n.positive? ? (n * cell_px).round : nil
  end
end

.svg_target_pixels(params, screen, bytes) ⇒ Object

Pick the rasterization target. Priority:

1. Explicit wire dim from the client (cells × cell_px, Npx, or %)
2. Intrinsic width/height/viewBox parsed from the <svg> tag
3. 512² fallback

Capped at 4096 per axis so a runaway ‘viewBox=“0 0 1e6 1e6”` can’t ask for gigabyte buffers.



114
115
116
117
118
119
120
121
122
123
124
125
126
127
# File 'lib/echoes/iterm2_images.rb', line 114

def svg_target_pixels(params, screen, bytes)
  cell_w = screen.cell_pixel_width.to_f
  cell_h = screen.cell_pixel_height.to_f
  w = svg_dim_to_pixels(params['width'],  cell_w, screen.cols)
  h = svg_dim_to_pixels(params['height'], cell_h, screen.rows)
  if w.nil? || h.nil?
    iw, ih = SvgSniffer.intrinsic_size(bytes)
    w ||= iw if iw
    h ||= ih if ih
  end
  w ||= 512
  h ||= 512
  [w.clamp(1, 4096), h.clamp(1, 4096)]
end