diff --git a/apps/desktop/src/main/ipc/notes-handlers-extra.test.ts b/apps/desktop/src/main/ipc/notes-handlers-extra.test.ts index 0af3b7389..941d06163 100644 --- a/apps/desktop/src/main/ipc/notes-handlers-extra.test.ts +++ b/apps/desktop/src/main/ipc/notes-handlers-extra.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import path from 'path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { NotesChannels } from '@memry/contracts/notes-api' import { PropertyTypes } from '@memry/contracts/property-types' @@ -10,6 +13,7 @@ const mocks = vi.hoisted(() => { } const windowInstance = { loadURL: vi.fn().mockResolvedValue(undefined), + loadFile: vi.fn().mockResolvedValue(undefined), webContents, destroy: vi.fn(), isDestroyed: vi.fn(() => false) @@ -34,6 +38,8 @@ const mocks = vi.hoisted(() => { windowInstance, webContents, fsWriteFile: vi.fn(), + fsRm: vi.fn(), + appGetPath: vi.fn(() => '/tmp'), resolveNoteByTitle: vi.fn(), resolveNotesByTitles: vi.fn(), getNoteTags: vi.fn(), @@ -63,6 +69,8 @@ const mocks = vi.hoisted(() => { countLocalOnlyNoteMetadata: vi.fn(), listPropertyDefinitions: vi.fn(), emitNoteAttachmentSaved: vi.fn(), + getVaultStatus: vi.fn(() => ({ path: null }) as { path: string | null }), + renderNoteAsHtml: vi.fn(() => 'note'), service: { get: vi.fn(), upsert: vi.fn(), @@ -84,12 +92,14 @@ vi.mock('electron', () => ({ removeHandler: mocks.removeHandler }, dialog: mocks.dialog, - BrowserWindow: mocks.BrowserWindow + BrowserWindow: mocks.BrowserWindow, + app: { getPath: mocks.appGetPath } })) vi.mock('fs/promises', async (importOriginal) => ({ ...(await importOriginal()), - writeFile: mocks.fsWriteFile + writeFile: mocks.fsWriteFile, + rm: mocks.fsRm })) vi.mock('../database', () => ({ @@ -173,10 +183,15 @@ vi.mock('../vault/property-definitions', () => ({ })) vi.mock('../lib/export-utils', () => ({ - renderNoteAsHtml: vi.fn(() => 'note'), + renderNoteAsHtml: mocks.renderNoteAsHtml, sanitizeFilename: vi.fn((value: string) => value.replace(/\W+/g, '_')) })) +vi.mock('../vault/index', async (importOriginal) => ({ + ...(await importOriginal()), + getStatus: mocks.getVaultStatus +})) + vi.mock('../lib/main-i18n', () => ({ getMainI18n: () => ({ t: (key: string) => key, @@ -197,7 +212,8 @@ vi.mock('@memry/storage-data', () => ({ listPropertyDefinitions: mocks.listPropertyDefinitions })) -vi.mock('@memry/shared/file-types', () => ({ +vi.mock('@memry/shared/file-types', async (importOriginal) => ({ + ...(await importOriginal()), getAllSupportedExtensions: vi.fn(() => ['md', 'pdf', 'png']) })) @@ -216,21 +232,38 @@ const successful = (result: unknown): unknown => { return result } +const PNG_BYTES = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', + 'base64' +) +const PNG_BASE64 = PNG_BYTES.toString('base64') + describe('notes-handlers extra coverage', () => { + let vaultPath: string + beforeEach(() => { + vaultPath = mkdtempSync(path.join(tmpdir(), 'memry-export-handler-')) + mkdirSync(path.join(vaultPath, 'attachments', 'note-a'), { recursive: true }) + writeFileSync(path.join(vaultPath, 'attachments', 'note-a', 'photo.png'), PNG_BYTES) vi.clearAllMocks() mocks.handlers.clear() mocks.webContents.printToPDF.mockResolvedValue(Buffer.from('pdf')) mocks.windowInstance.loadURL.mockResolvedValue(undefined) + mocks.windowInstance.loadFile.mockResolvedValue(undefined) mocks.windowInstance.isDestroyed.mockReturnValue(false) mocks.fsWriteFile.mockResolvedValue(undefined) + mocks.fsRm.mockResolvedValue(undefined) + mocks.appGetPath.mockReturnValue('/tmp') mocks.service.get.mockReturnValue(null) + mocks.getVaultStatus.mockReturnValue({ path: null }) + mocks.renderNoteAsHtml.mockReturnValue('note') registerNotesHandlers() }) afterEach(() => { unregisterNotesHandlers() mocks.handlers.clear() + rmSync(vaultPath, { recursive: true, force: true }) }) it('resolves a batch of titles into a plain record over one channel', async () => { @@ -531,6 +564,7 @@ describe('notes-handlers extra coverage', () => { const note = { id: 'note-a', + path: 'Note.md', title: 'Daily note', content: '# Today', emoji: null, @@ -539,6 +573,10 @@ describe('notes-handlers extra coverage', () => { modified: new Date('2026-05-10T00:00:00.000Z') } mocks.getNoteById.mockResolvedValue(note) + mocks.getVaultStatus.mockReturnValue({ path: vaultPath }) + mocks.renderNoteAsHtml.mockReturnValue( + '' + ) mocks.dialog.showSaveDialog.mockResolvedValueOnce({ canceled: false, filePath: '/tmp/Daily_note.pdf' @@ -559,6 +597,17 @@ describe('notes-handlers extra coverage', () => { ) expect(mocks.fsWriteFile).toHaveBeenCalledWith('/tmp/Daily_note.pdf', Buffer.from('pdf')) + // Staged to a real file rather than a `data:` URL, which Chromium rejects + // past its length ceiling once an image is inlined. + const staged = mocks.windowInstance.loadFile.mock.calls.at(-1)?.[0] as string + expect(staged).toMatch(/^\/tmp\/memry-export-.+\.html$/) + expect(mocks.fsWriteFile).toHaveBeenCalledWith( + staged, + ``, + 'utf-8' + ) + expect(mocks.fsRm).toHaveBeenCalledWith(staged, { force: true }) + mocks.dialog.showSaveDialog.mockResolvedValueOnce({ canceled: true }) await expect( invoke(NotesChannels.invoke.EXPORT_HTML, { @@ -579,13 +628,66 @@ describe('notes-handlers extra coverage', () => { pageSize: 'A4' }) ).resolves.toEqual({ success: true, path: '/tmp/Daily_note.html' }) + // Self-contained, so the file keeps its images once the user moves it. + expect(mocks.fsWriteFile).toHaveBeenCalledWith( + '/tmp/Daily_note.html', + ``, + 'utf-8' + ) + }) + + it('leaves an image it cannot read as written', async () => { + mocks.getNoteById.mockResolvedValue({ + id: 'note-a', + path: 'Note.md', + title: 'Daily note', + content: '# Today', + emoji: null, + tags: [], + created: new Date('2026-05-10T00:00:00.000Z'), + modified: new Date('2026-05-10T00:00:00.000Z') + }) + mocks.getVaultStatus.mockReturnValue({ path: vaultPath }) + mocks.renderNoteAsHtml.mockReturnValue('') + + await expect( + invoke(NotesChannels.invoke.EXPORT_HTML, { + noteId: 'note-a', + outputPath: '/tmp/Daily_note.html', + includeMetadata: false, + pageSize: 'A4' + }) + ).resolves.toEqual({ success: true, path: '/tmp/Daily_note.html' }) expect(mocks.fsWriteFile).toHaveBeenCalledWith( '/tmp/Daily_note.html', - 'note', + '', 'utf-8' ) }) + it('still exports when the staged HTML cannot be removed', async () => { + mocks.getNoteById.mockResolvedValue({ + id: 'note-a', + path: 'Note.md', + title: 'Daily note', + content: '# Today', + emoji: null, + tags: [], + created: new Date('2026-05-10T00:00:00.000Z'), + modified: new Date('2026-05-10T00:00:00.000Z') + }) + mocks.fsRm.mockRejectedValueOnce(new Error('EBUSY')) + + await expect( + invoke(NotesChannels.invoke.EXPORT_PDF, { + noteId: 'note-a', + outputPath: '/tmp/Daily_note.pdf', + includeMetadata: false, + pageSize: 'A4' + }) + ).resolves.toEqual({ success: true, path: '/tmp/Daily_note.pdf' }) + }) + it('destroys the hidden PDF window when rendering fails', async () => { const note = { id: 'note-a', @@ -608,10 +710,13 @@ describe('notes-handlers extra coverage', () => { }) ).toEqual({ success: false, error: 'printToPDF crashed' }) expect(mocks.windowInstance.destroy).toHaveBeenCalledTimes(1) - expect(mocks.fsWriteFile).not.toHaveBeenCalled() + expect(mocks.fsWriteFile).not.toHaveBeenCalledWith('/tmp/Daily_note.pdf', expect.anything()) + // The staged HTML is removed even when the print fails. + const staged = mocks.windowInstance.loadFile.mock.calls.at(-1)?.[0] as string + expect(mocks.fsRm).toHaveBeenCalledWith(staged, { force: true }) mocks.windowInstance.destroy.mockClear() - mocks.windowInstance.loadURL.mockRejectedValueOnce(new Error('loadURL crashed')) + mocks.windowInstance.loadFile.mockRejectedValueOnce(new Error('loadFile crashed')) expect( await invoke(NotesChannels.invoke.EXPORT_PDF, { noteId: 'note-a', @@ -619,7 +724,7 @@ describe('notes-handlers extra coverage', () => { includeMetadata: false, pageSize: 'A4' }) - ).toEqual({ success: false, error: 'loadURL crashed' }) + ).toEqual({ success: false, error: 'loadFile crashed' }) expect(mocks.windowInstance.destroy).toHaveBeenCalledTimes(1) // An already-destroyed window is never destroyed twice. diff --git a/apps/desktop/src/main/ipc/notes-handlers.ts b/apps/desktop/src/main/ipc/notes-handlers.ts index 20bd383c2..e002539ea 100644 --- a/apps/desktop/src/main/ipc/notes-handlers.ts +++ b/apps/desktop/src/main/ipc/notes-handlers.ts @@ -5,8 +5,10 @@ * @module ipc/notes-handlers */ -import { ipcMain, dialog, BrowserWindow } from 'electron' +import { ipcMain, dialog, BrowserWindow, app } from 'electron' import * as fs from 'fs/promises' +import * as path from 'path' +import { randomUUID } from 'crypto' import { z } from 'zod' import { NotesChannels, @@ -46,6 +48,7 @@ import { withErrorHandler } from './validate' import { registerCommand } from './lib/register-command' +import type { Note } from '../vault/notes' import { getNoteById, getNoteByPath, @@ -78,6 +81,8 @@ import { import { applyTemplateToNote } from '../notes/apply-template' import { getAllSupportedExtensions } from '@memry/shared/file-types' import { saveAttachment, deleteAttachment, listNoteAttachments } from '../vault/attachments' +import { getStatus as getVaultStatus } from '../vault/index' +import { inlineExportImages } from '../lib/export-image-inliner' import { readFolderConfig, writeFolderConfig, getFolderTemplate } from '../vault/folders' import { syncFolderConfigSet, @@ -193,6 +198,29 @@ const ExportNoteSchema = z.object({ outputPath: z.string().min(1).optional() }) +/** + * Render a note for export with its images carried inside the document. + * + * Both export paths go through here so the two stay in step. The PDF path has + * no base URL to resolve a relative `` against, and an exported + * `.html` only kept its images while it sat next to the attachments (#1935). + */ +async function renderNoteForExport(note: Note, includeMetadata: boolean): Promise { + const html = renderNoteAsHtml( + { + id: note.id, + title: note.title, + content: note.content, + emoji: note.emoji, + tags: note.tags, + created: note.created, + modified: note.modified + }, + { includeMetadata } + ) + return inlineExportImages(html, { notePath: note.path, vaultPath: getVaultStatus().path }) +} + /** * Register all note-related IPC handlers. * Call this once during app initialization. @@ -893,18 +921,7 @@ export function registerNotesHandlers(): void { targetPath = result.filePath } - const html = renderNoteAsHtml( - { - id: note.id, - title: note.title, - content: note.content, - emoji: note.emoji, - tags: note.tags, - created: note.created, - modified: note.modified - }, - { includeMetadata: input.includeMetadata } - ) + const html = await renderNoteForExport(note, input.includeMetadata) const win = new BrowserWindow({ show: false, @@ -915,9 +932,16 @@ export function registerNotesHandlers(): void { } }) + // A `data:` URL would be simpler, but Chromium rejects one past its URL + // length ceiling with ERR_INVALID_URL, and a note holding one phone + // photograph clears that ceiling once its images are inlined. A real file + // has no such limit. + const stagedHtmlPath = path.join(app.getPath('temp'), `memry-export-${randomUUID()}.html`) + let pdfData: Buffer try { - await win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`) + await fs.writeFile(stagedHtmlPath, html, 'utf-8') + await win.loadFile(stagedHtmlPath) await new Promise((resolve) => setTimeout(resolve, 100)) const pageSizeMap: Record = { @@ -938,6 +962,9 @@ export function registerNotesHandlers(): void { }) } finally { if (!win.isDestroyed()) win.destroy() + await fs.rm(stagedHtmlPath, { force: true }).catch((error) => { + logger.warn('Failed to remove the staged export HTML', { stagedHtmlPath, error }) + }) } await fs.writeFile(targetPath, pdfData) @@ -982,18 +1009,7 @@ export function registerNotesHandlers(): void { targetPath = result.filePath } - const html = renderNoteAsHtml( - { - id: note.id, - title: note.title, - content: note.content, - emoji: note.emoji, - tags: note.tags, - created: note.created, - modified: note.modified - }, - { includeMetadata: input.includeMetadata } - ) + const html = await renderNoteForExport(note, input.includeMetadata) await fs.writeFile(targetPath, html, 'utf-8') diff --git a/apps/desktop/src/main/lib/export-image-inliner.test.ts b/apps/desktop/src/main/lib/export-image-inliner.test.ts new file mode 100644 index 000000000..cb211e68d --- /dev/null +++ b/apps/desktop/src/main/lib/export-image-inliner.test.ts @@ -0,0 +1,225 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import path from 'path' +import { pathToFileURL } from 'url' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ warn: vi.fn() })) + +vi.mock('./logger', () => ({ + createLogger: () => ({ warn: mocks.warn, error: vi.fn(), info: vi.fn(), debug: vi.fn() }) +})) + +import { toMemryFileUrl } from './paths' +import { inlineExportImages } from './export-image-inliner' + +const PNG_BYTES = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', + 'base64' +) +const PNG_BASE64 = PNG_BYTES.toString('base64') + +let vaultPath: string +let outsidePath: string + +beforeEach(() => { + mocks.warn.mockClear() + vaultPath = mkdtempSync(path.join(tmpdir(), 'memry-export-vault-')) + outsidePath = mkdtempSync(path.join(tmpdir(), 'memry-export-outside-')) + mkdirSync(path.join(vaultPath, 'attachments', 'note-a'), { recursive: true }) + mkdirSync(path.join(vaultPath, 'Folder'), { recursive: true }) + writeFileSync(path.join(vaultPath, 'attachments', 'note-a', 'photo.png'), PNG_BYTES) +}) + +afterEach(() => { + rmSync(vaultPath, { recursive: true, force: true }) + rmSync(outsidePath, { recursive: true, force: true }) +}) + +describe('inlineExportImages', () => { + it('inlines a note-relative image as a data URI carrying the file bytes', async () => { + const html = await inlineExportImages( + '

pic

', + { notePath: 'Folder/Note.md', vaultPath } + ) + + expect(html).toBe(`

pic

`) + }) + + it('resolves a relative ref for a note that sits at the vault root', async () => { + const html = await inlineExportImages('', { + notePath: 'Note.md', + vaultPath + }) + + expect(html).toBe(``) + }) + + it('percent-decodes a relative ref before reading it off disk', async () => { + writeFileSync(path.join(vaultPath, 'attachments', 'note-a', 'my photo.png'), PNG_BYTES) + + const html = await inlineExportImages('', { + notePath: 'Note.md', + vaultPath + }) + + expect(html).toBe(``) + }) + + it('inlines an image that lives outside the vault', async () => { + const outside = path.join(outsidePath, 'outside.png') + writeFileSync(outside, PNG_BYTES) + + const html = await inlineExportImages(``, { + notePath: 'Folder/Note.md', + vaultPath + }) + + expect(html).toBe(``) + }) + + it('inlines an outside-the-vault image referenced by file:// URL', async () => { + const outside = path.join(outsidePath, 'outside.png') + writeFileSync(outside, PNG_BYTES) + + const html = await inlineExportImages(``, { + notePath: 'Folder/Note.md', + vaultPath + }) + + expect(html).toBe(``) + }) + + it('inlines a legacy memry-file:// ref', async () => { + const outside = path.join(outsidePath, 'my photo.png') + writeFileSync(outside, PNG_BYTES) + + const html = await inlineExportImages(``, { + notePath: 'Folder/Note.md', + vaultPath + }) + + expect(html).toBe(``) + }) + + it('leaves an unreadable path untouched and logs it', async () => { + const source = '' + + const html = await inlineExportImages(source, { notePath: 'Note.md', vaultPath }) + + expect(html).toBe(source) + expect(mocks.warn).toHaveBeenCalledWith( + 'Export could not inline an image, leaving the reference as written', + expect.objectContaining({ src: 'attachments/note-a/missing.png' }) + ) + }) + + it('leaves data:, http: and https: sources alone without reading disk', async () => { + const source = + '' + + expect(await inlineExportImages(source, { notePath: 'Note.md', vaultPath })).toBe(source) + expect(mocks.warn).not.toHaveBeenCalled() + }) + + it('leaves a ref that climbs above the vault root untouched', async () => { + // The file the climb lands on when `..` is collapsed instead of rejected. + // Without the guard this would be inlined from the vault root. + writeFileSync(path.join(vaultPath, 'escape.png'), PNG_BYTES) + const source = '' + + expect(await inlineExportImages(source, { notePath: 'Folder/Note.md', vaultPath })).toBe(source) + }) + + it('leaves a readable file whose extension is not a known image type', async () => { + writeFileSync(path.join(vaultPath, 'attachments', 'note-a', 'photo.heic'), PNG_BYTES) + const source = '' + + expect(await inlineExportImages(source, { notePath: 'Note.md', vaultPath })).toBe(source) + expect(mocks.warn).toHaveBeenCalledWith( + 'Export skipped a reference that is not a known image type', + expect.objectContaining({ src: 'attachments/note-a/photo.heic' }) + ) + }) + + it('never embeds a non-image file the note points at by absolute path', async () => { + const secret = path.join(outsidePath, 'id_rsa') + writeFileSync(secret, 'PRIVATE KEY BYTES') + + for (const src of [secret, pathToFileURL(secret).href, toMemryFileUrl(secret)]) { + const source = `` + const html = await inlineExportImages(source, { notePath: 'Folder/Note.md', vaultPath }) + + expect(html).toBe(source) + expect(html).not.toContain('PRIVATE KEY BYTES') + expect(html).not.toContain(Buffer.from('PRIVATE KEY BYTES').toString('base64')) + } + }) + + it('inlines every extension on the image allowlist', async () => { + const cases: Array<[string, string]> = [ + ['a.png', 'image/png'], + ['a.jpg', 'image/jpeg'], + ['a.jpeg', 'image/jpeg'], + ['a.gif', 'image/gif'], + ['a.webp', 'image/webp'], + ['a.svg', 'image/svg+xml'], + ['a.bmp', 'image/bmp'], + ['a.avif', 'image/avif'], + ['a.ico', 'image/x-icon'] + ] + + for (const [filename, mime] of cases) { + writeFileSync(path.join(vaultPath, 'attachments', 'note-a', filename), PNG_BYTES) + const html = await inlineExportImages(``, { + notePath: 'Note.md', + vaultPath + }) + + expect(html).toBe(``) + } + }) + + it('falls back to the raw ref when the percent-encoding is malformed', async () => { + writeFileSync(path.join(vaultPath, 'attachments', 'note-a', '%E0%A4%A.png'), PNG_BYTES) + + const html = await inlineExportImages('', { + notePath: 'Note.md', + vaultPath + }) + + expect(html).toBe(``) + }) + + it('leaves a malformed file: or memry-file: URL untouched instead of throwing', async () => { + for (const src of ['file://host/photo.png', 'memry-file://local/%E0%A4%A.png']) { + const source = `` + + expect(await inlineExportImages(source, { notePath: 'Note.md', vaultPath })).toBe(source) + } + }) + + it('rewrites every occurrence of a repeated image and keeps the original quoting', async () => { + const html = await inlineExportImages( + '', + { notePath: 'Note.md', vaultPath } + ) + + expect(html).toBe( + `` + ) + }) + + it('leaves the html alone when the note path or the vault path is unknown', async () => { + const source = '' + + expect(await inlineExportImages(source, { notePath: undefined, vaultPath })).toBe(source) + expect(await inlineExportImages(source, { notePath: 'Note.md', vaultPath: null })).toBe(source) + }) + + it('does not touch a src attribute that is not on an img tag', async () => { + const source = '' + + expect(await inlineExportImages(source, { notePath: 'Note.md', vaultPath })).toBe(source) + }) +}) diff --git a/apps/desktop/src/main/lib/export-image-inliner.ts b/apps/desktop/src/main/lib/export-image-inliner.ts new file mode 100644 index 000000000..93ada7bf4 --- /dev/null +++ b/apps/desktop/src/main/lib/export-image-inliner.ts @@ -0,0 +1,181 @@ +/** + * Carry an exported note's images inside the exported document. + * + * PDF export loads the rendered HTML through a `data:text/html` URL, which has + * an opaque origin and no base URL, so a relative `` + * has nothing to resolve against and `printToPDF` bakes in a broken image + * (#1935). HTML export only looked right because the file happened to land next + * to the attachments; move the `.html` and it breaks the same way. + * + * Inlining the bytes fixes both paths with one rule. It needs no base URL and + * no script, so it works with `webPreferences.javascript: false`, and it makes + * an exported `.html` self-contained. The cost is document size, since base64 + * runs about a third larger than the file on disk. + * + * Only the image extensions below are inlined. A note is data, and a synced or + * imported one can name any path its author liked, so without the allowlist an + * `` would put those bytes into a document the user + * then emails. `memry-file:` has the protocol handler's vault and userData + * check behind it; every other scheme here has nothing, so the extension is the + * gate. + * + * @module lib/export-image-inliner + */ + +import { readFile } from 'fs/promises' +import path from 'path' +import { fileURLToPath } from 'url' +import { getExtension } from '@memry/shared/file-types' +import { createLogger } from './logger' + +const logger = createLogger('ExportImageInliner') + +export interface ExportImageSource { + /** The exported note's vault-relative path, e.g. `Folder/Note.md`. */ + notePath?: string + /** Absolute path of the open vault. */ + vaultPath?: string | null +} + +const IMAGE_MIME: Record = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + svg: 'image/svg+xml', + bmp: 'image/bmp', + avif: 'image/avif', + ico: 'image/x-icon' +} + +const IMG_TAG = /]*>/gi +const SRC_ATTR = /(\ssrc\s*=\s*)(["'])([^"']*)\2/i + +/** Matches `https:`, `data:`, `memry-file:`, and `C:` on Windows. */ +const HAS_SCHEME = /^[a-zA-Z][a-zA-Z\d+\-.]*:/ +const WINDOWS_DRIVE = /^[a-zA-Z]:[/\\]/ +const SEPARATOR = /[/\\]/ + +/** + * Join the note's vault-relative directory with a relative ref, collapsing `.` + * and `..`. Returns null when the ref climbs above the vault root. + * + * Restated from the renderer's `resolve-note-relative-url.ts` rather than + * imported, because the renderer is not importable here. Both sides have to + * agree on what a note-relative ref means, or the export resolves images the + * editor does not. + */ +function joinWithinVault(dir: string, ref: string): string[] | null { + const out: string[] = [] + for (const segment of [...dir.split(SEPARATOR), ...ref.split(SEPARATOR)]) { + if (!segment || segment === '.') continue + if (segment === '..') { + if (out.length === 0) return null + out.pop() + continue + } + out.push(segment) + } + return out.length > 0 ? out : null +} + +function memryFileUrlToPath(url: string): string | null { + try { + const decoded = decodeURIComponent(new URL(url).pathname) + if (process.platform === 'win32') { + return decoded.startsWith('/') ? decoded.slice(1) : decoded + } + return decoded.startsWith('/') ? decoded : `/${decoded}` + } catch { + return null + } +} + +/** + * The on-disk file an `` names, or null when the src is not a local + * file we should read: a remote or already-inlined URL, an unknown scheme, or a + * relative ref that escapes the vault. + */ +function resolveLocalPath(src: string, source: ExportImageSource): string | null { + const ref = src.trim() + if (!ref) return null + if (WINDOWS_DRIVE.test(ref)) return ref + + if (HAS_SCHEME.test(ref)) { + const scheme = ref.slice(0, ref.indexOf(':')).toLowerCase() + if (scheme === 'file') { + try { + return fileURLToPath(ref) + } catch { + return null + } + } + if (scheme === 'memry-file') return memryFileUrlToPath(ref) + return null + } + + if (ref.startsWith('/') || ref.startsWith('\\')) return ref + + const { notePath, vaultPath } = source + if (!notePath || !vaultPath) return null + + let decoded: string + try { + decoded = decodeURIComponent(ref) + } catch { + decoded = ref + } + + const noteDir = notePath.split(SEPARATOR).slice(0, -1).join('/') + const segments = joinWithinVault(noteDir, decoded) + if (!segments) return null + + return path.join(vaultPath, ...segments) +} + +/** + * Rewrite every `` in a rendered note to a `data:` URI holding the + * file's bytes. A src that names no readable local file is left as written. + */ +export async function inlineExportImages(html: string, source: ExportImageSource): Promise { + const refs = new Set() + for (const tag of html.match(IMG_TAG) ?? []) { + const src = SRC_ATTR.exec(tag)?.[3] + if (src) refs.add(src) + } + if (refs.size === 0) return html + + const inlined = new Map() + await Promise.all( + [...refs].map(async (src) => { + const filePath = resolveLocalPath(src, source) + if (!filePath) return + + const mime = IMAGE_MIME[getExtension(filePath)] + if (!mime) { + logger.warn('Export skipped a reference that is not a known image type', { src, filePath }) + return + } + + try { + const bytes = await readFile(filePath) + inlined.set(src, `data:${mime};base64,${bytes.toString('base64')}`) + } catch (error) { + logger.warn('Export could not inline an image, leaving the reference as written', { + src, + filePath, + error + }) + } + }) + ) + if (inlined.size === 0) return html + + return html.replace(IMG_TAG, (tag) => + tag.replace(SRC_ATTR, (attribute: string, prefix: string, quote: string, src: string) => { + const dataUri = inlined.get(src) + return dataUri ? `${prefix}${quote}${dataUri}${quote}` : attribute + }) + ) +} diff --git a/apps/desktop/src/renderer/src/components/home/widgets/calendar-widget-refresh.test.tsx b/apps/desktop/src/renderer/src/components/home/widgets/calendar-widget-refresh.test.tsx index 79c683835..09a7444dc 100644 --- a/apps/desktop/src/renderer/src/components/home/widgets/calendar-widget-refresh.test.tsx +++ b/apps/desktop/src/renderer/src/components/home/widgets/calendar-widget-refresh.test.tsx @@ -31,10 +31,28 @@ const SOURCE_TYPE_BY_VISUAL_TYPE = { note: 'note' } as const +/** + * The instant these tests pretend it is, on today's real local date. + * + * `use-today` snapshots the local date into module scope at import and re-reads the wall clock + * for its first subscriber. A clock faked onto any other date therefore arrives as a midnight + * rollover, which moves `todayCalendarRange`, moves the query key with it, and makes the widget + * fetch a second day on mount. Only the time of day is pinned, and from local fields rather than + * a UTC instant, which far enough from UTC would name a different day. + */ +const NOW = new Date() +NOW.setHours(9, 30, 0, 0) + +function todayAtHour(hour: number): string { + const at = new Date(NOW) + at.setHours(hour, 0, 0, 0) + return at.toISOString() +} + function projectionItem( id: string, title: string, - hourUtc: number, + hour: number, visualType: keyof typeof SOURCE_TYPE_BY_VISUAL_TYPE ): CalendarProjectionItem { return { @@ -43,8 +61,8 @@ function projectionItem( sourceId: id, title, descriptionPreview: null, - startAt: `2026-08-31T${String(hourUtc).padStart(2, '0')}:00:00.000Z`, - endAt: `2026-08-31T${String(hourUtc + 1).padStart(2, '0')}:00:00.000Z`, + startAt: todayAtHour(hour), + endAt: todayAtHour(hour + 1), isAllDay: false, timezone: 'UTC', visualType, @@ -101,7 +119,7 @@ function renderApp(): { showBoard: (visible: boolean) => void } { describe('home calendar widget stays current', () => { beforeEach(() => { - vi.setSystemTime(new Date('2026-08-31T09:30:00.000Z')) + vi.setSystemTime(NOW) listeners.clear() server.items = [projectionItem('e1', 'Standup', 10, 'event')] mockGetRange.mockReset() diff --git a/apps/desktop/tests/e2e/note-export-images.e2e.ts b/apps/desktop/tests/e2e/note-export-images.e2e.ts new file mode 100644 index 000000000..40b11732d --- /dev/null +++ b/apps/desktop/tests/e2e/note-export-images.e2e.ts @@ -0,0 +1,201 @@ +/** + * Note export image embedding E2E (issue #1935) + * + * A note that embeds an attachment must keep that image in both export + * formats. PDF export loads the rendered HTML through a `data:` URL, which has + * no base URL, so a relative `` used to arrive at + * `printToPDF` broken. HTML export only looked right while the file sat next to + * the attachments folder. + * + * Export is driven through `window.api.notes.export*` with an explicit + * `outputPath`. That is the same handler the note menu invokes; the menu path + * itself opens a native save dialog, which Playwright cannot answer. + */ + +import fs from 'fs' +import os from 'os' +import path from 'path' +import zlib from 'zlib' +import type { Page } from '@playwright/test' +import { test, expect } from './fixtures' +import { waitForAppReady, waitForVaultReady } from './utils/electron-helpers' + +/** + * A `size`x`size` RGB PNG. `level` 0 stores the pixels uncompressed, which is + * how the large case reaches the megabytes needed to clear Chromium's URL + * length ceiling, the ceiling a phone photograph clears on its own. + */ +function makePng(size: number, level: number): Buffer { + const stride = size * 3 + 1 + const raw = Buffer.alloc(size * stride) + for (let i = 0; i < raw.length; i++) raw[i] = i % 251 + for (let y = 0; y < size; y++) raw[y * stride] = 0 + + const chunk = (type: string, data: Buffer): Buffer => { + const body = Buffer.concat([Buffer.from(type, 'latin1'), data]) + const length = Buffer.alloc(4) + length.writeUInt32BE(data.length) + const crc = Buffer.alloc(4) + crc.writeUInt32BE(zlib.crc32(body)) + return Buffer.concat([length, body, crc]) + } + + const ihdr = Buffer.alloc(13) + ihdr.writeUInt32BE(size, 0) + ihdr.writeUInt32BE(size, 4) + ihdr[8] = 8 + ihdr[9] = 2 + return Buffer.concat([ + Buffer.from('89504e470d0a1a0a', 'hex'), + chunk('IHDR', ihdr), + chunk('IDAT', zlib.deflateSync(raw, { level })), + chunk('IEND', Buffer.alloc(0)) + ]) +} + +/** The image the exported note embeds, wide enough to tell from a broken-image icon. */ +const IMAGE_WIDTH = 613 +const PNG_BYTES = makePng(IMAGE_WIDTH, 9) +const PNG_BASE64 = PNG_BYTES.toString('base64') + +/** + * The widest image object in a PDF. Chromium always writes some image, so the + * presence of one proves nothing. A broken reference leaves only the tiny + * broken-image icon, while a resolved one embeds the note's own bitmap. + */ +function widestEmbeddedImage(pdf: Buffer): number { + const widths = [...pdf.toString('latin1').matchAll(/\/Width\s+(\d+)/g)].map((m) => Number(m[1])) + return widths.length > 0 ? Math.max(...widths) : 0 +} + +interface SeededNote { + noteId: string + /** The note-relative ref the markdown carries, e.g. `../attachments//x.png`. */ + ref: string +} + +/** + * A note whose body embeds a real uploaded attachment. + * + * The attachment is uploaded against a host note created first, then the + * exported note is created WITH the markdown embed as its initial content, + * because a post-create update loses to the CRDT body. + */ +async function seedNoteWithImage( + page: Page, + title: string, + png: Buffer = PNG_BYTES +): Promise { + return page.evaluate( + async ({ t, base64 }) => { + const api = window.api + + const host = await api.notes.create({ title: `${t} host`, content: 'attachment host' }) + if (!host.success || !host.note) throw new Error(host.error ?? 'host note create failed') + + // base64 rather than a number array, so a multi-megabyte image serialises + // into the page in a fraction of the time. + const binary = atob(base64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) + + const file = new File([bytes], 'export-pic.png', { type: 'image/png' }) + const uploaded = await api.notes.uploadAttachment(host.note.id, file) + if (!uploaded.success || !uploaded.path) { + throw new Error(uploaded.error ?? 'attachment upload failed') + } + + const note = await api.notes.create({ title: t, content: `![pic](${uploaded.path})` }) + if (!note.success || !note.note) throw new Error(note.error ?? 'note create failed') + + return { noteId: note.note.id, ref: uploaded.path } + }, + { t: title, base64: png.toString('base64') } + ) +} + +test.describe('Note export keeps embedded images', () => { + let exportDir: string + let movedDir: string + + test.beforeEach(async ({ page }) => { + exportDir = fs.mkdtempSync(path.join(os.tmpdir(), 'memry-export-')) + movedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'memry-export-moved-')) + await waitForAppReady(page) + await waitForVaultReady(page) + }) + + test.afterEach(() => { + fs.rmSync(exportDir, { recursive: true, force: true }) + fs.rmSync(movedDir, { recursive: true, force: true }) + }) + + test('exported HTML carries the image after the file is moved', async ({ page }) => { + const seeded = await seedNoteWithImage(page, 'Export html images') + expect(seeded.ref).toContain('attachments/') + + const outputPath = path.join(exportDir, 'note.html') + const result = await page.evaluate( + async ({ noteId, out }) => + window.api.notes.exportHtml({ noteId, includeMetadata: false, outputPath: out }), + { noteId: seeded.noteId, out: outputPath } + ) + expect(result.success, result.error ?? 'export failed').toBe(true) + + const movedPath = path.join(movedDir, 'note.html') + fs.renameSync(outputPath, movedPath) + const html = fs.readFileSync(movedPath, 'utf-8') + + const src = /]*\ssrc="([^"]*)"/.exec(html)?.[1] + expect(src, 'exported html has no ').toBeTruthy() + expect(src).toBe(`data:image/png;base64,${PNG_BASE64}`) + expect(html).not.toContain('attachments/') + }) + + test('exported PDF embeds the image rather than a broken reference', async ({ page }) => { + const seeded = await seedNoteWithImage(page, 'Export pdf images') + + const outputPath = path.join(exportDir, 'note.pdf') + const result = await page.evaluate( + async ({ noteId, out }) => + window.api.notes.exportPdf({ + noteId, + includeMetadata: false, + pageSize: 'A4', + outputPath: out + }), + { noteId: seeded.noteId, out: outputPath } + ) + expect(result.success, result.error ?? 'export failed').toBe(true) + + const pdf = fs.readFileSync(outputPath) + expect(pdf.subarray(0, 5).toString('latin1')).toBe('%PDF-') + expect(pdf.toString('latin1')).toContain('/Image') + const widest = widestEmbeddedImage(pdf) + expect(widest, `widest embedded image was ${widest}px`).toBeGreaterThan(64) + }) + + test('exported PDF embeds an image far larger than the URL length ceiling', async ({ page }) => { + const png = makePng(900, 0) + expect(png.byteLength).toBeGreaterThan(2 * 1024 * 1024) + const seeded = await seedNoteWithImage(page, 'Export big pdf images', png) + + const outputPath = path.join(exportDir, 'big.pdf') + const result = await page.evaluate( + async ({ noteId, out }) => + window.api.notes.exportPdf({ + noteId, + includeMetadata: false, + pageSize: 'A4', + outputPath: out + }), + { noteId: seeded.noteId, out: outputPath } + ) + expect(result.success, result.error ?? 'export failed').toBe(true) + + const pdf = fs.readFileSync(outputPath) + expect(pdf.subarray(0, 5).toString('latin1')).toBe('%PDF-') + const widest = widestEmbeddedImage(pdf) + expect(widest, `widest embedded image was ${widest}px`).toBeGreaterThan(64) + }) +}) diff --git a/apps/docs/src/user-guide/notes/editing.md b/apps/docs/src/user-guide/notes/editing.md index 13d5cfade..7d343bc38 100644 --- a/apps/docs/src/user-guide/notes/editing.md +++ b/apps/docs/src/user-guide/notes/editing.md @@ -205,7 +205,7 @@ The **⋯ button** in the top-right of a note (the _More actions_ menu) collects - **Local graph** — show or hide the note's local link graph - **Find…** — open in-note search (also +F) - **Version history** — browse and restore past versions -- **Export** — export the note to PDF or HTML +- **Export** — export the note to PDF or HTML. Both formats embed the note's images in the exported file itself, so the PDF prints them and an exported `.html` keeps them after you move or send it - **Apply template** — insert a template into the note - **Full width** — toggle the wide editor layout