Search by

therealworld / dynimg-module

therealworld

Extends the OXID dynamic image generation to further image types and adds an optional watermark.

Package info

bitbucket.org/therealworld/dynimg-module/

Homepage

Type:oxideshop-module

pkg:composer/therealworld/dynimg-module

Statistics

Installs: 46

Dependents: 1

Suggesters: 1

v1.2.2 2026-10-25 12:13 UTC

This package is auto-updated.

Last update: 2026-10-25 12:15:41 UTC


README

Deutsche Fassung: README.de.md

vendor-logo the-real-world.de

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 db and dynimg collide on d (t:d:m is trw:db:migrate, t:dy:w is 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.php resolves product, manufacturer/vendor and category images
  • this module's trwdynimggetimg.php additionally 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)NameDefaultEffect
Use the extendable dynamic image generatorbTRWDynImgEnabledoffPoints 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)NameDefaultEffect
Own placeholder image (relative to the shop root)sTRWDynImgNopicPathemptyDelivered 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 typeaTRWDynImgNopicPathsemptypath 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.* in generated/), so a changed placeholder becomes visible at once. Real pictures are not touched.

Watermark

Setting (admin)NameDefaultEffect
Put a watermark on generated imagesbTRWDynImgWatermarkEnabledoffEvery 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)sTRWDynImgWatermarkPathout/pictures/watermark.pngA transparent PNG, relative to source/
Watermark positionsTRWDynImgWatermarkPositionBottom rightCorner: top left, top right, bottom left, bottom right
Margin from the border (px)iTRWDynImgWatermarkMargin10Distance from the image border
Opacity (0-100%)iTRWDynImgWatermarkOpacity1000 is treated as 100
Size (% of the image)iTRWDynImgWatermarkSize25The 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)NameDefaultEffect
Text next to the watermarksTRWDynImgWatermarkTextemptyEmpty means no text
FontsTRWDynImgWatermarkFontTypewriter (mono)mono (Courier Prime), serif (PT Serif), sans (PT Sans); shipped in fonts/ under the SIL OFL
Text colour (#RRGGBB)sTRWDynImgWatermarkTextColor#07640BAn invalid value gives this green
Text height (% of the watermark picture height)iTRWDynImgWatermarkTextSize22Height of the line (capital plus descender), so text and picture keep their proportion
Minimum text height (px)iTRWDynImgWatermarkTextMinPx9Below it — or when picture and text together do not fit the image — the picture goes on alone
White outline around the textbTRWDynImgWatermarkTextHaloonKeeps 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 .htaccess currently 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 what PictureHandler::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 blConvertImagesToWebP switched on the shop asks for name.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
  • --force or -f:
    • skip the confirmation prompt (--no-interaction does the same)
  • --types=a,b:
    • only these master folders. Both a whole branch (product) and a single folder (product/1) are accepted
  • --params=a,b:
    • only these shop config parameters (sThumbnailsize, aDetailImageSizes, …). Unknown names abort the run with the list of the known ones
  • --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-load does not count against it
  • --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, 0 switches it off. On a terminal the line rewrites itself, in a log file every interval gets its own line
  • --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:phantomimages clears 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:phantomimages comes 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>.lck files 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:phantomimages works per master image, and only below product/ — 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() globs generated/*/<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 aTRWDynImgConfParamToPath would 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 below generated/ 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
  • --force or -f:
    • skip the confirmation prompt (--no-interaction does the same)
  • --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 .htaccess rule and the symlink are only written when the settings are saved in the admin. Setting bTRWDynImgEnabled any other way (module configuration YAML, config import) does not switch the endpoint. The rewrite expects OXID's standard image rule in source/.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', [...]):

NameTypeContent
aTRWDynImgConfParamToPathaarrConfig parameter holding the size => regex of the generated folder, e.g. 'sTRWSliderPicSize' => '/.*\/generated\/slider\/\d+\_\d+\_\d+$/'. Merged into the core generator's map
aTRWDynImgDetectWithTwoOptionsarrPath fragments whose generated url carries two options instead of three, so the master path is derived one level higher (e.g. /slider/)
aTRWDynImgRecursiveTypesarrPath 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)
aTRWDynImgWatermarkExcludedPathsarrPath 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() reads oxmodule LIKE '' plus the active theme), and the core's own isValidPath() looks the size up in oxconfig, where it is not either. Both sides are handled here: DynImgSizeResolver resolves the size for the parameters named in aTRWDynImgConfParamToPath, 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 to source/bootstrap.php, so it works from vendor/ and from a symlinked extensions/ folder alike
  • Core\DynImgGenerator — extends ToolsDynamicImageGenerator (tools-plugin) with the image types, placeholder and watermark; Core\DynImgSizeResolver, Core\DynImgRequirements, Core\DynImgConfig (settings access)
  • Command\WarmupImagesCommand, Command\PruneImagesCommand on the shared Command\Base\AbstractImagesCommand (services trw.dynimg.warmup, trw.dynimg.prune)
  • Chain extension of ModuleConfiguration (admin): requirements check, locked fields, endpoint switch on save; template extension module_config.html.twig (blocks admin_module_config_form, admin_module_config_var_types, admin_module_config_var_type_bool)
  • On saving, a link trwtoolsgetimg.php left over from the time this lived in the tools module is removed and the rule switched over