Skip to content
Kward Search API index

Module: Kward::ImageAttachments

Defined in:
lib/kward/image_attachments.rb

Overview

Image attachment parsing, validation, encoding, and display helpers.

Constant Summary collapse

MAX_IMAGE_BYTES =
20 * 1024 * 1024
MIME_TYPES =
{
  ".gif" => "image/gif",
  ".jpg" => "image/jpeg",
  ".jpeg" => "image/jpeg",
  ".png" => "image/png",
  ".webp" => "image/webp"
}.freeze
DATA_URI_PATTERN =
%r{data:(image/(?:gif|jpe?g|png|webp));base64,([A-Za-z0-9+/=\r\n]+)}i.freeze
MARKDOWN_IMAGE_PATTERN =
/!\[[^\]]*\]\(([^)]+)\)/.freeze
EMBEDDED_IMAGE_EXTENSION_PATTERN =
/\.(?:gif|jpe?g|png|webp)\b/i.freeze
DEFAULT_TERMINAL_IMAGE_WIDTH =
"40".freeze
SCREENSHOT_SEARCH_DIRS =
["Desktop", "Downloads", "Pictures"].freeze
PASTED_IMAGE_BASENAME_PATTERN =
/\A(?:screenshot|screen shot|pasted[-_]image)/i.freeze

Class Method Summary collapse

Class Method Details

.clean_markdown_path(path) ⇒ Object



239
240
241
# File 'lib/kward/image_attachments.rb', line 239

