Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions WIP.Screenshots.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/IDE/Forms/Images/Designer_Zoom50.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions scripts/svgshot/bundle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
7 changes: 4 additions & 3 deletions scripts/svgshot/capture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
91 changes: 91 additions & 0 deletions scripts/svgshot/snapshot-svg.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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) {
Expand All @@ -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 <svg>. */
function insideSvg(n) {
for (; n; n = n.parent) if (n.name === "svg") return true;
Expand Down
79 changes: 79 additions & 0 deletions test/svgshot.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading