blob: d2c2a9196b2af48a88d820620130a60718b2e405 [file] [log] [blame]
// Copyright 2012 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "chrome/browser/web_applications/os_integration/mac/icon_utils.h"
#import <Cocoa/Cocoa.h>
#include <array>
#include <queue>
#include "third_party/skia/include/core/SkBitmap.h"
#include "third_party/skia/include/core/SkCanvas.h"
#include "third_party/skia/include/core/SkPaint.h"
#include "third_party/skia/include/core/SkPath.h"
#include "third_party/skia/include/core/SkRRect.h"
#include "third_party/skia/include/core/SkRect.h"
#include "third_party/skia/include/effects/SkImageFilters.h"
#include "third_party/skia/src/core/SkBitmapDevice.h"
#include "third_party/skia/src/core/SkDraw.h"
#include "ui/gfx/canvas.h"
#include "ui/gfx/geometry/rect.h"
#include "ui/gfx/image/image.h"
#include "ui/gfx/skia_util.h"
namespace {
// Returns parameters for Apple's icon grid bounding box and shadow for the
// given size. Values are derived from Apple icon design templates (March 2023
// macOS Production Templates).
struct IconGridParameters {
SkScalar inset;
SkScalar corner_radius;
SkScalar shadow_y_offset;
SkScalar shadow_blur_radius;
};
IconGridParameters GetIconGridParameters(int base_size) {
// According to Apple design templates, a macOS icon should be a rounded
// rect surrounded by some transparent padding. The rounded rect's size
// is approximately 80% of the overall icon, but this percentage varies.
// Exact mask size and shape gleaned from Apple icon design templates,
// specifically the March 2023 macOS Production Templates Sketch file at
// https://developer.apple.com/design/resources/. A few corner radius
// values were unavailable in the file because the relevant shapes were
// represenated as plain paths rather than rounded rects.
//
// The Web App Manifest spec defines a safe zone for maskable icons
// (https://www.w3.org/TR/appmanifest/#icon-masks) in a centered circle
// with diameter 80% of the overall icon. Since the macOS icon mask
// is a rounded rect that is never smaller than 80% of the overall icon,
// it is within spec to simply draw our base icon full size and clip
// whatever is outside of the rounded rect. This is what is currently
// implemented, even though is is different from what Apple does in macOS
// Sonoma web apps (where instead they first scale the icon to cover just
// the rounded rect, only clipping the corners). Somewhere in the middle
// of these options might be ideal, although with the current icon loading
// code icons have already been resized to neatly fill entire standard sized
// icons by the time this code runs, so doing any more resizing here would
// not be great.
switch (base_size) {
case 16:
// An exact value for the 16 corner radius was not available.
// this corner radius is derived by dividing the 32 radius by 2
return {.inset = 1.0,
.corner_radius = 2.785,
.shadow_y_offset = 0.5,
.shadow_blur_radius = 0.5};
case 32:
return {.inset = 2.0,
.corner_radius = 5.75,
.shadow_y_offset = 1.0,
.shadow_blur_radius = 1.0};
case 64:
return {.inset = 6.0,
.corner_radius = 11.5,
.shadow_y_offset = 2.0,
.shadow_blur_radius = 2.0};
case 128:
// An exact value for the 128 corner radius was not available.
// this corner radius is derived by dividing the 256 radius by 2
// or by multiplying the 64 radius by 2, both calculations
// have the same result.
return {.inset = 12.0,
.corner_radius = 23.0,
.shadow_y_offset = 1.25,
.shadow_blur_radius = 1.25};
case 256:
return {.inset = 25.0,
.corner_radius = 46.0,
.shadow_y_offset = 2.5,
.shadow_blur_radius = 2.5};
case 512:
return {.inset = 50.0,
.corner_radius = 92.0,
.shadow_y_offset = 5.0,
.shadow_blur_radius = 5.0};
case 1024:
// An exact value for the 1024 corner radius was not available.
// this corner radius is derived by multiplying the 512 radius by 2
return {.inset = 100.0,
.corner_radius = 184.0,
.shadow_y_offset = 10.0,
.shadow_blur_radius = 10.0};
default:
// Use 1024 sizes as a reference for approximating any size mask if needed
return {
.inset = static_cast<SkScalar>(base_size * 100.0 / 1024.0),
.corner_radius = static_cast<SkScalar>(base_size * 184.0 / 1024.0),
.shadow_y_offset = static_cast<SkScalar>(base_size * 10.0 / 1024.0),
.shadow_blur_radius =
static_cast<SkScalar>(base_size * 10.0 / 1024.0)};
}
}
SkPath CreateMaskingIconGridBoundingBoxPath(int base_size,
const IconGridParameters& params) {
SkPath path;
SkRect rect = SkRect::MakeIWH(base_size, base_size)
.makeInset(params.inset, params.inset);
path.addRoundRect(rect, params.corner_radius, params.corner_radius);
return path;
}
// Scales the given icon down to fit appropriately within the masked area.
gfx::Image ScaleDownInsideMask(const gfx::Image& icon,
IconGridParameters grid_params) {
const int width = icon.Width();
// Always scale the icon to 10% of the mask area - this means the corners may
// be slightly clipped, but this looks good, and scaling down further is too
// small.
int masked_width = width - 2 * grid_params.inset;
int margin = grid_params.inset + static_cast<int>(masked_width * 0.1f);
gfx::Canvas canvas(gfx::Size(width, width), 1.0f, /*is_opaque=*/false);
canvas.sk_canvas()->clear(SkColor4f{0, 0, 0, 0});
const gfx::RectF dst_rect(margin, margin, width - 2 * margin,
width - 2 * margin);
canvas.DrawImageInt(icon.AsImageSkia(),
/*src_x=*/0, /*src_y=*/0, width, width, dst_rect.x(),
dst_rect.y(), dst_rect.width(), dst_rect.height(),
/*filter=*/true);
return gfx::Image::CreateFrom1xBitmap(canvas.GetBitmap());
}
// Checks that all pixels outside of the given mask are within
// `max_non_alpha_deviation`. All pixels less than `min_alpha` are ignored to
// help prevent false negatives.
bool AreColorsOutsideMaskWithinDeviation(const SkBitmap& icon,
const SkPath& mask_path,
uint8_t min_alpha,
int max_non_alpha_deviation) {
if (!icon.peekPixels(nullptr)) {
return false;
}
std::array<uint8_t, 3> min_components = {255, 255, 255}; // R, G, B
std::array<uint8_t, 3> max_components = {0, 0, 0};
SkPixmap pixmap;
icon.peekPixels(&pixmap);
for (int x = 0; x < icon.width(); ++x) {
for (int y = 0; y < icon.height(); ++y) {
if (mask_path.contains(SkIntToScalar(x), SkIntToScalar(y))) {
continue; // Skip pixels inside the mask
}
SkColor current_color = pixmap.getColor(x, y);
if (SkColorGetA(current_color) < min_alpha) {
continue;
}
min_components[0] =
std::min<uint8_t>(min_components[0], SkColorGetR(current_color));
min_components[1] =
std::min<uint8_t>(min_components[1], SkColorGetG(current_color));
min_components[2] =
std::min<uint8_t>(min_components[2], SkColorGetB(current_color));
max_components[0] =
std::max<uint8_t>(max_components[0], SkColorGetR(current_color));
max_components[1] =
std::max<uint8_t>(max_components[1], SkColorGetG(current_color));
max_components[2] =
std::max<uint8_t>(max_components[2], SkColorGetB(current_color));
for (size_t i = 0; i < 3; ++i) {
if (max_components[i] - min_components[i] > max_non_alpha_deviation) {
return false;
}
}
}
}
return true;
}
gfx::Image CreateAppleMaskedAppIconWithPath(
const gfx::Image& base_icon,
const SkPath& icon_grid_bounding_box_path) {
int base_size = base_icon.Width();
SkImageInfo info =
SkImageInfo::Make(base_size, base_size, SkColorType::kN32_SkColorType,
SkAlphaType::kUnpremul_SkAlphaType);
SkBitmap bitmap;
bitmap.allocPixels(info);
SkCanvas canvas(bitmap);
SkRect base_rect = SkRect::MakeIWH(base_size, base_size);
// Draw the shadow
SkPaint shadowPaint;
shadowPaint.setStyle(SkPaint::kFill_Style);
shadowPaint.setARGB(77, 0, 0, 0);
IconGridParameters params = GetIconGridParameters(base_size);
shadowPaint.setImageFilter(SkImageFilters::Blur(
params.shadow_blur_radius, params.shadow_blur_radius, nullptr));
canvas.save();
canvas.translate(0, params.shadow_y_offset);
canvas.drawPath(icon_grid_bounding_box_path, shadowPaint);
canvas.restore();
// Clip to the mask
canvas.clipPath(icon_grid_bounding_box_path, /*doAntiAlias=*/true);
// Draw the base icon on a white background
// If the base icon is opaque, we shouldn't see any white. Unfortunately,
// first filling the clip with white and then overlaying the base icon
// results in white artifacts around the corners. So, we'll use an unclipped
// intermediate canvas to overlay the base icon on a full white background,
// and then draw the intermediate canvas in the clip in one shot to avoid
// white around the edges.
SkBitmap opaque_bitmap;
opaque_bitmap.allocPixels(info);
SkCanvas opaque_canvas(opaque_bitmap);
SkPaint backgroundPaint;
backgroundPaint.setStyle(SkPaint::kFill_Style);
backgroundPaint.setARGB(255, 255, 255, 255);
opaque_canvas.drawRect(base_rect, backgroundPaint);
opaque_canvas.drawImage(SkImages::RasterFromBitmap(base_icon.AsBitmap()), 0,
0);
canvas.drawImage(SkImages::RasterFromBitmap(opaque_bitmap), 0, 0);
return gfx::Image::CreateFrom1xBitmap(bitmap);
}
} // namespace
namespace web_app {
namespace testing {
namespace {
bool g_disable_icon_masking_for_testing = false;
} // namespace
base::AutoReset<bool> SetDisableIconMaskingForTesting(bool disabled) {
return base::AutoReset<bool>(&g_disable_icon_masking_for_testing, disabled);
}
} // namespace testing
gfx::Image CreateAppleMaskedAppIcon(const gfx::Image& base_icon) {
int base_size = base_icon.Width();
IconGridParameters params = GetIconGridParameters(base_size);
SkPath icon_grid_bounding_box_path =
CreateMaskingIconGridBoundingBoxPath(base_size, params);
return CreateAppleMaskedAppIconWithPath(base_icon,
icon_grid_bounding_box_path);
}
NSImageRep* OverlayImageRep(NSImage* background, NSImageRep* overlay) {
DCHECK(background);
NSInteger dimension = overlay.pixelsWide;
DCHECK_EQ(dimension, overlay.pixelsHigh);
NSBitmapImageRep* canvas = [[NSBitmapImageRep alloc]
initWithBitmapDataPlanes:nullptr
pixelsWide:dimension
pixelsHigh:dimension
bitsPerSample:8
samplesPerPixel:4
hasAlpha:YES
isPlanar:NO
colorSpaceName:NSCalibratedRGBColorSpace
bytesPerRow:0
bitsPerPixel:0];
// There isn't a colorspace name constant for sRGB, so retag.
canvas = [canvas
bitmapImageRepByRetaggingWithColorSpace:NSColorSpace.sRGBColorSpace];
// Communicate the DIP scale (1.0). TODO(tapted): Investigate HiDPI.
canvas.size = NSMakeSize(dimension, dimension);
NSGraphicsContext* drawing_context =
[NSGraphicsContext graphicsContextWithBitmapImageRep:canvas];
[NSGraphicsContext saveGraphicsState];
NSGraphicsContext.currentContext = drawing_context;
[background drawInRect:NSMakeRect(0, 0, dimension, dimension)
fromRect:NSZeroRect
operation:NSCompositingOperationCopy
fraction:1.0];
[overlay drawInRect:NSMakeRect(0, 0, dimension, dimension)
fromRect:NSZeroRect
operation:NSCompositingOperationSourceOver
fraction:1.0
respectFlipped:NO
hints:nil];
[NSGraphicsContext restoreGraphicsState];
return canvas;
}
gfx::Image MaskDiyAppIcon(const gfx::Image& icon) {
if (testing::g_disable_icon_masking_for_testing) {
return icon;
}
// If the alpha value of the color is less than this value then it is ignored
constexpr uint8_t kMinAlpha = 1;
// Maximum allowed deviation between RGB components of colors outside the mask
constexpr int kMaxNonAlphaDeviation = 5;
IconGridParameters grid_params = GetIconGridParameters(icon.Width());
SkPath mask = CreateMaskingIconGridBoundingBoxPath(icon.Width(), grid_params);
if (AreColorsOutsideMaskWithinDeviation(*icon.ToSkBitmap(), mask, kMinAlpha,
kMaxNonAlphaDeviation)) {
return CreateAppleMaskedAppIconWithPath(icon, mask);
} else {
gfx::Image scaled_icon = ScaleDownInsideMask(icon, grid_params);
return CreateAppleMaskedAppIconWithPath(scaled_icon, mask);
}
}
} // namespace web_app