gajim3/gajim/gtk/avatar.py

365 lines
11 KiB
Python

# This file is part of Gajim.
#
# Gajim is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation; version 3 only.
#
# Gajim is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Gajim. If not, see <http://www.gnu.org/licenses/>.
import logging
import hashlib
from math import pi
from functools import lru_cache
from collections import defaultdict
from gi.repository import Gdk
from gi.repository import GdkPixbuf
import cairo
from gajim.common import app
from gajim.common import configpaths
from gajim.common.helpers import Singleton
from gajim.common.helpers import get_groupchat_name
from gajim.common.const import AvatarSize
from gajim.common.const import StyleAttr
from .util import load_pixbuf
from .util import text_to_color
from .util import scale_with_ratio
from .util import get_css_show_class
from .util import convert_rgb_string_to_float
log = logging.getLogger('gajim.gui.avatar')
def generate_avatar(letters, color, size, scale):
# Get color for nickname with XEP-0392
color_r, color_g, color_b = color
# Set up colors and size
if scale is not None:
size = size * scale
width = size
height = size
font_size = size * 0.5
# Set up surface
surface = cairo.ImageSurface(cairo.Format.ARGB32, width, height)
context = cairo.Context(surface)
context.set_source_rgb(color_r, color_g, color_b)
context.rectangle(0, 0, width, height)
context.fill()
# Draw letters
context.select_font_face('sans-serif',
cairo.FontSlant.NORMAL,
cairo.FontWeight.NORMAL)
context.set_font_size(font_size)
extends = context.text_extents(letters)
x_bearing = extends.x_bearing
y_bearing = extends.y_bearing
ex_width = extends.width
ex_height = extends.height
x_pos = width / 2 - (ex_width / 2 + x_bearing)
y_pos = height / 2 - (ex_height / 2 + y_bearing)
context.move_to(x_pos, y_pos)
context.set_source_rgb(0.95, 0.95, 0.95)
context.set_operator(cairo.Operator.OVER)
context.show_text(letters)
return context.get_target()
def add_status_to_avatar(surface, show):
width = surface.get_width()
height = surface.get_height()
new_surface = cairo.ImageSurface(cairo.Format.ARGB32, width, height)
new_surface.set_device_scale(*surface.get_device_scale())
scale = surface.get_device_scale()[0]
context = cairo.Context(new_surface)
context.set_source_surface(surface, 0, 0)
context.paint()
# Correct height and width for scale
width = width / scale
height = height / scale
clip_radius = width / 5.5
center_x = width - clip_radius
center_y = height - clip_radius
context.set_source_rgb(255, 255, 255)
context.set_operator(cairo.Operator.CLEAR)
context.arc(center_x, center_y, clip_radius, 0, 2 * pi)
context.fill()
css_color = get_css_show_class(show)
color = convert_rgb_string_to_float(
app.css_config.get_value(css_color, StyleAttr.COLOR))
show_radius = clip_radius * 0.75
context.set_source_rgb(*color)
context.set_operator(cairo.Operator.OVER)
context.arc(center_x, center_y, show_radius, 0, 2 * pi)
context.fill()
if show == 'dnd':
line_length = clip_radius / 2
context.move_to(center_x - line_length, center_y)
context.line_to(center_x + line_length, center_y)
context.set_source_rgb(255, 255, 255)
context.set_line_width(clip_radius / 4)
context.stroke()
return context.get_target()
def square(surface, size):
width = surface.get_width()
height = surface.get_height()
if width == height:
return surface
new_surface = cairo.ImageSurface(cairo.Format.ARGB32, size, size)
new_surface.set_device_scale(*surface.get_device_scale())
context = cairo.Context(new_surface)
scale = surface.get_device_scale()[0]
if width == size:
x_pos = 0
y_pos = (size - height) / 2 / scale
else:
y_pos = 0
x_pos = (size - width) / 2 / scale
context.set_source_surface(surface, x_pos, y_pos)
context.paint()
return context.get_target()
def clip_circle(surface):
new_surface = cairo.ImageSurface(cairo.Format.ARGB32,
surface.get_width(),
surface.get_height())
new_surface.set_device_scale(*surface.get_device_scale())
context = cairo.Context(new_surface)
context.set_source_surface(surface, 0, 0)
width = surface.get_width()
height = surface.get_height()
scale = surface.get_device_scale()[0]
radius = width / 2 / scale
context.arc(width / 2 / scale, height / 2 / scale, radius, 0, 2 * pi)
context.clip()
context.paint()
return context.get_target()
def get_avatar_from_pixbuf(pixbuf, scale, show=None):
size = max(pixbuf.get_width(), pixbuf.get_height())
size *= scale
surface = Gdk.cairo_surface_create_from_pixbuf(pixbuf, scale)
if surface is None:
return None
surface = square(surface, size)
if surface is None:
return None
surface = clip_circle(surface)
if surface is None:
return None
if show is not None:
return add_status_to_avatar(surface, show)
return surface
class AvatarStorage(metaclass=Singleton):
def __init__(self):
self._cache = defaultdict(dict)
def invalidate_cache(self, jid):
self._cache.pop(jid, None)
def get_pixbuf(self, contact, size, scale, show=None, default=False):
surface = self.get_surface(contact, size, scale, show, default)
return Gdk.pixbuf_get_from_surface(surface, 0, 0, size, size)
def get_surface(self, contact, size, scale, show=None, default=False):
jid = contact.jid
if contact.is_gc_contact:
jid = contact.get_full_jid()
if not default:
surface = self._cache[jid].get((size, scale, show))
if surface is not None:
return surface
surface = self._get_avatar_from_storage(contact, size, scale)
if surface is not None:
if show is not None:
surface = add_status_to_avatar(surface, show)
self._cache[jid][(size, scale, show)] = surface
return surface
name = contact.get_shown_name()
# Use nickname for group chats and bare JID for single contacts
if contact.is_gc_contact:
color_string = contact.name
else:
color_string = contact.jid
letter = self._generate_letter(name)
surface = self._generate_default_avatar(
letter, color_string, size, scale)
if show is not None:
surface = add_status_to_avatar(surface, show)
self._cache[jid][(size, scale, show)] = surface
return surface
def get_muc_surface(self, account, jid, size, scale, default=False):
if not default:
surface = self._cache[jid].get((size, scale))
if surface is not None:
return surface
avatar_sha = app.storage.cache.get_muc_avatar_sha(jid)
if avatar_sha is not None:
surface = self.surface_from_filename(avatar_sha, size, scale)
if surface is None:
return None
surface = clip_circle(surface)
self._cache[jid][(size, scale)] = surface
return surface
con = app.connections[account]
name = get_groupchat_name(con, jid)
letter = self._generate_letter(name)
surface = self._generate_default_avatar(letter, jid, size, scale)
self._cache[jid][(size, scale)] = surface
return surface
def prepare_for_publish(self, path):
success, data = self._load_for_publish(path)
if not success:
return None, None
sha = self.save_avatar(data)
if sha is None:
return None, None
return data, sha
@staticmethod
def _load_for_publish(path):
pixbuf = load_pixbuf(path)
if pixbuf is None:
return None
width = pixbuf.get_width()
height = pixbuf.get_height()
if width > AvatarSize.PUBLISH or height > AvatarSize.PUBLISH:
# Scale only down, never up
width, height = scale_with_ratio(AvatarSize.PUBLISH, width, height)
pixbuf = pixbuf.scale_simple(width,
height,
GdkPixbuf.InterpType.BILINEAR)
return pixbuf.save_to_bufferv('png', [], [])
@staticmethod
def save_avatar(data):
"""
Save an avatar to the harddisk
:param data: bytes
returns SHA1 value of the avatar or None on error
"""
if data is None:
return None
sha = hashlib.sha1(data).hexdigest()
path = configpaths.get('AVATAR') / sha
try:
with open(path, 'wb') as output_file:
output_file.write(data)
except Exception:
log.error('Storing avatar failed', exc_info=True)
return None
return sha
@staticmethod
def get_avatar_path(filename):
path = configpaths.get('AVATAR') / filename
if not path.is_file():
return None
return path
def surface_from_filename(self, filename, size, scale):
size = size * scale
path = self.get_avatar_path(filename)
if path is None:
return None
pixbuf = load_pixbuf(path, size)
if pixbuf is None:
return None
surface = Gdk.cairo_surface_create_from_pixbuf(pixbuf, scale)
return square(surface, size)
def _load_surface_from_storage(self, contact, size, scale):
filename = contact.avatar_sha
size = size * scale
path = self.get_avatar_path(filename)
if path is None:
return None
pixbuf = load_pixbuf(path, size)
if pixbuf is None:
return None
surface = Gdk.cairo_surface_create_from_pixbuf(pixbuf, scale)
return square(surface, size)
def _get_avatar_from_storage(self, contact, size, scale):
if contact.avatar_sha is None:
return None
surface = self._load_surface_from_storage(contact, size, scale)
if surface is None:
return None
return clip_circle(surface)
@staticmethod
def _generate_letter(name):
for letter in name:
if letter.isalpha():
return letter.capitalize()
return name[0].capitalize()
@staticmethod
@lru_cache(maxsize=2048)
def _generate_default_avatar(letter, color_string, size, scale):
color = text_to_color(color_string)
surface = generate_avatar(letter, color, size, scale)
surface = clip_circle(surface)
surface.set_device_scale(scale, scale)
return surface