# ТЗ #39 — Безопасная работа с большими подложками - Issue: https://github.com/Matysh/houseplan-card/issues/39 - Приоритет: P3 (триаж владельца 2026-08-15), полный трек - Ревизия: 4.1 (2026-08-29, реализация) — уточнён серверный анкер: лимит ПЛАНОВ 8 МБ (`validation.py:22 MAX_PLAN_BYTES`), 50 МБ — вложения устройств; выводы не меняются (8 МБ JPEG ≈ 80–160 МП декодированных) ## Цель До каких-либо тяжёлых аллокаций предупреждать о растре, способном исчерпать память планшета/WebView, и предлагать уменьшенную копию — не трогая текущий план и оригинал файла при любой ошибке. ## Сценарий Владелец дома настраивает план с планшета или ноутбука: в диалоге пространства выбирает «Файл» и указывает скан поэтажного плана — обычно это фото или скан 150–600 DPI, который «как есть» весит десятки мегапикселей. Сегодня такой выбор молча вешает вкладку на слабом планшете (полный decode + тройная конвертация в base64 до каких-либо проверок). **До**: выбрал большой файл → вкладка замирает или падает без объяснений; план, который был на экране, можно потерять вместе с вкладкой. **После**: выбрал большой файл → мгновенно появляется понятный диалог «Файл очень большой (10000×10000, 58 МБ, потребует ~380 МБ памяти)» с кнопкой «Загрузить уменьшенную копию»; одно нажатие — и на плане та же картинка, но безопасного размера. Текущий план в любом исходе остаётся цел. ## Текущее состояние (анкеры кода) `_pickPlanFile` (`houseplan-editor-runtime.ts:8243`) принимает PNG/JPEG/WebP/SVG по MIME/расширению и БЕЗ какой-либо диагностики: (1) весь файл конвертируется в base64 ручным循 циклом `String.fromCharCode` — пик памяти ≈3.7× размера файла ещё до всякого предупреждения; (2) разрешение не проверяется вовсе; (3) aspect берётся через `` — то есть полный decode УЖЕ происходит на этом шаге. Сервер режет только байты: планы — `MAX_PLAN_BYTES` 8 МБ (`validation.py:22`), вложения — 50 МБ; квота (`plans.py:216`). 8 МБ JPEG — это спокойно 80–160 МП декодированных, так что клиентская диагностика остаётся главной защитой. Транзакция уже честная: staging не трогает конфиг до успешного save (комментарий `runtime:8365-8380`), Undo и явное удаление живут отдельно — эта часть issue выполняется существующим кодом и закрепляется тестом. ## Research: бенчмарк-матрица (проведено 2026-08-29) Desktop headless Chromium 139 (песочница CI), генерация PNG в OffscreenCanvas, decode `createImageBitmap`, даунскейл до 4096px + JPEG q0.9: | MP | px | файл PNG | RGBA | decode | downscale | |---|---|---|---|---|---| | 4 | 2000² | 3.6 МБ | 15 МБ | 39 мс | 21 мс | | 16 | 4000² | 12.7 МБ | 61 МБ | 118 мс | 74 мс | | 32 | 5657² | 22.9 МБ | 122 МБ | 235 мс | 102 мс | | 32+alpha | 5657² | 25.9 МБ | 122 МБ | 247 мс | 101 мс | | 64 | 8000² | 40.3 МБ | 244 МБ | 395 мс | 108 мс | | 100 | 10000² | 58.1 МБ | 381 МБ | 589 мс | 144 мс | | 165 | 12845² | 87.3 МБ | 629 МБ | 956 мс | 130 мс | Decode линеен (~6 мс/МП), даунскейл дешёв (<150 мс из любого размера) — окно «предупредить и уменьшить» практично. Скрипт кладётся в `demo/benchmark_backdrop_decode.mjs` для повторной калибровки. **Честная оговорка протокола**: reference low-end wall tablet в среде недоступен; десктоп decode не показывает OOM вовсе (165 МП ок). Константы берутся консервативно от известных лимитов WebView/WebKit (Android WebView renderer OOM в сотнях МБ decoded; лимит стороны canvas Chromium/WebKit 16384px), собраны В ОДНОМ модуле и печатаются в предупреждении — полевая рекалибровка после жалоб = правка одного файла. Пороги смягчены в сторону «предупреждаем раньше»: цена ложного срабатывания — один необязательный диалог. ## Константы (`src/backdrop-probe.ts`, единственный источник) - `WARN_DECODED_BYTES = 128 МиБ` (≈32 МП RGBA) — порог предупреждения; - `HARD_DIMENSION = 16384` px по стороне — потолок канваса браузеров, дальше только Cancel; - `DOWNSCALE_TARGET_PX = 4096` px длинная сторона (хватает 4K-дисплею плана); - `DOWNSCALE_JPEG_QUALITY = 0.9`. ## Диагностика: заголовки, не decode Ключевое отличие от ревизии 1: разрешение читается **парсером заголовков** (PNG IHDR + colour type/tRNS → alpha; JPEG SOF0/2; WebP VP8/VP8L/VP8X + флаг alpha) — чистый модуль `backdrop-probe.ts`, без ImageBitmap, canvas и сетевых запросов. Никакая аллокация размером с изображение не происходит до решения пользователя. `probeBackdrop(bytes, mime)` → `{kind: 'safe'|'warn'|'hard'| 'unknown', width, height, alpha, decodedBytes}`; `unknown` (битый/усечённый заголовок при растровом MIME) ведёт себя как `warn` без чисел разрешения — fail-closed к диалогу, не к тихому продолжению. SVG в probe не заходит: прежняя валидация типа/размера, растеризации нет (как сейчас). ## UX - `safe` — прежний путь (плюс замена ручного base64-цикла на `FileReader.readAsDataURL`: минус ~2× пикового JS-heap на любом файле). - `warn` — диалог (`hp-dialog`, паттерн существующих подтверждений): разрешение, размер файла, оценка decoded-памяти; действия «Загрузить уменьшенную копию» (основное), «Оставить оригинал», «Отмена». Числа — из probe, до decode. - `hard` — ДВЕ фазы с разным моментом наступления и одним исходом («ничего не изменилось»): - **фаза 1, синхронная** (сторона > 16384 по заголовку, до какого-либо decode): диалог `backdrop.too_large_*` с единственной кнопкой «Отмена» и советом уменьшить файл на десктопе; - **фаза 2, асинхронная** (пользователь выбрал «Уменьшенную копию», а decode упал или не уложился в таймаут 10 с): warn-диалог закрывается, показывается тост `backdrop.downscale_failed`; поле выбора файла сброшено, staging чист — пользователь может выбрать «Оставить оригинал», повторив выбор файла, либо уменьшить файл сам. Автоматического фолбэка на оригинал нет: молча грузить то, от чего пользователь только что отказался, нечестно. - Уменьшение: `createImageBitmap(file, {imageOrientation: 'from-image'})` (EXIF учтён) → OffscreenCanvas (fallback ``), aspect сохраняется; alpha из probe: PNG/WebP c alpha → PNG, opaque → JPEG q0.9 (WebP-энкод не берём — Safari не пишет). Уменьшенный Blob идёт тем же путём planFile → upload → квота/copy-on-write, что и оригинал. - Диалог на мобильной ширине без горизонтального скролла. ## i18n (ключи en/ru; de — перевод тех же ключей) - `backdrop.large_title` — "Large image" / «Большое изображение»; - `backdrop.large_body` — "This image is {w}×{h} ({fileMb} MB file) and needs about {decodedMb} MB of memory to display. On tablets this can crash the page." / «Изображение {w}×{h} ({fileMb} МБ), для показа потребуется около {decodedMb} МБ памяти. На планшетах это может привести к падению страницы.»; - `backdrop.unknown_body` — "The image dimensions could not be read — the file may be damaged. Continuing may crash the page on tablets." / «Не удалось прочитать размеры изображения — файл может быть повреждён. Продолжение может привести к падению страницы на планшете.»; - `backdrop.use_downscaled` — "Upload a reduced copy" / «Загрузить уменьшенную копию»; - `backdrop.keep_original` — "Keep the original" / «Оставить оригинал»; - `backdrop.too_large_title` — "Image is too large" / «Изображение слишком большое»; - `backdrop.too_large_body` — "This image exceeds what browsers can display ({w}×{h}, limit {limit} px per side). Please reduce it in a desktop editor and upload again." / «Изображение превышает возможности браузера ({w}×{h}, предел {limit} px по стороне). Уменьшите его в редакторе на компьютере и загрузите снова.»; - `backdrop.downscale_failed` — "Could not create the reduced copy. The original plan was not changed." / «Не удалось создать уменьшенную копию. Текущий план не изменён.»; - `toast.plan_formats` — существующий, без изменений. ## Транзакция (без изменений, закрепляется тестом) Existing backdrop/config живы до успешного upload+validation+save (текущий контракт `runtime:8365+`); отказ на любом шаге чистит staging и не трогает старый ref; Undo и явное удаление — как сейчас. «Оригинал до подтверждения» из issue = оригинальный ФАЙЛ пользователя никогда не модифицируется (это upload), а уменьшенная копия — отдельный Blob. ## AC 1. PNG 100 МП (фикстура-заголовок + маленькое тело для юнитов; настоящий файл для смока): предупреждение с razрешением/размером/оценкой памяти показано ДО какой-либо аллокации размером с изображение (в юните probe не создаёт canvas/bitmap вовсе; в смоке — до клика по действию нет createImageBitmap). 2. «Уменьшенная копия»: длинная сторона 4096, aspect сохранён; PNG с alpha → PNG (alpha жива), opaque → JPEG; результат уходит существующим upload-путём. 3. «Оставить оригинал» — прежнее поведение байт-в-байт. 4. `hard` фаза 1: сторона >16384 из заголовка — диалог «слишком большое» с одной «Отменой», без decode; подложка/staging/конфиг не изменились. 4б. `hard` фаза 2: decode-fail или таймаут 10 с после выбора «Уменьшенной копии» — тост `backdrop.downscale_failed`, staging чист, подложка/конфиг не изменились, повторный выбор файла работает. 5. SVG: только прежняя валидация, растеризации нет. 6. Битые/усечённые заголовки (PNG без IHDR, JPEG без SOF, WebP-огрызок) → `unknown` → warn-диалог без чисел; продолжение возможно только явным выбором. 7. Существующий транзакционный контракт закреплён: failed upload/save сохраняет прежний план и файлы (тест на текущее поведение). 8. EXIF-ориентация: повёрнутый JPEG уменьшается с учётом ориентации (w/h из SOF против imageOrientation — фикстура). 9. safe-путь через FileReader даёт тот же b64, что старый цикл (паритет-юнит). ## Тесты и мутанты Юниты: `test/backdrop-probe.test.mjs` — таблица заголовков (валидные PNG/JPEG/ WebP всех подвидов, alpha-варианты, битые/усечённые, границы порогов ±1); паритет FileReader-b64. Смок `demo/smoke_backdrop_guard.mjs`: warn-диалог на 100 МП PNG, уменьшение (проверка итоговых размеров загруженного), «оригинал», hard фаза 1, SVG-байпас, отсутствие createImageBitmap до выбора (хук на window.createImageBitmap). **Hard фаза 2 в смоке**: перед кликом «Уменьшенную копию» `window.createImageBitmap` подменяется на (а) reject и (б) вечный Promise (таймаут); ассерты — тост содержит `_t('backdrop.downscale_failed')`, `.value` сброшен, `_spaceDialog.planFile === null` (staging чист), повторный выбор файла открывает диалог заново, и НИ ОДНОГО вызова upload/planFile со старым оригиналом (счётчик на хуке). Мутанты реестра: (1) probe всегда `safe` — смок красный; (2) выброшена ветка alpha→PNG (всё в JPEG) — юнит/смок красный; (3) `hard` фаза 1 понижен до warn — смок красный; (4) staging не чистится при отказе — тест транзакции красный; (5) **автофолбэк на оригинал**: catch фазы 2 подменяется продолжением старого пути с оригиналом — смок красный по счётчику «оригинал не грузился». ## Риски - **Пороги не с reference-планшета**: калибровка десктопная с консервативным запасом; ложные срабатывания дешевы (лишний диалог), пропуски дороже — при полевых жалобах правится один модуль констант. Смягчение: числа печатаются в диалоге, жалобу легко диагностировать по скриншоту. - **Зоопарк заголовков** (прогрессивный JPEG, WebP VP8L, EXIF в необычном месте): парсер обязан отвечать `unknown`, не бросать — fail-closed к warn-диалогу; таблица юнит-фикстур покрывает все подвиды из спецификаций форматов. - **OffscreenCanvas недоступен** (старые WebView): фолбэк на `` в том же кадре; ветка покрыта юнитом через подмену глобала. - **Regress существующего upload-пути**: safe-путь меняет только механизм base64 (паритет-юнит AC9) — поведенчески байт-в-байт. ## Release-артефакты - CHANGELOG.md + CHANGELOG.ru.md — одна запись (User-Visible: yes); - docs/BACKDROP.md — раздел «Большие изображения» (пороги, поведение, совет); - docs/USER-GUIDE.md + USER-GUIDE.ru.md — абзац в теме подложек; - docs/TESTING.md — чек-лист сценариев (warn/уменьшение/hard×2/SVG); - i18n en/ru/de — ключи из раздела i18n (паритет-тесты стерегут); - golden: не задевается (диалог не входит в golden-матрицу; скриншоты документации пересъёмка capture по общему правилу src-правок); - performance: бенчмарк-скрипт `demo/benchmark_backdrop_decode.mjs` в repo, в перф-джобу CI НЕ добавляется (разовая калибровка, не регресс-гейт); - security (явное решение): новый парсер читает ТОЛЬКО фиксированные оффсеты/длины из уже полученного ArrayBuffer, не интерпретирует контент и не использует ни одно поле файла как размер аллокации (w×h — только арифметика для чисел диалога, с защитой от переполнения через Number-границы и отказом `unknown` при неправдоподобных значениях); любые исключения парсера ловятся в `unknown`. Юнит-таблица включает fuzz-набор битых/враждебных заголовков (нулевые/гигантские длины чанков, обрезанные VP8X, SOF без длины). SVG-путь парсером не трогается — существующая валидация как есть. ## Вне скоупа Автопревращение SVG в растр; серверная перекодировка; изменение MAX_FILE_BYTES/квот; #51 (свои изображения в декоре — там свой пайплайн). ## Откат Один revert; конфиг/схема/эндпоинты не меняются (только клиентская логика, i18n и тесты).