def clean_markdown_path(path)
  path.to_s.strip.sub(/\A["']/, "").sub(/["']\z/, "")
end

.content_from_text(text) ⇒ Object



30
31
32
33
34
35
36
# File 'lib/kward/image_attachments.rb', line 30

def content_from_text(text)
  text = text.to_s
  images = image_parts_from_text(text)
  return text if images.empty?

  [{ type: "text", text: text }] + images
end

.convert_image_part_to_png(part) ⇒ Object



338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
# File 'lib/kward/image_attachments.rb', line 338

def convert_image_part_to_png(part)
  path = part[:path] || part["path"]
  return nil unless path && File.file?(path)

  stdout, _stderr, status = Open3.capture3(
    "magick",
    "-limit", "memory", "128MiB",
    "-limit", "map", "256MiB",
    "#{path}[0]",
    "png:-"
  )
  return nil unless status.success?
  return nil if stdout.empty? || stdout.bytesize > MAX_IMAGE_BYTES

  part.merge(
    media_type: "image/png",
    data: Base64.strict_encode64(stdout),
    size_bytes: stdout.bytesize
  )
rescue SystemCallError
  nil
end

.data_uri_parts(text, seen) ⇒ Object



140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
# File 'lib/kward/image_attachments.rb', line 140

def data_uri_parts(text, seen)
  text.scan(DATA_URI_PATTERN).filter_map do |media_type, data|
    normalized_data = data.gsub(/\s+/, "")
    key = "data:#{media_type.downcase};#{normalized_data}"
    next if seen[key]

    decoded_bytes = Base64.decode64(normalized_data).bytesize
    next if decoded_bytes > MAX_IMAGE_BYTES

    seen[key] = true
    { type: "image", media_type: media_type.downcase.sub("image/jpg", "image/jpeg"), data: normalized_data }
  rescue ArgumentError
    nil
  end
end

.data_uri_references(text, seen) ⇒ Object



85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/kward/image_attachments.rb', line 85

def data_uri_references(text, seen)
  text.scan(DATA_URI_PATTERN).filter_map do |media_type, data|
    source_text = Regexp.last_match[0]
    normalized_data = data.gsub(/\s+/, "")
    key = "data:#{media_type.downcase};#{normalized_data}"
    next if seen[key]

    decoded_bytes = Base64.decode64(normalized_data).bytesize
    next if decoded_bytes > MAX_IMAGE_BYTES

    seen[key] = true
    {
      status: :attached,
      type: "image",
      label: "pasted image",
      media_type: media_type.downcase.sub("image/jpg", "image/jpeg"),
      size_bytes: decoded_bytes,
      source_text: source_text
    }
  rescue ArgumentError
    nil
  end
end

.data_url(part) ⇒ Object



365
366
367
368
# File 'lib/kward/image_attachments.rb', line 365

def data_url(part)
  media_type = part[:mimeType] || part["mimeType"] || part[:media_type] || part["media_type"]
  "data:#{media_type};base64,#{part[:data] || part["data"]}"
end

.display_text_without_references(text, references) ⇒ Object



49
50
51
52
53
54
55
56
# File 'lib/kward/image_attachments.rb', line 49

def display_text_without_references(text, references)
  references.reduce(text.to_s.dup) do |result, reference|
    source = reference[:source_text].to_s
    next result if source.empty?

    result.sub(source, "").sub(Shellwords.escape(source), "")
  end.gsub(/[ \t]{2,}/, " ").gsub(/[ \t]+\n/, "\n").strip
end

.embedded_image_candidate?(path) ⇒ Boolean

Returns:

  • (Boolean)


210
211
212
# File 'lib/kward/image_attachments.rb', line 210

def embedded_image_candidate?(path)
  image_reference_candidate?(path)
end

.embedded_image_candidates_from_line(line) ⇒ Object



194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
# File 'lib/kward/image_attachments.rb', line 194

def embedded_image_candidates_from_line(line)
  text = line.to_s
  candidates = []
  text.scan(EMBEDDED_IMAGE_EXTENSION_PATTERN) do
    end_index = Regexp.last_match.end(0)
    embedded_path_start_indexes(text, end_index).each do |start_index|
      candidate = clean_markdown_path(text[start_index...end_index])
      next unless embedded_image_candidate?(candidate)

      candidates << candidate
      break
    end
  end
  candidates
end

.embedded_path_start_indexes(text, end_index) ⇒ Object



214
215
216
217
218
# File 'lib/kward/image_attachments.rb', line 214

def embedded_path_start_indexes(text, end_index)
  starts = [0]
  text[0...end_index].scan(/\s+/) { starts << Regexp.last_match.end(0) }
  starts.uniq
end

.expand_image_basename(path) ⇒ Object



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

def expand_image_basename(path)
  path = clean_markdown_path(path)
  return nil unless image_extension?(path)
  return nil unless File.basename(path) == path

  current_candidate = File.join(Dir.pwd, path)
  return File.expand_path(current_candidate) if File.file?(current_candidate)
  return nil unless pasted_image_basename?(path)

  screenshot_search_dirs.filter_map do |dir|
    candidate = File.join(dir, path)
    next unless File.file?(candidate)

    File.expand_path(candidate)
  end.first
end

.expand_image_path(path) ⇒ Object



250
251
252
253
254
255
256
# File 'lib/kward/image_attachments.rb', line 250

def expand_image_path(path)
  path = clean_markdown_path(path)
  path = file_uri_path(path) if path.start_with?("file://")
  return nil unless image_extension?(path)

  File.expand_path(path)
end

.extract_references_from_text(text) ⇒ Object



43
44
45
46
47
# File 'lib/kward/image_attachments.rb', line 43

def extract_references_from_text(text)
  text = text.to_s
  references = references_from_text(text).select { |reference| reference[:status] == :attached }
  { text: display_text_without_references(text, references), attachments: references }
end

.file_uri_path(uri_text) ⇒ Object



285
286
287
288
289
290
# File 'lib/kward/image_attachments.rb', line 285

def file_uri_path(uri_text)
  uri = URI.parse(uri_text)
  CGI.unescape(uri.path.to_s)
rescue URI::InvalidURIError
  uri_text.delete_prefix("file://")
end

.image_extension?(path) ⇒ Boolean

Returns:

  • (Boolean)


292
293
294
# File 'lib/kward/image_attachments.rb', line 292

def image_extension?(path)
  MIME_TYPES.key?(File.extname(path.to_s).downcase)
end

.image_media_type(part) ⇒ Object



414
415
416
# File 'lib/kward/image_attachments.rb', line 414

def image_media_type(part)
  part[:media_type] || part["media_type"] || part[:mimeType] || part["mimeType"]
end

.image_part_from_path(path) ⇒ Object



296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
# File 'lib/kward/image_attachments.rb', line 296

def image_part_from_path(path)
  expanded_path = File.expand_path(path.to_s)
  return nil unless File.file?(expanded_path)

  media_type = mime_type(expanded_path)
  return nil unless media_type

  size = File.size(expanded_path)
  return nil if size > MAX_IMAGE_BYTES

  {
    type: "image",
    media_type: media_type,
    data: Base64.strict_encode64(File.binread(expanded_path)),
    path: expanded_path,
    size_bytes: size
  }
rescue SystemCallError
  nil
end

.image_parts_from_text(text) ⇒ Object



38
39
40
41
# File 'lib/kward/image_attachments.rb', line 38

def image_parts_from_text(text)
  seen = {}
  data_uri_parts(text, seen) + path_parts(text, seen)
end

.image_paths_from_text(text) ⇒ Object



179
180
181
182
183
184
185
186
187
188
189
190
191
192
# File 'lib/kward/image_attachments.rb', line 179

def image_paths_from_text(text)
  paths = []
  text.scan(MARKDOWN_IMAGE_PATTERN) do |match|
    paths << clean_markdown_path(match.first)
  end

  text.each_line do |line|
    candidate = path_candidate_from_line(line)
    paths << candidate if candidate
    paths.concat(path_tokens_from_line(line))
    paths.concat(embedded_image_candidates_from_line(line))
  end
  paths.compact.uniq
end

.image_reference(original_path, expanded_path) ⇒ Object



109
110
111
112
113
114
115
116
117
118
119
120
# File 'lib/kward/image_attachments.rb', line 109

def image_reference(original_path, expanded_path)
  {
    status: :attached,
    type: "image",
    label: File.basename(expanded_path),
    media_type: mime_type(expanded_path),
    size_bytes: File.size(expanded_path),
    path: expanded_path,
    original_path: original_path,
    source_text: original_path
  }
end

.image_reference_candidate?(path) ⇒ Boolean

Returns:

  • (Boolean)


132
133
134
135
136
137
138
# File 'lib/kward/image_attachments.rb', line 132

def image_reference_candidate?(path)
  path = clean_markdown_path(path)
  return false unless image_extension?(path) || path.start_with?("file://")
  return true if path.start_with?("file://", "/", "~/", "./", "../")

  File.basename(path) == path && pasted_image_basename?(path)
end

.iterm_image_protocol?(env) ⇒ Boolean

Returns:

  • (Boolean)


390
391
392
# File 'lib/kward/image_attachments.rb', line 390

def iterm_image_protocol?(env)
  TerminalImageSupport.iterm2_hint?(env)
end

.iterm_image_sequence(data, name, width, height = nil, env: ENV) ⇒ Object



398
399
400
401
# File 'lib/kward/image_attachments.rb', line 398

def iterm_image_sequence(data, name, width, height = nil, env: ENV)
  sequence = TerminalSequences.iterm2_image(data, name: name, width: width, height: height)
  env["TMUX"].to_s.empty? ? sequence : TerminalSequences.tmux_passthrough(sequence)
end

.kitty_image_protocol?(env) ⇒ Boolean

Returns:

  • (Boolean)


394
395
396
# File 'lib/kward/image_attachments.rb', line 394

def kitty_image_protocol?(env)
  TerminalImageSupport.kitty_hint?(env)
end

.kitty_image_sequence(data, _name, width, height = nil, image_id = nil, move_cursor: true, env: ENV) ⇒ Object



403
404
405
406
407
408
409
410
411
412
# File 'lib/kward/image_attachments.rb', line 403

def kitty_image_sequence(data, _name, width, height = nil, image_id = nil, move_cursor: true, env: ENV)
  sequence = TerminalSequences.kitty_graphics(
    data,
    image_id: image_id,
    columns: width,
    rows: height,
    move_cursor: move_cursor
  )
  env["TMUX"].to_s.empty? ? sequence : TerminalSequences.tmux_passthrough(sequence)
end

.mime_type(path) ⇒ Object



361
362
363
# File 'lib/kward/image_attachments.rb', line 361

def mime_type(path)
  MIME_TYPES[File.extname(path.to_s).downcase]
end

.missing_image_reference(path) ⇒ Object



122
123
124
125
126
127
128
129
130
# File 'lib/kward/image_attachments.rb', line 122

def missing_image_reference(path)
  {
    status: :missing,
    type: "image",
    label: File.basename(clean_markdown_path(path)),
    original_path: path,
    source_text: path
  }
end

.pasted_image_basename?(path) ⇒ Boolean

Returns:

  • (Boolean)


275
276
277
# File 'lib/kward/image_attachments.rb', line 275

def pasted_image_basename?(path)
  File.basename(path).match?(PASTED_IMAGE_BASENAME_PATTERN)
end

.path_candidate_from_line(line) ⇒ Object



220
221
222
223
224
225
226
227
228
229
230
231
# File 'lib/kward/image_attachments.rb', line 220

def path_candidate_from_line(line)
  stripped = line.strip
  return nil if stripped.empty?
  return stripped if stripped.start_with?("file://")

  shell_words = Shellwords.split(stripped)
  return shell_words.first if shell_words.length == 1

  stripped
rescue ArgumentError
  stripped
end

.path_parts(text, seen) ⇒ Object



156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
# File 'lib/kward/image_attachments.rb', line 156

def path_parts(text, seen)
  image_paths_from_text(text).filter_map do |path|
    expanded_path = resolve_image_path(path)
    next unless expanded_path
    next if seen[expanded_path]
    next unless File.file?(expanded_path)
    next if File.size(expanded_path) > MAX_IMAGE_BYTES

    media_type = mime_type(expanded_path)
    next unless media_type

    seen[expanded_path] = true
    {
      type: "image",
      media_type: media_type,
      data: Base64.strict_encode64(File.binread(expanded_path)),
      path: expanded_path
    }
  rescue SystemCallError
    nil
  end
end

.path_tokens_from_line(line) ⇒ Object



233
234
235
236
237
# File 'lib/kward/image_attachments.rb', line 233

def path_tokens_from_line(line)
  Shellwords.split(line).select { |word| image_extension?(word) || word.start_with?("file://") }
rescue ArgumentError
  line.scan(/\S+/).select { |word| image_extension?(word) || word.start_with?("file://") }
end

.png_dimensions(part) ⇒ Object



324
325
326
327
328
329
330
331
332
333
334
335
336
# File 'lib/kward/image_attachments.rb', line 324

def png_dimensions(part)
  encoded = part[:data] || part["data"]
  header = Base64.strict_decode64(encoded.to_s[0, 32])
  return nil unless header.start_with?("\x89PNG\r\n\x1A\n".b)
  return nil unless header.byteslice(12, 4) == "IHDR"

  width, height = header.byteslice(16, 8).unpack("NN")
  return nil unless width.positive? && height.positive?

  [width, height]
rescue ArgumentError, NoMethodError
  nil
end

.references_from_text(text) ⇒ Object



58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
# File 'lib/kward/image_attachments.rb', line 58

def references_from_text(text)
  seen = {}
  refs = data_uri_references(text.to_s, seen)
  image_paths_from_text(text.to_s).each do |path|
    key = "path:#{path}"
    next if seen[key]

    expanded_path = resolve_image_path(path)
    if expanded_path && File.file?(expanded_path)
      next if seen[expanded_path]
      next if File.size(expanded_path) > MAX_IMAGE_BYTES
      next unless mime_type(expanded_path)

      seen[key] = true
      seen[expanded_path] = true
      refs << image_reference(path, expanded_path)
    elsif image_reference_candidate?(path)
      seen[key] = true
      refs << missing_image_reference(path)
    end
  rescue SystemCallError
    seen[key] = true
    refs << missing_image_reference(path)
  end
  refs
end

.resolve_image_path(path) ⇒ Object



243
244
245
246
247
248
# File 'lib/kward/image_attachments.rb', line 243

def resolve_image_path(path)
  expanded_path = expand_image_path(path)
  return expanded_path if expanded_path && File.file?(expanded_path)

  expand_image_basename(path)
end

.screenshot_search_dirs(home: Dir.home, tmpdir: Dir.tmpdir) ⇒ Object



279
280
281
282
283
# File 'lib/kward/image_attachments.rb', line 279

def screenshot_search_dirs(home: Dir.home, tmpdir: Dir.tmpdir)
  (SCREENSHOT_SEARCH_DIRS.map { |dir| File.join(home, dir) } + [tmpdir]).uniq
rescue ArgumentError
  []
end

.terminal_image_part(part, protocol) ⇒ Object



317
318
319
320
321
322
# File 'lib/kward/image_attachments.rb', line 317

def terminal_image_part(part, protocol)
  return part unless protocol == :kitty
  return part if image_media_type(part) == "image/png"

  convert_image_part_to_png(part)
end

.terminal_image_protocol(env) ⇒ Object



386
387
388
# File 'lib/kward/image_attachments.rb', line 386

def terminal_image_protocol(env)
  TerminalImageSupport.static_protocol(env)
end

.terminal_image_sequence(part, width: DEFAULT_TERMINAL_IMAGE_WIDTH, height: nil, protocol: nil, image_id: nil, move_cursor: true, env: ENV) ⇒ Object



370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
# File 'lib/kward/image_attachments.rb', line 370

def terminal_image_sequence(part, width: DEFAULT_TERMINAL_IMAGE_WIDTH, height: nil, protocol: nil, image_id: nil, move_cursor: true, env: ENV)
  data = part[:data] || part["data"]
  return nil if data.to_s.empty?

  protocol ||= terminal_image_protocol(env)
  name = part[:path] || part["path"]
  case protocol
  when :iterm2
    iterm_image_sequence(data, name, width, height, env: env)
  when :kitty
    return nil unless image_media_type(part) == "image/png"

    kitty_image_sequence(data, name, width, height, image_id, move_cursor: move_cursor, env: env)
  end
end