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
-
.compute_cell_dimensions(params, image, screen) ⇒ Object
Translate the wire’s ‘width=` / `height=` into cell counts for the renderer.
-
.decode_image(bytes) ⇒ Object
Bytes → width:, height:.
-
.decode_payload(b64) ⇒ Object
Tolerant base64 decode (allows wrapped / whitespace payloads).
-
.decode_svg(bytes, params, screen) ⇒ Object
SVG → width:, height:.
-
.handle(rest, screen:, writer: nil) ⇒ Object
Top-level entry point.
- .parse_dim(value, natural_px:, cell_px:, screen_size:) ⇒ Object
-
.parse_params(str) ⇒ Object
Parse “k1=v1;k2=v2” into “k2”=>“v2”.
- .svg?(bytes) ⇒ Boolean
- .svg_dim_to_pixels(value, cell_px, screen_size) ⇒ Object
-
.svg_target_pixels(params, screen, bytes) ⇒ Object
Pick the rasterization target.
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
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 |