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(
+ '
',
+ { notePath: 'Folder/Note.md', vaultPath }
+ )
+
+ expect(html).toBe(`
`)
+ })
+
+ 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: `` })
+ 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