Skip to content

Commit da4fbe7

Browse files
committed
feat(editor): transfer pasted web images
1 parent ed9a0f2 commit da4fbe7

12 files changed

Lines changed: 591 additions & 14 deletions

File tree

apps/desktop/src-tauri/src/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ use opened_files::{
3131
};
3232
use tauri::Emitter;
3333
use watcher::{unwatch_markdown_file, watch_markdown_file, MarkdownWatcherState};
34-
use web_http::request_web_resource;
34+
use web_http::{download_web_image, request_web_resource};
3535
use windows::{
3636
apply_main_window_chrome, apply_webview_window_chrome, apply_window_event_chrome,
3737
open_blank_editor_window, open_settings_window, spawn_blank_editor_window,
@@ -105,6 +105,7 @@ pub fn run() {
105105
request_native_chat,
106106
request_native_chat_stream,
107107
request_web_resource,
108+
download_web_image,
108109
upload_s3_image,
109110
upload_webdav_image,
110111
write_markdown_file,

apps/desktop/src-tauri/src/markdown_files.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ fn clipboard_image_extension(mime_type: &str) -> Result<&'static str, String> {
145145
"image/webp" => Ok("webp"),
146146
"image/avif" => Ok("avif"),
147147
"image/bmp" => Ok("bmp"),
148+
"image/svg+xml" => Ok("svg"),
148149
_ => Err("Clipboard image type is not supported".to_string()),
149150
}
150151
}
@@ -1622,6 +1623,14 @@ mod tests {
16221623
fs::remove_dir_all(root).expect("test tree should be removed");
16231624
}
16241625

