@logomarks/core 0.1.11 → 0.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 (114) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +9 -2
  3. package/package.json +47 -4
  4. package/src/analyse/index.d.ts +1 -0
  5. package/src/analyse/index.js +3 -2
  6. package/src/analyse/optical.d.ts +20 -0
  7. package/src/analyse/optical.js +76 -0
  8. package/src/edit/cells.d.ts +13 -0
  9. package/src/edit/cells.js +26 -0
  10. package/src/edit/index.d.ts +3 -1
  11. package/src/edit/index.js +4 -2
  12. package/src/edit/pixelate.d.ts +2 -0
  13. package/src/edit/pixelate.js +15 -0
  14. package/src/font/glyphs.d.ts +31 -0
  15. package/src/font/glyphs.js +228 -0
  16. package/src/font/index.d.ts +10 -0
  17. package/src/font/index.js +10 -0
  18. package/src/font/measure.d.ts +16 -0
  19. package/src/font/measure.js +201 -0
  20. package/src/font/moves.d.ts +42 -0
  21. package/src/font/moves.js +263 -0
  22. package/src/forms/discs.d.ts +20 -0
  23. package/src/forms/discs.js +114 -0
  24. package/src/forms/emblems.d.ts +22 -0
  25. package/src/forms/emblems.js +109 -0
  26. package/src/forms/flow.d.ts +21 -0
  27. package/src/forms/flow.js +80 -0
  28. package/src/forms/folds.d.ts +18 -0
  29. package/src/forms/folds.js +147 -0
  30. package/src/forms/geo.d.ts +46 -0
  31. package/src/forms/geo.js +233 -0
  32. package/src/forms/hooks.d.ts +40 -0
  33. package/src/forms/hooks.js +204 -0
  34. package/src/forms/index.d.ts +22 -0
  35. package/src/forms/index.js +24 -0
  36. package/src/forms/jog.d.ts +33 -0
  37. package/src/forms/jog.js +162 -0
  38. package/src/forms/lines.d.ts +32 -0
  39. package/src/forms/lines.js +233 -0
  40. package/src/forms/ref.d.ts +41 -0
  41. package/src/forms/ref.js +246 -0
  42. package/src/forms/shatter.d.ts +40 -0
  43. package/src/forms/shatter.js +341 -0
  44. package/src/forms/slats.d.ts +35 -0
  45. package/src/forms/slats.js +78 -0
  46. package/src/forms/swash.d.ts +23 -0
  47. package/src/forms/swash.js +74 -0
  48. package/src/forms/tech.d.ts +40 -0
  49. package/src/forms/tech.js +293 -0
  50. package/src/forms/tracks.d.ts +36 -0
  51. package/src/forms/tracks.js +121 -0
  52. package/src/index.js +2 -1
  53. package/src/iso/index.d.ts +5 -0
  54. package/src/iso/index.js +13 -0
  55. package/src/iso/objects.d.ts +168 -0
  56. package/src/iso/objects.js +1042 -0
  57. package/src/iso/planes.d.ts +27 -0
  58. package/src/iso/planes.js +138 -0
  59. package/src/iso/voxels.d.ts +43 -0
  60. package/src/iso/voxels.js +255 -0
  61. package/src/letters/blackletter.d.ts +31 -0
  62. package/src/letters/blackletter.js +199 -0
  63. package/src/letters/dotstroke.d.ts +16 -0
  64. package/src/letters/dotstroke.js +77 -0
  65. package/src/letters/echo.d.ts +42 -0
  66. package/src/letters/echo.js +246 -0
  67. package/src/letters/frame.d.ts +6 -0
  68. package/src/letters/frame.js +14 -0
  69. package/src/letters/index.d.ts +16 -0
  70. package/src/letters/index.js +18 -0
  71. package/src/letters/inktrap.d.ts +66 -0
  72. package/src/letters/inktrap.js +244 -0
  73. package/src/letters/mono.d.ts +145 -0
  74. package/src/letters/mono.js +684 -0
  75. package/src/letters/pillar.d.ts +31 -0
  76. package/src/letters/pillar.js +257 -0
  77. package/src/letters/serif.d.ts +109 -0
  78. package/src/letters/serif.js +666 -0
  79. package/src/letters/stair.d.ts +8 -0
  80. package/src/letters/stair.js +43 -0
  81. package/src/letters/stem.d.ts +29 -0
  82. package/src/letters/stem.js +205 -0
  83. package/src/objects/animals.d.ts +105 -0
  84. package/src/objects/animals.js +469 -0
  85. package/src/objects/bio.d.ts +14 -0
  86. package/src/objects/bio.js +76 -0
  87. package/src/objects/devglyphs.d.ts +34 -0
  88. package/src/objects/devglyphs.js +226 -0
  89. package/src/objects/devices.d.ts +31 -0
  90. package/src/objects/devices.js +114 -0
  91. package/src/objects/faces.d.ts +94 -0
  92. package/src/objects/faces.js +344 -0
  93. package/src/objects/food.d.ts +12 -0
  94. package/src/objects/food.js +74 -0
  95. package/src/objects/index.d.ts +13 -0
  96. package/src/objects/index.js +18 -0
  97. package/src/objects/rosettes.d.ts +14 -0
  98. package/src/objects/rosettes.js +64 -0
  99. package/src/objects/sport.d.ts +8 -0
  100. package/src/objects/sport.js +37 -0
  101. package/src/objects/weapons.d.ts +31 -0
  102. package/src/objects/weapons.js +179 -0
  103. package/src/pixels/dither.d.ts +78 -0
  104. package/src/pixels/dither.js +399 -0
  105. package/src/pixels/emote.d.ts +6 -0
  106. package/src/pixels/emote.js +76 -0
  107. package/src/pixels/index.d.ts +7 -0
  108. package/src/pixels/index.js +8 -0
  109. package/src/sdf/index.d.ts +2 -0
  110. package/src/sdf/index.js +2 -0
  111. package/src/sdf/y2k.d.ts +12 -0
  112. package/src/sdf/y2k.js +80 -0
  113. package/src/util/vec.d.ts +14 -0
  114. package/src/util/vec.js +61 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,39 @@ All notable changes to `@logomarks/core` are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.1] — 2026-11-15
