web: raster images, WBLOCK/SAVEALL/SCRIPT/CTB/LAYTRANS/INSERT, filled TTF text

A fresh web-vs-desktop audit found the remaining gaps; this closes the
top ten.

Raster images: IMAGE attach read the picked file via image::open on a
name-only path (dead on wasm), and raster references inside opened
drawings decoded the same way. Picked bytes are now registered in a
name-keyed store (the PDF-underlay pattern), decoding runs from memory,
and dropped .png/.jpg/.bmp/.tif files register by name so unresolved
references in opened drawings re-resolve and render (and export to PDF).

Command gaps fixed with the bytes-at-pick / download_bytes pattern:
- WBLOCK now downloads the extracted block instead of failing on fs.
- SAVEALL saves every dirty tab instead of printing a no-op message.
- SCRIPT picks and reads the .scr file through the browser picker.
- CENTERLTYPEFILE picks the .lin/.dnl file and populates from text.
- Custom .ctb tables picked on web now parse, register for the
  session, appear in the plot dialog list, and apply (load_named
  previously returned builtin-only on wasm).
- LAYTRANS 'Load standards file' parses the picked drawing on the
  existing web worker.
- INSERT 'from file' in the block palette parses picked bytes and
  feeds the shared import path.

Text fidelity: TTF-named styles rendered as thin stroke glyphs on web
because the font database was empty. Fetched Noto faces are now
registered into it with a family alias table (Arial, Times New Roman,
Cambria, ... -> Noto Sans/Serif), so those styles render filled, shape,
export to PDF as vector fills, and appear in the style picker.
ViewCube labels request semibold again on web.

docs/native-vs-web.md: the hatch and font rows were stale (both work on
web now), the GPU row claimed WebGPU is used where available (the
backend is pinned to OpenGL), and the parallelism row missed the parse
worker — all corrected.

(cherry picked from commit c643a10e)

