diff --git a/Cargo.toml b/Cargo.toml index 0be33e3a7..93422e416 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -159,6 +159,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"] } diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 18a5f6678..93173e577 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -3589,6 +3589,7 @@ view = .opening-ocs-discussions-for-help-and-questions = يجري فتح مناقشات OCS للمساعدة والأسئلة... .opening-ocs-web = يجري فتح OCS Web... .opening-the-command-reference = فتح مرجع الأوامر... + .image-refusal-unknown-size = تعذّر قياس أبعاد "__ocs_fmt_0__" من ترويسته وسيتجاوز فك ترميزه حد الصور __ocs_fmt_1__ ميغابايت في هذا الإصدار — لا يمكن إرفاقه. .already-loaded-different-contents-kept = "__ocs_fmt_0__" محمّل بالفعل بمحتوى مختلف؛ تم الاحتفاظ بالملف الموجود. .opening-patreon-page = يجري فتح صفحة Patreon... .opening-feedback-page = يجري فتح صفحة الملاحظات... diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 7b7d6cd99..5815f9d6c 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -3576,6 +3576,7 @@ view = .opening-ocs-discussions-for-help-and-questions = Отваряне на OCS дискусии за помощ и въпроси... .opening-ocs-web = Отваряне на OCS уебсайта... .opening-the-command-reference = Отваряне на справочника за команди... + .image-refusal-unknown-size = Размерът на „__ocs_fmt_0__“ не можа да се измери от заглавната му част, а декодирането му би надвишило __ocs_fmt_1__ MB лимита на тази сборка — не може да се прикачи. .already-loaded-different-contents-kept = "__ocs_fmt_0__" вече е зареден с различно съдържание; съществуващият файл е запазен. .opening-patreon-page = Отваряне на страницата в Patreon... .opening-feedback-page = Отваряне на страницата за обратна връзка... diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 6d5578481..afcbf319f 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -3574,6 +3574,7 @@ view = .opening-ocs-discussions-for-help-and-questions = Otevírání diskuzí OCS pro nápovědu a dotazy... .opening-ocs-web = Otevírání webu OCS... .opening-the-command-reference = Otevírání referenční příručky příkazů... + .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. .already-loaded-different-contents-kept = "__ocs_fmt_0__" je již načten s jiným obsahem; existující soubor byl zachován. .opening-patreon-page = Otevírání stránky Patreon... .opening-feedback-page = Otevírá se stránka zpětné vazby... diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 24c0dd284..379047b6a 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -3575,6 +3575,7 @@ view = .opening-ocs-discussions-for-help-and-questions = Eröffnung OCS Diskussionen für Hilfe und Fragen... .opening-ocs-web = Öffnen OCS Web... .opening-the-command-reference = Öffnen der Befehlsreferenz... + .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. .already-loaded-different-contents-kept = "__ocs_fmt_0__" ist bereits mit anderem Inhalt geladen; die vorhandene Datei wurde behalten. .opening-patreon-page = Eröffnungsseite Patreon... .opening-feedback-page = Feedbackseite öffnen... diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index be93ddb51..04bd58c4e 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -3571,6 +3571,7 @@ view = .opening-ocs-discussions-for-help-and-questions = Άνοιγμα των συζητήσεων OCS για βοήθεια και ερωτήσεις... .opening-ocs-web = Άνοιγμα του OCS Web... .opening-the-command-reference = Άνοιγμα της αναφοράς εντολών... + .image-refusal-unknown-size = Το "__ocs_fmt_0__" δεν μπόρεσε να μετρηθεί από την κεφαλίδα του και η αποκωδικοποίησή του θα ξεπερνούσε το όριο __ocs_fmt_1__ MB αυτής της build — δεν μπορεί να επισυναφθεί. .already-loaded-different-contents-kept = Το "__ocs_fmt_0__" είναι ήδη φορτωμένο με διαφορετικό περιεχόμενο· διατηρήθηκε το υπάρχον αρχείο. .opening-patreon-page = Άνοιγμα της σελίδας Patreon... .opening-feedback-page = Άνοιγμα της σελίδας σχολίων... diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index c059b53be..80daa1b83 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -3572,6 +3572,7 @@ view = .opening-ocs-discussions-for-help-and-questions = Opening OCS Discussions for help and questions... .opening-ocs-web = Opening OCS Web... .opening-the-command-reference = Opening the command reference... + .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. .already-loaded-different-contents-kept = "__ocs_fmt_0__" is already loaded with different contents; the existing file was kept. .opening-patreon-page = Opening Patreon page... .opening-feedback-page = Opening feedback page... diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 6683f4624..993e199b4 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -3577,6 +3577,7 @@ view = .opening-ocs-discussions-for-help-and-questions = Abriendo Debates de OCS para obtener ayuda y hacer preguntas... .opening-ocs-web = Abriendo OCS Web... .opening-the-command-reference = Abriendo la referencia de comandos... + .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. .already-loaded-different-contents-kept = "__ocs_fmt_0__" ya está cargado con contenido diferente; se conservó el archivo existente. .opening-patreon-page = Abriendo la página de Patreon... .opening-feedback-page = Abriendo la página de comentarios... diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 637b4b57f..c7a5a0051 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -3576,6 +3576,7 @@ view = .opening-ocs-discussions-for-help-and-questions = OCS-keskustelujen avaaminen apua ja kysymyksiä varten... .opening-ocs-web = Avataan OCS Web... .opening-the-command-reference = Avataan komentoviittausta... + .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ää. .already-loaded-different-contents-kept = "__ocs_fmt_0__" on jo ladattu eri sisällöllä; olemassa oleva tiedosto säilytettiin. .opening-patreon-page = Avataan Patreon-sivua... .opening-feedback-page = Palautesivua avataan... diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 3d98356fd..6f43b0559 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -3577,6 +3577,7 @@ view = .opening-ocs-discussions-for-help-and-questions = Ouverture OCS Discussions pour l'aide et les questions... .opening-ocs-web = Ouverture du site Web OCS... .opening-the-command-reference = Ouverture de la référence des commandes... + .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. .already-loaded-different-contents-kept = "__ocs_fmt_0__" est déjà chargé avec un contenu différent ; le fichier existant a été conservé. .opening-patreon-page = Ouverture de la page Patreon... .opening-feedback-page = Ouverture de la page de rétroaction... diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index b2330a422..11138781a 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -3565,6 +3565,7 @@ view = .opening-ocs-discussions-for-help-and-questions = OCS मदद और प्रश्नों के लिए चर्चा खोलना। .opening-ocs-web = OCS वेब खोलना ... .opening-the-command-reference = कमांड संदर्भ खोला जा रहा है... + .image-refusal-unknown-size = "__ocs_fmt_0__" को उसके हेडर से मापा नहीं जा सका और उसे डिकोड करना इस बिल्ड की __ocs_fmt_1__ MB छवि सीमा पार कर देगा — जोड़ा नहीं जा सकता। .already-loaded-different-contents-kept = "__ocs_fmt_0__" अलग सामग्री के साथ पहले से लोड है; मौजूदा फ़ाइल रखी गई। .opening-patreon-page = Patreon पृष्ठ खोला जा रहा है… .opening-feedback-page = प्रतिक्रिया पृष्ठ खोलना diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index d76a45ce0..4603a9f3f 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -3576,6 +3576,7 @@ view = .opening-ocs-discussions-for-help-and-questions = A OCS beszélgetések megnyitása segítségért és kérdésekért... .opening-ocs-web = A OCS web megnyitása... .opening-the-command-reference = A parancsreferencia megnyitása... + .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ó. .already-loaded-different-contents-kept = A(z) "__ocs_fmt_0__" már betöltve eltérő tartalommal; a meglévő fájl megőrizve. .opening-patreon-page = Patreon oldal megnyitása... .opening-feedback-page = Visszajelzési oldal megnyitása... diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 949e8bd11..de23dea57 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -3576,6 +3576,7 @@ view = .opening-ocs-discussions-for-help-and-questions = Apertura delle discussioni OCS per assistenza e domande... .opening-ocs-web = Apertura del Web OCS... .opening-the-command-reference = Apertura del riferimento dei comandi... + .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. .already-loaded-different-contents-kept = "__ocs_fmt_0__" è già caricato con contenuti diversi; il file esistente è stato mantenuto. .opening-patreon-page = Apertura della pagina Patreon... .opening-feedback-page = Apertura della pagina di feedback... diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index b27286783..ff76117ab 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -3565,6 +3565,7 @@ view = .opening-ocs-discussions-for-help-and-questions = ヘルプと質問のため OCS ディスカッションを開いています... .opening-ocs-web = OCS Web を開いています... .opening-the-command-reference = コマンドリファレンスを開いています... + .image-refusal-unknown-size = "__ocs_fmt_0__" のサイズをヘッダーから測定できず、デコードするとこのビルドの __ocs_fmt_1__ MB 画像制限を超えます — 添付できません。 .already-loaded-different-contents-kept = "__ocs_fmt_0__" は既に別の内容で読み込まれています。既存のファイルを保持しました。 .opening-patreon-page = Patreon ページを開いています... .opening-feedback-page = フィードバックページを開いています... diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index ad35812ac..23243f623 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -3576,6 +3576,7 @@ view = .opening-ocs-discussions-for-help-and-questions = OCS 열기 도움말 및 질문에 대한 토론... .opening-ocs-web = OCS 웹을 여는 중... .opening-the-command-reference = 명령 참조 열기... + .image-refusal-unknown-size = "__ocs_fmt_0__"의 크기를 헤더에서 측정할 수 없고 디코딩 시 이 빌드의 __ocs_fmt_1__ MB 이미지 한도를 초과합니다 — 첨부할 수 없습니다. .already-loaded-different-contents-kept = "__ocs_fmt_0__"이(가) 이미 다른 내용으로 로드되었습니다. 기존 파일을 유지했습니다. .opening-patreon-page = Patreon 페이지를 여는 중... .opening-feedback-page = 피드백 페이지를 여는 중... diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 6d0f052e2..3122049b1 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -3575,6 +3575,7 @@ view = .opening-ocs-discussions-for-help-and-questions = Openen OCS Discussie voor hulp en vragen... .opening-ocs-web = Openen OCS Web... .opening-the-command-reference = Commandoreferentie openen... + .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. .already-loaded-different-contents-kept = "__ocs_fmt_0__" is al geladen met andere inhoud; het bestaande bestand is bewaard. .opening-patreon-page = Patreon pagina openen... .opening-feedback-page = Herinneringspagina openen... diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 95a2b39b7..12617c25e 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -3576,6 +3576,7 @@ view = .opening-ocs-discussions-for-help-and-questions = Otwieranie dyskusji OCS w celu uzyskania pomocy i pytań... .opening-ocs-web = Otwieranie OCS Web... .opening-the-command-reference = Otwieranie dokumentacji poleceń... + .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ć. .already-loaded-different-contents-kept = "__ocs_fmt_0__" jest już wczytany z inną zawartością; zachowano istniejący plik. .opening-patreon-page = Otwieranie strony Patreon... .opening-feedback-page = Otwieram stronę z opiniami... diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index bd070f9c1..a68c91621 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -3577,6 +3577,7 @@ view = .opening-ocs-discussions-for-help-and-questions = Abrindo Discussões do OCS para ajuda e perguntas... .opening-ocs-web = Abrindo OCS Web... .opening-the-command-reference = Abrindo a referência de comandos... + .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. .already-loaded-different-contents-kept = "__ocs_fmt_0__" já está carregado com conteúdo diferente; o ficheiro existente foi mantido. .opening-patreon-page = Abrindo página do Patreon... .opening-feedback-page = Abrindo página de comentários... diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index e4368d063..800fb2fea 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -3578,6 +3578,7 @@ view = .opening-ocs-discussions-for-help-and-questions = Открытие OCS Дискуссии о помощи и вопросах .opening-ocs-web = Открытие OCS Веб-сайт .opening-the-command-reference = Открытие справочника команд... + .image-refusal-unknown-size = «__ocs_fmt_0__» не удалось измерить по заголовку, а его декодирование превысит лимит __ocs_fmt_1__ МБ этой сборки — вложить нельзя. .already-loaded-different-contents-kept = "__ocs_fmt_0__" уже загружен с другим содержимым; существующий файл сохранён. .opening-patreon-page = Открытие страницы Patreon... .opening-feedback-page = Открываем страницу обратной связи... diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index ab9e3dba0..9a407ea40 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -3527,6 +3527,7 @@ view = .opening-ocs-discussions-for-help-and-questions = Yardım ve sorular için OCS Tartışmaları açılıyor... .opening-ocs-web = OCS Web açılıyor... .opening-the-command-reference = Komut başvurusu açılıyor... + .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. .already-loaded-different-contents-kept = "__ocs_fmt_0__" zaten farklı içerikle yüklü; mevcut dosya tutuldu. .opening-patreon-page = Patreon sayfası açılıyor... .opening-feedback-page = Geri bildirim sayfası açılıyor... diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index b7e204b1a..709b3fdd2 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -3560,6 +3560,7 @@ view = .opening-ocs-discussions-for-help-and-questions = 开幕OCS 讨论帮助和问题... .opening-ocs-web = 打开OCS网络... .opening-the-command-reference = 正在打开命令参考... + .image-refusal-unknown-size = 无法从文件头测得“__ocs_fmt_0__”的尺寸,解码将超过本版本 __ocs_fmt_1__ MB 的图像上限——无法附着。 .already-loaded-different-contents-kept = "__ocs_fmt_0__"已以不同内容加载;保留了现有文件。 .opening-patreon-page = 打开帕特伦的页面... .opening-feedback-page = 打开反馈页面... diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 7b173cf8c..3c8225c23 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -3572,6 +3572,7 @@ view = .opening-ocs-discussions-for-help-and-questions = 開幕OCS 討論幫助和問題... .opening-ocs-web = 開啟OCS網路... .opening-the-command-reference = 正在開啟命令參考... + .image-refusal-unknown-size = 無法從檔頭測得「__ocs_fmt_0__」的尺寸,解碼將超過本版本 __ocs_fmt_1__ MB 的影像上限——無法附加。 .already-loaded-different-contents-kept = "__ocs_fmt_0__"已以不同內容載入;保留了現有檔案。 .opening-patreon-page = 開啟帕特倫的頁面... .opening-feedback-page = 開啟反饋頁面... diff --git a/scripts/build-web.sh b/scripts/build-web.sh index f675b346d..862f7bf01 100644 --- a/scripts/build-web.sh +++ b/scripts/build-web.sh @@ -28,6 +28,12 @@ trunk build --release --dist dist/app --public-url /app/ "$@" # Mirror the font sources over the bundle and verify the copy-dir output. mkdir -p dist/app/fonts +# Hard-fail with a clear message when there are no fonts to verify. +first_font=$(echo web/fonts/*.ttf) +if [ ! -e "$first_font" ]; then + echo "build-web.sh: no .ttf fonts found under web/fonts — nothing to ship or verify" >&2 + exit 1 +fi cp -f web/fonts/* dist/app/fonts/ count=0 diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index fc116e3da..c3227c3aa 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -448,6 +448,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(®istered); diff --git a/src/io/mod.rs b/src/io/mod.rs index c0dda28f4..679cd711f 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -1905,7 +1905,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}\"")) })?; diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 2620bb16c..682da08de 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -1886,6 +1886,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")), "Opening the command reference..." => Some(("view", "opening-the-command-reference")), + "\"{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")), "\"{name}\" is already loaded with different contents; the existing file was kept." => Some(("view", "already-loaded-different-contents-kept")), "Opening Patreon page..." => Some(("view", "opening-patreon-page")), "Opening feedback page..." => Some(("view", "opening-feedback-page")), diff --git a/src/scene/model/image_model.rs b/src/scene/model/image_model.rs index b45bcdd6f..2e6ab9602 100644 --- a/src/scene/model/image_model.rs +++ b/src/scene/model/image_model.rs @@ -135,6 +135,19 @@ impl ImageModel { pub fn from_raster_image(img: &acadrust::entities::RasterImage) -> Option { 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: &acadrust::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. @@ -396,6 +409,14 @@ fn image_cache() -> &'static Mutex> { 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> { + static ORDER: OnceLock>> = OnceLock::new(); + ORDER.get_or_init(|| Mutex::new(Vec::new())) +} + fn source_cache() -> &'static Mutex>>> { static SOURCES: OnceLock>>>> = OnceLock::new(); SOURCES.get_or_init(|| Mutex::new(HashMap::new())) @@ -428,10 +449,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>) { - 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)); } @@ -466,10 +495,17 @@ fn registered_bytes_by_name(path: &str) -> Option>> { 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 @@ -593,12 +629,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, color: Option) -> 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. @@ -607,9 +682,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 @@ -658,24 +751,45 @@ fn decode_with_budgets( 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), }; } @@ -684,24 +798,32 @@ fn decode_with_budgets( 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, @@ -726,7 +848,7 @@ fn decode_with_budgets( 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, @@ -765,6 +887,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!( @@ -921,11 +1053,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 { + static FLAG: OnceLock> = 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 { outcomes() @@ -946,6 +1099,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, 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::() + .map_err(|_| RemoteFetchError::Network)?; + let mut bytes: Vec = 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. @@ -984,8 +1189,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") @@ -995,18 +1201,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 @@ -1047,7 +1242,8 @@ fn fetch_remote(url: &str) -> Option> { #[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")))]