diff --git a/WIP.Screenshots.md b/WIP.Screenshots.md index 31151387b..fbb879fef 100644 --- a/WIP.Screenshots.md +++ b/WIP.Screenshots.md @@ -421,8 +421,11 @@ whose project does not refer to the VB package. `tbFormDesigner_TurnGridIndicatorsOff` (what the toolbar's grid button toggles; settled by the form's style changing), because the scaled dots differ in every capture even with the ground left out. The change is the form's (its tab shows it changed, so the clip is formStateShot's, - without the tab row), and `formsReset` discards it: the shots after it keep their dots. Its - SVG is 4 % off the PNG (the scaled text), so the page shows the PNG. + without the tab row), and `formsReset` discards it: the shots after it keep their dots. The + designer scales the form with an inline `zoom: 50%`, under which the snapshot gives the boxes + zoomed and the computed lengths not (font-size 13px in a control 55 px wide); the converter + scales them by each element's effective zoom (`applyZoom`, `snapshot-svg.mjs`), and the SVG is + 0% strongly off its PNG (it was 1.7 %, the text drawn at twice its size). - **The Toolbox is scrolled with the mouse wheel**: a capture never holds more than the real window's height, so a taller page does not show more of it. Show Control Names is set from the panel's `...` menu and put back afterwards (saved nowhere). The search of Toolbox_Search is @@ -1106,6 +1109,21 @@ left, an atomic inline on the line, and a block scrolled sideways. `text-overflo only of a picture with a line its block clips (the miss `text-overflow of a line its block clips`): 18 of the 480 bundles of 2026-11-01, some of which may clip with no ellipsis. +**A CSS zoom scales the computed lengths** (`applyZoom` in `snapshot-svg.mjs`, 2026-11-02, after +Designer_Zoom50's text came out at twice its size). Chromium's snapshot gives the layout and text +boxes under a `zoom` as drawn, but the computed styles as getComputedStyle does, before the zoom, +and each element's `zoom` as its own factor (1 below the zoomed element; a text node reports its +element's). The converter multiplies the element's own factors down the tree and scales by the +product every px length it reads (`ZOOMED_LENGTHS`: font size, line height, borders, radii, +outline, shadows, padding, background size and position, transform origin, filter, clip path) +and a transform matrix's translation, and its offset, client and scroll rects, which are before +the zoom too (a scroller's scrollbar is its box less its borders and client width); a px inside a +`url()` is left alone. Not scaled: a scrollbar's part styles, which the page reads itself, and the +zoom of an element with no box (`display: contents`). `zoom` is in `MISSABLE_STYLES`: replay asks for a retake only +of a picture whose bundle has an element with an inline zoom other than 1 (the miss `zoom of an +element its inline style zooms`): Designer_Zoom, Designer_Zoom50 and Webpage of the 532 bundles of +2026-11-02, the first and last drawn as before. + **A background image is sized by its bytes** (`naturalSize`), not by the type its URL names: the IDE calls a JPEG and a BMP `image/png`. The About dialog's EXCALIBUR logo is such a JPEG, drawn with `background-size: contain`; read as a PNG, its JFIF header gave 4,718,592 by about 4.3 diff --git a/docs/IDE/Forms/Images/Designer_Zoom50.svg b/docs/IDE/Forms/Images/Designer_Zoom50.svg new file mode 100644 index 000000000..47f60d3b1 --- /dev/null +++ b/docs/IDE/Forms/Images/Designer_Zoom50.svg @@ -0,0 +1 @@ +File+ menu380 x 270 diff --git a/scripts/svgshot/bundle.mjs b/scripts/svgshot/bundle.mjs index 7a066e2d3..5d74607d0 100644 --- a/scripts/svgshot/bundle.mjs +++ b/scripts/svgshot/bundle.mjs @@ -184,6 +184,8 @@ export function drawBundle(bundle) { // Nor, before accent-color was read, the colour of a checked checkbox or radio button. for (const doc of measured.keys()) if (accentUnknown(doc)) misses.add("accent-color of a checked checkbox or radio button"); + // Nor, before zoom was read, the size of what an element its inline style zooms holds. + for (const doc of measured.keys()) if (doc.zoomUnknown) misses.add("zoom of an element its inline style zooms"); const mine = measured.get(decoded); if (!mine) throw new Error("the bundle has no measurements of the page"); diff --git a/scripts/svgshot/capture.mjs b/scripts/svgshot/capture.mjs index 806755a12..f2694fdc3 100644 --- a/scripts/svgshot/capture.mjs +++ b/scripts/svgshot/capture.mjs @@ -104,13 +104,14 @@ export const SNAPSHOT_STYLES = [ "position", ...["top", "right", "bottom", "left"].map((s) => `padding-${s}`), "-webkit-text-fill-color", + "zoom", ]; // The styles a bundle taken before they were read can do without: the converter // asks for them only where they matter, and a bundle without them says so as a -// miss (text-overflow, accent-color: bundle.mjs drawBundle), so that only the pictures -// that need one are retaken. -export const MISSABLE_STYLES = new Set(["text-overflow", "accent-color"]); +// miss (text-overflow, accent-color, zoom: bundle.mjs drawBundle), so that only the +// pictures that need one are retaken. +export const MISSABLE_STYLES = new Set(["text-overflow", "accent-color", "zoom"]); // What a scrollbar part's box is drawn from: its styles, and its size along the bar. const PART_STYLES = [ diff --git a/scripts/svgshot/snapshot-svg.mjs b/scripts/svgshot/snapshot-svg.mjs index e85d99219..20e22f16e 100644 --- a/scripts/svgshot/snapshot-svg.mjs +++ b/scripts/svgshot/snapshot-svg.mjs @@ -281,6 +281,7 @@ function decodeDocument(snapshot, di, styleNames, grid, seen) { if (l.style && l.text === undefined && !l.node.box) l.node.box = l; } for (const n of nodes) n.style = n.box?.style ?? n.layouts.find((l) => l.style)?.style ?? null; + const zoomUnknown = applyZoom(nodes, layouts, styleNames.includes("zoom")); const decoded = { id: di, url: str(doc.documentURL), @@ -291,6 +292,8 @@ function decodeDocument(snapshot, di, styleNames, grid, seen) { owner: null, nodes, layouts, + // A bundle taken before zoom was read, with an element whose inline style zooms it (zoomUnknown). + zoomUnknown, }; // The document of a frame, when the snapshot holds it: a frame of another process is no document here. for (const [i, k] of content) { @@ -301,6 +304,94 @@ function decodeDocument(snapshot, di, styleNames, grid, seen) { return decoded; } +// The computed styles that are lengths in px, which Chromium reports as they are before an +// element's CSS zoom (applyZoom). +const ZOOMED_LENGTHS = [ + "background-size", + "background-position", + ...["top", "right", "bottom", "left"].map((s) => `border-${s}-width`), + ...["top-left", "top-right", "bottom-right", "bottom-left"].map((c) => `border-${c}-radius`), + "outline-width", + "outline-offset", + "box-shadow", + "font-size", + "line-height", + "tab-size", + "text-decoration-thickness", + "text-underline-offset", + "transform-origin", + "filter", + "clip-path", + ...["top", "right", "bottom", "left"].map((s) => `padding-${s}`), +]; + +// A computed zoom as a factor: a number, a percentage, or `normal`. +function zoomFactor(v) { + const m = /^\s*(-?[\d.]+(?:e[+-]?\d+)?)(%?)\s*$/i.exec(v ?? ""); + const z = m ? Number(m[1]) / (m[2] ? 100 : 1) : 1; + return z > 0 ? z : 1; +} + +// The zoom an element's inline style sets, or null. +function inlineZoom(n) { + const m = /(?:^|;)\s*zoom\s*:\s*([^;!]+)/i.exec(n.attrs?.style ?? ""); + return m ? zoomFactor(m[1]) : null; +} + +const scaledPx = (v, z) => + v.replace(/url\([^)]*\)|(-?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)px/gi, (all, n) => + n === undefined ? all : `${Math.round(Number(n) * z * 1e4) / 1e4}px`, + ); + +// A computed transform's translation scaled by `z`: matrix(a, b, c, d, e, f) and matrix3d's +// m41 to m43. +function scaledMatrix(v, z) { + const m = /^(matrix(?:3d)?)\(([^)]*)\)$/.exec(v ?? ""); + if (!m) return v; + const k = m[2].split(",").map(Number); + const moved = m[1] === "matrix" ? [4, 5] : [12, 13, 14]; + for (const i of moved) k[i] = Math.round(k[i] * z * 1e4) / 1e4; + return `${m[1]}(${k.join(", ")})`; +} + +/** + * Puts the computed styles under a CSS zoom into the space of the boxes. The snapshot gives + * an element's layout and text boxes as drawn, but its computed styles as getComputedStyle + * does, before the zoom: under the form designer's `zoom: 50%` (Designer_Zoom50) a control + * 55 px wide said font-size 13px, line-height 24.1px and background-size 110px 24px. Each + * length is scaled by the element's effective zoom, its own `zoom` times its ancestors', and + * so are its offset, client and scroll rects, which said 110 by 24 for that control. The + * style of a text layout object is its element's, own zoom and all, so a text node takes its + * parent's zoom and adds none. Returns true when the styles hold no zoom (a bundle taken + * before it was read) and an element's inline style sets one other than 1, which the + * styles cannot then be drawn from; nothing is scaled then. + */ +function applyZoom(nodes, layouts, known) { + if (!known) return nodes.some((n) => (inlineZoom(n) ?? 1) !== 1); + const zoom = new Map(); + const zoomOf = (n) => { + if (!n) return 1; + if (zoom.has(n)) return zoom.get(n); + const own = n.type === 1 ? zoomFactor(n.style?.zoom) : 1; + const z = zoomOf(n.parent) * own; + zoom.set(n, z); + return z; + }; + const scaled = (r, z) => r && Object.fromEntries(Object.entries(r).map(([k, v]) => [k, v * z])); + for (const l of layouts) { + const z = zoomOf(l.node); + if (z === 1) continue; + // offsetRects, clientRects and scrollRects are offsetWidth, clientWidth and scrollWidth: before the zoom too + l.offset = scaled(l.offset, z); + l.client = scaled(l.client, z); + l.scroll = scaled(l.scroll, z); + if (!l.style) continue; + for (const p of ZOOMED_LENGTHS) if (l.style[p]) l.style[p] = scaledPx(l.style[p], z); + if (l.style.transform) l.style.transform = scaledMatrix(l.style.transform, z); + } + return false; +} + /** Whether `n`, or an element above it, is an . */ function insideSvg(n) { for (; n; n = n.parent) if (n.name === "svg") return true; diff --git a/test/svgshot.test.mjs b/test/svgshot.test.mjs index f8f987cd8..b451f3e63 100644 --- a/test/svgshot.test.mjs +++ b/test/svgshot.test.mjs @@ -1385,6 +1385,85 @@ describe("bundles", () => { assert.equal(accentUnknown(control({}, true, { ...shown, appearance: "none" })), false); }); + test("puts the styles under a CSS zoom into the space of the boxes, or says it cannot", () => { + // What Chromium's snapshot gives: each element's own zoom, and its lengths before the zoom. + const names = ["display", "font-size", "border-top-width", "background-image", "transform", "transform-origin"]; + const snapshotOf = (styleNames) => { + const strings = []; + const s = (v) => { + const i = strings.indexOf(v); + return i >= 0 ? i : strings.push(v) - 1; + }; + const style = (zoom) => + [ + "block", + "13px", + "2px", + 'url("data:x/2px")', + "matrix(1, 0, 0, 1, 10, 6)", + "800px 26px", + ...(styleNames.includes("zoom") ? [zoom] : []), + ].map(s); + const nodeNames = ["#document", "HTML", "DIV", "DIV", "#text", "SPAN", "#text"]; + return { + strings, + documents: [ + { + documentURL: s("http://localhost:1/"), + frameId: s("PAGE"), + nodes: { + parentIndex: [-1, 0, 1, 2, 3, 3, 5], + nodeType: [9, 1, 1, 1, 3, 1, 3], + nodeName: nodeNames.map(s), + nodeValue: nodeNames.map((n) => (n === "#text" ? s("x") : -1)), + attributes: nodeNames.map((_n, i) => (i === 2 ? [s("style"), s("zoom: 50%;")] : [])), + }, + // A text layout object has its element's style, own zoom and all. + layout: { + nodeIndex: [1, 2, 3, 4, 5, 6], + styles: ["1", "0.5", "1", "1", "2", "2"].map(style), + bounds: [[0, 0, 400, 100], ...Array(5).fill([0, 0, 50, 10])], + text: [-1, -1, -1, s("x"), -1, s("x")], + offsetRects: [[], [0, 0, 100, 20], [], [], [], []], + clientRects: [[], [0, 0, 96, 16], [], [], [], []], + scrollRects: [[], [8, 4, 300, 16], [], [], [], []], + }, + textBoxes: { layoutIndex: [], bounds: [], start: [], length: [] }, + }, + ], + }; + }; + const doc = decodeSnapshot(snapshotOf([...names, "zoom"]), [...names, "zoom"]); + const [html, zoomed, inner, text, span, spanText] = doc.layouts.map((l) => l.style); + assert.equal(doc.zoomUnknown, false); + assert.equal(html["font-size"], "13px"); + for (const st of [zoomed, inner, text]) { + assert.equal(st["font-size"], "6.5px"); + assert.equal(st["border-top-width"], "1px"); + assert.equal(st.transform, "matrix(1, 0, 0, 1, 5, 3)"); + assert.equal(st["transform-origin"], "400px 13px"); + // a px inside a url() is not a length + assert.equal(st["background-image"], 'url("data:x/2px")'); + } + // offsetWidth, clientWidth and scrollWidth are before the zoom too. + const box = doc.layouts[1]; + assert.deepEqual( + [box.offset, box.client, box.scroll], + [ + { w: 50, h: 10 }, + { w: 48, h: 8 }, + { x: 4, y: 2, w: 150, h: 8 }, + ], + ); + // zoom: 2 inside zoom: 50% draws at 1. + assert.equal(span["font-size"], "13px"); + assert.equal(spanText["font-size"], "13px"); + // A bundle taken before zoom was read: nothing scaled, and the inline zoom says so. + const old = decodeSnapshot(snapshotOf(names), names); + assert.equal(old.zoomUnknown, true); + assert.equal(old.layouts[2].style["font-size"], "13px"); + }); + test("draws a text field left with the browser's border and background as Chromium's native one", () => { const own = { appearance: "auto",