Crop PNG/SVG exports to tree bounds with uniform padding#12
Conversation
Agent-Logs-Url: https://github.com/psaegert/expression-tree-gen/sessions/c9e2989e-e474-4379-be4a-3c23f5510fce Co-authored-by: psaegert <36567814+psaegert@users.noreply.github.com>
Agent-Logs-Url: https://github.com/psaegert/expression-tree-gen/sessions/c9e2989e-e474-4379-be4a-3c23f5510fce Co-authored-by: psaegert <36567814+psaegert@users.noreply.github.com>
Agent-Logs-Url: https://github.com/psaegert/expression-tree-gen/sessions/c9e2989e-e474-4379-be4a-3c23f5510fce Co-authored-by: psaegert <36567814+psaegert@users.noreply.github.com>
There was a problem hiding this comment.
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/heightandviewBoxbased on padded tree bounds.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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) |
There was a problem hiding this comment.
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.
| 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) | |
| } |
| var warmPromise = (typeof warmLatexCache === 'function') | ||
| ? warmLatexCache(currentRoot, colors.text) | ||
| : Promise.resolve() | ||
| return warmPromise.then(function () { | ||
| var bounds = computeTreeBounds(currentRoot) |
There was a problem hiding this comment.
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.
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
computeTreeBounds(root)incanvas.jsto walk the tree and compute{minX, minY, maxX, maxY}from node centers andgetNodeRadius().EXPORT_PADDING = 16and a small helper to compute padded export size.PNG export now renders a cropped viewport
buildPngBlob(scale)now:(tree bounds + padding) * exportScaleSVG export now emits cropped dimensions/viewBox
treeToSvg(root, cssText)now:width/heightto padded tree boundsviewBoxorigin/size to the same padded boundsSafety/robustness
childrenarrays (node.children || []) to avoid export-time iteration errors.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/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)/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/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)/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/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)/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/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)/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/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)/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-containersize,cssWidthxcssHeight), 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.jsuse the full canvas dimensions:PNG export (
buildPngBlob)It uses
cssWidth/cssHeight(full container) and draws the tree at its absolute layout coordinates, leaving huge margins.SVG export (
treeToSvg)Same issue: the SVG is sized to the full container and the viewBox spans everything.
Node coordinates are assigned by
setCoordinatesintree.jsand nodes have a radius fromgetNodeRadius().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 fromgetNodeRadius(). Also consider edges, but since edges only connect node centers, the node circles already bound them.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, afterwarmPromiseresolves, 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:3. Crop the SVG export
In
treeToSvg, compute the bounds and set the SVG'swidth,height, andviewBoxto the cropped region (with padding). Keep the inner coordinates unchanged — just adjust the viewBox so viewers crop accordingly:(You can reuse the same
computeTreeBoundshelper in both places — put it at a scope accessible to bothbuildPngBlobandtreeToSvg.)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/drawTreeon 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.