getDrawingCollection() as $index => $drawing) { if (! $drawing instanceof Drawing || ! str_starts_with($drawing->getPath(), $prefix)) { $warnings[] = '图片 ' . ($index + 1) . ' 无法无损提取,请另附原图;整本 Excel 已保留。'; continue; } $stream = @fopen($drawing->getPath(), 'rb'); if ($stream === false) { $this->failed($sheet); } try { $bytes = stream_get_contents($stream, 20 * 1024 * 1024 + 1); } finally { fclose($stream); } if ($bytes === false || $bytes === '' || strlen($bytes) > 20 * 1024 * 1024) { $this->failed($sheet); } $mime = (new \finfo(FILEINFO_MIME_TYPE))->buffer($bytes); $extension = strtolower(pathinfo($drawing->getPath(), PATHINFO_EXTENSION)); // 不支持浏览器预览的原始格式仍按原字节留存,只提供下载。 $extension = preg_match('/^[a-z0-9]{1,8}$/', $extension) ? $extension : 'bin'; $name = $sheet->getTitle() . '-原件图片-' . ($index + 1) . '.' . $extension; $path = $directory . '/' . ($index + 1) . '.' . $extension; $savedPaths[] = $path; if (! Storage::disk('uploads')->put($path, $bytes, 'private')) { $this->failed($sheet); } $files[] = [ 'kind' => 'signed', 'path' => $path, 'name' => $name, 'sha256' => hash('sha256', $bytes), 'mime_type' => $mime, 'size' => strlen($bytes), 'sheet_name' => $sheet->getTitle(), 'anchor' => $drawing->getCoordinates(), 'image_index' => $index + 1, 'source' => 'workbook_embedded', 'archived_at' => now()->toIso8601String(), ]; } return ['signed_images' => $files, 'image_warnings' => array_values(array_unique($warnings))]; } private function failed(Worksheet $sheet): never { throw ValidationException::withMessages(['source_quote' => '工作表「' . $sheet->getTitle() . '」原件图片提取或存档失败(每张最多 20 MB),本次上传未完成,请重试。']); } }