Skip to content

Crop PNG/SVG exports to tree bounds with uniform padding#12

Merged
psaegert merged 4 commits into
masterfrom
copilot/crop-exported-image-content
Apr 22, 2026
Merged

Crop PNG/SVG exports to tree bounds with uniform padding#12
psaegert merged 4 commits into
masterfrom
copilot/crop-exported-image-content

Conversation

Copilot AI commented Apr 22, 2026

Copy link
Copy Markdown

Exports were generated from the full canvas container, so PNG/SVG outputs included large empty margins even when the tree occupied a small region. This PR scopes export dimensions to the tree’s actual geometry plus small padding, without changing on-screen rendering.

  • Shared export bounds computation

    • Added computeTreeBounds(root) in canvas.js to walk the tree and compute {minX, minY, maxX, maxY} from node centers and getNodeRadius().
    • Added EXPORT_PADDING = 16 and a small helper to compute padded export size.
  • PNG export now renders a cropped viewport

    • buildPngBlob(scale) now:
      • sizes the offscreen canvas to (tree bounds + padding) * exportScale
      • translates the context so existing absolute node coordinates draw into the cropped canvas
    • Result: downloaded/copied PNG contains only the tree with consistent margin.
  • SVG export now emits cropped dimensions/viewBox

    • treeToSvg(root, cssText) now:
      • sets width/height to padded tree bounds
      • sets viewBox origin/size to the same padded bounds
    • Inner node/edge coordinates remain unchanged; cropping is handled by SVG viewport metadata.
  • Safety/robustness

    • Bounds traversal tolerates missing children arrays (node.children || []) to avoid export-time iteration errors.
