From 14d5918e7355d570be6cf07beed2f9df0ab7d7a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agust=C3=ADn=20Busso?= <90727999+agustinbusso@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:29:04 -0300 Subject: [PATCH 1/2] Normalize inline screen images into media Adds a screen inline-image normalizer that converts base64 data URLs in HTML content into Spatie media, deduplicates repeated images by content hash, and stores the normalized config after saves and draft updates. Includes a console command for scanning and dry-run cleanup, plus front-end handling to sync the updated config and surface a success notice after save. --- .../Commands/NormalizeScreenInlineImages.php | 60 ++++ .../Http/Controllers/Api/FileController.php | 1 + .../Http/Controllers/Api/ScreenController.php | 37 +- ProcessMaker/Models/Screen.php | 12 +- .../ScreenInlineImageNormalizationResult.php | 33 ++ .../Screens/ScreenInlineImageNormalizer.php | 190 ++++++++++ public/builds/login/js/app-login.js | 336 +++++++++--------- .../js/processes/screen-builder/screen.vue | 31 +- tests/Feature/Api/ScreenInlineImagesTest.php | 71 ++++ .../ScreenInlineImageNormalizerTest.php | 96 +++++ 10 files changed, 695 insertions(+), 172 deletions(-) create mode 100644 ProcessMaker/Console/Commands/NormalizeScreenInlineImages.php create mode 100644 ProcessMaker/Screens/ScreenInlineImageNormalizationResult.php create mode 100644 ProcessMaker/Screens/ScreenInlineImageNormalizer.php create mode 100644 tests/Feature/Api/ScreenInlineImagesTest.php create mode 100644 tests/unit/Screens/ScreenInlineImageNormalizerTest.php diff --git a/ProcessMaker/Console/Commands/NormalizeScreenInlineImages.php b/ProcessMaker/Console/Commands/NormalizeScreenInlineImages.php new file mode 100644 index 0000000000..fd8342bdca --- /dev/null +++ b/ProcessMaker/Console/Commands/NormalizeScreenInlineImages.php @@ -0,0 +1,60 @@ +orderBy('id'); + if ($this->option('screen')) { + $query->where('id', (int) $this->option('screen')); + } + + $dryRun = (bool) $this->option('dry-run'); + $scanned = 0; + $modified = 0; + $converted = 0; + + $query->chunkById(50, function ($screens) use ($normalizer, $dryRun, &$scanned, &$modified, &$converted) { + foreach ($screens as $screen) { + $scanned++; + $config = $screen->config; + if (!is_array($config) || !$normalizer->configContainsInlineImages($config)) { + continue; + } + + if ($dryRun) { + $modified++; + $this->line("[dry-run] Screen #{$screen->id} ({$screen->title}) contains inline images"); + continue; + } + + $result = $normalizer->normalize($screen, $config); + if (!$result->wasModified()) { + continue; + } + + $screen->config = $result->config(); + $screen->saveOrFail(); + $modified++; + $converted += $result->convertedCount(); + $this->info("Screen #{$screen->id}: converted {$result->convertedCount()} image(s)"); + } + }); + + $this->info("Scanned {$scanned} screen(s); modified {$modified}; new media {$converted}"); + + return self::SUCCESS; + } +} diff --git a/ProcessMaker/Http/Controllers/Api/FileController.php b/ProcessMaker/Http/Controllers/Api/FileController.php index 47ad5560a9..99d1864b53 100644 --- a/ProcessMaker/Http/Controllers/Api/FileController.php +++ b/ProcessMaker/Http/Controllers/Api/FileController.php @@ -226,6 +226,7 @@ public function store(Request $request) 'model_id' => $addedMedia->model_id, 'file_name' => $addedMedia->file_name, 'mime_type' => $addedMedia->mime_type, + 'url' => $addedMedia->getUrl(), ], 200); } diff --git a/ProcessMaker/Http/Controllers/Api/ScreenController.php b/ProcessMaker/Http/Controllers/Api/ScreenController.php index d0545d6042..9f208d590f 100644 --- a/ProcessMaker/Http/Controllers/Api/ScreenController.php +++ b/ProcessMaker/Http/Controllers/Api/ScreenController.php @@ -20,6 +20,8 @@ use ProcessMaker\Models\ScreenType; use ProcessMaker\ProcessTranslations\ScreenTranslation; use ProcessMaker\Query\SyntaxError; +use ProcessMaker\Screens\ScreenInlineImageNormalizationResult; +use ProcessMaker\Screens\ScreenInlineImageNormalizer; use ProcessMaker\Traits\ProjectAssetTrait; class ScreenController extends Controller @@ -301,6 +303,8 @@ public function update(Screen $screen, Request $request) $screen->fill($request->input()); $original = $screen->getOriginal(); + $normalization = $this->normalizeInlineImages($screen); + $this->updateScreenDetails($request, $screen, $original, $lastVersion); $screen->saveOrFail(); @@ -319,7 +323,7 @@ public function update(Screen $screen, Request $request) $screenCache = ScreenCacheFactory::getScreenCache(); $screenCache->clearCompiledAssets(); - return response([], 204); + return $this->screenSaveResponse($normalization); } public function updateScreenDetails($request, $screen, $original, $lastVersion) @@ -392,9 +396,38 @@ public function draft(Screen $screen, Request $request) { $request->validate(Screen::rules($screen)); $screen->fill($request->input()); + $normalization = $this->normalizeInlineImages($screen); $screen->saveDraft(); - return response([], 204); + return $this->screenSaveResponse($normalization); + } + + private function normalizeInlineImages(Screen $screen): ScreenInlineImageNormalizationResult + { + $config = $screen->config; + if (!is_array($config)) { + return new ScreenInlineImageNormalizationResult([], 0, 0); + } + + $result = app(ScreenInlineImageNormalizer::class)->normalize($screen, $config); + if ($result->wasModified()) { + $screen->config = $result->config(); + } + + return $result; + } + + private function screenSaveResponse(ScreenInlineImageNormalizationResult $normalization) + { + if (!$normalization->wasModified()) { + return response([], 204); + } + + return response([ + 'converted_images' => $normalization->convertedCount(), + 'replaced_images' => $normalization->replacedCount(), + 'config' => $normalization->config(), + ], 200); } public function close(Screen $screen) diff --git a/ProcessMaker/Models/Screen.php b/ProcessMaker/Models/Screen.php index 734af25e52..02239718da 100644 --- a/ProcessMaker/Models/Screen.php +++ b/ProcessMaker/Models/Screen.php @@ -18,6 +18,8 @@ use ProcessMaker\Traits\ProjectAssetTrait; use ProcessMaker\Traits\SerializeToIso8601; use ProcessMaker\Validation\CategoryRule; +use Spatie\MediaLibrary\HasMedia; +use Spatie\MediaLibrary\InteractsWithMedia; /** * Class Screen @@ -64,7 +66,7 @@ * @OA\Property(property="url", type="string"), * ) */ -class Screen extends ProcessMakerModel implements ScreenInterface, PrometheusMetricInterface +class Screen extends ProcessMakerModel implements ScreenInterface, PrometheusMetricInterface, HasMedia { use SerializeToIso8601; use HideSystemResources; @@ -74,9 +76,12 @@ class Screen extends ProcessMakerModel implements ScreenInterface, PrometheusMet use ExtendedPMQL; use Exportable; use ProjectAssetTrait; + use InteractsWithMedia; const categoryClass = ScreenCategory::class; + public const INLINE_IMAGES_COLLECTION = 'inline_images'; + protected $connection = 'processmaker'; /** @@ -119,6 +124,11 @@ public static function boot() static::deleting($clearCacheCallback); } + public function registerMediaCollections(): void + { + $this->addMediaCollection(self::INLINE_IMAGES_COLLECTION); + } + /** * Validation rules * diff --git a/ProcessMaker/Screens/ScreenInlineImageNormalizationResult.php b/ProcessMaker/Screens/ScreenInlineImageNormalizationResult.php new file mode 100644 index 0000000000..5adb94dc5b --- /dev/null +++ b/ProcessMaker/Screens/ScreenInlineImageNormalizationResult.php @@ -0,0 +1,33 @@ +config; + } + + public function convertedCount(): int + { + return $this->convertedCount; + } + + public function replacedCount(): int + { + return $this->replacedCount; + } + + public function wasModified(): bool + { + return $this->replacedCount > 0; + } +} diff --git a/ProcessMaker/Screens/ScreenInlineImageNormalizer.php b/ProcessMaker/Screens/ScreenInlineImageNormalizer.php new file mode 100644 index 0000000000..3a13318813 --- /dev/null +++ b/ProcessMaker/Screens/ScreenInlineImageNormalizer.php @@ -0,0 +1,190 @@ +convertedCount = 0; + $this->replacedCount = 0; + $normalized = $this->walkPages($screen, $config); + + return new ScreenInlineImageNormalizationResult( + $normalized, + $this->convertedCount, + $this->replacedCount + ); + } + + public function configContainsInlineImages(array $config): bool + { + return (bool) preg_match(self::DATA_IMAGE_PATTERN, json_encode($config) ?: ''); + } + + private function walkPages(Screen $screen, array $config): array + { + foreach ($config as $pageIndex => $page) { + if (!is_array($page)) { + continue; + } + if (!empty($page['items']) && is_array($page['items'])) { + $config[$pageIndex]['items'] = $this->walkItems($screen, $page['items']); + } + } + + return $config; + } + + private function walkItems(Screen $screen, array $items): array + { + foreach ($items as $index => $item) { + if (!is_array($item)) { + continue; + } + + $component = Arr::get($item, 'component'); + if ($component === 'FormMultiColumn') { + $items[$index]['items'] = $this->walkMultiColumn($screen, Arr::get($item, 'items', [])); + continue; + } + + if (!empty($item['items']) && is_array($item['items'])) { + $items[$index]['items'] = $this->walkItems($screen, $item['items']); + } + + $items[$index] = $this->normalizeItem($screen, $items[$index]); + } + + return $items; + } + + private function walkMultiColumn(Screen $screen, array $columns): array + { + foreach ($columns as $columnIndex => $columnItems) { + if (!is_array($columnItems)) { + continue; + } + $columns[$columnIndex] = $this->walkItems($screen, $columnItems); + } + + return $columns; + } + + private function normalizeItem(Screen $screen, array $item): array + { + if (Arr::get($item, 'component') !== 'FormHtmlViewer') { + return $item; + } + + $content = Arr::get($item, 'config.content'); + if (is_string($content) && $content !== '') { + Arr::set($item, 'config.content', $this->replaceDataImagesInHtml($screen, $content)); + } + + return $item; + } + + private function replaceDataImagesInHtml(Screen $screen, string $html): string + { + return preg_replace_callback( + self::DATA_IMAGE_PATTERN, + function (array $matches) use ($screen) { + $dataUri = $matches[0]; + $url = $this->storeDataImage($screen, $dataUri); + + return $url ?? $dataUri; + }, + $html + ) ?? $html; + } + + private function storeDataImage(Screen $screen, string $dataUri): ?string + { + if (!preg_match(self::DATA_IMAGE_PATTERN, $dataUri, $matches)) { + return null; + } + + $extension = $this->extensionFromMime($matches[1]); + $payload = $matches[2]; + $hash = hash('sha256', $payload); + $url = $this->urlForExistingHash($screen, $hash); + + if ($url === null) { + $url = $this->createMediaFromDataUri($screen, $dataUri, $hash, $extension); + } + + if ($url !== null) { + $this->replacedCount++; + } + + return $url; + } + + private function urlForExistingHash(Screen $screen, string $hash): ?string + { + $existing = $screen->media() + ->where('collection_name', self::COLLECTION) + ->where('custom_properties->' . self::CONTENT_HASH_PROPERTY, $hash) + ->first(); + + return $existing?->getUrl(); + } + + private function createMediaFromDataUri(Screen $screen, string $dataUri, string $hash, string $extension): ?string + { + try { + $media = $screen + ->addMediaFromBase64($dataUri) + ->usingFileName('inline-' . substr($hash, 0, 12) . '.' . $extension) + ->withCustomProperties([ + self::CONTENT_HASH_PROPERTY => $hash, + 'source' => 'screen_inline_image', + ]) + ->toMediaCollection(self::COLLECTION); + } catch (Throwable $exception) { + Log::warning('Failed to store screen inline image as media', [ + 'screen_id' => $screen->id, + 'message' => $exception->getMessage(), + ]); + + return null; + } + + $this->convertedCount++; + + return $media->getUrl(); + } + + private function extensionFromMime(string $mimeSubtype): string + { + $normalized = strtolower($mimeSubtype); + $map = [ + 'jpeg' => 'jpg', + 'svg+xml' => 'svg', + ]; + + if (isset($map[$normalized])) { + return $map[$normalized]; + } + + $safe = preg_replace('/[^a-z0-9]/', '', $normalized); + + return $safe !== '' ? $safe : 'bin'; + } +} diff --git a/public/builds/login/js/app-login.js b/public/builds/login/js/app-login.js index 3e4e5777dd..be80587302 100644 --- a/public/builds/login/js/app-login.js +++ b/public/builds/login/js/app-login.js @@ -2944,13 +2944,13 @@ __webpack_require__.r(__webpack_exports__); /* harmony import */ var _modules_lang_js__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(/*! ./modules/lang.js */ "./resources/js/modules/lang.js"); /* harmony import */ var _components_common_mixins_accessibility__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(/*! ./components/common/mixins/accessibility */ "./resources/js/components/common/mixins/accessibility.js"); var _document$head$queryS; -function cov_2337h25edu() { - var path = "/Users/sanjacornelius/Documents/processmaker/resources/js/app-login.js"; - var hash = "195e5558a5d99dbe9490879ad07aabe07e89bf1c"; +function cov_1alrk227ck() { + var path = "/Users/agustin/Sites/processmaker/resources/js/app-login.js"; + var hash = "2651c9a18250a5d9e2cfc266641c2dc42915035e"; var global = new Function("return this")(); var gcv = "__coverage__"; var coverageData = { - path: "/Users/sanjacornelius/Documents/processmaker/resources/js/app-login.js", + path: "/Users/agustin/Sites/processmaker/resources/js/app-login.js", statementMap: { "0": { start: { @@ -3928,7 +3928,7 @@ function cov_2337h25edu() { "7": [0, 0] }, _coverageSchema: "1a1c01bbd47fc00a2c39e90264f33305004495a9", - hash: "195e5558a5d99dbe9490879ad07aabe07e89bf1c" + hash: "2651c9a18250a5d9e2cfc266641c2dc42915035e" }; var coverage = global[gcv] || (global[gcv] = {}); if (!coverage[path] || coverage[path].hash !== hash) { @@ -3937,13 +3937,13 @@ function cov_2337h25edu() { var actualCoverage = coverage[path]; { // @ts-ignore - cov_2337h25edu = function () { + cov_1alrk227ck = function () { return actualCoverage; }; } return actualCoverage; } -cov_2337h25edu(); +cov_1alrk227ck(); @@ -3959,35 +3959,35 @@ cov_2337h25edu(); -cov_2337h25edu().s[0]++; +cov_1alrk227ck().s[0]++; window.__ = _modules_lang_js__WEBPACK_IMPORTED_MODULE_7__["default"]; -cov_2337h25edu().s[1]++; +cov_1alrk227ck().s[1]++; window._ = __webpack_require__(/*! lodash */ "./node_modules/lodash/lodash.js"); -cov_2337h25edu().s[2]++; +cov_1alrk227ck().s[2]++; window.Popper = (__webpack_require__(/*! popper.js */ "./node_modules/popper.js/dist/esm/popper.js")["default"]); -cov_2337h25edu().s[3]++; +cov_1alrk227ck().s[3]++; window.$ = window.jQuery = __webpack_require__(/*! jquery */ "./node_modules/jquery/dist/jquery.js"); -cov_2337h25edu().s[4]++; +cov_1alrk227ck().s[4]++; window.Vue = vue__WEBPACK_IMPORTED_MODULE_9__["default"]; -cov_2337h25edu().s[5]++; +cov_1alrk227ck().s[5]++; window.vue = vue__WEBPACK_IMPORTED_MODULE_9__; -cov_2337h25edu().s[6]++; +cov_1alrk227ck().s[6]++; window.bootstrap = bootstrap__WEBPACK_IMPORTED_MODULE_0__; -cov_2337h25edu().s[7]++; +cov_1alrk227ck().s[7]++; window.Vue.use((vue_cookies__WEBPACK_IMPORTED_MODULE_6___default())); -cov_2337h25edu().s[8]++; +cov_1alrk227ck().s[8]++; window.Vue.use(_panter_vue_i18next__WEBPACK_IMPORTED_MODULE_5__["default"]); -var translationsLoaded = (cov_2337h25edu().s[9]++, false); -var mdates = (cov_2337h25edu().s[10]++, JSON.parse((_document$head$queryS = document.head.querySelector("meta[name=\"i18n-mdate\"]")) === null || _document$head$queryS === void 0 ? void 0 : _document$head$queryS.content)); +var translationsLoaded = (cov_1alrk227ck().s[9]++, false); +var mdates = (cov_1alrk227ck().s[10]++, JSON.parse((_document$head$queryS = document.head.querySelector("meta[name=\"i18n-mdate\"]")) === null || _document$head$queryS === void 0 ? void 0 : _document$head$queryS.content)); // Make $t available to all vue instances -cov_2337h25edu().s[11]++; +cov_1alrk227ck().s[11]++; vue__WEBPACK_IMPORTED_MODULE_9__["default"].mixin({ i18n: new _panter_vue_i18next__WEBPACK_IMPORTED_MODULE_5__["default"](i18next__WEBPACK_IMPORTED_MODULE_1__["default"]) }); -cov_2337h25edu().s[12]++; +cov_1alrk227ck().s[12]++; vue__WEBPACK_IMPORTED_MODULE_9__["default"].mixin(_components_common_mixins_accessibility__WEBPACK_IMPORTED_MODULE_8__["default"]); -cov_2337h25edu().s[13]++; +cov_1alrk227ck().s[13]++; window.ProcessMaker = { i18n: i18next__WEBPACK_IMPORTED_MODULE_1__["default"], /** @@ -3997,25 +3997,25 @@ window.ProcessMaker = { packages: [], missingTranslations: new Set(), missingTranslation: function missingTranslation(value) { - cov_2337h25edu().f[0]++; - cov_2337h25edu().s[14]++; + cov_1alrk227ck().f[0]++; + cov_1alrk227ck().s[14]++; if (this.missingTranslations.has(value)) { - cov_2337h25edu().b[0][0]++; - cov_2337h25edu().s[15]++; + cov_1alrk227ck().b[0][0]++; + cov_1alrk227ck().s[15]++; return; } else { - cov_2337h25edu().b[0][1]++; + cov_1alrk227ck().b[0][1]++; } - cov_2337h25edu().s[16]++; + cov_1alrk227ck().s[16]++; this.missingTranslations.add(value); - cov_2337h25edu().s[17]++; + cov_1alrk227ck().s[17]++; console.warn("Missing Translation:", value); }, $notifications: { icons: {} } }; -cov_2337h25edu().s[18]++; +cov_1alrk227ck().s[18]++; window.ProcessMaker.i18nPromise = i18next__WEBPACK_IMPORTED_MODULE_1__["default"].use((i18next_chained_backend__WEBPACK_IMPORTED_MODULE_2___default())).init({ lng: document.documentElement.lang, fallbackLng: "en", @@ -4025,20 +4025,20 @@ window.ProcessMaker.i18nPromise = i18next__WEBPACK_IMPORTED_MODULE_1__["default" nsSeparator: false, keySeparator: false, parseMissingKeyHandler: function parseMissingKeyHandler(value) { - cov_2337h25edu().f[1]++; - cov_2337h25edu().s[19]++; + cov_1alrk227ck().f[1]++; + cov_1alrk227ck().s[19]++; if (!translationsLoaded) { - cov_2337h25edu().b[1][0]++; - cov_2337h25edu().s[20]++; + cov_1alrk227ck().b[1][0]++; + cov_1alrk227ck().s[20]++; return value; } else { - cov_2337h25edu().b[1][1]++; + cov_1alrk227ck().b[1][1]++; } // Report that a translation is missing - cov_2337h25edu().s[21]++; + cov_1alrk227ck().s[21]++; window.ProcessMaker.missingTranslation(value); // Fallback to showing the english version - cov_2337h25edu().s[22]++; + cov_1alrk227ck().s[22]++; return value; }, backend: { @@ -4052,10 +4052,10 @@ window.ProcessMaker.i18nPromise = i18next__WEBPACK_IMPORTED_MODULE_1__["default" }] } }); -cov_2337h25edu().s[23]++; +cov_1alrk227ck().s[23]++; window.ProcessMaker.i18nPromise.then(function () { - cov_2337h25edu().f[2]++; - cov_2337h25edu().s[24]++; + cov_1alrk227ck().f[2]++; + cov_1alrk227ck().s[24]++; translationsLoaded = true; }); @@ -4064,85 +4064,85 @@ window.ProcessMaker.i18nPromise.then(function () { * REST api endpoints through oauth authentication * */ -cov_2337h25edu().s[25]++; +cov_1alrk227ck().s[25]++; window.ProcessMaker.apiClient = __webpack_require__(/*! axios */ "./node_modules/axios/index.js"); -cov_2337h25edu().s[26]++; +cov_1alrk227ck().s[26]++; window.ProcessMaker.apiClient.defaults.headers.common["X-Requested-With"] = "XMLHttpRequest"; // Setup api versions -var apiVersionConfig = (cov_2337h25edu().s[27]++, [{ +var apiVersionConfig = (cov_1alrk227ck().s[27]++, [{ version: "1.0", baseURL: "/api/1.0/" }, { version: "1.1", baseURL: "/api/1.1/" }]); -cov_2337h25edu().s[28]++; +cov_1alrk227ck().s[28]++; window.ProcessMaker.apiClient.defaults.baseURL = apiVersionConfig[0].baseURL; -cov_2337h25edu().s[29]++; +cov_1alrk227ck().s[29]++; window.ProcessMaker.apiClient.interceptors.request.use(function (config) { - cov_2337h25edu().f[3]++; - cov_2337h25edu().s[30]++; - if ((cov_2337h25edu().b[3][0]++, typeof config.url !== "string") || (cov_2337h25edu().b[3][1]++, !config.url)) { - cov_2337h25edu().b[2][0]++; - cov_2337h25edu().s[31]++; + cov_1alrk227ck().f[3]++; + cov_1alrk227ck().s[30]++; + if ((cov_1alrk227ck().b[3][0]++, typeof config.url !== "string") || (cov_1alrk227ck().b[3][1]++, !config.url)) { + cov_1alrk227ck().b[2][0]++; + cov_1alrk227ck().s[31]++; throw new Error("Invalid URL in the request configuration"); } else { - cov_2337h25edu().b[2][1]++; + cov_1alrk227ck().b[2][1]++; } - cov_2337h25edu().s[32]++; + cov_1alrk227ck().s[32]++; apiVersionConfig.forEach(function (_ref) { var version = _ref.version, baseURL = _ref.baseURL; - cov_2337h25edu().f[4]++; - var versionPrefix = (cov_2337h25edu().s[33]++, "/api/".concat(version, "/")); - cov_2337h25edu().s[34]++; + cov_1alrk227ck().f[4]++; + var versionPrefix = (cov_1alrk227ck().s[33]++, "/api/".concat(version, "/")); + cov_1alrk227ck().s[34]++; if (config.url.startsWith(versionPrefix)) { - cov_2337h25edu().b[4][0]++; - cov_2337h25edu().s[35]++; + cov_1alrk227ck().b[4][0]++; + cov_1alrk227ck().s[35]++; // eslint-disable-next-line no-param-reassign config.baseURL = baseURL; // eslint-disable-next-line no-param-reassign - cov_2337h25edu().s[36]++; + cov_1alrk227ck().s[36]++; config.url = config.url.replace(versionPrefix, ""); } else { - cov_2337h25edu().b[4][1]++; + cov_1alrk227ck().b[4][1]++; } }); - cov_2337h25edu().s[37]++; + cov_1alrk227ck().s[37]++; return config; }); // Set the default API timeout -var apiTimeout = (cov_2337h25edu().s[38]++, 5000); -cov_2337h25edu().s[39]++; -if ((cov_2337h25edu().b[6][0]++, window.Processmaker) && (cov_2337h25edu().b[6][1]++, window.Processmaker.apiTimeout !== undefined)) { - cov_2337h25edu().b[5][0]++; - cov_2337h25edu().s[40]++; +var apiTimeout = (cov_1alrk227ck().s[38]++, 5000); +cov_1alrk227ck().s[39]++; +if ((cov_1alrk227ck().b[6][0]++, window.Processmaker) && (cov_1alrk227ck().b[6][1]++, window.Processmaker.apiTimeout !== undefined)) { + cov_1alrk227ck().b[5][0]++; + cov_1alrk227ck().s[40]++; apiTimeout = window.Processmaker.apiTimeout; } else { - cov_2337h25edu().b[5][1]++; + cov_1alrk227ck().b[5][1]++; } -cov_2337h25edu().s[41]++; +cov_1alrk227ck().s[41]++; window.ProcessMaker.apiClient.defaults.timeout = apiTimeout; // click an active tab after all components have mounted -cov_2337h25edu().s[42]++; +cov_1alrk227ck().s[42]++; vue__WEBPACK_IMPORTED_MODULE_9__["default"].use({ install: function install(vue) { - cov_2337h25edu().f[5]++; - cov_2337h25edu().s[43]++; + cov_1alrk227ck().f[5]++; + cov_1alrk227ck().s[43]++; vue.mixin({ mounted: function mounted() { - cov_2337h25edu().f[6]++; - cov_2337h25edu().s[44]++; + cov_1alrk227ck().f[6]++; + cov_1alrk227ck().s[44]++; if (this.$parent) { - cov_2337h25edu().b[7][0]++; - cov_2337h25edu().s[45]++; + cov_1alrk227ck().b[7][0]++; + cov_1alrk227ck().s[45]++; // only run on root return; } else { - cov_2337h25edu().b[7][1]++; + cov_1alrk227ck().b[7][1]++; } } }); @@ -4150,7 +4150,7 @@ vue__WEBPACK_IMPORTED_MODULE_9__["default"].use({ }); // Send an event when the global Vue and ProcessMaker instance is available -cov_2337h25edu().s[46]++; +cov_1alrk227ck().s[46]++; window.dispatchEvent(new Event("app-bootstrapped")); /***/ }), @@ -4168,13 +4168,13 @@ __webpack_require__.r(__webpack_exports__); /* harmony export */ }); /* harmony import */ var _babel_runtime_helpers_slicedToArray__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! @babel/runtime/helpers/slicedToArray */ "./node_modules/@babel/runtime/helpers/esm/slicedToArray.js"); -function cov_20gejcf4ab() { - var path = "/Users/sanjacornelius/Documents/processmaker/resources/js/components/common/mixins/accessibility.js"; - var hash = "32ff012b53571eb61942410b27f8a8c856256151"; +function cov_njo9r729r() { + var path = "/Users/agustin/Sites/processmaker/resources/js/components/common/mixins/accessibility.js"; + var hash = "d071c26d273404aefb2d9070a6480c9d61eb4cbb"; var global = new Function("return this")(); var gcv = "__coverage__"; var coverageData = { - path: "/Users/sanjacornelius/Documents/processmaker/resources/js/components/common/mixins/accessibility.js", + path: "/Users/agustin/Sites/processmaker/resources/js/components/common/mixins/accessibility.js", statementMap: { "0": { start: { @@ -5413,7 +5413,7 @@ function cov_20gejcf4ab() { "14": [0, 0] }, _coverageSchema: "1a1c01bbd47fc00a2c39e90264f33305004495a9", - hash: "32ff012b53571eb61942410b27f8a8c856256151" + hash: "d071c26d273404aefb2d9070a6480c9d61eb4cbb" }; var coverage = global[gcv] || (global[gcv] = {}); if (!coverage[path] || coverage[path].hash !== hash) { @@ -5422,48 +5422,48 @@ function cov_20gejcf4ab() { var actualCoverage = coverage[path]; { // @ts-ignore - cov_20gejcf4ab = function () { + cov_njo9r729r = function () { return actualCoverage; }; } return actualCoverage; } -cov_20gejcf4ab(); +cov_njo9r729r(); /* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = ({ data: function data() { - cov_20gejcf4ab().f[0]++; - cov_20gejcf4ab().s[0]++; + cov_njo9r729r().f[0]++; + cov_njo9r729r().s[0]++; return { focusErrors: null }; }, mounted: function mounted() { - cov_20gejcf4ab().f[1]++; - cov_20gejcf4ab().s[1]++; + cov_njo9r729r().f[1]++; + cov_njo9r729r().s[1]++; // Listen only on the root Vue instance if (!this.$parent) { - cov_20gejcf4ab().b[0][0]++; - cov_20gejcf4ab().s[2]++; + cov_njo9r729r().b[0][0]++; + cov_njo9r729r().s[2]++; // Set the focus within any modal or popover that is instantiated this.$root.$on("bv::modal::shown", this.setFocusWithin); - cov_20gejcf4ab().s[3]++; + cov_njo9r729r().s[3]++; this.$root.$on("bv::popover::shown", this.setFocusWithin); } else { - cov_20gejcf4ab().b[0][1]++; + cov_njo9r729r().b[0][1]++; } - cov_20gejcf4ab().s[4]++; + cov_njo9r729r().s[4]++; if (this.focusErrors) { - cov_20gejcf4ab().b[1][0]++; - cov_20gejcf4ab().s[5]++; + cov_njo9r729r().b[1][0]++; + cov_njo9r729r().s[5]++; // watch an object for form errors this.$watch(this.focusErrors, this.focusErrorsChanged, { deep: true }); - cov_20gejcf4ab().s[6]++; + cov_njo9r729r().s[6]++; this.dontListenForApiClientError(); } else { - cov_20gejcf4ab().b[1][1]++; - cov_20gejcf4ab().s[7]++; + cov_njo9r729r().b[1][1]++; + cov_njo9r729r().s[7]++; // default api error focusing this.listenForApiClientError(); } @@ -5476,144 +5476,144 @@ cov_20gejcf4ab(); * @param modalId */ setFocusWithin: function setFocusWithin(shownEvent, modalId) { - cov_20gejcf4ab().f[2]++; - cov_20gejcf4ab().s[8]++; - if ((cov_20gejcf4ab().b[3][0]++, !(shownEvent.target instanceof HTMLElement)) && (cov_20gejcf4ab().b[3][1]++, !(shownEvent.relatedTarget instanceof HTMLElement))) { - cov_20gejcf4ab().b[2][0]++; - cov_20gejcf4ab().s[9]++; + cov_njo9r729r().f[2]++; + cov_njo9r729r().s[8]++; + if ((cov_njo9r729r().b[3][0]++, !(shownEvent.target instanceof HTMLElement)) && (cov_njo9r729r().b[3][1]++, !(shownEvent.relatedTarget instanceof HTMLElement))) { + cov_njo9r729r().b[2][0]++; + cov_njo9r729r().s[9]++; return; } else { - cov_20gejcf4ab().b[2][1]++; + cov_njo9r729r().b[2][1]++; } // If relatedTarget is present and an HTMLElement, // then we know we're working with a popover, // otherwise it's a modal - var target = (cov_20gejcf4ab().s[10]++, shownEvent.relatedTarget instanceof HTMLElement ? (cov_20gejcf4ab().b[4][0]++, shownEvent.relatedTarget) : (cov_20gejcf4ab().b[4][1]++, shownEvent.target)); + var target = (cov_njo9r729r().s[10]++, shownEvent.relatedTarget instanceof HTMLElement ? (cov_njo9r729r().b[4][0]++, shownEvent.relatedTarget) : (cov_njo9r729r().b[4][1]++, shownEvent.target)); // Find the first focusable element that isn't the // modal/popover close button - var focusableElement = (cov_20gejcf4ab().s[11]++, target.querySelector("input, select, textarea, button:not(.close), a")); + var focusableElement = (cov_njo9r729r().s[11]++, target.querySelector("input, select, textarea, button:not(.close), a")); // If there is an element to focus on, then do so - cov_20gejcf4ab().s[12]++; + cov_njo9r729r().s[12]++; if (focusableElement instanceof HTMLElement) { - cov_20gejcf4ab().b[5][0]++; + cov_njo9r729r().b[5][0]++; //do not set focus() if it is a vue-multiselect - var parentVue = (cov_20gejcf4ab().s[13]++, this.findHtmlElementParentVueComponent(focusableElement)); - cov_20gejcf4ab().s[14]++; - if ((cov_20gejcf4ab().b[7][0]++, parentVue !== null) && (cov_20gejcf4ab().b[7][1]++, parentVue.$options.name == 'vue-multiselect')) { - cov_20gejcf4ab().b[6][0]++; - cov_20gejcf4ab().s[15]++; + var parentVue = (cov_njo9r729r().s[13]++, this.findHtmlElementParentVueComponent(focusableElement)); + cov_njo9r729r().s[14]++; + if ((cov_njo9r729r().b[7][0]++, parentVue !== null) && (cov_njo9r729r().b[7][1]++, parentVue.$options.name == 'vue-multiselect')) { + cov_njo9r729r().b[6][0]++; + cov_njo9r729r().s[15]++; return; } else { - cov_20gejcf4ab().b[6][1]++; + cov_njo9r729r().b[6][1]++; } - cov_20gejcf4ab().s[16]++; + cov_njo9r729r().s[16]++; focusableElement.focus(); } else { - cov_20gejcf4ab().b[5][1]++; + cov_njo9r729r().b[5][1]++; } }, findHtmlElementParentVueComponent: function findHtmlElementParentVueComponent(element) { - cov_20gejcf4ab().f[3]++; - cov_20gejcf4ab().s[17]++; + cov_njo9r729r().f[3]++; + cov_njo9r729r().s[17]++; if (element === undefined) { - cov_20gejcf4ab().b[8][0]++; - cov_20gejcf4ab().s[18]++; + cov_njo9r729r().b[8][0]++; + cov_njo9r729r().s[18]++; return null; } else { - cov_20gejcf4ab().b[8][1]++; + cov_njo9r729r().b[8][1]++; } - cov_20gejcf4ab().s[19]++; + cov_njo9r729r().s[19]++; if ('__vue__' in element) { - cov_20gejcf4ab().b[9][0]++; - cov_20gejcf4ab().s[20]++; + cov_njo9r729r().b[9][0]++; + cov_njo9r729r().s[20]++; return element.__vue__; } else { - cov_20gejcf4ab().b[9][1]++; - cov_20gejcf4ab().s[21]++; + cov_njo9r729r().b[9][1]++; + cov_njo9r729r().s[21]++; return this.findHtmlElementParentVueComponent(element.parentNode); } }, hasCustomFocusErrors: function hasCustomFocusErrors() { - cov_20gejcf4ab().f[4]++; - cov_20gejcf4ab().s[22]++; + cov_njo9r729r().f[4]++; + cov_njo9r729r().s[22]++; if (this.$root._hasCustomFocusErrors) { - cov_20gejcf4ab().b[10][0]++; - cov_20gejcf4ab().s[23]++; + cov_njo9r729r().b[10][0]++; + cov_njo9r729r().s[23]++; this.$off; } else { - cov_20gejcf4ab().b[10][1]++; + cov_njo9r729r().b[10][1]++; } }, listenForApiClientError: function listenForApiClientError() { - cov_20gejcf4ab().f[5]++; - cov_20gejcf4ab().s[24]++; + cov_njo9r729r().f[5]++; + cov_njo9r729r().s[24]++; if (typeof window.ProcessMaker._focusErrorsIntitalized === "undefined") { - cov_20gejcf4ab().b[11][0]++; - cov_20gejcf4ab().s[25]++; + cov_njo9r729r().b[11][0]++; + cov_njo9r729r().s[25]++; window.ProcessMaker.EventBus.$on("api-client-error", this.onApiClientError); - cov_20gejcf4ab().s[26]++; + cov_njo9r729r().s[26]++; window.ProcessMaker._focusErrorsIntitalized = true; } else { - cov_20gejcf4ab().b[11][1]++; + cov_njo9r729r().b[11][1]++; } }, dontListenForApiClientError: function dontListenForApiClientError() { - cov_20gejcf4ab().f[6]++; - cov_20gejcf4ab().s[27]++; + cov_njo9r729r().f[6]++; + cov_njo9r729r().s[27]++; window.ProcessMaker.EventBus.$off("api-client-error", this.onApiClientError); - cov_20gejcf4ab().s[28]++; + cov_njo9r729r().s[28]++; window.ProcessMaker._focusErrorsIntitalized = true; }, onApiClientError: function onApiClientError(error) { - cov_20gejcf4ab().f[7]++; - var errors = (cov_20gejcf4ab().s[29]++, _.get(error, "response.data.errors", false)); - cov_20gejcf4ab().s[30]++; + cov_njo9r729r().f[7]++; + var errors = (cov_njo9r729r().s[29]++, _.get(error, "response.data.errors", false)); + cov_njo9r729r().s[30]++; if (errors) { - cov_20gejcf4ab().b[12][0]++; - cov_20gejcf4ab().s[31]++; + cov_njo9r729r().b[12][0]++; + cov_njo9r729r().s[31]++; this.focusErrorsChanged(errors); } else { - cov_20gejcf4ab().b[12][1]++; + cov_njo9r729r().b[12][1]++; } }, focusErrorsChanged: function focusErrorsChanged(newValue) { - cov_20gejcf4ab().f[8]++; - var selector = (cov_20gejcf4ab().s[32]++, Object.entries(newValue).filter(function (_ref) { + cov_njo9r729r().f[8]++; + var selector = (cov_njo9r729r().s[32]++, Object.entries(newValue).filter(function (_ref) { var _ref2 = (0,_babel_runtime_helpers_slicedToArray__WEBPACK_IMPORTED_MODULE_0__["default"])(_ref, 2), _ = _ref2[0], value = _ref2[1]; - cov_20gejcf4ab().f[9]++; - cov_20gejcf4ab().s[33]++; + cov_njo9r729r().f[9]++; + cov_njo9r729r().s[33]++; return value !== null; }) // Filter out null values .map(function (_ref3) { var _ref4 = (0,_babel_runtime_helpers_slicedToArray__WEBPACK_IMPORTED_MODULE_0__["default"])(_ref3, 2), field = _ref4[0], _ = _ref4[1]; - cov_20gejcf4ab().f[10]++; - cov_20gejcf4ab().s[34]++; + cov_njo9r729r().f[10]++; + cov_njo9r729r().s[34]++; return "[name='".concat(field, "']"); }) // Select elements matching the name attribute .join(", ")); - cov_20gejcf4ab().s[35]++; + cov_njo9r729r().s[35]++; if (!selector) { - cov_20gejcf4ab().b[13][0]++; - cov_20gejcf4ab().s[36]++; + cov_njo9r729r().b[13][0]++; + cov_njo9r729r().s[36]++; return; } else { - cov_20gejcf4ab().b[13][1]++; + cov_njo9r729r().b[13][1]++; } - var firstInput = (cov_20gejcf4ab().s[37]++, document.querySelector(selector)); // Find the first match - cov_20gejcf4ab().s[38]++; + var firstInput = (cov_njo9r729r().s[37]++, document.querySelector(selector)); // Find the first match + cov_njo9r729r().s[38]++; if (firstInput) { - cov_20gejcf4ab().b[14][0]++; - cov_20gejcf4ab().s[39]++; + cov_njo9r729r().b[14][0]++; + cov_njo9r729r().s[39]++; firstInput.focus(); } else { - cov_20gejcf4ab().b[14][1]++; + cov_njo9r729r().b[14][1]++; } } } @@ -5632,13 +5632,13 @@ __webpack_require__.r(__webpack_exports__); /* harmony export */ __webpack_require__.d(__webpack_exports__, { /* harmony export */ "default": () => (/* binding */ translate) /* harmony export */ }); -function cov_kcsn6du19() { - var path = "/Users/sanjacornelius/Documents/processmaker/resources/js/modules/lang.js"; - var hash = "2cc2f52cbb98903a23b86bcdba9de5ccc6934daa"; +function cov_28vu7yhuo8() { + var path = "/Users/agustin/Sites/processmaker/resources/js/modules/lang.js"; + var hash = "b5d117a2f761be676d1ae2d9b49aa04060168b6e"; var global = new Function("return this")(); var gcv = "__coverage__"; var coverageData = { - path: "/Users/sanjacornelius/Documents/processmaker/resources/js/modules/lang.js", + path: "/Users/agustin/Sites/processmaker/resources/js/modules/lang.js", statementMap: { "0": { start: { @@ -5686,7 +5686,7 @@ function cov_kcsn6du19() { }, b: {}, _coverageSchema: "1a1c01bbd47fc00a2c39e90264f33305004495a9", - hash: "2cc2f52cbb98903a23b86bcdba9de5ccc6934daa" + hash: "b5d117a2f761be676d1ae2d9b49aa04060168b6e" }; var coverage = global[gcv] || (global[gcv] = {}); if (!coverage[path] || coverage[path].hash !== hash) { @@ -5695,16 +5695,16 @@ function cov_kcsn6du19() { var actualCoverage = coverage[path]; { // @ts-ignore - cov_kcsn6du19 = function () { + cov_28vu7yhuo8 = function () { return actualCoverage; }; } return actualCoverage; } -cov_kcsn6du19(); +cov_28vu7yhuo8(); function translate(value) { - cov_kcsn6du19().f[0]++; - cov_kcsn6du19().s[0]++; + cov_28vu7yhuo8().f[0]++; + cov_28vu7yhuo8().s[0]++; return value; } diff --git a/resources/js/processes/screen-builder/screen.vue b/resources/js/processes/screen-builder/screen.vue index a9912c5f7c..697768dbd6 100644 --- a/resources/js/processes/screen-builder/screen.vue +++ b/resources/js/processes/screen-builder/screen.vue @@ -688,9 +688,10 @@ export default { custom_css: this.customCSS, watchers: this.watchers, }) - .then(() => { + .then((response) => { // Set draft status. this.setVersionIndicator(true); + this.applyInlineImageSaveResponse(response); ProcessMaker.EventBus.$emit("save-changes"); }) .catch((error) => { @@ -1067,6 +1068,33 @@ export default { // Recount number of elements this.countElements(); }, + applyInlineImageSaveResponse(response) { + const payload = response?.data; + if (!payload || typeof payload !== "object") { + return; + } + if (Array.isArray(payload.config)) { + this.syncNormalizedScreenConfig(payload.config); + } + if (payload.replaced_images > 0 || payload.converted_images > 0) { + ProcessMaker.alert( + this.$t( + "Inline images were stored as media files to keep this screen lightweight.", + ), + "success", + ); + } + }, + syncNormalizedScreenConfig(normalizedConfig) { + const builder = this.$refs.builder; + if (builder && Array.isArray(builder.config)) { + builder.config.splice(0, builder.config.length, ...normalizedConfig); + this.config = builder.config; + } else { + this.config = normalizedConfig; + } + this.screen.config = this.config; + }, previewSubmit() { // eslint-disable-next-line no-alert alert("Preview Form was Submitted"); @@ -1172,6 +1200,7 @@ export default { if (exportScreen) { this.exportScreen(); } + this.applyInlineImageSaveResponse(response); ProcessMaker.alert(this.$t("Successfully saved"), "success"); // Set published status. this.setVersionIndicator(false); diff --git a/tests/Feature/Api/ScreenInlineImagesTest.php b/tests/Feature/Api/ScreenInlineImagesTest.php new file mode 100644 index 0000000000..97f47ae980 --- /dev/null +++ b/tests/Feature/Api/ScreenInlineImagesTest.php @@ -0,0 +1,71 @@ +create(['type' => 'FORM']); + $dataUri = 'data:image/png;base64,' . self::TINY_PNG; + $config = [[ + 'name' => 'Default', + 'items' => [[ + 'component' => 'FormHtmlViewer', + 'config' => [ + 'content' => 'x', + ], + ]], + ]]; + + $response = $this->apiCall('PUT', "/screens/{$screen->id}/draft", [ + 'title' => $screen->title, + 'description' => $screen->description, + 'type' => $screen->type, + 'screen_category_id' => $screen->screen_category_id, + 'config' => $config, + ]); + + $response->assertStatus(200); + $response->assertJsonPath('converted_images', 1); + $this->assertStringNotContainsString( + 'base64,', + $response->json('config.0.items.0.config.content') + ); + + $draft = $screen->getDraftVersion(); + $this->assertNotNull($draft); + $this->assertStringNotContainsString('base64,', $draft->config[0]['items'][0]['config']['content']); + $this->assertSame(1, $screen->fresh()->getMedia(ScreenInlineImageNormalizer::COLLECTION)->count()); + } + + public function testDraftWithoutInlineImagesKeepsNoContentResponse() + { + $screen = Screen::factory()->create(['type' => 'FORM']); + + $response = $this->apiCall('PUT', "/screens/{$screen->id}/draft", [ + 'title' => $screen->title, + 'description' => $screen->description, + 'type' => $screen->type, + 'screen_category_id' => $screen->screen_category_id, + 'config' => [['name' => 'Default', 'items' => []]], + ]); + + $response->assertStatus(204); + } +} diff --git a/tests/unit/Screens/ScreenInlineImageNormalizerTest.php b/tests/unit/Screens/ScreenInlineImageNormalizerTest.php new file mode 100644 index 0000000000..607559ca3c --- /dev/null +++ b/tests/unit/Screens/ScreenInlineImageNormalizerTest.php @@ -0,0 +1,96 @@ +create(); + $dataUri = 'data:image/png;base64,' . self::TINY_PNG; + $config = [[ + 'name' => 'Default', + 'items' => [[ + 'component' => 'FormHtmlViewer', + 'config' => [ + 'content' => '

Hello

logo', + ], + ]], + ]]; + + $result = app(ScreenInlineImageNormalizer::class)->normalize($screen, $config); + + $this->assertTrue($result->wasModified()); + $this->assertSame(1, $result->convertedCount()); + $content = $result->config()[0]['items'][0]['config']['content']; + $this->assertStringNotContainsString('data:image/png;base64,', $content); + $this->assertStringContainsString('logo', + ], + ]], + ]]; + + $normalizer = app(ScreenInlineImageNormalizer::class); + $first = $normalizer->normalize($screen, $config); + $second = $normalizer->normalize($screen, $config); + + $this->assertSame(1, $first->convertedCount()); + $this->assertSame(0, $second->convertedCount()); + $this->assertTrue($second->wasModified()); + $this->assertSame(1, $screen->getMedia(ScreenInlineImageNormalizer::COLLECTION)->count()); + $this->assertSame( + $first->config()[0]['items'][0]['config']['content'], + $second->config()[0]['items'][0]['config']['content'] + ); + } + + public function testWalksMultiColumnNestedItems() + { + $screen = Screen::factory()->create(); + $dataUri = 'data:image/png;base64,' . self::TINY_PNG; + $config = [[ + 'name' => 'Default', + 'items' => [[ + 'component' => 'FormMultiColumn', + 'items' => [[ + [ + 'component' => 'FormHtmlViewer', + 'config' => [ + 'content' => '', + ], + ], + ]], + ]], + ]]; + + $result = app(ScreenInlineImageNormalizer::class)->normalize($screen, $config); + + $this->assertSame(1, $result->convertedCount()); + $content = $result->config()[0]['items'][0]['items'][0][0]['config']['content']; + $this->assertStringNotContainsString('base64,', $content); + } +} From 51f0a3f015b4560d5fef1e6212e631e205a77a5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agust=C3=ADn=20Busso?= <90727999+agustinbusso@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:05:18 -0300 Subject: [PATCH 2/2] Detect inline images in screen configs --- .../Screens/ScreenInlineImageNormalizer.php | 66 ++++++++++++++++++- .../ScreenInlineImageNormalizerTest.php | 37 +++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/ProcessMaker/Screens/ScreenInlineImageNormalizer.php b/ProcessMaker/Screens/ScreenInlineImageNormalizer.php index 3a13318813..1c9257b304 100644 --- a/ProcessMaker/Screens/ScreenInlineImageNormalizer.php +++ b/ProcessMaker/Screens/ScreenInlineImageNormalizer.php @@ -34,7 +34,71 @@ public function normalize(Screen $screen, array $config): ScreenInlineImageNorma public function configContainsInlineImages(array $config): bool { - return (bool) preg_match(self::DATA_IMAGE_PATTERN, json_encode($config) ?: ''); + return $this->containsInlineImagesInPages($config); + } + + private function containsInlineImagesInPages(array $config): bool + { + foreach ($config as $page) { + if (!is_array($page) || empty($page['items']) || !is_array($page['items'])) { + continue; + } + if ($this->containsInlineImagesInItems($page['items'])) { + return true; + } + } + + return false; + } + + private function containsInlineImagesInItems(array $items): bool + { + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + + $component = Arr::get($item, 'component'); + if ($component === 'FormMultiColumn') { + if ($this->containsInlineImagesInMultiColumn(Arr::get($item, 'items', []))) { + return true; + } + continue; + } + + if (!empty($item['items']) && is_array($item['items'])) { + if ($this->containsInlineImagesInItems($item['items'])) { + return true; + } + } + + if ($component === 'FormHtmlViewer' && $this->contentHasInlineImage(Arr::get($item, 'config.content'))) { + return true; + } + } + + return false; + } + + private function containsInlineImagesInMultiColumn(array $columns): bool + { + foreach ($columns as $columnItems) { + if (!is_array($columnItems)) { + continue; + } + if ($this->containsInlineImagesInItems($columnItems)) { + return true; + } + } + + return false; + } + + private function contentHasInlineImage(mixed $content): bool + { + return is_string($content) + && $content !== '' + && (bool) preg_match(self::DATA_IMAGE_PATTERN, $content); } private function walkPages(Screen $screen, array $config): array diff --git a/tests/unit/Screens/ScreenInlineImageNormalizerTest.php b/tests/unit/Screens/ScreenInlineImageNormalizerTest.php index 607559ca3c..fd201e4e9a 100644 --- a/tests/unit/Screens/ScreenInlineImageNormalizerTest.php +++ b/tests/unit/Screens/ScreenInlineImageNormalizerTest.php @@ -16,6 +16,43 @@ protected function setUpStorageFake(): void Storage::fake(config('media-library.disk_name')); } + public function testConfigContainsInlineImagesDetectsDataUris(): void + { + $dataUri = 'data:image/png;base64,' . self::TINY_PNG; + $config = [[ + 'name' => 'Default', + 'items' => [[ + 'component' => 'FormHtmlViewer', + 'config' => [ + 'content' => 'logo', + ], + ]], + ]]; + + $result = app(ScreenInlineImageNormalizer::class)->configContainsInlineImages($config); + + $this->assertTrue($result); + } + + public function testConfigContainsInlineImagesIgnoresFormImage(): void + { + $dataUri = 'data:image/png;base64,' . self::TINY_PNG; + $config = [[ + 'name' => 'Default', + 'items' => [[ + 'component' => 'FormImage', + 'config' => [ + 'image' => $dataUri, + 'name' => 'logo', + ], + ]], + ]]; + + $result = app(ScreenInlineImageNormalizer::class)->configContainsInlineImages($config); + + $this->assertFalse($result); + } + public function testReplacesBase64InRichTextContent() { $screen = Screen::factory()->create();