tecnickcom/tc-lib-pdf-image
Image import and embedding utilities for PDF streams. Handles PNG/JPEG and GD-based formats, transparency, palettes, caching, alternate images, and object generation for PDF embedding. Part of the TCPDF ecosystem. Requires PHP 8.2+, gd, zlib.
This is not a PDF writer. tc-lib-pdf-image is the image-ingest half of the TCPDF ecosystem: it decodes a file, normalizes it (palette, alpha, ICC, DPI), and returns an image ID plus metadata that a PDF document object later embeds. Reaching for it directly only makes sense when you want to separate "prepare images" from "render document" — otherwise install tecnickcom/tc-lib-pdf and let it pull this in transitively.
Prerequisites and first check
ext-gd, ext-zlib. Confirm with php -m | grep -E 'gd|zlib' — a missing GD fails at import time, not install time.tcpdf.org/docs/srcdoc/tc-lib-pdf-image for the real Import signature and return shapes. The README covers the happy path only.Minimal wiring
composer require tecnickcom/tc-lib-pdf-image
$encrypt = new \Com\Tecnick\Pdf\Encrypt\Encrypt();
$fileHelper = new \Com\Tecnick\File\File(
allowedHosts: config('pdf-image.allowed_hosts'), // e.g. ['cdn.example.com']
allowedPaths: config('pdf-image.allowed_paths'), // e.g. [storage_path('app/images')]
);
$import = new \Com\Tecnick\Pdf\Image\Import(
kunit: 72 / 25.4, // points-per-unit; 1.0 = points, this value = millimetres
encrypt: $encrypt,
fileHelper: $fileHelper,
);
$imageId = $import->add(storage_path('app/images/logo.png'));
Three things bite people on day one:
kunit must match your document units. If the surrounding PDF object is built in millimetres and you pass 1.0, image dimensions and DPI-derived scaling will be wrong by a factor of ~2.83. Derive it from one place in config.File allowlists are mandatory in practice. Without entries in allowedPaths/allowedHosts, local and remote loads are refused. Populate them from config, not hard-coded strings, and use absolute paths (Storage::disk('local')->path(...)).Encrypt is required even when you are not encrypting. Pass the same Encrypt instance the document uses when encryption is enabled — a fresh one will embed image streams that don't match the document's crypto state.First real use case: a Laravel invoice/statement job that embeds a tenant logo and a signature image. Import both once at the top of the job, keep the returned IDs, and reuse them for every page and every line item — repeated add() calls on the same instance hit the in-memory cache and cost almost nothing.
Bind it in a service provider, scope it per document. Import's default cache lives for the lifetime of the instance. Under Octane or a long-running queue worker, a singleton binding is a memory leak: every processed image stays resident forever. Use $this->app->scoped(...) or resolve a fresh instance per job.
$this->app->singleton(\Com\Tecnick\Pdf\Encrypt\Encrypt::class);
$this->app->bind(\Com\Tecnick\Pdf\Image\Import::class, fn ($app) => new Import(
kunit: (float) config('pdf-image.kunit'),
encrypt: $app->make(Encrypt::class),
fileHelper: new File(
allowedHosts: config('pdf-image.allowed_hosts'),
allowedPaths: config('pdf-image.allowed_paths'),
),
imageCache: $app->make(ImageCacheInterface::class),
));
Wrap it in your own ImageImporter service so controllers and jobs never touch the vendor class directly. That gives you validation, metrics, placeholder fallback, and a single place to swap the package out.
Resolve remote assets locally first. S3 disks cannot be handed to add() directly, and pre-signed URLs are a cache-key disaster (see Gotchas). The reliable pattern is: copy/download to a scratch path under an allowed local directory, import, then delete the temp file. Keep the allowlist narrow — it is an SSRF and path-traversal control, not a formality.
Persistent cache: implement ImageCacheInterface with Laravel's cache, but respect the contract.
final class LaravelImageCache implements ImageCacheInterface
{
public function get(string $key): ?array
{
$v = Cache::store(config('pdf-image.cache_store'))->get('pdfimg:'.hash('xxh128', $key));
return is_array($v) ? $v : null;
}
public function set(string $key, array $data): void
{
Cache::store(config('pdf-image.cache_store'))->put(
'pdfimg:'.hash('xxh128', $key), $data, now()->addDays(30)
);
}
}
Use it for brand assets reused across thousands of documents — that is the whole point of the interface. For one-off tenant uploads the in-memory cache is enough; a persistent cache just adds an invalidation problem.
Batch pipelines. Put PDF generation in a job, catch import failures per asset, log the path and exception, and substitute a bundled placeholder so one corrupt upload doesn't fail an entire nightly run. Fail fast with getimagesize() and a byte/dimension ceiling before calling add() — the library will happily decompress a 60 MP phone photo into ~240 MB of GD memory.
Share objects with the PDF layer. One Encrypt instance per document, shared between Import and the document builder. One kunit constant in config consumed by both. Treat image IDs as opaque document-scoped handles and never persist them across requests.
Testing. Storage::fake('images') plus two or three fixture files (opaque PNG, alpha PNG, baseline JPEG, one broken file). Assert: numeric ID returned, second add() of the same path returns the same ID, unsupported/corrupt input throws, and the allowlist rejects a path outside storage_path. Your CI image needs php-gd — the default php:8.x-cli has it, slim variants may not.
Extension seams are thin. ImageCacheInterface is the only designed-in extension point. Otherwise you subclass or decorate Import and File. There is no event system, no logging hooks, no plugin registry — plan for a wrapper class rather than monkey-patching.
logo-v3.png) or include a content hash in the key.allowed_classes => false and write atomically (LOCK_EX), exactly as the README's filesystem example does. Namespace keys with the package version so upgrades don't read stale structures... segments, and differing case all cause misses that surface as confusing "file not found" or permission exceptions from tc-lib-file, not this package. Use realpath() when you build the list, and test with the exact strings your app produces.ErrorException. A malformed image can therefore throw from deep inside the import with a message that looks unrelated to your code. Catch \Throwable around add() at the asset boundary and always log the source path.memory_limit for the queue worker explicitly, and prefer downscaling with GD/Intervention before handing anything to the library.Import or use reflection to dump the internal structure; log that alongside the document ID so you can correlate a bad render to an asset.kunit drift between the importer and the document builder produces images that are subtly the wrong size — often only noticed in print. Keep one config value and assert it in a test.Import. Per-instance memory cache + long-lived worker = steadily climbing RSS. scoped or per-job resolution.^3.14 in composer.json, read the release notes (3.14.x is a long patch line), and avoid undocumented methods — the public surface you should rely on is the constructor, add(), and ImageCacheInterface.NOASSERTION, while the README states LGPL-3.0. Check LICENSE before shipping. For a hosted SaaS that links dynamically and doesn't modify the library, this is normally fine; get it reviewed if you redistribute binaries or patch the source.Bottom line for a Laravel developer: treat this as a low-level dependency you configure once behind a small wrapper — allowlists from config, kunit from config, a scoped Import, an optional persistent cache for brand assets, per-asset try/catch in jobs, and a hard php-gd requirement in CI. The day-to-day value is cheap image reuse and clean separation; the day-to-day risk is memory, cache invalidation, and allowlist friction.
How can I help you explore Laravel packages today?