@arraypress/covers 1.1.0 → 1.4.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 (259) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +18 -809
  3. package/package.json +3 -1
  4. package/recipes/flyers/afterglow-motion.json +540 -0
  5. package/recipes/flyers/afterglow.json +70 -0
  6. package/recipes/flyers/arcfield-mirror-motion.json +397 -0
  7. package/recipes/flyers/arcfield-mirror.json +364 -0
  8. package/recipes/flyers/bauhaus-oktav.json +319 -0
  9. package/recipes/flyers/bauhaus-ritmo.json +735 -0
  10. package/recipes/flyers/bauhaus-volta.json +394 -0
  11. package/recipes/flyers/bloomfall.json +99 -0
  12. package/recipes/flyers/drag-solenne-motion.json +444 -0
  13. package/recipes/flyers/drag-solenne.json +426 -0
  14. package/recipes/flyers/fennick-patches.json +874 -0
  15. package/recipes/flyers/ferro-pass.json +398 -0
  16. package/recipes/flyers/ferrova.json +812 -0
  17. package/recipes/flyers/grid-poster.json +91 -0
  18. package/recipes/flyers/grid-rest-rhythm.json +125 -0
  19. package/recipes/flyers/groovy-honeycomb.json +816 -0
  20. package/recipes/flyers/groovy-marigold.json +479 -0
  21. package/recipes/flyers/groovy-peachfuzz.json +974 -0
  22. package/recipes/flyers/halden-orbit-motion.json +381 -0
  23. package/recipes/flyers/halden-orbit.json +356 -0
  24. package/recipes/flyers/halvard.json +120 -0
  25. package/recipes/flyers/kairo-weekender.json +696 -0
  26. package/recipes/flyers/linger-motion.json +518 -0
  27. package/recipes/flyers/linger.json +67 -0
  28. package/recipes/flyers/little-orchard.json +105 -0
  29. package/recipes/flyers/lofi-hexmoth.json +912 -0
  30. package/recipes/flyers/lofi-lowbit-motion.json +888 -0
  31. package/recipes/flyers/lofi-lowbit.json +880 -0
  32. package/recipes/flyers/lofi-nullpoint.json +3013 -0
  33. package/recipes/flyers/logofolio.json +60 -0
  34. package/recipes/flyers/lumio-stack-motion.json +648 -0
  35. package/recipes/flyers/lumio-stack.json +438 -0
  36. package/recipes/flyers/magazine-cover.json +168 -0
  37. package/recipes/flyers/magazine-extended.json +269 -0
  38. package/recipes/flyers/magazine-knockout.json +40 -0
  39. package/recipes/flyers/northfold-varsity.json +618 -0
  40. package/recipes/flyers/open-studio.json +69 -0
  41. package/recipes/flyers/orrin-merch.json +715 -0
  42. package/recipes/flyers/oxyde.json +113 -0
  43. package/recipes/flyers/pixel-mosaic.json +58 -0
  44. package/recipes/flyers/quiet-fields.json +610 -0
  45. package/recipes/flyers/scrapbook-halcyon.json +948 -0
  46. package/recipes/flyers/solenne-ticket.json +730 -0
  47. package/recipes/flyers/solvane-motion.json +576 -0
  48. package/recipes/flyers/solvane.json +98 -0
  49. package/recipes/flyers/subduct.json +99 -0
  50. package/recipes/flyers/sunvale-lineup.json +584 -0
  51. package/recipes/flyers/tidewell.json +91 -0
  52. package/recipes/flyers/tour-kairo.json +535 -0
  53. package/recipes/flyers/velora-stickers.json +885 -0
  54. package/recipes/flyers/wheatpaste-black.json +313 -0
  55. package/recipes/flyers/wheatpaste-mark.json +198 -0
  56. package/recipes/flyers/wheatpaste-torn.json +114 -0
  57. package/recipes/flyers/wheatpaste-wall.json +984 -0
  58. package/recipes/flyers/wheatpaste-words.json +233 -0
  59. package/recipes/release/aura.json +137 -0
  60. package/recipes/release/chrome.json +433 -0
  61. package/recipes/release/collage.json +372 -0
  62. package/recipes/release/duotone.json +113 -0
  63. package/recipes/release/film-still.json +102 -0
  64. package/recipes/release/headline.json +70 -0
  65. package/recipes/release/instant.json +170 -0
  66. package/recipes/release/label.json +364 -0
  67. package/recipes/release/liquid-glass.json +301 -0
  68. package/recipes/release/odd-tile-dark.json +688 -0
  69. package/recipes/release/odd-tile-dense.json +1056 -0
  70. package/recipes/release/odd-tile-shift.json +541 -0
  71. package/recipes/release/odd-tile-split.json +571 -0
  72. package/recipes/release/odd-tile.json +633 -0
  73. package/recipes/release/off-edge.json +96 -0
  74. package/recipes/release/plinth.json +794 -0
  75. package/recipes/release/riso.json +108 -0
  76. package/recipes/release/series/fluid-label.json +561 -0
  77. package/recipes/release/series/fluid-pixel.json +334 -0
  78. package/recipes/release/series/hairline.json +305 -0
  79. package/recipes/release/series/halo.json +282 -0
  80. package/recipes/release/series/mark-crop.json +135 -0
  81. package/recipes/release/series/mark-window.json +150 -0
  82. package/recipes/release/series/photo-label.json +475 -0
  83. package/recipes/release/series/smear-field.json +345 -0
  84. package/recipes/release/series/smear.json +202 -0
  85. package/recipes/release/series/spin.json +172 -0
  86. package/recipes/release/series/split-ghost.json +230 -0
  87. package/recipes/release/series/zoom.json +168 -0
  88. package/recipes/release/small-print.json +114 -0
  89. package/recipes/release/stack.json +156 -0
  90. package/recipes/release/swiss.json +117 -0
  91. package/schema/recipe.schema.json +638 -69
  92. package/src/animate/index.d.ts +9 -19
  93. package/src/animate/index.js +6 -111
  94. package/src/animate/keyframes.d.ts +21 -0
  95. package/src/animate/keyframes.js +119 -0
  96. package/src/animate/marquee.d.ts +30 -0
  97. package/src/animate/marquee.js +93 -0
  98. package/src/animate/morph.d.ts +8 -0
  99. package/src/animate/morph.js +99 -0
  100. package/src/animate/motion.d.ts +59 -0
  101. package/src/animate/motion.js +63 -0
  102. package/src/animate/sheet.d.ts +19 -0
  103. package/src/animate/sheet.js +61 -0
  104. package/src/animate/timing.d.ts +28 -0
  105. package/src/animate/timing.js +146 -0
  106. package/src/animate/tracks.d.ts +18 -0
  107. package/src/animate/tracks.js +139 -0
  108. package/src/diagram.js +4 -3
  109. package/src/extract.js +2 -2
  110. package/src/fonts.js +1 -2
  111. package/src/geometry.js +4 -34
  112. package/src/grid.js +1 -1
  113. package/src/handmade.js +9 -7
  114. package/src/icons.js +1 -2
  115. package/src/index.d.ts +47 -54
  116. package/src/index.js +5 -5
  117. package/src/inter-data.js +1 -1
  118. package/src/json-recipe.js +28 -7
  119. package/src/layer-options.js +26 -4
  120. package/src/layers/barcode.js +3 -3
  121. package/src/layers/geometry.js +5 -5
  122. package/src/layers/index.d.ts +6 -0
  123. package/src/layers/index.js +3 -0
  124. package/src/layers/mark-geometry.d.ts +15 -0
  125. package/src/layers/mark-geometry.js +46 -0
  126. package/src/layers/marks.d.ts +25 -0
  127. package/src/layers/marks.js +102 -0
  128. package/src/layers/mosaic.d.ts +32 -0
  129. package/src/layers/mosaic.js +155 -0
  130. package/src/layers/perforation.js +3 -3
  131. package/src/layers/repeat.js +2 -2
  132. package/src/layers/shape.d.ts +2 -2
  133. package/src/layers/shape.js +5 -5
  134. package/src/layers/soft.js +2 -2
  135. package/src/layers/svg.js +2 -3
  136. package/src/layers/table.js +3 -3
  137. package/src/layers/tear.d.ts +1 -1
  138. package/src/layers/tear.js +4 -4
  139. package/src/layers/texture.js +3 -3
  140. package/src/layers/util.js +15 -2
  141. package/src/layers.js +17 -32
  142. package/src/{effects/paint.js → paint/index.js} +5 -5
  143. package/src/{effects/paint-presets.d.ts → paint/presets.d.ts} +1 -1
  144. package/src/{effects/paint-presets.js → paint/presets.js} +1 -1
  145. package/src/{effects → paint}/tile.d.ts +1 -1
  146. package/src/{effects → paint}/tile.js +4 -4
  147. package/src/palette.js +1 -1
  148. package/src/platform/files.d.ts +6 -0
  149. package/src/platform/files.js +41 -0
  150. package/src/platform/harfbuzz.js +1 -1
  151. package/src/platform/index.d.ts +3 -3
  152. package/src/platform/index.js +6 -4
  153. package/src/platform/load.js +1 -2
  154. package/src/print.js +9 -7
  155. package/src/recipes.js +1 -1
  156. package/src/render/backdrop.js +2 -2
  157. package/src/render/grain.js +1 -1
  158. package/src/render/group.js +3 -3
  159. package/src/render/key.js +1 -2
  160. package/src/{effects → render}/transform.d.ts +2 -1
  161. package/src/{effects → render}/transform.js +17 -5
  162. package/src/render/use.d.ts +1 -1
  163. package/src/render/use.js +2 -2
  164. package/src/render/wrap.js +5 -60
  165. package/src/render.js +55 -18
  166. package/src/text.js +1 -1
  167. package/src/type/auto-color.js +1 -1
  168. package/src/type/fallback.js +1 -1
  169. package/src/type/glyphs.js +2 -2
  170. package/src/type/line.js +1 -1
  171. package/src/type/live.js +1 -1
  172. package/src/type/path.js +2 -2
  173. package/src/type/pixel.d.ts +0 -2
  174. package/src/type/pixel.js +3 -33
  175. package/src/type/spans.d.ts +5 -1
  176. package/src/type/spans.js +28 -5
  177. package/src/type/symbols.d.ts +1 -1
  178. package/src/type/symbols.js +20 -2
  179. package/src/type/typeset.js +25 -10
  180. package/src/version.js +1 -1
  181. package/src/color.js +0 -246
  182. package/src/effects/ascii.d.ts +0 -7
  183. package/src/effects/ascii.js +0 -93
  184. package/src/effects/buffer.d.ts +0 -60
  185. package/src/effects/buffer.js +0 -472
  186. package/src/effects/detail.d.ts +0 -12
  187. package/src/effects/detail.js +0 -124
  188. package/src/effects/digital.d.ts +0 -14
  189. package/src/effects/digital.js +0 -216
  190. package/src/effects/distort.d.ts +0 -20
  191. package/src/effects/distort.js +0 -314
  192. package/src/effects/dither.d.ts +0 -8
  193. package/src/effects/dither.js +0 -147
  194. package/src/effects/drop-shadow.d.ts +0 -4
  195. package/src/effects/drop-shadow.js +0 -45
  196. package/src/effects/extrude.d.ts +0 -5
  197. package/src/effects/extrude.js +0 -55
  198. package/src/effects/film.d.ts +0 -11
  199. package/src/effects/film.js +0 -204
  200. package/src/effects/glow.d.ts +0 -8
  201. package/src/effects/glow.js +0 -56
  202. package/src/effects/grade.d.ts +0 -30
  203. package/src/effects/grade.js +0 -274
  204. package/src/effects/halftone.d.ts +0 -8
  205. package/src/effects/halftone.js +0 -95
  206. package/src/effects/index.d.ts +0 -125
  207. package/src/effects/index.js +0 -277
  208. package/src/effects/ink.d.ts +0 -12
  209. package/src/effects/ink.js +0 -70
  210. package/src/effects/lens.d.ts +0 -14
  211. package/src/effects/lens.js +0 -277
  212. package/src/effects/light.d.ts +0 -10
  213. package/src/effects/light.js +0 -59
  214. package/src/effects/looks.d.ts +0 -9
  215. package/src/effects/looks.js +0 -74
  216. package/src/effects/mask.d.ts +0 -52
  217. package/src/effects/mask.js +0 -314
  218. package/src/effects/motion.d.ts +0 -6
  219. package/src/effects/motion.js +0 -60
  220. package/src/effects/newsprint.d.ts +0 -6
  221. package/src/effects/newsprint.js +0 -89
  222. package/src/effects/optics.d.ts +0 -10
  223. package/src/effects/optics.js +0 -213
  224. package/src/effects/options.d.ts +0 -6
  225. package/src/effects/options.js +0 -106
  226. package/src/effects/pixels.d.ts +0 -24
  227. package/src/effects/pixels.js +0 -138
  228. package/src/effects/polar.d.ts +0 -10
  229. package/src/effects/polar.js +0 -265
  230. package/src/effects/region.d.ts +0 -8
  231. package/src/effects/region.js +0 -136
  232. package/src/effects/round-border.d.ts +0 -5
  233. package/src/effects/round-border.js +0 -62
  234. package/src/effects/screen.d.ts +0 -14
  235. package/src/effects/screen.js +0 -212
  236. package/src/effects/sort.d.ts +0 -8
  237. package/src/effects/sort.js +0 -125
  238. package/src/effects/symmetry.d.ts +0 -6
  239. package/src/effects/symmetry.js +0 -74
  240. package/src/effects/tone.d.ts +0 -16
  241. package/src/effects/tone.js +0 -117
  242. package/src/effects/util.d.ts +0 -16
  243. package/src/effects/util.js +0 -85
  244. package/src/platform/bytes.d.ts +0 -22
  245. package/src/platform/bytes.js +0 -103
  246. package/src/platform/compress.d.ts +0 -12
  247. package/src/platform/compress.js +0 -81
  248. package/src/platform/node.d.ts +0 -26
  249. package/src/platform/node.js +0 -86
  250. package/src/png.js +0 -126
  251. package/src/raster/index.d.ts +0 -24
  252. package/src/raster/index.js +0 -171
  253. package/src/render/content.d.ts +0 -5
  254. package/src/render/content.js +0 -58
  255. package/src/render/tiles.d.ts +0 -6
  256. package/src/render/tiles.js +0 -77
  257. package/src/rng.js +0 -95
  258. package/src/svg.js +0 -69
  259. /package/src/{effects/paint.d.ts → paint/index.d.ts} +0 -0
