# Image Watermark Plugin — Complete Reference

The `image-watermark` plugin (`plugins/webkul/image-watermark`) applies text or logo watermarks to product images before they are pushed to Shopify. Originals on R2/public disk are never modified — watermarked copies are stored separately in a `watermarked/` prefix and selected per-channel.

---

## Installation

The plugin is registered in `bootstrap/plugins.php` but uses the standard `Package::isPluginInstalled()` guard — it won't appear in the admin until it's installed in the database.

```bash
# Run on the server — runs migrations AND sets is_installed = true in the plugins table
php artisan image-watermark:install

# Then clear caches
php artisan view:clear && php artisan optimize:clear && php artisan optimize
```

> **Not** `php artisan plugin:install image-watermark` — that namespace doesn't exist.
> The command pattern is `{short-name}:install`, so it's `image-watermark:install`.

If you ran `php artisan migrate` separately first (e.g. as part of a deploy), the install command still works — it checks `migrations` table and skips already-run migrations.

---

## Architecture overview

```
products_products.images           ← original UUID filenames (shared across companies)
        │
        ▼ WatermarkProductJob
watermarked_product_images         ← tracking table: original → watermarked filename + config_hash
        │
        ▼ resolveWatermarkedFilenames()
ShopifyBatchSyncJob / ShopifyBatchCreateJob  ← swaps filenames when channel.use_watermarked_images = true
        │
        ▼ REST image upload
Shopify CDN                        ← receives watermarked images
```

Original images are untouched. If a watermarked version isn't yet processed, the job falls back to the original silently.

---

## File map

```
plugins/webkul/image-watermark/
├── composer.json                                         — requires intervention/image ^3.0
├── src/
│   ├── ImageWatermarkServiceProvider.php                 — migrations, Artisan commands
│   ├── ImageWatermarkPlugin.php                          — Filament autodiscovery (Resources, Pages, Clusters)
│   ├── Models/
│   │   ├── WatermarkConfig.php                           — one per company; computeHash() for stale detection
│   │   └── WatermarkedProductImage.php                   — original_filename → watermarked_filename mapping
│   ├── Services/
│   │   └── WatermarkProcessor.php                        — core: reads image, applies watermark, writes to R2/public
│   ├── Jobs/
│   │   ├── WatermarkProductJob.php                       — single product, queue: high
│   │   └── WatermarkBatchJob.php                         — all products for a company, queue: default
│   ├── Console/
│   │   ├── ProcessWatermarksCommand.php                  — php artisan watermark:process
│   │   └── ClearStaleWatermarksCommand.php               — php artisan watermark:clear-stale
│   └── Filament/
│       ├── Clusters/ImageWatermark.php                   — Settings → Image Watermark nav cluster
│       └── Resources/
│           ├── WatermarkConfigResource.php               — CRUD for watermark_configs
│           └── WatermarkConfigResource/Pages/
│               ├── ListWatermarkConfigs.php
│               ├── CreateWatermarkConfig.php
│               └── EditWatermarkConfig.php
├── database/migrations/
│   ├── 2026_07_22_000001_create_watermark_configs_table.php
│   ├── 2026_07_22_000002_create_watermarked_product_images_table.php
│   └── 2026_07_22_000003_add_watermark_to_channels_channels_table.php
└── resources/
    ├── fonts/                                            ← TTF files go here (see "Fonts" section below)
    └── views/filament/pages/
        └── manage-watermarked-images.blade.php           ← product tab blade
```

**Product tab** (inventories plugin):
```
plugins/webkul/inventories/src/Filament/Clusters/Products/Resources/
  ProductResource/Pages/ManageWatermarkedImages.php       — tab page class (gated on company config)
```

---

## Database schema

### `watermark_configs`

