Skip to content

Commit

Permalink
Accept HTML strings in object input
Browse files Browse the repository at this point in the history
For example, a node with an HTML label:

  {
    name: "a",
    attributes: {
      label: { html: "<b>A</b>" }
    }
  }
  • Loading branch information
mdaines committed Aug 25, 2023
1 parent 5c9cbdc commit 764cba9
Show file tree
Hide file tree
Showing 8 changed files with 238 additions and 26 deletions.
6 changes: 6 additions & 0 deletions packages/viz/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## Unreleased

* Accept HTML attribute values in object input.

HTML attribute values are written as an object literal with a "html" property:

{ label: { html: "<i>the label</i>" } }

* Accept a "reduce" option. This has the same effect as using the -x Graphviz command-line option. When using the neato layout engine, it prunes isolated nodes.

* Accept default attributes for graphs, nodes, and edges in render options. This is similar to the -G, -N, -E options provided by the Graphviz command-line.
Expand Down
15 changes: 15 additions & 0 deletions packages/viz/src/module/viz.c
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,21 @@ Agraph_t *viz_read_one_graph(char *string) {
return graph;
}

EMSCRIPTEN_KEEPALIVE
char *viz_string_dup(Agraph_t *g, char *s) {
return agstrdup(g, s);
}

EMSCRIPTEN_KEEPALIVE
char *viz_string_dup_html(Agraph_t *g, char *s) {
return agstrdup_html(g, s);
}

EMSCRIPTEN_KEEPALIVE
int viz_string_free(Agraph_t * g, const char *s) {
return agstrfree(g, s);
}

EMSCRIPTEN_KEEPALIVE
Agnode_t *viz_add_node(Agraph_t *g, char *name) {
return agnode(g, name, TRUE);
Expand Down
42 changes: 34 additions & 8 deletions packages/viz/src/viz.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,22 +42,46 @@ function parseErrorMessages(module) {
return parseAgerrMessages(module["agerrMessages"]).concat(parseStderrMessages(module["stderrMessages"]));
}

function withStringPointer(module, graphPointer, value, callbackFn) {
let stringPointer;

if (typeof value === "object" && "html" in value) {
stringPointer = module.ccall("viz_string_dup_html", "number", ["number", "string"], [graphPointer, String(value.html)]);
} else {
stringPointer = module.ccall("viz_string_dup", "number", ["number", "string"], [graphPointer, String(value)]);
}

if (stringPointer == 0) {
throw new Error("couldn't dup string");
}

callbackFn(stringPointer);

module.ccall("viz_string_free", "number", ["number", "number"], [graphPointer, stringPointer]);
}

function setDefaultAttributes(module, graphPointer, defaultAttributes) {
if (defaultAttributes.graph) {
for (const [name, value] of Object.entries(defaultAttributes.graph)) {
module.ccall("viz_set_default_graph_attribute", "number", ["number", "string", "string"], [graphPointer, name, String(value)]);
withStringPointer(module, graphPointer, value, stringPointer => {
module.ccall("viz_set_default_graph_attribute", "number", ["number", "string", "number"], [graphPointer, name, stringPointer]);
});
}
}

if (defaultAttributes.node) {
for (const [name, value] of Object.entries(defaultAttributes.node)) {
module.ccall("viz_set_default_node_attribute", "number", ["number", "string", "string"], [graphPointer, name, String(value)]);
withStringPointer(module, graphPointer, value, stringPointer => {
module.ccall("viz_set_default_node_attribute", "number", ["number", "string", "number"], [graphPointer, name, stringPointer]);
});
}
}

if (defaultAttributes.edge) {
for (const [name, value] of Object.entries(defaultAttributes.edge)) {
module.ccall("viz_set_default_edge_attribute", "number", ["number", "string", "string"], [graphPointer, name, String(value)]);
withStringPointer(module, graphPointer, value, stringPointer => {
module.ccall("viz_set_default_edge_attribute", "number", ["number", "string", "number"], [graphPointer, name, stringPointer]);
});
}
}
}
Expand All @@ -78,9 +102,11 @@ function readStringInput(module, src, options) {
}
}

