@knowvah/dot-engine 1.1.1 → 1.2.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.
Files changed (61) hide show
  1. package/README.md +32 -17
  2. package/dist/api.js +24 -0
  3. package/dist/api.js.map +2 -2
  4. package/dist/common/arrows.d.ts.map +1 -1
  5. package/dist/common/edge-label-init.d.ts.map +1 -1
  6. package/dist/common/htmltable-emit.d.ts +0 -8
  7. package/dist/common/htmltable-emit.d.ts.map +1 -1
  8. package/dist/common/ps-fontalias.d.ts +22 -9
  9. package/dist/common/ps-fontalias.d.ts.map +1 -1
  10. package/dist/common/splines-clip.d.ts.map +1 -1
  11. package/dist/gvc/device.d.ts.map +1 -1
  12. package/dist/gvc/job.d.ts +4 -0
  13. package/dist/gvc/job.d.ts.map +1 -1
  14. package/dist/index.js +1981 -1533
  15. package/dist/index.js.map +4 -4
  16. package/dist/layout/dot/index.d.ts +1 -1
  17. package/dist/layout/dot/index.d.ts.map +1 -1
  18. package/dist/layout/sfdp/init.d.ts.map +1 -1
  19. package/dist/model/graph.d.ts +24 -0
  20. package/dist/model/graph.d.ts.map +1 -1
  21. package/dist/parser/builder.d.ts +32 -0
  22. package/dist/parser/builder.d.ts.map +1 -1
  23. package/dist/render/dot/agwrite.d.ts +186 -0
  24. package/dist/render/dot/agwrite.d.ts.map +1 -0
  25. package/dist/render/dot/attrs.d.ts +220 -0
  26. package/dist/render/dot/attrs.d.ts.map +1 -0
  27. package/dist/render/dot/edge-draw.d.ts +60 -0
  28. package/dist/render/dot/edge-draw.d.ts.map +1 -0
  29. package/dist/render/dot/types.d.ts +28 -0
  30. package/dist/render/dot/types.d.ts.map +1 -0
  31. package/dist/render/dot/xdot-ops.d.ts +206 -0
  32. package/dist/render/dot/xdot-ops.d.ts.map +1 -0
  33. package/dist/render/dot.d.ts +58 -277
  34. package/dist/render/dot.d.ts.map +1 -1
  35. package/dist/render/map.d.ts +14 -1
  36. package/dist/render/map.d.ts.map +1 -1
  37. package/dist/render/svg-graph.d.ts.map +1 -1
  38. package/dist/render.js +1942 -1494
  39. package/dist/render.js.map +4 -4
  40. package/package.json +1 -1
  41. package/src/common/arrows.ts +17 -4
  42. package/src/common/edge-label-init.ts +9 -2
  43. package/src/common/htmltable-emit.ts +36 -8
  44. package/src/common/poly-gencode.ts +8 -8
  45. package/src/common/ps-fontalias.ts +80 -49
  46. package/src/common/splines-clip.ts +36 -10
  47. package/src/gvc/device.ts +18 -6
  48. package/src/gvc/job.ts +4 -0
  49. package/src/layout/dot/index.ts +37 -5
  50. package/src/layout/sfdp/init.ts +11 -0
  51. package/src/model/graph.ts +26 -0
  52. package/src/parser/builder.ts +90 -5
  53. package/src/render/dot/agwrite.ts +506 -0
  54. package/src/render/dot/attrs.ts +437 -0
  55. package/src/render/dot/edge-draw.ts +203 -0
  56. package/src/render/dot/types.ts +32 -0
  57. package/src/render/dot/xdot-ops.ts +432 -0
  58. package/src/render/dot.ts +114 -1131
  59. package/src/render/map.ts +18 -1
  60. package/src/render/svg-graph.ts +4 -0
  61. package/src/render/svg-helpers.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knowvah/dot-engine",
3
- "version": "1.1.1",
3
+ "version": "1.2.1",
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",
@@ -14,7 +14,7 @@ import {
14
14
  ARR_MOD_OPEN, ARR_MOD_INV, ARR_MOD_LEFT, ARR_MOD_RIGHT,
15
15
  ARR_TYPE_NORM, ARR_TYPE_CROW, ARR_TYPE_TEE, ARR_TYPE_BOX,
16
16
  ARR_TYPE_DIAMOND, ARR_TYPE_DOT, ARR_TYPE_CURVE, ARR_TYPE_GAP,
17
- ARR_TYPE_MASK, ARR_LENFACT_BY_TYPE,
17
+ ARR_TYPE_MASK, ARR_LENFACT_BY_TYPE, NUMB_OF_ARROW_HEADS,
18
18
  } from './arrows-constants.js';
19
19
 
20
20
  // ---------------------------------------------------------------------------
