From 211f6ab90a3c31d30df268e0d06a64963cfef9e5 Mon Sep 17 00:00:00 2001 From: Florian Hubert Date: Mon, 31 Aug 2026 16:26:00 +0200 Subject: [PATCH 1/4] fix: Fixes pdf export via cli (on windows) --- src-tauri/src/cli.rs | 25 ++- src/App.tsx | 27 +++- src/components/preview/SlideRenderer.tsx | 12 +- src/components/preview/elements.tsx | 18 ++- src/engine/export/exportPdfNative.ts | 196 ++++++++++++++--------- src/hooks/useResolvedSlides.ts | 140 ++++++++++------ 6 files changed, 279 insertions(+), 139 deletions(-) diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs index 7fffd20c..f6afc425 100644 --- a/src-tauri/src/cli.rs +++ b/src-tauri/src/cli.rs @@ -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) => strip_verbatim_prefix(p.to_string_lossy().into_owned()), Err(_) => { attach_parent_console(); eprintln!("kova: {verb} '{path}': no such file"); @@ -675,6 +675,29 @@ fn canonicalise_or_exit(path: &str, verb: &str) -> String { } } +/// `std::fs::canonicalize` on Windows returns the `\\?\` extended-length +/// ("verbatim") prefix form (and `\\?\UNC\host\share` for UNC paths). The +/// frontend derives a document directory from this path with plain string +/// splitting (see resolvePath.ts), which doesn't expect that prefix — its +/// literal `?` is mistaken for the start of a URL query string and truncates +/// every path built from it down to a single backslash. Strip it back to the +/// ordinary `C:\...` / `\\host\share\...` form other platforms already have. +#[cfg(windows)] +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))] +fn strip_verbatim_prefix(path: String) -> String { + path +} + /// Release builds use the Windows GUI subsystem (no console), so terminal /// output vanishes unless the process attaches to the parent's console /// first. Silently a no-op when there is no parent console (GUI launches) diff --git a/src/App.tsx b/src/App.tsx index e179ab56..4529dd80 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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'; @@ -525,6 +525,28 @@ 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 asset:// (a placeholder used + // while the async read_file_b64 load is in flight) to data: URLs once loaded. + // 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://'); + const htmlIsPending = (html: string) => /src="asset:\/\//.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]); const safePresentIndex = visibleSlides.length > 0 ? Math.min(presentIndex, visibleSlides.length - 1) : 0; @@ -1701,6 +1723,7 @@ export default function App() { const coldExportFiredRef = useRef(false); useEffect(() => { if (!coldExport || coldExportFiredRef.current || !filePath) return; + if (hasPendingLocalMedia) return; // wait for local image/video data URLs to resolve coldExportFiredRef.current = true; if (visibleSlides.length === 0) { invoke('cli_exit', { @@ -1747,7 +1770,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, frontmatter, activeTheme, settings.locale, settings.pdfPageSize, runPdfExportCapture]); const handleExportHtml = useCallback(async () => { if (visibleSlides.length === 0) return; diff --git a/src/components/preview/SlideRenderer.tsx b/src/components/preview/SlideRenderer.tsx index 16880565..e85bc355 100644 --- a/src/components/preview/SlideRenderer.tsx +++ b/src/components/preview/SlideRenderer.tsx @@ -75,8 +75,18 @@ export function SlideRenderer({ slide, theme = DEFAULT_THEME, slideNumber, total diagramReadyCount.current += 1; if (diagramReadyCount.current >= mermaidCount) onAllDiagramsReadyRef.current?.(); }, [mermaidCount]); + // 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. + const zeroDiagramSignalledRef = useRef(false); useEffect(() => { - if (onAllDiagramsReady && mermaidCount === 0) onAllDiagramsReady(); + if (onAllDiagramsReady && mermaidCount === 0 && !zeroDiagramSignalledRef.current) { + zeroDiagramSignalledRef.current = true; + onAllDiagramsReady(); + } }, [onAllDiagramsReady, mermaidCount]); const templateVars = { title: docTitle, author: docAuthor, date: docDate, slideNumber, totalSlides }; diff --git a/src/components/preview/elements.tsx b/src/components/preview/elements.tsx index 16460823..253199bd 100644 --- a/src/components/preview/elements.tsx +++ b/src/components/preview/elements.tsx @@ -514,10 +514,20 @@ 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 is what will signal readiness. + return () => { + cancelled = true; + pendingSignalRef.current = null; + if (mermaidSvgCache.get(value)) signalReady(); + }; }, [baseId, value, mermaidInit]); if (!svg) { diff --git a/src/engine/export/exportPdfNative.ts b/src/engine/export/exportPdfNative.ts index 63a58492..807a8f6a 100644 --- a/src/engine/export/exportPdfNative.ts +++ b/src/engine/export/exportPdfNative.ts @@ -1,31 +1,35 @@ -import { invoke } from '@tauri-apps/api/core'; -import type { AspectRatio, Slide } from '../types'; -import { getSlideStepValues } from '../layout/steps'; -import { mermaidSvgCache } from './mermaidSvgCache'; -import { imageMime } from './imageMime'; -import { videoMime } from './videoMime'; -import { type PdfExportOpts, planPage, SLIDE_PX_W } from './pdfLayout'; +import {invoke} from '@tauri-apps/api/core'; -export type { PdfExportOpts }; +import {getSlideStepValues} from '../layout/steps'; +import type {AspectRatio, Slide} from '../types'; + +import {imageMime} from './imageMime'; +import {mermaidSvgCache} from './mermaidSvgCache'; +import {type PdfExportOpts, planPage, SLIDE_PX_W} from './pdfLayout'; +import {videoMime} from './videoMime'; + +export type {PdfExportOpts}; // ── Public entry point ─────────────────────────────────────────────────────── export async function exportPdfNative( - slideElements: HTMLElement[], - aspectRatio: AspectRatio, - savePath: string, - opts: PdfExportOpts = {}, -): Promise { + slideElements: HTMLElement[], + aspectRatio: AspectRatio, + savePath: string, + opts: PdfExportOpts = {}, + ): Promise { const html = await buildPrintDocument(slideElements, aspectRatio, opts); const plan = planPage(aspectRatio, opts); const perPage = opts.perPage ?? 1; - const pageCount = perPage > 1 ? Math.ceil(slideElements.length / perPage) : slideElements.length; + const pageCount = perPage > 1 ? Math.ceil(slideElements.length / perPage) : + slideElements.length; await invoke('export_pdf_native', { htmlContent: html, outputPath: savePath, widthMm: plan.pageWmm, heightMm: plan.pageHmm, - // Per-page capture rects for the macOS path (one createPDF per page, then merge). + // Per-page capture rects for the macOS path (one createPDF per page, then + // merge). pageCount, pageWidthPx: plan.pageWpx, pageHeightPx: plan.pageHpx, @@ -35,22 +39,23 @@ export async function exportPdfNative( // ── HTML serialiser ────────────────────────────────────────────────────────── export async function buildPrintDocument( - slideElements: HTMLElement[], - aspectRatio: AspectRatio, - opts: PdfExportOpts = {}, -): Promise { + slideElements: HTMLElement[], + aspectRatio: AspectRatio, + opts: PdfExportOpts = {}, + ): Promise { const plan = planPage(aspectRatio, opts); const perPage = opts.perPage ?? 1; // Read slide background color from the live DOM before cloning. const slideFrame = slideElements[0]?.querySelector('.slide-frame'); - const slideBg = slideFrame - ? getComputedStyle(slideFrame).getPropertyValue('--sl-bg').trim() - : ''; + const slideBg = slideFrame ? + getComputedStyle(slideFrame).getPropertyValue('--sl-bg').trim() : + ''; // 1. Clone elements and resolve all image/video URLs to data URIs in place. const clones = slideElements.map((el) => el.cloneNode(true) as HTMLElement); - await Promise.all(clones.map((el) => Promise.all([resolveImages(el), resolveVideos(el)]))); + await Promise.all( + clones.map((el) => Promise.all([resolveImages(el), resolveVideos(el)]))); // Belt-and-suspenders: if a Mermaid container is still a placeholder (SVG // not yet committed to the DOM when we cloned), inject from the render cache. clones.forEach(injectMermaidFallbacks); @@ -62,25 +67,41 @@ export async function buildPrintDocument( // Each slide is a 960×native box scaled into a frame sized to the slide's own // proportions (so the N-up border hugs the slide), centred in its slot. const slot = (el: HTMLElement) => - `
${el.outerHTML}
`; + `
${ + el.outerHTML}
`; // 3. Assemble pages — slides scaled/centred onto a standard paper page. let pages: string; if (plan.mode === 'nup') { const sheets: HTMLElement[][] = []; - for (let i = 0; i < clones.length; i += perPage) sheets.push(clones.slice(i, i + perPage)); - pages = sheets.map((sheet) => - `
${sheet.map(slot).join('')}
`, - ).join('\n'); + for (let i = 0; i < clones.length; i += perPage) + sheets.push(clones.slice(i, i + perPage)); + pages = + sheets + .map( + (sheet) => + `
${ + sheet.map(slot).join('')}
`, + ) + .join('\n'); } else if (plan.mode === 'notes') { - pages = clones.map((el, i) => { - const note = escapeHtml((opts.notes?.[i] ?? '').trim()); - return `
${slot(el)}
${note}
`; - }).join('\n'); + pages = + clones + .map((el, i) => { + const note = escapeHtml((opts.notes?.[i] ?? '').trim()); + return `
${ + slot(el)}
${note}
`; + }) + .join('\n'); } else { - pages = clones.map((el) => - `
${slot(el)}
`, - ).join('\n'); + pages = + clones + .map( + (el) => + `
${ + slot(el)}
`, + ) + .join('\n'); } const bgCss = slideBg ? `background: ${slideBg} !important;` : ''; @@ -156,8 +177,10 @@ html, body { justify-content: center !important; overflow: hidden !important; } -.kova-grid .kova-slot { width: ${plan.cellWpx}px !important; height: ${plan.cellHpx}px !important; } -.kova-col .kova-slot { width: 100% !important; height: ${plan.cellHpx}px !important; flex: 0 0 auto !important; } +.kova-grid .kova-slot { width: ${plan.cellWpx}px !important; height: ${ + plan.cellHpx}px !important; } +.kova-col .kova-slot { width: 100% !important; height: ${ + plan.cellHpx}px !important; flex: 0 0 auto !important; } .kova-center .kova-slot { width: 100% !important; height: 100% !important; } .kova-frame { position: relative !important; @@ -204,19 +227,20 @@ ${pages} * `buildPrintDocument`. */ export async function buildInteractiveDocument( - slideElements: HTMLElement[], - aspectRatio: AspectRatio, - slides: Slide[], -): Promise { - const plan = planPage(aspectRatio, { fullBleed: true }); + slideElements: HTMLElement[], + aspectRatio: AspectRatio, + slides: Slide[], + ): Promise { + const plan = planPage(aspectRatio, {fullBleed: true}); const slideFrame = slideElements[0]?.querySelector('.slide-frame'); - const slideBg = slideFrame - ? getComputedStyle(slideFrame).getPropertyValue('--sl-bg').trim() - : ''; + const slideBg = slideFrame ? + getComputedStyle(slideFrame).getPropertyValue('--sl-bg').trim() : + ''; const clones = slideElements.map((el) => el.cloneNode(true) as HTMLElement); - await Promise.all(clones.map((el) => Promise.all([resolveImages(el), resolveVideos(el)]))); + await Promise.all( + clones.map((el) => Promise.all([resolveImages(el), resolveVideos(el)]))); clones.forEach(injectMermaidFallbacks); clones.forEach(inlinePrintColorAdjust); @@ -233,10 +257,7 @@ export async function buildInteractiveDocument( /** Pure HTML assembler — unit-tested without Tauri/DOM asset fetches. */ export function assembleInteractiveDocument(opts: { - css: string; - slideHtml: string[]; - slideW: number; - slideH: number; + css: string; slideHtml: string[]; slideW: number; slideH: number; background?: string; /** * One entry per slide (parallel to `slideHtml`): that slide's distinct @@ -250,13 +271,20 @@ export function assembleInteractiveDocument(opts: { */ slideSteps?: number[][]; }): string { - const { css, slideHtml, slideW, slideH } = opts; + const {css, slideHtml, slideW, slideH} = opts; const slideSteps = opts.slideSteps ?? slideHtml.map(() => []); const background = opts.background || '#111'; - const slides = slideHtml.map((html, i) => - `
` + - `
${html}
`, - ).join('\n'); + const slides = + slideHtml + .map( + (html, i) => + `
` + + `
${ + html}
`, + ) + .join('\n'); const total = slideHtml.length; return ` @@ -454,18 +482,19 @@ ${slides} // (race between setSvg() and signalReady()), the clone will be a placeholder // div with no SVG child. Inject the cached SVG string so the diagram appears. function injectMermaidFallbacks(root: HTMLElement): void { - const containers = Array.from(root.querySelectorAll('[data-mermaid-src]')); + const containers = + Array.from(root.querySelectorAll('[data-mermaid-src]')); for (const container of containers) { if (container.querySelector('svg')) continue; const src = container.getAttribute('data-mermaid-src') ?? ''; const cached = mermaidSvgCache.get(src); if (!cached) continue; const scaled = cached.replace(/]*)>/i, (_m, attrs: string) => { - let a = attrs - .replace(/\bwidth="[^"]*"/, 'width="100%"') - .replace(/\bheight="[^"]*"/, 'height="100%"') - .replace(/\bstyle="[^"]*max-width[^"]*"/, ''); - if (!/preserveAspectRatio/.test(a)) a += ' preserveAspectRatio="xMidYMid meet"'; + let a = attrs.replace(/\bwidth="[^"]*"/, 'width="100%"') + .replace(/\bheight="[^"]*"/, 'height="100%"') + .replace(/\bstyle="[^"]*max-width[^"]*"/, ''); + if (!/preserveAspectRatio/.test(a)) + a += ' preserveAspectRatio="xMidYMid meet"'; return ``; }); container.innerHTML = scaled; @@ -495,21 +524,23 @@ async function resolveImages(el: HTMLElement): Promise { const imgs = Array.from(el.querySelectorAll('img')); await Promise.all(imgs.map(async (img) => { const src = img.getAttribute('src') ?? ''; - let dataUrl: string | null = null; + let dataUrl: string|null = null; try { if (src.startsWith('asset://')) { const path = decodeURIComponent(src.replace(/^asset:\/\/[^/]*/, '')); - const b64 = await invoke('read_file_b64', { path }); + const b64 = await invoke('read_file_b64', {path}); dataUrl = `data:${imageMime(path)};base64,${b64}`; } else if (src.startsWith('https://') || src.startsWith('http://')) { - const [b64, mime] = await invoke<[string, string]>('fetch_url_b64', { url: src }); + const [b64, mime] = + await invoke<[string, string]>('fetch_url_b64', {url: src}); dataUrl = `data:${mime};base64,${b64}`; } else if (src.startsWith('tauri://') || src.startsWith('/')) { const fetchUrl = src.startsWith('/') ? `tauri://localhost${src}` : src; const res = await fetch(fetchUrl); if (res.ok) dataUrl = await blobToDataUrl(await res.blob()); } - } catch { /* leave original src */ } + } catch { /* leave original src */ + } if (dataUrl) img.src = dataUrl; })); } @@ -518,21 +549,23 @@ async function resolveVideos(el: HTMLElement): Promise { const vids = Array.from(el.querySelectorAll('video')); await Promise.all(vids.map(async (vid) => { const src = vid.getAttribute('src') ?? ''; - let dataUrl: string | null = null; + let dataUrl: string|null = null; try { if (src.startsWith('asset://')) { const path = decodeURIComponent(src.replace(/^asset:\/\/[^/]*/, '')); - const b64 = await invoke('read_file_b64', { path }); + const b64 = await invoke('read_file_b64', {path}); dataUrl = `data:${videoMime(path)};base64,${b64}`; } else if (src.startsWith('https://') || src.startsWith('http://')) { - const [b64, mime] = await invoke<[string, string]>('fetch_url_b64', { url: src }); + const [b64, mime] = + await invoke<[string, string]>('fetch_url_b64', {url: src}); dataUrl = `data:${mime};base64,${b64}`; } else if (src.startsWith('tauri://') || src.startsWith('/')) { const fetchUrl = src.startsWith('/') ? `tauri://localhost${src}` : src; const res = await fetch(fetchUrl); if (res.ok) dataUrl = await blobToDataUrl(await res.blob()); } - } catch { /* leave original src */ } + } catch { /* leave original src */ + } if (dataUrl) vid.src = dataUrl; })); } @@ -552,7 +585,8 @@ async function extractAllCss(): Promise { try { const res = await fetch(sheet.href); if (res.ok) parts.push(await res.text()); - } catch { /* skip */ } + } catch { /* skip */ + } } } } @@ -580,7 +614,7 @@ async function resolveFontUrls(css: string): Promise { let dataUrl: string; if (url.startsWith('asset://')) { const path = decodeURIComponent(url.replace(/^asset:\/\/[^/]*/, '')); - const b64 = await invoke('read_file_b64', { path }); + const b64 = await invoke('read_file_b64', {path}); dataUrl = `data:${extToFontMime(path)};base64,${b64}`; } else if (url.startsWith('tauri://') || url.startsWith('/')) { const fetchUrl = url.startsWith('/') ? `tauri://localhost${url}` : url; @@ -588,10 +622,11 @@ async function resolveFontUrls(css: string): Promise { if (!res.ok) return; dataUrl = await blobToDataUrl(await res.blob()); } else { - return; // leave http/https font URLs as-is + return; // leave http/https font URLs as-is } resolved.set(url, dataUrl); - } catch { /* leave URL as-is */ } + } catch { /* leave URL as-is */ + } })); // Replace all matched URLs in the CSS. @@ -604,13 +639,18 @@ async function resolveFontUrls(css: string): Promise { // ── Utilities ──────────────────────────────────────────────────────────────── function escapeHtml(s: string): string { - return s.replace(/[&<>]/g, (c) => (c === '&' ? '&' : c === '<' ? '<' : '>')); + return s.replace( + /[&<>]/g, + (c) => + (c === '&' ? '&' : + c === '<' ? '<' : + '>')); } function blobToDataUrl(blob: Blob): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); - reader.onload = () => resolve(reader.result as string); + reader.onload = () => resolve(reader.result as string); reader.onerror = reject; reader.readAsDataURL(blob); }); @@ -619,8 +659,8 @@ function blobToDataUrl(blob: Blob): Promise { function extToFontMime(path: string): string { const ext = path.split('.').pop()?.toLowerCase() ?? ''; if (ext === 'woff2') return 'font/woff2'; - if (ext === 'woff') return 'font/woff'; - if (ext === 'ttf') return 'font/ttf'; - if (ext === 'otf') return 'font/otf'; + if (ext === 'woff') return 'font/woff'; + if (ext === 'ttf') return 'font/ttf'; + if (ext === 'otf') return 'font/otf'; return 'font/woff2'; } diff --git a/src/hooks/useResolvedSlides.ts b/src/hooks/useResolvedSlides.ts index d1a47b61..85ba479e 100644 --- a/src/hooks/useResolvedSlides.ts +++ b/src/hooks/useResolvedSlides.ts @@ -1,15 +1,11 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; -import { invoke } from '@tauri-apps/api/core'; -import { detectLayout } from '../engine/layout/autoLayout'; -import { imageMime } from '../engine/export/imageMime'; -import { videoMime } from '../engine/export/videoMime'; -import type { Slide, ListItem } from '../engine/types'; -import { - localPathFromMediaSrc, - resolveImageSrc, - resolveHtmlSrcs, - VIDEO_EXT_RE, -} from '../engine/resolveMediaPath'; +import {invoke} from '@tauri-apps/api/core'; +import {useEffect, useMemo, useRef, useState} from 'react'; + +import {imageMime} from '../engine/export/imageMime'; +import {videoMime} from '../engine/export/videoMime'; +import {detectLayout} from '../engine/layout/autoLayout'; +import {localPathFromMediaSrc, resolveHtmlSrcs, resolveImageSrc, VIDEO_EXT_RE,} from '../engine/resolveMediaPath'; +import type {ListItem, Slide} from '../engine/types'; // Rewrite image srcs to data: URLs (or asset:// while loading) so Tauri's // WebView can load them reliably on all platforms, including Windows/WebView2, @@ -25,10 +21,11 @@ import { // exist (deleted, or shifted out of cache by an insertion) are // garbage-collected rather than accumulating for the life of the session. export function useResolvedSlides(rawSlides: Slide[], docDir: string): Slide[] { - // Load all local images/videos as base64 data URLs via IPC. convertFileSrc / the - // asset:// protocol is unreliable on Windows/WebView2, so we bypass it + // Load all local images/videos as base64 data URLs via IPC. convertFileSrc / + // the asset:// protocol is unreliable on Windows/WebView2, so we bypass it // entirely for local media files — the same approach already used for logos. - const [localImageUrls, setLocalImageUrls] = useState>(() => new Map()); + const [localImageUrls, setLocalImageUrls] = + useState>(() => new Map()); // parseDocument always returns a new *array* reference for rawSlides (even // when individual Slide objects are reused), so this effect re-runs on // every keystroke, not just when media actually changes. Track the last @@ -70,30 +67,46 @@ export function useResolvedSlides(rawSlides: Slide[], docDir: string): Slide[] { const setIfChanged = (next: Map) => { const prev = resolvedRef.current; - const unchanged = next.size === prev.size - && Array.from(next).every(([k, v]) => prev.get(k) === v); + const unchanged = next.size === prev.size && + Array.from(next).every(([k, v]) => prev.get(k) === v); if (unchanged) return; resolvedRef.current = next; setLocalImageUrls(next); }; - if (paths.size === 0) { setIfChanged(new Map()); return; } + if (paths.size === 0) { + setIfChanged(new Map()); + return; + } let cancelled = false; - Promise.all(Array.from(paths).map(async (path) => { - try { - const b64 = await invoke('read_file_b64', { path }); - const mime = VIDEO_EXT_RE.test(path) ? videoMime(path) : imageMime(path); - return [path, `data:${mime};base64,${b64}`] as [string, string]; - } catch (e) { console.error('[Kova] read_file_b64 failed for', path, e); return null; } - })).then((entries) => { - if (!cancelled) setIfChanged(new Map(entries.filter((e): e is [string, string] => e !== null))); - }); + Promise + .all(Array.from(paths).map(async (path) => { + try { + const b64 = await invoke('read_file_b64', {path}); + const mime = + VIDEO_EXT_RE.test(path) ? videoMime(path) : imageMime(path); + return [path, `data:${mime};base64,${b64}`] as [string, string]; + } catch (e) { + console.error('[Kova] read_file_b64 failed for', path, e); + return null; + } + })) + .then((entries) => { + if (!cancelled) + setIfChanged(new Map( + entries.filter((e): e is[string, string] => e !== null))); + }); - return () => { cancelled = true; }; + return () => { + cancelled = true; + }; }, [rawSlides, docDir]); - const resolvedSlidesCacheRef = useRef<{ docDir: string; localImageUrls: Map; cache: WeakMap }>({ + const resolvedSlidesCacheRef = useRef<{ + docDir: string; localImageUrls: Map; + cache: WeakMap + }>({ docDir: '', localImageUrls: new Map(), cache: new WeakMap(), @@ -101,27 +114,35 @@ export function useResolvedSlides(rawSlides: Slide[], docDir: string): Slide[] { const slides = useMemo(() => { function resolveItem(item: ListItem): ListItem { - return { ...item, html: resolveHtmlSrcs(item.html, docDir, localImageUrls), children: item.children.map(resolveItem) }; + return { + ...item, + html: resolveHtmlSrcs(item.html, docDir, localImageUrls), + children: item.children.map(resolveItem) + }; } let cacheHolder = resolvedSlidesCacheRef.current; - if (cacheHolder.docDir !== docDir || cacheHolder.localImageUrls !== localImageUrls) { - // docDir or image cache changed — every resolved src would be wrong, start fresh. - cacheHolder = { docDir, localImageUrls, cache: new WeakMap() }; + if (cacheHolder.docDir !== docDir || + cacheHolder.localImageUrls !== localImageUrls) { + // docDir or image cache changed — every resolved src would be wrong, + // start fresh. + cacheHolder = {docDir, localImageUrls, cache: new WeakMap()}; resolvedSlidesCacheRef.current = cacheHolder; } - const { cache } = cacheHolder; + const {cache} = cacheHolder; // Pre-compute TOC entries from all non-hidden, titled slides. Derived from - // rawSlides (titles are identical in raw vs resolved) so it's always current. - // TOC slides are excluded from the WeakMap cache below because their resolved - // content depends on other slides' titles, not just their own raw text. - // Exclude the first non-hidden H1 slide (the cover/title slide) from the TOC. - // Subsequent H1 hero slides within the deck are included. - const coverIndex = rawSlides.find((s) => !s.hidden && s.titleLevel === 1)?.index ?? -1; - const tocEntries = rawSlides - .filter((s) => !s.hidden && s.title && s.index !== coverIndex) - .map((s) => ({ title: s.title, index: s.index })); + // rawSlides (titles are identical in raw vs resolved) so it's always + // current. TOC slides are excluded from the WeakMap cache below because + // their resolved content depends on other slides' titles, not just their + // own raw text. Exclude the first non-hidden H1 slide (the cover/title + // slide) from the TOC. Subsequent H1 hero slides within the deck are + // included. + const coverIndex = + rawSlides.find((s) => !s.hidden && s.titleLevel === 1)?.index ?? -1; + const tocEntries = + rawSlides.filter((s) => !s.hidden && s.title && s.index !== coverIndex) + .map((s) => ({title: s.title, index: s.index})); return rawSlides.map((slide) => { const hasToc = slide.elements.some((e) => e.type === 'toc'); @@ -130,26 +151,39 @@ export function useResolvedSlides(rawSlides: Slide[], docDir: string): Slide[] { if (cached) return cached; } const resolvedElements = slide.elements.map((el) => { - if (el.type === 'image' || el.type === 'video') return { ...el, src: resolveImageSrc(el.src, docDir, localImageUrls) }; - if (el.type === 'paragraph') return { ...el, html: resolveHtmlSrcs(el.html, docDir, localImageUrls) }; - if (el.type === 'list') return { ...el, items: el.items.map(resolveItem) }; - if (el.type === 'toc') return { ...el, entries: tocEntries.filter((e) => e.index !== slide.index) }; + if (el.type === 'image' || el.type === 'video') + return {...el, src: resolveImageSrc(el.src, docDir, localImageUrls)}; + if (el.type === 'paragraph') + return { + ...el, + html: resolveHtmlSrcs(el.html, docDir, localImageUrls) + }; + if (el.type === 'list') + return {...el, items: el.items.map(resolveItem)}; + if (el.type === 'toc') + return { + ...el, + entries: tocEntries.filter((e) => e.index !== slide.index) + }; return el; }); // detectLayout ran at parse time against a placeholder toc with zero // entries (the real slide titles aren't known until this pass), so a // long toc never tripped the two-column overflow guard. Re-run it now // that entries are populated, unless the user pinned an explicit layout. - const layout = hasToc && !slide.layoutOverride - ? detectLayout(resolvedElements, slide.titleLevel, !!slide.title) - : slide.layout; + const layout = hasToc && !slide.layoutOverride ? + detectLayout(resolvedElements, slide.titleLevel, !!slide.title) : + slide.layout; const resolved: Slide = { ...slide, elements: resolvedElements, layout, - backgroundImage: slide.backgroundImage - ? { ...slide.backgroundImage, src: resolveImageSrc(slide.backgroundImage.src, docDir, localImageUrls) } - : undefined, + backgroundImage: slide.backgroundImage ? { + ...slide.backgroundImage, + src: + resolveImageSrc(slide.backgroundImage.src, docDir, localImageUrls) + } : + undefined, }; if (!hasToc) cache.set(slide, resolved); return resolved; From fd56a4eea6d09471e34fc147d39ae5b5f6679c9f Mon Sep 17 00:00:00 2001 From: Florian Hubert Date: Wed, 2 Sep 2026 07:32:43 +0200 Subject: [PATCH 2/4] review: Adapt to review comments --- src-tauri/src/cli.rs | 25 +--------------- src-tauri/src/commands/window.rs | 3 +- src-tauri/src/file_io.rs | 28 ++++++++++++++++-- src/App.tsx | 31 +++++++++++++++----- src/components/preview/SlideRenderer.tsx | 37 +++++++++++++++++++----- src/components/preview/elements.tsx | 3 +- 6 files changed, 82 insertions(+), 45 deletions(-) diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs index f6afc425..3cd01492 100644 --- a/src-tauri/src/cli.rs +++ b/src-tauri/src/cli.rs @@ -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) => strip_verbatim_prefix(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"); @@ -675,29 +675,6 @@ fn canonicalise_or_exit(path: &str, verb: &str) -> String { } } -/// `std::fs::canonicalize` on Windows returns the `\\?\` extended-length -/// ("verbatim") prefix form (and `\\?\UNC\host\share` for UNC paths). The -/// frontend derives a document directory from this path with plain string -/// splitting (see resolvePath.ts), which doesn't expect that prefix — its -/// literal `?` is mistaken for the start of a URL query string and truncates -/// every path built from it down to a single backslash. Strip it back to the -/// ordinary `C:\...` / `\\host\share\...` form other platforms already have. -#[cfg(windows)] -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))] -fn strip_verbatim_prefix(path: String) -> String { - path -} - /// Release builds use the Windows GUI subsystem (no console), so terminal /// output vanishes unless the process attaches to the parent's console /// first. Silently a no-op when there is no parent console (GUI launches) diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index 4a755181..04c7018c 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -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); diff --git a/src-tauri/src/file_io.rs b/src-tauri/src/file_io.rs index 5df54bb7..4eee6d81 100644 --- a/src-tauri/src/file_io.rs +++ b/src-tauri/src/file_io.rs @@ -42,9 +42,7 @@ pub fn safe_read_path(path: &str) -> Result { .map_err(|e| format!("Failed to read file: {e}"))?; check_in_home(&canonical)?; Ok(canonical) -} - -// For writes the file may not exist yet; canonicalize the parent instead. +}// For writes the file may not exist yet; canonicalize the parent instead. pub fn safe_write_path(path: &str) -> Result { let p = Path::new(path); let parent = p.parent().ok_or_else(|| "Invalid path: no parent directory".to_string())?; @@ -56,6 +54,30 @@ pub fn safe_write_path(path: &str) -> Result { 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 { let safe = safe_read_path(path)?; std::fs::read_to_string(&safe).map_err(|e| format!("Failed to read file: {e}")) diff --git a/src/App.tsx b/src/App.tsx index 4529dd80..b3bd284d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -526,15 +526,19 @@ export default function App() { // 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 asset:// (a placeholder used - // while the async read_file_b64 load is in flight) to data: URLs once loaded. - // A cold-start CLI export can otherwise fire its capture the instant 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://'); - const htmlIsPending = (html: string) => /src="asset:\/\//.test(html); + 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) => { @@ -547,6 +551,19 @@ export default function App() { }); }); }, [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; @@ -1723,7 +1740,7 @@ export default function App() { const coldExportFiredRef = useRef(false); useEffect(() => { if (!coldExport || coldExportFiredRef.current || !filePath) return; - if (hasPendingLocalMedia) return; // wait for local image/video data URLs to resolve + if (hasPendingLocalMedia && !mediaWaitExpired) return; // wait (bounded) for local image/video data URLs to resolve coldExportFiredRef.current = true; if (visibleSlides.length === 0) { invoke('cli_exit', { @@ -1770,7 +1787,7 @@ export default function App() { await fail(err instanceof Error ? err.message : String(err)); } })(); - }, [coldExport, filePath, visibleSlides, hasPendingLocalMedia, 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; diff --git a/src/components/preview/SlideRenderer.tsx b/src/components/preview/SlideRenderer.tsx index 62959f90..04313213 100644 --- a/src/components/preview/SlideRenderer.tsx +++ b/src/components/preview/SlideRenderer.tsx @@ -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. @@ -71,23 +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. - const zeroDiagramSignalledRef = useRef(false); useEffect(() => { - if (onAllDiagramsReady && mermaidCount === 0 && !zeroDiagramSignalledRef.current) { - zeroDiagramSignalledRef.current = true; - onAllDiagramsReady(); - } - }, [onAllDiagramsReady, mermaidCount]); + 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) 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 diff --git a/src/components/preview/elements.tsx b/src/components/preview/elements.tsx index 253199bd..c30586e6 100644 --- a/src/components/preview/elements.tsx +++ b/src/components/preview/elements.tsx @@ -522,7 +522,8 @@ export function MermaidDiagram({ value, caption }: { value: string; caption?: st // 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 is what will signal readiness. + // the replacement render's own completion (or SlideRenderer's timeout + // fallback) is what will signal readiness instead. return () => { cancelled = true; pendingSignalRef.current = null; From 2d8c48b7f6c7c8f738f85dcc51a7abe18b871e7b Mon Sep 17 00:00:00 2001 From: Florian Hubert Date: Wed, 2 Sep 2026 07:42:45 +0200 Subject: [PATCH 3/4] review: revert back to original styleguide --- src/engine/export/exportPdfNative.ts | 196 +++++++++++---------------- src/hooks/useResolvedSlides.ts | 140 ++++++++----------- 2 files changed, 131 insertions(+), 205 deletions(-) diff --git a/src/engine/export/exportPdfNative.ts b/src/engine/export/exportPdfNative.ts index 807a8f6a..63a58492 100644 --- a/src/engine/export/exportPdfNative.ts +++ b/src/engine/export/exportPdfNative.ts @@ -1,35 +1,31 @@ -import {invoke} from '@tauri-apps/api/core'; +import { invoke } from '@tauri-apps/api/core'; +import type { AspectRatio, Slide } from '../types'; +import { getSlideStepValues } from '../layout/steps'; +import { mermaidSvgCache } from './mermaidSvgCache'; +import { imageMime } from './imageMime'; +import { videoMime } from './videoMime'; +import { type PdfExportOpts, planPage, SLIDE_PX_W } from './pdfLayout'; -import {getSlideStepValues} from '../layout/steps'; -import type {AspectRatio, Slide} from '../types'; - -import {imageMime} from './imageMime'; -import {mermaidSvgCache} from './mermaidSvgCache'; -import {type PdfExportOpts, planPage, SLIDE_PX_W} from './pdfLayout'; -import {videoMime} from './videoMime'; - -export type {PdfExportOpts}; +export type { PdfExportOpts }; // ── Public entry point ─────────────────────────────────────────────────────── export async function exportPdfNative( - slideElements: HTMLElement[], - aspectRatio: AspectRatio, - savePath: string, - opts: PdfExportOpts = {}, - ): Promise { + slideElements: HTMLElement[], + aspectRatio: AspectRatio, + savePath: string, + opts: PdfExportOpts = {}, +): Promise { const html = await buildPrintDocument(slideElements, aspectRatio, opts); const plan = planPage(aspectRatio, opts); const perPage = opts.perPage ?? 1; - const pageCount = perPage > 1 ? Math.ceil(slideElements.length / perPage) : - slideElements.length; + const pageCount = perPage > 1 ? Math.ceil(slideElements.length / perPage) : slideElements.length; await invoke('export_pdf_native', { htmlContent: html, outputPath: savePath, widthMm: plan.pageWmm, heightMm: plan.pageHmm, - // Per-page capture rects for the macOS path (one createPDF per page, then - // merge). + // Per-page capture rects for the macOS path (one createPDF per page, then merge). pageCount, pageWidthPx: plan.pageWpx, pageHeightPx: plan.pageHpx, @@ -39,23 +35,22 @@ export async function exportPdfNative( // ── HTML serialiser ────────────────────────────────────────────────────────── export async function buildPrintDocument( - slideElements: HTMLElement[], - aspectRatio: AspectRatio, - opts: PdfExportOpts = {}, - ): Promise { + slideElements: HTMLElement[], + aspectRatio: AspectRatio, + opts: PdfExportOpts = {}, +): Promise { const plan = planPage(aspectRatio, opts); const perPage = opts.perPage ?? 1; // Read slide background color from the live DOM before cloning. const slideFrame = slideElements[0]?.querySelector('.slide-frame'); - const slideBg = slideFrame ? - getComputedStyle(slideFrame).getPropertyValue('--sl-bg').trim() : - ''; + const slideBg = slideFrame + ? getComputedStyle(slideFrame).getPropertyValue('--sl-bg').trim() + : ''; // 1. Clone elements and resolve all image/video URLs to data URIs in place. const clones = slideElements.map((el) => el.cloneNode(true) as HTMLElement); - await Promise.all( - clones.map((el) => Promise.all([resolveImages(el), resolveVideos(el)]))); + await Promise.all(clones.map((el) => Promise.all([resolveImages(el), resolveVideos(el)]))); // Belt-and-suspenders: if a Mermaid container is still a placeholder (SVG // not yet committed to the DOM when we cloned), inject from the render cache. clones.forEach(injectMermaidFallbacks); @@ -67,41 +62,25 @@ export async function buildPrintDocument( // Each slide is a 960×native box scaled into a frame sized to the slide's own // proportions (so the N-up border hugs the slide), centred in its slot. const slot = (el: HTMLElement) => - `
${ - el.outerHTML}
`; + `
${el.outerHTML}
`; // 3. Assemble pages — slides scaled/centred onto a standard paper page. let pages: string; if (plan.mode === 'nup') { const sheets: HTMLElement[][] = []; - for (let i = 0; i < clones.length; i += perPage) - sheets.push(clones.slice(i, i + perPage)); - pages = - sheets - .map( - (sheet) => - `
${ - sheet.map(slot).join('')}
`, - ) - .join('\n'); + for (let i = 0; i < clones.length; i += perPage) sheets.push(clones.slice(i, i + perPage)); + pages = sheets.map((sheet) => + `
${sheet.map(slot).join('')}
`, + ).join('\n'); } else if (plan.mode === 'notes') { - pages = - clones - .map((el, i) => { - const note = escapeHtml((opts.notes?.[i] ?? '').trim()); - return `
${ - slot(el)}
${note}
`; - }) - .join('\n'); + pages = clones.map((el, i) => { + const note = escapeHtml((opts.notes?.[i] ?? '').trim()); + return `
${slot(el)}
${note}
`; + }).join('\n'); } else { - pages = - clones - .map( - (el) => - `
${ - slot(el)}
`, - ) - .join('\n'); + pages = clones.map((el) => + `
${slot(el)}
`, + ).join('\n'); } const bgCss = slideBg ? `background: ${slideBg} !important;` : ''; @@ -177,10 +156,8 @@ html, body { justify-content: center !important; overflow: hidden !important; } -.kova-grid .kova-slot { width: ${plan.cellWpx}px !important; height: ${ - plan.cellHpx}px !important; } -.kova-col .kova-slot { width: 100% !important; height: ${ - plan.cellHpx}px !important; flex: 0 0 auto !important; } +.kova-grid .kova-slot { width: ${plan.cellWpx}px !important; height: ${plan.cellHpx}px !important; } +.kova-col .kova-slot { width: 100% !important; height: ${plan.cellHpx}px !important; flex: 0 0 auto !important; } .kova-center .kova-slot { width: 100% !important; height: 100% !important; } .kova-frame { position: relative !important; @@ -227,20 +204,19 @@ ${pages} * `buildPrintDocument`. */ export async function buildInteractiveDocument( - slideElements: HTMLElement[], - aspectRatio: AspectRatio, - slides: Slide[], - ): Promise { - const plan = planPage(aspectRatio, {fullBleed: true}); + slideElements: HTMLElement[], + aspectRatio: AspectRatio, + slides: Slide[], +): Promise { + const plan = planPage(aspectRatio, { fullBleed: true }); const slideFrame = slideElements[0]?.querySelector('.slide-frame'); - const slideBg = slideFrame ? - getComputedStyle(slideFrame).getPropertyValue('--sl-bg').trim() : - ''; + const slideBg = slideFrame + ? getComputedStyle(slideFrame).getPropertyValue('--sl-bg').trim() + : ''; const clones = slideElements.map((el) => el.cloneNode(true) as HTMLElement); - await Promise.all( - clones.map((el) => Promise.all([resolveImages(el), resolveVideos(el)]))); + await Promise.all(clones.map((el) => Promise.all([resolveImages(el), resolveVideos(el)]))); clones.forEach(injectMermaidFallbacks); clones.forEach(inlinePrintColorAdjust); @@ -257,7 +233,10 @@ export async function buildInteractiveDocument( /** Pure HTML assembler — unit-tested without Tauri/DOM asset fetches. */ export function assembleInteractiveDocument(opts: { - css: string; slideHtml: string[]; slideW: number; slideH: number; + css: string; + slideHtml: string[]; + slideW: number; + slideH: number; background?: string; /** * One entry per slide (parallel to `slideHtml`): that slide's distinct @@ -271,20 +250,13 @@ export function assembleInteractiveDocument(opts: { */ slideSteps?: number[][]; }): string { - const {css, slideHtml, slideW, slideH} = opts; + const { css, slideHtml, slideW, slideH } = opts; const slideSteps = opts.slideSteps ?? slideHtml.map(() => []); const background = opts.background || '#111'; - const slides = - slideHtml - .map( - (html, i) => - `
` + - `
${ - html}
`, - ) - .join('\n'); + const slides = slideHtml.map((html, i) => + `
` + + `
${html}
`, + ).join('\n'); const total = slideHtml.length; return ` @@ -482,19 +454,18 @@ ${slides} // (race between setSvg() and signalReady()), the clone will be a placeholder // div with no SVG child. Inject the cached SVG string so the diagram appears. function injectMermaidFallbacks(root: HTMLElement): void { - const containers = - Array.from(root.querySelectorAll('[data-mermaid-src]')); + const containers = Array.from(root.querySelectorAll('[data-mermaid-src]')); for (const container of containers) { if (container.querySelector('svg')) continue; const src = container.getAttribute('data-mermaid-src') ?? ''; const cached = mermaidSvgCache.get(src); if (!cached) continue; const scaled = cached.replace(/]*)>/i, (_m, attrs: string) => { - let a = attrs.replace(/\bwidth="[^"]*"/, 'width="100%"') - .replace(/\bheight="[^"]*"/, 'height="100%"') - .replace(/\bstyle="[^"]*max-width[^"]*"/, ''); - if (!/preserveAspectRatio/.test(a)) - a += ' preserveAspectRatio="xMidYMid meet"'; + let a = attrs + .replace(/\bwidth="[^"]*"/, 'width="100%"') + .replace(/\bheight="[^"]*"/, 'height="100%"') + .replace(/\bstyle="[^"]*max-width[^"]*"/, ''); + if (!/preserveAspectRatio/.test(a)) a += ' preserveAspectRatio="xMidYMid meet"'; return ``; }); container.innerHTML = scaled; @@ -524,23 +495,21 @@ async function resolveImages(el: HTMLElement): Promise { const imgs = Array.from(el.querySelectorAll('img')); await Promise.all(imgs.map(async (img) => { const src = img.getAttribute('src') ?? ''; - let dataUrl: string|null = null; + let dataUrl: string | null = null; try { if (src.startsWith('asset://')) { const path = decodeURIComponent(src.replace(/^asset:\/\/[^/]*/, '')); - const b64 = await invoke('read_file_b64', {path}); + const b64 = await invoke('read_file_b64', { path }); dataUrl = `data:${imageMime(path)};base64,${b64}`; } else if (src.startsWith('https://') || src.startsWith('http://')) { - const [b64, mime] = - await invoke<[string, string]>('fetch_url_b64', {url: src}); + const [b64, mime] = await invoke<[string, string]>('fetch_url_b64', { url: src }); dataUrl = `data:${mime};base64,${b64}`; } else if (src.startsWith('tauri://') || src.startsWith('/')) { const fetchUrl = src.startsWith('/') ? `tauri://localhost${src}` : src; const res = await fetch(fetchUrl); if (res.ok) dataUrl = await blobToDataUrl(await res.blob()); } - } catch { /* leave original src */ - } + } catch { /* leave original src */ } if (dataUrl) img.src = dataUrl; })); } @@ -549,23 +518,21 @@ async function resolveVideos(el: HTMLElement): Promise { const vids = Array.from(el.querySelectorAll('video')); await Promise.all(vids.map(async (vid) => { const src = vid.getAttribute('src') ?? ''; - let dataUrl: string|null = null; + let dataUrl: string | null = null; try { if (src.startsWith('asset://')) { const path = decodeURIComponent(src.replace(/^asset:\/\/[^/]*/, '')); - const b64 = await invoke('read_file_b64', {path}); + const b64 = await invoke('read_file_b64', { path }); dataUrl = `data:${videoMime(path)};base64,${b64}`; } else if (src.startsWith('https://') || src.startsWith('http://')) { - const [b64, mime] = - await invoke<[string, string]>('fetch_url_b64', {url: src}); + const [b64, mime] = await invoke<[string, string]>('fetch_url_b64', { url: src }); dataUrl = `data:${mime};base64,${b64}`; } else if (src.startsWith('tauri://') || src.startsWith('/')) { const fetchUrl = src.startsWith('/') ? `tauri://localhost${src}` : src; const res = await fetch(fetchUrl); if (res.ok) dataUrl = await blobToDataUrl(await res.blob()); } - } catch { /* leave original src */ - } + } catch { /* leave original src */ } if (dataUrl) vid.src = dataUrl; })); } @@ -585,8 +552,7 @@ async function extractAllCss(): Promise { try { const res = await fetch(sheet.href); if (res.ok) parts.push(await res.text()); - } catch { /* skip */ - } + } catch { /* skip */ } } } } @@ -614,7 +580,7 @@ async function resolveFontUrls(css: string): Promise { let dataUrl: string; if (url.startsWith('asset://')) { const path = decodeURIComponent(url.replace(/^asset:\/\/[^/]*/, '')); - const b64 = await invoke('read_file_b64', {path}); + const b64 = await invoke('read_file_b64', { path }); dataUrl = `data:${extToFontMime(path)};base64,${b64}`; } else if (url.startsWith('tauri://') || url.startsWith('/')) { const fetchUrl = url.startsWith('/') ? `tauri://localhost${url}` : url; @@ -622,11 +588,10 @@ async function resolveFontUrls(css: string): Promise { if (!res.ok) return; dataUrl = await blobToDataUrl(await res.blob()); } else { - return; // leave http/https font URLs as-is + return; // leave http/https font URLs as-is } resolved.set(url, dataUrl); - } catch { /* leave URL as-is */ - } + } catch { /* leave URL as-is */ } })); // Replace all matched URLs in the CSS. @@ -639,18 +604,13 @@ async function resolveFontUrls(css: string): Promise { // ── Utilities ──────────────────────────────────────────────────────────────── function escapeHtml(s: string): string { - return s.replace( - /[&<>]/g, - (c) => - (c === '&' ? '&' : - c === '<' ? '<' : - '>')); + return s.replace(/[&<>]/g, (c) => (c === '&' ? '&' : c === '<' ? '<' : '>')); } function blobToDataUrl(blob: Blob): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); - reader.onload = () => resolve(reader.result as string); + reader.onload = () => resolve(reader.result as string); reader.onerror = reject; reader.readAsDataURL(blob); }); @@ -659,8 +619,8 @@ function blobToDataUrl(blob: Blob): Promise { function extToFontMime(path: string): string { const ext = path.split('.').pop()?.toLowerCase() ?? ''; if (ext === 'woff2') return 'font/woff2'; - if (ext === 'woff') return 'font/woff'; - if (ext === 'ttf') return 'font/ttf'; - if (ext === 'otf') return 'font/otf'; + if (ext === 'woff') return 'font/woff'; + if (ext === 'ttf') return 'font/ttf'; + if (ext === 'otf') return 'font/otf'; return 'font/woff2'; } diff --git a/src/hooks/useResolvedSlides.ts b/src/hooks/useResolvedSlides.ts index 85ba479e..d1a47b61 100644 --- a/src/hooks/useResolvedSlides.ts +++ b/src/hooks/useResolvedSlides.ts @@ -1,11 +1,15 @@ -import {invoke} from '@tauri-apps/api/core'; -import {useEffect, useMemo, useRef, useState} from 'react'; - -import {imageMime} from '../engine/export/imageMime'; -import {videoMime} from '../engine/export/videoMime'; -import {detectLayout} from '../engine/layout/autoLayout'; -import {localPathFromMediaSrc, resolveHtmlSrcs, resolveImageSrc, VIDEO_EXT_RE,} from '../engine/resolveMediaPath'; -import type {ListItem, Slide} from '../engine/types'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { detectLayout } from '../engine/layout/autoLayout'; +import { imageMime } from '../engine/export/imageMime'; +import { videoMime } from '../engine/export/videoMime'; +import type { Slide, ListItem } from '../engine/types'; +import { + localPathFromMediaSrc, + resolveImageSrc, + resolveHtmlSrcs, + VIDEO_EXT_RE, +} from '../engine/resolveMediaPath'; // Rewrite image srcs to data: URLs (or asset:// while loading) so Tauri's // WebView can load them reliably on all platforms, including Windows/WebView2, @@ -21,11 +25,10 @@ import type {ListItem, Slide} from '../engine/types'; // exist (deleted, or shifted out of cache by an insertion) are // garbage-collected rather than accumulating for the life of the session. export function useResolvedSlides(rawSlides: Slide[], docDir: string): Slide[] { - // Load all local images/videos as base64 data URLs via IPC. convertFileSrc / - // the asset:// protocol is unreliable on Windows/WebView2, so we bypass it + // Load all local images/videos as base64 data URLs via IPC. convertFileSrc / the + // asset:// protocol is unreliable on Windows/WebView2, so we bypass it // entirely for local media files — the same approach already used for logos. - const [localImageUrls, setLocalImageUrls] = - useState>(() => new Map()); + const [localImageUrls, setLocalImageUrls] = useState>(() => new Map()); // parseDocument always returns a new *array* reference for rawSlides (even // when individual Slide objects are reused), so this effect re-runs on // every keystroke, not just when media actually changes. Track the last @@ -67,46 +70,30 @@ export function useResolvedSlides(rawSlides: Slide[], docDir: string): Slide[] { const setIfChanged = (next: Map) => { const prev = resolvedRef.current; - const unchanged = next.size === prev.size && - Array.from(next).every(([k, v]) => prev.get(k) === v); + const unchanged = next.size === prev.size + && Array.from(next).every(([k, v]) => prev.get(k) === v); if (unchanged) return; resolvedRef.current = next; setLocalImageUrls(next); }; - if (paths.size === 0) { - setIfChanged(new Map()); - return; - } + if (paths.size === 0) { setIfChanged(new Map()); return; } let cancelled = false; - Promise - .all(Array.from(paths).map(async (path) => { - try { - const b64 = await invoke('read_file_b64', {path}); - const mime = - VIDEO_EXT_RE.test(path) ? videoMime(path) : imageMime(path); - return [path, `data:${mime};base64,${b64}`] as [string, string]; - } catch (e) { - console.error('[Kova] read_file_b64 failed for', path, e); - return null; - } - })) - .then((entries) => { - if (!cancelled) - setIfChanged(new Map( - entries.filter((e): e is[string, string] => e !== null))); - }); + Promise.all(Array.from(paths).map(async (path) => { + try { + const b64 = await invoke('read_file_b64', { path }); + const mime = VIDEO_EXT_RE.test(path) ? videoMime(path) : imageMime(path); + return [path, `data:${mime};base64,${b64}`] as [string, string]; + } catch (e) { console.error('[Kova] read_file_b64 failed for', path, e); return null; } + })).then((entries) => { + if (!cancelled) setIfChanged(new Map(entries.filter((e): e is [string, string] => e !== null))); + }); - return () => { - cancelled = true; - }; + return () => { cancelled = true; }; }, [rawSlides, docDir]); - const resolvedSlidesCacheRef = useRef<{ - docDir: string; localImageUrls: Map; - cache: WeakMap - }>({ + const resolvedSlidesCacheRef = useRef<{ docDir: string; localImageUrls: Map; cache: WeakMap }>({ docDir: '', localImageUrls: new Map(), cache: new WeakMap(), @@ -114,35 +101,27 @@ export function useResolvedSlides(rawSlides: Slide[], docDir: string): Slide[] { const slides = useMemo(() => { function resolveItem(item: ListItem): ListItem { - return { - ...item, - html: resolveHtmlSrcs(item.html, docDir, localImageUrls), - children: item.children.map(resolveItem) - }; + return { ...item, html: resolveHtmlSrcs(item.html, docDir, localImageUrls), children: item.children.map(resolveItem) }; } let cacheHolder = resolvedSlidesCacheRef.current; - if (cacheHolder.docDir !== docDir || - cacheHolder.localImageUrls !== localImageUrls) { - // docDir or image cache changed — every resolved src would be wrong, - // start fresh. - cacheHolder = {docDir, localImageUrls, cache: new WeakMap()}; + if (cacheHolder.docDir !== docDir || cacheHolder.localImageUrls !== localImageUrls) { + // docDir or image cache changed — every resolved src would be wrong, start fresh. + cacheHolder = { docDir, localImageUrls, cache: new WeakMap() }; resolvedSlidesCacheRef.current = cacheHolder; } - const {cache} = cacheHolder; + const { cache } = cacheHolder; // Pre-compute TOC entries from all non-hidden, titled slides. Derived from - // rawSlides (titles are identical in raw vs resolved) so it's always - // current. TOC slides are excluded from the WeakMap cache below because - // their resolved content depends on other slides' titles, not just their - // own raw text. Exclude the first non-hidden H1 slide (the cover/title - // slide) from the TOC. Subsequent H1 hero slides within the deck are - // included. - const coverIndex = - rawSlides.find((s) => !s.hidden && s.titleLevel === 1)?.index ?? -1; - const tocEntries = - rawSlides.filter((s) => !s.hidden && s.title && s.index !== coverIndex) - .map((s) => ({title: s.title, index: s.index})); + // rawSlides (titles are identical in raw vs resolved) so it's always current. + // TOC slides are excluded from the WeakMap cache below because their resolved + // content depends on other slides' titles, not just their own raw text. + // Exclude the first non-hidden H1 slide (the cover/title slide) from the TOC. + // Subsequent H1 hero slides within the deck are included. + const coverIndex = rawSlides.find((s) => !s.hidden && s.titleLevel === 1)?.index ?? -1; + const tocEntries = rawSlides + .filter((s) => !s.hidden && s.title && s.index !== coverIndex) + .map((s) => ({ title: s.title, index: s.index })); return rawSlides.map((slide) => { const hasToc = slide.elements.some((e) => e.type === 'toc'); @@ -151,39 +130,26 @@ export function useResolvedSlides(rawSlides: Slide[], docDir: string): Slide[] { if (cached) return cached; } const resolvedElements = slide.elements.map((el) => { - if (el.type === 'image' || el.type === 'video') - return {...el, src: resolveImageSrc(el.src, docDir, localImageUrls)}; - if (el.type === 'paragraph') - return { - ...el, - html: resolveHtmlSrcs(el.html, docDir, localImageUrls) - }; - if (el.type === 'list') - return {...el, items: el.items.map(resolveItem)}; - if (el.type === 'toc') - return { - ...el, - entries: tocEntries.filter((e) => e.index !== slide.index) - }; + if (el.type === 'image' || el.type === 'video') return { ...el, src: resolveImageSrc(el.src, docDir, localImageUrls) }; + if (el.type === 'paragraph') return { ...el, html: resolveHtmlSrcs(el.html, docDir, localImageUrls) }; + if (el.type === 'list') return { ...el, items: el.items.map(resolveItem) }; + if (el.type === 'toc') return { ...el, entries: tocEntries.filter((e) => e.index !== slide.index) }; return el; }); // detectLayout ran at parse time against a placeholder toc with zero // entries (the real slide titles aren't known until this pass), so a // long toc never tripped the two-column overflow guard. Re-run it now // that entries are populated, unless the user pinned an explicit layout. - const layout = hasToc && !slide.layoutOverride ? - detectLayout(resolvedElements, slide.titleLevel, !!slide.title) : - slide.layout; + const layout = hasToc && !slide.layoutOverride + ? detectLayout(resolvedElements, slide.titleLevel, !!slide.title) + : slide.layout; const resolved: Slide = { ...slide, elements: resolvedElements, layout, - backgroundImage: slide.backgroundImage ? { - ...slide.backgroundImage, - src: - resolveImageSrc(slide.backgroundImage.src, docDir, localImageUrls) - } : - undefined, + backgroundImage: slide.backgroundImage + ? { ...slide.backgroundImage, src: resolveImageSrc(slide.backgroundImage.src, docDir, localImageUrls) } + : undefined, }; if (!hasToc) cache.set(slide, resolved); return resolved; From 2d9b12900b724badd0efd48bb50d80de132fc6d0 Mon Sep 17 00:00:00 2001 From: RDMillen Date: Wed, 2 Sep 2026 20:34:22 +0100 Subject: [PATCH 4/4] style: restore blank line between safe_read_path and safe_write_path The separating blank line and the safe_write_path doc comment were collapsed onto the closing brace during the review edits. Co-Authored-By: Claude Sonnet 5 --- src-tauri/src/file_io.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/file_io.rs b/src-tauri/src/file_io.rs index 4eee6d81..cd9e8c18 100644 --- a/src-tauri/src/file_io.rs +++ b/src-tauri/src/file_io.rs @@ -42,7 +42,9 @@ pub fn safe_read_path(path: &str) -> Result { .map_err(|e| format!("Failed to read file: {e}"))?; check_in_home(&canonical)?; Ok(canonical) -}// For writes the file may not exist yet; canonicalize the parent instead. +} + +// For writes the file may not exist yet; canonicalize the parent instead. pub fn safe_write_path(path: &str) -> Result { let p = Path::new(path); let parent = p.parent().ok_or_else(|| "Invalid path: no parent directory".to_string())?;