glyphdust 0.2.1 → 0.3.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/CHANGELOG.md CHANGED
@@ -4,6 +4,23 @@ All notable changes to **glyphdust** are documented here.
4
4
  The format follows [Keep a Changelog](https://keepachangelog.com/), and the
5
5
  project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.3.0] — 2026-06-28
8
+
9
+ Non-breaking feature release. Defaults reproduce 0.2.1 exactly.
10
+
11
+ ### Added
12
+
13
+ - **Mixed fonts in one glyph (`segments`)** — a `TextKeyframe` can now carry a
14
+ `segments: { text, font? }[]` array. Each run is stamped with its own font and
15
+ flows inline (a `\n` inside any run breaks the line; the next run continues on the
16
+ new line), so a single particle glyph can blend, e.g., a bold serif word with a
17
+ light sans one. `text` stays the accessible/`resolveToDom` string; per-run `font`
18
+ falls back to the keyframe `font`. Works on the normal and `dense` sampling paths;
19
+ ignored under `domSelector` (the DOM provides layout). Defaults unchanged —
20
+ omitting `segments` reproduces prior behavior exactly.
21
+ _Why: particles only ride "ink", so the stamp was never font-bound — the limit was
22
+ the API exposing one font. (提案者: 凜さん)_
23
+
7
24
  ## [0.2.1] — 2026-06-26
8
25
 
9
26
  Flexibility & polish release. Glyphdust is no longer scroll-and-hero only — it now
package/README.md CHANGED
@@ -155,13 +155,35 @@ type Keyframe = TextKeyframe | ScatterKeyframe;
155
155
  | field | type | description |
156
156
  |---|---|---|
157
157
  | `text` | `string` | Text to render. Use `\n` for line breaks. |
158
+ | `segments` | `{ text, font? }[]?` | Mix fonts in one glyph (see below). Particles are stamped from the runs; `text` stays the accessible / `resolveToDom` string. |
158
159
  | `domSelector` | `string?` | Selector of a real element; particles align pixel-perfect to its rect & font. |
159
160
  | `resolveToDom` | `boolean?` | At the finale, cross-fade particles → real DOM text (usually the last keyframe). |
160
161
  | `dense` | `boolean?` | High-density, uniform sampling (best for solid wordmarks). |
161
- | `font` | `string?` | Canvas2D `font` string. Defaults to a density-appropriate value. |
162
+ | `font` | `string?` | Canvas2D `font` string. Defaults to a density-appropriate value. Also the default for `segments` runs. |
162
163
  | `worldW` | `number?` | Visible world width to fit the glyph into. |
163
164
  | `offsetX` / `offsetY` | `number?` | World-space offset (right / up are positive). |
164
165
 
166
+ #### Mix fonts in one glyph (`segments`)
167
+
168
+ Particles just ride wherever there's "ink", so a single glyph isn't bound to one
169
+ typeface. Split a keyframe into `segments` and each run gets its own `font`,
170
+ flowing inline (a `\n` inside a run starts a new line; the next run continues there):
171
+
172
+ ```tsx
173
+ {
174
+ type: "text",
175
+ text: "Mix fonts", // accessible / resolve string
176
+ segments: [
177
+ { text: "Mix ", font: "900 200px Georgia, serif" }, // bold serif
178
+ { text: "fonts", font: "300 150px 'Helvetica Neue', sans-serif" }, // light sans
179
+ ],
180
+ }
181
+ ```
182
+
183
+ Runs without a `font` fall back to the keyframe `font`. `segments` is ignored when
184
+ `domSelector` is set (the DOM provides the layout there). Particle **color** is still
185
+ governed globally by `colors` (ink/accent ratio), not per segment.
186
+
165
187
  **`ScatterKeyframe`** (`type: "scatter"`) — scatters particles into a random cloud:
166
188
 
167
189
  | field | type | description |
package/dist/index.cjs CHANGED
@@ -54,6 +54,38 @@ function fillScatterCluster(out, count, offsetX, offsetY, random) {
54
54
  out[i * 3 + 2] = (random() - 0.5) * 0.2;
55
55
  }
56
56
  }
