Sublime Forum

Hover Image Preview

#1

Hi everyone!

I’ve been using Sublime for a long time and I’ve always wanted to contribute with useful tools.
My first plugin is Hover Image Preview which allows you to preview images on a project simply by hovering on a file name.

Package Control Link

All the feedback, questions and suggestions are welcome.
Looking forward to joining this community :slightly_smiling:

9 Likes

#2

nice plugin

0 Likes

#3

@dannydyer thank you! Let me know if you have some suggestions or feedback.

0 Likes

#4

Awesome, works like a charm!

0 Likes

#5

Tanks you for the wonderful plugin.

Suggestion:
Add buttons:

                               r"(?:[-.@\w]+(?:[ ]+[-.@\w]+)*?[\\/])*"  # body
                               r"[-.@\w]+(?:[ ]+[-.@\w]+)*?"            # name

I changed it in main.py on my end.

import base64
import hashlib
import os
import os.path as osp
import re
import shutil
import subprocess
import tempfile
import sys

from urllib.parse import quote, unquote
from urllib.request import urlopen
try:
    from typing import List, Optional, Tuple
    assert List and Optional and Tuple
except ImportError:
    pass

import sublime  # type: ignore
import sublime_plugin  # type: ignore

from .utils.get_image_size import get_image_size, UnknownImageFormat  # type: ignore
from .utils.settings import Settings  # type: ignore


TEMPLATE = """
    <img style="width: %dpx;height: %dpx;" src="data:image/%s;base64,%s">
    <div>%dx%d %s</div>
    <div>
        <a href="open">Open</a> | <a href="openin">Open In</a> | <a href="reveal">Reveal</a>  | <a href="save">Save</a> | <a href="save_as">Save as</a>
    </div>
    """
TEMP_DIR = tempfile.gettempdir()
IMAGE_DATA_URL_RE = re.compile(r"data:image/(jpeg|png|gif|bmp|svg\+xml);base64,([a-zA-Z0-9+/ ]+={0,2})")
image_url_re = re.compile("")
image_file_re = re.compile("")
image_file_name_re = re.compile("")
all_formats = []  # type: List[str]
formats_to_convert = ()  # type: Tuple[str, ...]


def on_change(s):
    global all_formats,\
        formats_to_convert,\
        image_url_re,\
        image_file_re,\
        image_file_name_re

    Settings.update(s)
    # ST popups supported formats
    ST_FORMATS = {"png", "jpg", "jpeg", "bmp", "gif"}
    unique_formats_to_convert = set(Settings.formats_to_convert or ["png", "jpg", "jpeg", "gif", "bmp", "webp", "tiff"])
    all_formats = list(ST_FORMATS.union(unique_formats_to_convert))
    # filter out ST supported formats
    formats_to_convert = tuple('.' + ext for ext in unique_formats_to_convert - ST_FORMATS)
    formats_ored = '|'.join(all_formats)
    image_url_re = re.compile(r"(?:(https?)://)?"                       # http(s)://
                              r"(?:[^./\"'\s]+\.){1,3}[^/\"'.\s]+/"     # host
                              r"(?:[^/\"'\s]+/)*"                       # path
                              r"([^\"'/\s]+?\.(?:%s))([\?][^)\" \"]*)?" % formats_ored)  # name

    image_file_re = re.compile(r"(?:"                                   # drive
                               r"\w:\\|"                                # Windows (e.g C:\)
                               r"\\\\|"                                 # Linux (\\)
                               r"/|"                                    # base /
                               r"(?:\.{1,2}[\\/])?"                     # Mac OS and/or relative
                               r")"
                               # r"(?:[-.@\w]+?[\\/])*"                   # body
                               # r"[-.@\w]+?"                             # name
                               r"(?:[-.@\w]+(?:[ ]+[-.@\w]+)*?[\\/])*"  # body
                               r"[-.@\w]+(?:[ ]+[-.@\w]+)*?"            # name
                               r"\.(?:%s)" % formats_ored               # extension
                               )
    image_file_name_re = re.compile(r"[-.@\w]+"                         # name
                                    r"\.(?:%s)" % formats_ored          # extension
                                    )


def plugin_loaded():
    loaded_settings = sublime.load_settings("ImagePreview.sublime-settings")
    loaded_settings.clear_on_change("image_preview")
    on_change(loaded_settings)
    loaded_settings.add_on_change("image_preview", lambda ls=loaded_settings: on_change(ls))


def magick(inp, out):
    """Convert the image from one format to another."""

    subprocess.call(["magick", inp, out], shell=os.name == "nt")