9
+
10
+ ### Added
11
+
12
+ The Lookbook's mark measures, moved here from `@logomarks/lookbook/marks` (the code unchanged, documented and typed), so the cards and anything else that sizes a mark share one copy. Browser-safe, like the rest of `/analyse` and `/edit`.
13
+
14
+ - **The optical size in `/analyse`:** `markBox(paths)` (the real box round a mark's outlines, the 448 fit's box when there is nothing to read), `inkShare(paths)` (how much of that box is ink, by scanline coverage 16 cells across), `opticalScale(box, ink)` (how much bigger to draw a wide, slim or light mark: 1 for a square solid, 448 × 200 about 1.22, never above 1.3) and `OPTICAL`, its limits.
15
+ - **Cells by count in `/edit`:** `cellsAcross(n, box)` beside `pixelGrid` (a grid `n` cells across the box's longest side, any `n`, where `pixelGrid` takes an amount and stops at 14 across), and `markCells(paths, n)` (shapes → rings → coverage → filled cells and their path in one call).
16
+
17
+ Proved on `@logomarks/lookbook` 0.1.0: its own functions give the same values as these on the golden cases, and its equivalence tests pass unchanged on them. The golden tests gain the five cases (`optical.*`, `cells.*`), on six live marks.
18
+
19
+ ## [0.2.0] — 2026-11-15
20
+
21
+ ### Added
22
+
23
+ Every generic parts module the Logomarks generators still kept locally, moved here (the code unchanged, each part documented and typed), so the site keeps only its marks. Six new subpaths, none re-exported from the root:
24
+
25
+ - **`/iso`:** iso objects on `/parts`' `scene()` (cylinders, frustums, lathes, poles, roofs, shops and `shopMark()`, openings cut on the fitted faces with `isoCut()`, cut-outs square to the viewer drawn by `isoFlat()`, tipped slabs), flat planes hinged and folded into one mark (`planeScene()`, `planeMark()`, `planeGlyph()`, iso or oblique `ISO`/`OBL`), and voxels (`voxRight`, `voxPlan`, `pixDepth`, `slabs`, corner monograms `cornerMark()`). From the tools' isoparts, planeparts and blockparts.
26
+ - **`/letters`:** letters built from geometry: a Didone capital alphabet with its frames, interlace and one-move builds (`face`, `SERIF_GLYPHS`, `serifLetter`, `pairOf`, `split`, `ruled`, `framed`…), blackletter from one 30° nib (`nib`, `parted`, `BLACKLETTERS`, `blackLetter`), condensed pairs on one stem (`pillar`, `stack`), the monogram letters, and the Echo, Dot Stroke, Ink Trap and Stair faces, the last two on one 448 frame `T`, `B`, `H`.
27
+ - **`/font`:** a capital from a font file as a shape (`glyphShape`, `fontSource`, grown mitred to a fitted hairline), its measures (`weights`, `hairline`, `structuralHairline`, `thinnestStroke`, `runsH`…) and the moves placed on it (`underCrossbar`, `armLozenge`, `stemSplit`, `inlineOpen`…). `loadFont()` takes a path or the font's bytes and names no folder: the fonts stay with whoever draws with them.
28
+ - **`/pixels`:** dither tapers (cells, ramps, ordered-dither `taper()`, `sprite()`, free squares) and typed pixel emotes (`emote`).
29
+ - **`/objects`:** devices and code glyphs, blades, dishes, the boxing glove and pixel sprites, cells, faces and mascot features, animals (the gecko build, two-track animals, bold outlines) and rosettes.
30
+ - **`/forms`:** the Bold, Line and Track constructions: needle gaps and knobs, split discs, hooked capsules, jogs and chamfer checkers, tech sparkles and ribbons, split emblems, shatter, turning slats, flow grids, fold seams, swashes, rotation, stripes, guilloche and speed letters, the Track editions' kit, and soft blobs, Rail lines and interlocking locks.
31
+ - **`/sdf`** gains the Y2K parts built from its fields: `pinch`, `ballArm`, `node`, `blob`, `stadium`.
32
+
33
+ Renamed where two modules' names met in one subpath (`blackLetter`, `serifLetter`, `monoBowl`, `DOT_S`, `TRAP_S`, `PILLAR_LEFT`, `waveArm`, `geoDisc`, `discAt`, `hookAt`, `discSide`, `techChevron`, `foldQuad`, `swashQuad`, `evenBands`, `evenRing`, `shardStrip`, `GEO_X0`, `EMBLEM_W`, `HOOK_W`, `DISC_R`, `planeMark`, `planeGlyph`…). Copies of what core already had were dropped for core's own (`GAP`, `rad`, `openRing`, `mitreOffset`, `mitreBand`, `joinPts`, `chain`, `filletClosed`), and the vector helpers the families repeated are written once.
34
+
35
+ ### Dependencies
36
+
37
+ - `/font` loads three optional peers on first use: **opentype.js** (reading the font), **clipper-lib** (already a peer for `/edit`: `mitreGrow()` keeps Clipper's integer offset, which the exact `mitreOffset`/`offsetPolygon` can't match byte for byte) and **@resvg/resvg-js** (`thinnestStroke()`'s mask: core's own scanline fill was tried and differs on the edge pixels of 172 of 176 glyphs, so it would change the measured hairlines). Nothing else needs them.
38
+
39
+ Proved on the Logomarks generators: all 206 that import these parts redraw byte-identical with core in place of the local modules. The golden tests cover every new export the behaviour tests don't reach.
40
+
8
41
  ## [0.1.11] — 2026-11-15
9
42
 
10
43
  ### Added
package/README.md CHANGED
@@ -7,6 +7,7 @@ Geometry, SVG reading, fitting, analysis and similarity for logomarks on a 512 g
7
7
  ```bash
8
8
  npm install @logomarks/core
9
9
  npm install clipper-lib # only for the shape edits (reshape)
10
+ npm install opentype.js clipper-lib @resvg/resvg-js # only for /font
10
11
  ```
11
12
 
12
13
  ## Quick start
@@ -26,13 +27,19 @@ checkFit(mark).problems; // []
26
27
  - `/geometry`: shapes and real booleans, strokes, tracks, iso solids, badges and letter skeletons
27
28
  - `/svg`: one SVG path parser, filled shapes, bounds and rings
28
29
  - `/fit`: the 448 fit and its check (refitting a fitted mark gives the same file)
29
- - `/analyse`: specks, slivers and pin-holes, gap and stroke widths, shape tags
30
+ - `/analyse`: specks, slivers and pin-holes, gap and stroke widths, shape tags, the optical size (a mark's box, its ink, how much bigger to draw it)
30
31
  - `/similar`: outline and letter signatures, IoU and nearest marks
31
32
  - `/raster`: scanline fill, components, distance transform
32
- - `/edit`: the studio's shape edits (weight, soften, gap, detail) and pixelate
33
+ - `/edit`: the studio's shape edits (weight, soften, gap, detail) and pixelate, by amount or by a count of cells
33
34
  - `/sdf`: shapes as signed distance fields, smooth unions and exact gap cuts, traced to kit shapes
34
35
  - `/stitch`: a mark planned as machine embroidery (fill, satin and border stitches), for a renderer to draw as thread
35
36
  - `/parts`: the parts the industry symbols are built from (Node)
37
+ - `/iso`: iso objects (lathes, roofs, shops, cut-outs square to the viewer, tipped slabs), folded planes and voxels
38
+ - `/letters`: letters built from geometry: a Didone serif, a blackletter nib, monogram, pillar and stem pairs, and the echo, dot-stroke, ink-trap and stair faces
39
+ - `/font`: a capital from a font file (a path or its bytes) as a shape, its hairline measured and grown, and the house moves placed on it (Node)
40
+ - `/pixels`: dither tapers that break a pixel mark up, and typed pixel emotes
41
+ - `/objects`: devices and code glyphs, blades, dishes, sport, cells, faces, animals and rosettes
42
+ - `/forms`: the Bold, Line and Track forms: discs, hooks, jogs, emblems, shatter, slats, flow grids, folds, swashes, stripes, rails, locks and track editions
36
43
  - `/batch`: a generator's runner: seeded marks, the sidecars, the command line, `slug` (Node)
37
44
  - `logomarks-skill <dir>`: writes the design skill's `kit.mjs` and `fit.mjs`
38
45
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@logomarks/core",
3
- "version": "0.1.11",
4
- "description": "Geometry, SVG reading, fitting, analysis and similarity for logomarks on a 512 grid: the kit the Logomarks set is drawn with (shapes, real booleans, strokes, tracks, iso solids, badges, letter skeletons), one SVG path parser, the 448 fit, speck and shape-tag analysis, outline signatures, signed distance fields traced to shapes, stitch plans for embroidery, the industry-symbol parts and the batch runner generators use.",
3
+ "version": "0.2.1",
4
+ "description": "Geometry, SVG reading, fitting, analysis and similarity for logomarks on a 512 grid: the kit the Logomarks set is drawn with (shapes, real booleans, strokes, tracks, iso solids, badges, letter skeletons), one SVG path parser, the 448 fit, speck and shape-tag analysis, outline signatures, signed distance fields traced to shapes, stitch plans for embroidery, and the parts the generators draw with: iso objects, letters and type, font glyphs, pixel dither, objects and the Bold, Line and Track forms, plus the batch runner.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "types": "src/index.d.ts",
@@ -22,6 +22,36 @@
22
22
  "import": "./src/parts/index.js",
23
23
  "default": "./src/parts/index.js"
24
24
  },
25
+ "./iso": {
26
+ "types": "./src/iso/index.d.ts",
27
+ "import": "./src/iso/index.js",
28
+ "default": "./src/iso/index.js"
29
+ },
30
+ "./letters": {
31
+ "types": "./src/letters/index.d.ts",
32
+ "import": "./src/letters/index.js",
33
+ "default": "./src/letters/index.js"
34
+ },
35
+ "./font": {
36
+ "types": "./src/font/index.d.ts",
37
+ "import": "./src/font/index.js",
38
+ "default": "./src/font/index.js"
39
+ },
40
+ "./pixels": {
41
+ "types": "./src/pixels/index.d.ts",
42
+ "import": "./src/pixels/index.js",
43
+ "default": "./src/pixels/index.js"
44
+ },
45
+ "./objects": {
46
+ "types": "./src/objects/index.d.ts",
47
+ "import": "./src/objects/index.js",
48
+ "default": "./src/objects/index.js"
49
+ },
50
+ "./forms": {
51
+ "types": "./src/forms/index.d.ts",
52
+ "import": "./src/forms/index.js",
53
+ "default": "./src/forms/index.js"
54
+ },
25
55
  "./batch": {
26
56
  "types": "./src/batch/index.d.ts",
27
57
  "import": "./src/batch/index.js",
@@ -103,7 +133,10 @@
103
133
  "embroidery",
104
134
  "satin-stitch",
105
135
  "signed-distance-field",
106
- "sdf"
136
+ "sdf",
137
+ "letters",
138
+ "font",
139
+ "pixel-art"
107
140
  ],
108
141
  "author": "David Sherlock",
109
142
  "license": "MIT",
@@ -124,16 +157,26 @@
124
157
  "polygon-clipping": "^0.15.7"
125
158
  },