| Column | Type | Notes |
|---|---|---|
| `id` | int PK | |
| `company_id` | int unique FK | One config per company |
| `is_enabled` | boolean | Quick kill switch |
| `type` | varchar | `text` or `logo` |
| `watermark_text` | varchar nullable | e.g. `SOMERSET4X4` |
| `font_file` | varchar | Filename from `resources/fonts/` |
| `font_size` | int | px, default 28 |
| `font_color` | varchar | Hex, default `#ffffff` |
| `logo_path` | varchar nullable | R2/public path |
| `logo_scale` | decimal(5,3) | Fraction of image width, default 0.20 |
| `position` | varchar | `tile`, `top-left`, `center`, `bottom-right`, etc. |
| `angle` | int | Rotation degrees, default -30 (used in tile mode) |
| `tile_spacing_x` | int | Horizontal repeat gap, default 160 px |
| `tile_spacing_y` | int | Vertical repeat gap, default 90 px |
| `opacity` | int | 0–100, default 25 |
| `padding_x` / `padding_y` | int | Single-position offset from edge, default 20 px |

**`computeHash()`** on `WatermarkConfig` returns `md5(json_encode($config->only([all fields])))`. This hash is stored in `watermarked_product_images.config_hash`. When the config changes, any image whose stored hash differs from the current hash is stale and must be reprocessed.

### `watermarked_product_images`

| Column | Type | Notes |
|---|---|---|
| `product_id` | int FK | → `products_products` |
| `watermark_config_id` | int FK | → `watermark_configs` |
| `original_filename` | varchar | Bare UUID path as stored in `products_products.images` |
| `watermarked_filename` | varchar | Path written to R2/public, e.g. `watermarked/1/uuid.jpg` |
| `config_hash` | char(32) | MD5 at time of processing |
| `processed_at` | timestamp nullable | When processing finished |

Unique constraint: `(product_id, watermark_config_id, original_filename)`.

### Columns added to `channels_channels`

| Column | Type | Notes |
|---|---|---|
| `use_watermarked_images` | boolean | Default false; enable per channel |
| `watermark_config_id` | int nullable FK | Which config to use for lookup |

---

## WatermarkProcessor

`src/Services/WatermarkProcessor.php` — the core image manipulation service. Uses **Intervention Image v3** with the GD driver.

**`process(string $originalFilename, WatermarkConfig $config): string`**

1. Loads image bytes from R2 (or `public` disk if R2 not configured)
2. Sets `memory_limit = 256M` for large-image safety
3. Calls `applyTextWatermark()` or `applyLogoWatermark()` based on `$config->type`
4. Writes output to `watermarked/{config_id}/{uuid}.{ext}` on the same disk
5. Returns the new relative path

**Text watermark — tile mode (`position = 'tile'`)**

Loops across the full image area in a grid of `tile_spacing_x` × `tile_spacing_y` steps, drawing the watermark text at each point with the configured `angle`, `font_size`, `font_color`, and `opacity`. Font file is loaded from `resources/fonts/{font_file}`.

**Text watermark — single position**

Draws once, offset from the nearest corner/edge by `padding_x`/`padding_y`. Supports: `top-left`, `top-center`, `top-right`, `center`, `bottom-left`, `bottom-center`, `bottom-right`.

**Logo watermark**

Loads `logo_path` from R2/public, scales to `logo_scale × image_width`, places using the same position logic as single-position text. Alpha channel of the logo PNG is preserved.

**Logo tile mode (diagonal repeating)**

When `position = 'tile'`, `applyLogoTile()` clones and rotates the logo PNG *before* tiling, so the rotation is baked into the repeated stamp:

```php
if ($config->angle !== 0) {
    $logo = (clone $logo)->rotate((float) $config->angle, background: 'rgba(0,0,0,0)');
}
```

The `background` must be `'rgba(0,0,0,0)'` (transparent) — Intervention Image v3's default is white, which would fill the corners of the rotated bounding box. The loop extends by `max($width, $height)` in each direction so the diagonal pattern covers image edges without gaps.

---

## Fonts

`WatermarkProcessor` looks for font files at:
```
plugins/webkul/image-watermark/resources/fonts/{font_file}
```

**These files are not bundled in git** (binary files, open-source licenses). You need to download them manually and place them in the `resources/fonts/` directory.

### What to download