57
+ function segmentsToRunLines(segments, defaultFont) {
58
+ const lines = [[]];
59
+ for (const seg of segments) {
60
+ const font = seg.font ?? defaultFont;
61
+ const parts = seg.text.split("\n");
62
+ parts.forEach((part, i) => {
63
+ if (i > 0) lines.push([]);
64
+ if (part.length > 0) lines[lines.length - 1].push({ text: part, font });
65
+ });
66
+ }
67
+ return lines;
68
+ }
69
+ function drawSegmentedLines(ctx, runLines, cw, ch, lineHeight, align, leftPad) {
70
+ ctx.fillStyle = "#000";
71
+ ctx.textBaseline = "middle";
72
+ ctx.textAlign = "left";
73
+ const blockH = lineHeight * (runLines.length - 1);
74
+ runLines.forEach((runs, i) => {
75
+ const y = ch / 2 - blockH / 2 + i * lineHeight;
76
+ let total = 0;
77
+ for (const r of runs) {
78
+ ctx.font = r.font;
79
+ total += ctx.measureText(r.text).width;
80
+ }
81
+ let x = align === "left" ? leftPad : cw / 2 - total / 2;
82
+ for (const r of runs) {
83
+ ctx.font = r.font;
84
+ ctx.fillText(r.text, x, y);
85
+ x += ctx.measureText(r.text).width;
86
+ }
87
+ });
88
+ }
57
89
  function buildTextTargets(count, lines, opts) {
58
90
  const out = new Float32Array(count * 3);
59
91
  const random = opts.random ?? Math.random;
@@ -62,17 +94,22 @@ function buildTextTargets(count, lines, opts) {
62
94
  const ctx = createSamplingContext(cw, ch);
63
95
  if (!ctx) return out;
64
96
  const align = opts.align ?? "center";
65
- ctx.clearRect(0, 0, cw, ch);
66
- ctx.fillStyle = "#000";
67
- ctx.textAlign = align === "left" ? "left" : "center";
68
- ctx.textBaseline = "middle";
69
- ctx.font = opts.font;
70
- const drawX = align === "left" ? cw * 0.04 : cw / 2;
71
97
  const lh = opts.lineHeight;
72
- const blockH = lh * (lines.length - 1);
73
- lines.forEach((line, i) => {
74
- ctx.fillText(line, drawX, ch / 2 - blockH / 2 + i * lh);
75
- });
98
+ ctx.clearRect(0, 0, cw, ch);
99
+ if (opts.segments && opts.segments.length > 0) {
100
+ const runLines = segmentsToRunLines(opts.segments, opts.font);
101
+ drawSegmentedLines(ctx, runLines, cw, ch, lh, align, cw * 0.04);
102
+ } else {
103
+ ctx.fillStyle = "#000";
104
+ ctx.textAlign = align === "left" ? "left" : "center";
105
+ ctx.textBaseline = "middle";
106
+ ctx.font = opts.font;
107
+ const drawX = align === "left" ? cw * 0.04 : cw / 2;
108
+ const blockH = lh * (lines.length - 1);
109
+ lines.forEach((line, i) => {
110
+ ctx.fillText(line, drawX, ch / 2 - blockH / 2 + i * lh);
111
+ });
112
+ }
76
113
  const step = opts.step ?? 2;
77
114
  const pts = collectFilledPixels(ctx, cw, ch, step);
78
115
  const filled = pts.length / 2;
@@ -106,15 +143,20 @@ function buildDenseTextTargets(count, lines, opts) {
106
143
  const ctx = createSamplingContext(cw, ch);
107
144
  if (!ctx) return out;
108
145
  ctx.clearRect(0, 0, cw, ch);
109
- ctx.fillStyle = "#000";
110
- ctx.textAlign = "center";
111
- ctx.textBaseline = "middle";
112
- ctx.font = opts.font;
113
146
  const lh = ch * (opts.lineHeightRatio ?? 0.46);
114
- const blockH = lh * (lines.length - 1);
115
- lines.forEach((line, i) => {
116
- ctx.fillText(line, cw / 2, ch / 2 - blockH / 2 + i * lh);
117
- });
147
+ if (opts.segments && opts.segments.length > 0) {
148
+ const runLines = segmentsToRunLines(opts.segments, opts.font);
149
+ drawSegmentedLines(ctx, runLines, cw, ch, lh, "center", cw * 0.04);
150
+ } else {
151
+ ctx.fillStyle = "#000";
152
+ ctx.textAlign = "center";
153
+ ctx.textBaseline = "middle";
154
+ ctx.font = opts.font;
155
+ const blockH = lh * (lines.length - 1);
156
+ lines.forEach((line, i) => {
157
+ ctx.fillText(line, cw / 2, ch / 2 - blockH / 2 + i * lh);
158
+ });
159
+ }
118
160
  const step = opts.step ?? 1;
119
161
  const pts = collectFilledPixels(ctx, cw, ch, step);
120
162
  const filled = pts.length / 2;
@@ -517,6 +559,7 @@ function buildKeyframeTargets(kf, count, ctx) {
517
559
  if (kf.dense) {
518
560
  return buildDenseTextTargets(count, lines, {
519
561
  font: kf.font ?? DEFAULT_DENSE_FONT,
562
+ segments: kf.segments,
520
563
  worldW: kf.worldW ?? ctx.visW * (ctx.mobile ? 0.86 : 0.62),
521
564
  offsetX: kf.offsetX ?? 0,
522
565
  offsetY: kf.offsetY ?? 0,
@@ -528,6 +571,7 @@ function buildKeyframeTargets(kf, count, ctx) {
528
571
  }
529
572
  return buildTextTargets(count, lines, {
530
573
  font: kf.font ?? DEFAULT_TEXT_FONT,
574
+ segments: kf.segments,
531
575
  worldW: kf.worldW ?? ctx.visW * 0.7,
532
576
  lineHeight: 178,
533
577
  offsetX: kf.offsetX ?? 0,
@@ -1144,7 +1188,7 @@ function GlyphDust(props) {
1144
1188
  }
1145
1189
 
1146
1190
  // src/index.ts
1147
- var VERSION = "0.2.1";
1191
+ var VERSION = "0.3.0";
1148
1192
 
1149
1193
  exports.DEFAULT_TRIGGER_HEIGHT = DEFAULT_TRIGGER_HEIGHT;
1150
1194
  exports.FRAGMENT_SHADER = FRAGMENT_SHADER;