@arraypress/covers 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +76 -0
- package/README.md +296 -11
- package/package.json +33 -5
- package/recipes/brand.json +1143 -0
- package/schema/recipe.schema.json +7300 -770
- package/src/animate/index.d.ts +19 -0
- package/src/animate/index.js +116 -0
- package/src/builtins.js +4 -0
- package/src/effects/ascii.d.ts +7 -0
- package/src/effects/ascii.js +93 -0
- package/src/effects/buffer.d.ts +60 -0
- package/src/effects/buffer.js +472 -0
- package/src/effects/detail.d.ts +12 -0
- package/src/effects/detail.js +124 -0
- package/src/effects/digital.d.ts +14 -0
- package/src/effects/digital.js +216 -0
- package/src/effects/distort.d.ts +20 -0
- package/src/effects/distort.js +314 -0
- package/src/effects/dither.d.ts +8 -0
- package/src/effects/dither.js +147 -0
- package/src/effects/drop-shadow.d.ts +4 -0
- package/src/effects/drop-shadow.js +45 -0
- package/src/effects/extrude.d.ts +5 -0
- package/src/effects/extrude.js +55 -0
- package/src/effects/film.d.ts +11 -0
- package/src/effects/film.js +204 -0
- package/src/effects/glow.d.ts +8 -0
- package/src/effects/glow.js +56 -0
- package/src/effects/grade.d.ts +30 -0
- package/src/effects/grade.js +274 -0
- package/src/effects/halftone.d.ts +8 -0
- package/src/effects/halftone.js +95 -0
- package/src/effects/index.d.ts +125 -0
- package/src/effects/index.js +277 -0
- package/src/effects/ink.d.ts +12 -0
- package/src/effects/ink.js +70 -0
- package/src/effects/lens.d.ts +14 -0
- package/src/effects/lens.js +277 -0
- package/src/effects/light.d.ts +10 -0
- package/src/effects/light.js +59 -0
- package/src/effects/looks.d.ts +9 -0
- package/src/effects/looks.js +74 -0
- package/src/effects/mask.d.ts +52 -0
- package/src/effects/mask.js +314 -0
- package/src/effects/motion.d.ts +6 -0
- package/src/effects/motion.js +60 -0
- package/src/effects/newsprint.d.ts +6 -0
- package/src/effects/newsprint.js +89 -0
- package/src/effects/optics.d.ts +10 -0
- package/src/effects/optics.js +213 -0
- package/src/effects/options.d.ts +6 -0
- package/src/effects/options.js +106 -0
- package/src/effects/paint-presets.d.ts +10 -0
- package/src/effects/paint-presets.js +47 -0
- package/src/effects/paint.d.ts +49 -0
- package/src/effects/paint.js +66 -0
- package/src/effects/pixels.d.ts +24 -0
- package/src/effects/pixels.js +138 -0
- package/src/effects/polar.d.ts +10 -0
- package/src/effects/polar.js +265 -0
- package/src/effects/region.d.ts +8 -0
- package/src/effects/region.js +136 -0
- package/src/effects/round-border.d.ts +5 -0
- package/src/effects/round-border.js +62 -0
- package/src/effects/screen.d.ts +14 -0
- package/src/effects/screen.js +212 -0
- package/src/effects/sort.d.ts +8 -0
- package/src/effects/sort.js +125 -0
- package/src/effects/symmetry.d.ts +6 -0
- package/src/effects/symmetry.js +74 -0
- package/src/effects/tile.d.ts +7 -0
- package/src/effects/tile.js +55 -0
- package/src/effects/tone.d.ts +16 -0
- package/src/effects/tone.js +117 -0
- package/src/effects/transform.d.ts +35 -0
- package/src/effects/transform.js +127 -0
- package/src/effects/util.d.ts +16 -0
- package/src/effects/util.js +85 -0
- package/src/extract.js +4 -3
- package/src/fonts.d.ts +55 -0
- package/src/fonts.js +246 -53
- package/src/formats/cut.d.ts +10 -0
- package/src/formats/cut.js +77 -0
- package/src/formats/index.d.ts +6 -0
- package/src/formats/index.js +9 -0
- package/src/formats/presets.d.ts +41 -0
- package/src/formats/presets.js +89 -0
- package/src/formats/print.d.ts +25 -0
- package/src/formats/print.js +117 -0
- package/src/formats/zones.d.ts +10 -0
- package/src/formats/zones.js +64 -0
- package/src/geometry.js +1 -1
- package/src/icons.js +43 -9
- package/src/index.d.ts +205 -19
- package/src/index.js +12 -4
- package/src/inter-data.js +27 -0
- package/src/json-recipe.js +181 -23
- package/src/kit/index.d.ts +20 -0
- package/src/kit/index.js +58 -0
- package/src/layer-options.js +23 -4
- package/src/layers/barcode.d.ts +19 -0
- package/src/layers/barcode.js +63 -0
- package/src/layers/code128.d.ts +4 -0
- package/src/layers/code128.js +28 -0
- package/src/layers/expr.d.ts +6 -0
- package/src/layers/expr.js +121 -0
- package/src/layers/geometry.d.ts +6 -0
- package/src/layers/geometry.js +238 -0
- package/src/layers/index.d.ts +18 -0
- package/src/layers/index.js +15 -0
- package/src/layers/perforation.d.ts +14 -0
- package/src/layers/perforation.js +47 -0
- package/src/layers/repeat.d.ts +29 -0
- package/src/layers/repeat.js +90 -0
- package/src/layers/shape.d.ts +46 -0
- package/src/layers/shape.js +115 -0
- package/src/layers/soft.d.ts +5 -0
- package/src/layers/soft.js +43 -0
- package/src/layers/svg.d.ts +23 -0
- package/src/layers/svg.js +109 -0
- package/src/layers/table.d.ts +25 -0
- package/src/layers/table.js +80 -0
- package/src/layers/tear.d.ts +21 -0
- package/src/layers/tear.js +74 -0
- package/src/layers/texture.d.ts +17 -0
- package/src/layers/texture.js +106 -0
- package/src/layers/util.d.ts +8 -0
- package/src/layers/util.js +48 -0
- package/src/layers.js +125 -41
- package/src/lint/image-size.d.ts +4 -0
- package/src/lint/image-size.js +34 -0
- package/src/lint/index.d.ts +16 -0
- package/src/lint/index.js +127 -0
- package/src/lint/rules.d.ts +5 -0
- package/src/lint/rules.js +19 -0
- package/src/node.d.ts +2 -0
- package/src/node.js +20 -0
- package/src/platform/bytes.d.ts +22 -0
- package/src/platform/bytes.js +103 -0
- package/src/platform/compress.d.ts +12 -0
- package/src/platform/compress.js +81 -0
- package/src/platform/harfbuzz.d.ts +9 -0
- package/src/platform/harfbuzz.js +80 -0
- package/src/platform/index.d.ts +8 -0
- package/src/platform/index.js +13 -0
- package/src/platform/load.d.ts +6 -0
- package/src/platform/load.js +72 -0
- package/src/platform/no-harfbuzz.d.ts +2 -0
- package/src/platform/no-harfbuzz.js +11 -0
- package/src/platform/node.d.ts +26 -0
- package/src/platform/node.js +86 -0
- package/src/platform/settings.d.ts +28 -0
- package/src/platform/settings.js +51 -0
- package/src/png.js +77 -28
- package/src/print.js +8 -7
- package/src/raster/index.d.ts +24 -0
- package/src/raster/index.js +171 -0
- package/src/recipes.js +4 -3
- package/src/render/backdrop.d.ts +6 -0
- package/src/render/backdrop.js +55 -0
- package/src/render/content.d.ts +5 -0
- package/src/render/content.js +58 -0
- package/src/render/context.d.ts +82 -0
- package/src/render/grain.d.ts +7 -0
- package/src/render/grain.js +113 -0
- package/src/render/group.d.ts +23 -0
- package/src/render/group.js +67 -0
- package/src/render/key.d.ts +5 -0
- package/src/render/key.js +91 -0
- package/src/render/report.d.ts +5 -0
- package/src/render/report.js +19 -0
- package/src/render/tiles.d.ts +6 -0
- package/src/render/tiles.js +77 -0
- package/src/render/use.d.ts +27 -0
- package/src/render/use.js +73 -0
- package/src/render/wrap.d.ts +14 -0
- package/src/render/wrap.js +199 -0
- package/src/render.js +303 -212
- package/src/text.d.ts +29 -0
- package/src/text.js +22 -15
- package/src/type/auto-color.d.ts +9 -0
- package/src/type/auto-color.js +44 -0
- package/src/type/fallback.d.ts +11 -0
- package/src/type/fallback.js +92 -0
- package/src/type/glyphs.d.ts +24 -0
- package/src/type/glyphs.js +87 -0
- package/src/type/index.d.ts +5 -0
- package/src/type/index.js +10 -0
- package/src/type/kern.d.ts +4 -0
- package/src/type/kern.js +144 -0
- package/src/type/layout-ref.d.ts +10 -0
- package/src/type/layout-ref.js +74 -0
- package/src/type/line.d.ts +11 -0
- package/src/type/line.js +105 -0
- package/src/type/live.d.ts +6 -0
- package/src/type/live.js +39 -0
- package/src/type/name.d.ts +4 -0
- package/src/type/name.js +32 -0
- package/src/type/path.d.ts +18 -0
- package/src/type/path.js +89 -0
- package/src/type/pixel.d.ts +9 -0
- package/src/type/pixel.js +115 -0
- package/src/type/sfnt.d.ts +20 -0
- package/src/type/sfnt.js +258 -0
- package/src/type/spans.d.ts +33 -0
- package/src/type/spans.js +138 -0
- package/src/type/symbols.d.ts +7 -0
- package/src/type/symbols.js +89 -0
- package/src/type/typeset.d.ts +10 -0
- package/src/type/typeset.js +352 -0
- package/src/type/woff2.d.ts +6 -0
- package/src/type/woff2.js +301 -0
- package/src/version.js +4 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small helpers the poster layers share: coordinates in px or `'%'`, the
|
|
3
|
+
* opacity/blend wrapper, and the text of a string-or-object option.
|
|
4
|
+
*
|
|
5
|
+
* @module layers/util
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { attrs, esc } from '../svg.js';
|
|
9
|
+
import { len } from '../geometry.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A coordinate: px as a number, `'50%'` of the canvas along `axis`, or a
|
|
13
|
+
* length (`'4vmin'`).
|
|
14
|
+
*
|
|
15
|
+
* @param {unknown} v
|
|
16
|
+
* @param {'x' | 'y'} axis
|
|
17
|
+
* @param {{ width: number, height: number }} canvas
|
|
18
|
+
* @returns {number} px
|
|
19
|
+
*/
|
|
20
|
+
export function pos(v, axis, canvas) {
|
|
21
|
+
if (typeof v === 'string' && v.trim().endsWith('%')) return (parseFloat(v) / 100) * (axis === 'x' ? canvas.width : canvas.height);
|
|
22
|
+
return len(/** @type {any} */ (v), canvas, 0);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Wrap markup in a group with the layer's `opacity` and `blend` (only when
|
|
27
|
+
* it has them).
|
|
28
|
+
*
|
|
29
|
+
* @param {{ opacity?: number, blend?: string }} layer
|
|
30
|
+
* @param {string} body
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
export function wrap(layer, body) {
|
|
34
|
+
const a = attrs({ opacity: layer.opacity != null && layer.opacity < 1 ? Math.max(0, layer.opacity) : undefined, style: layer.blend && layer.blend !== 'normal' ? `mix-blend-mode:${esc(layer.blend)}` : undefined });
|
|
35
|
+
return a ? `<g${a}>${body}</g>` : body;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The text of a string-or-object option (`'Hello'` or `{ content: 'Hello' }`).
|
|
40
|
+
*
|
|
41
|
+
* @param {unknown} v
|
|
42
|
+
* @returns {string | undefined}
|
|
43
|
+
*/
|
|
44
|
+
export function contentOf(v) {
|
|
45
|
+
if (typeof v === 'string' || typeof v === 'number') return String(v);
|
|
46
|
+
if (v && typeof v === 'object' && 'content' in v) return /** @type {any} */ (v).content;
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
package/src/layers.js
CHANGED
|
@@ -14,17 +14,24 @@
|
|
|
14
14
|
* @module layers
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import {
|
|
17
|
+
import { loadBytes } from './platform/index.js';
|
|
18
|
+
import { toBase64, latin1, utf8 } from './platform/bytes.js';
|
|
18
19
|
import { attrs, esc, layerAttrs, num } from './svg.js';
|
|
19
20
|
import { len, place, anchorAlign, unit, regionPx } from './geometry.js';
|
|
20
21
|
import { toHex, mixOklab, toOklch, fromOklch } from './color.js';
|
|
21
22
|
import { harfbuzz, loadFont, pickFont } from './fonts.js';
|
|
22
|
-
import { layout, runPath } from './text.js';
|
|
23
|
+
import { layout, runPath, caseText } from './text.js';
|
|
23
24
|
import { resolveIcon, parseSvgIcon } from './icons.js';
|
|
24
25
|
import { gridPath } from './grid.js';
|
|
25
26
|
import { diagram } from './diagram.js';
|
|
26
27
|
import { paper, scribble } from './handmade.js';
|
|
27
28
|
import { ink, dither, contour, waveform, tiles, rule } from './print.js';
|
|
29
|
+
import { typeset, wantsType } from './type/index.js';
|
|
30
|
+
import { recordLayout } from './type/layout-ref.js';
|
|
31
|
+
import { shape, svg, table, barcode, perforation, tear, texture } from './layers/index.js';
|
|
32
|
+
import { pos } from './layers/util.js';
|
|
33
|
+
import { paint } from './effects/paint.js';
|
|
34
|
+
import { imageSize } from './lint/image-size.js';
|
|
28
35
|
|
|
29
36
|
/**
|
|
30
37
|
* Everything a layer renderer needs.
|
|
@@ -406,35 +413,31 @@ export function stripes(layer, ctx) {
|
|
|
406
413
|
*/
|
|
407
414
|
export async function image(layer, ctx) {
|
|
408
415
|
if (!layer.src) throw new TypeError('An image layer needs a src (a file path or bytes)');
|
|
409
|
-
const
|
|
416
|
+
const L = /** @type {any} */ (layer);
|
|
417
|
+
// 1.1: an explicit box in px (x, y, width, height), as an SVG <image> would take.
|
|
418
|
+
const box = L.x != null || L.y != null || L.width != null || L.height != null
|
|
419
|
+
? { x: pos(L.x ?? 0, 'x', ctx.canvas), y: pos(L.y ?? 0, 'y', ctx.canvas), width: L.width != null ? pos(L.width, 'x', ctx.canvas) : ctx.width, height: L.height != null ? pos(L.height, 'y', ctx.canvas) : ctx.height }
|
|
420
|
+
: regionPx(layer.region, ctx.canvas);
|
|
410
421
|
const fit = layer.fit ?? 'cover';
|
|
411
422
|
const focus = layer.focus ?? 'center';
|
|
412
|
-
let bytes = typeof layer.src === 'string' ? await readSource(layer.src) : layer.src;
|
|
423
|
+
let bytes = typeof layer.src === 'string' || layer.src instanceof URL ? await readSource(layer.src) : await loadBytes(layer.src, 'image');
|
|
413
424
|
let mime = sniff(bytes);
|
|
414
|
-
let preserve = `${alignFor(focus)} ${fit === 'contain' ? 'meet' : 'slice'}`;
|
|
425
|
+
let preserve = fit === 'stretch' ? 'none' : `${alignFor(focus)} ${fit === 'contain' ? 'meet' : 'slice'}`;
|
|
415
426
|
let sharpBlurred = false;
|
|
416
427
|
|
|
428
|
+
/** Source pixels and what was embedded, for lint. */
|
|
429
|
+
let source = imageSize(bytes);
|
|
430
|
+
let embedded = source;
|
|
417
431
|
if (ctx.sharp && mime !== 'image/svg+xml') {
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
let img = ctx.sharp(bytes).rotate().resize(w, hgt, { fit, position, background: { r: 0, g: 0, b: 0, alpha: 0 } });
|
|
422
|
-
// Stretch the tones first, so a duotone spans both of its colours.
|
|
423
|
-
if (layer.normalize ?? Boolean(layer.duotone || layer.grayscale)) img = img.normalise();
|
|
424
|
-
// Blur here rather than in SVG: sharp clamps at the edges, where an SVG
|
|
425
|
-
// blur (in librsvg) fades the photo to transparent.
|
|
426
|
-
if (layer.blur) {
|
|
427
|
-
img = img.blur(Math.max(0.3, len(layer.blur, ctx.canvas)));
|
|
428
|
-
sharpBlurred = true;
|
|
429
|
-
}
|
|
430
|
-
const meta = await ctx.sharp(bytes).metadata();
|
|
431
|
-
const alpha = meta.hasAlpha || fit === 'contain';
|
|
432
|
-
bytes = await (alpha ? img.png() : img.jpeg({ quality: 90 })).toBuffer();
|
|
433
|
-
mime = alpha ? 'image/png' : 'image/jpeg';
|
|
432
|
+
const out = await resample(ctx, bytes, box, fit, focus, layer);
|
|
433
|
+
({ bytes, mime, source, embedded } = out);
|
|
434
|
+
sharpBlurred = Boolean(layer.blur);
|
|
434
435
|
preserve = 'none';
|
|
435
436
|
} else if (!mime) {
|
|
436
437
|
throw new TypeError('Unrecognised image format (use PNG, JPEG, WebP, AVIF, GIF or SVG)');
|
|
437
438
|
}
|
|
439
|
+
const res = /** @type {any} */ (ctx).res ?? 1;
|
|
440
|
+
/** @type {any} */ (ctx).report?.({ kind: 'image', id: layer.id, drawn: { width: box.width * res, height: box.height * res }, source, embedded, fit });
|
|
438
441
|
|
|
439
442
|
const p = ctx.palette;
|
|
440
443
|
const id = `${ctx.id}f`;
|
|
@@ -454,7 +457,7 @@ export async function image(layer, ctx) {
|
|
|
454
457
|
? `<filter${attrs({ id, x: 0, y: 0, width: '100%', height: '100%', 'color-interpolation-filters': 'sRGB' })}>${filters.join('')}</filter>`
|
|
455
458
|
: '';
|
|
456
459
|
|
|
457
|
-
let body = `<image${attrs({ x: box.x, y: box.y, width: box.width, height: box.height, preserveAspectRatio: preserve, filter: defs ? `url(#${id})` : undefined, href: `data:${mime};base64,${
|
|
460
|
+
let body = `<image${attrs({ x: box.x, y: box.y, width: box.width, height: box.height, preserveAspectRatio: preserve, filter: defs ? `url(#${id})` : undefined, href: `data:${mime};base64,${toBase64(bytes)}` })}/>`;
|
|
458
461
|
if (layer.tint) {
|
|
459
462
|
const t = typeof layer.tint === 'string' ? { color: layer.tint } : layer.tint;
|
|
460
463
|
body += `<rect${attrs({ x: box.x, y: box.y, width: box.width, height: box.height, fill: color(t.color, p.accent), opacity: t.amount ?? 0.35, style: `mix-blend-mode:${t.blend ?? 'multiply'}` })}/>`;
|
|
@@ -462,6 +465,64 @@ export async function image(layer, ctx) {
|
|
|
462
465
|
return { defs, body: wrap(layer, body) };
|
|
463
466
|
}
|
|
464
467
|
|
|
468
|
+
/** Embeds up to this many pixels at top level are sized to their box, as in 1.0, even past the source's pixels. */
|
|
469
|
+
const CAP_FREE = 4e6;
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Resize and crop an image to its box with sharp, at the pixels it's drawn
|
|
473
|
+
* at (its box times the group and transform scale above it), never above
|
|
474
|
+
* the source's own pixels (the SVG scales it up instead, so a small photo
|
|
475
|
+
* isn't embedded at output size). Opaque results are JPEG, even from a
|
|
476
|
+
* source with an unused alpha channel; PNG only where there's transparency.
|
|
477
|
+
*
|
|
478
|
+
* @param {RenderContext} ctx
|
|
479
|
+
* @param {Uint8Array} bytes
|
|
480
|
+
* @param {{ width: number, height: number }} box
|
|
481
|
+
* @param {string} fit
|
|
482
|
+
* @param {any} focus
|
|
483
|
+
* @param {import('./index.js').ImageLayer} layer
|
|
484
|
+
* @returns {Promise<{ bytes: Uint8Array, mime: string, source: { width: number, height: number } | undefined, embedded: { width: number, height: number } }>}
|
|
485
|
+
*/
|
|
486
|
+
async function resample(ctx, bytes, box, fit, focus, layer) {
|
|
487
|
+
const sharp = /** @type {any} */ (ctx.sharp);
|
|
488
|
+
const meta = await sharp(bytes).metadata();
|
|
489
|
+
// EXIF orientations 5–8 turn the image a quarter.
|
|
490
|
+
const [sw, sh] = (meta.orientation ?? 1) >= 5 ? [meta.height, meta.width] : [meta.width, meta.height];
|
|
491
|
+
const res = /** @type {any} */ (ctx).res ?? 1;
|
|
492
|
+
let w = box.width * res;
|
|
493
|
+
let hgt = box.height * res;
|
|
494
|
+
// How much the fit would enlarge the source: past 1, embed at the source's
|
|
495
|
+
// own size. (A top-level image under 4 MP keeps 1.0's box-sized embed, so
|
|
496
|
+
// 1.0 covers stay byte-identical.)
|
|
497
|
+
const cap = res !== 1 || w * hgt > CAP_FREE;
|
|
498
|
+
const grow = !cap || fit === 'stretch' ? 1 : fit === 'contain' ? Math.min(w / sw, hgt / sh) : Math.max(w / sw, hgt / sh);
|
|
499
|
+
if (grow > 1) { w /= grow; hgt /= grow; }
|
|
500
|
+
if (cap && fit === 'stretch') { w = Math.min(w, sw); hgt = Math.min(hgt, sh); }
|
|
501
|
+
w = Math.max(1, Math.round(w));
|
|
502
|
+
hgt = Math.max(1, Math.round(hgt));
|
|
503
|
+
const position = typeof focus === 'string' && ['attention', 'entropy'].includes(focus) ? focus : (POSITIONS[focus] ?? 'centre');
|
|
504
|
+
let img = sharp(bytes).rotate().resize(w, hgt, { fit: fit === 'stretch' ? 'fill' : fit, position, background: { r: 0, g: 0, b: 0, alpha: 0 } });
|
|
505
|
+
// Stretch the tones first, so a duotone spans both of its colours.
|
|
506
|
+
if (layer.normalize ?? Boolean(layer.duotone || layer.grayscale)) img = img.normalise();
|
|
507
|
+
// Blur here rather than in SVG: sharp clamps at the edges, where an SVG
|
|
508
|
+
// blur (in librsvg) fades the photo to transparent. In the pixels drawn.
|
|
509
|
+
if (layer.blur) img = img.blur(Math.max(0.3, len(layer.blur, ctx.canvas) * res / Math.max(1, grow)));
|
|
510
|
+
const embedded = { width: w, height: hgt };
|
|
511
|
+
const source = { width: sw, height: sh };
|
|
512
|
+
if (!meta.hasAlpha && fit !== 'contain') return { bytes: await img.jpeg({ quality: 90 }).toBuffer(), mime: 'image/jpeg', source, embedded };
|
|
513
|
+
// An alpha channel: PNG only if it's used (a letterbox, a cutout).
|
|
514
|
+
const { data, info } = await img.clone().ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
515
|
+
const raw = { raw: { width: info.width, height: info.height, channels: 4 } };
|
|
516
|
+
let opaque = true;
|
|
517
|
+
for (let i = 3; i < data.length; i += 4) if (data[i] !== 255) { opaque = false; break; }
|
|
518
|
+
if (opaque) return { bytes: await sharp(data, raw).removeAlpha().jpeg({ quality: 90 }).toBuffer(), mime: 'image/jpeg', source, embedded };
|
|
519
|
+
if (fit === 'contain' && !meta.hasAlpha && res === 1 && grow <= 1) {
|
|
520
|
+
// As 1.0 encoded it (the letterbox's alpha), byte for byte.
|
|
521
|
+
return { bytes: await img.png().toBuffer(), mime: 'image/png', source, embedded };
|
|
522
|
+
}
|
|
523
|
+
return { bytes: await sharp(data, raw).png().toBuffer(), mime: 'image/png', source, embedded };
|
|
524
|
+
}
|
|
525
|
+
|
|
465
526
|
/** Anchor name to sharp's crop `position`. */
|
|
466
527
|
const POSITIONS = /** @type {Record<string, string>} */ ({
|
|
467
528
|
center: 'centre', top: 'top', bottom: 'bottom', left: 'left', right: 'right',
|
|
@@ -484,10 +545,7 @@ function defaultDuotone(p) {
|
|
|
484
545
|
|
|
485
546
|
/** @param {string} src @returns {Promise<Uint8Array>} */
|
|
486
547
|
async function readSource(src) {
|
|
487
|
-
|
|
488
|
-
if (data) return Buffer.from(data[1], 'base64');
|
|
489
|
-
if (/^https?:/i.test(src)) throw new TypeError('Image src must be a local file or bytes: covers never fetch from the network');
|
|
490
|
-
return readFile(src);
|
|
548
|
+
return loadBytes(src, 'image');
|
|
491
549
|
}
|
|
492
550
|
|
|
493
551
|
/**
|
|
@@ -500,10 +558,10 @@ export function sniff(b) {
|
|
|
500
558
|
if (b[0] === 0x89 && b[1] === 0x50) return 'image/png';
|
|
501
559
|
if (b[0] === 0xff && b[1] === 0xd8) return 'image/jpeg';
|
|
502
560
|
if (b[0] === 0x47 && b[1] === 0x49) return 'image/gif';
|
|
503
|
-
const head =
|
|
561
|
+
const head = latin1(b.subarray(0, 32));
|
|
504
562
|
if (head.startsWith('RIFF') && head.slice(8, 12) === 'WEBP') return 'image/webp';
|
|
505
563
|
if (head.slice(4, 12) === 'ftypavif' || head.slice(4, 12) === 'ftypavis') return 'image/avif';
|
|
506
|
-
if (/^\s*(<\?xml|<svg)/i.test(
|
|
564
|
+
if (/^\s*(<\?xml|<svg)/i.test(utf8(b.subarray(0, 256)))) return 'image/svg+xml';
|
|
507
565
|
return undefined;
|
|
508
566
|
}
|
|
509
567
|
|
|
@@ -766,7 +824,8 @@ export function icon(layer, ctx) {
|
|
|
766
824
|
const p = ctx.palette;
|
|
767
825
|
// A name that is markup is a raw SVG (so one `icon` option can carry either).
|
|
768
826
|
const name = typeof layer.name === 'string' ? layer.name.trim() : undefined;
|
|
769
|
-
const
|
|
827
|
+
const markup = /** @type {any} */ (layer).markup;
|
|
828
|
+
const svg = layer.svg ?? (name?.startsWith('<') ? name : undefined) ?? (markup ? `<svg viewBox="${(/** @type {any} */ (layer).viewBox ?? [0, 0, 512, 512]).join(' ')}"><g fill="currentColor">${markup}</g></svg>` : undefined);
|
|
770
829
|
const data = svg
|
|
771
830
|
? parseSvgIcon(svg)
|
|
772
831
|
: name
|
|
@@ -779,8 +838,11 @@ export function icon(layer, ctx) {
|
|
|
779
838
|
const tileOpts = layer.tile === true ? {} : layer.tile || null;
|
|
780
839
|
const size = len(layer.size, ctx.canvas, tileOpts ? '15vmin' : '24vmin');
|
|
781
840
|
const tileSize = tileOpts ? len(tileOpts.size, ctx.canvas, size / 0.5) : size;
|
|
782
|
-
const margin = len(layer.margin, ctx.canvas, unit(ctx.canvas) * 0.07);
|
|
783
|
-
|
|
841
|
+
const margin = len(layer.margin, ctx.canvas, /** @type {any} */ (ctx).inset ?? unit(ctx.canvas) * 0.07);
|
|
842
|
+
// 1.1: `x`, `y` place the icon's top-left corner in px (a mark at translate(x y) scale(size / 512)).
|
|
843
|
+
const at = /** @type {any} */ (layer).x != null || /** @type {any} */ (layer).y != null
|
|
844
|
+
? { x: pos(/** @type {any} */ (layer).x ?? 0, 'x', ctx.canvas), y: pos(/** @type {any} */ (layer).y ?? 0, 'y', ctx.canvas) }
|
|
845
|
+
: place({ width: tileSize, height: tileSize, canvas: ctx.canvas, anchor: layer.anchor ?? 'center', margin, position: layer.position, offset: layer.offset });
|
|
784
846
|
|
|
785
847
|
let defs = '';
|
|
786
848
|
let body = '';
|
|
@@ -807,6 +869,13 @@ export function icon(layer, ctx) {
|
|
|
807
869
|
const ih = vh * fitScale;
|
|
808
870
|
let inner = data.body;
|
|
809
871
|
if (layer.strokeWidth != null) inner = inner.replace(/stroke-width="[^"]*"/g, `stroke-width="${num(layer.strokeWidth)}"`);
|
|
872
|
+
// 1.1: `fill` paints the icon with a gradient, tile or preset, laid over its grid.
|
|
873
|
+
const fill = /** @type {any} */ (layer).fill;
|
|
874
|
+
if (fill != null && typeof fill === 'object') {
|
|
875
|
+
const fp = paint(fill, 'currentColor', { id: `${ctx.id}i`, box: { x: vx, y: vy, width: vw, height: vh }, canvas: ctx.canvas });
|
|
876
|
+
defs += fp.defs;
|
|
877
|
+
inner = inner.replace(/currentColor/g, fp.value);
|
|
878
|
+
}
|
|
810
879
|
body += `<svg${attrs({
|
|
811
880
|
x: at.x + (tileSize - iw) / 2,
|
|
812
881
|
y: at.y + (tileSize - ih) / 2,
|
|
@@ -814,7 +883,7 @@ export function icon(layer, ctx) {
|
|
|
814
883
|
height: ih,
|
|
815
884
|
viewBox: `${num(vx)} ${num(vy)} ${num(vw)} ${num(vh)}`,
|
|
816
885
|
overflow: 'visible',
|
|
817
|
-
color: color(layer.color, tileOpts ? p.accent : p.text),
|
|
886
|
+
color: typeof /** @type {any} */ (layer).fill === 'string' ? toHex(/** @type {any} */ (layer).fill) : color(layer.color, tileOpts ? p.accent : p.text),
|
|
818
887
|
})}>${inner}</svg>`;
|
|
819
888
|
return { defs, body: wrap(layer, body) };
|
|
820
889
|
}
|
|
@@ -843,14 +912,16 @@ function shadowPass(name, blur, dy, opacity) {
|
|
|
843
912
|
* @returns {Promise<Markup>}
|
|
844
913
|
*/
|
|
845
914
|
export async function text(layer, ctx) {
|
|
915
|
+
// 1.1 options (baseline placement, justification, fitting, paths, live text, auto colour).
|
|
916
|
+
if (wantsType(layer)) return typeset(layer, ctx);
|
|
846
917
|
// An override object in `content` (a recipe's "{title}") means its content.
|
|
847
918
|
const raw = layer.content && typeof layer.content === 'object' ? /** @type {any} */ (layer.content).content : layer.content;
|
|
848
|
-
const content =
|
|
919
|
+
const content = caseText(String(raw ?? ''), layer.transform);
|
|
849
920
|
if (!content.trim()) return { defs: '', body: '' };
|
|
850
921
|
const hb = await harfbuzz();
|
|
851
922
|
const p = ctx.palette;
|
|
852
923
|
const u = unit(ctx.canvas);
|
|
853
|
-
const margin = len(layer.margin, ctx.canvas, u * 0.07);
|
|
924
|
+
const margin = len(layer.margin, ctx.canvas, /** @type {any} */ (ctx).inset ?? u * 0.07);
|
|
854
925
|
const anchor = layer.anchor ?? 'bottom-left';
|
|
855
926
|
const align = layer.align ?? anchorAlign(anchor);
|
|
856
927
|
const bg = layer.background ? (typeof layer.background === 'string' ? { color: layer.background } : layer.background) : null;
|
|
@@ -886,7 +957,7 @@ export async function text(layer, ctx) {
|
|
|
886
957
|
const lsize = len(labelOpts.size, ctx.canvas, Math.max(12, set.size * 0.32, u * 0.02));
|
|
887
958
|
const lset = layout({
|
|
888
959
|
face: lface, hb,
|
|
889
|
-
text:
|
|
960
|
+
text: caseText(labelOpts.content, labelOpts.transform ?? 'uppercase'),
|
|
890
961
|
size: lsize,
|
|
891
962
|
letterSpacing: labelOpts.letterSpacing ?? 0.08,
|
|
892
963
|
maxWidth,
|
|
@@ -921,14 +992,16 @@ export async function text(layer, ctx) {
|
|
|
921
992
|
}
|
|
922
993
|
const d = set.lines.map((l, i) => runPath(face, l.run, set.size, lineX(l.run.width), y + cap + i * lh)).join('');
|
|
923
994
|
body += `<path${attrs({ d, fill: color(layer.color, p.text) })}/>`;
|
|
995
|
+
// Where the block landed, for later layers ({layout.<id>.bottom}); what was drawn, for lint (the label too).
|
|
996
|
+
recordLayout(ctx, layer.id, { x: left, y: top, width: blockW, height: blockH }, { x: lineX(set.lines[0]?.run.width ?? 0), y: y + cap, baseline: y + cap + (set.lines.length - 1) * lh, size: set.size, lines: set.lines.length });
|
|
997
|
+
const report = /** @type {any} */ (ctx).report;
|
|
998
|
+
if (report) {
|
|
999
|
+
report({ kind: 'text', id: layer.id, content, size: set.size, capHeight: cap, box: { x: at.x, y: at.y, width: boxW, height: boxH } });
|
|
1000
|
+
if (label) report({ kind: 'text', id: layer.id, content: label.set.lines.map((l) => l.text).join('\n'), size: label.set.size, capHeight: label.cap, box: null });
|
|
1001
|
+
}
|
|
924
1002
|
return { defs: '', body: wrap(layer, body) };
|
|
925
1003
|
}
|
|
926
1004
|
|
|
927
|
-
/** @param {string} s @param {string | undefined} t @returns {string} */
|
|
928
|
-
function transform(s, t) {
|
|
929
|
-
const clean = s.replace(/[\t\r ]+/g, ' ').replace(/ *\n */g, '\n').trim();
|
|
930
|
-
return t === 'uppercase' ? clean.toUpperCase() : t === 'lowercase' ? clean.toLowerCase() : clean;
|
|
931
|
-
}
|
|
932
1005
|
|
|
933
1006
|
/**
|
|
934
1007
|
* Padding as `[vertical, horizontal]` pixels.
|
|
@@ -969,4 +1042,15 @@ export function frame(layer, ctx) {
|
|
|
969
1042
|
}
|
|
970
1043
|
|
|
971
1044
|
/** Every layer type, by name. */
|
|
972
|
-
|
|
1045
|
+
/**
|
|
1046
|
+
* `group` (and `repeat`, `defs`, `use`) are drawn by the renderer (they
|
|
1047
|
+
* render nested stacks); this entry registers the type and explains itself
|
|
1048
|
+
* if called directly.
|
|
1049
|
+
*
|
|
1050
|
+
* @returns {never}
|
|
1051
|
+
*/
|
|
1052
|
+
function group() {
|
|
1053
|
+
throw new TypeError('A group layer is drawn by renderSvg()/renderImage(), not on its own');
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
export const LAYERS = Object.freeze({ solid, linear, radial, mesh, stripes, image, grain, pattern, shapes, icon, text, frame, diagram, paper, scribble, ink, dither, contour, waveform, tiles, rule, group, shape, svg, table, barcode, perforation, tear, texture, repeat: group, defs: group, use: group });
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An image's pixel size from its header, without decoding it.
|
|
3
|
+
*
|
|
4
|
+
* @module lint/image-size
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* An image's pixel size from its header (PNG, JPEG, WebP, GIF).
|
|
9
|
+
*
|
|
10
|
+
* @param {Uint8Array} b
|
|
11
|
+
* @returns {{ width: number, height: number } | undefined}
|
|
12
|
+
*/
|
|
13
|
+
export function imageSize(b) {
|
|
14
|
+
const v = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
|
15
|
+
if (b[0] === 0x89 && b[1] === 0x50) return { width: v.getUint32(16), height: v.getUint32(20) };
|
|
16
|
+
if (b[0] === 0x47 && b[1] === 0x49) return { width: v.getUint16(6, true), height: v.getUint16(8, true) };
|
|
17
|
+
if (b[0] === 0xff && b[1] === 0xd8) {
|
|
18
|
+
let i = 2;
|
|
19
|
+
while (i + 9 < b.length) {
|
|
20
|
+
if (b[i] !== 0xff) { i++; continue; }
|
|
21
|
+
const m = b[i + 1];
|
|
22
|
+
if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) return { width: v.getUint16(i + 7), height: v.getUint16(i + 5) };
|
|
23
|
+
i += 2 + v.getUint16(i + 2);
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
if (String.fromCharCode(b[0], b[1], b[2], b[3]) === 'RIFF' && String.fromCharCode(b[8], b[9], b[10], b[11]) === 'WEBP') {
|
|
28
|
+
const kind = String.fromCharCode(b[12], b[13], b[14], b[15]);
|
|
29
|
+
if (kind === 'VP8X') return { width: 1 + (b[24] | (b[25] << 8) | (b[26] << 16)), height: 1 + (b[27] | (b[28] << 8) | (b[29] << 16)) };
|
|
30
|
+
if (kind === 'VP8L') { const x = v.getUint32(21, true); return { width: (x & 0x3fff) + 1, height: ((x >> 14) & 0x3fff) + 1 }; }
|
|
31
|
+
if (kind === 'VP8 ') return { width: v.getUint16(26, true) & 0x3fff, height: v.getUint16(28, true) & 0x3fff };
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Release cover art checks.
|
|
2
|
+
import type { Cover, CoverOptions } from '../index.js';
|
|
3
|
+
export { imageSize } from './image-size.js';
|
|
4
|
+
|
|
5
|
+
/** A problem `lint` found. */
|
|
6
|
+
export interface LintIssue {
|
|
7
|
+
rule: 'aspect' | 'resolution' | 'url' | 'handle' | 'contact' | 'price' | 'promo' | 'platform' | 'date' | 'physical' | 'legibility' | 'metadata' | 'barcode' | 'upscaled';
|
|
8
|
+
severity: 'error' | 'warning';
|
|
9
|
+
message: string;
|
|
10
|
+
layer?: string;
|
|
11
|
+
text?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
/** Check a cover against Spotify's artwork requirements and distributors' content rules. */
|
|
16
|
+
export function lint(input: CoverOptions | Cover, options?: { target?: 'spotify' | 'release'; artist?: string; title?: string; size?: number }): Promise<{ ok: boolean; target: string; issues: LintIssue[] }>;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks for release cover art before it goes to a distributor.
|
|
3
|
+
*
|
|
4
|
+
* Spotify's own requirements (Spotify for Artists, "Cover art
|
|
5
|
+
* requirements"): square (1:1), 640–10000 px on each side, sRGB, and never
|
|
6
|
+
* upscaled. 3000×3000 is the size distributors and the other stores ask
|
|
7
|
+
* for. The content rules come from the distributors that deliver to
|
|
8
|
+
* Spotify (and Apple Music): no URLs, social handles or contact details, no
|
|
9
|
+
* prices or promotional wording ("out now", "free download"), no release
|
|
10
|
+
* dates or other time frames, no store or platform names and logos, no
|
|
11
|
+
* barcodes or QR codes, and text that matches the release's metadata.
|
|
12
|
+
* Those are reported as errors when a distributor would reject the art,
|
|
13
|
+
* warnings when it may.
|
|
14
|
+
*
|
|
15
|
+
* `lint` also renders the cover to measure what it draws, through every
|
|
16
|
+
* group, transform and stretch: text whose smallest capitals are under 4 px
|
|
17
|
+
* tall at a 64 px thumbnail (the size Spotify lists use) is flagged, and so
|
|
18
|
+
* are images drawn larger than their pixels (or embedded smaller than
|
|
19
|
+
* drawn). Store names that are also words ("Tidal Archive") and day
|
|
20
|
+
* names in titles ("Second Sun") need a cue to count; text spelled from
|
|
21
|
+
* the artist and title (a letter per layer, a knocked-out word) matches.
|
|
22
|
+
*
|
|
23
|
+
* @module lint
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { cover, renderReport } from '../render.js';
|
|
27
|
+
import { RULES } from './rules.js';
|
|
28
|
+
import { imageSize } from './image-size.js';
|
|
29
|
+
|
|
30
|
+
export { imageSize };
|
|
31
|
+
|
|
32
|
+
/** Glyphs set narrower than this (justification) read smaller than their cap height. */
|
|
33
|
+
const SQUEEZE = 0.6;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {object} LintIssue
|
|
37
|
+
* @property {string} rule
|
|
38
|
+
* @property {'error' | 'warning'} severity
|
|
39
|
+
* @property {string} message
|
|
40
|
+
* @property {string} [layer] - The layer's id, when it has one.
|
|
41
|
+
* @property {string} [text] - The text that tripped the rule.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Check a cover against a target's artwork rules.
|
|
46
|
+
*
|
|
47
|
+
* @param {import('../index.js').CoverOptions | import('../index.js').Cover} input
|
|
48
|
+
* @param {{ target?: 'spotify' | 'release', artist?: string, title?: string, size?: number }} [options] - `artist`, `title`: the release metadata the text should match. `size`: the delivery size (default 3000).
|
|
49
|
+
* @returns {Promise<{ ok: boolean, target: string, issues: LintIssue[] }>}
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* const { ok, issues } = await lint({ recipe: myCover, format: 'release', title: 'Night Garden' }, { target: 'spotify' });
|
|
53
|
+
*/
|
|
54
|
+
export async function lint(input, options = {}) {
|
|
55
|
+
const target = options.target ?? 'spotify';
|
|
56
|
+
if (target !== 'spotify' && target !== 'release') throw new TypeError(`Unknown lint target "${target}" (spotify, release)`);
|
|
57
|
+
const c = cover(input);
|
|
58
|
+
/** @type {LintIssue[]} */
|
|
59
|
+
const issues = [];
|
|
60
|
+
const add = (/** @type {LintIssue} */ i) => issues.push(i);
|
|
61
|
+
|
|
62
|
+
// Spotify's requirements.
|
|
63
|
+
if (c.width !== c.height) add({ rule: 'aspect', severity: 'error', message: `Cover art must be square (1:1); this is ${c.width}×${c.height}.` });
|
|
64
|
+
const side = Math.min(c.width, c.height);
|
|
65
|
+
if (side < 640) add({ rule: 'resolution', severity: 'error', message: `Cover art must be at least 640 px on each side; this is ${c.width}×${c.height}.` });
|
|
66
|
+
else if (side < (options.size ?? 3000)) add({ rule: 'resolution', severity: 'warning', message: `Deliver ${options.size ?? 3000}×${options.size ?? 3000} (stores and distributors ask for it); this is ${c.width}×${c.height}. Render with format: 'release'.` });
|
|
67
|
+
if (Math.max(c.width, c.height) > 10000) add({ rule: 'resolution', severity: 'error', message: 'Cover art must be at most 10000 px on each side.' });
|
|
68
|
+
|
|
69
|
+
// What's drawn (sizes in canvas px, through every group and transform).
|
|
70
|
+
const { svg, drawn } = await renderReport(c);
|
|
71
|
+
const texts = drawn.filter((d) => d.kind === 'text').map((d) => ({ ...d }));
|
|
72
|
+
// Raw SVG text the layer system didn't set.
|
|
73
|
+
for (const m of svg.matchAll(/<text\b[^>]*>([^<]+)<\/text>/g)) texts.push({ kind: 'text', content: m[1], size: undefined, capHeight: undefined });
|
|
74
|
+
|
|
75
|
+
for (const t of texts) {
|
|
76
|
+
const content = String(t.content ?? '');
|
|
77
|
+
for (const [rule, severity, re, what] of RULES) {
|
|
78
|
+
const m = re.exec(content);
|
|
79
|
+
if (m) add({ rule, severity, layer: t.id, text: m[0].trim(), message: `Text contains ${what}: "${m[0].trim()}". Distributors reject cover art with ${what}.` });
|
|
80
|
+
}
|
|
81
|
+
// Legibility at a 64 px thumbnail: the block's smallest capitals as drawn,
|
|
82
|
+
// narrowed by a heavy squeeze (justification scaling glyphs under 60% wide).
|
|
83
|
+
if (t.capHeight != null) {
|
|
84
|
+
const squeeze = (t.squeeze ?? 1) < SQUEEZE ? Math.sqrt(t.squeeze / SQUEEZE) : 1;
|
|
85
|
+
const thumb = (t.capHeight * squeeze * 64) / Math.max(c.width, c.height);
|
|
86
|
+
if (thumb < 4) add({ rule: 'legibility', severity: 'warning', layer: t.id, text: content.slice(0, 40), message: `"${content.slice(0, 40)}${content.length > 40 ? '…' : ''}" is ${thumb.toFixed(1)} px tall (capitals) at a 64 px thumbnail; under 4 px it can't be read.` });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Text that isn't the release's metadata. Text that is part of it (a
|
|
91
|
+
// letter, a word knocked out of a shape) passes; so does anything spelled
|
|
92
|
+
// inside the artist and title.
|
|
93
|
+
if (options.artist || options.title) {
|
|
94
|
+
const meta = `${options.artist ?? ''} ${options.title ?? ''}`.toLowerCase();
|
|
95
|
+
const allowed = new Set(meta.split(/[^\p{L}\p{N}]+/u).filter(Boolean));
|
|
96
|
+
const squashed = meta.replace(/[^\p{L}\p{N}]+/gu, '');
|
|
97
|
+
const seen = new Set();
|
|
98
|
+
for (const t of texts) {
|
|
99
|
+
const text = String(t.content ?? '').toLowerCase();
|
|
100
|
+
const letters = text.replace(/[^\p{L}\p{N}]+/gu, '');
|
|
101
|
+
if (!letters || squashed.includes(letters) || seen.has(text)) continue;
|
|
102
|
+
seen.add(text);
|
|
103
|
+
const extra = text.split(/[^\p{L}\p{N}]+/u).filter((w) => w && !allowed.has(w));
|
|
104
|
+
if (extra.length) add({ rule: 'metadata', severity: 'warning', layer: t.id, text: extra.slice(0, 6).join(' '), message: `Text doesn't match the release metadata (${extra.slice(0, 6).join(', ')}${extra.length > 6 ? ', …' : ''}). Stores ask that cover text matches the artist and title.` });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Barcodes and QR codes.
|
|
109
|
+
const walk = (/** @type {any[]} */ list, /** @type {(l: any) => void} */ fn) => list.forEach((l) => { fn(l); for (const k of ['layers', 'mask', 'knockout', 'clip']) if (Array.isArray(l[k])) walk(l[k], fn); });
|
|
110
|
+
walk(c.layers, (l) => {
|
|
111
|
+
if (l.type === 'barcode') add({ rule: 'barcode', severity: 'error', layer: l.id, message: 'Cover art can\'t carry a barcode or QR code.' });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Images drawn bigger than their pixels (at the delivery size), or
|
|
115
|
+
// embedded at fewer pixels than they're drawn at.
|
|
116
|
+
const delivery = (options.size ?? 3000) / Math.max(c.width, c.height);
|
|
117
|
+
for (const im of drawn.filter((d) => d.kind === 'image' && d.source)) {
|
|
118
|
+
const bw = im.drawn.width * delivery;
|
|
119
|
+
const bh = im.drawn.height * delivery;
|
|
120
|
+
// Output pixels per source pixel: contain fits the whole image, cover fills the box.
|
|
121
|
+
const grow = im.fit === 'contain' ? Math.min(bw / im.source.width, bh / im.source.height) : im.fit === 'stretch' ? Math.max(bw / im.source.width, bh / im.source.height) : Math.max(bw / im.source.width, bh / im.source.height);
|
|
122
|
+
if (grow > 1.05) add({ rule: 'upscaled', severity: 'warning', layer: im.id, message: `An image (${im.source.width}×${im.source.height}) is drawn ${grow.toFixed(1)}× its size at ${options.size ?? 3000}×${options.size ?? 3000}: it will look soft. Spotify asks for art that isn't upscaled.` });
|
|
123
|
+
else if (im.embedded && Math.max(im.embedded.width / im.drawn.width, im.embedded.height / im.drawn.height) < 0.95) add({ rule: 'upscaled', severity: 'warning', layer: im.id, message: `An image is embedded at ${im.embedded.width}×${im.embedded.height} but drawn at ${Math.round(im.drawn.width)}×${Math.round(im.drawn.height)}: it was downsampled and will look soft.` });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { ok: !issues.some((i) => i.severity === 'error'), target, issues };
|
|
127
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The text rules `lint` applies: what distributors reject on cover art.
|
|
3
|
+
*
|
|
4
|
+
* @module lint/rules
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Text rules: id, severity, pattern, what's wrong. */
|
|
8
|
+
export const RULES = /** @type {[string, 'error' | 'warning', RegExp, string][]} */ ([
|
|
9
|
+
['url', 'error', /\b(?:https?:\/\/|www\.)\S+|\b[a-z0-9][a-z0-9-]*\.(?:com|net|org|io|co|uk|fm|app|dev|me|tv|music|shop|store|info|biz|xyz|fair|art|band|live|link|studio|club|gg|ly|to|de|fr|nl)\b/i, 'a website address'],
|
|
10
|
+
['handle', 'error', /(?:^|[\s(])@[A-Za-z0-9_.]{2,}/, 'a social media handle'],
|
|
11
|
+
['contact', 'error', /[\w.+-]+@[\w-]+\.[\w.]+|(?:\+\d{1,3}[ .-]?)?(?:\(\d{2,5}\)[ .-]?|\d{2,5}[ .-])\d{3,4}[ .-]?\d{3,4}\b/, 'contact details (email or phone)'],
|
|
12
|
+
['price', 'error', /[$£€¥]\s?\d|\b\d+(?:[.,]\d+)?\s?(?:usd|eur|gbp)\b|\bfree(?:\s+(?:download|entry|in|stream))?\b|\bbuy now\b|\bon sale\b/i, 'a price or a sales line'],
|
|
13
|
+
['promo', 'error', /\b(?:out now|coming soon|new (?:single|album|ep|music)|available (?:now|everywhere)|pre-?(?:save|order)|stream (?:it )?now|listen now)\b/i, 'promotional wording'],
|
|
14
|
+
// Brand names that are only ever the platform; "Tidal" is also a word, so it counts with a cue ("on Tidal", "stream Tidal") or on its own.
|
|
15
|
+
['platform', 'error', /\b(?:spotify|apple music|itunes|youtube|bandcamp|soundcloud|deezer|amazon music|tiktok|instagram)\b|\b(?:on|via|stream(?:ing)?(?:\s+on)?|listen(?:\s+on)?|available\s+on|find\s+us\s+on|follow\s+(?:us\s+)?on)\s+tidal\b|^\s*tidal\s*$/im, 'a store or platform name'],
|
|
16
|
+
// Months and short day names count next to a number ("Sat 14", "14 March"); a full day name counts alone; "Second Sun" doesn't.
|
|
17
|
+
['date', 'warning', /\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\.?\s+\d{1,2}(?:st|nd|rd|th)?\b|\b\d{1,2}(?:st|nd|rd|th)?\s+(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\b|\b\d{1,2}[./-]\d{1,2}[./-]\d{2,4}\b|\b(?:mon|tues?|wed|thu(?:rs?)?|fri|sat|sun)\.?,?\s+\d{1,2}\b|\b\d{1,2}(?:st|nd|rd|th)?,?\s+(?:mon|tues?|wed|thu(?:rs?)?|fri|sat|sun)\b|\b(?:mon|tues|wednes|thurs|fri|satur|sun)day\b|\b(?:today|tonight|this (?:week|weekend|friday))\b/i, 'a date or time frame'],
|
|
18
|
+
['physical', 'warning', /\b(?:cd|vinyl|cassette|lp|7")\b/i, 'a physical format'],
|
|
19
|
+
]);
|
package/src/node.d.ts
ADDED
package/src/node.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@arraypress/covers/node`: the same API, with Node's modules installed
|
|
3
|
+
* up front. Needed only on Node older than 20.16 (no
|
|
4
|
+
* `process.getBuiltinModule`), or in a bundle where you want the file
|
|
5
|
+
* system wired in explicitly.
|
|
6
|
+
*
|
|
7
|
+
* @module @arraypress/covers/node
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as fs from 'node:fs';
|
|
11
|
+
import * as fsp from 'node:fs/promises';
|
|
12
|
+
import * as zlib from 'node:zlib';
|
|
13
|
+
import * as path from 'node:path';
|
|
14
|
+
import * as module from 'node:module';
|
|
15
|
+
import * as url from 'node:url';
|
|
16
|
+
import { installNode } from './platform/index.js';
|
|
17
|
+
|
|
18
|
+
installNode({ fs, fsp, zlib, path, module, url });
|
|
19
|
+
|
|
20
|
+
export * from './index.js';
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Byte helpers without Buffer.
|
|
2
|
+
|
|
3
|
+
/** Bytes as a Uint8Array view. */
|
|
4
|
+
export function u8(v: Uint8Array | ArrayBuffer | ArrayBufferView): Uint8Array;
|
|
5
|
+
/** Whether a value is bytes. */
|
|
6
|
+
export function isBytes(v: unknown): v is Uint8Array | ArrayBuffer;
|
|
7
|
+
/** Base64-encode bytes. */
|
|
8
|
+
export function toBase64(bytes: Uint8Array | ArrayBuffer): string;
|
|
9
|
+
/** Decode base64. */
|
|
10
|
+
export function fromBase64(s: string): Uint8Array;
|
|
11
|
+
/** Each byte as one character. */
|
|
12
|
+
export function latin1(b: Uint8Array): string;
|
|
13
|
+
/** Decode UTF-8. */
|
|
14
|
+
export function utf8(b: Uint8Array | ArrayBuffer): string;
|
|
15
|
+
/** Encode UTF-8. */
|
|
16
|
+
export function encodeUtf8(s: string): Uint8Array;
|
|
17
|
+
/** Join byte arrays. */
|
|
18
|
+
export function concat(parts: Uint8Array[]): Uint8Array;
|
|
19
|
+
/** A `data:` URI's bytes, or undefined when it isn't one. */
|
|
20
|
+
export function fromDataUri(uri: string): Uint8Array | undefined;
|
|
21
|
+
/** A hex digest with Web Crypto. */
|
|
22
|
+
export function hexDigest(algo: 'SHA-1' | 'SHA-256', data: Uint8Array | string): Promise<string>;
|