@logomarks/core 0.1.9 → 0.2.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.
Files changed (133) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +10 -1
  3. package/package.json +59 -4
  4. package/src/batch/args.d.ts +18 -0
  5. package/src/batch/args.js +44 -0
  6. package/src/batch/index.d.ts +7 -0
  7. package/src/batch/index.js +10 -0
  8. package/src/batch/run.d.ts +53 -0
  9. package/src/batch/run.js +92 -0
  10. package/src/batch/slug.d.ts +2 -0
  11. package/src/batch/slug.js +12 -0
  12. package/src/batch/source.d.ts +12 -0
  13. package/src/batch/source.js +23 -0
  14. package/src/fit/index.d.ts +4 -0
  15. package/src/fit/index.js +29 -5
  16. package/src/font/glyphs.d.ts +31 -0
  17. package/src/font/glyphs.js +228 -0
  18. package/src/font/index.d.ts +10 -0
  19. package/src/font/index.js +10 -0
  20. package/src/font/measure.d.ts +16 -0
  21. package/src/font/measure.js +201 -0
  22. package/src/font/moves.d.ts +42 -0
  23. package/src/font/moves.js +263 -0
  24. package/src/forms/discs.d.ts +20 -0
  25. package/src/forms/discs.js +114 -0
  26. package/src/forms/emblems.d.ts +22 -0
  27. package/src/forms/emblems.js +109 -0
  28. package/src/forms/flow.d.ts +21 -0
  29. package/src/forms/flow.js +80 -0
  30. package/src/forms/folds.d.ts +18 -0
  31. package/src/forms/folds.js +147 -0
  32. package/src/forms/geo.d.ts +46 -0
  33. package/src/forms/geo.js +233 -0
  34. package/src/forms/hooks.d.ts +40 -0
  35. package/src/forms/hooks.js +204 -0
  36. package/src/forms/index.d.ts +22 -0
  37. package/src/forms/index.js +24 -0
  38. package/src/forms/jog.d.ts +33 -0
  39. package/src/forms/jog.js +162 -0
  40. package/src/forms/lines.d.ts +32 -0
  41. package/src/forms/lines.js +233 -0
  42. package/src/forms/ref.d.ts +41 -0
  43. package/src/forms/ref.js +246 -0
  44. package/src/forms/shatter.d.ts +40 -0
  45. package/src/forms/shatter.js +341 -0
  46. package/src/forms/slats.d.ts +35 -0
  47. package/src/forms/slats.js +78 -0
  48. package/src/forms/swash.d.ts +23 -0
  49. package/src/forms/swash.js +74 -0
  50. package/src/forms/tech.d.ts +40 -0
  51. package/src/forms/tech.js +293 -0
  52. package/src/forms/tracks.d.ts +36 -0
  53. package/src/forms/tracks.js +121 -0
  54. package/src/geometry/index.d.ts +1 -0
  55. package/src/geometry/index.js +1 -0
  56. package/src/geometry/mitre.d.ts +8 -0
  57. package/src/geometry/mitre.js +64 -0
  58. package/src/geometry/modular.d.ts +1 -1
  59. package/src/index.js +2 -1
  60. package/src/iso/index.d.ts +5 -0
  61. package/src/iso/index.js +13 -0
  62. package/src/iso/objects.d.ts +168 -0
  63. package/src/iso/objects.js +1042 -0
  64. package/src/iso/planes.d.ts +27 -0
  65. package/src/iso/planes.js +138 -0
  66. package/src/iso/voxels.d.ts +43 -0
  67. package/src/iso/voxels.js +255 -0
  68. package/src/letters/blackletter.d.ts +31 -0
  69. package/src/letters/blackletter.js +199 -0
  70. package/src/letters/dotstroke.d.ts +16 -0
  71. package/src/letters/dotstroke.js +77 -0
  72. package/src/letters/echo.d.ts +42 -0
  73. package/src/letters/echo.js +246 -0
  74. package/src/letters/frame.d.ts +6 -0
  75. package/src/letters/frame.js +14 -0
  76. package/src/letters/index.d.ts +16 -0
  77. package/src/letters/index.js +18 -0
  78. package/src/letters/inktrap.d.ts +66 -0
  79. package/src/letters/inktrap.js +244 -0
  80. package/src/letters/mono.d.ts +145 -0
  81. package/src/letters/mono.js +684 -0
  82. package/src/letters/pillar.d.ts +31 -0
  83. package/src/letters/pillar.js +257 -0
  84. package/src/letters/serif.d.ts +109 -0
  85. package/src/letters/serif.js +666 -0
  86. package/src/letters/stair.d.ts +8 -0
  87. package/src/letters/stair.js +43 -0
  88. package/src/letters/stem.d.ts +29 -0
  89. package/src/letters/stem.js +205 -0
  90. package/src/objects/animals.d.ts +105 -0
  91. package/src/objects/animals.js +469 -0
  92. package/src/objects/bio.d.ts +14 -0
  93. package/src/objects/bio.js +76 -0
  94. package/src/objects/devglyphs.d.ts +34 -0
  95. package/src/objects/devglyphs.js +226 -0
  96. package/src/objects/devices.d.ts +31 -0
  97. package/src/objects/devices.js +114 -0
  98. package/src/objects/faces.d.ts +94 -0
  99. package/src/objects/faces.js +344 -0
  100. package/src/objects/food.d.ts +12 -0
  101. package/src/objects/food.js +74 -0
  102. package/src/objects/index.d.ts +13 -0
  103. package/src/objects/index.js +18 -0
  104. package/src/objects/rosettes.d.ts +14 -0
  105. package/src/objects/rosettes.js +64 -0
  106. package/src/objects/sport.d.ts +8 -0
  107. package/src/objects/sport.js +37 -0
  108. package/src/objects/weapons.d.ts +31 -0
  109. package/src/objects/weapons.js +179 -0
  110. package/src/parts/index.d.ts +2 -0
  111. package/src/parts/index.js +2 -0
  112. package/src/parts/pixels.d.ts +11 -0
  113. package/src/parts/pixels.js +74 -0
  114. package/src/parts/run.js +4 -2
  115. package/src/parts/span.d.ts +13 -0
  116. package/src/parts/span.js +100 -0
  117. package/src/pixels/dither.d.ts +78 -0
  118. package/src/pixels/dither.js +399 -0
  119. package/src/pixels/emote.d.ts +6 -0
  120. package/src/pixels/emote.js +76 -0
  121. package/src/pixels/index.d.ts +7 -0
  122. package/src/pixels/index.js +8 -0
  123. package/src/sdf/fields.js +135 -0
  124. package/src/sdf/gap.js +42 -0
  125. package/src/sdf/index.d.ts +58 -0
  126. package/src/sdf/index.js +14 -0
  127. package/src/sdf/ops.js +102 -0
  128. package/src/sdf/trace.js +90 -0
  129. package/src/sdf/y2k.d.ts +12 -0
  130. package/src/sdf/y2k.js +80 -0
  131. package/src/skill/bundle.js +1 -1
  132. package/src/util/vec.d.ts +14 -0
  133. package/src/util/vec.js +61 -0
