Fills the gaps between the admin panel screens and the backend: a full free-text + slide-count + language -> AI-generated presentation flow with new slide-cloning logic in TemplateFillService, a "my presentations" list endpoint, a swappable payment-gateway structure for wallet top-ups (fake driver for now), and avatar upload / public-profile toggle / account deletion / password change / notification preference endpoints. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
588 lines
20 KiB
PHP
588 lines
20 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Enums\TemplateKeyType;
|
|
use App\Models\TemplateGeneration;
|
|
use DOMDocument;
|
|
use DOMXPath;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use RuntimeException;
|
|
use ZipArchive;
|
|
|
|
/**
|
|
* Fills a .pptx template by editing it directly as a zip/XML archive, instead of
|
|
* round-tripping it through a full presentation object model. Real-world .pptx files
|
|
* (themes, masters, layouts, animations, embedded fonts) can be lost or corrupted by
|
|
* libraries that fully re-serialize the document; this way only the exact text runs
|
|
* and image bytes we touch are changed, everything else stays byte-identical.
|
|
*/
|
|
class TemplateFillService
|
|
{
|
|
private const NS_PRESENTATION = 'http://schemas.openxmlformats.org/presentationml/2006/main';
|
|
|
|
private const NS_DRAWING = 'http://schemas.openxmlformats.org/drawingml/2006/main';
|
|
|
|
private const NS_RELATIONSHIPS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
|
|
|
|
private const NS_CONTENT_TYPES = 'http://schemas.openxmlformats.org/package/2006/content-types';
|
|
|
|
private const NS_PACKAGE_RELATIONSHIPS = 'http://schemas.openxmlformats.org/package/2006/relationships';
|
|
|
|
private const SLIDE_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.presentationml.slide+xml';
|
|
|
|
public function fill(TemplateGeneration $generation): string
|
|
{
|
|
$template = $generation->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<string, string>, slides: array<int, array<string, string>>} $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<string, string> $textValues
|
|
* @param array<string, string> $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<int, string> 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<int, string>
|
|
*/
|
|
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<string, string> $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;
|
|
}
|
|
}
|