Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ new MiQ().setWatermark(new URL('https://example.com/logo.png')) // image — not
new MiQ().setWatermark(readFileSync('./logo.png')) // image — Buffer/Uint8Array
```

The two are mutually exclusive — the last one set wins. An image is drawn at `theme.watermark.imageSize` if set, or `theme.watermark.size` otherwise (the same height the text form would use); `theme.watermark.color`/`font`/`weight` only apply to the text form. A logo tends to read small at the text-tuned default — `imageSize: 0.03`–`0.05` or so is a reasonable starting point. `theme.watermark.position` (`'auto'`/`'bottom-left'`/`'bottom-center'`/`'bottom-right'`) works the same for both.
The two are mutually exclusive — the last one set wins. An image is drawn at `theme.watermark.imageSize` if set, or `theme.watermark.size` otherwise (the same height the text form would use); `theme.watermark.color`/`font`/`weight` only apply to the text form. `theme.watermark.margin` sets how far it sits from the corner, on both axes. `theme.watermark.position` (`'auto'`/`'bottom-left'`/`'bottom-center'`/`'bottom-right'`) works the same for both.

**Supported image formats** (same decoder as `setAvatar()`/`backgroundImage`, courtesy of `@napi-rs/canvas`'s Skia backend): PNG, JPEG, WebP, AVIF, GIF (first frame only — not animated), BMP, and SVG. SVG only decodes from bytes or a `data:` URL, not a bare SVG string.

Expand Down
25 changes: 24 additions & 1 deletion src/render/pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ describe('render', () => {
})

it('sizes an image watermark from imageSize instead of size, when set', async () => {
// Both corners are square (the stub logo is), so a taller imageSize also reaches further left — sampling near the top of that taller square, where the default-size image (size: 0.024) doesn't reach at all.
// Both corners are square (the stub logo is), so a taller imageSize also reaches further left — sampling near the top of that taller square, where the default-size image doesn't reach at all.
const x = 1150
const y = 560

Expand All @@ -418,6 +418,29 @@ describe('render', () => {
expect(defaultAlpha).toBe(255) // background, not the (much smaller) default watermark
expect([r, g, b]).toEqual([255, 0, 0]) // the logo itself, once large enough to reach here
})

it('moves an image watermark away from the corner as margin grows', async () => {
const x = 1140
const y = 590

const [r, g, b] = await pixelAt(
quote()
.setWatermark(redSquare())
.setTheme({ watermark: { imageSize: 0.05 } }),
x,
y,
)
const [, , , movedAlpha] = await pixelAt(
quote()
.setWatermark(redSquare())
.setTheme({ watermark: { imageSize: 0.05, margin: 0.15 } }),
x,
y,
)

expect([r, g, b]).toEqual([255, 0, 0]) // the logo, at the default corner margin
expect(movedAlpha).toBe(255) // background — the wider margin pulled it well clear of this point
})
})

describe('backgroundGradient', () => {
Expand Down
18 changes: 10 additions & 8 deletions src/render/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,8 @@ function drawWatermark(
if (invisible(theme.watermark.color, 'theme.watermark.color')) return

const size = toPixels(theme.watermark.size, theme.height)
const margin = theme.width * 0.04
const marginX = toPixels(theme.watermark.margin, theme.width)
const marginY = toPixels(theme.watermark.margin, theme.height)

ctx.font = font(theme.watermark.weight, size, theme.watermark.font)
ctx.fillStyle = toCSS(parseColor(theme.watermark.color, 'theme.watermark.color'))
Expand All @@ -436,18 +437,18 @@ function drawWatermark(
familyFor(theme.watermark.font),
size,
)
const y = theme.height * 0.96
const y = theme.height - marginY
const corner = watermarkCorner(theme)

if (corner === 'bottom-left') {
ctx.textAlign = 'left'
fillText(ctx, data.watermark, margin, y, stroke)
fillText(ctx, data.watermark, marginX, y, stroke)
} else if (corner === 'bottom-center') {
ctx.textAlign = 'center'
fillText(ctx, data.watermark, theme.width / 2, y, stroke)
} else {
ctx.textAlign = 'right'
fillText(ctx, data.watermark, theme.width - margin, y, stroke)
fillText(ctx, data.watermark, theme.width - marginX, y, stroke)
}
}

Expand All @@ -457,16 +458,17 @@ function drawWatermark(
function drawWatermarkImage(ctx: SKRSContext2D, image: Image, theme: Theme): void {
const height = toPixels(theme.watermark.imageSize ?? theme.watermark.size, theme.height)
const width = height * (image.width / Math.max(1, image.height))
const margin = theme.width * 0.04
const y = theme.height * 0.96 - height
const marginX = toPixels(theme.watermark.margin, theme.width)
const marginY = toPixels(theme.watermark.margin, theme.height)
const y = theme.height - marginY - height
const corner = watermarkCorner(theme)

const x =
corner === 'bottom-left'
? margin
? marginX
: corner === 'bottom-center'
? (theme.width - width) / 2
: theme.width - margin - width
: theme.width - marginX - width

ctx.drawImage(image, x, y, width, height)
}
3 changes: 2 additions & 1 deletion src/theme/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,9 @@ const dark: Theme = {
color: palettes.dark.faint,
font: SANS,
weight: 'normal',
size: 0.024,
size: 0.04,
imageSize: null,
margin: 0.04,
position: 'auto',
},
emoji: { sideMarginRatio: 0.08, topMarginRatio: 0.1, size: 64 },
Expand Down
2 changes: 2 additions & 0 deletions src/theme/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ export interface WatermarkTheme {
* Height of an image watermark, in place of `size`. `null` (default): the image uses `size` too, same as before this existed. `size` is tuned for a short text tag and reads small for a logo, so this exists to size the two forms independently without one dragging the other along.
*/
imageSize: number | null
/** Distance from the canvas edge on both axes, as a fraction of the canvas size (or pixels above 1). */
margin: number
/**
* `'auto'` keeps it clear of the avatar: opposite side for `side` layouts, bottom right for `new` ones.
*/
Expand Down
Loading