jssm 5.159.0 → 5.159.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.
@@ -65,6 +65,24 @@ declare function vc(col: string): string;
65
65
  * @internal
66
66
  */
67
67
  declare function doublequote(txt: string): string;
68
+ /**
69
+ * Reverse {@link doublequote}: turn DOT's `\"` escape back into the literal
70
+ * `"` that graphviz renders into SVG `<text>` content. Used by
71
+ * {@link state_svg_label_texts} to reconstruct a node's on-screen label from
72
+ * the DOT label it was handed, so the fence renderer keys against exactly what
73
+ * was drawn.
74
+ *
75
+ * ```typescript
76
+ * undoublequote('a\\"b'); // 'a"b'
77
+ * undoublequote('safe'); // 'safe'
78
+ * ```
79
+ *
80
+ * @param txt A DOT-escaped attribute string (as produced by `doublequote`).
81
+ * @returns The string with every `\"` collapsed back to `"`.
82
+ *
83
+ * @internal
84
+ */
85
+ declare function undoublequote(txt: string): string;
68
86
  /**
69
87
  * Convert a state name into a URL-friendly slug suitable for use as the
70
88
  * body of a dot/SVG node identifier. The transformation is:
@@ -459,6 +477,50 @@ declare type VizRenderOpts = {
459
477
  * @internal
460
478
  */
461
479
  declare function node_block_for<T>(u_jssm: jssm.Machine<T>, l_states: string[], state_index: Map<string, string>, state_kinds: Map<string, StateKind>, hide_labels: boolean, mode: RenderGroups): string;
480
+ /**
481
+ * The per-state group-chip map that {@link node_block_for} appends to labels
482
+ * in a given render mode. Mirrors that function's mode dispatch and calls the
483
+ * very same chip sources — {@link chips_for_all_groups} for `'chips'`,
484
+ * {@link plan_cluster_groups} (with identical inputs) for `'cluster'` — so a
485
+ * reconstructed label can never disagree with the one graphviz was handed.
486
+ * `'off'`, and any machine that declares no groups, yields an empty map.
487
+ *
488
+ * @internal
489
+ */
490
+ declare function chips_for_render_mode<T>(u_jssm: jssm.Machine<T>, l_states: string[], mode: RenderGroups): Map<string, string[]>;
491
+ /**
492
+ * The exact text graphviz places in each state's SVG `<text>` element(s) when
493
+ * a machine is rendered via {@link machine_to_dot} / {@link fsl_to_svg_string}:
494
+ * the state's display text plus any group chips the node builder appends, with
495
+ * DOT's `\"` escaping undone (SVG carries the literal character). A label that
496
+ * wraps across lines becomes several `<text>` elements; this returns the lines
497
+ * joined by `\n`, exactly how {@link extract_state_fills} reads them back — so
498
+ * the derived key and the extracted key meet at the same string.
499
+ *
500
+ * This is the single source of truth the static fence renderer keys its
501
+ * highlight and recolor lookups against, so those lookups can never drift from
502
+ * what was actually drawn — plain labels, group chips, and multi-line wraps
503
+ * alike. It is built by running the node builder's own
504
+ * `label_with_chips(doublequote(display_text), chips)` and inverting the one
505
+ * escaping step, so it follows any change to the label format for free.
506
+ *
507
+ * @param u_jssm The machine being rendered.
508
+ * @param opts Render flags; only `render_groups` affects the label text
509
+ * (default `'cluster'`, matching `fsl_to_svg_string`).
510
+ * @returns A map from each state name to its rendered SVG label text.
511
+ *
512
+ * ```typescript
513
+ * import { sm } from 'jssm';
514
+ * import { state_svg_label_texts } from 'jssm/viz';
515
+ *
516
+ * // a state in two groups renders a chip suffix in its node label
517
+ * state_svg_label_texts(sm`&g1 : [a b]; &g2 : [a]; a -> b;`).get('a'); // 'a [g1]'
518
+ * state_svg_label_texts(sm`a -> b;`).get('a'); // 'a'
519
+ * ```
520
+ *
521
+ * @see extract_state_fills
522
+ */
523
+ declare function state_svg_label_texts<T>(u_jssm: jssm.Machine<T>, opts?: VizRenderOpts): Map<string, string>;
462
524
  /**
463
525
  * Render a {@link jssm.Machine} as a graphviz dot string.
464
526
  *
@@ -578,7 +640,7 @@ declare function machine_to_svg_element<T>(u_jssm: jssm.Machine<T>, opts?: VizRe
578
640
  * @deprecated Use {@link machine_to_dot} instead.
579
641
  */
580
642
  declare function dot<T>(machine: jssm.Machine<T>): string;
