Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src-tauri/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -666,7 +666,7 @@ fn absolutize(path: &str) -> String {

fn canonicalise_or_exit(path: &str, verb: &str) -> String {
match std::fs::canonicalize(path) {
Ok(p) => p.to_string_lossy().into_owned(),
Ok(p) => crate::file_io::strip_verbatim_prefix(p.to_string_lossy().into_owned()),
Err(_) => {
attach_parent_console();
eprintln!("kova: {verb} '{path}': no such file");
Expand Down
3 changes: 1 addition & 2 deletions src-tauri/src/commands/window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,7 @@ pub fn show_in_file_manager(path: String) -> Result<(), String> {
if is_file {
// Strip the \\?\ extended-length prefix that canonicalize adds on Windows
// — Explorer /select does not recognise UNC-prefixed paths.
let path_str = canonical.to_string_lossy();
let clean = path_str.strip_prefix(r"\\?\").unwrap_or(&path_str);
let clean = file_io::strip_verbatim_prefix(canonical.to_string_lossy().into_owned());
cmd.arg(format!("/select,\"{}\"", clean));
} else {
cmd.arg(&canonical);
Expand Down
24 changes: 24 additions & 0 deletions src-tauri/src/file_io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,30 @@ pub fn safe_write_path(path: &str) -> Result<PathBuf, String> {
Ok(resolved)
}

/// `std::fs::canonicalize` on Windows returns the `\\?\` extended-length
/// ("verbatim") prefix form (and `\\?\UNC\host\share` for UNC paths), which
/// several downstream consumers of a canonicalised path don't expect: the
/// frontend derives a document directory from it with plain string
/// splitting (see resolvePath.ts) and mistakes the literal `?` for the start
/// of a URL query string, and Explorer's `/select` doesn't recognise the
/// prefix either. Strips it back to the ordinary `C:\...` /
/// `\\host\share\...` form other platforms already return.
#[cfg(windows)]
pub fn strip_verbatim_prefix(path: String) -> String {
if let Some(rest) = path.strip_prefix(r"\\?\UNC\") {
format!(r"\\{rest}")
} else if let Some(rest) = path.strip_prefix(r"\\?\") {
rest.to_string()
} else {
path
}
}

#[cfg(not(windows))]
pub fn strip_verbatim_prefix(path: String) -> String {
path
}

pub fn read(path: &str) -> Result<String, String> {
let safe = safe_read_path(path)?;
std::fs::read_to_string(&safe).map_err(|e| format!("Failed to read file: {e}"))
Expand Down
44 changes: 42 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ import { exportPdfNative, buildInteractiveDocument, type PdfExportOpts } from '.
import { SlideRenderer } from './components/preview/SlideRenderer';
import { BUILT_IN_THEMES, DEFAULT_THEME, parseThemeYaml, sanitiseThemeOverrides, type ThemeParseResult } from './engine/theme';
import { registerBundledFonts, registerCachedFont } from './engine/bundledFonts';
import type { Slide, Frontmatter } from './engine/types';
import type { Slide, ListItem, Frontmatter } from './engine/types';
import { parseAspectRatio } from './engine/types';
import { getSlideStepCount } from './engine/layout/steps';
import { imageMime } from './engine/export/imageMime';
Expand Down Expand Up @@ -525,6 +525,45 @@ export default function App() {
// Deck used for presentation + export — hidden slides removed. Reference-equal
// to entries in `slides`, so index translation uses indexOf/indexOf.
const visibleSlides = useMemo(() => slides.filter((s) => !s.hidden), [slides]);

// useResolvedSlides swaps local media srcs from a placeholder (used while the
// async read_file_b64 load is in flight) to data: URLs once loaded. The
// placeholder is convertFileSrc's asset:// URL on macOS/Linux, but Windows'
// WebView2 can't load custom schemes directly, so Tauri rewrites it there to
// http://asset.localhost/... instead — both forms must be recognised or this
// gate never fires on Windows, the platform the CLI export bug targets. A
// cold-start CLI export can otherwise fire its capture the instant slides
// resolve, racing that load — the button-triggered export never hits this
// because by the time a user clicks it, the deck has been open long enough
// for local media to have already resolved.
const hasPendingLocalMedia = useMemo(() => {
const isPending = (src: string) => src.startsWith('asset://') || src.startsWith('http://asset.localhost/');
const htmlIsPending = (html: string) => /src="(?:asset:\/\/|http:\/\/asset\.localhost\/)/.test(html);
const itemIsPending = (item: ListItem): boolean =>
htmlIsPending(item.html) || item.children.some(itemIsPending);
return visibleSlides.some((slide) => {
if (slide.backgroundImage && isPending(slide.backgroundImage.src)) return true;
return slide.elements.some((el) => {
if (el.type === 'image' || el.type === 'video') return isPending(el.src);
if (el.type === 'paragraph') return htmlIsPending(el.html);
if (el.type === 'list') return el.items.some(itemIsPending);
return false;
});
});
}, [visibleSlides]);

// Bounded wait for the gate above: if read_file_b64 fails for a missing or
// mistyped path, that src never becomes a data: URL and hasPendingLocalMedia
// would otherwise stay true forever, hanging a CLI export silently. This
// timer is wall-clock (not dependent on further slide/media changes to
// re-fire), so it still fires even if nothing else ever triggers a re-render.
const MEDIA_WAIT_TIMEOUT_MS = 5_000;
const [mediaWaitExpired, setMediaWaitExpired] = useState(false);
useEffect(() => {
if (!hasPendingLocalMedia) { setMediaWaitExpired(false); return; }
const timer = setTimeout(() => setMediaWaitExpired(true), MEDIA_WAIT_TIMEOUT_MS);
return () => clearTimeout(timer);
}, [hasPendingLocalMedia]);
const safePresentIndex = visibleSlides.length > 0
? Math.min(presentIndex, visibleSlides.length - 1)
: 0;
Expand Down Expand Up @@ -1701,6 +1740,7 @@ export default function App() {
const coldExportFiredRef = useRef(false);
useEffect(() => {
if (!coldExport || coldExportFiredRef.current || !filePath) return;
if (hasPendingLocalMedia && !mediaWaitExpired) return; // wait (bounded) for local image/video data URLs to resolve
coldExportFiredRef.current = true;
if (visibleSlides.length === 0) {
invoke('cli_exit', {
Expand Down Expand Up @@ -1747,7 +1787,7 @@ export default function App() {
await fail(err instanceof Error ? err.message : String(err));
}
})();
}, [coldExport, filePath, visibleSlides, frontmatter, activeTheme, settings.locale, settings.pdfPageSize, runPdfExportCapture]);
}, [coldExport, filePath, visibleSlides, hasPendingLocalMedia, mediaWaitExpired, frontmatter, activeTheme, settings.locale, settings.pdfPageSize, runPdfExportCapture]);

const handleExportHtml = useCallback(async () => {
if (visibleSlides.length === 0) return;
Expand Down
39 changes: 35 additions & 4 deletions src/components/preview/SlideRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import { SlideLayout } from './layouts';

mermaid.initialize({ startOnLoad: false, theme: 'base', securityLevel: 'strict' });

// Longer than queuedMermaidRender's own per-diagram timeout (mermaidRenderQueue.ts)
// so a diagram still genuinely in flight gets its full chance to finish first.
const MERMAID_READY_TIMEOUT_MS = 20_000;

// Header/footer text. A `|` in the *theme template* splits it into left | center | right
// parts (issue #30). Segments are pre-split from the template before variable resolution
// so a doc title containing `|` (e.g. "Costs | Benefits") is never treated as a separator.
Expand Down Expand Up @@ -71,13 +75,40 @@ export function SlideRenderer({ slide, theme = DEFAULT_THEME, slideNumber, total
const diagramReadyCount = useRef(0);
const onAllDiagramsReadyRef = useRef(onAllDiagramsReady);
useEffect(() => { onAllDiagramsReadyRef.current = onAllDiagramsReady; });
// Guards every path below that can call onAllDiagramsReady (threshold reached,
// zero-diagram fast path, timeout fallback) so a slide only ever signals once.
const allDiagramsSignalledRef = useRef(false);
const signalAllDiagramsReady = useCallback(() => {
if (allDiagramsSignalledRef.current) return;
allDiagramsSignalledRef.current = true;
onAllDiagramsReadyRef.current?.();
}, []);
const onDiagramReady = useCallback(() => {
diagramReadyCount.current += 1;
if (diagramReadyCount.current >= mermaidCount) onAllDiagramsReadyRef.current?.();
}, [mermaidCount]);
if (diagramReadyCount.current >= mermaidCount) signalAllDiagramsReady();
}, [mermaidCount, signalAllDiagramsReady]);
// No cleanup to undo this signal, so StrictMode's dev-only mount→cleanup→
// mount cycle would otherwise call onAllDiagramsReady twice per slide —
// overshooting the export's total ready count before slides with actual
// Mermaid diagrams (whose own signal is properly deduped, see MermaidDiagram)
// ever finish rendering, so the capture fires too early. Guard with a ref
// so only the first invocation counts.
useEffect(() => {
if (onAllDiagramsReady && mermaidCount === 0) signalAllDiagramsReady();
}, [onAllDiagramsReady, mermaidCount, signalAllDiagramsReady]);
// Bounded wait: a diagram whose render is cancelled before it ever caches an
// SVG (see MermaidDiagram's cleanup) doesn't advance the ready count, and
// relies on a later render of the same diagram to finish and signal instead
// — if that never happens (e.g. a broken diagram whose replacement render
// also keeps getting cancelled), this slide would otherwise block the export
// forever. Force readiness after a timeout comfortably longer than
// queuedMermaidRender's own per-diagram timeout, so a diagram that's still
// genuinely in flight gets its full chance to finish first.
useEffect(() => {
if (onAllDiagramsReady && mermaidCount === 0) onAllDiagramsReady();
}, [onAllDiagramsReady, mermaidCount]);
if (!onAllDiagramsReady || mermaidCount === 0) return;
const timer = setTimeout(signalAllDiagramsReady, MERMAID_READY_TIMEOUT_MS);
return () => clearTimeout(timer);
}, [onAllDiagramsReady, mermaidCount, signalAllDiagramsReady]);

const templateVars = { title: docTitle, author: docAuthor, date: docDate, slideNumber, totalSlides };
const headerSegs = theme.header.show
Expand Down
19 changes: 15 additions & 4 deletions src/components/preview/elements.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -514,10 +514,21 @@ export function MermaidDiagram({ value, caption }: { value: string; caption?: st
signalReady();
}
});
// If this render is cancelled mid-flight (e.g. theme change during export),
// signal ready so the export count still advances; the replacement render
// will also signal when it completes.
return () => { cancelled = true; pendingSignalRef.current = null; signalReady(); };
// A render cancelled mid-flight (e.g. StrictMode's dev-only discard of the
// first of two mounts, or a theme change during export) only counts as
// "ready" if a previously-rendered SVG for this exact source is already
// cached — that's what the belt-and-suspenders fallback in
// exportPdfNative.ts's injectMermaidFallbacks would inject. Otherwise this
// is the diagram's first-ever render, nothing is cached yet, and signalling
// ready here would let an export snapshot the DOM before the replacement
// render (already in flight) has a chance to actually commit an SVG —
// the replacement render's own completion (or SlideRenderer's timeout
// fallback) is what will signal readiness instead.
return () => {
cancelled = true;
pendingSignalRef.current = null;
if (mermaidSvgCache.get(value)) signalReady();
};
}, [baseId, value, mermaidInit]);

if (!svg) {
Expand Down
Loading