@knowvah/dot-engine 2.6.2 → 2.6.4

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.
Files changed (46) hide show
  1. package/dist/common/htmltable-lex.d.ts +38 -0
  2. package/dist/common/htmltable-lex.d.ts.map +1 -1
  3. package/dist/common/htmltable-parse-attrs.d.ts +41 -0
  4. package/dist/common/htmltable-parse-attrs.d.ts.map +1 -0
  5. package/dist/common/htmltable-parse.d.ts +3 -0
  6. package/dist/common/htmltable-parse.d.ts.map +1 -1
  7. package/dist/common/htmltable-pos.d.ts +17 -0
  8. package/dist/common/htmltable-pos.d.ts.map +1 -1
  9. package/dist/common/htmltable.d.ts +13 -1
  10. package/dist/common/htmltable.d.ts.map +1 -1
  11. package/dist/common/make-label.d.ts.map +1 -1
  12. package/dist/common/spline-midpoint.d.ts +8 -0
  13. package/dist/common/spline-midpoint.d.ts.map +1 -1
  14. package/dist/index.js +621 -323
  15. package/dist/index.js.map +4 -4
  16. package/dist/label/node.d.ts +7 -1
  17. package/dist/label/node.d.ts.map +1 -1
  18. package/dist/layout/circo/position-helpers.d.ts +15 -3
  19. package/dist/layout/circo/position-helpers.d.ts.map +1 -1
  20. package/dist/layout/circo/position.d.ts +17 -1
  21. package/dist/layout/circo/position.d.ts.map +1 -1
  22. package/dist/layout/dot/ortho-adapter.d.ts.map +1 -1
  23. package/dist/layout/dot/splines-flat-labeled.d.ts.map +1 -1
  24. package/dist/layout/neato/multispline.d.ts.map +1 -1
  25. package/dist/layout/pack/poly-pack.d.ts +2 -18
  26. package/dist/layout/pack/poly-pack.d.ts.map +1 -1
  27. package/dist/pathplan/shortest.d.ts.map +1 -1
  28. package/dist/render.js +774 -476
  29. package/dist/render.js.map +4 -4
  30. package/package.json +1 -1
  31. package/src/common/htmltable-lex.ts +265 -5
  32. package/src/common/htmltable-parse-attrs.ts +166 -0
  33. package/src/common/htmltable-parse.ts +262 -242
  34. package/src/common/htmltable-pos.ts +37 -38
  35. package/src/common/htmltable.ts +139 -45
  36. package/src/common/make-label.ts +19 -3
  37. package/src/common/spline-midpoint.ts +30 -9
  38. package/src/label/node.ts +8 -2
  39. package/src/layout/circo/position-helpers.ts +28 -17
  40. package/src/layout/circo/position.ts +28 -7
  41. package/src/layout/dot/__fixtures__/2796-flat-labeled-lost.dot +161 -0
  42. package/src/layout/dot/ortho-adapter.ts +20 -9
  43. package/src/layout/dot/splines-flat-labeled.ts +3 -1
  44. package/src/layout/neato/multispline.ts +17 -5
  45. package/src/layout/pack/poly-pack.ts +5 -31
  46. package/src/pathplan/shortest.ts +15 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knowvah/dot-engine",
3
- "version": "2.6.2",
3
+ "version": "2.6.4",
4
4
  "description": "Faithful TypeScript port of Graphviz (dot, neato, fdp, sfdp, circo, twopi, osage, patchwork) — no C: no native binary, no WASM. Oracle-verified against the C implementation, runs in the browser.",