@@ -0,0 +1,228 @@
1
+ /**
2
+ * A capital from a font file as a kit shape, drawn on the frame the serif and blackletter faces draw on (cap line at y 0,
3
+ * baseline at y H, y down, ink starting at x 0), so the house moves (/letters' rule, split, dot, plinth, frame, inline;
4
+ * the blackletter's parted lozenge and split stem) apply to a font's letter unchanged. From the Logomarks tools
5
+ * (fontparts.mjs, batches 242 and 243). Node only.
6
+ *
7
+ * Every function takes a font as a file path or as its bytes (a Buffer, Uint8Array or ArrayBuffer): core holds no fonts
8
+ * and no font folder. A font is parsed on first use and cached (by path, or by the bytes' object). It reads fonts with
9
+ * opentype.js and grows them with clipper-lib, both optional peers loaded on first use.
10
+ *
11
+ * Two traps the Logomarks font A/B found:
12
+ * - opentype.js getPath() writes NaN for Bodoni Moda's static instances and drops DM Serif Display's closepaths (which
13
+ * merges its rings), so paths are built here from glyph.path.commands;
14
+ * - font contours overlap (Bodoni, DM Serif, Unifraktur), so glyphs are filled non-zero (`nonzeroShape()`).
15
+ */
16
+ import fs from 'node:fs';
17
+ import { createRequire } from 'node:module';
18
+ import { U, D, I, move, bbox, rect } from '../geometry/index.js';
19
+ import { pathRings } from '../svg/index.js';
20
+ import { shoelace as sArea } from '../util/vec.js';
21
+ import { wind, runsH, runsV, strokeDepths, thinnestStroke } from './measure.js';
22
+
23
+ const require = createRequire(import.meta.url);
24
+
25
+ /**
26
+ * A cache for values of a font (and a sub-key): a Map for paths, keyed `path` or `path|sub` as strings, and a WeakMap of
27
+ * Maps for fonts passed as bytes.
28
+ *
29
+ * @template T
30
+ * @returns {(font: import('./glyphs.js').FontSource, sub: string | undefined, make: () => T) => T} The cached lookup.
31
+ */
32
+ const fontCache = () => {
33
+ const paths = new Map(), bytes = new WeakMap();
34
+ return (font, sub, make) => {
35
+ if (typeof font === 'string') { const k = sub === undefined ? font : `${font}|${sub}`; if (!paths.has(k)) paths.set(k, make()); return paths.get(k); }
36
+ let m = bytes.get(font); if (!m) bytes.set(font, (m = new Map()));
37
+ const k = sub ?? ''; if (!m.has(k)) m.set(k, make()); return m.get(k);
38
+ };
39
+ };
40
+
41
+ const fonts = fontCache();
42
+
43
+ /**
44
+ * A font, parsed (cached): from its file's path, or from its bytes.
45
+ *
46
+ * @param {import('./glyphs.js').FontSource} source The font file's path, or its contents.
47
+ * @returns {import('./glyphs.js').Font} The parsed font (opentype.js's Font).
48
+ */
49
+ export const loadFont = (source) => fonts(source, undefined, () => {
50
+ const opentype = require('opentype.js');
51
+ if (source instanceof ArrayBuffer) return opentype.parse(source);
52
+ const b = typeof source === 'string' ? fs.readFileSync(source) : source;
53
+ return opentype.parse(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength));
54
+ });
55
+
56
+ /**
57
+ * A font's cap height in font units: the top of its H (OS/2 sCapHeight is far off in Unifraktur).
58
+ *
59
+ * @param {import('./glyphs.js').Font} font The parsed font.
60
+ * @returns {number} The cap height.
61
+ */
62
+ export const capUnits = (font) => Math.max(...font.charToGlyph('H').path.commands.filter((c) => c.y !== undefined).map((c) => c.y));
63
+
64
+ /**
65
+ * A glyph's outline as SVG path data, scaled by k, its baseline at y H and y down; one subpath per contour.
66
+ *
67
+ * @param {{ path: { commands: Record<string, any>[] } }} glyph The glyph.
68
+ * @param {number} k The scale.
69
+ * @param {number} H The baseline.
70
+ * @returns {string} The path data.
71
+ */
72
+ const glyphD = (glyph, k, H) => {
73
+ const X = (v) => (v * k).toFixed(3), Y = (v) => (H - v * k).toFixed(3); let d = '';
74
+ for (const c of glyph.path.commands) {
75
+ if (c.type === 'M') d += (d ? 'Z' : '') + `M${X(c.x)} ${Y(c.y)}`;
76
+ else if (c.type === 'L') d += `L${X(c.x)} ${Y(c.y)}`;
77
+ else if (c.type === 'Q') d += `Q${X(c.x1)} ${Y(c.y1)} ${X(c.x)} ${Y(c.y)}`;
78
+ else if (c.type === 'C') d += `C${X(c.x1)} ${Y(c.y1)} ${X(c.x2)} ${Y(c.y2)} ${X(c.x)} ${Y(c.y)}`;
79
+ }
80
+ return d + 'Z';
81
+ };
82
+
83
+ /**
84
+ * Rings wound non-zero (outers one way, counters the other) as a shape: each counter cut from the smallest outer
85
+ * holding it, then the outers united.
86
+ *
87
+ * @param {readonly (readonly number[])[][]} rings The rings.
88
+ * @returns {import('../geometry/types.js').Shape} The shape.
89
+ */
90
+ const ringsShape = (rings) => {
91
+ const rs = rings.filter((r) => Math.abs(sArea(r)) > 0.5); if (!rs.length) return [];
92
+ const big = rs.reduce((a, r) => (Math.abs(sArea(r)) > Math.abs(sArea(a)) ? r : a)), sign = Math.sign(sArea(big));
93
+ const outers = rs.filter((r) => Math.sign(sArea(r)) === sign), holes = rs.filter((r) => Math.sign(sArea(r)) !== sign), own = outers.map(() => []);
94
+ for (const h of holes) {
95
+ let best = -1;
96
+ outers.forEach((o, i) => { if (wind(o, h[0]) !== 0 && (best < 0 || Math.abs(sArea(o)) < Math.abs(sArea(outers[best])))) best = i; });
97
+ if (best >= 0) own[best].push(h);
98
+ }
99
+ return U(...outers.map((o, i) => D([o], ...own[i].map((h) => [h]))));
100
+ };
101
+
102
+ /**
103
+ * Path data filled non-zero, as a shape, for contours that may overlap: each counter (a ring wound against the outers)
104
+ * is cut from the smallest outer that holds it, then the outers are united.
105
+ *
106
+ * @param {string} d The path data.
107
+ * @param {number} [step=1] The curves' sampling step.
108
+ * @returns {import('../geometry/types.js').Shape} The shape.
109
+ */
110
+ export const nonzeroShape = (d, step = 1) => ringsShape(pathRings(d, step));
111
+
112
+ /**
113
+ * A font's character as a shape, scaled so the font's cap height (its H) is `cap`: the cap line at y 0, the baseline at
114
+ * y cap, the ink starting at x 0. Overshoots and descenders fall outside 0…cap as the font draws them.
115
+ *
116
+ * @param {import('./glyphs.js').FontSource} font The font (a path or its bytes).
117
+ * @param {string} char The character.
118
+ * @param {{ cap?: number, step?: number }} [o] The cap height (380) and the curves' sampling step (1).
119
+ * @returns {{ s: import('../geometry/types.js').Shape, w: number }} The letter and its ink's width.
120
+ */
121
+ export const glyphShape = (font, char, { cap = 380, step = 1 } = {}) => {
122
+ const f = loadFont(font), k = cap / capUnits(f);
123
+ const s = nonzeroShape(glyphD(f.charToGlyph(char), k, cap), step), [x0, , x1] = bbox(s);
124
+ return { s: move(s, -x0, 0), w: x1 - x0 };
125
+ };
126
+
127
+ /**
128
+ * A mitred grow: the shape grown by d with its corners kept sharp (mitred up to `limit` times d, squared off past it),
129
+ * where the kit's grow() rounds every corner by d. Thickening a font's hairlines with a round grow softens every serif
130
+ * corner; this keeps the font's cut. Clipper's offset (clipper-lib, an optional peer), in thousandths: an exact polygon
131
+ * offset (/geometry's offsetPolygon) can't stand in for it, since Clipper's integer grid sets every point.
132
+ *
133
+ * @param {import('../geometry/types.js').Shape} shape The shape.
134
+ * @param {number} d How far (0: the shape as it is).
135
+ * @param {number} [limit=2] The mitre limit, in multiples of d.
136
+ * @returns {import('../geometry/types.js').Shape} The grown shape.
137
+ */
138
+ export const mitreGrow = (shape, d, limit = 2) => {
139
+ if (!d) return shape;
140
+ const CL = require('clipper-lib');
141
+ const P = (r) => r.map(([x, y]) => ({ X: Math.round(x * 1000), Y: Math.round(y * 1000) })), c = new CL.Clipper(), u = new CL.Paths();
142
+ c.AddPaths(shape.flatMap((pg) => pg.map(P)), CL.PolyType.ptSubject, true);
143
+ c.Execute(CL.ClipType.ctUnion, u, CL.PolyFillType.pftEvenOdd, CL.PolyFillType.pftEvenOdd); // a clean shape: outers come back positive, holes negative
144
+ const co = new CL.ClipperOffset(limit, 250), out = new CL.Paths(); co.AddPaths(u, CL.JoinType.jtMiter, CL.EndType.etClosedPolygon); co.Execute(out, d * 1000);
145
+ return ringsShape(out.map((r) => r.map(({ X, Y }) => [X / 1000, Y / 1000])));
146
+ };
147
+
148
+ /**
149
+ * A font's weights, read off its H at cap 1000: the stem S (the left stem across at mid-height) and the hairline h (the
150
+ * bar, down through the middle), as fractions of the cap.
151
+ *
152
+ * @param {import('./glyphs.js').FontSource} font The font.
153
+ * @returns {{ S: number, h: number }} The stem and the hairline.
154
+ */
155
+ export const weights = (font) => {
156
+ const H = glyphShape(font, 'H', { cap: 1000 }), st = runsH(H.s, 300)[0];
157
+ const bar = runsV(H.s, H.w / 2).find(([a, b]) => b - a < 300); return { S: (st[1] - st[0]) / 1000, h: bar ? (bar[1] - bar[0]) / 1000 : (st[1] - st[0]) / 1000 };
158
+ };
159
+
160
+ const wcache = fontCache();
161
+
162
+ /**
163
+ * weights(), cached.
164
+ *
165
+ * @param {import('./glyphs.js').FontSource} font The font.
166
+ * @returns {{ S: number, h: number }} The stem and the hairline.
167
+ */
168
+ const fontWeights = (font) => wcache(font, undefined, () => weights(font));
169
+
170
+ const hcache = fontCache();
171
+
172
+ /**
173
+ * A font's hairline as a fraction of its cap: the `q` quantile of the stroke depths of `chars` (a font whose H has no
174
+ * hairline bar, such as a Fraktur, is measured this way; the serif faces use their H's bar, `weights()`). Cached.
175
+ *
176
+ * @param {import('./glyphs.js').FontSource} font The font.
177
+ * @param {string} [chars='ABCDEGHMNRST'] The letters measured.
178
+ * @param {number} [q=0.1] The quantile.
179
+ * @returns {number} The hairline, as a fraction of the cap.
180
+ */
181
+ export const hairline = (font, chars = 'ABCDEGHMNRST', q = 0.1) => hcache(font, `${chars}|${q}`, () => {
182
+ const d = [...chars].flatMap((c) => strokeDepths(glyphShape(font, c, { cap: 1000, step: 4 }).s, 6)).sort((a, b) => a - b);
183
+ return d[Math.floor(d.length * q)] / 1000;
184
+ });
185
+
186
+ const scache = fontCache();
187
+
188
+ /**
189
+ * A font's structural hairline as a fraction of its cap: the thinnest structural stroke (thinnestStroke()) over `chars`
190
+ * at a 448 cap, so tips and spikes don't count (Abril Fatface 0.026, New Rocker 0.085, Germania One 0.131): pass it to
191
+ * fontSource() as `h`. Cached; needs @resvg/resvg-js.
192
+ *
193
+ * @param {import('./glyphs.js').FontSource} font The font.
194
+ * @param {string} [chars='ABEFGHMNORS'] The letters measured.
195
+ * @returns {number} The hairline, as a fraction of the cap.
196
+ */
197
+ export const structuralHairline = (font, chars = 'ABEFGHMNORS') => scache(font, chars, () => Math.min(...[...chars].map((c) => thinnestStroke(glyphShape(font, c, { cap: 448 }).s))) / 448);
198
+
199
+ /**
200
+ * A glyph source for /letters' moves (and any build that takes a letter): `(H, { g }) => { s, w, m }`, the font's `char`
201
+ * at cap H on the glyph frame, with m = { H, S, h, t, dy }: its stem and hairline (after the grow), the grow t and the
202
+ * lift dy. Given the gap g the mark is drawn with (exact() picks g so it lands on 22 once fitted, so the fit's scale is
203
+ * 22 ÷ g), the glyph is grown uniformly (mitred, so its corners stay sharp) just enough that its hairline is `hair` once
204
+ * fitted. The hairline is the font's H bar (`weights()`), or `h` (a fraction of the cap; hairline() and
205
+ * structuralHairline() measure one for a face without a bar). The grow, in fitted units, is reported through `onGrow`.
206
+ * Without g nothing is grown. Grown, the ink is moved back so it still starts at x 0 and its baseline stays at y H (its
207
+ * cap line rises 2t), so base serifs, rules and plinths line up; m.dy re-centres it in a frame.
208
+ *
209
+ * @param {import('./glyphs.js').FontSource} font The font.
210
+ * @param {string} char The character.
211
+ * @param {{ hair?: number, h?: number, onGrow?: (t: number) => void }} [o] The fitted hairline wanted (29), the font's
212
+ * hairline as a fraction of the cap (its H bar's by default), and a callback for the grow.
213
+ * @returns {import('../letters/serif.js').GlyphSource} The glyph source.
214
+ */
215
+ export const fontSource = (font, char, { hair = 29, h, onGrow } = {}) => (H, o = {}) => {
216
+ const wt = fontWeights(font), h0 = (h ?? wt.h) * H;
217
+ const k = o.g ? 22 / o.g : 1, t = o.g ? Math.max(0, (hair / k - h0) / 2) : 0; if (onGrow) onGrow(+(t * k).toFixed(2));
218
+ const L = glyphShape(font, char, { cap: H }), s = t ? move(mitreGrow(L.s, t), t, -t) : L.s;
219
+ return { s, w: L.w + 2 * t, m: { H, S: wt.S * H + 2 * t, h: h0 + 2 * t, t, dy: t } };
220
+ };
221
+
222
+ /**
223
+ * A glyph source with its ink below the baseline trimmed off (a vertex dipping through a plinth).
224
+ *
225
+ * @param {import('../letters/serif.js').GlyphSource} src The glyph source.
226
+ * @returns {import('../letters/serif.js').GlyphSource} The trimmed source.
227
+ */
228
+ export const onBase = (src) => (H, o) => { const L = src(H, o); return { ...L, s: I(L.s, rect(-50, -200, L.w + 100, H + 200)) }; };
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Letters from font files (Node): a font's capital as a kit shape on the serif and blackletter faces' frame, grown so its
3
+ * hairline holds, the measures to place a move on it, and the moves. Fonts are passed in as paths or bytes; opentype.js,
4
+ * clipper-lib and @resvg/resvg-js are optional peers, loaded on first use.
5
+ */
6
+ export type { FontSource, Font } from './glyphs.js';
7
+ export type { LetterMove } from './moves.js';
8
+ export { loadFont, capUnits, nonzeroShape, glyphShape, mitreGrow, weights, hairline, structuralHairline, fontSource, onBase } from './glyphs.js';
9
+ export { inside, runsH, runsV, strokeDepths, armEnd, thinnestStroke } from './measure.js';
10
+ export { underCrossbar, underTopBar, underBowl, cornerDot, fullStop, starBefore, underFork, underBowlR, inlineOpen, inlineStraight, armLozenge, stemSplit, barLozenge, leftLozenge, tongueLozenge, barEnd, barEnds, crossLozenge } from './moves.js';
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @module @logomarks/core/font
3
+ *
4
+ * Letters from font files (Node): a font's capital as a kit shape on the serif and blackletter faces' frame, grown so its
5
+ * hairline holds, the measures to place a move on it, and the moves. Fonts are passed in as paths or bytes; opentype.js,
6
+ * clipper-lib and @resvg/resvg-js are optional peers, loaded on first use.
7
+ */
8
+ export { loadFont, capUnits, nonzeroShape, glyphShape, mitreGrow, weights, hairline, structuralHairline, fontSource, onBase } from './glyphs.js';
9
+ export { inside, runsH, runsV, strokeDepths, armEnd, thinnestStroke } from './measure.js';
10
+ export { underCrossbar, underTopBar, underBowl, cornerDot, fullStop, starBefore, underFork, underBowlR, inlineOpen, inlineStraight, armLozenge, stemSplit, barLozenge, leftLozenge, tongueLozenge, barEnd, barEnds, crossLozenge } from './moves.js';
@@ -0,0 +1,16 @@
1
+ import type { Shape } from '../geometry/types.js';
2
+
3
+ /** The winding number of a ring about a point. */
4
+ export function wind(ring: readonly (readonly number[])[], p: readonly number[]): number;
5
+ /** Whether (x, y) is in the shape's ink, by winding. */
6
+ export function inside(s: Shape, x: number, y: number): boolean;
7
+ /** The ink runs [a, b] along the horizontal line y. */
8
+ export function runsH(s: Shape, y: number, x0?: number, x1?: number): number[][];
9
+ /** The ink runs [a, b] along the vertical line x. */
10
+ export function runsV(s: Shape, x: number, y0?: number, y1?: number): number[][];
11
+ /** The ink's depth across its strokes from points along the outline, smallest first. */
12
+ export function strokeDepths(s: Shape, step?: number): number[];
13
+ /** Where a stroke ends on the right between y0 and y1, and its depth there. */
14
+ export function armEnd(s: Shape, y0: number, y1: number, o?: { step?: number; back?: number }): { x: number; y: number; th: number; yc: number };
15
+ /** The thinnest structural stroke, by erosion (needs @resvg/resvg-js). */
16
+ export function thinnestStroke(s: Shape, k?: number): number;
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Measures for placing a move on a letter (a font's or any shape): the ink's runs along a line, the depth across its
3
+ * strokes, where an arm ends, and the thinnest stroke holding it together. From the Logomarks tools (fontparts.mjs,
4
+ * batches 242 and 243).
5
+ */
6
+ import { createRequire } from 'node:module';
7
+ import { bbox, toPath } from '../geometry/index.js';
8
+
9
+ /**
10
+ * The winding number of a ring about a point.
11
+ *
12
+ * @param {readonly (readonly number[])[]} ring The ring.
13
+ * @param {readonly number[]} p The point.
14
+ * @returns {number} The winding number.
15
+ */
16
+ export const wind = (ring, [px, py]) => {
17
+ let w = 0;
18
+ for (let i = 0, n = ring.length; i < n; i++) {
19
+ const [x1, y1] = ring[i], [x2, y2] = ring[(i + 1) % n], c = (x2 - x1) * (py - y1) - (px - x1) * (y2 - y1);
20
+ if (y1 <= py ? y2 > py && c > 0 : y2 <= py && c < 0) w += y1 <= py ? 1 : -1;
21
+ }
22
+ return w;
23
+ };
24
+
25
+ /**
26
+ * Whether (x, y) is in the shape's ink: inside an outer ring and outside its holes, by winding.
27
+ *
28
+ * @param {import('../geometry/types.js').Shape} s The shape.
29
+ * @param {number} x The point.
30
+ * @param {number} y The point.
31
+ * @returns {boolean} True when it is.
32
+ */
33
+ export const inside = (s, x, y) => s.some((pg) => wind(pg[0], [x, y]) !== 0 && !pg.slice(1).some((h) => wind(h, [x, y]) !== 0));
34
+
35
+ /**
36
+ * The runs [a, b] where `on(t)` holds, for t from a0 to a1 in steps of 0.25.
37
+ *
38
+ * @param {(t: number) => boolean} on The test.
39
+ * @param {number} a0 Where to start.
40
+ * @param {number} a1 Where to stop.
41
+ * @returns {number[][]} The runs.
42
+ */
43
+ const runs = (on, a0, a1) => {
44
+ const r = []; let a = null;
45
+ for (let t = a0; t <= a1; t += 0.25) { const v = on(t); if (v && a === null) a = t; if (!v && a !== null) { r.push([a, t]); a = null; } }
46
+ if (a !== null) r.push([a, a1]);
47
+ return r;
48
+ };
49
+
50
+ /**
51
+ * The ink runs [a, b] along the horizontal line y (x from x0 to x1, in steps of 0.25).
52
+ *
53
+ * @param {import('../geometry/types.js').Shape} s The shape.
54
+ * @param {number} y The line.
55
+ * @param {number} [x0=-50] Where to start.
56
+ * @param {number} [x1=1000] Where to stop.
57
+ * @returns {number[][]} The runs, left to right.
58
+ */
59
+ export const runsH = (s, y, x0 = -50, x1 = 1000) => runs((x) => inside(s, x, y), x0, x1);
60
+
61
+ /**
62
+ * The ink runs [a, b] along the vertical line x (y from y0 to y1, in steps of 0.25).
63
+ *
64
+ * @param {import('../geometry/types.js').Shape} s The shape.
65
+ * @param {number} x The line.
66
+ * @param {number} [y0=-50] Where to start.
67
+ * @param {number} [y1=1000] Where to stop.
68
+ * @returns {number[][]} The runs, top to bottom.
69
+ */
70
+ export const runsV = (s, x, y0 = -50, y1 = 1000) => runs((y) => inside(s, x, y), y0, y1);
71
+
72
+ /**
73
+ * The ink's depth across its strokes, sorted: from points every `step` along the outline, the distance along the inward
74
+ * normal to the far side (the ray against every edge). Its low end is the thinnest stroke (tips and tapered swash ends
75
+ * included, as a reader at 32 px sees them).
76
+ *
77
+ * @param {import('../geometry/types.js').Shape} s The shape.
78
+ * @param {number} [step=3] The spacing along the outline.
79
+ * @returns {number[]} The depths, smallest first.
80
+ */
81
+ export const strokeDepths = (s, step = 3) => {
82
+ const E = s.flatMap((pg) => pg.flatMap((r) => r.map((p, i) => [p, r[(i + 1) % r.length]]))), out = [];
83
+ for (const [p, q] of E) {
84
+ const L = Math.hypot(q[0] - p[0], q[1] - p[1]); if (L < 0.5) continue; const u = [(q[0] - p[0]) / L, (q[1] - p[1]) / L];
85
+ for (let t = step / 2; t < L; t += step) {
86
+ const x = p[0] + u[0] * t, y = p[1] + u[1] * t; let n = [u[1], -u[0]];
87
+ if (!inside(s, x + n[0] * 0.5, y + n[1] * 0.5)) n = [-n[0], -n[1]];
88
+ let best = Infinity;
89
+ for (const [a, b] of E) {
90
+ const ex = b[0] - a[0], ey = b[1] - a[1], den = n[0] * ey - n[1] * ex; if (Math.abs(den) < 1e-12) continue;
91
+ const wx = a[0] - x, wy = a[1] - y, d = (wx * ey - wy * ex) / den, v = (wx * n[1] - wy * n[0]) / den;
92
+ if (d > 0.3 && v >= 0 && v <= 1 && d < best) best = d;
93
+ }
94
+ if (best < Infinity) out.push(best);
95
+ }
96
+ }
97
+ return out.sort((a, b) => a - b);
98
+ };
99
+
100
+ /**
101
+ * Where a stroke ends on the right inside the band of heights y0…y1 (a middle arm's tip): the rightmost ink point found
102
+ * on rows `step` apart, and the stroke's depth a little back from it (the run down through x − back).
103
+ *
104
+ * @param {import('../geometry/types.js').Shape} s The shape.
105
+ * @param {number} y0 The band's top.
106
+ * @param {number} y1 Its bottom.
107
+ * @param {{ step?: number, back?: number }} [o] The rows' spacing (2) and how far back the depth is read (24).
108
+ * @returns {{ x: number, y: number, th: number, yc: number }} The tip, the stroke's depth there and its middle.
109
+ */
110
+ export const armEnd = (s, y0, y1, { step = 2, back = 24 } = {}) => {
111
+ let best = { x: -Infinity, y: y0 };
112
+ for (let y = y0; y <= y1; y += step) { const r = runsH(s, y); if (r.length && r.at(-1)[1] > best.x) best = { x: r.at(-1)[1], y }; }
113
+ const run = runsV(s, best.x - back).find(([a, b]) => a <= best.y + 2 && b >= best.y - 2) || [best.y - 30, best.y + 30];
114
+ return { ...best, th: run[1] - run[0], yc: (run[0] + run[1]) / 2 };
115
+ };
116
+
117
+ /* ---------- the structural stroke ---------- */
118
+ // strokeDepths() counts tips and spurs: right for a swash face, but a spiked face has so many sharp tips that its low end
119
+ // reads far thinner than its thinnest real stroke. thinnestStroke() measures by erosion instead: the glyph is rasterised k
120
+ // px a unit, eroded by r (a pixel survives while it is r or more from paper), and r raised until the topology changes (a
121
+ // piece splits off, or a counter opens into another). The stroke that breaks first is the thinnest stroke holding the
122
+ // letter together; tips, serif ends and spurs shrink without changing the topology, so they don't count. The raster is
123
+ // @resvg/resvg-js's (an optional peer, loaded on first use): core's own scanline fill differs from it on edge pixels.
124
+
125
+ /**
126
+ * The shape as a 0/1 mask, k px a unit, with a 6 px margin of paper.
127
+ *
128
+ * @param {import('../geometry/types.js').Shape} s The shape.
129
+ * @param {number} k Pixels a unit.
130
+ * @returns {{ M: Uint8Array, W: number, H: number }} The mask and its size.
131
+ */
132
+ const mask = (s, k) => {
133
+ const { Resvg } = createRequire(import.meta.url)('@resvg/resvg-js'), [x0, y0, x1, y1] = bbox(s), m = 6;
134
+ const W = Math.ceil((x1 - x0) * k) + 2 * m, H = Math.ceil((y1 - y0) * k) + 2 * m;
135
+ const px = new Resvg(`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}"><path shape-rendering="crispEdges" fill-rule="evenodd" fill="#000" transform="translate(${m} ${m}) scale(${k}) translate(${-x0} ${-y0})" d="${toPath(s)}"/></svg>`).render().pixels;
136
+ const M = new Uint8Array(W * H); for (let i = 0; i < W * H; i++) M[i] = px[i * 4 + 3] > 127 ? 1 : 0;
137
+ return { M, W, H };
138
+ };
139
+
140
+ /**
141
+ * The squared distance from each ink pixel to the nearest paper pixel (Felzenszwalb and Huttenlocher's two-pass
142
+ * transform).
143
+ *
144
+ * @param {{ M: Uint8Array, W: number, H: number }} mk The mask.
145
+ * @returns {Float64Array} The squared distances, row by row.
146
+ */
147
+ const edt = ({ M, W, H }) => {
148
+ const INF = 1e12, Dt = new Float64Array(W * H), n = Math.max(W, H), f = new Float64Array(n), d = new Float64Array(n), v = new Int32Array(n), z = new Float64Array(n + 1);
149
+ const pass = (len) => {
150
+ let k = 0; v[0] = 0; z[0] = -INF; z[1] = INF;
151
+ for (let q = 1; q < len; q++) {
152
+ let s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);
153
+ while (s <= z[k]) { k--; s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]); }
154
+ k++; v[k] = q; z[k] = s; z[k + 1] = INF;
155
+ }
156
+ k = 0; for (let q = 0; q < len; q++) { while (z[k + 1] < q) k++; d[q] = (q - v[k]) * (q - v[k]) + f[v[k]]; }
157
+ };
158
+ for (let x = 0; x < W; x++) { for (let y = 0; y < H; y++) f[y] = M[y * W + x] ? INF : 0; pass(H); for (let y = 0; y < H; y++) Dt[y * W + x] = d[y]; }
159
+ for (let y = 0; y < H; y++) { for (let x = 0; x < W; x++) f[x] = Dt[y * W + x]; pass(W); for (let x = 0; x < W; x++) Dt[y * W + x] = d[x]; }
160
+ return Dt;
161
+ };
162
+
163
+ /**
164
+ * How many connected pieces there are where on(i) holds (8-connected for ink, 4 for paper), ignoring any under minPx.
165
+ *
166
+ * @param {number} W The width.
167
+ * @param {number} H The height.
168
+ * @param {(i: number) => boolean} on Whether pixel i counts.
169
+ * @param {boolean} eight 8-connected (else 4).
170
+ * @param {number} [minPx=4] The smallest piece that counts.
171
+ * @returns {number} The count.
172
+ */
173
+ const pieces = (W, H, on, eight, minPx = 4) => {
174
+ const seen = new Uint8Array(W * H), st = new Int32Array(W * H); let n = 0;
175
+ const nb = eight ? [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1]] : [[1, 0], [-1, 0], [0, 1], [0, -1]];
176
+ for (let i = 0; i < W * H; i++) {
177
+ if (seen[i] || !on(i)) continue; let sp = 0, size = 0; st[sp++] = i; seen[i] = 1;
178
+ while (sp) {
179
+ const j = st[--sp], x = j % W, y = (j - x) / W; size++;
180
+ for (const [dx, dy] of nb) { const X = x + dx, Y = y + dy; if (X < 0 || Y < 0 || X >= W || Y >= H) continue; const q = Y * W + X; if (!seen[q] && on(q)) { seen[q] = 1; st[sp++] = q; } }
181
+ }
182
+ if (size >= minPx) n++;
183
+ }
184
+ return n;
185
+ };
186
+
187
+ /**
188
+ * The thinnest structural stroke of a shape, in its units (±1/k): the erosion radius at which a piece splits off or a
189
+ * counter opens. Needs @resvg/resvg-js (an optional peer).
190
+ *
191
+ * @param {import('../geometry/types.js').Shape} s The shape.
192
+ * @param {number} [k=2] Pixels a unit.
193
+ * @returns {number} The stroke's width (Infinity when nothing breaks before r 80).
194
+ */
195
+ export const thinnestStroke = (s, k = 2) => {
196
+ const mk = mask(s, k), Dt = edt(mk), { W, H } = mk;
197
+ const topo = (r) => { const r2 = r * r; return [pieces(W, H, (i) => Dt[i] >= r2, true), pieces(W, H, (i) => Dt[i] < r2, false)]; };
198
+ let [ink, paper] = topo(1);
199
+ for (let r = 1.5; r < 80; r += 0.5) { const [a, b] = topo(r); if (a > ink || b < paper) return (2 * (r - 0.75)) / k; ink = Math.min(ink, a); paper = b; }
200
+ return Infinity;
201
+ };
@@ -0,0 +1,42 @@
1
+ import type { Polygon, Shape } from '../geometry/types.js';
2
+ import type { Face, Placer, SourceGlyph, GlyphSource } from '../letters/serif.js';
3
+
4
+ /** A move on a blackletter letter: the letter and the gap to the new letter. */
5
+ export type LetterMove = (s: Shape, g: number) => Shape;
6
+
7
+ /** Split (A): the gap just under the crossbar. */
8
+ export function underCrossbar(m: Face, w: number, g: number, L: SourceGlyph): Polygon;
9
+ /** Split (T): the gap right under the top bar. */
10
+ export function underTopBar(at?: number): Placer<Polygon>;
11
+ /** Split (P): the gap across the stem under the bowl. */
12
+ export function underBowl(below?: number): Placer<Polygon>;
13
+ /** Dot (L): a ball in the L's corner, clear of the foot. */
14
+ export function cornerDot(o?: { along?: number; from?: number; clear?: number; r?: number }): Placer<number[]>;
15
+ /** Stop (H): a full stop a gap after the letter. */
16
+ export function fullStop(k?: number): Placer<number[]>;
17
+ /** Star (J): a five-point star before the stem. */
18
+ export function starBefore(o?: { k?: number; y?: number }): Placer<number[]>;
19
+ /** Split (Y): the gap just under the fork. */
20
+ export function underFork(m: Face, w: number, g: number, L: SourceGlyph): Polygon;
21
+ /** Split (R): the gap across the stem only, under the bowl's foot. */
22
+ export function underBowlR(m: Face, w: number, g: number, L: SourceGlyph): Polygon;
23
+ /** The serif inline, its channel opened where a stroke tapers. */
24
+ export function inlineOpen(src: GlyphSource, H?: number, open?: number): (g: number) => Shape;
25
+ /** The inline with a straight channel, for straight-stroked letters. */
26
+ export function inlineStraight(src: GlyphSource, H?: number, open?: number): (g: number) => Shape;
27
+ /** A parted lozenge on a font's middle arm. */
28
+ export function armLozenge(cap: number, y0: number, y1: number, k?: number): LetterMove;
29
+ /** A split stem down from the head or up from the foot. */
30
+ export function stemSplit(cap: number, end: 'head' | 'foot', depth: number): LetterMove;
31
+ /** A lozenge set across the H's bar. */
32
+ export function barLozenge(cap: number, o?: { k?: number; at?: number }): LetterMove;
33
+ /** armLozenge mirrored, for a stroke ending on the left. */
34
+ export function leftLozenge(cap: number, y0: number, y1: number, k?: number): LetterMove;
35
+ /** A short middle tongue taken off whole, a lozenge a gap clear of the stem. */
36
+ export function tongueLozenge(cap: number, y0: number, y1: number, z?: number): LetterMove;
37
+ /** A bar's right end run into a level parted lozenge. */
38
+ export function barEnd(cap: number, o?: { back?: number; k?: number }): LetterMove;
39
+ /** barEnd at both ends of the bar. */
40
+ export function barEnds(cap: number, o?: { back?: number; k?: number }): LetterMove;
41
+ /** A parted lozenge at the crossing or the fork. */
42
+ export function crossLozenge(cap: number, y0: number, y1: number, k?: number): LetterMove;