581
- export { configure, dot, dot_to_svg, fsl_to_dot, fsl_to_svg_string, fsl_to_svg_element, machine_to_dot, machine_to_svg_string, machine_to_svg_element, version, build_time };
643
+ export { configure, dot, dot_to_svg, fsl_to_dot, fsl_to_svg_string, fsl_to_svg_element, machine_to_dot, machine_to_svg_string, machine_to_svg_element, state_svg_label_texts, version, build_time };
582
644
  export type { VizRenderOpts, RenderGroups };
583
645
  /** @internal — test-only access to private helpers. */
584
646
  export declare const _test: {
@@ -594,6 +656,7 @@ export declare const _test: {
594
656
  style_for_state: typeof style_for_state;
595
657
  cluster_id_for: typeof cluster_id_for;
596
658
  label_with_chips: typeof label_with_chips;
659
+ undoublequote: typeof undoublequote;
597
660
  group_parent_map: typeof group_parent_map;
598
661
  group_ancestry: typeof group_ancestry;
599
662
  primary_group_for: typeof primary_group_for;
@@ -601,6 +664,7 @@ export declare const _test: {
601
664
  groups_to_subgraph_string: typeof groups_to_subgraph_string;
602
665
  chips_for_all_groups: typeof chips_for_all_groups;
603
666
  node_block_for: typeof node_block_for;
667
+ chips_for_render_mode: typeof chips_for_render_mode;
604
668
  edge_attr_for: typeof edge_attr_for;
605
669
  edge_defaults_body: typeof edge_defaults_body;
606
670
  graph_attr_for: typeof graph_attr_for;
@@ -23789,7 +23789,7 @@ function fslSemanticSpans(text) {
23789
23789
  * Useful for runtime diagnostics and for embedding in serialized machine
23790
23790
  * snapshots so that deserializers can detect version-skew.
23791
23791
  */
23792
- const version = "5.159.0";
23792
+ const version = "5.159.1";
23793
23793
 
23794
23794
  /**
23795
23795
  * The FSL Markdown fence convention parser — pure, host-agnostic logic that
@@ -29474,6 +29474,26 @@ function vc(col) {
29474
29474
  function doublequote(txt) {
29475
29475
  return txt.replace(/"/g, '\\"');
29476
29476
  }
29477
+ /**
29478
+ * Reverse {@link doublequote}: turn DOT's `\"` escape back into the literal
29479
+ * `"` that graphviz renders into SVG `<text>` content. Used by
29480
+ * {@link state_svg_label_texts} to reconstruct a node's on-screen label from
29481
+ * the DOT label it was handed, so the fence renderer keys against exactly what
29482
+ * was drawn.
29483
+ *
29484
+ * ```typescript
29485
+ * undoublequote('a\\"b'); // 'a"b'
29486
+ * undoublequote('safe'); // 'safe'
29487
+ * ```
29488
+ *
29489
+ * @param txt A DOT-escaped attribute string (as produced by `doublequote`).
29490
+ * @returns The string with every `\"` collapsed back to `"`.
29491
+ *
29492
+ * @internal
29493
+ */
29494
+ function undoublequote(txt) {
29495
+ return txt.replace(/\\"/g, '"');
29496
+ }
29477
29497
  /**
29478
29498
  * Convert a state name into a URL-friendly slug suitable for use as the
29479
29499
  * body of a dot/SVG node identifier. The transformation is:
@@ -30357,6 +30377,71 @@ function node_block_for(u_jssm, l_states, state_index, state_kinds, hide_labels,
30357
30377
  const { clusters, ungrouped_nodes } = groups_to_subgraph_string(u_jssm, l_states, state_index, state_kinds, hide_labels);
30358
30378
  return `${clusters}${(clusters && ungrouped_nodes) ? ' ' : ''}${ungrouped_nodes}`;
30359
30379
  }
30380
+ /**
30381
+ * The per-state group-chip map that {@link node_block_for} appends to labels
30382
+ * in a given render mode. Mirrors that function's mode dispatch and calls the
30383
+ * very same chip sources — {@link chips_for_all_groups} for `'chips'`,
30384
+ * {@link plan_cluster_groups} (with identical inputs) for `'cluster'` — so a
30385
+ * reconstructed label can never disagree with the one graphviz was handed.
30386
+ * `'off'`, and any machine that declares no groups, yields an empty map.
30387
+ *
30388
+ * @internal
30389
+ */
30390
+ function chips_for_render_mode(u_jssm, l_states, mode) {
30391
+ if ((mode === 'off') || (u_jssm.groups().length === 0)) {
30392
+ return new Map();
30393
+ }
30394
+ if (mode === 'chips') {
30395
+ return chips_for_all_groups(u_jssm, l_states);
30396
+ }
30397
+ const order = u_jssm.groups();
30398
+ const parents = group_parent_map(u_jssm._group_registry, order);
30399
+ return plan_cluster_groups(u_jssm, l_states, order, parents).chips;
30400
+ }
30401
+ /**
30402
+ * The exact text graphviz places in each state's SVG `<text>` element(s) when
30403
+ * a machine is rendered via {@link machine_to_dot} / {@link fsl_to_svg_string}:
30404
+ * the state's display text plus any group chips the node builder appends, with
30405
+ * DOT's `\"` escaping undone (SVG carries the literal character). A label that
30406
+ * wraps across lines becomes several `<text>` elements; this returns the lines
30407
+ * joined by `\n`, exactly how {@link extract_state_fills} reads them back — so
30408
+ * the derived key and the extracted key meet at the same string.
30409
+ *
30410
+ * This is the single source of truth the static fence renderer keys its
30411
+ * highlight and recolor lookups against, so those lookups can never drift from
30412
+ * what was actually drawn — plain labels, group chips, and multi-line wraps
30413
+ * alike. It is built by running the node builder's own
30414
+ * `label_with_chips(doublequote(display_text), chips)` and inverting the one
30415
+ * escaping step, so it follows any change to the label format for free.
30416
+ *
30417
+ * @param u_jssm The machine being rendered.
30418
+ * @param opts Render flags; only `render_groups` affects the label text
30419
+ * (default `'cluster'`, matching `fsl_to_svg_string`).
30420
+ * @returns A map from each state name to its rendered SVG label text.
30421
+ *
30422
+ * ```typescript
30423
+ * import { sm } from 'jssm';
30424
+ * import { state_svg_label_texts } from 'jssm/viz';
30425
+ *
30426
+ * // a state in two groups renders a chip suffix in its node label
30427
+ * state_svg_label_texts(sm`&g1 : [a b]; &g2 : [a]; a -> b;`).get('a'); // 'a [g1]'
30428
+ * state_svg_label_texts(sm`a -> b;`).get('a'); // 'a'
30429
+ * ```
30430
+ *
30431
+ * @see extract_state_fills
30432
+ */
30433
+ function state_svg_label_texts(u_jssm, opts = {}) {
30434
+ var _a, _b;
30435
+ const l_states = u_jssm.states();
30436
+ const mode = (_a = opts.render_groups) !== null && _a !== void 0 ? _a : 'cluster';
30437
+ const chips = chips_for_render_mode(u_jssm, l_states, mode);
30438
+ const out = new Map();
30439
+ for (const s of l_states) {
30440
+ const dot_label = label_with_chips(doublequote(u_jssm.display_text(s)), (_b = chips.get(s)) !== null && _b !== void 0 ? _b : []);
30441
+ out.set(s, undoublequote(dot_label));
30442
+ }
30443
+ return out;
30444
+ }
30360
30445
  /**
30361
30446
  * Render a {@link jssm.Machine} as a graphviz dot string.
30362
30447
  *
@@ -30473,6 +30558,17 @@ async function fsl_to_svg_string(fsl, opts = {}) {
30473
30558
  return dot_to_svg(fsl_to_dot(fsl, opts), opts);
30474
30559
  }
30475
30560
 
30561
+ /**
30562
+ * The render targets supported in v1, in canonical order (the CLI `--target`
30563
+ * enum and `--help` list order). This tuple is the single runtime source of
30564
+ * truth: both the {@link RenderTarget} type and the `fsl-render` CLI's
30565
+ * `--target` enum derive from it, so a new target is declared in exactly one
30566
+ * place. Future targets (mermaid, plantuml, scxml, ascii, fsl) land here in
30567
+ * v0.2+.
30568
+ *
30569
+ * @example
30570
+ * RENDER_TARGETS.includes('gif' as RenderTarget); // true
30571
+ */
30476
30572
  /**
30477
30573
  * Base error class for render-time failures.
30478
30574
  */
@@ -31330,8 +31426,22 @@ function xml_unescape(s) {
31330
31426
  }
31331
31427
  /** Match one graphviz node group; capture [1] the group body. @internal */
31332
31428
  const NODE_GROUP_RE = /<g[^>]*\bclass="node"[^>]*>([\s\S]*?)<\/g>/g;
31333
- const TEXT_RE = /<text[^>]*>([\s\S]*?)<\/text>/;
31429
+ const TEXT_RE_G = /<text[^>]*>([\s\S]*?)<\/text>/g;
31334
31430
  const SHAPE_FILL_RE = /(<(?:ellipse|polygon|path)\b[^>]*\bfill=")([^"]*)(")/;
31431
+ /**
31432
+ * The state key for one node group: the XML-unescaped content of *every* one
31433
+ * of its `<text>` elements, joined by `\n`. Graphviz splits a label that
31434
+ * wraps across lines into one `<text>` per line, so reading only the first
31435
+ * would key on a truncated string; joining the lines with `\n` reconstructs
31436
+ * the machine's display text exactly (matching `state_svg_label_texts`).
31437
+ * Returns `null` for a node group carrying no `<text>` element at all.
31438
+ *
31439
+ * @internal
31440
+ */
31441
+ function node_label_text(body) {
31442
+ const lines = [...body.matchAll(TEXT_RE_G)].map(m => xml_unescape(m[1]));
31443
+ return lines.length === 0 ? null : lines.join('\n');
31444
+ }
31335
31445
  /**
31336
31446
  * Read each state's current fill color out of a graphviz-rendered machine
31337
31447
  * SVG, keyed by state name. States whose shape carries no `fill` attribute
@@ -31348,13 +31458,10 @@ function extract_state_fills(svg) {
31348
31458
  const out = new Map();
31349
31459
  for (const group of svg.matchAll(NODE_GROUP_RE)) {
31350
31460
  const body = group[1];
31351
- const text = body.match(TEXT_RE);
31461
+ const label = node_label_text(body);
31352
31462
  const shape = body.match(SHAPE_FILL_RE);
31353
- if (shape !== null && text !== null) {
31354
- const state_name = xml_unescape(text[1]);
31355
- if (state_name) {
31356
- out.set(state_name, shape[2]);
31357
- }
31463
+ if (shape !== null && label !== null && label !== '') {
31464
+ out.set(label, shape[2]);
31358
31465
  }
31359
31466
  }
31360
31467
  return out;
@@ -31379,11 +31486,11 @@ function patch_state_fill(svg, state, fill) {
31379
31486
  if (done) {
31380
31487
  return whole;
31381
31488
  }
31382
- const text = body.match(TEXT_RE);
31383
- if (text === null) {
31489
+ const label = node_label_text(body);
31490
+ if (label === null) {
31384
31491
  return whole;
31385
31492
  }
31386
- if (xml_unescape(text[1]) !== state) {
31493
+ if (label !== state) {
31387
31494
  return whole;
31388
31495
  }
31389
31496
  // Function-form replacements: `fill` is caller-supplied and may itself
@@ -48858,7 +48965,8 @@ function quantize(rgba, max_colors = 256) {
48858
48965
  throw new JssmError(undefined, 'quantize: max_colors must be 2..256');
48859
48966
  }
48860
48967
  const pixel_count = rgba.length / 4;
48861
- // composite over white, build histogram of packed rgb keys
48968
+ // composite over white, build histogram of packed rgb keys (packed[] retained
48969
+ // so the per-pixel index step below need not recompute the keys)
48862
48970
  const packed = new Uint32Array(pixel_count);
48863
48971
  const histogram = new Map();
48864
48972
  for (let i = 0; i < pixel_count; ++i) {
@@ -48866,6 +48974,48 @@ function quantize(rgba, max_colors = 256) {
48866
48974
  packed[i] = key;
48867
48975
  histogram.set(key, ((_a = histogram.get(key)) !== null && _a !== void 0 ? _a : 0) + 1);
48868
48976
  }
48977
+ const { palette, palette_count, index_of } = palette_from_histogram(histogram, max_colors);
48978
+ const indices = new Uint8Array(pixel_count);
48979
+ for (let i = 0; i < pixel_count; ++i) {
48980
+ indices[i] = index_of.get(packed[i]);
48981
+ }
48982
+ return { palette, palette_count, indices };
48983
+ }
48984
+ /**
48985
+ * Accumulate one RGBA frame's composited-over-white colors into a shared
48986
+ * packed-rgb histogram, so an animation's union palette can be built by
48987
+ * streaming frame-by-frame instead of concatenating every frame's pixels into
48988
+ * one buffer first. Iterating frames in order reproduces the exact key/count
48989
+ * sequence a concatenated buffer would yield, so the resulting palette is
48990
+ * byte-identical to quantizing the concatenation.
48991
+ *
48992
+ * @param rgba - Straight RGBA bytes; length must be a multiple of 4.
48993
+ * @param histogram - Packed 0xRRGGBB key → count, mutated in place.
48994
+ *
48995
+ * @example
48996
+ * const h = new Map<number, number>();
48997
+ * accumulate_histogram(new Uint8Array([255,0,0,255]), h); // h: { 0xff0000 => 1 }
48998
+ *
48999
+ * @internal
49000
+ */
49001
+ function accumulate_histogram(rgba, histogram) {
49002
+ var _a;
49003
+ const pixel_count = rgba.length / 4;
49004
+ for (let i = 0; i < pixel_count; ++i) {
49005
+ const key = packed_over_white(rgba, i);
49006
+ histogram.set(key, ((_a = histogram.get(key)) !== null && _a !== void 0 ? _a : 0) + 1);
49007
+ }
49008
+ }
49009
+ /**
49010
+ * Derive a color palette from a packed-rgb histogram: when the histogram holds
49011
+ * `max_colors` or fewer distinct colors they are preserved exactly (in
49012
+ * insertion order); otherwise a median-cut partition supplies the palette.
49013
+ * Returned alongside the palette is `index_of`, mapping each source color key
49014
+ * to its palette slot, so callers can translate their own pixels.
49015
+ *
49016
+ * @internal
49017
+ */
49018
+ function palette_from_histogram(histogram, max_colors) {
48869
49019
  const distinct = [...histogram.keys()];
48870
49020
  if (distinct.length <= max_colors) {
48871
49021
  const index_of = new Map();
@@ -48876,13 +49026,9 @@ function quantize(rgba, max_colors = 256) {
48876
49026
  palette[i * 3 + 1] = (key >> 8) & 0xff;
48877
49027
  palette[i * 3 + 2] = key & 0xff;
48878
49028
  });
48879
- const indices = new Uint8Array(pixel_count);
48880
- for (let i = 0; i < pixel_count; ++i) {
48881
- indices[i] = index_of.get(packed[i]);
48882
- }
48883
- return { palette, palette_count: distinct.length, indices };
49029
+ return { palette, palette_count: distinct.length, index_of };
48884
49030
  }
48885
- return median_cut(packed, histogram, max_colors);
49031
+ return median_cut_palette(histogram, max_colors);
48886
49032
  }
48887
49033
  /** Channel extractors for packed 0xRRGGBB keys. @internal */
48888
49034
  const CHANNELS = [
@@ -48891,13 +49037,15 @@ const CHANNELS = [
48891
49037
  key => key & 0xff,
48892
49038
  ];
48893
49039
  /**
48894
- * Median-cut quantization over a pre-built histogram. Splits the box with
48895
- * the widest channel range at its count-weighted median until `max_colors`
48896
- * boxes exist, then averages each box into a palette entry.
49040
+ * Median-cut palette over a pre-built histogram. Splits the box with the
49041
+ * widest channel range at its count-weighted median until `max_colors` boxes
49042
+ * exist, then averages each box into a palette entry. Returns the palette and
49043
+ * the color→slot map only; per-pixel indices are the caller's concern (the
49044
+ * animation encoder maps each frame separately, so it never needs them here).
48897
49045
  *
48898
49046
  * @internal
48899
49047
  */
48900
- function median_cut(packed, histogram, max_colors) {
49048
+ function median_cut_palette(histogram, max_colors) {
48901
49049
  const boxes = [[...histogram.keys()]];
48902
49050
  while (boxes.length < max_colors) {
48903
49051
  // pick the box with the widest single-channel range
@@ -48954,11 +49102,7 @@ function median_cut(packed, histogram, max_colors) {
48954
49102
  palette[bi * 3 + 1] = Math.round(g / n);
48955
49103
  palette[bi * 3 + 2] = Math.round(b / n);
48956
49104
  });
48957
- const indices = new Uint8Array(packed.length);
48958
- for (let i = 0; i < packed.length; ++i) {
48959
- indices[i] = index_of.get(packed[i]);
48960
- }
48961
- return { palette, palette_count: boxes.length, indices };
49105
+ return { palette, palette_count: boxes.length, index_of };
48962
49106
  }
48963
49107
  /**
48964
49108
  * GIF-variant LZW compression: emits a leading clear code, grows code width
@@ -49036,8 +49180,9 @@ function lzw_encode(indices, min_code_size) {
49036
49180
  * @param frames - At least one frame; all with identical width/height and
49037
49181
  * `rgba.length === 4 · width · height`.
49038
49182
  *
49039
- * @throws {JssmError} on zero frames, mismatched dimensions, or an rgba
49040
- * buffer whose length contradicts its stated dimensions.
49183
+ * @throws {JssmError} on zero frames, a zero-width or zero-height frame,
49184
+ * mismatched dimensions, or an rgba buffer whose length contradicts its
49185
+ * stated dimensions.
49041
49186
  *
49042
49187
  * @example
49043
49188
  * const red = { rgba: new Uint8Array([255,0,0,255]), width: 1, height: 1 };
@@ -49050,6 +49195,12 @@ function encode_gif(frames, opts = {}) {
49050
49195
  throw new JssmError(undefined, 'encode_gif: at least one frame is required');
49051
49196
  }
49052
49197
  const { width, height } = frames[0];
49198
+ if (width === 0 || height === 0) {
49199
+ // A 0×0 (or 0×h / w×0) frame passes the `4·w·h === rgba.length` check with
49200
+ // an empty buffer, then reaches lzw_encode where `indices[0]!` coerces
49201
+ // undefined into a bogus code 0 — a silently-corrupt GIF. Fail loudly here.
49202
+ throw new JssmError(undefined, `encode_gif: frame dimensions must be non-zero (got ${width}x${height})`);
49203
+ }
49053
49204
  for (const f of frames) {
49054
49205
  if (f.width !== width || f.height !== height) {
49055
49206
  throw new JssmError(undefined, `encode_gif: frame dimensions differ (${f.width}x${f.height} vs ${width}x${height})`);
@@ -49060,25 +49211,30 @@ function encode_gif(frames, opts = {}) {
49060
49211
  }
49061
49212
  const delay_cs = (_a = opts.delay_cs) !== null && _a !== void 0 ? _a : 70;
49062
49213
  const loop = (_b = opts.loop) !== null && _b !== void 0 ? _b : 0;
49063
- // global palette from all frames combined; when few distinct colors exist, preserve all exactly
49064
- const combined = new Uint8Array(frames.reduce((sum, f) => sum + f.rgba.length, 0));
49065
- let pos = 0;
49214
+ // Global palette over the UNION of every frame, built by streaming each
49215
+ // frame's histogram rather than concatenating all frames into one buffer
49216
+ // first (64 frames × w×h×4 can reach hundreds of MB on a big machine).
49217
+ // Frames are visited in order, so the key/count sequence — and therefore the
49218
+ // palette — is byte-identical to quantizing the concatenation.
49219
+ const union_histogram = new Map();
49066
49220
  for (const f of frames) {
49067
- combined.set(f.rgba, pos);
49068
- pos += f.rgba.length;
49221
+ accumulate_histogram(f.rgba, union_histogram);
49069
49222
  }
49070
- const quantized = quantize(combined, 256);
49071
- const gct_bits = Math.max(1, Math.ceil(Math.log2(Math.max(2, quantized.palette_count))));
49223
+ const { palette, palette_count } = palette_from_histogram(union_histogram, 256);
49224
+ const gct_bits = Math.max(1, Math.ceil(Math.log2(Math.max(2, palette_count))));
49072
49225
  const gct_size = 1 << gct_bits;
49073
49226
  const min_code_size = Math.max(2, gct_bits);
49074
- /** Map a frame's pixels to nearest entries of the union-quantized global palette. @internal */
49227
+ // One nearest-color cache for the whole encode: the global palette is
49228
+ // identical across frames, so a color's best index never changes frame to
49229
+ // frame — rebuilding the cache per frame only re-did the same work.
49230
+ const palette_cache = new Map();
49231
+ /** Map a frame's pixels to nearest entries of the union global palette. @internal */
49075
49232
  const map_to_palette = (rgba) => {
49076
49233
  const n = rgba.length / 4;
49077
49234
  const out = new Uint8Array(n);
49078
- const cache = new Map();
49079
49235
  for (let i = 0; i < n; ++i) {
49080
49236
  const key = packed_over_white(rgba, i);
49081
- const hit = cache.get(key);
49237
+ const hit = palette_cache.get(key);
49082
49238
  if (hit !== undefined) {
49083
49239
  out[i] = hit;
49084
49240
  continue;
@@ -49087,67 +49243,83 @@ function encode_gif(frames, opts = {}) {
49087
49243
  const g = (key >> 8) & 0xff;
49088
49244
  const b = key & 0xff;
49089
49245
  let best = 0, best_d = Infinity;
49090
- for (let p = 0; p < quantized.palette_count; ++p) {
49091
- const dr = r - quantized.palette[p * 3];
49092
- const dg = g - quantized.palette[p * 3 + 1];
49093
- const db = b - quantized.palette[p * 3 + 2];
49246
+ for (let p = 0; p < palette_count; ++p) {
49247
+ const dr = r - palette[p * 3];
49248
+ const dg = g - palette[p * 3 + 1];
49249
+ const db = b - palette[p * 3 + 2];
49094
49250
  const d = dr * dr + dg * dg + db * db;
49095
49251
  if (d < best_d) {
49096
49252
  best_d = d;
49097
49253
  best = p;
49098
49254
  }
49099
49255
  }
49100
- cache.set(key, best);
49256
+ palette_cache.set(key, best);
49101
49257
  out[i] = best;
49102
49258
  }
49103
49259
  return out;
49104
49260
  };
49105
- const bytes = [];
49106
- const push_u16 = (v) => { bytes.push(v & 0xff, (v >> 8) & 0xff); };
49261
+ // Output accumulates into a growable Uint8Array (doubling on demand) rather
49262
+ // than a boxed-integer number[]; the final GIF is `out.subarray(0, len)`.
49263
+ let out = new Uint8Array(1024);
49264
+ let len = 0;
49265
+ const need = (extra) => {
49266
+ if (len + extra > out.length) {
49267
+ const bigger = new Uint8Array(Math.max(out.length * 2, len + extra));
49268
+ bigger.set(out.subarray(0, len));
49269
+ out = bigger;
49270
+ }
49271
+ };
49272
+ const push = (...vals) => {
49273
+ need(vals.length);
49274
+ for (let k = 0; k < vals.length; ++k) {
49275
+ out[len++] = vals[k] & 0xff;
49276
+ }
49277
+ };
49278
+ const push_u16 = (v) => { push(v & 0xff, (v >> 8) & 0xff); };
49107
49279
  // header + logical screen descriptor
49108
- bytes.push(0x47, 0x49, 0x46, 0x38, 0x39, 0x61); // "GIF89a"
49280
+ push(0x47, 0x49, 0x46, 0x38, 0x39, 0x61); // "GIF89a"
49109
49281
  push_u16(width);
49110
49282
  push_u16(height);
49111
- bytes.push(0x80 | 0x70 | (gct_bits - 1)); // GCT present, color res 8-bit, size
49112
- bytes.push(0, 0); // background index, aspect
49283
+ push(0x80 | 0x70 | (gct_bits - 1)); // GCT present, color res 8-bit, size
49284
+ push(0, 0); // background index, aspect
49113
49285
  // global color table, padded to 2^gct_bits entries
49114
49286
  for (let p = 0; p < gct_size; ++p) {
49115
- if (p < quantized.palette_count) {
49116
- bytes.push(quantized.palette[p * 3], quantized.palette[p * 3 + 1], quantized.palette[p * 3 + 2]);
49287
+ if (p < palette_count) {
49288
+ push(palette[p * 3], palette[p * 3 + 1], palette[p * 3 + 2]);
49117
49289
  }
49118
49290
  else {
49119
- bytes.push(0, 0, 0);
49291
+ push(0, 0, 0);
49120
49292
  }
49121
49293
  }
49122
49294
  // Netscape looping extension
49123
- bytes.push(0x21, 0xFF, 0x0B);
49124
- bytes.push(...'NETSCAPE2.0'.split('').map(c => c.charCodeAt(0)));
49125
- bytes.push(0x03, 0x01);
49295
+ push(0x21, 0xFF, 0x0B);
49296
+ push(...'NETSCAPE2.0'.split('').map(c => c.charCodeAt(0)));
49297
+ push(0x03, 0x01);
49126
49298
  push_u16(loop);
49127
- bytes.push(0x00);
49299
+ push(0x00);
49128
49300
  for (let fi = 0; fi < frames.length; ++fi) {
49129
49301
  // graphics control extension: disposal 1 (leave in place), no transparency
49130
- bytes.push(0x21, 0xF9, 0x04, 0x04);
49302
+ push(0x21, 0xF9, 0x04, 0x04);
49131
49303
  push_u16(delay_cs);
49132
- bytes.push(0x00, 0x00);
49304
+ push(0x00, 0x00);
49133
49305
  // image descriptor: full frame, no local color table
49134
- bytes.push(0x2C);
49306
+ push(0x2C);
49135
49307
  push_u16(0);
49136
49308
  push_u16(0);
49137
49309
  push_u16(width);
49138
49310
  push_u16(height);
49139
- bytes.push(0x00);
49311
+ push(0x00);
49140
49312
  const indices = map_to_palette(frames[fi].rgba);
49141
- const packed = lzw_encode(indices, min_code_size);
49142
- bytes.push(min_code_size);
49143
- for (let at = 0; at < packed.length; at += 255) {
49144
- const chunk = packed.slice(at, at + 255);
49145
- bytes.push(chunk.length, ...chunk);
49313
+ const packed_lzw = lzw_encode(indices, min_code_size);
49314
+ push(min_code_size);
49315
+ for (let at = 0; at < packed_lzw.length; at += 255) {
49316
+ const chunk = packed_lzw.slice(at, at + 255);
49317
+ push(chunk.length, ...chunk);
49146
49318
  }
49147
- bytes.push(0x00); // block terminator
49319
+ push(0x00); // block terminator
49148
49320
  }
49149
- bytes.push(0x3B); // trailer
49150
- return new Uint8Array(bytes);
49321
+ push(0x3B); // trailer
49322
+ return out.slice(0, len);
49151
49323
  }
49152
49324
 
49153
49325
  /**
@@ -49178,32 +49350,33 @@ function error_box(source, message) {
49178
49350
  /** Note comment, invisible but inspectable. @internal */
49179
49351
  const note_comment = (note) => `<!-- fsl-fence: ${escape_html(note)} -->`;
49180
49352
  /**
49181
- * Remap a display-text-keyed fill map (as produced by
49182
- * {@link extract_state_fills} from a rendered SVG) to be keyed by state
49183
- * NAME instead. Graphviz nodes are labeled with each state's display text
49184
- * (`label ?? name` the same derivation `jssm_viz.ts`'s `state_node_line`
49185
- * uses via `machine.display_text(s)`), but `highlight_fsl_html`'s
49186
- * `state_colors` option is keyed by the state's own name (the code
49187
- * highlighter's semantic spans carry names, not labels). Without this
49188
- * remap, any labeled state's diagram color silently fails to reach its
49189
- * code span.
49190
- *
49191
- * Handles the simple one-label-per-state case only; group-chip-suffixed
49192
- * labels (`label_with_chips` in jssm_viz.ts) render extra text after the
49193
- * label that this remap does not account for — filed as a follow-up.
49194
- *
49195
- * @param machine - The constructed machine the fills were rendered from.
49196
- * @param fills - Display-text-keyed fills, from `extract_state_fills`.
49353
+ * Remap an SVG-label-keyed fill map (as produced by
49354
+ * {@link extract_state_fills} from a rendered SVG) to be keyed by state NAME
49355
+ * instead. Graphviz labels each node with its full rendered label text
49356
+ * display text, plus any group chips, plus any multi-line wrap — while
49357
+ * `highlight_fsl_html`'s `state_colors` option is keyed by the state's own
49358
+ * name (the code highlighter's semantic spans carry names, not labels).
49359
+ * Without this remap, any state whose label diverges from its name silently
49360
+ * fails to carry its diagram color to its code span.
49361
+ *
49362
+ * Both sides of the join derive the SVG label text from the same source:
49363
+ * `label_texts` comes from {@link state_svg_label_texts} (the viz's own label
49364
+ * builder) and `fills`'s keys are read straight off the SVG, so group chips
49365
+ * and multi-line labels are handled without either side drifting.
49366
+ *
49367
+ * @param label_texts - State name its rendered SVG label text, from
49368
+ * `state_svg_label_texts`.
49369
+ * @param fills - SVG-label-keyed fills, from `extract_state_fills`.
49197
49370
  * @returns Fills re-keyed by state name; states with no matching fill are omitted.
49198
49371
  *
49199
49372
  * @internal
49200
49373
  */
49201
- function state_colors_by_name(machine, fills) {
49374
+ function state_colors_by_name(label_texts, fills) {
49202
49375
  const out = new Map();
49203
- for (const s of machine.states()) {
49204
- const fill = fills.get(machine.display_text(s));
49376
+ for (const [state, label] of label_texts) {
49377
+ const fill = fills.get(label);
49205
49378
  if (fill !== undefined) {
49206
- out.set(s, fill);
49379
+ out.set(state, fill);
49207
49380
  }
49208
49381
  }
49209
49382
  return out;
@@ -49246,7 +49419,7 @@ async function render_fence_html(source, info, opts = {}) {
49246
49419
  }
49247
49420
  const svg = await fsl_to_svg_string(source);
49248
49421
  const fills = extract_state_fills(svg);
49249
- const state_colors = state_colors_by_name(machine, fills);
49422
+ const state_colors = state_colors_by_name(state_svg_label_texts(machine), fills);
49250
49423
  const chunks = desc.notes.map(note_comment);
49251
49424
  for (const part of desc.parts) {
49252
49425
  if (INTERACTIVE_PARTS.has(part)) {
@@ -49381,14 +49554,15 @@ async function render_fence_gif(source, opts = {}) {
49381
49554
  const base_svg = await fsl_to_svg_string(source);
49382
49555
  const highlight = (_b = opts.highlight_fill) !== null && _b !== void 0 ? _b : '#ff9930';
49383
49556
  const scale = (_c = opts.scale) !== null && _c !== void 0 ? _c : 100;
49557
+ const label_texts = state_svg_label_texts(machine);
49384
49558
  const frames = [];
49385
49559
  for (const state of walk) {
49386
- // base_svg's nodes are labeled with display text (label ?? name; the
49387
- // same `machine.display_text(s)` derivation jssm_viz.ts's
49388
- // state_node_line uses), not the walk's state NAME — patch by that or
49389
- // a labeled state's frame silently no-ops (see state_colors_by_name for
49390
- // the render_fence_html analog of this same fix).
49391
- const patched = patch_state_fill(base_svg, machine.display_text(state), highlight);
49560
+ // base_svg's nodes are labeled with their full SVG label text (display
49561
+ // text + any group chips + any multi-line wrap), not the walk's state
49562
+ // NAME — patch by the shared derivation or a labeled/chipped/wrapped
49563
+ // state's frame silently no-ops. `walk` is drawn from the machine's own
49564
+ // states, so every entry has a label_texts key.
49565
+ const patched = patch_state_fill(base_svg, label_texts.get(state), highlight);
49392
49566
  const raster = await rasterizeRgba(patched, { scale });
49393
49567
  frames.push({ rgba: raster.rgba, width: raster.width, height: raster.height });
49394
49568
  }