package/src/type/pixel.js CHANGED
@@ -2,7 +2,8 @@
2
2
  * A built-in 5×7 pixel face (`font: 'pixel'`): A–Z, 0–9 and basic
3
3
  * punctuation, each glyph a set of square cells merged into row runs. It
4
4
  * is a face like any other (layout, fitting, justification, text on a
5
- * path, per-glyph control all work), drawn as paths.
5
+ * path, per-glyph control all work), drawn as paths. The bitmaps are
6
+ * image-effects' pixel font (its `ascii` effect draws with them).
6
7
  *
7
8
  * Metrics, in a 1000-unit em: a cell is 100 units, capitals are 7 cells
8
9
  * (700), the advance is 6 cells (a cell's gap after each glyph), a space
@@ -11,30 +12,7 @@
11
12
  * @module type/pixel
12
13
  */
13
14
 
14
- /** Glyph bitmaps, 5 wide × 7 tall, `#` inked. */
15
- const BITMAPS = /** @type {Record<string, string>} */ ({
16
- A: '.###.#...##...#######...##...##...#', B: '####.#...##...#####.#...##...#####.', C: '.###.#...##....#....#....#...#.###.',
17
- D: '####.#...##...##...##...##...#####.', E: '######....#....####.#....#....#####', F: '######....#....####.#....#....#....',
18
- G: '.###.#...##....#.####...##...#.####', H: '#...##...##...#######...##...##...#', I: '.###...#....#....#....#....#...###.',
19
- J: '..###...#....#....#....#.#..#..##..', K: '#...##..#.#.#..##...#.#..#..#.#...#', L: '#....#....#....#....#....#....#####',
20
- M: '#...###.###.#.##.#.##...##...##...#', N: '#...##...###..##.#.##..###...##...#', O: '.###.#...##...##...##...##...#.###.',
21
- P: '####.#...##...#####.#....#....#....', Q: '.###.#...##...##...##.#.##..#..##.#', R: '####.#...##...#####.#.#..#..#.#...#',
22
- S: '.#####....#.....###.....#....#####.', T: '#####..#....#....#....#....#....#..', U: '#...##...##...##...##...##...#.###.',
23
- V: '#...##...##...##...##...#.#.#...#..', W: '#...##...##...##.#.##.#.##.#.#.#.#.', X: '#...##...#.#.#...#...#.#.#...##...#',
24
- Y: '#...##...#.#.#...#....#....#....#..', Z: '#####....#...#...#...#...#....#####', '0': '.###.#...##..###.#.###..##...#.###.',
25
- '1': '..#...##....#....#....#....#...###.', '2': '.###.#...#....#...#...#...#...#####', '3': '#####...#...#.....#.....##...#.###.',
26
- '4': '...#...##..#.#.#..#.#####...#....#.', '5': '######....####.....#....##...#.###.', '6': '..##..#...#....####.#...##...#.###.',
27
- '7': '#####....#...#...#...#....#....#...', '8': '.###.#...##...#.###.#...##...#.###.', '9': '.###.#...##...#.####....#...#..##..',
28
- '.': '..........................##...##..', ',': '.....................##....#...#...', '!': '..#....#....#....#....#.........#..',
29
- '?': '.###.#...#....#...#...#.........#..', '-': '................###................', ':': '......##...##........##...##.......',
30
- ';': '......##...##........##....#...#...', "'": '..#....#...#.......................', '"': '.#.#..#.#..#.#.....................',
31
- '/': '.........#...#...#...#...#.........', '&': '.##..#..#.#.#...#...#.#.##..#..##.#', '+': '.......#....#..#####..#....#.......',
32
- '=': '..........#####.....#####..........', '(': '...#...#...#....#....#.....#.....#.', ')': '.#.....#.....#....#....#...#...#...',
33
- '#': '.#.#..#.#.#####.#.#.#####.#.#..#.#.', '@': '.###.#...##.####.#.##.####.....###.', '%': '##...##..#...#...#...#...#..##...##',
34
- '*': '.......#..#.#.#.###.#.#.#..#.......', _: '..............................#####', '<': '...#...#...#...#.....#.....#.....#.',
35
- '>': '.#.....#.....#.....#...#...#...#...', '[': '.###..#....#....#....#....#....###.', ']': '.###....#....#....#....#....#..###.',
36
- '|': '..#....#....#....#....#....#....#..', '$': '..#...#####.#...###...#.#####...#..', '·': '.................#.................',
37
- });
15
+ import { PIXEL_GLYPHS as BITMAPS } from '@logomarks/image-effects/pixel-font';
38
16
 
39
17
  /** Cell size, font units. */
40
18
  const CELL = 100;
@@ -105,11 +83,3 @@ export function pixelFace() {
105
83
 
106
84
  /** Characters the pixel face draws. */
107
85
  export const PIXEL_CHARS = Object.freeze(CHARS.join(''));
108
-
109
- /**
110
- * A glyph's 5×7 bitmap (35 cells row by row, `#` inked), or undefined.
111
- *
112
- * @param {string} ch
113
- * @returns {string | undefined}
114
- */
115
- export const pixelBitmap = (ch) => (Object.hasOwn(BITMAPS, ch.toUpperCase()) ? BITMAPS[ch.toUpperCase()] : undefined);
@@ -8,6 +8,8 @@ import type { Length, Color } from '../index.js';
8
8
  export interface TextSpan {
9
9
  content: string;
10
10
  font?: string; weight?: number; style?: 'normal' | 'italic'; axes?: Record<string, number>;
11
+ /** Set at the layer's `boldWeight` (default 700) when `weight` isn't given. */
12
+ bold?: boolean;
11
13
  /** Size relative to the layer's (default 1). */
12
14
  scale?: number;
13
15
  /** Size outright. */
@@ -24,7 +26,9 @@ export interface TextSpan {
24
26
  export interface Span { text: string; face: LoadedFont; scale: number; tracking: number; paint: string | undefined; dy: number }
25
27
 
26
28
  /** Resolve a layer's spans (faces loaded, sizes relative to `size`). */
27
- export function loadSpans(layer: { spans: (TextSpan | string)[]; font?: string; weight?: number; style?: string; axes?: Record<string, number>; letterSpacing?: number; transform?: string }, ctx: RenderContext, size: number): Promise<Span[]>;
29
+ export function loadSpans(layer: { spans: (TextSpan | string)[]; font?: string; weight?: number; boldWeight?: number; style?: string; axes?: Record<string, number>; letterSpacing?: number; transform?: string }, ctx: RenderContext, size: number): Promise<Span[]>;
30
+ /** A `content` array as spans, one a line, when any item is a span object; else `undefined`. */
31
+ export function lineSpans(content: unknown): TextSpan[] | undefined;
28
32
  /** Spans as lines (split at `\n`, wrapped at `maxWidth`). */
29
33
  export function spanLines(spans: Span[], size: number, maxWidth: number, fallbacks: LoadedFont[]): Span[][];
30
34
  /** Shape a line of spans as one run. */
package/src/type/spans.js CHANGED
@@ -3,15 +3,17 @@
3
3
  * style, axes, size, colour, tracking and baseline shift, set one after
4
4
  * another on the same lines (a `\n` in a span starts a new line).
5
5
  *
6
- * `spans: [{ content, font, weight, style, axes, scale, size, color, letterSpacing, dy }]`;
6
+ * `spans: [{ content, font, weight, bold, style, axes, scale, size, color, letterSpacing, dy }]`;
7
7
  * anything a span leaves out comes from the layer. `scale` sizes a span
8
- * relative to the layer (`size` gives it outright).
8
+ * relative to the layer (`size` gives it outright); `bold: true` sets it at
9
+ * the layer's `boldWeight` (700). A `content` array with span objects in it
10
+ * is one span a line (`lineSpans`): a headline whose lines change weight.
9
11
  *
10
12
  * @module type/spans
11
13
  */
12
14
 
13
- import { len } from '../geometry.js';
14
- import { toHex } from '../color.js';
15
+ import { len } from '@logomarks/image-effects/length';
16
+ import { toHex } from '@logomarks/image-effects/color';
15
17
  import { faceFor } from './line.js';
16
18
  import { shapeText } from './fallback.js';
17
19
 
@@ -37,7 +39,8 @@ export async function loadSpans(layer, ctx, size) {
37
39
  const out = [];
38
40
  for (const given of layer.spans) {
39
41
  const s = typeof given === 'string' ? { content: given } : given;
40
- const face = await faceFor(ctx, { font: s.font ?? layer.font, weight: s.weight ?? layer.weight, style: s.style ?? layer.style, axes: { ...layer.axes, ...s.axes } });
42
+ const weight = s.weight ?? (s.bold ? layer.boldWeight ?? 700 : layer.weight);
43
+ const face = await faceFor(ctx, { font: s.font ?? layer.font, weight, style: s.style ?? layer.style, axes: { ...layer.axes, ...s.axes } });
41
44
  const kase = s.transform ?? layer.transform;
42
45
  const raw = String(s.content ?? '').replace(/[\t\r ]+/g, ' ');
43
46
  out.push({
@@ -52,6 +55,26 @@ export async function loadSpans(layer, ctx, size) {
52
55
  return out;
53
56
  }
54
57
 
58
+ /**
59
+ * A `content` array as spans, one a line, when any item is a span object
60
+ * (`{ content, bold, weight, … }`); otherwise `undefined` (plain lines stay
61
+ * content).
62
+ *
63
+ * @param {unknown} content - A text layer's `content`.
64
+ * @returns {import('./spans.js').TextSpan[] | undefined}
65
+ *
66
+ * @example
67
+ * lineSpans([{ content: 'Signal', bold: true }, 'over the']);
68
+ * // [{ content: 'Signal\n', bold: true }, { content: 'over the' }]
69
+ */
70
+ export function lineSpans(content) {
71
+ if (!Array.isArray(content) || !content.some((x) => x && typeof x === 'object')) return undefined;
72
+ return content.map((item, i) => {
73
+ const s = item && typeof item === 'object' ? item : { content: item };
74
+ return { ...s, content: `${s.content ?? ''}${i < content.length - 1 ? '\n' : ''}` };
75
+ });
76
+ }
77
+
55
78
  /**
56
79
  * Spans as lines: split at `\n`, then (with a finite `maxWidth`) wrapped at
57
80
  * spaces, greedily, at `size`.
@@ -1,7 +1,7 @@
1
1
  // Built-in symbols, drawn as shapes (the last glyph fallback).
2
2
  import type { LoadedFont } from '../fonts.js';
3
3
 
4
- /** The symbol face: ✺ ✦ ✶ ★ ☆ ♥ ♡ ▶ ◀ ● ◆ ■ ♪. */
4
+ /** The symbol face: ✺ ✦ ✶ ★ ☆ ♥ ♡ ▶ ◀ ● ◆ ■ ♪ ≠. */
5
5
  export function symbolFace(): LoadedFont;
6
6
  /** The characters it draws. */
7
7
  export const SYMBOLS: string;
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * A built-in face of a few symbols type often wants and fonts often lack
3
- * (✺ ✦ ✶ ★ ☆ ♥ ♡ ▶ ◀ ● ◆ ■ ♪), drawn as shapes. The last fallback when a
3
+ * (✺ ✦ ✶ ★ ☆ ♥ ♡ ▶ ◀ ● ◆ ■ ♪ ≠), drawn as shapes. The last fallback when a
4
4
  * text layer's font has no glyph for a character.
5
5
  *
6
6
  * @module type/symbols
@@ -38,6 +38,23 @@ function heart(k) {
38
38
  /** @param {number} r @param {number} [n=48] @returns {[number, number][]} */
39
39
  const circle = (r, n = 48) => Array.from({ length: n }, (_, i) => /** @type {[number, number]} */ ([CX + r * Math.sin((i / n) * Math.PI * 2), CY + r * Math.cos((i / n) * Math.PI * 2)]));
40
40
 
41
+ /**
42
+ * Not-equal, on a narrower advance (`NARROW`): two bars and a slash, one
43
+ * contour each, all wound alike so they fill as one.
44
+ *
45
+ * @returns {string}
46
+ */
47
+ function notEqual() {
48
+ const cx = NARROW / 2;
49
+ const [half, bar, gap, lean, stem] = [230, 62, 112, 150, 31];
50
+ const rect = (/** @type {number} */ y) => poly([[cx - half, y + bar / 2], [cx + half, y + bar / 2], [cx + half, y - bar / 2], [cx - half, y - bar / 2]]);
51
+ const slash = poly([[cx + lean - stem, CY + 300], [cx + lean + stem, CY + 300], [cx - lean + stem, CY - 300], [cx - lean - stem, CY - 300]]);
52
+ return rect(CY + gap) + rect(CY - gap) + slash;
53
+ }
54
+
55
+ /** Advance of the narrow symbols (≠), font units. */
56
+ const NARROW = 600;
57
+
41
58
  /** Outlines by character. An inner contour runs the other way (a hole). */
42
59
  const SHAPES = /** @type {Record<string, string>} */ ({
43
60
  '✺': poly(star(16, 0.5)),
@@ -53,6 +70,7 @@ const SHAPES = /** @type {Record<string, string>} */ ({
53
70
  '◆': poly([[CX, CY + R], [CX + R * 0.8, CY], [CX, CY - R], [CX - R * 0.8, CY]]),
54
71
  '■': poly([[CX - R * 0.75, CY + R * 0.75], [CX + R * 0.75, CY + R * 0.75], [CX + R * 0.75, CY - R * 0.75], [CX - R * 0.75, CY - R * 0.75]]),
55
72
  '♪': poly([[CX - 40, CY + R], [CX + 220, CY + R * 0.7], [CX + 220, CY + R * 0.45], [CX + 20, CY + R * 0.72], [CX + 20, CY - R * 0.55], [CX - 40, CY - R * 0.55]]) + poly(circle(110).map(([x, y]) => /** @type {[number, number]} */ ([x - 120, y - R * 0.6]))),
73
+ '≠': notEqual(),
56
74
  });
57
75
 
58
76
  const CHARS = Object.keys(SHAPES);
@@ -80,7 +98,7 @@ export function symbolFace() {
80
98
  variations: {},
81
99
  source: { name: 'symbols', src: 'covers:symbols', weight: 400 },
82
100
  glyphToPath: (/** @type {number} */ gid) => SHAPES[CHARS[gid - 1]] ?? '',
83
- shapeRun: (/** @type {string} */ text) => Array.from(text).map((ch, i) => ({ gid: CHARS.indexOf(ch) + 1, cluster: i, xAdvance: ADV, xOffset: 0, yOffset: 0 })),
101
+ shapeRun: (/** @type {string} */ text) => Array.from(text).map((ch, i) => ({ gid: CHARS.indexOf(ch) + 1, cluster: i, xAdvance: ch === '≠' ? NARROW : ADV, xOffset: 0, yOffset: 0 })),
84
102
  };
85
103
  return face;
86
104
  }
@@ -24,7 +24,8 @@
24
24
  * - **Live text**: `render: 'live'` writes `<text>` with the font embedded
25
25
  * by `@font-face` (or linked by URL), laid out the same way, for in-browser
26
26
  * speed and selectable text.
27
- * - **Mixed runs**: `spans`, each with its own font, weight, size, colour.
27
+ * - **Mixed runs**: `spans`, each with its own font, weight, size, colour;
28
+ * or `content` lines as span objects (`{ content, bold }`), one a line.
28
29
  * - **Per-glyph control**: `glyphs: { scale, dx, dy, rotate, color }` arrays.
29
30
  * - **Fallback**: characters the font lacks come from `fallback`, fonts
30
31
  * registered with `fallback: true`, or the built-in symbols.
@@ -36,14 +37,15 @@
36
37
  * @module type/typeset
37
38
  */
38
39
 
39
- import { attrs, esc, num } from '../svg.js';
40
- import { len, place, anchorAlign, unit } from '../geometry.js';
41
- import { toHex } from '../color.js';
40
+ import { attrs, esc, num } from '@logomarks/image-effects/svg';
41
+ import { len } from '@logomarks/image-effects/length';
42
+ import { place, anchorAlign, unit } from '../geometry.js';
43
+ import { toHex } from '@logomarks/image-effects/color';
42
44
  import { layout, caseText } from '../text.js';
43
- import { paint } from '../effects/paint.js';
45
+ import { paint } from '../paint/index.js';
44
46
  import { pos, wrap } from '../layers/util.js';
45
- import { presetStrokes } from '../effects/paint-presets.js';
46
- import { extrude } from '../effects/extrude.js';
47
+ import { presetStrokes } from '../paint/presets.js';
48
+ import { extrude } from '@logomarks/image-effects/effects/extrude';
47
49
  import { dashArray } from '../layers/shape.js';
48
50
  import { faceFor, mappedPath, setLine } from './line.js';
49
51
  import { textPathD, pathSampler } from './path.js';
@@ -51,8 +53,9 @@ import { fontFace } from './live.js';
51
53
  import { autoColor } from './auto-color.js';
52
54
  import { fallbackFaces, shapeText } from './fallback.js';
53
55
  import { controlGlyphs, glyphCount } from './glyphs.js';
54
- import { loadSpans, spanLines, shapeSpans, spanText } from './spans.js';
56
+ import { loadSpans, spanLines, shapeSpans, spanText, lineSpans } from './spans.js';
55
57
  import { recordLayout } from './layout-ref.js';
58
+ import { marquee } from '../animate/marquee.js';
56
59
 
57
60
  /** Options that switch the text layer onto this typesetter. */
58
61
  export const TYPE_OPTIONS = ['x', 'y', 'width', 'justify', 'lengthAdjust', 'fit', 'stretch', 'axes', 'stroke', 'strokeWidth', 'strokes', 'fill', 'path', 'startOffset', 'fitPath', 'dy', 'render', 'autoColors', 'lines', 'spans', 'glyphs', 'fallback', 'split', 'dash', 'extrude', 'maxHeight', 'autoThreshold'];
@@ -81,6 +84,9 @@ export function wantsType(layer) {
81
84
  * @returns {Promise<import('../layers.js').Markup>}
82
85
  */
83
86
  export async function typeset(layer, ctx) {
87
+ // Lines given as span objects (a headline whose lines change weight) are spans.
88
+ const perLine = lineSpans(layer.content);
89
+ if (perLine) layer = { ...layer, content: undefined, spans: perLine };
84
90
  const rawContent = Array.isArray(layer.content) ? layer.content.join('\n') : layer.content && typeof layer.content === 'object' ? layer.content.content : layer.content;
85
91
  const kase = layer.transform === 'uppercase' || layer.transform === 'lowercase' ? layer.transform : undefined;
86
92
  let content = layer.spans ? '' : caseText(String(rawContent ?? ''), kase);
@@ -170,7 +176,7 @@ export async function typeset(layer, ctx) {
170
176
  }
171
177
  const so = layer.startOffset ?? (layer.fitPath ? 0 : 0.5);
172
178
  const offset = typeof so === 'number' && so <= 1 && so >= 0 ? so * sampler.length : pos(so, 'x', ctx.canvas);
173
- const start = layer.fitPath ? offset : align === 'center' || layer.align == null ? offset - run.width / 2 : align === 'right' ? offset - run.width : offset;
179
+ let start = layer.fitPath ? offset : align === 'center' || layer.align == null ? offset - run.width / 2 : align === 'right' ? offset - run.width : offset;
174
180
  const dy = len(layer.dy, ctx.canvas, 0);
175
181
  const adv = (/** @type {number} */ i) => (i + 1 < run.glyphs.length ? run.glyphs[i + 1].x : run.width) - run.glyphs[i].x;
176
182
  const mids = run.glyphs.map((g, i) => g.x + adv(i) / 2);
@@ -185,8 +191,17 @@ export async function typeset(layer, ctx) {
185
191
  const whole = { x: 0, y: 0, width: ctx.width, height: ctx.height };
186
192
  const fill = layer.fill === 'none' ? { defs: '', value: 'none' } : layer.color === 'auto' ? { defs: '', value: await autoColor(layer, ctx, whole) } : paint(layer.fill ?? layer.color, p.text, { id: `${id}f`, box: whole, canvas: ctx.canvas });
187
193
  defs += fill.defs;
194
+ // A marquee (motion): the still moves the start; playing, each glyph is drawn once in its own frame and placed by copies.
195
+ const running = /** @type {any} */ (ctx).marquee && marquee(/** @type {any} */ (ctx).marquee, {
196
+ run, mids, start, sampler, id,
197
+ glyph: (i) => drawBuckets({ glyphs: [run.glyphs[i]] }, (only) => mappedPath(face, run, size, (gx, gy) => [gx - mids[i], gy + dy], (g) => g === run.glyphs[i] && only(g)), fill.value, strokes, ctx),
198
+ });
199
+ if (running) {
200
+ start += running.shift;
201
+ defs += running.defs;
202
+ }
188
203
  report(ctx, layer, content, size, face, null, { capHeight: (face.capHeight / face.upem) * size });
189
- const drawn = drawBuckets(run, along, fill.value, strokes, ctx);
204
+ const drawn = running?.body ?? drawBuckets(run, along, fill.value, strokes, ctx);
190
205
  recordLayout(ctx, layer.id, whole, { x: 0, y: 0, baseline: 0, size, lines: 1 });
191
206
  return { defs, body: wrap(layer, finish(drawn, layer, ctx)) };
192
207
  }
package/src/version.js CHANGED
@@ -1,4 +1,4 @@
1
1
  // Generated by scripts/generate.mjs from package.json. Do not edit.
2
2
 
3
3
  /** The package version, part of every cache key. */
4
- export const VERSION = '1.1.0';
4
+ export const VERSION = '1.4.0';
package/src/color.js DELETED
@@ -1,246 +0,0 @@
1
- /**
2
- * Colour parsing and the few colour-space moves palettes need.
3
- *
4
- * Everything inside the library is a lowercase `#rrggbb` string. Input can
5
- * be hex (3, 4, 6 or 8 digits), `rgb()`/`rgba()` or `hsl()`/`hsla()`; alpha
6
- * is dropped, since a cover's layers carry their own `opacity`.
7
- *
8
- * Mixing and contrast come from `@arraypress/color-utils`; this module adds
9
- * OKLCH, so derived palettes can rotate hue and set lightness perceptually
10
- * instead of in sRGB (where "the same lightness" means very different
11
- * things for yellow and blue).
12
- *
13
- * @module color
14
- */
15
-
16
- import { hexToRgb, rgbToHex, contrastRatio } from '@arraypress/color-utils';
17
-
18
- /**
19
- * Parse a CSS colour to `#rrggbb`.
20
- *
21
- * @param {string} input - `#abc`, `#aabbcc`, `#aabbccdd`, `rgb(…)`, `rgba(…)`, `hsl(…)` or `hsla(…)`.
22
- * @returns {string} Lowercase `#rrggbb`.
23
- * @throws {TypeError} When the string isn't a colour this function understands.
24
- *
25
- * @example
26
- * toHex('rgb(37 99 235)'); // '#2563eb'
27
- * toHex('#FA0'); // '#ffaa00'
28
- * toHex('hsl(220 80% 50%)'); // '#1a5ee6'
29
- */
30
- export function toHex(input) {
31
- if (typeof input !== 'string') throw new TypeError(`Expected a colour string, got ${typeof input}`);
32
- const s = input.trim().toLowerCase();
33
-
34
- const hex = s.match(/^#?([0-9a-f]{3,8})$/);
35
- if (hex && [3, 4, 6, 8].includes(hex[1].length)) {
36
- let h = hex[1];
37
- if (h.length <= 4) h = h.split('').map((c) => c + c).join('');
38
- return '#' + h.slice(0, 6);
39
- }
40
-
41
- const fn = s.match(/^(rgba?|hsla?)\(([^)]*)\)$/);
42
- if (fn) {
43
- const parts = fn[2].split(/[\s,/]+/).filter(Boolean);
44
- if (parts.length >= 3) {
45
- if (fn[1].startsWith('rgb')) {
46
- const [r, g, b] = parts.slice(0, 3).map((p) => (p.endsWith('%') ? parseFloat(p) * 2.55 : parseFloat(p)));
47
- if ([r, g, b].every(Number.isFinite)) return rgbToHex(r, g, b);
48
- } else {
49
- const h = parseFloat(parts[0]);
50
- const sat = parseFloat(parts[1]) / 100;
51
- const l = parseFloat(parts[2]) / 100;
52
- if ([h, sat, l].every(Number.isFinite)) return hslToHex(h, sat, l);
53
- }
54
- }
55
- }
56
- throw new TypeError(`Unrecognised colour: "${input}"`);
57
- }
58
-
59
- /**
60
- * HSL to `#rrggbb`.
61
- *
62
- * @param {number} h - Hue in degrees.
63
- * @param {number} s - Saturation, 0–1.
64
- * @param {number} l - Lightness, 0–1.
65
- * @returns {string} `#rrggbb`.
66
- */
67
- function hslToHex(h, s, l) {
68
- const k = (n) => (n + h / 30) % 12;
69
- const a = s * Math.min(l, 1 - l);
70
- const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
71
- return rgbToHex(f(0) * 255, f(8) * 255, f(4) * 255);
72
- }
73
-
74
- /** @param {number} c - sRGB channel 0–1. @returns {number} Linear channel. */
75
- const toLinear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
76
- /** @param {number} c - Linear channel. @returns {number} sRGB channel 0–1. */
77
- const toGamma = (c) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
78
-
79
- /**
80
- * `#rrggbb` to OKLCH.
81
- *
82
- * @param {string} color - Any colour {@link toHex} accepts.
83
- * @returns {{ l: number, c: number, h: number }} Lightness 0–1, chroma (~0–0.37), hue in degrees.
84
- *
85
- * @example
86
- * toOklch('#2563eb'); // { l: 0.546, c: 0.215, h: 262.9 }
87
- */
88
- export function toOklch(color) {
89
- const { r, g, b } = hexToRgb(toHex(color));
90
- const [lr, lg, lb] = [r, g, b].map((v) => toLinear(v / 255));
91
- const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
92
- const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
93
- const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
94
- const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
95
- const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
96
- const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
97
- const h = (Math.atan2(B, A) * 180) / Math.PI;
98
- return { l: L, c: Math.hypot(A, B), h: h < 0 ? h + 360 : h };
99
- }
100
-
101
- /**
102
- * OKLCH to `#rrggbb`. Out-of-gamut colours are brought in by reducing
103
- * chroma (keeping lightness and hue), not by clipping channels, so a
104
- * derived colour keeps the hue it was asked for.
105
- *
106
- * @param {{ l: number, c: number, h: number }} lch - Lightness 0–1, chroma, hue in degrees.
107
- * @returns {string} `#rrggbb`.
108
- *
109
- * @example
110
- * fromOklch({ l: 0.7, c: 0.12, h: 30 }); // a soft coral
111
- */
112
- export function fromOklch({ l, c, h }) {
113
- const L = Math.max(0, Math.min(1, l));
114
- let chroma = Math.max(0, c);
115
- for (let i = 0; i < 24; i++) {
116
- const rgb = oklabToRgb(L, chroma * Math.cos((h * Math.PI) / 180), chroma * Math.sin((h * Math.PI) / 180));
117
- if (rgb.every((v) => v >= -0.0005 && v <= 1.0005) || chroma < 0.001) {
118
- return toRgbHex(rgb);
119
- }
120
- chroma *= 0.9;
121
- }
122
- return toRgbHex(oklabToRgb(L, 0, 0));
123
- }
124
-
125
- /**
126
- * Clamp 0–1 sRGB channels and write them as `#rrggbb`.
127
- *
128
- * @param {[number, number, number]} rgb
129
- * @returns {string}
130
- */
131
- function toRgbHex([r, g, b]) {
132
- const c = (/** @type {number} */ v) => Math.max(0, Math.min(1, v)) * 255;
133
- return rgbToHex(c(r), c(g), c(b));
134
- }
135
-
136
- /**
137
- * OKLab to gamma-encoded sRGB channels (0–1, possibly out of range).
138
- *
139
- * @param {number} L @param {number} A @param {number} B
140
- * @returns {[number, number, number]} sRGB channels.
141
- */
142
- function oklabToRgb(L, A, B) {
143
- const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;
144
- const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;
145
- const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;
146
- return [
147
- toGamma(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
148
- toGamma(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
149
- toGamma(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
150
- ];
151
- }
152
-
153
- /**
154
- * Mix two colours in OKLab (perceptually even, no muddy midpoints).
155
- *
156
- * @param {string} a - First colour.
157
- * @param {string} b - Second colour.
158
- * @param {number} t - 0 gives `a`, 1 gives `b`.
159
- * @returns {string} `#rrggbb`.
160
- *
161
- * @example
162
- * mixOklab('#ffffff', '#2563eb', 0.1); // a pale blue tint
163
- */
164
- export function mixOklab(a, b, t) {
165
- const p = toLab(a);
166
- const q = toLab(b);
167
- const rgb = oklabToRgb(p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t, p[2] + (q[2] - p[2]) * t);
168
- return toRgbHex(rgb);
169
- }
170
-
171
- /** @param {string} color @returns {[number, number, number]} OKLab. */
172
- function toLab(color) {
173
- const { l, c, h } = toOklch(color);
174
- return [l, c * Math.cos((h * Math.PI) / 180), c * Math.sin((h * Math.PI) / 180)];
175
- }
176
-
177
- /**
178
- * Mix two colours in OKLCH: lightness and chroma linearly, hue the short
179
- * way round. Unlike OKLab, a mix of two far-apart hues stays vivid instead
180
- * of passing through grey (orange → blue goes by way of pink, not mud).
181
- *
182
- * @param {string} a - First colour.
183
- * @param {string} b - Second colour.
184
- * @param {number} t - 0 gives `a`, 1 gives `b`.
185
- * @returns {string} `#rrggbb`.
186
- *
187
- * @example
188
- * mixOklch('#c9502a', '#a9bdf2', 0.5); // a clear rose, not a muddy mauve
189
- */
190
- export function mixOklch(a, b, t) {
191
- const p = toOklch(a);
192
- const q = toOklch(b);
193
- // A near-grey has no meaningful hue: take the other colour's.
194
- const ph = p.c < 0.02 ? q.h : p.h;
195
- const qh = q.c < 0.02 ? p.h : q.h;
196
- const dh = ((qh - ph + 540) % 360) - 180;
197
- return fromOklch({ l: p.l + (q.l - p.l) * t, c: p.c + (q.c - p.c) * t, h: (ph + dh * t + 360) % 360 });
198
- }
199
-
200
- /** Near-black used wherever white doesn't read. */
201
- export const NEAR_BLACK = '#111111';
202
-
203
- /**
204
- * A colour for a mark on one or more backgrounds: `prefer` when it clears
205
- * `ratio` against every one of them, else whichever of white and
206
- * near-black has the better worst case.
207
- *
208
- * For icons and other non-text marks WCAG 1.4.11 asks 3:1 (text needs
209
- * 4.5:1), so `onBackground(bg, 3, 'white')` keeps white on mid-tone fills
210
- * where a text rule would flip to black.
211
- *
212
- * @param {string | string[]} backgrounds - The colour(s) under the mark.
213
- * @param {number} [ratio=3] - Contrast to clear.
214
- * @param {'white' | 'black' | string} [prefer='white'] - The colour to use when it clears `ratio`.
215
- * @returns {string} `#rrggbb`.
216
- *
217
- * @example
218
- * onBackground('#e0533d', 3, 'white'); // '#ffffff' (3.6:1)
219
- * onBackground('#ffe14d', 3, 'white'); // '#111111'
220
- */
221
- export function onBackground(backgrounds, ratio = 3, prefer = 'white') {
222
- const bgs = [].concat(/** @type {any} */ (backgrounds)).map(toHex);
223
- const want = prefer === 'white' ? '#ffffff' : prefer === 'black' ? NEAR_BLACK : toHex(prefer);
224
- const worst = (/** @type {string} */ c) => Math.min(...bgs.map((b) => contrastRatio(c, b)));
225
- if (worst(want) >= ratio) return want;
226
- return worst('#ffffff') >= worst(NEAR_BLACK) ? '#ffffff' : NEAR_BLACK;
227
- }
228
-
229
- /**
230
- * The colour of an eased multi-stop gradient at `t` (0–1), the way the
231
- * `linear` and `radial` layers draw it (smoothstep between stops, in OKLab).
232
- *
233
- * @param {string[]} colors - Two or more stops, evenly spaced.
234
- * @param {number} t - Position along the gradient.
235
- * @param {boolean} [ease=true] - Ease between stops.
236
- * @returns {string} `#rrggbb`.
237
- */
238
- export function gradientAt(colors, t, ease = true) {
239
- const list = colors.map(toHex);
240
- if (list.length === 1) return list[0];
241
- const n = list.length - 1;
242
- const x = Math.max(0, Math.min(1, t)) * n;
243
- const i = Math.min(n - 1, Math.floor(x));
244
- const f = x - i;
245
- return mixOklab(list[i], list[i + 1], ease ? f * f * (3 - 2 * f) : f);
246
- }
@@ -1,7 +0,0 @@
1
- // The layer as text art.
2
- import type { Effect } from './index.js';
3
- import type { Pixels } from './buffer.js';
4
- import type { PixelResult } from './pixels.js';
5
-
6
- /** `ascii`, on pixels (one pixel per font pixel, scaled up crisp). */
7
- export function ascii(img: Pixels, fx: Extract<Effect, { kind: 'ascii' }>, ctx: object): PixelResult;
@@ -1,93 +0,0 @@
1
- /**
2
- * `ascii`: the layer as text art. Each cell's brightness picks a
3
- * character from a ramp (ordered by how much ink each glyph of the
4
- * built-in 5×7 pixel face carries), drawn in the cell's own colour or one
5
- * ink, on a background. Rendered at one pixel per font pixel and embedded
6
- * scaled up crisp, so it's tiny and sharp at any size.
7
- *
8
- * @module effects/ascii
9
- */
10
-
11
- import { px, premultiplied } from './buffer.js';
12
- import { rgb } from './util.js';
13
- import { pixelBitmap } from '../type/pixel.js';
14
-
15
- /** The default ramp, light to dense. */
16
- const RAMP = ' .:-=+*%#@';
17
-
18
- /**
19
- * `ascii`: `cell` (a length: a character's width; height is 8/6 of it),
20
- * `ramp` (characters, sparse to dense, re-ordered by their ink), `color`
21
- * (`'source'`, each character the colour under it, or one colour),
22
- * `background` (a colour, or `'none'`), `invert` (dense where it's dark,
23
- * for dark ink on paper), `contrast` (after the tones are stretched to the
24
- * layer's own range).
25
- *
26
- * @param {import('./buffer.js').Pixels} img
27
- * @param {import('./index.js').Effect & { kind: 'ascii' }} fx
28
- * @param {any} ctx
29
- * @returns {import('./pixels.js').PixelResult}
30
- */
31
- export function ascii(img, fx, ctx) {
32
- const { width: w, height: h } = img;
33
- const cw = Math.max(3, px(fx.cell, ctx, img, '1.4vmin'));
34
- const ch = (cw * 8) / 6;
35
- const cols = Math.max(1, Math.round(w / cw));
36
- const rows = Math.max(1, Math.round(h / ch));
37
- // Glyphs that the face draws, by ink.
38
- const glyphs = [...new Set([...(fx.ramp ?? RAMP)])].map((c) => ({ c, bits: c === ' ' ? '.'.repeat(35) : pixelBitmap(c) })).filter((g) => g.bits);
39
- if (!glyphs.length) throw new TypeError('ascii: the ramp has no characters the pixel face draws (A–Z, 0–9, punctuation)');
40
- glyphs.sort((a, b) => (a.bits?.split('#').length ?? 0) - (b.bits?.split('#').length ?? 0));
41
- const src = premultiplied(img);
42
- const acc = new Float64Array(cols * rows * 5);
43
- for (let y = 0; y < h; y++) {
44
- const cy = Math.min(rows - 1, ((y * rows) / h) | 0);
45
- for (let x = 0; x < w; x++) {
46
- const c = (cy * cols + Math.min(cols - 1, ((x * cols) / w) | 0)) * 5;
47
- const i = (y * w + x) * 4;
48
- acc[c] += src[i]; acc[c + 1] += src[i + 1]; acc[c + 2] += src[i + 2]; acc[c + 3] += src[i + 3]; acc[c + 4]++;
49
- }
50
- }
51
- const ink = fx.color && fx.color !== 'source' ? rgb(fx.color).map((v) => v * 255) : null;
52
- const bg = fx.background === 'none' ? null : rgb(fx.background ?? '#0b0b0b').map((v) => v * 255);
53
- const contrast = fx.contrast ?? 1.2;
54
- // Tones stretched to the cells' own range, so a dim layer still reads.
55
- const lums = new Float32Array(cols * rows);
56
- let lo = 1;
57
- let hi = 0;
58
- for (let c = 0; c < lums.length; c++) {
59
- const a = acc[c * 5 + 3];
60
- if (!a) continue;
61
- lums[c] = (0.2126 * acc[c * 5] + 0.7152 * acc[c * 5 + 1] + 0.0722 * acc[c * 5 + 2]) / a;
62
- lo = Math.min(lo, lums[c]);
63
- hi = Math.max(hi, lums[c]);
64
- }
65
- const span = hi - lo > 0.05 ? hi - lo : 1;
66
- const W = cols * 6;
67
- const H = rows * 8;
68
- const out = new Uint8ClampedArray(W * H * 4);
69
- for (let cy = 0; cy < rows; cy++) {
70
- for (let cx = 0; cx < cols; cx++) {
71
- const c = (cy * cols + cx) * 5;
72
- const n = acc[c + 4] || 1;
73
- const a = acc[c + 3] / n;
74
- const k = a ? 255 / a : 0;
75
- const r = (acc[c] / n) * k;
76
- const g = (acc[c + 1] / n) * k;
77
- const b = (acc[c + 2] / n) * k;
78
- let l = (lums[cy * cols + cx] - (span === 1 ? 0 : lo)) / span;
79
- l = Math.min(1, Math.max(0, (l - 0.5) * contrast + 0.5));
80
- if (fx.invert) l = 1 - l;
81
- const glyph = a < 8 ? glyphs[0] : glyphs[Math.min(glyphs.length - 1, Math.floor(l * glyphs.length))];
82
- const col = ink ?? [r, g, b];
83
- for (let gy = 0; gy < 8; gy++) {
84
- for (let gx = 0; gx < 6; gx++) {
85
- const on = gy < 7 && gx < 5 && glyph.bits?.[gy * 5 + gx] === '#';
86
- const o = ((cy * 8 + gy) * W + cx * 6 + gx) * 4;
87
- if (on) { out[o] = col[0]; out[o + 1] = col[1]; out[o + 2] = col[2]; out[o + 3] = 255; } else if (bg) { out[o] = bg[0]; out[o + 1] = bg[1]; out[o + 2] = bg[2]; out[o + 3] = 255; }
88
- }
89
- }
90
- }
91
- }
92
- return { data: out, width: W, height: H, pixelated: true };
93
- }