fix: native-bpp decode accounting, GIF frame bombs, streaming remote bodies

A second adversarial round (with empirical reproduction against the
vendored image 0.25) found the budget contract still had holes:

- The probe billed 4 bytes/px, but 16-bit formats allocate their
  native layout during decode (8/px for RGBA16 PNG; TIFF stacks ~3x
  codec scratch on top). In-budget 16-bit images were falsely refused
  with self-contradictory notes, and the decode transient could exceed
  the budget the tiers claimed to enforce. The probe now reads the
  color type alongside the dimensions and bills the real peak (native
  layout + RGBA where they coexist, plus the TIFF codec factor), so
  refusals are honest and per-platform-correct, and the TIFF allowance
  follows the same accounting.
- GIF: the probe reads the LOGICAL SCREEN, but a crafted frame may
  declare itself arbitrarily larger (empirically: a 10x10 probed
  screen with an 8192x8192 frame — 320 MB and 29 s from a
  probe-claimed-400-byte image). The GIF frame buffer is now capped
  to the probed size plus slack; frame bombs refuse like any other
  oversize.
- Remote bodies on web streamed incrementally with the 64 MB cap
  enforced DURING the read (array_buffer() materialized a hostile
  chunked reply in full before any check could fire), aborting and
  cancelling the reader the moment the cap is crossed.
- Degenerate raster entities (zero/non-finite size or vectors) no
  longer produce NaN placement math that aborted whole PDF exports —
  they resolve to the broken-reference placeholder.
- Same-name resolution now has a deterministic winner (newest
  registration, via insertion order) instead of HashMap iteration
  order; URL query strings no longer break by-name matching; the
  image pick matches its refusal note by the quoted file name
  (substring collisions like a.tif/2011.tif attributed errors to the
  wrong file); a dropped remote outcome at the queue cap triggers one
  unconditional catch-up re-resolution instead of waiting for another
  event; the unknown-size refusal says so instead of printing 0x0;
  and the build script fails cleanly when there are no fonts to
  verify.

(cherry picked from commit c6db94e8)