@@ -125,13 +125,26 @@ const parseOne = (s: string): { comp: ArrowComponent; rest: string } | null => {
125
125
  export function parseArrow(str: string): ArrowComponent[] {
126
126
  const result: ArrowComponent[] = [];
127
127
  let s = str;
128
- while (s.length > 0) {
128
+ // C warns and returns on an unparseable fragment, leaving *flag as whatever
129
+ // was accumulated; only a name that fails on the FIRST fragment yields the
130
+ // "no arrow spec at all" case the caller's `normal` default covers.
131
+ let parsedAny = false;
132
+ // C's loop counter `i` advances only when a component is STORED, so a gap
133
+ // dropped by the rules below does not consume one of the four slots.
134
+ while (s.length > 0 && result.length < NUMB_OF_ARROW_HEADS) {
129
135
  const m = parseOne(s);
130
136
  if (!m) break;
131
- result.push(m.comp);
137
+ parsedAny = true;
132
138
  s = m.rest;
139
+ const isGap = m.comp.name === 'none';
140
+ // A GAP in the LAST slot is dropped — `arrowhead="teeteeteenone"` draws
141
+ // three arrows, not three plus a trailing spacer.
142
+ if (isGap && result.length === NUMB_OF_ARROW_HEADS - 1) continue;
143
+ // A GAP that is the entire spec is dropped (`arrowhead=none` → no arrow).
144
+ if (isGap && result.length === 0 && s.length === 0) continue;
145
+ result.push(m.comp);
133
146
  }
134
- if (result.length === 0) result.push({ name: 'normal', open: false, left: false, right: false });
147
+ if (!parsedAny) result.push({ name: 'normal', open: false, left: false, right: false });
135
148
  return result;
136
149
  }
137
150
 
@@ -145,8 +145,15 @@ function applyLabel(e: Edge, g: Graph, fi: FontInfo, measurer: TextMeasurer): vo
145
145
  e.info.label = makeAnyLabel(content, isHtml, fi, measurer, e);
146
146
  g.root.info.has_labels |= EDGE_LABEL;
147
147
  // ED_label_ontop: mapbool(late_string(e, E_label_float, "false"))
148
- // @see lib/common/utils.c:522
149
- e.info.label_ontop = mapbool(e.attrs.get('label_float')) ? 1 : 0;
148
+ //
149
+ // The user-facing attribute is `labelfloat`, with no underscore — that is the
150
+ // name E_label_float is BOUND from (input.c:770). `label_float` (underscore)
151
+ // appears only at dotsplines.c:738, where it is rebound against the internal
152
+ // aux graph. Reading the underscored name here honoured an attribute C
153
+ // ignores and ignored the one it honours; no corpus graph declares either, so
154
+ // no parity track could see it (test/corpus/attr-frequency.ts).
155
+ // @see lib/common/utils.c:522 · lib/common/input.c:770
156
+ e.info.label_ontop = mapbool(e.attrs.get('labelfloat')) ? 1 : 0;
150
157
  }
151
158
 
152
159
  // ---------------------------------------------------------------------------
@@ -258,6 +258,23 @@ export function emitHtmlImg(
258
258
  *
259
259
  * @see lib/common/htmltable.c:emit_html_cell
260
260
  */
261
+ /**
262
+ * True when an HTML element's STYLE declares it invisible.
263
+ *
264
+ * HTML STYLE is lexed by its OWN parser, not the node/edge one: tokens are
265
+ * split on space or comma, compared case-insensitively, and BOTH "INVIS" and
266
+ * "INVISIBLE" set the flag. (The node/edge parser splits on comma only and
267
+ * matches exact-case, so it cannot be reused here.)
268
+ * @see lib/common/htmllex.c:168 (DELIM " ,", strview_case_str_eq)
269
+ */
270
+ function htmlStyleInvisible(style: string | undefined): boolean {
271
+ if (style === undefined) return false;
272
+ return style.split(/[ ,]+/).some((tok) => {
273
+ const t = tok.toUpperCase();
274
+ return t === 'INVIS' || t === 'INVISIBLE';
275
+ });
276
+ }
277
+
261
278
  export function emitHtmlCell(
262
279
  cell: PlacedCell,
263
280
  pos: Point,
@@ -268,6 +285,12 @@ export function emitHtmlCell(
268
285
  { href: cell.href, title: cell.title, target: cell.target, id: cell.id },
269
286
  absBox(cell.box, pos), renderer, job,
270
287
  );
288
+ // An invisible cell emits no fill, no border and no content — only the
289
+ // anchor, which C opens before the gate. @see htmltable.c:emit_html_cell:643
290
+ if (htmlStyleInvisible(cell.style)) {
291
+ if (inAnchor) endHtmlAnchor(renderer, job);
292
+ return;
293
+ }
271
294
  emitCellDecoration(cell, pos, renderer, job);
272
295
  if (cell.nested !== undefined) {
273
296
  // C: emit_html_cell recurses emit_html_tbl for an HTML_TBL child.
@@ -329,14 +352,19 @@ export function emitHtmlLabel(
329
352
  // @see lib/common/htmltable.c:emit_html_tbl (537, 583-588)
330
353
  const clustersLast = (job.flags & EMIT_CLUSTERS_LAST) !== 0;
331
354
  const inAnchor = clustersLast ? false : initHtmlAnchor(anchorData, tblBox, renderer, job);
332
- if (placed.bgcolor !== undefined) {
333
- emitBgFill({ bgcolor: placed.bgcolor, box: placed.box, pos, border: placed.border,
334
- renderer, job, gradientangle: placed.gradientangle, style: placed.style });
335
- }
336
- for (const cell of placed.cells) emitHtmlCell(cell, pos, renderer, job);
337
- emitTableRules(placed, pos, renderer, job);
338
- if (placed.border > 0) {
339
- emitBorder({ box: placed.box, pos, border: placed.border, color: placed.color, sides: placed.sides, style: placed.style }, renderer, job);
355
+ // An invisible TABLE suppresses its fill, ALL its cells, and its rules and
356
+ // border — the whole block C guards at emit_html_tbl:542.
357
+ const tblInvisible = htmlStyleInvisible(placed.style);
358
+ if (!tblInvisible) {
359
+ if (placed.bgcolor !== undefined) {
360
+ emitBgFill({ bgcolor: placed.bgcolor, box: placed.box, pos, border: placed.border,
361
+ renderer, job, gradientangle: placed.gradientangle, style: placed.style });
362
+ }
363
+ for (const cell of placed.cells) emitHtmlCell(cell, pos, renderer, job);
364
+ emitTableRules(placed, pos, renderer, job);
365
+ if (placed.border > 0) {
366
+ emitBorder({ box: placed.box, pos, border: placed.border, color: placed.color, sides: placed.sides, style: placed.style }, renderer, job);
367
+ }
340
368
  }
341
369
  if (inAnchor) endHtmlAnchor(renderer, job);
342
370
  if (clustersLast && initHtmlAnchor(anchorData, tblBox, renderer, job)) {
@@ -33,7 +33,7 @@ import {
33
33
  styleTokens,
34
34
  } from './style-resolve.js';
35
35
  import { stripedBox, wedgedEllipse } from '../render/svg-multicolor.js';
36
- import { resolveRenderColor, withColorScheme } from '../render/color-resolve.js';
36
+ import { resolveRenderColor } from '../render/color-resolve.js';
37
37
  import { svgNodeId } from '../render/svg-id.js';
38
38
  import { drawRoundCorners, mcircleHack, underlineDraw } from './poly-shapes.js';
39
39
 
@@ -349,13 +349,13 @@ function applyPenState(obj: ObjState, styleAttr: string | undefined,
349
349
  export function applyNodeStyle(obj: ObjState, n: Node, job: RenderJob): boolean {
350
350
  const styleAttr = nodeAttr(n, n.root, 'style');
351
351
  const colorAttr = nodeAttr(n, n.root, 'color');
352
- // C wraps each node's color block with setColorScheme so a `colorscheme`
353
- // attr applies to bare scheme indices. @see lib/common/emit.c:1781/1789
354
- return withColorScheme(nodeAttr(n, n.root, 'colorscheme'), () => {
355
- const filled = applyFillState(obj, n, job);
356
- applyPenState(obj, styleAttr, colorAttr, nodeAttr(n, n.root, 'penwidth'));
357
- return filled;
358
- });
352
+ // The node's `colorscheme` window is NOT opened here: C opens it once around
353
+ // the whole node emission (emit_begin_node..emit_end_node), which covers both
354
+ // this color block and the label textspans. Both callers of this function are
355
+ // codefns, so they already run inside that window. @see gvc/device.ts
356
+ const filled = applyFillState(obj, n, job);
357
+ applyPenState(obj, styleAttr, colorAttr, nodeAttr(n, n.root, 'penwidth'));
358
+ return filled;
359
359
  }
360
360
 
361
361
  /**
@@ -15,66 +15,77 @@
15
15
  // "Times New Roman" on _WIN32. The oracle and goldens are non-Windows, so the
16
16
  // non-Windows family strings are used here.
17
17
 
18
+ import { FontnameKind } from '../model/layoutParams.js';
19
+
18
20
  /**
19
- * One PostScript-font alias entry (the fields the SVG renderer uses in the
20
- * default NATIVEFONTS mode). `xfig_code` and the SVGFONTS-only svg_font_weight/
21
- * svg_font_style fields are intentionally omitted — unused for SVG output.
21
+ * One PostScript-font alias entry. `xfig_code` is omitted (xfig output is a
22
+ * non-goal); every other field is carried, because the `fontnames` graph
23
+ * attribute selects WHICH of them the SVG emitter uses.
22
24
  * @see lib/common/textspan.h _PostscriptAlias
23
25
  */
24
26
  export interface PostscriptAlias {
27
+ /** The PostScript name itself — the family under PSFONTS. */
28
+ name: string;
25
29
  family: string;
26
30
  weight: string | null;
27
31
  stretch: string | null;
28
32
  style: string | null;
29
33
  svgFontFamily: string;
34
+ svgFontWeight: string | null;
35
+ svgFontStyle: string | null;
30
36
  }
31
37
 
32
38
  // Rows ported verbatim from ps_font_equiv.h (macros resolved, non-Windows):
33
- // [name, family, weight, stretch, style, svgFontFamily].
39
+ // [name, family, weight, stretch, style, svgFontFamily, svgFontWeight,
40
+ // svgFontStyle].
34
41
  const ALIAS_ROWS: readonly (readonly [
35
42
  string, string, string | null, string | null, string | null, string,
43
+ string | null, string | null,
36
44
  ])[] = [
37
- ['AvantGarde-Book', 'URW Gothic L', 'book', null, null, 'sans-Serif'],
38
- ['AvantGarde-BookOblique', 'URW Gothic L', 'book', null, 'oblique', 'sans-Serif'],
39
- ['AvantGarde-Demi', 'URW Gothic L', 'demi', null, null, 'sans-Serif'],
40
- ['AvantGarde-DemiOblique', 'URW Gothic L', 'demi', null, 'oblique', 'sans-Serif'],
41
- ['Bookman-Demi', 'URW Bookman L', 'demi', null, null, 'serif'],
42
- ['Bookman-DemiItalic', 'URW Bookman L', 'demi', null, 'italic', 'serif'],
43
- ['Bookman-Light', 'URW Bookman L', 'light', null, null, 'serif'],
44
- ['Bookman-LightItalic', 'URW Bookman L', 'light', null, 'italic', 'serif'],
45
- ['Courier', 'Courier', null, null, null, 'monospace'],
46
- ['Courier-Bold', 'Courier', 'bold', null, null, 'monospace'],
47
- ['Courier-BoldOblique', 'Courier', 'bold', null, 'oblique', 'monospace'],
48
- ['Courier-Oblique', 'Courier', null, null, 'oblique', 'monospace'],
49
- ['Helvetica', 'Helvetica', null, null, null, 'sans-Serif'],
50
- ['Helvetica-Bold', 'Helvetica', 'bold', null, null, 'sans-Serif'],
51
- ['Helvetica-BoldOblique', 'Helvetica', 'bold', null, 'oblique', 'sans-Serif'],
52
- ['Helvetica-Narrow', 'Helvetica', null, 'condensed', null, 'sans-Serif'],
53
- ['Helvetica-Narrow-Bold', 'Helvetica', 'bold', 'condensed', null, 'sans-Serif'],
54
- ['Helvetica-Narrow-BoldOblique', 'Helvetica', 'bold', 'condensed', 'oblique', 'sans-Serif'],
55
- ['Helvetica-Narrow-Oblique', 'Helvetica', null, 'condensed', 'oblique', 'sans-Serif'],
56
- ['Helvetica-Oblique', 'Helvetica', null, null, 'oblique', 'sans-Serif'],
57
- ['NewCenturySchlbk-Bold', 'Century Schoolbook L', 'bold', null, null, 'serif'],
58
- ['NewCenturySchlbk-BoldItalic', 'Century Schoolbook L', 'bold', null, 'italic', 'serif'],
59
- ['NewCenturySchlbk-Italic', 'Century Schoolbook L', null, null, 'italic', 'serif'],
60
- ['NewCenturySchlbk-Roman', 'Century Schoolbook L', 'roman', null, null, 'serif'],
61
- ['Palatino-Bold', 'Palatino Linotype', 'bold', null, null, 'serif'],
62
- ['Palatino-BoldItalic', 'Palatino Linotype', 'bold', null, 'italic', 'serif'],
63
- ['Palatino-Italic', 'Palatino Linotype', null, null, 'italic', 'serif'],
64
- ['Palatino-Roman', 'Palatino Linotype', 'roman', null, null, 'serif'],
65
- ['Symbol', 'Symbol', null, null, null, 'fantasy'],
66
- ['Times-Bold', 'Times', 'bold', null, null, 'serif'],
67
- ['Times-BoldItalic', 'Times', 'bold', null, 'italic', 'serif'],
68
- ['Times-Italic', 'Times', null, null, 'italic', 'serif'],
69
- ['Times-Roman', 'Times', null, null, null, 'serif'],
70
- ['ZapfChancery-MediumItalic', 'URW Chancery L', 'medium', null, 'italic', 'serif'],
71
- ['ZapfDingbats', 'Dingbats', null, null, null, 'fantasy'],
45
+ ['AvantGarde-Book', 'URW Gothic L', 'book', null, null, 'sans-Serif', null, null],
46
+ ['AvantGarde-BookOblique', 'URW Gothic L', 'book', null, 'oblique', 'sans-Serif', null, 'italic'],
47
+ ['AvantGarde-Demi', 'URW Gothic L', 'demi', null, null, 'sans-Serif', 'bold', null],
48
+ ['AvantGarde-DemiOblique', 'URW Gothic L', 'demi', null, 'oblique', 'sans-Serif', 'bold', 'italic'],
49
+ ['Bookman-Demi', 'URW Bookman L', 'demi', null, null, 'serif', 'bold', null],
50
+ ['Bookman-DemiItalic', 'URW Bookman L', 'demi', null, 'italic', 'serif', 'bold', 'italic'],
51
+ ['Bookman-Light', 'URW Bookman L', 'light', null, null, 'serif', null, null],
52
+ ['Bookman-LightItalic', 'URW Bookman L', 'light', null, 'italic', 'serif', null, 'italic'],
53
+ ['Courier', 'Courier', null, null, null, 'monospace', null, null],
54
+ ['Courier-Bold', 'Courier', 'bold', null, null, 'monospace', 'bold', null],
55
+ ['Courier-BoldOblique', 'Courier', 'bold', null, 'oblique', 'monospace', 'bold', 'italic'],
56
+ ['Courier-Oblique', 'Courier', null, null, 'oblique', 'monospace', null, 'italic'],
57
+ ['Helvetica', 'Helvetica', null, null, null, 'sans-Serif', null, null],
58
+ ['Helvetica-Bold', 'Helvetica', 'bold', null, null, 'sans-Serif', 'bold', null],
59
+ ['Helvetica-BoldOblique', 'Helvetica', 'bold', null, 'oblique', 'sans-Serif', 'bold', 'italic'],
60
+ ['Helvetica-Narrow', 'Helvetica', null, 'condensed', null, 'sans-Serif', null, null],
61
+ ['Helvetica-Narrow-Bold', 'Helvetica', 'bold', 'condensed', null, 'sans-Serif', 'bold', null],
62
+ ['Helvetica-Narrow-BoldOblique', 'Helvetica', 'bold', 'condensed', 'oblique', 'sans-Serif', 'bold', 'italic'],
63
+ ['Helvetica-Narrow-Oblique', 'Helvetica', null, 'condensed', 'oblique', 'sans-Serif', null, 'italic'],
64
+ ['Helvetica-Oblique', 'Helvetica', null, null, 'oblique', 'sans-Serif', null, 'italic'],
65
+ ['NewCenturySchlbk-Bold', 'Century Schoolbook L', 'bold', null, null, 'serif', 'bold', null],
66
+ ['NewCenturySchlbk-BoldItalic', 'Century Schoolbook L', 'bold', null, 'italic', 'serif', 'bold', 'italic'],
67
+ ['NewCenturySchlbk-Italic', 'Century Schoolbook L', null, null, 'italic', 'serif', null, 'italic'],
68
+ ['NewCenturySchlbk-Roman', 'Century Schoolbook L', 'roman', null, null, 'serif', null, null],
69
+ ['Palatino-Bold', 'Palatino Linotype', 'bold', null, null, 'serif', 'bold', null],
70
+ ['Palatino-BoldItalic', 'Palatino Linotype', 'bold', null, 'italic', 'serif', 'bold', 'italic'],
71
+ ['Palatino-Italic', 'Palatino Linotype', null, null, 'italic', 'serif', null, 'italic'],
72
+ ['Palatino-Roman', 'Palatino Linotype', 'roman', null, null, 'serif', null, null],
73
+ ['Symbol', 'Symbol', null, null, null, 'fantasy', null, null],
74
+ ['Times-Bold', 'Times', 'bold', null, null, 'serif', 'bold', null],
75
+ ['Times-BoldItalic', 'Times', 'bold', null, 'italic', 'serif', 'bold', 'italic'],
76
+ ['Times-Italic', 'Times', null, null, 'italic', 'serif', null, 'italic'],
77
+ ['Times-Roman', 'Times', null, null, null, 'serif', null, null],
78
+ ['ZapfChancery-MediumItalic', 'URW Chancery L', 'medium', null, 'italic', 'serif', null, 'italic'],
79
+ ['ZapfDingbats', 'Dingbats', null, null, null, 'fantasy', null, null],
72
80
  ];
73
81
 
74
82
  const ALIAS_BY_NAME: ReadonlyMap<string, PostscriptAlias> = new Map(
75
83
  ALIAS_ROWS.map((r) => [
76
84
  r[0].toLowerCase(),
77
- { family: r[1], weight: r[2], stretch: r[3], style: r[4], svgFontFamily: r[5] },
85
+ {
86
+ name: r[0], family: r[1], weight: r[2], stretch: r[3], style: r[4],
87
+ svgFontFamily: r[5], svgFontWeight: r[6], svgFontStyle: r[7],
88
+ },
78
89
  ]),
79
90
  );
80
91
 
@@ -89,24 +100,44 @@ export function translatePostscriptFontname(name: string): PostscriptAlias | nul
89
100
 
90
101
  /**
91
102
  * Build the SVG font attribute string for a span's font name when it resolves
92
- * to a PostScript alias: ` font-family="family[,svgFontFamily]"` plus the
93
- * alias's weight/stretch/style. Returns null when there is no alias (the caller
94
- * emits the name verbatim). `weight`/`style` report whether the alias set them,
95
- * so the HTML-flag block can avoid duplicating bold/italic.
103
+ * to a PostScript alias, honouring the graph's `fontnames` mode. Returns null
104
+ * when there is no alias (the caller emits the name verbatim).
105
+ * `weight`/`style` report whether the alias set them, so the HTML-flag block
106
+ * can avoid duplicating bold/italic.
107
+ *
108
+ * C selects family/weight/style as a THREE-WAY switch on GD_fontnames, and the
109
+ * SVGFONTS branch reads different struct fields (svg_font_weight /
110
+ * svg_font_style) rather than reusing the native ones — so the mode changes
111
+ * more than the family string. The `,svgFontFamily` suffix is appended by the
112
+ * same rule in every mode: only when it differs from the chosen family, which
113
+ * is exactly why SVGFONTS emits a bare `sans-Serif` and NATIVEFONTS emits
114
+ * `Helvetica,sans-Serif`.
96
115
  *
97
116
  * Family strings contain no XML-special characters, so (like C's raw `%s`) no
98
117
  * escaping is applied here.
99
- * @see plugin/core/gvrender_core_svg.c:462-495 svg_textspan (NATIVEFONTS)
118
+ * @see plugin/core/gvrender_core_svg.c:462-495 svg_textspan
100
119
  */
101
120
  export function fontFamilyAttrs(
102
121
  fontName: string | null,
122
+ mode: FontnameKind = FontnameKind.NativeFonts,
103
123
  ): { attrs: string; weight: boolean; style: boolean } | null {
104
124
  const a = fontName !== null ? translatePostscriptFontname(fontName) : null;
105
125
  if (a === null) return null;
106
- const fam = a.svgFontFamily !== a.family ? a.family + ',' + a.svgFontFamily : a.family;
126
+ let family: string;
127
+ let weight: string | null;
128
+ let style: string | null;
129
+ switch (mode) {
130
+ case FontnameKind.PsFonts:
131
+ family = a.name; weight = a.weight; style = a.style; break;
132
+ case FontnameKind.SvgFonts:
133
+ family = a.svgFontFamily; weight = a.svgFontWeight; style = a.svgFontStyle; break;
134
+ default:
135
+ family = a.family; weight = a.weight; style = a.style; break;
136
+ }
137
+ const fam = a.svgFontFamily !== family ? family + ',' + a.svgFontFamily : family;
107
138
  let attrs = ' font-family="' + fam + '"';
108
- if (a.weight !== null) attrs += ' font-weight="' + a.weight + '"';
139
+ if (weight !== null) attrs += ' font-weight="' + weight + '"';
109
140
  if (a.stretch !== null) attrs += ' font-stretch="' + a.stretch + '"';
110
- if (a.style !== null) attrs += ' font-style="' + a.style + '"';
111
- return { attrs, weight: a.weight !== null, style: a.style !== null };
141
+ if (style !== null) attrs += ' font-style="' + style + '"';
142
+ return { attrs, weight: weight !== null, style: style !== null };
112
143
  }
@@ -97,6 +97,22 @@ class SplineClipHelper {
97
97
  return [sflag, eflag];
98
98
  }
99
99
 
100
+ /**
101
+ * The ORIGINAL edge's arrow end whose TYPE applies at a geometric spline end.
102
+ *
103
+ * C resolves both arrow types once from the original edge (arrow_flags) and
104
+ * encodes each type in its flag, so swapping the two flags when swapEnds is
105
+ * true carries the type along with it. This port keeps only the presence bit
106
+ * in the flag and re-reads the type from the `arrowhead`/`arrowtail`
107
+ * attribute, so the same swap has to be applied to the NAME lookup — without
108
+ * it a reversed back edge (`C->A [arrowhead=tee]`, head ranked above tail)
109
+ * draws its `arrowtail` type at the end where C draws the arrowhead.
110
+ * @see lib/common/splines.c:arrow_clip (if (j) SWAP(sflag, eflag))
111
+ */
112
+ static arrowAttrEnd(isTail: boolean, j: boolean): 'head' | 'tail' {
113
+ return isTail !== j ? 'tail' : 'head';
114
+ }
115
+
100
116
  static resolveArrowFlags(info: SplineInfo, hn: Node, fe: Edge, orig: Edge, j: boolean): [number, number] {
101
117
  let [sflag, eflag] = SplineClipHelper.arrowFlags(orig);
102
118
  if (info.splineMerge != null) {
@@ -147,9 +163,10 @@ class SplineClipHelper {
147
163
  */
148
164
  static arrowEndClip(
149
165
  e: Edge, ps: Point[], startp: number, endpIn: number, spl: Bezier, eflag: number,
166
+ attrEnd: 'head' | 'tail' = 'head',
150
167
  ): number {
151
168
  let endp = endpIn;
152
- const elen = arrowClipLength(edgeArrowName(e, 'head'),
169
+ const elen = arrowClipLength(edgeArrowName(e, attrEnd),
153
170
  SplineClipHelper.clipArrowsize(e), SplineClipHelper.clipPenwidth(e));
154
171
  spl.eflag = eflag;
155
172
  spl.ep = ps[endp + 3]!;
@@ -174,9 +191,10 @@ class SplineClipHelper {
174
191
  */
175
192
  static arrowStartClip(
176
193
  e: Edge, ps: Point[], startpIn: number, endp: number, spl: Bezier, sflag: number,
194
+ attrEnd: 'head' | 'tail' = 'tail',
177
195
  ): number {
178
196
  let startp = startpIn;
179
- const slen = arrowClipLength(edgeArrowName(e, 'tail'),
197
+ const slen = arrowClipLength(edgeArrowName(e, attrEnd),
180
198
  SplineClipHelper.clipArrowsize(e), SplineClipHelper.clipPenwidth(e));
181
199
  spl.sflag = sflag;
182
200
  spl.sp = ps[startp]!;
@@ -231,24 +249,29 @@ class SplineClipHelper {
231
249
  newspl.eflag = eflag;
232
250
  // C: ortho edges use arrowOrthoClip (keeps segments axis-aligned), not the
233
251
  // De-Casteljau start/end clips. @see lib/common/splines.c:90
252
+ // The arrow TYPE follows the flag swap, not the geometric end: see
253
+ // arrowAttrEnd. (Under ortho, ignoreSwap forces j=false, so these are the
254
+ // plain tail/head ends.)
255
+ const startEnd = SplineClipHelper.arrowAttrEnd(true, j);
256
+ const endEnd = SplineClipHelper.arrowAttrEnd(false, j);
234
257
  if (info.isOrtho) {
235
258
  if (sflag || eflag) {
236
259
  SplineClipHelper.arrowOrthoClip(
237
260
  orig, ps, bounds.start, bounds.end, newspl, sflag, eflag);
238
- if (sflag) SplineClipHelper.stashArrow(orig, newspl.sp, ps[bounds.start]!, true);
239
- if (eflag) SplineClipHelper.stashArrow(orig, newspl.ep, ps[bounds.end + 3]!, false);
261
+ if (sflag) SplineClipHelper.stashArrow(orig, newspl.sp, ps[bounds.start]!, true, startEnd);
262
+ if (eflag) SplineClipHelper.stashArrow(orig, newspl.ep, ps[bounds.end + 3]!, false, endEnd);
240
263
  }
241
264
  return;
242
265
  }
243
266
  if (sflag) {
244
267
  bounds.start = SplineClipHelper.arrowStartClip(
245
- orig, ps, bounds.start, bounds.end, newspl, sflag);
246
- SplineClipHelper.stashArrow(orig, newspl.sp, ps[bounds.start]!, true);
268
+ orig, ps, bounds.start, bounds.end, newspl, sflag, startEnd);
269
+ SplineClipHelper.stashArrow(orig, newspl.sp, ps[bounds.start]!, true, startEnd);
247
270
  }
248
271
  if (eflag) {
249
272
  bounds.end = SplineClipHelper.arrowEndClip(
250
- orig, ps, bounds.start, bounds.end, newspl, eflag);
251
- SplineClipHelper.stashArrow(orig, newspl.ep, ps[bounds.end + 3]!, false);
273
+ orig, ps, bounds.start, bounds.end, newspl, eflag, endEnd);
274
+ SplineClipHelper.stashArrow(orig, newspl.ep, ps[bounds.end + 3]!, false, endEnd);
252
275
  }
253
276
  }
254
277
 
@@ -314,9 +337,12 @@ class SplineClipHelper {
314
337
  }
315
338
 
316
339
  /** Record an arrow polygon on the edge for svgArrowPolygons. */
317
- static stashArrow(e: Edge, tip: Point, base: Point, isTail: boolean): void {
340
+ static stashArrow(
341
+ e: Edge, tip: Point, base: Point, isTail: boolean, attrEnd?: 'head' | 'tail',
342
+ ): void {
318
343
  const dir = { x: base.x - tip.x, y: base.y - tip.y };
319
- const ops = arrowDrawOpsForEnd(e, isTail ? 'tail' : 'head', tip, dir, SplineClipHelper.renderPenwidth(e));
344
+ const end = attrEnd ?? (isTail ? 'tail' : 'head');
345
+ const ops = arrowDrawOpsForEnd(e, end, tip, dir, SplineClipHelper.renderPenwidth(e));
320
346
  if (isTail) e.info.tailArrowOps = ops;
321
347
  else e.info.headArrowOps = ops;
322
348
  }
package/src/gvc/device.ts CHANGED
@@ -173,11 +173,19 @@ function nodeInBox(n: Node, job: RenderJob): boolean {
173
173
  function emitNodeBody(n: Node, renderer: RendererPlugin, job: RenderJob): void {
174
174
  setHtmlAnchorObj(svgNodeId(n, job), labelTextOf(n.info.label), job.obj ?? undefined);
175
175
  setHtmlObjImgscale(nodeAttr(n, n.root, 'imagescale'));
176
- renderer.beginNode(n, job);
177
- const shape = n.info.shape as ShapeDesc | undefined;
178
- if (shape?.fns?.codefn) shape.fns.codefn(job, n);
179
- renderNodeXLabel(n, renderer, job);
180
- renderer.endNode(n, job);
176
+ // C sets the node's colorscheme at the tail of emit_begin_node and restores
177
+ // it at the head of emit_end_node, so the window spans codefn AND the xlabel
178
+ // — not just the color block. Bare scheme indices in the node's LABEL (e.g.
179
+ // `fontcolor=3` under `colorscheme=rdbu5`) resolve inside this window; with
180
+ // the window scoped to the fill/pen block they fell back to black.
181
+ // @see lib/common/emit.c:1781/1789 · emit_node:1826-1831
182
+ withColorScheme(nodeAttr(n, n.root, 'colorscheme'), () => {
183
+ renderer.beginNode(n, job);
184
+ const shape = n.info.shape as ShapeDesc | undefined;
185
+ if (shape?.fns?.codefn) shape.fns.codefn(job, n);
186
+ renderNodeXLabel(n, renderer, job);
187
+ renderer.endNode(n, job);
188
+ });
181
189
  }
182
190
 
183
191
  /**
@@ -193,8 +201,12 @@ export function renderNode(n: Node, renderer: RendererPlugin, job: RenderJob, do
193
201
  // "and is in page/view" gate. @see lib/common/emit.c:1808 node_in_box
194
202
  if (!nodeInBox(n, job)) return;
195
203
  // Shortcircuit invisible nodes: C omits the whole node before emit_begin_node.
204
+ // C reads late_string(n, N_style, ""), i.e. agxget — which resolves the
205
+ // node's DECLARATION-SCOPE default, so `{ node [style=invis] p q }` hides p
206
+ // and q. Reading n.attrs directly sees only a style set on the node itself
207
+ // and drew every subgraph-scoped invisible node (switch.gv: 16 of them).
196
208
  // @see lib/common/emit.c:emit_node (style "invis" return)
197
- const nodeStyle = n.attrs.get('style');
209
+ const nodeStyle = nodeAttr(n, n.root, 'style');
198
210
  if (parseStyleFlags(nodeStyle).invis) return;
199
211
  // The "invisible" alias also suppresses drawing (gvrender_set_style →
200
212
  // PEN_NONE) for shapes that pass their raw style through — but NOT the point
package/src/gvc/job.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import type { Graph } from '../model/graph.js';
14
+ import { FontnameKind } from '../model/layoutParams.js';
14
15
  import type { Node } from '../model/node.js';
15
16
  import type { Edge } from '../model/edge.js';
16
17
  import type { Point, Box } from '../model/geom.js';
@@ -297,6 +298,9 @@ export class RenderJob {
297
298
  radialGradId: number = 0;
298
299
  /** Whether the graph being rendered is directed (edge titles use -> vs --). */
299
300
  directed: boolean = true;
301
+ /** GD_fontnames of the root graph — selects which PostScript-alias fields the
302
+ * SVG emitter uses. @see plugin/core/gvrender_core_svg.c:464 */
303
+ fontnames: FontnameKind = FontnameKind.NativeFonts;
300
304
  /** Root graph's `id` attribute (GD_drawing(root)->id), '' if unset. Used as
301
305
  * the `<gid>_` prefix for non-root object ids. @see lib/common/emit.c:getObjId */
302
306
  drawingId: string = '';
@@ -164,10 +164,37 @@ export function dotPhasePost(g: Graph): void {
164
164
  // dotLayoutPipeline — the static dotLayout function from dotinit.c
165
165
  // ---------------------------------------------------------------------------
166
166
 
167
+ /**
168
+ * How many pipeline phases to run: C's
169
+ * `late_int(g, agfindgraphattr(g, "phase"), -1, 1)`.
170
+ *
171
+ * The DOT attribute is `phase`; `maxphase` is only the name of C's local
172
+ * variable. The port read `maxphase` as the attribute name, so it responded to
173
+ * a name C ignores and ignored the one C honours — invisible to every parity
174
+ * track, because no corpus graph declares either (found via the attribute
175
+ * blind-spot scan, test/corpus/attr-frequency.ts).
176
+ *
177
+ * late_int's `minimum` argument is 1, and it clamps rather than rejects, so a
178
+ * present-but-smaller value becomes 1 — only an ABSENT or unparseable value
179
+ * yields the -1 "run everything" default.
180
+ * @see lib/dotgen/dotinit.c:297 · lib/common/utils.c:40 late_int
181
+ */
182
+ function graphPhase(g: Graph): number {
183
+ const v = g.attrs.get('phase');
184
+ if (v === undefined || v === '') return -1;
185
+ // strtol parses a numeric PREFIX, so "3abc" is 3, and parseInt agrees. The
186
+ // two failure exits return defaultValue WITHOUT clamping, so `phase=abc`
187
+ // runs the whole pipeline while `phase=0` clamps up to 1 — the distinction
188
+ // getAttrInt cannot express, since it collapses both onto defaultVal.
189
+ const n = parseInt(v, 10);
190
+ if (isNaN(n) || n > 2147483647) return -1;
191
+ return n < 1 ? 1 : n;
192
+ }
193
+
167
194
  /**
168
195
  * Runs the full dot layout pipeline on graph g.
169
196
  *
170
- * The optional `maxphase` attribute (read from g.attrs) controls how many
197
+ * The optional `phase` attribute (read from g.attrs) controls how many
171
198
  * phases run (1=rank only, 2=+mincross, 3=+position, ≥4 or absent=all).
172
199
  *
173
200
  * C's `dotLayout` checks `dot_mincross`'s return code and returns immediately
@@ -198,16 +225,21 @@ export function dotPhasePost(g: Graph): void {
198
225
  * where C produces near-empty output.
199
226
  */
200
227
  export function dotLayoutPipeline(g: Graph): void {
201
- const maxphase = getAttrInt(g, 'maxphase', -1);
228
+ const maxphase = graphPhase(g);
202
229
  dotPhaseInit(g);
203
230
  dotRank(g);
204
- if (maxphase === 1) return;
231
+ // A phase stop is a SUCCESS return in C (dotLayout returns 0), and
232
+ // `dot_layout` skips dotneato_postprocess only when doDot reports an ERROR
233
+ // (dotinit.c:512-517). So the postprocess still runs on every phase stop —
234
+ // without it the drawing keeps raw layout coordinates and is translated off
235
+ // the origin. The rc/posRc paths below are the error paths and stay bare.
236
+ if (maxphase === 1) { gvPostprocess(g); return; }
205
237
  const rc = dotMincross(g);
206
238
  if (rc !== 0) return;
207
- if (maxphase === 2) return;
239
+ if (maxphase === 2) { gvPostprocess(g); return; }
208
240
  const posRc = dotPosition(g);
209
241
  if (posRc !== 0) return;
210
- if (maxphase === 3) return;
242
+ if (maxphase === 3) { gvPostprocess(g); return; }
211
243
  dotPhasePost(g);
212
244
  }
213
245
 
@@ -140,6 +140,17 @@ export function tuneControl(g: Graph, ctrl: SpringElectricalControl): void {
140
140
  ctrl.beautifyLeaves = mapBool(aggetGraph(g, 'beautify'), false);
141
141
  ctrl.doShrinking = mapBool(aggetGraph(g, 'overlap_shrink'), true);
142
142
  ctrl.rotation = lateDouble(aggetGraph(g, 'rotation'), 0.0, -Number.MAX_VALUE);
143
+ // C rotates the layout before overlap removal (spring_electrical.c:1181).
144
+ // `rotate()` is not ported, and nothing reads ctrl.rotation, so a non-zero
145
+ // value silently produced an UNROTATED layout — native moves nodes ~320pt on
146
+ // a 6-node graph at rotation=45. Fail the way `smoothing` above does: a loud
147
+ // unported-feature error beats silently wrong geometry.
148
+ // Found by the attribute blind-spot scan; no corpus graph sets it.
149
+ if (ctrl.rotation !== 0) {
150
+ throw new Error(
151
+ `sfdp rotation="${ctrl.rotation}": rotate() is not ported ` +
152
+ '(unreachable at sfdp defaults); see plans/port-catalog/README.md');
153
+ }
143
154
  ctrl.edgeLabelingScheme = lateInt(aggetGraph(g, 'label_scheme'), 0, 0);
144
155
  if (ctrl.edgeLabelingScheme > 4) ctrl.edgeLabelingScheme = 0;
145
156
  }
@@ -105,6 +105,32 @@ export class Graph {
105
105
  */
106
106
  graphDefaultsSnapshot?: Map<string, string>;
107
107
 
108
+ /**
109
+ * Keys in `attrs` the BUILDER seeded from `graphDefaultsSnapshot` rather than
110
+ * the input declaring them in this scope (see builder.ts GRAPH_LABEL_INHERIT_
111
+ * KEYS). cgraph distinguishes these structurally — a seeded value is inherited
112
+ * through the dict view and has no local symbol, while a local declaration
113
+ * creates one even when its value equals the inherited value — so the
114
+ * serializer needs the provenance to decide what write_dict would print.
115
+ * Undefined on the root graph and on any scope that seeded nothing.
116
+ * @see lib/cgraph/write.c:262 write_dict
117
+ */
118
+ seededAttrs?: Set<string>;
119
+
120
+ /**
121
+ * Graph-attribute keys whose declaration in THIS scope was the first anywhere
122
+ * in the graph. cgraph's `setattr` splits on exactly this: a key with no
123
+ * existing symbol takes the global-declaration branch, which ends up seeding
124
+ * every direct subgraph that already exists with a local empty value (see
125
+ * `eagerEmptyParts`); a key some earlier scope already declared takes the
126
+ * "new local definition" branch and touches no subgraph at all. So an
127
+ * identical `graph [fill=x]` statement makes a child print `fill=""` or print
128
+ * nothing purely according to whether a SIBLING declared `fill` first.
129
+ * Undefined on any scope that declared nothing new.
130
+ * @see lib/cgraph/attr.c:257 setattr (lsym / psym / global branches)
131
+ */
132
+ firstGraphDecl?: Set<string>;
133
+
108
134
  /**
109
135
  * Layout-engine info; replaces GD_* macros via agbindrec (AD-1).
110
136
  * @see lib/cgraph/cgraph.h:agbindrec