var bounds = computeTreeBounds(currentRoot)
var size = getPaddedBoundsSize(bounds)
offscreen.width = Math.ceil(size.width * exportScale)
offscreen.height = Math.ceil(size.height * exportScale)
offscreenContext.scale(exportScale, exportScale)
offscreenContext.translate(-bounds.minX + EXPORT_PADDING, -bounds.minY + EXPORT_PADDING)
drawTree(currentRoot, offscreenContext)

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • accounts.google.com
    • Triggering command: /proc/self/exe /proc/self/exe --type=utility --utility-sub-type=network.mojom.NetworkService --lang=en-US --service-sandbox-type=network --no-sandbox --use-angle=swiftshader-webgl --crashpad-handler-pid=4538 --enable-crash-reporter=, --noerrdialogs --user-data-dir=/tmp/org.chromium.Chromium.scoped_dir.FfP36e --change-stack-guard-on-fork=enable --shared-files=network_parent_dirs_pipe:100,v8_context_snapshot_data:101 --field-trial-handle=3,i,709168681065812503,313572993504309999,262144 --disable-features=PaintHolding --variations-seed-version --pseudonymization-salt-handle=7,i,5211398886395417032,12759392833237834306,4 --trace-process-track-uuid=3190708989122997041 (dns block)
    • Triggering command: /usr/bin/chromium-browser chromium-browser --headless --disable-gpu --no-sandbox --screenshot=/tmp/expression-tree-ui.png --window-size=1440,900 file:///home/REDACTED/work/expression-tree-gen/expression-tree-gen/index.html _TOKEN"; }; f (dns block)
  • cdn.jsdelivr.net
    • Triggering command: /proc/self/exe /proc/self/exe --type=utility --utility-sub-type=network.mojom.NetworkService --lang=en-US --service-sandbox-type=network --no-sandbox --use-angle=swiftshader-webgl --crashpad-handler-pid=4538 --enable-crash-reporter=, --noerrdialogs --user-data-dir=/tmp/org.chromium.Chromium.scoped_dir.FfP36e --change-stack-guard-on-fork=enable --shared-files=network_parent_dirs_pipe:100,v8_context_snapshot_data:101 --field-trial-handle=3,i,709168681065812503,313572993504309999,262144 --disable-features=PaintHolding --variations-seed-version --pseudonymization-salt-handle=7,i,5211398886395417032,12759392833237834306,4 --trace-process-track-uuid=3190708989122997041 (dns block)
    • Triggering command: /usr/bin/chromium-browser chromium-browser --headless --disable-gpu --no-sandbox --screenshot=/tmp/expression-tree-ui.png --window-size=1440,900 file:///home/REDACTED/work/expression-tree-gen/expression-tree-gen/index.html _TOKEN"; }; f (dns block)
  • clients2.google.com
    • Triggering command: /proc/self/exe /proc/self/exe --type=utility --utility-sub-type=network.mojom.NetworkService --lang=en-US --service-sandbox-type=network --no-sandbox --use-angle=swiftshader-webgl --crashpad-handler-pid=4538 --enable-crash-reporter=, --noerrdialogs --user-data-dir=/tmp/org.chromium.Chromium.scoped_dir.FfP36e --change-stack-guard-on-fork=enable --shared-files=network_parent_dirs_pipe:100,v8_context_snapshot_data:101 --field-trial-handle=3,i,709168681065812503,313572993504309999,262144 --disable-features=PaintHolding --variations-seed-version --pseudonymization-salt-handle=7,i,5211398886395417032,12759392833237834306,4 --trace-process-track-uuid=3190708989122997041 (dns block)
    • Triggering command: /usr/bin/chromium-browser chromium-browser --headless --disable-gpu --no-sandbox --screenshot=/tmp/expression-tree-ui.png --window-size=1440,900 file:///home/REDACTED/work/expression-tree-gen/expression-tree-gen/index.html _TOKEN"; }; f (dns block)
  • redirector.gvt1.com
    • Triggering command: /proc/self/exe /proc/self/exe --type=utility --utility-sub-type=network.mojom.NetworkService --lang=en-US --service-sandbox-type=network --no-sandbox --use-angle=swiftshader-webgl --crashpad-handler-pid=4538 --enable-crash-reporter=, --noerrdialogs --user-data-dir=/tmp/org.chromium.Chromium.scoped_dir.FfP36e --change-stack-guard-on-fork=enable --shared-files=network_parent_dirs_pipe:100,v8_context_snapshot_data:101 --field-trial-handle=3,i,709168681065812503,313572993504309999,262144 --disable-features=PaintHolding --variations-seed-version --pseudonymization-salt-handle=7,i,5211398886395417032,12759392833237834306,4 --trace-process-track-uuid=3190708989122997041 (dns block)
    • Triggering command: /usr/bin/chromium-browser chromium-browser --headless --disable-gpu --no-sandbox --screenshot=/tmp/expression-tree-ui.png --window-size=1440,900 file:///home/REDACTED/work/expression-tree-gen/expression-tree-gen/index.html _TOKEN"; }; f (dns block)
  • www.google.com
    • Triggering command: /proc/self/exe /proc/self/exe --type=utility --utility-sub-type=network.mojom.NetworkService --lang=en-US --service-sandbox-type=network --no-sandbox --use-angle=swiftshader-webgl --crashpad-handler-pid=4538 --enable-crash-reporter=, --noerrdialogs --user-data-dir=/tmp/org.chromium.Chromium.scoped_dir.FfP36e --change-stack-guard-on-fork=enable --shared-files=network_parent_dirs_pipe:100,v8_context_snapshot_data:101 --field-trial-handle=3,i,709168681065812503,313572993504309999,262144 --disable-features=PaintHolding --variations-seed-version --pseudonymization-salt-handle=7,i,5211398886395417032,12759392833237834306,4 --trace-process-track-uuid=3190708989122997041 (dns block)
    • Triggering command: /usr/bin/chromium-browser chromium-browser --headless --disable-gpu --no-sandbox --screenshot=/tmp/expression-tree-ui.png --window-size=1440,900 file:///home/REDACTED/work/expression-tree-gen/expression-tree-gen/index.html _TOKEN"; }; f (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

