html-images-minifier.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. /*
  2. * Copyright 2018 Gildas Lormeau
  3. * contact : gildas.lormeau <at> gmail.com
  4. *
  5. * This file is part of SingleFile.
  6. *
  7. * SingleFile is free software: you can redistribute it and/or modify
  8. * it under the terms of the GNU Lesser General Public License as published by
  9. * the Free Software Foundation, either version 3 of the License, or
  10. * (at your option) any later version.
  11. *
  12. * SingleFile is distributed in the hope that it will be useful,
  13. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  15. * GNU Lesser General Public License for more details.
  16. *
  17. * You should have received a copy of the GNU Lesser General Public License
  18. * along with SingleFile. If not, see <http://www.gnu.org/licenses/>.
  19. */
  20. /* global CSSRule, docHelper, cssWhat, lazyLoader */
  21. this.imagesMinifier = this.imagesMinifier || (() => {
  22. const DEBUG = false;
  23. const SVG_NS = "http://www.w3.org/2000/svg";
  24. const PREFIX_DATA_URI_IMAGE_SVG = "data:image/svg+xml";
  25. const SINGLE_FILE_IMAGE_ATTRIBUTE = "single-file-ref";
  26. const IGNORED_ATTRIBUTES = ["src", "viewBox", "preserveAspectRatio", "xlink:href", "title", "class", "style"];
  27. const SINGLE_FILE_SELECTOR = {
  28. type: "attribute",
  29. action: "exists",
  30. ignoreCase: false,
  31. name: SINGLE_FILE_IMAGE_ATTRIBUTE,
  32. value: ""
  33. };
  34. return {
  35. process: (doc, mediaAllInfo, options) => {
  36. const matchedImageSelectors = new Map();
  37. getImageRuleInfos(mediaAllInfo, matchedImageSelectors);
  38. const imageGroups = getImageGroups(doc);
  39. let duplicates = new Set();
  40. const duplicateURLs = [];
  41. imageGroups.forEach((elements, src) => {
  42. if (elements.length > 1 && src && src != doc.baseURI) {
  43. elements.forEach(element => duplicates.add(element));
  44. duplicateURLs.push(src);
  45. }
  46. });
  47. if (duplicateURLs.length) {
  48. processStyleSheets(doc, duplicates, mediaAllInfo, matchedImageSelectors);
  49. processImages(doc, duplicates, duplicateURLs, options);
  50. }
  51. },
  52. postProcess(doc) {
  53. doc.querySelectorAll("svg[" + SINGLE_FILE_IMAGE_ATTRIBUTE + "]").forEach(svgElement => {
  54. const useElement = svgElement.childNodes[0];
  55. if (useElement) {
  56. const refImageId = useElement.getAttribute("xlink:href").substring(1);
  57. if (refImageId) {
  58. const refImageElement = doc.getElementById(refImageId);
  59. if (refImageElement && refImageElement.getAttribute("xlink:href").startsWith(PREFIX_DATA_URI_IMAGE_SVG)) {
  60. svgElement.removeAttributeNS(SVG_NS, "preserveAspectRatio");
  61. }
  62. }
  63. }
  64. });
  65. }
  66. };
  67. function getImageRuleInfos(parentMediaInfo, matchedImageSelectors) {
  68. parentMediaInfo.elements.forEach((elementInfo, element) => {
  69. const tagName = element.tagName.toLowerCase();
  70. if (tagName == "img" || tagName == "svg") {
  71. elementInfo.forEach(selectorInfo => {
  72. if (selectorInfo.selector) {
  73. const selectors = matchedImageSelectors.get(selectorInfo.ruleInfo.cssRule.selectorText) || JSON.parse(JSON.stringify(selectorInfo.ruleInfo.selectors));
  74. const selectorIndex = selectorInfo.ruleInfo.selectors.indexOf(selectorInfo.selector);
  75. if (selectorIndex != -1 && !selectors[selectorIndex].transformed) {
  76. let selector = JSON.parse(JSON.stringify(selectorInfo.selector));
  77. selector.push({
  78. type: "pseudo",
  79. name: "not",
  80. data: [[SINGLE_FILE_SELECTOR]]
  81. });
  82. selector.transformed = true;
  83. selectors[selectorIndex] = selector;
  84. selector = JSON.parse(JSON.stringify(selectorInfo.selector));
  85. selectors.push(selector);
  86. matchedImageSelectors.set(selectorInfo.ruleInfo.cssRule.selectorText, selectors);
  87. }
  88. }
  89. });
  90. }
  91. });
  92. parentMediaInfo.medias.forEach(mediaInfo => getImageRuleInfos(mediaInfo, matchedImageSelectors));
  93. }
  94. function getImageGroups(doc) {
  95. const imageGroups = new Map();
  96. doc.querySelectorAll("img[src]:not([srcset])").forEach(imageElement => {
  97. if (imageElement.src) {
  98. let imageInfo = imageGroups.get(imageElement.src);
  99. if (!imageInfo) {
  100. imageInfo = [];
  101. imageGroups.set(imageElement.src, imageInfo);
  102. }
  103. imageInfo.push(imageElement);
  104. }
  105. });
  106. return imageGroups;
  107. }
  108. function processStyleSheets(doc, duplicates, mediaAllInfo, matchedImageSelectors) {
  109. const matchedSelectors = getMatchedSelectors(duplicates, mediaAllInfo);
  110. doc.querySelectorAll("style").forEach((styleElement, sheetIndex) => {
  111. if (styleElement.sheet) {
  112. const cssRules = styleElement.sheet.cssRules;
  113. let mediaInfo;
  114. if (styleElement.media && styleElement.media != "all") {
  115. mediaInfo = mediaAllInfo.medias.get("style-" + sheetIndex + "-" + styleElement.media);
  116. } else {
  117. mediaInfo = mediaAllInfo;
  118. }
  119. styleElement.textContent = processRules(doc, cssRules, sheetIndex, mediaInfo, matchedSelectors, matchedImageSelectors);
  120. }
  121. });
  122. }
  123. function processImages(doc, duplicates, duplicateURLs, options) {
  124. const svgElement = doc.createElementNS(SVG_NS, "svg");
  125. const defsElement = doc.createElementNS(SVG_NS, "defs");
  126. svgElement.setAttributeNS(SVG_NS, "width", 0);
  127. svgElement.setAttributeNS(SVG_NS, "height", 0);
  128. svgElement.setAttributeNS(SVG_NS, "style", "display:none!important");
  129. svgElement.appendChild(defsElement);
  130. doc.body.appendChild(svgElement);
  131. const ignoredAttributeNames = [];
  132. if (options.lazyLoadImages) {
  133. const imageSelectors = lazyLoader.imageSelectors;
  134. Object.keys(imageSelectors.src).forEach(selector => ignoredAttributeNames.push(imageSelectors.src[selector]));
  135. Object.keys(imageSelectors.srcset).forEach(selector => ignoredAttributeNames.push(imageSelectors.srcset[selector]));
  136. }
  137. doc.querySelectorAll("img[src]:not([srcset])").forEach(imgElement => {
  138. let replaceImage = !options.lazyLoadImages;
  139. if (!replaceImage) {
  140. replaceImage = !Object.keys(ignoredAttributeNames).map(key => ignoredAttributeNames[key]).find(attributeName => imgElement.getAttribute(attributeName));
  141. }
  142. if (replaceImage && duplicates.has(imgElement)) {
  143. const urlIndex = duplicateURLs.indexOf(imgElement.src);
  144. if (urlIndex != -1) {
  145. const dataAttributeName = docHelper.imagesAttributeName(options.sessionId);
  146. const imageData = options.imageData[Number(imgElement.getAttribute(dataAttributeName))];
  147. const width = (imageData.naturalWidth > 1 && imageData.naturalWidth) || imageData.width;
  148. const height = (imageData.naturalHeight > 1 && imageData.naturalHeight) || imageData.height;
  149. if (width > 1 && height > 1) {
  150. const svgElement = doc.createElementNS(SVG_NS, "svg");
  151. const useElement = doc.createElementNS(SVG_NS, "use");
  152. svgElement.appendChild(useElement);
  153. imgElement.getAttributeNames().forEach(attributeName => {
  154. if (!IGNORED_ATTRIBUTES.concat([docHelper.imagesAttributeName(options.sessionId)]).includes(attributeName)) {
  155. try {
  156. svgElement.setAttributeNS(SVG_NS, attributeName, imgElement.getAttribute(attributeName));
  157. } catch (error) {
  158. /* ignored */
  159. }
  160. }
  161. });
  162. const className = imgElement.getAttribute("class");
  163. if (className) {
  164. svgElement.setAttribute("class", className);
  165. }
  166. const title = imgElement.getAttribute("title");
  167. if (title) {
  168. const titleElement = doc.createElementNS(SVG_NS, "title");
  169. titleElement.textContent = title;
  170. svgElement.appendChild(titleElement);
  171. }
  172. svgElement.setAttribute(SINGLE_FILE_IMAGE_ATTRIBUTE, "");
  173. const style = svgElement.getAttribute("style") || "";
  174. svgElement.setAttribute("style", style + (style && ";") + "box-sizing:content-box!important;width:" + imageData.clientWidth + "px!important;" + "height:" + imageData.clientHeight + "px!important");
  175. svgElement.setAttributeNS(SVG_NS, "viewBox", "0 0 " + width + " " + height);
  176. svgElement.setAttributeNS(SVG_NS, "width", imageData.clientWidth);
  177. svgElement.setAttributeNS(SVG_NS, "height", imageData.clientHeight);
  178. svgElement.setAttributeNS(SVG_NS, "preserveAspectRatio", "none");
  179. useElement.setAttributeNS(SVG_NS, "xlink:href", "#single-file-" + urlIndex);
  180. let imageElement = doc.getElementById("single-file-" + urlIndex);
  181. if (!imageElement) {
  182. imageElement = doc.createElementNS(SVG_NS, "image");
  183. imageElement.setAttribute("xlink:href", imgElement.src);
  184. imageElement.id = "single-file-" + urlIndex;
  185. defsElement.appendChild(imageElement);
  186. }
  187. if (!imageElement.getAttributeNS(SVG_NS, "width") && !imageElement.getAttributeNS(SVG_NS, "height")) {
  188. imageElement.setAttributeNS(SVG_NS, "viewBox", "0 0 " + width + " " + height);
  189. imageElement.setAttributeNS(SVG_NS, "width", width);
  190. imageElement.setAttributeNS(SVG_NS, "height", height);
  191. }
  192. imgElement.parentElement.replaceChild(svgElement, imgElement);
  193. }
  194. }
  195. }
  196. });
  197. }
  198. function getMatchedSelectors(duplicates, parentMediaInfo, matchedRules = new Map()) {
  199. duplicates.forEach(imageElement => {
  200. let elementInfo = parentMediaInfo.elements.get(imageElement);
  201. if (!elementInfo) {
  202. elementInfo = parentMediaInfo.pseudos.get(imageElement);
  203. }
  204. if (elementInfo) {
  205. elementInfo.forEach(elementInfo => {
  206. if (elementInfo.ruleInfo) {
  207. let selectors = matchedRules.get(elementInfo.selectorText);
  208. if (!selectors) {
  209. matchedRules.set(elementInfo.ruleInfo.cssRule.selectorText, elementInfo.ruleInfo.selectors);
  210. }
  211. }
  212. });
  213. }
  214. });
  215. parentMediaInfo.medias.forEach(mediaInfo => getMatchedSelectors(duplicates, mediaInfo, matchedRules));
  216. return matchedRules;
  217. }
  218. function processRules(doc, cssRules, sheetIndex, mediaInfo, matchedSelectors, matchedImageSelectors) {
  219. let sheetContent = "", mediaRuleIndex = 0;
  220. let startTime;
  221. if (DEBUG && cssRules.length > 1) {
  222. startTime = Date.now();
  223. log(" -- STARTED processRules", "rules.length =", cssRules.length);
  224. }
  225. Array.from(cssRules).forEach(cssRule => {
  226. if (cssRule.type == CSSRule.MEDIA_RULE) {
  227. sheetContent += "@media " + Array.from(cssRule.media).join(",") + "{";
  228. sheetContent += processRules(doc, cssRule.cssRules, sheetIndex, mediaInfo.medias.get("rule-" + sheetIndex + "-" + mediaRuleIndex + "-" + cssRule.media.mediaText), matchedSelectors, matchedImageSelectors);
  229. mediaRuleIndex++;
  230. sheetContent += "}";
  231. } else if (cssRule.type == CSSRule.STYLE_RULE) {
  232. const imageSelectors = matchedImageSelectors.get(cssRule.selectorText);
  233. let selectors = matchedSelectors.get(cssRule.selectorText);
  234. if (imageSelectors || selectors) {
  235. selectors = imageSelectors || selectors;
  236. selectors.forEach(selector => {
  237. const newSelector = getSVGSelector(selector);
  238. if (newSelector) {
  239. selectors.push(newSelector);
  240. }
  241. });
  242. const selectorText = selectors.map(selector => cssWhat.stringify([selector])).join(",");
  243. cssRule.selectorText = selectorText;
  244. }
  245. sheetContent += cssRule.cssText;
  246. } else {
  247. sheetContent += cssRule.cssText;
  248. }
  249. });
  250. if (DEBUG && cssRules.length > 1) {
  251. log(" -- ENDED processRules delay =", Date.now() - startTime);
  252. }
  253. return sheetContent;
  254. }
  255. function getSVGSelector(selector) {
  256. selector = JSON.parse(JSON.stringify(selector));
  257. let simpleSelector, selectorIndex = selector.length - 1, imageTagFound;
  258. while (selectorIndex >= 0 && !imageTagFound) {
  259. simpleSelector = selector[selectorIndex];
  260. if (simpleSelector.type == "pseudo" && simpleSelector.name == "not") {
  261. const negatedSelector = simpleSelector.data[0][0];
  262. if (negatedSelector.type == SINGLE_FILE_SELECTOR.type && negatedSelector.action == SINGLE_FILE_SELECTOR.action && negatedSelector.name == SINGLE_FILE_SELECTOR.name) {
  263. selector.splice(selectorIndex, 1);
  264. }
  265. selectorIndex--;
  266. } else {
  267. imageTagFound = simpleSelector.type == "tag" && simpleSelector.name == "img";
  268. if (!imageTagFound) {
  269. selectorIndex--;
  270. }
  271. }
  272. }
  273. if (imageTagFound) {
  274. simpleSelector.name = "svg";
  275. selector.splice(selectorIndex + 1, 0, SINGLE_FILE_SELECTOR);
  276. return selector;
  277. }
  278. }
  279. function log(...args) {
  280. console.log("S-File <img-min>", ...args); // eslint-disable-line no-console
  281. }
  282. })();