template; $sourceAbsolutePath = Storage::disk('local')->path($template->file_path); $outputRelativePath = 'templates/generations/'.$generation->uuid.'.pptx'; $outputAbsolutePath = Storage::disk('local')->path($outputRelativePath); Storage::disk('local')->makeDirectory('templates/generations'); if (! copy($sourceAbsolutePath, $outputAbsolutePath)) { throw new RuntimeException('امکان ساخت فایل خروجی وجود ندارد.'); } $values = $generation->values ?? []; $textValues = []; $imageValues = []; foreach ($template->keys as $key) { if (! array_key_exists($key->key, $values)) { continue; } match ($key->type) { TemplateKeyType::Text => $textValues[$key->key] = (string) $values[$key->key], TemplateKeyType::Image => $imageValues[$key->shape_name] = Storage::disk('local')->path($values[$key->key]), TemplateKeyType::Chart => throw new RuntimeException('پرکردن کلید از نوع چارت هنوز پشتیبانی نمی‌شود.'), }; } $zip = new ZipArchive; if ($zip->open($outputAbsolutePath) !== true) { throw new RuntimeException('باز کردن فایل پاورپوینت با خطا مواجه شد.'); } try { foreach ($this->findSlideEntries($zip) as $slideEntry) { $this->applyValuesToSlide($zip, $slideEntry, $textValues, $imageValues); } } finally { $zip->close(); } return $outputRelativePath; } /** * Generates a presentation from AI-produced content by cloning the template's * designated repeatable slide once per outline slide, then filling each slide * (repeatable and fixed alike) with its corresponding text values. Unlike fill(), * this can change the slide count, since the outline's slide count is user-chosen. * * @param array{title_values: array, slides: array>} $outline */ public function fillAiGeneration(TemplateGeneration $generation, array $outline): string { $template = $generation->template; if ($template->ai_repeatable_slide_number === null) { throw new RuntimeException('اسلاید تکرارشونده برای این قالب تنظیم نشده است.'); } $sourceAbsolutePath = Storage::disk('local')->path($template->file_path); $outputRelativePath = 'templates/generations/'.$generation->uuid.'.pptx'; $outputAbsolutePath = Storage::disk('local')->path($outputRelativePath); Storage::disk('local')->makeDirectory('templates/generations'); if (! copy($sourceAbsolutePath, $outputAbsolutePath)) { throw new RuntimeException('امکان ساخت فایل خروجی وجود ندارد.'); } $textKeys = $template->keys->where('type', TemplateKeyType::Text); $repeatableKeys = $textKeys->where('is_repeatable', true); $fixedKeys = $textKeys->where('is_repeatable', false); $titleValues = []; foreach ($fixedKeys as $key) { if (array_key_exists($key->key, $outline['title_values'] ?? [])) { $titleValues[$key->key] = (string) $outline['title_values'][$key->key]; } } $slides = array_values($outline['slides'] ?? []); $repeatCount = count($slides); $zip = new ZipArchive; if ($zip->open($outputAbsolutePath) !== true) { throw new RuntimeException('باز کردن فایل پاورپوینت با خطا مواجه شد.'); } try { $repeatableSlideEntry = sprintf('ppt/slides/slide%d.xml', $template->ai_repeatable_slide_number); $repeatableEntries = [$repeatableSlideEntry]; if ($repeatCount > 1) { $repeatableEntries = [...$repeatableEntries, ...$this->cloneSlide($zip, $repeatableSlideEntry, $repeatCount - 1)]; } foreach ($this->findSlideEntries($zip) as $slideEntry) { $repeatableIndex = array_search($slideEntry, $repeatableEntries, true); if ($repeatableIndex === false) { $this->applyValuesToSlide($zip, $slideEntry, $titleValues, []); continue; } $slideValues = []; foreach ($repeatableKeys as $key) { if (array_key_exists($key->key, $slides[$repeatableIndex] ?? [])) { $slideValues[$key->key] = (string) $slides[$repeatableIndex][$key->key]; } } $this->applyValuesToSlide($zip, $slideEntry, $slideValues, []); } } finally { $zip->close(); } return $outputRelativePath; } /** * @param array $textValues * @param array $imageValues */ private function applyValuesToSlide(ZipArchive $zip, string $slideEntry, array $textValues, array $imageValues): void { $xml = $zip->getFromName($slideEntry); if ($xml === false) { return; } if ($textValues !== []) { $newXml = $this->replaceTextInSlideXml($xml, $textValues); if ($newXml !== null) { $zip->addFromString($slideEntry, $newXml); $xml = $newXml; } } foreach ($imageValues as $shapeName => $imagePath) { $this->replaceShapeImage($zip, $slideEntry, $xml, $shapeName, $imagePath); } } /** * Clones the given slide entry $times additional times, registering each clone in * presentation.xml, presentation.xml.rels and [Content_Types].xml so PowerPoint * recognizes them as real slides, positioned immediately after the source slide in * deck order. Internal shape/paragraph ids inside the cloned XML are left as-is — * they only need to be unique within their own slide part, not deck-wide. * * @return array the newly created slide entry names, in order */ private function cloneSlide(ZipArchive $zip, string $sourceSlideEntry, int $times): array { if ($times <= 0) { return []; } $sourceSlideXml = $zip->getFromName($sourceSlideEntry); if ($sourceSlideXml === false) { throw new RuntimeException('اسلاید الگو برای شبیه‌سازی یافت نشد.'); } $sourceRelsEntry = 'ppt/slides/_rels/'.basename($sourceSlideEntry).'.rels'; $sourceRelsXml = $zip->getFromName($sourceRelsEntry); $presentationXml = $zip->getFromName('ppt/presentation.xml'); $presentationRelsXml = $zip->getFromName('ppt/_rels/presentation.xml.rels'); $contentTypesXml = $zip->getFromName('[Content_Types].xml'); if ($presentationXml === false || $presentationRelsXml === false || $contentTypesXml === false) { throw new RuntimeException('ساختار فایل پاورپوینت نامعتبر است.'); } $presentationDom = $this->loadXml($presentationXml); $relsDom = $this->loadXml($presentationRelsXml); $contentTypesDom = $this->loadXml($contentTypesXml); if ($presentationDom === null || $relsDom === null || $contentTypesDom === null) { throw new RuntimeException('خواندن ساختار فایل پاورپوینت با خطا مواجه شد.'); } $sourceRelId = $this->findSlideRelationshipId($relsDom, $sourceSlideEntry); if ($sourceRelId === null) { throw new RuntimeException('ارتباط اسلاید الگو با فایل ارائه یافت نشد.'); } $presentationXpath = new DOMXPath($presentationDom); $presentationXpath->registerNamespace('p', self::NS_PRESENTATION); $presentationXpath->registerNamespace('r', self::NS_RELATIONSHIPS); $sourceSldIdEl = null; foreach ($presentationXpath->query('//p:sldIdLst/p:sldId') as $sldIdEl) { if ($sldIdEl->getAttributeNS(self::NS_RELATIONSHIPS, 'id') === $sourceRelId) { $sourceSldIdEl = $sldIdEl; break; } } if ($sourceSldIdEl === null) { throw new RuntimeException('اسلاید الگو در فهرست اسلایدها یافت نشد.'); } $nextSlideNumber = $this->nextSlideNumber($zip); $nextRelIndex = $this->maxRelationshipId($relsDom) + 1; $nextSlideId = $this->maxSlideId($presentationDom) + 1; $newEntries = []; $insertAfter = $sourceSldIdEl; for ($i = 0; $i < $times; $i++) { $newSlideNumber = $nextSlideNumber++; $newSlideEntry = "ppt/slides/slide{$newSlideNumber}.xml"; $newRelsEntry = "ppt/slides/_rels/slide{$newSlideNumber}.xml.rels"; $newRelId = 'rId'.$nextRelIndex++; $newSlideId = (string) $nextSlideId++; $zip->addFromString($newSlideEntry, $sourceSlideXml); if ($sourceRelsXml !== false) { $zip->addFromString($newRelsEntry, $sourceRelsXml); } $override = $contentTypesDom->createElementNS(self::NS_CONTENT_TYPES, 'Override'); $override->setAttribute('PartName', '/'.$newSlideEntry); $override->setAttribute('ContentType', self::SLIDE_CONTENT_TYPE); $contentTypesDom->documentElement->appendChild($override); $relationship = $relsDom->createElementNS(self::NS_PACKAGE_RELATIONSHIPS, 'Relationship'); $relationship->setAttribute('Id', $newRelId); $relationship->setAttribute('Type', self::NS_RELATIONSHIPS.'/slide'); $relationship->setAttribute('Target', 'slides/'.basename($newSlideEntry)); $relsDom->documentElement->appendChild($relationship); $sldId = $presentationDom->createElementNS(self::NS_PRESENTATION, 'p:sldId'); $sldId->setAttribute('id', $newSlideId); $sldId->setAttributeNS(self::NS_RELATIONSHIPS, 'r:id', $newRelId); $insertAfter->parentNode->insertBefore($sldId, $insertAfter->nextSibling); $insertAfter = $sldId; $newEntries[] = $newSlideEntry; } $zip->addFromString('[Content_Types].xml', $contentTypesDom->saveXML()); $zip->addFromString('ppt/_rels/presentation.xml.rels', $relsDom->saveXML()); $zip->addFromString('ppt/presentation.xml', $presentationDom->saveXML()); return $newEntries; } private function findSlideRelationshipId(DOMDocument $relsDom, string $slideEntry): ?string { $target = 'slides/'.basename($slideEntry); foreach ($relsDom->getElementsByTagName('Relationship') as $relationship) { if ($relationship->getAttribute('Target') === $target) { return $relationship->getAttribute('Id'); } } return null; } private function maxRelationshipId(DOMDocument $relsDom): int { $max = 0; foreach ($relsDom->getElementsByTagName('Relationship') as $relationship) { if (preg_match('/^rId(\d+)$/', $relationship->getAttribute('Id'), $matches)) { $max = max($max, (int) $matches[1]); } } return $max; } private function maxSlideId(DOMDocument $presentationDom): int { $max = 0; foreach ($presentationDom->getElementsByTagNameNS(self::NS_PRESENTATION, 'sldId') as $sldId) { $max = max($max, (int) $sldId->getAttribute('id')); } return $max; } private function nextSlideNumber(ZipArchive $zip): int { $max = 0; foreach ($this->findSlideEntries($zip) as $entry) { if (preg_match('#slide(\d+)\.xml$#', $entry, $matches)) { $max = max($max, (int) $matches[1]); } } return $max + 1; } private function loadXml(string $xml): ?DOMDocument { $dom = new DOMDocument; $previousSetting = libxml_use_internal_errors(true); $loaded = $dom->loadXML($xml); libxml_clear_errors(); libxml_use_internal_errors($previousSetting); return $loaded ? $dom : null; } /** * @return array */ private function findSlideEntries(ZipArchive $zip): array { $entries = []; for ($i = 0; $i < $zip->numFiles; $i++) { $name = $zip->getNameIndex($i); if ($name !== false && preg_match('#^ppt/slides/slide\d+\.xml$#', $name)) { $entries[] = $name; } } return $entries; } /** * @param array $textValues */ private function replaceTextInSlideXml(string $xml, array $textValues): ?string { if (! str_contains($xml, '{')) { return null; } $lookup = []; foreach ($textValues as $key => $value) { $lookup[$this->normalizeKey($key)] = $value; } $dom = $this->loadXml($xml); if ($dom === null) { return null; } $xpath = new DOMXPath($dom); $xpath->registerNamespace('a', self::NS_DRAWING); $changed = false; foreach ($xpath->query('//a:p') as $paragraph) { $textNodes = iterator_to_array($xpath->query('.//a:r/a:t', $paragraph)); if ($textNodes === []) { continue; } $originalText = ''; foreach ($textNodes as $textNode) { $originalText .= $textNode->textContent; } if (! str_contains($originalText, '{')) { continue; } $newText = preg_replace_callback( '/\{\{?\s*([^{}]+?)\s*\}?\}/u', function (array $matches) use ($lookup): string { $normalizedKey = $this->normalizeKey($matches[1]); return array_key_exists($normalizedKey, $lookup) ? $lookup[$normalizedKey] : $matches[0]; }, $originalText ); if ($newText === null || $newText === $originalText) { continue; } $textNodes[0]->textContent = $newText; for ($i = 1; $i < count($textNodes); $i++) { $textNodes[$i]->textContent = ''; } $changed = true; } return $changed ? $dom->saveXML() : null; } private function normalizeKey(string $key): string { return mb_strtolower(trim($key, "{} \t\n\r\0\x0B")); } private function replaceShapeImage(ZipArchive $zip, string $slideEntry, string $slideXml, string $shapeName, string $newImagePath): void { $relationshipId = $this->findImageRelationshipId($slideXml, $shapeName); if ($relationshipId === null) { return; } $mediaPath = $this->resolveMediaPath($zip, $slideEntry, $relationshipId); if ($mediaPath === null) { return; } $zip->addFromString($mediaPath, $this->reencodeImage($mediaPath, $newImagePath)); } private function findImageRelationshipId(string $slideXml, string $shapeName): ?string { $dom = $this->loadXml($slideXml); if ($dom === null) { return null; } $xpath = new DOMXPath($dom); $xpath->registerNamespace('p', self::NS_PRESENTATION); $xpath->registerNamespace('a', self::NS_DRAWING); $xpath->registerNamespace('r', self::NS_RELATIONSHIPS); foreach ($xpath->query('//p:cNvPr') as $cNvPr) { if ($cNvPr->getAttribute('name') !== $shapeName) { continue; } $picNode = $cNvPr->parentNode; while ($picNode !== null && $picNode->nodeName !== 'p:pic') { $picNode = $picNode->parentNode; } if ($picNode === null) { continue; } $blips = $xpath->query('.//a:blip', $picNode); if ($blips->length === 0) { continue; } $embedId = $blips->item(0)->getAttributeNS(self::NS_RELATIONSHIPS, 'embed'); if ($embedId !== '') { return $embedId; } } return null; } private function resolveMediaPath(ZipArchive $zip, string $slideEntry, string $relationshipId): ?string { $relsEntry = 'ppt/slides/_rels/'.basename($slideEntry).'.rels'; $relsXml = $zip->getFromName($relsEntry); if ($relsXml === false) { return null; } $dom = $this->loadXml($relsXml); if ($dom === null) { return null; } foreach ($dom->getElementsByTagName('Relationship') as $relationship) { if ($relationship->getAttribute('Id') !== $relationshipId) { continue; } return $this->normalizeZipPath('ppt/slides/'.$relationship->getAttribute('Target')); } return null; } private function normalizeZipPath(string $path): string { $segments = []; foreach (explode('/', $path) as $segment) { if ($segment === '' || $segment === '.') { continue; } if ($segment === '..') { array_pop($segments); continue; } $segments[] = $segment; } return implode('/', $segments); } private function reencodeImage(string $mediaPath, string $newImagePath): string { $resource = imagecreatefromstring(file_get_contents($newImagePath)); if ($resource === false) { throw new RuntimeException('فایل تصویر ارسالی معتبر نیست.'); } $extension = strtolower(pathinfo($mediaPath, PATHINFO_EXTENSION)); ob_start(); match ($extension) { 'jpg', 'jpeg' => imagejpeg($resource, quality: 92), 'gif' => imagegif($resource), 'bmp' => imagebmp($resource), default => imagepng($resource), }; $bytes = ob_get_clean(); return $bytes; } }