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
- .clean_markdown_path(path) ⇒ Object
- .content_from_text(text) ⇒ Object
- .convert_image_part_to_png(part) ⇒ Object
- .data_uri_parts(text, seen) ⇒ Object
- .data_uri_references(text, seen) ⇒ Object
- .data_url(part) ⇒ Object
- .display_text_without_references(text, references) ⇒ Object
- .embedded_image_candidate?(path) ⇒ Boolean
- .embedded_image_candidates_from_line(line) ⇒ Object
- .embedded_path_start_indexes(text, end_index) ⇒ Object
- .expand_image_basename(path) ⇒ Object
- .expand_image_path(path) ⇒ Object
- .extract_references_from_text(text) ⇒ Object
- .file_uri_path(uri_text) ⇒ Object
- .image_extension?(path) ⇒ Boolean
- .image_media_type(part) ⇒ Object
- .image_part_from_path(path) ⇒ Object
- .image_parts_from_text(text) ⇒ Object
- .image_paths_from_text(text) ⇒ Object
- .image_reference(original_path, expanded_path) ⇒ Object
- .image_reference_candidate?(path) ⇒ Boolean
- .iterm_image_protocol?(env) ⇒ Boolean
- .iterm_image_sequence(data, name, width, height = nil, env: ENV) ⇒ Object
- .kitty_image_protocol?(env) ⇒ Boolean
- .kitty_image_sequence(data, _name, width, height = nil, image_id = nil, move_cursor: true, env: ENV) ⇒ Object
- .mime_type(path) ⇒ Object
- .missing_image_reference(path) ⇒ Object
- .pasted_image_basename?(path) ⇒ Boolean
- .path_candidate_from_line(line) ⇒ Object
- .path_parts(text, seen) ⇒ Object
- .path_tokens_from_line(line) ⇒ Object
- .png_dimensions(part) ⇒ Object
- .references_from_text(text) ⇒ Object
- .resolve_image_path(path) ⇒ Object
- .screenshot_search_dirs(home: Dir.home, tmpdir: Dir.tmpdir) ⇒ Object
- .terminal_image_part(part, protocol) ⇒ Object
- .terminal_image_protocol(env) ⇒ Object
- .terminal_image_sequence(part, width: DEFAULT_TERMINAL_IMAGE_WIDTH, height: nil, protocol: nil, image_id: nil, move_cursor: true, env: ENV) ⇒ Object
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
210 211 212 |
# File 'lib/kward/image_attachments.rb', line 210 def (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 (line) text = line.to_s candidates = [] text.scan(EMBEDDED_IMAGE_EXTENSION_PATTERN) do end_index = Regexp.last_match.end(0) (text, end_index).each do |start_index| candidate = clean_markdown_path(text[start_index...end_index]) next unless (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 (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 (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.(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.(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 (path) path = clean_markdown_path(path) path = file_uri_path(path) if path.start_with?("file://") return nil unless image_extension?(path) File.(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
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) = File.(path.to_s) return nil unless File.file?() media_type = mime_type() return nil unless media_type size = File.size() return nil if size > MAX_IMAGE_BYTES { type: "image", media_type: media_type, data: Base64.strict_encode64(File.binread()), 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((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, ) { status: :attached, type: "image", label: File.basename(), media_type: mime_type(), size_bytes: File.size(), path: , original_path: original_path, source_text: original_path } end |
.image_reference_candidate?(path) ⇒ 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
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
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
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| = resolve_image_path(path) next unless next if seen[] next unless File.file?() next if File.size() > MAX_IMAGE_BYTES media_type = mime_type() next unless media_type seen[] = true { type: "image", media_type: media_type, data: Base64.strict_encode64(File.binread()), 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] = resolve_image_path(path) if && File.file?() next if seen[] next if File.size() > MAX_IMAGE_BYTES next unless mime_type() seen[key] = true seen[] = true refs << image_reference(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) = (path) return if && File.file?() (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 |