126
159
  "peerDependencies": {
127
- "clipper-lib": "^6.4.2"
160
+ "@resvg/resvg-js": "^2.6.2",
161
+ "clipper-lib": "^6.4.2",
162
+ "opentype.js": "^2.0.0"
128
163
  },
129
164
  "peerDependenciesMeta": {
165
+ "@resvg/resvg-js": {
166
+ "optional": true
167
+ },
130
168
  "clipper-lib": {
131
169
  "optional": true
170
+ },
171
+ "opentype.js": {
172
+ "optional": true
132
173
  }
133
174
  },
134
175
  "devDependencies": {
176
+ "@resvg/resvg-js": "^2.6.2",
135
177
  "@types/node": "^26.6.6",
136
178
  "clipper-lib": "^6.4.2",
179
+ "opentype.js": "^2.0.0",
137
180
  "typescript": "^6.0.3"
138
181
  }
139
182
  }
@@ -6,3 +6,4 @@ export { SPECKS, vectorParts, rasterParts, markParts, smallParts } from './speck
6
6
  export { SHAPE, shapeTags, tagsOf } from './tags.js';
7
7
  export { MEASURE, shapeMeasures } from './shape/measures.js';
8
8
  export { WIDTHS, groupRuns, widths } from './widths.js';
9
+ export { OPTICAL, markBox, inkShare, opticalScale } from './optical.js';
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Analysis of a mark: its pieces and holes (specks, slivers, pin-holes), its stroke and gap widths, and its shape tags
3
- * (with the measures they are decided on).
2
+ * Analysis of a mark: its pieces and holes (specks, slivers, pin-holes), its stroke and gap widths, its shape tags
3
+ * (with the measures they are decided on), and its optical size (its real box and how much of it is ink).
4
4
  */