1626+
#[test]
1627+
fn saves_svg_clipboard_images_with_svg_extension() {
1628+
assert_eq!(
1629+
clipboard_image_extension("image/svg+xml").expect("SVG images should be supported"),
1630+
"svg"
1631+
);
1632+
}
1633+
16251634
#[test]
16261635
fn reads_markdown_images_below_the_current_markdown_file_directory() {
16271636
let root = std::env::temp_dir().join(format!(

apps/desktop/src-tauri/src/web_http.rs

Lines changed: 216 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@ use std::collections::HashMap;
22
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
33
use std::time::Duration;
44

5-
use reqwest::header::{HeaderMap, HeaderName, HeaderValue, CONTENT_TYPE, LOCATION};
5+
use reqwest::header::{HeaderMap, HeaderName, HeaderValue, CONTENT_LENGTH, CONTENT_TYPE, LOCATION};
66
use reqwest::redirect::Policy;
77
use reqwest::Url;
88
use serde::{Deserialize, Serialize};
99

1010
const WEB_RESOURCE_MAX_REDIRECTS: usize = 5;
1111
const WEB_RESOURCE_REQUEST_TIMEOUT_SECS: u64 = 30;
12+
const WEB_IMAGE_MAX_BYTES: u64 = 25 * 1024 * 1024;
1213

1314
#[derive(Debug, Deserialize)]
1415
#[serde(rename_all = "camelCase")]
@@ -28,13 +29,34 @@ pub(crate) struct WebResourceResponse {
2829
status: u16,
2930
}
3031

32+
#[derive(Debug, Deserialize)]
33+
#[serde(rename_all = "camelCase")]
34+
pub(crate) struct WebImageDownloadRequest {
35+
url: String,
36+
}
37+
38+
#[derive(Debug, PartialEq, Serialize)]
39+
#[serde(rename_all = "camelCase")]
40+
pub(crate) struct WebImageDownloadResponse {
41+
bytes: Vec<u8>,
42+
file_name: String,
43+
mime_type: String,
44+
}
45+
3146
#[tauri::command]
3247
pub(crate) async fn request_web_resource(
3348
request: WebResourceRequest,
3449
) -> Result<WebResourceResponse, String> {
3550
execute_web_resource_request(request).await
3651
}
3752

53+
#[tauri::command]
54+
pub(crate) async fn download_web_image(
55+
request: WebImageDownloadRequest,
56+
) -> Result<WebImageDownloadResponse, String> {
57+
execute_web_image_download(request).await
58+
}
59+
3860
async fn execute_web_resource_request(
3961
request: WebResourceRequest,
4062
) -> Result<WebResourceResponse, String> {
@@ -87,7 +109,73 @@ async fn execute_web_resource_request(
87109
Err("Web resource request followed too many redirects.".to_string())
88110
}
89111

90-
fn validated_web_resource_url(value: &str, allow_localhost: bool) -> Result<Url, String> {
112+
async fn execute_web_image_download(
113+
request: WebImageDownloadRequest,
114+
) -> Result<WebImageDownloadResponse, String> {
115+
let mut url = validated_web_resource_url(&request.url, false)?;
116+
let client = reqwest::Client::builder()
117+
.redirect(Policy::none())
118+
.timeout(Duration::from_secs(WEB_RESOURCE_REQUEST_TIMEOUT_SECS))
119+
.build()
120+
.map_err(|error| error.to_string())?;
121+
122+
for _ in 0..=WEB_RESOURCE_MAX_REDIRECTS {
123+
let response = client
124+
.get(url.clone())
125+
.send()
126+
.await
127+
.map_err(|error| error.to_string())?;
128+
let status = response.status();
129+
130+
if status.is_redirection() {
131+
let location = response
132+
.headers()
133+
.get(LOCATION)
134+
.ok_or_else(|| "Web image redirect did not include a location.".to_string())?;
135+
let location = location.to_str().map_err(|error| error.to_string())?;
136+
let next_url = url.join(location).map_err(|error| error.to_string())?;
137+
url = validated_web_resource_url(next_url.as_str(), false)?;
138+
continue;
139+
}
140+
141+
if !status.is_success() {
142+
return Err(format!(
143+
"Web image download failed: HTTP {}",
144+
status.as_u16()
145+
));
146+
}
147+
148+
if let Some(content_length) = response.headers().get(CONTENT_LENGTH) {
149+
let content_length = content_length
150+
.to_str()
151+
.ok()
152+
.and_then(|value| value.parse::<u64>().ok());
153+
if content_length.is_some_and(|length| length > WEB_IMAGE_MAX_BYTES) {
154+
return Err("Web image is too large to paste into the document.".to_string());
155+
}
156+
}
157+
158+
let mime_type = web_image_mime_type(response.headers().get(CONTENT_TYPE), &url)?;
159+
let file_name = web_image_file_name(&url, &mime_type);
160+
let bytes = response.bytes().await.map_err(|error| error.to_string())?;
161+
if bytes.len() as u64 > WEB_IMAGE_MAX_BYTES {
162+
return Err("Web image is too large to paste into the document.".to_string());
163+
}
164+
165+
return Ok(WebImageDownloadResponse {
166+
bytes: bytes.to_vec(),
167+
file_name,
168+
mime_type,
169+
});
170+
}
171+
172+
Err("Web image download followed too many redirects.".to_string())
173+
}
174+
175+
pub(crate) fn validated_web_resource_url(
176+
value: &str,
177+
allow_localhost: bool,
178+
) -> Result<Url, String> {
91179
let url = Url::parse(value).map_err(|error| error.to_string())?;
92180
if !matches!(url.scheme(), "http" | "https") {
93181
return Err("Only HTTP and HTTPS web resource URLs are supported.".to_string());
@@ -102,6 +190,96 @@ fn validated_web_resource_url(value: &str, allow_localhost: bool) -> Result<Url,
102190
Ok(url)
103191
}
104192

193+
fn web_image_mime_type(content_type: Option<&HeaderValue>, url: &Url) -> Result<String, String> {
194+
let normalized_content_type = content_type
195+
.and_then(|value| value.to_str().ok())
196+
.and_then(|value| value.split(';').next())
197+
.map(str::trim)
198+
.map(str::to_ascii_lowercase)
199+
.filter(|value| !value.is_empty());
200+
201+
if let Some(mime_type) = normalized_content_type {
202+
if mime_type.starts_with("image/") {
203+
return Ok(mime_type);
204+
}
205+
206+
if mime_type != "application/octet-stream" {
207+
return Err("Downloaded web resource is not an image.".to_string());
208+
}
209+
}
210+
211+
image_mime_type_from_url(url)
212+
.map(str::to_string)
213+
.ok_or_else(|| "Downloaded web resource is not a supported image.".to_string())
214+
}
215+
216+
fn web_image_file_name(url: &Url, mime_type: &str) -> String {
217+
let file_name = url
218+
.path_segments()
219+
.and_then(|segments| segments.filter(|segment| !segment.is_empty()).last())
220+
.filter(|segment| !segment.trim().is_empty())
221+
.unwrap_or("web-image");
222+
223+
if image_extension_from_file_name(file_name).is_some() {
224+
return file_name.to_string();
225+
}
226+
227+
format!(
228+
"{}.{}",
229+
file_name,
230+
image_extension_from_mime_type(mime_type)
231+
)
232+
}
233+
234+
fn image_mime_type_from_url(url: &Url) -> Option<&'static str> {
235+
let file_name = url
236+
.path_segments()
237+
.and_then(|segments| segments.filter(|segment| !segment.is_empty()).last())?;
238+
let extension = image_extension_from_file_name(file_name)?;
239+
image_mime_type_from_extension(extension)
240+
}
241+
242+
fn image_extension_from_file_name(file_name: &str) -> Option<&str> {
243+
let extension = file_name.rsplit_once('.')?.1;
244+
if is_supported_image_extension(extension) {
245+
Some(extension)
246+
} else {
247+
None
248+
}
249+
}
250+
251+
fn is_supported_image_extension(extension: &str) -> bool {
252+
matches!(
253+
extension.to_ascii_lowercase().as_str(),
254+
"avif" | "bmp" | "gif" | "jpeg" | "jpg" | "png" | "svg" | "webp"
255+
)
256+
}
257+
258+
fn image_mime_type_from_extension(extension: &str) -> Option<&'static str> {
259+
match extension.to_ascii_lowercase().as_str() {
260+
"avif" => Some("image/avif"),
261+
"bmp" => Some("image/bmp"),
262+
"gif" => Some("image/gif"),
263+
"jpeg" | "jpg" => Some("image/jpeg"),
264+
"png" => Some("image/png"),
265+
"svg" => Some("image/svg+xml"),
266+
"webp" => Some("image/webp"),
267+
_ => None,
268+
}
269+
}
270+
271+
fn image_extension_from_mime_type(mime_type: &str) -> &'static str {
272+
match mime_type {
273+
"image/avif" => "avif",
274+
"image/bmp" => "bmp",
275+
"image/gif" => "gif",
276+
"image/jpeg" | "image/jpg" => "jpg",
277+
"image/svg+xml" => "svg",
278+
"image/webp" => "webp",
279+
_ => "png",
280+
}
281+
}
282+
105283
fn is_local_or_private_host(url: &Url) -> bool {
106284
let Some(host) = url.host_str() else {
107285
return true;
@@ -203,6 +381,42 @@ mod tests {
203381
assert_eq!(url.as_str(), "http://localhost:8888/search?q=markra");
204382
}
205383

384+
#[test]
385+
fn accepts_image_content_types_for_web_image_downloads() {
386+
let url = Url::parse("https://example.com/assets/kitten").expect("URL should parse");
387+
let content_type = HeaderValue::from_static("image/png; charset=binary");
388+
389+
assert_eq!(
390+
web_image_mime_type(Some(&content_type), &url).expect("image content type should pass"),
391+
"image/png"
392+
);
393+
assert_eq!(web_image_file_name(&url, "image/png"), "kitten.png");
394+
}
395+
396+
#[test]
397+
fn infers_web_image_mime_type_from_url_for_octet_streams() {
398+
let url = Url::parse("https://cdn.example.com/assets/logo.svg?version=1")
399+
.expect("URL should parse");
400+
let content_type = HeaderValue::from_static("application/octet-stream");
401+
402+
assert_eq!(
403+
web_image_mime_type(Some(&content_type), &url)
404+
.expect("octet stream image URLs should infer a MIME type"),
405+
"image/svg+xml"
406+
);
407+
assert_eq!(web_image_file_name(&url, "image/svg+xml"), "logo.svg");
408+
}
409+
410+
#[test]
411+
fn rejects_non_image_content_types_for_web_image_downloads() {
412+
let url = Url::parse("https://example.com/page.html").expect("URL should parse");
413+
let content_type = HeaderValue::from_static("text/html");
414+
let error = web_image_mime_type(Some(&content_type), &url)
415+
.expect_err("non-image content type should be rejected");
416+
417+
assert!(error.contains("not an image"));
418+
}
419+
206420
#[test]
207421
fn parses_custom_headers() {
208422
let mut headers = HashMap::new();

apps/desktop/src/App.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ import {
5454
AI_EDITOR_PREVIEW_RESTORE_EVENT,
5555
type AiEditorPreviewAppliedDetail,
5656
type AiEditorPreviewActionDetail,
57-
type AiEditorPreviewRestoreDetail
57+
type AiEditorPreviewRestoreDetail,
58+
type RemoteClipboardImage
5859
} from "@markra/editor";
5960
import { aiAgentWebSearchAvailable } from "@markra/ai";
6061
import {
@@ -69,6 +70,7 @@ import { notifyAppEditorPreferencesChanged } from "./lib/settings/settings-event
6970
import {
7071
confirmNativeMarkdownFileDelete,
7172
confirmNativeUnsavedMarkdownDocumentDiscard,
73+
downloadNativeWebImage,
7274
readNativeMarkdownImageFile,
7375
readNativeMarkdownFile,
7476
saveNativeHtmlFile,
@@ -749,6 +751,19 @@ export default function App() {
749751
return result.image;
750752
}, [document.path, editorPreferences.preferences, refreshMarkdownFileTree, translate]);
751753

754+
const handleSaveRemoteClipboardImage = useCallback(async (image: RemoteClipboardImage) => {
755+
const downloadedImage = await downloadNativeWebImage({ src: image.src }).catch(() => null);
756+
if (!downloadedImage) {
757+
showAppToast({
758+
message: translate("app.clipboardImageSaveFailed"),
759+
status: "error"
760+
});
761+
return null;
762+
}
763+
764+
return handleSaveClipboardImage(downloadedImage);
765+
}, [handleSaveClipboardImage, translate]);
766+
752767
useEffect(() => {
753768
const storedWidth = editorPreferences.preferences.contentWidthPx ?? null;
754769
setEditorContentWidth(editorPreferences.preferences.contentWidth);
@@ -1337,6 +1352,7 @@ export default function App() {
13371352
onContentWidthChange={handleEditorContentWidthChange}
13381353
onContentWidthResizeEnd={handleEditorContentWidthResizeEnd}
13391354
onSaveClipboardImage={handleSaveClipboardImage}
1355+
onSaveRemoteClipboardImage={handleSaveRemoteClipboardImage}
13401356
openExternalUrl={openNativeExternalUrl}
13411357
onTextSelectionChange={handleTextSelectionChange}
13421358
resolveImageSrc={resolveImageSrc}

0 commit comments

Comments
 (0)