From 1132ef13e8fa52b7d5532ea3efacb69d31026dff Mon Sep 17 00:00:00 2001 From: Vladimir Urushev Date: Sun, 21 Jun 2026 19:18:44 +0200 Subject: [PATCH] feat: obsidian-style image width and local image upload --- src/elements/image.ts | 53 ++++++++++++----- src/images/preprocess.ts | 68 ++++++++++++++++++++++ src/sync.ts | 7 ++- src/types.ts | 1 + test/converter.test.ts | 46 ++++++++++++++- test/images.test.ts | 121 +++++++++++++++++++++++++++++++++++++++ test/sync.test.ts | 1 + 7 files changed, 281 insertions(+), 16 deletions(-) create mode 100644 src/images/preprocess.ts create mode 100644 test/images.test.ts diff --git a/src/elements/image.ts b/src/elements/image.ts index fc175bf..868799f 100644 --- a/src/elements/image.ts +++ b/src/elements/image.ts @@ -2,26 +2,51 @@ import type { Image } from 'mdast' import { registerConverter } from '../registry.js' import { escapeAttr } from '../utils.js' -registerConverter('image', (node, _context) => { +registerConverter('image', (node, context) => { const url = node.url - const alt = node.alt || '' const title = node.title + const { alt, width, height } = parseAltSize(node.alt || '') - // Check if it's a remote URL or local file - const isRemote = url.startsWith('http://') || url.startsWith('https://') + const altAttr = alt ? ` ac:alt="${escapeAttr(alt)}"` : '' + const titleAttr = title ? ` ac:title="${escapeAttr(title)}"` : '' + // width/height come from \d+ matches, so they need no escaping. + const widthAttr = width ? ` ac:width="${width}"` : '' + const heightAttr = height ? ` ac:height="${height}"` : '' + const attrs = `${altAttr}${titleAttr}${widthAttr}${heightAttr}` + const isRemote = url.startsWith('http://') || url.startsWith('https://') if (isRemote) { - // External image - const titleAttr = title ? ` ac:title="${escapeAttr(title)}"` : '' - const altAttr = alt ? ` ac:alt="${escapeAttr(alt)}"` : '' - return `` + return `` } - // Local file - will be uploaded as attachment - // The filename is the last part of the path - const filename = url.split('/').pop() || url - const titleAttr = title ? ` ac:title="${escapeAttr(title)}"` : '' - const altAttr = alt ? ` ac:alt="${escapeAttr(alt)}"` : '' + // data: URIs can't be uploaded as attachments and Confluence won't render them inline. + // Drop them with a warning rather than emit a bogus with the whole URI as a filename. + if (url.startsWith('data:')) { + console.warn('Skipping unsupported data: URI image') + return '' + } - return `` + // Local file: preprocessImages records the uploaded (content-hashed) name keyed by url. + // Fall back to the basename when it wasn't uploaded (missing file, or converter run without preprocess). + const filename = context.localImages.get(url) ?? (url.split('/').pop() || url) + return `` }) + +interface AltSize { + alt: string + width?: string + height?: string +} + +// Obsidian-style sizing lives after the last `|` in the alt text: `alt|300` or `alt|300x200`. +// A segment that isn't `` or `x` is left verbatim in the alt text. +function parseAltSize(alt: string): AltSize { + const idx = alt.lastIndexOf('|') + if (idx === -1) return { alt } + + const sizePart = alt.slice(idx + 1).trim() + const m = /^(\d+)(?:x(\d+))?$/.exec(sizePart) + if (!m) return { alt } + + return { alt: alt.slice(0, idx).trim(), width: m[1], height: m[2] } +} diff --git a/src/images/preprocess.ts b/src/images/preprocess.ts new file mode 100644 index 0000000..e3f1acd --- /dev/null +++ b/src/images/preprocess.ts @@ -0,0 +1,68 @@ +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { basename, extname, resolve } from 'node:path' +import type { Image, Node, Parent } from 'mdast' +import type { ConversionContext } from '../types.js' + +const CONTENT_TYPES: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', +} + +function contentTypeFor(ext: string): string | undefined { + return CONTENT_TYPES[ext.toLowerCase()] +} + +// A url is a local-file candidate unless it points at a remote or inline resource. +function isLocalCandidate(url: string): boolean { + return !url.startsWith('http://') && !url.startsWith('https://') && !url.startsWith('data:') +} + +function collectImages(node: Node, images: Image[]): void { + if (node.type === 'image') { + images.push(node as Image) + } + + if ('children' in node && Array.isArray((node as Parent).children)) { + for (const child of (node as Parent).children) { + collectImages(child, images) + } + } +} + +export async function preprocessImages(ast: Node, context: ConversionContext, baseDir: string): Promise { + const images: Image[] = [] + collectImages(ast, images) + + for (const image of images) { + const url = image.url + if (!isLocalCandidate(url)) continue + // Dedup by reference: the same url only needs reading once. + if (context.localImages.has(url)) continue + + const ext = extname(url) + const contentType = contentTypeFor(ext) + if (!contentType) { + console.warn(`Skipping image with unsupported extension: ${url}`) + continue + } + + let data: Buffer + try { + data = await readFile(resolve(baseDir, url)) + } catch (error) { + console.warn(`Failed to read local image ${url}: ${error}`) + continue + } + + const hash = createHash('md5').update(data).digest('hex').slice(0, 12) + const filename = `${basename(url, ext)}-${hash}${ext}` + + context.attachments.set(filename, { filename, data, contentType }) + context.localImages.set(url, filename) + } +} diff --git a/src/sync.ts b/src/sync.ts index c1ac79b..6554ef9 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -1,9 +1,10 @@ import { createHash } from 'node:crypto' import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' -import { extname, resolve } from 'node:path' +import { dirname, extname, resolve } from 'node:path' import { ConfluenceApiError, ConfluenceClient } from './confluence/client.js' import type { Page } from './confluence/types.js' import { convert } from './converter.js' +import { preprocessImages } from './images/preprocess.js' import { closeBrowser, preprocessMermaid } from './mermaid/index.js' import { parse } from './parser.js' import type { AttachmentInfo, Config, ConversionContext } from './types.js' @@ -54,12 +55,16 @@ async function syncFile(file: string, config: Config, client: ConfluenceClient): config, frontmatter, attachments: new Map(), + localImages: new Map(), pageId: frontmatter['confluence-page-id'], } // Pre-render mermaid diagrams (populates context.attachments) await preprocessMermaid(ast, context) + // Upload local image files referenced by the markdown (populates context.attachments + localImages) + await preprocessImages(ast, context, dirname(absolutePath)) + const content = convert(ast, context) const contentHash = createHash('md5').update(content).digest('hex') diff --git a/src/types.ts b/src/types.ts index c509c50..f05e3d8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -30,6 +30,7 @@ export interface ConversionContext { config: Config frontmatter: Frontmatter attachments: Map + localImages: Map pageId?: string } diff --git a/test/converter.test.ts b/test/converter.test.ts index b0a9aff..881df79 100644 --- a/test/converter.test.ts +++ b/test/converter.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { convert } from '../src/converter.js' import { getMermaidFilename } from '../src/mermaid/render.js' import { parse } from '../src/parser.js' @@ -15,6 +15,7 @@ function createContext(): ConversionContext { }, frontmatter: {}, attachments: new Map(), + localImages: new Map(), } } @@ -124,6 +125,49 @@ describe('Images', () => { const result = md2confluence('![alt](./image.png)') expect(result).toContain('') }) + + it('parses Obsidian-style width on remote images', () => { + const result = md2confluence('![a|300](https://x/p.png)') + expect(result).toContain('ac:width="300"') + expect(result).toContain('') + expect(result).toContain('ac:alt="a"') + expect(result).not.toContain('ac:height') + }) + + it('parses Obsidian-style width and height', () => { + const result = md2confluence('![a|300x200](https://x/p.png)') + expect(result).toContain('ac:width="300"') + expect(result).toContain('ac:height="200"') + }) + + it('leaves non-numeric pipe segments in alt text', () => { + const result = md2confluence('![a|b](https://x/p.png)') + expect(result).not.toContain('ac:width') + expect(result).toContain('ac:alt="a|b"') + }) + + it('omits empty alt when only a width is given', () => { + const result = md2confluence('![|300](https://x/p.png)') + expect(result).toContain('ac:width="300"') + expect(result).not.toContain('ac:alt') + }) + + it('applies width to local images via the basename fallback', () => { + const result = md2confluence('![a|300](./pic.png)') + expect(result).toContain('ac:width="300"') + expect(result).toContain('') + }) + + it('drops data: URI images instead of emitting a bogus attachment', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const result = md2confluence('![x](data:image/png;base64,iVBORw0KGgo=)') + expect(result).toBe('

') + expect(result).not.toContain('ri:attachment') + } finally { + warn.mockRestore() + } + }) }) describe('Blockquotes', () => { diff --git a/test/images.test.ts b/test/images.test.ts new file mode 100644 index 0000000..e90aa16 --- /dev/null +++ b/test/images.test.ts @@ -0,0 +1,121 @@ +import { createHash } from 'node:crypto' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { convert } from '../src/converter.js' +import { preprocessImages } from '../src/images/preprocess.js' +import { parse } from '../src/parser.js' +import type { ConversionContext } from '../src/types.js' + +function createContext(): ConversionContext { + return { + config: { + domain: 'test.atlassian.net', + space: 'TEST', + auth: { email: 'test@example.com', token: 'xxx' }, + }, + frontmatter: {}, + attachments: new Map(), + localImages: new Map(), + } +} + +describe('preprocessImages', () => { + it('uploads a local image under a content-hashed name and emits it', async () => { + const dir = mkdtempSync(join(tmpdir(), 'markfluence-img-')) + try { + const bytes = Buffer.from('not-a-real-png-but-fine-for-hashing') + writeFileSync(join(dir, 'img.png'), bytes) + const hash = createHash('md5').update(bytes).digest('hex').slice(0, 12) + const expectedName = `img-${hash}.png` + + const { ast } = parse('![diagram](./img.png)') + const context = createContext() + await preprocessImages(ast, context, dir) + + expect(context.attachments.has(expectedName)).toBe(true) + const attachment = context.attachments.get(expectedName) + expect(attachment?.contentType).toBe('image/png') + expect(attachment?.data).toEqual(bytes) + expect(context.localImages.get('./img.png')).toBe(expectedName) + + const xml = convert(ast, context) + expect(xml).toContain(``) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('warns and skips a missing local image without throwing', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const { ast } = parse('![missing](./nope.png)') + const context = createContext() + + await expect(preprocessImages(ast, context, tmpdir())).resolves.toBeUndefined() + expect(context.attachments.size).toBe(0) + expect(context.localImages.size).toBe(0) + expect(warn).toHaveBeenCalled() + + // Converter falls back to the basename for the un-uploaded file. + expect(convert(ast, context)).toContain('') + } finally { + warn.mockRestore() + } + }) + + it('skips unsupported extensions and leaves remote images alone', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const dir = mkdtempSync(join(tmpdir(), 'markfluence-img-')) + try { + writeFileSync(join(dir, 'notes.txt'), 'text') + + const { ast } = parse('![doc](./notes.txt)\n\n![remote](https://x/p.png)') + const context = createContext() + await preprocessImages(ast, context, dir) + + expect(context.attachments.size).toBe(0) + expect(context.localImages.size).toBe(0) + // Only the unsupported local file warns; the remote URL is left untouched. + expect(warn).toHaveBeenCalledTimes(1) + } finally { + warn.mockRestore() + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('skips data: URIs without reading or warning', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const { ast } = parse('![inline](data:image/png;base64,iVBORw0KGgo=)') + const context = createContext() + await preprocessImages(ast, context, tmpdir()) + + expect(context.attachments.size).toBe(0) + expect(context.localImages.size).toBe(0) + expect(warn).not.toHaveBeenCalled() + } finally { + warn.mockRestore() + } + }) + + it('reads a repeated local image only once', async () => { + const dir = mkdtempSync(join(tmpdir(), 'markfluence-img-')) + try { + const bytes = Buffer.from('dedup-me') + writeFileSync(join(dir, 'dup.png'), bytes) + const hash = createHash('md5').update(bytes).digest('hex').slice(0, 12) + + const { ast } = parse('![one](./dup.png)\n\n![two](./dup.png)') + const context = createContext() + await preprocessImages(ast, context, dir) + + expect(context.attachments.size).toBe(1) + expect(context.localImages.size).toBe(1) + expect(context.localImages.get('./dup.png')).toBe(`dup-${hash}.png`) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/test/sync.test.ts b/test/sync.test.ts index 7435c63..e8adcf1 100644 --- a/test/sync.test.ts +++ b/test/sync.test.ts @@ -153,6 +153,7 @@ describe('syncFiles', () => { config: mockConfig, frontmatter: {}, attachments: new Map(), + localImages: new Map(), } const expectedContent = convert(ast, context)