5
5
  export { SPECKS, vectorParts, rasterParts, markParts, smallParts } from './specks.js';
6
6
  export { SHAPE, shapeTags, tagsOf } from './tags.js';
7
7
  export { MEASURE, shapeMeasures } from './shape/measures.js';
8
8
  export { WIDTHS, groupRuns, widths } from './widths.js';
9
+ export { OPTICAL, markBox, inkShare, opticalScale } from './optical.js';
@@ -0,0 +1,20 @@
1
+ import type { Bounds } from '../svg/outline.js';
2
+
3
+ /** The optical size's limits: the most a mark grows, and how much its box and its ink count. */
4
+ export const OPTICAL: {
5
+ /** The most a mark grows (× its 448 fit). */
6
+ readonly max: 1.3;
7
+ /** The box's term: (448² / its area) to this power, so a square is 1, 448 × 200 about 1.22. */
8
+ readonly power: 0.25;
9
+ /** The ink's term: up to this much more for a light mark … */
10
+ readonly light: 0.1;
11
+ /** … in full at or below this share of its box inked, none at or above `solid`. */
12
+ readonly thin: 0.2;
13
+ readonly solid: 0.45;
14
+ };
15
+ /** The real box round a mark's outlines (its paths, or the whole file), on the 512 canvas; the 448 fit's box when there is nothing to read. */
16
+ export function markBox(paths: string): Bounds;
17
+ /** How much of a mark's own box is ink, 0–1 (scanline coverage, 16 cells across its longest side); 1 when there is nothing to read. */
18
+ export function inkShare(paths: string): number;
19
+ /** The mark's optical size: how much bigger to draw a wide, slim or light mark, 1 to `OPTICAL.max`; `ink` 1 leaves the ink out of it. */
20
+ export function opticalScale(box: Pick<Bounds, 'w' | 'h'>, ink?: number): number;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * @module @logomarks/core/analyse/optical
3
+ *
4
+ * A mark's optical size: its real box, how much of that box is ink, and how much bigger to draw it so a wide, slim or
5
+ * light mark holds as much of the eye as a square, solid one at the 448 fit. Browser-safe (core's SVG reader and
6
+ * scanline coverage, no renderer). From the Lookbook's mark measures (`@logomarks/lookbook/marks`).
7
+ */
8
+
9
+ import { shapes } from '../svg/shapes.js';
10
+ import { bounds } from '../svg/outline.js';
11
+ import { rings } from '../svg/rings.js';
12
+ import { cellsAcross, pixelCoverage } from '../edit/pixelate.js';
13
+ import { CANVAS, FIT } from '../fit/index.js';
14
+
15
+ /** The 448 fit's box on the 512 canvas: what `markBox` gives when there is nothing to read. */
16
+ const FITTED = { x: (CANVAS - FIT) / 2, y: (CANVAS - FIT) / 2, w: FIT, h: FIT };
17
+
18
+ /** The optical size's limits: the most a mark grows, and how much its box and its ink count. */
19
+ export const OPTICAL = {
20
+ /** The most a mark grows (× its 448 fit). */
21
+ max: 1.3,
22
+ /** The box's term: (448² / its area) to this power, so a square is 1, 448 × 200 about 1.22. */
23
+ power: 0.25,
24
+ /** The ink's term: up to this much more for a light mark … */
25
+ light: 0.1,
26
+ /** … in full at or below this share of its box inked, none at or above `solid`. */
27
+ thin: 0.2,
28
+ solid: 0.45,
29
+ };
30
+
31
+ /**
32
+ * The real box round a mark's outlines, on the 512 canvas (curves and arcs sampled, as the fit measures them); the
33
+ * 448 fit's box when there is nothing to read.
34
+ *
35
+ * @param {string} paths The mark's markup: its paths, or the whole file.
36
+ * @returns {import('../svg/outline.js').Bounds} The box.
37
+ */
38
+ export function markBox(paths) {
39
+ const list = shapes(paths);
40
+ if (!list.length) return { ...FITTED };
41
+ const b = bounds(list);
42
+ return Number.isFinite(b.w) && b.w > 0 ? b : { ...FITTED };
43
+ }
44
+
45
+ /**
46
+ * How much of a mark's own box is ink, 0–1: the scanline coverage on a grid 16 cells across its longest side.
47
+ *
48
+ * @param {string} paths The mark's markup: its paths, or the whole file.
49
+ * @returns {number} The share inked; 1 when there is nothing to read.
50
+ */
51
+ export function inkShare(paths) {
52
+ const list = shapes(paths);
53
+ if (!list.length) return 1;
54
+ const grid = cellsAcross(16, markBox(paths));
55
+ const cov = pixelCoverage(list.map((s) => ({ rings: rings(s.segs, 3), evenodd: s.evenodd })), grid);
56
+ let sum = 0;
57
+ for (const v of cov) sum += v;
58
+ return cov.length ? sum / cov.length : 1;
59
+ }
60
+
61
+ /**
62
+ * The mark's optical size: how much bigger to draw it so it holds as much of the eye as a square, solid mark at the
63
+ * 448 fit. Every mark is fitted to 448 on its longest side, so a wide, short one (448 × 200) or a slim one (266 × 448)
64
+ * carries far less area, and a light, line-drawn one less weight. 1 for a square solid; 448 × 200 about 1.22; never
65
+ * below 1, never above `OPTICAL.max`.
66
+ *
67
+ * @param {{ w: number, h: number }} box The mark's box (`markBox`).
68
+ * @param {number} [ink=1] The share of its box inked (`inkShare`); 1 leaves the ink out of it.
69
+ * @returns {number} The scale, 1 to `OPTICAL.max`.
70
+ */
71
+ export function opticalScale(box, ink = 1) {
72
+ const { max, power, light, thin, solid } = OPTICAL;
73
+ const area = (FIT * FIT) / Math.max(1, box.w * box.h);
74
+ const weight = 1 + light * Math.min(1, Math.max(0, (solid - ink) / (solid - thin)));
75
+ return Math.min(max, Math.max(1, area ** power * weight));
76
+ }
@@ -0,0 +1,13 @@
1
+ import type { PixelGrid } from './pixelate.js';
2
+
3
+ /** A mark as square cells: the grid, which cells are filled (row by row), and the filled cells as one path. */
4
+ export interface MarkCells {
5
+ grid: PixelGrid;
6
+ /** 1 per filled cell, row by row. */
7
+ on: Uint8Array;
8
+ /** The filled cells merged into rectangles, as one path's d; empty when none is filled. */
9
+ d: string;
10
+ }
11
+
12
+ /** The mark (its paths, or the whole file) as square cells, `n` across its longest side, by scanline coverage. */
13
+ export function markCells(paths: string, n: number): MarkCells;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @module @logomarks/core/edit/cells
3
+ *
4
+ * A mark as square cells by count: shapes → rings → scanline coverage → filled cells in one call, on a grid `n` cells
5
+ * across the mark's real box (`cellsAcross`). Browser-safe. From the Lookbook's mark measures (its pixel and LED looks).
6
+ */
7
+
8
+ import { shapes } from '../svg/shapes.js';
9
+ import { rings } from '../svg/rings.js';
10
+ import { markBox } from '../analyse/optical.js';
11
+ import { cellsAcross, pixelCoverage, pixelCells, pixelPath } from './pixelate.js';
12
+
13
+ /**
14
+ * The mark as square cells, `n` across its longest side, measured by scanline coverage (half a cell covered, or a
15
+ * fifth where a cell would otherwise break a thin line: `pixelCells`).
16
+ *
17
+ * @param {string} paths The mark's markup: its paths, or the whole file.
18
+ * @param {number} n Cells across the longest side.
19
+ * @returns {import('./cells.js').MarkCells} The grid, the filled cells and the filled cells as one path (empty when none).
20
+ */
21
+ export function markCells(paths, n) {
22
+ const list = shapes(paths);
23
+ const grid = cellsAcross(n, markBox(paths));
24
+ const cov = pixelCoverage(list.map((s) => ({ rings: rings(s.segs), evenodd: s.evenodd })), grid);
25
+ return { grid, on: pixelCells(cov, grid), d: pixelPath(cov, grid) ?? '' };
26
+ }
@@ -1,6 +1,8 @@
1
1
  export type { ShapeInfo, SmallSizeInfo } from './reshape.js';