Ported onto main (#1717 review): conflicts resolved toward main — the
font-file lookup, PDF searchable text, purge service, block palette and
plot-style copy stay as they are on main; the web pieces (font registry
and aliases, session plot styles, SCRIPT/SAVEALL/CENTERLTYPEFILE picks,
block-from-file on the web) are layered on top.
This commit is contained in:
Afonsop authored and Hakan Seven committed 2026-10-12 12:02:04 +03:00
1 parent 69fa7cae4f
commit 9cd898a17a
42 files changed
+1315 -155

No files matched your search

+18 -13
View File
@@ -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).
+1
View File
@@ -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__.
+1
View File
@@ -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__.
+1
View File
@@ -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__.
+1
View File
@@ -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__.
+1
View File
@@ -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__.
+1
View File
@@ -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__.
+1
View File
@@ -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__.
+1
View File
@@ -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__.
+1
View File
@@ -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__.
+1
View File
@@ -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) चला रहा है।
+1
View File
@@ -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.
+1
View File
@@ -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__.
+1
View File
@@ -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__ 個のコマンドを実行中です。
+1
View File
@@ -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__ 명령을 실행합니다.
+1
View File
@@ -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__.
+1
View File
@@ -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__.
+1
View File
@@ -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__.
+1
View File
@@ -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__.
+1
View File
@@ -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.
+1
View File
@@ -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__命令。
+1
View File
@@ -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__命令。
+72 -25
View File
@@ -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<Task<Message>> = 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 <path>` read and the
/// web picker's `ScriptFilePicked`.
pub(in crate::app) fn run_script(&mut self, path: &str, text: &str) -> Task<Message> {
let p = path;
let cmds: Vec<Task<Message>> = 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<String> {
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]`
+12
View File
@@ -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;
+21
View File
@@ -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<crate::modules::draw::layers::laytrans::TargetLayer>,
)>,
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.)
+38
View File
@@ -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 {
+2 -1
View File
@@ -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,
+87 -8
View File
@@ -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<Message> {
// 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();
}
+127 -15
View File
@@ -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<String> = 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(&registered);
}
}
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<String> =
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")))]
{
+126 -17
View File
@@ -232,6 +232,42 @@ pub async fn pick_layer_mapping_path(save: bool) -> Option<PathBuf> {
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<CadDocument, String> {
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<Option<(String, CadDocument)>, 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<u8>) -> Result<CadDocument, String> {
@@ -1766,29 +1846,51 @@ pub async fn pick_plot_style() -> Result<Option<plot_style::PlotStyleTable>, 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<Vec<u8>> = 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
+137
View File
@@ -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<String> {
})
.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<String> {
{
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<HashMap<String, Arc<Vec<u8>>>> {
static TABLES: OnceLock<Mutex<HashMap<String, Arc<Vec<u8>>>>> = 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<Vec<u8>>) {
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<Arc<Vec<u8>>> {
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<String> {
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]
+72 -9
View File
@@ -25,6 +25,9 @@ pub struct QueuedOpen {
}
static DROPPED: Mutex<VecDeque<QueuedOpen>> = 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<VecDeque<(String, Vec<u8>)>> = 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<u8>) -> 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<u8>) -> 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<u8>)> {
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"
)
}
+1
View File
@@ -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")),
+7
View File
@@ -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<Vec<TargetLayer>, 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<Vec<TargetLayer>, String> {
let mut targets: Vec<TargetLayer> = document
.layers
.iter()
+23
View File
@@ -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
+205 -6
View File
@@ -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<HashMap<String, ImageCacheEntry>> {
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
fn source_cache() -> &'static Mutex<HashMap<String, Arc<Vec<u8>>>> {
static SOURCES: OnceLock<Mutex<HashMap<String, Arc<Vec<u8>>>>> = OnceLock::new();
SOURCES.get_or_init(|| Mutex::new(HashMap::new()))
}
/// 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<Vec<u8>>) {
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<String> {
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<Arc<Vec<u8>>> {
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<Arc<Vec<u8>>> {
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<DecodedImage> {
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<DecodedImage> {
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<DecodedImage> {
let img = image::load_from_memory(bytes).ok()?;
rgba_image(img)
}
#[cfg(not(target_arch = "wasm32"))]
fn decode_from_file(path: &str) -> Option<DecodedImage> {
let img = image::open(Path::new(path)).ok()?;
rgba_image(img)
}
fn rgba_image(img: image::DynamicImage) -> Option<DecodedImage> {
let rgba = img.into_rgba8();
let (width, height) = rgba.dimensions();
Some(DecodedImage {
@@ -575,3 +679,98 @@ fn fetch_remote(url: &str) -> Option<Vec<u8>> {
fn fetch_remote(_url: &str) -> Option<Vec<u8>> {
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<u8> {
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"));
}
}
+3 -1
View File
@@ -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,
))
}
+305 -45
View File
@@ -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<String>,
db: Mutex<fontdb::Database>,
/// Sorted, de-duplicated family names for the picker and name resolution.
families: Mutex<Vec<String>>,
/// 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<SysFonts> = 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<String> = Vec::new();
let mut by_file: HashMap<String, String> = 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<String> {
let mut families: Vec<String> = 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<String> {
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<String>) {
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<String> {
#[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<HashMap<String, Option<String>>> {
static CACHE: OnceLock<Mutex<HashMap<String, Option<String>>>> = 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<String> {
static CACHE: OnceLock<Mutex<HashMap<String, Option<String>>>> = 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<String> {
}
fn canonical_family_name_uncached(family: &str) -> Option<String> {
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<String> {
};
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<String> {
}
}
// 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<fontdb::ID> {
/// (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<fontdb::ID> {
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<fontdb::ID>
/// within a TrueType collection. Returns `None` if the family is unknown.
pub fn with_face_data<T>(family: &str, f: impl FnOnce(&[u8], u32) -> T) -> Option<T> {
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<T>(
f: impl FnOnce(&[u8], u32) -> T,
) -> Option<T> {
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<String> {
/// 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<String> {
@@ -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<String> = 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
);
}
}
+6
View File
@@ -692,6 +692,12 @@ pub fn fallback_glyph(ch: char) -> Option<Arc<Glyph>> {
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();
}
+25 -14
View File
@@ -293,23 +293,34 @@ mod imp {
/// Record a fetch result: `Some(bytes)` on success, `None` on failure.
pub fn insert(script: Script, bytes: Option<Vec<u8>>) {
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<Arc<Vec<u8>>> = 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();
}
+3 -1
View File
@@ -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),
+4
View File
@@ -167,6 +167,10 @@ pub enum BlockPaletteMsg {
View(ViewMode),
PickFile,
FilePicked(Result<PathBuf, String>),
/// 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,