From 564f6d9f4b8eb43d37796723f7c5ba883a865f2e Mon Sep 17 00:00:00 2001 From: Sunshine Date: Sat, 24 Oct 2026 17:27:44 -0400 Subject: [PATCH] implement support for taking vector (SVG) full-page snapshots --- README.md | 3 +- inc/liquidappwindow.hpp | 10 +- inc/lqd.h | 5 +- inc/svgsnapshot.hpp | 18 + liquid.pro | 2 + res/scripts/html2svg.js | 1187 +++++++++++++++++++++++++++++++++++---- src/liquidappwindow.cpp | 224 ++++++-- src/svgsnapshot.cpp | 553 ++++++++++++++++++ 8 files changed, 1835 insertions(+), 167 deletions(-) create mode 100644 inc/svgsnapshot.hpp create mode 100644 src/svgsnapshot.cpp diff --git a/README.md b/README.md index 174e285..41510bb 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ What gets stored (if allowed): cookies. | Window transparency | ✅ | ❌ | See-through websites | | Full-page snapshots | ✅ | ❌ | Possible with plug-ins | | Transparent snapshots | ✅ | ❌ | See-through snapshots of websites | -| Vector snapshots | ❌ | ❌ | Experimental feature, SVG | +| Vector snapshots | ✅ | ❌ | Experimental feature, SVG with selectable text | | Ability to save pages as monolithic HTML files | ❌ | ❌ | Possible with plug-ins | | Complete absence of pop-up windows | ✅ | ❌ | Can be optionally disabled in most browsers | | Ability to completely disable JS | ✅ | ✅ | | @@ -61,6 +61,7 @@ What gets stored (if allowed): cookies. | Stop loading / exit from full-screen mode | `Esc` | | | Take snapshot | `Ctrl`+`T` | | | Take full-page snapshot | `Ctrl`+`Shift`+`T` | | +| Take vector snapshot (SVG) | `Ctrl`+`Shift`+`S` | | | Toggle window size lock | `Ctrl`+`L` | | | Toggle mute | `Ctrl`+`M` | | | Refresh current page | `Ctrl`+`R` | | diff --git a/inc/liquidappwindow.hpp b/inc/liquidappwindow.hpp index 250937c..3d4488f 100644 --- a/inc/liquidappwindow.hpp +++ b/inc/liquidappwindow.hpp @@ -40,6 +40,7 @@ public slots: void stopLoadingOrExitFullScreenMode(void); void takeSnapshotSlot(void); void takeSnapshotFullPageSlot(void); + void takeVectorSnapshot(void); void toggleFullScreenMode(void); void toggleWindowGeometryLock(void); void updateWindowTitle(const QString title); @@ -60,11 +61,13 @@ public slots: void takeSnapshot(const bool fullPage); QImage captureFullPage(void); void renderSnapshot(QPainter* painter); + void flash(void); + static QString getSnapshotsDirPath(void); static bool isUserInputEvent(const QEvent::Type eventType); static bool isEscapeKeyEvent(QEvent* event); QVariant runJavaScriptSync(const QString& js, const int timeoutMs = 5000); void waitForNextFrame(void); - const QString colorToRgba(const QColor color); + bool waitForDocumentToFinishLoading(void); QString* liquidAppName; @@ -84,8 +87,8 @@ public slots: bool pageHasError = false; bool pageIsLoading = false; bool windowGeometryIsLocked = false; - bool isCapturingFullPage = false; - bool fullPageCaptureCancelled = false; + bool isTakingSnapshot = false; + bool snapshotCancelled = false; QNetworkProxy* proxy = Q_NULLPTR; @@ -103,6 +106,7 @@ public slots: QAction* stopLoadingOrExitFullScreenModeAction; QAction* takeSnapshotAction; QAction* takeSnapshotFullPageAction; + QAction* takeSnapshotVectorAction; QAction* toggleFullScreenModeAction; QAction* toggleFullScreenModeAction2; QAction* toggleGeometryLockAction; diff --git a/inc/lqd.h b/inc/lqd.h index aac1923..6cccd9e 100644 --- a/inc/lqd.h +++ b/inc/lqd.h @@ -14,6 +14,9 @@ #define LQD_SNAPSHOT_FLASH_HOLD 50 // Milliseconds at full brightness #define LQD_SNAPSHOT_FLASH_FADE 300 // Milliseconds to fade out #define LQD_SNAPSHOT_FLASH_OPACITY 0.8 +#define LQD_FRAME_WAIT_WITHOUT_JS 200 // Milliseconds, used by full-page snapshots when JS is disabled +#define LQD_SNAPSHOT_LOAD_WAIT_TIMEOUT 30000 // Milliseconds, for how long snapshots wait for the page to finish loading +#define LQD_VECTOR_SNAPSHOT_TIMEOUT 60000 // Milliseconds #define LQD_SNAPSHOT_MAX_PIXELS 134217728 // Full-page snapshot size limit, in logical pixels (128 megapixels, 512 MB of RAM) #define LQD_ZOOM_LVL_MIN 0.25 #define LQD_ZOOM_LVL_MAX 5.0 // Limited to 5.0 by Chromium @@ -93,7 +96,7 @@ #define LQD_KBD_SEQ_QUIT "Ctrl+Q" #define LQD_KBD_SEQ_QUIT_2 "Ctrl+W" #define LQD_KBD_SEQ_SAVE_PAGE "Ctrl+S" -#define LQD_KBD_SEQ_SAVE_PAGE_AS "Ctrl+Shift+S" // TODO +#define LQD_KBD_SEQ_TAKE_SNAPSHOT_VECTOR "Ctrl+Shift+S" #define LQD_KBD_SEQ_ZOOM_LVL_INC "Ctrl+=" #define LQD_KBD_SEQ_ZOOM_LVL_INC_FINE "Ctrl+Shift+=" #define LQD_KBD_SEQ_ZOOM_LVL_DEC "Ctrl+-" diff --git a/inc/svgsnapshot.hpp b/inc/svgsnapshot.hpp new file mode 100644 index 0000000..10d624b --- /dev/null +++ b/inc/svgsnapshot.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include +#include +#include + +// Turns the output of html2svg.js (a JSON list of primitive shapes) into an SVG document. +// +// The page that the shapes came from is untrusted, so nothing gets copied from it into the SVG as-is: +// only a fixed set of elements and attributes ever gets written, every value gets validated, +// and embedded images get decoded and re-encoded. The result has no scripts, no event handlers, +// no external references, and no embedded HTML, i.e. it's a harmless, frozen picture of the page. +class SvgSnapshot +{ +public: + // Returns an empty array (and sets the error, if provided) upon failure + static QByteArray build(const QByteArray& json, const QColor& backdropColor, const qreal zoomFactor, QString* error = Q_NULLPTR); +}; diff --git a/liquid.pro b/liquid.pro index ba73d2b..39cbaea 100644 --- a/liquid.pro +++ b/liquid.pro @@ -30,6 +30,7 @@ HEADERS += inc/lqd.h \ inc/liquidappwindow.hpp \ inc/mainwindow.hpp \ inc/singleinstance.hpp \ + inc/svgsnapshot.hpp \ SOURCES += src/liquid.cpp \ src/liquidappcookiejar.cpp \ @@ -39,6 +40,7 @@ SOURCES += src/liquid.cpp \ src/main.cpp \ src/mainwindow.cpp \ src/singleinstance.cpp \ + src/svgsnapshot.cpp \ RESOURCES = res/resources.qrc diff --git a/res/scripts/html2svg.js b/res/scripts/html2svg.js index c0f7d01..b8ea75c 100644 --- a/res/scripts/html2svg.js +++ b/res/scripts/html2svg.js @@ -1,125 +1,1112 @@ -// html2svg.js, the engine behind Liquid's vector screenshot functionality - -const snapshot = { - width: %1, - height: %2, - backgroundColor: "%3", - fullPage: %4, -}; - -// const irrelevantNodeNames = [ -// "STYLE", -// "SCRIPT", -// ]; - -const svgDoc = document.implementation.createDocument("http://www.w3.org/2000/svg", "svg", null); - -const svgNode = svgDoc.rootElement; -svgNode.setAttribute("version", "1.1"); -svgNode.setAttribute("width", snapshot.width); -svgNode.setAttribute("height", snapshot.height); - -{ - const svgRectNode = document.createElementNS(null, "rect"); - svgRectNode.setAttribute("width", snapshot.width); - svgRectNode.setAttribute("height", snapshot.height); - svgRectNode.setAttribute("fill", snapshot.backgroundColor); - svgNode.appendChild(svgRectNode); -} - -function htmlElementPosition(htmlElement) { - var rect = htmlElement.getBoundingClientRect(), - scrollLeft = (snapshot.fullPage) ? 0 : (window.scrollX || document.documentElement.scrollLeft), - scrollTop = (snapshot.fullPage) ? 0 : (window.scrollY || document.documentElement.scrollTop); - - return { - left: rect.left + scrollLeft, - top: rect.top + scrollTop, - width: rect.width, - height: rect.height, - }; -} - -function processElement(htmlElement) { - const style = window.getComputedStyle(htmlElement); - - if (style.visibility != "hidden" && style.display != "none" && style.opacity > 0) { - const parameters = htmlElementPosition(htmlElement); - - if (parameters.width > 0 && parameters.height > 0) { - const svgRectNode = document.createElementNS(null, "rect"); - svgRectNode.setAttribute("x", parameters.left); - svgRectNode.setAttribute("y", parameters.top); - svgRectNode.setAttribute("width", parameters.width); - svgRectNode.setAttribute("height", parameters.height) ; - if (style.backgroundColor != "rgba(0, 0, 0, 0)") { - svgRectNode.setAttribute("fill", style.backgroundColor); +// html2svg.js, the engine behind Liquid's vector snapshots. +// +// Runs inside of the page (in an isolated JavaScript world, which works even when JavaScript is disabled +// for the page, and can't be tampered with by the page's own scripts), walks the rendered page, and produces +// a flat list of primitive shapes (rectangles, borders, lines of text, raster images) in document coordinates. +// +// This script does NOT produce any SVG markup itself: Liquid turns this list into SVG on its own, +// only ever writing out elements and attributes it knows to be harmless. +// +// Since some parts of the work are asynchronous (loading and rasterizing images), the result gets placed into +// window.lqdHtml2Svg (as a JSON string), for Liquid to pick it up once it's marked as done. + +(() => { + const state = window.lqdHtml2Svg = { done: false, result: null, error: null }; + + const MAX_ITEMS = 500000; + const MAX_IMAGE_SCALE = 2; // Raster images get embedded at up to 2x their displayed size + const SVG_NS = "http://www.w3.org/2000/svg"; + + const scrollX = window.scrollX; + const scrollY = window.scrollY; + const devicePixelRatio = window.devicePixelRatio || 1; + + const items = []; + const clips = []; + const clipIndexByKey = new Map(); + const pendingTasks = []; + let skippedImageCount = 0; + + //////////////////////////////////////////////////////////////////////////// + // Colors + //////////////////////////////////////////////////////////////////////////// + + const colorCanvas = document.createElement("canvas"); + colorCanvas.width = colorCanvas.height = 1; + const colorContext = colorCanvas.getContext("2d", { willReadFrequently: true }); + const colorCache = new Map(); + + // Returns [r, g, b, a] (0-255, 0-255, 0-255, 0-1), or null if fully transparent + function parseColor(cssColor) { + if (!cssColor || cssColor === "transparent") { + return null; + } + + let color = colorCache.get(cssColor); + + if (color === undefined) { + color = null; + + const match = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:[\s,/]+([\d.]+)(%?))?\s*\)$/.exec(cssColor); + + if (match) { + let alpha = (match[4] === undefined) ? 1 : parseFloat(match[4]); + + if (match[5] === "%") { + alpha /= 100; + } + + color = [Math.round(+match[1]), Math.round(+match[2]), Math.round(+match[3]), alpha]; } else { - svgRectNode.setAttribute("fill", "none"); + // Any other color syntax (e.g. oklch() or color()): let the browser paint it, then read the pixel back + colorContext.clearRect(0, 0, 1, 1); + colorContext.fillStyle = "rgba(0, 0, 0, 0)"; + colorContext.fillStyle = cssColor; + colorContext.fillRect(0, 0, 1, 1); + + const pixel = colorContext.getImageData(0, 0, 1, 1).data; + color = [pixel[0], pixel[1], pixel[2], pixel[3] / 255]; + } + + if (color && color[3] <= 0) { + color = null; + } + + colorCache.set(cssColor, color); + } + + return color; + } + + //////////////////////////////////////////////////////////////////////////// + // Geometry + //////////////////////////////////////////////////////////////////////////// + + function round(n) { + return Math.round(n * 100) / 100; + } + + // Converts a client rect into document coordinates + function toDocumentRect(rect) { + return { x: rect.left + scrollX, y: rect.top + scrollY, w: rect.width, h: rect.height }; + } + + function intersectRects(a, b) { + const x = Math.max(a.x, b.x); + const y = Math.max(a.y, b.y); + const right = Math.min(a.x + a.w, b.x + b.w); + const bottom = Math.min(a.y + a.h, b.y + b.h); + + return { x: x, y: y, w: Math.max(0, right - x), h: Math.max(0, bottom - y) }; + } + + function rectsOverlap(a, b) { + return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h; + } + + function getClipRect(clipIndex) { + return (clipIndex < 0) ? null : clips[clipIndex].rect; + } + + function addClip(parentClipIndex, rect) { + const parentRect = getClipRect(parentClipIndex); + const clipRect = (parentRect) ? intersectRects(parentRect, rect) : rect; + const key = [round(clipRect.x), round(clipRect.y), round(clipRect.w), round(clipRect.h)].join(","); + + let index = clipIndexByKey.get(key); + + if (index === undefined) { + index = clips.length; + clips.push({ rect: clipRect, data: [round(clipRect.x), round(clipRect.y), round(clipRect.w), round(clipRect.h)] }); + clipIndexByKey.set(key, index); + } + + return index; + } + + // Tells whether a rectangle would be visible at all, given the clip it's subject to + function isWorthPainting(rect, context) { + if (rect.w <= 0 || rect.h <= 0) { + return false; + } + + const clipRect = getClipRect(context.clip); + + return !clipRect || rectsOverlap(rect, clipRect); + } + + function getBorderWidths(style) { + return { + top: parseFloat(style.borderTopWidth) || 0, + right: parseFloat(style.borderRightWidth) || 0, + bottom: parseFloat(style.borderBottomWidth) || 0, + left: parseFloat(style.borderLeftWidth) || 0, + }; + } + + function getPaddings(style) { + return { + top: parseFloat(style.paddingTop) || 0, + right: parseFloat(style.paddingRight) || 0, + bottom: parseFloat(style.paddingBottom) || 0, + left: parseFloat(style.paddingLeft) || 0, + }; + } + + function shrinkRect(rect, edges) { + return { + x: rect.x + edges.left, + y: rect.y + edges.top, + w: Math.max(0, rect.w - edges.left - edges.right), + h: Math.max(0, rect.h - edges.top - edges.bottom), + }; + } + + // Returns corner radii as [top-left, top-right, bottom-right, bottom-left] (circular only), or null if none + function getRadii(style, rect) { + function parseRadius(value) { + const first = String(value).trim().split(/\s+/)[0]; + + if (first.endsWith("%")) { + return parseFloat(first) / 100 * Math.min(rect.w, rect.h); + } + + return parseFloat(first) || 0; + } + + const radii = [ + parseRadius(style.borderTopLeftRadius), + parseRadius(style.borderTopRightRadius), + parseRadius(style.borderBottomRightRadius), + parseRadius(style.borderBottomLeftRadius), + ]; + + if (radii.every((r) => r <= 0)) { + return null; + } + + // Scale radii down if they overlap (the same way browsers do) + const factor = Math.min( + 1, + rect.w / Math.max(radii[0] + radii[1], 1e-6), + rect.w / Math.max(radii[3] + radii[2], 1e-6), + rect.h / Math.max(radii[0] + radii[3], 1e-6), + rect.h / Math.max(radii[1] + radii[2], 1e-6), + ); + + return radii.map((r) => round(Math.max(0, r * factor))); + } + + function insetRadii(radii, inset) { + return (radii) ? radii.map((r) => round(Math.max(0, r - inset))) : null; + } + + //////////////////////////////////////////////////////////////////////////// + // Items + //////////////////////////////////////////////////////////////////////////// + + function pushItem(item, context) { + if (items.length >= MAX_ITEMS) { + throw new Error("Page is too complex"); + } + + item.clip = context.clip; + item.opacity = round(context.opacity); + items.push(item); + + return items.length - 1; + } + + function rectData(rect) { + return [round(rect.x), round(rect.y), round(rect.w), round(rect.h)]; + } + + function paintBackgroundColor(style, rect, context) { + const color = parseColor(style.backgroundColor); + + if (color && isWorthPainting(rect, context)) { + pushItem({ type: "rect", rect: rectData(rect), fill: color, radii: getRadii(style, rect) }, context); + } + } + + function paintBorders(style, rect, context) { + const widths = getBorderWidths(style); + const sides = ["top", "right", "bottom", "left"]; + const styles = { + top: style.borderTopStyle, + right: style.borderRightStyle, + bottom: style.borderBottomStyle, + left: style.borderLeftStyle, + }; + const colors = { + top: style.borderTopColor, + right: style.borderRightColor, + bottom: style.borderBottomColor, + left: style.borderLeftColor, + }; + + const visibleSides = sides.filter((side) => + widths[side] > 0 && styles[side] !== "none" && styles[side] !== "hidden" && parseColor(colors[side]) + ); + + if (visibleSides.length === 0 || !isWorthPainting(rect, context)) { + return; + } + + const isUniform = visibleSides.length === 4 && sides.every((side) => + widths[side] === widths.top && styles[side] === styles.top && colors[side] === colors.top + ); + + if (isUniform) { + // One stroked shape, which also takes care of rounded corners + const width = widths.top; + const inset = width / 2; + const strokeRect = { x: rect.x + inset, y: rect.y + inset, w: rect.w - width, h: rect.h - width }; + + if (strokeRect.w > 0 && strokeRect.h > 0) { + pushItem({ + type: "border", + rect: rectData(strokeRect), + width: round(width), + color: parseColor(colors.top), + dash: (styles.top === "dashed" || styles.top === "dotted") ? styles.top : "solid", + radii: insetRadii(getRadii(style, rect), inset), + }, context); } - svgNode.appendChild(svgRectNode); + } else { + // Each side as its own filled strip (corners are square) + for (const side of visibleSides) { + const width = widths[side]; + let strip; + + switch (side) { + case "top": strip = { x: rect.x, y: rect.y, w: rect.w, h: width }; break; + case "right": strip = { x: rect.x + rect.w - width, y: rect.y, w: width, h: rect.h }; break; + case "bottom": strip = { x: rect.x, y: rect.y + rect.h - width, w: rect.w, h: width }; break; + case "left": strip = { x: rect.x, y: rect.y, w: width, h: rect.h }; break; + } + + pushItem({ type: "rect", rect: rectData(strip), fill: parseColor(colors[side]), radii: null }, context); + } + } + } + + //////////////////////////////////////////////////////////////////////////// + // Text + //////////////////////////////////////////////////////////////////////////// + + const metricsCanvas = document.createElement("canvas"); + const metricsContext = metricsCanvas.getContext("2d"); + const fontMetricsCache = new Map(); + + function getCanvasFont(style) { + return [style.fontStyle, style.fontWeight, style.fontSize, style.fontFamily].join(" "); + } + + // Returns fraction of the text box's height that lies above the baseline + function getAscentRatio(style) { + const font = getCanvasFont(style); + let ratio = fontMetricsCache.get(font); + + if (ratio === undefined) { + metricsContext.font = font; + const metrics = metricsContext.measureText("Hg"); + const ascent = metrics.fontBoundingBoxAscent; + const descent = metrics.fontBoundingBoxDescent; + + ratio = (ascent > 0 && descent >= 0) ? ascent / (ascent + descent) : 0.8; + fontMetricsCache.set(font, ratio); + } + + return ratio; + } + + function measureTextWidth(text, style) { + metricsContext.font = getCanvasFont(style); + + return metricsContext.measureText(text).width; + } + + function applyTextTransform(text, style) { + switch (style.textTransform) { + case "uppercase": + return text.toUpperCase(); + + case "lowercase": + return text.toLowerCase(); + + case "capitalize": + return text.replace(/(^|[\s\-])(\S)/g, (match, prefix, letter) => prefix + letter.toUpperCase()); + + default: + return text; } } -} -function processTextNode(htmlTextNode) { - const text = htmlTextNode.nodeValue; + function getTextDecoration(style) { + const line = style.textDecorationLine || style.textDecoration || ""; + const decorations = []; - if (text.trim().length > 0) { - const htmlParentElement = htmlTextNode.parentElement; - const style = window.getComputedStyle(htmlParentElement); - const parameters = htmlElementPosition(htmlParentElement); + for (const decoration of ["underline", "line-through", "overline"]) { + if (line.includes(decoration)) { + decorations.push(decoration); + } + } - const svgTextNode = document.createElementNS(null, "text"); - svgTextNode.setAttribute("x", parameters.left); - svgTextNode.setAttribute("y", parameters.top); - svgTextNode.setAttribute("color", style.color); + return decorations.join(" "); + } - var textNode = document.createTextNode(text); - svgTextNode.appendChild(textNode); + function pushTextLine(text, left, right, top, bottom, style, fill, context) { + if (text.length === 0) { + return; + } - svgNode.appendChild(svgTextNode); + const rect = { x: left + scrollX, y: top + scrollY, w: right - left, h: bottom - top }; + + if (!isWorthPainting({ x: rect.x, y: rect.y, w: Math.max(rect.w, 1), h: Math.max(rect.h, 1) }, context)) { + return; + } + + pushItem({ + type: "text", + x: round(rect.x), + y: round(rect.y + rect.h * getAscentRatio(style)), // SVG text is positioned by its baseline + width: round(rect.w), + text: applyTextTransform(text, style), + fontFamily: style.fontFamily, + fontSize: round(parseFloat(style.fontSize) || 16), + fontWeight: style.fontWeight, + fontStyle: style.fontStyle, + decoration: context.decoration, + fill: fill, + }, context); } -} -function walkDom(htmlNode) { - if (htmlNode.nodeType == Node.ELEMENT_NODE) { - processElement(htmlNode); + function processTextNode(textNode, style, context) { + const text = textNode.nodeValue; + + if (!text || style.visibility !== "visible") { + return; + } + + const fill = parseColor(style.webkitTextFillColor || style.color); + + if (!fill) { + return; + } + + const whiteSpace = style.whiteSpace; + const collapsesSpaces = (whiteSpace === "normal" || whiteSpace === "nowrap" || whiteSpace === "pre-line"); + + if (collapsesSpaces && !/\S/.test(text)) { + return; + } + + const range = document.createRange(); + + // Fast path: text that fits on one line + range.selectNodeContents(textNode); + const nodeRects = range.getClientRects(); + + if (nodeRects.length === 0) { + return; + } + + if (nodeRects.length === 1 && !text.includes("\n")) { + const rect = nodeRects[0]; + + if (rect.width > 0 && rect.height > 0) { + if (collapsesSpaces) { + // Collapsed leading/trailing spaces may or may not take room, + // so measure from the first to the last visible character instead + range.setStart(textNode, text.search(/\S/)); + range.setEnd(textNode, text.trimEnd().length); + const trimmedRect = range.getBoundingClientRect(); + + pushTextLine(text.replace(/\s+/g, " ").trim(), trimmedRect.left, trimmedRect.right, + rect.top, rect.bottom, style, fill, context); + } else { + pushTextLine(text.replace(/\t/g, " "), rect.left, rect.right, rect.top, rect.bottom, style, fill, context); + } + } + + return; + } + + // Text spanning multiple lines: measure it word by word (character by character for words that wrap), + // and group pieces that ended up on the same line together + const pieces = []; + const wordPattern = /\S+/g; + let match; + + while ((match = wordPattern.exec(text)) !== null) { + const start = match.index; + const end = start + match[0].length; + + range.setStart(textNode, start); + range.setEnd(textNode, end); + const rects = range.getClientRects(); + + if (rects.length === 1) { + pieces.push({ start: start, end: end, rect: rects[0] }); + } else if (rects.length > 1) { + for (let i = start; i < end; i++) { + range.setStart(textNode, i); + range.setEnd(textNode, i + 1); + const charRect = range.getBoundingClientRect(); + + if (charRect.width > 0 || charRect.height > 0) { + pieces.push({ start: i, end: i + 1, rect: charRect }); + } + } + } + } + + let line = null; + + function flushLine() { + if (line) { + let content; + + if (collapsesSpaces) { + // Pieces are either whole words or single characters (of words broken across lines), + // only put a space in between those that had whitespace in between them to begin with + content = line.pieces.map((piece, index) => { + const gap = (index > 0) ? text.slice(line.pieces[index - 1].end, piece.start) : ""; + + return ((/\s/.test(gap)) ? " " : "") + text.slice(piece.start, piece.end); + }).join(""); + } else { + content = text.slice(line.pieces[0].start, line.pieces[line.pieces.length - 1].end).replace(/\t/g, " "); + } + + pushTextLine(content, line.left, line.right, line.top, line.bottom, style, fill, context); + line = null; + } + } + + for (const piece of pieces) { + const rect = piece.rect; + const startsNewLine = !line || + rect.top >= line.bottom - (line.bottom - line.top) * 0.25 || + rect.left < line.right - 1 || + text.slice(line.pieces[line.pieces.length - 1].end, piece.start).includes("\n"); - for (let childNode of htmlNode.childNodes) { - walkDom(childNode); + if (startsNewLine) { + flushLine(); + line = { pieces: [], left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom }; + } + + line.pieces.push(piece); + line.right = Math.max(line.right, rect.right); + line.top = Math.min(line.top, rect.top); + line.bottom = Math.max(line.bottom, rect.bottom); } - } else if (htmlNode.nodeType == Node.TEXT_NODE) { - processTextNode(htmlNode); + + flushLine(); } -} -////////////////////////////////////////////////////////////////////////////// + // Text inside of form controls doesn't exist as text nodes, it has to be painted separately + function paintFormControlText(element, style, rect, context) { + const tagName = element.localName; + let lines = null; + let fill = parseColor(style.color); + let isCentered = false; -/* -const elementsOfInterest = []; + if (tagName === "input") { + const type = (element.type || "text").toLowerCase(); -for (let x = (snapshot.fullPage) ? 0 : window.scrollX; x <= %1; x++) { - for (let y = (snapshot.fullPage) ? 0 : window.scrollY; y <= %1; y++) { - const elements = document.elementsFromPoint(x, y); - if (elements.length > 0) { - for (let z = 0; z < elements.length; z++) { - if (elementsOfInterest.indexOf(elements[z]) == -1) { - elementsOfInterest.push(elements[z]); + if (["button", "submit", "reset"].includes(type)) { + lines = [element.value || ""]; + isCentered = true; + } else if (["text", "search", "email", "url", "tel", "number", "password"].includes(type)) { + if (element.value) { + lines = [(type === "password") ? "•".repeat(element.value.length) : element.value]; + } else if (element.placeholder) { + lines = [element.placeholder]; + fill = parseColor(getComputedStyle(element, "::placeholder").color) || fill; } } + } else if (tagName === "textarea") { + if (element.value) { + lines = element.value.split("\n"); + } else if (element.placeholder) { + lines = element.placeholder.split("\n"); + fill = parseColor(getComputedStyle(element, "::placeholder").color) || fill; + } + } else if (tagName === "select") { + const option = element.options[element.selectedIndex]; + + if (option) { + lines = [option.text]; + } + } + + if (!lines || !fill) { + return; } + + const contentRect = shrinkRect(shrinkRect(rect, getBorderWidths(style)), getPaddings(style)); + + if (contentRect.w <= 0 || contentRect.h <= 0) { + return; + } + + const clippedContext = { clip: addClip(context.clip, contentRect), opacity: context.opacity, decoration: "" }; + const fontSize = parseFloat(style.fontSize) || 16; + const lineHeight = parseFloat(style.lineHeight) || fontSize * 1.2; + const ascentRatio = getAscentRatio(style); + // Single-line controls center their text vertically, multi-line ones start at the top + const firstLineTop = (tagName === "textarea") ? contentRect.y : contentRect.y + (contentRect.h - lineHeight) / 2; + + lines.forEach((lineText, index) => { + if (lineText.length === 0) { + return; + } + + const width = measureTextWidth(lineText, style); + const left = (isCentered) ? contentRect.x + (contentRect.w - width) / 2 : contentRect.x; + const textTop = firstLineTop + index * lineHeight + (lineHeight - fontSize * 1.2) / 2; + + pushItem({ + type: "text", + x: round(left), + y: round(textTop + fontSize * 1.2 * ascentRatio), + width: round(width), + text: lineText, + fontFamily: style.fontFamily, + fontSize: round(fontSize), + fontWeight: style.fontWeight, + fontStyle: style.fontStyle, + decoration: "", + fill: fill, + }, clippedContext); + }); } -} -for (let i = 0; i < elementsOfInterest.length; i++) { - processElement(elementsOfInterest[0]); -} -*/ + //////////////////////////////////////////////////////////////////////////// + // Images + //////////////////////////////////////////////////////////////////////////// + + function getPreserveAspectRatio(style) { + switch (style.objectFit) { + case "contain": + case "scale-down": + return "xMidYMid meet"; + + case "cover": + case "none": + return "xMidYMid slice"; + + default: // "fill" + return "none"; + } + } + + // Draws something drawable onto a canvas and returns it as a data URL (or null if that's not allowed) + function rasterize(source, sourceWidth, sourceHeight, displayWidth, displayHeight, preferJpeg) { + const maxSize = MAX_IMAGE_SCALE * Math.max(devicePixelRatio, 1) * Math.max(displayWidth, displayHeight); + const scale = Math.min(1, maxSize / Math.max(sourceWidth, sourceHeight, 1)); + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.round(sourceWidth * scale)); + canvas.height = Math.max(1, Math.round(sourceHeight * scale)); + + try { + canvas.getContext("2d").drawImage(source, 0, 0, canvas.width, canvas.height); + + // Throws if the canvas got "tainted" by an image from a different origin + return (preferJpeg) ? canvas.toDataURL("image/jpeg", 0.92) : canvas.toDataURL("image/png"); + } catch (error) { + return null; + } + } + + function pushImage(href, rect, preserveAspectRatio, radii, context) { + if (href && /^data:image\/(png|jpeg);base64,/.test(href)) { + pushItem({ type: "image", rect: rectData(rect), href: href, preserveAspectRatio: preserveAspectRatio, radii: radii }, context); + } else { + skippedImageCount++; + } + } + + function paintImage(element, style, rect, context) { + const contentRect = shrinkRect(shrinkRect(rect, getBorderWidths(style)), getPaddings(style)); + + if (!isWorthPainting(contentRect, context)) { + return; + } + + const radii = getRadii(style, rect); + + if (element.localName === "img") { + if (!element.complete || element.naturalWidth === 0) { + return; + } + + const source = element.currentSrc || element.src || ""; + const preferJpeg = /^data:image\/jpe?g|\.jpe?g($|[?#])/i.test(source); + + pushImage(rasterize(element, element.naturalWidth, element.naturalHeight, contentRect.w, contentRect.h, preferJpeg), + contentRect, getPreserveAspectRatio(style), radii, context); + } else if (element.localName === "canvas") { + pushImage(rasterize(element, element.width, element.height, contentRect.w, contentRect.h, false), + contentRect, "none", radii, context); + } else if (element.localName === "video") { + if (element.readyState >= 2 && element.videoWidth > 0) { + const fit = getPreserveAspectRatio(style); + + pushImage(rasterize(element, element.videoWidth, element.videoHeight, contentRect.w, contentRect.h, true), + contentRect, (fit === "none") ? "xMidYMid meet" : fit, radii, context); + } + } + } + + // Inline SVG gets rasterized (embedding page-provided SVG markup as-is would defeat the purpose of the + // resulting file being harmless). Page styles don't apply to a standalone copy, so computed ones get inlined. + const SVG_STYLE_PROPERTIES = [ + "fill", "fill-opacity", "fill-rule", "stroke", "stroke-width", "stroke-opacity", "stroke-linecap", + "stroke-linejoin", "stroke-dasharray", "opacity", "color", "visibility", "display", + "font-family", "font-size", "font-weight", "font-style", "text-anchor", "dominant-baseline", + "stop-color", "stop-opacity", + ]; + + function paintInlineSvg(svgElement, rect, context) { + if (!isWorthPainting(rect, context)) { + return; + } + + const clone = svgElement.cloneNode(true); + const originals = [svgElement, ...svgElement.querySelectorAll("*")]; + const copies = [clone, ...clone.querySelectorAll("*")]; + + originals.forEach((original, index) => { + const computed = getComputedStyle(original); + + copies[index].setAttribute("style", SVG_STYLE_PROPERTIES + .map((property) => property + ":" + computed.getPropertyValue(property)) + .join(";")); + }); + + // Resolve references to symbols defined elsewhere in the document (commonly used for icon sprites) + for (const use of clone.querySelectorAll("use")) { + const reference = use.getAttribute("href") || use.getAttributeNS("http://www.w3.org/1999/xlink", "href") || ""; + + if (!reference.startsWith("#")) { + continue; + } + + const id = reference.slice(1); + + if ([...clone.querySelectorAll("[id]")].some((node) => node.id === id)) { + continue; // Defined within this very SVG, nothing to resolve + } + + const target = document.getElementById(id); + + if (target) { + const replacement = document.createElementNS(SVG_NS, "svg"); + + for (const attribute of ["x", "y", "width", "height", "style"]) { + if (use.hasAttribute(attribute)) { + replacement.setAttribute(attribute, use.getAttribute(attribute)); + } + } + + if (target.localName === "symbol") { + if (target.hasAttribute("viewBox")) { + replacement.setAttribute("viewBox", target.getAttribute("viewBox")); + } + + for (const child of target.childNodes) { + replacement.appendChild(child.cloneNode(true)); + } + } else { + replacement.appendChild(target.cloneNode(true)); + } + + use.replaceWith(replacement); + } + } + + // Scripts and event handlers wouldn't run in an image anyway, but there's no reason to keep them around + clone.querySelectorAll("script, foreignObject").forEach((node) => node.remove()); + + clone.setAttribute("xmlns", SVG_NS); + clone.setAttribute("width", rect.w); + clone.setAttribute("height", rect.h); + + const markup = new XMLSerializer().serializeToString(clone); + const url = URL.createObjectURL(new Blob([markup], { type: "image/svg+xml" })); + const index = pushItem({ type: "image", rect: rectData(rect), href: null, preserveAspectRatio: "none", radii: null }, context); + + pendingTasks.push(loadImage(url).then((image) => { + if (image) { + const scale = MAX_IMAGE_SCALE * Math.max(devicePixelRatio, 1); + items[index].href = rasterize(image, rect.w * scale, rect.h * scale, rect.w, rect.h, false); + } + + URL.revokeObjectURL(url); + })); + } + + //////////////////////////////////////////////////////////////////////////// + // Background images + //////////////////////////////////////////////////////////////////////////// + + const MAX_BACKGROUND_CANVAS_AREA = 16 * 1024 * 1024; + const imageLoadCache = new Map(); + + // Splits a comma-separated CSS list, ignoring commas inside of parentheses + function splitCssList(value) { + const parts = []; + let depth = 0; + let current = ""; + + for (const character of String(value)) { + if (character === "(") { + depth++; + } else if (character === ")") { + depth--; + } + + if (character === "," && depth === 0) { + parts.push(current.trim()); + current = ""; + } else { + current += character; + } + } + + parts.push(current.trim()); + + return parts; + } + + function loadImage(url) { + let promise = imageLoadCache.get(url); + + if (!promise) { + // Event handlers and timers don't run when JavaScript is disabled for the page, promises do + const image = new Image(); + image.src = url; + promise = image.decode().then(() => image, () => null); + imageLoadCache.set(url, promise); + } + + return promise; + } + + // Resolves a length such as "10px", "50%", or "calc(100% - 10px)" against the given size + function resolveLength(value, size) { + value = String(value).trim(); + + let match = /^(-?[\d.]+)px$/.exec(value); + if (match) { + return parseFloat(match[1]); + } + + match = /^(-?[\d.]+)%$/.exec(value); + if (match) { + return parseFloat(match[1]) / 100 * size; + } + + match = /^calc\((-?[\d.]+)%\s*([+-])\s*(-?[\d.]+)px\)$/.exec(value); + if (match) { + const offset = parseFloat(match[3]) * ((match[2] === "-") ? -1 : 1); + return parseFloat(match[1]) / 100 * size + offset; + } + + return 0; + } + + function computeBackgroundLayerSize(sizeValue, image, area) { + const naturalWidth = image.naturalWidth || area.w; + const naturalHeight = image.naturalHeight || area.h; + + if (sizeValue === "cover" || sizeValue === "contain") { + const scales = [area.w / naturalWidth, area.h / naturalHeight]; + const scale = (sizeValue === "cover") ? Math.max(...scales) : Math.min(...scales); + + return { w: naturalWidth * scale, h: naturalHeight * scale }; + } + + const tokens = String(sizeValue).trim().split(/\s+/); + const widthToken = tokens[0] || "auto"; + const heightToken = tokens[1] || "auto"; + let width = (widthToken === "auto") ? null : resolveLength(widthToken, area.w); + let height = (heightToken === "auto") ? null : resolveLength(heightToken, area.h); + + if (width === null && height === null) { + width = naturalWidth; + height = naturalHeight; + } else if (width === null) { + width = height * naturalWidth / naturalHeight; + } else if (height === null) { + height = width * naturalHeight / naturalWidth; + } + + return { w: width, h: height }; + } + + // CSS background images (often used for icons and sprites) get rasterized together into a single image, + // which takes care of sizing, positioning, repeating, and clipping them all at once + function paintBackgroundImages(style, rect, context) { + const layers = splitCssList(style.backgroundImage); + const urls = layers.map((layer) => { + const match = /^url\(\s*(["']?)(.*)\1\s*\)$/.exec(layer); + return (match) ? match[2] : null; + }); + + if (!urls.some((url) => url) || !isWorthPainting(rect, context)) { + return; + } + + const sizes = splitCssList(style.backgroundSize); + const positionsX = splitCssList(style.backgroundPositionX); + const positionsY = splitCssList(style.backgroundPositionY); + const repeats = splitCssList(style.backgroundRepeat); + const origins = splitCssList(style.backgroundOrigin); + const clipAreas = splitCssList(style.backgroundClip); + const borders = getBorderWidths(style); + const paddings = getPaddings(style); + const borderBox = { x: 0, y: 0, w: rect.w, h: rect.h }; + const paddingBox = shrinkRect(borderBox, borders); + const contentBox = shrinkRect(paddingBox, paddings); + const boxes = { "border-box": borderBox, "padding-box": paddingBox, "content-box": contentBox }; + + const index = pushItem({ type: "image", rect: rectData(rect), href: null, preserveAspectRatio: "none", radii: getRadii(style, rect) }, context); + + pendingTasks.push(Promise.all(urls.map((url) => (url) ? loadImage(url) : null)).then((images) => { + let scale = MAX_IMAGE_SCALE * Math.max(devicePixelRatio, 1); + + if (rect.w * rect.h * scale * scale > MAX_BACKGROUND_CANVAS_AREA) { + scale = Math.sqrt(MAX_BACKGROUND_CANVAS_AREA / (rect.w * rect.h)); + } + + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.round(rect.w * scale)); + canvas.height = Math.max(1, Math.round(rect.h * scale)); + const context2d = canvas.getContext("2d"); + context2d.scale(scale, scale); + + let hasPainted = false; + + // The first layer is the topmost one, so paint them in reverse order + for (let i = layers.length - 1; i >= 0; i--) { + const image = images[i]; + + if (!image) { + continue; + } + + const area = boxes[origins[i % origins.length]] || paddingBox; + const clipArea = boxes[clipAreas[i % clipAreas.length]] || borderBox; + const size = computeBackgroundLayerSize(sizes[i % sizes.length], image, area); + + if (!(size.w > 0 && size.h > 0)) { + continue; + } + + const x = area.x + resolveLength(positionsX[i % positionsX.length], area.w - size.w); + const y = area.y + resolveLength(positionsY[i % positionsY.length], area.h - size.h); + const repeatTokens = repeats[i % repeats.length].split(/\s+/); + const repeatX = (repeatTokens[0] === "repeat-x") || (repeatTokens[0] !== "repeat-y" && repeatTokens[0] !== "no-repeat"); + const repeatY = (repeatTokens[0] === "repeat-y") || + (repeatTokens.length > 1 ? repeatTokens[1] !== "no-repeat" : (repeatTokens[0] !== "repeat-x" && repeatTokens[0] !== "no-repeat")); + + context2d.save(); + context2d.beginPath(); + context2d.rect(clipArea.x, clipArea.y, clipArea.w, clipArea.h); + context2d.clip(); + + if (!repeatX && !repeatY) { + context2d.drawImage(image, x, y, size.w, size.h); + } else { + const pattern = context2d.createPattern(image, (repeatX && repeatY) ? "repeat" : (repeatX) ? "repeat-x" : "repeat-y"); + const naturalWidth = image.naturalWidth || size.w; + const naturalHeight = image.naturalHeight || size.h; + + pattern.setTransform(new DOMMatrix().translate(x, y).scale(size.w / naturalWidth, size.h / naturalHeight)); + context2d.fillStyle = pattern; + context2d.fillRect(clipArea.x, clipArea.y, clipArea.w, clipArea.h); + } + + context2d.restore(); + hasPainted = true; + } + + if (hasPainted) { + try { + items[index].href = canvas.toDataURL("image/png"); + } catch (error) { + // Image(s) from a different origin + } + } + })); + } + + //////////////////////////////////////////////////////////////////////////// + // Walking the page + //////////////////////////////////////////////////////////////////////////// + + const IGNORED_ELEMENTS = new Set(["head", "script", "style", "noscript", "template", "iframe", "object", "embed", "frame", "frameset"]); + + const documentElement = document.documentElement; + const body = document.body; + + // The root element's background (or the body's, if the root has none) paints the entire canvas + let canvasBackgroundElement = null; + + if (parseColor(getComputedStyle(documentElement).backgroundColor)) { + canvasBackgroundElement = documentElement; + } else if (body && parseColor(getComputedStyle(body).backgroundColor)) { + canvasBackgroundElement = body; + } + + function getChildNodes(node) { + if (node.shadowRoot) { + return node.shadowRoot.childNodes; + } + + if (node.localName === "slot" && typeof node.assignedNodes === "function") { + const assigned = node.assignedNodes({ flatten: true }); + + if (assigned.length > 0) { + return assigned; + } + } + + return node.childNodes; + } + + function walkElement(element, context) { + if (IGNORED_ELEMENTS.has(element.localName)) { + return; + } + + const style = getComputedStyle(element); + + if (style.display === "none") { + return; + } + + const opacity = context.opacity * (parseFloat(style.opacity) || 0); + + if (opacity <= 0) { + return; + } + + // Fixed elements escape all scrollable containers (and stay where they currently are on the screen) + let clip = (style.position === "fixed") ? -1 : context.clip; + const ownContext = { clip: clip, opacity: opacity }; + const isVisible = style.visibility === "visible"; + const hasBox = style.display !== "contents"; + const rect = (hasBox) ? toDocumentRect(element.getBoundingClientRect()) : null; + + // Text decorations (e.g. underline) propagate to descendants' text, + // except for atomic inline-level, floating, and absolutely positioned ones + const isolatesDecorations = style.display.startsWith("inline-") || style.float !== "none" || + style.position === "absolute" || style.position === "fixed"; + const ownDecoration = getTextDecoration(style); + const inheritedDecoration = (isolatesDecorations) ? "" : context.decoration; + const decoration = [...new Set((inheritedDecoration + " " + ownDecoration).split(" ").filter((d) => d))].join(" "); + ownContext.decoration = decoration; + + if (hasBox && isVisible) { + if (element !== canvasBackgroundElement) { + paintBackgroundColor(style, rect, ownContext); + } + + if (style.backgroundImage !== "none") { + paintBackgroundImages(style, rect, ownContext); + } + + if (element !== canvasBackgroundElement) { + paintBorders(style, rect, ownContext); + } + } + + // Replaced elements + if (element.namespaceURI === SVG_NS) { + if (hasBox && isVisible && element.localName === "svg") { + paintInlineSvg(element, rect, ownContext); + } + + return; // Contents of SVG elements are taken care of by rasterizing them + } + + if (hasBox && isVisible) { + switch (element.localName) { + case "img": + case "canvas": + case "video": + paintImage(element, style, rect, ownContext); + return; + + case "input": + case "textarea": + case "select": + paintFormControlText(element, style, rect, ownContext); + return; + } + } + + // Elements that don't let their contents overflow clip their descendants to their padding box + if (hasBox && element !== documentElement && element !== body && + (style.overflowX !== "visible" || style.overflowY !== "visible")) { + clip = addClip(clip, shrinkRect(rect, getBorderWidths(style))); + + if (clips[clip].rect.w <= 0 || clips[clip].rect.h <= 0) { + return; + } + } + + const childContext = { clip: clip, opacity: opacity, decoration: decoration }; + + for (const child of getChildNodes(element)) { + if (child.nodeType === Node.ELEMENT_NODE) { + walkElement(child, childContext); + } else if (child.nodeType === Node.TEXT_NODE) { + processTextNode(child, style, childContext); + } + } + } + + //////////////////////////////////////////////////////////////////////////// + + (async () => { + const width = Math.max(documentElement.scrollWidth, documentElement.clientWidth, body ? body.scrollWidth : 0); + const height = Math.max(documentElement.scrollHeight, documentElement.clientHeight, body ? body.scrollHeight : 0); + + // The canvas background + if (canvasBackgroundElement) { + pushItem({ + type: "rect", + rect: [0, 0, round(width), round(height)], + fill: parseColor(getComputedStyle(canvasBackgroundElement).backgroundColor), + radii: null, + }, { clip: -1, opacity: 1 }); + } + + walkElement(documentElement, { clip: -1, opacity: 1, decoration: "" }); -processElement(document.documentElement); -walkDom(document.body); + await Promise.all(pendingTasks); -////////////////////////////////////////////////////////////////////////////// + const paintedItems = items.filter((item) => item.type !== "image" || item.href); -return '' + new XMLSerializer().serializeToString(svgDoc.documentElement); + return { + version: 1, + width: round(width), + height: round(height), + clips: clips.map((clip) => clip.data), + items: paintedItems, + skippedImages: skippedImageCount + (items.length - paintedItems.length), + }; + })().then((result) => { + state.result = JSON.stringify(result); + state.done = true; + }).catch((error) => { + state.error = String((error && error.message) || error); + state.done = true; + }); +})(); diff --git a/src/liquidappwindow.cpp b/src/liquidappwindow.cpp index 3d0be0b..2881449 100644 --- a/src/liquidappwindow.cpp +++ b/src/liquidappwindow.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -24,6 +25,7 @@ #include "liquidappcookiejar.hpp" #include "liquidappwebpage.hpp" #include "liquidappwindow.hpp" +#include "svgsnapshot.hpp" #include "lqd.h" #ifdef Q_OS_MAC #include "SetWindowBackgroundColor.h" @@ -410,6 +412,12 @@ void LiquidAppWindow::bindKeyboardShortcuts(void) addAction(takeSnapshotFullPageAction); connect(takeSnapshotFullPageAction, SIGNAL(triggered()), this, SLOT(takeSnapshotFullPageSlot())); + // Connect "take vector snapshot" shortcut + takeSnapshotVectorAction = new QAction; + takeSnapshotVectorAction->setShortcut(QKeySequence(tr(LQD_KBD_SEQ_TAKE_SNAPSHOT_VECTOR))); + addAction(takeSnapshotVectorAction); + connect(takeSnapshotVectorAction, &QAction::triggered, this, &LiquidAppWindow::takeVectorSnapshot); + // Connect "save page" shortcut savePageAction = new QAction; savePageAction->setShortcut(QKeySequence(tr(LQD_KBD_SEQ_SAVE_PAGE))); @@ -443,8 +451,8 @@ void LiquidAppWindow::certificateError(void) void LiquidAppWindow::closeEvent(QCloseEvent* event) { - // Closing the window in the middle of taking a full-page snapshot would pull the rug from under it - if (isCapturingFullPage) { + // Closing the window in the middle of taking a snapshot would pull the rug from under it + if (isTakingSnapshot) { event->ignore(); return; } @@ -453,15 +461,6 @@ void LiquidAppWindow::closeEvent(QCloseEvent* event) deleteLater(); } -const QString LiquidAppWindow::colorToRgba(const QColor color) -{ - return QString("rgba(%1, %2, %3, %4)") - .arg(color.red()) - .arg(color.green()) - .arg(color.blue()) - .arg(color.alphaF()); -} - void LiquidAppWindow::contextMenuEvent(QContextMenuEvent* event) { Q_UNUSED(event); @@ -474,10 +473,10 @@ void LiquidAppWindow::contextMenuEvent(QContextMenuEvent* event) bool LiquidAppWindow::eventFilter(QObject* watched, QEvent* event) { - if (isCapturingFullPage) { - // Hitting Escape cancels taking full-page snapshot + if (isTakingSnapshot) { + // Hitting Escape cancels taking the snapshot if (isEscapeKeyEvent(event)) { - fullPageCaptureCancelled = true; + snapshotCancelled = true; return true; } @@ -922,51 +921,28 @@ void LiquidAppWindow::takeSnapshotFullPageSlot(void) takeSnapshot(true); } +QString LiquidAppWindow::getSnapshotsDirPath(void) +{ + const QString path = QDir::homePath() + QDir::separator() + "Pictures"; + + // Ensure the target directory exists + QDir().mkpath(path); + + return path; +} + void LiquidAppWindow::takeSnapshot(const bool fullPage) { - const bool vector = false; // NOTE: experimental feature const int ratio = QPaintDevice::devicePixelRatio(); - const QSize snapshotSize = (fullPage) ? (page()->contentsSize().toSize() / ratio) : contentsRect().size(); + const QSize snapshotSize = contentsRect().size(); + const QString path = getSnapshotsDirPath(); - // Ensure the target directory exists - const QString path = QDir::homePath() + QDir::separator() + "Pictures"; - { - QDir dir(path); - if (!dir.exists()) { - dir.mkdir("."); - } - } // Compose snapshot file base name (a unique number gets appended to it upon saving) const QString baseName = QString("%1 %2") .arg(*liquidAppName) .arg(tr((fullPage) ? "full-page snapshot" : "snapshot")); - if (vector) { - QFile scriptFile(":/scripts/html2svg.js"); - scriptFile.open(QFile::ReadOnly | QFile::Text); - const QString js = QString(scriptFile.readAll()) - .arg(snapshotSize.width()) - .arg(snapshotSize.height()) - .arg(colorToRgba(page()->backgroundColor())) - .arg(fullPage); - - page()->runJavaScript(QString("(()=>{%1})();").arg(js), QWebEngineScript::ApplicationWorld, [path, baseName](const QVariant& res){ - qDebug().noquote() << res.toString(); - - // Save vector image to disk - { - QFile file(Liquid::getUniqueFilePath(path, baseName, "svg")); - if (file.open(QIODevice::ReadWrite)) { - QTextStream stream(&file); -#if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0) - stream << res.toString() << Qt::endl; -#else - stream << res.toString() << endl; -#endif - } - } - }); - } else if (fullPage) { + if (fullPage) { const QImage image = captureFullPage(); if (!image.isNull()) { @@ -1037,20 +1013,105 @@ void LiquidAppWindow::takeSnapshot(const bool fullPage) void LiquidAppWindow::renderSnapshot(QPainter* painter) { - if (snapshotFlashOverlay == Q_NULLPTR) { - snapshotFlashOverlay = new SnapshotFlashOverlay(this); - } - // Make sure that the flash from the previous snapshot (or chunk) doesn't end up in this one - snapshotFlashOverlay->hide(); + if (snapshotFlashOverlay != Q_NULLPTR) { + snapshotFlashOverlay->hide(); + } // Render contents of QWidget into QPainter render(painter); + flash(); +} + +void LiquidAppWindow::flash(void) +{ + if (snapshotFlashOverlay == Q_NULLPTR) { + snapshotFlashOverlay = new SnapshotFlashOverlay(this); + } + // Flash, the way cameras do snapshotFlashOverlay->flash(); } +void LiquidAppWindow::takeVectorSnapshot(void) +{ + if (isTakingSnapshot) { + return; + } + + QFile scriptFile(":/scripts/html2svg.js"); + if (!scriptFile.open(QFile::ReadOnly | QFile::Text)) { + qDebug().noquote() << "Unable to load html2svg.js"; + return; + } + const QString script = QString::fromUtf8(scriptFile.readAll()); + + // Ignore user input until done (see eventFilter()), Escape cancels + isTakingSnapshot = true; + snapshotCancelled = false; + QApplication::setOverrideCursor(Qt::WaitCursor); + + bool isDone = false; + + if (waitForDocumentToFinishLoading()) { + // The script walks the page asynchronously, and reports back by setting window.lqdHtml2Svg.done to true + page()->runJavaScript(script, QWebEngineScript::ApplicationWorld); + + QElapsedTimer timer; + timer.start(); + + while (!snapshotCancelled && timer.elapsed() < LQD_VECTOR_SNAPSHOT_TIMEOUT) { + if (runJavaScriptSync("window.lqdHtml2Svg ? window.lqdHtml2Svg.done === true : false").toBool()) { + isDone = true; + break; + } + + Liquid::sleep(50); + } + } + + QByteArray svg; + QString error; + + if (snapshotCancelled) { + qDebug().noquote() << "Vector snapshot cancelled"; + } else if (!isDone) { + qDebug().noquote() << "Timed out taking vector snapshot"; + } else { + const QVariantList outcome = runJavaScriptSync("[window.lqdHtml2Svg.result, window.lqdHtml2Svg.error]", LQD_VECTOR_SNAPSHOT_TIMEOUT).toList(); + + if (outcome.size() != 2) { + error = "No data received from the page"; + } else if (!outcome[1].isNull() && outcome[1].isValid()) { + error = outcome[1].toString(); + } else { + svg = SvgSnapshot::build(outcome[0].toString().toUtf8(), page()->backgroundColor(), zoomFactor(), &error); + } + + if (!error.isEmpty()) { + qDebug().noquote() << "Unable to take vector snapshot:" << error; + } + } + + // Free up the page's memory + page()->runJavaScript("delete window.lqdHtml2Svg;", QWebEngineScript::ApplicationWorld); + + if (!svg.isEmpty()) { + const QString baseName = QString("%1 %2").arg(*liquidAppName).arg(tr("vector snapshot")); + QSaveFile file(Liquid::getUniqueFilePath(getSnapshotsDirPath(), baseName, "svg")); + + if (file.open(QIODevice::WriteOnly) && file.write(svg) == svg.size() && file.commit()) { + flash(); + } else { + qDebug().noquote() << "Unable to save vector snapshot:" << file.errorString(); + } + } + + QApplication::restoreOverrideCursor(); + isTakingSnapshot = false; +} + QImage LiquidAppWindow::captureFullPage(void) { // The page gets captured one viewport-sized tile at a time while scrolling it from top to bottom, @@ -1067,13 +1128,16 @@ QImage LiquidAppWindow::captureFullPage(void) } // Lock the window down for the duration of the capture: no resizing, and no user input (see eventFilter()) - isCapturingFullPage = true; - fullPageCaptureCancelled = false; + isTakingSnapshot = true; + snapshotCancelled = false; const QSize origMinimumSize = minimumSize(); const QSize origMaximumSize = maximumSize(); setFixedSize(size()); QApplication::setOverrideCursor(Qt::WaitCursor); + // Don't start capturing until the entire page is there (if cancelled while waiting, the capture loop exits right away) + waitForDocumentToFinishLoading(); + // Hide scrollbars, otherwise they'd end up in every tile const bool hadScrollBarsShown = liquidAppWebSettings->testAttribute(QWebEngineSettings::ShowScrollBars); if (hadScrollBarsShown) { @@ -1102,7 +1166,7 @@ QImage LiquidAppWindow::captureFullPage(void) // (measuring the page's height upfront isn't reliable, pages may grow while being scrolled through) for (qreal y = 0; ; y += viewportHeight) { // The user may have hit Escape while the program was waiting for the page - if (fullPageCaptureCancelled) { + if (snapshotCancelled) { break; } @@ -1131,7 +1195,7 @@ QImage LiquidAppWindow::captureFullPage(void) waitForNextFrame(); - if (fullPageCaptureCancelled) { + if (snapshotCancelled) { break; } @@ -1164,7 +1228,7 @@ QImage LiquidAppWindow::captureFullPage(void) // Stitch the pieces together (unless cancelled, in which case nothing gets saved) QImage image; - if (fullPageCaptureCancelled) { + if (snapshotCancelled) { qDebug().noquote() << "Full-page snapshot cancelled"; pieces.clear(); if (snapshotFlashOverlay != Q_NULLPTR) { @@ -1175,7 +1239,7 @@ QImage LiquidAppWindow::captureFullPage(void) } if (image.isNull()) { - if (!fullPageCaptureCancelled) { + if (!snapshotCancelled) { qDebug().noquote() << "Unable to take full-page snapshot"; } } else { @@ -1203,7 +1267,7 @@ QImage LiquidAppWindow::captureFullPage(void) QApplication::restoreOverrideCursor(); setMinimumSize(origMinimumSize); setMaximumSize(origMaximumSize); - isCapturingFullPage = false; + isTakingSnapshot = false; return image; } @@ -1260,6 +1324,35 @@ bool LiquidAppWindow::isUserInputEvent(const QEvent::Type eventType) } } +bool LiquidAppWindow::waitForDocumentToFinishLoading(void) +{ + // Large pages (e.g. huge comment threads) arrive in pieces and get laid out as they do, + // taking a snapshot before the whole document is there would leave out everything that hasn't arrived yet + QElapsedTimer timer; + timer.start(); + bool isWaiting = false; + + while (!snapshotCancelled && timer.elapsed() < LQD_SNAPSHOT_LOAD_WAIT_TIMEOUT) { + // Scripts in ApplicationWorld run even when JavaScript is disabled for the web page + if (runJavaScriptSync("document.readyState").toString() != "loading") { + return true; + } + + if (!isWaiting) { + qDebug().noquote() << "Waiting for the page to finish loading before taking snapshot"; + isWaiting = true; + } + + Liquid::sleep(100); + } + + if (!snapshotCancelled) { + qDebug().noquote() << "The page is still loading, snapshot may be incomplete"; + } + + return !snapshotCancelled; +} + QVariant LiquidAppWindow::runJavaScriptSync(const QString& js, const int timeoutMs) { // Shared with the callback, which may get called after this function has already returned (upon timeout) @@ -1293,6 +1386,13 @@ QVariant LiquidAppWindow::runJavaScriptSync(const QString& js, const int timeout void LiquidAppWindow::waitForNextFrame(void) { + // Animation frame callbacks never run when JavaScript is disabled for the page (not even ones that come + // from an isolated world), so there's no way to know when the next frame is ready: wait a fixed amount of time + if (!liquidAppWebSettings->testAttribute(QWebEngineSettings::JavascriptEnabled)) { + Liquid::sleep(LQD_FRAME_WAIT_WITHOUT_JS); + return; + } + // Ask the page to signal once it has gone through two animation frames // (the first one is still being prepared at the moment of the request, the second one has the changes) runJavaScriptSync("window.lqdFrameReady = false;" diff --git a/src/svgsnapshot.cpp b/src/svgsnapshot.cpp new file mode 100644 index 0000000..104ae49 --- /dev/null +++ b/src/svgsnapshot.cpp @@ -0,0 +1,553 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "svgsnapshot.hpp" + +namespace { + +const QString SVG_NS = QStringLiteral("http://www.w3.org/2000/svg"); +const QString XLINK_NS = QStringLiteral("http://www.w3.org/1999/xlink"); + +const qreal MAX_COORDINATE = 10000000; // Anything beyond that is nonsense +const int MAX_TEXT_LENGTH = 100000; +const int MAX_FONT_FAMILY_LENGTH = 1000; + +enum ItemType { + RectItem, + BorderItem, + TextItem, + ImageItem, +}; + +struct Item { + ItemType type = RectItem; + int clip = -1; + qreal opacity = 1; + + // Rectangles, borders, and images + QRectF rect; + QVector radii; // Empty if no rounded corners + + // Fill color of rectangles and text, stroke color of borders + QColor color; + + // Borders + qreal strokeWidth = 0; + QString dash; + + // Text + qreal x = 0; + qreal y = 0; + qreal width = 0; + qreal fontSize = 0; + QString text; + QString fontFamily; + QString fontWeight; + QString fontStyle; + QString decoration; + + // Images + QByteArray imageData; // Validated PNG or JPEG data + QString imageMimeType; + QString preserveAspectRatio; + int roundedClip = -1; +}; + +// Formats a number using at most two decimal places, without trailing zeros +QString num(const qreal value) +{ + QString string = QString::number(value, 'f', 2); + + if (string.contains('.')) { + while (string.endsWith('0')) { + string.chop(1); + } + + if (string.endsWith('.')) { + string.chop(1); + } + } + + return (string == "-0") ? QStringLiteral("0") : string; +} + +bool readNumber(const QJsonValue& value, qreal* result) +{ + if (!value.isDouble()) { + return false; + } + + const qreal number = value.toDouble(); + + if (!std::isfinite(number) || std::fabs(number) > MAX_COORDINATE) { + return false; + } + + *result = number; + + return true; +} + +bool readRect(const QJsonValue& value, QRectF* result) +{ + const QJsonArray array = value.toArray(); + qreal x, y, width, height; + + if (!value.isArray() || array.size() != 4 + || !readNumber(array[0], &x) || !readNumber(array[1], &y) + || !readNumber(array[2], &width) || !readNumber(array[3], &height) + || width < 0 || height < 0) { + return false; + } + + *result = QRectF(x, y, width, height); + + return true; +} + +// Colors come as [red, green, blue, alpha], with alpha being in the range of 0 to 1 +bool readColor(const QJsonValue& value, QColor* result) +{ + const QJsonArray array = value.toArray(); + qreal components[4]; + + if (!value.isArray() || array.size() != 4) { + return false; + } + + for (int i = 0; i < 4; i++) { + if (!readNumber(array[i], &components[i])) { + return false; + } + } + + QColor color = QColor::fromRgb(qBound(0, qRound(components[0]), 255), + qBound(0, qRound(components[1]), 255), + qBound(0, qRound(components[2]), 255)); + color.setAlphaF(qBound(0.0, components[3], 1.0)); + + if (color.alpha() == 0) { + return false; + } + + *result = color; + + return true; +} + +// Corner radii come either as null, or as [top-left, top-right, bottom-right, bottom-left] +QVector readRadii(const QJsonValue& value) +{ + const QJsonArray array = value.toArray(); + QVector radii; + + if (!value.isArray() || array.size() != 4) { + return radii; + } + + bool hasAny = false; + + for (int i = 0; i < 4; i++) { + qreal radius; + + if (!readNumber(array[i], &radius) || radius < 0) { + return QVector(); + } + + radii.append(radius); + hasAny = hasAny || radius > 0; + } + + return (hasAny) ? radii : QVector(); +} + +// Strips characters that aren't allowed in XML (as well as line breaks, all text items are single-line) +QString sanitizeText(const QString& text, const int maxLength) +{ + QString result; + result.reserve(qMin(text.size(), maxLength)); + + for (int i = 0; i < text.size() && result.size() < maxLength; i++) { + const QChar character = text.at(i); + const uint code = character.unicode(); + + if (character.isHighSurrogate()) { + if (i + 1 < text.size() && text.at(i + 1).isLowSurrogate()) { + result += character; + result += text.at(++i); + } + + continue; // Lone surrogates get dropped + } + + if (character.isLowSurrogate() || code == 0xFFFE || code == 0xFFFF) { + continue; + } + + if (code == 0x9 || code == 0xA || code == 0xD) { + result += QLatin1Char(' '); + } else if (code >= 0x20) { + result += character; + } + } + + return result; +} + +// Returns the value if it fully matches the pattern, otherwise the fallback +QString allowOnly(const QString& value, const QRegularExpression& pattern, const QString& fallback) +{ + return (pattern.match(value).hasMatch()) ? value : fallback; +} + +// Accepts only base64-encoded PNG and JPEG data URLs whose contents actually decode as an image +bool readImage(const QJsonValue& value, QByteArray* data, QString* mimeType) +{ + static const QRegularExpression dataUrlPattern(QStringLiteral("^data:image/(png|jpeg);base64,([A-Za-z0-9+/]+={0,2})$")); + + const QRegularExpressionMatch match = dataUrlPattern.match(value.toString()); + + if (!match.hasMatch()) { + return false; + } + + const QString format = match.captured(1); + const QByteArray decoded = QByteArray::fromBase64(match.captured(2).toLatin1()); + QImage image; + + if (!image.loadFromData(decoded, (format == "png") ? "PNG" : "JPEG")) { + return false; + } + + *data = decoded; + *mimeType = "image/" + format; + + return true; +} + +bool parseItem(const QJsonObject& object, const int clipCount, Item* item) +{ + static const QRegularExpression fontWeightPattern(QStringLiteral("^(normal|bold|bolder|lighter|\\d{1,4})$")); + static const QRegularExpression fontStylePattern(QStringLiteral("^(normal|italic|oblique)$")); + static const QRegularExpression dashPattern(QStringLiteral("^(solid|dashed|dotted)$")); + static const QRegularExpression preserveAspectRatioPattern(QStringLiteral("^(none|xMidYMid meet|xMidYMid slice)$")); + + const QString type = object.value("type").toString(); + + item->clip = object.value("clip").toInt(-1); + if (item->clip < -1 || item->clip >= clipCount) { + item->clip = -1; + } + + if (!readNumber(object.value("opacity"), &item->opacity)) { + item->opacity = 1; + } + item->opacity = qBound(0.0, item->opacity, 1.0); + + if (item->opacity <= 0) { + return false; + } + + if (type == "rect") { + item->type = RectItem; + item->radii = readRadii(object.value("radii")); + + return readRect(object.value("rect"), &item->rect) + && readColor(object.value("fill"), &item->color); + } else if (type == "border") { + item->type = BorderItem; + item->radii = readRadii(object.value("radii")); + item->dash = allowOnly(object.value("dash").toString(), dashPattern, "solid"); + + return readRect(object.value("rect"), &item->rect) + && readColor(object.value("color"), &item->color) + && readNumber(object.value("width"), &item->strokeWidth) && item->strokeWidth > 0; + } else if (type == "text") { + item->type = TextItem; + item->text = sanitizeText(object.value("text").toString(), MAX_TEXT_LENGTH); + item->fontFamily = sanitizeText(object.value("fontFamily").toString(), MAX_FONT_FAMILY_LENGTH); + item->fontWeight = allowOnly(object.value("fontWeight").toString(), fontWeightPattern, "normal"); + item->fontStyle = allowOnly(object.value("fontStyle").toString(), fontStylePattern, "normal"); + + QStringList decorations; + for (const QString& decoration : object.value("decoration").toString().split(' ')) { + if ((decoration == "underline" || decoration == "line-through" || decoration == "overline") + && !decorations.contains(decoration)) { + decorations.append(decoration); + } + } + item->decoration = decorations.join(' '); + + return !item->text.trimmed().isEmpty() + && readNumber(object.value("x"), &item->x) + && readNumber(object.value("y"), &item->y) + && readNumber(object.value("width"), &item->width) && item->width >= 0 + && readNumber(object.value("fontSize"), &item->fontSize) && item->fontSize > 0 + && readColor(object.value("fill"), &item->color); + } else if (type == "image") { + item->type = ImageItem; + item->radii = readRadii(object.value("radii")); + item->preserveAspectRatio = allowOnly(object.value("preserveAspectRatio").toString(), preserveAspectRatioPattern, "none"); + + return readRect(object.value("rect"), &item->rect) + && !item->rect.isEmpty() + && readImage(object.value("href"), &item->imageData, &item->imageMimeType); + } + + return false; +} + +// Starts a (or a , for unevenly rounded corners) element and writes its geometry +void writeShapeStart(QXmlStreamWriter& writer, const QRectF& rect, const QVector& radii) +{ + const bool isUniform = radii.isEmpty() + || (qFuzzyCompare(radii[0], radii[1]) && qFuzzyCompare(radii[0], radii[2]) && qFuzzyCompare(radii[0], radii[3])); + + if (isUniform) { + writer.writeStartElement(SVG_NS, "rect"); + writer.writeAttribute("x", num(rect.x())); + writer.writeAttribute("y", num(rect.y())); + writer.writeAttribute("width", num(rect.width())); + writer.writeAttribute("height", num(rect.height())); + + if (!radii.isEmpty() && radii[0] > 0) { + writer.writeAttribute("rx", num(radii[0])); + writer.writeAttribute("ry", num(radii[0])); + } + } else { + const qreal x = rect.x(); + const qreal y = rect.y(); + const qreal w = rect.width(); + const qreal h = rect.height(); + const qreal tl = radii[0]; + const qreal tr = radii[1]; + const qreal br = radii[2]; + const qreal bl = radii[3]; + + const QString path = QString("M%1,%2 H%3 A%4,%4 0 0 1 %5,%6 V%7 A%8,%8 0 0 1 %9,%10 H%11 A%12,%12 0 0 1 %13,%14 V%15 A%16,%16 0 0 1 %1,%2 Z") + .arg(num(x + tl), num(y), num(x + w - tr), num(tr), num(x + w), num(y + tr), num(y + h - br), num(br), num(x + w - br)) + .arg(num(y + h), num(x + bl), num(bl), num(x), num(y + h - bl), num(y + tl), num(tl)); + + writer.writeStartElement(SVG_NS, "path"); + writer.writeAttribute("d", path); + } +} + +void writeColor(QXmlStreamWriter& writer, const QString& attribute, const QColor& color) +{ + writer.writeAttribute(attribute, color.name(QColor::HexRgb)); + + if (color.alpha() < 255) { + writer.writeAttribute(attribute + "-opacity", num(color.alphaF())); + } +} + +void writeCommonAttributes(QXmlStreamWriter& writer, const Item& item) +{ + if (item.clip >= 0) { + writer.writeAttribute("clip-path", QString("url(#c%1)").arg(item.clip)); + } + + if (item.opacity < 1) { + writer.writeAttribute("opacity", num(item.opacity)); + } +} + +void writeItem(QXmlStreamWriter& writer, const Item& item) +{ + switch (item.type) { + case RectItem: + writeShapeStart(writer, item.rect, item.radii); + writeColor(writer, "fill", item.color); + writeCommonAttributes(writer, item); + writer.writeEndElement(); + break; + + case BorderItem: + writeShapeStart(writer, item.rect, item.radii); + writer.writeAttribute("fill", "none"); + writeColor(writer, "stroke", item.color); + writer.writeAttribute("stroke-width", num(item.strokeWidth)); + if (item.dash == "dashed") { + writer.writeAttribute("stroke-dasharray", QString("%1 %1").arg(num(item.strokeWidth * 3))); + } else if (item.dash == "dotted") { + writer.writeAttribute("stroke-dasharray", QString("%1 %1").arg(num(item.strokeWidth))); + } + writeCommonAttributes(writer, item); + writer.writeEndElement(); + break; + + case TextItem: + writer.writeStartElement(SVG_NS, "text"); + writer.writeAttribute("x", num(item.x)); + writer.writeAttribute("y", num(item.y)); + writer.writeAttribute("font-family", item.fontFamily); + writer.writeAttribute("font-size", num(item.fontSize)); + if (item.fontWeight != "normal" && item.fontWeight != "400") { + writer.writeAttribute("font-weight", item.fontWeight); + } + if (item.fontStyle != "normal") { + writer.writeAttribute("font-style", item.fontStyle); + } + writeColor(writer, "fill", item.color); + if (item.width > 0) { + // Makes the text occupy exactly as much room as it did on the page, even if rendered using a different font + writer.writeAttribute("textLength", num(item.width)); + writer.writeAttribute("lengthAdjust", "spacingAndGlyphs"); + } + if (!item.decoration.isEmpty()) { + writer.writeAttribute("text-decoration", item.decoration); + } + writer.writeAttribute("xml:space", "preserve"); + writeCommonAttributes(writer, item); + writer.writeCharacters(item.text); + writer.writeEndElement(); + break; + + case ImageItem: + // Clipping by the parent group, rounded corners by the image itself + if (item.clip >= 0 || item.opacity < 1) { + writer.writeStartElement(SVG_NS, "g"); + writeCommonAttributes(writer, item); + } + + writer.writeStartElement(SVG_NS, "image"); + writer.writeAttribute("x", num(item.rect.x())); + writer.writeAttribute("y", num(item.rect.y())); + writer.writeAttribute("width", num(item.rect.width())); + writer.writeAttribute("height", num(item.rect.height())); + writer.writeAttribute("preserveAspectRatio", item.preserveAspectRatio); + if (item.roundedClip >= 0) { + writer.writeAttribute("clip-path", QString("url(#r%1)").arg(item.roundedClip)); + } + writer.writeAttribute(XLINK_NS, "href", "data:" + item.imageMimeType + ";base64," + QString::fromLatin1(item.imageData.toBase64())); + writer.writeEndElement(); + + if (item.clip >= 0 || item.opacity < 1) { + writer.writeEndElement(); + } + break; + } +} + +} // namespace + +QByteArray SvgSnapshot::build(const QByteArray& json, const QColor& backdropColor, const qreal zoomFactor, QString* error) +{ + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(json, &parseError); + const QJsonObject root = document.object(); + + qreal width = 0; + qreal height = 0; + + if (parseError.error != QJsonParseError::NoError || !document.isObject() + || root.value("version").toInt() != 1 + || !readNumber(root.value("width"), &width) || width <= 0 + || !readNumber(root.value("height"), &height) || height <= 0) { + if (error) { + *error = "Invalid page data"; + } + + return QByteArray(); + } + + // Clips (invalid ones become empty, to keep indices intact) + QVector clips; + for (const QJsonValue& value : root.value("clips").toArray()) { + QRectF rect; + clips.append((readRect(value, &rect)) ? rect : QRectF()); + } + + // Items + QVector items; + int roundedClipCount = 0; + for (const QJsonValue& value : root.value("items").toArray()) { + Item item; + + if (parseItem(value.toObject(), clips.size(), &item)) { + if (item.type == ImageItem && !item.radii.isEmpty()) { + item.roundedClip = roundedClipCount++; + } + + items.append(item); + } + } + + const qreal zoom = (std::isfinite(zoomFactor) && zoomFactor > 0) ? zoomFactor : 1; + + QByteArray output; + QXmlStreamWriter writer(&output); + writer.setAutoFormatting(true); + writer.setAutoFormattingIndent(0); + + writer.writeStartDocument(); + writer.writeDefaultNamespace(SVG_NS); + writer.writeNamespace(XLINK_NS, "xlink"); + writer.writeStartElement(SVG_NS, "svg"); + writer.writeAttribute("version", "1.1"); + writer.writeAttribute("width", num(width * zoom)); + writer.writeAttribute("height", num(height * zoom)); + writer.writeAttribute("viewBox", QString("0 0 %1 %2").arg(num(width), num(height))); + + // Definitions + if (!clips.isEmpty() || roundedClipCount > 0) { + writer.writeStartElement(SVG_NS, "defs"); + + for (int i = 0; i < clips.size(); i++) { + writer.writeStartElement(SVG_NS, "clipPath"); + writer.writeAttribute("id", QString("c%1").arg(i)); + writeShapeStart(writer, clips[i], QVector()); + writer.writeEndElement(); // rect + writer.writeEndElement(); // clipPath + } + + for (const Item& item : items) { + if (item.roundedClip >= 0) { + writer.writeStartElement(SVG_NS, "clipPath"); + writer.writeAttribute("id", QString("r%1").arg(item.roundedClip)); + writeShapeStart(writer, item.rect, item.radii); + writer.writeEndElement(); // rect or path + writer.writeEndElement(); // clipPath + } + } + + writer.writeEndElement(); // defs + } + + // The color Liquid paints behind the web page (may be partially or fully transparent) + if (backdropColor.isValid() && backdropColor.alpha() > 0) { + writer.writeStartElement(SVG_NS, "rect"); + writer.writeAttribute("width", num(width)); + writer.writeAttribute("height", num(height)); + writeColor(writer, "fill", backdropColor); + writer.writeEndElement(); + } + + for (const Item& item : items) { + writeItem(writer, item); + } + + writer.writeEndElement(); // svg + writer.writeEndDocument(); + + if (writer.hasError()) { + if (error) { + *error = "Unable to write SVG"; + } + + return QByteArray(); + } + + return output; +}