| Filename | Family | Download from |
|---|---|---|
| `DejaVuSans-Bold.ttf` | DejaVu Sans Bold | https://dejavu-fonts.github.io — download "DejaVu fonts" ZIP → `ttf/DejaVuSans-Bold.ttf` |
| `DejaVuSansMono.ttf` | DejaVu Sans Mono | Same ZIP → `ttf/DejaVuSansMono.ttf` |
| `Oswald-Bold.ttf` | Oswald Bold | https://fonts.google.com/specimen/Oswald → Download family → extract `Oswald-Bold.ttf` |
| `Montserrat-Bold.ttf` | Montserrat Bold | https://fonts.google.com/specimen/Montserrat → Download family → extract `static/Montserrat-Bold.ttf` |
| `Inter-Regular.ttf` | Inter Regular | https://fonts.google.com/specimen/Inter → Download family → extract `static/Inter_18pt-Regular.ttf`, rename to `Inter-Regular.ttf` |
| `RobotoMono-Regular.ttf` | Roboto Mono | https://fonts.google.com/specimen/Roboto+Mono → Download family → extract `static/RobotoMono-Regular.ttf` |

All six are SIL Open Font License. **DejaVu is the default** (`font_file` column default = `DejaVuSans-Bold.ttf`) — it must be present for watermarking to work unless you change the config default.

### Quickest way to get DejaVu (the only required one)

```bash
# From the project root — downloads and extracts just the Bold and Mono TTFs
curl -L https://github.com/dejavu-fonts/dejavu-fonts/releases/download/version_2_37/dejavu-fonts-ttf-2.37.zip -o /tmp/dejavu.zip
unzip -j /tmp/dejavu.zip "dejavu-fonts-ttf-2.37/ttf/DejaVuSans-Bold.ttf" "dejavu-fonts-ttf-2.37/ttf/DejaVuSansMono.ttf" -d plugins/webkul/image-watermark/resources/fonts/
rm /tmp/dejavu.zip
```

Google Fonts files must be downloaded via browser (or `npx google-fonts-dl`).

---

## Jobs

### `WatermarkProductJob` — single product

Queue: `high`. Called from the product tab "Re-watermark Images" button and by `WatermarkBatchJob`.

1. Loads `WatermarkConfig` for the product's company
2. Loads product: `id`, `sku`, `name`, `images`
3. Computes current `$configHash`
4. For each original filename:
   - If a `WatermarkedProductImage` row exists with a matching hash → skip (already current)
   - If stale (hash differs) → delete old watermarked file from R2, reprocess
   - If missing → process for the first time
5. Upserts the tracking row with new path + hash
6. Uses `JobLogger` for console visibility

**Console output format** — the job logs a rich detail block on completion:

```
SKU:     ERR3340 — Land Rover Discovery Front Bumper
Config:  logo · diagonal tile · opacity 25%
Result:  3/3 image(s) watermarked
```

- For multi-image products, `JobLogger::progress()` is called per image so the console bar shows `image 2/3` during processing.
- On error, the exception message is embedded in the title: `Watermark · ERR3340 — Unable to read font file`.

### `WatermarkBatchJob` — all products for a company

Queue: `default`. Dispatched by `php artisan watermark:process`.

Chunks `products_products` for the company (where `images IS NOT NULL`) and dispatches one `WatermarkProductJob` per product.

---

## Edit/Create Config page layout

The `EditWatermarkConfig` (and `CreateWatermarkConfig`) page uses a custom blade that overrides the default single-column Filament layout.

**File:** `plugins/webkul/image-watermark/resources/views/filament/resources/watermark-config/pages/edit-watermark-config.blade.php`

Layout is a single outer `.wmc-shell` card with a two-column inner grid:

```css
.wmc-body {
    display: grid;
    grid-template-columns: minmax(0, 1fr) 480px;  /* form fills | preview fixed */
}
@media (max-width: 1000px) {
    .wmc-body { grid-template-columns: 1fr; }      /* stacks on small screens */
}
```