function setAttributes(module, objectPointer, attributes) {
function setAttributes(module, graphPointer, objectPointer, attributes) {
for (const [key, value] of Object.entries(attributes)) {
module.ccall("viz_set_attribute", "number", ["number", "string", "string"], [objectPointer, String(key), String(value)]);
withStringPointer(module, graphPointer, value, stringPointer => {
module.ccall("viz_set_attribute", "number", ["number", "string", "number"], [objectPointer, key, stringPointer]);
});
}
}

Expand All @@ -90,15 +116,15 @@ function readGraph(module, graphPointer, graphData) {
}

if (graphData.attributes) {
setAttributes(module, graphPointer, graphData.attributes);
setAttributes(module, graphPointer, graphPointer, graphData.attributes);
}

if (graphData.nodes) {
graphData.nodes.forEach(nodeData => {
const nodePointer = module.ccall("viz_add_node", "number", ["number", "string"], [graphPointer, String(nodeData.name)]);

if (nodeData.attributes) {
setAttributes(module, nodePointer, nodeData.attributes);
setAttributes(module, graphPointer, nodePointer, nodeData.attributes);
}
});
}
Expand All @@ -108,7 +134,7 @@ function readGraph(module, graphPointer, graphData) {
const edgePointer = module.ccall("viz_add_edge", "number", ["number", "string", "string"], [graphPointer, String(edgeData.tail), String(edgeData.head)]);

if (edgeData.attributes) {
setAttributes(module, edgePointer, edgeData.attributes);
setAttributes(module, graphPointer, edgePointer, edgeData.attributes);
}
});
}
Expand Down
41 changes: 33 additions & 8 deletions packages/viz/test/manual/instance-reuse.mjs
Original file line number Diff line number Diff line change
@@ -1,18 +1,43 @@
import { instance } from "../../src/standalone.mjs";
import { randomGraph, dotStringify } from "./utils.mjs";

const basicObject = randomGraph(100, 10);
const basicString = dotStringify(basicObject);
const multipleGraphs = `${basicString}${basicString}`;
const invalidInput = "graph {";

function makeObject() {
return randomGraph(100, 10);
}

function makeObjectWithLabels() {
const graph = randomGraph(100, 10);
graph.nodes.forEach(node => {
node.attributes = { label: `${node.name}!` };
});
return graph;
}

function makeObjectWithHTMLLabels() {
const graph = randomGraph(100, 10);
graph.nodes.forEach(node => {
node.attributes = { label: { html: `<b>${node.name}</b>` } };
});
return graph;
}

function makeMultiple() {
return `${dotStringify(makeObject())}${dotStringify(makeObject())}`;
}

