|
| 1 | +""" |
| 2 | +Banner image generator for the macOS Security Compliance Project (mSCP). |
| 3 | +
|
| 4 | +Produces per-platform banner images (macOS, iOS, visionOS) in light, dark, |
| 5 | +and high-contrast (8 px stroke) modes by compositing the mSCP logo with |
| 6 | +platform-specific text and a rounded colored divider. |
| 7 | +
|
| 8 | +Background removal uses a BFS flood-fill from image edges (``isolate_logo``). |
| 9 | +Logo pixels are recolored per platform via ``change_color``. |
| 10 | +
|
| 11 | +Public entry point: ``generate_mscp_banners()`` |
| 12 | +
|
| 13 | +Configuration keys consumed from ``config``: |
| 14 | + images_dir — directory containing ``mscp_logo.png``; output is written here. |
| 15 | +
|
| 16 | +Output filename pattern: |
| 17 | + mscp_banner_<platform>_<mode>.png |
| 18 | + where platform ∈ {macos, ios, visionos} and mode ∈ {light, dark, both_8px}. |
| 19 | +""" |
| 20 | + |
| 21 | +import argparse |
| 22 | +from collections import deque |
| 23 | +from pathlib import Path |
| 24 | + |
| 25 | +from PIL import Image, ImageDraw, ImageFont |
| 26 | +from yaspin.core import Yaspin |
| 27 | +from yaspin.spinners import Spinners |
| 28 | + |
| 29 | +from ..common_utils import ( |
| 30 | + config, |
| 31 | + logger, |
| 32 | + conditional_inject_spinner, |
| 33 | +) |
| 34 | + |
| 35 | +LIGHT = {"fill": (0, 0, 0, 255), "stroke_fill": None, "stroke_width": 0} |
| 36 | +DARK = {"fill": (255, 255, 255, 255), "stroke_fill": None, "stroke_width": 0} |
| 37 | +BOTH_8PX = { |
| 38 | + "fill": (255, 255, 255, 255), |
| 39 | + "stroke_fill": (0, 0, 0, 255), |
| 40 | + "stroke_width": 8, |
| 41 | +} |
| 42 | + |
| 43 | +MSCP_GREEN = (41, 99, 54, 255) |
| 44 | +PLATFORMS = ["macOS", "iOS", "visionOS"] |
| 45 | +PLATFORM_COLORS = { |
| 46 | + "macOS": (41, 99, 54, 255), |
| 47 | + "iOS": (88, 86, 214, 255), |
| 48 | + "visionOS": (255, 45, 85, 255), |
| 49 | +} |
| 50 | + |
| 51 | +SUFFIX = "Security Configuration Guide" |
| 52 | +SUBTITLE = "as generated by the macOS Security Compliance Project" |
| 53 | +MODES = {"light": LIGHT, "dark": DARK, "both_8px": BOTH_8PX} |
| 54 | +FONT = "HelveticaNeue.ttc" |
| 55 | + |
| 56 | +BANNER_HEIGHT = 420 |
| 57 | +LOGO_LEFT_MARGIN = 110 |
| 58 | +LOGO_RIGHT_PADDING = 70 |
| 59 | +DIVIDER_GAP_LEFT = 25 |
| 60 | +DIVIDER_GAP_RIGHT = 25 |
| 61 | +DIVIDER_WIDTH = 8 |
| 62 | +DIVIDER_HEIGHT = 92 |
| 63 | +GAP_BETWEEN = 22 |
| 64 | +CANVAS_RIGHT_PADDING = 40 |
| 65 | + |
| 66 | + |
| 67 | +def color_dist(a: tuple, b: tuple) -> int: |
| 68 | + return sum(abs(a[i] - b[i]) for i in range(3)) |
| 69 | + |
| 70 | + |
| 71 | +def isolate_logo(logo_path: str, threshold: int = 40) -> Image.Image: |
| 72 | + """Remove the background of a logo image using a flood-fill from the edges. |
| 73 | +
|
| 74 | + Assumes the top-left pixel is representative of the background color. |
| 75 | + Edge-reachable pixels within *threshold* color distance (sum of absolute |
| 76 | + RGB channel differences) of that background are made fully transparent. |
| 77 | + The result is cropped to the tightest bounding box of remaining opaque content. |
| 78 | +
|
| 79 | + Args: |
| 80 | + logo_path: Path to the source image (any PIL-supported format). |
| 81 | + threshold: Maximum RGB channel distance to treat a pixel as background. |
| 82 | +
|
| 83 | + Returns: |
| 84 | + RGBA Image with background removed and cropped to content bounds. |
| 85 | + """ |
| 86 | + img = Image.open(logo_path).convert("RGBA") |
| 87 | + w, h = img.size |
| 88 | + src_pixels = img.load() |
| 89 | + bg = img.getpixel((0, 0)) # top-left pixel assumed to be background |
| 90 | + |
| 91 | + # visited is indexed [x][y] to match PIL's (x, y) convention |
| 92 | + visited = [[False] * h for _ in range(w)] |
| 93 | + q = deque() |
| 94 | + |
| 95 | + for x in range(w): |
| 96 | + for y in (0, h - 1): |
| 97 | + if not visited[x][y] and color_dist(src_pixels[x, y], bg) <= threshold: |
| 98 | + visited[x][y] = True |
| 99 | + q.append((x, y)) |
| 100 | + for y in range(h): |
| 101 | + for x in (0, w - 1): |
| 102 | + if not visited[x][y] and color_dist(src_pixels[x, y], bg) <= threshold: |
| 103 | + visited[x][y] = True |
| 104 | + q.append((x, y)) |
| 105 | + |
| 106 | + while q: |
| 107 | + x, y = q.popleft() |
| 108 | + for nx, ny in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)): |
| 109 | + if 0 <= nx < w and 0 <= ny < h and not visited[nx][ny]: |
| 110 | + if color_dist(src_pixels[nx, ny], bg) <= threshold: |
| 111 | + visited[nx][ny] = True |
| 112 | + q.append((nx, ny)) |
| 113 | + |
| 114 | + out = img.copy() |
| 115 | + out_pixels = out.load() |
| 116 | + for x in range(w): |
| 117 | + for y in range(h): |
| 118 | + if visited[x][y]: |
| 119 | + out_pixels[x, y] = (0, 0, 0, 0) |
| 120 | + |
| 121 | + bbox = out.getchannel("A").getbbox() |
| 122 | + return out.crop(bbox) |
| 123 | + |
| 124 | + |
| 125 | +def font( |
| 126 | + font_path: str, size: int, bold: bool = False, italic: bool = False |
| 127 | +) -> ImageFont.FreeTypeFont: |
| 128 | + # TTC index: 0=regular, 1=bold, 2=italic |
| 129 | + index = 2 if italic else (1 if bold else 0) |
| 130 | + return ImageFont.truetype(font_path, size, index=index) |
| 131 | + |
| 132 | + |
| 133 | +def draw_text( |
| 134 | + draw: ImageDraw.ImageDraw, |
| 135 | + xy: tuple, |
| 136 | + text: str, |
| 137 | + fnt: ImageFont.FreeTypeFont, |
| 138 | + style: dict, |
| 139 | +) -> None: |
| 140 | + kwargs = {"font": fnt, "fill": style["fill"]} |
| 141 | + if style.get("stroke_fill") is not None and style.get("stroke_width", 0) > 0: |
| 142 | + kwargs["stroke_fill"] = style["stroke_fill"] |
| 143 | + kwargs["stroke_width"] = style["stroke_width"] |
| 144 | + draw.text(xy, text, **kwargs) |
| 145 | + |
| 146 | + |
| 147 | +def change_color(image: Image.Image, old_color: tuple, new_color: tuple, tolerance: int = 0) -> Image.Image: |
| 148 | + pixels = image.load() |
| 149 | + width, height = image.size |
| 150 | + |
| 151 | + for y in range(height): |
| 152 | + for x in range(width): |
| 153 | + r, g, b, a = pixels[x, y] |
| 154 | + if a == 0: |
| 155 | + continue |
| 156 | + if ( |
| 157 | + abs(r - old_color[0]) <= tolerance |
| 158 | + and abs(g - old_color[1]) <= tolerance |
| 159 | + and abs(b - old_color[2]) <= tolerance |
| 160 | + ): |
| 161 | + pixels[x, y] = new_color |
| 162 | + return image |
| 163 | + |
| 164 | + |
| 165 | +@conditional_inject_spinner() |
| 166 | +def generate_mscp_banners(sp: Yaspin, args: argparse.Namespace) -> None: |
| 167 | + """Generate MSCP banner images for all supported platforms and display modes. |
| 168 | +
|
| 169 | + Reads ``mscp_logo.png`` from ``config["images_dir"]``, removes its background, |
| 170 | + recolors it per platform, and composites it with title text onto a transparent |
| 171 | + RGBA canvas. Nine files are written in total (3 platforms × 3 modes). |
| 172 | +
|
| 173 | + Args: |
| 174 | + sp: Yaspin spinner instance injected by ``@conditional_inject_spinner``. |
| 175 | + args: Parsed CLI arguments from argparse. |
| 176 | + """ |
| 177 | + logger.info("Generating MSCP banners...") |
| 178 | + sp.spinner = Spinners.dots |
| 179 | + sp.text = "Generating MSCP banners" |
| 180 | + |
| 181 | + outdir = Path(config["images_dir"]) |
| 182 | + logo = isolate_logo(Path(config["images_dir"]) / "mscp_logo.png") |
| 183 | + |
| 184 | + platform_font = font(FONT, 72, bold=True) |
| 185 | + suffix_font = font(FONT, 62) |
| 186 | + subtitle_font = font(FONT, 24, italic=True) |
| 187 | + |
| 188 | + probe = Image.new("RGBA", (10, 10), (0, 0, 0, 0)) |
| 189 | + dp = ImageDraw.Draw(probe) |
| 190 | + |
| 191 | + platform_widths = {} |
| 192 | + platform_heights = {} |
| 193 | + max_platform_h = 0 |
| 194 | + for p in PLATFORMS: |
| 195 | + bb = dp.textbbox((0, 0), p, font=platform_font) |
| 196 | + platform_widths[p] = bb[2] - bb[0] |
| 197 | + platform_heights[p] = bb[3] - bb[1] |
| 198 | + max_platform_h = max(max_platform_h, platform_heights[p]) |
| 199 | + |
| 200 | + suffix_bb = dp.textbbox((0, 0), SUFFIX, font=suffix_font) |
| 201 | + suffix_h = suffix_bb[3] - suffix_bb[1] |
| 202 | + suffix_w = suffix_bb[2] - suffix_bb[0] |
| 203 | + |
| 204 | + block_h = max_platform_h + GAP_BETWEEN + suffix_h |
| 205 | + |
| 206 | + logo_w, logo_h = logo.size |
| 207 | + resized_logo_h = BANNER_HEIGHT - 40 |
| 208 | + resized_logo_w = int(logo_w * (resized_logo_h / logo_h)) |
| 209 | + logo_resized = logo.resize((resized_logo_w, resized_logo_h), Image.LANCZOS) |
| 210 | + |
| 211 | + logo_x = LOGO_LEFT_MARGIN |
| 212 | + logo_y = (BANNER_HEIGHT - logo_resized.height) // 2 |
| 213 | + text_left = logo_x + logo_resized.width + LOGO_RIGHT_PADDING |
| 214 | + top_y = logo_y + (logo_resized.height - block_h) // 2 + 10 |
| 215 | + |
| 216 | + text_block_h = max(max(platform_heights.values()), suffix_h) |
| 217 | + platform_y = (BANNER_HEIGHT - text_block_h) // 2 - 6 |
| 218 | + suffix_y = platform_y + 8 |
| 219 | + |
| 220 | + divider_y = (BANNER_HEIGHT - DIVIDER_HEIGHT) // 2 + 8 |
| 221 | + |
| 222 | + count = 0 |
| 223 | + for p in PLATFORMS: |
| 224 | + logger.info(f"Generating banner for {p}...") |
| 225 | + platform_w = platform_widths[p] |
| 226 | + |
| 227 | + divider_x = text_left + platform_w + DIVIDER_GAP_LEFT |
| 228 | + suffix_x = divider_x + DIVIDER_GAP_RIGHT + DIVIDER_WIDTH |
| 229 | + |
| 230 | + colored_logo = change_color( |
| 231 | + logo_resized.copy(), |
| 232 | + MSCP_GREEN[:3], |
| 233 | + PLATFORM_COLORS.get(p, MSCP_GREEN[:3]), |
| 234 | + tolerance=25, |
| 235 | + ) |
| 236 | + |
| 237 | + canvas_width = ( |
| 238 | + text_left |
| 239 | + + platform_w |
| 240 | + + DIVIDER_GAP_LEFT |
| 241 | + + DIVIDER_WIDTH |
| 242 | + + DIVIDER_GAP_RIGHT |
| 243 | + + suffix_w |
| 244 | + + CANVAS_RIGHT_PADDING |
| 245 | + ) |
| 246 | + |
| 247 | + for mode_name, style in MODES.items(): |
| 248 | + canvas = Image.new("RGBA", (canvas_width, BANNER_HEIGHT), (0, 0, 0, 0)) |
| 249 | + draw = ImageDraw.Draw(canvas) |
| 250 | + canvas.alpha_composite(colored_logo, (logo_x, logo_y)) |
| 251 | + draw_text(draw, (text_left, platform_y), p, platform_font, style) |
| 252 | + draw.rounded_rectangle( |
| 253 | + (divider_x, divider_y, divider_x + DIVIDER_WIDTH, divider_y + DIVIDER_HEIGHT), |
| 254 | + fill=PLATFORM_COLORS.get(p, MSCP_GREEN), |
| 255 | + radius=DIVIDER_WIDTH // 2, |
| 256 | + ) |
| 257 | + draw_text(draw, (suffix_x, suffix_y), SUFFIX, suffix_font, style) |
| 258 | + draw_text( |
| 259 | + draw, |
| 260 | + (text_left, top_y + DIVIDER_HEIGHT + GAP_BETWEEN), |
| 261 | + SUBTITLE, |
| 262 | + subtitle_font, |
| 263 | + style, |
| 264 | + ) |
| 265 | + canvas.save(outdir / f"mscp_banner_{p.lower()}_{mode_name}.png") |
| 266 | + count += 1 |
| 267 | + |
| 268 | + logger.info(f"Created {count} files in {outdir}") |
| 269 | + sp.text = f"Created {count} files in {outdir}" |
| 270 | + sp.ok("✔") |
0 commit comments