Bug 1885337 - Part 2: Implement to/from base64 methods. r=dminor
[gecko.git] / dom / manifest / ImageObjectProcessor.sys.mjs
blobea57d596a170deb147b421b374ad4b597cc838bd
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4 /*
5  * ImageObjectProcessor
6  * Implementation of Image Object processing algorithms from:
7  * http://www.w3.org/TR/appmanifest/#image-object-and-its-members
8  *
9  * This is intended to be used in conjunction with ManifestProcessor.sys.mjs
10  *
11  * Creates an object to process Image Objects as defined by the
12  * W3C specification. This is used to process things like the
13  * icon member and the splash_screen member.
14  *
15  * Usage:
16  *
17  *   .process(aManifest, aBaseURL, aMemberName);
18  *
19  */
21 export function ImageObjectProcessor(aErrors, aExtractor, aBundle) {
22   this.errors = aErrors;
23   this.extractor = aExtractor;
24   this.domBundle = aBundle;
27 const iconPurposes = Object.freeze(["any", "maskable", "monochrome"]);
29 // Static getters
30 Object.defineProperties(ImageObjectProcessor, {
31   decimals: {
32     get() {
33       return /^\d+$/;
34     },
35   },
36   anyRegEx: {
37     get() {
38       return new RegExp("any", "i");
39     },
40   },
41 });
43 ImageObjectProcessor.prototype.process = function (
44   aManifest,
45   aBaseURL,
46   aMemberName
47 ) {
48   const spec = {
49     objectName: "manifest",
50     object: aManifest,
51     property: aMemberName,
52     expectedType: "array",
53     trim: false,
54   };
55   const { domBundle, extractor, errors } = this;
56   const images = [];
57   const value = extractor.extractValue(spec);
58   if (Array.isArray(value)) {
59     value
60       .map(toImageObject)
61       // Filter out images that resulted in "failure", per spec.
62       .filter(image => image)
63       .forEach(image => images.push(image));
64   }
65   return images;
67   function toImageObject(aImageSpec, index) {
68     let img; // if "failure" happens below, we return undefined.
69     try {
70       // can throw
71       const src = processSrcMember(aImageSpec, aBaseURL, index);
72       // can throw
73       const purpose = processPurposeMember(aImageSpec, index);
74       const type = processTypeMember(aImageSpec);
75       const sizes = processSizesMember(aImageSpec);
76       img = {
77         src,
78         purpose,
79         type,
80         sizes,
81       };
82     } catch (err) {
83       /* Errors are collected by each process* function */
84     }
85     return img;
86   }
88   function processPurposeMember(aImage, index) {
89     const spec = {
90       objectName: "image",
91       object: aImage,
92       property: "purpose",
93       expectedType: "string",
94       trim: true,
95       throwTypeError: true,
96     };
98     // Type errors are treated at "any"...
99     let value;
100     try {
101       value = extractor.extractValue(spec);
102     } catch (err) {
103       return ["any"];
104     }
106     // Was only whitespace...
107     if (!value) {
108       return ["any"];
109     }
111     const keywords = value.split(/\s+/);
113     // Emtpy is treated as "any"...
114     if (keywords.length === 0) {
115       return ["any"];
116     }
118     // We iterate over keywords and classify them into:
119     const purposes = new Set();
120     const unknownPurposes = new Set();
121     const repeatedPurposes = new Set();
123     for (const keyword of keywords) {
124       const canonicalKeyword = keyword.toLowerCase();
126       if (purposes.has(canonicalKeyword)) {
127         repeatedPurposes.add(keyword);
128         continue;
129       }
131       iconPurposes.includes(canonicalKeyword)
132         ? purposes.add(canonicalKeyword)
133         : unknownPurposes.add(keyword);
134     }
136     // Tell developer about unknown purposes...
137     if (unknownPurposes.size) {
138       const warn = domBundle.formatStringFromName(
139         "ManifestImageUnsupportedPurposes",
140         [aMemberName, index, [...unknownPurposes].join(" ")]
141       );
142       errors.push({ warn });
143     }
145     // Tell developer about repeated purposes...
146     if (repeatedPurposes.size) {
147       const warn = domBundle.formatStringFromName(
148         "ManifestImageRepeatedPurposes",
149         [aMemberName, index, [...repeatedPurposes].join(" ")]
150       );
151       errors.push({ warn });
152     }
154     if (purposes.size === 0) {
155       const warn = domBundle.formatStringFromName("ManifestImageUnusable", [
156         aMemberName,
157         index,
158       ]);
159       errors.push({ warn });
160       throw new TypeError(warn);
161     }
163     return [...purposes];
164   }
166   function processTypeMember(aImage) {
167     const charset = {};
168     const hadCharset = {};
169     const spec = {
170       objectName: "image",
171       object: aImage,
172       property: "type",
173       expectedType: "string",
174       trim: true,
175     };
176     let value = extractor.extractValue(spec);
177     if (value) {
178       value = Services.io.parseRequestContentType(value, charset, hadCharset);
179     }
180     return value || undefined;
181   }
183   function processSrcMember(aImage, aBaseURL, index) {
184     const spec = {
185       objectName: aMemberName,
186       object: aImage,
187       property: "src",
188       expectedType: "string",
189       trim: false,
190       throwTypeError: true,
191     };
192     const value = extractor.extractValue(spec);
193     let url;
194     if (typeof value === "undefined" || value === "") {
195       // We throw here as the value is unusable,
196       // but it's not an developer error.
197       throw new TypeError();
198     }
199     if (value && value.length) {
200       try {
201         url = new URL(value, aBaseURL).href;
202       } catch (e) {
203         const warn = domBundle.formatStringFromName(
204           "ManifestImageURLIsInvalid",
205           [aMemberName, index, "src", value]
206         );
207         errors.push({ warn });
208         throw e;
209       }
210     }
211     return url;
212   }
214   function processSizesMember(aImage) {
215     const sizes = new Set();
216     const spec = {
217       objectName: "image",
218       object: aImage,
219       property: "sizes",
220       expectedType: "string",
221       trim: true,
222     };
223     const value = extractor.extractValue(spec);
224     if (value) {
225       // Split on whitespace and filter out invalid values.
226       value
227         .split(/\s+/)
228         .filter(isValidSizeValue)
229         .reduce((collector, size) => collector.add(size), sizes);
230     }
231     return sizes.size ? Array.from(sizes) : undefined;
232     // Implementation of HTML's link@size attribute checker.
233     function isValidSizeValue(aSize) {
234       const size = aSize.toLowerCase();
235       if (ImageObjectProcessor.anyRegEx.test(aSize)) {
236         return true;
237       }
238       if (!size.includes("x") || size.indexOf("x") !== size.lastIndexOf("x")) {
239         return false;
240       }
241       // Split left of x for width, after x for height.
242       const widthAndHeight = size.split("x");
243       const w = widthAndHeight.shift();
244       const h = widthAndHeight.join("x");
245       const validStarts = !w.startsWith("0") && !h.startsWith("0");
246       const validDecimals = ImageObjectProcessor.decimals.test(w + h);
247       return validStarts && validDecimals;
248     }
249   }