Skip to content

Offline image #1774

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Mar 18, 2025
Merged

Offline image #1774

merged 12 commits into from
Mar 18, 2025

Conversation

jpolitz
Copy link
Member

@jpolitz jpolitz commented Feb 28, 2025

Progress on using node-canvas (https://www.npmjs.com/package/canvas) to make images work offline and in CLI mode (for, e.g., an autograder).

This boils down to:

  • Finding uses of document.createElement("canvas") and polyfilling with nodeCanvas.createCanvas
  • Finding places where canvas.style is used and working around it conditionally (offline canvases do not have styles; they aren't full DOM nodes)
  • Making sure we only depend on how pyret-lang loads rather than CPO as well

This is surprisingly straightforward and many tests pass already, including ones that render to canvases via BaseImage.prototype.equals (e.g. cropping a rectangle to a square and checking equality).

Other TODOs include:

  • Seeing if we can do image-url
  • Making sure image-file is properly hooked up to fs and the right combo of browser/CLI libraries
  • Probably adding some save-image function to actually view the images

This is surprisingly close; full output of test-images.arr at the end.

I'll do another commit to show the diff between CPO images and the changes made
here to make it work, but it's gratifyingly small as a diff.

file:///Users/joe/src/pyret-lang/tests/pyret/tests/test-images.arr:16:0-22:3: Overlay equality (2/2)

file:///Users/joe/src/pyret-lang/tests/pyret/tests/test-images.arr:24:0-51:3: Composing lists of images (9/10)

  line 29, column 2: ok
  line 31, column 2: ok
  line 33, column 2: ok
  line 35, column 2: ok
  line 37, column 2: ok
  line 39, column 2: ok
  line 43, column 2: failed because:
    Got unexpected exception  TypeError: Cannot set properties of undefined (setting 'visibility')
TypeError: Cannot set properties of undefined (setting 'visibility')
    at BaseImage.difference (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:170289:49)
    at Object.imageDifference (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:169942:19)
    at PFunction.app (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:183240:35)
    at PFunction._c408306ace046f838bbd554fa60d21cbef4ee2a8e6cc32b08191354d5e134c88__1 [as app] (eval at <anonymous> (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:163823:32), <anonymous>:611:31)
    at PFunction._87a6d29cadb57d99ecea353caea55134445d06163c8bbe11473760ca1805bb72__1442 [as app] (eval at <anonymous> (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:163823:32), <anonymous>:15500:27)
    at thisRuntime.run.sync (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:162257:24)
    at ActivationRecord.fun (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:161847:18)
    at iter (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:161965:28)
    at Object.run (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:162064:7)
    at Pause.resumer (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:162256:21)
    at iter (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:161991:41)
    at Object.run (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:162064:7)
    at Pause.resumer (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:162256:21)
    at Immediate.iter [as _onImmediate] (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:161991:41)
    at process.processImmediate (node:internal/timers:476:21)
  line 45, column 2: ok
  line 47, column 2: ok
  line 49, column 2: ok

file:///Users/joe/src/pyret-lang/tests/pyret/tests/test-images.arr:53:0-136:3: Polygons (76/76)

file:///Users/joe/src/pyret-lang/tests/pyret/tests/test-images.arr:138:0-146:3: color-lists (5/5)

file:///Users/joe/src/pyret-lang/tests/pyret/tests/test-images.arr:148:0-165:3: trimming (4/11)

  line 150, column 2: ok
  line 151, column 2: failed because:
    Got unexpected exception  TypeError: Cannot read properties of undefined (reading 'createElement')
TypeError: Cannot read properties of undefined (reading 'createElement')
    at trimCanvas (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:170672:39)
    at Object.trimImageToCanvas (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:170624:14)
    at PFunction.app (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:184184:28)
    at PFunction._c408306ace046f838bbd554fa60d21cbef4ee2a8e6cc32b08191354d5e134c88__274 [as app] (eval at <anonymous> (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:163823:32), <anonymous>:7587:25)
    at thisRuntime.run.sync (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:162257:24)
    at ActivationRecord.fun (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:161847:18)
    at iter (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:161965:28)
    at Object.run (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:162064:7)
    at Pause.resumer (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:162256:21)
    at Immediate.iter [as _onImmediate] (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:161991:41)
    at process.processImmediate (node:internal/timers:476:21)
  line 152, column 2: failed because:
    Got unexpected exception  TypeError: Cannot read properties of undefined (reading 'createElement')
TypeError: Cannot read properties of undefined (reading 'createElement')
    at trimCanvas (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:170672:39)
    at Object.trimImageToCanvas (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:170624:14)
    at PFunction.app (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:184184:28)
    at PFunction._c408306ace046f838bbd554fa60d21cbef4ee2a8e6cc32b08191354d5e134c88__277 [as app] (eval at <anonymous> (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:163823:32), <anonymous>:7677:25)
    at thisRuntime.run.sync (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:162257:24)
    at ActivationRecord.fun (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:161847:18)
    at iter (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:161965:28)
    at Object.run (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:162064:7)
    at Pause.resumer (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:162256:21)
    at Immediate.iter [as _onImmediate] (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:161991:41)
    at process.processImmediate (node:internal/timers:476:21)
  line 153, column 2: ok
  line 154, column 2: failed because:
    Got unexpected exception  TypeError: Cannot read properties of undefined (reading 'createElement')
TypeError: Cannot read properties of undefined (reading 'createElement')
    at trimCanvas (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:170672:39)
    at Object.trimImageToCanvas (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:170624:14)
    at PFunction.app (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:184184:28)
    at PFunction._c408306ace046f838bbd554fa60d21cbef4ee2a8e6cc32b08191354d5e134c88__284 [as app] (eval at <anonymous> (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:163823:32), <anonymous>:7865:25)
    at thisRuntime.run.sync (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:162257:24)
    at ActivationRecord.fun (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:161847:18)
    at iter (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:161965:28)
    at Object.run (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:162064:7)
    at Pause.resumer (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:162256:21)
    at Immediate.iter [as _onImmediate] (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:161991:41)
    at process.processImmediate (node:internal/timers:476:21)
  line 157, column 2: ok
  line 158, column 2: failed because:
    Got unexpected exception  TypeError: Cannot read properties of undefined (reading 'createElement')
TypeError: Cannot read properties of undefined (reading 'createElement')
    at trimCanvas (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:170663:42)
    at Object.trimImageToCanvas (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:170624:14)
    at PFunction.app (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:184184:28)
    at PFunction._c408306ace046f838bbd554fa60d21cbef4ee2a8e6cc32b08191354d5e134c88__291 [as app] (eval at <anonymous> (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:163823:32), <anonymous>:8061:25)
    at thisRuntime.run.sync (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:162257:24)
    at ActivationRecord.fun (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:161847:18)
    at iter (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:161965:28)
    at Object.run (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:162064:7)
    at Pause.resumer (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:162256:21)
    at Immediate.iter [as _onImmediate] (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:161991:41)
    at process.processImmediate (node:internal/timers:476:21)
  line 159, column 2: failed because:
    Got unexpected exception  TypeError: Cannot read properties of undefined (reading 'createElement')
TypeError: Cannot read properties of undefined (reading 'createElement')
    at trimCanvas (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:170663:42)
    at Object.trimImageToCanvas (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:170624:14)
    at PFunction.app (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:184184:28)
    at PFunction._c408306ace046f838bbd554fa60d21cbef4ee2a8e6cc32b08191354d5e134c88__294 [as app] (eval at <anonymous> (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:163823:32), <anonymous>:8151:25)
    at thisRuntime.run.sync (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:162257:24)
    at ActivationRecord.fun (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:161847:18)
    at iter (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:161965:28)
    at Object.run (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:162064:7)
    at Pause.resumer (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:162256:21)
    at Immediate.iter [as _onImmediate] (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:161991:41)
    at process.processImmediate (node:internal/timers:476:21)
  line 160, column 2: ok
  line 161, column 2: failed because:
    Got unexpected exception  TypeError: Cannot read properties of undefined (reading 'createElement')
TypeError: Cannot read properties of undefined (reading 'createElement')
    at trimCanvas (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:170663:42)
    at Object.trimImageToCanvas (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:170624:14)
    at PFunction.app (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:184184:28)
    at PFunction._c408306ace046f838bbd554fa60d21cbef4ee2a8e6cc32b08191354d5e134c88__301 [as app] (eval at <anonymous> (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:163823:32), <anonymous>:8339:25)
    at thisRuntime.run.sync (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:162257:24)
    at ActivationRecord.fun (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:161847:18)
    at iter (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:161965:28)
    at Object.run (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:162064:7)
    at Pause.resumer (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:162256:21)
    at Immediate.iter [as _onImmediate] (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:161991:41)
    at process.processImmediate (node:internal/timers:476:21)
  line 163, column 2: failed because:
    Got unexpected exception  TypeError: Cannot read properties of undefined (reading 'createElement')
TypeError: Cannot read properties of undefined (reading 'createElement')
    at trimCanvas (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:170663:42)
    at Object.trimImageToCanvas (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:170624:14)
    at PFunction.app (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:184184:28)
    at PFunction._c408306ace046f838bbd554fa60d21cbef4ee2a8e6cc32b08191354d5e134c88__305 [as app] (eval at <anonymous> (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:163823:32), <anonymous>:8437:25)
    at thisRuntime.run.sync (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:162257:24)
    at ActivationRecord.fun (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:161847:18)
    at iter (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:161965:28)
    at Object.run (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:162064:7)
    at Pause.resumer (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:162256:21)
    at Immediate.iter [as _onImmediate] (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:161991:41)
    at process.processImmediate (node:internal/timers:476:21)

file:///Users/joe/src/pyret-lang/tests/pyret/tests/test-images.arr:167:0-189:3: properties (4/4)

  Check block ended in the following error (not all tests may have run):

  ReferenceError: document is not defined
ReferenceError: document is not defined
    at getTextDimensions (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:171231:18)
    at new TextImage (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:171282:21)
    at Object.makeTextImage (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:171690:14)
    at PFunction.app (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:183279:32)
    at ActivationRecord._c408306ace046f838bbd554fa60d21cbef4ee2a8e6cc32b08191354d5e134c88__310 [as fun] (eval at <anonymous> (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:163823:32), <anonymous>:8941:24)
    at Immediate.iter [as _onImmediate] (/Users/joe/src/pyret-lang/tests/pyret/tests/test-images.jarr:161965:28)
    at process.processImmediate (node:internal/timers:476:21)

file:///Users/joe/src/pyret-lang/tests/pyret/tests/test-images.arr:191:0-222:3: predicates (23/23)

Passed: 123; Failed: 8; Ended in Error: 1; Total: 131
This commit will be immediately reverted; it has exactly the changes made to
the files we started from in code.pyret.org
This reverts commit cfe58ec.

That commit was made solely to show some meaningful diff information (but
backwards, because the edits had already been made)
@jpolitz jpolitz requested a review from blerner February 28, 2025 23:53
@jpolitz
Copy link
Member Author

jpolitz commented Feb 28, 2025

(This was meant to be a draft, tests still fail)

@jpolitz jpolitz requested a review from dbp February 28, 2025 23:54
@jpolitz jpolitz marked this pull request as draft February 28, 2025 23:54
@jpolitz
Copy link
Member Author

jpolitz commented Mar 2, 2025

The remaining issue is in text images, specifically with getTextDimensions in image-lib.js (https://github.com/brownplt/code.pyret.org/blob/horizon/src/web/js/trove/image-lib.js#L1411).

It has a comment next to it that there should be
https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/measureText,
but that was buggy at the time. The comment is at least 7 years old though, and
the referenced SO post has a comment on the top answer that MDN claims broad support for this:

https://stackoverflow.com/questions/1134586/how-can-you-find-the-height-of-text-on-an-html-canvas/9847841#9847841

That's next to look into.

@dbp
Copy link
Collaborator

dbp commented Mar 3, 2025

The remaining issue is in text images, specifically with getTextDimensions in image-lib.js (https://github.com/brownplt/code.pyret.org/blob/horizon/src/web/js/trove/image-lib.js#L1411).

It has a comment next to it that there should be https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/measureText, but that was buggy at the time. The comment is at least 7 years old though, and the referenced SO post has a comment on the top answer that MDN claims broad support for this:

https://stackoverflow.com/questions/1134586/how-can-you-find-the-height-of-text-on-an-html-canvas/9847841#9847841

That's next to look into.

Even if the browsers are still unreliable (though it seems like maybe not now, as you said), it seems like node-canvas supports this, so could use it offline?

https://github.com/Automattic/node-canvas/wiki/Compatibility-Status#drawing-text

Either way, this is awesome! :)

@jpolitz
Copy link
Member Author

jpolitz commented Mar 3, 2025

Reporting back....

Annoyingly, the unsupported features (textBoundingBoxAscent and textBoundingBoxDescent) on node-canvas are the ones that exactly match the current behavior on CPO. Unfortunately for this PR, I think the current behavior is good behavior as I've revisited it. Need to think a little.

image

(The textBoundingBox fields are present here because it's in a browser, absent when run from the CLI with node-canvas)

import color as C
include from C: indigo end
indigo-text = text-font("Goodbye", 48, indigo, "Helvetica", "modern", "normal", "normal", false)
image-height(indigo-text)
image-baseline(indigo-text)

On CPO, this reports a height of 55 and a baseline of 44 (11 of the pixels are below the text baseline for the trailing y). This actually leaves padding around the words for this example when rendered; presumably because there are taller characters if accents, etc are included. This is the difference between the bounding box of the actual text vs the bounding box of what the font could need.

The supported fields of node-canvas, actualBoundingBoxAscent and actualBoundingBoxDescent change the height as the string changes, which is an annoying property at best. For example, if you change the string from Goodbye to Goodbe without the y which trails below, the height changes from 46 to 36.

I've also been getting some issues with the height calculated from actualBoundingBox not being quite big enough to render the text (maybe messing in .render would help, but still).

image

Conclusions:

  • We could make the browser and CLI behave the same as one another, but differently from current CPO (by using actualBoundingBox instead of effectively fontBoundingBox)
  • We could allow the browser and CLI to differ, keeping the browser behaving as-is (and maybe simplifying the implementation). Size calculations and pinhole overlays of text images might be different in an offline autograder than in-browser
  • What CPO/the image library does is calculate the height needed for the font, which is arguably pretty good behavior.

@jpolitz
Copy link
Member Author

jpolitz commented Mar 3, 2025

This issue has a nice picture, duplicated here:

Automattic/node-canvas#2395

image

@jpolitz
Copy link
Member Author

jpolitz commented Mar 3, 2025

As a point of comparison, 2htdp/image appears to return the height-by-font.

> (image-height (text/font "Goodbye" 48 "indigo" "Helvetica" "modern" "normal" "normal" true))
48
> (image-height (text/font "Goodbe" 48 "indigo" "Helvetica" "modern" "normal" "normal" true))
48

@jpolitz
Copy link
Member Author

jpolitz commented Mar 3, 2025

Another note that using these calculations in image-lib avoids cutting off the top or bottom in a few small tests:

      this.height      = Math.ceil(metrics.actualBoundingBoxAscent) + Math.ceil(metrics.actualBoundingBoxDescent);
      this.alphaBaseline = Math.ceil(metrics.actualBoundingBoxAscent);

With Math.ceil:

image

Without:

image

jpolitz added 2 commits March 3, 2025 13:52
Here we're using the `actualBoundingBox` to calculate text sizes.

We update the tests a little to match this new behavior (since before they were
more based on font sizes).

More discussion here:

#1774
This all of a sudden mattered b/c the node-canvas library relies on a newer
GLIBC
@jpolitz
Copy link
Member Author

jpolitz commented Mar 3, 2025

OK, I thought about this some more. I think I'm pretty happy with the current pushed version for this PR (pending passing on CPO and some other details).

One of the things I said earlier isn't true – the heights in both 2htdp/image and in CPO-of-today can change depending on the contents of the string. The simplest thing is to use other alphabets (an analogous example shows a difference in the htdp image library)

# in CPO of today
hello1 = text("Hello", 48, "black")
hello2 = text("你好", 48, "black")

image-height(hello1) # 55
image-height(hello2) # 59

So as a general property, it's not the case that the height is fixed w.r.t. the font + size, so it's less weird to have things change height based on string contents.

Also, the baseline calculations are solid, and used well as the pinhole points. So, for example, monospace Good and good will overlay the ood pixel-perfectly (the screenshot below is on the new version using actualBoundingBox). This is the property that matters most for things looking right: if text changes in an animation or between otherwise-similar renderings, we don't want the text to feel like it “moved”.

image

The only cases of backwards-incompatibility this would introduce is in programs that care specifically about the pixel-height of text (which is already not quite consistent across browsers, OSes, and font availability). The position within existing overlays, etc, would look “good enough”.

jpolitz added 4 commits March 4, 2025 10:56
- Buffer in Node has a nice toString('base64') method (unlike in browser)
- FileReader doesn't exist natively in node
- So use .toString('base64') offline and FileReader in browser

Also, this polyfills Image (which is nicely provided by node-canvas)
- Always saves to .png format
- Uses toBuffer on canvases and relies on fs.writeFile to handle the buffer
This is a little annoying and polyglot: toBuffer is super convenient and
provided by node-canvas, but we have to go through Blobification in the
browser.
@jpolitz
Copy link
Member Author

jpolitz commented Mar 4, 2025

I think this is close to ready to merge. It has the corresponding CPO changes in brownplt/code.pyret.org#585, which ends up having some related bits, because (via the VScode extension) the CPO code has to provide writeFile.

@jpolitz jpolitz marked this pull request as ready for review March 11, 2025 19:58
@jpolitz jpolitz merged commit e9ee9ba into horizon Mar 18, 2025
2 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.

2 participants