Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
89.97% |
287 / 319 |
|
85.71% |
18 / 21 |
CRAP | |
0.00% |
0 / 1 |
| PdfStamper | |
89.97% |
287 / 319 |
|
85.71% |
18 / 21 |
91.12 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| open | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| openString | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| stampText | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| watermark | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| addPageNumbers | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| header | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| footer | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| stampImage | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| stampPdf | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
4 | |||
| save | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| toBytes | |
85.99% |
135 / 157 |
|
0.00% |
0 / 1 |
45.63 | |||
| getVersionWarnings | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getReader | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getPageCount | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| buildTextOps | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
2 | |||
| buildWatermarkOps | |
100.00% |
27 / 27 |
|
100.00% |
1 / 1 |
1 | |||
| escapeText | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| buildXObjectOps | |
100.00% |
36 / 36 |
|
100.00% |
1 / 1 |
7 | |||
| registerImageXObject | |
93.75% |
15 / 16 |
|
0.00% |
0 / 1 |
4.00 | |||
| registerPdfPageXObject | |
65.38% |
17 / 26 |
|
0.00% |
0 / 1 |
14.15 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Phpdftk\Pdf\Toolkit; |
| 6 | |
| 7 | use Phpdftk\Encoding\WinAnsiEncoder; |
| 8 | use Phpdftk\ImageMetadata\ImageParser; |
| 9 | use Phpdftk\Pdf\Core\Content\ContentStream; |
| 10 | use Phpdftk\Pdf\Core\Content\Resources; |
| 11 | use Phpdftk\Pdf\Core\File\IncrementalWriter; |
| 12 | use Phpdftk\Filesystem\LocalFilesystem; |
| 13 | use Phpdftk\Pdf\Core\Font\StandardFont; |
| 14 | use Phpdftk\Pdf\Core\Font\Type1Font; |
| 15 | use Phpdftk\Pdf\Core\Graphics\ExtGState; |
| 16 | use Phpdftk\Pdf\Core\Graphics\XObject\FormXObject; |
| 17 | use Phpdftk\Pdf\Core\PdfArray; |
| 18 | use Phpdftk\Pdf\Core\PdfDictionary; |
| 19 | use Phpdftk\Pdf\Core\PdfName; |
| 20 | use Phpdftk\Pdf\Core\PdfNumber; |
| 21 | use Phpdftk\Pdf\Core\PdfObject; |
| 22 | use Phpdftk\Pdf\Core\PdfReference; |
| 23 | use Phpdftk\Pdf\Core\PdfStream; |
| 24 | use Phpdftk\Pdf\Reader\PdfReader; |
| 25 | use Phpdftk\Pdf\Toolkit\Internal\PageResolver; |
| 26 | use Phpdftk\Pdf\Toolkit\Stamper\ImageStampStyle; |
| 27 | use Phpdftk\Pdf\Toolkit\Stamper\StampPosition; |
| 28 | use Phpdftk\Pdf\Toolkit\Stamper\StampStyle; |
| 29 | use Phpdftk\Pdf\Toolkit\Stamper\WatermarkStyle; |
| 30 | |
| 31 | /** |
| 32 | * Add text overlays, watermarks, page numbers, headers and footers to PDFs. |
| 33 | * |
| 34 | * Usage: |
| 35 | * PdfStamper::open('report.pdf') |
| 36 | * ->watermark('DRAFT') |
| 37 | * ->addPageNumbers(StampPosition::BottomCenter) |
| 38 | * ->save('stamped.pdf'); |
| 39 | * |
| 40 | * @api |
| 41 | */ |
| 42 | final class PdfStamper |
| 43 | { |
| 44 | private string $originalBytes; |
| 45 | |
| 46 | /** @var list<array{type: string, args: array}> */ |
| 47 | private array $operations = []; |
| 48 | |
| 49 | /** @var list<string> */ |
| 50 | private array $lastVersionWarnings = []; |
| 51 | |
| 52 | private function __construct( |
| 53 | private readonly PdfReader $reader, |
| 54 | string $originalBytes, |
| 55 | ) { |
| 56 | $this->originalBytes = $originalBytes; |
| 57 | } |
| 58 | |
| 59 | public static function open(string $path, string $password = ''): self |
| 60 | { |
| 61 | $bytes = LocalFilesystem::readFile($path); |
| 62 | return new self(PdfReader::fromString($bytes, $password), $bytes); |
| 63 | } |
| 64 | |
| 65 | public static function openString(string $pdfBytes, string $password = ''): self |
| 66 | { |
| 67 | return new self(PdfReader::fromString($pdfBytes, $password), $pdfBytes); |
| 68 | } |
| 69 | |
| 70 | // ----------------------------------------------------------------------- |
| 71 | // Stamp operations |
| 72 | // ----------------------------------------------------------------------- |
| 73 | |
| 74 | public function stampText( |
| 75 | string $text, |
| 76 | StampPosition $position, |
| 77 | ?PageSelector $pages = null, |
| 78 | ?StampStyle $style = null, |
| 79 | ): self { |
| 80 | $this->operations[] = ['type' => 'text', 'args' => compact('text', 'position', 'pages', 'style')]; |
| 81 | return $this; |
| 82 | } |
| 83 | |
| 84 | public function watermark( |
| 85 | string $text, |
| 86 | ?PageSelector $pages = null, |
| 87 | ?WatermarkStyle $style = null, |
| 88 | ): self { |
| 89 | $this->operations[] = ['type' => 'watermark', 'args' => compact('text', 'pages', 'style')]; |
| 90 | return $this; |
| 91 | } |
| 92 | |
| 93 | public function addPageNumbers( |
| 94 | StampPosition $position = StampPosition::BottomCenter, |
| 95 | string $format = 'Page {n} of {total}', |
| 96 | ?StampStyle $style = null, |
| 97 | ?PageSelector $pages = null, |
| 98 | ): self { |
| 99 | $this->operations[] = ['type' => 'pageNumbers', 'args' => compact('position', 'format', 'style', 'pages')]; |
| 100 | return $this; |
| 101 | } |
| 102 | |
| 103 | public function header(string $text, ?StampStyle $style = null, ?PageSelector $pages = null): self |
| 104 | { |
| 105 | return $this->stampText($text, StampPosition::TopCenter, $pages, $style); |
| 106 | } |
| 107 | |
| 108 | public function footer(string $text, ?StampStyle $style = null, ?PageSelector $pages = null): self |
| 109 | { |
| 110 | return $this->stampText($text, StampPosition::BottomCenter, $pages, $style); |
| 111 | } |
| 112 | |
| 113 | /** |
| 114 | * Overlay a JPEG or PNG image at a given position on selected pages. |
| 115 | * |
| 116 | * Dimensions default to the image's native pixel size (at 72 DPI). |
| 117 | * Set width or height in the style to scale; setting one preserves |
| 118 | * the aspect ratio. Set both to stretch. |
| 119 | */ |
| 120 | public function stampImage( |
| 121 | string $imagePath, |
| 122 | StampPosition $position, |
| 123 | ?PageSelector $pages = null, |
| 124 | ?ImageStampStyle $style = null, |
| 125 | ): self { |
| 126 | if (!is_file($imagePath)) { |
| 127 | throw new \RuntimeException("Image file not found: $imagePath"); |
| 128 | } |
| 129 | $this->operations[] = ['type' => 'image', 'args' => compact('imagePath', 'position', 'pages', 'style')]; |
| 130 | return $this; |
| 131 | } |
| 132 | |
| 133 | /** |
| 134 | * Overlay a page from another PDF at a given position on selected pages. |
| 135 | * |
| 136 | * The source page is imported as a Form XObject. Dimensions default to |
| 137 | * the source page's MediaBox size. Use width/height in the style to scale. |
| 138 | * |
| 139 | * @param string $pdfPath Path to the source PDF file |
| 140 | * @param int $pageIndex 0-based page index in the source PDF |
| 141 | */ |
| 142 | public function stampPdf( |
| 143 | string $pdfPath, |
| 144 | int $pageIndex = 0, |
| 145 | ?StampPosition $position = null, |
| 146 | ?PageSelector $pages = null, |
| 147 | ?ImageStampStyle $style = null, |
| 148 | ): self { |
| 149 | if (!is_file($pdfPath)) { |
| 150 | throw new \RuntimeException("PDF file not found: $pdfPath"); |
| 151 | } |
| 152 | $sourceReader = PdfReader::fromFile($pdfPath); |
| 153 | if ($pageIndex < 0 || $pageIndex >= $sourceReader->getPageCount()) { |
| 154 | throw new \InvalidArgumentException(sprintf( |
| 155 | 'Page index %d out of range (source has %d pages)', |
| 156 | $pageIndex, |
| 157 | $sourceReader->getPageCount(), |
| 158 | )); |
| 159 | } |
| 160 | $position ??= StampPosition::Center; |
| 161 | $this->operations[] = ['type' => 'pdf', 'args' => compact('sourceReader', 'pageIndex', 'position', 'pages', 'style')]; |
| 162 | return $this; |
| 163 | } |
| 164 | |
| 165 | // ----------------------------------------------------------------------- |
| 166 | // Output |
| 167 | // ----------------------------------------------------------------------- |
| 168 | |
| 169 | public function save(string $path): void |
| 170 | { |
| 171 | LocalFilesystem::writeFile($path, $this->toBytes(), createDirectories: true); |
| 172 | } |
| 173 | |
| 174 | public function toBytes(): string |
| 175 | { |
| 176 | if (empty($this->operations)) { |
| 177 | return $this->originalBytes; |
| 178 | } |
| 179 | |
| 180 | $writer = IncrementalWriter::fromReader($this->reader, $this->originalBytes); |
| 181 | $pageRefs = PageResolver::getPageReferences($this->reader); |
| 182 | $totalPages = count($pageRefs); |
| 183 | |
| 184 | // Register a standard font for text stamps (only when needed) |
| 185 | $fontRef = null; |
| 186 | $fontName = 'StF1'; |
| 187 | $needsFont = false; |
| 188 | foreach ($this->operations as $op) { |
| 189 | if (in_array($op['type'], ['text', 'watermark', 'pageNumbers'], true)) { |
| 190 | $needsFont = true; |
| 191 | break; |
| 192 | } |
| 193 | } |
| 194 | if ($needsFont) { |
| 195 | $font = new Type1Font(StandardFont::Helvetica); |
| 196 | $fontRef = $writer->addNewObject($font); |
| 197 | } |
| 198 | |
| 199 | // Pre-create shared ExtGState for opacity if needed |
| 200 | $gsRefs = []; |
| 201 | |
| 202 | // Pre-register XObject resources that are shared across pages |
| 203 | $xObjectCounter = 0; |
| 204 | /** @var array<string, PdfReference> $xObjectRefs xoName => ref */ |
| 205 | $xObjectRefs = []; |
| 206 | |
| 207 | // Pre-process image and PDF operations to register XObjects once |
| 208 | foreach ($this->operations as $idx => $op) { |
| 209 | if ($op['type'] === 'image') { |
| 210 | $xObjectCounter++; |
| 211 | $xoName = 'StXo' . $xObjectCounter; |
| 212 | $xoRef = $this->registerImageXObject($writer, $op['args']['imagePath']); |
| 213 | $xObjectRefs[$xoName] = $xoRef; |
| 214 | $this->operations[$idx]['xoName'] = $xoName; |
| 215 | |
| 216 | $info = ImageParser::parse($op['args']['imagePath']); |
| 217 | $this->operations[$idx]['sourceWidth'] = (float) $info->width; |
| 218 | $this->operations[$idx]['sourceHeight'] = (float) $info->height; |
| 219 | } elseif ($op['type'] === 'pdf') { |
| 220 | $xObjectCounter++; |
| 221 | $xoName = 'StXo' . $xObjectCounter; |
| 222 | $sourceReader = $op['args']['sourceReader']; |
| 223 | $pageIndex = $op['args']['pageIndex']; |
| 224 | $sourcePageDict = $sourceReader->getPage($pageIndex); |
| 225 | $sourceDims = PageResolver::getPageDimensions($sourcePageDict, $sourceReader); |
| 226 | |
| 227 | $xoRef = $this->registerPdfPageXObject($writer, $sourceReader, $pageIndex, $sourceDims); |
| 228 | $xObjectRefs[$xoName] = $xoRef; |
| 229 | $this->operations[$idx]['xoName'] = $xoName; |
| 230 | $this->operations[$idx]['sourceWidth'] = $sourceDims['width']; |
| 231 | $this->operations[$idx]['sourceHeight'] = $sourceDims['height']; |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | // Collect stamp content per page |
| 236 | /** @var array<int, list<string>> $pageOps 0-indexed page => list of operator strings */ |
| 237 | $pageOps = []; |
| 238 | /** @var array<int, array<string, PdfReference>> $pageExtGState */ |
| 239 | $pageExtGState = []; |
| 240 | /** @var array<int, array<string, PdfReference>> $pageXObjects */ |
| 241 | $pageXObjects = []; |
| 242 | |
| 243 | foreach ($this->operations as $op) { |
| 244 | for ($i = 0; $i < $totalPages; $i++) { |
| 245 | $pageNum = $i + 1; |
| 246 | $selector = $op['args']['pages'] ?? null; |
| 247 | if ($selector !== null && !$selector->matches($pageNum, $totalPages)) { |
| 248 | continue; |
| 249 | } |
| 250 | |
| 251 | $pageDict = $this->reader->getPage($i); |
| 252 | $dims = PageResolver::getPageDimensions($pageDict, $this->reader); |
| 253 | |
| 254 | $ops = match ($op['type']) { |
| 255 | 'text' => $this->buildTextOps( |
| 256 | $op['args']['text'], |
| 257 | $op['args']['position'], |
| 258 | $op['args']['style'] ?? new StampStyle(), |
| 259 | $dims, |
| 260 | $fontName, |
| 261 | ), |
| 262 | 'watermark' => $this->buildWatermarkOps( |
| 263 | $op['args']['text'], |
| 264 | $op['args']['style'] ?? new WatermarkStyle(), |
| 265 | $dims, |
| 266 | $fontName, |
| 267 | ), |
| 268 | 'pageNumbers' => $this->buildTextOps( |
| 269 | str_replace(['{n}', '{total}'], [(string) $pageNum, (string) $totalPages], $op['args']['format']), |
| 270 | $op['args']['position'], |
| 271 | $op['args']['style'] ?? new StampStyle(fontSize: 10.0), |
| 272 | $dims, |
| 273 | $fontName, |
| 274 | ), |
| 275 | 'image', 'pdf' => $this->buildXObjectOps( |
| 276 | $op['xoName'], |
| 277 | $op['args']['position'], |
| 278 | $op['args']['style'] ?? new ImageStampStyle(), |
| 279 | $dims, |
| 280 | $op['sourceWidth'], |
| 281 | $op['sourceHeight'], |
| 282 | ), |
| 283 | default => [], |
| 284 | }; |
| 285 | |
| 286 | if (!empty($ops)) { |
| 287 | $pageOps[$i] = array_merge($pageOps[$i] ?? [], $ops['operators']); |
| 288 | if (isset($ops['extGState'])) { |
| 289 | foreach ($ops['extGState'] as $gsName => $opacity) { |
| 290 | if (!isset($gsRefs[$gsName])) { |
| 291 | $gs = new ExtGState(); |
| 292 | $gs->ca = $opacity; |
| 293 | $gs->caLower = $opacity; |
| 294 | $gsRefs[$gsName] = $writer->addNewObject($gs); |
| 295 | } |
| 296 | $pageExtGState[$i][$gsName] = $gsRefs[$gsName]; |
| 297 | } |
| 298 | } |
| 299 | if (isset($ops['xObjects'])) { |
| 300 | foreach ($ops['xObjects'] as $xoName) { |
| 301 | $pageXObjects[$i][$xoName] = $xObjectRefs[$xoName]; |
| 302 | } |
| 303 | } |
| 304 | } |
| 305 | } |
| 306 | } |
| 307 | |
| 308 | // For each page with stamps, create content stream and modify page |
| 309 | foreach ($pageOps as $pageIdx => $operators) { |
| 310 | $cs = new ContentStream(); |
| 311 | $cs->raw(implode("\n", $operators)); |
| 312 | |
| 313 | // Build resources for this content stream |
| 314 | $resources = new Resources(); |
| 315 | if ($fontRef !== null) { |
| 316 | $resources->addFont($fontName, $fontRef); |
| 317 | } |
| 318 | foreach ($pageExtGState[$pageIdx] ?? [] as $gsName => $gsRef) { |
| 319 | $resources->addExtGState($gsName, $gsRef); |
| 320 | } |
| 321 | foreach ($pageXObjects[$pageIdx] ?? [] as $xoName => $xoRef) { |
| 322 | $resources->addXObject($xoName, $xoRef); |
| 323 | } |
| 324 | |
| 325 | $csRef = $writer->addNewObject($cs); |
| 326 | |
| 327 | // Modify the page to include the new content stream |
| 328 | $pageDict = $this->reader->getPage($pageIdx); |
| 329 | $existingContents = $pageDict->get('Contents'); |
| 330 | $contentsArray = []; |
| 331 | if ($existingContents instanceof PdfReference) { |
| 332 | $contentsArray[] = $existingContents; |
| 333 | } elseif ($existingContents instanceof PdfArray) { |
| 334 | $contentsArray = $existingContents->items; |
| 335 | } |
| 336 | $contentsArray[] = $csRef; |
| 337 | |
| 338 | $pageDict->set('Contents', new PdfArray($contentsArray)); |
| 339 | |
| 340 | // Merge resources: add font, extgstate, xobjects to existing page resources |
| 341 | $existingRes = $pageDict->get('Resources'); |
| 342 | if ($existingRes instanceof PdfDictionary) { |
| 343 | // Add font |
| 344 | if ($fontRef !== null) { |
| 345 | $fontDict = $existingRes->get('Font'); |
| 346 | if ($fontDict instanceof PdfDictionary) { |
| 347 | $fontDict->set($fontName, $fontRef); |
| 348 | } else { |
| 349 | $existingRes->set('Font', (new PdfDictionary())->set($fontName, $fontRef)); |
| 350 | } |
| 351 | } |
| 352 | // Add ExtGState |
| 353 | foreach ($pageExtGState[$pageIdx] ?? [] as $gsName => $gsRef) { |
| 354 | $gsDict = $existingRes->get('ExtGState'); |
| 355 | if ($gsDict instanceof PdfDictionary) { |
| 356 | $gsDict->set($gsName, $gsRef); |
| 357 | } else { |
| 358 | $existingRes->set('ExtGState', (new PdfDictionary())->set($gsName, $gsRef)); |
| 359 | } |
| 360 | } |
| 361 | // Add XObject |
| 362 | foreach ($pageXObjects[$pageIdx] ?? [] as $xoName => $xoRef) { |
| 363 | $xoDict = $existingRes->get('XObject'); |
| 364 | if ($xoDict instanceof PdfDictionary) { |
| 365 | $xoDict->set($xoName, $xoRef); |
| 366 | } else { |
| 367 | $existingRes->set('XObject', (new PdfDictionary())->set($xoName, $xoRef)); |
| 368 | } |
| 369 | } |
| 370 | } else { |
| 371 | // No existing resources dict — build inline resource dict |
| 372 | $resDict = new PdfDictionary(); |
| 373 | if ($fontRef !== null) { |
| 374 | $resDict->set('Font', (new PdfDictionary())->set($fontName, $fontRef)); |
| 375 | } |
| 376 | foreach ($pageExtGState[$pageIdx] ?? [] as $gsName => $gsRef) { |
| 377 | $gsDictEntry = $resDict->get('ExtGState'); |
| 378 | if (!$gsDictEntry instanceof PdfDictionary) { |
| 379 | $gsDictEntry = new PdfDictionary(); |
| 380 | $resDict->set('ExtGState', $gsDictEntry); |
| 381 | } |
| 382 | $gsDictEntry->set($gsName, $gsRef); |
| 383 | } |
| 384 | foreach ($pageXObjects[$pageIdx] ?? [] as $xoName => $xoRef) { |
| 385 | $xoDictEntry = $resDict->get('XObject'); |
| 386 | if (!$xoDictEntry instanceof PdfDictionary) { |
| 387 | $xoDictEntry = new PdfDictionary(); |
| 388 | $resDict->set('XObject', $xoDictEntry); |
| 389 | } |
| 390 | $xoDictEntry->set($xoName, $xoRef); |
| 391 | } |
| 392 | $pageDict->set('Resources', $resDict); |
| 393 | } |
| 394 | |
| 395 | // Create a PdfObject wrapper for the modified page |
| 396 | $pageObj = new class ($pageDict) extends PdfObject { |
| 397 | public function __construct(private readonly PdfDictionary $dict) {} |
| 398 | public function toPdf(): string |
| 399 | { |
| 400 | return $this->dict->toPdf(); |
| 401 | } |
| 402 | }; |
| 403 | $pageObj->objectNumber = $pageRefs[$pageIdx]->objectNumber; |
| 404 | $pageObj->generationNumber = 0; |
| 405 | $writer->addModifiedObject($pageObj); |
| 406 | } |
| 407 | |
| 408 | $result = $writer->generate(); |
| 409 | $this->lastVersionWarnings = $writer->getVersionWarnings(); |
| 410 | return $result; |
| 411 | } |
| 412 | |
| 413 | // ----------------------------------------------------------------------- |
| 414 | // Escape hatches |
| 415 | // ----------------------------------------------------------------------- |
| 416 | |
| 417 | /** @return list<string> */ |
| 418 | public function getVersionWarnings(): array |
| 419 | { |
| 420 | return $this->lastVersionWarnings; |
| 421 | } |
| 422 | |
| 423 | public function getReader(): PdfReader |
| 424 | { |
| 425 | return $this->reader; |
| 426 | } |
| 427 | |
| 428 | public function getPageCount(): int |
| 429 | { |
| 430 | return $this->reader->getPageCount(); |
| 431 | } |
| 432 | |
| 433 | // ----------------------------------------------------------------------- |
| 434 | // Internal |
| 435 | // ----------------------------------------------------------------------- |
| 436 | |
| 437 | /** |
| 438 | * @return array{operators: list<string>, extGState?: array<string, float>} |
| 439 | */ |
| 440 | private function buildTextOps( |
| 441 | string $text, |
| 442 | StampPosition $position, |
| 443 | StampStyle $style, |
| 444 | array $dims, |
| 445 | string $fontName, |
| 446 | ): array { |
| 447 | $textWidth = strlen($text) * $style->fontSize * 0.5; // approximate |
| 448 | $textHeight = $style->fontSize; |
| 449 | [$x, $y] = $position->computeCoordinates( |
| 450 | $dims['width'], |
| 451 | $dims['height'], |
| 452 | $textWidth, |
| 453 | $textHeight, |
| 454 | ); |
| 455 | |
| 456 | $escaped = $this->escapeText($text); |
| 457 | $operators = ['q']; |
| 458 | |
| 459 | $extGState = []; |
| 460 | if ($style->opacity < 1.0) { |
| 461 | $gsName = 'GsStamp' . (int) ($style->opacity * 100); |
| 462 | $operators[] = "/$gsName gs"; |
| 463 | $extGState[$gsName] = $style->opacity; |
| 464 | } |
| 465 | |
| 466 | $operators[] = sprintf('%.3f %.3f %.3f rg', $style->r, $style->g, $style->b); |
| 467 | $operators[] = 'BT'; |
| 468 | $operators[] = sprintf('/%s %.1f Tf', $fontName, $style->fontSize); |
| 469 | $operators[] = sprintf('%.2f %.2f Td', $x, $y); |
| 470 | $operators[] = sprintf('(%s) Tj', $escaped); |
| 471 | $operators[] = 'ET'; |
| 472 | $operators[] = 'Q'; |
| 473 | |
| 474 | return ['operators' => $operators, 'extGState' => $extGState]; |
| 475 | } |
| 476 | |
| 477 | /** |
| 478 | * @return array{operators: list<string>, extGState?: array<string, float>} |
| 479 | */ |
| 480 | private function buildWatermarkOps( |
| 481 | string $text, |
| 482 | WatermarkStyle $style, |
| 483 | array $dims, |
| 484 | string $fontName, |
| 485 | ): array { |
| 486 | $cx = $dims['width'] / 2; |
| 487 | $cy = $dims['height'] / 2; |
| 488 | $rad = deg2rad($style->rotation); |
| 489 | $cos = cos($rad); |
| 490 | $sin = sin($rad); |
| 491 | |
| 492 | $escaped = $this->escapeText($text); |
| 493 | $textWidth = strlen($text) * $style->fontSize * 0.5; |
| 494 | |
| 495 | $operators = ['q']; |
| 496 | |
| 497 | $gsName = 'GsWm' . (int) ($style->opacity * 100); |
| 498 | $extGState = [$gsName => $style->opacity]; |
| 499 | $operators[] = "/$gsName gs"; |
| 500 | |
| 501 | $operators[] = sprintf('%.3f %.3f %.3f rg', $style->r, $style->g, $style->b); |
| 502 | $operators[] = 'BT'; |
| 503 | $operators[] = sprintf('/%s %.1f Tf', $fontName, $style->fontSize); |
| 504 | // Position: translate to center, then apply rotation matrix |
| 505 | $operators[] = sprintf( |
| 506 | '%.4f %.4f %.4f %.4f %.2f %.2f Tm', |
| 507 | $cos, |
| 508 | $sin, |
| 509 | -$sin, |
| 510 | $cos, |
| 511 | $cx - ($textWidth * $cos / 2), |
| 512 | $cy - ($textWidth * $sin / 2), |
| 513 | ); |
| 514 | $operators[] = sprintf('(%s) Tj', $escaped); |
| 515 | $operators[] = 'ET'; |
| 516 | $operators[] = 'Q'; |
| 517 | |
| 518 | return ['operators' => $operators, 'extGState' => $extGState]; |
| 519 | } |
| 520 | |
| 521 | private function escapeText(string $text): string |
| 522 | { |
| 523 | // The stamper always renders with Helvetica (WinAnsi), so convert |
| 524 | // UTF-8 input to its WinAnsi byte form before escaping reserved |
| 525 | // characters. Without this, an em dash would emit three WinAnsi |
| 526 | // glyphs (â€") instead of one. |
| 527 | $text = (new WinAnsiEncoder())->encode($text); |
| 528 | return str_replace(['\\', '(', ')'], ['\\\\', '\\(', '\\)'], $text); |
| 529 | } |
| 530 | |
| 531 | /** |
| 532 | * Build content stream operators to render an XObject at a position. |
| 533 | * |
| 534 | * @return array{operators: list<string>, extGState?: array<string, float>, xObjects?: list<string>} |
| 535 | */ |
| 536 | private function buildXObjectOps( |
| 537 | string $xoName, |
| 538 | StampPosition $position, |
| 539 | ImageStampStyle $style, |
| 540 | array $dims, |
| 541 | float $sourceWidth, |
| 542 | float $sourceHeight, |
| 543 | ): array { |
| 544 | // Compute display dimensions |
| 545 | if ($style->width !== null && $style->height !== null) { |
| 546 | $displayWidth = $style->width; |
| 547 | $displayHeight = $style->height; |
| 548 | } elseif ($style->width !== null) { |
| 549 | $displayWidth = $style->width; |
| 550 | $displayHeight = $sourceHeight * ($style->width / $sourceWidth); |
| 551 | } elseif ($style->height !== null) { |
| 552 | $displayHeight = $style->height; |
| 553 | $displayWidth = $sourceWidth * ($style->height / $sourceHeight); |
| 554 | } else { |
| 555 | $displayWidth = $sourceWidth; |
| 556 | $displayHeight = $sourceHeight; |
| 557 | } |
| 558 | |
| 559 | [$x, $y] = $position->computeCoordinates( |
| 560 | $dims['width'], |
| 561 | $dims['height'], |
| 562 | $displayWidth, |
| 563 | $displayHeight, |
| 564 | ); |
| 565 | |
| 566 | $operators = ['q']; |
| 567 | $extGState = []; |
| 568 | |
| 569 | if ($style->opacity < 1.0) { |
| 570 | $gsName = 'GsStamp' . (int) ($style->opacity * 100); |
| 571 | $operators[] = "/$gsName gs"; |
| 572 | $extGState[$gsName] = $style->opacity; |
| 573 | } |
| 574 | |
| 575 | // cm operator: scale and translate the XObject |
| 576 | $operators[] = sprintf( |
| 577 | '%.4f 0 0 %.4f %.4f %.4f cm', |
| 578 | $displayWidth, |
| 579 | $displayHeight, |
| 580 | $x, |
| 581 | $y, |
| 582 | ); |
| 583 | $operators[] = "/$xoName Do"; |
| 584 | $operators[] = 'Q'; |
| 585 | |
| 586 | $result = ['operators' => $operators, 'xObjects' => [$xoName]]; |
| 587 | if (!empty($extGState)) { |
| 588 | $result['extGState'] = $extGState; |
| 589 | } |
| 590 | return $result; |
| 591 | } |
| 592 | |
| 593 | /** |
| 594 | * Register a JPEG/PNG image as an ImageXObject in the incremental writer. |
| 595 | */ |
| 596 | private function registerImageXObject(IncrementalWriter $writer, string $imagePath): PdfReference |
| 597 | { |
| 598 | $info = ImageParser::parse($imagePath); |
| 599 | $data = LocalFilesystem::readFile($imagePath); |
| 600 | |
| 601 | $dict = new PdfDictionary([ |
| 602 | 'Type' => new PdfName('XObject'), |
| 603 | 'Subtype' => new PdfName('Image'), |
| 604 | 'Width' => new PdfNumber($info->width), |
| 605 | 'Height' => new PdfNumber($info->height), |
| 606 | 'ColorSpace' => new PdfName($info->colorSpace), |
| 607 | 'BitsPerComponent' => new PdfNumber($info->bitsPerComponent), |
| 608 | ]); |
| 609 | |
| 610 | // Set the appropriate decode filter for pass-through formats |
| 611 | match ($info->format) { |
| 612 | 'jpeg' => $dict->set('Filter', new PdfName('DCTDecode')), |
| 613 | 'jpeg2000' => $dict->set('Filter', new PdfName('JPXDecode')), |
| 614 | default => null, |
| 615 | }; |
| 616 | |
| 617 | $xObject = new PdfStream($dict, $data); |
| 618 | return $writer->addNewObject($xObject); |
| 619 | } |
| 620 | |
| 621 | /** |
| 622 | * Import a page from a source PDF as a Form XObject. |
| 623 | * |
| 624 | * Extracts the page's content streams and resources, wrapping them in |
| 625 | * a single Form XObject that can be rendered via the Do operator. |
| 626 | */ |
| 627 | private function registerPdfPageXObject( |
| 628 | IncrementalWriter $writer, |
| 629 | PdfReader $sourceReader, |
| 630 | int $pageIndex, |
| 631 | array $sourceDims, |
| 632 | ): PdfReference { |
| 633 | $sourcePageDict = $sourceReader->getPage($pageIndex); |
| 634 | |
| 635 | // Collect content stream data |
| 636 | $contentData = ''; |
| 637 | $contents = $sourcePageDict->get('Contents'); |
| 638 | if ($contents instanceof PdfReference) { |
| 639 | $obj = $sourceReader->resolveReference($contents); |
| 640 | if ($obj instanceof PdfStream) { |
| 641 | $contentData = $obj->data; |
| 642 | } elseif ($obj instanceof PdfDictionary) { |
| 643 | $contentData = ''; |
| 644 | } |
| 645 | } elseif ($contents instanceof PdfArray) { |
| 646 | foreach ($contents->items as $ref) { |
| 647 | if ($ref instanceof PdfReference) { |
| 648 | $obj = $sourceReader->resolveReference($ref); |
| 649 | if ($obj instanceof PdfStream) { |
| 650 | $contentData .= $obj->data . "\n"; |
| 651 | } |
| 652 | } |
| 653 | } |
| 654 | } |
| 655 | |
| 656 | $bBox = new PdfArray([ |
| 657 | new PdfNumber(0), new PdfNumber(0), |
| 658 | new PdfNumber($sourceDims['width']), new PdfNumber($sourceDims['height']), |
| 659 | ]); |
| 660 | |
| 661 | $formXObject = new FormXObject($bBox, $contentData); |
| 662 | |
| 663 | // Copy resources from the source page |
| 664 | $sourceResources = $sourcePageDict->get('Resources'); |
| 665 | if ($sourceResources instanceof PdfReference) { |
| 666 | $sourceResources = $sourceReader->resolveReference($sourceResources); |
| 667 | } |
| 668 | if ($sourceResources instanceof PdfDictionary) { |
| 669 | $formXObject->resources = $sourceResources; |
| 670 | } |
| 671 | |
| 672 | return $writer->addNewObject($formXObject); |
| 673 | } |
| 674 | } |