Merge branch '5.3' into 5.4
[GitHub/WoltLab/WCF.git] / ts / WoltLabSuite / Core / Image / ExifUtil.ts
1 /**
2 * Provides helper functions for Exif metadata handling.
3 *
4 * @author Tim Duesterhus, Maximilian Mader
5 * @copyright 2001-2020 WoltLab GmbH
6 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
7 * @module WoltLabSuite/Core/Image/ExifUtil
8 * @woltlabExcludeBundle tiny
9 */
10
11 enum Tag {
12 SOI = 0xd8, // Start of image
13 APP0 = 0xe0, // JFIF tag
14 APP1 = 0xe1, // EXIF / XMP
15 APP2 = 0xe2, // General purpose tag
16 APP3 = 0xe3, // General purpose tag
17 APP4 = 0xe4, // General purpose tag
18 APP5 = 0xe5, // General purpose tag
19 APP6 = 0xe6, // General purpose tag
20 APP7 = 0xe7, // General purpose tag
21 APP8 = 0xe8, // General purpose tag
22 APP9 = 0xe9, // General purpose tag
23 APP10 = 0xea, // General purpose tag
24 APP11 = 0xeb, // General purpose tag
25 APP12 = 0xec, // General purpose tag
26 APP13 = 0xed, // General purpose tag
27 APP14 = 0xee, // Often used to store copyright information
28 COM = 0xfe, // Comments
29 }
30
31 // Known sequence signatures
32 const _signatureEXIF = "Exif";
33 const _signatureXMP = "http://ns.adobe.com/xap/1.0/";
34 const _signatureXMPExtension = "http://ns.adobe.com/xmp/extension/";
35
36 function isExifSignature(signature: string): boolean {
37 return signature === _signatureEXIF || signature === _signatureXMP || signature === _signatureXMPExtension;
38 }
39
40 function concatUint8Arrays(...arrays: Uint8Array[]): Uint8Array {
41 let offset = 0;
42 const length = arrays.reduce((sum, array) => sum + array.length, 0);
43
44 const result = new Uint8Array(length);
45 arrays.forEach((array) => {
46 result.set(array, offset);
47 offset += array.length;
48 });
49
50 return result;
51 }
52
53 async function blobToUint8(blob: Blob | File): Promise<Uint8Array> {
54 return new Promise((resolve, reject) => {
55 const reader = new FileReader();
56
57 reader.addEventListener("error", () => {
58 reader.abort();
59 reject(reader.error);
60 });
61
62 reader.addEventListener("load", () => {
63 resolve(new Uint8Array(reader.result! as ArrayBuffer));
64 });
65
66 reader.readAsArrayBuffer(blob);
67 });
68 }
69
70 /**
71 * Extracts the EXIF / XMP sections of a JPEG blob.
72 */
73 export async function getExifBytesFromJpeg(blob: Blob | File): Promise<Exif> {
74 if (!((blob as any) instanceof Blob) && !(blob instanceof File)) {
75 throw new TypeError("The argument must be a Blob or a File");
76 }
77
78 const bytes = await blobToUint8(blob);
79
80 let exif = new Uint8Array(0);
81
82 if (bytes[0] !== 0xff && bytes[1] !== Tag.SOI) {
83 throw new Error("Not a JPEG");
84 }
85
86 for (let i = 2; i < bytes.length; ) {
87 // each sequence starts with 0xFF
88 if (bytes[i] !== 0xff) break;
89
90 const length = 2 + ((bytes[i + 2] << 8) | bytes[i + 3]);
91
92 // Check if the next byte indicates an EXIF sequence
93 if (bytes[i + 1] === Tag.APP1) {
94 let signature = "";
95 for (let j = i + 4; bytes[j] !== 0 && j < bytes.length; j++) {
96 signature += String.fromCharCode(bytes[j]);
97 }
98
99 // Only copy Exif and XMP data
100 if (isExifSignature(signature)) {
101 // append the found EXIF sequence, usually only a single EXIF (APP1) sequence should be defined
102 const sequence = bytes.slice(i, length + i);
103 exif = concatUint8Arrays(exif, sequence);
104 }
105 }
106
107 i += length;
108 }
109
110 return exif;
111 }
112
113 /**
114 * Removes all EXIF and XMP sections of a JPEG blob.
115 */
116 export async function removeExifData(blob: Blob | File): Promise<Blob> {
117 if (!((blob as any) instanceof Blob) && !(blob instanceof File)) {
118 throw new TypeError("The argument must be a Blob or a File");
119 }
120
121 const bytes = await blobToUint8(blob);
122
123 if (bytes[0] !== 0xff && bytes[1] !== Tag.SOI) {
124 throw new Error("Not a JPEG");
125 }
126
127 let result = bytes;
128 for (let i = 2; i < result.length; ) {
129 // each sequence starts with 0xFF
130 if (result[i] !== 0xff) break;
131
132 const length = 2 + ((result[i + 2] << 8) | result[i + 3]);
133
134 // Check if the next byte indicates an EXIF sequence
135 if (result[i + 1] === Tag.APP1) {
136 let signature = "";
137 for (let j = i + 4; result[j] !== 0 && j < result.length; j++) {
138 signature += String.fromCharCode(result[j]);
139 }
140
141 // Only remove known signatures
142 if (isExifSignature(signature)) {
143 const start = result.slice(0, i);
144 const end = result.slice(i + length);
145 result = concatUint8Arrays(start, end);
146 } else {
147 i += length;
148 }
149 } else {
150 i += length;
151 }
152 }
153
154 return new Blob([result], { type: blob.type });
155 }
156
157 /**
158 * Overrides the APP1 (EXIF / XMP) sections of a JPEG blob with the given data.
159 */
160 export async function setExifData(blob: Blob, exif: Exif): Promise<Blob> {
161 blob = await removeExifData(blob);
162
163 const bytes = await blobToUint8(blob);
164
165 let offset = 2;
166
167 // check if the second tag is the JFIF tag
168 if (bytes[2] === 0xff && bytes[3] === Tag.APP0) {
169 offset += 2 + ((bytes[4] << 8) | bytes[5]);
170 }
171
172 const start = bytes.slice(0, offset);
173 const end = bytes.slice(offset);
174
175 const result = concatUint8Arrays(start, exif, end);
176
177 return new Blob([result], { type: blob.type });
178 }
179
180 export type Exif = Uint8Array;