def get_data(view: sublime.View, path: str) -> 'Tuple[int, int, int, int, int]':
    """
    Return a tuple of (width, height, real_width, real_height, size).

    `real_width` and `real_height` are the real dimensions of the image file
    `width` and `height` are adjusted to the viewport
    `size` is the size of the image file
    """

    # set max dimensions to 75% of the viewport
    max_width, max_height = view.viewport_extent()
    max_width *= 0.75
    max_height *= 0.75
    max_ratio = max_height / max_width

    try:
        real_width, real_height, size = get_image_size(path)
    except UnknownImageFormat:
        return -1, -1, -1, -1, -1

    # First check height since it's the smallest vector
    if real_height / real_width >= max_ratio and real_height > max_height:
        width = real_width * max_height / real_height
        height = max_height
    elif real_height / real_width <= max_ratio and real_width > max_width:
        width = max_width
        height = real_height * max_width / real_width
    else:
        width = real_width
        height = real_height

    return width, height, real_width, real_height, size


def check_recursive(base_folders, name) -> 'Optional[Tuple[str, str]]':
    """
    Return the path to the base folder and the path to the file if it is
    present in the project.
    """

    for base_folder in base_folders:
        for root, dirs, files in os.walk(base_folder):
            for f in files:
                if f == name:
                    return osp.dirname(base_folder), root
    return None


def get_file(view: sublime.View, string: str, name: str) -> 'Tuple[str, Optional[str]]':
    """
    Try to get a file from the given `string` and test whether it's in the
    project directory.
    """

    # if it's an absolute path get it
    if osp.isabs(string):
        return string, None

    # if search_mode: "project", search only in project
    elif Settings.search_mode == "project":
        # Get base project folders
        base_folders = sublime.active_window().folders()
        # if "recursive": true, recursively search for the name
        if Settings.recursive:
            ch_rec = check_recursive(base_folders, name)
            if ch_rec:
                base_folder, root = ch_rec
                return osp.join(root, name), base_folder
            return "", None
        else:
            # search only in base folders for the relative path
            for base_folder in base_folders:
                file_name = osp.normpath(osp.join(base_folder, string))
                if osp.exists(file_name):
                    return file_name, base_folder
            return "", None
    # if search_mode: "file" join the relative path to the file path
    else:
        return osp.normpath(osp.join(osp.dirname(view.file_name()), string)), None


def save(file: str, name: str, kind: str, folder=None, convert=False):
    """Save the image if it's not already in the project folders."""

    # all folders in the project
    base_folders = sublime.active_window().folders()
    # create the image folder in the first folder
    image_folder = osp.join(base_folders[0], Settings.image_folder_name)
    # exact or converted copy of the image
    copy = osp.join(image_folder, name)
    # a relative version of the image_folder for display in the status message
    image_folder_rel = osp.relpath(image_folder, osp.dirname(base_folders[0]))

    if osp.exists(copy):
        sublime.status_message("%s is already in %s" % (name, image_folder_rel))
        return

    if kind == "file" and folder:
        sublime.status_message("%s is already in %s" % (name, osp.relpath(osp.dirname(file), folder)))
        return

    ch_rec = check_recursive(base_folders, name)
    if ch_rec:
        folder, root = ch_rec
        sublime.status_message("%s is already in %s" % (name, osp.relpath(root, folder)))
        return

    if not osp.exists(image_folder):
        os.mkdir(image_folder)

    if convert:
        # create a converted copy
        magick(file, copy)
    else:
        # create an exact copy
        shutil.copyfile(file, copy)

    sublime.status_message("%s saved in %s" % (name, image_folder_rel))


def convert(file: str, kind: str, name=None):
    """Convert the image to the format chosen from the quick panel and save it."""

    basename, ext = osp.splitext(name or osp.basename(file))
    # remove the extension of the file
    other_formats = all_formats.copy()
    other_formats.remove(ext[1:])

    def on_done(i):
        if i != -1:
            save(file, basename + '.' + other_formats[i], kind, convert=True)

    sublime.active_window().show_quick_panel(other_formats, on_done)


