@knowvah/dot-engine 1.8.0 → 1.9.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knowvah/dot-engine",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
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",
@@ -124,7 +124,7 @@
124
124
  },
125
125
  "overrides": {
126
126
  "postcss": "^8.5.25",
127
- "brace-expansion": "^5.0.9",
127
+ "brace-expansion": "^5.0.12",
128
128
  "vitepress": {
129
129
  "vite": "^6.4.3"
130
130
  },
@@ -0,0 +1,109 @@
1
+ // SPDX-License-Identifier: EPL-2.0
2
+ //
3
+ // CSS `font` shorthand for canvas text measurement. C graphviz has no canvas
4
+ // measurer; its text-layout plugin measures through the PostScript alias
5
+ // (plugin/pango/gvtextlayout_pango.c:112), and the SVG emitter renders through
6
+ // the same alias (svg_textspan). This builder yields the face the SVG renders
7
+ // with, so browser measurement and rendering agree. Selection follows the
8
+ // NATIVEFONTS branch of svg_textspan (plans/canvas-font-mapping ADR-1).
9
+
10
+ import { translatePostscriptFontname, type PostscriptAlias } from './ps-fontalias.js';
11
+ import type { TextVariantFlags } from './textmeasure.js';
12
+
13
+ /** svg_textspan's family when a span has no font name. */
14
+ const DEFAULT_FAMILIES: readonly string[] = ['Times', 'serif'];
15
+
16
+ const CSS_GENERIC_FAMILIES: ReadonlySet<string> = new Set([
17
+ 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui',
18
+ ]);
19
+
20
+ const CSS_WEIGHT_KEYWORDS: ReadonlySet<string> = new Set([
21
+ 'normal', 'bold', 'bolder', 'lighter',
22
+ ]);
23
+
24
+ const DQ = '\u0022';
25
+ const SQ = '\u0027';
26
+ const BACKSLASH = '\\';
27
+
28
+ /**
29
+ * True when `w` is a value CSS accepts for font-weight (ADR-2). Alias weights
30
+ * are a fixed keyword set (ps_font_equiv.h), never numeric, so only the
31
+ * keywords are checked.
32
+ */
33
+ function isCssWeight(w: string): boolean {
34
+ return CSS_WEIGHT_KEYWORDS.has(w.toLowerCase());
35
+ }
36
+
37
+ /** Remove one pair of matching surrounding quotes. */
38
+ function stripQuotes(f: string): string {
39
+ const q = f.charAt(0);
40
+ if (f.length >= 2 && (q === DQ || q === SQ) && f.endsWith(q)) return f.slice(1, -1);
41
+ return f;
42
+ }
43
+
44
+ /** Quote a family name unless it is a CSS generic family (ADR-4). */
45
+ function quoteFamily(f: string): string {
46
+ if (CSS_GENERIC_FAMILIES.has(f.toLowerCase())) return f;
47
+ const escaped = f.split(BACKSLASH).join(BACKSLASH + BACKSLASH)
48
+ .split(DQ).join(BACKSLASH + DQ);
49
+ return DQ + escaped + DQ;
50
+ }
51
+
52
+ /** Split a non-alias fontname into its family entries (ADR-4). */
53
+ function splitFamilies(fontname: string | null): readonly string[] {
54
+ const entries = (fontname ?? '').split(',')
55
+ .map((f) => stripQuotes(f.trim()).trim())
56
+ .filter((f) => f !== '');
57
+ return entries.length > 0 ? entries : DEFAULT_FAMILIES;
58
+ }
59
+
60
+ /**
61
+ * NATIVEFONTS family list: family, plus svg_font_family when it differs.
62
+ * @see plugin/core/gvrender_core_svg.c:487-489
63
+ */
64
+ function aliasFamilies(a: PostscriptAlias): readonly string[] {
65
+ return a.svgFontFamily !== a.family ? [a.family, a.svgFontFamily] : [a.family];
66
+ }
67
+
68
+ /** Style token: the alias style, else italic from the HTML_IF flag. */
69
+ function styleToken(a: PostscriptAlias | null, flags?: TextVariantFlags): string | null {
70
+ if (a?.style != null) return a.style;
71
+ return flags?.italic === true ? 'italic' : null;
72
+ }
73
+
74
+ /**
75
+ * Weight token. The bold flag applies only when the alias set no weight —
76
+ * tested on the alias value BEFORE the ADR-2 drop, as C's HTML_BF guard tests
77
+ * the raw alias weight (so `demi` + bold renders, and measures, normal).
78
+ */
79
+ function weightToken(a: PostscriptAlias | null, flags?: TextVariantFlags): string | null {
80
+ if (a?.weight != null) return isCssWeight(a.weight) ? a.weight : null;
81
+ return flags?.bold === true ? 'bold' : null;
82
+ }
83
+
84
+ /**
85
+ * style/weight/stretch tokens in shorthand order.
86
+ * @see plugin/core/gvrender_core_svg.c:490-500
87
+ */
88
+ function variantTokens(a: PostscriptAlias | null, flags?: TextVariantFlags): string[] {
89
+ const tokens = [styleToken(a, flags), weightToken(a, flags), a?.stretch ?? null];
90
+ return tokens.filter((t): t is string => t !== null);
91
+ }
92
+
93
+ /**
94
+ * CSS `font` shorthand (`[style] [weight] [stretch] <size>px <families>`)
95
+ * naming the face svg_textspan renders `fontname` with. Pure; always
96
+ * syntactically valid CSS.
97
+ * @see plugin/core/gvrender_core_svg.c:462-500 svg_textspan (family/weight/style selection)
98
+ */
99
+ export function canvasFont(
100
+ fontname: string | null,
101
+ fontsize: number,
102
+ flags?: TextVariantFlags,
103
+ ): string {
104
+ // C translate_postscript_fontname matches the whole name only.
105
+ const a = fontname !== null ? translatePostscriptFontname(fontname) : null;
106
+ const families = a !== null ? aliasFamilies(a) : splitFamilies(fontname);
107
+ const tokens = [...variantTokens(a, flags), `${fontsize}px`];
108
+ return `${tokens.join(' ')} ${families.map(quoteFamily).join(', ')}`;
109
+ }
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { type FontFamilyData } from "./textmeasure-lut-data.js";
11
11
  import { getFamilyMetrics, normalizeFontName } from "./textmeasure-lookup.js";
12
+ import { canvasFont } from "./css-font.js";
12
13
 
13
14
  /** Number of hard-coded font families in the LUT. */
14
15
  export const LUT_FAMILY_COUNT = 11;
@@ -264,12 +265,26 @@ export class LutTextMeasurer implements TextMeasurer {
264
265
  }
265
266
  }