- **Left column** (`.wmc-form-col`): renders `{{ $this->content }}` (the Filament Schema). Filament's own section card styling is stripped to transparent so it doesn't create a nested box inside the outer shell. Uses `Grid::make(3)` in `WatermarkConfigResource::form()`.
- **Right column** (`.wmc-preview-col`): fixed 480px, light grey background. Contains:
  - Toolbar: "Preview" label + optional product select (`wire:model.live="previewProductId"`) + Generate button
  - Canvas: white box, min-height 260px, shows placeholder / error / rendered image
  - Note line: "Renders current (unsaved) settings."
- A vertical `border-right: 1px solid #e5e7eb` on the form column acts as the divider between panels.

The `$this->content` render is what makes the outer card a *single* visual card — Filament's own `fi-section` elements are styled to be transparent inside `.wmc-form-col`, so there's no nested "card within a card" visual.

---

## Artisan commands

```bash
# Process all companies with an enabled config
php artisan watermark:process

# Single company only
php artisan watermark:process --company=2

# Single product by SKU (dispatches WatermarkProductJob to high queue)
php artisan watermark:process --sku=ERR3340

# Dry-run stale detection (show what would be deleted without deleting)
php artisan watermark:clear-stale --dry-run

# Delete stale watermarked images for all companies
php artisan watermark:clear-stale

# Delete stale watermarked images for one company
php artisan watermark:clear-stale --company=2
```

"Stale" means a `watermarked_product_images` row whose `config_hash` differs from the current config's `computeHash()`. Run `watermark:clear-stale` after changing a watermark config to purge old files from R2 and force reprocessing on the next sync.

---

## Product tab — Watermarked Images

Shown on the product detail page (sub-nav tab) when the product's company has an enabled `WatermarkConfig`.

**File:** `plugins/webkul/inventories/src/Filament/Clusters/Products/Resources/ProductResource/Pages/ManageWatermarkedImages.php`

**View:** `plugins/webkul/image-watermark/resources/views/filament/pages/manage-watermarked-images.blade.php`

The tab shows a grid of image pairs (original left, watermarked right). Layout details:
- Grid: `repeat(auto-fill, minmax(540px, 1fr))` — wide cards so images have room
- Images: `height: 220px; object-fit: contain; padding: 0.5rem` — no cropping, light background shows full image with whitespace
- Each card has an original side and a watermarked side, each with a small "ORIGINAL" / "WATERMARKED" pill label
- Status dot below: green (current), amber (stale — config changed since processing), grey (not yet processed)
- Header shows image count + config type: e.g. "3 image(s) · logo watermark"

**"Re-watermark Images" button** → `wire:click="rewatermark"` → dispatches `WatermarkProductJob` to `high` queue. Button shows "Queuing…" spinner while dispatching (`wire:loading`).

The tab is **gated** — `ProductResource::getRecordSubNavigation()` only includes it when `WatermarkConfig::where('company_id', ...)->where('is_enabled', true)->exists()` returns true.

---

## Channel integration

### Enabling on a channel

Admin → Channels → edit any channel → **Use Watermarked Images** toggle. When enabled, a **Watermark Config** select appears — pick the config whose IDs you want to resolve against.

The config dropdown uses `getOptionLabelFromRecordUsing()` in `ChannelResource.php` to show a human-readable label instead of a numeric ID, e.g.:
- `Somerset4×4 — logo · diagonal tile`
- `Somerset4×4 — text · "SOMERSET4X4"`

The feature is already implemented in both batch sync jobs — just enabling the toggle and saving is all that's needed. Products with no watermarked version automatically fall back to their original image.

### How filenames are resolved

Both sync jobs call `resolveWatermarkedFilenames(int $productId, array $originalFilenames, Channel $channel)` right after the `$filenames` array is built (and after featured image reordering):

```php
private function resolveWatermarkedFilenames(int $productId, array $originalFilenames, Channel $channel): array
{
    if (! $channel->use_watermarked_images || ! $channel->watermark_config_id) {
        return $originalFilenames;   // passthrough — no change
    }

    $map = DB::table('watermarked_product_images')
        ->where('product_id', $productId)
        ->where('watermark_config_id', $channel->watermark_config_id)
        ->whereIn('original_filename', $originalFilenames)
        ->pluck('watermarked_filename', 'original_filename')
        ->toArray();

    return array_map(fn ($f) => $map[$f] ?? $f, $originalFilenames);
}
```

