aboutsummaryrefslogtreecommitdiffstats
path: root/web_src
diff options
context:
space:
mode:
authorsilverwind <me@silverwind.io>2024-02-19 03:23:06 +0100
committerGitHub <noreply@github.com>2024-02-19 02:23:06 +0000
commit5e72526da4e915791f03af056890e16821bde052 (patch)
tree762911ed77e32ecc3e94872b6959442ae025332e /web_src
parentf04e71f9bc05d4930e1eff0b69ceb0e890528e30 (diff)
downloadgitea-5e72526da4e915791f03af056890e16821bde052.tar.gz
gitea-5e72526da4e915791f03af056890e16821bde052.zip
Downscale pasted PNG images based on metadata (#29123)
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">
Diffstat (limited to 'web_src')
-rw-r--r--web_src/js/features/comp/ImagePaste.js20
-rw-r--r--web_src/js/utils/image.js47
-rw-r--r--web_src/js/utils/image.test.js29
3 files changed, 93 insertions, 3 deletions
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 = $(`<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
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});
+});