diff --git a/docs/native-vs-web.md b/docs/native-vs-web.md index b91d026fc..09440bde2 100644 --- a/docs/native-vs-web.md +++ b/docs/native-vs-web.md @@ -11,12 +11,12 @@ browser can't provide. This page lists the differences. |------|------------------|------------| | Windowing | Multi-window (`iced::daemon`) | Single window (`iced::application`); dialogs are in-canvas modals | | 3D solid modeling | Yes | Yes — the geometry kernel is pure Rust | -| Hatch rendering | Yes | **No** — WebGL2 has no vertex-stage storage | -| Fonts | Embedded stroke fonts + system TrueType + shaping + fallback | Embedded stroke fonts only | +| Hatch rendering | Yes | Yes — texture transport when WebGL2 lacks vertex-stage storage | +| Fonts | Embedded stroke fonts + system TrueType + shaping + fallback | Fetched Noto subsets (shaping, fallback, and family aliases) + embedded stroke fonts | | File open | Native file dialog → path | Browser picker → bytes | | File save | Path / Save dialog → write to disk | Save dialog → direct browser download | -| Parallelism | Multi-threaded (rayon) | Single-threaded | -| GPU backend | Vulkan / DX12 / Metal (wgpu) | WebGL2 (WebGPU where available) | +| Parallelism | Multi-threaded (rayon) | Main thread + one Web Worker (DWG/DXF parse, hashing) | +| GPU backend | Vulkan / DX12 / Metal (wgpu) | WebGL2 (the backend is pinned to OpenGL) | | Update check / external links | `ureq`, `open` | skipped / `window.open` | | External plugins | Native packages + Plugin Manager | **No** — native libraries cannot run in a browser | @@ -33,21 +33,26 @@ all, so none of that applies and the feature is gone. The web build makes primitives, runs booleans, draws ACIS solids read from a file and writes them back out as exact geometry, exactly as the desktop build does. -### Hatch rendering — web: not drawn +### Hatch rendering — web: same modes via a texture transport The batched hatch pipeline binds a read-only storage buffer in the vertex -stage. WebGL2 lacks `VERTEX_STORAGE`, so that pipeline is skipped on wasm and -hatches simply don't render. Everything else (lines, arcs, polylines, text, -images) draws normally. +stage, which WebGL2 lacks. The hatch renderer selects its texture backend +instead (an RGBA32F data texture + `hatch_texture.wgsl`) and draws the same +solid, patterned, and gradient hatches as native, with the same dash math and +sub-pixel-to-solid LOD fallback. Everything else (lines, arcs, polylines, +text, images) draws normally. -### Fonts — web: bundled stroke fonts only +### Fonts — web: fetched Noto subsets plus embedded stroke fonts - **Native** discovers installed system fonts (fontdb), extracts TrueType outlines (ttf-parser), shapes runs with cosmic-text (ligatures, Arabic joining, kerning, bidi), and falls back to a system font for glyphs a stroke font lacks. -- **Web** has no system fonts, and cosmic-text panics with "no default font - found" on an empty font set, so shaping and fallback are disabled. Only the - embedded LFF stroke fonts render; a glyph missing from them is skipped. - (A future option is to fetch a font file from the server at startup.) +- **Web** fetches per-script Noto subsets from the server, registers them + into the font database, and shapes runs with the same cosmic-text path + (ligatures, Arabic joining, bidi) with per-glyph Noto fallback. Text styles + naming a desktop family (Arial, Times New Roman, ...) resolve through an + alias table to the Noto faces, so they render filled instead of falling to + the stroke fonts; scripts without a fetched subset still render via the + embedded LFF stroke fonts. (Text pops in once the first font fetch lands.) ### File I/O — web: bytes and downloads - **Open**: the desktop returns a filesystem path and reads it (streaming). diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 70a9274a2..74dd95bb5 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -2448,6 +2448,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: حُفظ __ocs_fmt_0__ رسم__ocs_fmt_1__. .saveall-already-has-a-save-running = SAVEALL: يجري بالفعل حفظ __ocs_fmt_0__ .saveall-failed-e = SAVEALL: فشل __ocs_fmt_0__: ‏__ocs_fmt_1__ + .load-linetype-file = تحميل ملف نوع الخط .script-path-to-the-scr-file = SCRIPT مسار ملف ‎.scr: .script-cannot-read-p-e = SCRIPT: تعذرت قراءة __ocs_fmt_0__: ‏__ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT: يجري تشغيل __ocs_fmt_0__ أمر من __ocs_fmt_1__. diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 1019d86cf..744750a78 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -2435,6 +2435,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: запазени __ocs_fmt_0__ чертеж(а)__ocs_fmt_1__. .saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__ вече има изпълняващо се запазване .saveall-failed-e = SAVEALL: __ocs_fmt_0__ се провали: __ocs_fmt_1__ + .load-linetype-file = Зареждане на файл с типове линии .script-path-to-the-scr-file = SCRIPT път до .scr файла: .script-cannot-read-p-e = SCRIPT: не може да се прочете __ocs_fmt_0__: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT: изпълняват се __ocs_fmt_0__ команда(и) от __ocs_fmt_1__. diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 8e33eada4..b71130fbd 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -2435,6 +2435,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: uložený výkres(y) __ocs_fmt_0__ __ocs_fmt_1__. .saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__ již probíhá ukládání .saveall-failed-e = SAVEALL: __ocs_fmt_0__ se nezdařilo: __ocs_fmt_1__ + .load-linetype-file = Načíst soubor typů čar .script-path-to-the-scr-file = Cesta SCRIPT k souboru .scr: .script-cannot-read-p-e = SCRIPT: nelze číst __ocs_fmt_0__: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT: spuštění příkazu(ů) __ocs_fmt_0__ ze __ocs_fmt_1__. diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 7048cfcd3..6daeeb03d 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -2436,6 +2436,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: gespeichert __ocs_fmt_0__ Zeichnung(en)__ocs_fmt_1__. .saveall-already-has-a-save-running = SAVEALL: Für __ocs_fmt_0__ wird bereits gespeichert .saveall-failed-e = SAVEALL: __ocs_fmt_0__ fehlgeschlagen: __ocs_fmt_1__ + .load-linetype-file = Linetype-Datei laden .script-path-to-the-scr-file = SCRIPT Pfad zur .scr-Datei: .script-cannot-read-p-e = SCRIPT: kann nicht lesen __ocs_fmt_0__: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT: Ausführen des Befehls __ocs_fmt_0__ aus __ocs_fmt_1__. diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 8e34d27f2..dd756a954 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -2435,6 +2435,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: αποθηκεύτηκαν __ocs_fmt_0__ σχέδιο(α)__ocs_fmt_1__. .saveall-already-has-a-save-running = SAVEALL: για το __ocs_fmt_0__ εκτελείται ήδη αποθήκευση .saveall-failed-e = SAVEALL: το __ocs_fmt_0__ απέτυχε: __ocs_fmt_1__ + .load-linetype-file = Φόρτωση αρχείου τύπου γραμμής .script-path-to-the-scr-file = SCRIPT διαδρομή προς το αρχείο .scr: .script-cannot-read-p-e = SCRIPT: δεν είναι δυνατή η ανάγνωση του __ocs_fmt_0__: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT: εκτέλεση __ocs_fmt_0__ εντολής(ών) από __ocs_fmt_1__. diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 5244af9bb..d9d352f83 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -2436,6 +2436,7 @@ 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__ + .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__ .script-running-command-s-from-p = SCRIPT: running __ocs_fmt_0__ command(s) from __ocs_fmt_1__. diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 14d142b25..cabaef135 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -2436,6 +2436,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: se guardaron __ocs_fmt_0__ dibujos__ocs_fmt_1__. .saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__ ya tiene un guardado en curso .saveall-failed-e = SAVEALL: __ocs_fmt_0__ falló: __ocs_fmt_1__ + .load-linetype-file = Cargar archivo de tipos de línea .script-path-to-the-scr-file = SCRIPT ruta del archivo .scr: .script-cannot-read-p-e = SCRIPT: no se puede leer __ocs_fmt_0__: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT: ejecutando __ocs_fmt_0__ comandos desde __ocs_fmt_1__. diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 7f804b402..977116061 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -2435,6 +2435,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: tallennettu __ocs_fmt_0__-piirustukset __ocs_fmt_1__. .saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__:n tallennus on jo käynnissä .saveall-failed-e = SAVEALL: __ocs_fmt_0__ epäonnistui: __ocs_fmt_1__ + .load-linetype-file = Lataa viivatyylitiedosto .script-path-to-the-scr-file = SCRIPT polku .scr-tiedostoon: .script-cannot-read-p-e = SCRIPT: ei voi lukea __ocs_fmt_0__: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT: __ocs_fmt_0__-komentojen suorittaminen lähteestä __ocs_fmt_1__. diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 5e98ae7f9..388a77078 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -2436,6 +2436,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: enregistré __ocs_fmt_0__ dessin(s)__ocs_fmt_1__. .saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__ a déjà un enregistrement en cours d'exécution .saveall-failed-e = SAVEALL: __ocs_fmt_0__ échoué: __ocs_fmt_1__ + .load-linetype-file = Charger le fichier de type de ligne .script-path-to-the-scr-file = SCRIPT chemin vers le fichier .scr : .script-cannot-read-p-e = SCRIPT: ne peut pas lire __ocs_fmt_0__: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT : exécuter __ocs_fmt_0__ commande(s) depuis __ocs_fmt_1__. diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 4cf683129..163ac2082 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -2424,6 +2424,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: __ocs_fmt_0__ ड्रॉइंग सहेजी गईं; __ocs_fmt_1__। .saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__ पहले से ही एक बचा दौड़ है .saveall-failed-e = SAVEALL: __ocs_fmt_0__ विफल: __ocs_fmt_1__ + .load-linetype-file = लाइनटाइप फ़ाइल लोड करें .script-path-to-the-scr-file = SCRIPT .scr फ़ाइल के लिए पथ: .script-cannot-read-p-e = SCRIPTनहीं पढ़ सकते __ocs_fmt_0__: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT: __ocs_fmt_0__ __ocs_fmt_1__ से कमांड (s) चला रहा है। diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 95f3337db..d87139291 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -2448,6 +2448,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: mentett __ocs_fmt_0__ rajz(ok)__ocs_fmt_1__. .saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__ mentése már folyamatban van .saveall-failed-e = SAVEALL: __ocs_fmt_0__ sikertelen: __ocs_fmt_1__ + .load-linetype-file = Vonaltípusfájl betöltése .script-path-to-the-scr-file = SCRIPT elérési út a .scr fájlhoz: .script-cannot-read-p-e = SCRIPT: nem tudja olvasni __ocs_fmt_0__: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT: __ocs_fmt_0__ parancs(ok) futtatása a __ocs_fmt_1__ fájlból. diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 6c63c4bb6..44db88161 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -2435,6 +2435,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: disegno/i __ocs_fmt_0__ salvato/i__ocs_fmt_1__. .saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__ ha già un salvataggio in corso .saveall-failed-e = SAVEALL: __ocs_fmt_0__ non riuscito: __ocs_fmt_1__ + .load-linetype-file = Carica file tipi di linea .script-path-to-the-scr-file = SCRIPT percorso del file .scr: .script-cannot-read-p-e = SCRIPT: impossibile leggere __ocs_fmt_0__: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT: esecuzione dei comandi __ocs_fmt_0__ da __ocs_fmt_1__. diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 70d5a2e21..b77e8cd62 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -2426,6 +2426,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: __ocs_fmt_0__ 件の図面を保存しました__ocs_fmt_1__。 .saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__ は既に保存中です .saveall-failed-e = SAVEALL: __ocs_fmt_0__ 失敗: __ocs_fmt_1__ + .load-linetype-file = 線種ファイルを読み込む .script-path-to-the-scr-file = SCRIPT .scr ファイルのパス: .script-cannot-read-p-e = SCRIPT: __ocs_fmt_0__ を読み込めません: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT: __ocs_fmt_1__ から __ocs_fmt_0__ 個のコマンドを実行中です。 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 6c9e5c1cd..dcea038c4 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -2435,6 +2435,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: __ocs_fmt_0__ 도면 __ocs_fmt_1__를 저장했습니다. .saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__에는 이미 실행 중인 저장이 있습니다. .saveall-failed-e = SAVEALL: __ocs_fmt_0__ 실패: __ocs_fmt_1__ + .load-linetype-file = 선종 파일 로드 .script-path-to-the-scr-file = .scr 파일에 대한 SCRIPT 경로: .script-cannot-read-p-e = SCRIPT: __ocs_fmt_0__: __ocs_fmt_1__를 읽을 수 없습니다. .script-running-command-s-from-p = SCRIPT: __ocs_fmt_1__에서 __ocs_fmt_0__ 명령을 실행합니다. diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 54fca65db..7109c96f4 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -2436,6 +2436,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: opgeslagen __ocs_fmt_0__ tekening(s)__ocs_fmt_1__. .saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__ heeft al een opslag uitgevoerd .saveall-failed-e = SAVEALL: __ocs_fmt_0__ mislukt: __ocs_fmt_1__ + .load-linetype-file = Lijntypebestand laden .script-path-to-the-scr-file = SCRIPT pad naar het .scr bestand: .script-cannot-read-p-e = SCRIPT: kan __ocs_fmt_0__ niet lezen: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT: __ocs_fmt_0__ commando(s) van __ocs_fmt_1__. diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 65d055025..91f8b7439 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -2435,6 +2435,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: zapisane rysunki __ocs_fmt_0__ __ocs_fmt_1__. .saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__ ma już uruchomiony zapis .saveall-failed-e = SAVEALL: __ocs_fmt_0__ nie powiodło się: __ocs_fmt_1__ + .load-linetype-file = Wczytaj plik typów linii .script-path-to-the-scr-file = Ścieżka SCRIPT do pliku .scr: .script-cannot-read-p-e = SCRIPT: nie można odczytać __ocs_fmt_0__: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT: uruchamianie poleceń __ocs_fmt_0__ z __ocs_fmt_1__. diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index a9a4043d9..02d7f8cca 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -2436,6 +2436,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: __ocs_fmt_0__ desenhos salvos__ocs_fmt_1__. .saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__ já tem um salvamento em andamento .saveall-failed-e = SAVEALL: __ocs_fmt_0__ falhou: __ocs_fmt_1__ + .load-linetype-file = Carregar arquivo de tipos de linha .script-path-to-the-scr-file = SCRIPT caminho do arquivo .scr: .script-cannot-read-p-e = SCRIPT: não é possível ler __ocs_fmt_0__: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT: executando __ocs_fmt_0__ comandos de __ocs_fmt_1__. diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index c3354f80b..f34c776de 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -2439,6 +2439,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: сохранен __ocs_fmt_0__ чертеж(ы)__ocs_fmt_1__. .saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__ уже имеет резервный ход .saveall-failed-e = SAVEALL: __ocs_fmt_0__ не получилось: __ocs_fmt_1__ + .load-linetype-file = Загрузить файл типов линий .script-path-to-the-scr-file = SCRIPT Путь к файлу .scr: .script-cannot-read-p-e = SCRIPT: не может читать __ocs_fmt_0__: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT: выполнение команды (команд) __ocs_fmt_0__ из __ocs_fmt_1__. diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 2251b961b..e07c6fdb7 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -2419,6 +2419,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL: __ocs_fmt_0__ çizim kaydedildi__ocs_fmt_1__. .saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__ için zaten bir kaydetme işlemi çalışıyor .saveall-failed-e = SAVEALL: __ocs_fmt_0__ başarısız: __ocs_fmt_1__ + .load-linetype-file = Çizgi tipi dosyasını yükle .script-path-to-the-scr-file = SCRIPT .scr dosyasının yolu: .script-cannot-read-p-e = SCRIPT: __ocs_fmt_0__ okunamıyor: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT: __ocs_fmt_1__ dosyasından __ocs_fmt_0__ komut çalıştırılıyor. diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 6e01329ed..c27fc58c3 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -2423,6 +2423,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL:已保存 __ocs_fmt_0__ 绘图 __ocs_fmt_1__ 。 .saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__ 已经有一个保存运行 .saveall-failed-e = SAVEALL : __ocs_fmt_0__ 失败 : __ocs_fmt_1__ + .load-linetype-file = 加载线型文件 .script-path-to-the-scr-file = SCRIPT 路径 : .script-cannot-read-p-e = SCRIPT: 无法读取__ocs_fmt_0__: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT:从__ocs_fmt_1__运行__ocs_fmt_0__命令。 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 49514b49b..e9e205828 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -2435,6 +2435,7 @@ file = .saveall-saved-saved-drawing-s = SAVEALL:已儲存 __ocs_fmt_0__ 繪圖 __ocs_fmt_1__ 。 .saveall-already-has-a-save-running = SAVEALL: __ocs_fmt_0__ 已經有一個儲存執行 .saveall-failed-e = SAVEALL : __ocs_fmt_0__ 失敗 : __ocs_fmt_1__ + .load-linetype-file = 載入線型檔案 .script-path-to-the-scr-file = SCRIPT 路徑 : .script-cannot-read-p-e = SCRIPT: 無法讀取__ocs_fmt_0__: __ocs_fmt_1__ .script-running-command-s-from-p = SCRIPT:從__ocs_fmt_1__執行__ocs_fmt_0__命令。 diff --git a/src/app/commands/fileops.rs b/src/app/commands/fileops.rs index 7b922bec0..2c354b45b 100644 --- a/src/app/commands/fileops.rs +++ b/src/app/commands/fileops.rs @@ -80,9 +80,9 @@ impl OpenCADStudio { } #[cfg(target_arch = "wasm32")] { - self.command_line.push_info( - crate::t!("SAVEALL: save each tab individually in the web build.").as_ref(), - ); + // Web: every dirty saved tab is serialized and downloaded + // in one pass — see `saveall_web` for the flow. + self.saveall_web(); } return Some(Task::none()); } @@ -420,30 +420,30 @@ impl OpenCADStudio { let path = cmd.split_once(' ').map(|(_, r)| r.trim().to_string()); match path { Some(p) if !p.is_empty() => { - let clean_path = p.trim().trim_matches(|c| c == '"' || c == '\''); - match std::fs::read_to_string(clean_path) { - Ok(text) => { - let cmds: Vec> = text - .lines() - .map(str::trim) - .filter(|l| !l.starts_with('#') && !l.starts_with(';')) - .map(|l| Task::done(Message::ScriptLine(l.to_string()))) - .collect(); - self.command_line.push_output( - crate::tf!( - "SCRIPT: running {} command(s) from {clean_path}.", - cmds.len() - ) - .as_ref(), - ); - return Some(Task::batch(cmds)); - } - Err(e) => { - self.command_line.push_error( - crate::tf!("SCRIPT: cannot read {clean_path}: {e}").as_ref(), - ); + #[cfg(not(target_arch = "wasm32"))] + { + let clean_path = p.trim().trim_matches(|c| c == '"' || c == '\''); + match std::fs::read_to_string(clean_path) { + Ok(text) => return Some(self.run_script(clean_path, &text)), + Err(e) => { + self.command_line.push_error( + crate::tf!("SCRIPT: cannot read {clean_path}: {e}").as_ref(), + ); + } } } + // Web: nothing is behind a typed path — pick the + // script through the browser and read its bytes at + // pick time (the `pick_and_load_web` pattern); the + // picked text is fed to the same runner. + #[cfg(target_arch = "wasm32")] + { + let _ = &p; + return Some(Task::perform( + crate::io::pick_script_web(), + Message::ScriptFilePicked, + )); + } } _ => { self.command_line @@ -456,6 +456,53 @@ impl OpenCADStudio { } Some(self.finish_dispatch(cmd)) } + + /// Announce a script and feed its rows to the same runner the `--script` + /// startup flag uses — shared by the native `SCRIPT ` read and the + /// web picker's `ScriptFilePicked`. + pub(in crate::app) fn run_script(&mut self, path: &str, text: &str) -> Task { + let p = path; + let cmds: Vec> = script_command_lines(text) + .into_iter() + .map(|line| Task::done(Message::ScriptLine(line))) + .collect(); + self.command_line.push_output( + crate::tf!("SCRIPT: running {} command(s) from {p}.", cmds.len()).as_ref(), + ); + Task::batch(cmds) + } +} + +/// One runnable row of a `.scr` script: trimmed, comment rows (`#`, `;`) +/// dropped, blank rows kept so they submit Enter to the active command. +fn script_command_lines(text: &str) -> Vec { + text.lines() + .map(str::trim) + .filter(|line| !line.starts_with('#') && !line.starts_with(';')) + .map(str::to_string) + .collect() +} + +#[cfg(test)] +mod script_tests { + use super::script_command_lines; + + /// Comments drop, every other row survives trimmed, and blank or + /// whitespace-only rows stay as empty strings (Enter presses). + #[test] + fn comments_and_blanks_follow_the_scr_rules() { + let lines = + script_command_lines("# comment\n; also a comment\nLINE 0,0 10,0\n\n \n TRIM \n"); + assert_eq!( + lines, + vec![ + "LINE 0,0 10,0".to_string(), + String::new(), + String::new(), + "TRIM".to_string(), + ] + ); + } } /// Parse the argument list of the `BACKGROUND` command into an `[r,g,b,a]` diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 9b102e463..0599bdb89 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -1757,6 +1757,18 @@ impl OpenCADStudio { _ => unreachable!(), }; if let Some(value) = &value { + // Web CENTERLTYPEFILE: no path to read — pick the + // .lin/.dnl file through the browser, read its + // bytes at pick time and populate the document + // from the text (native reads the typed path + // inside `set_centerline_setting`). + #[cfg(target_arch = "wasm32")] + if name == "CENTERLTYPEFILE" && !value.trim().is_empty() { + return Some(Task::perform( + crate::io::pick_linetype_file_web(), + Message::CenterlineLinetypeFilePicked, + )); + } match self.tabs[i].scene.set_centerline_setting(&name, value) { Ok(message) => { self.tabs[i].dirty = true; diff --git a/src/app/mod.rs b/src/app/mod.rs index f68a06b7f..7bea27546 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2532,6 +2532,19 @@ pub enum Message { LayerTranslatorLoad, /// A target set was read from `path`. LayerTranslatorLoaded(std::path::PathBuf), + /// Web: a standards drawing picked and parsed in the browser, with the + /// target layers already extracted — a name-only path has nothing behind + /// it for `load_targets` to read. `Ok(None)` = cancelled. + #[cfg(target_arch = "wasm32")] + LayerTranslatorLoadedWeb( + Result< + Option<( + String, + Vec, + )>, + String, + >, + ), LayerTranslatorSelectFrom(String), LayerTranslatorSelectTo(String), /// Pair the two selected layers. @@ -2666,6 +2679,14 @@ pub enum Message { /// dispatch, this accepts an interactive verb and all of its arguments on /// the same line (`BOX 0,0,0 10,10,0 10`). ScriptLine(String), + /// Web SCRIPT: the picked .scr file's name and text. `None` = the + /// browser dialog was cancelled. + #[cfg(target_arch = "wasm32")] + ScriptFilePicked(Option<(String, String)>), + /// Web CENTERLTYPEFILE: the picked .lin/.dnl file's name and text. + /// `None` = cancelled. + #[cfg(target_arch = "wasm32")] + CenterlineLinetypeFilePicked(Option<(String, String)>), /// Append one typed character to the command-line input from the /// global key-press subscription. Used when the text-input widget /// itself isn't focused (focus parked on viewport / button / etc.) diff --git a/src/app/update/blocks_palette.rs b/src/app/update/blocks_palette.rs index 44920a950..d128c6707 100644 --- a/src/app/update/blocks_palette.rs +++ b/src/app/update/blocks_palette.rs @@ -51,6 +51,44 @@ impl OpenCADStudio { self.persist_settings_if_changed(); } BlockPaletteMsg::Refresh => self.refresh_block_palette(), + // Web: read the picked file's bytes and parse them right away — + // the picker's name-only path has nothing behind it to read later. + #[cfg(target_arch = "wasm32")] + BlockPaletteMsg::PickFile => { + return Task::perform( + async { + crate::io::pick_and_parse_web( + crate::t!("Select Drawing to Insert as Block").as_ref(), + crate::t!("DWG/DXF Files").as_ref(), + &["dwg", "dxf", "DWG", "DXF"], + ) + .await + .and_then(|picked| picked.ok_or_else(|| "Cancelled".to_string())) + }, + |r| Message::BlockPalette(BlockPaletteMsg::FilePickedDoc(r)), + ); + } + #[cfg(target_arch = "wasm32")] + BlockPaletteMsg::FilePickedDoc(Ok((name, document))) => { + let stem = std::path::Path::new(&name) + .file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + .unwrap_or_else(|| "Block".to_string()); + match self.import_document_block(document, stem, false) { + Ok(name) => { + self.refresh_block_palette(); + return self.start_palette_insert(&name, None); + } + Err(e) => self.command_line.push_error(&e), + } + } + #[cfg(target_arch = "wasm32")] + BlockPaletteMsg::FilePickedDoc(Err(e)) => { + if e != "Cancelled" { + self.command_line.push_error(&e); + } + } + #[cfg(not(target_arch = "wasm32"))] BlockPaletteMsg::PickFile => { return Task::perform( async { diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index c22914b8b..12f4232a1 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -13,6 +13,7 @@ use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip use crate::scene::{ self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, }; +use crate::ui::window::block_palette::BlockPaletteMsg; use crate::ui::PropertiesPanel; use codec::types::Color as AcadColor; use codec::{EntityType as AcadEntityType, Handle}; @@ -395,7 +396,7 @@ impl OpenCADStudio { self.import_document_block(doc, stem, false) } - fn import_document_block( + pub(super) fn import_document_block( &mut self, doc: codec::CadDocument, stem: String, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index ce534fb22..7dcf9ee0f 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -2298,12 +2298,95 @@ impl OpenCADStudio { crate::modules::insert::wblock::extract_block_to_doc(&document, &worker_name) } .map_err(|e| e.to_string())?; - crate::io::save(&document, &worker_path).map_err(|e| e.to_string()) + #[cfg(not(target_arch = "wasm32"))] + { + crate::io::save(&document, &worker_path).map_err(|e| e.to_string()) + } + // Web: no filesystem behind the name-only path — serialize to + // bytes (the same document version `save` would pick, format + // from the chosen file name) and hand the browser a download, + // the way the other web exports do. + #[cfg(target_arch = "wasm32")] + { + let ext = worker_path + .extension() + .map(|ext| ext.to_string_lossy().into_owned()) + .unwrap_or_else(|| "dwg".to_string()); + let bytes = crate::io::save_to_bytes(&document, &ext, document.version)?; + let name = worker_path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "block.dwg".into()); + crate::sys::download_bytes(&name, &bytes); + Ok(()) + } }, move |result| Message::WblockWriteFinished(block_name, path, result), ) } + /// Web SAVEALL: every dirty tab that already has a name-only path is + /// serialized and downloaded in one pass — the same + /// `save_to_bytes` + `download_bytes` route a single Save takes. The + /// browser may ask once for permission to download several files, which + /// is the platform's price. Clean tabs are skipped (re-downloading + /// identical bytes is noise); tabs with no path still need SAVEAS. + #[cfg(target_arch = "wasm32")] + pub(in crate::app) fn saveall_web(&mut self) { + let mut saved = 0usize; + let mut skipped = 0usize; + for t in 0..self.tabs.len() { + if self.tabs[t].is_start { + continue; + } + let Some(path) = self.tabs[t].current_path.clone() else { + skipped += 1; + continue; + }; + if !self.tabs[t].dirty { + continue; + } + // The same preparation a single web Save makes on the tab. + self.sync_view_state_for_save(t); + sync_annotation_scale_header(&mut self.tabs[t].scene); + self.stamp_header_sysvars(t); + self.sync_solid_models_for_save(t); + self.tabs[t].scene.sync_native_parametric_graph(); + let ext = path + .extension() + .map(|ext| ext.to_string_lossy().into_owned()) + .unwrap_or_else(|| "dwg".to_string()); + let version = self.tabs[t].scene.document.version; + let filename = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.to_string_lossy().into_owned()); + match crate::io::save_to_bytes(&self.tabs[t].scene.document, &ext, version) { + Ok(bytes) => { + crate::sys::download_bytes(&filename, &bytes); + self.tabs[t].scene.document.version = version; + self.tabs[t].dirty = false; + self.tabs[t].recovery_save_as_required = false; + saved += 1; + } + Err(error) => self + .command_line + .push_error(crate::tf!("SAVEALL: {} failed: {error}", path.display()).as_ref()), + } + } + self.command_line.push_output( + crate::tf!( + "SAVEALL: saved {saved} drawing(s){}.", + if skipped > 0 { + crate::tf!("; {skipped} need SAVEAS (no file path yet)").into_owned() + } else { + String::new() + } + ) + .as_ref(), + ); + } + pub(super) fn on_stl_export_path_some(&mut self, path: std::path::PathBuf) -> Task { // Re-build STL bytes (we can't easily pass them through the message). let i = self.active_tab; @@ -3538,9 +3621,7 @@ impl OpenCADStudio { /// single derivation every web snapshot/remove site must agree on, or a /// remove misses the snapshot a round wrote (and vice versa). #[cfg(target_arch = "wasm32")] - pub(super) fn web_autosave_key( - tab: &crate::app::document::DocumentTab, - ) -> String { + pub(super) fn web_autosave_key(tab: &crate::app::document::DocumentTab) -> String { tab.current_path .as_ref() .and_then(|path| path.file_name()) @@ -3784,10 +3865,8 @@ impl OpenCADStudio { // survives in OPFS and is re-offered on the next boot. if !crate::io::web_drop::enqueue_restore(open_name, bytes, key) { self.command_line.push_warning( - crate::tf!( - "Autosave copy could not be queued: too many pending opens" - ) - .as_ref(), + crate::tf!("Autosave copy could not be queued: too many pending opens") + .as_ref(), ); self.queue_next_web_autosave_prompt(); } diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 783916ca4..61f262861 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -686,6 +686,23 @@ impl OpenCADStudio { } else { Task::none() }; + // Dropped raster images register their bytes (name-keyed) and + // re-resolve any RasterImage the open drawings reference but + // couldn't load — the web stand-in for the drawing folder the + // images live beside natively. + let mut registered: Vec = Vec::new(); + while let Some((name, bytes)) = crate::io::web_drop::take_dropped_image() { + crate::scene::model::image_model::register_source( + &name, + std::sync::Arc::new(bytes), + ); + registered.push(name); + } + if !registered.is_empty() { + for tab in self.tabs.iter_mut() { + tab.scene.resolve_registered_image_names(®istered); + } + } Task::batch([control, dropped]) } #[cfg(not(target_arch = "wasm32"))] @@ -1115,13 +1132,11 @@ impl OpenCADStudio { // open's id so cancelling an unrelated open leaves a pending // restore marker alone. #[cfg(target_arch = "wasm32")] - if cancelled_id - .is_some_and(|id| { - self.web_autosave_consumed - .as_ref() - .is_some_and(|(_, recorded)| *recorded == id) - }) - && self.web_autosave_consumed.take().is_some() + if cancelled_id.is_some_and(|id| { + self.web_autosave_consumed + .as_ref() + .is_some_and(|(_, recorded)| *recorded == id) + }) && self.web_autosave_consumed.take().is_some() { self.queue_next_web_autosave_prompt(); } @@ -1140,9 +1155,7 @@ impl OpenCADStudio { // warning only (the drop path has no user-facing // surface for rejections by design — see QUEUE_CAP). let _ = match restore_key { - Some(key) => { - crate::io::web_drop::enqueue_restore(name, bytes, key) - } + Some(key) => crate::io::web_drop::enqueue_restore(name, bytes, key), None => crate::io::web_drop::enqueue_open(name, bytes), }; return Task::none(); @@ -1247,7 +1260,20 @@ impl OpenCADStudio { if self.opening.as_ref().map(|opening| opening.id) != Some(open_id) { return Task::none(); } - self.on_file_opened(name, path, doc, caches) + let task = self.on_file_opened(name, path, doc, caches); + // Web: raster files dropped while this open was in flight + // registered after its caches were built — re-resolve any + // image the (possibly new) drawing references by name. + #[cfg(target_arch = "wasm32")] + { + let names = crate::scene::model::image_model::registered_image_names(); + if !names.is_empty() { + for tab in self.tabs.iter_mut() { + tab.scene.resolve_registered_image_names(&names); + } + } + } + task } Message::FileOpened(open_id, Err(e)) => { @@ -2810,6 +2836,39 @@ impl OpenCADStudio { Message::ScriptLine(line) => self.feed_script_line(&line), + // Web SCRIPT: the picked .scr file arrived — run it through the + // same runner the native path feeds. + #[cfg(target_arch = "wasm32")] + Message::ScriptFilePicked(Some((name, text))) => self.run_script(&name, &text), + #[cfg(target_arch = "wasm32")] + Message::ScriptFilePicked(None) => Task::none(), + + // Web CENTERLTYPEFILE: the picked .lin/.dnl file's text arrived — + // load its linetypes, then record the setting under the file's + // name (the native command reads the typed path inside + // `set_centerline_setting` instead). + #[cfg(target_arch = "wasm32")] + Message::CenterlineLinetypeFilePicked(Some((name, source))) => { + let i = self.active_tab; + crate::io::linetypes::populate_document_from_source( + &mut self.tabs[i].scene.document, + &source, + ); + match self.tabs[i] + .scene + .set_centerline_setting("CENTERLTYPEFILE", &name) + { + Ok(message) => { + self.tabs[i].dirty = true; + self.command_line.push_output(&message); + } + Err(error) => self.command_line.push_error(&error), + } + Task::none() + } + #[cfg(target_arch = "wasm32")] + Message::CenterlineLinetypeFilePicked(None) => Task::none(), + Message::ToggleLayers => { if self.show_layers { return self.on_dock(crate::ui::dock::DockMsg::Close( @@ -3113,10 +3172,61 @@ impl OpenCADStudio { } // ── Layer Translator (#624) ────────────────────────────────── Message::LayerTranslatorLoad => { - Task::perform(crate::io::pick_layer_standard_path(), |path| match path { - Some(path) => Message::LayerTranslatorLoaded(path), - None => Message::Noop, - }) + #[cfg(not(target_arch = "wasm32"))] + { + Task::perform(crate::io::pick_layer_standard_path(), |path| match path { + Some(path) => Message::LayerTranslatorLoaded(path), + None => Message::Noop, + }) + } + // Web: read the picked file's bytes and parse them on the + // worker right away — a name-only path has nothing behind it + // for `load_targets` to read later. + #[cfg(target_arch = "wasm32")] + { + Task::perform( + async { + crate::io::pick_and_parse_web( + crate::t!("Load layer standard").as_ref(), + crate::t!("Drawings and standards").as_ref(), + &["dwg", "dws", "dwt", "dxf", "DWG", "DWS", "DWT", "DXF"], + ) + .await + .and_then(|picked| { + picked.map_or(Ok(None), |(name, document)| { + crate::modules::draw::layers::laytrans::targets_from_document( + &document, + ) + .map(|targets| Some((name, targets))) + }) + }) + }, + |loaded| Message::LayerTranslatorLoadedWeb(loaded), + ) + } + } + #[cfg(target_arch = "wasm32")] + Message::LayerTranslatorLoadedWeb(loaded) => { + match loaded { + Ok(Some((source_file, targets))) => { + let state = self.layer_translator.get_or_insert_with(Default::default); + state.source_file = source_file; + state.targets = targets; + // A target set that no longer contains a mapped name + // would translate onto nothing. + let names: Vec = + state.targets.iter().map(|t| t.name.clone()).collect(); + state + .mappings + .retain(|m| names.iter().any(|n| n.eq_ignore_ascii_case(&m.to))); + state.selected_to = None; + } + Err(why) => self + .command_line + .push_error(crate::tf!("LAYTRANS: {why}.").as_ref()), + Ok(None) => {} + } + Task::none() } Message::LayerTranslatorLoaded(path) => { use crate::modules::draw::layers::laytrans; @@ -10162,6 +10272,7 @@ impl OpenCADStudio { .unwrap_or_else(|| "drawing".into()); // The save dialog follows the dialog's file device, so the // SVG choice proposes `stem.svg` up front. + #[cfg(not(target_arch = "wasm32"))] let svg = self.plot_dialog.file_svg; #[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] { @@ -10199,6 +10310,7 @@ impl OpenCADStudio { .and_then(|p: &std::path::Path| p.file_stem()) .map(|s: &std::ffi::OsStr| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "drawing".into()); + #[cfg(not(target_arch = "wasm32"))] let svg = self.plot_dialog.file_svg; #[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] { diff --git a/src/io/mod.rs b/src/io/mod.rs index 3365462e6..f70276f2f 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -232,6 +232,42 @@ pub async fn pick_layer_mapping_path(save: bool) -> Option { Some(crate::sys::handle_path(&handle)) } +/// Web SCRIPT: pick the .scr file through the browser and read its text — +/// the byte-level stand-in for the typed path the native command reads with +/// `std::fs::read_to_string`. `None` means the user cancelled. +#[cfg(target_arch = "wasm32")] +pub async fn pick_script_web() -> Option<(String, String)> { + let Some(handle) = crate::sys::file_dialog() + .set_title(crate::t!("SCRIPT path to the .scr file:").as_ref()) + .add_filter(crate::t!("All Files").as_ref(), &["*"]) + .pick_file() + .await + else { + return None; + }; + let name = handle.file_name(); + let bytes = handle.read().await; + Some((name, String::from_utf8_lossy(&bytes).into_owned())) +} + +/// Web CENTERLTYPEFILE: pick the linetype (.lin/.dnl) file through the +/// browser and read its text — the stand-in for the typed path the native +/// command reads inside `set_centerline_setting`. `None` = cancelled. +#[cfg(target_arch = "wasm32")] +pub async fn pick_linetype_file_web() -> Option<(String, String)> { + let Some(handle) = crate::sys::file_dialog() + .set_title(crate::t!("Load linetype file").as_ref()) + .add_filter(crate::t!("All Files").as_ref(), &["*"]) + .pick_file() + .await + else { + return None; + }; + let name = handle.file_name(); + let bytes = handle.read().await; + Some((name, String::from_utf8_lossy(&bytes).into_owned())) +} + /// Load a CAD file from a known path. Parsing and cache building run on a /// dedicated OS thread so the async executor stays free for rendering during /// the load. Writes phase markers into `phase` so the UI can show @@ -734,6 +770,50 @@ async fn load_web_bytes( Ok((name.to_string(), path, doc, caches)) } +/// Web: parse a *second* drawing (a block to insert, a layer-standards +/// file) from bytes on the parse worker, with the same post-parse fixups +/// `load_web_bytes` applies — the byte-level counterpart of `load_file`. +#[cfg(target_arch = "wasm32")] +async fn parse_web_document(name: &str, bytes: &[u8]) -> Result { + let (outcome, _) = web_worker::parse_document(name, bytes, false, "") + .await + .map_err(|error| error.message)?; + let mut doc = outcome.document; + normalize_block_origins(&mut doc); + if name.to_ascii_lowercase().ends_with(".dxf") { + fix_dxf_dimension_rotations(&mut doc); + fix_dxf_layout_plot_settings(&mut doc); + } + fix_viewport_status_flags(&mut doc); + fix_current_style_names(&mut doc); + Ok(doc) +} + +/// Web: show the browser picker, read the chosen file's bytes, and parse it +/// as a drawing on the worker — `pick_and_load_web`'s pick-read-parse core +/// for features that consume a second drawing instead of opening a tab. +/// `Ok(None)` means the user cancelled. +#[cfg(target_arch = "wasm32")] +pub async fn pick_and_parse_web( + title: &str, + filter_label: &str, + extensions: &[&str], +) -> Result, String> { + let Some(handle) = crate::sys::file_dialog() + .set_title(title) + .add_filter(filter_label, extensions) + .add_filter(crate::t!("All Files").as_ref(), &["*"]) + .pick_file() + .await + else { + return Ok(None); + }; + let name = handle.file_name(); + let bytes = handle.read().await; + let document = parse_web_document(&name, &bytes).await?; + Ok(Some((name, document))) +} + /// Parse a drawing from bytes using its name or recovery-file signature. #[cfg(not(target_arch = "wasm32"))] pub fn load_bytes(name: &str, bytes: Vec) -> Result { @@ -1766,29 +1846,51 @@ pub async fn pick_plot_style() -> Result, Str let Some(handle) = dialog.pick_file().await else { return Ok(None); }; - let path = crate::sys::handle_path(&handle); - let table = plot_style::PlotStyleTable::load(&path)?; #[cfg(not(target_arch = "wasm32"))] - if let (Ok(dir), Some(file_name)) = (plot_style::ensure_plot_styles_dir(), path.file_name()) { - let destination = dir.join(file_name); - let same_place = path.parent().is_some_and(|parent| { - parent - .canonicalize() - .ok() - .zip(dir.canonicalize().ok()) - .is_some_and(|(a, b)| a == b) - }); - if !same_place && !destination.exists() { - let _ = std::fs::copy(&path, &destination); + { + let path = crate::sys::handle_path(&handle); + let table = plot_style::PlotStyleTable::load(&path)?; + if let (Ok(dir), Some(file_name)) = + (plot_style::ensure_plot_styles_dir(), path.file_name()) + { + let destination = dir.join(file_name); + let same_place = path.parent().is_some_and(|parent| { + parent + .canonicalize() + .ok() + .zip(dir.canonicalize().ok()) + .is_some_and(|(a, b)| a == b) + }); + if !same_place && !destination.exists() { + let _ = std::fs::copy(&path, &destination); + } } + Ok(Some(table)) + } + // Web: read the picked file's bytes now — the name-only path has nothing + // behind it — parse them, and remember them in the session registry so + // `load_named` resolves the name afterwards. There is no plot-styles + // folder on the web, so registration lasts for the session. + #[cfg(target_arch = "wasm32")] + { + let name = handle.file_name(); + let bytes: std::sync::Arc> = std::sync::Arc::new(handle.read().await); + let table = plot_style::PlotStyleTable::from_bytes(&name, &bytes)?; + plot_style::register_session_table(&name, bytes); + Ok(Some(table)) } - Ok(Some(table)) } // ── Image file picker ───────────────────────────────────────────────────── /// Show a file-open dialog for raster images and decode the selected file. /// Returns `(path, pixel_width, pixel_height)` or an error string. +/// +/// The bytes are read through the dialog handle and registered against the +/// returned path (name-only on the web — there is no filesystem behind it), +/// mirroring the PDF-underlay pick. Decoding then comes from those bytes, and +/// the entity the IMAGE command commits resolves through the same registry +/// instead of a `image::open` on a path that only exists natively. pub async fn pick_image_file() -> Result<(PathBuf, u32, u32), String> { let handle = crate::sys::file_dialog() .set_title(crate::t!("Select Image File").as_ref()) @@ -1803,9 +1905,16 @@ pub async fn pick_image_file() -> Result<(PathBuf, u32, u32), String> { .await .ok_or_else(|| "Cancelled".to_string())?; let path = crate::sys::handle_path(&handle); - let img = image::open(&path).map_err(|e| e.to_string())?; - let (w, h) = image::GenericImageView::dimensions(&img); - Ok((path, w, h)) + let bytes = std::sync::Arc::new(handle.read().await); + let reference = path.to_string_lossy().into_owned(); + let short = std::path::Path::new(&reference) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| reference.clone()); + crate::scene::model::image_model::register_source(&reference, bytes); + let decoded = crate::scene::model::image_model::resolve_image(&reference) + .ok_or_else(|| format!("Unsupported or corrupt image \"{short}\""))?; + Ok((path, decoded.width, decoded.height)) } /// Pick an image file and prepare it for embedding (IMAGEEMBED). The bytes diff --git a/src/io/plot_style.rs b/src/io/plot_style.rs index 13b1dc680..a48e74eef 100644 --- a/src/io/plot_style.rs +++ b/src/io/plot_style.rs @@ -11,6 +11,7 @@ use rustc_hash::FxHashMap as HashMap; use std::io::Read; use std::path::{Component, Path}; +use std::sync::{Arc, Mutex, OnceLock}; #[cfg(not(target_arch = "wasm32"))] use std::path::PathBuf; @@ -87,6 +88,9 @@ pub fn available_ctb_names() -> Vec { }) .collect(); names.extend(STANDARD_PLOT_STYLES.iter().map(|name| (*name).to_string())); + // Empty on native (nothing registers there); on the web this is + // where the tables picked this session surface. + names.extend(session_ctb_names()); names.sort_by_key(|name| name.to_ascii_lowercase()); names.dedup_by(|left, right| left.eq_ignore_ascii_case(right)); names @@ -95,10 +99,62 @@ pub fn available_ctb_names() -> Vec { { let mut names = vec![DEFAULT_PLOT_STYLE.into(), MONOCHROME_PLOT_STYLE.into()]; names.extend(STANDARD_PLOT_STYLES.iter().map(|name| (*name).to_string())); + // No folder to scan — the tables picked this session stand in. + names.extend(session_ctb_names()); + names.sort_by_key(|name| name.to_ascii_lowercase()); + names.dedup_by(|left, right| left.eq_ignore_ascii_case(right)); names } } +// ── Web session registry ──────────────────────────────────────────────────── + +/// Plot tables picked this session, keyed by file name. The browser keeps +/// no folder behind a picked name, so the web build remembers the bytes +/// instead and `load_named` resolves through here before falling back to +/// the built-ins — the plot-style counterpart of `pdf_raster::register_source`. +/// Native never writes it, so the lookup is always empty there and the +/// plot-styles folder stays authoritative. +fn session_tables() -> &'static Mutex>>> { + static TABLES: OnceLock>>>> = OnceLock::new(); + TABLES.get_or_init(|| Mutex::new(HashMap::default())) +} + +/// Remember a picked table's bytes so later `load_named` calls — the Plot +/// dialog's table list and APPLY, plot settings, display plot style — +/// resolve its name. Web only; on native, permanently registering a picked +/// table copies it into the plot-styles folder instead. +pub fn register_session_table(name: &str, bytes: Arc>) { + session_tables() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(name.to_string(), bytes); +} + +/// The bytes registered under `name` this session, matched +/// case-insensitively the way the folder scan matches file names. +fn session_table(name: &str) -> Option>> { + session_tables() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .iter() + .find(|(registered, _)| registered.eq_ignore_ascii_case(name)) + .map(|(_, bytes)| Arc::clone(bytes)) +} + +/// The colour-dependent (.ctb) names picked this session, for the Plot +/// dialog's table list. Named (.stb) tables are left out: the plotter is +/// color-dependent, so they cannot be applied anyway. +fn session_ctb_names() -> Vec { + session_tables() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .keys() + .filter(|name| name.to_ascii_lowercase().ends_with(".ctb")) + .cloned() + .collect() +} + // ── Standard lineweight table (index → mm) ─────────────────────────────────── /// Lineweight table: index value → mm, matching the stored LWEIGHT codes. @@ -289,6 +345,13 @@ impl PlotStyleTable { return Err(format!("Invalid plot style name: {name}")); } + // A table picked this session resolves through the registry first. + // Empty on native, so the folder search below stays authoritative + // there; on the web this is the only place a picked table lives. + if let Some(bytes) = session_table(name) { + return Self::from_bytes(name, &bytes); + } + #[cfg(not(target_arch = "wasm32"))] { let dir = ensure_plot_styles_dir()?; @@ -910,6 +973,80 @@ mod round_trip_tests { PlotStyleTable::from_bytes(table.name.clone(), &bytes).expect("reload") } + /// Real .stb files use the same container with an `STBVER1` tag; + /// they must decompress instead of dying in the deflate fallback. + #[test] + fn stb_container_headers_parse() { + let mut bytes = + compress_ctb(PlotStyleTable::identity("x").to_text().as_bytes()).expect("compress"); + // "PIAFILEVERSION_2.0," is 19 bytes, so 19..22 holds the "CTB" tag. + bytes[19..22].copy_from_slice(b"STB"); + let table = PlotStyleTable::from_bytes("mystyles.stb", &bytes).expect("stb parses"); + assert!(table.is_stb); + } + + /// Named tables are refused with the actual limitation, not a generic + /// invalid-name error (and without touching the file system). + #[test] + fn load_named_rejects_stb_with_reason() { + let error = PlotStyleTable::load_named("mystyles.stb").unwrap_err(); + assert!(error.contains("stb"), "{error}"); + assert!(error.contains("color-dependent"), "{error}"); + } + + /// A table registered this session (the web stand-in for the plot-styles + /// folder) is what `load_named` returns for its name — matched + /// case-insensitively, the way the folder scan matches file names. + #[test] + fn load_named_resolves_a_session_registered_table() { + let mut table = PlotStyleTable::identity("SessionPens.ctb"); + table.aci_entries[1].lineweight = 13; // 0.50 mm + let bytes = compress_ctb(table.to_text().as_bytes()).expect("compress"); + super::register_session_table("SessionPens.ctb", std::sync::Arc::new(bytes)); + + let loaded = PlotStyleTable::load_named("sessionpens.CTB").expect("registry hit"); + assert_eq!(loaded.name, "sessionpens.CTB"); + assert_eq!(loaded.resolve_lineweight(1), Some(0.50)); + + // A name nothing was registered under still falls back to the built-ins. + assert!(PlotStyleTable::load_named(super::MONOCHROME_PLOT_STYLE).is_ok()); + } + + /// The STB refusal comes before the registry lookup: even a registered + /// named table cannot be applied to a color-dependent plot. + #[test] + fn stb_refusal_precedes_the_session_registry() { + let bytes = + compress_ctb(PlotStyleTable::identity("x").to_text().as_bytes()).expect("compress"); + super::register_session_table("Refused.stb", std::sync::Arc::new(bytes)); + let error = PlotStyleTable::load_named("Refused.stb").unwrap_err(); + assert!(error.contains("color-dependent"), "{error}"); + } + + /// The Plot dialog's table list includes the session-registered colour + /// tables, but never a named (.stb) one. + #[test] + fn available_names_include_session_registered_tables() { + let bytes = + compress_ctb(PlotStyleTable::identity("x").to_text().as_bytes()).expect("compress"); + let bytes = std::sync::Arc::new(bytes); + super::register_session_table("DialogPens.ctb", std::sync::Arc::clone(&bytes)); + super::register_session_table("Named.stb", bytes); + let names = super::available_ctb_names(); + assert!( + names + .iter() + .any(|name| name.eq_ignore_ascii_case("DialogPens.ctb")), + "{names:?}" + ); + assert!( + !names + .iter() + .any(|name| name.eq_ignore_ascii_case("Named.stb")), + "{names:?}" + ); + } + /// Saving a table keeps its global scale factor, which scales every /// lineweight while apply_factor is on. It used to be cut to one decimal. #[test] diff --git a/src/io/web_drop.rs b/src/io/web_drop.rs index 8f5ec248c..78ea01079 100644 --- a/src/io/web_drop.rs +++ b/src/io/web_drop.rs @@ -25,6 +25,9 @@ pub struct QueuedOpen { } static DROPPED: Mutex> = Mutex::new(VecDeque::new()); +// Dropped raster images never open as drawings: they are registered by name +// so RasterImage entities the open drawing references can resolve them. +static DROPPED_IMAGES: Mutex)>> = Mutex::new(VecDeque::new()); // `OnceLock::take` needs `&mut self`, which an immutable static cannot // provide — an AtomicBool swap gives the same set-once marker and can still // be reverted when no document exists. @@ -69,6 +72,32 @@ pub fn enqueue_open(name: String, bytes: Vec) -> bool { }) } +/// Queue a dropped raster image for the poll to register as decodable bytes +/// (name-keyed) — the web analogue of the drawing folder a drawing's images +/// live beside natively. Returns `false` when the queue is full. +pub fn enqueue_image(name: String, bytes: Vec) -> bool { + let mut queue = DROPPED_IMAGES + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if queue.len() >= QUEUE_CAP { + drop(queue); + warn(&format!( + "ocs: dropped-image queue full; ignoring \"{name}\"" + )); + return false; + } + queue.push_back((name, bytes)); + true +} + +/// Pop the oldest dropped image the poll has not registered yet. +pub fn take_dropped_image() -> Option<(String, Vec)> { + DROPPED_IMAGES + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .pop_front() +} + /// Queue an autosave-restore file tagged with its snapshot key. Returns /// `false` when the queue is full and the bytes were dropped (the OPFS /// snapshot survives; the caller must not wait for an open that will @@ -162,22 +191,26 @@ pub fn install() { // reads that begin after the event returns, and parallel reads // would enqueue (and open) in completion order, not drop order. let mut reads: Vec<(String, js_sys::Promise)> = Vec::new(); + let mut image_reads: Vec<(String, js_sys::Promise)> = Vec::new(); for index in 0..files.length() { let Some(file) = files.get(index) else { continue; }; let name = file.name(); // A drop has no picker filter: accept the formats the web Open - // dialog accepts; anything else is logged, never parsed. - if !is_drawing_name(&name) { + // dialog accepts, plus the raster formats the IMAGE command + // accepts; anything else is logged, never parsed. + if is_drawing_name(&name) { + reads.push((name, file.array_buffer())); + } else if is_image_name(&name) { + image_reads.push((name, file.array_buffer())); + } else { warn(&format!( - "ocs: ignored dropped file \"{name}\" (not .dwg/.dxf)" + "ocs: ignored dropped file \"{name}\" (not .dwg/.dxf/image)" )); - continue; } - reads.push((name, file.array_buffer())); } - if reads.is_empty() { + if reads.is_empty() && image_reads.is_empty() { return; } // The page is the only intended drop target; hide drawing drops @@ -189,8 +222,7 @@ pub fn install() { Ok(buffer) => { // A full queue warns on the console; the drop // handler has no command line to talk to. - let _ = - enqueue_open(name, js_sys::Uint8Array::new(&buffer).to_vec()); + let _ = enqueue_open(name, js_sys::Uint8Array::new(&buffer).to_vec()); } Err(error) => { web_sys::console::error_1(&wasm_bindgen::JsValue::from_str(&format!( @@ -200,6 +232,19 @@ pub fn install() { } } } + for (name, read) in image_reads { + match JsFuture::from(read).await { + Ok(buffer) => { + let _ = enqueue_image(name, js_sys::Uint8Array::new(&buffer).to_vec()); + } + Err(error) => { + web_sys::console::error_1(&wasm_bindgen::JsValue::from_str(&format!( + "ocs: failed to read dropped image \"{name}\": {}", + error.as_string().unwrap_or_else(|| format!("{error:?}")) + ))) + } + } + } }); }, ); @@ -218,7 +263,25 @@ fn is_drawing_name(name: &str) -> bool { // a dot-less name is its own extension, so a file literally named // "DWG"/"dxf" parses and must not be filtered out here. matches!( - name.rsplit('.').next().unwrap_or_default().to_ascii_lowercase().as_str(), + name.rsplit('.') + .next() + .unwrap_or_default() + .to_ascii_lowercase() + .as_str(), "dwg" | "dxf" ) } + +/// The decodable raster set matches the IMAGE picker's filter (and the `image` +/// crate features the app builds with) — anything else would queue bytes that +/// can only ever fail to decode. +fn is_image_name(name: &str) -> bool { + matches!( + name.rsplit('.') + .next() + .unwrap_or_default() + .to_ascii_lowercase() + .as_str(), + "png" | "jpg" | "jpeg" | "bmp" | "tiff" | "tif" + ) +} diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index fec5e1025..9829ae2ec 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -2715,6 +2715,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "SCALETEXT: scaled {count} text entity(ies)." => Some(("styles", "scaletext-scaled-count-text-entity-ies")), "SCALETEXT: select Text/MText entities first." => Some(("styles", "scaletext-select-text-mtext-entities-first")), "SCALETEXT: value must be positive." => Some(("styles", "scaletext-value-must-be-positive")), + "Load linetype file" => Some(("file", "load-linetype-file")), "SCRIPT path to the .scr file:" => Some(("file", "script-path-to-the-scr-file")), "SCRIPT: cannot read {p}: {e}" => Some(("file", "script-cannot-read-p-e")), "SCRIPT: running {} command(s) from {p}." => Some(("file", "script-running-command-s-from-p")), diff --git a/src/modules/draw/layers/laytrans.rs b/src/modules/draw/layers/laytrans.rs index 2bc169cd8..f7c3ab1e9 100644 --- a/src/modules/draw/layers/laytrans.rs +++ b/src/modules/draw/layers/laytrans.rs @@ -72,6 +72,13 @@ impl Report { /// drawing. They are all the same format, so nothing here cares which. pub fn load_targets(path: &Path) -> Result, String> { let document = crate::io::load_file(path)?; + targets_from_document(&document) +} + +/// Extract the translation targets from an already-parsed drawing — the +/// tail of [`load_targets`], reused by the web flow, which parses the +/// picked file's bytes before it gets here. +pub fn targets_from_document(document: &codec::CadDocument) -> Result, String> { let mut targets: Vec = document .layers .iter() diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 5cfe113a9..6d49abf79 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -2468,6 +2468,29 @@ impl Scene { self.bump_geometry(); } + /// Web drop flow: `names` are raster files whose bytes were just + /// registered (name-keyed). When this drawing references one of them by + /// name but carries no model for it — the reference pointed at the + /// author's machine and was unresolvable at open time — rebuild the image + /// models so the entity finally renders. Returns whether a rebuild ran. + pub fn resolve_registered_image_names(&mut self, names: &[String]) -> bool { + let matches_name = |img: &codec::entities::RasterImage| { + names + .iter() + .any(|name| crate::scene::model::image_model::same_file_name(&img.file_path, name)) + }; + let unresolved = self.document.entities().any(|entity| match entity { + EntityType::RasterImage(img) => { + matches_name(img) && !self.images.contains_key(&img.common.handle) + } + _ => false, + }); + if unresolved { + self.populate_images_from_document(); + } + unresolved + } + fn populate_images_from_document_unbumped(&mut self) { self.images.clear(); let entries: Vec<(Handle, codec::entities::EntityType)> = self diff --git a/src/scene/model/image_model.rs b/src/scene/model/image_model.rs index 228d58f80..e970cf380 100644 --- a/src/scene/model/image_model.rs +++ b/src/scene/model/image_model.rs @@ -4,6 +4,7 @@ // from the RasterImage entity's insertion point, u/v vectors, and pixel size. use std::collections::HashMap; +#[cfg(not(target_arch = "wasm32"))] use std::path::Path; use std::sync::{Arc, Mutex, OnceLock}; @@ -496,6 +497,82 @@ fn image_cache() -> &'static Mutex> { CACHE.get_or_init(|| Mutex::new(HashMap::new())) } +fn source_cache() -> &'static Mutex>>> { + static SOURCES: OnceLock>>>> = OnceLock::new(); + SOURCES.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Lower-cased trailing component of a path-style reference. Drawings store +/// image paths as the author wrote them (often an absolute foreign path), so +/// name — not full path — is the only component a dropped or picked file on +/// another machine can be expected to match. +pub(crate) fn file_name_key(path: &str) -> String { + path.rsplit(['/', '\\']) + .next() + .unwrap_or_default() + .to_ascii_lowercase() +} + +/// Do two image references name the same file? Compares trailing components +/// case-insensitively (the web has no path beyond the name at all, and Windows +/// paths are case-insensitive where the drawing was authored). +pub(crate) fn same_file_name(a: &str, b: &str) -> bool { + let (a, b) = (file_name_key(a), file_name_key(b)); + !a.is_empty() && a == b +} + +/// Keep bytes selected through a file dialog or dropped onto the page, keyed +/// by the reference the drawing will carry. The browser exposes no reusable +/// filesystem path, so `decode_reference` reads these instead of a disk file; +/// native builds also skip a second disk read. Registering invalidates every +/// memoised answer that names the same file, so a re-pick (or a drop that +/// 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); + if let Ok(mut cache) = image_cache().lock() { + cache.retain(|cached, _| !same_file_name(cached, path)); + } +} + +/// Every reference with bytes currently registered (picked or dropped). +/// Used after a web open settles to catch images registered while the load — +/// which builds the raster models — was already running. +pub fn registered_image_names() -> Vec { + source_cache() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .keys() + .cloned() + .collect() +} + +/// Bytes registered for exactly this reference. +fn registered_bytes(path: &str) -> Option>> { + source_cache() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .get(path) + .cloned() +} + +/// Bytes registered under a different path that names the same file — the +/// web fallback for images inside opened drawings, whose stored path is the +/// original author's (a foreign absolute path no browser can open) while the +/// user dropped or picked only the bare file. +fn registered_bytes_by_name(path: &str) -> Option>> { + let cache = source_cache() + .lock() + .unwrap_or_else(|error| error.into_inner()); + cache + .iter() + .find(|(key, _)| same_file_name(key, path)) + .map(|(_, bytes)| Arc::clone(bytes)) +} + /// Drop every memoised image so the next resolve re-reads / re-fetches. Called /// when a new document is opened — a fresh drawing shouldn't inherit a prior /// one's stale successes or offline failures. @@ -529,15 +606,42 @@ pub fn resolve_image(path: &str) -> Option { entry.get_or_init(|| decode_reference(path)).clone() } -/// Decode a reference (local path or remote URL) to RGBA pixels. +/// Decode a reference to RGBA pixels. Priority: registered bytes (picked or +/// dropped — the only source on the web, where the reference is a name-only +/// path), then the filesystem (native), then any registered file that merely +/// shares the name (a drawing's foreign absolute path vs a dropped file). +/// `http`/`https` references stay a native-only network fetch. fn decode_reference(path: &str) -> Option { let lower = path.to_ascii_lowercase(); - let img = if lower.starts_with("http://") || lower.starts_with("https://") { + if lower.starts_with("http://") || lower.starts_with("https://") { let bytes = fetch_remote(path)?; - image::load_from_memory(&bytes).ok()? - } else { - image::open(Path::new(path)).ok()? - }; + return decode_from_memory(&bytes); + } + if let Some(bytes) = registered_bytes(path) { + return decode_from_memory(&bytes); + } + #[cfg(not(target_arch = "wasm32"))] + if let Some(decoded) = decode_from_file(path) { + return Some(decoded); + } + registered_bytes_by_name(path) + .as_deref() + .and_then(|bytes| decode_from_memory(bytes)) +} + +/// Decode in-memory image bytes (picked file, drop, or network body) to RGBA. +fn decode_from_memory(bytes: &[u8]) -> Option { + let img = image::load_from_memory(bytes).ok()?; + rgba_image(img) +} + +#[cfg(not(target_arch = "wasm32"))] +fn decode_from_file(path: &str) -> Option { + let img = image::open(Path::new(path)).ok()?; + rgba_image(img) +} + +fn rgba_image(img: image::DynamicImage) -> Option { let rgba = img.into_rgba8(); let (width, height) = rgba.dimensions(); Some(DecodedImage { @@ -575,3 +679,98 @@ fn fetch_remote(url: &str) -> Option> { fn fetch_remote(_url: &str) -> Option> { None } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use super::*; + + /// Encode a small deterministic PNG entirely in memory — the same codec + /// `decode_from_memory` reads, so no fixture file is needed. + fn png_bytes(w: u32, h: u32, tint: u8) -> Vec { + let img = + image::RgbaImage::from_fn(w, h, |x, y| image::Rgba([x as u8, y as u8, tint, 0xff])); + let mut out = Vec::new(); + image::DynamicImage::ImageRgba8(img) + .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png) + .expect("encode test png"); + out + } + + /// Registered bytes decode without any filesystem path — the web attach + /// flow, where the reference is the picked file's name and nothing else. + #[test] + fn registered_bytes_decode_without_a_filesystem_path() { + let path = "memory://image-registry-exact.png"; + register_source(path, Arc::new(png_bytes(7, 5, 0x42))); + let decoded = resolve_image(path).expect("registered bytes should decode"); + + assert_eq!((decoded.width, decoded.height), (7, 5)); + assert_eq!(decoded.pixels.len(), 7 * 5 * 4); + // First texel is (x=0, y=0) — row 0 counted from the top in the PNG. + assert_eq!(&decoded.pixels[0..3], &[0, 0, 0x42]); + } + + /// A re-registration replaces the bytes AND the memoised decode: picking + /// the same name again must show the new pixels, not the cached old ones. + #[test] + fn reregistering_replaces_the_memoised_decode() { + let path = "memory://image-registry-repick.png"; + register_source(path, Arc::new(png_bytes(3, 3, 0x11))); + assert_eq!(resolve_image(path).expect("first decode").width, 3); + + register_source(path, Arc::new(png_bytes(9, 4, 0x22))); + let decoded = resolve_image(path).expect("second decode"); + assert_eq!((decoded.width, decoded.height), (9, 4)); + assert_eq!(&decoded.pixels[0..3], &[0, 0, 0x22]); + } + + /// The opened-drawing flow: the entity stores the author's absolute path, + /// which exists on no machine but this one's — a registered file that + /// merely shares the (case-insensitive) name resolves it. + #[test] + fn a_name_only_match_resolves_a_foreign_path() { + register_source("Registry-Name.png", Arc::new(png_bytes(4, 2, 0x33))); + let foreign = if cfg!(windows) { + "C:\\far away\\registry-name.PNG" + } else { + "/mnt/far away/registry-name.PNG" + }; + + let decoded = resolve_image(foreign).expect("name match should decode"); + assert_eq!((decoded.width, decoded.height), (4, 2)); + assert_eq!(decoded.pixels.len(), 4 * 2 * 4); + } + + /// A cached failure must not outlive the registration that fixes it: the + /// drop flow registers bytes after the open already memoised `None`. + #[test] + fn registering_after_a_failed_resolve_retries_the_decode() { + let path = "memory://image-registry-late.png"; + let foreign = format!("/definitely/not/here/{path}"); + assert!(resolve_image(&foreign).is_none(), "nothing registered yet"); + + register_source(path, Arc::new(png_bytes(2, 2, 0x44))); + let decoded = resolve_image(&foreign).expect("late registration should resolve"); + assert_eq!((decoded.width, decoded.height), (2, 2)); + } + + /// A reference nothing can satisfy stays `None` (and stays memoised as + /// such) — the placeholder keeps its broken-reference treatment. + #[test] + fn an_unmatchable_reference_stays_none() { + assert!(resolve_image("memory://image-registry-missing.png").is_none()); + assert!(resolve_image("").is_none()); + assert!(resolve_image(" ").is_none()); + } + + /// The name comparison keys on the trailing component only, ignoring + /// case and separators, and rejects empty names. + #[test] + fn file_name_comparison_rules() { + assert!(same_file_name("C:\\A\\B\\site.png", "site.png")); + assert!(same_file_name("/a/b/SITE.PNG", "site.PNG")); + assert!(!same_file_name("site.png", "site.jpg")); + assert!(!same_file_name("", "")); + assert!(!same_file_name("C:\\dir\\", "dir")); + } +} diff --git a/src/scene/pipeline/viewcube_text_atlas.rs b/src/scene/pipeline/viewcube_text_atlas.rs index 4314d3cab..fba66aa17 100644 --- a/src/scene/pipeline/viewcube_text_atlas.rs +++ b/src/scene/pipeline/viewcube_text_atlas.rs @@ -176,7 +176,9 @@ fn font_system( Some(( FontSystem::new_with_locale_and_db("en-US".to_owned(), database), Some(script.family()), - Weight::NORMAL, + // Match the native request; fontdb picks the nearest available weight + // (the subsets ship Regular) and `embolden_alpha` adds the thickness. + Weight::SEMIBOLD, )) } diff --git a/src/scene/text/sysfont.rs b/src/scene/text/sysfont.rs index 8fd95f425..60dfe96d4 100644 --- a/src/scene/text/sysfont.rs +++ b/src/scene/text/sysfont.rs @@ -5,14 +5,21 @@ // picker and (b) borrow a face's raw bytes to extract glyph outlines (see the // TTF glyph engine). LFF stroke fonts stay separate — this is purely the // TrueType side of the renderer. +// +// The web build has no installed fonts to scan (`load_system_fonts` finds +// nothing on wasm), so the database is instead populated at runtime with the +// Noto subsets `web_font` fetches (#141): each completed fetch is registered +// via [`register_web_font`], and a static alias table maps the common +// office/CAD families (Arial, Cambria, …) onto that Noto set so styles naming +// them resolve to filled TTF glyphs instead of falling back to LFF strokes. use std::collections::HashMap; use std::sync::{Mutex, OnceLock}; struct SysFonts { - db: fontdb::Database, - /// Sorted, de-duplicated family names for the picker. - families: Vec, + db: Mutex, + /// Sorted, de-duplicated family names for the picker and name resolution. + families: Mutex>, /// Installed face file name (lower-case) → the family that face holds. /// A drawing references a TrueType style by *file* name, so this is what /// makes such a style resolve — see [`family_for_reference`]. @@ -23,16 +30,22 @@ static FONTS: OnceLock = OnceLock::new(); fn fonts() -> &'static SysFonts { FONTS.get_or_init(|| { - let mut db = fontdb::Database::new(); - db.load_system_fonts(); + // No system fonts to scan on the web — the database is filled from the + // fetched Noto subsets instead (see `register_web_font`). + #[cfg(not(target_arch = "wasm32"))] + let db = { + let mut db = fontdb::Database::new(); + db.load_system_fonts(); + db + }; + #[cfg(target_arch = "wasm32")] + let db = fontdb::Database::new(); - let mut families: Vec = Vec::new(); let mut by_file: HashMap = HashMap::new(); for face in db.faces() { let Some((family, _)) = face.families.first() else { continue; }; - families.push(family.clone()); // Remember every face file: a DWG style names the file, and the // family inside it is usually a different string. if let fontdb::Source::File(path) = &face.source { @@ -43,37 +56,145 @@ fn fonts() -> &'static SysFonts { } } } - families.sort_by_key(|n| n.to_lowercase()); - families.dedup(); - + let families = family_names(&db); SysFonts { - db, - families, + db: Mutex::new(db), + families: Mutex::new(families), by_file, } }) } -/// All installed system font families, sorted case-insensitively, de-duped. -pub fn families() -> &'static [String] { - &fonts().families + +/// Sorted, de-duplicated family names of every face currently in `db`. +fn family_names(db: &fontdb::Database) -> Vec { + let mut families: Vec = db + .faces() + .filter_map(|face| face.families.first().map(|(name, _)| name.clone())) + .collect(); + families.sort_by_key(|n| n.to_lowercase()); + families.dedup(); + families +} + +/// All known system font families, sorted case-insensitively, de-duped. On the +/// web this is the fetched Noto set, so it grows as fetches complete. +pub fn families() -> Vec { + fonts().families.lock().unwrap().clone() +} + +/// Sans families aliased to the Noto stand-in on the web. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +static SANS_ALIASES: &[&str] = &[ + "Arial", + "Calibri", + "DejaVu Sans", + "Helvetica", + "Liberation Sans", + "Segoe UI", + "Tahoma", + "Verdana", +]; + +/// Serif families aliased on the web; they prefer Noto Serif when such a +/// subset is ever fetched and otherwise degrade to Noto Sans. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +static SERIF_ALIASES: &[&str] = &[ + "Book Antiqua", + "Cambria", + "Constantia", + "DejaVu Serif", + "Garamond", + "Georgia", + "Liberation Serif", + "Palatino Linotype", + "Times New Roman", +]; + +/// Preferred stand-in families for an alias request (lowercase), in order. +/// `None` when the name is not a known alias. Pure table lookup, so it is unit +/// tested on every platform even though only the web resolution path uses it. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +fn alias_targets(family_lower: &str) -> Option<&'static [&'static str]> { + const SANS: &[&str] = &["Noto Sans"]; + const SERIF: &[&str] = &["Noto Serif", "Noto Sans"]; + if SANS_ALIASES + .iter() + .any(|alias| alias.eq_ignore_ascii_case(family_lower)) + { + Some(SANS) + } else if SERIF_ALIASES + .iter() + .any(|alias| alias.eq_ignore_ascii_case(family_lower)) + { + Some(SERIF) + } else { + None + } +} + +/// First alias stand-in that `known` reports as available. `known` receives +/// candidate family names; on the live path it scans the registered families. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +fn alias_resolution(family_lower: &str, known: impl Fn(&str) -> bool) -> Option<&'static str> { + alias_targets(family_lower)? + .iter() + .copied() + .find(|t| known(t)) +} + +/// Append the alias names to a family list when the sans stand-in is present +/// (every fetched subset carries the Latin face, so one successful fetch makes +/// all aliases resolvable). Pure, so the picker behaviour is unit testable. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +fn merge_web_aliases(families: &mut Vec) { + if !families.iter().any(|f| f.eq_ignore_ascii_case("Noto Sans")) { + return; + } + families.extend( + SANS_ALIASES + .iter() + .chain(SERIF_ALIASES.iter()) + .map(|name| name.to_string()), + ); + families.sort_by_key(|n| n.to_lowercase()); + families.dedup(); +} + +/// Families offered by the text-style picker's "System fonts" list: installed +/// fonts natively; on the web the fetched Noto families plus the common +/// office/CAD family names that stand in via them. +pub fn picker_families() -> Vec { + #[cfg(target_arch = "wasm32")] + { + let mut families = families(); + merge_web_aliases(&mut families); + families + } + #[cfg(not(target_arch = "wasm32"))] + families() +} + +fn canonical_cache() -> &'static Mutex>> { + static CACHE: OnceLock>>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) } /// Resolve a requested family name to the canonical installed system family name (with exact case). /// -/// Memoised process-wide: `resolve_font` calls this once per word on the MTEXT +/// Memoised: `resolve_font` calls this once per word on the MTEXT /// measure hot path for inline-`\f` TTF runs, and `Face::resolve` re-runs it /// immediately after — the underlying fontdb query plus linear family scans are -/// not free. The cache keys on the raw request string; results are stable for -/// the process lifetime (the font DB is loaded once via `OnceLock`). +/// not free. The cache keys on the raw request string. Results are stable for +/// the process lifetime on desktop; on the web the cache is cleared whenever a +/// fetched Noto subset joins the database, because earlier `None` resolutions +/// (font still in flight) must not stick. pub fn canonical_family_name(family: &str) -> Option { - static CACHE: OnceLock>>> = OnceLock::new(); - let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); - if let Some(hit) = cache.lock().unwrap().get(family) { + if let Some(hit) = canonical_cache().lock().unwrap().get(family) { return hit.clone(); } let resolved = canonical_family_name_uncached(family); - cache + canonical_cache() .lock() .unwrap() .insert(family.to_string(), resolved.clone()); @@ -81,30 +202,24 @@ pub fn canonical_family_name(family: &str) -> Option { } fn canonical_family_name_uncached(family: &str) -> Option { - let db = &fonts().db; + // Snapshot so the families lock is never held across the db lock below + // (`register_web_font` takes them in the opposite order, scoped). + let families = fonts().families.lock().unwrap().clone(); // 1. Try exact match first let query = fontdb::Query { families: &[fontdb::Family::Name(family)], ..Default::default() }; - if db.query(&query).is_some() { - if let Some(canonical) = fonts() - .families - .iter() - .find(|&f| f.eq_ignore_ascii_case(family)) - { + if fonts().db.lock().unwrap().query(&query).is_some() { + if let Some(canonical) = families.iter().find(|&f| f.eq_ignore_ascii_case(family)) { return Some(canonical.clone()); } return Some(family.to_string()); } // 2. Try case-insensitive match on the families we have - if let Some(matched) = fonts() - .families - .iter() - .find(|&f| f.eq_ignore_ascii_case(family)) - { + if let Some(matched) = families.iter().find(|&f| f.eq_ignore_ascii_case(family)) { return Some(matched.clone()); } @@ -119,8 +234,7 @@ fn canonical_family_name_uncached(family: &str) -> Option { }; if let Some(alias_name) = alias { - if let Some(matched) = fonts() - .families + if let Some(matched) = families .iter() .find(|&f| f.eq_ignore_ascii_case(alias_name)) { @@ -128,12 +242,23 @@ fn canonical_family_name_uncached(family: &str) -> Option { } } - // 4. Try matching prefix/subset case-insensitively. Require at least 3 + // 4. Web: the common office/CAD families the client cannot have installed + // stand in via the fetched Noto set (#141). Runs before the fuzzy prefix + // match so "Arial" can never drift onto an arbitrary Noto name. + #[cfg(target_arch = "wasm32")] + if let Some(target) = alias_resolution(&family_lower, |name| { + families.iter().any(|f| f.eq_ignore_ascii_case(name)) + }) { + if let Some(matched) = families.iter().find(|&f| f.eq_ignore_ascii_case(target)) { + return Some(matched.clone()); + } + } + + // 5. Try matching prefix/subset case-insensitively. Require at least 3 // chars so a 1–2 letter request can't grab an arbitrary family by the // first iteration order. Iterating sorted keeps the pick deterministic. if family_lower.len() >= 3 { - let mut candidates: Vec<&String> = fonts() - .families + let mut candidates: Vec<&String> = families .iter() .filter(|&f| { let f_low = f.to_lowercase(); @@ -158,14 +283,13 @@ fn face_id(family: &str) -> Option { /// (e.g. bold runs). Falls back through fontdb's fuzzy matching, so a /// family without a bold face still resolves — to its regular face. fn face_id_weighted(family: &str, weight: fontdb::Weight) -> Option { - let db = &fonts().db; let canonical = canonical_family_name(family)?; let query = fontdb::Query { families: &[fontdb::Family::Name(&canonical)], weight, ..Default::default() }; - db.query(&query) + fonts().db.lock().unwrap().query(&query) } /// Borrow the raw face bytes for `family` and run `f` over them. The byte slice @@ -174,7 +298,7 @@ fn face_id_weighted(family: &str, weight: fontdb::Weight) -> Option /// within a TrueType collection. Returns `None` if the family is unknown. pub fn with_face_data(family: &str, f: impl FnOnce(&[u8], u32) -> T) -> Option { let id = face_id(family)?; - fonts().db.with_face_data(id, f) + fonts().db.lock().unwrap().with_face_data(id, f) } /// Borrow the raw face bytes for `family` at `weight` (see @@ -186,7 +310,7 @@ pub fn with_face_data_weighted( f: impl FnOnce(&[u8], u32) -> T, ) -> Option { let id = face_id_weighted(family, weight)?; - fonts().db.with_face_data(id, f) + fonts().db.lock().unwrap().with_face_data(id, f) } /// The family of the installed face whose file is named `name` — a bare file @@ -203,7 +327,7 @@ fn family_for_file(name: &str) -> Option { /// nothing installed answers to it. /// /// A DWG text style stores the TrueType font *file* (`GOST2304_TypeA_italic.ttf`, -/// sometimes with a directory), which is what AutoCAD resolves; the family name +/// sometimes with a directory), which is what the style resolves by; the family name /// lives inside the file and is usually not the same string. Inline `\f` codes /// carry either form, so both are tried. pub fn family_for_reference(reference: &str) -> Option { @@ -223,6 +347,30 @@ pub fn has_family(family: &str) -> bool { face_id(family).is_some() } +/// Web: add a fetched font's bytes (a Noto subset, see `web_font`) to the +/// database so family-name resolution starts finding it. Fetches land after +/// the first text layout pass, so the family list is rebuilt and the +/// canonical-name memo cleared — negative resolutions from while the font was +/// in flight must not stick. Callers invalidate the glyph caches separately +/// (`ttf_glyph::clear_fallback_cache`). +/// +/// Faces may repeat across subsets (every file carries the Latin face, and the +/// CJK collections repeat it per language); `family_names` de-duplicates the +/// list, and fontdb picks one of the identical Latin faces per query. +#[cfg(target_arch = "wasm32")] +pub fn register_web_font(bytes: &[u8]) { + let sys = fonts(); + let names = { + let mut db = sys.db.lock().unwrap(); + db.load_font_data(bytes.to_vec()); + family_names(&db) + // db lock released before the families lock is taken — the resolution + // path takes them in the opposite order. + }; + *sys.families.lock().unwrap() = names; + canonical_cache().lock().unwrap().clear(); +} + #[cfg(test)] mod tests { use super::*; @@ -232,7 +380,7 @@ mod tests { /// and whether it comes as a bare name or as a path. #[test] fn face_files_resolve_to_their_family() { - let found = fonts().db.faces().find_map(|face| match &face.source { + let found = fonts().db.lock().unwrap().faces().find_map(|face| match &face.source { fontdb::Source::File(path) => { let file = path.file_name()?.to_string_lossy().into_owned(); let family = face.families.first().map(|(name, _)| name.clone())?; @@ -257,4 +405,116 @@ mod tests { assert_eq!(family_for_file("no-such-face-9f3.ttf"), None); assert_eq!(family_for_reference(" "), None); } + + #[test] + fn sans_aliases_map_to_noto_sans() { + for name in [ + "Arial", + "Helvetica", + "Verdana", + "Tahoma", + "Calibri", + "Segoe UI", + "Liberation Sans", + "DejaVu Sans", + ] { + assert_eq!( + alias_targets(&name.to_lowercase()), + Some(&["Noto Sans"][..]), + "{name} must alias to Noto Sans" + ); + } + } + + #[test] + fn serif_aliases_prefer_serif_with_sans_fallback() { + for name in [ + "Cambria", + "Times New Roman", + "Georgia", + "Liberation Serif", + "DejaVu Serif", + ] { + assert_eq!( + alias_targets(&name.to_lowercase()), + Some(&["Noto Serif", "Noto Sans"][..]), + "{name} must alias to Noto Serif first, Noto Sans second" + ); + } + } + + #[test] + fn alias_resolution_is_case_insensitive_and_needs_a_target() { + // Only the sans stand-in available (today's web fetch set): a serif + // alias degrades to Noto Sans rather than resolving to nothing. + assert_eq!( + alias_resolution("arial", |f| f == "Noto Sans"), + Some("Noto Sans") + ); + assert_eq!( + alias_resolution("ARIAL", |f| f == "Noto Sans"), + Some("Noto Sans") + ); + assert_eq!( + alias_resolution("cambria", |f| f == "Noto Sans"), + Some("Noto Sans") + ); + // Serif preferred when a serif subset is present. + assert_eq!( + alias_resolution("cambria", |f| f == "Noto Serif"), + Some("Noto Serif") + ); + // Nothing registered yet — fetch still in flight. + assert_eq!(alias_resolution("arial", |_| false), None); + // Unknown / stroke-font names are never aliased. + assert_eq!(alias_resolution("txt", |_| true), None); + assert_eq!(alias_resolution("isocp", |_| true), None); + assert_eq!(alias_resolution("monospace", |_| true), None); + } + + #[test] + fn picker_aliases_only_once_noto_arrives() { + // Before any fetch lands the picker must not offer unresolvable names. + let mut empty: Vec = Vec::new(); + merge_web_aliases(&mut empty); + assert!(empty.is_empty()); + + // With the Noto set present the aliases join, sorted and de-duplicated. + let mut with_noto = vec!["Noto Sans CJK SC".to_string(), "Noto Sans".to_string()]; + merge_web_aliases(&mut with_noto); + assert_eq!( + with_noto, + vec![ + "Arial".to_string(), + "Book Antiqua".to_string(), + "Calibri".to_string(), + "Cambria".to_string(), + "Constantia".to_string(), + "DejaVu Sans".to_string(), + "DejaVu Serif".to_string(), + "Garamond".to_string(), + "Georgia".to_string(), + "Helvetica".to_string(), + "Liberation Sans".to_string(), + "Liberation Serif".to_string(), + "Noto Sans".to_string(), + "Noto Sans CJK SC".to_string(), + "Palatino Linotype".to_string(), + "Segoe UI".to_string(), + "Tahoma".to_string(), + "Times New Roman".to_string(), + "Verdana".to_string(), + ] + ); + + // A registry that already carries an alias name (case differs) dedupes. + let mut dup = vec!["noto sans".to_string()]; + merge_web_aliases(&mut dup); + assert_eq!( + dup.iter() + .filter(|f| f.eq_ignore_ascii_case("Noto Sans")) + .count(), + 1 + ); + } } diff --git a/src/scene/text/ttf_glyph.rs b/src/scene/text/ttf_glyph.rs index e3588ba52..d11d45edb 100644 --- a/src/scene/text/ttf_glyph.rs +++ b/src/scene/text/ttf_glyph.rs @@ -692,6 +692,12 @@ pub fn fallback_glyph(ch: char) -> Option> { pub fn clear_fallback_cache() { fallback_cache().lock().unwrap().clear(); shape_cache().lock().unwrap().clear(); + // Web only: the per-family outline cache can also hold stale `None`s — + // the family only became resolvable when the font joined the sysfont + // database (`register_web_font`). On desktop the font set never changes, + // so the cache is kept. + #[cfg(target_arch = "wasm32")] + cache().lock().unwrap().clear(); #[cfg(target_arch = "wasm32")] crate::scene::text::sdf_atlas::reset_font_entries(); } diff --git a/src/scene/text/web_font.rs b/src/scene/text/web_font.rs index 6d69744a4..46dff66cb 100644 --- a/src/scene/text/web_font.rs +++ b/src/scene/text/web_font.rs @@ -293,23 +293,34 @@ mod imp { /// Record a fetch result: `Some(bytes)` on success, `None` on failure. pub fn insert(script: Script, bytes: Option>) { - let mut store = store().lock().unwrap(); - match bytes { - Some(bytes) => { - let bytes = Arc::new(bytes); - store.states.insert(script, State::Loaded(bytes.clone())); - if script != Script::Latin { - store - .states - .entry(Script::Latin) - .or_insert_with(|| State::Loaded(bytes)); + let mut fetched: Option>> = None; + { + let mut store = store().lock().unwrap(); + match bytes { + Some(bytes) => { + let bytes = Arc::new(bytes); + store.states.insert(script, State::Loaded(bytes.clone())); + if script != Script::Latin { + store + .states + .entry(Script::Latin) + .or_insert_with(|| State::Loaded(bytes.clone())); + } + fetched = Some(bytes); + } + None => { + store.states.insert(script, State::Failed); } } - None => { - store.states.insert(script, State::Failed); - } } - drop(store); + // Join the system-font database before the generation bump wakes + // consumers: a text style naming a TTF family (or one of its aliases, + // e.g. Arial → Noto Sans) then resolves to filled glyphs instead of + // LFF strokes. Family-name memo + glyph caches are invalidated by the + // caller (`clear_fallback_cache`) on the same message. + if let Some(bytes) = fetched { + crate::scene::text::sysfont::register_web_font(&bytes); + } super::bump_generation(); } diff --git a/src/ui/style/textstyle.rs b/src/ui/style/textstyle.rs index 1bf7b83c1..296fb41bd 100644 --- a/src/ui/style/textstyle.rs +++ b/src/ui/style/textstyle.rs @@ -329,7 +329,9 @@ pub fn view_window<'a>( .iter() .map(|value| (*value).to_string()) .collect(); - let system = crate::scene::text::sysfont::families().to_vec(); + // Web: the fetched Noto set plus the office/CAD family names it + // stands in for (Arial → Noto Sans, …); desktop: installed fonts. + let system = crate::scene::text::sysfont::picker_families(); column![ row![ font_list(t!("Stroke fonts"), built_in, v.font_buf, false, v.read_only), diff --git a/src/ui/window/block_palette.rs b/src/ui/window/block_palette.rs index 7c1c6c221..ef6b0e45a 100644 --- a/src/ui/window/block_palette.rs +++ b/src/ui/window/block_palette.rs @@ -167,6 +167,10 @@ pub enum BlockPaletteMsg { View(ViewMode), PickFile, FilePicked(Result), + /// Web: the picked drawing arrived already parsed from the picker's + /// bytes — a name-only path has nothing behind it to read later. + #[cfg(target_arch = "wasm32")] + FilePickedDoc(Result<(String, codec::CadDocument), String>), Refresh, Option(OptionMsg), ToggleOptions,