Line | Count | Source |
1 | | /* |
2 | | * stripai.c |
3 | | * |
4 | | * Copyright (C) 2026 Matteo Croce |
5 | | * All Rights Reserved. |
6 | | * |
7 | | * Removal of the metadata telling that an image was generated or edited |
8 | | * with AI: C2PA manifests (Content Credentials), the IPTC digital source |
9 | | * type in XMP, and the generation parameters (prompt, seed, sampler...) |
10 | | * that Stable Diffusion UIs store in the Exif UserComment. |
11 | | * |
12 | | * SPDX-License-Identifier: GPL-3.0-or-later |
13 | | * |
14 | | * This file is part of JPEGoptim. |
15 | | * |
16 | | * JPEGoptim is free software: you can redistribute it and/or modify |
17 | | * it under the terms of the GNU General Public License as published by |
18 | | * the Free Software Foundation, either version 3 of the License, or |
19 | | * (at your option) any later version. |
20 | | * |
21 | | * JPEGoptim is distributed in the hope that it will be useful, |
22 | | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
23 | | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
24 | | * GNU General Public License for more details. |
25 | | * |
26 | | * You should have received a copy of the GNU General Public License |
27 | | * along with JPEGoptim. If not, see <https://www.gnu.org/licenses/>. |
28 | | */ |
29 | | |
30 | | #include <stdio.h> |
31 | | #include <stdlib.h> |
32 | | #include <string.h> |
33 | | #include <ctype.h> |
34 | | #include <jpeglib.h> |
35 | | |
36 | | #include "jpegoptim.h" |
37 | | |
38 | 0 | #define XMP_ID "http://ns.adobe.com/xap/1.0/" |
39 | 0 | #define XMP_ID_LEN (sizeof(XMP_ID)) /* including the NUL terminator */ |
40 | 0 | #define EXIF_ID_LEN 6 /* "Exif\0\0" */ |
41 | | |
42 | 0 | #define EXIF_IFD_POINTER 0x8769 |
43 | 0 | #define EXIF_USER_COMMENT 0x9286 |
44 | | |
45 | | |
46 | | /* Algorithmic IPTC digital source types: trainedAlgorithmicMedia, |
47 | | compositeWithTrainedAlgorithmicMedia, algorithmicMedia, ... */ |
48 | | static const char *ai_source_types[] = { |
49 | | "algorithmicmedia", |
50 | | "compositesynthetic", |
51 | | "algorithmicallyenhanced", |
52 | | NULL |
53 | | }; |
54 | | |
55 | | /* References to a C2PA manifest (dcterms:provenance) */ |
56 | | static const char *c2pa_refs[] = { |
57 | | "c2pa", |
58 | | NULL |
59 | | }; |
60 | | |
61 | | /* Generation parameters, as written by AUTOMATIC1111, Forge and SD.Next */ |
62 | | static const char *ai_parameters[] = { |
63 | | "negative prompt:", |
64 | | "cfg scale:", |
65 | | "model hash:", |
66 | | NULL |
67 | | }; |
68 | | |
69 | | |
70 | | /* Case insensitive search of any of the strings in buf. NUL bytes in buf |
71 | | are skipped, so that UTF-16 text matches as well. */ |
72 | | static int has_any(const unsigned char *buf, size_t len, const char **strs) |
73 | 0 | { |
74 | 0 | for (; *strs; strs++) { |
75 | 0 | for (size_t i = 0; i < len; i++) { |
76 | 0 | const char *s = *strs; |
77 | 0 | size_t j = i; |
78 | |
|
79 | 0 | if (buf[i] == 0) |
80 | 0 | continue; |
81 | | |
82 | 0 | while (*s && j < len) { |
83 | 0 | if (buf[j] == 0) { |
84 | 0 | j++; |
85 | 0 | continue; |
86 | 0 | } |
87 | 0 | if (tolower(buf[j]) != *s) |
88 | 0 | break; |
89 | 0 | s++; |
90 | 0 | j++; |
91 | 0 | } |
92 | 0 | if (!*s) |
93 | 0 | return 1; |
94 | 0 | } |
95 | 0 | } |
96 | | |
97 | 0 | return 0; |
98 | 0 | } |
99 | | |
100 | | |
101 | | static long find(const unsigned char *buf, size_t len, size_t pos, |
102 | | const char *str, size_t str_len) |
103 | 0 | { |
104 | 0 | for (; pos + str_len <= len; pos++) { |
105 | 0 | if (!memcmp(buf + pos, str, str_len)) |
106 | 0 | return pos; |
107 | 0 | } |
108 | | |
109 | 0 | return -1; |
110 | 0 | } |
111 | | |
112 | | |
113 | | static int is_name_char(int c) |
114 | 0 | { |
115 | 0 | return (isalnum(c) || c == '_' || c == '-' || c == '.'); |
116 | 0 | } |
117 | | |
118 | | |
119 | | /* Remove from an XMP packet the properties having the given local name |
120 | | and a value containing any of the given strings. Both the attribute |
121 | | (prefix:name="value") and the element (<prefix:name>...</prefix:name>) |
122 | | forms are handled. Returns the number of properties removed. */ |
123 | | static int xmp_remove_property(unsigned char *buf, size_t *len, |
124 | | const char *name, const char **values) |
125 | 0 | { |
126 | 0 | size_t name_len = strlen(name); |
127 | 0 | size_t pos = 0; |
128 | 0 | int count = 0; |
129 | 0 | long i; |
130 | |
|
131 | 0 | while ((i = find(buf, *len, pos, name, name_len)) >= 0) { |
132 | 0 | size_t qname, start, end, value; |
133 | |
|
134 | 0 | pos = value = i + name_len; |
135 | | |
136 | | /* the name must be prefixed by a namespace: "prefix:name" */ |
137 | 0 | if (i < 2 || buf[i - 1] != ':') |
138 | 0 | continue; |
139 | 0 | qname = i - 1; |
140 | 0 | while (qname > 0 && is_name_char(buf[qname - 1])) |
141 | 0 | qname--; |
142 | 0 | if (qname == 0 || qname == (size_t)i - 1) |
143 | 0 | continue; |
144 | | |
145 | 0 | if (buf[qname - 1] == '<') { |
146 | | /* element: <prefix:name .../> or <prefix:name ...>...</prefix:name> */ |
147 | 0 | long gt = find(buf, *len, pos, ">", 1); |
148 | |
|
149 | 0 | if (gt < 0) |
150 | 0 | continue; |
151 | 0 | start = qname - 1; |
152 | 0 | end = gt + 1; |
153 | 0 | if (buf[gt - 1] != '/') { |
154 | 0 | size_t qname_len = value - qname; |
155 | 0 | long close = gt; |
156 | |
|
157 | 0 | while ((close = find(buf, *len, close + 1, "</", 2)) >= 0) { |
158 | 0 | if (close + 2 + qname_len < *len |
159 | 0 | && !memcmp(buf + close + 2, buf + qname, qname_len) |
160 | 0 | && !is_name_char(buf[close + 2 + qname_len])) |
161 | 0 | break; |
162 | 0 | } |
163 | 0 | if (close < 0 || (gt = find(buf, *len, close, ">", 1)) < 0) |
164 | 0 | continue; |
165 | 0 | end = gt + 1; |
166 | 0 | } |
167 | 0 | } else if (isspace(buf[qname - 1])) { |
168 | | /* attribute: prefix:name="value", removed with its leading space */ |
169 | 0 | size_t p = pos; |
170 | 0 | long quote; |
171 | |
|
172 | 0 | while (p < *len && isspace(buf[p])) |
173 | 0 | p++; |
174 | 0 | if (p >= *len || buf[p++] != '=') |
175 | 0 | continue; |
176 | 0 | while (p < *len && isspace(buf[p])) |
177 | 0 | p++; |
178 | 0 | if (p >= *len || (buf[p] != '"' && buf[p] != '\'')) |
179 | 0 | continue; |
180 | 0 | if ((quote = find(buf, *len, p + 1, (char *)buf + p, 1)) < 0) |
181 | 0 | continue; |
182 | 0 | start = qname - 1; |
183 | 0 | end = quote + 1; |
184 | 0 | } else { |
185 | 0 | continue; |
186 | 0 | } |
187 | | |
188 | 0 | if (!has_any(buf + value, end - value, values)) |
189 | 0 | continue; |
190 | | |
191 | 0 | memmove(buf + start, buf + end, *len - end); |
192 | 0 | *len -= end - start; |
193 | 0 | pos = start; |
194 | 0 | count++; |
195 | 0 | } |
196 | |
|
197 | 0 | return count; |
198 | 0 | } |
199 | | |
200 | | |
201 | | static unsigned int get16(const unsigned char *p, int le) |
202 | 0 | { |
203 | 0 | return (le ? p[0] | p[1] << 8 : p[0] << 8 | p[1]); |
204 | 0 | } |
205 | | |
206 | | |
207 | | static unsigned long get32(const unsigned char *p, int le) |
208 | 0 | { |
209 | 0 | return (le ? get16(p, le) | (unsigned long)get16(p + 2, le) << 16 |
210 | 0 | : (unsigned long)get16(p, le) << 16 | get16(p + 2, le)); |
211 | 0 | } |
212 | | |
213 | | |
214 | | /* Look for a tag in a TIFF IFD, returning the offset of its entry, |
215 | | or 0 if not found. */ |
216 | | static size_t ifd_find_tag(const unsigned char *tiff, size_t len, int le, |
217 | | size_t ifd, unsigned int tag) |
218 | 0 | { |
219 | 0 | unsigned int n; |
220 | |
|
221 | 0 | if (ifd < 8 || ifd >= len || len - ifd < 2) |
222 | 0 | return 0; |
223 | 0 | n = get16(tiff + ifd, le); |
224 | 0 | for (unsigned int i = 0; i < n; i++) { |
225 | 0 | size_t entry = ifd + 2 + i * 12; |
226 | |
|
227 | 0 | if (entry + 12 > len) |
228 | 0 | break; |
229 | 0 | if (get16(tiff + entry, le) == tag) |
230 | 0 | return entry; |
231 | 0 | } |
232 | | |
233 | 0 | return 0; |
234 | 0 | } |
235 | | |
236 | | |
237 | | /* Blank in place the Exif UserComment, if it holds generation parameters. |
238 | | It is not removed, so that no offset in the Exif data changes. */ |
239 | | static int exif_clear_user_comment(unsigned char *data, size_t len) |
240 | 0 | { |
241 | 0 | unsigned char *tiff = data + EXIF_ID_LEN; |
242 | 0 | size_t entry, offset, count; |
243 | 0 | int le; |
244 | |
|
245 | 0 | if (len < EXIF_ID_LEN + 8) |
246 | 0 | return 0; |
247 | 0 | len -= EXIF_ID_LEN; |
248 | 0 | if (!memcmp(tiff, "II", 2)) |
249 | 0 | le = 1; |
250 | 0 | else if (!memcmp(tiff, "MM", 2)) |
251 | 0 | le = 0; |
252 | 0 | else |
253 | 0 | return 0; |
254 | | |
255 | 0 | if (!(entry = ifd_find_tag(tiff, len, le, get32(tiff + 4, le), EXIF_IFD_POINTER))) |
256 | 0 | return 0; |
257 | 0 | if (!(entry = ifd_find_tag(tiff, len, le, get32(tiff + entry + 8, le), EXIF_USER_COMMENT))) |
258 | 0 | return 0; |
259 | | |
260 | | /* 8 bytes of character code, then the comment: as the UserComment |
261 | | is of UNDEFINED type, its size is the count itself */ |
262 | 0 | count = get32(tiff + entry + 4, le); |
263 | 0 | if (count <= 8) |
264 | 0 | return 0; |
265 | 0 | offset = get32(tiff + entry + 8, le); |
266 | 0 | if (offset > len || count > len - offset) |
267 | 0 | return 0; |
268 | 0 | if (!has_any(tiff + offset + 8, count - 8, ai_parameters)) |
269 | 0 | return 0; |
270 | | |
271 | 0 | memset(tiff + offset + 8, 0, count - 8); |
272 | |
|
273 | 0 | return 1; |
274 | 0 | } |
275 | | |
276 | | |
277 | | /* Remove the AI metadata from the markers saved by the decompressor. |
278 | | Returns the number of items removed. */ |
279 | | int strip_ai_markers(struct jpeg_decompress_struct *dinfo) |
280 | 0 | { |
281 | 0 | jpeg_saved_marker_ptr *mp = &dinfo->marker_list; |
282 | 0 | long c2pa_box = -1; |
283 | 0 | int count = 0; |
284 | |
|
285 | 0 | while (*mp) { |
286 | 0 | jpeg_saved_marker_ptr m = *mp; |
287 | |
|
288 | 0 | if (m->marker == JPEG_APP0 + 11 && m->data_length >= 8 |
289 | 0 | && !memcmp(m->data, "JP", 2)) { |
290 | | /* JUMBF: "JP", box instance and sequence number, then the |
291 | | superbox, which may continue in other APP11 markers of the |
292 | | same box instance. The first one has the description box |
293 | | telling the C2PA content type. */ |
294 | 0 | long box = get16(m->data + 2, 0); |
295 | |
|
296 | 0 | if (m->data_length >= 28 && !memcmp(m->data + 12, "jumb", 4) |
297 | 0 | && !memcmp(m->data + 20, "jumdc2pa", 8)) |
298 | 0 | c2pa_box = box; |
299 | 0 | if (box == c2pa_box) { |
300 | 0 | *mp = m->next; |
301 | 0 | count++; |
302 | 0 | continue; |
303 | 0 | } |
304 | 0 | } else if (m->marker == JPEG_APP0 + 1 && m->data_length > XMP_ID_LEN |
305 | 0 | && !memcmp(m->data, XMP_ID, XMP_ID_LEN)) { |
306 | 0 | size_t len = m->data_length - XMP_ID_LEN; |
307 | 0 | int n; |
308 | |
|
309 | 0 | n = xmp_remove_property(m->data + XMP_ID_LEN, &len, |
310 | 0 | "DigitalSourceType", ai_source_types); |
311 | 0 | n += xmp_remove_property(m->data + XMP_ID_LEN, &len, |
312 | 0 | "provenance", c2pa_refs); |
313 | 0 | m->data_length = XMP_ID_LEN + len; |
314 | 0 | count += n; |
315 | 0 | } else if (m->marker == JPEG_APP0 + 1 && m->data_length > EXIF_ID_LEN |
316 | 0 | && !memcmp(m->data, "Exif\0\0", EXIF_ID_LEN)) { |
317 | 0 | count += exif_clear_user_comment(m->data, m->data_length); |
318 | 0 | } |
319 | | |
320 | 0 | mp = &m->next; |
321 | 0 | } |
322 | |
|
323 | 0 | return count; |
324 | 0 | } |