diff --git a/Cargo.lock b/Cargo.lock index 05f0a90..0543ed6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1083,6 +1083,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1289,6 +1298,12 @@ dependencies = [ "syn 2.0.116", ] +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + [[package]] name = "deflate" version = "1.0.0" @@ -1688,6 +1703,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "event-listener" version = "5.4.1" @@ -1829,6 +1853,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + [[package]] name = "fluent-uri" version = "0.4.1" @@ -1864,6 +1894,29 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3a6f9af55fb97ad673fb7a69533eb2f967648a06fa21f8c9bb2cd6d33975716" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + [[package]] name = "foreign-types" version = "0.3.2" @@ -2232,6 +2285,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gif" version = "0.14.1" @@ -2948,7 +3011,7 @@ dependencies = [ "bytemuck", "byteorder-lite", "color_quant", - "gif", + "gif 0.14.1", "moxcms", "num-traits", "png 0.18.1", @@ -2957,6 +3020,22 @@ dependencies = [ "zune-jpeg 0.5.12", ] +[[package]] +name = "image-webp" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f79afb8cbee2ef20f59ccd477a218c12a93943d075b492015ecb1bb81f8ee904" +dependencies = [ + "byteorder-lite", + "quick-error 2.0.1", +] + +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + [[package]] name = "indexmap" version = "1.9.3" @@ -3507,6 +3586,17 @@ dependencies = [ "selectors", ] +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -3756,6 +3846,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -4750,6 +4849,12 @@ dependencies = [ "siphasher 1.0.2", ] +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project" version = "1.1.11" @@ -5441,6 +5546,23 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" +[[package]] +name = "resvg" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a325d5e8d1cebddd070b13f44cec8071594ab67d1012797c121f27a669b7958" +dependencies = [ + "gif 0.13.3", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", + "zune-jpeg 0.4.21", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -5475,6 +5597,15 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + [[package]] name = "ring" version = "0.17.14" @@ -5542,6 +5673,12 @@ dependencies = [ "url", ] +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "rsa" version = "0.9.10" @@ -5674,6 +5811,24 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rustybuzz" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c85d1ccd519e61834798eb52c4e886e8c2d7d698dd3d6ce0b1b47eb8557f1181" +dependencies = [ + "bitflags 2.11.0", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + [[package]] name = "ryu" version = "1.0.23" @@ -6152,6 +6307,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + [[package]] name = "siphasher" version = "0.3.11" @@ -6170,6 +6334,15 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -6303,6 +6476,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + [[package]] name = "string_cache" version = "0.8.9" @@ -6340,6 +6522,16 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "svgtypes" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo", + "siphasher 1.0.2", +] + [[package]] name = "swift-rs" version = "1.0.7" @@ -7020,6 +7212,32 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.17.16", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + [[package]] name = "tiny_http" version = "0.12.0" @@ -7369,6 +7587,15 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be21190ff5d38e8b4a2d3b6a3ae57f612cc39c96e83cedeaf7abc338a8bac4a" +dependencies = [ + "core_maths", +] + [[package]] name = "twoway" version = "0.1.8" @@ -7454,6 +7681,24 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64af057ad7466495ca113126be61838d8af947f41d93a949980b2389a118082f" + +[[package]] +name = "unicode-ccc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "260bc6647b3893a9a90668360803a15f96b85a5257b1c3a0c3daf6ae2496de42" + [[package]] name = "unicode-ident" version = "1.0.23" @@ -7469,12 +7714,30 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + [[package]] name = "unicode-segmentation" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + [[package]] name = "unicode-width" version = "0.1.14" @@ -7536,6 +7799,33 @@ dependencies = [ "url", ] +[[package]] +name = "usvg" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7447e703d7223b067607655e625e0dbca80822880248937da65966194c4864e6" +dependencies = [ + "base64 0.22.1", + "data-url", + "flate2", + "fontdb", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree", + "rustybuzz", + "simplecss", + "siphasher 1.0.2", + "strict-num", + "svgtypes", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + [[package]] name = "utf-8" version = "0.7.6" @@ -8633,10 +8923,12 @@ dependencies = [ name = "writer-store" version = "0.2.0" dependencies = [ + "base64 0.22.1", "blake3", "chrono", "dirs", "log", + "resvg", "rusqlite", "serde", "serde_json", @@ -8767,6 +9059,12 @@ dependencies = [ "markup5ever 0.12.1", ] +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + [[package]] name = "yaml-rust" version = "0.4.5" diff --git a/README.md b/README.md index aadd7c1..c126a1f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ -# Writer +# ~~Writer~~ Commonplace -Writer is a Tauri desktop writing app with a React frontend and a Rust backend. It treats user-selected folders as the source of truth, keeps app state in Rust-backed persistence, and layers focused writing, file management, and export workflows on top. +Commonplace is a distraction-free markdown editor. ![Split-pane markdown editor with live preview](www/src/static/images/split-pane-editor.png) diff --git a/crates/store/Cargo.toml b/crates/store/Cargo.toml index e3b51a0..b83f586 100644 --- a/crates/store/Cargo.toml +++ b/crates/store/Cargo.toml @@ -13,6 +13,8 @@ dirs = "6" tempfile = "3.27" log = "0.4" blake3 = "1" +resvg = "0.44" +base64 = "0.22" writer-core = { path = "../core" } writer-md = { path = "../markdown" } diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index 52a90e6..f977d6f 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -2385,6 +2385,48 @@ impl Store { log::debug!("image_list: {} assets in location {:?}", assets.len(), location_id); Ok(assets) } + + /// Converts an SVG file at the given absolute path to a PNG data URL. + /// + /// Renders at intrinsic size (capped at 2048px on the longest side) and returns + /// `data:image/png;base64,...`. Used by the PDF renderer to embed SVG images. + pub fn svg_to_png(&self, absolute_path: &std::path::Path) -> Result { + use base64::Engine as _; + + log::debug!("svg_to_png: {}", absolute_path.display()); + + let svg_data = std::fs::read(absolute_path).map_err(|e| AppError::io(format!("Failed to read SVG: {}", e)))?; + + let opt = resvg::usvg::Options::default(); + let tree = resvg::usvg::Tree::from_data(&svg_data, &opt) + .map_err(|e| AppError::io(format!("SVG parse error: {}", e)))?; + + let size = tree.size().to_int_size(); + let (w, h) = (size.width(), size.height()); + let scale = if w > 2048 || h > 2048 { (2048.0_f32 / w as f32).min(2048.0_f32 / h as f32) } else { 1.0_f32 }; + let out_w = ((w as f32 * scale).round() as u32).max(1); + let out_h = ((h as f32 * scale).round() as u32).max(1); + + let mut pixmap = resvg::tiny_skia::Pixmap::new(out_w, out_h) + .ok_or_else(|| AppError::io(format!("Failed to allocate pixmap ({}x{})", out_w, out_h)))?; + + resvg::render( + &tree, + resvg::tiny_skia::Transform::from_scale(scale, scale), + &mut pixmap.as_mut(), + ); + + let png_bytes = pixmap + .encode_png() + .map_err(|e| AppError::io(format!("PNG encode failed: {}", e)))?; + + let data_url = format!( + "data:image/png;base64,{}", + base64::engine::general_purpose::STANDARD.encode(&png_bytes), + ); + log::debug!("svg_to_png: ok, {} png bytes", png_bytes.len()); + Ok(data_url) + } } #[cfg(test)] diff --git a/docs/specs/image-handling.md b/docs/specs/image-handling.md index 54912b2..1453f3c 100644 --- a/docs/specs/image-handling.md +++ b/docs/specs/image-handling.md @@ -87,7 +87,7 @@ pub fn image_list(location_id: LocationId) -> Result, Error> #### Preview Rendering - The markdown preview must resolve `.writer-assets/` paths to `asset:` protocol URLs (Tauri asset protocol) or `convertFileSrc()` for display. -- Images render inline with `max-width: 100%` and optional click-to-zoom. +- Images render inline with `max-width: 100%` and click-to-zoom. #### State diff --git a/docs/tasks/image-handling.md b/docs/tasks/image-handling.md index 4a0b7d3..7d4cbb9 100644 --- a/docs/tasks/image-handling.md +++ b/docs/tasks/image-handling.md @@ -51,13 +51,13 @@ updated: 2026-03-21 ## Preview Rendering -- [ ] Resolve `.writer-assets/` paths in markdown preview +- [x] Resolve `.writer-assets/` paths in markdown preview - Use `convertFileSrc()` or Tauri asset protocol to create displayable URLs - Handle relative path resolution for documents in subdirectories -- [ ] Image display styling +- [x] Image display styling - `max-width: 100%`, responsive within content column - Maintain aspect ratio -- [ ] Click-to-zoom (optional polish) +- [x] Click-to-zoom - Click image in preview to open full-size overlay ## AT Protocol Blob Sync @@ -112,14 +112,14 @@ updated: 2026-03-21 ### Frontend -- [ ] Add image path resolution for PDF renderer +- [x] Add image path resolution for PDF renderer - Resolve `.writer-assets/` paths to base64 data URLs (similar to font preloading in `src/pdf/fonts.ts`) - Use `convertFileSrc()` → fetch bytes → encode as `data:;base64,...` -- [ ] Add `Image` case to `MarkdownPdfDocument.tsx` node renderer +- [x] Add `Image` case to `MarkdownPdfDocument.tsx` node renderer - Render `` with `maxWidth: 100%`, preserve aspect ratio -- [ ] Update `usePdfExport.tsx` to preload images before render +- [x] Update `usePdfExport.tsx` to preload images before render - Scan PdfNodes for Image variants, resolve all paths, then render -- [ ] Handle SVG gracefully —> convert to PNG on backend, then embed and render +- [x] Handle SVG gracefully - convert to PNG on the backend ## Test/QA Plan diff --git a/docs/tasks/parking-lot.md b/docs/tasks/parking-lot.md index 7ed88b9..0d55e2f 100644 --- a/docs/tasks/parking-lot.md +++ b/docs/tasks/parking-lot.md @@ -5,7 +5,7 @@ description: > updated: 2026-03-23 --- -1. **CJK Font Support** ✅ +1. **CJK Font Support**[^1][^2][^3] ✅ Maple Mono (4 TTF weights, ~1.1 MB bundled) is now the CJK-capable font. `@react-pdf/renderer` has no fallback chains[^4] — CJK fonts must be registered and selected explicitly. diff --git a/src-tauri/src/commands/images.rs b/src-tauri/src/commands/images.rs index eb85ccc..7f270c4 100644 --- a/src-tauri/src/commands/images.rs +++ b/src-tauri/src/commands/images.rs @@ -51,6 +51,21 @@ pub fn image_delete(state: State<'_, AppState>, location_id: i64, asset_path: St } } +/// Converts an SVG at the given absolute path to a PNG data URL. +/// +/// Delegates to `writer_store::Store::svg_to_png`. Returns `data:image/png;base64,...`. +#[tauri::command] +pub fn svg_to_png(state: State<'_, AppState>, absolute_path: String) -> CommandResponse { + log::debug!("svg_to_png: {}", absolute_path); + match state.store.svg_to_png(std::path::Path::new(&absolute_path)) { + Ok(data_url) => Ok(CommandResult::ok(data_url)), + Err(e) => { + log::error!("svg_to_png failed: {}", e); + Ok(CommandResult::err(e)) + } + } +} + /// Lists all image assets in `.writer-assets/` for the given location. /// /// Returns an empty vec if the assets directory does not exist yet. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0908633..91e5236 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -151,6 +151,7 @@ pub fn run() { cmd::image_import, cmd::image_delete, cmd::image_list, + cmd::svg_to_png, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/App.tsx b/src/App.tsx index 8c50931..a2591c8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,8 @@ const AppContent = ({ isFocusMode, view }: { isFocusMode: boolean; view: Workspa focusModePanelProps, handleExportPdf, previewResult, + activeDocLocationRootPath, + activeDocRelPath, editorFontFamily, editorText, atProto, @@ -66,7 +68,9 @@ const AppContent = ({ isFocusMode, view }: { isFocusMode: boolean; view: Workspa onExport={handleExportPdf} previewResult={previewResult} editorFontFamily={editorFontFamily} - documentText={editorText} /> + documentText={editorText} + locationRootPath={activeDocLocationRootPath} + docRelPath={activeDocRelPath} /> diff --git a/src/__tests__/Preview.test.tsx b/src/__tests__/Preview.test.tsx new file mode 100644 index 0000000..42c6eee --- /dev/null +++ b/src/__tests__/Preview.test.tsx @@ -0,0 +1,128 @@ +import { Preview, resolveAssetSrc } from "$components/Preview"; +import type { RenderResult } from "$types"; +import { convertFileSrc } from "@tauri-apps/api/core"; +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const makeRenderResult = (html: string): RenderResult => ({ + html, + metadata: { title: null, outline: [], links: [], task_items: { total: 0, completed: 0 }, word_count: 0 }, +}); + +const defaultProps = { + theme: "dark" as const, + editorLine: 1, + previewStyle: "github" as const, + editorFontFamily: "IBM Plex Mono" as const, +}; + +describe("resolveAssetSrc", () => { + it("resolves a root-level asset path", () => { + expect(resolveAssetSrc("/root", "doc.md", ".writer-assets/img.png")).toBe("/root/.writer-assets/img.png"); + }); + + it("resolves a relative asset path from a subdirectory document", () => { + expect(resolveAssetSrc("/root", "drafts/doc.md", "../.writer-assets/img.png")).toBe("/root/.writer-assets/img.png"); + }); + + it("resolves a deeply nested document", () => { + expect(resolveAssetSrc("/root", "a/b/doc.md", "../../.writer-assets/img.png")).toBe("/root/.writer-assets/img.png"); + }); + + it("handles no document subdirectory (root doc) with .writer-assets/ src", () => { + expect(resolveAssetSrc("/my/location", "notes.md", ".writer-assets/abc.jpg")).toBe( + "/my/location/.writer-assets/abc.jpg", + ); + }); +}); + +describe("Preview image resolution", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(convertFileSrc).mockImplementation((path: string) => `asset://localhost${path}`); + }); + + it("rewrites .writer-assets/ img src to convertFileSrc URL", () => { + const html = `test`; + render( + , + ); + + const img = screen.getByRole("img", { name: "test" }); + expect(convertFileSrc).toHaveBeenCalledWith("/Users/test/notes/.writer-assets/abc123.png"); + expect(img).toHaveAttribute("src", "asset://localhost/Users/test/notes/.writer-assets/abc123.png"); + }); + + it("rewrites relative asset paths from subdirectory documents", () => { + const html = `sub`; + render( + , + ); + + const img = screen.getByRole("img", { name: "sub" }); + expect(convertFileSrc).toHaveBeenCalledWith("/root/.writer-assets/img.jpg"); + expect(img).toHaveAttribute("src", "asset://localhost/root/.writer-assets/img.jpg"); + }); + + it("does not rewrite external http URLs", () => { + const html = `ext`; + render( + , + ); + + const img = screen.getByRole("img", { name: "ext" }); + expect(convertFileSrc).not.toHaveBeenCalled(); + expect(img).toHaveAttribute("src", "https://example.com/image.png"); + }); + + it("does not rewrite already-resolved asset: URLs", () => { + const html = `already`; + render( + , + ); + + const img = screen.getByRole("img", { name: "already" }); + expect(convertFileSrc).not.toHaveBeenCalled(); + expect(img).toHaveAttribute("src", "asset://localhost/root/.writer-assets/img.png"); + }); + + it("does not rewrite anything when locationRootPath is not provided", () => { + const html = `no-path`; + render(); + + const img = screen.getByRole("img", { name: "no-path" }); + expect(convertFileSrc).not.toHaveBeenCalled(); + expect(img).toHaveAttribute("src", ".writer-assets/img.png"); + }); + + it("re-resolves images when renderResult changes", () => { + const { rerender } = render( + `)} + locationRootPath="/root" + docRelPath="doc.md" />, + ); + + expect(convertFileSrc).toHaveBeenCalledWith("/root/.writer-assets/a.png"); + vi.clearAllMocks(); + + rerender( + `)} + locationRootPath="/root" + docRelPath="doc.md" />, + ); + + expect(convertFileSrc).toHaveBeenCalledWith("/root/.writer-assets/b.png"); + }); +}); diff --git a/src/__tests__/pdfImages.test.ts b/src/__tests__/pdfImages.test.ts new file mode 100644 index 0000000..3cd79f2 --- /dev/null +++ b/src/__tests__/pdfImages.test.ts @@ -0,0 +1,110 @@ +import { preloadPdfImages, resolveAssetSrc } from "$pdf/images"; +import type { MarkdownNode } from "$pdf/types"; +import { convertFileSrc, invoke } from "@tauri-apps/api/core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +describe("resolveAssetSrc", () => { + it("resolves root-level asset from a root doc", () => { + expect(resolveAssetSrc("/root", "doc.md", ".writer-assets/img.png")).toBe("/root/.writer-assets/img.png"); + }); + + it("resolves via ../ from a subdirectory doc", () => { + expect(resolveAssetSrc("/root", "drafts/doc.md", "../.writer-assets/img.png")).toBe("/root/.writer-assets/img.png"); + }); + + it("resolves deeply nested doc", () => { + expect(resolveAssetSrc("/root", "a/b/c/doc.md", "../../../.writer-assets/img.png")).toBe( + "/root/.writer-assets/img.png", + ); + }); +}); + +describe("preloadPdfImages", () => { + const locationRootPath = "/Users/test/notes"; + const docRelPath = "doc.md"; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(convertFileSrc).mockImplementation((path: string) => `asset://localhost${path}`); + vi.mocked(invoke).mockResolvedValue({ type: "ok", value: "data:image/png;base64,c3Zn" }); + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + if (url.includes("missing")) { + return { ok: false, status: 404 } as Response; + } + const encoder = new TextEncoder(); + const bytes = encoder.encode("fake-image-bytes"); + return await new Promise((resolve) => { + resolve({ + ok: true, + arrayBuffer: async () => + await new Promise((r) => { + r(bytes.buffer); + }), + } as unknown as Response); + }); + }), + ); + }); + + it("returns empty record when there are no image nodes", async () => { + const nodes: MarkdownNode[] = [{ type: "heading", level: 1, content: "Hello" }, { + type: "paragraph", + content: "World", + }]; + const result = await preloadPdfImages(nodes, locationRootPath, docRelPath); + expect(result).toEqual({}); + }); + + it("resolves a .writer-assets/ image to a base64 data URL", async () => { + const nodes: MarkdownNode[] = [{ type: "image", src: ".writer-assets/abc123.png", alt: "test" }]; + const result = await preloadPdfImages(nodes, locationRootPath, docRelPath); + expect(convertFileSrc).toHaveBeenCalledWith(`${locationRootPath}/.writer-assets/abc123.png`); + expect(result[".writer-assets/abc123.png"]).toMatch(/^data:image\/png;base64,/); + }); + + it("deduplicates the same image referenced multiple times", async () => { + const nodes: MarkdownNode[] = [{ type: "image", src: ".writer-assets/img.png", alt: "first" }, { + type: "image", + src: ".writer-assets/img.png", + alt: "second", + }]; + const result = await preloadPdfImages(nodes, locationRootPath, docRelPath); + expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1); + expect(result[".writer-assets/img.png"]).toBeDefined(); + }); + + it("gracefully omits images that fail to fetch", async () => { + const nodes: MarkdownNode[] = [{ type: "image", src: ".writer-assets/missing.png", alt: "broken" }]; + const result = await preloadPdfImages(nodes, locationRootPath, docRelPath); + expect(result[".writer-assets/missing.png"]).toBeUndefined(); + }); + + it("converts SVG images via svg_to_png backend command", async () => { + const nodes: MarkdownNode[] = [{ type: "image", src: ".writer-assets/diagram.svg", alt: "svg" }]; + const result = await preloadPdfImages(nodes, locationRootPath, docRelPath); + expect(vi.mocked(invoke)).toHaveBeenCalledWith("svg_to_png", { + absolutePath: `${locationRootPath}/.writer-assets/diagram.svg`, + }); + expect(result[".writer-assets/diagram.svg"]).toBe("data:image/png;base64,c3Zn"); + expect(vi.mocked(fetch)).not.toHaveBeenCalled(); + }); + + it("skips images not under .writer-assets/", async () => { + const nodes: MarkdownNode[] = [{ type: "image", src: "https://example.com/img.png", alt: "external" }]; + const result = await preloadPdfImages(nodes, locationRootPath, docRelPath); + expect(result).toEqual({}); + expect(vi.mocked(fetch)).not.toHaveBeenCalled(); + }); + + it("collects images from inside list items", async () => { + const nodes: MarkdownNode[] = [{ + type: "list", + ordered: false, + items: [{ type: "image", src: ".writer-assets/list-img.jpg", alt: "in list" }], + }]; + const result = await preloadPdfImages(nodes, locationRootPath, docRelPath); + expect(result[".writer-assets/list-img.jpg"]).toMatch(/^data:image\/jpeg;base64,/); + }); +}); diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts index 167d533..5e43f0d 100644 --- a/src/__tests__/setup.ts +++ b/src/__tests__/setup.ts @@ -20,7 +20,10 @@ export function clearMockListeners() { mockListeners.clear(); } -vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); +vi.mock( + "@tauri-apps/api/core", + () => ({ invoke: vi.fn(), convertFileSrc: vi.fn((path: string) => `asset://localhost${path}`) }), +); vi.mock( "@tauri-apps/plugin-log", () => ({ diff --git a/src/components/AppLayout/WorkspacePanel.tsx b/src/components/AppLayout/WorkspacePanel.tsx index 6acbc24..beba811 100644 --- a/src/components/AppLayout/WorkspacePanel.tsx +++ b/src/components/AppLayout/WorkspacePanel.tsx @@ -37,7 +37,15 @@ type K = | "imageHandlers"; export type WorkspaceEditorProps = Pick; -type PK = "renderResult" | "theme" | "editorLine" | "previewStyle" | "editorFontFamily" | "onScrollToLine"; +type PK = + | "renderResult" + | "theme" + | "editorLine" + | "previewStyle" + | "editorFontFamily" + | "onScrollToLine" + | "locationRootPath" + | "docRelPath"; export type WorkspacePreviewProps = Pick; export type WorkspaceDiagnosticsProps = { diff --git a/src/components/Preview.tsx b/src/components/Preview.tsx index 89e6698..85e7c3c 100644 --- a/src/components/Preview.tsx +++ b/src/components/Preview.tsx @@ -1,7 +1,11 @@ +import { resolveAssetSrc } from "$pdf/images"; import type { AppTheme, EditorFontFamily, MarkdownPreviewStyle, RenderResult } from "$types"; -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { convertFileSrc } from "@tauri-apps/api/core"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties } from "react"; +export { resolveAssetSrc }; + export type PreviewProps = { renderResult: RenderResult | null; theme: AppTheme; @@ -10,6 +14,8 @@ export type PreviewProps = { editorFontFamily: EditorFontFamily; onScrollToLine?: (line: number) => void; className?: string; + locationRootPath?: string; + docRelPath?: string; }; const PDF_PREVIEW_FONT_MAP: Record = { @@ -26,14 +32,39 @@ const PDF_PREVIEW_FONT_MAP: Record = { }; export function Preview( - { renderResult, theme, editorLine, previewStyle, editorFontFamily, onScrollToLine, className = "" }: PreviewProps, + { + renderResult, + theme, + editorLine, + previewStyle, + editorFontFamily, + onScrollToLine, + className = "", + locationRootPath, + docRelPath, + }: PreviewProps, ) { const containerRef = useRef(null); const isScrollingRef = useRef(false); const scrollTimeoutRef = useRef | null>(null); + const [zoomedSrc, setZoomedSrc] = useState(null); const previewContent = useMemo(() => ({ __html: renderResult?.html ?? "" }), [renderResult]); + useEffect(() => { + if (!containerRef.current || !locationRootPath) return; + + const imgs = containerRef.current.querySelectorAll("img"); + for (const img of imgs) { + const src = img.getAttribute("src"); + if (!src || !src.includes(".writer-assets/")) continue; + if (src.startsWith("http") || src.startsWith("asset:") || src.startsWith("data:")) continue; + + const absolutePath = resolveAssetSrc(locationRootPath, docRelPath ?? "", src); + img.src = convertFileSrc(absolutePath); + } + }, [renderResult, locationRootPath, docRelPath]); + const findElementForLine = useCallback((line: number): HTMLElement | null => { const container = containerRef.current; if (!container) return null; @@ -75,6 +106,21 @@ export function Preview( } }, [editorLine, findElementForLine]); + const handleImageClick = useCallback((e: React.MouseEvent) => { + if ((e.target as HTMLElement).tagName === "IMG") { + setZoomedSrc((e.target as HTMLImageElement).src); + } + }, []); + + useEffect(() => { + if (!zoomedSrc) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setZoomedSrc(null); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [zoomedSrc]); + const handleScroll = useCallback(() => { if (!containerRef.current || !onScrollToLine) return; @@ -137,12 +183,26 @@ export function Preview( ); return ( -
-
-
+ <> +
+
+
+ {zoomedSrc && ( +
setZoomedSrc(null)}> + e.stopPropagation()} /> +
+ )} + ); } diff --git a/src/components/Toolbar/Toolbar.tsx b/src/components/Toolbar/Toolbar.tsx index 718469b..c3b8fbb 100644 --- a/src/components/Toolbar/Toolbar.tsx +++ b/src/components/Toolbar/Toolbar.tsx @@ -141,21 +141,10 @@ export function Toolbar( icon: , }); } - if (onInsertImage) { - items.push({ label: "Insert Image", onClick: onInsertImage, icon: }); - } items.push({ divider: true as const }); items.push({ label: "Settings", onClick: handleOpenSettings, icon: }); return items; - }, [ - atProtoSession, - onAtProtoAuth, - onExportPdf, - isExportingPdf, - isPdfExportDisabled, - onInsertImage, - handleOpenSettings, - ]); + }, [atProtoSession, onAtProtoAuth, onExportPdf, isExportingPdf, isPdfExportDisabled, handleOpenSettings]); return (
@@ -182,13 +171,12 @@ export function Toolbar( iconOnly /> )} {onRefresh && } - {hasActiveDocument - ? ( - - - - ) - : null} + {onInsertImage && } + {hasActiveDocument && ( + + + + )}
diff --git a/src/components/export/ExportDialog/ExportDialog.tsx b/src/components/export/ExportDialog/ExportDialog.tsx index f41896e..4e74d02 100644 --- a/src/components/export/ExportDialog/ExportDialog.tsx +++ b/src/components/export/ExportDialog/ExportDialog.tsx @@ -37,6 +37,8 @@ export type ExportDialogProps = { previewResult: PdfRenderResult | null; editorFontFamily: EditorFontFamily; documentText?: string; + locationRootPath?: string; + docRelPath?: string; }; type ExportFormatTabId = ExportFormat | "string"; @@ -160,11 +162,18 @@ type PreviewPaneProps = { previewResult: PdfRenderResult | null; options: PdfExportOptions; editorFontFamily: EditorFontFamily; + locationRootPath?: string; + docRelPath?: string; }; -const PreviewPane = ({ previewResult, options, editorFontFamily }: PreviewPaneProps) => ( +const PreviewPane = ({ previewResult, options, editorFontFamily, locationRootPath, docRelPath }: PreviewPaneProps) => (
- +
); @@ -182,11 +191,22 @@ type PdfExportContentProps = { editorFontFamily: EditorFontFamily; documentText: string; handleExportClick: () => Promise; + locationRootPath?: string; + docRelPath?: string; }; function PdfExportContent( - { onCancel, showPreview, previewResult, options, editorFontFamily, documentText, handleExportClick }: - PdfExportContentProps, + { + onCancel, + showPreview, + previewResult, + options, + editorFontFamily, + documentText, + handleExportClick, + locationRootPath, + docRelPath, + }: PdfExportContentProps, ) { const { pdfExportError: error } = usePdfExportState(); const previewFont = resolvePdfFont(editorFontFamily as FontName, documentText); @@ -203,7 +223,14 @@ function PdfExportContent( showPreview ? "grid grid-cols-[minmax(0,1fr)_minmax(280px,320px)] gap-3" : "flex" }`}> {showPreview - ? + ? ( + + ) : null}
@@ -469,7 +496,9 @@ function StringExportContent({ onCancel, docFilename, documentText }: StringExpo ); } -export function ExportDialog({ onExport, previewResult, editorFontFamily, documentText = "" }: ExportDialogProps) { +export function ExportDialog( + { onExport, previewResult, editorFontFamily, documentText = "", locationRootPath, docRelPath }: ExportDialogProps, +) { const { isOpen, setOpen: setIsOpen, options } = usePdfDialogUiState(); const { resetPdfExport } = usePdfExportActions(); const { resetTextExport } = useTextExportActions(); @@ -577,13 +606,10 @@ export function ExportDialog({ onExport, previewResult, editorFontFamily, docume resetDocxExport(); }, [resetPdfExport, resetTextExport, resetDocxExport]); - const pdfExportProps = useMemo(() => ({ showPreview, previewResult, options, editorFontFamily, documentText }), [ - showPreview, - previewResult, - options, - editorFontFamily, - documentText, - ]); + const pdfExportProps = useMemo( + () => ({ showPreview, previewResult, options, editorFontFamily, documentText, locationRootPath, docRelPath }), + [showPreview, previewResult, options, editorFontFamily, documentText, locationRootPath, docRelPath], + ); return ( ; @@ -73,11 +73,12 @@ const createStyles = ( borderTopColor: "#ddd", color: PDF_MUTED_TEXT_COLOR, }, + image: { maxWidth: "100%", marginBottom: 10 }, }); -type DocumentNodeProps = { node: MarkdownNode; styles: TStyleSheet }; +type DocumentNodeProps = { node: MarkdownNode; styles: TStyleSheet; resolvedImages: Record }; -const DocumentNode = ({ node, styles }: DocumentNodeProps) => { +const DocumentNode = ({ node, styles, resolvedImages }: DocumentNodeProps) => { switch (node.type) { case "heading": { const headingStyle = getHeadingStyle(node.level, styles); @@ -113,6 +114,11 @@ const DocumentNode = ({ node, styles }: DocumentNodeProps) => { [{node.id}] {node.content} ); + case "image": { + const src = resolvedImages[node.src]; + if (!src) return null; + return ; + } default: return null; } @@ -124,6 +130,7 @@ type MarkdownPdfDocumentProps = { options: PdfExportOptions; editorFontFamily: FontName; useBuiltinFonts?: boolean; + resolvedImages?: Record; }; const DocumentTitle = ( @@ -139,19 +146,20 @@ const DocumentTitle = ( : null; const DocumentBody = ( - { nodes, title, styles, options, hasHeadingOne }: { + { nodes, title, styles, options, hasHeadingOne, resolvedImages }: { nodes: MarkdownNode[]; title?: string; styles: ReturnType; options: PdfExportOptions; hasHeadingOne: boolean; + resolvedImages: Record; }, ) => ( {nodes.map((node, index) => { const k = `${index}`; - return ; + return ; })} ); @@ -168,7 +176,7 @@ const DocumentFooter = ( ) => ; export const MarkdownPdfDocument = ( - { nodes, title, options, editorFontFamily, useBuiltinFonts = false }: MarkdownPdfDocumentProps, + { nodes, title, options, editorFontFamily, useBuiltinFonts = false, resolvedImages = {} }: MarkdownPdfDocumentProps, ) => { const fontStrategy = useBuiltinFonts ? "builtin" : "custom"; const bodyFont = getPdfFontFamily(editorFontFamily, fontStrategy); @@ -185,7 +193,13 @@ export const MarkdownPdfDocument = ( {showHeader && } - + {options.includeFooter && } diff --git a/src/components/export/preview/PdfPreview.tsx b/src/components/export/preview/PdfPreview.tsx index dae47be..df79df9 100644 --- a/src/components/export/preview/PdfPreview.tsx +++ b/src/components/export/preview/PdfPreview.tsx @@ -1,7 +1,8 @@ import { MarkdownPdfDocument } from "$components/export/MarkdownPdfDocument"; import { PDFError } from "$pdf/errors"; import { ensurePdfFontRegistered } from "$pdf/fonts"; -import type { FontStrategy, PdfExportOptions, PdfRenderResult } from "$pdf/types"; +import { preloadPdfImages } from "$pdf/images"; +import type { FontStrategy, MarkdownNode, PdfExportOptions, PdfRenderResult } from "$pdf/types"; import type { EditorFontFamily } from "$types"; import { f } from "$utils/serialize"; import { pdf } from "@react-pdf/renderer"; @@ -23,12 +24,16 @@ type UsePdfPreviewArgs = { result: PdfRenderResult | null; options: PdfExportOptions; editorFontFamily: EditorFontFamily; + locationRootPath?: string; + docRelPath?: string; }; export type PdfPreviewPanelProps = { result: PdfRenderResult | null; options: PdfExportOptions; editorFontFamily: EditorFontFamily; + locationRootPath?: string; + docRelPath?: string; }; type FitMode = "page" | "width"; @@ -47,7 +52,7 @@ const clamp = (value: number, min: number, max: number) => Math.min(Math.max(val const getErrorMessage = (error: unknown) => error instanceof Error ? error.message : "Failed to generate preview"; -export function usePdfPreview({ result, options, editorFontFamily }: UsePdfPreviewArgs) { +export function usePdfPreview({ result, options, editorFontFamily, locationRootPath, docRelPath }: UsePdfPreviewArgs) { const [state, setState] = useState({ status: "idle" }); const abortControllerRef = useRef(null); const currentPdfDocRef = useRef(null); @@ -60,6 +65,10 @@ export function usePdfPreview({ result, options, editorFontFamily }: UsePdfPrevi } }, []); + const resolveImages = useCallback(async (nodes: MarkdownNode[]) => { + return docRelPath && locationRootPath ? await preloadPdfImages(nodes, locationRootPath, docRelPath) : {}; + }, [docRelPath, locationRootPath]); + const renderPdfBlob = useCallback( async ( pdfResult: PdfRenderResult, @@ -67,6 +76,7 @@ export function usePdfPreview({ result, options, editorFontFamily }: UsePdfPrevi fontFamily: EditorFontFamily, strategy: FontStrategy, signal: AbortSignal, + images: Record, ): Promise => { if (signal.aborted) { throw new Error("Preview generation aborted"); @@ -81,13 +91,18 @@ export function usePdfPreview({ result, options, editorFontFamily }: UsePdfPrevi throw new Error("Preview generation aborted"); } + if (signal.aborted) { + throw new Error("Preview generation aborted"); + } + const blob = await pdf( , + useBuiltinFonts={strategy === "builtin"} + resolvedImages={images} />, ).toBlob(); logger.debug(f("PDF preview render attempt completed", { strategy, outputSizeBytes: blob.size })); @@ -115,7 +130,8 @@ export function usePdfPreview({ result, options, editorFontFamily }: UsePdfPrevi const strategy: FontStrategy = attempt === 0 ? "custom" : "builtin"; try { - blob = await renderPdfBlob(result, options, editorFontFamily, strategy, signal); + const images = await resolveImages(result.nodes); + blob = await renderPdfBlob(result, options, editorFontFamily, strategy, signal, images); strategyUsed = strategy; break; } catch (error) { @@ -173,7 +189,7 @@ export function usePdfPreview({ result, options, editorFontFamily }: UsePdfPrevi destroyCurrentPdfDoc(); setState({ status: "error", message: getErrorMessage(error) }); } - }, [destroyCurrentPdfDoc, editorFontFamily, options, renderPdfBlob, result]); + }, [destroyCurrentPdfDoc, editorFontFamily, options, renderPdfBlob, result, resolveImages]); useEffect(() => { abortControllerRef.current?.abort(); @@ -455,7 +471,7 @@ function MultiPageCanvas( return (
-
+
{pageNumbers.map((pageNumber, index) => (
- - - {zoomPercent}% - + {zoomPercent}%