Images without a processed watermark silently fall back to the original. Images with a current watermark get the `watermarked/...` path, which is then resolved to a full URL via `resolveImageUrl()` and sent to Shopify as a `src` upload.

This runs in **both**:
- `ShopifyBatchSyncJob` — Phase 3.5 image comparison/update
- `ShopifyBatchCreateJob` — Phase 4.5 image upload

---

## Typical workflow for a new company

1. **Create the config** — Admin → Settings → Image Watermark → New Config. Set company, type (text), watermark text (`SOMERSET4X4`), font, size, colour, position (`tile`).

2. **Place font files** in `plugins/webkul/image-watermark/resources/fonts/` (see Fonts section above).

3. **Process all products** — run `php artisan watermark:process --company=2` from the terminal. This dispatches `WatermarkProductJob` for every product with images. Monitor progress in the Console panel.

4. **Verify** — open any product → Watermarked Images tab → images should show processed with green dots.

5. **Enable on channel** — Admin → Channels → edit Banwell Website → enable "Use Watermarked Images" → select the Somerset4x4 config → save.

6. **Next sync** will automatically push watermarked images to Shopify for that channel.

---

## Config change workflow

After editing an existing `WatermarkConfig` (e.g. changing text, font, or opacity):

```bash
# 1. See what would be affected (dry run)
php artisan watermark:clear-stale --company=2 --dry-run

# 2. Delete stale watermarked files from R2 + clear DB rows
php artisan watermark:clear-stale --company=2

# 3. Re-process all products
php artisan watermark:process --company=2
```

The next Shopify sync will detect that the watermarked filenames have changed and upload the new versions.

---

## Storage layout

```
R2 bucket (or public/storage):
  {uuid}.jpg                          ← original (untouched)
  watermarked/
    {config_id}/
      {uuid}.jpg                      ← watermarked copy
```

The `config_id` prefix keeps different companies' watermarked images separate and allows batch-deleting a company's watermarks without touching others.

Public URL pattern (same as originals):
```
{R2_public_url}/watermarked/{config_id}/{uuid}.jpg
```
`resolveImageUrl()` in the jobs handles this — just pass the full relative path.

---

## Common pitfalls

| Problem | Cause | Fix |
|---|---|---|
| `Call to undefined function imagettftext()` | GD not compiled with FreeType | On XAMPP this should be available; on Linux servers install `php-gd` with FreeType support |
| `Unable to read font file` | Font TTF missing from `resources/fonts/` | Download fonts — see Fonts section |
| Images not swapping on Shopify after enabling watermarks | Shopify only uploads new images when filenames differ from what's already there | Watermarked paths (`watermarked/1/uuid.jpg`) differ from originals (`uuid.jpg`) — Shopify will see them as new and upload |
| Opacity has no effect | GD text opacity requires drawing on an alpha channel. If the image doesn't support alpha, opacity is ignored | Use PNG source images or ensure `WatermarkProcessor` creates an alpha layer before drawing |
| Tab not visible on product | Company has no enabled `WatermarkConfig` row | Create a config in Admin → Settings → Image Watermark |
| `WatermarkProductJob` fails immediately | Font file missing | Place required TTFs in `resources/fonts/` |
| Old watermarks shown after config change | `config_hash` in DB still matches old config | Run `watermark:clear-stale` then `watermark:process` |
| Memory exhausted during processing | Large images × many tile repetitions | `WatermarkProcessor` sets `memory_limit = 256M`; increase if needed in queue worker config |
| Logo tile has white corners after rotation | Intervention Image v3 defaults rotation background to `#ffffff` | Always pass `background: 'rgba(0,0,0,0)'` to `rotate()` when rotating PNGs with transparency |
| Watermark config dropdown shows numeric ID | Using `->relationship('field', 'id', ...)` labels by the `id` column | Add `->getOptionLabelFromRecordUsing()` to build a human-readable string |
