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..c4a4dfb34c 100644 --- a/src/PhpSpreadsheet/Reader/Xlsx.php +++ b/src/PhpSpreadsheet/Reader/Xlsx.php @@ -1481,6 +1481,20 @@ 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] = []; + } + $unparsedLoadedData['sheets'][$sheetCodeName]['drawingPassThroughEnabled'] = true; + } + if ($xmlDrawingChildren->oneCellAnchor) { foreach ($xmlDrawingChildren->oneCellAnchor as $oneCellAnchor) { $oneCellAnchor = self::testSimpleXml($oneCellAnchor); diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Drawing.php b/src/PhpSpreadsheet/Writer/Xlsx/Drawing.php index 887b436ea9..c578dd842f 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,31 @@ 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 + { + $unparsedLoadedData = $worksheet->getParentOrThrow()->getUnparsedLoadedData(); + if (!isset($unparsedLoadedData['sheets']) || !is_array($unparsedLoadedData['sheets'])) { + return null; + } + + $codeName = $worksheet->getCodeName(); + $sheetData = $unparsedLoadedData['sheets'][$codeName] ?? null; + // Only use pass-through XML if the Reader flag was explicitly enabled + if (!is_array($sheetData) || ($sheetData['drawingPassThroughEnabled'] ?? false) !== true || !is_array($sheetData['Drawings'] ?? null)) { + return null; + } + + $firstDrawing = reset($sheetData['Drawings']); + + return is_string($firstDrawing) ? $firstDrawing : null; + } } diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/DrawingPassThroughTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/DrawingPassThroughTest.php new file mode 100644 index 0000000000..f3015cc2d3 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/DrawingPassThroughTest.php @@ -0,0 +1,499 @@ +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); + } +} 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