From 2837bae12f05a9a1d3156f5776cfe52249e071a3 Mon Sep 17 00:00:00 2001 From: Michael Daines <1383+mdaines@users.noreply.github.com> Date: Wed, 28 Aug 2024 13:26:06 -0400 Subject: [PATCH] Add renderFormats method --- packages/viz/CHANGELOG.md | 9 ++ packages/viz/src/module/viz.c | 59 +++++++------ packages/viz/src/viz.mjs | 20 ++++- packages/viz/src/wrapper.mjs | 41 +++++++-- packages/viz/test/manual/instance-reuse.mjs | 1 + .../viz/test/manual/performance-multiple.mjs | 32 +++++++ .../viz/test/manual/performance-object.mjs | 19 +---- .../viz/test/manual/performance-timing.mjs | 26 ++---- packages/viz/test/manual/utils.mjs | 17 ++++ packages/viz/test/render-formats.test.mjs | 84 +++++++++++++++++++ packages/viz/test/types/render-formats.ts | 45 ++++++++++ packages/viz/test/types/viz.ts | 13 ++- packages/viz/types/index.d.ts | 9 ++ packages/website/src/api/index.html | 46 +++++++++- 14 files changed, 344 insertions(+), 77 deletions(-) create mode 100644 packages/viz/test/manual/performance-multiple.mjs create mode 100644 packages/viz/test/render-formats.test.mjs create mode 100644 packages/viz/test/types/render-formats.ts diff --git a/packages/viz/CHANGELOG.md b/packages/viz/CHANGELOG.md index 0336049d..08247d4f 100644 --- a/packages/viz/CHANGELOG.md +++ b/packages/viz/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +* Add renderFormats() method. + + This method accepts an array of formats to render. Using this method avoids redundant parsing and layout when rendering the same graph in multiple formats, similar to specifying multiple formats when using the Graphviz command-line. + + The return value is similar to the render() method, but the "output" value is an object keyed by format. + + const result = viz.renderFormats("digraph { a -> b [href=\"https://example.com\"] }", ["svg", "cmapx"], { engine: "neato" }); + result.output // => { "svg": ..., "cmapx": ... } + ## 3.9.0 * Update Graphviz to 12.1.1. diff --git a/packages/viz/src/module/viz.c b/packages/viz/src/module/viz.c index 563d89c0..efe7b86c 100644 --- a/packages/viz/src/module/viz.c +++ b/packages/viz/src/module/viz.c @@ -158,48 +158,45 @@ void viz_free_graph(Agraph_t *g) { } EMSCRIPTEN_KEEPALIVE -char *viz_render_graph(Agraph_t *graph, const char *format, const char *engine) { - GVC_t *context = NULL; - char *data = NULL; - unsigned int length = 0; - int layout_error = 0; - int render_error = 0; +GVC_t *viz_create_context() { + return gvContextPlugins(lt_preloaded_symbols, 0); +} - // Initialize context +EMSCRIPTEN_KEEPALIVE +void viz_free_context(GVC_t *context) { + gvFinalize(context); + gvFreeContext(context); +} - context = gvContextPlugins(lt_preloaded_symbols, 0); +EMSCRIPTEN_KEEPALIVE +int viz_layout(GVC_t *context, Agraph_t *graph, const char *engine) { + return gvLayout(context, graph, engine); +} - // Reset errors +EMSCRIPTEN_KEEPALIVE +void viz_free_layout(GVC_t *context, Agraph_t *graph) { + gvFreeLayout(context, graph); +} +EMSCRIPTEN_KEEPALIVE +void viz_reset_errors() { agseterrf(viz_errorf); agseterr(AGWARN); agreseterrors(); +} - // Layout - - layout_error = gvLayout(context, graph, engine); - - // Render (if layout was successful) - - if (!layout_error) { - render_error = gvRenderData(context, graph, format, &data, &length); - - if (render_error) { - gvFreeRenderData(data); - data = NULL; - } - } +EMSCRIPTEN_KEEPALIVE +char *viz_render(GVC_t *context, Agraph_t *graph, const char *format) { + char *data = NULL; + unsigned int length = 0; + int render_error = 0; - // Free the layout and context + render_error = gvRenderData(context, graph, format, &data, &length); - if (graph) { - gvFreeLayout(context, graph); + if (render_error) { + gvFreeRenderData(data); + data = NULL; } - gvFinalize(context); - gvFreeContext(context); - - // Return the result (if successful, the rendered graph; otherwise, null) - return data; } diff --git a/packages/viz/src/viz.mjs b/packages/viz/src/viz.mjs index 5e612442..9e996787 100644 --- a/packages/viz/src/viz.mjs +++ b/packages/viz/src/viz.mjs @@ -17,8 +17,26 @@ class Viz { return this.wrapper.getPluginList("layout"); } + renderFormats(input, formats, options = {}) { + return this.wrapper.renderInput(input, formats, { engine: "dot", ...options }); + } + render(input, options = {}) { - return this.wrapper.renderInput(input, { format: "dot", engine: "dot", ...options }); + let format; + + if (options.format === void 0) { + format = "dot"; + } else { + format = options.format; + } + + let result = this.wrapper.renderInput(input, [format], { engine: "dot", ...options }); + + if (result.status === "success") { + result.output = result.output[format]; + } + + return result; } renderString(src, options = {}) { diff --git a/packages/viz/src/wrapper.mjs b/packages/viz/src/wrapper.mjs index 58e57b4f..2e2e47cc 100644 --- a/packages/viz/src/wrapper.mjs +++ b/packages/viz/src/wrapper.mjs @@ -32,8 +32,8 @@ class Wrapper { return list; } - renderInput(input, options) { - let graphPointer, resultPointer, imageFilePaths; + renderInput(input, formats, options) { + let graphPointer, contextPointer, resultPointer, imageFilePaths; try { this.module["agerrMessages"] = []; @@ -62,9 +62,13 @@ class Wrapper { this.module.ccall("viz_set_y_invert", "number", ["number"], [options.yInvert ? 1 : 0]); this.module.ccall("viz_set_reduce", "number", ["number"], [options.reduce ? 1 : 0]); - resultPointer = this.module.ccall("viz_render_graph", "number", ["number", "string", "string"], [graphPointer, options.format, options.engine]); + contextPointer = this.module.ccall("viz_create_context"); - if (resultPointer === 0) { + this.module.ccall("viz_reset_errors"); + + let layoutError = this.module.ccall("viz_layout", "number", ["number", "number", "string"], [contextPointer, graphPointer, options.engine]); + + if (layoutError !== 0) { return { status: "failure", output: undefined, @@ -72,9 +76,28 @@ class Wrapper { }; } + let output = {}; + + for (let format of formats) { + resultPointer = this.module.ccall("viz_render", "number", ["number", "number", "string"], [contextPointer, graphPointer, format]); + + if (resultPointer === 0) { + return { + status: "failure", + output: undefined, + errors: this.#parseErrorMessages() + }; + } else { + output[format] = this.module.UTF8ToString(resultPointer); + + this.module.ccall("free", "number", ["number"], [resultPointer]); + resultPointer = 0; + } + } + return { status: "success", - output: this.module.UTF8ToString(resultPointer), + output: output, errors: this.#parseErrorMessages() }; } catch (error) { @@ -88,10 +111,18 @@ class Wrapper { throw error; } } finally { + if (contextPointer && graphPointer) { + this.module.ccall("viz_free_layout", "number", ["number"], [contextPointer, graphPointer]); + } + if (graphPointer) { this.module.ccall("viz_free_graph", "number", ["number"], [graphPointer]); } + if (contextPointer) { + this.module.ccall("viz_free_context", "number", ["number"], [contextPointer]); + } + if (resultPointer) { this.module.ccall("free", "number", ["number"], [resultPointer]); } diff --git a/packages/viz/test/manual/instance-reuse.mjs b/packages/viz/test/manual/instance-reuse.mjs index 7787f8f9..a0cbfd61 100644 --- a/packages/viz/test/manual/instance-reuse.mjs +++ b/packages/viz/test/manual/instance-reuse.mjs @@ -31,6 +31,7 @@ const tests = [ { label: "string", fn: viz => viz.render(dotStringify(makeObject())) }, { label: "string with labels", fn: viz => viz.render(dotStringify(makeObjectWithLabels())) }, { label: "string with HTML labels", fn: viz => viz.render(dotStringify(makeObjectWithHTMLLabels())) }, + { label: "string with multiple formats", fn: viz => viz.renderFormats(dotStringify(makeObject()), ["svg", "cmapx"]) }, { label: "object", fn: viz => viz.render(makeObject()) }, { label: "object with labels", fn: viz => viz.render(makeObjectWithLabels()) }, { label: "object with HTML labels", fn: viz => viz.render(makeObjectWithHTMLLabels()) }, diff --git a/packages/viz/test/manual/performance-multiple.mjs b/packages/viz/test/manual/performance-multiple.mjs new file mode 100644 index 00000000..85333dd9 --- /dev/null +++ b/packages/viz/test/manual/performance-multiple.mjs @@ -0,0 +1,32 @@ +import { instance } from "../../src/standalone.mjs"; +import { measure, randomGraph, dotStringify } from "./utils.mjs"; + +const tests = [ + { nodeCount: 100, randomEdgeCount: 10 }, + { nodeCount: 1000, randomEdgeCount: 50 }, + { nodeCount: 1000, randomEdgeCount: 500 }, + { nodeCount: 1000, randomEdgeCount: 1000 } +]; + +tests.forEach(test => { + test.input = dotStringify(randomGraph(test.nodeCount, test.randomEdgeCount)); +}); + +const timeLimit = 5000; + +for (const { input, nodeCount, randomEdgeCount } of tests) { + const viz = await instance(); + const result = measure(() => { + viz.render(input, { format: "svg" }); + viz.render(input, { format: "cmapx" }); + }, timeLimit); + console.log(`render, ${nodeCount} nodes, ${randomEdgeCount} edges: ${result}`); +} + +for (const { input, nodeCount, randomEdgeCount } of tests) { + const viz = await instance(); + const result = measure(() => { + viz.renderFormats(input, ["svg", "cmapx"]); + }, timeLimit); + console.log(`renderFormats, ${nodeCount} nodes, ${randomEdgeCount} edges: ${result}`); +} diff --git a/packages/viz/test/manual/performance-object.mjs b/packages/viz/test/manual/performance-object.mjs index 7c832f01..07b2a66a 100644 --- a/packages/viz/test/manual/performance-object.mjs +++ b/packages/viz/test/manual/performance-object.mjs @@ -1,22 +1,5 @@ import { instance } from "../../src/standalone.mjs"; -import { randomGraph, dotStringify } from "./utils.mjs"; - -function measure(operation, timeLimit) { - let callCount = 0; - - const startTime = performance.now(); - - while (performance.now() - startTime < timeLimit) { - operation(); - callCount++; - } - - const stopTime = performance.now(); - const duration = (stopTime - startTime) / 1000; - const speed = callCount / duration; - - return `${callCount} in ${duration.toFixed(2)} s, ${speed.toFixed(2)} calls/s` -} +import { measure, randomGraph, dotStringify } from "./utils.mjs"; const tests = [ { nodeCount: 100, randomEdgeCount: 10 }, diff --git a/packages/viz/test/manual/performance-timing.mjs b/packages/viz/test/manual/performance-timing.mjs index 04b4ca10..db834c7c 100644 --- a/packages/viz/test/manual/performance-timing.mjs +++ b/packages/viz/test/manual/performance-timing.mjs @@ -1,5 +1,5 @@ import { instance } from "../../src/standalone.mjs"; -import { randomGraph, dotStringify } from "./utils.mjs"; +import { measure, randomGraph, dotStringify } from "./utils.mjs"; const tests = [ { nodeCount: 100, randomEdgeCount: 0 }, @@ -13,24 +13,14 @@ const tests = [ { nodeCount: 100, randomEdgeCount: 300 } ]; +tests.forEach(test => { + test.input = dotStringify(randomGraph(test.nodeCount, test.randomEdgeCount)); +}); + const timeLimit = 5000; -for (const { nodeCount, randomEdgeCount } of tests) { +for (const { input, nodeCount, randomEdgeCount } of tests) { const viz = await instance(); - const src = dotStringify(randomGraph(nodeCount, randomEdgeCount)); - - let callCount = 0; - - const startTime = performance.now(); - - while (performance.now() - startTime < timeLimit) { - viz.render(src); - callCount++; - } - - const stopTime = performance.now(); - const duration = (stopTime - startTime) / 1000; - const speed = callCount / duration; - - console.log(`${nodeCount} nodes, ${randomEdgeCount} edges: ${callCount} in ${duration.toFixed(2)} s, ${speed.toFixed(2)} calls/s`); + const result = measure(() => viz.render(input), timeLimit); + console.log(`${nodeCount} nodes, ${randomEdgeCount} edges: ${result}`); } diff --git a/packages/viz/test/manual/utils.mjs b/packages/viz/test/manual/utils.mjs index ccc84d86..9c4633f0 100644 --- a/packages/viz/test/manual/utils.mjs +++ b/packages/viz/test/manual/utils.mjs @@ -1,3 +1,20 @@ +export function measure(operation, timeLimit) { + let callCount = 0; + + const startTime = performance.now(); + + while (performance.now() - startTime < timeLimit) { + operation(); + callCount++; + } + + const stopTime = performance.now(); + const duration = (stopTime - startTime) / 1000; + const speed = callCount / duration; + + return `${callCount} in ${duration.toFixed(2)} s, ${speed.toFixed(2)} calls/s` +} + const skipQuotePattern = /^([A-Za-z_][A-Za-z_0-9]*|-?(\.[0-9]+|[0-9]+(\.[0-9]+)?))$/; function quote(value) { diff --git a/packages/viz/test/render-formats.test.mjs b/packages/viz/test/render-formats.test.mjs new file mode 100644 index 00000000..c939fae8 --- /dev/null +++ b/packages/viz/test/render-formats.test.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import * as VizPackage from "../src/standalone.mjs"; + +describe("Viz", function() { + let viz; + + beforeEach(async function() { + viz = await VizPackage.instance(); + }); + + describe("renderFormats", function() { + it("renders multiple output formats", function() { + const result = viz.renderFormats("graph a { }", ["dot", "cmapx"]); + + assert.deepStrictEqual(result, { + status: "success", + output: { + dot: "graph a {\n\tgraph [bb=\"0,0,0,0\"];\n\tnode [label=\"\\N\"];\n}\n", + cmapx: "\n" + }, + errors: [] + }); + }); + + it("renders with the same format twice", function() { + const result = viz.renderFormats("graph a { }", ["dot", "dot"]); + + assert.deepStrictEqual(result, { + status: "success", + output: { + dot: "graph a {\n\tgraph [bb=\"0,0,0,0\"];\n\tnode [label=\"\\N\"];\n}\n" + }, + errors: [] + }); + }); + + it("renders with an empty array of formats", function() { + const result = viz.renderFormats("graph a { }", []); + + assert.deepStrictEqual(result, { + status: "success", + output: {}, + errors: [] + }); + }); + + it("accepts options", function() { + const result = viz.renderFormats("graph a { b }", ["dot", "cmapx"], { engine: "neato", reduce: true }); + + assert.deepStrictEqual(result, { + status: "success", + output: { + dot: "graph a {\n\tgraph [bb=\"0,0,0,0\"];\n\tnode [label=\"\\N\"];\n}\n", + cmapx: "\n" + }, + errors: [] + }); + }); + + it("returns error messages for invalid input", function() { + const result = viz.renderFormats("invalid", ["dot", "cmapx"]); + + assert.deepStrictEqual(result, { + status: "failure", + output: undefined, + errors: [ + { level: "error", message: "syntax error in line 1 near 'invalid'" } + ] + }); + }); + + it("returns error messages for invalid input and an empty array of formats", function() { + const result = viz.renderFormats("invalid", []); + + assert.deepStrictEqual(result, { + status: "failure", + output: undefined, + errors: [ + { level: "error", message: "syntax error in line 1 near 'invalid'" } + ] + }); + }); + }); +}); diff --git a/packages/viz/test/types/render-formats.ts b/packages/viz/test/types/render-formats.ts new file mode 100644 index 00000000..306cd41e --- /dev/null +++ b/packages/viz/test/types/render-formats.ts @@ -0,0 +1,45 @@ +import { instance, type MultipleRenderResult, type RenderError } from "@viz-js/viz"; + +instance().then(viz => { + let result: MultipleRenderResult = viz.renderFormats("digraph { a -> b }", ["svg", "cmapx"]); + + switch (result.status) { + case "success": + { + let output: string = result.output["svg"]; + break; + } + + case "failure": + { + // @ts-expect-error + let output: string = result.output; + break; + } + + // @ts-expect-error + case "invalid": + break; + } + + let error: RenderError | undefined = result.errors[0]; + + if (typeof error !== "undefined") { + let message: string = error.message; + + switch (error.level) { + case "error": + break; + + case "warning": + break; + + case undefined: + break; + + // @ts-expect-error + case "invalid": + break; + } + } +}); diff --git a/packages/viz/test/types/viz.ts b/packages/viz/test/types/viz.ts index 68306fde..6f22b6d3 100644 --- a/packages/viz/test/types/viz.ts +++ b/packages/viz/test/types/viz.ts @@ -1,4 +1,4 @@ -import { instance, type Viz } from "@viz-js/viz"; +import { instance, type Viz, type RenderResult, type MultipleRenderResult } from "@viz-js/viz"; export function myRender(viz: Viz, src: string): string { return viz.renderString(src, { graphAttributes: { label: "My graph" } }); @@ -11,6 +11,10 @@ instance().then(viz => { viz.render("digraph { a -> b }", { format: "svg", engine: "dot", yInvert: false }); + viz.renderFormats("digraph { a -> b }", ["svg", "cmapx"]); + + viz.renderFormats("digraph { a -> b }", ["svg", "cmapx"], { engine: "dot" }); + viz.render("digraph { a -> b }", { nodeAttributes: { shape: "circle" } }); viz.render({ edges: [{ tail: "a", head: "b" }] }); @@ -29,6 +33,13 @@ instance().then(viz => { // @ts-expect-error viz.render("digraph { a -> b }", { whatever: 123 }); + // @ts-expect-error + viz.render("digraph { a -> b }", { format: ["svg"] }); + + let result: RenderResult = viz.render("digraph { a -> b }"); + + let formatsResult: MultipleRenderResult = viz.renderFormats("digraph { a -> b }", ["svg", "cmapx"]); + let stringResult: string = viz.renderString("digraph { a -> b }"); let svgElementResult: SVGSVGElement = viz.renderSVGElement("digraph { a -> b }"); diff --git a/packages/viz/types/index.d.ts b/packages/viz/types/index.d.ts index ce9a8acf..ebb50e1f 100644 --- a/packages/viz/types/index.d.ts +++ b/packages/viz/types/index.d.ts @@ -13,6 +13,7 @@ declare class Viz { get formats(): string[] get engines(): string[] render(input: string | Graph, options?: RenderOptions): RenderResult + renderFormats(input: string | Graph, formats: string[], options?: RenderOptions): MultipleRenderResult renderString(input: string | Graph, options?: RenderOptions): string renderSVGElement(input: string | Graph, options?: RenderOptions): SVGSVGElement renderJSON(input: string | Graph, options?: RenderOptions): object @@ -45,6 +46,14 @@ interface FailureResult { errors: RenderError[] } +export type MultipleRenderResult = MultipleSuccessResult | FailureResult + +interface MultipleSuccessResult { + status: "success" + output: { [format: string]: string } + errors: RenderError[] +} + export interface RenderError { level?: "error" | "warning" message: string diff --git a/packages/website/src/api/index.html b/packages/website/src/api/index.html index 11d1bdc4..dc319d69 100644 --- a/packages/website/src/api/index.html +++ b/packages/website/src/api/index.html @@ -126,7 +126,20 @@
render(input: string | Graph, options?: RenderOptions) → RenderResult
Renders the graph described by the input and returns the result as an object. input
may be a string in DOT syntax or a graph object.
Renders the graph described by the input and returns the result as an object.
+ +input
may be a string in DOT syntax or a graph object.
This method does not throw an error if rendering failed, including for invalid DOT syntax, but it will throw for invalid types in input or unexpected runtime errors.
+renderFormats(input: string | Graph, formats: string[], options?: RenderOptions) → MultipleRenderResult
Renders the graph described by the input for each format in formats
and returns the result as an object. For a successful result, output
is an object keyed by format.
input
may be a string in DOT syntax or a graph object.
The format
option is ignored.
This method does not throw an error if rendering failed, including for invalid DOT syntax, but it will throw for invalid types in input or unexpected runtime errors.
renderSVGElement(input: string | Graph, options?: RenderOptions) → SVGSVGElement
Convenience method that renders the input, parses the output, and returns an SVG element. The format option is ignored. Throws an error if rendering failed.
+Convenience method that renders the input, parses the output, and returns an SVG element. The format
option is ignored. Throws an error if rendering failed.
renderJSON(input: string | Graph, options?: RenderOptions) → object
Convenience method that renders the input, parses the output, and returns a JSON object. The format option is ignored. Throws an error if rendering failed.
+Convenience method that renders the input, parses the output, and returns a JSON object. The format
option is ignored. Throws an error if rendering failed.
The result object returned by render
.
MultipleRenderResult = MultipleSuccessResult | FailureResult
The result object returned by renderFormats
.
SuccessResult
MultipleSuccessResult
Returned by renderFormats
if rendering was successful. errors
may contain warning messages even if the graph rendered successfully.
status: "success"
output: { [format: string]: string }
errors: RenderError[]
FailureResult
Returned by render
if rendering failed.