From 620c3161cfb4ff886a25247d4fb28173c628e61e Mon Sep 17 00:00:00 2001 From: KOUNOIKE Yuusuke Date: Thu, 16 Mar 2017 23:50:46 +0900 Subject: [PATCH 1/3] Add support TextAvatar feature and _avatar returns it. --- .../core/controller/AccountController.scala | 20 ++++---- .../core/service/TextAvatarService.scala | 49 +++++++++++++++++++ 2 files changed, 59 insertions(+), 10 deletions(-) create mode 100644 src/main/scala/gitbucket/core/service/TextAvatarService.scala diff --git a/src/main/scala/gitbucket/core/controller/AccountController.scala b/src/main/scala/gitbucket/core/controller/AccountController.scala index 714ae96bc..6af8ab659 100644 --- a/src/main/scala/gitbucket/core/controller/AccountController.scala +++ b/src/main/scala/gitbucket/core/controller/AccountController.scala @@ -21,13 +21,13 @@ import java.util.Date class AccountController extends AccountControllerBase with AccountService with RepositoryService with ActivityService with WikiService with LabelsService with SshKeyService with OneselfAuthenticator with UsersAuthenticator with GroupManagerAuthenticator with ReadableUsersAuthenticator - with AccessTokenService with WebHookService with RepositoryCreationService + with AccessTokenService with WebHookService with RepositoryCreationService with TextAvatarService trait AccountControllerBase extends AccountManagementControllerBase { self: AccountService with RepositoryService with ActivityService with WikiService with LabelsService with SshKeyService with OneselfAuthenticator with UsersAuthenticator with GroupManagerAuthenticator with ReadableUsersAuthenticator - with AccessTokenService with WebHookService with RepositoryCreationService => + with AccessTokenService with WebHookService with RepositoryCreationService with TextAvatarService => case class AccountNewForm(userName: String, password: String, fullName: String, mailAddress: String, description: Option[String], url: Option[String], fileId: Option[String]) @@ -150,17 +150,17 @@ trait AccountControllerBase extends AccountManagementControllerBase { get("/:userName/_avatar"){ val userName = params("userName") - (for { - account <- getAccountByUserName(userName) - image <- account.image - } yield (account, image)) match { - case Some((account, image)) => + getAccountByUserName(userName).map{ account => + account.image.map{ image => response.setDateHeader("Last-Modified", account.updatedDate.getTime) RawData(FileUtil.getMimeType(image), new java.io.File(getUserUploadDir(userName), image)) - case None => + }.getOrElse{ contentType = "image/png" - response.setDateHeader("Last-Modified", (new Date(0)).getTime) - Thread.currentThread.getContextClassLoader.getResourceAsStream("noimage.png") + response.setDateHeader("Last-Modified", new Date(0).getTime()) + textAvatar(account.fullName) + } + }.getOrElse{ + NotFound() } } diff --git a/src/main/scala/gitbucket/core/service/TextAvatarService.scala b/src/main/scala/gitbucket/core/service/TextAvatarService.scala new file mode 100644 index 000000000..58d5870fe --- /dev/null +++ b/src/main/scala/gitbucket/core/service/TextAvatarService.scala @@ -0,0 +1,49 @@ +package gitbucket.core.service + +import java.io.ByteArrayOutputStream +import java.awt.image.BufferedImage +import javax.imageio.ImageIO +import java.awt.{Color, Font, RenderingHints} +import java.awt.font.{FontRenderContext, TextLayout} +import gitbucket.core.util.StringUtil + +trait TextAvatarService { + def textAvatar(nameText: String): Array[Byte] = { + val drawText = nameText.substring(0, 1) + val md5 = StringUtil.md5(nameText) + val hashedInt = Integer.parseInt(md5.substring(0, 2), 16) + + val h = hashedInt / 256f + val bgColor = Color.getHSBColor(h, 1f, 1f) + val fgColor = Color.getHSBColor(h + 0.5f, 1f, 0.8f) + + val size = (200, 200) + val canvas = new BufferedImage(size._1, size._2, BufferedImage.TYPE_INT_ARGB) + val g = canvas.createGraphics() + + g.setColor(new Color(0, 0, 0, 0)) + g.fillRect(0, 0, canvas.getWidth, canvas.getHeight) + g.setColor(bgColor) + g.fillRoundRect(0, 0, canvas.getWidth, canvas.getHeight, 60, 60) + + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + + g.setColor(fgColor) + val font = new Font("SansSerif", Font.PLAIN, 180) + val context = g.getFontRenderContext + val txt = new TextLayout(drawText, font, context) + val bounds = txt.getBounds + + val x: Int = ((size._1 - bounds.getWidth) / 2 - bounds.getX).toInt + val y: Int = ((size._2 - bounds.getHeight) / 2 - bounds.getY).toInt + + g.setFont(font) + g.drawString(drawText, x, y) + + g.dispose() + + val stream = new ByteArrayOutputStream + ImageIO.write(canvas, "png", stream) + stream.toByteArray + } +} From 4d0e0b7bd24fd0552baef7c4320d726064ce232e Mon Sep 17 00:00:00 2001 From: KOUNOIKE Yuusuke Date: Sat, 18 Mar 2017 10:40:48 +0900 Subject: [PATCH 2/3] Change TextAvatarService to TextAvatarUtil, color detection algorithm, Add fallback to username feature when FullName can't draw by default font. --- .../core/controller/AccountController.scala | 9 ++- .../core/service/TextAvatarService.scala | 49 ------------- .../gitbucket/core/util/TextAvatarUtil.scala | 72 +++++++++++++++++++ 3 files changed, 76 insertions(+), 54 deletions(-) delete mode 100644 src/main/scala/gitbucket/core/service/TextAvatarService.scala create mode 100644 src/main/scala/gitbucket/core/util/TextAvatarUtil.scala diff --git a/src/main/scala/gitbucket/core/controller/AccountController.scala b/src/main/scala/gitbucket/core/controller/AccountController.scala index 6af8ab659..fa1023feb 100644 --- a/src/main/scala/gitbucket/core/controller/AccountController.scala +++ b/src/main/scala/gitbucket/core/controller/AccountController.scala @@ -21,13 +21,13 @@ import java.util.Date class AccountController extends AccountControllerBase with AccountService with RepositoryService with ActivityService with WikiService with LabelsService with SshKeyService with OneselfAuthenticator with UsersAuthenticator with GroupManagerAuthenticator with ReadableUsersAuthenticator - with AccessTokenService with WebHookService with RepositoryCreationService with TextAvatarService + with AccessTokenService with WebHookService with RepositoryCreationService trait AccountControllerBase extends AccountManagementControllerBase { self: AccountService with RepositoryService with ActivityService with WikiService with LabelsService with SshKeyService with OneselfAuthenticator with UsersAuthenticator with GroupManagerAuthenticator with ReadableUsersAuthenticator - with AccessTokenService with WebHookService with RepositoryCreationService with TextAvatarService => + with AccessTokenService with WebHookService with RepositoryCreationService => case class AccountNewForm(userName: String, password: String, fullName: String, mailAddress: String, description: Option[String], url: Option[String], fileId: Option[String]) @@ -151,13 +151,12 @@ trait AccountControllerBase extends AccountManagementControllerBase { get("/:userName/_avatar"){ val userName = params("userName") getAccountByUserName(userName).map{ account => + response.setDateHeader("Last-Modified", account.updatedDate.getTime) account.image.map{ image => - response.setDateHeader("Last-Modified", account.updatedDate.getTime) RawData(FileUtil.getMimeType(image), new java.io.File(getUserUploadDir(userName), image)) }.getOrElse{ contentType = "image/png" - response.setDateHeader("Last-Modified", new Date(0).getTime()) - textAvatar(account.fullName) + TextAvatarUtil.textAvatar(account.fullName).getOrElse(Thread.currentThread.getContextClassLoader.getResourceAsStream("noimage.png")) } }.getOrElse{ NotFound() diff --git a/src/main/scala/gitbucket/core/service/TextAvatarService.scala b/src/main/scala/gitbucket/core/service/TextAvatarService.scala deleted file mode 100644 index 58d5870fe..000000000 --- a/src/main/scala/gitbucket/core/service/TextAvatarService.scala +++ /dev/null @@ -1,49 +0,0 @@ -package gitbucket.core.service - -import java.io.ByteArrayOutputStream -import java.awt.image.BufferedImage -import javax.imageio.ImageIO -import java.awt.{Color, Font, RenderingHints} -import java.awt.font.{FontRenderContext, TextLayout} -import gitbucket.core.util.StringUtil - -trait TextAvatarService { - def textAvatar(nameText: String): Array[Byte] = { - val drawText = nameText.substring(0, 1) - val md5 = StringUtil.md5(nameText) - val hashedInt = Integer.parseInt(md5.substring(0, 2), 16) - - val h = hashedInt / 256f - val bgColor = Color.getHSBColor(h, 1f, 1f) - val fgColor = Color.getHSBColor(h + 0.5f, 1f, 0.8f) - - val size = (200, 200) - val canvas = new BufferedImage(size._1, size._2, BufferedImage.TYPE_INT_ARGB) - val g = canvas.createGraphics() - - g.setColor(new Color(0, 0, 0, 0)) - g.fillRect(0, 0, canvas.getWidth, canvas.getHeight) - g.setColor(bgColor) - g.fillRoundRect(0, 0, canvas.getWidth, canvas.getHeight, 60, 60) - - g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) - - g.setColor(fgColor) - val font = new Font("SansSerif", Font.PLAIN, 180) - val context = g.getFontRenderContext - val txt = new TextLayout(drawText, font, context) - val bounds = txt.getBounds - - val x: Int = ((size._1 - bounds.getWidth) / 2 - bounds.getX).toInt - val y: Int = ((size._2 - bounds.getHeight) / 2 - bounds.getY).toInt - - g.setFont(font) - g.drawString(drawText, x, y) - - g.dispose() - - val stream = new ByteArrayOutputStream - ImageIO.write(canvas, "png", stream) - stream.toByteArray - } -} diff --git a/src/main/scala/gitbucket/core/util/TextAvatarUtil.scala b/src/main/scala/gitbucket/core/util/TextAvatarUtil.scala new file mode 100644 index 000000000..2d33259a9 --- /dev/null +++ b/src/main/scala/gitbucket/core/util/TextAvatarUtil.scala @@ -0,0 +1,72 @@ +package gitbucket.core.util + +import java.io.ByteArrayOutputStream +import java.awt.image.BufferedImage +import javax.imageio.ImageIO +import java.awt.{Color, Font, RenderingHints} +import java.awt.font.{FontRenderContext, TextLayout} + +object TextAvatarUtil { + // https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef + private def relativeLuminance(c: Color): Double = { + val rgb = Seq(c.getRed, c.getGreen, c.getBlue).map{_/255.0}.map{x => if (x <= 0.03928) x / 12.92 else math.pow((x + 0.055) / 1.055, 2.4)} + 0.2126 * rgb(0) + 0.7152 * rgb(1) + 0.0722 * rgb(2) + } + + // https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef + private def contrastRatio(c1: Color, c2: Color): Double = { + val l1 = relativeLuminance(c1) + val l2 = relativeLuminance(c2) + if (l1 > l2) (l1 + 0.05) / (l2 + 0.05) else (l2 + 0.05) / (l1 + 0.05) + } + + private def goodContrastColor(base: Color, c1: Color, c2: Color): Color = { + if (contrastRatio(base, c1) > contrastRatio(base, c2)) c1 else c2 + } + + private def textImage(w: Int, h: Int, drawText: String, font: Font, fontSize: Int, bgColor: Color, fgColor: Color): Array[Byte] = { + val canvas = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB) + val g = canvas.createGraphics() + + g.setColor(new Color(0, 0, 0, 0)) + g.fillRect(0, 0, w, h) + g.setColor(bgColor) + g.fillRoundRect(0, 0, w, h, 60, 60) + + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + + g.setColor(fgColor) + val context = g.getFontRenderContext + val txt = new TextLayout(drawText, font, context) + val bounds = txt.getBounds + + val x: Int = ((w - bounds.getWidth) / 2 - bounds.getX).toInt + val y: Int = ((h - bounds.getHeight) / 2 - bounds.getY).toInt + + g.setFont(font) + g.drawString(drawText, x, y) + + g.dispose() + + val stream = new ByteArrayOutputStream + ImageIO.write(canvas, "png", stream) + stream.toByteArray + } + + def textAvatar(nameText: String): Option[Array[Byte]] = { + val drawText = nameText.substring(0, 1) + val md5 = StringUtil.md5(nameText) + val hashedInt = Integer.parseInt(md5.substring(0, 2), 16) + + val bgHue = hashedInt / 256f + val bgSaturation = 0.68f + val bgBlightness = 0.73f + val bgColor = Color.getHSBColor(bgHue, bgSaturation, bgBlightness) + val fgColor = goodContrastColor(bgColor, Color.BLACK, Color.WHITE) + + val size = (200, 200) + val fontSize = 180 + val font = new Font(Font.SANS_SERIF, Font.PLAIN, fontSize) + if (font.canDisplayUpTo(drawText) == -1) Some(textImage(size._1, size._2, drawText, font, fontSize, bgColor, fgColor)) else None + } +} From d27b9222baec145358a1ffcbffe09384a163f782 Mon Sep 17 00:00:00 2001 From: KOUNOIKE Yuusuke Date: Sun, 19 Mar 2017 02:14:26 +0900 Subject: [PATCH 3/3] Different style of avatar for groups. --- .../core/controller/AccountController.scala | 6 +- .../gitbucket/core/util/TextAvatarUtil.scala | 99 ++++++++++++++----- 2 files changed, 82 insertions(+), 23 deletions(-) diff --git a/src/main/scala/gitbucket/core/controller/AccountController.scala b/src/main/scala/gitbucket/core/controller/AccountController.scala index fa1023feb..139470232 100644 --- a/src/main/scala/gitbucket/core/controller/AccountController.scala +++ b/src/main/scala/gitbucket/core/controller/AccountController.scala @@ -156,7 +156,11 @@ trait AccountControllerBase extends AccountManagementControllerBase { RawData(FileUtil.getMimeType(image), new java.io.File(getUserUploadDir(userName), image)) }.getOrElse{ contentType = "image/png" - TextAvatarUtil.textAvatar(account.fullName).getOrElse(Thread.currentThread.getContextClassLoader.getResourceAsStream("noimage.png")) + (if (account.isGroupAccount) { + TextAvatarUtil.textGroupAvatar(account.fullName) + } else { + TextAvatarUtil.textAvatar(account.fullName) + }).getOrElse(Thread.currentThread.getContextClassLoader.getResourceAsStream("noimage.png")) } }.getOrElse{ NotFound() diff --git a/src/main/scala/gitbucket/core/util/TextAvatarUtil.scala b/src/main/scala/gitbucket/core/util/TextAvatarUtil.scala index 2d33259a9..48c03173e 100644 --- a/src/main/scala/gitbucket/core/util/TextAvatarUtil.scala +++ b/src/main/scala/gitbucket/core/util/TextAvatarUtil.scala @@ -3,10 +3,22 @@ package gitbucket.core.util import java.io.ByteArrayOutputStream import java.awt.image.BufferedImage import javax.imageio.ImageIO -import java.awt.{Color, Font, RenderingHints} +import java.awt.{Color, Font, Graphics2D, RenderingHints} import java.awt.font.{FontRenderContext, TextLayout} +import java.awt.geom.AffineTransform + object TextAvatarUtil { + private val iconSize = 200 + private val fontSize = 180 + private val roundSize = 60 + private val shadowSize = 20 + private val bgSaturation = 0.68f + private val bgBlightness = 0.73f + private val shadowBlightness = 0.23f + private val font = new Font(Font.SANS_SERIF, Font.PLAIN, fontSize) + private val transparent = new Color(0, 0, 0, 0) + // https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef private def relativeLuminance(c: Color): Double = { val rgb = Seq(c.getRed, c.getGreen, c.getBlue).map{_/255.0}.map{x => if (x <= 0.03928) x / 12.92 else math.pow((x + 0.055) / 1.055, 2.4)} @@ -24,27 +36,37 @@ object TextAvatarUtil { if (contrastRatio(base, c1) > contrastRatio(base, c2)) c1 else c2 } - private def textImage(w: Int, h: Int, drawText: String, font: Font, fontSize: Int, bgColor: Color, fgColor: Color): Array[Byte] = { - val canvas = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB) - val g = canvas.createGraphics() + private def strToHue(text: String): Float = { + Integer.parseInt(StringUtil.md5(text).substring(0, 2), 16) / 256f + } - g.setColor(new Color(0, 0, 0, 0)) - g.fillRect(0, 0, w, h) - g.setColor(bgColor) - g.fillRoundRect(0, 0, w, h, 60, 60) - - g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) - - g.setColor(fgColor) - val context = g.getFontRenderContext + private def getCenterToDraw(drawText: String, font: Font, w: Int, h: Int): (Int, Int) = { + val context = new FontRenderContext(new AffineTransform(), true, true) val txt = new TextLayout(drawText, font, context) + val bounds = txt.getBounds val x: Int = ((w - bounds.getWidth) / 2 - bounds.getX).toInt val y: Int = ((h - bounds.getHeight) / 2 - bounds.getY).toInt + (x, y) + } + private def textImage(drawText: String, bgColor: Color, fgColor: Color): Array[Byte] = { + val canvas = new BufferedImage(iconSize, iconSize, BufferedImage.TYPE_INT_ARGB) + val g = canvas.createGraphics() + val center = getCenterToDraw(drawText, font, iconSize, iconSize) + + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + + g.setColor(transparent) + g.fillRect(0, 0, iconSize, iconSize) + + g.setColor(bgColor) + g.fillRoundRect(0, 0, iconSize, iconSize, roundSize, roundSize) + + g.setColor(fgColor) g.setFont(font) - g.drawString(drawText, x, y) + g.drawString(drawText, center._1, center._2) g.dispose() @@ -55,18 +77,51 @@ object TextAvatarUtil { def textAvatar(nameText: String): Option[Array[Byte]] = { val drawText = nameText.substring(0, 1) - val md5 = StringUtil.md5(nameText) - val hashedInt = Integer.parseInt(md5.substring(0, 2), 16) - val bgHue = hashedInt / 256f - val bgSaturation = 0.68f - val bgBlightness = 0.73f + val bgHue = strToHue(nameText) val bgColor = Color.getHSBColor(bgHue, bgSaturation, bgBlightness) val fgColor = goodContrastColor(bgColor, Color.BLACK, Color.WHITE) - val size = (200, 200) - val fontSize = 180 val font = new Font(Font.SANS_SERIF, Font.PLAIN, fontSize) - if (font.canDisplayUpTo(drawText) == -1) Some(textImage(size._1, size._2, drawText, font, fontSize, bgColor, fgColor)) else None + if (font.canDisplayUpTo(drawText) == -1) Some(textImage(drawText, bgColor, fgColor)) else None + } + + private def textGroupImage(drawText: String, bgColor: Color, fgColor: Color, shadowColor: Color): Array[Byte] = { + val canvas = new BufferedImage(iconSize, iconSize, BufferedImage.TYPE_INT_ARGB) + val g = canvas.createGraphics() + val center = getCenterToDraw(drawText, font, iconSize - shadowSize, iconSize - shadowSize) + + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + + g.setColor(transparent) + g.fillRect(0, 0, iconSize, iconSize) + + g.setColor(shadowColor) + g.fillRect(shadowSize, shadowSize, iconSize, iconSize) + + g.setColor(bgColor) + g.fillRect(0, 0, iconSize - shadowSize, iconSize - shadowSize) + + g.setColor(fgColor) + + g.setFont(font) + g.drawString(drawText, center._1, center._2) + + g.dispose() + + val stream = new ByteArrayOutputStream + ImageIO.write(canvas, "png", stream) + stream.toByteArray + } + + def textGroupAvatar(nameText: String): Option[Array[Byte]] = { + val drawText = nameText.substring(0, 1) + + val bgHue = strToHue(nameText) + val bgColor = Color.getHSBColor(bgHue, bgSaturation, bgBlightness) + val fgColor = goodContrastColor(bgColor, Color.BLACK, Color.WHITE) + val shadowColor = Color.getHSBColor(bgHue, bgSaturation, shadowBlightness) + + if (font.canDisplayUpTo(drawText) == -1) Some(textGroupImage(drawText, bgColor, fgColor, shadowColor)) else None } }