# Extending Symelo Image Optimizations

For add-on developers. Symelo Image Optimizations optimizes images with PHP's image libraries (Imagick and GD). Add-ons can register more **engines** (encoders for JPEG, PNG, WebP, AVIF, PDF or resizing), add **cards and tabs** to the plugin screen, and add **warnings, notes, system information and status fields**. This document is the contract: everything listed here keeps working within API version 1.x.

Contents:

1. [Versioning](#versioning)
2. [Loading an add-on](#loading-an-add-on)
3. [Hooks](#hooks)
4. [Engines](#engines)
5. [Background speed](#background-speed)
6. [Admin screen](#admin-screen)
7. [Status, Site Health and system information](#status-site-health-and-system-information)
8. [Files, options and cleanup](#files-options-and-cleanup)
9. [Security rules](#security-rules)
10. [Public classes](#public-classes)

## Versioning

`SYMELO_IMAGES_API_VERSION` is a `major.minor` string, currently `1.0`. It is defined only when the plugin actually runs (PHP 7.4 or newer), before `symelo_images_loaded` fires.

- **Minor versions (1.1, 1.2, …)** only add: new hooks, new parameters at the end of a hook, new public methods, new methods with a default in `AbstractEngine`, new `Job` properties, new tasks in `Settings::TASKS`, new keys in arrays (status, profiles, system information sections), new CSS classes.
- **Major versions (2.0)** may break. Breaking changes are: removing or renaming a hook, a public class, method or constant listed here; changing a hook's or a method's parameters or return type; changing what a `Job` property, a task name or a profile key means; adding a method to `EngineInterface`; removing a CSS class or `data-` attribute listed in [Admin screen](#admin-screen).
- `EngineInterface` doesn't change within 1.x. Optional engine features added later come as methods of `AbstractEngine` with a default, so engines that extend it keep working. Implementing `EngineInterface` directly is allowed but gets none of those defaults.
- Everything not listed in this document (other classes, private and internal methods, the settings option layout, database tables, the `symelo_images_*` admin-post actions and nonces) is internal and may change in any version.

Check the version before using anything:

```php
function my_addon_api_ok(): bool {
	if ( ! defined( 'SYMELO_IMAGES_API_VERSION' ) ) {
		return false; // Not installed, not active, or PHP too old.
	}
	$version = (string) SYMELO_IMAGES_API_VERSION;
	return 1 === (int) $version && version_compare( $version, '1.0', '>=' ); // Major 1, at least 1.0.
}
```

## Loading an add-on

- Add `Requires Plugins: symelo-image-optimizations` to the add-on's header. WordPress 6.5 and newer then won't activate the add-on without the plugin, and won't deactivate the plugin while the add-on is active. Still guard everything: on WordPress 6.4, or when files are removed by hand, the add-on loads without the plugin.
- Plugins load in alphabetical order of their folder, and network-activated plugins load before the site's, so the add-on may load before or after Symelo Image Optimizations. Attach like this:

```php
if ( did_action( 'symelo_images_loaded' ) ) {
	my_addon_start( \Symelo\Images\Plugin::instance() );
} else {
	add_action( 'symelo_images_loaded', 'my_addon_start' );
}
// Still not started once every plugin is loaded: show an admin notice.
add_action( 'plugins_loaded', 'my_addon_check', 20 );
```

- In `my_addon_start()`, check the API version first. Load nothing that extends or implements a class of the plugin (engines, for example) before that: without the plugin, PHP would stop with a fatal error. The add-on's main file, its autoloader and its bootstrap code must work without the plugin.
- Deactivating Symelo Image Optimizations must never break the site: the add-on then only shows a notice ("Install and activate Symelo Image Optimizations").

## Hooks

### Actions

| Action | Arguments | When it fires |
|---|---|---|
| `symelo_images_loaded` | `Plugin $plugin` | Once, at the end of the plugin's boot, while plugins are loading. Attach your add-on here. |
| `symelo_images_register_engines` | `EngineManager $engines` | Each time the engine list is built: on first use in a request (lazily, e.g. when an image is optimized, on the plugin screen, REST `GET /status`, Site Health), and again after `Plugin::reset_engines()` (after settings are saved). Call `$engines->add( $engine )`. Don't keep the manager. |
| `symelo_images_file_optimized` | `FileRecord $after, FileRecord $before` | After a file was optimized, failed, was skipped or was restored. Read-only: don't change files here. |
| `symelo_images_engines_tab` | `Plugin $plugin` | At the end of the **Engines** tab, after its own cards. Print your cards here. |
| `symelo_images_tab_{$slug}` | `Plugin $plugin` | Prints the panel of a tab added with `symelo_images_tabs`. Every tab is printed on each load of the screen and shown when clicked. |

### Filters

| Filter | Arguments | Return | What for |
|---|---|---|---|
| `symelo_images_capability` | `string $capability` (`'manage_options'`) | Capability | Who may use the plugin screen, bulk optimization and the REST API. Read it with `Plugin::capability()`. |
| `symelo_images_engine_order` | `string[] $ids, string $task` | Engine IDs | The automatic order of a task. `$ids` is already sorted by rank; reorder or remove IDs. Unknown IDs and engines that don't handle the task are ignored. |
| `symelo_images_tabs` | `array $tabs` (`[]`) | `slug => label` | Extra tabs, shown before **Guide** and **Support**. Slugs go through `sanitize_key()`; built-in slugs (`overview`, `compression`, `delivery`, `folders`, `tools`, `advanced`, `guide`, `support`) are ignored. |
| `symelo_images_warnings` | `string[] $warnings, Plugin $plugin` | Plain-text strings | Warnings shown on the **Overview** tab (one notice each), in the system information, `wp symelo-images status` and `GET /status`. |
| `symelo_images_notes` | `string[] $notes, Plugin $plugin` | Plain-text strings | Notes shown under the engine table on the **Engines** tab, in the system information and `GET /status`. |
| `symelo_images_system_info` | `array $sections, Plugin $plugin` | `title => [ label => value ]` | Sections of the **Support** tab's system information (plain text). Add a section of your own. Never include passwords, keys or tokens. |
| `symelo_images_rest_status` | `array $status, Plugin $plugin` | Array | The `GET /wp-json/symelo-images/v1/status` response. Add one key of your own (e.g. `my_addon`); don't change existing keys. |
| `symelo_images_pace` | `array $profile, string $speed` | Profile | The Background speed profiles (see [Background speed](#background-speed)). |
| `symelo_images_max_pixels` | `int $pixels` (60,000,000) | Pixels | Larger images are skipped. |
| `symelo_images_max_bytes` | `int $bytes` (100 MB) | Bytes | Larger files are skipped. |
| `symelo_images_imagick_limits` | `array $limits` | Limits | ImageMagick resource limits (`RESOURCETYPE_*` constant name => value). |
| `symelo_images_worker_budget` | `int $seconds` | Seconds | How long a background run works (minimum 5). |
| `symelo_images_dispatch_args` | `array $args` | `wp_remote_post()` arguments | The request the site sends to itself to start background runs. |
| `symelo_images_skip_page` | `bool $skip` (`false`) | Bool | `true` skips the `<picture>` and lazy-loading pass for this request. |
| `symelo_images_more_from_symelo` | `array $products` (`[]`) | Products | "More from Symelo LTD" on the Support tab. |

Site Health tests: use core's `site_status_tests` filter with test IDs of your own prefix; nothing from this plugin is needed.

## Engines

An engine encodes files for one or more **tasks**: `jpeg` (JPEG compression), `png` (PNG compression), `webp` (WebP copies), `avif` (AVIF copies), `pdf` (PDF compression) and `resize` (scaling and rotating, also used to prepare copies for engines that can't). `Settings::TASKS` lists them. The plugin brings `imagick` and `gd`; no built-in engine handles `pdf`.

### Registering

```php
use Symelo\Images\Engines\AbstractEngine;
use Symelo\Images\Engines\EngineException;
use Symelo\Images\Engines\EngineManager;
use Symelo\Images\Engines\Job;

final class My_Webp_Engine extends AbstractEngine {

	public function id(): string {
		return 'my-webp';
	}

	public function label(): string {
		return 'My WebP encoder';
	}

	public function tasks(): array {
		return array( 'webp' => $this->works() ? true : __( 'The encoder is missing.', 'my-addon' ) );
	}

	public function rank( string $task ): int {
		return 10; // Before Imagick (50) and GD (60).
	}

	public function accepts( Job $job ): bool {
		return $this->can( $job->task ) && in_array( $job->info->format, array( 'jpeg', 'png' ), true ) && ! $job->needs_preparing();
	}

	public function run( Job $job, string $dest ): void {
		if ( ! my_encode( $job->source(), $dest, $job->quality ) ) {
			throw new EngineException( esc_html__( 'My encoder failed.', 'my-addon' ) );
		}
	}

	private function works(): bool {
		return true;
	}
}

add_action(
	'symelo_images_register_engines',
	static function ( EngineManager $engines ) {
		$engines->add( new My_Webp_Engine() );
	}
);
```

`EngineManager::add()` replaces an engine with the same ID; `remove( $id )` removes one. IDs must be what `sanitize_key()` returns (lowercase letters, digits, `_`, `-`) and not `auto`. Make them distinctive: prefix them unless they are the name of a well-known program (`cwebp`).

### EngineInterface

| Method | Contract |
|---|---|
| `id(): string` | Unique, stable ID. It is stored as the admin's choice for a task, so never change it. |
| `label(): string` | Name shown to admins. |
| `origin(): string` | Where the engine comes from, shown under its name (e.g. "PHP extension", "Installed on the server"), or `''`. |
| `version(): string` | Detected version, or `''`. With `''`, an engine that can't do anything shows **Not found**; with a version, **Outdated**. |
| `note(): string` | One plain-text message about the engine (why it isn't available, or a warning), or `''`. |
| `tasks(): array` | Every task the engine handles, with whether it works on this server: `task => true`, or `task => 'reason'` (plain text). Leave out tasks it never handles. Called often: cache expensive checks (per request, or in an option with a fingerprint). |
| `rank( string $task ): int` | Position in the automatic order: lower first. Imagick is 50 and GD 60 for every task; `AbstractEngine::RANK` is 100. Dedicated encoders that beat the PHP libraries return less than 50. |
| `explicit_only(): bool` | `true`: never chosen by **Automatic**, only when the admin picks it for a task (e.g. a lossy engine with security trade-offs). |
| `can_transform(): bool` | Whether the engine itself applies EXIF orientation and scaling (`Job::$resize`). See [Prepared copies](#prepared-copies). |
| `accepts( Job $job ): bool` | Whether it takes this particular file (format, animation, colour space, size, memory, PDF features…). Declining is normal and cheap: the next engine gets the job. Must not write anything. |
| `run( Job $job, string $dest ): void` | Encode into `$dest`. Throw `EngineException` on failure (see [Errors and results](#errors-and-results)). |

`AbstractEngine` implements everything except `id()`, `label()`, `tasks()` and `run()`: no origin, version or note, rank `static::RANK` (100), not explicit-only, can't transform, `accepts()` = `can( $job->task )`. It adds `can( string $task ): bool` (the task is `true` in `tasks()`). `EngineManager::can( $engine, $task )` does the same for any engine.

### Ranks and the automatic order

For a task, the order is built from every registered engine whose `tasks()` has that task (usable here or not):

1. sorted by `rank( $task )`, lower first; equal ranks keep the order of registration (Imagick, GD, then add-ons in the order they called `add()`);
2. passed through `symelo_images_engine_order` (`$ids`, `$task`), which may reorder or remove IDs.

That order is what the **Engines** tab lists. To optimize a file, the plugin tries, in turn:

1. the engine the admin chose for the task (**Engine for each job**), if it is still registered and works here;
2. every other engine in the order, except explicit-only ones;

and uses the first one whose `tasks()` has the task as `true` and whose `accepts()` returns `true`. If `run()` fails, the next one is tried. The engine shown as **Automatic (…)** is the first one that works for the task in general (`EngineManager::preferred()`).

The choice per task is validated against the registered engines: saving an engine that is registered but can't do the task here gives "*engine* can't do that job on this server, so Automatic is used instead." A stored choice for an engine that is no longer registered (the add-on was deactivated) silently counts as **Automatic** (`EngineManager::choice()` returns `auto`) and is dropped the next time the Engines tab is saved.

### Job

`Job` describes one piece of work. Engines read its properties and never change them (the plugin hands the same job to the next engine).

| Property / method | Meaning |
|---|---|
| `string $task` | The task (`Settings::TASKS`). |
| `ImageInfo $info` | The source: `path`, `format` (`jpeg`, `png`, `gif`, `webp`, `avif`, `pdf`, sniffed from the content), `bytes`, `width`, `height`, `animated`, `alpha`, `cmyk`, `bit_depth`, `orientation` (EXIF, 1–8), `has_icc`, `srgb_icc`, `jpeg_quality` (estimated, 0 when unknown). |
| `source(): string` | The source file's absolute path (`$info->path`). Read it; never write to it. |
| `int $quality` | 1–100, or 0 for lossless. For `jpeg` at 0 only lossless optimization is acceptable. |
| `?array $resize` | `[width, height]` to scale to, or `null`. |
| `bool $strip` | Remove EXIF/XMP/IPTC. The plugin also strips metadata from JPEG, PNG and WebP results itself, keeping the colour profile and orientation, so keeping markers is safe. |
| `bool $lossy_png` | `png` task: colour reduction allowed. |
| `string $pdf_preset` | `pdf` task: `''` lossless (Lossless level), `printer` moderate (Balanced, Custom), `ebook` smaller (Aggressive). Lossless engines ignore it. |
| `int $threads` | Encoder threads from the Background speed (0 = encoder default). |
| `needs_preparing(): bool` | The pixels must be scaled (`$resize`) or rotated (a JPEG with `orientation` > 1). |
| `with_source( ImageInfo $info ): Job` | A copy reading another file, with `resize` cleared. |
| `libavif_quality(): int` | `$quality` mapped to libavif's scale (for libavif-based AVIF encoders). |

### Prepared copies

Engines that can't rotate or scale return `false` from `can_transform()` and decline (`accepts()` → `false`) jobs that `needs_preparing()`. The plugin then makes a lossless PNG that is already rotated and scaled (with the engine for `resize`, Imagick or GD) and calls `accepts()` again with `$job->with_source( $prepared )`. An engine that doesn't decode pixels (a lossless JPEG optimizer that keeps the EXIF orientation, for example) may accept rotated JPEGs as long as `$resize` is `null`.

### Errors and results

- `run()` writes the result to `$dest`, a new path (overwrite it if it exists). During optimization it is a fresh file in the plugin's private temporary folder, which the plugin deletes afterwards; the delivery self-test writes test copies in `uploads/symelo-images/public/`. Don't write anywhere else except temporary files of your own in `Paths::temp_dir()`, and delete those before returning, also on failure.
- On failure throw `EngineException` with a message for admins, escaped like every exception message in WordPress: `new EngineException( esc_html__( '…', 'my-addon' ) )` or `esc_html( sprintf( … ) )`. The plugin stores `$e->plain_message()`, which undoes that escaping, and escapes again wherever it prints it. The message is shown with the file, and the next engine is tried.
- When the engine worked but the result can't be smaller, throw `EngineException::no_gain()`: the plugin stops trying other engines for that job and keeps what it has.
- After `run()`, the plugin checks the output: its content must be the expected format (sniffed from the bytes), same-format results are kept only when meaningfully smaller (1 KB or 1%, unless resized), WebP and AVIF copies only when smaller than the file (an AVIF also smaller than the WebP), and PDFs only when the output is a PDF and smaller. Writing in place is atomic, and the original is backed up first.
- PDF engines check `Image\PdfInfo::read( $job->source() )` (`encrypted`, `signed`, `forms`): signed PDFs must never be rewritten.

## Background speed

`Queue\Pace` holds the speed profiles of **Advanced → Background speed** (`fast`, `normal`, `gentle`).

| Key | Meaning | fast | normal | gentle |
|---|---|---|---|---|
| `budget` | Seconds a background slice works | 25 | 15 | 5 |
| `pause` | Seconds between bulk slices | 0 | 0 | 30 |
| `nice` | CPU priority for encoder programs (0 normal … 19 lowest) | 0 | 10 | 19 |
| `idle_io` | Disk priority "idle" for encoder programs (Linux `ionice -c 3`) | no | no | yes |
| `threads` | Encoder threads (0 = encoder default); also on `Job::$threads` | 0 | 2 | 1 |
| `max_load` | Bulk work waits while the 1-minute load per CPU core is at least this (0 = never) | 0 | 2.0 | 0.7 |

- `Pace::current(): array` is the profile in effect (with the `symelo_images_pace` filter applied). An engine that starts encoder programs reads `nice` and `idle_io` from it for every run, and its thread count from `Job::$threads`.
- `Pace::profile( string $speed ): array` returns one profile; `Pace::SPEEDS` lists the speeds.
- `Pace::busy( array $profile ): bool`, `Pace::load_per_core(): ?float` and `Pace::cpu_count(): int` tell whether the server is busy, for background work of your own.
- Uploads and explicit runs ("Optimize now", WP-CLI without `--paced`, the REST API) don't wait for pauses or load, but still use the profile's priority and threads.

## Admin screen

The screen is **Media → Image Optimizations** (page slug `Admin\Admin::PAGE`, `symelo-images`; screen ID `media_page_symelo-images`). Every tab is printed on each page load and switches in the browser, so cards must be cheap to render.

- **Engines tab cards:** print them on `symelo_images_engines_tab`.
- **Tabs:** add `slug => label` with `symelo_images_tabs`, then print the panel's content on `symelo_images_tab_{$slug}`. The plugin wraps it in `<div class="symelo-panel" data-panel="{slug}">`. `Admin\Ui::url( array( 'tab' => $slug ) )` links to it; links with `data-tab-link="{slug}"` switch tabs in place.
- **Assets:** the plugin's `assets/admin.css` and `assets/admin.js` are loaded on the screen. Load your own only on that screen (`admin_enqueue_scripts`, `$hook_suffix` contains `symelo-images`), with your own handle.

Markup and helpers to reuse (stable within 1.x):

| Class, attribute or helper | Use |
|---|---|
| `<div class="symelo-card">` with an `<h2>` | A card. |
| `<table class="widefat striped symelo-table">` | A table inside a card. |
| `Ui::badge( $status, $label )` → `.symelo-badge .symelo-badge--{ok,warn,bad,info,muted}` | Status badge (escapes its label; print it with a `phpcs:ignore` for `OutputNotEscaped` and that reason). |
| `Ui::checkbox( $name, $checked, $label, $help, $disabled, $why )` → `.symelo-check` | A checkbox that posts `0` when unticked; disabled ones show `$why` in `.symelo-unsupported`. |
| `.description`, `.symelo-note`, `.symelo-unsupported`, `.symelo-inline-error` | Help text, a note under a status, why something isn't possible, an inline error. |
| `.symelo-button-row` | Buttons in a row (on a `<form>` or `<p>`). |
| `.button-link.symelo-link-danger`, `.button.symelo-button-danger` | Destructive actions. |
| `data-confirm="Question?"` on a submit button | Asks before submitting (an empty value asks "Are you sure?"). |
| `.symelo-options` / `label.symelo-option` | Radio options with a title and a description. |
| `.symelo-inline-fields`, `table.symelo-meta`, `details.symelo-details`, `textarea.symelo-code` with a `data-copy="#id"` button | Inline fields, a label/value table, a collapsible section, copyable code. |
| `Ui::url( array $args )`, `Ui::bytes( int )`, `Ui::time( int )` | Screen URL, file sizes, dates in the site's time zone. |
| `Admin\Notices::flash( $type, $message )` | A plain-text message (`success`, `error`, `warning`, `info`) shown under the header after your handler redirects back. |

Forms and handlers:

- Post to `admin-post.php` with an action of your own prefix (`admin_post_{your_prefix}_{action}`). Never post to the plugin's `symelo_images_*` actions or use its nonces.
- In the handler: check `current_user_can( \Symelo\Images\Plugin::capability() )` first (stop with `wp_die( …, 403 )`), then `check_admin_referer( '{your_prefix}_{action}' )`, then sanitize every field. Redirect back with `wp_safe_redirect( Ui::url( array( 'tab' => 'tools' ) ) ); exit;`.
- After changing anything that affects engines, call `Plugin::instance()->reset_engines()` so the next use rebuilds the engine list.
- REST routes: a namespace of your own, `permission_callback` checking `Plugin::capability()`, argument schemas for every input.

## Status, Site Health and system information

- `symelo_images_warnings` / `symelo_images_notes`: plain-text strings (see [Filters](#filters)). Warnings are for things the admin should fix (an encoder with known security holes); notes are calm information. The plugin's own Site Health test "Image libraries are up to date" only covers its libraries; add your own Site Health tests with core's `site_status_tests`.
- `symelo_images_system_info`: `$sections['My Add-on'] = array( 'Version' => '1.2.3', … )`. Values are plain text.
- `symelo_images_rest_status`: `$status['my_addon'] = array( … )`.

## Files, options and cleanup

- The plugin owns `uploads/symelo-images/` (`backups/`, `tmp/`, `public/`) and deletes `uploads/symelo-images/tools/` (a folder of pre-release versions) when it is installed or updated. Keep add-on files in a folder of your own, for example `uploads/{your-slug}/`, protected like the plugin's (`index.php`, `.htaccess` with `Require all denied`, `web.config`).
- `Support\Paths::temp_dir()` is the plugin's private temporary folder, for short-lived files of engines. Files older than a day are deleted daily; delete yours right away.
- Keep settings in an option of your own. Never write `symelo_images_settings`, the plugin's table or its other options.
- When the plugin is deleted, it removes transients whose names start with `symelo_images_`, so add-on transients named like that are caches only.
- Clean up on your own uninstall (`uninstall.php`), without loading the plugin.

## Security rules

Add-ons should keep the plugin's rules:

- PHP 7.4 syntax and WordPress 6.4 APIs, unless the add-on requires more.
- Capability, then nonce, in every handler; sanitize input, escape output, `$wpdb->prepare()` every query.
- Never accept a file path or program path from a request or a setting; work only on `Job::source()`, `$dest` and folders you own.
- Run programs only with argument arrays (no shell), a minimal environment and time limits, and refuse versions with known security holes.
- Messages in `EngineException` are escaped where thrown.

## Public classes

Stable within API 1.x (namespace `Symelo\Images`):

| Class | Public API |
|---|---|
| `Plugin` | `instance()`, `capability()`, `settings()`, `engines()`, `reset_engines()` |
| `Engines\EngineInterface`, `Engines\AbstractEngine` | As above |
| `Engines\EngineManager` | `add()`, `remove()`, `all()`, `get()`, `order()`, `candidates()`, `choice()`, `preferred()`, `supports()`, `can()` (static), `usable_tasks()` (static) |
| `Engines\Job` | Properties and methods above |
| `Engines\EngineException` | `NO_GAIN`, `no_gain()`, `plain_message()` |
| `Image\ImageInfo` | Public properties, `read()`, `sniff()`, `read_bytes()`, `pixels()`, `is_image()` |
| `Image\PdfInfo` | `read()` |
| `Queue\Pace` | `SPEEDS`, `profile()`, `current()`, `busy()`, `load_per_core()`, `cpu_count()` |
| `Settings\Settings` | `TASKS` (setting keys themselves aren't part of the API) |
| `Support\Paths` | `temp_dir()` |
| `Support\SystemPackages` | `find()`, `is_patched()` (Debian/Ubuntu package versions with backported security fixes) |
| `Admin\Admin` | `PAGE` |
| `Admin\Ui` | `url()`, `badge()`, `checkbox()`, `bytes()`, `time()` |
| `Admin\Notices` | `flash()` |