2
2
  export type { Adjust, Frame, Similarity, AdjustEnv } from './adjust.js';
3
3
  export type { PixelGrid } from './pixelate.js';
4
+ export type { MarkCells } from './cells.js';
4
5
  export { loadShaper, shaperReady, hasCorners, reshape, pieceCount, shapeInfo, smallSizeInfo } from './reshape.js';
5
6
  export { NO_ADJUST, isAdjusted, adjustSuffix, transformPath, adjustPaths } from './adjust.js';
6
- export { SS, pixelGrid, pixelPath, pixelCells, pixelCoverage } from './pixelate.js';
7
+ export { SS, pixelGrid, cellsAcross, pixelPath, pixelCells, pixelCoverage } from './pixelate.js';
8
+ export { markCells } from './cells.js';
package/src/edit/index.js CHANGED
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * Shape edits for a studio: weight, soften, gap and detail (Clipper, loaded on first use), turns, tilts, flips and
3
- * frames, and pixel-art redraws. Browser-safe: what needs a renderer (exact bounds, a canvas) is passed in.
3
+ * frames, and pixel-art redraws (by amount, or by a count of cells across). Browser-safe: what needs a renderer (exact
4
+ * bounds, a canvas) is passed in.
4
5
  */
5
6
  export { loadShaper, shaperReady, hasCorners, reshape, pieceCount, shapeInfo, smallSizeInfo } from './reshape.js';
