nicevg 0.15.1 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,102 +1,92 @@
1
- # nicevg for TypeScript (Bun and Node)
1
+ # nicevg
2
2
 
3
- nicevg で図を SVG に描く。TypeScript や JavaScript から、Bun では `bun:ffi`、Node では [koffi](https://koffi.dev/) でネイティブライブラリを呼ぶ。図はノードとコネクタの配列(Sketch)のほか、SVG の文字列でも渡せる。処理は Rust のネイティブコードで動くので、CLI と同じ速さで、出力も同じになる。
3
+ [![npm](https://img.shields.io/npm/v/nicevg)](https://www.npmjs.com/package/nicevg)
4
+ [![CI](https://github.com/takagiy/nicevg/actions/workflows/ci.yml/badge.svg)](https://github.com/takagiy/nicevg/actions/workflows/ci.yml)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/takagiy/nicevg/blob/main/LICENSE)
4
6
 
5
- ## インストール
7
+ An SVG diagram tool for AI agents, for TypeScript and JavaScript on Bun and Node. It fixes a diagram's problems mechanically on the agent's behalf, and optimizes the layout while preserving the original intent.
6
8
 
7
- ```sh
8
- npm install nicevg # または bun add nicevg
9
- ```
10
-
11
- パッケージには、次の環境向けにビルド済みのネイティブライブラリが入っているので、インストール時にスクリプトは走らず、Rust も要らない。
12
-
13
- | 環境 | 対象 |
9
+ | Before | After |
14
10
  |---|---|
15
- | Linux | x64、arm64(glibc 2.17 以降) |
16
- | macOS | arm64、x64 |
17
- | Windows | x64 |
11
+ | ![A service diagram with overlapping boxes, an overflowing label, a diagonal line, lines through a box, a box cut off at the edge and labels far from their lines](https://cdn.jsdelivr.net/gh/takagiy/nicevg@main/docs/images/before.svg) | ![The same diagram repaired by nicevg](https://cdn.jsdelivr.net/gh/takagiy/nicevg@main/docs/images/after.svg) |
12
+
13
+ ## Install
18
14
 
19
- ## 使い方
15
+ ```sh
16
+ npm install nicevg
17
+ # or
18
+ bun add nicevg
19
+ ```
20
20
 
21
- 入口は `render()` 1 つ。図を受け取り、配置の問題を直し、ノードを少し動かして折れ・交差・混雑を減らして、SVG を表す `Svg` を返す。
21
+ ## Example
22
22
 
23
23
  ```ts
24
- import { render } from "nicevg";
24
+ import { render, svg, text } from "nicevg";
25
25
 
26
- const svg = render({
26
+ const image = render({
27
27
  nodes: [
28
28
  { id: "order", x: 20, y: 20, width: 120, height: 56, label: "Order" },
29
- { id: "bill", x: 300, y: 160, width: 120, height: 56, label: "Bill" },
30
- { id: "user", x: 40, y: 200, width: 80, height: 80, shape: "circle", label: "User" },
31
- ],
32
- connectors: [
33
- { from: "order", to: "bill", label: "invoice" },
34
- { from: "user", to: "order" },
29
+ { id: "orders", class: "pill", x: 300, y: 160, label: "Orders" },
35
30
  ],
31
+ edges: [{ from: "order", to: "orders", label: "save" }],
32
+ style: {
33
+ pill: ({ label }) => {
34
+ const name = text(label);
35
+ const w = name.width + 32, h = name.height + 20;
36
+ return svg`
37
+ <rect width="${w}" height="${h}" rx="${h / 2}" fill="white" stroke="black"/>
38
+ ${name.at({ cx: w / 2, cy: h / 2 })}`;
39
+ },
40
+ },
41
+ rootAttrs: { "font-family": "sans-serif" },
36
42
  });
37
-
38
- await svg.write("diagram.svg"); // ファイルに書く
39
- const text = svg.toString(); // SVG の文字列(`${svg}` でも同じ)
40
-
41
- for (const node of svg.report.diagram.nodes) {
42
- console.log(node.id, node.bounds); // 動いた後の位置と大きさ
43
- }
44
- for (const connector of svg.report.diagram.connectors) {
45
- console.log(connector.from, connector.to, connector.points); // 両端のノードと、直交する経路の折れ点
46
- }
47
- console.log(svg.report.issues); // 残った問題(なければ空)
48
- console.log(svg.changes); // 何を直し、何を動かしたか
43
+ await image.write("diagram.svg");
49
44
  ```
50
45
 
51
- - `input` は `Sketch`、`{ svg: string }`、または前に返った `Svg`。
52
-
53
- ### Sketch
54
-
55
- | 項目 | 内容 |
56
- |---|---|
57
- | `nodes[].id`、`x`、`y`、`width`、`height` | ノードの id と、左上の位置と大きさ |
58
- | `nodes[].shape` | `"rect"`(既定)か `"circle"`。円は外接する正方形を `x`〜`height` で渡す |
59
- | `nodes[].label`、`fontSize` | 中に書く文字(`\n` で改行)。子を持つノードでは左上に、それ以外では中央に置く |
60
- | `nodes[].parent` | このノードを入れる親ノードの id |
61
- | `nodes[].allowOverlap` | 意図した重なりなら `true` |
62
- | `connectors[].from`、`to` | 両端のノードの id |
63
- | `connectors[].points` | 経路の折れ点。省くと、修正と配置調整が両端のノードの辺から辺へ経路を引く |
64
- | `connectors[].label`、`fontSize` | コネクタの脇に置く文字 |
46
+ ## API
65
47
 
66
- `Svg` の `report.diagram` は、描いた図を読み直したもので、ノード(`bounds`、ラベルの範囲 `labelBounds`)、コネクタ(`points`)、ラベル(`bounds`、説明するコネクタの id `connector`)を持つ。コネクタは id を持たないので、`from` と `to` で見分ける。文字の幅は、全角を 1 文字あたりフォントサイズ、それ以外を 0.59 倍として見積もる。
48
+ ### `render(input)`
67
49
 
68
- 形の書き方が違う入力や、存在しないノードを指す `from`・`to`・`parent` は、どこが何を指しているか(`connectors[0].to` など)を書いた `NicevgError` を投げる。
50
+ Fixes the diagram, nudges its nodes into a cleaner layout and returns an `Svg`. `input` is a sketch (below), `{ svg: string }` with SVG text, or an `Svg` returned before. Bad input throws a `NicevgError` that says which field is wrong, such as `edges[0].to` or `nodes[1].class`.
69
51
 
70
- 描画は呼んだスレッドで最後まで走る。大きな図の配置調整は 1〜2 秒かかり、その間すべてのコアを使う。
71
-
72
- ## 開発
73
-
74
- Bun と Node(型を取り除いて .ts を動かせる 22.18 以降)、Rust のツールチェーンが要る。
75
-
76
- ```sh
77
- bun install
78
- bun run build # cargo でネイティブライブラリを作って native/ に置き、tsc で src/ を dist/ に書き出す
79
- bun run test # Bun で src/ を、Node でビルドした dist/ をテストする
80
- bun run typecheck
81
- ```
82
-
83
- パッケージは Bun には `src/` の TypeScript を、Node には `dist/` の JavaScript と型定義を渡す(`package.json` の `exports`)。ネイティブライブラリは `NICEVG_LIBRARY` 環境変数、`native/<platform>-<arch>/`(`bun run build` が置く場所で、公開するパッケージでは全環境ぶん)、リポジトリの `target/release/` の順に探す。
52
+ | Option | |
53
+ |---|---|
54
+ | `nodes` | The boxes, circles and styled nodes (below). |
55
+ | `edges` | The lines between nodes (below). `connectors` is accepted as its old name. |
56
+ | `style` | For each `class` used by a node, a function drawing that node with `svg` and `text`. |
57
+ | `rootAttrs` | Attributes for the root `<svg>`, such as `{ "font-family": "sans-serif" }`. |
84
58
 
85
- `v*` のタグを push すると、`.github/workflows/npm.yml` が各環境のネイティブライブラリを作ってパッケージに入れ、Bun と Node でテストしてから npm に公開する。タグの版と `package.json` の `version` が違えば止まるので、リリースのときは Rust の `Cargo.toml`(ルートと `bindings/js`)と一緒に `package.json` の版も上げる。手で実行すると、公開せずにビルドとパックだけを確かめられる。
59
+ | Node field | |
60
+ |---|---|
61
+ | `id`, `x`, `y` | Its id and its top left corner. |
62
+ | `width`, `height` | Its size. Not used with `class`. |
63
+ | `label`, `fontSize` | Its text, lines split at `\n`. |
64
+ | `shape` | `"rect"` (the default) or `"circle"`. |
65
+ | `parent` | The id of the node it sits inside. |
66
+ | `class` | The `style` that draws it. Other fields you add are passed to that style. |
67
+ | `allowOverlap` | `true` if it overlaps other nodes on purpose. |
68
+
69
+ | Edge field | |
70
+ |---|---|
71
+ | `from`, `to` | The ids of the nodes it joins. |
72
+ | `label`, `fontSize` | Its text, placed beside it. |
73
+ | `points` | Its route. Without one, rendering routes it from side to side. |
86
74
 
87
- 公開は npm の Trusted Publishing(GitHub Actions の OIDC)で、npm のトークンを使わず、配布元の証明(provenance)も自動で付く。
75
+ ### `Svg`
88
76
 
89
- Trusted Publisher はすでに npm にあるパッケージにしか登録できないので、最初の 1 回だけは手元から公開する。npm のアカウントで 2FA を有効にしたうえで、次のようにする。
77
+ | Member | |
78
+ |---|---|
79
+ | `write(path)` | Writes the SVG to a file. |
80
+ | `toString()` | The SVG text. |
81
+ | `report.issues` | Problems left after rendering; empty when there are none. |
82
+ | `report.diagram` | The diagram read back: `nodes` with their `bounds`, `connectors` with their `points`, and `labels`. |
83
+ | `changes` | What rendering fixed and moved, in order. |
90
84
 
91
- 1. Actions の「Publish to npm」を手で実行する(手での実行は公開しない)。全環境のライブラリをそれぞれの環境でテストし、まとめたパッケージが、成果物 `npm-package` に残る。
92
- 2. それを手元に落として公開する(2FA のワンタイムコードを聞かれる)。手元からの公開なので、この版には配布元の証明が付かない。
93
- ```sh
94
- gh run download <run-id> -n npm-package
95
- npm publish ./nicevg-<version>.tgz
96
- ```
97
- 3. このリポジトリと `npm.yml` を Trusted Publisher に登録する(npmjs.com のパッケージの設定か、次のコマンド。どちらも 2FA が要る)。
98
- ```sh
99
- npm trust github nicevg --file npm.yml --repo takagiy/nicevg --allow-publish
100
- ```
85
+ ### Drawing a style
101
86
 
102
- 以後はタグの push だけで公開される。npm のトークンは作らない(2FA を回避するトークンは、npm が段階的に廃止している)。
87
+ | Function | |
88
+ |---|---|
89
+ | `` svg`...` `` | SVG markup. Embedded strings are escaped; `svg` fragments, texts and arrays of them go in as they are. |
90
+ | `text(content, size = 14)` | One line of text, with the `width` and `height` nicevg measures it at. |
91
+ | `text(...).at({ left \| cx \| right, top \| cy \| bottom })` | Places the text so that point of its box sits at those coordinates. |
92
+ | `` svg`...`.at(x, y) `` | The fragment with its origin moved to `x`, `y`. |
package/dist/draw.d.ts ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Drawing a node's picture for `render()`'s `style`, as SVG written out:
3
+ *
4
+ * ```ts
5
+ * const store = ({ no, label }) => {
6
+ * const id = text(no), name = text(label);
7
+ * const sep = id.width + 24, w = sep + name.width + 24, h = Math.max(id.height, name.height) + 20;
8
+ * return svg`
9
+ * <path d="M ${w} 0 H 0 V ${h} H ${w}" fill="none" stroke="#333"/>
10
+ * <line x1="${sep}" y1="0" x2="${sep}" y2="${h}" stroke="#333"/>
11
+ * ${id.at({ cx: sep / 2, cy: h / 2 })}
12
+ * ${name.at({ left: sep + 12, cy: h / 2 })}`;
13
+ * };
14
+ * ```
15
+ */
16
+ /** SVG written with `svg`: embedded as it is, where strings are escaped. */
17
+ export declare class SvgFragment {
18
+ #private;
19
+ constructor(markup: string);
20
+ toString(): string;
21
+ /** This fragment with its origin moved to `x`, `y`. */
22
+ at(x: number, y: number): SvgFragment;
23
+ }
24
+ /** SVG markup with values embedded: strings escaped, fragments, texts and arrays of them as they are. */
25
+ export declare function svg(strings: TemplateStringsArray, ...values: unknown[]): SvgFragment;
26
+ /** Where a text goes: one of `left`, `cx`, `right` across, one of `top`, `cy`, `bottom` down. */
27
+ export type TextPlace = ({
28
+ left: number;
29
+ } | {
30
+ cx: number;
31
+ } | {
32
+ right: number;
33
+ }) & ({
34
+ top: number;
35
+ } | {
36
+ cy: number;
37
+ } | {
38
+ bottom: number;
39
+ });
40
+ /** A line of text, sized the way nicevg measures text, placed with `at`. */
41
+ export declare class Text {
42
+ readonly content: string;
43
+ readonly size: number;
44
+ readonly place: TextPlace;
45
+ /** Its width, wide characters one font size each and others 0.59. */
46
+ readonly width: number;
47
+ /** Its line's height, 1.2 font sizes. */
48
+ readonly height: number;
49
+ constructor(content: string, size?: number, place?: TextPlace);
50
+ /** This text with the point of its box named in `place` at the coordinates given. */
51
+ at(place: TextPlace): Text;
52
+ /** The `<text>` element, centred on its line by `dominant-baseline`. */
53
+ toString(): string;
54
+ }
55
+ /** A line of text at `size` (14 by default), to embed in `svg` once placed with `at`. */
56
+ export declare function text(content: string, size?: number): Text;
package/dist/draw.js ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Drawing a node's picture for `render()`'s `style`, as SVG written out:
3
+ *
4
+ * ```ts
5
+ * const store = ({ no, label }) => {
6
+ * const id = text(no), name = text(label);
7
+ * const sep = id.width + 24, w = sep + name.width + 24, h = Math.max(id.height, name.height) + 20;
8
+ * return svg`
9
+ * <path d="M ${w} 0 H 0 V ${h} H ${w}" fill="none" stroke="#333"/>
10
+ * <line x1="${sep}" y1="0" x2="${sep}" y2="${h}" stroke="#333"/>
11
+ * ${id.at({ cx: sep / 2, cy: h / 2 })}
12
+ * ${name.at({ left: sep + 12, cy: h / 2 })}`;
13
+ * };
14
+ * ```
15
+ */
16
+ const escape = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
17
+ /** SVG written with `svg`: embedded as it is, where strings are escaped. */
18
+ export class SvgFragment {
19
+ #markup;
20
+ constructor(markup) {
21
+ this.#markup = markup;
22
+ }
23
+ toString() {
24
+ return this.#markup;
25
+ }
26
+ /** This fragment with its origin moved to `x`, `y`. */
27
+ at(x, y) {
28
+ return svg `<g transform="translate(${x} ${y})">${this}</g>`;
29
+ }
30
+ }
31
+ function embed(value) {
32
+ if (value instanceof SvgFragment || value instanceof Text) {
33
+ return value.toString();
34
+ }
35
+ if (Array.isArray(value)) {
36
+ return value.map(embed).join("");
37
+ }
38
+ return escape(String(value));
39
+ }
40
+ /** SVG markup with values embedded: strings escaped, fragments, texts and arrays of them as they are. */
41
+ export function svg(strings, ...values) {
42
+ return new SvgFragment(strings.reduce((markup, string, index) => markup + embed(values[index - 1]) + string));
43
+ }
44
+ // shortcut: the common wide ranges (CJK, kana, hangul, fullwidth forms), not every East Asian Wide character; widen it if a script measures short.
45
+ const WIDE = /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]|[\u{20000}-\u{3fffd}]/u;
46
+ /** A line of text, sized the way nicevg measures text, placed with `at`. */
47
+ export class Text {
48
+ content;
49
+ size;
50
+ place;
51
+ /** Its width, wide characters one font size each and others 0.59. */
52
+ width;
53
+ /** Its line's height, 1.2 font sizes. */
54
+ height;
55
+ constructor(content, size = 14, place = { left: 0, top: 0 }) {
56
+ this.content = content;
57
+ this.size = size;
58
+ this.place = place;
59
+ let width = 0;
60
+ for (const character of content) {
61
+ width += WIDE.test(character) ? size : character.length * size * 0.59;
62
+ }
63
+ this.width = Math.ceil(width);
64
+ this.height = Math.ceil(size * 1.2);
65
+ }
66
+ /** This text with the point of its box named in `place` at the coordinates given. */
67
+ at(place) {
68
+ const across = ["left", "cx", "right"].filter((key) => key in place);
69
+ const down = ["top", "cy", "bottom"].filter((key) => key in place);
70
+ if (across.length !== 1 || down.length !== 1) {
71
+ throw new TypeError(`text "${this.content}": at() takes one of left, cx, right and one of top, cy, bottom, not ${Object.keys(place).join(", ") || "none"}`);
72
+ }
73
+ return new Text(this.content, this.size, place);
74
+ }
75
+ /** The `<text>` element, centred on its line by `dominant-baseline`. */
76
+ toString() {
77
+ const place = this.place;
78
+ const [x, anchor] = "cx" in place ? [place.cx, "middle"] : "right" in place ? [place.right, "end"] : [place.left, "start"];
79
+ const y = "cy" in place ? place.cy : "top" in place ? place.top + this.height / 2 : place.bottom - this.height / 2;
80
+ return `<text x="${x}" y="${y}" font-size="${this.size}" text-anchor="${anchor}" dominant-baseline="central">${escape(this.content)}</text>`;
81
+ }
82
+ }
83
+ /** A line of text at `size` (14 by default), to embed in `svg` once placed with `at`. */
84
+ export function text(content, size = 14) {
85
+ return new Text(content, size);
86
+ }
87
+ //# sourceMappingURL=draw.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"draw.js","sourceRoot":"","sources":["../src/draw.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,MAAM,MAAM,GAAG,CAAC,KAAa,EAAE,EAAE,CAC/B,KAAK,CAAC,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;AAEnG,4EAA4E;AAC5E,MAAM,OAAO,WAAW;IACb,OAAO,CAAS;IAEzB,YAAY,MAAc;QACxB,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC;IACxB,CAAC;IAED,QAAQ;QACN,OAAO,IAAI,CAAC,OAAO,CAAC;IACtB,CAAC;IAED,uDAAuD;IACvD,EAAE,CAAC,CAAS,EAAE,CAAS;QACrB,OAAO,GAAG,CAAA,2BAA2B,CAAC,IAAI,CAAC,MAAM,IAAI,MAAM,CAAC;IAC9D,CAAC;CACF;AAED,SAAS,KAAK,CAAC,KAAc;IAC3B,IAAI,KAAK,YAAY,WAAW,IAAI,KAAK,YAAY,IAAI,EAAE,CAAC;QAC1D,OAAO,KAAK,CAAC,QAAQ,EAAE,CAAC;IAC1B,CAAC;IACD,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;QACzB,OAAO,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACnC,CAAC;IACD,OAAO,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;AAC/B,CAAC;AAED,yGAAyG;AACzG,MAAM,UAAU,GAAG,CAAC,OAA6B,EAAE,GAAG,MAAiB;IACrE,OAAO,IAAI,WAAW,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,EAAE,CAAC,MAAM,GAAG,KAAK,CAAC,MAAM,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC;AAChH,CAAC;AAED,mJAAmJ;AACnJ,MAAM,IAAI,GAAG,4DAA4D,CAAC;AAM1E,4EAA4E;AAC5E,MAAM,OAAO,IAAI;IAOJ;IACA;IACA;IARX,qEAAqE;IAC5D,KAAK,CAAS;IACvB,yCAAyC;IAChC,MAAM,CAAS;IAExB,YACW,OAAe,EACf,OAAO,EAAE,EACT,QAAmB,EAAE,IAAI,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE;QAFtC,YAAO,GAAP,OAAO,CAAQ;QACf,SAAI,GAAJ,IAAI,CAAK;QACT,UAAK,GAAL,KAAK,CAAiC;QAE/C,IAAI,KAAK,GAAG,CAAC,CAAC;QACd,KAAK,MAAM,SAAS,IAAI,OAAO,EAAE,CAAC;YAChC,KAAK,IAAI,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC,MAAM,GAAG,IAAI,GAAG,IAAI,CAAC;QACxE,CAAC;QACD,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAC9B,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,GAAG,GAAG,CAAC,CAAC;IACtC,CAAC;IAED,qFAAqF;IACrF,EAAE,CAAC,KAAgB;QACjB,MAAM,MAAM,GAAG,CAAC,MAAM,EAAE,IAAI,EAAE,OAAO,CAAC,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,IAAI,KAAK,CAAC,CAAC;QACrE,MAAM,IAAI,GAAG,CAAC,KAAK,EAAE,IAAI,EAAE,QAAQ,CAAC,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,IAAI,KAAK,CAAC,CAAC;QACnE,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC7C,MAAM,IAAI,SAAS,CACjB,SAAS,IAAI,CAAC,OAAO,wEAAwE,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,MAAM,EAAE,CACvI,CAAC;QACJ,CAAC;QACD,OAAO,IAAI,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;IAClD,CAAC;IAED,wEAAwE;IACxE,QAAQ;QACN,MAAM,KAAK,GAAG,IAAI,CAAC,KAA+B,CAAC;QACnD,MAAM,CAAC,CAAC,EAAE,MAAM,CAAC,GACf,IAAI,IAAI,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,EAAG,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC,OAAO,IAAI,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,KAAM,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,IAAK,EAAE,OAAO,CAAC,CAAC;QAC5G,MAAM,CAAC,GAAG,IAAI,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,EAAG,CAAC,CAAC,CAAC,KAAK,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,GAAI,GAAG,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,MAAO,GAAG,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC;QACtH,OAAO,YAAY,CAAC,QAAQ,CAAC,gBAAgB,IAAI,CAAC,IAAI,kBAAkB,MAAM,iCAAiC,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC;IAC/I,CAAC;CACF;AAED,yFAAyF;AACzF,MAAM,UAAU,IAAI,CAAC,OAAe,EAAE,IAAI,GAAG,EAAE;IAC7C,OAAO,IAAI,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;AACjC,CAAC"}
package/dist/index.d.ts CHANGED
@@ -20,6 +20,8 @@
20
20
  * Rendering runs on the calling thread and blocks it until done; arranging
21
21
  * a large diagram takes a second or two, using every core.
22
22
  */
23
+ import type { SvgFragment } from "./draw.ts";
24
+ export { SvgFragment, Text, type TextPlace, svg, text } from "./draw.ts";
23
25
  export interface Point {
24
26
  x: number;
25
27
  y: number;
@@ -60,13 +62,46 @@ export interface SketchConnector {
60
62
  label?: string;
61
63
  fontSize?: number;
62
64
  }
63
- /** A diagram as nodes and the connectors between them. */
64
- export interface Sketch {
65
- nodes: SketchNode[];
65
+ /**
66
+ * A node drawn by the style its `class` names, with its origin at `x`, `y`.
67
+ * It lies around what the style draws, and rendering moves it but never
68
+ * resizes it. Its other fields, such as `label`, are for the style to use.
69
+ */
70
+ export interface StyledNode {
71
+ id: string;
72
+ x: number;
73
+ y: number;
74
+ class: string;
75
+ /** The node this one sits inside. */
76
+ parent?: string;
77
+ /** Whether it may overlap other nodes on purpose. */
78
+ allowOverlap?: boolean;
79
+ [field: string]: unknown;
80
+ }
81
+ /** Draws a styled node's picture, from its own origin, with `svg` and `text`. */
82
+ export type Style<Node extends StyledNode = StyledNode> = (node: Node) => SvgFragment;
83
+ /** A style for each class the nodes name, each given the nodes of its class. */
84
+ export type Styles<Node extends SketchNode | StyledNode> = {
85
+ [Class in Extract<Node, StyledNode>["class"]]: Style<Extract<Node, {
86
+ class: Class;
87
+ }>>;
88
+ };
89
+ /** A diagram as nodes and the edges (connectors) between them. */
90
+ export interface Sketch<Node extends SketchNode | StyledNode = SketchNode | StyledNode> {
91
+ nodes: readonly Node[];
92
+ edges?: SketchConnector[];
93
+ /** The same as `edges`, its name before; pass one or the other. */
66
94
  connectors?: SketchConnector[];
95
+ /** How to draw the nodes of each class. */
96
+ style?: Styles<NoInfer<Node>>;
97
+ /**
98
+ * Attributes for the root `<svg>`, such as `font-family`, which its texts
99
+ * inherit. nicevg writes `xmlns`, `viewBox`, `width` and `height` itself.
100
+ */
101
+ rootAttrs?: Record<string, string | number>;
67
102
  }
68
103
  /** A diagram to render: a sketch, SVG text following nicevg's SVG contract, or an Svg rendered before. */
69
- export type Input = Sketch | {
104
+ export type Input<Node extends SketchNode | StyledNode = SketchNode | StyledNode> = Sketch<Node> | {
70
105
  svg: string;
71
106
  } | Svg;
72
107
  export interface DiagramNode {
@@ -142,4 +177,4 @@ export declare class NicevgError extends Error {
142
177
  * routes through nodes, stray labels and more), routes the connectors given
143
178
  * no points, and moves nodes a little where that reads better.
144
179
  */
145
- export declare function render(input: Input): Svg;
180
+ export declare function render<const Node extends SketchNode | StyledNode>(input: Input<Node>): Svg;
package/dist/index.js CHANGED
@@ -22,6 +22,7 @@
22
22
  */
23
23
  import { writeFile } from "node:fs/promises";
24
24
  import { call as native } from "./native.js";
25
+ export { SvgFragment, Text, svg, text } from "./draw.js";
25
26
  /** A rendered diagram as SVG, with what rendering changed and the diagram read back from it. */
26
27
  export class Svg {
27
28
  changes;
@@ -55,11 +56,34 @@ export class NicevgError extends Error {
55
56
  * no points, and moves nodes a little where that reads better.
56
57
  */
57
58
  export function render(input) {
58
- const request = new TextEncoder().encode(JSON.stringify(input instanceof Svg ? { svg: input.toString() } : input));
59
+ const request = new TextEncoder().encode(JSON.stringify(input instanceof Svg ? { svg: input.toString() } : "svg" in input ? input : sketchRequest(input)));
59
60
  const reply = JSON.parse(native(request));
60
61
  if ("error" in reply) {
61
62
  throw new NicevgError(reply.error);
62
63
  }
63
64
  return new Svg(reply.ok.svg, reply.ok.changes, reply.ok.report);
64
65
  }
66
+ /** The sketch as the native library reads it: styled nodes drawn, edges as connectors. */
67
+ function sketchRequest({ nodes, edges, connectors, style, rootAttrs }) {
68
+ // Each style is called only with nodes of its own class.
69
+ const styles = (style ?? {});
70
+ if (edges && connectors) {
71
+ throw new NicevgError("edges and connectors are the same; pass one of them");
72
+ }
73
+ return {
74
+ nodes: nodes.map((node, index) => {
75
+ if (!("class" in node)) {
76
+ return node;
77
+ }
78
+ const draw = Object.hasOwn(styles, node.class) ? styles[node.class] : undefined;
79
+ if (!draw) {
80
+ throw new NicevgError(`nodes[${index}].class: no style named "${node.class}"`);
81
+ }
82
+ const { id, x, y, parent, allowOverlap } = node;
83
+ return { id, x, y, parent, allowOverlap, svg: String(draw(node)) };
84
+ }),
85
+ connectors: edges ?? connectors,
86
+ rootAttrs: rootAttrs && Object.fromEntries(Object.entries(rootAttrs).map(([name, value]) => [name, String(value)])),
87
+ };
88
+ }
65
89
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAE7C,OAAO,EAAE,IAAI,IAAI,MAAM,EAAE,MAAM,aAAa,CAAC;AA0G7C,gGAAgG;AAChG,MAAM,OAAO,GAAG;IAMH;IAEA;IAPF,KAAK,CAAS;IAEvB,YACE,IAAY;IACZ,mDAAmD;IAC1C,OAAiB;IAC1B,8FAA8F;IACrF,MAAsB;QAFtB,YAAO,GAAP,OAAO,CAAU;QAEjB,WAAM,GAAN,MAAM,CAAgB;QAE/B,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC;IACpB,CAAC;IAED,oBAAoB;IACpB,QAAQ;QACN,OAAO,IAAI,CAAC,KAAK,CAAC;IACpB,CAAC;IAED,qCAAqC;IACrC,KAAK,CAAC,IAAkB;QACtB,OAAO,SAAS,CAAC,IAAI,EAAE,IAAI,CAAC,KAAK,CAAC,CAAC;IACrC,CAAC;CACF;AAED,mEAAmE;AACnE,MAAM,OAAO,WAAY,SAAQ,KAAK;IAC3B,IAAI,GAAG,aAAa,CAAC;CAC/B;AAED;;;;GAIG;AACH,MAAM,UAAU,MAAM,CAAC,KAAY;IACjC,MAAM,OAAO,GAAG,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,YAAY,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,KAAK,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC;IACnH,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,OAAO,CAAC,CAEnB,CAAC;IACtB,IAAI,OAAO,IAAI,KAAK,EAAE,CAAC;QACrB,MAAM,IAAI,WAAW,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;IACrC,CAAC;IACD,OAAO,IAAI,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC,GAAG,EAAE,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,KAAK,CAAC,EAAE,CAAC,MAAM,CAAC,CAAC;AAClE,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nicevg",
3
- "version": "0.15.1",
3
+ "version": "0.17.1",
4
4
  "description": "Deterministic checks and repairs for diagrams, given as shapes or SVG, on Bun and Node through native FFI.",
5
5
  "keywords": [
6
6
  "diagram",
package/src/draw.ts ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Drawing a node's picture for `render()`'s `style`, as SVG written out:
3
+ *
4
+ * ```ts
5
+ * const store = ({ no, label }) => {
6
+ * const id = text(no), name = text(label);
7
+ * const sep = id.width + 24, w = sep + name.width + 24, h = Math.max(id.height, name.height) + 20;
8
+ * return svg`
9
+ * <path d="M ${w} 0 H 0 V ${h} H ${w}" fill="none" stroke="#333"/>
10
+ * <line x1="${sep}" y1="0" x2="${sep}" y2="${h}" stroke="#333"/>
11
+ * ${id.at({ cx: sep / 2, cy: h / 2 })}
12
+ * ${name.at({ left: sep + 12, cy: h / 2 })}`;
13
+ * };
14
+ * ```
15
+ */
16
+
17
+ const escape = (value: string) =>
18
+ value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
19
+
20
+ /** SVG written with `svg`: embedded as it is, where strings are escaped. */
21
+ export class SvgFragment {
22
+ readonly #markup: string;
23
+
24
+ constructor(markup: string) {
25
+ this.#markup = markup;
26
+ }
27
+
28
+ toString(): string {
29
+ return this.#markup;
30
+ }
31
+
32
+ /** This fragment with its origin moved to `x`, `y`. */
33
+ at(x: number, y: number): SvgFragment {
34
+ return svg`<g transform="translate(${x} ${y})">${this}</g>`;
35
+ }
36
+ }
37
+
38
+ function embed(value: unknown): string {
39
+ if (value instanceof SvgFragment || value instanceof Text) {
40
+ return value.toString();
41
+ }
42
+ if (Array.isArray(value)) {
43
+ return value.map(embed).join("");
44
+ }
45
+ return escape(String(value));
46
+ }
47
+
48
+ /** SVG markup with values embedded: strings escaped, fragments, texts and arrays of them as they are. */
49
+ export function svg(strings: TemplateStringsArray, ...values: unknown[]): SvgFragment {
50
+ return new SvgFragment(strings.reduce((markup, string, index) => markup + embed(values[index - 1]) + string));
51
+ }
52
+
53
+ // shortcut: the common wide ranges (CJK, kana, hangul, fullwidth forms), not every East Asian Wide character; widen it if a script measures short.
54
+ const WIDE = /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]|[\u{20000}-\u{3fffd}]/u;
55
+
56
+ /** Where a text goes: one of `left`, `cx`, `right` across, one of `top`, `cy`, `bottom` down. */
57
+ export type TextPlace = ({ left: number } | { cx: number } | { right: number }) &
58
+ ({ top: number } | { cy: number } | { bottom: number });
59
+
60
+ /** A line of text, sized the way nicevg measures text, placed with `at`. */
61
+ export class Text {
62
+ /** Its width, wide characters one font size each and others 0.59. */
63
+ readonly width: number;
64
+ /** Its line's height, 1.2 font sizes. */
65
+ readonly height: number;
66
+
67
+ constructor(
68
+ readonly content: string,
69
+ readonly size = 14,
70
+ readonly place: TextPlace = { left: 0, top: 0 },
71
+ ) {
72
+ let width = 0;
73
+ for (const character of content) {
74
+ width += WIDE.test(character) ? size : character.length * size * 0.59;
75
+ }
76
+ this.width = Math.ceil(width);
77
+ this.height = Math.ceil(size * 1.2);
78
+ }
79
+
80
+ /** This text with the point of its box named in `place` at the coordinates given. */
81
+ at(place: TextPlace): Text {
82
+ const across = ["left", "cx", "right"].filter((key) => key in place);
83
+ const down = ["top", "cy", "bottom"].filter((key) => key in place);
84
+ if (across.length !== 1 || down.length !== 1) {
85
+ throw new TypeError(
86
+ `text "${this.content}": at() takes one of left, cx, right and one of top, cy, bottom, not ${Object.keys(place).join(", ") || "none"}`,
87
+ );
88
+ }
89
+ return new Text(this.content, this.size, place);
90
+ }
91
+
92
+ /** The `<text>` element, centred on its line by `dominant-baseline`. */
93
+ toString(): string {
94
+ const place = this.place as Record<string, number>;
95
+ const [x, anchor] =
96
+ "cx" in place ? [place.cx!, "middle"] : "right" in place ? [place.right!, "end"] : [place.left!, "start"];
97
+ const y = "cy" in place ? place.cy! : "top" in place ? place.top! + this.height / 2 : place.bottom! - this.height / 2;
98
+ return `<text x="${x}" y="${y}" font-size="${this.size}" text-anchor="${anchor}" dominant-baseline="central">${escape(this.content)}</text>`;
99
+ }
100
+ }
101
+
102
+ /** A line of text at `size` (14 by default), to embed in `svg` once placed with `at`. */
103
+ export function text(content: string, size = 14): Text {
104
+ return new Text(content, size);
105
+ }
package/src/index.ts CHANGED
@@ -23,8 +23,11 @@
23
23
 
24
24
  import { writeFile } from "node:fs/promises";
25
25
 
26
+ import type { SvgFragment } from "./draw.ts";
26
27
  import { call as native } from "./native.ts";
27
28
 
29
+ export { SvgFragment, Text, type TextPlace, svg, text } from "./draw.ts";
30
+
28
31
  export interface Point {
29
32
  x: number;
30
33
  y: number;
@@ -69,14 +72,48 @@ export interface SketchConnector {
69
72
  fontSize?: number;
70
73
  }
71
74
 
72
- /** A diagram as nodes and the connectors between them. */
73
- export interface Sketch {
74
- nodes: SketchNode[];
75
+ /**
76
+ * A node drawn by the style its `class` names, with its origin at `x`, `y`.
77
+ * It lies around what the style draws, and rendering moves it but never
78
+ * resizes it. Its other fields, such as `label`, are for the style to use.
79
+ */
80
+ export interface StyledNode {
81
+ id: string;
82
+ x: number;
83
+ y: number;
84
+ class: string;
85
+ /** The node this one sits inside. */
86
+ parent?: string;
87
+ /** Whether it may overlap other nodes on purpose. */
88
+ allowOverlap?: boolean;
89
+ [field: string]: unknown;
90
+ }
91
+
92
+ /** Draws a styled node's picture, from its own origin, with `svg` and `text`. */
93
+ export type Style<Node extends StyledNode = StyledNode> = (node: Node) => SvgFragment;
94
+
95
+ /** A style for each class the nodes name, each given the nodes of its class. */
96
+ export type Styles<Node extends SketchNode | StyledNode> = {
97
+ [Class in Extract<Node, StyledNode>["class"]]: Style<Extract<Node, { class: Class }>>;
98
+ };
99
+
100
+ /** A diagram as nodes and the edges (connectors) between them. */
101
+ export interface Sketch<Node extends SketchNode | StyledNode = SketchNode | StyledNode> {
102
+ nodes: readonly Node[];
103
+ edges?: SketchConnector[];
104
+ /** The same as `edges`, its name before; pass one or the other. */
75
105
  connectors?: SketchConnector[];
106
+ /** How to draw the nodes of each class. */
107
+ style?: Styles<NoInfer<Node>>;
108
+ /**
109
+ * Attributes for the root `<svg>`, such as `font-family`, which its texts
110
+ * inherit. nicevg writes `xmlns`, `viewBox`, `width` and `height` itself.
111
+ */
112
+ rootAttrs?: Record<string, string | number>;
76
113
  }
77
114
 
78
115
  /** A diagram to render: a sketch, SVG text following nicevg's SVG contract, or an Svg rendered before. */
79
- export type Input = Sketch | { svg: string } | Svg;
116
+ export type Input<Node extends SketchNode | StyledNode = SketchNode | StyledNode> = Sketch<Node> | { svg: string } | Svg;
80
117
 
81
118
  export interface DiagramNode {
82
119
  id: string;
@@ -164,8 +201,10 @@ export class NicevgError extends Error {
164
201
  * routes through nodes, stray labels and more), routes the connectors given
165
202
  * no points, and moves nodes a little where that reads better.
166
203
  */
167
- export function render(input: Input): Svg {
168
- const request = new TextEncoder().encode(JSON.stringify(input instanceof Svg ? { svg: input.toString() } : input));
204
+ export function render<const Node extends SketchNode | StyledNode>(input: Input<Node>): Svg {
205
+ const request = new TextEncoder().encode(
206
+ JSON.stringify(input instanceof Svg ? { svg: input.toString() } : "svg" in input ? input : sketchRequest(input)),
207
+ );
169
208
  const reply = JSON.parse(native(request)) as
170
209
  | { ok: { svg: string; changes: Change[]; report: AnalysisReport } }
171
210
  | { error: string };
@@ -174,3 +213,27 @@ export function render(input: Input): Svg {
174
213
  }
175
214
  return new Svg(reply.ok.svg, reply.ok.changes, reply.ok.report);
176
215
  }
216
+
217
+ /** The sketch as the native library reads it: styled nodes drawn, edges as connectors. */
218
+ function sketchRequest<Node extends SketchNode | StyledNode>({ nodes, edges, connectors, style, rootAttrs }: Sketch<Node>) {
219
+ // Each style is called only with nodes of its own class.
220
+ const styles = (style ?? {}) as Record<string, Style>;
221
+ if (edges && connectors) {
222
+ throw new NicevgError("edges and connectors are the same; pass one of them");
223
+ }
224
+ return {
225
+ nodes: nodes.map((node, index) => {
226
+ if (!("class" in node)) {
227
+ return node;
228
+ }
229
+ const draw = Object.hasOwn(styles, node.class) ? styles[node.class] : undefined;
230
+ if (!draw) {
231
+ throw new NicevgError(`nodes[${index}].class: no style named "${node.class}"`);
232
+ }
233
+ const { id, x, y, parent, allowOverlap } = node;
234
+ return { id, x, y, parent, allowOverlap, svg: String(draw(node)) };
235
+ }),
236
+ connectors: edges ?? connectors,
237
+ rootAttrs: rootAttrs && Object.fromEntries(Object.entries(rootAttrs).map(([name, value]) => [name, String(value)])),
238
+ };
239
+ }