DEV Community

Maxwell
Maxwell

Posted on

Why Your Watermark Is Tiny on One Photo and Huge on the Next: Resolution-Independent Overlays in Swift

The obvious first version of batch watermark code has one setting that looks completely reasonable: a font size of 48.

On a screenshot that's enormous. On a 48-megapixel photo it's a speck in the corner you'd need to zoom in to find. On a portrait photo it sits at a different distance from the edge than on a landscape one. Every photo in the batch gets "the same" watermark, and no two of them look alike.

The bug isn't in any single line. It's the idea that a watermark has a size in points. When you're stamping a mixed set of images (phone photos, screenshots, crops, panoramas, images saved from chat apps), the only size that means anything is a size relative to the image. This post shows how to set that up in Swift with UIKit drawing, plus the few traps that are easy to miss.

The mixed set you actually get

If you let people pick photos from their library, assume the batch looks something like this:

  • 4032 × 3024 (12 MP, landscape)
  • 3024 × 4032 (the same camera, portrait)
  • 8064 × 6048 (48 MP)
  • 1179 × 2556 (a screenshot)
  • 1080 × 1080 (an image saved from social media)
  • 16000 × 3400 (a panorama)

A 48-pixel-tall line of text is about 1.6% of the short side of the 12 MP photo, 0.8% of the 48 MP one and 4% of the screenshot. That's the whole problem in three numbers.

Step 1: Work in pixels, not points

UIImage.size is in points and already accounts for orientation. Multiply by scale and you get the pixel size of the image as people see it, which is what we want to lay out against:

import UIKit

func pixelSize(of image: UIImage) -> CGSize {
    CGSize(width: image.size.width * image.scale,
           height: image.size.height * image.scale)
}
Enter fullscreen mode Exit fullscreen mode

Don't use cgImage.width and cgImage.height for layout. A portrait photo from the camera is often stored as a landscape bitmap with an orientation flag, so the CGImage will happily tell you it's 4032 wide when the person is looking at something 3024 wide. Your "bottom-right corner" then lands on the wrong edge.

The renderer has the same trap in a different place. A UIGraphicsImageRenderer uses the screen's scale by default, so a 4032 × 3024 canvas on a 3x iPhone becomes a 12096 × 9072 bitmap. That's nine times the pixels, nine times the memory, and an output file nobody asked for. Set the scale explicitly:

let format = UIGraphicsImageRendererFormat()
format.scale = 1          // 1 point == 1 pixel; we already sized in pixels
format.opaque = true      // photos have no alpha; skip it if you accept PNGs with transparency
let renderer = UIGraphicsImageRenderer(size: pixelSize(of: image), format: format)
Enter fullscreen mode Exit fullscreen mode

Step 2: Size against the short side

Once everything is in pixels, pick one dimension to measure against. I use the short side, min(width, height), for three reasons:

  1. It's the same for the portrait and landscape versions of a photo, so rotating a picture doesn't change the size of the mark.
  2. On a panorama, the long side is huge. Scaling to it would produce a watermark taller than the image's comfortable reading area.
  3. On a tall screenshot, scaling to the long side makes text that's too wide for the narrow edge.

Then every measurement becomes a fraction: text height, margin, shadow blur, logo width.