266
267
 
268
+ /**
269
+ * Two distinct, always-valid fonts used to detect a rejected `ctx.font`
270
+ * assignment: a candidate is accepted when assigning it changes the read-back
271
+ * after either probe (it cannot serialize identically to both).
272
+ */
273
+ const FONT_PROBES: readonly string[] = ['1px serif', '2px monospace'];
274
+
267
275
  /**
268
276
  * Canvas-based TextMeasurer via CanvasRenderingContext2D.
269
- * Height is fontsize (matches C behavior).
277
+ * The font is the face the SVG emitter renders with (canvasFont), so browser
278
+ * measurement and rendering agree. Height is fontsize (matches C behavior).
270
279
  * @see lib/common/textspan.c:estimate_textspan_size
271
280
  */
272
281
  export class CanvasTextMeasurer implements TextMeasurer {
282
+ /**
283
+ * fontname|fontsize|bold|italic → accepted font string. Mirrors pango's
284
+ * font reuse (plugin/pango/gvtextlayout_pango.c:99-100).
285
+ */
286
+ private readonly fonts = new Map<string, string>();
287
+
273
288
  constructor(private readonly ctx: CanvasRenderingContext2D) {}
274
289
 
275
290
  measure(
@@ -278,16 +293,34 @@ export class CanvasTextMeasurer implements TextMeasurer {
278
293
  fontsize: number,
279
294
  flags?: TextVariantFlags,
280
295
  ): TextSize {
281
- const bold = flags?.bold === true;
282
- const italic = flags?.italic === true;
283
- const style = bold && italic ? 'bold italic'
284
- : bold ? 'bold'
285
- : italic ? 'italic'
286
- : '';
287
- this.ctx.font = style ? `${style} ${fontsize}px ${fontname}` : `${fontsize}px ${fontname}`;
296
+ this.ctx.font = this.fontFor(fontname, fontsize, flags);
288
297
  const m = this.ctx.measureText(text);
289
298
  return { w: m.width, h: fontsize };
290
299
  }
300
+
301
+ /** Cached font string; built and validated once per distinct font. */
302
+ private fontFor(fontname: string, fontsize: number, flags?: TextVariantFlags): string {
303
+ const key = `${fontname}|${fontsize}|${flags?.bold === true}|${flags?.italic === true}`;
304
+ let font = this.fonts.get(key);
305
+ if (font === undefined) {
306
+ font = canvasFont(fontname, fontsize, flags);
307
+ // A string the browser rejects is silently ignored, keeping the previous
308
+ // font AND size; fall back to the default family at the requested size.
309
+ if (!this.accepts(font)) font = canvasFont(null, fontsize, flags);
310
+ this.fonts.set(key, font);
311
+ }
312
+ return font;
313
+ }
314
+
315
+ /** True when the context accepts `font` as a `ctx.font` value. */
316
+ private accepts(font: string): boolean {
317
+ return FONT_PROBES.some((probe) => {
318
+ this.ctx.font = probe;
319
+ const before = this.ctx.font;
320
+ this.ctx.font = font;
321
+ return this.ctx.font !== before;
322
+ });
323
+ }
291
324
  }
292
325
 
293
326
  /** graphviz estimate_textspan_size line spacing. @see lib/common/const.h:70 */