const tests = [
{ label: "valid input", fn: viz => viz.render(basicString) },
{ label: "object input", fn: viz => viz.render(basicObject) },
{ label: "valid input containing multiple graphs", fn: viz => viz.render(multipleGraphs) },
{ 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: "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()) },
{ label: "valid input containing multiple graphs", fn: viz => viz.render(makeMultiple()) },
{ label: "invalid input", fn: viz => viz.render(invalidInput) },
{ label: "invalid layout engine option", fn: viz => viz.render(basicString, { engine: "invalid" }) },
{ label: "invalid format option", fn: viz => viz.render(basicString, { format: "invalid" }) },
{ label: "invalid layout engine option", fn: viz => viz.render(dotStringify(makeObject()), { engine: "invalid" }) },
{ label: "invalid format option", fn: viz => viz.render(dotStringify(makeObject()), { format: "invalid" }) },
{ label: "list layout engines", fn: viz => viz.engines },
{ label: "list formats", fn: viz => viz.formats }
];
Expand Down
57 changes: 53 additions & 4 deletions packages/viz/test/manual/utils.mjs
Original file line number Diff line number Diff line change
@@ -1,18 +1,39 @@
const skipQuotePattern = /^([A-Za-z_][A-Za-z_0-9]*|-?(\.[0-9]+|[0-9]+(\.[0-9]+)?))$/;

function quote(value) {
if (typeof value === "object" && "html" in value) {
return "<" + value.html + ">";
}

const str = String(value);

if (skipQuotePattern.test(str)) {
return str;
} else {
return "\"" + str.replaceAll("\"", "\\\"").replaceAll("\n", "\\n") + "\"";
}
}

export function randomGraph(nodeCount, randomEdgeCount = 0) {
const result = {
nodes: [],
edges: []
};

const prefix = Math.floor(Number.MAX_SAFE_INTEGER * Math.random());

for (let i = 0; i < nodeCount; i++) {
result.nodes.push({ name: `node${i}` });
result.nodes.push({ name: `${prefix}-node${i}` });
}

for (let i = 0; i < randomEdgeCount; i++) {
const t = Math.floor(nodeCount * Math.random());
const h = Math.floor(nodeCount * Math.random());

result.edges.push({ tail: `node${t}`, head: `node${h}` });
result.edges.push({
tail: result.nodes[t].name,
head: result.nodes[h].name
});
}

return result;
Expand All @@ -25,11 +46,39 @@ export function dotStringify(obj) {
result.push("digraph {\n");

for (const node of obj.nodes) {
result.push(node.name, ";\n");
result.push(quote(node.name));

if (node.attributes) {
result.push(" [");

let sep = "";
for (const [key, value] of Object.entries(node.attributes)) {
result.push(quote(key), "=", quote(value), sep);
sep = ", ";
}

result.push("]");
}

result.push(";\n");
}

for (const edge of obj.edges) {
result.push(edge.tail, " -> ", edge.head, ";\n");
result.push(quote(edge.tail), " -> ", quote(edge.head));

if (edge.attributes) {
result.push(" [");

let sep = "";
for (const [key, value] of Object.entries(edge.attributes)) {
result.push(quote(key), "=", quote(value), sep);
sep = ", ";
}

result.push("]");
}

result.push(";\n");
}

result.push("}\n");
Expand Down
46 changes: 46 additions & 0 deletions packages/viz/test/standalone.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,26 @@ describe("standalone", function() {
});
});

it("default attribute values can be html strings", function() {
const result = viz.render("graph {}", {
defaultAttributes: {
node: {
label: { html: "<b>test</b>" }
}
}
});

assert.deepStrictEqual(result, {
status: "success",
output: `graph {
graph [bb="0,0,0,0"];
node [label=<<b>test</b>>];
}
`,
errors: []
});
});

it("returns an error for empty input", function() {
const result = viz.render("");

Expand Down Expand Up @@ -450,6 +470,32 @@ stop
});
});

it("html attributes", function() {
const result = viz.render({
nodes: [
{
name: "a",
attributes: {
label: { html: "<b>A</b>" }
}
}
]
});

assert.deepStrictEqual(result, {
status: "success",
output: `digraph {
graph [bb="0,0,54,36"];
a [height=0.5,
label=<<b>A</b>>,
pos="27,18",
width=0.75];
}
`,
errors: []
});
});

it("default attributes, graph attributes, nodes, edges, and nested subgraphs", function() {
const result = viz.render({
defaultAttributes: {
Expand Down
39 changes: 39 additions & 0 deletions packages/viz/test/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,15 @@ instance().then(viz => {
let result: RenderResult;

result = viz.render("digraph { a -> b }");

result = viz.render("digraph { a -> b }", { format: "svg" });

result = viz.render("digraph { a -> b }", { format: "svg", engine: "dot", yInvert: false });

result = viz.render("digraph { a -> b }", { defaultAttributes: { node: { shape: "circle" } } });

result = viz.render({});

result = viz.render({
edges: [
{ tail: "a", head: "b" }
Expand Down Expand Up @@ -94,6 +99,40 @@ instance().then(viz => {
]
});

result = viz.render({
attributes: {
width: 2,
abc: true,
label: { html: "<b>test</b>" }
},
defaultAttributes: {
node: {
width: 2,
abc: true,
label: { html: "<b>test</b>" }
}
},
nodes: [
{
name: "a",
attributes: {
width: 2,
abc: true,
label: { html: "<b>test</b>" }
}
}
]
});

result = viz.render({
attributes: {
// @ts-expect-error
blah: null,
// @ts-expect-error
label: { stuff: "abc" }
}
});

// @ts-expect-error
result = viz.render({ a: "b" });

Expand Down
Loading

0 comments on commit 764cba9

Please sign in to comment.