Ported onto main (#1717 review): the build-web.sh hunk stays out with
the wrapper; the new messages are cataloged (image refusal under the
file group it is looked up in, plus the extraction cap, shared-copy
warning and plot-preview task title that were missing), and the web
SAVEALL failure reuses the existing SAVEALL message.
This commit is contained in:
Afonsop authored and Hakan Seven committed 2026-10-12 12:02:05 +03:00
1 parent a10d9c6f89
commit f015c3b5fa
27 files changed
+286 -52

No files matched your search

+2
View File
@@ -179,6 +179,8 @@ web-sys = { version = "=0.3.85", features = [
"DataTransfer",
"FileList",
"Event",
"ReadableStreamDefaultReader",
"ReadableStream",
] }
iced = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39", features = ["webgl", "fira-sans"] }
+1
View File
@@ -2412,6 +2412,7 @@ command =
file =
.already-loaded-different-contents-kept = "__ocs_fmt_0__" محمّل بالفعل بمحتوى مختلف؛ تم الاحتفاظ بالملف الموجود.
.image-refusal-unknown-size = تعذّر قياس أبعاد "__ocs_fmt_0__" من ترويسته وسيتجاوز فك ترميزه حد الصور __ocs_fmt_1__ ميغابايت في هذا الإصدار — لا يمكن إرفاقه.
.parse-ms-purge-ms-caches-ms-xref-ms-total-ms = التحليل __ocs_fmt_0__ ملث · التنظيف __ocs_fmt_1__ ملث · الذاكرة المؤقتة __ocs_fmt_2__ ملث · المرجع الخارجي __ocs_fmt_3__ ملث · الإجمالي __ocs_fmt_4__ ملث
.archive-save-the-drawing-first-it-has-no-file = ARCHIVE: احفظ الرسم أولًا (ليس له مسار ملف بعد).
.autosaved-1-drawing = حُفظ رسم واحد تلقائيًا
+1
View File
@@ -2399,6 +2399,7 @@ command =
file =
.already-loaded-different-contents-kept = "__ocs_fmt_0__" вече е зареден с различно съдържание; съществуващият файл е запазен.
.image-refusal-unknown-size = Размерът на „__ocs_fmt_0__“ не можа да се измери от заглавната му част, а декодирането му би надвишило __ocs_fmt_1__ MB лимита на тази сборка — не може да се прикачи.
.parse-ms-purge-ms-caches-ms-xref-ms-total-ms = анализ __ocs_fmt_0__ms · изчистване __ocs_fmt_1__ms · кешове __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · общо __ocs_fmt_4__ms
.archive-save-the-drawing-first-it-has-no-file = ARCHIVE: първо запази чертежа (все още няма път до файл).
.autosaved-1-drawing = Автоматично запазен 1 чертеж
+1
View File
@@ -2399,6 +2399,7 @@ command =
file =
.already-loaded-different-contents-kept = "__ocs_fmt_0__" je již načten s jiným obsahem; existující soubor byl zachován.
.image-refusal-unknown-size = „__ocs_fmt_0__“ nešlo změřit ze záhlaví a jeho dekódování by překročilo limit __ocs_fmt_1__ MB tohoto sestavení — nelze připojit.
.parse-ms-purge-ms-caches-ms-xref-ms-total-ms = analyzovat __ocs_fmt_0__ms · vyčistit __ocs_fmt_1__ms · mezipaměti __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · celkem __ocs_fmt_4__ms
.archive-save-the-drawing-first-it-has-no-file = ARCHIVE: nejprve uložte výkres (ještě nemá žádnou cestu k souboru).
.autosaved-1-drawing = Automaticky uložen 1 výkres
+1
View File
@@ -2400,6 +2400,7 @@ command =
file =
.already-loaded-different-contents-kept = "__ocs_fmt_0__" ist bereits mit anderem Inhalt geladen; die vorhandene Datei wurde behalten.
.image-refusal-unknown-size = „__ocs_fmt_0__“ konnte anhand des Headers nicht vermessen werden, und das Dekodieren überschreitet das __ocs_fmt_1__-MB-Limit dieses Builds — kann nicht angehängt werden.
.parse-ms-purge-ms-caches-ms-xref-ms-total-ms = analysiere __ocs_fmt_0__ms · Säubere __ocs_fmt_1__ms · Puffer __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · gesamt __ocs_fmt_4__ms
.archive-save-the-drawing-first-it-has-no-file = ARCHIVE: Speichern Sie die Zeichnung zuerst (sie hat noch keinen Dateipfad).
.autosaved-1-drawing = 1 Zeichnung automatisch gespeichert
+1
View File
@@ -2399,6 +2399,7 @@ command =
file =
.already-loaded-different-contents-kept = Το "__ocs_fmt_0__" είναι ήδη φορτωμένο με διαφορετικό περιεχόμενο· διατηρήθηκε το υπάρχον αρχείο.
.image-refusal-unknown-size = Το "__ocs_fmt_0__" δεν μπόρεσε να μετρηθεί από την κεφαλίδα του και η αποκωδικοποίησή του θα ξεπερνούσε το όριο __ocs_fmt_1__ MB αυτής της build — δεν μπορεί να επισυναφθεί.
.parse-ms-purge-ms-caches-ms-xref-ms-total-ms = ανάλυση __ocs_fmt_0__ms · εκκαθάριση __ocs_fmt_1__ms · κρυφές μνήμες __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · σύνολο __ocs_fmt_4__ms
.archive-save-the-drawing-first-it-has-no-file = ARCHIVE: αποθηκεύστε πρώτα το σχέδιο (δεν έχει ακόμη διαδρομή αρχείου).
.autosaved-1-drawing = Αυτόματη αποθήκευση 1 σχεδίου
+4
View File
@@ -1888,6 +1888,7 @@ common =
.enter-a-github-url-or-repository-in-owner-repo-format = Enter a GitHub URL or repository in owner/repo format.
.dwgunits-convert-drawing-to = DWGUNITS Convert drawing to:
.opened-plot-preview = Opened plot preview.
.plot-preview-task = Plot preview
.auto-constrain = Auto Constrain
.show-all = Show All
@@ -2400,6 +2401,7 @@ command =
file =
.already-loaded-different-contents-kept = "__ocs_fmt_0__" is already loaded with different contents; the existing file was kept.
.image-refusal-unknown-size = "__ocs_fmt_0__" could not be sized from its header and decoding it exceeded the __ocs_fmt_1__ MB image limit of this build — it cannot be attached.
.parse-ms-purge-ms-caches-ms-xref-ms-total-ms = parse __ocs_fmt_0__ms · purge __ocs_fmt_1__ms · caches __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · total __ocs_fmt_4__ms
.archive-save-the-drawing-first-it-has-no-file = ARCHIVE: save the drawing first (it has no file path yet).
.autosaved-1-drawing = Autosaved 1 drawing
@@ -2441,6 +2443,8 @@ file =
.saveall-saved-saved-drawing-s = SAVEALL: saved __ocs_fmt_0__ drawing(s)__ocs_fmt_1__.
.saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__ already has a save running
.saveall-failed-e = SAVEALL: __ocs_fmt_0__ failed: __ocs_fmt_1__
.extraction-max-sources-web = At most __ocs_fmt_0__ drawings can be added in the browser.
.web-already-open-shared-copy = "__ocs_fmt_0__" is already open in another tab. On the web both tabs share one stored copy — the last save overwrites the other.
.load-linetype-file = Load linetype file
.script-path-to-the-scr-file = SCRIPT path to the .scr file:
.script-cannot-read-p-e = SCRIPT: cannot read __ocs_fmt_0__: __ocs_fmt_1__
+1
View File
@@ -2400,6 +2400,7 @@ command =
file =
.already-loaded-different-contents-kept = "__ocs_fmt_0__" ya está cargado con contenido diferente; se conservó el archivo existente.
.image-refusal-unknown-size = "__ocs_fmt_0__" no pudo medirse por su cabecera y decodificarlo excedería el límite de __ocs_fmt_1__ MB de esta build — no se puede adjuntar.
.parse-ms-purge-ms-caches-ms-xref-ms-total-ms = análisis __ocs_fmt_0__ ms · limpieza __ocs_fmt_1__ ms · cachés __ocs_fmt_2__ ms · ref. ext. __ocs_fmt_3__ ms · total __ocs_fmt_4__ ms
.archive-save-the-drawing-first-it-has-no-file = ARCHIVE: guarde primero el dibujo (todavía no tiene una ruta de archivo).
.autosaved-1-drawing = Se guardó automáticamente 1 dibujo
+1
View File
@@ -2399,6 +2399,7 @@ command =
file =
.already-loaded-different-contents-kept = "__ocs_fmt_0__" on jo ladattu eri sisällöllä; olemassa oleva tiedosto säilytettiin.
.image-refusal-unknown-size = Kohteen "__ocs_fmt_0__" kokoa ei voitu mitata otsakkeesta ja sen purkaminen ylittäisi tämän käännöksen __ocs_fmt_1__ MB kuvarajan — sitä ei voi liittää.
.parse-ms-purge-ms-caches-ms-xref-ms-total-ms = jäsentää __ocs_fmt_0__ms · tyhjentää __ocs_fmt_1__ms · välimuistit __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · yhteensä __ocs_fmt_4__ms
.archive-save-the-drawing-first-it-has-no-file = ARCHIVE: tallenna piirustus ensin (sillä ei ole vielä tiedostopolkua).
.autosaved-1-drawing = Automaattisesti tallennettu 1 piirros
+1
View File
@@ -2400,6 +2400,7 @@ command =
file =
.already-loaded-different-contents-kept = "__ocs_fmt_0__" est déjà chargé avec un contenu différent ; le fichier existant a été conservé.
.image-refusal-unknown-size = "__ocs_fmt_0__" n’a pas pu être mesuré par son en-tête et son décodage dépasserait la limite de __ocs_fmt_1__ Mo de cette version — impossible de l’attacher.
.parse-ms-purge-ms-caches-ms-xref-ms-total-ms = Analyse : __ocs_fmt_0__ ms, purge : __ocs_fmt_1__ ms, caches : __ocs_fmt_2__ ms, références externes : __ocs_fmt_3__ ms, total : __ocs_fmt_4__ ms
.archive-save-the-drawing-first-it-has-no-file = ARCHIVE : enregistrer le dessin en premier (il n'a pas encore de chemin de fichier).
.autosaved-1-drawing = 1 dessin enregistré automatiquement
+1
View File
@@ -2388,6 +2388,7 @@ command =
file =
.already-loaded-different-contents-kept = "__ocs_fmt_0__" अलग सामग्री के साथ पहले से लोड है; मौजूदा फ़ाइल रखी गई।
.image-refusal-unknown-size = "__ocs_fmt_0__" को उसके हेडर से मापा नहीं जा सका और उसे डिकोड करना इस बिल्ड की __ocs_fmt_1__ MB छवि सीमा पार कर देगा — जोड़ा नहीं जा सकता।
.parse-ms-purge-ms-caches-ms-xref-ms-total-ms = पार्स __ocs_fmt_0__ms · purge __ocs_fmt_1__Ms · कैश __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · कुल __ocs_fmt_4__ms
.archive-save-the-drawing-first-it-has-no-file = ARCHIVE: पहले ड्रॉइंग सहेजें (अभी फ़ाइल पथ निर्धारित नहीं है)।
.autosaved-1-drawing = 1 ड्रॉइंग स्वतः सहेजी गई
+1
View File
@@ -2412,6 +2412,7 @@ command =
file =
.already-loaded-different-contents-kept = A(z) "__ocs_fmt_0__" már betöltve eltérő tartalommal; a meglévő fájl megőrizve.
.image-refusal-unknown-size = A(z) "__ocs_fmt_0__" nem mérhető a fejlécéből, és dekódolása meghaladná ezen build __ocs_fmt_1__ MB-os képhatárát — nem csatolható.
.parse-ms-purge-ms-caches-ms-xref-ms-total-ms = elemzése __ocs_fmt_0__ms · __ocs_fmt_1__ms törlése · gyorsítótárak __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · összesen __ocs_fmt_4__ms
.archive-save-the-drawing-first-it-has-no-file = ARCHIVE: először mentse a rajzot (még nincs fájl elérési útja).
.autosaved-1-drawing = Automatikusan mentett 1 rajzot
+1
View File
@@ -2399,6 +2399,7 @@ command =
file =
.already-loaded-different-contents-kept = "__ocs_fmt_0__" è già caricato con contenuti diversi; il file esistente è stato mantenuto.
.image-refusal-unknown-size = "__ocs_fmt_0__" non è stato misurabile dall’intestazione e decodificarlo supererebbe il limite di __ocs_fmt_1__ MB di questa build — impossibile allegarlo.
.parse-ms-purge-ms-caches-ms-xref-ms-total-ms = analizza __ocs_fmt_0__ms · elimina __ocs_fmt_1__ms · memorizza nella cache __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · totale __ocs_fmt_4__ms
.archive-save-the-drawing-first-it-has-no-file = ARCHIVE: salva prima il disegno (non ha ancora il percorso del file).
.autosaved-1-drawing = 1 disegno salvato automaticamente
+1
View File
@@ -2390,6 +2390,7 @@ command =
file =
.already-loaded-different-contents-kept = "__ocs_fmt_0__" は既に別の内容で読み込まれています。既存のファイルを保持しました。
.image-refusal-unknown-size = "__ocs_fmt_0__" のサイズをヘッダーから測定できず、デコードするとこのビルドの __ocs_fmt_1__ MB 画像制限を超えます — 添付できません。
.parse-ms-purge-ms-caches-ms-xref-ms-total-ms = 解析 __ocs_fmt_0__ms · パージ __ocs_fmt_1__ms · キャッシュ __ocs_fmt_2__ms · 外部参照 __ocs_fmt_3__ms · 合計 __ocs_fmt_4__ms
.archive-save-the-drawing-first-it-has-no-file = ARCHIVE: 先に図面を保存してください(まだファイルパスがありません)。
.autosaved-1-drawing = 1 件の図面を自動保存しました
+1
View File
@@ -2399,6 +2399,7 @@ command =
file =
.already-loaded-different-contents-kept = "__ocs_fmt_0__"이(가) 이미 다른 내용으로 로드되었습니다. 기존 파일을 유지했습니다.
.image-refusal-unknown-size = "__ocs_fmt_0__"의 크기를 헤더에서 측정할 수 없고 디코딩 시 이 빌드의 __ocs_fmt_1__ MB 이미지 한도를 초과합니다 — 첨부할 수 없습니다.
.parse-ms-purge-ms-caches-ms-xref-ms-total-ms = __ocs_fmt_0__ms 구문 분석 · __ocs_fmt_1__ms 제거 · 캐시 __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · 총 __ocs_fmt_4__ms
.archive-save-the-drawing-first-it-has-no-file = ARCHIVE: 먼저 도면을 저장합니다(아직 파일 경로가 없음).
.autosaved-1-drawing = 그림 1개 자동 저장됨
+1
View File
@@ -2400,6 +2400,7 @@ command =
file =
.already-loaded-different-contents-kept = "__ocs_fmt_0__" is al geladen met andere inhoud; het bestaande bestand is bewaard.
.image-refusal-unknown-size = "__ocs_fmt_0__" kon niet via de header gemeten worden en decoderen overschrijdt de __ocs_fmt_1__ MB-limiet van deze build — kan niet worden toegevoegd.
.parse-ms-purge-ms-caches-ms-xref-ms-total-ms = ontleed __ocs_fmt_0__ms · zuivering __ocs_fmt_1__ms · caches __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · totaal __ocs_fmt_4__ms
.archive-save-the-drawing-first-it-has-no-file = ARCHIVE: sla de tekening eerst op (er is nog geen bestandspad).
.autosaved-1-drawing = Automatisch opslaan van 1 tekening
+1
View File
@@ -2399,6 +2399,7 @@ command =
file =
.already-loaded-different-contents-kept = "__ocs_fmt_0__" jest już wczytany z inną zawartością; zachowano istniejący plik.
.image-refusal-unknown-size = „__ocs_fmt_0__” nie dał się zmierzyć z nagłówka, a jego dekodowanie przekroczy limit __ocs_fmt_1__ MB tej kompilacji — nie można go dołączyć.
.parse-ms-purge-ms-caches-ms-xref-ms-total-ms = analizuj __ocs_fmt_0__ms · usuwaj __ocs_fmt_1__ms · pamięci podręczne __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · łącznie __ocs_fmt_4__ms
.archive-save-the-drawing-first-it-has-no-file = ARCHIVE: najpierw zapisz rysunek (nie ma on jeszcze ścieżki do pliku).
.autosaved-1-drawing = Automatycznie zapisano 1 rysunek
+1
View File
@@ -2400,6 +2400,7 @@ command =
file =
.already-loaded-different-contents-kept = "__ocs_fmt_0__" já está carregado com conteúdo diferente; o ficheiro existente foi mantido.
.image-refusal-unknown-size = "__ocs_fmt_0__" não pôde ser medido pelo cabeçalho e a sua descodificação excedeu o limite de __ocs_fmt_1__ MB desta build — não pode ser anexado.
.parse-ms-purge-ms-caches-ms-xref-ms-total-ms = análise __ocs_fmt_0__ms · limpeza __ocs_fmt_1__ms · caches __ocs_fmt_2__ms · ref. externa __ocs_fmt_3__ms · total __ocs_fmt_4__ms
.archive-save-the-drawing-first-it-has-no-file = ARCHIVE: salve o desenho primeiro (ele ainda não tem um caminho de arquivo).
.autosaved-1-drawing = 1 desenho salvo automaticamente
+1
View File
@@ -2403,6 +2403,7 @@ command =
file =
.already-loaded-different-contents-kept = "__ocs_fmt_0__" уже загружен с другим содержимым; существующий файл сохранён.
.image-refusal-unknown-size = «__ocs_fmt_0__» не удалось измерить по заголовку, а его декодирование превысит лимит __ocs_fmt_1__ МБ этой сборки — вложить нельзя.
.parse-ms-purge-ms-caches-ms-xref-ms-total-ms = parse __ocs_fmt_0__ms · чистка __ocs_fmt_1__ms · кэши __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · общая __ocs_fmt_4__ms
.archive-save-the-drawing-first-it-has-no-file = ARCHIVE: сначала сохраните чертёж (путь к файлу ещё не задан).
.autosaved-1-drawing = Автоматически сохранён 1 чертёж
+1
View File
@@ -2384,6 +2384,7 @@ command =
file =
.already-loaded-different-contents-kept = "__ocs_fmt_0__" zaten farklı içerikle yüklü; mevcut dosya tutuldu.
.image-refusal-unknown-size = "__ocs_fmt_0__" başlığından ölçülemedi ve kodu çözülürse bu sürümün __ocs_fmt_1__ MB görüntü limiti aşılır — eklenemez.
.parse-ms-purge-ms-caches-ms-xref-ms-total-ms = ayrıştırma __ocs_fmt_0__ ms · temizleme __ocs_fmt_1__ ms · önbellekler __ocs_fmt_2__ ms · xref __ocs_fmt_3__ ms · toplam __ocs_fmt_4__ ms
.archive-save-the-drawing-first-it-has-no-file = ARCHIVE: önce çizimi kaydedin; çizimin henüz bir dosya yolu yok.
.autosaved-1-drawing = 1 çizim otomatik kaydedildi
+1
View File
@@ -2387,6 +2387,7 @@ command =
file =
.already-loaded-different-contents-kept = "__ocs_fmt_0__"已以不同内容加载;保留了现有文件。
.image-refusal-unknown-size = 无法从文件头测得“__ocs_fmt_0__”的尺寸,解码将超过本版本 __ocs_fmt_1__ MB 的图像上限——无法附着。
.parse-ms-purge-ms-caches-ms-xref-ms-total-ms = 解析 __ocs_fmt_0__ 毫秒 · 清除 __ocs_fmt_1__ 毫秒 · 缓存 __ocs_fmt_2__ 毫秒 · xref __ocs_fmt_3__ 毫秒 · 总计 __ocs_fmt_4__ 毫秒
.archive-save-the-drawing-first-it-has-no-file = ARCHIVE:请先保存图形(尚未设置文件路径)。
.autosaved-1-drawing = 自动保存了 1 个绘图
+1
View File
@@ -2399,6 +2399,7 @@ command =
file =
.already-loaded-different-contents-kept = "__ocs_fmt_0__"已以不同內容載入;保留了現有檔案。
.image-refusal-unknown-size = 無法從檔頭測得「__ocs_fmt_0__」的尺寸,解碼將超過本版本 __ocs_fmt_1__ MB 的影像上限——無法附加。
.parse-ms-purge-ms-caches-ms-xref-ms-total-ms = 解析 __ocs_fmt_0__ 毫秒 · 清除 __ocs_fmt_1__ 毫秒 · 快取 __ocs_fmt_2__ 毫秒 · xref __ocs_fmt_3__ 毫秒 · 總計 __ocs_fmt_4__ 毫秒
.archive-save-the-drawing-first-it-has-no-file = ARCHIVE:請先儲存圖形(尚未設定檔案路徑)。
.autosaved-1-drawing = 自動儲存了 1 個繪圖
+2 -2
View File
@@ -2484,8 +2484,8 @@ impl OpenCADStudio {
self.tabs[i].dirty = false;
}
}
Err(error) => self.command_line.push_error(
crate::tf!("SAVEALL: {} failed: {error}", entry.path.display()).as_ref(),
Err(e) => self.command_line.push_error(
crate::tf!("SAVEALL: {} failed: {e}", entry.path.display()).as_ref(),
),
}
}
+8
View File
@@ -734,6 +734,14 @@ impl OpenCADStudio {
}
}
registered.extend(fetched);
if crate::scene::model::image_model::take_overflowed() {
// A settled fetch's rebuild trigger was dropped at the
// outcome cap; do one unconditional catch-up pass over
// every registered name.
registered.extend(
crate::scene::model::image_model::registered_image_names(),
);
}
if !registered.is_empty() {
for tab in self.tabs.iter_mut() {
tab.scene.resolve_registered_image_names(&registered);
+1 -1
View File
@@ -2045,7 +2045,7 @@ pub async fn pick_image_file() -> Result<(PathBuf, u32, u32), String> {
// references, which must survive for their own drains.
crate::scene::model::image_model::decode_refusals()
.iter()
.find(|note| note.contains(short.as_str()))
.find(|note| note.contains(&format!("\"{}\"", short)))
.cloned()
.unwrap_or_else(|| format!("Unsupported or corrupt image \"{short}\""))
})?;
+4
View File
@@ -2280,6 +2280,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
"Opening OCS Discussions for help and questions..." => Some(("view", "opening-ocs-discussions-for-help-and-questions")),
"Opening OCS Web..." => Some(("view", "opening-ocs-web")),
"\"{name}\" is already loaded with different contents; the existing file was kept." => Some(("file", "already-loaded-different-contents-kept")),
"\"{name}\" could not be sized from its header and decoding it exceeded the {limit} MB image limit of this build — it cannot be attached." => Some(("file", "image-refusal-unknown-size")),
"Opening Patreon page..." => Some(("view", "opening-patreon-page")),
"Opening feedback page..." => Some(("view", "opening-feedback-page")),
"Opening file" => Some(("ui", "opening-file")),
@@ -2701,6 +2702,8 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
"SAVEALL: saved {saved} drawing(s){}." => Some(("file", "saveall-saved-saved-drawing-s")),
"SAVEALL: {} already has a save running" => Some(("file", "saveall-already-has-a-save-running")),
"SAVEALL: {} failed: {e}" => Some(("file", "saveall-failed-e")),
"At most {MAX_EXTRACTION_SOURCES} drawings can be added in the browser." => Some(("file", "extraction-max-sources-web")),
"\"{}\" is already open in another tab. On the web both tabs share one stored copy — the last save overwrites the other." => Some(("file", "web-already-open-shared-copy")),
"SAVETIME autosave interval in minutes (0 = off):" => Some(("styles", "savetime-autosave-interval-in-minutes-0-off")),
"SAVETIME = {}" => Some(("styles", "savetime")),
"SCALE Specify base point [%{count} objects]:" => Some(("modify", "scale-specify-base-point-count-objects")),
@@ -5281,6 +5284,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
"Enter a GitHub URL or repository in owner/repo format." => Some(("common", "enter-a-github-url-or-repository-in-owner-repo-format")),
"DWGUNITS Convert drawing to:" => Some(("common", "dwgunits-convert-drawing-to")),
"Opened plot preview." => Some(("common", "opened-plot-preview")),
"Plot preview" => Some(("common", "plot-preview-task")),
"%{cmd} Select objects or [Settings] (%{count} selected, Enter to apply):" => Some(("common", "select-objects-or-settings-selected-enter-to-apply")),
"%{cmd} Select objects or [Settings]:" => Some(("common", "select-objects-or-settings")),
"+ Add parameter" => Some(("common", "add-parameter")),
+245 -49
View File
@@ -183,6 +183,19 @@ impl ImageModel {
pub fn from_raster_image(img: &codec::entities::RasterImage) -> Option<Self> {
let w = img.size.x;
let h = img.size.y;
// A degenerate or non-finite size/vectors (hostile entity data)
// produce NaN placement math that aborts whole PDF exports; treat
// it as an unresolvable reference instead.
if !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) {
return None;
}
let vector_ok = |v: &codec::Vector3| {
let (x, y, z) = (v.x, v.y, v.z);
x.is_finite() && y.is_finite() && z.is_finite() && (x != 0.0 || y != 0.0 || z != 0.0)
};
if !vector_ok(&img.u_vector) || !vector_ok(&img.v_vector) {
return None;
}
// Model-space geometry is drawn in (WCS - world_offset) so large UTM-
// scale coordinates stay within f32 precision; offset the image too.
// Corners come from a large insertion point plus small u/v spans.
@@ -504,6 +517,14 @@ fn image_cache() -> &'static Mutex<HashMap<String, ImageCacheEntry>> {
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Insertion order of the source cache's keys, so by-name resolution has a
/// deterministic winner (the MOST RECENT registration) instead of HashMap's
/// unspecified iteration order.
fn source_order() -> &'static Mutex<Vec<String>> {
static ORDER: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
ORDER.get_or_init(|| Mutex::new(Vec::new()))
}
fn source_cache() -> &'static Mutex<HashMap<String, Arc<Vec<u8>>>> {
static SOURCES: OnceLock<Mutex<HashMap<String, Arc<Vec<u8>>>>> = OnceLock::new();
SOURCES.get_or_init(|| Mutex::new(HashMap::new()))
@@ -536,10 +557,18 @@ pub(crate) fn same_file_name(a: &str, b: &str) -> bool {
/// finally supplies a missing reference) re-decodes instead of replaying a
/// stale success or a cached failure.
pub fn register_source(path: &str, bytes: Arc<Vec<u8>>) {
source_cache()
.lock()
.unwrap_or_else(|error| error.into_inner())
.insert(path.to_string(), bytes);
{
let mut sources = source_cache()
.lock()
.unwrap_or_else(|error| error.into_inner());
if !sources.contains_key(path) {
source_order()
.lock()
.unwrap_or_else(|error| error.into_inner())
.push(path.to_string());
}
sources.insert(path.to_string(), bytes);
}
if let Ok(mut cache) = image_cache().lock() {
cache.retain(|cached, _| !same_file_name(cached, path));
}
@@ -574,10 +603,17 @@ fn registered_bytes_by_name(path: &str) -> Option<Arc<Vec<u8>>> {
let cache = source_cache()
.lock()
.unwrap_or_else(|error| error.into_inner());
cache
// Deterministic winner: walk the insertion order newest-first so the
// most recent drop/pick wins, instead of HashMap iteration order.
let order = source_order()
.lock()
.unwrap_or_else(|error| error.into_inner());
order
.iter()
.find(|(key, _)| same_file_name(key, path))
.map(|(_, bytes)| Arc::clone(bytes))
.rev()
.filter(|key| cache.contains_key(*key))
.find(|key| same_file_name(key, path))
.and_then(|key| cache.get(key).cloned())
}
/// Original image file bytes registered for a reference, resolved the way
@@ -701,12 +737,51 @@ enum DecodeTier {
Full,
/// Decode fully, then downsample to the display cap before storing.
Downsample,
/// Full RGBA would exceed `MAX_DECODE_BYTES`. The decode still runs with
/// the budget enforced through `image::Limits`: it succeeds on a build
/// with headroom (native) or errors into a refusal (wasm).
/// The decode's peak memory would exceed `MAX_DECODE_BYTES`. The probe
/// bills the peak of the decoder's NATIVE bytes per pixel and the final
/// RGBA buffer (4/px): both transiently coexist mid-decode on 16-bit
/// formats, and the decoder's own limits reserve at native bpp — so an
/// image is refused up front when either exceeds the budget, without
/// decoding.
OverBudget,
}
/// The decoder's own memory accounting for a format: how many bytes per
/// pixel the DECODE transiently needs (native layout, plus the RGBA output
/// where they coexist) and any codec-specific scratch multiplier on top.
struct NativeFootprint {
/// Peak bytes per pixel during decode (native bpp + RGBA where both
/// live simultaneously; plain 8-bit formats overlap them in place).
peak_bpp: u64,
/// Extra codec scratch allowance the decoder stacks on the reserve
/// (image's TIFF codec needs ~3x in the worst strip/tile layout).
codec_factor: u64,
}
fn native_footprint(format: Option<image::ImageFormat>, color: Option<image::ColorType>) -> NativeFootprint {
let native_bpp = color.map_or(4u64, |color| match color {
image::ColorType::L8 => 1,
image::ColorType::La8 => 2,
image::ColorType::Rgb8 => 3,
image::ColorType::Rgba8 => 4,
image::ColorType::L16 => 2,
image::ColorType::La16 => 4,
image::ColorType::Rgb16 => 6,
image::ColorType::Rgba16 => 8,
image::ColorType::Rgb32F => 12,
image::ColorType::Rgba32F => 16,
_ => 4,
});
// A 16-bit frame and its RGBA conversion can coexist; an 8-bit one is
// converted with at most a small row buffer in flight.
let peak_bpp = if native_bpp > 4 { native_bpp + 4 } else { 4 };
let codec_factor = match format {
Some(image::ImageFormat::Tiff) => 3,
_ => 1,
};
NativeFootprint { peak_bpp, codec_factor }
}
/// Budget decision for probed pixel dimensions. Pure and cfg-independent
/// when the budgets are passed explicitly, so tests can probe arbitrary
/// budgets; the decode path always passes this target's constants.
@@ -715,9 +790,27 @@ fn tier_for(
height: u32,
max_decode_bytes: u64,
max_display_pixels: u64,
) -> DecodeTier {
tier_for_native(width, height, 4, 1, max_decode_bytes, max_display_pixels)
}
/// [`tier_for`] with the probed format's native footprint: the caller
/// supplies the probe's color type and format so 16-bit images are billed
/// at what their decode actually allocates.
fn tier_for_native(
width: u32,
height: u32,
peak_bpp: u64,
codec_factor: u64,
max_decode_bytes: u64,
max_display_pixels: u64,
) -> DecodeTier {
let pixels = width as u64 * height as u64;
if pixels.saturating_mul(4) > max_decode_bytes {
if pixels
.saturating_mul(peak_bpp)
.saturating_mul(codec_factor)
> max_decode_bytes
{
DecodeTier::OverBudget
} else if pixels > max_display_pixels {
DecodeTier::Downsample
@@ -766,24 +859,45 @@ fn decode_with_budgets<R: std::io::BufRead + std::io::Seek>(
max_decode_bytes: u64,
max_display_pixels: u64,
) -> DecodeAttempt {
// Probe pass: header dimensions only, no pixel decode.
// Probe pass: header dimensions AND color type, no pixel decode. The
// probe reader carries the full budget too so constructing a decoder on
// a huge-but-in-budget header cannot itself fail on image's default
// 512 MiB probe limits.
let probed = open()
.and_then(|reader| reader.with_guessed_format())
.ok()
.and_then(|reader| reader.into_dimensions().ok());
let tier = probed.map_or(DecodeTier::Full, |(width, height)| {
tier_for(width, height, max_decode_bytes, max_display_pixels)
.and_then(|mut reader| {
let format = reader.format();
let mut probe_limits = image::Limits::default();
probe_limits.max_alloc = Some(max_decode_bytes);
reader.limits(probe_limits);
use image::ImageDecoder as _;
let decoder = reader.into_decoder().ok()?;
let (width, height) = decoder.dimensions();
let color = decoder.color_type();
Some((width, height, format, color))
});
let tier = probed.map_or(DecodeTier::Full, |(width, height, format, color)| {
let footprint = native_footprint(format, Some(color));
tier_for_native(
width,
height,
footprint.peak_bpp,
footprint.codec_factor,
max_decode_bytes,
max_display_pixels,
)
});
// Trust the probe, not the decoder limits: `Limits::reserve` checks the
// decoder's NATIVE bytes-per-pixel, so a grayscale/RGB JPEG/PNG/BMP over
// the RGBA budget decodes fine and then `into_rgba8()` balloons to up to
// 4x it — exactly the tab death this exists to prevent. The probed
// dimensions already prove it cannot fit; refuse without decoding.
// Trust the probe, not the decoder limits: the tier above bills the
// decode's real peak (native bpp, codec scratch), so an OverBudget
// image provably cannot fit; refuse without decoding. This covers the
// grayscale/RGB over-RGBA-budget balloon AND the 16-bit formats whose
// native layout is double the RGBA size.
if tier == DecodeTier::OverBudget {
return DecodeAttempt::Refused {
width: probed.map_or(0, |(width, _)| width),
height: probed.map_or(0, |(_, height)| height),
width: probed.map_or(0, |(width, _, _, _)| width),
height: probed.map_or(0, |(_, height, _, _)| height),
};
}
@@ -792,24 +906,32 @@ fn decode_with_budgets<R: std::io::BufRead + std::io::Seek>(
Err(_) => return DecodeAttempt::Failed,
};
// For formats that FIT the budget, the limits still guard runaway
// allocations mid-decode. TIFF additionally stacks its own intermediate
// scratch buffers on top of the reserve (empirically ~3x the frame size
// in image 0.25), so refusing an in-budget TIFF at face value would
// produce a note claiming e.g. "400 MB, over the 512 MB limit".
let tiff = matches!(reader.format(), Some(image::ImageFormat::Tiff));
// allocations mid-decode. GIF is special: the probe reads the LOGICAL
// SCREEN, but a crafted frame may declare itself arbitrarily larger
// and the gif crate does not enforce frame-vs-screen consistency — so
// the frame buffer is capped to the probed size plus slack, refusing
// frame bombs into the existing Refused path. TIFF stacks its own
// intermediate scratch on top of the reserve (~3x worst case in image
// 0.25); the tier already folded that factor in, and the allowance
// matches it so an in-tier TIFF never codec-refuses.
let mut limits = image::Limits::default();
limits.max_alloc = Some(if tiff {
max_decode_bytes.saturating_mul(3)
} else {
max_decode_bytes
limits.max_alloc = Some(match reader.format() {
Some(image::ImageFormat::Gif) => probed
.map(|(width, height, _, _)| {
(width as u64).saturating_mul(height as u64).saturating_mul(4) + 64 * 1024 * 1024
})
.unwrap_or(max_decode_bytes)
.min(max_decode_bytes),
Some(image::ImageFormat::Tiff) => max_decode_bytes.saturating_mul(3),
_ => max_decode_bytes,
});
reader.limits(limits);
let img = match reader.decode() {
Ok(img) => img,
Err(image::ImageError::Limits(_)) => {
return DecodeAttempt::Refused {
width: probed.map_or(0, |(width, _)| width),
height: probed.map_or(0, |(_, height)| height),
width: probed.map_or(0, |(width, _, _, _)| width),
height: probed.map_or(0, |(_, height, _, _)| height),
};
}
Err(_) => return DecodeAttempt::Failed,
@@ -834,7 +956,7 @@ fn decode_with_budgets<R: std::io::BufRead + std::io::Seek>(
let (stored_w, stored_h) = rgba.dimensions();
// Sizing truth: the file's probed dimensions, not the (possibly reduced)
// buffer's, so placement and aspect come from the original raster.
let (source_width, source_height) = probed.unwrap_or((stored_w, stored_h));
let (source_width, source_height) = probed.map_or((stored_w, stored_h), |(w, h, _, _)| (w, h));
DecodeAttempt::Decoded(DecodedImage {
pixels: Arc::new(rgba.into_raw()),
width: stored_w,
@@ -873,6 +995,16 @@ fn refusal_message(path: &str, width: u32, height: u32) -> String {
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string());
if width == 0 || height == 0 {
// The probe could not determine a size (exotic header) — do not
// print a self-contradictory "0x0 px" figure.
return crate::tf!(
"\"{name}\" could not be sized from its header and decoding it exceeded the {limit} MB image limit of this build — it cannot be attached.",
name = name,
limit = MAX_DECODE_BYTES / (1024 * 1024)
)
.into_owned();
}
let decoded_mb = (width as u64).saturating_mul(height as u64).saturating_mul(4) / (1024 * 1024);
let limit_mb = MAX_DECODE_BYTES / (1024 * 1024);
crate::tf!(
@@ -1029,11 +1161,32 @@ mod web_fetch {
web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(
"ocs: remote-image outcome queue full; dropping an outcome",
));
// The dropped outcome may be a Loaded whose rebuild trigger is
// lost; remember that so the next poll does one unconditional
// image re-resolution instead of waiting for another event.
*overflowed()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = true;
return;
}
queue.push_back(outcome);
}
/// Whether an outcome was dropped at the cap and a catch-up rebuild is
/// owed. [`take_overflowed`] consumes it.
fn overflowed() -> &'static Mutex<bool> {
static FLAG: OnceLock<Mutex<bool>> = OnceLock::new();
FLAG.get_or_init(|| Mutex::new(false))
}
/// Consume the dropped-outcome flag (see [`overflowed`]).
pub fn take_overflowed() -> bool {
let mut flag = overflowed()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
std::mem::replace(&mut *flag, false)
}
/// Pop the oldest settled remote fetch the poll has not handled yet.
pub fn take_outcome() -> Option<RemoteFetchOutcome> {
outcomes()
@@ -1054,6 +1207,58 @@ mod web_fetch {
}
}
/// Read a response body incrementally, refusing past [`MAX_BYTES`] the
/// moment the cap is crossed (not after buffering the whole body).
async fn stream_body_capped(
window: &web_sys::Window,
response: &web_sys::Response,
) -> Result<Vec<u8>, RemoteFetchError> {
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::JsFuture;
let Some(stream) = response.body() else {
// No stream API (or already consumed): fall back to buffering,
// with the received-length check still applying after.
let buffer = JsFuture::from(
response.array_buffer().map_err(|_| RemoteFetchError::Network)?,
)
.await
.map_err(|error| rejection_kind(&error))?;
let bytes = js_sys::Uint8Array::new(&buffer).to_vec();
if bytes.len() as u64 > MAX_BYTES {
return Err(RemoteFetchError::TooLarge);
}
return Ok(bytes);
};
let reader = stream.get_reader().dyn_into::<web_sys::ReadableStreamDefaultReader>()
.map_err(|_| RemoteFetchError::Network)?;
let mut bytes: Vec<u8> = Vec::new();
loop {
let chunk = JsFuture::from(reader.read())
.await
.map_err(|error| rejection_kind(&error))?;
let done = js_sys::Reflect::get(&chunk, &wasm_bindgen::JsValue::from_str("done"))
.ok()
.and_then(|value| value.as_bool())
.unwrap_or(true);
if done {
break;
}
let value = js_sys::Reflect::get(&chunk, &wasm_bindgen::JsValue::from_str("value"))
.map_err(|_| RemoteFetchError::Network)?;
let view = js_sys::Uint8Array::new(&value);
if bytes.len() as u64 + view.length() as u64 > MAX_BYTES {
// Release the reader lock so the underlying connection can
// be torn down, then refuse.
let _ = JsFuture::from(reader.cancel()).await;
return Err(RemoteFetchError::TooLarge);
}
bytes.extend_from_slice(&view.to_vec());
}
let _ = window; // reader holds the stream; window kept for symmetry
Ok(bytes)
}
/// The actual request. `GET url` with an abort signal armed at
/// [`TIMEOUT_MS`]; the body is refused past [`MAX_BYTES`] using the
/// declared `Content-Length` first and the received length second.
@@ -1092,8 +1297,9 @@ mod web_fetch {
return Err(RemoteFetchError::Status(response.status()));
}
// Pre-check the declared size so an oversized body is refused before
// it is buffered; the received length is checked again after the
// read (chunked replies carry no Content-Length).
// it is buffered; then STREAM the body, aborting the moment the
// received bytes pass MAX_BYTES — array_buffer() would materialize
// a hostile chunked reply in full before any check could fire.
let declared = response
.headers()
.get("Content-Length")
@@ -1103,18 +1309,7 @@ mod web_fetch {
if declared.is_some_and(|declared| declared > MAX_BYTES) {
return Err(RemoteFetchError::TooLarge);
}
let buffer = JsFuture::from(
response
.array_buffer()
.map_err(|_| RemoteFetchError::Network)?,
)
.await
.map_err(|error| rejection_kind(&error))?;
let bytes = js_sys::Uint8Array::new(&buffer).to_vec();
if bytes.len() as u64 > MAX_BYTES {
return Err(RemoteFetchError::TooLarge);
}
Ok(bytes)
stream_body_capped(&window, &response).await
}
/// Start the background fetch for `url` if one hasn't been attempted this
@@ -1155,7 +1350,8 @@ fn fetch_remote(url: &str) -> Option<Vec<u8>> {
#[cfg(target_arch = "wasm32")]
pub use web_fetch::{
take_outcome as take_remote_fetch_outcome, RemoteFetchError, RemoteFetchOutcome,
take_outcome as take_remote_fetch_outcome, take_overflowed,
RemoteFetchError, RemoteFetchOutcome,
};
#[cfg(all(test, not(target_arch = "wasm32")))]