therealworld / dynimg-module
Extends the OXID dynamic image generation to further image types and adds an optional watermark.
Package info
bitbucket.org/therealworld/dynimg-module/
Type:oxideshop-module
pkg:composer/therealworld/dynimg-module
Requires
- php: ^8.3
- ext-gd: *
- oxid-esales/oxideshop-ce: >=v7.4
- therealworld/tools-plugin: >=v3.2
Requires (Dev)
None
Suggests
- ext-gd: built with FreeType (--with-freetype) for the watermark text
Provides
None
Conflicts
None
Replaces
None
README
Deutsche Fassung: README.de.md

description
Extends the OXID dynamic image generation to further image types, adds an optional watermark, lets the merchant bring their own placeholder — and pre-generates the whole set from the command line.
- Composer package:
therealworld/dynimg-module - OXID module id:
trwdynimg
New oe-console commands
- trw:dynimg:warmup [--dry-run] [--force] [--rebuild[=TIME]] [--types=a,b] [--params=a,b] [--limit=N] [--max-time=N] [--sleep=N] [--max-load=F] [--progress=N] [--samples=N] (short: t:dy:w)
- trw:dynimg:prune [--delete] [--force] [--types=a,b] (short: t:dy:p)
The short form is Symfony's built-in command abbreviation: each colon segment is shortened to the smallest prefix that is still unambiguous. Together with the commands from therealworld/clirun-plugin the second segment needs two letters, because
dbanddynimgcollide ond(t:d:mistrw:db:migrate,t:dy:wis this one).
The commands live in this module, not in the clirun-plugin: only the module knows the extra
image types, and this way the plugin does not need the module as a dependency. They are
registered through the module's services.yaml, so they appear in oe-console as soon as
the module is installed and active in the shop.
Requirements and installation
- OXID eShop CE
>= 7.4, PHP^8.3 ext-gd; for the watermark text GD has to be built with FreeType (--with-freetype)therealworld/tools-plugin>= 3.2
composer require therealworld/dynimg-module
vendor/bin/oe-console trw:module:setup trwdynimg
trw:module:setup comes with therealworld/clirun-plugin; without it,
oe:module:install vendor/therealworld/dynimg-module and oe:module:activate trwdynimg do the
same. The module has no database tables and no migration.
Activating the module changes nothing in the shop yet: the endpoint only takes over once Use the extendable dynamic image generator is switched on and the settings are saved.
The endpoint
The shop rewrites a request for a generated image to a PHP script, but only while the file is
not there (source/.htaccess: RewriteCond %{REQUEST_FILENAME} !-f). Once it exists, Apache
serves it statically. That script is what this module replaces:
- the shop's own
getimg.phpresolves product, manufacturer/vendor and category images - this module's
trwdynimggetimg.phpadditionally resolves the image types other modules bring along (e.g. sliders, editor media, or a module's own object type with pictures), puts the merchant's placeholder in place of the one OXID ships, and optionally watermarks everything it generates
Saving the settings with Use the extendable dynamic image generator switched on rewrites the
image rule in source/.htaccess to trwdynimggetimg.php and places a relative symlink to it in
the shop root; saving with the option off restores getimg.php and removes the link. Nothing
else has to be edited by hand. A real file of that name in the shop root is left alone.
Configuration
Extensions > Modules > the-real-world - Dynamic images > Settings
Image endpoint
| Setting (admin) | Name | Default | Effect |
|---|---|---|---|
| Use the extendable dynamic image generator | bTRWDynImgEnabled | off | Points the image requests at this module's endpoint (see above). Without it the additional image types are not generated at all and neither placeholder nor watermark apply |
Placeholder image
| Setting (admin) | Name | Default | Effect |
|---|---|---|---|
| Own placeholder image (relative to the shop root) | sTRWDynImgNopicPath | empty | Delivered whenever a requested image has no master file, in place of OXID's placeholder. Path relative to source/, e.g. out/pictures/nopic_shop.jpg |
| Placeholder per image type | aTRWDynImgNopicPaths | empty | path fragment => file. The fragment is the part between generated/ and the size folder — a whole branch or a single folder in it. The longest match wins, everything else falls back to the option above |
- The file must not live below
out/pictures/master/— the next shop update would overwrite it. - It has to be of the file type the shop asks for (JPG, or WebP when the WebP conversion is on): the generator converts sizes, not formats. Saving reports a placeholder that is missing or of the wrong type.
- Saving the settings deletes the placeholders already scaled (
nopic.*ingenerated/), so a changed placeholder becomes visible at once. Real pictures are not touched.
Watermark
| Setting (admin) | Name | Default | Effect |
|---|---|---|---|
| Put a watermark on generated images | bTRWDynImgWatermarkEnabled | off | Every newly generated image gets the watermark, product and category images included. Existing images stay as they are — rebuild them with trw:dynimg:warmup --rebuild |
| Path to the watermark file (relative to the shop root) | sTRWDynImgWatermarkPath | out/pictures/watermark.png | A transparent PNG, relative to source/ |
| Watermark position | sTRWDynImgWatermarkPosition | Bottom right | Corner: top left, top right, bottom left, bottom right |
| Margin from the border (px) | iTRWDynImgWatermarkMargin | 10 | Distance from the image border |
| Opacity (0-100%) | iTRWDynImgWatermarkOpacity | 100 | 0 is treated as 100 |
| Size (% of the image) | iTRWDynImgWatermarkSize | 25 | The largest share of image width and height it takes |
The watermark is only ever scaled down, never up: a file at least that share of the biggest image format (e.g. 15 % of 1200 px = 180 px wide) keeps the same proportion on every format. It is kept off the placeholder — that stands for a picture that is missing, not for one worth marking as ours. The watermark file is not part of the code; a deployment does not bring it along.
Watermark text
A line of text set right of the watermark picture, sharing its bottom edge — the shop address, for instance. Position, opacity and margin apply to picture and text together. Only together with the watermark picture.
| Setting (admin) | Name | Default | Effect |
|---|---|---|---|
| Text next to the watermark | sTRWDynImgWatermarkText | empty | Empty means no text |
| Font | sTRWDynImgWatermarkFont | Typewriter (mono) | mono (Courier Prime), serif (PT Serif), sans (PT Sans); shipped in fonts/ under the SIL OFL |
| Text colour (#RRGGBB) | sTRWDynImgWatermarkTextColor | #07640B | An invalid value gives this green |
| Text height (% of the watermark picture height) | iTRWDynImgWatermarkTextSize | 22 | Height of the line (capital plus descender), so text and picture keep their proportion |
| Minimum text height (px) | iTRWDynImgWatermarkTextMinPx | 9 | Below it — or when picture and text together do not fit the image — the picture goes on alone |
| White outline around the text | bTRWDynImgWatermarkTextHalo | on | Keeps the text readable on dark images |
The text is drawn per image rather than baked into the PNG precisely so that it can be left out where it stops being readable.
Requirements check
The settings page checks the PHP it runs in — the web server's, the one serving the images —
and lists what is missing or configured so that it cannot work: GD itself, image formats GD
cannot handle, FreeType (needed for the text), a watermark that is switched on while the
endpoint is not, a watermark file that is missing, unreadable or no PNG, and a text without the
watermark picture. Fields that can have no effect in that PHP are shown locked; a locked field is
not sent, so its stored value stays as it is. trw:dynimg:warmup shows the same for the shell's
PHP in its setup lines.
Warmup (trw:dynimg:warmup)
Pre-generates the dynamic images, so the image endpoint no longer has to. Every image warmed here is one PHP process a visitor — or a crawler — does not start any more.
Nothing about the image set is hardcoded in the command:
- the generator the shop's
.htaccesscurrently points at is instantiated, so the warmed files are byte-identical to the served ones, watermark included - its map "config parameter → generated path pattern" is read from that instance. For this
module's endpoint the map already carries the extra image types merged in from
aTRWDynImgConfParamToPath - the sizes come from wherever they are configured — the shop config for the shop's own image
types, the module configuration for the ones a module brings along — and the quality from
sDefaultImageQuality. Together that is exactly whatPictureHandler::getPicUrl()puts into the url - a master folder only gets the sizes whose path pattern matches the resulting generated folder. That is the very test the endpoint applies before it serves such a path at all
- with
blConvertImagesToWebPswitched on the shop asks forname.jpg.webp, and that is what gets warmed
So a new module image type needs no change here: configure it, and it is warmed.
The run is resumable and repeatable: existing images are recognized and skipped, so a second
run costs a stat() per target and nothing else. It can be interrupted with Ctrl-C or SIGTERM —
the image being written is finished first, everything built so far stays, another run continues
there.
That also means a changed setting — the watermark switched on, another placeholder — never reaches
the images already there. --rebuild is for that. The setup lines at the top say whether the
watermark is actually applied: the endpoint skips it silently when the file is missing or no PNG,
or when the .htaccess leads to the core endpoint.
Options
--dry-run:- only print the plan (per master folder: formats, masters, targets, present, to build) and stop. Nothing is generated
--rebuild[=TIME]:- build existing images anew as well — every one older than TIME (anything
strtotime()reads), without a value older than the start of this run. The old file is set aside and only removed once the new one is there; if the build fails, it comes back. What this run built is newer than TIME, so an interrupted rebuild resumes with--rebuild="<the time it started>"— the setup lines print exactly that
- build existing images anew as well — every one older than TIME (anything
--forceor-f:- skip the confirmation prompt (
--no-interactiondoes the same)
- skip the confirmation prompt (
--types=a,b:- only these master folders. Both a whole branch (
product) and a single folder (product/1) are accepted
- only these master folders. Both a whole branch (
--params=a,b:- only these shop config parameters (
sThumbnailsize,aDetailImageSizes, …). Unknown names abort the run with the list of the known ones
- only these shop config parameters (
--limit=N:- stop after N generated images. Caps the run, not the plan — the table keeps showing the honest total
--max-time=N:- stop after N seconds. Time spent in
--sleep/--max-loaddoes not count against it
- stop after N seconds. Time spent in
--sleep=N:- pause N milliseconds after every generated image
--max-load=F:- pause while the 1-minute load average is above F — full speed at night, out of the way during the day
--progress=N:- refresh the progress line (share, throughput, elapsed, ETA, counters) every N images,
default 25,
0switches it off. On a terminal the line rewrites itself, in a log file every interval gets its own line
- refresh the progress line (share, throughput, elapsed, ETA, counters) every N images,
default 25,
--samples=N:- how many of the noteworthy masters to list at the end, default 8
Run it as the web server user
Files and folders created here have to stay writable for the endpoint. Owned by the wrong user
they silently break the very on-demand path the warmup is supposed to relieve: the next size
nobody warmed cannot be written any more. The command says so when it finds a generated/
folder belonging to someone else, but it cannot fix it.
# preview
docker exec -u www-data <php-container> sh -c \
'cd /var/www/<shop> && vendor/bin/oe-console trw:dynimg:warmup --dry-run'
# the whole set, unattended
docker exec -u www-data <php-container> sh -c \
'cd /var/www/<shop> && vendor/bin/oe-console trw:dynimg:warmup --no-interaction'
# nightly slice: at most an hour, and out of the way while the machine is busy
vendor/bin/oe-console trw:dynimg:warmup -f --max-time=3600 --max-load=4 --sleep=20
# after switching the watermark on: everything anew, at night only
vendor/bin/oe-console trw:dynimg:warmup -f --rebuild --max-time=3600 --max-load=4
# ... and the next nights, with the start time the first run printed
vendor/bin/oe-console trw:dynimg:warmup -f --rebuild="2026-10-18 23:00:00" --max-time=3600 --max-load=4
# one image type only
vendor/bin/oe-console trw:dynimg:warmup -f --types=product/1 --params=sThumbnailsize
What the result table means
- built: the image is on disk, the endpoint will never be asked for it again
- empty: the master carries no image data, so the target is a 0-byte file. The endpoint
produces exactly the same, and the file is deliberately kept: it is worthless, but it ends the
"PHP process per request" for that url. It wants cleaning up (
trw:clear:phantomimages), not retrying - failed: no file was produced. The reason is printed per master, the most common ones are:
- master holds no image data — a 0-byte download or a stored error page
- master holds jpeg data but is named *.png — the file is a valid image, but not the one its name promises. The generator picks its reader by extension, so such a file cannot be scaled by any endpoint: rename it or re-import the article. Typical origin is an importer that writes every downloaded picture under a fixed extension
- placeholder instead of the target — the master is missing, the endpoint answers with the
placeholder and a 404. Every request for it runs PHP;
trw:clear:phantomimagesclears the article references
Articles whose master image is missing are not covered on purpose: they never produce a file, so there is nothing to warm.
trw:clear:phantomimagescomes with therealworld/clirun-plugin, which is deliberately not a dependency of this module. The command therefore only points at it when it is actually registered — and only when the run found something it can clean up. A master merely named after a type it is not stays untouched by it.
Stale
<target>.lckfiles in a generated folder are left over from older versions, where a master whose content contradicted its name aborted the generation inside the file lock. They can be deleted; new ones are not created any more.
Prune (trw:dynimg:prune)
The counterpart of the warmup: it removes the generated size folders that no configured image size asks for any more. Both commands answer "which sizes are legitimate?" with the same code, so they can never disagree.
Change an image size from 400*400 to 500*500 and nothing cleans up after you:
- the endpoint only ever creates
trw:clear:phantomimagesworks per master image, and only belowproduct/— a left over size folder holds derivatives of perfectly valid masters, so it is no phantom- the shop core deletes derivatives only when the master itself is deleted
(
UtilsPic::deletePicture()globsgenerated/*/<filename>), never when a size changes
So generated/slider/400_400_90/ stays behind with a full set of images nobody will ever
request again. Harmless to serve, expensive to store: on a shop with 5,000 articles a single
left over product format is easily a gigabyte. Changing sDefaultImageQuality ages every
folder at once, because the quality is part of the folder name.
What it will not do
- it never touches a picture folder for which no size is configured at all. An empty
aTRWDynImgConfParamToPathwould otherwise mean "everything is obsolete" and take every image of that type with it — and an incomplete map is exactly the state a shop is in when somebody reaches for a cleanup command. Such folders are reported as Left alone - it only ever considers folders named like a size (
width_height_quality) directly below a picture folder. Anything else belowgenerated/is none of its business - it has no
--params, on purpose: narrowing the parameters narrows the set of legitimate sizes, which is harmless while building and destructive while deleting - it deletes nothing without
--delete, and asks before it does - the master pictures are never read, let alone written — everything below
generated/can be recreated by the warmup at any time
Options
--delete:- actually remove the obsolete folders. Without it the run only reports them
--forceor-f:- skip the confirmation prompt (
--no-interactiondoes the same)
- skip the confirmation prompt (
--types=a,b:- only these picture folders, same syntax as in the warmup
# what would go, with file count and size per folder
vendor/bin/oe-console trw:dynimg:prune
# remove it, unattended
docker exec -u www-data <php-container> sh -c \
'cd /var/www/<shop> && vendor/bin/oe-console trw:dynimg:prune --delete --no-interaction'
# one image type only
vendor/bin/oe-console trw:dynimg:prune --types=product/1 --delete -f
The run can be interrupted with Ctrl-C or SIGTERM: the folder being removed is finished first, what was deleted stays deleted, another run continues there.
Limits
- The
.htaccessrule and the symlink are only written when the settings are saved in the admin. SettingbTRWDynImgEnabledany other way (module configuration YAML, config import) does not switch the endpoint. The rewrite expects OXID's standard image rule insource/.htaccess(… getimg.php [NC]). - Placeholder, watermark and the additional image types only work through this module's
endpoint, never through OXID's
getimg.php. - Settings never reach images already generated — use
trw:dynimg:warmup --rebuild.
For developers
Registering an image type
The image types of other modules are registered in four invisible settings (group => null),
usually from the module's onActivate via
ToolsConfig::addOptionToShopConfigArray(<name>, 'trwdynimg', [...]):
| Name | Type | Content |
|---|---|---|
aTRWDynImgConfParamToPath | aarr | Config parameter holding the size => regex of the generated folder, e.g. 'sTRWSliderPicSize' => '/.*\/generated\/slider\/\d+\_\d+\_\d+$/'. Merged into the core generator's map |
aTRWDynImgDetectWithTwoOptions | arr | Path fragments whose generated url carries two options instead of three, so the master path is derived one level higher (e.g. /slider/) |
aTRWDynImgRecursiveTypes | arr | Path fragments of types with nested folders: the size folder sits right in front of the file, the master path is the generated one with master for generated (rte module) |
aTRWDynImgWatermarkExcludedPaths | arr | Path fragments whose pictures never get the watermark (rte module) |
Since OXID 7 a module setting lives in the module configuration only.
getConfigParam()never sees it (Config::loadVarsFromDb()readsoxmodule LIKE ''plus the active theme), and the core's ownisValidPath()looks the size up inoxconfig, where it is not either. Both sides are handled here:DynImgSizeResolverresolves the size for the parameters named inaTRWDynImgConfParamToPath, and this module's generator accepts such a path. Without that, a module image type is neither warmed nor generated on demand — its size folder is simply never created.
Code
src/Root/trwdynimggetimg.php— the endpoint; it walks up tosource/bootstrap.php, so it works fromvendor/and from a symlinkedextensions/folder alikeCore\DynImgGenerator— extendsToolsDynamicImageGenerator(tools-plugin) with the image types, placeholder and watermark;Core\DynImgSizeResolver,Core\DynImgRequirements,Core\DynImgConfig(settings access)Command\WarmupImagesCommand,Command\PruneImagesCommandon the sharedCommand\Base\AbstractImagesCommand(servicestrw.dynimg.warmup,trw.dynimg.prune)- Chain extension of
ModuleConfiguration(admin): requirements check, locked fields, endpoint switch on save; template extensionmodule_config.html.twig(blocksadmin_module_config_form,admin_module_config_var_types,admin_module_config_var_type_bool) - On saving, a link
trwtoolsgetimg.phpleft over from the time this lived in the tools module is removed and the rule switched over