]> source.dussan.org Git - gitea.git/commitdiff
Downscale pasted PNG images based on metadata (#29123)
authorsilverwind <me@silverwind.io>
Mon, 19 Feb 2024 02:23:06 +0000 (03:23 +0100)
committerGitHub <noreply@github.com>
Mon, 19 Feb 2024 02:23:06 +0000 (02:23 +0000)
Some images like MacOS screenshots contain
[pHYs](http://www.libpng.org/pub/png/book/chapter11.html#png.ch11.div.8)
data which we can use to downscale uploaded images so they render in the
same dppx ratio in which they were taken.

Before:

<img width="584" alt="image"
src="https://github.com/go-gitea/gitea/assets/115237/50979e3a-5d5a-40dc-a0a4-36eb6e28f14a">

After:

<img width="329" alt="image"
src="https://github.com/go-gitea/gitea/assets/115237/0690902a-f2fe-4c6b-97b3-6fdd67c21bad">

web_src/js/features/comp/ImagePaste.js
web_src/js/utils/image.js [new file with mode: 0644]
web_src/js/utils/image.test.js [new file with mode: 0644]

index 27abcfe56f374a31052c0c28a6405df320c512e9..444ab89150ca78ccfc3eba97b064487820527b60 100644 (file)
@@ -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 = $(`<input name="files" type="hidden">`).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 <img> tag because it's the only
+      // method to change image size in Markdown that is supported by all implementations.
+      text = `<img width="${Math.round(width / dppx)}" alt="${htmlEscape(name)}" src="${htmlEscape(url)}">`;
+    } else {
+      text = `![${name}](${url})`;
+    }
+    editor.replacePlaceholder(placeholder, text);
+
+    const $input = $(`<input name="files" type="hidden">`).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 (file)
index 0000000..ed5d98e
--- /dev/null
@@ -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 (file)
index 0000000..ba47582
--- /dev/null
@@ -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});
+});