Original prompt

Problem

When the user exports (downloads or copies) the expression tree as PNG or SVG, the resulting image has a large amount of empty space/margin around the actual tree. This is because the export code rasterizes/serializes the entire canvas area (the full #canvas-container size, cssWidth x cssHeight), even though the tree itself only occupies a small region of it.

The goal is to crop the exported image to the tree's actual content, with only a small padding around it (no unnecessary whitespace).

Where the problem is

Both exporters in canvas.js use the full canvas dimensions:

PNG export (buildPngBlob)

var offscreen = document.createElement('canvas')
offscreen.width = cssWidth * exportScale
offscreen.height = cssHeight * exportScale
var offscreenContext = offscreen.getContext('2d')
offscreenContext.scale(exportScale, exportScale)
drawTree(currentRoot, offscreenContext)

It uses cssWidth / cssHeight (full container) and draws the tree at its absolute layout coordinates, leaving huge margins.

SVG export (treeToSvg)

return '<?xml version="1.0" encoding="UTF-8"?>' +
    '<svg xmlns="..." width="' + cssWidth + '" height="' + cssHeight + '" viewBox="0 0 ' + cssWidth + ' ' + cssHeight + '">' +
    ...

Same issue: the SVG is sized to the full container and the viewBox spans everything.

Node coordinates are assigned by setCoordinates in tree.js and nodes have a radius from getNodeRadius().

Required changes

Implement a bounding-box crop for both PNG and SVG exports.

1. Add a helper to compute the tree's bounding box

Walk the tree from currentRoot, and for every node compute the extents using its (x, y) and the node radius from getNodeRadius(). Also consider edges, but since edges only connect node centers, the node circles already bound them.

function computeTreeBounds(root) {
    var radius = getNodeRadius()
    var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
    function walk(node) {
        if (!node) return
        if (node.x - radius < minX) minX = node.x - radius
        if (node.y - radius < minY) minY = node.y - radius
        if (node.x + radius > maxX) maxX = node.x + radius
        if (node.y + radius > maxY) maxY = node.y + radius
        for (var i = 0; i < node.children.length; i++) walk(node.children[i])
    }
    walk(root)
    if (!isFinite(minX)) return null
    return { minX: minX, minY: minY, maxX: maxX, maxY: maxY }
}

Add a small padding constant (e.g. var EXPORT_PADDING = 16) so exports don't touch the edges of the image.

2. Crop the PNG export

In buildPngBlob, after warmPromise resolves, compute the bounds and size the offscreen canvas to the tree bounds + padding. Translate the context so the tree is drawn at the correct offset:

var bounds = computeTreeBounds(currentRoot)
if (!bounds) { resolve(null); return }
var pad = EXPORT_PADDING
var width = (bounds.maxX - bounds.minX) + 2 * pad
var height = (bounds.maxY - bounds.minY) + 2 * pad

var offscreen = document.createElement('canvas')
offscreen.width = Math.ceil(width * exportScale)
offscreen.height = Math.ceil(height * exportScale)
var offscreenContext = offscreen.getContext('2d')
offscreenContext.scale(exportScale, exportScale)
offscreenContext.translate(-bounds.minX + pad, -bounds.minY + pad)
drawTree(currentRoot, offscreenContext)

3. Crop the SVG export

In treeToSvg, compute the bounds and set the SVG's width, height, and viewBox to the cropped region (with padding). Keep the inner coordinates unchanged — just adjust the viewBox so viewers crop accordingly:

var bounds = computeTreeBoundsForSvg() // or pass radius in
var pad = EXPORT_PADDING
var vbX = bounds.minX - pad
var vbY = bounds.minY - pad
var vbW = (bounds.maxX - bounds.minX) + 2 * pad
var vbH = (bounds.maxY - bounds.minY) + 2 * pad

return '<?xml version="1.0" encoding="UTF-8"?>' +
    '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xhtml="http://www.w3.org/1999/xhtml" ' +
    'width="' + vbW + '" height="' + vbH + '" ' +
    'viewBox="' + vbX + ' ' + vbY + ' ' + vbW + ' ' + vbH + '">' +
    styleBlock +
    '<g stroke-width="2" fill="none">' + lines.join('') + '</g>' +
    nodes.join('') +
    '</svg>'

(You can reuse the same computeTreeBounds helper in both places — put it at a scope accessible to both buildPngBlob and treeToSvg.)

4. Edge safety

Double-check that the padding is enough to not clip node strokes (stroke-width of 2 means half-stroke = 1px outside the radius). A padding of 16 is plenty.

5. No regression for the on-screen canvas

Do NOT change the live on-screen rendering in renderRoot / drawTree on the main canvas. Only the export paths (buildPngBlob, treeToSvg) should be cropped. The main canvas should continue to fill the container as before...

This pull request was created from Copilot chat.

Copilot AI changed the title [WIP] Fix image export to remove excess whitespace Crop PNG/SVG exports to tree bounds with uniform padding Apr 22, 2026
Copilot AI requested a review from psaegert April 22, 2026 10:14
@psaegert psaegert marked this pull request as ready for review April 22, 2026 10:14
Copilot AI review requested due to automatic review settings April 22, 2026 10:14

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adjusts PNG/SVG export generation to crop output to the tree’s actual geometry (plus uniform padding) instead of exporting the full canvas container, reducing empty margins in exported images.

Changes:

  • Added shared tree-bounds computation and padded-size helper for exports.
  • Updated PNG export to render into a cropped offscreen canvas with a translated context.
  • Updated SVG export to emit cropped width/height and viewBox based on padded tree bounds.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread canvas.js
Comment on lines +146 to +149
minX = Math.min(minX, node.x - radius)
minY = Math.min(minY, node.y - radius)
maxX = Math.max(maxX, node.x + radius)
maxY = Math.max(maxY, node.y + radius)

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

computeTreeBounds assumes node.x/node.y are valid numbers. Because the code does arithmetic directly, null/undefined coordinates can be coerced (e.g., null - radius becomes -radius) and produce a seemingly-valid bounds box, which can lead to incorrectly cropped exports if coordinates haven’t been assigned yet. Consider validating node.x/node.y with Number.isFinite(...) (and skipping/returning null when invalid) or ensuring coordinates are assigned before computing bounds.

Suggested change
minX = Math.min(minX, node.x - radius)
minY = Math.min(minY, node.y - radius)
maxX = Math.max(maxX, node.x + radius)
maxY = Math.max(maxY, node.y + radius)
var x = node.x
var y = node.y
if (Number.isFinite(x) && Number.isFinite(y)) {
minX = Math.min(minX, x - radius)
minY = Math.min(minY, y - radius)
maxX = Math.max(maxX, x + radius)
maxY = Math.max(maxY, y + radius)
}

Copilot uses AI. Check for mistakes.
Comment thread canvas.js
Comment on lines 176 to +180
var warmPromise = (typeof warmLatexCache === 'function')
? warmLatexCache(currentRoot, colors.text)
: Promise.resolve()
return warmPromise.then(function () {
var bounds = computeTreeBounds(currentRoot)

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildPngBlob reads currentRoot multiple times across an async boundary (warmLatexCache(...).then(...)). If currentRoot changes while the cache is warming (e.g., user edits expression before the promise resolves), bounds/drawing may target a different tree than was warmed, leading to missing label rasters or inconsistent exports. Capture currentRoot into a local root at the start of buildPngBlob and use that consistently for warming, bounds, and drawTree.

Copilot uses AI. Check for mistakes.
@psaegert psaegert merged commit 3b3ba5e into master Apr 22, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants