diff --git a/web_src/js/features/comp/ImagePaste.js b/web_src/js/features/comp/ImagePaste.js
index 27abcfe56f..444ab89150 100644
--- a/web_src/js/features/comp/ImagePaste.js
+++ b/web_src/js/features/comp/ImagePaste.js
@@ -1,5 +1,7 @@
import $ from 'jquery';
+import {htmlEscape} from 'escape-goat';
import {POST} from '../../modules/fetch.js';
+import {imageInfo} from '../../utils/image.js';
async function uploadFile(file, uploadUrl) {
const formData = new FormData();
@@ -109,10 +111,22 @@ const uploadClipboardImage = async (editor, dropzone, e) => {
const placeholder = `![${name}](uploading ...)`;
editor.insertPlaceholder(placeholder);
- const data = await uploadFile(img, uploadUrl);
- editor.replacePlaceholder(placeholder, `![${name}](/attachments/${data.uuid})`);
- const $input = $(``).attr('id', data.uuid).val(data.uuid);
+ const {uuid} = await uploadFile(img, uploadUrl);
+ const {width, dppx} = await imageInfo(img);
+
+ const url = `/attachments/${uuid}`;
+ let text;
+ if (width > 0 && dppx > 1) {
+ // Scale down images from HiDPI monitors. This uses the tag because it's the only
+ // method to change image size in Markdown that is supported by all implementations.
+ text = ``;
+ } else {
+ text = `![${name}](${url})`;
+ }
+ editor.replacePlaceholder(placeholder, text);
+
+ const $input = $(``).attr('id', uuid).val(uuid);
$files.append($input);
}
};
diff --git a/web_src/js/utils/image.js b/web_src/js/utils/image.js
new file mode 100644
index 0000000000..ed5d98e35a
--- /dev/null
+++ b/web_src/js/utils/image.js
@@ -0,0 +1,47 @@
+export async function pngChunks(blob) {
+ const uint8arr = new Uint8Array(await blob.arrayBuffer());
+ const chunks = [];
+ if (uint8arr.length < 12) return chunks;
+ const view = new DataView(uint8arr.buffer);
+ if (view.getBigUint64(0) !== 9894494448401390090n) return chunks;
+
+ const decoder = new TextDecoder();
+ let index = 8;
+ while (index < uint8arr.length) {
+ const len = view.getUint32(index);
+ chunks.push({
+ name: decoder.decode(uint8arr.slice(index + 4, index + 8)),
+ data: uint8arr.slice(index + 8, index + 8 + len),
+ });
+ index += len + 12;
+ }
+
+ return chunks;
+}
+
+// decode a image and try to obtain width and dppx. If will never throw but instead
+// return default values.
+export async function imageInfo(blob) {
+ let width = 0; // 0 means no width could be determined
+ let dppx = 1; // 1 dot per pixel for non-HiDPI screens
+
+ if (blob.type === 'image/png') { // only png is supported currently
+ try {
+ for (const {name, data} of await pngChunks(blob)) {
+ const view = new DataView(data.buffer);
+ if (name === 'IHDR' && data?.length) {
+ // extract width from mandatory IHDR chunk
+ width = view.getUint32(0);
+ } else if (name === 'pHYs' && data?.length) {
+ // extract dppx from optional pHYs chunk, assuming pixels are square
+ const unit = view.getUint8(8);
+ if (unit === 1) {
+ dppx = Math.round(view.getUint32(0) / 39.3701) / 72; // meter to inch to dppx
+ }
+ }
+ }
+ } catch {}
+ }
+
+ return {width, dppx};
+}
diff --git a/web_src/js/utils/image.test.js b/web_src/js/utils/image.test.js
new file mode 100644
index 0000000000..ba4758250c
--- /dev/null
+++ b/web_src/js/utils/image.test.js
@@ -0,0 +1,29 @@
+import {pngChunks, imageInfo} from './image.js';
+
+const pngNoPhys = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAADUlEQVQIHQECAP3/AAAAAgABzePRKwAAAABJRU5ErkJggg==';
+const pngPhys = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAEElEQVQI12OQNZcAIgYIBQAL8gGxdzzM0A==';
+const pngEmpty = 'data:image/png;base64,';
+
+async function dataUriToBlob(datauri) {
+ return await (await globalThis.fetch(datauri)).blob();
+}
+
+test('pngChunks', async () => {
+ expect(await pngChunks(await dataUriToBlob(pngNoPhys))).toEqual([
+ {name: 'IHDR', data: new Uint8Array([0, 0, 0, 1, 0, 0, 0, 1, 8, 0, 0, 0, 0])},
+ {name: 'IDAT', data: new Uint8Array([8, 29, 1, 2, 0, 253, 255, 0, 0, 0, 2, 0, 1])},
+ {name: 'IEND', data: new Uint8Array([])},
+ ]);
+ expect(await pngChunks(await dataUriToBlob(pngPhys))).toEqual([
+ {name: 'IHDR', data: new Uint8Array([0, 0, 0, 2, 0, 0, 0, 2, 8, 2, 0, 0, 0])},
+ {name: 'pHYs', data: new Uint8Array([0, 0, 22, 37, 0, 0, 22, 37, 1])},
+ {name: 'IDAT', data: new Uint8Array([8, 215, 99, 144, 53, 151, 0, 34, 6, 8, 5, 0, 11, 242, 1, 177])},
+ ]);
+ expect(await pngChunks(await dataUriToBlob(pngEmpty))).toEqual([]);
+});
+
+test('imageInfo', async () => {
+ expect(await imageInfo(await dataUriToBlob(pngNoPhys))).toEqual({width: 1, dppx: 1});
+ expect(await imageInfo(await dataUriToBlob(pngPhys))).toEqual({width: 2, dppx: 2});
+ expect(await imageInfo(await dataUriToBlob(pngEmpty))).toEqual({width: 0, dppx: 1});
+});