diff --git a/src/PhpSpreadsheet/Reader/BaseReader.php b/src/PhpSpreadsheet/Reader/BaseReader.php index c4184bdd40..9dfcfb709d 100644 --- a/src/PhpSpreadsheet/Reader/BaseReader.php +++ b/src/PhpSpreadsheet/Reader/BaseReader.php @@ -60,6 +60,14 @@ abstract class BaseReader implements IReader */ protected bool $createBlankSheetIfNoneRead = false; + /** + * Enable drawing pass-through? + * Identifies whether the Reader should preserve unsupported drawing elements (shapes, grouped images, etc.) + * by storing the original XML for pass-through during write operations. + * When enabled, drawings cannot be modified programmatically but are preserved exactly. + */ + protected bool $enableDrawingPassThrough = false; + /** * IReadFilter instance. */ @@ -125,6 +133,18 @@ public function setIncludeCharts(bool $includeCharts): self return $this; } + public function getEnableDrawingPassThrough(): bool + { + return $this->enableDrawingPassThrough; + } + + public function setEnableDrawingPassThrough(bool $enableDrawingPassThrough): self + { + $this->enableDrawingPassThrough = $enableDrawingPassThrough; + + return $this; + } + /** @return null|string[] */ public function getLoadSheetsOnly(): ?array { diff --git a/src/PhpSpreadsheet/Reader/Xlsx.php b/src/PhpSpreadsheet/Reader/Xlsx.php index 6d36d714cf..09c63c765e 100644 --- a/src/PhpSpreadsheet/Reader/Xlsx.php +++ b/src/PhpSpreadsheet/Reader/Xlsx.php @@ -1481,6 +1481,29 @@ protected function loadSpreadsheetFromFile(string $filename): Spreadsheet $xmlDrawing = $this->loadZipNoNamespace($fileDrawing, ''); $xmlDrawingChildren = $xmlDrawing->children(Namespaces::SPREADSHEET_DRAWING); + // Store drawing XML for pass-through if enabled + if ($this->enableDrawingPassThrough) { + $unparsedDrawings[$drawingRelId] = $xmlDrawing->asXML(); + // Mark that pass-through is enabled for this sheet + $sheetCodeName = $docSheet->getCodeName(); + if (!isset($unparsedLoadedData['sheets']) || !is_array($unparsedLoadedData['sheets'])) { + $unparsedLoadedData['sheets'] = []; + } + if (!isset($unparsedLoadedData['sheets'][$sheetCodeName]) || !is_array($unparsedLoadedData['sheets'][$sheetCodeName])) { + $unparsedLoadedData['sheets'][$sheetCodeName] = []; + } + /** @var array $sheetUnparsedData */ + $sheetUnparsedData = &$unparsedLoadedData['sheets'][$sheetCodeName]; + $sheetUnparsedData['drawingPassThroughEnabled'] = true; + // Store original drawing relationships for pass-through + if ($relsDrawing) { + $sheetUnparsedData['drawingRelationships'] = $relsDrawing->asXML(); + } + // Store original media files paths and source file for pass-through + $sheetUnparsedData['drawingMediaFiles'] = $images; + $sheetUnparsedData['drawingSourceFile'] = File::realpath($filename); + } + if ($xmlDrawingChildren->oneCellAnchor) { foreach ($xmlDrawingChildren->oneCellAnchor as $oneCellAnchor) { $oneCellAnchor = self::testSimpleXml($oneCellAnchor); diff --git a/src/PhpSpreadsheet/Writer/Xlsx.php b/src/PhpSpreadsheet/Writer/Xlsx.php index a38c930e42..85289f0bc9 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx.php +++ b/src/PhpSpreadsheet/Writer/Xlsx.php @@ -437,7 +437,9 @@ public function save($filename, int $flags = 0): void } // Add drawing and image relationship parts - if (($drawingCount > 0) || ($chartCount > 0)) { + /** @var bool $hasPassThroughDrawing */ + $hasPassThroughDrawing = $unparsedSheet['drawingPassThroughEnabled'] ?? false; + if (($drawingCount > 0) || ($chartCount > 0) || $hasPassThroughDrawing) { // Drawing relationships $zipContent['xl/drawings/_rels/drawing' . ($i + 1) . '.xml.rels'] = $this->getWriterPartRels()->writeDrawingRelationships($this->spreadSheet->getSheet($i), $chartRef1, $this->includeCharts); @@ -558,6 +560,9 @@ public function save($filename, int $flags = 0): void } } + // Add pass-through media files (original media that may not be in the drawing collection) + $this->addPassThroughMediaFiles($zipContent); // @phpstan-ignore argument.type + Functions::setReturnDateType($saveDateReturnType); Calculation::getInstance($this->spreadSheet)->getDebugLog()->setWriteDebugLog($saveDebugLog); @@ -843,4 +848,43 @@ public function getRestrictMaxColumnWidth(): bool { return $this->restrictMaxColumnWidth; } + + /** + * Add pass-through media files from original spreadsheet. + * This copies media files that are referenced in pass-through drawing XML + * but may not be in the drawing collection (e.g., unsupported formats like SVG). + * + * @param string[] $zipContent + */ + private function addPassThroughMediaFiles(array &$zipContent): void + { + /** @var array> $sheets */ + $sheets = $this->spreadSheet->getUnparsedLoadedData()['sheets'] ?? []; + foreach ($sheets as $sheetData) { + /** @var string[] $mediaFiles */ + $mediaFiles = $sheetData['drawingMediaFiles'] ?? []; + /** @var ?string $sourceFile */ + $sourceFile = $sheetData['drawingSourceFile'] ?? null; + if (($sheetData['drawingPassThroughEnabled'] ?? false) !== true || $mediaFiles === [] || !is_string($sourceFile) || !file_exists($sourceFile)) { + continue; + } + + $sourceZip = new ZipArchive(); + if ($sourceZip->open($sourceFile) !== true) { + continue; // @codeCoverageIgnore + } + + foreach ($mediaFiles as $mediaPath) { + $zipPath = 'xl/media/' . basename($mediaPath); + if (!isset($zipContent[$zipPath])) { + $mediaContent = $sourceZip->getFromName($mediaPath); + if ($mediaContent !== false) { + $zipContent[$zipPath] = $mediaContent; + } + } + } + + $sourceZip->close(); + } + } } diff --git a/src/PhpSpreadsheet/Writer/Xlsx/ContentTypes.php b/src/PhpSpreadsheet/Writer/Xlsx/ContentTypes.php index 16c7bb978a..1c011a6237 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/ContentTypes.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/ContentTypes.php @@ -156,6 +156,33 @@ public function writeContentTypes(Spreadsheet $spreadsheet, bool $includeCharts $this->writeDefaultContentType($objWriter, $extension, $mimeType); } } + + // Add pass-through media content types + /** @var array> $sheets */ + $sheets = $unparsedLoadedData['sheets'] ?? []; + foreach ($sheets as $sheetData) { + if (($sheetData['drawingPassThroughEnabled'] ?? false) !== true) { + continue; + } + /** @var string[] $mediaFiles */ + $mediaFiles = $sheetData['drawingMediaFiles'] ?? []; + foreach ($mediaFiles as $mediaPath) { + $extension = strtolower(pathinfo($mediaPath, PATHINFO_EXTENSION)); + if ($extension !== '' && !isset($aMediaContentTypes[$extension])) { + $mimeType = match ($extension) { // @phpstan-ignore match.unhandled + 'png' => 'image/png', + 'jpg', 'jpeg' => 'image/jpeg', + 'gif' => 'image/gif', + 'bmp' => 'image/bmp', + 'tif', 'tiff' => 'image/tiff', + 'svg' => 'image/svg+xml', + }; + $aMediaContentTypes[$extension] = $mimeType; + $this->writeDefaultContentType($objWriter, $extension, $mimeType); + } + } + } + if ($spreadsheet->hasRibbonBinObjects()) { // Some additional objects in the ribbon ? // we need to write "Extension" but not already write for media content diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Drawing.php b/src/PhpSpreadsheet/Writer/Xlsx/Drawing.php index 887b436ea9..b14480edb3 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Drawing.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Drawing.php @@ -23,6 +23,11 @@ class Drawing extends WriterPart */ public function writeDrawings(\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet $worksheet, bool $includeCharts = false): string { + // Try to use pass-through drawing XML if available + if ($passThroughXml = $this->getPassThroughDrawingXml($worksheet)) { + return $passThroughXml; + } + // Create XML writer $objWriter = null; if ($this->getParentWriter()->getUseDiskCaching()) { @@ -592,4 +597,27 @@ private static function writeAttributeIf(XMLWriter $objWriter, ?bool $condition, $objWriter->writeAttribute($attr, $val); } } + + /** + * Get pass-through drawing XML if available. + * + * Returns the original drawing XML stored during load (when Reader pass-through was enabled). + * This preserves unsupported drawing elements (shapes, textboxes) that PhpSpreadsheet cannot parse. + * + * @return ?string The pass-through XML, or null if not available or should not be used + */ + private function getPassThroughDrawingXml(\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet $worksheet): ?string + { + /** @var array> $sheets */ + $sheets = $worksheet->getParentOrThrow()->getUnparsedLoadedData()['sheets'] ?? []; + $sheetData = $sheets[$worksheet->getCodeName()] ?? []; + // Only use pass-through XML if the Reader flag was explicitly enabled + /** @var string[] $drawings */ + $drawings = $sheetData['Drawings'] ?? []; + if (($sheetData['drawingPassThroughEnabled'] ?? false) !== true || $drawings === []) { + return null; + } + + return reset($drawings) ?: null; + } } diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Rels.php b/src/PhpSpreadsheet/Writer/Xlsx/Rels.php index 2f3be064b3..8befe745d6 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Rels.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Rels.php @@ -345,6 +345,12 @@ private function writeUnparsedRelationship(\PhpOffice\PhpSpreadsheet\Worksheet\W */ public function writeDrawingRelationships(\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet $worksheet, int &$chartRef, bool $includeCharts = false): string { + // Check if we should use pass-through relationships + $passThroughRels = $this->getPassThroughDrawingRelationships($worksheet); + if ($passThroughRels !== null) { + return $passThroughRels; + } + // Create XML writer $objWriter = null; if ($this->getParentWriter()->getUseDiskCaching()) { @@ -523,4 +529,24 @@ private function writeDrawingHyperLink(XMLWriter $objWriter, BaseDrawing $drawin return $i; } + + /** + * Get pass-through drawing relationships XML if available. + * + * Note: When pass-through is used, the original relationships are returned as-is. + * This means any drawings (images, charts, shapes) added programmatically after + * loading will not be included in the relationships. This is a known limitation + * when combining pass-through with drawing modifications. + */ + private function getPassThroughDrawingRelationships(\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet $worksheet): ?string + { + /** @var array> $sheets */ + $sheets = $worksheet->getParentOrThrow()->getUnparsedLoadedData()['sheets'] ?? []; + $sheetData = $sheets[$worksheet->getCodeName()] ?? []; + if (($sheetData['drawingPassThroughEnabled'] ?? false) !== true || !is_string($sheetData['drawingRelationships'] ?? null)) { + return null; + } + + return $sheetData['drawingRelationships']; + } } diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/DrawingPassThroughTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/DrawingPassThroughTest.php new file mode 100644 index 0000000000..c14b1929be --- /dev/null +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/DrawingPassThroughTest.php @@ -0,0 +1,843 @@ +setEnableDrawingPassThrough(true); + $spreadsheet = $reader->load(self::TEMPLATE); + + $sheet = $spreadsheet->getActiveSheet(); + + // Verify that drawing collection contains only the image (supported element) + $drawings = $sheet->getDrawingCollection(); + self::assertCount(1, $drawings, 'Drawing collection should contain only the image (supported element)'); + + // Verify that unparsed data contains the original drawing XML with shapes + $unparsedData = $spreadsheet->getUnparsedLoadedData(); + $codeName = $sheet->getCodeName(); + self::assertArrayHasKey('sheets', $unparsedData); + self::assertIsArray($unparsedData['sheets']); + self::assertArrayHasKey($codeName, $unparsedData['sheets']); + self::assertIsArray($unparsedData['sheets'][$codeName]); + self::assertArrayHasKey('Drawings', $unparsedData['sheets'][$codeName]); + + // Verify that the drawing XML contains shapes and textboxes + self::assertIsArray($unparsedData['sheets'][$codeName]['Drawings']); + $drawings = $unparsedData['sheets'][$codeName]['Drawings']; + $originalDrawingXml = reset($drawings); + self::assertIsString($originalDrawingXml); + self::assertStringContainsString('', $originalDrawingXml, 'Original XML should contain textbox element'); + + // Save to file + $tempFile = File::temporaryFilename(); + $writer = new XlsxWriter($spreadsheet); + $writer->save($tempFile); + $spreadsheet->disconnectWorksheets(); + + // Verify that the saved XLSX file contains shapes by reading the drawing XML directly + $zip = new ZipArchive(); + $zip->open($tempFile); + $drawingXml = $zip->getFromName('xl/drawings/drawing1.xml'); + $zip->close(); + unlink($tempFile); + + self::assertNotFalse($drawingXml, 'Drawing XML should exist in saved file'); + self::assertStringContainsString('', $drawingXml, 'Shapes should be preserved in saved file'); + self::assertStringContainsString('', $drawingXml, 'Textboxes should be preserved in saved file'); + } + + /** + * Test that WITHOUT Reader pass-through flag, shapes are NOT stored and are LOST. + * This test uses a file with both an image (supported) and a shape (unsupported). + */ + public function testWithoutReaderPassThroughShapesAreLost(): void + { + // First, verify that the original file contains a shape + // Load WITH pass-through to check file contents + $reader = new XlsxReader(); + $reader->setEnableDrawingPassThrough(true); + $spreadsheet = $reader->load(self::TEMPLATE); + $unparsedData = $spreadsheet->getUnparsedLoadedData(); + $codeName = $spreadsheet->getActiveSheet()->getCodeName(); + self::assertIsArray($unparsedData['sheets']); + self::assertArrayHasKey($codeName, $unparsedData['sheets']); + self::assertIsArray($unparsedData['sheets'][$codeName]); + self::assertArrayHasKey('Drawings', $unparsedData['sheets'][$codeName], 'Original file should have drawings'); + self::assertIsArray($unparsedData['sheets'][$codeName]['Drawings']); + $drawings = $unparsedData['sheets'][$codeName]['Drawings']; + $drawingXml = reset($drawings); + self::assertIsString($drawingXml); + self::assertStringContainsString('disconnectWorksheets(); + + // Now test: Load WITHOUT Reader pass-through (XML not stored) + $reader = new XlsxReader(); + // Don't enable pass-through! + $spreadsheet = $reader->load(self::TEMPLATE); + + $sheet = $spreadsheet->getActiveSheet(); + + // Verify that image is in collection (supported element) + $drawings = $sheet->getDrawingCollection(); + self::assertGreaterThan(0, count($drawings), 'Drawing collection should contain the image'); + + // Verify that shape XML is NOT stored (because pass-through disabled) + $unparsedData = $spreadsheet->getUnparsedLoadedData(); + $codeName = $sheet->getCodeName(); + self::assertIsArray($unparsedData['sheets']); + $sheetData = $unparsedData['sheets'][$codeName] ?? []; + self::assertArrayNotHasKey('Drawings', $sheetData, 'Drawings should NOT be stored without Reader pass-through flag'); + + // Save to file + $writer = new XlsxWriter($spreadsheet); + $tempFile = File::temporaryFilename(); + $writer->save($tempFile); + $spreadsheet->disconnectWorksheets(); + + // Verify that shape is LOST by reading the drawing XML directly + $zip = new ZipArchive(); + $zip->open($tempFile); + $drawingXml = $zip->getFromName('xl/drawings/drawing1.xml'); + $zip->close(); + unlink($tempFile); + + // The saved file should have drawing XML (for the image) but NOT the shape or textbox + self::assertNotFalse($drawingXml, 'Drawing XML should exist (for the image)'); + self::assertStringNotContainsString('', $drawingXml, 'Shape should be lost without Reader pass-through'); + self::assertStringNotContainsString('', $drawingXml, 'Textbox should be lost without Reader pass-through'); + } + + /** + * Test that pass-through preserves drawings when a comment is deleted. + * Comments are independent from drawings, so deleting a comment should not affect drawings. + */ + public function testDrawingPassThroughWithCommentDeletion(): void + { + // Load with pass-through enabled + $reader = new XlsxReader(); + $reader->setEnableDrawingPassThrough(true); + $spreadsheet = $reader->load(self::TEMPLATE); + + $sheet = $spreadsheet->getActiveSheet(); + + // Verify that image is in collection + $drawings = $sheet->getDrawingCollection(); + self::assertGreaterThan(0, count($drawings), 'Drawing collection should contain the image'); + + // Verify that shapes and textboxes are in unparsed data + $unparsedData = $spreadsheet->getUnparsedLoadedData(); + $codeName = $sheet->getCodeName(); + self::assertIsArray($unparsedData['sheets']); + self::assertArrayHasKey($codeName, $unparsedData['sheets']); + self::assertIsArray($unparsedData['sheets'][$codeName]); + self::assertArrayHasKey('Drawings', $unparsedData['sheets'][$codeName]); + self::assertIsArray($unparsedData['sheets'][$codeName]['Drawings']); + $drawings = $unparsedData['sheets'][$codeName]['Drawings']; + $originalDrawingXml = reset($drawings); + self::assertIsString($originalDrawingXml); + self::assertStringContainsString('', $originalDrawingXml, 'Original XML should contain textbox'); + + // Verify that a comment exists and delete it + $comments = $sheet->getComments(); + self::assertGreaterThan(0, count($comments), 'Original file should have at least one comment'); + $firstCommentCell = array_key_first($comments); + self::assertIsString($firstCommentCell, 'Comment cell should be a string'); + $originalCommentText = $sheet->getComment($firstCommentCell)->getText()->getPlainText(); + self::assertNotEmpty($originalCommentText, 'Comment should have text'); + + // Delete the comment + $sheet->removeComment($firstCommentCell); + + // Save to file + $tempFile = File::temporaryFilename(); + $writer = new XlsxWriter($spreadsheet); + $writer->save($tempFile); + $spreadsheet->disconnectWorksheets(); + + // Verify that shapes are still present and comment was deleted + $zip = new ZipArchive(); + $zip->open($tempFile); + $drawingXml = $zip->getFromName('xl/drawings/drawing1.xml'); + $commentsXml = $zip->getFromName('xl/comments1.xml'); + $zip->close(); + unlink($tempFile); + + self::assertNotFalse($drawingXml, 'Drawing XML should exist in saved file'); + self::assertStringContainsString('', $drawingXml, 'Shapes should be preserved after comment deletion'); + self::assertStringContainsString('', $drawingXml, 'Textboxes should be preserved after comment deletion'); + + // Verify that comment was deleted (comments XML should not exist or not contain the original comment) + if ($commentsXml !== false) { + self::assertStringNotContainsString($originalCommentText, $commentsXml, 'Original comment text should be deleted'); + } + } + + /** + * Test that WITH pass-through, drawing modifications are NOT applied. + * When pass-through is enabled, the Writer uses the stored XML instead of regenerating, + * so programmatic changes to drawings are ignored. + */ + public function testWithPassThroughDrawingModificationsAreIgnored(): void + { + // Load WITH pass-through + $reader = new XlsxReader(); + $reader->setEnableDrawingPassThrough(true); + $spreadsheet = $reader->load(self::TEMPLATE); + + $sheet = $spreadsheet->getActiveSheet(); + + // Verify that image is in collection + $drawings = $sheet->getDrawingCollection(); + self::assertGreaterThan(0, count($drawings), 'Drawing collection should contain the image'); + + // Modify the drawing (change description) + $drawing = null; + foreach ($drawings as $d) { + $drawing = $d; + + break; + } + self::assertNotNull($drawing, 'Should have at least one drawing'); + + $originalDescription = $drawing->getDescription(); + $newDescription = 'Modified description by test'; + $drawing->setDescription($newDescription); + self::assertNotSame($originalDescription, $newDescription, 'Description should be different'); + + // Save to file (with pass-through, Writer uses stored XML, modifications ignored) + $tempFile = File::temporaryFilename(); + $writer = new XlsxWriter($spreadsheet); + $writer->save($tempFile); + $spreadsheet->disconnectWorksheets(); + + // Reload and verify that the modification was NOT applied (original description preserved) + $reloadReader = new XlsxReader(); + $reloadedSpreadsheet = $reloadReader->load($tempFile); + unlink($tempFile); + + $reloadedDrawings = $reloadedSpreadsheet->getActiveSheet()->getDrawingCollection(); + self::assertGreaterThan(0, count($reloadedDrawings), 'Reloaded file should have drawings'); + + $reloadedDrawing = null; + foreach ($reloadedDrawings as $d) { + $reloadedDrawing = $d; + + break; + } + self::assertNotNull($reloadedDrawing, 'Should have at least one reloaded drawing'); + self::assertSame($originalDescription, $reloadedDrawing->getDescription(), 'Original description should be preserved (modification ignored with pass-through)'); + self::assertNotSame($newDescription, $reloadedDrawing->getDescription(), 'Modified description should NOT be applied with pass-through'); + + $reloadedSpreadsheet->disconnectWorksheets(); + } + + /** + * Test that pass-through preserves drawings when columns are inserted, + * but coordinates are NOT adjusted. + */ + public function testDrawingPassThroughWithColumnInsertion(): void + { + // Load with pass-through enabled + $reader = new XlsxReader(); + $reader->setEnableDrawingPassThrough(true); + $spreadsheet = $reader->load(self::TEMPLATE); + + $sheet = $spreadsheet->getActiveSheet(); + + // Get original drawing coordinates from XML + $originalUnparsedData = $spreadsheet->getUnparsedLoadedData(); + $codeName = $sheet->getCodeName(); + self::assertIsArray($originalUnparsedData['sheets']); + self::assertArrayHasKey($codeName, $originalUnparsedData['sheets']); + self::assertIsArray($originalUnparsedData['sheets'][$codeName]); + self::assertArrayHasKey('Drawings', $originalUnparsedData['sheets'][$codeName]); + self::assertIsArray($originalUnparsedData['sheets'][$codeName]['Drawings']); + $originalDrawings = $originalUnparsedData['sheets'][$codeName]['Drawings']; + $originalDrawingXml = reset($originalDrawings); + self::assertIsString($originalDrawingXml); + + // Extract original column coordinate + preg_match('/(\d+)<\/xdr:col>/', $originalDrawingXml, $originalColMatches); + $originalCol = $originalColMatches[1] ?? null; + self::assertNotNull($originalCol, 'Original drawing should have column coordinate'); + + // Insert a column before B (which should shift drawings at B or later) + $sheet->insertNewColumnBefore('B', 1); + + // Save to file + $tempFile = File::temporaryFilename(); + $writer = new XlsxWriter($spreadsheet); + $writer->save($tempFile); + $spreadsheet->disconnectWorksheets(); + + // Read the drawing XML directly from the saved file + $zip = new ZipArchive(); + $zip->open($tempFile); + $reloadedDrawingXml = $zip->getFromName('xl/drawings/drawing1.xml'); + $zip->close(); + unlink($tempFile); + + self::assertNotFalse($reloadedDrawingXml, 'Drawing XML should exist in saved file'); + + // Extract reloaded column coordinate + preg_match('/(\d+)<\/xdr:col>/', $reloadedDrawingXml, $reloadedColMatches); + $reloadedCol = $reloadedColMatches[1] ?? null; + + // Coordinates are NOT adjusted + // The column coordinate should remain the same (not shifted) + self::assertSame($originalCol, $reloadedCol, 'Drawing column coordinate should NOT be adjusted'); + } + + /** + * Test that pass-through preserves drawings when rows are deleted, + * but coordinates are NOT adjusted. + */ + public function testDrawingPassThroughWithRowDeletion(): void + { + // Load with pass-through enabled + $reader = new XlsxReader(); + $reader->setEnableDrawingPassThrough(true); + $spreadsheet = $reader->load(self::TEMPLATE); + + $sheet = $spreadsheet->getActiveSheet(); + + // Get original drawing coordinates from XML + $originalUnparsedData = $spreadsheet->getUnparsedLoadedData(); + $codeName = $sheet->getCodeName(); + self::assertIsArray($originalUnparsedData['sheets']); + self::assertArrayHasKey($codeName, $originalUnparsedData['sheets']); + self::assertIsArray($originalUnparsedData['sheets'][$codeName]); + self::assertArrayHasKey('Drawings', $originalUnparsedData['sheets'][$codeName]); + self::assertIsArray($originalUnparsedData['sheets'][$codeName]['Drawings']); + $originalDrawings = $originalUnparsedData['sheets'][$codeName]['Drawings']; + $originalDrawingXml = reset($originalDrawings); + self::assertIsString($originalDrawingXml); + + // Extract original row coordinate + preg_match('/(\d+)<\/xdr:row>/', $originalDrawingXml, $originalRowMatches); + $originalRow = $originalRowMatches[1] ?? null; + self::assertNotNull($originalRow, 'Original drawing should have row coordinate'); + + // Delete row 1 (which should shift drawings at row 2 or later) + $sheet->removeRow(1, 1); + + // Save to file + $tempFile = File::temporaryFilename(); + $writer = new XlsxWriter($spreadsheet); + $writer->save($tempFile); + $spreadsheet->disconnectWorksheets(); + + // Read the drawing XML directly from the saved file + $zip = new ZipArchive(); + $zip->open($tempFile); + $reloadedDrawingXml = $zip->getFromName('xl/drawings/drawing1.xml'); + $zip->close(); + unlink($tempFile); + + self::assertNotFalse($reloadedDrawingXml, 'Drawing XML should exist in saved file'); + + // Extract reloaded row coordinate + preg_match('/(\d+)<\/xdr:row>/', $reloadedDrawingXml, $reloadedRowMatches); + $reloadedRow = $reloadedRowMatches[1] ?? null; + + // Coordinates are NOT adjusted + // The row coordinate should remain the same (not shifted) + self::assertSame($originalRow, $reloadedRow, 'Drawing row coordinate should NOT be adjusted after row deletion'); + } + + public function testDrawingPassThroughGetterSetter(): void + { + // Test Reader getter/setter + $reader = new XlsxReader(); + + // Default should be false + self::assertFalse($reader->getEnableDrawingPassThrough()); + + // Enable pass-through + $result = $reader->setEnableDrawingPassThrough(true); + self::assertInstanceOf(XlsxReader::class, $result); + self::assertTrue($reader->getEnableDrawingPassThrough()); + + // Disable pass-through + $reader->setEnableDrawingPassThrough(false); + self::assertFalse($reader->getEnableDrawingPassThrough()); + } + + /** + * Test that the drawingPassThroughEnabled flag is correctly set in unparsedLoadedData. + * This verifies the Reader sets the flag and the Writer's getPassThroughDrawingXml checks it. + */ + public function testDrawingPassThroughEnabledFlagIsSetCorrectly(): void + { + // Test 1: Load WITHOUT pass-through (default) + $reader = new XlsxReader(); + self::assertFalse($reader->getEnableDrawingPassThrough(), 'Pass-through should be disabled by default'); + $spreadsheet = $reader->load(self::TEMPLATE); + + $sheet = $spreadsheet->getActiveSheet(); + $unparsedData = $spreadsheet->getUnparsedLoadedData(); + $codeName = $sheet->getCodeName(); + + // Verify that drawingPassThroughEnabled flag is NOT set when pass-through is disabled + self::assertArrayHasKey('sheets', $unparsedData); + self::assertIsArray($unparsedData['sheets']); + + // The sheet may exist in unparsedData (legacy empty drawings), but the flag should be absent or false + if (isset($unparsedData['sheets'][$codeName])) { + $sheetData = $unparsedData['sheets'][$codeName]; + self::assertIsArray($sheetData); + $flag = $sheetData['drawingPassThroughEnabled'] ?? false; + self::assertFalse($flag, 'drawingPassThroughEnabled should be false/absent when pass-through is disabled'); + } + + $spreadsheet->disconnectWorksheets(); + + // Test 2: Load WITH pass-through enabled + $reader2 = new XlsxReader(); + $reader2->setEnableDrawingPassThrough(true); + self::assertTrue($reader2->getEnableDrawingPassThrough(), 'Pass-through should be enabled'); + $spreadsheet2 = $reader2->load(self::TEMPLATE); + + $sheet2 = $spreadsheet2->getActiveSheet(); + $unparsedData2 = $spreadsheet2->getUnparsedLoadedData(); + $codeName2 = $sheet2->getCodeName(); + + // Verify that drawingPassThroughEnabled flag IS set when pass-through is enabled + self::assertArrayHasKey('sheets', $unparsedData2); + self::assertIsArray($unparsedData2['sheets']); + self::assertArrayHasKey($codeName2, $unparsedData2['sheets']); + self::assertIsArray($unparsedData2['sheets'][$codeName2]); + self::assertArrayHasKey('drawingPassThroughEnabled', $unparsedData2['sheets'][$codeName2], 'drawingPassThroughEnabled flag should exist'); + self::assertTrue($unparsedData2['sheets'][$codeName2]['drawingPassThroughEnabled'], 'drawingPassThroughEnabled should be true when pass-through is enabled'); + + // Verify that the drawing XML is also stored + self::assertArrayHasKey('Drawings', $unparsedData2['sheets'][$codeName2]); + self::assertIsArray($unparsedData2['sheets'][$codeName2]['Drawings']); + self::assertNotEmpty($unparsedData2['sheets'][$codeName2]['Drawings'], 'Drawing XML should be stored when pass-through is enabled'); + + $spreadsheet2->disconnectWorksheets(); + } + + /** + * Test that VML drawings (used by comments) and DrawingML (used by shapes/images) + * coexist without interference when pass-through is enabled. + * This addresses the concern that comments use the drawings folder with VML files. + * The template file already contains a comment in D1, so this test verifies that + * existing comments are preserved AND new comments can be added. + */ + public function testCommentsAndPassThroughCoexist(): void + { + // Load file with drawings (image + shape) and enable pass-through + // Note: The template already contains a comment in D1 + $reader = new XlsxReader(); + $reader->setEnableDrawingPassThrough(true); + $spreadsheet = $reader->load(self::TEMPLATE); + + $sheet = $spreadsheet->getActiveSheet(); + + // Verify the existing comment is loaded + $existingComment = $sheet->getComment('D1'); + $existingCommentText = $existingComment->getText()->getPlainText(); + self::assertNotEmpty($existingCommentText, 'Template should contain a comment in D1'); + + // Add a new comment to the sheet + $sheet->getComment('A1')->getText()->createText('Test comment with pass-through'); + $sheet->getComment('A1')->setAuthor('Test Author'); + + // Save the file + $tempFile = File::temporaryFilename(); + $writer = new XlsxWriter($spreadsheet); + $writer->save($tempFile); + $spreadsheet->disconnectWorksheets(); + + // Verify the file structure contains both VML (for comments) and DrawingML (for shapes) + $zip = new ZipArchive(); + $zip->open($tempFile); + + // Check for VML drawing (used by comments) + $vmlDrawing = $zip->getFromName('xl/drawings/vmlDrawing1.vml'); + self::assertNotFalse($vmlDrawing, 'VML drawing for comments should exist'); + self::assertStringContainsString('urn:schemas-microsoft-com:vml', $vmlDrawing, 'VML should contain VML namespace'); + + // Check for DrawingML (used by shapes/images) + $drawingXml = $zip->getFromName('xl/drawings/drawing1.xml'); + self::assertNotFalse($drawingXml, 'DrawingML for shapes/images should exist'); + self::assertStringContainsString('getFromName('xl/comments1.xml'); + self::assertNotFalse($commentsXml, 'Comments XML should exist'); + self::assertStringContainsString('Test comment with pass-through', $commentsXml, 'New comment (A1) should be in comments XML'); + self::assertStringContainsString($existingCommentText, $commentsXml, 'Existing comment (D1) should be preserved in comments XML'); + + $zip->close(); + unlink($tempFile); + } + + /** + * Test that pass-through preserves SVG images and their relationships. + * Excel stores SVG images with a PNG fallback using separate rIds. + * Without proper pass-through of relationships and media files, the rIds + * become misaligned and images break. + * + * File structure of merge.excel.xlsx: + * - xl/media/image1.png (PNG fallback for SVG) + * - xl/media/image2.svg (SVG image) + * - xl/media/image3.jpeg (JPEG image) + * - Drawing relationships: rId1=PNG, rId2=SVG, rId3=JPEG + */ + public function testDrawingPassThroughPreservesSvgImagesAndRelationships(): void + { + $template = self::DIRECTORY . 'merge.excel.xlsx'; + + // Load with pass-through enabled + $reader = new XlsxReader(); + $reader->setEnableDrawingPassThrough(true); + $spreadsheet = $reader->load($template); + + $sheet = $spreadsheet->getActiveSheet(); + + // Verify that drawing collection contains supported images (PNG and JPEG, not SVG) + $drawings = $sheet->getDrawingCollection(); + self::assertGreaterThanOrEqual(1, count($drawings), 'Drawing collection should contain at least one supported image'); + + // Verify that pass-through data is stored + $unparsedData = $spreadsheet->getUnparsedLoadedData(); + $codeName = $sheet->getCodeName(); + self::assertArrayHasKey('sheets', $unparsedData); + self::assertIsArray($unparsedData['sheets']); + self::assertArrayHasKey($codeName, $unparsedData['sheets']); + self::assertIsArray($unparsedData['sheets'][$codeName]); + self::assertTrue($unparsedData['sheets'][$codeName]['drawingPassThroughEnabled'] ?? false); + + // Verify that drawing relationships are stored + self::assertArrayHasKey('drawingRelationships', $unparsedData['sheets'][$codeName]); + $relsXml = $unparsedData['sheets'][$codeName]['drawingRelationships']; + self::assertIsString($relsXml); + self::assertStringContainsString('image1.png', $relsXml, 'Relationships should reference PNG'); + self::assertStringContainsString('image2.svg', $relsXml, 'Relationships should reference SVG'); + self::assertStringContainsString('image3.jpeg', $relsXml, 'Relationships should reference JPEG'); + + // Verify that media files paths are stored + self::assertArrayHasKey('drawingMediaFiles', $unparsedData['sheets'][$codeName]); + $mediaFiles = $unparsedData['sheets'][$codeName]['drawingMediaFiles']; + self::assertIsArray($mediaFiles); + self::assertGreaterThanOrEqual(3, count($mediaFiles), 'Should have at least 3 media files (PNG, SVG, JPEG)'); + + // Save to file + $tempFile = File::temporaryFilename(); + $writer = new XlsxWriter($spreadsheet); + $writer->save($tempFile); + $spreadsheet->disconnectWorksheets(); + + // Verify that the saved file preserves all images and relationships + $zip = new ZipArchive(); + $zip->open($tempFile); + + // Check that all media files are present + $pngContent = $zip->getFromName('xl/media/image1.png'); + $svgContent = $zip->getFromName('xl/media/image2.svg'); + $jpegContent = $zip->getFromName('xl/media/image3.jpeg'); + + self::assertNotFalse($pngContent, 'PNG image should be preserved'); + self::assertNotFalse($svgContent, 'SVG image should be preserved'); + self::assertNotFalse($jpegContent, 'JPEG image should be preserved'); + + // Check that drawing relationships are correct + $drawingRels = $zip->getFromName('xl/drawings/_rels/drawing1.xml.rels'); + self::assertNotFalse($drawingRels, 'Drawing relationships file should exist'); + self::assertStringContainsString('image1.png', $drawingRels, 'Drawing rels should reference PNG'); + self::assertStringContainsString('image2.svg', $drawingRels, 'Drawing rels should reference SVG'); + self::assertStringContainsString('image3.jpeg', $drawingRels, 'Drawing rels should reference JPEG'); + + // Check that drawing XML references are intact + $drawingXml = $zip->getFromName('xl/drawings/drawing1.xml'); + self::assertNotFalse($drawingXml, 'Drawing XML should exist'); + + // Verify SVG is referenced via svgBlip extension + self::assertStringContainsString('svgBlip', $drawingXml, 'Drawing should contain SVG blip reference'); + + $zip->close(); + unlink($tempFile); + } + + /** + * Test that WITHOUT pass-through, SVG images are lost due to rId misalignment. + * This documents the expected behavior without the fix. + */ + public function testWithoutPassThroughSvgImagesAreLost(): void + { + $template = self::DIRECTORY . 'merge.excel.xlsx'; + + // Verify that the original file contains SVG + $originalZip = new ZipArchive(); + $originalZip->open($template); + $originalSvgContent = $originalZip->getFromName('xl/media/image2.svg'); + $originalZip->close(); + self::assertNotFalse($originalSvgContent, 'Original file should contain SVG'); + + // Load WITHOUT pass-through + $reader = new XlsxReader(); + // Don't enable pass-through + $spreadsheet = $reader->load($template); + + // Save to file + $tempFile = File::temporaryFilename(); + $writer = new XlsxWriter($spreadsheet); + $writer->save($tempFile); + $spreadsheet->disconnectWorksheets(); + + // Verify that SVG is lost + $zip = new ZipArchive(); + $zip->open($tempFile); + + // SVG file should NOT be present (PhpSpreadsheet doesn't support SVG) + $svgContent = $zip->getFromName('xl/media/image2.svg'); + self::assertFalse($svgContent, 'SVG image should NOT be present without pass-through'); + + // PNG and JPEG should still be present (supported formats) + // Note: filenames may be different as they are regenerated + $mediaFiles = []; + for ($i = 0; $i < $zip->numFiles; ++$i) { + $name = $zip->getNameIndex($i); + if ($name !== false && str_starts_with($name, 'xl/media/')) { + $mediaFiles[] = $name; + } + } + + // Should have some media files but not SVG + self::assertNotEmpty($mediaFiles, 'Should have some media files'); + foreach ($mediaFiles as $file) { + self::assertStringNotContainsString('.svg', $file, 'No SVG files should be present without pass-through'); + } + + $zip->close(); + unlink($tempFile); + } + + /** + * Test that pass-through preserves shapes (textboxes, rectangles, etc.) + * in addition to images with SVG. + */ + public function testDrawingPassThroughPreservesShapesAndSvg(): void + { + $template = self::DIRECTORY . 'merge.excel.xlsx'; + + // Load with pass-through enabled + $reader = new XlsxReader(); + $reader->setEnableDrawingPassThrough(true); + $spreadsheet = $reader->load($template); + + // Save to file + $tempFile = File::temporaryFilename(); + $writer = new XlsxWriter($spreadsheet); + $writer->save($tempFile); + $spreadsheet->disconnectWorksheets(); + + // Verify shapes are preserved + $zip = new ZipArchive(); + $zip->open($tempFile); + + $drawingXml = $zip->getFromName('xl/drawings/drawing1.xml'); + self::assertNotFalse($drawingXml, 'Drawing XML should exist'); + + // Check for shapes (xdr:sp elements) + self::assertStringContainsString('', $drawingXml, 'Textboxes should be preserved'); + + // Check for images (xdr:pic elements) + self::assertStringContainsString('close(); + unlink($tempFile); + } + + /** + * Test that pass-through preserves grouped images. + * Grouped images are not parsed into the drawing collection, so they would be lost + * without proper pass-through of drawing XML, relationships, and media files. + * + * File structure of grouped_images.xlsx: + * - Contains a group (xdr:grpSp) with 2 images inside + * - xl/media/image1.png and xl/media/image2.png + * - The images are referenced from within the group element + */ + public function testDrawingPassThroughPreservesGroupedImages(): void + { + $template = self::DIRECTORY . 'grouped_images.xlsx'; + + // Verify that the original file contains a group with images + $originalZip = new ZipArchive(); + $originalZip->open($template); + $originalDrawingXml = $originalZip->getFromName('xl/drawings/drawing1.xml'); + $originalRels = $originalZip->getFromName('xl/drawings/_rels/drawing1.xml.rels'); + $originalZip->close(); + + self::assertNotFalse($originalDrawingXml, 'Original file should have drawing XML'); + self::assertStringContainsString('', $originalDrawingXml, 'Original file should contain a group'); + self::assertStringContainsString('', $originalDrawingXml, 'Original file should contain images inside group'); + self::assertNotFalse($originalRels, 'Original file should have drawing relationships'); + self::assertStringContainsString('image1.png', $originalRels, 'Original rels should reference image1.png'); + self::assertStringContainsString('image2.bmp', $originalRels, 'Original rels should reference image2.bmp'); + self::assertStringContainsString('image4.gif', $originalRels, 'Original rels should reference image4.gif'); + self::assertStringContainsString('image6.svg', $originalRels, 'Original rels should reference image6.svg'); + + // Load with pass-through enabled + $reader = new XlsxReader(); + $reader->setEnableDrawingPassThrough(true); + $spreadsheet = $reader->load($template); + + $sheet = $spreadsheet->getActiveSheet(); + + // Verify that drawing collection is empty (grouped images are not parsed) + $drawings = $sheet->getDrawingCollection(); + self::assertCount(0, $drawings, 'Drawing collection should be empty (grouped images are not parsed)'); + + // Verify that pass-through data is stored + $unparsedData = $spreadsheet->getUnparsedLoadedData(); + $codeName = $sheet->getCodeName(); + self::assertArrayHasKey('sheets', $unparsedData); + self::assertIsArray($unparsedData['sheets']); + self::assertArrayHasKey($codeName, $unparsedData['sheets']); + self::assertIsArray($unparsedData['sheets'][$codeName]); + self::assertTrue($unparsedData['sheets'][$codeName]['drawingPassThroughEnabled'] ?? false, 'Pass-through should be enabled'); + + // Verify that drawing relationships are stored + self::assertArrayHasKey('drawingRelationships', $unparsedData['sheets'][$codeName], 'Drawing relationships should be stored'); + + // Verify that media files paths are stored + self::assertArrayHasKey('drawingMediaFiles', $unparsedData['sheets'][$codeName], 'Media files should be stored'); + $mediaFiles = $unparsedData['sheets'][$codeName]['drawingMediaFiles']; + self::assertIsArray($mediaFiles); + self::assertGreaterThanOrEqual(2, count($mediaFiles), 'Should have at least 2 media files'); + + // Save to file + $tempFile = File::temporaryFilename(); + $writer = new XlsxWriter($spreadsheet); + $writer->save($tempFile); + $spreadsheet->disconnectWorksheets(); + + // Verify that the saved file preserves the group and images + $zip = new ZipArchive(); + $zip->open($tempFile); + + // Check that drawing XML contains the group + $drawingXml = $zip->getFromName('xl/drawings/drawing1.xml'); + self::assertNotFalse($drawingXml, 'Drawing XML should exist in saved file'); + self::assertStringContainsString('', $drawingXml, 'Group should be preserved'); + self::assertStringContainsString('', $drawingXml, 'Images inside group should be preserved'); + + // Check that drawing relationships are preserved + $drawingRels = $zip->getFromName('xl/drawings/_rels/drawing1.xml.rels'); + self::assertNotFalse($drawingRels, 'Drawing relationships file should exist'); + self::assertStringContainsString('image1.png', $drawingRels, 'Drawing rels should reference image1.png'); + self::assertStringContainsString('image2.bmp', $drawingRels, 'Drawing rels should reference image2.bmp'); + self::assertStringContainsString('image4.gif', $drawingRels, 'Drawing rels should reference image4.gif'); + self::assertStringContainsString('image6.svg', $drawingRels, 'Drawing rels should reference image6.svg'); + + // Check that media files are present + $image1Content = $zip->getFromName('xl/media/image1.png'); + $image2Content = $zip->getFromName('xl/media/image2.bmp'); + $image4Content = $zip->getFromName('xl/media/image4.gif'); + $image6Content = $zip->getFromName('xl/media/image6.svg'); + self::assertNotFalse($image1Content, 'image1.png should be preserved'); + self::assertNotFalse($image2Content, 'image2.bmp should be preserved'); + self::assertNotFalse($image4Content, 'image4.gif should be preserved'); + self::assertNotFalse($image6Content, 'image6.svg should be preserved'); + + // Check that Content_Types.xml contains all image extensions + $contentTypes = $zip->getFromName('[Content_Types].xml'); + self::assertNotFalse($contentTypes, 'Content_Types.xml should exist'); + self::assertStringContainsString('image/png', $contentTypes, 'Content_Types should declare PNG mime type'); + self::assertStringContainsString('image/bmp', $contentTypes, 'Content_Types should declare BMP mime type'); + self::assertStringContainsString('image/gif', $contentTypes, 'Content_Types should declare GIF mime type'); + self::assertStringContainsString('image/svg+xml', $contentTypes, 'Content_Types should declare SVG mime type'); + + $zip->close(); + unlink($tempFile); + } + + /** + * Test that WITHOUT pass-through, grouped images are lost. + * This documents the expected behavior without the fix. + */ + public function testWithoutPassThroughGroupedImagesAreLost(): void + { + $template = self::DIRECTORY . 'grouped_images.xlsx'; + + // Verify that the original file contains grouped images + $originalZip = new ZipArchive(); + $originalZip->open($template); + $originalDrawingXml = $originalZip->getFromName('xl/drawings/drawing1.xml'); + $originalZip->close(); + self::assertStringContainsString('', $originalDrawingXml, 'Original file should contain a group'); + + // Load WITHOUT pass-through + $reader = new XlsxReader(); + // Don't enable pass-through + $spreadsheet = $reader->load($template); + + $sheet = $spreadsheet->getActiveSheet(); + + // Verify that drawing collection is empty (grouped images are not parsed) + $drawings = $sheet->getDrawingCollection(); + self::assertCount(0, $drawings, 'Drawing collection should be empty without pass-through too'); + + // Save to file + $tempFile = File::temporaryFilename(); + $writer = new XlsxWriter($spreadsheet); + $writer->save($tempFile); + $spreadsheet->disconnectWorksheets(); + + // Verify that the group and images are lost + $zip = new ZipArchive(); + $zip->open($tempFile); + + // Drawing XML should not exist or not contain the group + $drawingXml = $zip->getFromName('xl/drawings/drawing1.xml'); + + if ($drawingXml !== false) { + // If drawing XML exists, it should NOT contain the group + self::assertStringNotContainsString('', $drawingXml, 'Group should be lost without pass-through'); + } + + // Media files should NOT be present (nothing was in the drawing collection) + $image1Content = $zip->getFromName('xl/media/image1.png'); + $image2Content = $zip->getFromName('xl/media/image2.bmp'); + $image4Content = $zip->getFromName('xl/media/image4.gif'); + $image6Content = $zip->getFromName('xl/media/image6.svg'); + self::assertFalse($image1Content, 'image1.png should NOT be present without pass-through'); + self::assertFalse($image2Content, 'image2.bmp should NOT be present without pass-through'); + self::assertFalse($image4Content, 'image4.gif should NOT be present without pass-through'); + self::assertFalse($image6Content, 'image6.svg should NOT be present without pass-through'); + + $zip->close(); + unlink($tempFile); + } +} diff --git a/tests/data/Writer/XLSX/grouped_images.xlsx b/tests/data/Writer/XLSX/grouped_images.xlsx new file mode 100644 index 0000000000..b59a542cad Binary files /dev/null and b/tests/data/Writer/XLSX/grouped_images.xlsx differ diff --git a/tests/data/Writer/XLSX/issue.4037.xlsx b/tests/data/Writer/XLSX/issue.4037.xlsx new file mode 100644 index 0000000000..5ddf61aeb1 Binary files /dev/null and b/tests/data/Writer/XLSX/issue.4037.xlsx differ diff --git a/tests/data/Writer/XLSX/merge.excel.xlsx b/tests/data/Writer/XLSX/merge.excel.xlsx new file mode 100644 index 0000000000..5f20fcca31 Binary files /dev/null and b/tests/data/Writer/XLSX/merge.excel.xlsx differ