diff --git a/crates/markdown/src/lib.rs b/crates/markdown/src/lib.rs index a2dba2b..4332a1f 100644 --- a/crates/markdown/src/lib.rs +++ b/crates/markdown/src/lib.rs @@ -189,7 +189,7 @@ pub enum PdfNode { /// Code block with optional language Code { content: String, language: Option }, /// List with items and ordering flag - List { items: Vec, ordered: bool }, + List { items: Vec, ordered: bool }, /// Blockquote content Blockquote { content: String }, /// Footnote with id and content @@ -234,8 +234,8 @@ pub struct DocxExportResult { /// A list item for PDF export #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PdfListItem { - /// Content of the list item (typically a paragraph) - pub content: String, + /// Content nodes within the list item + pub content: Vec, } /// Options for HTML export @@ -658,8 +658,37 @@ mod tests { let (items, ordered) = list.unwrap(); assert!(!ordered); assert_eq!(items.len(), 2); - assert!(matches!(items[0], PdfNode::Paragraph { ref content } if content == "One")); - assert!(matches!(items[1], PdfNode::Paragraph { ref content } if content == "Two")); + assert!(matches!(items[0].content[0], PdfNode::Paragraph { ref content } if content == "One")); + assert!(matches!(items[1].content[0], PdfNode::Paragraph { ref content } if content == "Two")); + } + + #[test] + fn test_render_for_pdf_preserves_images_inside_list_items() { + let engine = MarkdownEngine::new(); + let markdown = "- Before ![inline](images/photo.png) after"; + let result = engine.render_for_pdf(markdown, MarkdownProfile::GfmSafe).unwrap(); + + let list = result.nodes.iter().find_map(|node| match node { + PdfNode::List { items, ordered } => Some((items, ordered)), + _ => None, + }); + + assert!(list.is_some()); + let (items, ordered) = list.unwrap(); + assert!(!ordered); + assert_eq!(items.len(), 1); + assert!( + items[0] + .content + .iter() + .any(|node| matches!(node, PdfNode::Image { src, .. } if src == "images/photo.png")) + ); + assert!( + items[0] + .content + .iter() + .any(|node| matches!(node, PdfNode::Paragraph { content } if content.contains("Before"))) + ); } #[test] diff --git a/crates/markdown/src/transformer.rs b/crates/markdown/src/transformer.rs index b98a29d..1830e28 100644 --- a/crates/markdown/src/transformer.rs +++ b/crates/markdown/src/transformer.rs @@ -1,4 +1,4 @@ -use super::PdfNode; +use super::{PdfListItem, PdfNode}; use comrak::nodes::NodeValue; pub struct MarkdownTransformer; @@ -122,15 +122,15 @@ impl MarkdownTransformer { } /// Transforms list items from a list node - fn transform_list_items<'a>(list_node: &'a comrak::nodes::AstNode<'a>, _ordered: bool) -> Vec { + fn transform_list_items<'a>(list_node: &'a comrak::nodes::AstNode<'a>, _ordered: bool) -> Vec { let mut items = Vec::new(); for child in list_node.children() { match &child.data.borrow().value { comrak::nodes::NodeValue::Item(_) => { - let content = Self::extract_text_content(child); + let content = Self::transform_to_pdf_nodes(child); if !content.is_empty() { - items.push(PdfNode::Paragraph { content }); + items.push(PdfListItem { content }); } } _ => items.extend(Self::transform_list_items(child, _ordered)), diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index f977d6f..882f5c9 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -11,7 +11,7 @@ use writer_core::{ AppError, DocContent, DocId, DocListOptions, DocMeta, DocSortField, Encoding, ErrorCode, LineEnding, LocationDescriptor, LocationId, SavePolicy, SaveResult, SearchFilters, SearchHit, SortOrder, }; -use writer_core::{is_conflicted_filename, normalize_relative_path}; +use writer_core::{is_conflicted_filename, is_path_within_location, normalize_relative_path}; use writer_md::{MarkdownEngine, MarkdownProfile}; mod file_utils; @@ -2347,6 +2347,38 @@ impl Store { Ok(true) } + /// Resolves a local markdown asset reference against a document path. + /// + /// The returned path is validated to stay within the active location root. + pub fn asset_resolve( + &self, location_id: LocationId, doc_rel_path: &Path, asset_path: &str, + ) -> Result { + let location = self + .location_get(location_id)? + .ok_or_else(|| AppError::not_found(format!("Location not found: {:?}", location_id)))?; + let doc_id = DocId::new(location_id, doc_rel_path.to_path_buf())?; + + let trimmed_asset_path = asset_path.trim(); + if trimmed_asset_path.is_empty() { + return Err(AppError::invalid_path("Asset path is empty")); + } + + let doc_parent = doc_id.rel_path.parent().unwrap_or(Path::new("")); + let combined = doc_parent.join(trimmed_asset_path); + let normalized = normalize_relative_path(&combined)?; + let resolved = location.root_path.join(&normalized); + + if !is_path_within_location(&resolved, &location.root_path) { + return Err(AppError::invalid_path("Resolved asset path escaped the location root")); + } + + if !resolved.exists() { + return Err(AppError::not_found(format!("Asset not found: {}", trimmed_asset_path))); + } + + Ok(resolved) + } + /// Lists all image assets in `.writer-assets/` for the given location. /// /// Returns an empty vec if the directory does not exist yet. @@ -2453,6 +2485,9 @@ mod tests { fn write_test_image(dir: &std::path::Path, name: &str, bytes: &[u8]) -> PathBuf { let path = dir.join(name); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } std::fs::write(&path, bytes).unwrap(); path } @@ -3468,6 +3503,60 @@ mod tests { assert_eq!(result.unwrap_err().code, ErrorCode::InvalidPath); } + #[test] + fn test_asset_resolve_root_relative_asset() { + let (store, _tmp) = create_test_store(); + let (location_id, location_dir) = create_test_location(&store); + let expected = write_test_image(location_dir.path(), ".writer-assets/photo.png", b"\x89PNG"); + + let resolved = store + .asset_resolve(location_id, Path::new("doc.md"), ".writer-assets/photo.png") + .unwrap(); + + assert_eq!(resolved, expected); + } + + #[test] + fn test_asset_resolve_document_relative_asset() { + let (store, _tmp) = create_test_store(); + let (location_id, location_dir) = create_test_location(&store); + let expected = write_test_image(location_dir.path(), "images/cover.png", b"\x89PNG"); + + let resolved = store + .asset_resolve( + location_id, + Path::new("drafts/chapter/doc.md"), + "../../images/cover.png", + ) + .unwrap(); + + assert_eq!(resolved, expected); + } + + #[test] + fn test_asset_resolve_rejects_traversal() { + let (store, _tmp) = create_test_store(); + let (location_id, _location_dir) = create_test_location(&store); + + let error = store + .asset_resolve(location_id, Path::new("drafts/doc.md"), "../../../etc/passwd") + .unwrap_err(); + + assert_eq!(error.code, ErrorCode::InvalidPath); + } + + #[test] + fn test_asset_resolve_rejects_missing_files() { + let (store, _tmp) = create_test_store(); + let (location_id, _location_dir) = create_test_location(&store); + + let error = store + .asset_resolve(location_id, Path::new("doc.md"), ".writer-assets/missing.png") + .unwrap_err(); + + assert_eq!(error.code, ErrorCode::NotFound); + } + #[test] fn test_image_list_empty() { let (store, _tmp) = create_test_store(); diff --git a/docs/export.md b/docs/export.md index aed29c7..6aec1cf 100644 --- a/docs/export.md +++ b/docs/export.md @@ -1,6 +1,6 @@ --- title: "Exporting" -last_updated: 2026-03-13 +last_updated: 2026-03-24 --- ## Overview @@ -29,6 +29,7 @@ Implementation: - Runtime hook: `src/hooks/usePdfExport.tsx` - Preview panel: `src/components/export/preview/PdfPreview.tsx` - PDF document renderer: `src/components/export/MarkdownPdfDocument.tsx` +- Local asset resolver: `src/utils/assets.ts` + backend `asset_resolve` ### DOCX @@ -83,6 +84,7 @@ These stores track in-flight export state and user-visible error messages. - No file is written until the user picks a destination path. - Canceling the save dialog exits cleanly without side effects. - PDF export retries with built-in fonts if custom font registration fails. +- PDF preview/export resolve local markdown image paths through a shared backend-validated asset pipeline before rasterization. - DOCX and plaintext failures surface through export state and toasts. ## File Naming diff --git a/docs/pdf.md b/docs/pdf.md index ad125b8..6979823 100644 --- a/docs/pdf.md +++ b/docs/pdf.md @@ -1,6 +1,6 @@ --- title: "PDF Exporting" -last_updated: 2026-03-13 +last_updated: 2026-03-24 --- For the full multi-format export flow, see [Exporting](./export.md). @@ -18,11 +18,13 @@ PDF export is split into two stages: - Runtime export hook: `src/hooks/usePdfExport.tsx`. - Document renderer: PDF components under `src/components/export/`. - PDF preview: `src/components/export/preview/PdfPreview.tsx` (uses `pdfjs-dist` with zoom, fit mode, and page navigation). +- Local image hydration: `src/pdf/images.ts` resolves location-scoped markdown asset paths, fetches raster images through Tauri asset URLs, and converts SVGs through the backend before handing them to `@react-pdf/renderer`. ## Backend Flow - Command handler lives in `src-tauri/src/commands.rs`. - Markdown conversion is provided by `crates/markdown`. +- Asset resolution is validated in Tauri/store (`asset_resolve`) so preview and PDF export share the same location-scoped path rules. ## Export Options @@ -35,3 +37,5 @@ PDF export state is tracked in a dedicated Zustand store slice (`isExportingPdf` - User cancel exits cleanly without writes. - Export failures are surfaced through export error state. - Font fallback handling is implemented in `src/pdf/fonts.ts` — custom font fetch failures automatically fall back to builtin fonts. +- Missing or invalid local images are skipped per-image with debug logging instead of aborting the whole preview/export. +- Images inside markdown list items are preserved in the PDF AST and renderer instead of being flattened into plain text. diff --git a/docs/specs/image-handling.md b/docs/specs/image-handling.md index 1453f3c..a1e706b 100644 --- a/docs/specs/image-handling.md +++ b/docs/specs/image-handling.md @@ -1,6 +1,6 @@ --- title: Image Handling Spec -updated: 2026-03-21 +updated: 2026-03-24 --- > Goal: Support local image embedding in markdown documents with storage, preview, lifecycle management, AT Protocol blob sync, and PDF export. @@ -76,6 +76,17 @@ pub fn image_list(location_id: LocationId) -> Result, Error> - Returns all images in `.writer-assets/` with metadata (filename, size, dimensions if cheaply available). +#### `asset_resolve` + +```rust +#[tauri::command] +pub fn asset_resolve(location_id: LocationId, doc_rel_path: PathBuf, asset_path: String) -> Result +``` + +- Resolves a markdown-local path against the source document's directory. +- Rejects traversal outside the location root. +- Returns an absolute location-scoped path for preview/export consumers. + ### Frontend #### Editor Integration @@ -86,8 +97,10 @@ 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. +- The markdown preview resolves any local markdown image or file link that stays within the active location root, not just `.writer-assets/` imports. +- Resolution happens through `asset_resolve`, then image URLs are converted to Tauri `asset:` URLs for display. - Images render inline with `max-width: 100%` and click-to-zoom. +- Local file links open through the system opener instead of navigating the webview. #### State @@ -195,9 +208,10 @@ Update `MarkdownTransformer::transform_to_pdf_nodes()` to emit `PdfNode::Image` The `@react-pdf/renderer` `` component accepts a `src` that can be a URL, a file path, or a base64 data URL. Since Tauri asset protocol URLs may not work inside the PDF renderer's internal fetch: -- Resolve `.writer-assets/` paths to **base64 data URLs** before passing to the renderer. -- Use `convertFileSrc()` to get the Tauri asset URL, fetch the bytes via the webview, then encode to `data:;base64,...`. +- Resolve any location-scoped local image path to **base64 data URLs** before passing to the renderer. +- Use `asset_resolve` to validate and normalize the path first, then `convertFileSrc()` to fetch raster bytes via the webview. - This is similar to the font preloading strategy already in `src/pdf/fonts.ts`. +- SVG images are routed through `svg_to_png(location_id, doc_rel_path, asset_path)` so the backend performs the path resolution and rasterization in one scoped flow. #### Frontend: MarkdownPdfDocument Rendering @@ -210,6 +224,7 @@ case "Image": - Respect page margins — images should not overflow the content area. - Preserve aspect ratio. +- Preserve images inside list items by keeping list item content as nested PDF nodes instead of flattening it to text. #### Limitations diff --git a/src-tauri/src/commands/images.rs b/src-tauri/src/commands/images.rs index 7f270c4..0e91c76 100644 --- a/src-tauri/src/commands/images.rs +++ b/src-tauri/src/commands/images.rs @@ -51,13 +51,60 @@ 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. +/// Resolves a local markdown asset reference to an absolute path within the location root. +#[tauri::command] +pub fn asset_resolve( + state: State<'_, AppState>, location_id: i64, doc_rel_path: String, asset_path: String, +) -> CommandResponse { + let location_id = LocationId(location_id); + let doc_rel_path = PathBuf::from(&doc_rel_path); + + log::debug!( + "asset_resolve: location={:?}, doc_rel_path={:?}, asset_path={}", + location_id, + doc_rel_path, + asset_path + ); + + match state.store.asset_resolve(location_id, &doc_rel_path, &asset_path) { + Ok(resolved_path) => { + log::debug!("asset_resolve: ok → {}", resolved_path.display()); + Ok(CommandResult::ok(resolved_path.to_string_lossy().to_string())) + } + Err(e) => { + log::error!("asset_resolve failed: {}", e); + Ok(CommandResult::err(e)) + } + } +} + +/// Converts a location-scoped SVG asset to a PNG data URL. /// -/// Delegates to `writer_store::Store::svg_to_png`. Returns `data:image/png;base64,...`. +/// Resolves the asset path relative to the source document, then 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)) { +pub fn svg_to_png( + state: State<'_, AppState>, location_id: i64, doc_rel_path: String, asset_path: String, +) -> CommandResponse { + let location_id = LocationId(location_id); + let doc_rel_path = PathBuf::from(&doc_rel_path); + + log::debug!( + "svg_to_png: location={:?}, doc_rel_path={:?}, asset_path={}", + location_id, + doc_rel_path, + asset_path + ); + + let resolved_path = match state.store.asset_resolve(location_id, &doc_rel_path, &asset_path) { + Ok(path) => path, + Err(e) => { + log::error!("svg_to_png resolve failed: {}", e); + return Ok(CommandResult::err(e)); + } + }; + + match state.store.svg_to_png(&resolved_path) { Ok(data_url) => Ok(CommandResult::ok(data_url)), Err(e) => { log::error!("svg_to_png failed: {}", e); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 91e5236..144e5a9 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::asset_resolve, cmd::svg_to_png, ]) .run(tauri::generate_context!()) diff --git a/src/App.tsx b/src/App.tsx index a2591c8..527d9f9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,7 +23,7 @@ const AppContent = ({ isFocusMode, view }: { isFocusMode: boolean; view: Workspa focusModePanelProps, handleExportPdf, previewResult, - activeDocLocationRootPath, + activeDocLocationId, activeDocRelPath, editorFontFamily, editorText, @@ -69,7 +69,7 @@ const AppContent = ({ isFocusMode, view }: { isFocusMode: boolean; view: Workspa previewResult={previewResult} editorFontFamily={editorFontFamily} documentText={editorText} - locationRootPath={activeDocLocationRootPath} + locationId={activeDocLocationId} docRelPath={activeDocRelPath} /> diff --git a/src/__tests__/Preview.test.tsx b/src/__tests__/Preview.test.tsx index 42c6eee..733cc47 100644 --- a/src/__tests__/Preview.test.tsx +++ b/src/__tests__/Preview.test.tsx @@ -1,7 +1,9 @@ -import { Preview, resolveAssetSrc } from "$components/Preview"; +import { Preview } from "$components/Preview"; import type { RenderResult } from "$types"; -import { convertFileSrc } from "@tauri-apps/api/core"; -import { render, screen } from "@testing-library/react"; +import { invoke } from "@tauri-apps/api/core"; +import { openPath, openUrl } from "@tauri-apps/plugin-opener"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; const makeRenderResult = (html: string): RenderResult => ({ @@ -14,115 +16,128 @@ const defaultProps = { editorLine: 1, previewStyle: "github" as const, editorFontFamily: "IBM Plex Mono" as const, + locationId: 7, + docRelPath: "doc.md", }; -describe("resolveAssetSrc", () => { - it("resolves a root-level asset path", () => { - expect(resolveAssetSrc("/root", "doc.md", ".writer-assets/img.png")).toBe("/root/.writer-assets/img.png"); - }); +const normalize = (value: string) => { + const segments = value.split("/"); + const normalized: string[] = []; + for (const segment of segments) { + if (!segment || segment === ".") continue; + if (segment === "..") { + normalized.pop(); + continue; + } + normalized.push(segment); + } + return `/${normalized.join("/")}`; +}; - 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"); - }); +describe("Preview asset resolution", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(invoke).mockImplementation(async (command, payload) => { + if (command !== "asset_resolve") { + return await Promise.resolve(null); + } - it("resolves a deeply nested document", () => { - expect(resolveAssetSrc("/root", "a/b/doc.md", "../../.writer-assets/img.png")).toBe("/root/.writer-assets/img.png"); - }); + const { docRelPath, assetPath } = payload as { docRelPath: string; assetPath: string }; + const docDir = docRelPath.includes("/") ? docRelPath.split("/").slice(0, -1).join("/") : ""; - 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", - ); - }); -}); + if (assetPath === ".writer-assets/missing.png") { + throw new Error("missing"); + } -describe("Preview image resolution", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(convertFileSrc).mockImplementation((path: string) => `asset://localhost${path}`); + return normalize(`/root/${docDir ? `${docDir}/` : ""}${assetPath}`); + }); }); - it("rewrites .writer-assets/ img src to convertFileSrc URL", () => { - const html = `test`; + it("rewrites local image src values through the asset resolver", async () => { render( , + renderResult={makeRenderResult(`test`)} />, ); 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"); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith("asset_resolve", { + locationId: 7, + docRelPath: "doc.md", + assetPath: ".writer-assets/abc123.png", + }); + expect(img).toHaveAttribute("src", "asset://localhost/root/.writer-assets/abc123.png"); + }); }); - it("rewrites relative asset paths from subdirectory documents", () => { - const html = `sub`; + it("rewrites relative local image paths from subdirectory documents", async () => { render( , + docRelPath="drafts/doc.md" + renderResult={makeRenderResult(`cover`)} />, ); - 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"); + const img = screen.getByRole("img", { name: "cover" }); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith("asset_resolve", { + locationId: 7, + docRelPath: "drafts/doc.md", + assetPath: "../images/cover.png", + }); + expect(img).toHaveAttribute("src", "asset://localhost/root/images/cover.png"); + }); }); - it("does not rewrite external http URLs", () => { - const html = `ext`; + it("does not rewrite external image URLs", async () => { render( - , + `)} />, ); const img = screen.getByRole("img", { name: "ext" }); - expect(convertFileSrc).not.toHaveBeenCalled(); - expect(img).toHaveAttribute("src", "https://example.com/image.png"); + await waitFor(() => { + expect(img).toHaveAttribute("src", "https://example.com/image.png"); + }); + expect(invoke).not.toHaveBeenCalled(); }); - 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("opens resolved local file links with the system opener", async () => { + const user = userEvent.setup(); + render(Open report`)} />); - it("does not rewrite anything when locationRootPath is not provided", () => { - const html = `no-path`; - render(); + const link = screen.getByRole("link", { name: "Open report" }); + await user.click(link); - const img = screen.getByRole("img", { name: "no-path" }); - expect(convertFileSrc).not.toHaveBeenCalled(); - expect(img).toHaveAttribute("src", ".writer-assets/img.png"); + await waitFor(() => { + expect(openPath).toHaveBeenCalledWith("/root/files/report.pdf"); + }); }); - it("re-resolves images when renderResult changes", () => { - const { rerender } = render( - `)} - locationRootPath="/root" - docRelPath="doc.md" />, - ); + it("opens external links with the system opener instead of navigating the webview", async () => { + const user = userEvent.setup(); + render(External`)} />); - expect(convertFileSrc).toHaveBeenCalledWith("/root/.writer-assets/a.png"); - vi.clearAllMocks(); + await user.click(screen.getByRole("link", { name: "External" })); - rerender( + await waitFor(() => { + expect(openUrl).toHaveBeenCalledWith("https://example.com"); + }); + }); + + it("leaves missing local images unresolved", async () => { + render( `)} - locationRootPath="/root" - docRelPath="doc.md" />, + renderResult={makeRenderResult(`missing`)} />, ); - expect(convertFileSrc).toHaveBeenCalledWith("/root/.writer-assets/b.png"); + const img = screen.getByRole("img", { name: "missing" }); + await waitFor(() => { + expect(invoke).toHaveBeenCalled(); + }); + expect(img).toHaveAttribute("src", ".writer-assets/missing.png"); }); }); diff --git a/src/__tests__/pdfImages.test.ts b/src/__tests__/pdfImages.test.ts index 3cd79f2..89b8d27 100644 --- a/src/__tests__/pdfImages.test.ts +++ b/src/__tests__/pdfImages.test.ts @@ -1,49 +1,55 @@ -import { preloadPdfImages, resolveAssetSrc } from "$pdf/images"; +import { preloadPdfImages } 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"; + const locationId = 42; + const docRelPath = "drafts/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.mocked(invoke).mockImplementation(async (command, payload) => { + if (command === "asset_resolve") { + const { assetPath } = payload as { assetPath: string }; + if (assetPath.includes("missing")) { + throw new Error("missing"); + } + + switch (assetPath) { + case ".writer-assets/abc123.png": + return "/root/.writer-assets/abc123.png"; + case ".writer-assets/img.png": + return "/root/.writer-assets/img.png"; + case ".writer-assets/diagram.svg": + return "/root/.writer-assets/diagram.svg"; + case "images/photo.png": + return "/root/drafts/images/photo.png"; + case "images/list-img.jpg": + return "/root/drafts/images/list-img.jpg"; + default: + return `/root/${assetPath}`; + } + } + + if (command === "svg_to_png") { + return { type: "ok", value: "data:image/png;base64,c3Zn" }; + } + + return await Promise.resolve(null); + }); vi.stubGlobal( "fetch", vi.fn(async (url: string) => { if (url.includes("missing")) { - return { ok: false, status: 404 } as Response; + return await Promise.resolve({ 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); - }); + return await Promise.resolve( + { ok: true, arrayBuffer: async () => await Promise.resolve(bytes.buffer) } as unknown as Response, + ); }), ); }); @@ -53,58 +59,60 @@ describe("preloadPdfImages", () => { type: "paragraph", content: "World", }]; - const result = await preloadPdfImages(nodes, locationRootPath, docRelPath); + const result = await preloadPdfImages(nodes, locationId, docRelPath); expect(result).toEqual({}); }); - it("resolves a .writer-assets/ image to a base64 data URL", async () => { + it("resolves a local 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`); + const result = await preloadPdfImages(nodes, locationId, docRelPath); + expect(convertFileSrc).toHaveBeenCalledWith("/root/.writer-assets/abc123.png"); expect(result[".writer-assets/abc123.png"]).toMatch(/^data:image\/png;base64,/); }); - it("deduplicates the same image referenced multiple times", async () => { + it("supports non-.writer-assets local image paths", async () => { + const nodes: MarkdownNode[] = [{ type: "image", src: "images/photo.png", alt: "photo" }]; + const result = await preloadPdfImages(nodes, locationId, docRelPath); + expect(convertFileSrc).toHaveBeenCalledWith("/root/drafts/images/photo.png"); + expect(result["images/photo.png"]).toMatch(/^data:image\/png;base64,/); + }); + + it("deduplicates repeated image references", 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); + const result = await preloadPdfImages(nodes, locationId, docRelPath); expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1); expect(result[".writer-assets/img.png"]).toBeDefined(); }); - it("gracefully omits images that fail to fetch", async () => { + it("gracefully omits images that fail to resolve or fetch", async () => { const nodes: MarkdownNode[] = [{ type: "image", src: ".writer-assets/missing.png", alt: "broken" }]; - const result = await preloadPdfImages(nodes, locationRootPath, docRelPath); + const result = await preloadPdfImages(nodes, locationId, docRelPath); expect(result[".writer-assets/missing.png"]).toBeUndefined(); }); - it("converts SVG images via svg_to_png backend command", async () => { + it("converts SVG images via the backend command", async () => { const nodes: MarkdownNode[] = [{ type: "image", src: ".writer-assets/diagram.svg", alt: "svg" }]; - const result = await preloadPdfImages(nodes, locationRootPath, docRelPath); + const result = await preloadPdfImages(nodes, locationId, docRelPath); expect(vi.mocked(invoke)).toHaveBeenCalledWith("svg_to_png", { - absolutePath: `${locationRootPath}/.writer-assets/diagram.svg`, + locationId, + docRelPath, + assetPath: ".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" }], + items: [{ content: [{ type: "image", src: "images/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,/); + const result = await preloadPdfImages(nodes, locationId, docRelPath); + expect(result["images/list-img.jpg"]).toMatch(/^data:image\/jpeg;base64,/); }); }); diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts index 5e43f0d..b706d29 100644 --- a/src/__tests__/setup.ts +++ b/src/__tests__/setup.ts @@ -55,6 +55,7 @@ vi.mock( ); vi.mock("@tauri-apps/plugin-fs", () => ({ readTextFile: vi.fn(() => "") })); +vi.mock("@tauri-apps/plugin-opener", () => ({ openPath: vi.fn(async () => {}), openUrl: vi.fn(async () => {}) })); vi.mock( "$state/stores/toasts", diff --git a/src/components/AppLayout/WorkspacePanel.tsx b/src/components/AppLayout/WorkspacePanel.tsx index beba811..85cfd30 100644 --- a/src/components/AppLayout/WorkspacePanel.tsx +++ b/src/components/AppLayout/WorkspacePanel.tsx @@ -43,9 +43,9 @@ type PK = | "editorLine" | "previewStyle" | "editorFontFamily" - | "onScrollToLine" - | "locationRootPath" - | "docRelPath"; + | "locationId" + | "docRelPath" + | "onScrollToLine"; export type WorkspacePreviewProps = Pick; export type WorkspaceDiagnosticsProps = { diff --git a/src/components/Preview.tsx b/src/components/Preview.tsx index 85e7c3c..430acf2 100644 --- a/src/components/Preview.tsx +++ b/src/components/Preview.tsx @@ -1,10 +1,15 @@ -import { resolveAssetSrc } from "$pdf/images"; import type { AppTheme, EditorFontFamily, MarkdownPreviewStyle, RenderResult } from "$types"; -import { convertFileSrc } from "@tauri-apps/api/core"; +import { + isExternalAssetReference, + isResolvableLocalAssetReference, + logAssetResolutionFailure, + resolveAssetPath, + resolveAssetUrl, +} from "$utils/assets"; +import * as logger from "@tauri-apps/plugin-log"; +import { openPath, openUrl } from "@tauri-apps/plugin-opener"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { CSSProperties } from "react"; - -export { resolveAssetSrc }; +import type { CSSProperties, MouseEventHandler } from "react"; export type PreviewProps = { renderResult: RenderResult | null; @@ -12,10 +17,10 @@ export type PreviewProps = { editorLine: number; previewStyle: MarkdownPreviewStyle; editorFontFamily: EditorFontFamily; + locationId?: number; + docRelPath?: string; onScrollToLine?: (line: number) => void; className?: string; - locationRootPath?: string; - docRelPath?: string; }; const PDF_PREVIEW_FONT_MAP: Record = { @@ -38,10 +43,10 @@ export function Preview( editorLine, previewStyle, editorFontFamily, + locationId, + docRelPath, onScrollToLine, className = "", - locationRootPath, - docRelPath, }: PreviewProps, ) { const containerRef = useRef(null); @@ -52,18 +57,50 @@ export function Preview( const previewContent = useMemo(() => ({ __html: renderResult?.html ?? "" }), [renderResult]); useEffect(() => { - if (!containerRef.current || !locationRootPath) return; + if (!containerRef.current || locationId === undefined || !docRelPath) 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 container = containerRef.current; + let isCancelled = false; - const absolutePath = resolveAssetSrc(locationRootPath, docRelPath ?? "", src); - img.src = convertFileSrc(absolutePath); - } - }, [renderResult, locationRootPath, docRelPath]); + const resolvePreviewAssets = async () => { + const images = [...container.querySelectorAll("img[src]")]; + const links = [...container.querySelectorAll("a[href]")]; + + await Promise.allSettled(images.map(async (img) => { + const src = img.getAttribute("src"); + if (!src || !isResolvableLocalAssetReference(src)) return; + + try { + const resolvedUrl = await resolveAssetUrl(locationId, docRelPath, src); + if (!isCancelled) { + img.src = resolvedUrl; + } + } catch (error) { + logAssetResolutionFailure("preview image", src, error); + } + })); + + await Promise.allSettled(links.map(async (link) => { + const href = link.getAttribute("href"); + if (!href || !isResolvableLocalAssetReference(href)) return; + + try { + const resolvedPath = await resolveAssetPath(locationId, docRelPath, href); + if (!isCancelled) { + link.dataset.localAssetPath = resolvedPath; + } + } catch (error) { + logAssetResolutionFailure("preview link", href, error); + } + })); + }; + + void resolvePreviewAssets(); + + return () => { + isCancelled = true; + }; + }, [docRelPath, locationId, renderResult]); const findElementForLine = useCallback((line: number): HTMLElement | null => { const container = containerRef.current; @@ -106,19 +143,56 @@ export function Preview( } }, [editorLine, findElementForLine]); - const handleImageClick = useCallback((e: React.MouseEvent) => { - if ((e.target as HTMLElement).tagName === "IMG") { - setZoomedSrc((e.target as HTMLImageElement).src); + const handleContentClick = useCallback((e: React.MouseEvent) => { + const target = e.target as HTMLElement; + const link = target.closest("a"); + if (link) { + const localAssetPath = link.dataset.localAssetPath; + const href = link.getAttribute("href"); + + if (localAssetPath) { + e.preventDefault(); + void openPath(localAssetPath).catch((error) => { + void logger.warn( + `Failed to open local preview asset: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + return; + } + + if (href && isExternalAssetReference(href)) { + e.preventDefault(); + void openUrl(href).catch((error) => { + void logger.warn(`Failed to open preview link: ${error instanceof Error ? error.message : String(error)}`); + }); + return; + } + + if (href && locationId !== undefined && docRelPath && isResolvableLocalAssetReference(href)) { + e.preventDefault(); + void resolveAssetPath(locationId, docRelPath, href).then((resolvedPath) => openPath(resolvedPath)).catch( + (error) => { + logAssetResolutionFailure("preview link", href, error); + }, + ); + return; + } } - }, []); + + const image = target.closest("img"); + if (image) { + setZoomedSrc((image as HTMLImageElement).src); + } + }, [docRelPath, locationId]); useEffect(() => { if (!zoomedSrc) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setZoomedSrc(null); }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); + + globalThis.addEventListener("keydown", onKey); + return () => globalThis.removeEventListener("keydown", onKey); }, [zoomedSrc]); const handleScroll = useCallback(() => { @@ -182,12 +256,22 @@ export function Preview( [previewStyle, editorFontFamily], ); + const handleWrapperClick = useCallback(() => { + if (zoomedSrc) { + setZoomedSrc(null); + } + }, [zoomedSrc]); + + const handleImageClick: MouseEventHandler = useCallback((e) => { + e.stopPropagation(); + }, []); + return ( <>
@@ -195,12 +279,12 @@ export function Preview( {zoomedSrc && (
setZoomedSrc(null)}> + onClick={handleWrapperClick}> e.stopPropagation()} /> + onClick={handleImageClick} />
)} diff --git a/src/components/export/ExportDialog/ExportDialog.tsx b/src/components/export/ExportDialog/ExportDialog.tsx index 4e74d02..592b56f 100644 --- a/src/components/export/ExportDialog/ExportDialog.tsx +++ b/src/components/export/ExportDialog/ExportDialog.tsx @@ -37,7 +37,7 @@ export type ExportDialogProps = { previewResult: PdfRenderResult | null; editorFontFamily: EditorFontFamily; documentText?: string; - locationRootPath?: string; + locationId?: number; docRelPath?: string; }; @@ -162,17 +162,17 @@ type PreviewPaneProps = { previewResult: PdfRenderResult | null; options: PdfExportOptions; editorFontFamily: EditorFontFamily; - locationRootPath?: string; + locationId?: number; docRelPath?: string; }; -const PreviewPane = ({ previewResult, options, editorFontFamily, locationRootPath, docRelPath }: PreviewPaneProps) => ( +const PreviewPane = ({ previewResult, options, editorFontFamily, locationId, docRelPath }: PreviewPaneProps) => (
); @@ -191,7 +191,7 @@ type PdfExportContentProps = { editorFontFamily: EditorFontFamily; documentText: string; handleExportClick: () => Promise; - locationRootPath?: string; + locationId?: number; docRelPath?: string; }; @@ -204,7 +204,7 @@ function PdfExportContent( editorFontFamily, documentText, handleExportClick, - locationRootPath, + locationId, docRelPath, }: PdfExportContentProps, ) { @@ -228,7 +228,7 @@ function PdfExportContent( previewResult={previewResult} options={options} editorFontFamily={previewFont} - locationRootPath={locationRootPath} + locationId={locationId} docRelPath={docRelPath} /> ) : null} @@ -497,7 +497,7 @@ function StringExportContent({ onCancel, docFilename, documentText }: StringExpo } export function ExportDialog( - { onExport, previewResult, editorFontFamily, documentText = "", locationRootPath, docRelPath }: ExportDialogProps, + { onExport, previewResult, editorFontFamily, documentText = "", locationId, docRelPath }: ExportDialogProps, ) { const { isOpen, setOpen: setIsOpen, options } = usePdfDialogUiState(); const { resetPdfExport } = usePdfExportActions(); @@ -607,8 +607,8 @@ export function ExportDialog( }, [resetPdfExport, resetTextExport, resetDocxExport]); const pdfExportProps = useMemo( - () => ({ showPreview, previewResult, options, editorFontFamily, documentText, locationRootPath, docRelPath }), - [showPreview, previewResult, options, editorFontFamily, documentText, locationRootPath, docRelPath], + () => ({ showPreview, previewResult, options, editorFontFamily, documentText, locationId, docRelPath }), + [showPreview, previewResult, options, editorFontFamily, documentText, locationId, docRelPath], ); return ( diff --git a/src/components/export/MarkdownPdfDocument.tsx b/src/components/export/MarkdownPdfDocument.tsx index f7ef893..f63f701 100644 --- a/src/components/export/MarkdownPdfDocument.tsx +++ b/src/components/export/MarkdownPdfDocument.tsx @@ -1,5 +1,5 @@ import { getCodeFontFamily, getPdfFontFamily } from "$pdf/fonts"; -import type { FontName, MarkdownNode, PageSize, PdfExportOptions } from "$pdf/types"; +import type { FontName, MarkdownListItem, MarkdownNode, PageSize, PdfExportOptions } from "$pdf/types"; import { Document, Image, Page, StyleSheet, Text, View } from "@react-pdf/renderer"; import { useCallback, useMemo } from "react"; @@ -61,8 +61,10 @@ const createStyles = ( padding: 8, marginBottom: 10, }, - list: { marginLeft: 20, marginBottom: 10 }, - listItem: { fontSize: baseFontSize, lineHeight, marginBottom: 4, color: PDF_TEXT_COLOR }, + list: { marginBottom: 10 }, + listItemRow: { flexDirection: "row", alignItems: "flex-start", marginBottom: 4 }, + listMarker: { width: 18, fontSize: baseFontSize, lineHeight, color: PDF_TEXT_COLOR }, + listItemBody: { flex: 1 }, blockquote: { marginLeft: 20, paddingLeft: 10, borderLeftWidth: 2, borderLeftColor: "#ddd", marginBottom: 10 }, blockquoteText: { fontSize: baseFontSize, lineHeight, fontStyle: "italic", color: PDF_MUTED_TEXT_COLOR }, footnote: { @@ -78,7 +80,27 @@ const createStyles = ( type DocumentNodeProps = { node: MarkdownNode; styles: TStyleSheet; resolvedImages: Record }; +const ListItemContent = ( + { item, styles, resolvedImages, marker }: { + item: MarkdownListItem; + styles: TStyleSheet; + resolvedImages: Record; + marker: string; + }, +) => ( + + {marker} + + {item.content.map((contentNode, index) => { + const k = `${marker}:${index}`; + return ; + })} + + +); + const DocumentNode = ({ node, styles, resolvedImages }: DocumentNodeProps) => { + const listStyles = useMemo(() => [styles.list, styles.listItemBody], [styles.list, styles.listItemBody]); switch (node.type) { case "heading": { const headingStyle = getHeadingStyle(node.level, styles); @@ -90,14 +112,16 @@ const DocumentNode = ({ node, styles, resolvedImages }: DocumentNodeProps) => { return {node.content}; case "list": return ( - - {node.items?.map((item: MarkdownNode, i: number) => { - const k = `${i}`; + + {node.items?.map((item, index) => { + let k = `${index}`; return ( - - {node.ordered ? `${i + 1}. ` : "• "} - {item.type === "paragraph" ? item.content : ""} - + ); })} @@ -176,8 +200,9 @@ const DocumentFooter = ( ) => ; export const MarkdownPdfDocument = ( - { nodes, title, options, editorFontFamily, useBuiltinFonts = false, resolvedImages = {} }: MarkdownPdfDocumentProps, + { nodes, title, options, editorFontFamily, useBuiltinFonts = false, resolvedImages }: MarkdownPdfDocumentProps, ) => { + const images = useMemo(() => resolvedImages ?? {}, [resolvedImages]); const fontStrategy = useBuiltinFonts ? "builtin" : "custom"; const bodyFont = getPdfFontFamily(editorFontFamily, fontStrategy); const codeFont = getCodeFontFamily(fontStrategy); @@ -199,7 +224,7 @@ export const MarkdownPdfDocument = ( styles={styles} options={options} hasHeadingOne={hasHeadingOne} - resolvedImages={resolvedImages} /> + resolvedImages={images} /> {options.includeFooter && } diff --git a/src/components/export/preview/PdfPreview.tsx b/src/components/export/preview/PdfPreview.tsx index df79df9..33460eb 100644 --- a/src/components/export/preview/PdfPreview.tsx +++ b/src/components/export/preview/PdfPreview.tsx @@ -24,7 +24,7 @@ type UsePdfPreviewArgs = { result: PdfRenderResult | null; options: PdfExportOptions; editorFontFamily: EditorFontFamily; - locationRootPath?: string; + locationId?: number; docRelPath?: string; }; @@ -32,7 +32,7 @@ export type PdfPreviewPanelProps = { result: PdfRenderResult | null; options: PdfExportOptions; editorFontFamily: EditorFontFamily; - locationRootPath?: string; + locationId?: number; docRelPath?: string; }; @@ -52,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, locationRootPath, docRelPath }: UsePdfPreviewArgs) { +export function usePdfPreview({ result, options, editorFontFamily, locationId, docRelPath }: UsePdfPreviewArgs) { const [state, setState] = useState({ status: "idle" }); const abortControllerRef = useRef(null); const currentPdfDocRef = useRef(null); @@ -66,8 +66,8 @@ export function usePdfPreview({ result, options, editorFontFamily, locationRootP }, []); const resolveImages = useCallback(async (nodes: MarkdownNode[]) => { - return docRelPath && locationRootPath ? await preloadPdfImages(nodes, locationRootPath, docRelPath) : {}; - }, [docRelPath, locationRootPath]); + return docRelPath && locationId !== undefined ? await preloadPdfImages(nodes, locationId, docRelPath) : {}; + }, [docRelPath, locationId]); const renderPdfBlob = useCallback( async ( @@ -675,10 +675,8 @@ function PreviewSuccess({ pdfDoc, pageCount, usedBuiltinFonts }: PreviewSuccessP ); } -export function PdfPreviewPanel( - { result, options, editorFontFamily, locationRootPath, docRelPath }: PdfPreviewPanelProps, -) { - const previewState = usePdfPreview({ result, options, editorFontFamily, locationRootPath, docRelPath }); +export function PdfPreviewPanel({ result, options, editorFontFamily, locationId, docRelPath }: PdfPreviewPanelProps) { + const previewState = usePdfPreview({ result, options, editorFontFamily, locationId, docRelPath }); switch (previewState.status) { case "idle": diff --git a/src/hooks/controllers/useWorkspaceViewController.ts b/src/hooks/controllers/useWorkspaceViewController.ts index 38f228f..c9047fb 100644 --- a/src/hooks/controllers/useWorkspaceViewController.ts +++ b/src/hooks/controllers/useWorkspaceViewController.ts @@ -39,7 +39,7 @@ export type WorkspaceViewController = { focusModePanelProps: FocusModePanelProps; handleExportPdf: (options: PdfExportOptions) => Promise; previewResult: PdfRenderResult | null; - activeDocLocationRootPath: string | undefined; + activeDocLocationId: number | undefined; activeDocRelPath: string | undefined; editorFontFamily: EditorFontFamily; editorText: string; @@ -116,14 +116,9 @@ export function useWorkspaceViewController(): WorkspaceViewController { const imageLocationId = editorModel.docRef?.location_id ?? null; const { insertAt, handleImageFilePaste, handlePickAndInsertImage } = useEditorImageHandlers(imageLocationId); - const { handleOpenPdfExport, handleExportPdf, previewResult, activeDocLocationRootPath, activeDocRelPath } = - usePdfExportUI({ - activeTab, - text: editorModel.text, - editorFontFamily: editorPresentation.fontFamily, - exportPdf, - locations, - }); + const { handleOpenPdfExport, handleExportPdf, previewResult, activeDocLocationId, activeDocRelPath } = usePdfExportUI( + { activeTab, text: editorModel.text, editorFontFamily: editorPresentation.fontFamily, exportPdf }, + ); const hasOpenDocument = useMemo(() => isSameDocRef(activeTab?.docRef, editorModel.docRef), [ activeTab, @@ -268,12 +263,6 @@ export function useWorkspaceViewController(): WorkspaceViewController { const statusBarProps = useMemo(() => ({ docMeta: activeDocMeta, stats: editorStats }), [activeDocMeta, editorStats]); - const previewLocationRootPath = useMemo(() => { - const locationId = previewModel.docRef?.location_id; - if (locationId === undefined) return; - return locations.find((loc) => loc.id === locationId)?.root_path; - }, [previewModel.docRef?.location_id, locations]); - const previewProps = useMemo( () => ({ renderResult: previewModel.renderResult, @@ -281,19 +270,19 @@ export function useWorkspaceViewController(): WorkspaceViewController { editorLine: editorModel.cursorLine, previewStyle: editorPresentation.markdownPreviewStyle, editorFontFamily: editorPresentation.fontFamily, - onScrollToLine: syncPreviewLine, - locationRootPath: previewLocationRootPath, + locationId: previewModel.docRef?.location_id, docRelPath: previewModel.docRef?.rel_path, + onScrollToLine: syncPreviewLine, }), [ previewModel.renderResult, + previewModel.docRef?.location_id, previewModel.docRef?.rel_path, editorPresentation.theme, editorPresentation.markdownPreviewStyle, editorPresentation.fontFamily, editorModel.cursorLine, syncPreviewLine, - previewLocationRootPath, ], ); @@ -375,7 +364,7 @@ export function useWorkspaceViewController(): WorkspaceViewController { focusModePanelProps, handleExportPdf, previewResult, - activeDocLocationRootPath, + activeDocLocationId, activeDocRelPath, editorFontFamily: editorPresentation.fontFamily, editorText: editorModel.text, diff --git a/src/hooks/usePdfExport.tsx b/src/hooks/usePdfExport.tsx index 4f516e4..dbb99b6 100644 --- a/src/hooks/usePdfExport.tsx +++ b/src/hooks/usePdfExport.tsx @@ -5,7 +5,7 @@ import { preloadPdfImages } from "$pdf/images"; import type { FontName, FontStrategy, MarkdownNode, PdfExportOptions, PdfRenderResult } from "$pdf/types"; import { renderMarkdownForPdf, runCmd } from "$ports"; import { usePdfDialogUiState, usePdfExportActions } from "$state/selectors"; -import type { EditorFontFamily, LocationDescriptor, Tab } from "$types"; +import type { EditorFontFamily, Tab } from "$types"; import { f } from "$utils/serialize"; import { pdf } from "@react-pdf/renderer"; import { save } from "@tauri-apps/plugin-dialog"; @@ -17,7 +17,7 @@ export type ExportPdfFn = ( result: PdfRenderResult, options: PdfExportOptions, editorFontFamily: EditorFontFamily, - locationRootPath?: string, + locationId?: number, docRelPath?: string, ) => Promise; @@ -30,8 +30,8 @@ const runtimeContext = () => ({ export function usePdfExport(): ExportPdfFn { const { startPdfExport, finishPdfExport, failPdfExport } = usePdfExportActions(); - const resolveImages = useCallback(async (nodes: MarkdownNode[], locationRootPath?: string, docRelPath?: string) => { - return locationRootPath && docRelPath ? await preloadPdfImages(nodes, locationRootPath, docRelPath) : {}; + const resolveImages = useCallback(async (nodes: MarkdownNode[], locationId?: number, docRelPath?: string) => { + return locationId !== undefined && docRelPath ? await preloadPdfImages(nodes, locationId, docRelPath) : {}; }, []); const renderPdfBlob = useCallback( @@ -71,7 +71,7 @@ export function usePdfExport(): ExportPdfFn { result: PdfRenderResult, options: PdfExportOptions, editorFontFamily: EditorFontFamily, - locationRootPath?: string, + locationId?: number, docRelPath?: string, ) => { startPdfExport(); @@ -80,7 +80,7 @@ export function usePdfExport(): ExportPdfFn { let blob: Blob; let customRenderError: unknown = null; try { - const images = await resolveImages(result.nodes, locationRootPath, docRelPath); + const images = await resolveImages(result.nodes, locationId, docRelPath); blob = await renderPdfBlob(result, options, editorFontFamily, "custom", images); } catch (initialError) { customRenderError = initialError; @@ -92,7 +92,7 @@ export function usePdfExport(): ExportPdfFn { ); try { - const images = await resolveImages(result.nodes, locationRootPath, docRelPath); + const images = await resolveImages(result.nodes, locationId, docRelPath); blob = await renderPdfBlob(result, options, editorFontFamily, "builtin", images); logger.warn( f("PDF export completed with built-in fonts after custom font failure", { @@ -155,10 +155,9 @@ type UsePdfExportUIArgs = { text: string; editorFontFamily: EditorFontFamily; exportPdf: ExportPdfFn; - locations: LocationDescriptor[]; }; -export function usePdfExportUI({ activeTab, text, editorFontFamily, exportPdf, locations }: UsePdfExportUIArgs) { +export function usePdfExportUI({ activeTab, text, editorFontFamily, exportPdf }: UsePdfExportUIArgs) { const { setOpen: setPdfExportDialogOpen } = usePdfDialogUiState(); const { resetPdfExport } = usePdfExportActions(); const [previewResult, setPreviewResult] = useState(null); @@ -205,7 +204,6 @@ export function usePdfExportUI({ activeTab, text, editorFontFamily, exportPdf, l } const docRef = activeTab.docRef; - const locationRootPath = locations.find((loc) => loc.id === docRef.location_id)?.root_path; try { const renderResult = await new Promise((resolve, reject) => { @@ -213,7 +211,7 @@ export function usePdfExportUI({ activeTab, text, editorFontFamily, exportPdf, l }); const resolvedFont = resolvePdfFont(editorFontFamily as FontName, text); - const didExport = await exportPdf(renderResult, options, resolvedFont, locationRootPath, docRef.rel_path); + const didExport = await exportPdf(renderResult, options, resolvedFont, docRef.location_id, docRef.rel_path); if (didExport) { setPdfExportDialogOpen(false); resetPdfExport(); @@ -221,11 +219,9 @@ export function usePdfExportUI({ activeTab, text, editorFontFamily, exportPdf, l } catch (error) { logger.error(f("Failed to export PDF", { error: error instanceof Error ? error.message : String(error) })); } - }, [activeTab, editorFontFamily, exportPdf, locations, resetPdfExport, setPdfExportDialogOpen, text]); + }, [activeTab, editorFontFamily, exportPdf, resetPdfExport, setPdfExportDialogOpen, text]); - const activeDocLocationRootPath = activeTab - ? locations.find((loc) => loc.id === activeTab.docRef.location_id)?.root_path - : undefined; + const activeDocLocationId = activeTab?.docRef.location_id; const activeDocRelPath = activeTab?.docRef.rel_path; return { @@ -233,7 +229,7 @@ export function usePdfExportUI({ activeTab, text, editorFontFamily, exportPdf, l handleExportPdf, previewResult, isLoadingPreview, - activeDocLocationRootPath, + activeDocLocationId, activeDocRelPath, }; } diff --git a/src/pdf/images.ts b/src/pdf/images.ts index 3fb70f8..4a13a00 100644 --- a/src/pdf/images.ts +++ b/src/pdf/images.ts @@ -1,22 +1,11 @@ +import type { MarkdownListItem, MarkdownNode } from "$pdf/types"; +import { logAssetResolutionFailure, resolveAssetPath } from "$utils/assets"; import { f } from "$utils/serialize"; import { convertFileSrc, invoke } from "@tauri-apps/api/core"; import * as logger from "@tauri-apps/plugin-log"; -import type { MarkdownNode } from "./types"; const preloadedImageSources = new Map(); -export function resolveAssetSrc(locationRootPath: string, docRelPath: string, imgSrc: string): string { - const dirParts = docRelPath.split("/").slice(0, -1); - const base = dirParts.length > 0 ? `${locationRootPath}/${dirParts.join("/")}` : locationRootPath; - const segments = `${base}/${imgSrc}`.split("/"); - const resolved: string[] = []; - for (const seg of segments) { - if (seg === "..") resolved.pop(); - else if (seg !== ".") resolved.push(seg); - } - return resolved.join("/"); -} - function mimeForExtension(ext: string): string { switch (ext.toLowerCase()) { case "jpg": @@ -26,6 +15,8 @@ function mimeForExtension(ext: string): string { return "image/gif"; case "webp": return "image/webp"; + case "svg": + return "image/svg+xml"; default: return "image/png"; } @@ -43,7 +34,7 @@ async function fetchAsDataUrl(absolutePath: string): Promise { const bytes = new Uint8Array(await response.arrayBuffer()); let binary = ""; - for (let i = 0; i < bytes.length; i++) { + for (let i = 0; i < bytes.length; i += 1) { binary += String.fromCodePoint(bytes[i]); } const ext = absolutePath.split(".").pop() ?? "png"; @@ -53,20 +44,28 @@ async function fetchAsDataUrl(absolutePath: string): Promise { return dataUrl; } +function collectImageSrcsFromListItems(items: MarkdownListItem[]): string[] { + return items.flatMap((item) => collectImageSrcs(item.content)); +} + function collectImageSrcs(nodes: MarkdownNode[]): string[] { const srcs: string[] = []; for (const node of nodes) { - if (node.type === "image") srcs.push(node.src); - else if (node.type === "list") srcs.push(...collectImageSrcs(node.items)); + if (node.type === "image") { + srcs.push(node.src); + } else if (node.type === "list") { + srcs.push(...collectImageSrcsFromListItems(node.items)); + } } return srcs; } -async function fetchSvgAsPngDataUrl(absolutePath: string): Promise { +async function fetchSvgAsPngDataUrl(locationId: number, docRelPath: string, assetRef: string): Promise { + const absolutePath = await resolveAssetPath(locationId, docRelPath, assetRef); const cached = preloadedImageSources.get(absolutePath); if (cached !== undefined) return cached; - const result = await invoke("svg_to_png", { absolutePath }); + const result = await invoke("svg_to_png", { locationId, docRelPath, assetPath: assetRef }); if ( typeof result === "object" && result !== null && (result as Record)["type"] === "ok" @@ -76,29 +75,28 @@ async function fetchSvgAsPngDataUrl(absolutePath: string): Promise { preloadedImageSources.set(absolutePath, dataUrl); return dataUrl; } - throw new Error(`svg_to_png failed for ${absolutePath}`); + throw new Error(`svg_to_png failed for ${assetRef}`); } export async function preloadPdfImages( nodes: MarkdownNode[], - locationRootPath: string, + locationId: number, docRelPath: string, ): Promise> { - const srcs = [...new Set(collectImageSrcs(nodes).filter((src) => src.includes(".writer-assets/")))]; - + const srcs = [...new Set(collectImageSrcs(nodes))]; const resolved: Record = {}; await Promise.allSettled(srcs.map(async (src) => { try { - const absolutePath = resolveAssetSrc(locationRootPath, docRelPath, src); - resolved[src] = src.endsWith(".svg") - ? await fetchSvgAsPngDataUrl(absolutePath) + const absolutePath = await resolveAssetPath(locationId, docRelPath, src); + resolved[src] = src.toLowerCase().endsWith(".svg") + ? await fetchSvgAsPngDataUrl(locationId, docRelPath, src) : await fetchAsDataUrl(absolutePath); - } catch (err) { - const error = err instanceof Error ? err.message : (String(err)); - logger.debug(f(`Failed to preload image: ${src}`), { keyValues: { error } }); + } catch (error) { + logAssetResolutionFailure("PDF image", src, error); } })); + logger.debug(f("PDF image preload complete", { requested: srcs.length, resolved: Object.keys(resolved).length })); return resolved; } diff --git a/src/pdf/types.ts b/src/pdf/types.ts index d230330..d507348 100644 --- a/src/pdf/types.ts +++ b/src/pdf/types.ts @@ -21,11 +21,13 @@ export type PdfExportOptions = { includeFooter?: boolean; }; +export type MarkdownListItem = { content: MarkdownNode[] }; + export type MarkdownNode = | { type: "heading"; level: number; content: string } | { type: "paragraph"; content: string } | { type: "code"; content: string; language?: string } - | { type: "list"; items: MarkdownNode[]; ordered: boolean } + | { type: "list"; items: MarkdownListItem[]; ordered: boolean } | { type: "blockquote"; content: string } | { type: "footnote"; id: string; content: string } | { type: "image"; src: string; alt: string }; diff --git a/src/ports/commands.ts b/src/ports/commands.ts index 1792a98..6c6d728 100644 --- a/src/ports/commands.ts +++ b/src/ports/commands.ts @@ -20,6 +20,7 @@ import type { import { info } from "@tauri-apps/plugin-log"; import { invokeCmd, runCmd } from "./invoke"; import type { + AssetResolveParams, AtProtoLoginParams, AtProtoSessionStatusParams, BackendCaptureDocRef, @@ -489,4 +490,8 @@ export function imageList(...[locationId, onOk, onErr]: ImageListParams("image_list", { locationId }, onOk, onErr); } +export function assetResolve(...[locationId, docRelPath, assetPath, onOk, onErr]: AssetResolveParams): Cmd { + return invokeCmd("asset_resolve", { locationId, docRelPath, assetPath }, onOk, onErr); +} + export type { MarkdownProfile, SearchFiltersPayload }; diff --git a/src/ports/types.ts b/src/ports/types.ts index e8aa19c..6df3479 100644 --- a/src/ports/types.ts +++ b/src/ports/types.ts @@ -132,6 +132,15 @@ export type DirDeleteParams = [...LocationPathParams, ...LocParams]; export type DirRenameParams = Parameters< (locationId: LocationId, relPath: string, newName: string, onOk: SuccessCallback, onErr: ErrorCallback) => void >; +export type AssetResolveParams = Parameters< + ( + locationId: LocationId, + docRelPath: string, + assetPath: string, + onOk: SuccessCallback, + onErr: ErrorCallback, + ) => void +>; export type DirMoveParams = Parameters< ( locationId: LocationId, diff --git a/src/utils/assets.ts b/src/utils/assets.ts new file mode 100644 index 0000000..0807d54 --- /dev/null +++ b/src/utils/assets.ts @@ -0,0 +1,70 @@ +import { assetResolve, runCmd } from "$ports"; +import { f } from "$utils/serialize"; +import { convertFileSrc } from "@tauri-apps/api/core"; +import * as logger from "@tauri-apps/plugin-log"; + +const resolvedAssetPathCache = new Map>(); +const URL_SCHEME_PATTERN = /^[a-zA-Z][a-zA-Z\d+.-]*:/; + +function splitAssetReference(assetRef: string): { path: string; suffix: string } { + const separatorIndex = assetRef.search(/[?#]/); + const rawPath = separatorIndex >= 0 ? assetRef.slice(0, separatorIndex) : assetRef; + const suffix = separatorIndex >= 0 ? assetRef.slice(separatorIndex) : ""; + + try { + return { path: decodeURI(rawPath), suffix }; + } catch { + return { path: rawPath, suffix }; + } +} + +export function isExternalAssetReference(assetRef: string): boolean { + const trimmed = assetRef.trim(); + return trimmed.startsWith("//") || URL_SCHEME_PATTERN.test(trimmed); +} + +export function isResolvableLocalAssetReference(assetRef: string): boolean { + const trimmed = assetRef.trim(); + return trimmed !== "" && !trimmed.startsWith("#") && !isExternalAssetReference(trimmed); +} + +export async function resolveAssetPath(locationId: number, docRelPath: string, assetRef: string): Promise { + const { path } = splitAssetReference(assetRef.trim()); + + if (!isResolvableLocalAssetReference(path)) { + throw new Error(`Asset reference is not local: ${assetRef}`); + } + + const cacheKey = `${locationId}:${docRelPath}:${path}`; + const existing = resolvedAssetPathCache.get(cacheKey); + if (existing) { + return await existing; + } + + const pending = new Promise((resolve, reject) => { + void runCmd(assetResolve(locationId, docRelPath, path, resolve, reject)); + }); + + resolvedAssetPathCache.set(cacheKey, pending); + + try { + return await pending; + } catch (error) { + resolvedAssetPathCache.delete(cacheKey); + throw error; + } +} + +export async function resolveAssetUrl(locationId: number, docRelPath: string, assetRef: string): Promise { + const { suffix } = splitAssetReference(assetRef); + const resolvedPath = await resolveAssetPath(locationId, docRelPath, assetRef); + return `${convertFileSrc(resolvedPath)}${suffix}`; +} + +export function logAssetResolutionFailure(kind: string, assetRef: string, error: unknown) { + void logger.debug( + f(`Failed to resolve ${kind}`, { + keyValues: { assetRef, error: error instanceof Error ? error.message : String(error) }, + }), + ); +}