6
7
  export { NO_ADJUST, isAdjusted, adjustSuffix, transformPath, adjustPaths } from './adjust.js';
7
- export { SS, pixelGrid, pixelPath, pixelCells, pixelCoverage } from './pixelate.js';
8
+ export { SS, pixelGrid, cellsAcross, pixelPath, pixelCells, pixelCoverage } from './pixelate.js';
9
+ export { markCells } from './cells.js';
@@ -13,6 +13,8 @@ export interface PixelGrid {
13
13
  export const SS: 6;
14
14
  /** The grid for a box: 48 cells across its longest side at amount 0, 14 at amount 1, centred. */
15
15
  export function pixelGrid(amount: number, box: { x: number; y: number; width: number; height: number }): PixelGrid;
16
+ /** The grid for a box by count: `n` cells across its longest side (any `n`), centred. */
17
+ export function cellsAcross(n: number, box: { x: number; y: number; w: number; h: number }): PixelGrid;
16
18
  /** How much of each cell the mark's rings cover (0–1, row by row), by scanline at SS × SS samples a cell. */
17
19
  export function pixelCoverage(paths: readonly FillPath[], grid: PixelGrid): Float32Array;
18
20
  /** The filled cells (1 each, row by row): half covered, or a fifth where a cell stands alone or bridges two. */
@@ -36,6 +36,21 @@ export function pixelGrid(amount, box) {
36
36
  return { cols, rows, cell, x0, y0 };
37
37
  }
38
38
 
39
+ /**
40
+ * A pixel grid of `n` cells across a box's longest side, centred on it: `pixelGrid` sized by a count, any count
41
+ * (`pixelGrid` takes an amount and stops at 14 across).
42
+ *
43
+ * @param {number} n Cells across the longest side.
44
+ * @param {{ x: number, y: number, w: number, h: number }} box The mark's box (`markBox` in `/analyse`, or `bounds`).
45
+ * @returns {import('./index.js').PixelGrid} The grid: columns, rows, cell size and top-left corner.
46
+ */
47
+ export function cellsAcross(n, box) {
48
+ const cell = Math.max(box.w, box.h) / n;
49
+ const cols = Math.max(1, Math.round(box.w / cell)), rows = Math.max(1, Math.round(box.h / cell));
50
+ const x0 = box.x + box.w / 2 - (cols * cell) / 2, y0 = box.y + box.h / 2 - (rows * cell) / 2;
51
+ return { cols, rows, cell, x0, y0 };
52
+ }
53
+
39
54
  /**
40
55
  * How much of each cell the mark covers, measured from its rings by scanline at `SS` × `SS` samples a cell: what a
41
56
  * canvas would measure, without one.
@@ -0,0 +1,31 @@
1
+ import type { Shape } from '../geometry/types.js';
2
+ import type { GlyphSource } from '../letters/serif.js';
3
+
4
+ /** A font: its file's path, or its bytes. */
5
+ export type FontSource = string | Uint8Array | ArrayBuffer;
6
+ /** A parsed font (opentype.js's Font): what /font reads of it. */
7
+ export interface Font {
8
+ charToGlyph(char: string): { path: { commands: { type: string; x?: number; y?: number; x1?: number; y1?: number; x2?: number; y2?: number }[] } };
9
+ [key: string]: unknown;
10
+ }
11
+
12
+ /** A font, parsed and cached (needs opentype.js). */
13
+ export function loadFont(source: FontSource): Font;
14
+ /** A font's cap height in font units: the top of its H. */
15
+ export function capUnits(font: Font): number;
16
+ /** Path data filled non-zero, as a shape. */
17
+ export function nonzeroShape(d: string, step?: number): Shape;
18
+ /** A font's character at cap height `cap`, its ink from x 0. */
19
+ export function glyphShape(font: FontSource, char: string, o?: { cap?: number; step?: number }): { s: Shape; w: number };
20
+ /** The shape grown by d, its corners mitred (needs clipper-lib). */
21
+ export function mitreGrow(shape: Shape, d: number, limit?: number): Shape;
22
+ /** A font's stem and hairline, from its H, as fractions of the cap. */
23
+ export function weights(font: FontSource): { S: number; h: number };
24
+ /** A font's hairline: a quantile of its letters' stroke depths, as a fraction of the cap. */
25
+ export function hairline(font: FontSource, chars?: string, q?: number): number;
26
+ /** A font's structural hairline, by erosion, as a fraction of the cap (needs @resvg/resvg-js). */
27
+ export function structuralHairline(font: FontSource, chars?: string): number;
28
+ /** A font's character as a glyph source, grown so its hairline fits to `hair`. */
29
+ export function fontSource(font: FontSource, char: string, o?: { hair?: number; h?: number; onGrow?: (t: number) => void }): GlyphSource;
30
+ /** A glyph source with its ink below the baseline trimmed off. */
31
+ export function onBase(src: GlyphSource): GlyphSource;
@@ -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)) }; };