5
5
  "keywords": [
6
6
  "graphviz",
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { HtmlParseError } from './htmltable-types.js';
15
15
  import type { Token } from './htmltable-types.js';
16
- import { htmlEntityUTF8 } from './html-entities.js';
16
+ import { HTML_ENTITIES, htmlEntityUTF8 } from './html-entities.js';
17
17
 
18
18
  export type { Token } from './htmltable-types.js';
19
19
 
@@ -140,12 +140,272 @@ function parseOpenToken(inner: string) {
140
140
  return { type: 'open' as const, tag: normalizeTag(tag), attrs: parseAttrs(inner.slice(sp + 1)) };
141
141
  }
142
142
 
143
- /** @see lib/common/htmllex.c:findNext */
144
- export function tokenize(src: string) {
143
+ /**
144
+ * expat `XML_ErrorString` texts for the two errors the port detects. Taken
145
+ * from native dot's stderr (graphviz 16.1.0): expat's xmlparse.c is not
146
+ * vendored with the graphviz sources.
147
+ * @see lib/common/htmllex.c:htmllex (XML_ErrorString at the XML_Parse failure)
148
+ */
149
+ const XML_ERROR_UNDEFINED_ENTITY = 'undefined entity';
150
+ const XML_ERROR_TAG_MISMATCH = 'mismatched tag';
151
+ const XML_ERROR_INVALID_TOKEN = 'not well-formed (invalid token)';
152
+ const XML_ERROR_DUPLICATE_ATTRIBUTE = 'duplicate attribute';
153
+
154
+ /** Entities expat knows without a DTD; `scanEntity` leaves them as written. */
155
+ const XML_PREDEFINED_ENTITIES = new Set(['lt', 'gt', 'amp', 'quot', 'apos']);
156
+
157
+ const XML_NAME = /^[A-Za-z_:][\w.:-]*$/;
158
+
159
+ /**
160
+ * A label failure C reports on stderr before dropping the HTML label
161
+ * (`ctx->error`, parseHTML rv 3). `diagnostics` holds the lines in print
162
+ * order, each already carrying its `Error: ` prefix where C's agerrorf adds
163
+ * one; the caller prints them (one `console.error` each) and then C's
164
+ * `in label of ...` continuation (labels.c:make_label).
165
+ * @see lib/common/htmllex.c:htmllex
166
+ */
167
+ export class HtmlLexError extends HtmlParseError {
168
+ readonly diagnostics: readonly string[];
169
+
170
+ constructor(tag: string, message: string, diagnostics: readonly string[]) {
171
+ super(tag);
172
+ this.message = message;
173
+ this.diagnostics = diagnostics;
174
+ }
175
+ }
176
+
177
+ /**
178
+ * `agerrorf("%s in line %lu \n", msg, lineno)` followed by `error_context`.
179
+ * @see lib/common/htmllex.c:htmlerror
180
+ * @see lib/common/htmllex.c:error_context
181
+ */
182
+ export function htmlErrorInLine(message: string, line: number, context: string): HtmlLexError {
183
+ const text = `${message} in line ${line} `;
184
+ return new HtmlLexError(message, text, [`Error: ${text}`, `... ${context} ...`]);
185
+ }
186
+
187
+ /** The C lexer's current and previous "token" (raw chunk) plus open elements. */
188
+ interface ChunkState {
189
+ stack: string[];
190
+ prev: string;
191
+ curr: string;
192
+ }
193
+
194
+ /** Where a token came from: the raw chunk and the C `error_context` pair. */
195
+ interface ChunkInfo {
196
+ src: string;
197
+ pos: number;
198
+ end: number;
199
+ context: string;
200
+ }
201
+
202
+ const chunkInfo = new WeakMap<Token, ChunkInfo>();
203
+
204
+ /**
205
+ * expat's line number at `pos`: `\n`, `\r\n` and a lone `\r` each end a line.
206
+ * @see lib/common/htmllex.c:htmllineno
207
+ */
208
+ function lineAt(src: string, pos: number): number {
209
+ let line = 1;
210
+ for (let k = 0; k < pos; k++) {
211
+ if (src[k] === '\n' || (src[k] === '\r' && src[k + 1] !== '\n')) line++;
212
+ }
213
+ return line;
214
+ }
215
+
216
+ /**
217
+ * `XML_GetCurrentLineNumber` after the chunk that produced `tok`: a tag's own
218
+ * line, or for character data the line of expat's last data piece (pieces
219
+ * split at each newline).
220
+ */
221
+ function chunkLine(info: ChunkInfo): number {
222
+ if (info.src[info.pos] === '<') return lineAt(info.src, info.pos);
223
+ const last = info.src.lastIndexOf('\n', info.end - 1);
224
+ if (last < info.pos) return lineAt(info.src, info.pos);
225
+ return lineAt(info.src, last === info.end - 1 ? last : last + 1);
226
+ }
227
+
228
+ /**
229
+ * C's `htmlerror` (yacc error) as it reads when the lexer last returned
230
+ * `tok`; undefined when the token has no recorded chunk.
231
+ * @see lib/common/htmllex.c:htmlerror
232
+ */
233
+ export function htmlErrorAtToken(message: string, tok: Token | undefined): HtmlLexError | undefined {
234
+ const info = tok === undefined ? undefined : chunkInfo.get(tok);
235
+ if (info === undefined) return undefined;
236
+ return htmlErrorInLine(message, chunkLine(info), info.context);
237
+ }
238
+
239
+ /** Extent of the next chunk: `<..>` or the characters up to the next `<`. */
240
+ function chunkEnd(src: string, i: number): number {
241
+ const j = src[i] === '<' ? src.indexOf('>', i + 1) : src.indexOf('<', i);
242
+ if (j < 0) return src.length;
243
+ return src[i] === '<' ? j + 1 : j;
244
+ }
245
+
246
+ /** Offset of the first `&name;` that scanEntity leaves for expat to reject. */
247
+ function findUndefinedEntity(text: string): number {
248
+ for (let t = text.indexOf('&'); t >= 0; t = text.indexOf('&', t + 1)) {
249
+ if (text[t + 1] === '#') continue;
250
+ const semi = text.indexOf(';', t + 1);
251
+ if (semi < 0) continue;
252
+ const key = text.slice(t + 1, semi);
253
+ if (XML_NAME.test(key) && !Object.hasOwn(HTML_ENTITIES, key) && !XML_PREDEFINED_ENTITIES.has(key)) {
254
+ return t;
255
+ }
256
+ }
257
+ return -1;
258
+ }
259
+
260
+ interface TagChunk {
261
+ close: boolean;
262
+ selfClose: boolean;
263
+ name: string;
264
+ }
265
+
266
+ /** Element name of a complete `<..>` chunk; undefined for comments etc. */
267
+ function parseTagChunk(chunk: string): TagChunk | undefined {
268
+ if (!chunk.endsWith('>')) return undefined;
269
+ const body = chunk.slice(1, -1);
270
+ if (body.startsWith('!')) return undefined;
271
+ const close = body.startsWith('/');
272
+ const name = /^[^\s/>]*/.exec(close ? body.slice(1) : body)?.[0] ?? '';
273
+ return { close, selfClose: !close && body.endsWith('/'), name };
274
+ }
275
+
276
+ interface AttrError {
277
+ message: string;
278
+ offset: number;
279
+ }
280
+
281
+ const ATTR_NAME = /^[A-Za-z_:][\w.:-]*/;
282
+ const isSpace = (c: string | undefined): boolean => c !== undefined && /\s/.test(c);
283
+
284
+ const invalidToken = (offset: number): AttrError => ({ message: XML_ERROR_INVALID_TOKEN, offset });
285
+
286
+ function skipSpace(chunk: string, i: number): number {
287
+ let j = i;
288
+ while (isSpace(chunk[j])) j++;
289
+ return j;
290
+ }
291
+
292
+ interface AttrPair {
293
+ name: string;
294
+ next: number;
295
+ }
296
+
297
+ /**
298
+ * The `name = "value"` pair at `i`, or the error expat raises there;
299
+ * undefined for an unterminated quote, which is not modelled.
300
+ */
301
+ function readAttrPair(chunk: string, i: number): AttrPair | AttrError | undefined {
302
+ const name = ATTR_NAME.exec(chunk.slice(i))?.[0];
303
+ if (name === undefined) return invalidToken(i);
304
+ let j = skipSpace(chunk, i + name.length);
305
+ if (chunk[j] !== '=') return invalidToken(j);
306
+ j = skipSpace(chunk, j + 1);
307
+ const q = chunk[j];
308
+ if (q !== '"' && q !== "'") return invalidToken(j);
309
+ const end = chunk.indexOf(q, j + 1);
310
+ return end < 0 ? undefined : { name, next: end + 1 };
311
+ }
312
+
313
+ /**
314
+ * Walk the `name="value"` pairs of an open tag as expat does and return the
315
+ * first well-formedness error with its offset in the chunk. A duplicate is
316
+ * only reported when no invalid token follows it, as expat checks duplicates
317
+ * after tokenizing the whole tag.
318
+ */
319
+ function findAttrError(chunk: string, from: number): AttrError | undefined {
320
+ const seen = new Set<string>();
321
+ let dup: AttrError | undefined;
322
+ let i = skipSpace(chunk, from);
323
+ while (chunk[i] !== '>' && !(chunk[i] === '/' && chunk[i + 1] === '>')) {
324
+ const pair = readAttrPair(chunk, i);
325
+ if (pair === undefined) return undefined;
326
+ if ('message' in pair) return pair;
327
+ if (seen.has(pair.name) && dup === undefined) dup = { message: XML_ERROR_DUPLICATE_ATTRIBUTE, offset: i };
328
+ seen.add(pair.name);
329
+ i = skipSpace(chunk, pair.next);
330
+ }
331
+ return dup;
332
+ }
333
+
334
+ /**
335
+ * Raise the error C hits for this tag chunk: expat's mismatched tag first
336
+ * (it is detected before the end handler runs), then `lexerror`'s unknown
337
+ * element.
338
+ * @see lib/common/htmllex.c:lexerror
339
+ */
340
+ function checkTagChunk(src: string, start: number, st: ChunkState): void {
341
+ const tag = parseTagChunk(st.curr);
342
+ if (tag === undefined) return;
343
+ if (tag.close) {
344
+ if (st.stack.length > 0 && st.stack.pop() !== tag.name) {
345
+ throw htmlErrorInLine(XML_ERROR_TAG_MISMATCH, lineAt(src, start), st.prev + st.curr);
346
+ }
347
+ } else {
348
+ const bad = findAttrError(st.curr, 1 + tag.name.length);
349
+ if (bad !== undefined) {
350
+ throw htmlErrorInLine(bad.message, lineAt(src, start + bad.offset), st.prev + st.curr);
351
+ }
352
+ if (!tag.selfClose) st.stack.push(tag.name);
353
+ }
354
+ if (KNOWN_TAGS.has(tag.name.toUpperCase())) return;
355
+ const text = `Unknown HTML element <${tag.name}> on line ${lineAt(src, start)} `;
356
+ throw new HtmlLexError(tag.name.toUpperCase(), text, [`Error: ${text}`]);
357
+ }
358
+
359
+ /** Raise the expat or lexer error C would hit while parsing this chunk. */
360
+ function checkChunk(src: string, start: number, st: ChunkState): void {
361
+ if (st.curr.startsWith('<')) {
362
+ checkTagChunk(src, start, st);
363
+ return;
364
+ }
365
+ const off = findUndefinedEntity(st.curr);
366
+ if (off >= 0) {
367
+ throw htmlErrorInLine(XML_ERROR_UNDEFINED_ENTITY, lineAt(src, start + off), st.prev + st.curr);
368
+ }
369
+ }
370
+
371
+ /** Tokens produced before the first lexical error, plus that error. */
372
+ export interface TokenizeResult {
373
+ tokens: Token[];
374
+ error?: HtmlParseError;
375
+ }
376
+
377
+ /**
378
+ * Tokenize until the first lexical error and return both. C interleaves
379
+ * lexing with the yacc parse, so a syntax error in the tokens before the
380
+ * failing chunk is reported in preference to the lexical error; callers that
381
+ * parse the partial stream get that precedence.
382
+ * @see lib/common/htmllex.c:findNext
383
+ */
384
+ export function tokenizeDeferred(src: string): TokenizeResult {
145
385
  const tokens: Token[] = [];
386
+ const st: ChunkState = { stack: [], prev: '', curr: '' };
146
387
  let i = 0;
147
- while (i < src.length) {
148
- i = src[i] === '<' ? scanTag(src, i, tokens) : scanText(src, i, tokens);
388
+ try {
389
+ while (i < src.length) {
390
+ const first = tokens.length;
391
+ const start = i;
392
+ st.prev = st.curr;
393
+ st.curr = src.slice(i, chunkEnd(src, i));
394
+ checkChunk(src, i, st);
395
+ i = src[i] === '<' ? scanTag(src, i, tokens) : scanText(src, i, tokens);
396
+ const info: ChunkInfo = { src, pos: start, end: i, context: st.prev + st.curr };
397
+ for (let k = first; k < tokens.length; k++) chunkInfo.set(tokens[k]!, info);
398
+ }
399
+ } catch (e) {
400
+ if (e instanceof HtmlParseError) return { tokens, error: e };
401
+ throw e;
149
402
  }
403
+ return { tokens };
404
+ }
405
+
406
+ /** @see lib/common/htmllex.c:findNext */
407
+ export function tokenize(src: string): Token[] {
408
+ const { tokens, error } = tokenizeDeferred(src);
409
+ if (error !== undefined) throw error;
150
410
  return tokens;
151
411
  }
@@ -0,0 +1,166 @@
1
+ // SPDX-License-Identifier: EPL-2.0
2
+ /**
3
+ * Attribute interpretation for the HTML-like label parser: the values
4
+ * mkTbl/mkCell/mkBR/mkFont read off a tag, and the table/cell records built
5
+ * from them.
6
+ *
7
+ * @see lib/common/htmllex.c:mkTbl
8
+ * @see lib/common/htmllex.c:mkCell
9
+ */
10
+
11
+ import type {
12
+ HtmlAlign,
13
+ HtmlCell,
14
+ HtmlCellContent,
15
+ HtmlRow,
16
+ HtmlTable,
17
+ HtmlVAlign,
18
+ } from './htmltable-types.js';
19
+ import {
20
+ BORDER_LEFT,
21
+ BORDER_TOP,
22
+ BORDER_RIGHT,
23
+ BORDER_BOTTOM,
24
+ BORDER_MASK,
25
+ } from './htmltable-types.js';
26
+ import { htmlEntityUTF8 } from './html-entities.js';
27
+
28
+ /** Decode XML/HTML entities in an anchor attribute value (href/target), as C's
29
+ * HTML parser does. The stored value is the real character (`&amp;` → `&`), so
30
+ * imap emits it raw and cmapx re-escapes it. Undefined passes through. */
31
+ function anchorAttr(v: string | undefined): string | undefined {
32
+ return v === undefined ? undefined : htmlEntityUTF8(v);
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Attribute helpers
37
+ // ---------------------------------------------------------------------------
38
+
39
+ export const parseAlign = (v: string): HtmlAlign | undefined => {
40
+ const u = v.toUpperCase();
41
+ if (u === 'LEFT') return 'left';
42
+ if (u === 'RIGHT') return 'right';
43
+ if (u === 'CENTER') return 'center';
44
+ return undefined;
45
+ };
46
+
47
+ export const parseVAlign = (v: string): HtmlVAlign | undefined => {
48
+ const u = v.toUpperCase();
49
+ if (u === 'TOP') return 'top';
50
+ if (u === 'BOTTOM') return 'bottom';
51
+ if (u === 'MIDDLE') return 'middle';
52
+ return undefined;
53
+ };
54
+
55
+ export const num = (s: string | undefined): number | undefined => {
56
+ if (s === undefined) return undefined;
57
+ const n = parseInt(s, 10);
58
+ return isNaN(n) ? undefined : n;
59
+ };
60
+
61
+ export const parseFixedSize = (a: Record<string, string>): true | undefined =>
62
+ a['fixedsize']?.toUpperCase() === 'TRUE' ? true : undefined;
63
+
64
+ export const parseTitle = (a: Record<string, string>): string | undefined =>
65
+ a['title'] !== undefined ? a['title'] : a['tooltip'];
66
+
67
+ /**
68
+ * Parse SIDES attribute into a bitmask matching C sidesfn().
69
+ * Unrecognised chars are silently skipped (C issues a warning but continues).
70
+ * C guard: if (flags != BORDER_MASK) p->flags |= flags — the full-mask case
71
+ * is a no-op (means "all sides", the default), so we return undefined for it.
72
+ * @see lib/common/htmllex.c:sidesfn
73
+ */
74
+ export const parseSides = (v: string | undefined): number | undefined => {
75
+ if (v === undefined) return undefined;
76
+ let flags = 0;
77
+ for (const c of v.toLowerCase()) {
78
+ if (c === 'l') flags |= BORDER_LEFT;
79
+ else if (c === 't') flags |= BORDER_TOP;
80
+ else if (c === 'r') flags |= BORDER_RIGHT;
81
+ else if (c === 'b') flags |= BORDER_BOTTOM;
82
+ }
83
+ // C: if (flags != BORDER_MASK) p->flags |= flags
84
+ if (flags === 0 || flags === BORDER_MASK) return undefined;
85
+ return flags;
86
+ };
87
+
88
+ /**
89
+ * Parse GRADIENTANGLE attribute — integer in [0, 360].
90
+ * Out-of-range or non-integer values are ignored (C issues a warning).
91
+ * @see lib/common/htmllex.c:gradientanglefn
92
+ */
93
+ export const parseGradientAngle = (v: string | undefined): number | undefined => {
94
+ if (v === undefined) return undefined;
95
+ const n = parseInt(v, 10);
96
+ if (isNaN(n) || String(n) !== v.trim()) return undefined;
97
+ if (n < 0 || n > 360) return undefined;
98
+ return n;
99
+ };
100
+
101
+ /**
102
+ * Parse COLUMNS attribute — only "*" is valid (sets vrule=true).
103
+ * @see lib/common/htmllex.c:columnsfn
104
+ */
105
+ export const parseColumns = (v: string | undefined): true | undefined =>
106
+ v === '*' ? true : undefined;
107
+
108
+ /**
109
+ * Parse ROWS attribute — only "*" is valid (sets hrule=true).
110
+ * @see lib/common/htmllex.c:rowsfn
111
+ */
112
+ export const parseRows = (v: string | undefined): true | undefined =>
113
+ v === '*' ? true : undefined;
114
+
115
+ export const buildCell = (content: HtmlCellContent[], a: Record<string, string>): HtmlCell => ({
116
+ kind: 'cell',
117
+ content,
118
+ port: a['port'],
119
+ align: parseAlign(a['align'] !== undefined ? a['align'] : ''),
120
+ valign: parseVAlign(a['valign'] !== undefined ? a['valign'] : ''),
121
+ balign: parseAlign(a['balign'] !== undefined ? a['balign'] : ''),
122
+ bgcolor: a['bgcolor'],
123
+ color: a['color'],
124
+ border: num(a['border']),
125
+ cellpadding: num(a['cellpadding']),
126
+ cellspacing: num(a['cellspacing']),
127
+ sides: parseSides(a['sides']),
128
+ gradientangle: parseGradientAngle(a['gradientangle']),
129
+ style: a['style'],
130
+ width: num(a['width']),
131
+ height: num(a['height']),
132
+ fixedsize: parseFixedSize(a),
133
+ colspan: num(a['colspan']),
134
+ rowspan: num(a['rowspan']),
135
+ href: anchorAttr(a['href']),
136
+ title: parseTitle(a),
137
+ target: anchorAttr(a['target']),
138
+ id: a['id'],
139
+ });
140
+
141
+ export const buildTable = (rows: HtmlRow[], a: Record<string, string>): HtmlTable => ({
142
+ kind: 'table' as const,
143
+ rows,
144
+ border: num(a['border']),
145
+ cellborder: num(a['cellborder']),
146
+ cellspacing: num(a['cellspacing']),
147
+ cellpadding: num(a['cellpadding']),
148
+ bgcolor: a['bgcolor'],
149
+ color: a['color'],
150
+ style: a['style'],
151
+ align: parseAlign(a['align'] !== undefined ? a['align'] : ''),
152
+ valign: parseVAlign(a['valign'] !== undefined ? a['valign'] : ''),
153
+ fixedsize: parseFixedSize(a),
154
+ width: num(a['width']),
155
+ height: num(a['height']),
156
+ href: anchorAttr(a['href']),
157
+ title: parseTitle(a),
158
+ target: anchorAttr(a['target']),
159
+ id: a['id'],
160
+ port: a['port'],
161
+ sides: parseSides(a['sides']),
162
+ gradientangle: parseGradientAngle(a['gradientangle']),
163
+ vrule: parseColumns(a['columns']),
164
+ hrule: parseRows(a['rows']),
165
+ });
166
+