fix(templates): fill pptx via direct zip/XML editing instead of PhpPresentation

Round-tripping real-world .pptx files through PhpPresentation's object model
was silently corrupting/losing content it doesn't fully model (themes, masters,
layouts, fonts), so placeholder replacement wasn't reliable. Now the service
edits the .pptx as a zip archive directly: only the touched slide XML text runs
and swapped image bytes change, everything else stays byte-identical to the
source file. Drops the now-unused phpoffice/phppresentation dependency.
This commit is contained in:
2026-07-23 00:32:47 +03:30
parent 0a7b7fea17
commit e58dcd8581
3 changed files with 311 additions and 645 deletions

View File

@@ -4,30 +4,46 @@ namespace App\Services;
use App\Enums\TemplateKeyType;
use App\Models\TemplateGeneration;
use DOMDocument;
use DOMXPath;
use Illuminate\Support\Facades\Storage;
use PhpOffice\PhpPresentation\IOFactory;
use PhpOffice\PhpPresentation\PhpPresentation;
use PhpOffice\PhpPresentation\Shape\Drawing\File as FileDrawing;
use PhpOffice\PhpPresentation\Shape\Drawing\Gd as GdDrawing;
use PhpOffice\PhpPresentation\Shape\Group as GroupShape;
use PhpOffice\PhpPresentation\Shape\RichText;
use PhpOffice\PhpPresentation\Shape\RichText\BreakElement;
use PhpOffice\PhpPresentation\Shape\RichText\Paragraph;
use PhpOffice\PhpPresentation\Shape\Table as TableShape;
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';
public function fill(TemplateGeneration $generation): string
{
$template = $generation->template;
$sourcePath = Storage::disk('local')->path($template->file_path);
$presentation = IOFactory::load($sourcePath);
$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)) {
@@ -36,80 +52,72 @@ class TemplateFillService
match ($key->type) {
TemplateKeyType::Text => $textValues[$key->key] = (string) $values[$key->key],
TemplateKeyType::Image => $this->fillImage($presentation, $key->shape_name, Storage::disk('local')->path($values[$key->key])),
TemplateKeyType::Image => $imageValues[$key->shape_name] = Storage::disk('local')->path($values[$key->key]),
TemplateKeyType::Chart => throw new RuntimeException('پرکردن کلید از نوع چارت هنوز پشتیبانی نمی‌شود.'),
};
}
if ($textValues !== []) {
foreach ($presentation->getAllSlides() as $slide) {
$this->replaceInShapes($slide->getShapeCollection(), $textValues);
}
$zip = new ZipArchive;
if ($zip->open($outputAbsolutePath) !== true) {
throw new RuntimeException('باز کردن فایل پاورپوینت با خطا مواجه شد.');
}
$outputRelativePath = 'templates/generations/'.$generation->uuid.'.pptx';
$outputAbsolutePath = Storage::disk('local')->path($outputRelativePath);
try {
$slideEntries = $this->findSlideEntries($zip);
Storage::disk('local')->makeDirectory('templates/generations');
foreach ($slideEntries as $slideEntry) {
$xml = $zip->getFromName($slideEntry);
$writer = IOFactory::createWriter($presentation, 'PowerPoint2007');
$writer->save($outputAbsolutePath);
if ($xml === false) {
continue;
}
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);
}
}
} finally {
$zip->close();
}
return $outputRelativePath;
}
/**
* @param iterable<mixed> $shapes
* @param array<string, string> $textValues
* @return array<int, string>
*/
private function replaceInShapes(iterable $shapes, array $textValues): void
private function findSlideEntries(ZipArchive $zip): array
{
foreach ($shapes as $shape) {
if ($shape instanceof RichText) {
foreach ($shape->getParagraphs() as $paragraph) {
$this->replaceInParagraph($paragraph, $textValues);
}
$entries = [];
continue;
}
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if ($shape instanceof TableShape) {
foreach ($shape->getRows() as $row) {
foreach ($row->getCells() as $cell) {
foreach ($cell->getParagraphs() as $paragraph) {
$this->replaceInParagraph($paragraph, $textValues);
}
}
}
continue;
}
if ($shape instanceof GroupShape) {
$this->replaceInShapes($shape->getShapeCollection(), $textValues);
if ($name !== false && preg_match('#^ppt/slides/slide\d+\.xml$#', $name)) {
$entries[] = $name;
}
}
return $entries;
}
/**
* @param array<string, string> $textValues
*/
private function replaceInParagraph(Paragraph $paragraph, array $textValues): void
private function replaceTextInSlideXml(string $xml, array $textValues): ?string
{
$elements = $paragraph->getRichTextElements();
if ($elements === []) {
return;
}
$originalText = '';
foreach ($elements as $element) {
$originalText .= $element->getText();
}
if (! str_contains($originalText, '{')) {
return;
if (! str_contains($xml, '{')) {
return null;
}
$lookup = [];
@@ -118,36 +126,62 @@ class TemplateFillService
$lookup[$this->normalizeKey($key)] = $value;
}
$newText = preg_replace_callback(
'/\{\{?\s*([^{}]+?)\s*\}?\}/u',
function (array $matches) use ($lookup): string {
$normalizedKey = $this->normalizeKey($matches[1]);
$dom = new DOMDocument;
$previousSetting = libxml_use_internal_errors(true);
$loaded = $dom->loadXML($xml);
libxml_clear_errors();
libxml_use_internal_errors($previousSetting);
return array_key_exists($normalizedKey, $lookup) ? $lookup[$normalizedKey] : $matches[0];
},
$originalText
);
if ($newText === null || $newText === $originalText) {
return;
if (! $loaded) {
return null;
}
$target = null;
$xpath = new DOMXPath($dom);
$xpath->registerNamespace('a', self::NS_DRAWING);
foreach ($elements as $element) {
if ($element instanceof BreakElement) {
$changed = false;
foreach ($xpath->query('//a:p') as $paragraph) {
$textNodes = iterator_to_array($xpath->query('.//a:r/a:t', $paragraph));
if ($textNodes === []) {
continue;
}
if ($target === null) {
$target = $element;
$element->setText($newText);
$originalText = '';
foreach ($textNodes as $textNode) {
$originalText .= $textNode->textContent;
}
if (! str_contains($originalText, '{')) {
continue;
}
$element->setText('');
$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
@@ -155,52 +189,143 @@ class TemplateFillService
return mb_strtolower(trim($key, "{} \t\n\r\0\x0B"));
}
private function fillImage(PhpPresentation $presentation, ?string $shapeName, string $imagePath): void
private function replaceShapeImage(ZipArchive $zip, string $slideEntry, string $slideXml, string $shapeName, string $newImagePath): void
{
if ($shapeName === null || $shapeName === '') {
$relationshipId = $this->findImageRelationshipId($slideXml, $shapeName);
if ($relationshipId === null) {
return;
}
foreach ($presentation->getAllSlides() as $slide) {
$this->replaceImageInShapes($slide->getShapeCollection(), $shapeName, $imagePath);
$mediaPath = $this->resolveMediaPath($zip, $slideEntry, $relationshipId);
if ($mediaPath === null) {
return;
}
$zip->addFromString($mediaPath, $this->reencodeImage($mediaPath, $newImagePath));
}
/**
* @param iterable<mixed> $shapes
*/
private function replaceImageInShapes(iterable $shapes, string $shapeName, string $imagePath): void
private function findImageRelationshipId(string $slideXml, string $shapeName): ?string
{
foreach ($shapes as $shape) {
if ($shape instanceof GroupShape) {
$this->replaceImageInShapes($shape->getShapeCollection(), $shapeName, $imagePath);
$dom = new DOMDocument;
$previousSetting = libxml_use_internal_errors(true);
$loaded = $dom->loadXML($slideXml);
libxml_clear_errors();
libxml_use_internal_errors($previousSetting);
if (! $loaded) {
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 = new DOMDocument;
$previousSetting = libxml_use_internal_errors(true);
$loaded = $dom->loadXML($relsXml);
libxml_clear_errors();
libxml_use_internal_errors($previousSetting);
if (! $loaded) {
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;
}
if ($shape->getName() !== $shapeName) {
continue;
}
if ($shape instanceof GdDrawing) {
$this->replaceGdImage($shape, $imagePath);
} elseif ($shape instanceof FileDrawing) {
$shape->setPath($imagePath);
}
$segments[] = $segment;
}
return implode('/', $segments);
}
private function replaceGdImage(GdDrawing $shape, string $imagePath): void
private function reencodeImage(string $mediaPath, string $newImagePath): string
{
$info = getimagesize($imagePath);
$image = imagecreatefromstring(file_get_contents($imagePath));
$resource = imagecreatefromstring(file_get_contents($newImagePath));
if ($info === false || $image === false) {
if ($resource === false) {
throw new RuntimeException('فایل تصویر ارسالی معتبر نیست.');
}
$shape->setImageResource($image);
$shape->setMimeType($info['mime']);
$shape->setRenderingFunction(str_replace('/', '', $info['mime']));
$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;
}
}