struct WatermarkStyle {
    var text: String
    var relativeCapHeight: CGFloat = 0.035  // cap height as a fraction of the short side
    var relativeMargin: CGFloat = 0.03      // distance from the edges
    var maxWidthFraction: CGFloat = 0.8     // never wider than 80% of the image
    var opacity: CGFloat = 0.85
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Turn "how tall" into a font size

A font's point size isn't the height of its letters. It's roughly the height of the line box, which includes room for ascenders, descenders and some spacing. If you set the point size to 3.5% of the short side, the visible capitals end up noticeably smaller than that, and the ratio changes between fonts.

UIFont gives you capHeight, so you can solve for the point size that produces the letter height you want. The ratio is stable for a given font, so measure it once at a reference size:

func font(forCapHeight target: CGFloat,
          weight: UIFont.Weight = .semibold) -> UIFont {
    let reference = UIFont.systemFont(ofSize: 100, weight: weight)
    let capRatio = reference.capHeight / reference.pointSize   // about 0.7 for the system font
    return UIFont.systemFont(ofSize: target / capRatio, weight: weight)
}
Enter fullscreen mode Exit fullscreen mode

Now "3.5% of the short side" means the capitals are that tall, whichever font the person picked.

Step 4: Shrink to fit, don't overflow

Long watermark text ("© Maxwell Studio · Do not reuse") can be wider than a narrow image, especially a portrait screenshot. Measure it and scale the font down if it's over the limit. One measurement and one proportional correction is enough, because text width scales linearly with point size:

func attributes(font: UIFont, opacity: CGFloat, shortSide: CGFloat)
    -> [NSAttributedString.Key: Any] {
    let shadow = NSShadow()
    shadow.shadowColor = UIColor.black.withAlphaComponent(0.35)
    shadow.shadowBlurRadius = shortSide * 0.004    // scales with the image too
    shadow.shadowOffset = CGSize(width: 0, height: shortSide * 0.001)
    return [
        .font: font,
        .foregroundColor: UIColor.white.withAlphaComponent(opacity),
        .shadow: shadow
    ]
}

func fittedFont(for text: String, style: WatermarkStyle, canvas: CGSize) -> UIFont {
    let shortSide = min(canvas.width, canvas.height)
    let start = font(forCapHeight: shortSide * style.relativeCapHeight)
    let width = (text as NSString).size(
        withAttributes: attributes(font: start, opacity: 1, shortSide: shortSide)
    ).width

    let maxWidth = canvas.width * style.maxWidthFraction
    guard width > maxWidth else { return start }
    return start.withSize(start.pointSize * maxWidth / width)
}
Enter fullscreen mode Exit fullscreen mode

Notice the shadow. A fixed 4-point blur looks like a soft glow on a screenshot and like nothing at all on a 48 MP photo. Anything you'd set in points (blur, stroke width, corner radius of a backing plate) needs the same treatment as the font.

Step 5: Place it by the baseline, not the box

draw(at:withAttributes:) puts the top-left of the line box at the point you give it. If you place the box margin pixels from the bottom, the visible gap under the letters is bigger than margin, because the box includes descender space. Compared with the side margin, it looks like the text is floating.

Position by the baseline instead. The baseline sits font.ascender below the top of the line box, so:

func renderWatermark(on image: UIImage, style: WatermarkStyle) -> UIImage {
    let canvas = pixelSize(of: image)
    let shortSide = min(canvas.width, canvas.height)
    let font = fittedFont(for: style.text, style: style, canvas: canvas)
    let attrs = attributes(font: font, opacity: style.opacity, shortSide: shortSide)

    let text = style.text as NSString
    let textWidth = text.size(withAttributes: attrs).width
    let margin = shortSide * style.relativeMargin

    // Baseline exactly `margin` above the bottom edge, right edge `margin` from the side.
    let baselineY = canvas.height - margin
    let origin = CGPoint(x: canvas.width - margin - textWidth,
                         y: baselineY - font.ascender)

    let format = UIGraphicsImageRendererFormat()
    format.scale = 1
    format.opaque = true
    let renderer = UIGraphicsImageRenderer(size: canvas, format: format)

    return renderer.image { _ in
        image.draw(in: CGRect(origin: .zero, size: canvas))   // respects orientation
        text.draw(at: origin, withAttributes: attrs)
    }
}
Enter fullscreen mode Exit fullscreen mode

Letters with descenders (g, y, p) will dip slightly into the margin. That's what typesetters do too; it looks aligned because the eye reads the baseline.

Logos: same rule, one extra check

A logo gets the same treatment. Size its longer side as a fraction of the image's short side, keep its aspect ratio, and use the same margin:

func logoRect(logo: UIImage, canvas: CGSize,
              relativeSize: CGFloat = 0.16,
              relativeMargin: CGFloat = 0.03) -> CGRect {
    let shortSide = min(canvas.width, canvas.height)
    let logoPixels = pixelSize(of: logo)
    let longest = max(logoPixels.width, logoPixels.height)
    let scale = (shortSide * relativeSize) / longest

    let size = CGSize(width: logoPixels.width * scale, height: logoPixels.height * scale)
    let margin = shortSide * relativeMargin
    return CGRect(x: canvas.width - margin - size.width,
                  y: canvas.height - margin - size.height,
                  width: size.width, height: size.height)
}
Enter fullscreen mode Exit fullscreen mode

The extra check is upscaling. If scale comes out above 1, the logo bitmap is smaller than the space it's about to fill, and it will be soft on big photos. A 400-pixel PNG logo looks fine on a 1080 image and blurry on a 48 MP one. Either warn the person when scale > 1, or ask for a bigger logo file up front. A transparent PNG at 1000–2000 pixels on its long side covers almost everything a phone camera produces.

You might expect a logo to want a different reference than text. It doesn't need one: once both are tied to the short side, a text mark and a logo mark that look balanced on one photo keep the same proportions on every other photo in the set.

A quick way to test it

Make a test set with the six shapes from the start of this post. Solid-colour images are fine; you're testing geometry, not looks. Render each one, then scale every output to the same display height and lay them out side by side. If the system is right, the marks look identical: same apparent size, same distance from the corner, same softness of shadow.

Two edge cases worth adding:

  • Tiny images. A 300 × 300 thumbnail gives a cap height of about 10 pixels. Clamp to a minimum so the text stays legible.
  • Extreme panoramas. The short side keeps the size sensible, but a corner mark on a 16000-pixel-wide image can be a long way from where people look. That's a placement question rather than a sizing one, and it's worth deciding on purpose.

The rule, in one line

Never store a watermark size in points. Store fractions of the short side, convert to pixels per image at render time, and set the renderer's scale to 1 so a pixel stays a pixel.

I build an iPhone app called BatchMark that stamps text and logo marks on sets of up to 100 photos at once, and sizing everything relative to the photo is what lets one setting work across a mixed batch. If you're also thinking about where the mark should go so it doesn't get cropped out, I wrote up some notes on watermark placement. And if you've hit a sizing case I didn't cover, I'd like to hear about it in the comments.

Top comments (0)