def handle_as_url(view: sublime.View, point: int, string: str, name: str):
    """Handle the given `string` as a url."""

    # Let's assume this url as input:
    # (https://upload.wikimedia.org/wikipedia/commons/8/84/Example.svg)

    # Download the image
    # FIXME: avoid nested try-except clauses
    try:
        try:
            f = urlopen(unquote(string))  # <==
        except Exception:
            try:
                url_path = quote(string).replace("%3A", ':', 1)
                f = urlopen(url_path)
            except Exception:
                f = urlopen(string)
    # don't fill the console with stack-trace when there`s no connection !!
    except Exception as e:
        print(e)
        return

    # file needs conversion ?
    need_conversion = name.endswith(formats_to_convert)  # => True
    basename, ext = osp.splitext(name)  # => ("Example", ".svg")
    # create a temporary file
    temp_img = osp.join(TEMP_DIR, "tmp_image" + ext)  # => "TEMP_DIR/tmp_image.svg"

    # Save downloaded data in the temporary file
    content = f.read()
    with open(temp_img, "wb") as img:
        img.write(content)

    # if the file needs conversion, convert it then read data from the resulting png
    if need_conversion:
        ext = ".png"
        # keep the image's temporary file and name for later use
        conv_file = temp_img  # => "TEMP_DIR/tmp_image.svg"

        # => "TEMP_DIR/tmp_image.png"
        temp_png = osp.splitext(temp_img)[0] + ".png"

        # use the magick command of Imagemagick to convert the image to png
        magick(temp_img, temp_png)

        # read data from the resulting png
        with open(temp_png, "rb") as png:
            content = png.read()

        # set temp_file and name to the png file
        temp_img = temp_png  # => "TEMP_DIR/tmp_image.png"

    width, height, real_width, real_height, size = get_data(view, temp_img)
    encoded = str(base64.b64encode(content), "utf-8")

    def on_navigate(href):

        if href == "save":
            if need_conversion:
                save(conv_file, name, "url")
            else:
                save(temp_img, name, "url")
        elif href == "save_as":
            if need_conversion:
                convert(conv_file, "url", name)
            else:
                convert(temp_img, "url", name)
        elif href == "reveal":
            sublime.active_window().open_file(temp_img)
        elif href == "openin":
            sublime.active_window().open_file(temp_img)
        else:
            sublime.active_window().open_file(temp_img)

    view.show_popup(
        TEMPLATE % (width, height, ext, encoded, real_width, real_height,
                    str(size // 1024) + "KB" if size >= 1024 else str(size) + 'B'),
        sublime.HIDE_ON_MOUSE_MOVE_AWAY,
        point,
        *view.viewport_extent(),
        on_navigate=on_navigate
    )


def handle_as_data_url(view: sublime.View, point: int, ext: str, encoded: str):
    """Handle the string as a data url."""

    need_conversion = False
    # TODO: is this the only case ?
    if ext == "svg+xml":
        ext = "svg"
        need_conversion = True

    # create a temporary file
    temp_img = osp.join(TEMP_DIR, "tmp_data_image." + ext)
    basename = str(int(hashlib.sha1(encoded.encode('utf-8')).hexdigest(), 16) % (10 ** 8))
    name = basename + "." + ext

    # Save downloaded data in the temporary file
    try:
        img = open(temp_img, "wb")
        img.write(base64.b64decode(encoded))
    except Exception as e:
        print(e)
        return
    finally:
        img.close()

    if need_conversion:
        ext = ".png"

        conv_file = temp_img

        temp_png = osp.splitext(temp_img)[0] + ".png"

        magick(temp_img, temp_png)

        with open(temp_png, "rb") as png:
            encoded = str(base64.b64encode(png.read()), "utf-8")

        temp_img = temp_png

    def on_navigate(href):

        if href == "save":
            if need_conversion:
                save(conv_file, name, "data_url")
            else:
                save(temp_img, name, "data_url")
        elif href == "save_as":
            if need_conversion:
                convert(conv_file, "dat_url", name)
            else:
                convert(temp_img, "data_url", name)
        elif href == "reveal":
            sublime.active_window().open_file(temp_img)
        elif href == "openin":
            sublime.active_window().open_file(temp_img)
        else:
            sublime.active_window().open_file(temp_img)

    width, height, real_width, real_height, size = get_data(view, temp_img)

    view.show_popup(
        TEMPLATE % (width, height, ext, encoded, real_width, real_height,
                    str(size // 1024) + "KB" if size >= 1024 else str(size) + 'B'),
        sublime.HIDE_ON_MOUSE_MOVE_AWAY,
        point,
        *view.viewport_extent(),
        on_navigate=on_navigate
    )


def handle_as_file(view: sublime.View, point: int, string: str):
    """Handle the given `string` as a file."""

    name = osp.basename(string)
    file, folder = get_file(view, string, name)

    # if file doesn't exist, return
    if not osp.isfile(file):
        return

    # does the file need conversion ?
    need_conversion = file.endswith(formats_to_convert)
    ext = name.rsplit('.', 1)[1]

    # if the file needs conversion, convert it and read data from the resulting png
    if need_conversion:
        ext = ".png"
        # keep the image's file and name for later use
        conv_file = file

        # create a temporary file
        temp_png = osp.join(TEMP_DIR, "temp_png.png")

        # use the magick command of Imagemagick to convert the image to png
        magick(file, temp_png)

        file = temp_png

    with open(file, "rb") as img:
        encoded = str(base64.b64encode(img.read()), "utf-8")

    width, height, real_width, real_height, size = get_data(view, file)

    def on_navigate(href):

        if href == "save":
            if need_conversion:
                save(conv_file, name, "file")
            else:
                save(file, name, "file", folder)
        elif href == "save_as":
            convert(conv_file if need_conversion else file, "file")
        elif href == "reveal":
            # Checking the path and opening the file
             print(f"File path: {file}")  # Check, whether the file path is displayed
             if sys.platform == "darwin":  # For macOS
                 # result = subprocess.run(["open", file], capture_output=True, text=True)
                 subprocess.run(["osascript", "-e", f"tell application \"Finder\" to reveal POSIX file \"{file}\""])
                 subprocess.run(f"osascript -e 'tell application \"Finder\" to activate'", shell=True)
                 # subprocess.run(["osascript", "-e", f"tell application \"Finder\" to reveal POSIX file \"{file}\""])
                 if result.returncode != 0:
                     print(f"Error opening file: {result.stderr}")
             elif sys.platform == "win32":  # For Windows
                 result = subprocess.run(["start", f'"{file}"'], shell=True, capture_output=True, text=True)
                 if result.returncode != 0:
                     print(f"Error opening file: {result.stderr}")
        elif href == "openin":
            subprocess.run(["open", file])
        else:
            # Default opens in subfolder
            sublime.active_window().open_file(file)

    view.show_popup(
        TEMPLATE % (width, height, ext, encoded, real_width, real_height,
                    str(size // 1024) + "KB" if size >= 1024 else str(size) + 'B'),
        sublime.HIDE_ON_MOUSE_MOVE_AWAY,
        point,
        *view.viewport_extent(),
        on_navigate=on_navigate)


def preview_image(view: sublime.View, point: int):
    """Find the image path or url and Preview the image if possible."""

    line = view.line(point)

    string = view.substr(line)
    # the offset of point relative to the start of the line
    offset_point = point - line.a

    # search for the match in the string that contains the point

    # ==================URL=====================
    for match in image_url_re.finditer(string):
        if match.start() <= offset_point <= match.end():
            string, protocol, name = match.group(0, 1, 2)
            # if the url doesn't start with http or https try adding it
            # "www.gettyimages.fr/gi-resources/images/Embed/new/embed2.jpg"
            if not protocol:
                string = "http://" + string
            # print("[Image Preview] URL:", match.group(0, 1, 2))

            # don't block ST while handling the url
            return sublime.set_timeout_async(lambda: handle_as_url(view, point, string, name), 0)

    # =================DATA URL=================
    for match in IMAGE_DATA_URL_RE.finditer(string):
        if match.start() <= offset_point <= match.end():
            # print("[Image Preview] data URL:", match.groups())
            return handle_as_data_url(view, point, *match.groups())

    # =================FILE=====================
    # find full and relative paths (e.g ./screenshot.png)
    for match in image_file_re.finditer(string):
        if match.start() <= offset_point <= match.end():
            # print("[Image Preview] file:", match.group(0))
            return handle_as_file(view, point, match.group(0))

    # find file name (e.g screenshot.png)
    for match in image_file_name_re.finditer(string):
        if match.start() <= offset_point <= match.end():
            # print("[Image Preview] filename:", match.group(0))
            return handle_as_file(view, point, match.group(0))


class HoverPreviewImage(sublime_plugin.EventListener):

    def on_hover(self, view: sublime.View, point: int, hover_zone: int):

        if not Settings.preview_on_hover or hover_zone != sublime.HOVER_TEXT:
            return

        preview_image(view, point)


class PreviewImageCommand(sublime_plugin.TextCommand):

    def run(self, edit, event=None):
        if event:
            preview_image(self.view, self.view.window_to_text((event['x'], event['y'])))
        else:
            preview_image(self.view, self.view.selection[0].a)

    def is_visible(self, event):
        point = self.view.window_to_text((event['x'], event['y']))
        line = self.view.line(point)

        string = self.view.substr(line)
        point -= line.a

        for pattern in (image_url_re, IMAGE_DATA_URL_RE, image_file_re, image_file_name_re):
            for match in pattern.finditer(string):
                if match.start() <= point <= match.end():
                    return True
        return False

    def want_event(self):
        return True
2 Likes