diff options
author | bobzel <zzzman@gmail.com> | 2022-02-09 08:59:32 -0500 |
---|---|---|
committer | bobzel <zzzman@gmail.com> | 2022-02-09 08:59:32 -0500 |
commit | a6d904bcd18a2c9962abfd9b5b325340f6b18b0d (patch) | |
tree | 32ab8f94e8f742d9303ec06029247931f1ab3895 /src/client/views/nodes/WebBoxRenderer.js | |
parent | 66fb0e4bbf88a5e9bcb5869dd7c8b8a7714de024 (diff) |
speeding up rendering using bitmaps for webpages and other heavyweight docs.
Diffstat (limited to 'src/client/views/nodes/WebBoxRenderer.js')
-rw-r--r-- | src/client/views/nodes/WebBoxRenderer.js | 395 |
1 files changed, 395 insertions, 0 deletions
diff --git a/src/client/views/nodes/WebBoxRenderer.js b/src/client/views/nodes/WebBoxRenderer.js new file mode 100644 index 000000000..08a5746d1 --- /dev/null +++ b/src/client/views/nodes/WebBoxRenderer.js @@ -0,0 +1,395 @@ +/** + * + * @param {StyleSheetList} styleSheets + */ +var ForeignHtmlRenderer = function (styleSheets) { + + const self = this; + + /** + * + * @param {String} binStr + */ + const binaryStringToBase64 = function (binStr) { + return new Promise(function (resolve) { + const reader = new FileReader(); + reader.readAsDataURL(binStr); + reader.onloadend = function () { + resolve(reader.result); + } + }); + }; + + function prepend(extension) { + return window.location.origin + extension; + } + function CorsProxy(url) { + return prepend("/corsProxy/") + encodeURIComponent(url); + } + /** + * + * @param {String} url + * @returns {Promise} + */ + const getResourceAsBase64 = function (webUrl, inurl) { + return new Promise(function (resolve, reject) { + const xhr = new XMLHttpRequest(); + //const url = inurl.startsWith("/") && !inurl.startsWith("//") ? webUrl + inurl : inurl; + //const url = CorsProxy(inurl.startsWith("/") && !inurl.startsWith("//") ? webUrl + inurl : inurl);// inurl.startsWith("http") ? CorsProxy(inurl) : inurl; + var url = inurl; + if (inurl.startsWith("/static")) { + url = (new URL(webUrl).origin + inurl); + } else + if ((inurl.startsWith("/") && !inurl.startsWith("//"))) { + url = CorsProxy(new URL(webUrl).origin + inurl); + } else if (!inurl.startsWith("http") && !inurl.startsWith("//")) { + url = CorsProxy(webUrl + "/" + inurl); + } + xhr.open("GET", url); + xhr.responseType = 'blob'; + + xhr.onreadystatechange = async function () { + if (xhr.readyState === 4 && xhr.status === 200) { + const resBase64 = await binaryStringToBase64(xhr.response); + + resolve( + { + "resourceUrl": inurl, + "resourceBase64": resBase64 + } + ); + } else if (xhr.readyState === 4) { + console.log("COULDN'T FIND: " + (inurl.startsWith("/") ? webUrl + inurl : inurl)); + resolve( + { + "resourceUrl": "", + "resourceBase64": inurl + } + ); + } + }; + + xhr.send(null); + }); + }; + + /** + * + * @param {String[]} urls + * @returns {Promise} + */ + const getMultipleResourcesAsBase64 = function (webUrl, urls) { + const promises = []; + for (let i = 0; i < urls.length; i++) { + promises.push(getResourceAsBase64(webUrl, urls[i])); + } + return Promise.all(promises); + }; + + /** + * + * @param {String} str + * @param {Number} startIndex + * @param {String} prefixToken + * @param {String[]} suffixTokens + * + * @returns {String|null} + */ + const parseValue = function (str, startIndex, prefixToken, suffixTokens) { + const idx = str.indexOf(prefixToken, startIndex); + if (idx === -1) { + return null; + } + + let val = ''; + for (let i = idx + prefixToken.length; i < str.length; i++) { + if (suffixTokens.indexOf(str[i]) !== -1) { + break; + } + + val += str[i]; + } + + return { + "foundAtIndex": idx, + "value": val + } + }; + + /** + * + * @param {String} cssRuleStr + * @returns {String[]} + */ + const getUrlsFromCssString = function (cssRuleStr, selector = "url(", delimiters = [')'], mustEndWithQuote = false) { + const urlsFound = []; + let searchStartIndex = 0; + + while (true) { + const url = parseValue(cssRuleStr, searchStartIndex, selector, delimiters); + if (url === null) { + break; + } + searchStartIndex = url.foundAtIndex + url.value.length; + if (mustEndWithQuote && url.value[url.value.length - 1] !== '"') continue; + const unquoted = removeQuotes(url.value); + if (!unquoted /* || (!unquoted.startsWith('http')&& !unquoted.startsWith("/") )*/ || unquoted === 'http://' || unquoted === 'https://') { + continue; + } + + unquoted && urlsFound.push(unquoted); + } + + return urlsFound; + }; + + /** + * + * @param {String} html + * @returns {String[]} + */ + const getImageUrlsFromFromHtml = function (html) { + return getUrlsFromCssString(html, "src=", [' ', '>', '\t'], true); + }; + const getSourceUrlsFromFromHtml = function (html) { + return getUrlsFromCssString(html, "source=", [' ', '>', '\t'], true); + }; + + /** + * + * @param {String} str + * @returns {String} + */ + const removeQuotes = function (str) { + return str.replace(/["']/g, ""); + }; + + const escapeRegExp = function (string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string + }; + + /** + * + * @param {String} contentHtml + * @param {Number} width + * @param {Number} height + * + * @returns {Promise<String>} + */ + const buildSvgDataUri = async function (webUrl, contentHtml, width, height, scroll) { + + return new Promise(async function (resolve, reject) { + + /* !! The problems !! + * 1. CORS (not really an issue, expect perhaps for images, as this is a general security consideration to begin with) + * 2. Platform won't wait for external assets to load (fonts, images, etc.) + */ + + // copy styles + let cssStyles = ""; + let urlsFoundInCss = []; + + for (let i = 0; i < styleSheets.length; i++) { + try { + const rules = styleSheets[i].cssRules + for (let j = 0; j < rules.length; j++) { + const cssRuleStr = rules[j].cssText; + urlsFoundInCss.push(...getUrlsFromCssString(cssRuleStr)); + cssStyles += cssRuleStr; + } + } catch (e) { + + } + } + + // const fetchedResourcesFromStylesheets = await getMultipleResourcesAsBase64(webUrl, urlsFoundInCss); + // for (let i = 0; i < fetchedResourcesFromStylesheets.length; i++) { + // const r = fetchedResourcesFromStylesheets[i]; + // if (r.resourceUrl) { + // cssStyles = cssStyles.replace(new RegExp(escapeRegExp(r.resourceUrl), "g"), r.resourceBase64); + // } + // } + + contentHtml = contentHtml.replace(/<source[^>]*>/g, "") // <picture> tags have a <source> which has a srcset field of image refs. instead of converting each, just use the default <img> of the picture + .replace(/noscript/g, "div").replace(/<div class="mediaset"><\/div>/g, "") // when scripting isn't available (ie, rendering web pages here), <noscript> tags should become <div>'s. But for Brown CS, there's a layout problem if you leave the empty <mediaset> tag + .replace(/<link[^>]*>/g, "") // don't need to keep any linked style sheets because we've already processed all style sheets above + .replace(/srcset="([^ "]*)[^"]*"/g, "src=\"$1\""); // instead of converting each item in the srcset to a data url, just convert the first one and use that + let urlsFoundInHtml = getImageUrlsFromFromHtml(contentHtml); + const fetchedResources = await getMultipleResourcesAsBase64(webUrl, urlsFoundInHtml); + for (let i = 0; i < fetchedResources.length; i++) { + const r = fetchedResources[i]; + if (r.resourceUrl) { + contentHtml = contentHtml.replace(new RegExp(escapeRegExp(r.resourceUrl), "g"), r.resourceBase64); + } + } + + const styleElem = document.createElement("style"); + styleElem.innerHTML = cssStyles.replace(">", ">").replace("<", "<"); + + const styleElemString = new XMLSerializer().serializeToString(styleElem).replace(/>/g, ">").replace(/</g, "<"); + + // create DOM element string that encapsulates styles + content + const contentRootElem = document.createElement("body"); + contentRootElem.style.zIndex = "1111"; + // contentRootElem.style.transform = "scale(0.08)" + contentRootElem.innerHTML = styleElemString + contentHtml; + contentRootElem.setAttribute("xmlns", "http://www.w3.org/1999/xhtml"); + //document.body.appendChild(contentRootElem); + + const contentRootElemString = new XMLSerializer().serializeToString(contentRootElem); + + // build SVG string + const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='${width}' height='${height}'> + <foreignObject x='0' y='${-scroll}' width='${width}' height='${scroll + height}'> + ${contentRootElemString} + </foreignObject> + </svg>`; + + // convert SVG to data-uri + const dataUri = `data:image/svg+xml;base64,${window.btoa(unescape(encodeURIComponent(svg)))}`; + + resolve(dataUri); + }); + }; + + /** + * @param {String} html + * @param {Number} width + * @param {Number} height + * + * @return {Promise<Image>} + */ + this.renderToImage = async function (webUrl, html, width, height, scroll) { + return new Promise(async function (resolve, reject) { + const img = new Image(); + console.log("BUILDING SVG for:" + webUrl); + img.src = await buildSvgDataUri(webUrl, html, width, height, scroll); + + img.onload = function () { + console.log("IMAGE SVG created:" + webUrl); + resolve(img); + }; + }); + }; + + /** + * @param {String} html + * @param {Number} width + * @param {Number} height + * + * @return {Promise<Image>} + */ + this.renderToCanvas = async function (webUrl, html, width, height, scroll) { + return new Promise(async function (resolve, reject) { + const img = await self.renderToImage(webUrl, html, width, height, scroll); + + const canvas = document.createElement('canvas'); + canvas.width = img.width; + canvas.height = img.height; + + const canvasCtx = canvas.getContext('2d'); + canvasCtx.drawImage(img, 0, 0, img.width, img.height); + + resolve(canvas); + }); + }; + + /** + * @param {String} html + * @param {Number} width + * @param {Number} height + * + * @return {Promise<String>} + */ + this.renderToBase64Png = async function (webUrl, html, width, height, scroll) { + return new Promise(async function (resolve, reject) { + const canvas = await self.renderToCanvas(webUrl, html, width, height, scroll); + resolve(canvas.toDataURL('image/png')); + }); + }; + +}; + + +export function CreateImage(webUrl, styleSheets, html, width, height, scroll) { + const val = (new ForeignHtmlRenderer(styleSheets)).renderToBase64Png(webUrl, html.replace(/\n/g, "").replace(/<script((?!\/script).)*<\/script>/g, ""), width, height, scroll); + return val; +} + + + +var ClipboardUtils = new function () { + var permissions = { + 'image/bmp': true, + 'image/gif': true, + 'image/png': true, + 'image/jpeg': true, + 'image/tiff': true + }; + + function getType(types) { + for (var j = 0; j < types.length; ++j) { + var type = types[j]; + if (permissions[type]) { + return type; + } + } + return null; + } + function getItem(items) { + for (var i = 0; i < items.length; ++i) { + var item = items[i]; + if (item) { + var type = getType(item.types); + if (type) { + return item.getType(type); + } + } + } + return null; + } + function loadFile(file, callback) { + if (window.FileReader) { + var reader = new FileReader(); + reader.onload = function () { + callback(reader.result, null); + }; + reader.onerror = function () { + callback(null, 'Incorrect file.'); + }; + reader.readAsDataURL(file); + } else { + callback(null, 'File api is not supported.'); + } + } + this.readImage = function (callback) { + if (navigator.clipboard) { + var promise = navigator.clipboard.read(); + promise + .then(function (items) { + var promise = getItem(items); + if (promise == null) { + callback(null, null); + return; + } + promise + .then(function (result) { + loadFile(result, callback); + }) + .catch(function (error) { + callback(null, 'Reading clipboard error.'); + }); + }) + .catch(function (error) { + callback(null, 'Reading clipboard error.'); + }); + } else { + callback(null, 'Clipboard is not supported.'); + } + }; +}; + + +export function pasteImageBitmap(callback) { + return ClipboardUtils.readImage(callback); +}
\ No newline at end of file |