Skip to content

Commit 58d5def

Browse files
committed
Downscale pasted PNG images based on metadata (go-gitea#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">
1 parent e20023a commit 58d5def

File tree

3 files changed

+93
-3
lines changed

3 files changed

+93
-3
lines changed

web_src/js/features/comp/ImagePaste.js

+17-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import $ from 'jquery';
2+
import {htmlEscape} from 'escape-goat';
23
import {POST} from '../../modules/fetch.js';
4+
import {imageInfo} from '../../utils/image.js';
35

46
async function uploadFile(file, uploadUrl) {
57
const formData = new FormData();
@@ -110,10 +112,22 @@ const uploadClipboardImage = async (editor, dropzone, e) => {
110112

111113
const placeholder = `![${name}](uploading ...)`;
112114
editor.insertPlaceholder(placeholder);
113-
const data = await uploadFile(img, uploadUrl);
114-
editor.replacePlaceholder(placeholder, `![${name}](/attachments/${data.uuid})`);
115115

116-
const $input = $(`<input name="files" type="hidden">`).attr('id', data.uuid).val(data.uuid);
116+
const {uuid} = await uploadFile(img, uploadUrl);
117+
const {width, dppx} = await imageInfo(img);
118+
119+
const url = `/attachments/${uuid}`;
120+
let text;
121+
if (width > 0 && dppx > 1) {
122+
// Scale down images from HiDPI monitors. This uses the <img> tag because it's the only
123+
// method to change image size in Markdown that is supported by all implementations.
124+
text = `<img width="${Math.round(width / dppx)}" alt="${htmlEscape(name)}" src="${htmlEscape(url)}">`;
125+
} else {
126+
text = `![${name}](${url})`;
127+
}
128+
editor.replacePlaceholder(placeholder, text);
129+
130+
const $input = $(`<input name="files" type="hidden">`).attr('id', uuid).val(uuid);
117131
$files.append($input);
118132
}
119133
};

web_src/js/utils/image.js

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
export async function pngChunks(blob) {
2+
const uint8arr = new Uint8Array(await blob.arrayBuffer());
3+
const chunks = [];
4+
if (uint8arr.length < 12) return chunks;
5+
const view = new DataView(uint8arr.buffer);
6+
if (view.getBigUint64(0) !== 9894494448401390090n) return chunks;
7+
8+
const decoder = new TextDecoder();
9+
let index = 8;
10+
while (index < uint8arr.length) {
11+
const len = view.getUint32(index);
12+
chunks.push({
13+
name: decoder.decode(uint8arr.slice(index + 4, index + 8)),
14+
data: uint8arr.slice(index + 8, index + 8 + len),
15+
});
16+
index += len + 12;
17+
}
18+
19+
return chunks;
20+
}
21+
22+
// decode a image and try to obtain width and dppx. If will never throw but instead
23+
// return default values.
24+
export async function imageInfo(blob) {
25+
let width = 0; // 0 means no width could be determined
26+
let dppx = 1; // 1 dot per pixel for non-HiDPI screens
27+
28+
if (blob.type === 'image/png') { // only png is supported currently
29+
try {
30+
for (const {name, data} of await pngChunks(blob)) {
31+
const view = new DataView(data.buffer);
32+
if (name === 'IHDR' && data?.length) {
33+
// extract width from mandatory IHDR chunk
34+
width = view.getUint32(0);
35+
} else if (name === 'pHYs' && data?.length) {
36+
// extract dppx from optional pHYs chunk, assuming pixels are square
37+
const unit = view.getUint8(8);
38+
if (unit === 1) {
39+
dppx = Math.round(view.getUint32(0) / 39.3701) / 72; // meter to inch to dppx
40+
}
41+
}
42+
}
43+
} catch {}
44+
}
45+
46+
return {width, dppx};
47+
}

web_src/js/utils/image.test.js

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import {pngChunks, imageInfo} from './image.js';
2+
3+
const pngNoPhys = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAADUlEQVQIHQECAP3/AAAAAgABzePRKwAAAABJRU5ErkJggg==';
4+
const pngPhys = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAEElEQVQI12OQNZcAIgYIBQAL8gGxdzzM0A==';
5+
const pngEmpty = 'data:image/png;base64,';
6+
7+
async function dataUriToBlob(datauri) {
8+
return await (await globalThis.fetch(datauri)).blob();
9+
}
10+
11+
test('pngChunks', async () => {
12+
expect(await pngChunks(await dataUriToBlob(pngNoPhys))).toEqual([
13+
{name: 'IHDR', data: new Uint8Array([0, 0, 0, 1, 0, 0, 0, 1, 8, 0, 0, 0, 0])},
14+
{name: 'IDAT', data: new Uint8Array([8, 29, 1, 2, 0, 253, 255, 0, 0, 0, 2, 0, 1])},
15+
{name: 'IEND', data: new Uint8Array([])},
16+
]);
17+
expect(await pngChunks(await dataUriToBlob(pngPhys))).toEqual([
18+
{name: 'IHDR', data: new Uint8Array([0, 0, 0, 2, 0, 0, 0, 2, 8, 2, 0, 0, 0])},
19+
{name: 'pHYs', data: new Uint8Array([0, 0, 22, 37, 0, 0, 22, 37, 1])},
20+
{name: 'IDAT', data: new Uint8Array([8, 215, 99, 144, 53, 151, 0, 34, 6, 8, 5, 0, 11, 242, 1, 177])},
21+
]);
22+
expect(await pngChunks(await dataUriToBlob(pngEmpty))).toEqual([]);
23+
});
24+
25+
test('imageInfo', async () => {
26+
expect(await imageInfo(await dataUriToBlob(pngNoPhys))).toEqual({width: 1, dppx: 1});
27+
expect(await imageInfo(await dataUriToBlob(pngPhys))).toEqual({width: 2, dppx: 2});
28+
expect(await imageInfo(await dataUriToBlob(pngEmpty))).toEqual({width: 0, dppx: 1});
29+
});

0 commit comments

Comments
 (0)