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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user