common/app/views/support/ImageProfile.scala (356 lines of code) (raw):

package views.support import java.net.{URI, URISyntaxException} import java.util.Base64 import common.GuLogging import conf.switches.Switches.{ImageServerSwitch} import conf.{Configuration} import layout.{BreakpointWidth, WidthsByBreakpoint} import model._ import org.apache.commons.lang3.math.Fraction import play.api.libs.json.{Json, Writes} import Function.const /** ElementProfile is configuration for displaying an image, and is used to generate custom URLs (with parameters) for * calls to our Fastly image service. */ sealed trait ElementProfile { def width: Option[Int] def height: Option[Int] def hidpi: Boolean def compression: Int def isPng: Boolean def autoFormat: Boolean private def toSrc(maybeAsset: Option[ImageAsset]): Option[String] = maybeAsset.flatMap(_.url).map(ImgSrc(_, this)) def bestFor(image: ImageMedia): Option[ImageAsset] = { if (!ImageServerSwitch.isSwitchedOn) { val sortedCrops = image.imageCrops.sortBy(-_.width) width .flatMap { desiredWidth => sortedCrops.find(_.width >= desiredWidth) } .orElse(image.largestImage) } else image.largestImage } def bestSrcFor(image: ImageMedia): Option[String] = toSrc(bestFor(image)) def captionFor(image: ImageMedia): Option[String] = bestFor(image).flatMap(_.caption) def altTextFor(image: ImageMedia): Option[String] = bestFor(image).flatMap(_.altText) // NOTE - if you modify this in any way there is a decent chance that you decache all our images :( val qualityparam: String = if (hidpi) { "quality=45" } else { "quality=85" } val autoParam: String = if (autoFormat) "auto=format" else "" val fitParam = "fit=max" val dprParam: String = if (hidpi) { if (isPng) { "dpr=1.3" } else { "dpr=2" } } else { "" } val heightParam: String = height.map(pixels => s"height=$pixels").getOrElse("") val widthParam: String = width.map(pixels => s"width=$pixels").getOrElse("") val sharpenParam: String = "" def resizeString: String = { val params = Seq(widthParam, heightParam, qualityparam, autoParam, fitParam, dprParam, sharpenParam) .filter(_.nonEmpty) .mkString("&") s"?$params" } } /** ImageProfile is an ElementProfile that provides sensible defaults. It is used as the base class for lots of more * specific profiles. */ case class ImageProfile( override val width: Option[Int] = None, override val height: Option[Int] = None, override val hidpi: Boolean = false, override val compression: Int = 95, override val isPng: Boolean = false, override val autoFormat: Boolean = true, ) extends ElementProfile object VideoProfile { lazy val ratioHD = Fraction.getFraction(16, 9) } case class VideoProfile( override val width: Some[Int], override val height: Option[Int] = None, override val hidpi: Boolean = false, override val compression: Int = 95, override val isPng: Boolean = false, override val autoFormat: Boolean = true, ) extends ElementProfile {} /** SrcSet relates to the HTML `srcSet` attribute but only represents a single image source-width combo. For information * on the HTML attribute, see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-srcset. */ case class SrcSet(src: String, width: Int) { def asSrcSetString: String = { s"$src ${width}w" } } object SrcSet { implicit val srcSetWrites: Writes[SrcSet] = Json.writes[SrcSet] def asSrcSetString(srcSets: Seq[SrcSet]): String = { srcSets.map(_.asSrcSetString).mkString(", ") } } // Configuration of our different image profiles object Contributor extends ImageProfile(width = Some(140), height = Some(140)) object RichLinkContributor extends ImageProfile(width = Some(173)) object Item120 extends ImageProfile(width = Some(120)) object Item140 extends ImageProfile(width = Some(140)) object Item300 extends ImageProfile(width = Some(300)) object Item460 extends ImageProfile(width = Some(460)) object Item620 extends ImageProfile(width = Some(620)) object Item640 extends ImageProfile(width = Some(640)) object Item700 extends ImageProfile(width = Some(700)) object Item1200 extends ImageProfile(width = Some(1200)) object Video640 extends VideoProfile(width = Some(640)) object Video700 extends VideoProfile(width = Some(700)) object Video1280 extends VideoProfile(width = Some(1280)) object GoogleStructuredData extends ImageProfile(width = Some(300), height = Some(300)) // 1:1 // Used for AMP image structured data - see // https://developers.google.com/search/docs/data-types/article#article_types // and the image advice. object OneByOne extends ImageProfile(width = Some(1200), height = Some(1200)) { override val fitParam: String = "fit=crop" } object FourByThree extends ImageProfile(width = Some(1200), height = Some(900)) { override val fitParam: String = "fit=crop" } class ShareImage( overlayUrlParam: String, shouldIncludeOverlay: Boolean, shouldUpscale: Boolean = false, ) extends ImageProfile(width = Some(1200)) { override val heightParam = "height=630" override val fitParam = "fit=crop" val overlayAlignParam = "overlay-align=bottom%2Cleft" val overlayWidthParam = "overlay-width=100p" override def resizeString: String = { if (shouldIncludeOverlay) { val params = Seq( widthParam, heightParam, qualityparam, autoParam, fitParam, dprParam, overlayAlignParam, overlayWidthParam, overlayUrlParam, ).filter(_.nonEmpty).mkString("&") if (shouldUpscale) s"?$params&enable=upscale" else s"?$params" } else { super.resizeString } } } sealed trait ShareImageCategory case object GuardianDefault extends ShareImageCategory case object ObserverDefault extends ShareImageCategory case class CommentObserverOldContent(publicationYear: Int) extends ShareImageCategory case class CommentGuardianOldContent(publicationYear: Int) extends ShareImageCategory case object ObserverOpinion extends ShareImageCategory case object GuardianOpinion extends ShareImageCategory case object Live extends ShareImageCategory case class ObserverOldContent(publicationYear: Int) extends ShareImageCategory case class GuardianOldContent(publicationYear: Int) extends ShareImageCategory case class ObserverStarRating(rating: Int) extends ShareImageCategory case class GuardianStarRating(rating: Int) extends ShareImageCategory case object Paid extends ShareImageCategory trait OverlayBase64 { def overlayUrlBase64(overlay: String): String = Base64.getUrlEncoder.encodeToString(s"/img/static/overlays/$overlay".getBytes).replace("=", "") } object OpenGraphImage extends OverlayBase64 { def forCategory( category: ShareImageCategory, shouldIncludeOverlay: Boolean, shouldUpscale: Boolean = false, ): ElementProfile = { category match { case GuardianDefault => new ShareImage(s"overlay-base64=${overlayUrlBase64("tg-default.png")}", shouldIncludeOverlay, shouldUpscale) case ObserverDefault => new ShareImage(s"overlay-base64=${overlayUrlBase64("to-default.png")}", shouldIncludeOverlay, shouldUpscale) case ObserverOpinion => new ShareImage(s"overlay-base64=${overlayUrlBase64("to-opinions.png")}", shouldIncludeOverlay, shouldUpscale) case GuardianOpinion => new ShareImage(s"overlay-base64=${overlayUrlBase64("tg-opinions.png")}", shouldIncludeOverlay, shouldUpscale) case Live => new ShareImage(s"overlay-base64=${overlayUrlBase64("tg-live.png")}", shouldIncludeOverlay, shouldUpscale) case CommentObserverOldContent(year) => contentAgeNoticeCommentObserver(year, shouldIncludeOverlay, shouldUpscale) case CommentGuardianOldContent(year) => contentAgeNoticeComment(year, shouldIncludeOverlay, shouldUpscale) case ObserverOldContent(year) => contentAgeNoticeObserver(year, shouldIncludeOverlay, shouldUpscale) case GuardianOldContent(year) => contentAgeNotice(year, shouldIncludeOverlay, shouldUpscale) case ObserverStarRating(rating) => starRatingObserver(rating, shouldIncludeOverlay, shouldUpscale) case GuardianStarRating(rating) => starRating(rating, shouldIncludeOverlay, shouldUpscale) case Paid => Item700 } } private[this] def starRating( rating: Int, shouldIncludeOverlay: Boolean, shouldUpscale: Boolean = false, ): ShareImage = { val image = rating match { case x if 0 to 5 contains x => s"overlay-base64=${overlayUrlBase64(s"tg-review-$x.png")}" case _ => s"overlay-base64=${overlayUrlBase64("tg-default.png")}" } new ShareImage(image, shouldIncludeOverlay, shouldUpscale) } private[this] def starRatingObserver( rating: Int, shouldIncludeOverlay: Boolean, shouldUpscale: Boolean = false, ): ShareImage = { val image = rating match { case x if 0 to 5 contains x => s"overlay-base64=${overlayUrlBase64(s"to-review-$x.png")}" case _ => s"overlay-base64=${overlayUrlBase64("to-default.png")}" } new ShareImage(image, shouldIncludeOverlay, shouldUpscale) } private[this] def getContentAgeFileName(prefix: String, publicationYear: Int): String = { // WARNING: we have only produced these content age images up to the year 2025 if (publicationYear < 2025) { s"${prefix}-age-${publicationYear}.png" } else { s"${prefix}-default.png" } } private[this] def contentAgeNotice( publicationYear: Int, shouldIncludeOverlay: Boolean, shouldUpscale: Boolean = false, ): ShareImage = { val image = s"overlay-base64=${overlayUrlBase64(getContentAgeFileName("tg", publicationYear))}" new ShareImage(image, shouldIncludeOverlay, shouldUpscale) } private[this] def contentAgeNoticeObserver( publicationYear: Int, shouldIncludeOverlay: Boolean, shouldUpscale: Boolean, ): ShareImage = { val image = s"overlay-base64=${overlayUrlBase64(getContentAgeFileName("to", publicationYear))}" new ShareImage(image, shouldIncludeOverlay, shouldUpscale) } private[this] def contentAgeNoticeComment( publicationYear: Int, shouldIncludeOverlay: Boolean, shouldUpscale: Boolean = false, ): ShareImage = { val image = s"overlay-base64=${overlayUrlBase64(getContentAgeFileName("tg-opinions", publicationYear))}" new ShareImage(image, shouldIncludeOverlay, shouldUpscale) } private[this] def contentAgeNoticeCommentObserver( publicationYear: Int, shouldIncludeOverlay: Boolean, shouldUpscale: Boolean, ): ShareImage = { val image = s"overlay-base64=${overlayUrlBase64(getContentAgeFileName("to-opinions", publicationYear))}" new ShareImage(image, shouldIncludeOverlay, shouldUpscale) } } object EmailImage extends ImageProfile(width = Some(EmailImageParams.articleFullWidth), autoFormat = false) { override val qualityparam: String = EmailImageParams.qualityParam override val sharpenParam: String = EmailImageParams.sharpenParam override val dprParam: String = EmailImageParams.dprParam val knownWidth: Int = width.get } object EmailVideoImage extends ImageProfile(width = Some(EmailImageParams.videoFullWidth), autoFormat = false) with OverlayBase64 { override val qualityparam: String = EmailImage.qualityparam override val dprParam: String = EmailImageParams.dprParam val overlayAlignParam = "overlay-align=bottom,left" val overlayUrlParam = s"overlay-base64=${overlayUrlBase64("playx2.png")}" val knownWidth: Int = width.get override def resizeString: String = { val params = Seq(widthParam, heightParam, qualityparam, autoParam, dprParam, overlayAlignParam, overlayUrlParam) .filter(_.nonEmpty) .mkString("&") s"?$params" } } object EmailImageParams { val qualityParam: String = "quality=45" val sharpenParam: String = "sharpen=a0.8,r1,t1" val fullWidth: Int = 500 val articleFullWidth: Int = 580 val videoFullWidth: Int = 560 val dprParam: String = "dpr=2" } object FrontEmailImage { def apply(customWidth: Int): FrontEmailImage = new FrontEmailImage(customWidth) } class FrontEmailImage(customWidth: Int) extends ImageProfile(Some(customWidth), autoFormat = false) { override val dprParam: String = EmailImageParams.dprParam override val qualityparam: String = EmailImageParams.qualityParam override val sharpenParam: String = EmailImageParams.sharpenParam } // The imager/images.js base image. object SeoOptimisedContentImage extends ImageProfile(width = Some(460)) // Just degrade the image quality without adjusting the width/height object Naked extends ImageProfile(None, None) object ImgSrc extends GuLogging with implicits.Strings { private val imageServiceHost: String = Configuration.images.host private lazy val hostPrefixMapping: Map[String, String] = Map( "static.guim.co.uk" -> "static", "static-secure.guim.co.uk" -> "static", "media.guim.co.uk" -> "media", "uploads.guim.co.uk" -> "uploads", ) private val supportedImages = Set(".jpg", ".jpeg", ".png") def apply( url: String, imageType: ElementProfile, ): String = { try { val uri = new URI(url.trim.encodeURI) val isSupportedImage = supportedImages.exists(extension => uri.getPath.toLowerCase.endsWith(extension)) hostPrefixMapping .get(uri.getHost) .filter(const(ImageServerSwitch.isSwitchedOn)) .filter(const(isSupportedImage)) .map { hostPrefix => val signedPath = ImageUrlSigner.sign(s"${uri.getRawPath}${imageType.resizeString}") s"$imageServiceHost/img/$hostPrefix$signedPath" } .getOrElse(url) } catch { case error: URISyntaxException => log.error("Unable to decode image url", error) url } } def srcset(imageContainer: ImageMedia, widths: WidthsByBreakpoint): String = { widths.profiles.map { profile => srcsetForProfile(profile, imageContainer, hidpi = false).asSrcSetString } mkString ", " } def srcsetForBreakpoint( breakpointWidth: BreakpointWidth, breakpointWidths: Seq[BreakpointWidth], maybePath: Option[String] = None, maybeImageMedia: Option[ImageMedia] = None, hidpi: Boolean = false, ): Seq[SrcSet] = { val isPng = maybePath.exists(path => path.toLowerCase.endsWith("png")) breakpointWidth .toPixels(breakpointWidths) .map(browserWidth => ImageProfile(width = Some(browserWidth), hidpi = hidpi, isPng = isPng)) .flatMap { profile => { maybePath .map(url => srcsetForProfile(profile, url, hidpi)) .orElse(maybeImageMedia.map(imageContainer => srcsetForProfile(profile, imageContainer, hidpi))) } } } def srcsetForProfile( profile: ImageProfile, imageContainer: ImageMedia, hidpi: Boolean, ): SrcSet = SrcSet(profile.bestSrcFor(imageContainer).getOrElse("unknown"), profile.width.get * (if (hidpi) 2 else 1)) // profile.width really shouldn't be None, but if it is use the inline image desktop default width def srcsetForProfile(profile: ImageProfile, path: String, hidpi: Boolean): SrcSet = SrcSet(ImgSrc(path, profile), profile.width.getOrElse(620) * (if (hidpi) 2 else 1)) def getFallbackUrl(ImageElement: ImageMedia): Option[String] = Item300.bestSrcFor(ImageElement) def getAmpImageUrl(ImageElement: ImageMedia): Option[String] = Item620.bestSrcFor(ImageElement) def getFallbackAsset(ImageElement: ImageMedia): Option[ImageAsset] = Item300.bestFor(ImageElement) } object SeoThumbnail { def apply(page: Page): Option[String] = page match { case content: ContentPage => content.item.elements.thumbnail.flatMap(thumbnail => Item620.bestSrcFor(thumbnail.images)) case _ => None } }